Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建优化、缓存加速与安全加固指南
很多团队一开始用 Docker,目标都很朴素:能跑就行。但项目一旦进入 CI/CD、频繁发布、多环境部署,你很快就会碰到几个现实问题:
- 镜像动不动几百 MB,拉取慢、推送慢
- 构建时间越来越长,CI 队列堵住
- Dockerfile 越写越乱,缓存总是失效
- 镜像里带着编译工具、包管理器、测试文件,安全风险上升
- 同一份代码,本地能构建,CI 上却慢得离谱
这篇文章我会从**“为什么镜像会变胖、为什么缓存总失效、怎么把构建产物和运行环境拆开”**这几个角度,带你完整走一遍 Docker 优化实战。内容面向中级开发者,默认你已经会写基础 Dockerfile,但还没有系统整理过优化方法。
背景与问题
先看一个非常常见的 Node.js 项目 Dockerfile 写法:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题几乎是“教科书级”的:
-
基础镜像偏大
node:20往往比你想象得重,里面带了很多运行时并不需要的内容。 -
构建依赖和运行依赖混在一起
比如 TypeScript、打包工具、测试框架,这些只在构建阶段需要,运行时不该带进去。 -
缓存利用差
COPY . .放在前面,一旦代码任意文件变化,后面的npm install就会重新执行。 -
攻击面扩大
镜像里保留了 shell、编译链、包管理器,容器被利用时可操作空间更大。 -
不利于排查和协作
构建步骤耦合在一起,新同事一改 Dockerfile,经常会把缓存链打断。
换句话说,很多镜像“胖”,并不只是因为基础镜像大,而是构建阶段和运行阶段没有分离,再叠加缓存层设计不合理。
前置知识与环境准备
建议你具备以下环境:
- Docker 24+
- 已启用 BuildKit
- 一个可运行的 Node.js 示例项目
- 理解基础命令:
docker build、docker run、docker images
建议先开启 BuildKit,这对缓存优化非常关键:
export DOCKER_BUILDKIT=1
如果你在 Docker Desktop 中使用,一般默认已启用。
核心原理
这一部分不讲大而泛的概念,只抓住 4 个对实战最关键的点。
1. 多阶段构建:把“生产车间”和“展示橱窗”分开
多阶段构建的核心思想是:
- 前一个阶段负责下载依赖、编译、打包
- 最后一个阶段只保留运行所需的最小内容
你可以理解成:
前面的阶段像厨房,后面的阶段像上桌成品。厨房里锅碗瓢盆很多,但端上桌时只需要菜。
flowchart LR
A[源码] --> B[builder 构建阶段]
B --> C[安装依赖]
C --> D[编译/打包]
D --> E[runner 运行阶段]
E --> F[仅复制运行产物]
F --> G[更小更安全的镜像]
2. Docker 缓存:一层变了,后面可能全失效
Dockerfile 每一条指令通常都会形成一层。
缓存命中依赖两个关键因素:
- 指令本身是否变化
- 该指令依赖的文件内容是否变化
因此这两句顺序差异很大:
COPY . .
RUN npm install
vs
COPY package*.json ./
RUN npm install
COPY . .
后者的好处是:
如果你只改了业务代码,但 package.json 没变,那么 npm install 这一层还可以直接命中缓存。
3. 镜像瘦身:不是一味追求最小,而是“最少必要”
镜像瘦身常见手段有:
- 选更小的基础镜像
- 只复制构建产物
- 不把
.git、测试数据、文档带进去 - 减少无意义层数
- 清理包管理缓存
- 使用
.dockerignore
但注意边界:
最小不一定最好。例如 Alpine 镜像很小,但某些原生依赖、glibc 兼容场景会更麻烦。中级开发者最容易踩的坑,就是一上来就盲目“全量 Alpine 化”。
4. 安全加固:运行镜像应该尽量“无工具、低权限、少暴露”
一个好的运行时镜像,通常具备这些特征:
- 非 root 用户运行
- 不包含编译工具链
- 只暴露必要端口
- 基础镜像来源可信
- 依赖版本可控
- 尽量减少 shell 与包管理器可用性
用一个例子先建立全局视图
下面用一个典型 Node.js/TypeScript 服务做演示。目标是:
- 支持构建缓存
- 使用多阶段构建
- 只把编译产物和生产依赖放进最终镜像
- 用非 root 用户运行
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 / lockfile
Builder->>Builder: 安装依赖(可缓存)
Docker->>Builder: 复制源码
Builder->>Builder: npm run build
Builder->>Builder: npm prune --omit=dev
Builder->>Runner: 复制 dist 与生产依赖
Runner->>Dev: 输出精简运行镜像
实战代码(可运行)
示例项目结构
demo-app/
├── src/
│ └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例应用代码
src/index.js:
const http = require("http");
const port = process.env.PORT || 3000;
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
message: "hello docker multi-stage",
time: new Date().toISOString()
})
);
});
server.listen(port, () => {
console.log(`server running at http://0.0.0.0:${port}`);
});
package.json:
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {}
}
第一步:先看一个“能跑但不优”的版本
FROM node:20
WORKDIR /app
COPY . .
RUN npm install --production
EXPOSE 3000
CMD ["npm", "start"]
构建命令:
docker build -t demo-app:naive .
运行命令:
docker run --rm -p 3000:3000 demo-app:naive
验证:
curl http://localhost:3000
这版可以运行,但优化空间很大。
改造成多阶段构建版本
下面是更推荐的写法。
Dockerfile(多阶段构建 + 缓存友好 + 非 root)
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM base AS runner
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["npm", "start"]
这份 Dockerfile 做了几件关键事情:
-
base阶段统一基础环境
避免重复写WORKDIR等配置。 -
deps阶段只处理依赖
并且先复制package*.json,最大化缓存命中。 -
RUN --mount=type=cache
这是 BuildKit 提供的缓存挂载能力。即便镜像层重新构建,也能复用 npm 下载缓存,明显加速。 -
runner阶段不再执行安装命令
直接从deps拿生产依赖,减少不确定性。 -
使用
USER node
避免默认 root 运行。
构建:
docker build -t demo-app:optimized .
运行:
docker run --rm -p 3000:3000 demo-app:optimized
如果你的项目需要“编译”,该怎么写
现实里更常见的是前端 SSR、NestJS、TypeScript API、Go/Java 等需要构建产物的项目。下面给一个更贴近生产的 Node + TypeScript 写法。
项目结构示意
ts-app/
├── src/
│ └── main.ts
├── dist/
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
Dockerfile(构建阶段 + 运行阶段分离)
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-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-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/main.js"]
这版的重点是:
npm ci在 builder 阶段安装完整依赖,用于编译- 编译完成后执行
npm prune --omit=dev - 最终 runner 镜像只拿:
dist- 生产依赖
node_modules - 必要元信息
package*.json
这就是典型的多阶段构建价值:
构建工具还留在 builder,runner 里没有 TypeScript、测试框架、源码编辑辅助包。
.dockerignore:经常被低估,但收益很稳
很多构建慢,并不是 Dockerfile 本身有多差,而是构建上下文太大。
建议至少加上:
node_modules
npm-debug.log
Dockerfile*
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env
*.local
为什么它重要?
当你执行:
docker build .
Docker 会先把当前目录作为“构建上下文”发送给 daemon。
如果你把 .git、node_modules、测试报告、日志文件都打进去,构建一开始就慢了,而且还可能导致缓存误判。
我自己就踩过这个坑:项目明明没几行代码,但 .git 历史很大,结果每次 docker build 都像在搬家。
逐步验证清单
你可以按这个顺序验证优化是否生效。
1. 检查镜像大小
docker images | grep demo-app
对比 naive 与 optimized 版本的镜像体积。
2. 检查缓存命中
第一次构建:
docker build -t demo-app:optimized .
第二次在不修改 package.json 的情况下构建:
docker build -t demo-app:optimized .
你应该能看到依赖安装层大概率命中缓存。
3. 修改业务代码再构建
只改 src/index.js,再执行构建。
观察是否只有后半段步骤重新执行。
4. 检查运行用户
进入容器:
docker run --rm -it demo-app:optimized sh
如果基础镜像里没有 sh,你可以改成更适合的调试方式,或者临时用调试镜像。
检查当前用户:
id
应该不是 root。
常见坑与排查
这一部分我尽量写得“接地气”一点,因为很多问题不是不会写,而是明明照着写了,结果还是没生效。
坑 1:COPY . . 太早,缓存直接废掉
错误示例:
COPY . .
RUN npm ci
问题:
任意源码变动都会导致依赖安装层失效。
正确思路:
COPY package*.json ./
RUN npm ci
COPY . .
坑 2:npm install 和 npm ci 混用,结果不稳定
如果你在 CI 或生产构建中追求一致性,优先用:
npm ci
原因:
- 严格依赖 lockfile
- 安装结果更可预测
- 一般比
npm install更适合自动化场景
坑 3:Alpine 镜像虽然小,但原生依赖可能出问题
例如某些 Node 模块依赖 glibc 或原生编译环境,Alpine 的 musl 体系可能引发兼容问题。
如果你:
- 使用了
sharp、bcrypt、数据库驱动等原生模块 - 运行环境依赖特定系统库
那我更建议先从 *-slim 开始,而不是一上来就 Alpine。
坑 4:多阶段构建后,运行时报“找不到模块”
典型原因:
- 只复制了
dist,没复制生产依赖 npm prune --omit=dev在错误阶段执行- 最终启动命令路径不对
排查顺序建议:
- 进入最终镜像检查文件结构
- 看
node_modules是否存在 - 看
package.json中main或启动脚本是否匹配 - 看构建产物目录是否真的生成了
坑 5:BuildKit 缓存挂载没生效
你写了:
RUN --mount=type=cache,target=/root/.npm npm ci
但发现没提速,可能原因有:
- 没启用 BuildKit
- CI 环境每次都是全新 runner,缓存没持久化
- npm 缓存目录写错
- 权限或用户切换影响缓存路径
可以先明确启用:
DOCKER_BUILDKIT=1 docker build -t demo-app:optimized .
坑 6:镜像变小了,但构建反而更慢
这类情况并不少见。常见原因:
- 阶段拆太碎,导致维护复杂度增加
- 每次都从远程下载依赖,没有外部缓存支持
- 使用过度精简镜像,安装依赖时补库成本更高
- CI 没配置 registry cache / layer cache
所以要记住一点:
瘦身目标不是绝对最小,而是综合体积、速度、稳定性和维护成本。
安全/性能最佳实践
这一部分给你一组“实战可落地”的建议,不追求教条,强调边界条件。
1. 优先选择可信且合适的基础镜像
建议顺序通常是:
- 官方镜像
- 指定大版本甚至小版本
- 根据兼容性决定
slim还是alpine
例如:
FROM node:20-bookworm-slim
如果你非常重视供应链可追溯性,可以进一步固定 digest。
2. 运行时镜像不要包含编译工具链
最终镜像中尽量不要保留:
- gcc
- make
- git
- curl(视场景)
- 包管理器缓存
- 测试工具
这些都应该留在 builder 阶段。
3. 使用非 root 用户运行
USER node
如果是自定义用户:
RUN addgroup --system app && adduser --system --ingroup app app
USER app
注意:切换用户后,要确认应用目录权限正确。
4. 减少层数,但别为了“少一层”牺牲可维护性
有些文章喜欢把所有命令揉成一长串:
RUN apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*
这没错,但不要走极端。
如果你的 Dockerfile 可读性已经非常差,新人根本不敢改,长期成本会更高。
5. 清理系统包缓存
如果你安装了系统依赖,记得清理:
RUN apt-get update && apt-get install -y --no-install-recommends \
dumb-init \
&& rm -rf /var/lib/apt/lists/*
6. 尽量只复制必要文件
不要偷懒总写:
COPY . .
更推荐显式复制:
COPY package*.json ./
COPY src ./src
COPY tsconfig.json ./
这不仅减小上下文,还能提升缓存命中率。
7. 在 CI 中配合远程缓存
如果你的团队构建频繁,强烈建议把缓存策略做进 CI。
例如使用 Buildx 的缓存导入导出,把缓存存入 registry。
示例:
docker buildx build \
--cache-from=type=registry,ref=your-registry/demo-app:buildcache \
--cache-to=type=registry,ref=your-registry/demo-app:buildcache,mode=max \
-t your-registry/demo-app:latest \
.
这类配置对多人协作和云端 runner 特别有帮助,不然每次构建都像“失忆重来”。
8. 增加基础镜像与依赖扫描
镜像瘦身不等于安全,安全也不等于零漏洞。
建议至少做:
- 基础镜像定期升级
- 依赖漏洞扫描
- 镜像扫描
- 最小权限运行
可以在 CI 中接入扫描工具,形成发布门禁。
一个简化的优化决策图
如果你不确定该从哪里下手,可以按下面这个顺序做。
flowchart TD
A[镜像过大或构建过慢] --> B{是否已使用多阶段构建?}
B -- 否 --> C[先拆分 builder / runner]
B -- 是 --> D{依赖安装是否频繁重跑?}
D -- 是 --> E[调整 COPY 顺序并使用 lockfile]
D -- 否 --> F{构建上下文是否过大?}
F -- 是 --> G[完善 .dockerignore]
F -- 否 --> H{是否保留了编译工具和dev依赖?}
H -- 是 --> I[仅复制运行产物与生产依赖]
H -- 否 --> J[进一步做非root、扫描与远程缓存]
推荐的目录与 Dockerfile 组织方式
当项目稍微复杂一点时,我建议你这样组织:
- 一个主 Dockerfile 负责生产构建
- 如有本地开发需求,单独维护
Dockerfile.dev - 把和构建强相关的脚本写入
package.json scripts - 不要把业务逻辑塞进 Dockerfile
例如:
{
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"test": "jest"
}
}
这样 Dockerfile 更像“编排构建流程”,而不是承担业务构建细节。
总结
如果你只记住一件事,那就是:
多阶段构建的本质,不只是“把镜像做小”,而是把构建职责和运行职责彻底分开。
落地时,优先做这 5 件事:
- 拆分 builder 和 runner
- 把
COPY package*.json放在前面 - 使用
npm ci与 lockfile - 补齐
.dockerignore - 最终镜像使用非 root 运行
如果团队已经进入 CI/CD 阶段,再继续做:
- BuildKit 缓存挂载
- 远程缓存
- 基础镜像升级策略
- 镜像与依赖扫描
最后给一个实用判断标准:
如果你的优化方案让镜像更小、构建更快、排查更清晰,而且新同事 10 分钟内能看懂,那它大概率就是好方案。
反过来,如果你为了“极致瘦身”把 Dockerfile 写成谜语,后面维护的人通常会先把你“优化”掉。
希望这篇文章能帮你把 Docker 构建从“能用”推进到“好用、快用、稳用”。