Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全落地
很多团队开始用 Docker 时,往往先把“能跑起来”放在第一位:
FROM ubuntu、装一堆工具、复制整个项目、直接 docker build。短期看没问题,但一到 CI/CD、生产发布、漏洞扫描、镜像分发阶段,问题就会一起冒出来:
- 镜像体积大,拉取慢,构建也慢
- 依赖混乱,编译工具和运行环境混在一起
- 安全面暴露太多,基础镜像臃肿、攻击面大
- 构建缓存命中率低,每次改一行代码都要重来
- 本地能跑,线上却因为缺少动态库、权限不对而翻车
这篇文章我会带你从**“为什么镜像会又大又慢”讲到“如何用多阶段构建把它拆干净”,最后落到生产环境可用的安全与性能实践**。内容偏实战,示例可以直接跑。
背景与问题
先看一个很常见、但不太理想的 Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题并不少:
COPY . .太早- 只要项目里任意文件变动,后续
npm install缓存就容易失效。
- 只要项目里任意文件变动,后续
- 构建依赖和运行依赖混在一起
npm install可能带上 devDependencies。- 打包工具、源码、测试文件都进了生产镜像。
- 基础镜像偏大
node:18默认不是最轻量的运行时。
- 安全面较大
- 默认 root 用户运行。
- 不必要工具都在镜像里。
- 镜像分发成本高
- 对 CI、边缘节点、跨地域部署都不友好。
如果你的服务每天发布几次,或者公司内部有几十上百个微服务,这些问题都会被放大。
前置知识与环境准备
建议你具备下面这些基础:
- 会写基础 Dockerfile
- 知道镜像、容器、层(layer)的基本概念
- 机器已安装:
- Docker 20+
- 推荐启用 BuildKit
开启 BuildKit 的方式:
export DOCKER_BUILDKIT=1
或在构建时显式指定:
DOCKER_BUILDKIT=1 docker build -t demo-app .
核心原理
多阶段构建(Multi-stage Build)的核心思想,其实很朴素:
把“编译/打包”阶段和“运行”阶段分开,最后只把运行所需的最小产物带进最终镜像。
1. 为什么它能瘦身
因为很多构建工具在生产运行时根本不需要,比如:
- gcc、make、python
- npm/yarn/pnpm 的完整缓存
- TypeScript 源码
- 测试文件
.git- 打包中间产物
传统单阶段镜像会把这些都装进去,多阶段构建可以把它们留在前面的阶段,最终镜像不继承这些“历史包袱”。
2. 为什么它能加速
Docker 构建本质上依赖层缓存。
如果你把“变化少的步骤”放前面,把“变化快的步骤”放后面,缓存命中率就会高很多。
典型优化顺序:
- 复制依赖描述文件
- 安装依赖
- 复制业务源码
- 构建产物
这样平时只改业务代码时,依赖安装层可以复用。
3. 为什么它更安全
最终生产镜像里:
- 没有编译器
- 没有 shell(某些极简镜像中)
- 没有调试工具
- 没有源码和敏感文件
- 可以切换为非 root 用户运行
攻击面自然就小很多。
一张图看懂单阶段与多阶段差异
flowchart LR
A[源码] --> B[单阶段 Dockerfile]
B --> C[安装依赖]
C --> D[构建应用]
D --> E[最终镜像]
E --> E1[包含源码]
E --> E2[包含构建工具]
E --> E3[包含 dev 依赖]
A --> F[多阶段 Dockerfile]
F --> G[builder 阶段]
G --> H[安装依赖并构建]
H --> I[runner 阶段]
I --> J[仅复制运行产物]
J --> J1[更小]
J --> J2[更快]
J --> J3[更安全]
实战场景:以 Node.js Web 服务为例
为了把过程讲清楚,我们用一个最常见的场景:
一个 Express 应用,构建阶段需要安装完整依赖并生成 dist/,运行阶段只需要最少文件。
项目结构
demo-app/
├── src/
│ └── server.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例代码
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "dist/server.js",
"scripts": {
"build": "mkdir -p dist && cp src/server.js dist/server.js",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
src/server.js
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'hello from multi-stage docker build',
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
第一步:先写一个“能用但不优”的版本
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
构建:
docker build -t demo-app:fat .
运行:
docker run --rm -p 3000:3000 demo-app:fat
验证:
curl http://localhost:3000
虽然能跑,但这个镜像往往偏大,而且构建缓存效果也一般。
第二步:改造成多阶段构建
下面这个版本,才是更适合生产落地的思路。
生产可用的 Dockerfile
# syntax=docker/dockerfile:1
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS builder
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个 Dockerfile 做了几件关键的事:
deps阶段只负责安装依赖builder阶段复制源码并构建产物runner阶段只安装生产依赖,并复制dist- 最终使用
USER node,不以 root 运行
一张图看懂构建阶段流转
flowchart TD
A[base] --> B[deps]
B --> C[复制 package.json / lock 文件]
C --> D[npm ci]
D --> E[builder]
E --> F[复制 src]
F --> G[npm run build]
G --> H[runner]
H --> I[npm ci --omit=dev]
I --> J[从 builder 复制 dist]
J --> K[非 root 用户启动]
第三步:补上 .dockerignore
这个文件我非常建议认真写。
很多镜像莫名变大、构建莫名变慢,根源就是上下文(build context)传了太多没用的文件。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env
.env.*
这样做的好处:
- 减少发送给 Docker daemon 的文件量
- 避免本地
node_modules污染容器构建 - 避免敏感文件误入镜像
我自己就踩过一次坑:本地 .env 带着测试库地址,被 COPY . . 顺手打进镜像,结果预发环境连错库。后来我基本都会先检查 .dockerignore。
第四步:构建、运行与验证清单
构建镜像
docker build -t demo-app:multi .
启动容器
docker run --rm -p 3000:3000 demo-app:multi
访问服务
curl http://localhost:3000
返回示例:
{"message":"hello from multi-stage docker build","time":"2024-01-01T00:00:00.000Z"}
查看镜像层历史
docker history demo-app:multi
查看镜像大小
docker images | grep demo-app
你通常会看到多阶段版本比“胖镜像”明显更精简。
构建缓存优化:不仅瘦,还要快
除了多阶段,真正影响日常体验的还有缓存设计。
推荐的层顺序
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
RUN npm run build
这里的关键点是:
- 依赖文件先复制
- 安装依赖先执行
- 源码后复制
这样当你只改 src/server.js 时,不需要重新执行 npm ci。
使用 BuildKit 缓存挂载
如果你在 CI 或本地频繁构建,BuildKit 缓存会更香:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
构建时:
DOCKER_BUILDKIT=1 docker build -t demo-app:cache .
这会显著减少重复下载依赖的时间,尤其网络一般时特别明显。
常见坑与排查
这一部分很重要。多阶段构建不是写完就万事大吉,线上翻车常常出在细节上。
1. 运行阶段缺少动态库
现象:
- 容器启动时报错
- 比如某些 Node 原生模块、Python 包、Java JNI 依赖,在 Alpine 上不兼容
常见报错类似:
Error: libstdc++.so.6: cannot open shared object file
排查思路:
- 看最终镜像是不是从
alpine切过去了 - 看依赖是否包含原生编译模块
- 在 builder 和 runner 的系统环境是否一致
处理建议:
- 如果有复杂原生依赖,不要盲目追求最小镜像
- 可以考虑
debian-slim,兼容性通常更稳
2. COPY --from=builder 路径写错
现象:
COPY failed: stat ... no such file or directory
排查要点:
- builder 阶段里产物实际生成在哪
WORKDIR是否一致npm run build是否真的输出了dist/
建议直接在 builder 阶段临时加一句调试:
RUN ls -R /app
虽然有点“土”,但非常有效。
3. 切换非 root 用户后权限报错
现象:
EACCES: permission denied
原因通常是:
- 拷贝进来的文件归属是 root
- 应用运行时还要写某些目录
处理方式:
COPY --from=builder /app/dist ./dist
RUN chown -R node:node /app
USER node
如果目录很多,也可以在 COPY 时直接指定所有者:
COPY --chown=node:node --from=builder /app/dist ./dist
4. npm install 与 npm ci 混用导致结果不一致
经验上,生产构建更推荐:
npm ci
原因:
- 更适合 CI/CD
- 基于 lock 文件,结果更可预测
- 通常比
npm install更稳定
边界条件:
- 前提是你有可靠的
package-lock.json
5. .dockerignore 没配好,导致缓存频繁失效
例如你把下面这些带进上下文:
.gitcoverage- 本地构建产物
- 编辑器临时文件
任何变动都可能让 COPY . . 的哈希变化,从而拖垮缓存命中率。
安全最佳实践
镜像瘦身和安全,经常是同一件事的两面。
1. 使用更小但合适的基础镜像
建议优先级不是“越小越好”,而是:
满足兼容性的前提下,尽量小。
常见选择:
alpine:小,但某些原生依赖兼容性要注意debian-slim:比完整版小,兼容性更稳- distroless:更安全更纯净,但调试不方便
2. 不要把构建工具带进生产镜像
最终镜像只保留:
- 可执行程序
- 运行时依赖
- 配置所需目录
不要保留:
- gcc / make
- git / curl(除非业务确实需要)
- 测试脚本
- 源码(如果只需编译产物)
3. 使用非 root 用户
USER node
或者对其他语言镜像,显式创建用户:
RUN addgroup -S app && adduser -S app -G app
USER app
4. 不把密钥写进镜像
不要这样做:
ENV ACCESS_KEY=xxxxx
ENV DB_PASSWORD=xxxxx
更好的方式:
- 运行时通过环境变量注入
- 使用 Docker secrets / K8s Secret / 外部密钥管理系统
5. 固定基础镜像版本
不要长期依赖这种写法:
FROM node:latest
建议固定版本:
FROM node:18-alpine
更严格一点,可以固定 digest。这样可重复性更好,也方便漏洞回溯。
性能最佳实践
1. 减少无效层
例如下面这种写法层数多、缓存也不一定友好:
RUN apk update
RUN apk add curl
RUN rm -rf /var/cache/apk/*
更推荐合并:
RUN apk add --no-cache curl
2. 依赖安装与业务代码分层
这是最实用的一条,直接影响日常构建速度。
3. 在 CI 中复用构建缓存
例如:
- GitHub Actions cache
- Docker buildx cache
- 私有镜像仓库缓存层
4. 尽量缩小构建上下文
除了 .dockerignore,还要避免在仓库根目录塞太多无关内容。
生产环境落地建议
如果你准备把这套方式推广到团队,我建议按下面顺序推进。
建议一:先统一 Dockerfile 模板
不同服务可按语言做模板化:
- Node.js 模板
- Go 模板
- Java 模板
- Python 模板
这样团队不会每个人都“自由发挥”。
建议二:把检查项放进 CI
可以自动校验:
- 是否使用多阶段构建
- 是否存在
latest - 是否使用非 root 用户
- 是否镜像过大
- 是否通过漏洞扫描
建议三:设定镜像体积阈值
比如:
- Node API 服务目标小于 200MB
- Go 静态编译服务目标小于 50MB
不是绝对值,但有阈值团队才会持续优化。
一个更贴近生产的流程图
sequenceDiagram
participant Dev as 开发者
participant CI as CI流水线
participant Builder as 构建阶段
participant Registry as 镜像仓库
participant Prod as 生产环境
Dev->>CI: 提交代码
CI->>Builder: docker build
Builder->>Builder: 安装依赖/编译产物
Builder->>CI: 输出最小运行镜像
CI->>CI: 漏洞扫描/策略检查
CI->>Registry: 推送镜像
Registry->>Prod: 拉取镜像
Prod->>Prod: 非 root 启动服务
适用边界:不是所有场景都要极限瘦身
这一点我想特别提醒。
多阶段构建几乎都值得用,但“极限瘦身”不一定总是最优解。比如:
- 你依赖很多原生库,
alpine可能带来更多兼容性问题 - 你需要在线 debug,distroless 可能不方便
- 你的构建产物本身就大,例如包含模型文件、前端静态资源、字体包
所以正确思路不是“越小越先进”,而是:
在可维护、可调试、可兼容的前提下,尽量小。
总结
把这篇文章的重点浓缩成几句可执行的话:
- 一定要用多阶段构建
- 构建环境和运行环境分离,是镜像治理的起点。
- 先复制依赖描述文件,再安装依赖
- 这是提升缓存命中率最直接的方法。
- 认真写
.dockerignore- 很多“镜像大”“构建慢”其实是上下文污染。
- 生产镜像只保留运行必需内容
- 不带源码、不带编译器、不带 dev 依赖。
- 非 root 运行,固定基础镜像版本
- 这是安全落地的基本盘。
- 不要盲目追求最小
- 优先保证兼容性、稳定性和可维护性。
如果你现在维护的 Dockerfile 还是“一个阶段装到底”,最值得做的第一步不是大改架构,而是先把一个服务拆成 builder + runner 两段。通常只这一改,构建速度、镜像体积和安全性就会一起变好。