这一切都始于我向我们资深软件工程师提出的一个问题:
“嗯,不考虑通信的速度。你觉得用gRPC来开发通信真的比用REST更好吗?”
我没想到的答案立刻来了: “那还用说。”
在我提出这个问题之前,我在一个滚动更新期间监控我们服务的一种奇怪行为,尤其是在增加pod数量时。我们大多数微服务过去通过REST调用进行通信,一直都没有遇到问题。为了减少REST带来的开销,我们已经将一些集成从REST迁移到了gRPC。最近,我们注意到一些问题,这些问题都指向同一个方向:我们的gRPC通信。当然,我们遵循了在Kubernetes中运行gRPC而不需要服务网格的建议实践,比如在这篇博客文章中描述的方法,我们在服务器端使用了无头服务,并在gRPC客户端实现了基于DNS发现的“轮询”负载均衡等。
Kubernetes内部负载均衡器不均衡RPC请求,而是均衡TCP连接。你可以在我另一篇博客文章中了解更多关于Kubernetes如何平衡TCP连接的信息。
第4层负载均衡器很常见,因为它们简单且协议无关。然而,gRPC打破了Kubernetes提供的连接级负载均衡。这是因为gRPC是基于HTTP/2的,而HTTP/2设计为保持一个长时间的TCP连接,使得所有的请求可以在任何时间点都活跃在同一连接上。这减少了连接管理的开销。然而,在这种情况下,连接级均衡并不太有用,因为一旦连接建立,就不再需要进行均衡了。所有请求都会固定在原始目的Pod上,如下所示,直到新的DNS发现发生(带有无头服务)。这不会发生,除非现有的至少一个连接断开。
问题示例:
gRPC负载均衡 的例子
如我之前提到的,我们使用“客户端侧的负载均衡”,通过DNS发现使用无头服务。其他可能的选项包括使用代理负载均衡或实现另一种类似的方法,这种方法会通过Kubernetes API来查询,而不是通过DNS。
除此之外,gRPC 文档还提供了服务器端连接管理提案,我们也试了试。
以下是我对于设置服务器参数的建议,附有一个用Go代码初始化gRPC的示例:
MAX_CONNECTION_AGE
设置为 30 秒。这样足够长的时间可以实现低延迟通信,同时避免频繁且昂贵的连接建立过程。此外,它还允许服务能够相对快速地响应新 pod 的存在,使流量分配更为均衡。MAX_CONNECTION_AGE_GRACE
设置为 10 秒。定义了连接在完成 RPC 请求前可保持活跃的最大时间。grpc.KeepaliveParams(keepalive.ServerParameters{ MaxConnectionAge: time.Second * 30, // 这一个参数奏效了 MaxConnectionAgeGrace: time.Second * 10, // 最大连接年龄宽限期 })
它在现实生活中的表现:
应用 gRPC 配置变更前后的 pod 数量
在 gRPC 配置更改后,新 pod 中观察到的网络 I/O 活动情况
接下来是第三行
缩放问题已经搞定,但另一个问题变得更加明显。重点转向了客户端进行滚动更新(即逐个更新 Pod)时出现的 gRPC code=UNAVAILABLE 问题。有趣的是,这种情况只在滚动更新期间出现,而在单个 Pod 的缩放事件期间却没有发现。
滚动更新过程中出现的 gRPC 错误次数
滚动部署的过程很简单:创建一个新的replicaset,然后创建一个新的pod,当新pod准备好后,旧pod会被终止,依此类推。每次新pod启动之间的时间间隔为15秒。我们了解到,关于gRPC DNS重新发现,它只会在旧连接中断或收到_GOAWAY_信号时启动。因此,客户端每15秒启动一次新的重新发现,但得到的是过时的DNS记录。客户端会不断尝试重新发现,直到成功。
基本上都是 DNS 的问题……除非另有他因
DNS TTL缓存几乎在每个地方都可以找到。基础设施的DNS也有它自己的缓存。Java客户端受到默认30秒TTL缓存的影响比通常不实现DNS缓存的Go客户端要大得多。Go客户端报告的问题较少,而Java客户端报告了数百甚至数千次的问题。那么,在滚动更新期间仅影响gRPC时,为什么要做这样的改变呢?
幸运的是,有一个简单易行的解决办法:在新 pod 启动时设置 30 秒延迟。
最小就绪秒数设为30
Kubernetes部署规范允许我们设置一个新的Pod必须准备好之前的一个最短时间间隔,之后才会开始终止旧Pod。过了这段时间之后,连接会被终止,gRPC客户端会收到GOAWAY信号并开始重新寻找服务。TTL已经过期,因此客户端会获取新的、更新的记录。
gRPC 在配置方面就像一把瑞士军刀,可能默认情况下并不适合你的基础设施或应用程序。多看看文档,微调一下,多试试,充分利用现有资源。我觉得可靠且有弹性的通信应该是你最想达到的目标。
我建议你也看看: