chundev
日期:2026-06-02 環境:K3s + Prometheus + Alertmanager + CloudNativePG
K3s 叢集出現 40 個 active alerts,其中 33 個是 KubeJobFailed(多數是已恢復的歷史殘留)。備份相關的問題有三種根因:(1) node 重建造成 CNPG failover → PostgreSQL 17 incremental backup 的 WAL summary chain 斷裂;(2) Kustomize overlay 漏 patch PGDATABASE 導致 pgdump 靜默寫空檔;(3) 專案關閉後 CronJob 沒跟著 suspend。本文記錄完整的查詢、分類、清理、修復流程。
Alertmanager 在叢集內通常沒有 Ingress 暴露,但可以透過 kubectl get --raw 走 service proxy 查詢 API:
# 查詢所有 active、未被 silence、未被 inhibit 的 alerts
kubectl get --raw '/api/v1/namespaces/monitoring/services/alertmanager-operated:9093/proxy/api/v2/alerts?active=true&silenced=false&inhibited=false'
⚠️ 這會回傳一大坨 JSON,需要用 python3 或 jq 處理。
kubectl get --raw '/api/v1/namespaces/monitoring/services/alertmanager-operated:9093/proxy/api/v2/alerts?active=true&silenced=false&inhibited=false' \
| python3 -c "
import sys, json
from collections import Counter
alerts = json.load(sys.stdin)
print(f'Total active alerts: {len(alerts)}\n')
names = Counter(a['labels'].get('alertname','?') for a in alerts)
for name, count in names.most_common():
print(f' {name}: {count}')
"
kubectl get --raw '...' | python3 -c "
import sys, json
from collections import defaultdict
alerts = json.load(sys.stdin)
ns_jobs = defaultdict(list)
for a in alerts:
if a['labels'].get('alertname') == 'KubeJobFailed':
ns = a['labels'].get('namespace','?')
job = a['labels'].get('job_name','?')
ns_jobs[ns].append(job)
for ns in sorted(ns_jobs):
print(f'[{ns}] ({len(ns_jobs[ns])} alerts)')
for j in sorted(ns_jobs[ns]):
print(f' - {j}')
"
拿到 alert 列表後,按以下分類處理:
| Alert | 判斷 | 處理 |
|---|---|---|
| Watchdog | 設計上恆亮(Prometheus 健康檢查) | ✅ 忽略 |
| InfoInhibitor | Alertmanager 內部 | ✅ 忽略 |
| KubeJobFailed | 最大噪音來源,見下方詳細說明 | 🔍 逐一判斷 |
| KubeMemoryOvercommit / KubeCPUOvercommit | node 增減後資源重新分配 | ⚠️ 觀察,穩定後消退 |
| PostgreSQLPgdumpBackupOverdue | 備份逾期 | 🔧 必修 |
| PostgreSQLPgdumpJobFailed | 備份 Job 失敗 | 🔧 必修 |
Prometheus 的 kube_job_status_failed > 0 只要 Job 物件還存在且 failed > 0,就會一直報。即使同一個 CronJob 後來的 run 已經成功,舊的 Failed Job 也照報不誤。
CronJob 的 failedJobsHistoryLimit(常設 3-5)會保留歷史失敗 Job 物件 → 放大 alert 數量。
對每個 KubeJobFailed 的 Job:
1. 找出它屬於哪個 CronJob(看 Job 名稱前綴)
2. 查同一個 CronJob 是否有「比它更晚」且「成功」的 Job
→ 有:已恢復,可以安全 `kubectl delete job`
→ 沒有:真失敗,需要修
# 查 pvc-autoscaler 在 kymo-erp 的成功 jobs
kubectl get jobs -n kymo-erp --no-headers | grep pvc-autoscaler | grep Complete | tail -3
# 查 pg-incremental-backup 在 kymo-isms 的成功 jobs
kubectl get jobs -n kymo-isms -l app=pg-incremental-backup --field-selector=status.successful=1 --sort-by=.metadata.creationTimestamp | tail -3
# 刪除特定 namespace 下的特定 failed jobs
kubectl delete jobs -n <namespace> <job1> <job2> <job3>
# 多個 namespace 批次清理(fish shell 不支援 ${var##*/},用 bash -c 包)
bash -c '
for ns in kymo-erp kymo-isms kymo-task setraining; do
kubectl delete jobs -n $ns pvc-autoscaler-29671300 pvc-autoscaler-29671305 2>/dev/null
echo "[$ns] cleaned"
done
'
⚠️ fish shell 注意:bash 的 ${var##*/} 參數展開在 fish 裡不能用,批次操作要用 bash -c '...' 包起來。
刪掉 Job → Prometheus 下一次 scrape(通常 15-30 秒)不再看到 kube_job_status_failed metric → alert 進入 pending → 過 resolve_timeout(預設 5 分鐘)後自動 resolve。
症狀:
pg_basebackup: error: could not initiate base backup: ERROR:
WAL summaries are required on timeline 1 from 16/3B000028 to 16/870000A0,
but the summaries for that timeline and LSN range are incomplete
DETAIL: The first unsummarized LSN in this range is 16/3B000028.
根因:
PostgreSQL 17 的 incremental backup 依賴 WAL summarizer 背景程序。它持續掃描 WAL 記錄,為每段 LSN 範圍產生二進位摘要檔(存在 pg_wal/summaries/),記錄哪些 relation block 被修改過。pg_basebackup --incremental=<MANIFEST> 會要求 server 提供從上次備份 LSN 到當前 LSN 的完整 summary chain。
當 CNPG failover 發生(例如 node 重建後 pod 重新排程),新 primary 是從 replica promote 上來的,它的 pg_wal/summaries/ 裡不含舊 timeline 的完整摘要,summary chain 斷裂,所有 incremental backup 必定失敗。
確認方法:
# 手動跑一次 incremental backup 看錯誤
kubectl run debug-incr --rm -i --restart=Never -n <namespace> \
--image=postgres:17-alpine --overrides='{
"spec": {
"containers": [{
"name": "debug",
"image": "postgres:17-alpine",
"command": ["sh", "-c",
"MANIFEST_PATH=$(readlink -f /backups/latest-manifest) && pg_basebackup --incremental=$MANIFEST_PATH --checkpoint=fast --pgdata=/tmp/test --no-password --wal-method=none --verbose 2>&1 | tail -5"],
"env": [
{"name":"PGHOST","value":"postgres-cluster-rw"},
{"name":"PGPORT","value":"5432"},
{"name":"PGDATABASE","value":"<dbname>"},
{"name":"PGUSER","valueFrom":{"secretKeyRef":{"name":"postgres-cluster-app","key":"username"}}},
{"name":"PGPASSWORD","valueFrom":{"secretKeyRef":{"name":"postgres-cluster-app","key":"password"}}}
],
"volumeMounts": [{"name":"backup","mountPath":"/backups"}]
}],
"volumes": [{"name":"backup","persistentVolumeClaim":{"claimName":"postgres-backup-pvc"}}]
}
}' --timeout=120s
修復:重跑 full backup 重建 manifest
# 從 CronJob 建立一次性 full backup Job
kubectl create job --from=cronjob/postgres-basebackup \
postgres-basebackup-fix-$(date +%m%d%H%M) -n <namespace>
# 等待完成
kubectl get jobs -n <namespace> -w | grep basebackup-fix
# 完成後驗證 incremental backup 能跑
kubectl create job --from=cronjob/pg-incremental-backup \
pg-incr-verify-$(date +%H%M) -n <namespace>
Full backup 會產生新的 backup_manifest 並更新 latest-manifest symlink,後續 incremental backup 基於新 manifest 就能重新建立 chain。
症狀:
pgdump 檔案只有 20 bytes(空壓縮檔),CronJob 持續跑但產出都是空的。
# 檢查 NFS 上的 pgdump 檔案大小
kubectl run debug-nfs --rm -i --restart=Never -n <namespace> \
--image=busybox --overrides='{
"spec": {
"containers": [{
"name": "d", "image": "busybox",
"command": ["sh", "-c", "ls -lh /backups/pgdump*.sql.gz | sort -k5 -rn | head -10"],
"volumeMounts": [{"name":"b","mountPath":"/backups"}]
}],
"volumes": [{"name":"b","persistentVolumeClaim":{"claimName":"postgres-backup-pvc"}}]
}
}'
20 bytes 的 .sql.gz = pg_dump 失敗但 gzip 包了空 stdout。
根因:
Kustomize base 的 pgdump CronJob 裡 PGDATABASE 預設為 app,但某些專案的 CNPG Cluster bootstrap 設了不同的 database 名稱(如 dfaa)。如果 overlay 沒有 patch 這個 env var,pg_dump 會連到不存在的 database app → 失敗 → 空 gzip 檔。
確認方法:
# 直接測 pg_dump 能否連上
kubectl run debug-pgdump --rm -i --restart=Never -n <namespace> \
--image=postgres:17-alpine --overrides='{
"spec": {
"containers": [{
"name": "d", "image": "postgres:17-alpine",
"command": ["sh", "-c", "pg_dump --verbose --format=plain --no-owner --no-acl 2>&1 | head -5"],
"env": [
{"name":"PGHOST","value":"postgres-cluster-rw"},
{"name":"PGPORT","value":"5432"},
{"name":"PGDATABASE","value":"app"},
{"name":"PGUSER","valueFrom":{"secretKeyRef":{"name":"postgres-cluster-app","key":"username"}}},
{"name":"PGPASSWORD","valueFrom":{"secretKeyRef":{"name":"postgres-cluster-app","key":"password"}}}
]
}]
}
}' --timeout=60s
# 如果看到 FATAL: database "app" does not exist → 確認正確 DB 名稱
kubectl exec -n <namespace> <cnpg-pod> -c postgres -- psql -U postgres -l
修復:
# 立即修:kubectl patch live CronJob
kubectl patch cronjob postgres-pgdump-backup -n <namespace> \
--type='json' \
-p='[{"op":"replace","path":"/spec/jobTemplate/spec/template/spec/containers/0/env/2/value","value":"<correct-db>"}]'
# 持久化:在 Kustomize overlay 加 patch(才能在 GitOps 重新 apply 時保留)
Kustomize overlay 的 patch 範例:
patches:
- target:
kind: CronJob
name: postgres-pgdump-backup
patch: |
- op: replace
path: /spec/jobTemplate/spec/template/spec/containers/0/env/2/value
value: "correct-database-name"
- target:
kind: CronJob
name: postgres-basebackup
patch: |
- op: replace
path: /spec/jobTemplate/spec/template/spec/containers/0/env/2/value
value: "correct-database-name"
- target:
kind: CronJob
name: pg-incremental-backup
patch: |
- op: replace
path: /spec/jobTemplate/spec/template/spec/containers/0/env/2/value
value: "correct-database-name"
症狀: pgdump / incremental backup 的 CronJob SUSPEND=True,但還有殘留的 failed Job 物件在噴 KubeJobFailed alert。Overdue alert 也持續報因為不再有成功的備份記錄。
處理:
# 確認哪些 CronJob 被 suspend
kubectl get cronjob -n <namespace>
# 刪除殘留的 failed jobs(stops KubeJobFailed alerts)
kubectl get jobs -n <namespace> --field-selector=status.successful=0
kubectl delete jobs -n <namespace> <job1> <job2> ...
# Overdue alert 需要 silence 或從 PrometheusRule 排除該 namespace
# 重新查 alert 數量
kubectl get --raw '/api/v1/namespaces/monitoring/services/alertmanager-operated:9093/proxy/api/v2/alerts?active=true&silenced=false&inhibited=false' \
| python3 -c "
import sys, json
from collections import Counter
alerts = json.load(sys.stdin)
print(f'Total: {len(alerts)}')
for name, count in Counter(a['labels'].get('alertname','?') for a in alerts).most_common():
print(f' {name}: {count}')
"
預期:KubeJobFailed 在幾分鐘內消退(Alertmanager resolve_timeout),備份 Job 下一次排程跑應該成功。
failedJobsHistoryLimit 保留的歷史 Job 會持續噴 alert,需要定期清理。set -euo pipefail + if pg_dump | gzip 模式有盲點 — pg_dump 連線失敗時回傳非零,但 if 會捕捉它走 else 分支,而 gzip 已經在 stdout 端建了空壓縮檔。雖然腳本後段有 MIN_SIZE 檢查,但 pg_dump 連不上 DB 時連 BackupRecord insert 也會失敗(同一個 DB),失敗記錄寫不進去。pg_basebackup --incremental 機制詳解wal_summary_keep_time 預設 10 天