GNU Guix Shell:从 Manifest 到可验证开发闭包
一名开发者把 manifest.scm 交给同事后,两台机器都能进入带 GCC 和 Python 的 Shell,却编译出了不同结果。调查发现,一台机器的 Guix channel 比另一台新,另一台又从未经团队批准的 substitute 下载了闭包;测试脚本还读取了宿主的 PATH、Locale 和用户目录。包名一致只是起点,不能证明包 revision、构建闭包和运行输入一致。
GNU Guix 的价值不只是“安装软件”,而是把包、profile、构建图和环境投影变成可以检查的对象。工程上要同时固定 channel 与 manifest,明确 Shell 是否继承宿主、容器暴露了哪些路径和网络,验证 substitute 的签名与来源,并为 profile generation、Store 和打包产物设计清理与回退。这样得到的才是一条可复核的环境链路。
先分清六个容易混淆的对象
Guix 的对象彼此关联,但承担不同责任:
| 对象 | 保存什么 | 能解决什么 | 不能代替什么 |
|---|---|---|---|
| channel | Guix 与包定义的代码来源和 commit | 固定“如何求值包” | manifest 中的包选择 |
| manifest | 包 specification、output 与 profile 内容 | 固定“环境需要什么” | channel revision、内核和外部服务 |
| Store item | 已构建包及其依赖对象 | 提供不可变闭包 | 用户配置、运行数据和 Secret |
| profile | 指向一组 Store item 的 generation | 激活、升级和回退用户环境 | 隔离宿主文件系统 |
guix shell | 临时构造并激活环境 | 项目开发、构建和测试 | 完整虚拟机或生产调度 |
guix pack | 从包图生成可搬运 bundle | 离线交付或受控运行入口 | 自动保证目标内核与硬件兼容 |
guix shell 默认创建临时 profile,并为命令调整环境变量;进程退出后不会把包永久写进用户 profile。不过 Store item 可能继续被其他 GC root 引用,也可能在回收前占用磁盘。退出 Shell、删除项目目录和释放 Store 空间是三件不同的事。
还要把三类 profile 分开:guix pull 通常更新的是 $HOME/.config/guix/current,它决定当前用户调用哪一代 Guix 与包定义;guix package 默认修改 $HOME/.guix-profile,自定义 --profile 则维护另一条持久 generation 链;guix shell 为一次命令构造临时环境,不应该悄悄写入前两者。把这三层混成“Guix 环境”,会让回滚对象和 GC root 都判断错误。
在受支持的执行面安装
Guix 的主要执行面是 GNU/Linux。原生 Windows 和 macOS 不应按同等支持承诺;Windows 团队可在受控 Linux VM 或 WSL2 发行版里做兼容实验,但必须把宿主内核、文件系统挂载和网络代理记入证据。正式入口应从 GNU Guix 安装手册 选择发行版包或官方二进制安装方案。
在一次性 Linux VM 中先确认系统能力:
uname -a
getconf GNU_LIBC_VERSION
df -h /
command -v guix
guix --version
guix describeguix describe 比单独的 guix --version 更重要:前者能显示当前 channel generation 及 commit。还要检查 readlink -f "$HOME/.config/guix/current",确认 Shell 实际解析到的 Guix profile;只比较版本字符串,发现不了两名开发者来自不同 channel generation。企业代理、私有 CA 和 substitute 需要在安装前准备,不能通过关闭 TLS 校验解决。安装过程涉及 daemon、build users 和 /gnu/store 权限时,应使用隔离 VM 验证,不在共享开发机上试错式修改系统用户。
安装完成后做最小正向验证:
guix shell coreutils -- sh -c '
command -v sha256sum
sha256sum --version | head -n 1
'预期命令退出码为 0,sha256sum 来自 Guix 环境。若出现 daemon 连接失败,先检查 guix-daemon、socket 与当前用户权限;若下载失败,区分 DNS、TLS、代理、substitute 不可达和签名拒绝,不能把它们统一写成“网络问题”。
用 channel 固定包定义的时间轴
manifest 里的 gcc-toolchain 或 python 是 specification,不包含完整包定义。两台机器若使用不同 channel commit,同一 specification 可能解析到不同 derivation。因此项目要提交经过审查的 channel 文件。稳妥的起点是从当前已验证的 Guix 实例导出完整 channel 集合,而不是手写一个缺少认证上下文的 URL 与占位 commit:
guix describe -f channels > channels.scm
guix time-machine -C channels.scm -- describe导出后要审查每个 channel 的 URL、完整 commit 与 introduction/认证链,再把 channels.scm 写入受控仓库;不能因为文件由命令生成就跳过来源审查。用固定 channel 调用对应 Guix 实例:
guix time-machine -C channels.scm -- describe
guix time-machine -C channels.scm -- shell coreutils -- sha256sum --versionguix time-machine 固定 Guix 自身及包定义入口,但第一次实现闭包仍可能需要 substitute 或本地构建。它也不会固定宿主 kernel、CPU microcode、网络响应和运行数据。架构评审应把“包定义可重放”和“所有运行行为完全相同”分开表述。
channel 的认证与引入链同样属于供应链。新增私有 channel、改变 introduction、URL、branch 或 commit 都应触发代码所有者审查;不能因为 Git 传输成功就默认包定义可信。
guix pull -C channels.scm 会移动当前用户的 Guix profile,而 guix time-machine -C channels.scm -- ... 用固定 channel 启动一次命令,不要求先替换日常 current profile。开发仓库更适合后者;平台维护者升级个人或共享工具面时,才在可回退窗口内执行 pull。两条路径的证据也不同:前者要保存 pull 前后的 guix describe 和 current generation,后者要保存 channels.scm 摘要与该次命令输出。channel 与认证机制解释了 introduction、提交签名和 pull/time-machine 的信任链。
用 Manifest 表达项目工具闭包
一个小型 C/Python 项目可以提交如下 manifest.scm:
(specifications->manifest
(list
"coreutils"
"gcc-toolchain"
"make"
"pkg-config"
"python"
"python-pytest"))然后始终通过同一入口运行:
guix time-machine -C channels.scm -- shell -m manifest.scm -- sh -c 'gcc --version | head -n 1; python --version; pytest --version'Guix Manifest 手册还允许选择 package output、转换和更复杂的包对象。团队应只在确有需要时使用复杂 Scheme 表达式,因为 manifest 本身是可执行求值输入;从未知分支运行它,风险不低于执行构建脚本。
语言依赖仍需语言自己的锁文件。Guix manifest 固定 Python 解释器和 Guix 提供的包,不会自动锁定 pip install、Maven、npm 或 Go module 的网络解析结果。可靠项目把系统工具闭包与语言依赖闭包分层:
channels.scm -> Guix 与包定义 revision
manifest.scm -> 编译器、系统库、通用工具
package lock -> 应用语言依赖
verify script -> 构建、测试与运行不变量选择环境继承、pure 与 container
普通 guix shell 会调整环境,但仍运行在宿主文件系统和内核上。需要降低宿主环境变量干扰时使用 --pure;需要进一步隔离文件系统视图时使用 --container。两者不是同义词:
guix time-machine -C channels.scm -- shell -m manifest.scm --pure -- sh -c 'env | sort; command -v gcc'
guix time-machine -C channels.scm -- shell -m manifest.scm --container --network -- sh -c 'id; mount; getent hosts example.invalid || true'--pure 仍共享宿主文件系统、kernel、设备与网络;--container 使用 Linux namespace 构造更受限的文件系统和进程视图,也不是 VM。是否开放网络、映射当前目录、暴露缓存或共享设备必须显式决定。读取源码可用只读 expose,确需写入构建目录时才用 share;不要把整个 home、SSH 目录或凭据目录映射进去。
容器默认会保留当前工作目录的可见性;若要让路径合同更明确,可用目标映射把只读源码与可写构建目录分开。下面的复制式构建要求 manifest 同时提供 coreutils 与项目所需构建工具:
build_dir="$(mktemp -d)"
if guix time-machine -C channels.scm -- shell -m manifest.scm --container --no-cwd \
--expose="$PWD=/workspace/src" \
--share="$build_dir=/workspace/build" \
-- sh -c 'cp -a /workspace/src/. /workspace/build/ && cd /workspace/build && make test'; then
rm -rf -- "$build_dir"
else
printf '保留失败证据:%s\n' "$build_dir" >&2
false
fi--no-cwd 避免再隐式暴露调用时目录,--expose=源=目标 只读挂载源码,--share=源=目标 才给构建目录写权限。实际选项仍要以目标 Guix 发布线的 guix shell --help 为准。若构建工具假设 /bin/sh、/usr/lib 或动态加载器位于传统 FHS 路径,可评估 --emulate-fhs,但这是一项兼容例外,会扩大环境表面,不能默认开启。
正反实验:证明宿主逃逸输入
在隔离 GNU/Linux 环境的临时仓库创建一个脚本,故意读取未声明变量。命令后的输出是判定标准,执行时仍要保存本机的退出码、路径和 Guix generation:
lab="$(mktemp -d)"
cd "$lab"
cat > probe.sh <<'EOF'
#!/bin/sh
set -eu
printf 'compiler=%s\n' "$(command -v gcc)"
printf 'marker=%s\n' "${HOST_ONLY_MARKER-unset}"
printf 'locale=%s\n' "${LC_ALL-${LANG-unset}}"
EOF
chmod +x probe.sh
HOST_ONLY_MARKER=leaked guix shell gcc-toolchain -- ./probe.sh普通 Shell 可能看到宿主 marker。再运行:
HOST_ONLY_MARKER=leaked guix shell gcc-toolchain --pure -- ./probe.sh预期 marker=unset,说明 pure 模式减少了环境变量继承。若脚本仍能读取宿主文件或访问网络,这不是 pure 失效,而是它没有承诺文件系统或网络隔离。最后在 container 模式中逐项暴露需要的路径,记录哪些输入必须加入合同。
反向实验还应把 manifest.scm 中一个 package specification 改成不存在的值。预期求值非零退出并指出无法解析 package;恢复 manifest 后再运行。不能用系统 PATH 中同名二进制让错误声明“看起来能工作”。
Profile Generation 适合持久工具,不等于项目 Shell
需要长期安装少量个人工具时,可以创建独立 profile:
profile="$HOME/.guix-extra-profiles/demo-toolchain/demo-toolchain"
mkdir -p "$(dirname "$profile")"
guix package --profile="$profile" --manifest=manifest.scm
guix package --profile="$profile" --list-generationsprofile 每次变更形成 generation,可切换或回滚。项目构建仍优先使用 guix shell,避免个人 profile 的隐式工具覆盖项目声明。出现“交互终端成功、CI 失败”时,立即比较:
type -a gcc python
guix package --list-profiles
guix package --profile="$profile" --list-generations
guix describe先保存当前 generation,再制造一次可逆变更,能够证明回退切换的是 profile 链,而不是 manifest 或 channel:
guix package --profile="$profile" --list-generations
guix package --profile="$profile" --install hello
guix package --profile="$profile" --list-generations
guix package --profile="$profile" --roll-back
guix package --profile="$profile" --list-generations
guix package --profile="$profile" --switch-generation=+1安装后预期 generations 新增一项,--roll-back 将 profile 链接切回上一代,--switch-generation=+1 再前进到刚才的一代。若 rollback 后当前 generation 已变化,但当前 Shell 仍能找到 hello,先用 type -a hello 判断它是否来自另一个 profile 或系统 PATH;不要据此断言 Guix 回退失效。这个实验只操作隔离 profile,不应拿默认用户 profile 或 $HOME/.config/guix/current 试错。
不要在多个脚本里对默认用户 profile 做增量 guix install,再把最终状态当作声明式环境。manifest 应是权威输入,generation 是激活历史,channel 决定求值代码,三者缺一不可。需要删除旧 generation 时先列出并确认当前项,再执行 guix package --profile="$profile" --delete-generations=<pattern>;被删除的一代失去回退入口,但闭包是否可回收仍取决于其他 GC roots。profile 与 generation 命令给出了相对代数、精确代号和保留窗口的 pattern 语义。
Substitute 是分发入口,也是信任边界
Guix 可以从 substitute server 下载已构建 Store item,减少本地编译。配置需要同时回答三个问题:向哪个 URL 查询、信任哪把公钥、不可用或签名错误时怎样处理。只配置 URL 而不管理授权 key,不构成可信缓存。
先查看当前设置和构建计划:
guix describe
guix weather -m manifest.scm
guix build -m manifest.scm --dry-runguix weather 用于观察 substitute 覆盖、缺失和可用性,不应把一次高覆盖率当成永久 SLA。私有 substitute 需要短期最小权限认证、TLS 和受控 key 分发;Token 不写进 manifest、channel、命令示例或日志。
反向实验可在隔离 VM 中配置错误的测试公钥或不可达 URL,并构建一个体积很小的闭包。预期应是签名拒绝、连接失败或明确回退本地构建,证据中保留 URL、key fingerprint、退出码和 daemon 日志,不保存私钥。绝不能为让实验通过而关闭认证。
Store 对象和构建日志可能含源码路径、构建参数或生成文件。任何 Secret 一旦进入 Scheme 字符串、构建 environment 或 source,就可能进入 Store、日志或 substitute。凭据应在运行阶段通过受控文件描述符、短期代理或专用 Secret 工具提供,不参与 derivation。
用 Pack 交付可搬运闭包
guix pack 可以把 manifest 的闭包制作成 tarball、Docker image tarball 等手册列出的格式,适合离线环境、受控工具包和一次性任务。先生成归档并记录内容身份:
guix time-machine -C channels.scm -- pack -m manifest.scm -S /bin=bin -f tarball命令输出 Store path。交付时保存 channel commit、manifest 摘要、pack Store path、文件摘要、目标 system 和验证命令。pack 并不会自动把应用源码、运行数据、证书和配置都装进去,也不能跨不兼容 CPU 或 kernel 随意运行。
若生成容器格式,还要单独扫描镜像内容、设置非 root 用户、入口命令和只读文件系统,并验证导入目标平台后的实际行为。Guix pack 解决闭包打包,不替代容器运行时安全或制品签名治理。
清理、回退和磁盘容量
先观察,不要直接全局 GC:
guix gc --list-roots
guix gc --references "$(guix build -m manifest.scm)"
guix gc --list-failuresprofile generation、显式 Store 引用和 guix gc --list-roots 列出的其他 root 会保护闭包。$HOME/.config/guix/current、$HOME/.guix-profile 与自定义 profile 是不同的 root 链,删除其中一条不会替另外两条腾出空间。不要假定一次 time-machine 调用会永久保留其闭包;是否受保护以实际 root 列表为准。安全清理顺序是:
确认当前项目与回滚窗口需要哪些 generation。删除明确过期的隔离 profile generation。移除实验 pack、临时 profile 和显式 root。
再次核对 guix gc --list-roots、profile generations 与 Store 容量,确认保留集合。在维护窗口按明确的回收量或可用空间目标执行 GC,并重新验证保留环境。
guix gc 按可达性判断,不理解业务价值。全局回收可能让离线复现环境消失,之后又需要从网络下载或本地重建。团队应持续观察 Store 大小、闭包增长、substitute 命中、构建时长、失败闭包和保留 generation 数量。
实验目录的清理可以明确到路径:
rm -rf -- "$lab"
guix package --profile="$profile" --list-generations
guix package --profile="$profile" --delete-generations
rm -f -- "$profile"
rm -rf -- "$(dirname "$profile")"
guix gc --list-roots不带 pattern 的 --delete-generations 会删除该 profile 除当前项以外的 generations,因此只应对这个实验 profile 使用;随后移除当前 profile 链接,才算释放该实验的持久 root。guix gc 没有用于预览删除候选的 --dry-run 选项;最后一步只重新列出仍在保护 Store item 的 roots,不会执行回收。是否真正回收、采用 --collect-garbage 或 --free-space,由共享主机 owner 按目标 Guix 发布线的帮助与容量预算决定。
项目接入:把入口收敛到一条命令
仓库建议保留:
channels.scm
manifest.scm
scripts/dev-shell
scripts/verify-environment
docs/environment-troubleshooting.mdscripts/dev-shell 使用 guix time-machine -C channels.scm -- shell -m manifest.scm,避免每个人手工拼参数。verify-environment 输出不含敏感值的证据:channel commit、system、关键工具版本、Store path、语言锁文件摘要、构建和测试结果。
CI 应从空 profile 或一次性容器执行同一入口,并安排全新 worker 验证目标闭包不依赖某个开发者 Store。需要验证源码重建时,在可承受成本的专用 worker 上使用 --no-substitutes,不要清空共享 substitute 或其他任务的 Store。若冷构建成本过高,可日常使用受信 substitute,但仍要周期性抽样源码构建,确认缓存没有成为唯一无法重建的权威。
失败证据怎样分型
package 找不到
先比较 channel commit,再用 guix search 和 guix show 检查名称。相同 manifest 在不同 commit 上结果不同,优先修复 channel 漂移,而不是在脚本里临时改名。
substitute 命中失败
分别检查 URL 可达、TLS、代理、公钥授权、目标 system 是否有 nar、daemon 日志和本地构建权限。下载失败后自动源码构建会让命令最终成功,但冷启动时间突然增加;“成功”不能掩盖缓存失效。
Shell 内仍调用系统工具
使用 type -a、command -v 和 guix gc --references 确认路径。检查是否遗漏 manifest 包、脚本使用绝对宿主路径、未启用 pure,或个人 profile 先于 Shell PATH。
container 中构建失败
检查源码是否 expose、构建目录是否 share、网络是否显式打开、FHS 假设、设备需求与文件权限。不要用 --share=$HOME 一次性消除所有错误,那会同时破坏隔离和敏感数据边界。
回滚后仍无法重现
profile rollback 只切换 profile generation;项目 channel、manifest、语言锁、数据库和外部服务可能已经变化。按环境合同逐层比较,不把 profile generation 当成整个系统快照。
架构选型与长期治理
Guix 适合重视自由软件供应链、Scheme 可组合声明、源码可重建、Store 闭包与离线 pack 的 GNU/Linux 团队。只需要固定 Node/JDK/Python 版本且团队跨平台较多时,运行时版本管理器成本更低;需要完整 Linux 用户环境但不希望引入 Guix 生态时,可评估 Nix 系工具;需要 kernel 级隔离、图形环境或非 Linux guest 时,VM 更直接。
选型不能只比较“包数量”。还要比较目标平台、包新鲜度、私有软件打包、channel 维护、substitute 运维、冷构建成本、Store 容量、开发者学习曲线、许可证策略、离线能力和退出成本。迁移时先选一个可丢弃项目双轨运行,比较工具身份、构建产物、测试、耗时和故障恢复,再决定扩大。
团队职责应明确:
平台 owner 维护 channel 基线、substitute、公钥、daemon、容量和升级窗口。项目 owner 维护 manifest、语言锁、验证脚本和业务不变量。安全 owner 审查 channel introduction、构建源码、Secret 注入和缓存数据边界。
开发者不绕过 time-machine、pure/container 参数和项目入口,不把个人 profile 结果冒充团队证据。
退役 Guix 时,要先导出 channel/manifest/pack 和必要构建证据,迁移项目入口,再删除 profile、GC root、私有 substitute 凭据和 daemon 权限。最后从另一套受支持环境完成干净构建,确认没有隐藏依赖 /gnu/store 绝对路径。
交付前核对
channel commit、manifest、语言锁和验证脚本都已进入版本控制。目标 system、宿主逃逸输入和 pure/container 选择有明确证据。未知 manifest、channel 和构建脚本按代码执行入口审查。
substitute URL、公钥、认证、回退和源码重建路径都能验证。正例能从干净环境构建,反例会非零失败且留下可定位证据。Secret 不进入 Scheme、Store、构建日志、pack 或 substitute。
profile generation、pack、GC root、Store 容量和回滚窗口分层管理。项目退出时能移除 profile、凭据、缓存授权与绝对 Store 依赖。
