Blog
/
Accelerare l'integrazione continua con Docker

Accelerare l'integrazione continua con Docker

4 gennaio 2017

Noi del team di sviluppo di Driftrock puntiamo a sviluppare e fornire ai nostri clienti nuove funzionalità e correzioni il più rapidamente e frequentemente possibile. Un elemento fondamentale che ci permette di farlo è l'integrazione continua.

Ultimamente abbiamo iniziato a riscontrare tempi piuttosto lunghi per i test di CI: circa 10 minuti. Nel nostro team effettuiamo revisioni delle Pull Request e ciò significa che ogni volta che apportavamo un piccolo aggiornamento durante la discussione sulla PR, dovevamo attendere 10 minuti per poter unire le modifiche o chiedere al revisore di accettarle e sbloccare effettivamente il nostro lavoro per poter svolgere altre attività. Se lo mettiamo in prospettiva in una giornata lavorativa con 10 pull request, ciò rappresenta il 20% del tempo di lavoro (1 ora e 40 minuti in una giornata lavorativa di 8 ore).

Il problema

Dando un'occhiata al log di compilazione, possiamo notare che l'operazione che richiede più tempo è l'installazione dei pacchetti Gems e Node:

original-test-times.png

L'applicazione in questione è un'applicazione mista Ruby on Rails e JavaScript (Node), che richiede l'installazione sia di gemme che di pacchetti NPM. Questa operazione viene eseguita ogni volta, nonostante nella maggior parte dei casi i pacchetti non subiscano modifiche.

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 ottimizzato, ma al momento il problema maggiore è il tempo necessario per la creazione dei pacchetti.

Il file Dockerfile

Se non avete familiarità con l'ecosistema Docker, permettetemi di fare una breve digressione per illustrarvelo. In Docker, qualsiasi operazione si intraprenda, si parte da un'immagine Docker. Un'immagine Docker è un'istantanea di un sistema operativo – comprensiva di tutta la struttura del file system, degli strumenti CLI, della shell e dei programmi installati, insieme a tutte le relative dipendenze – escluso il kernel.

Si tratta di un concetto incredibilmente potente che consente di scaricare ed eseguire qualsiasi cosa, dalla shell Ruby al server PostgreSQL, con un unico comando. Non occorre più preoccuparsi di installare tutte le librerie, risolvere i conflitti e così via, come invece era necessario quando si eseguiva il software direttamente sui nostri sistemi operativi.

Per creare — o compilare — un'immagine Docker, Docker utilizza file denominati Dockerfile. Questi file contengono una serie di passaggi — ovvero comandi — che vengono eseguiti all'interno dell'immagine per generare una nuova immagine in vista della fase successiva e, infine, per creare l'immagine finale, alla quale è possibile assegnare un nome e riutilizzare per eseguire le proprie applicazioni.

DA ruby:latest

# Riesegui bundle install solo se il Gemfile viene modificato
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20

# Copia il resto del codice sorgente
COPY . ./L'esempio sopra riportato crea un'immagine contenente un'applicazione Ruby con le gemme installate tramite bundle in base al Gemfile.

Il bello dei Dockerfile è che ogni fase crea un'immagine intermedia che Docker salva e memorizza automaticamente nella cache. Poiché ogni fase è definita in modo deterministico dal proprio comando, Docker non ha bisogno di rieseguirla fintanto che la fase non cambia.

Per i passaggi RUN, si presume che, fintantoché il testo del comando rimane invariato, anche l'output e tutti gli effetti collaterali siano gli stessi. È interessante notare che, per i passaggi COPY, il sistema legge il contenuto dei file specificati, ne calcola l'impronta hash e, fintantoché il contenuto di tali file non cambia, utilizza l'immagine memorizzata nella cache.

Nell'esempio sopra riportato, questo comportamento viene sfruttato per eseguire la fase di installazione dei bundle solo se i file Gemfile e Gemfile.lock subiscono effettivamente delle modifiche. Se non cambiano, possiamo utilizzare la versione memorizzata nella cache e velocizzare così la compilazione. Poiché utilizza l'hash del contenuto del file - e non la data di modifica del file, non fa alcuna supposizione sul contenuto, ecc. - invalida sempre in modo affidabile la cache quando è necessario, eliminando la preoccupazione di utilizzare uno stato non valido della cache o simili, cosa che spesso temiamo quando si parla di caching.

La soluzione

Snap CI ha recentemente introdotto il supporto in versione beta per Docker nel proprio stack di compilazione. Consapevoli dei vantaggi offerti dalla cache delle immagini di Docker, abbiamo deciso di provarlo.

Il piano è il seguente:

  • Creare un'immagine Docker contenente i pacchetti Gem e Node e i file sorgente della build corrente utilizzando un Dockerfile, con la procedura di copiare solo il file Gemfile e package.json prima di eseguire `bundle install && npm install`
  • Sfrutta la cache in modo che ogni build aggiunga solo i nuovi file sorgente, senza rieseguire il bundle se le dipendenze non cambiano
  • Esegui i test utilizzando l'immagine precompilata

Abbiamo quindi creato questo Dockerfile e lo utilizziamo per compilare l'immagine all'interno dello script di compilazione:

DA ruby:2.3.1

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

COPY . /usr/src/app

Quando abbiamo provato a farlo, però, abbiamo scoperto che il comando `docker build` su Snap purtroppo non utilizza la cache come invece avviene sulla macchina locale. Si limita semplicemente a rieseguire tutti i comandi più e più volte. Forse perché la build viene eseguita ogni volta su un nodo diverso, oppure perché Snap cancella semplicemente i file di Docker prima di ogni esecuzione della build.

Hashing manuale

Se non possiamo utilizzare la funzione di caching integrata nel comando Docker, nulla ci impedisce di riprodurla e implementarla autonomamente. Abbiamo quindi deciso di procedere in tal senso e di modificare il piano come segue:

  • Creare l'immagine di base contenente i pacchetti Gems e Node (senza i file sorgente di compilazione) una sola volta
  • Salva quell'immagine nell'archivio immagini, contrassegnandola con l'hash dei file Gemfile e package.json
  • Prova a scaricare l'immagine nelle versioni successive
  • Crea una nuova immagine aggiungendo i file sorgente della build corrente
  • Esegui i test utilizzando quella nuova immagine

Ecco lo script che abbiamo ottenuto:

# hash dei file che possono influire sulle 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

# scarica l'immagine con le dipendenze se esiste, altrimenti creala e inviala al repository
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# invia al repository per la prossima volta
&& docker push $BASE_IMAGE
)

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

# crea l'immagine di test da app:base-current - aggiunge i file sorgente attuali 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 che installa solo le dipendenze:

DA ruby:2.3.1

COPIA Gemfile Gemfile.lock package.json /usr/src/app
ESEGUISCI npm install && bundle install

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

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

COPIA . /usr/src/app

Ispirati dal modo in cui il Dockerfile memorizza nella cache i comandi COPY in base all'hash del contenuto dei file, calcoliamo manualmente lo stesso hash. Quindi proviamo a scaricare l'immagine denominata con tale hash. Se non esiste – ovvero se il contenuto dei rispettivi file è nuovo – creiamo l'immagine e la archiviamo nel nostro repository privato di immagini Docker con un nome che contiene l'hash dei file.

Creiamo quindi un'immagine di test aggiuntiva aggiungendo i file sorgente attuali all'immagine di base che abbiamo appena scaricato (o creato). Questa rimane solo in locale per quella specifica build, poiché non distribuiamo ancora immagini Docker. Infine, eseguiamo i test all'interno di questa immagine.

Ecco i risultati:

tempi-post-test.png

Nella sezione "Gems e pacchetti Node" abbiamo sacrificato il tempo effettivo di installazione a favore del tempo necessario per scaricare l'immagine archiviata dal repository.

Trasferendo le nostre build Snap CI per questa applicazione su Docker e utilizzando un repository di immagini per archiviare le immagini con i bundle, siamo riusciti a ridurre il tempo di compilazione da circa 9 minuti a circa 6 minuti.

Prossimi passi

Il download dell'immagine richiede ancora un po' di tempo. La durata dipende principalmente dalle dimensioni dell'immagine. Se riusciamo a ridurre le dimensioni dell'immagine alleggerendo i pacchetti o ottimizzando l'immagine Docker rimuovendo i file binari non necessari, il processo risulterà ulteriormente accelerato.

Abbiamo ancora margine per ottimizzare i test stessi e per eseguire le suite di test indipendenti in parallelo, il che può far risparmiare almeno un'altra mezz'ora.

Conclusione

Anche se non utilizziamo Docker per l'esecuzione effettiva delle nostre app in produzione, Docker ha migliorato notevolmente il nostro processo di integrazione continua (CI).

Trasferendo la maggior parte del processo di compilazione in un ambiente Docker, abbiamo anche eliminato la dipendenza dall'ambiente di compilazione e le potenziali vulnerabilità legate a dipendenze non funzionanti, ecc. Questo ci permette di ridurre la dipendenza da uno specifico strumento di CI/CD, consentendoci di passare in modo più rapido ed economico a fornitori migliori o di cambiare in caso di interruzioni del servizio.

Abbiamo dimostrato che l'uso di Docker è vantaggioso in un ambiente di integrazione continua (CI) sia dal punto di vista strategico che in termini di efficienza. Trasferiremo altre parti della pipeline di compilazione su Docker per ridurre i tempi o la dipendenza dalle specificità dello strumento di compilazione che utilizziamo, nel nostro caso Snap CI.

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

Vale la pena sottolineare che il nostro problema era rappresentato dalla lunga durata dell'installazione delle dipendenze. Se la vostra applicazione ha poche dipendenze o se la loro installazione richiede un tempo irrisorio rispetto al resto del tempo di compilazione, questo approccio potrebbe non essere vantaggioso per voi.