Nella prima parte di questo articolo abbiamo imparato come scaricare, visualizzare e rimuovere immagini ma non abbiamo ancora spiegato come usarle…
Partiamo dalla pratica. Supponiamo di voler ottenere una istanza del noto DB engine Postgres. Innanzitutto cerchiamo se c’è una immagine ufficiale sul Registry. Sul sito docker hub la troverete facilmente. Nella sezione di documentazione, ci sono alcuni suggerimenti su come lanciarla. Useremo il seguente:
$ docker run --name postgres-davidlab -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:13.4
Unable to find image 'postgres:13.4' locally
13.4: Pulling from library/postgres
[...]
Analizziamo il comando nel dettaglio:
- docker run: l’abbiamo già usato nell’esempio con Ubuntu. Sappiamo che recupera l’immagine (eventualmente scaricandola dal Registry, come accade in questo caso) e ne esegue lo start.
- –name: permette di specificare un nome per il container. Con molta fantasia abbiamo scelto “postgres-articolo”
- -e POSTGRES_PASSWORD: con questo switch settiamo una variabile d’ambiente (in questo caso la password di accesso al database). Torneremo sull’argomento più avanti.
- -p 5432:5432: di default i container sono delle scatole chiuse inaccessibili dall’esterno. Siamo noi a dover decidere quali porte rendere visibili. Con il flag -p chiediamo a docker di mappare la porta esterna (in questo caso la 5432) sulla porta interna (la 5432 che è quella standard di Postgres).
- -d: questo switch (che sta per detached) indica a docker di eseguire il container il background (stiamo parlando di un server quindi ha senso farlo).
- Postgres:13.4: rappresentano rispettivamente il nome e il tag dell’immagine che vogliamo eseguire
Ora abbiamo un server Postgres in esecuzione. Possiamo provare a collegarci a esso con un DB client (quello ufficiale è PgAdmin, che dovrete installare a parte).
Facciamo un altro passo avanti. Abbiamo già detto come fare per ottenere l’elenco di tutti i container presenti nella nostra macchina (sia quelli in esecuzione che quelli stoppati). Approfondiamo un po' l’argomento:
$ docker ps –a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c8c118adc94b postgres:9.4 "/docker-entrypoint.s" 24 hours ago Up 16 minutes 0.0.0.0:8432->5432/tcp postgres-articolo
69184e44f611 ubuntu:20.04 "/bin/bash" 6 days ago Exited (137) 31 hours ago ubuntu-lab
Le informazioni più importanti che vengono mostrate sono:
- CONTAINER ID: ovvero un id univoco con il quale potete riferirvi ad un container
- IMAGE: l’immagine partendo dalla quale il container è stato generato
- STATUS: indica se l’istanza è in esecuzione o è stata stoppata
- PORTS: riporta (se esistono) le mappature tra le porte del host e le porte interne del container. Nell’esempio ritroviamo l’associazione 8432:5432 che abbiamo fatto quando abbiamo avviato il server postgres.
- NAMES: È il nome che abbiamo dato al container (o quello che docker ha inventato per noi) Dall’esempio vediamo che il container di nome postgres-articolo è “up” mentre ubuntu-lab è inattivo. Come fare per attivare anche quest’ultimo?
Il primo impulso sarebbe quello di eseguire di nuovo il comando
docker run -i -t ubuntu:latest
Se lo facessimo, però, otterremmo un nuovo container. Nella lista dei container vedremmo apparire una nuova voce relativa all’immagine ubuntu:20.04 ma con un id e un nome diverso da quella esistente.
Per far ripartire proprio quella istanza di Ubuntu-lab dovremmo invece scrivere:
$ docker start <ubuntu-image-name>
-oppure usando i primi caratteri dell'id-
$ docker start 69184
Possiamo usare indifferentemente il nome o l’id per riferirvi all’immagine e di quest’ultimo possiamo usare solo i primi caratteri.
Con docker ps potete vedere che ubuntu è avviato… Ma non avete modo d’interagire con esso.
Per farlo dovrete usare:
$ docker attach 69184
Vi apparirà finalmente la shell di linux. Potete ora operare liberamente. Quando avete finito potete digitare exit per uscire.
Docker attach vs docker exec
Per “entrare” in un container già in esecuzione in backgroud, docker ci mette a disposizione due strade:
- docker attach
- docker exec
Questi due comandi sembrano avere lo stesso effetto. In realtà sono pensati per essere usati in ambiti differenti.
Il primo riutilizza un processo già esistente nel container. Il secondo invece ne crea uno nuovo.
Ad esempio, nell’articolo abbiamo lanciato l’immagine con ubuntu:16.04. Questa immagine parte già con una shell attiva, quindi possiamo usare docker attach per riutilizzarla.
Invece l’immagine nginx non avvia una shell quindi se vogliamo prendere il controllo del container dobbiamo crearne un nuovo processo che la ospiti. Per far questo utilizzeremo docker exec.
Naturalmente esiste anche il comando per arrestare una macchina:
$ docker stop <image-name-or-id>
Un altro utile comando è quello che ci consente di controllare i log di un servizio in esecuzione:
$ docker logs <image-name-or-id>
[...]
LOG: database system was shut down at 2016-05-05 21:33:12 UTC
LOG: MultiXact member wraparound protections are now enabled
LOG: database system is ready to accept connections
LOG: autovacuum launcher started
In questo modo vengono visualizzati su schermo gli ultimi log. Se vogliamo ottenere anche il “follow” (ovvero vogliamo restare in ascolto per intercettare anche i prossimi eventi) possiamo aggiungere il flag -f
$ docker logs -f <image-name-or-id>
Costruire e modificare le immagini
Fin ora abbiamo visto come usare immagini preparate da qualcun altro. A volte però quest’ultime non si adattano ai nostri scopi. La buona notizia è che Docker ci mette a disposizione strumenti piuttosto potenti per costruire immagini personalizzate.
Ci sono due modi per farlo: tramite il comando docker commit
, metodo più semplice e più vicino al modo di lavorare cui siamo abituati, oppure usando un dockerfile
, sicuramente più ostico ma molto più versatile.
Partiamo dal primo.
Serviamoci dell’immagine ubuntu:20.04 che abbiamo scaricato in precedenza. Da essa creiamo un container che chiameremo ubuntu-apache2-base.
$ docker run --name ubuntu-apache2-base -ti ubuntu:20.04 /bin/bash
root@69184e44f611:/#
Ora abbiamo a disposizione una versione minimale di Ubuntu 20.04. Supponiamo di voler creare server LAMP (ma senza il DB server, per mantenere semplice l’esempio) con lo scopo di testare una nostra applicazione PHP.
Prima di tutto dobbiamo installare il web-server Apache2. Per farlo è sufficiente eseguire i seguenti comandi (ciascuno dei quali produrrà un output piuttosto lungo… che non riportiamo per brevità):
# apt update
# apt install apache2
Installiamo poi PHP 7 e il modulo che ne permette l’esecuzione in apache:
# apt install php libapache2-mod-php
Riavviamo il webserver per far recepire le modifiche:
# /etc/init.d/apache2 restart
Infine creiamo una pagina di info di PHP, per controllare che effettivamente l’istallazione sia stata eseguita in modo corretto:
# echo "<?php phpinfo(); ?>" > /var/www/html/phpinfo.php
Ora abbiamo un webserver pronto per eseguire le nostre applicazioni PHP. In teoria potremmo già testarne il corretto funzionamento richiamando lo script phpinfo.php puntando il browser all’URL:
http://localhost/phpinfo.php
Se ci provate, però, otterrete un allarmante messaggio di “Connection refused”.
Non è una anomalia. Come abbiamo già detto, ogni container è una scatola chiusa che non permette connessioni dall’esterno a meno che non siamo noi a specificare quale porta vogliamo aprire. Rimedieremo a breve a questa mancanza. Per ora usciamo con il comando exit. Il nostro container verrà immediatamente chiuso (lo potete verificare eseguendo docker ps -a).
Per gestire il ciclo di vita della nostra applicazione, vorremmo avere a disposizione i canonici tre ambienti: SVILUPPO, TEST, PRODUZIONE. Abbiamo già creato l’ambiente di sviluppo (ubuntu-apache2-dev), non ci resta che creare gli altri due, che chiameremo: ubuntu-apache2-test e ubuntu-apache2-prod.
Poiché i tre server dovranno essere identici nella struttura, potremmo creare altri due container dall’immagine ubuntu:20.04 e ripetere i passi descritti sopra per l’installazione… Questo modo di procedere non ci soddisfa molto (è contrario al principio DRY: Don’t Repeat Yourself!).
Fortunatamente docker ci mette a disposizione strumenti più sofisticati per ottenere lo stesso risultato senza ripetere n volte le stesse azioni. Provate a eseguire quanto segue:
$ docker commit ubuntu-apache2-base ubuntu-apache2-img
Abbiamo appena creato una nuova immagine chiamata ubuntu-apache2-img a partire dal container ubuntu-apache2-base. Potete verificarlo con:
$docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
ubuntu-apache2-img latest sha256:e397e 5 seconds ago 270.3 MB
[…]
Ora potete intuire da soli come procederemo. Da questa immagine costruiremo in modo immediato i tre ambienti che ci servono, stavolta avendo cura di aprire la porta 80 verso l’esterno in modo da poter utilizzare il webserver.
$ docker run -d --name ubuntu-apache2-dev -p 8080:80 ubuntu-apache2-img apache2ctl -D FOREGROUND
$ docker run -d --name ubuntu-apache2-test -p 8081:80 ubuntu-apache2-img apache2ctl -D FOREGROUND
$ docker run -d --name ubuntu-apache2-prod -p 8082:80 ubuntu-apache2-img apache2ctl -D FOREGROUND
Notate che:
- abbiamo assegnando a ciascun server una porta diversa. In realtà tutti e tre gli apache girano sulla porta 80 nel container. Quello che noi abbiamo cambiato è solo la mappatura verso l’esterno.
- Abbiamo usato il comando apache2ctl –D FOREGROUND per lanciare il webserver in foreground. Se non avessimo proceduto in questo modo, il container sarebbe stato lanciato e arrestato subito dopo, non essendoci nessun processo in primo piano a tenerlo attivo.
Puntiamo il browser all’url: http://localhost:8080/phpinfo.php
. Otterremo (finalmente!) la pagina d’informazioni di PHP.
Dockerfile
L’uso del metodo “commit” per creare una immagine è versatile ma non è quello raccomandato. Il suo problema principale è la trasparenza.
Uno dei punti di forza delle immagini è quello di poter essere condivise con altri utenti. Quelle che abbiamo appena creato, però, sono dei “blocchi” monolitici e non c’è modo di sapere con precisione quello che contengono. Vi fidereste a prendere un’immagine da internet e a usarla per far girare un vostro software in produzione? Chi vi garantisce che essa non contenga malware o che non abbia falle di sicurezza?
Riprendiamo per un momento la costruzione della nostro server unbutu-apache2. Quello che abbiamo fatto è stato semplicemente partire da una immagine di base ubuntu:20.04 ed eseguire, l’uno dopo l’altro, una serie di comandi nella shell di linux. Infine, all’atto del run del container, abbiamo specificato la mappatura delle porte e il comando da eseguire all’avvio.
Se potessimo trascrivere questi passaggi in un file, usando una opportuna sintassi, potremmo otterremo una sorta di script che se eseguito ricreerebbe esattamente l’immagine che abbiamo ottenuto con il commit. Vediamo come fare.
Create un file testuale di nome Dockerfile in una cartella di comodo e inserite in esso questo contenuto:
FROM ubuntu:20.04
MAINTAINER David C <david@example.com>
RUN apt-get update
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get install -y apache2
RUN apt-get install -y php libapache2-mod-php
RUN echo "<?php phpinfo(); ?>" > /var/www/html/phpinfo.php
EXPOSE 80
CMD ["apache2ctl", "-D", "FOREGROUND"]
Analizziamolo riga per riga:
Iniziamo con il comando FROM ubuntu:20.04
. Questo può essere presente una sola volta, all’inizio del file e indica l’immagine di base da cui partire per costruire tutto il resto.
MAINTAINER
è solo un metadato per indicare la persona che ha creato il Dockerfile e non ha effetti sull’immagine.
Seguono poi quattro comandi RUN
. Potete verificare che sono i quattro comandi che avevamo usato nell’esempio del paragrafo precedente, con l’unica differenza che per alcuni di essi (ad esempio apt-get install –y apache2
) abbiamo aggiunto il flag –y e una variabile d’ambiente DEBIAN_FRONTEND=noninteractive
. Queste evitano che l’installazione si blocchi chiedendo una conferma all’utente, rispondendo implicitamente sì a ogni domanda.
La riga che riporta EXPOSE 80
indica che la porta 80 (quella usata di default da apache) sarà esposta dal container. Questo non vuol dire che essa sarà immediatamente visibile all’esterno. Sarà sempre necessario eseguire un mapping esplicito in fase di run (-p 8080:80).
Per ultimo, c’è il comando che sarà eseguito quando l’immagine verrà lanciata: apache2ctl –D FOREGROUND
.
Questo Dockerfile rappresenta ora la stessa immagine che avevamo costruito in precedenza usando il metodo commit. Esso potrà essere distribuito con molta più facilità ad altri utenti che, semplicemente leggendolo, potranno capire esattamente cosa contiene.
Ci manca ancora un ultimo passaggio: la generazione dell’immagine a partire dal Dockerfile.
Posizionatevi nella cartella che avete creato nel passo precedente e digitate (attenzione a non dimenticare il punto finale)
$ docker build -t="apache2" .
docker build costruisce una immagine di nome “apache2” eseguendo in sequenza i comandi contenuti nel Dockerfile.Ogni singolo comando genera un nuovo layer che viene cachato per evitare di ricostruirlo in futuro (questo comportamento può essere disattivato usando il flag –no-chache).
Alla fine di tutto c’è un comando di commit implicito che crea materialmente l’immagine.
Quest’ultima, esattamente come nel caso precedente, può essere lanciata usando il comando:
$ docker run -d --name ubuntu-apache2-dev -p 8080:80 apache2
Concatenare i comandi RUN in un dockerfile Per evitare che siano generati troppi layers quando si esegue un build di un Dockerfile, si può usare il simbolo di concatenamento &&.
I seguenti comandi, ad esempio:
RUN apt-get install -y apache2
RUN apt-get install -y php libapache2-mod-php
Possono essere concatenati in questo modo:
RUN apt-get install -y apache2 && apt-get install -y php libapache2-mod-php
Volumes
Nei paragrafi precedenti, abbiamo avuto modo di apprezzare la potenza di docker. In pochi minuti abbiamo costruito un server web completamente funzionate. Manca però qualcosa.
Siamo in grado di avviare un server apache2… ma come dovremmo fare per deployare in esso una nostra applicazione?
Un approccio potrebbe essere quello usato per la pagina di riepilogo phpinfo.php, ovvero inserire i riferimenti della web-application direttamente nel Dockerfile. Questo approccio però è troppo rigido. Per cambiare anche solo una riga di html bisognerebbe rifare il build dell’immagine. E se volessimo realizzare una galleria fotografica con, diciamo, 1000 immagini? Dovremmo ogni volta attendere la copia di tutte le immagini nel container.
Insomma per più di un motivo questo approccio non è accettabile.
Docker ci viene in contro anche questa volta. C’è un flag (-v) che, in fase di run, permette di eseguire una mappatura di una cartella interna al container verso una cartella esterna.
$ docker run --name ubuntu-apache2-dev -p 8080:80 –v $HOME/lab/www:/var/www/html -d apache2
In questo esempio tutti i file che metteremo nella directory locale “$HOME/lab/www” saranno visti come se fossero posizionati nella cartella “/var/www/html” del container
Proviamo a creare in $HOME/lab/www un file di nome helloworld.php con l’unica riga di codice PHP
<?php echo "Hello World!" ?>
Richiamiamo poi da browser la pagina http://192.168.99.100:8080/helloworld.php
. Otterremo su schermo il celebre saluto.
Networking
Fin ora abbiamo usato singoli container per i nostri esempi. Molto spesso, però, accade che due o più container debbano interagire tra di loro. Il caso tipico è una applicazione che deve accedere ad un database.
Sarebbe certamente possibile integrare il DB nella stessa immagine del software ma questa è una pratica contraria alla filosofia alla base di Docker, che vuole che ogni container rappresenti un servizio specializzato. Le network sono nate per risolvere questo problema.
Come esempio ci proponiamo di creare un container con la nota applicazione di file sharing OwnCloud.
docker run --name articolo_owncloud -p 8080:80 -d owncloud:9
docker run --name=mysql_owncloud -e MYSQL_ROOT_PASSWORD=secret --net=owncloud_nw -d mysql:latest
Quando creiamo un container, esso viene aggiunto automaticamente ad una rete di default chiamata Bridge. Possiamo ottenere informazioni su di essa con il comando (nell’output viene evidenziata la parte che riguarda i nostri due container):
docker network inspect bridge
[…]
"Containers": {
"23e7a3162db5480e5201321aa0034147fce20acfcba28338bfe8aac403491108": {
"Name": "mysql_owncloud",
"EndpointID": "e0d0912262018fa8860fff0194dfe2609bc0c93c1b97ac5d08eee4210cd4d3d9",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
},
"85bfb8b5ded96162e1cc41ec8fab212ead5515092709874e0a9096304f22973d": {
"Name": "articolo_owncloud",
"EndpointID": "f680340ba40b5a91e07734069b8c5afd1736fda8a767963bce28b32c8b404281",
"MacAddress": "02:42:ac:11:00:03",
"IPv4Address": "172.17.0.3/16",
"IPv6Address": ""
}
},
[…]
Ora potrete connettervi al server owncloud, puntando il browser all’url: http://localhost:8080/index.php. Al primo avvio vi verrà mostrata una maschera che vi invita a creare un account di amministratore e vi offre la possibilità di scegliere un database server.
Se però provate a connettervi al container con il MySql, non ci riuscirete. Questo perché la rete bridge di default non permette la comunicazione tra i container. La si potrebbe abilitare usando il flag –link al momento del run. Ma questa però è una pratica deprecata. C’è un metodo più sicuro e versatile per farlo.
Possiamo creare delle reti personalizzate. La sintassi è semplice:
docker network create --driver bridge owncloud_nw
Con questo comando avrete creato una rete di nome owncloud_nw di tipo bridge (esiste anche un altro tipo di rete: l’overlay, utile se dovete gestire grossi network. In questo articolo non ne parleremo).
Dopo aver creato la network possiamo aggiungervi i nostri container. Questo si ottiene lanciando una immagine con la stessa sintassi che abbiamo usato in precedenza, ma specificando a quale rete vogliamo che appartenga, con il flag –net
docker run --name=mysql_owncloud -e MYSQL_ROOT_PASSWORD=secret --net=owncloud_nw -d mysql:latest
docker run --name owncloud -p 8080:80 --net=owncloud_nw -d owncloud:9
In questo modo i nostri due container fanno parte della stessa rete virtuale di nome owncloud_nw. Possiamo verificarlo con in comando: docker network inspect owncloud_nw
[…]
"Containers": {
"5dafeca4161875645f87ebd7dc0d1858088fd23d9746b6449a9b5e81d5422756": {
"Name": "mysql_owncloud",
"EndpointID": "eec1b8b047414c3450ea8ce2313477896b61506f5e9f413a3e0a80444808dcbd",
"MacAddress": "02:42:ac:12:00:02",
"IPv4Address": "172.18.0.2/16",
"IPv6Address": ""
},
"aca6624333d2990ccb2cef86df7e142689094ea34067856224d30e681c421748": {
"Name": "owncloud",
"EndpointID": "53aff7e2e4ca1dd01de208df5bab258bfc3472a3c93ef99aaa06841fafd3cc28",
"MacAddress": "02:42:ac:12:00:03",
"IPv4Address": "172.18.0.3/16",
"IPv6Address": ""
}
},
[…]
I due container ora possono comunicare. Potrete inserire i parametri nel form relativo al database di owncloud. Selezionate il db-engine MySql, inserite root come utente (attenzione! Usatelo solo per i test!) e secret come password. Per il nome del database potete usare owncloud mentre per l’indirizzo del server sarà sufficiente usare il nome del container, nel nostro caso mysql_owncloud. Questo nome verrà risolto automaticamente nell’indirizzo ip corretto (172.18.0.2) dal DNS interno di docker.
Dopo qualche secondo potrete usare il vostro owncloud personale.
Vorrei farvi, da ultimo, notare che abbiamo mappato la porta 80 di owncloud verso la porta esterna 8080. Ma non abbiamo fatto lo stesso per mysql. Questo vuol dire che non saremo in grado di accedere a MySql. Nello scenario in esame questo comportamento è corretto oltre che desiderabile.MySql ci serve infatti, solo per supporto a owncloud. Non abbiamo nessuna necessità di renderlo accessibile da fuori.