Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建加速、体积优化与安全基线配置
很多团队刚开始用 Docker 时,镜像能跑就算成功。可项目一旦进入持续集成、灰度发布、跨环境交付,你很快会遇到几个典型问题:
- 镜像越来越大:几百 MB 到几个 GB,拉取慢、分发慢、部署慢
- 构建越来越慢:代码改一行也要重新装一遍依赖
- 镜像里东西太多:编译工具、缓存、测试文件、密钥、包管理器都打进去了
- 安全基线薄弱:直接 root 运行、基础镜像过大、攻击面不清晰
这篇文章我不打算只讲概念,而是带你从“一个普通可运行 Dockerfile”,一步步优化到更适合生产环境的版本。重点放在三件事:
- 用多阶段构建拆分构建期与运行期
- 用缓存和层设计提升构建速度
- 建立最基本可执行的镜像安全基线
本文面向有一定 Docker 使用经验的开发者。如果你已经会写基本的 Dockerfile、知道 docker build 和 docker run,那就可以直接往下看。
背景与问题
先说一个很常见的真实场景。一个 Node.js 服务最开始的 Dockerfile 往往长这样:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题其实不少:
COPY . .太早,导致代码一变,依赖缓存失效npm install会安装开发依赖,运行时其实不需要- 构建工具链和源码都保留在最终镜像中
- 基础镜像较大
- 默认以 root 身份运行
.git、测试文件、文档、构建缓存等可能都被打进镜像
结果就是:构建慢、镜像大、风险高。
我们先看一下优化目标。
flowchart TD
A[原始 Dockerfile] --> B[分离依赖与源码复制顺序]
B --> C[启用多阶段构建]
C --> D[仅复制运行产物]
D --> E[使用更小基础镜像]
E --> F[非 root 运行]
F --> G[减少攻击面与体积]
对中级开发者来说,真正重要的不是“记住几条优化口诀”,而是理解背后的构建原理。理解了之后,不只是 Node.js,Go、Java、Python、前端静态站点都能套用。
前置知识与环境准备
建议你的环境至少满足以下条件:
- Docker 20.10+
- 建议启用 BuildKit
- 一台可联网机器,能拉取基础镜像
- 示例项目使用 Node.js 18
启用 BuildKit 的方式:
export DOCKER_BUILDKIT=1
或者临时构建时加:
DOCKER_BUILDKIT=1 docker build -t demo-app .
为什么建议开启 BuildKit?因为它在缓存复用、挂载缓存目录、多阶段构建体验上都更好,实际构建速度通常能明显提升。
核心原理
1. Docker 镜像为什么会变大
Docker 镜像本质上是分层文件系统。每一条 RUN、COPY、ADD 大概率都会生成一层。层不是简单覆盖,而是叠加。
这意味着:
- 你安装了很多工具,后面删掉,也不一定真正“变小”
- 如果把大量无关文件
COPY进去,即使后面删除,它们也可能已经进入镜像层历史 - Dockerfile 指令顺序直接影响缓存命中率
2. 多阶段构建解决什么问题
多阶段构建的核心思想是:
在前面的阶段完成编译、打包、测试;在最后的阶段只保留运行所需的最小产物。
比如:
- builder 阶段:安装依赖、编译源码
- runtime 阶段:只复制
dist/和生产依赖,甚至只复制单个二进制文件
这样能带来三个直接收益:
- 镜像更小
- 攻击面更小
- 构建职责更清晰
3. 缓存命中为什么重要
Docker 构建不是每次都从零开始。它会根据指令和上下文判断能否复用缓存。
最典型的优化是:
- 先复制依赖清单
- 安装依赖
- 再复制业务代码
因为日常开发中,代码变化频率远高于依赖变化频率。
如果顺序写反了,缓存就很难命中。
4. 运行时镜像不该承担构建职责
这是我自己早期最容易忽略的一点:运行镜像只应该负责运行。
也就是说,最终镜像中通常不应该存在:
- gcc、make、git、curl 之类构建工具
- 测试文件
- 源码(如果运行只需要编译产物)
- 包管理器缓存
- 调试工具
- 明文密钥
一张图看懂多阶段构建
sequenceDiagram
participant Dev as 开发者
participant Builder as builder 阶段
participant Runtime as runtime 阶段
Dev->>Builder: 复制 package*.json
Builder->>Builder: 安装依赖
Dev->>Builder: 复制源码
Builder->>Builder: 执行构建 npm run build
Builder->>Runtime: 复制 dist/ 与生产依赖
Runtime->>Runtime: 以非 root 用户启动
实战代码(可运行)
下面我们用一个简单的 Node.js Web 服务做演示。你可以直接照着创建文件并运行。
目录结构
demo-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── src/
└── index.js
第一步:准备一个最小可运行服务
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "dist/index.js",
"scripts": {
"build": "mkdir -p dist && cp src/index.js dist/index.js",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
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',
hostname: process.env.HOSTNAME || 'unknown'
});
});
app.get('/health', (req, res) => {
res.status(200).send('ok');
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
先在本地生成锁文件:
npm install
第二步:先看一个“能跑但不优”的版本
FROM node:18
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://127.0.0.1:3000/
虽然能跑,但这个版本适合作为对照组,不适合作为生产基线。
第三步:改成多阶段构建
下面是一个更推荐的版本。
Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本做了几件事:
- 使用
builder阶段构建产物 - 最终镜像只复制
dist - 用
npm ci保证依赖安装更稳定 - 使用缓存挂载加速 npm 包下载
- 设置
NODE_ENV=production - 创建非 root 用户运行服务
第四步:编写 .dockerignore
这是很多人会漏掉的关键文件。它对构建速度和体积都很有帮助。
.dockerignore
node_modules
npm-debug.log
dist
.git
.gitignore
Dockerfile
README.md
*.md
coverage
.vscode
.env
.env.*
为什么重要?
因为 Docker 构建时会先把构建上下文发送给 Docker daemon。你目录里文件越多,构建上下文越大,上传越慢,也更容易把不该打包的内容带进去。
第五步:构建并验证
构建镜像:
DOCKER_BUILDKIT=1 docker build -t demo-app:latest .
运行镜像:
docker run --rm -p 3000:3000 demo-app:latest
验证接口:
curl http://127.0.0.1:3000/
curl http://127.0.0.1:3000/health
查看镜像大小:
docker images | grep demo-app
查看镜像层历史:
docker history demo-app:latest
如果你同时构建 demo-app:bad 和 demo-app:latest,通常能明显看到优化版镜像更小、层更干净。
逐步验证清单
你可以按下面顺序验证这套优化是否真的生效:
- 服务能正常启动
- 接口返回正常
- 镜像大小下降
- 修改源码后重建,依赖层是否命中缓存
- 容器内是否不存在构建源码或无关文件
- 容器是否以非 root 运行
- 健康检查接口是否可用于编排系统探活
例如检查运行用户:
docker run --rm demo-app:latest id
如果输出的不是 root,说明配置生效。
也可以进入容器查看内容:
docker run --rm -it demo-app:latest sh
然后检查 /app 下只有运行必需文件。
核心优化点拆解
1. npm ci 优于 npm install
在 CI/CD 或镜像构建场景里,优先考虑:
npm ci
原因:
- 更适合基于锁文件的可重复构建
- 行为更稳定
- 通常更快
2. 先复制依赖清单,再复制源码
推荐:
COPY package*.json ./
RUN npm ci
COPY src ./src
不推荐:
COPY . .
RUN npm ci
前者只要 package-lock.json 没变,依赖安装层就可以复用缓存。
3. builder 和 runtime 分离
如果你在 builder 阶段用了很多工具,没关系。只要最终阶段不复制进去,它们就不会进入最终运行镜像。
4. 尽量选择合适的基础镜像
常见选择:
node:18:通用,体积偏大node:18-alpine:更小,适合大部分场景- distroless:更小更安全,但调试成本更高
边界条件也要说清楚:
不是所有应用都适合 Alpine。某些依赖会遇到 musl 与 glibc 兼容问题,尤其是原生扩展或特定二进制依赖。如果你碰到诡异运行错误,先排查基础镜像兼容性。
常见坑与排查
这部分我尽量写得“像在旁边陪你排查”,因为多阶段构建第一次上手时,坑点还挺集中。
坑 1:构建阶段能成功,运行阶段启动失败
典型现象:
Error: Cannot find module ...- 启动时找不到编译产物
排查思路:
- 检查 builder 阶段产物路径是否正确
- 检查
COPY --from=builder路径是否写对 - 检查最终
CMD指向的文件是否存在
例如:
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
可以进入最终镜像验证:
docker run --rm -it demo-app:latest sh
ls -R /app
坑 2:缓存完全没命中,构建总是很慢
最常见原因:
- 提前
COPY . . .dockerignore没写,导致上下文频繁变化- 锁文件经常变动
- BuildKit 没启用
可以看构建日志里每一层是否 CACHED。如果依赖层反复重建,先检查 Dockerfile 指令顺序。
坑 3:镜像已经用了多阶段,为什么还是很大
常见原因:
- 运行阶段又重新安装了很多不必要依赖
- 最终阶段仍然基于很大的基础镜像
- 静态资源、模型文件、日志文件被复制进来了
.dockerignore缺失
可以用下面命令观察:
docker history demo-app:latest
如果某一层异常大,基本就能定位到问题指令。
坑 4:容器里执行权限异常
如果你切换到非 root 用户,可能遇到:
- 无法读取某些文件
- 无法写日志目录
- 端口绑定失败
排查建议:
- 检查文件所有权
- 检查应用是否尝试写入只读目录
- 避免绑定 1024 以下端口
- 必要时在构建阶段调整权限
例如:
RUN chown -R appuser:appgroup /app
USER appuser
不过也别一上来就全量 chmod 777,那只是把问题藏起来。
坑 5:Alpine 很小,但某些依赖装不上
这是多阶段构建中非常现实的问题。你会看到:
- 原生模块编译失败
- 二进制库缺失
- 运行时报动态链接错误
解决思路:
- 构建阶段用 Debian/Ubuntu 系镜像,运行阶段再选更小镜像
- 如果兼容性差,别强行 Alpine,直接用
slim - 对原生依赖明确安装所需系统库
有时候,“稍大一点但稳定”比“极限压缩但脆弱”更适合生产。
安全/性能最佳实践
这一节给你一套比较务实的基线,不追求教科书式完美,但足够大部分项目落地。
1. 使用非 root 用户运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
收益:
- 降低容器逃逸或应用漏洞带来的破坏面
- 满足很多安全审计的基础要求
2. 固定基础镜像版本
不建议:
FROM node:latest
建议:
FROM node:18-alpine
更进一步可以固定到更具体版本,减少不可预期变更。
3. 减少镜像中无关内容
- 用
.dockerignore - 只复制必要目录
- 不要把
.env、私钥、Git 历史带进去
如果有敏感信息,应该通过运行时注入,而不是写进镜像。
4. 只安装生产依赖
RUN npm ci --omit=dev
如果运行时不需要开发依赖,就不要带进去。
5. 合理合并命令,但不要为“层数洁癖”牺牲可维护性
很多人喜欢把所有命令写成一行。确实能少几层,但可读性会下降。我的建议是:
- 对清理缓存、安装系统包这类强耦合操作可合并
- 对业务逻辑步骤尽量保持清晰
比如:
RUN apk add --no-cache curl
就比“安装后再删缓存”的写法更自然。
6. 增加健康检查
可以在编排系统外,也做一层容器自检。
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/health || exit 1
不过注意:
如果你的基础镜像里没有 wget 或 curl,这条命令会失败。要么安装工具,要么使用应用内部机制配合平台探活。
7. 用扫描工具做镜像安全检查
常见工具如:
- Trivy
- Docker Scout
例如用 Trivy 扫描:
trivy image demo-app:latest
这一步很重要。因为“镜像小”不等于“镜像安全”。
8. 利用缓存挂载加速构建
BuildKit 下可以这样写:
RUN --mount=type=cache,target=/root/.npm npm ci
适合频繁构建的 CI 环境,尤其是依赖下载成本高时。
进阶:构建流程推荐模板
如果你想把这套方法推广到团队,我建议按下面思路统一:
flowchart LR
A[源码提交] --> B[CI 构建]
B --> C[builder 阶段编译]
C --> D[runtime 阶段最小化打包]
D --> E[镜像扫描]
E --> F[推送仓库]
F --> G[部署]
比较实用的一条流水线顺序是:
- 拉取代码
- 构建多阶段镜像
- 执行单元测试/构建校验
- 扫描镜像漏洞
- 打 tag
- 推送仓库
- 部署
这样镜像优化和安全基线就不再依赖“某个同事比较认真”,而是被流程固化。
一个更适合生产的示例 Dockerfile
如果你想直接拿去做项目基线,可以参考下面这个版本:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
&& chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
如果你的服务只输出静态文件,比如 React/Vue 打包产物,还可以把运行阶段换成 Nginx 或更轻量的静态文件服务器镜像,体积通常还能继续下降。
方案取舍:什么时候不必极限瘦身
这点我想特别提醒一下。镜像优化不是越狠越好,要看场景。
适合极致瘦身的场景
- Serverless / 弹性伸缩频繁
- 边缘节点分发
- 带宽敏感环境
- 大量微服务并行部署
不必过度追求极限的场景
- 内部低频部署系统
- 调试优先的开发环境
- 依赖复杂、兼容性要求高的业务
比如你为了省几十 MB,换到一个调试极难的基础镜像,结果线上定位问题成本大增,这就未必划算。
我的经验是:
先做到“结构正确”,再追求“极限压缩”。
也就是先完成多阶段、缓存优化、非 root、依赖最小化,然后再考虑 distroless、scratch 之类更激进的方案。
总结
把 Docker 镜像做小、做快、做安全,核心不是堆技巧,而是建立一套稳定思路:
- 多阶段构建:构建和运行分离
- 缓存友好:先复制依赖清单,再复制源码
- 最小运行时:只保留运行所需内容
- 安全基线:非 root、固定版本、避免敏感文件入镜像
- 持续验证:用
docker history、容器内检查、漏洞扫描工具确认结果
如果你现在手里有一个“能跑但很胖”的 Dockerfile,我建议你按下面顺序改:
- 补
.dockerignore - 调整
COPY顺序 - 改成多阶段构建
- 最终镜像只保留运行产物
- 加非 root 用户
- 引入镜像扫描
做到这一步,通常已经能解决大多数团队在镜像体积、构建速度和基础安全上的问题。
别一上来追求“最小镜像宇宙冠军”,先把工程化基线落地。很多时候,这比再省 20MB 更有价值。