Docker 多阶段构建与镜像瘦身实战:从构建加速到安全优化的完整方案
很多团队刚开始用 Docker 时,都会先追求“能跑起来”。结果过一阵子就发现几个很现实的问题:
- 镜像体积越来越大,拉取慢、发布慢
- Dockerfile 越写越长,维护困难
- 构建阶段混入了编译工具、调试工具,生产镜像不够干净
- 容器默认 root 运行,安全风险偏高
- CI/CD 一跑就是十几分钟,开发体验也差
我自己第一次接手一个历史项目时,就见过一个 1GB+ 的 Java 镜像,构建时间和传输时间加起来,比代码真正编译的时间还长。后来回过头梳理,问题并不神秘:构建环境、运行环境、依赖缓存、安全策略,全都混在一起了。
这篇文章就从这个角度出发,带你系统做一遍:用 Docker 多阶段构建把镜像做小、把构建做快、把运行时做安全。
背景与问题
先看几个典型的“坏味道” Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个写法很常见,但问题不少:
- 源码、依赖、构建产物全在同一层
- 修改任意文件,缓存命中率都变差
- 构建依赖和运行依赖混在一起
- 比如
gcc、make、python这些只在编译时需要,却进了生产镜像
- 比如
- 上下文过大
.git、日志、node_modules、测试文件都可能被COPY . .带进去
- 安全基线弱
- 默认 root 用户、镜像中包过多、攻击面更大
很多人以为“镜像瘦身”只是为了省磁盘空间,其实它影响的是一整条链路:
- 本地开发拉镜像速度
- CI 构建耗时
- 仓库存储成本
- 发布速度
- 容器启动时间
- 安全面
所以它不是“可做可不做”的优化,而是稍微上规模就必须面对的工程问题。
前置知识与环境准备
本文默认你已经了解这些基础概念:
- Dockerfile 基本语法
- 镜像与容器的区别
docker build/docker run基本用法- 至少知道 Node.js 或 Go 项目的基本构建方式
建议环境:
- Docker 20.10+
- 开启 BuildKit
- 一台能联网拉基础镜像的开发机
建议先启用 BuildKit:
export DOCKER_BUILDKIT=1
或者直接使用:
DOCKER_BUILDKIT=1 docker build -t demo-app .
BuildKit 会显著改善构建缓存与高级特性体验,后面提到的缓存挂载也依赖它。
核心原理
1. 什么是多阶段构建
多阶段构建的核心思想很简单:
在一个 Dockerfile 里拆分“构建阶段”和“运行阶段”,最后只把真正需要的产物带进最终镜像。
比如:
- 第一阶段:安装依赖、编译代码
- 第二阶段:只复制编译好的二进制文件或静态资源,作为运行镜像
这样,构建工具链不会进入最终镜像。
2. 为什么它能瘦身
因为最终镜像不再包含这些内容:
- 编译器
- 构建缓存
- 源代码
- 单元测试文件
- 开发依赖
- 包管理器临时文件
对比一下流程:
flowchart LR
A[源码目录] --> B[传统单阶段构建]
B --> C[依赖安装]
C --> D[编译工具保留在镜像]
D --> E[最终镜像体积大]
A --> F[多阶段构建]
F --> G[构建阶段安装依赖并编译]
G --> H[仅复制产物到运行阶段]
H --> I[最终镜像更小更安全]
3. Docker 缓存为什么会失效
Docker 构建是按层执行的。某一层输入发生变化,该层之后的缓存通常都不能复用。
最典型的例子:
COPY . .
RUN npm install
只要项目里任意一个文件变了,COPY . . 层就会变化,后面的 npm install 就得重跑。
更好的顺序应该是:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
这样如果你只是改了业务代码,而 package.json 没变,那么依赖安装层就还能复用。
4. 安全优化为什么和瘦身是同一件事
镜像越大,意味着:
- 软件包越多
- 潜在漏洞面越大
- 攻击者可利用工具越多
所以“瘦身”本质上也是“减少攻击面”。
可以理解成:
flowchart TD
A[镜像瘦身] --> B[减少无用文件]
A --> C[减少系统包]
A --> D[减少工具链残留]
B --> E[更快拉取和启动]
C --> F[更少漏洞暴露]
D --> G[更低运行时风险]
实战代码(可运行)
下面我用一个 Node.js 前端静态站点 做示例,因为它特别适合展示多阶段构建的价值。
目录结构如下:
demo-web/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── server.js
└── public/
└── index.html
示例应用代码
package.json
{
"name": "demo-web",
"version": "1.0.0",
"description": "demo web app for docker multi-stage build",
"main": "server.js",
"scripts": {
"build": "mkdir -p dist && cp -r public/* dist/",
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
server.js
const express = require("express");
const path = require("path");
const app = express();
const port = process.env.PORT || 3000;
app.use(express.static(path.join(__dirname, "dist")));
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(port, () => {
console.log(`server started on port ${port}`);
});
public/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Docker Multi-stage Demo</title>
</head>
<body>
<h1>Hello Docker Multi-stage Build</h1>
<p>镜像瘦身成功。</p>
</body>
</html>
先写一个“普通版” Dockerfile
先不要急着优化,我们先写一个很多项目里都见过的版本。
Dockerfile.bad
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
构建:
docker build -f Dockerfile.bad -t demo-web:bad .
运行:
docker run --rm -p 3000:3000 demo-web:bad
验证:
curl http://localhost:3000/health
这个版本能跑,但不够好。
改造成多阶段构建
下面是推荐版本。
Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY server.js ./server.js
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
这个 Dockerfile 做了几件关键的事:
builder阶段负责构建静态资源runtime阶段只保留运行所需内容- 用
npm ci代替npm install- 构建更稳定,适合 CI
- 通过
--mount=type=cache利用 BuildKit 做包缓存 - 切换到非 root 用户运行
构建镜像
docker build -t demo-web:multi .
启动容器
docker run --rm -p 3000:3000 demo-web:multi
验证服务
curl http://localhost:3000/
curl http://localhost:3000/health
用 .dockerignore 继续瘦身
很多人做了多阶段构建,但忘了 .dockerignore,结果构建上下文还是很大。这个问题在 monorepo 或有大量日志文件时尤其明显。
.dockerignore
node_modules
npm-debug.log
Dockerfile.bad
.git
.gitignore
README.md
coverage
dist
*.log
它的价值在于:
- 减少传给 Docker daemon 的上下文体积
- 避免本地
node_modules污染镜像 - 提升构建速度
- 降低误拷贝风险
这一点我建议养成习惯:写 Dockerfile 的同时就写 .dockerignore。
逐步验证清单
教程类文章里,我很建议你按下面这套最小闭环去验证,不然很容易“以为优化了,其实没有”。
1. 看镜像大小
docker images | grep demo-web
对比 demo-web:bad 和 demo-web:multi 的体积。
2. 看镜像层历史
docker history demo-web:multi
你能明显看到最终镜像层更干净,没有冗余的构建步骤残留。
3. 验证运行用户
docker run --rm demo-web:multi id
如果输出不是 root,说明非 root 运行生效了。
4. 验证功能正常
curl http://localhost:3000/health
5. 验证缓存命中
第一次构建:
docker build -t demo-web:multi .
第二次只修改 public/index.html 后再构建:
docker build -t demo-web:multi .
如果 Dockerfile 分层合理,依赖安装步骤应尽量复用缓存。
进一步实战:Go 项目的多阶段构建
如果你做的是 Go 服务,多阶段构建会更“爽”,因为最终镜像往往可以很小。
示例 main.go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
http.ListenAndServe(":8080", nil)
}
Go 版本 Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app main.go
FROM alpine:3.19
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /src/app /app/app
USER appuser
EXPOSE 8080
CMD ["/app/app"]
构建与运行:
docker build -t demo-go .
docker run --rm -p 8080:8080 demo-go
curl http://localhost:8080/health
Go 的好处是编译产物通常是单文件,最终镜像可以非常克制。
常见坑与排查
这一部分我尽量写得实一点,都是平时比较容易踩中的坑。
坑 1:多阶段构建了,但镜像还是很大
常见原因:
- 最终阶段仍然使用了过大的基础镜像
- 把整个项目目录又
COPY . .到 runtime 阶段了 - 安装了不必要的系统包
- 没有清理包管理缓存
错误示例:
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM node:18
WORKDIR /app
COPY . .
COPY --from=builder /app/dist ./dist
CMD ["node", "server.js"]
这里 runtime 阶段的 COPY . .,基本把前面的努力抵消了。
排查建议:
docker history your-image
docker image inspect your-image
重点看哪些层突然变大。
坑 2:缓存不生效,构建总是很慢
常见原因:
- 把
COPY . .放在依赖安装之前 - lock 文件经常变化
- 构建上下文包含无关文件
- 没启用 BuildKit
推荐顺序:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
排查思路:
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 构建缓存
Dev->>Docker: docker build
Docker->>Cache: 检查 package*.json 层
alt 未变化
Cache-->>Docker: 命中依赖安装缓存
else 已变化
Cache-->>Docker: 重新执行 npm ci
end
Docker->>Cache: 检查源码复制层
Docker-->>Dev: 输出构建结果
坑 3:alpine 不是万能药
很多文章会直接说“想瘦身就用 alpine”,但实际项目里不能机械套用。
可能遇到的问题:
- 某些 Node 原生模块需要特定 libc 环境
- Python/Java/Node 某些依赖在 alpine 上编译更麻烦
- 调试体验较差
建议:
- 优先做多阶段和分层优化
- 再考虑是否切换到
alpine - 如果业务依赖复杂,
debian-slim往往更稳
也就是说,先把结构优化好,再谈基础镜像极限压缩。
坑 4:容器切成非 root 后程序跑不起来
这是很常见的,尤其在这些场景:
- 应用需要写临时目录
- 复制进来的文件属主不对
- 端口绑定权限问题
- 日志目录没有写权限
例如:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
如果你的应用还想写 /app/logs,那就要提前处理权限。
修正方式:
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app
USER appuser
坑 5:生产镜像里仍然有 secrets
有些人会在 Dockerfile 里写:
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
这样如果处理不当,凭证可能出现在镜像层历史里。
更安全的方式是使用 BuildKit secret mount,而不是把 secrets 写死进层里。比如:
RUN --mount=type=secret,id=npm_token \
sh -c 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && npm ci'
构建时:
docker build --secret id=npm_token,src=.npm_token -t demo-web:secure .
安全/性能最佳实践
这一部分给你一套比较实用的落地清单,不求“最极致”,但足够应对大多数团队场景。
1. 优先使用明确版本的基础镜像
不要长期依赖漂移标签:
FROM node:18-alpine
比起 latest,明确版本更可控。生产环境建议进一步固定到更具体的版本标签。
2. 运行时镜像尽量最小化
原则是:
- 只放运行所需文件
- 不要把源码、测试、文档、构建缓存带进去
- 不要安装调试工具,除非业务明确要求
适合的思路:
- Node:runtime 只保留生产依赖 + 构建产物
- Go:runtime 只保留二进制
- Java:runtime 只保留 jar 和 JRE
3. 使用非 root 用户运行
这是容器安全的基础动作,不应该省。
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
边界条件:
- 如果容器必须监听 1024 以下端口,可能需要额外能力配置
- 如果需要写文件,提前处理目录权限
4. 配合健康检查与最小权限
运行时也建议加上健康检查,例如:
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
如果基础镜像没有 wget 或 curl,你要评估是否值得为此引入额外包。对于极简镜像,有时更适合把健康探针放到编排层,比如 Kubernetes 的 liveness/readiness probe。
5. 定期扫描漏洞
镜像瘦了不代表没漏洞,仍然需要做扫描。
可以使用:
- Docker Scout
- Trivy
- Grype
例如用 Trivy:
trivy image demo-web:multi
建议在 CI 中加入扫描门禁。
6. 合理利用缓存,而不是盲目追求“单层合并”
有些人会过度追求少层,把所有命令塞进一个 RUN。这并不总是对。
更实用的原则是:
- 高频变动的内容放后面
- 低频变动的内容放前面
- 依赖安装与源码复制分离
- 让缓存命中逻辑符合团队实际开发模式
这比“层数少一点”更重要。
7. 在 CI/CD 中显式使用目标阶段
有时我们不仅想构建最终镜像,还想单独调试 builder 阶段。
例如:
docker build --target builder -t demo-web:builder .
这对排查编译问题非常有帮助,尤其是在复杂前端或原生依赖项目里。
8. 控制构建上下文
如果你的仓库很大,构建上下文本身就可能成为性能瓶颈。
建议:
- 善用
.dockerignore - 在 monorepo 中缩小构建目录
- 避免无脑
COPY . .
一个可复用的优化模板
如果你想把这套方法迁移到自己的项目,可以按下面这个模板检查。
flowchart TD
A[选择基础镜像] --> B[拆分 builder/runtime]
B --> C[先复制依赖描述文件]
C --> D[安装依赖并利用缓存]
D --> E[复制源码并构建]
E --> F[仅复制运行所需产物]
F --> G[切换非 root 用户]
G --> H[添加 .dockerignore]
H --> I[镜像扫描与体积验证]
对应的执行顺序建议是:
- 先拆多阶段
- 再优化 COPY 顺序
- 加
.dockerignore - 切非 root
- 引入缓存挂载
- 做镜像扫描
- 观察体积、构建时间、运行稳定性
方案取舍:什么时候不用“极致瘦身”
这里补一句很重要的话:不是所有项目都需要把镜像压到最小。
例如这些情况,你可以适当保守:
- 业务依赖复杂,切换
alpine成本过高 - 团队调试依赖 shell、curl、ca-certificates 等工具
- 构建时间比镜像体积更关键
- 运行环境本身已经有镜像分发加速
更合理的目标通常是:
- 明确构建阶段与运行阶段分离
- 体积明显下降
- 缓存可复用
- 安全基线达标
- 排障成本没有明显增加
换句话说,工程优化不是比赛,看的是整体收益,不是数字越小越好。
总结
把 Docker 用好,关键不在于记住多少条命令,而是建立一个清晰的判断框架:
- 构建和运行要分离
- 用多阶段构建,把工具链留在 builder
- 缓存要可复用
- 依赖描述文件先复制,源码后复制
- 上下文要收敛
.dockerignore必不可少
- 运行时要最小化
- 只保留必要产物和生产依赖
- 安全基线要补齐
- 非 root、漏洞扫描、凭证不落层
如果你准备马上动手,我建议按这个最小行动路径开始:
- 先把现有 Dockerfile 改成多阶段
- 再补
.dockerignore - 再检查
COPY顺序和缓存命中 - 最后加非 root 与漏洞扫描
这套顺序的好处是:改动可控、收益明显、风险也相对低。
很多时候,镜像优化不需要大改架构,只要把 Dockerfile 从“能跑”提升到“适合长期维护”,效果就已经很可观了。