Docker 多阶段构建与镜像瘦身实战:从构建缓存到安全加固的完整优化方案
Docker 用久了,很多团队都会碰到一个很现实的问题:镜像能跑,但越来越大;构建能过,但越来越慢;上线能发,但安全风险也越来越高。
我第一次系统整理这类问题,是在一个 Node.js 服务里。最开始一个镜像只有两三百 MB,后来加了编译依赖、调试工具、私有包、构建脚本,最后镜像接近 1GB。CI 构建慢、推送慢、拉取慢,容器启动也不轻快。更麻烦的是,镜像里还混进了 .git、测试文件、包管理缓存,甚至一度把 token 打进了层里。
这篇文章就从**“为什么镜像会越来越臃肿”**开始,带你一步一步做一次完整优化:
- 用 多阶段构建拆分“构建环境”和“运行环境”
- 用 构建缓存减少重复安装依赖
- 用
.dockerignore和基础镜像选择做镜像瘦身 - 用非 root 用户、最小权限和敏感信息隔离做安全加固
- 最后给出一套可运行的实战代码和排查思路
这不是概念扫盲,而是偏落地的一套方案。
前置知识与环境准备
建议你已经具备以下基础:
- 知道 Dockerfile 的基本指令:
FROM、COPY、RUN、CMD - 知道镜像、容器、层(layer)的基本关系
- 本机已安装:
- Docker 24+
- 推荐启用 BuildKit
启用 BuildKit(临时方式):
export DOCKER_BUILDKIT=1
或者直接在构建命令前使用:
DOCKER_BUILDKIT=1 docker build -t demo-app:latest .
背景与问题
先看一个很常见、但问题很多的 Dockerfile。
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题几乎是“新手经典全家桶”:
-
基础镜像太大
node:18通常比 slim 或 alpine 版本大很多。
-
缓存利用差
COPY . .放得太早,任何代码变动都会让npm install失效,依赖重新安装。
-
构建工具混入运行镜像
- TypeScript 编译、打包工具、开发依赖都被带到生产镜像里。
-
上下文过大
- 如果没有
.dockerignore,node_modules、日志、测试文件、.git都可能被打包进构建上下文。
- 如果没有
-
安全性差
- 默认 root 运行,攻击面大。
- 如果在构建时写入 token,可能永久留在镜像层历史里。
你会看到几个直接后果:
- CI/CD 构建越来越慢
- 镜像仓库存储成本升高
- 生产环境拉镜像慢
- 安全扫描漏洞越来越多
- 线上容器调试复杂,责任边界不清晰
核心原理
要把这个问题解决干净,先把几个核心原理捋顺。
1. Docker 镜像是分层的
每条会生成层的指令,都会形成一层。只要某一层内容变化,后续层的缓存都会失效。
flowchart TD
A[FROM node:18] --> B[WORKDIR /app]
B --> C[COPY package*.json]
C --> D[RUN npm ci]
D --> E[COPY src/]
E --> F[RUN npm run build]
F --> G[CMD node dist/index.js]
这里最关键的点是:
COPY package*.json和RUN npm ci要尽量放前面- 业务代码
COPY src/放后面 - 这样代码改动时,不必每次都重新安装依赖
2. 多阶段构建的本质:把“构建”与“运行”分开
构建阶段需要编译器、构建工具、开发依赖; 运行阶段通常只需要编译产物和少量运行时依赖。
flowchart LR
A[builder 阶段<br/>安装依赖/编译打包] --> B[生成 dist 或二进制]
B --> C[runner 阶段<br/>仅复制运行所需文件]
C --> D[最终生产镜像]
多阶段构建的价值很直接:
- 最终镜像更小
- 构建工具不进入生产环境
- 攻击面更小
- 镜像职责更清晰
3. 构建缓存优化,不只是“顺序对了”这么简单
BuildKit 支持更细粒度的缓存,比如缓存 npm/pip/apt 下载目录,避免每次重新拉包。
也就是说,优化有两层:
- Docker 层缓存:通过 Dockerfile 顺序优化
- 包管理器缓存:通过 BuildKit
--mount=type=cache
这两者叠加,效果会非常明显。
4. 镜像瘦身不只是为了“省空间”
很多人第一次优化镜像,只盯着体积。但实际上镜像瘦身还带来:
- 更快的构建、推送、拉取
- 更低的漏洞暴露面
- 更少的维护复杂度
- 更稳定的发布流程
所以它本质上是性能优化 + 安全治理 + 工程规范化。
环境准备:一个可运行的 Node.js 示例项目
下面我用一个最常见的 TypeScript Node.js 服务做演示。目录如下:
demo-app/
├── src/
│ └── index.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
src/index.ts
import express from "express";
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (_req, res) => {
res.json({
message: "hello docker",
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server running at http://localhost:${port}`);
});
package.json
{
"name": "demo-app",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.5.4"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
实战代码:一步步把 Dockerfile 优化到可上线
第 1 步:先写一个“能跑但不够好”的版本
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
构建并运行:
docker build -t demo-app:bad .
docker run --rm -p 3000:3000 demo-app:bad
访问:
curl http://localhost:3000
虽然能跑,但这只是起点。
第 2 步:引入 .dockerignore,先解决“无效文件进镜像”
这是最容易被忽略的一步,但收益很高。
.dockerignore
node_modules
npm-debug.log
dist
.git
.gitignore
Dockerfile
README.md
coverage
*.local
.env
这一步的作用:
- 减少构建上下文大小
- 避免宿主机
node_modules污染容器 - 避免把不必要文件送进 Docker daemon
- 避免敏感文件误入镜像
我见过不少项目镜像大,不是因为应用本身大,而是因为 .git 目录和测试产物全被送进去了。
第 3 步:优化层顺序,最大化缓存命中
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这里已经比前面进步很多了:
- 依赖安装单独一层
- 源码变动时,
npm ci层通常还能复用 - 基础镜像换成
node:20-slim
不过它仍然有问题:开发依赖和构建工具还在生产镜像里。
第 4 步:改成多阶段构建
这是整篇文章最核心的一步。
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本做了什么?
-
builder阶段:- 安装完整依赖
- 执行编译
- 编译完成后移除开发依赖
-
runner阶段:- 只复制运行所需内容
- 不带源码、TypeScript、编译中间文件
这就是多阶段构建最经典的落地方式。
第 5 步:使用 BuildKit 缓存包管理器目录
如果 CI 经常全新构建,这一步会特别值。
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
构建命令:
DOCKER_BUILDKIT=1 docker build -t demo-app:cache .
为什么这一步有效?
- 即使某些层失效,npm 下载缓存还在
- 对网络依赖重的 CI 环境提升明显
- 对 monorepo、依赖多的项目帮助更大
第 6 步:安全加固,避免 root 运行
生产镜像建议不要默认 root。
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
RUN useradd -r -u 1001 -g root appuser && \
chown -R appuser:root /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
这样做的意义:
- 即使应用被利用,默认权限也更低
- 减少对宿主环境和挂载卷的潜在风险
第 7 步:最终推荐版本
下面给一个更接近生产可用的完整版本。
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
RUN useradd -r -u 1001 -g root appuser && \
chown -R appuser:root /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
构建与运行:
DOCKER_BUILDKIT=1 docker build -t demo-app:prod .
docker run --rm -p 3000:3000 demo-app:prod
验证:
curl http://localhost:3000
逐步验证清单
做优化时,我很建议一边改,一边验证,而不是一口气重写 Dockerfile。
1. 看镜像体积
docker images | grep demo-app
2. 看历史层
docker history demo-app:prod
重点观察:
- 有没有异常大的层
- 是否把源码、缓存、构建工具带进最终镜像
3. 进入容器确认文件
docker run --rm -it demo-app:prod sh
检查:
ls -la
ls -la dist
ls -la src
如果最终运行镜像里还有 src/、测试目录、构建缓存,那通常说明复制策略有问题。
4. 检查运行用户
id
确认不是 root。
5. 验证缓存是否生效
先构建一次,再只改一行业务代码,再次构建:
DOCKER_BUILDKIT=1 docker build -t demo-app:prod .
如果你看到 npm ci 被缓存复用,说明层顺序设计正确。
构建流程全景图
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker BuildKit
participant Builder as builder 阶段
participant Runner as runner 阶段
Dev->>Docker: docker build
Docker->>Builder: 复制 package*.json
Builder->>Builder: npm ci(使用缓存)
Builder->>Builder: 复制源码并编译
Builder->>Builder: 删除 devDependencies
Docker->>Runner: 复制 dist/node_modules/package.json
Runner->>Runner: 创建非 root 用户
Runner-->>Dev: 输出生产镜像
常见坑与排查
这一节很重要。因为多阶段构建“看起来不复杂”,但实际踩坑不少。
1. COPY . . 太早,导致缓存雪崩
现象
哪怕只改了一行业务代码,npm ci 也重新执行。
原因
你在安装依赖之前就把整个项目复制进镜像了。
错误示例
COPY . .
RUN npm ci
正确示例
COPY package*.json ./
RUN npm ci
COPY . .
2. 使用 npm install 而不是 npm ci
现象
CI 环境构建结果不稳定,不同时间依赖版本可能漂移。
原因
npm install 可能修改锁文件解析结果,不够确定性。
建议
如果有 package-lock.json,生产构建优先用:
RUN npm ci
3. Alpine 不一定总是最优
很多人上来就说“瘦身就用 alpine”。这句话不总对。
你需要知道的边界:
alpine很小,但使用 musl libc- 某些 Node 原生模块、系统依赖、调试场景会更麻烦
slim往往是更稳妥的折中
经验建议
- 追求极致体积、依赖简单:可以试
alpine - 追求兼容性、排障便利:优先
slim
我个人在业务服务里,通常先选 slim,只有明确收益时才切到 alpine。
4. 开发依赖删早了,构建失败
现象
TypeScript、Webpack、Vite 等构建工具找不到。
原因
你在编译前就删了 devDependencies。
正确顺序
RUN npm ci
RUN npm run build
RUN npm prune --omit=dev
不是反过来。
5. 把密钥写进 Dockerfile
错误示例
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && npm ci
这样即使后面删掉 .npmrc,敏感信息仍可能存在于镜像层历史中。
更好的方式
使用 BuildKit secret:
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
构建命令:
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t demo-app:secret .
这样敏感文件不会进入最终镜像层。
6. 最终镜像里仍然有源码
现象
进入容器后,发现 /app/src 还在。
原因
通常是 runner 阶段又执行了一次 COPY . .
排查方法
检查最终阶段 Dockerfile,确保只复制必要文件:
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
7. 容器启动报权限错误
现象
切成非 root 用户后,应用读写目录失败。
原因
工作目录属主未调整,或程序要写入无权限目录。
处理方式
RUN useradd -r -u 1001 -g root appuser && \
chown -R appuser:root /app
USER appuser
如果应用必须写临时文件,明确指定可写目录,不要默认写系统目录。
安全/性能最佳实践
这一部分可以当成生产环境检查表。
1. 固定基础镜像版本
不要只写:
FROM node:20-slim
更稳妥的做法是固定更具体的 tag,甚至 digest:
FROM node:20.11.1-slim
原因:
- 避免基础镜像漂移
- 便于问题回溯
- 有利于构建可重复性
2. 运行镜像只放“运行所需文件”
最终镜像尽量只保留:
- 编译产物
- 运行依赖
- 必需配置文件
不要保留:
- 源码
- 测试脚本
- 构建缓存
- 包管理器认证文件
- 编译工具链
3. 优先使用非 root 用户
这几乎应该成为默认动作,而不是“有空再做”。
4. 善用 .dockerignore
很多镜像瘦身效果,最先来自它。
推荐至少忽略:
node_modules
.git
dist
coverage
.env
*.log
5. 减少层数,但不要为了“少一层”牺牲可维护性
层数不是越少越好。比起强行把所有命令写成一行,更重要的是:
- 缓存逻辑合理
- 每一步职责清晰
- 排障容易
6. 在 CI 中启用远程缓存
如果你的 CI 平台支持 Buildx,可以进一步使用 registry cache。
例如:
docker buildx build \
--cache-from=type=registry,ref=my-registry/demo-app:buildcache \
--cache-to=type=registry,ref=my-registry/demo-app:buildcache,mode=max \
-t my-registry/demo-app:latest .
适用场景:
- 频繁构建
- 多分支并行
- 依赖安装成本高
- 自托管 runner 缓存不稳定
7. 做镜像漏洞扫描
瘦身不代表一定安全,但通常有助于降低漏洞面。建议在流水线加入扫描步骤,比如:
- Trivy
- Grype
- Docker Scout
示例:
trivy image demo-app:prod
8. 明确区分“构建期变量”和“运行期配置”
- 构建期:
ARG - 运行期:
ENV或容器启动注入
不要把环境差异强行打进镜像,尽量让镜像本身保持通用。
方案对比:单阶段 vs 多阶段
| 维度 | 单阶段构建 | 多阶段构建 |
|---|---|---|
| 编写难度 | 低 | 中 |
| 镜像体积 | 通常较大 | 通常更小 |
| 缓存优化空间 | 一般 | 更高 |
| 构建工具隔离 | 差 | 好 |
| 安全性 | 一般 | 更好 |
| 生产可维护性 | 一般 | 更强 |
如果只是本地临时验证,单阶段未必不行;但只要进入 CI/CD 和生产环境,多阶段通常都值得。
一套我更推荐的落地思路
如果你打算在团队里推广,不要一上来追求“最极致最复杂”的 Dockerfile。我建议按下面顺序推进:
- 先补
.dockerignore - 再调整
COPY顺序,提升缓存命中 - 再改成多阶段构建
- 再接入 BuildKit 缓存
- 最后做非 root、安全 secret、漏洞扫描
这样好处是:
- 每一步收益都能被量化
- 出问题容易回滚
- 团队更容易接受
很多时候,工程优化的难点不在技术本身,而在于你是否能让优化过程可验证、可维护、可推广。
总结
Docker 镜像优化,表面上是在“瘦身”,本质上是在做一件更大的事:把构建过程、运行环境和安全边界重新梳理清楚。
这篇文章里,你可以直接带走的核心结论有 5 个:
- 多阶段构建是生产镜像优化的基础动作
COPY package*.json提前、源码后置,是缓存优化关键.dockerignore往往是最容易拿到收益的一步- BuildKit 缓存和 secret 能同时提升性能与安全
- 最终镜像要尽量小、尽量少、尽量低权限
如果你现在手上就有一个 Docker 项目,我建议你今天就做这三件事:
- 把
.dockerignore补上 - 把 Dockerfile 改成多阶段
- 用
docker history看一遍最终镜像到底装了什么
只要这三步做完,你的镜像质量通常就会明显提升。至于 alpine、远程缓存、漏洞扫描这些,可以在此基础上逐步加。
一句话收尾:先把镜像做“干净”,再去追求“极致小”。 这是我踩坑后觉得最稳的路线。