因为 Lease 对象非常小,因此其更新的代价远小于更新 node 对象。kubernetes 通过这个机制,显著的降低了 API Server 的 CPU 开销,同时也大幅减小了 etcd 中大量的 transaction logs,成功将其规模从 1000 扩展到了几千个节点的规模,该功能在社区 Kubernetes-1.14 中已经默认启用,更多细节详见 KEP-0009。
API Server load balancing
在生产集群中,出于性能和可用性的考虑,通常会部署多个节点组成高可用 Kubernetes 集群。但在高可用集群实际的运行中,可能会出现多个 API Server 之间的负载不均衡,尤其是在集群升级或部分节点发生故障重启的时候。这给集群的稳定性带来了很大的压力,原本计划通过高可用的方式分摊 API Server 面临的压力,但在极端情况下所有压力又回到了一个节点,导致系统响应时间变长,甚至击垮该节点继而导致雪崩。
下图为压测集群中模拟的一个 case,在三个节点的集群,API Server 升级后所有的压力均打到了其中一个 API Server 上,其 CPU 开销远高于其他两个节点。
解决负载均衡问题,一个自然的思路就是增加 load balancer。前文的描述中提到,集群中主要的负载是处理节点的心跳,那我们就在 API Server 与 kubelet 中间增加 lb,有两个典型的思路:
API Server 测增加 lb,所有的 kubelets 连接 lb,典型的云厂商交付的 Kubernetes 集群,就是这一模式;
kubelet 测增加 lb,由 lb 来选择 API Server。
通过压测环境验证发现,增加 lb 并不能很好的解决上面提到的问题,我们必须要深入理解 Kubernetes 内部的通信机制。深入到 Kubernetes 中研究发现,为了解决 tls 连接认证的开销,Kubernetes 客户端做了很多的努力确保“尽量复用同样的 tls 连接”,大多数情况下客户端 watcher 均工作在下层的同一个 tls 连接上,仅当这个连接发生异常时,才可能会触发重连继而发生 API Server 的切换。其结果就是我们看到的,当 kubelet 连接到其中一个 API Server 后,基本上是不会发生负载切换。为了解决这个问题,我们进行了三个方面的优化:
API Server:认为客户端是不可信的,需要保护自己不被过载的请求击溃。当自身负载超过一个阈值时,发送 429 - too many requests 提醒客户端退避;当自身负载超过一个更高的阈值时,通过关闭客户端连接拒绝请求;
Client:在一个时间段内频繁的收到 429 时,尝试重建连接切换 API Server;定期地重建连接切换 API Server 完成洗牌;
运维层面,我们通过设置 maxSurge=3 的方式升级 API Server,避免升级过程带来的性能抖动。
如上图左下角监控图所示,增强后的版本可以做到 API Server 负载基本均衡,同时在显示重启两个节点(图中抖动)时,能够快速的自动恢复到均衡状态。
List-Watch & Cacher
List-Watch 是 Kubernetes 中 Server 与 Client 通信最核心一个机制,etcd 中所有对象及其更新的信息,API Server 内部通过 Reflector 去 watch etcd 的数据变化并存储到内存中,controller/kubelets 中的客户端也通过类似的机制去订阅数据的变化。
在 List-Watch 机制中面临的一个核心问题是,当 Client 与 Server 之间的通信断开时,如何确保重连期间的数据不丢,这在 Kubernetes 中通过了一个全局递增的版本号 resourceVersion 来实现。如下图所示 Reflector 中保存这当前已经同步到的数据版本,重连时 Reflector 告知 Server 自己当前的版本(5),Server 根据内存中记录的最近变更历史计算客户端需要的数据起始位置(7)。
这一切看起来十分简单可靠,但是……
在 API Server 内部,每个类型的对象会存储在一个叫做 storage 的对象中,比如会有:
因为 storage 队列是有限的(FIFO),当 pods 的更新时队列,旧的变更就会从队列中淘汰。如上图所示,当队列中的更新与某个 Client 无关时,Client 进度仍然保持在 rv=5,如果 Client 在 5 被淘汰后重连,这时候 API Server 无法判断 5 与当前队列最小值(7)之间是否存在客户端需要感知的变更,因此返回 Client too old version err 触发 Client 重新 list 所有的数据。为了解决这个问题,Kubernetes 引入 watch bookmark 机制:
bookmark 的核心思想概括起来就是在 Client 与 Server 之间保持一个“心跳”,即使队列中无 Client 需要感知的更新,Reflector 内部的版本号也需要及时的更新。如上图所示,Server 会在合适的适合推送当前最新的 rv=12 版本号给 Client,使得 Client 版本号跟上 Server 的进展。bookmark 可以将 API Server 重启时需要重新同步的事件降低为原来的 3%(性能提高了几十倍),该功能有阿里云容器平台开发,已经发布到社区 Kubernetes-1.15 版本中。
Cacher & Indexing
除 List-Watch 之外,另外一种客户端的访问模式是直接查询 API Server,如下图所示。为了保证客户端在多个 API Server 节点间读到一致的数据,API Server 会通过获取 etcd 中的数据来支持 Client 的查询请求。从性能角度看,这带来了几个问题:
无法支持索引,查询节点的 pod 需要先获取集群中所有的 pod,这个开销是巨大的;
因为 etcd 的 request-response 模型,单次请求查询过大的数据会消耗大量的内存,通常情况下 API Server 与 etcd 之间的查询会限制请求的数据量,并通过分页的方式来完成大量的数据查询,分页带来的多次的 round trip 显著降低了性能;
为了确保一致性,API Server 查询 etcd 均采用了 Quorum read ,这个查询开销是集群级别,无法扩展的。
为了解决这个问题,我们设计了 API Server 与 etcd 的数据协同机制,确保 Client 能够通过 API Server 的 cache 获取到一致的数据,其原理如下图所示,整体工作流程如下:
t0 时刻 Client 查询 API Server;
API Server 请求 etcd 获取当前的数据版本 rv@t0;
API Server 请求进度的更新,并等待 Reflector 数据版本达到 rv@t0;
通过 cache 响应用户的请求。
这个方式并未打破 Client 的一致性模型(感兴趣的可以自己论证一下),同时通过 cache 响应用户请求时我们可以灵活的增强查询能力,比如支持 namespace nodename/labels 索引。该增强大幅提高了 API Server 的读请求处理能力,在万台规模集群中典型的 describe node 的时间从原来的 5s 降低到 0.3s(触发了 node name 索引),其他如 get nodes 等查询操作的效率也获得了成倍的增长。
Context-Aware
API Server 接收请求并完成请求需要访问外部服务,如访问 etcd 将数据持久化、访问 Webhook Server 完成扩展性的 Admission 或者 Auth,甚至是 API Server 自己访问自己(loopback client) 去完成 ServiceAccount 的鉴权工作。
在这种 API Server 处理请求模型的框架下,就有如下这样的问题:当一个客户端的请求已经被客户端主动结束、或者超时结束时,如果 API Server 还依然还在为这个请求去请求外部的服务的数据、并没有也在第一时间及时停止请求,那么就会导致 Goruntine 和资源的“积压”。而客户端在主动结束、或者超时结束它的请求之后,因为 Kubernetes 面向终态的架构,客户端势必会立刻又发起新的请求,从而使得“积压”甚至是泄露的 Goruntine 和资源越来越多,最终导致 API Server OOM 和 crash。
我们都知道 golang 中使用 context 来表示“上下文”的含义。API Server 请求外部服务的“上下文”就是客户端发起请求,那么当客户端的请求结束之后,API Server 也应该立刻回收 API Server 请求外部服务的资源,即这类请求也应该立刻停止并退出,只有这样,API Server 才能提高吞吐并不会被积压的 Goruntine 和资源所拖累。
阿里巴巴和蚂蚁金服的工程师发现并参与了 API Server 全链路的 context-aware 的优化工作,Kubernetes v1.16 版本已经将 Admission、Webhook 等优化为 context-aware,从而进一步提升 API Server 的性能和吞吐。
Requests Flood Prevention
API Server 对于接收处理请求的自我保护能力太过薄弱,目前可以说除了 max-inflight filter 做了限制最大读、写并发外,没有其它能够限制请求数量和并发的功能。这带来一个非常大的问题:API Server 可能因为接收并处理太多的请求从而导致 API Server OOM 或者崩溃。
虽然 API Server 是一个内部的系统,几乎没有外来请求的攻击,所有的请求都来自 Kubernetes 内部的组件和模块,API Server 也可能因为内部的请求量过大而导致自己身崩溃。根据我们的观察和经验,API Server 接收过多请求处理而导致崩溃的主要场景有如下两部分:
API Server 自身重启或者升级
我们知道 Kubernetes 是以 API Server 为中心的系统。当 API Server 重启或者升级之后,所有的组件 client 都断开了连接并开始重新请求 API Server,特别是重新建立 List/Watch 需要比较大的资源开销。而 API Server 与 etcd 有自己的 cache 层,当客户端的 Informer List 请求到来之时,如果 cache 还未 ready 就会去请求 etcd,而大量的从 etcd List 资源可能会将 API Server 与 etcd 网络链路打满,甚至出现 API Server 和 etcd 的 OOM。而刚启动的 API Server 就陷入 crash,势必会导致客户端更大量的请求,从而陷入雪崩状态。
对于这种场景,我们采用“主动拒绝”请求的方式。在 API Server 刚启动之时,如果 API Server 和 etcd 之间的 cache 还未 Ready,API Server 就会拒绝耗资源较大的请求,如 List 资源的请求:只有在 cache Ready 之后,API Server 才向客户端提供 List 资源的服务,否则返回 429 让客户端等待一短时间后重试。只有这样,API Server 才能将接受大规模请求的主要瓶颈优化到 API Server 和客户端网络上 IO 的瓶颈。
客户端组件出现 Bug,疯狂的请求 API Server
特别是 DaemonSet 组件出现 Bug,那么请求量将乘以节点数目。我们在线上发生过 Daemonset 出现 Bug,上万个节点一直疯狂 List Pod 从而导致 API Server crash 的案例。
对于这种的场景,我们采用应急限流的方案。我们实现了可以动态配置,根据请求来源的 User-Agent 去做限流。当再次出现此类问题时,我们从监控图表里发现有问题的 User-Agent 并将它限流。只有在 API Server 健康的前提下,我们才能对 DaemonSet 做出修复并升级。在 API Server crash 时,DaemonSet 的升级也失效了,从而陷入集群无法挽救的局面。
采用 User-Agent 而非根据 Identity 信息(请求的用户信息) 做限流原因是因为 API Server 做请求的身份识别也需要耗费资源,很可能因为在为大量请求做身份识别过程中就出现 API Server 资源耗尽的情况。其次,我们可以从监控中快速的发现有问题的请求的 User-Agent,从而做到更快速的响应。
阿里和蚂蚁金服的工程师已经将该限流方案的 User Story 和优化方式已经提交到社区。
Controller failover
在 10k node 的生产集群中,Controller 中存储着近百万的对象,从 API Server 获取这些对象并反序列化的开销是无法忽略的,重启 Controller 恢复时可能需要花费几分钟才能完成这项工作,这对于阿里巴巴规模的企业来说是不可接受的。为了减小组件升级对系统可用性的影响,我们需要尽量的减小 controller 单次升级对系统的中断时间,这里通过如下图所示的方案来解决这个问题:
预启动备 controller informer ,提前加载 controller 需要的数据;
主 controller 升级时,会主动释放 Leader Lease,触发备立即接管工作。
通过这个方案,我们将 controller 中断时间降低到秒级别(升级时 < 2s),即使在异常宕机时,备仅需等待 leader lease 的过期(默认 15s),无需要花费几分钟重新同步数据。通过这个增强,显著的降低了 controller MTTR,同时降低了 controller 恢复时对 API Server 的性能冲击。该方案同样适用于 scheduler。