一句話結論:在 k3d 裡自己 build 的映像,不要一直 k3d image import,建一個本機 registry,再把 docker.io 的 mirror 指過去,節點需要時才拉,磁碟用量和舊版本回收都會好處理很多。

遇到的問題

我在本機用 k3d 跑著一個多節點的叢集,裡面有不少服務是自己 build 的映像。

一開始都是 build 完就 k3d image import,簡單、不用架任何東西,用起來也很順。

直到有一天,系統碟快滿了。

一查才發現,每個節點的 /var/lib/rancher/k3s 都長到 20 多 GB。原因有兩個:

  1. 每個節點都存一份。 k3d image import 是把映像塞進「每一個」節點各自的 containerd,節點越多,存的份數越多。
  2. 舊版本永遠不會被回收。 tag 固定寫 0.1.0,每次重新 build 都覆蓋同一個 tag,舊的那份就變成 <none>,沒有任何東西會去清它。

而 kubelet 預設要等到磁碟用量 85% 才開始回收映像(回收到 80%)。

另外「閒置太久就回收」的 imageMaximumGCAge 預設是關閉的。

系統碟夠大的話,這個門檻幾乎永遠不會觸發,舊映像就這樣一路堆上去。

後來改成用本機 registry,下面是實際的做法,可以跟著做。

手把手步驟

步驟一:建叢集的時候順便建 registry

k3d 的設定檔可以直接宣告 registry,一次到位。新建一個 cluster.yaml:

apiVersion: k3d.io/v1alpha5 kind: Simple metadata: name: dev servers: 1 agents: 2 registries: create: name: k3d-dev-registry host: "127.0.0.1" hostPort: "5001" image: docker.io/library/registry:3 volumes: - /srv/k3d-registry:/var/lib/registry config: | mirrors: "docker.io": endpoint: - http://k3d-dev-registry:5000 

有幾個地方先說明一下。

兩個埠

hostPort: "5001" 是在主機上 push 用的埠。節點之間互相連是走容器內的 5000,所以 mirror 的 endpoint 寫 :5000。

這兩個埠很容易搞混。

資料放哪裡

volumes 把 registry 的資料放到主機上,叢集砍掉重建,映像也還在。路徑請換成自己的。

registry 版本

image 指定 registry:3,原因放在後面「踩到的坑」講。

mirror

重點是 mirrors:把 docker.io 的 mirror 指到這個本機 registry。

之後 Manifest 裡寫的 myorg/app:0.1.0(等於 docker.io/myorg/app:0.1.0)會先去本機 registry 找,找不到才自動回退到 Docker Hub。

k3s 文件寫得很清楚:containerd 對每個 registry 都有一個隱含的「預設 endpoint」,不管 mirror 怎麼設,最後一定會試它。

所以 Helm chart 或 YAML 完全不用改映像名稱。

然後建叢集:

k3d cluster create --config cluster.yaml 

步驟二:確認 registry 起來了

curl -s http://127.0.0.1:5001/v2/_catalog 

剛建好應該會看到:

{"repositories":[]} 

步驟三:build 之後 push 到本機 registry

先 build 你的映像,再 tag 成本機 registry 的位址然後 push:

docker build -t myorg/app:0.1.0 . docker tag myorg/app:0.1.0 127.0.0.1:5001/myorg/app:0.1.0 docker push 127.0.0.1:5001/myorg/app:0.1.0 

注意 push 的路徑是 myorg/app:0.1.0,不要帶 docker.io/。因為節點是依「docker.io 底下的 myorg/app」去 mirror 找的,路徑要對得上。

再看一次 catalog,應該就有東西了:

curl -s http://127.0.0.1:5001/v2/_catalog # {"repositories":["myorg/app"]} 

步驟四:部署,Manifest 照舊

你的 Deployment 繼續寫:

image: myorg/app:0.1.0 imagePullPolicy: IfNotPresent 

部署之後,建議直接到節點裡驗證,確認它真的是從本機 registry 拉的,而不是剛好從 Docker Hub 拉到同名映像:

docker exec k3d-dev-server-0 crictl pull myorg/app:0.1.0 

再去 registry 看一下有沒有被請求過(或者乾脆把 Docker Hub 上不存在的名字拿來測,能拉到就一定是本機的)。

步驟五:同一個 tag 重新 build 的時候

如果 tag 不變(例如固定用 0.1.0),重新 push 之後,節點上可能還留著舊的 0.1.0。

這時 IfNotPresent 不會重新去拉。解法是 push 完順手把各節點上同名的 tag 刪掉:

for node in k3d-dev-server-0 k3d-dev-agent-0 k3d-dev-agent-1; do docker exec "$node" crictl rmi docker.io/myorg/app:0.1.0 >/dev/null 2>&1 || true done 

這樣下次 Pod 重建時就會重新拉新的。

正在跑的容器不受影響。

被你覆蓋掉的舊版本,內容在 registry 裡還是會佔空間,要跑 garbage collect 才會回收。

跑 GC 之前,請先確定沒有人正在 push。

Distribution 官方文件明確警告:GC 進行時如果同時上傳映像,layer 可能被誤刪,造成映像損壞。

建議先用 --dry-run 預覽會刪什麼:

docker exec k3d-dev-registry \ registry garbage-collect --dry-run --delete-untagged /etc/distribution/config.yml 

確認沒問題再正式執行:

docker exec k3d-dev-registry \ registry garbage-collect --delete-untagged /etc/distribution/config.yml 

如果你的 tag 每次都不一樣(用 git SHA 之類),其實就不需要步驟五的第一段,只要偶爾跑 GC 就好。

步驟六:把 kubelet 的映像回收調積極一點

映像改由 registry 提供之後,節點上的映像被回收了也沒關係,要用時再拉回來就好。

所以可以把回收門檻調低。新增一個 kubelet-image-gc.conf:

apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration imageGCHighThresholdPercent: 50 imageGCLowThresholdPercent: 40 imageMaximumGCAge: 168h 

意思是:磁碟用到 50% 就開始回收,一直回收到 40%(kubelet 預設是 85% 和 80%);另外,閒置超過 7 天的映像,不管磁碟用量多少都回收。

有一點要知道:Kubernetes 文件提到,imageMaximumGCAge 不會跨 kubelet 重啟追蹤。

kubelet 一重啟,計時就歸零,要重新等滿這段時間。

所以節點常常重啟的話,這個條件很難真正觸發,主要還是靠磁碟門檻在回收。

然後在 cluster.yaml 把它掛進每個節點,並讓 kubelet 讀取那個目錄:

volumes: - volume: /srv/kubelet-image-gc.conf:/etc/rancher/k3s/kubelet.conf.d/50-image-gc.conf nodeFilters: - all options: k3s: extraArgs: - arg: --kubelet-arg=config-dir=/etc/rancher/k3s/kubelet.conf.d nodeFilters: - all 

實際效果

改用 registry、調整 kubelet 回收之後,節點的磁碟用量差很多。

改之前 改之後
每個節點的 /var/lib/rancher/k3s 約 25 GB 約 5 到 11 GB
本機 registry 沒有 約 3.6 GB

這組數字要稍微保守看待。

「改之前」是當時量到的,現在已經沒辦法重現。

「改之後」是現在量的。這個目錄除了映像,還包含容器的可寫層、log 等,所以不同節點差異會比較大,取決於上面跑了哪些 Pod。

所以拿來看趨勢就好,不是嚴格的前後對照實驗。

如果你已經有叢集了

不用重建。

做法是在每個節點寫一份 /etc/rancher/k3s/registries.yaml(內容就是上面的 mirror 設定),再逐一重啟節點。

要注意重啟節點時,上面的 Pod 也會跟著重啟,跟主機重開機差不多,挑個不忙的時間做。

流程可以寫成一個小 script:先把節點上現有的映像推進 registry,再寫設定,最後一個一個重啟。

踩到的坑

1. registry:2 的 GC 會把映像刪壞

這個坑我踩得最久。

--delete-untagged 在 registry:2 上,會把 OCI index 底下的子 manifest 當成沒有 tag 的東西刪掉。而用較新版 Docker(containerd image store)push 上去的映像,剛好就是 OCI index。

結果就是 GC 跑完,映像整個拉不下來。

換成 registry:3 之後就正常了,所以一開始就選 v3。

2. imageMaximumGCAge 沒有對應的命令列參數

本來想用 --kubelet-arg 直接傳,結果 kubelet 直接起不來。

它只能寫在 KubeletConfiguration 檔案裡,所以才要用 config-dir 這種 drop-in 的方式。

3. 先別在節點上跑 crictl rmi --prune

在映像還沒進 registry 之前,這個指令會把節點上「目前沒在使用」的映像一起刪掉。

而那些映像又沒有別的地方可以拉回來,之後排到那個節點的排程工作就會 ImagePullBackOff。

我就是這樣被咬過一次,最後從別的節點把映像複製回來才解決。

順序應該是:先把映像都放進 registry,再交給 kubelet 的 GC 去清。

為什麼這樣選

考慮過兩種做法。

直接用 localhost:5001/myorg/app 這種名稱

好處是很明確,不會有「這個映像到底從哪來」的疑問。

缺點是 Manifest 要改,而且 chart 要同時給本機和其他環境用,不想為了這個維護兩套映像名稱。

mirror docker.io(最後選的)

Manifest 一個字都不用改,要換環境也不受影響。

但缺點要記得:只要本機 registry 沒有,就會回退去 Docker Hub 拉同名的映像。

所以映像的命名空間(上面的 myorg)一定要是自己在 Docker Hub 上擁有的。不然有人在那邊註冊同名映像,registry 沒東西時就會拉到別人的。

如果只是做一次性的實驗,k3d image import 其實完全夠用,不需要為了這個架 registry。

映像很多、又會頻繁覆蓋同一個 tag 的情況下,才覺得值得。

延伸閱讀

  • k3d:Using image registries:registries.create 與 registries.config(mirrors)的範例。這一頁的範例只示範了 name,host、hostPort、image、volumes 這些欄位要看 JSON Schema;順便一提,schema 裡 image 的預設值是 registry:2,所以想用 v3 要自己指定。
  • K3s:Private Registry Configuration:節點上 registries.yaml 的 mirror 寫法(既有叢集要手動寫的就是這個),以及預設 endpoint 一定會當最後手段的說明。
  • Kubernetes:Garbage collection:kubelet 依門檻回收映像的機制,以及 imageMaximumGCAge 要寫在 kubelet 設定檔、重啟後計時歸零。
  • Kubernetes:Kubelet Configuration (v1beta1):imageGCHighThresholdPercent(預設 85)、imageGCLowThresholdPercent(預設 80)、imageMaximumGCAge(預設 0s=關閉)。
  • Distribution:Garbage collection:garbage-collect 的 --dry-run、--delete-untagged,以及「GC 時 registry 要唯讀或停止」的警告。

這篇的環境是 k3d v5.x、k3s 1.31。你的版本不同的話,記得對一下設定欄位。