Farm wird Open Source: Preview-Umgebungen ohne Schmerzen

Wie Farm von einem Self-hosted-Skript auf einer einzelnen VM zu einem Orchestrator für Preview-Umgebungen auf Docker und Kubernetes wurde — und warum wir den Code geöffnet haben.

Man will jeden Pull Request nicht nur durch die Tests bringen, sondern ihn auch live zeigen: dem Designer, dem Manager, dem Nachbarteam — damit sie alle im UI herumklicken können und sich freuen, oder Bugs finden — und sich ärgern. Klingt simpel: Umgebung hochfahren, Link teilen. Doch wenn täglich Dutzende Pull Requests hereinkommen und es jede Menge Teams gibt, wird aus “einfach eine Umgebung hochfahren” ein ausgewachsenes Infrastrukturprojekt.

Hallo! Mein Name ist Mikhail Golbakh, ich bin Senior Frontend-Entwickler bei Yandex Cloud. Seit einigen Jahren entwickeln mein Team und ich Farm — einen Dienst, der für jeden Pull Request eine Preview-Umgebung hochfährt. In diesem Artikel erzähle ich, wie Farm von einem simplen Self-hosted-Skript auf einer einzigen virtuellen Maschine zu einem Orchestrator auf Docker und Kubernetes herangewachsen ist — und warum wir ihren Code am Ende geöffnet haben.


Wie wir zur ersten Version von Farm kamen

Bei Yandex gibt es viele Frontend-Teams, die Hunderte Pull Requests am Tag erstellen. Jeder Pull Request stößt eine Vielzahl von Tests und anderen CI-Checks an. Unit-Tests, Type-Checks und Linter laufen problemlos auf isolierten virtuellen Maschinen.

Aber was tun, wenn man eine vollwertige Anwendung aus einem Branch hochfahren muss? Aus dieser Frage ergibt sich eine Aufgabe, vor der Frontend-Entwickler ständig stehen: für jeden Pull Request eine “Beta” hochzuziehen. Das Erste, was einem in den Sinn kommt: eine dedizierte Dev-Umgebung nach dem Vorbild der Produktion aufsetzen und sie unter den Entwicklern teilen. So machen es kleine Teams am Anfang auch oft, doch sie stoßen schnell auf ein Skalierungsproblem. Eine einzelne Umgebung lässt sich irgendwann nicht mehr teilen, und e2e-Tests kann man darauf auch nicht wirklich laufen lassen — ihre Stabilität würde eindeutig Fragen aufwerfen.

Traditionell richtet jedes Team seinen eigenen Prozess für das Deployment von Dev-Umgebungen ein und steckt dabei einiges an Aufwand hinein — und Ressourcen in die Wartung. Wir waren keine Ausnahme und entwickelten eine eigene Lösung. Allerdings haben wir sie von Anfang an als gemeinsame Technologie konzipiert, die sich trotz möglicher Unterschiede in der Infrastruktur auf Dutzende Teams skalieren lässt.

Damals im Jahr 2018 baute das Frontend-Team von Yandex Cloud die erste Version von Farm — im Kern einen simplen Self-hosted-Dienst, der den Code aus einem Repository-Branch herunterlud, die Build-Befehle für die Zielanwendung ausführte und deren Prozess auf einer VM startete, samt Traffic-Routing über eine dedizierte Domain. Selbst der Befehlssatz war hardcodiert: Farm war in erster Linie auf unsere eigenen Projekte zugeschnitten, die auf gemeinsamen Komponenten für Node.js-Webservices aufbauen. Herausgekommen ist eine Art vereinfachtes Deploy-System mit Yandex-Einschlag.

Im Laufe der Jahre entwickelte sich der Dienst weiter: Farm lernte, mit separaten Build- und Start-Konfigurationen für jedes Projekt umzugehen, bekam ein vollwertiges UI, eine Datenbank und jede Menge zusätzlichen Code, der die Arbeit mit der Yandex-Infrastruktur vereinfachte. Das Wesen des Dienstes änderte sich jedoch nicht — er löste nach wie vor die folgenden Aufgaben:

  • Code herunterladen und das Projekt aus einem Pull Request bauen;

  • den Traffic zur laufenden Anwendung routen;

  • die Lösung in einer einzigen Infrastrukturkomponente vereinheitlichen, die sich leicht verteilen und in die CI-Prozesse von Yandex einbetten lässt.

Wie es funktioniert

Farm ist eine kleine Node.js-Anwendung, deren vereinfachter Ablauf so aussieht:

  1. Farm erhält eine Generierungsanfrage. Sie kann von einem CI-Task kommen oder direkt von einem Nutzer über ein Formular im UI.

  2. Eine neue Anwendungsinstanz landet mit dem Status queued in der Datenbank und reiht sich in die Build-Warteschlange ein.

  3. Farm arbeitet die Warteschlange ab, lädt den Code aus dem Ziel-Repository und -Branch herunter, liest die Konfiguration und startet den Build.

  4. Der Anwendungsprozess startet: Von ihm wird erwartet, dass er einen Socket unter <instance_dir>/dist/server.sock öffnet.

  5. Farm — genauer gesagt an diesem Punkt bereits nginx mit einer speziellen Config — beginnt, den Traffic über eine eindeutige Domain zu routen, zum Beispiel: d008838956420e285cee652262dd5d18b2ed2a2a.farm.example.com.

Full screen image

Wie im Diagramm zu sehen ist, haben wir Farm nicht mit dem Load Balancing betraut, sondern nginx verwendet. Dieser Ansatz bleibt auch künftig bestehen: Traffic-Proxying zu pflegen ist eine nicht triviale Aufgabe, die wir lieber nicht selbst lösen wollen. Außerdem gab es Unterstützung für Git und Arc — Yandex' eigenes internes Versionskontrollsystem.

Build und Start selbst bestanden schlicht darin, dass Farm Befehle ausführte. Alles war so direkt und geradeheraus, wie es nur geht:

# Build
npm ci
npm run build

# Start
npm run start

Für das vollständige Bild werfen wir noch einen Blick auf eine Beispiel-Config 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"
        }
      }
    ]
  }
}

Am Ende hatten wir ein echtes Arbeitspferd. Ein Zielteam musste nur noch eine virtuelle Maschine vorbereiten, die Konfiguration von Farm selbst und ihrer Projekte beschreiben, den Dienst hochfahren und einen CI-Prozess für Pull Requests aufsetzen. Klingt einfach, oder? Aber…

Wachstum und neue Herausforderungen

In diesem Zustand lebte Farm eine ganze Weile und erledigte ihre Aufgaben ordentlich. Anfangs hatten wir sogar eine Art Wohngemeinschaft — eine gemeinsame Farm für mehrere Frontend-Teams, um nicht unnötig Kraft in die Pflege mehrerer Installationen zu stecken. Doch die Teams wuchsen, ihre Zahl nahm zu, und Farm konnte nur innerhalb einer einzigen VM arbeiten. Mit der Zeit reichten selbst die leistungsstärksten Konfigurationen nicht mehr aus.

Mehrere Builds parallel auszuführen verschlang sämtliche Ressourcen der Maschine, und eine Instanz konnte eine ganze Weile in der Warteschlange stehen. So begann ein organischer Prozess der Auflösung der Wohngemeinschaft in kleinere Farms — manchmal sogar eine pro Projekt.

Natürlich löste diese Aufteilung das Problem nicht — sie schob es nur auf. Die Rechenressourcen gingen trotzdem weiter zur Neige, und neue Teams mussten zusätzlichen Aufwand in Support und Wartung von Farm stecken. So lief zum Beispiel die Festplatte der VM ziemlich regelmäßig voll, was eine manuelle Bereinigung nicht mehr benötigter Dateien erzwang. Das erste Problem sind also begrenzte Ressourcen ohne Management und Skalierung. Merken wir uns das.

Ein weiteres Problem: Unter der Haube nutzte Farm SQLite als Datenbank — ohne jegliche Abstraktionen wie Knex. Das erzeugte einen Vendor-Lock und zwang uns außerdem, bei jeder Schemaänderung sämtliche Daten zu löschen, denn einen Migrationsmechanismus gab es nicht. Da Farm auf viele Team-Installationen verteilt war, führte das auch dazu, dass kaum jemand sie aktualisieren wollte: Wer weiß, was dabei kaputtgeht — und die Daten müsste man höchstwahrscheinlich auch noch löschen. Frei nach dem Motto: “Never change a running system.”

Obendrein konnte Farm nur Node.js-Anwendungen starten, und alle Prozesse liefen direkt auf dem Host — ohne jede Isolation. Und weil es keine Isolation gab, konnten die Anwendungen nicht einfach ihren gewohnten Port für den Traffic nutzen, denn Farm kümmerte sich nicht um Port-Management. Deshalb lief die Kommunikation über Unix-Sockets, was die Anwendungen zwang, diesen etwas seltsamen Kontrakt einzuhalten.

Wir mussten also die folgenden Probleme lösen:

  1. Begrenzte Ressourcen ohne Management und Skalierung.

  2. Keine Datenbankmigrationen und eine harte Abhängigkeit von SQLite.

  3. Keine Isolation der Instanzen, dazu die Einschränkung, eine Node.js-Anwendung sein zu müssen, die einen Unix-Socket veröffentlicht.

  4. Die Last von Support und Betrieb — schließlich wollten wir den Teams das Leben ursprünglich leichter machen und nicht für zusätzliche Kopfschmerzen sorgen (dieses Zusatzproblem klammern wir vorerst aus, kommen aber gegen Ende darauf zurück).

Wie wir das angegangen sind

Ein erfahrener Ingenieur, der auf diese Aufgaben blickt, würde höchstwahrscheinlich vorschlagen, Farm auf die Schienen eines k8s-Clusters zu setzen. Und genau zu dieser Idee kamen wir nach und nach: Build und Deployment der Anwendungen auf die Rechenleistung des Clusters in separate Pods verlagern und dessen Fähigkeiten für automatische Skalierung und Ressourcenmanagement nutzen. Die Anwendungen selbst würden als der wohlvertraute Docker-Container ausgeliefert — ein sehr klarer und einfacher Kontrakt für unsere Branche. Außerdem mussten wir die Möglichkeit beibehalten, Anwendungen wie gewohnt als Prozesse zu starten, damit kleine Teams keine aufwendige Migration auf die k8s-Lösung durchmachen müssen.

Farm einfach aufzugeben und Anwendungen direkt aus der CI in einen k8s-Cluster zu deployen, war aus folgenden Gründen unmöglich:

  1. Farm hatte in allen unseren bestehenden Prozessen und der CI tiefe Wurzeln geschlagen. Die Teams zur Migration auf eine völlig neue Lösung zu zwingen, ergab keinen Sinn.

  2. Schaut man genauer hin, baut und startet Farm nicht nur die Anwendung — sie übernimmt auch die Verantwortung für das Routing (wenn auch über nginx), stoppt und entfernt ungenutzte Instanzen und erledigt noch einiges mehr.

  3. Und auch das komfortable UI zählt eine Menge: Damit können nicht nur Frontend-, sondern auch Backend-Entwickler eine Instanz einer Anwendung hochfahren, um verschiedene Features zu testen.

In der Praxis erwies sich die k8s-Unterstützung als schwieriger als anfangs gedacht. Seit den frühesten Versionen war die Logik für den Umgang mit Anwendungsinstanzen über die gesamte Codebasis verstreut, und über die Jahre hatten sich darin jede Menge Details und elegante Krücken angesammelt — eine weitere Implementierung daneben hineinzuquetschen war knifflig.

Ein grundlegendes Refactoring

Als ersten Schritt nahmen wir ein großes Refactoring des Farm-Codes in Angriff. Zunächst mussten wir die Logik für den Umgang mit Instanzen in einer eigenen Provider-Klasse kapseln — einer Art Backend unserer Farm, das die Strategie für Build und Deployment einer Anwendung festlegen sollte. Das würde uns erlauben, das Legacy-Schema und k8s vorerst nebeneinander zu unterstützen und später neue Implementierungen über ein einheitliches Interface hinzuzufügen.

Nachdem wir den Code auseinandergenommen hatten, gelang es uns, alle providerspezifischen Methoden herauszulösen und eine abstrakte Klasse zu ihrer Beschreibung vorzubereiten. Etwas vereinfacht lief alles auf Folgendes hinaus:

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}>;
}

Etwa zur gleichen Zeit abstrahierten wir sämtliche Datenbankzugriffe und stellten sie auf Knex um, damit wir künftig auf ein anderes, leistungsfähigeres und persistentes DBMS wechseln können. Als Bonus bekamen wir einen günstigen Migrationsmechanismus, der Änderungen am Datenbankschema erleichtert.

Das neue Kubernetes-Setup

Im neuen Setup wird Farm im Cluster deployt, arbeitet als Controller, verbindet sich mit der k8s-API und beginnt, die Ressourcen des Clusters zu verwalten: Pods für Builds und für die Anwendung zu starten, andere Ressourcen zu erstellen und zu löschen.

Um das Setup zu verstehen, genügt es, die zwei Hauptprozesse zu betrachten: den Build und das Deployment einer Anwendungsinstanz.

Der Build besteht aus mehreren Etappen:

  1. Ein Nutzer oder die CI startet den Build (die Methode buildInstance).

  2. Aus dem Branch wird die Farm-Konfiguration für die Zielanwendung geholt (die Datei farm.json im Repository-Root).

  3. Ein Builder-Pod startet: Er lädt den Code aus dem Branch herunter, baut ein Docker-Image (standardmäßig Dockerfile.farm) und pusht es in eine vorab festgelegte Registry.

  4. Alle Logs des Builder-Pods werden in Echtzeit gestreamt und zum Debuggen im UI angezeigt.

  5. Nach Abschluss des Builds löscht Farm den Builder-Pod.

Full screen image

Das Ergebnis des Builds ist ein Artefakt — ein in die Registry gepushtes Anwendungs-Image, bereit zum Deployen und Starten. Als Bonus bekamen wir Build-Caching über Docker, was zur Beschleunigung der CI sehr nützlich ist.

Das Deployment startet direkt nach dem Build, in derselben Methode buildInstance — machen wir also ab dem Moment weiter, in dem das Image gepusht ist:

  1. Im Cluster wird ein Deployment erstellt, das das Image aus der vorherigen Etappe verwendet.

  2. Es werden ein Service mit dem Port, den wir in der Farm-Konfiguration angegeben haben, sowie ein Ingress mit einer Domain auf Basis der Instanz-ID erstellt.

  3. Dann betritt ein neuer Spieler das Feld — der Ingress NGINX Controller. Er übernimmt das gesamte Routing und macht die Anwendung von außen erreichbar. Hier bleibt Farm sich treu und delegiert das Traffic-Proxying an eine andere Komponente des Systems.

Full screen image

Technisch ließe sich fürs Routing jeder andere Ingress-Controller einsetzen — bis hin zu irgendeinem Cloud-ALB, falls nötig. Wir haben uns wegen der einfachen Handhabung und der schnellen Config-Updates für den Ingress NGINX Controller entschieden.

So ungefähr bekommen wir eine funktionierende Anwendungsinstanz, bereit, geöffnet und getestet zu werden. Zusätzlich achtet Farm darauf, dass Ressourcen nicht dupliziert werden und alle Operationen idempotent sind — das ist entscheidend für den stabilen Betrieb.

Ein einfacheres Setup mit purem Docker

Kaum war die Idee der k8s-Unterstützung in Farm geboren, folgte ein ziemlich naheliegender Vorschlag: Warum nicht auch das Ausführen von Instanzen einfach auf einer virtuellen Maschine in Docker unterstützen? Das würde das Isolationsproblem der Instanzen lösen, die Beschränkungen auf Node.js-Anwendungen und bei den Ports aufheben und Farm einen weiteren Betriebsmodus geben — diesmal für kleine Teams ohne k8s-Cluster. Später könnten wir die alte Startmethode mit gewöhnlichen Node.js-Prozessen komplett aufgeben, was die Wartung erheblich vereinfachen würde, weil sich ein Haufen Legacy-Code löschen ließe.

Wie funktioniert das am Ende? Farm verbindet sich über einen Socket mit der Docker Engine und tritt selbst als Builder auf — separate Build-Pods gibt es hier nicht. Und da ist auch schon der erste wichtige Unterschied zum k8s-Setup: Das Instanz-Image wird lokal gebaut, auf demselben Daemon, und bleibt auch dort wohnen. Es muss nicht in eine Registry gepusht werden — die Registry ist höchstens nützlich, um während des Builds Basis-Images zu ziehen. Das ist spürbar einfacher, und genau das brauchen kleine Teams ohne Cluster.

Dabei haben wir zwei Betriebsmodi vorgesehen:

  • docker_container — Farm selbst läuft als Container mit gemountetem Host-Socket und dirigiert die “Nachbar‘-Container auf demselben Daemon;

  • vm — Farm lebt direkt auf der VM mit ihrer eigenen Docker Engine.

Sowohl Farm als auch alle Instanzen treten einem gemeinsamen Docker-Netzwerk bei (standardmäßig farm), und jede Anwendungsinstanz bekommt einen Container mit dem sprechenden Namen farm-docker-<hash>. Genau über diesen Namen finden wir sie später wieder, wenn es an das Traffic-Routing geht.

Wie im k8s-Setup wohnen Build und Start in derselben Methode buildInstance, nur ohne separate Pods:

  1. Ein Nutzer oder die CI startet den Build (die Methode buildInstance).

  2. Der Code wird aus dem Branch geholt und die Farm-Konfiguration aus farm.json gelesen — daher stammen der Pfad zum Dockerfile sowie die Build- und Laufzeitvariablen.

  3. Farm baut das Docker-Image lokal und versieht es mit dem Tag farm-docker-<hash>, während sie nebenbei alle Build-Logs zum Debuggen ins UI streamt.

  4. Aus dem fertigen Image wird ein Container erstellt und im Netzwerk farm gestartet — mit durchgereichten Umgebungsvariablen und, falls gewünscht, einem überschriebenen Startbefehl.

Full screen image

Traffic-Routing

Hier delegiert Farm das Proxying wieder einmal an nginx. Der Instanz-Container veröffentlicht keinerlei Ports auf dem Host — erreichbar ist er über den Namen farm-docker-<hash> innerhalb des Docker-Netzwerks farm, wo der eingebaute DNS von Docker diesen Namen auflöst.

Bleibt nur die Frage, wo jener nginx sitzt, der den Namen auflösen kann — und das hängt vom Modus ab:

  • Im Modus docker_container geht eine Anfrage vom Host in den Farm-Container, und dessen interner nginx, der im Netzwerk farm hängt, bringt den Traffic direkt zur Instanz.

  • Im Modus vm läuft der nginx von Farm direkt auf dem Host und sieht keine Containernamen, deshalb proxyt er die Anfrage an einen separaten Docker Proxy-Container (derselbe nginx, nur innerhalb des Netzwerks farm gestartet), der den Traffic dann an die richtige Instanz weiterreicht.

Full screen image

Und so bekommen wir eine funktionierende Instanz auf einer gewöhnlichen virtuellen Maschine mit Docker, ganz ohne Cluster: Der Code wird zu einem Image gebaut, das Image wird zum Container, und nginx bringt den Traffic dorthin.

Nebenbei haben wir auch das Isolationsproblem samt jenem seltsamen Unix-Socket-Kontrakt gelöst — die Anwendung wird jetzt als ganz normales Docker-Image ausgeliefert und lauscht einfach auf ihrem Port, ohne irgendetwas vom Innenleben von Farm zu wissen.

Die Evolution von Farm und der Weg zu Open Source

Nach all den Verbesserungen — den Provider-Abstraktionen, der Unterstützung unterschiedlicher Build- und Startschemata und dem Umzug auf Knex — wirkte Farm bereits wie ein reifes, vollwertiges Produkt, in dem die Yandex-Spezifika keine große Rolle mehr spielten. Nach und nach war Farm zu einem Orchestrator ohne harte Kopplung an die Technologien geworden, die sie unter sich laufen ließ.

Farm ähnelte inzwischen einem vereinfachten Deploy-System nach dem Vorbild von Heroku, nur als Self-hosted-Dienst. So entstanden ehrgeizigere Pläne — den Code von Farm im Rahmen unseres Open-Source-Projekts Gravity UI zu veröffentlichen, damit sie nicht nur innerhalb des Unternehmens nützlich ist, sondern der ganzen Community.

Doch diese schöne Idee, den Code zu öffnen, hatte einen Haken. Auch wenn Farm abstrakter und sauberer geworden war, steckte noch reichlich Yandex-Spezifik darin: interne Authentifizierung, Arc-Unterstützung, das Posten von Statusmeldungen in unseren Issue-Tracker, Telemetrie und andere Kleinigkeiten, die an der internen Infrastruktur hängen. All das zu veröffentlichen ist keine gute Idee — und die Community kann damit ohnehin nichts anfangen. Es einfach herauszuschneiden kam aber auch nicht infrage: Auf genau dieser Spezifik laufen unsere eigenen Installationen, die Dutzende Teams täglich nutzen.

Die Aufgabe lautete also: alles Yandex-Spezifische vor die Klammer ziehen und aus Farm einen konfigurierbaren Kern herauslösen, in den sich die Spezifika von außen wieder einstecken lassen, ohne den Code des Kerns anzufassen. Im Grunde mussten wir eine weitere Grenze ziehen — zwischen dem, was wir der Welt öffnen wollen, und dem, was intern bleibt.

Am Ende haben wir Farm physisch in zwei Codebasen aufgeteilt.

  • Der offene Kern: die gesamte Orchestrierung, die Provider für Docker und k8s, die Git-Unterstützung, die Datenbank auf Knex-Basis, die API, das UI, die Build-Warteschlange, der Healthcheck — kurz: alles, was Farm als Produkt ausmacht. Der Kern weiß nichts von Yandex und kann bedenkenlos so veröffentlicht werden, wie er ist.

  • Eine dünne Erweiterungsschicht, die alles Proprietäre obendrauf packt.

Das Bindeglied zwischen beiden ist eine Plugin-Registry: Der Kern fährt sich selbst hoch und stellt einen einzigen Einstiegspunkt bereit, über den die Erweiterungsschicht ihre Implementierungen gegen klar definierte Interfaces registriert. Im Ergebnis lebt der gesamte Yandex-Teil jetzt in einem kleinen Modul und wird deklarativ eingebunden:

initCoreExtension(async () => {
    // eine eigene Art, Instanzen zu starten
    coreRegistry.farmProviders.plugIn('process', {
        constructor: ({internalApi, config}) => new ProcessFarmProvider(internalApi, config),
    });
    // ein eigenes Versionskontrollsystem
    coreRegistry.vcs.plugIn('arc', {constructor: () => new ArcVcs()});
    // eine eigene Webhook-Aktion — zum Beispiel einen Status in den Tracker posten
    coreRegistry.webhookActions.plugIn('tracker', new TrackerWebhookAction());
    // eigene Authentifizierung
    coreRegistry.authProviders.plugIn('internal', {constructor: () => new InternalAuthProvider()});
    // ...und dazu Telemetrie, CSP-Domains, Menüpunkte im UI
});

Die Registry ist als Satz separater Erweiterungspunkte mit jeweils eigenem Interface organisiert — über sie ist alles nach außen geführt, was sich ändern kann:

  • Provider (farmProviders) — wie und wohin deployt wird;

  • Versionskontrollsysteme (vcs) — öffentliches git, internes arc;

  • Authentifizierung (authProviders) — wie Nutzer ins UI und in die API gelassen werden;

  • Webhook-Aktionen (webhookActions) — was als Reaktion auf ein CI-Ereignis passieren soll, etwa einen Status zurück in den Tracker posten;

  • das farm.json-Schema (farmJsonConfig) — eigene Felder in der Anwendungskonfiguration für die Bedürfnisse eines bestimmten Providers;

  • UI und Sicherheit (uiConfiguration, cspDirectives) — Menüpunkte und die Liste vertrauenswürdiger Domains in der CSP.

Der entscheidende Punkt ist, dass für den Kern alle diese Implementierungen gleichberechtigt sind: arc unterscheidet sich für ihn nicht von git, und process nicht von docker oder k8s. Deshalb lässt sich der Kern öffnen, ohne auf die interne Küche schielen zu müssen, während unsere Installation weiterläuft wie bisher — einfach indem der Kern zusammen mit der Erweiterungsschicht gebaut wird.

Der Rollout von Farm auf k8s

Nach diesem langen Weg kamen wir endlich dazu, unsere Farm-Installationen auf k8s umzuziehen. Unsere Strategie: die größte Installation auf Kubernetes-Schienen heben und dann, wenn alles gut läuft, die Wohngemeinschaft wiederbeleben, indem wir die übrigen Projekte von den lokalen Farms auf eine gemeinsame Installation umziehen. Genau dieser Ansatz löst das Problem des Wartungsaufwands, das wir am Anfang markiert hatten. Damit müssen die Teams sich erneut keine Gedanken darüber machen, eine eigene Installation zu betreiben und deren Ressourcen zu überwachen — der gemeinsame Cluster skaliert unter Last automatisch.

Beim Aufbau der Infrastruktur können wir frei auf unsere eigenen Services und Cloud-Angebote zurückgreifen. Also erstellten wir einen Cluster mit Yandex Managed Service for Kubernetes® und beschrieben ausnahmslos alle Ressourcen mit Terraform. Außerdem richteten wir für jedes Projekt ein separates Secret-Management mit atomaren Zugriffsrechten auf jede Ressource ein. Herausgekommen ist ein wiederverwendbares Terraform-Modul, mit dem sich eine k8s-Installation von Farm ziemlich einfach ausrollen lässt.

An einem einzigen Tag zogen wir alle aktuellen Projekte erfolgreich auf die k8s-Installation von Farm um. Das war nicht besonders schwer, da der ursprüngliche Kontrakt und die Configs erhalten blieben. Die neue Farm machte ihre Sache richtig gut, und auch andere Projekte begannen, auf sie umzuziehen. Ziemlich bald tauchte ein neues Problem auf: Der Cluster skaliert zwar nach RAM und CPU, aber der Festplattenplatz lief trotzdem immer wieder voll, weil täglich etliche Images gebaut wurden und die Speicherplatz belegten.

Hier haben wir das Problem frontal gelöst und einen Cleaner-Mechanismus hinzugefügt:

  • Für Docker funktioniert er so: Man legt per Cron-Ausdruck eine bestimmte Zeit fest, zu der ungenutzte Images gelöscht werden sollen;

  • für k8s baut der Mechanismus auf der nativen Ressource CronJob auf, die auf jedem Node Pods startet, die ebenfalls alte Images löschen.

Das Experiment “Farm auf k8s” haben wir auf Basis der Resonanz der Teams für erfolgreich erklärt: Feedback, Zahl der Probleme und Zahl der Anfragen an die Maintainer.

Was Farm heute kann

Auf diesem Weg ist Farm vom Arbeitspferd für einen einzigen Stack zu einem reifen Orchestrator für Preview-Umgebungen herangewachsen. Hier eine kurze Zusammenfassung dessen, was sie heute kann:

  1. Build und Start aus einem Branch. Per Webhook oder aus dem UI ausgelöst, holt Farm den Code, baut die Anwendung und fährt eine isolierte Instanz mit eigener URL hoch.

  2. Mehrere Provider. k8s für große Installationen mit horizontaler Skalierung, docker für eine einzelne virtuelle Maschine ohne Cluster. Der Anwendungskontrakt ist bei allen Providern derselbe.

  3. Ein konfigurierbarer Kern mit Plugin-Registry. Provider, Versionskontrollsysteme, Authentifizierung und Webhook-Aktionen werden über eine einheitliche Registry eingebunden — so lässt sich Farm erweitern, ohne den Kern anzutasten.

  4. Ein Scheduler und eine Build-Warteschlange. Generierungsanfragen reihen sich in die Warteschlange ein, und der Scheduler arbeitet sie unter Beachtung der Limits für gleichzeitige Builds und laufende Instanzen ab.

  5. Lifecycle-Management. Automatischer Instanzstart aus dem UI sowie das Stoppen und Löschen untätiger Instanzen nach einem Timeout.

  6. Healthcheck. Farm überwacht den Zustand der Instanzen und spiegelt den aktuellen Status in UI und API wider: in Docker und im Prozessmodus mit eigenen Verfügbarkeitsprüfungen, in Kubernetes über native Liveness-/Readiness-Probes.

  7. Durchreichen von Umgebungsvariablen. Flexible Umgebungskonfiguration: Build- und Laufzeitvariablen, geschützte Variablen, die sich bei der Generierung nicht überschreiben lassen, und für Docker außerdem das Erben von Variablen des Hosts oder Containers.

  8. Eine persistente Datenbank mit Migrationen. Knex auf Basis von SQLite, PostgreSQL oder eines anderen DBMS.

  9. UI und API. Ein Webinterface zum Starten und Debuggen von Instanzen und eine HTTP-API für die CI-Integration.

  10. Cleaner für den Festplattenplatz. Regelmäßiges Aufräumen ungenutzter Images in Docker und k8s.

Was kommt als Nächstes

Nun zu unseren Plänen. Es gibt noch vieles, das wir an Farm verbessern möchten. Das sind die wichtigsten Richtungen im Moment:

  1. Der Umzug auf die Gateway API als Ersatz für Ingress im k8s-Provider.

  2. Alias-Unterstützung, damit eine Instanz statt über einen Hash über einen menschenlesbaren Namen erreichbar ist.

  3. Vollwertige Autorisierung — ein System aus Nutzern, Rollen und Zugriffsrechten.

  4. Getrennte Quoten für verschiedene Projekte.

Wie bei jedem anderen Open-Source-Projekt werden wir Dokumentation und Beispiele weiter ausbauen, damit sich Farm einfacher und bequemer nutzen lässt — und die Zahl der externen Nutzer zügig wächst. Wir würden uns freuen, wenn Farm mit der Zeit zu einem der Standardwerkzeuge für Preview-Umgebungen in der Branche wird — aber schauen wir mal, wie es läuft.

So probieren Sie es aus

Schauen Sie in unserem Repository vorbei und werfen Sie einen Blick in die README und die zugehörige Dokumentation. Zum Ausprobieren können Sie Farm mit installierter Docker Engine problemlos lokal hochfahren. Wir sind für jedes Feedback dankbar — legen Sie also Issues an, schicken Sie PRs, stellen Sie Fragen und bringen Sie Feature Requests mit, wenn Sie zusätzliche Funktionalität brauchen!

Danke fürs Lesen! Wenn Ihnen unser Projekt gefällt, freuen wir uns über Ihre Sterne! Und wenn Sie an den neuesten Nachrichten aus dem Team von Yandex Cloud interessiert sind, treten Sie unserem Kanal bei.

Farm wird Open Source: Preview-Umgebungen ohne Schmerzen

Sign in to save this post