Share Notes

chundev

View the Project on GitHub latteouka/share-notes

K3s Alert Triage 實戰手冊 — 查詢、分類、清理 Stale Jobs、修復備份

日期:2026-06-02 環境:K3s + Prometheus + Alertmanager + CloudNativePG


TL;DR

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。本文記錄完整的查詢、分類、清理、修復流程。


第一步:查詢 Active Alerts

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,需要用 python3jq 處理。

按 alertname 分群統計

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}')
"

按 namespace + alertname 分群,看每個 alert 的具體 job

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 列表後,按以下分類處理:

Alert 判斷 處理
Watchdog 設計上恆亮(Prometheus 健康檢查) ✅ 忽略
InfoInhibitor Alertmanager 內部 ✅ 忽略
KubeJobFailed 最大噪音來源,見下方詳細說明 🔍 逐一判斷
KubeMemoryOvercommit / KubeCPUOvercommit node 增減後資源重新分配 ⚠️ 觀察,穩定後消退
PostgreSQLPgdumpBackupOverdue 備份逾期 🔧 必修
PostgreSQLPgdumpJobFailed 備份 Job 失敗 🔧 必修

第三步:KubeJobFailed 噪音清理

為什麼 KubeJobFailed 是噪音?

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`
     → 沒有:真失敗,需要修

查某個 CronJob 近期是否有成功

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

批次清理已恢復的 stale jobs

# 刪除特定 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 後 Alert 何時消退?

刪掉 Job → Prometheus 下一次 scrape(通常 15-30 秒)不再看到 kube_job_status_failed metric → alert 進入 pending → 過 resolve_timeout(預設 5 分鐘)後自動 resolve。


第四步:備份問題修復

問題 A — Incremental Backup WAL Summary Chain 斷裂

症狀:

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。


問題 B — pgdump 靜默失敗(空檔案)

症狀:

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"

問題 C — 專案關閉但 CronJob 沒跟著關

症狀: 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 下一次排程跑應該成功。


學到的事


參考資料