Evitare sprechi di banda git-lfs con GitHub e CircleCI

Quando si lavora col software ci sono buone probabilità di usare un sistema di versionamento, un sistema di continuous integration (o CI), e che il progetto richieda un qualche tipo di asset binario. Gli asset non sono solitamente archiviati nel repository insieme col codice per molteplici ragioni che vanno dai requisiti di banda per clonare un repository alle performance di git in presenza di file binari, specie se modificati spesso. git-lfs è però un ottimo strumento che permette proprio di committare file binari minimizzando i problemi sopracitati e con l’obiettivo di ridurre la necessità d’infrastruttura aggiuntiva di cui ci sarebbe bisogno per gestire questo tipo di file esternamente al repository.

Questo articolo spiegherà come evitare di sprecare banda quando si usano repository che fanno uso di git-lfs che si trovano su GitHub e usando come sistema di CI CircleCI.

git-che?

La raison d’être di git-lfs è permettere un flusso di lavoro identico a quello che si ha con git consentendo però di lavorare con file binari di grandi dimensioni, senza andare ad impattare sulla velocità delle operazioni git più comuni o sulla dimensione del repository.

Per farlo, git-lfs ha bisogno di essere configurato (per ogni repository) con una lista di file di cui deve tener traccia. I file tracciati con git-lfs non sono memorizzati direttamente nel repository git; ivi si trovano invece i cosiddetti pointer files, file puntatore. Un file puntatore è un file di piccole dimensioni che contiene alcuni metadati riguardanti il file vero e proprio. Questo significa che git vedrà solo i file puntatore e le sue operazioni non saranno rallentate dalla necessità di operare su file di grandi dimensioni. Una negatività di questo approccio è il fatto che git-lfs abbisogna di supporto sia client che server per permettere di caricare e scaricare i file tracciati.

Se il server non supporta git-lfs, gli asset non potranno essere caricati; se un collaboratore non ha git-lfs installato sul priorio client non gli sarà invece possibile scaricare gli asset (anche se questi si trovano sul server): in tal caso verranno scaricati soltanto i file puntatore. Fortunatamente GitHub supporta git-lfs in maniera nativa. Dunque ci sarà solo bisogno di comunicare a git-lfs quali file tracciare e assicurarsi che tutti i collaboratori abbiano installato git-lfs sulle proprie macchine, dopodiché il classico flusso di lavoro git funzionerà come sempre, almeno finché non si aggiunge la continuous integration.

La continuous integration

La continuous integration è incredibilmente utile per automatizzare operazioni quali il lanciare i test ad ogni push per confermare che il codice sia corretto, automatizzare il sistema di compilazione e così via. Ovviamente per poter fare queste cose il sistema di CI avrà bisogno di avere accesso al codice sorgente.

Ne consegue logicamente che se un sistema di CI deve essere in grado di scaricare il codice — inclusi gli asset — dovrà supportare git-lfs. Fortunatamente — di nuovo — CircleCI supporta git-lfs in maniera nativa, quindi non c’è tecnicamente bisogno di fare niente; la configurazione predefinita tuttavia non si pone il problema di evitare sprechi di banda, che invece è il nostro obiettivo.

Il problema della banda

Per quanto concerne git-lfs, GitHub fornisce 1GB di spazio di archiviazione e 1GB di banda mensile gratuiti, con la possibilità di acquistare spazio e banda aggiuntivi tramite dei data pack. Ogni data pack fornisce 50GB di spazio di archiviazione e 50GB di banda.

Dunque se il sistema di CI usato si attiva per ogni push sul repository, che solitamente è proprio come sono configurati questi sistemi, e necessita di scaricare molti asset ogni volta — che è specialmente vero se viene fatto il vendoring delle dipendenze — allora si vedranno esaurire i 50GB di banda molto in fretta. Questo perché CircleCI deve effettuare un clone del repository per garantire un ambiente pulito nel quale compilare il codice o lanciare i test. Questo non è invece un problema durante il normale ciclo di sviluppo, visto che la maggior parte degli asset non dovrebbero cambiare molto spesso.

Per evitare di sprecare banda si può usare il sistema di caching di CircleCI e una interessante funzionalità esposta da git-lfs.

Passo 1: clonare solo i file puntatore

Esportando la variabile d’ambiente GIT_LFS_SKIP_SMUDGE=1 git-lfs cambierà il funzionamento del clone in modo tale da scaricare solo i file puntatore piuttosto che gli asset. Questo ci permette di evitare di sprecare la banda che GitHub mette a disposizione per le operazioni git-lfs.

Quindi una configurazione minimale per CircleCI a questo punto potrebbe essere la seguente:

version: 2

jobs:
  pull-code:
    docker:
      - image: some/image

    environment:
      - GIT_LFS_SKIP_SMUDGE: 1

    steps:
      - checkout

In questa configurazione il job che si occupa di scaricare il codice si chiama pull-code, ma può ovviamente essere chiamato in qualsiasi altro modo.

checkout è uno step built-in di CircleCI che fondamentalmente si occupa di clonare il repository. L’opzione environment è settata in modo tale che git-lfs scarichi solo i file puntatore come descritto in precedenza. Inutile dire che se gli asset sono necessari a compilare o testare il codice questa configurazione è insufficiente. In tal caso ci sarà bisogno di ottenere gli asset veri e propri tramite git-lfs: il trucco è farlo solo quando ce n’è effettiva necessità in modo da evitare sprechi di banda.

Fortunatamente CircleCI permette di definire delle cache in modo tale da permettere il riutilizzo di file senza bisogno di riscaricarli, ricompilarli, rigenerarli o altro, a meno che non sia necessario.

Passo 2: mettere gli asset in cache

Le cache di CircleCI sono identificate da una chiave univoca e sono immutabili. La chiave è definita dall’utente e CircleCI mette a disposizione delle funzionalità per permettere la creazione di chiavi univoche in maniera semplice.

Un modo per generare una chiave univoca è lasciare che CircleCI calcoli un checksum di un file e lo usi come (parte della) chiave. Questo ha come effetto secondario il fatto che quando il checksum del file cambia anche la chiave cambia, e la cache viene automaticamente invalidata.

Si può creare una cache col comando save_cache e ripristinarla col comando restore_cache.

Considerando tutto quello di cui sopra si può aggiungere quello che segue alla configurazione:

- restore_cache:
    key: v1-my-cache-key-{{ checksum ??? }}

- run:
    command: |
      git lfs pull

- save_cache:
    key: v1-my-cache-key-{{ checksum ??? }}
    paths:
      - .git/lfs

Vediamo l’esempio più nel dettaglio.

I passi sono eseguiti nell’ordine in cui sono scritti, quindi per prima cosa CircleCI proverà a ripristinare cache. Se non viene trovata alcuna cache con la chiave specificata non succede nulla.

Solo ora viene lanciato git lfs pull — che ignorerà la variabile d’ambiente GIT_LFS_SKIP_SMUDGE in quanto esplicitamente richiesta l’operazione di pull — ma non riscaricherà nessuno dei file tracciati che non sia diverso dalla versione presente su origin.

A questo punto viene salvata la cache usando la stessa chiave, specificando i percorsi che contengono i file che dovranno finire nella cache stessa. Fortunatamente git-lfs salva tutti i file tracciati nella cartella .git/lfs, non c’è quindi la necessità di cercarli in giro per il repository.

La prossima volta che CircleCI eseguirà questi passi troverà la cache con la chiave specificata, la ripristinerà scaricandola dai suoi server e a quel punto git lfs pull sarà una noop evitando quindi utilizzo di banda git-lfs.

??? nell’esempio rappresentano un singolo file.

Un’altra cosa: la chiave della cache inizia con v1. Si tratta di un trucchetto che permette, se necessario, di invalidare la cache manualmente semplicemente cambiando v1 in v2.

Il passo mancante: calcolare correttamente la chiave della cache

Ricapitolando: abbiamo un modo per scaricare i file puntatore piuttosto che gli asset per evitare di sprecare banda, e CircleCI permette di creare chiavi univoche per la cache in modo automatico tramite il calcolo di un checksum. Il checksum è calcolato solo per un singolo file e deve cambiare quando cambiano gli asset, in modo che la cache venga invalidata.

A questo punto non ci rimane che trovare un modo per calcolare un checksum che cambi solo quando cambia almeno uno degli asset. Esistono sicuramente molti modi per farlo; una possibilità è quella di usare il comando git lfs ls-files -l, che elenca tutti i file tracciati da git-lfs insieme con il loro identificativo univoco.

git lfs ls-files -l | cut -d' ' -f1 | sort > .assets-id

Questo comando elenca gli identificativi univoci (tipicamente lo sha256) dei file tracciati da git-lfs insieme con il loro percorso, prende solo la lista degli identificativi (cut -d' ' -f1), li ordina e salva il risultato in un file che sarà usato per calcolare parte della chiave della cache.

Da notare che ordinare il risultato non è opzionale, perché ls-files non garantisce nessun tipo di ordinamento. Se due chiamate diverse a ls-files forniscono due risultati ordinati in modo diverso, allora anche il calcolo della chiave della cache fornirà due risultati diversi quand’anche gli asset non fossero cambiati.

Il risultato finale

A questo punto abbiamo un file il cui checksum cambierà se e solo se almeno uno degli asset cambia. Era l’ultimo ingrediente!

La configurazione finale è la seguente:

version: 2

jobs:
  pull-code:
    docker:
      - image: some/image

    environment:
      - GIT_LFS_SKIP_SMUDGE: 1

    steps:
      - checkout

      - run:
          command: |
            git lfs ls-files -l | cut -d' ' -f1 | sort > .assets-id

      - restore_cache:
          key: v1-my-cache-key-{{ checksum ".assets-id" }}

      - run:
          command: |
            git lfs pull

      - save_cache:
          key: v1-my-cache-key-{{ checksum ".assets-id" }}
          paths:
            - .git/lfs

E questo è tutto! Con questa tecnica si può usare un sistema di CI per i repository GitHub che fanno uso di git-lfs senza paura di sprecare banda.