Finora ci siamo interfacciati con il Docker engine sempre e soltanto tramite riga di comando: ad esempio, abbiamo avviato il nostro webserver con il comando docker run -d httpd:2.4
. Tutto questo è molto pratico per sperimentare, ma non è il massimo in uno scenario reale.
Lasciare al terminale l’onere della definizione di un container, infatti, ha come effetto collaterale la mancata tracciabilità, e di conseguenza la mancata ripetibilità del processo di definizione stesso. Una volta persa la cronologia dei comandi, diventa molto laborioso capire cosa faccia ogni singolo container.
Un primo passo di avvicinamento al mondo reale consiste nel non vedere più le immagini ufficiali come dei container pronti per l’uso, ma come dei semplici template da cui partire per creare delle nostre immagini personalizzate.
Un Dockerfile è un semplice file di testo che, con una sintassi semplice e concisa, ci permette di esprimere le personalizzazioni che vogliamo apportare ai vari template affinché questi possano diventare delle immagini su misura per noi. Come qualsiasi file di testo, anche il Dockerfile si sposa benissimo con un qualsiasi sistema di versioning (come Git o Team Foundation Server, per citarne alcuni), permettendo di attenerci al desiderio di tracciabilità e ripetibilità e di avvicinarci a quella pratica meglio nota come Infrastructure as Code.
Anatomia di un Dockerfile
Tecnicamente parlando, il Dockerfile è un domain-specific language (DSL), ovvero un insieme di istruzioni per la definizione di immagini Docker. Fatte tutte le scelte in tema di configurazioni, possiamo passare il nostro Dockerfile all’engine che si occuperà di validarlo e di generare così una nuova immagine. Di seguito presenteremo le istruzioni più importanti che andranno a comporre questo file.
FROM
Tra le tante istruzione disponibili, FROM
è sicuramente quella più importante, nonché l’unica che non può mai mancare all’interno di ogni Dockerfile. Essa permette di specificare un’immagine di base (base image) da cui partire per derivare la nostra immagine personalizzata.
È fondamentale ricordare che, per quanto detto qualche riga sopra, la nostra ottica non sarà più quella di avviare un semplice container, bensì di definire una nuova immagine su misura per noi. Pertanto, la scelta della giusta immagine di base è di conseguenza il passo principale.
Così come per il comando pull
da riga di comando, anche in questo caso abbiamo tre modalità:
FROM <nome_immagine>
FROM <nome_immagine>:<tag>
FROM <nome_immagine>@<hash>
Per evitare problemi di compatibilità, sarebbe sempre meglio utilizzare le ultime due istruzioni. Oltre alla versione giusta, in fase di scelta dell’immagine cerchiamo di capire anche quanto questa verrà supportata e rimarrà attiva, onde evitare in futuro di dover modificare centinaia di Dockerfile perché la nostra base image non esiste più sull’hub.
Commenti
Qualora servissero, abbiamo anche la possibilità di inserire commenti nel Dockerfile anteponendo il carattere #
al commento stesso.
# xenial corrisponde alla versione 16.04
FROM ubuntu:xenial
ENV
L’istruzione ENV
(diminutivo di environment) offre la possibilità di impostare variabili di ambiente valide per tutto il contesto di esecuzione di un Dockerfile:
ENV <chiave>=<valore>
Per recuperare il valore della variabile d’ambiente all’interno del Dockerfile, basta riferirci ad essa anteponendo il carattere $
alla chiave stessa (per esempio: $FTP_HOME
). Esiste anche un altro modo per la definizione di variabili d’ambiente, ma quello appena illustrato risulta più leggibile e meno soggetto ad errori.
RUN
Grazie all’istruzione FROM
) di voler utilizzare Debian come immagine base, possiamo utilizzare l’istruzione RUN
per installare un pacchetto tramite il classico comando apt-get
:
# shell form
RUN <comando> <parametro1> ... <parametroN>
# exec form
RUN ["<comando>", "<parametro1>", ... , "<parametroN>"]
L’istruzione RUN
è disponibile in due modalità: shell
ed exec
. La prima modalità esegue il comando sfruttando le facility della shell del sistema operativo (espansione di variabili, I/O redirection e così via); l’altra modalità utilizza le sole primitive del sistema operativo.
In virtù di quanto detto nelle lezioni precedenti riguardo il filesystem Docker, l’immagine così come ottenuta dal FROM
rimane intatta. In fase di building dell’immagine, il comando RUN
fa sì che si generi un nuovo layer che porti con sé le modifiche derivate dal comando stesso. Ne deriva che ogni volta che l’engine incontra questa istruzione lungo il suo percorso, esso creerà un nuovo layer, per cui è caldamente consigliato far collassare più comandi correlati all’interno di una singola istruzione RUN
. Ad esempio, nel caso dell’installazione di un nuovo pacchetto, dove è prassi l’utilizzo della coppia di comandi apt-get update/install
, l’utilizzo di due istruzioni RUN
diverse per apt-get update
e apt-get install
potrebbe far fallire l’installazione.
ADD e COPY
ADD
e COPY
sono due diverse istruzioni, ma le trattiamo insieme poichè entrambe producono lo stesso risultato, ovvero spostare file e directory dal build context (il path sul nostro computer locale dove si trova il Dockerfile) all’interno del filesystem dell’immagine creata. Abbiamo due modi per utilizzare il comando ADD
:
# shell form
ADD <src> <dest>
# exec form
ADD ["<src>", "<dest>"]
L’istruzione è disponibile in due modalità shell ed exec, la prima modalità esegue il comando sfruttando le facility della shell del sistema operativo (espansione di variabili, I/O redirection e così via), l’altra utilizzando le sole primitive del sistema operativo.
Il path di destinazione può essere assoluto o relativo rispetto alla directory di lavoro corrente (vedi WORKDIR
), le due modalità (shell
o exec
) sono equivalenti, ma la seconda modalità è l’unica che ci permette di avere degli spazi all’interno di uno dei due path. In entrambe i casi possono essere specificati più sorgenti <src>
, e questi potranno essere file, directory o indirizzi URL. Nel caso in cui il file <src>
sia un archivio valido (ad esempio gzip, bzip2) questo risulterà decompresso come directory nel path di destinazione.
L’istruzione COPY
ha lo stesso effetto dell’istruzione ADD
, nonché la stessa sintassi, ed anch’essa è esprimibile in due modalità. Tuttavia, essa non ha il supporto per file remoti e archivi. Nonostante ciò, stando alla documentazione ufficiale, l’istruzione COPY
è preferibile rispetto ad ADD
, a meno che non si debba estrarre un archivio o lavorare con file remoti.
ENTRYPOINT
L’istruzione è una delle più importanti all’interno del Dockerfile e permette di eseguire un comando all’interno del container non appena questo si è avviato. Si differenzia dall’istruzione RUN
in quanto, gli effetti dell’istruzione si avranno sul container stesso piuttosto che sull’immagine che l’ha generato. Tipicamente è molto utilizzato quando si vuole istanziare un demone non appena il container è avviato. La sintassi del comando è come al solito duale, ed anche in questo caso è consigliabile l’utilizzo della forma exec
:
# shell form
ENTRYPOINT <comando> <parametro_1> ... <parametro_n>
# exec form
ENTRYPOINT ["<comando>", "<parametro_1>", ..., "<parametro_n>"]
Utilizzando questa istruzione, l’ultima parte del comando docker run
non sarà più un comando ma un parametro da passare al comando espresso nell’ENTRYPOINT
. Proviamo a fare un po’ di chiarezza: abbiamo visto nella lezione precedente che se nel terminale scriviamo docker run -it ubuntu /bin/bash
, una volta che il container otteniamo una shell verso l’interno del container. A questo punto però supponiamo di avere questo Dockerfile:
FROM ubuntu:precise
ENTRYPOINT ["ls"]
Supponiamo che l’immagine derivante da questo Dockerfile si chiami htmlit (vedremo nella lezione successiva come crearla). Il comando docker run htmlit
ci restituirà il risultato del comando ls
lanciato all’interno del container. Per quanto detto precedentemente, se aggiungessimo qualcosa dopo il nome dell’immagine da eseguire, questi saranno considerati come dei parametri al comando ls
. Se, ad esempio, volessimo vedere anche i file nascosti, eseguiremmo il container come docker run htmlit -a
, oppure passando anche un secondo parametro docker run htmlit -a -l
.
Se volessimo sovrascrivere il comportamento e fare in modo che all’avvio del container venga eseguito un altro comando, possiamo utilizzare il flag entrypoint
del docker run
.
CMD
Un’altra istruzione molto ricorrente e che spesso genera confusione con le istruzioni precedenti è l’istruzione CMD
. A complicare ancor di più la situazione è il comportamento stesso dell’istruzione CMD
, che è diverso a seconda che nel Dockerfile sia presente o meno l’istruzione ENTRYPOINT
. Ma procediamo con ordine. L’istruzione CMD
, come l’istruzione ENTRYPOINT
ha effetto sul container e non modifica l’immagine, né aggiunge layer a questa, come avviene con l’istruzione RUN
. Tale istruzione come la maggior parte di istruzioni del Dockerfile ha una doppia sintassi:
# shell form
CMD <comando> <parametro_1> ... <parametro_n>
# exec form
CMD ["<comando>", "<parametro_1>", ..., "<parametro_n>"]
Se non esiste un’istruzione ENTRYPOINT
all’interno del Dockerfile in esame, l’utilizzo dell’istruzione CMD
farà in modo che all’avvio del container venga eseguito il comando indicato. Questo è esattamente lo stesso comportamento dell’istruzione ENTRYPOINT
, ma in questo caso possiamo sovrascrivere questo comando specificandone uno diverso nel momento in cui eseguiamo il comando docker run
da terminale. Supponiamo di avere il seguente Dockerfile:
FROM ubuntu:precise
CMD ["ls"]
Dal Dockerfile creiamo la nostra immagine, che chiameremo htmlit (come già detto, nella prossima lezione vedremo come fare) e a questo punto docker run htmlit
ci mostrerà tutte le directory della root, grazie al comando ls
.
Se volessimo evitare il comportamento di default del container e specificare un nostro comando di default, ci basterà eseguire docker run htmlit whoami
: poichè siamo all’interno del container, l'output di whoami
sarà root.
Se, invece, all’interno del nostro Dockerfile esiste già l’istruzione ENTRYPOINT
, l’istruzione CMD
avrà il compito di fornire dei parametri di default al comando espresso nell’istruzione ENTRYPOINT
e l’unica sintassi disponibile sarà:
# exec form CMD ["<parametro_1>", ..., "<parametro_n>"]
In questo modo il comando non sarà più sovrascrivibile dall’esterno (a meno che non si utilizzi il flag entrypoint
nel docker run
), mentre resteranno sovrascrivibili tutti i parametri di default al comando specificati nel CMD
. Supponiamo di avere questo Dockerfile che genera la solita immagine htmlit:
FROM ubuntu:precise
ENTRYPOINT ["ls"]
CMD ["-a"]
Utilizzando il comando docker run htmlit
vedremo una lista di tutti i file, compresi quelli nascosti. Il comando docker run htmlit -l
andrà a sovrascrivere il comportamento precedente non mostrando più i file nascosti, ma visualizzando il contenuto nella forma estesa.
All’interno di un Dockerfile è ammessa una sola istruzione CMD
. Se ne sono specificate diverse, solo l’ultima verrà presa in considerazione.
WORKDIR
Un’altra istruzione che ha effetto su tutto il contesto del nostro Dockerfile è WORKDIR
. Questa istruzione viene utilizzata per impostare la working directory, ovvvero la directory all’interno del container su cui avranno effetto tutte le successive istruzioni.
# path assoluto
WORKDIR /path1/path2
# path relativo
WORKDIR path1/path2
Sebbene sia consigliato sempre l’utilizzo di path assoluti, l’istruzione supporta anche path relativi. In quest’ultimo caso, il path sarà relativo all'istruzione WORKDIR
immediatamente precedente. Inoltre, per definizione, se questa non è fornita esplicitamente, si assume che la working directory specificata sia la root.
LABEL
L’istruzione LABEL
ci pemette di aggiungere dei metadati alle nostre immagini, esprimibili come una coppia chiave valore:
LABEL "<chiave>"="<valore>" ...
È possibile, in questo modo, indicare informazioni utili non direttamente legate alla definizione dell’immagine stessa. Ad esempio, è possibile specificare un mantainer o indicare un numero di versione, una descrizione dell’immagine e così via. Ogni istruzione LABEL
crea un nuovo layer, per cui come nel caso dell’istruzione RUN
è consigliabile unire più istruzioni LABEL
in una unica che le raggruppi tutte.
EXPOSE
Grazie a questa istruzione è possibile dichiarare su quali porte il container resterà in ascolto. Tale istruzione non apre direttamente le porte specificate, ma grazie ad essa Docker saprà, in fase di avvio dell’immagine, che sarà necessario effettuarne il forwarding. La sintassi è molto semplice:
EXPOSE <porta_1> [<porta_n>]
È possibile inserire più porte semplicemente aggiungendo uno spazio. Una volta effettuato il build del container, all'avvio dello stesso potremo utilizzare il parametro -P
in modo da esporre le porte dichiarate nell’attributo EXPOSE
su delle porte casuali. Il tag -P
(maiuscolo) si comporta in maniera del tutto simile al tag -p
(minuscolo), con l’unica differenza che nell’ultimo caso eravamo noi a dover scegliere sia la porta del container che quella dell’host.
Una volta avviato il container, per sapere su quale porta è stata mappata la porta esposta nel Dockerfile, abbiamo a disposizione l'istruzione docker port <porta esposta> <nome del container | id del container>
. Se nel dockerfile abbiamo esposto la porta 4444 e il nostro container si chiama htmlit, il comando sarà:
docker port 4444 htmlit
VOLUME
In alcuni casi può essere necessario far comunicare fra loro il file system dell’host con quello del container e viceversa. L’istruzione VOLUME
rende possibile tutto ciò dando la possibilità di specificare un path all’interno del file system del container:
VOLUME ["/www"]
Quando avvieremo il nostro container, potremo specificare a quale path locale far corrispondere il path /www definito nel container. Tale mapping è realizzabile solo al momento dell’avvio del container e non all’interno del Dockerfile, in quanto quest’ultima soluzione creerebbe potenziali incompatibilità.