Envoy流量代理&K8s grpc负载均衡解决方案

背景

目前项目的gRPC调用采用的是k8s短域名调用,由于gRPC是基于HTTP2协议,多个请求在一个TCP连接上多路复用,一旦ClusterIP和某个pod建立了gRPC连接后,因为多路复用的缘故,后续其它请求也都会被转发给此pod,这样会导致请求负载失衡。在k8s中,svcIP的实现机制(不管iptables还是ipvs)是只会为三次握手中第一次握手的SYN 包挑选新的upstream,后续相同来源(就是svcIP+svcPort+Protocol一致)的所有包都会转 发给转发SYN包时选中的upstream,直至收到四次甩手的FIN包,这就是所谓的“连接追 踪”。 这种机制在http短连接的场景中一般不会有什么问题,因为每次发起HTTP连接都会重新进行 三次握手,然后挑选新的upstream,请求就有机会转到svcIP对应的每一个pod中;但grpc 使用http2协议,四层使用TCP长连接,在整个过程中,三次握手只发生一次,就是说对于 k8s svcIP的转发机构来说,所有的针对某个svcIP的长连接请求其实都只会转给了一个pod。

目的

解决服务间东西南北全流量代理,以及k8sgRPC负载失衡的问题,并通过云原生网关进行流量治理

 

方案一:Envoy代理南北流量 + ETCD东西流量服务发现

Envoy取代urlrouter中Nginx的功能,各业务服务的请求会先路由到Envoy中,当私网部分服务upstream启动异常时不会影响网关自身的可用性,实现南北流量的代理,后续可继续在基于Envoy的网关上进行扩展,如:统一限流、鉴权、甚至服务级别的降级、接入服务网格Istio等;通过使用ETCD对内部的gRPC服务注册发现,实现其 naming 和 resolver,解决k8s下gRPC负载均衡的问题,实现东西流量的代理。

 

ETCD gRPC服务发现负载均衡

使用etcd作为gRPC的服务发现和负载均衡,解决k8s下gRPC请求负载均衡的问题

 

实现gRPC的naming和resolver

naming

复制代码
import (
    "context"
    "go.etcd.io/etcd/clientv3"
    "log"
    "strings"
    "time"
)

// Register 注册地址到ETCD组件中 使用 , 分割
func Register(etcdAddr, name, addr, schema string, ttl int64) error {
    var err error
    if cli == nil {
        cli, err = clientv3.New(clientv3.Config{
            Endpoints:   strings.Split(etcdAddr, ";"),
            DialTimeout: 15 * time.Second,
        })
        if err != nil {
            log.Printf("connect to etcd err:%s", err)
            return err
        }
    }

    ticker := time.NewTicker(time.Second * time.Duration(ttl))

    go func() {
        for {
            getResp, err := cli.Get(context.Background(), "/"+schema+"/"+name+"/"+addr)
            if err != nil {
                log.Printf("getResp:%+v\n", getResp)
                log.Printf("Register:%s", err)
            } else if getResp.Count == 0 {
                err = withAlive(name, addr, schema, ttl)
                if err != nil {
                    log.Printf("keep alive:%s", err)
                }
            }
            <-ticker.C
        }
    }()
    return nil
}

// withAlive 创建租约
func withAlive(name, addr, schema string, ttl int64) error {
    leaseResp, err := cli.Grant(context.Background(), ttl)
    if err != nil {
        return err
    }

    log.Printf("key:%v\n", "/"+schema+"/"+name+"/"+addr)
    _, err = cli.Put(context.Background(), "/"+schema+"/"+name+"/"+addr, addr, clientv3.WithLease(leaseResp.ID))
    if err != nil {
        log.Printf("put etcd error:%s", err)
        return err
    }

    ch, err := cli.KeepAlive(context.Background(), leaseResp.ID)
    if err != nil {
        log.Printf("keep alive error:%s", err)
        return err
    }

    // 清空 keep alive 返回的channel
    go func() {
        for {
            <-ch
        }
    }()

    return nil
}

// UnRegister remove service from etcd
func UnRegister(name, addr, schema string) {
    if cli != nil {
        cli.Delete(context.Background(), "/"+schema+"/"+name+"/"+addr)
    }
}
复制代码

 



resolver

复制代码
import (
    "context"
    "log"
    "strings"
    "time"

    "go.etcd.io/etcd/clientv3"

    "github.com/coreos/etcd/mvcc/mvccpb"
    "google.golang.org/grpc/resolver"
)

var cli *clientv3.Client

// etcdResolver 解析struct
type etcdResolver struct {
    rawAddr string
    schema  string
    cc      resolver.ClientConn
}

// NewResolver initialize an etcd client
func NewResolver(etcdAddr, Schema string) resolver.Builder {
    return &etcdResolver{rawAddr: etcdAddr, schema: Schema}
}

// Build 构建etcd client
func (r *etcdResolver) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    var err error
    if cli == nil {
        cli, err = clientv3.New(clientv3.Config{
            Endpoints:   strings.Split(r.rawAddr, ";"),
            DialTimeout: 15 * time.Second,
        })
        if err != nil {
            return nil, err
        }
    }

    r.cc = cc

    go r.watch("/" + target.Scheme + "/" + target.Endpoint + "/")

    return r, nil
}

// Scheme etcd resolve scheme
func (r etcdResolver) Scheme() string {
    return r.schema
}

// ResolveNow
func (r etcdResolver) ResolveNow(rn resolver.ResolveNowOptions) {
    log.Println("ResolveNow")
}

// Close closes the resolver
func (r etcdResolver) Close() {
    log.Println("Close")
}

// watch 监听resolve列表变化
func (r *etcdResolver) watch(keyPrefix string) {
    var addrList []resolver.Address

    getResp, err := cli.Get(context.Background(), keyPrefix, clientv3.WithPrefix())
    if err != nil {
        log.Println(err)
    } else {
        for i := range getResp.Kvs {
            addrList = append(addrList, resolver.Address{Addr: strings.TrimPrefix(string(getResp.Kvs[i].Key), keyPrefix)})
        }
    }

    // 新版本etcd去除了NewAddress方法 以UpdateState代替
    r.cc.UpdateState(resolver.State{Addresses: addrList})

    rch := cli.Watch(context.Background(), keyPrefix, clientv3.WithPrefix())
    for n := range rch {
        for _, ev := range n.Events {
            addr := strings.TrimPrefix(string(ev.Kv.Key), keyPrefix)
            switch ev.Type {
            case mvccpb.PUT:
                if !exist(addrList, addr) {
                    addrList = append(addrList, resolver.Address{Addr: addr})
                    r.cc.UpdateState(resolver.State{Addresses: addrList})
                }
            case mvccpb.DELETE:
                if s, ok := remove(addrList, addr); ok {
                    addrList = s
                    r.cc.UpdateState(resolver.State{Addresses: addrList})
                }
            }
            log.Printf("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
        }
    }
}

// exist 判断resolve address是否存在
func exist(l []resolver.Address, addr string) bool {
    for i := range l {
        if l[i].Addr == addr {
            return true
        }
    }
    return false
}

// remove 从resolver列表移除
func remove(s []resolver.Address, addr string) ([]resolver.Address, bool) {
    for i := range s {
        if s[i].Addr == addr {
            s[i] = s[len(s)-1]
            return s[:len(s)-1], true
        }
    }
    return nil, false
}
server

// 将server结构体注册到grpc服务中
pb.RegisterHelloServerServer(srv, &EtcdGRPCServer{})

// etcd服务注册
go etcdservice.Register(config.Conf.EtcdAddr, config.Conf.ServiceName, addr, config.Conf.Schema, config.Conf.TTL)
client

// 解析etcd服务地址
r := etcdservice.NewResolver(config.Conf.EtcdAddr, config.Conf.Schema)
resolver.Register(r)
复制代码

 

 

方案优缺点

优点:

实现比较简单,Envoy替代nginx代理http请求,ETCD实现gRPC naming和resolver服务注册发现。

缺点:

私网环境可能会存在诸如:磁盘性能不够、网络原因、ETCD集群不稳定等情况,此情况会不断触发ETCD的选举,造成ETCD的不可用。

 

方案二:Envoy东西南北全量流量代理

通过Envoy+Ingress Controlle的集中式Proxy,或者基于Envoy的xds实现服务发现,可做到东西南北的全流量代理,保证gRPC在k8s下的负载均衡。

Envoy + Ingress Controller集中式Proxy

Envoy作为七层负载均衡LB代理http请求,并且与支持gRPC的Ingress Controller通信,通过集中式的代理来解决gRPC 负载均衡,通过Ingress Controller来终止 SSL/TLS 连接并将 gRPC 流量路由到适当的 Kubernetes 服务。

 

实现XDS协议负载均衡

需要实现xds服务器,负责发现gRPC服务器的端点并将其传达给client。gRPC client连接到xds服务器,并且在创建gRPC通道的目标URI中使用xds解析器,通过Envoy与xds server通信,动态更新node、endpoint信息。对于xds server的实现,可以使用Envoy的go-control-plane库:https://github.com/envoyproxy/go-control-plane

 

方案优缺点

优点:

非侵入,应用无感知,更容易与云原生相结合,易于扩展服务的全局限流、熔断、灰度发布等。

缺点:

集中式Proxy对性能会有所损耗,并且需引入支持gRPC的Ingress Controller如:Contour,Ambassador等,部署运维的复杂度会有所增加;实现xds协议服务负载均衡虽然是无Proxy的,对性能没有过多损耗,但是编码实现的复杂度会增加,同样会增加部署运维成本。

 

方案三:Envoy + Headless Service负载均衡

使用k8s的Headless Service来实现客户端的负载均衡,可使用Envoy代理南北流量、gRPC client解析dns客户端lb实现东西流量负载均衡;亦可直接使用Envoy的STRICT_DNS服务发现,配合k8s headless服务实现客户端gRPC负载均衡。通过设置 server 端 MaxConnectionAge 来定时踢掉client连接,做到即使 kill 某个 pod 触发重启、ip 发生变化也不会影响负载均衡。

Envoy代理南北流量 + Client LB + Headless Service

Envoy代理南北流量(仅替代nginx的功能),配合k8s headless服务实现客户端gRPC负载均衡。创建 Headless Service 后,k8s 会生成 DNS 记录,访问 Service 会返回后端多个 pod IP 的 A 记录,这样应用就可以基于 DNS 自定义负载均衡。在 grpc-client 指定 headless service 地址为 dns:/// 协议,DNS resolver 会通过 DNS 查询后端多个 pod IP,然后通过 client LB 算法来实现负载均衡。

gRPC client解析dns Headless 负载均衡:

  conn, err := grpc.DialContext(ctx, "dns:///xxx-announce:3136",
        grpc.WithInsecure(),
        grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"LoadBalancingPolicy": "%s"}`, roundrobin.Name)),
        grpc.WithBlock(),
    )

 

k8s创建headless服务,将.spec.clusterIP字段设置为"None"来创建headless服务,并给pod打上标签:

kind: Deployment
name: xxx-announce
spec:
  clusterIP: None
  selector:
    app: xxx-announce

 

通过设置 server 端 MaxConnectionAge 来定时踢掉client 连接:

container:
    command:
        - server
        - -maxConnectionAge

 

Envoy中STRICT_DNS服务发现 + Headless Service

通过Envoy的STRICT_DNS与k8s的Headless Service通信,负载均衡来获取gRPC server的pod ip。Envoy维护DNS返回的所有A记录的IP地址,并且每隔几秒钟刷新一次IP组,STRICT_DNS的服务发现,基于查询DNS记录和上游群集每个节点的IP地址A记录,并通过在Envoy中配置lb_policy,这样做到代理gRPC client到对应的server pod ip。

Envoy STRICT_DNS配置:

clusters:
  - name: service_announce
    connect_timeout: 0.25s
    type: STRICT_DNS
    dns_lookup_family: V4_ONLY
    lb_policy: LEAST_REQUEST
    hosts: [{ socket_address: { address: xxx-announce, port_value: 3136 }}]


将type设置为STRICT_DNS便于与k8s的Headless Service通信;lb_policy为负载均衡策略;address指定Envoy获取要发送到A记录的域名。

 

方案优缺点

优点:

无论是client dns resolver + lb + Headless 还是 Envoy STRICT_DNS + Headless,实现都比较简单、改造成本小,没有额外引入其它组件,相比Proxy性能更高;

 

缺点:

需支持k8s Headless;client lb的方式具有侵入式,grpc client需添加负载均衡策略,需要权衡 maxConnectionAge 参数;Envoy STRICT_DNS + Headless将所有请求流量集中到Envoy处理,可能存在单点压力的情况。

 

 

方案四:Envoy + 自实现k8s resolver

k8sresolver是一个grpcClient的NamingResolver,它根据k8s服务名去k8s apiserver中查 询服务对应的podIP,从而绕过k8s的服务负载均衡机制,从而避免上述内容中提到的问题; 同时它还会监听服务关联的pod的变化,把最新可用的podip随时同步给grpc client

通过k8s的服务名,获取pod ip,实现上类似于方案一ETCD的实现grpc的naming和resolving,但无需依赖ETCD,也可作为一种可选方案考虑。

在 k8s 中使用 gRPC Go 服务发现 :: Cong

 client

复制代码
func NewClient(ctx context.Context) (*grpc.ClientConn, error) {
 var opts []grpc.DialOption
 opts = append(opts, grpc.WithInsecure())
 opts = append(opts, grpc.WithResolvers(k8sresolver.NewBuilder("k8s")))
 opts = append(opts, grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{ "r
ound_robin":{}}]}`))
 return grpc.DialContext(ctx, "k8s:///your-service-name.your-service-ns-name.sv
c.cluster.local:80", opts...)
}
复制代码

 

resolver

复制代码
type K8sResolverBuilder struct {
   lock sync.Mutex
}

type K8sServiceResolver struct {
   target resolver.Target
   cc     resolver.ClientConn
}

func (k K8sServiceResolver) start() {
   serviceName := k.target.Endpoint
   log.Debug("k8s resolver,service name:%s", serviceName)
   k8sServiceWatcher.AddWatch(serviceName, func(state resolver.State) {
      log.Warn("k8s resolver update grpc client connection state")
      k.cc.UpdateState(state)
   })
}

var k8sServiceWatcher *K8sServiceWatcher

func (k K8sResolverBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
   k.lock.Lock()
   defer k.lock.Unlock()
   if k8sServiceWatcher == nil {
      k8sServiceWatcher = NewWatcher(context.Background())
      if err := k8sServiceWatcher.Start(); err != nil {
         return nil, err
      } else {
         go k8sServiceWatcher.Run()
      }
   }
   r := K8sServiceResolver{
      target: target,
      cc:     cc,
   }
   r.start()
   return r, nil
}
复制代码

 

service watch

复制代码
type K8sServiceWatcher struct {
    ctx              context.Context
    masterUrl        string
    kubeConfigPath   string
    defaultNamespace string
    endpointCache    map[string]*corev1.Endpoints
    svcCache         map[string]*corev1.Service
    endpointQueue    workqueue.DelayingInterface
    eventList        map[string]UpdateStateFuncList
    isRunning        bool
}

func (k *K8sServiceWatcher) Start() error {
    cfg, err := clientcmd.BuildConfigFromFlags(k.masterUrl, k.kubeConfigPath)
    if err != nil {
        return fmt.Errorf("error building kubernetes config:%s", err.Error())
    }
    cfg.QPS = 50
    cfg.Burst = 50
    cfg.ContentConfig.ContentType = runtime.ContentTypeProtobuf
    kubeClient, err := kubernetes.NewForConfig(cfg)
    if err != nil {
        return fmt.Errorf("error building kubernetes client:%s", err.Error())
    }
    factory := informers.NewSharedInformerFactory(kubeClient, 0)
    epInformer := factory.Core().V1().Endpoints()
    svcInformer := factory.Core().V1().Services()

    epInformer.Informer().AddEventHandler(
        cache.ResourceEventHandlerFuncs{
            AddFunc: func(cur interface{}) {
                endpoint, ok := cur.(*corev1.Endpoints)
                if ok {
                    k.endpointCache[getEndpointKey(endpoint)] = endpoint
                }
            },
            UpdateFunc: func(originalEndpoint, newEndpoint interface{}) {
                ep1, ok1 := originalEndpoint.(*corev1.Endpoints)
                ep2, ok2 := newEndpoint.(*corev1.Endpoints)
                if ok1 && ok2 && !reflect.DeepEqual(ep1.Subsets, ep2.Subsets) {
                    k.endpointCache[getEndpointKey(ep2)] = ep2
                    k.endpointQueue.Add(ep2)
                }
            },
            DeleteFunc: func(cur interface{}) {
                // do nothing
            },
        })

    svcInformer.Informer().AddEventHandler(
        cache.ResourceEventHandlerFuncs{
            AddFunc: func(cur interface{}) {
                svc, ok := cur.(*corev1.Service)
                if ok {
                    k.svcCache[getSvcKey(svc)] = svc
                }
            },
            UpdateFunc: func(originalSvc, newSvc interface{}) {
                svc1, ok1 := originalSvc.(*corev1.Service)
                svc2, ok2 := newSvc.(*corev1.Service)
                if ok1 && ok2 {
                    k.svcCache[getSvcKey(svc2)] = svc2
                    if !reflect.DeepEqual(svc1.Spec.Ports, svc2.Spec.Ports) {
                        if ep, ok := k.endpointCache[getSvcKey(svc2)]; ok {
                            k.endpointQueue.Add(ep)
                        }
                    }
                }
            },
            DeleteFunc: func(cur interface{}) {
                // do nothing
            },
        })

    factory.Start(k.ctx.Done())
    if !cache.WaitForCacheSync(k.ctx.Done(), svcInformer.Informer().HasSynced, epInformer.Informer().HasSynced) {
        return fmt.Errorf("waiting for cached sync timeout")
    }
    log.Info("k8s service watcher is ready")
    return nil
}

func (k *K8sServiceWatcher) parseService(serviceName string) (error, string, string, string, int) {
    var svc, ns, port string
    var connSize int
    var err error
    tmpl := strings.Split(serviceName, "/")
    if len(tmpl) == 1 {
        connSize = 1
    } else {
        connSize, err = strconv.Atoi(tmpl[1])
        if err != nil {
            return fmt.Errorf("invalid client connection size:%s", tmpl[1]), "", "", "", 0
        }
    }
    tmpl = strings.Split(tmpl[0], ":")
    if len(tmpl) == 1 {
        port = "80"
    } else {
        port = tmpl[1]
    }

    tmpl = strings.Split(tmpl[0], ".")

    if len(tmpl) >= 2 {
        ns = tmpl[1]
    } else {
        ns = k.defaultNamespace
    }
    svc = tmpl[0]
    return nil, svc, ns, port, connSize
}

func (k *K8sServiceWatcher) AddWatch(serviceName string, f func(state resolver.State)) {
    if len(serviceName) == 0 {
        return
    }
    err, svc, ns, port, connSize := k.parseService(serviceName)
    if err != nil {
        log.Error(err.Error())
        return
    }
    log.Debug("k8s resolver,svc:%s,ns:%s,port:%s,conn size:%d", svc, ns, port, connSize)
    ep, ok := k.endpointCache[getKey(ns, svc)]
    if !ok {
        log.Fatal("endpoint not found")
        return
    }
    updateState := UpdateStateFunc{}
    updateState.svc = ServiceName{Namespace: ns, Name: svc, Port: port, ConnSize: connSize}
    updateState.f = f
    f(k.endpointToResolverState(ep, updateState.svc))

    if e, ok := k.eventList[getKey(ns, svc)]; ok {
        log.Debug("append event for svc:%s", getKey(ns, svc))
        e = append(e, updateState)
        k.eventList[getKey(ns, svc)] = e
    } else {
        log.Debug("add event for svc:%s", getKey(ns, svc))
        k.eventList[getKey(ns, svc)] = UpdateStateFuncList{updateState}
    }
}
复制代码

 

方案优缺点

优点:

无需k8s Headless Service

 

缺点:

需自实现和维护k8s resolver组件,私网的支持度?需要配置 ServiceAccount 权限, 仅能在 k8s 内部使用?

 

四、影响面及总结

结合私网可维护性的问题,个人认为方案三:Envoy + Headless Service负载均衡,比较符合团队现状的改造、维护成本,改造升级涉及的影响面如下

Envoy代理南北流量 + Client LB + Headless Service 方案涉及的影响面:

1. urlrouter原nginx改造为Envoy,涉及各应用的路由适配等;

2. 公私网k8s服务配置修改为Headless Service模式;

3. 各应用gRPC client 需以"dns:///headless service name"连接,并配置lb policy;

 

Envoy中STRICT_DNS服务发现 + Headless Service 方案涉及的影响面:

1. urlrouter原nginx改造为Envoy,涉及各应用的路由适配等,并需要配置Envoy STRICT_DNS、lb_policy、address等;

2. 公私网k8s服务配置修改为Headless Service模式;

posted @   HarvardFly  阅读(815)  评论(0编辑  收藏  举报
(评论功能已被禁用)
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 记一次.NET内存居高不下排查解决与启示
点击右上角即可分享
微信分享提示