Accelerare il CI con Docker

4 gennaio 2017

Il team di sviluppo di Driftrock si propone di sviluppare e distribuire nuovi valori e correzioni ai clienti il più rapidamente e frequentemente possibile. Una cosa importante che ci permette di farlo è l'integrazione continua.

Recentemente abbiamo iniziato a sperimentare tempi di test CI piuttosto lunghi, circa 10 minuti. Nel nostro team ci occupiamo della revisione delle richieste di pull e questo significa che ogni volta che abbiamo fatto un piccolo aggiornamento durante la discussione della PR abbiamo dovuto aspettare 10 minuti per poter unire le modifiche o chiedere al revisore di accettare le modifiche e di fatto sbloccarci per fare altro lavoro. Se lo mettiamo nella prospettiva di una giornata lavorativa con 10 richieste di pull, questo rappresenta il 20% del tempo di lavoro (1:40 ore in una giornata lavorativa di 8 ore).

Il problema

Osservando il log dell'output di compilazione, si può notare che il tempo più lungo è richiesto dall'installazione dei pacchetti Gems e Node:

originale-test-times.png

In questo caso si tratta di un'applicazione mista Ruby on Rails e Javascript (Node), che richiede l'installazione di entrambi i pacchetti Gems e NPM. Questa operazione viene eseguita ogni volta, anche se nella maggior parte dei casi i pacchetti non cambiano.

C'è anche un po' di tempo che Snap impiega per inizializzare l'ambiente di compilazione - non possiamo farci nulla. E poi c'è il tempo effettivo dei test, che forse può essere migliorato, ma il tempo di impacchettamento è il problema principale.

Il file Docker

Se non avete familiarità con l'ecosistema Docker, lasciatemi deviare un po' dalla trama principale. In Docker, tutto ciò che si fa, inizia con un'immagine Docker. L'immagine Docker è un'istantanea di un sistema operativo - tutta la struttura del file system, gli strumenti CLI, la shell e i programmi installati in esso insieme a tutte le sue dipendenze - meno il kernel.

Si tratta di un concetto incredibilmente potente che consente di scaricare ed eseguire qualsiasi cosa, dalla shell Ruby al server Postgresql, utilizzando un solo comando. Non c'è bisogno di preoccuparsi di installare tutte le librerie, risolvere i conflitti e così via, come siamo abituati a fare quando eseguiamo il software direttamente sul nostro sistema operativo.

Per creare o costruire un'immagine Docker, Docker utilizza dei file chiamati Dockerfile. Questi file contengono i passi - comandi - che vengono eseguiti all'interno dell'immagine per creare una nuova immagine per il passo successivo e infine creare l'immagine finale a cui si può dare un nome e riutilizzare per eseguire le proprie applicazioni.

FROM ruby:latest

# Rieseguire l'installazione del bundle solo se il Gemfile cambia
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20

# Copiare il resto dei sorgenti
COPY . ./L'esempio precedente crea un'immagine contenente un'applicazione Ruby con installato il bundle Gems basato sul Gemfile.

La parte più bella di Dockerfiles è che ogni passo crea un'immagine intermedia che Docker memorizza e mette in cache automaticamente. Poiché ogni passo è definito in modo deterministico dal suo comando, Docker non ha bisogno di eseguirlo di nuovo finché il passo non cambia.

Per i passaggi di RUN si assume che, finché il testo del comando è lo stesso, il suo output e tutti gli effetti collaterali che produce sono gli stessi. Più interessante è il fatto che per i passaggi COPY legge il contenuto dei file specificati, ne calcola l'impronta digitale hash e, finché questi file non cambiano il loro contenuto, utilizza l'immagine memorizzata nella cache.

Nell'esempio precedente questo comportamento viene sfruttato per eseguire il passo di installazione del bundle solo se il file Gemfile e Gemfile.lock cambiano effettivamente. Se non cambia, si può usare la versione in cache e quindi velocizzare la compilazione. Poiché utilizza l'hash del contenuto del file - non il tempo di modifica del file, non fa alcuna ipotesi sul contenuto, ecc. - invalida sempre in modo affidabile la cache quando è necessario, eliminando la preoccupazione di utilizzare uno stato non valido della cache o altro, che spesso si teme quando si parla di cache.

La soluzione

Snap CI ha recentemente introdotto il supporto beta per Docker nel suo build stack. Conoscendo i vantaggi della cache delle immagini Docker, abbiamo deciso di provare.

Il piano è:

  • Costruire l'immagine Docker contenente i pacchetti Gems e Node e i file sorgente della build corrente usando Dockerfile con un passaggio che copi solo il Gemfile e il package.json prima di eseguire bundle install && npm install
  • Sfruttare la cache, in modo che ogni compilazione aggiunga solo i nuovi file sorgenti, senza rieseguire il bundle se le dipendenze non cambiano.
  • Eseguire i test utilizzando l'immagine costruita

Quindi abbiamo creato questo file Docker e lo usiamo per costruire l'immagine all'interno dello script di compilazione:

FROM ruby:2.3.1

COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install

COPY . /usr/src/app

Quando abbiamo fatto questo, però, abbiamo scoperto che il comando docker build su Snap purtroppo non usa la cache come fa sul computer locale. Esegue sempre e solo tutti i comandi. Forse perché la compilazione viene eseguita ogni volta in un nodo diverso o perché Snap semplicemente pulisce i file Docker prima di ogni esecuzione della compilazione.

Hashing manuale

Se non possiamo usare la cache build nel comando Docker, nulla ci impedisce di imitarla e implementarla da soli. Abbiamo quindi deciso di farlo e di modificare il piano in:

  • Costruire l'immagine di base contenente i pacchetti Gems e Node (senza file sorgente di compilazione) solo una volta
  • Memorizzare l'immagine nel repository delle immagini con l'hash dei file Gemfile e package.json.
  • Provare a scaricare l'immagine nelle build successive
  • Crea una nuova immagine aggiungendo i file sorgenti della build corrente
  • Eseguire i test utilizzando la nuova immagine

Questo è il copione che abbiamo ottenuto:

# hash dei file che possono influenzare le dipendenze
PACKAGE_SHA=$( cat Dockerfile-package-base Gemfile Gemfile.lock package.json | sha256sum | cut -d" " -f1 )

BASE_IMAGE=repository.example/driftrock/app:base-$PACKAGE_SHA

# scaricare l'immagine con le dipendenze se esiste, se non esiste costruirla e spingerla nel repo
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# push al repository per la prossima volta
&& docker push $BASE_IMAGE
)

# tagga localmente al nome costante in modo che possa essere usato in Dockerfile-test
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current

# costruisce l'immagine di test da app:base-current - aggiunge i file sorgente correnti all'immagine di base
docker build -f Dockerfile-test -t app-test .

# esegue i test all'interno dell'immagine creata
docker run app-test ./scripts/run-tests.sh

Con Dockerfile-package-base si installano solo le dipendenze:

DA ruby:2.3.1

COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install

E il test di Dockerfile aggiunge solo i file sorgente correnti all'immagine di base:

FROM repository.example/driftrock/app:base-current

COPY . /usr/src/app

Ispirandoci al modo in cui Dockerfile memorizza i comandi COPY in base all'hash del contenuto dei file, calcoliamo lo stesso hash manualmente. Poi cerchiamo di scaricare l'immagine con questo hash. Se non esiste, cioè se il contenuto dei rispettivi file è nuovo, creiamo l'immagine e la memorizziamo nel nostro repository privato di immagini Docker con un nome contenente l'hash dei file.

Si costruisce quindi un'immagine aggiuntiva app-test aggiungendo i file sorgente attuali all'immagine di base appena scaricata (o costruita). Questa rimane in locale solo per la particolare compilazione, poiché non distribuiamo ancora le immagini Docker. Infine, eseguiamo i test all'interno di questa immagine.

Ecco i risultati:

dopo-il-test-tempi.png

Nella parte "Pacchetti Gems e Node" abbiamo scambiato il tempo di installazione effettivo con il tempo di download dell'immagine memorizzata dal repository.

Spostando le build di Snap CI per questa applicazione su Docker e sfruttando il repository di immagini per archiviare le immagini con il bundle, siamo riusciti a ridurre il tempo di compilazione da ~9 minuti a ~6 minuti.

Le prossime tappe

Il download dell'immagine richiede ancora del tempo. Questo tempo dipende principalmente dalle dimensioni dell'immagine. Se siamo in grado di ridurre le dimensioni dell'immagine pulendo la quantità di pacchetti o ottimizzando l'immagine Docker rimuovendo i binari non necessari, questo accelererà ulteriormente.

Abbiamo ancora spazio per ottimizzare i test stessi e per eseguire i test indipendenti in parallelo, il che può far risparmiare almeno altri 1:30 minuti.

Conclusione

Anche se non usiamo Docker per eseguire effettivamente le nostre applicazioni in produzione, Docker ha migliorato in modo significativo il nostro CI.

Spostando la maggior parte dell'esecuzione della build in ambiente Docker, abbiamo anche eliminato la dipendenza dall'ambiente di build e le possibili vulnerabilità dovute a dipendenze non corrette, ecc. Questo ci permette di essere meno dipendenti da un particolare strumento CI/CD, il che ci consente di passare più rapidamente e meno costosamente a fornitori migliori o di cambiare in caso di interruzione.

Abbiamo dimostrato che l'uso di Docker è vantaggioso in un ambiente CI sia dal punto di vista strategico che dell'efficienza. Sposteremo altre parti della pipeline di compilazione su Docker per ridurre i tempi o la dipendenza dalle specifiche dello strumento di compilazione che utilizziamo, nel nostro caso Snap CI.

Nel complesso, questa modifica rappresenta un miglioramento significativo del nostro approccio alla CI e la estenderemo al resto delle applicazioni Driftrock.

Vale la pena ricordare che il nostro problema era la lunga installazione delle dipendenze. Forse se la vostra applicazione ha meno dipendenze o la sua installazione richiede un tempo insignificante rispetto al resto del tempo di creazione, questo approccio potrebbe non essere vantaggioso.