Questo post e, alcuni tra i prossimi, hanno lo scopo di dettagliare le istruzioni principali per la definizione di un Dockerfile necessario alla build di un’immagine.

Per la comprensione di alcuni concetti ricorrenti, utilizzeremo l’immagine ufficiale di mysql presente nel docker hub a questo indirizzo: https://hub.docker.com/_/mysql.
Ho scelto questo esempio perché è abbastanza completo e mi da modo di mostrare un implicazione reale di quasi tutti i costrutti principali.

Aprendo la pagina noteremo ad un certo punto una sezione con la dicitura “Supported tags and respective Dockerfile links”.

Ogni riga contiene immagini che condividono lo stesso Dockerfile.

Facendo click su uno qualunque dei tag, si aprirà un link ad un Dockerfile che è esattamente quello utilizzato per creare l’immagine con tutti i tag presenti in quella riga.

Personalmente, ogni volta che ho a che fare con un’immagine che non conosco, vado a leggere il Dockerfile – se disponibile – per avere più informazioni possibili su come è stata costruita e su come è preferibile utilizzarla. Mi sento di consigliare questo approccio perché a mio avviso è molto utile.

Sintassi

È giunto il momento di una carrellata nozionistica, ma ahimè indispensabile.

FROM

Ogni Dockerfile “deve iniziare” con l’istruzione FROM.

Questa istruzione specifica l’immagine base da cui partire per costruirne una di nuova.

FROM alpine:latest

Questa istruzione sta dicendo a Docker di costruire l’immagine a partire dall’immagine del repository alpine contrassegnato dal tag latest

Piccola nota, ma di fondamentale importanza. Le immagini vengono tenute il più leggere possibile, di conseguenza è possibile che alcuni pacchetti che siamo soliti utilizzare nei sistemi corrispondenti all’immagine di base potrebbero non essere presenti di default e necessitare di installazione.

Ad esempio, l’immagine di base di Ubuntu non ha il comando ping abilitato di default. Per averne prova basta eseguire:

docker container run --rm ubuntu ping dockertutorial.it

l’output che otteniamo è:

docker: Error response from daemon: OCI runtime create failed: container_linux.go:349: starting container process caused "exec: \"ping\": executable file not found in $PATH": unknown.

Il demone ci dice che non sa come eseguire “ping” e fallisce.

Ho approfittato di questo esempio per utilizzare il flag –rm all’interno di un istruzione per l’avvio di un container. Questo flag è comodissimo perché rimuove il container al termine dell’esecuzione evitando di lasciare memoria allocata inutilmente e evitandoci il lavoro di pulizia.

Eseguendo invece lo stesso container, ma con il comando ls

docker container run --rm ubuntu ls

otteniamo l’elenco dei file e cartelle.

bin
boot
dev
etc
home
lib
lib32
lib64
libx32
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

Questo accade perché il listing è abilitato di default.
Finché parliamo di ping, può ance sembrare una cosa di scarso interesse, ma potrebbe capitare per servizi molto più di largo uso quali i package manager. In questo caso l’idea è di partire da immagini che già li supportano nativamente ad esempio Ubuntu per apt, Debian per yum o Alpine per pkg.

ARG

In casi particolari, può aver senso specificare uno o più parametri da passare come argomento al FROM.

In ambiente Docker questi si chiamano argomenti e vengono definiti con la keyword ARG.

Solo la definizione di questi parametri può precedere la definizione dell’immagine di partenza, comparendo quindi prima della keyword FROM.

Modifichiamo l’esempio visto in precedenza e incapsuliamo il tag latest in un argomento.

ARG VERSIONE_IMG=latest
FROM alpine:${VERSIONE_IMG}

Andiamo per ordine.
In questo caso viene creato un parametro visibile solo al di fuori della build che riporta il tag dell’immagine di alpine da utilizzare come immagine di partenza.

La valutazione e restituzione dell’argomento viene eseguita mediante la sintassi bash ${} e verrà utilizzata nel comando che verrà invocato.

Anche se ci assomiglia, non è bash!

Nel Dockerfile si utilizza anche lo scripting bash, ma il Dockerfile non è uno script bash.

Come abbiamo detto in precedenza, l’istruzione FROM è quella che determina l’inizio della build. Quindi tutti gli argomenti definiti con ARG non sono visibili dopo il tag FROM. Almeno non di default.

Si possono rendere visibili gli argomenti anche all’interno del processo di costruzione dell’immagine dichiarandoli anche dopo il tag FROM, senza assegnarne alcun valore.

ARG VERSIONE_IMG=latest
FROM alpine:${VERSIONE_IMG}
ARG VERSIONE_IMG
RUN echo $VERSION > file.txt

Subito dopo la dichiarazione dell’argomento, vediamo il comando RUN.

In questo caso verrà eseguito un echo che produrrà in output la stringa “latest”. L’output verrà poi dirottato in un nuovo file che ho chiamato “file.txt”.

RUN

Il comando RUN ha lo scopo di eseguire una o più istruzioni definite in formato bash in un nuovo layer. Di fatto aggiungendo un blocco immutabile all’immagine.

Durante la build, l’istruzione di build successiva a quella del RUN appena definito, sarà costruita a partire dall’immagine ottenuta dall’applicazione di tutti i layer precedenti. Quello appena definito sarà l’ultimo in ordine di comparizione.

Un po’ come una pila di piatti, noi prendiamo tutta la pila ma ogni piatto viene posto sopra la tutti quelli impilati precedentemente.

Questo è un concetto chiave del Dockerfile e di Docker in generale. La build avviene per composizione di layer successivi definiti con buona approssimazione da ogni riga del dockerfile a partire da FROM.

Due modalità distinte per invocare il RUN

Quando definiamo i comandi RUN, possiamo scegliere tra due tipologie di sintassi:

  1. Modalità bash
  2. Modalità exec ( detta anche json )

La modalità bash esegue il comando in una shell.
Diretta conseguenza di ciò è che viene prefissa implicitamente la dichiarazione della shell. Nello specifico, in ambiente unix il modulo builder del docker engine ci anteporrà “/bin/sh -c”.

RUN echo 'testo di prova'

Ricordiamoci di questa semplificazione perché non viene proprio a gratis, ma porta con se delle conseguenze.


L’altra sintassi è quella exec. Questa modalità non invoca la shell direttamente ma fa il parse di un array json e compone la stringa dell’istruzione da eseguire fondendo i valori recuperati da tale array. 

 Tutte le stringhe che compongono l’istruzione devono essere racchiuse da doppi apici e non da apici singoli.

RUN ["sh", "-c", "echo hello"]
RUN ["/bin/bash", "-c", "echo hello"]
RUN ["file_eseguibile.sh"]
RUN ["sh", "-c", "echo $ARG_ESEMPIO"]

Inoltre, non essendo direttamente un comando su shell, la sostituzione delle stringhe con gli argomenti (quelli preceduti dal dollaro che abbiamo visto in precedenza) non funziona.

Se dovessimo trovarci nella situazione in cui necessitiamo che vengano interpretate alcune variabili,  dovremmo predisporre l’array impostando le prime stringhe con la definizione di una shell, ad esempio RUN [“sh”, “-c”, “echo $ARG_ESEMPIO”]. Questo è un mezzo trucchetto perché la $ARG_ESEMPIO non verrebbe comunque interpretata in modalità exec, però, essendo preceduta da una shell la valutazione e sostituzione delle stringhe avviene in un secondo momento, al di fuori della valutazione dell’array json.

Essendo json, può essere necessario fare l’escape dei caratteri speciali, il carattere preposto per questo scopo è, come di consueto, il backslash (\). Lo scopo è quello di comunicare all’interprete di trattare il carattere successivo al backslash come carattere di contenuto e non come un carattere che potrebbe avere una valenza diversa(ad esempio i doppi apici che chiuderebbero la stringa”).

Errori noti

Ricordate quando parlavamo di layer delle immagini? Avevamo visto che un layer che non viene modificato dalla build precedente, non viene ricostruito nuovamente durante la build di un’immagine perché già in cache.

Ma cosa succede se il layer è generato, ad esempio, da un comando che fa il pull da un dato branch di un repository? Oppure se verifica l’ora corrente e se maggiore di un certo valore cambia il comportamento?

Attenzione che questo aspetto provoca spesso errori. Le informazioni cambiano al di fuori del Dockerfile, quindi verrebbe eseguito solo la prima volta, perché l’istruzione che lo genera non ha subito modifiche e di conseguenza verrebbe semplicemente recuperato dalla cache.

Docker costruisce le immagini con i Dockerfile che non hanno consapevolezza delle modifiche apportate all’esterno, ma solo delle variazioni dei propri layer.

Quando vi sono di questi problemi, è il caso di eseguire la build utilizzando il flag –no-cache che ha lo scopo di invalidare la cache relativa alle sole istruzioni RUN.

Dato che siamo in tema di errori noti, ne approfitto per darvi una dritta sull’invocazione dei comandi bash. Può capitare a volte di vedere delle invocazioni di più comandi separati dai caratteri “&&” oppure dal carattere “;“. La differenza sta nel fatto che nel primo caso, ogni comando viene eseguito solo se tutti i precedenti sono andati a buon fine, mentre nel secondo caso, verranno eseguite sempre tutte le istruzioni. Quando scriviamo dei Dockerfile oppure anche dei file bash generici, dobbiamo sempre pensare bene al flusso delle istruzioni multiple in modo da evitare risultati diversi da quelli che ci aspettiamo.

Fatte queste considerazioni, torniamo per un attimo al Dockerfile di mysql.

RUN set -eux; \
savedAptMark="$(apt-mark showmanual)"; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates wget; \
rm -rf /var/lib/apt/lists/*; \
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
apt-mark auto '.*' > /dev/null; \
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
chmod +x /usr/local/bin/gosu; \
gosu --version; \
gosu nobody true

Il comando RUN si estende per più righe utilizzando l’apposito carattere per mandare accapo: ‘\’; Colgo la palla al balzo per evidenziare che se l’istruzione seguente è un comando, deve essere preceduta dall’indicazione di concatenazione affinché venga concatenata correttamente (in questo caso viene usato il carattere ‘;’).

Quest’ultimo punto può sembrare solo un aspetto stilistico, ma in realtà non lo è per niente. Ricordate che poco fa vi avevo detto che ogni riga del Dockerfile origina un nuovo layer? Utilizzare questa sintassi permette di raggruppare più istruzioni in un solo layer ottimizzando il processo.

CMD

Passiamo ora all’ultima istruzione necessaria per permetterci di fare la nostra prima build, ovvero CMD.

CMD è l’istruzione di default per l’esecuzione di un container. Fondamentalmente significa che al run di un container a partire dall’immagine che stiamo definendo, verrà invocata questa istruzione.

Il comando CMD, viene eseguito ogni volta che viene avviato un nuovo container oppure quando viene avviato con il comando docker container start dopo aver fermato il relativo container con docker container stop.

E’ possibile sovrascrivere questa istruzione esplicitandone una di diversa in coda al comando docker container run come abbiamo visto poche righe sopra quando abbiamo testato ping e ls sull’immagine di Ubuntu.

Ogni Dockerfile ben formato dovrebbe avere al massimo una sola occorrenza di CMD. Ad ogni modo, in presenza di invocazioni multiple, verrà utilizzata solo l’ultima di esse.

Anche in questo caso è possibile utilizzare sia la modalità bash sia quella exec.

Può sembrare che CMD e RUN siano equivalenti, ma in realtà non è così.

Sebbene siano simili nella forma non lo sono nella sostanza.

RUN  agisce in fase di build apportando modifiche all’immagine, mentre il comando CMD specifica cosa fare quando viene avviato un container a partire da un’immagine che a tutti gli effetti è già sigillata.


Prosegui su: Creare un’immagine

Immagine:  Acqua foto creata da freepik – it.freepik.com