这篇文章上次修改于 288 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

前言

这大抵是春节前的最后一篇随笔了吧,预计是在除夕前完成。同时,这也是我今年思考过的问题之一,谨以此文作为今年工作的收尾之作。不过说实话,这个选题拿来单独开一篇随笔,是否有点太过敷衍了呢?毕竟其中原理十分简单易懂。但我还是决定开这个题,主要还是因为,我认为这篇随笔可以是后续微服务治理有关的文章的前言,就权当一点开胃小菜吧!

本篇随笔会从传统HTTP服务开始介绍,分析gRPC和HTTP的相似和不同之处,再来介绍如何在Kubernetes中实现gRPC的负载均衡。

正文

负载均衡在‘哪一层’实现?

在开始进入正题之前,我们需要先聊一个概念性问题——负载均衡在哪里实现?这点我就放上OSI七层模型图来讲吧。

OSI七层模型图

我们都知道,Kubernetes中最经典的负载均衡方案便是其核心组件之一——KubeProxy。KubeProxy在K8s中通过IPVS或iptables,转发来自外部或内部的流量请求和数据包。它事实上并没有解析HTTP或者其它七层协议的数据,只是简单地转发三层的TCP/UDP数据。这个时候我们说这个负载均衡是在三层(转发TCP/UDP数据包)实现的,因为HTTP报文(七层协议报文)的内容到HTTP服务器时仍然与原始报文一致。

此外,对于传统的HTTP业务,还有说法是通过Nginx等HTTP服务器进行反向代理。对于Nginx来讲,它不仅会解析三层TCP协议,还会解析七层HTTP协议的数据包,再发送给上游服务器。显然,这个时候的负载均衡是在七层完成的。因为Nginx事实上解析并读取了到达的请求流量,并且对请求进行了再封包才发送至上游服务。

HTTP服务如何实现负载均衡?

在过去,针对HTTP协议的负载均衡方案,就如上文所述。HTTP/1在协议上不存在多路复用等特性,无论负载均衡在三层还是在七层实现,实际的效果都不会差的太大。所以我们在Kubernetes中常用KubeProxy的TCP负载均衡(Service IP)模式来暴露HTTP服务。此外,常见方案有Nginx(L7)、HAProxy(L3/L7)、Apache2(L7)、Traefik(L3/L7)等。

传统负载均衡场景预想图

笔者注: 我这里讨论的HTTP协议是HTTP/1(含1.x),HTTP/2、gRPC协议的方案需要纯L7层代理,在下一段讨论。

gRPC服务为何需要特殊方案实现负载均衡?

首先来聊聊gRPC协议,gRPC协议事实上是基于HTTP/2调整得来的,本身兼具HTTP/2的诸多特性。HTTP/2相较HTTP/1做出了许多改进,这些改进也同时导致了传统L3负载均衡方案(TCP连接级负载均衡)在HTTP/2上并不起作用。

为什么L3负载均衡方案对HTTP/2(gRPC)无效?

HTTP/2在数据传输效率上有了质的提升,其主要原因就是它的多路复用特性上。多路复用,即同一客户端对同一服务端的多次请求,可以复用同一个TCP连接完成。换句话来讲,HTTP/2在设计上就倾向在一条TCP连接上把所有事情做完。

我们目前的业务场景,其实可以简单用这样一张图表示:

星河外部请求处理流程图.png

如图,gRPC在星河内部主要应用于对性能要求高的业务层和计算层服务的连接中,这个连接在业务层服务启动时就会建立。这也意味着,基于TCP连接进行的L3负载均衡(连接级负载均衡),对HTTP/2并不起作用——因为在KubeProxy、HAProxy(L3)、Traefik(L3)等负载均衡器眼里,不考虑服务重启、更新的情况下,无论L7的HTTP/2协议进行了多少次请求,在L3层都仅仅是一条TCP连接。对计算层服务来讲,这种粒度的负载均衡会直接导致单个服务副本负载过高,在高峰时期出现“一核有难,多核围观”的情况。当然这里提到的问题跟CPU核心调度没有关系,只是拿来做一个类比。这种模式下,L3负载均衡完全无法满足业务场景的需要。

gRPC负载均衡的方案

目前主流的对gRPC服务实现负载均衡的方案,主要分客户端负载均衡和服务端负载均衡。当然这里的客户端意思不是用户的客户端,而是业务层服务和计算层服务之间的关系——业务层主动访问计算层服务的gRPC Endpoints,此时称业务层服务为客户端,计算层服务为服务端。

gRPC负载均衡模式.png

服务端负载均衡的方案较多,在K8s环境下的实现主要是Envoy、Istio等服务网格(Service Mesh)方案的边车(Sidecar)服务。关于这方面的介绍应该是后面的文章的重头戏,本篇文章主要介绍客户端负载均衡的方案,所以此处不过多赘述。

关于客户端负载均衡,目前我们采用的方案是,通过Headless Service + Cluster DNS进行服务发现,客户端同时维系多个与gRPC服务的连接,并定期查询和刷新连接池。这块go-grpc的开发者写的很好,通过简单的代码就可以实现这些逻辑:

// Connect 初始化客户端连接
// useTLS: 本次连接是否使用TLS加密
// endpoints: 连接使用的端点,格式应为 passthrough:///<addr>:<port>,
// 如果要结合K8s使用Headless Service,格式应为dns:///<service-domain>:<port>
func Connect(endpoints string, useTLS bool) (*grpc.ClientConn, error) {
    options := []grpc.DialOption{}

    if !useTLS {
        options = append(options, grpc.WithTransportCredentials(insecure.NewCredentials()))
    }

    options = append(options, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(512*1024*1024)))
    options = append(options, grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(512*1024*1024)))
    options = append(options, grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`))
    options = append(options, grpc.WithTimeout(5*time.Minute))

    channel, err := grpc.Dial(endpoints, options...)
    if err != nil {
        return err
    }

    return channel, nil
}

客户端负载均衡方案的缺点

那么古尔丹,代价是什么呢?

go-grpc实现的的dns-resolver每次进行刷新,都会断开所有gRPC连接并重新建立。在高并发高流量场景下,就算是很短很短的闪断也会造成部分请求异常。并且,通过DNS来做服务发现也存在诸多问题。例如业务层服务端无法主动获悉计算层服务端的端点变动,只能每隔几分钟被动地查询刷新一次,这确实给服务热备、动态扩容更新等操作带来了困难。

客户端负载均衡并不是完美的的方案,针对这些问题,目前没有较好的解决办法。这也许也是服务端负载均衡存在的意义,这个就留到后面再介绍和研究了。

后记

以上就是本文的正文部分了,感谢你耐心读到这里。这篇文章被设计为后续service-mesh方面的开胃菜,所以也请关注我后续的文章,还会分享一些有意思的东西。

Q.E.D.
0xC4A1
2024-02-07 21:25 提笔
2024-02-08 13:00 完稿

希望本篇文章的观点能获得您的认同。本文编写时间较短,某些内容缺乏仔细考证和审稿,如您有别的看法,欢迎在下方评论区讨论,或通过“关于”页面联系我。

祝各位新年快乐,我们来年(?)再见!