chundev
日期:2026-04-10 技術棧:Kubernetes 1.29 (kubeadm) / Harbor 2.11 / Tomcat 9 + Spring / Angular SPA
把一個專有企業 Java 平台(Tomcat + PostgreSQL + 內嵌 Harbor 客戶端)接到自建的現代 K8s + 現代 Harbor 時,踩到四個看似獨立的坑:K8s 1.24+ 不再自動建 Service Account token Secret、前端 Connection Test dialog 的 state 跟外層 form 分離導致測試成功但送空 secret、token 被加密存讓 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 亮起來」的簡單事,實際上中間有四層暗雷。
按照舊版教學 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。
拿到後解碼 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。
在管理介面:
❌ 嘗試 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 只用來驗證連線,不要依賴它傳值。
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 完全消失。
當「PUT 200 但 DB 找不到」時:
| 檢查順序 | 方法 | 產出 |
|---|---|---|
| 1 | 查應用程式 DEBUG log 看 PUT request body | 確認 API 收到的是對的資料 |
| 2 | 查應用程式 DEBUG log 看 save 操作 | 看 service layer 有沒有呼叫 DB write |
| 3 | 查 DB 對應 table 的 lastmodifiedtime 或 updated_at |
確認 row 是否被 touch |
| 4 | 如果 touch 了,查那個 row 的所有欄位(不是 grep) | 找到看起來像 base64 的 blob = 加密欄位 |
| 5 | 嘗試 app 自己的 decrypt 邏輯或 DB trigger | 驗證加密 |
關鍵:別用 grep 驗證加密資料。lastmodifiedtime 才是 write 是否發生的可靠 proxy。
接完 K8s 和 registry 之後,dashboard 上的「Registry Health」widget 噴紅色 banner:
載入鏡像倉庫健康狀態失敗
Index 0 out of bounds for length 0
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/v2.0/systeminfo 的回應 schema 做了兩個 breaking change:
storage 欄位從物件變陣列 — 為了支援多儲存後端:
```diff
storage 欄位從 /systeminfo 搬到 /systeminfo/volumes — /systeminfo 本身不再回傳 storageVendor 的 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
| 選項 | 評價 |
|---|---|
| 降級 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。
四個坑看似不相關,但背後是同一個結構問題:跨系統邊界的假設會隨時間失效。
| 坑 | 邊界 | 老化的假設 |
|---|---|---|
| 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 那天成立) |
預防這類坑的通用方法:
kubectl create sa,要手動建 type: kubernetes.io/service-account-token 的 Secret 並加 kubernetes.io/service-account.name annotation。K8s controller 會自動塞 JWT 進去,這個 token 直到 Secret 被刪前都永不過期。lastmodifiedtime 是否更新。加密 at rest 是企業系統常見設計。JsonToken.START_ARRAY mismatch error — 這類 deserialize error 的典型原因是 schema drift