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
- Una metodología, no una lista de comandos
- El árbol de decisión: del síntoma a la causa
- Los eventos: la fuente principal y peor aprovechada
- La tabla maestra: síntoma → causas probables → comando
- Diagnóstico detallado de cada síntoma
- Las herramientas de inspección
kubectl debug: contenedores efímeros, copias y nodos- Caso real: la tienda devuelve 502 durante los despliegues
- Recoger pruebas antes de reiniciar
- Errores comunes y consejos
- Ejercicios
- 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.
- 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/saludLa 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}'Ahí se ve directamente qué pods están recibiendo tráfico y cuáles no.
- 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 -20Salida 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 apiEsa 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-ttlLas 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 describeno mostrará nada útil, aunque el problema siga presente. - Un pod que lleva tres días en
CrashLoopBackOffsolo 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).jsonOpció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:
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.
- 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 |
- Diagnóstico detallado de cada síntoma
Pending: el planificador no lo coloca
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.taintsUn 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:
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=16GiEs 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 imageDiferencia 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ó.
{"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 restartedSi 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 -- shDentro 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 completoOOMKilled 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 foundLas 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 anotherSe 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
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.
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=mergeCausa 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.
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.
0/3 Ready: las sondas fallan
Running con 0/1 es la firma exacta de una readiness fallando. El contenedor está vivo, pero no entra en los Endpoints.
Warning Unhealthy 30s (x48 over 8m) kubelet Readiness probe failed:
HTTP probe failed with statuscode: 503El 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-labelsUna 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.
- Las herramientas de inspección
kubectl describe: la vista completa
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:8080El 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 8080Aviso 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.
kubectl debug: contenedores efímeros, copias y nodos
kubectl debug: contenedores efímeros, copias y nodoskubectl 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:
PID USER COMMAND
1 node node /app/server.js ← el proceso de api-reservas
28 root bash ← nuestra sesión de depuraciónY 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 -tulpnPuntos 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.0Por 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:
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:
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-reservasAdvertencia 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 |
- 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:
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:
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:
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 apiY 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: httpConfirmado: 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:
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-reservasResultado: 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: 2Y 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 5Resultado: 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:
Plana en cero durante el despliegue.
Paso 8 — Prevenir la recurrencia
El incidente no está cerrado hasta que no pueda repetirse:
- 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"-
Comprobación automática en la integración continua: rechazar cualquier manifiesto de un componente con Service que no declare
preStopyreadinessProbe. -
Prueba de carga durante el despliegue como paso obligatorio en
rutas-norte-preantes 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.
- 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 | Sí, si no están centralizados (07-05) |
Logs de la ejecución anterior (--previous) |
Sí, irrecuperablemente |
| Estado de la memoria y los procesos | Sí |
| Descriptores de fichero y conexiones abiertas | Sí |
Contenido de volúmenes emptyDir |
Sí |
lastState.terminated con el código de salida |
Sí |
| 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:
- Ejecutar el script de captura (30 segundos, no más).
- Restaurar el servicio.
- 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 --overwriteEfectos, 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:
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 12mY 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 PersistentVolumeClaimsAdemás, tienda-web devuelve 503 a los clientes pese a estar 1/1 Running.
Para cada uno de los cuatro problemas:
- Identifica la causa probable.
- Escribe la secuencia de comandos que la confirma.
- Propón la corrección.
- Explica por qué
tienda-webda 503 estandoRunningy 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.
- ¿Por qué no puedes usar
kubectl exec -it POD -- sh? - Escribe los comandos para investigar el pod sin reiniciarlo y sin sacarlo de producción.
- Escribe la alternativa que lo saca de producción sin matarlo, explicando qué ganas.
- 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- ¿Por qué no hay eventos ni logs?
- Enumera todas las fuentes de información que sí siguen disponibles, con el comando o consulta concreta de cada una.
- Reconstruye qué pudo pasar si Prometheus muestra que el nodo
rutas-norte-worker-2estuvo con presión de memoria entre las 04:05 y las 04:30, y que el volumendatos-postgres-reservas-0estaba al 96 %. - 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 -- shCorrecció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}' | jqCorrecció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:
# ¿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 storageclassLas 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:
- El selector del Service no coincide con las etiquetas del pod.
- El puerto del Service apunta a un
targetPortincorrecto. - 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-webEndpoints vacío con un pod Ready: el selector no casa.
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 selectorCorrecció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-webLa 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: unknown2. 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 \
-- bashClaves de este comando:
--target=apicomparte el namespace de procesos con el contenedorapi, 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 --overwriteQué 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 -- bash4. 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 -25Un 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-cacheVeinte 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 aceptadasComprobació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-configCon 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/hostnameMedida 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=.lastTimestampcomo 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,OOMKilledy el código 137,ContainerCreatingatascado,Terminatingeterno,0/n Readyy la distinción entre el502y el503del Ingress. - Las herramientas de inspección, con
port-forwardcomo la prueba de aislamiento más valiosa ykubectl debugen sus tres modos: contenedores efímeros para depurar un poddistrolesssin reiniciarlo,--copy-topara experimentar sin tocar producción, ynode/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 poddestruye--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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
