压测跑到一半,TPS 曲线突然躺平,Prometheus 上 Pod 的 CPU 顶到天花板,节点里躺着一堆 Pending,日志开始吐 connection reset——这几乎是每个做容器化压测的人都见过的一幕。"压测 k8s 服务资源不足怎么处理"看着是一句话的问题,真上手排查会发现"资源"这两个字至少牵扯五层:压测客户端本身、网络接入链路、调度层、Pod 运行时、后面的依赖服务。多数人第一反应是给 Pod 加 CPU 加内存,结果加了半天发现瓶颈在压测机上,或者卡在 CPU throttling 上,白折腾一整天。
这篇东西就是把我自己踩过的坑和常用的排查顺序整理出来,从怎么判断瓶颈在哪一层,到 requests/limits 怎么定、HPA 怎么调、内核参数怎么改,尽量给到能直接抄的参数和命令。不管你是刚接手压测任务、只会kubectl get pod的新手,还是已经调过几轮但总感觉"扩容了也没用"的老手,下面这套分层定位的思路应该都能用得上。全文围绕真实压测场景讲,不扯虚的。
1. 先搞清楚"资源不足"是谁不足:五层链路拆解
1.1 压测链路上的五类资源池
很多人一看到服务端报错就默认是业务 Pod 扛不住,实际上压测是一个端到端的闭环,任何一环塌了表现都一样:TPS 上不去、延迟飙升、错误率上升。我习惯把它拆成五个资源池来分别看,每个池子的耗尽信号完全不同。
第一层是压测客户端。它要消耗 CPU 跑协议编解码、消耗内存维持连接和会话、消耗本地端口发起新连接、消耗文件描述符管理 socket。单机压测工具在几千 QPS 到几万 QPS 之间就会撞墙,具体取决于协议复杂度(HTTP 短连接和 gRPC 长连接差好几倍)。
第二层是接入链路。Service 的 iptables/IPVS 规则、Ingress 或网关的连接数上限、负载均衡器的带宽和新建连接速率(CPS),都会在高压下成为瓶颈。这一层的资源往往不在你的 kubectl 视野里,但对结果影响极大。
第三层是调度层,也就是节点还有没有地方放 Pod。请求量上来了,HPA 想扩副本,结果节点 CPU 不够、内存碎片、亲和性不满足、PVC 绑不上,Pod 全部 Pending,服务能力根本没有增加。
第四层是Pod 运行时,Pod 起来了但被 cgroup 掐着脖子。CPU 被 limits 限流导致周期性停顿,内存触到 limit 被 OOMKilled,文件描述符不够导致 accept 失败,连接池满了导致请求排队。
第五层是依赖与数据层。数据库连接池上限、缓存带宽、消息队列分区数、下游服务限流阈值,这些通常不是 k8s 资源问题,但表现出来和"资源不足"一模一样。
把这五层分清楚的意义在于:你优化的对象必须是真正的那一层。给 Pod 加 CPU 解决不了压测机端口耗尽,给压测机加机器也解决不了 CPU throttling。
1.2 三分钟判断瓶颈落在哪一层
我的习惯是先做两个动作:固定并发看吞吐曲线,以及同时看客户端和服务端的资源水位。判断逻辑整理成下面这张表,实际排查时对着看能省很多时间。
| 现象特征 | 优先怀疑层 | 快速验证方式 |
|---|---|---|
| 并发往上加,TPS 完全不涨,客户端 CPU 打满 | 压测客户端 | top看压测进程,ss -s看 TIME_WAIT 数量 |
| TPS 有小幅波动,错误类型是连接被拒 | 客户端端口或接入层 | ss -tan state time-wait | wc -l,看端口范围 |
| Pod 大量 Pending,服务实例数没变化 | 调度层 | kubectl describe pod看 Events 里的调度失败原因 |
| 实例数正常,但 P99 周期性地抖出尖刺 | Pod 运行时 CPU 限流 | 查 cgroup throttling 指标,对照 limits 配置 |
| 实例数正常,偶发大量 502/超时后自愈 | 运行时内存或 GC | kubectl describe pod看 Last State 是否 OOMKilled |
| 单实例 QPS 上不去,但 CPU 只用了一半 | 依赖层或连接池 | 看数据库慢查询、连接池等待数、下游 RT |
有一个非常实用的小技巧:做阶梯压测时,每档并发稳住 3 到 5 分钟再记录数据。很多人一口气把并发拉满,采集到的是系统还没进入稳态的中间数据,误判率极高。另外务必记录每档并发下的客户端资源占用,客户端 CPU 超过 60% 时,后面测出来的服务端数据就已经不可信了。
提示:压测前先把"基线"跑出来——单实例在无干扰情况下能扛多少 QPS、P99 多少。后面所有扩容和调参都是在基线之上做对比,没有基线的话,你连"变好了"还是"变差"都说不清。
2. 压测客户端:最容易被忽略的第一瓶颈
2.1 客户端先倒下的几种典型姿势
我见过太多团队花了三天调服务端,最后一查是压测机自己的问题。客户端耗尽基本集中在四处,每一处的表现都不一样。
本地端口耗尽是最常见的。Linux 默认的临时端口范围是 32768 到 60999,可用端口约 2.8 万个。如果压测用的是短连接,连接关闭后进入 TIME_WAIT 状态并占用端口约 60 秒,那么理论上单机每秒能发起的新连接上限就是两三万,实际因为端口复用规则还要打折。现象很典型:跑到某个 QPS 之后开始报Cannot assign requested address或connection reset by peer,服务端日志里几乎看不到请求。
临时缓解手段是调内核参数,先看当前值:
sysctl net.ipv4.ip_local_port_range sysctl net.ipv4.tcp_tw_reuse cat /proc/sys/net/ipv4/tcp_max_tw_buckets ulimit -n需要关注的几个点:把临时端口范围扩大到 1024 到 65535,打开tcp_tw_reuse允许复用 TIME_WAIT 状态的连接(仅对客户端主动发起的连接生效,安全性上没有副作用),把tcp_max_tw_buckets调大避免 TIME_WAIT 被强制回收引发异常。但这些都是缓解,真正的解法是改用长连接 + 连接池,或者上多机分布式压测。
文件描述符耗尽排第二。默认ulimit -n经常是 1024,一个连接占一个 fd,几千并发直接打满,报错是Too many open files。压测进程启动前必须显式设置,别指望在 shell 里ulimit -n 65535就一劳永逸,systemd 托管的服务要改 unit 文件里的LimitNOFILE。
ulimit -n 65535 # 压测进程内也需要检查实际生效值 cat /proc/<pid>/limits | grep "open files"客户端 CPU 打满是最隐蔽的。压测工具做 TLS 握手、JSON 序列化、断言校验都要吃 CPU,单核跑到 100% 之后 TPS 就锁死了,但服务端看起来一切正常、CPU 才 30%,于是误判成"服务端没压力"。所以压测期间必须盯客户端的 CPU 使用率,超过 60% 就该考虑拆分了。
客户端内存与 GC次之。基于 JVM 的压测工具在堆内存吃紧时会频繁 Full GC,表现为 TPS 出现规律性的周期性下跌。堆给到 4G 以上、开启 G1、减少每线程的上下文对象,能缓解不少。
2.2 分布式压测的拆分原则与隔离部署
单机撞墙之后就是拆机器,但拆分也有讲究,瞎拆反而制造新问题。
第一,按连接类型拆。短连接场景按目标 QPS 均分到多台压测机,每台控制在端口能力的 50% 以内。长连接场景按并发连接数拆,同时注意每台机器的连接数不要超过服务端单个实例能承受的上限,否则流量分布不均。
第二,压测机必须和业务集群隔离。这一条我踩过坑:早期为了省事,把压测 Pod 直接跑在业务节点池里,结果压测开始后压测 Pod 自己和业务 Pod 抢 CPU,节点资源瞬间见底,触发驱逐,业务 Pod 被踢走,数据全废。正确做法是单独建一个压测节点池,打上污点,业务 Pod 不会调度过去:
kubectl taint nodes <压测节点名> workload=loadtest:NoSchedule压测 Job 里配对应的 toleration 和 nodeSelector,同时给它设一个明确的 requests 上限,避免单机开太多线程把节点吃干。
第三,做客户端能力标定。上多机之前,先单机压一个明显不会成为瓶颈的目标(比如 1000 QPS),记录客户端 CPU 占用,然后线性推算这台机器的能力上限,再留 40% 余量决定需要几台。这个动作花十分钟,能避免后面反复怀疑"是不是压测机不行"。
第四,统一时钟和采集。分布式压测时各机器的系统时间必须同步,否则聚合出来的 P99 延迟会莫名其妙地偏移。压测结果采集要统一汇总到一个地方,别让每台机器各看各的。
3. 调度层:Pod 起不来到底卡在哪
3.1 Pending 的典型原因与验证命令
扩容指令下去了,副本数显示增加,但kubectl get pod一看全是 Pending,这时候要看 Events,别猜。
kubectl describe pod <pod-name> -n <namespace> | sed -n '/Events/,$p'常见的调度失败原因就那么几种,对应处理方式也不同:
| Events 关键字 | 真实原因 | 处理方向 |
|---|---|---|
Insufficient cpu | 节点可分配 CPU 不够 | 扩容节点、调小 requests、清理碎片 |
Insufficient memory | 同上,内存维度 | 检查是否有大内存 Pod 挤占 |
node(s) had taint that the pod didn't tolerate | 节点有污点 | 补 toleration 或换节点池 |
node(s) didn't match Pod's node affinity | 亲和性/节点选择器过严 | 放宽 selector,或给新节点打标签 |
persistentvolumeclaim not found / not bound | 存储没绑上 | 检查 StorageClass 与 WaitForFirstConsumer |
Too many pods | 节点 Pod 数量达到上限(默认 110) | 扩节点或合并小 Pod |
node(s) didn't satisfy topology spread constraints | 拓扑分布约束 | 放宽 maxSkew 或增加可用区节点 |
有一类特别容易误判的情况叫资源碎片。节点总容量 16 核,已经用了 12.5 核,剩 3.5 核,但新 Pod 的 requests 是 4 核,调度器就是放不进去,Events 只写Insufficient cpu,看起来像节点"还有余量却不用"。这时要么把 requests 调到合理区间,要么用统一规格的节点池减少碎片,要么用 descheduler 做重平衡。压测场景下我更推荐第二种——规格统一之后碎片问题基本消失。
3.2 requests 从压测目标反推,而不是拍脑袋
requests 决定调度,limits 决定 cgroup 上限,两者的关系必须捋清楚。给大了浪费容量、制造碎片;给小了调度上去也跑不动。压测场景下我按这个流程定:
第一步,压出单实例能力基线。单副本、无干扰,从低并发开始阶梯加压,记录 QPS 与 P99 拐点,得到"单核可承载 QPS"这个关键数字,比如某服务实测单核 300 QPS(纯计算型,具体数值跟业务强相关)。
第二步,算总量需求。目标 3 万 QPS 除以单核 300 QPS,需要 100 核的计算能力。考虑到突发流量和单点故障,留 30% 冗余,取 130 核。
第三步,算单副本规格。如果希望滚动发布时不影响容量,最少要 6 个副本;那么单副本 130/6 约等于 22 核,这个规格偏大,调度困难。反过来定单副本 4 核、需要 33 个副本,调度更灵活但连接数管理更复杂。这里是个权衡,我的经验是单副本 2 到 8 核之间最好调度,超过 16 核的 Pod 在常规节点池里很容易因为碎片起不来。
第四步,验证。按算出来的规格起一批 Pod,跑一次目标流量的 80%,看实际 CPU 水位是否落在 60% 到 70% 区间。偏离太多就回头修正单核 QPS 的估算——很可能是第一步的基线数据受客户端瓶颈污染了。
另外压测环境一定要配PriorityClass。给核心业务 Pod 高优先级,给压测流量 Pod 低优先级。这样节点资源紧张时,占用资源的是压测 Pod 被抢占而非业务 Pod 被驱逐,业务稳定性有保障。
apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: loadtest-low value: -10 globalDefault: false description: "压测流量使用,资源紧张时优先被抢占"4. 运行时:Pod 起来了但性能上不去
4.1 CPU throttling 是 P99 抖动的头号嫌疑
这是我最想强调的一节。很多团队给 Pod 设了limits.cpu: 500m,压测时 CPU 使用率显示只有 45%,看着很健康,但 P99 延迟一直抖。原因是limits 是硬上限,不是平均值:CFS 调度器把 CPU 时间切成 100ms 一个周期,如果 Pod 在这个周期内用完配额,剩下的时间就会被强制等待下一个周期。业务代码里一个 20ms 的突发计算,可能被拉长到 100ms。
表现出来就是:平均延迟还行,P99 和 P95 有周期性尖刺,且尖刺间隔和 CFS 周期相关。查证方法是看 cgroup 的 throttling 统计:
# 在 Pod 所在节点上执行 cat /sys/fs/cgroup/cpu/kubepods.slice/.../cpu.stat # 关键字段 # nr_periods / nr_throttled / throttled_timenr_throttled占nr_periods的比例超过 5% 就说明限流严重了。如果是 cgroup v2,路径换成/sys/fs/cgroup/.../cpu.stat,字段名一致。
处理上有两个方向。首选是不给 CPU 设 limit,只设 requests。这样调度时有资源保证,运行时又能借用节点的空闲 CPU,突发性能好得多。副作用是节点上的 Pod 会互相争抢,需要一个能容忍这种竞争的运维习惯,并且节点 CPU 水位不要长期超过 80%。
如果必须设 limit(比如多租户环境要求隔离),那就把 limit 设成 requests 的 2 到 4 倍,而不是相等。比如 requests 1 核、limits 4 核,既能保证调度,又给足了突发空间,throttling 概率大幅降低。
resources: requests: cpu: "1000m" memory: "1Gi" limits: memory: "1Gi" # CPU limit 有意省略,靠 requests 做调度保证这个配置对应的是BurstableQoS 等级,内存有保障不会被驱逐,CPU 可以弹性使用,是压测场景下比较务实的选择。
4.2 OOMKilled 与堆外内存的估算方法
Pod 内存打满被 OOMKilled 的特征很好认,kubectl describe pod的 Last State 里会有Reason: OOMKilled,退出码 137。麻烦的是它通常不报错、不告警,只是容器悄悄重启,重启期间连接全断,压测结果里出现一波 502。
根因往往是只算了堆内存,忘了堆外。以 JVM 为例,容器内存 = 堆(-Xmx)+ Metaspace + Code Cache + 线程栈 + 直接内存 + GC 自身开销 + 栈外结构性开销。经验公式是heap = 容器limit × 0.6 到 0.7。给 1Gi limit 就配-Xmx700m,给 2Gi 配-Xmx1400m,别贪心地配到 0.9,GC 一抖就爆。
Go 服务相对简单,但要注意 GOGC 默认 100 意味着堆会涨到存活对象的 2 倍,峰值 RSS 可能远超预期,压测时用GOMEMLIMIT兜底更稳。Python 多进程服务要注意每个 worker 都是独立的 RSS,容器 limit 要按 worker 数相乘。
验证方法是在压测过程中持续观察容器的 working set,而不是只看 node 的内存水位:
kubectl top pod -n <namespace> --containers # 或者看 cgroup 的详细数据 cat /sys/fs/cgroup/memory/.../memory.current cat /sys/fs/cgroup/memory/.../memory.peak内存 limit 要设在"峰值 working set × 1.3"。压测前先用目标流量的 50% 跑十分钟,记录峰值,再上浮 30%,这个数值比拍脑袋靠谱得多。
4.3 连接、句柄与内核参数的联动调整
有些"资源不足"既不是 CPU 也不是内存,而是连接层面的配额。典型表现是压测到一定并发后开始报connection refused、too many open files、cannot allocate memory(后者往往是 socket buffer 分配失败)。
在 Pod 层面要看两处:容器内的nofile限制,以及应用自身的连接池大小。nofile默认继承宿主机或运行时配置,常见是 1024 或 1048576,差异很大,压测前务必确认:
kubectl exec -it <pod> -- sh -c 'ulimit -n; cat /proc/1/limits | grep files'如果偏小,通过 securityContext 或者节点级配置调大。应用侧要检查连接池上限是否小于 Pod 的实际承载能力,否则压测表现为下游 RT 正常但上游一直超时。
节点层面要看的参数集中在网络栈:
sysctl net.core.somaxconn # 全连接队列长度,默认 4096 或 128 sysctl net.ipv4.tcp_max_syn_backlog # 半连接队列 sysctl net.netfilter.nf_conntrack_max # 连接跟踪表上限 cat /proc/sys/net/netfilter/nf_conntrack_countnf_conntrack是特别隐蔽的一个坑。Service 做 DNAT 需要连接跟踪,压测并发上万时表容易打满,日志里会出现nf_conntrack: table full, dropping packet,表现为丢包、偶发超时,但应用层指标看起来很健康。查到 count 接近 max,就把 max 调大,同时缩短nf_conntrack_tcp_timeout_established(默认 432000 秒太大),避免连接长期占用表项。
还有 Ingress 或网关层的 keepalive 与超时配置。上游 keepalive 连接池太小会导致每个请求都新建连接,压测时直接把 CPS 顶到网关上限。这类问题要看网关自己的监控,而不是 k8s 的 Pod 指标。
5. 弹性扩容:让集群跟得上压测节奏
5.1 HPA 的指标选择与关键参数
压测时最典型的困境是:流量上来了,HPA 也开始扩了,但扩出来的速度跟不上流量爬升的速度,前两分钟服务被打挂,之后即使扩容完成,客户端也因为重试逻辑陷入雪崩。
先说说 HPA 指标怎么选。基于 CPU 是最简单的,但有个陷阱:CPU 利用率 = 实际使用量 / requests。如果把 requests 设得远大于实际用量(很多人为了"保险"会这么干),利用率上不去,HPA 就永远不触发。所以调 HPA 之前必须先把 requests 校准到接近真实用量的水平。
更贴近压测场景的是自定义指标,比如单 Pod QPS 或请求排队时长。用 Prometheus Adapter 把业务指标暴露给 HPA,目标设为每 Pod 200 QPS,扩容逻辑直接对应业务量,比 CPU 百分比直观得多。
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: api-server-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: api-server minReplicas: 8 maxReplicas: 60 metrics: - type: Pods pods: metric: name: http_requests_per_second target: type: AverageValue averageValue: "200" behavior: scaleUp: stabilizationWindowSeconds: 0 policies: - type: Percent value: 100 periodSeconds: 30 scaleDown: stabilizationWindowSeconds: 300这里两个参数值得说明。scaleUp.stabilizationWindowSeconds设为 0 表示不等待、立刻响应,压测和突发场景必须这样,默认的 0 秒其实也是 0,但很多人误配成 60 秒导致扩容迟钝。scaleDown的窗口给到 300 秒,避免流量小幅波动时反复缩容又扩容,那种抖动对服务稳定性伤害更大。
5.2 扩容延迟的三段构成与缓冲手段
HPA 从"决定扩容"到"真正能扛流量",中间有三段延迟,加起来轻松超过两分钟:
第一段是指标采集延迟,Prometheus 抓取间隔 15 秒,metrics server 同步周期 15 到 30 秒,HPA 控制器再 15 秒轮询一次,这里就 30 到 60 秒。
第二段是调度与启动延迟,如果节点资源足够,Pod 调度加启动 10 到 30 秒;如果不够,还要等 Cluster Autoscaler 开新节点,云上通常 1 到 3 分钟。
第三段是应用就绪延迟,镜像拉取、依赖初始化、JVM 预热、缓存加载,冷启动的服务在就绪后的头 30 秒性能通常只有稳态的一半。
三段加起来,从流量爬升到服务能力真正跟上,2 到 5 分钟很正常。压测场景不可能等这么久,所以要用缓冲手段:
手段一,overprovision 占位 Pod。部署一批低优先级的 pause 容器,requests 等于业务 Pod 的规格,数量按峰值预估。它们平时占着节点资源"排队",一旦业务 Pod 要扩容、节点资源不足,这些低优先级的占位 Pod 会被抢占驱逐,腾出位置给业务 Pod,等效于"预留了已经准备好的节点"。这是个非常有效的技巧,代价是要多付一些资源费用。
手段二,压测前手动预热。既然压测是有计划的,那就别指望 HPA 自己追。压测前 10 分钟把副本数手动打到目标值的 60% 到 80%,HPA 的 minReplicas 也临时上调到这个数,让它只在峰值段做微调。这是最稳妥的做法,我基本每次都这么干。
手段三,节点池预热与扩容模板优化。用预热节点池(保持若干台空闲已就绪节点),把镜像预拉到节点上,减少 init 容器的大文件下载。镜像从 800MB 优化到 200MB,冷启动能省十几秒。
手段四,压测流量爬坡要平滑。别一上来就满并发,用 30 到 60 秒做线性爬坡,给 HPA 一点反应时间。真实业务的流量增长也很少是垂直的。
6. 常见问题速查表与避坑实录
6.1 症状对照速查表
把压测中最常遇到的资源类问题整理成一张表,出问题时先对号入座,能省掉大量猜测时间。
| 症状 | 高概率原因 | 定位命令 | 处理动作 |
|---|---|---|---|
客户端报Cannot assign requested address | 本地端口耗尽 | ss -s、sysctl ip_local_port_range | 扩端口范围、改长连接、分布式压测 |
客户端Too many open files | fd 上限太低 | cat /proc/<pid>/limits | 调 ulimit、改 systemd LimitNOFILE |
| Pod 全部 Pending,副本数不变 | 节点资源不足或亲和性不满足 | kubectl describe pod看 Events | 扩节点池、放宽亲和性、调 requests |
| Pod 起来了但 P99 有周期性尖刺 | CPU limits 导致 throttling | cpu.stat看 nr_throttled | 去掉 CPU limit 或放大到 requests 的 2-4 倍 |
| 容器反复重启,日志无异常 | 内存 OOMKilled | kubectl describe pod看 Last State | 调低堆内存、调高 limit、查堆外内存 |
| 偶发丢包、应用层指标却正常 | conntrack 表满 | nf_conntrack_count对比 max | 调大 max、缩短 established 超时 |
| HPA 不扩容 | requests 远大于实际用量 | 对比kubectl top与 requests | 校准 requests |
| 扩容了但 TPS 没提升 | 瓶颈在依赖层或客户端 | 看依赖 RT、客户端 CPU | 分别处理,不是扩 Pod 就能解决 |
| 新 Pod 就绪后仍大量超时 | 冷启动未预热 | 对比新老 Pod 的 RT | 加就绪探针延迟、预热、减少镜像体积 |
6.2 几个我反复踩的坑
坑一:只调内存 limit,不看 requests。有人以为 OOM 就是 limit 太小,直接把 limit 从 512Mi 提到 4Gi,结果 Pod 调度不上去,因为 requests 也跟着涨了。改 limit 不改 requests 是无效的(除非只改 limit 那一项),而改 requests 会直接影响调度和 HPA 的利用率计算,这两个必须一起看。
坑二:压测环境的节点规格和线上不一致。压测用 16 核机器、线上用 8 核,压出来的单实例性能完全不可比。更坑的是 CPU 型号不同,同核数性能能差 30%。压测节点池的机器规格要尽量对齐线上。
坑三:忘记看节点的 Allocatable。kubectl describe node里 Capacity 和 Allocatable 差挺多,因为 kubelet 预留了system-reserved和kube-reserved,还有驱逐阈值。一个 16 核节点 Allocatable 可能只有 14.2 核,算容量的时候必须用 Allocatable,用 Capacity 会算多。
坑四:压测流量打到同一批节点。Pod 反亲和性没配,所有副本被调度到少数几个节点,那几个节点先爆,其他节点闲得很。压测前用kubectl get pod -o wide看一眼分布,必要的话配topologySpreadConstraints或者简单的 Pod 反亲和。
坑五:忽略探针配置。压测期间 Pod 压力大,liveness 探针超时时间设得太短(比如 1 秒),会把正常但响应慢的 Pod 杀掉重启,越压越乱。压测场景下 liveness 的 timeoutSeconds 和 failureThreshold 都要放宽。
提示:排查时永远先看"错误的具体形态",而不是看"哪个指标红了"。超时、连接拒绝、连接重置、5xx,这四种错误指向的原因完全不同,混在一起看就会得出错误结论。压测工具的聚合报表里通常能按错误码分组,用起来。
7. 一次完整的压测调优复盘
7.1 初始现象与第一轮假设
说个虚构但很典型的案例。某团队要验证一个新服务能不能扛住 3 万 QPS,第一次压测的结果是 TPS 卡在 9000 左右,P99 从 80ms 飙到 2 秒,错误率 12%。同时 HPA 显示副本数从 6 扩到了 20,但实际 Running 的只有 11 个,其余 Pending。
初始假设有三个:服务端计算能力不足、HPA 扩容太慢、节点资源不够。这三个假设都合理,但都不完整。第一轮动作是手动把副本数打到 40,结果 Pending 更多了,TPS 一点没涨——这说明瓶颈不在副本数上,扩了也没用,必须先找到真正的卡点。
回看压测客户端的监控,发现压测进程的 CPU 到了 95%,ss -s显示 TIME_WAIT 有 2.1 万个,接近端口上限。第一个真凶找到了:客户端端口耗尽,9000 QPS 这个数字根本不是服务端的能力上限,而是压测机的能力上限。
7.2 逐层收敛与最终参数
把压测改成 4 台机器分布式、启用 HTTP 长连接之后,TPS 上到 18000 才再次卡住。这次客户端 CPU 只有 40%,端口也不再是问题,说明瓶颈真的转到服务端了。此时观察 Pod 状态,发现新扩出来的 Pod 里 P99 明显比老 Pod 高,且抖动剧烈。
查 cgroup 的 throttling 数据,nr_throttled / nr_periods达到 23%。原来 Pod 配的是requests.cpu: 500m、limits.cpu: 500m,Java 服务的 JIT 编译和 GC 线程一爆发就被限流。把 CPU limit 去掉、requests 提到 800m 之后,P99 从 2 秒降到 220ms,throttling 比例降到 1% 以下。
再往后扩到 25000 QPS 时出现了内存问题,Pod 偶发 OOMKilled。容器 limit 是 1Gi,JVM 参数配的-Xmx900m,堆外只有 100m 空间,Metaspace 加线程栈加直接内存根本不够。改成 limit 2Gi、-Xmx1200m之后稳定了。这里的关键是堆内存占容器 limit 的比例要控制在 60% 左右,别贪。
最后一轮是扩容节奏问题。流量从 5000 爬到 25000 只用了一分钟,HPA 完全跟不上。最终方案是压测前把 minReplicas 从 6 调到 20,加上 overprovision 的占位 Pod,配合 60 秒的线性爬坡,做压力缓冲。最终跑到了 32000 QPS,P99 稳定在 260ms,错误率 0.3%。
调优前后的关键参数对比:
| 参数 | 调整前 | 调整后 | 效果 |
|---|---|---|---|
| 压测机数量 | 1 台 | 4 台 + 长连接 | 客户端瓶颈消除 |
| CPU limits | 500m(等于 requests) | 不设 limit,requests 800m | throttling 从 23% 降到 1% |
| 内存 limit / 堆 | 1Gi / 900m | 2Gi / 1200m | OOMKilled 消失 |
| minReplicas | 6 | 20(压测期间) | 扩容起点抬高 |
| 扩容缓冲 | 无 | overprovision 占位 Pod | 节点秒级可用 |
| 流量爬坡 | 瞬间满并发 | 60 秒线性 | 给 HPA 反应时间 |
整个过程花了差不多两天,真正调参数的时间不到三小时,剩下的都在定位——先是误以为是服务端容量问题,白白扩了十几轮 Pod。这件事之后我养成一个习惯:压测前先花二十分钟把客户端和链路的能力标定清楚,把"测不准"的因素排除掉,再开始测服务端。否则你测出来的所有数据都是可疑的,基于可疑数据做的调优决策自然会走弯路。
另一个体会是,资源不足这件事很少是单一原因,通常是两三层同时存在,解决掉最外层那个之后,里面那个才会暴露出来。所以要一层一层剥,每剥一层就重新跑一次阶梯压测,别指望一次调完所有参数。至于具体调到什么程度算够,我的标准是:目标流量的 120% 下持续跑 30 分钟,P99 和错误率都在可接受范围,且 CPU 水位不超过 70%,这时候才算真的有余量。