那次 gRPC 连接每隔两分钟就断,最后发现是 Keepalive 的 permit-keepalive-time 配反了
事情是这样的:生产环境里有一组 gRPC 服务,突然开始每隔两分钟规律性地断开连接。不是偶发,是精确的两分钟,像上了闹钟一样。
日志里反复出现 UNAVAILABLE: keepalive watchdog timeout 或者 ENHANCE_YOUR_CALM 这类错误。一开始以为是网络设备中间有 NAT 超时、负载均衡器有 idle timeout,排查了一圈网络层,防火墙、L4 LB、容器网络插件挨个翻,都没找到两分钟这个时间阈值的配置。
最后在服务端配置里看到这么一行:
GRPC_ARG_KEEPALIVE_PERMIT_TIME_WITHOUT_CALLS: 120000
客户端配置是:
grpc.keepalive_time_ms: 30000
grpc.keepalive_timeout_ms: 10000
问题就在这。permit-keepalive-time 的含义是「服务端在没有收到任何 RPC 调用的情况下,允许客户端发送 keepalive ping 的最短间隔」。客户端每 30 秒发一个 ping,但服务端要求至少 120 秒才能发一次不带调用的 ping——客户端发得太勤了,服务端直接返回 GOAWAY 帧,连接被强制关闭。
把这两个值的关系搞反了,是这次故障的根源。
这两个参数到底在控制什么
gRPC 的 Keepalive 机制分两端,各自有几个核心参数。
客户端参数:
grpc.keepalive_time_ms:客户端每隔多久发送一次 HTTP/2 PING 帧。默认值是无限大,也就是不开启。常见配置是 30-60 秒。grpc.keepalive_timeout_ms:发出 PING 之后,等多长时间没收到 ACK 就认定连接已死。常见配置 10-20 秒。grpc.keepalive_permit_without_calls:布尔值,是否允许在没有进行中的 RPC 调用时也发送 keepalive ping。默认 false。如果不开启,只有在有活跃 stream 时才会发 ping。
服务端参数:
grpc.http2.min_ping_interval_without_data_ms(C-core)/GRPC_ARG_HTTP2_MIN_PING_INTERVAL_WITHOUT_DATA_MS(Java)/permit-keepalive-time(Go):服务端允许客户端在没有数据帧的情况下,两次 PING 之间的最小间隔。如果客户端发 ping 的频率超过这个限制,服务端会发送 GOAWAY 帧,终止连接,并返回ENHANCE_YOUR_CALM。grpc.http2.max_ping_strikes:服务端允许的「违规 ping」次数。默认是 2。连续收到两次过于频繁的 ping,连接就会被关闭。grpc.server_keepalive_time_ms/grpc.server_keepalive_timeout_ms:服务端主动向客户端发 keepalive ping 的周期和超时,用于检测僵死连接。
关键就在这里:客户端的 keepalive_time_ms 必须大于或等于服务端的 permit-keepalive-time,否则连接必然会被服务端主动掐断。
我当时的配置里,客户端 30 秒发一次 ping,服务端要求至少 120 秒才能发一次,客户端频率是服务端容忍上限的 4 倍。服务端按照 max_ping_strikes=2 的默认值,在收到第二次过于频繁的 ping 后(也就是大约 60 秒到 120 秒之间),直接踢掉连接。从连接建立到被踢,正好两分钟左右——这就解释了为什么断连的时间窗口如此规律。
为什么这个问题在生产环境才暴露
测试环境和预发环境一直正常,因为那两套环境的服务端没有配置 permit-keepalive-time,用的是默认值(C-core 默认 5 分钟,Go 默认不限制)。生产环境为了「安全」加了这个参数,但配成了 120 秒,反而制造了一个比客户端心跳周期更长的限制窗口。
这是一个典型的配置漂移问题。三个环境的 gRPC 参数不一致,导致测试环境根本无法覆盖生产场景。
怎么根据实际网络环境调这组参数
第一步:搞清楚你的网络中间件到底有多长 idle timeout。
云环境的 Load Balancer、NAT 网关、防火墙通常都有 idle timeout,AWS Classic ELB 是 60 秒,ALB/NLB 可以调到 400 秒,GCP Cloud Load Balancing 是 600 秒,Azure Load Balancer 默认 4 分钟可调到 30 分钟。很多企业自建的数据中心网络设备默认 300 秒甚至更短。
客户端的 keepalive_time_ms 必须小于这个 idle timeout,否则连接会在网络层被静默回收,但 gRPC 两端都以为连接还活着,产生半开连接。一般设为 idle timeout 的一半或三分之一比较安全。比如 ALB 配置了 120 秒 idle timeout,客户端 keepalive_time_ms 可以设为 60 秒。
第二步:服务端的 permit-keepalive-time 必须小于或等于客户端的 keepalive_time_ms。
这个关系是硬约束。一旦违反,服务端会主动终止连接。通常做法是服务端设一个宽松的值,比客户端周期小 10%-20%,或者干脆相等。比如客户端 60 秒,服务端设 50 秒或 60 秒。
第三步:客户端的 keepalive_timeout_ms 要足够短,但不能太短。
这个值决定了你多快能发现连接真的死了。设 10 秒意味着如果服务端 10 秒内没有回复 PING ACK,客户端就认为连接断开,触发重连。太短了容易因网络抖动误判,太长了故障发现太慢。10-20 秒是个合理的区间。
第四步:别忘了 permit_without_calls。
如果你的连接池里有很多空闲连接(没有进行中的 RPC),但这个参数是 false(默认值),那 keepalive 根本不会生效,这些空闲连接依然会被网络中间件回收。对于长连接场景,这个参数一定要显式设为 true。
一个能落地的配置方案
假设网络环境是 AWS ALB,idle timeout 配置为 300 秒。客户端和服务端都是 gRPC-Go。
客户端:
import "google.golang.org/grpc/keepalive"
var kacp = keepalive.ClientParameters{
Time: 60 * time.Second, // 每60秒发一次ping,远小于ALB的300秒
Timeout: 15 * time.Second, // 15秒没回ACK就认为连接挂了
PermitWithoutStream: true, // 空闲连接也要保持心跳
}
conn, err := grpc.Dial(target,
grpc.WithKeepaliveParams(kacp),
// ... 其他选项
)
服务端:
var kaep = keepalive.EnforcementPolicy{
MinTime: 50 * time.Second, // 允许客户端至少50秒发一次ping
PermitWithoutStream: true, // 允许客户端在没有active stream时发ping
}
var kasp = keepalive.ServerParameters{
Time: 60 * time.Second, // 如果服务端也要主动发ping检测客户端
Timeout: 15 * time.Second, // 15秒没收到客户端的ACK就断开
}
s := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(kaep),
grpc.KeepaliveParams(kasp),
)
Go 的 keepalive.EnforcementPolicy.MinTime 对应 C-core 的 GRPC_ARG_HTTP2_MIN_PING_INTERVAL_WITHOUT_DATA_MS,也就是前面说的 permit-keepalive-time。服务端 MinTime=50 秒,客户端 Time=60 秒,客户端发 ping 的频率在服务端容忍范围内,不会被踢。
如果场景里连接数特别大(比如数千甚至上万个连接),客户端 Time 可以适当拉长到 120 秒甚至 300 秒,减少 PING 帧的开销。但要同步把服务端 MinTime 也调大,保持客户端频率 ≤ 服务端限制。
排查这类问题的几个切入点
遇到 gRPC 连接规律性断开,先不要急着查网络设备。抓几个关键信息:
- 确认断开的时间间隔是否固定。 如果非常规律(比如恰好两分钟、五分钟),大概率是参数冲突,而不是网络抖动。
- 看客户端日志的错误码。
ENHANCE_YOUR_CALM是服务端显式告诉客户端「你的行为太激进」的信号,基本可以锁定是 keepalive ping 频率问题。UNAVAILABLE伴随着keepalive watchdog timeout则可能是服务端主动发 ping 但客户端没响应。 - 分别查两端 keepalive 配置的实际值。 不要看代码里的默认值,要确认运行时实际加载的配置。很多框架有配置覆盖、环境变量注入,实际值可能和代码里写的不一样。Go 的话可以用
DEBUG=grpc环境变量开启 gRPC 内部日志,或者在连接建立后打一下grpc.ConnectParams()的输出。 - 抓包看 PING 帧的频率。 Wireshark 过滤
http2.ping,能看到 PING 和 PING ACK 的时间戳,直接算出发送间隔。这比看配置更可靠。
那次故障修起来其实很简单,把服务端的 permit-keepalive-time 从 120 秒改成 20 秒就解决了——让服务端的容忍度大于客户端的发送频率。但找到这个根因花了整整一个下午,中间绕了无数弯路。说到底,gRPC keepalive 这组参数的设计逻辑并不复杂,但两个端的参数容易让人产生对称性的错觉,以为服务端设一个大的值更「安全」,实际上恰恰相反。
常见问题
为什么客户端 keepalive_time_ms 配得比服务端 MinTime 小就一定会断?
因为服务端把过于频繁的 PING 视为异常行为。HTTP/2 规范里 PING 帧本身不承载数据,纯粹用于连接健康检查和 RTT 测量。如果一个客户端在没有数据传输的情况下高频发送 PING,服务端会认为这是资源滥用,通过 GOAWAY 帧终止连接。max_ping_strikes 默认是 2,意味着容忍两次违规后就直接断开。
我把两端参数调成一样会不会有问题?
不会有问题。MinTime 的含义是「PING 间隔不得小于这个值」,等于这个值是允许的。客户端 60 秒发一次,服务端 MinTime 设 60 秒,刚好在边界内,不会被判定为违规。但要注意时钟漂移和网络延迟可能让实际间隔出现微小波动,留 5-10 秒的 buffer 会更稳妥。
如果已经出现大量 ENHANCE_YOUR_CALM 错误,需要重启服务吗?
不需要。修好配置后重新部署即可,新配置只对新建立的连接生效。已经返回 GOAWAY 的连接会被客户端自动重建,重连后就会使用新的参数约束。但要注意如果客户端侧有连接池,旧的连接可能不会立即被回收,观察一段时间确保连接数恢复正常。