StackPractices
intermediate Por Mathias Paulenko

Patrón Health Check: Sondeos de Liveness y Readiness

Implementa sondeos de liveness y readiness para orquestadores. Cubre probes en Kubernetes, chequeos de dependencias y degradación elegante.

Visión General

Los health checks exponen endpoints que le indican al orquestador si una aplicación está corriendo y lista para servir tráfico. Kubernetes, ECS y otros orquestadores usan dos tipos de probe distintos: liveness (¿el proceso está vivo?) y readiness (¿el proceso puede atender requests?). El probe de liveness determina si un contenedor debería reiniciarse; el de readiness determina si debería recibir tráfico. Confundirlos provoca reinicios innecesarios o tráfico enviado a instancias que no están listas.

Este patrón cubre el lado del orquestador en el contrato — ciclo de vida de los probes, timings, umbrales y comportamiento durante el apagado. Si lo que necesitas son los endpoints como superficie de monitoreo general para dashboards y chequeos externos, eso es el Patrón de Monitoreo de Endpoint de Salud.

Cuándo Usar

  • Cualquier aplicación corriendo en un orquestador de contenedores (Kubernetes, ECS, Nomad)
  • Servicios con dependencias que tardan en inicializarse (conexiones a base de datos, calentamiento de caché)
  • Aplicaciones que pueden quedar bloqueadas sin crashear (deadlocks, agotamiento de threads)
  • Servicios que necesitan drenar conexiones antes de apagarse
  • Health checks de load balancer para el enrutado de tráfico

Cuándo No Usar

  • Scripts simples o batch jobs — no hay proceso de larga duración que sondear
  • Aplicaciones sin dependencias externas — un chequeo de proceso basta
  • Cuando el orquestador no soporta probes — usa un gestor de procesos en su lugar
  • Para monitoreo interno de salud — usa métricas y alertas, no probes

Solución

Endpoint de salud básico (Python/FastAPI)

# Python — endpoint de health check básico
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/health")
async def health():
    """Chequeo básico de liveness — el proceso está vivo."""
    return {"status": "healthy"}

@app.get("/health/live")
async def liveness():
    """Probe de liveness — ¿el proceso está corriendo?"""
    return {"status": "alive"}

@app.get("/health/ready")
async def readiness():
    """Probe de readiness — ¿podemos servir tráfico?"""
    checks = await run_dependency_checks()
    all_healthy = all(checks.values())

    if not all_healthy:
        return JSONResponse(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            content={"status": "not ready", "checks": checks},
        )

    return {"status": "ready", "checks": checks}

async def run_dependency_checks():
    return {
        "database": await check_database(),
        "redis": await check_redis(),
        "message_queue": await check_message_queue(),
    }

Chequeos de dependencias con timeouts

# Python — chequeos de dependencias con timeouts
import asyncio
import aio_pika
import asyncpg
import redis.asyncio as aioredis

async def check_database(timeout: float = 2.0) -> bool:
    try:
        async with asyncpg.create_pool("postgresql://localhost/myapp") as pool:
            async with pool.acquire() as conn:
                await asyncio.wait_for(
                    conn.execute("SELECT 1"),
                    timeout=timeout,
                )
        return True
    except Exception:
        return False

async def check_redis(timeout: float = 1.0) -> bool:
    try:
        client = aioredis.from_url("redis://localhost:6379")
        pong = await asyncio.wait_for(client.ping(), timeout=timeout)
        await client.aclose()
        return pong
    except Exception:
        return False

async def check_message_queue(timeout: float = 2.0) -> bool:
    try:
        # Comprueba si podemos conectar a RabbitMQ
        connection = await asyncio.wait_for(
            aio_pika.connect_robust("amqp://localhost"),
            timeout=timeout,
        )
        await connection.close()
        return True
    except Exception:
        return False

Configuración de probes en Kubernetes

# Deployment de Kubernetes con probes de liveness y readiness
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: order-service:1.0.0
          ports:
            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2
          startupProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 0
            periodSeconds: 5
            failureThreshold: 30
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"

Health checks en Express.js

// JavaScript — endpoints de health check en Express
const express = require('express');
const app = express();

// Liveness — el proceso está vivo
app.get('/health/live', (req, res) => {
  res.json({ status: 'alive', uptime: process.uptime() });
});

// Readiness — puede servir tráfico
app.get('/health/ready', async (req, res) => {
  // Ejecuta los chequeos en paralelo — awaits secuenciales
  // apilarían la latencia de cada chequeo y arriesgarían
  // agotar el timeout del probe
  const [database, redisOk, queue] = await Promise.all([
    checkDatabase(),
    checkRedis(),
    checkQueue(),
  ]);
  const checks = { database, redis: redisOk, queue };

  const allHealthy = Object.values(checks).every(v => v === true);

  if (allHealthy) {
    res.json({ status: 'ready', checks });
  } else {
    res.status(503).json({ status: 'not ready', checks });
  }
});

// Endpoint de salud completo
app.get('/health', async (req, res) => {
  const checks = {
    database: await checkDatabase(),
    redis: await checkRedis(),
    queue: await checkQueue(),
    diskSpace: checkDiskSpace(),
    memoryUsage: checkMemoryUsage(),
  };

  const status = Object.values(checks).every(v => v === true)
    ? 'healthy'
    : 'degraded';

  res.json({
    status,
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    checks,
  });
});

async function checkDatabase() {
  try {
    await db.raw('SELECT 1');
    return true;
  } catch {
    return false;
  }
}

async function checkRedis() {
  try {
    await redis.ping();
    return true;
  } catch {
    return false;
  }
}

function checkMemoryUsage() {
  const used = process.memoryUsage().rss;
  const max = 512 * 1024 * 1024; // límite de 512MB
  return used < max;
}

Health checks en Java Spring Boot

Spring Boot Actuator ya trae grupos de estado de liveness y readiness listos para usar; la receta Chequeos de Salud Personalizados con Spring Boot Actuator explica cómo cablearlos. Los indicadores personalizados se ven así:

// Java — health indicators de Spring Boot Actuator
import org.springframework.boot.actuate.health.*;
import org.springframework.stereotype.Component;

@Component
public class DatabaseHealthIndicator extends AbstractHealthIndicator {

    private final DataSource dataSource;

    public DatabaseHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    protected void doHealthCheck(Health.Builder builder) {
        try (Connection conn = dataSource.getConnection()) {
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("SELECT 1");
            }
            builder.up().withDetail("database", "PostgreSQL").withDetail("status", "connected");
        } catch (SQLException e) {
            builder.down().withDetail("error", e.getMessage());
        }
    }
}

@Component
public class RedisHealthIndicator extends AbstractHealthIndicator {

    private final RedisTemplate<String, String> redisTemplate;

    public RedisHealthIndicator(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doHealthCheck(Health.Builder builder) {
        try {
            String response = redisTemplate.getConnectionFactory()
                .getConnection().ping();
            builder.up().withDetail("redis", response);
        } catch (Exception e) {
            builder.down().withDetail("error", e.getMessage());
        }
    }
}

// application.yml — configurar los endpoints de salud
// management:
//   endpoints:
//     web:
//       exposure:
//         include: health,info,metrics
//   endpoint:
//     health:
//       probes:
//         enabled: true
//       show-details: always
//   health:
//     livenessstate:
//       enabled: true
//     readinessstate:
//       enabled: true

Apagado elegante con health checks

# Python — apagado elegante con probe de readiness
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import JSONResponse

shutting_down = False

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    # Se ejecuta al apagar (aquí aterriza el SIGTERM de Kubernetes)
    global shutting_down
    shutting_down = True
    # Dale a kube-proxy/endpoints unos segundos para observar
    # readiness=503 antes de que el servidor deje de aceptar conexiones
    await asyncio.sleep(5)
    await drain_connections(timeout=25)

app = FastAPI(lifespan=lifespan)

@app.get("/health/ready")
async def readiness():
    if shutting_down:
        return JSONResponse(
            status_code=503,
            content={"status": "shutting down"},
        )
    # Chequeo normal de readiness
    checks = await run_dependency_checks()
    if not all(checks.values()):
        return JSONResponse(status_code=503, content={"status": "not ready", "checks": checks})
    return {"status": "ready", "checks": checks}

async def drain_connections(timeout: int = 25):
    """Espera a que las requests en curso terminen."""
    deadline = asyncio.get_event_loop().time() + timeout
    while getattr(app.state, "active_requests", 0) > 0:
        if asyncio.get_event_loop().time() > deadline:
            break
        await asyncio.sleep(0.5)

Probe de arranque para inicializaciones lentas

# Python — startup probe para aplicaciones con inicialización lenta
import time
from fastapi import FastAPI
from fastapi.responses import JSONResponse

app = FastAPI()
startup_time = time.time()
ready_since = None

@app.get("/health/startup")
async def startup_probe():
    """Startup probe — ¿la app sigue inicializándose?"""
    if ready_since is None:
        return JSONResponse(status_code=503, content={"status": "starting"})
    return {"status": "started", "ready_since": ready_since}

@app.get("/health/ready")
async def readiness():
    global ready_since
    if ready_since is None:
        # Comprueba si la inicialización está completa
        if await is_initialization_complete():
            ready_since = time.time()
        else:
            return JSONResponse(status_code=503, content={"status": "initializing"})
    # Chequeo normal de readiness
    return {"status": "ready"}

Health check compuesto con circuit breakers

// JavaScript — salud compuesta con estados de circuit breaker
const CircuitBreaker = require('opossum');

const dbBreaker = new CircuitBreaker(queryDatabase, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

app.get('/health/ready', async (req, res) => {
  const checks = {};

  // Chequeo de base de datos vía circuit breaker
  try {
    await dbBreaker.fire('SELECT 1');
    checks.database = {
      status: dbBreaker.opened ? 'degraded' : 'healthy',
      circuitState: dbBreaker.status,
    };
  } catch {
    checks.database = {
      status: 'unhealthy',
      circuitState: dbBreaker.status,
    };
  }

  const allHealthy = Object.values(checks)
    .every(c => c.status === 'healthy');

  res.status(allHealthy ? 200 : 503).json({
    status: allHealthy ? 'ready' : 'degraded',
    checks,
  });
});

Explicación

Es el kubelet —no un controlador central— quien ejecuta cada probe contra cada contenedor. Cada tipo de probe responde una pregunta distinta y dispara una acción distinta cuando falla:

ProbePregunta que respondeQué hace Kubernetes cuando falla
startupProbe¿El contenedor terminó de arrancar?Sigue sondeando hasta agotar failureThreshold × periodSeconds y luego reinicia el contenedor. Mientras corre, liveness y readiness quedan en pausa.
readinessProbe¿El contenedor puede servir tráfico ahora?Saca el pod de los endpoints del Service. El proceso sigue corriendo — no hay reinicio.
livenessProbe¿El proceso está vivo o bloqueado?Reinicia el contenedor tras failureThreshold fallos consecutivos.
flowchart diagram: Inicia el contenedor

De este modelo se deducen dos consecuencias. Primero, un probe de readiness que falla es barato: el pod simplemente deja de recibir requests hasta recuperarse, así que es el lugar correcto para los chequeos de dependencias. Segundo, un probe de liveness que falla es caro: el contenedor se mata y reinicia, perdiendo trabajo en curso y cachés calientes. Esa asimetría es la razón por la que liveness solo debería probar si el proceso está atascado, nunca si una base de datos o una API externa responde.

El startupProbe existe porque initialDelaySeconds obliga a un mal compromiso: con un valor bajo los arranques lentos se matan a mitad de la inicialización, y con uno alto los contenedores realmente muertos tardan minutos en reiniciarse. El startup probe reemplaza esa apuesta por un bucle — failureThreshold: 30 con periodSeconds: 5 le da a la app 150 segundos para arrancar, y después los probes normales toman el control a toda velocidad.

Los health checks también tienen un presupuesto de coste. Un probe de readiness que consulta tres dependencias cada 5 segundos genera carga real sobre tu base de datos y Redis: con 10 réplicas son 6 conexiones por segundo por dependencia. Mantén los chequeos baratos (SELECT 1, PING), acótalos con timeouts cortos y no dejes que una dependencia lenta consuma todo el presupuesto del probe — para eso sirve la variante con circuit breaker de arriba.

Variantes

Health check con grafo de dependencias

# Python — health check con grafo de dependencias
class HealthChecker:
    def __init__(self):
        self.dependencies = {}

    def register(self, name, check_fn, dependencies=None):
        self.dependencies[name] = {
            "check": check_fn,
            "deps": dependencies or [],
        }

    async def check_all(self):
        results = {}
        for name, config in self.dependencies.items():
            # Chequea primero las dependencias
            dep_results = {dep: results.get(dep, False) for dep in config["deps"]}
            if not all(dep_results.values()):
                results[name] = {
                    "status": "skipped",
                    "reason": f"Dependencies unhealthy: {[d for d, v in dep_results.items() if not v]}",
                }
                continue

            try:
                healthy = await config["check"]()
                results[name] = {"status": "healthy" if healthy else "unhealthy"}
            except Exception as e:
                results[name] = {"status": "unhealthy", "error": str(e)}

        return results

# Uso
checker = HealthChecker()
checker.register("database", check_database)
checker.register("redis", check_redis)
checker.register("cache", check_cache, dependencies=["redis"])
checker.register("api", check_api, dependencies=["database", "cache"])

Health check por TCP

# Kubernetes — probe de socket TCP para servicios sin HTTP
apiVersion: v1
kind: Pod
spec:
  containers:
    - name: tcp-service
      image: tcp-service:1.0
      livenessProbe:
        tcpSocket:
          port: 5432
        initialDelaySeconds: 15
        periodSeconds: 10
      readinessProbe:
        tcpSocket:
          port: 5432
        initialDelaySeconds: 5
        periodSeconds: 5

Health check por comando

Para procesos worker sin servidor HTTP, usa un probe exec que ejecuta un script dentro del contenedor. La misma idea alimenta la instrucción HEALTHCHECK de Docker — mira Configuración de Health Check en Docker para la variante nativa de Docker.

# Python — probe por comando para procesos worker
# probe_script.py
import sys
import psycopg2

def check_worker_health():
    try:
        conn = psycopg2.connect("postgresql://localhost/myapp")
        cur = conn.cursor()
        # Sondeo de conectividad barato — si la query corre, el worker está bien
        cur.execute("SELECT COUNT(*) FROM jobs WHERE status = 'processing'")
        cur.fetchone()
        cur.close()
        conn.close()
        return True
    except Exception:
        return False

if __name__ == "__main__":
    if check_worker_health():
        print("healthy")
        sys.exit(0)
    else:
        print("unhealthy")
        sys.exit(1)
# Kubernetes — probe por comando
spec:
  containers:
    - name: worker
      image: worker:1.0
      livenessProbe:
        exec:
          command:
            - python
            - /app/probe_script.py
        initialDelaySeconds: 30
        periodSeconds: 60

Mejores Prácticas

  • Para una guía más profunda, mira el Patrón de Monitoreo de Endpoint de Salud.
  • Separa liveness de readiness — liveness comprueba si el proceso está vivo, readiness si puede servir tráfico
  • Mantén liveness simple — no chequees dependencias en liveness, solo el propio proceso
  • Usa startup probes para inicializaciones lentas — evita reinicios prematuros durante el arranque
  • Configura timeouts razonables — 2-3 segundos para probes HTTP, más para el arranque
  • Devuelve 503 cuando no esté lista — los orquestadores miran el código HTTP, no el cuerpo de la respuesta
  • Incluye el detalle de dependencias en readiness — ayuda a depurar por qué un servicio no está listo
  • Implementa el apagado elegante — devuelve 503 en readiness durante el apagado para cortar el tráfico
  • No abuses de la frecuencia — sondear cada segundo es excesivo; cada 5-10 segundos es suficiente
  • Usa circuit breakers en los health checks — que una dependencia lenta no haga expirar tu probe

Errores Comunes

  • Mismo endpoint para liveness y readiness: si readiness falla (base de datos caída), liveness también falla y provoca reinicios innecesarios. Usa endpoints separados.
  • Chequear dependencias en liveness: liveness solo debería verificar que el proceso corre. Los chequeos de dependencias pertenecen a readiness.
  • Sin startup probe: las aplicaciones de arranque lento se matan antes de terminar de inicializar. Usa startup probes con un failureThreshold alto.
  • Probes demasiado agresivos: sondear cada segundo con timeout de 1 segundo produce falsos fallos bajo carga. Usa intervalos de 5-10 segundos.
  • No gestionar el apagado elegante: durante el apagado el proceso está vivo pero no puede servir tráfico. Readiness debería devolver 503 de inmediato.

Ver También

Preguntas frecuentes

¿Cuál es la diferencia entre los probes de liveness y readiness?

Liveness responde "¿el proceso está atascado?" — si falla, Kubernetes reinicia el contenedor, que es caro. Readiness responde "¿puede atender requests ahora mismo?" — si falla, el pod solo sale de los endpoints del Service y sigue corriendo. Un síntoma clásico de confundirlos: tu base de datos parpadea, readiness falla, y si liveness comparte el mismo endpoint todas las réplicas se reinician a la vez en lugar de esperar a que pase la caída.

¿Cuándo conviene usar un startup probe?

Siempre que el arranque varíe o supere los ~30 segundos — cargar modelos de ML, calentar cachés, abrir muchas conexiones. Sin él te ves forzado a inflar initialDelaySeconds en el probe de liveness, lo que retrasa la detección de contenedores realmente muertos. Un startup probe con failureThreshold: 30 y periodSeconds: 5 concede 150 segundos de arranque y después le cede el control a chequeos de liveness rápidos.

¿Los health checks deberían chequear todas las dependencias?

Solo las necesarias para servir requests, y solo en readiness. Si tu app puede degradar con elegancia sin Redis (fallback cache-aside), Redis tampoco debería condicionar readiness. Liveness no debería chequear nada externo — una base de datos caída nunca significa que tu proceso necesite un reinicio.

¿Qué código HTTP deberían devolver los health checks?

200 cuando está sano o listo, 503 cuando no. El kubelet solo mira el código de estado — el cuerpo JSON es para humanos y dashboards. Cualquier código en el rango 200-399 cuenta como éxito, pero quédate con 200/503 para que dashboards y humanos lean la misma señal que el orquestador.

¿Cómo gestiono el apagado elegante?

Al recibir SIGTERM, pasa readiness a 503 de inmediato para que Kubernetes deje de enrutar tráfico nuevo — pero sigue sirviendo, porque la baja del endpoint es asíncrona y tarda unos segundos en propagarse por kube-proxy. Luego drena las requests en curso y sal antes de que expire terminationGracePeriodSeconds (30 por defecto) y llegue el SIGKILL.