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

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

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

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

很多团队刚开始用 Docker 时,镜像能跑就行:一个 Dockerfile 从头装到尾,编译工具、源码、临时文件、包管理缓存全塞进镜像里。结果通常是:

  • 镜像几百 MB 甚至上 GB
  • 拉取慢,发布慢,回滚也慢
  • 构建环境和运行环境混在一起,安全风险更高
  • 线上镜像带着 curlgccgit,攻击面变大

我自己第一次接手旧项目容器化时,就遇到过“应用只有 50MB,镜像却 1.2GB”的情况。最后排查下来,问题不在业务,而在构建方式:把“怎么编译”与“怎么运行”混成了一锅。

这篇文章不讲空泛原则,而是带你从一个可运行示例出发,完整走一遍:

  1. 为什么单阶段镜像容易臃肿
  2. 多阶段构建到底解决了什么
  3. 如何把镜像做小、做快、做安全
  4. 发布前怎么验证,出了问题怎么排查

背景与问题

先看一个典型的“能用但不优雅”的 Dockerfile:

FROM node:20

WORKDIR /app
COPY . .

RUN npm install
RUN npm run build

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

这个写法的问题很集中:

  • node:20 默认可能偏大
  • 构建依赖和运行依赖混在一起
  • COPY . . 太早,任何源码变动都会让依赖层缓存失效
  • npm install 会把开发依赖也装进来
  • 构建产物、源码、测试文件、.git 都可能被打进镜像

如果是 Java、Go、Rust、前端项目,这类问题只会更明显:编译器、构建工具链、缓存目录,都会让镜像体积迅速膨胀。

我们真正想要的目标

一个更合理的生产镜像,应该尽量满足:

  • 构建快:充分利用缓存
  • 体积小:只保留运行所需内容
  • 职责清晰:构建阶段和运行阶段分离
  • 更安全:最小化基础镜像、非 root 运行、减少攻击面
  • 便于发布:可追踪、可扫描、可复现

前置知识与环境准备

建议你本地准备:

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

启用 BuildKit:

export DOCKER_BUILDKIT=1

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


核心原理

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

把“编译应用”放在前面的阶段,把“运行应用”放在最后一个阶段,最终镜像只保留运行必需文件。

单阶段 vs 多阶段

flowchart LR
    A[源码] --> B[安装构建依赖]
    B --> C[编译产物]
    C --> D[连同源码/缓存/工具一起进入最终镜像]

    E[源码] --> F[构建阶段: 安装依赖并编译]
    F --> G[生成 dist / 二进制]
    G --> H[运行阶段: 仅复制运行所需文件]

单阶段的问题,是“所有中间产物都留下来”。
多阶段的优点,是“只拷贝最后真正需要的东西”。

多阶段构建的常见收益

  1. 镜像瘦身
    构建工具链不进入最终镜像。

  2. 安全性更高
    最终镜像不包含 gccmakegit 等工具。

  3. 发布更快
    镜像更小,推送和拉取速度更快。

  4. 职责清晰
    开发环境、构建环境、运行环境分层明确。

分层缓存为什么重要

Docker 构建是按层缓存的。你如果把 COPY . . 放在安装依赖之前,任何代码改动都会导致依赖重新安装。

更合理的顺序通常是:

  1. 先复制依赖清单,如 package.jsonpackage-lock.json
  2. 先安装依赖
  3. 再复制业务代码
  4. 最后构建
sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker 构建器
    participant Cache as 层缓存

    Dev->>Docker: COPY package*.json
    Docker->>Cache: 检查依赖层缓存
    Cache-->>Docker: 命中/未命中

    Dev->>Docker: RUN npm ci
    Dev->>Docker: COPY src/
    Dev->>Docker: RUN npm run build

    Note over Docker: 代码变更时,依赖层通常可复用

实战代码(可运行)

下面我用一个 Node.js 示例演示。即使你主力不是 Node,也能把思路迁移到 Java、Go、Python。

示例项目结构

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

src/server.js

const express = require('express');

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

app.get('/', (req, res) => {
  res.json({
    message: 'hello, docker multi-stage build',
    time: new Date().toISOString()
  });
});

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

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage demo",
  "main": "src/server.js",
  "scripts": {
    "build": "mkdir -p dist && cp -r src/* dist/",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

.dockerignore

这个文件非常关键,很多人会忽略。它对镜像体积和构建速度影响很大。

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

先写一个“普通版” Dockerfile

FROM node:20

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

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

构建:

docker build -t demo-app:single .

运行:

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

查看镜像大小:

docker images | grep demo-app

这个版本能跑,但问题前面已经说过:大、不够安全、缓存利用也一般。


改造成多阶段构建

下面是更推荐的版本。

版本一:基础多阶段构建

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app

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

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

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

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

  • builder 负责构建
  • runner 只负责运行
  • 运行阶段不复制源码中的无关文件
  • 使用 npm ci 提升可复现性
  • --omit=dev 避免开发依赖进入运行镜像

版本二:更接近生产的写法

我更推荐下面这个版本,考虑了非 root 用户和环境变量。

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

ENV NODE_ENV=production
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts \
    && 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, docker multi-stage build","time":"2024-01-01T00:00:00.000Z"}

进一步瘦身:构建缓存与依赖优化

如果你已经用 BuildKit,可以进一步利用缓存挂载提升构建速度。

# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS builder

WORKDIR /app

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

COPY src ./src
RUN npm run build

FROM node:20-alpine AS runner

ENV NODE_ENV=production
WORKDIR /app

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts \
    && npm cache clean --force

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

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

这个优化的重点不是镜像更小,而是重复构建更快


逐步验证清单

做完 Dockerfile 后,我建议你别急着推仓库,先按这个清单过一遍。

1. 看镜像体积

docker images | grep demo-app

对比 singlemulti 两个版本。

2. 看镜像分层

docker history demo-app:multi

你可以观察每一层是不是都合理。
如果看到某一层特别大,通常意味着你复制了不该复制的内容,或者安装了多余依赖。

3. 进入容器检查文件

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

检查目录:

ls -lah
ls -lah dist

确认最终镜像里没有源码、测试目录、构建缓存等无关内容。

4. 检查运行用户

docker run --rm demo-app:multi id

如果看到不是 root,说明这一步做对了。


常见坑与排查

多阶段构建看起来简单,但真到项目里,坑不少。下面这些问题我基本都见过。

1. COPY --from=builder 路径不对

现象:

COPY failed: stat /var/lib/docker/...: no such file or directory

原因通常是构建产物路径和你写的不一致。

比如你以为产物在 /app/dist,实际构建脚本输出到 /app/build

排查办法:

  • 检查 npm run build 实际输出目录
  • 临时进入 builder 阶段查看文件

你甚至可以这样单独构建 builder:

docker build --target builder -t demo-app:builder .
docker run --rm -it demo-app:builder sh

然后看目录:

ls -lah /app
ls -lah /app/dist

2. alpine 镜像下原生模块编译失败

现象:

  • npm ci 失败
  • 某些 Node 原生依赖找不到系统库
  • 程序运行时报 libc 相关错误

原因:

  • alpine 使用 musl libc
  • 某些依赖默认按 glibc 环境编译或发布

处理思路:

  • 如果项目依赖较重的原生模块,优先测试 node:slim
  • 不要为了“看起来更小”盲目上 Alpine
  • 小体积和兼容性之间要做平衡

一个更稳妥的替代:

FROM node:20-bookworm-slim AS builder
...
FROM node:20-bookworm-slim AS runner
...

我的经验是:
Alpine 适合简单应用,不适合所有应用。


3. npm install 导致构建不可复现

现象:

  • 同一份代码,不同时间构建结果不同
  • CI 和本地安装依赖不一致

建议:

  • 有锁文件时优先用 npm ci
  • 锁文件要纳入版本控制
  • CI 不要偷偷修改锁文件

4. .dockerignore 缺失导致构建上下文过大

现象:

  • docker build 一开始就很慢
  • 终端显示 sending build context 很大
  • 镜像里出现 .gitnode_modules、日志文件

排查:

docker build .

看输出里类似:

Sending build context to Docker daemon  823.4MB

如果上下文特别大,十有八九是 .dockerignore 没写好。


5. 运行时缺少依赖

现象:

Error: Cannot find module 'xxx'

常见原因:

  • 你只复制了构建产物,但运行时依赖没有安装
  • 构建脚本把依赖打包方式理解错了

要区分两种场景:

  1. Node 解释执行应用
    运行阶段通常还需要 node_modules

  2. 已打成独立二进制或静态资源
    运行阶段可以只复制产物

别把前端打包思路机械套到后端服务。


安全/性能最佳实践

这一部分是文章的重点。镜像瘦身不是为了“好看”,而是为了发布效率和安全治理。

1. 使用更小但合适的基础镜像

优先级不是“越小越好”,而是:

  • 能满足运行需求
  • 兼容性足够好
  • 更新维护可靠

建议大致如下:

  • 追求平衡:debian/bookworm-slim
  • 追求极致小:alpine,但先验证兼容性
  • 静态二进制应用:可以考虑 scratch 或 distroless

2. 最终镜像只保留运行必需内容

保留:

  • 可执行文件或构建产物
  • 运行时依赖
  • 必需配置

不要保留:

  • 编译器
  • 源码
  • 单元测试
  • 包管理缓存
  • git 元数据
  • 临时文件
flowchart TD
    A[构建阶段内容] --> A1[源码]
    A --> A2[编译器]
    A --> A3[依赖缓存]
    A --> A4[构建脚本]
    A --> A5[产物]

    B[最终镜像] --> B1[运行时]
    B --> B2[产物]
    B --> B3[最少依赖]

    A5 --> B2

3. 非 root 用户运行

默认 root 运行不是不能用,而是不推荐。
一旦容器内应用存在漏洞,root 权限的风险会明显放大。

Node 官方镜像通常自带 node 用户:

USER node

如果是其他基础镜像,也可以自己创建用户:

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

4. 固定依赖版本,保证可复现

包括:

  • 基础镜像尽量固定大版本甚至 digest
  • 依赖用锁文件
  • 构建参数显式声明

例如:

FROM node:20-alpine

更稳妥的做法是固定到更具体版本,甚至镜像摘要。这样能减少“今天能构建,明天突然挂了”的情况。


5. 做镜像扫描

瘦身不等于安全,安全还需要扫描。

常见工具:

  • Trivy
  • Docker Scout
  • Grype

例如用 Trivy:

trivy image demo-app:multi

你会看到漏洞列表、严重级别、修复建议。
这一步非常适合接入 CI/CD。


6. 合理组织层,提升缓存命中

一个通用原则:

  • 不常变的内容放前面
  • 常变化的内容放后面

例如:

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

而不是:

COPY . .
RUN npm ci
RUN npm run build

前者在你只改业务代码时,依赖层还能复用。


7. 不在镜像里写死敏感信息

不要这样做:

ENV DB_PASSWORD=123456

更合理的方式:

  • 运行时通过环境变量注入
  • 使用 Kubernetes Secret / Docker Secret
  • CI 中按环境注入配置

8. 用多阶段支持测试、构建、发布分离

很多团队只把多阶段当成“瘦身工具”,其实它还很适合流程治理。

例如:

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

FROM deps AS test
COPY . .
RUN npm test

FROM deps AS build
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=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

这样你在 CI 里可以:

  • 先跑 --target test
  • 再跑 --target build
  • 最后产出 runner 镜像
stateDiagram-v2
    [*] --> deps
    deps --> test
    deps --> build
    build --> runner
    test --> [*]
    runner --> [*]

一个更完整的生产级示例

下面给出一个更实用的 Dockerfile,可以直接作为模板起步。

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm-slim AS base
WORKDIR /app

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

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

FROM base AS prod-deps
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts \
    && npm cache clean --force

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

COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./

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

这个版本做了几件事:

  • 把依赖安装、构建、生产依赖拆开
  • 避免在 runner 中重复做太多工作
  • 保持结构清晰,适合 CI 扩展

发布前检查建议

真正上线前,我建议至少做这几项:

镜像内容检查

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

确认:

  • 没有源码泄露
  • 没有测试目录
  • 没有构建缓存
  • 没有敏感配置文件

漏洞扫描

trivy image demo-app:multi

运行身份检查

docker run --rm demo-app:multi whoami

端口与健康验证

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

分层与体积分析

docker history demo-app:multi
docker images | grep demo-app

边界条件:什么时候不必过度瘦身

说实话,镜像瘦身也不是越极端越好。下面几种情况,我会建议你适度而为:

1. 项目依赖复杂,Alpine 兼容性差

如果换 Alpine 让你天天修编译问题,那省下来的几十 MB,未必值。

2. 团队对调试依赖很强

有些场景确实需要容器里保留少量调试工具。
这时可以:

  • 生产镜像保持最小化
  • 另做一个 debug 镜像

3. CI 构建时间比镜像大小更重要

有时为了缩小镜像,反而引入更多复杂步骤,导致构建变慢、维护成本变高。
这时应该优先做缓存优化,而不是一味压缩体积。


总结

多阶段构建的本质,不只是“把镜像做小”,而是把容器镜像从“能跑”提升到“适合发布”。

你可以把本文的要点记成 5 句话:

  1. 构建阶段和运行阶段分开
  2. 先复制依赖清单,再安装依赖,再复制源码
  3. .dockerignore 控制构建上下文
  4. 最终镜像只保留运行必需内容,并尽量非 root 运行
  5. 上线前做体积分析、内容检查和漏洞扫描

如果你现在手上有一个历史项目,我建议按下面顺序改,不容易翻车:

  1. 先补 .dockerignore
  2. 再调整 COPY 顺序提升缓存命中
  3. 再改成多阶段构建
  4. 最后加非 root、扫描和 CI 集成

这样做,通常不用大改业务代码,就能明显改善:

  • 构建速度
  • 镜像大小
  • 发布效率
  • 安全性

多阶段构建不是高级技巧,而是现在写生产 Dockerfile 的默认姿势。只要你愿意花半小时把 Dockerfile 重构一遍,后面每一次构建、每一次发布,都会省下真金白银的时间。


分享到:

上一篇
《Web3 中级实战:从零搭建基于 EVM 的钱包登录与链上签名认证系统》
下一篇
《自动化测试中的稳定性治理实战:从脆弱用例定位到失败重试策略设计》