NixOS 模块化服务(Modular Services)实战指南:以模块为单位声明可组合、可移植的服务
NixOS 模块化服务Modular Services实战指南以模块为单位声明可组合、可移植的服务【免费下载链接】nixpkgsNix Packages collection NixOS项目地址: https://gitcode.com/GitHub_Trending/ni/nixpkgs模块化服务Modular Services是 NixOS 25.11 引入的服务定义新范式它把传统上写在模块里的一组选项提升为本身就是模块的服务从而让服务具备天然的可组合性、可复用性与跨进程管理器systemd、launchd、s6 等的可移植性。本文将以 NixOS 手册中的 Modular Services 章节 为主线结合 Nixpkgs 仓库中lib/services/、nixos/modules/system/service/的源码实现以及 ghostunnel 等真实模块化服务实例系统讲解其设计原理、选项体系、编写规范与测试方法。读完本文你将掌握如何接入system.services.name声明服务、如何写出可移植的_class service模块、如何在 systemd 下定制单元行为以及如何为模块化服务编写 VM 测试并通过passthru.services将其随包分发。传统服务的困境选项写在模块里而不是模块本身在模块化服务出现之前NixOS 服务的定义方式是在 NixOS 模块例如services.nginx、services.postgresql内部声明一组选项把进程启动、配置生成、系统集成逻辑统统固化在这组选项里。这种做法的根本问题是非模块化non-modular。服务不是独立、可自由搬移的单元而是与特定配置管理框架NixOS的选项命名空间深度耦合。由此引发一系列问题可组合性差很难把某个服务作为构件嵌入到更大的服务或组合体中服务的实例无法用模块系统原生机制去 import 和叠加。复用困难同一份服务逻辑很难在另一个配置管理框架如 Home Manager、nix-darwin中复用因为它假定自己是运行在 NixOS 的系统命名空间下。不可移植服务的定义隐含依赖了 systemd 的选项树而其他进程管理器launchd 等无法消费这些定义。模块化服务的核心思路就是把这些逻辑重新组织成模块本身——一个服务就是一个模块可以被imports组合、被赋予用户指定的名字、被拆成子服务树。配置管理框架与服务管理组件要理解模块化服务的定位需要先厘清两个层次的概念。**配置管理框架configuration management framework**是evalModules的一个应用场景把class与specialArgs输入参数设为特定值从而得到一套带有约束和特殊参数的模块求值体系。NixOS 是一个配置管理框架Home Manager、nix-darwin 也是。对应源码位于 lib/modules.nix。**服务管理组件service management component**是配置管理框架中负责把 Nix 表达式与底层服务进程管理器连接起来的那一组模块选项对 NixOS 而言是包装 systemd 的模块nixos/modules/system/service/systemd/system.nix对 nix-darwin 而言是包装 launchd 的模块未来也可以是对 s6、runit 等进程管理器的包装。模块化服务modular service正是插在这个组件上的模块它为核心选项集包括运行哪个程序定义值。由于它本身是模块因此可以经由imports与其他模块组合、扩展其功能。NixOS 的接入点system.services.nameNixOS 为模块化服务提供了两个可插入的位置system.services.name——系统级服务用户级服务的选项TBD——尚未落地仍在设计中。关键在于这些选项的类型是attrsOf与submodule的组合。服务名就是attrsOf对应的属性名——你在system.services下写什么属性名服务就叫什么名字。该submodule被预加载了两个模块参见 system.nix 中的portable-lib.configure调用一个通用可移植模块——即lib/services/service.nix定义的便携服务基座它不依赖任何具体进程管理器一个 systemd 特定模块——即nixos/modules/system/service/systemd/service.nix其选项的值或默认值从通用模块的选项值推导而来例如把process.argv转译为systemd.mainExecStart、把process.reloadSignal转译为ExecReload。因此system.services.name的默认值并不是一个完整服务。它要求用户提供值而通常的做法是导入一个模块。手册给出了最典型的用法原样继承自 modular-services.md{ system.services.my-service-instance { imports [ pkgs.some-application.services.some-service-module ]; foo.settings { # ... }; }; }从源码结构看这一魔术分成两半完成见 system.nix前半system.services选项被声明为types.attrsOf modularServiceConfiguration.serviceSubmodule把便携服务基座与 systemd 特定模块混入同一个子模块类型后半把在隔离环境中定义的 unitmakeUnits services/makeUnits sockets、configData文件makeNixosEtcFiles以及断言/警告portable-lib.getAssertions/getWarnings回流到 NixOS 的systemd.services、systemd.sockets、environment.etc等系统级选项中。这也是设计取舍的记录system.services.name相比systemServices、services.abstract、services.modular等备选命名更自然详见 nixos/modules/system/service/README.md 中的设计决策日志Design decision log。可移植服务选项Portable Service Options详解便携服务基座由 lib/services/service.nix 定义包含以下核心选项。这些选项不依赖 systemd任何进程管理器实现launchd、s6都可以消费它们。process.argv启动命令行process.argv是启动服务进程的命令文件名与参数列表类型为types.listOf pathOrStr。其中pathOrStr是types.path与types.str的强制转换类型源码注释明确说明路径采用插值${x}而非toString这样路径会被复制进 store服务运行机器上依然可以解析而toString会留下配置求值源树的路径运行时不存在见 service.nix。process.argv [ (lib.getExe config.package) --nobackground ];注意事项见 service.nix这是原始命令行不应包含任何 shell 转义如需环境变量展开应使用 shell 脚本或pkgs.execline的importas当设置了flags时由flags渲染出的参数会合并进argv二者共享同一套lib.mkOrder优先级空间。process.flags与process.flagFormat声明式参数process.flags让你用属性集或列表声明传给进程的参数key 是参数名如--portvalue 是参数值。每个name value对经由lib.cli.toCommandLine配合flagFormat渲染见 service.nixnull参数被省略无论flagFormat如何bool按flagFormat.explicitBool渲染——false默认时true输出裸参数、false被省略true时true/false都按formatArg显式输出string / path / int按flagFormat.sep连接到选项名再经formatArg字符串化。需要重复传同一参数时用列表形式例如[ { --host a; } { --host b; } ]。源码层面渲染发生在 service.nixprocess.argv由mapDefinitionValue把每个 flag 定义映射为lib.cli.toCommandLine config.process.flagFormat attr后mkMerge而成。排序语义重要细节argv与flags共享单个lib.mkOrder空间未加排序属性的 flag 被放到优先级1250unadornedFlagPriority介于默认优先级 1000裸argv定义与lib.mkAfter1500之间因此普通 flag 会跟在命令名和普通argv参数之后lib.mkAfter的argv仍然排在 flags 之后用来表达尾部位置参数lib.mkOrder作用于 flag 时则原样生效可用于把子命令插到两组 flags 之间见 service.nix 与 service.nix。process.reloadSignal与process.reloadCommand热重载process.reloadSignalnullOr str默认null如HUP向服务管理器配置重载信号。process.reloadCommandnullOr str默认null底层服务管理器用于重载的命令。二者之间存在一条推导与约束规则见 service.nix当设置了reloadSignal时reloadCommand会以mkDefault优先级推导为${pkgs.coreutils}/bin/kill -${reloadSignal} $MAINPID同时通过assertions检查——只有用户显式设置了reloadCommand优先级不高于defaultOverridePriority且reloadSignal非空时才报错避免两者冲突。notificationProtocol就绪通知协议notificationProtocol是一个子模块见 service.nix声明服务与底层服务管理器之间支持的就绪通知协议notificationProtocol.systemd true; # 支持 systemd-notify notificationProtocol.s6 true; # 支持 s6-notify在 systemd 集成层notificationProtocol.systemd会直接影响单元的Type值notify而非默认的simple见下文 systemd 特定选项。configData免重启更新配置configData定义于 lib/services/config-data.nix为服务提供配置文件可以在不终止、不重启服务进程的情况下更新——这正是许多服务支持 SIGHUP 热重载或自动感知文件变化的用武之地。每个configData.name条目是一个子模块见 lib/services/config-data-item.nix属性包括选项类型说明enablebool默认true是否生成该配置文件可单独禁用某个文件namestr配置文件相对服务配置目录的名字默认取属性名pathstrreadOnly文件实际可用路径由服务管理器实现决定NixOS 下是绝对路径其他管理器可提供相对路径以便无特权/可重定位textnullOr lines文件文本内容sourcepath源文件路径当text非空时自动由pkgs.writeText派生示例摘自 config-data.nixconfigData { server.conf { text port 8080 workers 4 ; }; ssl/cert.pem { source ./cert.pem; }; };在 NixOS 上systemd 集成层通过 config-data-path.nix 递归为每个服务含子服务计算唯一路径形如/etc/system-services/webserver/与/etc/system-services/webserver-api/再经 system.nix 的makeNixosEtcFiles把configData映射为environment.etc条目。configData之所以不叫environment.etc是为了保持服务管理器无关性——其他管理器可能以不同目录、相对路径的方式暴露配置数据见 nixos/modules/system/service/README.md。services子服务归属关系便携基座还声明了services选项attrsOf子模块类型为submoduleWithclass service用于在一个服务内部声明子服务。子服务之间、子服务与父服务之间的关系被定义为归属关系ownership——它不会自动创建任何其他关系例如 systemd slice除非另有选项显式定义并启用见 service.nix。这是服务组合的关键机制下文详述。meta.maintainers与assertions/warnings便携基座通过imports预加载了两个通用模块见 service.nixlib/modules/generic/meta-maintainers.nix提供meta.maintainers与meta.teams选项其类型会把每个定义追溯到具体模块文件sourceList并要求值必须来自lib.maintainers/lib.teamslib/modules/generic/assertions.nix提供assertions与warnings选项供服务模块表达求值期必须成立的条件与警告信息。在 NixOS 集成层portable-lib.getAssertions与getWarnings定义于 lib/services/lib.nix会递归遍历服务树把每个服务含子服务里的断言/警告带上in 选项路径:前缀后提升到系统级与 NixOS 自身的断言体系合并。systemd 特定服务选项详解systemd 集成模块 nixos/modules/system/service/systemd/service.nix 定义了以下选项它们只在 systemd 环境中存在——这恰恰是便携性的关键下文 Portability 一节会说明。systemd.mainExecStart主命令行直接对应 systemd 的ExecStart默认值是config.systemd.lib.escapeSystemdExecArgs config.process.argv即把便携的process.argv转义后的结果——转义会禁用 systemd 的%说明符与$变量替换保证process.argv按字面执行。需要启用 systemd 的替换特性如%n、%i、%t等 specifier或${VAR}环境变量时显式设置该选项例如见 service.nixsystemd.mainExecStart config.systemd.lib.escapeSystemdExecArgs config.process.argv --systemd-unit %n;systemd.mainExecReload主重载命令行对应ExecReload默认取process.reloadCommand按原样使用以保留$MAINPID这类引用。当process.reloadCommand未设置时该选项为null此时不会渲染出ExecReload行服务可以在systemd.service.serviceConfig.ExecReload里自行定义。可参考systemd.mainExecStart的写法追加 systemd specifier 扩展它。systemd.lib.escapeSystemdExecArgs导出的转义函数用于把参数列表安全地拼进Exec*行用toJSON产生 systemd 接受的引号转义子集并把%转成%%、$转成$$从而禁掉 specifier 与变量展开裸的;也会因 JSON 转义而失去分隔命令的意义见 service.nix。systemd.lib.escapeSystemdExecArgs [ /bin/echo Unit %n ] # 产出 /bin/echo Unit %%nsystemd.services与systemd.sockets单元定义逃生口这两个选项用于声明 systemd 单元单元名会被自动加上抽象服务名前缀。注意它们的类型是deferredModuleWith/deferredModule——选项里存放的是延迟deferred模块尚未与系统配置合并因此无法读取该选项的值正确的用法是定义一个模块让它读取合并进系统配置时才可用的模块参数如config见 service.nix。同时在systemd.service选项上做了两个别名service.nixlib.mkAliasOptionModule [ systemd service ] [ systemd services ] lib.mkAliasOptionModule [ systemd socket ] [ systemd sockets ]默认单元模板见 service.nix会为每个服务生成systemd.services. { wantedBy lib.mkDefault [ multi-user.target ]; serviceConfig { ExecReload lib.mkIf (config.systemd.mainExecReload ! null) config.systemd.mainExecReload; Type lib.mkDefault (if config.notificationProtocol.systemd then notify else simple); Restart lib.mkDefault always; RestartSec lib.mkDefault 5; ExecStart [ config.systemd.mainExecStart ]; }; };也就是说声明notificationProtocol.systemd true会让单元变成Typenotify默认Restartalways、RestartSec5ExecReload只有在mainExecReload非空时才出现。这些默认行为都有 nixos/tests/system-services-compliance.nix 中的求值级断言测试testDefaultType、testNotifyType、testReloadExecReload、testNoReloadExecReloadUnset、testServiceOwnExecReload做保障。子服务的 systemd 展开systemd 集成模块同样把自己的选项递归注入子服务services选项class service见 service.nix而 system.nix 的makeUnits会递归遍历整棵服务树把每个叶子单元以父服务名-子服务名的形式dash拼接导出为真正的systemd.services.name/systemd.sockets.name。可移植性Portability模块化服务可以写成可移植的要么完全避开systemd选项树要么以可选方式定义进程管理器特定配置。手册给出的标准写法见 modular-services.md{ config, options, lib, ... }: { _class service; config { process.argv [ (lib.getExe config.foo.program) ]; } // lib.optionalAttrs (options ? systemd) { # ... systemd-specific definitions ... }; }这样该模块可以加载进不使用 systemd 的配置管理器中systemd相关定义会被静默忽略其他配置管理器也可以为服务声明自己的选项做定制。这个options ? systemd模式正是nixos/README-modular-services.md审查清单中的硬性要求Systemd-specific definitions are behindoptionalAttrs (options ? systemd)。一个补充模块化服务模块不应使用pkgs模块参数。基础设施刻意不向服务模块暴露pkgs派生体与构建函数通过词法闭包提供使依赖关系显式、避免这个pkgs是哪个版本的歧义。服务模块应把包依赖声明为选项如foo.package mkOption { type types.package; }由调用方提供或用passthru.services在包内以词法闭包方式提供详见 nixos/modules/system/service/README.md。组合与归属Composition and Ownership与传统服务相比模块化服务天然更可组合它们本身是模块并在导入时获得用户提供的名字。但组合不能止步于此——服务之间需要互相交互这有两条路径用户在 NixOS 配置中把服务链接起来通过imports引入多个服务模块在配置里互相传值服务作为其他服务的组合体利用便携基座的services子服务机制一个服务可以内嵌多个子服务。这两种方式并不互斥。实际上良好的开发实践是先把每个服务写成独立服务再组合成更高级的组合体其中每个服务包括它们的组合都是一个合法的模块化服务。从实现看组合的递归展开发生在三个层面makeUnits/makeNixosEtcFiles递归遍历服务树system.nix、flattenMapServicesConfigToList递归收集断言/警告lib/services/lib.nix、config-data-path.nix递归计算唯一配置路径config-data-path.nix。每个子服务的单元、配置文件、断言都会被扁平化到系统级且名字带有唯一的服务前缀互不冲突。nixos/tests/modular-service-etc/test.nix是组合的完整演示webserver服务下挂了一个名为api的子服务测试断言其生成的单元名为webserver.service与webserver-api.service配置文件分别位于/etc/system-services/webserver/webroot与/etc/system-services/webserver-api/webroot。迁移Migration很多服务都可以迁移到模块化服务体系但即便模块化服务成熟之后也没有必要迁移全部服务。例如许多系统级服务是桌面系统的强制组成部分多实例化它们没有意义手册以 TODO 注记留待补充单实例服务示例把这类服务的逻辑拆分到独立 Nix 文件里仍可能对不启用这些服务的配置求值效率有好处但这只是次要收益——除非模块化服务将来成为定义服务的标准方式这种收益才变得显著。编写与审查一个模块化服务关于编写与审查的完整贡献者规范见 nixos/README-modular-services.md。其要点如下。最低标准Minimum Standard模块化服务必须附带一个NixOS VM 测试来实际运行验证模块化服务必须有meta.maintainers模块属性列出服务维护者不必与 NixOS 模块的维护者相同若你不是 NixOS 模块维护者建议加入其meta.maintainers团队以便参与评审。审查清单贡献者评审模块化服务时逐项检查- [ ] 有 NixOS VM 测试 - [ ] 有 meta.maintainers 属性 - [ ] systemd 特定定义放在 optionalAttrs (options ? systemd) 之后以促进可移植性 - [ ] _class service - [ ] 通过 passthru.services 提供的模块化服务必须用 finalAttrs.finalPackage 覆盖包选项默认值 - [ ] 模块化服务基础设施是否足以支撑该服务如有未覆盖特性去对应的 issue 中评论 - [ ] 已加入 nixos/modules/misc/documentation/modular-services.nix最后一项对应 nixos/modules/misc/documentation/modular-services.nix它用fakeSubmodule为每个已入库的模块化服务渲染文档目前包括autopush-rs、ghostunnel、git-pages、ktls-utils、php、snid、trailbase等纳入documentation.nixos.extraModules。_class service_class声明确保当模块被意外导入到非模块化服务的配置如 NixOS 配置时模块系统给出清晰报错。提供方式把它作为模块的第一个属性。# 非模块依赖importApply { writeScript, runtimeShell }: # 服务模块 { lib, config, ... }: { _class service; options { # ... }; config { # ... }; }注意与evalModules的class参数配合NixOS 的模块类是nixos见 system.nix服务子模块类是service见 lib/services/lib.nix类型不符时会在求值期报错。整个便携基座也声明_class serviceservice.nix。覆盖包默认值finalAttrs.finalPackage当模块化服务通过passthru.services提供时必须用finalAttrs.finalPackage覆盖包选项的默认值。原因是某些包本身就是由 override 定义出来的如果不这样做模块化服务会启动错误的包如果还能构建的话。若无法做到这一点、或该模块无法用单个包表示则应考虑只按文件路径直接暴露模块化服务。ghostunnel的包定义示范了标准做法见 pkgs/by-name/gh/ghostunnel/package.nixstdenv.mkDerivation (finalAttrs: { pname ghostunnel; # ... passthru.services.default { imports [ (lib.modules.importApply ./service.nix { }) ]; ghostunnel.package finalAttrs.finalPackage; }; })实战案例ghostunnel 模块化服务ghostunnel是一个带双向认证的 TLS 代理。其服务模块 pkgs/by-name/gh/ghostunnel/service.nix 是模块化服务的教科书实现声明_class service声明一系列ghostunnel.*选项package、listen、target、keystore、cert、key、cacert、disableAuthentication、allowAll、allowCN、allowOU、allowDNS、allowURI、extraArguments、unsafeTarget其中package的defaultText注明由提供本模块的 ghostunnel 包给出——即由passthru.services传入的finalAttrs.finalPackage填充在config中把选项渲染成process.argv命令行并用assertions强制至少设置一种访问控制方式通过// lib.optionalAttrs (options ? systemd)追加 systemd 专属增强用systemd.mainExecStart追加带${CREDENTIALS_DIRECTORY}变量替换的凭据参数这些变量替换在默认转义路径下会被禁用所以必须显式重写主命令行并设置systemd.service.serviceConfig的DynamicUser、AmbientCapabilities、LoadCredential、Restart等。在 NixOS 配置中启用该服务见 nixos/tests/ghostunnel-modular.nixsystem.services.ghostunnel-plain-old { imports [ pkgs.ghostunnel.services.default ]; ghostunnel { listen 0.0.0.0:443; cert /root/service-cert.pem; key /root/service-key.pem; disableAuthentication true; target backend:80; unsafeTarget true; }; };同一个模块在同一台机器上被实例化两次ghostunnel-plain-old与ghostunnel-client-cert正是多实例化能力的直接体现——这在传统单实例services.ghostunnel式设计中难以实现。测试与验证NixOS VM 测试每个模块化服务必须有 VM 测试。最佳实践是保持测试最小且聚焦启动 VM、启用服务、断言一次基本请求成功。ghostunnel-modular.nix的测试脚本依次验证后端连通性、忽略证书的 TLS、带 CA 校验的 TLS、客户端证书认证成功、以及无客户端证书时连接必须失败。nixos/tests/modular-service-etc/test.nix则验证configData的能力启动主服务webserver.service与子服务webserver-api.service断言/etc/system-services/下两个服务各自的配置目录与文件存在且内容正确记录两个服务的 MainPID切换到specialisation.updated后断言切换输出中不出现这两个服务、PID 不变——证明配置文件被原地更新而服务进程未被重启再断言 curl 抓取到的内容已是更新后的版本without restarting the service。该测试的配置文件目录还展示了configData与子服务组合的完整结构webserverservices.api子服务其模块定义在 nixos/tests/modular-service-etc/python-http-server.nixpython-http-server.directory的默认值直接取config.configData.webroot.path并通过enable mkDefault (...)按需启用配置文件。求值级合规测试nixos/tests/system-services-compliance.nix 用pkgs.testers.modularServiceCompliance对服务树做求值级断言默认单元Typesimple、启用notificationProtocol.systemd后Typenotify、reloadSignal HUP时ExecReload为kill -HUP $MAINPID、未设置重载命令时不得渲染ExecReload避免与服务自带的定义冲突或渲染出裸ExecReload行、服务自带ExecReload时保持原样。这些断言直接锚定了上文 systemd 默认单元模板的行为。现状与展望模块化服务是 NixOS 25.11 的新功能状态为in development后续很可能有显著变化。它对应的是 RFC 163 的中间产物——先尝试基于模块的可移植服务方案尚非广泛认可的标准解法。手册也明确模块化服务不是 NixOS 模块的替代品将来或许会而用模块化服务实现 NixOS 模块是预期用例只是目前对广泛使用的模块而言不确定性尚不可接受见 nixos/README-modular-services.md。即便如此从仓库现状system.services集成、lib/services便携基座、多套 VM/合规测试、多个已入库服务可以推断模块化服务正在成为 NixOS 定义可多实例、可组合、可移植服务的标准通道。对于编写新服务或重构既有服务的开发者_class serviceprocess.*optionalAttrs (options ? systemd)的组合已经是一套可复制、可验证的成熟套路对于希望在 launchd、s6 等管理器上复用的场景lib/services/lib.nix 中configure的文档注释serviceManagerPkgs、baseModules、extraRootModules、extraRootSpecialArgs参数提供了清晰的接入指引——一个非 systemd 的配置管理框架只需调用lib.services.configure拿到serviceSubmodule类型再实现自己的服务管理器专属模块即可。【免费下载链接】nixpkgsNix Packages collection NixOS项目地址: https://gitcode.com/GitHub_Trending/ni/nixpkgs创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考