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。
目的
解决服务间东西南北全流量代理,以及k8s下gRPC负载失衡的问题,并通过云原生网关进行流量治理。
方案一: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模式;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 记一次.NET内存居高不下排查解决与启示