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

《Docker 多阶段构建与镜像瘦身实战:从构建加速到安全发布的完整方案》

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

Docker 多阶段构建与镜像瘦身实战:从构建加速到安全发布的完整方案

很多团队一开始用 Docker,都能很快把服务“跑起来”,但跑起来不等于“跑得好”。我见过不少镜像:

  • 一个简单 Go 服务,镜像 800MB+
  • Node.js 项目把 node_modules、构建缓存、测试文件一股脑打进去
  • 生产镜像里还保留编译器、包管理器、shell 工具
  • CI 每次构建都从零开始,慢得让人怀疑人生
  • 镜像能跑,但安全扫描一堆高危漏洞

这些问题背后,其实都指向同一件事:镜像构建过程没有被设计过

这篇文章我不打算只讲“什么是多阶段构建”,而是带你从一个常见项目出发,把 多阶段构建、镜像瘦身、构建加速、安全发布 串成一套能落地的方案。你看完之后,至少能做到三件事:

  1. 会写可维护的多阶段 Dockerfile
  2. 知道镜像为什么大、慢、不安全
  3. 能在 CI/CD 里落地一套更稳的发布流程

背景与问题

先看几个典型症状。

1. 镜像太大

镜像大通常不只是“占磁盘”,还会带来连锁问题:

  • 拉取慢,部署慢
  • 节点扩容时启动慢
  • 仓库流量成本增加
  • 安全扫描耗时更久
  • 层里包含太多无关文件,攻击面变大

2. 构建太慢

常见原因有:

  • Dockerfile 指令顺序不合理,导致缓存失效
  • COPY . . 太早,任何代码变动都会触发依赖重装
  • 没有利用 BuildKit 缓存挂载
  • CI 每次都是冷启动构建

3. 生产镜像不安全

如果构建镜像和运行镜像是同一个环境,就容易把这些东西一起带进生产:

  • 编译器
  • 调试工具
  • 包管理器
  • 源码
  • 测试文件
  • 凭证文件或误带的 .env

多阶段构建的意义,就是把“构建所需”和“运行所需”彻底分开。


前置知识与环境准备

建议你本地准备:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一个简单的 Node.js 项目
  • 能使用 docker build, docker run, docker image ls

启用 BuildKit 的方式之一:

export DOCKER_BUILDKIT=1

如果你用 Docker Desktop,通常默认已经支持。


核心原理

什么是多阶段构建

多阶段构建的核心思路很简单:

在前面的阶段完成编译、打包、测试;在最后的阶段只保留运行时真正需要的产物。

比如一个前端或 Node.js 服务:

  • builder 阶段:安装完整依赖、编译代码
  • runner 阶段:只复制编译结果和生产依赖

这样最终镜像里就不会包含构建工具链。

为什么它能瘦身

因为 Docker 镜像是分层的。你在某一层安装了大量构建工具,如果最终镜像还是基于那个层继续往下叠,那些内容就会一直存在。

而多阶段构建相当于:

  • 前面阶段“随便装”
  • 最后阶段“只拿结果,不拿过程”

为什么它能提升安全性

攻击面来自镜像里的内容。你去掉:

  • gcc / make
  • git / curl / bash
  • 源码与测试文件
  • 多余包管理工具

就等于减少了潜在利用入口。


一张图看懂多阶段构建流程

flowchart LR
    A[源码目录] --> B[构建阶段 builder]
    B --> C[安装依赖]
    C --> D[编译/打包]
    D --> E[产物 dist]
    E --> F[运行阶段 runner]
    F --> G[仅复制运行所需文件]
    G --> H[生成最终生产镜像]

单阶段构建为什么容易出问题

先看一个“能用但不优雅”的单阶段 Dockerfile。

FROM node:20

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

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

这个写法的问题很集中:

  1. COPY . . 太早,任何代码改动都会让依赖缓存失效
  2. npm install 会装开发依赖
  3. 构建工具和源码都留在最终镜像里
  4. 没有 .dockerignore 时,连 .git、日志、测试文件都可能被打进去

实战代码:从普通 Dockerfile 升级为多阶段构建

下面我用一个简单的 Node.js + TypeScript 服务做示例。目录结构大致如下:

demo-app/
├── src/
│   └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
└── Dockerfile

示例应用代码

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.7.4",
    "typescript": "^5.6.2"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true
  }
}

src/server.ts

import express from "express";

const app = express();
const port = process.env.PORT || 3000;

app.get("/", (_req, res) => {
  res.json({
    message: "hello docker multi-stage build"
  });
});

app.listen(port, () => {
  console.log(`server started on port ${port}`);
});

第一步:写一个基础版多阶段 Dockerfile

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

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

EXPOSE 3000

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

这个版本已经比单阶段构建好很多了:

  • builder 负责编译
  • runner 只保留运行依赖和编译结果
  • 生产镜像里没有 TypeScript 源码和 devDependencies

构建与运行

docker build -t demo-app:basic .
docker run -p 3000:3000 demo-app:basic

访问:

curl http://localhost:3000/

预期输出:

{"message":"hello docker multi-stage build"}

第二步:进一步优化构建缓存

很多人已经用了多阶段构建,但构建依然慢,原因通常在于 缓存层设计不合理

更合理的顺序

先复制依赖描述文件,再安装依赖,最后复制业务代码:

FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

为什么这样更快?

因为日常开发中,改动最频繁的是 src/,不是 package-lock.json。只要依赖文件没变,npm ci 这层就能复用缓存。


第三步:使用 BuildKit 缓存提升安装速度

如果 CI 或本地经常重新构建,npm ci 仍然会花不少时间。BuildKit 的缓存挂载就很有帮助。

# syntax=docker/dockerfile:1.6

FROM node:20-alpine AS builder

WORKDIR /app

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

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

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

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

这类缓存的意义是:

  • 即使镜像层缓存失效
  • npm 包下载缓存仍然可复用
  • 对 CI 场景尤其友好

第四步:进一步瘦身与安全加固

下面是我更推荐在生产里使用的一版。

# syntax=docker/dockerfile:1.6

FROM node:20-alpine AS builder

WORKDIR /app

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

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

ENV NODE_ENV=production
WORKDIR /app

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

EXPOSE 3000

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

这里多做了几件事:

  • 设置 NODE_ENV=production
  • 使用 npm ci --omit=dev 仅安装生产依赖
  • 切换为非 root 用户 node
  • 清理 npm cache

构建产物流转图

sequenceDiagram
    participant Dev as 开发者
    participant Builder as builder 阶段
    participant Runner as runner 阶段
    participant Registry as 镜像仓库

    Dev->>Builder: 复制 package*.json
    Builder->>Builder: npm ci
    Dev->>Builder: 复制源码
    Builder->>Builder: npm run build
    Builder->>Runner: 复制 dist
    Runner->>Runner: 安装生产依赖
    Runner->>Registry: 推送最终镜像

.dockerignore 是瘦身的关键配角

很多时候,镜像过大不是 Dockerfile 本身的问题,而是构建上下文太脏。

建议至少加上:

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

为什么这很重要

执行 docker build . 时,当前目录会作为构建上下文发送给 Docker daemon。
如果没有 .dockerignore,哪怕 Dockerfile 最终没用到某些文件,它们也可能已经被传输了,拖慢构建。

这个点我以前真踩过:代码不大,但 .git 历史很重,构建上下文传了半天,最后发现 Dockerfile 一行都没引用它。


多阶段构建的常见模式

除了 Node.js,这个思路对很多语言都适用。

Go 应用

Go 特别适合多阶段构建,因为最终只需要一个二进制文件。

FROM golang:1.22-alpine AS builder

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /src/app ./app

EXPOSE 8080
CMD ["./app"]

如果编译为静态二进制,甚至可以考虑 scratch 作为最终镜像基底。

前端静态站点

FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

这种场景非常经典:构建环境是 Node,运行环境却根本不需要 Node。


逐步验证清单

写完 Dockerfile 后,建议你按这个顺序验证,而不是“能跑就算了”。

1. 验证镜像大小

docker image ls | grep demo-app

2. 查看镜像层历史

docker history demo-app:basic

关注是否还有:

  • 编译工具
  • 多余文件复制
  • 大层异常增长

3. 检查运行用户

docker run --rm demo-app:basic id

如果输出是 root,就说明还没降权运行。

4. 进入容器看实际内容

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

检查是否还残留:

  • src/
  • 测试目录
  • 构建工具
  • 私密配置文件

5. 验证服务可用性

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

常见坑与排查

这部分很重要。多阶段构建不难学,但容易在细节上翻车。

坑 1:COPY . . 导致缓存频繁失效

现象

每次改一行业务代码,依赖都重新安装。

原因

依赖文件和源码一起复制,只要任意文件变化,对应层缓存就会失效。

解决

先复制依赖清单,再安装依赖,最后复制源码。

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

坑 2:builder 阶段能跑,runner 阶段启动失败

现象

容器启动时报错:

  • 找不到模块
  • 缺少配置文件
  • 二进制无法执行

原因

你只复制了构建产物,但忘了复制运行时仍需要的文件,比如:

  • package.json
  • 配置文件
  • 静态资源
  • 动态库

解决

明确列出运行阶段真正需要的文件,不要想当然。

例如:

COPY package.json package-lock.json ./
COPY --from=builder /app/dist ./dist
COPY config ./config

坑 3:Alpine 镜像更小,但运行出错

现象

某些依赖在 alpine 下编译或运行异常。

原因

Alpine 使用 musl libc,而很多预编译依赖是基于 glibc 的。
Node、Python、Java 某些原生模块场景里比较常见。

解决建议

  • 如果是纯 JS/纯 Go 场景,alpine 往往很好用
  • 如果有原生依赖,优先验证兼容性
  • 不要为了小几十 MB 强行上 Alpine,稳定性更重要

我个人的经验是:先确保稳定,再追求极限瘦身


坑 4:删除文件不等于镜像层真的变小

现象

Dockerfile 里 RUN rm -rf xxx 了,但镜像还是很大。

原因

Docker 是分层存储。你在前一层加进去的东西,后一层删除,并不会抹掉前一层的体积。

错误示例

RUN apt-get update
RUN apt-get install -y build-essential
RUN make build
RUN apt-get remove -y build-essential

更好的方式

  • 把临时内容放在同一个 RUN 里处理
  • 或者更直接:放到 builder 阶段,最终镜像根本不带这些层

坑 5:误把敏感文件打进镜像

常见来源

  • .env
  • 私钥
  • 云厂商凭证
  • npm token
  • git 凭证

排查方式

docker run --rm -it your-image sh
find /app -maxdepth 2 -type f

解决

  • .dockerignore
  • 用构建参数或 secret,不要直接 COPY
  • CI 中不要把敏感信息写入镜像层

安全/性能最佳实践

这一部分可以当成团队规范来用。

1. 运行时镜像尽量最小化

原则不是“最小”,而是“只保留必要内容”。

建议保留:

  • 应用二进制或编译产物
  • 运行时依赖
  • 必要配置与静态资源

建议不要保留:

  • 编译工具链
  • 源码
  • 测试文件
  • 文档
  • 缓存目录

2. 使用非 root 用户运行

很多基础镜像默认是 root。生产环境中尽量避免。

USER node

如果是自定义用户:

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

3. 固定基础镜像版本

不要只写:

FROM node:latest

推荐写明确版本:

FROM node:20-alpine

更严格一点可以固定 digest,不过这要结合你的供应链管理策略。


4. 优先使用 npm ci 而不是 npm install

原因:

  • 更适合 CI
  • 依赖更可重复
  • 基于 lock 文件安装,结果更稳定

5. 充分利用缓存,但别滥用缓存

缓存适合:

  • 包管理器下载缓存
  • 依赖层缓存

缓存不适合:

  • 把随时变化的大目录提前复制
  • 把日志、构建产物带进上下文

6. 扫描镜像漏洞

至少在发布前做一次扫描。常见工具思路包括:

  • Docker Scout
  • Trivy
  • Grype

示例:

trivy image demo-app:basic

如果高危漏洞来自基础镜像,优先考虑:

  • 升级基础镜像版本
  • 更换更干净的发行版
  • 减少不必要包安装

7. 在 CI/CD 中拆分“构建镜像”和“发布镜像”职责

一个更稳妥的流程通常是:

  1. 代码提交
  2. 执行测试
  3. 构建多阶段镜像
  4. 漏洞扫描
  5. 打标签
  6. 推送仓库
  7. 部署到环境

发布流程示意图

flowchart TD
    A[代码提交] --> B[单元测试]
    B --> C[Docker 多阶段构建]
    C --> D[镜像漏洞扫描]
    D --> E{是否通过}
    E -- 否 --> F[阻断发布]
    E -- 是 --> G[推送镜像仓库]
    G --> H[部署到测试/生产环境]

一个更完整的生产示例

下面给一份相对均衡的 Dockerfile,适合大多数 Node.js 服务参考。

# syntax=docker/dockerfile:1.6

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

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build

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

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
EXPOSE 3000
CMD ["node", "dist/server.js"]

这版的特点

  • deps 阶段专门处理依赖
  • builder 复用依赖进行构建
  • runner 单独安装生产依赖
  • 阶段职责清晰,后期维护更方便

如果你追求极致构建速度,也可以在某些场景下直接从 deps 复制依赖到 builderrunner,但要注意 dev/prod 依赖混淆问题。


如何判断“瘦身是否过度”

这是一个很现实的问题。不是镜像越小越好。

可以接受的“不过度”标准

  • 构建过程清晰,团队能看懂
  • 本地和 CI 可稳定复现
  • 运行环境不缺依赖
  • 安全扫描结果可控
  • 镜像大小明显优于旧方案

可能过度优化的信号

  • 为了小几十 MB,引入很复杂的脚本
  • 使用过于冷门的基础镜像,排障困难
  • 牺牲可读性,导致团队没人敢改 Dockerfile
  • Alpine 兼容性问题频发

我比较建议的顺序是:

  1. 先做多阶段构建
  2. 再补 .dockerignore
  3. 再优化 Dockerfile 缓存层
  4. 再做非 root、安全扫描
  5. 最后才考虑极限瘦身

总结

如果你只记住一句话,那就是:

把“构建环境”和“运行环境”分开,是 Docker 工程化的第一步。

多阶段构建带来的价值不只是镜像变小,它实际上同时解决了四类问题:

  • 体积:最终镜像更轻
  • 速度:构建缓存更稳定,CI 更快
  • 安全:减少工具链和无关文件,缩小攻击面
  • 可维护性:构建职责清晰,排障更容易

最后给你几个可以直接执行的建议:

  1. 先检查现有 Dockerfile 里是否有 COPY . . 过早出现
  2. 立即补上 .dockerignore
  3. 将构建与运行拆成至少两个阶段:builderrunner
  4. 生产镜像只装运行依赖,尽量使用非 root 用户
  5. 在 CI 中增加镜像漏洞扫描和基础镜像升级策略

如果你的项目已经上了 Docker,但镜像还很大、构建还很慢、发布还不够稳,那么多阶段构建通常是最值得优先改造的一步。它不是技巧,而是生产环境里的基本功。


分享到:

上一篇
《集群架构中服务发现与流量治理的实战设计:从注册中心到故障隔离》
下一篇
《前端性能实战:基于 Core Web Vitals 的渲染优化与问题排查指南》