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

《Docker 多阶段构建与镜像瘦身实战:从构建提速到生产环境安全交付》

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

Docker 多阶段构建与镜像瘦身实战:从构建提速到生产环境安全交付

很多团队一开始用 Docker,都是“能跑就行”:一个 Dockerfile 里从装依赖、编译、打包,到最终启动,全都塞进同一个镜像。短期看确实省事,但项目一旦进入 CI/CD、发布频繁、环境复杂、审计严格的阶段,问题会集中爆发:

  • 镜像体积大,拉取慢,发布耗时长
  • 构建缓存利用率差,稍微改点代码就全量重建
  • 编译工具、包管理器、临时文件都进了生产镜像
  • 漏洞扫描一堆告警,安全团队看了直皱眉
  • 本地能跑,线上却因为基础镜像、依赖版本差异出问题

这篇文章我会带你从**“为什么要多阶段构建”讲到“怎么落地瘦身、提速和安全交付”**,并给出一套可运行示例。重点不是背语法,而是让你形成一套在项目里能直接用的思路。


背景与问题

先看一个很常见的“初级版” Dockerfile,以 Node.js 服务为例:

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

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

它的问题几乎一眼就能数出来:

  1. 构建上下文太粗暴

    • COPY . . 把所有文件都复制进去,包括 .git、本地缓存、测试文件、文档等。
  2. 依赖安装不可控

    • npm install 会根据环境变化安装依赖,结果不够稳定。
    • 开发依赖也会被带进生产镜像。
  3. 构建工具进入生产环境

    • npm、编译依赖、甚至 gcc/python/make 这类东西,可能全留在最终镜像里。
  4. 缓存命中差

    • 代码一变,COPY . . 导致后续层全部失效,构建时间明显变长。
  5. 安全面扩大

    • 镜像里包含越多组件,漏洞面就越大。
    • 默认 root 用户运行,生产环境风险更高。

如果你在 CI 里每次构建都要 5~10 分钟,镜像几百 MB 甚至上 GB,发布时每台机器都在慢吞吞拉镜像,这就是该下决心治理的时候了。


前置知识与环境准备

建议你具备以下基础:

  • 会写基础 Dockerfile
  • 了解镜像分层与缓存
  • 会基本的 Node.js 项目构建方式

本文示例环境:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 示例项目:一个简单 Node.js Web 服务

先确认本机支持 BuildKit:

export DOCKER_BUILDKIT=1
docker buildx version

如果能正常输出版本,说明可以继续。


核心原理

多阶段构建的核心思想其实很朴素:

把“构建环境”和“运行环境”拆开,只把最终运行真正需要的产物复制到生产镜像中。

比如一个前端或 Node 服务构建过程通常需要:

  • 安装依赖
  • 编译打包
  • 生成 dist/
  • 启动运行

这里真正部署时需要的,往往只有:

  • 编译后的产物
  • 生产依赖
  • 必要配置
  • 一个最小可运行基础镜像

而像下面这些内容,通常只在构建阶段需要:

  • typescript
  • webpack/vite
  • gcc/make/python
  • 源码里的测试、脚本、文档
  • 包管理器缓存

多阶段构建流程图

flowchart LR
    A[源码与依赖清单] --> B[builder 阶段<br/>安装依赖/编译]
    B --> C[生成构建产物 dist]
    C --> D[runtime 阶段<br/>仅复制 dist 和生产依赖]
    D --> E[最终生产镜像]

镜像分层与缓存的关键点

Docker 构建是按层进行的,前面的层不变,后面的层就有机会复用缓存。所以 Dockerfile 的顺序非常重要。

一个优化后的思路应该是:

  1. 先复制依赖描述文件,如 package.jsonpackage-lock.json
  2. 安装依赖
  3. 再复制业务代码
  4. 执行构建

这样业务代码变了,依赖层仍然能复用缓存。

构建与运行分离的安全收益

sequenceDiagram
    participant Dev as 开发者
    participant CI as CI构建机
    participant Builder as builder镜像
    participant Runtime as runtime镜像
    participant Prod as 生产环境

    Dev->>CI: 提交代码
    CI->>Builder: 安装依赖并构建
    Builder->>Runtime: 仅复制产物与运行依赖
    Runtime->>Prod: 推送并部署
    Note over Prod: 无编译工具、无源码、攻击面更小

这一步非常关键:生产镜像不应该承担“构建职责”。它只负责稳定地运行。


实战代码(可运行)

下面我们从一个简单 Node.js 服务开始,做一版“可直接跑”的多阶段构建。

示例项目结构

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

示例代码

src/index.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 is running on port ${port}`);
});

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "Docker multi-stage demo",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

先在本地生成锁文件:

npm install

第一步:先看一个“能用但不优”的版本

单阶段 Dockerfile

FROM node:18

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

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

构建镜像:

docker build -t demo-app:single .

运行容器:

docker run -d -p 3000:3000 --name demo-single demo-app:single

测试:

curl http://localhost:3000

这个版本已经比最粗暴的 COPY . . 再安装依赖好一点了,但仍然有两个问题:

  • 生产镜像里仍然带有 npm、完整 node 基础环境以及构建中间内容
  • 如果项目涉及编译打包,这种方式仍会把很多无关内容留在最终镜像中

第二步:改造成多阶段构建

多阶段 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:18-alpine AS runtime
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package*.json ./

ENV NODE_ENV=production
EXPOSE 3000

USER node
CMD ["node", "src/index.js"]

这个版本适合没有前端打包、没有 TypeScript 编译的简单服务。它的核心变化是:

  • 依赖安装放到 deps 阶段
  • 运行镜像只复制必要文件
  • 使用 npm ci --omit=dev,更适合 CI 和生产环境
  • 使用 node 非 root 用户运行

构建并运行:

docker build -t demo-app:multi .
docker run -d -p 3000:3000 --name demo-multi demo-app:multi
curl http://localhost:3000

第三步:加入“构建阶段”,适合 TypeScript/前端打包场景

在真实项目里,往往会有 build 动作,比如 TypeScript 编译、前端打包、NestJS 构建等。这时通常要拆成三个阶段:

  1. deps:安装完整依赖
  2. builder:执行构建
  3. runtime:只保留运行所需内容

典型三阶段 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runtime
WORKDIR /app

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

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

ENV NODE_ENV=production
EXPOSE 3000

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

这个写法很常见,也比较稳妥。它的思路是:

  • 构建阶段需要完整依赖,包括 devDependencies
  • 运行阶段重新安装生产依赖,避免把开发依赖带进去
  • 最终只复制 dist/

三阶段构建关系图

flowchart TD
    A[deps<br/>npm ci] --> B[builder<br/>COPY source + npm run build]
    B --> C[runtime<br/>npm ci --omit=dev]
    B --> D[dist 产物]
    D --> C
    C --> E[生产镜像]

第四步:加上 .dockerignore,这是镜像瘦身的低成本高收益动作

很多人做了多阶段构建,但忘了 .dockerignore,结果仍然把一堆没必要的文件传给 Docker daemon。

.dockerignore

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

这里有两个实际收益:

  1. 减少构建上下文大小
  2. 避免敏感文件误入镜像

我自己踩过的坑是:某次同事把 .env.production 放在项目目录里,本地构建时直接被 COPY . . 带进镜像,最后漏洞扫描才发现。这个问题其实完全可以靠 .dockerignore 预防。


第五步:使用 BuildKit 缓存进一步提速

多阶段构建解决的是“干净交付”,但要把构建速度再往前推,可以利用 BuildKit 的缓存挂载。

带缓存的 Dockerfile

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runtime
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

ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

构建命令:

DOCKER_BUILDKIT=1 docker build -t demo-app:cache .

这类缓存对 CI 里的重复构建很有帮助,尤其依赖不经常变化时,提速会比较明显。


逐步验证清单

写完 Dockerfile,不建议“构建成功就算完”。我更推荐按下面清单验一遍。

1. 看镜像体积

docker images | grep demo-app

2. 看镜像层历史

docker history demo-app:multi

观察是否还有明显的大层,比如:

  • 安装编译工具
  • 大量源码复制
  • 不必要缓存

3. 进入容器检查内容

docker run --rm -it demo-app:multi sh

检查是否有这些问题:

whoami
ls -lah
du -sh /app

你应该关注:

  • 当前是否为非 root 用户
  • 是否只保留最小运行文件
  • 有没有测试目录、源码、缓存文件残留

4. 验证服务是否正常

docker run --rm -p 3000:3000 demo-app:multi
curl http://localhost:3000

5. 扫描漏洞

如果环境允许,可以用 Trivy:

trivy image demo-app:multi

常见坑与排查

多阶段构建本身不复杂,但在真实项目里,经常会遇到一些很具体的问题。

坑 1:COPY --from=builder 路径写错

现象:

  • 构建成功或半成功,但运行时提示找不到入口文件
  • 容器启动后报 Cannot find moduleNo such file or directory

比如你写了:

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

但实际构建产物在 /app/dist

排查方式:

docker build --target builder -t demo-builder .
docker run --rm -it demo-builder sh
ls -lah /app
ls -lah /app/dist

这招非常好用:先单独进入中间阶段看实际文件布局


坑 2:Alpine 很小,但不是所有项目都适合

alpine 的优势是小,但它使用 musl libc,某些原生模块可能跟 glibc 生态不完全一致。

现象:

  • 本地运行没问题,容器里某些依赖崩溃
  • 涉及原生扩展的模块安装失败

排查思路:

  • 看依赖是否包含 native module
  • 临时切回 node:18-slim 试验
  • 对比构建日志与运行日志

如果项目依赖比较“重”或者包含原生编译模块,slim 往往是更稳妥的折中。


坑 3:npm installnpm ci 混用导致结果不一致

生产环境里我更建议优先使用:

npm ci

原因:

  • 基于锁文件,安装结果更可预测
  • 更适合 CI/CD
  • 对版本漂移更敏感,更容易暴露问题

如果你的 package-lock.json 不存在或与 package.json 不一致,npm ci 会直接失败。虽然一开始觉得“麻烦”,但这恰恰是在帮你尽早发现不一致。


坑 4:容器里能构建,运行时权限报错

常见原因:

  • 最终切换到 USER node 后,复制进去的文件属主不对
  • 应用运行时需要写目录,但目录没有权限

可以在复制时显式设置属主:

COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node package*.json ./

如果应用需要写日志或临时文件,也要提前创建目录并授权。


坑 5:构建缓存失效,速度忽快忽慢

典型错误写法:

COPY . .
RUN npm ci

这样任何代码变动都会让依赖安装层失效。

更好的顺序:

COPY package*.json ./
RUN npm ci
COPY . .

这点看似细节,但在 CI 里影响非常大。


安全/性能最佳实践

这一节我想把“镜像瘦身”和“生产交付”真正串起来。瘦身不是目的,稳定、安全、可审计地上线才是。

1. 优先选择更小但稳定的基础镜像

常见选择:

  • alpine:小,适合纯解释型、依赖简单的服务
  • slim:比完整镜像小,同时兼容性更稳
  • distroless:更极致,但调试成本更高

一个简单判断:

  • 追求极致体积且依赖简单:alpine
  • 追求稳定兼容与较小体积:slim
  • 对安全和最小运行面要求极高:distroless

边界条件是:别为了小而小。如果 Alpine 导致你排查 native 依赖花了一整天,那不一定划算。


2. 生产镜像中不要保留构建工具

最终镜像应尽量避免出现:

  • git
  • curl
  • wget
  • gcc
  • make
  • 包管理器缓存
  • 测试文件与源码

如果确实需要调试能力,也更建议:

  • 用临时 debug 容器
  • 或者保留独立 debug 镜像
  • 而不是污染生产镜像

3. 使用非 root 用户运行

最少要做到:

USER node

更严格一点的场景,可以自建用户:

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

这样即使应用被利用,攻击者能获取的权限也相对受限。


4. 控制镜像内容的可预测性

建议:

  • 固定基础镜像大版本,最好固定 digest
  • 使用锁文件
  • 统一 CI 构建环境
  • 避免运行时动态安装依赖

例如,不要在容器启动命令里写:

npm install && npm start

这会把“可重复交付”变成“现场碰运气”。


5. 做好健康检查与最小暴露

可以加上健康检查:

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://127.0.0.1:3000/ || exit 1

如果镜像里没有 wget/curl,也可以通过编排层做探针检查。这里要注意:健康检查本身不要引入额外攻击面和大量依赖


6. 在 CI/CD 中结合镜像扫描与签名

完整的生产交付链路建议包含:

  • 镜像构建
  • 漏洞扫描
  • 合规检查
  • 签名或制品追踪
  • 部署准入控制

生产交付建议流程

flowchart LR
    A[代码提交] --> B[CI 构建多阶段镜像]
    B --> C[单元测试/集成测试]
    C --> D[漏洞扫描]
    D --> E[镜像签名/制品归档]
    E --> F[部署到生产环境]

这一步的意义在于:你交付的不只是一个“能跑的容器”,而是一个来源可追踪、内容可验证、风险可评估的制品。


7. 用 docker history 和实际运行内容反推优化点

我通常会从两个角度看镜像是否还可继续瘦身:

  • docker history:看哪些层大
  • 进容器 find /app:看哪些文件不该出现

经验上,最容易继续优化的地方往往有:

  • 没加 .dockerignore
  • 开发依赖混入生产
  • 构建缓存和安装缓存未清理
  • 产物复制路径过宽,整个项目都进了最终镜像

一份更接近生产的 Dockerfile 模板

下面给一份中规中矩、适合大多数 Node 服务的模板:

# 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

RUN useradd -r -s /usr/sbin/nologin appuser \
    && chown -R appuser:appuser /app

USER appuser

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

如果你是前端静态站点,运行阶段甚至可以替换成 nginx:alpine,只复制打包后的静态文件,这会更小。


什么时候不必过度设计多阶段构建

虽然我一直推荐多阶段构建,但也不是所有项目都值得上很复杂的模板。

可以适度简化的情况:

  • 很小的内部工具脚本
  • 纯运行型服务,没有编译过程
  • 只在内网临时环境使用
  • 生命周期很短的 PoC

但即便如此,至少也建议做到:

  • .dockerignore
  • 非 root 用户
  • 依赖和代码分层复制
  • 尽量不用 latest

也就是说,不一定非要“完美”,但最好别停留在“把整个目录扔进去能跑就行”的状态。


总结

如果把这篇文章浓缩成一句话,那就是:

多阶段构建不是为了炫技,而是为了把“构建环境”和“生产环境”彻底分开,让镜像更小、构建更快、交付更安全。

你可以按下面的优先级落地:

  1. 先补 .dockerignore
  2. COPY package*.json 和依赖安装前置,优化缓存
  3. 引入多阶段构建,分离 builder 与 runtime
  4. 运行阶段只保留产物和生产依赖
  5. 使用非 root 用户
  6. 在 CI 中结合 BuildKit 缓存、漏洞扫描和制品治理

如果你现在的镜像还很重,别一上来就追求“极致最小化”。我更建议你先达到这三个目标:

  • 体积明显下降
  • 构建时间稳定可控
  • 生产镜像不再携带构建工具和无关文件

做到这一步,Docker 镜像就已经从“能用”走向“可交付”了。


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式设计订单与库存一致性方案》
下一篇
《集群架构实战:从单体服务到高可用多节点部署的设计与演进路径》