这次升级的机器不是一台随时可以重装的测试机。
它既是我的日常 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
我的最终升级顺序是:
其中最值得复用的原则有六条:
system.stateVersion和当前 NixOS release 不是一回事,升级时不要顺手修改。- 操作系统升级和应用升级要解耦,容器镜像应临时固定到精确 digest。
nix eval成功不代表系统能构建,必须真实构建整个 system closure。- 基础组件跨版本变化较多时,优先使用
boot,不要热switch。 - 回滚 generation 和数据备份解决的是两类不同问题,两者都要准备。
- 验收不能只看刚开机的几分钟,timer 和定时任务可能稍后才暴露错误。
为什么我没有直接 nixos-rebuild switch
NixOS 的声明式配置和 generation 回滚能力很强,但它并不能自动消除所有运行时风险。
这次候选系统同时包含了这些变化:
- D-Bus 从
dbus-daemon切换到dbus-broker; - initrd 开始使用 systemd;
- systemd、Docker、GNOME 和 NetworkManager 跨版本升级;
- glibc 更新会影响 PostgreSQL 的 collation 元数据;
- Home Manager 也有一批 option 迁移;
- 容器如果仍引用
latest或main,重启时还可能顺便升级应用。
switch 会尝试在当前系统里立即切换服务。单个小改动时这很方便,但当基础服务、桌面会话和有状态应用一起变化时,在线切换会制造一种尴尬状态:磁盘上的目标系统是新的,部分正在运行的进程却仍来自旧环境,另一些服务则可能已经重启。
我最终采用的是:
bashsudo ./result-26.05/bin/switch-to-configuration boot
它只把新 generation 写成下一次启动目标,不在当前会话里热切换全部服务。旧系统继续稳定运行,直到备份和检查完成后再主动重启。这样故障边界清楚得多:重启前是完整的旧系统,重启后是完整的新系统。
第一步:先记录基线,而不是先改版本号
升级前,我先记录了这些信息:
nixos-version、内核、Nix 和 systemd 版本;/与/boot的剩余空间;- 当前失败的 systemd units;
- PostgreSQL、Redis、Docker 等有状态服务的版本与运行状态;
- 正在运行的容器以及它们的真实镜像 digest;
- 当前 system generation 和至少一个可启动的旧 generation。
这一步看起来没有“做事”,却决定了升级后能否回答两个关键问题:到底变了什么,以及现在看到的问题是不是升级前就存在。
不要修改 stateVersion
我保留了原来的:
nixsystem.stateVersion = "25.05";home.stateVersion = "24.05";
stateVersion 不是“当前安装了哪个版本”的展示字段,而是声明系统要保留哪个时期的历史兼容行为。它可能影响服务的数据目录、默认配置和数据格式。
更新 nixpkgs 到 26.05,不等于应该把 system.stateVersion 也改成 26.05。除非准备逐项审查对应的状态迁移,否则最安全的选择通常是保持不变。
先保护回滚入口
我原来的维护任务每周运行:
bashnix-collect-garbage -d
-d 会删除所有非当前 generations,这和“升级后保留旧系统用于回滚”的目标正面冲突。我把它改成:
bashnix-collect-garbage --delete-older-than 30d
NixOS 可以生成可回滚的系统,不代表垃圾回收策略一定会替你保留它。回滚能力也是需要维护的配置。
第二步:冻结应用版本,减少同时变化的变量
升级前,Open WebUI、Dockhand 和 Traefik 的配置使用了 main、latest 或普通 tag。如果此时重启 Docker,注册表里的 tag 可能已经指向另一份镜像,于是一次系统升级会同时变成三次应用升级,甚至触发数据库迁移。
我先从运行中的容器取得真实 digest,再把配置改成:
niximage = "ghcr.io/example/app@sha256:<digest>";
这样重启后运行的仍是升级前已经验证过的应用版本。
这里的核心不是“永远不要用 tag”,而是:一次变更窗口只处理一个层级的问题。 先完成操作系统升级,确认数据和服务正常,再在另一个变更中升级应用。否则故障发生时,排查空间会成倍增长。
第三步:处理配置迁移,但不要止步于 option 改名
从 25.11 到 26.05,最直接的工作是修改 Flake release:
nixnixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";home-manager = {url = "github:nix-community/home-manager/release-26.05";inputs.nixpkgs.follows = "nixpkgs";};
随后根据求值错误和弃用提示迁移配置。这次有几个典型案例。
systemd-resolved 改用结构化配置
原来的 extraConfig、dnssec 和 fallbackDns 被迁移为:
nixservices.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 从布尔开关变成包选择
原来的:
nixservices.ollama.acceleration = false;
改成显式选择 CPU 包:
nixservices.ollama.package = pkgs.ollama-cpu;
升级配置时应关注“原来的意图是什么”,而不只是机械寻找同名新 option。这里真正需要保留的语义是“不启用 GPU acceleration”。
PostgreSQL 服务端和客户端要固定到同一个大版本
我的 PostgreSQL 服务已经固定在 15:
nixservices.postgresql.package = pkgs.postgresql_15;
但系统工具列表里仍然写着不带版本的 postgresql。在新的 nixpkgs 中,它解析到了 PostgreSQL 17,最终在构建 system-path 时和 PostgreSQL 15 发生文件碰撞。
修复方式是把 CLI 也固定为:
nixenvironment.systemPackages = with pkgs; [postgresql_15];
这件事说明:服务版本固定了,不代表整个系统里的相关工具链也固定了。 对数据库、编译器和语言运行时这类大版本敏感的软件,应该检查 closure 中是否混入了另一套版本。
清理不再交付的 Flake 输出
目标 ThinkPad 和 Home Manager 都构建成功后,nix flake check --no-build 仍然发现一个已经退役的 NUC/Proxmox 配置无法在 26.05 下求值。
它虽然不会阻止 ThinkPad 启动,却说明整个 Flake 已经不再自洽。确认那台设备确实退役后,我删除了对应 output、input 和主机配置。
如果一个输出已经没有所有者,也不会被持续验证,它就不是“留着以后也许有用”的资产,而是下一次升级的隐性成本。
第四步:完整构建,不要把 eval 当作验收
我先确认目标配置能求值:
bashnix eval --raw \.#nixosConfigurations.nixos.config.system.build.toplevel.drvPath
然后真实构建整个系统:
bashnix build \.#nixosConfigurations.nixos.config.system.build.toplevel \--print-build-logs \-o result-26.05
并独立构建 Home Manager:
bashnix 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
构建完成后,我运行:
bashnix 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。
第六步:重启后的验收要分层进行
“能够进入桌面”只能证明系统启动了,不能证明升级完成。我把验收分成了几层。
系统身份
bashnixos-versionuname -rreadlink -f /run/current-systemsystemctl is-system-runningsystemctl --failed
先确认当前运行的就是目标 store path,而不是误进了旧 generation。
有状态服务
bashpg_isready -U postgresredis-cli pingcurl 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,并确认其中没有用户表和用户索引。在升级前备份已经存在的前提下,才刷新元数据:
sqlALTER DATABASE postgres REFRESH COLLATION VERSION;ALTER DATABASE template1 REFRESH COLLATION VERSION;
如果数据库中存在依赖 locale 排序规则的索引,不能只刷新版本数字;应先评估并重建受影响对象。REFRESH COLLATION VERSION 本身不会替你重建索引。
ExecStart 不是 shell 命令行
刚重启时系统状态正常,但午夜 timer 触发后,drop-caches.service 失败并让系统进入 degraded。原配置是:
nixserviceConfig.ExecStart ="${pkgs.coreutils}/bin/sync; echo 3 > /proc/sys/vm/drop_caches";
systemd 不会默认把 ExecStart= 交给 shell。; 和 > 没有命令拼接或重定向语义,它把 sync; 当成了可执行文件名的一部分,最终报 203/EXEC。
如果确实需要 shell 语义,应写成 NixOS service script:
nixsystemd.services.drop-caches = {serviceConfig.Type = "oneshot";script = ''${pkgs.coreutils}/bin/syncecho 3 > /proc/sys/vm/drop_caches'';};
不过更合理的处理很可能是直接删除这个 timer。Linux 本来就会管理 page cache,日常定时清空缓存通常只会降低命中率。
这个问题不是 26.05 引入的,而是旧配置一直存在,只是之前没有进入我的观察窗口。它提醒我:升级验收不能只检查开机后的瞬时状态,还要覆盖延迟执行的 timer。
写本文时,这个 service 仍是已知遗留项,我没有把“核心服务正常”包装成“系统没有任何问题”。
不要被日志中的每一个 error 牵着走
升级后的日志还出现了 dbus-broker 重复 service name、Fcitx/PAM 提示,以及一些 BIOS/ACPI/PMC 警告。
它们值得记录,但不能仅凭关键词判断严重程度。我最终采用的判断方式是:
- 错误是否对应一个 failed unit;
- 相关功能能否通过独立检查;
- 错误是否持续出现或造成重试;
- 它是升级新引入,还是旧系统已有;
- 修复它是否会扩大本次变更范围。
例如 dbus-broker 虽然输出了大量 duplicate 提示,但 GNOME、portal 和 keyring 实际工作正常,因此它被记录为非阻塞日志噪声,没有在升级窗口里顺便重构整个桌面包集合。
严谨不是见到红字就改配置,而是让每个判断都能被状态和功能验证支持。
一份可以复用的升级清单
升级前
- 创建独立升级分支。
- 记录系统、内核、Nix、systemd 和数据库版本。
- 检查
/、/boot、失败 units 和当前 generations。 - 列出所有有状态服务及其数据目录。
- 记录运行容器的精确 digest。
- 保持
system.stateVersion与home.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