Loading...

Day 19:多模組架構 — 當設定檔長到你看不下去的時候

學習將膨脹的 Nix 設定檔拆分為多模組架構,讓 NixOS 與 macOS 共用同一個 Flake 並各自引入專屬模組。

Day 19:多模組架構 — 當設定檔長到你看不下去的時候

🗓 系列:NixOS 30 天學習之旅
📦 階段:第三階段 — Flakes 與 Home Manager(Day 15 – Day 21)
🎯 階段核心目標:現代化 Nix 流程(2024 年後的標準做法)


我們的環境

在繼續之前,先釐清我們的使用情境。這個系列會同時涵蓋兩種環境:

環境角色管理工具特性
遠端 NixOS server純 CLI serverNixOS(nixosConfigurations無 GUI、無桌面環境,跑 service 為主
本地 MacBook日常開發機nix-darwin(darwinConfigurationsGUI + CLI,透過 nix-darwin 管理系統設定

多模組架構的精髓在於:兩種環境可以共用同一個 Flake repository,共享 modules/common.nix 的通用設定,再各自引入專屬模組。這正是今天要學的核心技巧。


前言:當設定檔越來越大…

如果你從 Day 1 一路跟到現在,你的系統設定大概已經膨脹到好幾百行了。Boot loader 設定、網路配置、使用者管理、開發工具、各種 service… 全部塞在同一個檔案裡。

一開始還好,畢竟檔案不長,滾動幾下就能找到你要改的地方。但隨著配置越來越豐富,你會開始遇到這些問題:

  • 想改個 Nginx 設定,得在 300 行的檔案裡找半天
  • NixOS server 和 MacBook 共用同一份設定,但其實有一大半配置彼此不相干
  • 想把配置分享給別人,對方得自己挑出需要的段落

聽起來很熟悉?這正是軟體工程中最經典的問題 — 單一檔案承載過多職責。解法也很經典:拆模組

今天我們要學的,就是如何把龐大的系統設定拆分成多個獨立模組,讓每個檔案各司其職、好讀好改。而且我們會把 NixOS 和 nix-darwin 兩種環境一起考慮進去。


為什麼要模組化?

在動手之前,先想清楚模組化到底帶來什麼好處:

1. 職責分離(Separation of Concerns)

把「Nginx 設定」和「Homebrew 套件管理」放在同一個檔案裡,邏輯上就是混在一起。拆開後,每個模組只負責一個明確的領域,修改時不會誤觸其他設定。

2. 跨機器、跨平台複用

你同時管理一台 NixOS server 和一台 MacBook。兩者都需要相同的基礎設定(locale、時區、常用工具),但 server 需要 Nginx,MacBook 需要 GUI app 和開發工具。模組化之後,共用的部分抽成 common.nix,各自獨有的設定放在不同模組,乾淨俐落。

3. 易於維護與協作

當配置檔放進 Git repository(你應該這樣做!),模組化的結構讓 diff 更清晰、code review 更容易。同事改了 modules/server.nix,你一眼就知道這次變更只跟 server 有關。

4. 漸進式組合

你可以把模組想像成樂高積木。需要 Docker?加上 modules/docker.nix。需要開發環境?加上 modules/dev-tools.nix。不需要的時候拿掉就好,不用在一大份檔案裡到處刪刪改改。


imports 語法與運作原理

NixOS 的模組系統核心就是 imports。其實你從 Day 2 就已經看過它了:

# /etc/nixos/configuration.nix
{ config, pkgs, ... }:

{
  imports = [
    ./hardware-configuration.nix
  ];

  # ...其他配置
}

hardware-configuration.nix 就是你的第一個模組!它在安裝時由 NixOS 自動生成,負責描述硬體相關的設定(磁碟分割、kernel module 等)。

imports 的運作方式

imports 接受一個 list of paths,每個 path 指向一個 .nix 檔案。NixOS 在 evaluate 整份配置時,會把所有被 import 的模組合併(merge)成一份完整的 configuration。

imports = [
  ./modules/common.nix
  ./modules/server.nix
  ./modules/dev-tools.nix
];

這裡有幾個重要的觀念:

觀念說明
路徑是相對的./modules/common.nix 是相對於當前檔案的路徑
合併而非覆蓋如果多個模組都設定了 environment.systemPackages,NixOS 會把它們合併成一個 list,而不是後者覆蓋前者
順序通常不影響結果因為是 merge 而非 override,所以模組的順序大多數情況下不重要
每個模組都是 function被 import 的檔案格式和 configuration.nix 一樣,都是 { config, pkgs, ... }: { ... }

merge 的行為

這點非常重要,用一個例子來說明:

# modules/common.nix
{ pkgs, ... }:
{
  environment.systemPackages = with pkgs; [ vim git ];
}
# modules/server.nix
{ pkgs, ... }:
{
  environment.systemPackages = with pkgs; [ nginx iotop ];
}

NixOS 會自動把兩個模組的 environment.systemPackages 合併起來,最終結果等同於:

environment.systemPackages = with pkgs; [ vim git nginx iotop ];

你不需要手動處理合併邏輯,NixOS 的 module system 會幫你搞定。這就是 Nix 模組系統的精髓 — 宣告意圖,系統負責整合

⚠️ 注意:不是所有 option 都能 merge。List 型別(如 systemPackages)會合併、boolean 型別如果衝突會報錯。當兩個模組對同一個 option 給了矛盾的值(例如一個設 true、一個設 false),NixOS 會在 evaluate 時拋出錯誤,而不是靜默覆蓋。這是一個安全機制。


設計模組結構:跨平台目錄規劃

模組該怎麼拆、目錄該怎麼組織?沒有唯一正解,但以下是一個針對「NixOS server + MacBook」雙環境設計的實戰結構:

flake.nix                        # Flake 入口,定義 nixos + darwin 兩組配置
├── modules/
│   ├── common.nix               # 兩環境共用的系統設定
│   ├── server.nix               # NixOS server 專用(Nginx、防火牆等)
│   └── darwin.nix               # MacBook 專用(nix-darwin 設定)
├── home/
│   ├── common.nix               # 兩環境共用的 Home Manager 設定
│   ├── server.nix               # Server 的 Home Manager(tmux、監控等)
│   └── macbook.nix              # MacBook 的 Home Manager(GUI app、開發工具)
└── hosts/
    ├── homelab/
    │   └── hardware-configuration.nix  # NixOS server 硬體設定
    └── macbook/
        └── default.nix                 # MacBook 專屬覆寫(hostname 等)

設計原則

  1. 按職責拆分:每個模組對應一個功能領域,而非按「改動頻率」或「檔案大小」拆分。
  2. 平台分離modules/server.nix 放 NixOS 專屬設定,modules/darwin.nix 放 nix-darwin 專屬設定,modules/common.nix 放兩邊都適用的設定。
  3. Home Manager 也要分層home/ 目錄的邏輯和 modules/ 對稱,共用的 shell 設定放 home/common.nix,平台特有的放各自的檔案。
  4. 命名要直覺:看到檔名就知道裡面在幹嘛。server.nix 一看就是 server 相關,不需要打開檔案才知道。

這種結構的好處是:共用的邏輯寫在 common.nix,平台特有的設定各自獨立。新增一台機器?只要建一個新的 hosts/<machine-name>/ 目錄,選擇性地 import 需要的模組就好。


實作:拆分 common.nix、server.nix、darwin.nix

讓我們動手把設定拆開。這裡以「NixOS server + MacBook」雙環境為例。

拆分前:所有東西擠在一起

# configuration.nix(拆分前,NixOS server)
{ config, pkgs, ... }:

{
  imports = [ ./hardware-configuration.nix ];

  boot.loader.grub.enable = true;
  boot.loader.grub.device = "/dev/sda";

  networking.hostName = "homelab";
  networking.networkmanager.enable = true;

  time.timeZone = "Asia/Taipei";
  i18n.defaultLocale = "en_US.UTF-8";

  environment.systemPackages = with pkgs; [
    vim git curl wget htop tmux unzip
    nginx
    iotop iftop lsof strace
  ];

  services.openssh = {
    enable = true;
    settings.PermitRootLogin = "no";
  };

  services.nginx.enable = true;

  users.users.james = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
  };

  networking.firewall.allowedTCPPorts = [ 22 80 443 ];

  system.stateVersion = "24.05";
}

看起來不算太長?但這只是一個精簡版。現實中加上 PostgreSQL、Docker、自訂 systemd service、fail2ban… 輕鬆突破 500 行。更別說你還有一台 MacBook 要管理。

拆分後

Step 1:建立目錄結構

mkdir -p modules home hosts/homelab hosts/macbook

Step 2:抽出 modules/common.nix

把兩個環境都會用到的基礎設定放進來。注意:這裡只放 NixOS 和 nix-darwin 都支援的 option

# modules/common.nix — NixOS 與 nix-darwin 共用
{ config, pkgs, ... }:

{
  # 時區與語系
  time.timeZone = "Asia/Taipei";

  # 每台機器都需要的基礎工具
  environment.systemPackages = with pkgs; [
    vim
    git
    curl
    wget
    htop
    tmux
    unzip
  ];

  # Nix 本身的設定
  nix.settings.experimental-features = [ "nix-command" "flakes" ];
}

💡 注意common.nix 裡只放兩邊都支援的 option。像 boot.loaderservices.openssh 這些 NixOS 專屬的設定就不該放在這裡。如果你放了 nix-darwin 不認識的 option,evaluate 時會直接報錯。

Step 3:抽出 modules/server.nix

NixOS server 專用的設定:

# modules/server.nix — NixOS server 專用
{ config, pkgs, ... }:

{
  # SSH(server 必備)
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
    };
  };

  # Nginx
  services.nginx = {
    enable = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;
  };

  # 防火牆
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 80 443 ];
  };

  # Server 監控工具
  environment.systemPackages = with pkgs; [
    iotop
    iftop
    lsof
    strace
  ];

  # 自動更新安全性修補
  system.autoUpgrade = {
    enable = true;
    allowReboot = false;
  };
}

Step 4:抽出 modules/darwin.nix

MacBook 透過 nix-darwin 管理的系統設定:

# modules/darwin.nix — MacBook 專用(nix-darwin)
{ config, pkgs, ... }:

{
  # macOS 系統偏好設定
  system.defaults = {
    dock.autohide = true;
    finder.AppleShowAllExtensions = true;
    NSGlobalDomain.AppleShowAllFiles = true;
  };

  # Homebrew 整合(nix-darwin 可以管理 Homebrew cask)
  homebrew = {
    enable = true;
    casks = [
      "iterm2"
      "raycast"
    ];
    onActivation.cleanup = "zap";
  };

  # macOS 專用開發工具
  environment.systemPackages = with pkgs; [
    darwin.apple_sdk.frameworks.Security
  ];
}

Step 5:抽出 modules/users.nix

使用者管理也值得獨立:

# modules/users.nix
{ config, pkgs, ... }:

{
  users.users.james = {
    isNormalUser = true;
    description = "James Hsueh";
    extraGroups = [ "wheel" ];
    shell = pkgs.zsh;
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3Nza... james@my-laptop"
    ];
  };

  programs.zsh.enable = true;
}

💡 注意users.users 的格式在 NixOS 和 nix-darwin 略有不同。如果需要跨平台共用使用者設定,建議把 user 相關邏輯放到 Home Manager 裡處理。

Step 6:精簡主設定 — NixOS server

# hosts/homelab/configuration.nix
{ config, pkgs, ... }:

{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common.nix
    ../../modules/server.nix
    ../../modules/users.nix
  ];

  # 這台 server 專屬的設定
  networking.hostName = "homelab";
  boot.loader.grub.enable = true;
  boot.loader.grub.device = "/dev/sda";

  system.stateVersion = "24.05";
}

看到了嗎?common.nix 被 NixOS server 和 MacBook 共用,只有平台差異由不同模組處理。修改共用設定時只需要改一個地方,所有機器都會同步更新。


跨平台 Flake:NixOS + nix-darwin 共用一個 flake.nix

到目前為止,我們拆分了 modules/,但還沒解決最核心的問題:如何讓 NixOS server 和 MacBook 共用同一個 Flake repository?

答案是在 flake.nix 中同時定義 nixosConfigurationsdarwinConfigurations

完整的跨平台 flake.nix

# flake.nix
{
  description = "我的跨平台 Nix 設定";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";

    # nix-darwin(管理 macOS)
    darwin = {
      url = "github:LnL7/nix-darwin";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Home Manager(管理使用者環境)
    home-manager = {
      url = "github:nix-community/home-manager/release-24.05";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, darwin, home-manager, ... }: {

    # ── NixOS server ──────────────────────────────
    nixosConfigurations.homelab = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./hosts/homelab/hardware-configuration.nix
        ./modules/common.nix
        ./modules/server.nix
        ./modules/users.nix

        # NixOS 上的 Home Manager
        home-manager.nixosModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.james = import ./home/server.nix;
        }
      ];
    };

    # ── MacBook(nix-darwin)──────────────────────
    darwinConfigurations.macbook = darwin.lib.darwinSystem {
      system = "aarch64-darwin";
      modules = [
        ./modules/common.nix
        ./modules/darwin.nix

        # nix-darwin 上的 Home Manager
        home-manager.darwinModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.james = import ./home/macbook.nix;
        }
      ];
    };
  };
}

關鍵觀念

觀念說明
nixosConfigurationsNixOS 專用的 output,用 nixos-rebuild switch --flake .#homelab 套用
darwinConfigurationsnix-darwin 專用的 output,用 darwin-rebuild switch --flake .#macbook 套用
共用 modules/common.nix兩邊都 import 同一個 common.nix,只要裡面的 option 兩邊都支援就沒問題
Home Manager 入口不同NixOS 用 home-manager.nixosModules.home-manager,nix-darwin 用 home-manager.darwinModules.home-manager
system 不同NixOS server 通常是 x86_64-linux,MacBook(Apple Silicon)是 aarch64-darwin

Home Manager 也要分層

Home Manager 管理的是使用者層級的設定(shell、editor、dotfile 等),和系統模組一樣適用分層概念:

# home/common.nix — 兩環境共用的使用者設定
{ config, pkgs, ... }:

{
  home.stateVersion = "24.05";

  # Shell 設定
  programs.zsh = {
    enable = true;
    shellAliases = {
      ll = "ls -la";
      gs = "git status";
    };
  };

  programs.git = {
    enable = true;
    userName = "James Hsueh";
    userEmail = "james@example.com";
  };

  programs.tmux = {
    enable = true;
    keyMode = "vi";
  };
}
# home/server.nix — Server 使用者設定
{ config, pkgs, ... }:

{
  imports = [ ./common.nix ];

  # Server 上需要的額外工具
  home.packages = with pkgs; [
    jq
    yq
    ripgrep
  ];
}
# home/macbook.nix — MacBook 使用者設定
{ config, pkgs, ... }:

{
  imports = [ ./common.nix ];

  # MacBook 開發工具
  home.packages = with pkgs; [
    nodejs
    python3
    ripgrep
    fd
    bat
  ];

  # macOS 專用的 shell 設定
  programs.zsh.initExtra = ''
    eval "$(/opt/homebrew/bin/brew shellenv)"
  '';
}

套用設定的指令

# 在 NixOS server 上
sudo nixos-rebuild switch --flake .#homelab

# 在 MacBook 上
darwin-rebuild switch --flake .#macbook

模組間的參數傳遞

隨著模組越拆越多,你可能會遇到一個問題:模組 A 需要讀取模組 B 定義的值

NixOS 的 module system 提供了一個優雅的解法:config 參數。每個模組的 function 都會接收到 config,它代表的是所有模組 merge 之後的最終配置

範例:根據 hostname 做不同設定

# modules/networking.nix
{ config, pkgs, ... }:

{
  # 讀取其他模組(或主設定檔)定義的 hostname
  networking.firewall.allowedTCPPorts =
    if config.networking.hostName == "homelab"
    then [ 22 80 443 ]
    else [ 22 ];
}

自訂 option(進階用法)

如果你想讓模組更有彈性,可以用 mkOption 定義自己的 option:

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

let
  cfg = config.my.settings;
in
{
  options.my.settings = {
    enableDevTools = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = "是否安裝開發工具";
    };

    extraPackages = lib.mkOption {
      type = lib.types.listOf lib.types.package;
      default = [];
      description = "額外要安裝的套件";
    };
  };

  config = {
    environment.systemPackages = with pkgs;
      (if cfg.enableDevTools then [ gcc gnumake python3 ] else [])
      ++ cfg.extraPackages;
  };
}

然後在主設定檔或其他模組中使用:

# configuration.nix
{
  imports = [ ./modules/my-module.nix ];

  my.settings = {
    enableDevTools = true;
    extraPackages = with pkgs; [ nodejs ];
  };
}

這就是 NixOS module system 最強大的地方:你可以把模組設計成可配置的元件,透過 option 暴露介面,使用者只需要設定自己關心的參數就好。整個 NixOS 的 services.*programs.* 底層都是用這套機制實作的。


條件式載入模組

有時候你不想每次都手動調整 imports,而是希望根據某些條件自動決定要不要載入特定模組。以下是幾種常見做法:

方法一:用 mkIf 條件式啟用

模組本身永遠被 import,但內容透過 mkIf 決定是否生效:

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

{
  options.my.enableDocker = lib.mkOption {
    type = lib.types.bool;
    default = false;
    description = "是否啟用 Docker";
  };

  config = lib.mkIf config.my.enableDocker {
    virtualisation.docker.enable = true;
    users.users.james.extraGroups = [ "docker" ];
    environment.systemPackages = with pkgs; [ docker-compose ];
  };
}
# configuration.nix
{
  imports = [ ./modules/docker.nix ];
  my.enableDocker = true;  # 設成 false 就等於沒載入
}

這是 NixOS 社群最推薦的做法。模組始終被 import,但透過 enable flag 控制行為,語義清晰且不會有漏引入的問題。

方法二:在 imports 中使用條件判斷

如果你真的想在 import 層級就做條件判斷,也可以用 lib.optional

# configuration.nix
{ config, lib, pkgs, ... }:

let
  isServer = true;  # 或從其他地方取得
in
{
  imports = [
    ./hardware-configuration.nix
    ./modules/common.nix
    ./modules/users.nix
  ] ++ lib.optional isServer ./modules/server.nix;
}

💡 lib.optional condition value 的作用是:當 conditiontrue 時回傳 [ value ],否則回傳 [](空 list)。用 ++ 串接後就能做到條件式 import。

方法三:搭配 Flakes 在不同平台使用不同模組

如果你已經在用 Flakes(你應該在 Day 15 學過了),最乾淨的做法是在 flake.nix 中為不同機器定義不同的 module 組合。我們在前面的「跨平台 Flake」段落已經示範過了:

  • nixosConfigurations.homelab → import common.nix + server.nix
  • darwinConfigurations.macbook → import common.nix + darwin.nix

每台機器在 flake.nix 層級就決定好自己要用哪些模組,不需要在模組內部做平台判斷。

# 在 NixOS server 上
sudo nixos-rebuild switch --flake .#homelab

# 在 MacBook 上
darwin-rebuild switch --flake .#macbook

這是 2024 年後最主流的做法:用 Flakes 管理跨平台的配置,每台機器各自組合需要的模組


常見陷阱與除錯技巧

模組化之後,偶爾會遇到一些小問題,這裡列出常見的陷阱:

1. Option 衝突

兩個模組對同一個 boolean option 給了不同的值:

error: The option `services.openssh.enable` has conflicting definitions

解法:確保同一個 option 只在一個模組中設定,或使用 lib.mkDefaultlib.mkForce 來控制優先順序:

# 設定一個「預設值」,可以被其他模組覆蓋
services.openssh.enable = lib.mkDefault true;

# 強制覆蓋,優先權最高
services.openssh.enable = lib.mkForce false;

2. 路徑寫錯

error: file '/etc/nixos/modules/destkop.nix' was not found

Nix 在 evaluate 階段就會檢查路徑是否存在,拼錯檔名會直接報錯。仔細核對檔名即可。

3. 忘記傳 lib 參數

如果你在模組中用到 lib.mkIflib.mkOption 等功能,記得在 function 參數中加上 lib

# ✅ 正確
{ config, lib, pkgs, ... }:

# ❌ 忘記了 lib,使用時會報 undefined variable 錯誤
{ config, pkgs, ... }:

小結

今天我們學會了如何把系統設定拆分成多個模組,並且讓 NixOS server 和 MacBook 共用同一個 Flake repository:

觀念說明
imports將多個 .nix 模組檔案引入並自動 merge
職責分離每個模組只負責一個功能領域(server、darwin、使用者等)
跨平台複用common.nix 放兩邊都支援的設定,平台特有的分開放
darwinConfigurationsnix-darwin 在 flake.nix 中的 output,和 nixosConfigurations 並列
Home Manager 分層home/common.nix 共用,home/server.nixhome/macbook.nix 各自獨立
config 參數讀取所有模組 merge 後的最終配置,實現模組間溝通
mkOption定義自訂 option,讓模組變成可配置的元件
mkIf條件式啟用模組內容,搭配 enable flag 使用

模組化不只是「把檔案拆開」這麼簡單,它是一種設計思維。當你開始把配置想成可組合的元件,而不是一份越寫越長的清單時,管理跨平台的多台機器、分享配置、甚至貢獻到社群,都會變得輕鬆許多。


明日預告

Day 20:自訂 NixOS Module — 今天我們拆分了既有的配置,明天要更進一步:從零開始撰寫一個完整的自訂 NixOS module。包含 options 宣告、config 實作、型別系統(types.strtypes.porttypes.submodule 等),讓你的模組不只能用,還能像官方模組一樣提供完善的介面與文件。

我們明天見! 🚀


📚 延伸閱讀