Tutto quello che avreste voluto sapere sui container (ma non avete mai osato chiedere, più o meno)

Chi si occupa di tecnologia incontra il termine container con una frequenza ormai quotidiana, da qualche anno a questa parte. Spesso viene accostato alle macchine virtuali, altre volte contrapposto, e ne risulta un pasticcio di termini (Docker, Kubernetes, immagini, layer, orchestrazione) usati spesso senza una reale distinzione. Provo a fare un po’ di ordine, partendo dalle basi e senza dare nulla per scontato, ma senza scendere nei dettagli sulle modalità operative; l’obiettivo è arrivare a un modello mentale semplice e corretto: capire che cos’è davvero un container, come riesce a isolare un’applicazione senza avere un sistema operativo completo, e perché questa caratteristica lo rende così utile.

Il problema di partenza: gestire le risorse di un computer

Un computer è fatto di hardware (CPU, memoria, disco, rete) e di un sistema operativo. Il cuore del sistema operativo è il kernel, quel pezzo di software che fa da ponte tra l’hardware e tutto il resto. Il kernel si occupa di eseguire i processi, gestire i dispositivi, leggere e scrivere dati, gestire i driver video; insomma: senza kernel il computer è un fermacarte. La parte del sistema operativo che non è kernel è lo spazio utente: l’ambiente dove girano i programmi che usiamo ogni giorno; essi comunicano continuamente con il kernel per fare richieste all’hardware.

Con il tempo, i computer sono diventati potentissimi; così potenti che, spesso, la CPU restava in attesa di lavoro, sottoutilizzata. È quindi diventato naturale far girare più carichi di lavoro sullo stesso computer, per sfruttare meglio le risorse. Ma questo ha subito portato a un problema: come fare per isolare i vari carichi di lavoro gli uni dagli altri? L’isolamento serve per diversi motivi:

  • Sicurezza: una vulnerabilità in un’applicazione non deve compromettere automaticamente l’intero sistema.
  • Compatibilità: due applicazioni possono richiedere versioni diverse delle stesse librerie, e quelle versioni potrebbero essere incompatibili tra loro.
  • Separazione: più servizi possono convivere sullo stesso host senza doverli trattare come un unico ambiente.

Un singolo computer con un singolo sistema operativo, quindi, non era sempre la risposta adeguata. Serviva un modo per dividere il computer in ambienti separati.

La prima risposta: le macchine virtuali

La prima soluzione diffusa è stata la virtualizzazione, con le macchine virtuali (VM). Una VM è essenzialmente un computer virtuale. Al suo interno ha un sistema operativo completo, con il suo kernel, e gira su uno strato software chiamato hypervisor. Il punto fondamentale è capire che cosa viene astratto. Una macchina virtuale astrae l’hardware: l’hypervisor presenta alla VM una CPU, della memoria, dei dischi, delle interfacce di rete e altri dispositivi virtuali. Il sistema operativo che gira nella VM non deve sapere quale sia l’hardware fisico sottostante; per lui, quella macchina virtuale è un computer. Le VM hanno risolto il problema dell’isolamento, ma hanno un costo: ogni VM ha il suo sistema operativo completo, con il suo kernel. Questo richiede memoria, CPU e storage e rende l’avvio più lento rispetto a un processo o a un container. Il vantaggio fondamentale delle VM è però proprio questo livello di astrazione: l’hardware fisico viene nascosto al sistema operativo guest, che può quindi essere diverso da quello dell’host. Una VM Windows può girare su un host Linux e una VM Linux può girare su un host Windows, perché ogni VM porta con sé il proprio kernel.

Con i container il livello di astrazione cambia completamente: non viene astratto l’hardware, ma l’ambiente operativo in cui i processi vedono ed utilizzano le risorse del sistema. Questa distinzione sarà importante quando arriveremo ai container.

La seconda risposta: i container

container affrontano lo stesso problema dell’isolamento con un approccio diverso. Non virtualizzano l’hardware e non portano con sé un kernel proprio. Isolano invece processi che vengono eseguiti direttamente sul kernel dell’host. Se la VM astrae l’hardware, il container può essere visto come un’astrazione a livello di sistema operativo. Il processo dentro il container non riceve un computer virtuale completo e continua a usare il kernel dell’host, ma il kernel gli presenta una vista isolata del sistema, con un proprio spazio dei processi, una propria rete, un proprio filesystem e, in base alla configurazione, una propria identità utente e limiti sulle risorse.

Questa è la differenza fondamentale:

  • Una VM ha il suo kernel e il suo sistema operativo completo.
  • Un container utilizza il kernel dell’host e contiene lo spazio utente e le librerie necessari all’applicazione.

Un container, quindi, non è una macchina virtuale più piccola. È un insieme di normali processi che il kernel tratta come appartenenti a un ambiente isolato. Questa architettura rende i container leggeri e veloci. Non devono avviare un sistema operativo completo, quindi possono essere creati e avviati molto rapidamente e permettono di concentrare molti più workload sullo stesso host rispetto a un equivalente basato su VM.

C’è però un compromesso fondamentale da considerare: la sicurezza. Poiché i container condividono il kernel dell’host, una vulnerabilità a livello di kernel o una configurazione errata può permettere a un processo di uscire dall’isolamento (container escape) e potenzialmente compromettere tutti i container presenti sulla macchina. Le VM, avendo un kernel completamente isolato, offrono un perimetro di sicurezza molto più rigido.

Come si ottiene l’isolamento

Il kernel Linux mette a disposizione due meccanismi fondamentali per costruire questo tipo di isolamento: namespaces e cgroups.

1. Namespaces, segregazione della vista

namespace determinano cosa un processo può vedere. Quando un processo viene inserito in un set di namespace dedicati, la sua visuale del sistema viene ristretta.

  • PID Namespace: il processo vede i processi appartenenti al proprio namespace e può vedere il processo iniziale del proprio ambiente come PID 1. Non vede normalmente i processi dell’host come se fossero parte del proprio ambiente.
  • NET Namespace: il container può avere una propria interfaccia di rete virtuale, la propria tabella di routing e i propri indirizzi IP e porte.
  • MNT Namespace: il processo può avere una propria vista del filesystem.
  • IPC / UTS / USER Namespaces: isolano rispettivamente la comunicazione tra processi, l’hostname e la mappatura degli utenti.

L’idea è importante: il processo continua a usare il kernel dell’host, ma il kernel gli presenta una vista limitata delle risorse del sistema.

2. Cgroups, controllo delle risorse

Se i namespace stabiliscono cosa un processo può vedere, i cgroups stabiliscono quanto può consumare. I cgroups consentono all’amministratore, o al runtime di container, di impostare limiti e controlli su:

  • Quantità di memoria RAM utilizzabile.
  • Utilizzo della CPU.
  • Banda e I/O.

Questo evita che un singolo workload possa consumare senza controllo tutte le risorse disponibili sull’host. Per esempio, se un container supera il proprio limite di memoria, il kernel può intervenire e terminare il processo che sta consumando memoria, proteggendo gli altri workload nella misura consentita dall’architettura e dalla configurazione del sistema. Quindi possiamo già costruire un modello mentale abbastanza preciso:

namespace = isolamento della vista

cgroups = controllo delle risorse

Ed è proprio la combinazione di questi meccanismi, insieme ad altri componenti del kernel e del runtime, a permettere di eseguire container senza creare una macchina virtuale per ogni workload.

In realtà namespaces e cgroups da soli non bastano: il kernel applica anche capability, seccomp e moduli di sicurezza per restringere cosa un processo può fare, ma non ne parliamo qui.

Nota a margine: Sebbene namespaces e cgroups siano tecnologie native del kernel Linux, il concetto di container si è esteso anche ad altri ecosistemi. Esistono i Windows Container che condividono il kernel di Windows nella modalità di isolamento standard (process), mentre quella Hyper-V usa un kernel dedicato. Per fare un esempio pratico, quando usi Docker su macOS, il sistema utilizza in realtà una leggerissima VM Linux nascosta per poter eseguire i container.

Immagini e Container

A questo punto bisogna distinguere due concetti che nel mondo Docker vengono spesso confusi: immagine e container. L’immagine è un artefatto statico che contiene l’applicazione, le librerie necessarie, i file di sistema e i metadati necessari a creare l’ambiente di esecuzione. Non contiene un kernel. Il container è un’istanza in esecuzione ottenuta a partire da un’immagine. Quando avvii un’immagine, il runtime prepara il filesystem, configura isolamento e risorse e avvia i processi dell’applicazione.

L’immagine è quindi il modello da cui si può creare un container. Il container è l’ambiente di runtime effettivamente in esecuzione. Questa distinzione è fondamentale perché consente di separare la costruzione dell’applicazione dalla sua esecuzione. Posso costruire un’immagine una volta, conservarla in un registry e utilizzarla per creare molte istanze dello stesso workload.

L’immagine Docker e i layer

Un’immagine Docker è composta da una serie di layer. Ogni layer rappresenta una modifica al filesystem e i layer vengono combinati per costruire la vista finale del filesystem disponibile al container. Questo ha alcuni vantaggi importanti.

  • Riutilizzo: se più immagini condividono gli stessi layer di base, questi possono essere memorizzati una sola volta.
  • Distribuzione efficiente: quando un’immagine viene trasferita, è possibile riutilizzare i layer già presenti invece di trasferire nuovamente tutto.
  • Costruzione incrementale: un Dockerfile può costruire l’immagine attraverso una sequenza di passaggi, rendendo più semplice il riutilizzo delle parti comuni.

Per esempio, un’immagine Docker per un’applicazione web potrebbe partire da un’immagine Linux di base (quindi una distribuzione completa a livello di spazio utente ma senza kernel), aggiungere i pacchetti necessari per PHP, poi Apache e infine i file dell’applicazione. Ogni passaggio può generare un nuovo layer dell’immagine. I layer vengono poi combinati per ottenere il filesystem finale visto dal container.

Quando il container viene avviato, il runtime aggiunge al filesystem dell’immagine un livello scrivibile associato a quella specifica istanza. Le modifiche effettuate durante la vita del container non modificano l’immagine originale. Per i dati che devono sopravvivere alla distruzione e ricreazione del container si usano invece i volumi o altri meccanismi di storage persistente.

Docker: il motivo del successo

A questo punto è importante chiarire una cosa: Docker non è sinonimo di container. I container Linux esistevano prima di Docker, ma Docker ha avuto un ruolo enorme nel renderli semplici da costruire, distribuire e utilizzare, creando un ecosistema coerente intorno alla tecnologia. È costruito su diversi componenti e standard del mondo Linux e dei container, tra cui runc e containerd, e ha aggiunto strumenti e convenzioni che ne hanno decretato il successo. Tra le caratteristiche principali ci sono:

  1. Facilità d’uso: Docker ha reso i container accessibili a sviluppatori, sistemisti e architetti. Con pochi comandi si costruisce un’immagine e si avvia un container.
  2. Portabilità: l’immagine contiene l’applicazione e le sue dipendenze in una forma standardizzata. Questo riduce drasticamente le differenze tra ambiente di sviluppo, test e produzione, anche se il risultato dipende sempre dall’ambiente di esecuzione sottostante.
  3. Registry: Docker Hub e gli altri registry permettono di pubblicare, distribuire e recuperare immagini container.
  4. Leggerezza e velocità: il container non deve avviare un sistema operativo completo.
  5. Modularità e scalabilità: Docker si presta bene a distribuire applicazioni composte da componenti separati, che possono essere aggiornati e scalati indipendentemente.

Docker: i concetti fondamentali

Per usare Docker, è utile conoscere alcuni concetti base:

  • Docker Engine: l’insieme dei componenti che permettono di costruire e gestire immagini e container, esponendo anche un’API attraverso cui gli strumenti possono interagire con il runtime.
  • Dockerfile: è un file di testo che contiene le istruzioni per costruire un’immagine Docker. Può installare pacchetti, copiare file, definire variabili d’ambiente, impostare il comando di avvio e altro ancora.
  • Immagine Docker: è il modello immutabile da cui vengono creati i container.
  • Container: è l’istanza in esecuzione dell’immagine, con il proprio isolamento e il proprio ciclo di vita.
  • Volumi: permettono di mantenere dati persistenti indipendentemente dal ciclo di vita del container.

Questa distinzione permette di capire anche un principio importante del mondo container: il container è normalmente sacrificabile. Se l’applicazione è correttamente progettata, posso distruggere un container e crearne un altro a partire dalla stessa immagine senza perdere i dati che devono essere persistenti.

Un’altra faccia dei container: Proxmox e gli LXC

Prima di immergerci nei microservizi e in Kubernetes, vale la pena fare un passo laterale e parlare di un altro tipo di container, che nel mio homelab uso quotidianamente: gli LXC di Proxmox. Proxmox VE è una piattaforma di virtualizzazione open source che integra due tecnologie:

  • KVM per le macchine virtuali, quindi virtualizzazione hardware.
  • LXC per i container, quindi isolamento a livello di sistema operativo.

LXC è una tecnologia matura per l’esecuzione di container Linux e sfrutta, tra le altre cose, namespace e cgroups. A differenza di Docker, non nasce principalmente come piattaforma per impacchettare e distribuire singole applicazioni. In Proxmox, un container LXC viene spesso trattato come un piccolo server Linux isolato, che ha un proprio filesystem, una propria configurazione di rete e i propri processi, ma condivide il kernel dell’host.

Perché usare LXC in Proxmox?

Nel mio homelab, su quattro nodi Proxmox, ho circa quaranta container LXC. Li uso per servizi che voglio tenere separati ma senza il peso di una VM: DNS, DHCP, piccoli webserver, tool di monitoring, ambienti di test, servizi di automazione. I vantaggi che ho trovato sono:

  • Leggerezza: un container LXC consuma poca RAM e si avvia rapidamente.
  • Semplicità di gestione: posso trattarlo in molti aspetti come una piccola macchina, con la sua interfaccia di rete, il suo storage e i suoi backup.
  • Integrazione nativa in Proxmox: posso gestirlo dalla stessa interfaccia delle VM, con backup, snapshot, migrazione live quando supportata dalla configurazione e template predefiniti.
  • Performance: non essendoci una virtualizzazione hardware completa, il costo dell’isolamento è ridotto.

LXC e Docker: due strumenti per esigenze diverse

È importante capire che LXC e Docker non sono semplicemente due implementazioni equivalenti dello stesso concetto. LXC tende a fornire un ambiente Linux isolato che può essere gestito come un piccolo server. Docker tende invece a concentrarsi sul packaging, sulla distribuzione e sull’esecuzione di applicazioni. In pratica:

AspettoLXC in ProxmoxDocker
CategoriaSystem ContainerApplication Container
ObiettivoFornire un ambiente Linux isolatoDistribuire ed eseguire applicazioni
Modello mentalePiù vicino a un piccolo server virtualePiù vicino al ciclo di vita del singolo servizio
FilesystemPersistente, parte integrante dell’ambienteDerivato dall’immagine (read-only) + storage persistente separato per i dati
GestioneIntegrata nativamente in ProxmoxGestita dal Docker Engine e dall’ecosistema container
Uso tipicoServizi di infrastruttura, DNS, DHCP, piccoli serverApplicazioni web, API, microservizi, pipeline CI/CD

Nel mio caso uso LXC per quello che una volta avrei probabilmente messo in una piccola VM, mentre uso Docker quando voglio distribuire un’applicazione in modo riproducibile e modulare. Le due tecnologie possono anche convivere. Per esempio, Docker può essere eseguito all’interno di un container LXC, anche se questa configurazione introduce ulteriori livelli di astrazione e va valutata in funzione delle esigenze di sicurezza, compatibilità e gestione.

Container, microservizi e orchestrazione

Uno degli usi più importanti dei container è l’implementazione di architetture a microservizi. Invece di avere un’unica applicazione monolitica, si suddivide l’applicazione in servizi indipendenti, ognuno con un compito specifico, e ogni microservizio può essere eseguito in un container.

Precisazione importante: microservizi e container sono due cose diverse, che si sposano bene ma non si implicano a vicenda. I microservizi sono un modo di progettare l’applicazione, dividendola in componenti indipendenti. I container sono un modo di eseguirla, isolando i processi. Puoi fare microservizi senza container, distribuendo i servizi come processi separati o in VM diverse, e puoi usare container senza microservizi, per esempio per impacchettare e distribuire un’applicazione monolitica. La combinazione è naturale, ma è una scelta, non una conseguenza automatica.

Questo modello permette di scalare separatamente i componenti: se il servizio che gestisce le immagini riceve molto più traffico degli altri, posso avviare più istanze di quel servizio senza dover replicare l’intera applicazione. Inoltre, i container si sposano bene con l’approccio DevOps, perché permettono di definire in modo riproducibile l’ambiente applicativo e di automatizzare gran parte del ciclo di build, test e deployment.

Quando però il numero dei container cresce, nasce un altro problema: chi li gestisce? Se devo gestire decine, centinaia o migliaia di container distribuiti su più macchine, non posso più pensare di avviarli e controllarli manualmente. Serve un orchestratore, che può occuparsi di:

  • Pianificare su quali macchine avviare i container.
  • Gestire aggiornamenti e rollback.
  • Riavviare automaticamente i workload che falliscono.
  • Distribuire il carico tra più istanze.
  • Gestire la comunicazione tra i diversi componenti.

Il principale orchestratore open source è Kubernetes. È uno strumento potente e complesso, progettato per gestire applicazioni containerizzate su larga scala. Esistono anche altri orchestratori, meno diffusi, come Nomad e Docker Swarm.

Ed è qui che il percorso si chiude: il container risolve il problema dell’esecuzione isolata di un workload. Docker semplifica la costruzione e la distribuzione di quel workload. Un orchestratore come Kubernetes affronta il problema successivo, cioè come gestire molti workload distribuiti su molte macchine.

Quindi, perché usare un container invece di una VM?

La domanda corretta non è “container o VM?”. La domanda è: che cosa devo isolare e che cosa devo distribuire?

  • Se devo eseguire un intero sistema operativo, con il suo kernel e il suo ambiente completo, una VM è la scelta naturale.
  • Se devo distribuire un’applicazione insieme alle librerie e ai componenti necessari per eseguirla, senza avere bisogno di un sistema operativo completo, un container può essere più adatto.
  • Se devo gestire centinaia o migliaia di workload containerizzati distribuiti su molti nodi, posso aggiungere un orchestratore.

Non sono quindi tecnologie necessariamente alternative. Possono occupare livelli diversi della stessa architettura.

Un esempio concreto: un server fisico può eseguire un hypervisor; l’hypervisor può eseguire una VM Linux; la VM può eseguire un runtime container; il runtime può eseguire decine di container; un orchestratore può distribuire quei container tra più VM e più host. Ogni livello risolve un problema diverso.

In una frase

Se dovessi spiegare un container a qualcuno che lavora nell’IT e non lo ha mai usato, direi questo:

Un container è un insieme di processi isolati che “girano” sul kernel dell’host, con un filesystem e una vista delle risorse propri, e che possono essere creati in modo riproducibile a partire da un’immagine.

La cosa importante da ricordare è quindi che un container non è una macchina virtuale più piccola. Una VM astrae l’hardware e presenta a un sistema operativo un computer virtuale completo. Un container astrae l’ambiente operativo e isola i processi senza fornire loro un kernel proprio. La VM virtualizza un computer. Il container isola processi. È questa differenza di livello di astrazione che spiega quasi tutto quello che viene dopo: perché i container sono leggeri, perché si avviano rapidamente, perché possono essere numerosi sullo stesso host, perché Docker è diventato così importante e perché, quando diventano abbastanza numerosi, entra in gioco Kubernetes.

Trivia

Da dove viene il nome “container”?

Il termine non nasce con Docker. L’idea del container software richiama direttamente i container utilizzati nel trasporto delle merci. Prima degli anni ’50, per caricare una nave servivano casse di legno, sacchi, botti, balle di stoffa. Ogni merce richiedeva un imballaggio e una manipolazione diversa. Poi arrivò il container marittimo standard: una scatola metallica dalle dimensioni fisse che poteva essere spostata indifferentemente da un camion, un treno o una nave, senza che il trasportatore dovesse preoccuparsi del contenuto. Nel software il principio è analogo: l’applicazione viene impacchettata insieme a ciò che le serve per funzionare e può essere trasferita tra ambienti diversi mantenendo lo stesso modello di esecuzione.

E Kubernetes? Qui c’è una curiosità linguistica: Kubernetes deriva dal greco κυβερνήτης, che indica il “timoniere”, il “comandante” o, più precisamente, colui che governa una nave. (E infatti il logo di Kubernetes è la ruota di un timone).

Lascia un commento

Questo sito utilizza Akismet per ridurre lo spam. Scopri come vengono elaborati i dati derivati dai commenti.