本文最初发布于 Pinterest Engineering Blog,经原作者授权由 InfoQ 中文站翻译并分享。
前言
距离上一次分享我们在Pinterest上搭建Kubernetes之旅已经过去一年多了。从那时开始,我们交付了许多功能,方便用户进行采用,确保可靠性和可延展性,并积累了很多运维经验和最佳实践。
总的来说,Kubernetes 平台的用户反馈都很正面。根据我们的调查,在用户心中排行前三的好处分别是,减轻管理计算资源的负担、更好的资源和故障隔离,以及更灵活的容量管理。
在 2020 年底,我们在 Kubernetes 集群中利用超过 2,500 个节点,协调了超过 35,000 个用于支持 Pinterest 各项业务的 Pod,而这项数据的增长依旧如火箭般窜升。
2020 年概况
随着用户采用的不断增加,负载的种类和数量也在不断增长。这就意味着 Kubernetes 平台需要有更强的可扩展性,才能跟得上日益增长的负载管理、Pod 的调度和放置,以及分配和取消分配节点的工作量。随着更多关键业务的负载被搬上 Kubernetes 平台,用户对平台可靠性的期望自然也水涨船高。
全平台范围的停机也的确发生过。2020 年初,在我们一个集群上,短时间内有大量的 Pod 被创建,数量超过了计划容量的三倍,导致该集群的自动协调器启用了 900 个节点以满足需求。kube-apiserver率先开始出现延迟峰值以及错误率的增长,随后便因资源限制而被 OOM 杀进程(Out of Memory Kill,内存不足时杀进程)。来自 Kubelets 的非绑定重试请求导致 kube-apiserver 负载猛增 7 倍。写入请求数量的爆发导致etcd提前到达其总数据量的限制,并开始拒绝所有的写入请求,平台无法继续管理负载。为了缓解这次事件,我们不得不通过执行 etcd 操作来恢复平台运行,例如压缩旧版本程序,碎片整理冗余空间,以及禁用警报。此外,我们还得暂时扩容承载 kube-apiserver 和 etcd 的 Kubernetes 主节点,以减少对资源的限制。
Kubernetes API 服务器延迟峰值
在 2020 年下半年,我们的一个基础设施组件在 kube-apiserver 的集成中出了一个 bug,表现是短时间内生成大量的、对所有 kube-apiserver 的 Pod 和节点的昂贵查询。这导致了 Kubernetes 的主节点资源使用量激增,kube-apiserver 进入了 OMM Kill 的状态。幸运的是,这个出 bug 的组件被发现得很早,并且很快就回滚了。但在这次的事件中,平台的性能受到了降级的影响,其中包括负载的处理出现了延迟以及陈旧的服务状态。
Kubernetes API 服务器执行 OOM Killed
为规模化做好准备
在我们的 Kubernetes 之旅中,我们不断反思自己平台的治理、弹性和可操作性,尤其是在当事故发生在我们最薄弱的地方时。对于一个工程资源有限的小团队,我们必须要深入思考,找出问题的根本所在,确定短板的文职,并根据回报与成本的性价比确定解决方案的优先次序。我们面对复杂的 Kubernetes 生态系统时的策略是,尽量减少方案与社区中提供方法的区别的同时不断回馈社区,但仍不放弃自己编写内部组件代码的可能性。
Pinterest 上的 Kubernetes 平台架构(蓝色代表我们自己编写的内容,绿色则是开源内容)
治理
资源配额的执行
Kubernetes 已有的资源配额管理确保了任何的命名空间都无法在绝大多数的维度上无限制地请求或占用资源,无论是 Pod、CPU 还是内存。如我们在前文的故障中所提到的,单个命名空间中数量激增的 Pod 创建事件可能会让 kube-apiserver 过载,导致级联故障。为确保其稳定性,单个命名空间的资源使用量都应有一定限制,这一点很重要。
这项任务的难点之一在于,在每一个命名空间强制执行资源配额需要一个潜在条件:所有的 Pod 和容器都需规定资源的请求和限制。在 Pinterest 的 Kubernetes 平台上,不同命名空间的负载属于不同的团队和不同的项目,而平台用户则是通过 Pinterest 的 CRD 配置他们的负载。我们对这一问题的解决方案是,为 GRD 的转换层中所有的 Pod 和容器添加默认资源请求和限制。
除此之外,我们还在 CRD 的验证层中拒绝了所有未规定资源请求和限制的 Pod。
另一难点则在于,如何简化跨团队和组织的配额管理。为了资源配额的安全实现,我们参考了过往的资源使用情况,在其高峰值的基础上额外增加 20%的净空,并将其设置为所有项目资源配额的初始值。我们创建了一个定时任务来监控配额的使用情况,如果某个项目的用量接近一定限度,会在营业时间内向负责该项目的团队发送警报。这项设置鼓励了负责团队对项目进行更好的容量规划,并在资源配额发生变动时提出申请。资源配额的变更在人工审核并签署之后,才会进行自动部署。
客户端访问的执行
我们强制要求了所有的 KubeAPI 客户端都遵循 Kubernetes 已有的最佳实践:
控制器框架
控制器框架为优化读取操作提供了一个利用informer-reflector-cache的、可共享的缓存架构。Informer 通过 kube-apiserver 监控目标对象,Reflector 将目标对象的变更反应到底层缓存(Cache)中,并将观测到的事件传播给事件处理程序。同一控制器中的多个组件可以为 OnCreate、OnUpdate,以及 OnDelete 事件注册 Informer 事件处理程序,并从 Cache 中获取对象(而非是直接从 Kube-apiserver 中获取)。这样一来,就减少了很多不必要或多余的调用。
Kubernetes 的控制器架构
速率限制
Kubernetes 的 API 客户端通常会在不同的控制器中共享,而 API 是在不同的线程中调用的。Kubernetes 的 API 客户端是与它支持的可配置 QPS 和 burst 的token桶速率限制器相绑定的,burst 超过阈值就节制 API 的调用,这样单个的控制器就不会影响到 kube-apiserver 的带宽。
共享缓存
除了控制器框架自带 kube-apiserver 内置缓存之外,我们还在平台的 API 中添加了另一个基于 Informer 的写通缓存层。这种设置是为了防止不必要的读取调用对 kube-apiserver 的冲击,重复利用服务器端的缓存也可以避免应用程序代码中过于繁杂的客户端。
对于从应用中访问 kube-apiserver 的情况,我们强制要求所有的请求都需要通过平台 API,利用共享缓存为访问控制和流量控制分配安全身份。对于从负载器访问 kube-apiserver 的情况,我们强制要求所有控制器的实现都是要基于带有速率限制的控制框架。
恢复力
硬化 Kubelet
Kubernetes 的控制平台会进入级联故障的一个关键原因是,传统的反射器(Reflector)的实现在处理错误时会有无限制次数的重试。这种小瑕疵在有些时候会被无限放大,尤其时当 API 服务器被 OMM Kill 时,很容易造成集群上所有的反射器一起进行同步。
为了解决这个问题,我们通过与社区的紧密合作,反馈问题、讨论解决方案,并最终让我们的 pull request(注1,2)通过审核并成功 merge。我们的想法是通过在反射器的 ListWatch 重试逻辑中添加指数回退,这样,当 kube-apiserver 过载或请求失败时,kubelet 和其他的控制器就不会继续尝试 kube-apiserver 了。这种弹性的改进在大多数的情况下都是锦上添花,但我们也发现,随着 Kubernetes 集群中节点和 Pod 数量的增加,这种改进的必要性也体现出来了。
调整并发请求
随着我们管理的节点的数量的增加,负载的创建和销毁速度越快,QPS 服务器需要处理的 API 调用数量也在增加。我们首先根据预估的负载大小,上调了变异和非变异操作的最大并发 API 调用的次数。这两项设置将限制需要处理的 API 调用次数不能超过配置的数量,从而使 kube-apiserver 的 CPU 和内存消耗保持在一定的阈值之内。
在 Kubernetes 的 API 请求处理链中,每个请求在最开始都需要通过一连串的过滤器。最大机上 API 调用的限制则是在过滤器链中实现的。对于超过配置阈值的 API 调用,客户端会收到“请求过多(429)”的反馈,从而触发对应的重试操作。在未来,我们计划对EventRateLimit功能进行更深入的研究,用更精细的准入控制提供更高的服务质量。
缓存更多的历史纪录
Watch 缓存时 kube-apiserver 内部的一种机制,它将各类资源中过去的事件缓存到一个环形缓存区中,以方便特定版本的 watch 调用。缓存区越大,服务器能保存的事件就更多,连接中断时为客户端提供的事件流也就更流畅。鉴于这一事实,我们还改进了 kube-apiserver 的目标 RAM 大小,其内部最终会转移到 Watch 缓存容量中,以提供更稳健的事件流。Kube-apiserver 有提供更详细的配置更详细的 Watch 缓存大小的方法,可以用于更精细的缓存需求。
Kubernetes 的 Watch 缓存
可操作性
可视性
为减少事故检测和缓解的事件,我们不断改善 Kubernetes 控制平面的可视性。这一点的挑战在于要如何平衡故障检测的覆盖率和信号的灵敏度。对于现有的 Kubernetes 指标,我们通过分流并挑选重要区域进行监测和报警,如此一来,我们就可以更加主动地发现问题所在。除此之外,我们还对 kube-apiserver 进行监测,以覆盖更细小的区域,从而更快地缩小问题根源所在区域。最后,我们调整了警报统计和阈值大小,以减少噪音和错误警报。
在高层次上,我们通过查看 QPS 和并发请求、错误率,以及请求延迟来监控 kube-apiserver 的负载。我们也可以将流量按照资源类型、请求动词以及相关的服务账号进行细分。而对于 listing 这类的昂贵流量,我们通过对象计数和字节大小来计算请求负载,即使只有很小的 QPS,这类流量也很容易导致 kube-apiserver 过载。最后,我们还监测了 etcd 的 watch 事件处理 QPS 和延迟处理的计数,以作为重要的服务器性能指标。
Kubernetes API 调用类型分类
可调试性
为了能更好地了解 Kubernetes 控制面板的性能和资源消耗,我们还用boltdb库和flamegraph搭建了一个 etcd 数据存储分析工具,以便对数据存储的细化进行可视化展示。数据存储分析的结果让用户可以洞察其内部,更便于进行优化。
单个密钥空间的 Etcd 数据用量
除此之外,我们还启用了 Go 语言的剖析工具pprof以及可视化的堆内存足迹,以快速找出资源最密集的代码路径和请求模式,一个例子是,列表资源调用的转换响应对象。在我们调查 kube-apiserver 的 OOM 时发现的另一个大问题是,kube-apiserver 使用的页面缓存是被计入 cgroup 的内存限制的,而通过使用匿名内存可以盗取同一 cgroup 的页面缓存用量。于是,即使 kube-apiserver 只有 20GB 的堆内存用量,整个 cgroup 的 200GB 内存用量也有可能到达上限。虽然目前的内核默认设置是不主动回收分配的页面以有效地重复利用,但我们目前仍然在研究基于 memory.stat 文件的设置的监控,并强制要求 cgroup 在内存使用接近上限时尽可能多地回收页面。
Kubernetes API 服务器的内存详情
总结
通过在治理、弹性和可操作性方面的努力,我们在很大程度上避免了计算资源、控制面板带宽方面的用量激增情况,确保了整个平台的性能和稳定性。优化后的 kube-apiserver 减少了 90%的 QPS(主要是读取),使得 kube-apiserver 的用量更加稳定、高效、健壮。我们从中学习到的关于 Kubernetes 内部的深入认知和额外见解,让我们的能够更好地进行系统操作和集群维护。
优化推出后 Kube-apiserver 的 QPS 减少情况
以下是我们在这段旅程中的一些重要收获,希望能够对你在处理 Kubernetes 的可扩展性和可靠性问题上有所帮助:
诊断问题并找到其根源所在。在考虑“怎么办”之前先搞清楚问题“是什么”。解决问题的第一步是了解瓶颈在哪以及其生成原因。如果你找到了问题的根本,那么你已经掌握了一半的解决方案。
首先尝试小规模、渐进式的优化,而不是直接上手激进的架构改革。在大多数情况下,这种方法不会让你吃太大的亏,这点在资源不足的小团队中也是很重要的。
在规划或调整调查和修复的优先顺序时,请将数据作为参考凭据。正确的遥测技术可以帮助你在决定优化项目顺序时做出更好的决定。
关键的基础设施组件在设计时应考虑到其复原力。分布式系统总是会出现故障的,我们总要做好最坏的打算。正确的防护可以有效防止级联故障,并将事故的影响范围最小化。
展望未来
联邦
随着我们规模的稳步增长,单一集群架构已经不足以支持日益增加的负载。目前的单一集群环境是高效且稳定的,我们的下一个里程碑即是将这个计算平台向横向扩展。通过利用联邦框架,我们的目标是以最小的操作开销将新的集群插入到环境中去,同时保持终端用户平台界面的稳定。我们的联邦集群环境目前还在开发阶段,期待它在产品化后能为我们带来更多的可能性。
容量规划
我们目前采用的资源配额的执行方法是简化后的、并依赖反应的容量规划方式。随着我们不断添加用户的负载和系统组件,平台的动态变化、项目的等级,或者是集群范围的容量限制可能无法跟上版本的变化。我们希望能够探索出一种主动的容量规划方案,根据历史数据、增长轨迹,以及涵盖资源配额和 API 配额的复杂的容量模型进行预测。这种更主动,也更准确的容量规划可以有效防止平台的过度承诺和交付不足。
原文链接:
https://medium.com/pinterest-engineering/scaling-kubernetes-with-assurance-at-pinterest-a23f821168da
评论 2 条评论