Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南
很多团队在刚开始用 Docker 时,往往先解决“能跑起来”,然后才发现两个现实问题:
- 镜像越来越大,一个 Node.js 服务轻轻松松几百 MB;
- 构建越来越慢,代码改一行,依赖又重装一遍;
- 安全风险越来越多,镜像里带着编译器、包管理器、root 用户,漏洞扫描一片红。
我自己第一次给线上服务做镜像瘦身时,原本觉得“几十 MB 没什么”,结果 CI/CD 一天构建几十次,拉取镜像、推送镜像、跨环境分发的时间被不断放大,最后才意识到:镜像体积、构建速度和安全性,其实是同一个工程问题的三个面。
这篇文章我们不讲空泛原则,而是围绕一个可运行示例,把这几个目标一起落地:
- 用 多阶段构建 拆分“构建环境”和“运行环境”
- 用合理的 Dockerfile 设计提高 缓存命中率
- 用更小的基础镜像和复制策略完成 镜像瘦身
- 用非 root、只带运行时依赖等方式做 安全加固
背景与问题
先看一个常见但不太理想的 Dockerfile。以一个 TypeScript Node.js 服务为例:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
它的问题很典型:
COPY . .太早,任何源码改动都会让npm install缓存失效- 构建环境和运行环境混在一起,最终镜像里保留了:
- 源码
devDependencies- 编译工具链
- 默认 root 用户运行,安全面较大
- 没有
.dockerignore,可能把.git、日志、测试文件都打进镜像
结果通常是:
- 镜像大
- 构建慢
- 漏洞多
- 交付链路成本高
一个更实际的目标
中级开发者做镜像优化时,不要只盯着“体积最小”,而应该追求这三个平衡:
- 构建快
- 运行稳
- 安全边界清晰
前置知识与环境准备
建议你本地准备:
- Docker 20.10+
- Docker BuildKit(建议开启)
- 一个 Node.js 示例项目
- 可选:
docker buildx、docker image inspect、docker history
开启 BuildKit:
export DOCKER_BUILDKIT=1
如果你在 Windows PowerShell 中:
$env:DOCKER_BUILDKIT=1
核心原理
1. 多阶段构建到底解决了什么
多阶段构建的本质,是把一个镜像构建过程拆成多个阶段:
- builder 阶段:安装编译依赖、执行构建
- runner 阶段:只保留运行所需文件
这样最终镜像不会把中间阶段的“工具链垃圾”带进去。
flowchart LR
A[源代码] --> B[Builder 阶段]
B --> C[安装依赖]
C --> D[编译产物 dist]
D --> E[Runner 阶段]
E --> F[仅复制运行所需文件]
F --> G[更小更安全的生产镜像]
2. 缓存命中率为什么影响构建速度
Docker 构建按层执行。某一层输入变了,这一层及其后续层缓存都会失效。
比如下面两种写法差异很大:
不推荐:
COPY . .
RUN npm ci
只要任何文件变动,npm ci 就会重新执行。
推荐:
COPY package*.json ./
RUN npm ci
COPY . .
这样只有依赖声明变化时,才会重新安装依赖。普通业务代码变动时,可以复用缓存。
3. 镜像瘦身不只是“换小底座”
很多人会先想到 alpine,这当然有帮助,但不够完整。真正有效的瘦身通常来自四件事:
- 少装依赖
- 少复制文件
- 只保留运行产物
- 减少层浪费
4. 安全加固的底线思路
容器不是天然安全,它只是隔离手段。镜像层面的基本加固建议至少包括:
- 不用 root 运行
- 运行镜像不带编译器和包管理器
- 使用明确版本标签,避免漂移
- 控制复制内容,减少敏感文件进入镜像
- 定期扫描漏洞
示例项目结构
我们先准备一个最小可运行示例。
demo-app/
├── src/
│ └── index.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "Docker multi-stage build demo",
"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.10.1",
"typescript": "^5.7.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true
},
"include": ["src/**/*"]
}
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 multi-stage build",
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server running at http://localhost:${port}`);
});
.dockerignore
这个文件非常关键,很多人会漏掉。
node_modules
dist
.git
.gitignore
Dockerfile
npm-debug.log
README.md
coverage
*.local
.env
实战代码(可运行)
下面从“普通写法”升级到“生产可用写法”。
第一步:一个相对合理的多阶段 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-bookworm-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:18-bookworm-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
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本做了什么
- 用
builder阶段完成依赖安装与 TypeScript 编译 npm prune --omit=dev删除开发依赖runner阶段只复制:package*.json- 生产依赖后的
node_modules - 构建产物
dist
- 使用
node非 root 用户运行
构建并运行
docker build -t demo-app:multi-stage .
docker run --rm -p 3000:3000 demo-app:multi-stage
验证:
curl http://localhost:3000
预期输出:
{"message":"hello docker multi-stage build","time":"2025-01-01T00:00:00.000Z"}
第二步:进一步优化缓存与构建速度
如果你已经在 CI 中频繁构建,接下来建议用 BuildKit 的缓存挂载。
# syntax=docker/dockerfile:1.4
FROM node:18-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:18-bookworm-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
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
为什么这版更快
deps阶段专门处理依赖- 只要
package-lock.json不变,这一层就能最大概率命中缓存 - BuildKit 会把 npm 缓存持久化,减少重复下载
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 构建缓存
participant Registry as npm Registry
Dev->>Docker: docker build
Docker->>Cache: 检查 package-lock 对应层
alt 依赖未变化
Cache-->>Docker: 命中缓存
else 依赖变化
Docker->>Registry: 下载依赖
Registry-->>Docker: 返回依赖包
Docker->>Cache: 写入缓存
end
Docker->>Docker: 编译源码
Docker-->>Dev: 输出镜像
第三步:更进一步的瘦身思路
对于 Node.js 应用,常见选择有:
node:bookworm-slimnode:alpine- distroless(更安全、更小,但调试不方便)
什么时候选 slim
适合大多数中级团队,原因很实际:
- 兼容性更稳
- 原生模块踩坑更少
- 出问题时更容易排查
什么时候考虑 alpine
适合对体积更敏感、依赖较简单的场景,但要注意:
- musl libc 与 glibc 差异
- 原生依赖编译/运行兼容问题
- 某些 Node 模块在 Alpine 下更容易踩坑
distroless 方案示例
如果你追求更强的最小化和更少攻击面,可以考虑 distroless:
FROM node:18-bookworm-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 gcr.io/distroless/nodejs18-debian12
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["dist/index.js"]
这个方案的优点是镜像更干净,但缺点也很明显:
- 没有 shell
- 线上排障难度更高
- 需要团队有更成熟的观测与日志体系
逐步验证清单
实践时我建议不要“一把梭”,而是每改一步都验证。
1. 验证镜像体积
docker images | grep demo-app
2. 查看镜像层历史
docker history demo-app:multi-stage
3. 检查容器运行用户
docker run --rm demo-app:multi-stage id
如果输出不是 uid=0(root),说明非 root 生效。
4. 检查运行文件是否过多
docker run --rm -it demo-app:multi-stage sh
如果是 distroless 镜像,这一步不适用。普通 slim 镜像可以用来人工检查是否仍然带了源码、测试文件等。
5. 比较构建耗时
第一次和第二次分别执行:
time docker build -t demo-app:multi-stage .
如果缓存策略合理,第二次构建应明显更快。
常见坑与排查
这一节非常重要,因为多阶段构建的思路并不难,难的是“为什么它在我项目里不工作”。
坑 1:COPY . . 把缓存打爆
现象
明明只改了一个业务文件,结果 npm ci 又重新执行。
原因
你把全部上下文过早复制进镜像,任何文件变化都会影响后续层。
解决
先复制依赖描述文件,再安装依赖,最后复制业务代码。
COPY package*.json ./
RUN npm ci
COPY . .
坑 2:生产镜像里仍然有 devDependencies
现象
镜像体积不小,漏洞扫描还扫出一堆构建期依赖问题。
原因
你在运行阶段直接复制了完整 node_modules,但没有裁掉开发依赖。
解决
使用:
RUN npm prune --omit=dev
或者在单独生产依赖阶段中执行:
RUN npm ci --omit=dev
但要注意:如果构建需要 TypeScript、webpack 这类工具,那它们通常是开发依赖,构建阶段必须先装全依赖。
坑 3:alpine 下原生模块异常
现象
本地构建成功,容器启动时报错,常见于 sharp、bcrypt、canvas 等原生模块。
原因
Alpine 使用 musl libc,与很多预编译二进制或动态库依赖存在兼容差异。
解决
- 优先改用
bookworm-slim - 必要时在 Alpine 中补齐编译依赖
- 对原生模块项目,不要为了几十 MB 盲目换底座
这是我比较想强调的一点:“更小”不一定“更省事”。
坑 4:非 root 用户导致权限错误
现象
容器启动时报权限问题,比如无法写日志目录、缓存目录。
原因
复制文件后所有权不匹配,或者应用写入了没有权限的路径。
解决
必要时使用 --chown:
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
如果应用必须写文件,尽量写到明确可控目录,并提前设置权限。
坑 5:.dockerignore 缺失导致构建上下文过大
现象
docker build 还没开始执行命令,就在“Sending build context…”阶段耗时很久。
原因
把 node_modules、.git、测试报告、日志、构建产物全传给 Docker daemon 了。
解决
补齐 .dockerignore,这是最容易做、收益又很高的优化。
安全/性能最佳实践
这一节给出可以直接落地的建议,不求“绝对最优”,但求团队能稳定执行。
1. 固定基础镜像版本,不用模糊标签
不推荐:
FROM node:latest
推荐:
FROM node:18-bookworm-slim
更进一步,可以固定到 digest,不过维护成本会提高。
2. 运行镜像只保留运行时内容
一个生产镜像里,尽量不要有:
- 源码
- 测试文件
- 编译工具链
- 包管理缓存
- shell(在极致安全场景下)
核心原则是:能不带就不带。
flowchart TD
A[生产镜像内容审查] --> B{是否运行必需}
B -->|是| C[保留]
B -->|否| D[移除]
D --> E[减少体积]
D --> F[降低攻击面]
D --> G[减少漏洞数量]
3. 使用非 root 用户运行
最低限度:
USER node
如果你用的是自定义基础镜像,也可以显式创建用户:
RUN useradd -r -s /sbin/nologin appuser
USER appuser
边界条件是:如果应用需要绑定 1024 以下端口、写某些系统目录,就需要额外设计权限模型,而不是简单回退到 root。
4. 合理设计层顺序,提高缓存复用
推荐顺序通常是:
- 工作目录
- 复制依赖声明
- 安装依赖
- 复制源码
- 执行构建
- 切换到运行阶段
不要把高频变化文件放在前面,否则缓存收益会迅速下降。
5. 借助扫描工具做持续治理
可以接入这些工具:
docker scouttrivygrype- 云厂商镜像扫描能力
例如用 Trivy:
trivy image demo-app:multi-stage
这里要有一个务实认知:漏洞扫描不会让镜像自动安全,但它能帮你发现明显问题,并把风险治理纳入流水线。
6. 控制容器运行时资源与行为
镜像安全只是第一步,运行时同样重要。比如:
- 限制 CPU/内存
- 尽量使用只读文件系统
- 不随意挂载宿主机敏感目录
- 配合
--cap-drop最小化能力集
示例:
docker run --rm \
-p 3000:3000 \
--read-only \
--memory="256m" \
--cpus="1" \
demo-app:multi-stage
如果应用确实需要写临时目录,需要额外挂载可写目录,不要硬套只读策略。
一个可作为生产起点的 Dockerfile
如果你想要一个“够用、稳妥、便于团队推广”的版本,我建议从下面这个模板开始:
# syntax=docker/dockerfile:1.4
FROM node:18-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:18-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --chown=node:node --from=builder /app/package*.json ./
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本的特点是:
- 结构清晰,团队容易理解
- 构建缓存友好
- 体积明显优于单阶段
- 安全性比“默认 root + 全量依赖”好很多
- 出问题时仍然容易调试
方案取舍:别把优化做成负担
中级开发者经常会遇到一个实际问题:到底要不要一步到位上最极致方案?
我的建议是分层推进:
适合大多数团队的优先级
- 先补
.dockerignore - 再改多阶段构建
- 调整 COPY 顺序,优化缓存
- 运行阶段裁剪 devDependencies
- 切换非 root
- 最后再评估 alpine / distroless
原因很简单:
- 前四步收益大、风险低
- 后两步收益也不错,但兼容性和排障成本更高
换句话说,先拿到 80 分,再冲 95 分,通常比一开始追求 100 分更划算。
总结
Docker 镜像优化不是一个“写几行 Dockerfile 技巧”的小问题,而是贯穿开发、构建、交付、运行与安全治理的工程实践。
如果你只记住三件事,我建议是:
- 用多阶段构建,把构建环境和运行环境彻底分开
- 围绕缓存设计 Dockerfile 顺序,优先保证构建速度
- 生产镜像只保留运行必需内容,并坚持非 root 运行
最后给你一套可执行落地建议:
- 新项目:直接从多阶段模板起步
- 老项目:先加
.dockerignore和依赖分层复制 - 原生模块项目:优先
bookworm-slim,别急着上 Alpine - 高安全场景:评估 distroless,但要先补齐日志、探针和可观测性
- CI 中:开启 BuildKit,并接入镜像扫描
当你把这些动作做完后,通常会看到三个直接结果:
- 镜像更小
- 构建更快
- 发布更稳,也更安全
这不是“过度优化”,而是容器化应用走向工程化的必经之路。