在 Kubernetes 上运行我们的 CDE 六年之后,我们发现 Kubernetes 对我们来说并不是正确的选择。以下是原因。
译自 We’re leaving Kubernetes - Blog,作者 Christian Weichel and Alejandro de Brito Fontes。
Kubernetes 似乎是构建远程、标准化和自动化开发环境的显而易见的选择。我们也曾这样认为,并且六年来一直致力于打造互联网规模上最受欢迎的云开发环境平台。该平台拥有 150 万用户,我们经常看到每天有数千个开发环境。在这段时间里,我们发现 Kubernetes 并不是构建开发环境的正确选择。
这是我们构建基于 Kubernetes 的开发环境的实验、失败和死胡同的旅程。多年来,我们尝试了许多想法,范围从 microVMs、kubevirt 到 vCluster,涉及 SSD、PVC、eBPF、seccomp notify、TC 和 io_uring、shiftfs、FUSE 和 idmapped mounts。
我们追求最佳的基础设施,以平衡安全性、性能和互操作性。同时还要应对构建一个可扩展、在处理任意代码执行时保持安全、并且足够稳定以供开发人员工作的系统的独特挑战。
这不是关于是否将 Kubernetes 用于生产工作负载的故事,这是一个完全不同的讨论。关于如何构建一个全面的、从头到尾的开发者体验以在 Kubernetes 上交付应用程序也是一个单独的话题。
这是关于如何(不)在云中构建开发环境的故事。
在我们深入探讨之前,了解开发环境与生产工作负载相比有何独特之处至关重要:
- 它们是极其有状态且交互式的:这意味着它们无法从一个节点移动到另一个节点。数千兆字节的源代码、构建缓存、Docker 容器和测试数据会发生很大的变化,迁移成本很高。与许多生产服务不同,开发人员与其环境之间存在一对一的交互。
- 开发人员对其源代码及其所做的更改投入了大量精力:开发人员不愿丢失任何源代码更改或被任何系统阻止。这使得开发环境尤其不能容忍失败。
- 它们具有不可预测的资源使用模式:开发环境具有特殊且不可预测的资源使用模式。它们在大多数时间不需要太多 CPU 带宽,但在几百毫秒内将需要多个内核。任何比这更慢的情况都会表现为不可接受的延迟和无响应。
- 它们需要广泛的权限和能力:与生产工作负载不同,开发环境通常需要 root 访问权限以及下载和安装软件包的能力。对生产工作负载构成安全隐患的事情,却是开发环境的预期行为:获得 root 访问权限、扩展的网络功能和对系统的控制(例如,挂载额外的文件系统)。
这些特性使开发环境与典型的应用程序工作负载区分开来,并极大地影响了我们一路做出的基础设施决策。
当我们启动 Gitpod 时,Kubernetes 似乎是我们的基础设施的理想选择。它对可扩展性、容器编排和丰富生态系统的承诺与我们对云开发环境的愿景完美契合。然而,随着我们扩展规模和用户群的增长,我们遇到了围绕安全性和状态管理的几个挑战,这些挑战将 Kubernetes 推到了极限。从根本上说,Kubernetes 的构建是为了运行控制良好的应用程序工作负载,而不是难以管理的开发环境。
大规模管理 Kubernetes 非常复杂。虽然像 GKE 和 EKS 这样的托管服务可以缓解一些痛点,但它们也有其自身的限制。我们发现,许多希望运营 CDE 的团队低估了 Kubernetes 的复杂性,这导致我们之前的自托管 Gitpod 产品带来了巨大的支持负担。
我们面临的最重大挑战之一是资源管理,尤其是每个环境的 CPU 和内存分配。乍一看,在一个节点上运行多个环境似乎很有吸引力,可以在这些资源之间共享资源(例如 CPU、内存、IO 和网络带宽)。在实践中,这会导致严重的“邻居效应”,从而导致用户体验下降。
CPU 时间看起来像是环境之间共享的最简单的候选资源。大多数情况下,开发环境不需要太多 CPU,但当它们需要时,它们需要快速响应。当用户的语言服务器开始滞后或终端变得卡顿时,延迟会立即显现出来。开发环境 CPU 需求的这种峰值性质(不活动期之后是密集的构建)使得难以预测何时需要 CPU 时间。
为了找到解决方案,我们尝试了各种基于 CFS(完全公平调度器)的方案,使用 DaemonSet 实现了一个自定义控制器。一个核心挑战是我们无法预测何时需要 CPU 带宽,而只能通过观察 cgroup 的 cpu_stats
的 nr_throttled
来了解何时需要 CPU 带宽。
即使使用静态 CPU 资源限制,挑战依然存在,因为与应用程序工作负载不同,开发环境将在同一个容器中运行许多进程。这些进程竞争相同的 CPU 带宽,这可能导致例如 VS Code 断开连接,因为 VS Code 服务器缺乏 CPU 时间。
我们尝试通过调整各个进程的进程优先级来解决这个问题,例如提高 bash 或 vscode-server
的优先级。然而,这些进程优先级适用于整个进程组(取决于你的内核的 autogroup
调度配置),因此也适用于在 VS Code 终端中启动的资源密集型编译器。使用进程优先级来对抗终端延迟需要一个精心编写的控制循环才能有效。
我们引入了基于 cgroupv1 构建的自定义 CFS 和进程优先级控制循环,并在托管 Kubernetes 平台上 1.24 版本更易于使用 cgroupsv2 后迁移到了 cgroupsv2。Kubernetes 1.26 引入的动态资源分配意味着人们不再需要部署 DaemonSet 并直接修改 cgroup,这可能是以牺牲控制循环速度和效率为代价的。上述所有方案都依赖于每秒重新调整 CFS 限制和 niceness 值。
内存管理也面临着一系列挑战。为每个环境分配固定数量的内存,以便在最大占用情况下每个环境都能获得其固定的份额,这很简单,但非常有限。在云中,RAM 是更昂贵的资源之一,因此希望过度使用内存。
在 Kubernetes 1.22 中可用交换空间之前,内存超额分配几乎不可能实现,因为回收内存不可避免地意味着杀死进程。随着交换空间的加入,对内存超额分配的需求有所减少,因为交换在实践中非常适用于托管开发环境。
存储性能对于开发环境的启动性能和体验至关重要。我们发现,特别是 IOPS 和延迟会影响环境内的体验。然而,IO 带宽会直接影响你的工作区启动性能,尤其是在创建/恢复备份或提取大型工作区镜像时。
我们尝试了各种设置,以找到速度和可靠性、成本和性能之间的最佳平衡。
- SSD RAID 0: 这提供了高 IOPS 和带宽,但将数据绑定到特定节点。任何单个磁盘的故障都会导致完整的数据丢失。这是 gitpod.io 如今的运作方式,我们还没有看到这样的磁盘故障发生。此设置的更简单版本是使用连接到节点的单个 SSD。这种方法提供的 IOPS 和带宽较低,并且仍然将数据绑定到单个节点。
- 永久连接到节点的块存储(例如 EBS 卷或 Google 永久磁盘): 大大扩展了可使用的不同实例或可用区。虽然仍然绑定到单个节点,并且提供的吞吐量/带宽比本地 SSD 低得多,但它们的可用性更广。
-
在使用 Kubernetes 时,持久卷声明 (PVC) 似乎是显而易见的选择。 作为不同存储实现的抽象,它们提供了很大的灵活性,但也带来了新的挑战:
- 不可预测的连接和分离时间,导致不可预测的工作区启动时间。结合增加的调度复杂性,它们使实现有效的调度策略变得更加困难。
- 可靠性问题导致工作区故障,尤其是在启动期间。这在 Google Cloud(2022 年)上尤其明显,并使我们尝试使用 PVC 变得不切实际。
- 可以连接到实例的磁盘数量有限,这对调度程序和每个节点的工作区数量施加了额外的限制。
- AZ 地域限制使得跨 AZ 平衡工作空间更加困难。
备份和恢复本地磁盘被证明是一项昂贵的操作。我们使用 daemonSet 实现了一个解决方案,该方案将未压缩的 tar 存档上传到 S3 或从 S3 下载。这种方法需要仔细平衡 I/O、网络带宽和 CPU 使用率:例如,(解)压缩存档会消耗节点上的大部分可用 CPU,而未压缩备份产生的额外流量通常不会消耗所有可用网络带宽(如果并发启动/停止工作空间的数量得到仔细控制)。
节点上的 IO 带宽由工作空间共享。我们发现,除非我们限制每个工作空间可用的 IO 带宽,否则其他工作空间可能会因 IO 带宽不足而停止运行。尤其是内容备份/恢复会产生这个问题。我们实施了基于 cgroup 的 IO 限制器,它对每个环境施加了固定的 IO 带宽限制来解决这个问题。
我们的主要目标是不惜一切代价缩短启动时间。不可预测的等待时间会严重影响生产力和用户满意度。然而,这个目标往往与我们希望密集打包工作空间以最大限度地提高机器利用率的愿望相冲突。
我们最初认为在一个节点上运行多个工作空间会有助于缩短启动时间,因为可以共享缓存。然而,这并没有像预期的那样成功。现实情况是,Kubernetes 对启动时间施加了一个下限,因为需要进行所有内容操作,需要将内容移动到适当的位置,这需要时间。
除了将工作空间保持热备状态(这将非常昂贵)之外,我们必须找到其他方法来优化启动时间。
为了最大限度地减少启动时间,我们探索了各种提前扩展的方法:
- 幽灵工作空间: 在集群自动缩放器插件可用之前,我们尝试了“幽灵工作空间”。这些是抢占式 pod,它们占据空间以提前扩展。我们使用自定义调度程序实现了这一点。然而,这种方法被证明替换起来缓慢且不可靠。
- 压舱 pod: 幽灵工作空间概念的演变,压舱 pod 填满了整个节点。与幽灵工作空间相比,这导致更少的替换成本和更快的替换时间。
- 自动缩放器插件: 2022 年 6 月,当集群自动缩放器插件推出时,我们切换到了使用它们。有了这些插件,我们不再需要“欺骗”自动缩放器,而是可以直接影响扩展的方式。这标志着我们的扩展策略的重大改进。
为了更有效地处理峰值负载,我们实施了一个比例自动缩放系统。这种方法将扩展速率控制为启动开发环境速率的函数。它的工作原理是使用 pause 镜像启动空 pod,使我们能够快速增加容量以响应需求峰值。
启动时间优化的另一个关键方面是改进镜像拉取时间。工作空间容器镜像(即开发人员可用的所有工具)解压缩后的大小可能超过 10 GB。为每个工作空间下载和解压缩如此大量的数据会极大地占用节点的资源。我们探索了许多策略来加快镜像拉取速度:
- Daemonset预取: 我们尝试使用DaemonSet预取常用镜像。然而,事实证明,这种方法在扩容操作期间无效,因为当节点上线并且工作区启动时,镜像仍然不会出现在节点上。此外,预取操作现在会与启动的工作区竞争IO和CPU带宽。
- 层复用最大化: 我们使用名为dazzle的自定义构建器构建了自己的镜像,它可以独立构建层。这种方法旨在最大化层复用。然而,我们发现由于OCI清单中的高基数和大量的间接寻址,层复用很难观察。
- 预烘焙镜像: 我们尝试将镜像烘焙到节点磁盘镜像中。虽然这提高了启动时间,但它也有明显的缺点。镜像很快就会过时,而且这种方法不适用于自托管安装。
- Stargazer和惰性拉取: 这种方法需要转换所有镜像,这增加了我们操作的复杂性、成本和时间。此外,当我们在2022年尝试这种方法时,并非所有注册表都支持这种方法。
- Registry-facade + IPFS: 这种解决方案在实践中运行良好,提供了良好的性能和分发。我们在2022年的KubeCon演讲中介绍了这种方法。然而,它给我们的系统带来了显著的复杂性。
没有一种万能的镜像缓存解决方案,而是在复杂性、成本和对用户施加的限制(他们可以使用的镜像)方面需要权衡取舍。我们发现工作区镜像的同质性是优化启动时间的最佳方法。
Kubernetes中的网络带来了其自身的一系列挑战,特别是:
-
开发环境访问控制: 默认情况下,环境的网络需要彼此完全隔离,即一个环境无法访问另一个环境。用户访问工作区也是如此。网络策略在很大程度上确保了环境彼此之间正确断开连接。最初,我们使用Kubernetes服务以及将流量转发到服务的入口代理(使用DNS解析)来控制对各个环境端口(例如IDE或工作区中运行的服务)的访问。由于服务数量庞大,这很快就在规模上变得不可靠。名称解析会失败,如果不谨慎(例如设置
enableServiceLinks: false
),可能会导致整个工作区崩溃。 - 节点上的网络带宽共享是另一种需要与单个节点上的多个工作区共享的资源。一些CNI提供网络整形支持(例如Cilium的带宽管理器)。现在,您又多了一种需要控制的资源,并且可能需要在环境之间共享。
我们在基于Kubernetes的基础架构中面临的最重大挑战之一是在为用户提供开发所需灵活性的同时提供安全的环境。用户希望能够安装其他工具(例如,使用apt-get install
)、运行Docker,甚至在其开发环境中设置Kubernetes集群。将这些需求与强大的安全措施相结合是一项复杂的工作。
最简单的解决方案是授予用户对其容器的root访问权限。然而,这种方法很快就会暴露出其缺陷:
- 授予用户root访问权限本质上是向用户提供对节点本身的root权限,从而授予对开发环境平台和在该节点上运行的其他开发环境的访问权限。
- 这消除了用户和主机系统之间任何有意义的安全边界,这意味着开发人员可能会意外地或故意地干扰和破坏开发环境平台本身,甚至访问其他人的开发环境。
- 它还会使基础架构面临潜在的滥用和安全风险。因此,也不可能实现真正的访问控制模型,并且该架构达不到零信任。您无法确保系统中执行操作的给定参与者确实是他们自己。
显然,需要一种更复杂的方法。
为了应对这些挑战,我们转向了用户命名空间,这是一种Linux内核功能,可以对容器内用户和组ID的映射进行细粒度控制。这种方法允许我们在不损害主机系统安全性的情况下授予用户容器内的“类似root”的权限。 虽然 Kubernetes 在 1.25 版本中引入了对用户命名空间的支持,但我们从 Kubernetes 1.22 开始就已经实现了我们自己的解决方案。我们的实现涉及几个复杂的组件:
-
文件系统 UID 偏移: 这是必要的,以确保在容器内创建的文件正确映射到主机系统上的 UID。我们尝试了几种方法:
- 我们继续使用 shiftfs 作为文件系统 UID 偏移的主要方法。尽管在某些情况下已被弃用,但 shiftfs 仍然以可接受的性能特征提供了我们所需的功能。
- 我们尝试了 fuse-overlayfs,它提供了必要的功能,但存在性能限制。
- 虽然 idmapped 挂载提供了潜在的好处,但由于各种兼容性和实现方面的考虑,我们还没有过渡到它们。
-
挂载屏蔽的 proc: 当容器启动时,它通常想要挂载 /proc。然而,在我们的安全模型中,/proc 被合理地屏蔽以防止潜在的安全绕过。解决此限制需要一个棘手的解决方案:
- 我们构建一个屏蔽的 proc 文件系统。
- 然后将此屏蔽的 proc 移动到正确的挂载命名空间。
- 我们使用 seccomp notify 来实现这一点,它允许我们拦截和修改某些系统调用。
-
FUSE 支持: 添加 FUSE(用户空间中的文件系统)支持对于许多开发工作流程至关重要,这需要实现自定义设备策略。这涉及修改容器的 eBPF(扩展伯克利包过滤器)设备过滤器,这是一种低级编程功能,允许我们对设备访问进行细粒度决策。
-
网络功能: 作为真正的 root 用户,拥有 CAP_NET_ADMIN 和 CAP_NET_RAW 功能,这些功能提供了配置网络的广泛权限。容器运行时(例如 Docker/runc)广泛使用这些功能。将此类功能授予开发环境容器会干扰 CNI 并破坏安全隔离。为了提供此类功能,我们最终在 Kubernetes 容器内创建了另一个网络命名空间,首先使用 slirp4netns 连接到外部世界,后来使用 veth 对和自定义 nftables 规则。
-
启用 docker: 需要对 Docker 本身进行一些特定的修改。我们注册了一个自定义 runc-facade,它修改了 Docker 生成的 OCI 运行时规范。这让我们可以删除例如 OOMScoreAdj,这仍然是不允许的,因为这需要节点上的 CAP_SYS_ADMIN。
实现此安全模型也带来了一系列挑战:
- 性能影响: 我们的某些解决方案,尤其是早期的解决方案(如 fuse-overlayfs),会对性能产生显著影响。我们一直在努力优化这些解决方案。
- 兼容性: 并非所有工具和工作流程都与此受限环境兼容。我们必须在安全性和可用性之间谨慎地权衡。
- 复杂性: 生成的系统比简单的容器化环境复杂得多,这会影响开发和运营开销。
- 跟上 Kubernetes: 随着 Kubernetes 的发展,我们不得不调整我们的自定义实现以利用新功能,同时保持向后兼容性。
在我们努力应对 Kubernetes 的挑战时,我们开始探索微虚拟机 (uVM) 技术,例如 Firecracker、Cloud Hypervisor 和 QEMU,将其作为潜在的中间地带。这种探索是由改进资源隔离、与其他工作负载(例如 Kubernetes)的兼容性和安全性的承诺所驱动的,同时可能保持容器化的一些优势。
微虚拟机提供了几个诱人的好处,这些好处与我们对云开发环境的目标非常吻合:
- 增强的资源隔离: uVM 比容器提供了更好的资源隔离,尽管是以牺牲超额预订能力为代价的。使用 uVM,我们不再需要争用共享内核资源,这可能导致每个开发环境的性能更可预测。
- 内存快照和快速恢复: 最令人兴奋的功能之一,尤其是在 Firecracker 使用 userfaultfd 的情况下,是对内存快照的支持。这项技术承诺了近乎即时的完整机器恢复,包括正在运行的进程。对于开发人员来说,这意味着更快的环境启动时间以及能够从中断的地方继续工作。
- 改进的安全边界: uVM 有潜力充当强大的安全边界,可能消除在我们 Kubernetes 设置中实现的复杂用户命名空间机制的需求。这可以提供与更广泛工作负载的完全兼容性,包括嵌套容器化(在开发环境中运行 Docker 甚至 Kubernetes)。
然而,我们对微虚拟机的实验揭示了几个重大挑战:
- 开销: 即使是轻量级虚拟机,uVM 也比容器引入了更多开销。这影响了性能和资源利用率,这是云开发环境平台的关键考虑因素。
- 镜像转换: 将 OCI(开放容器倡议)镜像转换为 uVM 可使用的文件系统需要自定义解决方案。这增加了我们镜像管理管道的复杂性,并可能影响启动时间。
-
特定技术的限制:
-
Firecracker:
- 缺乏 GPU 支持,这对于某些开发工作流程来说越来越重要。
- 在我们实验时(2023 年年中)不支持 virtiofs,限制了我们高效文件系统共享的选项。
-
Cloud hypervisor:
- 由于缺乏 userfaultfd 支持,快照和恢复过程较慢,抵消了我们希望从 uVM 获得的关键优势之一。
-
Firecracker:
- 数据移动挑战: 使用 uVM,移动数据变得更具挑战性,因为我们现在必须处理大型内存快照。这影响了调度和启动时间,这是云开发环境中用户体验的两个关键因素。
-
存储注意事项: 我们将 EBS 卷附加到微虚拟机的实验开辟了新的可能性,但也提出了新的问题:
- 持久存储:将工作区内容保存在附加卷上减少了重复从 S3 拉取数据的需要,从而有可能缩短启动时间并减少网络使用。
- 性能注意事项:虽然在工作空间之间共享高吞吐量卷显示出提高 I/O 性能的希望,但也引发了人们对实施有效配额、管理延迟和确保可扩展性的担忧。
虽然微虚拟机最终没有成为我们的主要基础设施解决方案,但该实验提供了宝贵的见解:
- 我们喜欢为开发环境提供的完整工作区备份和运行时状态挂起/恢复的体验。
- 我们第一次考虑放弃 Kubernetes。将 KVM 和 uVM 集成到 pod 的工作让我们探索了 Kubernetes 之外的选项。
- 我们再次将存储确定为提供以下三项的关键要素:可靠的启动性能、可靠的工作空间(不丢失我的数据)和最佳的机器利用率。
正如我在开头提到的,对于开发环境,我们需要一个尊重开发环境独特状态性质的系统。我们需要为开发人员提供必要的权限以提高生产力,同时确保安全边界。我们需要在保持低运营开销且不损害安全性的情况下完成所有这些工作。
如今,使用 Kubernetes 实现上述所有目标是可能的,但代价高昂。我们以艰难的方式了解了应用程序和系统工作负载之间的区别。
Kubernetes 非常棒。它得到了一个积极参与的热情社区的支持,该社区构建了一个真正丰富的生态系统。如果您正在运行应用程序工作负载,Kubernetes 仍然是一个不错的选择。然而,对于像开发环境这样的系统工作负载,Kubernetes 在安全性和运营开销方面都面临着巨大的挑战。微虚拟机和明确的资源预算有所帮助,但使成本成为更主要的因素。
因此,在多年有效地逆向工程并将开发环境强制应用到 Kubernetes 平台之后,我们退后一步,思考我们认为未来的开发架构需要是什么样子。2024 年 1 月,我们开始构建它。10 月,我们发布了它:Gitpod Flex。
超过六年的在互联网规模下安全运行开发环境的来之不易的经验,为架构基础奠定了基础。
在 Gitpod Flex 中,我们继承了 Kubernetes 的基础方面,例如控制理论的自由应用和声明式 API,同时简化了架构并改进了安全基础。
我们使用一个深受 Kubernetes 启发的控制平面来编排开发环境。我们引入了一些特定于开发环境的必要抽象层,并摒弃了我们不需要的许多基础设施复杂性——所有这些都将零信任安全放在首位。
图注: Gitpod Flex 的安全边界。
这种新架构使我们能够无缝集成 devcontainer。我们还解锁了在您的桌面上运行开发环境 的能力。现在我们不再背负 Kubernetes 平台的沉重负担,Gitpod Flex 可以在不到三分钟内部署自托管,并且可以在任意数量的区域中部署,从而在建模组织边界和域时对合规性进行更细粒度的控制并增加灵活性。
我们将在未来几周或几个月内发布更多关于 Gitpod Flex 架构的内容。我很乐意邀请您参加 11 月 6 日的虚拟活动,届时我将演示 Gitpod Flex,并深入探讨其架构和安全模型。您可以在此注册。
在构建用于标准化、自动化和安全的开发环境的平台时,选择一个系统是因为它可以改善您的开发人员体验,减轻您的运营负担并提高您的利润。您选择的不是 Kubernetes 与其他东西,而是选择一个系统是因为它可以改善您所支持团队的体验。