Docker 多阶段构建与镜像瘦身实战:中级开发者的构建加速、体积优化与安全基线指南
很多团队开始用 Docker 时,最先感受到的不是“优雅”,而是几个很现实的问题:
- 镜像越做越大,动不动几百 MB 甚至上 GB
- CI 构建越来越慢,改一行代码也要等半天
- 线上容器里什么工具都有,安全面巨大
- Dockerfile 越写越长,后面的人根本不敢动
我自己在项目里也踩过类似的坑:一个 Node.js 服务,初版镜像接近 1.1GB,因为直接拿 node 基础镜像构建,依赖、源码、编译缓存、调试工具全都进了生产镜像。结果是构建慢、分发慢、漏洞扫描结果一片红。
这篇文章不讲“概念罗列”,而是带你从一个常见项目出发,做一遍多阶段构建 + 镜像瘦身 + 安全基线。读完后,你至少能做到:
- 把构建镜像和运行镜像分离
- 让 Docker 缓存真正发挥作用
- 明确哪些文件不该进镜像
- 给生产镜像加上最基本的安全约束
- 知道构建慢、镜像大时该从哪排查
背景与问题
先看一个很常见、也很“原始”的 Dockerfile。
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个写法的问题非常典型:
- 构建环境和运行环境混在一起
npm install、构建工具链、源码、缓存都进入最终镜像。
- 缓存利用率低
COPY . .太早,只要代码有一点变化,依赖层就失效。
- 不区分生产依赖和开发依赖
- 测试包、TypeScript、打包器都进了生产镜像。
- 攻击面大
- 默认 root 用户运行,基础镜像较大,系统包过多。
- 上下文污染
- 如果没有
.dockerignore,node_modules、日志、.git都可能被带进构建上下文。
- 如果没有
这些问题放在本地也许只是“有点慢”,但一旦进 CI/CD 和生产环境,代价会被放大:
- 推镜像慢
- 拉镜像慢
- 漏洞数量多
- 回滚耗时变长
- 磁盘和仓库成本上升
前置知识与环境准备
建议你本地准备以下环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个可运行的 Node.js 示例项目
启用 BuildKit:
export DOCKER_BUILDKIT=1
如果你用的是 Docker Desktop,一般默认已开启。
假设我们的示例项目结构如下:
demo-app/
├── src/
│ └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例 package.json:
{
"name": "demo-app",
"version": "1.0.0",
"scripts": {
"build": "mkdir -p dist && cp -r src/* dist/",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
示例 src/index.js:
const express = require('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://0.0.0.0:${port}`);
});
核心原理
1. 多阶段构建到底解决了什么
多阶段构建的核心思想很简单:
- 前面的阶段负责“造东西”
- 最后的阶段只负责“运行东西”
比如:
- 构建阶段:安装依赖、编译、打包
- 运行阶段:只复制构建产物和最少运行依赖
这样最终镜像就不必包含:
- 编译器
- 调试工具
- 源码
- 缓存目录
- 开发依赖
下面这张图可以直观看出差异:
flowchart LR
A[源码与依赖清单] --> B[builder阶段<br/>安装依赖/编译/打包]
B --> C[产物 dist/]
C --> D[runtime阶段<br/>仅复制运行所需文件]
D --> E[最终生产镜像]
2. Docker 缓存为什么经常失效
Docker 是按层构建的。每一条指令都会形成一层。
如果上层内容变了,后面的层通常都要重建。
所以这两种写法差别很大:
低效写法:
COPY . .
RUN npm install
只要源码有变化,npm install 就会重跑。
更优写法:
COPY package*.json ./
RUN npm ci
COPY . .
只有依赖描述文件变化时,依赖安装层才会失效。
缓存命中流程可以理解成:
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker构建器
participant Cache as 本地/远程缓存
Dev->>Docker: docker build
Docker->>Cache: 检查 COPY package*.json 是否变化
Cache-->>Docker: 未变化,可复用依赖层
Docker->>Cache: 检查 COPY src 是否变化
Cache-->>Docker: 已变化,重建后续层
Docker-->>Dev: 输出新镜像
3. 瘦身不只是“小”,更是“少暴露”
镜像瘦身常常被理解成“减少 MB 数量”,但我更建议你把它理解成:
- 减少无用依赖
- 减少攻击面
- 减少漏洞数量
- 减少分发成本
越少的内容,通常意味着越少的风险。
实战代码(可运行)
下面我们从“普通写法”升级到“可用于生产的中级写法”。
第一步:先写好 .dockerignore
这是最容易忽略、但收益很高的一步。
node_modules
npm-debug.log
Dockerfile*
.dockerignore
.git
.gitignore
coverage
dist
.env
*.md
作用:
- 避免把无关文件发给 Docker daemon
- 减少构建上下文大小
- 提升构建速度
- 避免本地
node_modules污染镜像
你可以先执行:
docker build -t demo-app:naive .
观察构建日志中的上下文大小。如果上下文很大,通常就是 .dockerignore 不够干净。
第二步:先看一个“单阶段但较规范”的版本
FROM node:18-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
比最原始写法已经好不少,但仍然有问题:
- 构建命令依然在最终镜像里执行过
- 源码和构建依赖仍可能残留
- 如果构建阶段需要开发依赖,这里会比较尴尬
第三步:多阶段构建正式版
这是更推荐的方案。
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:18-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本做了什么
deps 阶段
只做一件事:安装完整依赖。
这样依赖安装和源码变更解耦,缓存更稳定。
builder 阶段
复制依赖和源码,执行 npm run build。
如果你的项目需要 TypeScript、Webpack、Vite,这个阶段很合适。
runtime 阶段
最终只保留:
- 生产依赖
- 构建产物
- 运行命令
而且切到了非 root 用户 node。
第四步:构建与运行验证
构建镜像:
docker build -t demo-app:multi .
运行容器:
docker run --rm -p 3000:3000 demo-app:multi
验证服务:
curl http://localhost:3000
预期输出:
{"message":"hello docker multi-stage build","time":"2023-08-28T01:15:21.000Z"}
第五步:进一步加速构建 —— 利用 BuildKit 缓存挂载
如果你的 CI 构建频繁,依赖安装会非常耗时。
这时可以结合 BuildKit 的缓存挂载。
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个优化在本地和 CI 中都很实用,尤其是依赖多、网络波动大的场景。
逐步验证清单
如果你想确认“多阶段构建真的生效了”,可以按这个顺序检查。
1. 看镜像体积
docker images | grep demo-app
重点比较:
demo-app:naivedemo-app:multi
2. 看构建历史层
docker history demo-app:multi
你会看到最终镜像不再包含某些构建动作的完整上下文。
3. 进入容器查看内容
docker run --rm -it demo-app:multi sh
检查是否只剩必要文件:
ls -lah
理想情况下一般只有:
distpackage.jsonpackage-lock.jsonnode_modules
不应该看到:
src.git- 测试文件
- 本地日志
4. 验证运行身份
id
如果输出用户不是 root,说明非 root 运行已生效。
常见坑与排查
这一部分很重要。多阶段构建本身不难,难的是“为什么我照着写了还是不对”。
坑 1:COPY . . 太早,缓存全废
现象:
- 改一行业务代码,
npm ci重新执行 - CI 构建时间没明显下降
错误示例:
COPY . .
RUN npm ci
修正方式:
COPY package*.json ./
RUN npm ci
COPY . .
排查思路:
- 看 Docker 构建日志有没有
CACHED - 改一个源码文件,再构建一次,观察依赖层是否复用
坑 2:本地 node_modules 把镜像搞脏了
现象:
- 容器里依赖异常
- 本地能跑,容器里报错
- 出现平台相关问题,比如 Mac 本地装的包拿到 Linux 容器里失效
根因:
没有写 .dockerignore,把本地 node_modules 带进去了。
排查命令:
docker build --no-cache -t demo-app:debug .
如果 COPY . . 步骤上传上下文很大,通常就是这个问题。
坑 3:构建阶段能过,运行阶段启动失败
典型报错:
Error: Cannot find module 'xxx'
原因之一:
你在 builder 阶段用了完整依赖构建,但在 runtime 阶段只装了生产依赖,而应用实际运行时还在引用开发依赖。
排查方法:
- 检查
dependencies和devDependencies是否分错 - 不要把真正运行时依赖放到
devDependencies
坑 4:切换非 root 用户后权限出错
现象:
- 容器启动时报权限问题
- 写日志、写临时文件失败
原因:
复制文件后所属用户还是 root,而运行用户变成了 node。
解决办法:
可以在复制时设置拥有者:
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node package*.json ./
如果你需要写某些目录,也要提前创建并授权。
坑 5:alpine 不是永远更好
很多人看到“瘦身”就立刻上 alpine,但这不是银弹。
可能的问题:
- 某些原生依赖编译更麻烦
- 基于
musl而不是glibc - 调试复杂度增加
经验建议:
- 想要一个兼顾兼容性与体积的默认选项,可以优先考虑
*-slim - 明确知道依赖兼容性后,再考虑
alpine
安全/性能最佳实践
这一部分我会按“能马上落地”的方式来讲。
1. 生产镜像尽量使用更小、职责更单一的基础镜像
常见选择:
node:18:全量,方便但偏大node:18-slim:更平衡,通常够用- distroless:更极致,但调试门槛更高
建议中级开发者的默认策略:
- 先用
slim - 有明确收益再上更极致方案
2. 永远不要默认用 root 跑业务
至少做到:
USER node
更进一步:
- 业务目录设定正确权限
- 挂载卷时注意宿主机权限映射
容器不是虚拟机,root 一旦被拿到,风险不小。
3. 显式区分开发依赖和生产依赖
Node 项目里建议:
RUN npm ci --omit=dev
好处:
- 减少镜像体积
- 降低漏洞数量
- 降低启动时依赖扫描负担
4. 合理排序 Dockerfile 指令
一个实用原则:
- 最稳定的层放前面
- 最常变化的层放后面
典型顺序:
基础镜像
-> 系统依赖
-> 依赖清单
-> 安装依赖
-> 复制源码
-> 编译构建
-> 设置运行参数
这条原则对缓存命中率影响非常大。
5. 清理缓存,但别过度“手工打扫”
例如:
RUN npm ci --omit=dev && npm cache clean --force
但如果你已经在多阶段构建里把构建垃圾留在前一阶段,很多时候不需要写一堆复杂的清理命令。
多阶段构建本身,就是最有效的清理方式。
6. 做漏洞扫描,但要理解结果
常用命令:
docker scan demo-app:multi
或者接入企业里的镜像扫描平台。
注意:
- 漏洞数量不是唯一指标
- 要看是否可利用、是否在运行路径上
- 优先修复高危且可被触达的问题
7. 为容器增加基础运行约束
运行时可加入:
docker run \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
-p 3000:3000 \
demo-app:multi
这几个参数的意义:
--read-only:根文件系统只读--tmpfs /tmp:给临时目录可写空间--cap-drop ALL:去掉多余 Linux capabilities
当然,这有边界条件:
如果你的应用确实要写文件、生成缓存、落盘日志,就要针对性开放,而不是硬套。
一个更完整的生产示例
如果你想把“权限、缓存、运行镜像”一起纳入考虑,可以参考这个版本:
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force \
&& chown -R node:node /app
COPY --chown=node:node --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
它背后的结构可以总结成:
flowchart TD
A[package.json/package-lock.json] --> B[deps阶段 npm ci]
B --> C[builder阶段 复制源码]
C --> D[执行 npm run build]
D --> E[runtime阶段 安装生产依赖]
E --> F[复制 dist]
F --> G[非root启动应用]
方案取舍:什么时候值得“更狠地瘦身”
中级开发者常见困惑不是“会不会写多阶段”,而是“要不要继续优化到极致”。
我给一个比较务实的判断标准:
适合继续深挖的情况
- CI 时间已经影响团队效率
- 镜像分发频繁,带宽成本明显
- 漏洞扫描压力大
- 服务数量多,镜像规范急需统一
不必过度优化的情况
- 小型内部工具,更新频率低
- 团队对 Docker 调试能力还比较弱
- 当前瓶颈根本不在镜像构建
也就是说,优化要服务业务,不要为了“镜像再小 30MB”把可维护性搞没了。
总结
如果你只记住这篇文章里的 5 件事,我建议是这 5 条:
- 一定用多阶段构建
- 构建和运行分离,是镜像治理的起点。
- 先复制依赖清单,再安装依赖
- 这是提升缓存命中率最直接的办法。
- 写好
.dockerignore- 很多“镜像莫名其妙变大”的根因就在这里。
- 生产镜像只保留运行必要内容
- 产物、生产依赖、启动命令,够了就行。
- 默认非 root 运行
- 这是最基础、最划算的安全基线。
如果你准备在团队里落地,我建议按下面的顺序推进,不容易翻车:
- 第一步:统一
.dockerignore - 第二步:所有服务改成多阶段构建
- 第三步:切换到
slim类基础镜像 - 第四步:默认使用非 root 用户
- 第五步:为 CI 增加 BuildKit 缓存和镜像扫描
最后给一句很实在的话:
镜像瘦身不是“美化 Dockerfile”,而是把构建效率、发布成本和安全基线一起往前推。
你不一定要一上来做到极致,但至少要先把“构建环境”和“运行环境”分开。这一步,通常就已经能解决 70% 的问题。