Tech Insights

Metrics Exporter Custom per Kubernetes: guida completa

Come costruire un metrics exporter personalizzato in Go, containerizzarlo e integrarlo con Prometheus e l'HorizontalPodAutoscaler in un cluster Kubernetes.

metrics exporter

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.