GNU Guix 函数式包管理教程 / 第九章 可重现构建
第九章:可重现构建
9.1 什么是可重现构建?
可重现构建(Reproducible Build)是指:给定相同的源码和构建环境,任何人在任何时间、任何机器上执行构建,都能得到完全相同的二进制输出。
相同输入 ──► 相同构建过程 ──► 相同输出
│ │ │
│ 源码 │ 沙箱化 │ 二进制完全一致
│ 依赖 │ 确定性 │ SHA256 哈希相同
└──────────────┴──────────────┘
9.1.1 为什么可重现构建很重要?
| 重要性 | 说明 |
|---|---|
| 安全性 | 验证二进制是否确实来自源码,防止供应链攻击 |
| 可审计性 | 任何人都可以验证发布的二进制 |
| 可调试性 | 相同环境可重现 bug |
| 可回滚 | 精确回退到任意历史版本 |
| 协作 | 团队成员使用完全相同的环境 |
9.1.2 Guix 的可重现构建策略
Guix 在以下层面保证可重现性:
| 层面 | 措施 |
|---|---|
| 构建沙箱 | 每次构建在隔离的沙箱中执行 |
| 时间固定 | 构建时间被固定为 Unix epoch (1970-01-01) |
| 环境变量清理 | 只暴露必要的环境变量 |
| 路径固定 | Store 路径包含输入哈希 |
| 确定性排序 | 文件列表排序固定 |
| 去除不确定性 | 移除时间戳、UID 等非确定性信息 |
9.2 时间戳问题
9.2.1 时间戳如何影响可重现性
许多构建工具会在输出中嵌入时间戳:
// 源码中的 __DATE__ 宏
printf("Built on %s at %s\n", __DATE__, __TIME__);
// 每次编译输出不同 → 不可重现
9.2.2 Guix 的解决方案
Guix 构建沙箱自动将时间固定为 Unix epoch:
# 验证构建沙箱中的时间
guix build vim --no-substitutes 2>&1 | grep "epoch"
# 构建日志中可以看到:
# date: Thu Jan 1 00:00:00 UTC 1970
9.2.3 处理源码中的时间戳
;; 在包定义中添加补丁来移除时间戳
(package
;; ...
(source (origin
;; ...
(patches (search-patches "myapp-remove-timestamp.patch"))))
(arguments
(list
#:phases
#~(modify-phases %standard-phases
;; 方法一:设置 SOURCE_DATE_EPOCH 环境变量
(add-before 'configure 'set-source-date-epoch
(lambda _
(setenv "SOURCE_DATE_EPOCH" "0")))
;; 方法二:替换硬编码的时间戳
(add-after 'unpack 'remove-build-timestamp
(lambda _
(substitute* "src/version.c"
(("__DATE__")
"\"1970-01-01\"")
(("__TIME__")
"\"00:00:00\""))
#t))))))
9.2.4 SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH 是一个标准环境变量,许多工具支持它来控制时间戳输出:
# 手动设置
export SOURCE_DATE_EPOCH=0
# 在构建系统中
make SOURCE_DATE_EPOCH=0
# Guix 沙箱中自动设置为 0
支持 SOURCE_DATE_EPOCH 的工具:
| 工具 | 支持情况 |
|---|---|
| GCC | ✅ 使用 epoch 替代 __DATE__ |
| Python | ✅ .pyc 文件使用 epoch |
| gzip | ✅ 头部时间戳 |
| zip | ✅ 时间戳 |
| tar | ✅ 使用 --mtime |
| TeX | ✅ PDF 元数据 |
| Rust | ✅ 通过 env! 宏 |
9.3 环境变量控制
9.3.1 沙箱中的环境变量
Guix 构建沙箱严格控制环境变量:
沙箱中的环境变量:
├── HOME=/homeless-shelter (固定路径)
├── PATH=/gnu/store/.../bin (仅构建输入的 PATH)
├── SOURCE_DATE_EPOCH=0 (时间戳固定)
├── LANG=C.UTF-8 (locale 固定)
└── 其他通过 #:make-flags 传递的变量
9.3.2 自定义构建环境变量
(arguments
(list
#:make-flags
#~(list
;; 设置编译器
(string-append "CC=" #$(cc-for-target))
;; 设置安装前缀
(string-append "PREFIX=" #$output)
;; 自定义变量
"VERBOSE=1")
#:phases
#~(modify-phases %standard-phases
(add-before 'build 'set-env
(lambda _
;; 设置环境变量
(setenv "CFLAGS" "-O2 -g")
(setenv "LDFLAGS" "-Wl,-z,relro")
;; 禁止记录构建路径
(setenv "LC_ALL" "C.UTF-8")
#t)))))
9.3.3 禁止非确定性输出
(arguments
(list
#:phases
#~(modify-phases %standard-phases
;; strip 阶段移除调试信息中的构建路径
;; (这通常由标准阶段自动处理)
;; 处理 Go 二进制中的构建路径
(add-before 'build 'trim-build-path
(lambda _
(setenv "GOPATH" "/tmp/gopath")
(setenv "GOCACHE" "/tmp/gocache")
#t)))))
9.4 固定构建输入
9.4.1 Store 路径的哈希计算
Guix Store 中每个对象的路径都包含其所有输入的哈希:
/gnu/store/abc123...-vim-9.0
│
└── 哈希 = hash(源码 + 所有依赖 + 构建脚本)
哈希计算递归包含所有依赖:
vim 的 store 路径
= hash(
vim-source,
hash(ncurses,
hash(glibc,
hash(gcc, ...))),
build-script
)
9.4.2 内容寻址存储
Guix 正在向**内容寻址存储(Content-Addressed Storage)**迁移:
# 当前:输入寻址
/gnu/store/<hash-of-inputs>-vim-9.0
# 未来:内容寻址
/gnu/store/<hash-of-content>-vim-9.0
内容寻址的优势:
- 相同内容的包只存储一份
- 减少二进制替代的下载量
- 增强去重
9.4.3 固定源码版本
;; 使用精确的 commit hash,而非 tag
(origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/user/project")
(commit "abc1234def56789012345678901234567890abcd")))
(sha256 (base32 "0xyz...")))
9.5 补丁管理
9.5.1 添加补丁
(source (origin
(method url-fetch)
(uri (string-append "https://example.com/foo-"
version ".tar.gz"))
(sha256 (base32 "0abc..."))
;; 补丁列表
(patches (search-patches
"foo-fix-build.patch"
"foo-security-cve-2024-1234.patch"
"foo-reproducible-build.patch"))))
9.5.2 创建可重现构建补丁
# 创建补丁目录
mkdir -p /path/to/guix-patches
# 常见的可重现构建补丁类型:
# 1. 移除 __DATE__ 和 __TIME__
cat > foo-fix-date.patch << 'EOF'
--- a/src/version.c
+++ b/src/version.c
@@ -1,3 +1,3 @@
-char *build_date = __DATE__;
-char *build_time = __TIME__;
+char *build_date = "1970-01-01";
+char *build_time = "00:00:00";
EOF
# 2. 固定 sort 排序
cat > foo-fix-sort.patch << 'EOF'
--- a/Makefile
+++ b/Makefile
@@ -10,1 +10,1 @@
-LIST = $(shell ls src/)
+LIST = $(shell ls src/ | LC_ALL=C sort)
EOF
# 3. 移除用户名和主机名
cat > foo-fix-user.patch << 'EOF'
--- a/configure.ac
+++ b/configure.ac
@@ -5,2 +5,2 @@
-AC_DEFINE_UNQUOTED([BUILD_USER], ["$USER"])
-AC_DEFINE_UNQUOTED([BUILD_HOST], ["$HOSTNAME"])
+AC_DEFINE_UNQUOTED([BUILD_USER], ["guix"])
+AC_DEFINE_UNQUOTED([BUILD_HOST], ["guix"])
EOF
9.5.3 搜索和管理补丁
# Guix 补丁通常存储在 gnu/packages/patches/ 目录
ls ~/.config/guix/current/share/guix/patches/
# 使用 search-patches 函数搜索
# (search-patches "foo-fix.patch") 会在标准路径中搜索
9.6 常见不可重现因素
9.6.1 不确定性来源表
| 来源 | 示例 | 解决方案 |
|---|---|---|
| 时间戳 | __DATE__, __TIME__ | SOURCE_DATE_EPOCH、补丁 |
| 用户信息 | $USER, $HOME | 沙箱中固定为 “guix” 和 “/homeless-shelter” |
| 主机名 | $HOSTNAME | 沙箱中无主机名 |
| 随机数 | rand(), /dev/urandom | 固定种子或使用确定性随机 |
| 文件排序 | ls, glob | 使用 LC_ALL=C sort |
| 并行构建 | 线程竞争 | 禁用并行或修复并发 bug |
| 网络访问 | 下载动态内容 | 沙箱中禁止网络 |
| 绝对路径 | 硬编码 /usr/lib | 使用相对路径或 store 路径 |
| 构建路径 | 调试信息中的路径 | strip 或 --fdebug-prefix-map |
| Locale | 不同排序规则 | 固定为 C.UTF-8 |
9.6.2 诊断不可重现问题
# 方法一:两次构建对比
guix build vim --no-substitutes -o /tmp/build1
guix build vim --no-substitutes -o /tmp/build2
diffoscope /tmp/build1 /tmp/build2
# 方法二:使用 diffoscope 工具
guix shell diffoscope -- diffoscope /gnu/store/abc... /gnu/store/def...
;; 在 manifest 中添加诊断工具
(specifications->manifest
'("diffoscope" ; 二进制比较工具
"strip-nondeterminism" ; 移除非确定性信息
"diffutils" ; diff 工具
"binutils")) ; objdump 等
9.7 验证可重现性
9.7.1 Rebasing 验证
# 从源码重新构建并与官方二进制对比
guix build --no-substitutes --check vim
# --check 选项会:
# 1. 从源码构建
# 2. 与已有的 store 对象对比
# 3. 如果不一致则报错
9.7.2 批量验证
# 验证多个包
for pkg in vim git python gcc; do
echo "Checking $pkg..."
guix build --no-substitutes --check $pkg || \
echo "FAILED: $pkg"
done
9.7.3 社区验证基础设施
https://reproducible-builds.org/
https://tests.reproducible-builds.org/
Guix 参与可重现构建社区:
- 定期提交构建报告
- 修复不可重现的包
- 参与标准制定
9.8 可重现的开发环境
9.8.1 Manifest 驱动的可重现环境
;; project-env.scm — 完全确定性的开发环境
(specifications->manifest
'("gcc-toolchain" ; 编译器工具链
"cmake" ; 构建系统
"pkg-config" ; 库发现
"gdb" ; 调试器
"valgrind" ; 内存检查
"strace" ; 系统调用跟踪
"python" ; 脚本
"python-pytest")) ; 测试框架
# 从 manifest 创建可重现环境
guix time-machine --channels=channels.scm -- \
shell --manifest=project-env.scm
9.8.2 跨团队的环境一致性
# 步骤 1:团队负责人锁定环境
guix describe --format=channels > channels.scm
# channels.scm 和 manifest.scm 提交到 Git 仓库
# 步骤 2:团队成员使用
git pull
guix time-machine --channels=channels.scm -- \
shell --manifest=manifest.scm
# 现在所有成员拥有完全相同的开发环境
9.9 CI/CD 中的可重现构建
9.9.1 GitLab CI 配置
# .gitlab-ci.yml
stages:
- build
- test
build:
stage: build
image: guix
script:
- guix time-machine --channels=channels.scm -- \
build --no-substitutes my-project
test:
stage: test
image: guix
script:
- guix time-machine --channels=channels.scm -- \
shell --manifest=manifest.scm -- make test
9.9.2 GitHub Actions
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Guix
run: |
wget https://git.savannah.gnu.org/cgit/guix.git/plain/etc/guix-install.sh
chmod +x guix-install.sh
sudo ./guix-install.sh
- name: Build
run: |
guix time-machine --channels=channels.scm -- \
build --no-substitutes my-project
9.10 可重现构建检查清单
| 检查项 | 命令/方法 |
|---|---|
| 时间戳是否固定 | strings binary | grep -i date |
| 用户名是否固定 | strings binary | grep -i user |
| 主机名是否固定 | strings binary | grep -i host |
| 构建路径是否固定 | strings binary | grep /gnu/store |
| 文件排序是否确定 | 检查 Makefile 中的 sort 命令 |
| 并行构建是否安全 | --parallel-build? #f 测试 |
| 补丁是否应用 | guix build -v 3 查看日志 |
| 两次构建一致 | guix build --no-substitutes --check |
9.11 总结
本章深入讲解了可重现构建的原理与实践:
- 核心概念——相同输入产出相同输出
- 时间戳处理——SOURCE_DATE_EPOCH 和补丁
- 环境变量控制——沙箱中的确定性环境
- 输入固定——哈希计算和版本锁定
- 补丁管理——修复不可重现问题
- 诊断工具——diffoscope 和验证命令
- CI 集成——在持续集成中实现可重现构建
下一章我们将学习 Guix Home 的用户环境管理。