2964 字
约 9 分钟
13
S01-A11 · 构建产物的可复现:同样的源码为什么编出不同的包

同一个 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. 自测

  1. 双构建哈希不一致时,为什么先查依赖漂移而不是先追求字节一致?
  2. 锁文件锁住了依赖版本,为什么还可能漂移?
  3. 签名能证明"包和源码一致"吗?还差哪一步?
  4. 消掉时间戳与环境路径的成本最低,为什么很多团队反而不做?
  5. 哪些场景下字节级可复现的收益收不回来?
  6. 重建成功率该怎么实测,为什么不能看文档?
S01-A11 · 构建产物的可复现:同样的源码为什么编出不同的包
https://www.clxhxhhr.top/posts/6321/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。