iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 16

Day 16:單節點的 GC 門檻與 Nexus cleanup policy

  • 分享至 

  • xImage
  •  

今日目的

明天開始組 Pipeline,PipelineRun 會成批產生 Pod、拉 image、在 Nexus 裡堆 blob。這篇先弄清楚三件事:

  1. 這些東西由誰清、什麼時候清
  2. 單節點跟多節點差在哪
  3. 量這些數字的時候,哪些地方會量錯

先備知識

  • PV 與 storageClass,CRC 的 hostPath provisioner
  • Nexus 的 blobstore

開場對照表

常見的理解 這台的實測
單機 CRC 沒有內建的回收機制 三層機制都存在,也都在跑
排程任務回報 OK 代表有回收到東西 Nexus 的六個任務全綠,回收量是零
image GC 會定期清理 門檻觸發,不到 85% 完全不動
imagefsnodefs 是不同的盤 這台連 containerfs 都是同一顆
PVC 寫 179Gi 就有 179Gi 十顆 PV 名目 1,641 Gi,實體盤 180 G
kubelet.conf 就知道門檻設多少 門檻不在檔案裡
du -shx 加了 -x 就準了 還會跨路徑去重,分解表會少算

實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
CRI-O 1.34.7-2.rhaos4.21
RHCOS 9.6.20260504-0(kernel 5.14.0-570)
Red Hat OpenShift Pipelines Operator 1.23.1
Nexus Repository 3.93.0-06(COMMUNITY)
用戶端:Windows 11 Pro + PowerShell 5.1

CRI-O 版本要列——第 3 節的 imagefs 回報行為跟 runtime 實作綁在一起。


1. 三層回收,各管一層

三層互不知道彼此存在,觸發條件、設定位置、預設值都不一樣。

管什麼 誰在跑 怎麼觸發
image GC 節點上的容器映像 kubelet 用量門檻
pruner PipelineRun / TaskRun Pipelines Operator 的 CronJob 排程 + 保留筆數
cleanup Nexus 的 component / blob Nexus 排程 排程 + cleanup policy

1.1 kubelet 的 image garbage collection

kubelet 每 5 分鐘看一次 imagefs 用量(週期是上游預設,本次沒實測),超過 imageGCHighThresholdPercent(預設 85)就從最久沒用到的開始刪,刪到低於 imageGCLowThresholdPercent(預設 80)。

三件常被誤會的:

  • 不是定期清理。 不到 85% 什麼都不做,image 一直堆著是正常的。
  • 不是所有 image 都可回收。 被執行中容器使用的不刪,存在未滿 imageMinimumGCAge(預設 2 分鐘)的不刪。
  • imageMaximumGCAge 預設關閉。 開了之後超過指定時間沒用到的 image 就會被刪,不看用量。這是唯一能讓 image GC 脫離門檻觸發的設定。

1.2 Tekton 的 pruner

Kubernetes 自己對跑完的 Pod 幾乎不管——PodGC 的預設門檻是叢集內累積 12,500 顆終止 Pod(上游預設,本次沒查這台 kube-controller-manager 的實際參數),小叢集碰不到。

所以 Pipelines Operator 附了一個 CronJob:依 keep 保留最近幾筆,其餘刪掉。刪 PipelineRun 會透過 owner reference 級聯刪除它產生的 TaskRun 與 Pod。

級聯刪除是非同步的。 刪掉 PipelineRun 的當下再查一次,TaskRun 還在;幾秒後才消失。那是 Kubernetes garbage collector 的正常行為,不是沒刪掉。驗證回收類的東西時要把這個時間差算進去,不然會誤判成「級聯沒生效」。

1.3 Nexus 的兩階段 cleanup

分兩階段,第二階段清的是第一階段留下的東西。這個單向依賴是後面看懂問題的關鍵。

階段 任務型別 做什麼 需要什麼
repository.cleanup 依規則把 component 標記移除 repository 掛上 cleanup policy
assetBlob.cleanup 刪掉「asset 記錄已不在、blob 還在」的孤兒 第一階段留下的殘骸

cleanup policy 是規則本身(例如「最後下載超過 30 天就刪」),是獨立物件,建好之後要掛到 repository 上才生效。


2. 這台的實際值

機制 設定 狀態
image GC 85 / 80,imageMinimumGCAge 2m 用量 28%,未觸發
pruner keep: 100,每日 08:00,resources: [pipelinerun] PipelineRun 3 顆,未觸發
Nexus cleanup 六個任務全部 OK 十一個 repository 皆未掛 cleanup policy

前兩個是量還沒到門檻。第三個是設定不完整。

2.1 Nexus:兩階段沒接起來

Cleanup unused npm blobs      assetBlob.cleanup    0 */30 * * * ?   每 30 分   OK
Cleanup unused docker blobs   assetBlob.cleanup    0 */30 * * * ?   每 30 分   OK
Cleanup unused nuget blobs    assetBlob.cleanup    0 */30 * * * ?   每 30 分   OK
Cleanup unused maven2 blobs   assetBlob.cleanup    0 */30 * * * ?   每 30 分   OK
Cleanup service               repository.cleanup   0 0 1 * * ?      每日 01:00 OK

十一個 repository 全部 cleanup = (無)

於是第一階段沒標記任何 component,第二階段就沒有孤兒可清。四支 assetBlob.cleanup 每 30 分鐘跑一次,找不到東西,正常結束,回報 OK

這是設定不完整,不是故障。 從任務清單、執行結果、錯誤日誌都看不出來,因為每一項都正常。

「回收量是零」是推論,不是量到的。 /service/rest/v1/tasks/{id} 的欄位跟清單端點完全相同,沒有處理量、掃描數、刪除數,properties 也是空的。結論靠的是「沒有 policy → 沒東西可標記 → 沒有孤兒可清」這條鏈。

補起來的方式是建 cleanup policy 再掛到 repository 上,掛好之後 repository 設定裡的 cleanup.policyNames 會有值。

/service/rest/v1/cleanup-policies 在這台 CE 回 404,這是預期行為。 Sonatype 文件把 Cleanup Policies API 標為 Pro 專屬功能,CE 沒有這組端點,所以不是「查不到」而是「沒有」。反查「有沒有掛」可以走 repository 端點;新增只能走 UI,文件給的路徑是 Settings → Repository → Cleanup Policies——這台 3.93.0 CE 的 UI 是不是長這樣,我沒實際點過

2.2 pruner 有兩個覆蓋缺口

{"disabled":false,"keep":100,"resources":["pipelinerun"],"schedule":"0 8 * * *"}

目前 PipelineRun 3 顆、TaskRun 7 顆,離 keep: 100 很遠,所以現在不是問題。但覆蓋範圍有兩個缺口:

缺口一:resources 只有 pipelinerun 由 PipelineRun 產生的 TaskRun 有 owner reference,會跟著刪;手動建立的獨立 TaskRun 沒有擁有者,不在清單裡

這不是假設,這台已經有一顆。Day 15 驗證 path routing 時建的 probe-svc-pathrouting 是直接送出的 TaskRun,跟同一個 namespace 裡由 PipelineRun 產生的一比就看得出來:

d14-run-8nxtn-git-clone   ownerReferences = PipelineRun/d14-run-8nxtn   ← 會被級聯刪
probe-svc-pathrouting     ownerReferences = (無)                        ← 沒有任何機制會清它

一次性的除錯與驗證最容易產生這種 TaskRun,而它們正好是最沒人記得回頭刪的。

這是 Operator 1.23.1 寫進去的值,不是被人改的(managedFields 只有 openshift-pipelines-operator,沒有任何 namespace 帶 operator.tekton.dev/prune.* annotation)。而 Red Hat 官方文件到 1.20 為止,pruner 的預設範例都還是 resources: [taskrun, pipelinerun] 兩個都列——1.20 那頁我這次實際開來看過,1.11 到 1.16 只看到搜尋摘要,1.21 至 1.23 的對應頁面我沒查到,沒有核對。所以能講的是「文件範例與這台實際值不一致」,還不能講「哪一版改的」。

缺口二:只涵蓋七個 namespace。 CronJob 的 args 逐一列出目標:

ci / default / gitea / hostpath-provisioner / nexus-proxy / openshift / sealed-secrets

openshift-pipelines 自己和其他 openshift-* 都不在內。

要涵蓋的話,resources 加上 taskrun,namespace 那層則要看 Operator 的判定規則。

這台的終止 Pod 有 3 顆來自 image-pruner CronJob 自己——回收機制執行時也會留下需要回收的東西


3. 單節點差異一:三個檔案系統是同一顆盤

kubelet 生效的值(取自 configz,不是設定檔,理由見 5.3):

imageGCHighThresholdPercent = 85
imageGCLowThresholdPercent  = 80
imageMinimumGCAge           = 2m0s
imageMaximumGCAge           = 0s(未啟用)

evictionHard:  imagefs.available=15%   nodefs.available=10%   memory.available=100Mi
               imagefs.inodesFree=5%   nodefs.inodesFree=5%
evictionSoft:  (不存在)

後兩項這台碰不到——inode 用了 350,153 / 94,109,120(1%)。下面只討論容量那三項。

imagefs.available < 15% 就是已用超過 85%,和 image GC 的啟動門檻同一個數字

而且這台沒有 evictionSoft,只有 hard eviction——達到門檻立即驅逐,沒有寬限期

官方的三個名詞,在這台全是同一顆

Kubernetes 用三個名詞:nodefs(emptyDir、log)、imagefs(唯讀映像層)、containerfs(容器可寫層)。多節點的「split image filesystem」是把 imagefs 分出去,讓兩組門檻各自獨立。官方文件自己也註明,這三個名稱指的是 kubelet 觀察到的檔案系統,不必然對應到不同的掛載點

這台的 kubelet stats:

nodefs      capacity=192,668,479,488   avail=139,225,587,712
imageFs     capacity=192,668,479,488   avail=139,225,587,712
containerFs capacity=192,668,479,488   avail=139,225,587,712

逐位元組相同。 三個名詞指向同一個檔案系統,兩組門檻疊在同一個 85%。

imageFs.usedBytes 不能用。 這台回報 7,051,929(約 7 MB),而實際容器儲存是 40 G。共用檔案系統時 CRI-O 不分開統計 imagefs 用量。要看容器儲存用 podman system dfdu -shx

到了 85% 會發生什麼

依官方文件:只有 nodefs 的情況下,kubelet 觸發 eviction 會先回收死掉的 Pod 與容器,再刪除所有未使用的 image——比 image GC 的「刪到 80% 為止」更徹底。

所以門檻重合的後果不是「GC 沒時間」,而是兩條回收路徑同時啟動,eviction 那條刪得更狠。壓不回門檻以下才會標記 DiskPressure,然後打上 node.kubernetes.io/disk-pressure:NoSchedule 汙點,新的 Pod 不再排到這個節點。

症狀是 Pod 停在 PendingnodeName 為空——跟資源不足的排隊看起來一樣,差別在 oc describe node 的 Conditions 與 Taints。

這台用量 28%,沒觸發過。上面的回收順序是官方文件的說法,不是實測——要實測必須把磁碟填到 85%,這台在用不能這樣做。症狀描述則來自另一套環境的紀錄,兩台數字不能互相對照。

要拉開兩個門檻

  • 啟用 imageMaximumGCAge,讓 image GC 改成按年齡定期清,脫離門檻觸發
  • 調低 imageGCHighThresholdPercent(例如 70),讓 GC 早於驅逐門檻啟動

兩者都要改 KubeletConfig,屬節點層級設定,本篇不做。


4. 單節點差異二:PVC 的 capacity 沒有執行力

10 顆 PV:9 × 179Gi + 1 × 30Gi = 名目 1,641 Gi
實體盤 180 G,實際佔用 1.1 G

hostPath provisioner 把資料放在節點的 /var/lib/csi-hostpath-data/pvc-*——普通目錄,沒有 quota

雲端的 PV 背後是真實裝置,capacity 對應實際配額,寫超過得到 ENOSPC 而不影響其他 volume。這裡的 capacity 只是 PV 物件上的一個數字,用來滿足 PVC 的容量請求比對,執行期不做限制。

這不是實作缺漏。CSI hostpath driver 的專案首頁就自述是單節點用的非生產範例 driver,容量是用 -capacity 旗標模擬出來的;沒設定時就回報上限值。名目容量本來就不打算對應到任何真實配額。

實務上是:十顆 PV 共用剩下的空間,先寫先得。 寫爆得到的錯誤是磁碟滿,不是「你的額度用完了」。

另外有三顆 Released 的 PV,因為 Retain 政策資料還在磁碟上(合計 4.9 M)。Retain 的語意就是保留給人工處理——前面三層是機制沒生效,這個是本來就沒有機制


5. 三個會量到錯誤數字的地方

由難發現到好發現排列。

5.1 du 會跨路徑去重(最難發現)

同一組路徑,只調換順序,數字就變:

du -shx /var/lib/kubelet /var/lib/csi-hostpath-data
  2.1G  /var/lib/kubelet
  6.5M  /var/lib/csi-hostpath-data      ← 幾乎歸零

du -shx /var/lib/csi-hostpath-data /var/lib/kubelet
  1.1G  /var/lib/csi-hostpath-data
  1.1G  /var/lib/kubelet                ← 換這邊縮水

各自單獨跑
  2.1G  /var/lib/kubelet
  1.1G  /var/lib/csi-hostpath-data

CSI hostpath driver 把 PV 目錄 bind-mount 進 kubelet 的 pod 目錄,兩條路徑指向同一組 inode:

482345753  /var/lib/csi-hostpath-data/pvc-95ec8cf3-…
482345753  /var/lib/kubelet/pods/…/volumes/kubernetes.io~csi/pvc-95ec8cf3-…/mount

GNU du 在單次呼叫內記住看過的 inode 只算一次,歸給先走到的那條路徑。參數順序決定數字怎麼分配。

這也不是實作巧合。GNU coreutils 手冊本身就寫了:多個連結指向同一個檔案時只計一次,而引數順序會決定哪一條被計入,改順序就可能改變輸出的數字與項目。手冊講的情境是 hard link,這裡是 bind-mount,但去重依據同樣是 inode。

難發現的原因是它沒有任何異常訊號:-x 加了、單一數字合理、總和也對得上 df。只有在「把同一份資料當成兩個獨立項目列進分解表」時才出錯——而列目錄佔用分解表正是這類文章的標準寫法。

對策:分解表裡有 CSI volume 相關路徑時,一律各自單獨跑,或把重疊的兩者併成一項。

5.2 du -sh 在容器儲存上重複計算

df /sysroot 已用       50 G
du -sh  containers/storage   116 G    ← 錯
du -shx containers/storage    40 G    ← 對
podman system df              38.87 GB

節點上有 229 個 overlay on /var/lib/containers/storage/... 掛載點(快照值,隨執行中容器變動)。每個執行中容器的 merged 目錄是 overlay 的合併視圖,du 走進去會把下層再算一次。

訊號很明顯:單一子目錄大於整顆盤。

5.3 kubelet.conf 裡的 0s 有兩種意思

設定檔裡 grep imageGC|imageMinimum|imageMaximum|eviction,只有三行:

evictionPressureTransitionPeriod: 0s
imageMaximumGCAge: 0s
imageMinimumGCAge: 0s

configz 的生效值:

檔案 生效值
imageGCHighThresholdPercent 不存在 85
imageGCLowThresholdPercent 不存在 80
evictionHard 不存在 五項
imageMinimumGCAge 0s 2m0s
evictionPressureTransitionPeriod 0s 5m0s
imageMaximumGCAge 0s 0s(真的停用)

同一個 0s,前兩個是「未指定,套用預設」,最後一個是「明確關閉」,從檔案分不出來。而三個關鍵門檻根本不在檔案裡。

這不是 OpenShift 特有的行為。上游 KubeletConfiguration 型別的欄位註解就寫著 evictionPressureTransitionPeriod0s 會被轉成預設的 5m,上游也有 issue 專門講這件事(標題直接叫「silently default 0s to 5m」)。

只看設定檔會得出「這台沒有設任何門檻」的結論。 生效值要問 kubelet:

& $OC --kubeconfig $kc get --raw "/api/v1/nodes/crc/proxy/configz"

順帶:evictionPressureTransitionPeriod5m0s解除 DiskPressure 前必須連續維持在門檻以下的最短時間,用來防止狀態抖動,不是標記前的緩衝。


6. 目前的用量與距離

/dev/sda4   179.4G 容量   50G 已用(28%)   130G 可用
項目 大小
/var/lib/containers/storage(容器 image) 40 G
/var/log 3.0 G
/var/lib/kubelet已含 PV 資料 1.1 G,見 5.1) 2.1 G
/var/lib/etcd 485 M

image 佔八成。其中 29 張 dangling,含兩張接近 1 GB——這些是 GC 觸發時最先被清掉的一批。

image GC 啟動 (85%)   還可再長約 102 G
imagefs 驅逐  (85%)   同上,重合
nodefs  驅逐  (90%)   還可再長約 111 G

成長率算不出來

長期平均:40 G ÷ 約 93 天    ≈ 0.43 G/天
單日觀測:46 G → 50 G        ≈ 3.7 G/天

差了近十倍,兩個都不能用。長期平均混進了叢集初裝時一次拉下來的大量 image(前八大有四張屬於 OpenShift 自身的 release 與 operator index);單日 3.7 G 則是一次 Angular npm install 加三次 image pull 的突發。

要有可信數字得定期取樣,而這台沒有:

$ oc adm top node
error: Metrics API not available

用量可以 df 查。


參考資料


上一篇
Day 15:讓 image 真的經過 Nexus —— path routing、Bearer Token
下一篇
Day 17:DAG 的順序就是防線 —— runAfter 的兩條硬邊界
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言