← tutti gli articoli

Azure

Come ho containerizzato la mia API e fatto il deploy su Azure

Un Dockerfile multi-stage, Docker Compose con un SQL Server con health check, e la Sports Betting API in esecuzione su Azure Container Apps con Azure SQL dietro.

Come ho containerizzato la mia API e fatto il deploy su Azure

La Sports Betting API è il primo progetto che ho portato dall'inizio, in esecuzione sulla mia macchina, fino a essere davvero raggiungibile su internet. Docker per il packaging, Azure Container Apps per l'hosting, Azure SQL Database dietro.

Il Dockerfile ha due stage, non uno

La versione ingenua di un Dockerfile .NET usa l'immagine dell'SDK e si ferma lì. Funziona, e produce un'immagine con l'intero SDK .NET dentro, che è molto più grande di quanto serva solo per eseguire la cosa.

Quindi il build avviene in uno stage e il risultato viene copiato in uno più piccolo:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# ... restore e publish ...
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "SportsBetting.API.dll"]

L'immagine finale è basata sul runtime ASP.NET e contiene solo l'output pubblicato. Lo stage dell'SDK fa il suo lavoro e viene buttato via.

Copiare i file di progetto prima del source

Questo è il dettaglio che non è ovvio e che ho sbagliato la prima volta. Ogni .csproj viene copiato per primo, poi gira dotnet restore, e solo dopo viene copiato il resto del source:

COPY SportsBetting.sln ./
COPY src/Backend/SportsBetting.API/SportsBetting.API.csproj src/Backend/SportsBetting.API/
COPY src/Backend/SportsBetting.Application/SportsBetting.Application.csproj src/Backend/SportsBetting.Application/
# ... il resto dei progetti ...

RUN dotnet restore
COPY . .

Docker mette in cache ogni layer e lo riusa se niente da cui quel layer dipende è cambiato. Dato che il restore dipende solo dai file di progetto, modificare un file C# non lo invalida, e i pacchetti non vengono scaricati di nuovo. Copiare tutto in anticipo e solo dopo fare il restore fa sì che ogni singola modifica al source riscarichi ogni pacchetto.

È un piccolo riordinamento che trasforma la maggior parte dei rebuild da lenti a quasi istantanei.

Compose, così il database parte insieme all'app

In locale l'API ha bisogno di SQL Server, e avviarli separatamente nell'ordine giusto stanca in fretta. Compose gestisce entrambi, e la parte utile è far aspettare all'API che il database sia davvero pronto e non solo avviato:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    healthcheck:
      test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "..." -Q "SELECT 1" -C
      interval: 10s
      retries: 5
      start_period: 30s

  api:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      sqlserver:
        condition: service_healthy

depends_on da solo aspetta solo che il container si avvii, e un container SQL Server accetta connessioni parecchio dopo. Abbinarlo a un health check che esegue davvero una query è ciò che fa la differenza tra l'API che parte pulita e che va in crash su una connessione fatta troppo presto.

Azure Container Apps

Per il deploy sono andato con Azure Container Apps invece di una VM o App Service. Esegue direttamente un'immagine container, che è esattamente quello che avevo già, e scala a zero quando niente la sta chiamando.

Scalare a zero ha una conseguenza visibile: il primo request dopo un periodo di inattività impiega qualche secondo mentre un container si avvia. Per un progetto di studio è uno scambio giusto, dato che non costa niente mentre resta inattivo. Per qualcosa con utenti reali terrei invece almeno una replica calda.

Il database è Azure SQL Database, l'opzione managed, quindi non sto mantenendo io stesso un'istanza di SQL Server. Configurazione e connection string arrivano da environment variable nel Container App, lo stesso meccanismo che Compose usa in locale, il che significa che l'immagine stessa è identica in entrambi i posti.

Cosa mi ha fatto davvero capire

La cosa che ho capito solo dopo averlo fatto: il container è l'unità deployabile, e l'ambiente è solo configurazione attorno a esso. La stessa immagine che costruisco ed eseguo in locale è quella che gira su Azure, con environment variable diverse puntate a infrastruttura diversa. Nessun build separato per la produzione, nessun codice che si comporta diversamente a seconda di dove gira.

Questa è la parte che ha fatto scattare l'idea di Docker per me. Non "impacchetta l'app", ma che la cosa che ho testato in locale è letteralmente la cosa che gira nel cloud.

Cosa farei dopo

Un GitHub Actions workflow che builda l'immagine e la deploya a ogni push su main, così fare il deploy non è qualcosa che faccio a mano. Questo è il divario ovvio tra questo e come funzionerebbe in un team vero.