跳转到内容
123xiao | 无名键客

《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布-381》

字数: 0 阅读时长: 1 分钟

Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布

很多团队一开始写 Dockerfile,目标都很朴素:先跑起来再说。结果项目上线几个月后,镜像越来越大、构建越来越慢、漏洞扫描越来越多,发布链路也开始变得不稳定。

我自己就踩过一个很典型的坑:一个 Node.js 服务,业务代码其实不到 50 MB,但最终镜像做到了 1.2 GB。CI 每次构建拉基础镜像都慢,发布时推送镜像更慢,安全扫描里一堆其实根本不会在生产运行时用到的构建工具漏洞。后来回过头看,本质问题只有一句话:

把“构建环境”和“运行环境”混在一起了。

这篇文章不讲空泛概念,直接带你从问题出发,理解多阶段构建为什么能解决镜像臃肿、构建缓慢和生产风险,并通过一个可运行的示例,把“开发可构建、生产可发布”的流程串起来。


背景与问题

在没有多阶段构建之前,很多 Dockerfile 大概长这样:

FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/server.js"]

看起来没毛病,但实际上隐藏了几个常见问题:

  1. 镜像过大

    • 把源码、测试文件、构建缓存、开发依赖都带进去了。
    • npm install 默认会装开发依赖,生产根本用不到。
  2. 构建慢

    • COPY . . 太早,任何代码变动都会让依赖层缓存失效。
    • 每次 CI 都从头装依赖、重新构建。
  3. 安全面扩大

    • 运行时镜像里包含编译器、包管理器、调试工具。
    • 漏洞扫描结果噪音大,真正的高风险项不容易看清。
  4. 发布不稳定

    • 本地能跑,不代表容器内最终运行环境一致。
    • 构建产物和运行依赖之间边界不清晰。

所以,多阶段构建并不只是“让镜像变小”,它更像是一个工程化分层手段:把构建、测试、打包、运行拆开。


前置知识与环境准备

建议你本地先准备:

  • Docker 24+
  • 启用 BuildKit(推荐)
  • 一台可以联网拉镜像的机器

先确认版本:

docker version
docker buildx version

启用 BuildKit 的一个简单方式:

export DOCKER_BUILDKIT=1

如果你用 Docker Desktop,通常默认已经开启。


核心原理

什么是多阶段构建

多阶段构建的核心,就是在一个 Dockerfile 里写多个 FROM,每个阶段只负责一件事:

  • 一个阶段装依赖
  • 一个阶段编译代码
  • 一个阶段只保留最终运行所需文件

最后通过 COPY --from=阶段名,只把必要产物复制到最终镜像。

一个直观流程图

flowchart LR
    A[源码与配置] --> B[依赖安装阶段]
    B --> C[构建阶段]
    C --> D[测试阶段]
    C --> E[生产运行阶段]
    B -.缓存复用.-> B
    E --> F[发布到镜像仓库]

为什么它能同时解决“体积、速度、安全”

可以从三个维度理解:

1. 体积变小

最终镜像只保留:

  • 编译后的产物
  • 运行时依赖
  • 最小化基础系统

不再带上:

  • 源码
  • 编译工具链
  • 测试工具
  • 构建缓存

2. 构建更快

通过分层设计,把最不常变化的步骤放前面,让缓存尽可能命中,比如:

  • 先复制 package.json / package-lock.json
  • 再安装依赖
  • 最后复制业务代码

这样改一行业务代码,不需要重装依赖。

3. 更安全

最终生产镜像里没有:

  • gcc、make、python 这类编译工具
  • git、curl 等不必要工具
  • 测试与调试脚本

攻击面更小,漏洞数也会明显下降。


一个分阶段发布模型

sequenceDiagram
    participant Dev as 开发者
    participant CI as CI/CD
    participant Builder as 构建阶段镜像
    participant Runtime as 运行阶段镜像
    participant Registry as 镜像仓库
    participant Prod as 生产环境

    Dev->>CI: 提交代码
    CI->>Builder: 安装依赖并构建
    Builder->>Builder: 执行测试/产出 dist
    CI->>Runtime: 复制 dist 与生产依赖
    Runtime->>Registry: 推送最终镜像
    Registry->>Prod: 拉取并部署

实战代码(可运行)

下面我们用一个 Node.js + Express 的最小示例,演示:

  • 普通构建方式的问题
  • 多阶段构建的改造方法
  • 如何验证镜像体积和运行结果

示例目录结构

demo-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── src/
│   └── server.js

第一步:准备示例应用

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"
  },
  "devDependencies": {
    "nodemon": "^3.1.4"
  }
}

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}`);
});

生成锁文件

如果你本地没有 package-lock.json,执行:

npm install

第二步:先看一个“不够好”的 Dockerfile

FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["npm", "run", "start"]

这个版本能跑,但它的问题非常典型:

  • 开发依赖也被装进镜像
  • 全量源码、缓存都被复制进去
  • 代码一改,npm install 缓存就失效
  • 最终镜像包含完整 Node 构建环境

第三步:改造为多阶段构建

下面是一个更实用的版本。

Dockerfile

# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY src ./src
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/dist ./dist

EXPOSE 3000

USER node
CMD ["node", "dist/server.js"]

这个版本做了几件关键的事:

  • deps 阶段:只安装依赖,利于缓存
  • build 阶段:只复制源码并生成 dist
  • production 阶段:重新安装生产依赖,不带 devDependencies
  • 最终以 node 非 root 用户运行

第四步:补上 .dockerignore

这个文件非常重要,很多人做了多阶段构建,却忘了它,结果上下文还是巨大。

.dockerignore

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
dist
coverage
*.md

它的作用是:减少构建上下文
如果不加,Docker 在 build 时会先把一堆无关文件打包传给守护进程,网络构建时尤其慢。


第五步:构建镜像

docker build -t demo-app:multi .

查看镜像大小:

docker images | grep demo-app

如果你也保留了“单阶段版 Dockerfile”做对比,通常会看到多阶段版体积明显更小。


第六步:运行并验证

启动容器:

docker run --rm -p 3000:3000 demo-app:multi

另开一个终端访问:

curl http://localhost:3000/

预期返回:

{"message":"hello from multi-stage docker build","time":"2025-08-29T09:39:25.000Z"}

逐步验证清单

如果你想确认“瘦身”和“安全发布”是否真的生效,可以按这个顺序检查:

1. 检查最终镜像里是否没有源码目录

docker run --rm demo-app:multi sh -c "ls -R /app"

你应该看到主要只有:

  • dist
  • package.json
  • package-lock.json
  • node_modules

而不是完整的 src、测试目录、Git 元数据。

2. 检查运行用户不是 root

docker run --rm demo-app:multi id

输出中不应是 uid=0(root)

3. 检查开发依赖是否未被安装

docker run --rm demo-app:multi sh -c "ls node_modules | grep nodemon || true"

正常应无输出。

4. 查看构建历史

docker history demo-app:multi

可以观察哪些层体积大,判断后续还能不能继续优化。


构建缓存优化:让 CI 不再“每次从零开始”

多阶段构建解决了结构问题,但想要构建加速,还要进一步利用 BuildKit 缓存。

使用缓存挂载优化 npm 安装

# syntax=docker/dockerfile:1.7

FROM node:20-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 deps AS build
COPY src ./src
RUN npm run build

FROM node:20-alpine AS production
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=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

这样做的好处是:

  • 即使镜像层失效,npm 下载缓存仍可复用
  • 在 CI/CD 中重复构建速度会更稳定

缓存命中策略图

flowchart TD
    A[复制 package.json 和 lock 文件] --> B[npm ci]
    B --> C[复制源码]
    C --> D[npm run build]
    D --> E[生成生产镜像]

    F[仅修改业务代码] -.-> C
    G[修改依赖声明] -.-> A
    G --> B

这张图背后的原则很简单:

  • 依赖声明变了,才重新装依赖
  • 业务代码变了,只重做构建
  • 这就是 Docker 层缓存最常见、也最有效的优化手法

常见坑与排查

这一部分我尽量讲“真实会遇到的坑”,不是教材式罗列。

1. COPY --from=build 路径写错

现象:

COPY --from=build /app/build ./dist

但你的实际构建产物在 /app/dist,构建时报错:

failed to compute cache key: "/app/build" not found

排查方法:

docker build --target build -t demo-app:build .
docker run --rm -it demo-app:build sh
ls -R /app

先进入中间阶段镜像看文件结构,是最快的。


2. npm ci 失败,提示 lock 文件不一致

现象:

npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync

原因:

  • 你改了 package.json
  • 却没有更新 package-lock.json

解决:

npm install
git add package-lock.json

经验上,生产构建里优先用 npm ci 而不是 npm install,因为前者更可重复。


3. Alpine 镜像导致某些原生模块异常

现象:

某些依赖含原生编译模块时,在 node:alpine 下可能因为 musl libc 与 glibc 差异出现兼容问题。

解决思路:

  • 优先尝试 node:20-slim
  • 如果你依赖 sharpgrpcbcrypt 这类模块,更要提前验证
  • 不要盲目认为 Alpine 一定最好,小不等于适合

一个更稳妥的生产阶段示例:

FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

4. 切换非 root 用户后权限报错

现象:

EACCES: permission denied

原因:

复制进去的文件属主还是 root,而运行用户是 node

解决方式之一:

COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node package.json package-lock.json ./

或者在切换用户前统一调整权限:

RUN chown -R node:node /app
USER node

5. .dockerignore 配置不当导致构建失败

现象:

你把 package-lock.json 也忽略掉了,结果 COPY package.json package-lock.json ./ 直接报错。

排查建议:

先看 .dockerignore,很多构建问题根本不是 Dockerfile 本身,而是上下文文件没传进去。


6. 误把测试工具带进生产镜像

有些人会这么写:

FROM build AS production
CMD ["node", "dist/server.js"]

这其实只是“换了个名字”,并没有真正缩减运行环境。因为 build 阶段里往往还带着:

  • devDependencies
  • 源码
  • 构建工具
  • 测试脚本

正确思路是:最终阶段要重新定义运行时边界


安全/性能最佳实践

这里我把实战里最有价值的建议整理成一组可执行清单。

1. 基础镜像尽量明确版本

不建议:

FROM node:latest

建议:

FROM node:20-alpine

或者:

FROM node:20-slim

好处:

  • 构建结果更稳定
  • 避免上游镜像变化导致不可预期问题

2. 使用非 root 用户运行

这是生产环境最基础也最有效的一条。

USER node

如果镜像没有内置普通用户,就自己创建:

RUN addgroup -S app && adduser -S app -G app
USER app

3. 只复制必要文件

不要一上来就:

COPY . .

更推荐:

COPY package.json package-lock.json ./
COPY src ./src

这件事既影响缓存,也影响安全边界。


4. 在生产镜像中移除开发依赖

Node 场景下:

RUN npm ci --omit=dev

Python 场景可以只安装 requirements.txt 中的生产依赖;Go 场景则通常直接复制二进制。


5. 善用最小运行时镜像,但别盲目极限瘦身

常见选择大概是:

  • alpine:体积小,但兼容性要验证
  • slim:更稳妥,体积适中
  • distroless:更安全、更小,但调试难度高

如果你是中级读者、团队还在完善工程化,我的建议是:

  • 先从 slimalpine 开始
  • 等流程稳定,再考虑 distroless

6. 将测试放在中间阶段,而不是最终阶段

你可以加入 test 阶段:

FROM deps AS test
COPY src ./src
RUN npm run build
RUN npm test

然后 CI 只在测试通过后才构建生产镜像。这能把“质量门禁”和“生产产物”分离开。


7. 为生产镜像增加健康检查

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

这不是必须,但对容器编排环境很有帮助。


8. 构建完成后做镜像扫描

常见做法:

docker scout quickview demo-app:multi

或者使用:

  • Trivy
  • Grype
  • Snyk

重点不是“零漏洞”神话,而是识别:

  • 高危且可利用的漏洞
  • 实际存在于运行层的漏洞
  • 是否能通过升级基础镜像快速消除

9. 为镜像打上清晰标签

比如:

docker build -t registry.example.com/demo-app:1.0.0 -t registry.example.com/demo-app:latest .

更进一步可以加 Git 提交号:

docker build -t registry.example.com/demo-app:1.0.0 \
  -t registry.example.com/demo-app:git-abc1234 .

这样回滚更容易,也更方便排查问题。


一个更贴近生产的 Dockerfile 示例

如果你想直接拿去做模板,下面这个版本会更像生产实践:

# syntax=docker/dockerfile:1.7

FROM node:20-slim AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build
COPY src ./src
RUN npm run build

FROM deps AS test
COPY src ./src
RUN npm run build
RUN node dist/server.js & sleep 2 && kill $!

FROM node:20-slim AS production
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=build /app/dist ./dist

RUN chown -R node:node /app
USER node

EXPOSE 3000

CMD ["node", "dist/server.js"]

构建生产镜像:

docker build --target production -t demo-app:prod .

如果只想调试构建阶段:

docker build --target build -t demo-app:build .

这就是多阶段构建另一个很实用的点:你可以按阶段调试,而不是一次性黑盒执行到底。


什么时候不必过度优化

虽然我很推荐多阶段构建,但也要说边界条件。

如果你的项目:

  • 只是本地临时开发工具
  • 不会进入生产
  • 镜像只在个人机器使用
  • 构建频率非常低

那你未必需要把 Dockerfile 设计得很复杂。

但只要满足下面任一条件,就值得认真做:

  • 要进 CI/CD
  • 要部署到线上
  • 需要漏洞扫描
  • 团队多人协作
  • 构建速度已经影响研发体验

换句话说,多阶段构建不是“高级技巧”,而是生产交付的基础能力。


总结

把这篇文章压缩成几条最重要的落地建议,就是:

  1. 把构建和运行彻底分开

    • 用多个 FROM
    • 最终镜像只保留运行时必要内容
  2. 优化缓存顺序

    • 先复制依赖描述文件
    • 后复制业务代码
    • 尽量让依赖层稳定复用
  3. 减少上下文与依赖

    • 写好 .dockerignore
    • 生产环境只装生产依赖
  4. 默认按安全标准构建

    • 固定基础镜像版本
    • 使用非 root 用户
    • 做镜像扫描
  5. 不要迷信最小镜像,要结合兼容性

    • alpine 更小
    • slim 往往更稳
    • 生产中稳定性优先于极限瘦身

如果你现在的 Dockerfile 还是“一个阶段把所有事做完”,最值得马上动手的一步不是追求极限,而是先把它拆成:

  • deps
  • build
  • production

只要完成这一步,你通常就已经同时收获了: 更小的镜像、更快的构建、更清晰的发布边界,以及更安全的生产环境。


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与故障补偿》
下一篇
《从请求签名到参数还原:一次中级 Web 逆向实战中的加密逻辑定位与复现》