8 台后端只有 1 台在扛 CPU:LVS 四层负载均衡被 gRPC 长连接“打回单机”的排查笔记

凌晨 1 点 20 分,第三台了

报警是从 CPU 开始的。8 台 RS(Real Server),跑的是 gRPC 服务,8C16G,平时 CPU 都稳在 10%~15%。但从晚上 11 点开始,每四五十分钟就有一台冲到 90%+,持续十几分钟后自己掉下来,然后换另一台继续冲。

值班同学的第一反应是:“谁在做定时任务?” 查完 crontab、查完业务日志,发现没有。第二反应是:“负载均衡的会话保持是不是忘关了?”

于是就有了这篇记录。

第一轮:先把 LB 自己的底牌翻出来

架构是标准的 keepalived + LVS DR 模式,两台 Director 挂在 VIP 10.0.0.10:8443,后端 8 台 RS 10.0.3.11~10.0.3.18,服务端口 8080。

ipvsadm -Ln
TCP  10.0.0.10:8443 rr
  -> 10.0.3.11:8080              Route   1      0          0
  -> 10.0.3.12:8080              Route   1      0          0
  -> 10.0.3.13:8080              Route   1      0          0
  ...
  -> 10.0.3.18:8080              Route   1      0          0

算法是 rr,权重全 1,没有持久化(-p)参数。调度层面看起来是干净的,不是“会话保持把人钉死了”。这一步其实很快,但必须做——不然你会花两个小时怀疑一个根本不存在的问题。

第二轮:看活跃连接表,不对劲的感觉来了

ipvsadm -Lnc | awk 'NR > 1 {print $NF}' | sort | uniq -c | sort -rn
     47 10.0.3.14:8080
      6 10.0.3.11:8080
      5 10.0.3.17:8080
      4 10.0.3.12:8080
      4 10.0.3.13:8080
      3 10.0.3.15:8080
      3 10.0.3.16:8080
      2 10.0.3.18:8080

总共才 78 条活跃连接,但 LVS 的转发计数告诉我 QPS 是 6000+。

78 条连接扛 6000 QPS,也就是说平均每条连接上已经跑过了几十万个请求。而 47 条压在同一台 RS 上——热点就是它。

先确认一下包数是真倾斜,不是连接表采样问题:

ipvsadm -Ln --stats | grep -A9 '10.0.0.10:8443'

InPkts 那一列,热点 RS 的累计包数是其它机器的 20~40 倍。这不是抖动,这是结构性倾斜。

第三轮:建连速率,一锤定音

我让同事在 Director 上挂了 30 秒抓包,只看 SYN:

tcpdump -i eth0 -nn -q 'tcp port 8443 and tcp[tcpflags] & tcp-syn != 0' -w /tmp/syn.pcap &
sleep 30
kill %1
tcpdump -nn -r /tmp/syn.pcap | grep -c 'Flags \[S\]'

结果是 9

30 秒、18 万次 RPC、只有 9 次 TCP 建连。

到这里其实已经不用再往下查了。但为了把根因钉死,我关掉抓包,去 RS 上看了一眼连接来源:

# 在任意一台 RS 上执行
ss -Htn state established 'sport = :8080' | awk '{print $4}' | cut -d: -f1 | sort | uniq -c
      7 10.0.5.21
      5 10.0.5.22
      3 10.0.5.24
      1 10.0.5.31

整套系统后面只有 4 个客户端实例,每个实例只维持个位数的 TCP 连接。

DR 模式的好处在这里体现出来了:不改源 IP,RS 上直接就能看到真实客户端网段,不然还得先补一层 PROXY Protocol 才能定位到这一步。(如果走的是 FULLNAT 或者云托管 LB 做了 SNAT,这一步会麻烦很多——我们这套跑在轻云互联的 VPC 里,没开源地址检查,DR 的回包出了网关畅通无阻,省掉了一个经常要跟云厂商扯皮的环节。)

根因:四层负载均衡的公平粒度是“连接”,不是“请求”

这是整件事唯一需要记住的一句话:

LVS / 云 LB 的 TCP 监听,是在 new connection 那一刻做一次调度决策,之后这条连接上的所有请求,都绑定到同一台 RS。 它没有能力、也不打算理解你在连接上跑了多少条 RPC。

把它和 L7 对比一下就清楚了:

  • Nginx / Envoy 作为反向代理,是 每条 request 重新选一次 peer,所以哪怕客户端只有 1 条连接,请求也能均摊到 8 台。
  • LVS / 四层 LB 是 每条 connection 选一次 peer,客户端有多少条连接,就有多少个“调度单位”。

当客户端是 gRPC、HTTP/2、WebSocket、长轮询这类长连接模型,客户端的连接数往往远小于 RS 数量时,四层 LB 的轮询就退化成了“随机挑一台,然后全压上去”。

每次连接因超时/GC/网络抖动断掉重连,就会重新掷一次骰子——所以你看到的是“热点每 40 分钟换一台”,而不是固定的某台被打满。

一个容易踩空的伪解法:改客户端 round_robin

知道根因之后,很多人的第一反应是去改客户端:

conn, _ := grpc.DialContext(ctx, "lb.internal:8443",
    grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

没用。 gRPC 的 round_robin 是“每个 DNS 解析出的地址建一个 subchannel”。你的目标地址是 lb.internal,DNS 只返回一个 VIP,解析出来就一个地址,round_robin 面对一个地址也只能建一条连接。把默认的 pick_first 换成 round_robin,从 1 条连接变成 1 条连接。

这个坑很隐蔽,因为配置改了、日志也正常、主观上“我优化过了”,但连接表纹丝不动。

真正落地的修复

方案 A:服务端强制连接轮换(最快止血,改一行配置)

让服务端主动周期性下发 GOAWAY,客户端收到后会优雅排空正在处理的 RPC,然后重新建连——重新建连就会重新经过 LVS 的 rr,落到新的 RS 上。8 台 RS 各自的计时器是独立跑的,关闭时刻天然错开,流量会自动重新摊匀。

Go 版本:

s := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        MaxConnectionAge:      90 * time.Second,
        MaxConnectionAgeGrace: 20 * time.Second,
        Time:                  20 * time.Second,
        Timeout:               5 * time.Second,
    }),
)

Java 版本:

NettyServerBuilder.forPort(8080)
    .maxConnectionAge(90, TimeUnit.SECONDS)
    .maxConnectionAgeGrace(20, TimeUnit.SECONDS)
    .permitKeepAliveTime(20, TimeUnit.SECONDS)
    .permitKeepAliveWithoutCalls(true)
    .build();

MaxConnectionAgeGrace 千万别设成 0 或者几秒,否则长事务、大 streaming 会在排空期被硬切,表现成“偶发 UNAVAILABLE”。我的经验值是不小于客户端 P99 处理耗时的 3 倍。

方案 B:在 LVS 后面加一层 L7(治本)

如果业务形态允许,最干净的做法是把“请求级均衡”交回给七层:客户端 → LVS(rr) → Nginx/Envoy → 后端。这样连接级的不均衡被 L7 那一跳吃掉了,后端看到的永远是均匀的请求分布。代价是多一跳网络,以及需要处理 $remote_addr 的透传(proxy_protocol / X-Forwarded-For)。

方案 C:HTTP/1.1 场景下限制单连接请求数

如果上游是 Nginx 做反代,把连接池的复用次数压下来也能缓解:

upstream app_backend {
    least_conn;
    server 10.0.3.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.3.12:8080 max_fails=3 fail_timeout=10s;
    # ...
    keepalive 256;
    keepalive_requests 200;   # 单连接最多 200 个请求就关掉重建
    keepalive_time   60s;
    keepalive_timeout 30s;
}

注意 keepalive_requests / keepalive_time 需要 Nginx 1.15.3+。这个参数本质上是“手工制造连接轮换”,跟方案 A 是一个思路,只是发生在 HTTP/1.1 层。

验证:别再看 CPU,直接盯连接分布

修完之后,验证方式不是看 CPU 曲线,而是直接算倾斜比。我把它写成了一个 5 分钟跑一次的巡检脚本:

#!/bin/bash
# /usr/local/bin/lvs_spread_check.sh
ipvsadm -Lnc | awk 'NR > 1 {print $NF}' | sort | uniq -c | sort -rn > /tmp/conn.txt

total=$(awk '{s+=$1} END{print s}' /tmp/conn.txt)
rs=$(wc -l < /tmp/conn.txt)
[ "$rs" -eq 0 ] && { echo "no conn"; exit 0; }

max=$(head -1 /tmp/conn.txt | awk '{print $1}')
avg=$(( total / rs ))
ratio=$(( max * 100 / (avg + 1) ))

echo "total=${total} rs=${rs} max=${max} avg=${avg} skew=${ratio}%"
[ "$ratio" -gt 300 ] && echo "WARN: LVS 连接分布倾斜,max/avg=${ratio}%"

上线后 skew 稳定在 110%~140% 之间(正常抖动),再没超过 200%。CPU 也回到了 8 台均衡的 10%~15%。

复盘清单

  • 四层 LB 只保证连接级公平。 判断你的场景会不会翻车,只需要算一个比值:QPS / 新建连接 QPS。这个值超过 1000,四层 LB 的均衡基本就是在碰运气。
  • 先量再猜。 两条命令定乾坤:ipvsadm -Lnc | awk 'NR>1{print $NF}' | sort | uniq -c 看分布,tcpdump ... 'tcp[tcpflags] & tcp-syn != 0' 看建连速率。
  • 长连接协议(gRPC / HTTP2 / WS)在四层 LB 后面,必须配连接轮换。 服务端 MaxConnectionAge 是性价比最高的解药。
  • 别迷信客户端 round_robin。 目标地址只有一个 VIP 时,它建不出多于一条的连接。
  • 告警要盯连接倾斜,而不是 CPU。 CPU 是结果,倾斜是原因,等 CPU 报警的时候,热点那台的 RT 已经开始劣化了。
  • DR 模式的 ARP 抑制顺手复查一遍:RS 上的 lo 配 VIP,arp_ignore=1arp_announce=2,以及云平台侧的源地址检查是否放行回包——这三个漏一个,就会在流量重新分布之后冒出新的“随机 502”。