Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南
很多团队把 Docker 用起来后,第一阶段通常是“能跑就行”。
但到了第二阶段,问题会非常集中地冒出来:
- 镜像动不动几百 MB,拉取慢、发布慢
- CI 构建越来越久,缓存命中率越来越差
- 镜像里塞了编译工具、包管理器、调试命令,安全面过大
- 同一个 Dockerfile 本地能构建,到了 CI/CD 就开始玄学失败
- 明明只改了一行业务代码,却触发整套依赖重装
这些问题,多阶段构建(Multi-stage Build) 往往是最先该上的手段之一。但我要先说一句实话:
它不是“写两个 FROM 就自动瘦身”,而是要和构建上下文控制、缓存设计、基础镜像选择、最小权限运行一起用,效果才明显。
这篇文章我会带你从一个典型 Node.js 服务出发,做一套可运行、可验证的实战,把“镜像更小、构建更快、安全更稳”串成一条完整链路。
背景与问题
先看一个很常见的 Dockerfile 写法:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个写法的问题不止一个:
-
基础镜像偏大
node:18往往包含很多运行时并不需要的内容。 -
构建依赖和运行依赖混在一起
TypeScript 编译、构建工具、测试依赖,全都被打进最终镜像。 -
缓存利用差
COPY . .太早,任何代码改动都会导致npm install重新执行。 -
安全性一般
默认 root 用户运行,镜像内工具过多,被利用面更大。 -
上下文污染
如果没有.dockerignore,node_modules、日志、测试产物、Git 历史都可能被送进构建上下文。
换句话说,慢、大、不安全,常常不是单点问题,而是一套“默认写法”叠加出来的结果。
前置知识与环境准备
为了顺利跟着做,建议你本地具备:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个可运行的 Node.js 示例项目
- 基础命令行能力:
docker build、docker run、docker image ls
建议先开启 BuildKit:
export DOCKER_BUILDKIT=1
如果你用的是较新的 Docker Desktop,通常默认已经启用。
核心原理
1. 多阶段构建到底在解决什么
多阶段构建的核心思想很简单:
- 前一阶段负责“造产物”
- 后一阶段只负责“运行产物”
比如:
- builder 阶段:安装完整依赖、执行编译
- runner 阶段:只复制编译后的产物和运行所需的最小依赖
这样最终镜像就不会带上 gcc、make、测试框架、源码、缓存文件等“运行时不需要的东西”。
flowchart LR
A[源码与依赖清单] --> B[builder阶段]
B --> C[安装构建依赖]
C --> D[执行编译/打包]
D --> E[生成dist与生产依赖]
E --> F[runner阶段]
F --> G[仅复制运行所需文件]
G --> H[最终小镜像]
2. 缓存为什么总失效
Docker 构建是按层缓存的。
如果某一层输入变了,这一层以及后续层通常都要重新执行。
比如这段:
COPY . .
RUN npm install
只要项目里任何文件变动,COPY . . 的哈希就变,后面的 npm install 也就白缓存了。
更合理的思路是:
COPY package*.json ./
RUN npm ci
COPY . .
这样只有依赖清单变化时,才会重新安装依赖。
3. 瘦身不只是“换 alpine”
很多人第一反应是用 alpine。这个方向不算错,但不是万能答案。
alpine镜像小- 但它使用
musl libc - 某些 Node 原生模块、Python/C 扩展、glibc 相关依赖可能踩坑
所以基础镜像选择要看场景:
- 追求极致小体积:可优先评估
alpine - 追求兼容性与稳定性:可考虑
slim - 追求最小攻击面:可考虑 distroless,但调试难度会上升
4. 安全加固的本质
安全不是靠一句“别用 root”就结束。实际至少要看四件事:
- 最终镜像中有没有多余工具
- 是否使用非 root 用户运行
- 是否固定基础镜像版本
- 是否尽量减少依赖和系统包
flowchart TD
A[镜像安全] --> B[减少内容]
A --> C[降低权限]
A --> D[固定依赖]
A --> E[缩小攻击面]
B --> B1[多阶段构建]
B --> B2[删除缓存/临时文件]
C --> C1[USER 非root]
D --> D1[固定基础镜像Tag或Digest]
E --> E1[选择更小运行时镜像]
实战代码(可运行)
下面我们以一个简单的 Node.js + TypeScript 服务为例。
项目结构
demo-app/
├── src/
│ └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
第一步:准备示例代码
src/server.ts
import express from "express";
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (_req, res) => {
res.json({
ok: true,
message: "hello docker multi-stage build"
});
});
app.listen(port, () => {
console.log(`server started on port ${port}`);
});
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"typescript": "^5.6.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
第二步:先看一个“能用但不优”的版本
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
构建:
docker build -t demo-app:bad .
这个版本通常能跑,但问题很多。接下来我们一步步优化。
第三步:加上 .dockerignore
这是最容易被忽略、但收益很直接的一步。
.dockerignore
node_modules
dist
.git
.gitignore
Dockerfile
npm-debug.log
coverage
*.md
它的作用是:
- 避免把无关文件送进构建上下文
- 减少构建传输量
- 减少缓存失效概率
我自己踩过一个坑:本地 node_modules 被复制进构建上下文后,和容器内环境不一致,导致“本地能构建,CI 不能跑”。
所以这一步真的别省。
第四步:改造成多阶段构建
这是本篇核心。
优化版 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN useradd -r -u 1001 -g root appuser && chown -R appuser:root /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个 Dockerfile 做了哪些事
1)拆分了职责
deps:安装完整依赖,供构建用build:编译 TypeScriptrunner:只装生产依赖,复制编译产物
2)优化了缓存
先复制 package*.json,再安装依赖。
业务代码改了,不会立刻让依赖层失效。
3)使用 BuildKit 缓存挂载
RUN --mount=type=cache,target=/root/.npm npm ci
这样 npm 缓存可复用,尤其在 CI 中能明显减少重复下载。
4)运行时只保留生产依赖
npm ci --omit=dev
避免把 TypeScript、类型定义、构建工具带进最终镜像。
5)切换为非 root 用户运行
USER appuser
这是非常基础但很重要的加固项。
第五步:构建与运行验证
构建镜像:
docker build -t demo-app:multi .
运行容器:
docker run --rm -p 3000:3000 demo-app:multi
验证接口:
curl http://localhost:3000/
期望输出:
{"ok":true,"message":"hello docker multi-stage build"}
第六步:对比镜像体积与层信息
查看镜像:
docker image ls | grep demo-app
查看镜像层:
docker history demo-app:multi
如果你想更直观地分析,可以用:
docker inspect demo-app:multi
或者第三方工具 dive:
dive demo-app:multi
dive 很适合查这类问题:
- 哪些层体积最大
- 哪些文件其实没必要进最终镜像
- 是否有删除文件但体积仍保留在旧层的问题
进一步优化:更激进的瘦身方案
如果你已经完成基础优化,还想继续压缩体积,可以考虑以下变体。
方案一:使用 Alpine
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
优点:
- 镜像通常更小
注意:
- 如果依赖里有原生模块,可能出现编译或运行兼容性问题
方案二:Distroless 运行时镜像
如果你更关注攻击面收缩,可以考虑 distroless。
FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:18-slim AS prod-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=prod-deps /app/package.json ./package.json
EXPOSE 3000
CMD ["dist/server.js"]
优点:
- 没有 shell、没有包管理器,攻击面更小
缺点:
- 调试难度更高
- 某些排障命令没法直接在容器里跑
建议边界:
生产环境适合,开发环境未必方便。
构建流程全景图
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Deps as deps阶段
participant Build as build阶段
participant Run as runner阶段
Dev->>Docker: docker build
Docker->>Deps: 复制 package*.json
Deps->>Deps: npm ci
Docker->>Build: 复制源码
Build->>Build: npm run build
Docker->>Run: 安装生产依赖
Build->>Run: 复制 dist
Run-->>Dev: 生成最终镜像
常见坑与排查
这一节我尽量讲“真会遇到的坑”,不是只讲教科书式问题。
1. COPY . . 导致缓存雪崩
现象
只改了一个业务文件,结果 npm ci 又完整执行了一遍。
原因
依赖安装前复制了整个项目,任意文件变化都会让上一层失效。
解决
改成:
COPY package*.json ./
RUN npm ci
COPY . .
2. .dockerignore 没写,构建上下文过大
现象
构建开始时卡很久,或者日志里看到上下文传输几十 MB、几百 MB。
排查
构建时看日志:
docker build -t demo-app .
如果看到:
Sending build context to Docker daemon 350.4MB
那就要警惕了。
解决
补 .dockerignore,排除:
node_modules.git- 日志
- 测试产物
- 本地编译结果
3. Alpine 下原生模块安装失败
现象
构建时报错,类似:
gyp ERR!
或运行时报动态库问题。
原因
某些模块依赖 glibc 或需要本地编译环境,Alpine 的 musl 环境不兼容。
解决
优先尝试:
- 改用
node:18-slim - 如果必须 alpine,补齐构建工具链
例如:
RUN apk add --no-cache python3 make g++
但要注意,这些工具最好只出现在构建阶段,不要留在最终镜像里。
4. 非 root 用户运行后权限报错
现象
应用启动时报:
EACCES: permission denied
原因
复制进容器的文件仍属于 root,应用用户无权访问。
解决
复制后执行 chown,或在 COPY 时指定属主。
例如:
COPY --chown=1001:0 --from=build /app/dist ./dist
或者:
RUN chown -R appuser:root /app
5. 使用 distroless 后无法进入容器调试
现象
你想执行:
docker exec -it <container> sh
但发现根本没有 shell。
原因
distroless 就是故意不带这些工具。
解决思路
- 开发环境用
slim - 生产环境用 distroless
- 或保留一个 debug 版镜像用于排障
这不是 bug,而是取舍。
6. npm install 与 npm ci 混用导致结果不稳定
建议
在 CI/CD 和镜像构建里,优先用:
npm ci
原因:
- 按 lock 文件精确安装
- 更可重复
- 更适合自动化流水线
安全/性能最佳实践
这里给一份我比较认可的“够用且不折腾”的清单。
安全最佳实践
1. 使用非 root 用户运行
USER 1001
如果应用不需要特权端口、也不需要操作宿主资源,尽量不要 root。
2. 固定基础镜像版本
不要只写:
FROM node:latest
建议至少固定主版本甚至具体 tag:
FROM node:18-slim
更进一步可使用 digest 锁定。
3. 最终镜像只保留运行时必须内容
不要把下面这些带进生产镜像:
- 测试工具
- 编译工具
- 包管理缓存
- 源码(如果运行时不需要)
- 文档和样例数据
4. 定期扫描漏洞
常见方式:
docker scout quickview demo-app:multi
或者使用:
- Trivy
- Grype
- Snyk
5. 减少系统包安装
每多一个包,就多一份维护成本和攻击面。
实战里我会先问自己一句:这个包是构建必须,还是运行必须?
性能最佳实践
1. 把“变化慢”的层放前面
典型顺序:
- 基础镜像
- 依赖清单
- 安装依赖
- 源码复制
- 编译
这样缓存命中率会更高。
2. 善用 BuildKit 缓存挂载
例如 npm:
RUN --mount=type=cache,target=/root/.npm npm ci
对于 apt、pip、go mod 等也有类似优化空间。
3. 控制镜像层中无效文件
不要先生成大文件,再在后续层删除。
因为删掉不代表前一层体积不存在。
4. 尽量减少构建上下文
上下文越大:
- 传输越慢
- 哈希计算越慢
- 缓存越容易失效
5. 结合 CI 缓存策略
如果你的 CI 平台支持远程缓存,构建速度会再提升一个档次。
尤其是依赖安装和编译阶段,收益非常可观。
逐步验证清单
你可以按下面的顺序做一次自测。
基础验证
- 容器能正常启动
-
curl返回预期结果 - 镜像中不存在源码目录(如果运行时不需要)
- 镜像中不存在 devDependencies
体积验证
- 优化后镜像体积明显小于单阶段版本
-
docker history中没有异常大层 -
.dockerignore生效,构建上下文明显变小
安全验证
- 容器不是 root 用户运行
- 基础镜像不是
latest - 最终镜像不包含编译工具链
- 已完成一次漏洞扫描
一个更实用的生产模板
如果你不想每次都从头组织,下面这份可以作为 Node.js 服务的通用模板:
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS prod-deps
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN useradd -r -u 1001 -g root appuser && chown -R appuser:root /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个模板的特点是:
- 结构清晰
- 缓存友好
- 最终镜像不混入 devDependencies
- 默认非 root 运行
- 适合作为中小型 Node 服务的起点
总结
如果只记住一句话,我希望是这句:
多阶段构建不是“高级写法”,而是生产 Dockerfile 的基础能力。
它解决的不是一个问题,而是一串连锁问题:
- 让镜像更小
- 让构建更快
- 让缓存更稳
- 让运行环境更干净
- 让安全基线更容易落实
真正落地时,建议按下面顺序推进:
- 先补
.dockerignore - 把依赖安装从源码复制中解耦
- 改为多阶段构建
- 最终镜像只保留生产依赖和产物
- 切换非 root 用户
- 根据兼容性在
slim/alpine/ distroless 之间选择
最后给一个边界建议:
- 如果你团队当前还在“镜像能跑就行”阶段,优先上
slim + 多阶段 + 非 root - 如果你对体积极敏感,再评估
alpine - 如果你对攻击面和合规要求更高,再考虑 distroless
别一上来追求“最小”,先追求可维护、可复用、可排障。
我自己的经验也是这样:先把构建链路做稳,再把体积做到合理,最后再做极限优化。