Seleccione su idioma

Una incidencia real en OKD: procesos bloqueados, agentes huérfanos, cgroups, pipelines que no deben reanudarse y un problema inesperado al retirar las protecciones de recuperación.

Un viernes cualquiera

Todo empezó con un worker de un clúster OKD que dejó de responder correctamente.

El clúster, basado en OKD 4.22, tenía tres nodos de control y tres workers. Uno de estos últimos, con 16 CPU y 32 GiB de memoria, había acumulado una cantidad extraordinaria de procesos bloqueados. El load average se acercaba a 900, las operaciones de E/S se habían disparado y Kubernetes había terminado marcándolo como NotReady.

En ese worker convivían varios servicios persistentes: repositorios Git, bases de datos, un gestor de artefactos, monitorización y Jenkins, entre otros.

La prioridad no era simplemente reiniciar la máquina. Había que entender qué estaba provocando el bloqueo y recuperar el nodo sin comprometer los volúmenes persistentes.

La investigación acabó revelando varios problemas relacionados, aunque técnicamente distintos:

  • Agentes efímeros de Jenkins que habían quedado atascados durante su terminación.
  • Una tormenta de procesos y operaciones de E/S que impedía al worker funcionar normalmente.
  • Pipelines interrumpidos que Jenkins intentaría recuperar automáticamente.
  • Una configuración de concurrencia demasiado permisiva para la capacidad disponible.
  • Un bloqueo entre pipelines provocado al reducir excesivamente esa concurrencia.
  • Un archivo residual de inicialización que, durante la limpieza final, impidió arrancar Jenkins.

La parte interesante no fue un único error, sino cómo cada decisión de recuperación obligó a comprender un poco mejor el comportamiento conjunto de Kubernetes y Jenkins.

1. Un nodo con casi 900 procesos bloqueados

El síntoma inicial era un worker completamente saturado.

Las comprobaciones habituales permitían apreciar la magnitud del problema:

oc get nodes
oc adm top nodes

ssh core@worker-a '
    uptime
    free -h
    vmstat 1 3
'

El load average había llegado a valores cercanos a 900. Había cientos de tareas bloqueadas, una actividad de disco anormalmente elevada y muy poca memoria disponible.

Es importante distinguir el load average del porcentaje de CPU utilizado. En Linux, el primero también incluye tareas que están esperando en determinadas condiciones de E/S no interrumpible. Un sistema puede presentar un load average gigantesco sin que ello signifique que sus CPU estén ejecutando código útil continuamente.

En este caso, la observación de los procesos bloqueados y la presión de E/S apuntaba a un problema más profundo que una simple compilación consumiendo demasiada CPU.

La primera medida fue impedir que Kubernetes asignara nuevas cargas al nodo:

oc adm cordon worker-a

No resolvía la situación existente, pero evitaba añadir trabajo mientras se investigaba.

Agentes de Jenkins que no terminaban

Al revisar las cargas del worker aparecieron varios pods de Jenkins en estado Terminating.

Eran agentes efímeros utilizados para ejecutar pipelines de compilación. Algunos llevaban demasiado tiempo intentando desaparecer y sus procesos seguían asociados a cgroups del nodo.

Un detalle importante era que varios agentes estaban clasificados como BestEffort: no disponían de solicitudes ni límites de recursos definidos.

En un clúster compartido, esto es especialmente delicado. Un agente de compilación puede consumir cantidades importantes de memoria, CPU y E/S sin una reserva explícita que ayude al scheduler a planificarlo, ni un límite de memoria que acote su consumo.

La investigación descendió entonces desde los objetos Kubernetes hasta los cgroups del sistema operativo.

Los procesos afectados no estaban bajo el árbol burstable, sino en el correspondiente a besteffort. Eso explicó por qué una primera búsqueda no había localizado algunos de los agentes problemáticos.

En el nodo se identificó el cgroup concreto de uno de los pods que seguía atascado. Se utilizó su interfaz cgroup.kill para terminar los procesos pertenecientes a ese grupo.

La respuesta fue inmediata.

Los procesos bloqueados descendieron desde varios cientos hasta una cifra de un solo dígito. La presión de E/S se normalizó, el nodo recuperó capacidad de respuesta y finalmente volvió al estado Ready.

No fue necesario reiniciar el worker.

Conviene ser preciso: esta intervención confirmó que los procesos de aquel cgroup estaban implicados directamente en el bloqueo. No demuestra, por sí sola, cuál fue el fallo original que desencadenó toda la actividad de E/S.

Tampoco convierte cgroup.kill en una solución genérica para pods atascados. Es una operación de bajo nivel que requiere identificar de forma inequívoca el cgroup y comprender las consecuencias de terminar sus procesos.

2. Recuperar el nodo no era suficiente

El worker había vuelto a funcionar, pero todavía quedaba Jenkins.

El controlador mantenía información sobre numerosas ejecuciones interrumpidas durante la incidencia. Si se arrancaba normalmente, existía el riesgo de que intentara reanudar un conjunto de pipelines antiguos y volviera a crear agentes Kubernetes.

Para evitarlo, se detuvieron temporalmente las instancias de Jenkins escalando sus StatefulSets a cero réplicas, conservando sus PVC.

La estrategia de recuperación tenía dos objetivos:

  1. Poder iniciar el controlador para inspeccionar su estado y configuración.
  2. Evitar que intentara ejecutar inmediatamente los trabajos pendientes.

Quiet Down desde el arranque

Jenkins dispone del modo Quiet Down, que impide comenzar nuevas ejecuciones mientras permite que las existentes finalicen.

Para garantizar que el controlador entrara en ese estado desde el inicio, se montó temporalmente un script Groovy en su directorio de inicialización:

import jenkins.model.Jenkins

Jenkins.get().doQuietDown()

println("RECOVERY: Jenkins started in Quiet Down mode")

La idea era que Jenkins pudiera arrancar y cargar su configuración sin desencadenar nuevas compilaciones.

Sin embargo, Quiet Down no resuelve por sí solo el problema de los pipelines interrumpidos que el motor intenta restaurar.

Impedir la reanudación de ejecuciones antiguas

Se utilizó temporalmente la siguiente propiedad JVM del componente Pipeline:

-Dorg.jenkinsci.plugins.workflow.job.WorkflowRun.maxResumptionAge=1s

Con un umbral tan reducido, Jenkins rechazó la reanudación de las ejecuciones antiguas.

Los registros confirmaron el comportamiento:

Refusing to resume running build ...
because it is too old and might be corrupt

Esta distinción es importante: no se estaban dejando los pipelines en pausa; se estaba impidiendo su recuperación.

Era una medida deliberada para una situación de emergencia, no una configuración apropiada para el funcionamiento habitual de Jenkins.

El controlador terminó arrancando correctamente, en Quiet Down, sin ejecuciones activas.

Por primera vez desde el incidente, teníamos un entorno estable sobre el que trabajar.

3. El problema de los agentes sin límites de recursos

La siguiente pregunta era evidente: ¿cómo evitar que una nueva ráfaga de compilaciones volviera a saturar el worker?

La configuración de Kubernetes Cloud en Jenkins permitía hasta diez agentes simultáneos.

Además, los agentes no tenían una política homogénea de recursos. Jenkins utilizaba una plantilla global para el contenedor de conexión (jnlp), mientras que otros contenedores se definían dinámicamente mediante una biblioteca compartida de pipelines.

Esta separación tenía consecuencias importantes.

La plantilla global no era suficiente

En la interfaz de Jenkins se configuraron los recursos del contenedor jnlp:

Recurso Request Limit
CPU 100m 500m
Memoria 256Mi 1Gi

Pero modificar esa plantilla no garantizaba que los contenedores de compilación estuvieran limitados.

Los pipelines utilizaban distintas versiones de Java y herramientas específicas. Los contenedores Maven, por ejemplo, se construían desde una función Groovy de la biblioteca compartida.

Había que modificar la fuente real de las definiciones YAML.

Límites en la biblioteca compartida

Para los contenedores Maven, tanto Java 8 como Java 17, se estableció:

resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "2000m"
    memory: "4Gi"

Para el contenedor de análisis estático:

resources:
  requests:
    cpu: "250m"
    memory: "512Mi"
  limits:
    cpu: "1000m"
    memory: "2Gi"

De esta forma, un agente típico con Maven y JNLP tenía los siguientes recursos agregados:

Recurso Request total Limit total
CPU 600m 2500m
Memoria 1280Mi 5Gi

La distinción entre requests y limits resulta esencial.

Los requests intervienen en las decisiones de planificación de Kubernetes. Los limits acotan el consumo permitido, aunque su comportamiento concreto depende del recurso: superar un límite de memoria puede provocar un OOMKill; la CPU normalmente se limita mediante throttling.

En nuestro caso, establecer límites de memoria era especialmente importante para evitar que una compilación pudiera consumir de forma descontrolada la memoria del nodo.

La modificación se publicó en el repositorio de la biblioteca compartida y se verificó posteriormente sobre los pods reales.

Kubernetes mostraba exactamente los recursos esperados para los contenedores Maven y JNLP.

4. El límite de concurrencia de uno parecía seguro… hasta que dejó de serlo

Para la primera prueba se redujo el límite global de Kubernetes Cloud de diez agentes a uno.

La intención era razonable: comprobar el funcionamiento completo sin permitir que Jenkins creara simultáneamente múltiples pods de compilación.

Se seleccionó una ejecución controlada de un proyecto Java que utilizaba distintas etapas con Java 8 y Java 17.

La compilación comenzó a avanzar.

Los pods aparecían y desaparecían conforme Jenkins cambiaba de etapa, algunos con estados finales que inicialmente resultaban preocupantes.

Sin embargo, el pipeline continuaba y los nuevos límites estaban siendo aplicados correctamente.

El problema real apareció más adelante.

Un pipeline que espera a otro pipeline

En una de sus etapas, el pipeline principal ejecutaba un segundo job para realizar una operación de despliegue:

build job: 'deploy-application', wait: true

El segundo job necesitaba también un agente Kubernetes.

Los registros mostraban:

[Pipeline] podTemplate
[Pipeline] node
Still waiting to schedule task
All nodes of label 'deploy-agent-...' are offline

El pipeline principal seguía ocupando un agente mientras esperaba al segundo job. Pero Kubernetes Cloud tenía permitido un único agente simultáneo.

Habíamos creado una situación de espera circular:

Pipeline principal
    |
    | ocupa el único agente permitido
    |
    +-- lanza pipeline de despliegue
            |
            | necesita otro agente
            |
            +-- bloqueado por el límite global

No era un fallo de Kubernetes. Era una consecuencia de cómo se habían combinado la estructura del pipeline y la política de concurrencia.

De uno a dos agentes

La solución fue aumentar el límite global a dos.

El segundo agente se creó inmediatamente y el despliegue pudo continuar.

Los dos pods quedaron ejecutándose simultáneamente, con sus límites de memoria y CPU correctamente aplicados.

El resultado final fue satisfactorio:

Ejecución Resultado Duración
Pipeline principal Java SUCCESS 11 minutos
Pipeline de despliegue SUCCESS 6 min 49 s

La prueba confirmó que los nuevos límites permitían ejecutar el proceso completo y que, para esta estructura concreta de pipelines, una concurrencia de uno no era viable.

El mínimo de concurrencia no depende únicamente de cuántas compilaciones queremos permitir, sino también de cuántos agentes simultáneos puede necesitar una misma ejecución lógica.

5. ¿Por qué Kubernetes elegía siempre el mismo worker?

Durante las pruebas surgió otra cuestión.

Todos los agentes se estaban ejecutando en el mismo worker donde residía el controlador de Jenkins.

Una primera hipótesis fue que los volúmenes utilizados por los agentes los estuvieran fijando a ese nodo.

Los pods montaban PVC compartidos para el repositorio Maven y otros datos de compilación.

Sin embargo, la comprobación mostró que ambos volúmenes tenían modo de acceso ReadWriteMany (RWX).

Por tanto, no existía la restricción típica de un volumen ReadWriteOnce que impidiera utilizarlo simultáneamente desde otros nodos.

Tampoco había en la plantilla un nodeSelector que fijara los agentes al worker del controlador. Únicamente se exigía Linux.

La distribución de memoria observada durante la prueba era aproximadamente:

Worker Memoria utilizada
Worker A 23 %
Worker B 82 %
Worker C 76 %

Era coherente que el scheduler prefiriera el worker menos cargado, aunque hay que recordar que Kubernetes planifica principalmente en función de los recursos solicitados y otras restricciones, no simplemente del porcentaje de memoria utilizada que muestra oc adm top.

No quedó demostrada una afinidad automática entre Jenkins y sus agentes.

La distribución de estos últimos quedó como una mejora pendiente: estudiar reglas de afinidad, dispersión o políticas de planificación que eviten una concentración excesiva sin empeorar innecesariamente la utilización de los recursos.

6. El problema inesperado al retirar la configuración temporal

Con los dos pipelines finalizados correctamente y el worker estable, llegaba la última parte: retirar las protecciones de recuperación.

Había que eliminar del StatefulSet el montaje del script quiet-start.groovy y la propiedad JVM que impedía reanudar pipelines antiguos.

Se preparó una actualización controlada del StatefulSet utilizando la partición de su estrategia RollingUpdate, para separar la modificación de la plantilla del momento del reinicio.

Después de retirar ambas opciones, se reinició Jenkins.

Y Jenkins dejó de arrancar.

CrashLoopBackOff después de una limpieza aparentemente correcta

El pod comenzó a entrar en CrashLoopBackOff.

Las sondas de arranque devolvían errores HTTP 503 y, en algunos intentos, conexiones rechazadas.

Los primeros fragmentos del registro mostraban excepciones de componentes internos y del sistema de plugins durante el apagado.

Pero esas excepciones no eran el origen.

Al recuperar el registro completo de un intento fallido apareció el error relevante:

Failed GroovyInitScript.init

/var/jenkins_home/init.groovy.d/quiet-start.groovy
can not be read

Jenkins seguía detectando el archivo de inicialización, a pesar de que ya no estaba montado desde el ConfigMap.

Al inspeccionar el volumen persistente encontramos:

-rwxrw---- root jenkins 0 quiet-start.groovy

Era un archivo regular vacío, de cero bytes, que había quedado en el directorio init.groovy.d.

El script original se había introducido mediante un montaje de ConfigMap con subPath. Al retirar el montaje, quedó un archivo residual en el almacenamiento persistente.

El registro indicaba que Jenkins no podía leerlo. Aunque el archivo tenía una combinación particular de propietario y permisos, esos datos por sí solos no demostraban el motivo exacto del fallo de lectura. Lo que sí se verificó es que apartarlo resolvía el problema de arranque.

Recuperación desde un pod de mantenimiento

No tenía sentido reinstalar Jenkins ni modificar sus plugins.

El problema estaba localizado en un único archivo del PVC.

Se escaló el StatefulSet a cero y se creó un pod temporal que montaba el mismo volumen persistente, pero sin arrancar Jenkins.

Desde ese pod se inspeccionó el archivo y se movió fuera de init.groovy.d, conservándolo con otro nombre.

A continuación, se eliminó el pod de mantenimiento y se volvió a escalar Jenkins a una réplica.

Esta vez arrancó correctamente.

El controlador quedó en 1/1 Running, sin reinicios, con Quiet Down desactivado y con el límite de dos agentes conservado.

Fue un buen recordatorio de que retirar un montaje de Kubernetes no implica necesariamente eliminar los archivos que hayan quedado en el sistema de archivos del volumen.

7. La última comprobación: Helm y la configuración persistente

La recuperación funcional estaba completada, pero quedaba una última cuestión: ¿sobreviviría la nueva concurrencia a un despliegue del operador?

Se ejecutó el despliegue habitual mediante Helm y se volvió a consultar la configuración activa de Kubernetes Cloud.

El resultado fue:

Kubernetes Cloud: Kubernetes
Concurrency Limit: 2
Quiet Down: false
Queue: 0

La configuración se había conservado.

Sin embargo, al revisar los valores del chart apareció un detalle: el límite de dos agentes no estaba declarado en el repositorio de despliegue.

El chart disponía de un valor predeterminado de diez agentes, pero la configuración automática de Jenkins Configuration as Code estaba deshabilitada.

Por tanto, el nuevo valor se conservaba porque Jenkins almacenaba su configuración en su volumen persistente.

Esto es suficiente para un despliegue sobre la instalación existente, pero no garantiza que una instalación desde cero produzca exactamente el mismo resultado.

Queda pendiente trasladar esa configuración a una fuente declarativa, sin sobrescribir accidentalmente el resto de las opciones del Kubernetes Cloud que actualmente mantiene Jenkins.

No siempre basta con añadir un parámetro al values.yaml. Antes hay que comprobar si el chart realmente aplica ese parámetro a la configuración efectiva del servicio.

8. Qué aprendimos

De esta incidencia me quedo con varias conclusiones técnicas.

Los pods no son toda la historia. Cuando Kubernetes muestra agentes en Terminating, los procesos asociados pueden seguir vivos en el sistema operativo. Comprender los cgroups permite investigar situaciones que no se explican únicamente desde la API de Kubernetes.

Los límites de recursos deben definirse donde realmente nacen los contenedores. En Jenkins, una plantilla global puede convivir con plantillas dinámicas generadas mediante bibliotecas compartidas. Configurar una de ellas no implica haber configurado todas.

Una concurrencia menor no siempre significa una configuración mejor. Un pipeline que lanza otro job y espera su resultado puede necesitar varios agentes simultáneos. Reducir la concurrencia sin revisar esas dependencias puede introducir bloqueos que antes no existían.

La recuperación de Jenkins requiere distinguir entre ejecuciones nuevas y ejecuciones antiguas. Quiet Down evita empezar trabajos, pero no equivale a impedir la restauración de pipelines interrumpidos. Son mecanismos diferentes y tienen consecuencias distintas.

Los errores de apagado no siempre son la causa del problema. Durante el CrashLoopBackOff, varias excepciones aparecían al cerrar Jenkins. La causa original estaba unos segundos antes, en la ejecución de un script Groovy de inicialización.

La configuración que sobrevive a un despliegue no es necesariamente reproducible. Un PVC puede conservar perfectamente un cambio realizado desde la interfaz, aunque ese cambio no esté declarado en Helm.

Resultado

El worker se recuperó sin reiniciar el sistema operativo. Jenkins volvió a funcionar, se validó una compilación Java completa con su despliegue asociado y los agentes pasaron a tener límites explícitos de CPU y memoria.

La concurrencia global quedó reducida de diez a dos agentes, un valor que permitió completar el flujo probado sin volver a saturar el worker.

La limpieza de las protecciones temporales también terminó correctamente, después de resolver el problema del archivo residual de inicialización.

Quedan mejoras por hacer, especialmente la distribución de agentes y la configuración declarativa del Kubernetes Cloud.

Pero el objetivo principal se cumplió: recuperar el servicio y reducir el riesgo de que una nueva ejecución de Jenkins volviera a comprometer el nodo.

La lección más útil quizá sea que, en sistemas donde se combinan Kubernetes, almacenamiento persistente y pipelines dinámicos, la recuperación no termina cuando todos los pods aparecen en verde. Termina cuando entendemos qué los llevó a fallar, qué mecanismos hemos modificado para recuperarlos y qué ocurrirá la próxima vez que el sistema tenga que arrancar desde cero.