高效编排有状态应用——TiDB 的云原生实践与思考

做者:吴叶磊,PingCAP Cloud 工程师,毕业于浙江大学,热爱云原生与开源技术,开发并维护 kubectl-debug, aliyun-exporter 等开源项目,同时也是专一于云原生技术的博客做者,现负责 TiDB Operator 研发。曾负责酷家乐数据同步平台与容器监控系统的研发。git

导语

云原生时代以降,无状态应用以其天生的可替换性率先成为各种编排系统的宠儿。以 Kubernetes 为表明的编排系统可以充分利用云上的可编程基础设施,实现无状态应用的弹性伸缩与自动故障转移。这种基础能力的下沉无疑是对应用开发者生产力的又一次解放。 然而,在轻松地交付无状态应用时,咱们应当注意到,状态自己并无消失,而是按照各种最佳实践下推到了底层的数据库、对象存储等有状态应用上。那么,“负重前行”的有状态应用是否能充分利云与 Kubernetes 的潜力,复制无状态应用的成功呢?github

或许你已经知道,Operator 模式已经成为社区在 Kubernetes 上编排有状态应用的最佳实践,脚手架项目 KubeBuilder 和 operator-sdk 也已经愈发成熟,而对磁盘 IO 有严苛要求的数据库等应用所必须的 Local PV(本地持久卷)也已经在 1.14 中 GA。这些积木彷佛已经足够搭建出有状态应用在平稳运行在 Kubernetes 之上这一和谐景象。然而,书面上的最佳实践与生产环境之间还有无数工程细节造就的鸿沟,要在 Kubernetes 上可靠地运行有状态应用仍须要至关多的努力。下面我将以 TiDB 与 Kubernetes 的“爱恨情仇”为例,总结有状态应用走向云原生的工程最佳实践。web

TiDB 简介

首先让咱们先熟悉熟悉研究对象。TiDB 是一个分布式的关系型数据库,它采用了存储和计算分离的架构,而且分层十分清晰:数据库

1.png

<center>图 1 TiDB 架构</center>编程

其中 TiDB 是 SQL 计算层,TiDB 进程接收 SQL 请求,计算查询计划,再根据查询计划去查询存储层完成查询。api

存储层就是图中的 TiKV,TiKV 会将数据拆分为一个个小的数据块,好比一张 1000000 行的表,在 TiKV 中就有可能被拆分为 200 个 5000 行的数据块。这些数据块在 TiKV 中叫作 Region,而为了确保可用性, 每一个 Region 都对应一个 Raft Group,经过 Raft Log 复制实现每一个 Region 至少有三副本。网络

2.png

<center>图 2 TiKV Region 分布</center>架构

而 PD 则是集群的大脑,它接收 TiKV 进程上报的存储信息,并计算出整个集群中的 Region 分布。借由此,TiDB 便能经过 PD 获知该如何访问某块数据。更重要的是,PD 还会基于集群 Region 分布与负载状况进行数据调度。好比,将过大的 Region 拆分为两个小 Region,避免 Region 大小因为写入而无限扩张;将部分 Leader 或数据副本从负载较高的 TiKV 实例迁移到负载较低的 TiKV 实例上,以最大化集群性能。这引出了一个颇有趣的事实,也就是 TiKV 虽然是存储层,但它能够很是简单地进行水平伸缩。这有点意思对吧?在传统的存储中,假如咱们经过分片打散数据,那么加减节点数每每须要从新分片或手工迁移大量的数据。而在 TiKV 中,以 Region 为抽象的数据块迁移可以在 PD 的调度下彻底自动化地进行,而对于运维而言,只管加机器就好了。运维

了解有状态应用自己的架构与特性是进行编排的前提,好比经过前面的介绍咱们就能够概括出,TiDB 是无状态的,PD 和 TiKV 是有状态的,它们三者均能独立进行水平伸缩。咱们也能看到,TiDB 自己的设计就是云原生的——它的容错能力和水平伸缩能力可以充分发挥云基础设施提供的弹性,既然如此,云原生“操做系统” Kubernetes 不正是云原生数据库 TiDB 的最佳载体吗?TiDB Operator 应运而生。分布式

TiDB Operator 简介

Operator 你们都很熟悉了,目前几乎每一个开源的存储项目都有本身的 Operator,好比鼻祖 etcd-operator 以及后来的 prometheus-operator、postgres-operator。Operator 的灵感很简单,Kubernetes 自身就用 Deployment、DaemonSet 等 API 对象来记录用户的意图,并经过 control loop 控制集群状态向目标状态收敛,那么咱们固然也能够定义本身的 API 对象,记录自身领域中的特定意图,并经过自定义的 control loop 完成状态收敛。

在 Kubernetes 中,添加自定义 API 对象的最简单方式就是 CustomResourceDefinition(CRD),而添加自定义 control loop 的最简单方式则是部署一个自定义控制器。自定义控制器 + CRD 就是 Operator。具体到 TiDB 上,用户能够向 Kubernetes 提交一个 TidbCluster 对象来描述 TiDB 集群定义,假设咱们这里描述说“集群有 3 个 PD 节点、3 个 TiDB 节点和 3 个 TiKV 节点”,这是咱们的意图。 而 TiDB Operator 中的自定义控制器则会进行一系列的 Kubernetes 集群操做,好比分别建立 3 个 TiKV、TiDB、PD Pod,来让真实的集群符合咱们的意图。

3.png

<center>图 3 TiDB Operator</center>

TiDB Operator 的意义在于让 TiDB 可以无缝运行在 Kubernetes 上,而 Kubernetes 又为咱们抽象了基础设施。所以,TiDB Operator 也是 TiDB 多种产品形态的内核。对于但愿直接使用 TiDB Operator 的用户, TiDB Operator 能作到在既有 Kubernetes 集群或公有云上开箱即用;而对于不但愿有太大运维负载,又需求一套完整的分布式数据库解决方案的用户,咱们则提供了打包 Kubernetes 的 on-premise 部署解决方案,用户能够直接经过方案中打包的 GUI 操做 TiDB 集群,也能经过 OpenAPI 将集群管理能力接入到本身现有的 PaaS 平台中;另外,对于彻底不想运维数据库,只但愿购买 SQL 计算与存储能力的用户,咱们则基于 TiDB Operator 提供托管的 TiDB 服务,也即 DBaaS(Database as a Service)。

4.png

<center>图 4 TiDB Operator 的多种上层产品形态</center>

多样的产品形态对做为内核的 TiDB Operator 提出了更高的要求与挑战——事实上,因为数据资产的宝贵性和引入状态后带来的复杂性,有状态应用的可靠性要求与运维复杂度每每远高于无状态应用,这从 TiDB Operator 所面临的挑战中就可见一斑。

挑战

描绘架构老是让人以为美好,而生产中的实际挑战则将咱们拖回现实。

TiDB Operator 的最大挑战就是数据库的场景极其严苛,大量用户的期盼都是个人数据库可以“永不停机”,对于数据不一致或丢失更是零容忍。不少时候你们对于数据库等有状态应用的可用性要求甚至是高于承载线上服务的 Kubernetes 集群的,至少线上集群宕机还能补救,而数据一旦出问题,每每意味着巨大的损失和补救成本,甚至有可能“回天乏术”。这自己也会在很大程度上影响你们把有状态应用推上 Kubernetes 的信心。

第二个挑战是编排分布式系统这件事情自己的复杂性。Kubernetes 主导的 level driven 状态收敛模式虽然很好地解决了命令式编排在一致性、事务性上的种种问题,但它自己的心智模型是更为抽象的,咱们须要考虑每一种可能的状态并针对性地设计收敛策略,而最后的实际状态收敛路径是随着环境而变化的,咱们很难对整个过程进行准确的预测和验证。假如咱们不能有效地控制编排层面的复杂度,最后的结果就是没有人能拍胸脯保证 TiDB Operator 可以知足上面提到的严苛挑战,那么走向生产也就无从谈起了。

第三个挑战是存储。数据库对于磁盘和网络的 IO 性能至关敏感,而在 Kubernetes 上,最主流的各种网络存储很难知足 TiDB 对磁盘 IO 性能的要求。假如咱们使用本地存储,则不得不面对本地存储的易失性问题——磁盘故障或节点故障都会致使某块存储不可用,而这两种故障在分布式系统中是屡见不鲜。

最后的问题是,尽管 Kubernetes 成功抽象了基础设施的计算能力与存储能力,但在实际场景的成本优化上考虑得不多。对于公有云、私有云、裸金属等不一样的基础设施环境,TiDB Operator 须要更高级、特化的调度策略来作成本优化。你们也知道,成本优化是没有尽头的,而且每每伴随着一些牺牲,怎么找到优化过程当中边际收益最大化的点,一样也是很是有意思的问题之一。

其中,场景严苛能够做为一个前提条件,而针对性的成本优化则不够有普适性。咱们接下来就从编排和存储两块入手,从实际例子来看 TiDB 与 TiDB Operator 如何解决这些问题,并推广到通常的有状态应用上。

控制器——剪不断,理还乱

TiDB Operator 须要驱动集群向指望状态收敛,而最简单的驱动方式就是建立一组 Pod 来组成 TiDB 集群。经过直接操做 Pod,咱们能够自由地控制全部编排细节。举例来讲,咱们能够:

  • 经过替换 Pod 中容器的 image 字段完成原地升级。
  • 自由决定一组 Pod 的升级顺序。
  • 自由下线任意 Pod。

事实上咱们也确实采用过彻底操做 Pod 的方案,可是当真正推动该方案时咱们才发现,这种彻底“本身造轮子”的方案不只开发复杂,并且验证成本很是高。试想,为何你们对 Kubernetes 的接受度愈来愈高, 即便是传统上较为保守的公司如今也勇于拥抱 Kuberentes?除了 Kubernetes 自己项目素质过硬以外,更重要的是有整个社区为它背书。咱们知道 Kubernetes 已经在各类场景下经受过大量的生产环境考验,这种信心是各种测试手段都无法给到咱们的。回到 TiDB Operator 上,选择直接操做 Pod 就意味着咱们抛弃了社区在 StatefulSet、Deployment 等对象中沉淀的编排经验,随之带来的巨大验证成本大大影响了整个项目的开发效率。

所以,在目前的 TiDB Operator 项目中,你们能够看到控制器的主要操做对象是 StatefulSet。StatefulSet 可以知足有状态应用的大部分通用编排需求。固然,StatefulSet 为了作到通用化,作了不少没必要要的假设,好比高序号的 Pod 是隐式依赖低序号 Pod 的,这会给咱们带来一些额外的限制,好比:

  • 没法指定 Pod 进行下线缩容。
  • 滚动更新顺序固定。
  • 滚动更新须要后驱 Pod 所有 Ready。

StatefulSet 和 Pod 的抉择,最终是灵活性和可靠性的权衡,而在 TiDB 面临的严苛场景下,咱们只有先作到可靠,才能作开发、敢作开发。最后的选择天然就呼之欲出——StatefulSet。固然,这里并非说,使用基于高级对象进行编排的方案要比基于 Pod 进行编排的方案更好,只是说咱们在当时认为选择 StatefulSet 是一个更好的权衡。固然这个故事尚未结束,当咱们基于 StatefulSet 把初版 TiDB Operator 作稳定后,咱们正在接下来的版本中开发一个新的对象来水平替换 StatefulSet,这个对象可使用社区积累的 StatefulSet 测试用例进行验证,同时又能够解除上面提到的额外限制,给咱们提供更好的灵活性。 假如你也在考虑从零开始搭建一个 Operator,或许也能够参考“先基于成熟的原生对象快速迭代,在验证了价值后再加强或替换原生对象来解决高级需求”这条落地路径。

接下来的问题是控制器如何协调基础设施层的状态与应用层的状态。举个例子,在滚动升级 TiKV 时,每次重启 TiKV 实例前,都要先驱逐该实例上的全部 Region Leader;而在缩容 TiKV 时,则要先在 PD 中将待缩容的 TiKV 下线,等待待缩容的 TiKV 实例上的 Region 所有迁移走,PD 认为 TiKV 下线完成时,再真正执行缩容操做调整 Pod 个数。这些都是在编排中协调应用层状态的例子,咱们能够怎么作自动化呢?

你们也注意到了,上面的例子都和 Pod 下线挂钩,所以一个简单的方案就经过 container lifecycle hook,在 preStop 时执行一个脚本进行协调。这个方案碰到的第一个问题是缺少全局信息,脚本中没法区分当前是在滚动升级仍是缩容。固然,这能够经过在脚本中查询 apiserver 来绕过。更大的问题是 preStop hook 存在 grace period,kubelet 最多等待 .spec.terminationGracePeriodSeconds 这么长的时间,就会强制删除 Pod。对于 TiDB 的场景而言,咱们更但愿在自动的下线逻辑失败时进行等待并报警,通知运维人员介入,以便于最小化影响,所以基于 container hook 来作是不可接受的。

第二种方案是在控制循环中来协调应用层的状态。好比,咱们能够经过 partition 字段来控制 StatefulSet 升级进度,并在升级前确保 leader 迁移完毕,以下图所示:

5.png

<center>图 5 在控制循环中协调状态</center>

在伪代码中,每次咱们由于要将全部 Pod 收敛到新版本而进入这段控制逻辑时,都会先检查下一个要待升级的 TiKV 实例上 leader 是否迁移完毕,直到迁移完毕才会继续往下走,调整 partition 参数,开始升级对应的 TiKV 实例。缩容也是相似的逻辑。但你可能已经意识到,缩容和滚动更新两个操做是有可能同时出如今状态收敛的过程当中的,也就是同时修改 replicas 和 image 字段。这时候因为控制器须要区分缩容与滚动更新,诸如此类的边界条件会让控制器愈来愈复杂。

第三种方案是使用 Kubernetes 的 Admission Webhook 将一部分协调逻辑从控制器中拆出来,放到更纯粹的切面当中。针对这个例子,咱们能够拦截 Pod 的 Delete 请求和针对上层对象的 Update 请求,检查缩容或滚动升级的前置条件,假如不知足,则拒绝请求并触发指令进行协调,好比驱逐 leader,假如知足,那么就放行请求。控制循环会不断下发指令直到状态收敛,所以 webhook 就相应地会不断进行检查直到条件知足,以下图所示:

6.png

<center>图 6 在 Webhook 中协调状态</center>

这种方案的好处是咱们把逻辑拆分到了一个与控制器垂直的单元中,从而能够更容易地编写业务代码和单元测试。固然,这个方案也有缺点,一是引入了新的错误模式,处理 webhook 的 server 假如宕机,会形成集群功能降级;二是该方案适用面并不广,只能用于状态协调与特定的 Kubernetes API 操做强相关的场景。在实际的代码实践中,咱们会按照具体场景选择方案二或方案三,你们也能够到项目中一探究竟。

上面的两个例子都是关于如何控制编排逻辑复杂度的,关于 Operator 的各种科普文中都会用一句“在自定义控制器中编写领域特定的运维知识”将这一部分轻描淡写地一笔带过,而咱们的实践告诉咱们,真正编写生产级 的自定义控制器充满挑战与抉择。

Local PV —— 想说爱你不容易

接下来是存储的问题。咱们不妨看看 Kubernetes 为咱们提供了哪些存储方案:

7.png

<center>图 7 存储方案</center>

其中,本地临时存储中的数据会随着 Pod 被删除而清空,所以不适用于持久存储。

远程存储则面临两个问题:

  • 一般来讲,远程存储的性能较差,这尤为体如今 IOPS 不够稳定上,所以对于磁盘性能有严格要求的有状态应用,大多数远程存储是不适用的。
  • 一般来讲,远程存储自己会作三副本,所以单位成本较高,这对于在存储层已经实现三副本的 TiDB 来讲是没必要要的成本开销。

所以,最适用于 TiDB 的是本地持久存储。这其中,hostPath 的生命周期又不被 Kubernetes 管理,须要付出额外的维护成本,最终的选项就只剩下了 Local PV。

Local PV 并不是免费的午饭,全部的文档都会告诉咱们 Local PV 有如下限制:

  • 数据易失(相比于远程存储的三副本)。
  • 节点故障会影响数据访问。
  • 难以垂直扩展容量(至关一部分远程存储能够直接调整 volume 大小)。

这些问题一样也是在传统的虚拟机运维场景下的痛点,所以 TiDB 自己设计就充分考虑了这些问题:

  • 本地存储的易失性要求应用自身实现数据冗余。

    • TiDB 的存储层 TiKV 默认就为每一个 Region 维护至少三副本。
    • 当副本缺失时,TiKV 能自动补齐副本数。
  • 节点故障会影响本地存储的数据访问。

    • 节点故障后,相关 Region 会从新进行 leader 选举,将读写自动迁移到健康节点上。
  • 本地存储的容量难以垂直扩展。

    • TiKV 的自动数据切分与调度可以实现水平伸缩。

存储层的这些关键特性是 TiDB 高效使用 Local PV 的前提条件,也是 TiDB 水平伸缩的关键所在。固然,在发生节点故障或磁盘故障时,因为旧 Pod 没法正常运行,咱们须要自定义控制器帮助咱们进行恢复,及时补齐实例数,确保有足够的健康实例来提供整个集群所需的存储空间、计算能力与 IO 能力。这也就是自动故障转移。

咱们先看一看为何 TiDB 的存储层不能像无状态应用或者使用远程存储的 Pod 那样自动进行故障转移。假设下图中的节点发生了故障,因为 TiKV-1 绑定了节点上的 PV,只能运行在该节点上,所以 在节点恢复前,TiKV-1 将一直处于 Pending 状态:

8.png

<center>图 8 节点故障</center>

此时,假如咱们可以确认 Node 已经宕机而且短时间没法恢复,那么就能够删除 Node 对象(好比 NodeController 在公有上会查询公有云的 API 来删除已经释放的 Node)。此时,控制器经过 Node 对象不存在这一事实理解了 Node 已经没法恢复,就能够直接删除 pvc-1 来解绑 PV,并强制删除 TiKV-1,最终让 TiKV-1 调度到其它节点上。固然,咱们同时也要作应用层状态的协调,也就是先在 PD 中下线 TiKV-1,再将新的 TiKV-1 做为一个新成员加入集群,此时,PD 就会通知 TiKV-1 建立 Region 副原本补齐集群中的 Region 副本数。

9.png

<center>图 9 可以肯定节点状态时的故障转移</center>

固然,更多的状况下,咱们是没法在自定义控制器中肯定节点状态的,此时就很难针对性地进行原地恢复,所以咱们经过向集群中添加新 Pod 来进行故障转移:

10.png

<center>图 10 没法肯定节点状态时的故障转移</center>

上面讲的是 TiDB 特有的故障转移策略,但其实能够类推到大部分的有状态应用上。好比对于 MySQL 的 slave,咱们一样能够经过新增 slave 来作 failover,而在 failover 时,咱们一样也要作应用层的一些事情, 好比说去 S3 上拉一个全量备份,再经过 binlog 把增量数据补上,当 lag 达到可接受的程度以后开始对外提供读服务。所以你们就能够发现,对于有状态应用的 failover 策略是共通的,也都须要应用自己支持某种 failover 形式。好比对于 MySQL 的 master,咱们只能经过 M-M 模式作必定程度上的 failover,并且还会损失数据一致性。这固然不是 Kubernetes 或云原生自己有什么问题,而是说 Kubernetes 只是改变了应用的运维模式,但并不能影响应用自己的架构特性。假如应用自己的设计就不是云原生的,那只能从应用自己去解决。

总结

经过 TiDB Operator 的实践,咱们有如下几条总结:

  • Operator 自己的复杂度不可忽视。
  • Local PV 能知足高 IO 性能需求,代价则是编排上额外的复杂度。
  • 应用自己必须迈向云原生(meets kubernetes part way)。

最后,言语的描述老是不如代码自己来得简洁有力,TiDB Operator 是一个彻底开源的项目,眼见为实,你们能够尽情到 项目仓库 中拍砖,也欢迎你们加入社区一块儿玩起来,期待你的 issue 和 PR!

假如你对于文章有任何问题或建议,或是想直接加入 PingCAP 鼓捣相关项目,欢迎经过个人邮箱 wuyelei@pingcap.com 联系我。

本文为吴叶磊在 2019 QCon 全球软件开发大会(上海)上的专题演讲实录,Slides 下载地址

PingCAP 官方公众号

相关文章
相关标签/搜索