Cosa sono i Docker container e come si usano le immagini di Docker
In questo articolo abbiamo presentato Docker il suo funzionamento, introducendo le componenti essenziali della sua architettura. Vediamo ora come si può lavorare con immagini e container Docker. Ovviamente, nella realtà, in AIknow, l’utilizzo di Docker arriva a livelli abbastanza complessi, pero se può essere d’aiuto, anche noi abbiamo iniziato da qui, speriamo che questa documentazione ti possa aiutare a intraprendere i primi passi nel mondo di Docker.
Creazione ed esecuzione di un docker container
Il processo di creazione ed esecuzione di un container si svolge solitamente secondo alcuni passi che presentiamo brevemente di seguito.
1.Creazione dell’immagine
- Lo sviluppatore definisce le specifiche dell’ambiente applicativo in un file chiamato Dockerfile. Questo file contiene istruzioni per la build di un’immagine.
- Utilizzando il comando docker build, Docker legge il Dockerfile, scarica le dipendenze necessarie e crea un’immagine.
2.Memorizzazione su Docker Registry
- L’immagine può essere memorizzata localmente o pubblicata su un registro Docker, come Docker Hub, mediante il comando docker push.
- Il registro serve come repository di immagini, accessibile sia agli sviluppatori che ai server di produzione.
3.Esecuzione del container
- Utilizzando il comando docker run, un container viene avviato da un’immagine.
- Durante l’esecuzione, il container ha un suo spazio del file system isolato, ma condivide il kernel del sistema operativo host.
4.Isolamento delle Risorse
- Docker utilizza caratteristiche del kernel Linux come i namespaces e i cgroups per isolare i processi appartenenti ai container. I namespaces assicurano che i processi all’interno di un container non siano visibili agli altri, mentre i cgroups limitano l’accesso alle risorse del sistema.
5.Networking
- Docker fornisce un modello di rete che consente ai container di comunicare tra loro e con il mondo esterno. Ogni container può avere il proprio indirizzo IP e porte assegnate.
Esempio
Usando Docker Desktop, presentiamo una breve guida all’esecuzione di un container. Scegliamo di eseguire un container ubuntu, disponibile su Docker Hub.
Come prima cosa, eseguiamo la pull dell’immagine ubuntu:latest:
docker pull ubuntu:latest
Se l’immagine ubuntu non è presente sulla macchina host, Docker la estrae dal registro configurato.
Ora possiamo eseguire il programma /bin/bash usando l’immagine scaricata:
docker run -i -t ubuntu:latest /bin/bash
- Docker crea un nuovo container.
- Docker assegna un filesystem di lettura-scrittura al container come layer finale. Ciò consente al container in esecuzione di creare o modificare file e directory nel suo filesystem locale.
- Docker crea un’interfaccia di rete per connettere il container alla rete predefinita, poiché non è stata specificata alcuna opzione di rete. Ciò include l’assegnazione di un indirizzo IP al container. Per impostazione predefinita, i container possono connettersi a reti esterne utilizzando la connessione di rete della macchina host.
- Docker avvia il container ed esegue il comando /bin/bash. Poiché il container viene eseguito in modo interattivo ed è collegato al terminale (grazie ai flag -i e -t), si può fornire input al container utilizzando la tastiera, mentre Docker invia l’output al terminale.
- Quando si esegue exit per terminare il comando /bin/bash, il container si arresta, ma non viene rimosso. È possibile avviarlo di nuovo o rimuoverlo.
Possiamo creare una nostra immagine Docker scrivendo un file chiamato Dockerfile. All’interno del file scriviamo:
FROM ubuntu:latest RUN echo "Hello" > /hello.txt CMD ["/bin/bash"]
La nostra immagine si baserà su ubuntu:latest, come specificato da FROM, personalizzandola.
Eseguiamo un comando con RUN: la direttiva cat “Hello” > /hello.txt crea un layer nella nostra immagine dove scriviamo Hello in un file posizionato in / (nel filesystem relativo del container) e chiamato hello.txt.
La nostra immagine usa come comando di avvio /bin/bash, il che ci permette di ottenere una shell Bash nel container, se questo viene avviato con i flag -i e -t.
Eseguiamo la build dell’immagine:
docker build -t my-ubuntu:latest .
Eseguiamo poi il comando:
docker run --rm my-ubuntu cat /hello.txt
Il flag –rm fa in modo che il container venga rimosso quando termina. Il comando cat /hello.txt ci farà visualizzare il contenuto del file /hello.txt creato nella nostra immagine.
Docker Compose: Gestione di Applicazioni Multi-container
Docker Compose è uno strumento che semplifica la gestione di applicazioni multi-container. Con Docker Compose, è possibile definire le configurazioni di più servizi, reti e volumi in un file YAML. Questo file descrive l’intera applicazione, consentendo la creazione e l’avvio di tutti i container con un singolo comando. È particolarmente utile per sviluppatori e team che gestiscono applicazioni con molteplici componenti.
Ecco un esempio di un file Docker Compose:
version: '3' services: web: image: nginx:latest database: image: mysql:latest environment: MYSQL_ROOT_PASSWORD: example
I nomi di default da dare al file sono compose.yml o docker-compose.yml. In questo modo, i comandi docker compose rilevano automaticamente la configurazione nel file. Facciamo riferimento alle più recenti funzionalità V2 (Compose V2 functionality, integrate nel 2023 nella CLI Docker, si veda qui per maggiori informazioni).
Con questo file, è possibile avviare contemporaneamente un servizio web basato su Nginx e un database MySQL utilizzando il comando docker compose up.
Se si desidera dare un nome diverso al file, è necessario poi specificare il file stesso quando si eseguono comandi docker compose. Ad esempio, se chiamassimo il file stack.yml, per eseguire le applicazioni dovremmo impartire il comando:
docker compose -f stack.yml up
dove -f stack.yml indica a Docker Compose di fare riferimento al file stack.yml.
Per un riferimento alle funzionalità di Compose nella Docker CLI (Command Line Interface) si veda qui.
Applicazione Multi-Container
Creiamo una semplice applicazione multi-container per comprendere le basi del workflow con Docker Compose. I primi step di questa procedura sono gli stessi che useremmo anche se scegliessimo di usare Docker Swarm (v. più avanti).
L’Applicazione
Creiamo un’applicazione che restituisce una risposta ad una chiamata GET a delle API. Rilasciamo le API dietro a un reverse proxy. L’idea di base è che il proxy gestisca la comunicazione con i client, eventuali certificati TLS (che qui non introduciamo) e possibilmente agisca da load balancer, mentre le API sono isolate. Eventualmente, accanto alle API possiamo avere un database, che sarebbe egualmente isolato nella rete Docker.
Potremmo anche avere più componenti API, ciascuna che si occupa di uno specifico compito. Insomma, l’esempio che introduciamo è la connessione di base su cui si potrebbe lavorare per ottenere un’architettura a micro-servizi.
API
Creiamo delle API che ci forniranno un esempio di applicazione backend. Usiamo NodeJS ed Express.
Nella directory api creiamo un file app.js con il codice sorgente delle API Express, la cui parte fondamentale è:
app.get( '/', (req, res) => { res.send('Example API!'); } );
Questo server API verrà messo in ascolto sulla porta TCP 3000, restituendo la stringa Example API! ad una chiamata HTTP GET al path /.
Creiamo ora un file che ci permette di generare un’immagine Docker che useremo poi per eseguire le nostre API: nella stessa directory api creiamo il file chiamato Dockerfile:
FROM node:16.13.2-alpine WORKDIR /usr/src/app COPY package.json . COPY app.js . RUN npm install EXPOSE 3000 CMD node app.js
Quali operazioni esegue il Dockerfile?
- Innanzitutto, specifichiamo nella direttiva FROM di partire da un’immagine già esistente, node:16.13.2-alpine, che ci permette di evitare di dover configurare noi stessi un server NodeJS.
- In seguito, nella direttiva WORKDIR indichiamo che la directory del filesystem containerizzato in cui si dovranno eseguire i comandi e salvare il codice è /usr/src/app.
- Eseguiamo poi il comando npm ci.
- Con COPY copiamo i file necessari della directory corrente (api) sul filesystem del container, nella directory precedentemente specificata con WORKDIR.
- Esponiamo la porta TCP 3000 con la specifica EXPOSE.
- Infine, indichiamo infine che il comando che il container dovrà eseguire è node app.js, il quale esegue le nostre API.
Proxy
Innanzitutto, configuriamo il nostro proxy, che sarà Nginx, creando nella directory proxy un file chiamato nginx.conf che configura il reverse proxy. La direttiva fondamentale è:
server { listen 8080 default_server; location / { proxy_pass http://api:3000/; } }
La configurazione qui presentata è chiaramente incompleta ed essenziale; non è adatta ad un ambiente di produzione. Si veda la documentazione di Nginx per maggiori indicazioni su come configurare il proxy server.
Senza addentrarci troppo nei dettagli, questo file configura Nginx per ascoltare sulla porta 8080 e girare tutte le richieste che riceve con location / all’URI http://api:3000/.
Docker Compose provvederà a risolvere il nome api nell’indirizzo della rete interna corrispondente alla componente API.
Creiamo ora un file chiamato Dockerfile che conterrà:
FROM nginx:1.23.4-alpine COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 8080
Vediamo la sintassi del Dockerfile:
- si inizia specificando un’immagine di base, in questo caso nginx:1.23.4-alpine, che è già configurata per l’utilizzo di un server Nginx (risparmiandoci così molto lavoro);
- proseguiamo copiando la nostra configurazione del server, contenuta nel file nginx.conf;
- infine indichiamo al container di esporre la porta 8080.
Docker Compose File
Come ultimo passo creiamo nella directory multi-container-app un file chiamato compose.yml e contenente la configurazione dello stack applicativo da eseguire:
version: '3' services: api: build: context: api dockerfile: Dockerfile proxy: build: context: proxy dockerfile: Dockerfile ports: - 8080:8080
Questo file indica a Docker Compose di eseguire due servizi: uno, chiamato api, che lancia un container basato sull’immagine indicata nel Dockerfile nella directory api (indicata mediante build e context), l’altro, chiamato proxy, lancia un container basato sull’immagine specificata nella cartella con lo stesso nome (il Dockerfile corrispondente indica come costruire quell’immagine). Infine, il servizio proxy expone la porta 8080 sulla stessa porta della macchina host (la macchina che esegue il Docker daemon).
Eseguiamo
Possiamo eseguire lo stack lanciando in un terminale (nella directory multi-container-app) il comando:
docker compose up
o, se preferiamo non bloccare lo stream in output con i log dei container, possiamo lanciare
docker compose up -d
Docker Compose individua automaticamente il file chiamato compose.yml ed esegue una build e una run dei container ivi indicati, creando una rete Docker isolata a cui essi hanno accesso.
La rete Docker dello stack permette ai container di comunicare tra loro, rimanendo comunque isolati dalla rete della macchina host: l’unico accesso ai container viene configurato mediante la direttiva ports associata al servizio proxy.
Un fatto molto comodo è che Docker Compose è in grado di eseguire autonomamente una build delle immagini necessarie, se ciò gli viene indicato dalla specifica build.
Se accediamo mediante un browser web alla pagina http://localhost:8000/, otteniamo la risposta della API:
Se abbiamo lanciato il comando docker compose up, possiamo vedere nei log dei container l’accesso al proxy:
✔ Container compose-proxy-1 Created 0.1s ✔ Container compose-api-1 Created 0.1s Attaching to api-1, proxy-1 api-1 | Listening on port [3000]... proxy-1 | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration proxy-1 | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/ proxy-1 | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh proxy-1 | 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf proxy-1 | 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf proxy-1 | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh proxy-1 | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh proxy-1 | /docker-entrypoint.sh: Configuration complete; ready for start up proxy-1 | 172.23.0.1 - - [08/Feb/2024:09:45:18 +0000] "GET / HTTP/1.1" 200 12 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" proxy-1 | 172.23.0.1 - - [08/Feb/2024:09:45:18 +0000] "GET /favicon.ico HTTP/1.1" 404 150 "http://localhost:8080/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
Docker Swarm: Orchestrazione Integrata
Docker Swarm è uno strumento di orchestrazione integrato in Docker, che consente di gestire e scalare i container su più nodi. Con Swarm, è possibile creare un cluster di host Docker e distribuire i container su di essi. Swarm gestisce la scalabilità, la disponibilità e la distribuzione automatica delle applicazioni.
Come funziona Docker Swarm?
Docker Swarm utilizza un approccio leader-follower, in cui un nodo leader coordina le attività degli altri nodi follower. È possibile definire i servizi desiderati in un file YAML (simile a quello di Docker Compose visto sopra, molte delle opzioni sono le stesse); utilizzando il comando docker stack, si possono distribuire i servizi su un cluster Swarm.
Swarm monitora costantemente lo stato dei servizi e garantisce che il numero desiderato di repliche sia sempre in esecuzione
Esempio
Possiamo eseguire lo stack precedentemente presentato usando Docker Swarm. Vi sono alcune piccole differenze da tenere in conto: i comandi dovranno essere lanciati da un nodo orchestrator, come spiegato qui.
Inoltre, dobbiamo eseguire una build delle immagini Docker usando il comando
docker build -t [image_name]:[image_tag] [build_context]
Inoltre, dovremo modificare il compose.yml specificando invece della direttiva build la direttiva image, che indica le immagini Docker da usare per creare i servizi: se, da un lato, Docker Compose è in grado di eseguire automaticamente una build delle immagini, Swarm necessita di immagini Docker già pronte.
Poi, invece di lanciare docker compose up, eseguiamo in un terminale il comando docker stack deploy indicando le opzioni necessarie (si veda qui per maggiori informazioni).
Kubernetes: Orchestrazione di Docker Container su Larga Scala
Kubernetes è un sistema open-source per l’orchestrazione automatizzata, la distribuzione e la gestione di container. Anche se può sembrare simile a Docker Swarm, Kubernetes è più potente e adatto a ambienti più complessi e su larga scala.
Caratteristiche di Kubernetes:
- Orchestrazione avanzata: Kubernetes gestisce la distribuzione, l’aggiornamento e il rollback delle applicazioni containerizzate su un cluster di nodi.
- Scalabilità: Kubernetes può scalare *automaticamente* i container in base alle esigenze del carico di lavoro.
- Gestione dello stato: Kubernetes supporta applicazioni con stato, consentendo la gestione di database e altri servizi che richiedono persistenza.
- Gestione delle risorse: Kubernetes assegna risorse ai container, monitora le prestazioni e bilancia il carico per garantire un utilizzo efficiente delle risorse.
Conclusione
In conclusione, Docker e le sue tecnologie correlate hanno cambiato il modo in cui si sviluppano, distribuiscono e gestiscono le applicazioni. L’uso di container offre maggiore portabilità e scalabilità.
Se si desidera semplificare la gestione dei container su un singolo host, Docker Compose è la scelta ideale.
Per ambienti più complessi e su larga scala, Docker Swarm e Kubernetes forniscono potenti strumenti di orchestrazione.
La scelta tra queste tecnologie dipende dalle esigenze specifiche del progetto e dalla scala di implementazione desiderata.