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

《Docker 镜像瘦身实战:从多阶段构建到层缓存优化的中级指南》

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

Docker 镜像瘦身实战:从多阶段构建到层缓存优化的中级指南

很多团队在用 Docker 一段时间后,都会遇到一个很现实的问题:镜像越来越大,构建越来越慢,CI 越跑越久,发布也越来越“肉”

我自己第一次认真做镜像瘦身,是因为一个 Node.js 服务镜像做到了 1GB 以上。构建慢、推送慢、拉取慢,线上回滚也慢。后来回头看,问题并不神秘:把不该打进镜像的东西都打进去了,把本可以缓存的步骤放错了位置,还把构建工具链也一起带到了生产环境。

这篇文章不讲太“玄”的理论,而是按实战思路,带你把一个常见应用镜像一步步优化下来,重点放在两个方向:

  1. 多阶段构建:只把运行时真正需要的内容放进最终镜像
  2. 层缓存优化:让经常不变的步骤尽量命中缓存,减少重复构建

适合已经会写基础 Dockerfile、但希望进一步把镜像做小、做快的中级读者。


背景与问题

先看几个典型症状:

  • 基础业务服务镜像动不动几百 MB,甚至上 GB
  • 代码改一行,npm install / pip install / go mod download 又来一遍
  • CI/CD 构建时间持续膨胀
  • 镜像里混进源码、测试文件、构建缓存、包管理器缓存
  • 生产镜像里居然还带着编译器、调试工具、Git

这些问题表面上看是“镜像大”,但本质一般分成两类:

1. 内容装太多

比如:

  • 使用 node:latestpython:latest 这种偏大的基础镜像
  • .git、测试数据、日志、构建产物、依赖缓存都复制进去了
  • 构建依赖和运行依赖没分开
  • 临时文件在某一层创建了,即使后面删除,那层体积还在

2. 构建顺序不合理

Docker 构建依赖层缓存。如果 COPY . . 放得太早,那么任何代码改动都会让后续层全部失效。最常见的后果就是:

  • 依赖重装
  • 编译重跑
  • 镜像重打包
  • CI 时间暴涨

前置知识与环境准备

本文示例以一个简单的 Node.js Web 服务为例,但方法也适用于 Go、Java、Python 等项目。

环境准备

  • Docker 20.x 及以上
  • 推荐启用 BuildKit
export DOCKER_BUILDKIT=1

验证 Docker 版本:

docker version

示例目录结构:

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

核心原理

在动手之前,先把几个关键原理捋顺。理解了这部分,后面很多优化动作就不是“背答案”,而是自然推出来的。

原理 1:Docker 镜像是分层的

每条 RUNCOPYADD 等指令,都会生成一个新层。镜像不是一个单文件,而是一组叠加的只读层。

flowchart TD
    A[基础镜像层] --> B[安装系统依赖]
    B --> C[安装应用依赖]
    C --> D[复制源码]
    D --> E[构建产物]
    E --> F[容器运行时视图]

这里最容易误解的一点是:

你在后续层删除前面层里的文件,并不会真正减少前面层的体积

比如:

RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

虽然第二层删除了缓存文件,但如果第一层已经把大量缓存写进去了,镜像总体仍然会受影响。更好的方式是把相关操作合并到同一个 RUN 中。

原理 2:缓存命中取决于指令和上下文是否变化

Docker 会从上到下构建。某一层缓存失效后,后续层通常都要重建。

例如:

COPY . .
RUN npm install

只要项目里任何文件变了,COPY . . 这一层就变,后面的 npm install 也会重跑。

但如果改成:

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

那只要依赖声明文件没变,npm ci 就可以复用缓存。

原理 3:构建环境和运行环境往往不是一回事

很多应用构建时需要:

  • 编译器
  • 打包工具
  • 头文件
  • Git
  • 测试工具

但运行时只需要:

  • 二进制文件
  • 构建后的静态资源
  • 必要的运行时依赖

这就是多阶段构建的价值:前面阶段负责“生产”,最后阶段只负责“交付”

flowchart LR
    A[builder 阶段<br/>安装编译工具/下载依赖/构建] --> B[生成 dist 或二进制]
    B --> C[runtime 阶段<br/>仅复制运行所需文件]
    C --> D[更小的生产镜像]

实战代码(可运行)

下面我们从一个“能跑但不优”的 Dockerfile 开始,逐步优化。


示例应用

先准备一个最小 Node.js 服务。

package.json

{
  "name": "demo-app",
  "version": "1.0.0",
  "description": "Docker image optimization demo",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "build": "mkdir -p dist && cp -r src/* dist/"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

src/index.js

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

app.get('/', (req, res) => {
  res.send('hello docker slim image');
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`server listening on ${port}`);
});

第一步:先看一个“常见但不优”的 Dockerfile

FROM node:18

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

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

这个写法的问题很典型:

  • node:18 默认镜像不算小
  • COPY . . 太早,导致缓存命中差
  • npm install 会安装更多不必要内容,且依赖版本可能漂移
  • 构建工具和源码都保留在生产镜像中
  • 没有 .dockerignore

构建:

docker build -t demo-app:fat .

运行:

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

第二步:先加 .dockerignore

这一步经常被低估,但收益很大。它影响的是构建上下文大小。上下文越大,发送给 Docker daemon 的内容越多,COPY 越容易无谓失效。

.dockerignore

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

你可以先观察构建日志里的这一行:

Sending build context to Docker daemon

如果没有 .dockerignore,这个上下文大小可能非常夸张。很多项目还没开始构建,时间已经花在传输无关文件上了。


第三步:优化层顺序,先命中依赖缓存

改成这样:

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build

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

这里做了两件事:

  1. 基础镜像换成 node:18-alpine
  2. 先复制依赖声明,再安装依赖,最后复制源码

为什么有效?

  • 代码改动频繁,依赖文件改动相对少
  • npm ci 尽量待在变化较少的层上,就能复用缓存

不过它仍然有两个不足:

  • 构建依赖和运行依赖还混在一起
  • 最终镜像里仍有源码和整个 Node 运行环境的“构建痕迹”

第四步:使用多阶段构建

下面是更实战的版本。

Dockerfile(推荐版)

FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY src ./src
RUN npm run build


FROM node:18-alpine AS runtime

WORKDIR /app
ENV NODE_ENV=production

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

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

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

这个版本的思路是:

  • builder 阶段负责安装依赖、复制源码、执行构建
  • runtime 阶段只保留运行所需依赖和构建产物

构建:

docker build -t demo-app:slim .

运行:

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

测试:

curl http://localhost:3000

输出:

hello docker slim image

第五步:进一步优化缓存与构建速度

如果你已经启用 BuildKit,可以使用缓存挂载优化依赖下载速度。

Dockerfile(BuildKit 缓存优化版)

# syntax=docker/dockerfile:1.4

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

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

这里的 --mount=type=cache 不会把缓存直接烘焙进最终镜像层,而是用于加速重复构建。尤其在 CI 或本地反复构建时,体感会很明显。


构建流程可视化

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant B as builder 阶段
    participant R as runtime 阶段

    Dev->>Docker: docker build
    Docker->>B: 复制 package*.json
    B->>B: npm ci
    Docker->>B: 复制 src
    B->>B: npm run build
    Docker->>R: 复制 package*.json
    R->>R: npm ci --omit=dev
    B->>R: 复制 dist
    R-->>Dev: 输出瘦身后的镜像

第六步:验证镜像是否真的变小、变快

优化不是“看起来像优化”,最好做验证。

查看镜像体积

docker images | grep demo-app

查看层组成

docker history demo-app:slim

你会更直观看到哪些步骤生成了较大的层。

对比构建缓存是否命中

第一次构建:

docker build -t demo-app:slim .

只修改 src/index.js 后再次构建:

docker build -t demo-app:slim .

理想情况:

  • COPY package*.json 命中缓存
  • npm ci 命中缓存
  • 只有复制源码和构建步骤重跑

逐步验证清单

你可以按下面这份清单来检查自己的 Dockerfile 是否已经走在正确方向上:

  • 是否使用了更合适的基础镜像,而不是默认 latest
  • 是否配置了 .dockerignore
  • 是否把依赖安装步骤放在源码复制之前
  • 是否使用了 npm ci 而不是 npm install
  • 是否通过多阶段构建隔离了构建环境和运行环境
  • 最终镜像是否只保留运行所需文件
  • 是否清理了包管理器缓存
  • 是否使用非 root 用户运行
  • 是否通过 docker history 看过镜像层
  • 是否对构建时间和镜像大小做过前后对比

常见坑与排查

这部分我踩过不少坑,很多问题其实不是 Docker “有毛病”,而是镜像构建逻辑没设计好。

坑 1:删除文件了,为什么镜像还是大?

典型写法:

RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

问题在于这些操作分成了多层。删除发生在后面的层,前面层已经把体积留住了。

更好的写法:

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

坑 2:COPY . . 一改代码就全量重建

这通常是缓存失效的根因。排查方式:

  1. 看 Dockerfile 中 COPY . . 是否过早出现
  2. 看依赖安装步骤是不是放在它后面
  3. 看是否缺少 .dockerignore

坑 3:Alpine 镜像更小,但应用反而构建失败

alpine 基于 musl libc,一些原生模块或二进制依赖在编译、运行时可能会有兼容问题。比如 Node 的某些原生扩展、Python 一些带 C 扩展的包。

排查思路:

  • 查看构建日志中是否出现编译错误
  • 检查依赖是否需要 glibc
  • 尝试切换到 debian-slim 变体验证

边界条件很重要:不是所有项目都适合 Alpine。如果为了省几十 MB,换来调试成本暴涨,未必划算。

坑 4:多阶段构建后,程序启动报文件找不到

常见原因:

  • 只复制了构建产物,没复制运行必须的配置文件
  • 工作目录不一致
  • CMD 指向旧路径
  • 构建产物目录和实际输出目录不一致

排查命令:

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

进入容器后直接看文件:

ls -R /app

坑 5:依赖缓存没命中

检查这几项:

  • package-lock.json 是否频繁变化
  • 是否在依赖安装前执行了 COPY . .
  • CI 是否每次都在全新环境中构建
  • 是否启用了 BuildKit 缓存挂载

坑 6:镜像小了,但构建仍慢

这说明你优化了“产物体积”,却没真正优化“构建路径”。常见原因:

  • 网络下载依赖仍是主要瓶颈
  • 编译步骤本身耗时长
  • 没有远程缓存策略
  • 基础镜像层每次都在重新拉取

这时应分开看两个指标:

  • 镜像大小
  • 构建时长

它们相关,但不是一回事。


安全/性能最佳实践

镜像瘦身不只是为了“看上去专业”,它也直接影响安全和交付效率。

1. 固定基础镜像版本,不要滥用 latest

不推荐:

FROM node:latest

推荐:

FROM node:18-alpine

更进一步,可以固定到更具体的版本标签。这样可重复性更好,也更利于排查问题。

2. 使用最小满足需求的基础镜像

选择顺序一般可以这样考虑:

  • 能用 distroless:优先考虑
  • 不行再看 alpine
  • Alpine 不兼容时用 debian-slim

但记住:兼容性优先于极致瘦身

3. 不要把构建工具带进生产镜像

例如:

  • gcc
  • make
  • git
  • curl
  • 调试工具链

这些东西既占空间,也扩大攻击面。

4. 使用非 root 用户运行

USER node

或者自己创建运行用户。即使是内部服务,我也建议养成这个习惯。

5. 减少不必要的包和缓存

例如 Node 项目:

RUN npm ci --omit=dev && npm cache clean --force

系统包安装时:

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

6. 关注镜像中的敏感信息

不要把这些东西打进镜像:

  • 私钥
  • .env 生产密钥
  • 云平台凭证
  • 私有仓库 token

如果构建时需要使用密钥,优先考虑 BuildKit secret,而不是 ARG 或直接 COPY

7. 把“变动少”的内容放前面

一条经验公式:

  • 基础环境
  • 系统依赖
  • 应用依赖声明
  • 安装依赖
  • 源码
  • 构建
  • 启动命令

按这个顺序,缓存利用率通常不会太差。


一张状态图:从“臃肿”到“可发布”

stateDiagram-v2
    [*] --> 初始镜像
    初始镜像 --> 加入dockerignore: 排除无关文件
    加入dockerignore --> 调整层顺序: 优化缓存命中
    调整层顺序 --> 多阶段构建: 分离构建与运行
    多阶段构建 --> 清理缓存与临时文件
    清理缓存与临时文件 --> 非root运行
    非root运行 --> 可发布镜像
    可发布镜像 --> [*]

一个更通用的 Dockerfile 模板

如果你想把思路迁移到自己的 Node 项目,可以从这个模板出发:

# 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
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

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

适用前提:

  • 项目有明确的构建产物目录,如 dist/
  • 运行时不依赖源码中的其他文件
  • 依赖管理使用 lockfile

如果你的应用在运行时还需要模板、配置、静态资源,那就把这些目录明确复制进去,不要默认依赖 COPY . . 一把梭。


总结

做 Docker 镜像瘦身,最有效的不是“疯狂删文件”,而是抓住两个核心点:

  1. 多阶段构建:让最终镜像只保留运行必需内容
  2. 层缓存优化:让依赖安装等重步骤尽量稳定命中缓存

你可以把本文的方法落到一个简单执行方案里:

  • 先加 .dockerignore
  • 再调整 Dockerfile 顺序:先复制依赖文件,再安装依赖,再复制源码
  • 引入多阶段构建,拆分 builder 和 runtime
  • 清理缓存、减少系统包、使用非 root 用户
  • docker history 和构建日志验证,而不是凭感觉优化

最后给一个很实际的建议:不要盲目追求“最小镜像”,而要追求“足够小、足够稳、足够快”
如果 Alpine 让你兼容性问题不断,那就退回 slim;如果多阶段让构建逻辑过于复杂,也可以先在关键服务上落地。优化是工程权衡,不是体重竞赛。

如果你现在手里正好有一个构建慢、镜像大的服务,最值得先做的动作只有一个:
打开 Dockerfile,看看你的 COPY . . 是不是放早了。


分享到:

上一篇
《自动化测试中的接口回归体系设计:基于 Pytest 与持续集成的实战落地指南》
下一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速与安全优化指南》