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.bool | true/false |
| 整数 | lib.types.int | 含范围校验 int [0 65535] |
| 端口 | lib.types.port | 0-65535 整数 |
| 字符串 | lib.types.str | 单行字符串 |
| 多行 | lib.types.lines | 保留换行 |
| 路径 | lib.types.path | store 路径 |
| 包 | lib.types.package | derivation 引用 |
| 列表 | 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 encountered | config 循环引用 | 检查是否用 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/ 把自定义模块批量部署到生产集群。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。