2103 字
约 7 分钟
13
S06-A23 · 镜像分层:为什么"只改一行代码"要重新拉 800MB

一次上线"只改了一个配置默认值",而部署时"镜像 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. 自测

  1. 分层的三条事实里,哪一条是"成本放大器"?
  2. 为什么"先 COPY 依赖清单再安装"能只重建 2 层?
  3. COPY . 带进"杂物"为什么会让"层永不命中"?
  4. 合并 RUN 的收益与代价分别是什么?
  5. "多阶段构建"为什么能把镜像"降一个量级"?
  6. 判断"是否值得打磨分层"的那个问题是什么?
S06-A23 · 镜像分层:为什么"只改一行代码"要重新拉 800MB
https://www.clxhxhhr.top/posts/6648/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。