Day 19:多模組架構 — 當設定檔長到你看不下去的時候
學習將膨脹的 Nix 設定檔拆分為多模組架構,讓 NixOS 與 macOS 共用同一個 Flake 並各自引入專屬模組。
Day 19:多模組架構 — 當設定檔長到你看不下去的時候
🗓 系列:NixOS 30 天學習之旅
📦 階段:第三階段 — Flakes 與 Home Manager(Day 15 – Day 21)
🎯 階段核心目標:現代化 Nix 流程(2024 年後的標準做法)
我們的環境
在繼續之前,先釐清我們的使用情境。這個系列會同時涵蓋兩種環境:
| 環境 | 角色 | 管理工具 | 特性 |
|---|---|---|---|
| 遠端 NixOS server | 純 CLI server | NixOS(nixosConfigurations) | 無 GUI、無桌面環境,跑 service 為主 |
| 本地 MacBook | 日常開發機 | nix-darwin(darwinConfigurations) | GUI + 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 等)
設計原則
- 按職責拆分:每個模組對應一個功能領域,而非按「改動頻率」或「檔案大小」拆分。
- 平台分離:
modules/server.nix放 NixOS 專屬設定,modules/darwin.nix放 nix-darwin 專屬設定,modules/common.nix放兩邊都適用的設定。 - Home Manager 也要分層:
home/目錄的邏輯和modules/對稱,共用的 shell 設定放home/common.nix,平台特有的放各自的檔案。 - 命名要直覺:看到檔名就知道裡面在幹嘛。
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.loader、services.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 中同時定義 nixosConfigurations 和 darwinConfigurations。
完整的跨平台 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;
}
];
};
};
}
關鍵觀念
| 觀念 | 說明 |
|---|---|
nixosConfigurations | NixOS 專用的 output,用 nixos-rebuild switch --flake .#homelab 套用 |
darwinConfigurations | nix-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的作用是:當condition為true時回傳[ value ],否則回傳[](空 list)。用++串接後就能做到條件式 import。
方法三:搭配 Flakes 在不同平台使用不同模組
如果你已經在用 Flakes(你應該在 Day 15 學過了),最乾淨的做法是在 flake.nix 中為不同機器定義不同的 module 組合。我們在前面的「跨平台 Flake」段落已經示範過了:
nixosConfigurations.homelab→ importcommon.nix+server.nixdarwinConfigurations.macbook→ importcommon.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.mkDefault、lib.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.mkIf、lib.mkOption 等功能,記得在 function 參數中加上 lib:
# ✅ 正確
{ config, lib, pkgs, ... }:
# ❌ 忘記了 lib,使用時會報 undefined variable 錯誤
{ config, pkgs, ... }:
小結
今天我們學會了如何把系統設定拆分成多個模組,並且讓 NixOS server 和 MacBook 共用同一個 Flake repository:
| 觀念 | 說明 |
|---|---|
imports | 將多個 .nix 模組檔案引入並自動 merge |
| 職責分離 | 每個模組只負責一個功能領域(server、darwin、使用者等) |
| 跨平台複用 | common.nix 放兩邊都支援的設定,平台特有的分開放 |
darwinConfigurations | nix-darwin 在 flake.nix 中的 output,和 nixosConfigurations 並列 |
| Home Manager 分層 | home/common.nix 共用,home/server.nix 和 home/macbook.nix 各自獨立 |
config 參數 | 讀取所有模組 merge 後的最終配置,實現模組間溝通 |
mkOption | 定義自訂 option,讓模組變成可配置的元件 |
mkIf | 條件式啟用模組內容,搭配 enable flag 使用 |
模組化不只是「把檔案拆開」這麼簡單,它是一種設計思維。當你開始把配置想成可組合的元件,而不是一份越寫越長的清單時,管理跨平台的多台機器、分享配置、甚至貢獻到社群,都會變得輕鬆許多。
明日預告
Day 20:自訂 NixOS Module — 今天我們拆分了既有的配置,明天要更進一步:從零開始撰寫一個完整的自訂 NixOS module。包含 options 宣告、config 實作、型別系統(types.str、types.port、types.submodule 等),讓你的模組不只能用,還能像官方模組一樣提供完善的介面與文件。
我們明天見! 🚀
📚 延伸閱讀