本文经授权转自公众号CSDN(ID:CSDNnews)
作者 | Gitpod,责编 | 苏宓
作为一款基于云原生技术的开源在线 IDE,Gitpod 允许开发者通过浏览器即时启动完全配置好的开发环境,无需在本地机器上安装任何软件。正因此,它也受到了不少开发者的喜爱。过去,Gitpod 的工作原理是在 Kubernetes 集群上运行开发环境,这些环境可以根据开发者的需要进行定制,包括安装特定的工具、设置环境变量等。近日,Gitpod 联合创始人、CTO Christian Weichel 发文重磅宣布,「他们决定放弃使用 Kubernetes」,而这究竟是怎么一回事?我们将从其官宣文章中一探究竟。
来源:https://www.gitpod.io/blog/we-are-leaving-kubernetes#lessons-from-the-uvm-experiment
以下为译文:
Kubernetes 似乎是构建远程、标准化和自动化开发环境显而易见的选择。我们也有同感,并且在过去六年里投入了大量精力,打造了一个最受欢迎的云开发环境平台。这个平台拥有 150 万用户,我们每天都会看到成千上万的开发环境。然而,在这段时间里,我们发现 Kubernetes 并不适合用来构建开发环境。
多年来,我们尝试用 Kubernetes 开发了涉及 SSD、PVC、eBPF、seccomp通知、TC、io_uring、shiftfs、FUSE 和 idmapped 挂载等多个程序,覆盖微虚拟机(microVMs)、kubevirt 到 vCluster 等。
我们的目标是追求最优化的基础设施,以平衡安全性、性能和互操作性。同时,我们还要应对构建这样一个系统所面临的独特挑战——这个系统能够扩展、在处理任意代码执行时保持安全,并且足够稳定供开发者使用。
这并不是关于是否应该在生产负载中使用 Kubernetes 的故事,那是另一个话题。同样,如何在 Kubernetes 上为应用交付构建全面的端到端开发者体验也是一个独立的话题。
今天这篇文章将是一个关于如何(以及为何 Kubernetes 不适合)在云端构建开发环境的故事。
1、为什么开发环境是独一无二的?
在深入探讨之前,理解开发环境与生产工作负载相比的独特之处至关重要:
它们具有高度的状态性和交互性:这意味着它们不能从一个节点移动到另一个节点。大量的源代码、构建缓存、Docker 容器和测试数据都具有很高的变更率,迁移成本高昂。与许多生产服务不同的是,开发者与其环境之间存在一对一的互动。
开发者对源代码及其修改非常重视:开发者不愿意丢失任何源代码的更改,也不愿意因为系统问题而受阻。这使得开发环境对故障特别敏感。
资源使用模式不可预测:开发环境有着特殊且不可预测的资源使用模式。大多数时候它们不需要太多的 CPU 带宽,但在几百毫秒内可能就需要几个核心。任何比这更慢的情况都会表现为无法接受的延迟和响应迟钝。
这些特性使开发环境区别于典型的应用程序负载,并对我们沿途做出的基础设施决策产生了重大影响。
2、当今的系统:显然它是 Kubernetes
当我们开始 Gitpod 的时候,Kubernetes 似乎是我们基础设施的理想选择。它承诺的可扩展性、容器编排和丰富的生态系统与我们对云开发环境的愿景完美契合。然而,随着我们的扩展和用户群体的增长,我们在安全性和状态管理方面遇到了一些挑战,这些挑战将 Kubernetes 推向了极限。从根本上说,Kubernetes 是为了运行良好控制的应用负载而设计的,而不是无序的开发环境。
大规模管理 Kubernetes 是复杂的。虽然像 GKE 和 EKS 这样的托管服务减轻了一些痛点,但它们也带来了自己的限制和局限性。我们发现,许多希望运营CDE(云开发环境)的团队低估了 Kubernetes 的复杂性,这导致了我们之前的自管 Gitpod 产品有相当大的支持负担。
3、资源管理难题
我们面临的一个最重要的挑战就是资源管理,特别是每个环境的 CPU 和内存分配。乍一看,在一个节点上运行多个环境似乎很吸引人,可以在这些环境之间共享资源(如CPU、内存、I/O和网络带宽)。实际上,这样做会导致显著的邻居干扰效应,从而损害用户体验。
CPU 挑战
CPU 时间似乎是环境间共享的最简单选择。大多数时候,开发环境不需要太多 CPU,但当它们需要时,必须迅速得到。如果用户的语言服务器开始滞后或终端变得卡顿,延迟会立即显现出来。开发环境的 CPU 需求通常是尖峰性质的(不活跃期后跟着密集的构建),这使得很难预测何时需要 CPU 时间。
为了解决这些问题,我们尝试了各种基于 CFS(完全公平调度器)的方案,并使用 DaemonSet 实现了一个自定义控制器。一个核心挑战是,我们无法预测何时需要 CPU 带宽,只能在看到 cgroup 的 cpu_stats 中的 nr_throttled 时才知道何时需要。
即使使用静态的 CPU 资源限制,也会遇到挑战,因为与应用程序负载不同,开发环境中会在同一个容器内运行许多进程。这些进程竞争相同的 CPU 带宽,可能会导致 VS Code 断开连接,因为 VS Code 服务器缺乏 CPU 时间。
我们试图通过调整各个进程的优先级来解决这个问题,比如增加 bash 或 vscode-server 的优先级。然而,这些进程优先级适用于整个进程组(取决于你的内核自动组调度配置),因此也适用于在 VS Code 终端中启动的资源消耗型编译器。使用进程优先级来对抗终端延迟需要一个精心编写的控制循环才能有效。
我们引入了基于 cgroupv1 的自定义 CFS 和进程优先级控制循环,并在托管 Kubernetes 平台广泛支持 cgroupsv2 时转向了后者。Kubernetes 1.26 引入的动态资源分配意味着不再需要部署 DaemonSet 并直接修改 cgroups,这可能是以牺牲控制循环速度和效果为代价的。上述所有方案都依赖于每秒调整 CFS 限制和精细度值。
内存管理
内存管理带来了自己的挑战。给每个环境分配固定的内存数量,确保在最大占用时每个环境都能得到其固定的份额,这看起来很简单,但非常有限制性。在云中,RAM 是一种较为昂贵的资源,因此有过度预订内存的需求。
直到 Kubernetes 1.22 版本引入了交换空间(swap space),内存过度预订几乎是不可能实现的,因为回收内存不可避免地意味着杀死进程。随着交换空间的加入,过度预订内存的需求有所减弱,因为在实际操作中,交换空间对于托管开发环境表现良好。
4、存储性能优化
存储性能对于开发环境的启动性能和体验至关重要。我们发现,特别是 IOPS(每秒输入输出操作次数)和延迟会影响环境内的体验。而 I/O 带宽则直接影响你的工作空间启动性能,特别是在创建/恢复备份或提取大型工作空间镜像时。
为了找到速度和可靠性、成本和性能之间的正确平衡,我们尝试了多种设置。
SSD RAID 0:这种方式提供了高 IOPS 和带宽,但将数据绑定到了特定的节点上。任何一个磁盘的故障都会导致数据的完全丢失。目前 gitpod.io 就是这样运作的,而且我们还没有遇到过这样的磁盘故障。这种设置的一个简化版本是使用单个 SSD 连接到节点上。这种方法提供的 IOPS 和带宽较低,而且仍然将数据绑定到单独的节点上。
块存储(如 EBS 卷或 Google 持久磁盘),这些永久连接到节点上的存储大大拓宽了可用的不同实例或可用区。尽管它们仍然绑定到单个节点上,并且提供的吞吐量/带宽远低于本地SSD,但它们更加普及。
当使用 Kubernetes 时,PVC 似乎是显而易见的选择。作为不同存储实现的抽象层,它们提供了很大的灵活性,但也引入了新的挑战:
不可预测的挂载和卸载时间,导致不可预测的工作空间启动时间。结合增加的调度复杂度,它们使得实施有效的调度策略变得更加困难。
可靠性问题导致工作空间故障,尤其是在启动期间。这一点在 2022 年的 Google Cloud 上尤为明显,使得我们尝试使用 PVC 变得不切实际。
每个实例可以附加的磁盘数量有限,对调度器和每个节点的工作空间数量施加了额外的限制。
可用区(AZ)局部性约束,使得在AZ之间平衡工作空间变得更加困难。
事实证明,本地磁盘的备份和恢复被证明是一项昂贵的操作。我们实现了一个使用 daemonSet 的解决方案,该方案上传和下载未压缩的 tar 归档文件至 S3。这种方法要求仔细平衡 I/O、网络带宽和 CPU 使用:例如,(解)压缩归档文件会消耗节点上大部分可用的 CPU,而未压缩备份产生的额外流量通常不会消耗所有可用的网络带宽(如果同时启动/停止的工作空间数量受到谨慎控制的话)。
节点上的 I/O 带宽是在工作空间之间共享的。我们发现,除非我们限制每个工作空间可用的I/O带宽,否则其他工作空间可能会因 I/O 带宽不足而停止工作。特别是内容备份/恢复会产生这个问题。为了解决这个问题,我们实现了基于 cgroup 的 I/O 限速器,对每个环境强加了固定的 I/O 带宽限制。
5、自动伸缩和启动时间优化
我们的主要目标是尽量缩短启动时间。不可预测的等待时间会严重影响生产力和用户满意度。然而,这个目标经常与我们希望通过紧密打包工作空间来最大化机器利用率的愿望相冲突。
最初我们认为,在一个节点上运行多个工作空间可以帮助缩短启动时间,因为可以共享缓存。但实际上,这并没有像我们预期的那样奏效。实际情况是,由于需要进行的各种内容操作,Kubernetes 对启动时间设定了一个最低限度,因为内容需要被移动到位,而这需要时间。
除了将工作空间保持在热备用状态(这将非常昂贵)之外,我们不得不寻找其他方法来优化启动时间。
向前拓展:我们的方法演变
为了最大限度地缩短启动时间,我们探索了各种扩大规模和向前拓展的方法:
幽灵工作空间:在集群自动伸缩器插件出来之前,我们尝试了“幽灵工作空间”。这些是抢占式的 Pod,用来提前占住空间。我们通过自定义调度器实现了这一点。但是,这种方法替换起来既慢又不可靠。
压载舱:这是幽灵工作空间的一个改进版,压载舱会填满整个节点。相比幽灵工作空间,这种方法减少了替换成本,并加快了替换时间。
自动伸缩器插件:2022 年 6 月,当集群自动伸缩器插件推出时,我们开始使用这些插件。通过这些插件,我们不再需要“欺骗”自动伸缩器,而是可以直接控制扩缩容的方式。这大大改进了我们的扩缩容策略。
针对峰值负载的比例自动伸缩
为了更有效地处理高峰期的负载,我们实现了一个比例自动伸缩系统。这个系统通过启动环境的速度来控制扩增的速度。它是通过启动使用 pause 镜像的空 Pod 来工作的,这样我们可以在需求激增时快速增加容量。
镜像拉取优化:多次尝试的故事
启动时间优化的另一个关键方面是提高镜像拉取速度。工作空间容器镜像(即开发者可用的所有工具)解压后可能超过 10GB。每次为工作空间下载和解压这么多数据都会极大地消耗节点的资源。我们探索了多种策略来加速镜像拉取:
DaemonSet 预拉取:我们尝试使用 DaemonSet 预先拉取常用的镜像。然而,这种方法在扩容时效果不佳,因为当节点上线时,工作空间开始启动,镜像仍然不在节点上。此外,预拉取会与正在启动的工作空间争夺 IO 和 CPU 资源。
最大化层复用:我们使用自定义构建器 dazzle 来独立构建镜像层,目的是最大化层复用。然而,由于 OCI 清单中的高基数和大量的间接引用,层复用很难实现。
预烘焙镜像:我们尝试将镜像烘焙到节点的磁盘镜像中。虽然这提高了启动时间,但也有一些明显的缺点。镜像很快就会过时,而且这种方法不适用于自托管安装。
Stargazer 和懒加载:这种方法要求所有镜像都要转换,这增加了操作的复杂性、成本和时间。此外,当我们 2022 年尝试时,并非所有镜像仓库都支持这种方法。
Registry-facade + IPFS:这个方案在实际使用中表现良好,提供了良好的性能和分发效果。我们在 2022 年的 KubeCon 大会上介绍了这种方法。然而,它给我们的系统引入了显著的复杂性。
没有一种通用的镜像缓存解决方案,只有在复杂性、成本和对用户限制(可以使用的镜像)之间的权衡。我们发现,工作空间镜像的同质化是最简单的方式来优化启动时间。
6、网络复杂性
Kubernetes 中的网络带来了一些挑战,具体包括:
开发环境访问控制:默认情况下,不同环境的网络需要完全隔离,也就是说一个环境不能访问另一个环境。同样,用户也不能随意访问其他工作空间。网络策略在这方面起了重要作用,确保各个环境之间正确隔离,互不干扰。
初始阶段的访问控制:一开始,我们使用 Kubernetes 服务来控制对个别环境端口(比如 IDE 或工作空间中运行的服务)的访问,同时用一个入口代理来转发流量到这些服务,通过 DNS 解析。但是,当服务数量变多时,这种方法在大规模下变得不可靠。名称解析会出问题,如果不小心(比如设置 enableServiceLinks: false),甚至会导致整个工作空间崩溃。
节点上的网络带宽共享:节点上的网络带宽是另一个需要在多个工作空间之间共享的资源。有些 CNIs(容器网络接口)提供网络整形支持,比如 Cilium 的带宽管理器。这意味着你不仅要控制网络带宽,还可能需要在不同的环境之间公平分配。
7、安全性和隔离:平衡灵活性和保护
我们在使用 Kubernetes 建立基础设施时,最大的挑战之一是在确保安全的同时,还要给用户足够的灵活性。用户希望可以安装额外的工具(比如用 `apt-get install`),运行 Docker,甚至在他们的开发环境中搭建一个 Kubernetes 集群。要在这些需求和严格的安全措施之间找到平衡,是一件非常复杂的事情。
简单的做法:root 访问权限
最简单的解决方案是给予用户对容器的 root 访问权限。然而,这种方法很快暴露了它的缺陷:
给用户根权限实际上等于给了他们整个节点的最高权限,他们可以访问开发环境平台和其他在同一节点上运行的开发环境。
这样做就没有任何有效的安全边界了,意味着开发者可能会无意或有意地干扰和破坏开发环境平台,甚至可以访问其他人的开发环境。
这还会让基础设施容易被滥用,增加安全风险。因此,无法实现真正的访问控制,也无法满足零信任的要求。你无法确保系统中执行操作的人真的是他们自己。
显然,需要一个更复杂的方法来解决这个问题。
用户命名空间:更细致的解决方案
为了解决这些挑战,我们转向了用户命名空间,这是 Linux 内核的一项功能,提供了对容器内部用户和组 ID 映射的细粒度控制。这种方法使我们能够在不危及主机系统安全的情况下,给予用户“类似 root”的权限。
尽管 Kubernetes 在 1.25 版本中引入了对用户命名空间的支持,但我们早在 Kubernetes 1.22 版本时就已经实现了自己的解决方案。我们的实现涉及几个复杂的组件:
- 我们继续使用 shiftfs 来转换文件系统的用户 ID。虽然在某些情况下已经被弃用,但 shiftfs 仍然能满足我们的需求,并且性能不错。
- 我们试过 fuse-overlayfs,它能满足需求,但性能有问题。
- 虽然 idmapped 挂载有潜在的好处,但由于各种兼容性和实现问题,我们还没有切换到这种方法。
- 我们构建了一个屏蔽的 proc 文件系统。
- 然后把这个屏蔽的 proc 移动到正确的挂载命名空间。
- 我们使用 seccomp 通知来实现这一点,它可以拦截并修改某些系统调用。
FUSE 支持:添加 FUSE(用户空间文件系统)支持对很多开发工作流非常重要,这需要实现自定义设备策略。这涉及到修改容器的 eBPF 设备过滤器,这是一种低级别的编程能力,使我们能够对设备访问做出细粒度的决策。
网络功能:作为真正的根用户,拥有 CAP_NET_ADMIN和CAP_NET_RAW 权限,可以进行广泛的网络配置。容器运行时(如Docker/runc)广泛使用这些权限。如果把这些权限授予开发环境容器,会干扰 CNI 并破坏安全隔离。
为了提供这些功能,我们在 Kubernetes 容器内部创建了另一个网络命名空间,先用 slirp4netns 连接外部世界,后来用 veth 对和自定义 nftables 规则。
启用 Docker:需要对 Docker 进行一些特定的修补。我们注册了一个自定义的 runc-facade,这个 facade 修改了 Docker 生成的 OCI 运行时规范。这使我们可以移除如 OOMScoreAdj 等项,因为这仍然不允许,因为它需要节点上的 CAP_SYS_ADMIN 权限。
实施这一安全模型带来了以下挑战:
性能影响:我们的一些解决方案,特别是早期的如 fuse-overlayfs,对性能有明显的影响。我们一直在努力优化这些方案。
兼容性:并不是所有工具和工作流都与这种受限环境兼容。我们必须仔细平衡安全性和可用性。
复杂性:最终的系统比简单的容器化环境要复杂得多,这增加了开发和运维的开销。
跟上 Kubernetes 的步伐:随着 Kubernetes 的发展,我们必须不断调整我们的自定义实现,以便利用新功能同时保持向后兼容性。
8、微虚拟机实验
在应对 Kubernetes 带来的挑战时,我们开始探索像 Firecracker、Cloud Hypervisor 和 QEMU 这样的微虚拟机(micro-VM, uVM)技术,作为一种潜在的折衷方案。这一探索是由改善资源隔离、与其他工作负载(如 Kubernetes)的兼容性和安全性,同时可能保留容器化部分优势的承诺驱动的。
微虚拟机的前景
微虚拟机提供了几项诱人的好处,这些好处与我们对云端开发环境的目标高度一致:
增强资源隔离:uVM 承诺提供比容器更好的资源隔离,尽管是以牺牲过度预订能力为代价的。使用 uVM,我们将不再需要与共享内核资源作斗争,这可能导致每个开发环境的性能更加可预测。
内存快照和快速恢复:其中一个最令人兴奋的功能,特别是在使用 userfaultfd 的 Firecracker 中,是对内存快照的支持。这项技术承诺实现近乎即时的全机器恢复,包括正在运行的进程。对于开发者来说,这意味着环境启动得更快,而且可以从上次停下来的地方继续工作。
改进安全边界:uVM 有可能作为坚固的安全边界,可能消除我们在 Kubernetes 设置中实现的复杂用户命名空间机制的需要。这可以提供与更广泛工作负载的完全兼容性,包括嵌套容器化(在开发环境中运行 Docker 或甚至是 Kubernetes)。
微虚拟机的挑战
然而,我们对微虚拟机的实验揭示了几个重要的挑战:
- Firecracker:
- 缺乏 GPU 支持,这对某些开发工作流越来越重要。
- 在我们实验时(2023 年中期),缺乏 virtiofs 支持,限制了我们高效文件系统共享的选项。
- Cloud Hypervisor:
- 由于缺乏 userfaultfd 支持,快照和恢复过程较慢,抵消了我们希望从 uVM 获得的一个关键优势。
数据移动挑战:使用 uVM 后,数据移动变得更加困难,因为我们现在必须处理大内存快照。这影响了调度和启动时间,这两者对于云端开发环境中的用户体验至关重要。
存储考量:我们尝试将 EBS 卷附加到微虚拟机上,这开启了新的可能性,但也引发了一些新的问题:
持久化存储:把工作空间的内容保存在附加的硬盘上,这样就不需要每次都从 S3 拉取数据,可以加快启动速度,减少网络使用。
性能考量:在不同的工作空间之间共享高性能的硬盘可以提高数据读写速度,但也带来了如何有效分配资源、管理延迟和保证系统扩展性的问题。
从微虚拟机实验中学到的经验
虽然微虚拟机最终没有成为我们的主要基础设施解决方案,但这次实验提供了宝贵的见解:
我们喜欢微虚拟机为开发环境提供的完整工作空间备份和运行时状态挂起/恢复的体验。
我们第一次考虑从 Kubernetes 转向其他选择。将 KVM 和 uVM 集成到 Pod 中的努力使我们探索了 Kubernetes 之外的选项。
我们再次认识到存储是提供三个关键点的核心要素:可靠的启动性能、可靠的工作空间(不丢失我的数据)和最佳的机器利用率。
9、Kubernetes 作为开发环境平台的挑战
正如我在开头提到的,对于开发环境,我们需要一个尊重开发环境独特状态性质的系统。我们需要给予开发者必要的权限以提高生产效率,同时确保安全边界。并且,我们需要在保持低运营开销和不妥协安全性的前提下完成这一切。
今天,使用 Kubernetes 实现上述所有目标是可能的——但这需要付出巨大的代价。我们以艰难的方式学到了应用工作负载和系统工作负载之间的区别。
Kubernetes 的发展虽然让人惊叹,不仅是因为它有一个积极和热情的社区的支持,同时它也构建了一个真正丰富的生态系统。如果你运行的是应用工作负载,Kubernetes 仍然是一个不错的选择。然而,对于像开发环境这样的系统工作负载,Kubernetes 在安全性和运营开销方面提出了巨大的挑战。虽然微虚拟机和明确的资源预算可以在一定程度上帮助解决这些问题,但它们也会带来更高的成本。例如,微虚拟机虽然提供了更好的安全隔离和资源管理,但它们的运行开销比传统的容器更高,这会导致整体成本上升。
因此,经过多年有效地逆向工程和将开发环境强行迁移到 Kubernetes 平台上之后,我们退一步思考未来的开发架构应该是什么样子。2024 年 1 月,我们开始了建设。2024 年 10 月,我们发布了 Gitpod Flex(https://www.gitpod.io/blog/introducing-gitpod-flex)。超过六年的宝贵经验,用于在互联网规模上安全地运行开发环境,被融入了架构基础。
10、开发环境的未来
在 Gitpod Flex 中,我们继承了 Kubernetes 的基础方面,例如控制理论和声明式 API 的自由应用,同时简化了架构并改进了安全基础。
我们使用一个受 Kubernetes 启发的控制平面来编排开发环境。我们引入了一些特定于开发环境的必要抽象层,并抛弃了我们不需要的大量基础设施复杂性——同时始终将零信任安全放在首位。
这种新的架构允许我们无缝集成 devcontainer。我们还解锁了在桌面运行开发环境的能力。现在,我们不再承担 Kubernetes 平台的沉重负担,Gitpod Flex 可以在不到三分钟的时间内自托管部署,并可以在任意数量的区域部署,提供更细粒度的合规控制和建模组织边界和领域的灵活性。
在构建标准化、自动化和安全的开发环境平台时,需要时刻谨记,选择一个系统是因为它能改善用户的开发者体验,减轻运营负担。
本文转自公众号“CSDN”,ID:CSDNnews
---END---