Nell’informatica si dice che le grandi idee nascano sempre da grandi problemi e che per capire a fondo un prodotto bisogna prima comprendere il bisogno che esso soddisfa.

Docker ha come obiettivo il rendere più semplice la pacchettizzazione e la distribuzione di software.

Le moderne applicazioni, soprattutto se superano una certa dimensione, non sono mai dei blocchi monolitici. Sono piuttosto formate da una serie di componenti (application server, database, librerie, framework,…) che devono interagire fra di loro. Inoltre esse sono sviluppate su dei semplici PC ma in produzione dovranno girare su server che hanno hardware e sistemi operativi completamente differenti.

Il processo di distribuzione di un software è dunque esposto a una serie di complicazioni (installazioni e configurazioni di numerosi componenti, differenze di ambienti) che sono fonte di errori e di cui gli sviluppatori farebbero volentieri a meno.

Docker tenta (con successo) di risolvere proprio questi problemi. L’idea alla base è molto semplice: mettere a disposizione dei programmatori dei container che ospitino il software e tutte le sue dipendenze. Tali container potranno essere distribuiti come blocchi unici su altre macchine. In questo modo si ottengono numerosi vantaggi tra i quali:

  • non sarà più necessario ripetere l’installazione e la configurazione dell’applicazione. Questi passaggi saranno eseguiti solo una volta nel container.
  • vengono eliminate le differenze di ambiente. Docker infatti garantisce che i container funzioneranno in modo identico su qualsiasi hardware e su qualsiasi sistema operativo.

Virtual machine VS Container

Un containers docker spesso viene erroneamente considerato una macchina virtuale leggera. Non è esattamente così.

Le macchine virtuali sono state create avendo in mente l’hardware e puntano a ricreare un computer completo. Per ciascuna è possibile scegliere hard disk, schede video e audio, interfacce di comunicazione e naturalmente il sistema operativo.

Dal punto di vista sistemistico sono state una rivoluzione e certamente il loro uso si rivela comodo anche per gli sviluppatori… Ma solo in modo indiretto.

L’approccio di Docker è differente: viene sacrificata la complessità hardware (in pratica si rinuncia all’emulazione, sfruttando invece il kernel del sistema operativo host) in favore della versatilità lato software.

In questo modo si perdono interfacce grafiche, scelta della cpu, supporto per dispositivi esterni, e varie altre comodità disponibili con le Virtual Machine tradizionali ma si guadagna in compattezza e in velocità di esecuzione.

Per darvi un’idea di quanto sia la differenza: una virtual machine con una installazione standard di ubuntu server (quindi senza interfaccia grafica) richiede diversi GB. L’immagine della stessa versione di ubuntu in Docker occupa meno di 40 MB (ma c’è anche Alpine linux occupa appena 5 MB!).

Inoltre, viene considerevolmente ridotto l’overhead dovuto all’emulazione, il che rende un container notevolmente più veloce e meno avido di risorse rispetto a una macchina virtuale.

Questo risultato è reso possibile dal fatto che dal sistema operativo vengono eliminati tutti i software e le librerie non strettamente indispensabili. Sarà poi lo sviluppatore a decidere cosa installare in base alle proprie necessità.

Il fatto di avere dei contenitori così piccoli permette di eseguirne molti sulla stessa macchina host senza intaccare in modo significativo sulle sue risorse.

Container e immagini

I container rappresentano singole unità di sviluppo e possono ospitare un software con tutte le sue dipendenze. Ecco alcune loro caratteristiche salienti:

  • Sono progettati per essere “leggeri”: di norma contengono solo il minimo indispensabile per permettere ad una applicazione di essere eseguita.
  • Sono standardizzati, ovvero ne viene garantita la portabilità tra vari sistemi host, siano essi il cloud AWS di Amazon, un server linux o windows o un PC casalingo.
  • Permettono di distribuire un software con tutte le proprie dipendenze come un unico blocco e in un unico passaggio.
  • Ognuno è una istanza isolata. Questo minimizza problemi di sicurezza e di accoppiamento.

I vantaggi per gli sviluppatori sono molti. L’impatto maggiore riguarda forse la distribuzione del software.

Supponiamo di aver sviluppato sul nostro PC una applicazione web mediamente complessa, formata ad esempio da un application server Tomcat, un DB server Postgres e un software scritto in java usando spring mvc. Con il metodo “tradizionale”, per portarla nell’ambiente di Test (e successivamente in quello di produzione) dovremmo installare e configurare di nuovo Tomcat, Postgres e deployare la web application java con tutte le sue dipendenze… sperando che vada tutto bene perché il PC e il server hanno hardware e sistema operativo differenti.

Con docker la stessa operazione richiederebbe solo la copia di alcuni container. E il funzionamento sarebbe garantito perché i container sono indipendenti dall’ambiente in cui vengono eseguiti.

I Container sono costituiti da Immagini (o meglio da layers di immagini) che possono essere scaricate da un Registry o costruite passo passo a partire da una serie di istruzioni.

Se un container rappresenta una versione minimale di un sistema operativo linux, possiamo pensare ad una immagine come il software che girerà su di esso.

Approfondiremo (e chiariremo meglio) questi concetti nel proseguo dell’articolo.

Primi passi

Installare docker sul proprio PC non è affatto difficile. Se avete un sistema Windows o Mac il tutto si riduce a seguire le istruzioni di un wizard. Troverete una ottima documentazione sul sito ufficiale, all’indirizzo: https://docs.docker.com/get-docker/

Non ci soffermeremo oltre sull’argomento. Daremo per scontato che abbiate eseguito correttamente la procedura e che siate di fronte al prompt di comandi di Docker. Se stessimo trattando di un linguaggio di programmazione, a questo punto muoveremmo un primo passo eseguendo il classico codice hello world. Possiamo fare una cosa del genere anche in Docker: scriviamo da riga di comando la seguente istruzione e osserviamo l’output che ci viene restituito (in corsivo… troncato per brevità):

$ docker run hello-world

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:e86134b19508866f9a94095ebed0cc4322d1340f1855f52057a748ca06880720
Status: Downloaded newer image for hello-world:latest

Hello from Docker.
This message shows that your installation appears to be working correctly.
[...]

Stiamo chiedendo a Docker di eseguire l’immagine chiamata “hello-world”. Nella prima riga dell’output, ci viene comunicato che tale immagine non è presente in locale (e non potrebbe essere altrimenti visto che abbiamo appena installato l’ambiente). Si tenterà quindi di scaricarla da internet (vedremo più avanti da dove). Questa operazione si chiama pull.

Finito il download, l’immagine viene eseguita. Hello-world si limita a stampare su schermo la frase “Hello from Docker” e a uscire. L’output ci indica che la nostra installazione sembra funzionare correttamente e, nell’ultima riga, ci invita a provare qualcosa di più “ambizioso” (e utile) come ad esempio lanciare l’immagine di Ubuntu.

Seguiamo questo consiglio e lanciamo il comando:

$ docker run -i -t ubuntu:20.04

Unable to find image 'ubuntu:20.04' locally
20.04: Pulling from library/ubuntu
16ec32c2132b: Pull complete
Digest: sha256:82becede498899ec668628e7cb0ad87b6e1c371cb8a1e597d83a47fac21d6af3
Status: Downloaded newer image for ubuntu:20.04
root@06b5d37c20dd:/#

Rispetto al primo esempio ci sono alcune piccole differenza:

  • abbiamo aggiunto la stringa “:20.04” in coda al nome dell’immagine.
  • Abbiamo usato due flag: -i e -t.
  • Il prompt dei comandi nell’ultima riga è cambiato (root@06b5d37c20dd:/#)

Per capire il significato di queste aggiunte, dobbiamo considerare che stiamo chiedendo a Docker di far girare una istanza (minimale) di Ubuntu di cui esistono molte versioni… Con “:20.04” ne stiamo scegliendo una.

I due flag -t e -i ci permettono di richiedere una console (-t) interattiva (-i) verso il processo in esecuzione. Se non li specifichiamo, l’immagine verrà eseguita e subito chiusa (come è accaduto per Hello-world). Questo perché un container può rimanere attivo solo se c’è un processo che gira in foreground. Specificando gli switch -t -i, invece, avremo accesso a una console di root del sistema operativo ospite (vedi ultima riga dell’output).

Se provate a digitare un qualsiasi comando unix, ad esempio un banale uname –a che stampa informazioni relative al sistema operativo, otterrete la prova di quanto abbiamo appena detto:

root@06b5d37c20dd:/# uname -a 
Linux 06b5d37c20dd 5.10.47-linuxkit #1 SMP Sat Jul 3 21:51:47 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

Per uscire e tornare alla “normalità” potete scrivere “exit”.

A questo punto, prima di andare avanti occorre fare una piccola digressione…

Il registry ufficiale

Negli esempi precedenti abbiamo usato delle immagini di base già pronte. Ma non abbiamo detto da dove provengono. Docker mette a disposizione degli spazi, chiamati Registry, dai quali si possono prelevare immagini o condividere con la comunità le proprie. Il Registry ufficiale è raggiungibile all’url: http://hub.docker.com.

Nel sito (che si chiama Docker Hub) troverete una distinzione tra immagini ufficiali, ovvero create direttamente dalle case produttrici dei software, e pubbliche, cioè messe a disposizione da utenti “normali”.

Per poter usare Docker in modo proficuo avrete bisogno di creare un vostro account. Potete farlo in modo abbastanza semplice usando il form disponibile nella homepage del sito.

Completata la procedura di iscrizione, verrete reindirizzati alla dashboard dalla quale potrete creare dei vostri repository oppure iniziare a esplorare quelli esistenti.

Cliccando sul tasto Explore in alto a sinistra, verrete trasferiti alla pagina delle immagini ufficiali. Provate ad aprire una voce dalla lista (ad esempio il webservier nginx). Troverete -dopo un brevissima descrizione- una sezione che riporta delle informazioni apparentemente criptiche:

[...]
1.21.3, mainline, 1, 1.21, latest
...
1.20.1, stable, 1.20
[...]

Sono dei tag che permettono di scegliere una specifica versione del software quando eseguiamo un comando run. Se ad esempio volessimo ottenere la versione 1.20 di nginx dovremmo scrivere:

$ docker run -d nginx:1.20

Notate che alcuni tag sono semplicemente degli alias. Ad esempio latest, 1, 1.9, 1.9.15 si riferiscono tutti ad una stessa versione. Abbiamo detto che parallelamente ai repository ufficiali, esistono anche quelli cosiddetti “pubblici”, ovvero gestiti da utenti non verificati. Mediawiki (il motore che sta alla base di Wikipedia), ad esempio, non ha uno spazio ufficiale. Provate a cercarlo con la casella Search in alto a destra. Otterrete un discreto numero di risultati, tutti marcati come public.

Le immagini pubbliche non sono in alcun modo verificate, per cui fate attenzione a come le usate perché in molti casi non potrete sapere con precisione cosa contengono.

La gestione delle immagini

Sappiamo dunque da dove provengono le immagini. In un esempio precedente ne abbiamo scaricato ed eseguito una usando: docker run -i -t ubuntu:16.04

Se proviamo a riusare lo stesso comando, vedremo apparire la shell di ubuntu quasi istantaneamente. La prima volta invece avevamo dovuto aspettare che fosse completato il download dal Registry. Sembrerebbe quindi che Docker abbia salvato da qualche parte una copia delle immagini scaricate da internet. E' esattamente quello che accade.

Per poter vedere la cache locale potete eseguire il comando:

$ docker images
REPOSITORY            TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
ubuntu                16.04               sha256:44776        1 days ago          120.1 MB
hello-world           latest              sha256:690ed        6 months ago        960 B

La tabella che si ottiene in output ha le colonne: repository, tag, image id, created, virtual size. Di queste la più importante è image id che ci permette di individuare in modo univoco una certa immagine. Notate poi quanto sono “leggeri” i container: quello con ubuntu occupa solo 120 MB.

Tutte le immagini sono conservate finché non vengono cancellate esplicitamente. Per farlo si può usare il comando rmi:

docker rmi <image id>

Ogni volta che usiamo un comando docker run, creiamo un nuovo container basato su una certa immagine. Anche questi sono mantenuti in una sorta di cache. Possiamo ottenerne l’elenco digitando:

docker ps –a
CONTAINER ID      IMAGE               [...]     NAMES
972e22a14d59      ubuntu:20.04        [...]     amazing_lichterman

Tra i campi riportati in output troviamo il container id che, analogamente a quanto accade per le immagini, è un id che ci permette di individuare in modo univoco uno specifico container. Troviamo poi l’immagine di riferimento e per ultimo un nome. Ogni container ne deve avere uno. Se non lo si specifica in fase di creazione, sarà docker ad inventarne uno per voi (in modo piuttosto fantasioso).

Per eliminare una istanza si può usare:

docker rm <container id>

Notate che non dovete per forza specificare l’id per intero. Docker vi permette di scriverne solo pochi caratteri (a patto che non ci siano conflitti). Sarà poi lui a completare la stringa. In alternativa potete usare il nome.

Ad esempio, potremo scrivere indifferentemente

docker rm 972e22a14d59
docker rm 972e
docker rm amazing_lichterman