环境缓存、二进制来源与可追溯性
缓存快了十分钟,却把已经修复的工具链带了回来
一个远程开发工作区刚启动就能编译,开发者自然以为它使用了仓库里刚更新的锁文件。直到同一提交在禁用预构建后失败,团队才发现:工作区命中了旧快照,快照里已经装好的编译器来自另一条构建链;与此同时,本机 Nix 从 binary cache 取回了同名 Store Path,容器则通过可移动标签拉到了不同架构的 manifest。三个系统都显示“命中缓存”,却没有回答命中的对象由谁构建、对应哪组输入、是否经过允许的签名、能否从空状态重建。
缓存不是来源,它只是交付路径。可信命中必须保留原对象身份:传统 input-addressed Nix 输出路径由 derivation 输入决定,content-addressed Store Path 才把内容地址纳入身份;OCI 镜像由 manifest digest 标识;远程预构建则应绑定仓库提交、环境声明、平台和构建策略。只记录缓存 URL、镜像标签或“最近成功快照”,等于把位置误当身份;位置一旦被覆盖,旧日志再完整也无法证明当前拿到的字节是谁产生的。
先把“同一个环境对象”写成可核对的身份
三类缓存的载荷不同,但一次命中都要能回答五个问题:请求的输入身份是什么,解析出的不可变对象是什么,哪个构建身份产生了它,客户端依据什么信任它,失效后能否用同一输入重新产生等价结果。把这些字段落进项目配置,故障时才能区分“键算错了”“对象被替换了”“签名不可信”和“构建本身不可复现”。
# environment-cache-policy.yaml
schema: 1
project: your-project
inputs:
source_revision: "<commit-sha>"
environment_lock_digest: "sha256:<lock-digest>"
build_definition_digest: "sha256:<definition-digest>"
platform: "linux-amd64"
cache:
namespace: "your-org/your-project"
epoch: 3
trust:
policy_generation: 7
allowed_builders:
- id: "https://example.com/builders/environment"
oidc_issuer: "https://token.actions.githubusercontent.com"
allowed_signing_keys:
- "cache-prod-1:<public-key>"
revoked_signing_keys: []
require_signature: true
require_provenance: true
require_sbom: true
retention:
keep_last_successful_per_platform: 2
quarantine_on_policy_failure: true只有这些字段真正参与键计算和命中校验时,source_revision 才能阻止不同源码共享预构建,environment_lock_digest 才能约束包解析结果,build_definition_digest 才能捕获 Dockerfile、flake 或工作区模板变化,platform 才能防止 CPU 架构和操作系统混用。namespace 必须落实为独立读写权限、对象前缀和配额,不能只是日志标签。epoch 是对象失效旋钮:当旧缓存键遗漏关键输入或投毒影响面不明时递增它。policy_generation 是信任策略代际:密钥、OIDC issuer、builder 身份或来源约束变化时递增,并让消费端保存实际使用的代际。对象代际和信任代际不能共用一个数字,否则团队无法区分“对象应重建”和“对象未变但旧签名者已失去授权”。
信任链至少有四个独立判断。摘要回答“字节是否对应这个不可变身份”;签名回答“哪个密钥或证书身份认可了这些字节”;provenance 回答“哪个构建平台以哪些材料和构建定义产生了它”;SBOM 回答“扫描器识别到哪些组件”。签名有效不代表签名者仍被允许,provenance 存在不代表其中的 builder.id、源码仓库和提交满足策略,SBOM 完整也不证明制品由受控流水线产生。消费端必须把允许的 issuer、subject、builder、源码位置、构建类型和 subject digest 写成约束,不能只检查附件存在或命令返回零。
缓存键还必须排除凭据。代理令牌、私有源密码和云临时凭据进入键会泄露秘密,也会造成每次轮换都无法命中;它们应只在构建执行时注入,且不得写进 Store、镜像层、快照、日志或 SBOM。真正会改变输出的非秘密配置,例如目标平台、编译特性和系统库版本,则必须进入输入身份。两者分错,前者泄漏,后者串用。
在隔离目录启用 Nix file cache
Nix 是观察这条信任链最直接的入口,因为 binary cache 公开了 .narinfo、NAR 摘要和签名。Linux 与 macOS 可从 Nix 安装入口 获取安装脚本;Windows 应在 WSL2 的 Linux 文件系统内执行实验。先下载脚本、人工查看,再选择多用户安装。多用户模式会启动 daemon,需要管理员权限;受管设备不能授予该权限时,应使用团队提供的受控开发机,不要绕过终端策略。
这组命令要求可用的 <nixpkgs> 入口,并假定未启用 content-addressed derivations;require-sigs 默认只强制非 content-addressed Store Path 具有受信签名。多用户安装还要由管理员允许实验用户使用该 file substituter 和公钥,否则失败可能来自 daemon 权限而不是坏签名。
curl -L https://nixos.org/nix/install -o /tmp/install-nix
less /tmp/install-nix
sh /tmp/install-nix --daemon
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
nix --version安装验收条件是最后一条命令输出已安装版本。企业代理若在下载阶段返回 HTML 登录页,sh 往往报语法错误;先执行 file /tmp/install-nix 和 head /tmp/install-nix,确认拿到 shell 脚本,再处理代理和企业 CA。缓存实验使用 nix-command,不要求永久打开实验特性,命令逐次通过 --extra-experimental-features nix-command 启用。
建立隔离目录并生成一对仅用于实验的 binary cache 密钥。私钥负责给 .narinfo 签名,公钥交给客户端;私钥文件不得提交,也不能放进会被远程工作区快照捕获的目录。
mkdir cache-trust-lab && cd cache-trust-lab
nix-store --generate-binary-cache-key lab-cache-1 \
./lab-cache.private ./lab-cache.public
chmod 600 ./lab-cache.private
cat ./lab-cache.public接着构建一个最小输出,把它复制到本地 file:// cache。--no-link 不创建 result GC root,--print-out-paths 把 Store Path 留给后续验证。nix copy 的 --secret-key-files 让 cache 写入带签名的 narinfo。
out_path="$({
nix --extra-experimental-features nix-command build \
--impure --expr \
'with import <nixpkgs> {}; runCommand "cache-probe" {} "echo trusted-cache > $out"' \
--no-link --print-out-paths
})"
nix --extra-experimental-features nix-command copy \
--to "file://$PWD/cache-good" \
--secret-key-files "$PWD/lab-cache.private" \
"$out_path"
rg '^(StorePath|NarHash|NarSize|Sig):' cache-good/*.narinfo生成验收条件是 narinfo 同时出现 StorePath、NarHash、NarSize 和以 lab-cache-1: 开头的 Sig。StorePath 是请求身份,NarHash 校验 NAR 序列化内容,Sig 让客户端验证 cache 元数据是否由受信密钥签署。自定义 cache 的 substituters 与 trusted-public-keys 配置方式可对照 Nix binary cache 指南;trusted-substituters 控制非受信用户可以附加哪些 substituter,它不等于信任 cache 中的任意签名。
正向实验:删除本地输出后只从受信缓存恢复
先保存公钥文本,再删除本地 Store Path。删除失败并显示仍被引用时,运行 nix-store --query --roots "$out_path" 找出 GC root;不要为了一个实验执行全局 GC。确认没有业务 profile 或其他结果引用它后再删除。
public_key="$(cat ./lab-cache.public)"
nix --extra-experimental-features nix-command store delete "$out_path"
test ! -e "$out_path"
nix --extra-experimental-features nix-command copy \
--from "file://$PWD/cache-good" \
--option require-sigs true \
--option trusted-public-keys "$public_key" \
"$out_path"
cat "$out_path"
nix --extra-experimental-features nix-command store verify \
--sigs-needed 1 \
--trusted-public-keys "$public_key" \
"$out_path"验收条件是 cat 输出 trusted-cache,nix store verify 同时完成内容与至少一个受信签名的检查。由于恢复命令只执行 nix copy,这一步验证按同一 Store Path 从 cache 导入对象;它没有证明源码在任意机器上都能位复现,也没有证明构建者没有在允许输入之外读取网络。签名证明受信密钥认可了对象,构建隔离和来源声明还要补上“怎样产生”的证据。
反向实验:篡改签名必须明确阻断
复制一份 cache,只破坏 narinfo 的 Sig 字段,NAR 本体保持不变。这样能区分“下载损坏”和“身份不可信”:若客户端仍恢复成功,说明签名策略没有真正生效。
cp -a cache-good cache-bad
perl -pi -e 's/^Sig:.*/Sig: lab-cache-1:AAAA/' cache-bad/*.narinfo
rg '^Sig:' cache-bad/*.narinfo
nix --extra-experimental-features nix-command store delete "$out_path"
nix --extra-experimental-features nix-command copy \
--from "file://$PWD/cache-bad" \
--option require-sigs true \
--option trusted-public-keys "$public_key" \
"$out_path"拒绝验收条件是最后一条命令非零退出,错误特征包含无效签名、路径未被受信密钥签名或无法导入路径,并另行执行 test ! -e "$out_path" 确认对象未导入。若它成功,先执行 nix --extra-experimental-features nix-command config show | rg 'require-sigs|trusted-public-keys|substituters',检查 daemon 配置是否覆盖了命令参数,也要确认测试对象不是可免签的 content-addressed path。多用户安装中,客户端参数与 daemon 允许项的边界也可能导致“参数写了但服务端未接受”。修复不是把坏 cache 加进信任列表,而是恢复正确公钥和 narinfo,或隔离该 cache 后重建。
再做一次不可达实验,把 --from 改成不存在的 file://$PWD/cache-missing。错误应分型为路径不存在或查询失败,而不是签名错误。团队告警如果把网络失败、对象未命中和签名拒绝都记成“cache miss”,投毒事件会被普通冷启动噪声淹没。
撤销实验:旧签名仍正确,也必须失去准入资格
密钥轮换不是在信任列表后追加一把新公钥。若旧私钥疑似泄漏,旧对象的密码学签名仍可能完全正确,但授权已经失效。先生成第二代实验密钥,以新密钥发布同一个 Store Path;消费端只配置第二代公钥,再尝试从只含第一代签名的 cache 恢复:
nix-store --generate-binary-cache-key lab-cache-2 \
./lab-cache-2.private ./lab-cache-2.public
chmod 600 ./lab-cache-2.private
new_public_key="$(cat ./lab-cache-2.public)"
nix --extra-experimental-features nix-command copy \
--from "file://$PWD/cache-good" \
--option require-sigs true \
--option trusted-public-keys "$public_key" \
"$out_path"
nix --extra-experimental-features nix-command copy \
--to "file://$PWD/cache-rotated" \
--secret-key-files "$PWD/lab-cache-2.private" \
"$out_path"
nix --extra-experimental-features nix-command store delete "$out_path"
nix --extra-experimental-features nix-command copy \
--from "file://$PWD/cache-good" \
--option require-sigs true \
--option trusted-public-keys "$new_public_key" \
"$out_path"最后一条命令必须非零退出,且 test ! -e "$out_path" 成立;随后把 --from 换成 cache-rotated 应恢复成功。这里验证的是消费端信任根已经切换,不是简单证明新密钥能签名。生产轮换还要冻结旧写入身份、从准入策略删除旧 key ID、标记受影响对象、轮换读取端配置,并审计仍在使用旧 policy_generation 的客户端。若只轮换构建端私钥,旧客户端仍信任泄漏公钥,攻击者就仍可发布看似有效的对象。
把 Nix 的判断映射到 OCI 镜像
OCI 镜像以 descriptor 和 digest 组成内容寻址图。一个 tag 可以移动,digest 则标识 manifest 的确切字节;manifest 再按 digest 指向 config 与 layers。OCI Image Manifest 明确了这种 descriptor 关系,OCI Distribution Specification 则定义了 registry 通过 tag 或 digest 取 manifest 的行为。
启用容器缓存时,先安装团队支持的 Docker 或兼容 OCI 客户端,执行 docker version 确认 client 与 daemon 都可用。项目配置保留可读标签用于升级提示,但运行入口固定 digest:
services:
dev:
image: registry.example.com/your-org/dev-env@sha256:<manifest-digest>docker pull registry.example.com/your-org/dev-env@sha256:<manifest-digest>
docker image inspect \
registry.example.com/your-org/dev-env@sha256:<manifest-digest> \
--format '{{json .RepoDigests}}'拉取验收条件是输出含请求的 digest。若只写 :stable,registry 管理员或自动流水线可以让同一配置在两次拉取时解析到不同 manifest;“本机已有 layer 所以启动很快”会掩盖这个变化。多架构镜像还要记录 image index digest 与最终平台 manifest digest:固定 index digest 保证平台集合不变,运行时仍需记录实际选中的平台 manifest。平台错误常表现为 no matching manifest,修复应是补齐目标平台构建或更正平台声明,而不是强制运行不兼容二进制。
digest 只证明内容未被替换,不证明发布者被允许。镜像进入开发环境前应由策略验证签名主体和签名所绑定的 digest,命令形态取决于团队采用的签名客户端。以 Cosign 的 keyless 验证为例,身份和 OIDC issuer 都要写成精确允许值:
cosign version
cosign verify \
--certificate-identity 'https://example.com/builders/environment' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
registry.example.com/your-org/dev-env@sha256:<manifest-digest>签名验收条件是返回已验证 payload;身份不匹配、issuer 不匹配或签名绑定到另一个 digest 都必须非零退出。示例身份只是占位值,GitHub Actions 等构建身份应替换为实际证书中的精确 workflow identity,不能照抄域名。安装包应来自 Sigstore Cosign 发布入口,版本和验证参数由团队工具基线固定。不要把 cosign verify 的完整 JSON 原样上传公共日志,其中可能包含仓库、工作流和构建环境元数据;保留 digest、策略结果、签名主体和审计事件 ID 即可。
签名验证只说明某个身份签了这个 digest,不能代替构建来源声明。镜像准入还要验证 provenance attestation:先校验 DSSE 签名和证书身份,再要求 in-toto Statement 的 subject.digest 等于当前平台 manifest digest,predicateType 属于批准的 SLSA provenance 类型,builder、源码提交、构建定义和材料摘要满足策略。SLSA provenance 将 builder.id 定义为受信构建平台边界的标识,而不是一段任意描述;消费策略要按制品自身期望约束源码和 builder,不能把“任何 SLSA 声明”都视为可信。cosign verify-attestation 是验证入口,但仅有零退出仍不够;必须让策略引擎检查 predicate,且在 attestation 缺失、被隐藏或类型不符时 fail closed。参数随固定的 Cosign 版本维护,可对照 Sigstore attestation 验证文档 与 SLSA Build Provenance。
SBOM 要绑定对象,不能漂在标签旁边
SBOM 描述对象里有什么,签名回答谁认可该对象,来源声明描述它怎样被构建。三者都应以 Nix Store Path、OCI digest 或 prebuild snapshot digest 为 subject。只有一份按 :stable 周期生成的 SBOM,即使格式合法,也可能在标签移动后继续展示旧组件。
安装 Syft 后先确认版本,再从 digest 生成 CycloneDX JSON;发布时把 SBOM 作为 OCI referrer 或平台支持的等价附件绑定到 subject digest。SPDX 也可作为交换格式,选择取决于消费工具,但格式转换不能丢失包身份、版本、文件关系和 license 表达。SPDX 规范 可用于核对字段语义。
syft version
syft registry.example.com/your-org/dev-env@sha256:<manifest-digest> \
-o cyclonedx-json > sbom.cdx.json
jq -e '.bomFormat == "CycloneDX" and (.components | length > 0)' \
sbom.cdx.json
sha256sum sbom.cdx.json本地生成验收条件是 jq 返回成功并且组件数非零,但这一步只检查文件格式和最低内容,不证明它已经绑定镜像。发布端必须把 SBOM 作为 OCI referrer 或签名 attestation 关联到确切 subject digest;消费端既验证附件签名,也核对 subject、predicate type 和目标平台。OCI manifest 的 subject 是关联而不是把附件内容并入镜像 Merkle DAG,因此 registry 还必须保证 referrers 可发现,准入策略在附件缺失时拒绝。空 SBOM、扫描了错误平台 manifest、只有操作系统包而缺少语言包,都是拒绝发布的证据。环境快照里可能包含 IDE 索引、下载资产和构建产物,SBOM 生成器未必理解这些内容;不能把“扫描完成”误写成“快照所有文件都有组件归属”。对不可解析区域,至少保留文件摘要、生成步骤和数据分类。
远程 workspace prebuild 也必须有可重算的键
预构建通常先创建临时工作区,执行环境构建和初始化任务,再保存文件系统快照;新工作区命中快照后继续执行用户阶段的启动任务。以 Ona Prebuilds 为例,预构建会执行特定创建阶段命令并捕获开发容器文件系统,而用户上下文阶段的命令会在环境启动后运行。这个差异意味着“预构建成功”不等于“用户工作区就绪”,也意味着快照可能固化依赖、下载资产和构建输出。
平台侧至少要导出下面这组状态,字段名可映射到具体 API:
{
"prebuild_id": "pb_<id>",
"source_revision": "<commit-sha>",
"environment_lock_digest": "sha256:<lock-digest>",
"build_definition_digest": "sha256:<definition-digest>",
"platform": "linux-amd64",
"builder_identity": "service-account:environment-builder",
"snapshot_digest": "sha256:<snapshot-digest>",
"status": "<completed|failed>",
"policy_result": "<allow|deny>"
}启用步骤应从一个非敏感试点项目开始:创建最小权限服务账号,只授予读取试点仓库、读取构建所需项目级秘密和写入该项目 prebuild 的权限;选择单一平台规格;把依赖安装放进预构建阶段,把个人 dotfiles、用户令牌和交互登录留在启动阶段;触发一次 prebuild;从它创建工作区并运行项目探针;最后删除工作区和试点快照。验收证据应包括临时环境清理事件、新工作区命中的 prebuild ID,以及与状态记录一致的提交、锁摘要和平台;缺任一项都不能把“启动成功”记为可信命中。
反向实验只改锁文件,不改源码业务代码。若平台仍命中旧 prebuild,说明键遗漏 environment_lock_digest 或平台采用了“最近成功”而非输入匹配。此时先停止扩大试点,递增 cache epoch 或删除对应快照,修正键后从空状态重建。另一条反例是在预构建任务里写入 PREBUILD_TOKEN=<secret> 后扫描快照;正确设计应让任务读取临时凭据但不落盘,实验只能使用假令牌,并在结束后撤销。任何真实令牌一旦可能进入快照,都按泄漏处理,而不是只删快照。
项目接入要把快路径和慢路径放在同一个验证入口
项目不能只有“使用缓存”脚本。仓库应提交一个可执行的 scripts/build-environment-probe.sh,由项目 owner 在其中运行最小构建、测试并校验产物摘要;缓存命中与禁用缓存的重建都执行同一入口。探针比较工具版本、锁摘要、关键动态库、目标平台和一次真实构建结果,而不是只看命令退出码。入口缺失必须明确失败,不能静默跳过:
#!/usr/bin/env bash
set -euo pipefail
printf 'source=%s\n' "$(git rev-parse HEAD)"
printf 'lock=%s\n' "$(sha256sum flake.lock | cut -d' ' -f1)"
printf 'platform=%s-%s\n' "$(uname -s | tr A-Z a-z)" "$(uname -m)"
nix --version
docker version --format '{{.Client.Version}}'
probe=./scripts/build-environment-probe.sh
if [[ ! -x "$probe" ]]; then
printf 'missing executable project probe: %s\n' "$probe" >&2
exit 64
fi
"$probe"CI 或远程工作区创建任务先运行快路径并记录命中对象,再按批次运行禁用 substituter、清空镜像 layer 或跳过 prebuild 的慢路径。两条路径的耗时可以不同,身份字段和项目探针结果必须一致。慢路径若永远不跑,缓存会逐渐从加速层变成无法替换的隐式主存储。
投毒故障要沿“拒绝、隔离、换钥、重建”恢复
最危险的现象不是所有构建失败,而是只有部分开发者命中异常对象。先冻结写入并保存 cache namespace、对象 digest、签名主体、首次异常命中和受影响项目,不要立即全量删除证据。客户端策略随后拒绝可疑密钥、builder identity 或旧信任代际;可疑对象移入只读隔离区,不能继续被正常解析。若私钥可能泄漏,应从准入信任根撤销旧公钥、轮换构建身份,并检查旧签名覆盖的全部对象,而不是只修当前 Store Path。Nix 公钥列表本身不提供在线撤销查询,撤销是否生效取决于消费端配置是否收敛,因此必须统计仍携带旧策略代际的客户端并设定阻断期限。
重建必须从受控输入和空 cache 开始。Nix 检查新 narinfo 的 Store Path、NarHash 与新签名;OCI 检查 manifest digest、签名主体、来源声明与 SBOM subject;prebuild 检查提交、锁摘要、构建定义、平台、服务账号和 snapshot digest。项目探针通过后,先开放一个隔离 namespace,再逐批恢复读取。若空状态无法重建,说明旧缓存承载了声明之外的输入,恢复工作应先补齐环境合同,不能把隔离对象重新标记为可信。
常见证据可以直接反推故障层。HTTP 401/403 或 Nix daemon 拒绝附加 substituter,优先检查账号和信任配置;404 或 cache miss 检查键与保留策略;NarHash、layer digest 不一致说明传输或存储字节损坏;签名主体不匹配说明信任策略或发布身份错误;签名通过但项目探针不同,则继续查未进入键的输入、构建网络访问、时间、CPU 特性和启动阶段脚本。
容量与成本要按“可重建价值”分层
Nix NAR、OCI layers 和 workspace snapshots 都会产生存储、请求、跨区流量和重建算力。只追求命中率会让过期漏洞和无人使用的平台长期驻留;只追求最短保留又会让每个工作日从冷构建开始。容量模型应同时记录每个 namespace 的逻辑对象数、物理去重后字节、读取命中、最后访问、重建耗时、失败重建次数和出口流量。
保留顺序由重建代价和风险决定。仍受支持平台的最近成功对象可以保留两代以便回退;来自已撤销密钥、过期 builder 或未知锁摘要的对象立即隔离;分支预构建在合并或关闭后进入短保留;基础 layer 即使共享率高,也必须随漏洞策略和基础镜像升级失效。远程快照还包含持久盘和索引成本,空闲停止只减少计算,不一定停止存储计费;平台应分别展示运行、快照、持久盘与网络费用。
长期维护靠四次演练保持可信
环境平台 owner 每个升级批次都要完成四类演练。第一类是受信命中:同一输入从 cache 恢复并通过项目探针。第二类是明确拒绝:错误签名、错误 builder、错误平台或过期 epoch 必须在环境可用前失败。第三类是冷重建:隔离全部缓存后仍能在预算时间内产生新对象。第四类是退出清理:删除 namespace、快照、临时凭据和服务账号授权后,旧 URL、旧 digest 和旧 prebuild ID 都不能继续恢复环境。
清理本地实验时只删除刚创建的对象与密钥,不执行全局 Store GC:
nix --extra-experimental-features nix-command store delete "$out_path" || true
rm -rf cache-good cache-bad cache-missing cache-rotated
shred -u lab-cache.private 2>/dev/null || rm -f lab-cache.private
shred -u lab-cache-2.private 2>/dev/null || rm -f lab-cache-2.private
rm -f lab-cache.public lab-cache-2.public sbom.cdx.json environment-cache-policy.yaml
cd .. && rmdir cache-trust-lab远端清理还要撤销试点服务账号的仓库和秘密读取权、删除 prebuild 与工作区、确认临时计算已销毁,并从平台审计记录中取得对象不存在或删除完成的事件。做到这里,缓存才真正回到它应有的位置:可以丢、可以拒绝、可以重建的加速层,而不是一份无人敢清空的环境真相。
