Loading...

Day 26:自定義 NixOS Module — 讓你的配置也能像官方服務一樣被呼叫

學習撰寫自訂 NixOS Module,理解 module system 中 options 宣告與 config 實作的核心機制,打造可重用、可組合的系統模組。

Day 26:自定義 NixOS Module — 讓你的配置也能像官方服務一樣被呼叫

🗓 系列:NixOS 30 天學習之旅
📦 階段:第四階段 — 工程師進階實務(Day 22 – Day 30)
🎯 階段核心目標:佈署、自動化、安全性與貢獻


前言:從使用模組到撰寫模組

在前面的旅程中,我們已經大量使用了 NixOS 的 module。像是 services.nginx.enable = trueservices.openssh.enable = true,一行設定就能啟動一個完整的服務,背後的複雜度全部被封裝起來。

你有沒有想過:這些 module 是怎麼做到的?

今天,我們要翻到舞台的另一面 — 不只是使用別人寫好的 module,而是學會自己撰寫 module。當你掌握了 NixOS module system 的核心機制,你就能把自己的服務配置、應用程式設定、甚至整套佈署流程,打包成可重用、可組合的模組。

這不只是進階技巧,更是邁向「NixOS 架構師」的關鍵一步。


NixOS Module System 概述

NixOS 的 module system 是整個系統的骨幹。你在 configuration.nix 裡寫的每一行設定,背後都有一個對應的 module 在定義它的行為。

Module System 的核心設計哲學

NixOS module system 的設計目標是讓多個獨立的模組可以互相組合,而不需要知道彼此的存在。每個 module 負責:

  1. 宣告 options:定義這個模組提供哪些可配置的選項(「我接受哪些參數」)
  2. 實作 config:根據使用者設定的 option 值,產出最終的系統配置(「拿到參數後我要做什麼」)

這種 options + config 的拆分方式,讓模組之間可以自由組合。Module A 可以讀取 Module B 定義的 option,Module B 也可以根據 Module A 的設定來調整自己的行為。

Module 的合併機制

當你在 configuration.nix 中 import 了多個 module,NixOS 會把所有 module 的 options 合併成一棵完整的 option tree,然後把所有 module 的 config 合併成最終的系統配置。

Module A (options + config)  ─┐
Module B (options + config)  ─┼→ 合併 → 完整的系統配置
Module C (options + config)  ─┘

這個合併過程是 NixOS 的核心魔法。它讓你可以把系統拆成數十個甚至數百個小模組,各自獨立維護,最終組合成一個完整的系統。


Module 的基本結構

一個 NixOS module 本質上就是一個 Nix function,回傳一個包含 optionsconfig 的 attribute set。

最精簡的 module

# my-module.nix
{ config, lib, pkgs, ... }:

{
  options = {
    # 定義這個模組提供的選項
  };

  config = {
    # 根據選項的值,產出系統配置
  };
}

參數說明

參數說明
config整個系統最終合併後的配置(可以讀取其他 module 的設定值)
libNix 的標準函式庫,包含 mkOptionmkIftypes 等工具
pkgsNixpkgs 套件集合
...允許接收其他可能的參數(必須加上,否則 NixOS 會報錯)

完整範例:一個簡單的 greeting module

讓我們從一個最簡單的例子開始:

# modules/greeting.nix
{ config, lib, pkgs, ... }:

let
  cfg = config.services.greeting;
in
{
  options.services.greeting = {
    enable = lib.mkEnableOption "greeting service";

    message = lib.mkOption {
      type = lib.types.str;
      default = "Hello, NixOS!";
      description = "The greeting message to display.";
    };
  };

  config = lib.mkIf cfg.enable {
    environment.etc."greeting.txt".text = cfg.message;
  };
}

使用這個 module 時,你可以在 configuration.nix 中這樣寫:

{
  imports = [ ./modules/greeting.nix ];

  services.greeting = {
    enable = true;
    message = "歡迎來到我的 NixOS 伺服器!";
  };
}

看起來是不是跟使用官方 module 一模一樣?這就是 module system 的威力 — 你的自定義配置,享有和官方服務完全一致的使用體驗。


mkOptiontypes 詳解

mkOption 是定義 module option 的核心函式。它接收一個 attribute set,用來描述這個選項的各種屬性。

mkOption 的完整參數

lib.mkOption {
  type = lib.types.str;          # 型別(必填)
  default = "some value";        # 預設值(選填)
  example = "example value";     # 範例值,用於文件產生
  description = "描述這個選項";    # 說明文字
}
參數必填說明
type指定這個 option 接受的值的型別
default預設值。如果不提供,使用者就必須明確設定這個 option
example範例值,會出現在 nixos-option 或線上文件中
description選項的文字說明,建議一定要寫

常用的 types

NixOS 提供了豐富的型別系統,以下是最常用的幾種:

基本型別

lib.types.str          # 字串
lib.types.int          # 整數
lib.types.bool         # 布林值
lib.types.float        # 浮點數
lib.types.path         # 檔案路徑
lib.types.package      # Nix 套件
lib.types.port         # port 號碼 (0-65535)

複合型別

lib.types.listOf lib.types.str        # 字串 list(如 ["a" "b" "c"])
lib.types.attrsOf lib.types.int       # 以字串為 key、整數為 value 的 attribute set
lib.types.nullOr lib.types.str        # 可以是 null 或字串
lib.types.enum [ "debug" "info" "warn" "error" ]  # 列舉型別
lib.types.either lib.types.str lib.types.int       # 字串或整數

Submodule(巢狀結構)

當你需要定義更複雜的結構化選項時,submodule 非常好用:

lib.types.submodule {
  options = {
    host = lib.mkOption {
      type = lib.types.str;
      description = "Hostname of the server.";
    };
    port = lib.mkOption {
      type = lib.types.port;
      default = 8080;
      description = "Port number.";
    };
  };
}

使用 submodule 搭配 attrsOf,可以定義多組具名的設定:

options.services.myApp.virtualHosts = lib.mkOption {
  type = lib.types.attrsOf (lib.types.submodule {
    options = {
      root = lib.mkOption {
        type = lib.types.path;
        description = "Document root.";
      };
      port = lib.mkOption {
        type = lib.types.port;
        default = 80;
      };
    };
  });
  default = {};
  description = "Virtual host configurations.";
};

使用時就像這樣:

services.myApp.virtualHosts = {
  "blog.example.com" = {
    root = /var/www/blog;
    port = 8081;
  };
  "api.example.com" = {
    root = /var/www/api;
    port = 8082;
  };
};

這就是 NixOS 官方 module(如 services.nginx.virtualHosts)背後的實作方式。


實戰:撰寫一個自訂服務模組

讓我們動手寫一個比較完整的 module — 一個簡單的 systemd service,定期執行健康檢查腳本。

需求

  • 可以啟用 / 停用這個服務
  • 可以設定要檢查的 URL
  • 可以設定檢查間隔
  • 可以設定 log 輸出路徑
  • 服務以非 root 使用者身份執行

實作

# modules/health-check.nix
{ config, lib, pkgs, ... }:

let
  cfg = config.services.healthCheck;

  # 產生健康檢查腳本
  healthCheckScript = pkgs.writeShellScript "health-check" ''
    #!/usr/bin/env bash
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    HTTP_CODE=$(${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" "${cfg.url}")

    if [ "$HTTP_CODE" -eq 200 ]; then
      echo "[$TIMESTAMP] OK - ${cfg.url} returned $HTTP_CODE" >> ${cfg.logFile}
    else
      echo "[$TIMESTAMP] FAIL - ${cfg.url} returned $HTTP_CODE" >> ${cfg.logFile}
    fi
  '';
in
{
  # ============================
  # Options:定義可配置的選項
  # ============================
  options.services.healthCheck = {
    enable = lib.mkEnableOption "periodic health check service";

    url = lib.mkOption {
      type = lib.types.str;
      example = "https://example.com/health";
      description = "The URL to check.";
    };

    interval = lib.mkOption {
      type = lib.types.str;
      default = "5min";
      example = "1min";
      description = ''
        How often to run the health check.
        Uses systemd timer format (e.g., "5min", "1h", "30s").
      '';
    };

    logFile = lib.mkOption {
      type = lib.types.path;
      default = "/var/log/health-check.log";
      description = "Path to the log file.";
    };

    user = lib.mkOption {
      type = lib.types.str;
      default = "health-check";
      description = "User account under which the service runs.";
    };
  };

  # ============================
  # Config:根據 options 產出設定
  # ============================
  config = lib.mkIf cfg.enable {
    # 建立專用使用者
    users.users.${cfg.user} = {
      isSystemUser = true;
      group = cfg.user;
      description = "Health check service user";
    };
    users.groups.${cfg.user} = {};

    # 建立 systemd service
    systemd.services.health-check = {
      description = "Periodic Health Check Service";
      after = [ "network-online.target" ];
      wants = [ "network-online.target" ];

      serviceConfig = {
        Type = "oneshot";
        ExecStart = "${healthCheckScript}";
        User = cfg.user;
      };
    };

    # 建立 systemd timer
    systemd.timers.health-check = {
      description = "Timer for Health Check Service";
      wantedBy = [ "timers.target" ];

      timerConfig = {
        OnBootSec = cfg.interval;
        OnUnitActiveSec = cfg.interval;
        Unit = "health-check.service";
      };
    };
  };
}

使用方式

configuration.nix 中引入並配置:

{ config, pkgs, ... }:

{
  imports = [ ./modules/health-check.nix ];

  services.healthCheck = {
    enable = true;
    url = "https://my-app.example.com/health";
    interval = "2min";
    logFile = "/var/log/my-app-health.log";
  };
}

執行 sudo nixos-rebuild switch 之後,NixOS 會自動建立使用者、產生 systemd service 和 timer,開始定期檢查你指定的 URL。

看看,使用起來是不是就跟官方內建的服務一模一樣? 這就是 NixOS module system 的美妙之處。


mkEnableOptionmkIf

這兩個是撰寫 module 時最常搭配使用的工具,值得單獨拿出來講清楚。

mkEnableOption

mkEnableOptionmkOption 的語法糖,專門用來定義 enable 選項:

# 這兩種寫法完全等價
enable = lib.mkEnableOption "my service";

enable = lib.mkOption {
  type = lib.types.bool;
  default = false;
  description = "Whether to enable my service.";
};

使用 mkEnableOption 的好處是簡潔,而且自動產生標準化的 description。它接受一個字串參數,會自動組合成 "Whether to enable <你給的字串>." 這樣的說明文字。

mkIf:條件化配置

mkIf 讓你可以根據某個條件,決定是否套用一段配置。這是實作 enable 開關的核心:

config = lib.mkIf cfg.enable {
  # 只有當 enable = true 時,這裡面的設定才會生效
  systemd.services.myService = { ... };
  environment.systemPackages = [ pkgs.myPackage ];
};

mkIf 的重要特性:它不是「有條件地執行程式碼」,而是「有條件地將這段配置納入合併」。在 Nix 的世界裡沒有 side effect,一切都是 value。mkIf 產生的是一個帶有條件標記的值,NixOS 在合併所有 module 的配置時,會根據條件決定是否將這段值納入最終結果。

mkMerge:合併多段條件配置

當你需要根據不同條件套用不同的配置時,mkMerge 搭配 mkIf 非常實用:

config = lib.mkMerge [
  (lib.mkIf cfg.enable {
    systemd.services.myService = { ... };
  })

  (lib.mkIf cfg.enableMonitoring {
    services.prometheus.exporters.myExporter = { ... };
  })

  (lib.mkIf (cfg.enable && cfg.ssl) {
    security.acme.certs."my-domain" = { ... };
  })
];

這讓你可以把不同功能面向的配置分開撰寫,邏輯更清晰。


模組測試與除錯

寫好 module 之後,怎麼確認它是正確的?以下介紹幾種實用的測試與除錯方法。

1. 使用 nixos-rebuild build 做語法檢查

在正式套用之前,先用 build 驗證配置是否有語法錯誤:

sudo nixos-rebuild build

這只會 build 出新的 system,不會實際切換。如果你的 module 有語法錯誤或 type mismatch,就會在這一步被抓出來。

2. 使用 nixos-option 查詢 option 定義

# 查看某個 option 的定義與當前值
nixos-option services.healthCheck.url

# 列出某個 namespace 下的所有 options
nixos-option services.healthCheck

這個指令非常適合用來確認你的 option 有沒有被正確註冊、type 是否符合預期。

3. 使用 nix repl 互動式除錯

# 載入系統配置進入 REPL
nix repl '<nixpkgs/nixos>' --arg configuration /etc/nixos/configuration.nix

在 REPL 中,你可以互動式地查看各種 option 值:

nix-repl> config.services.healthCheck.enable
true

nix-repl> config.services.healthCheck.url
"https://my-app.example.com/health"

nix-repl> config.systemd.services.health-check.serviceConfig.ExecStart
"/nix/store/xxx...-health-check"

4. 用 NixOS test framework 撰寫整合測試

NixOS 有一個強大的 VM-based test framework,可以在虛擬機中自動化測試你的 module:

# tests/health-check-test.nix
import <nixpkgs/nixos/tests/make-test-python.nix> ({ pkgs, ... }: {
  name = "health-check";

  nodes.server = { config, pkgs, ... }: {
    imports = [ ../modules/health-check.nix ];

    services.healthCheck = {
      enable = true;
      url = "http://localhost:8080/health";
      interval = "10s";
    };

    # 起一個簡單的 HTTP server 來測試
    systemd.services.dummy-http = {
      wantedBy = [ "multi-user.target" ];
      script = ''
        ${pkgs.python3}/bin/python3 -m http.server 8080
      '';
    };
  };

  testScript = ''
    server.wait_for_unit("timers.target")
    server.wait_for_unit("health-check.timer")

    # 手動觸發一次 health check
    server.succeed("systemctl start health-check.service")

    # 確認 log 檔案存在且有內容
    server.wait_until_succeeds("test -s /var/log/health-check.log")
    server.succeed("grep 'OK' /var/log/health-check.log")
  '';
})

執行測試:

nix-build tests/health-check-test.nix

這會自動啟動一個 QEMU 虛擬機,佈署你的 module,執行測試腳本,然後回報結果。雖然執行速度比較慢,但這是最可靠的 module 驗證方式。

5. 常見錯誤與排查

錯誤訊息可能原因解法
The option ... does not existoption 路徑打錯或 module 沒有被 import檢查 imports 和 option 路徑
A value of type ... is not of type ...型別不匹配確認 mkOptiontype 和你傳入的值是否一致
The option ... is used but not defined使用了未定義的 option確認 module 有正確定義 options 區塊
Infinite recursion encountered在 option 定義中直接引用了 config 的值確保 options 區塊不要依賴 config

💡 小技巧Infinite recursion 是 NixOS module 開發中最常見也最惱人的錯誤。記住一個原則:options 區塊定義結構,config 區塊讀取 config.* 的值。不要在 optionsdefault 裡面去讀 config 中其他 option 的值,否則就會造成循環依賴。


進階技巧:Module 的組織與最佳實踐

隨著你的 module 越寫越多,以下是一些維護性的建議:

1. 檔案組織

/etc/nixos/
├── configuration.nix          # 主配置
├── hardware-configuration.nix # 硬體設定
└── modules/
    ├── health-check.nix       # 健康檢查模組
    ├── backup.nix             # 備份模組
    └── monitoring.nix         # 監控模組

2. 使用 let cfg = config.xxx 慣用寫法

幾乎所有 NixOS 官方 module 都會在最上方定義 cfg

let
  cfg = config.services.myService;
in
{
  # 之後就可以用 cfg.enable、cfg.port 等簡短寫法
}

這不只是為了方便,更是一種約定俗成的風格,讓其他開發者一眼就能看懂。

3. 善用 lib.mdDoc 撰寫 option description

description = lib.mdDoc ''
  The URL endpoint to monitor.

  This should be a full URL including the protocol,
  for example `https://example.com/health`.
'';

使用 Markdown 格式的 description,在產生文件時會有更好的排版效果。

4. 為你的 module 選擇正確的 option 命名空間

  • 系統服務放在 services.<name>
  • 程式設定放在 programs.<name>
  • 跟安全相關的放在 security.<name>
  • 跟網路相關的放在 networking.<name>

遵循既有的命名慣例,讓你的 module 融入整個 NixOS 生態系。


小結

今天我們學會了 NixOS module system 的核心:

概念說明
Module 結構一個 function,回傳包含 optionsconfig 的 attribute set
mkOption定義 option 的函式,可指定 typedefaultdescription
types豐富的型別系統,從基本的 strint 到複合的 listOfsubmodule
mkEnableOption定義 enable 選項的語法糖
mkIf條件化配置,搭配 enable 實現功能開關
mkMerge合併多段條件配置
測試nixos-rebuild build 語法檢查到 VM-based test framework

掌握了 module system,你就能把任何自訂的服務、配置、佈署流程包裝成模組化的形式。不僅自己用起來方便,也能輕鬆分享給團隊或社群。


明日預告

Day 27:貢獻 Nixpkgs — 你已經會寫 module 了,那何不把它貢獻回社群?明天我們會介紹如何向 Nixpkgs 發送 Pull Request,從 fork repository、撰寫 commit message、到通過 CI 審查,帶你走一遍完整的貢獻流程。

我們明天見! 🚀


📚 延伸閱讀