为什么Kubernetes这么难? • Buttondown


Kubernetes 比我使用过的其他一些系统感觉更大、更可怕、更难处理。在我学习并使用它的过程中,我试图理解为什么它看起来像现在这样,以及哪些设计决策和权衡导致它看起来像现在这样。我并不声称拥有完整的答案,但这篇文章试图将我所拥有的两个具体想法或范式提交给我,因为我试图理解为什么使用 Kubernetes 有时会让人感觉如此毛骨悚然。
 
Kubernetes 是一个集群操作系统
很容易将 Kubernetes 视为一个用于部署容器化应用程序的系统,或者一些类似的功能描述。虽然这可能是一个有用的观点,但我认为将 Kubernetes 视为通用集群操作系统内核会更有成效。
传统操作系统的工作是使用单台计算机及其所有附属硬件,并公开程序可以用来访问该硬件的接口。虽然具体细节各不相同,但总的来说,有一些以下目标:

  • 资源共享——我们希望使用一台物理计算机并将其资源细分到多个程序中,以使它们在一定程度上相互隔离。
  • 可移植性——我们希望在一定程度上抽象出底层硬件的精确细节,这样同一个程序就可以在不同的硬件上运行而无需修改,或者只需进行少量修改。
  • 通用性——当我们提出新类型的硬件,或者将新硬件插入我们的计算机时,我们希望能够以增量的方式将它们融入我们的抽象和接口,理想情况下不(a)彻底改变任何接口或(b ) 破坏任何不使用该硬件的现有软件。
  • 整体性——与一般性相关,我们希望操作系统调解对硬件的所有访问:软件完全绕过操作系统内核应该很少或不可能。软件可以使用操作系统内核建立与硬件的直接连接,以便将来直接发生交互(例如,设置内存映射命令管道),但初始分配和配置仍然在操作系统的支持下。
  • 性能——与“直接编写一个直接在硬件上运行并具有对硬件的独占直接访问权限的专用软件”(ala unikernel)相比,我们希望为这种抽象支付可接受的小性能成本。在某些情况下,我们希望通过提供 I/O 调度程序或缓存层等优化,在实践中实现比此类系统更高的性能。

虽然 "易于编程 "通常是一个额外的目标,但在实践中,它往往输给了上述关注。操作系统内核通常是围绕上述目标设计的,然后编写用户空间库,将低级的、通用的、高性能的接口包装成更容易使用的抽象概念。操作系统开发者往往更关心 "如何使nginx在我的操作系统上快速运行",而不是 "nginx移植到我的操作系统上能缩短多少行代码?"
我认为Kubernetes是在一个非常类似的设计空间中运行的;然而,它的目标不是抽象出一台计算机,而是抽象出整个数据中心或云,或其中的一大片。

我认为这种观点有帮助的原因是,这个问题比 "使HTTP应用程序在容器中部署成为可能 "要难得多,也更普遍,而且它指出了Kubernetes如此灵活的具体原因。Kubernetes希望它足够普遍和强大,能够在任何类型的硬件(或虚拟机实例)上部署任何类型的应用程序。
我认为,这个观点所解释的最大的设计选择也许是Kubernetes的可插拔和可配置性。一般来说,不可能做出对所有人都适用的选择,特别是如果你渴望在不付出奢侈的性能成本的情况下做到这一点。
特别是在现代云环境中,应用程序的类型和部署的硬件类型有很大的不同,而且是非常快速的移动目标。因此,如果你想成为所有人的一切,你最终需要极大的可配置性,这最终创造了一个强大的系统,但它可能难以理解,或使 "简单 "的任务变得复杂。

我感觉到,许多用户认为Kubernetes本质上是(或者,也许,希望它是)"一个Heroku",即作为一个部署应用程序的平台,抽象出大多数传统的底层操作系统和分布式系统的细节。

我的论点是,Kubernetes认为自己解决的问题声明更接近于 "CloudFormation"--在这个意义上,它希望足以定义你的整个基础设施--除了它还试图以一种在底层云提供商或硬件上通用的方式做到这一点。
 
Kubernetes中的一切是一个控制循环
我们可以想象一个非常必要的 "集群操作系统",就像上面所说的那样,它暴露了 "分配5个CPU的计算量 "或 "创建一个新的虚拟网络 "这样的基元,这些基元反过来又支持系统内部抽象中的配置变化或对EC2 API(或其他基础云提供商)的调用。

Kubernetes,作为一个核心的设计决定,并不像那样工作。相反,Kubernetes的核心设计决定是,所有的配置都是声明性的,所有的配置都是通过作为控制循环的 "操作者 "的方式实现。他们不断地将期望的配置与现实的状态进行比较,然后试图采取行动,使现实与期望的状态相一致。

这是一个非常慎重的设计选择,也是一个有充分理由的选择。一般来说,任何没有被设计成控制循环的系统都将不可避免地偏离期望的配置,因此,在规模上,需要有人来编写控制循环。通过内部化,Kubernetes希望能让大多数核心控制环路只写一次,而且是由领域专家来写,从而使在其上构建可靠的系统变得更加容易。这也是一个系统的自然选择,因为它的本质是分布式的,而且是为构建分布式系统而设计的。分布式系统的决定性性质是部分故障的可能性,这就要求超过一定规模的系统能够自我修复,并在不考虑局部故障的情况下收敛到正确状态。

然而,这种设计选择也带来了大量的复杂性和混乱的机会。
挑两个具体的来说。

  • 错误被延迟

在Kubernetes中创建一个对象(例如一个pod),一般来说,只是在配置存储中创建一个对象,断言该对象的预期存在。如果由于资源限制(集群的容量),或者由于对象在某些方面内部不一致(你引用的容器镜像不存在),实际上不可能满足该请求,一般来说,你不会在创建时看到该错误。配置创建将通过,然后,当相关的操作者醒来并试图实施改变时,才会产生一个错误。

这种间接性使得一切都更难调试和推理,因为你不能用 "创建成功 "作为 "结果对象存在 "的一个好的速记方法。这也意味着与失败有关的日志信息或调试输出不会出现在创建对象的进程中。一个写得好的控制器会发出Kubernetes事件,解释正在发生的事情,或以其他方式注释有问题的对象;但对于一个测试较差的控制器或更罕见的失败,你可能只是在控制器自己的日志中得到日志垃圾。有些变化可能涉及到多个控制器,独立行动,甚至联合行动,这使得追踪哪段该死的代码真正失败变得更加困难。

  • 运维者可能是错误的

声明性的控制-循环模式提供了一个隐含的承诺,即你,用户,不需要担心如何从状态A到状态B;你只需要把状态B写进配置数据库,然后等待。当它运行良好时,这实际上是一个巨大的简化。
然而,有时不可能从状态A到状态B,即使状态B本身就可以实现。也可能是可能的,但需要停机时间。也可能是可能的,但这是一个罕见的用例,所以控制器的作者忘记了实现它。对于Kubernetes中的核心内置基元,你有一个很好的保证,那就是它们经过了很好的测试和使用,希望能工作得很好。但是当你开始添加第三方资源,以管理TLS证书或云负载均衡器或托管数据库或外部DNS名称(Kubernetes的设计倾向于将你推向这个方向,因为当它可以成为你整个堆栈的真理之源时,它更高兴),你会偏离轨道,它变得更不清楚所有的路径是如何经过测试的。而且,与之前关于延迟错误的观点一致,故障模式是微妙的,而且发生在远处;而且很难区分 "这个变化还没有被接受 "和 "这个变化永远不会被接受"。