這份筆記整理自 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 容易:

  1. 漏掉某個 ConfigMap、Secret 或 PVC。
  2. Stage / Prod 設定散在不同檔案,容易 drift。
  3. 開發者 push 新 image 之後還要有人去改 deployment.yaml、commit、apply。
  4. 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.0sherpa-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

做兩件事:

  1. /etc/rancher/rke2/registries.yaml 讓 containerd 信任 Harbor。
  2. /etc/hostsharbor.thebarkingdog.tw 解到 Nginx 反代(內網走 192.168.1.78)。

# Cluster 層級

bash bootstrap/cluster/setup.sh

做的事:

  1. Apply NVIDIA GPU time-slicing 設定(每張 GPU 切兩份)。
  2. flux install 安裝 controllers,含 image-reflector / image-automation。
  3. 建立四個 secret:Harbor credentials、Gitea PAT、webhook token、SOPS age key。
  4. Apply 五個 Flux CR:GitRepository / ImageRepository / ImagePolicy / Receiver / ImageUpdateAutomation。
  5. 把 webhook receiver service patch 成 NodePort(Harbor 才打得到)。
  6. 修 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 ] │

 ◀️─────────────────────────────╯

# 十、相關連結