Kubernetes monitora nativamente CPU e memoria, ma nella realtà operativa queste due dimensioni raramente bastano. Quante richieste sono in attesa nella coda? Quanto ha impiegato l'ultimo batch job? Quante connessioni WebSocket attive sta gestendo un singolo Pod? Per rispondere a queste domande serve un metrics exporter personalizzato: un componente che fa da ponte tra lo stato interno dell'applicazione e il sistema di osservabilità del cluster.
Questo articolo descrive come costruirne uno da zero in Go, containerizzarlo con un'immagine minimale e collegarlo a Prometheus — e, in ultima istanza, all'HorizontalPodAutoscaler (HPA) — per abilitare un autoscaling guidato da segnali applicativi reali.
Cosa fa concretamente un metrics exporter
Un exporter è un piccolo server HTTP con un solo compito: esporre lo stato dell'applicazione in formato testuale sull'endpoint /metrics. Prometheus esegue lo scrape — la raccolta periodica dei dati da quell'endpoint — a intervalli regolari, archivia le serie temporali e le rende disponibili per query, alert e regole di autoscaling.
In alcuni scenari è possibile strumentare l'applicazione direttamente, incorporando la libreria client di Prometheus nello stesso processo. Un exporter standalone ha invece senso quando la sorgente dati è esterna all'applicazione o quando non si controlla il codice sorgente.
Il formato atteso da Prometheus è testo semplice: una metrica per riga, con nome, label opzionali e valore numerico. Le librerie client si occupano della serializzazione; allo sviluppatore spetta decidere cosa misurare e aggiornare il valore corretto al momento giusto.
Scegliere il tipo di metrica corretto
Prima di scrivere una sola riga di codice, è fondamentale identificare il tipo di segnale da misurare. Il data model di Prometheus prevede tre tipi principali:
- Counter — valore monotonicamente crescente; adatto per totali cumulativi (richieste servite, job elaborati, errori). Non deve mai essere usato per valori che possono diminuire.
- Gauge — snapshot istantaneo di un valore che può salire e scendere liberamente. Profondità della coda, connessioni attive, dimensione della cache sono tutti gauge.
- Histogram — registra la distribuzione dei valori osservati, ad esempio la latenza delle richieste. Permette di calcolare percentili (p50, p99) anziché semplici medie.
La convenzione di naming suggerisce la struttura <namespace>_<name>_<unit> in snake_case. Un worker processor potrebbe esporre worker_jobs_processed_total (counter), worker_queue_depth (gauge) e worker_job_duration_seconds (histogram). Nomi chiari riducono drasticamente il tempo di debug in produzione.
Configurazione del progetto in Go
La libreria client Go di Prometheus è la scelta più diffusa nell'ecosistema Kubernetes, in parte perché alimenta molti componenti ufficiali del progetto. La struttura iniziale del modulo si crea con pochi comandi:
mkdir my-exporter && cd my-exporter
go mod init example.com/my-exporter
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp
Registrazione delle metriche
Nel file main.go si dichiarano le metriche e le si registra nel registry di default di Prometheus. La registrazione è necessaria perché le metriche appaiano nell'output anche prima che venga registrata la prima osservazione:
package main
import (
"log"
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
jobsProcessed = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "worker_jobs_processed_total",
Help: "Total number of jobs processed, partitioned by status.",
},
[]string{"status"},
)
queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "worker_queue_depth",
Help: "Current number of jobs waiting in the queue.",
})
jobDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "worker_job_duration_seconds",
Help: "Time spent processing a single job.",
Buckets: prometheus.DefBuckets,
})
)
func init() {
prometheus.MustRegister(jobsProcessed, queueDepth, jobDuration)
}
prometheus.MustRegister genera un panic in caso di registrazione duplicata, rendendo immediatamente visibili le misconfigurazioni al momento dell'avvio. Se l'exporter è incorporato in una libreria condivisa con altri package che strumentano le stesse metriche, è preferibile usare prometheus.Register e gestire esplicitamente l'errore.
Raccolta dei valori reali
Con le metriche registrate, il passo successivo è mantenerle aggiornate. Il pattern più comune è un polling loop — una goroutine (unità di esecuzione concorrente leggera del runtime Go) che interroga periodicamente la sorgente dati e aggiorna le metriche registrate:
import (
"math/rand"
"time"
)
func collectMetrics() {
for {
// Sostituire con letture reali dalla propria applicazione.
depth := float64(rand.Intn(50))
queueDepth.Set(depth)
start := time.Now()
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
jobDuration.Observe(time.Since(start).Seconds())
jobsProcessed.WithLabelValues("success").Inc()
time.Sleep(5 * time.Second)
}
}
L'intervallo di polling — qui cinque secondi — deve essere inferiore all'intervallo di scrape di Prometheus. Il valore di default nella maggior parte dei cluster è quindici secondi, il che lascia un margine confortevole.
Esposizione dell'endpoint
La funzione main collega il loop di raccolta all'handler HTTP. Un path /healthz separato offre a Kubernetes un target per la liveness probe senza esporre i dati delle metriche sulla rotta di health check:
func main() {
go collectMetrics()
http.Handle("/metrics", promhttp.Handler())
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Println("Listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("server error: %v", err)
}
}
Prima di procedere alla containerizzazione, vale la pena verificare l'output in locale:
go run .
curl http://localhost:8080/metrics | grep worker_
La presenza dei blocchi # HELP e # TYPE per ciascuna metrica conferma che l'exporter funziona correttamente.
Build dell'immagine container
Un multi-stage build (tecnica Dockerfile che separa la fase di compilazione da quella di esecuzione per ridurre la dimensione dell'immagine finale) mantiene l'immagine leggera ed evita di distribuire la toolchain Go in produzione. Il primo stage compila un binario staticamente linkato; il secondo copia solo quel binario in una base minimale:
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /exporter .
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /exporter /exporter
EXPOSE 8080
ENTRYPOINT ["/exporter"]
L'immagine base distroless/static:nonroot non contiene shell né package manager ed esegue il processo come utente non-root per default, soddisfacendo la maggior parte delle security policy dei cluster senza configurazioni aggiuntive.
docker build -t <registry>/my-exporter:v1.0.0 .
docker push <registry>/my-exporter:v1.0.0
In un contesto produttivo, questi comandi dovrebbero essere eseguiti automaticamente da una pipeline CI/CD.
Deploy nel cluster
Sono sufficienti due manifest: un Deployment che gestisce il ciclo di vita del Pod e un Service che fornisce a Prometheus un indirizzo stabile da interrogare.
Il Deployment definisce resource limit conservativi, appropriati per un processo leggero in stile sidecar, e utilizza /healthz per la liveness probe:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: my-exporter
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: my-exporter
template:
metadata:
labels:
app.kubernetes.io/name: my-exporter
spec:
containers:
- name: exporter
image: <registry>/my-exporter:v1.0.0
ports:
- name: metrics
containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
Il Service nomina la porta metrics, riferimento che il ServiceMonitor utilizzerà nella sezione successiva:
apiVersion: v1
kind: Service
metadata:
name: my-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: my-exporter
spec:
selector:
app.kubernetes.io/name: my-exporter
ports:
- name: metrics
port: 8080
targetPort: metrics
kubectl apply -f deployment.yaml -f service.yaml
Configurare Prometheus per lo scrape
Le modalità di configurazione dipendono da come Prometheus è stato installato nel cluster.
Opzione 1: Prometheus Operator e ServiceMonitor
Se Prometheus è stato installato tramite il Prometheus Operator o il chart Helm kube-prometheus-stack, il modo più esplicito e debuggabile è creare un ServiceMonitor (Custom Resource Definition che istruisce il Prometheus Operator su quali Service monitorare e con quale frequenza). La label release deve corrispondere al selettore configurato sulla risorsa Prometheus:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: my-exporter
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: my-exporter
endpoints:
- port: metrics
interval: 15s
path: /metrics
Opzione 2: Discovery basata su annotation
Per i cluster che usano la discovery tramite annotation sul Pod template, è sufficiente aggiungere:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
In caso di dubbio sull'approccio adottato nel proprio cluster, il ServiceMonitor è sempre preferibile per la maggiore trasparenza in fase di troubleshooting.
Verifica dello scrape
Per confermare che l'exporter sia stato scoperto correttamente, si può fare un port-forward verso il servizio Prometheus e aprire la pagina dei target:
kubectl port-forward svc/prometheus-operated 9090 -n monitoring
Navigando su http://localhost:9090/targets, il target my-exporter deve apparire con stato UP. In caso di stato DOWN, i punti da verificare sono la corrispondenza della label release nel ServiceMonitor e lo stato del Pod:
kubectl get pods -n monitoring -l app.kubernetes.io/name=my-exporter
kubectl describe servicemonitor my-exporter -n monitoring
Una volta che il target è healthy, una query nel browser delle espressioni conferma che i dati fluiscono correttamente:
rate(worker_jobs_processed_total{status="success"}[2m])
Un risultato non nullo certifica che l'intera pipeline funziona: l'applicazione produce dati, Prometheus li raccoglie e le serie temporali sono archiviate e interrogabili.
Il passo successivo: autoscaling su metriche custom
Un exporter funzionante è la fondamenta, non il traguardo. Il passo naturale successivo è collegare queste metriche all'HorizontalPodAutoscaler affinché il workload scala sui segnali che guidano effettivamente il carico — non solo sulla CPU. Questo richiede un metrics adapter (componente che espone metriche custom all'API Kubernetes rendendole disponibili per l'HPA): il Prometheus Adapter è l'opzione più diffusa in produzione. Una volta registrato, qualsiasi HorizontalPodAutoscaler nel cluster può referenziare direttamente worker_queue_depth o worker_jobs_processed_total nel proprio blocco metrics.
Per chi vuole esplorare exporter già pronti per database, message broker e servizi cloud, il catalogo ufficiale Prometheus exporters and integrations è il punto di partenza più completo.
