Share Notes

chundev

View the Project on GitHub latteouka/share-notes

K8s 用 NFS 當 storage 的 quota 真相與 Btrfs snapshot 陷阱

日期:2026-05-04 環境:k3s + NFS CSI Driver + Synology NAS(Btrfs)


TL;DR

PVC 上寫 50Gi 完全不會被 enforce — NFS 不像 block storage 會強制 quota。真正的容量限制來自 NAS 端 shared folder 的 quota。Registry GC 跑成功不代表 NAS 空間真的釋放,因為 Synology Btrfs 預設開 24h snapshot,刪掉的 blob 還會被 snapshot 保留住,造成「明明 GC 跑了還是滿」的錯覺。


背景

K3s cluster 跑 local docker registry,storage 走 NFS CSI driver 接 NAS。多個專案的 image push 到同一台 registry,pull 進 cluster 部署。

某天 make app-deploy 失敗,Docker push 一半收到 blob upload unknown,registry log 直接寫 disk quota exceeded。處理過程中發現幾個會誤導排查方向的點,記下來。


問題 / 錯誤訊息

The push refers to repository [<registry>/<image>]
35519686be6e: Retrying in 3 seconds
cad2ad9ae25d: Retrying in 3 seconds
...
blob upload unknown
make: *** [app-push-clean] Error 1

Registry pod log:

level=error msg="error closing blobwriter:
  filesystem: sync .../hashstates/sha256/0: disk quota exceeded"
level=error msg="response completed with error"
  err.code=unknown err.detail="filesystem: sync .../startedat: disk quota exceeded"

整個 storage 配置邏輯

先把架構畫出來,後面的排查才看得懂。

NAS (Synology, Btrfs)
└── volume1
    └── <shared-folder>          ← admin 在 DSM 設 quota(例如 500GB)
        └── cold-storage/
            ├── kube-system/
            │   └── local-registry-pvc/    ← Registry 寫這裡
            ├── kymo-erp/
            │   └── web-uploads-pvc/
            ├── dfaa/
            │   └── postgres-cluster-tbs-cold-data/
            └── ... 共用同一個 quota

K8s 那邊用一個 nfs-cold StorageClass 對應這個 shared folder:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-cold
provisioner: nfs.csi.k8s.io
parameters:
  server: "${NFS_SERVER}"
  share: "${NFS_COLD_SHARE}"
  # 每個 PVC 自動建子目錄,但共用 share 的 quota
  subDir: "cold-storage/${pvc.metadata.namespace}/${pvc.metadata.name}"
mountOptions:
  - nfsvers=4.1
  - hard

PVC 寫 50Gi

apiVersion: v1
kind: PersistentVolumeClaim
spec:
  storageClassName: nfs-cold
  resources:
    requests:
      storage: 50Gi   # ← 對 NFS 來說只是 metadata,沒人 enforce

重點:


怎麼確認 NAS 實際給多少(從 pod 內查)

不用爬到 NAS 上,從任何掛該 PVC 的 pod 都查得到:

kubectl exec -n kube-system deploy/local-registry \
  -- df -h /var/lib/registry
Filesystem                Size      Used Available Use% Mounted on
<NAS-IP>:/<volume>/<share>/cold-storage/kube-system/local-registry-pvc
                        500.0G    500.0G    128.0K 100% /var/lib/registry

Size 欄就是 NAS 端 shared folder 的 quota。這個值跟 PVC 的 50Gi 一點關係都沒有。

要看實際用了多少(vs quota 算的):

kubectl exec -n kube-system deploy/local-registry \
  -- du -sh /var/lib/registry/docker/registry/v2/blobs

如果 du < df 差很多(例如 du 23GB / df 顯示 500GB 滿),這就是這篇筆記的主角 — snapshot 陷阱


除錯過程:「明明 GC 跑了還是滿」

嘗試 做法 結果 為什麼沒用
❌ 1 手動觸發 registry-gc CronJob log 顯示「Deleting blob: sha256:…」、「Done.」 df 還是 500G 滿
❌ 2 重啟 registry pod 釋放 NFS file handle pod ready,沒有 .nfs* lock file df 還是 500G 滿
✅ 3 確認 du 只 23G、df 顯示 500G → 上 NAS DSM 看 snapshot 發現 shared folder 開了自動 snapshot 24h retention,吃掉空間 刪 snapshot 後立刻釋放

Synology DSM 預設行為:

這就是「最近一直滿」的根因 — 不是寫入量真的暴衝,而是每次 push 累積的舊 layer 都被 snapshot 凍住。


Docker Registry GC 的真實行為

順便補完 GC 的限制 — 這也是排查中容易誤判的點:

registry garbage-collect /etc/docker/registry/config.yml

加上 snapshot 陷阱:「GC log 寫 Deleting」≠「真的釋放空間」,必須對照 df 才算數。


解法 / 永久根治

按優先級:

  1. NAS shared folder 關掉 snapshot Registry storage 本來就不需要 snapshot 保護(GC 是它自己的清理機制;image 本身內容定址不需要版本)。對其他需要 snapshot 的 shared folder 影響也分開。

  2. registry-gc 改 daily(如果還是 weekly) Multi-repo push churn 一週累積容易爆。日跑成本很低(registry 只需要短暫 read-only)。

  3. 進階:寫腳本定期刪舊 manifest 再 GC
    # 偽碼
    for repo in $(curl /v2/_catalog); do
      for tag in $(curl /v2/$repo/tags/list | older_than 30d); do
        digest=$(curl -I /v2/$repo/manifests/$tag | grep Docker-Content-Digest)
        curl -X DELETE /v2/$repo/manifests/$digest
      done
    done
    registry garbage-collect /etc/docker/registry/config.yml
    
  4. 長期:registry storage 改 node local SSD NFS 不是 registry 的好搭檔(small file write 多、metadata-heavy)。本機 SSD 直接躲開所有 NAS 端的副作用(snapshot、quota、network blip)。

怎麼跟 NAS 管理員描述要擴的標的

這個是踩坑時必踩的另一個雷 — 講錯標的,admin 改錯地方。完整描述模板:

NAS:<DSM IP> (Synology)

標的:<volume><shared-folder> 這個 shared folder 的 quota

動作:把 quota 從 N GB 擴到 M GB

確認位置:DSM → Control Panel → Shared Folder → 找 <shared-folder> → Edit → Quota 頁籤

背景:這個 shared folder 是 k3s 叢集 nfs-cold storage class 的根目錄,N 個 PVC 共用(local registry、各專案的 web-uploads、postgres cold tablespace 等)。

NFS 端不需要動(沒有 LVM resize、沒有 PV resize);DSM 改 quota 之後 K8s 立刻看得到新空間,不需要重啟任何 pod。

順便讓 admin 一起做:


學到的事

  1. PVC 的 storage: 50Gi 對 NFS 來說只是 hint。NFS provisioner 不會 enforce,scheduler 也不會檢查實際用量。要 enforce 必須在 storage server 端設 quota。
  2. df 看的是 NAS 回報的 quota,du 看的是實際用量。兩者落差很大時,幾乎一定是 storage server 端的 snapshot / recycle bin / quota 計算方式有事。
  3. Btrfs snapshot 預設保留刪除檔案佔用 quota。任何走 Btrfs 的 NFS share,如果有頻繁刪除/覆寫場景(registry / log rotation / temporary upload),都要考慮關 snapshot 或縮短 retention。
  4. Docker Registry GC 不刪 tagged manifest。「永遠 push :latest」的 workflow 加上單純 GC,無法 reclaim 多少空間 — 因為每個 :latest 還是有效 tag。要清舊版需另外呼叫 Registry API。
  5. 重啟 pod 不能解決 quota 問題.nfs* sillyrename file 是 file handle 沒釋放的徵兆;如果沒這類檔案但 df 還是滿,就確定不是 client 端的事。
  6. NFS CSI 的 subDir pattern 是把單一 share 切多個 PVC 的好方法,但代價是「一個 PVC 寫爆殃及所有 PVC」。重要工作負載(如 registry)若預期會大量寫入,最好獨立 share folder 設獨立 quota。

參考資料