Caddy come Load Balancer e strategie di fallback

Quando si costruiscono applicazioni web, si arriva inevitabilmente a un punto in cui un singolo server non è più sufficiente. Serve ridondanza, e questo significa avere un load balancer.

Sebbene esistano molte soluzioni enterprise molto avanzate, Caddy è diventato una scelta estremamente interessante. È scritto in Go, gestisce automaticamente HTTPS e include un reverse proxy e load balancer sorprendentemente robusto.

Ma leggere la documentazione non basta per fidarsi davvero. Per capire come la tua infrastruttura gestisce i guasti, dobbiamo romperla di proposito. In questo articolo costruiremo un piccolo laboratorio usando Podman, FastAPI e Caddy. Configureremo un load balancer e testeremo cosa succede quando un backend va in crash, quando un servizio diventa lento e come instradare il traffico in modo più intelligente.

Setup: costruiamo il laboratorio

Per vedere Caddy in azione, abbiamo bisogno di una semplice applicazione su cui bilanciare il traffico. Useremo una leggera app Python con FastAPI che restituisce quale backend ha risposto alla richiesta.

Ecco il nostro codice applicativo (app.py)

				
					from fastapi import FastAPI
import os
import time

app = FastAPI()
backend_id = os.getenv("BACKEND_ID", "Unknown")

@app.get("/")
def health_check():
    return {"status": "ok", "backend": backend_id}

@app.get("/test-api")
def test_api():
    print(f"DEBUG: Request received by Backend {backend_id}")
    return {"message": f"Hello, I'm BACKEND_{backend_id}"}
    
# We will use this later for our "delayed" scenario
@app.get("/slow-api")
def slow_api():
    # Only make Backend 2 simulate a hang, leaving Backend 1 healthy
    if backend_id == "2":
        time.sleep(5) 
        return {"message": f"Slow response from BACKEND_{backend_id}"}
        
    return {"message": f"Normal response from BACKEND_{backend_id}"}

				
			

Per eseguirlo abbiamo bisogno semplicemente di un Containerfile:

				
					FROM python:3.11-slim
RUN pip install fastapi uvicorn
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

				
			
E un file compose.yml per orchestrare Caddy insieme a due istanze di backend:
				
					services:
  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
    depends_on:
      - backend1
      - backend2

  backend1:
    build: .
    environment:
      - BACKEND_ID=1

  backend2:
    build: .
    environment:
      - BACKEND_ID=2

				
			

Il caso ideale (Happy Path)

Diamo un’occhiata alla configurazione iniziale (Caddyfile) Questa configurazione dice a Caddy di ascoltare sulla porta 80 e distribuire il traffico equamente tra i due backend usando una policy round-robin.

				
					:80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy round_robin

        # Retry once if a connection fails
        lb_retries 1
        
        # Active Health Checks
        health_uri /
        health_interval 30s
        health_status 200
        
        # Passive Health Check: How long to remember a node is dead
        fail_duration 10s
    }

    log {
        output stdout
    }
}

				
			

Se avviamo tutto e facciamo richieste ripetute all’endpoint, vedremo le risposte alternarsi in modo uniforme tra il primo e il secondo backend.

				
					starting test
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200
{"message":"Hello, I'm BACKEND_2"} 200
{"message":"Hello, I'm BACKEND_1"} 200

				
			

Tutto funziona perfettamente.


Cosa succede quando un servizio va in crash?

Il vero test di un load balancer non è come gestisce il successo, ma come gestisce i fallimenti. Cosa succede se il secondo backend smette completamente di funzionare?

Utilizziamo uno script bash (test.sh) per scoprirlo: aspettiamo che i controlli di salute entrino in funzione, fermiamo intenzionalmente uno dei container e poi inviamo alcune richieste.

				
					#!/usr/bin/env bash

echo "Starting failover test..."
sleep 32 

echo "Stopping backend2..."
docker stop caddy-load-balancer-test-backend2-1

echo "Firing requests immediately after crash..."
for i in {1..4}; do
    curl -s -w " HTTP: %{http_code}\n" http://localhost/test-api
done

echo "Waiting 10 seconds for Caddy's fail_duration..."
sleep 10

echo "Firing requests after fail_duration expires..."
for i in {1..4}; do
    curl -s -w " HTTP: %{http_code}\n" http://localhost/test-api
done

echo "Restarting backend2..."
docker start caddy-load-balancer-test-backend2-1
sleep 2 

echo "Firing requests after recovery..."
for i in {1..4}; do
    curl -s -w " HTTP: %{http_code}\n" http://localhost/test-api
done

				
			

Quando eseguiamo questo test, succede qualcosa di molto interessante: l’utente finale non vede mai errori.

				
					Starting failover test...
Stopping backend2...
caddy-load-balancer-test-backend2-1
Firing requests immediately after crash...
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
Waiting 10 seconds for Caddy's fail_duration...
Firing requests after fail_duration expires...
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
Restarting backend2...
caddy-load-balancer-test-backend2-1
Firing requests after recovery...
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_2"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_2"} HTTP: 200

				
			

Quando il secondo backend va giù, la prima richiesta che Caddy gli invia fallisce. Grazie alla configurazione, Caddy intercetta l’errore, ritenta automaticamente la richiesta sul primo backend e restituisce una risposta corretta.

Successivamente, Caddy segna il backend come non disponibile per un certo periodo e instrada tutto il traffico verso quello sano. Dopo questo intervallo, riprova a usarlo e, se è tornato disponibile, lo reintegra automaticamente.


Il caso del servizio lento

Un errore immediato è facile da gestire. Ma cosa succede se un nodo è online ma lento? Ad esempio, potrebbe essere bloccato su una query al database, causando ritardi di diversi secondi.

Di default, Caddy aspetta pazientemente la risposta del backend. Questo significa che, se un backend è lento, parte degli utenti subirà latenze elevate.

				
					80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy round_robin
        lb_try_duration 2s
        
        health_uri /
        health_interval 30s
        health_status 200
        fail_duration 10s
    }
}

				
			

Possiamo migliorare questo comportamento impostando un limite di tempo: se il backend non risponde entro una certa soglia, la richiesta viene ritentata su un altro nodo.

In questo modo, anche se un backend è lento, l’utente riceve comunque una risposta veloce, evitando che un singolo nodo degradi l’esperienza complessiva.

				
					Starting failover test...
caddy-load-balancer-test-backend2-1
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200
{"message":"Hello, I'm BACKEND_1"} HTTP: 200

				
			

Oltre il Round-Robin: altre tecniche di routing

Il round-robin è il punto di partenza ideale perché è completamente prevedibile, ma è anche “cieco”. Distribuisce semplicemente il traffico come se stesse distribuendo carte. A seconda della tua architettura, potresti aver bisogno che Caddy instradi il traffico in modo più intelligente. Puoi trovare tutte le opzioni disponibili nella documentazione ufficiale del load balancing del reverse proxy di Caddy. Ecco alcune alternative e come inserirle nel tuo Caddyfile:

Least Connections (least_conn)

Invece di inviare ciecamente ogni seconda richiesta a un server specifico, Caddy controlla quante richieste attive e non ancora completate ogni backend sta gestendo in quel momento. Instrada quindi le nuove richieste verso il server meno occupato. Se un server è appesantito dalla generazione di un report pesante, Caddy indirizzerà naturalmente le nuove richieste, più leggere, verso gli altri server inattivi, evitando congestioni.

				
					:80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy least_conn
        
        # Active Health Checks
        health_uri /
        health_interval 2s
        health_timeout 2s
        health_status 200
        
        fail_duration 10s
    }
}

				
			

Cosa succede se un server è lento? Proviamo con questo script di test:

				
					#!/usr/bin/env bash

echo "Starting Least Connections test..."

# We fire 4 simultaneous requests to absolutely guarantee Backend 2 
# catches at least one and gets "jammed" for 5 seconds.
echo "Triggering a traffic jam in the background..."
(curl -s http://localhost/slow-api; echo " <-- Background request") &
(curl -s http://localhost/slow-api; echo " <-- Background request") &
(curl -s http://localhost/slow-api; echo " <-- Background request") &
(curl -s http://localhost/slow-api; echo " <-- Background request") &

# Wait a full second to ensure the connections are registered by Caddy
sleep 1

echo "Firing 6 rapid-fire requests to the fast endpoint..."
# Because Backend 2 is now definitely stuck, Caddy will route ALL of these to Backend 1.
for i in {1..6}; do
    curl -s http://localhost/test-api
    echo "" 
done

echo "Waiting for the jammed background requests to finally clear..."
wait
echo "Test complete."


Starting Least Connections test...
Triggering a traffic jam in the background...
{"message":"Normal response from BACKEND_1"}{"message":"Normal response from BACKEND_1"} <-- Background request
 <-- Background request
Firing 6 rapid-fire requests to the fast endpoint...
{"message":"Hello, I'm BACKEND_1"}
{"message":"Hello, I'm BACKEND_1"}
{"message":"Hello, I'm BACKEND_1"}
{"message":"Hello, I'm BACKEND_1"}
{"message":"Hello, I'm BACKEND_1"}
{"message":"Hello, I'm BACKEND_1"}
Waiting for the jammed background requests to finally clear...
{"message":"Slow response from BACKEND_2"}{"message":"Slow response from BACKEND_2"} <-- Background request
 <-- Background request
Test complete.


				
			

Nel frattempo, il Backend 2 è bloccato nell’esecuzione del comando time.sleep(5). Al momento ha 2 connessioni attive in sospeso.

Una frazione di secondo dopo, bombardiamo il server con 6 nuove richieste. Se stessimo usando il round_robin, Caddy ne invierebbe ciecamente 3 al Backend 2, costringendo quegli utenti ad aspettare in coda. Ma dato che stiamo usando least_conn, Caddy controlla la situazione: vede che il Backend 2 è occupato (2 connessioni) mentre il Backend 1 è completamente libero (0 connessioni). A questo punto instrada in modo aggressivo il 100% del nuovo traffico verso il Backend 1, proteggendo completamente l’utente dal server lento.

IP Hash (ip_hash)

Se la tua applicazione memorizza le sessioni utente nella memoria locale del server (come uno stato di login), il round-robin può rompere il funzionamento dell’app. Se un utente effettua il login sul primo server e la richiesta successiva finisce sul secondo, risulterà improvvisamente disconnesso.

IP Hash risolve questo problema utilizzando l’indirizzo IP dell’utente per associarlo a uno specifico backend. Garantisce che, finché l’IP dell’utente non cambia, tutte le richieste verranno indirizzate allo stesso server. Un approccio ancora migliore è utilizzare sessioni basate su cookie.

				
					:80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy ip_hash
        
        health_uri /
        health_interval 30s
        health_status 200
        fail_duration 10s
    }
}

				
			

Sticky Sessions tramite Cookie (cookie)

Sebbene l’IP Hash sia utile, può risultare poco affidabile se centinaia di utenti condividono lo stesso indirizzo IP dietro un firewall aziendale, oppure se l’IP di una rete mobile cambia dinamicamente.

La policy basata su cookie è il modo più robusto per gestire applicazioni con stato. Caddy inserisce uno speciale cookie di sessione nel browser dell’utente durante la prima visita. Per tutte le richieste successive, Caddy legge quel cookie e garantisce che l’utente venga instradato direttamente verso lo stesso server che contiene i dati della sua sessione.

				
					:80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy cookie sticky_session_id
        
        health_uri /
        health_interval 30s
        health_status 200
        fail_duration 10s
    }
}

				
			

Random (random)

È interessante notare che, se non si specifica alcuna policy di load balancing nel Caddyfile, il comportamento predefinito è proprio random. In pratica, seleziona un backend completamente a caso.

Questa cosa mi ha incuriosito, quindi mi sono chiesto il perché. Ho scoperto che, mentre il round-robin è più adatto per configurazioni piccole (come il nostro laboratorio a due nodi), il routing casuale è in realtà estremamente veloce e richiede un overhead computazionale molto ridotto quando si ha a che fare con grandi cluster di microservizi identici.

Per questo motivo, può avere senso in applicazioni stateless su larga scala.

				
					:80 {
    reverse_proxy backend1:8080 backend2:8080 {
        lb_policy random
        
        health_uri /
        health_interval 30s
        health_status 200
        fail_duration 10s
    }
}

				
			

Conclusione

Il load balancing serve a proteggere gli utenti dai momenti inevitabilmente critici dell’infrastruttura. Come abbiamo visto in questo laboratorio, Caddy rende la configurazione di questa resilienza estremamente semplice. Con poche righe di configurazione è possibile costruire un sistema che gestisce in modo elegante crash improvvisi, mitiga problemi di rete silenziosi e instrada il traffico in modo intelligente in base alle esigenze specifiche dell’applicazione.

Ultimi articoli

Guida tecnica alla generazione dei DICOM UID

Programma per visualizzare immagini DICOM

DICOM Standard: cos’è, come funziona e perché è fondamentale nell’imaging medico

intelligenza artificiale nella diagnostica per immagini

Intelligenza artificiale nella diagnostica per immagini: come cambia la radiologia

area contatti

Per informazioni, progetti, idee, scrivici