有些时候,服务器就像凌晨三点的夜市,大家都在抢一个插座。容器应用说我要 CPU,宿主机上的监控脚本也说我需要 CPU,结果谁都没落着好,服务响应一会儿快一会儿慢。这篇文章记录了我用 CRI-O 管理容器时遇到的一次资源竞争排查经历,以及后来我如何通过给容器“定食谱”的方式,把资源分配策略调整得明明白白。

一、场景还原:一个疯狂吃 CPU 的容器

事情是这样的,一台 8 核 16G 的测试机上,用 CRI-O 跑着好几个容器。其中一个容器是跑大数据分析的,一到凌晨就自动开始算东西。本想着这些容器各干各的,互不打扰,结果第二天早上同事说服务器奇卡无比,连 SSH 登录都要转好几圈。

我赶紧上线看了一眼,系统负载直接飙到了 20 多。再打开进程监视画面一看,好几个 dd 进程在疯狂刷 CPU。这些 dd 进程不只是把容器内的 CPU 占满,还让宿主机上的其他进程也跟着倒霉。这种现象,就是我们常说的资源竞争:多个进程或容器同时抢物理机上的 CPU、内存,谁也不让谁。

要明白问题所在,得先看看这些 dd 进程是从哪个容器里冒出来的。这里我用的技术栈是 Linux Shell + CRI-O + systemd,下面所有排查和优化命令都基于这套环境。

# 先看系统负载,三个数字分别代表 1 分钟、5 分钟、15 分钟的平均负载
uptime

# 再打开进程监视画面,按 CPU 排序,只看前 20 行
top -b -n 1 -o %CPU | head -20

在那台机器上,进程监视画面的输出特别吓人。第一行显示平均负载已经超过了 CPU 核数,这通常意味着有进程在排队等 CPU。第二行是进程列表,排在前面的是清一色名字叫 dd 的进程,CPU 使用率加起来快 700%。那台机器总共 8 核,光一个容器就用掉了接近 7 核,留下的余量自然不多。

二、问题排查:用 crictl 和 systemd 顺藤摸瓜

既然定位到是 dd 进程在捣乱,下一步就要找到它的“主人”。在 CRI-O 环境里,crictl 是最常用的命令行工具,它可以查看容器、镜像、Pod 等信息。

2.1 列出现有容器

# 查看所有正在运行的容器
crictl ps -a

输出会有一串 ID、容器名、镜像名和状态。我根据镜像名(比如分析任务用的镜像叫 resource-test)很快就锁定了那个异常容器。注意,刚才那个容器列表命令默认只显示运行中的容器,加一个参数可以连已经停止的一起看。

2.2 获取容器对应的宿主机进程 PID

容器不是虚拟机,它本质上就是宿主机上的几个进程。要想知道容器里那个 dd 进程的宿主 PID,可以用专门的容器详情命令拿到容器实例的详细信息,里面包含容器主进程的 PID。

# 先用容器名或 ID 拿到完整容器 ID
CID=$(crictl ps -a | grep resource-test | awk '{print $1}')

# 从详情结果里提取主进程 PID
PID=$(crictl inspect "$CID" | grep -E '"pid"' | head -n 1 | grep -oE '[0-9]+')

# 打印 PID,方便下一步使用
echo "容器主进程 PID: $PID"

这里用到了 Shell 脚本里的变量赋值和管道处理。文本过滤和按列取数这两个工具是我们日常排查问题的好帮手,用它们从大段文本里抓取关键数字非常顺手。

2.3 查看进程的 cgroup 归属

拿到 PID 之后,我们可以看一眼这个进程跑在哪个 cgroup 里。cgroup 是 Linux 内核用来限制、记录进程资源使用的机制,CRI-O 和 systemd 都靠它做资源管控。

# 查看 PID 对应的 cgroup 路径
cat /proc/$PID/cgroup

在我的那次故障现场,输出结果里出现了类似这样的路径:

0::/system.slice/crio.service/kubepods/besteffort/pod1234/abc456

翻译一下:这个进程归属于 systemd 管理的 crio.service 单元,接着下面又挂着一个 kubepods 的 cgroup 目录,这是 Kubernetes 和 CRI-O 为所有 Pod 创建的公共父目录。后面那个 besteffort 说明这个 Pod 属于“尽力而为”级别,也就是没有设置任何资源请求或限制的 Pod。也就是说,容器在创建时压根没给资源约束,内核觉得它有本事就尽情用。

如果还不放心,可以直接查看 cgroup 里的资源使用统计。在 cgroup v2 环境下,CPU 总使用时间可以从统计文件里读出来:

# 根据 cgroup 路径拼接成实际目录,再读取 CPU 统计信息
CGPATH=$(cat /proc/$PID/cgroup | cut -d ':' -f 3)
cat /sys/fs/cgroup$CGPATH/cpu.stat

看到 usage_usec 和 system_usec 这两个数字一直疯涨,就可以确定是这个容器在大量消耗 CPU。

三、技术剖析:CRI-O 容器为啥会跟宿主机抢资源

这一节聊聊原理。CRI-O 本身是 Kubernetes 的容器运行时之一,它的工作就是跟内核打交道,把镜像启动成隔离的容器。然而隔离并不等于限制。默认情况下,没有设置资源上限的容器会像饿汉一样,宿主有多少空闲 CPU,它就敢用多少。

Linux 内核通过一组叫 cgroup 的控制器来管理资源。CPU 控制器负责给进程分配 CPU 时间;内存控制器负责统计和限制内存使用。CRI-O 创建容器时,会把 Kubernetes 里配置的资源请求和上限转换成 cgroup 参数。如果没配置,它就不写这些参数,于是容器进程继承的父 cgroup 没有任何限制,宿主机上其他进程当然抢不过它。

用一句话总结:CRI-O 只是负责“开门”的角色,真正决定“锅里饭怎么分”的是 cgroup。而 Kubernetes 的资源字段又是给 CRI-O 传话的入口。

所以在排查资源竞争问题时,除了看容器进程,更要看它的 cgroup 配置。下面这条命令可以输出某个容器在 cgroup 中的 CPU 限制情况:

# CPU 账本,period 是调度周期,quota 是周期内最多能用的 CPU 时间
cat /sys/fs/cgroup$CGPATH/cpu.max

如果输出是 max 100000,说明 CPU 不受限制;如果看到类似 200000 100000,就说明在 100ms 周期内最多用 200ms CPU 时间,也就是最多占用 2 核。

四、优化调整:给容器先定规矩,再让它干活

找到原因后,接下来就是调整资源分配策略。在正式环境里,最佳做法是在 Kubernetes 的 Pod 定义里写清楚资源请求和上限。但如果你只是临时测试,或者希望通过 CRI-O 命令行手动跑一个限制容器,也可以直接使用 CRI-O 自带的运行命令配合 JSON 配置实现。

4.1 通过 crictl 配置 CPU 和内存限制

技术栈仍然是 Linux Shell + CRI-O + systemd。我先用 Shell 命令生成一个 Pod 沙箱配置文件,再生成一个容器配置文件。容器配置中的 resources 字段可以指定 CPU period、CPU quota 和内存上限。

# 创建 Pod 沙箱配置文件,注意 JSON 本身不允许注释,所以解释放在 Shell 注释里
cat > /tmp/sandbox.json <<'EOF'
{
  "metadata": {
    "name": "resource-demo-sandbox",
    "namespace": "default",
    "attempt": 1
  },
  "log_directory": "/tmp/pod-logs",
  "linux": {}
}
EOF

# 创建容器配置文件,重点看 linux.resources
cat > /tmp/container.json <<'EOF'
{
  "metadata": {
    "name": "resource-demo-container",
    "attempt": 1
  },
  "image": {
    "image": "docker.io/library/busybox:latest"
  },
  "command": [
    "/bin/sh",
    "-c",
    "for i in 1 2 3 4; do dd if=/dev/zero of=/dev/null & done; wait"
  ],
  "linux": {
    "resources": {
      "cpu_period": 100000,
      "cpu_quota": 200000,
      "memory_limit_in_bytes": 268435456
    }
  }
}
EOF

上面这个命令我先用 cat 重定向创建了沙箱配置,又创建了容器配置。容器启动之后会同时跑四个 dd 进程,正常情况下能轻松吃满四个核。但由于设置了 cpu_quota 为 200000,cpu_period 为 100000,这四个进程加在一起最多只能用两个核。内存限制 268435456 字节,也就是 256MB,可以防止容器无限地消耗内存。

接下来用 CRI-O 的容器管理命令把容器跑起来。我这里使用标准的三步流程:先启动沙箱,再创建容器,最后启动容器。

# 第一步:启动 Pod 沙箱,拿到 Pod ID
POD_ID=$(crictl runp /tmp/sandbox.json)

# 第二步:在沙箱里创建容器,拿到容器 ID
CONTAINER_ID=$(crictl create "$POD_ID" /tmp/container.json /tmp/sandbox.json)

# 第三步:正式启动容器
crictl start "$CONTAINER_ID"

# 检查容器状态
crictl ps -a | grep resource-demo-container

启动之后,再到宿主机上用系统负载命令或者进程监视画面看,你会发现即使容器里面四个 dd 进程都在跑,整体 CPU 占用率也会被死死摁在 200% 左右。这就是资源限制的威力。

4.2 使用 systemd 对相关资源组做限制

如果你管着很多容器,不方便逐个改配置,也可以从 systemd 入手。CRI-O 启动容器后,容器进程通常挂在 crio.service 相关的 cgroup 切片下面。整体限制这些切片,能让宿主机留出“保命资源”。

# 限制整体资源组 CPU 上限为 4 核
systemctl set-property kubepods.slice CPUQuota=400000

# 同时限制内存上限为 8G
systemctl set-property kubepods.slice MemoryMax=8G

# 查看当前限制是否生效
systemctl show kubepods.slice -p CPUQuota -p MemoryMax

这种做法适合机器上所有 Pod 都属于内部业务、可以共享资源池的场景。因为它是“一刀切”,所以需要谨慎设置,不然某些高并发服务可能会莫名其妙变慢。

4.3 验证资源限制的效果

不管是哪种方式,最终都要用数据说话。CRI-O 自带的资源统计命令可以实时查看每个容器的 CPU 和内存消耗:

# 持续观察容器资源占用
crictl stats

# 只看刚才那个容器
crictl stats | grep resource-demo-container

输出里 CPU 百分比那一栏如果接近 200%,就说明限制已经生效。如果想看到更精确的 CPU 时间,可以查看 cgroup 下面的统计文件,方法和前面排查时一样。

以上两种方式的应用场景不太一样。crictl 手动配置适合临时验证某个镜像在限定 CPU 或内存下的表现,比如想测一测 Java 服务在 512MB 内存下会不会频繁 Full GC。而 systemd 对资源组做限制,更适合在混合部署机器上给所有容器画一条安全线,避免某个 Pod 异常拖垮同一台机器上的其他 Pod。

五、注意事项和技术优缺点

先说注意事项。第一,资源限制不是越大越好,也不是越小越好。限制太紧,容器会频繁被内核“暂停”,业务延迟上升;限制太宽,容器之间还是会互相抢。第二,不要手动去修改 cgroup 目录里的文件。CRI-O 和 systemd 有自己的一套管理流程,手动改很容易在容器重启或者 Pod 删除后失效,甚至导致状态不一致。第三,CPU quota 对单线程程序有局限。假设你只给了 1 核配额,但程序是单线程的,它依然是在单个 CPU 核上跑,并不能因为配额少就把它的单核频率降下来,所以单线程应用的延迟可能会受影响。

再来说说技术优缺点。使用资源限制的最大优点是提高了整体稳定性。一个容器里出现死循环也不会拖垮宿主机,其他服务还能正常工作。缺点是区分不同业务的资源需求需要经验。比如有些在线业务对 CPU 非常敏感,如果配额留得太小,即使负载不高也会出现大量超时。做容量规划的时候,最好通过压测来确定合理的请求和限制值。

另外,CRI-O 与 Kubernetes 的联动需要格外注意。如果通过 Kubernetes 部署应用,资源字段是首选;crictl 手动运行容器的方式更适合平时调试或者临时验证。不要把临时配置当成生产长期方案,那样会失去 Kubernetes 的资源调度能力。

六、总结

CRI-O 容器跟宿主机之间出现抢资源的情况,本质上不是 CRI-O 本身的问题,而是我们没设置资源边界。cgroup 这个内核机制把每个容器能吃的“饭量”限定住了,CRI-O 又负责把资源请求翻译成 cgroup 参数。排查问题时,先观察系统负载和进程情况,再用容器管理命令找出容器,最后用 cgroup 文件确认限制情况。解决时,要么在容器配置里写明 CPU 和内存上限,要么在 systemd 层面对资源组做整体控制。

资源分配策略没有标准答案,重点是找到适合自己业务的边界。留足系统空余,设定合理的上限,再配合监控告警,容器之间才能真正做到“和平共处”。