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

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

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

Mermaid Diagram

具體的錯誤訊息:

error: timed out waiting for the condition

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

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

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

Mermaid Diagram

查看 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

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

Mermaid Diagram

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

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

緊急恢復:Reboot 節點

恢復的流程很直接:

Mermaid Diagram

當下最快的恢復方式是 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。

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

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

Mermaid Diagram

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

缺少磁碟監控:沒有設定 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 機制,單節點架構至少要有手動切換的方案