Informatica per script kiddies 8 – Containers, cosa e perché

Oggi Informatica per script kiddies ospita finalmente un articolo di u/Sudneo invece che del solito u/LBreda. Siamo sempre alla ricerca di collaboratori, scrivete pure a LBreda se volete dare una mano! Trovate qui tutti gli articoli della rubrica.

L’argomento di oggi è qualcosa che da un paio di anni a questa parte è sulla bocca di tutti: i containers. C’è chi ne ha sentito parlare riguardo Docker, chi magari ha sentito parlare di Kubernetes, Podman o chissà cos’altro. Tutto si basa però su dei concetti che generali, che tenteremo di trattare in quanto tali.

Un po’ di storia

L’idea dietro ai container non è nuova, al contrario è piuttosto vecchia e ha origine negli anni ’70, con l’introduzione della sistema chroot, che permette di isolare una parte di filesystem impedendo ai processi di far riferimento a oggetti che si trovano al di fuori di essa. Da lì in poi, sviluppandosi ed estendendosi, passando per le jail di FreeBSD, le funzionalità di OpenVZ e tanto altro, siamo arrivati al 2015, che ha visto l’esplosione di Docker, quello che — ancora oggi — è il de facto standard per la containerizzazione.

Cosa è un container?

La definizione più classica di container al giorno d’oggi è quella di un’unità software che all’interno contiene un’applicazione e le sue dipendenze. Usando termini meno complessi, possiamo iniziare con il definire un container per ciò che non è, e cioè una piccola macchina virtuale.

Una macchina virtuale utilizza un hypervisor con il quale si interfaccia con il sistema operativo “reale”, e all’interno contiene un intero sistema operativo installato, con tutte le librerie, il Kernel, i driver eccetera.

Un container invece non usa alcun hypervisor, e all’interno non contiene né il Kernel né le librerie di sistema. Questa infatti è la differenza essenziale che c’è tra una macchina virtuale e un container: il container condivide con la macchina “reale” il sistema operativo (il Kernel, nello specifico), e si limita a segregare i propri processi, la propria visione del filesystem, della rete etc., da quella del sistema host via software. La maggior parte delle implementazioni ottiene questa segregazione tramite lo strumento dei Linux namespaces.

I Linux Namespaces sono una funzionalità del sistema operativo Linux che permette di suddividere le risorse in gruppi rigidamente separati, in modo che le risorse di un gruppo possano far riferimento solo ad altre risorse del gruppo stesso. Questo permette la creazione di ambienti diversi in cui le risorse possono assumere lo stesso nome senza andare in conflitto fra loro, e senza “accorgersi” di trovarsi in una bolla isolata.

Prendiamo per esempio l’insieme dei processi in un sistema Linux: ogni processo ha un Process ID (PID) che lo identifica. Il namespace dei PID per la macchina fisica e quello del container sono separati, dunque può esistere un PID 1 in entrambi. Lo stesso succede per le connessioni di rete, l’hostname, il layout del filesystem (ad esempio / nella macchina fisica è un path diverso da / all’interno del container) e altro.

Perché oggi tutti containerizzano?

La ragione dell’efficacia dei container può essere spiegata ancora una volta in analogia e opposizione ad una macchina virtuale.

Uno dei grandi motivi per cui si mettono in piedi sistemi di macchine virtuali è quello di poter far girare un’applicazione in maniera quanto più indipendente possibile dal sistema in uso. Con le macchine virtuali ad esempio è possibile installare qualsiasi sistema senza toccare (o quasi) l’host. Tuttavia, una macchina virtuale richiede un’installazione completa di un sistema operativo, che significa avere un sacco di risorse impegnate per qualcosa che non è strettamente legato al far girare l’applicazione. Far girare sullo stesso host molte macchine virtuali, magari con sistemi operativi simili, significa impiegare risorse hardware per far girare molti sistemi operativi quasi uguali contemporaneamente.

Il vantaggio di un container invece è che, utilizzando il sistema operativo dell’host, può avere una taglia molto ridotta e un uso molto più efficiente delle risorse.

Per parlare in termini concreti, immaginiamo che vogliamo far girare un’applicazione, diciamo qualcosa di semplice, come mkdocs, un sistema usato per generare siti statici orientati alla documentazione. Se volessi usare una macchina virtuale dovrei installare una distribuzione Linux, installare le dipendenze (in questo caso alcuni pacchetti Python) oppure scaricare una macchina virtuale in un formato importabile (ad esempio .OVA) che contiene già un sistema configurato con mkdocs. Probabilmente l’applicazione può girare con pochi MB di memoria, ma la macchina virtuale peserà centinaia di MB, se non qualche GB, e richiederà alcune centinaia di MB di RAM per poter girare.

Possiamo però far girare mdocks separatamente dal sistema host anche utilizzando i container: possiamo scaricare un container già costruito o costruirne uno, includendo le dipendeze Python e nient’altro, e ottenendo una immagine che peserà qualche MB. Non solo, ma una volta che il container verrà fatto girare, richiederà solo le risorse necessarie a mkdocs, che probabilmente sono nell’ordine di qualche MB.

Il risultato finale, e la motivazione principale che ha reso i container così popolari, è che è possibile far girare un numero molto elevato (decine, centinaia) di container su una sola macchina, mantenendo un certo grado di isolamento nei confronti dell’host e degli altri container. Ne consegue che l’ottica diventa quella di far girare un’applicazione per ciascun container, rispetto ad avere una macchina virtuale con decine di applicazioni (che riprensentano di nuovo problemi di interdipendenze etc.) o installare ciascuna applicazione sull’host, senza alcun isolamento o facilitazione nella gestione delle dipendenze.

I problemi dei container

Come dicevamo poco fa, il vantaggio dei container è anche una debolezza: condividere il Kernel con l’host si porta con sè problematiche come il fatto che problemi di sicurezza relativi al Kernel possono essere usati da dentro i container per rompere l’isolamento che questi forniscono.

Un altro problema è che un container configurato in maniera insicura può portare molto più facilmente alla compromissione dell’host di quanto non succeda in una macchina virtuale.

L’isolamento garantito dai container, insomma, non è lo stesso garantito dalle macchine virtuali. Le discussioni sulla sicurezza relativa ai container potrebbe costituire un articolo a sé stante (che sarei felice di discutere se ci dovesse essere interesse), quindi non voglio entrare troppo nei dettagli, ma ci sono molti altri scenari da discutere.

Docker come archetipo dei container.

Disclaimer: da qui in poi utilizzerò Docker per fare esempi concreti di container e come usarli; non perché Docker è il miglior sistema possibile, ma perché è quello più diffuso e che più probabilmente ciascuno incontrerà inizialmente.

Costruire un container

I container in genere vengono costruiti attraverso un sistema a strati (layer), un po’ come funziona con l’ereditarietà programmando a oggetti. L’idea è piuttosto semplice: un container è rappresentato da una immagine, statica, che può essere fatta girare. Tuttavia, questo container può essere usato come base per costruire un altro container.

Le più comuni (e anche quelle meno comuni) distribuzioni linux hanno ad esempio una propria immagine Docker che può essere usata come strato ‘di partenza’. Allo stesso tempo sono disponibili un’infinità di immagini che hanno già installata una determinata dipendenza. Ad esempio è possibile costruire un container partendo da un’immagine python, che conterrà appunto l’interprete python e il gestore di pacchetti pip.

Scelta la base dalla quale partire, nel caso di Docker creeremo un Dockerfile, che è un fie nel quale sono descritti i passi da compiere per costruire una nuova immagine. Un esempio di questi passi può essere, per esempio, “installa un certo pacchetto python” e poi “copia i file della mia applicazione in una certa destinazione”. È molto importante notare che il Dockerfile costruisce l’immagine, non la esegue, e questi passi vengono quindi fatti prima che l’immagine venga fatta girare.

Anche durante la fase di costruzione il processo a strati continua. Ogni istruzione del Dockerfile verrà eseguita in uno strato separato, che verrà usato come base per l’istruzione successiva. Questo rappresenta il motivo per il quale in molti Dockerfile troveremo istruzioni da molte righe tutte eseguite in un unico comando: ciò permette di non creare strati inutili, e di raggruppare nello stesso strato cose analoghe. Allo stesso tempo questo permette di costruire immagini simili senza sprecare spazio, riutilizzando strati già costruiti. Pensiamo ad esempio a due immagini che differiscono solo per l’ultima istruzione del Dockerfile. Tutti gli strati precedenti a quest’ultima saranno identici e potranno essere riutilizzati.

Facendo un esempio concreto:

 FROM debian:latest
 RUN apt-get update && apt-get install -y myapp

Questo semplice Dockerfile non fa altro che prendere l’immagine pubblica debian con il tag latest (primo strato), e poi all’interno di questa eseguire il comando per installare myapp (secondo strato). Questa immagine a sua volta potrà essere usata come FROM per costruirne un’altra che ha bisogno di myapp.

Finalmente, una volta che abbiamo scritto il nostro Dockerfile possiamo costruire la nostra immagine, che verrà al momento salvata localmente.

Caso particolare: le applicazioni in Golang

Un esempio specifico di applicazioni che si prestano incredibilmente bene ad essere containerizzate è rappresentato dalle applicazioni in Golang. I binari in Golang sono compilati staticamente, quindi hanno al loro interno tutte le librerie necessarie all’applicazione. Questo significa che il rispettivo container non ha neanche bisogno di una ‘base’ dalla quale partire, può usare la base scratch, che è lo strato 0 di ogni container. Basta aggiungere il binario compilato ad un certo path e l’immagine è pronta. L’immagine prodotta peserà praticamente quanto il binario stesso, e l’applicazione potrà girare in maniera portabile su ogni macchina beneficiando del grado di isolamento che i container forniscono, minimizzando la possibilità di attacchi (nessuna shell disponibile, nessun binario standard cat, ls, nessun compilatore, editor di testo etc.).

Utilizzare container già pronti

Anche qui è necessario fare un disclaimer molto importante: utilizzare immagini già pronte è indubbiamente comodo, permette di usare una qualsiasi applicazione, dalla più semplice alla più complessa, con un comando, ma ha molti rischi.

Come detto prima, il container condivide il Kernel con la nostra macchina (e in molti casi sarà necessario condividere altro, per esempio porzioni del filesystem). È evidente dunque che se il container è costruito male (intenzionalmente o meno), questo può potenzialmente compromettere l’intera macchina.

Esistono vari repository che contengono migliaia di immagini Docker già costruite e pronte all’uso: il più famoso (e anche quello di default) per Docker è il Docker Hub. Qui è possibile cercare sia immagini da usare come base che immagini che già contengono applicazioni complesse (per esempio Jenkins, Postgresql, WordPress etc.). È possibile esaminare il Dockerfile di ciascuna immagine disponibile e le immagini presenti nella libreria principale sono mantenute e controllate (e dunque meno rischiose).

I container in pratica

Dopo aver discusso cosa è e come ottenere un container, ora possiamo finalmente passare alla parte divertente: come usarlo?

Ci sono alcuni principi base che è bene tenere a mente:

  • Un container dovrebbe contenere il minimo indispensabile per una e una sola applicazione. Nello specifico, Docker considera l’applicazione con PID 1 l’unica importante. Se questo processo termina, l’intero container verrà terminato.
  • I container sono volatili, ciò significa che una volta terminato un container, tutto ciò che è stato fatto al suo interno verrà perso. Per far fronte a questo problema ed usare applicazioni che hanno bisogno di persistenza, bisogna condividere parti del filesystem della macchina host con il container.
  • Se un container all’interno ha una shell (come bash), è possibile entrarvici (similmente a chroot) e eseguire comandi come fossimo in una normale macchina (anche se con ogni probabilità avremo un set limitato di strumenti a disposizione).

Nel caso di Docker, è probabile che faremo girare il nostro container con il comando run, e nel processo possiamo configurare molte opzioni per il nostro container: possiamo configurare variabili d’ambiente, mappare directory e porte dell’host all’interno del container, e molto altro. Questo permette di far girare applicazioni arbitrariamente complesse senza limitazioni e senza dover installare assolutamente nulla nell’host.

Kubernetes, Docker-compose e altre creature magiche

Questo argomento potrebbe richiedere non uno ma una serie di articoli, ma è bene comunque toccarlo per dare una prospettiva a quello che è stato discusso.

Dover gestire centinaia o migliaia di container, che interagiscono tra loro, devono essere aggiornati, possono crashare etc. è complesso. Per questo motivo, vari sistemi che si occupano di organizzare applicazioni containerizzate sono stati sviluppati. Il più famoso tra questi è Kubernetes.

Senza entrare nel dettaglio, lo scopo principale di Kubernetes è quello di sapere quanti e quali container devono girare e far si che questo stato sia sempre soddisfatto (ad esempio, se un container crasha, un altro con la stessa immagine viene fatto girare). Esistono diversi sistemi di questo genere, e il primo con cui ci si trova ad avere a che fare è assai semplice e si chiama docker-compose. Mentre però docker-compose è un sistema che è pensato per orchestrare container su una singola macchina, software come Kubernetes sono pensati per fondere decine, centinaia o migliaia di macchine (server, macchine virtuali etc.) in un unico insieme di risorse che può essere gestito come si preferisce.

Alcuni casi utili per i container

Per concludere, voglio dare alcuni spunti su alcuni casi in cui usare container diventa particolarmente utile:

  • Quando un’applicazione è estremamente difficile da configurare. Molte applicazioni hanno questo problema; fare lo sforzo di costruire un’immagine una volta (nel caso qualcuno non l’abbia già fatto) pagherà decisamente in futuro, soprattutto nel caso di aggiornamenti, cambio di server eccetera.
  • Quando si vuole fare self-hosting. Questo è un altro tema enorme, ma molte persone che self-hostano le proprie applicazioni usano un setup che include un reverse proxy e vari container con le varie applicazioni. Questo rende il setup molto flessibile e soprattutto replicabile in pochi minuti su un’altra macchina, volendo cambiare server. Alcuni reverse proxy (come Traefik) interagiscono direttamente con Docker e semplificano ancora di più il tutto.
  • Quando sono necessarie più versioni della stessa applicazione allo stesso tempo. Per quanto sia possibile farlo in generale anche senza container, immaginiamo i possibili problemi: versioni di librerie incompatibili, conflitti su porte e chissà cos’altro. Con i container è invece una faccenda di pochi secondi, i container saranno indipendenti e ciascuno avrà la propria copia delle dipendenze, mentre tutte le applicazioni vivranno nell’illusione di usare la stessa porta che può essere mappata su porte diverse nell’host (ad esempio la porta 8080 dell’host punta alla 8080 del container1, mentre la porta 8081 dell’host punta alla 8080 del container 2).

Conclusione

L’argomento è vasto, quindi non possiamo dire di averlo coperto nemmeno in parte. Tuttavia spero che chiunque abbia letto questo testo e prima sapeva poco o nulla dei container, adesso ne abbia un’idea più chiara e potrà così iniziare a tuffarvici (e magari semplificarsi la vita nel frattempo).

Per riassumere ciò che è stato detto, lascerei questo tl;dr:

  • I container sono leggeri, molto più delle macchine virtuali, fornendo un certo grado di isolamento.
  • I container sono portabili, contengono un’applicazione con le sue dipendenze, e permettono di far girare applicazioni semplici o complesse allo stesso modo (o quasi).
  • I container non isolano quanto una macchina virtuale, e ciò comporta dei rischi.
  • Se si vuole provare un’applicazione, è molto più semplice far girare questa in un container che ‘sporcare’ il proprio sistema.