作者|Christian Weichel、Alejandro de Brito Fontes
提供容器化开发环境的 Gitpod 已将其服务从 Kubernetes 迁移到名为 Flex 的新自主平台,理由是 Gitpod 认为 Kubernetes 存在复杂性、资源管理和状态管理方面的问题。
Gitpod 联合创始人兼首席技术官 Christian Weichel 和工程师 Alejandro de Brito Fontes 表示, Kubernetes “似乎是构建远程、标准化和自动化开发环境的不二之选”,自 Gitpod 成立以来就一直在使用。不过现在,他们共同写了篇文章回顾了 Gitpod 的 Kubernetes 采用之旅以及在此过程中踩过的坑,他们认为,从他们的技术实践来看 Kubernetes 就是一条“死胡同”。
Gitpod 确实认为 Kubernetes 不适合生产应用,但声称开发环境是一个特例,因为它们具有极强的状态性、资源使用情况不可预测,并且需要广泛的权限。
该文章在 Hacker News 上引发了剧烈讨论。事实上,有些问题并非开发环境所独有。例如,复杂性被认为是“巨大的支持负担”,可能 Gitpod 团队低估了这些挑战。“托管的 Kubernetes 服务有所帮助,但也有自己的限制(例如 GKE 上的内核版本)——状态处理和存储仍未解决,” Weichel 在 Hacker News 上如是说。 近年来,Kubernetes 似乎已经成为建立远程标准化及自动化开发环境的最佳选项,我们也不例外。之前六年时间,我们大规模建立起广受欢迎的云开发环境平台,其中的 150 万用户定期运行着成千上万个开发环境。但在实践过程中,我们逐渐意识到 Kubernetes 并不是建立开发环境的正确选择。
我们曾在 Kubernetes 上开展过建立开发环境的诸多实验,过程当中不乏各种失败和挫折。多年来,我们尝试过许多涉及 SSD、PVC、EBPF、SecComp Notify、TC 和 io_uring、shiftfs,FUSE 和 idmapped mounts 的想法,范围也从 Microvms、Kubevirt 再到 Vcluster。
所谓追求最佳基础设施,就是希望在安全性、性能和互操作性之间找到平衡点。使其能够在稳定运行的同时,建立起一套能够适应系统扩展挑战的解决方案,既能够在处理代码执行时保持安全、又能以良好的稳定性支撑起开发者的使用需求。
接下来的内容主要探讨的,并不在于 Kubernetes 到底适不适合用于支撑生产级工作负载。相反,我们的重点在于怎样在 Kubernetes 上建立起全面且良好的开发者使用体验。
换言之,我们想要讨论的,是如何在云端构建起理想的开发环境。
在深入讨论之前,我们先聊聊与生产工作负载相比,开发环境到底有哪些关键的特别之处:
开发环境具有极高状态性与交互性:也就是说,我们无法轻易将开发环境在不同节点之间往来转移。源代码、构建缓存、Docker 容器以及测试数据等大量信息都有着极高的变化率,而且迁移成本很高。与各种生产服务不同,开发人员与其环境之间往往保持着一对一的紧密交互关系。
开发人员对其源代码及相应变更进行了深入投资:开发人员绝不愿丢失任何源代码变更或者被任何系统所阻断。也就是说,开发环境对于故障的容忍度极低。
开发环境具有不可预测的资源使用模式:开发环境有着特殊且不可预测的资源使用模式。在大多数情况下,它们不需要太多 CPU 资源,但又可能在某 100 毫秒之内同时调用好几个计算核心。如果资源无法支撑调用需求,则可能产生开发者无法接受的卡顿和未响应问题。
开发环境需要深入的权限和功能:与生产级工作负载不同,开发环境通常需要 root 访问以及下载 / 安装软件包的能力。在生产工作负载中会引发安全威胁的问题,在开发环境中则属于最常规的预期行为,具体包括获取 root 访问权限、扩展网络功能以及对系统的控制能力(例如安装其他文件系统)等等。
这些特征共同成就了开发环境的特殊性,而且与典型的应用工作负载不同,这将显著影响我们在开发过程中做出的每一个基础设施决策。
当我们启动 Gitpod 时,Kubernetes 似乎就是最理想的基础设施选项。它对可伸缩性、容器编排以及丰富生态系统的承诺,与我们对于云开发环境的愿景完全一致。然而,随着我们业务规模的扩展以及用户群体的增长,我们在安全性和状态管理方面开始遇到一些挑战,并很快意识到 Kubernetes 在这些方面存在局限。从根本上讲,Kubernetes 是为了运行具有良好控制的应用类工作负载所设计,却并不太适应以不守规矩为常态的开发环境。
对 Kubernetes 进行大规模管理是件复杂的工作。尽管 GKE 和 EKS 之类的托管服务有助于缓解某些痛点,但它们自己也同样会带来一系列约束和局限性。我们发现,许多希望操作 CDE 的团队低估了 Kubernetes 的复杂性,这也为我们长久以来自主管理的 Gitpod 产品带来了沉重的支持负担。
我们面临的核心挑战之一就是资源管理,特别是在不同环境下如何分配 CPU 与内存资源。乍看之下,在节点上运行多个环境,似乎意味着可能在各节点之间共享资源(例如 CPU、内存、IO 和网络带宽)。但在实践层面,这会产生严重的相邻影响,进而损害用户的开发体验。
CPU 时间似乎是在不同环境间最易于共享的资源类型。大多数时间下,开发环境并不需要占用太多 CPU 资源,但在需要时又要求迅速提供。而一旦用户的语言服务器开始滞后或者终端发生卡顿时,开发体验将立即受到影响。开发环境这种高度强调峰值状态的 CPU 需求(大段闲置期加上频繁出现的偶发资源需求)导致我们很难对其趋势做出预测。
至于解决方案,我们尝试了基于各种 CFS(完全公平调度程序)的方案,并使用 Daemonset 建立了自定义控制器。但其中最大的挑战,在于我们无法预测何时需要 CPU 带宽,而只能发现何时需要(通过观察 CGROUP CPU_STATS 的 nr_throttled)。
即使是使用静态 CPU 资源限制,也同样无法彻底解决挑战。因为与应用级工作负载不同,开发环境需要在同一容器中运行多个进程。这些进程会相互竞争 CPU 带宽,因此导致 VS Code 服务器无法获取 CPU 时间而断开连接等问题。
我们也尝试过调整各个进程的优先级来解决这个问题,例如上调 BASH 或者 VScode-server 的优先级。然而,这些进程优先级适用于整个进程组(取决于内核的 autogroup scheduling 调度配置),因此 VS Code 终端内启动的编译器也会大量吞噬计算资源。总而言之,要想使用进程优先级来对抗终端卡顿,至少得精心设计出符合开发实践要求的控制循环才有可能起效。
我们还引入了基于 cgroupv1 构建的自定义 CFS 和基于进程优先级的控制循环,并在 Kubernetes 1.24 平台上迎来了 cgroupsv2。由 Kubernetes 1.26 版本引入的动态资源分配,意味着我们不再需要部署守护程序并直接修改 cgroup,但这似乎需要以控制循环速度和有效性为代价。总之,这些方案都依赖于对 CFS 限制还有正确取值的秒级重新调整,对应的工作量可见一斑。
内存管理也有属于自己的一系列挑战。为每个环境分配固定数量的内存,意味着在最大占用之下,分配过程虽然简单但成效有限。在云端,内存则属于成本高昂的资源类型,每位租户都希望超订、俗称“多吃多占”。
在 Kubernetes 1.22 中 swap-space 出现之前,我们几乎不可能对内存进行超订,因为回忆的回收将不可避免地消除进程。随着 swap-space 的引入,超订内存的需求开始逐渐消失,托管开发环境中的交换实践真正成为首选方案。
存储性能对于启动性能和开发环境的使用体验同样非常重要。我们发现,IOPS 和延迟效应对于开发者体验的影响尤其明显。其中 I/O 带宽直接影响工作区的启动性能,在创建 / 还原备份或者提取成规模的工作区内容时体现得最为直接。
我们尝试了各种设置,希望能在速度、可靠性、成本以及性能之间找到适当的平衡。
SSD RAID 0:提供高 IOPS 与传输带宽,但需要将数据绑定至特定节点。任何单个磁盘的故障都会导致数据完全丢失。这也是 GitPod.io 当前动作的方式,好在我们还没有遇到过磁盘故障的情况。这套设置还有进一步简化的版本,就是使用附加至节点上的单块 SSD。这种方法提供的 IOPS 和传输带宽较低,但仍能够将数据与单个节点结合在一起。
使用 EBS 分卷或者谷歌持久磁盘等永久接入节点的块存储:这类方案能够大大扩展可供使用的不同实例或者可用性区域。但缺点在于仍须绑定至特定节点,且提供的吞吐量 / 传输带宽比本地 SSD 相比又要低得多。
在使用 Kubernetes 时,持久卷声明(PVC)似乎就是最明显的选项。作为对不同存储实现的抽象方案,它能提供很大的灵活性,但也会带来以下几种新挑战:
附加与拆分时间无法预测,导致工作区的启动速度也不可预测。再结合由调度机制增加的复杂性,导致有效执行规划策略变得更加困难。
可靠性问题导致工作区故障,尤其是在启动期间。这个问题在 Google Cloud(2002 年)上惹出过大麻烦,也让我们意识到不可能选择使用 PVC。
可附加至实例的磁盘数量有限,这会对调度程序乃至各节点的恭区数量施加进一步约束。
可用区的局部性约束,导致在跨各可用区的工作区之间进行负载均衡变得更加困难。
事实证明,对本地磁盘的备份和恢复是现基成本高昂的操作。我们使用 Daemonset 实施了一套解决方案,该方案面向 S3 上传和下载的均是未经压缩的 TAR 归档文件。这种方法需要认真平衡 I/O、网络带宽和 CPU 资源:例如,压缩档案会在节点上消耗掉大量可用 CPU,而未压缩备份产生的额外流量则会严重占用网络传输带宽(除非认真控制同时启动 / 停止的工作区数量)。
节点上的 I/O 带宽跨工作区共享。我们发现,除非对各个工作区上的可用 I/O 带宽做出限制,否则其他工作区很可能由于得不到 I/O 带宽而停止运行。内容备份 / 还原造成的此类问题最为严重。为此我们实施了基于 cgroup 的 IO 限制器,用于为各个环境施加固定的 I/O 带宽限制以解决这方面问题。
我们的另一个主要目标,就是不惜一切代价尽可能缩短启动时间。不可预测的等待时间会大大影响生产效率和用户满意度。但是,这个目标往往跟我们希望密集加载工作区,从而实现设备利用率最大化的愿望相互抵触。
我们刚开始认为,在同一节点上运行多个工作区将有助于优化启动时间,毕竟这样可以实现缓存资源共享。但实际情况与我们的预期完全相反。现实情况是,Kubernetes 为了应对所有将要执行的操作而令启动时间有了硬性下限,就是为了给负载转移到位留下充足的时间。
所以除了将工作区保持在热待机状态(但这非常昂贵)之外,我们还得找到其他方法来优化启动速度。
为了最大程度缩短启动时间,我们探索了各种规模扩展方法:
Ghost 工作区:在集群 autoscaler 插件出现之前,我们先是实验了“ghost 工作区”。这些属于占位用的 pod,提前占据了接下来可能要使用的空间。我们使用自定义调度程序实现了这项功能,但事实证明这套方案速度很慢而且不够可靠。
镇流 pod:属于对 ghost 工作区概念的演变,镇流 pod 会充斥整个节点。与 ghost 工作区相比,这种方法的替换成本更低且替换速度更快。
Autoscaler 插件:2022 年 6 月,我们开始正式使用集群 autoscaler 插件。有了这些插件,我们不再需要以“欺骗”的方式实现自动规模伸缩,而能够直接操纵扩展的执行方式。这也标志着我们的绽放策略迎来了一波重大改进。
为了更有效地处理峰值负载,我们建立起一套等比例自动伸缩系统。该方案能够控制伸缩速率,依靠的就是初始开发环境的速率函数。它能够使用暂停镜像启动空 pod 来发挥作用,使得我们能够快速纠结容量以满足峰值需求。
启动时间优化当中的另一个关键方向,就是改善镜像拉取时间。工作区容器镜像(包含开发者所使用的全部工具)最多可能达到 10 GB 的未压缩体量。因此在为各个工作区下载和提取相应数据时,自然会严重占用节点上的资源。为此,我们探索了多种加快镜像拉取速度的策略:
Daemonset 预拉取:我们尝试使用 Daemonset 预先拉取常用的镜像。然而,事实证明这种方式在大规模操作中根本无效,因为当节点上线且工作区启动时,这些镜像仍不在节点之上。此外,预拉取机制还会跟刚刚启动的工作区争夺 IO 和 CPU 带宽。
最大化层重用:我们使用名为 Dazzler 的自定义 builder 建立了自己的镜像,这款 builder 工具能够构建起独立的镜像层。这种方法旨在最大程度利用分层架构。但我们发现,由于 OCI 表现出的高基数和间接负载规模,其实很难观察镜像层的实际复用率。
预烧录镜像:我们尝试将镜像烧录至节点磁盘当中。尽管这确实能够改善启动时间,但也有其他严重缺点。由于这些镜像很快就会过时,所以对于自托管安装根本不起作用。
预测和懒拉取:这种方法需要转换所有镜像,这进一步增加了我们操作的复杂性、成本和时间投入。此外,在 2022 年进行尝试时,还有相当一部分注册表不支持这种方法。
注册表 facade+IPFS:这套解决方案在实践中效果很好,能够实现良好的性能与镜像分发表现。我们在 2022 年的 KubeCon 大会上分享过这套方案。唯一的缺点,就是它大大增加了我们系统的复杂性。
可以看到,并不存在一种能够适应所有镜像缓存需求的解决方案,我们只能在复杂性、成本和限制(可以使用的镜像)之间做出权衡。最终在实践中,我们发现保证工作区镜像的同质性才是优化启动时间最为直接的方法。
Kubernetes 中的网络也有自己的一系列挑战,具体包括:
最初,我们使用 Kuberntes 服务控制了对单个环境端口(例如在工作区中运行的 IDE 或者服务)的访问,并且连带将访问量转发至服务的 Ingress 代理,再使用 DNS 解析该服务。但由于服务数量过于庞大,这种方式很快就出现了可靠性问题。名称解析经常失败,稍有不慎(例如设置 enableServiceLinks:false)甚至可能导致整个工作区宕机。
我们在基于 Kubernetes 的基础设施当中面临的最大挑战之一,就是如何提供一套安全的环境,同时为用户提供高效开发所必需的灵活性。用户希望能够随时安装其他工具(例如使用 APT-GET 进行安装)、运行 Docker,甚至在开发环境录中进一步设置 Kubernetes 集群。事实证明,在这些要求与强有力的安全保障之间求取平衡,又成了令人头痛的新难题。
最简单粗暴的解决方案,就是允许用户 root 访问其容器。但是,这种方法很快就暴露出了其缺陷:
为用户提供 root 访问,实际上就相当于向他们开发了针对节点的 root 权限,他们可以对该节点上运行的一切开发环境平台及其他开发环境执行访问。
这意味着用户与主机系统之间再无任何有意义的安全边界。也就是说,开发人员可能会意外或者故意干扰 / 破坏开发环境平台本身,甚至访问其他租户的开发环境。
这还会将基础设施暴露于潜在的滥用和安全风险当中。因此,将无法建立切实可行的访问控制模型,整个架构完全有违零信任原则。我们根本无法保证执行操作的特定系统角色的真实身份与其宣称的身份相一致。
很明显,我们还得想一种更完备的解决办法。
为了应对这些挑战,我们开始转向用户名称空间。这是一项 Linux 内核功能,允许对容器中的用户和组 ID 映射进行细粒度控制。这种方法让我们能够在容器中为用户提供“类 root”的权限,但又不致损害主机系统的安全性。
尽管 Kubernetes 在 1.25 版本中引入了对用户名称空间的支持,但我们早在 1.22 版本起就开始实现了自己的解决方案。我们的实施使用到好几款复杂的组件:
文件系统 UID 转换:确保在容器中创建的文件能够正确被映射至主机系统的 UID。为此,我们尝试了以下几种方法:
我们继续使用 shiftfs 作为文件系统 UID 转换的主要方法。尽管在某些情况下并不适合,但 shiftfs 确实能够提供我们需要的功能,而且性能表现也完全可以接受。
我们还尝试了 fuse-overlayfs,它虽然也能提供必要功能,但在性能方面表现不佳。
虽然 idmapped mounts 具有不少潜在优势,但受到各种兼容性和实施限制的影响,我们还没有全面转向这套方案
FUSE 支持:添加对于 FUSE(用户空间中的文件系统)这套对众多开发流程而言至关重要的系统的支持,需要配合自定义设备策略。具体涉及修改容器的 EBPF(扩展伯克利数据包过滤器)设备过滤器。这是一种底层编程功能,使得我们能够就设备访问做出细粒度决策。
网络功能:作为拥有 CAP_NET_ADMIN 和 CAP_NET_RAW 等真 root 特性的功能,我们可以在配置网络中提供深度权限。各种容器运行时(例如 Docker/runc)都在广泛使用这些功能。将这些功能授予开发环境容器有助于干扰 CNI 并打破安全隔离。
为了实现此类功能,我们最终在 Kubernetes 容器内建立了另一个网络名称空间。该容器首先使用 Slirp4netns 与外部连接,之后再使用 VETH Pair 以及自定义及 Nftables 规则。
但这套安全模型的实施,也给我们带来了以下几项新的挑战:
性能影响:我们的一些解决方案,特别是 fuse-overlayfs 等早期解决方案,对于性能有着明显影响。我们也一直在努力加以优化。
兼容性:这套受限环境还无法兼容所有工具和工作流。我们必须认真在安全性和可用性之间寻求平衡。
复杂性:这套系统明显比常规容器化环境要复杂得多,因此会影响到开发和运营开销。
对齐 Kubernetes:随着 Kubernetes 的逐渐演进,我们必须调整各种自定义实现,在运用新功能的同时保持向下兼容。
在应对 Kubernetes 挑战的过程中,我们开始以中立的态度探索微虚拟机技术的实践意义,包括 Firecracker、Cloud Hypervisor 以及 QEMU 等等。这种探索来自对于改善资源隔离、与其他工作负载(例如 Kubernetes)的安全兼容等追求,同时希望能够维持住容器化技术的固有优势。
微虚拟机提出的承诺可以说相当诱人,也与我们在云开发环境中的目标保持一致:
增强资源隔离:与容器技术相比,微虚拟机承诺提供更好的资源隔离效果,只是需要以超订功能为代价。借助微虚拟机,我们不再需要与内核资源共享相冲突,因此有望让各开发环境获得更加可预测的性能表现。
内存快照与快速恢复:作为最令人兴奋的功能之一,特别是在配合 Firecracker 使用 userfaultfd 的情况下,我们终于获得了对内存快照的支持。这项技术承诺建立起相当完整的机器恢复功能,包括运行中的进程。对于开发者而言,这意味着环境的启动速度更快,而且能够准确还原特定时间点上的状态。
更好的安全边界:微虚拟机拥有强大的安全边界机制,有望消除我们在 Kubernetes 当中设置复杂用户名称空间的硬性需求。这样我们就能与更广泛的工作负载范围实现完全兼容,包括嵌套容器化(在开发环境中运行 Docker 甚至是 Kubernetes)。
但是,我们对微虚拟机的实验也发现了不少重大难题:
运行开销:即使属于轻量化虚拟机,微虚拟机的运行开销也比容器更高。这会影响性能表现与资源占用率两大在云开发环境平台中最为核心的考量因素。
镜像转换:将 OCI(开放容器倡议)镜像转换为 UVM 所需文件系统时,需要借助自定义解决方案。这不仅增加了我们镜像管理管线的复杂性,同时可能会影响到系统启动时间。
特定技术局限:Firecracker:缺乏对 GPU 的支持,而这对于某些开发工作流程正变得越来越重要;在我们实验时(2023 年年中)还无法支持 virtiofs,因此限制了我们在进行文件系统共享时的选择空间。
Cloud hypervisor:由于无法支持 userfaultfd,快照和还原速度太慢,抵消了我们希望从微虚拟机中获取的一大关键优势。
数据移动挑战:由于现在需要处理大规模内存快照,因此导致数据移动变得越来越困难。这会影响到调度和启动时间两项在云开发环境中直接决定用户体验的关键因素。
存储问题:我们在实验中将 EBS 卷附加至微虚拟机的作法似乎可行,但也带来了新的问题:持久存储:将工作区内容保留在附加卷上可以反复从 S3 中获取数据,因此有望改善启动时间并减少网络资源占用。
性能问题:在工作区之间共享高通量卷确实有望提高 I/O 性能,同时也引起了大家对于实施有效配额、管理延迟和保障可伸缩性的担忧。
尽管微虚拟机最终未能成为我们主基础设施的实际解决方案,但本轮实验仍然让我们积累下了宝贵的经验和教训:
我们非常认可这种为开发环境提供完整工作区备份及运行时状态暂停 / 恢复的能力。
我们首次考虑脱离 Kubernetes 将 KVM 和微虚拟机集成至 pod 当中的实验,让我们切实探索了除 Kubernetes 之外的其他选项。
我们再次确定,存储是实现可靠的启动性能、可靠的工作区运行状态(不致丢失业务数据)以及实现最佳机器资源利用率三大关键因素的核心。
正如我们在文章起首所提到,对于开发环境,我们需要一套在设计阶段就充分考虑到其独特状态的托管系统。我们需要为开发人员提供必要的权限,同时又保证具有理想的安全边界。总而言之,我们需要尽一切努力让操作趋近于底层,同时又不致危害安全性。
就目前来看,Kubernetes 确实能够满足上述需求,但却需要付出相当大的代价或者说成本。我们也用自己的血泪教训,真正认识到应用级负载一系统级负载之间的区别。
别误会,Kubernetes 仍然是个令人难以置信的伟大项目,它拥有敬业且热情的社区支持,并且衍生出一个极其丰富的生态系统。如果大家正在运行应用级工作负载,那么 Kubernetes 绝对是个理想的选择。但是对于系统级工作负载,比如说开发环境,Kubernetes 在安全性和运营开销方面都面临着巨大挑战。配合微虚拟机和明确的资源规划肯定有助于解决问题,但也使得成本在决策流程中占据了过高的比重。
因此在经过多年的逆向工程和运用 Kubernetes 构建开发环境的实践探索之后,我们决定退后一步,从头开始规划更适应未来开发架构的平台样式。2024 年 1 月我们开始着手,2024 年 10 月正式发布,这就是 Gitpod Flex。
它的诞生,离不开我们以超大规模体量在六年多时间内安全运行开发环境的实践经验,以及过程当中积累下的无数心得和洞见。
在 Gitpod Flex 当中,我们延续了 Kubernetes 的很多基础性优势,例如对控制理论的自由应用以及声明性 API,同时简化了架构并改善了其安全基础。
我们使用受 Kubernetes 启发而来的控制平面对开发环境进行编排。我们还引入了一系列针对开发环境的必要抽象层,抛弃了大量并不需要的基础设施复杂性因素,同时将零信任安全性放在设计路线的第一位。
Gitpod Flex 安全边界示意图
这套全新架构使得我们能够无缝集成开发容器。我们还解锁了在桌面端运行开发环境的能力。由于不再需要承担 Kubernetes 平台的体量,Gitpod Flex 能够在三分钟以内在任意数量的区域实现自部署,同时在合规性层面实现细粒度控制,还可根据不同组织的业务模型随意调整灵活性边界和领域设置。
在为标准化、自动化且安全的开发环境建立平台时,选择合适的系统无法能帮助我们改善开发者体验、减轻运营负担并改善成本投入。Gitpod Flex 的意义并不在于替代 Kubernetes,而在于为大家提供更多选择,在更广阔的空间之内尽可能改善开发团队的工作体验。
Christian Weichel 是 Gitpod 联创 &CTO,Alejandro de Brito Fontes 是 Gitpod 资深工程师。
原文链接:
https://www.gitpod.io/blog/we-are-leaving-kubernetes
声明:本文由 InfoQ 翻译整理,未经许可禁止转载。