Push deux-temps : accélérer la migration Git LFS des grands dépôts

git gitlab

Pas intéressé par les explications ? Ticket express pour le tl;pl 🚀

J’ai récemment eu à migrer plusieurs dépôts Git d’une instance GitLab à une autre. La nouvelle instance avait des contraintes plus strictes, notamment une taille maximale de 1 Go, et un dépôt en particulier n’était pas dans les clous, avec une taille totale bare de 1,5 Go (du -sh .) pour 700K objets Git (git rev-list --objects --all | wc -l) sur un git clone --mirror tout neuf.

Deux manières principales de réduire la taille d’un dépôt :

  • Filtrer l’historique Git et élaguer les objets indésirables (fichiers commit par erreur, vieux objets qui ne seront plus jamais nécessaires, etc.).
  • Migrer les grands blobs vers Git LFS (ne compteront plus dans la taille des objets Git, car sur stockage externe).

Au moment de la rédaction, les meilleurs outils pour ce faire sont :

Note : si vous cherchez un peu, vous trouverez probablement des recommandations pour BFG Repo-Cleaner. Je recommande de ne pas l’utiliser, cf. ci-dessous pour plus de détails.

Les deux méthodes ont été utilisées pour réduire la taille de ce dépôt spécifique. Je vais rapidement résumer les deux étapes pour me concentrer sur un problème où le premier git push après une migration LFS sur un grand dépôt peut prendre plusieurs heures voire jours, apparemment bloqué lors du téléversement des objets LFS.

Filtrage de l’historique

git-filter-repo --analyze # identification des candidats au filtrage
git-filter-repo --strip-blobs-bigger-than 5M --path some-existing-dir/ --path some-deleted-dir/ --invert-paths

Après analyse, j’ai déterminé que tous les blobs de plus de 5 Mo pouvaient être filtrés sans impact, ainsi qu’un certain nombre de répertoires (aussi bien existants que supprimés). Le dépôt faisait maintenant 900 Mo. Pas mal ! Mais encore un peu trop près des 1 Go à mon goût.

Migration Git LFS

git lfs info --top=25 # identification des candidats LFS
git lfs migrate import --everything --include="*.ext1,*.ext2"

Après analyse, j’ai sélectionné un certain nombre d’extensions de fichiers binaires à convertir en LFS. Près de 2600 objets ont été convertis (find lfs/objects/ -type f | wc -l) pour un total de 800 Mo. Nickel ! Les objets standards Git ne représentaient plus qu’environ 100 Mo de taille de dépôt, le reste correspondant aux objets LFS.

Note : s’il s’agit de votre première migration LFS, vous voudrez peut-être lire ci-dessous à propos de la sensitivité à la casse des filtres Git et le nettoyage après une migration LFS.

Le problème

L’étape suivante consistait à pousser l’historique filtré et migré sur LFS vers un dépôt distant (remote). Ça partait bien au début :

$ git push
Uploading LFS objects: 100% (2312/2312), 765 MB | 4.6 MB/s

…mais ensuite c’est resté bloqué là, à « 100% ».

Note : ce comportement est indépendant de l’outil utilisé : je l’ai reproduit aussi bien avec BFG (déprécié) que git lfs migrate. Le problème est lié aux blobs LFS eux-mêmes, et non aux outils utilisés pour les obtenir.

Analyse

ps aux montrait que git-lfs tournait à fond, et en observant attentivement, je voyais effectivement le nombre d’objets monter de temps en temps (i.e. passage à 2312/2315 puis 2315/2315 après quelques secondes). À ce moment, mon hypothèse était que l’opération n’était pas bloquée, mais parcourait bien les blobs LFS, juste très lentement.

Google n’a pas été d’une grande aide : la plupart des gens avec des problèmes à l’étape Uploading semblaient souffrir principalement de goulots d’étranglement du réseau, soit à cause de contraintes de bande passante ou de tailles de fichiers, mais ce n’était pas mon cas.

J’ai ensuite activé l’affichage de débogage et observé un phénomène intéressant :

$ GIT_TRACE=1 git push
Uploading LFS objects: 100% (2312/2312), 765 MB | 4.6 MB/s
16:41:13.318312 trace git-lfs: tq: running as batched queue, batch size of 100
16:41:13.318312 trace git-lfs: run_command: git rev-list --stdin --objects --not --remotes=<redacted> --
... [repeating]

Une inspection rapide du code révèle que cette entrée apparaît lors du traitement de la file de transfert, et une recherche Google pour cette entrée spécifique donne des résultats plus substantiels (#3707, #3915), ce qui semble confirmer l’hypothèse :

[…] we’re traversing the entire history to see what objects need to be pushed. If you have a large repository with a large history, that can be expensive. It’s unfortunately unavoidable, since we need to know which objects have to be pushed, and the only way to know that is to read all of the pointer files from all of the unpushed history.

When we’re pushing a repository that’s newly rewritten, we’re going to traverse every blob that exists that’s less than 1024 bytes so that we can determine if an LFS object is in that object.

Ceci explique pourquoi git push prend très longtemps pour traiter les objets LFS sur ce dépôt : il doit parcourir l’ensemble des 700K objets Git, 100 par 100, pour déterminer s’il s’agit ou non d’objets LFS.

Note : le même problème apparaît si on essaie de pousser les objets LFS avec git lfs push origin --all, ce qui est cohérent car c’est essentiellement ce que fait git push grâce au hook pre-push installé via git lfs install. ÉDITH : va peut-être changer dans une future version Git LFS, voir ci-dessous.

Improvisation d’un push deux-temps

J’ai essayé de laisser git push faire son œuvre, mais après 24 heures j’en ai conclu que ce n’était pas adéquat dans notre cas, en particulier car il était clair qu’il était loin d’en avoir fini : le décompte des objets était autour de 2500, alors qu’il avait commencé à 2300 et que je savais qu’il y avait 2600 objets LFS à téléverser au total (find lfs/objects/ -type f | wc -l).

Ce dernier élément fut la clef de la solution : comme je pouvais lister tous les objets LFS, je me suis demandé si je pouvais pousser en deux temps, un pour les objets standards Git et l’autre pour les objets LFS. Ainsi on éviterait la nécessité de parcourir l’ensemble des blobs.

Mon idée initiale ressemblait à ça :

GIT_LFS_SKIP_PUSH=1 git push # pousser les objets standards Git
git lfs push origin --object-id `find lfs/objects/ -type f -printf "%f "` # pousser les objets LFS

Elle s’appuie sur :

  • GIT_LFS_SKIP_PUSH pour empêcher l’envoi des objets LFS en désactivant sélectivement le hook pre-push de LFS (documentation). Note : variable ajoutée en Git LFS 2.13.0, sur version antérieure on peut utiliser git push --no-verify pour désactiver tous les hooks pre-push.
  • --object-id pour spécifier quels objets envoyer (documentation).

Cependant, j’ai immédiatement été interrompu car l’opération git push était activement refusée par les hooks pre-receive de GitLab à cause des références vers des objets LFS manquants :

$ git push
remote: GitLab: LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".
To <redacted>.git
 ! [remote rejected] master -> master (pre-receive hook declined)
 ... [repeat for all branches]
error: failed to push some refs to '<redacted>.git'

Ce comportement est en réalité normal, comme indiqué dans la documentation. Heureusement, comme le suggère le hook, on peut simplement faire l’inverse et pousser les objets LFS en premier :

git lfs push origin --object-id `find lfs/objects/ -type f -printf "%f "` # pousser les objets LFS
GIT_LFS_SKIP_PUSH=1 git push # pousser les objets standards Git

Va aussi vite que la vitesse du réseau le permet. Aucun parcours d’historique nécessaire 🎉

On peut alors essayer un git push standard à nouveau, et recevoir Everything up-to-date comme confirmation que tout s’est bien passé. Et effectivement, les clonages et autres opérations standards Git sur le dépôt migré fonctionnement parfaitement.

Suite

Quant à savoir pourquoi il ne s’agit du comportement par défaut, je n’en ai aucune idée. J’ai ouvert une issue sur GitHub pour poser la question.

ÉDITH : Ce n’est pas le comportement par défaut car ce serait inefficient dans la plupart des cas en dehors d’une migration intégrale, e.g. quand il y a seulement besoin de réécrire quelques objets de l’historique récent à la suite d’une erreur de push :

The reason that your solution is efficient in your case is because you know that the remote repository has no LFS objects and you’ll need to upload them all. However, when we use the pre-push hook to do an upload, we have no way of knowing what refs may already exist on the remote side. All we know is that none of the refs being pushed exist yet. This case, generally, is indistinguishable from pushing a small number of new branches with only a few commits.

In the case of an LFS migration into an existing repository, you just look like you’re updating many refs at once, and that could be just a single commit on each one. We don’t know until we start to look at history.

In the common case, walking history is much, much faster than pushing all objects, especially on slow connections, because we’ll traverse a relatively small number of commits.

Note intéressante : dans une version future, git lfs push origin --all va peut-être évoluer pour éviter de parcourir l’historique et se comporter comme le contournement improvisé.

tl;pl

Le premier git push après une migration LFS sur un grand dépôt peut prendre plusieurs heures voire jours, apparemment bloqué lors du téléversement des objets LFS.

Il est possible de découper manuellement l’opération de push en deux temps, un pour les objets standards Git et l’autre pour les objets LFS :

git lfs push origin --object-id `find lfs/objects/ -type f -printf "%f "` # pousser les objets LFS
GIT_LFS_SKIP_PUSH=1 git push # pousser les objets standards Git

Note : variable GIT_LFS_SKIP_PUSH ajoutée en Git LFS 2.13.0, sur version antérieure on peut utiliser git push --no-verify pour désactiver tous les hooks pre-push.

En fonction de votre fournisseur d’hébergement Git et ses hooks de vérification, vous devrez peut-être inverser l’ordre de push (ci-dessus : compatible GitLab).


Annexes

BFG Repo-Cleaner

Vous pouvez trouver BFG Repo-Cleaner mentionné dans diverses ressources, à la fois pour le filtrage d’historique et la migration LFS. Au moment de la rédaction de cet article, il est en particulier recommandé par la documentation GitLab pour les migrations LFS. Toutefois je déconseille son utilisation car il semble ne plus convenir à un usage moderne :

  • « Soft-déprécié » pour le filtrage d’historique lorsque la commande git historique filter-branch fut dépréciée en faveur de git-filter-repo, cette dernière étant une réimplémentation de BFG (9df53c5d) et une alternative strictement supérieure en termes de fonctionnalités (notamment le support des refs/replace).
  • « Soft-déprécié » pour la migration LFS, git lfs ayant son propre processus de migration avec git lfs migrate.
  • Ne convertit pas correctement en profondeur vers LFS (#349).
  • Gère les .gitattributes LFS d’une manière peu pratique (#116).

De façon anecdotique, je peux attester avoir rencontré ces deux problèmes en suivant la documentation GitLab et en testant une migration LFS avec BFG sur mon dépôt.

Sensitivité à la casse des filtres Git

Vous êtes probablement déjà au courant si vous avez manipulé des .gitattributes auparavant, mais les filtres Git sont sensibles à la casse. Lors de l’enregistrement d’extensions de fichiers sur LFS, il est judicieux d’utiliser des filtres glob pour le faire sans tenir compte de la casse :

git lfs track "*.png"          # n'enregistrera que les fichiers en ".png"
git lfs track "*.[pP][nN][gG]" # enregistre les fichiers en ".png" et ".PNG", ainsi que tout mix

Idem pour git lfs migrate :

git lfs migrate import --everything --include="*.[pP][nN][gG]"

Nettoyage après une migration LFS

git-filter-repo nettoie automatiquement derrière lui, mais pas git lfs migrate. Il est judicieux de forcer un nettoyage avant de procéder aux opérations de push post-migration :

git reflog expire --expire=now --all
git gc --prune=now --aggressive

Article précédent Article suivant