Containerizzazione con Docker e deployment di applicazioni .NET
Concludiamo questa quarta serie, e con essa l'intero percorso, portando la nostra applicazione dove deve arrivare: in esecuzione su un server. Il modo moderno di distribuire il software è la containerizzazione, che impacchetta l'applicazione insieme a tutte le sue dipendenze in un'unità isolata e portabile. Docker è lo strumento di riferimento per farlo, e .NET è progettato per integrarsi perfettamente con esso. In questo articolo vediamo come containerizzare un'applicazione .NET e i concetti fondamentali del deployment.
Perché i container
Il problema che i container risolvono è tanto vecchio quanto diffuso: un'applicazione funziona sulla macchina dello sviluppatore ma non su quella di produzione, per differenze nell'ambiente, nelle versioni installate o nella configurazione. Un container impacchetta l'applicazione con il runtime e le librerie di cui ha bisogno, garantendo che si comporti in modo identico ovunque venga eseguito. Rispetto alle macchine virtuali, i container sono leggeri e si avviano in pochi istanti, condividendo il kernel del sistema ospite.
Il concetto di immagine e container
Due termini vanno distinti fin da subito. Un'immagine è un modello immutabile che contiene tutto il necessario per eseguire l'applicazione: il codice compilato, il runtime, le dipendenze. Un container è un'istanza in esecuzione di un'immagine. La relazione ricalca quella tra classe e oggetto vista nella programmazione orientata agli oggetti: l'immagine è il progetto, il container l'esemplare vivo.
Il Dockerfile
Un'immagine si costruisce a partire da un Dockerfile, un file di testo che descrive passo dopo passo come assemblarla. Per le applicazioni .NET si adotta una costruzione a più stadi: un primo stadio compila l'applicazione usando l'SDK completo, un secondo stadio ne copia solo il risultato in un'immagine più leggera contenente il solo runtime. Questo riduce sensibilmente la dimensione finale.
# Primo stadio: compilazione con l'SDK completo
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copia il file di progetto e ripristina le dipendenze
COPY *.csproj ./
RUN dotnet restore
# Copia il resto del codice e pubblica l'applicazione
COPY . ./
RUN dotnet publish -c Release -o /app/publish
# Secondo stadio: immagine leggera con il solo runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish ./
# Comando eseguito all'avvio del container
ENTRYPOINT ["dotnet", "MyApp.dll"]
La separazione tra il ripristino delle dipendenze e la copia del codice non è casuale: sfrutta la cache di Docker. Finché il file di progetto non cambia, il livello con le dipendenze ripristinate viene riutilizzato, accelerando le successive costruzioni quando si modifica solo il codice.
Costruire ed eseguire l'immagine
Con il Dockerfile pronto, si costruisce l'immagine con il comando build, assegnandole un nome tramite un tag. Si esegue poi un container a partire da essa, mappando le porte per renderla raggiungibile dall'esterno.
# Costruisce l'immagine e le assegna un nome
docker build -t myapp:latest .
# Avvia un container mappando la porta 8080 dell'host sulla 8080 del container
docker run -p 8080:8080 myapp:latest
Configurare tramite variabili d'ambiente
Qui torna utile il sistema di configurazione dell'articolo precedente. Poiché le variabili d'ambiente hanno priorità sui file, si passano al container i valori specifici dell'ambiente di esecuzione senza modificare l'immagine. Questo permette di usare la stessa immagine in collaudo e in produzione, cambiando solo la configurazione al momento dell'avvio.
docker run -p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ConnectionStrings__Default="Data Source=/data/app.db" \
myapp:latest
Da notare la convenzione: il doppio underscore nelle variabili d'ambiente corrisponde ai due punti nella struttura gerarchica della configurazione, così ConnectionStrings__Default valorizza la chiave ConnectionStrings:Default.
Orchestrare più container
Un'applicazione reale è spesso composta da più servizi: l'API, un database, una cache. Docker Compose permette di descrivere l'insieme di questi container e le loro relazioni in un unico file, avviandoli e collegandoli con un solo comando. È lo strumento ideale per l'ambiente di sviluppo e per deployment semplici.
services:
api:
build: .
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
depends_on:
- database
database:
image: postgres:16
environment:
- POSTGRES_PASSWORD=secret
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Per ambienti di produzione su larga scala, con molti container distribuiti su più macchine, si ricorre a sistemi di orchestrazione più avanzati come Kubernetes, che gestiscono automaticamente scalabilità, bilanciamento del carico e ripristino dai guasti. Sono un mondo a sé, ma poggiano sugli stessi container che abbiamo imparato a costruire.
Buone pratiche di deployment
Alcuni principi accompagnano un deployment solido:
- Mantenere le immagini piccole con la costruzione a più stadi, includendo solo ciò che serve in produzione.
- Non inserire mai segreti come password o chiavi nell'immagine: passarli tramite variabili d'ambiente o gestori di segreti dedicati.
- Assegnare tag di versione precisi alle immagini, evitando di affidarsi solo a
latest, per sapere sempre cosa è in esecuzione. - Prevedere endpoint di controllo dello stato di salute, che permettono agli orchestratori di verificare che l'applicazione sia operativa.
Conclusione
Abbiamo containerizzato un'applicazione .NET con Docker, comprendendo la differenza tra immagini e container, scrivendo un Dockerfile a più stadi per immagini compatte, costruendo ed eseguendo l'immagine e configurandola tramite variabili d'ambiente. Abbiamo introdotto Docker Compose per orchestrare più servizi e accennato a Kubernetes per la produzione su larga scala.
Con questo si chiude non solo la serie su prestazioni e produzione, ma l'intero percorso di ventotto articoli su C#. Siamo partiti dall'installazione dell'SDK e dal primo programma, abbiamo attraversato i fondamenti del linguaggio, la programmazione orientata agli oggetti, i costrutti intermedi come generics, LINQ e asincronia, i principi di progettazione e i framework applicativi, per arrivare oggi a distribuire un'applicazione completa in un container. C# è un linguaggio vasto e in continua evoluzione, ma le fondamenta costruite lungo questo cammino offrono una base solida per affrontare qualsiasi direzione si scelga di esplorare, dallo sviluppo di giochi al cloud, dai servizi ad alte prestazioni alle applicazioni desktop e mobile.