Share Notes

chundev

View the Project on GitHub latteouka/share-notes

整合企業專有 Java 平台到自建 K8s 踩的四個暗雷

日期:2026-04-10 技術棧:Kubernetes 1.29 (kubeadm) / Harbor 2.11 / Tomcat 9 + Spring / Angular SPA


TL;DR

把一個專有企業 Java 平台(Tomcat + PostgreSQL + 內嵌 Harbor 客戶端)接到自建的現代 K8s + 現代 Harbor 時,踩到四個看似獨立的坑:K8s 1.24+ 不再自動建 Service Account token Secret前端 Connection Test dialog 的 state 跟外層 form 分離導致測試成功但送空 secrettoken 被加密存讓 grep debug 找不到Harbor 2.8 把 systeminfo.storage 從物件改成陣列但 vendor 的 OpenAPI code-gen client 從沒更新。這四個坑共通的教訓是:任何「橫跨系統邊界」的整合點,假設都會老化


背景

拿到一個專有企業 Java 平台(已 containerized 成 docker compose 跑),需要把它接到另外自建的 Kubernetes 叢集和私有 Harbor registry。這個 Java 平台的管理介面要求填 K8s 叢集的 API 端點和一個「管理員 token」,以及 Harbor registry 的連線資訊。理論上應該是「填表單 → 存檔 → dashboard 亮起來」的簡單事,實際上中間有四層暗雷。


暗雷 1:K8s 1.24+ ServiceAccount 不再自動產生 token Secret

症狀

按照舊版教學 kubectl create serviceaccount foo,然後 kubectl get serviceaccounts foo -o yaml 想找 secrets: 欄位取 token — 發現 secrets 欄位根本不存在

根因

從 Kubernetes 1.24 起(LegacyServiceAccountTokenNoAutoGeneration),ServiceAccount 不再自動建立對應的 token Secret。改成用 TokenRequest API 按需索取短期 token(預設 1 小時、最長 24 小時)。

這對 pod-native 工作負載很好 — 長期 token 本來就是安全反 pattern,kubelet 會定期輪換 TokenRequest 拿到的短期 token 然後 mount 進 pod。

但對 外部應用(例如 Tomcat 跑在 docker compose,不是 pod)不友善 — 它們沒有 kubelet 幫忙輪換,需要一個長期、穩定的 token

解法:手動建立 service-account-token 類型的 Secret

# 1. 建立 ServiceAccount
kubectl create serviceaccount external-app

# 2. 綁 ClusterRole(依需求調整,這裡給 cluster-admin 當範例)
kubectl create clusterrolebinding external-app-cluster-admin \
  --clusterrole=cluster-admin \
  --serviceaccount=default:external-app

# 3. ⭐ 手動建立 type=service-account-token 的 Secret,用 annotation 連結 SA
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Secret
metadata:
  name: external-app-token
  annotations:
    kubernetes.io/service-account.name: external-app
type: kubernetes.io/service-account-token
EOF

# 4. K8s 的 token controller 會自動偵測這個 Secret、產生 JWT 塞進 .data.token
#    這個 token 永不過期(直到 Secret 被刪或 SA 被刪)

# 5. 取出 token
kubectl get secret external-app-token -o jsonpath='{.data.token}'

關鍵在第 3 步的 type: kubernetes.io/service-account-token 和 annotation — 這是唯一能產生長期 token 的方式。這個機制在 K8s docs 其實有寫,但官方刻意放在不顯眼的地方,因為他們希望大家用短期 token。

驗證 token 是真的

拿到後解碼 JWT payload 檢查 identity:

echo "<token>" | base64 -d | awk -F'.' '{print $2"=="}' | base64 -d

正確的 payload 長這樣:

{
  "iss": "kubernetes/serviceaccount",
  "kubernetes.io/serviceaccount/namespace": "default",
  "kubernetes.io/serviceaccount/secret.name": "external-app-token",
  "kubernetes.io/serviceaccount/service-account.name": "external-app",
  "sub": "system:serviceaccount:default:external-app"
}

⚠️ 注意kubectl get secret -o jsonpath='{.data.token}' 回的是雙重 base64 — K8s Secret 欄位自身 base64,裡面存的 JWT 本身又是 base64.base64.signature 格式。要貼到外部系統時,通常直接貼 base64 那層,由對方 decode。


暗雷 2:Connection Test dialog 的 state 跟外層 form 分裂

症狀

在管理介面:

  1. 打開「Cloud Provider」編輯面板(外層 form)
  2. 點「Connection Test」按鈕,彈出 dialog
  3. 在 dialog 裡填 token 和 endpoint,按「Test」
  4. 顯示「連線成功 ✓」
  5. 關掉 dialog,按外層 form 的「Update」
  6. PUT 200 OK
  7. 但後端資料是空的,dashboard 沒反應

Debug 過程

嘗試 1:以為 update 失敗 — 檢查 HTTP status, PUT 是 200 OK ❌ 嘗試 2:以為權限問題 — 管理員帳號、cluster-admin 綁好了 ❌ 嘗試 3:看 DB — pg_dump | grep <token-prefix> 找不到 token(誤導判斷,見暗雷 3) ✅ 嘗試 4:看應用程式 DEBUG log 裡 PUT request body 的實際內容:

# 第一次更新的 request body(從 log 擷取):
updateSysCloudProviderDTO([{
  "enable": true,
  "accessKeyDTO": {
    "secret": "",                  ← ⚠️ 空字串
    "accessKey": "admin"
  }
}])

secret 送出時是空字串。Dialog 裡填的 token 從來沒同步到外層 form 的 state。

根因

前端 Angular 的 Connection Test dialog 是一個獨立 component,有自己的 form state。使用者在 dialog 裡填的 token 只存在 dialog 的區域 state,沒有 emit 事件回外層。測試通過只代表「dialog 這個 state 的資料打 API 能通」,不代表「外層 form 的欄位被填好了」。

外層 form 也有一個「管理帳號密碼」欄位,但跟 dialog 沒連動 — 如果使用者沒直接在外層欄位貼 token,外層送出時就是空的。

測試成功的綠色勾勾誤導使用者以為「form 全部填好了」。

解法

直接在外層 form 的「密碼」欄位貼 token,完全跳過 dialog 路徑。Dialog 只用來驗證連線,不要依賴它傳值。

教訓


暗雷 3:加密儲存的 grep 陷阱

症狀

驗證 PUT 200 之後,想確認 token 是不是真的落地 DB。直覺做 pg_dump database | grep <token-prefix>0 筆結果

立刻推論「vendor bug,update 回 200 但沒寫 DB」。

錯誤的推論

這個推論半對半錯

真相

企業系統通常會對敏感欄位(token / password)加密後才存 DB。加密後的 blob 看起來跟原文完全無關:

原 token (base64 JWT):
ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJ...

DB 裡儲存的版本(AES/CBC base64):
C4exlOe63E1G1n49xkFEwyOdSSr5oSDKoZCms6o/MR/JPfroGoCDxTUcfJEJ4nO...

grep <原 token 前綴> 絕對找不到 — 因為加密讓 plaintext 完全消失。

正確的 debug 流程

當「PUT 200 但 DB 找不到」時:

檢查順序 方法 產出
1 查應用程式 DEBUG log 看 PUT request body 確認 API 收到的是對的資料
2 查應用程式 DEBUG log 看 save 操作 看 service layer 有沒有呼叫 DB write
3 查 DB 對應 table 的 lastmodifiedtimeupdated_at 確認 row 是否被 touch
4 如果 touch 了,查那個 row 的所有欄位(不是 grep) 找到看起來像 base64 的 blob = 加密欄位
5 嘗試 app 自己的 decrypt 邏輯或 DB trigger 驗證加密

關鍵:別用 grep 驗證加密資料lastmodifiedtime 才是 write 是否發生的可靠 proxy。


暗雷 4:OpenAPI code-gen client 的 schema drift

症狀

接完 K8s 和 registry 之後,dashboard 上的「Registry Health」widget 噴紅色 banner:

載入鏡像倉庫健康狀態失敗
Index 0 out of bounds for length 0

Stack trace

Cannot deserialize value of type
  `com.vendor.thirdparty.io.goharbor.harbor.api.model.Storage`
  from Array value (token `JsonToken.START_ARRAY`)
  through reference chain: SystemInfo["storage"]

  at RegistryServiceImpl.checkStatus(...)
  at HarborStorageCommonServiceImpl.get(...)

根因 — Harbor 2.8 改過 API schema

從 Harbor 2.8 開始,/api/v2.0/systeminfo 的回應 schema 做了兩個 breaking change:

  1. storage 欄位從物件變陣列 — 為了支援多儲存後端: ```diff
    • “storage”: {“free”: …, “total”: …}
    • “storage”: [{“free”: …, “total”: …}] ```
  2. storage 欄位從 /systeminfo 搬到 /systeminfo/volumes/systeminfo 本身不再回傳 storage

Vendor 的 Java Harbor client 是從 Harbor 2.5-2.7 某個版本的 OpenAPI spec auto-generated 的,SystemInfo.storage 的欄位型別寫死成 Storage 物件。Jackson deserialize 現代 Harbor 的 array 就噴 JsonToken.START_ARRAY 例外。

驗證

# 現代 Harbor (2.8+) 實際回應
$ curl http://harbor/api/v2.0/systeminfo/volumes
{
  "storage": [              # ← 陣列
    {"free": 276346028032, "total": 314346831872}
  ]
}

# /systeminfo 本身不再回 storage
$ curl http://harbor/api/v2.0/systeminfo | jq .storage
null

為什麼 vendor 沒發現

Workaround

選項 評價
降級 Harbor 到 2.7 ❌ 失去 4 年安全更新
HTTP proxy 攔截 response 把 array 改成 object ❌ hack,維護負擔
改 vendor JAR 的 Jackson annotation ❌ 沒 source
忽略這個 widget,其他功能正常 ✅ 務實選擇

關鍵洞察:Docker Registry V2 protocol(/v2/* endpoint)跟 Harbor 自家管理 API(/api/v2.0/*)是兩套東西docker push/pull、K8s Pod pull image 走的是前者,完全不受影響。後者只影響 UI / 監控 widget。這個切分讓「忽略」變成可行的 workaround。


共通 pattern:橫跨系統邊界的假設都會老化

四個坑看似不相關,但背後是同一個結構問題:跨系統邊界的假設會隨時間失效

邊界 老化的假設
1. K8s SA token K8s ←→ 外部應用 kubectl create sa 會自動產 token」(1.24 變了)
2. Dialog state 分裂 前端 component 之間 「測試成功 = form 填好了」(從來就不成立)
3. 加密儲存 grep 陷阱 App ←→ DB grep plaintext 可以驗證資料落地」(加密後不成立)
4. OpenAPI schema drift Vendor client ←→ Harbor API 「generated client 跟 upstream API 同步」(只在 regenerate 那天成立)

預防這類坑的通用方法


學到的事


參考資料