上周三凌晨两点,压测群里有人贴了一张截图:脚本里的并发从 500 往上推,推到 800 就推不动了,QPS 曲线像撞了天花板一样平掉,可服务端各项监控指标看起来还算健康,唯独kubectl get pods里横七竖八躺着六个 Pending。这就是「压测 k8s 服务资源不足怎么处理」这个问题的经典开局——注意,现象叫压不上去,原因却未必是资源不足。我见过太多次把压测机自己跑满 CPU、或者把数据库先打穿,然后一群人围着应用 Pod 加副本的情况。
这篇东西想讲清楚三件事:一是把「资源不足」这四个字拆成可定位的层次,二是从压测口径出发把容量算出来而不是拍脑袋,三是把现场救火和事后治理分开做。适合正在做全链路压测、容量规划、服务上容器之后遇到扩容瓶颈的同学,无论你是刚开始接手 k8s 业务,还是已经踩过几轮坑,应该都能从里面找到能直接抄的东西。
1. 先别急着加机器:把「资源不足」拆成四个层次
1.1 调度层的不足和运行层的不足,症状完全不同
我刚接触这块的时候,最大的误区是把所有「压不上去」都归成资源不够。实际上在 k8s 上,资源相关的故障至少分布在四个层次,每一层的表征、排查命令、解法都不一样。分清楚层次,能让排查时间从半天压缩到十分钟。
| 层次 | 典型现象 | 第一手排查手段 | 常见解法 |
|---|---|---|---|
| 调度层 | Pod 卡在 Pending,副本数上不去 | kubectl describe pod看 Events | 补 requests 匹配的节点、拆碎片、去亲和约束 |
| 运行层 | Pod 起来了但反复重启,Exit Code 137 | kubectl describe pod看 Last State、kubectl top pod | 调内存 limit、调 CPU limit、修探针 |
| 节点层 | 节点 NotReady、Pod 被驱逐、DiskPressure | kubectl describe node看 Conditions | 清磁盘、扩节点池、调 kubelet 预留 |
| 链路层 | Pod 一切正常,就是 QPS 上不去、错误率高 | 容器内看 conntrack、fd、端口、内核参数 | 调 sysctl、改长连接、加中间层 |
调度层的不足是最容易识别的,因为 k8s 直接把答案写在 Events 里了。运行层麻烦一点,因为它表现为「Pod 明明在跑但一直被干掉」。节点层最隐蔽,因为可能只是某一个节点触发了磁盘压力,然后 kubelet 开始驱逐 Pod,你在应用侧只看到副本数忽上忽下。链路层最容易被误判,因为 CPU、内存监控全都正常,但吞吐就是上不去。
我一般会按这个顺序过一遍:先看 Pod 是不是全部 Running 且 Ready,再看副本数有没有达到期望值,再看有没有重启记录,最后才去看内核和网络层。这个顺序能把 80% 的问题在前两步定位掉。
1.2 压测口径没对齐,资源永远算不准
这是我最想强调的一点。压测目标如果只写了「扛住 5000 QPS」,那这个数字几乎没有工程意义。同样叫 5000 QPS,短连接和长连接对服务端的资源消耗能差出三四倍,请求体 200 字节和 200KB 差得更离谱。
做容量计算之前,至少要把下面这几件事定死:
- 流量模型:读写比例、缓存命中率、请求体与响应体的平均大小和 P99 大小。
- 连接模型:长连接还是短连接,是否复用连接池,是否走 TLS。
- 并发模型:是固定并发还是固定 RPS,有没有思考时间(think time)。
- 数据状态:冷数据还是热数据,缓存预热做没做。这一条坑最多,没预热就开压,第一波流量直接穿透到数据库。
- 爬坡曲线:是阶梯加压还是瞬间打满。瞬间打满对自动扩容类机制极不友好。
- 持续时间:跑 3 分钟和跑 3 小时,内存泄漏、连接泄漏、日志堆积的暴露程度完全不是一个量级。
这些参数定完,才能用利特尔法则(Little's Law)把并发和 QPS 互转:并发数 ≈ QPS × 平均响应时间。举个例子,目标 2000 QPS,平均响应时间 50ms,那稳定状态下同时在处理的请求大约就是 100 个,这是你配置线程池、连接池下限的参考值。但注意这个公式只适用于稳定态,压测爬坡阶段和排队积压阶段是不成立的,别拿它当万能钥匙。
还有一个经常被忽略的口径问题:压测流量打的是不是同一批实例。如果你的服务在 Service 后面挂了 20 个 Pod,压测只打到其中 5 个(比如因为会话保持或者长连接没有重新负载),那你测出来的单副本能力和集群总能力会严重失真。我习惯在压测前先确认每个副本的 QPS 分布是否均匀,kubectl top pod扫一眼就能看出来谁在摸鱼。
2. 压测前的容量基线怎么算出来
2.1 从单副本能力反推集群总资源
跳过单副本能力测试直接上集群压测,是我见过最浪费时间的做法。因为集群压测出来的数字是一锅粥,你不知道瓶颈在应用、在调度、还是在某个中间件。
正确的做法是先把单副本压到拐点。具体操作是:只保留一个副本(或者固定路由到一个副本),逐步加压,记录每一档的 QPS、P99、CPU usage、内存 RSS。所谓拐点,就是 P99 开始非线性上涨的那个点。比如实测数据是这样:
| 并发 | QPS | P99 (ms) | CPU 使用 (核) | 内存 RSS (MiB) |
|---|---|---|---|---|
| 50 | 180 | 28 | 1.6 | 620 |
| 100 | 340 | 31 | 2.9 | 680 |
| 150 | 380 | 45 | 3.4 | 720 |
| 200 | 395 | 120 | 3.8 | 780 |
| 250 | 400 | 380 | 3.9 | 840 |
从这个表能读出来的信息比一行「单副本 400 QPS」多得多:400 QPS 之后 CPU 已经接近 4 核上限,P99 从 45ms 跳到 120ms,说明瓶颈在 CPU 而不是在内存。这时候如果目标是 3000 QPS,粗算就是 3000 / 380 ≈ 8 个副本,再按 25% 余量取整到 10 个副本。
对应的资源账是这样的:CPU request 每个副本 2 核,10 个副本就是 20 核 request;CPU limit 每个 4 核,总 limit 40 核。调度看的是 request 总和,节点实际承载看的是 usage,这两笔账要分开算。集群里必须有至少 20 核可分配的 CPU,同时物理核数要能兜住峰值 usage,按 65% 水位反推,物理核数最好在 30 核以上。
内存这边同理。单副本 RSS 峰值 840MiB,但要注意这只是 RSS,不含 JVM 的堆外、线程栈、直接内存缓冲区。如果是 Java 服务,容器内存 limit 一般按「堆大小 ÷ 0.7」来估,比如堆设 2G,limit 给 3G 左右比较稳。压测期间如果看到 137 退出码,十有八九是这一步估少了。
2.2 requests 和 limits 到底怎么取值
这两个字段的区别值得单独讲,因为很多资源不足的问题根源就在这里。requests 决定调度,limits 决定运行时。调度器只看 requests,它把 Pod 往节点上塞的时候,只关心节点剩余的可分配资源够不够 requests 之和。而 kubelet 和内核在运行时约束的是 limits。
这就产生了两个方向的坑。requests 设太小,调度器会把太多 Pod 塞到同一台机器上,节点实际负载爆表,所有 Pod 一起变慢。requests 设太大,节点明明还有余量却调度不上去,白白浪费。我一般的取法是:
- CPU request:按压测实测的稳定态 CPU 使用量 × 1.2 来设。上面那个例子稳定态 2.9 核,request 设 3 核偏保守,设 2 核更贴近实际但节点会超卖。压测环境我倾向设 2 核,因为要测出真实上限。
- CPU limit:延迟敏感的服务建议设成 request 的 1.5 到 2 倍,不要设等值。原因是 CFS 的节流是按 100ms 周期算的,一个短时突发(比如 GC、批量请求)很容易在单个周期内超额,然后被强制等到下个周期,表现就是 P99 抖动。
- 内存 request:按稳定态 RSS 设,不用乘系数。
- 内存 limit:按峰值 RSS × 1.3 到 1.5 估,同时要覆盖堆外内存。
CPU limit 的节流问题值得再多说两句。我见过一个服务,CPU usage 常年在 limit 的 60%,但 P99 就是不稳定。后来查container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total这个比值,超过 25%。原因就是请求处理有突发性,100ms 周期里前 30ms 用掉了大半配额,后 70ms 全在等。所以如果你的压测目标是稳定的低延迟,CPU limit 要么放宽,要么干脆不设(但前提是节点上跑的都是可信负载,否则一个 Pod 能把整台机器抢干)。
顺带提一下 QoS 类别,它会直接影响节点资源紧张时谁先被驱逐:
- Guaranteed:所有容器的 requests 等于 limits,最后被驱逐。
- Burstable:至少一个容器设置了 requests 但没设 limits,中间优先级。
- BestEffort:完全没设,第一个被干掉。
压测期间如果你的服务是 BestEffort,节点一紧张它就先没,副本数忽上忽下,压测数据根本没法看。所以至少保证 Burstable,核心服务尽量上 Guaranteed。
2.3 给调度预留足够的余量
集群水位这个话题,我的观点是压测环境也别跑满。原因有三个。
第一,滚动更新会瞬时叠加资源。假设你有 10 个副本,maxSurge设了 25%,那更新过程中会短暂出现 13 个副本,多出来的 3 个副本的 request 需要节点能接住。如果集群已经跑到 95%,滚动更新就会卡在 Pending,压测直接中断。
第二,DaemonSet 类的组件也要吃资源。日志采集、监控代理、网络插件,这些在每个节点上都有一份,小节点上一扣就是几百毫核。节点 Allocatable 的算法是:Capacity 减去 kubeReserved,再减去 systemReserved,再减去 evictionHard 阈值。一台 4 核 8G 的节点,扣完之后可分配可能只有 3.2 核 6.5G,和标称容量差了不少。
第三,资源碎片是真实存在的。假设集群总共有 6 核空闲,分散在 5 台机器上,每台剩 1.2 核,而你的 Pod 需要 2 核,那就一个都调度不上。kubectl describe node里每个节点的 Allocated resources 那一栏能看到详细的碎片分布。缓解办法有几种:把单个 Pod 的资源调小一点(比如把 2 核拆成 1.8 核加一个小的 sidecar),用更小规格的节点,或者用拓扑分布约束把同类 Pod 打散。
集群整体水位我一般控制在 60% 到 70% 之间,留 30% 给突发和滚动更新。压测期间可以适当提高,但别超过 85%,再往上各种边缘问题会集中爆发。
3. 现场排查:从 Pending 到 OOMKilled 的完整链路
3.1 调度侧:三分钟定位卡在哪一环
压测现场最需要的是速度,所以我习惯用一条命令先看清全局:
kubectl get pods -n <namespace> -o wide --field-selector status.phase=Pending锁定 Pending 的 Pod 之后,describe出来的 Events 段落基本就把答案写明了:
kubectl describe pod <pod-name> -n <namespace> | sed -n '/Events:/,$p'常见的几条信息和解法对应如下:
0/12 nodes are available: 5 Insufficient cpu—— 节点 CPU request 不够了,要么降 request,要么扩节点。3 node(s) had untolerated taint—— 节点有污点,检查是不是压测专用节点池没打上对应的容忍。2 node(s) didn't match Pod's node affinity/selector—— 亲和性写死了,Pod 被绑在特定标签的节点上。2 node(s) didn't have free ports for the requested pod ports—— 用了 hostPort,同节点端口冲突,这个坑很隐蔽。pod has unbound immediate PersistentVolumeClaims—— PVC 没绑上,常见于跨可用区存储。
资源碎片的情况要多做一步,看节点的实际可分配情况:
kubectl describe node <node-name> | sed -n '/Allocated resources/,/Events/p'同时确认节点上还有没有其他「隐性消耗」,比如已被驱逐但还在终止中的 Pod、被 DaemonSet 占掉的份额、以及 kubelet 的预留资源。
我还遇到过一种情况:Events 里明确写着资源不足,但集群总的空闲资源明明够。查下来是topologySpreadConstraints配得太严,要求同类 Pod 在每个可用区数量差不超过 1,而某个可用区的节点刚好满了。这类约束在压测扩容时特别容易翻车,因为扩容是瞬间加很多副本,约束条件会直接卡死一半。压测期间我建议临时放宽分布约束,或者干脆去掉,压完再恢复。
3.2 运行侧:怎么确认是 OOMKilled 还是 CPU 节流
Pod 调度上去了,但压测数据还是上不去,那就看运行侧。先确认有没有重启:
kubectl get pods -n <namespace> -o custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[*].restartCount有重启就看原因:
kubectl describe pod <pod-name> -n <namespace> | grep -A 5 "Last State"如果是Reason: OOMKilled, Exit Code: 137,那就是内存超了 limit。这里有个细节:OOMKilled 有可能是容器主进程被杀,也有可能是整个 cgroup 被内核干掉,现象一样但排查方向不同。前者通常伴随应用日志里的堆栈,后者则完全静默。
判断内存到底用了多少,可以进容器看 cgroup 的计数:
# cgroup v1 cat /sys/fs/cgroup/memory/memory.max_usage_in_bytes # cgroup v2 cat /sys/fs/cgroup/memory.peak这个峰值会比kubectl top pod看到的采样值更准,因为 top 是按周期采样的,抓不到瞬时尖峰。很多 OOM 就是这么来的——采样看是 1.8G,limit 给 2G,看起来很安全,但实际峰值冲到 2.1G 就被杀了。
Java 服务要特别注意堆外内存。堆设 1.5G,limit 给 2G,看起来留了 500M 余量,但 Metaspace、线程栈(每个线程默认 1M 栈空间)、DirectByteBuffer、JIT 代码缓存加起来很容易吃掉 400M 以上,再算上 glibc 的 malloc arena 碎片,2G 根本不够。我的经验是limit = 堆 × 1.4 到 1.5,压测期间再往上加一档。
CPU 节流不像 OOM 那么暴烈,它表现为「服务没挂,但延迟上不去」。判断指标是:
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total这个比值超过 20% 就说明有明显节流了。同步看一下container_cpu_cfs_throttled_seconds_total的增速,如果压测期间持续增长,那就是 limit 卡得太死。
第三种运行侧问题是探针误杀。压测时响应变慢,readiness 探针的timeoutSeconds设得太短(很多人设 1 秒),探针失败,Pod 被摘出 Endpoints。摘流之后剩余副本压力更大,响应更慢,更多探针失败,直接雪崩。liveness 探针更狠,失败直接重启容器,压测中途重启一次,前面测的数据全废。
我的做法是压测期间把探针参数放宽一档:timeoutSeconds给 3 到 5 秒,failureThreshold从 3 提到 5,periodSeconds从 10 提到 15。同时把initialDelaySeconds和startupProbe配好,避免冷启动阶段就被判死。
3.3 集群侧:节点扩容和临时资源的取舍
确认了是集群整体资源不够,就得扩。扩容有几个维度:
- 横向加节点:往节点池里加机器。有弹性伸缩组件的会自动加,没配的手动加。
- 纵向升配:把现有节点规格调大。这个操作通常需要重启节点,压测期间不推荐。
- 临时节点池:专门起一个压测用的节点池,压完就释放,成本可控。
横向扩容最大的问题是延迟。从触发扩容到新节点 Ready,中间要经历实例创建(1 到 3 分钟)、系统初始化、容器运行时拉起、网络插件就绪、镜像拉取(如果镜像大,再等一两分钟)。整个过程三到五分钟很正常。如果你的压测爬坡曲线是「10 秒内从 0 打到满」,那自动扩容永远追不上,压测必然失败。
所以压测前我一般会做两件事:一是提前预热节点,把最小节点数调高,让节点处于已就绪状态;二是放缓爬坡曲线,比如每 30 秒加 100 并发,给扩容留出反应窗口。
还有一个特别容易忽略的点:云上 vCPU 配额。临时加机器的时候被配额挡住,报错信息还不在 k8s 层面,得去云控制台看。我建议压测前一天就把配额提上去,或者提前确认当前配额余量。
节点侧还有两个信号要盯着:MemoryPressure和DiskPressure。磁盘压力在压测时特别常见,因为日志量暴增。默认的evictionHard阈值是nodefs.available<10%,一旦触发,kubelet 开始驱逐 Pod,而且优先驱逐那些用了超过 request 的 Burstable Pod。表现就是压测跑着跑着副本数突然少几个。压测前把日志级别调低、把日志轮转配好,比事后救火划算得多。
4. 临时救火与长期治理的分界线
4.1 压测现场允许做的三件事
压测是有时间窗口的,很多时候来不及做完整分析,得先让数据跑出来。我给自己划的线是:现场只做三类操作,且必须记录下来,压完就还。
第一类,调整压测口径。如果确认是压测机自身瓶颈(CPU 打满、端口耗尽、连接池不够),改压测端配置是最快的。压测机端口耗尽的判断方法是看netstat里的 TIME_WAIT 数量,如果几万个,那就是本地端口范围不够。压测机的内核参数和业务机的内核参数一样重要,别只顾着调服务端。
第二类,临时放宽调度约束。去掉podAntiAffinity的硬约束,把topologySpreadConstraints的whenUnsatisfiable从DoNotSchedule改成ScheduleAnyway,放宽节点选择器的标签范围。这些改动不影响服务逻辑,只影响调度,压完恢复成本低。
第三类,临时提高资源上限。把 limit 往上调一档,把 HPA 的最小副本数拉高避免扩容抖动,把副本数手动定死避免自动扩缩容的滞后干扰压测。但注意,提 limit 只是让容器不被杀,不会让节点凭空多出资源。如果节点已经满载,提 limit 只会让节点更快进入压力状态,甚至触发驱逐。所以提 limit 之前必须先确认节点水位。
有一件事我在现场坚决不做:不改业务代码逻辑。压测中改代码,测出来的数据没有任何参考价值,而且会引入新的不确定性。
4.2 压测结束后必须还的债
这句话听起来像老生常谈,但我确实见过有人把压测时临时调的 limit 带到生产,结果单副本吃了 8 核,节点上只能放两个 Pod,资源利用率低得离谱。
压测结束后要还的债,我列了个清单:
- 资源配额回归:把临时调高的 requests/limits 改回去,改回去之后重跑一次短时压测验证。
- 调度约束恢复:亲和性、分布约束、污点容忍全部复原。
- HPA 参数标定:压测得到的拐点数据正好用来定 HPA 的阈值。CPU 目标利用率一般设在 request 的 60% 到 70%,留出扩容窗口。同时把
behavior配好,扩容可以激进(比如 30 秒窗口),缩容一定要保守(默认 300 秒是有道理的,防止抖动)。 - 容量模型沉淀:把「单副本 X QPS 对应 Y 核」这个关系写进文档,后续扩容直接查表,不用重新压。
- 瓶颈清单:这次压测暴露的瓶颈按优先级排个序,是代码问题、配置问题还是架构问题,分派到具体的改进项。
顺便说个 HPA 的坑。HPA 的 CPU 利用率是相对于requests算的,不是相对于 limit,也不是相对于节点容量。所以你把 request 从 2 核改成 1 核,HPA 看到的利用率会直接翻倍,然后疯狂扩容。压测期间调 request 的时候,记得同步检查 HPA 阈值,不然会出现「明明负载没变,副本数翻了三倍」的诡异现象。另外 HPA 的同步周期默认 15 秒,加上 metrics-server 的采样延迟,实际响应可能要 30 秒以上,压测爬坡太快它根本跟不上。
5. 那些年踩过的坑:问题速查与避坑清单
5.1 常见问题速查表
| 现象 | 最可能的原因 | 验证方式 | 处理方向 |
|---|---|---|---|
| Pod 一直 Pending | 节点 request 不足或碎片 | describe pod看 Events | 降 request、扩节点、拆碎片 |
| Pod 反复重启,Exit Code 137 | 内存超 limit 被 OOMKilled | describe pod看 Last State、cgroup 峰值 | 提内存 limit、查内存泄漏 |
| 服务不挂但 P99 抖动大 | CPU limit 导致 CFS 节流 | 算 throttled_periods 比值 | 提 CPU limit 或不设 limit |
| 副本数忽上忽下 | 节点压力驱逐 BestEffort Pod | describe node看 Conditions | 设 requests、升 QoS 等级 |
| QPS 上不去但服务端很闲 | 压测机自身瓶颈 | 看压测机 CPU、TIME_WAIT 数量 | 加压测机、调端口范围、加连接池 |
| 压测中突然大量超时 | conntrack 表满或 fd 耗尽 | conntrack -S、`ls /proc/ /fd | wc -l` |
| 新副本一直起不来 | 镜像拉取慢或启动探针失败 | 看 Pod Events、镜像大小 | 预热镜像、调 startupProbe |
| 扩容后 QPS 没涨 | 流量没均匀打到新副本 | kubectl top pod看分布 | 检查负载均衡、连接复用策略 |
5.2 几条不太成文但很有用的经验
先证伪压测端,再怀疑服务端。这条我放在第一位,因为它能省掉一半的无效排查。压测机的 CPU、内存、网络带宽、本地端口、文件描述符,任何一项打满都会表现为「服务端扛不住」。我的习惯是压测时同时开两个窗口,一个看服务端kubectl top,一个看压测机top和ss -s。两边同时到达瓶颈,说明测试环境是均衡的;只有一边到瓶颈,另一边就是瓶颈本身。
看 usage 别看 request。request 是你写的,usage 是真实的。很多资源不足的判断错误,都是拿 request 当实际用量来分析。kubectl top pod只能看当前值,要看历史趋势得上监控系统,重点看 P95 和峰值,别只看平均值。
并发不是越高越好,找到拐点比找到上限更有价值。上限告诉你「最多能扛多少」,拐点告诉你「从多少开始变差」。生产上真正该关心的是拐点,因为超过拐点之后,加再多并发也只是让排队变长、延迟变高,吞吐反而可能下降。
压测前把缓存预热跑一遍。冷启动状态下压测,第一波流量全部穿透到下游存储,压出来的数字完全不能用。预热的方式可以是用低并发跑几分钟,也可以是直接灌一批热点数据。
日志和监控的采样精度要匹配压测时长。跑 3 分钟的压测,用 1 分钟采样间隔的监控,你只能看到三个点,什么都分析不出来。压测期间把关键指标的采样间隔调到 10 秒甚至 5 秒,压完再改回去。
多个服务共用集群时,给压测单独划命名空间和节点池。一是避免影响其他业务,二是方便整套资源压完就释放。配合ResourceQuota限制命名空间总量,配合PriorityClass保证压测 Pod 在资源紧张时的优先级符合预期。
别忘了中间件。应用侧扩到 20 个副本,结果数据库连接池只有 100 个连接,多出来的副本全在等连接。压测前把下游的容量上限也摸一遍,别让应用层背锅。
最后分享一个我最近才想明白的事:压测中所谓的「资源不足」,很多时候不是资源真的不够,而是资源的形状不对。你有 100 核,但分散在 50 台 2 核的机器上,一个需要 4 核的 Pod 永远调度不上去;你有 500G 内存,但每个 Pod 的 limit 都卡得死死的,一有突发就被杀。所以与其盯着总量看,不如多花点时间看分布和形状。这个视角转换之后,很多之前想不通的「明明资源够却跑不动」的问题,答案就自己浮出来了。