前言:一次神秘的 Pod 複製事件

在一次例行的 Strapi CMS 更新部署到 AWS EKS 時,遇到了一個詭異的現象:明明 Deployment 設定檔中清楚寫著 replicas: 1,但實際運行的 Pod 卻有兩個!更奇怪的是,其中一個 Pod 持續處於 CrashLoopBackOff 狀態,而另一個則正常運行。

無論怎麼刪除多餘的 Pod,它總是會像打不死的蟑螂一樣再次出現。這種「靈異事件」讓我開始懷疑 Kubernetes 是不是有自己的想法…

本文將深入探討:

  • 為什麼會出現多餘的 Pod
  • CrashLoopBackOff 背後的機制
  • Kubernetes Deployment 和 ReplicaSet 的運作原理
  • 實戰排查步驟與解決方案
  • Secret 編碼陷阱與預防措施

問題背景:Deployment 說一個,實際卻有兩個

問題現象

預期行為:

# my-strapi-prod-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-strapi-prod
spec:
  replicas: 1  # 只要 1 個 Pod
  selector:
    matchLabels:
      app: my-strapi-prod

實際情況:

$ kubectl get pods -n default | grep my-strapi-prod

NAME                               READY   STATUS             RESTARTS   AGE
my-strapi-prod-7d4b5c8f9d-x2k4p    1/1     Running            0          10m
my-strapi-prod-8c9a6d7e5f-w8n2q    0/1     CrashLoopBackOff   5          5m

兩個 Pod 同時存在,卻只有一個正常運行!

問題時間線

timeline
    title Kubernetes 部署問題時間線
    section 初始狀態
        舊版本正常運行 : 1 個 Pod 正常
    section 更新部署
        推送新版本 : 更新 Deployment
        : 創建新 ReplicaSet
    section 問題出現
        新 Pod 啟動失敗 : CrashLoopBackOff
        : 舊 Pod 繼續運行
    section 現象
        兩個 Pod 共存 : 新舊並存
        : 無法自動恢復

核心概念:理解 Kubernetes Deployment 機制

在排查問題之前,先來理解 Kubernetes 是如何管理 Pod 的。

Deployment、ReplicaSet 與 Pod 的關係

graph TB
    subgraph "Kubernetes 部署架構"
        D[Deployment]
        D -->|控制| RS1[ReplicaSet v1<br/>舊版本]
        D -->|控制| RS2[ReplicaSet v2<br/>新版本]
        RS1 -->|管理| P1[Pod v1<br/>Running]
        RS2 -->|管理| P2[Pod v2<br/>CrashLoopBackOff]
    end

    style D fill:#4ade80
    style RS1 fill:#60a5fa
    style RS2 fill:#f97316
    style P1 fill:#22c55e
    style P2 fill:#ef4444

層級說明:

  1. Deployment:部署控制器

    • 負責管理應用的整個生命週期
    • 創建和管理 ReplicaSet
    • 支援滾動更新(Rolling Update)
  2. ReplicaSet:副本集

    • 確保指定數量的 Pod 副本在運行
    • 每次 Deployment 更新都會創建新的 ReplicaSet
    • 舊的 ReplicaSet 不會立即刪除(用於回滾)
  3. Pod:最小部署單元

    • 實際運行容器的地方
    • 由 ReplicaSet 創建和管理

滾動更新機制

當你更新 Deployment 時,Kubernetes 會執行「滾動更新」:

sequenceDiagram
    participant User as 開發者
    participant Deploy as Deployment
    participant RS_Old as ReplicaSet 舊版
    participant RS_New as ReplicaSet 新版
    participant Pod_Old as Pod 舊版
    participant Pod_New as Pod 新版

    User->>Deploy: 更新 Deployment (kubectl apply)
    Deploy->>RS_New: 創建新的 ReplicaSet
    RS_New->>Pod_New: 啟動新 Pod

    Note over Pod_New: ⏳ 等待新 Pod 就緒 (Readiness Probe)

    alt 新 Pod 啟動成功
        Pod_New-->>RS_New: ✅ Pod Ready
        RS_New->>Pod_Old: 開始終止舊 Pod
        Deploy->>RS_Old: 縮減舊 ReplicaSet 副本數
    else 新 Pod 啟動失敗
        Pod_New-->>RS_New: ❌ CrashLoopBackOff
        Note over RS_New,Pod_Old: ⚠️ 保留舊 Pod<br/>不敢刪除
        Note over Deploy: 滾動更新卡住
    end

關鍵機制:安全保護

Kubernetes 的滾動更新有一個重要的安全機制:

  • 只有當新 Pod 通過健康檢查(Readiness Probe)後,才會刪除舊 Pod
  • 如果新 Pod 一直無法啟動,舊 Pod 會繼續運行
  • 這就是為什麼會出現「兩個 Pod 並存」的現象!

問題排查:一步步找出根本原因

步驟 1:確認 Pod 狀態

# 查看所有 Pod 狀態
kubectl get pods --all-namespaces | grep my-strapi-prod

輸出結果:

NAMESPACE   NAME                               READY   STATUS             RESTARTS   AGE
default     my-strapi-prod-7d4b5c8f9d-x2k4p    1/1     Running            0          10m
default     my-strapi-prod-8c9a6d7e5f-w8n2q    0/1     CrashLoopBackOff   5          5m

關鍵資訊:

  • 兩個 Pod 的名稱中間部分不同(7d4b5c8f9d vs 8c9a6d7e5f
  • 這些是 ReplicaSet 的 hash,代表它們來自不同的 ReplicaSet

步驟 2:檢查 ReplicaSet

# 查看所有 ReplicaSet
kubectl get rs -n default | grep my-strapi-prod

輸出結果:

NAME                         DESIRED   CURRENT   READY   AGE
my-strapi-prod-7d4b5c8f9d    1         1         1       10m
my-strapi-prod-8c9a6d7e5f    1         1         0       5m

發現問題!

  • 存在兩個 ReplicaSet 同時運行
  • 舊 ReplicaSet(7d4b5c8f9d):1 個 Pod Ready
  • 新 ReplicaSet(8c9a6d7e5f):1 個 Pod 但 0 個 Ready

步驟 3:查看 Pod 日誌找出 Crash 原因

# 查看失敗 Pod 的日誌
kubectl logs my-strapi-prod-8c9a6d7e5f-w8n2q -n default

日誌輸出:

[2025-05-06 10:23:45.123] ERROR: Failed to connect to database
[2025-05-06 10:23:45.124] Error: password authentication failed for user "strapi"
[2025-05-06 10:23:45.125] INFO: Retrying in 5 seconds...

找到根本原因: 資料庫密碼錯誤!

步驟 4:檢查 Secret 設定

# 查看 Secret
kubectl get secret strapi-db-secret -n default -o yaml

發現問題:

apiVersion: v1
kind: Secret
metadata:
  name: strapi-db-secret
data:
  DB_PASSWORD: bXlwYXNzd29yZAo=  # ❌ 多了換行符號 \n

解碼驗證:

$ echo "bXlwYXNzd29yZAo=" | base64 -d | xxd
00000000: 6d79 7061 7373 776f 7264 0a              mypassword.

# 最後的 0a 就是 \n (換行符號)

CrashLoopBackOff 狀態深度解析

什麼是 CrashLoopBackOff?

CrashLoopBackOff 是 Kubernetes 的一種 Pod 狀態,表示:

  • Pod 啟動後立即崩潰(Crash)
  • Kubernetes 嘗試重啟 Pod(Loop)
  • 每次重啟失敗後,等待時間會遞增(Backoff)

重啟等待時間演算法:

首次重啟:立即
第二次:10 秒
第三次:20 秒
第四次:40 秒
第五次:80 秒
最大值:5 分鐘

CrashLoopBackOff 流程圖

stateDiagram-v2
    [*] --> Pending: 創建 Pod
    Pending --> Running: 容器啟動
    Running --> Succeeded: 正常結束
    Running --> Failed: 程序崩潰
    Failed --> Waiting: 等待重啟
    Waiting --> Running: Backoff 後重啟
    Failed --> CrashLoopBackOff: 多次失敗
    CrashLoopBackOff --> Running: 繼續重試
    Succeeded --> [*]

    note right of CrashLoopBackOff
        重啟間隔遞增:
        10s → 20s → 40s → 80s → 5min
    end note

常見的 CrashLoopBackOff 原因

原因類型具體問題排查指令
設定錯誤環境變數、Secret、ConfigMap 錯誤kubectl logs <pod>
資源不足記憶體 OOM、CPU 限制kubectl describe pod <pod>
映像檔問題映像檔不存在、拉取失敗kubectl describe pod <pod>
健康檢查Liveness Probe 設定過於嚴格查看 Deployment YAML
應用程式錯誤程式碼 bug、依賴缺失kubectl logs <pod>
權限問題無法存取 Volume、Secretkubectl describe pod <pod>

解決方案:從錯誤到修復

嘗試方案 1:修正 Secret(失敗)

最直接的想法是修正資料庫密碼的 Secret。

正確的 base64 編碼方式:

# ❌ 錯誤:會包含換行符號
echo "mypassword" | base64
# 輸出:bXlwYXNzd29yZAo=  (多了 \n)

# ✅ 正確:不包含換行符號
echo -n "mypassword" | base64
# 輸出:bXlwYXNzd29yZA==

# ✅ 或使用 printf
printf "mypassword" | base64
# 輸出:bXlwYXNzd29yZA==

更新 Secret:

# 方法 1:直接編輯
kubectl edit secret strapi-db-secret -n default

# 方法 2:重新建立
kubectl create secret generic strapi-db-secret \
  --from-literal=DB_PASSWORD=mypassword \
  --dry-run=client -o yaml | kubectl apply -f -

刪除 Pod 讓它重建:

kubectl delete pod -l app=my-strapi-prod -n default

結果: 新 Pod 啟動後還是 crash!檢查日誌後發現可能還有其他設定錯誤…

嘗試方案 2:Rollback 到上一版本(成功!)

既然新版本有問題,不如先回到穩定版本。

查看 Deployment 歷史:

kubectl rollout history deployment my-strapi-prod -n default

輸出:

REVISION  CHANGE-CAUSE
1         Initial deployment
2         Update Strapi to v4.15.0
3         Fix database configuration (current, failing)

執行回滾:

# 回滾到上一個版本
kubectl rollout undo deployment my-strapi-prod -n default

# 或回滾到特定版本
kubectl rollout undo deployment my-strapi-prod --to-revision=2 -n default

查看回滾狀態:

kubectl rollout status deployment my-strapi-prod -n default

輸出:

Waiting for deployment "my-strapi-prod" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "my-strapi-prod" rollout to finish: 1 old replicas are pending termination...
deployment "my-strapi-prod" successfully rolled out

確認結果:

kubectl get pods -n default | grep my-strapi-prod

輸出:

NAME                               READY   STATUS    RESTARTS   AGE
my-strapi-prod-7d4b5c8f9d-n5r8t    1/1     Running   0          2m

只剩下 1 個 Pod 了!✅


深入理解:為什麼回滾會成功?

Rollback 流程詳解

flowchart TD
    Start[執行 rollout undo] --> CheckHistory[查詢部署歷史]
    CheckHistory --> FindPrevious[找到上一個 Revision]
    FindPrevious --> UpdateDeployment[更新 Deployment 設定<br/>使用舊版 Revision]
    UpdateDeployment --> CreateOldRS[重啟舊的 ReplicaSet]
    CreateOldRS --> StartOldPod[啟動舊版 Pod]

    StartOldPod --> CheckReady{Pod 就緒?}
    CheckReady -->|Yes| ScaleDownNew[縮減新 ReplicaSet 到 0]
    CheckReady -->|No| Retry[重試]
    Retry --> StartOldPod

    ScaleDownNew --> TerminateNewPod[終止新版 Pod]
    TerminateNewPod --> Cleanup[清理失敗的 Pod]
    Cleanup --> End[✅ 回滾完成]

    style Start fill:#4ade80
    style End fill:#22c55e
    style CheckReady fill:#f97316

關鍵點:

  1. Rollback 不是「恢復」,而是「前進到舊版本」
  2. 實際上是創建一個新的 Revision,內容等同於舊版本
  3. Kubernetes 會保留所有的 ReplicaSet 歷史(預設 10 個)

ReplicaSet 保留機制

# 查看所有 ReplicaSet(包含已縮減到 0 的)
kubectl get rs -n default

# 輸出範例
NAME                         DESIRED   CURRENT   READY   AGE
my-strapi-prod-7d4b5c8f9d    1         1         1       20m  ← 當前運行
my-strapi-prod-8c9a6d7e5f    0         0         0       15m  ← 失敗的版本
my-strapi-prod-6a2b3c4d5e    0         0         0       2h   ← 更早的版本

為什麼保留舊的 ReplicaSet?

  • 快速回滾:不需要重新拉取映像檔或重新建立資源
  • 歷史記錄:可以查看每個版本的設定
  • 漸進式部署:可以手動控制流量分配(Canary Deployment)

設定保留數量:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-strapi-prod
spec:
  revisionHistoryLimit: 10  # 保留 10 個歷史版本(預設值)
  replicas: 1
  # ...

Secret Base64 編碼陷阱與最佳實踐

為什麼 echo 會加入換行符號?

# echo 預設會加入換行符號
$ echo "password" | xxd
00000000: 7061 7373 776f 7264 0a                   password.
                             ^^
                             這是 \n (0x0a)

# echo -n 抑制換行符號
$ echo -n "password" | xxd
00000000: 7061 7373 776f 7264                      password

Secret 建立的最佳實踐

方法 1:使用 –from-literal(推薦)

kubectl create secret generic my-secret \
  --from-literal=username=admin \
  --from-literal=password=mypassword \
  -n default

方法 2:使用 –from-file

# 將密碼寫入檔案(不含換行)
printf "mypassword" > password.txt

# 從檔案建立 Secret
kubectl create secret generic my-secret \
  --from-file=password=password.txt \
  -n default

# 記得刪除明文密碼檔案
rm password.txt

方法 3:使用 –from-env-file

# .env 檔案
cat <<EOF > secret.env
USERNAME=admin
PASSWORD=mypassword
DB_HOST=postgres.example.com
EOF

# 從 env 檔案建立
kubectl create secret generic my-secret \
  --from-env-file=secret.env \
  -n default

rm secret.env

方法 4:手動編寫 YAML(最靈活)

# 先產生 base64 值
DB_PASSWORD=$(echo -n "mypassword" | base64)

# 建立 YAML
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: my-secret
  namespace: default
type: Opaque
data:
  DB_PASSWORD: ${DB_PASSWORD}
EOF

Secret 驗證方法

# 方法 1:解碼 Secret 內容
kubectl get secret my-secret -n default -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
# 應該輸出:mypassword (不含換行)

# 方法 2:檢查 hex dump
kubectl get secret my-secret -n default -o jsonpath='{.data.DB_PASSWORD}' | base64 -d | xxd
# 確認最後沒有 0a (換行符號)

# 方法 3:在 Pod 中驗證
kubectl run debug-pod --rm -it --image=busybox --restart=Never -- sh
# 在 Pod 中
echo $DB_PASSWORD | xxd

實用的 kubectl 排查指令集

查看資源狀態

# 查看 Pod 詳細資訊(包含事件)
kubectl describe pod <pod-name> -n <namespace>

# 查看 Pod 日誌
kubectl logs <pod-name> -n <namespace>

# 查看之前崩潰的 Pod 日誌
kubectl logs <pod-name> -n <namespace> --previous

# 即時查看日誌(類似 tail -f)
kubectl logs -f <pod-name> -n <namespace>

# 查看特定容器的日誌(多容器 Pod)
kubectl logs <pod-name> -c <container-name> -n <namespace>

Deployment 管理

# 查看 Deployment 狀態
kubectl get deployment <deployment-name> -n <namespace>

# 查看 Deployment 詳情
kubectl describe deployment <deployment-name> -n <namespace>

# 查看 Rollout 歷史
kubectl rollout history deployment <deployment-name> -n <namespace>

# 查看特定 Revision 的詳情
kubectl rollout history deployment <deployment-name> --revision=2

# 暫停 Rollout(用於多次修改後一次部署)
kubectl rollout pause deployment <deployment-name> -n <namespace>

# 恢復 Rollout
kubectl rollout resume deployment <deployment-name> -n <namespace>

# 查看 Rollout 狀態
kubectl rollout status deployment <deployment-name> -n <namespace>

ReplicaSet 管理

# 查看所有 ReplicaSet
kubectl get rs -n <namespace>

# 查看特定 Deployment 的 ReplicaSet
kubectl get rs -n <namespace> -l app=<app-label>

# 手動調整 ReplicaSet(不推薦,應該透過 Deployment)
kubectl scale rs <replicaset-name> --replicas=0 -n <namespace>

強制刪除卡住的資源

# 強制刪除 Pod(當 Pod 卡在 Terminating 狀態)
kubectl delete pod <pod-name> --grace-period=0 --force -n <namespace>

# 刪除所有失敗的 Pod
kubectl delete pods --field-selector=status.phase=Failed -n <namespace>

# 刪除所有 CrashLoopBackOff 的 Pod
kubectl get pods -n <namespace> | grep CrashLoopBackOff | awk '{print $1}' | xargs kubectl delete pod -n <namespace>

預防措施與最佳實踐

1. 設定健康檢查

正確設定 Liveness 和 Readiness Probe 可以讓 Kubernetes 更準確地判斷 Pod 狀態。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-strapi-prod
spec:
  template:
    spec:
      containers:
      - name: strapi
        image: my-strapi:v1.0.0
        ports:
        - containerPort: 1337

        # Readiness Probe:Pod 是否準備好接收流量
        readinessProbe:
          httpGet:
            path: /_health
            port: 1337
          initialDelaySeconds: 30  # 啟動後等待 30 秒再檢查
          periodSeconds: 10        # 每 10 秒檢查一次
          timeoutSeconds: 5        # 請求超時時間
          successThreshold: 1      # 成功 1 次即視為就緒
          failureThreshold: 3      # 失敗 3 次才視為未就緒

        # Liveness Probe:Pod 是否還活著
        livenessProbe:
          httpGet:
            path: /_health
            port: 1337
          initialDelaySeconds: 60  # 給足夠的啟動時間
          periodSeconds: 30        # 不需要太頻繁
          timeoutSeconds: 5
          failureThreshold: 3      # 失敗 3 次才重啟

2. 設定資源限制

spec:
  containers:
  - name: strapi
    resources:
      requests:      # 最小保證資源
        memory: "512Mi"
        cpu: "250m"
      limits:        # 最大可用資源
        memory: "1Gi"
        cpu: "500m"

3. 使用 Init Containers 進行預先檢查

spec:
  initContainers:
  - name: wait-for-db
    image: busybox:1.35
    command: ['sh', '-c']
    args:
      - |
        until nc -z postgres-service 5432; do
          echo "Waiting for PostgreSQL..."
          sleep 2
        done
        echo "PostgreSQL is ready!"

4. 設定合理的 Rollout 策略

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0      # 確保至少有 1 個 Pod 運行
      maxSurge: 1            # 最多多出 1 個 Pod

5. 建立 CI/CD 流程驗證

# .gitlab-ci.yml
deploy:
  script:
    # 部署後等待 rollout 完成
    - kubectl apply -f deployment.yaml
    - kubectl rollout status deployment/my-app --timeout=5m

    # 如果失敗自動回滾
    - |
      if [ $? -ne 0 ]; then
        echo "Deployment failed, rolling back..."
        kubectl rollout undo deployment/my-app
        exit 1
      fi

總結與收穫

關鍵要點

技術層面:

  • Kubernetes 的滾動更新是漸進式的:新 Pod 就緒後才會刪除舊 Pod
  • CrashLoopBackOff 是保護機制:避免無限快速重啟消耗資源
  • ReplicaSet 是中間層:Deployment 透過 ReplicaSet 管理 Pod
  • rollout undo 是救命稻草:可以快速回到穩定版本
  • Secret base64 編碼要小心:使用 echo -n--from-literal

排查思路:

  1. 先查看 Pod 狀態 → kubectl get pods
  2. 檢查 ReplicaSet → kubectl get rs
  3. 查看 Pod 日誌 → kubectl logs
  4. 檢查設定 → kubectl describe
  5. 必要時回滾 → kubectl rollout undo

最佳實踐

開發階段:

  • 使用 --dry-run=client 預覽變更
  • 本地測試 Secret 的 base64 編碼
  • 設定合理的健康檢查

部署階段:

  • 使用 kubectl apply 而非 kubectl create
  • 部署後監控 kubectl rollout status
  • 保留足夠的 Revision 歷史

維運階段:

  • 定期檢查 kubectl get rs 清理無用的 ReplicaSet
  • 監控 Pod 重啟次數
  • 建立告警機制捕捉 CrashLoopBackOff

延伸思考

Q: 為什麼不直接刪除失敗的 ReplicaSet? A: 因為它是由 Deployment 管理的。手動刪除後,Deployment 會立即重新創建它。要解決問題,應該修正 Deployment 設定或執行 rollback。

Q: 可以同時運行多個版本的 Pod 嗎? A: 可以!這就是 Canary Deployment 的原理。手動調整新舊 ReplicaSet 的副本數,就能控制流量分配(需要搭配 Service Mesh 或 Ingress)。

Q: rollout undo 會影響正在運行的服務嗎? A: 正常情況下不會,因為是滾動更新。但如果舊版本也有問題,或資源不足,可能會短暫中斷。


參考資源

官方文件:

實用工具:

延伸閱讀: