SOP - K8s 节点 orphaned pod 残留目录清理(修复 kubelet rmdir ENOTDIR bug)¶
文档类型:运维操作 SOP(标准作业程序)+ 故障解决报告 执行日期:2026-08-24 执行人:rian@192.168.0.11 (admin 工作站) 目标节点:k8s-node-02 (192.168.0.6) 目标 Pod UID:322a2355-92c8-4a64-a0c9-f6e9f8705c58 集群环境:K8s v1.22.1 / kubeadm 部署 / 3M+3W / docker 20.10.8 / kubelet 1.22.1 修复方案:手动清理(不升级 K8s)
一、更改的背景¶
1.1 故障时间与影响¶
| 项 | 内容 |
|---|---|
| 故障时间 | 2026-08-02 ~ 2026-08-24(持续 22 天,首次出现 Pod 删除时即开始) |
| 告警级别 | kubelet WARN(不影响业务,但告警噪音 + 节点磁盘泄漏) |
| 影响范围 | k8s-node-02 节点 kubelet,告警 250+ 次/1000行日志 |
| 业务影响 | 无(只是告警噪音 + 残留目录 8KB 占用) |
1.2 故障现象¶
kubelet 周期性报:
E0824 HH:MM:SS kubelet_volumes.go:245] "There were many similar errors. Turn up verbosity to see them."
err="orphaned pod \"322a2355-92c8-4a64-a0c9-f6e9f8705c58\" found, but failed to rmdir() subpath at path
/var/lib/kubelet/pods/322a2355-92c8-4a64-a0c9-f6e9f8705c58/volume-subpaths/mysql-nfs-pv/mysql/0: not a directory"
numErrs=1
告警频率:每 2 秒 1 次(kubelet 的 orphaned pod 清理周期),22 天累积告警 250+ 次(1000 行日志窗口内)。
二、为什么要更改(动机与价值)¶
2.1 K8s 1.22 已知 bug 的影响¶
K8s 1.22 的 kubelet cleanupOrphanedPodDirs() 函数在清理 Pod 残留时:
- 调用 rmdir() 系统调用删除 volume-subpaths/<pv-name>/<container-name>/<index> 路径
- 但该路径是 NFS subPath 挂载点文件(不是目录)
- rmdir() 对文件返回 ENOTDIR("not a directory")错误
- kubelet 周期重试 → 250+ 次 warn 告警累积
这是 K8s GitHub issue #81824 的已知行为(类似案例)。
2.2 不升级 K8s 的修复动机¶
- 升级 K8s 涉及 etcd/apiserver/kubelet 全集群滚动升级,风险高 + 停机时间长
- 本次告警仅是告警噪音,不影响业务
- 手动清理残留目录 = 一次性操作 + 0 业务影响 + 0 升级风险
- 适合"测试学习集群"这种场景
2.3 改造价值¶
| 之前 | 之后 |
|---|---|
| kubelet 每 2 秒报 1 次 orphaned 告警 | 60 秒内 0 告警 ✅ |
| /var/lib/kubelet/pods/ 目录数 14(差 API pod 数 1) | 13 vs 13,差=0 ✅ |
| 节点磁盘 8KB 残留泄漏 | 已清理 ✅ |
| 长期累积每删一个带 NFS subPath 的 Pod 都会留下孤儿 | 残留目录已清,未来再发生可用同一脚本清理 |
三、底层原理与执行逻辑¶
3.1 故障原理图¶
mysql Pod 被 kubectl delete(2026-08-02)
↓
kubelet 收到 Pod 删除事件
↓
kubelet 清理 Pod 目录:
① CleanPodSandbox → 成功(容器 sandbox 已清理)
② doCleanupPodVolumePlugins → 失败 ❌
- volumes/ → 成功删除("Cleaned up orphaned pod volumes dir")
- volume-subpaths/mysql-nfs-pv/mysql/0 → 失败
原因:kubelet 用 rmdir() 想删 "0" 路径
但 "0" 是 NFS 卷的 subPath 挂载点(文件,不是目录)
rmdir() 系统调用对文件返回 ENOTDIR 错误
③ CleanOrphanedPodDirs → 卡在前置步骤,未执行
↓
kubelet 每 2 秒重试一次 → 250 次告警累积(1000 行日志窗口)
3.2 修复方案选择(不升级 K8s 的 3 个备选方案对比)¶
| 方案 | 描述 | 优点 | 缺点 | 选用 |
|---|---|---|---|---|
| A. 手动清理残留目录 | 停 kubelet → rm -rf 目录 → 启动 kubelet | 一次性操作,0 业务影响,简单直接 | 不根治 bug,下次还会发生 | ✅ 选用 |
| B. 节点重启 | 重启节点,kubelet 启动时重新清理 | 简单 | 仍会失败(rmdir bug 还在);节点重启影响业务 | ❌ |
| C. 升级 K8s 到 1.23+ | 修复了 volume-subpaths 清理逻辑 | 根治 | 涉及全集群升级,风险高 + 停机长 | ❌(用户明确要求不升级) |
最终选择方案 A:手动清理 + 提供可复用的修复脚本(脚本可应对未来类似场景)。
3.3 关键技术细节¶
- 为什么必须停 kubelet 后再 rm:避免 kubelet 周期重试与我们的 rm 操作冲突
- 为什么 rm -rf 而不是手动 rmdir/unlink:rm -rf 会递归删除目录下所有文件+子目录,绕过 kubelet 用错 rmdir 的 bug
- kubelet 启动后会有瞬时告警(约 60 秒):kubelet 启动时一次性扫描所有 sandbox,扫描过程中会触发 orphaned 告警,但目录已删除后扫描完成就停止
- 目录数差验证:API pod 数 vs kubelet pods 目录数,差=0 说明无残留
四、更改后的作用与达成的效果¶
4.1 修复前 vs 修复后对比¶
| 指标 | 修复前 | 修复后 | 验证方法 |
|---|---|---|---|
| 60 秒内 322a2355 告警数 | 持续告警(每 2 秒 1 次) | 0 ✅ | journalctl -u kubelet --since '60 seconds ago' \| grep -c 322a2355 |
| 60 秒内 orphaned 告警数 | 30+ 次 | 0 ✅ | journalctl -u kubelet --since '60 seconds ago' \| grep -ci orphaned |
| 节点 pods 目录数差 | 14 vs 13(差 1) | 13 vs 13(差 0)✅ | ls /var/lib/kubelet/pods/ \| wc -l vs kubectl get pods -A --field-selector spec.nodeName=... \| wc -l |
| 目标目录是否存在 | 8KB 残留 | No such file ✅ | ls /var/lib/kubelet/pods/322a2355-... |
| PV 引用此 UID | 无 | 无 ✅ | kubectl get pv -o json \| grep 322a2355 |
| kubelet 状态 | Running | active ✅ | systemctl is-active kubelet |
4.2 达成的运维能力¶
- 告警噪音消除:node-02 kubelet 日志不再每 2 秒报 1 次 orphaned 告警
- 磁盘泄漏修复:8KB 残留目录已清,未来再发生类似问题可用同一脚本快速清理
- 运维能力可复用:生成了
k8s_cleanup_orphaned_pod.sh脚本,未来节点出现 orphaned pod 告警可一键修复 - 不升级 K8s:保持集群版本稳定,避免升级风险
五、更改实现的具体步骤与方法(可复现 SOP)¶
5.1 前置准备¶
# 1. admin 工作站 0.11 上需有:
# - kubectl 已配置(~/.kube/config)
# - SSH 免密到目标节点(参考 SOP-0.11到12台主机SSH免密登录配置.md)
# - 修复脚本已部署(./k8s_cleanup_orphaned_pod.sh)
5.2 排查阶段(先定位残留 Pod UID)¶
# 1. 在目标节点 kubelet 日志中找到 orphaned pod UID
ssh <node> "journalctl -u kubelet --no-pager -n 1000 | grep -i orphaned | head -5"
# 输出: orphaned pod "322a2355-92c8-4a64-a0c9-f6e9f8705c58" found, but failed to rmdir() subpath at path ...
# 2. 确认该 UID 在 K8s API 中已不存在
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.uid}{"\n"}{end}' | grep 322a2355
# 期望输出:空(不存在)
# 3. 检查残留目录内容
ssh <node> "ls -la /var/lib/kubelet/pods/322a2355-92c8-4a64-a0c9-f6e9f8705c58/"
5.3 修复执行(一行命令完成)¶
# 在 admin 工作站 0.11 上执行:
bash ./k8s_cleanup_orphaned_pod.sh \
k8s-node-02 \
322a2355-92c8-4a64-a0c9-f6e9f8705c58
# 脚本会自动执行:
# 步骤1:5 项前置安全检查(API 不存在 + 无 PV 持有 + 无 mount 拋留 + 非活 Pod + 目录存在)
# 步骤2:备份残留目录到 /tmp/orphaned_pod_bak_<UID>_<TS>.tar.gz
# 步骤3:停止 kubelet(systemctl stop kubelet)
# 步骤4:【高危操作】rm -rf /var/lib/kubelet/pods/<UID>/
# 步骤5:启动 kubelet(systemctl start kubelet)
# 步骤6:等 60 秒,验证告警消失 + 目录数差=0 + 目标目录已删除
5.4 手动分步执行(如脚本失败,按 SOP 手动执行)¶
# 高危操作前置提醒:
# ⚠️ rm -rf 是高危操作,必须严格核对 Pod UID(UUID 格式 8-4-4-4-12)
# ⚠️ 已备份到 /tmp/orphaned_pod_bak_*.tar.gz
# 🔁 回滚命令:ssh <node> tar -xzf /tmp/orphaned_pod_bak_*.tar.gz -C /
# Step 1: 备份残留目录
TARGET_UID="322a2355-92c8-4a64-a0c9-f6e9f8705c58"
TS=$(date +%Y%m%d_%H%M%S)
ssh k8s-node-02 "tar -czf /tmp/orphaned_pod_bak_${TARGET_UID}_${TS}.tar.gz -C /var/lib/kubelet/pods/ ${TARGET_UID}/"
# Step 2: 停止 kubelet
ssh k8s-node-02 "systemctl stop kubelet && sleep 3 && systemctl is-active kubelet"
# 期望输出:inactive
# Step 3: 【高危】rm -rf 残留目录
ssh k8s-node-02 "rm -rf /var/lib/kubelet/pods/${TARGET_UID}/ && echo 'rm -rf 完成'"
# Step 4: 启动 kubelet
ssh k8s-node-02 "systemctl start kubelet && sleep 5 && systemctl is-active kubelet"
# 期望输出:active
# Step 5: 等 60 秒后验证
sleep 60
ssh k8s-node-02 "journalctl -u kubelet --since '60 seconds ago' --no-pager | grep -c ${TARGET_UID}"
# 期望输出:0
# Step 6: 目录数差验证
ls_count=$(ssh k8s-node-02 "ls /var/lib/kubelet/pods/ | wc -l")
api_count=$(kubectl get pods -A --field-selector spec.nodeName=k8s-node-02 --no-headers | wc -l)
echo "kubelet pods 目录数: $ls_count, API pod 数: $api_count, 差: $((ls_count - api_count))"
# 期望输出:差=0
5.5 验证步骤¶
# 1. 验证告警消失(60 秒内 0 告警)
ssh k8s-node-02 "journalctl -u kubelet --since '60 seconds ago' --no-pager | grep -c 322a2355"
# 期望:0
# 2. 验证目录数差=0
ls_count=$(ssh k8s-node-02 "ls /var/lib/kubelet/pods/ | wc -l")
api_count=$(kubectl get pods -A --field-selector spec.nodeName=k8s-node-02 --no-headers | wc -l)
echo "差: $((ls_count - api_count))"
# 期望:0
# 3. 验证目标目录已删除
ssh k8s-node-02 "ls /var/lib/kubelet/pods/322a2355-92c8-4a64-a0c9-f6e9f8705c58"
# 期望:No such file or directory
# 4. 验证 kubelet 服务正常
ssh k8s-node-02 "systemctl is-active kubelet"
# 期望:active
# 5. 验证节点上所有 Pod 仍 Running
kubectl get pods -A --field-selector spec.nodeName=k8s-node-02
# 期望:全部 Running
六、风险与回滚方案¶
6.1 风险评估¶
| 风险 | 等级 | 缓解措施 |
|---|---|---|
| rm -rf 误删其他 Pod 目录 | 🔴 HIGH | 5 项前置安全检查 + UUID 格式校验 + tar 备份 |
| kubelet 启动失败 | 🟡 MEDIUM | systemctl start 失败时立即 journalctl 排查 |
| 误删活 Pod | 🔴 HIGH | 步骤1 ④校验目标 UID 不在节点活 Pod UID 列表 |
| 误删 PV claimRef 持有的 UID | 🔴 HIGH | 步骤1 ②校验无 PV 持有此 UID |
| 残留 mount 挂载点导致 rm 失败 | 🟡 MEDIUM | 步骤1 ③校验 mount 中无此 UID 挂载点 |
6.2 一键回滚方案¶
# 如误删了其他 Pod 目录 → 从备份还原
ssh k8s-node-02 "tar -xzf /tmp/orphaned_pod_bak_<UID>_<TS>.tar.gz -C /"
# 如 kubelet 启动失败 → 看日志
ssh k8s-node-02 "systemctl start kubelet; journalctl -u kubelet -n 50 --no-pager"
七、关联文件清单(运维回溯用)¶
| 文件/路径 | 说明 |
|---|---|
| 资产清单 | ../inventory.yaml |
| 本 SOP 文档 | ./SOP-K8s节点orphaned-pod残留目录清理.md |
| 修复脚本 | ./k8s_cleanup_orphaned_pod.sh(可执行,8003 字节) |
| 残留目录备份 | k8s-node-02:/tmp/orphaned_pod_bak_322a2355-92c8-4a64-a0c9-f6e9f8705c58_20260824_111130.tar.gz(1.1K) |
| 修复前巡检报告 | ../k8s_core_components_alerts/K8s集群核心组件日志告警汇总排查报告.md(含告警 #2 详情) |
| SSH 免密配置 SOP | ../ssh_passwordless_setup/SOP-0.11到12台主机SSH免密登录配置.md |
八、注意事项与后续优化建议¶
8.1 注意事项¶
- K8s 1.22 已知 bug 未根治:本次清理只清当前残留,未来再删除带 NFS subPath 的 Pod 还会留下孤儿
- 脚本只清理已知 UID:如未来节点出现多个孤儿 Pod,需要为每个 UID 单独执行
- kubelet 启动后约 60 秒内有瞬时告警:是 kubelet 初始化扫描造成的,不是失败信号
- 修复脚本可在 6 个节点上复用:未来任何节点出现 orphaned pod 告警,可用同一脚本清理
8.2 后续优化建议¶
- 批量清理脚本:可扩展为扫描所有节点的 orphaned pod 残留并批量清理(当前是单节点单 UID)
- Prometheus 告警:在 PrometheusRule 中加一条 alert,监控
kubelet_orphaned_pod告警,发现立即通知 - 根本解决:长期建议升级 K8s 到 1.23+(修复 volume-subpaths 清理逻辑),或迁移到 containerd(更新 CRI runtime)
九、执行结果归档¶
| 阶段 | 状态 | 验收 |
|---|---|---|
| FIX2-1 备份 + 5 项前置安全检查 | ✅ 完成 | API 不存在 + 无 PV 持有 + 无 mount 拋留 + 非活 Pod + 目录存在 |
| FIX2-2 停 kubelet + rm -rf + 启 kubelet | ✅ 完成 | kubelet 状态 active,目录已删除 |
| FIX2-3 最终验证 | ✅ 完成 | 60 秒内 0 告警 + 目录数差=0 + 目标目录已删除 |
| FIX2-4 SOP.md + 修复脚本 | ✅ 完成 | 本文档 + k8s_cleanup_orphaned_pod.sh (可执行) |
🎉 总体验收:告警 #2 orphaned pod 清理失败 100% 修复,未来类似问题可用脚本一键复用。
来源:
/home/rain/Documents/trae_for_om/k8s_orphaned_pod_cleanup/SOP-K8s节点orphaned-pod残留目录清理.md| 最后更新:2026-08-27 17:50(由build/sync.py同步,请勿在站点侧修改)