11 月 27 日晚滴滴发生了大范围、长时间的故障。官方消息说是“底层系统软件发生故障”,而据网上的小道消息,一个规模非常大的 K8s 集群进行在线热升级,因为某些原因,所有 Pod(容器)被 kill,而 K8s 的元数据已经被新版本 K8s 修改,无法回滚,因此恢复时间拉的很长。
从滴滴近期分享的技术文章来看,这个说法并不是空穴来风。滴滴团队近两个月正在把公司内部的 K8s 从 1.12 升级到 1.20,1.12 是 2018 年 9 月发布的,而 1.20 是 2020 年 12 月,对高速发展的 K8s 项目来说,两个版本存在相当大的差距。K8s 官方推荐的方法是沿着一个个版本升上去。但滴滴团队认为多次升级风险更高,采取了一把梭哈的策略,跨越 8 个版本一把升。而且为了避免中断业务,在不重启容器的情况下原地升级,滴滴团队还修改了 kubelet 的代码。
这个升级流程如果一切正常理论上是 work 的。我推测还是遇上了未考虑到的意外因素,比如运维误操作,造成了这次大规模的故障。从我的经验来说,遵循以下设计原则,可以极大的降低风险概率、减小故障范围:
控制规模,用多个小规模 K8s 集群的联邦代替一个大 K8s
先说我的⼀个经历。早年阿⾥云发起过⼀个 5K 项⽬,⽤ 5000 台物理机组成⼀个 ODPS(即后来的 MaxCompute)大集群来⽀持阿⾥内部的⼤数据业务,5K 项⽬在 13 年上半年进⼊攻坚阶段,我作为技术⻣⼲被抽调到这个项⽬⾥。ODPS 有⼀个组件叫⼥娲,类似 Hadoop 的 Name Node,提供名字解析、分布式锁等服务。当 5K 集群进⾏机房级掉电测试时,⼏千台服务器(其中有⼏万个服务)在重启后会向 3 台⼥娲服务器发起远程调⽤去解析彼此的地址,就像 DDoS 攻击⼀样,瞬时流量会持续打满千兆⽹卡,造成整个 5K 集群⼀波⼜⼀波的雪崩。
这段经历告诉我,当一个集群规模很大时,很容易在意想不到的地方发生类似的问题。因此,在设计系统时,我倾向于把集群的规模控制在⼀个合理的范围。例如把⼀个资源池的⼤⼩控制在⼏百台机器的规模,当需要更多资源时,不是去扩展单个资源池的⼤⼩,⽽是去新建资源池,扩展资源池的数量。换句话说,为了解决业务增长问题,不要 scale up 单个集群,⽽是 scale out 出更多的集群!
另一个要考虑的问题是爆炸半径。再举个例子,PolarDB 的底层存储组件叫 PolarStore,从外部来看,PolarStore 是⼀个可⽆限扩容的存储服务,但内部实现上,我将其设计成由一个个隔离开的⼩集群组成,每个集群⼏⼗到上百台服务器。这个设计 2017 年投入商用,虽然之后 PolarDB 的规模逐年激增,但 PolarStore 服务从来没出现过大范围的故障。K8s 集群也是⼀样。特别⼤规模的 K8s 集群,例如上万台的,其实都存在爆炸半径过⼤的的稳定性⻛险。与其不断优化与提⾼ K8s 的规模极限,不如梳理业务,把这些巨型 K8s 集群拆为多个⼤⼩适中的集群。
实际系统设计和开发中,我们可能会因为多种原因倾向于选择大集群。我听过的⼀个理由是为了避免跨 K8s 集群⽹络不连通问题,就⼲脆把所有 Pod 都塞到了⼀个 K8s 集群⾥。但⽹络连通性的问题还是应该由⽹络⽅案来解,比如这个问题可以通过负载均衡器(LB)暴露 Pod 地址,或给 Pod 额外分配⼀个 Underlay ⽹络地址(例如物理网络或者 VPC 网络的地址)来解决,而不应该将其和架构设计相耦合。KubeBlocks 的⽤户经常来咨询我们,⼀套 KubeBlocks 能不能管理⼏千或者上万个数据库实例。我们给出的答案是能,但我们推荐的最佳实践是采⽤多个 K8s 集群⽔平扩展的⽅法,每个 K8s 集群都是⼀个资源池,各⾃部署⼀套⾃治的 KubeBlocks,然后再把这些资源池注册到我们的中⼼管理集群⾥来,通过这种架构扩展到⼏⼗万个数据库实例数都不存在问题。这本质上是⼀种将多个 K8s 集群组成联邦的⽅案。
避免单点,一个 K8s 集群也应该被视作一个单点
架构师们一直都很小心谨慎的避免单点故障。服务要进行冗余部署,数据库要有主备。在机房内,网络要有双上联交换机,服务器除了市电供电,还要准备柴油发电机应急。在支付宝机房被挖断光纤后,大家又开始重视机房级别的高可用容灾,业务要做跨机房甚至是跨地域的部署。如果一个机房断网、断电,那流量要能快速的从故障机房切走,业务处理以及底层的存储都要切换到其他机房。比如,八年前我在设计 RDS 专有云双机房部署方案的时候,要考虑单机房灾难发生时,数据库的主备复制关系、负载均衡还有 IP 网段如何从主机房漂移到备机房,以及故障恢复时流量如何再切回主机房。
但 K8s 经常被架构师们视作是一个业已具有多机房容灾、高可用能力的分布式系统。从而忽视了把高可用业务部署在单 K8s 集群上的固有风险。K8s 是一个管理容器编排的系统软件,如同所有的软件系统,遇到非预期的事件,例如本次滴滴故障中跨多个版本的原地升级,也是有几率彻底挂掉的。这个时候,业务和存储系统在单 K8s 里纵使有再多的副本,也要歇菜。
因此我们建议架构师们在设计部署方案时,要把 K8s 视作存在单点风险的单元,一个 K8s 是一个部署单元,把过去多单元多活的技术方案移植到多 K8s 多活的场景下来。不仅是无状态的服务,服务所依赖的数据库的副本也要跨 K8s 部署。除了数据面,在控制面管理业务负载与数据库的 K8s operator 管理软件也需要做到多 K8s 多活。这类似于,在 RDS 系统里,除了数据库要做高可用,RDS 的管控系统必须要实现双活,否则故障发生时,自动化系统都失效了,所有操作都得人工执行,自然会增加故障的处理时长。这个能力我们会在 KubeBlocks 里支持上。
额外的好处是,如果你的业务部署在多个 K8s 集群中,那么 K8s 的升级策略可以更加灵活,可以通过逐个升级 K8s 集群,这样能够进一步降低升级过程中可能出现的风险。
拥抱重启,把重启和迁移视作常态
回到 K8s 的升级,K8s 官方推荐的方式是这样的,逐一地将每个节点上的 Pod 驱逐到其他节点上去,从集群中移除节点,升级,然后再将它重新加入到集群,这是一种滚动升级机制(Rolling)。而 AWS EKS 还支持一种蓝绿部署机制(Blue-Green),创建一个新的节点组,使用新的 K8s 版本,然后,将 Pod 从旧的节点组迁移到新的节点组,实现蓝绿部署,一旦所有的 Pod 都已经成功迁移到新的节点组,再可以删除旧的节点组。两种方法都需要迁移和重启 Pod。这次故障里,滴滴采用了非常规的 K8s 升级手段,其中一个重要的动机是避免 Pod 重启影响业务。这其实代表了一类 old-school 的服务器管理理念。
在 DevOps 中,"Pets vs Cattle" 是一个常用的比喻,用来描述两种不同的服务器管理策略。Pets(宠物,例如猫)代表的是那些我们精心照料和维护的服务器。当它们出现问题时,我们会尽一切可能去修复,而不是直接替换它们。每一个 Pet 都是独一无二的,有自己的名字,我们知道它们的性能,甚至它们的"性格"(例如,某个服务器可能会经常出现某种特定的问题)。过去工程师和运维们非常的厌恶服务器重启,以至于云厂商 ECS 团队的一个奋斗目标就是把虚拟机做到如小型机般的可靠,为此不断的改进虚拟机热迁移等技术。
Cattle(牲口,例如牛)代表的是那些我们可以随意新增或删除的服务器。我们不会对它们进行个别管理,而是将它们视为一个整体来管理。如果其中的任何一个出现问题或者变化,我们通常会选择直接替换,而不是修复。Cattle 的例子包括在云环境中运行的虚机,或者是在 Kubernetes 集群中运行的 Pod。甚至可以把 K8s 集群本身也视作 Cattle,如果 K8s 出现问题,或者是 K8s 集群要做版本升级,直接把这个 K8s 换掉,把老的 K8s 里的 Pod 直接迁移到新的 K8s 里。
把 Pod 看做 Pets,就会想尽一切办法来避免 Pod 重启。而把 Pod 看做 Cattle,就会换一个思维,把 Pod 的重启和迁移作为一个需求来设计系统。我建议工程师们采用后一种思维。不要害怕 Pod 重启和迁移,而是把处理 Pod 重启、迁移以及遇到问题回滚的代码视为系统的正常运行例程的一部分。在复杂系统设计中,期待并规划故障的发生,而不是试图阻止它们发生,通过定期升级系统,验证处理重启、迁移、回滚的代码,确保系统在面对重启和迁移这种常态时能够正常运作。把重启和迁移视为常态,而不是异常,这种思维方式能够帮助我们设计出更可靠、更健壮的系统。
因此,在 KubeBlocks 执行数据库大版本升级时,我们并不推荐原地升级,因为原地升级总有一天会踩到坑的。我们会新建一个高版本的数据库实例,通过全量和增量数据迁移将数据导入到新实例中,通过数据库代理层保持来自应用端的网络连接,降低在实例间切换对业务的影响。在升级完成后,我们还会保持老版本和新版本的数据库同时运行一段时间并且维持双向同步,在业务确认升级不造成非预期影响后再清理老版本的实例。
数据面的可用性和控制面要解耦
最后想说的一个经验是数据面的可用性要和控制面解耦。我先举两个例子,这两个都是存储系统,但是基于不同理念设计的:
第一个系统是 PolarDB 的存储系统 PolarStore。PolarStore 采取了控制面与数据面分离的理念(详情参考我在 VLDB 2018 年发表的"PolarFS"论文)。数据面的读写操作都仅依赖查询缓存在本地的全量元数据。控制面仅仅在执行管理操作,例如创建卷、卷的扩缩容、节点宕机发起数据迁移、集群扩缩容的时候需要修改元数据才会被强依赖。控制面对元数据的修改会通过元数据通知机制异步更新到数据面的缓存里。这个设计的优点是高度的可靠性,即使整个控制面不可用,在数据面读写文件都可以正常完成,这对数据库业务而言很重要。
而在另一个系统中,控制面与数据面是耦合的。这个系统有三个很重要的 Master 节点,Master 节点除了承担控制面的任务外,还承担了一部分数据面的职责。举个例子,在这个系统中,数据是以 Append only 的形式不断追加到一个日志流中,而日志流会按 64MB 分割为 chunk,每写满一批 chunk,数据面的节点就要找 Master 节点分配下一批新 chunk 的调度策略。这个设计有一个缺限,就是 Master 节点一旦宕机,整个存储集群很快就无法写入新数据。为了克服这个缺陷,Master 从三副本改为了五副本,同时 Master 还采用了 Sharding 的方案来提高吞吐能力。
我还想到第三个例子,前阵子阿里云的史诗级故障,对象存储的关键路径里依赖了 RAM 的鉴权逻辑,因此 RAM 出现故障时,也造成了对象存储的不可用。这几个存储例子告诉我们,数据面的可用性如果和控制面解耦,那么控制面挂掉对数据面的影响很轻微。否则,要么要不断去提高控制面的可用性,要么就要接受故障的级联发生。
KubeBlocks 也采取了控制面与数据面分离的设计,控制面包括 KubeBlocks operator、K8s API Server、Scheduler、Controller Manager 和 etcd 存储,它负责整个集群的管理,包括调度、资源分配、对象生命周期管理等功能。而数据面则是在 Pods 中运行的容器,包含各种数据库的 SQL 处理与数据存储组件。KubeBlocks 可以保证即使控制面的节点全部宕机,数据面仍然可用。而结合数据库内核、代理与负载均衡的协同,还可以进一步做到控制面失败,数据面仍然可以执行高可用切换。
结语
控制规模、避免单点、拥抱重启、数据面的可用性和控制面解耦。这些点是我过去十多年在设计 RDS 和 PolarDB 这样的大规模云服务时所重视的一些设计原则,这些原则可以帮助防御系统出现大规模故障。在开发 KubeBlocks 的过程中,我发现这些原则在 K8s 的场景下仍然是有效的,希望可以帮助架构师和工程师们设计更稳定的系统。
本文仅供学习!所有权归属原作者。侵删!文章来源: 云猿生聊技术 -曹伟(鸣嵩) :http://mp.weixin.qq.com/s?__biz=Mzg3MDk3ODA0Mg==&mid=2247488226&idx=1&sn=e029954f869dc030a14b36acf3e19de8&scene=21#wechat_redirect
文章评论