3、Dockerfile安全实践:减少层数、不存储敏感信息、使用非root用户、多阶段构建
聊到 Dockerfile,我脑子里第一个蹦出来的词就是「安全债」。很多团队把 Dockerfile 当成一个简单的打包脚本,写完能跑就行。但说实话,这里面埋的坑,一个比一个深。
我自己就吃过亏。有一次线上容器被入侵,查了半天,发现攻击者是通过镜像里残留的私钥拿到了服务器权限。嗯,从那以后,我对 Dockerfile 的安全要求就变得特别苛刻。
今天咱们就聊聊四个核心实践:减少层数、不存敏感信息、用非 root 用户、多阶段构建。这四点做好了,你的镜像安全性能上一个台阶。
3.1 减少层数:少一层,少一分风险
Docker 镜像是由一层层文件系统叠加起来的。每一条 RUN、COPY、ADD 指令,都会生成一个新层。层数多了有什么问题?
- 镜像体积变大:每层都会保留历史数据,即使后面删了文件,上一层还在
- 攻击面增加:每一层都可能包含敏感信息或漏洞
- 构建变慢:层数多,构建和推送都更耗时
怎么减少?我个人的习惯是:能合并的指令就合并。
❌ 不推荐的做法:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y vim
RUN rm -rf /var/lib/apt/lists/*
✅ 推荐的做法:
RUN apt-get update && \
apt-get install -y curl vim && \
rm -rf /var/lib/apt/lists/*
你看,把 update、install、clean 放在一条 RUN 里,既减少了层数,又避免了缓存污染。这里有个小技巧:把清理操作放在同一条指令里,这样不会产生额外的层来保存那些没用的缓存文件。
💡 我的经验: 一个生产环境的镜像,层数控制在 20 层以内比较理想。超过 30 层,就该考虑优化了。
3.2 不存储敏感信息:别把密码写进镜像
这个问题我见过太多次了。有人把数据库密码、API 密钥、SSH 私钥直接写在 Dockerfile 里,或者 COPY 进镜像。你想想看,镜像一旦被推送到仓库,这些信息就永远留在历史层里了。即使你后面删了文件,只要有人能拉取镜像,就能用 docker history 看到。
我曾经帮一个客户做安全审计,发现他们的镜像里居然有一个 config.json,里面写着生产环境的数据库连接串。我问他们为什么这么做,回答是「方便测试」。嗯,这个「方便」的代价太大了。
⚠️ 绝对不要做的事:
- 在 Dockerfile 里用 ENV 设置密码
- 用 COPY 把包含密钥的配置文件加进去
- 在 RUN 指令里直接写密码(比如
curl -u user:password)
那正确的做法是什么?
- 使用构建参数(ARG):构建时传入,但注意 ARG 的值也会留在镜像历史里
- 使用 Docker Secrets:Swarm 模式下用 secrets 管理敏感信息
- 运行时注入环境变量:通过
-e或--env-file传入 - 挂载配置文件:用 volume 把配置文件挂载进去
✅ 安全做法示例:
# 构建时传入版本号,不传密码
ARG APP_VERSION
RUN echo "Building version $APP_VERSION"
# 运行时注入环境变量
# docker run -e DB_PASSWORD=xxx myimage
3.3 使用非 root 用户:别让容器拥有「超级权限」
默认情况下,容器里的进程是以 root 身份运行的。这意味着什么?如果攻击者通过应用漏洞进入了容器,他就能以 root 权限执行任何操作——安装软件、修改系统文件、甚至逃逸到宿主机。
我见过一个案例:某公司的容器被入侵,攻击者利用 root 权限在容器里装了挖矿程序,还通过挂载的 Docker socket 控制了宿主机。整个集群差点瘫痪。
解决方案很简单:在 Dockerfile 里创建一个普通用户,然后用这个用户运行应用。
✅ 推荐做法:
FROM node:18-alpine
# 创建非 root 用户
RUN addgroup -S appgroup && \
adduser -S appuser -G appgroup
# 设置工作目录并修改权限
WORKDIR /app
COPY --chown=appuser:appgroup . .
# 切换到非 root 用户
USER appuser
# 运行应用
CMD ["node", "app.js"]
这里要注意几点:
- 用户 ID 不要用 0:0 是 root,即使用户名不是 root,UID 为 0 也等于 root
- 权限要最小化:只给用户需要的目录和文件权限
- 端口绑定:非 root 用户不能绑定 1024 以下的端口,记得用 1024 以上的端口
💡 避坑指南: 我曾经遇到一个情况,应用需要写日志到 /var/log,但非 root 用户没有写权限。解决办法是创建一个专门的日志目录,并赋予 appuser 写权限。
3.4 多阶段构建:又小又安全
多阶段构建是我最喜欢的功能之一。它允许你在一个 Dockerfile 里使用多个 FROM 指令,每个 FROM 都是一个独立的构建阶段。你可以把编译工具、依赖包放在第一个阶段,然后把编译好的产物复制到第二个阶段。
这样做的好处很明显:
- 镜像体积小:最终镜像只包含运行时的文件,没有编译工具和中间产物
- 攻击面小:少了那些不必要的工具和库,漏洞自然就少了
- 构建速度快:可以利用缓存,只重新编译有变化的部分
✅ 多阶段构建示例(Go 应用):
# 第一阶段:编译
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .
# 第二阶段:运行
FROM alpine:3.18
RUN addgroup -S appgroup && \
adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/myapp /usr/local/bin/myapp
USER appuser
CMD ["myapp"]
你看,最终镜像只有 alpine 基础镜像和编译好的二进制文件,连 Go 编译器都没有。攻击者就算进了容器,也找不到什么有用的工具。
⚠️ 注意事项:
- 第一阶段的基础镜像可以大一些,反正最后会被丢弃
- 第二阶段尽量用 alpine 或 distroless 这类精简镜像
- 复制文件时记得用
--chown设置正确的所有者
3.5 综合实践:一个安全的 Dockerfile 模板
说了这么多,咱们来一个完整的例子。这个 Dockerfile 融合了上面所有的实践:
# 第一阶段:构建
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# 第二阶段:运行
FROM node:18-alpine
RUN addgroup -S appgroup && \
adduser -S appuser -G appgroup && \
mkdir -p /app && \
chown appuser:appgroup /app
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /build/dist ./dist
COPY --from=builder --chown=appuser:appgroup /build/node_modules ./node_modules
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
这个模板里:
- 用了多阶段构建,最终镜像只有运行时文件
- 创建了非 root 用户 appuser
- 没有存储任何敏感信息
- 层数控制在合理范围内
- 还加了健康检查,方便编排工具监控
💡 最后说一句: Dockerfile 的安全不是一蹴而就的。我建议你每次构建完镜像后,用 docker scan 或 Trivy 扫描一下,看看有没有已知漏洞。安全这件事,多花点时间总没错。