结论先行
你 2.7ms 的写延迟增量,大概率不是 CPU,而是「K3s 组件 + 业务容器共享同一块 eMMC 的块设备排队(queue depth 叠加 + 调度器抢占)」。CPU 只多 5 个点、await 却从 1.2→3.5ms,这个指纹指向 I/O 侧竞争而非算力不足。要拆到「每个 K3s 组件各偷了多少」,cgroup v2 的 io.stat 是权威数据源(比 pidstat -d 更准、且能落到 cgroup 而非进程),配合「逐组件 cgroup 隔离 + 单一变量开关」就能得到可复现的纯业务基线。下面给可直接抄的命令、隔离手段和一个 4C ARM 的开销预算锚点。
一、权威数据源:cgroup v2 的 io.stat + cpu.stat(不是 pidstat)
pidstat -d 的坑你已经踩了:它按进程聚合,宿主机进程和业务容器不在同一 cgroup 下,而且它读的是 /proc/<pid>/io,看不到 cgroup 维度的 block 队列归属。cgroup v2 下每个组件(kubelet / containerd / coredns / metrics-server / 业务容器)都是一个独立 cgroup,io.stat 能直接读出该 cgroup 实际落盘的块数、读写字节,这才是「谁在写 eMMC」的权威账本。
1.1 找到每个组件的 cgroup 路径
K3s 默认把组件跑在 systemd 服务里(k3s.service),而业务容器在 kubepods.slice 下。先定位:
# 宿主机组件(systemd 管理的)
systemctl show -p ControlGroup k3s
# 典型值: /system.slice/k3s.service
# 注意:K3s 单二进制,kubelet/containerd/supervisor 都在这一个 cgroup 里(见 §3 拆分)
# 业务 Pod 容器
crictl ps -q | head -1 | xargs -I{} crictl inspect {} | grep -i cgroup
# 或在节点上:
ls /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/
# 业务容器一般在这里:
# /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<UID>.slice/<container-id>.scope
1.2 读 io.stat(落盘字节/块数)与 cpu.stat(CPU 时间)
io.stat 是关键。格式:<dev> <key> <value>,dev 是 MAJ:MIN(先 lsblk / cat /proc/partitions 找到 eMMC 的 MAJ:MIN,RK3588 上一般是 179:0 或 179:24,以你 lsblk 为准):
DEV="179:0" # 用 lsblk 确认 eMMC 的 MAJ:MIN
CG=/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<UID>.slice/<cid>.scope
echo "=== 业务容器 io.stat ==="
cat $CG/io.stat
# 关注字段:
# <DEV> read_bytes <N> 读字节
# <DEV> write_bytes <N> 写字节
# <DEV> writes <N> 写块数(512B 块)
# <DEV> dflimit / wlatency / rlatency 各内核版本不同,5.10 部分有
echo "=== 业务容器 cpu.stat ==="
cat $CG/cpu.stat
# 关注:
# usage_usec <N> 累计 CPU 时间(微秒)
# user_usec / system_usec
采样方法(10s 窗口做差值,比单次快照可信):
#!/bin/bash
# k3s_iobaseline.sh — 对指定 cgroup 采 10s 差值
CG=$1
DEV=$2
read_io(){ grep -E "^${DEV} (write_bytes|writes) " $CG/io.stat | awk '{s+=$3} END{print s+0}'; }
read_cpu(){ grep usage_usec $CG/cpu.stat | awk '{print $2}'; }
W1=$(read_io); C1=$(read_cpu)
sleep 10
W2=$(read_io); C2=$(read_cpu)
echo "write_bytes/s = $(( (W2-W1)/10 ))"
echo "cpu_pct(单核) = $(awk "BEGIN{print ($C2-$C1)/100000/10*100}")"
对 kubelet cgroup、业务 cgroup 各跑一遍,你就拿到「K3s 组件 10s 写了多少字节、业务写了多少」的直接对比——这比 iostat 的全局 await 能直接回答「谁的写块占了队列」。
二、可复现的「逐组件开关」基线(这才是你要的基线测量法)
cgroup io.stat 告诉你总量,但要拆「kubelet 心跳 vs containerd 事件日志 vs coredns」各占多少,最干净的还是单一变量法——你 systemctl stop k3s 已经做了「K3s 全开 vs 全关」的两个极值,中间补几个点就能归因:
| 实验 | 操作 | 测什么 |
|---|---|---|
| E0(基线) | 裸机 + systemd,业务单跑 | 纯业务 P99(你已测 2.1ms) |
| E1 | K3s 全开 + 业务 | K3s 全量开销(你已测 4.8ms) |
| E2 | K3s 全开,但 --disable=metrics-server | 摘掉 metrics-server 的拉取日志/etcd 读 |
| E3 | K3s 全开 + coredns 关闭(用 --disable=coredns + 节点 hosts) | 摘掉 DNS 回包 I/O |
| E4 | K3s 全开 + 日志重定向到 tmpfs(见 §3) | 隔离「日志写盘」这一项 |
每个实验都跑 同一段业务压测(100ms 采集周期 × 300s),记录 eMMC await + 业务 P99 写延迟。相邻两个实验的差值 = 被摘组件的开销。这套做完你手上就有「kubelet/containerd/coredns/metrics-server 各偷了多少 ms」的分组件账本,而且可复现(命令固定、压测脚本固定)。
三、更细粒度隔离:把 K3s 组件的写 I/O 挪出 eMMC(治本)
你问的「限 I/O」方向对,但 io.max 限不住宿主机进程的问题有个更聪明的绕法——不是限流,而是把它们的写盘从 eMMC 上挪走:
-
日志重定向到 tmpfs(收益最大,直接消掉一部分 eMMC 写):
# K3s 组件日志默认落 /var/log/pods、/var/log/k3s、/run/k3s/agent 下 # 把日志目录挂到 tmpfs,写日志就不再落 eMMC: mkdir -p /run/k3s-logs mount -t tmpfs -o size=256M tmpfs /run/k3s-logs # 配合 journald: /etc/systemd/journald.conf # Storage=volatile # 日志不落盘 # SystemMaxUse=128M # RuntimeMaxUse=128M systemctl restart systemd-journald注意:tmpfs 日志会丢历史,边缘节点如果依赖日志排障,保留最近 N 小时即可,用
journalctl --vacuum-time=12h控制。 -
containerd 事件/镜像层:镜像本身必须落盘(没法挪),但镜像 GC 的写放大是 eMMC 杀手(你引用的 #965 讲的就是这个)。给 containerd 单独 cgroup 限 I/O 带宽:
# K3s 单二进制,kubelet+containerd 同 cgroup。要拆分需 K3s 1.31 的 --node-label 拆分 # 或直接用 io.max 给 k3s.service 的 cgroup 限写带宽(字节/秒): CG=/sys/fs/cgroup/system.slice/k3s.service DEV="179:0" echo "${DEV} wbps=1048576" > $CG/io.max # 写带宽限 1MB/s,防止 GC 时打满队列wbps(write bytes per second)在 cgroup v2 5.10 内核下可用(需CONFIG_BLK_CGROUP_IOLIMIT),RK3588 的 5.10 通常开了。这能把你偶发的 12ms 尖峰(大概率是镜像 GC 或 journal flush 撞车)压平。 -
业务容器反向保护:给业务容器 cgroup 设
io.weight拉高(默认 100,提到 200+),在调度器层面让业务写优先于 K3s 组件写:echo 200 > $BUSY_CG/io.weight
四、4C ARM 的开销预算锚点(给你留多少算力)
我这边没有 RK3588 的实测(不同 eMMC 颗粒差异大),但给一个 K3s 边缘节点的经验预算,你用 §2 的 E0–E4 实验去校准:
| 项 | 稳态 CPU | 事件峰值 CPU | 说明 | |---|---|---|---| | kubelet(心跳/状态写) | ~0.05C | 0.2–0.3C(Pod 重启时) | 稳态很轻,事件时抖 | | containerd(运行时) | ~0.1C | 0.5C(镜像拉取/GC 时) | GC 是 I/O 大头,不是 CPU | | coredns | ~0.05C | 0.1C | 稳态轻 | | metrics-server + etcd(K3s 内置) | ~0.1C | 0.3C | etcd 的 fsync 是 eMMC 写来源之一 | | K3s 合计(稳态) | ~0.3C | ~1C+ | 4C 节点,稳态偷走 7–8% 算力 | | I/O | 稳态低 | GC/日志 flush 时打满队列 | 这才是你 2.7ms 的主因 |
给你的预算建议:4C 节点,给业务留 ≥3C 稳态(K3s 稳态 0.3C + 系统 0.4C 留余量);I/O 上,K3s 组件的写带宽峰值要压到 ≤ 业务写带宽的 20%(用 §3 的 io.max wbps 实现),这样业务 P99 写延迟才能稳定在 5ms SLA 内。你实测 2.7ms 增量里,估计 ≥60% 来自「日志 + etcd fsync + 镜像 GC 三者撞同一队列」,把 §3 的三条(日志 tmpfs + io.max + io.weight)做掉,大概率能收回 1.5–2ms。
五、边界条件(哪些情况这套会失效)
- etcd 是 K3s 内置且 fsync 严格:K3s 单节点模式下 etcd 就在同一块 eMMC,
sync频率高,io.stat里会看到 k3s.service cgroup 的 write_bytes 周期性尖峰——这是 K3s 单节点 vs K3s agent 模式的本质差异,单节点模式 I/O 开销更高; - tmpfs 日志的坑:节点掉电日志全丢,边缘场景如果排障依赖历史日志,需要
journalctl定时导出到对象存储/上级节点,别裸奔 tmpfs; - io.max 的 5.10 内核依赖:
wbps需要CONFIG_BLK_CGROUP_IOLIMIT=y,RK3588 厂商内核有些裁掉了,cat /sys/kernel/config/...或echo试写io.max报EINVAL就说明没开,退回用io.cost(cgroup v2 的 cost model,更细但配置更复杂); - eMMC 颗粒差异:不同批次 eMMC 的队列深度、写放大系数差异很大,你的基线只能在自己的板上量,跨板不可直接套用数字(命令和流程可套,阈值要重标)。
一句话:用 cgroup v2 io.stat 做权威账本 + E0–E4 单一变量开关做分组件归因 + 「日志 tmpfs / io.max / io.weight」三条把 K3s 写 I/O 挪出业务队列——这套做完,「K3s 偷了多少、留多少给业务」就从猜变成可复现的数字了。