同一个 commit,两个人构建,dist/app.jar 一个大一点一个小一点,解包比对,差异在 META-INF/MANIFEST.MF 的一行 Built-By,以及几个 class 文件的字节序。没人能说清"多出来的是什么",于是没人敢确认这次发出去的东西和上次测过的是同一份。
可复现不是洁癖。它回答的问题是:线上跑的这个包,能不能被重新造出来。 答案是否定的时候,回滚、审计、故障复盘三件事都要打折。
1. 三个等级:字节一致、行为一致、依赖一致
| 等级 | 判定方式 | 达成成本 | 能支撑什么 | 支撑不了什么 |
|---|---|---|---|---|
| 依赖一致 | 锁文件 + 构建工具版本固定,依赖树可枚举 | 低,通常一天内完成 | 复现"用了哪些库" | 编译产物本身是否相同 |
| 行为一致 | 同源码同输入,测试与关键路径表现一致 | 中,需固定环境与顺序 | 换机器重跑、外包构建 | 二进制比对、供应链校验 |
| 字节一致 | 产物哈希完全相同 | 高,要消掉全部非确定性源 | 发布指纹、来源证明、离线重建 | 跨编译器版本、跨平台 |
多数团队的现状是:依赖一致做到了,行为一致靠运气,字节一致从未尝试。而"两次构建出的包大小不同"这个现象,落到等级上就是行为一致都不成立——因为连输入清单都没对齐。
判据:如果同一个 commit 在 CI 上重建两次,产物哈希不同,先别追求字节一致,先查依赖是否漂移。
2. 五个不可复现来源与各自的消除代价
<!-- pom.xml:最容易消掉的两个来源,一行开关 + 一条构建参数 -->
<properties>
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<reproducible>true</reproducible>
</configuration>
</plugin>
</plugins>
</build>
//─── 路径依赖:用 -trimpath 这类等价开关,按工具链核对参数名
$ go build -trimpath -ldflags="-buildid=" -o app ./cmd/app
$ unzip -l target/app.jar | head -5 // 看是否把绝对路径写进了条目
| 来源 | 具体表现 | 消除手段 | 代价 |
|---|---|---|---|
| 时间戳 | MANIFEST 里的构建时间、zip 条目时间 |
outputTimestamp / SOURCE_DATE_EPOCH |
极低,改配置 |
| 环境路径 | 编译期写进 class 的绝对路径、source path | -trimpath、相对路径构建、容器内固定路径 |
低,但要全流程一致 |
| 依赖版本漂移 | 无锁文件或用了动态版本区间 | 锁文件、拒绝 SNAPSHOT、镜像仓库代理 | 中,需要治理节奏配合 |
| 构建环境 | 编译器小版本、locale、JDK 发行版 | 固定镜像、固定 locale、固定工具链 | 中,镜像要跟着升级 |
| 构建顺序 | 并行打包导致 zip 条目顺序不同 | 排序式打包、关并行 | 高,可能拖慢构建 |
时间戳与路径两项加起来通常能吃掉大部分差异,成本却最低。先做这两项,再谈其他三项——顺序反了容易在做不动的环境中耗掉全部预算。
3. 依赖漂移:唯一同时影响安全与行为的一项
//─── 三类写法,可复现性差一个量级
dependencies:
- "com.yg:core:1.2.3" // 固定版:可复现
- "com.yg:core:1.2.+" // 区间版:解析结果随仓库内容变化
- "com.yg:core:LATEST" // 漂移最严重,解析不到才是常态
区间版本与 LATEST 的危险不在"可能升级",而在于它让"同源码"这个前提失效。源码没变,依赖变了,产物自然不同——而构建日志里通常只留下最终解析结果,不留"上次是多少"。
锁文件是对策,但它有明确的边界:
| 锁文件能锁 | 锁文件锁不住 |
|---|---|
| 依赖的精确版本 | 构建工具自身版本(mvn/gradle 的 app 版本) |
| 传递依赖的解析结果 | 构建工具依赖的插件版本 |
| 平台相关的条件依赖(部分) | 编译器小版本、JDK 发行版 |
| 校验和(若开启验证) | 仓库内容被替换(除非校验和强制) |
| — | 反射或运行期拉取的插件 |
锁文件锁不住构建工具本身,这一点最常被忽略。mvn 从 3.8 升到 3.9,插件解析行为可能变化,产物随之变化,而锁文件完全没有察觉。所以工具版本要和锁文件一样进仓库——用 wrapper。
//─── 工具版本进仓库,谁检出都用同一份
$ ./mvnw -v
$ cat .mvn/wrapper/maven-wrapper.properties
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
4. 环境一致:为什么"本地能构建,CI 不能"
| 差异点 | 本地 | CI | 表现 |
|---|---|---|---|
| JDK 发行版 | 自装 Temurin 17.0.9 | 镜像自带 17.0.11 | 编译警告不同,字节码可能不同 |
| locale | zh_CN.UTF-8 |
POSIX / C |
排序、大小写转换、日期格式不一致 |
| 默认时区 | Asia/Shanghai |
UTC |
构建期写进资源的时间差 8 小时 |
| 并行度 | -T 1C 手动 |
单线程 | zip 条目顺序不同 |
| 磁盘路径 | /Users/yg/proj |
/builds/abc/0/repo |
路径被写进产物 |
| 缓存 | 有本地缓存 | 冷启动 | 依赖解析走不同路径 |
"Cannot find symbol" 这类报错其实好处理,真正麻烦的是构建成功但产物不同。这类问题不会报错,只会在某个下游环节以"某台机器上行为不一样"的形式出现。
//─── 构建镜像固定四件事:JDK 版本、locale、时区、工作目录
FROM eclipse-temurin:17.0.11_9-jdk
ENV TZ=UTC LANG=C.UTF-8 LC_ALL=C.UTF-8
WORKDIR /build
固定 locale 会带来一个副作用:本地开发时输出里的中文排序和 CI 不一样。这个代价要提前接受,否则第一次遇到"本地测试过、CI 排序不同"就会有人把 locale 配置删掉。
5. 签名、校验与重建:可复现换来的东西
//─── 产物指纹与来源记录:发出去时一并存档
$ sha256sum target/app.jar > target/app.jar.sha256
$ sha256sum target/app.jar
9f2c1b... target/app.jar // 占位值,不是实测结果
| 能力 | 依赖可复现的哪个等级 | 缺了会怎样 |
|---|---|---|
| 发布指纹比对 | 字节一致 | 只能比对"版本号",而版本号是人工打的 |
| 来源证明(provenance) | 依赖一致 + 环境固定 | 说不清这个包由哪台机器、哪份源码产出 |
| 应急重建 | 依赖一致(最低要求) | 源码在但造不出同样的包 |
| 离线审计 | 依赖一致 + 镜像仓库留存 | 依赖被删就再也编不出来 |
| 增量排障 | 字节一致 | 无法通过二进制差异定位变更点 |
"构建产物必须能从源码重建"是应急恢复的前提,这句话在两种情况里最刺人:一是发布包被误删而源码还在,二是要确认三个月前那个版本的代码到底是什么。前者是工程问题,后者常常要应付审计。
注意签名的边界:签名证明"这个包由持有私钥的人签过",不证明"这个包和源码一致"。要证明后者需要可复现构建 + 独立重建比对,签名只是把两者绑定。
//─── 独立重建比对:重建者不需要信任产出方
$ git clone --depth 1 --branch v1.4.2 REPO_DIR && cd REPO_DIR
$ ./mvnw -B -DskipTests package
$ sha256sum target/app.jar // 与发布方公布的哈希逐位比对
6. 度量与边界
| 指标 | 计算方式 | 读数含义 |
|---|---|---|
| 构建差异率 | 同 commit 双构建哈希不一致的次数 ÷ 总构建次数 | 字节级目标的直接读数 |
| 依赖漂移次数 | 锁文件以外发生依赖变化的构建数 | 区间版本与 SNAPSHOT 的存量 |
| 重建成功率 | 从 tag 重建出可用产物的比例 | 应急能力的实测值 |
| 环境差异事件 | 因环境不一致导致的构建失败或行为差异 | 镜像与工具链治理的缺口 |
| 非确定性来源数 | 已知未消除的来源项 | 治理进度,不追求归零 |
"重建成功率"最值得定期实测:随便挑三个历史 tag,在干净环境里重建一次,看能不能编译通过、能不能跑起来。一年没人重建过的仓库,这个值通常不是 100%。
| 场景 | 建议 | 理由 |
|---|---|---|
| 一次性交付、之后不再构建 | 记录依赖清单即可 | 没有第二次构建,字节一致无意义 |
| 对内脚本、不对外分发 | 行为一致足够 | 没有人会比对它的哈希 |
| 产物只做短期缓存、可随时重造 | 依赖一致足够 | 存在时间短于依赖漂移的观察窗口 |
| 跨平台分发同一产物 | 只追求行为一致 | 编译器与平台的字节差异不可消除 |
| 需要供应链证明 | 必须做字节一致 | 这是该等级唯一的目的 |
字节级可复现的投入曲线很陡:从零到行为一致可能是一天,从行为一致到字节一致可能是几周,而且一旦换了编译器大版本就要重做。所以它只在**"产物需要被第三方独立验证"**这个前提下才值得。内部系统里坚持字节一致,收益通常收不回来。
7. 排错:五种"对不上"
| 现象 | 首查项 | 处理 |
|---|---|---|
| 两次构建哈希不同,大小差几 KB | MANIFEST 与 zip 条目时间 |
打开 outputTimestamp 与 reproducible 开关 |
| 加了锁文件仍然漂移 | 构建工具与插件版本 | wrapper 固定工具,插件版本显式声明 |
| 本地能构建,CI 报找不到符号 | JDK 发行版与 locale | 统一镜像,ENV LANG/LC_ALL |
| 解包后 class 文件字节不同但功能相同 | 编译器小版本或注解处理器 | 固定 maven.compiler.release 与工具链 |
| 产物里出现构建机的绝对路径 | 路径未裁剪 | -trimpath 或容器内固定 WORKDIR |
最后一项在 Java 里常表现为 SourceFile 属性或调试信息里的路径。它不影响运行,但会让两份"应该相同"的产物哈希不同——而这类差异最容易把人引向错误方向:去查代码差异,而代码一行没变。
8. 自测
- 双构建哈希不一致时,为什么先查依赖漂移而不是先追求字节一致?
- 锁文件锁住了依赖版本,为什么还可能漂移?
- 签名能证明"包和源码一致"吗?还差哪一步?
- 消掉时间戳与环境路径的成本最低,为什么很多团队反而不做?
- 哪些场景下字节级可复现的收益收不回来?
- 重建成功率该怎么实测,为什么不能看文档?