1. 为什么 CI 里用 Nix?
传统 CI(apt/yarn/pip 安装依赖)的问题:每次跑都是重新下载、版本漂移、缓存不可控。Nix 改变这一切的核心在于:
- 内容寻址的 store 路径:
/nix/store/<hash>-name-version,hash 由全部构建输入(源码、依赖、编译参数、环境变量)决定 - 二进制缓存:只要 hash 相同,构建结果可直接从缓存拉取,跳过构建
- flakeref 可复现:
flake.lock锁定输入,CI 与本地构建完全一致
所以 Nix + CI 的价值不是「构建更快」(首次构建可能更慢),而是**「缓存命中时的秒级复用」**——这正是 Cachix 等缓存服务存在的意义。本文与 https://plumephp.com/nix-flakes-best-practices/ 的仓库布局配合,把发布流水线做成「本地构建一次 → 推缓存 → CI 秒级拉取」。
📌 相关专题:GitHub Actions 基础见 https://plumephp.com/posts/github-actions/;DevOps 整体流程见 https://plumephp.com/posts/devops/。
2. derivation 缓存复用:理解 hash 与引用
2.1 store 路径是怎么算出来的
nix build .#default 的输出路径由 NAR hash 决定;而每一步 derivation 的 hash 取决于:
输入 hash = hash( 所有 src 文件内容 + 依赖的 store 路径 + builder 脚本 + 环境变量 + 平台 )
因此要让缓存命中,必须保证这些完全一致:
# 查看 derivations 之间的依赖引用(closure)
nix path-info --all ./result | head
# 查看闭包总大小(决定缓存体积)
nix path-info --closure-size ./result
# 对比两个 result 是否相同(hash 相同即内容相同)
nix path-info -sh ./result
2.2 什么会导致缓存 miss
| 因素 | 说明 | 应对 |
|---|---|---|
| src 变化 | 任何源码改动都换 hash | 正常行为,Push 级缓存覆盖 PR 级 |
| 依赖升级 | nix flake update 改输入 | CI 构建后立即推缓存 |
| 时间戳/路径漂移 | 构建内嵌绝对路径、动态生成版本号 | 禁用 SOURCE_DATE_EPOCH 之外的非确定性 |
| 不确定构建 | 网络下载、随机数、未固定顺序 | 用 sandbox 构建保证确定性 |
| 平台 | x86_64-linux 与 aarch64-linux 是不同路径 | 多架构各自推缓存 |
2.3 让构建可缓存的小技巧
# 包内避免使用非确定的时间戳
stdenv.mkDerivation {
pname = "app";
version = "1.2.0";
# 固定构建时间(可复现构建)
SOURCE_DATE_EPOCH = "0";
# 显式列出所有环境依赖,避免隐式污染
nativeBuildInputs = [ pkg-config cmake ];
buildInputs = [ openssl ];
}
3. Cachix:二进制缓存的推送与拉取
3.1 Cachix 是什么
Cachix 是托管二进制缓存服务,URL 形如 https://<name>.cachix.org。任何 Nix 实例配置了该 substituter 后,都能在构建前尝试拉取缓存闭包:
# 安装 cachix 客户端
nix profile install nixpkgs#cachix
# 登录并创建/激活缓存
cachix use mycache # 自动写入 nix.conf 的 substituters
cachix push mycache ./result
对应的 nix.conf 配置(cachix use 会自动写入,也可手动):
# /etc/nix/nix.conf 或 ~/.config/nix/nix.conf
experimental-features = nix-command flakes
substituters = https://cache.nixos.org https://mycache.cachix.org
trusted-public-keys = mycache.cachix.org-1:xxx... cache.nixos.org-1:xxx...
3.2 push 的粒度:整闭包 vs 单路径
# 推送单个路径及其完整闭包(默认行为)
cachix push mycache ./result
# 推送时排除已存在的(增量推送,CI 中省时)
cachix push mycache --skip-existing ./result
# 推送 derivation 依赖树中「尚未被缓存」的中间产物
# 让 PR 也能命中大部分依赖缓存
cachix push mycache $(nix build .#default --no-link 2>/dev/null; nix path-info -r .#default)
3.3 缓存命中验证
# 查看从缓存拉取的情况(日志中会打印 substituter 来源)
nix build .#default --show-trace 2>&1 | grep -E "downloaded|will be built"
# 输出 "1 store paths downloaded" 表示命中;"will be built" 表示需要构建
# 查看已配置的 substituters
nix config show substituters
4. GitHub Actions 集成:完整工作流
4.1 基础骨架:安装 Nix + 拉缓存 + 构建
# .github/workflows/build.yml
name: Nix Build
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v30
with:
# 显式开启 flakes
extra_nix_config: |
experimental-features = nix-command flakes
- name: Set up Cachix cache
uses: cachix/cachix-action@v15
with:
name: mycache
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
# pushFilter 控制哪些路径推缓存
pushFilter: '.*(myapp|mylib).*'
- name: Build
run: nix build .#default --accept-flake-config
- name: Run checks
run: nix flake check --accept-flake-config
4.2 PR 与 push 的分层缓存策略
| 事件 | 缓存角色 | 目标 |
|---|---|---|
push 到 main | 生产者:构建并推全量缓存 | 让所有依赖闭包进 Cachix |
pull_request | 消费者:拉缓存快速验证 | 命中率达 90%+,秒级完成 |
| 手动 workflow_dispatch | 全量重建 + 校验 | 清理可疑缓存 |
jobs:
build:
strategy:
matrix:
# 多架构:x86_64 与 aarch64 各跑一个 job
system: [x86_64-linux, aarch64-linux]
runs-on: ubuntu-latest
steps:
- name: Setup QEMU (for cross)
if: matrix.system == 'aarch64-linux'
uses: docker/setup-qemu-action@v3
- name: Install Nix
uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Build for ${{ matrix.system }}
run: |
# 通过 --system 强制目标平台构建(需要远程构建器或 emulation)
nix build ".#packages.${{ matrix.system }}.default" --system ${{ matrix.system }}
4.3 并发安全:缓存写冲突
多个 job 同时 cachix push 相同路径是无害的(内容寻址,幂等)。但要注意:
- 多个 PR 构建相同输入时会争抢构建锁;用
concurrency限制同 PR 的重复构建 cachix-action的skipUsingPushFilter/skipPush可用于「只读 job」
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
5. 远程构建:把重活交给专用构建机
5.1 为什么需要远程构建
CI runner(如 GitHub-hosted)构建 Nix 有天然劣势:CPU 共享、内存受限、每次全新环境。大型 derivation(LLVM、浏览器引擎)动辄数十分钟。把构建转发到专用 Linux 构建机,是工程化标配。
5.2 本地配置远程构建器
# /etc/nix/nix.conf
# 把指定机器加入构建池
builders = ssh://builder1.mydomain.com aarch64-linux ssh://builder-arm.mydomain.com
builders-use-substitutes = true
# 可选:构建结果的 substituter 优先
# 测试远程构建
nix build .#default --max-jobs 0 --builders 'ssh://builder1.mydomain.com x86_64-linux'
# --max-jobs 0 表示不在本地构建,全部转发
5.3 GitHub Actions + remote builder
GitHub Actions 无法直接「持有」你的远程构建机,但可以:
- 在本地/自建 runner 上跑 Nix job(self-hosted runner)
- 或把构建机暴露为 substituter:远程构建机构建完推 Cachix,CI 从 Cachix 拉取
最省事的组合是方案 2——CI 永远只做「拉缓存 + 轻量验证」:
- name: Install Nix
uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
experimental-features = nix-command flakes
substituters = https://cache.nixos.org https://mycache.cachix.org
6. 缓存命中率调优:让 CI 快 10 倍的工程细节
6.1 拆细 derivation,放大缓存粒度
把「大包」拆成多个可独立缓存的小 derivation,改动局部时缓存命中率骤升:
# 反模式:一个巨大 derivation
stdenv.mkDerivation {
pname = "monolith";
src = ./src;
# 任何源码改动都导致全量重建
}
# 正模式:分层缓存
{ lib, stdenv, fetchurl }:
let
# 层 1:锁定不变的第三方依赖(几乎永远命中缓存)
vendor = stdenv.mkDerivation {
pname = "app-vendor";
src = fetchurl { url = ".../vendor.tar.gz"; sha256 = "..."; };
};
# 层 2:业务代码(小改动 → 只重建这一层)
in stdenv.mkDerivation {
pname = "app";
src = ./src;
# 通过 buildInputs 引用 vendor,形成依赖链
buildInputs = [ vendor ];
# 缓存上会保留两个独立路径
}
6.2 优先推送「依赖闭包」而非「单个结果」
CI 推缓存时如果只推 ./result,PR 场景下的中间依赖(CMake、编译器、库)仍然要重新构建。推整棵依赖闭包能显著提升命中率:
- name: Push full closure
run: |
nix build .#default --no-link
cachix push mycache $(nix path-info -r .#default | tr '\n' ' ')
6.3 定期全量刷新
当 nixpkgs 大版本更新时,缓存中的大量路径失效。安排每周/每双周的「全量重建 + 推送」job,把热路径重新预热:
- name: Weekly cache refresh
if: github.event_name == 'schedule'
run: |
nix flake update
nix build .#default
cachix push mycache $(nix path-info -r .#default | tr '\n' ' ')
7. 多平台矩阵:cross 与远程构建协同
跨架构构建详见 https://plumephp.com/nix-cross-compilation-overlay/,这里给出 CI 层面的组合拳:
jobs:
build-matrix:
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
runner: ubuntu-latest
- system: aarch64-linux
runner: [self-hosted, linux, arm64] # 原生 ARM runner
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v30
- name: Build
run: nix build .#packages.${{ matrix.system }}.default
- name: Push cache
uses: cachix/cachix-action@v15
with:
name: mycache
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
8. 常见问题与排障
| 症状 | 原因 | 解法 |
|---|---|---|
CI 一直显示 will be built | substituter 未配置或 key 不信任 | cachix use + 检查 trusted-public-keys |
error: substituter ... returned store path ... which does not exist | 推送的是符号链接或未闭合 | 推 $(nix path-info -r ...) 整闭包 |
| 缓存巨大、push 很慢 | 闭包包含大量 nixpkgs 全量依赖 | pushFilter 只推项目相关路径 |
| 构建「在别处成功、CI 失败」 | 环境差异(locale、PATH、网络) | 用 sandbox = true + builders-use-substitutes |
权限错误 401 Unauthorized | Cachix token 失效 | secrets 里更新 CACHIX_AUTH_TOKEN |
nix flake check 很慢 | checks 构建了全量测试 | 拆分 checks,PR 只跑轻量集 |
9. 总结
Nix + CI 的本质是把「构建」变成「缓存查找」:
- 可复现:
flake.lock+ 内容寻址 store 让 Push 与 PR 构建同一路径 - 可复用:Cachix 提供二进制缓存,闭包级推送让命中率最大化
- 可分层:
push生产缓存、PR消费缓存、schedule预热缓存 - 可扩展:远程构建器 +
--max-jobs 0把重活移出 CI 沙箱
落地路径:先搭好 https://plumephp.com/nix-flakes-best-practices/ 的模块化布局 → 用本文的 workflow 接入 GitHub Actions 与 Cachix → 配合 https://plumephp.com/nix-cross-compilation-overlay/ 扩展到多架构。至此,你的「一次构建、处处复用」流水线就成型了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。