Seit etwas über einem Jahr arbeite ich bei GitLab und habe engen Kontakt mit Kunden, die entweder schon GitLab einsetzen oder bald einsetzen werden. Ein Thema was zuletzt immer häufiger aufkommt, ist das Thema „Monorepo“. Noch spannender ist, wie einige Kunden sagen, sie haben „mehrere Monorepos“, was keinen Sinn ergibt. Denn nicht umsonst setzt sich das Wort auf „mono“ und „repo“ zusammen, also „einzel“ und „Repository“. Im Endeffekt hat man also ein großes Repository wo der komplette Quellcode enthalten und verwaltet wird. Jedes Mal, wenn ein Kunde von „mehreren Monorepos“ spricht, dann zwickt mein Auge immer wieder stark, denn meistens ist damit eigentlich nur gemeint, dass es sich um „Multi-Projekt Repositorys“ handelt. Also sind es meist eher Repositorys wo mehrere Projekte innerhalb eines Repositorys verwaltet werden.

Stellenweise finden sich im Internet diverse Blogposts wo Monorepos die Abkürzung von „monolithisches Repository“ ist. Das ist nicht die Definition, die ich wählen würde und auch nicht die, die gängig verbreitet ist. Ein Monolith aus Sicht der Software-Entwicklung kann schließlich auch aus mehreren Repositorys bestehen – und tut es in vielen Umgebungen auch. Überschneidungen gibt es hier aber sicherlich.

Echte Monorepos finden sich in der freien Wildbahn eher selten. Der wohl bekannteste Vertreter ist Google. Google hat für ihre internen Tools ein großes Repository, was nicht auf Git aufsetzt, sondern eine proprietäre Eigenentwicklung ist. Enthalten ist dort der Quellcode und die Versionshistorie der ganzen Firma seit Anbeginn der Zeit. In einem Paper von Juli 2016 beschreiben sie recht ausführlich, wie ihr Monorepo aufgebaut ist, wie sie damit Arbeiten und wie groß es mittlerweile ist. Wem die Details interessieren, sollte da mal reinlesen. Knapp 5 Jahre später dürfte das Repository noch deutlich riesiger geworden sein.

Ein weiterer bekannter Nutzer von Monorepos sind weitere große Techfirmen wie Facebook, Microsoft oder Twitter. Bei jeder Firma dürfte die Nutzung durchaus unterschiedlich sein. Facebook setzt beispielsweise auf Mercurial als SCM tool. Details darüber schrieben sie im Blogpost „Scaling Mercurial at Facebook“. Der Artikel ist von Januar 2014, also auch schon etwas älter.

Warum überhaupt Monorepos?

Egal ob man mit multiplen Repositorys arbeitet oder mit einem Monorepo: Beide Varianten haben je nach betrachteten Aspekt Vor- und Nachteile. Während man bei einem Monorepo-Ansatz vielleicht nicht die Probleme des Multi-Repo-Ansatzes hat, bekommt man stattdessen andere Herausforderungen.

Statt hier plump die Vor- und Nachteile beider Varianten einzugehen, verfolge ich hier einen etwas anderen Ansatz und beleuchte einzelne Aspekte und Methodiken mit beiden Varianten. Dies betrifft sowohl technische Herausforderungen vom eingesetzten Versionsverwaltungsprogramm, als auch die Build-Tools, sowie die Arbeitsweise.

Internes Dependency-Managements

Ein wichtiger Grund für ein Monorepo (oder auch Multi-Projekt-Repository) ist die vereinfachte Möglichkeit des Managements von internen Abhängigkeiten. Und dieser Use-Case ist generell gar nicht mal so selten.

Häufig sieht es so aus, dass es innerhalb einer Firma mehrere Projekte gibt, welche die gleiche Abhängigkeit nutzen. Soweit nichts Besonderes. Aber wie sieht nun der Vorgang aus, wenn eine Änderung an der Abhängigkeit gemacht werden muss?

Im klassischen Konzept, also ohne einem Monorepo, werden gleich jeweils eine Änderung an zwei Repositorys benötigt: Einmal in der Library, wo die eigentliche Änderung durchgeführt wird und anschließend die nächste Änderung im Hauptprojekt. Diese ist allerdings abhängig von der Änderung der Abhängigkeit. Hier muss also erst einmal abgewartet werden, bis die Änderung eingeflossen ist, bevor so wirklich die Änderung im Hauptprojekt gemacht werden kann. Der Prozess dauert also automatisch etwas länger und führt zu längeren Entwicklungszeiten. Zusätzlich könnte auch noch sein, das mehrere Projekte diese Abhängigkeit verwenden und diese auch eine Änderung benötigen, diese muss – auch wenn es sich vielleicht nur um eine kleine Anpassung handelt – in jedem Projekt nachgepflegt werden.

Hier ist also schonmal ein wesentlicher Vorteil von Monorepos beziehungsweise Multi-Projekt-Repos: Eine Änderung in einer Abhängigkeit kann in einem Rutsch und einem Review durchgeführt werden. Komplexere Abhängigkeiten beim Mergen von Änderungen über verschiedene Repositorys hinweg wird vermieden und das Review ist gleichzeitig transparenter durchführbar, da man in einer Ansicht und mit einem Review projektübergreifend die Änderung überblicken kann.

Was ich hier allerdings noch nicht betrachtet habe, ist das CI (und CD) Setup, denn das hat auch einen wesentlichen Einfluss auf die Arbeitsweise. Aber dazu im nächsten Abschnitt mehr.

CI/CD und Build Tools

Ein weiterer Aspekt der wichtig zu betrachten ist, ist das Setup rund um CI/CD und den Build Tools. Wie im Abschnitt zuvor schon erwähnt steht und fällt es der verwendete Ansatz an den Tools und Workflows.

Um das Beispiel aus dem vorherigen Abschnitt fortzuführen ist es nun wichtig zu betrachten, wie der Build-Prozess nun aussieht. Im traditionellen Umfeld mit mehreren Repositorys hat jedes Projekt im Repository meistens eine Pipeline-Definition, welche bei jedem Merge-Request ausgeführt wird, um das Projekt zu bauen und zu testen. Kompliziert wird es aber, wenn man es auch „richtig“ testen will und das ist gar nicht so einfach.

Angenommen, die Abhängigkeit mit dem Namen „DependencyA“ ist die Abhängigkeit von „HauptprojektA“ und „HauptprojektB“. Wenn also für eine Änderung in „HauptprojektA“ eine Änderung in „DependencyA“ benötigt wird, dann muss diese Änderung da normal eingepflegt werden, worin dann die Pipeline läuft und das Projekt baut und die Tests ausführt. Wenn es erfolgreich war, dann wird ggf. eine neue Version getaggt, damit es dann von „HauptprojektA“ und „HauptprojektB“ referenziert und verwendet werden kann. Dort müssen dann nochmals Merge Requests erzeugt werden, die dann die Version von „DependencyA“ hochziehen und dann alles Bauen und Testen.

Das ist im Prinzip das, was ich auch zum internen Dependency-Management geschrieben habe. Das Problem hierbei ist, dass immer nur jedes Projekt für sich getestet wird, aber nicht alle Projekte als ganzes. Das heißt im Konkret, wenn die Änderung in „DependencyA“ zu einem Fehler im „HauptprojektB“ führt, muss der ganze Workflow wieder von vorne gestartet werden.

Was häufig aber fehlt, ist die Möglichkeit alle Projekte zu bauen, die voneinander abhängen für die einzelne Änderung. Das heißt, ich mache eine Änderung an „DependencyA“, die Pipeline baut nicht nur das Projekt selbst und testet es, sondern es zieht bei „HauptprojektA“ und „HauptprojektB“ die Änderung „trocken“ mit und baut es im gleichen Zuge auch mit durch und führt die Tests aus. So kann sichergestellt werden, dass die Änderung nicht zu Folgefehlern in den eigentlichen Projekten führt.

Beim Einsatz von mehreren Repositorys ist das nicht soo einfach zu lösen, da theoretisch automatisiert in den entsprechenden Reverse-Dependency-Projekten neue Branches mit den Änderungen erstellt werden müssen, um die Pipeline zu triggern. Oder es muss eine andere Art der Parametrisierung eingebaut werden, um dies zu bewerkstelligen. zuul-ci.org ist ein Projekt um so etwas umzusetzen. Praktische Erfahrung habe ich damit allerdings nicht.

In Monorepos ist das grundsätzliche Problem eine Änderung in allen Projekten durchzubauen auch enthalten, ist da aber grundsätzlich einfacher zu verwalten, weil es eben in einem Repository enthalten ist. Nichtsdestotrotz wird auch hier ein eigenes bzw. spezielles Buildtool benötigt, was selbstständig die Abhängigkeiten auf allen Projekten im Repository erkennt und entsprechend durchbaut. Das Buildtool Bazel unterstützt das und baut etwa nur die Änderungen durch, die auch wirklich benötigt werden.

Kollaborationen im Team und zwischen Teams

Beim Arbeiten über den Grenzen von verschiedenen Teams ist die Abstimmung bei Änderungen essenziell. Beim Einsatz von mehreren Repositorys sind die Verantwortlichkeiten über die Repository-Berechtigungen selbst gesetzt. Die Hürde bei „fremden“ Teams die Änderung an einer gemeinsam genutzten Abhängigkeit und beim Projekt selbst einzubringen ist häufig eher hoch. Aber das ist mehr eine organisatorische und auch kulturelle Frage.

In Monorepos und Multi-Projekt-Repos ist das nicht so einfach möglich, weil sich hier das klassische Modell nicht anwenden lässt. Dafür gibt es allerdings das Konzept der CODEOWNERS wo sich eintragen lässt, welcher User der „Eigentümer“ welches Unterverzeichnisses ist, damit diese im Code-Review automatisch benachrichtigt werden und nur diese es mergen dürfen. Diese Funktion ist keine Funktion von Git selbst, sondern wird von den verschiedenen Git-Hosting-Diensten selbst implementiert. In GitLab ist die Code Owners Funktionalität kostenpflichtig, in GitHub je nach Visbilität des Projekts auch.

Git Funktionen für große Repositorys

Bisher habe ich mich hauptsächlich mit den konzeptionellen Vor- und Nachteilen beschäftigt. Fakt ist allerdings, dass die echten Vor- und Nachteile sehr stark von den eigentlichen Firmen, Strukturen und Projekten ab.

Bisher habe ich nicht nur die Arbeitsweise und Workflows betrachtet, weniger wie gut bzw. schlecht es mit Git selbst funktioniert. Darum geht es nun in den folgenden Abschnitten.

Performance

Das Git von Haus aus nicht wirklich für große Repositorys gemacht ist, ist vielen mittlerweile hinlänglich bekannt, da alle Versionen aller Dateien im Standard im Repository enthalten sind. Je mehr Dateien und Verzeichnisse sind, desto eher wird die Performance leiden. Dies ist nicht nur daran geschuldet, dass das Repository sehr groß (in Gigabyte gemessen) wird, sondern auch, dass nicht effizient mit mehreren Millionen von Dateien umgehen kann.

Wie sich das ganze Auswirken kann, zeigt ein Konferenz-Vortrag von Microsoft: „Scaling Git at Microsoft“. Dort wird erzählt welche Probleme Microsoft hatte, um das Windows Repository auf Git zu migrieren. Es resultierte in ein 270GB großes Repository, wo das Klonen 12h gedauert hat, ein git checkout 3h, das Ausführen von git status auch immerhin 8 Minuten und Ausführen von git commit dauerte auch schon mal eben 30 Minuten. Alles wohlgemerkt auf SSDs. Aus technischer Sicht ist das ein sehr spannender Vortrag, bei dem ich erst richtig verstanden hab, was für Probleme Git bei großen Repositorys hat.

Allerdings muss man sowieso hervorheben, dass wohl die wenigsten Projekte wirklich so große Repositorys haben werden.

Git LFS

Ein angesprochenes Problem von Git ist wie zuvor erwähnt die Handhabung von Binärdateien. Dazu gibt es Git LFS, womit sich Binärdateien außerhalb des eigentlichen Git-Repositorys von Git speichern und verwalten lassen. Der einzige Zweck ist wirklich nur die Handhabung von Binärdateien und weniger von generellen großen Repositorys.

In einem separaten Blogpost habe ich das Thema Git LFS näher beleuchtet.

git submodule und git subtree

Wenn man mit mehreren Repositorys arbeitet, gibt es verschiedene Möglichkeiten die Abhängigkeiten mit hereinzuziehen. Wenn diese Abhängigkeiten einfach über den Paketmanager der verwendeten Programmiersprache verwendet werden kann, ist es am einfachsten. Wenn man allerdings den Code einer Abhängigkeit direkt in das Hauptprojekt einbinden muss, dann gibt es die Möglichkeit mit Submodulen zu arbeiten. Die eher unbekanntere Methode ist Subtree zu nutzen.

Beide Varianten haben Vor- und Nachteile. Die Handhabung von git submodule ist eher aufwändig und mühselig. Es müssen relativ viele Schritte durchgeführt werden, damit alles ordnungsgemäß funktioniert. Mit git subtree kann man hingegen das Zurückführen der Änderung gut und gerne mal vergessen, ist dafür aber angenehmer zu nutzen.

Auch auf diese beiden Befehle gehe in einem weiteren Blogpost näher darauf ein.

git sparse-checkout

Sowohl git submodule als auch git subtree waren zwei Funktionen, die eher nicht für Monorepos relevant sind. Was hingegen relevant ist, ist die Möglichkeit nur mit einem Teil des Repositorys arbeiten zu können. Ein gängiges git clone lädt schließlich das komplette Repository herunter, was bei einem echten Monorepo eher ungünstig ist, da man dann auch allen Code lokal vorliegen hat, den man gar nicht braucht. Mit einem Sparse-Checkout lässt sich ein Klonen und Arbeiten mit einzelnen Unterverzeichnissen bewerkstelligen.

Das GitHub-Blog hat einen ausführlichen Blogpost veröffentlicht in dem genauer beschrieben wird, wie man mit git sparse-checkout arbeiten kann, damit das Arbeiten mit einem Monorepo überhaupt erst halbwegs ordentlich möglich ist.

Fazit

Wenn ihr nun diesen Blogpost gelesen habt, habt ihr vielleicht das Gefühl, dass die Vorteile den Nachteilen von Monorepos überwiegen und dies auch meine Einstellung ist. Tatsächlich ist diese Frage gar nicht so einfach zu beantworten.

Prinzipiell bin ich kein großer Fan von Monorepos. Die meisten Firmen dürften viel zu klein sein, um überhaupt die Vorteile von Monorepo-Ansätzen profitieren zu können. Die Nachteile überwiegen da häufig, da eher speziellere Tools für das Bauen und Verwalten der ganzen Toolchain gebraucht wird, die zu meist auch komplexer sind. Und ich meine hier ganz bewusst echte Monorepos und nicht Multi-Projekt-Repositorys. Die sind nicht ganz so komplex und umspannen in der Regel nicht völlig verschiedene Teams, Abteilungen die nichts miteinander zu tun haben.

Bei ganz großen Firmen sieht das natürlich anders aus, aber sehr viele ähnlich große Firmen die vergleichbar mit Microsoft, Google oder Twitter sind, gibt es dann doch eher seltener. Und die haben auch genug Personal, um spezielles eigenes Tooling zu entwickeln.

Was man hingegen nicht verleugnen sollte, ist der Fakt, dass an Git aktuell vor allem Verbesserung bei der Handhabung von großen Repositorys sichtbar ist. Da dürften noch viele weitere Neuigkeiten mit einfließen, so wie git sparse-checkout ein relativ neues Feature ist.

Unabhängig davon: Das Thema Monorepos haben Dirk und ich in unserem Podcast TILpod in Folge TIL006 besprochen mit einer etwas anderen Art und Weise.