跳转至

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 达成的运维能力

  1. 告警噪音消除:node-02 kubelet 日志不再每 2 秒报 1 次 orphaned 告警
  2. 磁盘泄漏修复:8KB 残留目录已清,未来再发生类似问题可用同一脚本快速清理
  3. 运维能力可复用:生成了 k8s_cleanup_orphaned_pod.sh 脚本,未来节点出现 orphaned pod 告警可一键修复
  4. 不升级 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 注意事项

  1. K8s 1.22 已知 bug 未根治:本次清理只清当前残留,未来再删除带 NFS subPath 的 Pod 还会留下孤儿
  2. 脚本只清理已知 UID:如未来节点出现多个孤儿 Pod,需要为每个 UID 单独执行
  3. kubelet 启动后约 60 秒内有瞬时告警:是 kubelet 初始化扫描造成的,不是失败信号
  4. 修复脚本可在 6 个节点上复用:未来任何节点出现 orphaned pod 告警,可用同一脚本清理

8.2 后续优化建议

  1. 批量清理脚本:可扩展为扫描所有节点的 orphaned pod 残留并批量清理(当前是单节点单 UID)
  2. Prometheus 告警:在 PrometheusRule 中加一条 alert,监控 kubelet_orphaned_pod 告警,发现立即通知
  3. 根本解决:长期建议升级 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 同步,请勿在站点侧修改)