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
- Qué significa que una aplicación sea operable
- Añadir Actuator y qué aparece de inmediato
- El catálogo completo de endpoints
- Exposición y descubrimiento
- Puerto de gestión separado
- Asegurar Actuator con Spring Security
/actuator/healtha fondo- Grupos de salud y sondas de disponibilidad
- Un
HealthIndicatorpropio:SaludRedEstaciones - Cambiar la disponibilidad con
AvailabilityChangeEvent /actuator/info: qué versión está desplegada/actuator/loggers: cambiar el nivel de log en calientemappings,configprops,envy los secretos- Un endpoint propio con
@Endpoint - Actuator sobre JMX
- Errores Comunes y Consejos
- Ejercicios
- 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.
- 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:
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:
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.
- 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.
- 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.
- Puerto de gestión separado
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.
- 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 elbase-pathy 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 elreddel apartado 14.anyRequest().hasRole("ADMIN")cierra el resto. Dentro de esta cadenaanyRequest()significa «cualquier endpoint de Actuator», porque elsecurityMatcherya acotó el ámbito.httpBasicen lugar de JWT, deliberadamente: las herramientas de operaciones —Prometheus en 09-04, uncurlde guardia, el sistema de alertas— no saben pedir un token a/api/v1/auth/loginni renovarlo. Un usuario técnico conADMINy 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.STATELESSy 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.
/actuator/health a fondo
/actuator/health a fondohealth 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ímeroValor 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.
- 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: livenessStateAhora 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.
- Un
HealthIndicator propio: SaludRedEstaciones
HealthIndicator propio: SaludRedEstacionesLa 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 sufijoHealthIndicator; nombrarlo evita que un renombrado rompa la configuración del gruporeadiness. - Nunca dejes escapar una excepción. Un indicador que lanza produce un
DOWNcon 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
RegistroAlquileresActivosen memoria. - Piensa a qué grupo pertenece.
redEstacionesenreadinesssignifica: 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/healthgeneral 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.
- Cambiar la disponibilidad con
AvailabilityChangeEvent
AvailabilityChangeEventA 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.
/actuator/info: qué versión está desplegada
/actuator/info: qué versión está desplegadaDe 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.
/actuator/loggers: cambiar el nivel de log en caliente
/actuator/loggers: cambiar el nivel de log en calienteEs 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.alquileresTres 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.
mappings, configprops, env y los secretos
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.
- Un endpoint propio con
@Endpoint
@EndpointCuando 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.
- 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:
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.
- 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:
exposure.includees una lista explícita, nunca*, en el perfilprod.env,heapdump,threaddump,beansyconfigpropsestán excluidos o restringidos aADMIN, yshutdownsigue deshabilitado.- Existe una cadena con
EndpointRequest.toAnyEndpoint()yanyRequest()no queda sin regla. management.server.portes un puerto cerrado en el cortafuegos al tráfico externo, yshow-detailsyshow-valuesno valenalways.- 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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
