Misschien een betere manier om de releasegeschiedenis van een Git-repo bij te houden
Telkens wanneer een van de applicaties in de repo werd gedeployed naar staging of productie, maakte de deployment-tool een tag-object aan met een naam zoals deze: refs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b
De tag bevatte de naam van de doelomgeving (in dit geval stg voor "staging"), de naam van de applicatie en de klasse hosts waarop deze draaide (www.our-app), de datum en tijd, en de hash van de tree die werd gedeployed. Dit zorgde voor een volledig overzicht van wat er waar en wanneer was gedeployed.
Na verloop van tijd waren er duizenden van deze tag-objecten. Git is goed ontworpen voor het verwerken van duizenden commits, maar niet voor duizenden refs. In die tijd sloeg Git refs op in een bestand genaamd packed-refs, waarin voor elke ref een vermelding stond met de naam van de ref en de ID van het object waarnaar deze verwees. (Zoals bij alles in Git is de werkelijke situatie iets complexer, maar deze details hebben geen invloed op de volgende discussie.)
Elke operatie waarbij een ref-naam moest worden omgezet naar een object, moest door het packed-refs-bestand zoeken. Dit kostte aanzienlijk veel tijd, veel meer dan wenselijk was voor een handeling die iedereen meerdere keren per dag uitvoerde. Dit beïnvloedde niet alleen lokale operaties, maar ook operaties op de Git-server, en het was duidelijk dat dit alleen maar langzamer zou worden. Bovendien raakten de resultaten van commando's zoals git fetch en git branch steeds meer vervuild met de namen van tag-objecten die enkel bedoeld waren om de releasegeschiedenis vast te leggen.
Het leek me redelijk om de deployment-geschiedenis in de repository op te slaan, maar het gebruik van duizenden tag-objecten had duidelijk ernstige nadelen. Naast de prestatieproblemen was de metadata zelf puur gebaseerd op een conventie in het formaat van de tag-naam en was deze niet leesbaar voor Git zelf. Er stond wel een tree-ID in, maar er was niets dat afdwong dat de tag daadwerkelijk verwees naar een commit met die tree.
Ook leken de tag-objecten zelf ongeschikt voor dit doel. Een goede reden om geschiedenisgegevens in de repository op te slaan, is om ze onveranderlijk (immutable) te maken. Maar tags zijn niet onveranderlijk; ze kunnen op elk moment worden verwijderd en er kunnen nieuwe worden ingevoegd met willekeurige namen.
Bovendien is er de vraag die men het meest natuurlijk zou willen beantwoorden aan de hand van de releasegeschiedenis: Wanneer hebben we de app www.our-app voor het laatst naar productie gedeployed?
De enige manier om deze vraag te beantwoorden was om de volledige lijst met tags op te halen, deze te filteren op prod.www.our-app, vervolgens op datum te sorteren en het laatste item te pakken. Of stel dat de meest recente staging-deployment foutief was en we direct wilden terugkeren naar de versie daarvóór; dan hadden we (of het herstelproces) opnieuw een lijst van duizenden tags moeten filteren en sorteren om het voorlaatste item te selecteren.
In 2019 stelde ik een alternatief voor. Dit slaat de deployment-geschiedenis van elke app op als een reeks onbenoemde maar gerelateerde Git-objecten, gebruikmakend van het eigen geschiedenismechanisme van Git. Hierdoor is het eenvoudig, efficiënt en sluit het goed aan bij de standaard Git-commando's.
Ik geloof niet dat het ooit is aangenomen, en het kan zijn dat er nadelen zijn die ik niet heb voorzien, maar het kan nuttig zijn voor anderen in een vergelijkbare situatie.
Een audit- en herstelspoor bijhouden zonder duizenden refs
Momenteel wordt er elke keer dat een nieuwe versie van een app wordt gedeployed een ref aangemaakt: refs/release/stg.www.our-app-2019-09-06T114410.954-0700-treesha-179dab6fd11bee5195f0f8e31cea3edfb86ae23b
Naarmate deze refs toenemen, worden veel operaties in de Git-repo langzamer. We kunnen deze informatie op een manier vastleggen die minstens zo nuttig is, maar de ref-lijst niet vervuilt.
Huidige praktijk
De huidige releaseprocedure werkt als volgt:
- Er wordt een nieuwe release gemaakt bij commit
H. - Er wordt een nieuw tag-object aangemaakt dat naar
Hwijst:
git tag -a refs/release/stg.www.our-app-2019-09-.... H
Elk nieuw tag-object heeft een naam in de ref-database, wat betekent dat er voor elke release een nieuwe naam wordt toegevoegd. De structuur is: één ref-object per release, elk wijzend naar zijn eigen head-commit.
Alternatief voorstel
In dit voorstel wordt elk (omgeving, app) paar gerepresenteerd door een gelinkte lijst van release-commits:
- De kop (head) van de lijst is de meest recente release.
- Eén enkele ref wijst naar de kop van de lijst.
- Oudere releases kunnen worden gevonden door de links terug in de tijd te volgen.
Het belangrijkste punt van dit voorstel is dat er slechts één ref is voor elk (omgeving, app) paar, ongeacht hoeveel releases er zijn gedaan.
De structuur ziet er als volgt uit:
- Een keten van metainfo-commits.
- Elke metainfo-commit heeft de vorige metainfo-commit als eerste ouder (first parent) en de werkelijke release-commit als tweede ouder (second parent).
- De ene ref (die een echt ref-object kan zijn of niet) is benoemd naar bijvoorbeeld
stg.www.our-app. Deze wijst niet naar de werkelijke release-commit, maar naar een commit die metainformatie bevat, zoals de treesha. - De exacte tijd van de release hoeft niet expliciet te worden opgeslagen, omdat dit exact de tijd is waarop de metainfo-commit is aangemaakt.
Omdat de metainfo-commits op exact dezelfde manier aan elkaar zijn gekoppeld als normale Git-commitgeschiedenis, werken de gebruikelijke Git-commando's op een logische manier. In het bijzonder zal het commando git log stg.www.our-app een overzicht van de releasegeschiedenis opleveren.
Technische details
Het maken van zo'n commit met twee ouders is eenvoudig. Stel dat de ref stg.www.our-app wijst naar de huidige metainfo-commit, en we willen een nieuwe commit H releasen:
- Schrijf een nuttige commit-boodschap in
/tmp/message.$$. - Voer het volgende uit:
git commit-tree -p refs/release/stg.www.our-app -p H H^{tree} < /tmp/message.$$
Dit creëert een nieuwe metainfo-commit met twee ouders: de metainfo-commit van de huidige release en de nieuwe commit H.
Het commando git commit-tree maakt de nieuwe metainfo-commit aan en schrijft de SHA naar de standaarduitvoer. Stel dat deze SHA N is: git push origin N:refs/release/stg.www.our-app
De release-ref wijst nu naar de metainfo-commit van de nieuwe release.
Gebruik
Dit is eenvoudiger in gebruik dan het huidige systeem, omdat de commando's van Git specifiek zijn ontworpen om lijsten van commits te beheren die op deze manier zijn gekoppeld.
Je kunt de volledige releasegeschiedenis van een project ophalen met één fetch: git fetch origin refs/release/stg.www.our-app:HISTORY
Daarna zal git log HISTORY de volledige releasegeschiedenis in omgekeerd chronologische volgorde tonen. Alle gebruikelijke git log-opties kunnen worden gebruikt om deze lijst te filteren of te doorzoeken. Bijvoorbeeld, om de laatste release vóór een bepaalde datum te vinden: git log -1 --until=date HISTORY
Voor een chronologische lijst van release-commit SHA's met hun releasedata: git log --reverse --format="%P %cd" HISTORY
Navigatie-overzicht:
- De huidige release-commit is bereikbaar via
HISTORY^2. - De metainfo-commit van n releases geleden is
HISTORY~n. - De release-commit van n releases geleden is
HISTORY~n^2.
Opmerking: Als M een metainfo-commit is die een release beschrijft, dan is M^ de metainfo-commit van de vorige release, en M^{2} is de exacte commit die daadwerkelijk is gereleased. De tree die is gereleased in release M is dus M^{2}^{tree}.
Omdat Git deze organisatie van geschiedenis begrijpt, is het eenvoudig om een specifieke vorige release te lokaliseren voor een rollback. Bij een rollback kunnen we terugkeren naar exact dezelfde commit als voorheen: de nieuwe metainfo-commit en de oude kunnen dezelfde release-commit-object delen.
Het verschil tussen de laatste twee releases kan worden bekeken zonder de exacte data te kennen: git diff HISTORY~1^2 HISTORY^2
De metainfo-commit is een volledige commit met alle Git-metadata, zoals de creatiedatum. In de commit-boodschap kan ongestructureerde tekst worden opgenomen. Men zou kunnen eisen dat de boodschap in TOML-formaat is, of gebruikmaken van git-interpret-trailers voor semi-gestructureerde informatie.
Mocht de ingebouwde commit-structuur onvoldoende zijn, dan heeft de metainfo-commit ook een bijbehorende tree waarin onbeperkt gestructureerde informatie kan worden opgeslagen. In het bovenstaande voorbeeld is deze tree identiek aan die van de release-commit, wat handig is omdat je dan via beide commits de releasedate bestanden kunt inzien. De tree hoeft echter niet hetzelfde te zijn; er kunnen specifieke metainformatiebestanden aan worden toegevoegd of een geheel aparte tree worden gebruikt.
Prestaties
Er is de vraag gesteld over de prestaties van het doorlopen van een gelinkte lijst van commits. Dit is echter exact hetzelfde mechanisme dat Git normaal gebruikt voor geschiedenis. Wanneer we git log uitvoeren, doorloopt Git een gelinkte lijst van commits op precies dezelfde manier als hier voorgesteld. Als een operatie in Git snel is, dan is het deze.
Momenteel doet Git er een paar seconden over om de geschiedenis van 275.000 commits van onze monorepo te doorlopen. Het doorlopen van een vergelijkbaar gestructureerde geschiedenis van enkele honderden release-commits zal geen enkel probleem vormen.
Groetjes,