Docker

Da Webmobili Wiki.

Introduzione

Docker è un progetto open source nato con lo scopo di automatizzare la distribuzione di applicazioni sotto forma di “contenitori” leggeri, portabili e autosufficienti che possono essere eseguiti su cloud (pubblici o privati) o in locale.

I “contenitori” di Docker, d’ora in poi chiamati con il termine inglese Container, sono quindi l’insieme dei dati di cui necessita un’applicazione per essere eseguita: librerie, altri eseguibili, rami del file system, file di configurazione, script, ecc..

Il processo di distribuzione di un’applicazione si riduce quindi alla semplice creazione di una Immagine Docker, ovvero di un file contenente tutti i dati di cui sopra. L’immagine viene utilizzata da Docker per creare un Container, un’istanza dell’immagine che eseguirà l’applicazione in essa contenuta.

I container sono quindi autosufficienti perché contengono già tutte le dipendenze dell’applicazione e non richiedono quindi particolari configurazioni sull’host, ma sono soprattuto portabili perché essi sono distribuiti in un formato standard (le immagini appunto), che può essere letto ed eseguito da qualunque server Docker.

Inoltre, i container sono leggeri perché sfruttano i servizi offerti dal kernel del sistema operativo ospitante, invece di richiedere l’esecuzione di un kernel virtualizzato come avviene nel caso delle VM. Ciò permette di ospitare un gran numero di container nella stessa macchina fisica.

Data la sua stretta relazione con il kernel Linux, Docker è nativamente disponibile in ambiente GNU/Linux. Tuttavia, essendo uno strumento concepito per gli sviluppatori, si è resa necessaria la possibilità di eseguirlo direttamente sulle workstation, senza imporre limiti sul sistema operativo in uso. Docker è pertanto disponibile anche in ambiente macOS e Windows. In entrambi i casi, si ricorre ad una tecnologia di virtualizzazione per eseguire un kernel Linux al quale si appoggia l’engine Docker.

Funzionamento

Il modello di esecuzione basato su container si fonda su una serie di funzionalità sviluppate nel kernel Linux e successivamente adottate anche da altre piattaforme, espressamente studiate per questo scopo.

Un sistema basato su container inteso come alternativa alla virtualizzazione deve garantire prestazioni superiori pur offrendo le stesse caratteristiche in termini di flessibilità nella gestione delle risorse e di sicurezza.

L’incremento prestazionale è dovuto essenzialmente all’eliminazione di uno strato: a differenza di quanto avviene quando un processo viene eseguito all’interno di una macchina virtuale, i processi eseguiti da un container sono di fatto eseguiti dal sistema ospitante, usufruendo dei servizi offerti dal kernel che esso esegue. Viene quindi eliminato l’overhead dovuto all’esecuzione di ogni singolo kernel di ogni VM.

I requisiti di flessibilità e sicurezza in un sistema virtualizzato sono a carico dell’Hypervisor, lo strato software in esecuzione nella macchina ospitante che si occupa di gestire le risorse allocate a ciascuna VM e che adotta (anche con l’ausilio dell’hardware) tutte le politiche necessarie per isolare i processi in esecuzione su VM differenti.

In un ambiente basato su container, dove quindi non è presente un Hypervisor, queste funzionalità sono assolte dal kernel del sistema operativo ospitante. Linux dispone di due caratteristiche progettate proprio per questo scopo: Control Groups (o cgroups) e Namespaces.

I control groups (cgroups)

I control groups sono lo strumento utilizzato dal kernel Linux per gestire l’utilizzo delle risorse di calcolo da parte di un gruppo specifico di processi. Grazie ai cgroups è possibile limitare la quantità di risorse utilizzate da uno o più processi. Ad esempio, è possibile limitare il quantitativo massimo di memoria RAM che un gruppo di processi può utilizzare.

In un sistema Linux esistono più cgroups, ciascuno associato ad uno o più resource controllers (talvolta chiamati anche subsystems), in base alle risorse che gestiscono. Ad esempio, un cgroup può essere associato al resource controller memory per gestire la quantità di memoria allocabile da un certo insieme di processi.

I cgroup sono organizzati in modo gerarchico in una struttura ad albero. Ogni nodo dell’albero rappresenta una gruppo, definito dalle regole che gestiscono la risorsa a cui è associato (ad esempio la dimensione massima di memoria allocabile) e dalla lista dei processi che ne fanno parte. Ciascun processo in quella lista risponderà alle regole definite nel gruppo.

Si può interagire manualmente con i cgroup attraverso il filesystem virtuale /sys. I cgroup attualmente in uso dal kernel sono accessibili come subdirectory di /sys/fs/cgroup. Per creare un nuovo cgroup è sufficiente creare una subdirectory in quel ramo del filesystem.

Ad esempio, si supponga di voler limitare la memoria massima allocabile da un processo, il cui PID è 1234, a 100 MB. Per far ciò è possibile creare un nuovo cgroup sotto memory, impostare il limite, ed aggiungere il processo al cgroup, con i comandi:

mkdir /sys/fs/cgroup/memory/miocgroup

echo 104857600 > /sys/fs/cgroup/memory/miocgroup/memory.limit_in_bytes

echo 1234 > /sys/fs/cgroup/memory/miocgroup/cgroup.procs

Come si intuisce, questa caratteristica permette di gestire le risorse allocate ad un particolare container in esecuzione. Qualora sia necessario impostare un limite per una risorsa specifica, sarà sufficiente creare un cgroup configurandolo opportunamente.

Docker sfrutta questa caratteristica del kernel Linux per implementare i limiti delle risorse allocate ai container. Quando un container è configurato con un limite su una o più risorse, Docker crea i corrispondenti cgroups in /sys/fs/cgroup/ ed aggiunge automaticamente i PID dei processi in esecuzione nel container.

Namespaces

Oltre a gestire l’allocazione delle risorse, il kernel ospitante ha anche il compito di garantire l’isolamento dei processi in esecuzione in container differenti. Per ovvi motivi di sicurezza non deve essere possibile per un processo in esecuzione in un container accedere direttamente alla macchina ospite o ad altri container.

Questa funzionalità è implementata nel kernel Linux mediante l’uso dei namespaces. Un namespace è essenzialmente un “contenitore” che astrae le risorse offerte dal kernel. Quando un processo fa parte di un certo namespace esso potrà accedere soltanto alle risorse presenti nel namespace.

In particolare, esistono diversi namespaces di default, ciascuno associato ad una tipologia differente di risorse: cgroup, IPC, Network, Mount, PID, User, UTS.

Per ciascun container in esecuzione Docker crea un opportuno gruppo di namespaces ed associa i processi in esecuzione nel container a quel namespace. In questo modo, i processi in esecuzione nel container non accederanno ai namspace della macchina ospitate, né a quelli di altri container. Ciò permette effettivamente di isolare i processi in esecuzione in un container.

Se non si creassero dei namespace ad hoc per ogni container, i processi del container potrebbero accedere direttamente alle risorse dell’host. Ad esempio, se un container non fosse associato al proprio namespace “cgroups”, esso potrebbe accedere ai cgroups della macchina host, avendo così il potere di gestire le risorse ad esso assegnate a piacimento.

Analogamente, se un container non fosse ristretto al proprio namespace “Network”, esso potrebbe accedere allo stack di rete della macchina host, avendo il potere di interferire arbitrariamente con le socket attive sull’host.

Come si intuisce dal nome, il namespace “PID” permette di isolare l’albero dei processi in esecuzione: un processo che voglia enumerare i processi in esecuzione potrà solo vedere quelli nel proprio namespace. Inoltre ogni namespace PID gestisce una propria numerazione, quindi i processi enumerati all’interno di un container avranno un PID diverso rispetto a quello effettivamente assegnatogli nel namespace globale.

Assegnare dei namespace differenti ad ogni container significa quindi creare delle sandbox in grado di isolare i processi del container dal resto della macchina.

Conclusioni

Cgroups e Namespaces sono caratteristiche fondamentali per l’implementazione di un sistema basato su container. Esse sono considerate mature essendo presenti in Linux da più di un decennio e basandosi su concetti già concepiti durante lo sviluppo di OpenVZ (nell’ormai lontano 2005).

Funzionalità analoghe sono state successivamente integrate anche in ambiente Windows, come parte dei Windows Native Containers. Tuttavia, ricordiamo che non è possibile eseguire container Linux direttamente sul kernel Windows (dato che, per l’appunto, non si virtualizza il sistema operativo ospite). In questi casi (host Windows ed immagini Linux), Docker ricorre comunque ad una macchina virtuale Linux, in esecuzione su HyperV.


Union filesystem

Una peculiarità delle immagini Docker è la loro stratificazione in layer, ognuno dei quali contribuisce alla definizione di quello che sarà poi il file system del container. Particolare di questi layer è la loro immutabilità: i layer sono infatti accessibili in sola lettura e non sono modificabili direttamente.

Questa, che potrebbe sembrare una limitazione, è invece un grande punto di forza di Docker, che in questo modo offre la possibilità a più immagini di condividere un layer comune.

Ad esempio, le immagini redis:3.0.0 e nginx:1.7 si basano entrambe su un layer comune (debian:jessie), sebbene la prima immagine appartenga ad un key/value store e la seconda ad un webserver.

L’immagine spiega abbastanza chiaramente la situazione, ma per dimostrarlo faremo una prova scaricando le immagini di cui sopra. Per iniziare, ci procureremo l’immagine di redis, e siccome non vogliamo eseguire il container utilizziamo il comando pull al posto del precedente run:

$ docker pull redis:3.0.0
3.0.0: Pulling from library/redis
193224d99eda: Pull complete
a3ed95caeb02: Pull complete
5d614b26c26f: Pull complete
8274a6625da0: Pull complete
86d9ae0920b7: Pull complete
f4f11f46a20e: Pull complete
c3192ae156a0: Pull complete
317cb6aa0b20: Pull complete
a1a961e320bc: Pull complete
Digest: sha256:06ce8790b8f63ad1ee9eec1aec5513c34331a350f66a370572405cb15508ecdc
Status: Downloaded newer image for redis:3.0.0

Completato questo comando, abbiamo disponibile in locale la nostra immagine. Si faccia attenzione al fatto che, in realtà, non abbiamo scaricato l’immagine redis:3.0.0 ma nove diversi layer (uno per ogni riga Pull complete) che sovrapposti daranno luogo all’immagine redis:3.0.0.

Procediamo a scaricare la seconda immagine dell’esempio:

$ docker pull nginx:1.7
1.7: Pulling from library/nginx
193224d99eda: Already exists
a3ed95caeb02: Pull complete
eb250aa1fe8b: Pull complete
26547bfb8cca: Pull complete
9118cfaa8eaa: Pull complete
a6efe51e1a3b: Pull complete
a2318bfd27ef: Pull complete
Digest: sha256:02537b932a849103ab21c75fac25c0de622ca12fe2c5ba8af2c7cb23339ee6d4
Status: Downloaded newer image for nginx:1.7

Come si vede, il layer 193224d99eda esiste già in locale (Already exists), essendo stato scaricato precedentemente durante il pull dell’immagine redis:3.0.0. Non sarà quindi necessario un nuovo download.

Immagini e container

Abbiamo posto l’attenzione su come un’immagine rappresenti, di fatto, una serie di layer immutabili, e di come questa caratteristica permetta a due immagini di condividere un layer comune.

Una pila di layer accessibili in sola lettura, però, non ha molto senso di esistere, ragion per cui quello che avviene nel momento in cui si chiede a Docker di istanziare un container è la creazione, in cima a tutti gli altri layer, di un singolo layer scrivibile.

D’ora in avanti, tutte le modifiche apportate al container verranno memorizzate all’interno di questo layer, detto anche layer container per differenziarlo da quelli in sola lettura, detti invece layer immagine.

Relazione tra immagini e container: un’immagine è una pila di layer accessibili in sola lettura; il container relativo è la stessa pila di layer con sopra un layer scrivibile. Il contenuto finale del container è ottenuto per sovrapposizione di tutti questi layer.

Così come da un negativo posso sviluppare più copie della stessa foto, a partire da un’immagine posso avviare più istanze dello stesso container.

Nel momento in cui arriverà la richiesta di avviare una seconda istanza di un container, Docker non farà altro che creare un nuovo layer container. Ad esempio, se eseguissimo due istanze dell’immagine nginx:1.7, si verrebbe a creare una situazione simile alla seguente:

Come possiamo osservare nella figura, i layer immagine sono condivisi ed hanno tutti lo stesso identificativo, essendo comuni e generati in base al loro contenuto. Gli identificativi dei due layer container, invece, sono diversi tra loro poichè, a differenza dei layer immagine sottostanti, questi vengono generati in maniera casuale (per ovvi motivi).


Comandi

Per vedere tutti i container, anche quelli non più attivi:

$ docker ps -a
CONTAINER ID    IMAGE          COMMAND   CREATED          STATUS   PORTS   NAMES
c3849603f184    hello-world    "/hello"  3 weeks ago      Exited          trusting_bell
0017d2a0b9d1    hello-world    "/hello"  6 seconds ago    Exited          boring_turing

Il comando mostrerà a video l’id breve del container layer, il nome dell’immagine che l’ha generato, il processo al suo interno, la data di creazione e lo stato. Oltre all’id esadecimale, ogni container ha anche un nome più “user friendly”, che viene assegnato da Docker ed ha sempre la forma di un aggettivo, seguito dal nome di uno scienziato famoso.

Se volessimo scegliere noi un nome più significativo, potremmo farlo specificandolo all’interno del comando docker run, utilizzando il flag --name: $ docker run --name hello_html_it hello-world