事發:部署成功但服務掛了

一次例行的 Strapi 後端部署,Jenkins build 成功、Docker image 推上 ECR、kubectl set image 也順利執行。但 rollout 等了 300 秒後超時。

以下是整個部署流程,可以看到問題出在最後一步:

flowchart LR
    A[Jenkins Build] -->|✅ 成功| B[推送 Image 到 ECR]
    B -->|✅ 成功| C[kubectl set image]
    C -->|✅ 成功| D[rollout status 等待]
    D -->|❌ 超時 300s| E[自動回滾]

    classDef success fill:#d4edda,stroke:#28a745,color:#155724
    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24

    class A,B,C success
    class D,E error

具體的錯誤訊息:

error: timed out waiting for the condition

Jenkins 回報「部署失敗,已自動回滾」。奇怪的是,build 每一步都成功了,問題出在 rollout 階段。

調查:Pod 起不來的真正原因

問題排查的過程如下,從 pod 狀態開始一路追到節點層級:

flowchart TD
    A[部署超時] --> B[檢查 Pod 狀態]
    B --> C{Pod 狀態}
    C -->|Pending| D[kubectl describe pod]
    C -->|Terminating| E[節點可能有問題]
    D --> F[FailedScheduling]
    F --> G[檢查節點狀態]
    E --> G
    G --> H{節點狀態}
    H -->|NotReady + env=stg| I[問題節點]
    H -->|Ready + env=prod| J[Node Selector 不符]
    I --> K[kubectl get events]
    K --> L[🔍 發現 DiskPressure]

    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24
    classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef info fill:#d1ecf1,stroke:#17a2b8,color:#0c5460

    class I error
    class J warning
    class L info

查看 pod 狀態,發現新 pod 卡在 Pending,舊 pod 卡在 Terminating

$ kubectl get pods
NAME                              READY   STATUS        AGE
strapi-stg-5896c67c-kvrn2        0/1     Pending       88s
strapi-stg-69f7c958b7-kcbc7      1/1     Terminating   44h
web-stg-7bb99cfb54-x8j99         0/1     Pending       88s

直覺反應是看 pod events:

$ kubectl describe pod strapi-stg-5896c67c-kvrn2
Events:
  Warning  FailedScheduling  0/2 nodes are available:
    1 node(s) had untolerated taint {node.kubernetes.io/unreachable: }
    1 node(s) didn't match Pod's node affinity/selector

兩個節點都不能用。 一個 unreachable,另一個 node selector 不符(env=stg vs env=prod)。

確認節點狀態:

$ kubectl get nodes
NAME              STATUS     LABELS
ip-192-168-xxx    NotReady   env=stg     # ← 掛了
ip-192-168-yyy    Ready      env=prod    # ← 但 stg pod 排不上去

根因:磁碟空間不足 (DiskPressure)

查看 K8s events 揭露了真正的根因:

$ kubectl get events --sort-by='.lastTimestamp'
NodeHasDiskPressure   Node ip-192-168-xxx status is now: NodeHasDiskPressure
NodeNotReady          Node ip-192-168-xxx status is now: NodeNotReady

這個連鎖反應的完整過程:

flowchart TD
    A[每次部署拉新 Image ≈ 1GB] --> B[舊 Image 未清理]
    B --> C[磁碟使用率逐漸上升]
    C --> D[磁碟空間 100%]
    D --> E[Kubelet 標記 DiskPressure]
    E --> F[節點狀態變為 NotReady]
    F --> G[現有 Pod 被驅逐]
    G --> H[新 Pod FailedScheduling]
    H --> I[STG 環境完全中斷]

    subgraph 累積階段
        A
        B
        C
    end

    subgraph 爆發階段
        D
        E
        F
    end

    subgraph 影響階段
        G
        H
        I
    end

    classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24

    class D warning
    class I error

為什麼磁碟會滿?EKS 節點用的是 t4g.medium,配了 40G EBS。每次部署都會拉新的 container image(約 1GB),但 containerd 不會自動清理舊版 image。部署了幾十個版本後,image 快取就把磁碟塞滿了。

這就是為什麼問題在部署時爆發——拉新 image 剛好把最後的空間用光。

緊急恢復:Reboot 節點

恢復的流程很直接:

flowchart LR
    A[找到 EC2 Instance ID] --> B[aws ec2 reboot-instances]
    B --> C[等待 ~2 分鐘]
    C --> D[節點恢復 Ready]
    D --> E[Pod 自動重新排程]
    E --> F[服務恢復正常]

    G[磁碟使用率 ~100%] -.->|reboot 後| H[磁碟使用率 42%]

    classDef success fill:#d4edda,stroke:#28a745,color:#155724
    classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24

    class F,H success
    class G error

當下最快的恢復方式是 reboot 節點,讓 containerd 重新整理:

# 找到 EC2 instance
$ aws ec2 describe-instances \
    --filters "Name=private-ip-address,Values=192.168.xxx.xxx" \
    --query "Reservations[].Instances[].InstanceId"
["i-0a1b2c3d4e5f67890"]

# Reboot
$ aws ec2 reboot-instances --instance-ids i-0a1b2c3d4e5f67890

約 2 分鐘後節點回來,pod 自動重新排程:

$ kubectl get nodes
ip-192-168-xxx   Ready    env=stg   # ✅ 恢復了

$ kubectl get pods
strapi-stg-764756c8fc-mj9vq   1/1   Running   # ✅ 服務正常

Reboot 後磁碟使用量從接近 100% 降到 42%。containerd 在啟動時清理了部分暫存和未使用的層。

預防:設定自動清理 CronJob

治標之後要治本。在 K8s 上建立 CronJob,定期用 crictl rmi --prune 清理未被任何 pod 使用的舊 image:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: image-cleanup
spec:
  schedule: "0 20 * * *"  # UTC 20:00 = 台北凌晨 4:00
  jobTemplate:
    spec:
      template:
        spec:
          nodeSelector:
            env: stg
          containers:
          - name: cleanup
            image: alpine:3.19
            command:
            - nsenter
            - -t
            - "1"
            - -m
            - -u
            - -i
            - -n
            - --
            - sh
            - -c
            - "crictl rmi --prune"
            securityContext:
              privileged: true
          restartPolicy: OnFailure

關鍵設計:用 nsenter 進入 host 的 namespace,才能存取 host 上的 crictl。普通容器看不到 host 的 containerd。

反思:為什麼沒有提早發現?

完整的預防架構應該長這樣:

flowchart TD
    subgraph 監控層
        CW[CloudWatch Alarm<br/>磁碟使用率 > 80%]
        CW -->|告警| SNS[SNS 通知]
    end

    subgraph 自動清理層
        CJ[CronJob 每日凌晨 4:00]
        CJ --> NS[nsenter 進入 Host]
        NS --> CR[crictl rmi --prune]
        CR --> CLEAN[清理未使用的 Image]
    end

    subgraph Kubelet 層
        KL[imageGCHighThresholdPercent: 85%]
        KL --> GC[自動 Image GC]
    end

    subgraph 架構層
        FB[Fallback 機制]
        FB --> LB[緊急時標記其他節點<br/>kubectl label node env=stg]
    end

    classDef info fill:#d1ecf1,stroke:#17a2b8,color:#0c5460
    classDef success fill:#d4edda,stroke:#28a745,color:#155724
    classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef primary fill:#cce5ff,stroke:#007bff,color:#004085

    class CW,SNS info
    class CJ,NS,CR,CLEAN success
    class KL,GC warning
    class FB,LB primary

這次事故暴露了幾個盲點:

缺少磁碟監控:沒有設定 CloudWatch alarm 監控節點磁碟使用率。如果在 80% 就告警,就不會等到 100% 才爆炸。

單點故障的節點架構:每個環境只有一個節點,掛了就全掛。但增加節點意味著成本翻倍,對小團隊來說需要權衡。一個折衷方案是允許 pod 在其他環境節點上當 fallback:

# 緊急時可以讓另一個節點暫時跑 stg
kubectl label node ip-192-168-yyy env=stg --overwrite

Image 生命週期管理:containerd 預設不限制 image 快取大小。Kubelet 有 imageGCHighThresholdPercent(預設 85%)和 imageGCLowThresholdPercent(預設 80%)設定,理論上會自動清理。但在某些 EKS 版本上,這個機制可能沒有正確運作。

結論

DiskPressure 是 K8s 叢集中最容易被忽略卻最致命的問題之一。每次部署都在累積 image 快取,像是定時炸彈。預防措施只需要一個 CronJob,但事後恢復可能要搶救整個環境。

三個 takeaway:

  1. 監控磁碟使用量,在 80% 就告警
  2. 定期清理 container image,用 CronJob 自動化
  3. 考慮 fallback 機制,單節點架構至少要有手動切換的方案