大家下午好,我是来自 ApeCloud 的吴学强,非常高兴能够在 KubeCon 做分享。今天的分享由我和来自快手的刘裕惺同学共同完成,我们分享的主题是将大规模的 Redis 实例从裸机迁移到 Kubernetes 上来提高资源的利用率。
我们今天的议题包括几个方面,首先我会来简单介绍一下 KubeBlocks 项目。第二部分我会从单个 Redis 集群的视角来讲一下 KubeBlocks 解决了哪些问题,从而能够让包括 Redis 在内的各种数据库可以更好地跑在 K8s 上面。接下来裕惺会站在大规模的视角来讲一下,分享多个 Redis 集群跑在多个 K8s 集群上的一些经验,最后是一个简单的总结。
什么是 KubeBlocks?
首先我们来了解一下 KubeBlocks 这个项目。我们是一家创业公司,团队的大部分小伙伴都来自于阿里云数据库,这意味着我们团队有非常多的数据库开发和运维经验。两年前我们开始创业,我们选择的创业方向是开发一些产品,能够使数据库更好地运行在 K8s 上。
去年我们开源了 KubeBlocks 项目,简单来说,它是 K8s 上的一个 Operator,跟其他数据库 Operator 不一样的地方在于,KubeBlocks 可以同时支持非常多类型的数据库。可以看到目前 KubeBlocks 已经支持了 35 种数据库。
为了达到这样的一个目标,在设计 KubeBlocks 的 API 或叫 CRD 时,我们将它们分成了两类,第一类面向数据库提供者 ,我们称之为 Add-on Provider API,通过这些 API,可以很容易地将新的数据库接入到 KubeBlocks 系统当中。
第二类是面向数据库的使用者和运维者 的,我们称之为 Developer&DevOps API,通过这些 API 可以跟数据库进行交互或者运维。我们提供了声明式 API,所以跟 Kubernetes 生态里面其他的一些工具或者系统可以很好地进行对接,比如说 kubectl、Argo CD、Terraform 等等。
KubeBlocks 解决了什么问题:单个 Redis 集群视角
对 KubeBlocks 有一个总体的了解之后,我们现在来看一下,站在单个 Redis 集群的视角, KubeBlocks 都解决了哪些问题。
如何处理数据复制(Replication)
我们都知道将数据库跑在 K8s 上面是非常有挑战的一件事情。这里面要解决的一个基础的问题就是所谓的数据复制。
比如这张图里面最左边的这张图所示,我们的系统当中使用了 Redis 数据库,如果 Redis 是一个单实例在运行,也就是说它跑在了一个 StatefulSet 里,它的 Replicas 是 1,那么这样的系统就会有非常多的问题。
第一个问题是这显然是一个单点,很容易发生单点故障 。第二个问题是这样的一个单点它的吞吐量是有限的 。此外,它的数据丢失风险非常高。
为了解决这些问题, 我们可以通过 StatefulSet 做水平扩容,扩展出更多的副本,所以我们可以将 Redis 从一个副本扩展到三个副本。那么这里就有问题了,在数据库当中,数据副本并不是独立的,它们之间是有数据的相互复制关系的。那么这会引入一些新的问题需要去考虑。
第一个问题是我们怎么样让 Service 能够找到主节点。我们都知道在 Redis 的主从架构当中只有主节点才具备写入的能力。第二个问题是我们在做扩容的时候,新增加的节点如何找到主节点,以便正确的搭建复制关系。第三个是在对 Redis 集群做小版本升级的时候,我们怎么样设计我们的升级策略,才能够使整个系统的不可用时长降到最低。除此之外,还有其他的一系列问题需要去考虑。
我们可以看到 K8s 原生的 StatefulSet 并不能很好地解决这些问题,所以在 KubeBlocks 里,我们新引入了一个专门为数据库设计的 AP,我们称之为 InstanceSet。
跟 StatefulSet 相同的地方在于,InstanceSet 为每一个 Pod 都分配了一个唯一的、固定的网络标识 ,不一样的地方在于,InstanceSet 里的每一个Pod 并不是独立的,它们是有角色的,这个角色是怎么来的呢?
InstanceSet 在创建每一个 Pod 的时候,会植入一个叫做 kbagent 的 Sidecar ,它会周期性地探测主容器,也就是我们的 DB 进程,当前的角色是什么,也就是在复制关系里面的角色是什么。探测到这个角色之后会最终更新到 Pod 的 Label 上面, 这个 Label 我们称之为角色 Label。
通过这样的方式,我们给每个 Pod 都增加了一个角色的 Label,它跟这个 Pod 里面所运行的数据库实例的角色是保持实时同步的。每个 Pod 有了这样一个角色的 Label 之后,我们就可以实现很多功能。
首先,我们可以在 Service Selector 中增加一个角色 Label,并且它的值设为 Master,从而自动选择到 Redis 集群主节点。
然后,在做 Redis 集群水平扩容时,我们可以通过角色 Label 找到 Master 实例,从而正确搭建新增实例与主实例直接的复制关系。
同时,有了角色之后,我们可以根据角色来设置我们的更新的策略。比如按照数据库运维的最佳实践,在升级 Redis 集群时,我们应该先把从库进行升级,然后再升级主库,在升级主库之前先做一次 switchover,就像下面这张图里描述的、基于角色的 UpdateStrategy 一样。
通过这些设计,我们就可以很好的来管理好数据的复制关系,同时可以让 Redis 集群的可用性得到一个比较大的提升。
如何实现高可用(HA)
说到可用性,这里就引入了第二个话题,数据库里面大家都非常关心的高可用能力。
数据库的高可用涉及的维度非常多,我们这里重点从控制平面视角讲解。除了基于角色的更新策略之外,这里进一步介绍跟高可用相关的另外两个特性。
第一个特性我们称之为实例原地更新(in-place update)。 K8s 当中 Pod 的原地更新、PVC 的 Volume Resizing 都有非常多的讨论和资料。那么在 InstanceSet 当中我们也复用了 Pod 和 PVC 相关的能力。
除此之外更重要的是,我们都知道数据库有非常多的配置,很多配置都需要经常修改,如果每次修改配置后都会数据库进行重启的话,会对服务可用性造成比较大的影响,所以在 InstanceSet 中进一步设计了配置的原地更新能力。
具体来说我们将数据库的配置项分成三类,第一类我们称之为 Immutable ,也就是一旦设置就不可改变的。第二类我们称之为 Static , Static 的参数它可以改变,但是需要重启 DB 进程或者 Container。然后第三类我们称之为 Dynamic,这类参数的修改可以通过数据库 DB 原生支持的类似于 Reload 这样的指令热加载。通过这样的方式,InstanceSet 实现了配置的原地更新。这样的特性有了之后,就可以进一步降低 Redis 集群 的 RTO 或者提高服务的可用时长。
第二个特性我们称之为实例重搭。我们都知道仍然有非常多的数据库是基于 LocalPV 运行的。如这张图对左边所示,我们现在有一个一主两从的 Redis 集群,它们运行在X、Y、Z 这三个 Node 上,某一时刻 node-y 因为磁盘故障导致必须被下线修复。这个时候就可以用 InstanceSet 提供的指定节点下线的能力,将 node-y 上的 pod-1 和 pvc-1 进行下线,使系统进入降级状态。接下来我们在一个新的 node-p 上,把 pod-1 和 pvc-1 再建立出来,同时我们重搭一个新的 PV,并且保证新的 PV 里面的数据跟原来 node-y上面 PV 里面的数据是一样的,之后进一步修复新的 pod-1 跟其它 Redis 节点之间的复制关系,从而使整个集群恢复正常。
如何管理单个大规模集群
聊完高可用相关的话题,我们再来聊一下我这部分的最后一个话题,即 Operater P10K 问题。
在我们快手的应用场景当中有这样一个非常极端的情况,单个 Redis 集群包含了接近 1 万个 Pod。也就意味着在 KubeBlocks 中,一个 InstanceSet 的 CR 包含了超过 10K 二级资源,这对 Controller 的调谐过程产生非常大的影响。
所以针对这样的一个场景,我们对 InstanceSet Controller 做了一些参数调优和设计。
首先调优的部分是,我们将 InstanceSet Controller 所在的 Pod 的 CPU 和 Memory 等资源调大,以便能够缓存更多的对象。进一步我们将 InstanceSet Controller 跟 API Server 之间的流控上限调高,主要是 ClientQPS 和 ClientBurst 这两个指标。之后再将 InstanceSet CR 里面比较大的字段进行压缩,从而使它不超过 K8s 对象大小限制。最后一个是,我们将调谐的 goroutine 数量从一个改为多个。
这是我们关于调优的部分。第二部分是我们对 InstanceSet 调谐过程的设计。
我们将 InstanceSet Controller 的调谐过程设计成了两阶段,即 Prepare 和 Commit 两个阶段。在 Prepare 阶段我们会通过对比 InstanceSet CR 里面的期望状态,以及系统当前的状态来计算出哪些二级资源需要创建、更新或删除,然后在 Commit 阶段一次性提交给 API Server。这样做的好处是,通常二级资源的更新会触发后续新的调谐,那么集中的提交可以使后续的调谐合并为一次,这样可以减少总的调谐次数。
第二点是,我们在 Prepare 阶段借鉴了函数式编程的思想,将每一个调谐步骤都设计为一个确定性的函数,并在 Commit 阶段保证每一个操作的幂等性。通过这两者的配合,我们解决了 Stale Cache 引发的一些问题。
第三点是,我们将 Prepare 阶段那些比较重的操作进行异步化处理,来进一步提高调谐的效率。
经过这些优化之后,我们便可以很好的来处理 P10K 问题了。
最后我们再来一个小结,我们通过 InstanceSet 提供的角色相关特性来管理 Redis Server 的主备复制关系,通过实例原地更新、实例重搭等来提高 Redis Server 可用性。最后通过 P10K 问题优化来解决单集群大规模 Pod 这样的极端场景。
下面我们欢迎裕惺来进一步讲一下,将多个 Redis 集群群跑在多个 K8s 上的一些经验。
快手如何应对多 Redis 集群和多 K8s 集群的挑战
接下来,我们将分享快手是如何使用 KubeBlocks 实现在多个 K8s 集群上运行多个 Redis 集群实例的经验。
快手中的 Redis
在深入探讨细节之前,我们先了解一些背景信息。快手的 Redis 采用的是经典的主从架构,包含三个组件(Server、Sentinel 和 Proxy)。极大规模是快手 Redis 集群的一个明显特征,不仅体现在实例总数上,还体现在单个集群的规模上,单个集群规模甚至可以超过 1 万个实例。
在快手中,Redis 已经稳定运行,支撑了如此大的规模的时候,为什么我们会愿意来折腾,推进 Redis 的云原生化呢?因为我们发现快手 Redis 资源利用率相对不高,而对于如此大规模的系统,即使进行一点小小的优化,也会带来巨大的收益。
那提升资源利用率一般有哪些好的方式呢?其实我们发现云原生技术已经给我们提供了最短路径与最佳实践。此外,容器云已经成为业务与基础设施之间的新接口,而且快手的无状态服务大部分已经迁移到了容器云中。从长远来看,基础设施的统一是不可避免的趋势。这不仅可以将业务与基础设施解耦,还能提高业务的敏捷性,降低基础设施的运营成本。
因此,我们决定进行云原生转型,从而实现成本优化。为了更好地完成这项工作,我们不仅邀请了 Redis 团队,还邀请了云原生团队参与其中。
为什么选择 KubeBlocks?
事实上,今天我们能在这里交流,已经表明我们是通过 KubeBlocks 来实现的。
接下来,我想和大家分享为什么我们选择了 KubeBlocks?简单来说,KubeBlocks 提供了面向有状态服务的 API。
那么,什么是有状态服务?有状态服务和无状态服务之间的关键区别是什么呢?
根据字面意思,区别似乎在于是否具有状态信息。然而,我们认为真正的区别在于实例之间的不平等关系 。正是因为不同的实例扮演着不同的角色并存储不同的数据,因此我们不能随意丢弃任何实例。此外,这种不平等关系并不是静态的,它在运行时很可能发生变化,比如发生主备切换。KubeBlocks 正是为了解决这个问题而设计的,它提供了基于角色的管理能力。
更进一步说,KubeBlocks 支持多种数据库,因此不需要为每个数据库部署一个专用的 Operator 。此外,还有一个非常有趣的点是,KubeBlocks 通过 OpsRequest 提供了面向过程的 API ,使得数据库的云原生化改造更加容易。如果你感兴趣,可以通过官方文档了解更多详细信息。
Redis 集群编排定义
我们已经讨论了很多关于 KubeBlocks API 的内容,那么 KubeBlocks 究竟是如何定义 Redis 集群的呢?它应该包含所有的组件(即 Server/Sentinel/Proxy)。
为了减少组件定义的重复,KubeBlocks 将组件定义(Component Definition)和组件版本(Component Version)独立开来,这样在创建新集群时可以直接引用。这里我们以 Redis Server 为例,这是最复杂的组件形态。
-
首先,Redis Server 是通过 ShardSpec 定义的,它包含了多个分片列表,以支持更大规模的数据。对于一个 Redis Server 集群来说,它需要被分割成多个分片,每个分片都有主从实例。
-
另一个关键点是,同一分片内的主从实例可能有不同的配置。为了处理这一点,KubeBlocks 允许用户使用 InstanceTemplate(实例模板)在同一组件内定义多种配置。
通过 Redis Server 组件示例,我们可以看到对象关系分为以下几个层次:
-
Cluster - 用于定义整个 Redis 集群。
-
ShardSpec - 用于定义 Redis Server 分片的列表。
-
Component - 用于定义 Redis Proxy、Sentinel 和单个 Server 分片。
-
InstanceTemplate - 用于定义同一组件内的不同配置。
-
InstanceSet - 它是最终自动生成的工作负载,提供了我们之前提到的基于角色的管理能力。
角色(Role)管理
关于基于角色的管理能力,可以总结为两个关键点:
- 构建并维护正确的关系。
- 实现细粒度的基于角色的管理,关于这一点,学强已经为我们做了介绍。
我们重点关注如何维护正确的角色关系,有一件重要的事情需要注意。我们认为,单个分片的主节点信息非常重要。如果发生错误或无法获取,将会导致严重的问题。因此,为了确保业务的稳定性,我们倾向于在此处分离数据面(data plane)和控制面(control plane)。这就是为什么我们不从 KubeBlocks 获取主节点信息的原因。
部署架构
我们已经看到,KubeBlocks 在单个 Kubernetes 集群上运行有状态服务时表现得非常出色。然而,正如之前提到的,快手的 Redis 数量非常庞大,远远超出了单个 Kubernetes 集群的容量。因此,我们不得不使用多个 Kubernetes 集群来支持业务。
关于多集群的管理,如果我们将所集群的复杂性直接暴露给 Redis 业务,将会带来以下几个问题:
- Redis 团队需要为所有 Kubernetes 集群维护缓冲资源池,这意味着更多的资源浪费。
- Redis 团队必须在单个 Kubernetes 集群达到上限之前提前迁移 Redis 集群。
我们认为,最好隐藏多集群的复杂性。而且,我们已经通过联邦集群提供了所需的能力。不过 KubeBlocks 本身并不支持多集群。
我们是如何解决这个问题的呢?以下是整体架构。
我们将 KubeBlocks operator 拆分为两部分。Cluster Operator 和 Component Operator 放置在联邦集群中,而 InstanceSet Controller 放置在成员集群中 。在中间,有一个名为 Federal InstanceSet 控制器的组件,用于将 InstanceSet 对象从联邦集群分发到成员集群。那么,Federal InstanceSet 控制器是如何工作的呢?
- 首先,它的首要职责是根据调度建议,决策每个集群应该部署多少个实例。
- 其次,它的主要任务是拆分 InstanceSet 并将其分发到成员集群。
与 StatefulSet 类似,InstanceSet 中的实例也有编号名称。为了确保不打破这一规则,我们重新设计了 InstanceSet 中的 ordinals 字段,允许自定义编号范围。
通过这种架构,我们能够在不做重大修改的情况下支持 KubeBlocks 在多个 Kubernetes 集群中运行。
稳定性保障
除了在功能上满足业务需求外,我们还需要确保解决方案能够达到生产级别,特别是在稳定性方面的保障。我们在这里讨论几个关键点。
首先,在调度能力方面,为了确保 Redis 的高可用性,我们应该确保实例尽可能地分散,但同时也要考虑单台机器故障对 Redis 集群规模的影响。因此,我们定制了一种细粒度的分散调度能力,既支持配置每个节点的最大实例数,又支持配置每个 Redis 集群的最大节点数。
我们还提供了基于 CPU/内存/网络带宽的负载均衡调度能力。
接下来,我们来看一下运行时控制。我们都喜欢 Kubernetes 带来的自动化,但这也意味着更大的风险:一个小的变动可能导致大规模的故障。因此,我们对运行中的实例进行了大量控制,比如并发控制、仅允许原地更新等。此外,还有许多其他的工作。由于时间限制,我不会一一列举。
总结
最后,让我们做一个简单的总结。我相信大家已经意识到,KubeBlocks 是一个出色的项目。与 StatefulSet 相比,KubeBlocks 为有状态服务设计了全新的 API,并提供了基于角色的管理,这些设计使得有状态业务的云原生转型更加容易。
看起来,KubeBlocks API 几乎可以支持所有的有状态服务。不过,我认为仍然有一些工作需要完成:
- 如何与现有的数据库 Operator 建立连接,并对齐它们的功能。
- 尝试推动有状态服务标准 API 的构建,快手也愿意与 KubeBlocks 一起在这个领域努力。也欢迎大家一起在这个领域做出更多的探索。
迄今为止,快手已经在许多功能上与 KubeBlocks 展开了合作,如 InstanceSet 直管 Pod 和 PVC 、实例模板 、与联邦集群集成等等。