El servidor que se reiniciaba cada cinco horas
Los peores bugs no se caen a gritos. Durante varios releases, el servidor de
SendDock se moría en silencio — sin panic, sin stack trace, solo un contenedor
que se reemplazaba una y otra vez mientras los usuarios veían 502
intermitentes. Cada vez que lo arreglaba, volvía disfrazado de otra cosa. Este es
el recorrido completo, porque cada disfraz me enseñó algo sobre los health
checks.
Disfraz #1: el health check que no se alcanzaba a sí mismo
En algunos hosts de VPS y Swarm con net.ipv6.bindv6only=1, el listener por
defecto :8080 de Go quedaba escuchando solo en IPv6. El HEALTHCHECK del
contenedor apuntaba a 127.0.0.1:8080 — IPv4. Cada sonda fallaba. Tras tres
reintentos el orquestador marcaba el contenedor como unhealthy, lo mataba,
arrancaba uno nuevo, y el nuevo hacía exactamente lo mismo. Un loop de
crash-replace de 60–90 segundos, sin panic y sin logs de error — solo Bad Gateway intermitente desde el proxy mientras un servicio "sano" rotaba en
silencio por debajo.
El arreglo fueron dos líneas: enlazar 0.0.0.0:8080 explícitamente y apuntar el
health check a localhost (que resuelve tanto 127.0.0.1 como ::1). La
lección de fondo era más grande: un health check que no puede alcanzar aquello
que revisa es peor que no tener health check — destruye activamente un servidor
que funciona.
Disfraz #2: el health check que mentía
Después, /health devolvía {"status":"ok"} sin tocar nunca la base de datos.
Así que cuando Postgres quedaba zombie — una acumulación de transacciones inactivas (idle-in-transaction), un
worker matado por OOM, una partición de red — el pool de conexiones de Go
mantenía sus viejos sockets TCP abiertos y nunca se enteraba. Docker seguía
enrutando tráfico a un contenedor que reportaba salud perfecta; las queries
detrás se colgaban; el proxy devolvía 502. Reiniciar la app no ayudaba, porque
el lado roto era Postgres.
El arreglo obvio — hacer ping a la base de datos dentro de /health — fue justo
donde planté el siguiente bug.
Disfraz #3: el arreglo que se volvió el bug
Hice que /health corriera un PingContext síncrono con un timeout de 2
segundos. Razonable — hasta que Postgres tardaba más de dos segundos por un
motivo perfectamente transitorio: una pausa de GC, un disco lento, un vecino
ruidoso en un VPS pequeño. Un pico de lentitud puntual, cinco sondas fallidas en tres minutos,
contenedor muerto. Había convertido un tropiezo momentáneo en un crash.
El arreglo de verdad fue dejar de hacer trabajo dentro del health check. Ahora
una goroutine en segundo plano hace ping a Postgres cada 10s y guarda de forma
atómica el timestamp del último éxito. /health solo lee ese atómico y devuelve
503 únicamente si ningún ping tuvo éxito en los últimos 60 segundos. Los picos
sueltos se absorben; una caída sostenida sí sale a la superficie. Un health
check debe observar, no ejecutar.
Disfraz #4: cada cinco horas, en punto
Este es el que la gente recuerda. Después de todo lo anterior, el contenedor se acomodó en un ritmo nuevo — se reiniciaba más o menos cada cinco horas. Nada de aleatorio. Como un reloj.
El culpable era el rate limiter, y estaba ahogando al health check. Mi
Increment de Redis hacía un INCR seguido incondicionalmente de un EXPIRE,
que reseteaba el TTL de un minuto de la llave en cada llamada. El health check
de Docker pegaba a /health cada 30 segundos, así que la llave del contador
nunca alcanzaba a expirar. En vez de contar ~2 requests por minuto y resetearse,
se acumulaba para siempre. Alrededor de los 600 hits — unas cinco horas a dos por
minuto — cruzaba el umbral de 600 req/min, y /health empezaba a devolver 429.
wget -q … || exit 1 convertía eso en una sonda fallida, y el orquestador hacía
lo que hacen los orquestadores.
Dos arreglos, ambos dignos de enunciarse como reglas:
- El contador ahora usa un script Lua atómico que fija el TTL solo cuando la
llave se crea por primera vez (
count == 1). Eso es lo que en realidad es un contador de ventana fija.INCRmás unEXPIREincondicional es un reset deslizante que, con tráfico constante, nunca expira. /healthya no pasa por el rate limiter, el CORS ni el middleware de tamaño de body. Está montado en un mux (router) raíz aparte que evita todo el pipeline. Tu health check nunca debe estar sujeto a los throttles que construiste para los clientes — no es un cliente.
Lo que me enseñó toda la saga
Cuatro bugs, un mismo tema: el health check terminaba siendo lo que mataba al servidor. No es coincidencia. Un health check está en el punto más peligroso del sistema — tiene la autoridad de terminar un proceso, y suele correr con el menor escrutinio. Cuando está mal no falla de forma segura; falla ejecutando un servidor sano.
Así que estas son las reglas que hoy sostengo, cada una pagada con su propio loop de reinicios:
- Debe saltarse todo el middleware expuesto al cliente — sin rate limits, sin CORS, sin auth.
- Debe observar el estado de forma barata, nunca hacer trabajo costoso en el camino del request.
- Debe revisar de verdad la dependencia que importa — pero tolerar los picos transitorios.
- Debe ser alcanzable en la dirección exacta que usa la sonda.
Ninguna de estas es ingeniosa. Las cuatro me costaron un incidente en producción. Los bugs silenciosos son los caros.
Notas relacionadas
El editor que se comía mis <
Escribías un < solo en el editor de código y se convertía en silencio en <. El culpable era un componente oculto, dos pestañas más allá, haciendo su trabajo al pie de la letra.
Que el envío sobreviva a un reinicio
La primera versión mandaba toda la campaña desde una sola goroutine — y lo perdía todo si el proceso parpadeaba. Reconstruirla como una cola durable por destinatario.