1、为什么Kubernetes这么难?
Anthropic在Kubernetes内运行我们的大部分系统,因此我对该工具积累了更多的经验,对其也更加熟悉。虽然在它真的很棒,但我也确实经历了(大家会普遍经历的)其复杂性和调试的超高难度。
虽然在学习新系统时,这些感觉相当普遍,但Kubernetes确实比我使用过的其他一些系统感觉更大、更可怕、更棘手。在学习并使用它的过程中,我试图理解为什么它看起来是这样的,以及哪些设计决策和权衡导致它成为现在这样。这篇文章尝试写出两种特定的想法,并会解释为什么与Kubernetes一起工作有时会感到棘手。
2、Kubernetes是一个集群操作系统
大家很容易将Kubernetes视为部署容器化应用程序或一些类似功能描述的系统。虽然这可能是一个有用的视角,但我认为将Kubernetes视为通用集群操作系统内核会更合理。这两者之间有何区别?
传统操作系统的工作是使用一台计算机及其所有附属硬件,并公开程序可用于访问该硬件的接口。虽然确切的细节各不相同,但通常这个界面有以下几个目标:
1)资源共享——我们希望将一台物理计算机的资源细分到多个程序中,以便在某种程度上相互隔离。
2)可移植性——我们希望在某种程度上抽象底层硬件的精确细节,以便同一程序可以在不同的硬件上运行,而无需修改或仅进行小幅修改。
3)通用性——当我们想出新型硬件或将新硬件插入计算机时,我们希望能够以增量的方式将这些硬件放入我们的抽象和接口中,最好不要大幅更改任何接口或破坏任何不使用该硬件的现有软件。
4)整体性——与通用性相关,我们希望操作系统调解对硬件的所有访问:软件几乎不可能完全绕过操作系统内核。软件可以使用操作系统内核设置与硬件的直接连接,以便未来的交互直接发生(例如设置内存映射的命令管道),但初始分配和配置仍在操作系统的保护下。
5)性能——与“直接编写一个专用软件,它直接运行在硬件上,并且对硬件有独占的直接访问权”相比,我们希望为拥有这种抽象支付可接受的小性能成本。在某些情况下,我们希望通过提供I/O调度器或缓存层等优化,在实践中实现比此类系统更高的性能。
虽然“编程的便捷性”通常是一个额外的目标,但在实践中,它往往因为上述担忧而被忽视。操作系统内核通常围绕上述目标进行设计,然后编写用户空间库,将低级、通用、高性能的接口封装到更易于使用的抽象中。操作系统开发人员更关心“在我的操作系统上运行nginx有多快”,而不是“nginx端口到我的操作系统的代码能短多少行?”
我认为Kubernetes在一个非常相似的设计空间中运行;然而,它的目标不是抽象单个计算机,而是抽象整个数据中心或云,或其中的很大一部分。
这种观点有帮助的原因是,这个问题比“使在容器中部署HTTP应用程序成为可能”更困难,也更普遍,它指出了Kubernetes如此灵活的具体原因。Kubernetes渴望足够通用和强大,可以在任何类型的硬件(或虚拟机实例)上部署任何类型的应用程序,而无需“绕过”或“跳开”Kubernetes接口。
我不会试图在这里就它是否实现了这个目标(或者,它在实践中是否实现了这个目标)发表意见;只需将它视为一个要解决的问题,就能理解所遇到的许多设计决策,这样的视角是可行的。
从这个角度来看,Kubernetes的可插拔性和可配置性可能是最大的设计选择。一般来说,不可能做出对所有人都适用的选择,特别是你希望在没有高昂的性能成本的情况下做出选择。特别是在现代云环境中,部署的应用程序类型和硬件类型差异很大,是变化速度非常快的目标。因此,想成为所有人的万能工具,你最终需要高度可配置性,这最终会创建一个强大的系统,但这个系统可能很难理解,甚至使“简单”任务变得复杂。
当然,还有另一个视角:
许多用户认为Kubernetes本质上是“Heroku”,也就是说,Kubernetes本质上是一个部署应用程序的平台,抽象了大多数传统的底层操作系统和分布式系统的细节。
Kubernetes认为自己解决了更接近“CloudFormation”的问题——在某种意义上,它希望足以定义您的整个基础设施—它也试图以比底层云提供商或硬件通用的方式做到这一点。
3、Kubernetes中的所有内容都是一个控制循环
大家可以想象一个十分必要的“集群操作系统”,上文中,它暴露了“分5个CPU的计算能力”或“创建新的虚拟网络”等原语,这些原语反过来又支持系统内部抽象中的配置更改或对EC2 API(或其他底层云提供商)的调用。
Kubernetes作为核心设计决策,并不是这样工作的。相反,Kubernetes做出了核心设计决策,即所有配置都是声明性的,所有配置都是通过作为控制回路的“操作员”实现的:他们不断将所需的配置与现实状态进行比较,然后试图采取行动使现实与所需的状态保持一致。
这是一个非常深思熟虑的设计选择,而且是有充分理由的。一般来说,任何不是设计为控制回路的系统都会不可避免地偏离预期的配置,因此,在规模上,需要有人编写控制回路。通过内部化它们,Kubernetes希望允许大多数核心控制循环只编写一次,并由领域专家编写,从而更容易在它们之上构建可靠的系统。
对于一个本质上是分布式、为构建分布式系统而设计的系统而言,这也是一个自然的选择。分布式系统的定义本质是局部故障的可能性,这要求超过一定规模的系统能够自我修复,并在不考虑局部故障的情况下收敛到正确的状态。
然而,这种设计选择也带来了产生巨大的复杂性和混乱的可能性。以下为两个具体的例子:
1)错误被延迟。 在Kubernetes中创建对象(例如pod),通常只需在配置存储中创建一个对象,断言该对象的预期存在。如果事实证明无法实际满足该请求,要么是因为资源限制,要么是因为对象在某些方面内部不一致(引用的容器映像不存在),通常不会在创建时发现该错误。配置创建将会进行,然后,当相关操作符唤醒并尝试实现更改时,才会创建错误。
这种间接性使调试和推理变得更加困难,因为你不能用“创建成功”作为“生成的对象存在”的良好标记。这也意味着与失败相关的日志消息或调试输出不会出现在创建对象的流程的上下文中。
编写良好的控制器会发出Kubernetes事件来解释正在发生的事情,或以其他方式注释有问题的对象;但对于测试较差的控制器或更罕见的故障,您可能只会在控制器自己的日志中获取日志垃圾邮件。一些更改可能涉及多个控制器,会独立或联合行动,这使得跟踪某一段代码变得更加困难。
2)运算符可能有漏洞。 声明性控制环模式提供了隐含的承诺,即您作为用户无需担心如何从状态A到状态B;您只需将状态B写入配置数据库,然后等待。当它运行良好时,这实际上是一个巨大的简化。
然而,有时候从状态A到状态B是不可能的,即使状态B可以自己实现。或许这是可能的,但需要停机时间。虽然这是可能的,但这是一个罕见的用例,所以控制器的作者忘了实现它。
对于Kubernetes中的核心内置原语,您可以保证它们经过良好的测试和使用,并希望它们能很好地工作。但当您开始添加第三方资源、管理TLS证书或云负载平衡器或托管数据库或外部DNS名称时,您会偏离常规,所有路径的测试效果会变得不那么清楚。
而且,与之前关于延迟错误的要点一致,故障模式很微妙,而且发生在较远的位置;很难区分“更改尚未被接收”和“更改永远不会被接收”之间的区别。
4、结论
本文一直试图避免就这些设计决策的好坏做出价值判断。因为关于Kubernetes何时成为什么样的有价值的系统才是有意义的。
我发现以自己的方式对Kubernetes有很好的理解,并更好地理解其复杂性来自哪里,以及它正在服务的目标,这是非常有价值的。
这种分析可以应用于现在使用的任何系统。即使一个系统的设计方式在当前的环境中并不理想,但出于某种原因,它总是以这种方式出现。只要这是一个你必须与之互动、推理和决策的系统,如果你能理解这些原因、动机和将系统推向这一点的内部逻辑,而不是立即将其忽视,则会有更好的使用体验。
希望这篇文章能帮助其他对在生产中使用Kubernetes不熟悉、或正在考虑采用Kubernetes的人,帮助大家提供一些有用的框架来解释为什么(我相信)它看起来的样子,以及对它有什么合理的期望。
如果我们想更细致入微,我们可以认为它预先加载复杂性,而不是添加复杂性。这种设计让你预先处理可能长期忽视的实际问题。这是否是一个理想的选择取决于您的目标、规模、时间范围和相关因素。