Share Notes

chundev

View the Project on GitHub latteouka/share-notes

從「登入失敗」一路追到 VMware vmballoon kernel soft lockup — k3s + Longhorn + Postgres 三層 debugging 完整指令導覽

日期:2026-05-25 環境:k3s on VMware vSphere + Longhorn (single replica) + CloudNativePG


TL;DR

正式環境 Web 應用突然全部登入失敗,網頁打得開、登入一定錯。一路追:

  1. App 層: Next.js 印 prisma:error could not open file "global/pg_filenode.map": Input/output error
  2. DB 層: 連 PostgreSQL 自己的 local socket 都 FATAL: I/O error,pod 卻顯示 1/1 Ready(probe 沒抓到)
  3. Storage 層: Longhorn volume state=detached, robustness=faulted,replica failedAt=2026-05-22T14:47:08Z
  4. Kernel 層: node 上 journalctl -k 顯示 watchdog: BUG: soft lockup ... vmballoon_inflate ... [vmw_balloon]VMware host memory pressure → balloon driver 在 guest VM 內 inflate 卡死 kernel CPU → Longhorn instance-manager 失聯 → replica 被標 faulted → 之後檔案系統 I/O error

整段故障被以下三個獨立疏失放大成 4 天無感停機:

這篇是完整指令導覽,可以照著重跑一遍。


環境


症狀

使用者回報:「網頁打得開但登入一定失敗。」截圖看登入後立刻錯誤,看不出 server-side 內容。

prisma:error could not open file "global/pg_filenode.map": Input/output error
[ldap-plugin] Error: Error [DriverAdapterError]: could not open file "global/pg_filenode.map": Input/output error
    at N.onError (.next/server/chunks/_0jhm2f9._.js:1:11599)
  clientVersion: '7.4.2',
  [cause]: {
    originalCode: '58030',
    originalMessage: 'could not open file "global/pg_filenode.map": Input/output error',
    kind: 'postgres',
    code: '58030',
    severity: 'FATAL',
    message: 'could not open file "global/pg_filenode.map": Input/output error',
  }

SQLSTATE 58030 = io_error(PostgreSQL errcodes 文件)。


Stage 1 — 確認應用層症狀,先別動 code

第一個想知道的事:pod 是否在跑?最近有沒有重新部署?code 變更會不會是原因?

# Application pod 狀態(讀 your-namespace, your-app-label 替換)
kubectl get pods -n NAMESPACE -o wide
kubectl get deployments -n NAMESPACE
git log --oneline -10
kubectl get events -n NAMESPACE --sort-by='.lastTimestamp' | tail -30

關鍵欄位:

這次的結果: pod AGE 3d15h、RESTARTS=0,最近 commit 全是依賴升級。所以 bug 不在 app code。


Stage 2 — 讀 app log,找到實際 error

# 完整 200 行
kubectl logs -n NAMESPACE POD_NAME --tail=200

# 過濾關鍵字(auth/db/error/io)
kubectl logs -n NAMESPACE POD_NAME --tail=500 \
  | grep -iE "error|fail|auth|prisma|io.error|timeout" \
  | tail -50

這次撈到 prisma:error could not open file "global/pg_filenode.map": Input/output error

pg_filenode.map 是什麼? PostgreSQL PGDATA/global/pg_filenode.map 是 system catalog 從 OID 對應到實際 disk file 的關鍵元資料檔(官方說明)。PostgreSQL 啟動時必讀。如果這個檔讀不到,連 system catalog 都查不到 → 任何 query 都 FATAL。

而且 error 是 Input/output error(POSIX errno EIO)→ 不是 “permission denied”、不是 “no such file” → 是 block device 層的 I/O 錯誤,問題不在 PostgreSQL,在它底下的 storage。


Stage 3 — 直接連 DB 驗證(連自己的 unix socket 都不行)

# 進 postgres container 跑 psql(CloudNativePG 的 unix socket 在 /controller/run/)
kubectl exec -n NAMESPACE POSTGRES_POD -c postgres -- \
  psql -U postgres -d YOUR_DB -c "SELECT 1;"

這次的結果:

psql: error: connection to server on socket "/controller/run/.s.PGSQL.5432" failed:
FATAL: could not open file "global/pg_filenode.map": Input/output error
command terminated with exit code 2

重點推論:

但這時 pod 顯示 1/1 Ready 是怎麼回事?看 readiness probe:

kubectl get pod -n NAMESPACE POSTGRES_POD -o jsonpath='{.spec.containers[?(@.name=="postgres")].readinessProbe}' \
  | python3 -m json.tool
{
    "failureThreshold": 3,
    "httpGet": {
        "path": "/readyz",
        "port": 8000,
        "scheme": "HTTPS"
    },
    "periodSeconds": 10,
    "successThreshold": 1,
    "timeoutSeconds": 5
}

CloudNativePG 內建 /readyz HTTPS endpoint,內部用 pg_isready 檢查pg_isready 只測「postmaster 是否在 listen」,不會送 SQL。所以 postmaster 還活著但底下 PGDATA filesystem 已死的情況,probe 完全感覺不到。

這是設計上的 readiness 失明點。


Stage 4 — 進到 Storage 層,看 Longhorn volume 狀態

CNPG 把 PGDATA 存在 PVC,PVC 對應到 Longhorn volume。

# 1) 找出 postgres pod 對應的 PVC
kubectl get pvc -n NAMESPACE

# 2) PVC name 對應到 PV/volume 名稱(在 Longhorn 內 volume name = pvc UUID)
kubectl get pvc -n NAMESPACE PVC_NAME -o jsonpath='{.spec.volumeName}'

# 3) 看 Longhorn cluster 狀態
kubectl get cluster -n NAMESPACE
# (CloudNativePG 的 cluster CRD,不是 Longhorn 的)

# 4) Longhorn volume 健康度
kubectl get volume.longhorn.io -n longhorn-system VOLUME_NAME \
  -o jsonpath='State={.status.state} Robustness={.status.robustness} NodeID={.spec.nodeID}{"\n"}'

# 5) Longhorn replica(block-level data 實體存放)
kubectl get replicas.longhorn.io -n longhorn-system \
  -o jsonpath='{range .items[?(@.spec.volumeName=="VOLUME_NAME")]}{.metadata.name}{" node="}{.spec.nodeID}{" state="}{.status.currentState}{" failedAt="}{.spec.failedAt}{" healthyAt="}{.spec.healthyAt}{"\n"}{end}'

這次的結果:

State=detached Robustness=faulted NodeID=
ReplicaState=stopped failedAt=2026-05-22T14:47:08Z healthyAt=2026-01-11T03:30:34Z

也看一下 Longhorn engine / disk 是否還健康(為了排除磁碟空間爆掉):

# 看每個 node 上的 disk 健康度
for n in NODE1 NODE2 NODE3 NODE4; do
  echo "--- $n ---"
  kubectl get nodes.longhorn.io -n longhorn-system $n \
    -o jsonpath='{range .status.diskStatus.*}{.diskUUID}{" | scheduled="}{.storageScheduled}{" available="}{.storageAvailable}{" | conds: "}{range .conditions[*]}{.type}={.status}{"("}{.reason}{") "}{end}{"\n"}{end}'
done

這次發現的副產品:3 個 node 都掛了同一個 NFS path 當 Longhorn disk → filesystem UUID 共用 → 全部 Ready=False(DiskFilesystemChanged)。跟此次故障無關但是個 latent 設計問題(NFS 不該當 Longhorn disk,因為 Longhorn 期望 disk 是 node-local block device)。


Stage 5 — 試 Salvage(最小破壞性的復原嘗試)

Longhorn 的 salvage 機制:當 volume robustness=faulted 時,UI 上應該出現 “Salvage” 按鈕讓使用者選 last-known-good replica。但是當 volume 設定 revisionCounterDisabled: true 時,Longhorn 為了資料一致性不會自動 salvage,必須手動操作。

UI 也可能沒按鈕(不同版本行為不一)。可以直接從 CLI 操作。

5.1 — 先備份 yaml(reversibility)

mkdir -p /tmp/longhorn-salvage && cd /tmp/longhorn-salvage
kubectl get volumes.longhorn.io -n longhorn-system VOLUME_NAME -o yaml > volume.before.yaml
kubectl get replicas.longhorn.io -n longhorn-system REPLICA_NAME -o yaml > replica.before.yaml

5.2 — 清掉 replica 的 failedAt,並把 healthyAt 設為 lastHealthyAt

kubectl patch replicas.longhorn.io -n longhorn-system REPLICA_NAME \
  --type=merge -p '{"spec":{"failedAt":"","healthyAt":"LAST_HEALTHY_TIMESTAMP"}}'

LAST_HEALTHY_TIMESTAMPkubectl describe replicaLast Healthy At: 欄位。

5.3 — 等 Longhorn 自動重新評估、attach、然後 CNPG operator 自動重建 pod

# Volume 應該變 attached + healthy
kubectl get volume.longhorn.io -n longhorn-system VOLUME_NAME \
  -o jsonpath='State={.status.state} Robustness={.status.robustness} NodeID={.spec.nodeID}{"\n"}'

# Pod 重啟(CNPG 偵測到 volume 健康後 reconcile)
kubectl wait --for=condition=Ready pod -n NAMESPACE POSTGRES_POD --timeout=60s

# 看 postgres recovery log
kubectl logs -n NAMESPACE POSTGRES_POD -c postgres --tail=30 \
  | grep -E "interrupted|recovery|redo|ready to accept"

這次的結果(PostgreSQL 自動 WAL recovery,0 資料損失):

LOG: database system was interrupted; last known up at 2026-05-22 14:05:01 UTC
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 27/72004070
LOG: redo done at 27/72004178
LOG: checkpoint complete: ... lsn=27/73000028
LOG: database system is ready to accept connections

重點推論:


Stage 6 — 驗證 DB 可查 + 資料對得起時間軸

# 應用本身的 sanity check
kubectl exec -n NAMESPACE POSTGRES_POD -c postgres -- \
  psql -U postgres -d YOUR_DB -c "SELECT count(*) FROM \"User\";"

# 最後一筆操作時間(如果有 audit log table)
kubectl exec -n NAMESPACE POSTGRES_POD -c postgres -- \
  psql -U postgres -d YOUR_DB -c "SELECT MAX(\"createdAt\") FROM \"AuditLog\";"

# App 端是否還在噴 error
kubectl logs -n NAMESPACE APP_POD --tail=50 --since=30s \
  | grep -iE "prisma:error|error|FATAL"

AuditLog 的最後一筆時間應該跟「使用者最後一次使用系統」吻合 — 若吻合 = 證實這段時間使用者真的沒成功操作(不是有人成功但你沒看到)。


Stage 7 — 找其他可能受影響的 cluster(很關鍵的一步)

如果故障是 storage 層的 node-level 事件,同一個 node 上其他 PVC 也可能 fault。看 CNPG operator log 一下子就會發現別的 namespace 也在炸:

# 看整個 CNPG operator log,找 prisma 之外的同樣錯誤
kubectl logs -n cnpg-system -l app.kubernetes.io/name=cloudnative-pg --tail=200 \
  | grep -iE "fail|error|warning|disk|pg_filenode" \
  | head -20

這次撈到:

namespace=ANOTHER_NAMESPACE name=postgres-cluster
error: ...FATAL: could not open file "global/pg_filenode.map": Input/output error

另一個 namespace 也有同樣症狀,pod 也是 1/1 Running 偽裝健康。同樣 salvage 流程救起來。

對於 single-node Longhorn 上跑很多 PVC 的情況,節點層事件會放大成多 PVC 同時死


Stage 8 — 進 node 抓 journal(找真正的 root cause)

到這步已經把 application 修復了,但還沒回答「為什麼 5/22 22:47 那一刻 Longhorn replica 會被標 faulted」

要看 node 上的 systemd journal、kernel ring buffer、k3s-agent log。kubectl debug node 是最方便的方式,但 attach 模式會卡住(如果 stdin 沒接),改用自己寫 manifest 起 privileged pod 並 chroot 進 host:

# 1) 寫一個 forensic pod manifest(hostPID + privileged + mount host root)
cat <<'EOF' > /tmp/node-forensic.yaml
apiVersion: v1
kind: Pod
metadata:
  name: node-forensic
  namespace: default
spec:
  nodeName: TARGET_NODE
  hostPID: true
  hostNetwork: true
  restartPolicy: Never
  containers:
  - name: forensic
    image: ubuntu:24.04
    securityContext:
      privileged: true
    command: ["sleep", "3600"]
    volumeMounts:
    - name: host-root
      mountPath: /host
  volumes:
  - name: host-root
    hostPath:
      path: /
EOF

kubectl apply -f /tmp/node-forensic.yaml
kubectl wait --for=condition=Ready pod node-forensic --timeout=30s

# 2) 從 forensic pod chroot 進 host 跑指令
kubectl exec node-forensic -- chroot /host bash -c 'COMMAND'

# 用完一定要刪
kubectl delete pod node-forensic --grace-period=0 --force

先確認 journal 有保留到故障時段(systemd persistent journal 必須有設):

kubectl exec node-forensic -- chroot /host bash -c '
echo "=== Journal disk usage ==="; journalctl --disk-usage
echo "=== Boot list ==="; journalctl --list-boots
echo "=== Oldest entry ==="; journalctl --no-pager -o short-iso -n 1 --reverse | head -2
echo "=== Newest entry ==="; journalctl --no-pager -o short-iso -n 1 | head -2
'

若 oldest entry 比故障時間還新,表示 journal 已 rotate 掉了,這條路斷掉。若 journal 沒設成 persistent,journalctl --list-boots 只會顯示當前 boot 的 log(重啟過就沒了)。


Stage 9 — 在 journal 找觸發事件

從 replica failedAt 時間(這次是 2026-05-22T14:47:08Z)往前看 ±2 小時的 priority=err:

kubectl exec node-forensic -- chroot /host bash -c '
journalctl --no-pager -o short-iso \
  --since "2026-05-22 12:00:00 UTC" \
  --until "2026-05-22 16:00:00 UTC" \
  -p err 2>&1 | head -100
'

這次跳出來大量 iSCSI 連線失敗 + 一個 zombie k3s.service 反覆 restart loop(restart counter 96 萬次)。

k3s.service zombie 是干擾項:實際運作的是 k3s-agent.service,但 unit 名字相近容易誤判。確認:

kubectl exec node-forensic -- chroot /host bash -c '
systemctl list-units --all "k3s*" --no-pager
systemctl status k3s-agent.service --no-pager | head -10
'
UNIT              LOAD   ACTIVE     SUB          DESCRIPTION
k3s-agent.service loaded active     running      Lightweight Kubernetes
k3s.service       loaded activating auto-restart Lightweight Kubernetes

k3s-agent 4 個月 active running,沒重啟過 → kubelet 沒掛,故障在 kubelet 之下。


Stage 10 — 找 OOM kill 和 kernel soft lockup

這是關鍵的兩步。

# 5/22 12:00~16:00 UTC 的 OOM events
kubectl exec node-forensic -- chroot /host bash -c '
journalctl --no-pager -o short-iso \
  --since "2026-05-22 12:00:00 UTC" \
  --until "2026-05-22 16:00:00 UTC" -k 2>&1 \
  | grep "invoked oom-killer"
'

# I/O errors / scsi reset / ext4 journal errors
kubectl exec node-forensic -- chroot /host bash -c '
journalctl --no-pager -o short-iso \
  --since "2026-05-22 12:00:00 UTC" \
  --until "2026-05-22 16:00:00 UTC" -k 2>&1 \
  | grep -iE "i/o error|input/output|sd[a-z]: error|EXT4-fs error|JBD2|read-only|reset.*scsi|Buffer I/O error"
'

# Soft lockup(kernel watchdog 偵測到 CPU 卡住)
kubectl exec node-forensic -- chroot /host bash -c '
journalctl --no-pager -o short-iso \
  --since "2026-05-22 12:00:00 UTC" \
  --until "2026-05-22 16:00:00 UTC" -k 2>&1 \
  | grep -iE "soft lockup|hung_task|RIP:|Call Trace:" | head -30
'

這次撈到:

2026-05-22T12:07:14 kernel: postgres invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=985
2026-05-22T12:07:14 kernel: oom-kill:constraint=CONSTRAINT_MEMCG ... oom_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podc83b4c23.../
2026-05-22T12:07:14 kernel: Memory cgroup out of memory: Killed process 2402326 (postgres) ...
2026-05-22T14:37:22 kernel: postgres invoked oom-killer: ...
2026-05-22T14:47:08 kernel: watchdog: BUG: soft lockup - CPU#4 stuck for 28s! [kworker/4:3:2736300]
2026-05-22T14:53:56 kernel: watchdog: BUG: soft lockup - CPU#1 stuck for 41s! [longhorn-instan:12945]
2026-05-22T14:57:12 kernel: Buffer I/O error on dev sdd, logical block 557056, lost sync page write
2026-05-22T14:57:12 kernel: JBD2: I/O error when updating journal superblock for sdd-8.

這就是觸發鏈:

時間 事件
12:07:14 postgres pod cgroup hit memory limit,整個 pod cgroup OOM killed(memory.oom.group set
14:37:22 postgres 再度 OOM
14:47:08 kworker soft lockup CPU 28s → 同一秒 Longhorn 標 replica failedAt
14:53:56 longhorn-instan process soft lockup CPU 41s
14:57:12 Buffer I/O error on dev sdd + ext4 journal corruption

kworker 在做什麼才會卡住?看 stack:


Stage 11 — 抓 kernel call trace,定位真正肇事者

# 取 14:47:08 那一刻完整 kernel 訊息(含 stack trace)
kubectl exec node-forensic -- chroot /host bash -c '
journalctl --no-pager -o short-iso \
  --since "2026-05-22 14:46:30 UTC" \
  --until "2026-05-22 14:48:00 UTC" -k 2>&1 \
  | grep -vE "^.*Modules linked in:" \
  | head -60
'

Modules linked in: 行噪音很大、每行印兩三千字元,要先 filter 掉才看得到關鍵 Call Trace。

關鍵幾行:

2026-05-22T14:47:08 kernel: Workqueue: events_freezable vmballoon_work [vmw_balloon]
2026-05-22T14:47:08 kernel: RIP: 0010:clear_page_erms+0x7/0x10
2026-05-22T14:47:08 kernel: Call Trace:
2026-05-22T14:47:08 kernel:  vmballoon_inflate+0x352/0x400 [vmw_balloon]
2026-05-22T14:47:08 kernel:  vmballoon_work+0x108/0x1d1 [vmw_balloon]
2026-05-22T14:47:08 kernel:  process_one_work+0x184/0x3a0
2026-05-22T14:47:08 kernel:  worker_thread+0x306/0x440
2026-05-22T14:47:08 kernel:  kthread+0xf2/0x120
Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform

找到 root cause:VMware vmballoon driver inflate page 時把 kernel CPU 卡死。


vmballoon 是什麼

VMware vSphere hypervisor 在 host 記憶體不夠時,會透過 guest 內的 vmware-balloon driver 跟 guest 索回 page:

  1. ESXi host memory overcommit,需要記憶體
  2. host 透過 backdoor channel 告訴 VM 內的 vmw_balloon driver「我要 X 頁」
  3. guest 的 vmballoon driver 呼叫 alloc_pages() 拿 page,標成「不能 swap、不能 release」,然後告訴 hypervisor「拿去」
  4. hypervisor 把那些 page frame 從 guest 移走,給其他 VM 用

問題: 當 ESXi host 強烈 memory pressure 時,vmballoon inflate 會嘗試一次拿很多 page。在 guest VM 內:

完整因果鏈:

ESXi host memory pressure
  ↓
vmballoon driver 在 guest VM 內 inflate
  ↓
guest VM 內 memory squeeze + CPU 被 vmballoon kthread 卡住
  ↓
postgres pod 觸發 cgroup OOM
  ↓
Longhorn instance-manager process 因 CPU schedule 不到 → soft lockup
  ↓
longhorn-manager 在其他 node 偵測 instance-manager 失聯 → 標 replica failedAt
  ↓
此時 instance-manager 與 iSCSI engine 之間的 TCP session 斷
  ↓
Buffer I/O error on /dev/sdd → ext4 journal corruption
  ↓
此後該 volume 上所有檔案讀寫都 I/O error
  ↓
應用沒 user 接觸到下班,到週末沒人發現,週一才被報

而且這個事件同時影響該 node 上所有 Longhorn replica — 我們有兩個不同 namespace 的 postgres cluster,replica 巧合都排在同一個 node,failedAt 時間戳完全相同:2026-05-22T14:47:08Z


為什麼 4 天才被發現

訊號 為什麼沒人看到
postgres pod READY 1/1 readiness probe 用 pg_isready,不執行 SQL,I/O error 看不到
CNPG operator log 噴錯 沒有 alerting,沒人盯著
pg_dump backup 連續 3 天失敗 也沒有 alerting;而且 backup script 寫的方式 (見下) 會留下 ~20 byte 空檔但 BackupRecord 標 success
Longhorn volume faulted 沒有 alerting
5/22 週五晚上發生,週末沒人用 ERP 直到週一上班才有人試登入

四個 alert 缺口,加上週末,等於 60+ 小時零察覺。


Backup script 的設計缺陷(順便看到的)

# 簡化版
set -e

BACKUP_PATH="/backups/$(date +%Y%m%d-%H%M%S).sql.gz"

if pg_dump --verbose | gzip > ${BACKUP_PATH}; then
  # ... 寫入 BackupRecord 標 success
else
  # ... 寫入 BackupRecord 標 failed
fi

set -e 沒抓到 pg_dump 失敗,因為 pg_dump | gzippg_dump 不是最後一個指令。set -o pipefail 才會把整條 pipe 的 exit code 設成「最後一個 non-zero」

結果:DB 壞掉時 pg_dump 在 stderr 噴 error 後 exit 1,但 gzip 收到空 stdin 後仍正常結束 exit 0。整個 if 判斷成功 → 寫 4-byte gzip header 到檔案 → BackupRecord 標 success → 沒有人察覺。

修正:

set -euo pipefail

-u (unset var error)、-o pipefail (pipe 失敗傳遞 exit code),是 production shell script 的最低門檻。


Salvage 完整流程(給未來再次遇到的人)

# 1) 找 PVC → volume → replica
PVC_NAME=postgres-cluster-1
NAMESPACE=YOUR_NAMESPACE
VOL=$(kubectl get pvc -n $NAMESPACE $PVC_NAME -o jsonpath='{.spec.volumeName}')
REPLICA=$(kubectl get replicas.longhorn.io -n longhorn-system \
  -o jsonpath="{range .items[?(@.spec.volumeName=='$VOL')]}{.metadata.name}{end}")
LAST_HEALTHY=$(kubectl get replica.longhorn.io -n longhorn-system $REPLICA \
  -o jsonpath='{.spec.lastHealthyAt}')

echo "Volume: $VOL"
echo "Replica: $REPLICA"
echo "Last healthy: $LAST_HEALTHY"

# 2) 備份 yaml
mkdir -p /tmp/longhorn-salvage && cd /tmp/longhorn-salvage
kubectl get volumes.longhorn.io -n longhorn-system $VOL -o yaml > volume.before.yaml
kubectl get replicas.longhorn.io -n longhorn-system $REPLICA -o yaml > replica.before.yaml

# 3) 清 failedAt(觸發 salvage)
kubectl patch replicas.longhorn.io -n longhorn-system $REPLICA \
  --type=merge -p "{\"spec\":{\"failedAt\":\"\",\"healthyAt\":\"$LAST_HEALTHY\"}}"

# 4) 等 Longhorn 自動 attach + CNPG 自動 reconcile postgres pod
until [ "$(kubectl get volume.longhorn.io -n longhorn-system $VOL -o jsonpath='{.status.state}')" = "attached" ]; do
  sleep 3
done

kubectl wait --for=condition=Ready pod -n $NAMESPACE $PVC_NAME --timeout=120s

# 5) 驗證 postgres recovery
kubectl logs -n $NAMESPACE $PVC_NAME -c postgres --tail=30 \
  | grep -E "interrupted|recovery|redo|ready to accept"

# 6) 手動補 backup(之前的可能都是空檔)
TS=$(date +%Y%m%d-%H%M%S)
kubectl create job --from=cronjob/postgres-pgdump-backup pgdump-manual-$TS -n $NAMESPACE
kubectl create job --from=cronjob/postgres-basebackup basebackup-manual-$TS -n $NAMESPACE

安全重啟 Longhorn 上跑 single-replica 工作負載的 node

如果 root cause 是 host memory pressure,要改 ESXi 端記憶體配置或加 reservation。VM 端就是調 RAM、重啟。但重啟前 single-replica 的 PVC 是個問題 — node 一關,volume 就再次 detach + faulted(剛經歷的一模一樣)。

正確做法:先把 replica 數臨時升到 2,等同步完再重啟。

# 1) 把 single-replica volume 升到 2
for VOL in VOLUME_A VOLUME_B; do
  kubectl patch volume.longhorn.io -n longhorn-system $VOL \
    --type=merge -p '{"spec":{"numberOfReplicas":2}}'
done

# 2) 等到 robustness=healthy(同步完)
until [ "$(kubectl get volume.longhorn.io -n longhorn-system VOLUME_A -o jsonpath='{.status.robustness}')" = "healthy" ]; do
  sleep 5
done

# 3) Cordon + drain
kubectl cordon TARGET_NODE
kubectl drain TARGET_NODE --ignore-daemonsets --delete-emptydir-data --force --grace-period=120

# 4) 確認 node 上沒有 attached volume
kubectl get volume.longhorn.io -n longhorn-system \
  -o jsonpath='{range .items[?(@.spec.nodeID=="TARGET_NODE")]}{.metadata.name}{"\n"}{end}'

# 5) Shutdown VM、改 RAM、開機

# 6) Node Ready 之後 uncordon
until kubectl wait --for=condition=Ready node/TARGET_NODE --timeout=10s; do sleep 5; done
kubectl uncordon TARGET_NODE

強烈建議:步驟 6 之後不要把 replica 數調回 1。多一份 replica 換來的 HA 餘裕,比省下的 disk 空間值得太多。


學到的事

  1. pg_filenode.map: Input/output error 不是 PostgreSQL 的錯,是底下 block storage I/O 死了。 SQLSTATE 58030 是 I/O 錯誤類別,跟 page corruption(XX001 系列)不同。前者要修 storage,後者要 PITR。

  2. pg_isready 不執行 SQL,只測 postmaster 是否 listen。 用它做 readiness probe,等於只測「server 還在開 port」,不能測 server 「能不能工作」。CloudNativePG /readyz 用這個是設計取捨,但對 I/O 死掉這種場景失明。production 建議加額外 SQL probe(如 psql -c "SELECT 1" 包進 livenessProbe)。

  3. Longhorn 對於 revisionCounterDisabled: true 的 volume 不會自動 salvage,這是資料安全設計(避免 split-brain 寫壞資料)。手動 salvage 等於人類確認「我願意接受這個 replica 作為 truth source」。

  4. Single replica + revisionCounterDisabled = 沒有 HA,全靠 backup。這個組合在很多輕量化部署是預設的(CloudNativePG single instance + Longhorn numberOfReplicas: 1),但風險高。如果 host 上有不可預測的事件(hypervisor 行為、kernel bug、單磁碟壞)就是 100% 影響。至少要 numberOfReplicas: 2

  5. 同一個 node 上的所有 Longhorn replica 共用一個 instance-manager pod。 node 上的 kernel 事件會同時影響該 node 所有 replica。要分散風險就要把不同 cluster 的 replica 排到不同 node(Longhorn data locality + taint/toleration 控制)。

  6. VMware vmballoon kernel soft lockup 是真實會發生的事,特別在 host memory overcommit + 大量 page reclaim 場景。 vSphere 端對應的解法:
    • VM 設 memory reservation(保證 guest 拿得到、ballooning 不會強制收回)
    • host 監控 active memory vs allocated,避免 overcommit ratio 太高
    • guest 端:如果不想被 ballooning 影響,可以 rmmod vmw_balloon + blacklist(但會放棄 hypervisor 動態 memory 管理,trade-off)
  7. Shell script set -e 不夠,要 set -euo pipefail Pipe 中段的失敗 (pg_dump | gzip) 若沒 pipefail,整條 pipe 仍然 exit 0。這個 1 字差距會把「備份失敗」隱藏成「備份成功,內容是 20 bytes 的空檔」。

  8. 沒有 alerting,就等於 production down 沒人知道。 這次有四道防線(pod readiness、CNPG status、Longhorn robustness、backup job status)全部失守,每一道都沒人盯。最少最少要有一條:定期 curl 一個會打 DB 的 endpoint,掛了就 ping 人

  9. kubectl debug node 在 attach 模式會 hang,要嘛改用自己寫 hostPID/privileged pod manifest(更可控),要嘛改 non-interactive 模式。debug pod 結束後要手動刪,--rm 配 stdin 沒接時不會自動清。

  10. journalctl 預設可能不是 persistent。若 /etc/systemd/journald.conf 沒設 Storage=persistent/var/log/journal/ 不存在,重啟 node 後上一次 boot 的 log 就沒了。production 必開 persistent journal。

參考資料