Farm 开源了:轻松搭建预览环境

Farm 如何从单台虚拟机上的 self-hosted 脚本成长为基于 Docker 和 Kubernetes 的预览环境编排器,以及我们为什么将它开源。

每一个 pull request,你都不只想让它通过测试,还想拿出来「真机实演」:给设计师看、给经理看、给隔壁团队看,让大家都能在 UI 里戳一戳——或是欣喜一番,或是翻出几个 bug——再沮丧一场。听上去没什么难的:起个环境、丢个链接就行。可当 pull request 每天几十个地砸过来、团队又是一大片时,「随手起个环境」就变成了一个正儿八经的基础设施项目。

大家好!我叫 Mikhail Golbakh,是 Yandex Cloud 的高级前端开发工程师。这几年来,我和团队一直在打造 Farm——一个为每个 pull request 拉起预览环境的服务。这篇文章里,我会讲讲 Farm 如何从单台虚拟机上的一个简单 self-hosted 脚本,成长为跑在 Docker 和 Kubernetes 之上的编排器,以及我们最终为什么开源了它。


Farm 第一版的由来

Yandex 内部有众多前端团队,每天会创建数百个 pull request。每个 pull request 都会触发一大批测试和其他 CI 检查。单元测试、类型检查、linter 在隔离的虚拟机上跑起来毫无压力。

可要是需要从分支拉起一个完整的应用呢?这个问题引出了前端开发者三天两头要面对的任务:给每个 pull request 起一个「beta 环境」。最先想到的办法,是照着生产环境的样子搭一个专用的 dev 环境,让开发者们共用。小团队在起步阶段常常就是这么干的,但很快就会撞上扩展性的墙。一个环境已经不够分,e2e 测试也没法真的在上面跑——那样它们的稳定性显然要打上问号。

按老规矩,每支团队都会为部署 dev 环境搭一套自己的流程,为此投入相当的精力,还得搭上维护资源。我们也不例外,做出了自己的方案。不过我们从一开始就把它设计成一项通用技术:即便各团队的基础设施可能各不相同,也能推广到几十支团队。

早在 2018 年,Yandex Cloud 前端团队就做出了 Farm 的第一版——本质上是个简单的 self-hosted 服务:从仓库分支下载代码,执行目标应用的构建命令,在虚拟机上把它的进程跑起来,再把专用域名的流量路由过去。连命令集本身都是写死在代码里的:Farm 首先瞄准的是我们自己的项目,而它们都构建在 Node.js Web 服务的通用组件之上。最终得到的,是一套带着 Yandex 风味的简化版部署系统。

此后多年,Farm 服务不断进化:学会了为每个项目使用独立的构建与启动配置,有了完整的 UI 和数据库,还多了一大批简化 Yandex 基础设施相关工作的附加代码。但服务的本质没有变——它解决的仍然是这几件事:

  • 从 pull request 下载代码并构建项目;

  • 把流量路由到跑起来的应用;

  • 把方案统一成单个基础设施组件,便于分发,也便于嵌入 Yandex 的 CI 流程。

工作原理

Farm 是一个小巧的 Node.js 应用,简化后的工作流程是这样的:

  1. Farm 收到一个生成请求。它可能来自 CI 里的任务,也可能是用户直接通过 UI 里的表单发起的。

  2. 一个新的应用实例以 queued 状态进入数据库,排进构建队列。

  3. Farm 消化队列:从目标仓库和分支下载代码,读取配置,启动构建。

  4. 应用进程启动:按照约定,它要在 <instance_dir>/dist/server.sock 打开一个 socket。

  5. Farm——确切地说,此刻已经是挂着特殊配置的 nginx——开始把流量路由到一个唯一域名,例如:d008838956420e285cee652262dd5d18b2ed2a2a.farm.example.com。

Full screen image

从示意图里可以看出,我们没有把负载均衡的职责压给 Farm,而是用了 nginx。这个思路会一直延续下去:维护流量代理不是件小事,我们宁可不自己去啃。此外还支持 Git 和 Arc——后者是 Yandex 内部自研的版本控制系统。

构建和启动本身,无非是 Farm 把几条命令跑一遍。一切都极尽简单粗暴:

# 构建
npm ci
npm run build

# 启动
npm run start

为了画面完整,我们再看一个 farm.json 配置的例子:

{
  "preview-generator": {
    "build": ["npm ci", "npm run build"],
    "start": {
      "command": "npm",
      "args": "run start"
    },
    "env": {
      "APP_BUILDER_CDN": "false",
      "IS_FARM": "true"
    },
    "instances": [
      {
        "name": "preprod",
        "urlTemplate": "https://{hash}.farm.example.com",
        "env": {
          "APP_INSTALLATION": "russia",
          "APP_ENV": "preprod"
        }
      },
      {
        "name": "prod",
        "urlTemplate": "https://{hash}.farm.example.com",
        "env": {
          "APP_INSTALLATION": "russia",
          "APP_ENV": "prod"
        }
      }
    ]
  }
}

最终我们得到了一匹实打实的「老黄牛」。目标团队要做的,只是准备一台虚拟机,写好 Farm 自身和各项目的配置,把它拉起来,再为 pull request 配好 CI 流程。听着不难,对吧?可是……

成长与新挑战

Farm 以这副模样过了相当久,分内的任务也解决得像模像样。起初我们甚至是「合租」——几支前端团队共用一个 Farm,免得为维护多套部署白费力气。可团队的规模在长,数量也在涨,而 Farm 只能局限在一台虚拟机里干活。日子一久,就连最强悍的机器配置也不够用了。

几个构建并行一跑,整台机器的资源就被吃干抹净,实例可能在队列里干等许久。于是,「合租」自发地开始拆伙,分成一个个更小的 Farm,有时甚至一个项目一个。

显然,拆伙并没有解决问题,只是把它往后推了推。算力照样一次次见底,新团队还得额外花力气支持和维护 Farm。比如,虚拟机的磁盘会相当有规律地被塞满,逼着人手动清理无用文件。所以第一个问题是:资源有限,却既没有资源管理,也没有扩缩容。先把这一点记下来。

另一个问题是,Farm 的底层直接拿 SQLite 当数据库,连 Knex 之类的抽象层都没有。这带来了 vendor lock(供应商锁定),而且每次改动数据库模式都得把数据全部清空,因为压根没有迁移机制。再考虑到 Farm 已经按团队拆成了许多套部署,结果就是几乎没人愿意给它升级:谁知道会坏掉什么——而且八成还得把数据清掉。正所谓「能跑就别动」。

此外,Farm 只会运行 Node.js 应用,而且所有进程都直接跑在宿主机上,毫无隔离。既然没有隔离,应用就没法简单地用自己惯用的端口接收流量,因为 Farm 不做端口管理。于是通信只能走 unix socket,逼着应用遵守这个略显别扭的契约。

于是,摆在我们面前的是这样几个问题:

  1. 资源有限,且没有资源管理与扩缩容。

  2. 数据库没有迁移机制,还死绑着 SQLite。

  3. 实例没有隔离,外加「必须是发布 unix socket 的 Node.js 应用」这条限制。

  4. 支持与运维的负担——毕竟我们当初是想让各团队省心,而不是添堵(这是个额外的问题,暂且按下不表,临近结尾再回来谈)。

我们如何应对

有经验的工程师看到这些任务,多半会建议把 Farm 搬上 k8s 集群的轨道。我们也是一步步走到了这个想法:把应用的构建和部署挪到集群的算力上、放进独立的 pod,用集群的能力来做自动扩缩容和资源管理。应用本身则以业界人人熟悉的 docker 容器形态交付——对我们这个行业来说,这是一份再清晰简单不过的契约。除此之外,我们还得保留按老方式、以进程形态运行应用的能力,免得小团队被迫经历一场吃力的 k8s 迁移。

直接抛弃 Farm、改成从 CI 直接往 k8s 集群部署应用,是行不通的,原因如下:

  1. Farm 早已深深扎根在我们现有的全部流程和 CI 里。逼着团队迁到一个全新方案毫无道理。

  2. 细究起来,Farm 干的不只是构建和启动应用——它还揽下了路由(虽然是借 nginx 之手),会停止并删除不再使用的实例,诸如此类还有很多。

  3. 顺手好用的 UI 也很关键:靠它,不只前端,连后端工程师也能拉起一个应用实例来测试各种功能。

实际动手后才发现,添加 k8s 支持比当初设想的要难。从最早的版本起,处理应用实例的逻辑就散落在代码库各处,多年下来,这里面积攒了大量细节和各式精巧的 hack——想在旁边再挤进一套实现,谈何容易。

一次大规模重构

第一步,我们对 Farm 的代码发起了一次大规模重构。首先要把处理实例的逻辑封装进一个专门的 provider 类——相当于 Farm 的一种「后端」,由它来定义应用的构建与部署策略。这样一来,我们暂时可以让 legacy 方案和 k8s 并肩共存,之后再通过统一接口添加新的实现。

把代码拆解一遍之后,我们提取出了所有与 provider 相关的方法,并准备了一个描述它们的抽象类。稍作简化,大致就是这样:

class BaseFarmProvider {
    startup(): Promise<void>;

    buildInstance(
        generateData: GenerateInstanceData,
        observer: SubscriptionObserver<InstanceObservableEmitValue>,
    ): Promise<void>;

    stopBuilder(hash: string): Promise<void>;

    startInstance(instance: Instance): Promise<void>;

    stopInstance(hash: string): Promise<void>;

    restartInstance(instance: Instance): Promise<void>;

    deleteInstance(hash: string): Promise<void>;

    getInstanceStatus(instance: Instance): Promise<InstanceProviderStatus>;

    getInstances(): Promise<Array<InstanceProviderInfo>>;

    getInstanceLogs(params: {
        hash: string;
        stdout?: LogParams;
        stderr?: LogParams;
    }): Promise<{stdout?: string; stderr?: string}>;
}

差不多同一时期,我们把数据库访问也全部抽象了出来,迁到了 Knex 上,好让将来能换用另一种更强大、更持久的 DBMS。作为附赠,我们还得到了一套低成本的迁移机制,改起数据库模式来更轻松。

基于 Kubernetes 的新方案

在新方案里,Farm 部署在集群内,以控制器的身份工作:连接 k8s API,开始管理集群资源——为构建和应用拉起 pod,创建并删除其他资源。

要理解这套方案,只需要讲清楚两个主要流程:应用实例的构建与部署。

构建分为几个阶段:

  1. 用户或 CI 启动构建(buildInstance 方法)。

  2. 从分支获取目标应用的 Farm 配置(仓库根目录下的 farm.json 文件)。

  3. 启动一个 builder pod:它从分支下载代码,构建 docker 镜像(默认用 Dockerfile.farm),并推送到预先约定的镜像仓库。

  4. builder pod 的所有日志都会实时流式输出,显示在 UI 里供调试。

  5. 构建结束后,Farm 会删除这个 builder pod。

Full screen image

构建的产物是一个制品(artifact)——已推送到镜像仓库的应用镜像,随时可以部署和启动。作为附赠,我们还借 Docker 得到了构建缓存,这对加速 CI 非常有用。

部署在构建之后立即开始,就在同一个 buildInstance 方法里,所以我们从镜像推送完成的那一刻接着讲:

  1. 在集群中创建 Deployment,使用上一阶段产出的镜像。

  2. 创建 Service,端口是我们在 Farm 配置里指定的那个;同时创建 Ingress,域名基于实例 ID 生成。

  3. 接下来,新玩家入场——Ingress NGINX Controller。它包揽全部路由,让应用能从外部访问。在这里 Farm 一如既往地保持本色,把流量代理托付给系统的另一个组件。

Full screen image

从技术上讲,路由用任何其他 Ingress 控制器都行——真有需要,接某个云上的 ALB 也未尝不可。我们选中 Ingress NGINX Controller,图的是它的简单,以及配置更新的速度。

大体上,我们就是这样得到一个可用的应用实例,随时可以打开、上手测试。此外,Farm 还会确保资源不被重复创建、所有操作都是幂等的——这对稳定运行至关重要。

纯 Docker 的简化方案

自从 Farm 里冒出支持 k8s 的想法,一个相当顺理成章的提议也跟着来了:为什么不支持在一台虚拟机上直接用 Docker 跑实例呢?这既能解决实例隔离问题,又能摘掉「必须是 Node.js 应用」和端口方面的限制,还能顺手给 Farm 添一种新的工作模式——这回面向的是手里没有 k8s 集群的小团队。再往后,甚至可以彻底告别以普通 Node.js 进程运行的老路,删掉一大堆祖传代码,让维护省心得多。

那它最终怎么工作?Farm 通过 socket 连上 Docker Engine,并亲自兼任 builder——这里没有单独的构建 pod。这便是与 k8s 方案的第一个重要区别:实例镜像就在本地、在同一个守护进程上构建,并且留在原地。不需要把它推送到镜像仓库——镜像仓库顶多在构建期间用来拉取基础镜像。整个流程明显更简单,而这正合没有集群的小团队的胃口。

我们设计了两种工作模式:

  • docker_container——Farm 自己以容器形态运行,挂载宿主机的 socket,指挥同一守护进程上的「邻居」容器;

  • vm——Farm 直接住在虚拟机上,用它自己的 Docker Engine。

Farm 和所有实例都会加入一个共享的 Docker 网络(默认名为 farm),每个应用实例都会得到一个名字不言自明的容器:farm-docker-<hash>。等到要路由流量的时候,我们正是靠这个名字找到它。

与 k8s 方案一样,构建和启动同住在一个 buildInstance 方法里,只是不再拆分成单独的 pod:

  1. 用户或 CI 启动构建(buildInstance 方法)。

  2. 从分支拉取代码,并从 farm.json 读取 Farm 配置——Dockerfile 的路径、构建与运行时的变量都从这里来。

  3. Farm 在本地构建 docker 镜像并打上 farm-docker-<hash> 标签,一路把所有构建日志流式传进 UI 供调试。

  4. 用做好的镜像在 farm 网络里创建并启动容器——环境变量原样传入,愿意的话还可以覆盖启动命令。

Full screen image

流量路由

在这里,Farm 又一次把代理交给 nginx。实例容器不在宿主机上发布任何端口——但在 farm 这张 Docker 网络内部,凭 farm-docker-<hash> 这个名字就能访问到它,名字由 Docker 内置的 DNS 负责解析。

唯一的问题是:那个会解析这个名字的 nginx 住在哪儿——这就取决于模式了:

  • 在 docker_container 模式下,来自宿主机的请求进入 Farm 容器,而 Farm 内部的 nginx 本就身处 farm 网络,直接把流量送到实例门口。

  • 在 vm 模式下,Farm 的 nginx 直接跑在宿主机上,看不见容器名,于是把请求代理给一个单独的 Docker Proxy 容器(还是那个 nginx,只不过跑在 farm 网络内部),再由它把流量交到正确的实例手上。

Full screen image

就这样,我们在一台装着 Docker 的普通虚拟机上得到了可用的实例,全程没有集群什么事:代码构建成镜像,镜像变成容器,nginx 把流量送到它面前。

顺带地,隔离问题连同那个古怪的 unix socket 契约也一并解决了——应用如今以寻常的 docker 镜像交付,安安心心监听自己的端口,对 Farm 的内部门道一无所知。

Farm 的演进与开源之路

经过这一轮轮改进——provider 抽象、对多种构建与运行方案的支持、迁移到 Knex——Farm 看上去已经是个成熟完整的产品,Yandex 的特有部分在里面已无足轻重。Farm 逐渐变成了一个编排器,与它底下跑着的具体技术再无强耦合。

此时的它,已经像一套 Heroku 式的简化部署系统,只不过是 self-hosted 形态。于是更有野心的计划冒了出来——把 Farm 的代码作为我们开源项目 Gravity UI 的一部分发布,让它不止在公司内部发光发热,也能惠及整个社区。

但「开放代码」这个漂亮的想法有一个疙瘩。虽说 Farm 已经变得更抽象、更干净,里面仍残留着不少 Yandex 特有的东西:内部身份认证、Arc 支持、向我们的任务跟踪系统回发状态、遥测,以及其他缠在内部基础设施上的琐碎。把这些统统发布出去可不是好主意——社区也用不着它们。可简单地一刀砍掉同样不行:我们自己的那些部署每天被几十个团队使用,靠的恰恰是这些特有逻辑。

于是任务就归结成了这样:把所有 Yandex 特有的东西挪出画面,从 Farm 中提炼出一个可配置的内核,让这些特有逻辑可以从外部重新接回来,而完全不碰内核的代码。本质上,我们需要再划一条边界:哪些准备向世界开放,哪些留在墙内。

最终,我们把 Farm 物理拆成了两套代码库。

  • 开放的内核:全部编排逻辑、Docker 与 k8s 的 provider、Git 支持、基于 Knex 的数据库、API、UI、构建队列、healthcheck——一句话,所有让 Farm 成其为产品的东西。内核对 Yandex 一无所知,可以放心地原样发布。

  • 一层薄薄的扩展层,把所有专有的东西叠加上去。

把两者粘在一起的是插件注册表:内核自行启动,并提供一个统一入口,扩展层经由它、按照定义明确的接口注册自己的实现。结果就是,整个 Yandex 部分如今住在一个小小的模块里,以声明式的方式接入:

initCoreExtension(async () => {
    // 自定义的实例运行方式
    coreRegistry.farmProviders.plugIn('process', {
        constructor: ({internalApi, config}) => new ProcessFarmProvider(internalApi, config),
    });
    // 自定义的版本控制系统
    coreRegistry.vcs.plugIn('arc', {constructor: () => new ArcVcs()});
    // 自定义的 webhook 动作——例如向任务跟踪系统回发状态
    coreRegistry.webhookActions.plugIn('tracker', new TrackerWebhookAction());
    // 自定义的身份认证
    coreRegistry.authProviders.plugIn('internal', {constructor: () => new InternalAuthProvider()});
    // ……还有遥测、CSP 域名、UI 菜单项
});

注册表被组织成一组各带接口的独立扩展点——一切可能变化的东西都经由它们暴露出来:

  • provider(farmProviders)——怎么部署、部署到哪里;

  • 版本控制系统(vcs)——公开的 git、内部的 arc;

  • 身份认证(authProviders)——如何放用户进入 UI 和 API;

  • webhook 动作(webhookActions)——如何回应 CI 事件,比如把状态回发给任务跟踪系统;

  • farm.json 模式(farmJsonConfig)——按具体 provider 的需要,在应用配置里加自定义字段;

  • UI 与安全(uiConfiguration、cspDirectives)——菜单项,以及 CSP 里的可信域名列表。

关键在于:对内核而言,这些实现一律平等——arc 和 git 没有分别,process 和 docker 或 k8s 也没有分别。所以内核可以放心开放,不必回头顾虑内部那摊事;而我们自己的部署照常运转——只要把内核和扩展层一起构建就好。

在 k8s 上落地 Farm

走过漫长的路,我们终于来到把自家 Farm 部署迁上 k8s 这一步。选定的策略是:先把规模最大的那套部署搬上 Kubernetes 的轨道,如果一切运转良好,就复兴「合租」——把剩下的项目从各自的本地 Farm 迁到一套公共部署上。恰恰是这个思路,解决了我们开头点名的维护复杂度问题。团队再次不必琢磨怎么养活自己的那套部署、盯着它的资源,因为公共集群会随负载自动扩缩容。

搭建基础设施时,我们大可以放开手脚用自家的服务和云产品。于是我们用 Yandex Managed Service for Kubernetes® 建起集群,并用 Terraform 把全部资源一个不落地描述出来。我们还为每个项目配置了独立的密钥管理,每个资源上的访问权限都按原子粒度单独授予。最终沉淀出一个可复用的 Terraform 模块,靠它部署一套 Farm 的 k8s 环境相当容易。

只用一天,我们就把当前的全部项目顺利搬上了 Farm 的 k8s 部署。这并不算难,因为最初的契约和配置全都原封未动。新 Farm 表现相当不错,其他项目也开始陆续迁来。很快,一个新问题浮出水面:集群虽然能按 RAM 和 CPU 扩缩容,磁盘空间却照旧不断被塞满——每天要构建的镜像着实不少,都得占存储。

这一次我们迎着问题正面出手,加上了清理器机制:

  • 对 Docker,它是这么工作的:用 cron 表达式设定一个具体时间,到点删除不再使用的镜像;

  • 对 k8s,这套机制建立在原生的 CronJob 资源上:它会在每个节点上拉起 pod,同样负责删除旧镜像。

基于各团队的反响——反馈内容、问题数量、找维护者求助的次数——我们宣布 Farm 上 k8s 的实验取得了成功。

Farm 如今能做什么

一路走来,Farm 已经从只服务单一技术栈的「老黄牛」,成长为一个成熟的预览环境编排器。简单总结一下它现在的能力:

  1. 从分支构建并运行。由 webhook 或 UI 触发,Farm 会拉取代码、构建应用,并拉起一个拥有独立 URL 的隔离实例。

  2. 多种 provider。k8s 面向需要水平扩展的大型部署,docker 面向没有集群的单台虚拟机。应用契约在所有 provider 之间保持一致。

  3. 带插件注册表的可配置内核。provider、版本控制系统、身份认证和 webhook 动作都通过统一的注册表接入,扩展 Farm 无需改动内核。

  4. 调度器与构建队列。生成请求依次排队,调度器在处理队列时会遵守并发构建数和运行实例数的上限。

  5. 生命周期管理。可从 UI 自动启动实例,闲置实例超时后会被停止并删除。

  6. Healthcheck。Farm 会盯着实例的健康状况,并把最新状态反映到 UI 和 API:在 Docker 和进程模式下用自己的可用性检查,在 Kubernetes 里则用原生的 liveness/readiness 探针。

  7. 环境变量传递。灵活的环境配置:构建期与运行时变量、生成时无法被覆盖的受保护变量,Docker 下还能继承宿主机或容器的变量。

  8. 带迁移机制的持久化数据库。基于 Knex,底层可用 SQLite、PostgreSQL 或其他 DBMS。

  9. UI 与 API。用来启动和调试实例的 Web 界面,以及用于对接 CI 的 HTTP API。

  10. 磁盘空间清理器。定期清扫 Docker 和 k8s 里不再使用的镜像。

下一步计划

现在说说计划。Farm 里想改进的东西还有很多。眼下的主要方向如下:

  1. 在 k8s provider 中转向 Gateway API,用它取代 Ingress。

  2. 支持别名(alias),让实例能用一个人类可读的名字访问,而不是一串哈希。

  3. 完整的授权能力——用户、角色与访问权限体系。

  4. 按项目划分的独立配额。

和其他任何开源项目一样,我们会继续完善文档和示例,让 Farm 用起来更简单顺手——好让外部用户的数量快速涨起来。我们希望 Farm 有朝一日能跻身业界预览环境的常用工具之列——不过成不成,还得看后续发展。

如何尝试

欢迎来我们的代码仓库做客,读读 README 和相应的文档。想动手试试的话,只要装好 Docker Engine,就能在本地自由拉起一个 Farm。任何反馈我们都感激不尽:欢迎提 issue、发 PR、抛问题;需要什么额外功能,尽管带着 feature request 来!

感谢阅读!要是喜欢我们的项目,欢迎点亮 star!想第一时间了解 Yandex Cloud 团队的最新动态,欢迎加入我们的频道

Farm 开源了:轻松搭建预览环境

Sign in to save this post