Cuando ningún exporter existente mide lo que necesitas, puedes escribir el tuyo: un pequeño servicio que calcula unas métricas y las publica por HTTP en el formato que Prometheus entiende. En este tutorial crearás un exporter real que vigila un directorio de copias de seguridad (cuántas hay, cuánto ocupan y cuándo se hizo la última), lo ejecutarás como servicio de systemd en Ubuntu 24.04, lo conectarás a Prometheus con una alerta de copia atrasada y verás la misma implementación en Go.
Requisitos previos
Para seguir esta guía necesitas:
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con Python 3.12 (incluido en Ubuntu 24.04).
- Un usuario no root con privilegios
sudo. - Un directorio con copias de seguridad que quieras vigilar. En los ejemplos es
/var/backups/appcon archivos*.tar.gz. - Un servidor Prometheus con su configuración en
/etc/prometheus/prometheus.yml, para el paso 6. - Para la sección de Go, Go 1.25 o posterior en tu equipo de desarrollo.
Conceptos: tipos de métrica e instrumentación frente a exporter
Prometheus distingue cuatro tipos de métrica:
| Tipo | Qué representa | Ejemplo |
|---|---|---|
| Counter | Valor que solo crece (o vuelve a 0 al reiniciar) | Peticiones atendidas, errores |
| Gauge | Valor que sube y baja | Archivos en un directorio, memoria usada |
| Histogram | Distribución de observaciones en rangos (buckets) | Duración de peticiones |
| Summary | Cuantiles calculados en el cliente | Latencia p99 de un único proceso |
Hay dos formas de generar métricas propias:
- Instrumentación directa: tu aplicación crea contadores e histogramas y los actualiza cuando ocurre algo. Es lo adecuado para código propio. En Python sería:
from prometheus_client import Counter, Histogram
REQUESTS = Counter("myapp_requests", "Peticiones procesadas.", ["method", "status"])
LATENCY = Histogram("myapp_request_duration_seconds", "Duración de las peticiones.")
REQUESTS.labels(method="GET", status="200").inc()
with LATENCY.time():
handle_request()
- Exporter con collector propio: un proceso aparte que, en cada scrape, consulta un sistema que no controlas (un directorio, una API, un dispositivo) y devuelve el estado actual. Es lo que construirás en esta guía.
Sigue las convenciones de nombres de Prometheus: minúsculas con guiones bajos, un prefijo común para todo el exporter (backup_), unidades base en el nombre (_seconds, _bytes) y el sufijo _total solo en counters. Evita etiquetas con valores ilimitados, como nombres de archivo o IDs de usuario: cada combinación de etiquetas crea una serie temporal nueva.
Paso 1: Preparar el entorno de Python
Instala el módulo venv, crea un directorio para el exporter y un entorno virtual con la librería oficial prometheus-client:
sudo apt update
sudo apt install python3-venv
sudo mkdir -p /opt/backup_exporter
sudo python3 -m venv /opt/backup_exporter/venv
sudo /opt/backup_exporter/venv/bin/pip install prometheus-client
Comprueba la versión instalada:
/opt/backup_exporter/venv/bin/pip show prometheus-client | grep Version
Version: 0.26.0
Paso 2: Escribir el exporter
El exporter usa un collector propio: una clase con un método collect() que la librería llama en cada petición a /metrics. Así las métricas siempre reflejan el estado del directorio en el momento del scrape, sin hilos en segundo plano ni valores desfasados.
Crea el archivo:
sudo nano /opt/backup_exporter/backup_exporter.py
#!/usr/bin/env python3
"""Exporter de Prometheus con el estado de las copias de seguridad de un directorio."""
import argparse
import os
import time
from pathlib import Path
from prometheus_client import start_http_server
from prometheus_client.core import REGISTRY, GaugeMetricFamily
from prometheus_client.registry import Collector
def gauge(name, documentation, value):
metric = GaugeMetricFamily(name, documentation)
metric.add_metric([], value)
return metric
class BackupCollector(Collector):
def __init__(self, directory: Path, pattern: str) -> None:
self.directory = directory
self.pattern = pattern
def collect(self):
start = time.monotonic()
readable = self.directory.is_dir() and os.access(self.directory, os.R_OK | os.X_OK)
yield gauge("backup_directory_up", "1 si el directorio de copias se puede leer.", int(readable))
if not readable:
return
files = []
for path in self.directory.glob(self.pattern):
try:
st = path.stat()
except FileNotFoundError:
continue # el archivo se borró entre el listado y el stat
if path.is_file():
files.append(st)
yield gauge("backup_files", "Número de copias en el directorio.", len(files))
yield gauge("backup_files_size_bytes", "Tamaño total de las copias en bytes.",
sum(st.st_size for st in files))
if files:
newest = max(files, key=lambda st: st.st_mtime)
yield gauge("backup_last_modified_timestamp_seconds",
"Fecha de modificación de la copia más reciente (tiempo Unix).",
newest.st_mtime)
yield gauge("backup_last_size_bytes", "Tamaño de la copia más reciente en bytes.",
newest.st_size)
yield gauge("backup_exporter_collect_duration_seconds",
"Tiempo empleado en recoger las métricas.", time.monotonic() - start)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", type=Path, required=True)
parser.add_argument("--pattern", default="*.tar.gz")
parser.add_argument("--listen-address", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9900)
args = parser.parse_args()
REGISTRY.register(BackupCollector(args.directory, args.pattern))
start_http_server(args.port, addr=args.listen_address)
print(f"Escuchando en http://{args.listen_address}:{args.port}/metrics", flush=True)
while True:
time.sleep(3600)
if __name__ == "__main__":
main()
Puntos importantes del código:
GaugeMetricFamilycrea métricas de tipo gauge con un valor calculado en ese momento. Hay equivalentes para los demás tipos (CounterMetricFamily,HistogramMetricFamily).backup_directory_upindica si el directorio se puede leer. Sin ella, un directorio inaccesible se confundiría con uno vacío.- La fecha de la última copia se publica como marca de tiempo Unix y no como "horas desde la última copia". Así el cálculo de la antigüedad se hace en PromQL con
time()y el valor es correcto aunque Prometheus lo lea con retraso. - El exporter escucha por defecto solo en
127.0.0.1. Para que lo alcance un Prometheus remoto se pasa--listen-address 0.0.0.0en el servicio. start_http_servertambién publica métricas del propio proceso (process_*,python_gc_*), útiles para vigilar el exporter.
Pruébalo manualmente con un directorio temporal:
mkdir -p /tmp/backups-test
head -c 1000 /dev/urandom > /tmp/backups-test/app-1.tar.gz
/opt/backup_exporter/venv/bin/python /opt/backup_exporter/backup_exporter.py --directory /tmp/backups-test
Escuchando en http://127.0.0.1:9900/metrics
Desde otra terminal, consulta las métricas:
curl -s http://127.0.0.1:9900/metrics | grep '^backup_'
backup_directory_up 1.0
backup_files 1.0
backup_files_size_bytes 1000.0
backup_last_modified_timestamp_seconds 1.7903402298785698e+09
backup_last_size_bytes 1000.0
backup_exporter_collect_duration_seconds 0.00020041607785969973
Detén el exporter con Ctrl+C en la primera terminal.
Paso 3: Validar el formato de las métricas
promtool, la herramienta de línea de comandos de Prometheus, comprueba que la salida cumple el formato de exposición y las convenciones de nombres. En Ubuntu está en el paquete prometheus (si Prometheus está en otro servidor, instala solo ese paquete aquí o ejecuta la comprobación allí contra la URL del exporter):
curl -s http://127.0.0.1:9900/metrics | promtool check metrics
Si no muestra nada, el formato es correcto. Si hay problemas, promtool indica la métrica y el motivo, por ejemplo un counter sin el sufijo _total o una unidad no estándar.
Paso 4: Ejecutar el exporter como servicio
Crea un usuario de sistema para el exporter:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin backup_exporter
Este usuario necesita permiso de lectura y de acceso al directorio de copias. Si el directorio pertenece a root con permisos restrictivos, la forma más limpia de dárselo sin cambiar el propietario es una ACL:
sudo apt install acl
sudo setfacl -m u:backup_exporter:rx /var/backups/app
Crea la unidad de systemd:
sudo nano /etc/systemd/system/backup_exporter.service
[Unit]
Description=Prometheus backup exporter
Wants=network-online.target
After=network-online.target
[Service]
User=backup_exporter
Group=backup_exporter
ExecStart=/opt/backup_exporter/venv/bin/python /opt/backup_exporter/backup_exporter.py \
--directory /var/backups/app \
--pattern "*.tar.gz" \
--listen-address 0.0.0.0 \
--port 9900
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
ProtectSystem=strict monta todo el sistema de archivos en solo lectura para el servicio, lo que es suficiente porque el exporter nunca escribe nada.
Arranca el servicio y comprueba que responde:
sudo systemctl daemon-reload
sudo systemctl enable --now backup_exporter
curl -s http://127.0.0.1:9900/metrics | grep '^backup_directory_up'
backup_directory_up 1.0
Si devuelve 0.0, el usuario backup_exporter no puede leer el directorio: revisa la ACL con getfacl /var/backups/app.
El puerto 9900 no está asignado a ningún exporter conocido, pero si ejecutas muchos exporters consulta la lista de puertos por defecto del proyecto para evitar colisiones. Ábrelo solo al servidor de Prometheus:
sudo ufw allow from prometheus_server_ip to any port 9900 proto tcp
Paso 5: Implementar el mismo exporter en Go
Go genera un único binario estático sin dependencias en el servidor, lo que simplifica el despliegue. La librería oficial es client_golang, y la versión actual requiere Go 1.25 o posterior (el paquete golang-go de Ubuntu 24.04 es más antiguo, así que instala Go desde go.dev/dl).
En tu equipo de desarrollo, crea el módulo:
mkdir backup_exporter && cd backup_exporter
go mod init backup_exporter
go get github.com/prometheus/client_golang@latest
Crea main.go:
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
upDesc = prometheus.NewDesc("backup_directory_up",
"1 si el directorio de copias se puede leer.", nil, nil)
filesDesc = prometheus.NewDesc("backup_files",
"Número de copias en el directorio.", nil, nil)
sizeDesc = prometheus.NewDesc("backup_files_size_bytes",
"Tamaño total de las copias en bytes.", nil, nil)
lastDesc = prometheus.NewDesc("backup_last_modified_timestamp_seconds",
"Fecha de modificación de la copia más reciente (tiempo Unix).", nil, nil)
)
type backupCollector struct {
dir string
pattern string
}
func (c backupCollector) Describe(ch chan<- *prometheus.Desc) {
prometheus.DescribeByCollect(c, ch)
}
func (c backupCollector) Collect(ch chan<- prometheus.Metric) {
if info, err := os.Stat(c.dir); err != nil || !info.IsDir() {
ch <- prometheus.MustNewConstMetric(upDesc, prometheus.GaugeValue, 0)
return
}
ch <- prometheus.MustNewConstMetric(upDesc, prometheus.GaugeValue, 1)
// El patrón se valida al arrancar, así que Glob no puede devolver error.
matches, _ := filepath.Glob(filepath.Join(c.dir, c.pattern))
var count, total, newest float64
for _, m := range matches {
info, err := os.Stat(m)
if err != nil || !info.Mode().IsRegular() {
continue
}
count++
total += float64(info.Size())
if t := float64(info.ModTime().Unix()); t > newest {
newest = t
}
}
ch <- prometheus.MustNewConstMetric(filesDesc, prometheus.GaugeValue, count)
ch <- prometheus.MustNewConstMetric(sizeDesc, prometheus.GaugeValue, total)
if count > 0 {
ch <- prometheus.MustNewConstMetric(lastDesc, prometheus.GaugeValue, newest)
}
}
func main() {
dir := flag.String("directory", "", "directorio de las copias")
pattern := flag.String("pattern", "*.tar.gz", "patrón de los archivos")
addr := flag.String("listen-address", "127.0.0.1:9900", "dirección de escucha")
flag.Parse()
if *dir == "" {
log.Fatal("falta --directory")
}
if _, err := filepath.Match(*pattern, ""); err != nil {
log.Fatalf("patrón no válido: %v", err)
}
reg := prometheus.NewRegistry()
reg.MustRegister(
backupCollector{dir: *dir, pattern: *pattern},
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
log.Printf("Escuchando en http://%s/metrics", *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}
En Go, un collector implementa Describe y Collect. MustNewConstMetric crea una métrica con el valor calculado en ese momento, el equivalente a GaugeMetricFamily en Python. Al usar un registro propio (prometheus.NewRegistry()) solo se publican las métricas que registras de forma explícita, en este caso las del collector y las del runtime de Go y del proceso.
Resuelve dependencias y compila para Linux x86_64 (funciona desde cualquier sistema operativo):
go mod tidy
GOOS=linux GOARCH=amd64 go build -o backup_exporter .
Copia el binario al servidor en /usr/local/bin/backup_exporter y cambia la línea ExecStart de la unidad por:
ExecStart=/usr/local/bin/backup_exporter --directory /var/backups/app --listen-address 0.0.0.0:9900
Las métricas backup_* tienen los mismos nombres que en Python, así que Prometheus y las alertas no cambian.
Paso 6: Añadir el exporter a Prometheus y crear una alerta
En el servidor de Prometheus, añade el trabajo dentro de scrape_configs en /etc/prometheus/prometheus.yml, sustituyendo backup_server_ip por la IP del servidor con las copias:
- job_name: "backups"
static_configs:
- targets: ["backup_server_ip:9900"]
Crea el archivo de reglas:
sudo nano /etc/prometheus/backup_alerts.yml
groups:
- name: backups
rules:
- alert: BackupTooOld
expr: time() - backup_last_modified_timestamp_seconds > 26 * 3600
for: 15m
labels:
severity: critical
annotations:
summary: "No hay copias recientes en {{ $labels.instance }}"
description: "La última copia tiene {{ $value | humanizeDuration }} de antigüedad."
- alert: BackupDirectoryUnreadable
expr: backup_directory_up == 0
for: 15m
labels:
severity: warning
annotations:
summary: "El exporter no puede leer el directorio de copias en {{ $labels.instance }}"
BackupTooOld salta cuando la copia más reciente tiene más de 26 horas, un margen razonable para una copia diaria. Regístralo en rule_files dentro de /etc/prometheus/prometheus.yml:
rule_files:
- "backup_alerts.yml"
Valida y reinicia Prometheus:
promtool check rules /etc/prometheus/backup_alerts.yml
promtool check config /etc/prometheus/prometheus.yml
sudo systemctl restart prometheus
Checking /etc/prometheus/backup_alerts.yml
SUCCESS: 2 rules found
En Status > Targets el trabajo backups debe aparecer como UP. Para ver la antigüedad de la última copia en horas, ejecuta en la pestaña Graph:
(time() - backup_last_modified_timestamp_seconds) / 3600
Solución de problemas
backup_directory_up vale 0 aunque el directorio existe. El usuario del servicio no tiene permisos r y x sobre el directorio. Pruébalo con sudo -u backup_exporter ls /var/backups/app.
El servicio falla con ModuleNotFoundError: No module named 'prometheus_client'. ExecStart está usando el Python del sistema en lugar del del entorno virtual. Revisa que la ruta empieza por /opt/backup_exporter/venv/bin/python.
Prometheus marca el destino como DOWN con connection refused. El exporter escucha en 127.0.0.1 o UFW bloquea el puerto. Comprueba con sudo ss -ltnp | grep 9900 que escucha en 0.0.0.0:9900 y revisa sudo ufw status.
Los scrapes tardan mucho. Recorrer directorios con miles de archivos en cada scrape es costoso. Consulta backup_exporter_collect_duration_seconds y, si supera un segundo, restringe el --pattern o sube el scrape_interval de ese trabajo.
Conclusión
Has creado un exporter de Prometheus con un collector propio en Python, lo has desplegado como servicio de systemd con permisos mínimos, has visto la implementación equivalente en Go y has configurado Prometheus para alertar cuando las copias se quedan atrasadas. El mismo patrón sirve para exponer cualquier dato que hoy solo consultas a mano: una API interna, una cola de trabajos o el estado de un dispositivo. Como siguientes pasos, puedes añadir etiquetas acotadas (por ejemplo, una por aplicación si vigilas varios directorios), publicar un panel en Grafana con estas métricas o enviar las alertas a Alertmanager.
