
專案原始碼與完整實作:nfs-model-library
系列專案索引:onprem-ops-30days
在 Day 1 開出的設備清單中,我規劃在 QNAP TS-464 NAS 上開一個共享資料夾,作為環境內 AI 算力 Cluster 所有運算節點共用的 NFS 集中模型庫。理想很豐滿,一份模型權重,大家掛載一起讀,省下好幾百 GB 的磁碟空間與重複下載時間。
但到了 Day 12,這個集中模型庫在實際運作中接連爆了三次雷,每一次都直指架構設計上的根本缺口:
pull。三輪測試裡只有第一輪碰巧有一邊成功,其餘兩輪都在拉取大型 layer 時互相把對方的 -partial 暫存檔刪除,最後留下鎖死且損壞的殘檔,導致兩邊節點雙雙卡死。這三起事件分別代表了地端 AI 基礎設施最常見的三大問題,也就是 併發寫入損壞、版本來源不明 與 節點掛載漂移。
今天決定不靠工程師彼此口頭約定的作法,而是定下一套硬性架構規則,並搭配一支只依賴 Python 標準函式庫的自研管理工具 mlib.py(收錄於 nfs-model-library),將這三個問題解掉。
Day 8 的慘痛教訓證明:多節點併發寫入同一個共享目錄不是運氣好就不會出事,而是必然會引發災難。
Ollama 或 Hugging Face CLI 本身並沒有跨主機的分散式檔案鎖機制。最乾淨、最穩健的架構解法不是在應用層到處補鎖,而是在存取架構上讓併發衝突不可能發生,也就是「單寫多讀(Single-Writer Multi-Reader, SWMR)」。
因此,把所有的模型寫入、驗證與退役權責收攏到單一節點,並在架構上定義其為圖書館員(Librarian),就是我自己很喜歡的村上春樹小說《世界末日與冷酷異境》裡面,司書的角色。硬體對應如下:
+---------------------------------------+
| QNAP TS-464 NAS |
| (QuTS hero 6.0.2 / ZFS) |
| /srv/models 共享資料夾 |
+-------------------+-------------------+
|
+-------------------------+-------------------------+
| (NFS Read-Write) | (NFS Read-Only)
v v
+-----------------------------------+ +-----------------------------------+
| 主控桌機 / GB10 工作站 | | GPU 運算節點 / 容器節點 |
| 【圖書館員 / Librarian 節點】 | | (DGX 節點、ComfyUI、NAS 容器) |
+-----------------------------------+ +-----------------------------------+
| - 唯一具備讀寫權限 | | - 嚴格唯讀掛載 (ro) |
| - 執行 mlib.py add / ingest / pull| | - 僅負責載入模型與推論運算 |
| - 權限凍結、計算校驗碼 | | - 完全杜絕誤刪、誤蓋或殘檔寫入 |
| - 世代淘汰與 gc 清理 | +-----------------------------------+
+-----------------------------------+
| 實體主機 / 節點 | 架構角色 | 掛載權限 | 允許操作 |
|---|---|---|---|
| 主控桌機 / GB10 工作站 | 圖書館員(Librarian) | 讀寫(RW) | 模型下載(add)、登錄(ingest)、Ollama 鏡像拉取(pull)、退役與清理 |
| DGX 運算節點 | 推論讀者 | 唯讀(RO) | vLLM / SGLang 模型載入與推論 |
| Intel 工作站 | 推論讀者 | 唯讀(RO) | ComfyUI 載入 Checkpoints / LoRA |
| NAS 本地 Ollama 容器 | 推論讀者 | Bind Mount :ro |
僅載入權重推論(Day 8 實測唯讀僅跳快取警告,運作正常) |
唯讀不能依賴客戶端自律,必須由 NAS 底層的 NFS Export 規則保證。
TS-464 採用 QuTS hero 6.0.2(底層為 ZFS)。在「控制台 > 權限 > 共用資料夾 > 編輯共用資料夾權限」中選擇「NFS 主機存取」:
192.168.1.0/24)設為讀寫,否則等於將寫入衝突的大門重新打開。即便只有圖書館員主機有寫入權限,仍可能發生兩位工程師同時 SSH 進去執行 mlib.py add 的情況。
傳統的 flock 在 NFS 上高度依賴 rpc.statd 與 lockd,在網路抖動或特定 NAS 實作下極度不可靠。因此,mlib.py 採用 POSIX 標準中最乾淨的原子操作,mkdir 原子鎖:
.lock/ 目錄成功者取得鎖,目錄內記錄 PID、主機名與時間戳,若目錄已存在則代表被佔用。.lock 只保護 Manifest 寫入與退役搬移,下載的防禦則是直接以目標 Commit 目錄的 os.mkdir 進行原子佔位,只要目錄已存在即拒絕下載,避免同版本重複拉取的資源浪費。/srv/models/ # NAS 匯出的 models 共享資料夾根目錄
├── MANIFEST.tsv # 只增不改 (Append-only),全庫唯一的元資料帳本
├── .lock/ # 跨程序的原子 mkdir 鎖目錄
├── .trash/ # 軟刪除回收桶
│ └── <YYYYMMDD>/<entry>/ # 退役項目暫存處,等待 gc 週期性清除
├── llm/ # Hugging Face 格式模型 (Safetensors / Config)
│ └── <org>/<name>/<commit12>/ # 以 Commit Hash 前 12 碼命名,絕對不可變
├── gguf/ # GGUF 格式量化權重檔
│ └── <org>/<name>/<commit12>/
├── comfy/ # 專供 ComfyUI 讀取的目錄結構 (對應 Day 7)
└── ollama/ # Ollama 專屬 blob 目錄 (僅由圖書館員執行 pull)
main?在初版設計中,目錄直接採用 repo 的 revision 名稱(例如 main)。這在實務上立刻帶來嚴重後果,
main 分支更新時,再次執行 add 會發現 main 目錄已存在而被拒絕。因此,mlib.py add 在連線至 Hugging Face 時,會先呼叫 API 將 main 或 tag 動態解析為具體的 40 位 Commit Hash,並截取前 12 碼建立目錄。
main 分支在半夜悄悄變更。
MANIFEST.tsv在模型庫根目錄維護一份 MANIFEST.tsv,所有操作只在檔尾追加(Append-only),包含 10 個標準欄位:
added_at entry source revision bytes files sha256_of_sums license added_by note
grep、awk 或 cut 就能閱讀與復原元資料。source 記為 retired 的紀錄,完整保留該項目從建立到除役的時間軸。+--------------------------------------------------------------------------+
| 第一層校驗:目錄內獨立校驗 |
| 項目目錄/SHA256SUMS ---> 可直接使用系統標準指令:sha256sum -c SHA256SUMS |
+--------------------------------------------------------------------------+
^
| (計算整體 SHA256)
+-----------------------------------+--------------------------------------+
| 第二層校驗:中央防篡改校驗 |
| MANIFEST.tsv 中的 sha256_of_sums 欄位 |
| 作用:杜絕「修改了模型權重,同時順手重新產生 SHA256SUMS」的隱蔽篡改狀況 |
+--------------------------------------------------------------------------+
SHA256SUMS。任何節點無需安裝任何專屬工具,原生 Linux 下跑 sha256sum -c 即可驗證權重是否損壞。MANIFEST.tsv 中,記錄該目錄 SHA256SUMS 檔案本身的雜湊值(sha256_of_sums)。
SHA256SUMS,第一層檢驗會全數通過,但 mlib.py verify 會拿它與 Manifest 比對,立刻揪出 sums-changed 違規。Ollama 的內部結構由其本身的 manifest 與 blob 管理,mlib.py 不會在 ollama/ 目錄內寫入校驗碼或凍結目錄。但實測發現:Ollama 只有在 pull 下載時驗證 Digest,在載入推論時完全不驗證,
🧪 破壞性實驗:
我們故意將smollm2:135m的權重 blob 內容竄改一個位元組。檔名雜湊與內部資料早已對不上,但 Ollama 載入時毫無警訊,依然若無其事地回覆「Hello! How can I help you today」,在推論品質上默默劣化。
為了解決這個隱患,mlib.py verify --ollama 提供深度巡檢,重算每個 blob 的雜湊並直接比對其檔名,同時主動揪出 Day 8 殘留的 -partial 垃圾暫存檔。
Day 9 評估大型 MoE 模型(如動輒 150GB+)進駐後,模型庫年增量將達到 1~2 TB。定期的生命週期維護刻不容緩:
| 治理維度 | 規則設定 | 實作機制 |
|---|---|---|
| 保留版本數 | 同一模型保留最新 2 代 Commit | mlib.py gc --keep-gens 2 自動退役過舊版本 |
| 回收緩衝期 | 移至 .trash/ 暫存 14 天 |
mlib.py gc --keep-days 14,超過天數才執行硬刪除 |
| 例外鎖定標記 | Note 欄位包含 keep 關鍵字 |
豁免於 GC 清理,不計入 2 代名額,mlib.py ls 標示為 [K] |
| 清理範圍隔離 | 僅鎖定 llm/ 與 gguf/ |
絕對不動 ollama/(由 Ollama 自行管理)與 comfy/(有自屬權重流動機制) |
# 建議設定於圖書館員節點(主控桌機 / GB10)的 crontab:每週一清晨發送模擬報告
0 4 * * 1 /usr/local/bin/mlib.py gc --dry-run | mail -s "[MLOps] 每週模型清理建議" ops@internal
維運原則:自動化腳本絕不直接執行 150GB 檔案的
rm -rf。一定是先退役至.trash,或是透過變更單確認後,由工程師手動核可才執行刪除。
為徹底根除 Day 11 的「掛載配置漂移」,所有運算節點統一把掛載寫入 /etc/fstab,廢除不可預測的 autofs。
nas:/models /srv/models nfs4 ro,vers=4.1,hard,noatime,nconnect=4,rsize=1048576,_netdev 0 0
關鍵參數說明:
ro:讀者端從 kernel 層就擋死寫入行為。vers=4.1:QNAP QuTS hero 的 NFS 服務支援 v4、v4.1 與「v4.2 (Beta)」。生產環境嚴禁使用 Beta 版,4.1 具備完整的 Session 機制與效能表現。hard:強烈建議,若設為 soft,在 NAS 短暫抖動或網路塞車時,I/O 會直接回傳錯誤給載入程式,導致推論引擎只載入半套損壞的模型,hard 掛載會維持掛起重試,確保資料完整性。nconnect=4:透過多個 TCP Session 壓榨 10GbE 頻寬(Day 14 將實測吞吐效能)。NFS
nconnect的連線共用陷阱
在 Linux 核心中,針對同一個 NAS IP 位址,只有第一個掛載點的nconnect參數會生效!後續對同 IP 的所有掛載點都會默默共用既有的連線池。
檢驗連線數是否有達到預期的唯一標準是透過 Socket 指令檢查,而非看mount的輸出:
ss -tn 'dst <NAS_IP>:2049' | tail -n +2 | wc -l
mlib.py 的邏輯不僅通過單元測試,更在實體環境下進行了高強度驗證。
| 測試情境 | 實測表現 |
|---|---|
初版 add 同時帶多個 --include |
huggingface_hub 0.36.2 只認最後一個參數,遺漏核心 safetensors。新版改為分次下載,完整抓取 |
竄改權重後執行 verify |
精確指認 mismatch config.json,回傳結束代碼 1 |
| 竄改權重並「重新計算 SHA256SUMS」 | sha256sum -c 被騙過,但 mlib.py verify 比對第二層 Manifest 雜湊立刻報出 sums-changed |
| Ollama 偽造壞 blob 載入 | Ollama 無警訊照常載入,但 mlib.py verify --ollama 精準揪出損壞並列出殘留 -partial 檔案 |
| 重入鎖(Re-entrancy)邊界 | 修正初版 gc 呼叫 retire 時內部重複索鎖導致死鎖 5 分鐘的 Bug |
我們在真實的 NFS TS-464 掛載環境下,由兩台節點以 UID 1000(非 root)同時執行併發壓力測試:
# 兩台主機同時執行 1500 次搶鎖、計數器累加與追加 Manifest
./stress_test.py --iterations 1500
所有工具指令均收錄於 nfs-model-library。
# 取得工具並指定模型庫根目錄
git clone https://github.com/ivanusto/nfs-model-library && cd nfs-model-library
export MLIB_ROOT=/srv/models
# 1. 正規化下載 Hugging Face 模型 (自動解析 Commit、下載、校驗、凍結)
./mlib.py add Qwen/Qwen2.5-Coder-32B-Instruct \
--include '*.safetensors' \
--include '*.json' \
--license Apache-2.0
# 2. 登錄非下載來源的既有分片 (如 Day 10 自建模型)
./mlib.py ingest llm/LibertAIDAI/DeepSeek-V4.1-Flash-REAP-256E/unknown \
--source https://huggingface.co/LibertAIDAI/DeepSeek-V4.1-Flash-REAP-256E \
--revision unknown \
--note "keep: Day 10 對照組模型"
# 3. 代理拉取 Ollama 模型 (寫入中央 ollama 目錄並記錄 Manifest)
./mlib.py pull gpt-oss:20b
# 4. 全庫巡檢 (包含 Ollama blob 完整性與殘檔掃描)
./mlib.py ls
./mlib.py verify --ollama
# 5. 週維護:生命週期淘汰模擬
./mlib.py gc --dry-run
讀者端完全不需要安裝 Python 腳本或任何額外套件:
# 1. 快速自我驗證模型完整性
cd /srv/models/llm/Qwen/Qwen2.5-Coder-32B-Instruct/<commit12>
sha256sum -c SHA256SUMS | grep -v ': OK$'
# 若無任何輸出,代表該目錄下所有權重百份之百健康
# 2. 檢視模型入庫帳本歷史
grep -v '^#' /srv/models/MANIFEST.tsv | cut -f1,2,4,8 | column -t
透過「單寫多讀」、「NFSv4 ACL 權限凍結」、「雙層 Manifest 校驗」與「Commit-based 命名規範」,我們徹底告別了併發踩踏、來源不明與節點漂移的噩夢。現在,整座 cluster 的模型儲存庫真正具備了生產級別的防禦力與可追蹤性。
但集中化架構最常面臨的質疑始終是:「大家擠在同一台 NAS 上讀模型,速度真的夠快嗎?」
Day 14,將進入儲存路徑實測,在同一套硬體環境下,針對同一個大型模型,正面對決 本地 NVMe SSD vs NFS (nconnect=4) vs iSCSI 的冷啟動載入秒數,並透過真實效能資料與 RAID 計算器,評估 NAS 儲存池究竟該選 RAID 60 還是 ZFS RAIDZ2!