Cincuenta y cinco lecciones. srv-tramontana está instalado, endurecido, monitorizable, respaldado, automatizado, cifrado en tránsito y con una base de datos que ya no corre con la configuración de fábrica. Sabes reconstruirlo en cincuenta minutos, recuperar la base de datos a un segundo concreto, diagnosticar una latencia con eBPF y decidir con números si hace falta alta disponibilidad.

Y aun así falta algo, porque «funciona» y «está en producción» no son lo mismo. Un sistema en producción es aquel del que otras personas dependen sin saber que existe: tiene un dueño, un objetivo de servicio acordado, alguien que lo mira cada mañana, alertas que se atienden, un procedimiento cuando se rompe y un registro de por qué está como está. Nada de eso es software; todo eso es operación.

Esta lección cierra el curso poniendo esa capa. La checklist completa con su evidencia, la monitorización con Prometheus y Grafana que quedó aplazada desde 05-07, alertas que se puedan atender sin odiarlas, la rutina diaria, la revisión post-incidente sin culpables, y el presupuesto de error que convierte el 99,8 % de 07-07 en una decisión ejecutable. Y las tres deudas que siguen abiertas desde el Módulo 5, que se cierran hoy.

Contenido

  1. Qué significa estar en producción
  2. La checklist de puesta en producción
  3. Monitorizar y alertar: no son lo mismo
  4. Prometheus y node_exporter
  5. PromQL de lo imprescindible
  6. Métricas propias: las que ya tienes sin saberlo
  7. Grafana y el panel mínimo
  8. Alertas que se pueden atender
  9. Registros centralizados: la deuda de 05-06
  10. Operación diaria, semanal y mensual
  11. El cuaderno de guardia y el registro de cambios
  12. La revisión post-incidente sin culpables
  13. Gestión del cambio y ventanas de despliegue
  14. SLI, SLO y presupuesto de error
  15. Cierre de las deudas pendientes
  16. Cumplimiento: RGPD y retención de datos
  17. Cierre del curso

Qué significa estar en producción

«Funciona en mi máquina» En producción
Quién depende de ello Tú Personas que no saben que existe
Cuándo tiene que funcionar Cuando lo miras Siempre
Si se rompe de madrugada Se arregla mañana Alguien se entera y actúa
Configuración En tu cabeza En código, versionada
Cambios Cuando apetece Con ventana y aprobación
Datos Reproducibles Irreemplazables
Objetivo de disponibilidad Ninguno Acordado y medido
Cuando alguien pregunta «¿va bien?» «Creo que sí» Un número

La última fila es la que resume la lección. La diferencia entre un sistema aficionado y uno profesional no es la tecnología: es que ante «¿va bien?» hay un dato, no una impresión.

Y hay una prueba práctica, más útil que cualquier definición:

¿Podrías irte dos semanas de vacaciones sin ordenador?

Para responder que sí hacen falta seis cosas: que el sistema se autorrepare en los fallos previstos, que alguien reciba las alertas, que ese alguien tenga procedimientos escritos que pueda seguir sin ser tú, que las copias se verifiquen solas, que haya un camino de escalado, y que exista un registro de qué se ha cambiado y por qué. Esta lección construye las seis.

La checklist de puesta en producción

Cada fila lleva evidencia comprobable: un comando que devuelve un resultado, no una casilla de conciencia. Sin evidencia, una checklist es una lista de buenas intenciones.

Infraestructura

# Requisito Evidencia Estado
1 Sistema operativo con soporte a largo plazo lsb_release -d → Ubuntu 24.04 LTS ✅ 01-04
2 Configuración reproducible en código ansible-playbook --check --diff sin cambios ✅ 07-06
3 Reconstrucción completa medida Registro del último ensayo: 50 min ✅ 07-06
4 Servicios gestionados por systemd, no scripts sueltos systemctl list-units --failed vacío ✅ 05-05
5 Arranque verificado tras reinicio systemd-analyze critical-chain sin fallos ✅ 07-01
6 Recursos con margen medido revision_salud.sh → 0 ✅ 05-07
7 Entorno de pruebas equivalente srv-tramontana-pruebas operativo ✅ 07-04

Seguridad

# Requisito Evidencia Estado
8 Cortafuegos de lista blanca ufw status verbose ✅ 06-03
9 SSH sin contraseña, sin root, con fail2ban sshd -T | grep -E 'permitroot|passwordauth' ✅ 06-02
10 Cifrado en tránsito activo curl -sI https://... | grep strict-transport ✅ 08-01
11 Certificado válido y renovándose solo certbot certificates; revisar_certificado.sh ✅ 06-05
12 Secretos fuera del código y cifrados pass ls; systemd-creds list ✅ 06-05
13 Servicio con mínimo privilegio systemd-analyze security tramontana → 1.6 ✅ 05-05
14 Confinamiento obligatorio activo aa-status | grep tramontana (enforce) ✅ 06-06
15 Detección de cambios en ficheros aide --check; BD fuera del servidor ✅ 06-04
16 Auditoría de eventos sensibles auditctl -l con reglas cargadas ✅ 06-04
17 Actualizaciones de seguridad al día apt list --upgradable | grep -c security → 0 ✅ 06-06
18 Revisión de exposición externa Índice de Lynis: 82 ✅ 06-06
19 Acceso remoto sin exponer servicios wg show con pares activos ✅ 08-04

Datos

# Requisito Evidencia Estado
20 RPO y RTO acordados por escrito RPO 15 min, RTO 2 h ✅ 07-06, 08-02
21 Copias automáticas y verificadas comprobar_copia.sh → 0; restic check ✅ 05-08
22 Copia fuera del servidor (regla 3-2-1) restic snapshots en repositorio remoto ✅ 05-08
23 Copia externa inmutable (append-only) Token de solo añadir ⚠️ Se cierra hoy
24 Restauración ensayada con tiempo medido Runbook RB-BD-02: 24 min ✅ 08-02
25 Recuperación a un punto en el tiempo Archivado de WAL: failed_count = 0 ✅ 08-02
26 Cifrado en reposo de la copia LUKS + cifrado de restic ✅ 05-04, 06-05
27 Política de retención acorde con RGPD Definida y aplicada ⚠️ Pendiente: auditoria

Observabilidad

# Requisito Evidencia Estado
28 Registros persistentes y rotados journalctl --disk-usage; logrotate -d ✅ 05-06
29 Monitorización continua con histórico up{job="tramontana"} = 1 ⚠️ Se cierra hoy
30 Alertas que llegan a una persona Ruta de Alertmanager probada ⚠️ Se cierra hoy
31 Panel con los cuatro golden signals Grafana operativo ⚠️ Se cierra hoy
32 Extremo de salud significativo curl -s /salud | jq .estado ✅ 07-07

Operación

# Requisito Evidencia Estado
33 Objetivo de disponibilidad formal SLO acordado con Marta ⚠️ Se cierra hoy
34 Runbooks fuera del servidor Archivador + repositorio ✅ 05-08, 08-02
35 Rutina diaria definida y ejecutada Tabla del apartado 10 ⚠️ Se cierra hoy
36 Registro de cambios git log de ~/tramontana-infra ✅ 07-06
37 Despliegue reversible y probado desplegar.sh con rollback ✅ 04-07
38 Escalado definido: quién y cuándo Apartado 8 ⚠️ Se cierra hoy

Documentación y cumplimiento

# Requisito Evidencia Estado
39 Inventario de sistemas y responsables docs/inventario.md ⚠️ Pendiente
40 Todos los accesos justificados authorized_keys revisado ⚠️ authorized_keys2: hoy
41 Registro de actividades de tratamiento Documento de RGPD ⚠️ Pendiente
42 Diagrama de arquitectura actualizado docs/arquitectura.md ✅ 07-07

Resumen: 30 filas cumplidas de 42. Nueve se cierran en esta lección; tres quedan como trabajo planificado con fecha. Esa cifra es en sí misma la evidencia número 43: saber exactamente qué falta.

Monitorizar y alertar: no son lo mismo

Es la distinción que evita el error más caro de esta área.

Monitorizar Alertar
Pregunta que responde «¿Qué está pasando y qué pasó?» «¿Alguien tiene que hacer algo ahora?»
Cuándo se consulta Cuando alguien mira Te busca a ti
Volumen adecuado Todo lo que se pueda medir Muy poco
Coste de un exceso Disco Que se ignoren las importantes
Herramienta Prometheus, Grafana Alertmanager

Monitoriza todo lo que puedas; alerta de casi nada. Es contraintuitivo y es la única forma de que las alertas sirvan. Un sistema que envía cuarenta notificaciones al día es un sistema sin alertas, porque nadie las lee.

Los cuatro golden signals

De 05-07, ahora con instrumentación real:

Señal Qué mide En Tramontana
Latencia Cuánto tarda una petición $upstream_response_time de 08-01
Tráfico Cuánta demanda hay Peticiones por segundo
Errores Qué proporción falla Respuestas 5xx
Saturación Cuán lleno está el sistema CPU, memoria, conexiones de PgBouncer

Dos matices que hacen la diferencia entre medir y medir bien:

La latencia se mide en percentiles, nunca en media. Una media de 80 ms puede esconder que el 1 % de los usuarios espera 4 segundos. Y hay que separar la latencia de las peticiones correctas de la de las erróneas: un error 500 devuelto en 2 ms mejora la media y empeora el servicio.

La latencia de los errores se excluye del SLI. Si no, una caída total —donde todo falla rápido— aparecería como una mejora de rendimiento.

Y el método USE, complementario, para cada recurso: Utilización, Saturación y Errores.

Golden signals Método USE
Punto de vista Del usuario De los recursos
Responde a «¿Está bien el servicio?» «¿Qué recurso es el cuello de botella?»
Cuándo se usa Alertar Diagnosticar

Prometheus y node_exporter

Prometheus es una base de datos de series temporales que recolecta métricas: en lugar de que los servicios le envíen datos, él los pide periódicamente a extremos HTTP.

Recolección (Prometheus) Envío (StatsD, Graphite)
Quién inicia El servidor El cliente
Detectar que un servicio ha muerto Trivial: up == 0 Difícil: ausencia de datos
Configuración Centralizada En cada cliente
Objetivos efímeros Necesita descubrimiento Natural

Esa segunda fila es una ventaja enorme: con recolección, un servicio caído produce inmediatamente up == 0, que es una señal explícita.

$ sudo apt install prometheus prometheus-node-exporter
$ prometheus --version
prometheus, version 2.48.1 (branch: HEAD)

Las unidades del paquete son razonables pero no cumplen el estándar del curso. Drop-ins, como siempre:

# /etc/systemd/system/prometheus-node-exporter.service.d/override.conf
[Service]
# Escuchar SOLO en localhost: Prometheus corre en la misma maquina.
# Sin esto, cualquiera en 10.0.2.0/24 lee metricas que revelan
# version del kernel, sistemas de ficheros y procesos.
ExecStart=
ExecStart=/usr/bin/prometheus-node-exporter \
  --web.listen-address=127.0.0.1:9100 \
  --collector.systemd \
  --collector.textfile.directory=/var/lib/node_exporter/textfile \
  --no-collector.wifi --no-collector.hwmon --no-collector.infiniband

ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
CapabilityBoundingSet=
MemoryMax=256M
CPUQuota=20%
$ sudo mkdir -p /var/lib/node_exporter/textfile
$ sudo chown prometheus:prometheus /var/lib/node_exporter/textfile
$ sudo systemctl daemon-reload && sudo systemctl restart prometheus-node-exporter
$ systemd-analyze security prometheus-node-exporter
→ Overall exposure level: 2.1 OK
# /etc/prometheus/prometheus.yml
global:
  scrape_interval: 15s          # cada cuánto se piden las métricas
  evaluation_interval: 15s      # cada cuánto se evalúan las reglas
  external_labels:
    entorno: produccion
    servidor: srv-tramontana

rule_files:
  - /etc/prometheus/reglas/*.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['127.0.0.1:9093']

scrape_configs:
  # El propio Prometheus: si él falla, hay que saberlo
  - job_name: prometheus
    static_configs:
      - targets: ['127.0.0.1:9090']

  # Métricas del sistema: CPU, memoria, disco, red, systemd
  - job_name: node
    static_configs:
      - targets: ['127.0.0.1:9100']
        labels: {instancia: srv-tramontana}

  # La aplicación (requiere que Luis exponga /metrics)
  - job_name: tramontana
    metrics_path: /metrics
    static_configs:
      - targets: ['127.0.0.1:8080']
    # Descartar métricas de alta cardinalidad: una etiqueta con el ID
    # de reserva crearía una serie por reserva y reventaría la memoria.
    metric_relabel_configs:
      - source_labels: [__name__]
        regex: 'tramontana_reserva_detalle.*'
        action: drop

  - job_name: nginx
    static_configs:
      - targets: ['127.0.0.1:9113']

  - job_name: postgres
    static_configs:
      - targets: ['127.0.0.1:9187']
$ sudo promtool check config /etc/prometheus/prometheus.yml
Checking /etc/prometheus/prometheus.yml
 SUCCESS: 1 rule files found

$ sudo systemctl reload prometheus
$ curl -s 'http://127.0.0.1:9090/api/v1/targets' | \
      jq -r '.data.activeTargets[] | "\(.labels.job)\t\(.health)"'
prometheus	up
node	up
tramontana	up
nginx	up
postgres	up

promtool check config es el nginx -t de Prometheus, y se usa igual: siempre antes de recargar.

Sobre la retención y el disco, que es la pregunta que siempre surge:

$ sudo du -sh /var/lib/prometheus/metrics2
412M	/var/lib/prometheus/metrics2

# Estimacion: bytes ≈ series x (tiempo / intervalo) x ~2 bytes por muestra
# 4.000 series x (30 dias / 15 s) x 2 B ≈ 1,4 GB
# /etc/default/prometheus
ARGS="--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=4GB \
      --web.listen-address=127.0.0.1:9090"

Treinta días bastan para investigar incidentes y ver tendencias mensuales. Para histórico de años existen Thanos o Mimir, y para Tramontana son innecesarios.

PromQL de lo imprescindible

PromQL asusta al principio y se reduce a cinco patrones que cubren el 90 % de los casos.

1. up: lo más importante y lo más simple.

up{job="tramontana"}
# 1 = el objetivo respondió a la última recolección; 0 = no respondió

2. rate() sobre contadores. Un contador solo sube. Su valor absoluto no dice nada; lo que importa es a qué ritmo crece.

# Peticiones por segundo, media de los ultimos 5 minutos
rate(tramontana_peticiones_total[5m])

rate() maneja correctamente los reinicios: si el contador vuelve a cero porque el proceso se reinició, lo detecta y no produce un valor negativo absurdo.

Regla: rate() solo sobre contadores (_total). Para medidores —memoria, temperatura, conexiones— se usa el valor directamente.

3. Agregaciones y proporciones.

# Proporcion de errores 5xx sobre el total: la SEÑAL DE ERRORES
sum(rate(nginx_http_requests_total{status=~"5.."}[5m]))
  /
sum(rate(nginx_http_requests_total[5m]))

# Uso de CPU: 'idle' es lo que sobra, asi que 1 menos eso
1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))

# Porcentaje de memoria disponible
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100

4. histogram_quantile(): los percentiles de latencia.

# p95 de latencia, en segundos
histogram_quantile(0.95,
  sum(rate(tramontana_duracion_peticion_seconds_bucket[5m])) by (le))

La etiqueta le (less or equal) define los cubos del histograma y debe conservarse en el by. Es el error más común de PromQL: agregar sin by (le) y obtener resultados sin sentido.

5. Predicción, que es lo que convierte una alerta en útil.

# ¿A este ritmo, se llenará el disco en las próximas 4 horas?
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0

Alertar de «disco al 90 %» es alertar tarde o alertar en vano —un disco estable al 91 % no es un problema—. Alertar de «a este ritmo se llena en cuatro horas» es accionable, que es la propiedad que define una buena alerta.

Métricas propias: las que ya tienes sin saberlo

Los node_exporter y compañía dan métricas técnicas. Las que de verdad importan son las que responden a preguntas del negocio, y la mayoría ya las estás calculando en los scripts del curso — solo hay que exponerlas.

El mecanismo es el recolector de ficheros de texto: cualquier script deja un .prom en un directorio y node_exporter lo publica.

#!/usr/bin/env bash
#
# metricas_tramontana.sh - Expone metricas propias a Prometheus
#
# Recoge lo que los scripts del curso ya calculan y lo publica en
# formato de exposicion de Prometheus. Se ejecuta cada 5 min.
#
set -euo pipefail

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/comunes.sh"

readonly SALIDA=/var/lib/node_exporter/textfile/tramontana.prom
readonly DOMINIO=reservas.tramontana.example

umask 022   # node_exporter debe poder LEERLO

main() {
    # Escritura ATOMICA: si el script muere a medias, node_exporter
    # leeria un fichero truncado y descartaria todas las metricas.
    local tmp; tmp="$(mktemp "${SALIDA}.XXXXXX")"
    trap 'rm -f "$tmp"' EXIT

    {
        # --- 1. Edad de la ultima copia correcta (05-08) ---
        # La metrica de copias mas util NO es "¿se ejecuto el timer?"
        # sino "¿cuanto hace que hay una copia VERIFICADA?".
        echo '# HELP tramontana_copia_edad_segundos Segundos desde la ultima copia verificada'
        echo '# TYPE tramontana_copia_edad_segundos gauge'
        if [[ -f /var/lib/tramontana/ultima-copia-correcta ]]; then
            printf 'tramontana_copia_edad_segundos %d\n' \
                $(( $(date +%s) - $(stat -c %Y /var/lib/tramontana/ultima-copia-correcta) ))
        else
            printf 'tramontana_copia_edad_segundos %d\n' 999999
        fi

        # --- 2. Dias hasta la caducidad del certificado (06-05) ---
        echo '# HELP tramontana_certificado_dias Dias hasta la caducidad del certificado TLS'
        echo '# TYPE tramontana_certificado_dias gauge'
        local fin dias
        if fin="$(echo | timeout 10 openssl s_client -connect "${DOMINIO}:443" \
                -servername "$DOMINIO" 2>/dev/null | \
                openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"; then
            dias=$(( ( $(date -d "$fin" +%s) - $(date +%s) ) / 86400 ))
            printf 'tramontana_certificado_dias %d\n' "$dias"
        fi

        # --- 3. Indice de Lynis (06-06) ---
        echo '# HELP tramontana_lynis_indice Indice de endurecimiento de Lynis'
        echo '# TYPE tramontana_lynis_indice gauge'
        if [[ -f /var/log/lynis-report.dat ]]; then
            printf 'tramontana_lynis_indice %s\n' \
                "$(awk -F= '/^hardening_index=/{print $2}' /var/log/lynis-report.dat)"
        fi

        # --- 4. Retraso de la replica de PostgreSQL (08-02) ---
        # Cuantos bytes se perderian si el primario cayera AHORA.
        echo '# HELP tramontana_replica_retraso_bytes Retraso de la replica en bytes'
        echo '# TYPE tramontana_replica_retraso_bytes gauge'
        printf 'tramontana_replica_retraso_bytes %s\n' \
            "$(sudo -u postgres psql -tAc \
               "SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn,replay_lsn)),0)::bigint
                FROM pg_stat_replication" 2>/dev/null || echo 0)"

        # --- 5. Fallos de archivado de WAL (08-02) ---
        echo '# HELP tramontana_wal_archivado_fallos Fallos acumulados de archivado de WAL'
        echo '# TYPE tramontana_wal_archivado_fallos gauge'
        printf 'tramontana_wal_archivado_fallos %s\n' \
            "$(sudo -u postgres psql -tAc \
               "SELECT failed_count FROM pg_stat_archiver" 2>/dev/null || echo 0)"

        # --- 6. Estado de revision_salud.sh (0/1/2) ---
        echo '# HELP tramontana_salud_estado 0 correcto, 1 aviso, 2 critico'
        echo '# TYPE tramontana_salud_estado gauge'
        local estado=0
        "${SCRIPT_DIR}/revision_salud.sh" >/dev/null 2>&1 || estado=$?
        printf 'tramontana_salud_estado %d\n' "$estado"

        # --- 7. Metricas de NEGOCIO: las que le importan a Marta ---
        echo '# HELP tramontana_reservas_total Reservas registradas hoy'
        echo '# TYPE tramontana_reservas_total gauge'
        printf 'tramontana_reservas_total %s\n' \
            "$(sudo -u postgres psql -tAc \
               "SELECT count(*) FROM app.reservas WHERE fecha = CURRENT_DATE" \
               -d tramontana 2>/dev/null || echo 0)"

        # --- 8. Marca de frescura: detecta que ESTE script ha muerto ---
        echo '# HELP tramontana_metricas_generadas_segundos Marca temporal de generacion'
        echo '# TYPE tramontana_metricas_generadas_segundos gauge'
        printf 'tramontana_metricas_generadas_segundos %d\n' "$(date +%s)"

    } > "$tmp"

    chmod 0644 "$tmp"
    mv "$tmp" "$SALIDA"     # atomico
    trap - EXIT
}

main "$@"
$ sudo ~/scripts/metricas_tramontana.sh
$ curl -s http://127.0.0.1:9100/metrics | grep '^tramontana_'
tramontana_copia_edad_segundos 21840
tramontana_certificado_dias 71
tramontana_lynis_indice 82
tramontana_replica_retraso_bytes 0
tramontana_wal_archivado_fallos 0
tramontana_salud_estado 0
tramontana_reservas_total 14
tramontana_metricas_generadas_segundos 1755518402

Cuatro decisiones de diseño del script:

  1. Escritura atómica con mktemp y mv. node_exporter puede leer el fichero en cualquier momento; uno truncado hace que descarte todo su contenido.
  2. umask 022, en contra de la convención general del curso: node_exporter corre como otro usuario y necesita leerlo. Es una excepción justificada, y por eso está comentada.
  3. La métrica 8, la marca de frescura, es la que hace fiables a las otras siete. Sin ella, si este script deja de ejecutarse, Prometheus seguiría publicando los últimos valores conocidos indefinidamente y todo parecería correcto para siempre. Con ella se puede alertar de que las métricas están rancias.
  4. La métrica de negocio, tramontana_reservas_total, es la que a Marta le importa y la que detecta el fallo más peligroso: el que no rompe nada. Si el sistema responde 200 a todo pero nadie consigue reservar, ninguna métrica técnica lo revelará.

Y para la aplicación, lo que Luis debe exponer en /metrics:

# HELP tramontana_peticiones_total Peticiones HTTP atendidas
# TYPE tramontana_peticiones_total counter
tramontana_peticiones_total{metodo="GET",ruta="/casas",codigo="200"} 41822

# HELP tramontana_duracion_peticion_seconds Duracion de las peticiones
# TYPE tramontana_duracion_peticion_seconds histogram
tramontana_duracion_peticion_seconds_bucket{le="0.05"} 38120
tramontana_duracion_peticion_seconds_bucket{le="0.1"}  40911
tramontana_duracion_peticion_seconds_bucket{le="0.5"}  41780
tramontana_duracion_peticion_seconds_bucket{le="+Inf"} 41822
tramontana_duracion_peticion_seconds_sum 1284.41
tramontana_duracion_peticion_seconds_count 41822

# HELP tramontana_bd_conexiones_activas Conexiones a la base de datos en uso
# TYPE tramontana_bd_conexiones_activas gauge
tramontana_bd_conexiones_activas 12

Grafana y el panel mínimo

$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://apt.grafana.com/gpg.key | \
      sudo gpg --dearmor -o /etc/apt/keyrings/grafana.gpg
$ echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | \
      sudo tee /etc/apt/sources.list.d/grafana.list
$ sudo apt update && sudo apt install grafana
# /etc/grafana/grafana.ini (fragmento)
[server]
http_addr = 127.0.0.1        # NO expuesto: se llega por Nginx o VPN
http_port = 3000
root_url = https://panel.tramontana.example/

[security]
admin_user = operador
# La contrasena se inyecta con systemd-creds (06-05), no aqui
disable_gravatar = true
cookie_secure = true
cookie_samesite = strict
content_security_policy = true

[users]
allow_sign_up = false

Y el acceso, aprovechando lo montado: por la VPN de 08-04, sin publicar nada nuevo.

$ sudo ufw allow from 10.8.0.0/24 to any port 3000 proto tcp comment 'Grafana por VPN'

El panel mínimo, seis paneles y ni uno más:

Panel Consulta Por qué
Disponibilidad (30 d) avg_over_time(up{job="tramontana"}[30d]) * 100 El número que Marta pregunta
Latencia p50/p95/p99 histogram_quantile(...) Golden signal: latencia
Peticiones por segundo sum(rate(nginx_http_requests_total[5m])) Golden signal: tráfico
Proporción de errores 5xx sobre total Golden signal: errores
Saturación CPU, memoria, disco, conexiones Golden signal: saturación
Estado del sistema Edad de copia, días de certificado, retraso de réplica, Lynis Lo que se mira cada mañana

Un consejo que contradice el instinto: un panel con cuarenta gráficas no se mira. El panel principal debe caber en una pantalla y responder en tres segundos a «¿va bien?». El detalle vive en paneles secundarios a los que se llega cuando hace falta.

Alertas que se pueden atender

La regla

Toda alerta debe requerir una acción humana inmediata. Si el receptor no puede hacer nada, o puede esperar a mañana, no es una alerta: es un panel o un informe.

La fatiga de alertas es el modo de fallo característico de la monitorización, y su progresión es siempre la misma:

  1. Se configuran alertas para todo, «por si acaso».
  2. Llegan diez al día, casi todas irrelevantes.
  3. La gente empieza a ignorarlas.
  4. Se crea un filtro de correo que las archiva.
  5. Llega la alerta importante y nadie la ve.

El sistema queda peor que sin alertas, porque hay una falsa sensación de vigilancia. Y el remedio es contraintuitivo: borrar alertas.

Síntoma frente a causa

Alerta de síntoma Alerta de causa
Qué detecta Que el servicio no funciona Que un componente falla
Ejemplo «La tasa de errores supera el 5 %» «La CPU está al 90 %»
Falsos positivos Pocos Muchos: la CPU al 90 % puede ser normal
Cobertura Detecta fallos imprevistos Solo los previstos
Ayuda a diagnosticar Poco Mucho

Regla: alerta de síntomas, diagnostica con causas. Un for: 5m sobre la tasa de errores detecta cualquier fallo que afecte al usuario, incluidos los que nadie anticipó. Una alerta de CPU al 90 % dispara cuando se ejecuta la copia nocturna y el servicio va perfectamente.

Con dos excepciones legítimas, que son alertas de causa y deben existir porque su síntoma llega demasiado tarde:

  • El disco se va a llenar. Cuando el síntoma aparece, el servicio ya está caído.
  • El certificado va a caducar. Cuando el síntoma aparece, la web ya no carga.

Ambas comparten la propiedad clave: avisan con horas o días de antelación y la acción es evidente.

# /etc/prometheus/reglas/tramontana.yml
groups:
  - name: sintomas
    interval: 30s
    rules:
      # --- CRITICAS: despiertan a alguien de madrugada ---
      - alert: ServicioCaido
        expr: up{job="tramontana"} == 0
        for: 2m                 # 2 min evita el ruido de un reinicio
        labels: {severidad: critica, equipo: operaciones}
        annotations:
          resumen: "Tramontana Reservas no responde"
          descripcion: "El objetivo {{ $labels.instance }} lleva 2 min sin responder."
          accion: "Ver runbook RB-OPS-01. systemctl status tramontana; journalctl -u tramontana -n50"
          runbook: "https://docs.tramontana.example/RB-OPS-01"

      - alert: TasaErroresAlta
        expr: |
          sum(rate(nginx_http_requests_total{status=~"5.."}[5m]))
          / sum(rate(nginx_http_requests_total[5m])) > 0.05
        for: 5m
        labels: {severidad: critica, equipo: operaciones}
        annotations:
          resumen: "Más del 5 % de las peticiones falla"
          descripcion: "Tasa actual: {{ $value | humanizePercentage }}."
          accion: "journalctl -t nginx_error -n 50; comprobar PostgreSQL"

      - alert: LatenciaDegradada
        expr: |
          histogram_quantile(0.95,
            sum(rate(tramontana_duracion_peticion_seconds_bucket[5m])) by (le)) > 2
        for: 10m
        labels: {severidad: aviso, equipo: operaciones}
        annotations:
          resumen: "p95 de latencia por encima de 2 s"
          accion: "pg_stat_statements por total_exec_time; diagnostico_latencia.sh"

  - name: causas_que_avisan_con_tiempo
    rules:
      # --- Las dos excepciones justificadas ---
      - alert: DiscoSeLlenara
        expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
        for: 15m
        labels: {severidad: critica, equipo: operaciones}
        annotations:
          resumen: "/ se llenará en menos de 4 horas al ritmo actual"
          accion: "du -sh /var/log/* /srv/*; revisar rotacion y purga"

      - alert: CertificadoCaduca
        expr: tramontana_certificado_dias < 20
        for: 1h
        labels: {severidad: aviso, equipo: operaciones}
        annotations:
          resumen: "El certificado caduca en {{ $value }} días"
          accion: "certbot renew --dry-run; revisar certbot.timer"

  - name: datos
    rules:
      - alert: CopiaAntigua
        # Umbral en 2x el RPO acordado: no alerta por un retraso normal
        expr: tramontana_copia_edad_segundos > 2*4*3600
        for: 30m
        labels: {severidad: critica, equipo: operaciones}
        annotations:
          resumen: "Sin copia verificada desde hace {{ $value | humanizeDuration }}"
          accion: "Ver runbook RB-BD-01. journalctl -u tramontana-respaldo"

      - alert: ArchivadoWALFallando
        # Alerta de causa deliberada: el sintoma (PostgreSQL detenido)
        # aparece horas despues y para entonces no hay salida facil.
        expr: tramontana_wal_archivado_fallos > 0
        for: 5m
        labels: {severidad: critica, equipo: operaciones}
        annotations:
          resumen: "El archivado de WAL está fallando"
          accion: "df -h /srv/tramontana/backups; ver incidente del 2026-08-18"

      - alert: ReplicaRetrasada
        expr: tramontana_replica_retraso_bytes > 100*1024*1024
        for: 10m
        labels: {severidad: aviso, equipo: operaciones}
        annotations:
          resumen: "La réplica va {{ $value | humanize1024 }}B por detrás"

  - name: metamonitorizacion
    rules:
      # La alerta que vigila a la monitorizacion. Sin ella, un script
      # de metricas muerto hace que TODO parezca correcto para siempre.
      - alert: MetricasRancias
        expr: time() - tramontana_metricas_generadas_segundos > 1800
        for: 5m
        labels: {severidad: aviso, equipo: operaciones}
        annotations:
          resumen: "Las métricas propias llevan más de 30 min sin actualizarse"
          accion: "systemctl status metricas-tramontana.timer"
$ sudo promtool check rules /etc/prometheus/reglas/tramontana.yml
  SUCCESS: 10 rules found

# Probar una regla SIN esperar a que ocurra
$ sudo promtool test rules /etc/prometheus/pruebas/reglas_test.yml
Unit Testing:  SUCCESS

Alertmanager: rutas y severidades

# /etc/alertmanager/alertmanager.yml
global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'severidad']
  group_wait: 30s          # esperar 30 s por si llegan alertas relacionadas
  group_interval: 5m
  repeat_interval: 4h      # recordatorio cada 4 h si sigue activa
  receiver: correo-operaciones

  routes:
    # Críticas: correo y, fuera de horario, teléfono
    - matchers: [severidad="critica"]
      receiver: guardia
      group_wait: 10s
      repeat_interval: 1h
    # Avisos: solo correo, sin prisa
    - matchers: [severidad="aviso"]
      receiver: correo-operaciones
      repeat_interval: 12h

# Silenciar alertas derivadas cuando la causa raíz ya alertó
inhibit_rules:
  - source_matchers: [alertname="ServicioCaido"]
    target_matchers: [severidad=~"aviso|critica"]
    equal: [instance]

receivers:
  - name: correo-operaciones
    email_configs:
      - to: [email protected]
        headers: {Subject: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'}

  - name: guardia
    email_configs:
      - to: [email protected]
    webhook_configs:
      - url: 'http://127.0.0.1:9095/telefono'

Las inhibit_rules merecen atención: cuando el servicio cae, se dispararían también las alertas de latencia, de errores y de conexiones a la base de datos. Recibir cinco notificaciones del mismo incidente es fatiga de alertas en su forma más pura. La inhibición envía una: la causa.

Severidades y escalado

Severidad Criterio Canal Respuesta
Crítica El servicio está caído o hay riesgo de pérdida de datos Teléfono, 24×7 Inmediata
Aviso Degradado, o algo se romperá en horas Correo Horario laboral
Informativa Conviene saberlo Panel Revisión semanal
Nivel 1: operador (tú)                 -> 15 min sin respuesta
Nivel 2: Luis (desarrollo)             -> 30 min sin respuesta
Nivel 3: Marta (decisiones de negocio) -> se le informa siempre en críticas

Y el silencio programado durante el mantenimiento, sin el cual la ventana de despliegue genera una avalancha de notificaciones:

$ amtool silence add alertname=~".*" --duration=1h \
      --author=operador --comment="Ventana de mantenimiento: despliegue 3.3.0"
b3f19c8d-4a2e-4f91-b7c2-1e9d0f8a3b45

$ amtool silence expire b3f19c8d-4a2e-4f91-b7c2-1e9d0f8a3b45   # al terminar

Los silencios se ponen con caducidad, siempre. Un silencio indefinido puesto un martes es una alerta que no volverá a sonar nunca, y nadie se acordará.

Registros centralizados: la deuda de 05-06

En 05-06 quedó una pieza pendiente: el journal es persistente y local. Con un solo servidor funciona; en cuanto hay dos —los nodos de aplicación de 07-07, el balanceador, la réplica— diagnosticar exige entrar en cada máquina y correlacionar a mano.

Por qué hace falta centralizar:

Problema Con registros locales Centralizados
Correlacionar entre máquinas A mano, con marcas de tiempo Una consulta
Un servidor comprometido El atacante borra sus huellas Ya están fuera
Un servidor destruido Se pierden los registros Conservados
Retención legal Por máquina Una política
Buscar en 30 días de logs zgrep sobre ficheros rotados Índice

Esa segunda fila es de seguridad pura y enlaza con 06-04: si los registros solo viven en la máquina atacada, el atacante los edita. Es el mismo razonamiento por el que la base de datos de AIDE vive fuera del servidor.

Loki como opción ligera:

Loki Elasticsearch/OpenSearch journald remoto
Qué indexa Solo las etiquetas, no el texto Todo el texto Nada
Recursos Muy bajos Altos: 4-8 GB de RAM mínimo Mínimos
Consulta LogQL, parecido a PromQL Muy potente journalctl
Integración con Grafana Nativa: mismo panel Buena No
Coste de almacenamiento Bajo: objetos comprimidos Alto Bajo

Loki es la elección para Tramontana por dos razones: no indexa el texto completo —que es lo que hace a Elasticsearch caro— y comparte etiquetas y sintaxis con Prometheus, de modo que se puede saltar de una gráfica de latencia a los registros de ese mismo instante en el mismo panel. Esa correlación es lo que hace útil un sistema de registros.

# /etc/promtail/config.yml (agente que envía los registros)
clients:
  - url: http://10.0.2.15:3100/loki/api/v1/push

scrape_configs:
  - job_name: journal
    journal:
      max_age: 12h
      labels: {job: systemd-journal, servidor: srv-tramontana}
    relabel_configs:
      - source_labels: ['__journal__systemd_unit']
        target_label: unidad
      - source_labels: ['__journal_priority_keyword']
        target_label: nivel
# Consulta LogQL: errores de la aplicacion en la ultima hora
{unidad="tramontana.service", nivel=~"err|crit"} |= "ERROR"

# Tasa de errores por minuto, que se puede graficar junto a las metricas
sum(rate({unidad="tramontana.service"} |= "ERROR" [5m]))

Y la advertencia obligatoria: los registros contienen datos personales —IP, identificadores de usuario, a veces correos—. Centralizarlos multiplica el alcance de una fuga. Retención acotada, acceso restringido y el mismo tratamiento que la base de datos.

Operación diaria, semanal y mensual

Los cinco minutos de cada mañana

# Qué se mira Dónde Señal de alarma
1 Alertas activas Alertmanager Cualquiera sin atender
2 Panel principal Grafana Anomalía frente a ayer
3 Edad de la última copia Panel > 24 h
4 Unidades fallidas systemctl --failed Cualquiera
5 Errores nuevos en el registro Loki o journalctl -p err Patrón desconocido
#!/usr/bin/env bash
# revision_matutina.sh — los cinco minutos, en un comando
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib/comunes.sh"

main() {
    printf '\n=== REVISIÓN MATUTINA · %s ===\n\n' "$(date '+%F %T')"

    printf '── Alertas activas ──\n'
    amtool alert query --output=extended 2>/dev/null | head -10 || echo "  (ninguna)"

    printf '\n── Unidades fallidas ──\n'
    systemctl --failed --no-legend || echo "  (ninguna)"

    printf '\n── Estado general ──\n'
    curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_salud_estado' | \
        jq -r '.data.result[0].value[1] as $e |
               "  revision_salud: " + (if $e=="0" then "correcto"
                                       elif $e=="1" then "AVISO" else "CRITICO" end)'
    curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_copia_edad_segundos' | \
        jq -r '"  ultima copia: hace " + ((.data.result[0].value[1]|tonumber/3600|floor)|tostring) + " h"'
    curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_certificado_dias' | \
        jq -r '"  certificado: " + .data.result[0].value[1] + " dias"'

    printf '\n── Errores de las últimas 24 h ──\n'
    journalctl -p err --since "24 hours ago" --no-pager -q | \
        awk '{$1=$2=$3=""; print}' | sort | uniq -c | sort -rn | head -5

    printf '\n── Disponibilidad (7 d) ──\n'
    curl -s --data-urlencode 'query=avg_over_time(up{job="tramontana"}[7d])*100' \
        'http://127.0.0.1:9090/api/v1/query' | \
        jq -r '"  " + (.data.result[0].value[1]|tonumber|.*100|round/100|tostring) + " %"'
    printf '\n'
}

main "$@"
$ ~/scripts/revision_matutina.sh

=== REVISIÓN MATUTINA · 2026-08-18 08:04:11 ===

── Alertas activas ──
  (ninguna)

── Unidades fallidas ──
  (ninguna)

── Estado general ──
  revision_salud: correcto
  ultima copia: hace 6 h
  certificado: 71 dias

── Errores de las últimas 24 h ──
      3 tramontana[1204]: ERROR consulta lenta en /informes/facturacion
      1 nginx_error: upstream timed out

── Disponibilidad (7 d) ──
  99.94 %

Semanal (30 minutos) y mensual (2 horas)

Frecuencia Tarea Por qué
Semanal Revisar tendencias de 7 días Ver crecimientos lentos
Semanal Actualizaciones de seguridad pendientes Ventana de exposición
Semanal Top 5 de pg_stat_statements Consultas que se degradan
Semanal Espacio en disco y crecimiento Anticipar, no reaccionar
Semanal Registro de cambios de la semana Contexto para incidentes
Mensual Restaurar un fichero de la copia Verificar de verdad
Mensual Revisar y podar alertas Fatiga de alertas
Mensual Lynis y AIDE Deriva de configuración
Mensual Revisar accesos: usuarios, claves SSH, pares de VPN Se acumulan
Mensual Consumo del presupuesto de error Decidir ritmo de cambios
Trimestral Ensayo completo de PITR (RB-BD-02) La copia sin ensayo es un fichero
Semestral Simulacro de reconstrucción Validar el RTO
Anual Revisar SLO, modelo de amenazas y arquitectura El contexto cambia

El cuaderno de guardia y el registro de cambios

El cuaderno de guardia es un fichero de texto donde se anota lo que pasa. Suena trivial y es la herramienta que más tiempo ahorra en un incidente, porque responde a «¿esto ya nos había pasado?».

# Cuaderno de guardia · srv-tramontana

## 2026-08-18

**08:04** Revisión matutina. Todo correcto. Disponibilidad 7 d: 99,94 %.

**11:20** [CAMBIO] Nginx como proxy inverso con TLS. Cerrada la deuda del
cifrado en tránsito abierta desde 06-05. HSTS con max-age=300 a propósito;
subir a 2 años el 25/08 tras verificar la renovación. Medido: -78 % de bytes
transferidos, +19 ms en el primer handshake.

**14:32** [INCIDENTE] `DELETE` accidental sobre app.reservas (40.218 filas).
Detectado 14:51. Recuperado por PITR en máquina de pruebas y reimportado.
Tiempo total 34 min. Sin pérdida de datos. Post-incidente: 20/08.

**16:10** [OBSERVACIÓN] p95 sube a 1,4 s los martes entre 16 y 18 h. Coincide
con el informe de facturación de Marta. No es un problema hoy; vigilar.

## 2026-08-17

**03:14** [ALERTA] Espacio en /var al 91 %. Causa: lv-backups lleno →
archive_command falla → pg_wal crece. Resuelto purgando WAL con
pg_archivecleanup y ampliando el LV 10 GiB. **Alerta nueva creada:
ArchivadoWALFallando (failed_count > 0).**

Cuatro reglas para que funcione:

  1. Se escribe en el momento, no al final del día. Lo que se pospone no se escribe.
  2. Marcas de tiempo siempre, para poder correlacionar con las gráficas.
  3. Las observaciones valen tanto como los incidentes. La entrada de las 16:10 es la que explicará una alerta dentro de dos meses.
  4. Vive fuera del servidor —en el repositorio de Ansible— porque hará falta precisamente cuando el servidor no esté.

El registro de cambios ya existe: es el git log de ~/tramontana-infra. La disciplina es que todo cambio de configuración pase por ahí, para que se pueda responder a «¿qué cambió antes de que empezara a fallar?».

$ cd ~/tramontana-infra && git log --oneline --since="7 days ago"
a4f19c8 Rol web: HSTS a 2 años tras verificar renovación
3e2b1d5 Rol basedatos: ajuste de memoria para 3,8 GB + PgBouncer
9c8a7f2 Alerta ArchivadoWALFallando tras el incidente del 17/08
1b4d6e3 Rol vpn: alta de marta-tableta

# La pregunta clave durante un incidente
$ git log --since="6 hours ago" --stat

La revisión post-incidente sin culpables

Un incidente es un fallo del sistema, no de una persona. Si alguien pudo borrar 40.000 filas con un comando, el problema es que el sistema lo permitiera sin fricción, no que esa persona se equivocara.

Esto no es amabilidad corporativa: es la única forma de obtener información verdadera. En una cultura donde se buscan culpables, la gente oculta los errores, y entonces los incidentes se repiten porque nadie sabe que ocurrieron. La revisión sin culpables es una decisión de ingeniería.

Revisión post-incidente: borrado accidental de reservas

Incidente: INC-2026-003 · Fecha: 2026-08-18 · Severidad: Alta Duración del impacto: 34 min · Datos perdidos: ninguno Redactado por: Operaciones · Revisado con: Marta, Luis

1. Qué pasó

Durante una limpieza rutinaria de datos antiguos, se ejecutó en la base de datos de producción un DELETE cuya cláusula WHERE no era la prevista, eliminando 40.218 reservas históricas. Se detectó 19 minutos después y se recuperaron mediante recuperación a un punto en el tiempo. No hubo pérdida de datos ni interrupción del servicio.

2. Impacto

Dimensión Impacto
Disponibilidad del servicio Ninguno: la web siguió funcionando
Datos perdidos definitivamente Ninguno
Datos temporalmente inaccesibles 40.218 reservas históricas, 34 min
Clientes afectados Ninguno (datos de consulta interna)
Presupuesto de error consumido 0 %

3. Línea temporal

Hora Suceso
14:30 Se inicia la limpieza de datos anteriores a 2024
14:32 Se ejecuta el DELETE con la condición incorrecta
14:51 Un informe devuelve cifras anómalas: se detecta
14:53 Se detiene toda escritura adicional. Se abre el incidente
14:58 Se localiza la marca temporal exacta en pg_stat_statements
15:02 Comienza la restauración PITR en srv-tramontana-pruebas
15:19 Recuperación completada y verificada en pruebas
15:24 Datos reimportados a producción con psql -1
15:26 Verificación final. Incidente cerrado

4. Causa raíz: los cinco porqués

# Pregunta Respuesta
1 ¿Por qué se borraron 40.218 reservas? Un DELETE con WHERE incorrecto
2 ¿Por qué se ejecutó con la condición incorrecta? Se escribió a mano, sin SELECT previo
3 ¿Por qué se escribió a mano? No existe procedimiento para limpiezas de datos
4 ¿Por qué no existe? Nunca se había hecho una limpieza masiva
5 ¿Por qué hizo falta ahora? La tabla de auditoría creció sin política de retención

Causa raíz: la ausencia de una política de retención de datos obligó a una limpieza manual improvisada, sin procedimiento ni salvaguardas.

Y una observación sobre el método: los cinco porqués atraviesan la respuesta fácil —«alguien se equivocó al teclear»— hasta llegar a una causa sistémica y accionable. Quedarse en el porqué número 2 habría producido la conclusión inútil de «hay que tener más cuidado».

5. Qué funcionó bien

Esta sección es obligatoria y se olvida siempre:

  • La recuperación a un punto en el tiempo funcionó exactamente como se ensayó (RB-BD-02, 08-02). El ensayo trimestral demostró su valor a los tres días.
  • La restauración en máquina aparte preservó las transacciones posteriores. Restaurar sobre producción habría perdido 19 minutos de reservas reales.
  • La detección en 19 minutos fue razonable, aunque mejorable.
  • Se avisó de inmediato, sin ocultarlo. Eso permitió actuar rápido.

6. Qué se cambia

# Acción Responsable Fecha Estado
1 Política de retención de auditoria acordada con Marta Marta + Ops 25/08 Pendiente
2 Runbook de limpiezas: SELECT count(*) obligatorio antes de cualquier DELETE Ops 22/08 En curso
3 Rol svc_limpieza con statement_timeout y sin permiso de DELETE masivo Ops 25/08 Pendiente
4 Purga programada como timer idempotente y probado, no manual Ops 31/08 Pendiente
5 Alerta si una tabla pierde más del 10 % de filas en 5 min Ops 25/08 Pendiente
6 psql de producción con \set ON_ERROR_ROLLBACK off y aviso en el prompt Ops 22/08 Hecho

Ninguna acción es «tener más cuidado». Una acción correctiva que depende de la atención humana no es una acción correctiva: es una expresión de deseo.

7. Lecciones

  1. Los ensayos de recuperación se pagan solos. El de 08-02 se hizo el día 15; el día 18 hizo falta de verdad, y nadie tuvo que improvisar.
  2. Una tabla que crece sin política de retención acaba forzando una operación arriesgada. El crecimiento silencioso es una deuda que se cobra de golpe.
  3. Detectar en 19 minutos es mucho para un borrado masivo. La acción 5 lo bajará a menos de 5.

Gestión del cambio y ventanas de despliegue

La mayoría de las interrupciones no vienen de fallos de hardware: vienen de cambios. Y sin embargo el cambio es necesario. Gestionarlo es ponerle fricción proporcional al riesgo, no impedirlo.

Tipo de cambio Ejemplos Aprobación Ventana
Estándar Actualizaciones de seguridad, alta de VPN Ninguna: preaprobado Cualquier momento
Normal Despliegue de versión, cambio de configuración Revisión de un compañero Ventana acordada
Mayor Cambio de esquema, versión mayor de PostgreSQL Marta + plan de vuelta atrás Ventana planificada
De emergencia Parche de vulnerabilidad crítica Posterior Inmediata

La ventana de Tramontana: martes y miércoles, de 10:00 a 12:00. Y las razones de por qué no se despliega el viernes por la tarde, que es la regla más famosa del sector:

  1. Los problemas aparecen con carga real, horas después. Un viernes a las 18:00, esa carga llega el lunes.
  2. La gente se va. Si algo falla a las 20:00 del viernes, quien lo desplegó ya no está.
  3. El fin de semana es cuando más reservas se hacen en Tramontana: el peor momento posible para un fallo.
  4. Un incidente el viernes se arrastra el fin de semana, con la persona de guardia trabajando en un cambio que no hizo.

La regla, formulada correctamente: no se despliega si no hay tiempo suficiente por delante para detectar y revertir el problema con el equipo disponible. El martes por la mañana cumple; el viernes por la tarde no.

Lista de verificación previa a cada despliegue:

# Comprobación
1 ¿Está probado en srv-tramontana-pruebas?
2 ¿Hay copia reciente y verificada?
3 ¿Está escrito el plan de vuelta atrás?
4 ¿Las migraciones de esquema son compatibles hacia atrás?
5 ¿Está avisado quien deba estarlo?
6 ¿Hay silencio de alertas programado y con caducidad?
7 ¿Queda presupuesto de error?
8 ¿Hay tiempo por delante para revertir con calma?

SLI, SLO y presupuesto de error

Tres conceptos que se confunden y son distintos:

SLI SLO SLA
Qué es Un indicador medido Un objetivo interno Un acuerdo contractual
Ejemplo 99,94 % de peticiones correctas ≥ 99,8 % mensual 99,5 %, con penalización
Quién lo fija La medición El equipo con negocio Legal y comercial
Si se incumple — Se cambia el ritmo de trabajo Hay consecuencias económicas

El SLO se pone siempre más exigente que el SLA, para tener margen antes de incumplir el contrato.

El SLO de Tramontana

Recogiendo el 99,8 % propuesto en 07-07 y convirtiéndolo en números concretos:

SLI: proporción de peticiones HTTP a reservas.tramontana.example respondidas con código < 500 y en menos de 2 segundos.

SLO: ≥ 99,8 % de esas peticiones, medido en ventana móvil de 30 días.

# El SLI, tal cual se mide
(
  sum(rate(nginx_http_requests_total{status!~"5.."}[30d]))
  - sum(rate(tramontana_duracion_peticion_seconds_count{le="2"}[30d]))
) / sum(rate(nginx_http_requests_total[30d]))

El presupuesto de error

Aquí está la idea que cambia la forma de trabajar:

Presupuesto de error = 100 % − SLO. Es la cantidad de fallo que te puedes permitir. No es un residuo: es un recurso que se gasta.

SLO Presupuesto En 30 días
99,9 % 0,1 % 43 min 12 s
99,8 % 0,2 % 1 h 26 min 24 s
99,5 % 0,5 % 3 h 36 min

Tramontana puede estar caída 1 h 26 min cada 30 días y seguir cumpliendo su objetivo. Ese tiempo es un presupuesto que se puede gastar deliberadamente: en despliegues arriesgados, en actualizaciones, en experimentos.

Y de ahí sale una regla de gestión que sustituye a las discusiones eternas entre «hay que ir más rápido» y «hay que ser más estables»:

Presupuesto consumido Decisión
< 50 % Se puede desplegar con normalidad. Hay margen
50-80 % Despliegues normales, más cuidado con los mayores
80-100 % Solo cambios estándar y correcciones. Se congelan las funciones nuevas
Agotado Congelación total. Todo el esfuerzo a estabilizar
# Presupuesto consumido en los ultimos 30 dias, en porcentaje
(1 - (
  sum(rate(nginx_http_requests_total{status!~"5.."}[30d]))
  / sum(rate(nginx_http_requests_total[30d]))
)) / 0.002 * 100
$ curl -s --data-urlencode 'query=(1 - (sum(rate(nginx_http_requests_total{status!~"5.."}[30d])) / sum(rate(nginx_http_requests_total[30d])))) / 0.002 * 100' \
    http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
31.4

31,4 % consumido: hay margen. Ese número va al panel principal, y es lo que convierte «¿desplegamos?» de una discusión de opiniones en una consulta de datos.

Lo más valioso del presupuesto de error, y suele sorprender: es tan malo agotarlo como no gastarlo. Un equipo que termina el mes con el 5 % consumido está siendo demasiado conservador — podría haber desplegado más, entregado más valor y asumido más riesgo dentro de lo acordado. La fiabilidad excesiva también tiene un coste: el de las funciones que no se hicieron.

Cierre de las deudas pendientes

Deuda 1: la copia externa no es append-only

El problema. restic sube las copias con un token que también puede borrar. Un atacante con acceso a srv-tramontana puede ejecutar restic forget --prune y destruir todo el histórico. Es exactamente el modo de operar del ransomware moderno: primero borra las copias, luego cifra los datos.

# La comprobación que revela la deuda
$ restic forget --keep-last 1 --dry-run
# Si esto NO da un error de permisos, el token puede borrar.

La solución: un token de solo añadir, más un restic en modo --append-only en el lado del repositorio.

# 1. Token nuevo en el proveedor, SIN permiso de borrado
#    (Backblaze B2: "Write Only"; S3: politica sin s3:DeleteObject)
$ pass insert tramontana/restic-append-only

# 2. Bloqueo de objetos en el proveedor: nada se puede borrar
#    durante 30 dias, ni siquiera con credenciales de administrador
$ b2 update-bucket --defaultRetentionMode compliance \
      --defaultRetentionPeriod "30 days" tramontana-copias

# 3. El servidor usa el token restringido
$ sudo sed -i 's|^RESTIC_TOKEN=.*|RESTIC_TOKEN_CMD="pass tramontana/restic-append-only"|' \
      /etc/tramontana/respaldo.env

# 4. VERIFICAR que no puede borrar
$ RESTIC_PASSWORD=$(pass restic/tramontana) restic forget --keep-last 1 --dry-run
Fatal: unable to remove files: AccessDenied

# 5. La purga se hace desde OTRA MAQUINA con credenciales distintas,
#    mensualmente, con un token que sí puede borrar y que NUNCA está
#    en el servidor respaldado.

El principio general, aplicable mucho más allá de esto: el sistema respaldado no debe poder destruir sus copias. Si puede, no son copias: son una réplica con retraso.

Antes Después
El servidor puede borrar copias Sí No
Superviviente a un ransomware No Sí, 30 días
Superviviente a un rm accidental No Sí
Quién purga El propio servidor Otra máquina, mensualmente

Checklist fila 23: cerrada.

Deuda 2: el authorized_keys2 sin explicar

$ ls -la /home/operador/.ssh/
-rw------- 1 operador operador  742 ene 12  2025 authorized_keys
-rw------- 1 operador operador  381 mar  3  2025 authorized_keys2    # <-- ???

$ ssh-keygen -lf /home/operador/.ssh/authorized_keys2
2048 SHA256:Xk9m2pQ7... soporte@proveedor-antiguo (RSA)

Qué es. authorized_keys2 es un fichero obsoleto de OpenSSH 2.x, cuando había ficheros separados para claves SSH-1 y SSH-2. Las versiones modernas de OpenSSH lo ignoran por completo, salvo que AuthorizedKeysFile lo mencione explícitamente.

$ sudo sshd -T | grep -i authorizedkeysfile
authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2

Y ahí está el problema: la configuración por defecto de Ubuntu sí lo incluye. Esa clave RSA de 2048 bits, de un proveedor con el que ya no se trabaja, da acceso al servidor hoy mismo.

Es un ejemplo perfecto de un riesgo real: no es una vulnerabilidad de software, es un fichero que nadie miró en dieciocho meses.

# 1. Investigar antes de borrar: ¿se ha usado?
$ sudo journalctl -u ssh --since "90 days ago" | \
      grep 'Accepted publickey' | grep -o 'SHA256:[A-Za-z0-9+/]*' | sort -u
SHA256:aB3cD4eF5g...   # la de operador
# La huella de authorized_keys2 NO aparece: sin uso en 90 dias

# 2. Preguntar a Marta si el proveedor sigue teniendo relación
#    -> Respuesta: contrato terminado en marzo de 2025

# 3. Retirar, conservando prueba (06-04)
$ sudo cp /home/operador/.ssh/authorized_keys2 \
      /var/log/incidentes/authorized_keys2.retirado-$(date +%F)
$ sudo shred -u /home/operador/.ssh/authorized_keys2

# 4. Y la corrección estructural: que no vuelva a poder existir
$ sudo tee /etc/ssh/sshd_config.d/60-authorized-keys.conf <<'EOF'
# Solo un fichero de claves autorizadas. authorized_keys2 es un resto
# de OpenSSH 2.x que Ubuntu sigue incluyendo por defecto, y que permite
# que una clave olvidada de un tercero de acceso sin que nadie la vea.
AuthorizedKeysFile .ssh/authorized_keys
EOF
$ sudo sshd -t && sudo systemctl reload ssh
$ sudo sshd -T | grep -i authorizedkeysfile
authorizedkeysfile .ssh/authorized_keys

Y en Ansible, para que sea permanente y verificado:

- name: No permitir authorized_keys2
  ansible.builtin.template:
    src: 60-authorized-keys.conf.j2
    dest: /etc/ssh/sshd_config.d/60-authorized-keys.conf
    validate: '/usr/sbin/sshd -t -f %s'
  notify: Recargar ssh

- name: Comprobar que no existen ficheros de claves fuera de política
  ansible.builtin.find:
    paths: /home
    patterns: 'authorized_keys2'
    recurse: true
    hidden: true
  register: claves_extra
  failed_when: claves_extra.matched > 0

La lección de método: en el inventario de accesos, un fichero que no se puede explicar es un fichero que se retira. Y el error de fondo fue no tener nunca una revisión periódica de accesos — que ahora es la fila mensual de la tabla del apartado 10.

Checklist fila 40: cerrada.

Deuda 3: no hay objetivo formal de disponibilidad

Se cierra formalizando lo propuesto en 07-07:

Acuerdo de nivel de servicio interno · Tramontana Reservas Acordado el 18/08/2026 entre Operaciones y Dirección (Marta Vidal). Revisión: agosto de 2027.

Concepto Valor
SLI Peticiones con código < 500 y latencia < 2 s
SLO 99,8 % en ventana móvil de 30 días
Presupuesto de error 1 h 26 min cada 30 días
Ventana de mantenimiento Martes y miércoles, 10:00-12:00
RPO 15 minutos
RTO 2 horas
Horario de guardia Laborable 8:00-20:00; críticas 24×7
Revisión del cumplimiento Mensual, en el panel

Checklist fila 33: cerrada.

Cumplimiento: RGPD y retención de datos

Tramontana guarda datos personales de clientes: nombre, contacto y fechas de estancia. Eso activa obligaciones concretas del Reglamento General de Protección de Datos, y algunas son técnicas y son tuyas.

Obligación Estado en Tramontana
Registro de actividades de tratamiento (art. 30) ⚠️ Pendiente
Medidas técnicas apropiadas (art. 32) ✅ Cifrado en tránsito (08-01) y en reposo (LUKS)
Minimización: solo los datos necesarios ⚠️ Revisar con Luis
Limitación del plazo de conservación ⚠️ auditoria sin política
Derecho de supresión ⚠️ Procedimiento no escrito
Derecho de acceso y portabilidad ⚠️ No automatizado
Notificación de brechas en 72 h ✅ Detección (06-04); procedimiento por escribir
Registro de accesos a datos personales ✅ auditd (06-04)

Y el punto que conecta directamente con el incidente del apartado 12: la tabla auditoria ocupa 1.204 MB, más que todos los datos de reservas juntos, y nadie ha decidido cuánto tiempo se conservan. Eso no es solo un problema de espacio: conservar datos personales sin plazo definido es un incumplimiento del principio de limitación del plazo de conservación.

-- Propuesta de retención, a decidir con Marta y con asesoría legal
-- Reservas: 6 años (obligación mercantil de conservación contable)
-- Auditoría técnica: 12 meses
-- Registros con IP: 12 meses
-- Datos de contacto de reservas canceladas: 1 año

CREATE OR REPLACE PROCEDURE app.purgar_retencion() LANGUAGE plpgsql AS $$
BEGIN
    DELETE FROM app.auditoria WHERE creado_en < now() - interval '12 months';
    UPDATE app.huespedes SET telefono = NULL, email = NULL, anonimizado = true
     WHERE id IN (SELECT huesped_id FROM app.reservas
                   WHERE estado = 'cancelada' AND fecha < now() - interval '1 year')
       AND anonimizado = false;
    RAISE NOTICE 'purga de retencion completada';
END $$;

Anonimizar en lugar de borrar conserva la utilidad estadística —cuántas reservas hubo, de qué casas— sin conservar los datos personales. Es la solución técnicamente correcta y la que más se pasa por alto.

La advertencia final, y es importante: el RGPD no es un asunto técnico, es un asunto legal con implicaciones técnicas. Nada de este apartado sustituye a asesoría profesional. Lo que sí es responsabilidad tuya como administrador es: que existan las medidas técnicas, que la retención sea aplicable con una orden, que haya trazabilidad de accesos, y que quien decide —Marta— tenga la información para decidir. Un administrador que implementa una purga sin que nadie haya decidido los plazos está tomando una decisión legal sin autoridad para hacerlo.

Errores Comunes y Consejos

  • Confundir monitorizar con alertar. Monitoriza todo; alerta de casi nada.
  • Alertar de causas en lugar de síntomas. «CPU al 90 %» dispara cuando corre la copia. «Más del 5 % de errores» detecta cualquier fallo real.
  • No podar alertas. Diez notificaciones al día equivalen a ninguna. La revisión mensual de alertas es tan importante como crearlas.
  • Alertas sin acción. Si el receptor no puede hacer nada, es un panel, no una alerta. Toda alerta lleva su accion y su enlace al runbook.
  • Silencios sin caducidad. Un silencio indefinido es una alerta apagada para siempre.
  • No monitorizar la monitorización. Un script de métricas muerto hace que todo parezca correcto indefinidamente. La marca de frescura lo resuelve.
  • Medias de latencia en lugar de percentiles. Una media de 80 ms esconde que el 1 % espera 4 segundos.
  • Incluir los errores en la latencia. Una caída total, donde todo falla rápido, aparecería como mejora de rendimiento.
  • rate() sobre un medidor. Solo tiene sentido sobre contadores (_total).
  • Agregar histogramas sin by (le). Es el error clásico de PromQL: los percentiles salen sin sentido.
  • Etiquetas de alta cardinalidad. Una etiqueta con el ID de reserva crea una serie por reserva y revienta la memoria de Prometheus.
  • Un panel con cuarenta gráficas. No se mira. El principal debe caber en una pantalla.
  • Post-incidentes que buscan culpables. Garantizan que el siguiente incidente se oculte.
  • Acciones correctivas del tipo «tener más cuidado». No son acciones: son deseos. Deben cambiar el sistema.
  • Desplegar el viernes por la tarde. Los problemas aparecen con carga real y para entonces no hay nadie.
  • Un SLO sin presupuesto de error. El objetivo sin su consecuencia operativa no cambia ninguna decisión.
  • Gastar el 5 % del presupuesto de error. Estás siendo demasiado conservador: podrías haber entregado más valor.
  • Copias que el propio servidor puede borrar. No son copias: son una réplica con retraso, y el ransomware lo sabe.
  • No revisar los accesos periódicamente. Un authorized_keys2 de un proveedor de hace año y medio da acceso hoy.
  • Guardar datos personales sin plazo definido. Es un incumplimiento del RGPD, no solo un problema de espacio.
  • Consejo de método. Ante «¿va bien el sistema?», si la respuesta no es un número, no estás en producción.

Ejercicios

Ejercicio 1

A las 03:47 se dispara TasaErroresAlta. Documenta la respuesta completa al incidente —desde la notificación hasta el cierre— y redacta la revisión post-incidente resultante.

Ejercicio 2

Diseña la revisión mensual de alertas: qué se mide de cada alerta, con qué criterios se decide eliminarla, ajustarla o mantenerla, y aplica el método a las alertas configuradas en esta lección.

Ejercicio 3

Marta pide un informe de estado del sistema para la reunión anual con los socios: qué se ha hecho este año, en qué situación está y qué se necesita. Redáctalo.

Soluciones

Solución 1

03:47 — Notificación.

[CRITICA] TasaErroresAlta
Resumen: Más del 5 % de las peticiones falla
Descripción: Tasa actual: 34,2 %.
Acción: journalctl -t nginx_error -n 50; comprobar PostgreSQL
Runbook: https://docs.tramontana.example/RB-OPS-02

03:49 — Confirmar que el problema es real, antes de tocar nada.

$ curl -sI https://reservas.tramontana.example/casas | head -1
HTTP/2 502

$ curl -s --data-urlencode 'query=sum(rate(nginx_http_requests_total{status=~"5.."}[5m])) / sum(rate(nginx_http_requests_total[5m]))' \
    http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
0.342

Confirmado: uno de cada tres usuarios recibe un error. No es una falsa alarma.

03:51 — Acotar el alcance con el panel, no adivinando.

Panel Lectura
Errores 502 desde las 03:41
Tráfico Normal para la hora
Latencia p95 Subió a 8 s a las 03:38, antes de los errores
CPU / memoria Normales
Conexiones de PgBouncer cl_waiting = 47
tramontana_salud_estado 2 (crítico)

La latencia subió tres minutos antes que los errores, y hay 47 clientes esperando conexión. El cuello de botella está en la base de datos, no en la aplicación ni en Nginx.

03:53 — El registro de cambios, que es la pregunta obligatoria.

$ cd ~/tramontana-infra && git log --since="12 hours ago" --oneline
(sin resultados)

Sin cambios recientes. No es un despliegue: es algo que ha ocurrido solo.

03:55 — Diagnóstico en la base de datos.

$ sudo -u postgres psql -x -c "
  SELECT pid, state, wait_event_type, wait_event,
         now()-xact_start AS duracion, left(query,60) AS consulta
  FROM pg_stat_activity WHERE state != 'idle'
  ORDER BY xact_start LIMIT 3;"
-[ RECORD 1 ]---+--------------------------------------------
pid             | 12844
state           | active
wait_event_type | Lock
wait_event      | transactionid
duracion        | 00:19:12
consulta        | UPDATE app.disponibles SET estado = 'ocupada'

$ sudo -u postgres psql -c "
  SELECT bloqueada.pid AS esperando, bloqueante.pid AS bloqueando,
         left(bloqueante_act.query, 50) AS consulta_bloqueante
  FROM pg_locks bloqueada
  JOIN pg_locks bloqueante ON bloqueante.transactionid = bloqueada.transactionid
       AND bloqueante.granted
  JOIN pg_stat_activity bloqueante_act ON bloqueante_act.pid = bloqueante.pid
  WHERE NOT bloqueada.granted;"
 esperando | bloqueando |            consulta_bloqueante
-----------+------------+--------------------------------------------
     12844 |      11902 | VACUUM FULL app.disponibles

Causa raíz encontrada en ocho minutos. Un VACUUM FULL sobre app.disponibles tiene un bloqueo ACCESS EXCLUSIVE que impide toda lectura y escritura de esa tabla. Las peticiones se acumulan esperando, agotan el pool de PgBouncer, y la aplicación devuelve 502.

$ sudo -u postgres psql -c "SELECT pid, backend_start, application_name
    FROM pg_stat_activity WHERE pid = 11902;"
  pid  |         backend_start         | application_name
-------+-------------------------------+------------------
 11902 | 2026-08-19 03:30:12.441+02    | psql

application_name = psql y arrancado a las 03:30: alguien lo lanzó a mano. La consulta al cuaderno de guardia lo confirma —una nota de la tarde anterior: «disponibles con 31,7 % de tuplas muertas, mirar».

03:58 — Mitigación.

# Cancelar la consulta (SIGINT). NO pg_terminate_backend, que corta
# la conexión de golpe: cancelar es más limpio y suele bastar.
$ sudo -u postgres psql -c "SELECT pg_cancel_backend(11902);"
 pg_cancel_backend
-------------------
 t

$ sleep 20 && curl -sI https://reservas.tramontana.example/casas | head -1
HTTP/2 200

$ sudo -u postgres psql -tAc "SELECT count(*) FROM pg_stat_activity
    WHERE wait_event_type = 'Lock';"
0

04:00 — Verificar y cerrar.

$ curl -s --data-urlencode 'query=sum(rate(nginx_http_requests_total{status=~"5.."}[5m])) / sum(rate(nginx_http_requests_total[5m]))' \
    http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
0.0021

$ amtool alert query alertname=TasaErroresAlta
(sin alertas activas)

04:05 — Anotar en el cuaderno mientras está fresco.

**03:47** [INCIDENTE] TasaErroresAlta, 34 % de 502. Causa: VACUUM FULL
manual sobre app.disponibles lanzado a las 03:30, bloqueo ACCESS
EXCLUSIVE. Mitigado con pg_cancel_backend a las 03:58. Duración del
impacto: 17 min. Post-incidente el 21/08.

Revisión post-incidente: errores 502 por bloqueo de VACUUM FULL

Incidente: INC-2026-004 · Fecha: 2026-08-19 · Severidad: Crítica Duración del impacto: 17 min (03:41-03:58) · Detección: 6 min

1. Qué pasó

Un VACUUM FULL ejecutado manualmente sobre app.disponibles adquirió un bloqueo exclusivo que impidió toda lectura y escritura de la tabla durante 28 minutos. Las peticiones se acumularon, se agotó el pool de conexiones y la aplicación devolvió errores 502 al 34 % de las peticiones durante 17 minutos.

2. Impacto

Dimensión Valor
Duración del impacto 17 min
Peticiones fallidas ~2.100 (34 % de 6.200)
Reservas perdidas estimadas 1-2 (madrugada, tráfico bajo)
Presupuesto de error consumido 19,7 % del mes (17 de 86 min)
Datos perdidos Ninguno

3. Línea temporal

Hora Suceso
03:30 Se lanza VACUUM FULL app.disponibles manualmente
03:38 El p95 de latencia sube a 8 s (síntoma temprano, sin alerta)
03:41 Primeros 502: el pool de PgBouncer se agota
03:47 Se dispara TasaErroresAlta y llega la notificación
03:49 Se confirma el problema
03:51 Se acota con el panel: la base de datos
03:55 Se identifica el bloqueo y el VACUUM FULL
03:58 pg_cancel_backend. Servicio restablecido
04:00 Verificado. Incidente cerrado

4. Causa raíz: los cinco porqués

# Pregunta Respuesta
1 ¿Por qué hubo 502? La aplicación no pudo consultar la base de datos
2 ¿Por qué no pudo? Un bloqueo ACCESS EXCLUSIVE sobre app.disponibles
3 ¿Por qué había ese bloqueo? Un VACUUM FULL manual
4 ¿Por qué se ejecutó a mano en producción? Se detectó 31,7 % de tuplas muertas y se quiso resolver rápido
5 ¿Por qué había tanta hinchazón? autovacuum no está ajustado para esa tabla

Causa raíz: autovacuum con los valores por defecto (umbral del 20 %) no sigue el ritmo de escritura de app.disponibles. La hinchazón resultante motivó una intervención manual con una herramienta cuyo bloqueo no se anticipó.

Causa contribuyente: no había ningún control que impidiera ejecutar una operación bloqueante en producción, ni alerta que avisara antes de que los usuarios se vieran afectados.

5. Qué funcionó bien

  • La alerta era de síntoma y funcionó: detectó un fallo que nadie había anticipado. Una alerta de causa sobre VACUUM no existía y no habría hecho falta.
  • El panel acotó el problema en dos minutos. Sin él, el diagnóstico habría sido a ciegas.
  • El registro de cambios descartó un despliegue en treinta segundos.
  • La mitigación fue reversible: cancelar en lugar de terminar la conexión.
  • El cuaderno de guardia dio el contexto —la nota del día anterior— que explicó quién y por qué.

6. Qué se cambia

# Acción Responsable Fecha Estado
1 Ajustar autovacuum de app.disponibles al 2 % (08-02) Ops 19/08 Hecho
2 lock_timeout = 5s por defecto en la sesión de psql de producción Ops 20/08 Hecho
3 Instalar pg_repack y documentarlo como la herramienta para hinchazón Ops 26/08 Pendiente
4 Alerta EsperaBloqueoProlongada (> 30 s) — avisa antes del síntoma Ops 22/08 Pendiente
5 Runbook RB-BD-03: mantenimiento de base de datos, con la lista de operaciones bloqueantes Ops 26/08 Pendiente
6 Panel con tuplas muertas por tabla, para no descubrirlo por casualidad Ops 22/08 Pendiente
7 Ninguna operación de mantenimiento manual en producción fuera de ventana Equipo 20/08 Acordado
# Acción 2: /etc/postgresql/16/main/conf.d/50-proteccion.conf
# Si una consulta no consigue un bloqueo en 5 s, falla en lugar de
# esperar. Una consulta que falla rápido es infinitamente preferible
# a una que bloquea el servicio entero.
lock_timeout = 5s

7. Lecciones

  1. La latencia subió 9 minutos antes que los errores. Ese es el margen que perdimos por no tener una alerta temprana. La acción 4 lo recupera.
  2. La intención era buena y el resultado fue una caída. No es un fallo de la persona: es un fallo del sistema, que permitía ejecutar una operación bloqueante en producción sin ninguna fricción. Las acciones 2, 5 y 7 añaden esa fricción.
  3. El 19,7 % del presupuesto de error en un incidente de 17 minutos demuestra por qué el SLO cambia decisiones: con dos incidentes así, el mes entra en zona de precaución y los despliegues se moderan.
  4. VACUUM FULL no es «un vacuum más grande». Bloquea la tabla entera. Merece estar en un runbook con esa advertencia en mayúsculas.

Solución 2

Por qué existe esta revisión. Las alertas se crean tras cada incidente y casi nunca se eliminan. En un año, un sistema acumula treinta alertas de las que cinco sirven. La revisión mensual es el contrapeso.

Las cuatro métricas que se miden de cada alerta:

Métrica Cómo se obtiene Qué revela
Frecuencia Veces que se disparó en 30 días Ruido o silencio
Precisión (Disparos que exigieron acción) / (disparos totales) Falsos positivos
Sensibilidad Incidentes que detectó / incidentes reales Huecos de cobertura
Tiempo de reacción Minutos hasta la primera acción humana Si se atiende o se ignora
# Frecuencia y duracion de cada alerta en 30 dias, desde Prometheus
$ curl -s --data-urlencode \
    'query=sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d]))' \
    http://127.0.0.1:9090/api/v1/query | \
    jq -r '.data.result[] | "\(.metric.alertname)\t\(.value[1])"' | sort -k2 -rn
CopiaAntigua	412
LatenciaDegradada	38
DiscoSeLlenara	4
TasaErroresAlta	2
CertificadoCaduca	0
ServicioCaido	0
ArchivadoWALFallando	0
MetricasRancias	0
ReplicaRetrasada	0

El árbol de decisión, aplicado a cada alerta:

¿Se disparó en 30 días?
├── NO
│   ├── ¿Habría detectado un incidente real que hubo? → MANTENER
│   ├── ¿Cubre un riesgo grave aunque raro?           → MANTENER
│   └── ¿Ninguna de las dos?                          → CANDIDATA A ELIMINAR
└── SÍ
    ├── ¿Precisión < 50 %?    → AJUSTAR umbral o 'for', o ELIMINAR
    ├── ¿Frecuencia > 10/mes? → Es ruido: AJUSTAR
    ├── ¿Nadie actuó nunca?   → NO ES UNA ALERTA: pasar a panel
    └── ¿Precisión alta y baja frecuencia? → MANTENER

Aplicación a las alertas de esta lección:

Alerta Frec. Precisión Reacción Decisión
CopiaAntigua 412 0,2 % Ignorada AJUSTAR: rota
LatenciaDegradada 38 21 % 45 min AJUSTAR: demasiado sensible
DiscoSeLlenara 4 100 % 12 min MANTENER
TasaErroresAlta 2 100 % 2 min MANTENER: ejemplar
ServicioCaido 0 — — MANTENER: riesgo máximo
ArchivadoWALFallando 0 — — MANTENER: detectó el incidente del 17/08
CertificadoCaduca 0 — — MANTENER: coste cero, riesgo alto
ReplicaRetrasada 0 — — MANTENER
MetricasRancias 0 — — MANTENER: vigila a la vigilancia

Análisis y corrección de las dos problemáticas:

CopiaAntigua: 412 disparos, 0,2 % de precisión. Es el caso de libro de fatiga de alertas: se disparaba cada 30 minutos, todas las noches, entre la ejecución de la copia y su verificación. Cuatrocientas doce notificaciones que nadie leía, y que además entrenaron al equipo a ignorar el canal.

# Diagnostico: ¿cuando se dispara exactamente?
$ curl -s --data-urlencode \
    'query=ALERTS{alertname="CopiaAntigua",alertstate="firing"}' \
    http://127.0.0.1:9090/api/v1/query_range... | jq ...
# -> Todas entre las 02:30 y las 03:10
      # ANTES: umbral demasiado ajustado y sin margen de ejecución
      # expr: tramontana_copia_edad_segundos > 2*4*3600     # 8 h
      # for: 30m

      # DESPUÉS: 26 h cubre un ciclo diario completo más margen de
      # ejecución; 'for: 1h' evita el ruido de la ventana de copia.
      - alert: CopiaAntigua
        expr: tramontana_copia_edad_segundos > 26*3600
        for: 1h
        labels: {severidad: critica}
        annotations:
          resumen: "Sin copia verificada desde hace {{ $value | humanizeDuration }}"
          accion: "Ver runbook RB-BD-01"

LatenciaDegradada: 38 disparos, 21 % de precisión, 45 minutos hasta la reacción. Los 45 minutos son el dato revelador: el equipo ya la estaba ignorando. Se dispara cada tarde durante el informe de facturación de Marta, que es lento y esperado.

      # ANTES: p95 > 2 s durante 10 min -> capturaba los informes
      # DESPUÉS: dos cambios que la vuelven útil
      #   1. Excluir la ruta de informes, que es lenta por diseño
      #   2. Umbral y ventana más laxos: 3 s durante 15 min
      - alert: LatenciaDegradada
        expr: |
          histogram_quantile(0.95, sum(rate(
            tramontana_duracion_peticion_seconds_bucket{ruta!~"/informes/.*"}[5m]
          )) by (le)) > 3
        for: 15m
        labels: {severidad: aviso}

Y para los informes, que sí conviene vigilar pero con otro umbral:

      - alert: InformesMuyLentos
        expr: |
          histogram_quantile(0.95, sum(rate(
            tramontana_duracion_peticion_seconds_bucket{ruta=~"/informes/.*"}[5m]
          )) by (le)) > 15
        for: 30m
        labels: {severidad: aviso}

Resultado de la revisión:

Antes Después
Notificaciones al mes 456 ~8
Precisión media 2 % ~85 %
Alertas activas 9 10 (una nueva, dos ajustadas)
Tiempo medio de reacción 45 min Estimado < 5 min

Cuatro principios que resume esta revisión:

  1. Una alerta con precisión baja es peor que ninguna, porque entrena al equipo a ignorar el canal donde también llegan las buenas.
  2. El tiempo de reacción mide si la alerta se atiende. Cuarenta y cinco minutos significa que se ignora, y ese dato es más honesto que cualquier opinión.
  3. Una alerta que nunca se dispara no es inútil, si cubre un riesgo grave y su coste es cero. ServicioCaido no se ha disparado en un año y debe seguir ahí.
  4. Ajustar antes que eliminar. Las dos problemáticas cubrían riesgos reales; el problema estaba en el umbral, no en la idea.

Y una nota final de método: esta revisión se anota en el cuaderno de guardia, con el antes y el después de cada alerta modificada. Dentro de seis meses, cuando alguien se pregunte por qué el umbral de CopiaAntigua es 26 horas y no 8, la respuesta estará escrita.

Solución 3

Informe anual de sistemas · Tramontana Reservas

Para: Marta Vidal y socios · De: Operaciones de sistemas Ejercicio: 2026 · Fecha: 18 de agosto de 2026


Resumen ejecutivo

El sistema que sostiene Tramontana Reservas ha pasado, en el último año, de ser una máquina configurada a mano que nadie más que yo sabía reconstruir a una infraestructura documentada, automatizada, vigilada y con procedimientos escritos que otra persona podría seguir.

Los tres cambios más importantes, en una línea cada uno:

  • Los datos de nuestros clientes ya viajan cifrados. Hasta junio no lo hacían.
  • Podemos recuperar la base de datos a cualquier instante concreto, y lo hemos ensayado: 24 minutos.
  • Reconstruir el servidor entero pasó de 8 horas a 50 minutos, y está medido, no estimado.

Disponibilidad medida en los últimos 30 días: 99,94 %, por encima del objetivo del 99,8 % que proponemos formalizar.


1. Dónde estábamos y dónde estamos

Hace un año Hoy
Configuración del servidor En mi cabeza En código, versionado
Reconstruir tras un desastre 8 h estimadas 50 min medidos
Datos que podríamos perder Hasta 24 h 15 minutos
Recuperar un borrado accidental Imposible sin perder un día Al segundo anterior
¿Se han probado las copias? Nunca Ensayo trimestral documentado
Cifrado del tráfico web No Sí
Acceso remoto seguro Solo desde la oficina Desde cualquier sitio, por túnel cifrado
¿Sabemos si va bien? «Creo que sí» 99,94 %, en un panel
Si algo falla de madrugada Nos enteramos por la mañana Alerta al teléfono
Documentación operativa Ninguna 6 procedimientos escritos y ensayados

2. Lo que se ha hecho, agrupado

Fiabilidad. Automatización completa de la configuración: el servidor se reconstruye desde cero con un comando, y eso ha reducido el tiempo de recuperación ante un desastre de ocho horas a cincuenta minutos. Es la mejora que más ha aumentado la disponibilidad, y no ha costado un euro en equipamiento.

Protección de datos. Copias automáticas verificadas, cifradas, guardadas fuera del servidor y —desde este mes— imposibles de borrar desde el propio servidor durante 30 días, que es la defensa concreta contra un ataque de secuestro de datos. Y la capacidad de rebobinar la base de datos a cualquier instante, ensayada.

Seguridad. Cortafuegos restrictivo, detección de intrusiones, auditoría de accesos, contraseñas y certificados guardados cifrados, y el servicio funcionando con los mínimos permisos posibles. Una evaluación automática independiente puntúa nuestro nivel de endurecimiento en 82 sobre 100, cuando la instalación por defecto ronda el 60.

Rendimiento. Ajuste de la base de datos a la máquina real, que ha reducido los accesos a disco un 93 %. Y una consulta de informes que tardaba 3,8 segundos ahora tarda 0,18.

Visibilidad. Este es el cambio más reciente y el que más cambia el día a día: un panel que responde en tres segundos a «¿va bien?», con histórico de 30 días, y alertas que avisan antes de que el problema afecte a los clientes.


3. Incidentes del año

Fecha Qué pasó Impacto Estado
17/08 El disco de copias se llenó y bloqueó el registro de la base de datos Ninguno: detectado antes Resuelto + alerta nueva
18/08 Borrado accidental de 40.218 reservas históricas Ninguno: recuperadas en 34 min Resuelto + 6 medidas
19/08 Errores durante 17 min por una operación de mantenimiento ~2.100 peticiones fallidas Resuelto + 7 medidas

Ningún incidente causó pérdida de datos. Los tres se analizaron con el mismo método —qué pasó, por qué, qué se cambia— y generaron quince mejoras concretas, todas aplicadas o con fecha.

Quiero destacar el del 18 de agosto: se recuperaron 40.000 reservas sin perder nada, usando un procedimiento que habíamos ensayado tres días antes. Ese ensayo, que parecía burocracia, se pagó solo en 72 horas.


4. Lo que proponemos acordar

Un objetivo de servicio. Hasta ahora no teníamos ninguno, y sin él no se puede decidir cuánto invertir. Proponemos:

Concepto Propuesta Qué significa en la práctica
Disponibilidad 99,8 % mensual Como máximo 1 h 26 min de fallo cada 30 días
Datos recuperables 15 minutos En el peor caso se perderían 15 min de reservas
Tiempo de recuperación 2 horas Desde un desastre total hasta el servicio restablecido
Ventana de cambios Martes y miércoles por la mañana Nunca viernes por la tarde ni fin de semana

Estamos por encima de ese objetivo hoy (99,94 %). Lo proponemos así a propósito: un objetivo que ya se cumple con holgura permite desplegar mejoras con tranquilidad, y uno demasiado exigente obligaría a frenar el desarrollo del producto.

Y una idea que quiero explicar porque cambia cómo decidimos. Ese 0,2 % de margen es un presupuesto: podemos «gastarlo» en cambios y mejoras. Si un mes lo consumimos por debajo de la mitad, seguimos desplegando con normalidad. Si nos acercamos al límite, se congelan las funciones nuevas y se dedica el esfuerzo a estabilizar. Convierte la discusión «¿vamos demasiado rápido?» en una consulta a un número. Este mes llevamos consumido el 31 %.


5. Lo que hace falta

Prioridad alta, decisión de dirección:

  1. Política de retención de datos. Guardamos el histórico de actividad de la aplicación sin plazo definido, y ya ocupa más que todas las reservas juntas. Además de ser un problema de espacio, conservar datos personales sin plazo determinado no es defendible ante el Reglamento de Protección de Datos. Necesito que se decida cuánto tiempo guardamos cada cosa; lo aplico en una semana. Recomiendo asesoría legal para fijar los plazos.
  2. Registro de actividades de tratamiento. Es un documento que el RGPD exige y que no tenemos. No es trabajo técnico, pero necesita mi aportación.

Prioridad media, inversión moderada:

  1. Segundo servidor de aplicación con reparto de carga. Es la recomendación que analicé en detalle: eliminaría las interrupciones por despliegue y por fallo de una máquina, que son la mayoría. Coste: dos máquinas y una semana de trabajo, más tres o cuatro semanas de desarrollo previo con Luis. Nos llevaría del 99,8 % actual a un margen mucho más holgado.
  2. Réplica de la base de datos. Justificada sobre todo porque permitiría ejecutar los informes pesados sin afectar a la web, y de paso actualizar sin cortar servicio.

Lo que NO recomiendo, y quiero dejarlo por escrito:

  • Arquitectura de alta disponibilidad completa (cinco máquinas, cambio automático de servidor). Analizado con números: cuesta varias veces más que la opción 3 y aporta apenas 670 € adicionales de ahorro anual. Y añadiría una complejidad que con la plantilla actual no podemos vigilar bien — un sistema sofisticado mal mantenido es menos fiable que uno sencillo bien cuidado.
  • Kubernetes, que es la tecnología de moda. Lo he montado y evaluado en el entorno de pruebas: para nuestro tamaño consumiría entre dos y cuatro semanas de trabajo al año solo en mantenerlo, para resolver problemas que no tenemos.

6. Riesgos que asumimos conscientemente

Prefiero que estén escritos a que se descubran el día malo:

Riesgo Consecuencia Por qué lo aceptamos
Una sola máquina de aplicación Un fallo de hardware = servicio caído hasta 2 h Propuesta 3 lo resolvería
Cambio manual de base de datos 5-15 min adicionales en ese caso Automatizarlo exige 5 máquinas y crearía un riesgo mayor
Una sola persona con conocimiento operativo Vacaciones o baja = respuesta lenta Mitigado en parte: procedimientos escritos
Dependencia de un proveedor Una caída suya nos afecta entera Habitual a nuestra escala

El tercero es el que más me preocupa a medio plazo, y es la razón de que este año haya dedicado tanto esfuerzo a escribir procedimientos: hoy, alguien con conocimientos técnicos generales podría seguir la mayoría de ellos sin haber trabajado nunca en nuestro sistema. Hace un año, no.


7. Conclusión

El sistema está en una situación considerablemente mejor que hace un año, y —lo que más importa— está en una situación que puedo demostrar con datos en lugar de afirmar con confianza. Hemos pasado de «creo que va bien» a «99,94 %, aquí está el panel».

Lo que queda pendiente son sobre todo decisiones, no trabajo técnico: cuánto tiempo guardamos los datos, qué nivel de servicio nos comprometemos a dar, y si invertimos en el segundo servidor. Con esas tres decisiones tomadas, tenemos un sistema del que se puede depender.

Conclusión

El curso termina donde tenía que terminar: no en una herramienta más, sino en la capa que convierte un servidor que funciona en un servicio del que se puede depender. Tienes una checklist de cuarenta y dos filas con evidencia comprobable en cada una, y sabes exactamente cuáles faltan, que es una forma de saber más valiosa que creer que están todas. Tienes monitorización con histórico de treinta días, alertas que se pueden atender porque son pocas y son de síntoma, y un panel que responde en tres segundos a la única pregunta que importa.

Sobre todo tienes criterio operativo. Sabes que monitorizar y alertar no son lo mismo y que hay que hacer mucho de lo primero y muy poco de lo segundo. Sabes que una alerta con precisión del 2 % es peor que ninguna, porque entrena al equipo a ignorar el canal por donde también llegan las buenas. Sabes que un post-incidente que busca culpables garantiza que el siguiente se oculte, y que una acción correctiva del tipo «tener más cuidado» no es una acción sino un deseo. Y sabes que el presupuesto de error convierte «¿desplegamos?» en una consulta a un número, con la conclusión que sorprende: gastar solo el 5 % del presupuesto significa haber sido demasiado conservador.

Las tres deudas están cerradas. La copia externa ya no la puede borrar el servidor que respalda, porque un sistema que puede destruir sus copias no tiene copias. El authorized_keys2 resultó ser lo que suelen ser estas cosas: no una vulnerabilidad exótica, sino un fichero de un proveedor de hace año y medio que seguía dando acceso porque nadie lo había mirado — y lo que se ha corregido no es el fichero, es la ausencia de revisión periódica que permitió que estuviera ahí. Y el objetivo de disponibilidad existe por fin, con su número, su ventana y su presupuesto.


Y con esto se cierra el curso completo. Ocho módulos.

Empezaste en el Módulo 1 sin saber qué era un kernel, y montaste el sistema desde una imagen de instalación. En el 2 dejaste de temer la terminal: permisos, inodos, enlaces y man como primera parada. En el 3 el shell dejó de ser un intérprete de órdenes sueltas y pasó a ser un lenguaje: tuberías, find, awk, procesos, señales. En el 4 escribiste scripts que no se rompen —set -euo pipefail, trap, flock, idempotencia—, y desplegar.sh con su rollback atómico sigue en producción cincuenta lecciones después. El 5 fue la administración de verdad: usuarios, paquetes con pinning, LVM, systemd endurecido, journal persistente, y unas copias con RPO y RTO acordados en lugar de improvisados. El 6 puso la seguridad como disciplina y no como lista de trucos: modelo de amenazas, cortafuegos de lista blanca, AIDE con su base de datos fuera, secretos cifrados y aquel certificado TLS que esperó dos módulos. El 7 miró debajo del capó —arranque, strace, perf, eBPF, el kernel, KVM, contenedores, Ansible— y terminó con el análisis honesto de si hacía falta alta disponibilidad. Y el 8 aplicó todo a construcciones completas, de principio a fin.

Lo que se llevan de aquí no es una lista de comandos. Es un método: medir antes y después, porque un ajuste sin medición es superstición. Probar en pruebas antes de producción. --dry-run antes de actuar. Copia y diff -u antes de editar. Validar antes de recargar, sea nginx -t, promtool check, testparm -s o pg_hba_file_rules. Nunca cierres la puerta por la que estás entrando. Silencio si todo va bien. Y decir siempre qué protege y qué no protege una medida, que es lo que separa un informe honesto de un argumento de venta.

Y una idea que ha aparecido en cada módulo con distinta ropa: la respuesta profesional no siempre es que sí. No a la arquitectura completa de alta disponibilidad, no a Kubernetes, no a subir max_connections, no a copiar seis terabytes a la nube. Saber decir que no, con números y con alternativa, vale tanto como saber montarlo.

Sobre cómo seguir. Las certificaciones LPIC-1 y LPIC-2 o la RHCSA ordenan y acreditan lo que ya sabes; la RHCSA es práctica y exigente, y prepararla enseña de verdad. Las especializaciones naturales desde aquí son cuatro: ingeniería de fiabilidad, que es esta última lección llevada a escala; plataforma y nube, donde Kubernetes y la infraestructura como código son el día a día; ciberseguridad, prolongando el Módulo 6; y bases de datos, prolongando 08-02. Participa en la comunidad —listas, foros, un informe de error bien escrito, una corrección de documentación— porque explicar algo es la mejor forma de descubrir si lo entiendes. Y lee los cambios de las versiones del kernel y de tu distribución: es la manera más barata de no quedarse atrás.

Pero si solo puedes conservar una cosa, que sea esta: mantén el laboratorio. Esa VM de pruebas, o el servidor de medios de 08-03, o el clúster de k3s que decidiste no llevar a producción. Un sitio propio donde romper cosas a propósito, provocar los fallos en horario laboral en lugar de esperarlos, y probar lo que aún no sabes. Todo lo que has aprendido en este curso lo has aprendido haciéndolo, y esa es la única forma que hay. La diferencia entre quien lee sobre sistemas y quien los administra no es lo que ha estudiado: es lo que ha roto y ha vuelto a levantar. Enciende la máquina y sigue.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados