Docker 多阶段构建与镜像瘦身实战:为中型项目建立高效、可维护的生产镜像
中型项目做容器化,最容易走到一个尴尬阶段:能跑,但镜像又大又慢;能发版,但一查漏洞一堆;开发方便,生产却不够干净。
我自己第一次给团队整理 Dockerfile 的时候,最典型的问题就是:
- 一个镜像里把编译工具、源码、测试脚本、调试命令全塞进去了
- 镜像体积从几百 MB 到 1GB+,拉取很慢
- CI 构建越来越久,缓存命中率也不稳定
- 线上镜像里居然还有
gcc、git、curl,安全面过大 - 改一行业务代码,就导致整个依赖层失效重建
这篇文章我不讲太“概念化”的东西,而是带你从中型项目的生产视角,把一个“能跑的 Dockerfile”升级为一个高效、可维护、适合上线的生产镜像方案。
背景与问题
先说一个很常见的中型项目场景:
- 后端服务:Node.js / Java / Go / Python 任一种
- 有前端静态资源构建
- 依赖较多,构建链复杂
- 需要在 CI/CD 中频繁构建
- 生产环境希望镜像尽量小、启动尽量快、风险尽量低
很多团队最开始会写出这样的 Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这份配置的问题非常典型:
- 源码和依赖一起复制,改任何文件都会导致
npm install重新执行 - 开发依赖和生产依赖都打进镜像
- 构建工具链遗留在运行镜像中
- 上下文过大,
.git、测试文件、文档可能都进了镜像 - 安全边界模糊,默认 root 运行,基础镜像偏重
如果项目还要跑前端打包,那情况通常更糟:Node、构建工具、产物、源代码、缓存,全部堆在一个镜像里。
前置知识与环境准备
本文默认你已经会:
- 使用基础 Docker 命令
- 理解 Dockerfile 常见指令:
FROM、COPY、RUN、CMD - 能在本地安装 Docker 20+ 版本
建议环境:
- Docker Engine >= 20.x
- 启用 BuildKit(推荐)
- 一个简单 Node.js 示例项目
启用 BuildKit:
export DOCKER_BUILDKIT=1
如果你在 CI 中使用,也建议明确开启。
核心原理
多阶段构建的本质,是把“构建环境”和“运行环境”拆开。
- 构建阶段:安装编译工具、下载依赖、执行打包
- 运行阶段:只复制最终运行所需文件
这样做的价值非常直接:
- 镜像体积更小
- 依赖更少,漏洞面更小
- 结构更清晰,维护更容易
- 可以针对不同阶段做缓存优化
一张图看懂多阶段构建
flowchart LR
A[源码目录] --> B[构建阶段 builder]
B --> C[安装依赖]
C --> D[执行编译/打包]
D --> E[产物 dist/ + 生产依赖]
E --> F[运行阶段 runtime]
F --> G[生成生产镜像]
为什么单阶段构建容易变胖
flowchart TD
A[单阶段镜像] --> B[基础运行时]
A --> C[编译工具 gcc/git]
A --> D[全部源码]
A --> E[测试文件]
A --> F[开发依赖]
A --> G[构建缓存]
A --> H[最终产物]
你会发现,真正上线需要的,往往只有:
- 运行时
- 编译后的产物
- 最小化生产依赖
- 必要配置文件
其余大部分都不该留在最终镜像里。
一个中型项目该怎么拆阶段
这里我给一个适合中型 Node.js 服务的思路。即使你不是 Node 项目,也可以照着这个拆法理解:
- base 阶段:统一基础环境
- deps 阶段:只安装依赖,最大化缓存
- builder 阶段:复制源码并构建产物
- runtime 阶段:仅保留运行需要的文件
这样的结构比“只有 builder + runtime”更适合中型项目,因为后续维护成本更低。
实战代码(可运行)
下面我们做一个可运行示例,项目结构大概如下:
demo-app/
├── src/
│ └── server.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
1)示例应用代码
src/server.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({
message: "hello from multi-stage docker image",
pid: process.pid,
env: process.env.NODE_ENV || "development"
})
);
});
server.listen(port, () => {
console.log(`server listening on ${port}`);
});
2)package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage build demo",
"main": "dist/server.js",
"scripts": {
"build": "mkdir -p dist && cp src/server.js dist/server.js",
"start": "node dist/server.js"
},
"dependencies": {},
"devDependencies": {}
}
3).dockerignore
这个文件经常被低估,但它对镜像瘦身和构建速度帮助非常大。
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
test
tests
*.md
如果没有 .dockerignore,Docker 会把很多无关文件一起发送到构建上下文中。项目一大,这个损耗会非常明显。
4)推荐版 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN npm run build
FROM node:18-alpine AS runtime
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
第一步:抽出 base 阶段
FROM node:18-alpine AS base
WORKDIR /app
这里统一工作目录和基础运行环境,后面各阶段都复用它。
第二步:单独安装依赖
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
为什么这样写?
因为 package.json 和业务源码变化频率不同。
只复制依赖描述文件,意味着:
- 代码改动时,不必重新安装依赖
- Docker 层缓存命中率更高
- CI 构建更快
npm ci 比 npm install 更适合生产和 CI,因为它更可预测,严格依赖 lock 文件。
第三步:构建产物
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN npm run build
这一阶段可以做任何“重活”:
- TS 编译
- 前端打包
- 压缩静态资源
- 代码生成
- 单元测试(可选,不建议放最终构建链尾部)
第四步:构建最终运行镜像
FROM node:18-alpine AS runtime
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"]
这里是核心:
最终镜像中没有源码目录,没有构建脚本,没有构建缓存,也没有开发依赖。
构建与运行
构建镜像
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 image","pid":1,"env":"production"}
再进一步:为中型项目做缓存优化
如果你的项目依赖安装很慢,可以结合 BuildKit 的缓存挂载。
带缓存的依赖安装示例
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
这类优化在 CI 上很有价值,特别是依赖多、构建频繁的中型项目。
生产镜像设计思路:不是越小越好,而是越“干净”越好
很多人一开始会执着于“把镜像做到最小”,这没错,但我更建议你关注两个维度:
- 是否只保留运行所需内容
- 是否足够可维护
比如有些团队为了极致小体积,直接换成 scratch,结果:
- 没有 shell,排障困难
- 缺少证书,HTTP 请求异常
- 缺少时区或基础文件,兼容性问题频出
所以对中型项目来说,比较务实的路线通常是:
- 优先考虑
alpine或精简运行时镜像 - 保证构建、调试、运维三者平衡
- 逐步收缩,而不是一步极限压缩
常见坑与排查
这部分我按“现象 → 原因 → 处理方式”来讲,比较贴近真实排障。
1. 构建成功,运行时报模块缺失
常见报错:
Error: Cannot find module 'xxx'
原因
通常是以下几种:
- 只复制了
dist,但运行时仍依赖某些 node_modules - 构建阶段有 devDependencies,运行阶段没有正确安装 production 依赖
- 构建工具把依赖外置了,但运行镜像未包含
排查方法
先进入容器看文件:
docker run --rm -it demo-app:multi sh
查看目录:
ls -R
检查 package.json 与产物引用关系,确认最终镜像中是否包含实际运行需要的依赖。
建议
- Node 项目优先使用
npm ci --omit=dev - 如果是打包后完全自包含产物,再考虑不带
node_modules - 不要盲目只拷贝
dist
2. 镜像没变小,反而构建更慢
原因
常见是 Dockerfile 顺序写反了:
COPY . .
RUN npm ci
这样只要源码有任何变动,依赖层缓存就失效。
正确写法
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
经验建议
先复制“变化少”的文件,再复制“变化快”的文件,这几乎是 Docker 缓存优化的基本法则。
3. Alpine 镜像更小,但某些依赖编译失败
原因
alpine 使用 musl libc,某些原生模块或预编译包兼容性不如 Debian/Ubuntu 系列镜像。
排查方式
看构建日志,关注:
node-gyppythonmakeg++- 原生扩展模块编译报错
处理方式
- 如果业务依赖原生扩展较多,优先考虑
node:18-slim - 需要构建工具时,只放在 builder 阶段
- 不要为了解决 builder 的问题,把整套编译工具带进 runtime
4. 容器启动后权限异常
典型报错:
EACCES: permission denied
原因
切换为非 root 用户后,某些文件属主不对。
解决方式
复制文件时指定属主:
COPY --chown=node:node --from=builder /app/dist ./dist
或者在构建阶段处理好目录权限。
5. .dockerignore 没配好,构建上下文巨大
现象
执行构建时会看到类似输出:
Sending build context to Docker daemon 582.3MB
问题
这说明你把很多没必要的文件也打包传给 Docker 了。
处理方式
补充 .dockerignore,重点排除:
node_modules.git- 日志
- 测试产物
- 文档
- 本地缓存目录
安全/性能最佳实践
这一部分是最值得落地到团队规范里的。
1)使用非 root 用户运行
USER node
如果镜像默认没有合适用户,也可以自行创建:
RUN addgroup -S app && adduser -S app -G app
USER app
这样即便应用被利用,攻击者能直接拿到的权限也更有限。
2)明确区分构建依赖和运行依赖
多阶段构建不是为了“好看”,而是为了把不该进入生产的内容隔离出去,比如:
- 编译器
- git
- curl
- 测试工具
- 调试脚本
- 临时文件
这是镜像瘦身,也是安全加固。
3)固定基础镜像版本
不要只写:
FROM node:18-alpine
更稳妥的做法是固定到更具体的版本。
否则上游基础镜像更新后,可能导致构建结果波动。
例如:
FROM node:18.17-alpine3.18
如果你的供应链要求更严格,还可以固定 digest。
4)减少层数不是第一目标,减少无效内容才是
很多人会执着于把多个 RUN 合并成一行。
这有一定意义,但真正的大头通常在:
- 依赖是否合理安装
- 是否把无关文件复制进去了
- 是否留下了构建产物之外的垃圾
也就是说,先做内容收缩,再做层数优化。
5)在 CI 中加入镜像检查
建议至少做两类检查:
- 漏洞扫描
- 镜像体积与层分析
常用命令示例:
docker image ls
docker history demo-app:multi
查看镜像层历史:
docker history --no-trunc demo-app:multi
这样你可以很快看出,究竟是哪一层把镜像“吃胖了”。
6)用健康的目录结构约束镜像内容
中型项目里,我很建议统一约定:
/app/dist:编译产物/app/config:配置模板/app/scripts:仅构建阶段使用,不进生产/app/tmp:运行时临时目录
目录清晰后,多阶段复制会容易很多,也更不容易把无关内容带进 runtime。
一个更完整的构建流程图
sequenceDiagram
participant Dev as 开发者
participant CI as CI系统
participant Builder as builder阶段
participant Runtime as runtime阶段
participant Registry as 镜像仓库
Dev->>CI: 提交代码
CI->>Builder: docker build
Builder->>Builder: 安装依赖
Builder->>Builder: 编译/打包
Builder->>Runtime: 复制最小运行产物
Runtime->>Registry: 推送生产镜像
Registry->>Dev: 提供部署镜像
逐步验证清单
如果你想确认自己的 Dockerfile 是否真的达到了“生产可用”的标准,可以按下面这份清单检查。
构建层面
- 依赖安装是否放在源码复制之前
- 是否使用多阶段构建
- 是否存在
.dockerignore - 构建工具是否只存在于 builder 阶段
- 是否使用 lock 文件进行确定性构建
运行层面
- 生产镜像中是否只保留运行所需文件
- 是否使用非 root 用户
- 是否设置
NODE_ENV=production或等价生产环境变量 - 是否暴露了正确端口
- 启动命令是否足够直接、可观测
安全层面
- 基础镜像是否尽量精简
- 版本是否固定
- 是否定期做镜像漏洞扫描
- 是否避免把源码、密钥、调试工具带进生产镜像
什么时候不必过度追求多阶段
也要说一句边界条件,不是所有项目都值得把 Dockerfile 写得很复杂。
以下场景可以适度简化:
- 很小的内部工具
- 无构建步骤的简单服务
- 生命周期很短的临时项目
- 纯开发环境用途镜像
但只要你满足下面任一条,我都建议认真做多阶段:
- 项目需要上线生产
- CI 每天频繁构建
- 镜像要跨环境分发
- 团队成员较多,需要统一规范
- 依赖链复杂,漏洞治理有要求
总结
多阶段构建真正解决的,不只是“镜像大”这个表面问题,而是整个生产镜像的工程质量问题:
- 更小:减少无关文件和依赖
- 更快:提高缓存命中,降低构建与拉取时间
- 更稳:构建与运行环境分离,行为更可预测
- 更安全:减少工具链、降低攻击面
- 更易维护:阶段职责清晰,团队更容易协作
如果你准备在中型项目里落地,我建议直接从这三步开始:
- 先补
.dockerignore - 再把依赖安装和源码复制拆开
- 最后落地 builder/runtime 双阶段,逐步扩展成多阶段
不要一上来就追求“极限最小镜像”,先做到干净、稳定、可复用。
对大多数团队来说,这比省下几十 MB 更有长期价值。