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

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

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

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

很多团队刚开始用 Docker 时,最常见的状态是:能跑就行
于是 Dockerfile 里把编译工具、源码、测试依赖、调试命令全塞进去,镜像从几百 MB 一路膨胀到 1GB 以上,构建慢、拉取慢、发布慢,安全风险还高。

我自己第一次接手这类项目时,就见过一个 JavaScript 服务镜像接近 1.2GB,CI 每次构建都像“等电梯”。后来逐步引入多阶段构建基础镜像收敛非 root 运行最小化拷贝之后,镜像体积和发布时间都明显下降,排查问题也更清晰了。

这篇文章就从实战角度带你做一遍,重点解决三个问题:

  1. 为什么镜像会臃肿?
  2. 多阶段构建到底在优化什么?
  3. 怎样把“能运行的镜像”变成“适合生产环境发布的镜像”?

背景与问题

在真实项目里,Docker 镜像变胖通常不是单一原因,而是几个问题叠加:

  • 使用了过大的基础镜像,比如直接 ubuntunode:fullopenjdk 全量版
  • 构建工具和运行环境混在一起
  • 把整个项目目录都 COPY 进去,连 .git、测试文件、文档都没放过
  • 安装依赖时没有利用缓存,导致每次构建都全量重来
  • 最终镜像里保留了编译器、包管理器缓存、临时文件
  • 容器默认 root 运行,发布到生产后权限面过大

这些问题的直接后果包括:

  • CI/CD 构建时间长
  • 镜像仓库占用高
  • 节点拉镜像慢,扩容慢
  • 漏洞扫描结果一大堆,修都不好修
  • 生产环境被入侵时,攻击面更大

先看一个典型的“臃肿版”流程:

flowchart TD
    A[源码目录] --> B[单阶段 Dockerfile]
    B --> C[安装构建依赖]
    C --> D[复制全部源码]
    D --> E[编译打包]
    E --> F[保留 node_modules源码缓存工具链]
    F --> G[生成大镜像]
    G --> H[推送慢 拉取慢 风险高]

这就是很多项目的起点:构建方便,但运行不经济


前置知识与环境准备

本文示例使用一个简单的 Node.js Web 服务演示,因为它既有“构建阶段”,也有“运行阶段”,比较适合说明多阶段构建。

环境要求

  • Docker 20+
  • 建议启用 BuildKit
  • Linux / macOS / WSL2 均可

启用 BuildKit:

export DOCKER_BUILDKIT=1

示例目录结构如下:

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

示例代码

app.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 started on ${port}`);
});

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "docker multi-stage demo",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

核心原理

什么是多阶段构建

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

构建阶段需要的东西,不一定要进入最终运行镜像。

你可以在同一个 Dockerfile 里定义多个阶段:

  • 第一阶段:安装依赖、编译、打包
  • 第二阶段:只复制构建产物和最小运行依赖

这样最终镜像里只保留“运行真正需要的内容”。

为什么它能瘦身

镜像是按层叠加的。每一条 RUNCOPYADD 都可能产生新层。
如果你在早期层里装了 gcc、make、git、测试工具,即使后面删除,也未必真的让镜像变小,因为这些内容已经进入历史层了。

多阶段构建直接从结构上避免了这个问题:
构建工具所在阶段不会进入最终镜像。

关键优化点

1. 分离构建环境与运行环境

例如:

  • 构建阶段用 node:20-bookworm
  • 运行阶段用 node:20-alpine 或更轻量的运行镜像

2. 优化缓存命中

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

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

这样当源码变化但依赖不变时,npm ci 这一层能复用缓存。

3. 最小化拷贝范围

不要直接把整个目录无脑塞进去,配合 .dockerignore 使用,效果非常明显。

4. 以非 root 用户运行

镜像瘦身和安全发布其实是一体两面:
镜像越小,包含的工具越少,攻击面越小;
权限越收敛,生产风险越低。


实战代码(可运行)

下面我们先写一个“可用但不优”的版本,再重构成生产可用版本。


第一步:一个不推荐的单阶段 Dockerfile

FROM node:20

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["npm", "start"]

这个版本的问题很典型:

  • 基础镜像偏大
  • COPY . . 太早,缓存利用差
  • 没有 .dockerignore
  • npm install 不够稳定,生产构建更推荐 npm ci
  • 默认 root 运行
  • 源码、测试、缓存全都可能进入镜像

构建并运行:

docker build -t demo-app:fat .
docker run --rm -p 3000:3000 demo-app:fat

查看镜像体积:

docker images | grep demo-app

第二步:编写 .dockerignore

先把没必要进入构建上下文的内容排除掉。

.dockerignore

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

这一步经常被忽略,但很值。
因为 Docker 构建时会先把上下文打包发给 daemon,目录越大,构建越慢。


第三步:升级为多阶段构建

Dockerfile

# syntax=docker/dockerfile:1.6

FROM node:20-bookworm AS builder

WORKDIR /app

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

COPY . .

FROM node:20-alpine AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY --from=builder /app /app

USER node

EXPOSE 3000

CMD ["node", "app.js"]

这个版本已经比单阶段好很多了,但还可以继续收紧。


第四步:进一步瘦身与安全收敛

对于这个简单服务,其实不需要在 builder 和 runtime 都保留完整应用目录。
可以按需复制。

更推荐的 Dockerfile

# syntax=docker/dockerfile:1.6

FROM node:20-bookworm AS deps

WORKDIR /app

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

FROM node:20-alpine AS runtime

WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps /app/node_modules /app/node_modules
COPY app.js /app/app.js
COPY package.json /app/package.json

USER node

EXPOSE 3000

CMD ["node", "app.js"]

这个版本的特点:

  • 依赖安装单独成阶段,职责清晰
  • 最终镜像只复制 node_modulesapp.jspackage.json
  • 不把整个源码目录带入运行镜像
  • 使用 Alpine 作为运行环境
  • 使用非 root 用户 node

构建与运行验证

docker build -t demo-app:slim .
docker run --rm -p 3000:3000 demo-app:slim

访问:

curl http://localhost:3000/

预期输出:

{"message":"hello docker multi-stage build","time":"2025-01-01T00:00:00.000Z"}

查看镜像历史层

这个命令非常适合排查“为什么镜像还是大”:

docker history demo-app:slim

如果你看到某一层特别大,通常说明:

  • COPY . . 拷了太多文件
  • 安装了不必要的软件包
  • 某一步下载了大文件却没处理好
  • 构建产物没和运行产物分离

逐步验证清单

实际做镜像优化时,我建议不要一步改完,而是按下面清单逐项验证:

  • 是否使用了 .dockerignore
  • 是否区分了构建阶段与运行阶段
  • 是否优先复制依赖文件以利用缓存
  • 是否使用 npm ci 而不是 npm install
  • 是否去掉了开发依赖
  • 是否只复制运行所需文件
  • 是否使用非 root 用户
  • 是否确认应用在容器内可正常启动
  • 是否检查了镜像层历史
  • 是否做了漏洞扫描

这个过程看起来啰嗦,但很实用。尤其在团队里推广时,有一份 checklist 比讲一堆概念更容易落地。


多阶段构建的执行流程

flowchart LR
    A[deps阶段] --> B[复制 package.json package-lock.json]
    B --> C[npm ci --omit=dev]
    C --> D[runtime阶段]
    D --> E[复制 node_modules]
    D --> F[复制 app.js]
    D --> G[复制 package.json]
    E --> H[最终生产镜像]
    F --> H
    G --> H

这个图背后的重点是:
最终镜像不是继承完整构建过程,而是按需“摘取成果”。


生产环境安全发布流程

光把镜像做小还不够,生产发布要关注完整链路。

sequenceDiagram
    participant Dev as 开发者
    participant CI as CI流水线
    participant Scan as 漏洞扫描
    participant Registry as 镜像仓库
    participant Prod as 生产环境

    Dev->>CI: 提交代码与 Dockerfile
    CI->>CI: 多阶段构建镜像
    CI->>Scan: 扫描基础镜像与依赖漏洞
    Scan-->>CI: 返回结果
    alt 通过策略
        CI->>Registry: 推送版本化镜像
        Registry->>Prod: 拉取并发布
    else 不通过
        CI-->>Dev: 阻断发布并反馈问题
    end

建议在流水线中至少加入:

  • 镜像构建
  • 单元测试或最小冒烟测试
  • 漏洞扫描
  • 镜像签名或来源校验
  • 版本标签与回滚策略

常见坑与排查

下面这些问题,我基本都踩过,属于“看起来不复杂,但真会卡人”。

1. Alpine 镜像更小,但运行时报错

现象:

  • 某些 Node 原生模块、Python 包、Java 依赖在 Alpine 上行为异常
  • 提示缺少动态库,或者二进制不兼容

原因:

  • Alpine 使用的是 musl libc,不是很多发行版常见的 glibc

排查方式:

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

进入容器后检查依赖和错误日志。

解决建议:

  • 如果业务依赖原生库较多,不要盲目追求 Alpine
  • 可改用 debian-slim 一类镜像,在兼容性和体积之间折中

2. 明明删除了文件,镜像体积还是没降

原因:

  • 删除动作发生在后续层,前面的层已经把文件保存下来了

错误示例:

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

即使后面删了,镜像仍然可能很大。

解决建议:

  • 把安装和清理放在同一层
  • 更推荐直接用多阶段构建,把构建工具留在前一阶段

3. COPY . . 导致缓存失效

现象:

  • 改一行代码,整个依赖安装都重新执行

原因:

  • 依赖文件和业务代码一起复制,只要任意文件变化,缓存就失效

正确方式:

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

4. 容器非 root 后权限报错

现象:

  • 程序启动时报权限不足
  • 无法写日志、临时文件目录不可写

原因:

  • 复制后的文件属主不匹配
  • 应用仍尝试写入 root 才能访问的目录

解决方法:

COPY --chown=node:node app.js /app/app.js
COPY --chown=node:node package.json /app/package.json

如果目录需要写权限,也要确保属主和权限正确。


5. latest 标签导致回滚困难

现象:

  • 发布后出问题,却说不清线上到底跑的是哪个镜像
  • 回滚时只能“猜你上次推的是啥”

建议:

  • 不要只推 latest
  • 使用明确版本号、Git SHA、构建时间等标签

例如:

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

安全/性能最佳实践

这一部分我尽量给“能直接抄到项目里”的建议,而不是停留在口号。

1. 选择合适的基础镜像,而不是一味追求最小

经验上可以这么选:

  • 兼容优先debian-slim
  • 极致瘦身alpine
  • 更强安全收敛:distroless 类镜像
  • 构建阶段:带工具链的官方镜像
  • 运行阶段:最小运行时镜像

边界条件是:
如果你依赖 native module、字体库、系统证书、调试工具,过度极简会提高排障成本。


2. 固定依赖与基础镜像版本

不要写这种:

FROM node:latest

更推荐:

FROM node:20.11-alpine

原因:

  • 构建结果更可复现
  • 避免某次上游更新导致行为变化
  • 便于审计和回滚

3. 减少镜像中无关内容

重点清理这些内容:

  • 包管理器缓存
  • 测试数据
  • 文档
  • .git
  • 本地构建输出
  • 调试工具
  • shell 历史记录或临时文件

4. 使用非 root 用户运行

最小要求:

USER node

如果是自定义用户:

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

这是生产环境很基础但非常重要的一步。


5. 在 CI 里做镜像扫描

常见目标包括:

  • 基础镜像系统包漏洞
  • 应用依赖漏洞
  • 敏感文件泄露
  • 高危配置,如 root 用户、过多 capability

即使你暂时不做签名和准入策略,先把扫描接上,收益也很高。


6. 控制镜像层与指令顺序

把稳定、不常变化的步骤放前面,把易变化的源码放后面。
这样缓存利用率会更高,构建速度提升很明显。

一个典型排序是:

  1. 选择基础镜像
  2. 设置工作目录
  3. 复制依赖文件
  4. 安装依赖
  5. 复制业务代码
  6. 构建产物
  7. 进入运行阶段并复制最小必要内容

7. 尽量做到“镜像即制品”

也就是:

  • 同一镜像可在测试、预发、生产复用
  • 环境差异通过环境变量注入,而不是在镜像里写死
  • 构建一次,到处运行

这样发布链路才稳定,回滚也简单。


一个更贴近生产的发布示例

下面给一个稍微完整一点的构建与发布命令示例。

构建

docker build -t registry.example.com/demo-app:1.0.0 \
  -t registry.example.com/demo-app:git-$(git rev-parse --short HEAD) .

本地冒烟验证

docker run --rm -d --name demo-app-test -p 3000:3000 registry.example.com/demo-app:1.0.0
curl http://localhost:3000/
docker stop demo-app-test

推送镜像

docker push registry.example.com/demo-app:1.0.0
docker push registry.example.com/demo-app:git-$(git rev-parse --short HEAD)

部署时使用固定版本

docker run -d \
  --name demo-app \
  -p 3000:3000 \
  -e NODE_ENV=production \
  registry.example.com/demo-app:1.0.0

这里的原则很简单:
部署永远用明确版本,不用模糊标签。


方案取舍:不是所有项目都该极限瘦身

做镜像优化时,容易陷入一种误区:
“只要镜像还能再小一点,就一定更好。”

其实未必。下面是几个常见取舍点:

方案优点缺点适用场景
单阶段构建简单直接镜像大、风险高本地临时验证
多阶段 + slim兼容性和体积平衡仍比极简镜像大大多数生产服务
多阶段 + alpine体积更小兼容性需验证依赖简单的服务
distroless攻击面小排障不方便安全要求高、流程成熟的团队

我的建议是:

  • 中小团队先落地多阶段构建 + 非 root + 漏洞扫描
  • 不要一上来就追求 distroless 或极限最小镜像
  • 先把“标准化”做好,再做“极致优化”

总结

如果把这篇文章压缩成几条最值得执行的建议,我会给这份清单:

  1. 一定用多阶段构建,把构建工具和运行环境拆开
  2. 一定写 .dockerignore,减少构建上下文
  3. 优先复制依赖清单,再安装依赖,提高缓存命中
  4. 最终镜像只保留运行需要的文件
  5. 使用非 root 用户运行
  6. 不要依赖 latest,始终发布明确版本
  7. 把漏洞扫描接入 CI/CD

最后给一个判断标准:
如果你的镜像里还保留编译器、源码、测试文件、包管理缓存、root 权限,那它大概率还没有准备好进入生产环境。

镜像瘦身的目标从来不只是“省几十 MB”,而是让你的构建更快、发布更稳、攻击面更小。
当你把多阶段构建当成默认习惯,而不是“高级技巧”时,Docker 才真正开始服务工程效率。


分享到:

上一篇
《大模型应用中的 RAG 落地实践:从知识库构建到检索增强效果优化》
下一篇
《面向中型业务的集群架构实战:从高可用部署、服务发现到故障切换的落地设计》