一份還沒裝任何東西的 NixOS live ISO,量起來是 458MiB——比整個 Alpine image 大上好幾倍。作者用 nix why-depends 一路追,把 Nix、文件、SSH、144MiB 的 kernel modules、56MiB 的 Perl 逐一拆掉,最後壓到 183MiB;代價是這份系統幾乎什麼正事都做不了。
把 NixOS 映像從 458MB 砍到 183MB
這不是一篇教你怎麼做精簡發行版的指南,而是一份好奇心驅動的拆解日誌:作者從一份標準的 NixOS minimal live ISO 出發,目標只有一個——看看這 458MiB 的重量到底壓在哪幾塊依賴上,能砍多少。整趟過程沒有什麼魔法,就是一輪一輪地問 nix why-depends「這東西為什麼會在 closure 裡」,找到答案就把它關掉,再量一次大小。每關掉一層,數字就往下掉一截,然後逼你去看下一塊還沒拆的重量。
值得記下來的不是「最後到了 183MiB」這個結果,而是中間每一刀切在哪裡、切掉的是什麼。NixOS 的 closure 是一張完整的依賴圖,ISO 的大小就是這張圖物化到磁碟上的投影;要讓它變小,你得先看懂哪個高階選項(nix.enable、documentation.enable)在背後拖進了哪些低階套件(Nix daemon 帶進 Boost、activation script 帶進 Perl)。下面這個 widget 把整趟瘦身做成一排開關——你可以逐一打開每一刀,看 ISO 的 MiB 怎麼往下掉,也看清楚哪一刀切得最深。
勾選每一刀,看 ISO 從 458 往下掉 · 5 個可切換的依賴塊
458MiB 的空殼——重量壓在哪裡
起點本身就是這趟拆解的第一個衝擊。一份什麼正事都還沒做的 NixOS live ISO,量出來是 458MiB——作者在文章裡的反應是「458MiB‽ 它連個鬼都還沒做」。對照組擺在旁邊看更刺眼:一份 Alpine Linux 的 VM ISO 大約 66MiB。同樣是能開機的 Linux,差距接近七倍。
NixOS 之所以胖,跟它的設計哲學直接相關。Nix 的賣點是「整個系統是一張可重現、可還原的 closure」,這意味著系統把建構自己所需的工具——Nix 本體、各種 activation 腳本、完整的核心模組樹——統統打包進 image,因為原則上你隨時可能想在這台機器上重建或修改系統。這份「自帶建構能力」的代價,就是 ISO 裡塞了一堆「開機之後其實用不到、但理論上你可能會用到」的東西。
要把這些東西找出來,靠的不是猜,而是 nix why-depends。這個指令回答一個很具體的問題:套件 A 為什麼會出現在套件 B 的依賴閉包裡,中間經過哪條路徑。closure 是一張可能有上千個節點的有向圖,你光看「ISO 裡有哪些 store path」只能得到一份扁平清單,看不出誰把誰拖了進來;why-depends 的價值就在於它把這張圖沿著某個目標節點往上回溯,印出一條「從系統頂層一路連到這個套件」的最短路徑。換句話說,它把「這塊重量為什麼在這裡」這個問題,從一句抱怨變成一條可以逐段檢查的證據鏈。
作者拿它去問 closure 裡幾個明顯的大塊,得到的答案常常是一條意料之外的鏈。最典型的一條:closure 裡有一份 15MiB 的 Boost C++ library,追下去發現它是被 Nix daemon 連結進來的。作者的結論很乾脆——「所以我們裝了 Nix daemon,它就把 Boost 一起帶進來了」。你根本沒打算用 Boost,但只要 Nix 本身在 image 裡,Boost 就在。這就是整趟拆解最核心的觀念:closure 裡幾乎沒有一塊重量是「直接」被你選進來的,每一塊背後都掛著一個你當初開啟的高階選項,而那個選項在 evaluate 的時候默默把一整串低階套件織了進來。你要砍的從來不是看到的那塊肉,而是把那塊肉拖進來的那個開關。
這也解釋了為什麼瘦身不能靠「看到大的就刪」。如果你直接把 Boost 從 store 裡挖掉,下次 rebuild 它又會被 Nix daemon 重新拉回來,因為依賴關係沒變、只是你手動破壞了一次結果。正確的做法是順著 why-depends 給的路徑往上走,找到那條鏈的源頭——通常是 NixOS module 裡一個布林選項——再從源頭把它關掉,讓整條鏈在 evaluate 階段就不成立。這是 Nix 這套宣告式系統獨有的拆法:你不是在刪檔案,你是在改一份描述「系統該長什麼樣」的設定,然後讓建構流程重新算出一張更小的圖。
nix.enable。
逐刀往下切——nix.enable、documentation、kernel modules
第一刀切在 Nix 自己。nix.enable = false 把 Nix daemon 連同它拖進來的 Boost 一起移出 image,這一塊大約 74MiB。會這樣做的前提是你接受「這份系統不再能自己 rebuild」——對一份只想拿來跑一次性實驗的 live ISO 來說,這個取捨成立;對任何要長期使用的系統來說,這等於把 NixOS 最核心的能力拔掉了。
這一刀的份量值得停下來想一下。NixOS 的整個賣點,就是系統本身是一份可以重新 evaluate、再次建構的設定——你改一行 config、跑一次 nixos-rebuild,系統就原子性地切到新狀態。把 Nix daemon 從 image 裡拿掉,等於把這個迴路斷開:這份開起來的系統,再也沒辦法在自己身上重算出下一個版本。它從一台「活的、可演化的 NixOS」退化成一份「凍結的快照」,跟你用 dd 寫出來的任何唯讀映像沒有本質差別。對 live ISO 而言這個落差不痛,因為 live ISO 本來就是用過即丟;但這也是為什麼後面作者會反覆強調,這份配方不能往正式系統上套——你砍掉的第一塊,就是 NixOS 之所以是 NixOS 的那塊。
緊接著的一刀是文件。documentation.enable = false(連同 documentation.man.enable = false)把 man page 與整套手冊移除,再把 register-nix-paths 這個 bootstrap service 也用 lib.mkForce {} 清掉。兩刀切完,ISO 從 458MiB 先掉到 384MiB,再掉到 360MiB。作者對這個進度的評語是「算是個開始,但還沒完」——這時候砍掉的都還是相對表層的東西。
真正大的一塊藏在更深處。作者用 nix why-depends 翻 closure 時發現,光是 kernel modules 就佔了 144MiB——他的原話是「這裡還有 144MiB 純粹是 kernel modules。這大約是我們總大小的四分之一,光這一塊就比整個 Alpine image 還大」。一個能開機的系統不需要載入所有可能的硬體驅動模組,尤其在虛擬機裡跑、硬體組合固定的場景。作者用 system.systemBuilderCommands = lib.mkAfter "rm $out/kernel-modules" 在系統建構的最後一步把整個模組目錄刪掉。
這一刀切得最深,但也最危險。刪掉 kernel modules 等於拔掉 runtime 載入驅動的能力——你沒辦法再 modprobe 任何沒被編進核心本體的東西。作者誠實記下了副作用:這讓系統失去了切換到「稍微舒服一點的顯示解析度」的能力。但對「VM 裡跑、只做一件小事」的目標來說,他判斷這份 ISO「我還是會說它能完整開機」。同一輪裡他還把雙份 GRUB 清掉——NixOS 的 ISO preset 同時打包了 UEFI 與 BIOS 兩個版本的 bootloader,光這兩份就約 62MiB——再用 environment.systemPackages = lib.mkForce config.environment.corePackages 把 system packages 壓到只剩核心套件。這幾刀切完,數字落到 197MiB。
為什麼一個「什麼硬體都還沒插」的系統,預設會帶著 144MiB 的模組樹?因為通用發行版沒辦法預先知道你要把它開在什麼機器上:可能是某張冷門網卡、某顆特殊的儲存控制器、某種檔案系統。為了讓「同一份 ISO 在任意硬體上都能開起來」,預設就得把幾乎所有 in-tree 的驅動模組都打包進去,開機時再依偵測到的硬體挑著載入。這份「對未知硬體的保險」在你已經知道目標是固定的虛擬機時,整塊都是浪費——virtio 那幾個必要的東西通常早就被靜態編進核心或最小集合裡,剩下成百上千個模組純粹是壓艙物。作者敢整塊 rm 掉,賭的就是「我知道這台只在 VM 裡跑」這個前提。一旦這份 ISO 被搬到真實硬體上、需要某個被刪掉的驅動,它就會在最不該失敗的開機早期卡住。
GRUB 那一刀也透露出同樣的邏輯。ISO 同時塞 UEFI 與 BIOS 兩套 bootloader,是為了讓它在新舊韌體的機器上都能被引導;當你鎖定一種開機路徑,另一份就是純多餘。但這幾刀的共同點是——它們省下的每一 MiB,都是拿「這份 image 還能在多少種環境下開機」去換的。瘦身曲線往下走的同時,這份系統的適用範圍也在同步收窄,而 ISO 的大小其實量不出這個代價,只有你把它搬到預期外的環境才會撞上。
最後一刀靠實驗選項——拿掉 Perl 的 56MiB
197MiB 之後剩下的肥肉變得難啃。作者再翻 closure,發現有一塊約 56MiB 的 Perl——而 Perl 在這份系統裡的角色,「結果發現它只用來在系統 activation 期間設定 users 和 /etc」。換句話說,這 56MiB 的腳本語言只為了開機時跑一段設定使用者帳號和產生 /etc 檔案的邏輯而存在。一旦系統開起來,它就再也用不到。
把 Perl 拔掉的辦法不是直接刪,而是把它負責的那兩件事換成不依賴 Perl 的實作。NixOS 近期加進了兩個還帶實驗性質的選項正好對上:system.etc.overlay.enable = true(搭配 mutable = false)改用 overlay filesystem 的方式組出 /etc,不再走 Perl 腳本逐檔產生;services.userborn.enable = true 則用一個專門的程式取代原本用 Perl 寫的 user activation 邏輯。兩個選項一起打開,那 56MiB 的 Perl 依賴就從 closure 裡消失,ISO 落到最終的 183MiB——作者的評語是「183MiB。沒那麼龐大了,但還是不錯」。
這裡值得補一句歷史脈絡:NixOS 用 Perl 寫 activation 邏輯,是個非常老的設計選擇,老到後來幾乎沒人會去質疑它——逐檔產生 /etc、逐個調整 user 與 group,這些都是 NixOS 開機時必跑的雜務,幾年來一直由那段 Perl 腳本扛著。問題在於 Perl runtime 本身不小,而它扛的這兩件事一旦做完就再也不被呼叫。這是依賴管理裡很常見的一種隱形債:一個工具因為「歷史上一直都在」而長駐,重量攤在每一份 image 上,但沒有人把它跟它實際提供的價值放在一起秤過。why-depends 能把它揪出來,正是因為它讓「這 56MiB 換到的是什麼」這個問題變得可問。
這兩個選項之所以關鍵,是因為它們動的不是「裝什麼套件」,而是「activation 階段用什麼工具」。這是依賴瘦身裡最不直覺的一類:你要砍的東西不在你裝的套件清單裡,而藏在系統建構流程本身的實作選擇中。system.etc.overlay 把 /etc 的組成方式從「Perl 逐檔寫入」換成「一層 overlay filesystem 直接掛上去」,services.userborn 則把 user 管理交給一支專用的小程式而非 Perl 腳本;這兩者都不是去刪 Perl,而是把 Perl 唯一的兩個用途各自抽掉,讓它在依賴圖上變成沒有任何上游需要它的孤兒節點,於是整塊自然從 closure 掉出去。代價也清楚——etc.overlay 和 userborn 都還掛著實驗標籤,把它們開在正式環境意味著你接受了一條尚未充分驗證的系統 activation 路徑;而 activation 正是系統開機時最不容出錯的階段,一旦這條新路徑在你的硬體或設定組合上出問題,症狀往往是開機就掛、不好事後補救。這 14MiB 換的,是用一條較新、較少里程數的程式碼路徑去頂替一條跑了很多年的成熟路徑。這兩個選項各自取代 Perl 負責的一半工作,下面這組分頁把它們替換掉的對象分開看。
那塊約 56MiB 的 Perl,在這份系統裡只擔任 activation 期間的兩件雜務:把 NixOS 設定裡宣告的 /etc 檔案逐一產生到磁碟上,以及依設定建立、調整 user 與 group。開機跑完這段,整個 Perl runtime 在系統運轉期間就再也不被呼叫——它是一筆只為了「開機那一瞬間」而長駐在 image 裡的重量。
system.etc.overlay.enable = true(搭配 mutable = false)改用 overlay filesystem 的方式把 /etc 組起來,而不是讓 Perl 腳本一個檔一個檔產生。/etc 變成一層唯讀 overlay,activation 不再需要那段逐檔寫入的 Perl 邏輯,這一半的依賴就斷了。
services.userborn.enable = true 用一個專門的程式接手原本由 Perl 處理的 user activation——建帳號、設定 group、套用密碼設定。兩個選項一起打開,Perl 負責的兩件事都有了非 Perl 的替身,那 56MiB 才能整塊從 closure 移除。兩者目前都還是實驗性選項,這是它換來 14MiB 的代價。
下面這張表把每一刀的選項、砍掉的東西、與大致重量並排起來。
每一刀的 config 選項對應到砍掉的東西 · 5 列
| 選項 / 改動 | 砍掉了什麼 | 放棄的能力 | 約略重量 |
|---|---|---|---|
nix.enable = false |
Nix daemon(連帶 Boost) | 系統不能再自己 rebuild | ~74 MiB |
documentation.enable = false |
man page、整套手冊、register-nix-paths | 沒有本機文件 | ~24 MiB |
force corePackages + 移除雙 GRUB |
UEFI/BIOS 雙份 bootloader、多餘 system packages | 只剩單一開機路徑與核心套件 | ~62 MiB |
system.systemBuilderCommands |
rm $out/kernel-modules |
不能 runtime 載入驅動(modprobe) | ~144 MiB |
etc.overlay + userborn |
activation 用的 Perl | 走實驗性 /etc 與 user 管理路徑 | ~56 MiB |
把整張表並排看,真正的故事不是「砍了多少」,而是「重量的分佈完全不直覺」:一份還沒做任何事的系統,最大的單一塊是 144MiB 的 kernel modules,比整個能用的 Alpine image 還大。
183MiB 換來什麼——一份幾乎不能用的系統
走到 183MiB,比起點少了約 60%。但作者在文章最後沒有把這當成一個值得推薦的配方,反而給了一個誠實到近乎潑冷水的判斷。問到「這份系統能拿來做正事、或當成你真的要用的桌面嗎」,他的回答是「相當確定不行,那些東西擺在那裡都是有原因的」。被砍掉的每一塊——Nix、文件、kernel modules、Perl——都對應某個真實場景下你會需要的能力。
那這份 ISO 到底有什麼用?作者給的定位很窄:「有時候你就是需要一個很小、能開機去跑個實驗、只做一件小事的東西」。在這個前提下,183MiB 的精簡 image 開機快、佔空間少、attack surface 小。但他同時提醒,正因為砍得這麼狠,「很可能你真正需要的某個東西,已經被一起砍掉了」——你得很清楚自己這次只做哪一件事,才敢用這份系統。
把這份判斷攤開來看,會發現它對 production-readiness 給的是一個相當乾脆的「不」。不是「再補幾個選項就能上線」那種有條件的保留,而是結構性的:被砍掉的五塊裡,nix.enable 拔掉自我演化的能力、kernel modules 拔掉對未知硬體的適應力、文件拔掉離線排錯的依靠、Perl 那刀換上的還是兩個實驗選項。這些能力平常你感覺不到,正是因為它們是在「出事的時候」才派上用場的那種保險。一份正式系統的價值,很大一部分就藏在這些你希望永遠用不到、但需要時必須在的東西裡;而這趟拆解恰恰是把這些保險一層層撕掉的過程。所以作者說「那些東西擺在那裡都是有原因的」,講的不是客套,而是這份 183MiB image 與一份能長期信賴的系統之間,差的就是這些被當成肥肉砍掉的安全邊際。
換個角度,這也讓 SSH 那塊在文章裡留下的小插曲格外有意思:作者想把 SSH 也拔掉,卻發現它「比我想像中更難擺脫」,因為它被綁進核心套件、沒有一個乾淨的關閉選項。這恰好反證了前面的方法論——能不能砍掉一塊依賴,最終取決於上游有沒有把那個高階開關設計出來。nix.enable、documentation.enable 之所以好砍,是因為 NixOS 已經替它們準備了乾淨的布林選項;而沒有對應開關的東西,就算你看得到它的重量、也用 why-depends 追得出它的來路,照樣動不了。瘦身的天花板,一半是你願意放棄多少能力,另一半是上游願意給你多少可關的開關。
這趟拆解最後留下的不是一份可以照抄的 NixOS 設定,而是一張「重量地圖」。它把一個平常被當成黑箱的數字——「為什麼 NixOS 的 ISO 這麼大」——攤開成幾個具體的依賴塊,並且讓你看清楚每一塊背後對應的是哪個高階選項、哪個能力。作者自己對為什麼一路砍到這裡的解釋也很坦白:「砍到某個點之後,我純粹是因為好奇就繼續往下做了」。
The lesson:要看懂一個 closure 的重量壓在哪,不能盯著套件清單,得用 nix why-depends 往上追到「是哪個高階選項把這塊低階依賴拖進來的」——而能砍多少,最終受限於你願意放棄多少這份系統本來該有的能力。