Lockfile、缓存与离线重建:证明构建能从批准材料重新发生
“CI 已经连续一个月构建成功”并不能证明项目可恢复。它可能只证明同一批缓存一直命中。等缓存过期、上游删除包、证书轮换或构建节点换平台,隐藏的输入才会同时暴露。
也不要把 lockfile 当成依赖压缩包。大多数 lockfile 记录解析结果、来源或完整性,但不会保存所有字节;平台专用包、编译器、系统库、生命周期脚本和外部下载仍可能改变结果。缓存则更弱,它首先是性能设施,不天然是备份。
可恢复的构建要分别管理三类对象:
declaration + lock + toolchain + platform policy
|
v
approved dependency bytes
/ \
disposable cache archived offline set
\ /
execution evidence
|
v
verified artifact锁定回答“解析器应选择什么”,缓存回答“哪些结果可以复用”,离线材料回答“上游不可达时,批准字节在哪里”。把三者混成一个目录,最终既清不敢清,也无法审计。
先做一组冷热对照实验
选择项目的权威构建命令,连续完成四次运行:
热缓存构建:确认日常路径和基准耗时。隔离缓存构建:使用新的空缓存目录,不破坏原缓存。受控离线构建:禁止网络,只允许读取准备好的材料。
干净 CI 构建:使用与开发机相同的锁定和工具链合同。
每次保存工具版本、平台、锁文件哈希、来源清单、缓存模式、退出码、依赖图和产物哈希。只有四次结果能够解释,才知道项目依赖了什么。
不要用全盘删除验证冷缓存
冷缓存的正确方法是改用空目录或新容器,而不是删除用户主目录。
$env:NPM_CONFIG_CACHE = Join-Path $PWD ".tmp-cache/npm"
$env:PNPM_HOME = Join-Path $PWD ".tmp-cache/pnpm-home"
$env:GRADLE_USER_HOME = Join-Path $PWD ".tmp-cache/gradle"
$env:CARGO_HOME = Join-Path $PWD ".tmp-cache/cargo"
$env:GOMODCACHE = Join-Path $PWD ".tmp-cache/go-mod"
npm ci --ignore-scripts
./gradlew build --no-daemon
cargo build --locked
go mod download这样做有三个好处:原环境可回滚、失败现场可保留、不同工具不会误删共享目录。实验结束后先归档必要证据,再删除项目内的临时缓存。
产物一致不等于字节一定相同
有些构建会把时间戳、绝对路径、文件顺序或签名时间写入产物。判断标准要分层:
依赖图是否一致;编译输入是否一致;测试结果是否一致;
产物内容是否可比;若哈希不同,差异是否来自已知非确定字段。
先比较解包后的文件清单和内容,再决定是否要求整包哈希相同。不能为了让哈希相同而忽略真正的二进制差异。
Lockfile 锁住了什么,又漏掉了什么
跨生态治理最危险的做法,是规定“所有项目必须提交 lockfile”后就结束。不同工具的锁定对象并不相同。
| 生态 | 常见锁定文件或状态 | 主要锁定对象 | 仍需额外控制 |
|---|---|---|---|
| npm | package-lock.json | 依赖树、解析地址、完整性 | npm 版本、安装参数、脚本、OS/CPU |
| pnpm | pnpm-lock.yaml | importer、快照、完整性 | pnpm 主版本、linker、patched deps、平台 |
| Yarn | yarn.lock | descriptor 到 locator、checksum | Yarn 版本、linker、cache、插件 |
| Maven | 无统一传递依赖 lock | POM 与仓库元数据驱动解析 | BOM、插件版本、仓库内容、离线仓库 |
| Gradle | gradle.lockfile 等 | 已解析模块版本 | configuration、plugin、verification metadata |
| pip | requirements/constraints、pylock.toml 等 | 取决于生成方式 | Python/ABI/平台 wheel、哈希、build deps |
| Poetry | poetry.lock | Python 依赖解析与文件哈希 | Poetry 版本、source、Python 标记 |
| uv | uv.lock | 跨平台解析与来源元数据 | uv 版本、index、Python 与系统依赖 |
| NuGet | packages.lock.json | PackageReference 依赖图 | RID/TFM、SDK、source mapping |
| Cargo | Cargo.lock | crate 版本、source、checksum | Rust 工具链、feature、target、build script |
| Go | go.mod + go.sum | module 图与下载校验 | Go 版本、proxy、private 边界、vendor |
| Composer | composer.lock | PHP 包版本、source/dist reference | PHP 平台、扩展、插件与 scripts |
| Bundler | Gemfile.lock | gem 版本、platform、source | Ruby 实现、Bundler、native extension |
| Conan | lockfile | recipe/package revision 与图约束 | profile、settings、options、remote |
| vcpkg | manifest + baseline | port 版本基线与约束 | triplet、compiler、registry、binary ABI |
表格不是让团队统一成一种格式,而是提醒审查者追问:锁定是否包含传递依赖、来源、完整性和目标平台;构建工具主版本变化是否会重写它;同一个 lockfile 是否允许不同平台选择不同工件。
平台边界必须进入构建合同
Python wheel 的 python_version、ABI 和 platform tag,NuGet 的 TFM/RID,Ruby gem 的 platform,npm 可选依赖的 OS/CPU,Rust target 和 C/C++ triplet 都可能让同一个声明产生不同字节。
因此缓存键和离线材料至少要包含:
lock hash
+ package manager version
+ runtime/compiler version
+ operating system and architecture
+ ABI/libc/target/triplet
+ source policy version
+ relevant install flags“Linux 缓存”粒度通常不够。glibc 与 musl、x64 与 arm64、不同 JDK 或 MSVC toolset 都可能要求隔离。
三层缓存不要混写
构建现场通常同时存在三类缓存。
下载缓存或内容存储
保存 registry 元数据、压缩包、crate、wheel、gem 或源码归档。它可以避免重复下载,但未必包含完整离线解析所需的元数据。
解包、解析或本地仓库
例如 Maven local repository、Gradle dependency cache、pnpm store、Go module cache、Cargo registry/git cache。它们可能同时保存元数据、锁、负缓存和已解包文件,对工具版本与并发模型更敏感。
编译结果缓存
例如 vcpkg binary cache、Conan package cache、编译器对象缓存。它们复用的是平台相关构建结果,需要更严格的 ABI 和工具链键。
若错误来自 Nx、Turborepo、Rush 或 Bazel 复用的远端任务输出,排障对象已经不是包管理器缓存,应切换到远端任务缓存的输入哈希和执行证据链。不要用后面的依赖缓存清理命令处理任务输出缓存。
只读种子加每任务可写层
共享缓存最稳妥的权限模型不是“所有节点挂载同一个可写目录”,而是:
approved seed cache (read-only)
|
v
per-job writable cache -> validation -> optional promotion by trusted job开发者和普通 PR 任务只读批准种子,并写入各自隔离目录。只有受信任的种子任务可以把经过校验的新对象晋级。这样可以控制三类风险:并发锁不兼容、恶意 PR 污染共享缓存、坏对象扩散到所有项目。
Gradle 官方支持共享只读依赖缓存与单独可写缓存组合,但复制时不能带入锁文件;不同 Gradle 版本的 cache compatibility 也要核对。vcpkg 官方同样建议 CI 对二进制缓存读写,而开发者只读消费 CI 产物。
共享目录若必须可写,至少满足:
每个对象内容寻址或具备校验和;原子写入,未完成文件不对其他任务可见;租户和信任级别隔离;
写入身份可追溯;配额与清理不会删除正在使用的对象;恶意或损坏对象可以按哈希隔离,而不是清空全库。
JavaScript:冻结安装与缓存模式要同时验证
npm 的权威 CI 入口通常是 npm ci。它要求 lockfile 存在且与 manifest 一致,不会改写 lockfile,并会清理已有 node_modules。但生成 lockfile 时使用的影响树形的参数也要固化,否则 CI 仍可能失败。
npm ci --ignore-scripts
npm cache verify
npm config get cachenpm cache 是可丢弃的内容寻址缓存,不应作为离线备份。npm cache只承诺返回时内容与写入一致,不保证对象永久存在。npm cache clean --force 也不是常规排障第一步;先 verify,再隔离单个项目缓存重建。
pnpm 将内容寻址 store 与项目虚拟 store 分开。--prefer-offline 允许缺失时联网,不等于断网验证;--offline 才要求只使用本地 store。
pnpm install --frozen-lockfile
pnpm store status
pnpm fetch --prod
pnpm install --offline --frozen-lockfile --prod容器中可以先用 lockfile 执行 pnpm fetch,再复制项目并 --offline 安装。要注意本地 file: 依赖不会在 fetch 阶段完整覆盖,patches 和 workspace 配置也属于输入。
Yarn Modern 可以把 .yarn/cache 作为项目本地离线镜像。使用 Zero-Installs 时,CI 应同时锁住 lockfile 与 cache,并按 Yarn install 的 immutable 语义对外部 PR 检查缓存内容:
yarn install --immutable --immutable-cache --check-cache--immutable-cache 证明安装没有增删 cache,--check-cache 才会重新抓取并对照 lockfile 与现有缓存的 checksum。是否把 cache 提交 Git 是仓库体积、审查成本与上游可用性的取舍,不能只看安装速度。
JVM:Maven 本地仓库与 Gradle cache 都不是复制即用
Maven 的 ~/.m2/repository 同时包含工件、POM、元数据和失败标记。受控离线流程应先在线准备,再切换到独立本地仓库验证:
./mvnw -Dmaven.repo.local=.cache/m2 dependency:go-offline
./mvnw -o -Dmaven.repo.local=.cache/m2 verifydependency:go-offline 不一定能预见所有运行时下载,尤其是动态插件、测试扩展或脚本触发的工具。断网验证失败时要补材料清单,而不是把个人 .m2 整体打包。
Gradle 依赖锁定与 dependency verification 应配合使用:前者固定解析版本,后者校验下载内容。Gradle dependency caching还说明了 offline、refresh 与共享只读 cache 的差异。
./gradlew dependencies --write-locks
./gradlew build --offline --no-daemon
./gradlew build --refresh-dependencies --no-daemon--offline 只使用 cache 中已有模块,缺失就失败。--refresh-dependencies 会重新核对动态版本和工件,但会尽量通过 checksum 复用有效文件;它比删除整个 $GRADLE_USER_HOME 更适合确认仓库状态漂移。
Python:wheelhouse 比“复制 pip cache”更适合离线交付
pip cache 是优化设施,内容和保留策略不应被当成离线合同。离线材料应按 pip repeatable installs 的思路显式下载到 wheelhouse,并记录目标 Python 与平台。
python -m pip download `
--requirement requirements.txt `
--dest wheelhouse
python -m pip install `
--no-index `
--find-links wheelhouse `
--require-hashes `
--requirement requirements.txt如果包含 sdist,离线环境还需要构建后端、编译器、系统头文件和它们的依赖。更可控的做法是为批准平台预构建 wheel,并保留 wheel tag、Python 版本与哈希清单。
Poetry 与 uv 的 lockfile 能描述更完整的解析结果,但平台 marker 和 index 仍是输入。uv 可以先同步 cache,再用离线模式验证;如果团队采用 uv 管理 Python 本身,Python 发行包也必须进入断网材料。
uv sync --locked
uv sync --locked --offline
uv cache dir不要把本机 .venv 当成跨机器离线包。虚拟环境常含绝对路径、平台二进制和解释器引用,应在目标环境从锁定材料重建。
.NET、Rust 与 Go:锁定之外还有目标框架和校验服务
NuGet 的 locked mode 用于拒绝依赖图漂移:
dotnet restore --use-lock-file
dotnet restore --locked-mode --packages .cache/nuget离线恢复要同时覆盖 TFM、RID、SDK workload 和 package source mapping。只缓存某个开发机实际选中的 RID 包,不能证明其他发布目标可恢复。
Cargo 使用 Cargo.lock 固定 crate 版本与 checksum,--locked 禁止需要修改 lockfile 的构建,--offline 禁止网络;需要可归档材料时可按 Cargo source replacement配置 vendor 目录:
cargo fetch --locked
cargo build --locked --offline
cargo vendor vendorcargo vendor 生成目录 source 时,每个 crate 还带文件 checksum 元数据。vendoring 适合可归档的离线集合,但要把 .cargo/config.toml、target、feature 和 Rust toolchain 一起版本化。
Go 的 go.sum 校验模块字节,模块缓存仍是可再生状态。可以按照 Go Modules Reference 的 vendor 模式建立项目级材料:
go mod download -json all
go mod verify
go mod vendor
go build -mod=vendor ./...私有模块可能绕过公共 checksum database,因此企业必须保存自己的来源和完整性证据。把 GOSUMDB=off 全局关闭不是离线方案;它扩大了所有模块的信任范围。
PHP 与 Ruby:平台约束和插件执行不能漏
Composer 的 composer.lock 记录解析,但 PHP 版本与扩展会影响平台检查。在线准备后,离线恢复应优先使用明确的 dist/source 材料和只读 cache,而不是依赖某台机器的历史状态。
composer install --no-interaction --no-scripts --no-plugins --prefer-dist
composer check-platform-reqs
composer diagnose真正启用 scripts/plugins 前,要确认其代码也在批准材料里,并在隔离环境执行。--ignore-platform-reqs 只能隐藏目标环境缺失,不能作为恢复手段。
Bundler 的 lockfile 包含 platform;bundle cache 可以把 gem 材料放入 vendor/cache:
bundle lock --add-platform x86_64-linux
bundle cache --all-platforms
bundle install --local --frozen原生扩展仍依赖 Ruby 实现、编译器和系统库。离线演练要在每个受支持平台重建,而不是只检查 vendor/cache 文件存在。
Conan 与 vcpkg:缓存键必须覆盖二进制兼容性
Conan 的 package ID 来自 settings、options、dependencies 等输入,lockfile 固定图,但 profile 仍决定二进制选择。恢复演练要保存 profile、lockfile、recipe revision 与 package revision。
conan lock create . --lockfile-out=conan.lock
conan install . --lockfile=conan.lock --build=never
conan cache check-integrity "*"--build=never 能暴露离线材料缺包;若改成 --build=missing,则还要确保源码、构建工具和编译器都在断网环境可用。
vcpkg 的 binary cache 按 ABI hash 复用构建结果,asset cache 保存源码和预构建工具。两者职责不同:只有 binary cache,遇到 ABI 变化仍要重新下载源码;只有 asset cache,则每次都要重新编译。
$env:VCPKG_BINARY_SOURCES = "clear;files,$PWD/.cache/vcpkg-binary,read"
$env:X_VCPKG_ASSET_SOURCES = "clear;x-azurl,$env:VCPKG_ASSET_CACHE_URI,,read;x-block-origin"
vcpkg install --x-manifest-root=.开发机应只读消费 CI 生成并验证的 binary cache。写权限集中到受信任任务,避免个人构建结果污染团队。builtin-baseline、registry commit、triplet、编译器和 toolchain 文件共同进入恢复清单。
冷、热和受控离线是三种不同结论
| 模式 | 网络 | 缓存 | 能证明什么 |
|---|---|---|---|
| 热缓存 | 可用 | 已命中 | 日常性能和已有状态可继续使用 |
| 冷缓存 | 可用 | 空 | 批准来源仍能完整提供依赖 |
| 受控离线 | 禁止 | 仅批准材料 | 断网材料足以恢复 |
| 灾难恢复 | 主源不可用 | 灾备归档 | 组织能在目标 RTO/RPO 内恢复 |
prefer-offline、镜像可用但公网断开、或命中个人历史缓存,都不等于受控离线。离线测试必须从网络层阻断未批准出口,并记录所有失败连接。
设计可审计的 cache key
一个常见 CI 键只有 ${os}-${lockHash}。它漏掉工具主版本、运行时、来源策略与安装参数,容易恢复“不属于这次构建”的缓存。
建议按三层组成:
ecosystem/tool-major
/os-arch-abi-runtime
/lock-hash-source-policy-install-flags键太宽会污染,键太窄会不断 miss。先保护正确性,再通过只读种子、分层 fallback 和内容寻址优化命中率。fallback key 只能恢复下载材料,不能恢复平台相关编译结果。
缓存命中后仍要运行 lock 一致性与 checksum 验证。命中不是跳过验证的理由。
清理顺序决定能否保留证据
遇到“本地成功、CI 失败”时,推荐按以下顺序处理:
保存工具版本、有效配置、锁文件哈希和失败日志。在原缓存上运行工具自带的 verify/status/diagnose。用新空缓存目录重跑,不动原现场。
若新目录成功,按对象或命名空间隔离原缓存中的可疑项。只有确认磁盘回收需求或结构性损坏后,才执行工具支持的 prune/clean。清理后同时跑冷缓存和离线验证,确认没有误删唯一材料。
常用检查入口:
npm cache verify
pnpm store status
pnpm store prune
python -m pip cache info
./gradlew build --refresh-dependencies
go clean -modcache
conan cache check-integrity "*"其中 go clean -modcache 会删除整个模块缓存,只应在已保留现场且确认能够重新下载或有 vendor 材料时执行。团队手册不应把破坏性清理写成第一步。
checksum、签名和来源分别回答不同问题
checksum 证明拿到的字节与记录一致;签名证明某个密钥对这些字节作出声明;来源策略证明客户端被允许从哪里获取;
锁文件证明解析器应选择哪个版本或对象;归档清单证明灾备材料完整。
任何一个都不能替代其余四个。同版本被上游重发时,正确反应是停止并调查;不能直接“刷新 checksum”。若确认是合法变更,也要通过依赖升级流程更新锁定与证据,保留前后字节和批准记录。
断网材料清单要能交给另一支团队
合格的离线包至少包含:
项目声明、lockfile、补丁与 vendor 配置;包管理器和运行时/编译器的可验证安装介质;目标 OS、架构、ABI、RID、target 或 triplet 列表;
依赖压缩包、wheel、crate、gem、module、源码归档或二进制包;每个对象的来源、版本、checksum,必要时包含签名验证材料;企业 CA、公钥和信任链更新方式,不包含私钥;
恢复命令、网络阻断方式、预期输出和停止条件;材料过期时间、维护 owner 与替换流程。
归档完成后,由没有参与制作的人在干净环境复原。只有制作者自己的机器能恢复,说明包里仍隐含个人缓存或环境知识。
灾难回滚从隔离开始
发现共享缓存污染、镜像同版本字节变化或 lockfile 被错误工具重写时,先停止晋级和写入,不要立即清空所有副本。
freeze writers
-> snapshot indexes and suspicious objects
-> block affected hashes/versions
-> switch consumers to last-known-good read-only seed
-> rebuild in isolated empty cache
-> compare dependency graph and artifacts
-> rotate credentials if leakage is possible恢复完成后再决定删除。保留污染对象的隔离副本,是为了查明影响范围、来源和首次出现时间;它不能继续被任何构建读取。
回滚验收至少包括:旧 lockfile 能否解析、批准材料是否完整、所有写入身份是否收敛、冷缓存是否通过、断网是否通过、产物与最近可信版本有何差异。
团队把“可复现”变成持续任务
语言工具链 owner 维护锁定语义、工具版本和清理手册。项目 owner 审查 lockfile 变更、平台矩阵和依赖例外。CI owner 维护 cache key、只读/可写权限、冷缓存与断网任务。
制品与平台团队维护批准来源和离线材料存储,但不替项目判断依赖图是否合理。安全团队维护 checksum/签名策略、污染响应和敏感日志清理。
日常流水线跑冻结安装,定期任务跑冷缓存,季度或重大升级前跑受控离线和灾难恢复。每次失败都应产出“缺了哪份材料、哪个输入没进合同、哪个缓存键过宽”的可执行修正,而不是给缓存继续续命。
