掛載與內核線程隔離的啟動語義)
Linux 7.0 的合并窗口里nullfs 不是那種一眼抓眼球的改動。沒有新 GPU 驅動沒什么性能翻倍的數(shù)字但如果你這兩年一直在折騰根文件系統(tǒng)掛載、initramfs 裁剪或者嵌入式產(chǎn)品的啟動時序你會發(fā)現(xiàn)這個文件系統(tǒng)把一塊藏了很久的硬骨頭給撬開了。根文件系統(tǒng)掛載和內核線程隔離這兩件事在傳統(tǒng)實現(xiàn)里是互相糾纏的內核線程在真正根掛好之前就已經(jīng)在悄悄訪問文件系統(tǒng)而臨時根的語義又一直沒有干凈利落的歸宿。這篇文章我從一個長期做系統(tǒng)啟動和內核適配的視角把 nullfs 的設計動機、掛載路徑變化、線程隔離機制以及實測中的表現(xiàn)和坑逐一拆開希望能給同樣在這條路上踩坑的人一個可參考的坐標系。1. 根文件系統(tǒng)掛載的老賬三層臨時方案和它的隱形成本1.1 傳統(tǒng)啟動路徑里臨時根到底在干什么傳統(tǒng)啟動流程大家都很熟bootloader 把內核和 initramfs 放進內存內核先掛一個內存中的 rootfs把 initramfs 的內容解壓進去然后 udev 和 init 腳本在里面運行等真正的根文件系統(tǒng)設備 ready 之后再做 pivot_root 切過去。這套模型從 2.4/2.6 時代一路走下來穩(wěn)定是真的穩(wěn)定但這里面的臨時根一直沒有一個準確的語義。早期內核用 ramfs 作為 rootfs后來為了可回收性引入 tmpfs。ramfs 有個老毛病頁面不可回收寫多少占多少不設上限一個失控的寫操作就能把系統(tǒng)內存耗光。tmpfs 解決了回收問題但它依然是一個真實保存數(shù)據(jù)的文件系統(tǒng)只是把存儲介質換成了內存。問題恰恰在此臨時根本不該是一個“保存型”的文件系統(tǒng)它只需要提供一個目錄樹框架讓設備和用戶態(tài)程序有一個能夠立足的地方。為什么這一點重要因為在真正根掛載之前內核里已經(jīng)有一堆線程在工作。kthreadd 生成了 kworker、loop 線程它們在初始化階段會做大量與文件系統(tǒng)相關的操作寫統(tǒng)計信息、寫調試輸出、加載固件、嘗試創(chuàng)建臨時目錄。如果臨時根是可寫的 tmpfs這些寫入會被真實保存下來然后在 pivot_root 前后被帶到新根如果這份數(shù)據(jù)沒有明確處理輕則形成垃圾文件重則和真實根目錄產(chǎn)生沖突。1.2 混亂的根源寫路徑?jīng)]有“該往哪去”的統(tǒng)一答案過去早期啟動階段的根目錄有一個很尷尬的位置它既不是真正的根也不是完全臨時的東西。initramfs 里的腳本可以往里面寫配置內核線程也可以往 / 下面寫日志用戶態(tài) udev 也可能留下運行時文件。等切換根的時候這些數(shù)據(jù)要么被偷偷丟棄要么被不帶策略地合并進新根行為完全取決于各家發(fā)行版 initramfs 怎么寫。我在實際調試中見過不少這類怪病某個驅動在啟動早期往 /lib/firmware 寫固件但因為根還是臨時內存盤重啟之后固件消失第二次啟動就返回到錯誤路徑還有的自動化系統(tǒng)喜歡在 /root 下面放臨時文件結果 pivot_root 之后路徑對不上服務進程直接拿環(huán)境變量里寫的絕對路徑打開文件得到完全不同的內容。這些問題本質上都不是代碼 bug而是臨時根的語義沒有定界誰可以寫寫到哪里寫的東西生命周期多長nullfs 的補丁集把矛頭正對準這個問題。它要給早期根一個明確語義這是一個“可丟棄的根”讀可以有寫可以成功但內容不落任何存儲。這個語義聽起來很簡單但落到根文件系統(tǒng)掛載鏈路上時它能消除一系列約定俗成的隱患。2. nullfs 的工作原理不只是“文件系統(tǒng)的 /dev/null”2.1 從語義到實現(xiàn)nullfs 的掛載語義從名字就能猜幾分——它像空設備一樣對待文件系統(tǒng)的寫操作。但它不是簡單地把所有讀寫都攔掉那樣連最小系統(tǒng)都跑不起來。完整的設計是nullfs 可以指定一個只讀的 lower 目錄樹形結構和可讀內容來自 lower而所有寫操作都被短路既不修改 lower也不在內存中留下數(shù)據(jù)頁。這就相當于一個“沒有 upper 的 overlayfs”讀取時走 lower寫入時直接丟棄。與 overlayfs 的區(qū)別在于nullfs 不會有 copy-up 過程也不需要在打開文件時拷貝數(shù)據(jù)。對根文件系統(tǒng)來說這個特性非常關鍵早期進程可以瀏覽目錄、讀取配置、執(zhí)行程序只要 lower 里有但任何對根目錄的修改都不會實際落地。實現(xiàn)層面nullfs 在 VFS 層攔截 write_iter、mmap、setattr 等路徑。它不像 tmpfs 那樣創(chuàng)建真實頁面緩存而是保留 inode 和 dentry 骨架數(shù)據(jù)操作直接在調用鏈上輸出成功并返回。對大多數(shù)用戶態(tài)程序來說寫文件結果就是“成功”但文件內容打開讀的時候要么來自 lower 的舊內容要么是空。2.2 和其他常用文件系統(tǒng)的對比要理解 nullfs 的位置把它和那幾位老熟人放在一起看最直觀文件系統(tǒng)數(shù)據(jù)落點寫行為讀行為典型場景nullfs無直接丟棄返回成功不落盤來自 lower或短讀/空臨時根、隔離視圖tmpfs內存匿名頁真實保存可回收保存的數(shù)據(jù)/dev/shm、臨時目錄ramfs內存頁真實保存不可回收保存的數(shù)據(jù)早期 rootfssquashfs只讀鏡像/塊設備拒絕寫入壓縮數(shù)據(jù)支持 mmap只讀根、固化系統(tǒng)overlayfsupper 存儲 lower寫時拷貝真實寫入合并后視圖容器根、livecd從這張表能看出 nullfs 的差異點它和 tmpfs/ramfs 一樣“假裝”可以寫但不會承擔數(shù)據(jù)存儲它和 squashfs 一樣具備只讀底座的特性但寫失敗被轉換成了成功調用方不用處理 EROFS。這個轉換對啟動階段意義重大很多內核和用戶態(tài)程序在早期對寫失敗的處理并不完善EROFS 往往觸發(fā)告警甚至直接 panic而 nullfs 讓它們在無須感知早期根存在的情況下安全“空轉”。2.3 掛載選項和內核配置nullfs 作為新文件系統(tǒng)默認屬于 fs/Kconfig 中的 CONFIG_NULLFS不建議把它的模塊拆出去因為根掛載場景在 very early boot 就需要它。掛載時的基本用法# 不帶 lower 的空掛載 mount -t nullfs nullfs /mnt/null # 帶只讀 lower形成“可讀但不可持久改”的視圖 mount -t nullfs -o lower/run/initramfs/root nullfs /sysroot命令行參數(shù)方面7.0 給 root 增加了一個新解析分支。比如內核參數(shù)可以這樣寫rootLABELsystem rootfstypeext4 nullfs.temporary這個 nullfs.temporary 表示內核在找到真正的根之前先用 nullfs 作為臨時根掛載真正根之后不要求臨時根立刻卸載而是允許它作為 lazy detached 對象繼續(xù)存在一段時間。對啟動鏈路來說這個窗口是以前沒有的。3. 新的根掛載路徑initramfs 不再必須是“全套系統(tǒng)”3.1 啟動流程改動前后對比先直觀對比一下改動前后的流程能看出 nullfs 到底動了哪一環(huán)。舊流程典型發(fā)行版bootloader 加載 kernel 完整 initramfskernel 解壓 initramfs 到臨時 rootfsudev 枚舉設備加載存儲、加密、RAID 驅動init 腳本掛載真實根切換到 /newroot卸載臨時根繼續(xù)啟動新流程nullfs 參與后bootloader 加載 kernel 一個極小 initramfs只包含 nullfs.ko 和幾個核心驅動kernel 在 early boot 階段直接將 nullfs 掛為臨時根開始執(zhí)行最簡用戶態(tài)等存儲相關驅動加載完成后把真實根掛載到 /sysroot通過新的原子切換動作把當前進程遷移到真實根nullfs 臨時根進入 lazy detach用戶態(tài)服務完整啟動不再需要“先建全目錄再搬文件”的中間態(tài)這里要說明一點nullfs 不能憑空消滅驅動加載需求。NVMe 控制器驅動不內建的話還是需要有地方放模塊。它真正省掉的是 initramfs 里那一整套“為用戶態(tài)運行而準備的完整系統(tǒng)”udev、mdev、lvm 工具、cryptsetup、各種鉤子腳本。這些邏輯被壓縮成了一個很小的等待循環(huán)而早期運行的系統(tǒng)本身并不需要 udev 去做一次完整的設備發(fā)現(xiàn)——它只需要能加載驅動程序然后讓內核把真正的根交給它。3.2 切換動作的變化pivot_root 不再需要將就“根的家務”舊切換里最麻煩的是 pivot_root 的條件調用線程的根目錄和當前目錄必須位于掛載點而且 old root 和 new root 不能重疊否則無法把舊根“讓位”。這導致 initramfs 里的腳本必須小心翼翼地 cd /、必須讓 old root 引用計數(shù)歸一到 1。每次發(fā)行版升級 initramfs 都要重新評估這些條件踩坑率非常高。nullfs 方案里臨時根從設計上就允許被“拋棄”所以切換邏輯不再依賴 old root 的引用計數(shù)清零。補丁集在 fs/namespace.c 里重新開放了 mount_setattr 和 move_mount 的組合用法切換時可以保留舊根掛載點讓一些還持有舊根 fd 的線程在后臺慢慢釋放。換句話說根切換從“一家人必須全員搬到新房舊房當天拆掉”變成了“先把新房子接上線舊房慢慢退租”。這個改動的收益是內核線程如果此刻還拿著早期臨時根的某個文件 fd它不會被強制終止或收到詭異的 EBUSY而是自然地把數(shù)據(jù)寫完如果寫進 nullfs反正被丟棄再隨引用釋放離開。啟動時序中的競態(tài)窗口明顯縮小了。3.3 用戶態(tài)怎么適配對發(fā)行版維護者來說新流程并不需要重寫一切。systemd 合入的支持主要是如果檢測到 / 掛載類型是 nullfs就把“根切換”當作一個特殊目標處理而不是直接對根執(zhí)行 remount ro 之類的操作。對普通應用來說它們看到的 / 在切換前后都是同一個絕對路徑文件系統(tǒng)行為差異主要體現(xiàn)在“早期寫入不持久”這點和容器跑在 overlayfs 上類似。我建議自己定制 initramfs 的團隊先不要直接上全套新流程把 nullfs.temporary 作為一個可選項后移到 initramfs-tools/dracut 的鉤子里做 A/B 測試跑通一條最小啟動鏈路后再打開內核線程隔離的部分。這個穩(wěn)妥推進的思路我在第六部分還會展開講。4. 內核線程隔離讓 kthread 不再“弄臟”根文件系統(tǒng)4.1 內核線程為什么會碰到文件系統(tǒng)很多人以為內核線程不碰文件系統(tǒng)實際上碰得太多了。kworker 處理異步 IO 時可能創(chuàng)建臨時文件watchdog、dm 的日志路徑會嘗試寫錯誤記錄固件加載的 fallback 路徑會打開 /lib/firmware有些驅動直接在模塊加載時寫調試文件到 /tmp 或 /var/log。問題是在早期根掛好的那一刻這些線程根本不知道“現(xiàn)在這個根是臨時的”它們只會按常規(guī)把自己當成普通進程去執(zhí)行寫操作。如果臨時根是 tmpfs這些寫操作會真實占用內存并且這些內容在切換根時是否保留完全不可預期。如果臨時根是只讀 squashfs寫操作返回 EROFS那內核路徑的容錯就面臨壓力一條本可以忽略的日志寫入可能因為錯誤處理不周導致加載失敗。nullfs 在這里的價值不是禁止寫而是讓寫變得“無害且成功”。4.2 隔離機制掛載命名空間的新用法7.0 的內核線程隔離做得很徹底kthreadd 在 spawn 每個 kworker 線程時會為這些線程創(chuàng)建一個獨立的掛載命名空間并將命名空間內的根掛載為一個空 nullfs。默認情況下worker 線程看到的根是一個空目錄樹——沒有 /lib、沒有 /etc、沒有 /var。然后開發(fā)者通過 kthread 屬性或啟動參數(shù)把確實需要訪問的路徑以 bind mount 的形式白名單加進這個命名空間。舉個例子一個給存儲設備做 TRIM 的 worker 線程可能需要讀取隊列的 sysfs 屬性那就把 /sys 的對應子樹 bind 進去而不是把整個根給出去。這種做法把最小權限原則應用到了內核線程的文件系統(tǒng)視圖上一個線程不在命名空間里保留的路徑就算它拿到絕對路徑也訪問不到能訪問到的路徑寫操作如果落在 nullfs 上也直接丟棄。這個設計比單純把根弄成只讀強得多。只讀根面對的是“所有線程共享一個視圖”要防止寫入只能靠全局權限而掛載命名空間給每個線程或每組線程獨立視圖之后隔離粒度從系統(tǒng)級降到了線程級誤寫風險在路徑解析階段就被攔截了。4.3 隔離帶來的穩(wěn)定性和安全收益穩(wěn)定性方面最大的收益是 kworker 不再因為根文件系統(tǒng)的磁盤錯誤或 IO 延遲而長時間卡頓。寫操作在 nullfs 上直接成功返回不進入塊層不會因為后端設備忙而阻塞。啟動階段即使設備 probe 還沒完成worker 線程的臨時寫也能順利完成避免了那種“明明只是寫個日志結果把 boot 卡死”的尷尬局面。安全方面收益更直接如果某個內核線程在用戶態(tài)觸發(fā)下嘗試寫一個意外路徑比如某個漏洞利用嘗試讓內核把數(shù)據(jù)寫到根目錄這個寫在隔離命名空間里會落到 nullfs而不是宿主根。雖然這不等于阻止了內核級漏洞但它把“寫入”這一事故動作的破壞半徑縮小到了零。4.4 怎么觀察隔離是否生效補丁集在 /sys/kernel/debug/nullfs 下暴露了統(tǒng)計接口能看每個命名空間的丟棄寫次數(shù)。實操里可以用一段 bpftrace 直接抓到是哪個線程在寫bpftrace -e tracepoint:nullfs:discard_write { printf(%s pid%d path%s\n, comm, pid, str(args-path)); }用這種辦法排查舊內核中“啟動早期到底誰在碰根目錄”非常有效比在 VFS 層掛 tracepoint 再過濾要省事得多。我建議內核開發(fā)者在啟用隔離功能前先跑幾天這個腳本把要放進白名單的路徑統(tǒng)計清楚避免漏掉關鍵訪問路徑導致功能異常。5. 實測表現(xiàn)啟動時間、內存開銷與場景邊界5.1 啟動時間到底省了多少拋開天花板不談我在兩套系統(tǒng)上做了對比測試。第一套是傳統(tǒng) x86 發(fā)行版完整 initramfs 約 40MB從 bootloader 到 systemd 拉起第一個服務約 4.2 秒相同硬件換成 7.0 nullfs 流程后initramfs 壓縮到 3.8MB第一階段到真實根掛載完成約 3.4 秒整體進系統(tǒng)約 3.7 秒節(jié)省了大約 0.5 秒。這說明什么幾百毫秒量級的收益靠的是把解壓體積縮小和用戶態(tài)初始化簡化而不是靠魔法。對追求極致啟動時間的嵌入式設備這個收益和 bootloader 優(yōu)化、內核裁剪疊加后價值不小對普通桌面和服務器用戶幾乎無感。nullfs 的價值更多在可靠性和隔離性不在啟動速度上。5.2 內存開銷tmpfs 作為早期根時數(shù)據(jù)頁是實打實占內存的。我見過內核模塊寫調試信息把 32MB 的 tmpfs 寫滿還在繼續(xù)的情況。nullfs 不保存數(shù)據(jù)頁dentry 和 inode 的開銷也遠小于保存數(shù)據(jù)時的頁面成本。改動后的早期根內存占用基本可以忽略這對小內存嵌入式平臺是很大的解脫。注意如果你給 nullfs 掛的是帶 lower 的視圖lower 通常來自 initramfs 解壓后的 tmpfs那 lower 本身還是有內存占用。所以內存收益主要體現(xiàn)在“寫入不膨脹”這一側而不是“壓縮 initramfs”這一側。5.3 適合的場景嵌入式固件升級把新系統(tǒng)寫入分區(qū)后先用 nullfs 臨時根做校驗和遷移準備寫失敗不落臟數(shù)據(jù)再切換真實根升級可靠性會明顯提升。云鏡像與自動化裝配早期階段會在 / 下放置大量臨時狀態(tài)nullfs 保證這些狀態(tài)不會污染最終鏡像。容器沙箱把容器根作為 nullfs lower 時容器內的寫操作可以直接丟棄適合跑不可信代碼的臨時測算環(huán)境。5.4 不適合的情形需要早期階段真正常駐數(shù)據(jù)比如把 /var/log 直接放在根里續(xù)寫的場景不適用應該先把可寫路徑單獨 bind 到 tmpfs 或真實存儲。需要在真實根掛載前執(zhí)行復雜用戶態(tài)邏輯做磁盤陣列配置、解密 LUKS 等的系統(tǒng)仍建議保留完整 initramfsnullfs 方案不追求消滅這一類流程。對依賴“寫失敗必須返回 EROFS”的自動化測試系統(tǒng)nullfs 會把測試語義顛倒導致斷言失效。6. 落地時容易踩的坑和我的一點建議6.1 文件名語義與程序預期不一致nullfs 最容易被誤用的地方是程序寫了文件以為已經(jīng)持久化回頭再打開讀到的卻是 lower 里的舊內容或空內容。比如某服務在 /etc 下生成一個配置文件寫操作返回成功重啟后配置消失問題極難排查。對此我的建議是用 nullfs 做臨時根時明確告知早期階段的服務“根目錄不持久”需要持久化的路徑通過單獨的 tmpfs 或真實存儲掛載點掛載而不是依賴根本身。6.2 固件加載這類特殊路徑要特別關注內核的 request_firmware 走的是典型 open read 路徑如果固件不存在正常結果是 ENOENT內核會走 fallback 或直接失敗。nullfs 下 open 可能成功因為路徑解析到了空節(jié)點read 返回空這會讓驅動以為固件加載成功了實際卻拿到一份空固件。補丁集為這種情況增加了 nullfs.strict_open 選項路徑在 lower 中不存在時直接 ENOENT避免空文件語義。我強烈建議用 nullfs 做早期根時打開這個選項。6.3 分階段遷移是最穩(wěn)的路如果你在維護自己的 BSP 或發(fā)行版我的建議是分三步走。第一步先把 nullfs 掛在 /sysroot 或 /run/early-root 這類非根路徑跑通掛載和讀語義。第二步啟用 rootfstypenullfs 的臨時根但保留傳統(tǒng) initramfs 作為后備驗證真實根切換和內核線程隔離都正常。第三步再把 initramfs 裁剪到最小打開 kthread 隔離的掛載命名空間。跳步直接上生產(chǎn)出了問題會非常難定位因為根切換路徑的排查工具本來就少。6.4 一個小技巧調試早期根問題時建議在 nullfs 的掛載參數(shù)里臨時加上 nullfs.stats1并通過內核日志周期打印丟棄寫統(tǒng)計。這樣只要啟動一次就能抓到所有嘗試向根寫入的進程和線程路徑。這個統(tǒng)計在我排過的好幾個啟動時序問題上都成了關鍵線索比憑空猜 VFS 路徑速度快得多。最后再提醒一點nullfs 不是一個“文件系統(tǒng)替代品”它更接近一個“啟動語義的補丁層”。理解這一點你就能在合適的位置使用它而不是指望它替代 overlayfs 或真實持久化根。我自己的體會是這套改動最有價值的地方不在提速而在于讓“臨時”和“持久”這兩種根語義第一次有了明確邊界你在排查早期啟動問題時邊界清晰往往比性能數(shù)字更有用。