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 como env, heapdump o configprops pueden 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 sufijo HealthIndicator, así que la clave en JSON es paymentGateway.
  • withDetail(...) agrega información adicional, solo se muestra cuando show-details lo permite.
  • Health.down(e) automáticamente incluye el mensaje de error en details.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/health se 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, customerId o 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 como channel, status o region.

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/metrics devuelve 404: no has agregado metrics a exposure.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: falta micrometer-registry-prometheus o 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.

spring-bootactuatormicrometermonitoringprometheus