事發:部署成功但服務掛了
一次例行的 Strapi 後端部署,Jenkins build 成功、Docker image 推上 ECR、kubectl set image 也順利執行。但 rollout 等了 300 秒後超時。
以下是整個部署流程,可以看到問題出在最後一步:
具體的錯誤訊息:
error: timed out waiting for the condition
Jenkins 回報「部署失敗,已自動回滾」。奇怪的是,build 每一步都成功了,問題出在 rollout 階段。
調查:Pod 起不來的真正原因
問題排查的過程如下,從 pod 狀態開始一路追到節點層級:
查看 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
這個連鎖反應的完整過程:
為什麼磁碟會滿?EKS 節點用的是 t4g.medium,配了 40G EBS。每次部署都會拉新的 container image(約 1GB),但 containerd 不會自動清理舊版 image。部署了幾十個版本後,image 快取就把磁碟塞滿了。
這就是為什麼問題在部署時爆發——拉新 image 剛好把最後的空間用光。
緊急恢復:Reboot 節點
恢復的流程很直接:
當下最快的恢復方式是 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。
反思:為什麼沒有提早發現?
完整的預防架構應該長這樣:
這次事故暴露了幾個盲點:
缺少磁碟監控:沒有設定 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:
- 監控磁碟使用量,在 80% 就告警
- 定期清理 container image,用 CronJob 自動化
- 考慮 fallback 機制,單節點架構至少要有手動切換的方案
