简介
在 ClickHouse Cloud,我们热爱 Kubernetes,并在 Kubernetes 中运行客户的 ClickHouse 集群(服务器和 Keeper)。对于托管在 AWS 中的 ClickHouse 集群,我们使用 Elastic Kubernetes Service (EKS)。来自不同 ClickHouse 集群的服务器 Pod 可以调度到同一个 EKS 节点上。我们使用 Kubernetes 命名空间 + Cilium 进行隔离。随着 EKS 集群在新区域中配置以支持我们的客户,我们的集群规模一直在显著增长,EC2 实例的基础设施成本也随之增长。
为了优化成本,我们分析了 EKS 节点的利用率。EC2 实例按小时收费,而不是基于使用量,因此未充分利用的节点/集群意味着我们浪费了资金。可以通过提高利用率并减少所需的 EC2 节点总数来降低成本。请继续阅读,了解我们如何改进 Pod 分配并节省数百万美元。
评估 EKS 节点利用率
为了尽可能提高资源消耗效率,我们进行了一项练习,以确定 Pod 的分配方式。我们分析了 EKS 集群节点中的 CPU/内存利用率。以下屏幕截图来自 eks-node-viewer,这是一个可视化 EKS 资源利用率的工具。
图 1 显示了我们较大的 EKS 集群之一的 CPU 利用率百分比。整个集群的 CPU 利用率约为 50%。此外,许多大型且昂贵的节点(如图 2 所示)未得到充分利用。根据我们集群的 eks-node-viewer
结果,我们得出结论,服务器 Pod 在节点之间没有紧密打包。本可以调度到同一节点上的两个 ClickHouse Pod 却调度到不同的节点上,导致我们需要更多数量的 EC2 节点才能运行相同数量的 Pod,每个节点的资源利用率降低,集群成本更高。我们集群中许多节点上的 CPU/内存资源大多处于空闲状态,但我们仍然需要为这些节点付费。
根本原因分析
经过一些调查,我们确定了利用率低下的根本原因。
- Kubernetes 默认调度器使用LeastAllocated 评分策略,在将待处理 Pod 调度到可用节点时对节点进行评分。LeastAllocated 评分策略偏爱具有更多可用资源的节点,这导致 Pod 在集群节点上稀疏分布。
- 这种 LeastAllocated 评分策略使得节点规模缩减非常不可能。假设一个旧的 Pod 在节点上终止。然后,调度器将优先选择此节点来调度新的 Pod。因此,节点利用率很难降至集群自动扩缩器的阈值以下而被回收。
同时,我们对新解决方案还有三个额外的要求和约束
- 提高整个集群的资源(CPU/内存)利用率(并降低我们的 EC2 占用空间和成本)。
- 不影响客户体验,例如增加 ClickHouse 实例的配置时间。
- 最大限度地减少对客户正在运行的实例的干扰。
考虑到这些因素,我们探索了一些潜在的解决方案。
调整集群自动扩缩器和过度配置(已放弃)
一种显而易见的方法是降低 集群自动扩缩器缩减阈值。但是,这意味着更多的客户 Pod 将被频繁驱逐,这不符合要求 #3(对客户查询的干扰)。
我们还简要考虑了通过调整资源请求和限制来过度配置节点。但是,过度配置资源可能会导致争用。但是,无论是 CPU 争用(查询被限制)还是内存争用(可能被 OOM 杀死)在满足客户体验方面都是不可接受的。
主动打包 Pod(已放弃)
我们还考虑添加注释 cluster-autoscaler.kubernetes.io/safe-to-evict 到 ClickHouse Pod。这允许 Kubernetes 集群自动扩缩器在节点利用率降至一定阈值以下时驱逐 Pod。但是,Pod 驱逐可能会给我们的客户带来干扰。例如,长时间运行的查询将被中断。
我们已经为 ClickHouse 服务器配置了 PodDisruptionBudget,以限制在任何时候 StatefulSet 中只有一个 Pod 不可用。但根据我们的经验,让集群自动扩缩器在随机时间终止一些 Pod 对于像 ClickHouse 这样的有状态工作负载来说仍然太具有破坏性。
因此,我们决定不选择这种方法。
相反,我们决定将 kube-scheduler(默认 Kubernetes 调度器)评分策略从 LeastAllocated 更改为 MostAllocated,以更有效地打包我们的集群。此解决方案为我们的 Pod 实施了 装箱优化 范例。这有什么帮助呢?
- 当需要创建新的 Pod 时,调度器现在更喜欢利用率更高的节点。这使得集群的整体利用率随着时间的推移而更高。
- 当旧的 Pod 在节点上终止时,该节点不太可能被考虑,因此更有可能达到低利用率阈值。最终,集群自动扩缩器可以从集群中删除此节点,从而降低总成本。
此策略是 NodeResourcesFit 插件的一部分,该插件在 Kubernetes 中默认启用。启用此设置后,在 Pod 调度阶段,Kubernetes 调度器执行以下操作
- 首先,它识别集群中具有可用资源的节点,如 Pod 请求规范中所指定的那样。这是“过滤阶段”。
- 接下来,它将根据过滤后的节点的综合资源利用率(CPU 和内存)降序排列这些节点。这称为“评分阶段”。
- 为 Pod 选择的节点将始终是能够容纳 Pod 并且资源利用率最高的节点。
由于该解决方案满足了我们所有的要求,我们决定研究如何在 EKS 中指定此调度策略。
EKS 设置支持 kube-scheduler 自定义
当我们开始研究如何在 EKS 中为 kube-scheduler 设置此策略时,我们发现 EKS 不支持通过 EKS 设置/配置自定义 kube-scheduler。许多用户已经 请求了此功能,但目前 AWS 尚无迹象表明将在近期添加支持。由于我们无法通过 EKS 设置执行此操作,因此我们选择在 Kubernetes 集群中自行设置自定义调度器。
具有 most-allocated
评分策略的自定义调度器
为了为集群中的 Pod 设置自定义调度器,我们主要遵循了 Kubernetes 提供的 便捷指南。Kubernetes 允许您创建自己的调度器二进制文件,但这在我们的案例中不是必需的,因为现有的 kube-scheduler 镜像以及 most-allocated
评分策略满足了我们的要求。为了在我们的集群中创建此调度器,我们采取了以下步骤
- 构建并将 kube-scheduler 镜像部署到我们的容器注册表中。我们找不到托管此镜像的公共注册表。维护此镜像对我们来说并不理想,但目前没有其他替代解决方案。
- 为调度器创建部署,如 指南 中所述。在 configMap 中,我们为评分策略提供了相关设置 - ‘MostAllocated’ 以及在考虑分配时 CPU/内存的权重。configMap 的 profile 部分如下所示
profiles:
- pluginConfig:
- args:
apiVersion: kubescheduler.config.k8s.io/v1beta3
kind: NodeResourcesFitArgs
scoringStrategy:
resources:
- name: cpu
weight: 1
- name: memory
weight: 1
type: MostAllocated
name: NodeResourcesFit
plugins:
score:
enabled:
- name: NodeResourcesFit
weight: 1
schedulerName: <schedulerName>
- 为了确保高可用性,我们选择在自定义调度器部署中定义三个 Pod,并启用领导者选举,以便只有一个 Pod 执行主动调度,而其他两个 Pod 处于备用状态。
- 我们将此调度器部署到我们的集群,然后更新我们的 Pod 以使用此 most-allocated 调度器,方法是在 PodSpec 中指定 schedulerName。
通过这种设置,我们可以确保调度吞吐量与现有的 kube-scheduler 相似,并且自定义调度器设置中内置了冗余。我们还可以以最小的干扰提高集群资源利用率。
系统实用程序工作负载
我们的 EKS 集群有一些系统 Pod,负责实用程序工作负载,例如 CoreDNS、ArgoCD、Cilium Cluster Mesh 等。有时,这些是低利用率节点上仅存的 Pod。集群自动扩缩器在驱逐其中一些 Pod 时遇到问题,因为它们使用本地存储。反过来,这些被占用的节点无法缩减。
为了解决这个问题,我们使用 safe-to-evict: true 注释了所有此类系统实用程序工作负载。
过度配置以实现更平滑的横向扩展
EKS 集群自动扩缩器 Pod 本身也可能阻止节点被回收。对于自动扩缩器,我们选择在小型节点上运行它,而不是配置 safe-to-evict: true
。这提供了更好的稳定性。
在 ClickHouse Cloud 中,我们使用 EKS 集群自动扩缩器推荐的 过度配置 工作负载。我们创建具有相似资源需求但较低 PriorityClass 的工作负载。这种较低的优先级允许驱逐过度配置的 Pod 以支持 ClickHouse Pod。
我们注意到,当过度配置的 Pod 使用 default-scheduler
,而 ClickHouse Pod 使用自定义调度器时,抢占不起作用。因为每个调度器只抢占由自身调度的 Pod。在这种情况下,集群自动扩缩器也会错误地认为不需要横向扩展。为了解决这个问题并使事情更加一致,我们使过度配置的 Pod 也使用自定义调度器。
测试和推广
为了确保自定义调度器不会导致性能下降,我们还进行了一些 Pod 调度压力测试。我们创建了一个作业来持续创建要由自定义调度器调度的 Pod。然后,我们杀死了持有租约锁的自定义调度器 Pod。我们观察到其他调度器备用 Pod 迅速接管。Pod 调度没有受到显著影响,并且根据我们的测试,调度吞吐量也不是问题。
另一个潜在的风险是 Pod 冷启动时间的增加。现在集群更加密集,理论上,创建新实例更可能触发节点组横向扩展以调度待处理的 Pod。为了观察这一点,我们测量了 P90 和 P99 冷启动时间以检查影响。我们验证了这种影响可以忽略不计。这可能是由于用于容纳 ClickHouse 服务的节点配置不够频繁,以至于没有产生重大影响。
在最终推广时,我们仍然谨慎行事
- 我们按区域逐步推广调度器更改,并从几个小区域开始。
- 在单个区域内,我们首先仅更新较小的 ClickHouse 实例,然后再逐渐将此调度器应用于集群中的所有实例。
- 一旦 Pod 规范使用此自定义调度器更新了 ‘schedulerName’ 字段,正在运行的 Clickhouse Pod 将使用新的调度器重新调度。由于我们已经为有状态集配置了 Pod 中断预算以及优雅关闭(以避免中断正在运行的查询),因此当调度器更改时,我们的客户实例没有遇到任何中断。在此一次性重新调度之后,正在运行的 Pod 将永远不会再次受到调度器更改的影响,这在某种程度上满足了先前解决方案无法满足的要求 #3。
推广后的集群利用率
作为这些更改的直接结果,集群利用率提高到 70%。
我们注意到节点数量减少了约 10%,其中最大的节省来自清理了一些大型 24xl 节点。加上大型节点的减少,我们在 EC2 成本方面实现了超过 20% 的降幅。
最后,我们还与我们的 AWS 成本和使用情况报告 进行了交叉引用,该报告证实了类似的节省金额。
结论
总而言之,我们通过将 Kubernetes 调度器评分策略更改为 MostAllocated
,显著降低了我们的 EKS 基础设施成本。我们通过在 EKS 集群中设置自定义调度器(具有 MostAllocated
评分策略的 kube-scheduler 镜像)来实现这一点。这种方法很好地平衡了成本降低和维护客户工作负载的稳定性。我们还彻底注释了一些可抢占的系统工作负载,以确保节点可以及时回收。
对我们而言,此项目的成功之处在于实现的成本节省以及客户的可靠性或性能没有下降。
通过上述更改,我们能够将 EKS 集群资源利用率提高 20-30%,并在 EKS 集群中的 EC2 实例上实现相应的成本节省。