El módulo 6 terminó con una afirmación y una carencia. La afirmación: CicloUrbana es correcta, y ./mvnw verify lo demuestra. La carencia: no es operable. Nadie puede preguntarle si está sana antes de mandarle tráfico, ni si la base de datos responde, ni qué versión está desplegada en el servidor del ayuntamiento de Ribalta. Cuando la aplicación se queda colgada a las tres de la mañana, la única herramienta disponible es reiniciarla y esperar.

Esta lección tapa esa carencia con Spring Boot Actuator, el módulo que abre la aplicación por dentro y expone su estado a través de HTTP y JMX. Veremos qué endpoints trae, cuáles conviene exponer y cuáles no, cómo asegurarlos con la cadena de seguridad que ya escribimos en 05-02, cómo funciona de verdad /actuator/health —incluidas las sondas de disponibilidad que Kubernetes necesitará en 08-04—, cómo escribir indicadores de salud y endpoints propios para la red de Ribalta, y cómo saber, con una sola petición, qué commit exacto está corriendo en producción.

Contenido

  1. Qué significa que una aplicación sea operable
  2. Añadir Actuator y qué aparece de inmediato
  3. El catálogo completo de endpoints
  4. Exposición y descubrimiento
  5. Puerto de gestión separado
  6. Asegurar Actuator con Spring Security
  7. /actuator/health a fondo
  8. Grupos de salud y sondas de disponibilidad
  9. Un HealthIndicator propio: SaludRedEstaciones
  10. Cambiar la disponibilidad con AvailabilityChangeEvent
  11. /actuator/info: qué versión está desplegada
  12. /actuator/loggers: cambiar el nivel de log en caliente
  13. mappings, configprops, env y los secretos
  14. Un endpoint propio con @Endpoint
  15. Actuator sobre JMX
  16. Errores Comunes y Consejos
  17. Ejercicios

  1. Qué significa que una aplicación sea operable

Una aplicación correcta hace lo que promete. Una aplicación operable además puede ser gestionada por alguien que no es su autor, en mitad de la noche, sin leer el código. Son dos propiedades independientes: un sistema puede ser impecable por dentro y un agujero negro por fuera. La diferencia se ve en preguntas concretas que el equipo de operaciones del ayuntamiento de Ribalta hará antes o después:

Pregunta operativa Sin Actuator Con Actuator
¿Puedo mandarle tráfico ya? Probar un endpoint real y ver si contesta GET /actuator/health/readiness
¿Está viva o hay que reiniciarla? Mirar si el proceso existe (que existe aunque esté colgada) GET /actuator/health/liveness
¿Responde la base de datos? Leer el log y buscar excepciones GET /actuator/health con detalles
¿Qué versión está desplegada? Preguntar a quien desplegó GET /actuator/info
Necesito logs DEBUG de un paquete, ya Cambiar el YAML, reconstruir y reiniciar POST /actuator/loggers/com.ciclourbana
¿Se está ejecutando la tarea nocturna? Buscar en el log GET /actuator/scheduledtasks

Todas esas respuestas existen ya dentro del proceso: Spring conoce sus beans, su configuración, sus rutas y el estado de su pool de conexiones. Actuator no las calcula, las publica. Ese es todo su trabajo, y por eso añadirlo cuesta una dependencia.

  1. Añadir Actuator y qué aparece de inmediato

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Sin versión, porque la gobierna el spring-boot-starter-parent (01-04). Al arrancar, el log muestra una línea nueva:

Exposing 1 endpoint(s) beneath base path '/actuator'

Un endpoint, no quince. Actuator registra internamente todos los suyos, pero solo expone health por HTTP de forma predeterminada. La razón es de seguridad y es una lección aprendida a golpes: en Spring Boot 1.x se exponía casi todo, y hubo una época en la que buscar /env en Internet devolvía credenciales de bases de datos de empresas reales. Desde Boot 2 la política es la inversa: nada sale salvo que lo pidas por su nombre.

La raíz /actuator devuelve un índice HAL con enlaces a lo que está expuesto, útil para descubrir qué hay disponible sin conocer los nombres de memoria. Y la salud responde escueta a propósito:

curl -s http://localhost:8080/actuator/health
# {"status":"UP"}

El estado agregado es información pública tolerable; el desglose no lo es, y por eso está oculto por defecto. En el apartado 7 lo abrimos.

  1. El catálogo completo de endpoints

Esta es la tabla de referencia de la lección. La columna de riesgo es la que decide qué se expone en Ribalta.

Endpoint Qué expone Riesgo si se filtra ¿Exponer?
health Estado agregado y, opcionalmente, el de cada componente Bajo sin detalles; medio con ellos (revela motor de BD, rutas de disco) Sí, sin detalles en público
info Datos arbitrarios: versión, commit, fecha de compilación Bajo, si no metes nada sensible Sí
metrics Métricas de Micrometer (memoria, hilos, peticiones); se explota en 09-03 Medio: revela volumen de tráfico y capacidad Solo autenticado
env Todo el entorno: propiedades, variables, argumentos Muy alto: contraseñas, secretos JWT, claves de API Solo ADMIN, con valores sanitizados
beans Todos los beans del contexto con sus tipos y dependencias Medio: mapa completo de la arquitectura interna Solo ADMIN
configprops Los @ConfigurationProperties de 02-05 con sus valores Alto: mismo problema que env Solo ADMIN, sanitizado
mappings Todas las rutas registradas y a qué método van Medio: inventario de la API, incluidas rutas internas Solo ADMIN
loggers Lee y modifica niveles de log en caliente Alto: escritura; un atacante puede llenar el disco Solo ADMIN
threaddump Volcado de todos los hilos con sus pilas Alto: nombres de clases, y a veces datos en variables Solo ADMIN
heapdump Descarga un .hprof con toda la memoria Crítico: contiene tokens, contraseñas en claro, datos personales Casi nunca; puntualmente y por túnel
scheduledtasks Tareas @Scheduled registradas y su periodicidad (07-03) Bajo Solo ADMIN
flyway Migraciones aplicadas y versión del esquema (04-08) Bajo-medio Solo ADMIN
caches Cachés registradas; permite vaciarlas (09-02) Medio: escritura Solo ADMIN
shutdown Apaga la aplicación Crítico Casi nunca; desactivado por defecto
httpexchanges Últimas peticiones y respuestas HTTP en memoria Alto: cabeceras con tokens de otros usuarios Solo ADMIN, y requiere un bean explícito

Tres observaciones sobre la tabla. La primera: shutdown es el único endpoint desactivado por defecto, no solo no expuesto —hay que habilitarlo con management.endpoint.shutdown.enabled: true— porque su efecto es irreversible. La segunda: heapdump es el más peligroso de todos y el que menos lo parece; un volcado de memoria de CicloUrbana contiene los JWT de las sesiones vivas, los datos personales de los ciudadanos y el secreto de firma. La tercera: httpexchanges no funciona con solo exponerlo; necesita además un bean HttpExchangeRepository, precisamente para que nadie lo active sin saber lo que está guardando.

  1. Exposición y descubrimiento

Tres propiedades gobiernan qué se ve y dónde:

management:
  endpoints:
    web:
      base-path: /actuator          # prefijo de todas las rutas de gestión
      exposure:
        include: health,info,metrics,loggers,mappings,configprops,flyway,scheduledtasks
        exclude: env,heapdump,threaddump
Propiedad Efecto
exposure.include Lista blanca de endpoints publicados por HTTP; * los publica todos
exposure.exclude Lista negra que gana siempre sobre include, incluso sobre *
base-path Cambia el prefijo de todas las rutas (/gestion, /interno)
management.endpoint.<id>.enabled Activa o desactiva el endpoint por completo, también en JMX

Distinguir enabled de exposure es importante. Un endpoint deshabilitado no existe: ni por HTTP, ni por JMX, ni como bean. Uno habilitado pero no expuesto existe y funciona, pero no tiene ruta HTTP. La combinación habitual en producción es habilitado + expuesto + protegido, no deshabilitado, porque los indicadores de salud se siguen necesitando internamente.

Sobre include: "*": las comillas son obligatorias en YAML —el asterisco desnudo es un alias— y, sobre todo, es una decisión aceptable solo en desarrollo. Escribir la lista explícita cuesta treinta segundos y elimina la clase de accidente que consiste en que una actualización de Spring Boot exponga un endpoint que no existía cuando escribiste la configuración. En 07-02 esta lista será distinta por perfil: generosa en dev, mínima en prod.

  1. Puerto de gestión separado

management:
  server:
    port: 8081
    base-path: /actuator

Con eso, la API de Ribalta sigue en 8080 y toda la gestión pasa a http://localhost:8081/actuator/.... Es una práctica recomendada por una razón que no es criptográfica sino topológica: permite que el cortafuegos, y no la aplicación, decida quién habla con la gestión.

graph LR
    C[Ciudadanos<br/>Internet] -->|:8080| LB[Balanceador]
    LB -->|:8080 /api/v1| APP[CicloUrbana]
    OPS[Red interna<br/>operaciones] -->|:8081 /actuator| APP
    K8S[Sondas del<br/>orquestador] -->|:8081 /actuator/health| APP
Aspecto Puerto único (8080) Puerto de gestión (8081)
Aislamiento en red Ninguno: la gestión viaja por el mismo puerto público El puerto se cierra al exterior en el cortafuegos
Riesgo de error de configuración Un fallo en la cadena de seguridad expone Actuator a Internet Aunque la cadena falle, el puerto no es alcanzable
Sondas del orquestador Comparten puerto —y pool de hilos— con el tráfico Conector independiente: siguen respondiendo bajo saturación

Ese último punto es más útil de lo que parece: si el pool de hilos que atiende 8080 está agotado, la sonda de salud que viaja por 8080 también se queda esperando y el orquestador concluye que la aplicación está muerta. Advertencia: al cambiar management.server.port, la cadena de seguridad de la API deja de aplicarse a Actuator, porque son puertos distintos. Sigue haciendo falta autenticación —el apartado siguiente— y hay que usar EndpointRequest, que sabe resolver el puerto de gestión, en lugar de escribir la ruta a mano.

  1. Asegurar Actuator con Spring Security

En 05-02 dejamos escrita una cadena aparte con @Order(1) y securityMatcher("/actuator/**"). Aquella versión funcionaba, pero tiene dos defectos: la ruta está escrita a mano —si alguien cambia base-path, la seguridad deja de aplicarse en silencio— y no contempla el puerto de gestión. Actuator trae un RequestMatcher propio que resuelve ambos:

package com.ciclourbana.seguridad;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;   // + anotaciones de Spring Security

@Configuration
public class ConfiguracionSeguridadActuator {

    @Bean
    @Order(1)
    SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Salud e info: públicos, los consultan el balanceador y el orquestador
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class))
                    .permitAll()
                // Todo lo demás: solo ADMIN
                .anyRequest().hasRole("ADMIN"))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Las decisiones, una a una:

  • EndpointRequest.toAnyEndpoint() coincide con todos los endpoints expuestos, cualquiera que sea el base-path y esté en el puerto que esté. Es la diferencia entre una regla que se mantiene sola y una que caduca en silencio.
  • EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class) identifica endpoints por su clase, no por su ruta. Existe la variante por identificador, EndpointRequest.to("health", "info"), útil para endpoints propios como el red del apartado 14.
  • anyRequest().hasRole("ADMIN") cierra el resto. Dentro de esta cadena anyRequest() significa «cualquier endpoint de Actuator», porque el securityMatcher ya acotó el ámbito.
  • httpBasic en lugar de JWT, deliberadamente: las herramientas de operaciones —Prometheus en 09-04, un curl de guardia, el sistema de alertas— no saben pedir un token a /api/v1/auth/login ni renovarlo. Un usuario técnico con ADMIN y contraseña larga, por un puerto que no sale de la red interna y sobre TLS, es más operable y no menos seguro. La alternativa madura es mTLS entre el sistema de monitorización y la aplicación.
  • STATELESS y CSRF desactivado, por coherencia con el resto del proyecto (05-02): no hay sesión ni formularios.

El RoleHierarchy de 05-03 sigue vigente, así que un ADMIN cubre también lo exigible a OPERARIO. Y el permitAll sobre salud es revisable: si la red lo permite, cerrar también health y dejar públicas solo las sondas por grupo (apartado 8) es todavía mejor.

  1. /actuator/health a fondo

health no es una comprobación, es una agregación: Spring recoge todos los beans que implementan HealthIndicator, pregunta a cada uno y combina los resultados.

management:
  endpoint:
    health:
      show-details: when-authorized      # never | when-authorized | always
      show-components: when-authorized
      roles: ADMIN
  health:
    diskspace:
      enabled: false                     # ruidoso en contenedores con disco efímero
Valor de show-details Quién ve el desglose
never (por defecto) Nadie: solo {"status":"UP"}
when-authorized Solo usuarios autenticados con alguno de los roles de roles
always Todo el mundo, incluidos los anónimos

Con when-authorized y credenciales de ADMIN, la respuesta cambia:

{
  "status": "UP",
  "components": {
    "db":        { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
    "diskSpace": { "status": "UP", "details": { "total": 494384795648, "free": 91234567890 } },
    "redEstaciones": { "status": "UP", "details": { "estaciones": 4, "bicicletasDisponibles": 37 } }
  }
}

Los indicadores automáticos aparecen solos según lo que haya en el classpath: db con cualquier DataSource (pide una conexión y ejecuta isValid()), diskSpace siempre (espacio libre por encima de un umbral de 10 MB), ping siempre (responde UP si la aplicación atiende), ssl desde Boot 3.4 (certificados a punto de caducar) y uno por cada starter de infraestructura presente: mail, redis, rabbit, kafka, elasticsearch. Cada uno se apaga con management.health.<nombre>.enabled: false.

Las reglas de agregación son la parte que más sorpresas da: UP produce 200, DOWN y OUT_OF_SERVICE producen 503, y UNKNOWN produce 200. El estado global es el peor de todos según un orden configurable, y cualquier DOWN tumba el conjunto. De ahí la trampa más común de esta lección: un indicador de un sistema no crítico puede sacar la aplicación de producción. Si CicloUrbana declara un indicador para la pasarela de pagos de Ribalta y la pasarela se cae, /actuator/health devuelve 503, el balanceador retira la instancia y la red entera deja de alquilar bicicletas por un problema que solo afectaba al cobro. La solución no es quitar el indicador, sino sacarlo del grupo que mira el balanceador.

  1. Grupos de salud y sondas de disponibilidad

Un grupo es un subconjunto de indicadores con su propia URL y su propia política:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: db
          show-details: always
        liveness:
          include: livenessState

Ahora existen /actuator/health/readiness y /actuator/health/liveness, cada una agregando solo lo suyo. La pasarela de pagos del ejemplo anterior puede seguir apareciendo en /actuator/health completo —donde la ve el equipo— sin estar en readiness, que es lo que mira el balanceador.

Las sondas de disponibilidad son la pieza que hará falta en 07-04 y 08-04. Con probes.enabled: true —y automáticamente cuando Spring detecta que corre en Kubernetes— el framework gestiona dos estados propios:

Sonda Pregunta Si falla, el orquestador... Causas típicas
Liveness ¿El proceso está sano o irrecuperable? Mata y reinicia el contenedor Estado interno corrupto, interbloqueo total, OutOfMemoryError
Readiness ¿Puede atender peticiones ahora? Deja de enviarle tráfico, sin matarlo Arrancando, calentando cachés, dependencia crítica caída, mantenimiento
sequenceDiagram
    participant K as Orquestador
    participant A as CicloUrbana
    K->>A: GET /actuator/health/liveness
    A-->>K: 503 (aún no hay servidor)
    Note over A: Contexto listo · ApplicationReadyEvent
    K->>A: GET /actuator/health/liveness
    A-->>K: 200 UP (proceso sano)
    K->>A: GET /actuator/health/readiness
    A-->>K: 503 (Flyway aún migrando)
    K->>A: GET /actuator/health/readiness
    A-->>K: 200 UP
    K->>K: Añade la instancia al balanceador

La distinción es crítica y se equivoca constantemente. Si metes la base de datos en liveness, una caída de PostgreSQL de treinta segundos hará que el orquestador mate todas las instancias de CicloUrbana, que al reiniciar tampoco encontrarán la base de datos, entrando en un bucle de reinicios que convierte una incidencia de la base de datos en una caída total. La regla es sencilla: en liveness solo va lo que se arregla reiniciando; en readiness va todo lo que impide atender bien. Una dependencia externa caída nunca se arregla reiniciando.

  1. Un HealthIndicator propio: SaludRedEstaciones

La salud de CicloUrbana no es solo técnica. Si las cuatro estaciones de Ribalta están sin bicicletas disponibles, la aplicación responde perfectamente y el servicio, en la práctica, no existe. Eso es un indicador de negocio:

package com.ciclourbana.estaciones;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component("redEstaciones")
public class SaludRedEstaciones implements HealthIndicator {

    private final EstacionRepositorio estacionRepositorio;
    private final BicicletaRepositorio bicicletaRepositorio;

    // constructor omitido

    @Override
    public Health health() {
        try {
            long estaciones  = estacionRepositorio.count();
            long disponibles = bicicletaRepositorio.contarPorEstado(EstadoBicicleta.DISPONIBLE);
            long conBicis    = estacionRepositorio.contarConBicicletasDisponibles();

            Health.Builder salud = disponibles == 0
                    ? Health.down().withDetail("motivo", "ninguna bicicleta disponible en la red")
                    : Health.up();

            return salud.withDetail("estaciones", estaciones)
                        .withDetail("estacionesConBicicletas", conBicis)
                        .withDetail("bicicletasDisponibles", disponibles)
                        .build();

        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

Cuatro detalles que separan un indicador útil de uno dañino:

  • El nombre del bean es el nombre del componente. @Component("redEstaciones") produce esa clave en el JSON. Sin él, Spring lo deriva de la clase quitando el sufijo HealthIndicator; nombrarlo evita que un renombrado rompa la configuración del grupo readiness.
  • Nunca dejes escapar una excepción. Un indicador que lanza produce un DOWN con el mensaje de la excepción dentro del JSON, y ese mensaje puede contener la URL de la base de datos con el usuario.
  • Tiene que ser rápido. Se ejecuta en cada sonda, cada tres o cinco segundos, en cada instancia. Una consulta que recorra el histórico de alquileres convierte la sonda en un problema de rendimiento; si el cálculo es caro, cachéalo (09-02) o léelo del RegistroAlquileresActivos en memoria.
  • Piensa a qué grupo pertenece. redEstaciones en readiness significa: si Ribalta se queda sin bicicletas, el balanceador retira la instancia. Casi con seguridad no es lo que quieres —el ciudadano debe poder consultar estaciones aunque estén vacías—, así que encaja mejor en /actuator/health general y en una alerta. Es el ejemplo perfecto de por qué la pregunta «¿en qué grupo va?» importa más que el propio código.

Variantes: AbstractHealthIndicator ahorra el try/catch sobrescribiendo doHealthCheck(Health.Builder); CompositeHealthContributor agrupa varios sub-indicadores bajo una clave común —útil para una salud por estación—; y en una aplicación WebFlux se implementa ReactiveHealthIndicator, cuyo health() devuelve un Mono<Health> para no bloquear el bucle de eventos. CicloUrbana es servlet, así que usamos la variante bloqueante.

  1. Cambiar la disponibilidad con AvailabilityChangeEvent

A veces el estado no se deduce, se decide. Cuando el equipo de Ribalta va a aplicar una migración pesada, quiere que la instancia siga viva pero deje de recibir tráfico. Spring lo modela con dos enumerados, LivenessState y ReadinessState, que se cambian publicando un evento:

@Service
public class ModoMantenimiento {

    private final ApplicationEventPublisher eventos;   // constructor omitido

    public void activar() {
        AvailabilityChangeEvent.publish(eventos, this, ReadinessState.REFUSING_TRAFFIC);
    }

    public void desactivar() {
        AvailabilityChangeEvent.publish(eventos, this, ReadinessState.ACCEPTING_TRAFFIC);
    }

    @EventListener
    public void alCambiar(AvailabilityChangeEvent<ReadinessState> evento) {
        log.warn("Disponibilidad de la red de Ribalta: {}", evento.getState());
    }
}

A partir del publish, /actuator/health/readiness devuelve 503 y el balanceador retira la instancia sin matarla, mientras liveness sigue en UP; las peticiones en curso terminan gracias al apagado ordenado de 01-05. Publicar LivenessState.BROKEN tiene el efecto contrario y mucho más drástico: le dice al orquestador «estoy irrecuperable, mátame». El @EventListener registra en el log quién dejó de aceptar tráfico y cuándo, que es justo lo que se busca después en una investigación.

  1. /actuator/info: qué versión está desplegada

De todas las preguntas de operaciones, «¿qué versión hay desplegada?» es la que más tiempo hace perder cuando no tiene respuesta automática. info la responde, pero viene vacío por defecto y hay que llenarlo desde tres fuentes: propiedades estáticas, los datos de compilación y el commit de Git.

management:
  info:
    env:   { enabled: true }        # desactivado por defecto desde Boot 2.6
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    java:  { enabled: true }
info:
  aplicacion: { nombre: CicloUrbana, ciudad: Ribalta }

Los dos plugins que generan META-INF/build-info.properties y git.properties durante el empaquetado:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution><id>build-info</id><goals><goal>build-info</goal></goals></execution>
    </executions>
</plugin>
<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>
    <configuration><generateGitPropertiesFile>true</generateGitPropertiesFile></configuration>
</plugin>

El resultado combinado:

{
  "aplicacion": { "nombre": "CicloUrbana", "ciudad": "Ribalta" },
  "build": { "artifact": "ciclourbana", "version": "2.4.0", "time": "2026-09-01T07:12:44.118Z" },
  "git":   { "branch": "main", "commit": { "id": "9f3a2b1", "time": "2026-09-01T07:10:02Z" } }
}

Con esa respuesta, «¿está desplegado el arreglo del cálculo de tarifa?» se contesta comparando un hash de siete caracteres, sin entrar en la máquina. En 08-05 esto se convierte en la base de la verificación posterior al despliegue. Un InfoContributor propio añade además datos calculados en caliente:

@Component
public class InfoRedRibalta implements InfoContributor {

    private final EstacionRepositorio estacionRepositorio;
    private final RedProperties red;                      // constructor omitido

    @Override
    public void contribute(Info.Builder builder) {
        builder.withDetail("red", Map.of(
                "ciudad", red.ciudad(),
                "estaciones", estacionRepositorio.count(),
                "duracionMaximaAlquiler", red.duracionMaximaAlquiler().toString()));
    }
}

Advertencia: info suele quedar público, así que no metas ahí nada que no pondrías en un cartel. La URL de la base de datos, el nombre del servidor o la lista de dependencias con sus versiones —un inventario perfecto de vulnerabilidades conocidas— no deben aparecer.

  1. /actuator/loggers: cambiar el nivel de log en caliente

Es el endpoint que más veces salva una guardia. Permite subir el detalle del log de un paquete concreto sin reiniciar y sin desplegar, y volver a bajarlo cuando termines.

# Consultar el nivel efectivo
curl -s -u admin:*** http://localhost:8081/actuator/loggers/com.ciclourbana.alquileres
# {"configuredLevel":null,"effectiveLevel":"INFO","members":[...]}

# Subir solo ese paquete a DEBUG
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.alquileres

# Devolverlo a su valor configurado
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":null}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.alquileres

Tres consejos operativos. Sé quirúrgico: poner org.hibernate.SQL en DEBUG en una instancia con tráfico real puede generar gigabytes en minutos y llenar el disco, lo que sí es una caída. Acuérdate de deshacerlo, porque el cambio vive en memoria y sobrevive hasta el próximo reinicio, que puede tardar semanas. Y recuerda que es un endpoint de escritura: esa es exactamente la razón por la que exige ADMIN. El tratamiento sistemático del registro —formatos, JSON estructurado, agregación— es materia de 09-05; aquí solo nos interesa la palanca.

  1. mappings, configprops, env y los secretos

/actuator/mappings devuelve el inventario completo de rutas: el patrón, los métodos HTTP, el produces/consumes y el método Java que atiende. Responde en segundos a «¿esta ruta existe?», «¿por qué esta petición cae en el controlador equivocado?» y «¿queda alguna ruta de depuración publicada?».

/actuator/configprops muestra los @ConfigurationProperties de 02-05 con sus valores efectivos —RedProperties, TarifasProperties, PasarelaProperties— y, junto con /actuator/env, responde la pregunta más frustrante de la configuración de Spring: ¿de dónde sale este valor? env lista las fuentes en orden de precedencia, de modo que se ve que la contraseña viene de una variable de entorno y no del YAML.

Y aquí está el riesgo: ambos endpoints ven todo, incluido ciclourbana.jwt.secreto y spring.datasource.password. Por eso Spring sanitiza:

management:
  endpoint:
    env:         { show-values: when-authorized }   # never (por defecto) | when-authorized | always
    configprops: { show-values: when-authorized }

Con never, cualquier valor aparece como "******". Con when-authorized se muestran solo a quien tenga los roles configurados; y aun así Spring enmascara las claves cuyo nombre coincide con patrones sensibles (password, secret, key, token, credentials), enmascaramiento afinable con un bean SanitizingFunction.

La regla de Ribalta: env y configprops no se exponen en producción. El diagnóstico que aportan es real, pero se puede obtener en pre con la misma configuración, y ninguna cantidad de sanitización compensa el día en que alguien añada una propiedad llamada ciclourbana.pasarela.credencial-maestra —que no coincide con ningún patrón— y la publique en JSON. El enmascaramiento es una segunda línea de defensa, no la primera.

  1. Un endpoint propio con @Endpoint

Cuando la información que necesita operaciones no encaja en salud ni en info, se escribe un endpoint. En CicloUrbana, el resumen del estado de la red de Ribalta:

package com.ciclourbana.estaciones;

import org.springframework.boot.actuate.endpoint.annotation.*;   // Endpoint, ReadOperation...

@Component
@Endpoint(id = "red")
public class EndpointRed {

    private final EstacionService estacionService;
    private final ModoMantenimiento mantenimiento;        // constructor omitido

    /** GET /actuator/red */
    @ReadOperation
    public Map<String, Object> resumen() {
        List<Estacion> estaciones = estacionService.listarTodas();
        return Map.of(
                "instante", Instant.now().toString(),
                "estaciones", estaciones.size(),
                "anclajesTotales", estaciones.stream().mapToInt(Estacion::capacidad).sum(),
                "detalle", estaciones.stream().map(e -> Map.of(
                        "nombre", e.nombre(),
                        "disponibles", estacionService.contarDisponibles(e.id()))).toList());
    }

    /** GET /actuator/red/{nombre} */
    @ReadOperation
    public Map<String, Object> detalleEstacion(@Selector String nombre) {
        return estacionService.resumenPorNombre(nombre);
    }

    /** POST /actuator/red  con cuerpo {"mantenimiento": true} */
    @WriteOperation
    public Map<String, Object> cambiarMantenimiento(boolean mantenimiento) {
        if (mantenimiento) this.mantenimiento.activar(); else this.mantenimiento.desactivar();
        return Map.of("mantenimiento", mantenimiento);
    }
}
Anotación Verbo HTTP Notas
@ReadOperation GET Puede haber varias si se distinguen por @Selector
@WriteOperation POST Los parámetros se leen del cuerpo JSON por nombre
@DeleteOperation DELETE Para operaciones de invalidación o limpieza
@Selector — Convierte un parámetro en segmento de ruta: /actuator/red/{nombre}

Cuatro puntos a tener en cuenta. El id debe ser alfanumérico en minúsculas y no chocar con otro endpoint. Hay que exponerlo explícitamente en exposure.include, igual que los integrados. Un endpoint no es un controlador: no pasa por el ManejadorGlobalExcepciones de 03-06 ni por la validación de 03-04, así que valida tú los parámetros y devuelve estructuras simples. Y @Endpoint publica a la vez por HTTP y por JMX; para restringirlo a uno existen @WebEndpoint y @JmxEndpoint. Que este endpoint permita activar el mantenimiento con un POST explica por qué la cadena del apartado 6 exige ADMIN para todo lo que no sea salud ni info.

  1. Actuator sobre JMX

Además de HTTP, Actuator publica sus endpoints como MBeans bajo el dominio org.springframework.boot, aunque por defecto está desactivado desde Spring Boot 2.2:

management:
  endpoints:
    jmx:
      exposure: { include: health,info,red }
spring:
  jmx: { enabled: true }

Con eso, JConsole, VisualVM o JMC conectadas al proceso ven los endpoints como operaciones invocables. Sigue siendo útil en despliegues tradicionales sobre máquinas con acceso por SSH, pero en contenedores el camino HTTP es el habitual: no requiere abrir el puerto RMI ni lidiar con la negociación de puertos aleatorios de JMX, notoriamente hostil con NAT y con Docker.

  1. La advertencia que cierra la lección

Actuator es, literalmente, una puerta de servicio a la aplicación. Con env se leen secretos, con heapdump se descarga la memoria completa con los JWT de los ciudadanos de Ribalta dentro, con loggers se llena el disco y con shutdown se apaga el servicio. Un Actuator expuesto sin autenticar a Internet no es una mala práctica: es una brecha. Los buscadores especializados en dispositivos expuestos indexan rutas /actuator/env de forma sistemática, y encuentran resultados todos los días.

La lista mínima de verificación antes de cada despliegue a Ribalta:

  1. exposure.include es una lista explícita, nunca *, en el perfil prod.
  2. env, heapdump, threaddump, beans y configprops están excluidos o restringidos a ADMIN, y shutdown sigue deshabilitado.
  3. Existe una cadena con EndpointRequest.toAnyEndpoint() y anyRequest() no queda sin regla.
  4. management.server.port es un puerto cerrado en el cortafuegos al tráfico externo, y show-details y show-values no valen always.
  5. Todo viaja por TLS, porque la autenticación básica manda la contraseña codificada, no cifrada.

Errores Comunes y Consejos

Exponer todo con include: "*" y olvidarlo. Funciona el primer día, se copia al perfil de producción y sobrevive años. Escribe la lista explícita desde el principio.

Meter la base de datos en liveness. Convierte una caída de PostgreSQL en un bucle de reinicios de todas las instancias. En liveness solo va lo que se arregla reiniciando.

Un HealthIndicator lento. Se ejecuta en cada sonda de cada instancia: una consulta de dos segundos convierte la comprobación de salud en un problema de rendimiento y provoca falsos DOWN por tiempo de espera.

Un indicador no crítico que tumba el health global. El estado agregado es el peor de todos: si la pasarela de pagos no debe retirar la instancia del balanceador, sácala del grupo readiness.

Confundir enabled con exposure. Deshabilitar health «para que no se vea» rompe también las sondas internas. Lo que quieres es dejarlo habilitado y no exponerlo, o exponerlo protegido.

Consejo: pon Actuator en su propio puerto desde el primer día, porque cambiarlo después obliga a tocar balanceadores, cortafuegos, alertas y manifiestos. Y usa EndpointRequest, nunca la ruta literal: "/actuator/**" deja de coincidir el día que alguien cambia base-path, y la seguridad desaparece sin un solo error en el log.

Consejo: documenta tus grupos de salud —qué mira el balanceador, qué mira el orquestador, qué dispara una alerta— y escribe una prueba (06-04) que pida /actuator/env sin credenciales y espere 401: así la política de exposición pasa a estar defendida por la construcción.

Ejercicios

Ejercicio 1: configuración operativa completa

Configura Actuator para CicloUrbana según estos requisitos: puerto de gestión 8081; exposición de health, info, metrics, loggers, flyway y el endpoint propio red; detalles de salud solo para usuarios autorizados con rol ADMIN; sondas de disponibilidad activas; un grupo readiness que incluya solo la base de datos; el indicador de espacio en disco desactivado; e info con datos de compilación y de Git. Escribe el YAML y la cadena de seguridad, y explica qué responde cada URL a un cliente anónimo.

Ejercicio 2: indicador de salud del pool de conexiones

Escribe un HealthIndicator llamado poolConexiones que consulte el HikariDataSource de 04-02 a través de su HikariPoolMXBean y devuelva DOWN si hay más de tres hilos esperando conexión o si las conexiones activas superan el 90 % del máximo, y UP en caso contrario, con el desglose como detalle. Decide razonadamente en qué grupo de salud debe ir.

Ejercicio 3: revisión de una configuración peligrosa

Un compañero ha subido esta configuración de producción. Encuentra todos los problemas y propón la corrección.

management:
  endpoints:
    web:
      exposure: { include: "*" }
  endpoint:
    health:
      show-details: always
      group:
        liveness: { include: db, pasarelaPagos }
    shutdown: { enabled: true }
    env: { show-values: always }
@Bean
@Order(1)
SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
    http.securityMatcher("/actuator/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
    return http.build();
}

Soluciones

Solución 1

management:
  server:
    port: 8081
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info,metrics,loggers,flyway,red
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      roles: ADMIN
      probes: { enabled: true }
      group:
        readiness: { include: db }
        liveness:  { include: livenessState }
  health:
    diskspace: { enabled: false }
  info:
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    env:   { enabled: true }

info:
  aplicacion: { nombre: CicloUrbana, ciudad: Ribalta }

Con la cadena de seguridad del apartado 6, lo que ve un cliente anónimo en el puerto 8081 es:

URL Respuesta anónima Motivo
/actuator/health 200 con {"status":"UP"} Público, pero show-details: when-authorized oculta el desglose
/actuator/health/readiness 200 o 503 según el estado de db Pertenece a health, también público
/actuator/info 200 con versión, commit y datos de la red Público por decisión explícita
/actuator/metrics 401 Expuesto pero exige ADMIN
/actuator/loggers 401 Igual, y además es de escritura
/actuator/red 401 Endpoint propio, cubierto por toAnyEndpoint()
/actuator/env 404 No está en include: la ruta no existe
/actuator/beans 404 Tampoco expuesto

La diferencia entre 401 y 404 es informativa: 404 significa «no expuesto» y 401, «expuesto y protegido». Y conviene recordar que estas URL solo son alcanzables desde la red interna, porque el puerto 8081 no se publica al exterior.

Solución 2

@Component("poolConexiones")
public class SaludPoolConexiones implements HealthIndicator {

    private static final int ESPERA_MAXIMA = 3;
    private static final double UMBRAL_OCUPACION = 0.90;

    private final HikariDataSource dataSource;            // constructor omitido

    @Override
    public Health health() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        if (pool == null) {
            return Health.unknown().withDetail("motivo", "pool aún no inicializado").build();
        }

        int activas   = pool.getActiveConnections();
        int esperando = pool.getThreadsAwaitingConnection();
        int maximo    = dataSource.getMaximumPoolSize();

        boolean saturado = esperando > ESPERA_MAXIMA
                || (double) activas / maximo > UMBRAL_OCUPACION;

        return (saturado ? Health.down() : Health.up())
                .withDetail("activas", activas)
                .withDetail("inactivas", pool.getIdleConnections())
                .withDetail("esperando", esperando)
                .withDetail("maximo", maximo)
                .withDetail("ocupacion", Math.round(100.0 * activas / maximo) + "%")
                .build();
    }
}

Decisiones. Se inyecta HikariDataSource y no DataSource, porque el MXBean es específico de Hikari; el null inicial se contempla porque el pool se crea perezosamente y una sonda muy temprana puede llegar antes. En ese caso se devuelve UNKNOWN y no DOWN, porque UNKNOWN mapea a 200 y una instancia recién arrancada no debe declararse enferma. Los umbrales son constantes por brevedad; en el proyecto real serían propiedades de @ConfigurationProperties (02-05) ajustables por entorno. El indicador es barato: solo lee contadores en memoria, sin tocar la base de datos.

En qué grupo va. Este sí es buen candidato para readiness, y es el contraste exacto con redEstaciones. Un pool saturado significa que la instancia no puede atender bien más peticiones, así que retirarla del balanceador es lo correcto: el tráfico va a instancias sanas y esta se recupera drenando su cola. En liveness sería un error grave —un pico de carga mataría las instancias que están aguantando—. Cautela final: si todas las instancias saturan el pool a la vez, todas se declaran REFUSING_TRAFFIC y la red de Ribalta se queda sin ninguna disponible; por eso el umbral debe ser alto (90 %, no 60 %) y debe existir una alerta que avise mucho antes de que la sonda actúe.

Solución 3

Siete problemas, en orden de gravedad:

# Problema Consecuencia Corrección
1 permitAll() sobre todo Actuator Cualquiera en Internet lee env, descarga heapdump y apaga la aplicación EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class).permitAll() y anyRequest().hasRole("ADMIN")
2 shutdown.enabled: true Un POST anónimo apaga CicloUrbana Quitarlo: dejarlo deshabilitado
3 include: "*" Publica env, beans, heapdump, threaddump y cualquier endpoint futuro Lista explícita
4 env.show-values: always El secreto JWT y la contraseña de PostgreSQL salen en claro never, o directamente no exponer env
5 pasarelaPagos dentro de liveness Una caída de la pasarela mata y reinicia todas las instancias en bucle Sacarlo de liveness; ni siquiera debería estar en readiness
6 db dentro de liveness Mismo bucle de reinicios ante una incidencia de base de datos Moverlo a readiness
7 show-details: always El desglose (motor, versión, rutas de disco, estado del pool) es público when-authorized con roles: ADMIN

Hay un octavo problema, de forma: la cadena usa la ruta literal "/actuator/**", así que si alguien cambia base-path o activa management.server.port, la regla deja de coincidir sin ningún aviso.

La configuración corregida es la de la solución 1 más la cadena del apartado 6. Y una lección de fondo: los problemas 1 y 2 juntos permiten a cualquiera apagar la red de bicicletas de una ciudad con un curl de una línea. No es un fallo de Spring Boot —todos sus valores por defecto son seguros—, es el resultado de haberlos cambiado uno a uno buscando comodidad durante el desarrollo y haber promocionado el fichero a producción. De ahí el módulo siguiente.

Conclusión

CicloUrbana ya se deja preguntar. Sabes qué significa que una aplicación sea operable y por qué es una propiedad distinta de ser correcta. Conoces el catálogo completo de endpoints de Actuator con el riesgo de cada uno, entiendes por qué solo health sale por HTTP de fábrica y por qué esa política nace de una lección aprendida a golpes. Sabes exponerlos con include/exclude, moverlos a un base-path distinto y, sobre todo, separarlos en su propio puerto para que sea el cortafuegos —y no una regla de código— quien decida quién habla con la gestión. Has cerrado la cadena de seguridad con EndpointRequest.toAnyEndpoint(), que no caduca cuando cambia la ruta, dejando públicos solo salud e info y exigiendo ADMIN para el resto.

Dominas /actuator/health por dentro: la agregación al peor estado, show-details, los indicadores automáticos y cómo apagarlos, y la pieza que hará falta en Docker y en Kubernetes —los grupos de salud y las sondas liveness y readiness—, con la regla que evita el error más caro: en liveness solo va lo que se arregla reiniciando. Has escrito SaludRedEstaciones, que mide la salud del servicio y no solo la del proceso, con las cuatro cautelas que separan un indicador útil de uno dañino, y sabes cambiar la disponibilidad a voluntad con AvailabilityChangeEvent para vaciar de tráfico una instancia sin matarla. /actuator/info responde por fin qué versión y qué commit están desplegados en Ribalta, /actuator/loggers sube el detalle del log sin reiniciar, mappings y configprops sirven de herramienta de diagnóstico, y tienes grabada la advertencia sobre env y los secretos. Y has escrito tu propio @Endpoint(id = "red") con operaciones de lectura, escritura y borrado.

Queda un endpoint deliberadamente sin abrir: /actuator/metrics. Actuator lo publica, pero explotarlo —contadores y temporizadores de negocio, @Timed, percentiles, MeterRegistry— es el trabajo de 09-03, y su recolección por Prometheus y su representación en Grafana, el de 09-04. Aquí hemos montado la instrumentación; allí se leerá.

Y ahora aparece el problema que el ejercicio 3 ha dejado al descubierto. Toda la configuración de esta lección —qué se expone, cuántos detalles se muestran, si shutdown está habilitado— debe ser distinta en el portátil de un desarrollador y en el servidor del ayuntamiento. Lo mismo pasa con la base de datos (H2 frente a PostgreSQL), con el nivel de log, con Swagger, con ddl-auto, con CORS y con la caducidad del JWT. Hasta ahora los hemos ido cambiando a mano en un único application.yml, que es exactamente cómo una configuración de desarrollo termina desplegada en producción. La siguiente lección, Perfiles de Spring Boot, resuelve eso con el principio de construir una vez y desplegar en muchos sitios: un solo artefacto, varios entornos, ninguna recompilación.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados