devenv:从 Nix 声明到任务、服务与 CI 复验
一个新成员运行项目启动命令,API 进程显示 started,数据库端口也在监听,初始化任务却没有执行。另一名开发者删掉缓存后恢复正常,于是团队把问题归结为“缓存脏了”。继续拆状态才发现:普通 devenv up 没有调度 process 下游的配置 task,数据库数据又保存在独立的 service state 中;清理求值缓存既不会补跑任务,也不会重建旧数据目录。
devenv 把 Nix 包、环境变量、语言工具、任务、进程、开发服务和测试组合进项目声明。它能固定 Nix inputs,并让开发机与 CI 使用同一入口,却不会自动锁住外部 API、容器 tag、数据库内容、运行时凭据和所有宿主差异。真正可维护的做法,是把 lock、ready、task DAG、缓存和持久状态分别建模,再为每一种状态提供证据与清理动作。
先把 Nix 执行面和 devenv 版本固定下来
devenv 依赖 Nix daemon,主要运行在 Linux、macOS 和 WSL2,覆盖 x86_64 与 ARM64。Windows 团队应在 WSL2 发行版内建立基线,并记录仓库位于 Linux 文件系统还是 Windows 挂载路径;原生 Windows 不能按同等行为假设。安装 Nix 时使用 devenv 安装入口 指向的受支持方案,随后再通过 Nix profile、NixOS、nix-darwin 或 Home Manager 安装 devenv。
安装后立即记录实际执行面:
nix --version
devenv version
uname -a在普通 Linux 或 macOS 开发机上,先按 Nix 安装器的企业审批结论安装 daemon,再从 Nixpkgs 安装 CLI;WSL2 在 Linux 发行版内执行同样步骤:
curl -sSfL https://artifacts.nixos.org/nix-installer -o /tmp/nix-installer
less /tmp/nix-installer
sh /tmp/nix-installer install
rm -f /tmp/nix-installer
nix profile install nixpkgs#devenv
devenv version下载后先审阅再执行能保留代理替换、异常重定向和内容劫持的判断点;安全要求更高的团队应把安装器、哈希和 Nixpkgs revision 固定在内部软件源。NixOS、nix-darwin 与 Home Manager 则应把 pkgs.devenv 写进系统声明,由同一升级流程维护,不能再叠加一份个人 profile 安装制造 PATH 歧义。
首次解析 inputs 会访问 GitHub 和二进制缓存。企业代理、DNS、CA 或 API 限额有问题时,常见现象是 fetch 失败、TLS 错误或长时间停在 input resolution,而不是 Nix 语法错误。GitHub access token 若用于提高 API 限额,应写入用户 Nix 配置或运行时 secret 注入,不能提交到项目。
安装渠道可能落后于项目使用的 modules。项目应在 devenv.yaml 开启版本约束,让 CLI 与声明不兼容时立即失败。项目基线设为 devenv 2.1.2 时,清理进程使用 devenv processes down,不能借用后续版本才出现的 top-level 简写。
三个文件分别决定声明、组合与输入身份
在一个临时 Git 仓库运行:
mkdir devenv-lab && cd devenv-lab
git init
devenv initdevenv.nix 是唯一必需的环境声明,负责 packages、env、languages、scripts、tasks、processes、services 和 tests 等 module options。devenv.yaml 管 inputs、imports、profile、纯度策略和 SecretSpec provider 等组合层配置。任一需要解析 input 的命令都会生成或更新 devenv.lock,其中保存解析后的 input revision。
为了避免依赖随版本变化的隐式默认,先写一份显式配置:
require_version: true
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling再用最小声明证明包与环境变量进入 Shell:
{ pkgs, ... }:
{
packages = [ pkgs.jq ];
env.FAMILY38_VALUE = "locked";
enterTest = ''
test "$FAMILY38_VALUE" = locked
jq --version
'';
}运行:
devenv test
test -f devenv.lock
devenv shell -- jq --version
git status --short预期是 devenv test 成功执行断言,jq --version 输出具体版本,仓库出现 devenv.lock。提交配置时应同时提交 .nix、.yaml 与 lock,并在 PR 中审查 lock revision 变化。devenv.local.nix 和 devenv.local.yaml 适合不提交的本机覆盖,但它们会有意制造差异;出现“只有我机器能跑”时必须检查这两个文件。
lock 固定 inputs,不固定整个运行世界
devenv.lock 的证明力是“本次 Nix input 图解析到这些 revision”。Nix 根据这些输入求值 derivation,在 store 中形成按内容身份组织的闭包;命中受信 binary cache 时下载产物,未命中时本机构建。相同 lock 能显著减少包集合漂移,却不能固定 task 内部下载的网络文件、未锁语言依赖、数据库数据、外部 API 响应、SecretSpec 实际值、宿主内核和硬件。
用两轮执行观察 lock 的稳定性:
cp devenv.lock devenv.lock.baseline
devenv test
cmp devenv.lock devenv.lock.baseline
devenv shell -- jq --versioncmp 无输出且退出码为零,说明同一声明没有改写 lock。记录首轮和热缓存轮耗时可以帮助容量规划,但不能据此设定跨机器通用阈值。
反向实验应在复制目录或临时分支做。删除复制目录中的 lock 后运行 devenv test,会重新解析并生成 lock;运行 devenv update 则显式推进 input revision。比较新旧 lock 后,再恢复原 lock 并重跑测试。这个实验只能推出 inputs 发生或没有发生变化,不能推出数据库与 secret 也被回滚。
impure: true、--impure、local overrides 和宿主环境透传都会降低可复现强度。同一 devenv.nix 在 Linux 与 macOS 也可能选择不同包,应使用 stdenv.isLinux、stdenv.isDarwin 和体系结构条件显式建模,并在每个目标平台运行 CI,而不是期待一个 store path 跨平台通用。
本地 path input 还可能把整个目录复制到 Nix store,并忽略 .gitignore。任何进入 Nix 求值的文件都要按“可能进入 store”审查;大型工作区可考虑 git+file 输入,减少无关文件进入求值上下文。
自动激活先选一种 hook,再决定何时 reload
devenv 2.x 可以按 自动激活机制 用原生 hook 进入子 Shell。以 Bash 为例,把这一行放在 Shell 启动文件末尾,Zsh、Fish 和 Nushell 使用各自名称:
eval "$(devenv hook bash)"进入含 devenv.nix 的目录时,首次需要 devenv allow 建立目录信任;离开项目时子 Shell 退出。原生 hook 的信任粒度是项目目录,direnv 的信任对象是 .envrc,两者不能混写成同一套授权状态。若团队偏好原地修改当前 Shell,可继续使用 direnv 的 eval "$(devenv direnvrc)" 与 use devenv,但同一开发机不要同时为同一项目启用两种自动激活链。
devenv.yaml 的 reload 默认开启,声明文件变化后可重载当前 devenv Shell;它解决的是环境声明更新,不负责重启已经运行的业务进程。可用无害变量做正反验证:先令 env.FAMILY38_VALUE = "one" 并进入 Shell,再改为 two,观察 reload 后值变化;随后启动一个不带 watch 的 process,修改其源码,确认 PID 或启动时间没有自动变化。这个失败证据能阻止团队把 Shell reload 误当成 service rollout。
Shell 适合投影工具,task 适合表达工作
devenv shell 构建环境并启动交互 Shell,packages 把工具加入 PATH,env 设置非敏感变量,enterShell 在激活时执行。一次性提示可以放进 enterShell,但数据库迁移、代码生成和初始化不应塞进激活脚本:这些动作需要依赖、跳过条件、失败状态和可单独重跑的入口。
例如把生成配置声明为 task:
{ pkgs, ... }:
{
packages = [ pkgs.jq ];
tasks."app:config" = {
exec = ''
mkdir -p "$DEVENV_STATE/generated"
printf '%s\n' '{"mode":"development"}' \
> "$DEVENV_STATE/generated/app.json.tmp"
mv "$DEVENV_STATE/generated/app.json.tmp" \
"$DEVENV_STATE/generated/app.json"
'';
status = ''
jq -e 'type == "object" and .mode == "development"' \
"$DEVENV_STATE/generated/app.json" >/dev/null
'';
};
}运行 devenv tasks run app:config 后,目标文件应存在,且 status 中的 jq -e 应确认它是包含预期 mode 的 JSON 对象。再次运行时,status 返回零会跳过 exec,并恢复最近一次成功输出。这里的 status 是工程师定义的“可跳过证据”;只检查文件存在会把截断文件误判为成功,所以真实任务应校验 schema、hash 或业务版本。
execIfModified 会结合 mtime 与内容 hash 判断输入是否变化,适合代码生成,但缓存不等于业务幂等。写数据库的 task 仍需事务、唯一约束、失败恢复和结果校验。缓存只回答“是否值得重跑”,不能回答“重复执行是否安全”。
process、service 和 task 保存的是不同状态
processes.<name> 是低层命令监督,由 devenv up 启动;它可以设置 cwd、env、依赖、restart、ready、watch 和端口。services.<name> 是数据库等常见开发依赖的高层预配置,最终仍会生成 process,同时把数据保存在 $DEVENV_STATE。tasks 则组成有向无环图,按依赖状态执行并缓存结果。
最小项目可以声明 PostgreSQL 服务和一个 API process:
{ pkgs, ... }:
{
packages = [ pkgs.curl pkgs.python3 ];
services.postgres = {
enable = true;
initialDatabases = [{ name = "app"; }];
};
processes.api = {
exec = "python -m http.server 8080";
ready.http.get = {
host = "127.0.0.1";
port = 8080;
path = "/";
};
};
}不同 devenv 小版本与 process manager 对 option 名称有兼容差异,落地时应以目标版本的 process options 校验配置。这里的 ready.http.get 是 2.1.2 原生 manager 的 typed probe。先运行 devenv eval processes 检查最终声明,再启动:
devenv up -d
devenv processes wait --timeout 120
curl --fail http://127.0.0.1:8080/
devenv processes down端口可连接只能说明某个监听者存在;ready probe 成功才说明声明的服务条件已经满足。process 依赖在未写后缀时默认等待 @ready;若声明了 listen socket 或分配端口却没显式 probe,原生 manager 会退化为 TCP 连通检查,而没有任何可判定条件时更不能把“进程仍存活”写成业务 ready。清理后再次运行 curl 应连接失败,并用端口检查工具确认没有残留监听者。
源码变化后的重启是另一条生命周期。devenv 2.x 可给 process 声明 watch:
processes.api = {
exec = "python -m http.server 8080";
watch = {
paths = [ ./src ];
extensions = [ "py" ];
ignore = [ "__pycache__" "*.log" ];
};
};长进程在匹配文件变化后重启;一次性命令退出后 watcher 仍驻留,后续变化会再次执行。验证不能只看页面“还能访问”,应记录变更前后的 PID、启动时间和 ready 恢复;反向实验修改 ignore 命中的日志文件,PID 应保持不变。把 node_modules、构建输出或日志树纳入 watch 会放大文件句柄、CPU 和重启风暴,团队应限制路径集合,并为连续失败设置人工停止入口,而不是让监督器无限吞掉错误。
服务初始化选项只在空状态上执行的情况很常见。修改 initialDatabases 后旧 $DEVENV_STATE 可能让声明看似“未生效”;刷新 Nix 求值缓存不会删除数据库目录。先备份需要保留的数据,再针对具体 service state 清理,最后重启并验证。不要把删除整个 .devenv 当成默认修复,那会同时抹掉多类诊断证据。
用 @ready 和 task DAG 表达真正的依赖
process 依赖后缀区分 @started、@ready 与 @completed;task 依赖区分 @started、@succeeded 与 @completed。依赖 API 的探测 task 应明确等待 ready:
{
tasks."app:probe" = {
after = [ "devenv:processes:api@ready" ];
exec = ''
curl --fail --silent http://127.0.0.1:8080/ >/dev/null
'';
};
}正向验证顺序是启动、等待 ready、执行 probe、停止:
devenv up -d
devenv processes wait --timeout 120
devenv tasks run app:probe
devenv processes down若进程存在但 probe 超时,第一证据应是 process 状态和 ready probe 日志,而不是继续增加 sleep。固定等待只隐藏启动波动;ready 条件应该检查服务真正能接受的最小操作。
devenv 2.1 的 task execution mode 还会改变实际调度子图。默认 before mode 运行目标及其上游,single 只运行目标,all 运行整个连通图;devenv up 使用 before mode,devenv test 使用 all mode。一个 process 下游的 configure task 可能被普通 devenv up 跳过。
反向实验可让下游 task 创建 $DEVENV_STATE/configured。先运行普通 devenv up,断言文件不存在;清理进程后运行 devenv up --mode all,再断言文件存在。若实验呈现这个结果,修复方向是重新设计依赖方向或显式选择 mode,而不是清空所有缓存。团队升级 devenv 时应保留该回归实验,因为调度语义会直接改变初始化是否发生。
四类缓存和两类运行目录不能混成一个“脏缓存”
Nix store 与 binary cache 保存包闭包;SQLite evaluation cache 保存属性求值及其读取依赖;task cache 保存跳过判断和最近成功输出;$DEVENV_STATE 保存 service data。除此之外,$DEVENV_RUNTIME 保存 socket 等临时运行文件,$DEVENV_HOME 保存 GC roots 和用户级持久数据。
求值结果异常时,可依次运行:
devenv --refresh-eval-cache test
devenv --no-eval-cache test具体参数位置应以 devenv --help 和目标版本帮助为准。强刷后恢复说明问题与 evaluation cache 或失效依赖有关;数据库内容仍旧则是预期,因为 service state 不在该缓存里。修改一份求值实际读取的普通文件后,相关 attribute 应重新求值;没有读取关系的文件变化不应让全部属性无差别失效。
大多数包会从 Nix 官方 cache 或 devenv 默认 Cachix cache 拉取。未命中时,成本转为本机 CPU、磁盘、网络和等待时间。项目应分开记录冷启动、热求值、binary cache 命中与本地构建,分别观察 Nix store、项目 .devenv、语言依赖缓存和 service data 的磁盘趋势。
自建 Cachix 会增加存储、网络、组织权限和供应链信任成本。通常只让 CI 使用最小权限的 per-cache token 推送,开发机只读;不要把共享写 token 发给所有成员。缓存来源、签名策略、保留周期和失效入口都应有 owner。
devenv gc 清理 dangling GC roots,并让 Nix GC 回收不再从 live roots 可达的 store path。它不删除数据库等业务 service data,也不等于删除 .devenv/state。执行 GC 前应确认没有其他项目仍依赖待回收路径;执行后用同一 lock 重建,验证 cache miss 只增加构建成本,没有改变声明结果。
SecretSpec 把声明与真实值分开
把 .env 直接接入 devenv 很方便,却有一个严重后果:dotenv.enable = true 会把整个文件复制进 Nix store。在典型多用户 NixOS 中,能读取 store 的其他用户可能看到其中内容。因此真实 token、密码和私钥不能进入 devenv.nix、devenv.yaml、lock 或参与 Nix 求值的文件。
SecretSpec 把需要哪些 secret 声明在 secretspec.toml,再由开发机和 CI 选择 keyring、1Password、dotenv、env 或其他 provider。更窄的注入方式是先构建非敏感的 devenv 环境,再只给目标进程注入:
devenv shell -- secretspec run -- ./bin/integration-test这样 Nix 求值先完成,真实值只在运行目标进程时出现,不成为 lock 或交互 Shell 的一部分。不同环境的 provider 值可以不同,这是“环境声明可复核、凭据不入库”的有意设计。若在 devenv 求值阶段读取 config.secretspec.secrets 并写入 env,secret 会扩大到整个 Shell 及其子进程,应只作为兼容方案,并审查 trace、日志和缓存是否泄漏。
可以用无害占位符做反向实验:创建仅含 DEMO_SECRET=not-a-real-secret 的 .env.demo,启用 dotenv integration,构建后在对应 store closure 中搜索占位符。看到它证明文件进入了 store;随后移除实验 GC root 并做受控 GC。实验不能使用真实 secret,也不能把完整 store 扫描结果上传到 CI artifact。
CI 中的 provider token来自 runner secret store,并限制到单仓库、单环境或单 cache。失败日志只保留脱敏后的 task/process 状态与 lock diff,不打印完整环境。凭据轮换、撤销和访问审计由 secret 系统完成,devenv 只持有短生命周期的运行入口。
把同一环境入口带进 CI
CI 的基本链路是 checkout、安装 Nix、配置只读或受限 Cachix、安装匹配版本的 devenv,然后运行 devenv test 或 devenv shell -- <command>。必须提交 devenv.lock,并在日志中记录 devenv version、nix --version 与 system,便于区分 input 漂移、CLI 不兼容和平台分支。
GitHub Actions 中每个 run step 都是新 Shell。在一个 step 里运行交互 devenv shell,不会让后续 step 自动继承环境。单条命令直接写:
- name: Test in devenv
run: devenv shell -- ./bin/test多行脚本可让 step 的 shell 本身进入 devenv:
- name: Project checks
shell: devenv shell bash -- -e {0}
run: |
jq --version
./bin/lint
./bin/test安装 devenv 的 step 还不能使用这个自定义 shell,应显式用普通 bash,直到工具可用。Linux 与 macOS 都是目标平台时,使用矩阵分别执行,平台条件必须在两个 runner 上真实求值。
devenv test 会构建环境、运行 git hooks 与 enterTest,并自动启动和停止声明的 processes。它能证明环境可构建、测试入口可执行、process 生命周期在测试内闭合;它不能证明生产高可用、滚动升级、租户隔离和业务数据兼容。CI 失败时保留锁差量、脱敏日志和进程状态,避免上传 .env、完整环境与 service data。
故障要先按状态层分型
input fetch 或 TLS 失败发生在求值前,先检查代理、CA、DNS、GitHub 限额和 cache substituter;不要删除 service data。devenv.lock 意外变化则比较 input revision,确认是否执行过 update、删除过 lock 或启用了本机 override。
包版本正确但任务输出旧,检查 task 的 status、execIfModified 输入和 task cache;用输出 schema 或 hash 证明结果,而不是只看“task skipped”。进程 started 却请求失败,检查 ready probe、监听地址、端口占用和依赖后缀。服务配置改变却数据结构不变,检查 $DEVENV_STATE 与初始化只执行一次的语义。
“刷新缓存后好了”仍需指出是哪一类缓存以及哪个依赖没有正确失效。若只能靠删除整个 .devenv 恢复,先复制目录留作诊断,再逐层缩小到 eval、task、runtime 或 service state。否则下一次故障仍无法选择低风险清理动作。
清理、回滚和版本升级
停止开发服务先执行:
devenv processes downdevenv 2.2 及以上也可以使用等价简写 devenv down;项目若把 CLI 约束在 2.1.x,运维脚本必须保留长命令,不能引用新版本帮助页里的简写。
然后确认端口、子进程和 socket 已释放。只需要回滚 inputs 时,恢复上一版 devenv.lock 与匹配的 .nix/.yaml,再运行 devenv test;这不会回滚数据库。service schema 发生不兼容变化时,应使用项目自己的备份、迁移与恢复步骤,不能依赖 Nix lock。
要重置某个开发服务,先导出需要的数据,确认目标目录位于 $DEVENV_STATE,只删除该服务的状态,再重启并验证初始化。要释放包空间,使用 devenv gc 与受控 Nix GC;要彻底退出项目,依次停止进程、处理 service data、移除项目 GC roots、删除可再生 .devenv,最后按安装入口卸载 devenv 与 Nix。每一步影响的状态不同,不应合并成一个递归删除命令。
从 devenv 1.x 或更早配置迁到 2.x 时,要检查原生 process manager、task mode、命令名和输出格式。原生 manager 的 restart、信号与旧 process-compose 并非完全等价;关键长进程应做启动、ready、失败重启和停止信号回归。升级变更与 input update 分开提交,任何一步失败都能恢复上一版 CLI、声明和 lock。
选型看逃逸输入与状态责任
项目只需要几个变量和 PATH 时,direnv 的维护成本更低。已经使用 Flake 且只需要包闭包与 devShell 时,直接 nix develop 结构更透明。需要把语言工具、任务、多个开发服务、ready 与测试入口放进同一声明时,devenv 才体现价值。
它仍共享宿主内核、网络、文件系统和设备,也不承担生产调度。要求固定用户空间边界时应比较容器;要求不同内核时使用 VM;要求集中算力、统一身份与工作区审计时再进入远程工作区。选型时应比较未被 lock 约束的输入数量、首次构建与热缓存成本、磁盘增长、目标平台覆盖、Nix 学习成本、secret 暴露面和退出所需的数据迁移。
团队长期治理至少要明确声明与 lock 的评审 owner、binary cache 的信任与写权限、service state 的数据 owner、SecretSpec provider 的权限 owner、跨平台 CI 的维护者和升级窗口。冷构建时间、cache 命中、本地构建 CPU、Nix store 与 service data 容量应分开观察;只要这些趋势能解释、状态能逐层清理、同一 lock 能在目标 CI 重建,devenv 才真正提供了可运营的开发环境,而不只是另一条启动命令。
