一次上线"只改了一个配置默认值",而部署时"镜像 800MB 全部重新下载",于是"滚动发布从 30 秒变成 6 分钟"。原因是 Dockerfile 把"复制源码"写在了"安装依赖"之前——于是"任何代码改动"都会让"依赖层"失效。
镜像分层的规则很机械:每条指令一层,改动让"该层及之后的所有层"失效。这个"顺序决定复用"的机制很朴素:每一层都要"输入完全相同"才命中缓存,而"输入"包括"上一层的产物"——于是"靠前的一层变了,后面全废"。
而"顺序"决定了"哪些层能复用"。
1. 分层的三条事实
| 事实 | 含义 |
|---|---|
| 每条指令一层(多数) | 层数 = 指令数 |
| 层"内容不变"才复用 | 拷贝/安装都会变 |
| 改动"之后全部重建" | 顺序即成本 |
第三项是"成本放大器":把"变化快的"放在后面,把"变化慢的"放在前面——于是"改代码"只重建最后一两层。
2. "顺序"的三种写法
| 写法 | 改代码时的重建层数 |
|---|---|
| 先 COPY 源码,再安装依赖 | 全部(最差) |
先 COPY 依赖清单(pom.xml/package.json),再安装 |
2 层(好) |
| 再加"依赖缓存挂载"(BuildKit) | 1 层(最好) |
第二项的"前提"是"依赖清单单独存在":pom.xml/package-lock.json/go.sum 要能"只拷清单"而不拷源码——而"单文件清单"正好满足。
第二项是"最常见也最有效"的改动:先拷清单、装依赖、再拷源码——于是"改代码"不影响"依赖层"。
// 反例:COPY . 在前 → 任何改动都让"装依赖"重来
COPY . /app
RUN mvn -q dependency:go-offline && mvn -q package
// 正解:先依赖清单,再依赖,再源码
COPY pom.xml /app/
RUN mvn -q dependency:go-offline # 只有 pom 变了才重跑
COPY src /app/src
RUN mvn -q package # 只有源码变了才重跑
"层是否命中"要能"看见",否则"调顺序"是盲调:
// 两种"看缓存"的方式
docker build --progress=plain . # 输出里 CACHED 表示命中
docker build --no-cache . # 对照:全量耗时(差值 ≈ 缓存收益)
// 判据:改一行代码后,"CACHED" 的行数应 ≥ 除最后一层外的全部
3. "层缓存"被破坏的三种原因
| 原因 | 表现 |
|---|---|
指令里有"不稳定输入"(apt-get update 与 RUN 同行早) |
每次都重建 |
COPY . 带进"不该进"的文件(日志/临时文件) |
层内容总在变 |
构建参数(ARG)变化 |
之后全失效 |
第二项很常见:.git、node_modules、target 被 COPY . 带进镜像第二项的"修法"是".dockerignore 写"反向白名单"":不是"排除什么",而是"只包含什么"——因为"新产生的临时文件"总会漏。
——于是"什么都没改"也会"层不同"。
4. "镜像大小"与"层数"的关系
| 手段 | 效果 |
|---|---|
| 多阶段构建(build → runtime) | 只留运行所需 |
合并 RUN(减少层) |
变小(但"缓存粒度"也变粗) |
--no-install-recommends / 清缓存 |
变小 |
第二项的"实践建议"是"把"同一目的的多条命令"合并":apt-get update && apt-get install && rm -rf /var/lib/apt/lists/* 合成一条——于是"缓存"与"清理"同时成立。
第二项是"权衡":合并 RUN 会"减少层数",但也让"缓存粒度变粗"——于是"合并哪几条"要看"哪部分最容易变"。
"多阶段"的收益可以量化,而它常被"单阶段"掩盖:
// 两阶段(构建 800MB → 运行 180MB)
FROM maven:3.9-eclipse-temurin-17 AS build
COPY pom.xml . && RUN mvn -q dependency:go-offline
COPY src ./src && RUN mvn -q package -DskipTests
FROM eclipse-temurin:17-jre-alpine
COPY --from=build /app/target/app.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
// 关键:运行镜像里没有 maven、没有源码、没有 .m2
5. "分层"之外的三种优化
| 手段 | 收益 |
|---|---|
| 精简基础镜像(alpine/distroless) | 拉取更快 |
| 只拷"运行产物"(多阶段) | 体积降一个量级 |
| 分层压缩/惰性拉取(registry 支持) | 启动更快 |
第二项是"最直接的":**"编译用 JDK 镜像、运行用 JRE/Alpine"**第二项的"注意"是"运行时需要的东西别漏":Alpine 用 musl,某些"本地库"(glibc 依赖)会跑不起来——于是"换 Alpine"要"跑一遍集成测试"。
——于是"800MB 变 200MB"。
"构建不可复现"(同源码不同哈希)有三个常见来源,要逐一排除:
// 三个来源
1 时间戳:COPY 会带上"文件 mtime" → 用 --build-context 或 COPY 后 touch 固定
2 随机/网络:apt 源、依赖快照版本 → 锁定版本与镜像 digest
3 构建参数:ARG BUILD_TIME → 别把"会变的值"放进镜像
// 判据:同源码连续构建两次,镜像 digest 应相同
6. 排错:五种"镜像问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改一行代码全部重建 | 顺序错 | 依赖清单前置 |
| 层"永远不命中" | COPY . 带杂物 |
.dockerignore |
| 镜像特别大 | 单阶段 + 带构建工具 | 多阶段 |
| 构建"看起来一样但哈希不同" | 时间戳/随机 | 固定输入 |
| 拉取慢 | 基础镜像大 / 无本地缓存 | 换基础镜像 |
"基础镜像"的选择比"层顺序"影响更大,值得先做:
// 三种基础镜像的取舍
ubuntu:22.04(≈ 77MB):兼容最好,体积大
eclipse-temurin:17-jre(≈ 180MB):Java 运行首选
alpine + jre(≈ 100MB):最小,musl 兼容性要测
// 判据:先换基础镜像(收益大、风险一次评估),再调层顺序
7. 边界:什么情况不必打磨分层
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性镜像(实验) | 不重复构建 | 不管 |
| 镜像很小(< 50MB) | 重建成本低 | 不管 |
| 构建本就很快(< 30s) | 分层收益小 | 不管 |
| 已有"构建缓存服务"(远程 cache) | 由它兜 | 弱化顺序 |
最后一条与"CI"有关的边界:"分层优化"的收益在"CI 无本地缓存"时最大:全新 runner 每次"从零拉层",于是"层顺序"直接决定"每次构建拉多少"。而"有本地缓存"的开发机看不出差别。
判断口径一句话:"改一行代码后,要重建多少层"。多于 2–3 层,就值得调顺序;已经是"改代码只重建最后一层",再优化收益很小。
最后一个"团队"现实:"镜像分层"的收益在"部署时"体现,而"构建时间"在"开发时"体现——两者容易"只有一方推动"。所以它需要"一个能同时看到两边的人"来改 Dockerfile,而"只看构建时间"的人往往把"依赖安装"与"源码拷贝"合并(因为他觉得自己改代码不多),结果让"每次发布多拉 800MB"。而这类改动的"账"要按"每天部署几次 × 每次多拉多少"算,而不是按"构建快了几秒"。
还有一条"验收"建议:把"改一行代码后的镜像拉取量"写进 CI 输出——"本次构建:复用 12 层,重传 2 层,新增 1.2MB"。这个数字很小,但它让"层顺序失控"变成"可见指标";否则"顺序被改坏"只会在"某次发布的多等三分钟"里被感知,而没人会去追。
而"镜像大小"与"拉取速度"在"多节点集群"上会被"节点数"放大:50 个节点各拉 800MB,就是 40GB 流量——这也是"少一层"值钱的地方。
8. 自测
- 分层的三条事实里,哪一条是"成本放大器"?
- 为什么"先 COPY 依赖清单再安装"能只重建 2 层?
COPY .带进"杂物"为什么会让"层永不命中"?- 合并
RUN的收益与代价分别是什么? - "多阶段构建"为什么能把镜像"降一个量级"?
- 判断"是否值得打磨分层"的那个问题是什么?