存储故障排查 runbook
把前面 5 篇的所有知识串起来用——给定一个生产现象,怎么一步步定位根因。每个 case 都按"分层排错 + 真实案例"展开。
这篇怎么用
报警 → 判断现象 → 选择 Runbook → 定位根因。
7 个 Runbook:
- PVC 卡 Pending
- Pod 卡 ContainerCreating
- 容器内文件丢
- 磁盘满了
- I/O 慢
- NFS 卡 D 状态
- 节点 DiskPressure
0. 总原则
节点磁盘可能"满"有 4 种:
1. 字节空间满 (df -h)
2. inode 满 (df -i)
3. 进程持有的 deleted 文件
4. ext4 reserved 5%
文件 / mount / 数据至少 4 个视角:
应用代码 → 容器 mount ns → 节点 host → 物理盘
应用报错时确认是哪个视角的错。详见 01-container-volumes.md。
DEST=/tmp/storage-incident-$(date +%F-%H%M)
mkdir -p $DEST
# K8s 视角
kubectl get pv,pvc -A -o wide > $DEST/pv-pvc.txt
kubectl describe pod my-pod > $DEST/pod-desc.txt
kubectl get events -A --sort-by=".lastTimestamp" > $DEST/events.txt
# 节点
ssh m4 'df -hT; df -i; mount; findmnt' > $DEST/m4-storage.txt
ssh m4 'lsblk -f; iostat -x 5 3' > $DEST/m4-disk.txt
ssh m4 'dmesg -T | tail -200' > $DEST/m4-dmesg.txt
# 应用
kubectl logs my-pod --tail=500 > $DEST/app.log
# CSI driver
kubectl logs -n kube-system ⟨csi-controller⟩ -c csi-provisioner > $DEST/csi.log
tar czf storage-incident.tar.gz $DEST
恢复前先 snapshot。
Runbook 1:PVC 卡 Pending
flowchart TD
Start[PVC Pending] --> S1[kubectl describe pvc<br>看 Events]
S1 --> E1{Events 关键字}
E1 -->|storageclass not found| F1[创建/修正 StorageClass]
E1 -->|ProvisioningFailed| S2[看 CSI 日志]
E1 -->|waiting for first consumer| S3{Pod 也 Pending?}
E1 -->|no PV matches| S4[手动建 PV / 改 PVC accessMode]
S2 --> CSI{CSI 错误}
CSI -->|IAM/auth| F2[修云上凭证]
CSI -->|quota| F3[申请配额]
CSI -->|network| F4[CSI 到云 API 网络]
S3 -->|Pod Pending| F5[Pod 调度问题:taint/资源]
S3 -->|Pod 不存在| F6[要先创建 pod 才会 provision]
# 1. PVC 状态
kubectl get pvc -A
kubectl describe pvc my-pvc
# 2. StorageClass
kubectl get storageclass
kubectl describe storageclass ⟨name⟩
# 3. CSI driver
kubectl get pods -n kube-system | grep csi
kubectl logs -n kube-system ⟨csi-controller-pod⟩ -c csi-provisioner | tail -50
kubectl logs -n kube-system ⟨csi-controller-pod⟩ -c csi-attacher | tail -30
# 4. 看 PV 池
kubectl get pv
# 5. Events
kubectl get events --sort-by=".lastTimestamp" | grep -i pvc
Case 1.1:StorageClass 不存在
$ kubectl describe pvc my-pvc
Events:
Warning ProvisioningFailed ... storageclass.storage.k8s.io "fast-ssd" not found
根因:PVC 引用了不存在的 SC(拼错 / 没装 driver)。
修:
$ kubectl get storageclass
# 看实际有的、改 PVC 的 storageClassName
Case 1.2:CSI driver 调云 API 失败
$ kubectl logs -n kube-system ebs-csi-controller-xxx -c csi-provisioner
... rpc error: code = ResourceExhausted desc = You have reached the maximum number of EBS volumes
根因:云上单账户单区配额满。
修:申请扩配额(云控制台 + 工单),临时清理废 PVC。
Case 1.3:节点亲和性 / AZ 不一致
$ kubectl describe pvc my-pvc
Events:
Warning WaitForFirstConsumer ... waiting for first consumer to be created before binding
$ kubectl describe pod my-pod
Events:
Warning FailedScheduling ... 0/3 nodes are available: ...
根因:Pod 调度不上 → PVC 不能 provision。
修:先解决 pod 调度(资源 / taint / nodeSelector)、PVC 会自动 provision。
Case 1.4:PV 已存在但 ReclaimPolicy=Retain、状态 Released
$ kubectl get pv
pv-001 Released default/old-pvc
$ kubectl describe pvc new-pvc
Events:
Normal WaitForFirstConsumer ... waiting for first consumer
# ...
根因:Released 状态的 PV 不会被新 PVC 自动 bind。
修:
$ kubectl edit pv pv-001
# 删除 spec.claimRef 整段
# PV 状态变 Available
或者删 PV 让 SC 重新 provision(数据保留在底层 / 看 reclaim policy)。
Runbook 2:Pod 卡 ContainerCreating + Volume 错误
$ kubectl get pod my-pod
NAME READY STATUS AGE
my-pod 0/1 ContainerCreating 5m
$ kubectl describe pod my-pod
Events:
Warning FailedMount ...
# 看具体错
Case 2.1:FailedAttachVolume
Warning FailedAttachVolume AttachVolume.Attach failed for volume "pv-xxx" :
rpc error: ...
含义:CSI controller 调云 API attach 失败。
排查:
# 1. 看 CSI controller 日志
kubectl logs -n kube-system ⟨csi-controller⟩ -c csi-attacher
# 看具体 rpc 错
# 2. 看 VolumeAttachment
kubectl get volumeattachment | grep ⟨pv-name⟩
# 3. 看云上盘状态(云控制台 / 命令行)
# AWS: aws ec2 describe-volumes --volume-id ⟨vol-id⟩
常见原因:
| 错误 | 修法 |
|---|---|
| IAM 权限不够 | 给 K8s 节点 / serviceAccount 加云 IAM |
| 卷已经 attached 到别的节点 | RWO + 跨节点调度问题,drain 老节点或强制 detach |
| 跨 AZ | StorageClass 加 WaitForFirstConsumer |
| API rate limit | 等 / 拆 PVC 批量操作 |
Case 2.2:FailedMount
Warning FailedMount Unable to attach or mount volumes: unmounted volumes=[data]:
timed out waiting for the condition
含义:mount 步骤失败(节点上)。
排查:
# 1. 节点 CSI node pod 日志
$ ssh m4
$ NODE_POD=$(kubectl get pod -n kube-system -l app=ebs-csi-node -o name -A | head -1)
$ kubectl logs -n kube-system $NODE_POD -c csi-driver | tail -50
# 2. 节点 kubelet 日志
$ journalctl -u kubelet --since "5 min ago" | grep -i mount
# 3. 节点手动测 mount
$ ls /var/lib/kubelet/plugins/⟨csi-driver⟩/ # CSI staging 目录在不在
$ ls /dev/disk/by-id/ | grep ⟨vol-id⟩ # 盘 attached 看到了吗
# 4. 文件系统问题
$ blkid /dev/sdb1 # 已格式化吗
$ fsck -n /dev/sdb1 # 文件系统损坏?
Case 2.3:MountVolume.MountDevice failed
... MountVolume.MountDevice failed for volume "pv-xxx" :
failed to mount: exit status 32
含义:底层 mount 系统调用失败。
排查:
# 1. 看具体退出码含义
# exit 32 通常是文件系统损坏 / 已被 mount / 设备不存在
# 2. 节点 dmesg
ssh m4 'dmesg -T | tail -50 | grep -iE "mount|sd|nvme|xfs|ext4"'
# 3. 看是否真有这个块设备
ssh m4 'lsblk -f'
# 4. 强制重新 attach(实在搞不定的核选项)
kubectl delete volumeattachment ⟨name⟩
# K8s 会重新 attach
Case 2.4:FsGroupChange 卡几分钟
$ kubectl describe pod my-pod
Events:
Normal Pulled ... image already present
Normal Created ... Created container
# 然后卡 5 分钟
securityContext.fsGroup + PV 里几十万文件 → kubelet 一个一个 chown,慢。
修:
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch # K8s 1.20+ 只在根 owner 不对时改
或者应用层管理权限(不用 fsGroup)。
Runbook 3:容器内文件不见 / 数据丢了
Case 3.1:emptyDir 数据"丢"
$ kubectl exec my-pod -- ls /cache
# 空、之前有的文件没了
先确认场景:
| 情况 | 数据状态 |
|---|---|
| Container restart(同 pod) | ✅ 保留 |
| Pod restart(同 pod,UID 不变) | ✅ 保留 |
| Pod 删了重新创建(UID 变了) | ❌ 没 |
| 节点 reboot 后 pod 重新调度到别的节点 | ❌ 没 |
| 节点 reboot 后 pod 留在原节点 | ✅ 保留 |
emptyDir 本来就是 "Pod 级"——pod UID 变了数据就没了。需要持久 → PVC。
Case 3.2:subPath 挂的 ConfigMap "改了但 pod 没看到"
volumeMounts:
- name: cfg
mountPath: /etc/app.conf
subPath: app.conf
ConfigMap 改了 1 小时、pod 内还是老内容。
根因:subPath 用 bind mount 单个文件、K8s 不会自动更新 bind 源。
修法:
A. 不用 subPath、整个目录挂:
volumeMounts:
- name: cfg
mountPath: /etc/app/
volumes:
- name: cfg
configMap:
name: my-config
应用读 /etc/app/app.conf、K8s 周期性 sync。
B. 用 annotation hash 触发 rolling restart(Helm 套路):
spec:
template:
metadata:
annotations:
checksum/config: "{{ ... 计算 ConfigMap hash ... }}"
ConfigMap 改 → hash 变 → deployment rolling 更新。
Case 3.3:hostPath 数据"飘了"
# Pod 调度到节点 m1 → 看到的 /var/log/myapp
# Pod 后来调度到 m4 → 完全不同的 /var/log/myapp
根因:hostPath 不是"持久存储"——是节点本地路径。节点之间内容不一样。
修:用 Local PV + nodeAffinity 把 pod 钉到特定节点:
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-m4
spec:
local:
path: /mnt/disks/ssd1
nodeAffinity: # ← 钉到 m4
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [m4]
Case 3.4:PVC 数据"突然"没了
$ kubectl exec my-pod -- ls /data
# 空、本来一堆文件
最可怕场景。可能:
A. PVC 被重建(reclaimPolicy=Delete + PVC 删了)→ 数据真没了。看历史 events:
$ kubectl get events -A --sort-by=".lastTimestamp" | grep -i pvc
B. PV bind 错了(手动 yaml 装到 K8s、PV 的 volumeHandle 改了)→ 看似新盘没数据。
C. subPath 改了(删了 subPath、应用看到 PV 根目录的不同内容)。
D. 应用自己写错了(rm -rf / 测试代码进生产)。
排查:
# 1. PV 还指向同一个底层盘吗
$ kubectl get pv ⟨pv-name⟩ -o yaml | grep -E "volumeHandle|nfs|local"
# 2. 看 PVC 的 volumeName 是不是变了
$ kubectl get pvc my-pvc -o yaml | grep volumeName
# 3. 节点上看实际数据位置
$ ssh m4 'ls /var/lib/kubelet/pods/⟨pod-uid⟩/volumes/'
最坏:从 Velero / VolumeSnapshot 还原。
Runbook 4:节点磁盘满
决策树
flowchart TD
Start[节点报满] --> S1{df -h<br>哪个 mount?}
S1 -->|/| L1[根分区满]
S1 -->|/var/lib/containerd| L2[容器存储满]
S1 -->|/var/log| L3[日志满]
S1 -->|没看出满| S2[df -i 看 inode]
L1 --> Q1{du -sh /*}
L2 --> A1[crictl rmi --prune]
L3 --> A2[journalctl --vacuum-size]
S2 -->|inode 满| A3[找小文件大户清]
S2 -->|inode 不满| S3[lsof grep deleted]
S3 -->|有 deleted 文件| A4[重启占用进程]
S3 -->|没| A5[ext4 reserved 5%? <br/>tune2fs -m 1]
Case 4.1:节点 /var/lib/containerd 满
典型场景:containerd 没清旧镜像、overlay 层堆积。
$ ssh m4 'df -h /var/lib/containerd'
/dev/sda3 100G 95G 5G 95%
$ ssh m4 'du -sh /var/lib/containerd/* | sort -hr | head'
30G /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
20G /var/lib/containerd/io.containerd.content.v1.content/
...
# 清没引用的镜像
$ ssh m4 'crictl rmi --prune'
详见 crictl.md。
Case 4.2:inode 满
$ ssh m4 'df -i /'
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda3 6291456 6280000 11456 100% /
^^^^^
满了
# 找 inode 大户
$ ssh m4 'for d in /var/*/; do
count=$(find "$d" -xdev | wc -l)
echo "$count $d"
done | sort -rn | head'
3000000 /var/lib/kubelet/
1500000 /var/log/journal/
500000 /var/lib/containerd/
常见原因:
- K8s 日志切片太碎(pod 多 + retention 长)
- Volume 里大量小文件
- journald 没轮转
修:
# journald 清理
journalctl --vacuum-size=500M
# K8s pod 日志
crictl rmi --prune # 没用镜像
find /var/log/pods -name "*.log.*" -mtime +7 -delete # 老 pod 日志
# 节点 ext4 inode 数不够 → 重建(核选项、需要 reformat)
mkfs.ext4 -N 12000000 /dev/sda3 # 双倍 inode
xfs 用动态 inode、不会满。新装节点考虑 xfs。
Case 4.3:lsof | grep deleted
$ ssh m4 'df -h /'
/dev/sda3 100G 95G 5G 95%
$ ssh m4 'du -sh /'
50G ← 实际只有 50G?
# 找
$ ssh m4 'lsof | grep deleted | sort -k7 -hr | head'
journalctl ... 30000000000 /var/log/journal/... (deleted)
^^^^^^^^^^^^
一个 30G 的已删文件、进程还持着
修:重启那个进程:
$ ssh m4 'systemctl restart systemd-journald'
$ ssh m4 'df -h /'
# 空间释放
或者截断那个文件的 fd(无需重启):
# 找进程持有的 fd
$ ssh m4 'lsof -p <PID> | grep deleted'
journalctl 1234 root 6w ... ... /var/log/journal/...
^^^^^
fd = 6
# 截断
$ ssh m4 'echo > /proc/1234/fd/6'
# 文件大小 → 0,但 fd 还在
Runbook 5:I/O 慢
Case 5.1:应用 I/O latency 高
# Pod 内
$ kubectl exec my-pod -- time dd if=/dev/zero of=/data/test bs=1M count=100
100+0 records in
100+0 records out
104857600 bytes (105 MB) copied, 5.2 s, 20 MB/s ← 慢!期望 200+
逐层排查:
# 1. 应用层缓存设置
# 数据库 fsync / 操作系统 sync
# 2. K8s PV 类型
$ kubectl get pv ⟨pv-name⟩ -o yaml | grep -E "storageClassName|csi:"
# 是 fast-ssd 还是 standard ?
# 3. 节点 I/O 总量(是不是某个 pod 在抢)
$ ssh m4 'iostat -x 5 3'
# 看 %util / await
# 4. 进程级 I/O
$ ssh m4 'iotop -bn1'
# 哪个进程在 I/O
# 5. 底层云盘 metric
# 云控制台看 EBS / 云盘 IOPS / 吞吐 / 队列深度
Case 5.2:%util 100% 但应用还快
$ iostat -x 1
Device ... await r_await w_await %util
nvme0n1 ... 0.5 0.3 0.8 100.0
NVMe SSD 利用率高 ≠ 慢。NVMe 多队列、%util 计算方式对它不准。看 延迟:
- await < 1ms → 快、即使 100% util
- await > 10ms → 慢
老 HDD / SATA SSD 看 %util、NVMe 看 await。
Case 5.3:fsync 慢
# 测 fsync 延迟
$ kubectl exec my-db-pod -- ioping -W -c 10 /data
数据库 fsync 慢 → 整个 TPS 下降。可能:
- 底层盘 fsync 慢(NFS / 共享存储)
- 节点 write barrier / fua 配置
典型场景:MySQL 跑 NFS 上 → 灾难。换块存储。
详见 04-nfs-deep.md。
Runbook 6:NFS 卡 D 状态
现象
$ kubectl get pod
NAME STATUS READY
my-pod Unknown 1/1 ← 状态怪
$ ssh m4 'ps aux | awk "\$8 ~ /D/"'
USER PID ... STAT COMMAND
root 1234 ... D java -jar app.jar ← 卡 D
根因:NFS server 不可达、应用 syscall 卡内核。
救命步骤
# 1. NFS server 通吗
$ ssh m4 'ping -c 3 nfs.example.com'
# 不通 → 先恢复 server / 网络
# 2. NFS 端口通吗
$ ssh m4 'nc -zv nfs.example.com 2049'
# 看 TCP
# 3. 节点尝试 umount lazy
$ ssh m4 'umount -l /mnt/nfs'
# 不立即释放、但新进程不再卡
# 4. server 恢复后
$ ssh m4 'mount -a' # 重新挂
# 5. D 状态进程恢复(可能要 reboot)
$ ssh m4 'reboot' # 最坏情况
预防
详见 04-nfs-deep.md:
- NFS server 高可用
- 节点 NFS mount 不要让 kubelet 依赖
- 应用尽量避免关键路径走 NFS
Runbook 7:节点 DiskPressure
现象
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
m4 NotReady ...
$ kubectl describe node m4
Conditions:
DiskPressure True KubeletHasDiskPressure
Ready False
含义:kubelet 检测节点磁盘紧张、开始 驱逐 pod。
kubelet 判断 DiskPressure 的阈值
默认(K8s):
evictionHard:
imagefs.available: "15%" # imagefs 可用 < 15%
nodefs.available: "10%" # nodefs < 10%
nodefs.inodesFree: "5%" # inode 剩余 < 5%
imagefs.inodesFree: "5%"
低于阈值 → kubelet 开始驱逐 pod(按 priority 顺序)。
排查
# 1. 哪个分区
$ ssh m4 'df -hT'
$ ssh m4 'df -i'
# 2. K8s rootfs / imagefs 在哪
$ ssh m4 'mount | grep containerd'
# 3. 看驱逐事件
$ kubectl get events -A | grep -i evict
# 4. 清理
$ ssh m4 'crictl rmi --prune'
$ ssh m4 'journalctl --vacuum-size=500M'
紧急救援:禁止 evict
# 临时
$ ssh m4 'systemctl edit kubelet'
# 加: --eviction-hard=nodefs.available=1%
$ ssh m4 'systemctl restart kubelet'
或者 drain 节点、清磁盘、再 uncordon:
$ kubectl drain m4 --ignore-daemonsets --delete-emptydir-data
$ ssh m4 'cleanup_disk.sh'
$ kubectl uncordon m4
8 个救命命令(通用排查)
# 1. 节点磁盘整体
ssh m4 'df -hT; df -i'
# 2. 找占空间最多
ssh m4 'du -sh /var/* /home/* 2>/dev/null | sort -hr | head'
# 3. 找 K8s pod volume 占用
ssh m4 'du -sh /var/lib/kubelet/pods/*/volumes/* 2>/dev/null | sort -hr | head'
# 4. K8s pv/pvc 状态
kubectl get pv,pvc -A -o wide
# 5. CSI driver 健康
kubectl get pods -n kube-system | grep csi
kubectl logs -n kube-system ⟨csi-controller⟩ --tail=50
# 6. iostat 看真实 I/O
ssh m4 'iostat -x 5 3'
# 7. lsof deleted
ssh m4 'lsof | grep deleted | sort -k7 -hr | head'
# 8. dmesg 看内核错
ssh m4 'dmesg -T | tail -50 | grep -iE "i/o|sector|sd|nvme|filesystem"'
总结:存储排错心理建设
几句"经验之谈"
- 从存储栈底往上排错(盘 → 文件系统 → mount → 应用)
- 多视角检查(应用 / 容器 / 节点 / 物理盘)
- 数据安全永远第一——排错前 snapshot / 备份
- 不要
rm -rfPV 数据——除非你知道你在做什么 - PVC 删之前查清 reclaim policy(Delete = 数据消失)
- NFS 卡死优先
umount -l、别第一时间 reboot - 生产数据库不要放 NFS / 共享文件系统
- K8s 节点磁盘满 = 集群级危机——立刻处理
排错速度的差距,70% 在心智模型、20% 在工具熟练度、10% 在运气。
全系列回顾
| 篇 | 内容 |
|---|---|
| 00-storage-mental-model.md | 存储心智模型 |
| 01-container-volumes.md | 容器挂载 + mount propagation |
| 02-k8s-volumes.md | K8s Volume 类型大全 |
| 03-pv-pvc-storageclass.md | PV / PVC / CSI |
| 04-nfs-deep.md | NFS |
| 05-distributed-storage.md | Ceph / Longhorn |
| 本篇 | 故障排查 runbook |