Un web server in Go su Raspberry Pi, senza dipendenze
Il Raspberry Pi è la macchina ideale per tenere acceso un servizio piccolo in casa: consuma pochi watt, sta in un cassetto e non ha ventole. Il problema è che ci si finisce sopra con uno stack sproporzionato (Node, Python con un virtualenv, un reverse proxy, Docker) per servire quattro pagine.
Con Go serve molto meno. Il risultato finale è un singolo file eseguibile: niente runtime da installare, niente dipendenze da aggiornare, niente node_modules. Lo copi sul Pi e parte.
Il server
Tutto quello che segue usa solo la standard library. Nessun framework, nessun go get.
package main
import (
"log"
"net/http"
"os"
"time"
)
func main() {
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.Dir("static")))
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("in ascolto su %s", addr)
log.Fatal(srv.ListenAndServe())
}
Una nota sull’unica parte che non è ovvia: i timeout. Quasi tutti gli esempi che si trovano in giro usano http.ListenAndServe(addr, mux), che è una riga più corta ma non ne imposta nessuno. Significa che una connessione aperta e mai chiusa resta lì a occupare risorse per sempre. Su un Pi con mezzo giga di RAM è il modo più rapido per trovare il servizio morto dopo una settimana. I tre valori sopra sono il minimo sindacale.
La rotta /health serve dopo, per il monitoraggio: è comodo poter chiedere al servizio se è vivo senza scaricare una pagina intera.
Cross-compilare dal proprio computer
Qui sta il vantaggio pratico di Go. Non serve compilare sul Raspberry, cosa lenta e fastidiosa, perché il compilatore genera binari per altre architetture nativamente:
GOOS=linux GOARCH=arm64 go build -o pisrv .
Due variabili d’ambiente e basta. Il risultato è un eseguibile ARM a 64 bit che gira su qualsiasi Pi con sistema operativo a 64 bit (Pi 3 in avanti):
pisrv: ELF 64-bit LSB executable, ARM aarch64, statically linked
statically linked è la parola importante: non ha bisogno di librerie di sistema, quindi non esiste la classe di problemi “sulla mia macchina funziona”.
Sul Pi vecchio con sistema a 32 bit si usa GOARCH=arm GOARM=7.
Il binario pesa 8,1 MB. Sembrano tanti per un server così piccolo, ed è il prezzo di avere tutto dentro. Si scende a 5,5 MB togliendo le informazioni di debug:
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o pisrv .
Poi si copia:
scp pisrv static/* pi@raspberrypi.local:~/app/
Farlo partire da solo
Un binario lanciato a mano muore al primo riavvio. Su Raspberry Pi OS c’è systemd, che risolve la cosa con un file. In /etc/systemd/system/pisrv.service:
[Unit]
Description=Web server
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/app
ExecStart=/home/pi/app/pisrv
Environment=ADDR=:8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now pisrv
sudo systemctl status pisrv
Restart=always è il motivo per cui si usa systemd invece di lanciarlo a mano: se il processo muore, riparte da solo in tre secondi. WorkingDirectory conta, perché il server cerca la cartella static nel percorso relativo.
Cosa resta fuori
Questo è deliberatamente il minimo che funziona. Se il servizio deve uscire su internet, mancano ancora due cose. La prima è HTTPS, che conviene delegare a Caddy davanti al server, visto che gestisce i certificati Let’s Encrypt da solo. La seconda è che la porta 8080 non è la 80. Finché resti sulla rete di casa, così com’è va bene.
L’ho scritto perché è l’esempio più chiaro di una cosa che con Go vale anche altrove: la distribuzione è un file da copiare, e su una macchina piccola quel vincolo in meno cambia molto.