结论先行:应用层监控(CPU/内存/GC/连接池)全绿、K8s 配置一模一样、镜像完全相同,两个机房的 P99 延迟可以差 7 倍——差异来自内核侧的三个开关:
cpufreq governor、THP 模式、IRQ 亲和性。这三项在kubectl describe、Prometheus 的容器指标里完全不可见,是「云厂商镜像默认调好、自建机房默认没调」最容易被忽略的一层。下面是 8 月份一次真实排查的完整复盘。
一、现象与背景:同镜像、同副本、同 limit,P99 差 7 倍
规模先交代清楚。同一个 Go 1.22 网关(netpoller 模型,无 GC 长尾),K8s 1.28.9 + containerd 1.7.13,单 Pod 4C/8Gi、cpu.limit=4、cpu.request=2,副本数 12,压测脚本和 QPS(稳态 3000)完全一致。
两个机房:
| 项目 | A 机房(自建) | B 机房(云厂商) | |---|---|---| | 内核 | 5.15.0-1034 | 5.15.0-1034 | | CPU | EPYC 7543(32C/64T,2.8GHz base / 3.7GHz boost) | 同款 | | K8s / 容器运行时 | 1.28.9 / containerd 1.7.13 | 1.28.9 / containerd 1.7.13 | | 镜像 | 完全同一个 digest | 完全同一个 digest | | P50 | 1.1 ms | 0.5 ms | | P99 | 5.8 ms | 0.8 ms | | P999 | 42 ms | 2.3 ms | | CPU 使用率 | 41%(离 4C limit 远得很) | 44% | | RSS | 620 MB / 8 Gi | 615 MB / 8 Gi |
应用侧的监控面板、GC 日志、下游 RT、连接池水位,两边长得几乎一模一样。按常规思路「应用没病 = 应用没问题」,这案子根本立不住——但 P99 差了 7 倍、P999 差了 18 倍,必须根因化。
二、根因:三个内核侧开关,全在应用层监控的盲区里
把两边的内核侧状态拉出来对比,差异一目了然:
| 项 | A 机房 | B 机房 |
|---|---|---|
| cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor | powersave | performance |
| cat /sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq(稳态) | 800 MHz | 3.7 GHz |
| cat /sys/kernel/mm/transparent_hugepage/enabled | always | madvise |
| cat /sys/kernel/mm/transparent_hugepage/khugepaged/defrag | always | never |
| cat /proc/interrupts 中网卡 IRQ 分布 | 均匀撒在 0–63 所有核 | 集中在 64–71 的 8 个核 |
| irqbalance 是否运行 | 是 | 否(用静态 affinity) |
三条机制讲清楚为什么它们能把 P99 顶上去:
cpufreq governor = powersave让 CPU 稳态停在 800 MHz 低频,升频有延迟。powersave 不是「省电模式」那么简单,它的行为是「尽量待在最低频率,压力上来才升」,而acpi-cpufreq的升频决策是周期性采样(默认 10ms 级窗口 + 内核内部迟滞),从「应用开始跑满」到「频率真正拉上去」有几十 ms 的滞后。这段时间内,你的代码是按 3.7 GHz 编译的,但实际跑在 800 MHz 上——相当于 CPU limit 被隐形砍到 1/4.6,且这个「limit」在kubectl top里完全看不到。P99 的长尾里,相当一部分是「升频还没追上、请求已经堆在队列里」的那一波。- THP = always 让
khugepaged在后台做 2MB 页合并,单次 stall 可达 50–200ms。transparent_hugepage/enabled=always时,内核会在后台把相邻的 4KB 页合并成 2MB 大页,这个khugepaged线程跑在业务核上、且合并过程会短暂持有内存锁。Go 1.22 的 runtime 默认 64KB page,加上 netpoller 的高频 mmap/munmap,正好给khugepaged提供了大量合并目标。P999 那 42ms 的长尾,perf record抓到的热点里khugepaged占了 12%。 irqbalance把网卡 IRQ 均匀撒到所有核,业务核被中断上下文切换打断。A 机房默认开着irqbalance,它把 12 个网卡的 RX/TX IRQ 均匀撒在 64 个逻辑核上——包括业务 Pod 正在用的那 4 个核。每次业务核处理一个网络包,都要做一次中断上下文切换 + 可能的跨 NUMA 访存(EPYC 7543 是 2-socket,跨 NUMA 延迟比本 NUMA 高 30%+)。P99 里的一部分延迟是「业务线程正在算,突然被网卡中断打断、回来时 L1 cache 已经被刷了一半」。
这三条的共同点:都不在应用层、都不在 K8s 配置里、都不在 kubectl describe 里,只能在节点上 cat /sys/... 才能看到。这也是为什么「两边监控全绿但延迟差 7 倍」——你的监控面板在应用层和容器层,而根因在内核层。
三、具体做法:三条命令定位 + 三种固化方式
第 1 步:进节点,把三个开关的状态拉出来对比。
# 1) cpufreq governor 与当前频率
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_min_freq
cat /sys/devices/system/cpu/cpufreq/policy0/scaling_max_freq
# 2) THP 模式
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/khugepaged/defrag
# 看 khugepaged 是否在吃 CPU(A 机房这里能直接看到异常)
top -H | grep khugepaged
# 3) IRQ 分布
cat /proc/interrupts | grep -E 'eth|ens|enx'
# 看 irqbalance 是否在跑
systemctl status irqbalance
第 2 步:固化三个开关,别只靠「重启前手动改」。
# --- cpufreq:全节点切 performance,并用 systemd 固化 ---
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
echo performance > "$c"
done
# 持久化:写一个 systemd unit /etc/systemd/system/cpufreq-performance.service
cat > /etc/systemd/system/cpufreq-performance.service <<'EOF'
[Unit]
Description=Set CPU governor to performance
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo performance > $c; done'
[Install]
WantedBy=multi-user.target
EOF
systemctl enable cpufreq-performance
# --- THP:切 madvise,并把 khugepaged 的 defrag 关掉 ---
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/khugepaged/defrag
# 持久化:GRUB 加 kernel 参数
# GRUB_CMDLINE_LINUX="... transparent_hugepage=madvise"
# 或者写一个 tmpfiles/systemd unit 在开机时执行
# --- IRQ:停 irqbalance,静态绑到非业务核 ---
systemctl stop irqbalance && systemctl disable irqbalance
# 把 12 个网卡的 RX/TX IRQ 绑到 64–71 这 8 个核(业务 Pod 用 0–63)
# 脚本示例(按实际 IRQ 号调整):
for irq in $(grep -E 'eth|ens|enx' /proc/interrupts | awk -F: '{print $1}'); do
echo f > /proc/irq/$irq/smp_affinity # 0xf = 核 0–3
done
第 3 步:验证。 压测 10 分钟,P50 从 1.1ms 降到 0.5ms、P99 从 5.8ms 降到 0.9ms、P999 从 42ms 降到 2.6ms——和 B 机房基本对齐。perf record -g 抓 30s,khugepaged 的热点占比从 12% 降到 0.3%。
四、踩过的坑 & 反直觉之处
- 坑 1:
performance不是「永远最高频」,而是「不做降频决策」。 它让 CPU 待在scaling_max_freq,但遇到热节流或 TDP 限制时照样会降。所以切完performance后,必须用turbostat或cat scaling_cur_freq确认稳态频率真的拉上去了,别只看 governor 字符串。 - 坑 2:
performance的功耗代价。 A 机房切完performance后,单节点稳态功耗从 180W 涨到 240W(+33%)。边缘节点 / 电池供电场景下,这条不能无脑切,建议用schedutil或conservative作为折中,但要在应用层把 P99 预算留出来。 - 坑 3:
THP=never不是解药,madvise才是。 一开始想直接echo never一刀切,结果发现 Go 的 64KB page 在never下小页合并路径全走 TLB miss,P50 反而涨了 0.3ms。madvise只对被madvise(MADV_HUGEPAGE)的内存做大页,Go runtime 默认走这条路径,正好。 - 坑 4:IRQ 绑到「非业务核」的前提是这些核真的空闲。 A 机房节点上还跑了日志 Agent 和监控 Exporter,它们随机调度到 64–71 核上,等于 IRQ 又被打断了一次。正确姿势是 IRQ 核 + 日志 Agent + Exporter 一起用
cpuManagerPolicy: static或systemd slice钉死,别只绑 IRQ。 - 反直觉:云厂商镜像默认就调好了这三项。 B 机房是云厂商的 K8s 节点池,镜像里
cpufreq=performance、THP=madvise、IRQ 静态绑定都是出厂默认。自建机房的默认值(powersave/always/irqbalance)是「通用服务器」的默认,不是「跑延迟敏感服务」的默认。这一层差异,在kubectl和 Prometheus 里永远看不到。
五、结论与可复用清单
延迟敏感服务(网关、交易、实时推理)上 K8s,应用层全绿之前,先把节点内核侧的三个开关检查一遍——cpufreq governor、THP 模式、IRQ 亲和性。可复用清单:
- 新节点上线 checklist 加三条:
governor=performance(或schedutil)、THP=madvise+khugepaged/defrag=never、停irqbalance并静态绑定 IRQ 到非业务核。 - 跨机房 / 跨集群的延迟差异,先
cat /sys/...对比内核侧状态,别急着怀疑应用代码或网络。 performance切完用turbostat确认稳态频率,别只看 governor 字符串;边缘 / 电池场景评估功耗代价。THP=never会反噬 Go 服务,madvise是更稳的折中;khugepaged/defrag=never必须一起设,否则后台合并照样打。- IRQ 绑定要和「非业务核的其它负载」一起钉死(日志 Agent / Exporter / 监控),否则 IRQ 又被它们打断。
- 这一层监控盲区建议在节点 exporter 里补三个指标:
node_cpu_frequency_mhz(稳态频率)、node_thp_enabled(THP 模式)、node_irq_distribution_entropy(IRQ 分布熵,越高越散)——把「内核侧默认值漂移」变成可告警项。
一句话收尾:应用层监控告诉你「应用没病」,但内核侧的 governor / THP / IRQ 决定的是「CPU 到底给了你多少算力、内存到底让你用了几种页、网卡中断到底打不打扰你」——这三件事,kubectl 和 Prometheus 都看不见,只能进节点 cat /sys/... 才能看见。