Estaba levantando el inventario de una aplicación PHP con quince años en producción: un script recorría sus pantallas una a una para saber qué hay dentro. Trabajo de auditoría, sin escribir nada. Solo mirar.
A media tarde noté que los ventiladores del portátil llevaban un buen rato a tope. Miré el contenedor: ocho procesos de Apache con horas de CPU acumulada cada uno y el contenedor al 794% — ocho núcleos al máximo. Según los sensores, la máquina llevaba tres horas y media entre 94 y 96 grados.
Ninguno de esos ocho procesos tenía a nadie al otro lado. Mi barrido había cerrado esas conexiones hacía horas y había seguido a lo suyo tan tranquilo.
- 8
- workers de Apache girando a la vez
- 3 h 30
- al 100% de CPU sin que nadie lo pidiera
- 96 °C
- temperatura de la máquina durante todo ese rato
- 3
- protecciones puestas que no sirvieron de nada
Tres protecciones, y ninguna funcionó
La primera era el timeout de mi propio barrido. Veinte segundos por página: si no cargaba, cortaba y seguía. Funcionó exactamente como estaba escrito, y ahí está el problema. Un timeout de cliente corta la conexión, no el trabajo. Al otro lado nadie se entera de que ya no le espera nadie.
La segunda era la detección de desconexión de PHP. Existe, y en general funciona: cuando el cliente se va, el script aborta. Pero PHP solo descubre que el cliente se ha ido cuando intenta escribir en la salida. Un bucle que no imprime nada nunca llega a ese punto, así que nunca se entera. Apache tampoco lo mata: las conexiones se quedaban en CLOSE_WAIT y el worker seguía girando con el socket medio cerrado en la mano.
La tercera era max_execution_time, la red de seguridad de PHP de toda la vida. La puse en 60 segundos, recargué una de las páginas malas y la dejé correr con un cronómetro delante. A los 120 segundos seguía girando.
El motivo está documentado y es fácil de olvidar: en Unix ese límite no cuenta el tiempo que el proceso pasa fuera de PHP — llamadas al sistema, operaciones de E/S, consultas a la base de datos — y el temporizador solo puede saltar cuando el motor recupera el control entre instrucciones. Un bucle que se pasa la vida dentro de una llamada nativa no le da nunca esa oportunidad. El límite existe, está configurado, y no se aplica donde hace falta.
Las tres son lo mismo visto desde tres sitios: límites que se aplican donde no duele. A un proceso que no colabora solo se le puede parar desde fuera del proceso.
La columna que casi me la cuela
Mi primer reflejo fue abrir ps y ordenar por %CPU. Es la columna equivocada. El %CPU de ps es la media sobre toda la vida del proceso, y un worker de Apache vive mucho y atiende muchas peticiones: uno que ha estado tres horas dormido y ahora lleva un minuto en bucle sale con un porcentaje ridículo. Justo el caso que quieres cazar pronto es el que peor se ve.
La columna que no miente es TIME: tiempo de CPU acumulado. En una aplicación sana ahí hay segundos. Aquí había horas.
Lo que sí funcionó: matarlos desde fuera
Veinticinco líneas de bash. Cada quince segundos le pregunta al contenedor qué procesos tiene, se queda con los apache2 que llevan más de 60 segundos de CPU acumulada, y los mata.
Y lo que de verdad importa no es el script, es dónde se engancha. El barrido lo arranca él solo antes de la primera visita, y se niega a empezar si no lo encuentra:
Una protección que hay que acordarse de encender no es una protección. Es una nota mental, y las notas mentales se pierden a las siete de la tarde de un lunes.
Lo que sigue mal, y lo digo yo antes de que lo encuentres tú
El bucle sigue ahí. El watchdog es una venda del lado de quien mira, no un arreglo del lado del que sangra. Hay cuatro páginas de esa aplicación que entran en bucle o se quedan bloqueadas esperando a una máquina de la red interna; están en la lista de exclusiones del barrido y en el informe del cliente. Arreglarlas es otro trabajo, con su propio presupuesto y su propia decisión.
La primera versión del watchdog se quedaba huérfana. El barrido le mandaba SIGTERM al terminar, pero el proceso hijo sobrevivía. Inofensivo —lo peor que hace es vigilar de más— pero es justo el cabo suelto que alguien encuentra tres meses después y no sabe qué es ni si puede matarlo.
Y lo más incómodo: no me avisó nada. No saltó una alerta, ni un umbral, ni un log. Me enteré porque apoyé la mano en el portátil y quemaba. Tres horas y media es, exactamente, lo que tardé en tocar la carcasa.
Lo que me llevo
Cuando entro en un sistema antiguo, la pregunta que traigo aprendida ya no es solo «¿qué hace esto?». Es también «¿de qué manera concreta me va a morder mientras lo miro?». Un inventario, un barrido o un script de diagnóstico son código en producción durante el rato que corren, y el sistema al que miras no tiene por qué comportarse bien.
Nada de esto sale en el informe que entrego. Pero es la mitad del trabajo de tocar software heredado, y es la mitad que no se ve.
El cliente está bajo acuerdo de confidencialidad: no aparece su nombre, ni su código, ni nada de su sistema más allá de lo que le hizo a mi máquina. El watchdog y el barrido son herramienta mía, y por eso están enteros. Stack: PHP sobre Apache en Docker, barrido en Node con Playwright.