在容器化项目中,镜像“能构建、能运行”只是起点,如何减少无关文件、降低运行时依赖并提升构建效率,才是生产环境真正关心的问题。Docker 多阶段构建可以把编译环境与运行环境拆开,只将最终产物交付到运行镜像中,从而让 Dockerfile 保持清晰,同时实现镜像精简与安全收敛。🚀
一、多阶段构建解决了什么问题
传统 Dockerfile 往往在同一个阶段完成依赖下载、源码编译、测试和应用启动。编译器、包管理器、源码、缓存及临时文件可能全部进入最终镜像,不仅增加镜像体积,还扩大了潜在攻击面。
多阶段构建允许在一个 Dockerfile 中使用多个 FROM 指令。每个 FROM 都会开启独立阶段,前面的阶段负责构建,最后一个阶段负责运行。通过 COPY --from,可以只复制二进制文件、JAR 包或前端静态资源,而不携带完整工具链。具体语法可参考 Docker 多阶段构建官方文档。
二、从单阶段改造成多阶段
以 Go 应用为例,构建阶段需要 Go 工具链,但运行阶段通常只需要编译后的程序。一个基础结构如下:
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app ./cmd/app
FROM alpine:3.21 AS runtime
WORKDIR /app
COPY --from=builder /out/app ./app
ENTRYPOINT ["./app"]
这里将第一个阶段命名为 builder,负责下载依赖和编译程序;第二个阶段只接收 /out/app。即使后续调整阶段顺序,使用阶段名称也比数字编号更容易维护。对于完全静态链接的程序,还可以评估使用 scratch;如果程序需要证书、时区数据或动态链接库,则应选择 Alpine、slim 或其他满足运行条件的基础镜像。
三、利用缓存缩短重复构建时间
镜像精简不等于构建一定更快,Dockerfile 指令顺序同样重要。Docker 会按层复用缓存,因此应先复制变化较少的依赖描述文件,完成依赖安装后,再复制频繁变化的业务源码。这样修改一行业务代码时,就不必重新下载全部依赖。⚡
例如 Node.js 项目可以先复制 package.json 和锁文件,执行依赖安装,再复制其余源码。Go 项目则先复制 go.mod 与 go.sum。对于启用 BuildKit 的环境,还可以使用缓存挂载保存包管理器缓存,避免每次构建都从零下载。不过,缓存目录只用于加速构建,不应被复制进最终运行阶段。
四、只复制真正需要的构建产物
多阶段构建最常见的误区,是在最终阶段执行 COPY --from=builder /app /app,结果把源码、测试报告、开发依赖和缓存全部带入运行镜像。更稳妥的做法是让构建命令统一输出到独立目录,例如 /out、/dist 或 /target,并精确复制该目录中的产物。
- Go、Rust、C/C++:复制编译后的二进制文件及必要动态库。
- Java:复制最终 JAR、WAR 或经过分层处理的运行文件。
- Node.js:复制 dist 目录和生产依赖,不携带测试工具与开发依赖。
- 前端项目:在 Node 阶段完成打包,再将静态资源复制到 Nginx 等运行镜像。
- Python:可在构建阶段生成 wheel 包,再在精简运行阶段安装。
五、配合 .dockerignore 缩小构建上下文
即使使用了多阶段构建,如果把整个项目目录无差别发送给 Docker,构建上下文仍可能包含 .git、node_modules、日志、测试结果、本地配置和编辑器文件。建议维护 .dockerignore,从源头排除不参与构建的内容。
.git
node_modules
coverage
*.log
.env
tests
docs
.idea
.vscode
排除规则需要结合项目实际情况设置,不能机械复制。例如构建流程依赖测试目录时,就不应提前忽略 tests。特别要注意,不要将密钥、令牌和生产环境配置复制进任何镜像层;即使后续删除,相关内容仍可能存在于历史层中。需要构建期凭据时,应使用 BuildKit 的 secret mount 等专用机制。
六、精简运行镜像时兼顾可用性
基础镜像并非越小越好。scratch 几乎不包含系统组件,攻击面较小,但排查问题和处理证书、DNS、时区时需要额外准备;Alpine 体积较小,但使用 musl libc,部分依赖可能存在兼容性问题;Debian 或 Ubuntu 的 slim 变体通常具有更广泛的兼容性。选择时应以应用的真实运行依赖为准,而不是单纯追求文件大小。
生产阶段还应创建并切换到非 root 用户,只保留必要目录权限。同时固定基础镜像版本,必要时使用镜像摘要,以提高构建可重复性。镜像发布前,可以执行漏洞扫描、启动测试和健康检查,验证精简过程中没有漏掉证书、共享库或配置文件。🔐
七、进一步拆分测试与调试阶段
多阶段构建不必只有 builder 和 runtime。可以增加 dependencies、test、debug 等阶段,并通过 docker build --target 阶段名 构建指定目标。开发环境可保留调试工具,持续集成阶段执行测试,生产阶段则只保留运行产物。这样能够复用同一份 Dockerfile,又避免把调试工具带进线上镜像。
- dependencies 阶段负责下载并缓存依赖。
- build 阶段负责编译或打包。
- test 阶段运行单元测试和基础检查。
- runtime 阶段仅包含应用及必要运行组件。
八、构建产物精简检查清单
- 是否明确区分构建环境与运行环境?
- 最终阶段是否只复制必要产物?
- 是否通过 .dockerignore 排除了无关文件?
- 依赖文件是否先于业务源码复制,以提高缓存命中率?
- 是否误将密钥、源码、测试报告或包管理器缓存写入镜像?
- 运行镜像是否满足证书、时区和动态库需求?
- 容器是否以非 root 用户运行?
- 是否完成启动验证、依赖检查和安全扫描?
总结
Docker 多阶段构建的核心不是简单增加几个 FROM,而是为构建、测试和运行划定清晰边界。实践中应围绕“最小构建上下文、稳定缓存、精确复制产物、合适的运行基础镜像和最小权限”持续优化。镜像精简也不应以牺牲兼容性和可维护性为代价,只有经过实际启动与安全验证的精简方案,才适合稳定交付到生产环境。✅