LoadBalancer在kubernetes架构下的实践

Backgound

借助于kubernetes优秀的弹性扩缩功能,运行其中的应用程序能够在流量突增的时候坦然应对,在流量低谷的时候无需担心成本。但于此同时,也带来了极大的挑战: 弹性扩缩导致容器IP动态变化,客户端无法直接依赖于容器IP进行访问,我们必须通过某种方式固定流量入口,将流量通过该固定入口均衡地分发到后端,在容器扩缩的过程能够随着容器启停动态更新后端地址。

在这种场景下,我们自然而然地会想到广泛使用的LoadBalancer。kubernetes中service资源其实就是一种LoadBalancer。 service可以会产生一个serviceIP,通过label selecter选定一组pod,流量会通过该serviceIp负载均衡到后端的pod。

service有很多类型: ClusterIP,NodePort,LoadBalancer。在应用于实际复杂的业务场景,以上类型各有利弊:

  • ClusterIP 是通过分配一个虚拟IP给每个service,通过kube-proxy实现转发,这个虚拟的IP在集群外无法被直接访问到,只适合集群内部的互相调用。
  • NodePort 是通过将流量转发到宿主机上,然后通过kube-proxy转发到对应的pod, 每创建一个该类型的service就会占用一个宿主机的端口用做转发。此种类型的service虽然可以实现集群外部访问, 但是无法大规模应用,因为service比较多的时候,端口容易冲突,管理起来比较麻烦。
  • LodaBalacner会创建一个真实的LoadBalancer, 然后将流量转发到NodePort service之上,因为此时NodePort端口对用户透明,由kubernetes自动分配并管理,所以不存在上述提到的端口冲突的问题。 但是缺点就是性能,功能和扩展性
    • 性能不高: 需要经过nodePort的转发,LB首先将流量转发到其中某一台node上面,然后再经过kube-proxy的转发,如果pod没有在这一台机器上面,还需要再转发一次到其他的node上面,如此一来就多了一跳。
    • 扩展性: 同时由于LoadBalancer会直接将所有的node挂载到LB之上,如果集群规模变大,到了几百几千台就会达到LB的限制,无法继续添加机器。社区虽然提供了externalTraficPolicy这种机制,只挂载pod所在的node到LB, 但是这样会导致流量转发不均衡,例如如果nodeA上面有两个pod,nodeB上面有一个pod, LB是将流量平均的转达到两个node上面, 而不是根据pod数目设置不同的权重, 参见社区Caveats and Limitations when preserving source IPs。
    • 功能: 会有源IP丢失的问题,在转发过程中需要做SNAT和NAT, 在某些业务场景下无法满足用户需求。

除了service之外,还有ingress用来实现负载均衡。 ingress本质上是一个代理,广泛用于七层协议,对于一些四层或者gRPC类型的支持不太好。同时ingress controller容器本身也会发生容器漂移等现象,也需要一个四层的负载均衡动态地转发流量到后端。

Requirement

明确了上述各种类型的service的特点之后,我们需要明确我们所需要的service到底是什么样子,主要体现为: 功能,可用性,性能。

功能

能够在集群外部被访问到,将流量从外部均匀地传递到集群内的多个容器。这其实就是kubernetes中LoadBalacner类型的service,对于每一个service我们使用一个真实的负载均衡器,借助于公司内部的或者公有云厂商提供的负载均衡设备即可,这些产品一般都比较成熟。

性能

流量能够高效地转发到容器中,LoadBalancer作为底层基础架构,需要满足各种各样业务对网络性能的要求。流量能够高效的转发到容器内, 这点需要我们LB后端直接挂载容器,不用再经过NodePort或者iptable转发, 对于这点我们需要对底层网络有一定的要求,需要LB能够连接到podIP上,需要VPC直连的容器网络方案,而overlay方式的容器网络在容器集群外是无法直接访问的,此处就无法使用。不过一般情况下,真正在生产环境中被广泛使用的也就是VPC直连的容器网络方案,各个云厂商也有提供相应的解决方案。

VPC直连的网络方案现在被广泛采用,不光是为了解决LB连接的问题, 还具有其他优势:

  • 首先业务需要podIP可以被直接访问到,便于架构上云时进行迁移,有些时候, 部分业务在容器里, 部分还在物理机上,他们需要能够互通。
  • 性能需求,VPC直连的没有overlay封包解包的性能损耗
  • 方便诊断,维护起来更加简单,可以直接看做是物理机使用
  • 目前各个云厂商都有相关的CNI插件, 有利于多云架构的实现

对于LB直接连接容器, 其实在之前的架构下也是这么做的,已经证明了可行性。 只是老的架构是通过在富容器中启动一个agent,由agent注册自身容器IP到LB。老架构由于设计的较早,当时容器还是被当作虚拟机使用,当时还没有kubernetes,没有controller模式, 随着慢慢发展暴露出很多问题:

  • 权限管理难以实现, 分散在各个容器之中
  • 异常处理不集中, 在容器被暴力清理掉之后,来不及从LB上解绑就退出, 进而导致流量继续转发到该容器之中, 或者需要另一个异步清理的进程来实现清理
  • 系统调用耦合严重,接口难以升级, 升级接口需要重启所有的容器
  • 耗费资源,每个富容器中都会有相关的agent

由于老的架构设计较早,问题比较多,再重新思考这个问题的时候, 希望用云原生的方式,运用operater模式实现整个流程。

可用性

在容器动态扩缩过程中,需要保证流量平滑迁移,不能导致业务流量丢失。这是最基本的可用性保证。也是需要考虑最多的地方。kubernetes为了架构的简单,将功能分成多个模块异步执行,例如pod启动和健康检查是由kubelet负责,但是流量转发是由kube-proxy负责,他们之间没有直接的交互,这就会碰到分布式系统中执行时序的问题。如果容器还没启动流量就已经转发过来了就导致流量的丢失,或者容器已经退出但流量继续转发过来也会导致流量的丢失,这些情况对于滚动更新的pod尤其明显。 因为所有的操作都需要远程调用来操作LoadBalaner, 我们不得不考虑执行速度带来的影响。

一般情况下对于容器启动的时候我们无需过多担心, 只有启动之后才能接收流量, 需要担心的容器退出的过程中,需要确保流量还没有摘掉前容器不能退出,否则就会导致流量丢失。主要体现为两点:

  • 滚动更新的过程中需要保证新版本容器正常接收到流量之后才能继续滚动更新的过程,才能去删除老版本容器。如果随便kill掉老版本实例,此时新版本注册还没有生效, 就会导致流量的丢失。
  • 在退出的过程中需要等待流量完全摘除掉之后才能去删除容器。
滚动更新过程

对于滚动更新, 该过程一般是由对应的workload controller负责的, 例如deployment,statfulSet。 以deployment滚动更新为例,如果不加干预整个流程为: 新版本pod启动,readiness探针通过, controller将podIP挂载到LB上面, LB生效一般都需要时间,此时流量还不能转发到新版本pod里面。于此同时deployment认为新容器已经就绪,就进行下一步,删除掉老版本的pod。 此时新老版本都不能接收流量了,就导致了整个服务的不可用。这里根本原因是deployment认为pod就绪并不会考虑LB是否就绪,LB是k8s系统外部的资源,deployment并不认识。退一步来讲,我们平时使用的InCluster类型的service也是有这个问题的,kubelet中容器退出和kube-proxy流量摘除似乎是同时进行的,并没有时序保证,如果kube-proxy执行的稍微慢一点,kubelet中容器退出的稍微快一点,就会碰到流量丢失地情况。幸运的是目前kub-proxy是基于iptables实现的转发,刷新iptables规则在一般情况下执行速度足够快,我们很难碰到这种情况。 但是如果我们基于LoadBalancer直接挂载容器IP,就没有这么幸运了,我们需要远程调用操作LB,而且需要云厂商的LB生效都比较慢,鉴于此,我们需要想办法等到LB就绪之后才能认为整个pod就绪, 即pod就绪等于容器就绪(健康检查探针通过) + LB挂载就绪, pod就绪后才能进行滚动更新。

社区也碰到过过这个问题,开发了Pod Readiness Gates(ready++)的特性,用户可以通过 ReadinessGates 自定义 Pod 就绪的条件,当用户自定义的条件以及容器状态都就绪时,kubelet 才会标记 Pod 准备就绪。 如下所示,用户需要设置readinessGate:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    run: nginx
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      run: nginx
  template:
    metadata:
      labels:
        run: nginx
    spec:
      readinessGates:
      - conditionType: cloudnativestation.net/load-balancer-ready    # <- 这里设置readinessGatea
      containers:
      - image: nginx
        name: nginx

当我们给deployment设置了readinessGate这个字段之后, 当pod启动成功通过reainess的检查之后,并不会认为整个pod已经就绪,因为此时LB还没有就绪, 如果我们此时观察pod的status会发现如下信息

  status:
    conditions:
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      status: "True"
      type: Initialized
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      message: corresponding condition of pod readiness gate "cloudnativestation.net/load-balancer-ready"
        does not exist.
      reason: ReadinessGatesNotReady
      status: "False"
      type: Ready                   # <--- Ready为False
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:20Z"
      status: "True"
      type: ContainersReady          # <--- container Ready为Ture
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:34:18Z"
      status: "True"
      type: PodScheduled
    containerStatuses:
    - containerID: docker://42e761fd53ccb2b2886c500295ceeff8f1d2ffc2376eb66dd95a436c395b95c0
      image: nginx:latest
      imageID: docker-pullable://nginx@sha256:2e6775f4300fc79b9d7fe6bb60c83b5fefe584258d9318ed408746789af48885
      lastState: {}
      name: nginx
      ready: true
      restartCount: 0
      state:
        running:
          startedAt: "2020-03-14T11:34:19Z"

conditions信息中 ContainerReady为True, 但是Ready却为False, message中提示"对应的readiness gate condition还不存在", 那我们只需要patch上对应的condition即可, 如下所示:

  status:
    conditions:
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      message: LB synced successfully
      reason: LBHealthy
      status: "True"
      type: cloudnativestation.net/load-balancer-ready       # <--- 增加readiness gate condtion
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      status: "True"
      type: Initialized
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:05Z"
      status: "True"
      type: Ready                                     # <--- pod状态变为ready
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:05Z"
      status: "True"
      type: ContainersReady
    - lastProbeTime: null
      lastTransitionTime: "2020-03-14T11:38:03Z"
      status: "True"
      type: PodScheduled
    containerStatuses:
    - containerID: docker://65e894a7ef4e53c982bd02da9aee2ddae7c30e652c5bba0f36141876f4c30a01
      image: nginx:latest
      imageID: docker-pullable://nginx@sha256:2e6775f4300fc79b9d7fe6bb60c83b5fefe584258d9318ed4087467

手动设置完readiness gate的condtion之后整个pod才能变为ready。

容器退出过程

对于容器退出的过程中, 我们需要及时将流量从LB上面摘除。 一个pod典型的退出流程为: 我们从控制台下达删除pod的命令时,apiserver会记录pod deletionTimestamp 标记在pod的manifest中, 随后开始执行删除逻辑,首先发送SIGTERM 信号, 然后最大等待terminationGracePeriodSeconds发送SIGKILL信号强制清理, terminationGracePeriodSeconds该值用户可以自行在pod的manifest中指定。
结合整个退出过程,我们需要在监听到容器退出开始时(也就是deletionTimestamp被标记时) 在LB上将该pod流量权重置为0, 这样新建连接就不到达该容器,同时已有连接不受影响,可以继续提供服务。等到容器真正退出时才将该pod从LB上面摘除。用户如果想要更加安全的流量退出逻辑,可以设置一个稍长一点的terminationGracePeriodSeconds, 甚至设置prestop逻辑或者处理SIGTERM信号, 让pod在退出前等待足够长的时间将流量彻底断掉,

Action

明确了整个架构中的关键点后,就是具体的实现环节了。 这部分我们可以借鉴社区提供的service controller及各个云厂商LB在kubernetes中的应用。 社区为了屏蔽掉不同云厂商产品的差异,开发了cloud-controller-manager, 其内部定义了很多接口, 各个云厂商只需要实现其中的接口就可以在合适的时候被调用。 对于LoadBalancer定义接口如下:

// LoadBalancer is an abstract, pluggable interface for load balancers.
type LoadBalancer interface {
	// TODO: Break this up into different interfaces (LB, etc) when we have more than one type of service
	// GetLoadBalancer returns whether the specified load balancer exists, and
	// if so, what its status is.
	// Implementations must treat the *v1.Service parameter as read-only and not modify it.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	GetLoadBalancer(ctx context.Context, clusterName string, service *v1.Service) (status *v1.LoadBalancerStatus, exists bool, err error)
	// GetLoadBalancerName returns the name of the load balancer. Implementations must treat the
	// *v1.Service parameter as read-only and not modify it.
	GetLoadBalancerName(ctx context.Context, clusterName string, service *v1.Service) string
	// EnsureLoadBalancer creates a new load balancer 'name', or updates the existing one. Returns the status of the balancer
	// Implementations must treat the *v1.Service and *v1.Node
	// parameters as read-only and not modify them.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	EnsureLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (*v1.LoadBalancerStatus, error)
	// UpdateLoadBalancer updates hosts under the specified load balancer.
	// Implementations must treat the *v1.Service and *v1.Node
	// parameters as read-only and not modify them.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	UpdateLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) error
	// EnsureLoadBalancerDeleted deletes the specified load balancer if it
	// exists, returning nil if the load balancer specified either didn't exist or
	// was successfully deleted.
	// This construction is useful because many cloud providers' load balancers
	// have multiple underlying components, meaning a Get could say that the LB
	// doesn't exist even if some part of it is still laying around.
	// Implementations must treat the *v1.Service parameter as read-only and not modify it.
	// Parameter 'clusterName' is the name of the cluster as presented to kube-controller-manager
	EnsureLoadBalancerDeleted(ctx context.Context, clusterName string, service *v1.Service) error
}

当用户创建LoabBalancer类型的service时,cloud-controller-manager中的service controller就会利用informer监听service的创建、更新、删除事件,然后调用各个云厂商注册的接口,云厂商只需要提供以上的接口就行了。

对于Loadbalancer,具体各个厂商实现不同, 但是目前的实现基本都是直接挂载nodePort, 可以看到上述EnsureLoadBalancer中传递的参数也是nodes列表。 上述的接口我们无法直接使用,需要对其改造, 实现一个自定义的service controller。在EnsureLoadBalancer的时候传递的参数也应该是pod的IP列表, 我们挂载的是pod而不是node。所以此处需要不断监听pod的变化,然后选择判断该pod是否被service label selector选中,如果选中则该pod是service的后端,需要设置将流量转发到该pod上面, 这里很多熟悉kubernetes的小伙伴就会好奇,这里不是和endpoints的功能一模一样吗? 为什么不直接监听endpoint, 然后将endpoint中的ip列表拿出来直接使用?

要弄明白这个问题,我们需要回顾我们在保证流量不丢的时候设置了readinessGate, 此时pod就绪状态会变为: 容器就绪+LB就绪。但是在endpoint的工作原理中, endpoint controller会判断pod是否就绪,pod就绪之后才会将podIP放在endpoint的结构体中。而我们期望容器就绪之后就在endpoint显示出来,这样我们就可以拿着这个enpoint的ip列表去注册到LB上, LB注册成功之后,pod才能变为就绪。 社区endpoint中iplist的顺序和我们期望的略有差异, 只能自己实现一个类似的结构体了,和社区的使用方式大部分相同, 只是判断就绪的逻辑略有不同。

自定义endpoint的另外一个原因是: endpoint controller会将service选中的所有pod分为ready和unready两组, 当pod刚启动时, 还未通过readiness探针检查时会将pod放置在unReadAddress列表中,通过readiness检查后会移动到address列表中,随后在退出时会直接将pod移出address列表中。 在我们的场景下,更加合理的逻辑应该是在退出过程中应该从endpoint中address列表移动到unReadyAddress列表,这样我们就可以根据unReadyAddress来决定在退出的时候将哪些podIP在LB上面将权重置为0。

自定义endpoint controller并没有更改kubernetes原来的endpoint controller的代码, 这里我们只是作为一个内部的数据结构体使用, 直接结合在service controller中即可,也无需监听endpoint变化,直接监听pod变化生成对应的service 即可。

收获

在落地kubernetes的过程中, 相信kube-proxy被不少人诟病,甚至有不少公司完全抛弃了kube-proxy。 不好的东西我们就要积极探索一种更好,更适合公司内部情况的解决方案。目前该满足了不同业务上云时的网络需求,承载了不同的流量类型。 同时很好地应用在多云环境下,私有云和公有云下都可以适配, 尽管私有云或者公有云的底层网络方案或者LB实现不同,但是整个架构相同,可以无缝地在私有云,aws, 阿里,金山云直接迁移。

kubernetes的快速发展为我们带来了很多惊喜,但是于此同时很多细节的地方需要打磨,需要时间的沉淀才能更加完美, 相信在落地kubernetes的过程中不少人被kubernetes的网络模型所困扰,此时我们需要根据企业内部的情况, 结合已有的基础设施,根据社区已经提供的和尚未提供的功能进行一些大胆的微创新,然后探索更多的可能性。

你可能感兴趣的:(LoadBalancer在kubernetes架构下的实践)