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

《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南》

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

Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南

很多团队刚开始用 Docker 时,重点往往只有一件事:“先跑起来再说。”
结果项目能跑是能跑,但镜像越来越大,构建越来越慢,安全扫描一片红。

我自己最早也这么干过:一个 Dockerfile 里从装编译工具、装依赖、拷贝源码、打包、运行,全写在一起。短期很省事,长期代价很明显:

  • 镜像体积动不动几百 MB,甚至上 GB
  • CI 构建耗时长,推送和拉取都慢
  • 镜像里残留编译器、缓存、包管理器元数据
  • 安全扫描暴露大量不必要的漏洞面
  • 层缓存设计不好,一改一行代码就全量重建

这篇文章不讲“概念式入门”,而是带你从问题出发,用多阶段构建把构建链路拆干净,再结合缓存、基础镜像选择、运行时最小化和权限收缩,真正做到:

  • 构建更快
  • 镜像更小
  • 运行更安全
  • 排障更清晰

背景与问题

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

FROM node:18

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

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

这类写法的问题非常集中:

  1. 构建依赖和运行依赖混在一起
    npm install 可能把开发依赖、编译工具链都带进最终镜像。

  2. 源码变动导致缓存失效严重
    COPY . . 放得太早,只要业务代码改动,依赖层也会重建。

  3. 基础镜像偏大
    node:18 完整镜像虽然方便,但通常包含很多运行时并不需要的内容。

  4. 安全边界模糊
    默认 root 用户运行,一旦容器被突破,后果更严重。

  5. 构建产物与环境耦合
    编译阶段残留在最终镜像中,导致体积和攻击面都放大。

多阶段构建要解决的,本质上就是一句话:

让“构建环境”和“运行环境”彻底分离。


前置知识与环境准备

建议你的环境至少具备:

  • Docker 20.10+
  • 开启 BuildKit
  • 一个可运行的 Node.js 示例项目
  • 会使用基础命令:docker builddocker rundocker images

建议先开启 BuildKit:

export DOCKER_BUILDKIT=1

如果你用 Docker Desktop,一般默认已启用。


核心原理

1. 什么是多阶段构建

多阶段构建允许在一个 Dockerfile 中定义多个 FROM 阶段。
前面的阶段用来编译、打包、测试;最后一个阶段只保留运行所需的最小内容。

比如:

  • builder 阶段:安装依赖、执行构建
  • runner 阶段:只复制构建产物和必要运行依赖

这样最终镜像不会包含编译器、缓存目录和中间文件。

2. 为什么它能瘦身

因为 Docker 最终只保留最后阶段的内容。
前面阶段生成的内容,只有显式 COPY --from=... 的部分才会进入最终镜像。

3. 为什么它能提速

多阶段构建本身不是“魔法加速器”,真正加速来自两点:

  • 更好的分层设计,让缓存命中率更高
  • 把频繁变化的源码层放后面,把稳定依赖层放前面

4. 为什么它更安全

运行阶段可以做到:

  • 使用更小的基础镜像
  • 不带编译工具链
  • 不带 shell(视场景)
  • 以非 root 用户运行
  • 只暴露必要端口
  • 减少已知漏洞包数量

一张图看懂构建流转

flowchart LR
    A[源码与依赖清单] --> B[builder 阶段]
    B --> C[安装依赖]
    C --> D[执行构建/打包]
    D --> E[生成 dist]
    E --> F[runner 阶段]
    F --> G[仅复制 dist 与生产依赖]
    G --> H[最终镜像更小更安全]

镜像层与缓存命中的关键关系

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 层缓存

    Dev->>Docker: 提交 Dockerfile + 源码
    Docker->>Cache: 检查 package.json / lock 文件层
    alt 依赖未变化
        Cache-->>Docker: 命中依赖缓存
    else 依赖变化
        Docker->>Docker: 重新安装依赖
    end
    Docker->>Cache: 检查源码 COPY 层
    Dev->>Docker: 修改业务代码
    Docker->>Docker: 仅重建源码相关层
    Docker-->>Dev: 输出新镜像

实战代码(可运行)

下面我们用一个 Node.js Web 项目做示例。即便你的主项目是 Go、Java 或 Python,思路也是一样的:编译在 builder,运行在 runner。

示例目录结构

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

1)准备一个最小可运行项目

package.json

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

server.js

const express = require('express');
const app = express();

const port = process.env.PORT || 3000;

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

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

2)先写一个“不推荐但常见”的版本

FROM node:18

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

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

这个版本能用,但不够好。

3)改造成多阶段构建版本

Dockerfile

# syntax=docker/dockerfile:1.6

FROM node:18-alpine AS deps
WORKDIR /app

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

FROM node:18-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
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 /app/dist

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

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

4)配套 .dockerignore

这个文件非常重要,很多人会忽略。
它直接决定你发送给 Docker daemon 的构建上下文有多大。

.dockerignore

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

如果没有 .dockerignore,你本地的 node_modules.git、测试结果、构建产物都可能被打包进上下文,不仅拖慢构建,还会污染缓存。


逐步验证清单

构建镜像

docker build -t demo-app:multi .

运行容器

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

访问接口

curl http://localhost:3000

预期输出:

{"message":"hello from docker multi-stage build","time":"2024-04-04T00:00:00.000Z"}

查看镜像体积

docker images | grep demo-app

查看镜像历史层

docker history demo-app:multi

你会看到最终镜像中只保留了运行所需内容,而不是整个构建过程的所有杂物。


进一步优化:让缓存更稳定

上面的版本已经不错了,但还可以继续优化。
一个经验是:先复制依赖清单,再安装依赖,最后复制源码。

为什么?因为:

  • package.json / package-lock.json 变化频率通常低于业务源码
  • 这样业务代码变更时,依赖层仍可复用

比如下面这种写法是缓存友好的:

FROM node:18-alpine AS builder
WORKDIR /app

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

COPY . .
RUN npm run build

如果你写成:

COPY . .
RUN npm ci
RUN npm run build

那么只要任意源码变化,npm ci 那层也会失效。


BuildKit 缓存挂载:进一步提速

如果你的 CI 或本地构建频繁,BuildKit 的缓存挂载很值得上。

# syntax=docker/dockerfile:1.6

FROM node:18-alpine AS builder
WORKDIR /app

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

COPY . .
RUN npm run build

这样 npm 缓存可以复用,尤其在 CI 中能明显减少依赖下载时间。

注意:缓存挂载加速的是下载和构建过程,不会把缓存目录直接放进最终镜像。


更适合生产的版本

下面给一个更贴近生产使用的版本:包含健康检查、权限控制和更清晰的阶段分离。

# syntax=docker/dockerfile:1.6

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 /app/node_modules
COPY . .
RUN npm run build

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

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

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

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000

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

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

这个版本的思路更清楚:

  • base:统一工作目录
  • deps:安装完整依赖,供构建使用
  • builder:执行打包
  • prod-deps:安装仅生产依赖
  • runner:最小化运行环境

多阶段构建的常见模式

flowchart TD
    A[源码] --> B[deps 安装依赖]
    B --> C[builder 编译/测试]
    C --> D{是否需要生产依赖?}
    D -->|是| E[prod-deps 安装生产依赖]
    D -->|否| F[直接进入 runner]
    E --> G[runner 最终运行镜像]
    F --> G

适用于不同语言的常见策略:

  • Go:builder 用 golang 镜像编译,runner 用 distroless 或 alpine
  • Java:builder 用 maven/gradle,runner 用 jre 或更小的 runtime
  • Python:builder 编译 wheel,runner 只装 wheel 和运行时依赖
  • Node.js:builder 安装全量依赖打包,runner 只保留生产依赖和产物

常见坑与排查

这一节很关键,很多人不是不会写多阶段,而是写了之后“怎么构建失败了”。

坑 1:COPY --from=builder 路径不对

现象:

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

原因通常是 builder 阶段输出目录和你复制的目录不一致。
比如构建产物实际在 /app/build,你却复制 /app/dist

排查方式:

RUN ls -la /app
RUN ls -la /app/dist

在 builder 阶段临时加上这些命令,先确认文件真的存在。


坑 2:npm ci 失败

现象:

npm ERR! cipm can only install packages when package-lock.json is present

原因:

  • 缺少 package-lock.json
  • package.json 与 lock 文件不匹配

建议:

  • 在 CI 和生产构建中优先使用 npm ci
  • 把 lock 文件纳入版本控制
  • 不要本地乱改依赖后忘记提交 lock 文件

坑 3:Alpine 镜像并不总是最优

很多人一上来就说“用 alpine 肯定最小”。这句话只对了一半。

Alpine 的优势:

  • 体积小

但它也有边界条件:

  • 使用 musl libc,某些原生模块兼容性可能有坑
  • 某些编译依赖安装更复杂
  • 调试体验不如 Debian/Ubuntu 系基础镜像直接

如果你项目里有复杂原生依赖,比如某些图像处理、加密、数据库驱动库,不要机械地追求 alpine
我自己踩过的坑就是:镜像是小了几十 MB,但构建脚本复杂了一倍,排障成本远超收益。


坑 4:本地 node_modules 干扰构建

如果 .dockerignore 没写好,本地 node_modules 可能被复制进去,导致:

  • 构建上下文非常大
  • 本地平台依赖污染容器内环境
  • Linux 容器里混入 macOS/Windows 依赖产物

排查命令:

docker build --no-cache -t demo-app:test .

同时检查构建日志中的上下文大小是否异常。


坑 5:非 root 用户运行后权限不足

现象:

  • 容器启动时报文件权限错误
  • 应用无法写日志或临时文件

处理方式:

RUN chown -R appuser:appgroup /app
USER appuser

如果程序需要写某个目录,记得提前创建并授权。


坑 6:健康检查命令不可用

比如你在极简镜像里用了:

HEALTHCHECK CMD curl -f http://localhost:3000/ || exit 1

但镜像里根本没有 curl
要么安装一个轻量工具,要么换成镜像内已有命令,比如 wget,或者用应用自身暴露的探针机制。


安全/性能最佳实践

这一部分我建议你直接当作“上线前检查表”。

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

原则:

  • 最终镜像只保留运行必需文件
  • 不保留编译工具、测试文件、缓存和源码(视场景)
  • 能拆阶段就拆,不要图省事混在一起

2. 使用非 root 用户

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

这是性价比非常高的一步。
它不能代替完整安全方案,但能有效降低容器被利用后的破坏范围。

3. 固定基础镜像版本

不要总写:

FROM node:latest

建议写明确版本,最好细到次版本甚至 digest:

FROM node:18.20-alpine

更稳定,也更利于问题回溯。

4. 减少层中的无效文件

例如安装系统包时注意清理缓存。
Debian/Ubuntu 系通常这样处理:

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

5. 利用 .dockerignore

这是最容易被忽视、但收益非常直接的一项优化。

至少排除:

  • .git
  • node_modules
  • 本地构建目录
  • 测试报告
  • .env
  • IDE 配置文件

6. 在 CI 中做镜像扫描

推荐把镜像扫描集成到流水线中。
例如用 Trivy:

trivy image demo-app:multi

这样你能尽早发现基础镜像或依赖中的高危漏洞。

7. 按需使用 Distroless

如果你的应用运行时不需要 shell、包管理器、调试工具,可以考虑 Distroless。
优点是更小、更少攻击面;缺点是调试难度更高。

适合:

  • 已经比较稳定的生产服务
  • 有成熟可观测性方案的团队

不太适合:

  • 还在频繁排障的早期项目
  • 需要经常进入容器做临时诊断的场景

8. 不要为了小而小

镜像瘦身是手段,不是 KPI。
如果为了省 20MB,结果让维护复杂度上升、构建不稳定、排障困难,那就得不偿失。

一个很实用的判断标准是:

  • 是否明显减少构建时间
  • 是否明显降低传输成本
  • 是否减少漏洞数量
  • 是否没有显著增加维护成本

符合这些,再推进。


如何评估优化是否真的有效

不要只凭感觉。建议至少看这几项指标:

镜像体积

docker images

构建耗时

CI 中记录:

  • 总构建时间
  • 依赖安装时间
  • 推送镜像时间

层缓存命中率

观察构建日志,看哪些步骤频繁被复用,哪些步骤总是在重跑。

漏洞数量

用扫描工具比较优化前后:

  • Critical
  • High
  • Medium

启动速度

镜像更小不一定启动更快,但网络拉取通常会更快。
在跨地域部署和弹性扩缩容场景里,这一点尤其明显。


一个简化的优化路线图

如果你现在项目里的 Dockerfile 还比较“原始”,我建议按这个顺序改:

  1. 先加 .dockerignore
  2. 调整 COPY 顺序,优先缓存依赖层
  3. 改成多阶段构建
  4. 运行时只保留生产依赖
  5. 切换为非 root 用户
  6. 固定基础镜像版本
  7. 接入漏洞扫描
  8. 视情况尝试 Distroless 或更小 runtime

这个顺序的好处是:收益逐步可见,风险可控。


总结

Docker 多阶段构建不是“高级技巧”,它应该成为生产构建的默认姿势。

你可以把这篇文章的核心记成三句话:

  1. 构建环境和运行环境一定要分离
  2. 缓存友好的分层设计,比盲目换小镜像更重要
  3. 镜像瘦身的终点不是数字更小,而是交付更快、风险更低、维护更稳

如果你现在就准备落地,我建议先从下面这个最小行动清单开始:

  • 给项目补上 .dockerignore
  • 把 Dockerfile 改成 builder + runner 两阶段
  • COPY package*.json 放到 COPY . . 前面
  • 运行阶段只装生产依赖
  • 改成非 root 用户运行
  • 在 CI 里增加一次镜像漏洞扫描

这些动作不复杂,但通常就能解决 80% 的镜像臃肿和构建低效问题。

当你把这些基础做好之后,再去谈更激进的优化,比如 Distroless、跨平台构建缓存、远程缓存复用,才会事半功倍。


分享到:

上一篇
《大模型推理优化实战:从 KV Cache、量化到并发调度的性能提升路径》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建缓存到安全加固的完整优化方案》