症狀
CI 模板裡的 build job 原本用 docker:24+docker:24-dind。GitLab runner 改裝在 Kubernetes 之後,這個組合出了兩個問題:
- dind 要 privileged pod。 等於把整台節點的最高權限交給 CI job,這是公認的危險設定。
- dind 每個 job 都是全新的 daemon,沒有 layer 快取,所以慢。
而且第一個問題在我這裡不是「危險」而已,是直接跑不起來:job 的 namespace 強制了 Pod Security baseline,dind job 在 21 秒內失敗,錯誤是 Cannot connect to the Docker daemon at tcp://docker:2375。用 kubectl apply --dry-run=server 試建一個 privileged 的 dind pod,准入直接回 violates PodSecurity "baseline:latest": privileged。
原因
privileged 這一半:GitLab 文件把 Docker-in-Docker 列為需要 privileged 的做法,寫明「Use this approach when your runner supports privileged mode」(Use Docker to build Docker images)。Kubernetes 的 Pod Security Standards 則在 Baseline 等級就規定 securityContext.privileged 只允許未設定或 false,理由是「Privileged Pods disable most security mechanisms and must be disallowed」(Pod Security Standards)。兩邊一對,dind 在 baseline namespace 本來就不可能活。
慢的這一半,我歸納成三個原因:
- 每個 job 一個新 daemon,沒有 layer 快取(最主要)。GitLab 文件也寫了 dind 是「Each job gets its own isolated Docker daemon」(同上連結)。
- overlay 疊 overlay,常退回很慢的 vfs storage driver。(這點是經驗說法,這次沒有另外量測。)
- daemon 啟動本身要時間,這次量到 17 秒。
我在一個暫時開放 privileged 的 namespace 實測 dind:daemon 就緒 17.4 秒、冷建置 3.81 秒;同一個 daemon 第二次 build 只要 0.51 秒。但 CI 每個 job 都是新 daemon,0.51 秒這個數字在 CI 上不會發生。這正是「本機感覺很快、CI 很慢」的來源:本機的 Docker daemon 是長駐的,快取一直都在。
修法
改用 GitLab 官方文件的 rootless BuildKit,快取放在 registry:
.buildkit-build:
image:
name: moby/buildkit:v0.33.1-rootless
entrypoint: [""]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
CACHE_REF: $CI_REGISTRY_IMAGE/cache:buildcache # 固定 tag,不用 latest
script:
- buildctl-daemonless.sh build --frontend dockerfile.v0
--local context=. --local dockerfile=.
--import-cache type=registry,ref=$CACHE_REF
--export-cache type=registry,ref=$CACHE_REF,mode=max
--output type=image,name=$IMAGE,push=true幾個設定的來源:
entrypoint: [""]:moby/buildkit:rootlessimage 預設會把 BuildKit daemon 當長駐服務啟動,所以 GitLab 範例把 entrypoint 清空,改用buildctl-daemonless.sh在 job 內起停 daemon(Build Docker images with BuildKit)。BUILDKITD_FLAGS: --oci-worker-no-process-sandbox:同一份 GitLab 文件列為 rootless 模式的必要設定。BuildKit 的 rootless 文件說明,這個旗標讓 buildkitd 不替建置步驟建立新的 PID namespace、不掛新的/proc;Kubernetes 沒有systempaths=unconfined的對應設定,所以 Kubernetes 範例改用這個旗標(BuildKit rootless docs)。mode=max:GitLab 文件說明它會把所有 layer 匯出到快取,讓後續建置重用最多。
要知道的代價:BuildKit 文件把 --oci-worker-no-process-sandbox 標為 discouraged,因為 BuildKit 無法終止 ExecOp 裡沒有自行結束的程序,而且建置容器可以 kill 掉 daemon 容器裡的任意程序(同上 BuildKit 連結)。對「每個 job 用完即丟的 pod」來說我接受這個代價,但它不是零成本。
另外,GitLab 文件也提醒 rootless 建置仍需要 runner 允許 BuildKit 用到的系統呼叫;自建、沒開 privileged 的 Docker executor 可能會遇到權限錯誤(同上 GitLab 連結)。我這次是在 kind(arm64、kindest/node:v1.37.0)的 baseline namespace 跑,不需要把 seccomp/AppArmor 設成 Unconfined 就成功了;但只驗過這一種環境。
原本 build job 裡用 docker compose 做的 boot smoke,在沒有 Docker daemon 之後改成獨立 job:把剛 build 好的 image 當 services: 起來打 health check。Kubernetes executor 的 service 和 job 跑在同一個 pod 裡,用 alias 連得到(Kubernetes executor)。
實測數字
環境是本機 kind(arm64),fixture 是 busybox:
| 做法 | 每個 CI job 實際付的時間 |
|---|---|
| dind | 約 21 秒(daemon 啟動 17.4+冷建置 3.8,不含推 image);在 baseline namespace 根本起不來 |
| BuildKit 無快取 | 16.5 秒(含推 image 與匯出快取) |
| BuildKit 有快取 | 7.5–8.0 秒(含推 image) |
BuildKit 那三次(一次無快取、兩次有快取)都是綠的。
限制要講清楚: busybox 幾乎沒有建置內容,所以這組數字量到的只是「每個 job 的固定成本」(daemon 啟動、推 image、匯入匯出快取)。真實 app 有大量依賴安裝層時,registry 快取能省多少,這次沒有量,不能從這張表外推。
為什麼這樣選
- 不選 kaniko:原專案已在 2025 年 6 月 3 日被封存,README 寫明「archived and no longer developed or maintained」(kaniko repo)。要放進給別人照抄的 CI 模板,不該選一個不再維護的工具。
- 不選 Podman:GitLab 也有 Podman 的文件,但 BuildKit 的 registry 快取是一等公民,而且跟本機
docker buildx是同一套引擎,本機和 CI 的行為比較一致。 - 快取放 registry,而不是 runner 的分散式快取:不用另外架 S3/MinIO,pod 用完即丟也沒關係。快取 ref 用固定 tag,不用
latest,避免跟真正的 image tag 混在一起。
怎麼預防再次發生
- CI 跑在 Kubernetes 時,job 的 namespace 直接強制 Pod Security baseline。之後誰再引入需要 privileged 的 job,會在准入階段就被擋下,不靠 code review 記得。
- 工具進 CI 模板之前先走情境驗證:沒有它是紅的、加了它變綠、重跑也綠。「紅」的那段要做成可以重跑的 job,而不是只截圖一次。
- 換到有 AppArmor 的節點(例如 Ubuntu 主機)時,要重驗 rootless 是否需要 Unconfined。BuildKit 文件的 Docker 範例是把 seccomp 和 AppArmor 都設成 unconfined,並說明 host 沒用 AppArmor 時才不需要後者(同上 BuildKit 連結)。這次只在 kind 上驗過。
相關
- 相關主題:同一輪把自建 GitLab runner(Kubernetes executor)裝起來的過程與踩坑