Al cerrar la lección anterior teníamos las tres señales de la observabilidad: las sondas dicen si un componente está sano, las métricas dicen cuánto y cómo funciona, y los logs dicen exactamente qué pasó. Lo que aún no tenemos es un método para usarlas juntas.

Porque cuando a las 03:14 suena el teléfono y la tienda web devuelve 502, saber usar Grafana y Kibana no basta. Bajo presión, sin dormir, con el director preguntando cada cinco minutos, la diferencia entre resolver en diez minutos o en dos horas no está en conocer más comandos: está en tener un procedimiento que vaya del síntoma a la causa sin dar vueltas.

Esta lección construye esa metodología. Veremos el árbol de decisión que ordena la investigación, los eventos de Kubernetes como la fuente más importante y peor aprovechada —con el hecho crítico de que caducan en una hora—, una tabla maestra de síntoma → causa probable → comando que lo confirma, las herramientas de inspección incluidos los contenedores efímeros de kubectl debug, y finalmente un incidente real de Rutas Norte resuelto paso a paso usando todo lo aprendido en el módulo. Es la última lección del módulo 7.

Contenido

  1. Una metodología, no una lista de comandos
  2. El árbol de decisión: del síntoma a la causa
  3. Los eventos: la fuente principal y peor aprovechada
  4. La tabla maestra: síntoma → causas probables → comando
  5. Diagnóstico detallado de cada síntoma
  6. Las herramientas de inspección
  7. kubectl debug: contenedores efímeros, copias y nodos
  8. Caso real: la tienda devuelve 502 durante los despliegues
  9. Recoger pruebas antes de reiniciar
  10. Errores comunes y consejos
  11. Ejercicios

  1. Una metodología, no una lista de comandos

El error más común al depurar en Kubernetes no es desconocer un comando: es empezar a mirar por el sitio equivocado.

Ante un "la web no funciona", la reacción instintiva suele ser kubectl logs del primer pod que aparezca. Pero si el pod está en Pending, no hay logs que leer, porque nunca ha arrancado. Si el problema es que el Service no tiene Endpoints, los logs de la aplicación estarán perfectos y no dirán nada. Media hora perdida mirando en el lugar donde no está la respuesta.

Una metodología ordena el trabajo con dos principios:

Principio 1: seguir el ciclo de vida del pod, en orden. Un pod pasa por fases sucesivas, y un fallo en una fase hace irrelevante todo lo posterior. No tiene sentido preguntar si un pod recibe tráfico si todavía no ha sido programado en un nodo.

Principio 2: confirmar cada hipótesis con un comando concreto. "Creo que es la memoria" no es un diagnóstico. kubectl get pod X -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' devolviendo OOMKilled sí lo es.

Las cinco preguntas en orden, que son la columna vertebral de todo lo que sigue:

# Pregunta Si la respuesta es NO, mira...
1 ¿El pod existe? El controlador (Deployment, StatefulSet, CronJob)
2 ¿Está programado en un nodo? El planificador: recursos, taints, afinidad, PVC
3 ¿Ha arrancado el contenedor? Imagen, volúmenes, ConfigMaps, Secrets
4 ¿Está listo? Las sondas de 07-01 y sus dependencias
5 ¿Recibe tráfico? Service, EndpointSlice, selector, Ingress, NetworkPolicy

Cada pregunta tiene un comando que la responde en un segundo. La disciplina consiste en no saltarse ninguna.

  1. El árbol de decisión: del síntoma a la causa

flowchart TD
    S["Síntoma reportado"] --> Q1{"¿Existe el pod?<br/>kubectl get pods"}
    Q1 -->|No| C1["Revisar el controlador:<br/>kubectl describe deploy/sts<br/>¿ReplicaSet creado? ¿Cuota agotada?"]
    Q1 -->|Sí| Q2{"¿Cuál es su STATUS?"}

    Q2 -->|Pending| C2["El planificador no lo coloca:<br/>recursos, taints, afinidad,<br/>PVC sin enlazar"]
    Q2 -->|ContainerCreating| C3["El kubelet no lo arranca:<br/>volumen, Secret o ConfigMap<br/>que no existe"]
    Q2 -->|ImagePullBackOff| C4["No descarga la imagen:<br/>nombre, etiqueta,<br/>credenciales del registro"]
    Q2 -->|CrashLoopBackOff| C5["Arranca y muere:<br/>logs --previous,<br/>OOMKilled, liveness"]
    Q2 -->|Terminating| C6["No termina:<br/>finalizadores,<br/>periodo de gracia"]
    Q2 -->|Running| Q3{"¿READY es n/n?"}

    Q3 -->|No| C7["Las sondas fallan:<br/>describe → eventos Unhealthy<br/>07-01"]
    Q3 -->|Sí| Q4{"¿El Service tiene<br/>Endpoints?"}

    Q4 -->|No| C8["Selector mal, o ningún<br/>pod está Ready"]
    Q4 -->|Sí| Q5{"¿Responde por<br/>port-forward directo?"}

    Q5 -->|No| C9["Problema en la aplicación:<br/>logs, exec, métricas"]
    Q5 -->|Sí| C10["Problema en la capa de red:<br/>Ingress, NetworkPolicy,<br/>DNS, TLS"]

Los comandos que responden a cada nodo de decisión:

NS=rutas-norte-pro

# Q1 — ¿Existe el pod?
kubectl -n $NS get pods -l app=api-reservas

# Q2 — ¿Cuál es su estado? Con -o wide se ve además en qué nodo está
kubectl -n $NS get pods -o wide

# Q3 — ¿Está listo? La columna READY es la que importa
kubectl -n $NS get pods -l app=api-reservas

# Q4 — ¿El Service tiene Endpoints? LA COMPROBACIÓN MÁS INFRAVALORADA
kubectl -n $NS get endpointslices -l kubernetes.io/service-name=api-reservas

# Q5 — ¿Responde saltándose el Service y el Ingress?
kubectl -n $NS port-forward pod/api-reservas-7d9f8c4b5-x2klm 8080:8080
curl -s localhost:8080/salud

La pregunta 4 merece un comentario especial. Un EndpointSlice vacío es la causa del 80 % de los "el Service no funciona", y se comprueba en dos segundos:

kubectl -n rutas-norte-pro get endpointslices -l kubernetes.io/service-name=api-reservas \
  -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
10.244.2.17	true
10.244.1.23	true
10.244.3.41	false

Ahí se ve directamente qué pods están recibiendo tráfico y cuáles no.

  1. Los eventos: la fuente principal y peor aprovechada

Los eventos son objetos de la API de Kubernetes que registran lo que los componentes del clúster hacen y observan: el planificador cuando no puede colocar un pod, el kubelet cuando falla al descargar una imagen, el controlador de endpoints cuando actualiza un Service.

Son, con diferencia, la fuente de diagnóstico más útil y la que menos se consulta.

Cómo consultarlos

NS=rutas-norte-pro

# EL COMANDO MÁS ÚTIL DE ESTA LECCIÓN: eventos ordenados cronológicamente.
# Sin --sort-by salen en orden arbitrario y son casi inútiles.
kubectl -n $NS get events --sort-by=.lastTimestamp

# Solo los problemas
kubectl -n $NS get events --field-selector type=Warning --sort-by=.lastTimestamp

# De todo el clúster (fundamental cuando el problema es de infraestructura)
kubectl get events -A --sort-by=.lastTimestamp | tail -40

# Los de un objeto concreto
kubectl -n $NS get events --field-selector involvedObject.name=api-reservas-7d9f8c4b5-x2klm

# Combinando selectores de campo
kubectl -n $NS get events \
  --field-selector type=Warning,involvedObject.kind=Pod \
  --sort-by=.lastTimestamp

# El comando moderno (Kubernetes 1.26+), más legible
kubectl -n $NS events --for pod/api-reservas-7d9f8c4b5-x2klm
kubectl -n $NS events --types=Warning
kubectl -n $NS events --watch                # en tiempo real

# Y la vía más frecuente en la práctica: la sección Events de describe
kubectl -n $NS describe pod api-reservas-7d9f8c4b5-x2klm | tail -20

Salida típica:

LAST SEEN   TYPE      REASON              OBJECT                              MESSAGE
5m12s       Normal    Scheduled           pod/api-reservas-7d9f8c4b5-x2klm    Successfully assigned rutas-norte-pro/api-reservas-7d9f8c4b5-x2klm to rutas-norte-worker-2
5m10s       Normal    Pulled              pod/api-reservas-7d9f8c4b5-x2klm    Container image "registry.rutasnorte.example/api-reservas:2.8.1" already present on machine
5m10s       Normal    Created             pod/api-reservas-7d9f8c4b5-x2klm    Created container api
5m09s       Normal    Started             pod/api-reservas-7d9f8c4b5-x2klm    Started container api
4m22s       Warning   Unhealthy           pod/api-reservas-7d9f8c4b5-x2klm    Readiness probe failed: HTTP probe failed with statuscode: 503
3m18s       Warning   BackOff             pod/api-reservas-7d9f8c4b5-x2klm    Back-off restarting failed container api

Esa secuencia cuenta una historia completa en seis líneas: se programó bien, la imagen estaba, arrancó, la readiness empezó a fallar con 503 y acabó en backoff. Diagnóstico prácticamente hecho.

Normal frente a Warning

Tipo Significado Ejemplos
Normal Operación esperada del ciclo de vida Scheduled, Pulled, Created, Started, Killing
Warning Algo no ha ido como debía FailedScheduling, Failed, BackOff, Unhealthy, FailedMount, Evicted

Regla práctica: empieza siempre por los Warning. Pero no ignores los Normal: la ausencia de un evento Scheduled esperado, o un Killing que no esperabas, es información valiosa. Y en el caso de la sección 8, un evento Normal será la pieza clave del diagnóstico.

Los eventos más frecuentes y qué significan

REASON Emisor Qué significa
FailedScheduling planificador No hay ningún nodo donde quepa el pod
Scheduled planificador Asignado a un nodo
Pulling / Pulled kubelet Descargando / imagen lista
Failed (ErrImagePull) kubelet No pudo descargar la imagen
Created / Started kubelet Contenedor creado / arrancado
BackOff kubelet Espera exponencial antes de reintentar
Unhealthy kubelet Una sonda ha fallado (07-01)
Killing kubelet Terminando el contenedor y por qué
FailedMount kubelet Volumen, Secret o ConfigMap no disponible
FailedAttachVolume controlador de volúmenes El volumen sigue asociado a otro nodo
Evicted kubelet Desalojado por presión de recursos del nodo
NodeNotReady controlador de nodos El nodo dejó de reportar
Preempting planificador Desalojando pods de menor prioridad (06-05)

⚠️ El hecho crítico: los eventos caducan en una hora

Este es el punto que hay que grabar a fuego, porque cambia por completo la forma de trabajar.

Los eventos de Kubernetes se guardan en etcd con un TTL por defecto de 1 hora. Pasado ese tiempo, el API Server los borra automáticamente. No es una rotación por espacio: es un borrado por tiempo.

# Ver el TTL configurado en el clúster (parámetro del API Server)
kubectl -n kube-system get pod -l component=kube-apiserver \
  -o jsonpath='{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep event-ttl
"--event-ttl=1h0m0s"

Las consecuencias son muy concretas:

  • Si el incidente ocurrió a las 03:14 y lo investigas a las 09:00, los eventos ya no existen. kubectl describe no mostrará nada útil, aunque el problema siga presente.
  • Un pod que lleva tres días en CrashLoopBackOff solo tiene eventos de la última hora, no del momento en que empezó a fallar.
  • La evidencia más valiosa para reconstruir una cronología desaparece sola, sin avisar.

Cómo resolverlo: exportar los eventos.

Opción A — Captura manual e inmediata. Lo primero que haces al empezar a investigar:

kubectl get events -A --sort-by=.lastTimestamp \
  -o json > /tmp/eventos-incidente-$(date +%Y%m%d-%H%M).json

Opción B — kube-state-metrics. Como vimos en 07-03, expone métricas del estado de los objetos. No son los eventos en sí, pero permiten reconstruir mucho:

# Reinicios a lo largo del tiempo: sobrevive al TTL de los eventos
increase(kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"}[1h])

# Motivo de la última terminación del contenedor
kube_pod_container_status_last_terminated_reason{namespace="rutas-norte-pro"}

Opción C (la recomendada) — Exportar los eventos al sistema de logs. Un componente como kubernetes-event-exporter observa los eventos y los envía a Elasticsearch o Loki, donde quedan sujetos a la retención de 30 días de 07-05, no a la de una hora.

# k8s/base/registro/event-exporter-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: event-exporter-config
  namespace: registro
data:
  config.yaml: |
    logLevel: info
    logFormat: json
    route:
      routes:
        - match:
            - receiver: registro-centralizado
    receivers:
      - name: registro-centralizado
        # Salida a stdout: la recoge Fluent Bit como cualquier otro log,
        # con lo que hereda toda la pila y la retención de 07-05.
        stdout:
          deDot: true
          layout:
            timestamp: "{{ .LastTimestamp }}"
            nivel: "{{ if eq .Type \"Warning\" }}warn{{ else }}info{{ end }}"
            componente: "kubernetes-eventos"
            mensaje: "{{ .Message }}"
            razon: "{{ .Reason }}"
            objeto_tipo: "{{ .InvolvedObject.Kind }}"
            objeto_nombre: "{{ .InvolvedObject.Name }}"
            namespace: "{{ .InvolvedObject.Namespace }}"
            nodo: "{{ .Source.Host }}"
            recuento: "{{ .Count }}"

Con esto, en Kibana se puede buscar:

componente: "kubernetes-eventos" and razon: "OOMKilled" and namespace: "rutas-norte-pro"

y ver todos los OOMKilled de las últimas cuatro semanas. Es una de las mejoras de mayor relación valor/esfuerzo de toda la observabilidad.

Regla operativa de Rutas Norte: el primer comando de cualquier investigación es capturar los eventos a fichero. Lo segundo, mirar la hora del incidente y comprobar si el TTL ya se los ha llevado.

  1. La tabla maestra: síntoma → causas probables → comando

Esta tabla es el mapa mental que hay que interiorizar. Cada fila desarrolla su diagnóstico en la sección siguiente.

Síntoma Causas probables (en orden de frecuencia) Comando que lo confirma
Pending Recursos insuficientes; PVC sin enlazar; taints sin tolerar; afinidad imposible; cuota agotada kubectl describe pod → evento FailedScheduling
ImagePullBackOff / ErrImagePull Nombre o etiqueta mal; credenciales del registro; imagen borrada; red del nodo kubectl describe pod → evento Failed con el mensaje del registro
CrashLoopBackOff Fallo de arranque; configuración ausente; OOMKilled; liveness demasiado agresiva; comando mal kubectl logs --previous
OOMKilled (salida 137) limits.memory corto; fuga de memoria; pico legítimo kubectl get pod -o jsonpath='{...lastState.terminated}'
Error / Completed inesperado Código de salida distinto de 0; proceso que termina cuando no debe kubectl logs --previous + exitCode
ContainerCreating atascado Secret o ConfigMap inexistente; PVC no enlazado; volumen atascado en otro nodo kubectl describe pod → evento FailedMount
Terminating eterno Finalizadores pendientes; proceso que ignora SIGTERM; nodo caído kubectl get pod -o jsonpath='{.metadata.finalizers}'
0/3 Ready Sonda de readiness fallando; dependencia caída kubectl describe pod → evento Unhealthy
503 del Ingress Service sin Endpoints; selector mal; ningún pod listo kubectl get endpointslices
502 del Ingress El backend cierra la conexión; carrera del SIGTERM; timeout Logs del controlador de Ingress + preStop (07-01)
Evicted Presión de memoria o disco en el nodo; QoS BestEffort kubectl describe node → condiciones de presión

  1. Diagnóstico detallado de cada síntoma

Pending: el planificador no lo coloca

kubectl -n rutas-norte-pro describe pod api-reservas-7d9f8c4b5-x2klm | tail -15
Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  2m14s  default-scheduler  0/4 nodes are available:
           1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
           2 Insufficient memory,
           1 node(s) had volume node affinity conflict.
           preemption: 0/4 nodes are available: 4 No preemption victims found.

El mensaje de FailedScheduling es un diagnóstico completo, nodo por nodo. Traducción de esa línea:

Fragmento Significado Solución
1 node(s) had untolerated taint El plano de control tiene taint (06-05) Correcto: no queremos cargas ahí
2 Insufficient memory Dos nodos sin memoria libre para la requests Bajar la requests (07-02) o añadir nodos
1 node(s) had volume node affinity conflict El PV existe en otra zona (05-02) El pod debe ir donde está su volumen

Comprobaciones complementarias:

# ¿Cuánto queda realmente reservado en cada nodo?
kubectl describe node rutas-norte-worker-2 | grep -A 8 "Allocated resources"

# ¿Se ha agotado la ResourceQuota del namespace? (03-04)
kubectl -n rutas-norte-pro describe resourcequota

# ¿El PVC está enlazado?
kubectl -n rutas-norte-pro get pvc

# ¿Qué taints tienen los nodos?
kubectl get nodes -o custom-columns=NOMBRE:.metadata.name,TAINTS:.spec.taints

Un caso que despista mucho: si la ResourceQuota está agotada, el pod ni siquiera se crea. No verás un pod en Pending: no verás ningún pod. El error está en el ReplicaSet:

kubectl -n rutas-norte-pro describe replicaset api-reservas-7d9f8c4b5 | tail -8
Warning  FailedCreate  1m  replicaset-controller  Error creating: pods "api-reservas-7d9f8c4b5-" is
forbidden: exceeded quota: cuota-pro, requested: requests.memory=512Mi, used: requests.memory=15872Mi,
limited: requests.memory=16Gi

Es la respuesta a la pregunta 1 del árbol de decisión: cuando el pod no existe, hay que mirar al controlador.

ImagePullBackOff y ErrImagePull

Events:
  Warning  Failed   45s (x4 over 2m)  kubelet  Failed to pull image
  "registry.rutasnorte.example/api-reservas:2.8.2": rpc error: code = NotFound
  desc = failed to pull and unpack image: not found
  Warning  Failed   45s (x4 over 2m)  kubelet  Error: ErrImagePull
  Normal   BackOff  20s (x6 over 2m)  kubelet  Back-off pulling image

Diferencia entre los dos estados: ErrImagePull es el primer fallo; ImagePullBackOff es el estado tras varios intentos, con espera exponencial creciente.

Las cuatro causas y sus mensajes distintivos:

Mensaje del registro Causa Solución
not found / manifest unknown Etiqueta o nombre incorrecto Verificar la etiqueta exacta
unauthorized / authentication required Falta el imagePullSecrets Crear el Secret de tipo docker-registry
no such host El nodo no resuelve el registro DNS del nodo, no del clúster
context deadline exceeded Red lenta o imagen enorme Ampliar timeout, precargar la imagen
# Verificar el nombre exacto de la imagen declarada
kubectl -n rutas-norte-pro get deploy api-reservas \
  -o jsonpath='{.spec.template.spec.containers[*].image}'

# ¿Está declarado el secreto del registro?
kubectl -n rutas-norte-pro get deploy api-reservas \
  -o jsonpath='{.spec.template.spec.imagePullSecrets}'

# Crear el secreto si falta
kubectl -n rutas-norte-pro create secret docker-registry registro-rutasnorte \
  --docker-server=registry.rutasnorte.example \
  --docker-username=despliegues \
  --docker-password="$REGISTRY_PASSWORD"

Un caso especialmente traicionero: etiquetas mutables. Si alguien usa :latest (algo que la convención de Rutas Norte prohíbe expresamente: etiquetas de imagen inmutables), el pod puede funcionar en un nodo donde la imagen está cacheada y fallar en otro donde no. Un problema intermitente e inexplicable.

CrashLoopBackOff: arranca y muere

El más común y el que más pasos requiere.

Paso 1: los logs de la ejecución anterior. El contenedor actual acaba de arrancar y no dirá nada; la causa está en el que murió.

kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --previous
{"nivel":"fatal","componente":"api-reservas","mensaje":"Variable de entorno DB_PASSWORD no definida"}

Diagnóstico inmediato. Pero muchas veces los logs están vacíos, y entonces:

Paso 2: el motivo y el código de salida de la terminación.

kubectl -n rutas-norte-pro get pod api-reservas-7d9f8c4b5-x2klm \
  -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | jq
{
  "containerID": "containerd://3f8a2c...",
  "exitCode": 137,
  "finishedAt": "2026-08-06T03:14:22Z",
  "reason": "OOMKilled",
  "startedAt": "2026-08-06T03:12:58Z"
}

Tabla de códigos de salida:

Código Significado Causa habitual
0 Terminación limpia El proceso terminó cuando no debía
1 Error genérico de la aplicación Excepción no capturada
2 Uso incorrecto de un comando de shell Argumentos mal
126 El comando no es ejecutable Falta permiso de ejecución
127 Comando no encontrado Binario ausente en la imagen
137 SIGKILL (128+9) OOMKilled o periodo de gracia agotado
139 SIGSEGV (128+11) Violación de segmento
143 SIGTERM (128+15) Terminación normal por Kubernetes

Paso 3: descartar la causa de 07-01. Una livenessProbe demasiado agresiva mata contenedores perfectamente sanos que simplemente tardan en arrancar. El indicio:

Warning  Unhealthy  2m (x9 over 5m)  kubelet  Liveness probe failed: Get "http://10.244.2.17:8080/salud":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Normal   Killing    2m (x3 over 5m)  kubelet  Container api failed liveness probe, will be restarted

Si el evento Killing viene precedido de Unhealthy y los logs de la aplicación no muestran ningún error, casi seguro que la sonda es el problema, no la aplicación. La solución es la startupProbe de 07-01.

Paso 4: el truco para depurar un contenedor que muere al instante. Si el contenedor muere en menos de un segundo, no da tiempo a hacer exec. Se lanza una copia con el comando sustituido por algo que no termine:

kubectl -n rutas-norte-pro debug pod/api-reservas-7d9f8c4b5-x2klm \
  --copy-to=api-depuracion \
  --container=api \
  -- sleep 3600

# Ahora el pod vive y se puede entrar a investigar
kubectl -n rutas-norte-pro exec -it api-depuracion -c api -- sh

Dentro se puede comprobar todo lo que la aplicación necesitaba:

env | grep DB_          # ¿están las variables?
ls -la /etc/config/     # ¿está montado el ConfigMap?
cat /etc/secretos/db    # ¿el Secret tiene lo que espera?
node server.js          # ejecutar a mano y ver el error completo

OOMKilled y el código 137

# Buscar todos los OOMKilled del namespace
kubectl -n rutas-norte-pro get pods -o json | jq -r '
  .items[] |
  select(.status.containerStatuses[]?.lastState.terminated.reason == "OOMKilled") |
  "\(.metadata.name)\t\(.status.containerStatuses[0].lastState.terminated.finishedAt)"'

Diferenciar las dos situaciones, que exigen soluciones distintas:

Situación Cómo distinguirla Solución
Límite demasiado bajo El consumo es estable y cercano al límite Subir limits.memory (07-02)
Fuga de memoria El consumo crece linealmente hasta morir Arreglar el código; el límite solo retrasa

La distinción se hace con la métrica de 07-03:

container_memory_working_set_bytes{namespace="rutas-norte-pro", pod=~"api-reservas-.*", container="api"}

Una sierra ascendente que llega al límite y cae a cero repetidamente es la firma inconfundible de una fuga de memoria. Un valor estable que un día roza el límite es un límite corto.

Error y Completed inesperado

Un pod en Completed con restartPolicy: Always acaba en CrashLoopBackOff, porque Kubernetes lo reinicia y vuelve a terminar. La causa habitual: el proceso principal no es un servicio de larga duración.

kubectl -n rutas-norte-pro get pod X -o jsonpath='{.spec.containers[0].command} {.spec.containers[0].args}'

Errores típicos: un command que ejecuta un script que termina, un servidor lanzado en segundo plano mientras PID 1 sale, o un CMD que arranca en modo demonio.

Para el CronJob informes-ocupacion (06-03), en cambio, Completed con código 0 es lo correcto. El diagnóstico es el opuesto:

kubectl -n rutas-norte-pro get jobs -l app=informes-ocupacion
kubectl -n rutas-norte-pro logs job/informes-ocupacion-28934520
kubectl -n rutas-norte-pro describe job informes-ocupacion-28934520 | grep -A 5 "Pod Statuses"

ContainerCreating atascado

El pod está programado pero el kubelet no consigue crear el contenedor.

Events:
  Warning  FailedMount  1m (x8 over 5m)  kubelet  MountVolume.SetUp failed for volume
  "config-api" : configmap "configuracion-api-reservas" not found

Las cuatro causas:

Causa Mensaje Comprobación
ConfigMap inexistente configmap "X" not found kubectl get cm X
Secret inexistente secret "X" not found kubectl get secret X
PVC no enlazado PersistentVolumeClaim is not bound kubectl get pvc
Volumen en otro nodo Multi-Attach error for volume kubectl get volumeattachment

El último es el más pesado. Un volumen ReadWriteOnce (05-03) solo puede estar montado en un nodo a la vez. Si el pod se recrea en otro nodo antes de que el volumen se libere del anterior, se queda bloqueado:

Warning  FailedAttachVolume  2m  attachdetach-controller  Multi-Attach error for volume
"pvc-4f8a2c91-..." Volume is already exclusively attached to one node and can't be attached to another

Se resuelve solo en unos minutos cuando el CSI libera el volumen. Si el nodo original está muerto, puede tardar seis minutos o más. Es una razón de peso para que postgres-reservas sea un StatefulSet (06-01) con planificación estable.

Pod Terminating eterno

kubectl -n rutas-norte-pro get pod X
NAME   READY   STATUS        RESTARTS   AGE
X      1/1     Terminating   0          47m

Tres causas, en orden de frecuencia:

Causa 1: finalizadores. Un finalizador es un marcador que impide el borrado hasta que un controlador haga su trabajo de limpieza. Si ese controlador está caído, el objeto se queda bloqueado para siempre.

kubectl -n rutas-norte-pro get pod X -o jsonpath='{.metadata.finalizers}'
["kubernetes.io/pvc-protection","rutasnorte.example/limpieza-cache"]

El primero es normal (protege el PVC). El segundo es de un controlador propio: si ese controlador está caído, hay que arreglarlo.

Forzar el borrado eliminando el finalizador es la última opción, porque salta la limpieza que ese finalizador garantizaba:

# ⚠️ ÚLTIMO RECURSO: puede dejar recursos huérfanos
kubectl -n rutas-norte-pro patch pod X -p '{"metadata":{"finalizers":null}}' --type=merge

Causa 2: el proceso ignora el SIGTERM. Como vimos en 07-01, si PID 1 es un shell que no propaga señales, el proceso real nunca recibe el SIGTERM y hay que esperar al SIGKILL del final del periodo de gracia. Si terminationGracePeriodSeconds es 3600, el pod tarda una hora en morir.

kubectl -n rutas-norte-pro get pod X -o jsonpath='{.spec.terminationGracePeriodSeconds}'

Causa 3: el nodo está caído. Si el kubelet no responde, nadie confirma que el pod ha muerto. Kubernetes espera (5 minutos por defecto) antes de darlo por perdido.

kubectl get nodes
kubectl describe node rutas-norte-worker-2 | grep -A 6 Conditions

0/3 Ready: las sondas fallan

NAME                            READY   STATUS    RESTARTS   AGE
api-reservas-7d9f8c4b5-x2klm    0/1     Running   0          8m

Running con 0/1 es la firma exacta de una readiness fallando. El contenedor está vivo, pero no entra en los Endpoints.

kubectl -n rutas-norte-pro describe pod api-reservas-7d9f8c4b5-x2klm | grep -A 5 Events
Warning  Unhealthy  30s (x48 over 8m)  kubelet  Readiness probe failed:
HTTP probe failed with statuscode: 503

El siguiente paso es ejecutar la sonda a mano desde dentro del pod, que es lo que da la respuesta real:

kubectl -n rutas-norte-pro exec -it api-reservas-7d9f8c4b5-x2klm -c api -- \
  curl -s -w "\nHTTP %{http_code}\n" localhost:8080/preparado
{
  "estado": "no-preparado",
  "motivo": "pool de conexiones agotado",
  "comprobaciones": { "poolLibre": false }
}

Ahí está la causa, y es exactamente el escenario con el que abrimos 07-01. La readiness está haciendo su trabajo: apartar el pod hasta que pueda servir. El problema no es la sonda, es PostgreSQL.

503 y 502 del Ingress

Son errores distintos con causas distintas, y confundirlos cuesta tiempo.

Código Significado Causa en Kubernetes
503 Service Unavailable El Ingress no tiene a quién enviar Service sin Endpoints; ningún pod Ready
502 Bad Gateway El backend existe pero falló El pod cerró la conexión; timeout; carrera del SIGTERM

Diagnóstico del 503:

# ¿Hay Endpoints? Si está vacío, ahí está la respuesta
kubectl -n rutas-norte-pro get endpointslices -l kubernetes.io/service-name=tienda-web

# ¿Coincide el selector del Service con las etiquetas de los pods?
kubectl -n rutas-norte-pro get svc tienda-web -o jsonpath='{.spec.selector}'
kubectl -n rutas-norte-pro get pods -l app=tienda-web --show-labels

Una fuente clásica de error con nuestra convención: el Service selecciona app y entorno, y alguien despliega los pods con entorno: produccion en vez de entorno: pro. Los pods están perfectos, el Service está perfecto, y no se encuentran.

Diagnóstico del 502: es el caso de la sección 8.

  1. Las herramientas de inspección

kubectl describe: la vista completa

kubectl -n rutas-norte-pro describe pod api-reservas-7d9f8c4b5-x2klm

Las secciones que hay que leer, por orden de utilidad:

Sección Qué buscar
Events (al final) Empieza siempre por aquí
Status / Conditions Ready, ContainersReady, PodScheduled y sus motivos
Containers → State / Last State Estado actual y motivo de la terminación anterior
Containers → Restart Count Cuántas veces ha muerto
Node Dónde está: permite correlacionar con problemas del nodo
Mounts / Volumes Qué monta y de dónde
QoS Class Guaranteed, Burstable o BestEffort (03-05)

kubectl logs, exec y port-forward

Ya los conocemos de 01-05 y 07-05. Los usos específicos para depurar:

# Logs de la ejecución anterior: EL comando ante un CrashLoopBackOff
kubectl -n rutas-norte-pro logs POD --previous -c api

# Entrar en el contenedor
kubectl -n rutas-norte-pro exec -it POD -c api -- sh

# Comprobaciones habituales una vez dentro
env | sort
cat /etc/config/aplicacion.yaml
nslookup postgres-reservas.rutas-norte-pro.svc.cluster.local
wget -qO- http://localhost:8080/preparado

# port-forward: SALTARSE el Ingress y el Service para aislar el problema
kubectl -n rutas-norte-pro port-forward pod/api-reservas-7d9f8c4b5-x2klm 8080:8080

El port-forward directo al pod es la prueba de aislamiento más valiosa que existe. Si funciona, has descartado de golpe: el Ingress, el certificado TLS, el Service, el kube-proxy y las NetworkPolicies. El problema está en la capa de red, no en la aplicación. Y si no funciona, el problema está en la aplicación y no hace falta mirar la red.

Un pod netshoot para problemas de red

Las imágenes de producción son mínimas y no traen herramientas de red. Un pod desechable con todo el instrumental:

kubectl -n rutas-norte-pro run netshoot --rm -it \
  --image=nicolaka/netshoot:latest \
  --labels="app=depuracion,entorno=pro" \
  --restart=Never -- bash
# ¿Resuelve el DNS del clúster? (04-03)
nslookup postgres-reservas.rutas-norte-pro.svc.cluster.local
dig +short api-reservas.rutas-norte-pro.svc.cluster.local

# ¿Se llega al puerto? Confirma o descarta NetworkPolicy (04-06)
nc -zv postgres-reservas 5432
curl -v http://api-reservas/salud

# ¿Cuál es la ruta y dónde se pierde?
traceroute 10.244.2.17
mtr --report --report-cycles 10 api-reservas

# Capturar tráfico
tcpdump -i any -n port 8080

Aviso importante: las etiquetas del pod importan. En rutas-norte-pro hay una NetworkPolicy deny-all, así que un pod netshoot sin las etiquetas correctas no podrá hablar con nada, y confundirás un problema de política con un problema de aplicación. Por eso lo lanzamos con --labels.

  1. kubectl debug: contenedores efímeros, copias y nodos

kubectl debug es la herramienta más potente y menos conocida, estable desde Kubernetes 1.25. Tiene tres modos muy distintos.

Modo 1: contenedores efímeros

Un contenedor efímero se añade a un pod que ya está corriendo, compartiendo su namespace de red y opcionalmente el de procesos. Es la solución al problema de las imágenes distroless, que no tienen ni shell:

kubectl -n rutas-norte-pro debug -it api-reservas-7d9f8c4b5-x2klm \
  --image=nicolaka/netshoot:latest \
  --target=api \
  -- bash
Flag Función
--image Imagen con las herramientas que necesitas
--target=api Comparte el namespace de procesos con ese contenedor
-it Sesión interactiva

Con --target, desde el contenedor efímero ves los procesos del contenedor original:

ps aux
PID   USER     COMMAND
1     node     node /app/server.js          ← el proceso de api-reservas
28    root     bash                          ← nuestra sesión de depuración

Y desde ahí:

# Ver los descriptores de fichero abiertos del proceso real
ls -l /proc/1/fd | head -20

# Ver sus variables de entorno
cat /proc/1/environ | tr '\0' '\n'

# Volcar la pila de la JVM o hacer un perfilado
kill -QUIT 1

# Comprobar la red DESDE dentro del pod, con su misma IP
curl -v localhost:8080/preparado
netstat -tulpn

Puntos importantes:

  • El contenedor efímero no se puede eliminar una vez añadido: se queda hasta que el pod muera.
  • No tiene sondas ni resources, y no puede modificar el pod.
  • No reinicia nada: el pod sigue sirviendo tráfico mientras investigas. Es su gran ventaja.

Modo 2: --copy-to, depurar una copia sin tocar producción

Crea un pod nuevo, copia del original, con las modificaciones que le pidas. El pod original no se toca.

# Copia con un contenedor de depuración añadido
kubectl -n rutas-norte-pro debug api-reservas-7d9f8c4b5-x2klm \
  --copy-to=api-depuracion \
  --image=nicolaka/netshoot:latest \
  --share-processes \
  -it -- bash

# Copia con el comando sustituido: para depurar un CrashLoopBackOff
kubectl -n rutas-norte-pro debug api-reservas-7d9f8c4b5-x2klm \
  --copy-to=api-depuracion \
  --container=api \
  -- sleep 7200

# Copia con otra imagen: probar si una versión anterior funciona
kubectl -n rutas-norte-pro debug api-reservas-7d9f8c4b5-x2klm \
  --copy-to=api-version-anterior \
  --set-image=api=registry.rutasnorte.example/api-reservas:2.8.0

Por qué es tan valioso: el pod copiado no lleva las etiquetas del selector del Service, así que no recibe tráfico de producción. Puedes reventarlo, reiniciarlo, modificarlo y experimentar sin que ningún cliente lo note. Cuando terminas:

kubectl -n rutas-norte-pro delete pod api-depuracion

Este es el modo que resuelve el dilema clásico de "necesito depurar en producción pero no puedo tocar producción".

Modo 3: kubectl debug node/, entrar en el nodo

Cuando el problema es del nodo y no del pod:

kubectl debug node/rutas-norte-worker-2 -it --image=busybox

Crea un pod en ese nodo con el sistema de ficheros del anfitrión montado en /host:

chroot /host

# Espacio en disco: la causa más común de desalojos
df -h

# ¿Qué está pasando en el nodo?
top
journalctl -u kubelet --since "1 hour ago" | tail -50
crictl ps -a | head
crictl logs <id-contenedor>

# Los logs de los contenedores, tal como los vimos en 07-05
ls -la /var/log/containers/ | grep api-reservas

Advertencia de seguridad: kubectl debug node/ crea un pod privilegiado con acceso completo al nodo. Quien pueda ejecutarlo controla efectivamente esa máquina y puede leer los secretos de todos los pods que corren en ella. Debe restringirse por RBAC a un grupo muy reducido. El módulo 8 desarrolla estos controles.

Tabla comparativa de los tres modos

Modo Toca el pod original Recibe tráfico Para qué
Contenedor efímero Sí (añade contenedor) Sí, sigue sirviendo Inspeccionar un pod vivo sin shell
--copy-to No No Experimentar sin riesgo; depurar CrashLoopBackOff
node/ No (pod nuevo) No Problemas del nodo: disco, kubelet, runtime

  1. Caso real: la tienda devuelve 502 durante los despliegues

Ahora juntamos todo. Este es el incidente que ha estado abierto desde 02-04, y lo vamos a cerrar usando las tres señales del módulo.

El síntoma

Cada vez que se despliega una versión nueva de api-reservas en rutas-norte-pro, durante unos 90 segundos algunos clientes reciben un error 502 al intentar comprar. No todos, y no siempre los mismos. Fuera de los despliegues, todo va perfecto.

El equipo lo lleva ignorando meses: "solo pasa al desplegar, y se pasa solo".

Paso 1 — La métrica confirma que el problema existe y lo acota

En el cuadro de mando de 07-04, panel de errores de api-reservas, con el rango puesto en el despliegue de ayer:

sum(rate(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[1m]))
  / sum(rate(rutasnorte_peticiones_total{entorno="pro"}[1m]))

El gráfico muestra tres picos de error del 4-6 %, de unos 25 segundos cada uno, separados por unos 40 segundos. La tasa de error es cero antes y después.

Primera pista importante: tres picos, no uno. El Deployment tiene 6 réplicas con maxSurge: 2, así que el despliegue avanza en tres tandas. Un pico por tanda. Eso descarta un problema puntual de arranque de la versión nueva: es un patrón que se repite con cada sustitución de pods.

Y una segunda consulta descarta al proveedor externo:

sum(rate(rutasnorte_pasarela_pagos_llamadas_total{resultado=~"error|timeout"}[1m]))
  / sum(rate(rutasnorte_pasarela_pagos_llamadas_total[1m]))

Plana en cero durante todo el despliegue. El problema es nuestro.

Paso 2 — Los logs dicen quién falla y quién no

En Kibana, acotando a la ventana del despliegue:

kubernetes.namespace_name: "rutas-norte-pro" and codigo_http: 502

Resultado: 4 812 documentos, todos de tienda-web (que actúa de proxy hacia la API), ninguno de api-reservas.

Esto es muy revelador y hay que pararse a pensarlo. Si api-reservas estuviera generando errores, sus propios logs mostrarían códigos 500. No hay ni uno. api-reservas nunca vio esas peticiones. El 502 lo genera quien intenta hablar con ella.

Siguiente consulta, sobre los logs del controlador de Ingress:

kubernetes.labels.app: "ingress-nginx" and mensaje: *502*
upstream prematurely closed connection while reading response header from upstream,
upstream: "http://10.244.2.17:8080/api/reservas"

"Upstream prematurely closed connection": el backend cerró la conexión mientras el Ingress esperaba la respuesta. No la rechazó (eso daría connection refused): la aceptó y la cerró a mitad.

Paso 3 — Los eventos dan la cronología exacta

Aquí es donde el exportador de eventos de la sección 3 demuestra su valor: el despliegue fue ayer, así que los eventos originales caducaron hace 20 horas. Pero están en Elasticsearch:

componente: "kubernetes-eventos" and namespace: "rutas-norte-pro"
  and objeto_nombre: api-reservas-*

Ordenado cronológicamente:

14:32:01  Normal  Killing     api-reservas-7d9f8c4b5-x2klm  Stopping container api
14:32:01  Normal  Scheduled   api-reservas-9f2c1a8b6-kp3nm  Successfully assigned to worker-1
14:32:04  Normal  Started     api-reservas-9f2c1a8b6-kp3nm  Started container api
14:32:19  Normal  Killing     api-reservas-7d9f8c4b5-mn8pq  Stopping container api

Y ahora la observación decisiva, que exige mirar la IP:

El pod 10.244.2.17 (api-reservas-7d9f8c4b5-x2klm) recibió "Killing" a las 14:32:01.
Los errores 502 hacia 10.244.2.17 en los logs del Ingress están fechados
entre las 14:32:01,340 y las 14:32:03,180.

El Ingress siguió enviando tráfico a ese pod durante 1,8 segundos después de que el kubelet empezara a terminarlo.

Ese es exactamente el fenómeno que estudiamos en 07-01: la carrera entre la retirada del Endpoint y el SIGTERM. Cuando Kubernetes decide terminar un pod, dos caminos avanzan en paralelo sin coordinarse: el kubelet manda SIGTERM casi al instante, mientras que la retirada del endpoint tiene que propagarse por el controlador de endpoints, kube-proxy y el controlador de Ingress. El proceso muere antes de que dejen de mandarle tráfico.

Paso 4 — Confirmar la hipótesis con el manifiesto

kubectl -n rutas-norte-pro get deploy api-reservas -o yaml | \
  grep -A 6 -E "terminationGracePeriodSeconds|lifecycle|preStop|readinessProbe"
      terminationGracePeriodSeconds: 30
      containers:
      - name: api
        readinessProbe:
          httpGet:
            path: /preparado
            port: http

Confirmado: no hay lifecycle.preStop. El manifiesto que llegó a producción se quedó sin esa parte de lo que diseñamos en 07-01.

Y una comprobación adicional que refina el diagnóstico:

kubectl -n rutas-norte-pro get deploy api-reservas \
  -o jsonpath='{.spec.strategy.rollingUpdate}'
{"maxSurge":2,"maxUnavailable":1}

Un segundo problema: maxUnavailable: 1 permite que durante el despliegue haya solo 5 pods sirviendo en lugar de 6, lo que agrava el impacto de cada pico.

Paso 5 — Reproducir en preproducción

No se arregla producción a ciegas. Se reproduce en rutas-norte-pre con carga sintética:

# Terminal 1: generar carga constante
kubectl -n rutas-norte-pre run carga --rm -it --image=williamyeh/hey --restart=Never -- \
  -z 300s -c 20 http://api-reservas/api/rutas

# Terminal 2: observar los Endpoints en tiempo real
kubectl -n rutas-norte-pre get endpointslices -l kubernetes.io/service-name=api-reservas -w

# Terminal 3: provocar el despliegue
kubectl -n rutas-norte-pre rollout restart deployment/api-reservas

Resultado: 1,4 % de errores durante el despliegue. Reproducido.

Paso 6 — La corrección

# k8s/entornos/pro/api-reservas-despliegue.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  replicas: 6
  minReadySeconds: 15
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0        # CORRECCIÓN 2: nunca menos de 6 sirviendo
  template:
    spec:
      # CORRECCIÓN 3: 45 s en total. Con el preStop de 10 s, quedan 35 s
      # para que la aplicación cierre limpiamente tras el SIGTERM.
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          # CORRECCIÓN 1 (la principal): ganar la carrera contra el SIGTERM.
          # Durante estos 10 segundos el contenedor SIGUE SIRVIENDO con
          # normalidad, mientras la retirada del endpoint se propaga por
          # el controlador de endpoints, kube-proxy y el Ingress.
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]
          readinessProbe:
            httpGet:
              path: /preparado
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2

Y una corrección complementaria en el controlador de Ingress, para que reaccione más rápido a los cambios de endpoints:

# Anotaciones en el Ingress de api-reservas
metadata:
  annotations:
    # Reintentar en otro backend si este cierra la conexión
    nginx.ingress.kubernetes.io/proxy-next-upstream: "error timeout http_502"
    nginx.ingress.kubernetes.io/proxy-next-upstream-tries: "2"

Paso 7 — Verificar

kubectl -n rutas-norte-pre apply -f k8s/entornos/pre/api-reservas-despliegue.yaml
# Repetir la prueba de carga del paso 5

Resultado: 0 errores durante el despliegue. El mismo escenario que antes producía un 1,4 %.

Se lleva a producción y se verifica en el cuadro de mando de 07-04:

sum(increase(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[10m]))

Plana en cero durante el despliegue.

Paso 8 — Prevenir la recurrencia

El incidente no está cerrado hasta que no pueda repetirse:

  1. Alerta específica (07-04): detectar errores durante los despliegues, en lugar de esperar a que un cliente se queje.
- alert: ErroresDuranteDespliegue
  expr: |
    (
      sum(rate(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[2m]))
      / sum(rate(rutasnorte_peticiones_total{entorno="pro"}[2m]))
    ) > 0.01
    and on()
    (changes(kube_deployment_status_observed_generation{
      namespace="rutas-norte-pro"}[10m]) > 0)
  for: 2m
  labels:
    severity: aviso
    equipo: plataforma
  annotations:
    summary: "Hay errores 5xx coincidiendo con un despliegue"
    description: >
      Tasa de error del {{ $value | humanizePercentage }} mientras un
      Deployment está actualizándose. Revisar preStop, readinessProbe y
      maxUnavailable del componente afectado.
    runbook_url: "https://runbooks.rutasnorte.example/errores-despliegue"
  1. Comprobación automática en la integración continua: rechazar cualquier manifiesto de un componente con Service que no declare preStop y readinessProbe.

  2. Prueba de carga durante el despliegue como paso obligatorio en rutas-norte-pre antes de promocionar a producción.

La lección del caso

Ninguna señal por sí sola resolvía el problema:

Señal Qué aportó Qué no podía aportar
Métricas (07-03/04) Que existía, cuándo y con qué patrón de tres picos Por qué
Logs (07-05) Que el 502 lo genera el Ingress, no la API; el mensaje exacto Cuándo se terminó cada pod
Eventos (07-06) La cronología exacta al milisegundo El impacto en clientes
Sondas (07-01) El marco conceptual: la carrera del SIGTERM La evidencia

Y un detalle que no es menor: los eventos originales habían caducado 20 horas antes. Sin el exportador de eventos a Elasticsearch de la sección 3, el paso 3 —el que dio la respuesta— habría sido imposible, y el equipo habría seguido con teorías.

  1. Recoger pruebas antes de reiniciar

Este apartado corto es el consejo más importante de la lección.

Cuando algo falla, el impulso universal es reiniciarlo. kubectl delete pod, kubectl rollout restart, reiniciar el nodo. Y muchas veces funciona: el síntoma desaparece.

Pero reiniciar destruye la evidencia. Y si no sabes por qué falló, va a volver a fallar, probablemente en peor momento.

Qué se pierde exactamente al borrar un pod:

Evidencia ¿Se pierde?
Logs del contenedor actual , si no están centralizados (07-05)
Logs de la ejecución anterior (--previous) Sí, irrecuperablemente
Estado de la memoria y los procesos
Descriptores de fichero y conexiones abiertas
Contenido de volúmenes emptyDir
lastState.terminated con el código de salida
Eventos del pod Sí, en cuanto pasa el TTL de 1 hora
Métricas No (Prometheus las conserva)
Contenido de PVC No

El checklist de captura

Ejecútalo antes de tocar nada. Son 30 segundos y salva la investigación:

#!/bin/bash
# capturar-evidencias.sh <namespace> <pod>
# Ejecutar SIEMPRE antes de borrar o reiniciar un pod problemático.

NS=$1
POD=$2
DIR="/tmp/incidente-$(date +%Y%m%d-%H%M%S)-${POD}"
mkdir -p "$DIR"

echo "Capturando evidencias en $DIR ..."

# 1. Definición y estado completo del pod
kubectl -n "$NS" get pod "$POD" -o yaml > "$DIR/pod.yaml"

# 2. describe: incluye los eventos, que CADUCAN EN 1 HORA
kubectl -n "$NS" describe pod "$POD" > "$DIR/describe.txt"

# 3. Logs actuales y anteriores, de TODOS los contenedores
for C in $(kubectl -n "$NS" get pod "$POD" \
           -o jsonpath='{.spec.containers[*].name} {.spec.initContainers[*].name}'); do
  kubectl -n "$NS" logs "$POD" -c "$C" > "$DIR/logs-$C.txt" 2>&1
  kubectl -n "$NS" logs "$POD" -c "$C" --previous > "$DIR/logs-$C-previo.txt" 2>&1
done

# 4. Eventos del namespace completo: la cronología del incidente
kubectl -n "$NS" get events --sort-by=.lastTimestamp -o yaml > "$DIR/eventos.yaml"
kubectl get events -A --sort-by=.lastTimestamp > "$DIR/eventos-cluster.txt"

# 5. Estado del nodo donde vive el pod
NODO=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{.spec.nodeName}')
kubectl describe node "$NODO" > "$DIR/nodo-$NODO.txt"

# 6. Estado del Service y sus Endpoints
APP=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{.metadata.labels.app}')
kubectl -n "$NS" get svc,endpointslices -l app="$APP" -o yaml > "$DIR/red.yaml"

# 7. Consumo actual (07-02)
kubectl -n "$NS" top pod "$POD" --containers > "$DIR/consumo.txt" 2>&1

# 8. Estado general del namespace, para contexto
kubectl -n "$NS" get all -o wide > "$DIR/namespace.txt"

tar czf "$DIR.tar.gz" -C /tmp "$(basename "$DIR")"
echo "Evidencias empaquetadas en $DIR.tar.gz"
echo "AHORA ya puedes reiniciar."

Cuándo sí hay que reiniciar de inmediato

Hay una excepción legítima, y hay que ser honesto con ella: cuando el servicio está caído y restaurarlo es más urgente que entenderlo.

En ese caso, el orden correcto es:

  1. Ejecutar el script de captura (30 segundos, no más).
  2. Restaurar el servicio.
  3. Investigar con las evidencias capturadas.

Nunca "reiniciar y ya investigaremos", porque a las nueve de la mañana no quedará nada que investigar.

La alternativa: aislar en vez de reiniciar

Si el problema afecta a un solo pod de varias réplicas, hay una opción mucho mejor que borrarlo: sacarlo del Service sin matarlo.

# Cambiar una etiqueta del selector: el pod sale de los Endpoints
# pero sigue vivo, con toda su memoria y su estado intactos.
kubectl -n rutas-norte-pro label pod api-reservas-7d9f8c4b5-x2klm app=api-reservas-aislado --overwrite

Efectos, todos deseables:

  • El pod deja de recibir tráfico inmediatamente: los clientes dejan de sufrirlo.
  • El ReplicaSet ve que le falta una réplica y crea un pod nuevo y sano: el servicio se restaura.
  • El pod problemático sigue vivo con todo su estado: puedes hacer exec, volcar la memoria, mirar los descriptores abiertos, investigar con calma.

Es la mejor herramienta para depurar un problema que solo se manifiesta en producción y solo en una réplica. Cuando termines:

kubectl -n rutas-norte-pro delete pod api-reservas-7d9f8c4b5-x2klm

Errores Comunes y Consejos

1. Empezar por kubectl logs sin mirar el estado. Si el pod está en Pending, no hay logs. Sigue el árbol de decisión en orden.

2. Olvidar --sort-by=.lastTimestamp en los eventos. Sin él salen en orden arbitrario y la cronología, que es lo valioso, se pierde.

3. No saber que los eventos caducan en una hora. Es el hecho más importante de esta lección. Exporta los eventos a la pila de logs.

4. Olvidar --previous ante un CrashLoopBackOff. Los logs del contenedor actual no dicen nada: acaba de arrancar. La causa está en el que murió.

5. Reiniciar antes de capturar. Destruye --previous, el estado de memoria y los eventos. Treinta segundos de captura ahorran horas.

6. No usar port-forward para aislar la capa. Es la prueba que descarta de golpe Ingress, Service, kube-proxy y NetworkPolicies.

7. Confundir 502 con 503. El 503 es "no hay backend" (Endpoints vacíos); el 502 es "el backend falló" (la carrera del SIGTERM). Causas y soluciones distintas.

8. Lanzar un pod de depuración sin las etiquetas correctas. Con deny-all en rutas-norte-pro, un netshoot sin etiquetas no habla con nada, y confundirás una NetworkPolicy con un problema de aplicación.

9. Depurar en producción modificando el pod real. Usa kubectl debug --copy-to: la copia no lleva las etiquetas del selector, no recibe tráfico y puedes hacer lo que quieras con ella.

10. Ignorar los eventos Normal. En el caso de la sección 8, el evento decisivo fue un Killing de tipo Normal. La ausencia de un evento esperado también informa.

11. No correlacionar las tres señales. Una métrica dice cuándo, un log dice qué y un evento dice en qué orden. Ninguna basta sola.

12. Cerrar el incidente al restaurar el servicio. Sin causa raíz identificada y sin medida preventiva, volverá a pasar. El paso 8 de la sección 8 no es opcional.

Ejercicios

Ejercicio 1 — Diagnosticar tres pods con la tabla maestra

Tras un despliegue en rutas-norte-pre, este es el estado:

NAME                                     READY   STATUS              RESTARTS      AGE
api-reservas-9f2c1a8b6-kp3nm             0/1     CrashLoopBackOff    7 (30s ago)   12m
worker-notificaciones-5f7c8b9d4-tz2mv    0/1     ContainerCreating   0             12m
tienda-web-6c8b9d7f4-hj3ks               1/1     Running             0             3d
redis-cache-0                            0/1     Pending             0             12m

Y estos eventos:

LAST SEEN   TYPE      REASON              OBJECT                                MESSAGE
30s         Warning   BackOff             pod/api-reservas-9f2c1a8b6-kp3nm      Back-off restarting failed container api
2m          Warning   FailedMount         pod/worker-notificaciones-5f7c8b9d4-tz2mv  MountVolume.SetUp failed for volume "credenciales-smtp": secret "credenciales-smtp-v2" not found
11m         Warning   FailedScheduling    pod/redis-cache-0                     0/3 nodes are available: 3 pod has unbound immediate PersistentVolumeClaims

Además, tienda-web devuelve 503 a los clientes pese a estar 1/1 Running.

Para cada uno de los cuatro problemas:

  1. Identifica la causa probable.
  2. Escribe la secuencia de comandos que la confirma.
  3. Propón la corrección.
  4. Explica por qué tienda-web da 503 estando Running y listo.

Ejercicio 2 — Depurar un contenedor sin shell

api-reservas se ha migrado a una imagen distroless (sin shell, sin curl, sin ps). En producción, un pod concreto de seis responde con latencias de 8 segundos mientras los otros cinco van a 40 ms. La métrica lo confirma, los logs no muestran errores, y el pod está 1/1 Running.

  1. ¿Por qué no puedes usar kubectl exec -it POD -- sh?
  2. Escribe los comandos para investigar el pod sin reiniciarlo y sin sacarlo de producción.
  3. Escribe la alternativa que lo saca de producción sin matarlo, explicando qué ganas.
  4. Una vez dentro, ¿qué tres comprobaciones concretas harías para explicar la latencia?

Ejercicio 3 — Reconstruir un incidente pasado

El domingo a las 04:12, informes-ocupacion (el CronJob de 06-03) falló. El lunes a las 10:00 te piden que expliques qué pasó. Compruebas:

$ kubectl -n rutas-norte-pro get jobs -l app=informes-ocupacion
NAME                          STATUS     COMPLETIONS   DURATION   AGE
informes-ocupacion-28936960   Failed     0/1           14m        30h

$ kubectl -n rutas-norte-pro get events --field-selector involvedObject.name=informes-ocupacion-28936960
No resources found in rutas-norte-pro namespace.

$ kubectl -n rutas-norte-pro logs job/informes-ocupacion-28936960
Error from server (BadRequest): pod for job "informes-ocupacion-28936960" not found
  1. ¿Por qué no hay eventos ni logs?
  2. Enumera todas las fuentes de información que sí siguen disponibles, con el comando o consulta concreta de cada una.
  3. Reconstruye qué pudo pasar si Prometheus muestra que el nodo rutas-norte-worker-2 estuvo con presión de memoria entre las 04:05 y las 04:30, y que el volumen datos-postgres-reservas-0 estaba al 96 %.
  4. Propón tres medidas para que el próximo incidente nocturno sí sea investigable.

Soluciones

Solución 1

Problema 1: api-reservas en CrashLoopBackOff.

Causa probable: el contenedor arranca y muere. Las candidatas, en orden: fallo de configuración, OOMKilled, o una liveness demasiado agresiva (07-01).

Confirmación:

NS=rutas-norte-pre
POD=api-reservas-9f2c1a8b6-kp3nm

# 1. La causa suele estar aquí
kubectl -n $NS logs $POD --previous

# 2. Motivo y código de salida de la terminación
kubectl -n $NS get pod $POD \
  -o jsonpath='{.status.containerStatuses[0].lastState.terminated}' | jq

# 3. ¿Hay eventos Unhealthy antes de los Killing? (liveness)
kubectl -n $NS describe pod $POD | grep -B 3 -A 12 Events

# 4. Si los logs están vacíos: lanzar una copia con el comando sustituido
kubectl -n $NS debug pod/$POD --copy-to=api-depuracion --container=api -- sleep 3600
kubectl -n $NS exec -it api-depuracion -c api -- sh

Corrección, según lo que devuelva el paso 2:

reason / exitCode Causa Corrección
OOMKilled / 137 Límite de memoria corto o fuga Subir limits.memory (07-02) o arreglar la fuga
Error / 1 con log de config Falta una variable o un Secret Corregir el ConfigMap o el Secret
Error / 127 Binario no encontrado Corregir el command o la imagen
Sin log, precedido de Unhealthy Liveness demasiado agresiva Añadir startupProbe (07-01)

Problema 2: worker-notificaciones en ContainerCreating.

Causa: el evento la da directamente: secret "credenciales-smtp-v2" not found. El manifiesto referencia un Secret que no existe en este namespace. Patrón típico: el Secret se renombró a -v2 en pro pero no se creó en pre.

Confirmación:

# ¿Existe el Secret que espera?
kubectl -n rutas-norte-pre get secret credenciales-smtp-v2

# ¿Cuál existe realmente?
kubectl -n rutas-norte-pre get secrets | grep smtp

# ¿Qué nombre referencia el Deployment?
kubectl -n rutas-norte-pre get deploy worker-notificaciones \
  -o jsonpath='{.spec.template.spec.volumes}' | jq

Corrección: crear el Secret que falta, o corregir la referencia del manifiesto:

kubectl -n rutas-norte-pre create secret generic credenciales-smtp-v2 \
  [email protected] \
  --from-literal=password="$SMTP_PASSWORD"

El pod pasa a Running automáticamente en cuanto el Secret exista: el kubelet reintenta el montaje periódicamente. No hace falta borrarlo.

Prevención: este error es un síntoma de gestionar los manifiestos por entorno a mano. Es exactamente el problema que resuelven Kustomize (10-04) y GitOps (10-05).

Problema 3: redis-cache-0 en Pending.

Causa: el evento lo dice: 3 pod has unbound immediate PersistentVolumeClaims. El PVC del StatefulSet no está enlazado.

Confirmación:

# Estado del PVC
kubectl -n rutas-norte-pre get pvc -l app=redis-cache
NAME                     STATUS    VOLUME   CAPACITY   STORAGECLASS          AGE
datos-redis-cache-0      Pending                       rutasnorte-rapida     12m
# ¿Por qué no se aprovisiona?
kubectl -n rutas-norte-pre describe pvc datos-redis-cache-0 | tail -10

# ¿Existe la StorageClass? (05-04)
kubectl get storageclass

Las tres causas típicas:

Causa Comprobación
La StorageClass no existe en este clúster kubectl get sc rutasnorte-rapida
El aprovisionador CSI está caído kubectl -n kube-system get pods | grep csi
No queda capacidad en el backend Eventos del PVC

Corrección: si es la primera (lo más probable en pre, donde puede no existir la clase rápida), crear la StorageClass o cambiar el volumeClaimTemplates a rutasnorte-estandar.

Ojo con un detalle de 06-01: volumeClaimTemplates es inmutable. No puedes cambiar la StorageClass de un StatefulSet existente; hay que borrarlo con --cascade=orphan, recrear el manifiesto y volver a crearlo.

Problema 4: tienda-web da 503 estando 1/1 Running.

Esta es la parte más instructiva del ejercicio, porque el pod parece perfecto.

Causa: un 503 del Ingress significa "no hay backend al que enviar". Si el pod está Running y Ready, la causa está entre el Service y el pod:

  1. El selector del Service no coincide con las etiquetas del pod.
  2. El puerto del Service apunta a un targetPort incorrecto.
  3. El Ingress apunta a un Service con nombre o puerto equivocado.

Confirmación, en este orden exacto:

NS=rutas-norte-pre

# 1. LA COMPROBACIÓN CLAVE: ¿hay Endpoints?
kubectl -n $NS get endpointslices -l kubernetes.io/service-name=tienda-web
NAME               ADDRESSTYPE   PORTS   ENDPOINTS   AGE
tienda-web-x4k2p   IPv4          <unset> <unset>     3d

Endpoints vacío con un pod Ready: el selector no casa.

# 2. Comparar selector y etiquetas
kubectl -n $NS get svc tienda-web -o jsonpath='{.spec.selector}'
{"app":"tienda-web","entorno":"preproduccion"}
kubectl -n $NS get pods -l app=tienda-web --show-labels
NAME                         READY   STATUS    LABELS
tienda-web-6c8b9d7f4-hj3ks   1/1     Running   app=tienda-web,entorno=pre,...

Ahí está. El Service busca entorno: preproduccion y el pod tiene entorno: pre. Los dos objetos son válidos, están sanos, y no se encuentran.

# 3. Confirmar que el pod funciona, saltándose el Service
kubectl -n $NS port-forward pod/tienda-web-6c8b9d7f4-hj3ks 8080:80
curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/
# → 200: el pod está perfecto, el problema es el selector

Corrección: alinear con la convención de Rutas Norte, que usa dev/pre/pro:

kubectl -n rutas-norte-pre patch svc tienda-web \
  -p '{"spec":{"selector":{"app":"tienda-web","entorno":"pre"}}}'

# Verificar que aparecen los Endpoints
kubectl -n rutas-norte-pre get endpointslices -l kubernetes.io/service-name=tienda-web

La lección: el estado del pod no dice nada sobre si recibe tráfico. Un pod puede estar 1/1 Running durante días sin que le llegue una sola petición. La pregunta 4 del árbol de decisión (¿el Service tiene Endpoints?) es la que hay que hacerse, y se responde en dos segundos.

Solución 2

1. Por qué falla kubectl exec -it POD -- sh.

Una imagen distroless contiene solo el runtime del lenguaje y la aplicación: no hay /bin/sh, ni /bin/bash, ni ningún binario de utilidad. Es una decisión de seguridad excelente (superficie de ataque mínima, que veremos en 08-05), pero impide el exec clásico:

$ kubectl -n rutas-norte-pro exec -it api-reservas-9f2c1a8b6-kp3nm -- sh
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknown

2. Investigar sin reiniciar y sin sacarlo de producción: contenedor efímero.

NS=rutas-norte-pro
POD=api-reservas-9f2c1a8b6-kp3nm

kubectl -n $NS debug -it $POD \
  --image=nicolaka/netshoot:latest \
  --target=api \
  --profile=general \
  -- bash

Claves de este comando:

  • --target=api comparte el namespace de procesos con el contenedor api, lo que permite ver e inspeccionar su proceso.
  • El contenedor efímero comparte además el namespace de red: misma IP, mismos puertos, misma vista de la red.
  • El pod sigue sirviendo tráfico durante toda la sesión. No se reinicia nada.

Advertencia: el contenedor efímero no se puede quitar. Se queda en el pod hasta que el pod muera. Con seis réplicas, eso es aceptable.

3. La alternativa que lo saca de producción sin matarlo.

# Cambiar la etiqueta del selector
kubectl -n rutas-norte-pro label pod api-reservas-9f2c1a8b6-kp3nm \
  app=api-reservas-cuarentena --overwrite

Qué ganas exactamente:

Efecto Beneficio
Sale de los Endpoints al instante Los clientes dejan de sufrir los 8 segundos de latencia
El ReplicaSet crea un pod nuevo El servicio vuelve a tener 6 réplicas sanas
El pod problemático sigue vivo Conservas memoria, conexiones, descriptores y estado
Sin tráfico entrante Puedes hacer volcados y perfilados sin afectar a nadie

Es estrictamente superior a kubectl delete pod: restauras el servicio igual de rápido y conservas toda la evidencia.

Alternativa complementaria con --copy-to, si quieres experimentar con cambios:

kubectl -n rutas-norte-pro debug $POD \
  --copy-to=api-analisis \
  --image=nicolaka/netshoot:latest \
  --share-processes -it -- bash

4. Las tres comprobaciones para explicar la latencia.

Comprobación A — ¿Está el proceso saturado o bloqueado?

# ¿Cuánta CPU consume realmente?
top -b -n 1 -p 1

# ¿En qué está bloqueado? Estado D = espera de E/S ininterrumpible
cat /proc/1/status | grep -E "State|Threads|VmRSS"

# ¿Qué llamadas al sistema hace? (si hay permiso de traza)
strace -p 1 -c -f -T 2>&1 | head -25

Un proceso con CPU al 100 % apunta a un bucle o a un GC descontrolado. Un proceso en estado D con CPU baja apunta a espera de red o de disco: la respuesta más probable en este caso.

Comprobación B — ¿A dónde van las conexiones y cuántas hay?

# Conexiones establecidas, agrupadas por destino
ss -tanp state established | awk '{print $5}' | sort | uniq -c | sort -rn
     20 10.244.3.88:5432      ← postgres-reservas: 20 conexiones (el pool LLENO)
      1 10.244.1.45:6379      ← redis-cache

Veinte conexiones a PostgreSQL con un pool de máximo 20: el pool está agotado. Es el escenario exacto de 07-01. Cada petición nueva espera a que se libere una conexión, y de ahí los 8 segundos.

# ¿Hay conexiones acumuladas en la cola de escucha?
ss -tln | grep 8080
# La columna Recv-Q muestra peticiones esperando a ser aceptadas

Comprobación C — ¿Está la latencia en nuestro lado o en el de PostgreSQL?

# Medir la latencia real hacia la base de datos desde ESTE pod
for i in $(seq 1 10); do
  time nc -zv postgres-reservas.rutas-norte-pro.svc.cluster.local 5432
done

# ¿Resuelve el DNS con normalidad? Un DNS lento explica latencias raras
time nslookup postgres-reservas.rutas-norte-pro.svc.cluster.local

# El endpoint de preparación de 07-01 responde con el diagnóstico
curl -s -w "\ntiempo: %{time_total}s\n" localhost:8080/preparado
{"estado":"no-preparado","motivo":"pool de conexiones agotado","comprobaciones":{"poolLibre":false}}

Confirmado. Y queda la pregunta de por qué solo este pod de seis. Dos hipótesis a contrastar con las métricas de 07-03:

# ¿Recibe este pod más tráfico que los demás?
sum by (pod) (rate(rutasnorte_peticiones_total{entorno="pro"}[5m]))

# ¿Está el pod en un nodo con problemas?
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{
  namespace="rutas-norte-pro", pod=~"api-reservas-.*"}[5m]))
  / sum by (pod) (rate(container_cpu_cfs_periods_total{
  namespace="rutas-norte-pro", pod=~"api-reservas-.*"}[5m]))

Si el throttling de CPU está alto solo en este pod, la explicación es que comparte nodo con informes-ocupacion o con otro proceso pesado, y su liberación de conexiones va más lenta que la de los demás. Solución: una regla de anti-afinidad (06-05) o revisar los límites (07-02).

Solución 3

1. Por qué no hay eventos ni logs.

No hay eventos porque caducan en una hora. El fallo fue a las 04:12 del domingo; son las 10:00 del lunes: han pasado 30 horas. El API Server los borró a las 05:12 del domingo, 29 horas antes.

No hay logs porque el pod ya no existe. Un CronJob con failedJobsHistoryLimit (por defecto 1) conserva el objeto Job, pero los pods se recogen cuando se supera el límite o cuando actúa el recolector de basura. kubectl logs job/... necesita que el pod exista: solo lee del fichero del nodo.

Y aunque el pod existiera, la rotación del kubelet (07-05) probablemente ya se habría llevado el fichero.

2. Fuentes que sí siguen disponibles.

Fuente Qué aporta Comando o consulta
El objeto Job Estado, tiempos, número de intentos kubectl -n rutas-norte-pro get job informes-ocupacion-28936960 -o yaml
Logs centralizados (07-05) El log completo del pod componente: "informes-ocupacion" and @timestamp >= "2026-08-02T04:00:00Z"
Eventos exportados (07-06) La cronología, superviviente al TTL componente: "kubernetes-eventos" and objeto_nombre: informes-ocupacion-*
Prometheus (07-03) Estado del job, del nodo y de los recursos kube_job_status_failed, container_memory_working_set_bytes
Grafana (07-04) Los cuadros de mando con el rango puesto en el incidente Panel de informes-ocupacion
Alertmanager Qué alertas dispararon y cuándo Histórico de alertas o el canal de Slack
kube-state-metrics Motivo de la última terminación kube_pod_container_status_last_terminated_reason
Git Si hubo un cambio de configuración git log --since="2026-08-01" -- k8s/

Comandos y consultas concretas:

# El objeto Job conserva mucha información
kubectl -n rutas-norte-pro get job informes-ocupacion-28936960 -o yaml | \
  yq '.status, .spec.backoffLimit, .spec.activeDeadlineSeconds'
status:
  conditions:
    - type: Failed
      status: "True"
      reason: BackoffLimitExceeded
      message: Job has reached the specified backoff limit
      lastTransitionTime: "2026-08-02T04:26:11Z"
  failed: 3
  startTime: "2026-08-02T04:12:00Z"

Ya sabemos: empezó a las 04:12, falló tres veces y se dio por perdido a las 04:26.

En Kibana:

componente: "informes-ocupacion"
  and @timestamp >= "2026-08-02T04:00:00.000Z"
  and @timestamp <= "2026-08-02T04:30:00.000Z"

Y en Prometheus:

# Memoria del nodo durante el incidente
node_memory_MemAvailable_bytes{instance=~"rutas-norte-worker-2.*"}

# Espacio del volumen de PostgreSQL
kubelet_volume_stats_available_bytes{persistentvolumeclaim="datos-postgres-reservas-0"}
  / kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="datos-postgres-reservas-0"}

# Motivo de la última terminación de los pods del job
kube_pod_container_status_last_terminated_reason{
  namespace="rutas-norte-pro", pod=~"informes-ocupacion-.*"}

# Desalojos en ese nodo
increase(kube_pod_status_reason{reason="Evicted", node="rutas-norte-worker-2"}[1h])

3. Reconstrucción del incidente.

Con los dos datos aportados (presión de memoria en worker-2 entre 04:05 y 04:30, y el volumen de PostgreSQL al 96 %), la reconstrucción más probable es:

~04:00  El backup con pg_dump programado (05-06) arranca y escribe el volcado
        en el volumen de postgres-reservas, que ya estaba muy lleno.
        El volumen llega al 96%.

04:05   La escritura intensiva satura la caché de página del nodo worker-2.
        La memoria disponible del nodo empieza a caer.

04:12   El CronJob informes-ocupacion arranca su primer pod, planificado en
        worker-2 (el nodo con más CPU libre en ese momento). El informe
        necesita ~800 Mi de memoria y hace consultas pesadas contra
        postgres-reservas.

04:14   El kubelet de worker-2 detecta presión de memoria y comienza a
        desalojar pods, empezando por los de menor QoS. El pod del job,
        con QoS Burstable, es candidato preferente frente a los pods
        Guaranteed de la plataforma.
        → El pod es desalojado (Evicted) o muere OOMKilled.

04:15   El controlador de Job reintenta (intento 2 de 3).
        El nodo sigue bajo presión. Vuelve a fallar.

04:20   Tercer intento. Además, ahora es posible que las consultas contra
        PostgreSQL también fallen o vayan lentísimas, porque el volumen
        al 96% degrada el rendimiento de escritura de los ficheros
        temporales que necesita la consulta de agregación.

04:26   BackoffLimitExceeded: el Job se marca como Failed.
        No hay informe de ocupación del domingo.

Confirmación de la hipótesis (los comandos que la validan o la descartan):

# ¿Hubo desalojos en worker-2? Confirmaría la teoría del desalojo
increase(kube_pod_status_reason{reason="Evicted", node="rutas-norte-worker-2"}[2h])

# ¿O fue OOMKilled? Sería una hipótesis alternativa
kube_pod_container_status_last_terminated_reason{
  pod=~"informes-ocupacion-.*", reason="OOMKilled"}

Y en los logs:

componente: "kubernetes-eventos"
  and objeto_nombre: informes-ocupacion-*
  and razon: ("Evicted" or "OOMKilling" or "Failed")

Un evento Evicted con el mensaje The node was low on resource: memory confirmaría la reconstrucción completa.

Nota metodológica importante: esto es una hipótesis coherente con los datos, no un hecho probado. La diferencia importa. Con las medidas del punto 4, el próximo incidente permitirá afirmarlo con certeza en lugar de deducirlo.

4. Tres medidas para que el próximo sea investigable.

Medida A — Exportar los eventos a la pila de logs. Es la carencia que ha causado el 80 % de la dificultad de este ejercicio.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: event-exporter
  namespace: registro
spec:
  replicas: 1
  selector:
    matchLabels:
      app: event-exporter
  template:
    metadata:
      labels:
        app: event-exporter
        app.kubernetes.io/part-of: rutas-norte
    spec:
      serviceAccountName: event-exporter   # RBAC de solo lectura sobre events
      containers:
        - name: exporter
          image: ghcr.io/resmoio/kubernetes-event-exporter:v1.7
          args: ["-conf=/etc/config/config.yaml"]
          volumeMounts:
            - name: config
              mountPath: /etc/config
          resources:
            requests:
              cpu: "20m"
              memory: "64Mi"
            limits:
              memory: "128Mi"
      volumes:
        - name: config
          configMap:
            name: event-exporter-config

Con la configuración de la sección 3, que emite a stdout y por tanto hereda toda la pila de 07-05, con sus 30 días de retención. Coste: unos 64 Mi de memoria. Beneficio: nunca volver a perder la cronología de un incidente.

Medida B — Conservar el histórico de Jobs y evitar la recogida de pods.

# k8s/base/informes-ocupacion/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: informes-ocupacion
  namespace: rutas-norte-pro
spec:
  schedule: "12 4 * * *"
  timeZone: "Europe/Madrid"
  concurrencyPolicy: Forbid

  # Conservar los últimos 7 fallos y 3 éxitos, con SUS PODS.
  # Por defecto es 1 y 3: se pierde casi todo.
  failedJobsHistoryLimit: 7
  successfulJobsHistoryLimit: 3

  jobTemplate:
    spec:
      backoffLimit: 3
      activeDeadlineSeconds: 3600
      # Mantener los pods completados 48 h antes de recogerlos:
      # tiempo suficiente para investigar un fallo del fin de semana.
      ttlSecondsAfterFinished: 172800
      template:
        spec:
          restartPolicy: Never
          # QoS Guaranteed: el pod ya NO es candidato preferente al desalojo
          # cuando el nodo tiene presión de memoria.
          containers:
            - name: informes
              image: registry.rutasnorte.example/informes-ocupacion:1.9.2
              resources:
                requests:
                  cpu: "1"
                  memory: "1Gi"
                limits:
                  cpu: "1"
                  memory: "1Gi"
          # Evitar el nodo donde vive postgres-reservas: el informe hace
          # consultas pesadas y no debe competir con la base de datos por
          # la memoria del mismo nodo (06-05).
          affinity:
            podAntiAffinity:
              preferredDuringSchedulingIgnoredDuringExecution:
                - weight: 100
                  podAffinityTerm:
                    labelSelector:
                      matchLabels:
                        app: postgres-reservas
                    topologyKey: kubernetes.io/hostname

Medida C — Alertas que avisen en el momento, no 30 horas después.

# Ya la teníamos en 07-04, pero verificamos que el for no la retrase de más
- alert: CronJobInformesNoEjecutado
  expr: |
    time() - max(kube_job_status_completion_time{job_name=~"informes-ocupacion.*"}) > 100000
  for: 30m
  labels:
    severity: aviso
    componente: informes-ocupacion
    equipo: datos

# NUEVA: avisar del fallo en el momento, no de la ausencia acumulada
- alert: JobInformesFallido
  expr: |
    kube_job_status_failed{namespace="rutas-norte-pro", job_name=~"informes-ocupacion.*"} > 0
  for: 5m
  labels:
    severity: aviso
    componente: informes-ocupacion
    equipo: datos
  annotations:
    summary: "El job {{ $labels.job_name }} ha fallado"
    description: >
      Investigar AHORA, mientras los eventos y los logs del pod todavía
      existen. Ejecutar capturar-evidencias.sh antes de tocar nada.
    runbook_url: "https://runbooks.rutasnorte.example/cronjob-informes"

# NUEVA: la causa raíz probable, avisada con antelación (07-04)
- alert: NodoConPresionDeMemoria
  expr: kube_node_status_condition{condition="MemoryPressure", status="true"} == 1
  for: 5m
  labels:
    severity: aviso
    equipo: plataforma
  annotations:
    summary: "El nodo {{ $labels.node }} tiene presión de memoria"
    description: >
      El kubelet está desalojando pods. Los de QoS Burstable y BestEffort
      son los primeros candidatos. Revisar qué se está ejecutando ahí.

Con estas tres medidas, el mismo incidente el próximo domingo produciría: una alerta a las 04:17 (fallo del job) y otra a las 04:10 (presión de memoria en el nodo), los eventos completos en Elasticsearch, los logs del pod conservados 48 horas y el pod fallido todavía en el clúster el lunes por la mañana. De reconstruir con hipótesis a confirmar con evidencia.

Conclusión

Cerramos el módulo 7. Hemos pasado de una plataforma completamente opaca a una que se ve, se mide y se explica.

En esta última lección hemos construido lo que faltaba: el método.

  • Una metodología de cinco preguntas en orden —¿existe?, ¿está programado?, ¿arrancó?, ¿está listo?, ¿recibe tráfico?— que evita el error más caro de la depuración: mirar donde no está la respuesta.
  • Los eventos como la fuente principal y peor aprovechada, con --sort-by=.lastTimestamp como comando imprescindible, y sobre todo con el hecho crítico de que caducan en una hora, lo que obliga a exportarlos si se quiere investigar algo que pasó anoche.
  • La tabla maestra de síntoma → causa probable → comando que lo confirma, cubriendo Pending, ImagePullBackOff, CrashLoopBackOff, OOMKilled y el código 137, ContainerCreating atascado, Terminating eterno, 0/n Ready y la distinción entre el 502 y el 503 del Ingress.
  • Las herramientas de inspección, con port-forward como la prueba de aislamiento más valiosa y kubectl debug en sus tres modos: contenedores efímeros para depurar un pod distroless sin reiniciarlo, --copy-to para experimentar sin tocar producción, y node/ para entrar en la máquina.
  • Y hemos cerrado, por fin, el incidente de los 502 durante los despliegues que arrastrábamos desde 02-04: la métrica dijo cuándo y con qué patrón, los logs dijeron que el 502 lo generaba el Ingress y no la API, los eventos exportados dieron la cronología al milisegundo, y las sondas de 07-01 dieron el marco para entenderlo. Ninguna señal sola bastaba.
  • Con el consejo que más incidentes salva: recoger pruebas antes de reiniciar, porque un kubectl delete pod destruye --previous, el estado de memoria y los eventos; y la alternativa mejor, aislar cambiando una etiqueta para restaurar el servicio sin perder la evidencia.

Rutas Norte se ve. Cada componente declara si está sano, cada consumo queda registrado, cada petición deja rastro, cada alerta llega a quien puede actuar y cada incidente puede reconstruirse.

Y ahí aparece el problema siguiente, que es incómodo precisamente porque lo hemos construido nosotros.

Todo lo que hemos montado en este módulo asume que quien accede al clúster es alguien de confianza. Pero hoy, en rutas-norte-pro, cualquiera con un kubectl configurado puede leer el Secret con la contraseña de postgres-reservas y, con ella, el nombre, el DNI, el teléfono y el correo de todos los clientes de Rutas Norte. Puede desplegar un contenedor privilegiado que monte el sistema de ficheros del nodo y lea los secretos de los demás pods. Puede ejecutar kubectl debug node/ y controlar la máquina. Puede desplegar una imagen que nadie ha revisado y que ejecuta lo que quiera como root. Y el sistema de auditoría que nos diría quién hizo qué, sencillamente, no existe.

En el módulo 8 cerramos esa brecha: RBAC para que cada persona y cada ServiceAccount tengan exactamente los permisos que necesitan y ni uno más; contextos de seguridad para que ningún contenedor corra como root ni pueda escalar privilegios; los Pod Security Standards para que esas reglas se apliquen a nivel de namespace sin depender de la buena voluntad de quien escribe el manifiesto; seguridad de red, seguridad de imágenes y, por fin, auditoría. Porque una plataforma que se ve pero que cualquiera puede vulnerar no está lista para producción.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados