Pinterest 如何揪出 Kubernetes 集群里的"CPU 僵尸"

2026-05-14 24 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:10 分钟

Pinterest 的机器学习训练任务在 PinCompute(基于 Kubernetes 的内部平台)上频繁出现性能抖动——训练跑着跑着就慢了,吞吐量忽高忽低,却找不到明显的业务代码问题。最终,工程师把根因锁定在一个根本没在用的 Amazon ECS Agent:它悄悄制造了 memory cgroup 泄漏,导致 CPU 调度出现饥饿。禁用该 Agent 后,集群性能立刻恢复平稳。

这个案例值得每个跑混合负载 K8s 集群的人看看:系统默认配置里藏着什么,往往比你的应用代码更致命。

症状:训练任务遭遇 CPU 饥饿

Pinterest 的 PinCompute 上同时跑着在线服务和离线 ML 训练。训练任务对 CPU 利用率极其敏感——一旦拿不到足够的 CPU 时间,batch 处理速度就会肉眼可见地下降。

问题表现: - 训练任务 CPU 利用率周期性跌到极低值,但节点整体负载并不高 - 同一节点上的在线服务基本不受影响 - 问题跨多个节点随机出现,没有固定模式

这种"CPU 明明有空却吃不到"的现象,典型的术语叫 CPU starvation——任务被调度器限流,但限流的根因不在业务层。

排查:从 cgroup 泄漏找到僵尸进程

Pinterest 工程师的排查路径大致如下:

  1. 确认不是业务代码问题——训练脚本本身没有显式的 CPU limit 突破或死锁。
  2. 在节点层面抓指标——发现出问题节点的 /sys/fs/cgroup/memory 下出现了大量未被回收的 cgroup 目录。
  3. 追踪 cgroup 来源——这些泄漏的 cgroup 属于一个已经不再使用的 Amazon ECS Agent。Pinterest 早期曾用 ECS 跑部分工作负载,后来迁移到 PinCompute,但 ECS Agent 作为 AMI 默认安装的一部分仍然留在节点上。
  4. 验证因果关系——ECS Agent 即使不接收任何任务,也会持续创建和销毁 cgroup 来模拟容器生命周期。由于 Agent 已被弃用,这些 cgroup 的清理逻辑不再触发,memory cgroup 逐渐堆积。Linux 内核在 cgroup 数量过多时,调度器的遍历开销上升,直接拖慢了 CPU 时间分配。

一句话总结:一个没人用的 Agent,在后台默默泄漏 cgroup,把 CPU 调度器拖成了僵尸。

修复:一刀禁用,立竿见影

修复方式极其简单——在节点初始化脚本中禁用 ECS Agent:

# 在节点 bootstrap 或 user-data 脚本中加入
systemctl stop ecs-agent || true
systemctl disable ecs-agent || true

# 可选:彻底卸载,避免残留
yum remove -y ecs-agent || apt-get remove -y ecs-agent || true

禁用后,泄漏的 cgroup 不再新增,已有的残留 cgroup 在节点重启后被内核回收。训练任务的 CPU 利用率立刻回到预期水平,抖动消失。

Pinterest 工程师在博客中强调:这个案例的核心教训不是"ECS Agent 有 bug",而是你必须审视节点上的每一个默认组件,哪怕它看起来和你当前的工作负载毫无关系

实战:如何在自己的集群里检测类似问题

Pinterest 的遭遇不是孤例。任何从其他调度系统迁移到 Kubernetes 的团队,都可能遇到类似的"遗留组件暗中搞破坏"的情况。下面是一套可以直接拿去用的检测脚本。

检查节点上的 cgroup 泄漏

#!/bin/bash
# check_cgroup_leak.sh — 检测 memory cgroup 泄漏
# 用法: 在目标节点上执行,或在 debug pod 中挂载 node rootfs 后执行

CGROUP_ROOT="/sys/fs/cgroup/memory"

# 统计 memory cgroup 目录数量
total_dirs=$(find "$CGROUP_ROOT" -type d | wc -l)
echo "memory cgroup 目录总数: $total_dirs"

# 找出没有活跃进程的僵尸 cgroup(目录存在但 cgroup.procs 为空)
zombie_count=0
zombie_samples=""
while IFS= read -r dir; do
    procs=$(cat "$dir/cgroup.procs" 2>/dev/null | wc -l)
    if [ "$procs" -eq 0 ]; then
        zombie_count=$((zombie_count + 1))
        # 只记录前 5 个样本
        if [ "$zombie_count" -le 5 ]; then
            zombie_samples="$zombie_samples\n  $dir"
        fi
    fi
done < <(find "$CGROUP_ROOT" -mindepth 1 -type d)

echo "僵尸 cgroup 数量(无活跃进程): $zombie_count"
if [ "$zombie_count" -gt 0 ]; then
    echo -e "僵尸 cgroup 样本:$zombie_samples"
fi

# 阈值警告:超过 500 个僵尸 cgroup 就值得深入排查
if [ "$zombie_count" -gt 500 ]; then
    echo "⚠️  僵尸 cgroup 超过 500,可能影响 CPU 调度性能,建议排查来源"
fi

运行前注意:在 K8s 节点上直接执行即可。如果集群不允许 SSH 到节点,可以起一个 privileged debug pod: kubectl debug node/<node-name> -it --image=ubuntu --profile=sysadmin

检查节点上不该存在的遗留服务

#!/bin/bash
# check_legacy_services.sh — 扫描节点上与当前 K8s 工作负载无关的服务

# 常见的遗留调度器 Agent 列表,按需增补
LEGACY_SERVICES=(
    "ecs-agent"          # Amazon ECS
    "mesos-agent"        # Mesos
    "nomad-agent"        # HashiCorp Nomad
    "docker"             # 旧版 Docker daemon(K8s 1.24+ 已弃用 dockershim)
)

echo "=== 检查遗留服务 ==="
for svc in "${LEGACY_SERVICES[@]}"; do
    if systemctl is-active "$svc" 2>/dev/null | grep -q "active"; then
        echo "⚠️  $svc 正在运行但可能不再需要"
    elif systemctl is-enabled "$svc" 2>/dev/null | grep -q "enabled"; then
        echo "⚠️  $svc 已启用但未运行,仍可能在节点重启后启动"
    fi
done

echo ""
echo "=== 当前活跃的 systemd 服务(含 agent/daemon 关键词) ==="
systemctl list-units --type=service --state=active | grep -iE "agent|daemon" | head -20

在节点初始化时主动清理(Terraform/user-data 示例)

如果你用 EKS 且节点从托管 AMI 启动,可以在 user-data 中加一段清理:

# EKS managed node group user-data snippet (MIME multipart format)
cat <<'CLEANUP' > /etc/bootstrap_cleanup.sh
#!/bin/bash
set -e

# 禁用 ECS Agent(EKS AMI 默认包含)
systemctl stop ecs-agent || true
systemctl disable ecs-agent || true

# 清理残留 cgroup(重启后内核会回收,这里做一次即时清理)
# 注意:只清理 kubepods 外的空 cgroup
find /sys/fs/cgroup/memory -mindepth 2 -type d -not -path "/sys/fs/cgroup/memory/kubepods*" | while read -r dir; do
    procs=$(cat "$dir/cgroup.procs" 2>/dev/null | wc -l)
    if [ "$procs" -eq 0 ]; then
        # 尝试删除空 cgroup(内核允许时才会成功)
        rmdir "$dir" 2>/dev/null || true
    fi
done

echo "Cleanup done at $(date)" >> /var/log/node_cleanup.log
CLEANUP

chmod +x /etc/bootstrap_cleanup.sh
# 在节点启动后异步执行,避免阻塞 kubelet 启动
nohup /etc/bootstrap_cleanup.sh &

采纳建议与排查清单

Pinterest 这个案例看起来简单,但背后的模式很普遍:集群迁移后遗留的"无害"组件,在特定负载下变成性能杀手。以下是一个排查清单,适用于任何从旧平台迁移到 K8s 的团队:

  • 审计节点 AMI 默认安装的所有服务——EKS、GKE、AKS 的托管 AMI 都会装一些你可能不需要的 Agent。列出它们,逐个判断是否还要保留。
  • 监控 cgroup 数量趋势——在 Prometheus 中抓取 container_cpu_cgroup_periods_total 或自定义指标,设置告警阈值。cgroup 数量持续增长且不回落,就是泄漏信号。
  • 区分 CPU throttling 和 CPU starvation——前者是 resources.limits.cpu 导致的限流,后者是调度层面的饥饿。排查时先看 kubectl top pod 和节点级 CPU 指标,如果 pod 没超 limit 但 CPU 利用率仍然低,往 cgroup 和调度器方向查。
  • 迁移后做一轮"减法"——每迁移一个工作负载到 K8s,就检查旧调度器在节点上的残留是否已彻底移除。不要只看"服务是否停了",还要看"AMI 里是否还装着"。
  • 保留 debug 路径——确保集群允许 privileged pod 或 node debug,否则遇到这类底层问题时你连 cgroup 目录都看不到。

最后一点:Pinterest 工程师在回顾时说,这个问题的根因"不在我们写的代码里,而在我们没删掉的代码里"。下次你的 K8s 集群出现莫名其妙的性能抖动,先别急着改业务逻辑——看看节点上还藏着哪些僵尸。


相关推荐