WEBKT

Kubernetes 下 gRPC 莫名连接中断?聊透 TCP Keepalive 缺失的排查与终极修复

23 0 0 0

在 Kubernetes 生产环境中,你可能遇到过这样一种令人抓狂的现象:

两个微服务通过 gRPC 进行通信,在业务高峰期一切正常。但只要稍微空闲一段时间(比如几分钟到十几分钟),下一次调用就会大概率报错:rpc error: code = Unavailable desc = transport is closing。重试之后,连接又奇迹般地恢复了。

查看客户端日志,除了连接被重置(RST)或超时,没有任何有价值的信息;查看服务端日志,一片风平浪静,完全没有 OOM、崩溃或主动关闭连接的迹象。

这其实是一个极其经典的 “静默连接断开” 现象。由于 gRPC 基于 HTTP/2,而 HTTP/2 又是典型的长连接协议,一旦底层网络链路上某些节点(如 Kubernetes 的 Service 代理、Cloud Provider 的 NAT 网关、负载均衡器或防火墙)单方面回收了“空闲连接”,而客户端和服务端对此一无所知,就会导致上述异常。

本文将带你深入底层,还原这个故障的根源,并一步步教你如何通过工具定位,并最终通过合理的 Keepalive 配置彻底解决它。


为什么长连接在 Kubernetes 中会“悄无声息”地死掉?

要理解这个现象,首先需要看清 Kubernetes 中的网络拓扑。

gRPC 的长连接并不是直接从客户端 Pod 的网卡连到服务端 Pod 的网卡,中间通常要经过多层物理和虚拟网络设备的转发:

Pod (Client) -> Envoy/istio-proxy (可选) -> Conntrack (Node 节点) -> 负载均衡器 (NLB/ALB) -> Pod (Server)

在这条链路上的绝大多数网络中间件,为了维护自身的路由表和资源限制,都有一套 “空闲超时清理机制”(Idle Timeout):

  • NAT 网关 / 负载均衡器 (L4 LBR):通常会将处于 ESTABLISHED 状态但无流量的 TCP 连接保留 300 秒 ~ 900 秒。超时一到,这些中间件会单方面将连接从路由表中抹去。
  • Linux Conntrack 表:Linux 的连接跟踪表也有超时时间,长期无流量的连接记录会被清理。

致命的问题在于:
当中间件抹去这条连接时,它往往不会向客户端或服务端发送 RSTFIN 包。

此时:

  1. 客户端认为连接依然健康,继续持有该 Socket。
  2. 过了 15 分钟,客户端尝试发送一个新的 gRPC 请求。
  3. 数据包到达中间件(如 NAT 网关/防火墙)后,中间件发现自己的路由表里根本没有这个连接的相关记录,于是它会直接丢弃该数据包,或者向客户端回一个 RST 包。
  4. 客户端此时才如梦初醒,抛出 transport is closing 的异常。

如何在 Kubernetes 中精准定位此问题?

面对这种“时隐时现”的连接断开,我们可以通过以下三步法进行定位。

第一步:开启 gRPC 详细调试日志

在无需修改代码的情况下,可以通过环境变量让 gRPC 客户端/服务端输出详细的传输层日志。

以 Go 语言构建的服务为例,在 Deployment 的 YAML 中注入以下环境变量:

spec:
  containers:
  - name: my-grpc-client
    env:
    - name: GRPC_GO_LOG_SEVERITY_LEVEL
      value: "info"
    - name: GRPC_GO_LOG_VERBOSITY_LEVEL
      value: "2"

当异常发生时,你会观察到类似下面的日志:

INFO: 2023/10/24 10:14:02 transport: loopyWriter writing on dead connection
INFO: 2023/10/24 10:14:02 transport: http2Client.readerLoop failed to read frames: read tcp 10.244.1.45:49210->10.244.2.88:50051: read: connection reset by peer

这明确告诉你:底层 TCP 连接其实已经死了(dead connection),客户端是在对死连接进行读写时才发现了异常。

第二步:利用 ssnetstat 观察 Socket 的 Keepalive 状态

进入发生异常的 Pod,查看当前 TCP 连接的计时器状态:

# -t 显示 TCP 协议连接,-o 显示计时器,-i 显示详细信息
ss -t -o -i

输出可能如下:

State      Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB      0      0      10.244.1.45:49210  10.244.2.88:50051

请注意,如果该连接后面没有出现类似 timer:(keepalive,14min,0) 的字样,说明该 Socket 根本没有启用 TCP 层的 Keepalive,或者启用时间长得毫无意义。在默认的 Linux 内核配置下,tcp_keepalive_time 默认是 7200秒(2小时),这远远大于任何中间件的空闲超时时间(通常为 5~15 分钟)。

第三步:终极手段——抓包分析(The Smoking Gun)

在 Kubernetes 中,我们可以利用 ksniff 工具或在 Node 节点上使用 nsenter 直接在 Pod 的网络命名空间内抓包。

假设我们要抓客户端 Pod(IP 10.244.1.45)向服务端(端口 50051)发送的流量:

# 假设已知 Pod 的网络命名空间,或者在 Node 上直接使用 tcpdump 
tcpdump -i any port 50051 -vv -w grpc_issue.pcap

用 Wireshark 打开抓包文件,你会看到非常经典的几何学走势:

  1. 10:00:00:最后一次正常业务请求结束(双方开始陷入安静)。
  2. 10:11:00:(中间什么数据包都没有,没有 TCP Keep-Alive,也没有 HTTP/2 PING)。
  3. 10:11:05:客户端尝试发起一个新的 gRPC 请求(发送了带有 HEADERS 帧的 TCP 包)。
  4. 10:11:05:客户端立刻收到一个来自对端(或者中间代理)的 [RST, ACK],或者客户端在连续重传(Retransmission)数秒后宣告超时。

这就证实了猜想: 在 11 分钟的空闲期内,因为没有任何保活流量,连接被中间网络设备悄悄“掐断”了。


终极解决方案

要彻底解决这个问题,我们需要在两个维度上发力:应用层(HTTP/2 PING)网络层(OS TCP Keepalive)。对于 gRPC 来说,由于其运行在 HTTP/2 之上,最推荐也最彻底的方案是配置应用层的 gRPC Keepalive

方案 A:配置 gRPC 级别的 Keepalive(首选)

gRPC 本身提供了强大的 Keepalive 参数。它不依赖底层的操作系统,而是通过在 HTTP/2 协议层定时发送 PING 帧来维持连接,并且一旦发现 PING 无响应,会主动断开并重建连接。

下面以 Go 语言为例,展示客户端和服务端的安全配置:

1. 客户端配置

客户端需要主动启动 PING 发送机制:

import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/keepalive"
    "time"
)

func main() {
    // 定义客户端 Keepalive 参数
    kacp := keepalive.ClientParameters{
        Time:                1 * time.Minute, // 如果 1 分钟内没有活动,则发送 PING
        Timeout:             20 * time.Second, // PING 发送后,等 20 秒,若无回应则认为连接已断开
        PermitWithoutStream: true,             // 即使没有活跃的 RPC 请求,也允许发送 PING 保活(关键配置)
    }

    conn, err := grpc.Dial(
        "service-address:50051",
        grpc.WithInsecure(),
        grpc.WithKeepaliveParams(kacp),
    )
    // ...
}

2. 服务端配置

许多人配了客户端却依然报错,这是因为服务端有安全防御机制。为了防止恶意客户端发送大量的 PING 造成 DDoS 攻击,gRPC 服务端默认对接收 PING 的频率有限制(默认不低于 5 分钟)。如果客户端发送太频繁,服务端会发送 GOAWAY 并强制断开连接(错误码 ENHANCE_YOUR_CALM)。

因此,服务端也必须进行对等配置,允许客户端的行为:

import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/keepalive"
    "time"
)

func main() {
    // 1. 服务端对客户端发送 PING 的限制策略
    kaep := keepalive.EnforcementPolicy{
        MinTime:             10 * time.Second, // 允许客户端最快每 10 秒发送一次 PING
        PermitWithoutStream: true,             // 即使没有活动 stream,也允许客户端发送 PING
    }

    // 2. 服务端自身的连接保活与探测
    kasp := keepalive.ServerParameters{
        Time:    2 * time.Minute, // 如果 2 分钟内没有活动,服务端也主动向客户端发送 PING
        Timeout: 20 * time.Second, // 等待 20 秒无回应则断开客户端连接
    }

    server := grpc.NewServer(
        grpc.KeepaliveEnforcementPolicy(kaep),
        grpc.KeepaliveParams(kasp),
    )
    // ...
}

提示:Java, C++, Node.js 等其他语言的 gRPC SDK 也拥有完全对等的配置项。


方案 B:修改 Pod 的系统级 TCP Keepalive(备用/补充)

如果你的业务不方便修改代码,或者在运行一个老旧的第三方组件,你可以通过 Kubernetes 的 securityContext 直接调整 Pod 内部的 Linux 内核网络参数(Sysctl)。

默认的系统级 Keepalive 是 2 小时,我们可以将其缩短到 1 分钟左右:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  template:
    spec:
      securityContext:
        sysctls:
        # 探测包首次发送的空闲时间,修改为 60 秒
        - name: net.ipv4.tcp_keepalive_time
          value: "60"
        # 两次探测之间的间隔时间,修改为 10 秒
        - name: net.ipv4.tcp_keepalive_intvl
          value: "10"
        # 探测失败的最大次数,达到这个次数后断开
        - name: net.ipv4.tcp_keepalive_probes
          value: "6"
      containers:
      - name: my-container
        image: my-app-image:latest

注意: 使用 sysctls 需要 Kubernetes 集群管理员在 Kubelet 中允许对这些参数进行自定义设置(部分受限 Sysctls 需要加入白名单)。此外,如果应用程序创建 Socket 时没有设置 SO_KEEPALIVE 属性,系统级别的 TCP Keepalive 配置依然不会对该 Socket 生效(而 gRPC 默认在创建底层 Socket 时通常会开启 SO_KEEPALIVE,因此该设置在多数情况下有效)。


方案 C:Service Mesh 环境下的配置(如 Istio)

如果你的集群部署了 Istio 等 Service Mesh,你可以在 DestinationRule 中配置对下游/上游的 TCP 探测参数,从而不需要改动任何业务代码:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: my-service-keepalive
spec:
  host: my-service.default.svc.cluster.local
  trafficPolicy:
    connectionPool:
      tcp:
        connectTimeout: 30s
        tcpKeepalive:
          time: 60s
          interval: 10s
          probes: 5

Istio 的 Sidecar (Envoy) 会代替你的应用程序去维护底层连接的活性,并在超时前自动处理连接保活。


总结

在云原生和 Kubernetes 环境下,“网络黑盒”的数量呈指数级增长。由于 gRPC 偏爱维持长连接的本性,这与中间网络件的“积极回收空闲资源”策略存在天然的冲突。

要杜绝 transport is closing 的异常断开:

  1. 优先在应用层部署 gRPC Keepalive,这是最精准、最不容易受基础设施变动影响的实践。配置客户端时,别忘了配置服务端的 EnforcementPolicy 进行放行。
  2. 配合系统级 Sysctls 保底,缩短 Linux 内核的 Keepalive 探测周期(推荐 60 秒),防止普通 TCP 空闲连接在 L4 负载均衡器处静默夭折。
  3. 善用工具链,遇到问题时,通过 ss -o、gRPC 调试日志、Wireshark 抓包,能够帮你快速证明是底层网络节点的拦截,还是对端的主动拒绝。
SREDeepDive KubernetesgRPC

评论点评