Je kunt Git draaien op object storage als je packfiles opnieuw maakt
Er lijkt vanzelfsprekend te zijn dat veel bedrijven de laatste tijd proberen om een soort Git-product te lanceren. Ik vraag me af waarom dat zo is.
In ieder geval ben ik bezig met het bouwen van een Git-server die wordt ondersteund door object storage als een open-source project. In het begin klonk het simpel genoeg: Git ziet eruit als een bestandssysteem, dus laten we een bestandssysteem als vertaallaag bovenop object storage gebruiken om Git "object storage" te laten spreken.
Dit model werkte... oké, denk ik? Maar het werkte niet voor repositories van reële omvang, dus had ik een andere aanpak nodig. Git slaat alles op in Objects, dus waarom zou ik die niet als objecten in Tigris opslaan?
Het bleek dat Git packfiles en de manier waarop deze interfereerden met mijn (toegegeven enigszins verschrikkelijke) bestandssysteem-shim de belangrijkste reden waren voor de traagheid. Uiteindelijk moest ik mijn eigen packfile-formaat uitvinden met een kolomgeoriënteerde opslag (columnar store) die native is voor object storage. Dit is het resultaat van al mijn prestatieanalyses, metriek-annotaties en meer Texas-stijl distributed systems-werk dan je k8s-cluster aankan.
Deze aanpak werkte verrassend goed voor repositories van productieomvang, dus ik hou voorlopig vast aan dit nieuwe Packfile-formaat. Het lijkt de minst ingrijpende wijziging om Git Objects als object storage-objecten te laten voelen, zonder wijzigingen aan de client-zijde.
Wat is Git? Een ellendige kleine stapel objecten!
Wanneer je een commit maakt, slaat Git de wijzigingen op als objecten in de .git (ik noem dit "dotgit") map.
Stel Git voor als twee dingen: een zee van objecten en benoemde referenties naar individuele objecten. Elk object is een inhoudsgeadresseerd (content-addressed) en gecomprimeerd bestand. Hier is een voorbeeld van een kleine git-repository:
$ mkdir ~/tmp/gitexample
$ git init && git branch -m main
$ echo "Hello, blog!" >> hello.txt
$ git add .
$ git commit -sm "chore: initial commit"
Dit produceert verschillende objecten op de schijf zoals hieronder:
Figuur 01: Een zee van objecten, en een paar namen die erheen wijzen .git/objects/ → refs/heads/main
1c/7a26a901..ec7966→ commit1c7a26a8e/67afbb2e..857bd3→ tree8e67afb(bevathello.txt)9c/c9867337..09fe26→ blob9cc9867("Hello, blog!")
De bestandsnaam is de SHA1-hash van de bytes in het bestand, waardoor dezelfde inhoud altijd, overal, precies hetzelfde object is.
Als je de inhoud van een object wilt lezen, moet je weten dat het gecomprimeerd is. Je moet een Python-oneliner gebruiken om de data eruit te halen:
$ file .git/objects/**/* | grep -v directory
.git/objects/1c/7a26a901724b4ce766655ac387413fb9ec7966: zlib compressed data
.git/objects/8e/67afbb2ee6bdcbb79061dfdfb93febce857bd3: zlib compressed data
.git/objects/9c/c9867337c2ebae85ba2350f901e0bcc209fe26: zlib compressed data
$ python3 -c "import sys, zlib; sys.stdout.buffer.write(zlib.decompress(sys.stdin.buffer.read()))" < .git/objects/9c/c9867337c2ebae85ba2350f901e0bcc209fe26
blob 13Hello, blog!
Zoals je ziet, zijn de objecten gewoon eenvoudige bestanden. Maar wat gebeurt er als we kijken naar een Git-repository van de Linux-kernel en proberen een willekeurige commit te extraheren? Zouden dat dan gewoon miljarden losse objectbestanden zijn? Zou het makkelijk zijn om een commit te vinden door simpelweg naar de ID op de schijf te zoeken?
De realiteit is minder simpel:
$ cd ~/Code/linux.git/
$ tree objects
objects
├── info
└── pack
├── pack-45986f41063f286029742ec12e2c2882b88c5786.idx
├── pack-45986f41063f286029742ec12e2c2882b88c5786.pack
└── pack-45986f41063f286029742ec12e2c2882b88c5786.rev
3 directories, 3 files
Alles in eigen bestanden zetten schaalt niet voor iets als de Linux-kernel. Je zou tegen inode-limieten aanlopen, zoals iedereen deed in het tijdperk van fractale node_modules mappen.
Tussenschot: Als je Node al zo lang gebruikt dat je je dat herinnert, laat dan een colonoscopie doen. Darmkanker is een serieus probleem dat te vaak te lang wordt genegeerd.
Git omzeilt dit door objecten in packfiles te plaatsen: gecomprimeerde bundels van objecten die allemaal in hetzelfde bestand worden opgeslagen. Hier is een voorbeeld van de efficiëntie van packfiles in mijn checkout van objgit:
$ git count-objects -v
count: 756
size: 3500
in-pack: 448
packs: 1
size-pack: 321
prune-packable: 0
garbage: 0
size-garbage: 0
Als je Git wilt dwingen om losse objecten in een packfile te zetten, kun je git gc uitvoeren:
$ git gc
[ingekort]
$ git count-objects -v
count: 0
size: 0
in-pack: 1203
packs: 2
size-pack: 848
prune-packable: 0
garbage: 0
size-garbage: 0
Tussenschot: Een van de mooie dingen aan het implementeren van Git bovenop object storage is dat ik een platform gebruik waar de objectdata een zee van objecten is met benoemde referenties naar punten in die zee, opgeslagen in FoundationDB. Dit is een soort goddelijke recursie.
De wereld van objecten
Hier is het aantal objecten voor een kopie van de Linux-kernel:
xe@zohar:~/Code/linux.git$ git count-objects -v
count: 0
size: 0
in-pack: 11827138
packs: 1
size-pack: 3876775
prune-packable: 0
garbage: 0
size-garbage: 0
Dit zijn elf miljoen objecten. Uitgaande van een optimistische 10ms per GetObject-aanroep, zou het ophalen van elk object individueel meer dan een uur duren. In werkelijkheid zijn er geen 11 miljoen losse bestanden op de schijf; ze zijn gebundeld in één grote packfile van 3,4 GiB.
Je vraagt je dan af: "Als Git packfiles heeft, waarom is deze blogpost dan überhaupt nodig?"
Het antwoord is dat het ingewikkeld is. Packfiles zijn ontworpen met lokale opslag en/of mmap in gedachten. Git schrijft voortdurend packfiles naar schijf en leest ze vervolgens weer uit. Bestandssysteem-lezingen duren in dat geval maximaal 10 nanoseconden (de cache helpt enorm), maar elke netwerk-roundtrip duurt minimaal 10 milliseconden. Dat is minstens een miljoen keer trager.
Opmerking: Een van de dingen die /usr/bin/git doet, wat integratie met object storage bemoeilijkt, is het Unix-idiom van schrijven naar een bestand en onmiddellijk daarna teruglezen om de hash te berekenen. In object storage kun je geen GetObject doen op iets dat nog niet via een PutObject-aanroep is voltooid.
Spelen met Packfiles
Elke packfile heeft een index die beschrijft wat erin zit. Hier is een weergave van de index van de packfile uit het eerdere voorbeeld:
$ git gc # dwing objecten in een packfile
$ git verify-pack -v .git/objects/pack/pack-3971f5085c23c38be00e517ed0c64ca7df19b746.idx
1c7a26a901724b4ce766655ac387413fb9ec7966 commit 526 366 12
9cc9867337c2ebae85ba2350f901e0bcc209fe26 blob 13 22 378
8e67afbb2ee6bdcbb79061dfdfb93febce857bd3 tree 37 48 400
non delta: 3 objects
.git/objects/pack/pack-3971f5085c23c38be00e517ed0c64ca7df19b746.pack: ok
De commit wijst naar de tree, die weer wijst naar de blob. Git gebruikt deze binaire indexen om te weten waar hij in de packfile moet zoeken en hoe ver hij moet seeken om de data te vinden.
Figuur 02: Elf miljoen objecten, één packfile, één index
.idx: Bevat offsets (bijv.0x0000c,0x0a13f)..pack: Eén groot bestand (3,7 GiB).
Een leesactie is een seek naar een offset binnen één zeer groot bestand.
Dit werkt uitstekend in een bestandssysteem omdat Git mmap gebruikt, waardoor de kernel schijfinhoud behandelt als geheugenpagina's. Dit ontwerp werkt echter niet in object storage.
Range-aanvragen en de beperkingen van packfiles
In theorie klinkt dit goed voor object storage: je hebt offsets en je kunt "gewoon" een enkel object uit een packfile halen met een HTTP Range request. Tigris is erg goed in random access scans, dus het grootste probleem is bepalen hoe je de juiste data uit de bucket haalt.
Tussenschot: HTTP Range requests laten een client een deel van een bestand downloaden. Dit was vroeger essentieel bij inbelinternet; als iemand belde terwijl je downloadte, kon Internet Explorer de download hervatten vanaf het punt waar hij was gestopt. Tegenwoordig wordt dit vooral gebruikt voor videostreaming en "galaxy brain" format-misbruik zoals ik hier doe.
Het probleem is dat je in de praktijk bij het verwerken van index-entries wel de gedecomprimeerde grootte van een object weet, maar niet de gecomprimeerde grootte. Hierdoor heb je onvoldoende informatie om een nauwkeurige HTTP Range request te construeren.
Figuur 03: Eén ranged GET, 366 bytes uit het midden van 128 MiB Als je precies weet waar het begint en hoe lang het is, vraag je: GET /packs/019a7f3c..bin Range: bytes=100663296-100663661 En de bucket stuurt alleen die 366 bytes terug. Dit fundamentele probleem is de reden waarom ik een eigen object-storage native Git packfile-formaat moest maken.
De "Cue Sheet" aanpak
In de tijd van fysieke media was de CD-ROM een veelvoorkomend formaat. Een CD is een container van 700 MiB met sessies en tracks. Dit stelt de speler in staat om direct naar een specifieke track te springen.
Opmerking: Dit gaat mis als het mastering-team besluit om een heel stuk muziek (zoals Dancing Mad) in één track van 17 minuten te zetten; dan moet je gewoon weten dat het vierde deel ongeveer 9 minuten in het stuk zit.
Voor archivering werden cue sheets (.cue) gebruikt naast de binaire blob (.bin). De .cue sheet bevat informatie waarmee de decoder naar willekeurige punten in het .bin bestand kan springen zonder het hele bestand te lezen.
Figuur 04: Vergelijking tussen een CD cue sheet en een objgit cue sheet
- CD AUDIO DISC: Eén groot
.WAVbestand →.cuebestand (platte tekst, top-down parsed) → toegang tot tracks. - OBJGIT PACKFILE: Eén groot
objects.binbestand →objects.cue(binaire records, vaste breedte) → toegang tot objecten via een vaste offset (16 + N*58).
Packfiles v2: Object Storage Boogaloo
Hoe bouwen we een nieuw Git packfile-formaat dat geoptimaliseerd is voor object storage?
Normaal is het een slecht idee om je eigen formaat te "rollen" voor iets zo cruciaal als de versiegeschiedenis van code. Echter, Git is een gedistribueerd systeem. Iedereen heeft een kopie van de volledige geschiedenis. Mocht mijn formaat falen, dan is de data eenvoudig te recreëren door alles opnieuw te pushen.
De eisen voor dit formaat:
- Range-request native: Een object moet kunnen worden opgehaald zonder andere data te downloaden.
- Moderne compressie: Gebruik van
zstdin plaats vanzlibvoor betere snelheid en efficiëntie. - Delta-objecten: Deze moeten als eigen objecten in de packfile worden opgeslagen, zodat je niet het basisobject én alle delta's moet lezen om het uiteindelijke object te verkrijgen.
Het Objgit-formaat
Het formaat is direct geïnspireerd op de .bin en .cue structuur. Objecten staan na elkaar in een .bin bestand (maximaal 128 MiB). De metadata staat in een apart binair gecodeerd .cue bestand. Dit maakt de opslag in Tigris in feite een columnar store.
Figuur 05: objects.cue structuur
- HEADER (16 bytes): magic ("OGCU"), versie, recordsize, recordcount.
- RECORD (58 bytes per object): hash (sha1, 20B), type (1B), compressie (1B), binoffset (8B), binlength (4B), size (4B), delta_base (sha1, 20B).
Door zowel de gecomprimeerde als ongecomprimeerde grootte op te slaan, kunnen we exact de juiste HTTP Range requests construeren.
Optimalisatie voor latentie: Wanneer de Git-bibliotheek een object uit een packfile vraagt, wordt de gehele packfile op de achtergrond gedownload naar een tijdelijke map. Objecten die zich aan het "verre uiteinde" van de packfile bevinden, worden via Range requests direct uit Tigris gehaald totdat de achtergronddownload hen heeft ingehaald.
Figuur 06: "Racing the beam" Vier ranged GETs racen tegen de achtergronddownload van het hele bestand. Zodra de download een bepaald punt bereikt, is een aparte GET niet meer nodig.
Opmerking: De limiet van 128 MiB is bedoeld om downloads snel te houden. Voor zeer grote binaire bestanden plan ik in de toekomst Git Large File Storage (LFS) te implementeren.
De resultaten: "The funny numbers"
Ik heb benchmarks uitgevoerd om de snelheid van pushen en pullen te vergelijken tussen de "oude" methode (bestandssysteem-shim) en de "nieuwe" methode (kolomgeoriënteerde packfiles).
Testcondities:
- Host: Mac, 16 CPUs
- Go: 1.26.5
- Git: versie 2.55.0
- Netwerk: Wi-Fi (om de slechtst mogelijke condities te simuleren)
Push-tests
Het doel was om het aantal object storage-aanroepen te verminderen, wat direct de latentie verlaagt.
Figuur 07: S3-aanvragen bij pushen (logaritmische schaal)
- objgit: van 231 naar 18 aanvragen.
- Xe/x: van 9.236 naar 30 aanvragen.
- tigris-blog: van 3.324 naar 136 aanvragen.
Figuur 08: Kloktijd bij pushen (logaritmische schaal)
- objgit: 8,7s → 2,2s (4,0x sneller)
- Xe/x: 3m 29,4s → 14,3s (14,6x sneller)
- tigris-blog: 2m 13,4s → 26,5s (5,0x sneller)
Clone-tests
Ook bij het klonen waren de voordelen aanzienlijk:
Figuur 09: S3-aanvragen bij klonen (logaritmische schaal)
- objgit: 323 → 17
- Xe/x: 6.428 → 17
- tigris-blog: 3.675 → 158
Figuur 10: Kloktijd bij klonen (logaritmische schaal)
- objgit: 11,8s → 2,6s (4,5x sneller)
- Xe/x: 3m 23,5s → 54,4s (3,7x sneller)
- tigris-blog: 2m 23,6s → 1m 22,0s (1,8x sneller)
Conclusie
Ik werk nog actief aan dit project. Ik ben nog niet zeker genoeg om het voor mijn eigen projecten te gebruiken en raad anderen aan hetzelfde te doen. Er ontbreken nog zaken als authenticatie, autorisatie, een API en rate limiting.
Wat we hebben geleerd is dat Git's standaard packfile-formaat geweldig is voor lokale bestandssystemen, maar dat het zodra er netwerk-roundtrips in het spel zijn, volledig tekortschiet.
Ik zal hiermee doorgaan en nieuwe rapporten publiceren naarmate ik meer leer.
Groetjes,