Docker, terza puntata: tre container e un hangar di Gundam

Ci sono persone che imparano una tecnologia costruendo una lista della spesa. Io trovo più motivante avere un hangar di robottini Gundam. Le dipendenze sono le stesse, ma almeno quando qualcosa esplode possiamo chiamarlo collaudo del reattore.

Costruiamo un sito che saluta il visitatore e, soltanto quando premi un pulsante, chiede i prodotti a un’API. I prodotti arrivano davvero da PostgreSQL: niente array travestiti da database con un paio di occhiali e una query disegnata sopra.

I tre container, con un lavoro ciascuno

web serve Vue.js tramite Nginx e inoltra /api/ a FastAPI. api espone saluto, prodotti e controllo di salute. db conserva i dati in PostgreSQL. Nginx vive nel container web: non stiamo introducendo un quarto inquilino di nascosto.

Browser → localhost:8080 → web (Nginx + Vue)
                            └─ /api/ → api (FastAPI) → db (PostgreSQL)

Il browser conosce solo localhost:8080. I nomi api e db valgono sulla rete Docker, non nel browser del visitatore. Il proxy mantiene un’unica origine, così non dobbiamo convocare una riunione diplomatica sul CORS.

Cartelle e password

Crea una cartella nuova, gundam-docker, con questa struttura. Tutti i comandi Compose vanno eseguiti dalla sua radice. Se la porta 8080 è ancora occupata dal test precedente, fermalo prima.

gundam-docker/
├── compose.yaml
├── .env
├── db/init.sql
├── api/Dockerfile
├── api/requirements.txt
├── api/main.py
├── web/Dockerfile
├── web/default.conf
└── web/index.html

Nel file .env scrivi DB_PASSWORD= seguito da una password locale lunga. Per generarla puoi usare openssl rand -hex 24. Inserisci .env nel .gitignore: il repository non deve diventare il portachiavi del laboratorio. Le variabili d’ambiente qui semplificano l’esempio; un deployment reale richiede una gestione dedicata dei segreti.

compose.yaml

name: gundam-hangar
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: hangar
      POSTGRES_USER: hangar
      POSTGRES_PASSWORD: ${DB_PASSWORD:?Definisci DB_PASSWORD nel file .env}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U hangar -d hangar"]
      interval: 5s
      timeout: 3s
      retries: 20
    networks: [backend]
  api:
    build: ./api
    environment:
      DB_HOST: db
      DB_NAME: hangar
      DB_USER: hangar
      DB_PASSWORD: ${DB_PASSWORD:?Definisci DB_PASSWORD nel file .env}
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
      interval: 5s
      timeout: 3s
      retries: 20
    networks: [frontend, backend]
  web:
    build: ./web
    ports:
      - "127.0.0.1:8080:80"
    depends_on:
      api:
        condition: service_healthy
    networks: [frontend]
volumes:
  pgdata:
networks:
  frontend:
  backend:
    internal: true

Pubblichiamo solo la porta web, e solo in locale. Nessuna porta host per API e database. La rete backend è interna e collega soltanto API e PostgreSQL. Il volume pgdata sopravvive alla rimozione dei container. Gli healthcheck fanno attendere il database e l’API prima di proseguire: l’ordine di avvio, da solo, non prova che un servizio sia pronto. Vedi la guida Compose alla readiness.

db/init.sql

CREATE TABLE products (
    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL,
    grade text NOT NULL,
    price numeric(10, 2) NOT NULL CHECK (price >= 0),
    stock integer NOT NULL CHECK (stock >= 0)
);
INSERT INTO products (name, grade, price, stock) VALUES
    ('RX-78-2 Gundam', 'HG 1/144', 24.90, 12),
    ('Wing Gundam Zero EW', 'MG 1/100', 64.90, 5),
    ('Gundam Aerial', 'HG 1/144', 29.90, 8);

Prezzi e quantità sono inventati: non sono offerte commerciali. I Gunpla sono modelli da montare, non robot da mandare a comprare il pane. Usiamo numeric per i prezzi e vincoli per impedire scorte negative: il magazzino quantistico lo lasciamo agli altri.

L’immagine postgres:17 esegue gli script di inizializzazione solo quando la directory dati è vuota. Modificare questo SQL dopo il primo avvio non aggiorna il database esistente. Per quello servono migrazioni o comandi SQL espliciti. Restiamo su PostgreSQL 17 e sul suo percorso dati: non cambiare major alla cieca, le versioni successive hanno differenze nel layout del volume. Riferimento: immagine ufficiale PostgreSQL.

api/requirements.txt

fastapi>=0.115,<1
uvicorn>=0.30,<1
psycopg[binary]>=3.2,<4

Questi intervalli permettono aggiornamenti compatibili senza fingere che il tutorial sia un lockfile. Per rilasci ripetibili blocca le dipendenze e i digest delle immagini dopo averle collaudate. Qui non uso latest e non dichiaro che questi siano i numeri più recenti.

api/Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser
COPY main.py .
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

api/main.py

import os
from decimal import Decimal

import psycopg
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Hangar Gundam", root_path="/api")

class Product(BaseModel):
    id: int
    name: str
    grade: str
    price: Decimal
    stock: int

def connect():
    return psycopg.connect(
        host=os.environ["DB_HOST"], dbname=os.environ["DB_NAME"],
        user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"],
        row_factory=dict_row, connect_timeout=3,
    )

@app.get("/hello")
def hello():
    return {"message": "Ciao, pilota! Benvenuto nell'hangar Gundam."}

@app.get("/health")
def health():
    try:
        with connect() as conn:
            conn.execute("SELECT 1").fetchone()
    except psycopg.Error:
        raise HTTPException(status_code=503, detail="Database non disponibile") from None
    return {"status": "ok"}

@app.get("/products", response_model=list[Product])
def products():
    try:
        with connect() as conn:
            return conn.execute(
                "SELECT id, name, grade, price, stock FROM products ORDER BY id"
            ).fetchall()
    except psycopg.Error:
        raise HTTPException(status_code=503, detail="Hangar temporaneamente chiuso") from None

Ogni richiesta apre e chiude la connessione con un context manager: per un piccolo laboratorio va bene; sotto carico useremmo un pool. Il modello Product descrive la risposta. Il prezzo Decimal viene serializzato come stringa decimale, evitando di promettere precisione contabile a un float. Le route sono /hello e /products; il prefisso esterno /api è comunicato a FastAPI con root_path, utile anche per Swagger.

web/Dockerfile

FROM node:22-alpine AS assets
WORKDIR /assets
RUN npm install --no-audit --no-fund --save-exact vue@3.5.13

FROM nginx:stable-alpine
COPY --from=assets /assets/node_modules/vue/dist/vue.global.prod.js /usr/share/nginx/html/vue.global.prod.js
COPY default.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html

La build usa Node per prendere Vue e copia il file nel risultato finale Nginx. Node non rimane in esecuzione: sempre tre container. Vue è servito localmente, quindi il visitatore non deve scaricarlo da una CDN. La scelta della versione è esplicita; aggiornala con un test, non con un incantesimo.

web/default.conf

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://api:8000/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location / { try_files $uri $uri/ =404; }
}

La barra finale in proxy_pass conta: /api/products viene inoltrato come /products. Se la togli, cambi il percorso che riceve FastAPI. Una barra. Ore della nostra vita. La documentazione Swagger sarà su http://localhost:8080/api/docs.

web/index.html

<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hangar Gundam</title>
<style>
body{font:18px system-ui;background:#101827;color:#edf2ff;max-width:900px;margin:3rem auto;padding:0 1rem}
button{background:#ffd54a;color:#101827;border:0;border-radius:6px;padding:1rem;font:inherit;cursor:pointer}
button:disabled{opacity:.5}li{padding:1rem;margin:1rem 0;background:#1e2b40;border-radius:8px}
.error{color:#ffb4ab}[v-cloak]{display:none}small{color:#b6c5de}
</style>
<script src="/vue.global.prod.js" defer></script>
</head>
<body>
<main id="app" v-cloak>
  <small>Tre container. Nessuna colonia spaziale richiesta.</small>
  <h1>Hangar Gundam</h1>
  <p aria-live="polite">{{ greeting }}</p>
  <button @click="loadProducts" :disabled="loading">
    {{ loading ? 'Apro il portellone…' : 'Mostra i robottini' }}
  </button>
  <p v-if="error" class="error" role="alert">{{ error }}</p>
  <p v-if="loaded && !products.length">Hangar vuoto. Sono tutti in missione.</p>
  <ul v-if="products.length">
    <li v-for="p in products" :key="p.id">
      <strong>{{ p.name }}</strong> — {{ p.grade }}<br>
      {{ euro(p.price) }} · Disponibili: {{ p.stock }}
    </li>
  </ul>
</main>
<noscript>Per pilotare questo hangar serve JavaScript.</noscript>
<script>
window.addEventListener('DOMContentLoaded', () => {
  const { createApp, ref, onMounted } = Vue;
  createApp({ setup() {
    const greeting = ref('Contatto la torre di controllo…');
    const products = ref([]), loading = ref(false), loaded = ref(false), error = ref('');
    async function request(path) {
      const response = await fetch(path, { signal: AbortSignal.timeout(8000) });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    }
    onMounted(async () => {
      try { greeting.value = (await request('/api/hello')).message; }
      catch { greeting.value = 'Ciao, pilota! La torre non risponde. Riprova tra poco.'; }
    });
    async function loadProducts() {
      loading.value = true; error.value = ''; loaded.value = false; products.value = [];
      try { products.value = await request('/api/products'); loaded.value = true; }
      catch { error.value = 'Robottini non disponibili: controlla API e database, poi riprova.'; }
      finally { loading.value = false; }
    }
    const euro = value => new Intl.NumberFormat('it-IT', {
      style: 'currency', currency: 'EUR'
    }).format(Number(value));
    return { greeting, products, loading, loaded, error, loadProducts, euro };
  }}).mount('#app');
});
</script>
</body>
</html>

All’apertura parte solo la richiesta di saluto. I prodotti arrivano al clic, con pulsante disabilitato durante l’attesa, timeout ed errore leggibile. Vue inserisce i nomi come testo, senza v-html. Il prezzo viene convertito a Number solo per la visualizzazione in euro: eventuali calcoli monetari restano un altro mestiere.

Avviamo l’hangar

sudo docker compose config --quiet
sudo docker compose up -d --build --wait
sudo docker compose ps
curl -fsS http://127.0.0.1:8080/api/hello
curl -fsS http://127.0.0.1:8080/api/products

Apri http://localhost:8080: vedrai il saluto. Premi «Mostra i robottini»: devono comparire RX-78-2, Wing Zero EW e Aerial con prezzi e disponibilità. Le due chiamate curl devono restituire rispettivamente un oggetto con message e una lista di tre prodotti. In Compose ps devono comparire web, api e db avviati; API e database devono risultare healthy.

Il controllo che distingue un database da una scenografia

sudo docker compose exec db psql -U hangar -d hangar -c "SELECT name, price, stock FROM products ORDER BY id;"
sudo docker compose exec db psql -U hangar -d hangar -c "UPDATE products SET stock = 11 WHERE id = 1;"
curl -fsS http://127.0.0.1:8080/api/products
sudo docker compose down
sudo docker compose up -d --wait
curl -fsS http://127.0.0.1:8080/api/products

La scorta del primo prodotto deve restare 11 anche dopo down e up. Abbiamo ricreato i container, ma il volume è rimasto. Se vuoi ripristinare il valore iniziale usa un UPDATE con stock = 12. Non è necessario abbattere l’intera colonia per spostare un robottino.

Quando il portellone non si apre

sudo docker compose logs --tail 80 api db web
sudo docker compose exec api python -c "import socket; print(socket.gethostbyname('db'))"

Un 502 suggerisce che il proxy non raggiunga l’API; un 503 indica che l’API non riesce a usare il database. Guarda i log prima di riscrivere tutto con un framework nuovo. Se cambi DB_PASSWORD dopo aver inizializzato il volume, PostgreSQL conserva la password precedente: il file .env non modifica da solo gli utenti già creati.

Per fermare il laboratorio basta sudo docker compose down. Il comando sudo docker compose down -v elimina anche i volumi del progetto e tutti i prodotti memorizzati: usalo soltanto se vuoi davvero ricominciare da zero. I Gunpla non si opporranno, ma il tuo inventario sì.

Cosa abbiamo costruito

Abbiamo un sito locale completo nel suo piccolo: interfaccia reattiva, API documentata e dati persistenti. Non è ancora un negozio: mancano autenticazione, carrello, backup verificati e HTTPS per un eventuale accesso pubblico. Ma il flusso browser → API → database è reale e ogni pezzo ha una responsabilità chiara.

Ed è una bella sensazione. Premi un bottone, una query si muove, un robottino compare. Nessuna slide motivazionale avrebbe potuto renderlo altrettanto soddisfacente. Adesso rimane il problema più difficile dell’intero progetto: convincersi che sulla mensola ci sia posto anche per il prossimo.

Riferimenti: avvio rapido Vue, FastAPI in Docker, proxy_pass di Nginx.