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 的网络模型所困扰, 此时我们需要根据企业内部的情况, 结合已有的基础设施, 根据社区已经提供的和尚未提供的功能进行一些大胆的微创新, 然后探索更多的可能性.
来源: https://www.cnblogs.com/gaorong/p/12951036.html