在 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 上交付应用程序也是一个单独的话题。
这是关于如何(不)在云中构建开发环境的故事。
在我们深入探讨之前,了解开发环境与生产工作负载相比有何独特之处至关重要:
这些特性使开发环境与典型的应用程序工作负载区分开来,并极大地影响了我们一路做出的基础设施决策。
当我们启动 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 带宽会直接影响你的工作区启动性能,尤其是在创建/恢复备份或提取大型工作区镜像时。
我们尝试了各种设置,以找到速度和可靠性、成本和性能之间的最佳平衡。
备份和恢复本地磁盘被证明是一项昂贵的操作。我们使用 daemonSet 实现了一个解决方案,该方案将未压缩的 tar 存档上传到 S3 或从 S3 下载。这种方法需要仔细平衡 I/O、网络带宽和 CPU 使用率:例如,(解)压缩存档会消耗节点上的大部分可用 CPU,而未压缩备份产生的额外流量通常不会消耗所有可用网络带宽(如果并发启动/停止工作空间的数量得到仔细控制)。
节点上的 IO 带宽由工作空间共享。我们发现,除非我们限制每个工作空间可用的 IO 带宽,否则其他工作空间可能会因 IO 带宽不足而停止运行。尤其是内容备份/恢复会产生这个问题。我们实施了基于 cgroup 的 IO 限制器,它对每个环境施加了固定的 IO 带宽限制来解决这个问题。
我们的主要目标是不惜一切代价缩短启动时间。不可预测的等待时间会严重影响生产力和用户满意度。然而,这个目标往往与我们希望密集打包工作空间以最大限度地提高机器利用率的愿望相冲突。
我们最初认为在一个节点上运行多个工作空间会有助于缩短启动时间,因为可以共享缓存。然而,这并没有像预期的那样成功。现实情况是,Kubernetes 对启动时间施加了一个下限,因为需要进行所有内容操作,需要将内容移动到适当的位置,这需要时间。
除了将工作空间保持热备状态(这将非常昂贵)之外,我们必须找到其他方法来优化启动时间。
为了最大限度地减少启动时间,我们探索了各种提前扩展的方法:
为了更有效地处理峰值负载,我们实施了一个比例自动缩放系统。这种方法将扩展速率控制为启动开发环境速率的函数。它的工作原理是使用 pause 镜像启动空 pod,使我们能够快速增加容量以响应需求峰值。
启动时间优化的另一个关键方面是改进镜像拉取时间。工作空间容器镜像(即开发人员可用的所有工具)解压缩后的大小可能超过 10 GB。为每个工作空间下载和解压缩如此大量的数据会极大地占用节点的资源。我们探索了许多策略来加快镜像拉取速度:
没有一种万能的镜像缓存解决方案,而是在复杂性、成本和对用户施加的限制(他们可以使用的镜像)方面需要权衡取舍。我们发现工作区镜像的同质性是优化启动时间的最佳方法。
Kubernetes中的网络带来了其自身的一系列挑战,特别是:
enableServiceLinks: false
),可能会导致整个工作区崩溃。我们在基于Kubernetes的基础架构中面临的最重大挑战之一是在为用户提供开发所需灵活性的同时提供安全的环境。用户希望能够安装其他工具(例如,使用apt-get install
)、运行Docker,甚至在其开发环境中设置Kubernetes集群。将这些需求与强大的安全措施相结合是一项复杂的工作。
最简单的解决方案是授予用户对其容器的root访问权限。然而,这种方法很快就会暴露出其缺陷:
显然,需要一种更复杂的方法。
为了应对这些挑战,我们转向了用户命名空间,这是一种Linux内核功能,可以对容器内用户和组ID的映射进行细粒度控制。这种方法允许我们在不损害主机系统安全性的情况下授予用户容器内的“类似root”的权限。 虽然 Kubernetes 在 1.25 版本中引入了对用户命名空间的支持,但我们从 Kubernetes 1.22 开始就已经实现了我们自己的解决方案。我们的实现涉及几个复杂的组件:
实现此安全模型也带来了一系列挑战:
在我们努力应对 Kubernetes 的挑战时,我们开始探索微虚拟机 (uVM) 技术,例如 Firecracker、Cloud Hypervisor 和 QEMU,将其作为潜在的中间地带。这种探索是由改进资源隔离、与其他工作负载(例如 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 与其他东西,而是选择一个系统是因为它可以改善您所支持团队的体验。