這份筆記整理自 gpu-k8s-devops 這個 repo 的實作。重點放在「為什麼這樣設計」與「整條 CD pipeline 怎麼串」,做為部落格底稿用。
# TL;DR
- Git 是真相來源(Source of Truth):叢集所有狀態都來自 Git,包含加密過的 secret。
- Kustomize 解決多環境配置爆炸:base 寫骨架,overlay 寫差異,避免複製貼上。
- FluxCD 解決「誰把 Git 同步到叢集」:跑在叢集裡的 controller,10 分鐘 pull 一次、
prune: true連刪除都同步。 - Image Automation 解決「tag 升版要不要人工 commit」:Harbor push → Flux 自己改 Git → 自己 apply,全自動。
- SOPS + age 解決「secret 怎麼也進 Git」:對稱加密後存 Git,叢集端用 age private key 解。
# 一、設計目標
# 痛點
換機器、重建叢集時,過去靠 shell script + 手動 kubectl apply 容易:
- 漏掉某個 ConfigMap、Secret 或 PVC。
- Stage / Prod 設定散在不同檔案,容易 drift。
- 開發者 push 新 image 之後還要有人去改 deployment.yaml、commit、apply。
- Secret 不能進 Git,於是變成 out-of-band 狀態,新人接手要到處要密碼。
# 目標
- 「換機器」這件事縮短到兩個指令以內。
- 開發者只需要
docker push到 Harbor,叢集會自己升版。 - 所有狀態(包含 secret)都進 Git,可審計、可 rollback。
- 多環境(stage / prod)共用骨架、各自覆蓋。
# 二、整體架構
開發者 docker push
↓
Harbor (registry)
↓ webhook
┌────────────────── gpu-k8s 叢集 ──────────────────┐
│ │
│ FluxCD controllers (flux-system namespace) │
│ ├─ source-controller ← pull Git │
│ ├─ kustomize-controller ← kustomize build + apply
│ ├─ image-reflector ← 掃 Harbor tag │
│ ├─ image-automation ← 改 Git commit │
│ └─ notification-controller ← 收 webhook │
│ │
│ ↑ pull / push │
└──────────────────────────────────────────────────┘
↓
Gitea (Git server)
↑
開發者改 YAML、push
關鍵:沒有任何外部 CI 系統往叢集 push。所有變更都是叢集自己 pull 進來。這是 GitOps 的核心紀律。
# 三、Kustomize 分層
# 為什麼不用 Helm?
- Helm 用 Go template 拼字串,輸出常常是「對不上 schema 才發現」的錯。
- Kustomize 操作的是 Kubernetes object 本身(merge YAML),語意更乾淨。
- 多環境只有「差異」,不需要 Helm 那層 chart 的抽象。
# 兩層結構
apps/
├── base/ ← 骨架(共用)
│ └── langchain-mcp/
│ ├── deployment.yaml ← 只寫 container 結構、probe、volume
│ ├── service.yaml
│ └── kustomization.yaml
│
└── stage/ ← Stage overlay
├── kustomization.yaml ← 統一入口,列出要啟用哪些服務
└── services/
└── langchain-mcp/
├── kustomization.yaml ← 引用 base + 加 patch
├── deployment.yaml ← 只寫差異(image、env、replicas)
└── secret.yaml ← SOPS 加密
# Base 刻意不寫的東西
- Image tag — 由 overlay 寫,Image Automation 才能精準改
- 環境變數 — 連哪個 DB、哪個 LLM endpoint,環境之間一定不同
- Replicas — stage 通常 1,prod 可能 N
- Resource limits — 環境之間差很多
# Overlay 怎麼把 patch 套上去
# apps/stage/services/langchain-mcp/kustomization.yaml
resources:
- ../../../../apps/base/langchain-mcp
- secret.yaml
patches:
- path: deployment.yaml
patches 是 strategic merge patch — 你的 YAML 只寫想覆蓋的欄位,Kustomize 會跟 base 合併。
# 啟用 / 停用服務
# apps/stage/kustomization.yaml
resources:
- ./infra/postgres
- ./infra/redis
# - ./infra/minio ← 註釋 = 停用,下一輪 reconcile 會被 prune 掉
- ./services/tbd-nlp
註釋一行就停用,配合 prune: true 會連帶從叢集刪掉。這就是把「啟用清單」也納入版本控制的好處。
# 四、FluxCD 三件套
FluxCD 是一組跑在叢集裡的 controller。核心三個 CR:
# 1. GitRepository — 訂閱 Git
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: flux-system
namespace: flux-system
spec:
interval: 10m0s
url: https://git.thebarkingdog.tw/tbd/gpu-k8s-devops.git
ref:
branch: main
secretRef:
name: gitea-credentials
source-controller 每 10 分鐘 clone / pull 一次,把結果存成 in-cluster artifact 給其他 controller 用。
# 2. Kustomization — 把 Git 內容 apply 進叢集
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 10m0s
sourceRef:
kind: GitRepository
name: flux-system
path: ./apps/stage
prune: true # ← Git 刪掉 = 叢集刪掉
timeout: 5m0s
decryption:
provider: sops # ← SOPS 自動解密
secretRef:
name: sops-age
kustomize-controller 把 ./apps/stage 跑 kustomize build、解 SOPS、`apply 到叢集。
# 3. Image Automation 三兄弟
這是讓「push image 自動升版」運作的關鍵:
| CR | 職責 | 輪詢間隔 |
|---|---|---|
| ImageRepository | 列 Harbor 上所有 tag | 5 分鐘 |
| ImagePolicy | 從 tag list 挑「最新」 | 即時計算 |
| ImageUpdateAutomation | 把 policy 結果寫回 Git | 1 分鐘 |
| Receiver | Harbor webhook 入口,立刻踢 ImageRepository | 即時 |
# 五、Image Automation 完整鏈路
# Tag 篩選策略:三種團隊慣例
實務上不同服務 tag 風格不一致,全部硬塞 semver 不切實際。所以分三類:
# A. 純 semver(1.24.0)
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: realtime-asr
spec:
imageRepositoryRef:
name: realtime-asr
policy:
semver:
range: ">=0.0.0"
# B. Prefix + semver(api-0.20.0、sherpa-0.20.0)
同一個 image repo 有多個流派,用 regex filterTags 先把 prefix 拆掉再比 semver:
spec:
imageRepositoryRef:
name: tbd-nlp
filterTags:
pattern: "^sherpa-(?P<version>\\d+\\.\\d+\\.\\d+.*)$"
extract: "$version"
policy:
semver:
range: ">=0.0.0"
這樣 api-* 跟 sherpa-* 可以是同一個 ImageRepository、不同 ImagePolicy,互不干擾。
# C. 純數字 timestamp(03300208)
舊服務沒跑 semver,但 tag 是遞增整數:
spec:
filterTags:
pattern: "^(?P<ts>\\d{4,})$"
extract: "$ts"
policy:
numerical:
order: asc
# 關鍵魔法:# {"$imagepolicy": ...} 註解
# apps/stage/services/langchain-mcp/deployment.yaml
containers:
- name: api
image: harbor.thebarkingdog.tw/tbd/langchain-mcp:1.16.3 # {"$imagepolicy": "flux-system:langchain-mcp"}
這個行尾註解是 ImageUpdateAutomation 的 strategy: Setters 要找的 marker。
- 沒有這個註解的 image 行 Flux 不會動**。
- 有的話,Flux 會把
:1.16.3換成flux-system/langchain-mcp這個 ImagePolicy 當下指向的版本。
這設計超讚:你完全控制哪些 image 要自動化、哪些要手動釘版。
# Webhook:把 5 分鐘 polling 縮成秒級
apiVersion: notification.toolkit.fluxcd.io/v1
kind: Receiver
metadata:
name: harbor-receiver
namespace: flux-system
spec:
type: generic
secretRef:
name: webhook-token
resources:
- kind: ImageRepository
name: realtime-asr
# ...
Harbor push 完戳 webhook → Flux 立刻叫對應 ImageRepository 重掃,不用等 5 分鐘。
# 完整時序:從 push 到 pod 起來
T+0s docker push harbor.../langchain-mcp:1.17.0
T+1s Harbor webhook → Receiver → ImageRepository 立即掃描
T+2s ImageRepository 拿到 tag list,ImagePolicy 算出 1.17.0
T+0~60s ImageUpdateAutomation 醒來,發現 Git 還是 1.16.3
T+~60s Flux 改 Git 檔、以 gitea-bot commit + push
T+0~600s Kustomization 下次 reconcile(最多 10 分鐘)
T+~660s kustomize build + apply,Deployment 開始 rolling
T+~670s 新 pod ready、舊 pod 砍掉
最慢約 11 分鐘(有 webhook)/ 16 分鐘(無 webhook)。
瓶頸在 Kustomization 的 10 分鐘 interval。如果想再快,可以調短,但會增加 Git server 負載。
# 六、Secret:SOPS + age
# 為什麼不用 Sealed Secrets / External Secrets?
- **Sealed Secrets 綁特定叢集的 controller key,換叢集就要重新 seal,bootstrap 麻煩。
- External Secrets 要外部 secret store(Vault、AWS Secrets Manager),多一層服務。
- SOPS 是純檔案加密,搭 age 做非對稱密鑰,換叢集只要把 age key 復原,無狀態。
# .sops.yaml 規則
creation_rules:
- path_regex: apps/.*/secret\.yaml$
encrypted_regex: '^(data|stringData)$'
age: age1xxxxxxx...
只加密 Secret 物件的 data / stringData 欄位,metadata 仍可讀,這樣 git diff 還能看出改了哪個 secret 物件、不會整檔變亂碼。
# 編輯流程
# 編輯(自動解密 → 編輯器 → 存檔自動加密)
sops apps/stage/services/langchain-mcp/secret.yaml
# 只想看明文
sops -d apps/stage/services/langchain-mcp/secret.yaml
# 叢集端怎麼解
Bootstrap 時把 age private key 塞進 K8s Secret:
kubectl -n flux-system create secret generic sops-age \
--from-file=age.agekey="${AGE_KEY_FILE}"
Kustomization 的 decryption.secretRef: sops-age 會在 apply 前自動解密。
# VS Code 整合
裝 signageos/vscode-sops,打開加密 yaml 會即時解密顯示,存檔自動加密。前提是本機 ~/Library/Application Support/sops/age/keys.txt 有 age private key。
# 七、Bootstrap:兩個指令重建叢集
# Node 層級
bash bootstrap/node/setup.sh
做兩件事:
- 寫
/etc/rancher/rke2/registries.yaml讓 containerd 信任 Harbor。 - 寫
/etc/hosts讓harbor.thebarkingdog.tw解到 Nginx 反代(內網走192.168.1.78)。
# Cluster 層級
bash bootstrap/cluster/setup.sh
做的事:
- Apply NVIDIA GPU time-slicing 設定(每張 GPU 切兩份)。
flux install安裝 controllers,含 image-reflector / image-automation。- 建立四個 secret:Harbor credentials、Gitea PAT、webhook token、SOPS age key。
- Apply 五個 Flux CR:GitRepository / ImageRepository / ImagePolicy / Receiver / ImageUpdateAutomation。
- 把 webhook receiver service patch 成 NodePort(Harbor 才打得到)。
- 修 NetworkPolicy 允許內網 IP 進 webhook。
# Out-of-band 的東西(不能進 Git)
| 東西 | 怎麼給 |
|---|---|
| age private key | 拷貝 keys.txt 到新機器 |
| Harbor admin 密碼 | 寫死在 install.sh(或用 .env) |
| Gitea PAT token | 手動跑 kubectl create secret |
age key 是「打開 Git 裡所有 secret 的鑰匙」,所以它本身不能進 Git,必須手動傳遞。這就是非對稱加密的本質。
# 八、實戰心得
# 對的選擇
- Kustomize 不是 Helm:學起來快、IDE 補全好、debug 好(
kustomize build直接看輸出)。 - prune: true 從一開始就開:不開的話「停用服務」要手動清,遲早 drift。
- Image Automation 用 Setters 不用 commit:行尾註解標記哪些要自動化、哪些手動,控制權還是在 PR review。
- SOPS partial encryption:只加密 data 欄位讓 git diff 仍可讀,是 review secret 變更的關鍵。
# 踩過的坑
- Service 不能叫 postgres:K8s 會把 service name 轉成大寫環境變數注入到所有 pod,覆蓋掉應用程式自己的
POSTGRES_PORT=5432,變成tcp://...連線會壞。改名pg-db解決。 - 2 GPU 服務不能 RollingUpdate:4 張卡 + time-slicing replicas:2,rolling 會瞬間需要 2x GPU 把排程卡死。改成
Recreate。 - RTX 5070 Ti 要 open kernel driver:Blackwell 架構必須
nvidia-driver-590-open,裝錯版本會 No devices found。 - Image Automation 改不到 image tag:99% 是行尾註解漏寫,或 ImagePolicy 名字打錯。
- 手
kubectl edit改 deployment:10 分鐘內會被 Flux 改回 Git 寫的樣子。一律改 Git。
# 想再優化的地方
- 加 Slack notification(NotificationController + Provider):image 升版、apply 失敗時通知。
- Prod 環境分開 cluster path(
./apps/prod),目前還是空的。 - 加 flux diff 進 PR CI:merge 前看到「這個 PR 會對叢集產生什麼變更」。
# 九、CD 流程速查
[ 開發者 ]
│
│ docker push harbor.../foo:1.2.3
▼
[ Harbor ] ──webhook──▶️ [ Flux Receiver (NodePort 30292) ]
│ │
│ ▼
│ [ ImageRepository ] ──5min poll──╮
│ │ │
│ ▼ │
│ [ ImagePolicy 算出 latest ] │
│ │ │
│ ▼ │
│ [ ImageUpdateAutomation 1min poll ] │
│ │ │
│ │ 改 deployment.yaml │
│ │ commit + push │
│ ▼ │
[ Gitea ] ◀️───────────────────────╯ │
│ │
│ 10min poll │
▼ │
[ GitRepository ] ──▶️ [ Kustomization ] │
│ │
│ kustomize build │
│ + SOPS decrypt │
│ + kubectl apply │
▼ │
[ K8s API ] │
│ │
▼ │
[ Deployment rolling update ] │
│ │
▼ │
[ Pod ready ] │
│
◀️─────────────────────────────╯