关于fasthttp + K8S service负载均衡的一些心得

最近在做一个项目,项目中用golang 写了一个网关gateway,gateway接受来自外部的请求,并转发到后端的容器中。gateway和应用的容器都部署在同一个K8S集群当中。流程如下图

gateway到pod的请求,是通过K8S的dns机制来访问service,使用的是service的endpoint的负载均衡机制。当gateway得到一个请求之后,通过解析对应的参数,然后可以判断需要转发到哪个host,例如:请求转发到service.namespace.svc.cluster.local:8080,然后DNS解析会解析出对应service的clusterIp,通过service转发请求到后端的pod上(具体转发原理可以了解一下kube-proxy的原理),gateway到service的请求通过golang的 fasthttp实现,并且为了提高效率,采用的是长连接的形式。

我们现在为了实现自动化扩缩容,引入了HPA扩缩容机制,也就是说service对应的pod会根据访问量和CPU的变化进行自动的扩缩容。现在的问题是,这种方案能否在扩容之后实现负载均衡吗?答案是不能,或者说负载均衡的效果并不好(如果采用RoundRobin的负载均衡策略,多个pod并不能均匀的接受到请求),下面说一下我的分析:

我们知道,使用fasthttp作为客户端并采用长连接的时候,TPC的连接存在一个连接池,而这个连接池是如何管理的至关重要。看代码: client.go

func (c *Client) Do(req *Request, resp *Response) error {
    uri := req.URI()
    host := uri.Host()

    isTLS := false
    scheme := uri.Scheme()
    if bytes.Equal(scheme, strHTTPS) {
        isTLS = true
    } else if !bytes.Equal(scheme, strHTTP) {
        return fmt.Errorf("unsupported protocol %q. http and https are supported", scheme)
    }

    startCleaner := false

    c.mLock.Lock()
    m := c.m
    if isTLS {
        m = c.ms
    }
    if m == nil {
        m = make(map[string]*HostClient)
        if isTLS {
            c.ms = m
        } else {
            c.m = m
        }
    }
    hc := m[string(host)]
    if hc == nil {
        hc = &HostClient{
            Addr:                          addMissingPort(string(host), isTLS),
            Name:                          c.Name,
            NoDefaultUserAgentHeader:      c.NoDefaultUserAgentHeader,
            Dial:                          c.Dial,
            DialDualStack:                 c.DialDualStack,
            IsTLS:                         isTLS,
            TLSConfig:                     c.TLSConfig,
            MaxConns:                      c.MaxConnsPerHost,
            MaxIdleConnDuration:           c.MaxIdleConnDuration,
            MaxIdemponentCallAttempts:     c.MaxIdemponentCallAttempts,
            ReadBufferSize:                c.ReadBufferSize,
            WriteBufferSize:               c.WriteBufferSize,
            ReadTimeout:                   c.ReadTimeout,
            WriteTimeout:                  c.WriteTimeout,
            MaxResponseBodySize:           c.MaxResponseBodySize,
            DisableHeaderNamesNormalizing: c.DisableHeaderNamesNormalizing,
        }
        m[string(host)] = hc
        if len(m) == 1 {
            startCleaner = true
        }
    }
    c.mLock.Unlock()

    if startCleaner {
        go c.mCleaner(m)
    }

    return hc.Do(req, resp)
}

其中

hc := m[string(host)]

这一行代码就是关键。大概解释一下,httpclient当中维护了一个  map[string]*HostClient ,其中key即为host,value为hostClient对象。那这个host,即为我们请求的host。在本例中就是service.namespace.svc.cluster.local:8080,而每一个hostClient,又维护了一个TCP的连接池,这个连接池中,真正维护着TCP连接。每次进行http请求时,先通过请求的host找到对应的hostClient,再从hostClient的连接池中取一个连接来发送http请求。问题的关键就在于,map中的key,用的是域名+端口还是ip+端口的形式。如果是域名+端口,那么对应的hostClient中的连接,就会可能包含到该域名对应的各个ip的连接,而这些连接的数量无法保证均匀。但如果key是ip+端口,那么对应hostClient中的连接池只有到该ip+端口的连接。如下图:

图中每一个方框代表一个hostclient的连接池,框1指的就是本例中的情况,而框2和框3指的是通过ip+端口建立连接的情况。在K8S中,service的负载均衡指的是建立连接时,会均衡的和pod建立连接,但是,由于我们pod的创建顺序有先后区别(初始的时候只有一个pod,后面通过hpa扩容起来),导致框1中的连接肯定无法做到均匀分配,因此扩容起来之后的pod,无法做到真正意义的严格的负载均衡。

那么有什么办法改进呢:

1.gateway到后端的请求是通过host(K8S的域名)通过service进行请求的,如果改成直接通过podIP进行访问,那么就可以自己实现负载均衡方案,但是这样的复杂度在于必须要自己做服务发现机制,即不能依赖K8S的service服务发现。

2.采用短连接,短连接显然没有任何问题,完全取决于service的负载均衡。但是短连接必然会影响转发效率,所以,可以采用一种长短连接结合的方式,即每个连接设置最大的请求次数或连接持续时间。这样能在一定程度上解决负载分配不均匀的问题。

以上是个人的一些理解和看法,因笔者水平有限,难免有理解错误或不足的地方,欢迎大家指出,也欢迎大家留言讨论。

————————————————————————————2019.11.11更新——————————————————————————————

以前的faasthttp的client里,没有 MaxConnDuration 字段,我也在github上提出了一个issue,希望作者能加上该字段,很高兴,作者已经更新,加上了该字段。参考 https://github.com/valyala/fasthttp/issues/692

这个字段的意思是连接持续最大多长时间后就会关闭,用该参数,实现了长短连接结合的方式,兼顾了效率和负载均衡的问题。