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.

← Tutti i post