Servire un'applicazione Go con nginx

Go è un linguaggio compilato e staticamente tipizzato che si presta molto bene alla scrittura di server HTTP ad alte prestazioni. Il suo pacchetto standard net/http include un server HTTP completo, capace di gestire migliaia di connessioni concorrenti senza dipendenze esterne. Tuttavia, in un ambiente di produzione, esporre direttamente il processo Go a Internet non è una pratica consigliata. nginx, server web e reverse proxy tra i più diffusi al mondo, si interpone tra il client e l'applicazione Go, aggiungendo un livello di sicurezza, flessibilità e prestazioni difficile da replicare manualmente.

In questo articolo vedremo come strutturare un'applicazione Go che espone un server HTTP, come configurare nginx come reverse proxy, come gestire i certificati TLS, e come avviare il tutto in modo affidabile usando systemd.

Architettura generale

Il modello più comune prevede che l'applicazione Go ascolti su una porta locale non privilegiata, tipicamente 127.0.0.1:8080, e che nginx ascolti sulle porte 80 e 443 esposte a Internet. nginx riceve le richieste HTTP/HTTPS dai client, le inoltra all'applicazione Go tramite connessione TCP locale, e restituisce al client la risposta prodotta dall'applicazione.

Questo schema offre diversi vantaggi concreti:

  • nginx gestisce la terminazione TLS, sollevando l'applicazione Go da questa responsabilità.
  • nginx può servire file statici direttamente dal filesystem, senza coinvolgere il processo Go.
  • nginx applica rate limiting, compressione gzip, intestazioni di sicurezza e caching a livello di proxy.
  • L'applicazione Go non ha bisogno di privilegi di root per ascoltare su porte basse.
  • nginx può distribuire il traffico su più istanze dell'applicazione Go (load balancing).

Scrivere l'applicazione Go

Partiamo da una struttura di progetto minima ma realistica. Creiamo una directory di progetto e inizializziamo il modulo Go:

# Creazione della directory e inizializzazione del modulo
mkdir goapp
cd goapp
go mod init example.com/goapp

La struttura del progetto sarà la seguente:

goapp/
  cmd/
    server/
      main.go
  internal/
    handler/
      handler.go
  go.mod

Definiamo prima il package degli handler:

// internal/handler/handler.go

package handler

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

// Response rappresenta la struttura della risposta JSON
type Response struct {
    Message   string    `json:"message"`
    Timestamp time.Time `json:"timestamp"`
}

// NewRouter costruisce e restituisce un nuovo ServeMux con le route registrate
func NewRouter() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("/", handleIndex)
    mux.HandleFunc("/api/status", handleStatus)
    return mux
}

// handleIndex risponde alle richieste sulla route principale
func handleIndex(w http.ResponseWriter, r *http.Request) {
    // Restituiamo 404 per qualsiasi path diverso da "/"
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    _, err := w.Write([]byte("Benvenuto nell'applicazione Go.\n"))
    if err != nil {
        log.Printf("Errore durante la scrittura della risposta: %v", err)
    }
}

// handleStatus risponde con un oggetto JSON che indica lo stato del servizio
func handleStatus(w http.ResponseWriter, r *http.Request) {
    // Accettiamo solo il metodo GET
    if r.Method != http.MethodGet {
        http.Error(w, "Metodo non consentito", http.StatusMethodNotAllowed)
        return
    }
    resp := Response{
        Message:   "ok",
        Timestamp: time.Now().UTC(),
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(resp); err != nil {
        log.Printf("Errore durante la codifica JSON: %v", err)
    }
}

Passiamo ora al punto di ingresso del programma:

// cmd/server/main.go

package main

import (
    "context"
    "errors"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "example.com/goapp/internal/handler"
)

func main() {
    // Leggiamo l'indirizzo di ascolto dalla variabile d'ambiente, con valore di default
    addr := os.Getenv("LISTEN_ADDR")
    if addr == "" {
        addr = "127.0.0.1:8080"
    }

    router := handler.NewRouter()

    server := &http.Server{
        Addr:         addr,
        Handler:      router,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Canale per i segnali del sistema operativo
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    // Avviamo il server in una goroutine separata
    go func() {
        log.Printf("Server in ascolto su %s", addr)
        if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("Errore del server: %v", err)
        }
    }()

    // Blocchiamo fino alla ricezione di un segnale
    sig := <-quit
    log.Printf("Segnale ricevuto: %v. Avvio dello shutdown graceful.", sig)

    // Concediamo al server 30 secondi per completare le connessioni attive
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Shutdown forzato: %v", err)
    }

    log.Println("Server terminato correttamente.")
}

La gestione del graceful shutdown è fondamentale in produzione: quando il processo riceve SIGTERM (come avviene durante un riavvio del servizio o un aggiornamento), il server smette di accettare nuove connessioni e attende che quelle esistenti vengano completate entro il timeout configurato, evitando risposte troncate ai client.

Compilare e installare il binario

Compiliamo il binario per la piattaforma di destinazione. Su un server Linux a 64 bit:

# Compilazione ottimizzata per la produzione
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o goapp ./cmd/server

# Copia del binario nella directory di sistema
sudo cp goapp /usr/local/bin/goapp
sudo chmod 755 /usr/local/bin/goapp

Il flag CGO_ENABLED=0 produce un binario completamente statico, senza dipendenze da librerie condivise del sistema. I flag -s -w passati al linker rimuovono le informazioni di debug, riducendo le dimensioni del file eseguibile.

Creare un utente di sistema dedicato

Per principio del minimo privilegio, l'applicazione Go non deve girare come root. Creiamo un utente di sistema senza shell interattiva:

# Creazione di un utente di sistema dedicato
sudo useradd --system --no-create-home --shell /usr/sbin/nologin goapp

Configurare systemd

systemd è il sistema di init standard sulle distribuzioni Linux moderne. Creiamo un'unità di servizio per l'applicazione Go:

# /etc/systemd/system/goapp.service

[Unit]
Description=Applicazione Go di esempio
After=network.target

[Service]
# Utente e gruppo sotto cui gira il processo
User=goapp
Group=goapp

# Percorso del binario
ExecStart=/usr/local/bin/goapp

# Porta di ascolto del server Go
Environment=LISTEN_ADDR=127.0.0.1:8080

# Riavvio automatico in caso di errore
Restart=on-failure
RestartSec=5s

# Reindirizzamento dei log verso il journal di systemd
StandardOutput=journal
StandardError=journal

# Protezioni di sicurezza opzionali
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Ricarichiamo systemd e avviamo il servizio:

# Ricaricamento della configurazione di systemd
sudo systemctl daemon-reload

# Abilitazione all'avvio automatico
sudo systemctl enable goapp

# Avvio del servizio
sudo systemctl start goapp

# Verifica dello stato
sudo systemctl status goapp

Per leggere i log del servizio in tempo reale utilizziamo journalctl:

# Visualizzazione dei log in tempo reale
sudo journalctl -u goapp -f

Installare e configurare nginx

Su Debian e Ubuntu, nginx si installa con:

# Installazione di nginx
sudo apt update
sudo apt install nginx

Su RHEL, CentOS e Fedora:

# Installazione di nginx su sistemi Red Hat
sudo dnf install nginx

Configurazione di nginx come reverse proxy

nginx organizza la configurazione in blocchi server contenuti nei file all'interno di /etc/nginx/sites-available/ (su Debian/Ubuntu) o direttamente in /etc/nginx/conf.d/. Creiamo il file di configurazione per la nostra applicazione:

# /etc/nginx/sites-available/goapp

upstream goapp_backend {
    # Indirizzo del server Go in ascolto localmente
    server 127.0.0.1:8080;
    # Manteniamo le connessioni keepalive verso il backend
    keepalive 32;
}

server {
    listen 80;
    listen [::]:80;
    server_name esempio.it www.esempio.it;

    # Reindirizzamento permanente verso HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name esempio.it www.esempio.it;

    # Percorso dei certificati TLS (gestiti da Certbot)
    ssl_certificate     /etc/letsencrypt/live/esempio.it/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/esempio.it/privkey.pem;

    # Configurazione TLS moderna e sicura
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;

    # Intestazioni di sicurezza HTTP
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Dimensione massima del corpo della richiesta
    client_max_body_size 10m;

    # Timeout per le operazioni di proxy
    proxy_connect_timeout 10s;
    proxy_send_timeout    30s;
    proxy_read_timeout    30s;

    location / {
        proxy_pass http://goapp_backend;

        # Intestazioni necessarie per il corretto funzionamento del reverse proxy
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Abilitiamo il keepalive verso il backend
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }

    # Servire i file statici direttamente da nginx, senza passare per Go
    location /static/ {
        alias /var/www/goapp/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Pagina di errore personalizzata per il caso di backend non raggiungibile
    error_page 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
        internal;
    }
}

Abilitiamo il sito e testiamo la configurazione:

# Creazione del link simbolico per abilitare il sito
sudo ln -s /etc/nginx/sites-available/goapp /etc/nginx/sites-enabled/goapp

# Verifica della sintassi della configurazione nginx
sudo nginx -t

# Ricaricamento della configurazione senza interruzione del servizio
sudo systemctl reload nginx

Ottenere un certificato TLS con Certbot

Let's Encrypt fornisce certificati TLS gratuiti e automatizzabili tramite lo strumento Certbot. L'installazione avviene tramite snap sulla maggior parte delle distribuzioni moderne:

# Installazione di Certbot tramite snap
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# Ottenimento del certificato con il plugin nginx
sudo certbot --nginx -d esempio.it -d www.esempio.it

Certbot modificherà automaticamente il file di configurazione nginx per inserire i percorsi dei certificati e ricaricherà nginx al termine. Verificate che il rinnovo automatico funzioni correttamente:

# Simulazione del rinnovo per verificare che tutto funzioni
sudo certbot renew --dry-run

Leggere l'IP reale del client nell'applicazione Go

Quando nginx fa da reverse proxy, il campo RemoteAddr della struttura http.Request conterrà sempre l'indirizzo IP di nginx (127.0.0.1), non quello del client originale. L'IP reale viene trasmesso nell'intestazione X-Forwarded-For o X-Real-IP, che abbiamo già configurato sopra. Ecco come leggerlo nell'applicazione Go:

// internal/middleware/realip.go

package middleware

import (
    "net"
    "net/http"
    "strings"
)

// RealIP è un middleware che legge l'IP reale del client dall'intestazione X-Real-IP
// oppure dal primo indirizzo nella lista X-Forwarded-For
func RealIP(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Priorità a X-Real-IP, impostato direttamente da nginx
        if ip := r.Header.Get("X-Real-IP"); ip != "" {
            if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
                r.RemoteAddr = parsed.String()
            }
        } else if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
            // X-Forwarded-For può contenere una lista separata da virgole;
            // il primo valore è l'IP originale del client
            parts := strings.Split(forwarded, ",")
            if len(parts) > 0 {
                if parsed := net.ParseIP(strings.TrimSpace(parts[0])); parsed != nil {
                    r.RemoteAddr = parsed.String()
                }
            }
        }
        next.ServeHTTP(w, r)
    })
}

Registriamo il middleware nella costruzione del router:

// internal/handler/handler.go (versione aggiornata di NewRouter)

func NewRouter() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("/", handleIndex)
    mux.HandleFunc("/api/status", handleStatus)

    // Avvolgiamo il mux con il middleware per l'IP reale
    return middleware.RealIP(mux)
}

Logging strutturato con l'IP del client

Una pratica comune in produzione è aggiungere un middleware di logging che registri ogni richiesta con IP, metodo, path, codice di risposta e durata. Utilizziamo il pacchetto log/slog, disponibile a partire da Go 1.21:

// internal/middleware/logger.go

package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

// responseWriter è un wrapper attorno a http.ResponseWriter che cattura il codice di stato HTTP
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func newResponseWriter(w http.ResponseWriter) *responseWriter {
    // Il codice di stato di default è 200 se WriteHeader non viene mai chiamato
    return &responseWriter{w, http.StatusOK}
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// Logger è un middleware che registra ogni richiesta HTTP con slog
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := newResponseWriter(w)
        next.ServeHTTP(wrapped, r)
        slog.Info("richiesta",
            "method", r.Method,
            "path", r.URL.Path,
            "status", wrapped.statusCode,
            "duration", time.Since(start).String(),
            "remote_addr", r.RemoteAddr,
        )
    })
}

Aggiornamento zero-downtime

Durante la distribuzione di una nuova versione dell'applicazione vogliamo evitare interruzioni del servizio. Grazie al graceful shutdown implementato e alla configurazione di systemd, la procedura di aggiornamento è semplice:

# Compilazione della nuova versione
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o goapp ./cmd/server

# Sostituzione atomica del binario
sudo cp goapp /usr/local/bin/goapp

# Riavvio del servizio: systemd invia SIGTERM, attende lo shutdown graceful, poi avvia il nuovo processo
sudo systemctl restart goapp

# Verifica che il nuovo processo sia in esecuzione correttamente
sudo systemctl status goapp

Per aggiornamenti a impatto zero su sistemi con più istanze, è possibile adottare uno schema con due istanze in alternanza, aggiornando una alla volta mentre nginx continua a servire traffico sull'altra. In questo scenario, il blocco upstream di nginx elenca entrambe le istanze e gestisce automaticamente il failover se una di esse non risponde.

Monitoraggio e health check

nginx può essere configurato per verificare periodicamente lo stato del backend tramite un endpoint di health check esposto dall'applicazione Go. Aggiungiamo la route:

// Aggiunta della route di health check al router
mux.HandleFunc("/healthz", handleHealthz)

// handleHealthz risponde con 200 OK se il servizio è operativo
func handleHealthz(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    // Risposta minimale per ridurre il traffico generato dai check
    _, _ = w.Write([]byte(`{"status":"ok"}`))
}

Sul lato nginx, con il modulo ngx_http_upstream_hc_module (disponibile nella versione Plus, o tramite il modulo open source nginx_upstream_check_module), è possibile escludere automaticamente i backend non sani dal pool. Nella versione open source, una configurazione robusta si ottiene combinando i timeout di proxy con la direttiva max_fails nel blocco upstream:

upstream goapp_backend {
    # Dopo 3 tentativi falliti nell'arco di 30 secondi, il server viene marcato come non disponibile
    server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

Considerazioni finali

La combinazione di Go e nginx copre la stragrande maggioranza dei casi d'uso per applicazioni web in produzione. Go gestisce la logica applicativa con efficienza e concorrenza nativa; nginx si occupa della terminazione TLS, della gestione delle connessioni client, della compressione e del caching degli asset statici. systemd garantisce l'avvio automatico, il riavvio in caso di crash e la raccolta centralizzata dei log.

I punti cardine da tenere a mente sono: eseguire il processo Go con un utente senza privilegi, implementare il graceful shutdown per evitare richieste troncate durante gli aggiornamenti, configurare timeout espliciti sia nel server Go sia in nginx, e non fidarsi ciecamente delle intestazioni X-Forwarded-For senza validarne la provenienza, limitandosi a leggerle solo quando la richiesta arriva effettivamente dal proxy locale.