一次不靠运气的 NixOS 大版本升级:从 25.11 到 26.05

这次升级的机器不是一台随时可以重装的测试机。

它既是我的日常 GNOME 桌面和开发环境,也运行着 PostgreSQL、Redis、Docker、Ollama、Traefik、Open WebUI 等服务。NixOS 25.11 升级到 26.05 时,systemd、D-Bus、Docker、GNOME、NetworkManager 和 glibc 都会变化。如果直接执行一次 nixos-rebuild switch,出了问题很难马上判断究竟是操作系统、数据库、容器镜像,还是正在运行的用户会话导致的。

所以这次升级最重要的成果,并不是记住了几个被重命名的 NixOS option,而是形成了一套更稳妥的升级方法:先缩小变量,再完整构建;先准备回滚,再部署启动项;最后用真实服务和数据完成验收。

TL;DR

我的最终升级顺序是:

其中最值得复用的原则有六条:

  1. system.stateVersion 和当前 NixOS release 不是一回事,升级时不要顺手修改。
  2. 操作系统升级和应用升级要解耦,容器镜像应临时固定到精确 digest。
  3. nix eval 成功不代表系统能构建,必须真实构建整个 system closure。
  4. 基础组件跨版本变化较多时,优先使用 boot,不要热 switch
  5. 回滚 generation 和数据备份解决的是两类不同问题,两者都要准备。
  6. 验收不能只看刚开机的几分钟,timer 和定时任务可能稍后才暴露错误。

为什么我没有直接 nixos-rebuild switch

NixOS 的声明式配置和 generation 回滚能力很强,但它并不能自动消除所有运行时风险。

这次候选系统同时包含了这些变化:

  • D-Bus 从 dbus-daemon 切换到 dbus-broker
  • initrd 开始使用 systemd;
  • systemd、Docker、GNOME 和 NetworkManager 跨版本升级;
  • glibc 更新会影响 PostgreSQL 的 collation 元数据;
  • Home Manager 也有一批 option 迁移;
  • 容器如果仍引用 latestmain,重启时还可能顺便升级应用。

switch 会尝试在当前系统里立即切换服务。单个小改动时这很方便,但当基础服务、桌面会话和有状态应用一起变化时,在线切换会制造一种尴尬状态:磁盘上的目标系统是新的,部分正在运行的进程却仍来自旧环境,另一些服务则可能已经重启。

我最终采用的是:

bash
sudo ./result-26.05/bin/switch-to-configuration boot

它只把新 generation 写成下一次启动目标,不在当前会话里热切换全部服务。旧系统继续稳定运行,直到备份和检查完成后再主动重启。这样故障边界清楚得多:重启前是完整的旧系统,重启后是完整的新系统。

第一步:先记录基线,而不是先改版本号

升级前,我先记录了这些信息:

  • nixos-version、内核、Nix 和 systemd 版本;
  • //boot 的剩余空间;
  • 当前失败的 systemd units;
  • PostgreSQL、Redis、Docker 等有状态服务的版本与运行状态;
  • 正在运行的容器以及它们的真实镜像 digest;
  • 当前 system generation 和至少一个可启动的旧 generation。

这一步看起来没有“做事”,却决定了升级后能否回答两个关键问题:到底变了什么,以及现在看到的问题是不是升级前就存在。

不要修改 stateVersion

我保留了原来的:

nix
system.stateVersion = "25.05";
home.stateVersion = "24.05";

stateVersion 不是“当前安装了哪个版本”的展示字段,而是声明系统要保留哪个时期的历史兼容行为。它可能影响服务的数据目录、默认配置和数据格式。

更新 nixpkgs 到 26.05,不等于应该把 system.stateVersion 也改成 26.05。除非准备逐项审查对应的状态迁移,否则最安全的选择通常是保持不变。

先保护回滚入口

我原来的维护任务每周运行:

bash
nix-collect-garbage -d

-d 会删除所有非当前 generations,这和“升级后保留旧系统用于回滚”的目标正面冲突。我把它改成:

bash
nix-collect-garbage --delete-older-than 30d

NixOS 可以生成可回滚的系统,不代表垃圾回收策略一定会替你保留它。回滚能力也是需要维护的配置。

第二步:冻结应用版本,减少同时变化的变量

升级前,Open WebUI、Dockhand 和 Traefik 的配置使用了 mainlatest 或普通 tag。如果此时重启 Docker,注册表里的 tag 可能已经指向另一份镜像,于是一次系统升级会同时变成三次应用升级,甚至触发数据库迁移。

我先从运行中的容器取得真实 digest,再把配置改成:

nix
image = "ghcr.io/example/app@sha256:<digest>";

这样重启后运行的仍是升级前已经验证过的应用版本。

这里的核心不是“永远不要用 tag”,而是:一次变更窗口只处理一个层级的问题。 先完成操作系统升级,确认数据和服务正常,再在另一个变更中升级应用。否则故障发生时,排查空间会成倍增长。

第三步:处理配置迁移,但不要止步于 option 改名

从 25.11 到 26.05,最直接的工作是修改 Flake release:

nix
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
home-manager = {
url = "github:nix-community/home-manager/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};

随后根据求值错误和弃用提示迁移配置。这次有几个典型案例。

systemd-resolved 改用结构化配置

原来的 extraConfigdnssecfallbackDns 被迁移为:

nix
services.resolved.settings.Resolve = {
DNSSEC = false;
DNSOverTLS = "no";
MulticastDNS = "no";
LLMNR = "yes";
Cache = "yes";
FallbackDNS = [
"1.1.1.1"
"8.8.8.8"
];
};

这类迁移表面上只是语法变化,但重启后仍然要用 resolvectl status 检查实际生效的 resolver、链路 DNS 和 resolv.conf 模式。

Ollama 从布尔开关变成包选择

原来的:

nix
services.ollama.acceleration = false;

改成显式选择 CPU 包:

nix
services.ollama.package = pkgs.ollama-cpu;

升级配置时应关注“原来的意图是什么”,而不只是机械寻找同名新 option。这里真正需要保留的语义是“不启用 GPU acceleration”。

PostgreSQL 服务端和客户端要固定到同一个大版本

我的 PostgreSQL 服务已经固定在 15:

nix
services.postgresql.package = pkgs.postgresql_15;

但系统工具列表里仍然写着不带版本的 postgresql。在新的 nixpkgs 中,它解析到了 PostgreSQL 17,最终在构建 system-path 时和 PostgreSQL 15 发生文件碰撞。

修复方式是把 CLI 也固定为:

nix
environment.systemPackages = with pkgs; [
postgresql_15
];

这件事说明:服务版本固定了,不代表整个系统里的相关工具链也固定了。 对数据库、编译器和语言运行时这类大版本敏感的软件,应该检查 closure 中是否混入了另一套版本。

清理不再交付的 Flake 输出

目标 ThinkPad 和 Home Manager 都构建成功后,nix flake check --no-build 仍然发现一个已经退役的 NUC/Proxmox 配置无法在 26.05 下求值。

它虽然不会阻止 ThinkPad 启动,却说明整个 Flake 已经不再自洽。确认那台设备确实退役后,我删除了对应 output、input 和主机配置。

如果一个输出已经没有所有者,也不会被持续验证,它就不是“留着以后也许有用”的资产,而是下一次升级的隐性成本。

第四步:完整构建,不要把 eval 当作验收

我先确认目标配置能求值:

bash
nix eval --raw \
.#nixosConfigurations.nixos.config.system.build.toplevel.drvPath

然后真实构建整个系统:

bash
nix build \
.#nixosConfigurations.nixos.config.system.build.toplevel \
--print-build-logs \
-o result-26.05

并独立构建 Home Manager:

bash
nix build \
.#homeConfigurations.cheverjohn.activationPackage \
--no-link

eval 只能证明模块合并和表达式求值成功。它发现不了所有问题,例如:

  • system-path 中两个包提供同名文件;
  • 某个闭源软件的下载源不可用;
  • Home Manager activation package 生成失败;
  • 服务脚本在 derivation 构建阶段出错;
  • 国内镜像缺少某个 NAR,且没有其他 substituter 可回退。

这次构建规模超过一千个 derivations,完整 closure 从约 31.6 GiB 增长到 34.0 GiB。它确实比一次 eval 昂贵得多,但这笔成本应该在旧系统仍然正常运行时支付,而不是在重启之后支付。

比较 closure,而不只看配置 diff

构建完成后,我运行:

bash
nix store diff-closures /run/current-system ./result-26.05

Git diff 告诉我“配置写了什么”,closure diff 则告诉我“最终会运行什么”。通过它可以快速发现:

  • 内核、systemd、Docker 是否跨大版本;
  • PostgreSQL 是否意外升级;
  • Node.js 等运行时是否发生替换;
  • D-Bus 实现、桌面和固件实际发生了哪些变化。

对于声明式系统,最终 closure 才是部署对象。

第五步:备份数据,再写入启动项

旧 generation 能回滚系统配置和软件,却不能撤销数据库写入或应用数据迁移。因此我在部署前分别备份了:

  • PostgreSQL:pg_dumpall
  • Redis:确认持久化状态后保存 RDB;
  • Open WebUI 等文件型应用:停止相关容器后归档 bind mount;
  • 当前配置、lock 文件和关键状态清单。

这里有一个容易混淆的边界:

  • generation 回滚处理“新系统不能正常启动或配置错误”;
  • 数据备份处理“新服务已经修改了持久化数据”。

只准备其中一个,都不算完整的恢复方案。

完成备份后,我才把已经构建好的结果写入 systemd-boot,并确认启动菜单中仍保留 NixOS 25.11 的 generation。

第六步:重启后的验收要分层进行

“能够进入桌面”只能证明系统启动了,不能证明升级完成。我把验收分成了几层。

系统身份

bash
nixos-version
uname -r
readlink -f /run/current-system
systemctl is-system-running
systemctl --failed

先确认当前运行的就是目标 store path,而不是误进了旧 generation。

有状态服务

bash
pg_isready -U postgres
redis-cli ping
curl http://127.0.0.1:11434/api/version

除了进程是否 active,还要检查版本、数据是否存在、持久化是否正常,以及 API 是否真正响应。

容器

我检查了每个容器的:

  • health 状态;
  • .Config.Image 是否仍是固定的 digest;
  • restart count;
  • 对外 HTTP 入口;
  • 应用日志中是否出现迁移错误。

桌面与网络

这部分包括 GNOME 会话、Home Manager 用户服务、Wi-Fi、DNS、蓝牙和外网连通性。桌面机器尤其不能只验收 system services,用户级 unit 和 portal 同样可能因版本变化而失效。

两个只有重启后才暴露的坑

glibc 更新后的 PostgreSQL collation 告警

新系统使用 glibc 2.42,而原 PostgreSQL cluster 记录的 collation version 是 2.40。连接数据库时出现版本不一致告警。

我没有看到告警就立即执行修复,而是先只读检查每个数据库的 recorded/actual version,并确认其中没有用户表和用户索引。在升级前备份已经存在的前提下,才刷新元数据:

sql
ALTER DATABASE postgres REFRESH COLLATION VERSION;
ALTER DATABASE template1 REFRESH COLLATION VERSION;

如果数据库中存在依赖 locale 排序规则的索引,不能只刷新版本数字;应先评估并重建受影响对象。REFRESH COLLATION VERSION 本身不会替你重建索引。

ExecStart 不是 shell 命令行

刚重启时系统状态正常,但午夜 timer 触发后,drop-caches.service 失败并让系统进入 degraded。原配置是:

nix
serviceConfig.ExecStart =
"${pkgs.coreutils}/bin/sync; echo 3 > /proc/sys/vm/drop_caches";

systemd 不会默认把 ExecStart= 交给 shell。;> 没有命令拼接或重定向语义,它把 sync; 当成了可执行文件名的一部分,最终报 203/EXEC

如果确实需要 shell 语义,应写成 NixOS service script:

nix
systemd.services.drop-caches = {
serviceConfig.Type = "oneshot";
script = ''
${pkgs.coreutils}/bin/sync
echo 3 > /proc/sys/vm/drop_caches
'';
};

不过更合理的处理很可能是直接删除这个 timer。Linux 本来就会管理 page cache,日常定时清空缓存通常只会降低命中率。

这个问题不是 26.05 引入的,而是旧配置一直存在,只是之前没有进入我的观察窗口。它提醒我:升级验收不能只检查开机后的瞬时状态,还要覆盖延迟执行的 timer。

写本文时,这个 service 仍是已知遗留项,我没有把“核心服务正常”包装成“系统没有任何问题”。

不要被日志中的每一个 error 牵着走

升级后的日志还出现了 dbus-broker 重复 service name、Fcitx/PAM 提示,以及一些 BIOS/ACPI/PMC 警告。

它们值得记录,但不能仅凭关键词判断严重程度。我最终采用的判断方式是:

  1. 错误是否对应一个 failed unit;
  2. 相关功能能否通过独立检查;
  3. 错误是否持续出现或造成重试;
  4. 它是升级新引入,还是旧系统已有;
  5. 修复它是否会扩大本次变更范围。

例如 dbus-broker 虽然输出了大量 duplicate 提示,但 GNOME、portal 和 keyring 实际工作正常,因此它被记录为非阻塞日志噪声,没有在升级窗口里顺便重构整个桌面包集合。

严谨不是见到红字就改配置,而是让每个判断都能被状态和功能验证支持。

一份可以复用的升级清单

升级前

  • 创建独立升级分支。
  • 记录系统、内核、Nix、systemd 和数据库版本。
  • 检查 //boot、失败 units 和当前 generations。
  • 列出所有有状态服务及其数据目录。
  • 记录运行容器的精确 digest。
  • 保持 system.stateVersionhome.stateVersion 不变。
  • 确认垃圾回收不会提前删掉回滚 generation。

配置与构建

  • 让 nixpkgs 与 Home Manager release 匹配。
  • 处理 removed、renamed 和 changed-semantics options。
  • 固定数据库服务端与 CLI 的大版本。
  • 冻结容器镜像,避免同时升级应用。
  • 完整构建 NixOS system closure。
  • 独立构建 Home Manager activation package。
  • 运行 formatter、git diff --check 和 flake check。
  • nix store diff-closures 审查最终版本变化。

部署与恢复

  • 备份 PostgreSQL、Redis 和文件型应用数据。
  • 确认备份可以读取,并记录恢复前需要停止的服务。
  • 使用 boot 部署,避免基础服务在线混合切换。
  • 检查启动菜单里仍存在旧 generation。
  • 明确回滚系统和恢复数据的触发条件。

重启后

  • 确认 /run/current-system 指向目标 closure。
  • 检查 system 和 user units。
  • 验证数据库连接、版本、数据与持久化。
  • 验证容器 digest、health、restart count 和 HTTP 入口。
  • 验证桌面、Wi-Fi、DNS、蓝牙与外网。
  • 检查 glibc/PostgreSQL collation version。
  • 等 timer 触发后再次检查 failed units。
  • 保留备份和旧 generation,直到观察期结束。

最后

这次 NixOS 25.11 到 26.05 的升级最终成功:新内核、systemd、dbus-broker、GNOME、Docker、PostgreSQL、Redis、Ollama 和 Home Manager 都正常运行,旧 generation 和升级前备份也仍然保留。

但真正让我更有信心的不是“这次没翻车”,而是整个过程不依赖运气:

  • 构建发生在旧系统仍可用的时候;
  • 应用升级被移出本次变更窗口;
  • 部署前已经准备好系统回滚和数据恢复;
  • 重启后的每个结论都有命令、状态或真实请求支撑;
  • 唯一的失败 unit 也被如实留下,而不是为了一个漂亮结论被忽略。

NixOS 的优势从来不只是“配置可以写成代码”。更重要的是,它让我们有机会把一次高风险升级拆成一系列可审查、可构建、可验证、可回退的状态变化。工具提供了这个能力,是否真正安全,仍然取决于我们如何设计升级过程。


Comments