在容器化应用中,Dockerfile 不只是“能构建镜像”就够了,还要兼顾镜像体积、构建速度、安全性与可维护性。多阶段构建通过多个 FROM 指令划分构建环境与运行环境,只把最终需要的产物复制到运行阶段,是优化生产镜像最实用的方法之一。🚀
一、多阶段构建解决了什么问题
传统单阶段构建通常会把编译器、包管理器、源代码、测试工具和临时文件一起留在镜像中。它们虽然参与了构建,却不一定是应用运行所必需的,不仅增加镜像体积,也扩大了潜在攻击面。
多阶段构建允许每个 FROM 指令开启一个独立阶段。前面的阶段负责编译、测试或打包,最后一个阶段只接收可执行文件、静态资源及必要的运行时依赖。Docker 官方文档也建议利用 COPY --from 在不同阶段之间选择性复制构建产物,避免无关内容进入最终镜像。[1] Docker 多阶段构建文档 citeturn1search1
二、建立清晰的阶段职责
一个易于维护的 Dockerfile,应让每个阶段只承担一种主要职责。例如,可以划分为 dependencies、test、builder 和 runtime:依赖阶段负责安装依赖,测试阶段运行检查,构建阶段生成发布产物,运行阶段启动应用。🧩
不建议使用数字位置引用阶段,例如 COPY --from=0。更稳妥的方式是通过 AS 为阶段命名,再使用 COPY --from=builder。这样即使后续调整阶段顺序,复制关系也不会因编号变化而失效。
FROM golang:1.26 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM scratch AS runtime
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
以上结构中,Go 编译工具链只存在于 builder 阶段,最终镜像仅保留应用程序。需要注意的是,scratch 没有 Shell、证书和常见系统文件。如果应用需要访问 HTTPS 服务、处理时区或依赖动态链接库,就应显式复制相关文件,或者选择合适的精简运行时镜像。
三、充分利用构建缓存
Docker 会根据指令和输入内容判断缓存是否可复用,因此 Dockerfile 的指令顺序会直接影响构建效率。经常变化的源代码应尽量靠后复制,而依赖清单应优先复制并安装依赖。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
如果先执行 COPY . .,任何源文件变化都可能导致后续依赖安装层失效。将依赖清单独立复制后,只要锁定文件没有变化,依赖层通常就可以继续复用。📦 对 Maven、Gradle、Go 和 Python 项目,也可以采用相同思路,先复制依赖描述文件,再复制业务源码。
同时应配置 .dockerignore,排除 .git、测试报告、本地依赖目录、日志、编辑器配置和临时文件。这样可以减少构建上下文,避免无关文件触发缓存失效,更重要的是降低敏感文件被误复制进镜像的风险。
四、只向最终阶段复制必要内容
多阶段构建的关键不是“使用了多个 FROM”,而是严格控制最终阶段的输入。不要直接从 builder 复制整个工作目录,应明确复制可执行文件、编译后的静态资源、生产依赖和必要配置。
- 编译型语言:复制二进制文件及运行所需证书、动态库。
- Node.js 项目:复制构建产物,并仅安装或复制生产依赖。
- Java 项目:复制构建生成的 JAR 或经过分层处理的运行内容。
- 前端项目:把 dist、build 等静态产物复制到 Nginx 或其他静态服务器镜像。
还要警惕“先复制秘密文件,再在后续层删除”的做法。删除操作不会自动抹去历史层中的数据。访问私有仓库所需的密钥或令牌,应通过 BuildKit 的 secret 或 SSH 挂载能力在构建时临时使用,避免进入镜像层。🔐
五、运行阶段坚持最小权限
最终镜像应选择与应用需求匹配的基础镜像,而不是盲目追求最小。scratch 适合依赖明确的静态二进制;精简发行版或 distroless 镜像适合需要基础运行环境的应用。镜像越精简,通常意味着调试工具越少,因此应结合可观测性和故障排查方式综合选择。
应用应尽可能以非 root 用户运行。可以在运行阶段创建专用用户,设置文件所有权,然后通过 USER 切换身份。复制文件时使用 COPY --chown,可以减少额外的权限调整层。基础镜像还应使用明确版本,并在团队流程中定期更新和扫描。
六、用目标阶段支持开发与测试
多阶段构建不仅用于缩小生产镜像,还可以服务不同环境。开发阶段可包含调试工具,测试阶段可执行单元测试,生产阶段只保留运行内容。构建时使用 docker build --target test,可以停在指定阶段,从而在持续集成中复用同一份 Dockerfile。Docker 官方说明支持通过 --target 构建指定阶段。官方说明 citeturn1search1
- 先构建测试阶段,确保代码和依赖通过验证。
- 再构建生产阶段,并执行镜像安全扫描。
- 启动容器进行健康检查和基本功能验证。
- 最后使用内容摘要或不可变标签发布镜像。
七、常见误区与检查清单
常见误区包括:builder 与 runtime 使用相同的大型镜像、把源码整体复制进生产阶段、使用 latest 导致构建不可复现、在镜像中保存凭据,以及为了体积而选择缺少必要证书或动态库的基础镜像。⚠️
提交 Dockerfile 前,可以检查以下事项:
- 阶段是否有清晰名称和单一职责。
- 依赖文件是否早于业务源码复制。
- .dockerignore 是否排除了无关与敏感内容。
- 最终阶段是否只包含运行必需文件。
- 容器是否使用非 root 用户运行。
- 基础镜像版本是否明确且可维护。
- 测试阶段与生产阶段是否能独立构建。
总结
高质量的多阶段 Dockerfile,本质上是在管理构建边界:构建工具留在构建阶段,测试任务留在测试阶段,生产镜像只承载运行应用所需的最小集合。通过阶段命名、缓存优化、精确复制、秘密挂载、非 root 运行和目标阶段构建,可以同时改善镜像体积、安全性、构建效率与维护体验。✅ 最佳实践不是追求绝对最小,而是在功能完整、可复现和易运维的前提下,删除所有不必要的内容。