Spring Boot Actuator: health check y métricas
1. Visión general
Un servicio ejecutado en production necesita responder dos preguntas: “¿sigue vivo?” y “¿cómo está funcionando?”. Spring Boot Actuator responde a ambas a través de endpoints integrados, principalmente /actuator/health y /actuator/metrics.
En este artículo, habilitarás Actuator, escribirás un HealthIndicator personalizado y configurarás liveness/readiness probes para Kubernetes. Luego registrarás métricas empresariales usando Micrometer, exportarás a Prometheus y escribirás tests para ambas partes.
2. Instalación
Necesitas agregar el starter de Actuator. El ejemplo en este artículo también usa spring-boot-starter-web para que los endpoints se sirvan a través de HTTP:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Inicia la aplicación e intenta hacer una llamada:
curl http://localhost:8080/actuator
{"_links":{"self":{...},"health":{...},"health-path":{...}}}
Por defecto, Spring Boot 3.x solo expone el endpoint health a través de HTTP. Los otros endpoints siguen existiendo pero no son accesibles desde el exterior. Esta es una elección de seguridad deliberada.
2.1. Exponer más endpoints
Especificas explícitamente qué endpoints quieres abrir en application.properties:
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always
La primera línea abre tres endpoints: health, info y metrics. La segunda permite que /actuator/health devuelva detalles de cada componente en lugar de solo un estado general.
Nota: Evita usar
management.endpoints.web.exposure.include=*en production. Endpoints comoenv,heapdumpoconfigpropspueden exponer contraseñas, configuración interna y datos en memoria.
3. Health check
3.1. Endpoint por defecto
Con la configuración anterior, llamar a /actuator/health devuelve algo como:
{
"status": "UP",
"components": {
"diskSpace": {
"status": "UP",
"details": { "total": 499963174912, "free": 213456789504, "threshold": 10485760 }
},
"ping": { "status": "UP" }
}
}
Spring Boot registra automáticamente HealthIndicators basados en lo que hay en el classpath. Si tienes un DataSource, verás db. Si tienes Redis, verás redis, y así sucesivamente. El estado general es una agregación: si un componente está DOWN, todo está DOWN, y el estado HTTP devuelto es 503.
3.2. Escribir un HealthIndicator personalizado
Supongamos que tu servicio de órdenes depende de una puerta de pago externa. Primero, tienes un cliente simple:
public interface PaymentGatewayClient {
boolean ping();
}
Luego escribe un indicator que verifica este cliente:
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
this.client = client;
}
@Override
public Health health() {
try {
if (client.ping()) {
return Health.up().withDetail("provider", "VNPay").build();
}
return Health.down().withDetail("reason", "ping returned false").build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Algunos puntos a notar:
- El nombre del bean es
paymentGatewayHealthIndicator. Spring Boot quita el sufijoHealthIndicator, así que la clave en JSON espaymentGateway. withDetail(...)agrega información adicional, solo se muestra cuandoshow-detailslo permite.Health.down(e)automáticamente incluye el mensaje de error endetails.error.
El resultado cuando la puerta de pago falla:
{
"status": "DOWN",
"components": {
"diskSpace": { "status": "UP", ... },
"paymentGateway": {
"status": "DOWN",
"details": { "error": "java.net.ConnectException: Connection refused" }
},
"ping": { "status": "UP" }
}
}
Nota: El método
health()se ejecuta cada vez que se llama al endpoint. Si hace llamadas de red sin un timeout, una dependencia lenta hará que/actuator/healthse bloquee. Siempre deberías establecer un timeout corto (por ejemplo 1-2 segundos) para el cliente usado en health checks.
3.3. Liveness y readiness probes
Kubernetes distingue entre dos tipos de probes:
- Liveness: ¿la aplicación está bloqueada? Si falla, el pod se reinicia.
- Readiness: ¿está la aplicación lista para recibir solicitudes? Si falla, el pod se retira del balanceador de carga pero no se reinicia.
Spring Boot los habilita automáticamente cuando detecta que se ejecuta en Kubernetes. Para ejecutar localmente, habilítalos manualmente:
management.endpoint.health.probes.enabled=true
management.endpoint.health.group.readiness.include=readinessState,paymentGateway
La primera línea crea dos endpoints /actuator/health/liveness y /actuator/health/readiness. La segunda agrega paymentGateway al grupo readiness. Cuando la puerta de pago falla, el pod solo detiene el tráfico pero no se reinicia innecesariamente.
curl http://localhost:8080/actuator/health/liveness
{"status":"UP"}
La configuración correspondiente en el manifest de Kubernetes:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
Nota: No incluyas dependencias externas (base de datos, API de pagos) en el grupo liveness. Si la base de datos falla, todos los pods fallarán en liveness y se reiniciarán simultáneamente. Reiniciar no arregla la base de datos sino que causa carga adicional durante el arranque.
4. Métricas con Micrometer
Actuator usa Micrometer como capa abstracta para métricas. No necesitas configuración adicional, ya tienes métricas de JVM, CPU, solicitudes HTTP, connection pool y mucho más.
curl http://localhost:8080/actuator/metrics
{"names":["application.ready.time","http.server.requests","jvm.gc.pause","jvm.memory.used","process.cpu.usage", ...]}
Para ver los detalles de una métrica, agrega su nombre a la URL. Puedes filtrar por tags usando el parámetro tag:
curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/orders"
4.1. Counter para número de órdenes
Las métricas del sistema no son suficientes, generalmente necesitas métricas empresariales también. El siguiente ejemplo cuenta el número de órdenes creadas, clasificadas por canal de ventas:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final MeterRegistry registry;
public OrderService(MeterRegistry registry) {
this.registry = registry;
}
public Order createOrder(Customer customer, String channel) {
Order order = new Order(customer.id(), channel);
// ... guardar orden
Counter.builder("orders.created")
.description("Número de órdenes creadas")
.tag("channel", channel)
.register(registry)
.increment();
return order;
}
}
public record Customer(long id, String name) {}
public record Order(long customerId, String channel) {}
MeterRegistry se configura automáticamente en Spring Boot y se inyecta. Llamar a register(registry) múltiples veces con el mismo nombre y tags es seguro, ya que Micrometer devuelve el contador que ya existe. Después de crear tres órdenes a través del canal web:
curl http://localhost:8080/actuator/metrics/orders.created
{
"name": "orders.created",
"description": "Número de órdenes creadas",
"measurements": [ { "statistic": "COUNT", "value": 3.0 } ],
"availableTags": [ { "tag": "channel", "values": ["web"] } ]
}
4.2. Timer para tiempo de procesamiento de pagos
Timer tanto cuenta el número de llamadas como mide el tiempo total y máximo:
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final Timer paymentTimer;
public PaymentService(MeterRegistry registry) {
this.paymentTimer = Timer.builder("payment.process")
.description("Tiempo de procesamiento de pago")
.register(registry);
}
public boolean pay(Order order) {
return paymentTimer.record(() -> {
// ... llamar a puerta de pago
return true;
});
}
}
El Timer se crea una vez en el constructor y luego se reutiliza, ya que no hay tags que cambien por cada llamada. record(Supplier) mide el tiempo de ejecución del lambda y devuelve su resultado. El endpoint /actuator/metrics/payment.process mostrará tres estadísticas: COUNT, TOTAL_TIME y MAX.
4.3. Tags comunes para todas las métricas
Cuando múltiples servicios envían métricas a un lugar, deberías agregar el nombre de la aplicación a todas las métricas:
management.metrics.tags.application=order-service
Nota: No uses valores con una cantidad ilimitada o muy grande como tags, como
orderId,customerIdo email. Cada combinación única de tags crea una serie temporal separada. Millones de órdenes crearían millones de series, inflando la memoria de la aplicación y del sistema de monitoreo. Los tags solo deben contener conjuntos de valores pequeños y fijos comochannel,statusoregion.
5. Exportar a Prometheus
El endpoint /actuator/metrics es útil para ver rápidamente. Para almacenamiento a largo plazo y graficar, necesitas un sistema como Prometheus. Agrega el registry correspondiente:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Luego expone el endpoint prometheus:
management.endpoints.web.exposure.include=health,info,metrics,prometheus
curl http://localhost:8080/actuator/prometheus | grep -E "orders|payment"
# HELP orders_created_total Número de órdenes creadas
# TYPE orders_created_total counter
orders_created_total{application="order-service",channel="web"} 3.0
payment_process_seconds_count{application="order-service"} 5.0
payment_process_seconds_sum{application="order-service"} 0.412
payment_process_seconds_max{application="order-service"} 0.153
Micrometer automáticamente renombra según la convención de Prometheus: puntos se convierten en guiones bajos, los contadores tienen el sufijo _total, los timers se calculan en segundos. El resto es configurar Prometheus para que haga scrape de la ruta /actuator/prometheus.
6. Pruebas
6.1. Test HealthIndicator
HealthIndicator es una clase normal, así que puedes probarla sin necesidad de iniciar el contexto de Spring:
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;
import static org.assertj.core.api.Assertions.assertThat;
class PaymentGatewayHealthIndicatorTest {
@Test
void whenPingSucceeds_thenStatusIsUp() {
var indicator = new PaymentGatewayHealthIndicator(() -> true);
Health health = indicator.health();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health.getDetails()).containsEntry("provider", "VNPay");
}
@Test
void whenPingThrows_thenStatusIsDownWithError() {
var indicator = new PaymentGatewayHealthIndicator(() -> {
throw new IllegalStateException("timeout");
});
Health health = indicator.health();
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
assertThat(health.getDetails().get("error").toString()).contains("timeout");
}
}
Como PaymentGatewayClient solo tiene un método, pasas un lambda en lugar de un mock.
6.2. Test de métricas
Micrometer proporciona SimpleMeterRegistry que almacena métricas en memoria, muy conveniente para tests unitarios:
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceTest {
@Test
void whenOrdersCreated_thenCounterIncrementsPerChannel() {
var registry = new SimpleMeterRegistry();
var service = new OrderService(registry);
var customer = new Customer(1L, "An");
service.createOrder(customer, "web");
service.createOrder(customer, "web");
service.createOrder(customer, "mobile");
assertThat(registry.get("orders.created").tag("channel", "web").counter().count())
.isEqualTo(2.0);
assertThat(registry.get("orders.created").tag("channel", "mobile").counter().count())
.isEqualTo(1.0);
}
}
Si la métrica no existe, registry.get(...) lanza MeterNotFoundException. Esto ayuda a detectar nombres de métricas mal escritos en los tests.
7. Seguridad y errores comunes
Separar puerto de administración. Puedes ejecutar Actuator en un puerto separado, solo abierto en la red interna:
management.server.port=8081
La aplicación se sirve en 8080, pero /actuator/** solo está disponible en 8081. Bloquea este puerto en el firewall o en el ingress. En Kubernetes, recuerda cambiar el puerto en la configuración de probes a 8081.
Limitar detalles de salud. En production, usa show-details=when-authorized junto con Spring Security en lugar de always. Los usuarios anónimos solo verán {"status":"UP"}.
Errores comunes:
- Llamada a
/actuator/metricsdevuelve404: no has agregadometricsaexposure.include. - Health check falla aleatoriamente: un indicator hace llamadas de red sin timeout, o tiene tiempos de respuesta más largos que el timeout de la probe.
- Pod se reinicia continuamente cuando la base de datos es inestable: la base de datos está incluida en el grupo liveness.
- La memoria crece gradualmente con el tiempo: la métrica usa tags con cantidad ilimitada de valores.
- No ves el endpoint
/actuator/prometheus: faltamicrometer-registry-prometheuso no expusiste el endpoint.
8. Conclusión
Spring Boot Actuator te proporciona health checks y métricas casi gratis. Tu trabajo es configurarlo correctamente: exponer solo los endpoints necesarios, escribir HealthIndicators con timeouts para dependencias críticas, y separar liveness de readiness para que Kubernetes reaccione apropiadamente.
Con métricas, Micrometer te permite registrar Counters y Timers para lógica empresarial en solo unas pocas líneas, luego exportar a Prometheus con una sola dependencia. Mantén la cantidad de valores únicos de tags pequeña y escribe tests con SimpleMeterRegistry para que las métricas no se rompan silenciosamente después de cada refactorización.