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 扫描一下,看看有没有已知漏洞。安全这件事,多花点时间总没错。