NixOS 模块系统:options 声明与模块合并语义

NixOS 模块系统是声明式系统配置的心脏。本文深入 options 声明、模块合并语义、mkOption/mkIf/mkMerge/mkDefault 的优先级规则、以及如何开发可复用的自定义模块,从源码语义讲透为什么 NixOS 配置能「合并而不冲突」。

1. 模块系统解决什么问题

普通配置文件(INI/YAML/TOML)的问题是**「覆盖」是隐式的**:A 配置里写了 port = 8080,B 配置里写了 port = 9090,最终谁赢取决于加载顺序,且无法表达「合并」或「条件生效」。

NixOS 模块系统把这一切变成显式、可组合、声明式:

  • 每个模块声明 options(这个模块「能配置什么」)
  • 多个模块可以声明同一个 option,通过合并语义决定最终值
  • 合并是无序的(除了优先级),不再依赖文件加载顺序
  • 模块可以条件性声明(mkIf)、叠加合并(mkMerge)、跨模块引用(config 与 options 的相互引用)

理解模块系统是 https://plumephp.com/nixos-configuration/ 的进阶,也是编写可复用 NixOS 基础设施的必修课。本文配合 https://plumephp.com/nix-language-deep-dive/ 中 fix 定点组合的知识——模块系统正是 fix 在「配置世界」的应用。

📌 相关专题:系统配置基础见 https://plumephp.com/nixos-configuration/;跨机器复用可参考 https://plumephp.com/nix-deployment-tools/;用户级模块见 https://plumephp.com/nix-home-manager/。


2. 模块是什么:函数 + 属性的合一

2.1 模块的两种形态

# 形态一:属性集模块(没有参数)
{
  services.nginx.enable = true;
}

# 形态二:函数模块(接收 config/pkgs/lib/options 等)
{ config, pkgs, lib, ... }:
{
  services.nginx.enable = true;
  environment.systemPackages = [ pkgs.curl ];
}

模块函数接收的参数由模块系统注入(fix 的体现):config 是合并后的最终配置,pkgs 是包集,lib 是函数库,options 是全部 options 的声明。正因为是惰性求值,模块之间可以互相读取对方声明的值——这就是跨模块引用的基础。

2.2 ... 的必要性

函数模块必须写 ...(或显式列出所有参数),因为模块系统会注入 config, pkgs, lib, options, modulesPath 等参数。忘写 ... 会直接报错。


3. options 声明:定义「能配置什么」

3.1 mkOption 基础

{ lib, ... }:
{
  options.my-service = {
    enable = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = "是否启用 my-service。";
    };

    port = lib.mkOption {
      type = lib.types.port;
      default = 8080;
      description = "监听端口。";
    };

    backendHosts = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [ "localhost" ];
      description = "上游后端地址列表。";
    };
  };
}

3.2 常用类型

类型写法说明
布尔lib.types.booltrue/false
整数lib.types.int含范围校验 int [0 65535]
端口lib.types.port0-65535 整数
字符串lib.types.str单行字符串
多行lib.types.lines保留换行
路径lib.types.pathstore 路径
包lib.types.packagederivation 引用
列表lib.types.listOf t元素类型 t
属性集lib.types.attrsOf t属性值类型 t
枚举lib.types.enum [ "a" "b" ]限定取值
子模块lib.types.submodule { ... }结构化嵌套配置
null 或某类型lib.types.nullOr t可空

3.3 子模块:结构化配置的利器

options.web = {
  virtualHosts = lib.mkOption {
    type = lib.types.attrsOf (lib.types.submodule {
      options = {
        serverName = lib.mkOption { type = lib.types.str; };
        ssl = lib.mkOption { type = lib.types.bool; default = false; };
        locations = lib.mkOption {
          type = lib.types.attrsOf (lib.types.submodule {
            options.proxyPass = lib.mkOption { type = lib.types.str; };
          });
        };
      };
    });
    default = {};
  };
};

4. 模块合并语义:为什么「无序」却能精确

4.1 合并的规则

当多个模块声明同一个 option 时,NixOS 按优先级合并:

优先级值语义对应写法
default最低(默认值)default = ...
mkDefault v覆盖默认值lib.mkDefault v
mkOverride 10 v普通覆盖(模块赋值默认 100)lib.mkOverride 10 v
mkForce v强制覆盖(高优先级 1000)lib.mkForce v
mkOrder 500 v调整合并顺序(列表/attrset 专用)lib.mkOrder 500 v
mkBefore / mkAfter顺序标记(列表合并)lib.mkBefore [ x ]
{ lib, ... }: {
  services.nginx.virtualHosts."example.com" = lib.mkForce {
    listen = [ { addr = "0.0.0.0"; port = 443; ssl = true; } ];
    locations."/api".proxyPass = "http://backend:3000";
  };
}

4.2 标量类型:后到优先

对 str/int/bool 等标量,合并规则是「高优先级赢」;同优先级时后加载的模块覆盖先加载的:

# 模块 A
{ services.foo.port = 100; }
# 模块 B(同优先级,后加载)
{ services.foo.port = 200; }
# 最终值:200(B 覆盖 A)

4.3 列表与 attrset:默认拼接

对 listOf 类型,多个声明会拼接而非覆盖(除非显式 mkForce):

# 模块 A
{ services.foo.servers = [ "a" ]; }
# 模块 B
{ services.foo.servers = [ "b" ]; }
# 最终:["a" "b"] 或 ["b" "a"],取决于 mkOrder / 加载顺序

# 用 mkBefore / mkAfter 控制顺序
{ services.foo.servers = lib.mkBefore [ "primary" ]; }
{ services.foo.servers = lib.mkAfter  [ "backup" ]; }

对 attrsOf,同名 key 递归合并,不同 key 并存:

# 模块 A
{ services.foo.hosts.beta = "127.0.0.1"; }
# 模块 B
{ services.foo.hosts.prod = "10.0.0.5"; }
# 最终:{ beta = ...; prod = ...; }  —— 两个 key 都在

4.4 合并优先级速查表

场景推荐写法
想给模块提供「默认值但允许覆盖」mkDefault
想强制锁定某个值(不允许用户改)mkForce
列表顺序敏感mkBefore / mkAfter
多个来源合成为一个列表直接并列声明(自动拼接)
想无条件禁止某配置mkForce false / mkForce null

5. mkIf:条件式声明

5.1 条件配置的基础形态

mkIf 让模块系统在求值阶段根据条件决定是否包含某组配置——它不只是「跳过赋值」,而是让被跳过部分完全不参与合并:

{ config, pkgs, lib, ... }:
{
  config = lib.mkIf config.my-service.enable {
    # 仅当 enable = true 时这些配置生效
    environment.systemPackages = [ pkgs.my-service ];
    systemd.services.my-service = {
      wantedBy = [ "multi-user.target" ];
      serviceConfig.ExecStart = "${pkgs.my-service}/bin/my-service";
    };
  };
}

5.2 mkIf 的求值细节

mkIf 的求值是惰性短路的:

  • 条件为 false 时,其子树完全不被强制求值——引用不存在的包或属性也不会报错
  • 条件本身可以是惰性表达式,由合并后的 config 决定
# 合法:config 尚未完全求值时也可以作为条件
{ config, lib, ... }: {
  config = lib.mkIf (config.services.nginx.enable) {
    networking.firewall.allowedTCPPorts = [ 80 443 ];
  };
}

5.3 mkMerge:一个模块内的多条件分支

{ config, lib, ... }: {
  config = lib.mkMerge [
    (lib.mkIf config.isServer {
      services.openssh.enable = true;
    })
    (lib.mkIf config.isDesktop {
      services.xserver.enable = true;
    })
    # 无条件的基础配置
    {
      environment.systemPackages = [ pkgs.vim ];
    }
  ];
}

mkMerge 与多个模块声明等价,但让同一模块内的多分支表达更紧凑。


6. 开发自定义模块:完整实战

6.1 一个可复用的「my-service」模块

# modules/my-service.nix
{ config, pkgs, lib, ... }:

let
  cfg = config.my-service;
  # 类型名冲突时引入别名的惯用写法
  types = lib.types;
in
{
  options.my-service = {
    enable = lib.mkOption {
      type = types.bool;
      default = false;
      description = "启用 my-service 守护进程。";
    };

    port = lib.mkOption {
      type = types.port;
      default = 8080;
    };

    dataDir = lib.mkOption {
      type = types.path;
      default = "/var/lib/my-service";
    };

    settings = lib.mkOption {
      type = types.attrsOf types.str;
      default = {};
      description = "任意键值配置,写入 /etc/my-service/settings.conf。";
    };
  };

  config = lib.mkIf cfg.enable {
    # 生成配置文件(内容寻址,纯声明)
    environment.etc."my-service/settings.conf".text =
      lib.generators.toINI {} cfg.settings;

    # 用户与数据目录
    users.users.my-service = {
      isSystemUser = true;
      home = cfg.dataDir;
      createHome = true;
      group = "my-service";
    };
    users.groups.my-service = {};

    # systemd 服务
    systemd.services.my-service = {
      description = "My Service daemon";
      wantedBy = [ "multi-user.target" ];
      after = [ "network.target" ];
      serviceConfig = {
        ExecStart = "${pkgs.my-service}/bin/my-service --config ${config.environment.etc."my-service/settings.conf".source}";
        Restart = "on-failure";
        RestartSec = "2s";
        User = "my-service";
        Group = "my-service";
        WorkingDirectory = cfg.dataDir;
      };
    };

    # 开放防火墙端口(由用户模块再决定是否强制)
    networking.firewall.allowedTCPPorts = lib.mkDefault [ cfg.port ];
  };
}

6.2 模块的导入与封装

# configuration.nix 或 flake 模块列表
{ ... }: {
  imports = [
    ./modules/my-service.nix
    # 也可以从 flake 引用外部模块
    # (import ./modules/other.nix)
  ];

  my-service = {
    enable = true;
    port = 8443;
    settings = {
      logLevel = "debug";
      maxConnections = "1000";
    };
  };
}

6.3 从 flake 导出可复用模块

配合 https://plumephp.com/nix-flakes-best-practices/ 的布局:

# flake.nix
{
  outputs = { self, nixpkgs, ... }:
  let
    lib = nixpkgs.lib;
  in {
    nixosModules.my-service = import ./modules/my-service.nix;
    nixosModules.default = self.nixosModules.my-service;

    nixosConfigurations.server = lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        self.nixosModules.my-service
        ./hosts/server.nix
      ];
    };
  };
}

7. 常见陷阱与调试

7.1 忘记 config = 包裹

mkIf 必须放在 config = lib.mkIf ... 里,不能直接平铺在模块顶层:

# 错误
{ config, lib, ... }: {
  services.xserver.enable = lib.mkIf config.myflag.enable true;
}

# 正确
{ config, lib, ... }: {
  config = lib.mkIf config.myflag.enable {
    services.xserver.enable = true;
  };
}

7.2 调试选项求值

# 导出合并后的完整配置
nixos-rebuild build --show-trace
# 或
nix-instantiate --eval --json '<nixpkgs/nixos>' -A config.my-service --arg configuration ./configuration.nix

# 查看 option 文档(带类型与描述)
nixos-rebuild build --show-trace 2>&1 | grep -A2 'error:'

7.3 优先级被意外覆盖

排查「我明明设了值却被别处覆盖」:

# 用 trace 查看 option 来源
{ config, lib, ... }: {
  config = lib.traceSeq config.services.foo 0;
  # 或直接声明时带上优先级:
  services.foo.port = lib.mkForce 9090;
}

7.4 常见错误速查

错误信息原因解法
attribute 'my-service' missing模块未 import 或 options 未声明imports = [ ./modules/... ]
The option ... has conflicting definition values同优先级标量冲突显式 mkForce / mkDefault
infinite recursion encounteredconfig 循环引用检查是否用 mkIf 切断条件
cannot coerce a set to a string类型不匹配检查 option 的 type 声明

8. 模块系统的完整语义模型

用一个表总结「一个 option 的生命周期」:

阶段机制对应工具
声明定义类型、默认值、描述mkOption + lib.types.*
条件化决定哪些声明参与合并mkIf
合并按优先级与类型规则合并优先级数字 + 类型规则
排序列表/顺序敏感配置mkOrder / mkBefore / mkAfter
注入把合并结果传给模块函数config 参数(fix)
消费模块读取其他模块的值config / options 引用

底层原理:模块系统对全部模块做一次 foldl' 合并,再用 lib.fix 让 config 与 options 相互可见——这正是 https://plumephp.com/nix-language-deep-dive/ 里 fix 定点组合在真实世界的最大应用。


9. 总结

  • options 即契约:模块能配置什么,完全由 options 声明决定
  • 合并即语义:标量高优先级赢、列表拼接、attrset 递归合并,mkIf/mkMerge 控制条件与分支
  • 优先级即治理:mkDefault 给默认、mkForce 给铁律、mkOrder 给顺序
  • 子模块即结构化:复杂配置用 submodule 组织,避免扁平怪物
  • 模块即资产:用 nixosModules 导出,跨机器、跨团队复用

掌握模块系统后,你就能像写函数库一样写系统配置。下一步建议与 https://plumephp.com/nix-home-manager/(用户级模块)协同,或配合 https://plumephp.com/nix-deployment-tools/ 把自定义模块批量部署到生产集群。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「DevOps」更多文章

  1. 备份与容灾自动化:RPO/RTO、Velero、PITR 与恢复演练
  2. 配置漂移与安全基线:IaC漂移检测、CIS合规、供应链安全与密钥轮换
  3. 内部开发者平台(IDP)工程化:Backstage、Golden Path 与自服务能力