Ronda de Diseño ML · Orquestación · ejemplos de C01
OrquestaciónPara escribir y leer manifests con la documentación abierta, operar con kubectl y diagnosticar un pod roto rápido. Docker construye la imagen; Kubernetes la corre, la escala y la reinicia. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y un laboratorio opcional para un cluster local. En la versión web, al final hay un diagnosticador de síntomas, un revisor de YAML y un quiz de errores.
Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verde
En las figuras:lo que pides (spec)listoesperandofallaGPUcontrol plane
¿Qué estados, números y códigos conviene saberse de memoria para leer un pod roto en segundos?
Sintaxis con variables
kubectl get pods -n namespace -o wide
Un pod es la unidad mínima de Kubernetes: uno o varios contenedores. Tres columnas cuentan su historia: STATUS (en qué paso está), READY (contenedores listos / total) y RESTARTS (cuántas veces lo reinició el kubelet, el agente que corre en cada máquina o nodo).
kubectl describe pod pod -n namespaceBusca Last State: Terminated con su Reason y su Exit Code: cómo murió el intento anterior. Al final, los Events dicen qué pasó y en qué orden.
kubectl logs pod -n namespace --previous
El log del contenedor que murió. Sin --previous ves el intento actual, que quizás todavía no falló.
kubectl explain recurso.campo
El cluster te explica el campo y, si tiene default, casi siempre lo dice, para tu versión. Ejemplo: kubectl explain deployment.spec.progressDeadlineSeconds.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| namespace | carpeta lógica donde vive el pod | classifier |
| pod | nombre del pod, como sale en get pods | triton-5c7d9b8f6d-q2x7k (ilustrativo) |
| recurso | tipo de objeto, en minúsculas | deployment |
| campo | ruta del campo dentro del objeto | spec.strategy.rollingUpdate.maxSurge |
| número de la señal que mató al proceso (kill -l las lista) | 9 = SIGKILL, 15 = SIGTERM | |
| plazo total para apagarse: terminationGracePeriodSeconds | 45 s en el gateway, 60 s en Triton | |
| lo que dura el preStop (un comando que corre antes de apagar); va dentro del plazo, antes del SIGTERM | 15 s en el gateway, 10 s en Triton | |
| lo que le queda a tu proceso después del SIGTERM | 45 − 15 = 30 s; uvicorn usa hasta 20 | |
| número de la espera: el primer reinicio es inmediato, y la espera 1 va antes del segundo | la 4.ª espera es de 80 s; de la 6.ª en adelante, 300 s | |
| réplicas deseadas; surge y faltantes son los pods de más y de menos que permite un rolling update (el cambio de versión de a poco) | N = 3 → surge 1 y faltantes 0; N = 8 → 2 y 2 |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Pending | El cluster aceptó el pod, pero todavía no tiene nodo o no terminó de preparar sus contenedores. | Triton esperando una GPU libre |
| ContainerCreating | Ya tiene nodo: el kubelet baja la imagen, monta volúmenes y crea el contenedor. | pull de la imagen de Triton (varios GB) |
| Running con READY 0/1 | El proceso corre, pero falla la readinessProbe (el chequeo de ¿le mando tráfico?): no recibe requests. | Triton cargando el engine |
| CrashLoopBackOff | Se cae una y otra vez; el kubelet espera antes de reiniciarlo: 10, 20, 40… hasta 300 s. | RESTARTS sube cada vez más lento |
| ImagePullBackOff / ErrImagePull | No pudo bajar la imagen; reintenta con espera creciente. | tag inexistente o nodo sin permiso en el registry (ECR, el de AWS) |
| CreateContainerConfigError | No pudo armar el contenedor: falta su configuración (Secret, ConfigMap) o pediste no-root (runAsNonRoot) y la imagen es root. | secret "hf-token" not found |
| Completed / Error | Motivo que anota el runtime de contenedores al terminar: salió con 0 / salió con otro código. | Error con Exit Code 1: una excepción de Python |
| OOMKilled · 137 | Superó su límite de memoria: el kernel lo mató con SIGKILL (128 + 9), una señal que no se atrapa. | pico de RAM al cargar pesos, sin una línea de log |
| 137 sin OOMKilled | También SIGKILL, por otra causa: casi siempre no salió dentro del plazo de gracia al borrarlo o reiniciarlo. | Reason: Error y Exit Code: 137 tras un deploy |
| 143 | 128 + 15: murió por el SIGTERM (la señal de apágate). Normal en un deploy o al bajar réplicas. | un pod viejo de un rolling update |
| StartError · 128 | El runtime (containerd) no pudo ni arrancar el proceso: el comando no existe o no es ejecutable. | exec: "uvicorn": executable file not found in $PATH |
| 126 / 127 | Solo aparecen si un shell dentro del contenedor los devuelve: no es ejecutable / no existe. | command: ["sh", "-c", "uvicorn …"] en una imagen sin uvicorn |
| Terminating | Tiene orden de borrado: corre el preStop, recibe SIGTERM y tiene hasta el plazo de gracia para salir. | hasta 45 s en el gateway |
| Evicted | El kubelet lo sacó del nodo porque se quedaba sin memoria o sin disco; el pod queda en Failed. | The node was low on resource: memory |
| Campo | Default | En el C01 | Por qué cambia |
|---|---|---|---|
| terminationGracePeriodSeconds | 30 s | gateway 45 · Triton 60 | preStop + apagado del servidor + margen |
| probes: periodSeconds / timeoutSeconds / failureThreshold | 10 s / 1 s / 3 | readiness del gateway y de Triton: 5 s / 2 s / 2 | salir del Service en ≈ 10 s, con 2 s de holgura por chequeo |
| probes: initialDelaySeconds / successThreshold | 0 s / 1 | liveness (¿reiniciar?) del gateway: 5 s · Triton: startupProbe (¿ya arrancó?) | la carga del modelo la cubre la startupProbe (60 × 5 s), no un retraso fijo |
| rollingUpdate.maxSurge | 25 %, redondea hacia arriba | gateway 25 % · Triton 1 | cada pod extra de Triton es una GPU extra |
| rollingUpdate.maxUnavailable | 25 %, redondea hacia abajo | 0 en los dos | nunca bajar de la capacidad actual durante un deploy |
| revisionHistoryLimit | 10 | 5 | ReplicaSets viejos para rollout undo; 5 alcanzan |
| progressDeadlineSeconds | 600 s | Triton 900 | un pod con nodo GPU nuevo tarda ≈ 4–11 min (estimación del curso) |
| minReadySeconds | 0 | sin cambiar | el pod cuenta como disponible apenas está Ready |
| Espera entre reinicios | 1.er reinicio inmediato; después 10 s, ×2 hasta 300 s; vuelve a 10 s tras 10 min bien | — | es del kubelet; el tope se ajusta por nodo (maxContainerRestartPeriod, beta desde 1.35) |
| HPA (autoscaler de réplicas) | cada 15 s · tolerancia 10 % · bajar tras 300 s de estabilización | gateway: baja 1 pod por minuto | no oscilar con el tráfico del día |
| Service | type ClusterIP · targetPort = port | targetPort: http (por nombre) | el número del puerto puede cambiar sin tocar el Service |
| ALB: deregistration delay / idle timeout | 300 s / 60 s | 30 s / 60 s | con 300 s, cada target viejo puede quedar hasta 5 min drenando |
Verbos de bolsillo, en orden: get (en qué paso está) → describe (por qué) → logs --previous (qué dijo al morir) → events (en qué orden pasó todo). Y kubectl explain te da el default de cualquier campo en la versión de tu propio cluster.
spec:
# preStop (15 s) + --timeout-graceful-shutdown de uvicorn (20 s) + margen
terminationGracePeriodSeconds: 45
# …
containers:
- name: gateway
# …
lifecycle:
preStop:
exec:
# El ALB tarda en desregistrar el target: seguimos atendiendo 15 s antes del SIGTERM
command: ["sleep", "15"]terminationGracePeriodSeconds: 45 es el plazo total (t_gracia). Empieza a contar cuando se ordena borrar el pod; sin el campo serían 30 s.preStop es un hook: algo que el kubelet corre dentro del contenedor antes de apagarlo. Aquí duerme 15 s dentro del plazo, y el pod sigue atendiendo mientras el balanceador (ALB) deja de mandarle requests.--timeout-graceful-shutdown 20, en su Dockerfile): sobran 10 de margen.Exit Code: 137 con Reason: Error, no OOMKilled. Si el preStop todavía corre al vencer el plazo, el kubelet da una sola prórroga de 2 s.docker stop -t (10 s por defecto); en Kubernetes lo fija el pod, y su default es 30 s.Cómo recordarla
Resta 128 y busca la señal: 137 = 9 = KILL (memoria si Reason dice OOMKilled; si dice Error, casi siempre se acabó el plazo) y 143 = 15 = TERM (apagado pedido). Los defaults que más muerden: 30 s de gracia, probes 10 s / 1 s / 3 y rollout 25 % / 25 %.
Trampa
Copiar la tabla de exit codes de Docker. Con containerd (el runtime que usan EKS en AWS y kind en tu Mac), un comando que no existe no da 127: da Reason: StartError y Exit Code: 128, y el 125 no aparece (lo da el comando docker, no el runtime). Y leer 137 como falta de memoria sin mirar Reason.
En el curso: Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Docker y kubectl · kubectl: mirar, entrar y conectar M6 · Los objetos que sí debes conocer M6 · Alta disponibilidad: multi-AZ, PDB y N+1
Qué vas a provocar: Provocar Completed, Error, OOMKilled (137) y un apagado lento por el plazo de gracia, y leer cada uno donde Kubernetes lo deja.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, e internet para bajar busybox:1.38 y python:3.12-slim (la misma del lab de OOM del cheat sheet de Docker)
kubectl create namespace lab-k8s
kubectl run ok -n lab-k8s --image=busybox:1.38 --restart=Never --command -- sh -c 'exit 0'
kubectl run falla -n lab-k8s --image=busybox:1.38 --restart=Never --command -- sh -c 'exit 3'Qué deberías ver: namespace/lab-k8s created, pod/ok created y pod/falla created. --restart=Never crea pods que nadie reinicia.
sleep 20; kubectl get pods -n lab-k8sQué deberías ver: ok en Completed y falla en Error, los dos con READY 0/1 y RESTARTS 0.
kubectl get pod falla -n lab-k8s -o jsonpath='{.status.containerStatuses[0].state.terminated.reason} {.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'Qué deberías ver: Error 3: el código es el que devolvió tu proceso; Error es el motivo que anota el runtime para cualquier código distinto de 0.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: oom
spec:
restartPolicy: Never
containers:
- name: py
image: python:3.12-slim
command: ["python", "-c", "x = b'x' * (200 * 1024 * 1024)"]
resources:
requests: { memory: 64Mi }
limits: { memory: 64Mi }
EOF
sleep 30; kubectl get pod oom -n lab-k8s
kubectl describe pod oom -n lab-k8s | grep -E 'Reason|Exit Code'Qué deberías ver: STATUS OOMKilled, y en describe Reason: OOMKilled y Exit Code: 137. Python pidió 200 MiB con un límite de 64 MiB y el kernel lo mató sin aviso. Si sigue en ContainerCreating, todavía baja la imagen: espera y repite.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: lento
spec:
terminationGracePeriodSeconds: 10
containers:
- name: app
image: busybox:1.38
command: ["sleep", "3600"]
EOF
kubectl wait --for=condition=Ready pod/lento -n lab-k8s --timeout=60s
time kubectl delete pod lento -n lab-k8sQué deberías ver: El delete tarda ≈ 10 s: sleep es el primer proceso del contenedor (PID 1) y no maneja SIGTERM, y a un PID 1 sin manejador el kernel no le entrega esa señal. Sigue vivo hasta que vence el plazo y llega el SIGKILL.
kubectl run rapido -n lab-k8s --image=busybox:1.38 --command -- sh -c 'trap "exit 0" TERM; sleep 3600 & wait'
kubectl wait --for=condition=Ready pod/rapido -n lab-k8s --timeout=60s
time kubectl delete pod rapido -n lab-k8sQué deberías ver: Uno o dos segundos: el shell atrapa el SIGTERM y sale solo, sin esperar el plazo (30 s por defecto).
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted, tras unos segundos: borra todo lo que había adentro.
Cómo arreglarlo y confirmarlo
Cada código se arregla en otro lado. Error con un código: kubectl logs (con --previous si se reinició). OOMKilled: un límite de memoria medido, no adivinado. Un apagado que siempre agota el plazo: un proceso que atrape SIGTERM (uvicorn y Triton lo hacen) o un plazo más largo.
Verificado contra la documentación; no ejecutado aquí.
¿Qué pasa, en orden, entre tu kubectl apply y el primer request que llega a un pod?
Sintaxis con variables
kubectl apply -f archivo --dry-run=server
El API server (la puerta del cluster) revisa el esquema, tus permisos y sus reglas de admisión (admission), sin guardar nada. Lo que falla aquí casi siempre fallaría igual en el apply real.
kubectl apply -f archivoGuarda el estado deseado en el cluster y vuelve en segundos: no espera a que los pods (las unidades que corren tus contenedores) estén listos.
kubectl rollout status deployment/deploy -n namespace --timeout=plazo
Espera el resto de la cadena: pods creados, con nodo, arrancados y Ready. Sale con error si vence el plazo.
kubectl get endpointslices -n namespace -l kubernetes.io/service-name=servicio
El último eslabón: las IPs de los pods detrás del Service (el nombre fijo que les reparte tráfico). <unset> = ninguna. Ojo: la columna lista también pods no listos; cuáles reciben tráfico lo dice conditions.ready.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| archivo | manifest (o carpeta) a aplicar | k8s/gateway.yaml |
| deploy | nombre del Deployment | gateway |
| namespace | namespace de los objetos | classifier |
| plazo | cuánto esperar antes de dar el deploy por fallido | p. ej. 5m para el gateway; 25m para Triton (estimación del curso) |
| servicio | nombre del Service | gateway |
| esperar un nodo con espacio; si hay que crearlo, minutos | ≈ 2–5 min para un g4dn nuevo (estimación del curso) | |
| bajar la imagen al nodo; cero si ya estaba | segundos para el gateway; minutos para los varios GB de Triton | |
| el proceso arranca y carga lo que necesita | Triton carga el engine; su startupProbe (¿ya arrancó?) le da hasta 5 min | |
| hasta que pasa la readinessProbe (¿le mando tráfico?) | chequeo cada 5 s en el gateway |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| kubeconfig y contexto | Archivo con tus clusters y credenciales; el contexto dice a cuál le habla kubectl ahora. | kubectl config current-context |
| kubectl apply | Manda los objetos del archivo al API server: crea los que faltan y actualiza los que cambiaron. | kubectl apply -f k8s/gateway.yaml |
| API server (kube-apiserver) | La única puerta del cluster: autentica, revisa permisos (RBAC), valida y guarda. | rechaza un typo: strict decoding error: unknown field |
| admission | Paso del API server que completa o revisa el objeto antes de guardarlo: defaults, políticas, webhooks (programas externos). | el webhook del AWS LB Controller le agrega al pod un readiness gate (esperar a que el ALB lo vea sano) |
| etcd | La base de datos del cluster: guarda el estado deseado de cada objeto. | en EKS la opera AWS |
| Deployment controller | Lee el Deployment y crea un ReplicaSet (mantiene N copias iguales) a partir de su template, el molde del pod. | gateway-<hash> |
| ReplicaSet controller | Crea los pods que faltan para llegar a las réplicas pedidas. | 3 pods recién creados, en Pending |
| scheduler (kube-scheduler) | Elige nodo para cada pod nuevo según lo que pide (requests), los vetos del nodo (taints) y la zona. | Triton solo en nodos workload=gpu-t4 |
| kubelet | El agente de cada nodo: arranca los contenedores del pod y corre sus probes (chequeos periódicos). | evento Started |
| container runtime (containerd) | Baja la imagen y crea el proceso aislado, como Docker en tu Mac. | evento Pulled: cuánto tardó el pull |
| plugin de red (CNI) | Le da una IP a cada pod; en EKS, una IP de la red privada de AWS (VPC CNI). | la IP que el balanceador (ALB) registra como target |
| readinessProbe | Chequeo periódico de ¿le mando tráfico? Mientras falle, el pod no recibe requests del Service. | /readyz en el gateway |
| EndpointSlice controller | Arma la lista de IP:puerto de los pods de cada Service y marca cuáles están listos. | label kubernetes.io/service-name=gateway |
| kube-proxy y AWS LB Controller | kube-proxy arma reglas de red en cada nodo; el controller registra esas IPs en el ALB (balanceador de AWS). | target-type ip: el ALB va directo al pod |
Tú, con kubectl
Manifest (YAML)
kubectl diff -f k8s/gateway.yamlTu estado deseado, en git. diff muestra qué cambiaría en el cluster (exit 1 si hay cambios).
kubectl apply
kubectl apply -f k8s/gateway.yamlManda cada objeto al API server con tus credenciales. Vuelve en segundos: no espera pods.
Control plane: guarda y decide
API server
kubectl apply --dry-run=server -f k8s/gateway.yamlAutentica, revisa permisos (RBAC), corre admission y valida el esquema. El objeto que rechaza no pasa a ningún eslabón más.
etcd
kubectl get deployment gateway -n classifier -o yamlEl objeto queda guardado con su spec; el status lo irán llenando los controllers.
Deployment controller
kubectl get rs -n classifier -l app.kubernetes.io/name=gatewayCrea (o escala) un ReplicaSet gateway-<hash> con el template.
ReplicaSet controller
kubectl get pods -n classifier -l app.kubernetes.io/name=gatewayCrea los pods que faltan. Nacen Pending: todavía sin nodo.
Scheduler
kubectl get pods -n classifier -o wideElige nodo según requests, taints y reparto por zona: se llena la columna NODE.
En el nodo: arranca el contenedor
kubelet + containerd
kubectl events -n classifier --for pod/PODEn el nodo: baja la imagen, monta volúmenes y arranca el contenedor (Pulling, Pulled, Created, Started).
Pod Ready
kubectl get pods -n classifier -l app.kubernetes.io/name=gateway -wLa readinessProbe /readyz pasa: READY 1/1. Con el readiness gate del ALB, además espera a que el ALB vea sano al pod.
Hacia el tráfico
EndpointSlice
kubectl get endpointslices -n classifier -l kubernetes.io/service-name=gatewayLa IP del pod queda como lista para recibir tráfico.
Tráfico
El ALB (target-type ip) le manda requests a la IP del pod cuando su health check pasa; dentro del cluster, kube-proxy reparte las conexiones.
template:
metadata:
labels:
app.kubernetes.io/name: gateway
# …
spec:
# …
containers:
- name: gateway
# …
ports:
- name: http
containerPort: 8080
# …
readinessProbe:
httpGet:
path: /readyz
port: http
# …
apiVersion: v1
kind: Service
# …
spec:
type: ClusterIP # el ALB (target-type ip) apunta directo a las IPs de los pods; el Service da los endpoints
selector:
app.kubernetes.io/name: gateway
ports:
- name: http
port: 8080
targetPort: httptemplate se copian a cada pod. app.kubernetes.io/name: gateway es lo que buscan el ReplicaSet, el Service y el reparto por zona.name: http) y el Service apunta a ese nombre (targetPort: http), no al número 8080./readyz falle (Triton no responde o el modelo no cargó), el pod corre pero no recibe requests: su endpoint queda marcado como no listo.selector del Service busca esas labels en los pods. Un typo aquí no da ningún error: el Service existe, pero sin endpoints.type: ClusterIP: el ALB (target-type ip) no pasa por la IP del Service. El AWS Load Balancer Controller lee los endpoints y registra directo las IPs de los pods.Cómo recordarla
Apply guarda; los controllers construyen; la readiness abre la puerta. Si el apply salió bien y no llega tráfico, recorre la cadena en orden: ¿ReplicaSet? ¿pods? ¿nodo? ¿Ready? ¿endpoints?
Trampa
Creer que kubectl apply terminó el deploy: solo guardó el estado deseado y sale en segundos aunque la imagen no exista. Lo que pasa después lo dicen rollout status, los eventos y los endpoints. Y un selector con un typo no da error: el Service existe, sin endpoints.
En el curso: M6 · Los objetos que sí debes conocer Docker y kubectl · kubectl: mirar, entrar y conectar Docker y kubectl · Depurar pods: síntoma → comandos → causa típica C01 · Archivos de referencia
Qué vas a provocar: Ver cada eslabón de la cadena con un Deployment y un Service de nginx, y después cortar el último: los endpoints.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, e internet para bajar nginx:1.30-alpine
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
ports:
- { name: http, containerPort: 80 }
readinessProbe:
httpGet: { path: /, port: http }
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: { app: web }
ports:
- { name: http, port: 80, targetPort: http }
EOFQué deberías ver: deployment.apps/web created y service/web created, en menos de un segundo: solo se guardó el estado deseado.
kubectl get deploy,rs,pods -n lab-k8s -o wideQué deberías ver: El Deployment web, un ReplicaSet web-<hash> y dos pods web-<hash>-<sufijo>, quizás todavía en ContainerCreating, cada uno con su nodo.
kubectl events -n lab-k8sQué deberías ver: Los eslabones en orden: ScalingReplicaSet (Deployment controller), SuccessfulCreate (ReplicaSet controller), Scheduled (scheduler), y Pulling, Pulled, Created y Started (kubelet). Si la imagen ya estaba en el nodo, Pulled dice already present on machine.
kubectl rollout status deployment/web -n lab-k8s --timeout=2mQué deberías ver: deployment "web" successfully rolled out: los dos pods pasaron la readiness.
kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=webQué deberías ver: Una fila con 80 en PORTS y las dos IPs de los pods en ENDPOINTS.
kubectl scale deployment/web -n lab-k8s --replicas=0
kubectl wait --for=delete pod -l app=web -n lab-k8s --timeout=60s
kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=webQué deberías ver: ENDPOINTS en <unset>: el Service sigue existiendo, con su IP y su DNS, pero ya no tiene a quién mandar tráfico.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Cuando no llega tráfico, sigue la cadena en orden: get deploy,rs,pods → events → rollout status → endpointslices. El primer eslabón vacío o con error es tu problema.
Verificado contra la documentación; no ejecutado aquí.
¿Qué hace Kubernetes por ti, se programa o se configura, y qué partes tiene un cluster?
Sintaxis con variables
aws eks update-kubeconfig --region región --name cluster
Agrega el cluster de EKS (el Kubernetes gestionado de AWS) a tu kubeconfig, el archivo de clusters y credenciales de kubectl, y lo deja como contexto actual.
kubectl config use-context contexto
Cambia de cluster. Un contexto junta cluster, usuario y namespace por defecto; kubectl config current-context dice dónde estás antes de cambiar algo.
kubectl get kind nombre -n namespace -o yaml
Los dos estados del objeto, uno arriba del otro: spec (lo que pediste) y status (lo que el cluster logró hasta ahora).
kubectl get nodes -L labelLos nodos (las máquinas del cluster), con esa label (etiqueta clave=valor) como columna. En el C01: -L workload,topology.kubernetes.io/zone.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| región | región de AWS del cluster | us-east-1 |
| cluster | nombre del cluster de EKS | el de tu equipo (el C01 no lo fija) |
| contexto | nombre del contexto en tu kubeconfig | docker-desktop en tu Mac; en EKS, el ARN del cluster (su identificador en AWS) |
| kind | tipo de objeto | deployment |
| nombre | nombre del objeto | triton |
| namespace | carpeta lógica del objeto (namespace) | classifier |
| label | label de nodo que quieres ver como columna | workload (vale gpu-t4 en los nodos GPU del C01) |
| lo que cobra AWS por el control plane, aparte de los nodos | 0.10 US$/h en soporte estándar (14 meses por versión) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| cluster | Un grupo de máquinas (nodos) más el control plane que las gobierna. | el EKS del C01: nodos de CPU y nodos g4dn con T4 |
| nodo (node) | Una máquina, física o virtual (en EKS, una máquina virtual de AWS), donde corren tus pods (grupos de contenedores). | g4dn.xlarge con la label workload=gpu-t4 |
| control plane | El cerebro: guarda lo que pediste y decide qué hacer. No corre tus pods. | en EKS lo opera AWS |
| kube-apiserver | Expone la API HTTP del cluster. Todo pasa por aquí, también kubectl. | kubectl get pods = un GET a la API |
| etcd | Base de datos clave-valor, consistente y replicada, con todos los objetos. | en EKS: 3 instancias en 3 zonas |
| kube-scheduler | Asigna un nodo a cada pod que todavía no tiene. | busca un nodo con una T4 libre para Triton |
| kube-controller-manager | Corre los controllers: lazos que acercan lo real a lo deseado. | Deployment, ReplicaSet, EndpointSlice |
| kubelet | El agente de cada nodo: arranca los contenedores de sus pods y los vigila. | reinicia un Triton que falla la liveness (¿sigue vivo?) |
| container runtime | Baja imágenes y corre contenedores en el nodo. | containerd en los nodos de EKS |
| kube-proxy y CNI | kube-proxy arma las reglas de los Services; el plugin de red (CNI) le da IP a cada pod. | aws-node (VPC CNI) en cada nodo de EKS |
| add-on | Software extra que corre como pods del cluster: DNS, métricas, drivers. | CoreDNS, NVIDIA device plugin, KEDA |
| controller propio u operator | Programa que vigila objetos y actúa: eso sí es código, y rara vez lo escribes tú. | KEDA: agrega el kind ScaledObject con una CRD (definición de un tipo nuevo) y crea un HPA (autoscaler de réplicas) |
| kubectl | Herramienta de línea de comandos (CLI): lee tu kubeconfig y habla con la API. Tolera una versión menor de diferencia. | kubectl 1.37 con clusters 1.36 a 1.38 |
| Amazon EKS | Kubernetes gestionado: AWS opera el control plane; nodos y add-ons siguen siendo tuyos. | 0.10 US$/h por cluster (≈ 73 al mes) |
Control planeen EKS lo opera AWS
cada kubelet le habla al API server: recibe sus pods y reporta cómo van
CPUnodo CPU · us-east-1a
label topology.kubernetes.io/zone=us-east-1a
kubelet · containerd
CPUnodo CPU · us-east-1b
label topology.kubernetes.io/zone=us-east-1b
kubelet · containerd
GPUnodo GPU g4dn.xlarge · us-east-1a
label workload=gpu-t4
taint nvidia.com/gpu=present:NoSchedule
kubelet · containerd
GPUnodo GPU g4dn.xlarge · us-east-1b
label workload=gpu-t4
taint nvidia.com/gpu=present:NoSchedule
kubelet · containerd
pods del C01add-ons (también son pods)nodo GPU
AWS opera todo el control plane (0.10 US$/h por cluster) y tú no ves esos procesos como pods. Nodos, kubelet, containerd y add-ons corren en tu cuenta. Con EKS Auto Mode, AWS también opera los nodos y varios add-ons.
Prerequisitos del cluster: NVIDIA device plugin, node group de `g4dn.xlarge` con label
`workload=gpu-t4` y taint `nvidia.com/gpu=present:NoSchedule`, AWS Load Balancer Controller, KEDA,
Prometheus (kube-prometheus-stack, con kube-state-metrics), un CNI que aplique NetworkPolicy (VPC CNI
con `enableNetworkPolicy=true`, o Calico) y las imágenes en ECR. Kubernetes ≥ 1.35: 1.34 sale del
soporte upstream el 27 de octubre de 2026 y del soporte estándar de EKS el 2 de diciembre de 2026. Nada
en los manifests depende de 1.34: pasan la validación estricta contra 1.35–1.37 y
`trafficDistribution: PreferSameZone` es GA desde 1.35 (en 1.31–1.33 cámbialo por `PreferClose`).nvidia.com/gpu y Triton queda Pending.triton.yaml: el nodeSelector (en qué nodos puede caer) y la toleration (el permiso para ignorar ese veto). En EKS, un paso del API server, el admission plugin ExtendedResourceToleration, agregaría solo una toleration equivalente, porque Triton pide nvidia.com/gpu. Escribirla la deja explícita y sirve en cualquier cluster.Cómo recordarla
Kubernetes funciona como un termostato para contenedores: tú fijas el estado deseado en YAML y los controllers corrigen sin parar hacia él. Se configura con YAML y kubectl apply; tu código vive en la imagen.
Trampa
Pensar que kubectl le habla a los nodos o a los contenedores: solo le habla al API server, y todo lo demás lo hacen controllers que leen de ahí. Por eso un objeto aceptado todavía no es un pod corriendo. Y en EKS, buscar etcd o el scheduler en kube-system: AWS los corre fuera de tus nodos.
En el curso: M6 · Qué resuelve Kubernetes (y cuándo no lo necesitas) M6 · Los objetos que sí debes conocer M13 · Managed vs self-hosted: la cuenta de los dos lados Docker y kubectl · kubectl: mirar, entrar y conectar
Qué vas a provocar: Ver el control plane de un cluster local y el lazo de control en acción: borras un pod y el cluster lo repone sin que nadie se lo pida.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, e internet para bajar nginx:1.30-alpine
kubectl config current-context
kubectl get nodes -o wideQué deberías ver: docker-desktop (o kind-kind si usas kind) y al menos un nodo Ready, con su versión de Kubernetes.
kubectl get pods -n kube-systemQué deberías ver: Pods etcd-…, kube-apiserver-…, kube-scheduler-… y kube-controller-manager-… (el sufijo es el nombre del nodo), más coredns y kube-proxy. En un cluster local el control plane corre como pods; en EKS no los verías.
kubectl create namespace lab-k8s
kubectl create deployment web -n lab-k8s --image=nginx:1.30-alpine --replicas=3
kubectl rollout status deployment/web -n lab-k8s --timeout=2mQué deberías ver: namespace/lab-k8s created, deployment.apps/web created y deployment "web" successfully rolled out.
POD=$(kubectl get pods -n lab-k8s -l app=web -o name | head -1)
kubectl delete -n lab-k8s "$POD"
kubectl get pods -n lab-k8s -l app=webQué deberías ver: Otra vez 3 pods, uno con AGE de pocos segundos. Nadie lo pidió: el ReplicaSet vio 2 de 3 y creó otro.
kubectl get deployment web -n lab-k8s -o jsonpath='{.spec.replicas} {.status.readyReplicas}{"\n"}'Qué deberías ver: 3 3: spec (lo que pediste) y status (lo que hay). Si repites justo después de borrar un pod, verás 3 2 por un momento.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Cuando algo vuelve solo, no lo pelees borrándolo: cambia el estado deseado (el YAML, o kubectl scale si no hay autoscaler). El controller siempre gana.
Verificado contra la documentación; no ejecutado aquí.
¿Qué partes tiene un manifest, quién escribe cada una y cómo se encuentran los objetos entre sí?
Sintaxis con variables
apiVersion: grupo/versión
kind: Kind
metadata:
name: nombre
namespace: namespace
labels:
clave: valor
spec:
# el estado deseado: cambia según el kindLo escribes tú. Los objetos básicos (grupo core) no llevan grupo: apiVersion: v1 (Service, ConfigMap); el resto sí: apps/v1, networking.k8s.io/v1.
spec:
selector:
matchLabels:
clave: valor
template:
metadata:
labels:
clave: valorEn un Deployment, las labels que busca el selector tienen que estar en las del template (el molde de los pods); si no, el API server lo rechaza.
kubectl api-resources --api-group=grupoQué tipos de objeto (kinds) existen en ese grupo, con su apiVersion y si van dentro de un namespace. Con --api-group=keda.sh ves si KEDA (el autoscaler por eventos del C01) está instalado.
kubectl get pods -n namespace -l clave=valor
Busca por label (etiqueta clave=valor), igual que un Service. Si aquí no sale ningún pod, el Service tampoco encuentra ninguno.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| grupo | grupo de la API; el core no tiene | apps (Deployment), networking.k8s.io (Ingress), keda.sh (ScaledObject) |
| versión | versión dentro del grupo | v1; v1alpha1 en el ScaledObject de KEDA |
| Kind | tipo de objeto, en CamelCase | Deployment |
| nombre | nombre único por kind y namespace, en minúsculas | gateway |
| namespace | carpeta lógica del objeto | classifier |
| clave | clave de una label | app.kubernetes.io/name |
| valor | valor de esa label | gateway |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| apiVersion | Grupo y versión de la API que define el kind. El core no lleva grupo. | apps/v1 · v1 · keda.sh/v1alpha1 |
| kind | Qué tipo de objeto es, en CamelCase. | Deployment, Service, ScaledObject |
| metadata.name | Nombre único dentro de su kind y su namespace, en minúsculas. | gateway (Deployment) y gateway (Service): kinds distintos |
| metadata.namespace | Carpeta lógica. Si falta, el objeto va al namespace de tu contexto (casi siempre default). | classifier |
| metadata.labels | Etiquetas clave-valor para buscar y agrupar; es lo que miran los selectors (filtros por label). | app.kubernetes.io/name: gateway |
| metadata.annotations | Notas para herramientas y controllers; no sirven para seleccionar. | alb.ingress.kubernetes.io/scheme: internal |
| spec | El estado deseado: lo escribes tú. | réplicas, imagen, puertos, probes |
| status | El estado real: lo escribe el cluster. No va en tu YAML. | readyReplicas, conditions |
| spec.selector | Qué pods gobierna el objeto. En un Deployment no se puede cambiar después. | matchLabels: app.kubernetes.io/name: gateway |
| spec.template | El molde de los pods que crea el controller. Cambiarlo dispara un rollout (cambio de versión). | labels, containers, probes |
| --- (separador) | Separa varios objetos dentro de un mismo archivo YAML. | gateway.yaml: Deployment, Service, HPA e Ingress |
| CRD (CustomResourceDefinition) | Agrega un kind nuevo a la API. Sin ella, apply falla con no matches for kind. | ScaledObject (KEDA), ServiceMonitor (Prometheus Operator) |
| ownerReferences | Quién es dueño del objeto: al borrar al dueño, el cluster borra a los hijos. | Pod → ReplicaSet → Deployment |
| app.kubernetes.io/* | Labels recomendadas: name, instance, version, component, part-of, managed-by. | part-of: classifier |
Deseado · spec
spec del Deployment gateway: 3 réplicas del template con la imagen :3f9c2ab (las 3 las fijó el HPA)
Real · status
status: 2 réplicas listas, porque vaciaron un nodo para mantenimiento (drain) y desalojaron uno de los pods
Quién corre el lazo: Deployment y ReplicaSet controllers (dentro de kube-controller-manager)
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
namespace: classifier
labels:
app.kubernetes.io/name: gateway
app.kubernetes.io/part-of: classifier
spec:
# Sin "replicas": las decide el HPA de abajo (3–12)
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # pods nuevos antes de bajar viejos
maxUnavailable: 0 # capacidad completa durante todo el deploy
selector:
matchLabels:
app.kubernetes.io/name: gateway
template:
metadata:
labels:
app.kubernetes.io/name: gateway
app.kubernetes.io/part-of: classifierapps/v1: grupo apps, versión v1. El Service del mismo archivo usa v1 a secas porque es del grupo core.metadata.labels etiqueta al Deployment mismo: sirve para encontrarlo (kubectl get deploy -l …), no decide qué pods gobierna.spec.selector.matchLabels decide qué pods son suyos. En apps/v1 es obligatorio e inmutable: cambiarlo obliga a borrar y recrear el Deployment.spec.template.metadata.labels se copia a cada pod. Tiene que contener al selector y puede tener labels de más (aquí, part-of).status ni replicas: el status lo escribe el cluster y las réplicas las decide el HPA (el autoscaler de réplicas) del mismo archivo.Cómo recordarla
apiVersion + kind dicen qué es; metadata, cómo se llama y dónde vive; spec, lo que quieres; status, lo que hay (lo escribe el cluster). Las labels son el pegamento: Services, PodDisruptionBudgets (cuántos pods deben quedar en pie en un mantenimiento) y NetworkPolicies (el firewall entre pods) encuentran pods por labels, nunca por nombre.
Trampa
Copiar un manifest y cambiar solo metadata.name: si el selector y las labels quedan iguales, dos Deployments pelean por los mismos pods. Y olvidar el namespace en un archivo: el objeto cae en el namespace de tu contexto (casi siempre default) y desde classifier nadie lo ve.
En el curso: M6 · Los objetos que sí debes conocer Docker y kubectl · kubectl: mirar, entrar y conectar C01 · Archivos de referencia
Qué vas a provocar: Romper un manifest de tres maneras, leer qué contesta el API server en cada caso y ver quién escribe el status.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, e internet para bajar nginx:1.30-alpine
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replica: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- { name: nginx, image: "nginx:1.30-alpine" }
EOFQué deberías ver: namespace/lab-k8s created y después Error from server (BadRequest) con strict decoding error: unknown field "spec.replica". kubectl valida en el servidor y en modo estricto por defecto: un typo no pasa.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: webb }
spec:
containers:
- { name: nginx, image: "nginx:1.30-alpine" }
EOFQué deberías ver: The Deployment "web" is invalid: spec.template.metadata.labels: Invalid value: … `selector` does not match template `labels`.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- { name: nginx, image: "nginx:1.30-alpine" }
EOFQué deberías ver: resource mapping not found … no matches for kind "Deployment" in version "v1" y ensure CRDs are installed first: el kind existe, pero en apps/v1.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- { name: nginx, image: "nginx:1.30-alpine" }
EOF
kubectl rollout status deployment/web -n lab-k8s --timeout=2m
kubectl get deployment web -n lab-k8s -o jsonpath='{.status.readyReplicas} {.status.conditions[*].type}{"\n"}'Qué deberías ver: deployment.apps/web created, successfully rolled out y 2 Available Progressing: eso es status, y nadie lo escribió a mano.
kubectl create configmap prueba --from-literal=a=1
kubectl get configmap prueba -n lab-k8s
kubectl delete configmap pruebaQué deberías ver: configmap/prueba created, después Error from server (NotFound): configmaps "prueba" not found: sin -n fue al namespace de tu contexto (default), no a lab-k8s. El delete lo limpia.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Lee el error por su parte final. unknown field: typo o indentación. selector does not match: labels del template. no matches for kind: apiVersion equivocado o falta instalar el CRD. Para atraparlos antes, corre kubectl apply --dry-run=server en CI (las pruebas automáticas de cada cambio).
Verificado contra la documentación; no ejecutado aquí.
¿Cómo mantiene Kubernetes N copias de tu servicio y cómo cambia de versión sin cortar?
Sintaxis con variables
spec:
replicas: n
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: extra
maxUnavailable: faltantesQuita replicas si un autoscaler (HPA o KEDA) decide cuántas. Defaults de la estrategia: 25 % y 25 %.
kubectl get deploy,rs,pods -n namespace -l selector
La cadena completa de un vistazo: el Deployment, sus ReplicaSets (uno por versión del template) y los pods.
kubectl set image deployment/deploy contenedor=imagen -n namespaceCambia la imagen en el template y dispara un rollout (el cambio de versión de a poco). Sirve en un lab; en producción, el cambio va en git y se aplica.
kubectl rollout history deployment/deploy -n namespaceLas revisiones guardadas: una por ReplicaSet, el actual y hasta revisionHistoryLimit viejos. Es a lo que puede volver rollout undo.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| n | réplicas deseadas | 16 en el vLLM del c10; sin campo en el C01 (las deciden el HPA y KEDA) |
| extra | maxSurge: pods de más durante el rollout (número o %) | 25 % en el gateway, 1 en Triton |
| faltantes | maxUnavailable: pods de menos durante el rollout | 0 en el C01 |
| namespace | namespace del Deployment | classifier |
| selector | label para filtrar | app.kubernetes.io/name=triton |
| deploy | nombre del Deployment | triton |
| contenedor | nombre del contenedor dentro del template | triton |
| imagen | imagen nueva, con tag | 123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-triton:2026-09-15-resnet50-fp16 |
| réplicas deseadas en ese momento | 2 (mínimo de KEDA para Triton) | |
| maxSurge y maxUnavailable como fracción | 0.25 por defecto |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Pod | La unidad mínima: uno o varios contenedores que comparten IP y volúmenes, en un solo nodo. | un pod de Triton = un contenedor + una T4 |
| contenedores del pod | Nacen y mueren con el pod y se hablan por localhost. | un agente de logs junto a la app |
| sidecar nativo | Contenedor de arranque (initContainer) con restartPolicy: Always: arranca antes y se apaga después del principal (estable desde 1.33). | un proxy o un agente de métricas |
| restartPolicy | Qué hace el kubelet (agente del nodo) al terminar un contenedor. En un Deployment, el del pod solo admite Always. | Always (Deployment) · OnFailure o Never (Job) |
| ReplicaSet | Mantiene N pods iguales: si falta uno, lo crea; si sobra, lo borra. | triton-5c7d9b8f6d (ilustrativo) |
| Deployment | Maneja ReplicaSets, uno por versión del template (el molde del pod), para cambiar de versión de a poco. | triton, gateway |
| pod-template-hash | Label que el Deployment pone en cada ReplicaSet y sus pods para no mezclar versiones. | triton-5c7d9b8f6d-q2x7k |
| RollingUpdate | Estrategia por defecto: sube pods nuevos y baja viejos de a poco. | gateway y Triton del C01 |
| Recreate | Borra todos los pods viejos antes de crear los nuevos: hay corte. | dos versiones que no pueden convivir |
| maxSurge / maxUnavailable | Cuántos pods de más y de menos se permiten durante el rollout. | Triton: 1 y 0 |
| revisionHistoryLimit | ReplicaSets viejos (en 0 réplicas) que se guardan para volver atrás. Default 10. | 5 en el C01 |
| progressDeadlineSeconds | Plazo para avanzar. Si vence: Progressing=False con ProgressDeadlineExceeded. No revierte solo. | 900 s en Triton |
| minReadySeconds | Segundos que un pod nuevo debe seguir Ready para contar como disponible. Default 0. | el C01 no lo usa |
| StatefulSet · DaemonSet · Job | Otros controllers de pods: identidad estable · un pod por nodo · correr hasta terminar. | el NVIDIA device plugin es un DaemonSet |
Deploymenttritonlo escribes tú
spec: el template nuevo (otra imagen), maxSurge 1, maxUnavailable 0; las réplicas (2 a 4) las escribe el HPA de KEDA
↓ crea y controla↑ ownerReferences: apunta a su dueño
ReplicaSettriton-<hash nuevo>lo crea el Deployment
sube de 0 a 2 de a un pod; el viejo, triton-<hash viejo>, baja de 2 a 0 y queda guardado para rollout undo
↓ crea y controla↑ ownerReferences: apunta a su dueño
Podtriton-<hash nuevo>-<sufijo>lo crea el ReplicaSet
nace Pending hasta que hay una T4 libre; cuenta como disponible cuando pasa la readiness. Si su contenedor muere, el kubelet lo reinicia dentro del mismo pod
Se encuentran por labels: app.kubernetes.io/name=triton (y pod-template-hash, que agrega el Deployment)
spec:
# Sin "replicas": las gestiona el HPA que crea KEDA. Si lo fijas aquí, cada `kubectl apply`
# resetea la escala y pelea con el autoscaler.
revisionHistoryLimit: 5 # ReplicaSets viejos para `kubectl rollout undo`
progressDeadlineSeconds: 900 # un pod con nodo GPU nuevo tarda ≈ 4–11 min: el default de 600 s vence en el caso pesimista
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # de a un pod nuevo: cada pod necesita una GPU libre (Karpenter/cluster-autoscaler agrega el nodo)
maxUnavailable: 0 # nunca bajar de la capacidad actual durante un deploy
selector:
matchLabels:
app.kubernetes.io/name: triton # inmutable: debe coincidir con las labels del template
template:
metadata:
labels:
app.kubernetes.io/name: triton
app.kubernetes.io/part-of: classifier
spec:
# Tiempo total para apagar: preStop (10 s) + --exit-timeout-secs de Triton (30 s) + margen
terminationGracePeriodSeconds: 60replicas: el número lo pone el HPA (el autoscaler de réplicas de Kubernetes) que KEDA crea y alimenta para Triton (mínimo 2). Si lo fijaras aquí, cada kubectl apply lo pisaría.maxSurge: 1 y maxUnavailable: 0: con 2 réplicas, el rollout tiene 3 pods un rato. El tercero necesita una T4 libre, o un nodo nuevo: por eso va de a uno.progressDeadlineSeconds: 900: un pod que espera nodo GPU, imagen y engine puede pasar los 600 s del default. Vencido el plazo, el Deployment marca ProgressDeadlineExceeded, pero no revierte solo.revisionHistoryLimit: 5: guarda 5 ReplicaSets viejos en 0 réplicas. Volver al modelo anterior con kubectl rollout undo es re-escalar uno de ellos.pod-template-hash a cada ReplicaSet para no mezclar versiones.Cómo recordarla
Deployment → ReplicaSet → Pod: el Deployment cambia de versión, el ReplicaSet cuenta copias y el pod corre. Un rollout es un ReplicaSet nuevo que sube mientras el viejo baja, al ritmo de maxSurge (de más) y maxUnavailable (de menos).
Trampa
Esperar un rollout al editar un ConfigMap (la configuración que vive aparte del pod) o al escalar: solo cambiar spec.template dispara uno. Y en un servicio de GPU, el surge por defecto pide GPUs extra que quizás no existen: el pod nuevo queda Pending y el deploy se queda a medias.
En el curso: M6 · Los objetos que sí debes conocer M6 · Alta disponibilidad: multi-AZ, PDB y N+1 Docker y kubectl · Rollouts y escalado M11 · Rollback: criterios automáticos y qué guardar para volver
Qué vas a provocar: Ver un rolling update de verdad (un ReplicaSet nuevo que sube mientras el viejo baja), uno que se traba con una imagen inexistente y el rollback.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, dos terminales e internet para bajar nginx:1.30-alpine y nginx:1.31-alpine
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
readinessProbe:
httpGet: { path: /, port: 80 }
EOF
kubectl rollout status deployment/web -n lab-k8s --timeout=2m
kubectl get rs -n lab-k8sQué deberías ver: Un solo ReplicaSet web-<hash> con DESIRED, CURRENT y READY en 3.
kubectl get rs -n lab-k8s -wQué deberías ver: Déjalo corriendo en otra terminal: vas a ver cómo cambian los números en el paso siguiente.
kubectl set image deployment/web nginx=nginx:1.31-alpine -n lab-k8s
kubectl rollout status deployment/web -n lab-k8s --timeout=2mQué deberías ver: En la otra terminal, un ReplicaSet nuevo sube 1, 2, 3 mientras el viejo baja 2, 1, 0, nunca con menos de 3 pods listos (maxUnavailable 0). Termina con successfully rolled out.
kubectl rollout history deployment/web -n lab-k8sQué deberías ver: Las revisiones 1 y 2: una por ReplicaSet. El viejo sigue ahí, en 0 réplicas.
kubectl set image deployment/web nginx=nginx:9.99-noexiste -n lab-k8s
kubectl rollout status deployment/web -n lab-k8s --timeout=30s
kubectl get pods -n lab-k8sQué deberías ver: error: timed out waiting for the condition. Un pod nuevo en ErrImagePull o ImagePullBackOff y los 3 viejos Running: con maxUnavailable 0, una versión rota no le quita capacidad al servicio.
kubectl rollout undo deployment/web -n lab-k8s
kubectl rollout status deployment/web -n lab-k8s --timeout=2mQué deberías ver: deployment.apps/web rolled back y successfully rolled out: el ReplicaSet de la 1.31 volvió a ser el actual y el pod roto desapareció.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted. Corta con Ctrl+C el watch de la otra terminal.
Cómo arreglarlo y confirmarlo
Un rollout trabado no se arregla con rollout restart: se arregla la causa (imagen, readiness, GPU libre) o se vuelve atrás con rollout undo. Con GitOps (el cluster se sincroniza solo con lo que hay en git), el rollback es un revert en git.
Verificado contra la documentación; no ejecutado aquí.
¿Cómo encuentra el tráfico a tus pods si cambian de IP cada vez que se recrean?
Sintaxis con variables
apiVersion: v1
kind: Service
metadata:
name: servicio
spec:
selector:
clave: valor
ports:
- name: nombre_puerto
port: puerto
targetPort: puerto_podUn nombre y una IP estables delante de los pods (las unidades que corren tus contenedores) que cumplen el selector, el filtro por labels. Sin type es ClusterIP: solo se alcanza desde dentro del cluster.
servicio.namespace.svc.cluster.local:puerto
El nombre completo en el DNS interno del cluster (CoreDNS). Desde el mismo namespace alcanza con servicio; desde otro, con servicio.namespace.
spec:
ingressClassName: clase
rules:
- http:
paths:
- path: ruta
pathType: tipo
backend:
service:
name: servicio
port:
name: nombre_puertoUn Ingress solo describe reglas HTTP (qué ruta va a qué Service): lo implementa el controller de su clase (alb = AWS Load Balancer Controller). Sin controller, no pasa nada.
kubectl port-forward svc/servicio puerto_local:puerto -n namespaceUn túnel desde tu laptop hasta un pod detrás del Service, para probar sin exponer nada. No sirve para pruebas de carga.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| servicio | nombre del Service (y de su DNS) | triton |
| clave | clave de la label que busca el selector | app.kubernetes.io/name |
| valor | valor de esa label | triton |
| nombre_puerto | nombre del puerto; obligatorio si el Service tiene varios | http |
| puerto | puerto del Service, al que llamas | 8000 |
| puerto_pod | puerto del contenedor, por número o por nombre; si falta, igual a port | http (= containerPort 8000) |
| namespace | namespace del Service | classifier |
| clase | ingressClassName: qué controller atiende el Ingress | alb |
| ruta | path de la regla | /v1/classify |
| tipo | pathType: Exact, Prefix o ImplementationSpecific | Exact |
| puerto_local | puerto en tu laptop | 8000 |
| cuánto deja la app abierta una conexión sin uso | 75 s (--timeout-keep-alive de uvicorn) | |
| cuánto deja el balanceador de AWS (ALB) abierta una conexión sin uso; default 60 s | 60 s en el C01 |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Service | Nombre e IP estables delante de los pods que cumplen su selector. | triton, gateway |
| ClusterIP (default) | IP virtual solo dentro del cluster; kube-proxy (reglas de red en cada nodo) reparte por conexión, no por request. | Triton: nunca expuesto afuera |
| NodePort | Abre el mismo puerto (30000–32767 por defecto) en todos los nodos. | lo necesita un ALB en modo instance |
| LoadBalancer | Le pide a la nube un balanceador externo para el Service. | en AWS, un balanceador de red (NLB) con el AWS LB Controller |
| headless (clusterIP: None) | Sin IP virtual: el DNS devuelve directo las IPs de los pods listos. | asr-worker del c13: el router elige el pod |
| selector | Las labels que busca el Service; solo admite igualdad (clave = valor). | app.kubernetes.io/name: triton |
| port / targetPort | port: al que llamas. targetPort: el del contenedor; si falta, vale lo mismo que port. | 8000 → http |
| EndpointSlice | Lista de IP:puerto de los pods del Service, con cuáles están listos. | kubernetes.io/service-name=triton |
| DNS del cluster (CoreDNS) | Traduce el nombre del Service a su IP: servicio.namespace.svc.cluster.local. | triton.classifier.svc.cluster.local |
| trafficDistribution | Preferencia de ruteo: PreferSameZone manda a pods de la misma zona si hay (estable desde 1.35). | Service triton: no cruzar de zona |
| Ingress | Reglas HTTP (host, path) hacia Services. Su API está congelada: no recibe cambios nuevos. | /v1/classify → gateway:http |
| IngressClass y controller | Quién implementa el Ingress; sin controller, el Ingress no hace nada. | ingressClassName: alb |
| Gateway API | El reemplazo que recomienda Kubernetes para Ingress: Gateway + HTTPRoute. | para diseños nuevos |
| NetworkPolicy | Firewall entre pods, por labels. Lo aplica el plugin de red, si lo soporta. | solo el gateway entra a Triton:8000 |
camino del requestfuera del camino
Cliente en la VPCHTTPS 443
el backend del marketplace u otro equipo; resuelve el DNS del ALB interno
ALB interno (Ingress gateway)443 → regla /v1/classify (Exact)
lo crea el AWS Load Balancer Controller; health check a /readyz; deregistro en 30 s
Fuera del camino: de aquí saca las IPs el controller del ALB
Service gateway (ClusterIP)8080 → targetPort http
no está en el camino del ALB: el controller lee sus endpoints para registrar targets
EndpointSlice del gatewayIP del pod:8080
label kubernetes.io/service-name=gateway; un pod que falla la readiness queda como no listo
target-type ip: el ALB va directo a la IP de cada pod
Pods gatewaycontainerPort 8080 (http)
readiness /readyz; preStop de 15 s mientras el ALB lo desregistra
Service triton (ClusterIP, PreferSameZone)8000 → http
TRITON_URL = triton.classifier.svc.cluster.local:8000; kube-proxy elige un pod listo
Pods triton8000 (http) · 8002 (metrics)
la NetworkPolicy solo deja entrar al gateway al 8000 y al namespace monitoring al 8002
DNStriton.classifier.svc.cluster.local → IP virtual del Service (ClusterIP), estable aunque los pods cambien de IP
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gateway
namespace: classifier
annotations:
alb.ingress.kubernetes.io/scheme: internal
# ip: el ALB manda directo a la IP del pod (sin NodePort ni salto extra de kube-proxy)
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]'
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/00000000-0000-0000-0000-000000000000
# El health check del ALB usa la misma señal que la readiness de k8s
alb.ingress.kubernetes.io/healthcheck-path: /readyz
alb.ingress.kubernetes.io/success-codes: "200"
# Drenado al sacar un pod: 30 s (el default de 300 s hace eternos los deploys)
alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
# Idle timeout 60 s < --timeout-keep-alive 75 de uvicorn: el ALB siempre cierra primero (sin 502)
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=60
spec:
ingressClassName: alb # lo atiende el AWS Load Balancer Controller
rules:
- http:
paths:
- path: /v1/classify
pathType: Exact
backend:
service:
name: gateway
port:
name: httpingressClassName: alb: el AWS Load Balancer Controller ve este objeto y crea un balanceador de aplicaciones de AWS (ALB). Sin ese controller instalado, el Ingress existe y nada pasa.scheme: internal (también el default): el ALB solo se alcanza desde la red privada (VPC). internet-facing lo expondría a internet.target-type: ip: el ALB registra las IPs de los pods y les manda tráfico directo. El Service gateway no está en el camino: el controller solo lee sus endpoints. El default, instance, exigiría un Service NodePort (o LoadBalancer).healthcheck-path: /readyz: el ALB y la readiness de Kubernetes miran la misma señal. Sin la anotación, el ALB revisaría /.deregistration_delay en 30 s (el default de AWS es 300) y el preStop de 15 s del Deployment trabajan juntos. El pod sigue atendiendo mientras el ALB deja de mandarle requests./v1/classify, con pathType: Exact: /metrics, /healthz y /readyz no quedan expuestos fuera del cluster.Cómo recordarla
Los pods cambian de IP; el Service no. El selector busca pods por labels, el EndpointSlice marca cuáles están listos y el DNS da servicio.namespace.svc.cluster.local. El Ingress (o Gateway API) es la puerta HTTP desde afuera, y siempre necesita un controller.
Trampa
Un Service con un selector que no coincide no da error: existe, tiene IP y DNS, y cada llamada falla porque no tiene endpoints. Lo ves con kubectl get endpointslices -l kubernetes.io/service-name=…. Y una NetworkPolicy en un cluster cuyo plugin de red no la aplica se acepta sin aviso y no bloquea nada.
En el curso: C01 · Serving M6 · Alta disponibilidad: multi-AZ, PDB y N+1 Docker y kubectl · kubectl: mirar, entrar y conectar C01 · Archivos de referencia
Qué vas a provocar: Llamar a un Service por DNS desde otro pod, ver cómo la readiness saca a un pod del reparto y romper el selector.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind, e internet para bajar nginx:1.30-alpine y busybox:1.38
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
ports:
- { name: http, containerPort: 80 }
readinessProbe:
httpGet: { path: /, port: http }
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: { app: web }
ports:
- { name: http, port: 80, targetPort: http }
EOF
kubectl rollout status deployment/web -n lab-k8s --timeout=2mQué deberías ver: deployment.apps/web created, service/web created y deployment "web" successfully rolled out.
kubectl run cliente -n lab-k8s --rm -it --restart=Never --image=busybox:1.38 -- wget -qO- http://web.lab-k8s.svc.cluster.localQué deberías ver: El HTML de bienvenida de nginx (Welcome to nginx!), y el pod cliente se borra solo al terminar (--rm): el DNS del Service te llevó a uno de los pods.
POD=$(kubectl get pods -n lab-k8s -l app=web -o name | head -1)
kubectl exec -n lab-k8s "$POD" -- rm /usr/share/nginx/html/index.html
sleep 40; kubectl get pods -n lab-k8s -l app=webQué deberías ver: Un pod en READY 0/1 y Running. Sin index.html, nginx responde 403 en /; la readiness falla 3 veces seguidas (≈ 30 s con los defaults) y el pod deja de estar listo, sin reiniciarse.
kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=web -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'Qué deberías ver: Dos IPs: una con ready=true y otra con ready=false. El Service solo le manda tráfico a la primera.
kubectl patch service web -n lab-k8s -p '{"spec":{"selector":{"app":"webb"}}}'
kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=web
kubectl run cliente -n lab-k8s --rm -it --restart=Never --image=busybox:1.38 -- wget -qO- -T 5 http://webQué deberías ver: service/web patched, ENDPOINTS en <unset> y wget falla (conexión rechazada o timeout): el Service existe, pero su selector ya no encuentra pods.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Si un Service no responde, mira primero sus endpoints (¿hay IPs listas?). Después compara el selector con las labels de los pods (kubectl get pods --show-labels) y el targetPort con el puerto del contenedor.
Verificado contra la documentación; no ejecutado aquí.
¿Cómo sabe Kubernetes si tu contenedor ya arrancó, si puede recibir tráfico o si se colgó? ¿Y cómo lo apaga sin cortar requests a la mitad?
Sintaxis con variables
startupProbe:
httpGet:
path: ruta_listo
port: puerto
periodSeconds: periodo
failureThreshold: fallosUna probe (sonda) es una pregunta periódica del kubelet, el agente de cada nodo, a tu contenedor. La de arranque da fallos × periodo segundos; mientras, las otras dos esperan.
readinessProbe:
httpGet:
path: ruta_listo
port: puerto
periodSeconds: periodo
timeoutSeconds: timeout
failureThreshold: fallos¿Le mando tráfico? Si falla, el pod sale de los endpoints del Service (la lista de pods que reciben tráfico), sin reiniciarse.
livenessProbe:
httpGet:
path: ruta_vivo
port: puerto
periodSeconds: periodo
timeoutSeconds: timeout
failureThreshold: fallos¿Se colgó? Si falla failureThreshold veces seguidas, el kubelet mata el contenedor y lo reinicia. Pregunta solo por el proceso, nunca por una dependencia.
spec:
terminationGracePeriodSeconds: gracia
containers:
- name: contenedor
lifecycle:
preStop:
sleep:
seconds: esperaApagado: primero el preStop (un paso que corre antes de apagar), luego SIGTERM (la señal de "termina") y, si sigue vivo al vencer terminationGracePeriodSeconds (el plazo de gracia), SIGKILL (muerte inmediata). La acción sleep es estable desde 1.34 y la corre el kubelet, así que la imagen no necesita un sleep; C01 usa exec con sleep 10.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| ruta_listo | ruta que da 200 solo cuando el modelo está cargado | /v2/health/ready (Triton), /readyz (gateway) |
| ruta_vivo | ruta barata que solo dice si el proceso responde | /v2/health/live (Triton), /healthz (gateway) |
| puerto | número o nombre del puerto del contenedor | http (= 8000 en Triton) |
| periodo | cada cuántos segundos pregunta (periodSeconds; default 10, mínimo 1) | 5 |
| timeout | segundos que espera la respuesta (timeoutSeconds; default 1) | 2 |
| fallos | fallos seguidos para declarar la falla (failureThreshold; default 3) | 60 en la startup de Triton; 2 en su readiness |
| gracia | plazo total del apagado, preStop incluido (default 30) | 60 (Triton), 45 (gateway) |
| contenedor | nombre del contenedor dentro del pod | triton |
| espera | segundos que el preStop retrasa el SIGTERM | 10 (Triton), 15 (gateway) |
| lo que tu app tarda en quedar lista; tiene que caber en f × p de la startup | carga del engine (el modelo compilado) + warmup (inferencias de calentamiento) ≤ 60 × 5 s = 300 s | |
| failureThreshold de esa probe | 60 (startup), 2 (readiness), 3 (liveness) en Triton | |
| periodSeconds de esa probe | 5, 5 y 10 s | |
| cuánto tarda en notarse una falla | readiness: 2 × 5 ≈ 10 s; liveness: 3 × 10 ≈ 30 s | |
| terminationGracePeriodSeconds | 60 s en Triton | |
| duración del preStop; corre dentro del plazo | 10 s | |
| lo que el servidor tarda en terminar lo que tiene en curso | hasta 30 s (--exit-timeout-secs=30) | |
| colchón para el resto del apagado | 20 s (60 − 10 − 30) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| startupProbe | ¿Ya arrancó? Mientras no pasa, no corren liveness ni readiness. Si agota f × p, el kubelet reinicia el contenedor. | Triton: 60 × 5 s = 5 min |
| readinessProbe | ¿Le mando tráfico? Si falla, el pod sale de los endpoints del Service. Sigue vivo y sigue probando. | READY 0/1 con RESTARTS sin cambio |
| livenessProbe | ¿Se colgó? Si falla f veces seguidas, el kubelet corre el preStop, mata el contenedor y lo reinicia. | /v2/health/live cada 10 s |
| httpGet · tcpSocket · grpc · exec | Cómo pregunta: GET con respuesta 200–399, puerto que acepta conexión, health check de gRPC que responde SERVING, o comando con exit 0. | exec: ["cat", "/tmp/listo"] (crea procesos en cada probe: en nodos llenos, mejor httpGet) |
| periodSeconds · timeoutSeconds | Cada cuánto pregunta y cuánto espera la respuesta. Defaults: 10 s y 1 s. | Triton: 5 s y 2 s |
| failureThreshold · successThreshold | Fallos seguidos para declarar la falla (default 3) y éxitos para volver (default 1; liveness y startup: siempre 1). | readiness de Triton: 2 fallos |
| initialDelaySeconds | Espera antes de la primera probe (default 0). Con una startupProbe casi nunca hace falta. | liveness del gateway: 5 |
| Condición Ready | Pod listo: todos sus contenedores pasan la readiness y se cumplen los readinessGates. El Service solo manda tráfico a pods Ready. | kubectl get pods → READY 1/1 |
| readinessGates | Condiciones extra para estar Ready. El AWS Load Balancer Controller agrega una: que el ALB (el balanceador de AWS) ya vea sano al pod. | label elbv2.k8s.aws/pod-readiness-gate-inject=enabled en el namespace |
| lifecycle.preStop | Corre antes del SIGTERM y dentro del plazo. Mantiene el proceso atendiendo mientras el pod sale de los endpoints. | sleep 10 (Triton), sleep 15 (gateway) |
| terminationGracePeriodSeconds | Plazo total del apagado, preStop incluido. Al vencer, SIGKILL (exit 137); si el preStop sigue, recibe 2 s extra. | 60 (Triton), 45 (gateway); default 30 |
| Eventos Unhealthy y Killing | Lo que deja cada falla en kubectl describe pod y kubectl events. | Readiness probe failed: HTTP probe failed with statuscode: 503 |
| HEALTHCHECK del Dockerfile | Kubernetes no lo usa: solo cuentan las probes del manifest. | el del gateway sirve en docker run y en Compose |
Arranque · t = 0: arranca el contenedor
Apagado · t = 0: kubectl delete, scale-down, rollout o drain
la startup prueba y fallapasa: Ready y en los endpointsespera a la startupSIGKILL
| Probe | Pregunta | Si falla | En Triton (C01) |
|---|---|---|---|
| startupProbe | ¿Ya terminó de arrancar? | Al agotar f × p, reinicia el contenedor. Mientras no pasa, las otras dos no corren. | /v2/health/ready cada 5 s, hasta 60 fallos = 5 min |
| readinessProbe | ¿Le mando tráfico? | El pod sale de los endpoints (READY 0/1). No se reinicia. | /v2/health/ready cada 5 s, 2 fallos ≈ 10 s |
| livenessProbe | ¿Se colgó el proceso? | Reinicia el contenedor (con preStop y plazo de gracia). | /v2/health/live cada 10 s, 3 fallos ≈ 30 s |
Defaults si no los escribes: periodSeconds 10, timeoutSeconds 1, failureThreshold 3, successThreshold 1, initialDelaySeconds 0. Readiness y liveness no se esperan entre sí: solo la startup frena a las otras dos.
startupProbe:
httpGet:
path: /v2/health/ready
port: http
periodSeconds: 5
failureThreshold: 60 # hasta 5 min para quedar listo
# readiness: ¿recibe tráfico? ready = servidor + modelo cargado (--strict-readiness)
readinessProbe:
httpGet:
path: /v2/health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2 # ~10 s sin estar listo → sale del Service
# liveness: ¿el proceso está colgado? Solo "live", NO "ready": un modelo que se descarga
# no se arregla reiniciando en bucle
livenessProbe:
httpGet:
path: /v2/health/live
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
# Al borrar el pod, el Service tarda unos segundos en sacarlo de los endpoints.
# Seguimos sirviendo 10 s antes del SIGTERM para no cortar requests en vuelo.
command: ["sleep", "10"]startupProbe le da a Triton hasta 60 × 5 s = 5 min para cargar el engine y hacer el warmup. Mientras no pasa, el kubelet no corre la liveness: no mata a un pod que solo está cargando./v2/health/ready, que con --strict-readiness=true da 200 solo con el modelo cargado. Antes de eso Triton ni abre el puerto: la probe falla con connection refused, y es normal.timeoutSeconds: 2 evita el default de 1 s, que bajo carga da falsos negativos./v2/health/live (el proceso responde), no ready: si el modelo no carga, reiniciar en bucle no lo arregla. Un cuelgue se detecta en ≈ 3 × 10 s = 30 s.terminationGracePeriodSeconds: 60 (línea 32): 10 s de preStop + 30 s de --exit-timeout-secs + 20 s de margen. El gateway hace lo mismo con 15 + 20 (uvicorn) + 10 = 45, porque el ALB tarda más en dejar de mandarle tráfico que kube-proxy (el que reparte el tráfico de los Services en cada nodo).Cómo recordarla
startup = ¿ya arrancó?, readiness = ¿le mando tráfico?, liveness = ¿lo reinicio?, y al apagar, preStop → SIGTERM → SIGKILL dentro de terminationGracePeriodSeconds. Como en un restaurante: la startup espera a que la cocina esté lista, la readiness es el letrero de abierto o cerrado (cerrar no despide a nadie) y la liveness reemplaza a quien cocina si se desmayó.
Trampa
poner una dependencia en la liveness (Triton en la del gateway, la base de datos en la de tu API): si la dependencia cae, el kubelet reinicia en cascada pods sanos que no arreglan nada, así que las dependencias van en la readiness. Sin startupProbe, un modelo que tarda 3 min en cargar muere a manos de la liveness (3 × 10 s con los defaults) antes de terminar, y el pod entra en CrashLoopBackOff (reinicios con esperas cada vez más largas). Y con el timeoutSeconds de 1 s por defecto, un proceso saturado falla la liveness justo en el pico: reinicias capacidad cuando más falta.
En el curso: M6 · Los objetos que sí debes conocer M6 · Alta disponibilidad: multi-AZ, PDB y N+1 Docker y kubectl · Depurar pods: síntoma → comandos → causa típica C01 · Modos de falla y degradación C01 · Archivos de referencia
Qué vas a provocar: Ver a la startup frenar a las otras probes, a la readiness sacar un pod sin reiniciarlo, a la liveness reiniciarlo, y medir un apagado con preStop.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster, con kubeadm o kind; contexto docker-desktop) o kind. La primera vez se baja busybox:1.38 (≈ 2 MB para arm64).
kubectl config current-context
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: probes
spec:
terminationGracePeriodSeconds: 20
containers:
- name: app
image: busybox:1.38
command: ["sh", "-c", "trap 'exit 0' TERM; touch /tmp/vivo; sleep 15; touch /tmp/listo; while true; do sleep 1; done"]
startupProbe:
exec:
command: ["cat", "/tmp/listo"]
periodSeconds: 2
failureThreshold: 30
readinessProbe:
exec:
command: ["cat", "/tmp/listo"]
periodSeconds: 2
livenessProbe:
exec:
command: ["cat", "/tmp/vivo"]
periodSeconds: 2
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
EOF
kubectl get pod probes -n lab-k8s -wQué deberías ver: El contexto tiene que ser docker-desktop (o tu cluster kind), nunca uno de trabajo. El pod queda Running con READY 0/1 unos 15 s: la startup falla cada 2 s hasta que aparece /tmp/listo. Después pasa a 1/1. Sal con Ctrl+C.
kubectl exec -n lab-k8s probes -- rm /tmp/listo
kubectl get pod probes -n lab-k8s -wQué deberías ver: En ≈ 6 s (3 fallos × 2 s) READY pasa a 0/1 y RESTARTS sigue en 0: la readiness saca al pod del tráfico sin reiniciarlo. Ctrl+C, y devuélvele el archivo con kubectl exec -n lab-k8s probes -- touch /tmp/listo: en ≈ 2 s vuelve a 1/1.
kubectl exec -n lab-k8s probes -- rm /tmp/vivo
kubectl get pod probes -n lab-k8s -wQué deberías ver: En ≈ 6 s el kubelet declara la falla, corre el preStop (5 s) y reinicia el contenedor: RESTARTS pasa a 1. Es un contenedor nuevo, así que /tmp vuelve vacío: otra vez ≈ 15 s en 0/1 hasta que pasa la startup.
kubectl events -n lab-k8s --for pod/probesQué deberías ver: Eventos Unhealthy con Startup probe failed, Readiness probe failed y Liveness probe failed (cada uno con el error de cat), y un Killing con Container app failed liveness probe, will be restarted.
time kubectl delete pod probes -n lab-k8sQué deberías ver: Tarda ≈ 6 s: 5 s de preStop y, cuando llega el SIGTERM, el trap sale en menos de 1 s. Sin el trap, sh como PID 1 (el primer proceso del contenedor) ignora el SIGTERM y kubectl esperaría los 20 s del plazo hasta el SIGKILL (lo mismo que viste con docker stop en la hoja de Docker).
Cómo arreglarlo y confirmarlo
Arranque lento: una startupProbe con failureThreshold × periodSeconds mayor que el peor arranque, no un initialDelaySeconds largo en la liveness. Readiness que va y viene: busca la dependencia caída o la saturación; no toques la liveness. Requests cortados al desplegar: un preStop que cubra la propagación de los endpoints y terminationGracePeriodSeconds ≥ preStop + drenado + margen. Limpia con kubectl delete namespace lab-k8s.
Verificado contra la documentación; no ejecutado aquí. Campos, defaults y orden del apagado verificados en kubernetes.io (probes, ciclo de vida del pod, lifecycle hooks) para Kubernetes 1.37; los textos de los eventos (Startup probe failed, Container app failed liveness probe, will be restarted) salen del código del kubelet 1.37. busybox:1.38 es multi-arquitectura (verificado en Docker Hub).
¿Cuánta CPU, memoria y GPU reserva tu pod, qué pasa si se pasa, y a quién saca Kubernetes primero cuando a un nodo le falta memoria?
Sintaxis con variables
resources:
requests:
cpu: "cpu"
memory: mem
limits:
memory: memEl request es tu reserva: el scheduler, que elige el nodo, solo te pone donde cabe. El limit es el techo. C01: CPU sin limit; memoria con limit = request.
resources:
requests:
cpu: "cpu"
memory: mem
nvidia.com/gpu: gpus
limits:
memory: mem
nvidia.com/gpu: gpusLa GPU es un recurso extendido (Kubernetes no la trae de fábrica): la anuncia el device plugin de NVIDIA, un pod que corre en cada nodo GPU. Se pide en limits, en números enteros; si también va en requests, tiene que ser el mismo número.
resources:
requests:
cpu: "cpu"
memory: mem
limits:
cpu: "cpu"
memory: memClase de QoS (calidad de servicio) Guaranteed: request = limit de CPU y de memoria en cada contenedor. Suele ser de lo último en salir si al nodo le falta memoria; a cambio, el limit de CPU trae throttling (la CPU se frena al llegar al tope).
kubectl describe node nodo kubectl get pod pod -o jsonpath='{.status.qosClass}' kubectl top pod pod --containers
Lo que ve el scheduler (Allocatable, Allocated resources), la clase de QoS (calidad de servicio) y el uso real, que requiere metrics-server (el medidor de CPU y memoria).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| cpu | cores: "1", "2" (las comillas son opcionales), o milicores con m ("500m" = medio core) | "1" (gateway), "2" (Triton) |
| mem | bytes con sufijo: Mi y Gi son base 2; M y G, base 10 | 1Gi (gateway), 6Gi (Triton) |
| gpus | GPUs enteras. Salvo que configures una forma de compartirla (time-slicing: los pods se turnan; MIG: la GPU partida en pedazos), cada pod se queda con una GPU completa | 1 (una T4) |
| nodo | nombre del nodo, de kubectl get nodes | docker-desktop en tu Mac |
| pod | nombre del pod, de kubectl get pods | oom (en el lab) |
| request del pod que el scheduler quiere colocar; se compara por recurso: CPU, memoria y GPU | Triton: 2 CPU, 6Gi y 1 GPU | |
| lo que el nodo ofrece a los pods: su capacidad menos lo reservado para el sistema operativo, el kubelet (el agente del nodo) y el umbral de desalojo | una g4dn.xlarge tiene 4 vCPU, 16 GiB y 1 T4 de capacidad; Allocatable es algo menor en CPU y memoria | |
| en qué momento de cada ventana de 100 ms se acaba la cuota de CPU | 25 ms | |
| limits.cpu en cores | 1 | |
| hilos usando CPU a la vez | 4 decodes en paralelo | |
| utilización que mide el HPA (sección 9) | 0.6 cores / 1 core = 60 % |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| requests.cpu | Reserva para elegir nodo y peso cuando varios contenedores compiten por CPU. Es la base del % del HPA (el autoscaler de réplicas, sección 9). | "1" en el gateway: 60 % = 0.6 cores |
| limits.cpu | Cuota por ventana de 100 ms. Al agotarla, el kernel congela el contenedor hasta la siguiente (throttling). No lo mata. | C01 no lo pone |
| requests.memory | Reserva para elegir nodo. Si al nodo le falta memoria, salen primero los pods que usan más que su request. | 6Gi (Triton) |
| limits.memory | Techo duro. Si lo pasa, el kernel mata el proceso (OOMKilled, exit 137) y el kubelet reinicia el contenedor. | = request en C01 |
| Unidades | CPU en cores: 500m es medio core. Memoria en bytes: Mi y Gi base 2; M y G base 10. | cpu: 500m · memory: 512Mi (memory: 400m serían 0.4 bytes) |
| Solo limits, sin requests | Kubernetes copia el limit como request, salvo que un LimitRange (defaults por namespace) ponga otro. | limits.memory: 1Gi → request 1Gi |
| nvidia.com/gpu | Recurso extendido que anuncia el device plugin de NVIDIA. Enteros, sin sobreasignar, request = limit. | una T4 entera por pod de Triton |
| Allocatable · Allocated resources | Lo que el nodo ofrece y lo que ya está reservado. El scheduler suma requests, no el uso real. | kubectl describe node |
| Clase de QoS (calidad de servicio) | Guaranteed, Burstable o BestEffort según requests y limits de CPU y memoria. La GPU no cuenta. | gateway y Triton: Burstable |
| ephemeral-storage | Disco local del nodo: lo que el contenedor escribe fuera de volúmenes, sus logs y los emptyDir (carpetas temporales del pod). Pasarse del limit desaloja el pod. | C04: request 10Gi, limit 30Gi |
| emptyDir con medium: Memory | Carpeta en RAM (tmpfs). Lo que escribes ahí cuenta contra el limit de memoria del contenedor. | /dev/shm de Triton, sizeLimit 1Gi |
| LimitRange · ResourceQuota | Defaults de requests y limits por namespace, y topes totales del namespace (también de GPUs). | requests.nvidia.com/gpu: 4 |
| Resize en el lugar (in-place) | Cambia CPU y memoria de un pod sin recrearlo (estable desde 1.35). No aplica a GPUs ni cambia la QoS. | kubectl patch pod … --subresource resize (kubectl ≥ 1.32) |
| OOMKilled · Evicted | OOMKilled: el kernel lo mató por memoria (casi siempre, pasó su limit). Evicted: el kubelet sacó el pod porque al nodo le faltaba memoria o disco. | The node was low on resource: memory. |
request: la reservalimit de memoria: el techo (si lo pasa, OOMKilled)sin limit: usa lo libre del nodo
g4dn.xlarge (nodo de Triton)
capacidad: 4 CPU · 16 GiB · 1 GPU
Allocatable es algo menor en CPU y memoria: el sistema y el kubelet reservan una parte
triton · cpu
request 2 cores · sin limit
Si se pasa: sin limit: usa la CPU libre del nodo; nada lo congela
triton · memory
request 6 GiB · limit 6 GiB
Si se pasa: pasar 6 GiB (el /dev/shm cuenta): OOMKilled, exit 137
triton · nvidia.com/gpu
request 1 GPU · limit 1 GPU
Si se pasa: no hay "pasarse": la T4 es entera de este pod; otro Triton no cabe en el nodo
c6i.xlarge (nodo del gateway)
capacidad: 4 CPU · 8 GiB
gateway · cpu
request 1 core · sin limit
Si se pasa: sin limit: sin throttling; el HPA mide contra este 1 core
gateway · memory
request 1 GiB · limit 1 GiB
Si se pasa: pasar 1 GiB: OOMKilled, exit 137
Contraejemplo: con limit de CPU
contraejemplo · cpu
request 1 core · limit 1 core · 4 hilos
025 ms100 ms
Si se pasa: con 4 hilos agota la cuota a los 25 ms y espera hasta el ms 100: throttling
| Clase | Regla | Si al nodo le falta memoria | Ejemplo |
|---|---|---|---|
| Guaranteed | Todos los contenedores con request = limit de CPU y de memoria | Al final: nunca usa más de lo que pidió. Si el kernel tiene que matar, va último (oom_score_adj −997, el ajuste con que el kernel elige) | un pod con cpu 2/2 y memory 6Gi/6Gi |
| Burstable | Al menos un request o limit de CPU o memoria, sin llegar a Guaranteed | Si usa más de lo que pidió, compite con los BestEffort (decide la prioridad y cuánto se pasa); si no, sale al final | gateway y Triton de C01 (CPU sin limit) |
| BestEffort | Ningún request ni limit de CPU ni de memoria | Entre los primeros: todo lo que usa pasa su request de 0 (oom_score_adj 1000: el kernel lo mata primero) | un pod que solo pide nvidia.com/gpu: 1 |
El kubelet no mira la clase: saca primero a los pods que usan más memoria de la que pidieron y, entre ellos, decide por prioridad y por cuánto se pasan. La QoS solo sirve para predecir ese orden. Si el nodo se queda sin memoria antes de que el kubelet actúe, mata el kernel, y ahí sí pesa la clase.
resources:
requests:
cpu: "1" # base del HPA: 60 % de utilización = 0.6 cores por pod
memory: 1Gi # ≈ 80 MB de Python + 2 decodes de peor caso (≈ 320 MB c/u) + bodies en vuelo; con PREPROCESS_CONCURRENCY=4 sube a 2Gi (4 × 320 MB ya pasa de 1Gi)
limits:
memory: 1Gi # límite = request: si se pasa, OOMKilled claro en vez de afectar al nodo
# SIN límite de CPU a propósito: con límite, CFS reparte la CPU en ventanas de 100 ms;
# una ráfaga de decodes agota la cuota y el pod queda "throttled" el resto de la
# ventana → picos de p99 aunque el uso promedio sea bajo. El request ya garantiza su parte.
# … y el contenedor de k8s/triton.yaml (líneas 76–84):
resources:
requests:
cpu: "2" # Triton usa CPU para red, deserializar y el scheduler de batching
memory: 6Gi # librerías CUDA + buffers pinned; el modelo en sí vive en la GPU
nvidia.com/gpu: 1 # recurso extendido del NVIDIA device plugin: una T4 entera por pod
limits:
memory: 6Gi # memoria: límite = request (sin sorpresas de OOM por vecinos)
nvidia.com/gpu: 1 # las GPUs no se comparten ni se sobre-asignan: request = limit
# Sin límite de CPU: el nodo es solo para Triton y el throttling de CFS mete latenciaPREPROCESS_CONCURRENCY=2 del Deployment acota la concurrencia para que el pico quepa.nvidia.com/gpu igual en requests y limits. Una g4dn.xlarge tiene una sola T4: un segundo Triton no cabe en ese nodo (Insufficient nvidia.com/gpu, sección 8). Tampoco le alcanzaría la CPU: 2 + 2 cores contra un Allocatable de algo menos de 4./dev/shm de Triton es un emptyDir en RAM de hasta 1Gi (líneas 120–127): cuenta dentro de los 6Gi del limit.Cómo recordarla
request = la reserva, limit = el techo, y el scheduler solo mira reservas, nunca el uso real. Pasarte de CPU es esperar turno (throttling); pasarte de memoria es que te saquen (OOMKilled, 137).
Trampa
CUDA out of memory no es OOMKilled: OOMKilled es la RAM del contenedor contra limits.memory (exit 137, sin una línea en el log), mientras que el error de CUDA es la memoria de la GPU (VRAM) y aparece en el log, así que subir limits.memory no lo arregla. Un pod que solo pide nvidia.com/gpu es BestEffort: sin reserva de CPU ni de memoria, primero en salir y sin request contra el cual el HPA pueda medir. Y según el README del device plugin, un contenedor que corre en un nodo GPU sin pedir GPUs puede ver todas las del nodo.
En el curso: M6 · Los objetos que sí debes conocer M6 · GPUs en Kubernetes M4 · Compartir una GPU entre modelos Docker y kubectl · Depurar pods: síntoma → comandos → causa típica C01 · Archivos de referencia
Qué vas a provocar: Provocar un OOMKilled y un throttling de CPU en tu cluster local, leer la clase de QoS de cada pod y ver qué reserva el scheduler en el nodo.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster, con kubeadm o kind; contexto docker-desktop) o kind. La primera vez se baja python:3.12-slim (≈ 44 MB para arm64), la misma imagen de los labs de la hoja de Docker.
kubectl config current-context
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: oom
spec:
containers:
- name: app
image: python:3.12-slim
command: ["python", "-c", "b = bytearray(300 * 1024 * 1024); print('cupo')"]
resources:
requests:
memory: 64Mi
limits:
memory: 128Mi
EOF
kubectl get pod oom -n lab-k8s -wQué deberías ver: STATUS pasa por OOMKilled y después CrashLoopBackOff, con RESTARTS creciendo: el kubelet lo reinicia enseguida la primera vez y después con esperas de 10 s, 20 s, 40 s… hasta 5 min. Python pidió 300 MiB contra un limit de 128 MiB y nunca imprime cupo. Sal con Ctrl+C.
kubectl describe pod oom -n lab-k8s
kubectl get pod oom -n lab-k8s -o jsonpath='{.status.qosClass}'; echoQué deberías ver: En el contenedor: Last State: Terminated, Reason: OOMKilled, Exit Code: 137, y Restart Count creciendo. La QoS es Burstable: tiene memoria, pero no cumple Guaranteed (request ≠ limit y sin CPU).
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: cpu
spec:
containers:
- name: app
image: python:3.12-slim
command: ["python", "-c", "while True: pass"]
resources:
requests:
cpu: 500m
memory: 64Mi
limits:
cpu: 500m
memory: 64Mi
EOF
sleep 20
kubectl exec -n lab-k8s cpu -- cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.stat
kubectl get pod cpu -n lab-k8s -o jsonpath='{.status.qosClass}'; echoQué deberías ver: cpu.max dice 50000 100000: 50 ms de CPU por cada ventana de 100 ms, que es el limit de 500m. En cpu.stat, nr_throttled y throttled_usec ya son mayores que 0 y crecen si repites el comando: el bucle quiere un core entero y el kernel lo frena, no lo mata. Esta vez la QoS es Guaranteed (request = limit en CPU y memoria). Son archivos de cgroup v2 (el mecanismo del kernel que aplica los límites), el default de Docker Desktop.
NODE=$(kubectl get pod cpu -n lab-k8s -o jsonpath='{.spec.nodeName}')
kubectl describe node "$NODE" | grep -A 12 "Allocated resources"Qué deberías ver: La tabla Allocated resources suma los requests de los pods del nodo (incluidos los 500m del pod cpu), no su uso real. Un pod nuevo entra solo si su request cabe en Allocatable menos esa suma.
Cómo arreglarlo y confirmarlo
OOMKilled: mide el pico real (container_memory_working_set_bytes en Prometheus; kubectl top solo ve el presente) y fija limit = request con margen; si el pico viene de cargar el modelo o de decodes en paralelo, acota la concurrencia. Throttling: en servicios sensibles a latencia quita el limit de CPU y deja el request. En EKS (el Kubernetes administrado de AWS) la GPU se pide con nvidia.com/gpu en limits; en tu Mac no hay device plugin, así que un pod que la pida queda Pending (sección 8). Limpia con kubectl delete namespace lab-k8s (tarda ≈ 30 s: python como PID 1, el primer proceso del contenedor, no atiende el SIGTERM y espera el SIGKILL).
Verificado contra la documentación; no ejecutado aquí. Requests, limits, QoS, orden de desalojo y oom_score_adj verificados en kubernetes.io (Kubernetes 1.37); cpu.max y cpu.stat según la documentación de cgroup v2 del kernel. bytearray(n) llena la memoria con ceros (memset en CPython), así que las páginas se usan de verdad.
¿Cómo le das a un pod su configuración, sus secretos, un lugar donde escribir y permisos de AWS, sin meter nada de eso en la imagen?
Sintaxis con variables
kubectl create configmap cm --from-literal=CLAVE=valor kubectl create secret generic secreto --from-literal=CLAVE=valor kubectl rollout restart deployment/deploy
Un ConfigMap guarda config no secreta (clave–valor); un Secret, lo sensible, en base64: codificado, no cifrado. Tras cambiarlos, rollout restart recrea los pods para que las variables tomen el valor nuevo.
envFrom:
- configMapRef:
name: cm
env:
- name: VAR
valueFrom:
secretKeyRef:
name: secreto
key: CLAVEComo variables de entorno: todas las claves del ConfigMap, o una variable desde una clave puntual. Se leen una sola vez, al arrancar el contenedor.
volumeMounts:
- name: vol
mountPath: ruta
readOnly: true
volumes:
- name: vol
configMap:
name: cm
- name: tmp
emptyDir:
sizeLimit: tamañoComo archivos: un volumen (carpeta montada en el contenedor) con un archivo por clave; se actualiza solo, salvo con subPath (montar solo una parte del volumen). El emptyDir es temporal: vive lo que el pod.
# en el pod serviceAccountName: sa # IRSA: anotación en la ServiceAccount kubectl annotate serviceaccount sa -n ns eks.amazonaws.com/role-arn=arn:aws:iam::cuenta:role/rol # EKS Pod Identity: asociación en EKS, sin anotación aws eks create-pod-identity-association --cluster-name cluster --namespace ns --service-account sa --role-arn arn:aws:iam::cuenta:role/rol
Una ServiceAccount es la identidad del pod. En EKS (el Kubernetes administrado de AWS) hay dos formas de darle un rol de IAM (permisos de AWS) sin llaves: IRSA (roles de IAM para ServiceAccounts) y EKS Pod Identity. En las dos, los pods que ya corrían deben recrearse.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| cm | nombre del ConfigMap | app-config (seg-worker-config en C04) |
| secreto | nombre del Secret | app-secret |
| CLAVE | clave dentro del ConfigMap o del Secret; con envFrom, también es el nombre de la variable | INFER_TIMEOUT_S |
| valor | su valor; en un Secret, la API lo muestra en base64 (codificado, no cifrado) | 0.15 |
| VAR | nombre de la variable de entorno en el contenedor | API_KEY |
| deploy | Deployment cuyos pods hay que recrear | gateway |
| vol | nombre del volumen; une volumes con volumeMounts | config |
| ruta | carpeta donde aparecen los archivos | /config (C04) |
| tmp | nombre del volumen temporal | tmp |
| tamaño | tope del emptyDir; pasarlo desaloja el pod | 64Mi (gateway), 20Gi (C04) |
| sa | ServiceAccount: la identidad del pod | seg-worker (C04) |
| ns | namespace de la ServiceAccount | c04 |
| cuenta | ID de la cuenta de AWS (12 dígitos) | 123456789012 |
| rol | rol de IAM con solo los permisos que ese pod necesita | c04-seg-worker |
| cluster | nombre del cluster de EKS | prod-ml (ejemplo) |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| ConfigMap | Pares clave–valor no secretos, hasta 1 MiB. Llegan como variables de entorno o como archivos. | MODEL_NAME, INFER_TIMEOUT_S |
| Secret | Como un ConfigMap, para datos sensibles. data va en base64 (codificado, no cifrado); stringData acepta texto plano. Tipo default: Opaque. | kubectl create secret generic |
| envFrom · valueFrom | Todas las claves como variables, o una variable desde una clave (configMapKeyRef, secretKeyRef) o desde un campo del pod (fieldRef). | POD_NAME desde metadata.name (C04) |
| Volumen configMap o secret | Cada clave es un archivo. Se actualiza solo (puede tardar hasta ≈ 90 s), salvo con subPath. Los de un Secret viven en RAM (tmpfs). | /config/worker.yaml (C04) |
| kubectl rollout restart | Recrea los pods de un Deployment con rolling update (de a poco): así las variables de entorno toman el valor nuevo. | kubectl rollout restart deployment/gateway |
| immutable · optional | immutable: true impide cambios (solo borrar y recrear). optional: true deja arrancar al pod aunque falte el objeto. | configMapRef con optional: true |
| Cifrado en reposo | Cifra los Secrets dentro de etcd, la base de datos del cluster. En EKS viene activo desde 2025, con KMS (el servicio de llaves de AWS). | EKS 1.28+; quien puede leer Secrets o crear pods en el namespace igual los lee |
| emptyDir | Carpeta temporal del pod: nace vacía, sobrevive a reinicios del contenedor y se borra con el pod. medium Memory: RAM. | /tmp 64Mi (gateway), /dev/shm 1Gi (Triton) |
| PVC (PersistentVolumeClaim) | Pedido de disco que sobrevive al pod. En EBS (el disco de red de AWS): un nodo a la vez y una sola zona. | accessModes: ["ReadWriteOnce"]; la StorageClass (plantilla de disco) dice qué disco crear; EFS (archivos compartidos de AWS) sirve para ReadWriteMany |
| ServiceAccount | Identidad del pod ante la API (permisos por roles, RBAC: sección 11) y, con IRSA o Pod Identity, ante AWS. | default si no la indicas · kubectl auth can-i get secrets --as=system:serviceaccount:c04:seg-worker -n c04 |
| automountServiceAccountToken: false | No monta el token de la API en /var/run/secrets/kubernetes.io/serviceaccount: para pods que no hablan con Kubernetes. | asr-worker (C13) |
| IRSA (IAM roles for service accounts) | Anotas la ServiceAccount con el rol. Al crear el pod, un webhook le agrega un token; el SDK (la librería) de AWS lo canjea por credenciales temporales. | eks.amazonaws.com/role-arn (requiere registrar en IAM el proveedor OIDC del cluster, su emisor de tokens) |
| EKS Pod Identity | Asociación rol ↔ ServiceAccount guardada en EKS, sin anotación ni OIDC; un agente por nodo entrega las credenciales. | aws eks create-pod-identity-association (AWS la recomienda cuando se puede; no funciona en Fargate, los nodos sin servidor de AWS) |
| CreateContainerConfigError | Lo más común: una variable de entorno pide un ConfigMap, Secret o clave que no existe. El contenedor no se crea; el kubelet reintenta. | configmap "app-config" not found (si falta en un volumen: ContainerCreating con FailedMount) |
| Pieza | Para qué | Cómo llega al contenedor | ¿Cambia sin recrear el pod? | Dura hasta… |
|---|---|---|---|---|
| ConfigMap | config no secreta: URLs, timeouts, nombre del modelo | variables de entorno o archivos | env: no (rollout restart); archivo: sí, en hasta ≈ 90 s; con subPath: no | que lo borres |
| Secret | credenciales de terceros y contraseñas | variables de entorno o archivos en tmpfs (RAM) | igual que el ConfigMap | que lo borres |
| emptyDir | scratch, /tmp, /dev/shm | volumen vacío al crear el pod | no aplica: lo escribe el pod | que el pod deje el nodo (sobrevive a reinicios del contenedor) |
| PVC | datos que tienen que sobrevivir al pod | volumen de un disco (EBS: un nodo a la vez, una zona) | no aplica | que borres el PVC (con reclaimPolicy Delete, el default, se borra el disco) |
| ServiceAccount | identidad ante la API de Kubernetes y, con IRSA o Pod Identity, ante AWS | token montado y variables AWS_* | el token se renueva solo; un rol nuevo exige recrear los pods | que la borres |
apiVersion: v1
kind: ServiceAccount
metadata:
name: seg-worker
namespace: c04
annotations:
# … (comentarios: los permisos mínimos del rol, SQS y S3 sin DeleteObject)
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/c04-seg-worker
# … el Deployment seg-worker usa serviceAccountName: seg-worker; en su contenedor:
volumeMounts:
- name: scratch
mountPath: /scratch # el WSI descargado (0.5–3 GB) + el archivo de liveness
- name: tmp
mountPath: /tmp # el root filesystem es de solo lectura
- name: config
mountPath: /config
readOnly: true
volumes:
# emptyDir vive en el disco del kubelet. Para que sea el NVMe local del g6.2xlarge (y no el EBS
# raíz), el nodo debe montar el instance store como almacenamiento efímero (en Karpenter:
# instanceStorePolicy: RAID0 en el EC2NodeClass). Un slide a la vez: 20 GiB sobran.
- name: scratch
emptyDir:
sizeLimit: 20Gi
- name: tmp
emptyDir:
sizeLimit: 1Gi
- name: config
configMap:
name: seg-worker-configseg-worker lleva la anotación de IRSA. Cada pod que la usa recibe, por un webhook (un servicio que retoca cada pod al crearlo), AWS_ROLE_ARN y AWS_WEB_IDENTITY_TOKEN_FILE; el SDK de AWS cambia ese token por credenciales temporales del rol. No hay llaves de AWS en el YAML ni en un Secret.aws eks create-pod-identity-association) y el rol confía en pods.eks.amazonaws.com. AWS la recomienda cuando se puede; los manifests del curso usan IRSA, que también funciona fuera de EKS.seg-worker-config se monta de solo lectura en /config y el worker lee /config/worker.yaml. Si lo editas, el archivo cambia solo (puede tardar hasta ≈ 90 s), pero el worker tiene que volver a leerlo; una variable de entorno no cambiaría./scratch (20Gi para el WSI, la imagen de microscopio que baja el worker) y /tmp (1Gi, porque la raíz es de solo lectura). Viven lo que vive el pod y cuentan como almacenamiento efímero (el pod pide 10Gi, con limit de 30Gi). Pasarse de sizeLimit desaloja el pod: Usage of EmptyDir volume "scratch" exceeds the limit "20Gi".env literal en el Deployment (TRITON_URL, INFER_TIMEOUT_S). Cambiar un valor ahí cambia la plantilla del pod y dispara un rollout; cambiar un ConfigMap, no. Por eso Kustomize (la herramienta que arma manifests, la de kubectl apply -k) agrega un hash al nombre del ConfigMap que genera.Cómo recordarla
ConfigMap = config, Secret = config sensible, volumen = disco, ServiceAccount = identidad. Un Secret es una carta en un sobre sin sellar: base64 no es cifrado, y quien puede leerlo la lee.
Trampa
creer que un Secret está cifrado porque no se lee a simple vista: data es base64 y base64 --decode lo revela; lo protegen el cifrado en reposo (en EKS, activo por defecto desde 2025) y, sobre todo, quién puede leer Secrets o crear pods en el namespace. Y nada se recarga solo: cambiar un ConfigMap no reinicia los pods (sus variables de entorno siguen con el valor viejo hasta un kubectl rollout restart), y anotar la ServiceAccount con un rol tampoco toca a los que ya corren.
En el curso: M15 · Secretos y credenciales M15 · Autenticación y autorización entre servicios M6 · Los objetos que sí debes conocer M5 · Pesos del modelo: ¿dentro de la imagen o al arrancar? C04 · Archivos de referencia
Qué vas a provocar: Pasarle config y un secreto a un pod como variables y como archivo, comprobar que base64 no esconde nada, cambiar el ConfigMap y ver qué se actualiza solo, y provocar un CreateContainerConfigError.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster, con kubeadm o kind; contexto docker-desktop) o kind. La primera vez se baja busybox:1.38 (≈ 2 MB para arm64).
kubectl config current-context
kubectl create namespace lab-k8s
kubectl create configmap app-config -n lab-k8s --from-literal=MODEL_NAME=classifier --from-literal=INFER_TIMEOUT_S=0.15
kubectl create secret generic app-secret -n lab-k8s --from-literal=API_KEY=solo-para-el-lab
kubectl get secret app-secret -n lab-k8s -o jsonpath='{.data.API_KEY}'; echo
kubectl get secret app-secret -n lab-k8s -o jsonpath='{.data.API_KEY}' | base64 --decode; echoQué deberías ver: configmap/app-config created y secret/app-secret created. El primer get imprime c29sby1wYXJhLWVsLWxhYg== y el segundo, solo-para-el-lab: base64 es una codificación, no un cifrado.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: config-demo
spec:
replicas: 1
selector:
matchLabels:
app: config-demo
template:
metadata:
labels:
app: config-demo
spec:
automountServiceAccountToken: false
containers:
- name: app
image: busybox:1.38
command: ["sh", "-c", "trap 'exit 0' TERM; while true; do echo \"env=$INFER_TIMEOUT_S archivo=$(cat /etc/app/INFER_TIMEOUT_S) clave=${#API_KEY} caracteres\"; sleep 5; done"]
envFrom:
- configMapRef:
name: app-config
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: app-secret
key: API_KEY
volumeMounts:
- name: config
mountPath: /etc/app
readOnly: true
volumes:
- name: config
configMap:
name: app-config
EOF
kubectl rollout status deployment/config-demo -n lab-k8s
kubectl logs -n lab-k8s deploy/config-demo -fQué deberías ver: Cada 5 s: env=0.15 archivo=0.15 clave=16 caracteres. El programa imprime el largo del secreto, nunca el secreto: no lo mandes a los logs. Ctrl+C deja de seguir.
kubectl patch configmap app-config -n lab-k8s --type merge -p '{"data":{"INFER_TIMEOUT_S":"0.30"}}'
kubectl logs -n lab-k8s deploy/config-demo -fQué deberías ver: configmap/app-config patched. En hasta ≈ 90 s el archivo pasa a 0.30 (el kubelet revisa cada pod cada 60–90 s y ahí actualiza el volumen), pero env sigue en 0.15: la variable se leyó al arrancar. Ctrl+C.
kubectl rollout restart deployment/config-demo -n lab-k8s
kubectl rollout status deployment/config-demo -n lab-k8s
kubectl logs -n lab-k8s -l app=config-demo --prefix --tail=2Qué deberías ver: El pod nuevo imprime env=0.30 archivo=0.30 (--prefix muestra de qué pod viene cada línea; si el viejo todavía no termina, verás sus líneas con 0.15). rollout restart recrea los pods con rolling update: con varias réplicas, de a poco y sin cortar el servicio.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: sin-config
spec:
containers:
- name: app
image: busybox:1.38
command: ["sleep", "3600"]
envFrom:
- configMapRef:
name: no-existe
EOF
kubectl get pod sin-config -n lab-k8s
kubectl events -n lab-k8s --for pod/sin-configQué deberías ver: STATUS CreateContainerConfigError (si todavía dice ContainerCreating, repite el get) y un evento Failed con Error: configmap "no-existe" not found. El kubelet reintenta solo: si creas ese ConfigMap (o marcas la referencia con optional: true en un pod nuevo), el contenedor arranca.
Cómo arreglarlo y confirmarlo
Config que no se aplica: si es una variable de entorno, kubectl rollout restart; si es un archivo, revisa que no uses subPath y que la app lo vuelva a leer. CreateContainerConfigError: crea el ConfigMap o el Secret (o la clave) que falta en ese namespace. En EKS, las credenciales de AWS por IRSA o Pod Identity, nunca llaves en un Secret. Limpia con kubectl delete namespace lab-k8s.
Verificado contra la documentación; no ejecutado aquí. Comportamiento verificado en kubernetes.io (ConfigMap, Secret, volúmenes, ServiceAccount) y en el código del kubelet 1.37 (configmap "…" not found, CreateContainerConfigError). Los ≈ 90 s salen del período de sync del kubelet (1 min por defecto, más hasta 50 % de variación al azar en pod_workers.go de 1.37) más la caché. El base64 se calculó localmente.
¿Cómo decides en qué nodos puede correr cada pod, cómo apartas los nodos GPU para quien los usa, y qué pasa cuando un pod no cabe en ninguno?
Sintaxis con variables
spec:
nodeSelector:
label: valor
tolerations:
- key: clave_taint
operator: Equal
value: valor_taint
effect: NoScheduleEl scheduler elige nodo para cada pod nuevo: solo usa nodos con estas labels (etiquetas). Un taint (marca en el nodo) rechaza pods; la toleration es el permiso para entrar igual.
kubectl label nodes nodo label=valor
kubectl taint nodes nodo clave_taint=valor_taint:NoSchedule
kubectl taint nodes nodo clave_taint=valor_taint:NoSchedule-
kubectl get nodes -L label,topology.kubernetes.io/zoneEtiquetar, poner y quitar un taint (el guion final lo quita), y ver labels como columnas. En EKS los fija el node group (grupo de máquinas iguales) o el NodePool de Karpenter (un autoscaler de nodos).
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: label
operator: In
values: ["valor", "otro_valor"]Node affinity (afinidad de nodo): como nodeSelector, con operadores (In, NotIn, Exists, DoesNotExist, Gt, Lt) y una versión suave, preferredDuringSchedulingIgnoredDuringExecution, con peso de 1 a 100.
topologySpreadConstraints:
- maxSkew: sesgo
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: si_no_cabe
labelSelector:
matchLabels:
app.kubernetes.io/name: appReparte las réplicas entre zonas (datacenters separados de una región) o entre nodos. Si no se puede, DoNotSchedule deja el pod Pending; ScheduleAnyway lo pone igual, con un reparto imperfecto.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| label | clave de la label del nodo | workload |
| valor | valor de la label | gpu-t4 |
| otro_valor | otro valor aceptado: In acepta una lista | us-east-1b, si la clave es topology.kubernetes.io/zone y el primer valor us-east-1a |
| clave_taint | clave del taint; para GPUs, el nombre del recurso | nvidia.com/gpu |
| valor_taint | valor del taint | present |
| nodo | nombre del nodo, de kubectl get nodes | docker-desktop en tu Mac |
| sesgo | maxSkew: diferencia máxima de réplicas entre zonas | 1 |
| si_no_cabe | DoNotSchedule (default: queda Pending) o ScheduleAnyway (lo pone igual) | DoNotSchedule (Triton), ScheduleAnyway (gateway) |
| app | label de las réplicas que se cuentan | triton |
| réplicas que ya hay en la zona candidata | 1 en us-east-1a | |
| réplicas en cada zona elegible; cuenta la menor | 0 en us-east-1b | |
| desbalance permitido | 1: la 2.ª réplica de Triton solo puede ir a 1b | |
| nvidia.com/gpu en Allocatable (lo que el nodo ofrece a los pods) | 1 en una g4dn.xlarge; 4 en una g4dn.12xlarge | |
| nvidia.com/gpu en limits | 1 (Triton) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Scheduler (kube-scheduler) | Elige nodo para cada pod nuevo: descarta los que no sirven y puntúa el resto. No mueve pods ya puestos. | evento Scheduled: Successfully assigned … |
| Labels de nodo | Etiquetas clave=valor. Kubernetes y la nube ponen algunas; tú agregas las tuyas. | topology.kubernetes.io/zone, node.kubernetes.io/instance-type, workload=gpu-t4 |
| nodeSelector | Solo nodos con todas estas labels. Lo más simple para fijar un tipo de nodo. | workload: gpu-t4 |
| nodeAffinity | nodeSelector con operadores: required (obligatorio) o preferred (con peso). IgnoredDuringExecution: si la label cambia después, el pod sigue donde está. | operator: In |
| podAffinity · podAntiAffinity | Reglas respecto de otros pods: juntos o separados por nodo o por zona (topologyKey). | no dos réplicas en el mismo nodo |
| topologySpreadConstraints | Reparte réplicas entre zonas o nodos con un desbalance máximo (maxSkew). | Triton: una por zona |
| Taint | Marca del nodo que rechaza pods sin toleration. NoSchedule; PreferNoSchedule, suave; NoExecute, además saca a los que corren. | nvidia.com/gpu=present:NoSchedule |
| Toleration | Permiso del pod para entrar a un nodo con ese taint. No lo obliga a ir ahí. | key: nvidia.com/gpu, operator: Equal, value: present |
| ExtendedResourceToleration | Admission controller (filtro del API server) activo en EKS: a los pods que piden GPU les agrega por su cuenta la toleration nvidia.com/gpu (Exists). | funciona porque el taint usa el nombre del recurso |
| Taints automáticos | not-ready y unreachable (NoExecute): por defecto el pod los tolera 300 s y después se desaloja. | node.kubernetes.io/unreachable |
| PriorityClass y preemption | Prioridad del pod. Si no cabe, el scheduler puede desalojar pods de menor prioridad para hacerle lugar. | placeholder de prioridad −10 que reserva un nodo GPU (M06) |
| FailedScheduling | Evento con el motivo de cada nodo descartado. El pod queda Pending, con PodScheduled=False y reason Unschedulable. | 0/6 nodes are available: … |
| Karpenter · Cluster Autoscaler | Ven los pods Unschedulable y agregan un nodo que los satisfaga (label, taint, GPU, zona); lo quitan cuando sobra. | NodePool de T4 con la label workload: gpu-t4 y el taint |
| nodeName | Asigna el nodo a mano y se salta el scheduler, incluidos los taints NoSchedule. Casi nunca lo quieres. | — |
pods ↓ · nodos →
cpu-1a
cpu-1b
gpu-1a
label workload=
taint nvidia.com/
GPU ocupada
ya corre la 1.ª réplica de Triton
gpu-1b
label workload=
taint nvidia.com/
1 GPU libre
gateway
nodeSelector ninguno
toleration ninguna
pide cpu 1, memory 1Gi
triton (2.ª réplica)
nodeSelector workload=
toleration nvidia.com/
pide cpu 2, memory 6Gi, nvidia.com/
spread maxSkew 1 por zona
triton (3.ª réplica)
nodeSelector workload=
toleration nvidia.com/
pide cpu 2, memory 6Gi, nvidia.com/
spread maxSkew 1 por zona
✓ el scheduler puede ponerlo ahí✗ el motivo que verías en FailedSchedulingnodo GPU
Y después: La 3.ª réplica queda Pending. Karpenter lanza una g4dn.xlarge con la label y el taint de su NodePool; el spread la acepta en cualquiera de las dos zonas (1 + 1 − 1 = 1).
| Mensaje (parte central) | Qué significa | Qué haces |
|---|---|---|
| Insufficient nvidia.com/gpu | Ningún nodo tiene una GPU libre, o ninguno la anuncia | Espera al autoscaler de nodos o revisa su límite, la cuota y la capacidad de la zona; si el nodo GPU existe y no la muestra en Allocatable, revisa el device plugin (el pod que anuncia las GPUs del nodo) |
| node(s) didn't match Pod's node affinity/selector | Ningún nodo tiene la label que pide el pod | Corrige la label en el pod o en el NodePool o node group (un typo es lo más común) |
| node(s) had untolerated taint(s) | Nodos con un taint que el pod no tolera. Desde 1.35 (y 1.34.2) el mensaje no dice cuál; antes: node(s) had untolerated taint {nvidia.com/gpu: present} | Agrega la toleration, o mira el taint con kubectl describe node (bloque Taints) |
| node(s) didn't match pod topology spread constraints | Ponerlo ahí rompería el maxSkew | Capacidad en la otra zona, o ScheduleAnyway |
| Insufficient cpu · Insufficient memory | Los requests ya llenan los nodos, aunque el uso real sea bajo | Requests realistas o más nodos |
Formato: 0/6 nodes are available: 2 Insufficient nvidia.com/gpu, 4 node(s) didn't match Pod's node affinity/selector. preemption: … El número delante de cada motivo es cuántos nodos lo dieron.
nodeSelector:
workload: gpu-t4 # solo el node group de g4dn.xlarge (T4, sm_75): el engine se compiló para esa GPU
tolerations:
# Los nodos GPU tienen el taint nvidia.com/gpu=present:NoSchedule para que ningún pod CPU
# (gateway, sistema) ocupe una máquina cara; Triton lo tolera explícitamente.
- key: nvidia.com/gpu
operator: Equal
value: present
effect: NoSchedule
topologySpreadConstraints:
# Réplicas balanceadas entre zonas (con 2: una por zona). Si cae una AZ, la otra GPU sigue sirviendo (N+1).
# DoNotSchedule: preferimos un pod Pending (y una alerta) a dos GPUs en la misma AZ.
# Ojo: con nodeTaintsPolicy por defecto (Ignore), los nodos NotReady de una AZ caída siguen contando
# como dominio con 0 pods: la zona sana no pasa de 1 réplica y KEDA no puede escalar durante la caída.
# Aquí es aceptable (1 T4 aguanta el pico de diseño al ≈ 42 %). nodeTaintsPolicy: Honor lo evita, pero
# un taint transitorio (nodo arrancando o drenando) puede dejar las dos GPUs en la misma AZ: pruébalo en un game day.
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: tritonnodeSelector: workload: gpu-t4 fija el tipo de GPU: el engine de TensorRT se compiló para la T4 (su arquitectura, sm_75) y en otra GPU no carga. Pedir nvidia.com/gpu solo asegura un nodo con alguna GPU libre.nvidia.com/gpu=present:NoSchedule de los nodos GPU (Equal y el mismo valor). El taint aleja al gateway y a todo pod sin esa toleration (los DaemonSets del sistema, que ponen un pod en cada nodo, como el device plugin, suelen traerla): gateway.yaml no tiene toleration, así que nunca cae en una máquina cara.ExtendedResourceToleration: a cualquier pod que pida nvidia.com/gpu le agrega una toleration nvidia.com/gpu con Exists, porque el taint usa el nombre del recurso como clave. C01 la escribe igual: queda a la vista y funciona en clusters sin ese admission controller.maxSkew: 1 y DoNotSchedule: con 1 Triton en 1a y 0 en 1b, el segundo solo puede ir a 1b (1 + 1 − 0 = 2 en 1a no cumple; 0 + 1 − 0 = 1 en 1b sí). Si en 1b no hay GPU libre, espera Pending a que llegue un nodo ahí.nodeTaintsPolicy por defecto (Ignore), los nodos caídos de una zona siguen contando con 0 réplicas, así que durante esa caída la zona sana no pasa de una.Cómo recordarla
label + nodeSelector atrae; el taint repele y la toleration deja pasar. El nodeSelector es buscar la puerta con cierto letrero; el taint es un control en la puerta que solo deja pasar con pase, y la toleration es el pase: te deja entrar, no te obliga a ir; en nodos GPU usas los dos.
Trampa
leer Pending como "faltan nodos" sin leer el mensaje. node(s) didn't match Pod's node affinity/selector suele ser un typo en la label, y si ningún NodePool o node group pone esa label, ningún autoscaler va a crear un nodo para ese pod: espera para siempre. Y desde 1.35 (y 1.34.2) el mensaje del taint ya no dice cuál es: míralo en el nodo con kubectl describe node.
En el curso: M6 · GPUs en Kubernetes M6 · Aprovisionar nodos GPU: Cluster Autoscaler y Karpenter M6 · Alta disponibilidad: multi-AZ, PDB y N+1 Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Docker y kubectl · Autoscaling y capacidad
Qué vas a provocar: Ver dos motivos de Pending (una GPU que no existe y una label que nadie tiene), arreglar uno con una label y comprobar que IgnoredDuringExecution es literal.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster, con kubeadm o kind; contexto docker-desktop) o kind. La primera vez se baja busybox:1.38 (≈ 2 MB para arm64).
kubectl config current-context
kubectl create namespace lab-k8s
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: quiere-gpu
spec:
containers:
- name: app
image: busybox:1.38
command: ["sleep", "3600"]
resources:
limits:
nvidia.com/gpu: 1
---
apiVersion: v1
kind: Pod
metadata:
name: quiere-t4
spec:
nodeSelector:
lab-k8s/workload: gpu-t4
containers:
- name: app
image: busybox:1.38
command: ["sleep", "3600"]
EOF
kubectl get pods -n lab-k8sQué deberías ver: El contexto tiene que ser docker-desktop (o tu cluster kind): este lab etiqueta nodos. Los dos pods quedan Pending: ningún nodo los acepta y nada está creando nodos. La label es lab-k8s/workload y no la workload de C01, para no tocar labels reales si te equivocas de cluster.
kubectl events -n lab-k8s --for pod/quiere-gpu
kubectl events -n lab-k8s --for pod/quiere-t4Qué deberías ver: FailedScheduling con 0/1 nodes are available: 1 Insufficient nvidia.com/gpu. para el primero y 0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector. para el segundo, cada uno seguido de una parte preemption: … Con varios nodos (kind) cambian los números, y el nodo de control plane puede sumar node(s) had untolerated taint(s).
kubectl describe nodes | grep -A 8 Allocatable
kubectl label nodes --all lab-k8s/workload=gpu-t4
kubectl get pod quiere-t4 -n lab-k8s -o wideQué deberías ver: Allocatable muestra cpu, memory, ephemeral-storage y pods (y unas líneas hugepages-…), pero no nvidia.com/gpu: sin device plugin no hay GPUs que repartir. Con la label, quiere-t4 pasa a Running en segundos (si todavía dice Pending, repite el get; -o wide muestra el nodo). quiere-gpu sigue Pending: una label no crea una GPU.
kubectl label nodes --all lab-k8s/workload-
kubectl get pod quiere-t4 -n lab-k8s -o wideQué deberías ver: quiere-t4 sigue Running aunque ningún nodo tenga ya la label: IgnoredDuringExecution. El nodeSelector solo cuenta al elegir nodo.
Cómo arreglarlo y confirmarlo
Insufficient nvidia.com/gpu: en EKS, espera o destraba al autoscaler de nodos (límites del NodePool, cuota de vCPUs de instancias G, capacidad en la zona); si el nodo GPU existe y no muestra la GPU en Allocatable, revisa el DaemonSet del device plugin. didn't match … selector: corrige la label en el pod o en el NodePool. untolerated taint(s): agrega la toleration. Limpia con kubectl delete namespace lab-k8s (y kubectl label nodes --all lab-k8s/workload- si saltaste el paso 4).
Verificado contra la documentación; no ejecutado aquí. Los motivos y el formato de FailedScheduling (0/N nodes are available, Insufficient …, node(s) didn't match Pod's node affinity/selector, node(s) had untolerated taint(s)) salen del código del scheduler de Kubernetes 1.37; el cambio del mensaje del taint, de comparar 1.33.x, 1.34.1, 1.34.2 y 1.35.0. El resto, de kubernetes.io (asignar pods a nodos, taints y tolerations, topology spread, admission controllers) y de la lista de admission controllers de EKS.
¿Quién decide cuántas réplicas corren, con qué métrica, y qué pasa cuando las réplicas nuevas no tienen nodo donde caer?
Sintaxis con variables
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nombre
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: deployment
minReplicas: min
maxReplicas: max
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: porcentajeUn HPA (HorizontalPodAutoscaler) revisa una métrica cada 15 s y ajusta cuántas réplicas tiene el Deployment. Con Utilization, el objetivo es un % del request de CPU: sin request, no escala.
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: pods_por_periodo
periodSeconds: periodo
scaleDown:
stabilizationWindowSeconds: ventanaLa velocidad: cuánto puede subir por período y cuánto espera antes de bajar. Si escribes policies, reemplazan a las del default.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: nombre
spec:
scaleTargetRef:
name: deployment
minReplicaCount: min
maxReplicaCount: max
triggers:
- type: prometheus
metricType: Value
metadata:
serverAddress: url_prometheus
query: query
threshold: "umbral"KEDA (Kubernetes Event-driven Autoscaling) es un operador (un controlador que instalas). Con este ScaledObject crea el HPA keda-hpa- + nombre y le sirve la métrica. Escribe minReplicaCount: su default es 0.
kubectl get hpa -n ns -w kubectl describe hpa nombre -n ns kubectl get scaledobject -n ns kubectl get hpa keda-hpa-nombre -n ns
TARGETS es actual/objetivo, como cpu: 45%/60%; si donde va el número dice unknown, no llega la métrica. describe muestra las condiciones (ScalingActive, ScalingLimited) y los eventos.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| nombre | nombre del HPA o del ScaledObject | gateway · triton |
| deployment | Deployment que escala | gateway · triton |
| min | piso de réplicas | 3 (gateway), 2 (Triton) |
| max | techo de réplicas | 12 (gateway), 4 (Triton) |
| porcentaje | objetivo: % del request de CPU, promedio de los pods | 60 |
| pods_por_periodo | pods que puede agregar como mucho en cada período | 1 (Triton) |
| periodo | largo del período de la política, en s (máximo 1800) | 60 |
| ventana | ventana de estabilización al bajar, en s (default 300) | 300 (gateway), 600 (Triton) |
| url_prometheus | dirección del Prometheus (la base de métricas) del cluster | http://prometheus-operated.monitoring.svc.cluster.local:9090 |
| query | consulta en PromQL (el lenguaje de Prometheus) que devuelve un solo número | espera media en la cola de Triton, en ms |
| umbral | objetivo de la métrica; en KEDA va como texto | 10 |
| ns | namespace | classifier |
| réplicas que pide el HPA; después las recortan min, max y behavior | 8 (gateway); 5 → 4 (Triton) | |
| réplicas actuales; con métricas Value de KEDA (External), las Ready | 5 (gateway); 2 (Triton) | |
| valor actual: % de CPU promedio o lo que devuelve la query | 90 %; 25 ms | |
| el target del HPA o el threshold de KEDA | 60 %; 10 ms | |
| suma de la métrica en todos los pods (mensajes en cola, req/s) | 120 mensajes en una cola de SQS (M06) | |
| cuánto de esa métrica le toca a cada réplica | 15 → ⌈120 / 15⌉ = 8 workers | |
| de que la carga sube a que el pod nuevo atiende | ≈ 5–13 min en C01 (M06) | |
| Prometheus lee la métrica (scrape) + ventana de la query + ciclo del HPA | ≈ 0.5–1.5 min | |
| lanzar la instancia, unirse al cluster y que el device plugin (el pod que anuncia las GPUs del nodo) anuncie la GPU | ≈ 2–5 min | |
| descargar la imagen de Triton (≈ 9 GB) | ≈ 2–5 min | |
| arrancar Triton, cargar el engine y pasar la readiness | ≈ 15–75 s |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| HPA (HorizontalPodAutoscaler) | Controlador que cada 15 s calcula réplicas con una métrica y cambia replicas del Deployment. | kubectl autoscale deployment gateway --min=3 --max=12 --cpu=60% (kubectl ≥ 1.34; antes, --cpu-percent=60) |
| minReplicas · maxReplicas | Piso y techo. El HPA los impone aunque no consiga la métrica. | 3–12 (gateway), 2–4 (Triton) |
| Resource + Utilization | Uso de CPU como % del request, promedio de los pods. Sin request, el HPA no puede medir ni actuar. | averageUtilization: 60 |
| Pods · Object · External | Métricas que no son CPU ni memoria: por pod, de un objeto del cluster o de afuera (colas, queries). | KEDA publica External |
| metrics-server | Publica CPU y memoria (la API metrics.k8s.io). Sin él: TARGETS cpu: <unknown>/60% y kubectl top no funciona. | Docker Desktop no lo trae |
| behavior: policies | Cuánto puede cambiar por período, en pods o en %. Si hay varias, gana la que permite más (selectPolicy: Max). | Triton: +1 pod cada 60 s |
| stabilizationWindowSeconds | Al bajar, usa la recomendación más alta de la ventana (default 300 s): baja despacio, sin oscilar. Al subir: 0. | 600 en Triton |
| Tolerancia | Si métrica / objetivo queda entre 0.9 y 1.1, no hace nada. Por HPA: behavior.scaleUp.tolerance y scaleDown.tolerance (estable desde 1.37). | 64 % contra 60 %: sin cambio |
| ScaledObject (KEDA) | Le dices a KEDA qué escalar y con qué evento; KEDA crea y alimenta un HPA. No agregues otro. | keda-hpa-triton (keda-hpa- + el nombre) |
| metricType: Value · AverageValue | Value: proporcional a las réplicas Ready (tiempos). AverageValue: total ÷ objetivo por pod (colas, req/s). | Triton: Value, umbral 10 ms |
| minReplicaCount · cooldownPeriod · pollingInterval | Defaults 0, 300 s y 30 s. KEDA decide 0 ↔ 1; el HPA, 1 ↔ N. Escribe el mínimo. | 2 en Triton: nunca baja a 0 |
| ignoreNullValues | Con "false", una query vacía es un error visible; con el default (true), un 0 silencioso. | "false" en C01 |
| Karpenter · Cluster Autoscaler | Ven pods Pending por falta de lugar y agregan nodos; los quitan cuando sobran. | NodePool (grupo de nodos de Karpenter) de T4; Cluster Autoscaler quita un nodo tras 10 min sin hacer falta |
| kubectl scale con autoscaler | Dura poco: el HPA vuelve a su número (si subiste, al ritmo de su scaleDown). Cambia min/max en git. | --replicas=6 con máximo 4 → vuelve a 4 en ≈ 15 s |
Métrica: espera media en la cola de Triton (ms) · metricType: Value
Ahora
El HPA pide
Recorta al máximo
réplica Readyla pide el HPAno entra: pasa el máximo
Línea de tiempo, en minutos: cada barra va del inicio al fin del rango
la cola pasa de ≈ 2 a 25 ms 0 min
keda-hpa-triton: 2 → 3; el pod 3 queda Pending (Insufficient nvidia.com/gpu) 0.5–1.5 min
Karpenter ve el pod Unschedulable y lanza una g4dn.xlarge 0.5–1.5 min
si la cola sigue alta: 3 → 4 (otro Pending, otro nodo) 1.5–2.5 min
nodo Ready; el device plugin anuncia nvidia.com/gpu: 1; el scheduler pone el pod 2.5–6.5 min
imagen de Triton descargada 4.5–11.5 min
engine cargado y readiness OK: entra al Service 4.75–12.75 min
Al bajar: el HPA espera 600 s y quita 1 pod cada 300 s (borrar réplicas no pasa por el PodDisruptionBudget); el nodo que queda vacío lo retira Karpenter (consolidación: quita o cambia nodos que sobran) o Cluster Autoscaler (tras 10 min sin hacer falta).
| Pieza | Decide | Con qué | Tiempo en C01 |
|---|---|---|---|
| KEDA (operador) | Crear el HPA; pasar de 0 a 1 y de 1 a 0 réplicas | Los triggers del ScaledObject | No aplica: el mínimo es 2 |
| HPA keda-hpa-triton | Cuántas réplicas entre el mínimo y el máximo (2–4) | ⌈Ready × métrica / umbral⌉ y el behavior | Cada 15 s; +1 pod por minuto |
| Scheduler | En qué nodo cae cada pod nuevo | Requests, labels, taints y el reparto entre zonas | Segundos; si no cabe, Pending |
| Karpenter (o Cluster Autoscaler) | Cuántos nodos | Pods Unschedulable y las reglas del NodePool | ≈ 2–5 min por nodo GPU |
| kubelet, device plugin y Triton | Cuándo sirve el pod nuevo | Pull de la imagen, carga del engine, readiness | ≈ 2–5 min + ≈ 15–75 s |
Del primer síntoma al primer request del pod nuevo: ≈ 5–13 min. Por eso C01 nunca baja de 2 réplicas (una por zona, y cada una aguanta sola el pico de diseño), y los picos cortos los absorben ese margen y el load shedding (rechazar rápido, con un 503, lo que no cabe), no el autoscaling.
# … ScaledObject "triton" (namespace classifier) → Deployment triton
minReplicaCount: 2 # nunca menos de 2: una GPU por AZ (N+1), aunque 1 sola cubra el pico
maxReplicaCount: 4 # techo de costo: 4 × g4dn.xlarge; si no alcanza, TritonQueueTimeHigh avisa
# … pollingInterval, cooldownPeriod y el behavior del HPA (ver notas)
triggers:
- type: prometheus
# Value (no AverageValue): réplicas deseadas = ceil(actuales × valor / umbral). Proporcional,
# como corresponde a una latencia; AverageValue dividiría el valor entre réplicas, lo que tiene
# sentido para un caudal (req/s), no para un tiempo.
metricType: Value
metadata:
# Prometheus del cluster (kube-prometheus-stack). Con Amazon Managed Prometheus se agrega
# autenticación SigV4 vía TriggerAuthentication.
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
# Si la query vuelve vacía (Prometheus no scrapea Triton: falta el ServiceMonitor de
# k8s/monitoring.yaml), "false" la vuelve un error visible del scaler (eventos del ScaledObject).
# Con el default (true) KEDA la toma como 0 y Triton se queda en 2 réplicas sin que nada avise.
ignoreNullValues: "false"
# Espera media en cola en ms (misma fórmula que la alerta TritonQueueTimeHigh).
# clamp_min evita 0/0 = NaN sin tráfico; si todo falla (0 éxitos) el valor se dispara y escala.
query: |
sum(rate(nv_inference_queue_duration_us{model="classifier"}[2m]))
/
clamp_min(sum(rate(nv_inference_request_success{model="classifier"}[2m])), 1e-9)
/ 1000
# Objetivo: 10 ms en cola. Normal con max_queue_delay de 2 ms es ≈ 1–3 ms; > 10 ms es saturación.
# Ojo: la cola es no lineal (casi 0 hasta que la GPU se satura). Alternativa más estable:
# AverageValue sobre sum(rate(nv_inference_count[1m])) con umbral ≈ 400 img/s por réplica.
threshold: "10"keda-hpa-triton (mín. 2, máx. 4) con una métrica External. Cada 15 s el HPA le pide el número al servidor de métricas de KEDA, que corre la query en Prometheus; con mínimo ≥ 1, pollingInterval solo refresca el estado del ScaledObject.metricType: Value: deseadas = ⌈Ready × ms / 10⌉. Normal, ≈ 2 ms: ⌈2 × 0.2⌉ = 1, y se queda en 2 (el mínimo). Saturada, 25 ms: ⌈2 × 2.5⌉ = 5, y se queda en 4 (el máximo). AverageValue dividiría la métrica entre las réplicas: sirve para colas y req/s, no para un tiempo.advanced.horizontalPodAutoscalerConfig) sube +1 pod cada 60 s y baja −1 cada 300 s, después de 600 s de estabilización: de 2 a 4 réplicas pasa al menos un minuto, más el nodo de cada una. cooldownPeriod solo aplica al bajar a 0, que aquí nunca pasa.ignoreNullValues: "false": si falta el ServiceMonitor (el objeto que le dice a Prometheus qué leer) y la query vuelve vacía, el scaler da error (prometheus metrics 'prometheus' target may be lost, the result is empty) y lo ves en los eventos del ScaledObject. Con el default (true) sería un 0 silencioso y Triton nunca pasaría de 2.gateway.yaml): CPU al 60 % de su request de 1 core. 5 pods al 90 % → ⌈5 × 90 / 60⌉ = ⌈7.5⌉ = 8. Sube hasta +100 % cada 30 s y baja de a 1 pod por minuto después de 300 s.Insufficient nvidia.com/gpu). Karpenter lo ve Unschedulable y lanza una g4dn.xlarge con la label y el taint de su NodePool; el device plugin anuncia la GPU y recién ahí el scheduler pone el pod. De Pending a Ready, ≈ 4–11 min según el comentario de progressDeadlineSeconds en triton.yaml.Cómo recordarla
el Deployment es un termostato (mantiene N copias); el HPA mueve la perilla N con una regla proporcional; KEDA le acerca métricas de afuera; Karpenter agrega nodos cuando las copias no caben. Pods en segundos, nodos en minutos.
Trampa
fijar réplicas a mano con un autoscaler activo: un kubectl scale no dura (el HPA vuelve a su número) y, si dejas replicas: en el YAML, cada kubectl apply resetea la escala y pelea con el autoscaler; por eso los Deployments de C01 no lo tienen. Para cambiar capacidad, cambia minReplicas o minReplicaCount en git. Y maxReplicaCount: 20 no te da 20 GPUs: cada réplica necesita un nodo, cuota y capacidad en la zona.
En el curso: M6 · HPA: por qué escalar por CPU no sirve para un servicio de GPU M6 · KEDA: escalar por eventos y hasta cero M6 · Aprovisionar nodos GPU: Cluster Autoscaler y Karpenter M6 · Cold starts de punta a punta Docker y kubectl · Autoscaling y capacidad C01 · Serving
Qué vas a provocar: Ver al HPA imponer su mínimo y su máximo, ganarle a kubectl scale y a un replicas en el YAML, y leer por qué no escala sin metrics-server.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster, con kubeadm o kind; contexto docker-desktop) o kind. La primera vez se baja nginx:1.30-alpine (≈ 29 MB para arm64). No necesitas KEDA ni metrics-server.
kubectl config current-context
kubectl create namespace lab-k8s
mkdir -p ~/lab-k8s-hpa && cd ~/lab-k8s-hpa
cat > web.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.30-alpine
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 128Mi
EOF
cat > hpa.yaml <<'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 2
maxReplicas: 4
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
EOF
kubectl apply -n lab-k8s -f web.yaml -f hpa.yaml
kubectl get deploy web -n lab-k8s -wQué deberías ver: READY empieza en 1/1 y en ≈ 15 s pasa a 2/2: el HPA impone su mínimo aunque el YAML diga replicas: 1. Sal con Ctrl+C.
kubectl get hpa web -n lab-k8s
kubectl describe hpa web -n lab-k8sQué deberías ver: Sin metrics-server, TARGETS dice cpu: <unknown>/60%. En Conditions, ScalingActive False con FailedGetResourceMetric (failed to get cpu utilization); en Events, SuccessfulRescale con New size: 2; reason: Current number of replicas below Spec.MinReplicas. Si instalaste metrics-server, verás un porcentaje y ScalingActive True.
kubectl scale deploy web -n lab-k8s --replicas=6
kubectl get deploy web -n lab-k8s -wQué deberías ver: Sube a 6 y en ≈ 15 s el HPA lo baja a 4: New size: 4; reason: Current number of replicas above Spec.MaxReplicas. Un kubectl scale no le gana al autoscaler. Ctrl+C.
kubectl apply -n lab-k8s -f web.yaml
kubectl get deploy web -n lab-k8s -wQué deberías ver: kubectl responde deployment.apps/web configured y las réplicas caen a 1, lo que dice el YAML; en ≈ 15 s el HPA las vuelve a subir a 2. Por eso, con HPA o KEDA, el Deployment no lleva replicas (como en C01). Ctrl+C.
Cómo arreglarlo y confirmarlo
Para escalar de verdad por CPU en tu Mac necesitas metrics-server, que no viene con Docker Desktop. En EKS (el Kubernetes administrado de AWS), si el HPA no escala, sigue la cadena: ¿llega la métrica? (TARGETS y ScalingActive), ¿pide más réplicas? (ScalingLimited), ¿los pods nuevos quedan Pending? (FailedScheduling), ¿llega un nodo? (Karpenter o Cluster Autoscaler). Si quitas replicas de un Deployment ya aplicado, ese primer apply lo baja a 1 (el default) hasta el siguiente ciclo del HPA; la doc de HPA lo evita borrando antes el campo con kubectl apply edit-last-applied deployment/NOMBRE. Limpia con kubectl delete namespace lab-k8s y cd ~ && rm -r ~/lab-k8s-hpa.
Verificado contra la documentación; no ejecutado aquí. Verificado en el código del controlador de HPA de Kubernetes 1.37: impone min y max sin calcular métricas, eventos SuccessfulRescale con New size y reason, condición ScalingActive con FailedGetResourceMetric, y el formato cpu: <unknown>/60% de TARGETS. El reset de replicas sale del merge de tres vías de kubectl apply (la diferencia va del estado actual al YAML) y de la doc de HPA.
¿Cómo cambias de versión sin cortar el servicio, cómo vuelves atrás y qué te protege cuando alguien vacía un nodo?
Sintaxis con variables
kubectl rollout status deploy/nombre -n ns --timeout=plazo
Un rollout es el cambio de versión de un Deployment: entran pods nuevos y salen los viejos, poco a poco. Este comando espera a que termine y sale con error si se traba.
kubectl rollout history deploy/nombre -n ns kubectl rollout undo deploy/nombre -n ns --to-revision=n
Cada cambio del template (el molde de los pods) crea una revisión: un ReplicaSet nuevo, y los viejos quedan guardados en 0 réplicas. undo vuelve a una revisión; sin --to-revision, a la anterior.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: nombre, namespace: ns }
spec:
minAvailable: disponibles
selector: { matchLabels: { app.kubernetes.io/name: app } }Un PodDisruptionBudget (PDB) dice cuántos pods deben seguir listos cuando alguien vacía un nodo a propósito (un drain: sacarle los pods para apagarlo o actualizarlo). Usa minAvailable o maxUnavailable, nunca los dos.
strategy:
canary:
steps:
- setWeight: peso
- pause: { duration: espera }Argo Rollouts es un controlador que se instala aparte. Con él, un canary manda un porcentaje chico del tráfico a la versión nueva y espera antes de subirlo. pause: {} espera hasta que alguien lo promueva.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| nombre | nombre del Deployment o del PDB | triton, gateway |
| ns | namespace: la carpeta lógica donde vive el objeto | classifier |
| plazo | cuánto espera el comando antes de rendirse (no cambia el Deployment) | 25m (Triton: nodo GPU, imagen y engine, un pod a la vez) |
| n | número de revisión, sacado de rollout history | 3 |
| disponibles | pods que deben quedar listos (número o %) | 1 |
| app | label que comparten los pods que proteges | triton |
| peso | porcentaje del tráfico para la versión nueva | 5, después 25 (el canary de M11) |
| espera | cuánto se queda en ese porcentaje | 4h |
| réplicas deseadas del Deployment | 2 en Triton | |
| pods de más durante el rollout; si es %, se redondea hacia arriba | 1 en Triton (default 25 %) | |
| pods de menos durante el rollout; si es %, se redondea hacia abajo | 0 en Triton (default 25 %) | |
| pods del PDB con la condición Ready en True | 2 | |
| el mínimo que pide el PDB | 1 → 1 evicción permitida |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| kubectl rollout status | Espera el fin del rollout. Sale con error si vence el plazo del Deployment o --timeout. | deploy/triton --timeout=25m |
| kubectl rollout history | Lista las revisiones guardadas; con --revision=N muestra el template de una. | REVISION · CHANGE-CAUSE |
| kubectl rollout undo | Vuelve a la revisión anterior (o a --to-revision=N) creando una revisión nueva con el template viejo. | deployment.apps/triton rolled back |
| kubectl rollout restart | Recrea todos los pods con la misma imagen, de a poco (rolling update). | releer un Secret que se lee al arrancar |
| kubectl rollout pause · resume | Congela el Deployment: los cambios no arrancan rollout hasta resume. En pausa no hay undo. | juntar varios cambios en uno |
| kubernetes.io/change-cause | Anotación del Deployment que se copia a la revisión: llena la columna CHANGE-CAUSE. | "classifier 2026-09-15-resnet50-fp16" |
| revisionHistoryLimit | Cuántos ReplicaSets viejos guarda para volver atrás. Default 10; con 0 no hay undo. | 5 en C01 |
| progressDeadlineSeconds | Plazo para avanzar. Si vence: Progressing=False con ProgressDeadlineExceeded. Default 600 s. No revierte solo. | 900 en Triton |
| PodDisruptionBudget | Mínimo de pods listos cuando alguien desaloja pods a propósito: drain, upgrade del cluster o un autoscaler que apaga nodos. | minAvailable: 1 en Triton |
| unhealthyPodEvictionPolicy: AlwaysAllow | Deja desalojar pods que corren pero no están Ready, aunque el PDB esté al límite. El default (IfHealthyBudget) no. | un pod en CrashLoopBackOff que traba un drain |
| kubectl drain NODO | Marca el nodo para que no reciba pods nuevos y desaloja los que tiene, respetando los PDB. | --ignore-daemonsets |
| Rollout (Argo Rollouts) | Recurso que reemplaza al Deployment y agrega pasos de canary: setWeight, pause, analysis. | el gateway de C01 en M11 |
| AnalysisTemplate | Consulta métricas (Prometheus) durante el canary y lo aborta si un criterio falla. | 5xx > 0.5 % aborta |
| kubectl argo rollouts promote · abort | promote avanza un canary en pausa; abort lo detiene y vuelve a la versión estable. | promote gateway -n classifier |
r = 2 réplicasmaxSurge 1 → nunca más de 3 podsmaxUnavailable 0 → nunca menos de 2 listos
v1versión vieja (sirve)v2nueva, todavía no Readyv2nueva y Readymínimo de listosmáximo de pods
apiVersion: apps/v1
kind: Deployment
metadata:
name: triton # kube_deployment_status_replicas_available{deployment="triton"} → alerta TritonNotReady
namespace: classifier
# …
spec:
# Sin "replicas": las gestiona el HPA que crea KEDA. Si lo fijas aquí, cada `kubectl apply`
# resetea la escala y pelea con el autoscaler.
revisionHistoryLimit: 5 # ReplicaSets viejos para `kubectl rollout undo`
progressDeadlineSeconds: 900 # un pod con nodo GPU nuevo tarda ≈ 4–11 min: el default de 600 s vence en el caso pesimista
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # de a un pod nuevo: cada pod necesita una GPU libre (Karpenter/cluster-autoscaler agrega el nodo)
maxUnavailable: 0 # nunca bajar de la capacidad actual durante un deploy
# …
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: triton
namespace: classifier
spec:
# Evicciones VOLUNTARIAS (drain de nodos, upgrades del cluster, consolidación de Karpenter):
# siempre queda al menos 1 Triton sirviendo. No protege de fallas involuntarias (eso es N+1).
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: tritonmaxSurge: 1 y maxUnavailable: 0: cada pod nuevo necesita una GPU libre antes de que se vaya uno viejo. Si no hay, queda Pending y el rollout espera; los viejos siguen sirviendo.progressDeadlineSeconds: 900: un pod de GPU en un nodo nuevo tarda ≈ 4–11 min (nodo, imagen, engine). Con el default de 600 s, un rollout sano pero lento podría marcarse como fallido.revisionHistoryLimit: 5 guarda 5 ReplicaSets viejos (el default es 10): son las versiones a las que rollout undo puede volver. En C01 el modelo va horneado en la imagen, así que volver al tag anterior es volver al modelo anterior.kubectl drain, o a Karpenter (el autoscaler de nodos) cuando junta pods para apagar un nodo, si eso dejaría 0 Triton listos. No frena tu propio rollout (eso lo controla maxUnavailable) ni la muerte de un nodo (eso lo cubre tener 2 réplicas).minAvailable: 1 permite 1 evicción (desalojo) a la vez. Con minAvailable: 2 no se permitiría ninguna y los upgrades del cluster se quedarían trabados.maxUnavailable: 0, un rollout undo también necesita una GPU libre para el pod que vuelve: el rollback no es instantáneo.Cómo recordarla
status → history → undo. Cada cambio del template deja una revisión, y volver atrás es elegir una. El PDB cuida los drains; maxUnavailable cuida tu rollout.
Trampa
creer que Kubernetes vuelve atrás solo. Un rollout trabado marca ProgressDeadlineExceeded y se queda ahí: el rollback lo haces tú, o una herramienta como Argo Rollouts. Y con GitOps (una herramienta como Argo CD aplica lo que está en git), un rollout undo dura hasta el próximo sync: el rollback de verdad es un revert en git.
En el curso: M6 · Alta disponibilidad: multi-AZ, PDB y N+1 M11 · Canary: porcentajes, tiempos y criterios para avanzar M11 · Rollback: criterios automáticos y qué guardar para volver M11 · Blue/green y rollback instantáneo Docker y kubectl · Rollouts y escalado C01 · Modos de falla y degradación
Qué vas a provocar: Hacer un rollout sano, romper el siguiente con un tag que no existe, ver cómo se traba sin cortar el servicio, volver atrás con undo y comprobar qué permite un PDB.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind. Baja nginx:1.29-alpine y nginx:1.30-alpine.
kubectl create namespace lab-k8s && kubectl create deployment web -n lab-k8s --image=nginx:1.29-alpine --replicas=2 && kubectl patch deploy web -n lab-k8s -p '{"spec":{"progressDeadlineSeconds":60}}' && kubectl rollout status deploy/web -n lab-k8sQué deberías ver: namespace/lab-k8s created, deployment.apps/web created, deployment.apps/web patched y, al final, deployment "web" successfully rolled out. El plazo de 60 s es solo para el lab: así ves el fallo en un minuto.
kubectl set image deploy/web -n lab-k8s nginx=nginx:1.30-alpine && kubectl annotate deploy/web -n lab-k8s kubernetes.io/change-cause="nginx 1.30" && kubectl rollout status deploy/web -n lab-k8sQué deberías ver: deployment.apps/web image updated, deployment.apps/web annotated, algunas líneas Waiting for deployment "web" rollout to finish… y successfully rolled out. El contenedor se llama nginx porque create deployment lo nombra como la imagen.
kubectl set image deploy/web -n lab-k8s nginx=nginx:no-existe && kubectl annotate deploy/web -n lab-k8s --overwrite kubernetes.io/change-cause="tag roto" && kubectl rollout status deploy/web -n lab-k8s; echo "exit=$?"Qué deberías ver: Se queda en Waiting for deployment "web" rollout to finish: 1 out of 2 new replicas have been updated... y, en un minuto, error: deployment "web" exceeded its progress deadline y exit=1.
kubectl get pods -n lab-k8s; kubectl get deploy web -n lab-k8s -o jsonpath='{.status.conditions[?(@.type=="Progressing")].reason}{"\n"}'Qué deberías ver: Dos pods Running (los de 1.30) y uno en ErrImagePull o ImagePullBackOff; después, ProgressDeadlineExceeded. Nadie volvió atrás: el Deployment solo avisa, y el servicio nunca bajó de 2 pods.
kubectl rollout history deploy/web -n lab-k8sQué deberías ver: Tres revisiones: 1 con CHANGE-CAUSE <none>, 2 con nginx 1.30 y 3 con tag roto.
kubectl rollout undo deploy/web -n lab-k8s && kubectl rollout status deploy/web -n lab-k8s && kubectl rollout history deploy/web -n lab-k8sQué deberías ver: deployment.apps/web rolled back y successfully rolled out. En el historial ya no está la 2: reaparece como 4 (nginx 1.30), porque volver atrás crea una revisión nueva con el template viejo. El pod roto se borró.
kubectl create pdb web -n lab-k8s --selector=app=web --min-available=2 && sleep 3 && kubectl get pdb web -n lab-k8sQué deberías ver: poddisruptionbudget.policy/web created y una fila con MIN AVAILABLE 2 y ALLOWED DISRUPTIONS 0: con 2 pods y un mínimo de 2, un drain no podría sacar ninguno. Así se traba un upgrade del cluster.
kubectl patch pdb web -n lab-k8s --type=merge -p '{"spec":{"minAvailable":1}}' && sleep 3 && kubectl get pdb web -n lab-k8sQué deberías ver: poddisruptionbudget.policy/web patched y ALLOWED DISRUPTIONS 1: se puede sacar un pod a la vez, como con el PDB de Triton en C01.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted. El Deployment, sus ReplicaSets y el PDB se van con el namespace.
Cómo arreglarlo y confirmarlo
Rollout trabado: kubectl rollout undo para mitigar y después corrige el tag. En producción, progressDeadlineSeconds según tu cold start medido (900 s en Triton), no 60. En un PDB, minAvailable menor que las réplicas.
Verificado contra la documentación; no ejecutado aquí.
¿Cómo le preguntas al cluster qué hay, qué cambiaría y si tienes permiso, antes de tocar nada?
Sintaxis con variables
kubectl verbo tipo/nombre -n ns -o formato
Casi todo kubectl tiene esta forma: un verbo, un recurso, el namespace (la carpeta lógica donde vive) y el formato de salida. Para mirar o cambiar algo, kubectl siempre pasa por el API server, la puerta de entrada del cluster.
kubectl config current-context kubectl config use-context contexto kubectl config set-context --current --namespace=ns
Un contexto junta cluster, usuario y namespace por defecto. Vive en tu kubeconfig, el archivo de clusters y credenciales de kubectl (~/.kube/config). Míralo antes de cambiar algo.
kubectl get tipo/nombre -n ns -o jsonpath='{ruta}{"\n"}' kubectl explain tipo.campo kubectl diff -f archivo
Mirar fino: un campo exacto con JSONPath (una ruta dentro del objeto, como .status.readyReplicas), la documentación de un campo y qué cambiaría si aplicas (exit 0 = nada, 1 = hay diferencias).
kubectl exec -it -n ns pod -c contenedor -- comando kubectl port-forward -n ns svc/nombre local:puerto kubectl auth can-i verbo tipo -n ns
Entrar a un contenedor, abrir un túnel a un pod y preguntar si puedes. RBAC (los permisos de Kubernetes) decide si puedes hacer las dos primeras; auth can-i se lo pregunta.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| verbo | lo que haces | get, describe, delete · en can-i: list, create |
| tipo | clase de recurso, con nombre largo o corto | deployment (deploy), service (svc), pods (po) |
| nombre | nombre del objeto | triton |
| ns | namespace | classifier |
| formato | cómo imprime la respuesta | wide, yaml, json, name, jsonpath='…' |
| contexto | entrada del kubeconfig: cluster + usuario + namespace | docker-desktop · en EKS, por defecto el ARN del cluster (su identificador en AWS) |
| ruta | camino al campo dentro del objeto; empieza con punto | .status.readyReplicas |
| campo | camino al campo para explain, sin punto inicial | spec.progressDeadlineSeconds |
| archivo | manifest local | k8s/triton.yaml |
| pod | nombre del pod, o deploy/NOMBRE para que elija uno | deploy/triton |
| contenedor | contenedor dentro del pod | triton |
| comando | lo que corres adentro | nvidia-smi, sh |
| local | puerto en tu máquina | 8000 |
| puerto | puerto del Service (o del pod) | 8000 |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| kubectl config get-contexts | Lista tus contextos; el asterisco marca el activo. | docker-desktop · arn:aws:eks:…:cluster/NOMBRE |
| -n NS · -A | Un namespace, o todos a la vez (--all-namespaces). | -n classifier · -n keda |
| -o wide | Agrega columnas: en pods, la IP y el nodo. | en qué nodo GPU cayó cada Triton |
| -o yaml | El objeto completo como lo guarda el cluster, con defaults y status. Oculta managedFields (quién cambió cada campo). | kubectl get deploy triton -o yaml |
| -o jsonpath='{…}' | Saca solo los campos que pides. Para scripts y respuestas cortas. | '{.status.readyReplicas}' |
| -l · --show-labels · -L | Filtra por label, muestra todas, o pone una label como columna. | -l app.kubernetes.io/name=triton |
| -w | Se queda mirando y muestra cada cambio. | kubectl get pods -w |
| kubectl explain | Documentación de un campo, servida por tu cluster y su versión. | explain deploy.spec.progressDeadlineSeconds |
| kubectl diff -f | Qué cambiaría si aplicas, calculado por el servidor. Exit 0 sin cambios, 1 con cambios. | diff -f k8s/triton.yaml |
| kubectl api-resources | Todos los tipos: nombre corto, grupo y si viven en un namespace. | pdb, hpa, scaledobjects |
| kubectl exec -it … -- sh | Abre una terminal, o corre un comando, dentro de un contenedor que ya corre. | -- nvidia-smi |
| kubectl port-forward | Túnel de tu máquina a un solo pod, sin exponer el servicio. | svc/triton 8000:8000 |
| kubectl debug -it --image --target | Agrega un contenedor temporal con herramientas a un pod que corre. | imágenes sin shell |
| kubectl auth can-i | Pregunta si puedes hacer algo. Responde yes o no. | create pods --subresource=exec |
| Quieres saber | Comando | Qué mirar |
|---|---|---|
| ¿En qué cluster estoy? | kubectl config current-context | El nombre del contexto; en EKS, el ARN del cluster |
| ¿Qué namespace uso si no pongo -n? | kubectl config view --minify -o jsonpath='{..namespace}' | Vacío = default |
| ¿Qué pods hay y dónde corren? | kubectl get pods -n classifier -o wide | STATUS, READY, RESTARTS y NODE |
| ¿Qué tiene de verdad el cluster? | kubectl get deploy triton -n classifier -o yaml | La spec con defaults y el status |
| ¿Qué significa este campo? | kubectl explain deploy.spec.progressDeadlineSeconds | Tipo, descripción y default |
| ¿Cómo se llama ese recurso? | kubectl api-resources | SHORTNAMES, APIVERSION y NAMESPACED |
| ¿Qué cambiaría si aplico? | kubectl diff -f k8s/triton.yaml | Líneas con - y +; exit 0 o 1 |
| ¿Puedo hacerlo? | kubectl auth can-i create pods --subresource=exec -n classifier | yes o no |
| ¿Qué puedo hacer en total? | kubectl auth can-i --list -n classifier | Recursos y verbos permitidos |
| ¿Responde desde mi máquina? | kubectl port-forward -n classifier svc/triton 8000:8000 | Forwarding from 127.0.0.1:8000 -> 8000 |
# ¿Dónde estoy? Antes de tocar nada
kubectl config current-context # en EKS: arn:aws:eks:us-east-1:123456789012:cluster/…
kubectl config set-context --current --namespace=classifier
# ¿Qué hay y dónde corre?
kubectl get pods -l app.kubernetes.io/name=triton -o wide
kubectl get deploy triton -o jsonpath='{.spec.strategy.rollingUpdate}{"\n"}' # {"maxSurge":1,"maxUnavailable":0}
# ¿Qué cambiaría si aplico lo que está en git?
kubectl diff -f k8s/triton.yaml; echo "exit=$?" # 0 = igual, 1 = hay diferencias
# ¿Puedo entrar? exec necesita create sobre pods/exec
kubectl auth can-i create pods --subresource=exec
# Túnel: localhost:8000 y :8002 → los mismos puertos de UN pod detrás de svc/triton
kubectl port-forward svc/triton 8000:8000 8002:8002
# En otra terminal
curl -s localhost:8000/v2/health/ready -o /dev/null -w "%{http_code}\n" # 200 = listo
curl -s localhost:8002/metrics | grep nv_inference_queue_duration_us # la señal de KEDA
# ¿Triton ve la GPU y cuánta memoria ocupa?
kubectl exec -it deploy/triton -- nvidia-smiset-context --current --namespace=classifier ya no repites -n classifier. Queda guardado en tu kubeconfig para ese contexto: la próxima vez, míralo.kubectl diff le pide al API server un dry-run (calcula el resultado sin guardarlo): el servidor aplica sus defaults y su validación, así que ves lo que de verdad cambiaría, no una comparación de texto con tu archivo.port-forward a svc/triton elige un pod y se corta si ese pod muere. Sirve para una prueba de humo, no para un load test: no reparte como el Service.exec y port-forward piden el verbo create sobre pods/exec y pods/portforward. Desde 1.35 el API server lo exige también cuando kubectl se conecta por WebSocket, el canal que usan las versiones nuevas; antes, por esa vía alcanzaba con get. En producción suelen estar restringidos: auth can-i responde antes de que lo intentes.exec deploy/triton elige un pod del Deployment. Para uno en particular, usa el nombre del pod.Cómo recordarla
¿dónde? → ¿qué hay? → ¿qué es? → ¿qué cambiaría? → ¿puedo?: config current-context, get, explain, diff, auth can-i. Recién después, apply o exec.
Trampa
correr algo que cambia el cluster sin mirar el contexto. Un delete en producción, cuando creías estar en staging, no se deshace. Y con GitOps (una herramienta como Argo CD aplica lo que está en git), un kubectl edit o un scale se pierde en el próximo sync: el cambio va en git.
En el curso: Docker y kubectl · kubectl: mirar, entrar y conectar Docker y kubectl · Qué de esto sirve en la entrevista M15 · Secretos y credenciales C01 · Archivos de referencia
Qué vas a provocar: Practicar el recorrido completo en un namespace propio: contexto, get con formatos, explain, diff, exec, port-forward y permisos con RBAC.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind. Baja nginx:1.30-alpine; curl viene con macOS.
kubectl config current-context && kubectl config get-contextsQué deberías ver: docker-desktop (o kind-kind con kind). En get-contexts, el * marca el activo.
kubectl create namespace lab-k8s && kubectl create deployment web -n lab-k8s --image=nginx:1.30-alpine --replicas=2 && kubectl expose deployment web -n lab-k8s --port=80 && kubectl rollout status deploy/web -n lab-k8sQué deberías ver: namespace/lab-k8s created, deployment.apps/web created, service/web exposed y deployment "web" successfully rolled out.
kubectl get pods -n lab-k8s -o wide --show-labelsQué deberías ver: Dos pods web-… en Running, con IP y NODE, y las labels app=web y pod-template-hash=… (esta última la pone el Deployment).
kubectl get deploy web -n lab-k8s -o jsonpath='{.spec.strategy}{"\n"}{.spec.revisionHistoryLimit}{"\n"}'Qué deberías ver: {"rollingUpdate":{"maxSurge":"25%","maxUnavailable":"25%"},"type":"RollingUpdate"} y 10: defaults que no escribiste, pero que el cluster guardó.
kubectl explain deployment.spec.revisionHistoryLimitQué deberías ver: GROUP: apps, KIND: Deployment, VERSION: v1, FIELD: revisionHistoryLimit <integer> y una descripción que termina en Defaults to 10.
kubectl create deployment web -n lab-k8s --image=nginx:1.30-alpine --replicas=3 --dry-run=client -o yaml > /tmp/web-lab.yaml && kubectl diff -f /tmp/web-lab.yaml; echo "exit=$?"Qué deberías ver: Un diff con replicas de 2 a 3, la generation que subiría y la anotación last-applied-configuration que agregaría apply; después, exit=1. No cambió nada: diff solo compara.
kubectl exec -n lab-k8s deploy/web -- nginx -vQué deberías ver: nginx version: nginx/1.30.x, desde uno de los dos pods.
kubectl port-forward -n lab-k8s svc/web 8080:80 & sleep 2; curl -s localhost:8080 | grep -o '<title>.*</title>'; kill %1Qué deberías ver: Forwarding from 127.0.0.1:8080 -> 80 (y la línea de [::1]), Handling connection for 8080 y <title>Welcome to nginx!</title>. El túnel llegó a un solo pod.
kubectl auth can-i list pods -n lab-k8s --as=ana; kubectl create role lector -n lab-k8s --verb=get,list,watch --resource=pods && kubectl create rolebinding lector-ana -n lab-k8s --role=lector --user=ana && kubectl auth can-i list pods -n lab-k8s --as=ana && kubectl auth can-i delete pods -n lab-k8s --as=anaQué deberías ver: no; role.rbac.authorization.k8s.io/lector created; rolebinding.rbac.authorization.k8s.io/lector-ana created; yes; no. Un Role da verbos sobre recursos en un namespace; un RoleBinding se los da a alguien. --as (actuar como otro usuario) funciona porque tu usuario local es admin.
kubectl get pods -n lab-k8s --as=ana && kubectl delete pod -n lab-k8s -l app=web --as=anaQué deberías ver: La lista sale bien; el delete falla con Error from server (Forbidden): pods "web-…" is forbidden: User "ana" cannot delete resource "pods" in API group "" in the namespace "lab-k8s".
kubectl delete namespace lab-k8s; rm -f /tmp/web-lab.yamlQué deberías ver: namespace "lab-k8s" deleted. El Role y el RoleBinding se van con el namespace.
Cómo arreglarlo y confirmarlo
Si un comando hace algo distinto de lo que esperabas, corre primero kubectl config current-context y revisa el -n: casi siempre es el cluster o el namespace equivocado.
Verificado contra la documentación; no ejecutado aquí.
Tu pod no funciona: ¿qué miras primero, qué después, y qué te dice cada estado?
Sintaxis con variables
kubectl get pods -n ns -o wide
Estado: en qué fase se trabó (STATUS), si está listo para recibir tráfico (READY), cuántas veces se reinició (RESTARTS) y en qué nodo corre.
kubectl describe pod pod -n ns kubectl events -n ns --for pod/pod
Eventos: avisos del scheduler (el que elige nodo) y del kubelet (el agente que corre los pods en cada nodo), en orden. Casi siempre el porqué está aquí.
kubectl logs pod -n ns -c contenedor --previous
Logs: lo que escribió el intento que murió. Sin --previous ves el intento actual, que quizá todavía no falló.
kubectl exec -it pod -n ns -c contenedor -- sh kubectl debug -it pod -n ns --image=busybox:1.38 --target=contenedor
Exec: mirar por dentro (variables, archivos, red). Si la imagen no trae shell, debug agrega un contenedor temporal con herramientas.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| ns | namespace del pod | classifier |
| pod | nombre del pod (sale de get pods) | triton-5f6b…-7tq2m |
| contenedor | contenedor dentro del pod; hace falta si hay más de uno | triton |
| lo que muestra describe en Last State | 137 | |
| número de la señal que terminó el proceso | 9 = SIGKILL (OOMKilled, o venció el plazo de gracia); 15 = SIGTERM | |
| número de reinicio seguido | 1, 2, 3… | |
| espera antes de reiniciar en CrashLoopBackOff | 10, 20, 40… hasta 300 s; vuelve a 10 s tras 10 min corriendo bien |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Pending | El pod existe, pero no tiene nodo todavía, o espera algo antes de crear contenedores. | FailedScheduling: Insufficient nvidia.com/gpu |
| ContainerCreating | Ya tiene nodo: baja la imagen, monta los volúmenes y crea el contenedor. | la imagen de Triton, de varios GB |
| ErrImagePull · ImagePullBackOff | No pudo bajar la imagen; reintenta con esperas crecientes, hasta 5 min. | tag inexistente, 403 de ECR |
| CreateContainerConfigError | No puede armar el contenedor: falta un Secret, un ConfigMap o una clave, o choca runAsNonRoot (pediste no ser root). | secret "triton-token" not found |
| Running · READY 0/1 | El proceso corre, pero falla la readiness (el chequeo de ¿le mando tráfico?): no recibe requests del Service. | Triton cargando el engine |
| Running · READY 1/1 | Listo: está en los endpoints del Service, la lista de pods a los que manda tráfico. | lo normal |
| CrashLoopBackOff | El contenedor muere una y otra vez; el kubelet espera más antes de cada reinicio. | exit 1 al cargar el modelo |
| OOMKilled (en Last State) | Pasó su límite de memoria y el kernel lo mató: exit 137, sin línea en el log. | pesos leídos a RAM |
| Error · Completed | El contenedor terminó: con un código distinto de 0, o con 0. | un CMD que no se queda sirviendo |
| RunContainerError · StartError | containerd (crea los contenedores en EKS y kind) no encuentra o no puede ejecutar el comando: StartError, exit 128. | executable file not found in $PATH |
| Terminating | Se borra: corre el preStop (paso previo al apagado), llega el SIGTERM y el plazo de gracia pone el tope. | hasta 60 s en Triton |
| Evicted | El kubelet lo sacó porque al nodo le faltó memoria o disco. Fase Failed. | The node was low on resource: memory |
| Unknown | No se pudo saber el estado del pod, casi siempre porque su nodo no responde. | nodo NotReady |
esperandolistofallaapagándose
# Deploy de un Triton nuevo: el pod nuevo no arranca y el rollout no termina
kubectl get pods -n classifier -l app.kubernetes.io/name=triton -o wide
# NAME READY STATUS RESTARTS AGE IP NODE
# triton-7d9c…-x2k4p 1/1 Running 0 3d 10.0.1.12 ip-10-0-1-5…
# triton-7d9c…-q8n7d 1/1 Running 0 3d 10.0.2.40 ip-10-0-2-9…
# triton-5f6b…-7tq2m 0/1 CrashLoopBackOff 4 (40s ago) 6m 10.0.1.77 ip-10-0-1-31…
kubectl describe pod -n classifier triton-5f6b…-7tq2m
# Last State: Terminated
# Reason: Error
# Exit Code: 1
# Warning BackOff … kubelet Back-off restarting failed container triton in pod …
kubectl logs -n classifier triton-5f6b…-7tq2m --previous | tail -n 20
# el error al cargar model.plan: Triton sale si un modelo no carga (--exit-on-error)
kubectl rollout undo deploy/triton -n classifier # ya con la causa: vuelve al tag anterior
kubectl rollout status deploy/triton -n classifier --timeout=25mget dice dónde se trabó; describe, por qué (Last State, Exit Code y Events); logs --previous, qué dijo el proceso al morir.Reason: Error es tu aplicación: no subas memoria. Exit 137 con Reason: OOMKilled sí es memoria.4 (40s ago) en RESTARTS: cuántas veces y hace cuánto fue el último. Entre intentos, la espera crece: 10 s, 20 s, 40 s… hasta 5 min. Ese default sigue igual en 1.37; desde 1.35, un admin puede bajar el tope por nodo en la configuración del kubelet.maxSurge: 1 y maxUnavailable: 0 (entra un pod nuevo antes de que salga uno viejo), los dos Triton viejos siguen sirviendo: no hay caída, así que diagnosticas sin apuro antes de decidir.describe y logs --previous antes de borrar nada: los eventos duran 1 h por defecto, y borrar el pod se lleva su log.Reason: StartError y Exit Code: 128; 126 y 127 solo salen si un shell dentro del contenedor los devuelve, y 125 no aparece.Cómo recordarla
estado → eventos → logs → exec. get dice en qué fase se trabó; describe y events, por qué; logs --previous, qué dijo al morir; exec, cómo se ve por dentro.
Trampa
borrar el pod o hacer rollout restart para “arreglarlo”. Se lleva el log del intento que murió, y el pod nuevo sale igual de roto. Y no te quedes en STATUS: CrashLoopBackOff es el síntoma; la causa está en el Exit Code y en el log.
En el curso: Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Docker y kubectl · Autoscaling y capacidad C01 · Modos de falla y degradación M6 · Los objetos que sí debes conocer M6 · Cold starts de punta a punta
Qué vas a provocar: Provocar cuatro fallas típicas (imagen que no existe, proceso que muere, Secret que falta, readiness que falla), reconocerlas con get, describe, logs y events, y arreglar una.
Necesitas: un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind. Baja busybox:1.38 y nginx:1.30-alpine.
kubectl create namespace lab-k8s && kubectl run tag-roto -n lab-k8s --image=nginx:no-existe && kubectl run sale -n lab-k8s --image=busybox:1.38 -- sh -c 'echo arrancando; exit 1'Qué deberías ver: namespace/lab-k8s created, pod/tag-roto created y pod/sale created.
kubectl apply -n lab-k8s -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: cfg}
spec:
containers:
- name: app
image: busybox:1.38
command: ["sleep", "3600"]
env:
- name: TOKEN
valueFrom: {secretKeyRef: {name: no-existe, key: token}}
---
apiVersion: v1
kind: Pod
metadata: {name: listo, labels: {app: listo}}
spec:
containers:
- name: web
image: nginx:1.30-alpine
readinessProbe:
httpGet: {path: /listo, port: 80}
EOFQué deberías ver: pod/cfg created y pod/listo created. cfg pide un Secret que no existe; listo tiene una readiness contra un path que nginx no sirve.
sleep 30; kubectl get pods -n lab-k8sQué deberías ver: tag-roto 0/1 ErrImagePull o ImagePullBackOff; sale 0/1 CrashLoopBackOff (o Error entre reinicios); cfg 0/1 CreateContainerConfigError; listo 0/1 Running.
kubectl events -n lab-k8s --for pod/tag-rotoQué deberías ver: Failed to pull image "nginx:no-existe": … not found (el texto del medio depende del runtime de tu cluster), Error: ErrImagePull, Back-off pulling image "nginx:no-existe" y Error: ImagePullBackOff.
kubectl logs sale -n lab-k8s --previous; kubectl describe pod sale -n lab-k8s | grep -A3 'Last State'Qué deberías ver: arrancando, y Last State: Terminated, Reason: Error, Exit Code: 1. El log del intento que murió dice lo que pasó; RESTARTS sube en cada vuelta.
kubectl events -n lab-k8s --for pod/cfgQué deberías ver: Un Warning con Error: secret "no-existe" not found. El pod no arranca hasta que el Secret exista.
kubectl events -n lab-k8s --for pod/listo --types=Warning; kubectl expose pod listo -n lab-k8s --port=80 && sleep 2 && kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=listo -o jsonpath='{.items[0].endpoints[0].conditions.ready}{"\n"}'Qué deberías ver: Readiness probe failed: HTTP probe failed with statuscode: 404, service/listo exposed y false: el pod corre, pero el Service no le manda tráfico.
kubectl create secret generic no-existe -n lab-k8s --from-literal=token=abc && sleep 20 && kubectl get pod cfg -n lab-k8sQué deberías ver: secret/no-existe created y cfg 1/1 Running: el kubelet reintenta solo (si todavía no, espera unos segundos más y repite el get).
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Cada falla tiene su arreglo: el tag correcto; un comando que no termine en error; crear el Secret antes que el pod; una readiness contra un path que exista. Confírmalo con el mismo comando que te mostró la falla.
Verificado contra la documentación; no ejecutado aquí.
Lees el mensaje: ¿quién lo dijo, qué significa, qué corres primero y dónde está la explicación?
Sintaxis con variables
Error from server (motivo): mensaje
Lo rechazó el API server (la puerta de entrada del cluster): permisos, validación o algo que no existe. Ese objeto no se creó ni cambió.
The Kind "nombre" is invalid: campo: detalle
La validación rechazó tu YAML: un valor imposible, o un campo que ya no se puede cambiar.
Warning motivo edad origen mensaje
Un evento en describe o kubectl events: lo dijo el scheduler, el kubelet o un controlador. Explica por qué el pod no avanza.
Last State: Terminated · Reason: motivo · Exit Code: codigo
Cómo murió el intento anterior del contenedor. Lo que pasó adentro está en kubectl logs --previous.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| motivo | la razón corta, en CamelCase | Forbidden, NotFound · FailedScheduling, BackOff · OOMKilled, Error |
| mensaje | el texto que explica la razón | pods is forbidden: User "ana" cannot list resource "pods"… |
| Kind | el tipo de objeto | Deployment |
| nombre | el nombre del objeto | gateway |
| campo | la ruta del campo con problema | spec.selector |
| detalle | qué tiene de malo | field is immutable |
| edad | hace cuánto, y cuántas veces se repitió | 2m (x10 over 6m) |
| origen | quién lo emitió | default-scheduler, kubelet, cluster-autoscaler |
| codigo | exit code del proceso | 137 |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Error from server (Forbidden) | Casi siempre RBAC (permisos): te falta ese verbo sobre ese recurso en ese namespace. También: una cuota llena. | cannot list resource "pods" |
| Error from server (NotFound) | No existe con ese nombre en ese namespace; a veces el namespace es otro. | deployments.apps "triton" not found |
| Error from server (AlreadyExists) | Hiciste create de algo que ya existe; apply lo actualizaría. | kubectl create -f dos veces |
| Error from server (BadRequest) | El servidor no puede procesar el pedido: un campo desconocido, o logs de un contenedor que no arrancó. | strict decoding error: unknown field |
| Error from server (Conflict) | Otro cambió el objeto entre que lo leíste y lo escribiste. Reintenta sobre la versión nueva. | the object has been modified |
| The … is invalid: | La validación rechazó un valor: formato, rango o un campo inmutable. | spec.selector: … field is immutable |
| error: You must be logged in to the server | El API server no acepta tus credenciales: vencidas o de una identidad que no conoce. | (Unauthorized) |
| The connection to the server … was refused | No hay API server en esa dirección: cluster apagado o sin kubeconfig. | localhost:8080 |
| Unable to connect to the server | No llega a la dirección: red, VPN, DNS o un cluster que solo se alcanza desde su red privada. | dial tcp …:443: i/o timeout |
| error: … (sin from server) | Lo dijo kubectl mismo: un flag, un archivo que no parsea, un tipo que no existe en el cluster. | the server doesn't have a resource type |
| Warning FailedScheduling | El scheduler no encontró nodo; cuenta los nodos por motivo. | 0/5 nodes are available |
| Warning Failed · BackOff | El kubelet no pudo bajar la imagen, armar o arrancar el contenedor, y reintenta. | Back-off pulling image |
| Warning Unhealthy | Falló una probe: Readiness, Liveness o Startup probe failed. | HTTP probe failed with statuscode: 503 |
| Last State: Terminated | Cómo murió el intento anterior: Reason y Exit Code. | OOMKilled · 137 |
Quiz · un mensaje a la vez
Mensaje 1 de 26
0/3 nodes are available: 3 Insufficient nvidia.com/gpu.
26 de 26
0/3 nodes are available: 3 Insufficient nvidia.com/gpu.
El scheduler no encontró un nodo con una GPU libre: todas están asignadas (cada pod se lleva una entera), o el nodo no anuncia nvidia.com/gpu porque el device plugin (el pod de NVIDIA que anuncia las GPUs del nodo) no corre ahí. El pod queda Pending; si el autoscaler de nodos puede, agrega uno.
Primero: kubectl get nodes -o 'custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu'
node(s) had untolerated taint {nvidia.com/gpu: present} · node(s) had untolerated taint(s)
Hay nodos, pero con un taint (una marca que rechaza pods) que tu pod no tolera. Según la versión, el mensaje nombra el taint (hasta 1.33, y en 1.34.0–1.34.1) o no (desde 1.35, y 1.34.2 en adelante): míralo en el nodo. En EKS, un pod que pide nvidia.com/gpu recibe sola la toleration de ese taint (la agrega ExtendedResourceToleration, un paso del API server): si un pod de GPU ve este mensaje, busca otro taint.
Primero: kubectl get nodes -o 'custom-columns=NAME:.metadata.name,TAINTS:.spec.taints[*].key'
node(s) didn't match Pod's node affinity/selector
Ningún nodo tiene las labels que pide tu nodeSelector o tu affinity (las reglas de a qué nodos puede ir el pod). En C01, Triton pide workload=gpu-t4: si el grupo de nodos de GPU no tiene esa label, el pod no cae en ningún lado.
Primero: kubectl get nodes -L workload
Failed to pull image "…": … not found · Back-off pulling image "…"
El kubelet no pudo bajar la imagen: primero ErrImagePull y después ImagePullBackOff, que reintenta con esperas crecientes, hasta 5 min. not found es un tag o un nombre mal escrito; 403 Forbidden o 401, permisos del registry. El texto del medio depende del runtime (containerd en EKS y kind).
Primero: kubectl describe pod POD -n classifier
no match for platform in manifest · exec /…: exec format error
La imagen es de otra arquitectura: la construiste en tu Mac (arm64) y los nodos son amd64. Si su índice (la lista de variantes por arquitectura) no trae amd64, falla el pull; si es una imagen de una sola arquitectura, baja bien y muere al arrancar, con exec format error en el log.
Primero: docker buildx imagetools inspect REGISTRY/IMAGEN:TAG
Back-off restarting failed container triton in pod triton-…
El evento del CrashLoopBackOff: el contenedor muere y el kubelet espera cada vez más antes de reiniciarlo (10 s, 20 s, 40 s… hasta 5 min). Es el síntoma; la causa está en el log del intento anterior y en el Exit Code.
Primero: kubectl logs POD -n classifier --previous
container has runAsNonRoot and image will run as root
Pediste no correr como root (runAsNonRoot: true), pero la imagen arranca como root y no fijaste otro usuario: el kubelet no crea el contenedor (CreateContainerConfigError). Las imágenes de NGC (el catálogo de contenedores de NVIDIA) arrancan como root; por eso C01 fija runAsUser: 1000 en Triton.
Primero: kubectl get pod POD -n classifier -o jsonpath='{.spec.securityContext}'
Readiness probe failed: HTTP probe failed with statuscode: 503
La readiness respondió 503 (o cualquier código fuera de 200–399): el pod sale de los endpoints del Service (la lista de pods que reciben tráfico), sin reiniciarse. En C01, el /readyz del gateway da 503 a propósito cuando Triton no está listo.
Primero: kubectl logs POD -n classifier --tail=50
Liveness probe failed: Get "http://…/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers) · Container gateway failed liveness probe, will be restarted
La liveness no respondió dentro de timeoutSeconds (1 s por defecto) y, tras failureThreshold fallos seguidos, el kubelet reinició el contenedor. Bajo carga suele ser un timeout corto o una liveness cara, no un proceso colgado.
Primero: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].livenessProbe}'
Last State: Terminated · Reason: OOMKilled · Exit Code: 137
El contenedor pasó su limits.memory y el kernel lo mató, sin dejar una línea en el log. Es RAM, no VRAM; en un servicio de modelos suele ser el pico al cargar los pesos.
Primero: kubectl describe pod POD -n classifier
The node was low on resource: memory.
Un desalojo (eviction): al nodo le faltó memoria y el kubelet sacó pods, primero los que usaban más de lo que pidieron. El pod queda en fase Failed con Reason: Evicted. Si dice ephemeral-storage, lo que se llenó es el disco.
Primero: kubectl describe node NODO
CUDA out of memory. Tried to allocate …
PyTorch se quedó sin memoria de la GPU (VRAM). Es una excepción en el log (torch.OutOfMemoryError, o torch.cuda.OutOfMemoryError según la versión), no un OOMKilled. Se arregla con batch o resolución más chicos, menos procesos por GPU u otra GPU, no subiendo limits.memory.
Primero: kubectl exec -n classifier POD -- nvidia-smi
503 Service Temporarily Unavailable (la respuesta del ALB)
Lo generó el balanceador, no tu app: el target group (la lista de pods a los que el ALB manda tráfico) no tiene targets registrados. En C01 pasa si ningún gateway pasa /readyz (por ejemplo, con Triton caído). Si los targets registrados están todos unhealthy, el ALB igual les manda tráfico (fail open). Ojo: el gateway de C01 también responde 503, con su JSON y Retry-After, cuando corta carga; ese no es el ALB.
Primero: kubectl get endpointslices -n classifier -l kubernetes.io/service-name=gateway
Failed build model due to couldn't auto-discover subnets: unable to resolve at least one subnet
El AWS Load Balancer Controller no encontró subredes para crear el ALB: les falta el tag kubernetes.io/role/elb (ALB público) o kubernetes.io/role/internal-elb (interno, como en C01). El Ingress se queda sin dirección.
Primero: kubectl describe ingress gateway -n classifier
Error: secret "triton-token" not found · couldn't find key MODEL_NAME in ConfigMap classifier/gateway-config
CreateContainerConfigError: el pod usa un Secret, un ConfigMap o una clave que no existe en su namespace. El kubelet reintenta solo cuando lo creas.
Primero: kubectl get secret,configmap -n classifier
→ Sección 7 · ConfigMaps, Secrets, volúmenes y ServiceAccounts
Not authorized to perform sts:AssumeRoleWithWebIdentity
IRSA (el pod recibe un rol de IAM, es decir, permisos de AWS, a través de su ServiceAccount) no funciona: el pod no puede asumir ese rol. Casi siempre, la trust policy del rol (quién puede usarlo) no coincide con el namespace y el nombre del ServiceAccount, o apunta al OIDC provider (el emisor de tokens) de otro cluster. Lo verás en el log de tu app (lo dice el SDK de AWS), no en los eventos.
Primero: kubectl get serviceaccount SA -n classifier -o yaml
→ Sección 7 · ConfigMaps, Secrets, volúmenes y ServiceAccounts
Error from server (Forbidden): pods is forbidden: User "…" cannot list resource "pods" in API group "" in the namespace "classifier"
RBAC (los permisos de Kubernetes): tu identidad no tiene ese verbo sobre ese recurso en ese namespace. El mensaje dice quién, qué verbo, qué recurso y dónde.
Primero: kubectl auth can-i list pods -n classifier
error: You must be logged in to the server (Unauthorized)
El API server no acepta tus credenciales: vencieron, o son de una identidad que el cluster no conoce. En EKS: tu terminal usa otra identidad de IAM (otro usuario o rol de AWS), o esa identidad no tiene un access entry (el permiso de EKS que la conecta con el cluster) ni una entrada en el ConfigMap aws-auth, el método anterior.
Primero: aws sts get-caller-identity
The connection to the server localhost:8080 was refused - did you specify the right host or port?
kubectl no encontró un cluster: sin kubeconfig apunta a localhost:8080, donde no hay nada. Con el cluster local apagado, o un contexto que apunta a otro lado, sale el mismo mensaje con otra dirección.
Primero: kubectl config current-context
no matches for kind "ScaledObject" in version "keda.sh/v1alpha1" · ensure CRDs are installed first
El cluster no conoce ese tipo: falta su CRD (CustomResourceDefinition, la definición del tipo nuevo, que instala KEDA o el operador que sea). Instala el componente antes de aplicar sus objetos; si ya está, revisa el apiVersion.
Primero: kubectl get crd scaledobjects.keda.sh
error: deployment "triton" exceeded its progress deadline
El rollout no avanzó dentro de progressDeadlineSeconds (600 s por defecto; 900 en Triton): Progressing=False con ProgressDeadlineExceeded. Kubernetes no vuelve atrás solo; los pods viejos siguen sirviendo.
Primero: kubectl get pods -n classifier -l app.kubernetes.io/name=triton -o wide
→ Sección 10 · Rollouts, rollback, PodDisruptionBudget y canary
Cannot evict pod as it would violate the pod's disruption budget.
Un desalojo voluntario (un kubectl drain, el upgrade de un grupo de nodos) chocó con el PodDisruptionBudget: ahora no se permite sacar ese pod. kubectl drain reintenta cada 5 s. Si el PDB pide tantos pods como réplicas, nunca va a poder.
Primero: kubectl get pdb -n classifier
→ Sección 10 · Rollouts, rollback, PodDisruptionBudget y canary
failed to get cpu utilization: missing request for cpu in container gateway of Pod gateway-…
El HPA (el autoscaler de pods) mide la CPU como porcentaje del request, y ese contenedor no tiene requests.cpu: no puede calcular la utilización y no escala.
Primero: kubectl describe hpa gateway -n classifier
prometheus metrics 'prometheus' target may be lost, the result is empty
KEDA consultó a Prometheus y la respuesta vino vacía. En C01 aparece como error a propósito (ignoreNullValues: "false"): casi siempre falta el ServiceMonitor (el objeto que le dice a Prometheus qué leer) y Prometheus no lee las métricas de Triton. Con el default (true), KEDA lo tomaría como 0, en silencio.
Primero: kubectl describe scaledobject triton -n classifier
strict decoding error: unknown field "spec.template.spec.containers[0].resource"
Un campo que no existe en ese lugar: un typo (resource en vez de resources) o mala indentación. kubectl valida en el servidor en modo estricto por defecto, así que lo rechaza en vez de ignorarlo.
Primero: kubectl explain deployment.spec.template.spec.containers.resources
spec.template.metadata.labels: Invalid value: …: `selector` does not match template `labels`
El selector del Deployment no coincide con las labels del template de pods: el Deployment no podría encontrar sus propios pods. Pon las mismas labels en los dos lados, como app.kubernetes.io/name: gateway en C01.
Primero: kubectl apply -f k8s/gateway.yaml --dry-run=server
Ningún mensaje tiene ese texto. Prueba con una palabra del mensaje (por ejemplo OOMKilled o taint).
$ kubectl describe pod -n classifier triton-5f6b…-9xk2w
…
Events:
Type Reason Age From Message
---- ------ --- ---- -------
Warning FailedScheduling 2m default-scheduler 0/5 nodes are available: 2 Insufficient nvidia.com/gpu, 3 node(s) didn't match Pod's node affinity/selector. preemption: 0/5 nodes are available: 2 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.
Normal TriggeredScaleUp 2m cluster-autoscaler pod triggered scale-up: [{… 2->3 (max: 4)}]0/5 nodes are available es el total; después viene cuántos nodos fallaron por cada motivo. Esa lista es la causa.Insufficient nvidia.com/gpu) y los 3 de CPU no tienen la label workload=gpu-t4. Lo segundo está bien: Triton no debe caer ahí.preemption: dice que sacar otros pods no ayudaría. No es la causa: no hay nada que arreglar ahí.TriggeredScaleUp es del Cluster Autoscaler (el que agrega nodos): pidió un nodo GPU más. El pod espera unos minutos y se programa solo. Si en cambio ves NotTriggerScaleUp, no encontró un grupo de nodos que pueda crecer y le sirva al pod: están en su máximo, ninguno trae la label o la GPU, o quedaron en pausa tras fallar (por ejemplo, sin cuota).{nvidia.com/gpu: present}); desde 1.35 (y 1.34.2 en adelante) solo dice untolerated taint(s): míralo en el nodo.Cómo recordarla
quién habla dice dónde mirar. Error from server es el API server (permisos, validación, no existe); un Warning en los eventos es el scheduler o el kubelet; Last State es cómo murió el proceso; lo demás lo dijo tu app y está en kubectl logs.
Trampa
leer solo la primera línea, o solo STATUS. En un FailedScheduling, el conteo por motivo dice qué arreglar y lo que sigue a preemption: no es la causa. Y algunos textos cambian según la versión: desde 1.35 (y 1.34.2) el scheduler ya no nombra el taint, y desde 1.33 el conflicto de zona de un volumen dice didn't match PersistentVolume's node affinity (antes, had volume node affinity conflict).
En el curso: Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Docker y kubectl · kubectl: mirar, entrar y conectar Docker y kubectl · Rollouts y escalado M6 · GPUs en Kubernetes
Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar cuatro en tu cluster local: NotFound, Forbidden, un campo desconocido y un selector inmutable.
Necesitas: Una terminal de macOS para la ronda; para los pasos 3 a 6, un cluster local: Kubernetes de Docker Desktop (Dashboard → vista Kubernetes → Create cluster; contexto docker-desktop) o kind.
ronda() { printf '%s\n' '0/3 nodes are available: 3 Insufficient nvidia.com/gpu' 'node(s) had untolerated taint(s)' "node(s) didn't match Pod's node affinity/selector" 'Failed to pull image … not found · Back-off pulling image' 'no match for platform in manifest · exec format error' 'Back-off restarting failed container' 'container has runAsNonRoot and image will run as root' 'Readiness probe failed: HTTP probe failed with statuscode: 503' 'Liveness probe failed: … context deadline exceeded' 'Reason: OOMKilled · Exit Code: 137' 'The node was low on resource: memory' 'CUDA out of memory' '503 Service Temporarily Unavailable' "couldn't auto-discover subnets" 'Error: secret "triton-token" not found' 'Not authorized to perform sts:AssumeRoleWithWebIdentity' 'pods is forbidden: User "ana" cannot list resource "pods"' 'error: You must be logged in to the server (Unauthorized)' 'The connection to the server localhost:8080 was refused' 'no matches for kind "ScaledObject" in version "keda.sh/v1alpha1"' 'exceeded its progress deadline' "Cannot evict pod as it would violate the pod's disruption budget" 'failed to get cpu utilization: missing request for cpu' "prometheus metrics 'prometheus' target may be lost, the result is empty" 'strict decoding error: unknown field' '`selector` does not match template `labels`' | sort -R | head -5; }; rondaQué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (API server, scheduler, kubelet, un controlador o tu app), la causa probable y el primer comando. Después compáralo con la lista.
rondaQué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.
kubectl get deploy triton -n default; kubectl get pods -n kube-system --as=ana; kubectl get scaledobjectsQué deberías ver: Error from server (NotFound): deployments.apps "triton" not found; Error from server (Forbidden): pods is forbidden: User "ana" cannot list resource "pods" in API group "" in the namespace "kube-system"; error: the server doesn't have a resource type "scaledobjects" (no tienes KEDA en local).
kubectl create namespace lab-k8s && kubectl create deployment web -n lab-k8s --image=nginx:1.30-alpine --dry-run=client -o yaml | sed 's/replicas:/replicaz:/' | kubectl apply -f -Qué deberías ver: namespace/lab-k8s created y Error from server (BadRequest): error when creating "STDIN": Deployment in version "v1" cannot be handled as a Deployment: strict decoding error: unknown field "spec.replicaz". Un typo en un campo no se ignora.
kubectl create deployment web -n lab-k8s --image=nginx:1.30-alpine && kubectl patch deploy web -n lab-k8s --type=merge -p '{"spec":{"selector":{"matchLabels":{"app":"otro"}}}}'Qué deberías ver: deployment.apps/web created y The Deployment "web" is invalid: con dos errores, spec.template.metadata.labels: … `selector` does not match template `labels` y spec.selector: … field is immutable. Cambiar el selector rompe las dos reglas a la vez.
kubectl delete namespace lab-k8sQué deberías ver: namespace "lab-k8s" deleted.
Cómo arreglarlo y confirmarlo
Los que fallaste: abre la sección de su tema (el enlace de la lista) y haz su laboratorio. Repite la ronda otro día: la meta es 5 de 5 en menos de 2 minutos.
Verificado contra la documentación; no ejecutado aquí.
Del síntoma al primer comando, y una revisión rápida de tus manifests antes del apply.
Elige lo que ves. Te dice qué correr primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla.
Elige un síntoma para ver las causas.
Mi pod se queda en Pending y nunca arranca.
Corre primero
kubectl describe pod POD -n classifier
Ningún nodo tiene lugar: la suma de requests (lo que cada pod reserva) de CPU, memoria o nvidia.com/gpu no entra (Insufficient …). Con GPU: todas asignadas, o el device plugin (el pod de NVIDIA que anuncia las GPUs) no corre y el nodo no anuncia nvidia.com/gpu.
Compruébalo: kubectl describe node NODO (Allocatable contra Allocated resources) · kubectl get nodes -o 'custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu'
Arreglo: Requests realistas, o deja que el autoscaler de nodos agregue uno. Cada pod se lleva una GPU entera. Un nodo GPU con <none>: revisa el DaemonSet (un pod por nodo) del device plugin.
Un nodeSelector o una affinity (las labels de nodo que el pod exige) que ningún nodo cumple, o un taint (una marca del nodo que rechaza pods) que el pod no tolera (didn't match Pod's node affinity/selector; untolerated taint). En EKS, un pod que pide nvidia.com/gpu ya recibe sola la toleration de ese taint: el que falta suele ser otro.
Compruébalo: kubectl get nodes -L workload · kubectl get nodes -o 'custom-columns=NAME:.metadata.name,TAINTS:.spec.taints[*].key'
Arreglo: En C01: la label workload=gpu-t4 en el node group (grupo de nodos) de GPU. Tolerations para los taints propios del equipo; fuera de EKS, también la de nvidia.com/gpu, como la que ya trae Triton.
El autoscaler de nodos no puede crear el nodo: grupo en su máximo, sin cuota de GPU o sin capacidad en la zona.
Compruébalo: kubectl events -n classifier --for pod/POD (NotTriggerScaleUp) · kubectl get configmap cluster-autoscaler-status -n kube-system -o yaml
Arreglo: Sube el máximo del grupo o la cuota; permite más tipos de instancia o zonas. Con Karpenter, mira kubectl get nodeclaims.
Un volumen (PVC, un pedido de disco) sin enlazar, o un disco EBS que quedó en otra zona (pod has unbound immediate PersistentVolumeClaims; desde 1.33, didn't match PersistentVolume's node affinity; antes, had volume node affinity conflict).
Compruébalo: kubectl get pvc -n classifier
Arreglo: Para discos nuevos, una StorageClass (la receta del disco) con volumeBindingMode: WaitForFirstConsumer: el disco se crea en la zona donde cae el pod. Si el disco ya existe, el pod necesita un nodo en esa zona.
El pod lleva minutos en ContainerCreating.
Corre primero
kubectl events -n classifier --for pod/POD
Está bajando una imagen grande (la de Triton pesa varios GB). No es un error: es lento.
Compruébalo: kubectl events -n classifier --for pod/POD (Pulling image … y, al terminar, Successfully pulled image … in …)
Arreglo: Una imagen más chica o nodos con la imagen ya cargada; mide el pull y súmalo al cold start (lo que tarda un pod nuevo en servir).
Un volumen no se puede montar: un Secret o ConfigMap que no existe, o un disco que sigue pegado a otro nodo (FailedMount, Multi-Attach error).
Compruébalo: kubectl describe pod POD -n classifier (Events: FailedMount o FailedAttachVolume)
Arreglo: Crea el objeto en el mismo namespace. Un disco ReadWriteOnce se monta en un solo nodo a la vez: en un Deployment, usa la estrategia Recreate (baja el pod viejo antes de crear el nuevo) o un StatefulSet.
En EKS, la red del pod: el CNI (el plugin que le da a cada pod una IP de la VPC) no consigue una, porque la subred se quedó sin IPs o el nodo llegó a su tope de IPs (FailedCreatePodSandBox, failed to assign an IP address to container).
Compruébalo: kubectl describe pod POD -n classifier · kubectl get pods -n kube-system -l k8s-app=aws-node
Arreglo: Subred sin IPs: subredes más grandes, o un rango de IPs secundario en la VPC para los pods. Nodo en su tope: prefix delegation en el VPC CNI (más IPs por nodo).
ImagePullBackOff o ErrImagePull: no baja la imagen.
Corre primero
kubectl describe pod POD -n classifier
El tag o el nombre están mal escritos: esa imagen no existe (… not found).
Compruébalo: aws ecr describe-images --repository-name classifier-triton --image-ids imageTag=TAG
Arreglo: Corrige el tag; en producción, mejor fijar la imagen por digest (su hash sha256, que no cambia).
Sin permiso para leer del registry: el rol de IAM de los nodos no puede leer de ECR, o la policy del repositorio lo niega (403 Forbidden o 401 Unauthorized). Con otro registry privado, falta el imagePullSecret (el Secret con la clave del registry).
Compruébalo: aws iam list-attached-role-policies --role-name ROL_DE_LOS_NODOS · aws ecr get-repository-policy --repository-name classifier-triton · kubectl get pod POD -n classifier -o jsonpath='{.spec.imagePullSecrets}'
Arreglo: En EKS, la política AmazonEC2ContainerRegistryPullOnly en el rol de los nodos, sin un Deny en la policy del repositorio. Con otro registry privado, un imagePullSecret.
La imagen no trae variante para la arquitectura del nodo: la construiste en tu Mac (no match for platform in manifest).
Compruébalo: docker buildx imagetools inspect REGISTRY/IMAGEN:TAG
Arreglo: docker buildx build --platform linux/amd64, mejor en CI.
Límite de descargas de Docker Hub (429 Too Many Requests).
Compruébalo: kubectl events -n classifier --for pod/POD (429 en el mensaje del pull)
Arreglo: Copia la imagen a ECR, o usa un pull through cache de ECR (ECR la baja de Docker Hub una vez y la guarda), y bájala de ahí.
CrashLoopBackOff: RESTARTS no para de subir.
Corre primero
kubectl logs POD -n classifier --previous
La app falla al arrancar (Exit Code 1): falta una variable, o el modelo o el engine no cargan.
Compruébalo: kubectl logs POD -n classifier --previous
Arreglo: Arregla lo que dice el log. Si vino con un deploy, mientras tanto kubectl rollout undo.
Lo mató la memoria (Reason: OOMKilled, Exit Code: 137).
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
Arreglo: Mide el pico y fija limits.memory con margen (mira el síntoma OOMKilled).
Una liveness o una startupProbe lo reinicia mientras carga o está lento (Liveness probe failed …, y después failed liveness probe, will be restarted).
Compruébalo: kubectl events -n classifier --for pod/POD
Arreglo: Una startupProbe que cubra la carga (C01: 60 × 5 s = 5 min) y una liveness barata, sin dependencias.
El comando termina enseguida o no se puede ejecutar: Exit Code 0; con containerd (el runtime de EKS y kind), Reason StartError y exit 128; o exec format error si la imagen es de otra arquitectura.
Compruébalo: kubectl describe pod POD -n classifier (Last State: Reason y Exit Code)
Arreglo: Un servidor en primer plano, el comando correcto en la imagen y el build para linux/amd64.
OOMKilled: Exit Code 137 en Last State.
Corre primero
kubectl describe pod POD -n classifier
Pico de RAM al cargar el modelo: los pesos pasan por la RAM antes de ir a la GPU.
Compruébalo: kubectl top pod POD -n classifier --containers (necesita metrics-server; solo muestra el presente)
Arreglo: Mide el pico (container_memory_working_set_bytes en Prometheus) y fija el límite con margen.
/dev/shm en memoria (un emptyDir con medium: Memory) cuenta contra el límite del contenedor.
Compruébalo: kubectl exec -n classifier POD -- df -h /dev/shm
Arreglo: Incluye el sizeLimit de /dev/shm dentro del límite de memoria (en el Triton de C01, 1Gi dentro de 6Gi).
Demasiada concurrencia: varios decodes o workers en paralelo multiplican la memoria.
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].env}'
Arreglo: Acota la concurrencia, como PREPROCESS_CONCURRENCY=2 en el gateway de C01.
No es OOMKilled sino la VRAM: CUDA out of memory aparece en el log y no hay exit 137.
Compruébalo: kubectl logs POD -n classifier --previous | grep -i 'out of memory'
Arreglo: Batch o resolución más chicos, menos procesos por GPU u otra GPU. Subir limits.memory no ayuda.
Running pero READY 0/1: no recibe tráfico.
Corre primero
kubectl describe pod POD -n classifier
Todavía carga el modelo: Triton abre el puerto recién al terminar (connection refused en la probe).
Compruébalo: kubectl logs -f POD -n classifier
Arreglo: Espera. Si la carga es lenta, una startupProbe con margen.
Una dependencia no está lista: el /readyz del gateway de C01 da 503 a propósito si Triton no responde.
Compruébalo: kubectl get endpointslices -n classifier -l kubernetes.io/service-name=triton
Arreglo: Arregla la dependencia; la readiness hace bien en sacar al pod.
La probe apunta a un puerto o a un path que la app no sirve (statuscode: 404, o connection refused en otro puerto).
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].readinessProbe}'
Arreglo: El mismo puerto (mejor por nombre, como port: http en C01) y un path que exista.
Timeout corto con el proceso ocupado: timeoutSeconds vale 1 s por defecto.
Compruébalo: kubectl events -n classifier --for pod/POD --types=Warning (context deadline exceeded)
Arreglo: Un timeout realista (2 s en C01) y un endpoint de health barato.
Los pods están Ready, pero el Service no les manda tráfico.
Corre primero
kubectl get endpointslices -n classifier -l kubernetes.io/service-name=gateway
El selector del Service no coincide con las labels de los pods: el Service se queda sin endpoints.
Compruébalo: kubectl get svc gateway -n classifier -o jsonpath='{.spec.selector}' · kubectl get pods -n classifier --show-labels
Arreglo: Las mismas labels en el selector del Service y en el template del Deployment.
El targetPort no coincide con el puerto del contenedor, o con su nombre.
Compruébalo: kubectl get svc gateway -n classifier -o jsonpath='{.spec.ports}'
Arreglo: targetPort por nombre (http en C01): así no se desalinean.
Una NetworkPolicy (un firewall entre pods) no deja pasar al que llama: en C01, al puerto 8000 de Triton solo entra el gateway, y al 8002 solo el namespace monitoring.
Compruébalo: kubectl get networkpolicy -n classifier · kubectl describe networkpolicy triton-ingress -n classifier
Arreglo: Permite el origen correcto con podSelector o namespaceSelector. Sin un plugin de red (CNI) que las aplique, no hacen nada.
El cliente usa otro nombre o puerto: un nombre de DNS mal escrito, o el Service de otro namespace.
Compruébalo: kubectl run dns -n classifier -it --rm --restart=Never --image=busybox:1.38 -- nslookup triton.classifier.svc.cluster.local
Arreglo: El nombre completo servicio.namespace.svc.cluster.local y el puerto del Service, como TRITON_URL en C01.
El Ingress (el ALB) responde 503 o 504.
Corre primero
kubectl describe ingress gateway -n classifier
503: el target group (la lista de pods a los que el ALB manda tráfico) no tiene targets registrados. En C01, ningún gateway pasa /readyz (por ejemplo, Triton caído) y no quedan endpoints listos.
Compruébalo: kubectl get endpointslices -n classifier -l kubernetes.io/service-name=gateway
Arreglo: Arregla la readiness o su dependencia; mientras, quien llama usa su plan B (en C01, el vendedor elige la categoría a mano).
El AWS Load Balancer Controller (el que crea el ALB a partir del Ingress) no actualizó el ALB, o ni lo creó (el Ingress queda sin ADDRESS): subredes sin tag o permisos de IAM (FailedBuildModel, FailedDeployModel).
Compruébalo: kubectl describe ingress gateway -n classifier (Events) · kubectl logs -n kube-system deploy/aws-load-balancer-controller
Arreglo: Subredes con el tag kubernetes.io/role/internal-elb=1 (ALB interno, como en C01) y la política de IAM del controlador.
504: lo devuelve el gateway cuando Triton no responde en 150 ms (INFER_TIMEOUT_S en C01), o el ALB cuando el target (el pod registrado) no acepta la conexión en 10 s o no responde dentro del idle timeout (60 s en C01).
Compruébalo: kubectl logs -n classifier deploy/gateway --tail=50 · kubectl get targetgroupbindings -n classifier -o jsonpath='{.items[*].spec.targetGroupARN}' · aws elbv2 describe-target-health --target-group-arn ARN
Arreglo: Si es del gateway, Triton está saturado: mira su tiempo en cola y si KEDA escala. Si es del ALB, busca el target lento o caído.
Errores solo durante cada deploy: un pod viejo se va antes de que el ALB deje de mandarle requests, o los viejos salen antes de que el ALB registre a los nuevos (suele verse como 502 o 503).
Compruébalo: kubectl get deploy gateway -n classifier -o jsonpath='{.spec.template.spec.containers[0].lifecycle}'
Arreglo: preStop con sleep (15 s en C01) para seguir atendiendo mientras el ALB lo saca, y el readiness gate del controlador (la label del namespace) para que el deploy espere a que el ALB vea sano al pod nuevo.
kubectl rollout status no termina, o falla con exceeded its progress deadline.
Corre primero
kubectl get pods -n classifier -l app.kubernetes.io/name=triton -o wide
El pod nuevo no llega a Ready: CrashLoopBackOff, imagen que no baja o probe que falla.
Compruébalo: kubectl describe pod POD -n classifier
Arreglo: kubectl rollout undo deploy/triton -n classifier para mitigar; después arregla la causa.
El pod nuevo está Pending: con maxSurge 1 y maxUnavailable 0 necesita una GPU libre, y no hay.
Compruébalo: kubectl events -n classifier --for pod/POD (FailedScheduling: Insufficient nvidia.com/gpu)
Arreglo: Espera el nodo nuevo. Si tarda más que el plazo, sube progressDeadlineSeconds (900 s en Triton).
El Deployment está en pausa (kubectl rollout pause).
Compruébalo: kubectl get deploy triton -n classifier -o jsonpath='{.spec.paused}'
Arreglo: kubectl rollout resume deploy/triton -n classifier
El ReplicaSet no puede crear pods: una ResourceQuota (el tope de recursos del namespace) llena o un ServiceAccount que no existe (FailedCreate).
Compruébalo: kubectl describe rs -n classifier -l app.kubernetes.io/name=triton
Arreglo: Ajusta la cuota del namespace o crea el ServiceAccount.
El HPA no escala: TARGETS dice <unknown>, o no pasa del mínimo.
Corre primero
kubectl describe hpa gateway -n classifier
No le llegan métricas: metrics-server (el que mide CPU y memoria de los pods) no está instalado o su API no responde.
Compruébalo: kubectl get apiservice v1beta1.metrics.k8s.io
Arreglo: Instala metrics-server; en EKS no siempre viene.
Los pods no tienen request de CPU: el porcentaje no tiene contra qué medirse (missing request for cpu).
Compruébalo: kubectl get deploy gateway -n classifier -o jsonpath='{.spec.template.spec.containers[0].resources}'
Arreglo: requests.cpu en cada contenedor (1 en el gateway de C01).
Ya está en maxReplicas (ScalingLimited en True, TooManyReplicas).
Compruébalo: kubectl get hpa gateway -n classifier
Arreglo: Sube maxReplicas en git, después de mirar el costo.
Con KEDA, la consulta a Prometheus vuelve vacía o falla (KEDAScalerFailed).
Compruébalo: kubectl describe scaledobject triton -n classifier
Arreglo: Que Prometheus lea las métricas de Triton (los ServiceMonitors de C01) y que la query devuelva un número.
Un pod se queda en Terminating.
Corre primero
kubectl get pod POD -n classifier -o wide
Se está apagando en orden: preStop y SIGTERM dentro del plazo de gracia (hasta 60 s en Triton). Es normal.
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.metadata.deletionGracePeriodSeconds}'
Arreglo: Espera. Si siempre llega al límite, revisa que la app maneje SIGTERM.
Su nodo no responde (NotReady): nadie confirma que el contenedor paró.
Compruébalo: kubectl get nodes
Arreglo: Si el nodo murió, que el autoscaler lo reemplace o borra el Node. Último recurso: kubectl delete pod POD -n classifier --grace-period=0 --force (el contenedor podría seguir vivo en ese nodo).
Un finalizer (una marca que pide limpiar algo antes de borrar) que ningún controlador quita.
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.metadata.finalizers}'
Arreglo: Arregla el controlador dueño del finalizer. Quitarlo a mano se salta su limpieza.
Pods desalojados: Evicted en describe (en STATUS puede verse Evicted, Error o ContainerStatusUnknown).
Corre primero
kubectl get pods -n classifier --field-selector=status.phase=Failed
Al nodo le faltó memoria: salen primero los pods que usan más de lo que pidieron.
Compruébalo: kubectl describe node NODO (Conditions: MemoryPressure)
Arreglo: limits.memory igual a requests.memory, como en C01: el pod no puede pasarse de lo que reservó.
Disco del nodo lleno: imágenes CUDA grandes, logs, emptyDir (carpeta temporal del pod) sin sizeLimit (The node was low on resource: ephemeral-storage).
Compruébalo: kubectl describe pod POD -n classifier (Message: The node was low on resource: …)
Arreglo: Un disco de nodo más grande para imágenes de GPU, y sizeLimit en los emptyDir.
El pod pasó su propio límite de disco local o el sizeLimit de un emptyDir (Usage of EmptyDir volume … exceeds the limit).
Compruébalo: kubectl describe pod POD -n classifier
Arreglo: Sube ese límite o escribe menos en disco local. Borra los pods Failed después de leer su motivo.
CreateContainerConfigError: el contenedor ni se crea.
Corre primero
kubectl describe pod POD -n classifier
Falta el Secret o el ConfigMap que usa env o envFrom (Error: secret "…" not found).
Compruébalo: kubectl get secret,configmap -n classifier
Arreglo: Créalo antes que el pod y en el mismo namespace: un pod no ve objetos de otro namespace.
Existe, pero le falta la clave (couldn't find key … in ConfigMap …).
Compruébalo: kubectl get configmap NOMBRE -n classifier -o yaml
Arreglo: Agrega la clave, o marca la referencia con optional: true si de verdad es opcional.
runAsNonRoot (pediste no correr como root) con una imagen que corre como root, o con un usuario que no es numérico.
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.spec.securityContext}'
Arreglo: Un runAsUser numérico: 10001 en el gateway y 1000 en Triton (C01).
El pod de GPU corre, pero no ve la GPU (nvidia-smi falla o torch.cuda.is_available() da False).
Corre primero
kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].resources.limits}'
No pediste nvidia.com/gpu en limits: el device plugin (el pod de NVIDIA que reparte las GPUs) no le asigna ninguna. Ojo: según el runtime del nodo, una imagen de NVIDIA podría ver todas sin control.
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].resources.limits}'
Arreglo: nvidia.com/gpu: 1 en limits, como Triton en C01.
El pod cayó en un nodo sin GPU: sin el request de GPU y sin selector, nada lo lleva a uno.
Compruébalo: kubectl get pod POD -n classifier -o wide · kubectl get nodes -L workload
Arreglo: El request de GPU, más el nodeSelector y la toleration de C01.
El driver del nodo es más viejo que el CUDA de la imagen (CUDA driver version is insufficient for CUDA runtime version).
Compruébalo: kubectl exec -n classifier POD -- nvidia-smi
Arreglo: Una imagen con un CUDA que ese driver soporte, o nodos con un driver más nuevo.
La imagen trae PyTorch sin CUDA (el paquete para CPU).
Compruébalo: kubectl exec -n classifier POD -- python -c "import torch; print(torch.__version__, torch.version.cuda)"
Arreglo: El paquete de PyTorch con CUDA (por ejemplo, el de cu126) que soporte el driver del nodo.
Pega uno o más manifests (separados con ---). Revisa 18 reglas típicas en tu navegador: lee el YAML y mira campos, labels, puertos y probes. No reemplaza a kubectl apply --dry-run=server, que valida contra tu cluster.
Lee el YAML con js-yaml (de cdnjs): después de la primera visita queda guardado y funciona sin conexión.