Pinterest 的机器学习训练任务在 PinCompute(基于 Kubernetes 的内部平台)上频繁出现性能抖动——训练跑着跑着就慢了,吞吐量忽高忽低,却找不到明显的业务代码问题。最终,工程师把根因锁定在一个根本没在用的 Amazon ECS Agent:它悄悄制造了 memory cgroup 泄漏,导致 CPU 调度出现饥饿。禁用该 Agent 后,集群性能立刻恢复平稳。
这个案例值得每个跑混合负载 K8s 集群的人看看:系统默认配置里藏着什么,往往比你的应用代码更致命。
症状:训练任务遭遇 CPU 饥饿
Pinterest 的 PinCompute 上同时跑着在线服务和离线 ML 训练。训练任务对 CPU 利用率极其敏感——一旦拿不到足够的 CPU 时间,batch 处理速度就会肉眼可见地下降。
问题表现: - 训练任务 CPU 利用率周期性跌到极低值,但节点整体负载并不高 - 同一节点上的在线服务基本不受影响 - 问题跨多个节点随机出现,没有固定模式
这种"CPU 明明有空却吃不到"的现象,典型的术语叫 CPU starvation——任务被调度器限流,但限流的根因不在业务层。
排查:从 cgroup 泄漏找到僵尸进程
Pinterest 工程师的排查路径大致如下:
- 确认不是业务代码问题——训练脚本本身没有显式的 CPU limit 突破或死锁。
- 在节点层面抓指标——发现出问题节点的
/sys/fs/cgroup/memory下出现了大量未被回收的 cgroup 目录。 - 追踪 cgroup 来源——这些泄漏的 cgroup 属于一个已经不再使用的 Amazon ECS Agent。Pinterest 早期曾用 ECS 跑部分工作负载,后来迁移到 PinCompute,但 ECS Agent 作为 AMI 默认安装的一部分仍然留在节点上。
- 验证因果关系——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 集群出现莫名其妙的性能抖动,先别急着改业务逻辑——看看节点上还藏着哪些僵尸。