原文Christian Weichel - 2024.10.31

Kubernetes 似乎是构建远程、标准化和自动化开发环境的显而易见选择。我们也曾这样认为,并且花费了六年时间,致力于打造最受欢迎的云开发环境平台,并达到了互联网级的规模。我们的用户数量达到了 150 万,每天都有成千上万的开发环境使用 Kubernetes。然而,在这个过程中,我们发现 Kubernetes 并不是构建开发环境的最佳选择

这是我们在 Kubernetes 上构建开发环境时的一系列实验、失败和走入死胡同的故事。多年来,我们尝试了涉及 SSD、PVC、eBPFseccomp 通知TCio_uringshiftfs、FUSE 和 idmapped 挂载 的各种想法,从 microVMskubevirtvCluster

我们一直在追求最优的基础设施,平衡安全性、性能和互操作性。与此同时,我们还要应对一个独特的挑战,即建立一个可扩展的系统,在处理任意代码执行时保持安全,并足够稳定,以便开发人员在其中工作。

这不是关于是否使用 Kubernetes 进行生产工作负载的讨论,那是一个完全不同的话题。也不是关于如何在 Kubernetes 上构建一整套开发者体验以交付应用程序的讨论。

这是关于如何(不要)在云中构建开发环境的故事。

为什么开发环境是独特的?

在深入探讨之前,了解开发环境与生产工作负载相比有何独特之处至关重要:

  • 它们极其有状态且具有交互性:这意味着它们无法从一个节点移动到另一个节点。大量的源代码、构建缓存、Docker 容器和测试数据的变化率很高,迁移成本很高。与许多生产服务不同,开发者与其环境是 1 比 1 的交互。
  • 开发者对自己的源代码和更改投入甚深:开发者非常在意代码的任何更改,且不容忍任何系统导致的阻碍。因此,开发环境对故障有极低的容忍度。
  • 它们的资源使用模式不可预测:开发环境有特定且不可预测的资源使用模式。大部分时间它们不需要太多的 CPU 带宽,但在某些时刻它们需要在几百毫秒内使用多个核心。任何比这更慢的响应都会表现为不可接受的延迟和无响应。
  • 它们需要广泛的权限和能力:与生产工作负载不同,开发环境经常需要 root 访问权限,并且能够下载和安装软件包。对于生产工作负载来说是安全问题的行为,在开发环境中却是常态:获取 root 权限、扩展网络能力并控制系统(例如挂载额外的文件系统)。

这些特性使开发环境与典型的应用工作负载截然不同,并显著影响了我们在基础设施方面做出的决策。

当前的系统:显然是 Kubernetes

当我们开始 Gitpod 项目时,Kubernetes 似乎是我们基础设施的理想选择。它的可扩展性、容器编排能力以及丰富的生态系统与我们对云开发环境的愿景完美契合。然而,随着我们的扩展和用户规模的增长,我们遇到了与安全性和状态管理相关的诸多挑战,这些挑战将 Kubernetes 推向了极限。从根本上讲,Kubernetes 是为运行可控的应用工作负载而设计的,而不是无序的开发环境。

在大规模管理 Kubernetes 颇具复杂性。尽管 GKE 和 EKS 等托管服务在某些方面缓解了痛点,但它们也带来了自身的一系列限制和局限性。我们发现,许多团队在运营 CDE(云开发环境)时低估了 Kubernetes 的复杂性,这也导致了我们之前自托管 Gitpod 服务的支持负担显著增加。

资源管理的困境

我们面临的最重大挑战之一是资源管理,特别是每个环境的 CPU 和内存分配。乍一看,在一个节点上运行多个环境以共享资源(如 CPU、内存、IO 和网络带宽)似乎是一个很有吸引力的选择。但实际上,这会引发显著的「邻居噪声效应(noisy neighbor)」,从而严重影响用户体验。

CPU 挑战

CPU 时间看起来像是环境之间共享的最简单候选资源。大多数时候,开发环境并不需要太多的 CPU 时间,但当它们需要时,它们需要得很快。当语言服务器开始延迟或终端变得卡顿时,用户会立刻感受到延迟。这种 CPU 需求的峰值特性(非活跃时期之后紧接着是密集的构建任务)使得很难预测何时需要 CPU 时间。

为了解决这个问题,我们尝试了基于 完全公平调度器(CFS)的各种方案,使用 DaemonSet 实现了自定义控制器。一个核心问题是我们无法预测何时需要 CPU 带宽,而只能通过观察 cgroup 的 cpu_stats 中的 nr_throttled 来了解何时曾经需要。

即便采用静态 CPU 资源限制,也会出现挑战,因为与应用工作负载不同,一个开发环境将在同一个容器中运行多个进程。这些进程会竞争同样的 CPU 带宽,可能导致 VS Code 断开连接,因为 VS Code 服务器分配不到 CPU 时间而“饿”死了。

我们尝试通过调整各个进程的优先级来解决这一问题,例如提高 bash 或 vscode-server 的优先级。然而,这些进程优先级会应用于整个进程组(取决于内核的 autogroup 调度配置),因此也会影响 VS Code 终端中启动的资源密集型编译器。使用进程优先级来解决终端卡顿问题需要一个精心编写的控制循环才能有效。

我们基于 cgroupv1 引入了自定义的 CFS 和进程优先级控制循环,并在 1.24 版 Kubernetes 托管平台上更容易获得 cgroupsv2 后,将其转移到了 cgroupsv2 上。Kubernetes 1.26 引入的动态资源分配意味着不再需要部署 DaemonSet 并直接修改 cgroup,尽管这可能会以控制循环速度和效果为代价。上述所有方案都依赖于每秒对 CFS 限制和优先级值进行调整。

内存管理

内存管理也带来了自己的一系列挑战。为每个环境分配固定的内存量,以便在最大占用时每个环境都能获得其固定份额,这种方式简单直接,但非常受限。在云中,内存(RAM)是相对昂贵的资源,因此我们希望能够超额预定内存。

Kubernetes 1.22 引入交换空间 之前,内存超额预定几乎是不可能的,因为回收内存不可避免地意味着杀死进程。随着交换空间的加入,内存超额预定的需求有所减少,因为交换空间在托管开发环境时运行良好。

存储性能优化

存储性能对于开发环境的启动性能和体验至关重要。我们发现,IOPS 和延迟尤其影响环境中的体验。而 IO 带宽直接影响工作空间的启动性能,特别是在创建/恢复备份或提取大型工作空间镜像时。

我们尝试了各种设置,以找到速度与可靠性、成本与性能之间的最佳平衡。

  • SSD RAID 0:这提供了高 IOPS 和带宽,但将数据绑定到特定节点。任何单个磁盘的故障都会导致数据完全丢失。这是 gitpod.io 今天的操作方式,我们尚未遇到过此类磁盘故障。这个设置的简化版本是使用单个附加到节点的 SSD。该方法提供了较低的 IOPS 和带宽,但仍然将数据绑定到单个节点。
  • 块存储(如 EBS 卷或 Google 持久磁盘)永久附加到节点上,可以显著扩大可使用的不同实例或可用区。尽管仍然绑定到单个节点,并且提供的吞吐量/带宽远低于本地 SSD,但它们在可用性上更具优势。
  • 在使用 Kubernetes 时,持久卷声明(PVC)似乎是显而易见的选择。作为不同存储实现的抽象层,它们提供了很大的灵活性,但也带来了新的挑战:
    • 不可预测的挂载和卸载时间,导致工作空间启动时间不可预测。再加上调度复杂性的增加,它们使得实现有效的调度策略变得更加困难。
    • 可靠性问题导致工作空间故障,特别是在启动期间。这在 Google Cloud(2022 年)上尤为明显,使得我们尝试使用 PVC 的做法无法实践。
    • 每个实例可附加的磁盘数量有限,给调度程序和每个节点的工作空间数量带来了额外的限制。
    • 可用区本地性约束使得在可用区之间平衡工作空间变得更加困难。

备份和恢复本地磁盘被证明是一项昂贵的操作。我们使用 daemonSet 实现了一种解决方案,它可以上传和下载未压缩的 tar 存档到 S3 或从 S3 下载。 这种方法需要在 I/O、网络带宽和 CPU 使用之间进行仔细的平衡:例如,(解)压缩存档会消耗节点上几乎所有可用的 CPU,而未压缩备份产生的额外流量通常不会消耗所有可用的网络带宽(如果同时启动/停止工作空间的数量得到仔细控制)。

节点上的 IO 带宽在工作空间之间共享。我们发现,除非我们限制每个工作空间可用的 IO 带宽,否则其他工作空间可能会因 IO 带宽不足而停止运行,尤其是在内容备份/恢复期间会出现这种问题。为了解决这个问题,我们实现了基于 cgroup 的 IO 限制器,为每个环境设置了固定的 IO 带宽限制。

自动扩展和启动时间优化

我们的主要目标是尽可能减少启动时间。不可预测的等待时间会显著影响生产力和用户满意度。然而,这一目标通常与我们希望密集部署工作空间以最大化机器利用率的愿望相冲突。

我们最初认为在一个节点上运行多个工作空间可以改善启动时间,因为它们可以共享缓存。然而,现实情况并非如此。Kubernetes 的启动时间受限于下层操作,因为内容需要移动到位,这需要时间。

在不保持工作空间热备(这将非常昂贵)的情况下,我们必须寻找其他方法来优化启动时间。

提前扩展:我们的方法演变

为了尽量减少启动时间,我们探索了多种扩展方法:

  • Ghost 工作空间:在集群扩展插件可用之前,我们尝试了“Ghost 工作空间”。这些是占用空间的可抢占 Pod,用来自行扩展。我们通过自定义调度器实现了这一点。然而,这种方法在替换时既慢又不可靠。
  • Ballast Pod:Ghost 工作空间的进化版,Ballast Pod 填满了整个节点。与 Ghost 工作空间相比,这减少了替换成本,并加快了替换时间。
  • 扩展插件:2022 年 6 月,我们转向使用集群自动扩展插件。这些插件允许我们直接影响扩展的方式,而不再需要“欺骗”自动扩展器。这极大地改善了我们的扩展策略。

峰值负载的比例扩展

为了更有效地处理峰值负载,我们实施了比例扩展系统。此方法根据开发环境启动的速度控制扩展速度。它通过使用暂停镜像启动空 Pod,使我们能够快速增加容量以应对需求激增。

镜像拉取优化:多次尝试的故事

启动时间优化的另一个关键方面是提高镜像拉取速度。工作空间容器镜像(即开发者可用的所有工具)可能会超过 10 GB 未压缩数据。为每个工作空间下载和解压缩这么多数据会大大消耗节点资源。我们探索了多种加速镜像拉取的策略:

  • DaemonSet 预拉取:我们尝试通过 DaemonSet 预拉取常用镜像。然而,在扩展操作期间,当节点上线且工作空间启动时,镜像仍然不会出现在节点上。此外,预拉取现在还会与启动中的工作空间争夺 IO 和 CPU 带宽。
  • 最大化层复用:我们使用自定义构建器 dazzle 构建镜像,旨在最大化层的复用。然而,我们发现由于 OCI manifest 中的高基数和间接性,层复用很难观察到。
  • Pre-baked 镜像:我们尝试将镜像预先写入节点磁盘镜像。虽然这改善了启动时间,但也有显著弊端。镜像很快过时,这种方法对自托管安装无效。
  • Stargazer 和延迟拉取:这种方法要求将所有镜像转换,这增加了复杂性、成本和操作时间。此外,在我们 2022 年尝试时,并非所有注册表都支持此方法。
  • Registry-facade + IPFS:这种解决方案在实践中效果良好,提供了不错的性能和分发。我们在 2022 年的 KubeCon 介绍了这种方法。然而,它给我们的系统增加了很大的复杂性。

镜像缓存并没有一个适用于所有场景的解决方案,而是一组在复杂性、成本和对用户施加的限制(即他们可以使用的镜像)之间的权衡。我们发现,工作空间镜像的同质性是优化启动时间的最简单方法。

网络复杂性

Kubernetes 的网络引入了一系列挑战,特别是:

  • 开发环境访问控制:默认情况下,开发环境之间的网络需要完全隔离,即一个环境无法访问另一个。同样,用户访问工作空间的方式也需要隔离。网络策略 在确保环境之间正确断开连接方面发挥了重要作用。

    最初,我们使用 Kubernetes 服务控制各个环境端口的访问(例如 IDE 或工作空间中运行的服务),并配合 Ingress 代理将流量转发到相应服务,通过 DNS 解析。然而,由于服务数量庞大,名称解析开始频繁失败。如果不小心(例如,未设置 enableServiceLinks: false),整个工作空间可能会崩溃。

  • 网络带宽共享:节点上的网络带宽是另一个需要在单个节点上的多个工作空间之间共享的资源。一些 CNI 提供 network shaping 支持(例如 Cilium 的 带宽管理器)。这又为我们增加了一个需要控制和在环境之间共享的资源。

安全与隔离:在灵活性和保护之间寻找平衡

在我们基于 Kubernetes 的基础设施中,提供安全环境的同时赋予用户开发所需的灵活性是面临的最大挑战之一。用户希望能够安装额外的工具(例如,使用 apt-get install),运行 Docker,甚至在开发环境中设置 Kubernetes 集群。如何在实施强有力的安全措施的同时满足这些需求是一个复杂的问题。

简单方法:root 权限

最简单的解决方案是给用户他们容器的 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 通知实现这一点,它允许我们拦截和修改某些系统调用。
  • FUSE 支持:添加 FUSE(Filesystem in Userspace)支持对许多开发工作流至关重要,这需要实现自定义设备策略。这涉及修改容器的 eBPF 设备过滤器,这是一种允许我们对设备访问做出细粒度决策的低级编程能力。

  • 网络能力:作为真正的 root,用户拥有 CAP_NET_ADMIN 和 CAP_NET_RAW 权限,赋予了广泛的网络配置特权。容器运行时(例如 Docker/runc)广泛使用这些权限。将这些权限授予开发环境容器会干扰 CNI 并破坏安全隔离。

    为了提供这些能力,我们最终在 Kubernetes 容器内创建了另一个网络命名空间,首先通过 slirp4netns 连接到外部网络,后来使用 veth 成对和自定义 nftables 规则。

  • 启用 Docker:为 Docker 本身实现了一些特定的 hacks。我们注册了自定义的 runc-facade,它修改了 Docker 生成的 OCI 运行时规范。这使我们能够移除例如 OOMScoreAdj(仍不允许),因为这需要节点上的 CAP_SYS_ADMIN 权限。

实现此安全模型带来了许多挑战:

  • 性能影响:我们的某些解决方案,尤其是早期的 fuse-overlayfs,具有显著的性能影响。我们一直在努力优化这些解决方案。
  • 兼容性:并非所有工具和工作流都与这种受限环境兼容。我们必须在安全性和可用性之间仔细平衡。
  • 复杂性:最终系统比简单的容器化环境复杂得多,这对开发和运营都带来了影响。
  • 跟上 Kubernetes 的发展:随着 Kubernetes 的发展,我们不得不调整我们的自定义实现,以利用新功能,同时保持向后兼容。

微型虚拟机(micro-VM)实验

当我们在应对 Kubernetes 的挑战时,我们开始探索微型虚拟机技术,例如 FirecrackerCloud HypervisorQEMU,以寻找一种中间方案。这种探索的驱动力来自于 micro-VM 在资源隔离、与其他工作负载(如 Kubernetes)的兼容性和安全性方面的承诺,同时有可能保持容器化的一些优势。

微型虚拟机的承诺

微型虚拟机提供了几个与我们云开发环境目标高度契合的诱人优势:

  • 增强的资源隔离:micro-VM 提供了比容器更好的资源隔离,尽管这需要牺牲某些超额预订功能。使用 micro-VM 后,我们不再需要处理共享内核资源的问题,这可能会为每个开发环境带来更可预测的性能。
  • 内存快照与快速恢复:尤其是 Firecracker 使用 userfaultfd 提供的内存快照功能令人兴奋,这项技术承诺几乎可以实现完整的机器恢复,包括运行中的进程。对于开发者来说,这意味着显著缩短环境启动时间,并能精确恢复到上次离开的状态。
  • 改进的安全边界:micro-VM 有潜力成为一个强大的安全边界,可能不再需要我们在 Kubernetes 设置中实现的复杂用户命名空间机制。这可以确保与更广泛的工作负载(包括在开发环境中运行 Docker 甚至 Kubernetes)完全兼容。

微型虚拟机的挑战

然而,我们在 micro-VM 上的实验也暴露了几个重要的挑战:

  • 开销:尽管是轻量级虚拟机,micro-VM 仍引入了比容器更大的开销。这影响了性能和资源利用率,这对于云开发环境平台来说都是关键考虑因素。
  • 镜像转换:将 OCI(开放容器倡议)镜像转换为 micro-VM 可用的文件系统需要定制解决方案。这为我们的镜像管理流程增加了复杂性,并可能影响启动时间。
  • 特定技术的限制
    • Firecracker:
      • 缺乏 GPU 支持,这对某些开发工作流越来越重要。
      • 在我们实验时(2023 年中期)不支持 virtiofs,限制了我们高效文件系统共享的选择。
    • Cloud Hypervisor:
      • 由于缺乏 userfaultfd 支持,快照和恢复过程较慢,抵消了我们希望从 micro-VM 中获得的关键优势之一。
  • 数据传输挑战:使用 micro-VM 后,处理大规模内存快照变得更加复杂,这影响了调度和启动时间,这两个因素对用户体验至关重要。
  • 存储考虑:我们在将 EBS 卷附加到微型虚拟机的实验中,发现了新的可能性,但也提出了一些新的问题:
    • 持久存储:将工作空间内容保存在附加卷上减少了从 S3 反复拉取数据的需求,可能改善启动时间并减少网络使用。
    • 性能考量:在工作空间之间共享高吞吐量卷有助于提高 I/O 性能,但也带来了实施有效配额、管理延迟和确保可扩展性的问题。

微型虚拟机实验的经验教训

尽管微型虚拟机最终没有成为我们的主要基础设施解决方案,但这次实验提供了宝贵的见解:

  • 我们非常喜欢完整工作空间备份和运行时状态挂起/恢复的体验,这为开发环境提供了极大的便利。
  • 我们第一次认真考虑是否要放弃 Kubernetes。将 KVM 和微型虚拟机集成到 pods 中的努力让我们探索了 Kubernetes 之外的选项。
  • 我们再次意识到存储是提供可靠启动性能、可靠工作空间(不要丢失数据)和优化机器利用率的关键要素。

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 或其他什么,而是在选择一个能改善您支持的团队体验的系统。