最近,我和领域内的专家Vallery Lancey有过一次闲聊,主题是关于Kubernetes的。具体说来,假设我们从零开始设计一个新的编排系统,不必拘泥于与现有Kubernetes的兼容性,我们可能会采取哪些不同的做法。我发现对话过程非常有意义,以至于我觉得有必要记录下期间涌现的诸多想法,所以就有了这篇文章。
落笔前,我想强调几点:
- 这不是一个完全成形的设计。其中某些想法可能根本无法落地,或者需要进行大量的重构。很多章节的内容都是想到哪写到哪。
- 这些观点不单纯是我一个人的想法。有些是我原创的,更多的是集体思考,交流碰撞后的产物,就像Kubernetes社区中的许多设计一样。我知道至少Vallery和Maisem Ali不止一次地启发了我的思考,还有更多我说不出名字的。如果你觉得文中有些想法很不错,那请把它当成是集体努力的结果。如果你不太认同其中一些看法,那把它当成是我个人的小小失误吧。
- 文中的一些观点是非常极端激进的。我只是尝试把脑海的一些设计表达出来,一吐为快。
设计原则
我过往的Kubernetes实践经验来自两个截然不同的地方:一个是为裸金属机集群维护MetalLB;另一个是在GKE SRE中运维大型的集群即服务(clusters-as-a-service)。这两个经历都让我觉得,Kubernetes相当复杂,要达到目前市面上宣传的效果,往往需要做大量的前置工作,而大多数跃跃欲试的用户对此都没有充分的准备。
维护MetalLB的经历告诉我,想构建与Kubernetes集成的健壮性优异的软件十分困难。我认为MetalLB稳定性堪称优秀,但是Kubernetes还是非常容易使它出现配置错误的情况,而调试起来也相当费劲。 GKE SRE的运维经历则教会我,即使是最出色的Kubernetes专家也无法不出差错地运维大规模Kubernetes集群(尽管GKE SRE借助一些工具能做得非常出色)。
Kubernetes可以类比成编排软件中的C ++。功能强大,特性完备,看上去似乎挺简单,但你会不停踩坑,直到你投入大量时间和精力,去弄清它的所有原理为止。即便如此,Kubernetes的配置和部署方式方法众多,生态还在不停发展,以至于很难让人觉得可以停下脚步歇口气了。
按照这个类比,我理想的参照物是Go。如果Kubernetes是C ++,那么编排系统中Go会是什么样的呢?极度简洁,特点突出,缓慢而谨慎地拓展新特性,你可以在不到一周的时间里上手,然后就能用它去完成你的工作。
接下来,我们就遵循上面这些原则开始了。重新设计一个Kubernetes,可以不考虑和现有的兼容,另辟蹊径,该考虑哪些点呢?
可修改的Pod
在Kubernetes中,大部分(但不是全部)Pod在创建后是不可变的。如果你想直接修改一个Pod,不行。得重新创建一个再删掉旧的。这与Kubernetes中的大多数资源对象的处理方式不同,在Kubernetes中的大多数对象都是可变的,并且当预期状态出现变化时,可以优雅地将实际状态协调到与预期一致。
因此,我不想让Pod成为一个例外。我打算将它设计成完全可读写,并让它拥有像其它资源对象一样的调谐逻辑。
对此我第一时间想到的方案是原地重启。如果Pod的调度约束和需求资源前后没有改变,猜一下如何实现? 发出SIGTERM信号终止runc,使用不同的参数重新启动runc,就已完成重启。这样一来,Pod有点像从前的systemd服务,必要时还可以在不同机器之间漂移。
请注意,这不需要在运行时层面操作可变性。如果用户更改了Pod定义,仍然可以先终止容器并使用新配置重新启动容器。 Pod仍会保留在原节点上预留的资源,因此从概念上讲,它等效于systemctl restart blah.service。你也可以尝试在并在运行时层级上来执行Pod的更新操作,但其实没有必要这样做。不这么做的主要好处是将调度、Pod生命周期及运行时生命周期管理解耦。
版本管理
来继续讨论Pod的设计:既然它现在是可变的了,那么我接下来考虑的事情自然而然就是Pod回滚。为此,让我们保留Pod旧版本的定义,如此一来“回滚到特定旧版本”就轻而易举了。
现在,Pod更新流程如下:编写文件更新Pod定义,并进行更新以符合预期定义。更新出错?回滚上一个版本,流程结束。
上述流程的好处是:无需依赖所谓的GitOps,即可轻松了解到集群中应用的版本迭代。如无必要就不用引入GitOps,尽管它有不少优点。如果,你只希望解决一个很基本的“集群发生了什么变化?”的问题,仅仅使用集群中的数据就够了。
其中还涉及到更多设计细节。尤其是我想将外部更改(用户提交Pod的变更)与系统变更(Kubernetes内部触发的Pod定义变更)这两者区分开。我还没有考虑清楚如何对这两种变更的历史信息进行编码,并使得运维人员和其他系统组件都可以获取到这些变更。也许可以设计成完全通用的,“修改人”在提交新版本时会标识自己。然后用户可以指定特定修改人(或排除特定修改人)以查询某类变更(类似于标签查询的工作原理)。同样的,这里还需要更多的设计考量,我确定的是我想要一个具有版本管理特性的Pod对象,可以查询它的历史版本记录。
最后,还需要考虑垃圾回收。具体说就是,对每个Pod的更改应该可以很好地进行增量压缩。默认设置是保留所有变更内容,积累到一定数据量后,在此基础上进行一次压缩。保留所有变更内容也会对系统产生一定压力,但可避免“因频繁提交更改”而给系统的其它部分带来影响。这里用户要注意,为方便聚合,应该进行次数更少同时更有意义的变更,而不是每次改动一个字段进而产生一系列版本。
一旦有了历史版本这个功能后,我们还可以整一些其它的小功能。例如,节点可以将最近的若干个版本的容器镜像保留在节点上,从而使回滚更快。原来的垃圾回收超过一定期限就触发,有了历史版本记录,可以更精确地保留需要的版本数。概括而言,所有编排软件都将旧版本用作各种资源对象的GC roots,以加快回滚速度。回滚是避免服务中断的基本方式,这是非常有价值的事情。
用PinnedDeployment替换Deployment
这是部分内容比较简短,主要是受Vallery启发。他的PinnedDeployment设计非常让人惊叹。PinnedDeployment使运维人员可以通过跟踪两个版本的Deployment状态来控制应用发布。这是由SRE设计的部署对象。设计人员非常清楚SRE在部署中的关注的焦点。我个人很喜欢这个设计。
这可以和上面的可版本管理、可原地更新的Pod结合得非常好,真想不到还有什么可以添加的了。它非常清晰的解释了多个Pod时的工作流程。要从Kubernetes各项约束中脱离来适应这一个全新的流程,可能需要做些调整,但是大体设计是非常不错的。
显式的编排流程
我认为Kubernetes的“API machinery”机制最大问题是编排,即一系列独立控制循环的松散集合所构成工作流程。从表面上看,这似乎是一个好主意:你有好几十个微小的控制循环,每个控制循环只负责一个小功能。当它们被整合到一个集群时,它们彼此互相协作以调谐资源对象状态并收敛至符合预期的最终状态。所以这其中有何问题?
问题就在于出现错误时几乎不可能去进行调试。 Kubernetes中典型的出错,就是用户将变更提交给集群,然后反复刷新以等待资源对象符合预期,如果迟迟没有符合……那么,来问题了。 Kubernetes分辨不出“对象已符合预期”和“控制循环被中断并阻塞了其他事物”之间的区别。你或许希望有问题的控制循环会发布它所管理对象的一些事件来帮你排错,但总的来说它们发挥不了多少作用。
此时,你唯一的可行选择是收集可能涉及的每个控制循环的日志,寻找被中断的循环。如果你对所有控制循环的工作机制都有深入的了解,则定位错误的速度可以更快一些,丰富的经验可以让你从资源对象的当前状态,推断出是哪个控制循环出错,并正尝试恢复运行。
这里要注意关键一点,我们看待复杂度的视角已经从控制循环的设计者转换到到了集群运维人员。设计一个可以独立执行单一任务的控制循环很容易(并非是说其不重要)。但是,要在集群中维护数十个这样的控制循环,就需要运维人员非常熟悉这些控制循环的操作,以及它们之间的交互,并尝试理解这样一个组织松散的系统。这是必须认真考虑的问题,通常设计者编写控制循环代码验证其功能这样的工作是一次性的,但是运维人员可能要终日和它们打交道,并反复处理控制循环出现的问题。简化那些你只需要做一次的事情对运维人员来说不公平。
为了解决这个问题,我会参照systemd的做法。它解决了类似的生命周期管理问题:给定当前状态A和目标状态B,如何从A变为B?区别是,在systemd中,操作步骤及其依赖是显式的。你告诉systemd,你的服务单元是multi-user.target服务组的一部分,则它必须在挂载文件系统之后联网之前启动运行。您还可以依赖系统的其他具体组件,例如说只要sshd运行,你的服务就需要运行(听起来像边车,是吧?)。
这样做的好处是systemd可以准确地告诉用户,是系统的哪一部分发生故障,哪部分仍在运行,或是哪个前置条件没通过。它甚至还可以打印出系统启动的执行过程,以供分析定位问题,例如“哪个服务的启动耗时最长”。
我想批量的照搬这些设计到我的集群编排系统中。不过也确实需要一些微调,但大致来说:控制循环必须声明它们对其他控制循环的依赖性,必须生成结构化日志,以便用户可以轻松搜索到“有关Pod X的所有控制循环的操作日志”,并且编排系统处理生命周期事件,可以采取像systemd那样的做法,逐个排查定位到出问题的服务组单元。
这在实践起来会是怎么样的?先结合Pod的生命周期说起。可能我们将定义一个抽象的“运行”target,这是我们要达到的状态——Pod已经启动并且一切正常。容器运行时将添加一个任务到“运行”之前,以启动容器。但它可能要到存储系统完成网络设备挂载后才能运行,因此它将在“存储”target之后自行启动。同样地,对于网络,容器希望在“网络”target之后启动。
现在,你的Ceph控制循环将自己安排在“存储”target之前运行,因为它负责启动存储。其他存储控制循环也是相同的执行流程(local bind mount,NFS等)。请注意,这里的执行流程可以是并发执行,因为它们都声明要在存储准备就绪之前执行,但是并不在意在其他存储插件的控制循环之前还是之后执行。也有可能存在例外情况!比如你编写了一个很棒的存储插件,它功能出色,但是必须先进行NFS挂载,然后才能运行。好了,我们只需要在nfs-mounts步骤中添加一个依赖项,就可以完成了。这就和systemd类似,我们既规定了顺序,又规定了“还需要其他组件才能正常工作”这样的硬性要求,因此用户可以轻松定义服务的启动步骤。
(此处的讨论我稍微简化了一下,并假设各项操作步骤没有太多的循环依赖。要深入的话,这可以展开出更复杂的流程。请参阅下文进一步探讨,这里先不讨论太过于复杂的流程。)
有了这些设计,编排系统可以回答用户“为什么Pod没有启动?”用户可以dump下Pod的启动流程图,并查看哪些步骤已完成,哪些步骤失败,哪些已在运行。 NFS挂载已经进行了5分钟?会不会有可能是NFS服务器已挂掉,但控制循环没报超时?服务的各项配置和可能的状态,叠加出来的结果矩阵是非常庞大的:如果有了我们设计的这样一个辅助调试的工具,这也不算个大问题。 Systemd允许用户以任意顺序、任意约束往服务的启动过程添加内容。但是当出现问题时,我仍然可以轻松对其进行故障排查,根据约束条件,在调试工具的辅助下,我可以第一时间定位到问题的关键所在。
和Systemd给系统启动带来的好处类似,这让系统可以尽可能地并行执行生命周期操作,但也仅此而已。而且由于工作流程是显式的,它还可以扩展。你的集群是否存在这种情况:在每个Pod上都有企业定制的操作,且必须在某个生命周期阶段内执行的?可以定义一个新的中间target,使其依赖于于正确的前置或后置条件,然后将控制循环挂接(hook)到这里接收回调。编排系统将确保控制循环在生命周期的各阶段发挥作用。
请注意,这还解决了诸如Istio之类的存在奇葩问题。在Istio中,它们必须注入一些额外的开发者提供的定义才能起作用。没必要!提供对应的控制循环介入到生命周期管理中,并根据实际需要在进行调整。只要你可以向系统表示,在生命周期中某个特定阶段需要执行操作的,就无需考虑通过向运维人员提供额外的资源对象去操作。
这部分内容很长,但想表达的意思很简短。这和Kubernetes的原来的API machinery大相径庭,因此需要大量新的设计工作才能实现。最突出的变化,控制循环不再只是简单地观察集群对象的状态并做出修正,还必须等待编排器(Orchestrator)完成对特定对象的调用,当这些对象达到符合预期的状态时,控制循环再进一步响应。你现在可以通过注解和约定,将其在Kubernetes实现上。但除非对工作机制的细枝末节的都了解得一清二楚,否则就可观察性和可调试性来说,没什么帮助。
有趣的是,Kubernetes已经有其中一些想法的原型实现:Initializers和Finalizers 。它们分别是生命周期两个不同阶段里执行预操作的钩子。它使您可以将控制循环挂接到两个硬编码的“target”上。他们将控制循环分为三个部分:初始,“默认”和终结。虽然是硬编码,这是显式工作流程图的雏形。我打算把这个设计推广到更一般的情况。
显式的字段归属
承接上一部分设计的适度扩展:使资源对象的每个字段都被特定的控制循环显式拥有。该循环是唯一允许写入该字段的循环。如果未定义所有者,则该字段可被集群运维人员写入,但运维人员不能写其他任何内容。这是由API machinery(而非约定)强制执行的。
这已经是大多数情况,但还是存在字段所有权模糊不清的时候。这导致两个问题:如果字段错误,则很难弄清谁负责;而且一不小心就会进入到两个控制器修改一个字段的情况,陷入循环。
后者是MetalLB存在的大麻烦,它与其他一些负载均衡器实现方式发生了冲突。不应该出现这样的情况。Orchestrator应该拒绝MetalLB添加到集群中,因为与LB相关的字段将有两个所有者。
可能需要留个后门,让用户处理一个字段有多个归属者的情况。但简单起见,我在这里先不考虑,然后看看设计是不是经得起考验。除非有充分证明支持,否则共享所有权就是一个会带来潜在隐患的设计。
如果你还要求控制循环显式注册读取的字段(并把那些没有注册的字段剥离出来——不准作弊),这也可以让你做一些有意义的事情,比如证明系统收敛(没有读->写->读的循环),或是帮你照出拖慢系统响应速度的调用环节。
有且只有IPv6
我对Kubernetes网络部分非常熟悉,它是我最想全盘推倒重来的一个部分。有很多原因造成了网络模块发展成今天这个局面,我并不是想说Kubernetes网络设计得一无是处。但网络不在我的编排体系里。这部分内容很长,请带点耐心。
首先,让我们先彻底抛开Kubernetes现有的网络。覆盖网络,Service, CNI,kube-proxy,网络插件,这些统统都不要了。
(顺便提一句,为什么网络插件是不应该出现K8s理想的设计中的。目前,已经有不少企业开始兜售他们的网络插件了,你最好不要相信他们能保持客观中立,让我来列出反驳他们的理由。无论是自然界还是软件界,所有生态系统的第一要务都是确保其继续存在。你不能要求一个生态系统自我进化到灭亡,你必须从外部触发灭亡。)
回到正题,现在一切归零了。我们有容器,它们需要互相通信,和外部通信。那该做什么?
让我们赋予每个Pod一个IPv6地址。是的,只有一个IPv6地址。它们从哪里来?这里要求局域网支持IPv6(假定具备这样的条件,毕竟我们的设计要面向未来),IP地址就从这来。你几乎都不需要做IP地址冲突检测,2^64足够大,随机生成的IP地址基本上就满足需求了。我们需要一个机制,好让每个节点上之间能互相发现,这样就可以找到其他 Pod 在哪个节点上。这应该不难实现,这么做的理由很简单:对集群网络内的其他部分而言,一个 Pod 看起来就像是在运行其中的某个节点。
或者我们干脆组一个全是唯一本地地址的网络,然后手动在每个节点上做路由。这实现起来非常容易,而且地址分配基本上就是 "随便选一个数字就可以了"。可能还需要设计一个子集,这样节点到节点的路由才会更有效率,但这都是不太复杂的东西。
有个麻烦是云服务商喜欢介入到网络的基础部分。所以IPAM模块要保持可插拔性(在上文所提到工作流模型之内),这样我们就可以做一些事情,比如向AWS解释流量是如何转发的。不过,使用IPv6可能就无法在GCP上运行了。
不管怎么说,有许多备选的方案来做这部分的设计。就其根本而言,我只想在节点之间使用IPv6和配置一些基本的、简单的路由就可以了。这样就可以在接近零配置的情况下,解决Pod之间的连接问题,因为IPv6是有足够大的地址空间,我们选一些随机数字就能完事。
如果你有更复杂的连接需求,你就把这些作为额外的网络接口和我设计的简单、可预测的IPv6路由接上。需要保证节点间的通信安全?引入wireguard隧道,添加路由,通过wireguard隧道推送节点IP,完事。编排系统不需要知道这些细枝末节,除了可能会在节点生命周期管理中添加一个小小的控制循环,这样在隧道建立好之后,才让节点处于就绪状态。
好了,Pod之间的互联互通,Pod和外部网络的连接,这两个问题都解决了。考虑到现在只有IPv6,我们如何处理流入集群的IPv4流量呢?
首先,我们规定IPv4只适用于Pod和Internet连通的这种情况。在集群内必须强制使用IPv6。
我们可以用几种方法来应对这个限制。简单来说,我们可以让每个节点封装IPv4流量,为Pod预留一小块符合RFC 1918规范的地址空间(所有节点上预留的地址都从属于这个空间)。这样就可以让它们到达IPv4互联网,但这都是每个节点的静态配置,根本不需要集群可见。你甚至可以将IPv4的东西完全隐藏在控制平面中,这只是每台机器运行时的一个实现细节。
我们也可以用NAT64和CLAT来找点乐子:让整个网络只用IPv6,但用CLAT来欺骗Pods,让它们以为自己有IPv4连接。然后在Pod内进行 IPv4到IPv6的地址翻译,并将流量发送到NAT64网关。可以是每台机器的NAT64,也可以是集群内的部署的。如果你需要处理大量的NAT64流量,甚至可以用一个集群外部类似CGNAT这样的东西。在这一点上,CLAT和NAT64已经有很好的应用:你的手机可能正是通过这样的方式来让你获得IPv4地址接入互联网。
我可能会从简单的IPv4伪装开始(第一种方案),因为所需的配置量极少,都可以由每台机器在本地处理,不会有任何交叉影响,让我们更容易着手实现。另外,后期改起来也很方便,因为在Pod看来都是一样的,而且我们也不希望通过一个网络插件来处理任何东西。
到这我们已经处理了出站方面的问题,我们有双栈上网。接下来怎么处理入站端呢?负载均衡器。不考虑把它构建在核心编排系统中。编排系统应该专注于一件事:如果一个数据包的目的IP是Pod IP,就把这个数据包交付给Pod。
正好,这应该主要适合公有云的场景。厂商们也倾向于这样的模型,这样就可以把他们的负载均衡器产品卖给你了。好吧,你赢了,姑且先采取这样的设计模型。不过我想要一个控制循环来控制负载均衡器,并将其与IPAM集成,这样VPC就能明白如何将数据包路由到Pod IP。
这忽略了由物理机搭建集群的场景。但这也不是一件坏事,因为没有一个放之四海而皆准的负载均衡器。如果我试图给你一个负载均衡器,但它没有完全按照你预想的工作。这说不定还会让你抓狂,一气之下装起了Istio,这时我所讨论到降低复杂性都是无用功了。
让负载均衡器集中精力把一件事做好:如果要把数据包转发给Pod,那就把数据包转发给Pod。在遵循这一原则的前提下,你可以基于LVS、Nginx、无状态、云厂商负载均衡服务、F5等来构建负载均衡器,你可以自由发挥。这里也许可以考虑提供几个“默认”实现。对于负载均衡器这部分,我确实有很多想法,也许我设计的方案就挺合适。这里的关键是编排系统对负载均衡器如何实现毫不关心,只要能把数据包转发到Pod上。
我没有触及IPv4 ingress的问题,主要是我认为这是负载均衡器该做的事情,让它们各自用最合适的方式来解决问题。像Nginx这样的代理型负载均衡器,只需要通过IPv6转发后端就可以了,没什么问题。无状态的负载均衡器可以很容易地将IPv4地址转换成IPv6,其间有个转换标准。源地址为::fffff:1.2.3.4数据包到达Pod时,Pod可以将其转回IPv4。或者干脆将其视为IPv6直接处理,这样的处理方式就假定网络中的地址都是IPv6。如果使用了无状态翻译方式,出站的时候需要有状态的跟踪机制,来映射回IPv4。但这也比原先在IPv4下采取的层层封装方式来得好。从节点的视角来看,这完全可以通过一条额外的::fffff:0000:0000/96的路由来处理。
将极简贯彻到底
作为上述所有网络问题的替代方案,我们干脆都不要了。回到Borg式的端口分配,所有服务都在主机的网络命名空间中运行,并且必须请求分配端口。不是监听:80,而是监听:%port%,然后编排系统会用一个未使用的端口号来代替。例如,最终会变成监听主机上的:53928。
这样的设计真的非常非常简单。简单到基本没什么需要额外做的。在分配端口时,需要做一些烦人的检查,以避免端口冲突,这倒是一个令人头疼的问题。还有一个端口耗尽的问题,因为如果你的客户端非常活跃且数量不少,65000个端口其实并不算太多。但这个真的非常非常简洁。我个人崇尚简洁。
我们也可以采用经典的Docker方式,将其和上面的设计结合起来:容器在自己的网路命名空间中运行,使用一些临时的私有 IP。你可以使用任意的端口,但对其他Pod和外部可见的只有那些告知运行时要暴露的端口。而且你只能声明要暴露的容器端口,映射到主机上的端口是由容器运行时选择的。(这里也可以留一些后门来应对特例,你可以告诉系统你非要80端口不可,然后通过调度约束来起作用,调度到80端口没被占用的机器——类似于当前Kubernetes在这方面的处理。)
上面论述的关键点是,这些设计极大地简化了网络层。以至于我可以在短短几分钟内向别人解释清楚,确保他们能够了解其工作机制。
缺点是这把复杂性推给了服务发现。你不能使用“纯粹的DNS”作为发现机制,因为大多数DNS客户端不解析SRV记录,因此不会动态发现随机端口。
搞笑的是,服务网格的逐渐普及让这个问题不再是个问题。因为人们现在假设存在一个本地智能代理,它可以做任何服务发现能做的事情,并将其映射到一个网络视图上,而这个视图只被需要它的 Pod 看到。不过,我不太愿意接受这种做法,因为服务网格增加了太多的复杂性和维护成本,所以我不想采用它们……至少在有实践方案表明能使它们良好运行之前,我维持这样的观点。
所以,我们不妨做一些类似服务网格的东西,但更简单点。在源主机上做一些自动的IP端口转换……不过这看起来很像kube-proxy,这随之而来的就是复杂性和调试困难(这不是一个通过在不同的地方执行tcpdump就能解决的问题,因为流量会在不同的跳数之间变化)。
所以,这个方案也表明显式主机端口映射可能也算个解决方案,但仍存在很多隐藏的复杂性(我相信这就是为什么Kubernetes一开始就采用单Pod单IP的原因)。Borg通过强制规定解决了这种复杂性,它规定了应用都必须用自家设计的依赖库和框架。所以这里有个显而易见的缺点,不能随意更换的服务发现和负载平衡的实现框架。除非我们采用真正的服务网格,否则做不到这点。
本节描述的方案还有可改善之处,但我更倾向于上一节的实现。它的设计时要考虑的东西更多一些,但可以得到是一个可组合、可调试、可理解的系统,不需要无限制地增加功能以满足新需求。
安全同样重要
长篇大论的探讨完网络之后,来简单说一下安全问题。容器默认应该被最大限度的沙盒化,并需要显式的双重确认步骤。
我们可以直接应用Jessie Frazelle在容器安全方面出色的工作成果。打开默认的apparmor和seccomp配置文件。不允许在容器中以root身份运行。使用user命名空间进行隔离,这样哪怕有人设法在容器中升级为root,那也不是系统root。默认阻止所有设备挂载。不允许主机绑定挂载。为了达到效果,写一个你能想的的最严格的Pod安全策略,然后把他们作为默认值,并且让它们很难背离默认值。
Pod安全策略与此相当接近,因为它们强制执行双重确认:集群运维人员确认允许用户做不安全的操作,而用户必须显式申请权限执行不安全的操作。遗憾的是,Kubernetes现有的设计并没有这么考虑。这里我们先不关心向下的兼容性,把默认值做得尽可能安全。
(温馨提示:从这里开始,章节内容开始有点天马行空。这些都是我想要的设计,不过我强烈意识到,很多细节没有考虑清楚。)
gVisor?Firecracker?
说到默认情况下的最高级别的安全,我觉得不妨采取更激进的沙盒化措施。可以考虑将gVisor或Firecracker作为默认容器沙箱,并开启双重确认机制,最终达到“与主机共享内核的最安全的容器环境”这目的?
这里需要再斟酌斟酌。一方面,这些工具所承诺的极度安全非常吸引我。另一方面,这也不可避免地要运行一大堆额外的代码,也带来潜在漏洞和复杂性。而且这些沙盒对你能做的事情进行了极度的限制。甚至,任何与存储有关的事情都会演变成“不,你不能有任何存储”。这对于某些场景而言来说是不错,但把它变成默认值就限制得太过分了。
至少在存储方面,virtio-fs的成熟会解决很多这样的问题,能让这些沙盒在不破坏安全模型的前提下,执行有效地绑定和挂载操作。可能我们应该在那个时候再重新审视这个决定?
去中心化集群
我猜这个时髦的术语应该是“边缘计算”,但我真正的意思是,我想让我所有的服务器都在一个编排系统下,把它们作为一个单元来运作。这意味着我家里服务器机架上的计算机,我在DigitalOcean上的虚拟机,以及在互联网上的其他几台服务器。这些都应该是集群内基本等效的一部分,实际上也具备这样的能力。
这就导致了几个与Kubernetes不一样的地方。首先,工作节点应该设计得比当前更加独立,对控制节点的依赖更少。可以在没有控制节点的情况下长时间(极端情况下是几周)运行,只要没有机器故障,导致需要Pod重新调度。
我认为主要的转变是将更多的数据同步节点上,并存到持久化存储中。这样节点自身就有了恢复正常运行所需要的数据,和主节点失联后也能从冷启动中恢复到可响应状态。理论上,我希望集群编排系统在每个节点上填充一组systemd单元,在节点的运行过程中扮演一个被动管理的角色。它在本地拥有它需要的一切,除非这些东西需要改变,否则节点是独立于管理节点的。
这确实导致了如何处理节点失联的问题。在“中心化”的集群中,这是触发工作负载重新调度的关键信号,但在去中心化的情况下,我更有可能会说“别担心,这可能是短暂的失联,节点很快就会回来”。所以,节点生命周期以及它与Pod生命周期的关系将不得不改变。也许你必须显式声明你是否希望Pod是“高可用”(即当节点失联时主动地重新调度)或 “尽力”(只有当系统确定一个节点已经挂了并无法恢复时才重新调度)。
一种说法是,在我设计的“去中心化”集群中,Pod的表现更像是“独一无二的宠物”而不是“牧场里的羊群”。我会考虑设计类似无状态应用水平扩展的机制,但与Kubernetes不同,在这个场景下,当应用副本数缩小到一个时,我可以干预这一个应用运行在哪个节点上。这是Kubernetes不鼓励的做法,所以此处我们不得不背离Kubernetes的某些做法。
另一种观点是,将集群联邦视为一级对象。实际上可以把分散的机器看成是单独的集群,各自有自己的控制平面,然后将它们整合作为一个超大型集群来使用。这当然可以,并且回答了一些关于如何将节点与控制平面解耦的问题(我个人的答案:不要尝试这么做,应当将控制平面的功能尽可能地下放到数量庞大的工作节点)。在这种情况下,我希望控制平面是极其精简的,否则在Kubernetes中这样做的开销会很大,我个人希望避免这种情况。
这也提高了网络部分的难度,因为我们现在必须跨网连接。我的做法是以某种方式去和Tailscale集成,这刚好解决我们需求。也可以选择需要一些更定制化的、组件更少的方案(不要进行多余的NAT转换)。
纳管虚拟机
注意:当我在这里说虚拟机的时候,我并不是指“用户在Kubernetes上运行的Pod”。我指的是管理员自己创建的hypervisor的服务器虚拟机。类似Proxmox或ESXi创建出来的,但不是EC2这种托管的。
我希望我的编排系统能够无缝地管理容器和虚拟机。否则,在实践中,我将需要一个单独的hypervisor,那样一来我将有两套的管理系统。
我不确定这究竟会成为一种怎样的设计,只是一个粗略的想法。kubevirt提供的功能应该内置到系统中,并成为系统关键的一部分,就像容器一样。这是一个相当庞大的问题,因为这可能意味着从“让我运行一个带有虚拟软盘的系统”到“运行一个看起来和感觉都有点像EC2的管理程序”,这是非常不同的两件事。我唯一确定的是,我不希望运行同时运行Proxmox和这套编排系统,但我确实需要同时拥有虚拟机和容器。
如何存储?
在我当前的设想中,存储是一个巨大未知数。我缺乏充足的经验,没有太多独到的见解。我觉得CSI太复杂了,应当精简,但除了上面提到的,与生命周期工作流程有关的那一小部分,我也没有好的想法可以提出来。存储是我目前唯一个想保留目前Kubernetes插件化设计的模块,不过一旦我对这方面的知识了解到位,我可能会有不同想法。
最后
写到这里,文章的内容很多,我相信我可能遗漏了一些我一开始想解决的问题或是一些古怪的想法。但是,如果我明天就要着手替换Kubernetes,上面列的几点应该是我一定要改的地方。
我没有过多提及这个行业内的其他玩家——Hashicorp的Nomad,Facebook的Twine,Google的Borg和Omega,Twitter的Mesos。除了Borg之外,我还没有实践过其它方案,无法对其有深刻见解。如果要着手开发一个全新的Kubernetes,我一定先投入更多的时间去了解清楚这些竞品,这样我就可以取其精华,去其糟粕。我也会对Nix进行深入的思考,好好想想如何把它糅合到我的设计中。
老实说,我可能也只是想想而已,什么也没实践。我从Borg上学到了很多关于云计算理念的精髓,而Kubernetes也促使我进行了反思。我目前依旧相信,最好的容器编排系统就是没有容器编排系统,而这种努力将不惜一切代价避免Kubernetes各种坑。显然,这个想法与构建容器编排系统是格格不入的。