Linux、cgroup与容器诊断:宿主机空闲,服务为何仍然慢
一次请求变慢,可能是在等调度、锁、页、连接或下游。先从进程和资源边界解释“谁不能前进”,再讨论 JVM 参数。本页 Linux 接口依据 cgroup v2 文档,实现固定 OpenJDK jdk-17.0.12+7,不把当前文档所有新增接口都当成所有部署内核已有功能。实际内核版本、v1/v2、挂载与 JDK 构建必须随证据保存。
进程与线程:调度实体不等于业务请求
进程提供地址空间、文件描述符表等资源边界,同进程线程共享这些资源,但各有栈、寄存器和调度状态。Linux 调度的是 task;一个 Java 请求可能排队尚未获得线程,也可能经过多个线程。加线程既不会制造 CPU 配额,也不会让同一把锁允许更多持有者。
Linux 的 PID/TGID、TID,Java Thread.getId() 与 HotSpot 栈里的 nid 不是同一个编号。top -H / pidstat -t 的 TID 可转换十六进制匹配同一 PID namespace 中的 nid;跨宿主/容器先核对 /proc/PID/status 的 NSpid/NStgid 与 namespace。Java RUNNABLE 包括等操作系统资源的情况,不等于采样时一直占 CPU。
辨别三种反例:运行队列长但线程没获得时间片,是调度/配额问题;CPU 低且多个线程 BLOCKED 在同一 monitor,是锁竞争;请求 pending 高而线程等借连接,是下游容量问题。上下文切换多也不是独立病因,要看主动等待还是被抢占、单位请求完成量是否下降。
配额、父层与亲和性共同限制 CPU
cgroup v2 的 cpu.max = 200000 100000 表示每 100 ms 周期允许该组共消耗 200 ms CPU 时间,是带宽上界,不是独占两颗物理核。8 个可运行线程在理想无竞争条件下可能约 25 ms 耗尽预算;余下周期即使宿主机空闲也不能随意继续。实际分片、周期相位与竞争会改变等待,不能把 75 ms 当作每个请求固定延迟。
检查必须从 /proc/PID/cgroup 与 /proc/PID/mountinfo 定位控制组;cgroup namespace 可能隐藏宿主祖先,容器内看不到不代表没有父层限制。沿可见祖先查 cpu.max、cpu.stat,必要时由宿主运维只读核验。max 只代表本层无配额;父层 1 核、子层 2 核时,全体兄弟共享父层预算,子层得不到保证的 2 核。
先限定所有线程共享同一 affinity:把它与有效 cpuset 的交集大小记为 A,各层有限 quota/period 的最小值记为 Q,则长期 CPU 使用上界不超过 min(A, Q)。若线程亲和性不同,不能拿任一线程的交集大小约束整组;例如两个线程分别只允许 CPU 0、CPU 1,组仍可能累计使用两核。一般上界需看组内各线程有效 CPU 集合的并集,同时还受可运行线程数等约束;本页预算模型采用共同 affinity 的简化前提。
这不是服务等级保证:兄弟竞争、调度策略、SMT、周期相位和宿主负载会进一步改变可用算力。cpu.weight 是竞争时相对权重,不是硬配额或保底核数;v1 的 quota/period/shares 与 v2 文件名也不能直接照搬。
同窗口比较 cpu.stat 的 nr_periods、nr_throttled、throttled_usec 差量,以及进程 CPU 时间与延迟。节流计数证明调度约束发生过,不自动证明全部 P99 都由它造成;聚合节流时间也不是一个请求的等待时间。若减少重复计算后节流和尾延迟同步下降,因果证据才更强。
JVM 读取限制,不替内核分配 CPU
入口链是 OSContainer::init → cgroup 子系统识别 → CgroupV2Subsystem::cpu_quota/cpu_period → CgroupSubsystem::active_processor_count。这是 HotSpot 对资源的认知,不是 Linux 调度器。该 tag 的局部读取也不能证明它看见了部署中的所有祖先限制。
原码连续摘录:cgroupV2Subsystem_linux.cpp L85–L90。仅去公共缩进。
int CgroupV2Subsystem::cpu_quota() {
char * cpu_quota_str = cpu_quota_val();
int limit = (int)limit_from_str(cpu_quota_str);
log_trace(os, container)("CPU Quota is: %d", limit);
return limit;
}cpu_quota_val 取得字段,limit_from_str 解释数值/无限制;无配额不等于解析失败,也不能跳过周期的合法性。调用方如何避免把不可用值当成核数,见下一段。
原码连续摘录:cgroupSubsystem_linux.cpp L500–L515。
cpu_count = limit_count = os::Linux::active_processor_count();
int quota = cpu_quota();
int period = cpu_period();
// It's not a good idea to use cpu_shares() to limit the number
// of CPUs used by the JVM. See JDK-8281181.
int share = UseContainerCpuShares ? cpu_shares() : -1;
if (quota > -1 && period > 0) {
quota_count = ceilf((float)quota / (float)period);
log_trace(os, container)("CPU Quota count based on quota/period: %d", quota_count);
}
if (share > -1) {
share_count = ceilf((float)share / (float)PER_CPU_SHARES);
log_trace(os, container)("CPU Share count based on shares: %d", share_count);
}有限 quota 与正 period 才进入除法;0.5 核配额向上取整为 1,是并行度启发式,不意味着获得一整核带宽。后续 L517–L533 根据 quota/share 分支选择 limit,再与 cpu_count 取小值;若删掉有效值判断,会把缺失控制信息误当成真实限制。该版本已对 shares 的使用设开关,早期 17u 行为可能不同,不能仅凭“Java 17”推断默认线程数。
-XX:ActiveProcessorCount 可影响 JVM、GC 与默认并行度的认知,但不会改 cpu.max。把它调大可能增加抢占和节流;应同时记录 availableProcessors()、容器日志、原始控制文件和线程池实际配置,不能以监控核数变大当优化结果。
内存记账与 OOM 的两条失败路径
先拆口径:虚拟地址预留不是已提交内存,committed 不是当前驻留 RSS;NMT 只覆盖 HotSpot 跟踪类别。memory.current 统计控制组及后代的相关记账,不等于某一个 Java 进程的 RSS,也不等于堆使用量。memory.stat 中 anon、file、kernel 等类别用于解释来源,有些字段是上级分类的子集,不能把每一行相加。共享页、页缓存与第三方 native 分配都会使简单对账失效。
| 现象 | 证据与不能推出的结论 |
|---|---|
| Java OOME,进程还活着 | 核对 heap/direct/native thread 的异常、JVM 限额、日志与 dump;不必然触发 cgroup OOM |
| 容器被内核杀掉,无 dump | 查 memory.events 的 oom/oom_kill 差量、宿主 OOM 日志、编排事件;进程可能没有执行 Java 异常路径的机会 |
| 退出码 137 | 常见为 SIGKILL 编码,但人为终止也可能如此;退出码单独不能证明 OOM |
| memory.current 高、堆稳定 | 看 file/anon/kernel 趋势、RSS/映射、线程数、直接缓冲与 NMT diff;不自动等于 Java 泄漏 |
memory.high 通常通过回收压力/节流促使降用量,不是 memory.max 硬边界的同义词。memory.events 是层次累计,需结合本层 memory.events.local(内核支持时)和父层检查定位范围;oom 与 oom_kill 含义不同,不保证每次触限都杀进程。
例:容器 2 GiB、堆上限 1.5 GiB,余下 512 MiB 要容纳栈实际驻留、元空间、代码缓存、GC/native、直接内存及相关页缓存。300 线程 × -Xss 只是栈虚拟预算近似,不是实际 RSS;仍应留峰值和诊断余量。JVM 专有采集方法只引用 NMT 与堆证据,不另复制一份工具课。
FD 与连接:数量之外还要找持有者
socket、日志文件、管道、eventfd 都可能占 FD。/proc/PID/limits 的软限制影响该进程,连接池“活跃 50”不等于只使用 50 个 FD;还要看空闲连接、文件与其他客户端实例。逐次采样 /proc/PID/fd 数量和目标类型,再关联创建/关闭调用链。不要只把 nofile 调高来掩盖泄漏。
CLOSE_WAIT 表示对端结束发送后本端还未完成关闭,应看应用是否遗漏响应体/socket 清理;正常长时间半关闭也需协议解释。TIME_WAIT 通常是 TCP 关闭后的内核状态,不需要有一个活进程 FD 与之对应,多不等于 FD 泄漏;短连接可能先耗源端口或 conntrack。磁盘满、日志写阻塞、fsync 长尾与不可中断 I/O 等待也能拖慢业务,不能归成 Java monitor。
同窗口证据链与恢复顺序
Linux 只读采集示例(替换 PID,使用短采样;本次未在 Linux 执行):
cat /proc/12345/cgroup
cat /proc/12345/mountinfo
cat /proc/12345/status
cat /proc/12345/limits
ls -l /proc/12345/fd
pidstat -u -w -t -p 12345 1 5定位控制目录后读取 cpu.max/stat、cpuset.cpus.effective、memory.current/stat/events,前后样本须同窗口保存,不改生产配额。namespace、宿主权限不够时标为缺失证据,而非猜路径。macOS 没有这些 Linux /proc 数据,JFR/NMT 的本机结果不能替代。
假设 P99 从 80 ms 变 400 ms:记录影响和请求队列 → 对齐 CPU/节流/内存/FD 差量 → 用线程栈/JFR 找持有者与热点 → 做小范围降并发或移除重复计算的反例 → 恢复后同负载比较。CPU 低、锁等待高与节流增长可能同时存在,应先限制放大器、保留少量关键证据,再有界重启/降载;重启见效不能证明根因修复。唯一健康副本上反复全量 dump 可能使事故恶化。
架构师推演:扩容会把瓶颈搬到哪里
8 个副本每个下游池上限 40,理论连接上限 320;扩大到 16 副本即 640,若数据库只给 400 的预算,CPU 扩容会把故障转移到借连接与数据库排队。还要预留滚动升级重叠和节点失效余量,不能把日常峰值配额与故障剩余容量重复花费。
公开实验 runner 的 budget 模式验证纯算例:子 2 核/父 1 核/4 核亲和性上界为 1;本层 max/父 1.5 核上界为 1.5;父子无限但亲和性 1 核上界为 1,并拒绝非法周期。它只证明预算解析和算术,不模拟 Linux 调度、节流、OOM 或尾延迟。真实 Linux 压力实验本次未运行。既有 JVM 故障实验 保留,二者回答不同问题。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。