再见 Kube-Proxy,是时候拥抱下一代 K8s 负载均衡 KPNG 了

Laf 公众号已接入了 GPT4,完全免费!欢迎前来调戏

再见 Kube-Proxy,是时候拥抱下一代 K8s 负载均衡 KPNG 了_第1张图片

<<< 左右滑动见更多 >>>

原文链接:https://maao.cloud/2022/12/14/KPNG%EF%BC%9A%E4%B8%8B%E4%B8%80%E4%BB%A3Kube-Proxy

kpng 是社区新设计开发的下一代 Kube-Proxy,目前仍然在积极开发中。

  • 官方 repo:https://github.com/kubernetes-sigs/kpng

  • kep:https://github.com/kubernetes/enhancements/pull/2094

简介

kpng 主要目的是:

  • 解耦 Kubernetes 的业务逻辑(比如 serivce 的 externalTrafficPolicy、internalTrafficPolicy 之类逻辑)与后端 proxy 的实现,以实现一套通用的框架。对于当前的 Kube-Proxy,如果要自己开发其他类型的 proxy,需要实现自己的 proxier 来实现 Provider[1] 接口,而 Provider 接口包含了对 Service、Endpoint、Node 资源的处理逻辑,这部分对于所有 proxier 来说都是重复的代码。同时,不同 proxier 内部也存在某些子系统的重复逻辑,比如 conntrack 模块。

  • 提高可扩展性,支撑更大规模的集群。目前 Kube-Proxy 的扩展需要增加对 Kube-APIServer 的 watch 请求,不利于规模的扩大。

kpng 的主要思路如下:

[k8s API] ----> [local model] ----> [plugin] ----> [aggregator] ----> [subsystem]

1)根据 k8s API 的资源,构建一个本地期望状态 ENLS(expected node-local state),只有当 ENLS 发生变化的时候,后端 plugin 才会触发操作。相比 Kube-Proxy 直接监听 Kube-APIServer 的 event,这种方式能减少无效触发(不改变 ENLS 的 API event)。引用社区在 1K service、1.5K pod 下的测试。其中 rev 是 ENLS 发送变化的 event 数量,events 是 API  events。

stats: time events rev usr cpu sys cpu tot cpu mem revs/events
stats: ms count count ms ms % MiB %
stats: 0 0 0 0 0 +Inf 1.22 NaN
stats: 1000 2134 1064 3 308 31.280 3.93 49.859
stats: 2000 2137 1064 0 1 0.131 3.94 49.789
stats: 3000 2138 1064 0 0 0.050 3.94 49.766
[...]
stats: 298000 21785 1067 0 0 0.050 4.16 4.898
stats: 299000 21787 1067 0 0 0.089 4.16 4.897
stats: 300000 21788 1067 0 0 0.061 4.17 4.897
stats: 301000 23925 1068 4 64 6.882 3.35 4.464 # GC after 2k+ events (without a proxy-related change)
stats: 302000 23926 1068 0 0 0.052 3.35 4.464
[...]
stats: 1798000 130941 1218 0 0 0.090 4.68 0.930
stats: 1799000 130942 1218 0 0 0.055 4.69 0.930
outside_default.png

2)aggregate 聚合多个组件的对子系统的操作,最终进行统一的提交。比如 calico、Kube-Proxy、hostport cni 等对 iptables 的操作,统一由 aggregate 进行操作的合并,避免相互之间的锁竞争。

KPNG 的设计

由于 KPNG 目前仍在开发中,最终的技术方案可能会有变动,暂时使用最新版本[2] 的实现来介绍。KPNG 架构图如下。

outside_default.png img

首先是上半 Server 部分,核心是存储了 Services、Endpoints、Nodes 信息的 proxystore.Store,底层是一个 B 树,proxystore.Store 的输入来源可以是其他 kpng 的 gRPC  API(api2store)、Kube-APIServer(kube2store)以及本地文件(file2store),对外提供访问的方式可以是 gRPC server(store2api)、本地内存(store2localdiff)、本地文件(store2file)。代码实现都在 server/jobs/xxx2store 以及 server/jobs/store2xxx 下。

下半 Client 部分,获取数据并交于 backend 进行处理,backend 可以是简单的日志打印,或是实现 iptables、ipvs、ebpf 规则等。获取数据方式目前有两种,一种是 gRPC,对应上半部分的 store2api;另一种是内存获取,对应 store2localdiff。两者都会调用 localsink.Sinkfunc Send(op *OpItem) error,将相应的变更操作传递给后面的逻辑进行处理。

KPNG 使用

官方博客里的例子[3] 是通过 gRPC 的方式实现资源变更的打印,backend 的实现在代码库的 examples/pipe-exec/cmd/kpng-json/main.go 中。

除此外,这里给出 iptables 模式的 KPNG 部署。首先在代码库的根目录下生成镜像。

$ docker build -t kpng:latest .

创建如下的 daemonset:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: kpng-proxy
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: kpng-proxy
  template:
    metadata:
      labels:
        app: kpng-proxy
    spec:
      # 偷懒,直接使用Kube-Proxy的sa
      serviceAccount: kube-proxy
      hostNetwork: true
      priorityClassName: system-node-critical
      nodeSelector:
        kubernetes.io/os: linux
      containers:
      - name: kpng
        image: kpng:latest
        imagePullPolicy: IfNotPresent
        command:
        - kpng
        - kube
        - --service-proxy-name=kpng-example
        - to-local
        - to-iptables
        securityContext:
          privileged: true

接着创建带有 service.kubernetes.io/service-proxy-name: kpng-example 标签的 service,就能看到在 iptables 里生成对应的规则了。(如果不确定是 Kube-Proxy 生成的 iptables 规则还是 kpng,可以在测试前,先把 Kube-Proxy 切换为 ipvs 模式或删除)。

上面例子中,kube 子命令指定了通过 kube2store 监听 kube-apiserver 来获取 proxystore.Storeto-local 子命令指定了通过 store2localdiffproxystore.Store 变更增量发送到后端,to-iptables 子命令指定使用后端 iptables

KPNG 代码粗读

首先,kpng 代码里实现了很多种名叫 storesinkjob 的类。总的来说:

  • job 实现数据的处理与传递。比如 server/jobs/kube2store/kube2store.go 中的 Job 实现数据从 Kube-APIServer(通过 List-Watch)到 proxystore.Store 的传递;server/jobs/store2diff/store2diff.go 中的 Job 实现数据从 proxystore.Storestore2diff.Sink 的传递。

  • store 用于存储数据,一般底层都是 B 树,主要的包括存储 Services、Endpoints、Nodes 信息的 proxystore.Store,能提供数据变化跟踪的 lightdiffstore.DiffStorediffstore.Store

  • sink 提供一些接口,用于接受 job 数据的传递,一般定义为 interface。比如上面提到的 client/localsink/localsink.go 中的 localsink.Sinksink 的实现也可能会是另一个 job

代码结构

主要的代码包括:

  • api/:grpc api 的定义,Services、Endpoints、Nodes 资源信息的 api 定义。

  • backends/:后端实现,都实现了 client/backendcmd 中的 Cmd interface,使用 client/backendcmd 中的 Register() 注册,然后就能通过子命令调用对应的后端。

    func init() {
     backendcmd.Register("to-iptables", func() backendcmd.Cmd { return &Backend{} })
    }
  • client/:内容比较杂,包含了 client 用到的 storesink 等结构。

  • cmd/kpng/:main 程序代码。

  • from-k8s/:从 kubernetes 代码库中移植过来的代码。

  • server/

    • server/jobs:包含了 server 端 xxx2storestore2xxx 的各种 job。

    • server/proxystore:定义了基本的存储结构 proxystore.Store

接下来分别以 gRPC server + examples/print-state 的用例和 local server + iptables backend 的用例介绍代码流程,两个用例能覆盖大多数核心代码,对 KPNG 有个大体的了解。

gPRC Server

当执行 kpng kube to-api 时,kpng 会监听 Kube-APIServer,并启动 gRPC server。

kube 子命令

kube 子命令的定义:

// cmd/kpng/k2s.go
func kube2storeCmd() *cobra.Command {
 // kube to * command
 k2sCmd := &cobra.Command{
  Use:   "kube",
  Short: "watch Kubernetes API to the globalv1 state",
 }
 ...

 // setupKube2store定义了从kube-apiserver到store的逻辑
 // storecmds.Commands为kube子命令添加store到local\gRPC\file的逻辑
 k2sCmd.AddCommand(storecmds.Commands(setupKube2store)...)

 return k2sCmd
}

func setupKube2store() (ctx context.Context, store *proxystore.Store, err error) {
 ...
 // 创建新的proxystore.store
 store = proxystore.New()

 // kube2stroe job是Kube-APIServer到store主要逻辑的实现
 go kube2store.Job{
  Kube:   kubeClient,
  Store:  store,
  Config: k2sCfg,
 }.Run(ctx)

 return
}

kube2store.Job 的实现在 server/jobs/kube2store 下,server/jobs 下也包含其他与 store 相关的 job,大同小异。

// server/jobs/kube2store/kube2store.go
func (j Job) Run(ctx context.Context) {
 ...
 // service的informer factory,list时只选择label service.kubernetes.io/service-proxy-name的值与kpng ServiceProxyName相同的service
 // 由于informer factory的ListOptions是对所有资源都进行过滤的,因此后面还创建了一个coreFactory
 svcFactory := informers.NewSharedInformerFactoryWithOptions(j.Kube, time.Second*30,
  informers.WithTweakListOptions(func(options *metav1.ListOptions) { options.LabelSelector = labelSelector }))
 ...
 // start watches
 coreFactory := factory.Core().V1()
 
 ...
 //启动service、nodes、endpointSlices的List-watch
 servicesInformer.AddEventHandler(&serviceEventHandler{j.eventHandler(servicesInformer)})
 go servicesInformer.Run(stopCh)

 nodesInformer := coreFactory.Nodes().Informer()
 nodesInformer.AddEventHandler(&nodeEventHandler{j.eventHandler(nodesInformer)})
 go nodesInformer.Run(stopCh)

 slicesInformer := factory.Discovery().V1().EndpointSlices().Informer()
 slicesInformer.AddEventHandler(&sliceEventHandler{j.eventHandler(slicesInformer)})
 go slicesInformer.Run(stopCh)
 ...
}

上面的各个 eventHandler 最终根据 List-watch 的事件,更新 store,以 add service event 为例。

// server/jobs/kube2store/service-event-handler.go
func (h *serviceEventHandler) OnAdd(obj interface{}) {
 h.onChange(obj)
}

func (h *serviceEventHandler) onChange(obj interface{}) {
 ...
 // 把corev1.service转换为kpng api中定义的localv1.Service,用于保存在store中
 service := &localv1.Service{
  Namespace:   svc.Namespace,
  Name:        svc.Name,
  Type:        string(svc.Spec.Type),
  Labels:      globsFilter(svc.Labels, h.config.ServiceLabelGlobs),
  Annotations: globsFilter(svc.Annotations, h.config.ServiceAnnonationGlobs),
  IPs: &localv1.ServiceIPs{
   ClusterIPs:  &localv1.IPSet{},
   ExternalIPs: localv1.NewIPSet(svc.Spec.ExternalIPs...),
  },
  ExternalTrafficToLocal: svc.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal,
  InternalTrafficToLocal: internalTrafficPolicy == v1.ServiceInternalTrafficPolicyLocal,
 }
 ...
 // 存储到store中,proxystore.Tx是在proxystore.Store上封装了一层,前者能把service、nodes、endpoint转成kv,存储到proxystore.Store中
 // Update()传入闭包,操作proxystore.Tx
 h.s.Update(func(tx *proxystore.Tx) {
  ...
  tx.SetService(service)
 })
}

到此,kube 子命令的功能就结束了。

to-api 子命令

上面有提到,为 kube 子命令添加 store 到 local、gRPC、file 的逻辑,都是在 storecmds.Commands 中实现的。

// cmd/kpng/storecmds/storecmds.go
func (c SetupFunc) ToAPICmd() *cobra.Command {
 cmd := &cobra.Command{
  Use: "to-api",
 }
 ...
 cmd.RunE = func(_ *cobra.Command, _ []string) (err error) {
  ...
  // 从xxx2store对应的SetupFunc中获取store
  ctx, store, err := c()
  // store2api.Job实现将proxystore.Store暴露给gRPC server上
  j := &store2api.Job{
   Store:  store,
   Config: cfg,
  }
  return j.Run(ctx)
 }
 ...
}

// server/jobs/store2api/store2api.go
func (j *Job) Run(ctx context.Context) error {
 lis := server.MustListen(j.Config.BindSpec)

 // 创建gRPC服务
 ...
  srv = grpc.NewServer()
  
 // 启动global API与endpoint API的server
 if j.Config.GlobalAPI {
  global.Setup(srv, j.Store)
 }
 if j.Config.LocalAPI {
  endpoints.Setup(srv, j.Store)
 }
 
 return srv.Serve(lis)
}

endpoints.Setup() 为例,注册的 gRPC server 设置如下:

// api/localv1/api_grpc.pb.go

func RegisterSetsServer(s grpc.ServiceRegistrar, srv SetsServer) {
 // 当客户端请求gRPC server时,会使用srv调用_Sets_Watch_Handler()方法
 s.RegisterService(&Sets_ServiceDesc, srv)
}

func _Sets_Watch_Handler(srv interface{}, stream grpc.ServerStream) error {
 // 调用SetsServer的Watch方法,传入setsWatchServer,用于信息的接受与发送
 return srv.(SetsServer).Watch(&setsWatchServer{stream})
}

// setsWatchServer将grpc.ServerStream封装为Send()与Recv()方法
type setsWatchServer struct {
 grpc.ServerStream
}

func (x *setsWatchServer) Send(m *OpItem) error {
 return x.ServerStream.SendMsg(m)
}

func (x *setsWatchServer) Recv() (*WatchReq, error) {
 if err := x.ServerStream.RecvMsg(m); err != nil {
  ...
 }
 ...
}

var Sets_ServiceDesc = grpc.ServiceDesc{
 ...
 Streams: []grpc.StreamDesc{
  {
  ...
   Handler:       _Sets_Watch_Handler,
  },
 },
}

可以看出 gRPC Server 接收的是 WatchReq,而发送的 OpItemOpItem 有点像 List-watch 的 event,但只包括 set、delete、reset、sync 四种,set、delete 用来告诉客户端资源有哪些增量的变化,sync 表示最新的增量已传输完毕,告诉客户端可以进行 backend 规则的同步了,reset 一般用在 gRPC 重连的情况。SetsServer 是个 interface,实现是在 server/pkg/server/endpoints/server.go 里的 Server

func (s *Server) Watch(res localv1.Sets_WatchServer) error {
 ...
 // store2localdiff会根据Store计算资源的变化,通过Sink的Send方法将增量发送
 job := &store2localdiff.Job{
  Store: s.Store,
  // serverSink继承了localv1.Sets_WatchServer的Send方法,也就是上面setsWatchServer的Send方法,因此最终通过gRPC发到Client端
  Sink:  serverSink{res, remote},
 }
 ...
 return job.Run(res.Context())
}

diff

store2localdiff.Job.Run() 内部调用了 store2diff.Job.Run(),这块是实现资源变更的增量发送逻辑,即每次只将资源变更的增量通过 localsink.Sink.Send() 发送,不发送资源的全部信息。

diff 这块代码较多,简言之就是:

  • 资源变更的追踪是依靠 client/lightdiffstore 中的 DiffStore 完成的,其本质是一个 B 树,在 value 中记录了此 value 在上一次 Reset 之后的状态(changed or unchanged)。而 server/pkg/server/watchstate 中的 WatchStateDiffStore 上封装一层,可以将 DiffStore diff 的结果通过 localv1.OpSink.Send() 发送出去。

  • store2diff.Job.Run() 会先创建 WatchState。然后,在每一轮循环中,先通过 store2diff.Sink.Update()proxystore.Store 中更新 WatchState;再通过 store2diff.Sink.SendDiff() 发送 diff 的结果;当 diff 结果发送完成后,在发送 sync 的 OpItem,表示发送结束,可以同步。

    // server/jobs/store2diff/store2diff.go
    func (j *Job) Run(ctx context.Context) (err error) {
     // 创建 watchstate
     w := watchstate.New(j.Sink, j.Sets)
     ...
     for {
      ...
      for !updated {
       // block until the revision has been
       // incremented... then, we update our state from the
       // proxystore
       rev, closed = j.Store.View(rev, func(tx *proxystore.Tx) {
        j.Sink.Update(tx, w)
       })
       ...
       // send the diff
       updated = j.Sink.SendDiff(w)
      }
    
      // signal the change set is fully sent
      w.SendSync()
    
      if w.Err != nil {
       return w.Err
      }
     }
  • store2localdiff.Job 实现了 store2diff.Sink 接口,其中在 SendDiff() 中,发送完 diff 结果后,会调用 WatchStateReset() 进行资源状态的重置。

到此为止,整个 gRPC Server 的流程就结束了。

gRPC Client

在 kpng 中,要实现一个自定义的 gRPC Client 逻辑只要调用 client.Run(func),将实现 func 传入即可。以 examples/print-state 为例,其实现了接收内容的打印。

// examples/print-state/main.go
func main() {
 client.Run(printState)
}

// ServiceEndpoints结构记录了一个Service与它对应的本节点的Endpoint
func printState(items []*client.ServiceEndpoints) {
 fmt.Println("# ------------------------------------------------------------------------")
 fmt.Println("#", time.Now())
 fmt.Println("#")
 for _, item := range items {
  fmt.Fprintln(os.Stdout, item)
 }
}

client.Run 使用了 fullstate.Sinkfullstate.Sink 内部有个 B 树结构,存储 gRPC 客户端接受到的数据,然后每次在收到 Server 端发送的 sync 信号后,将全量的数据发给回调函数,也就是传入 client.Run() 的自定义逻辑。

再见 Kube-Proxy,是时候拥抱下一代 K8s 负载均衡 KPNG 了_第2张图片

到此为止,整个 gRPC Server + gRPC Client 的模式就结束了。

local Server

除了上面的 gRPC 的方式外,还有就是上面 iptable 用例中,kpng kube to-local to-iptables 命令通过内存的方式传递数据。Server 和 Client 会编译到一个二进制文件中。

to-local

to-local 接 kube 子命令,将 proxystore.Store 中的资源通过 store2localdiff.Job 处理后发送给下一个 localsink.Sink 的实现。

// cmd/kpng/storecmds/storecmds.go
func (c SetupFunc) ToLocalCmd() (cmd *cobra.Command) {
 cmd = &cobra.Command{
  Use: "to-local",
 }

 job := &store2localdiff.Job{}
 ...
 cmd.AddCommand(LocalCmds(func(sink localsink.Sink) error {
  job.Sink = sink
  return job.Run(ctx)
 })...)

 return
}

backend

上面有介绍,backend 都是通过 backendcmd.Register 方法注册,然后在 LocalCmds() 创建相应的命令。

// cmd/kpng/storecmds/storecmds.go
func LocalCmds(run func(sink localsink.Sink) error) (cmds []*cobra.Command) {
 // 获取所有注册的backend
 for _, useCmd := range backendcmd.Registered() {
  backend := useCmd.New()

  cmd := &cobra.Command{
   Use: useCmd.Use,
   RunE: func(_ *cobra.Command, _ []string) error {
   // 获取backend的Sink,用于在to-local子命令创建的store2localdiff.Job中获取diff结果
    return run(backend.Sink())
   },
  }
  ...
 }
}

以 iptable 为例,Sink() 方法如下:

// backends/iptables/sink.go
func (s *Backend) Sink() localsink.Sink {
 return filterreset.New(pipe.New(decoder.New(s), decoder.New(conntrack.NewSink())))
}

Server 端发送的 diff 结果,流向如下:

| --> decoder.sink --> iptables
                                    |
diff --> filterreset.sink --> pipe.sink --> decoder.sink --> conntrack

可以看到 kpng 中真的是有各种各样的 Sink:pipe.Sink 将数据发送给多个目的地;decoder.Sink 将 raw 数据转换成 service、endpoint、node API;filterreset.Sink 是相对于上面介绍的 fullstate.Sink 来说的,不会将全量数据往后发送,而是过滤掉之前发送过且无变化的资源,相当于是发送增量数据。

再见 Kube-Proxy,是时候拥抱下一代 K8s 负载均衡 KPNG 了_第3张图片

如上图所示,资源数据最后到达 backend,由 backend 配置数据路径。kpng 中的 iptables、ipvs 等实现主要是搬的 Kube-Proxy 代码,同时还在开发 eBPF 的数据路径。

最后

除了上面的介绍外,kpng 项目中还有很多值得一看的亮点。比如 kpng 在 diffstore.Store 中用到了 go 1.18 新支持的范型;代码库中的 doc/service-proxy.md 是一篇非常值得一读的 Kube-Proxy 设计思想的文档。

引用链接

[1]

Provider: https://github.com/kubernetes/kubernetes/blob/23c65ec590/pkg/proxy/types.go#L30

[2]

最新版本: https://github.com/kubernetes-sigs/kpng/tree/2d38809c64cb70694731b37d150bf6c0268844ca

[3]

例子: https://kubernetes.io/blog/2021/10/18/use-kpng-to-write-specialized-kube-proxiers/

关于 Laf

Laf 是一款为所有开发者打造的集函数、数据库、存储为一体的云开发平台,助你像写博客一样写代码,随时随地发布上线应用!3 分钟上线 ChatGPT 应用!

GitHub:https://github.com/labring/laf

官网(国内):https://laf.run

官网(海外):https://laf.dev

开发者论坛:https://forum.laf.run

关注 Laf 公众号与我们一同成长

再见 Kube-Proxy,是时候拥抱下一代 K8s 负载均衡 KPNG 了_第4张图片

你可能感兴趣的:(kubernetes,负载均衡,java,容器,云原生)