Ronda de Diseño ML · Orquestación · ejemplos de C01

Orquestación

Cheat sheet de Kubernetes

Para 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

0

Números y códigos de bolsillo

¿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 namespace

Busca 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

VariableQué esEjemplo
namespacecarpeta lógica donde vive el podclassifier
podnombre del pod, como sale en get podstriton-5c7d9b8f6d-q2x7k (ilustrativo)
recursotipo de objeto, en minúsculasdeployment
camporuta del campo dentro del objetospec.strategy.rollingUpdate.maxSurge
nsen˜aln_{\text{señal}}número de la señal que mató al proceso (kill -l las lista)9 = SIGKILL, 15 = SIGTERM
tgraciat_{\text{gracia}}plazo total para apagarse: terminationGracePeriodSeconds45 s en el gateway, 60 s en Triton
tpreStopt_{\text{preStop}}lo que dura el preStop (un comando que corre antes de apagar); va dentro del plazo, antes del SIGTERM15 s en el gateway, 10 s en Triton
tappt_{\text{app}}lo que le queda a tu proceso después del SIGTERM45 − 15 = 30 s; uvicorn usa hasta 20
nnnúmero de la espera: el primer reinicio es inmediato, y la espera 1 va antes del segundola 4.ª espera es de 80 s; de la 6.ª en adelante, 300 s
NNré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

exit code  =  128+nsen˜al137=128+9 (KILL)143=128+15 (TERM)\text{exit code} \;=\; 128 + n_{\text{señal}} \qquad 137 = 128 + 9\ (\text{KILL}) \qquad 143 = 128 + 15\ (\text{TERM})
tapp  =  tgracia−tpreStoptgracia=30 s por defectot_{\text{app}} \;=\; t_{\text{gracia}} - t_{\text{preStop}} \qquad t_{\text{gracia}} = 30\ \text{s por defecto}
tespera(n)  =  min⁡(10⋅2 n−1, 300) s(vuelve a 10 s tras 10 min corriendo bien)t_{\text{espera}}(n) \;=\; \min\left(10 \cdot 2^{\,n-1},\ 300\right)\ \text{s} \qquad (\text{vuelve a 10 s tras 10 min corriendo bien})
surge=⌈0.25 N⌉faltantes=⌊0.25 N⌋(defaults del rolling update)\text{surge} = \lceil 0.25\,N \rceil \qquad \text{faltantes} = \lfloor 0.25\,N \rfloor \qquad (\text{defaults del rolling update})

Las piezas

PiezaQué haceEjemplo
PendingEl cluster aceptó el pod, pero todavía no tiene nodo o no terminó de preparar sus contenedores.Triton esperando una GPU libre
ContainerCreatingYa 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/1El proceso corre, pero falla la readinessProbe (el chequeo de ¿le mando tráfico?): no recibe requests.Triton cargando el engine
CrashLoopBackOffSe 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 / ErrImagePullNo pudo bajar la imagen; reintenta con espera creciente.tag inexistente o nodo sin permiso en el registry (ECR, el de AWS)
CreateContainerConfigErrorNo 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 / ErrorMotivo 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 · 137Superó 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 OOMKilledTambié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
143128 + 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 · 128El 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 / 127Solo aparecen si un shell dentro del contenedor los devuelve: no es ejecutable / no existe.command: ["sh", "-c", "uvicorn …"] en una imagen sin uvicorn
TerminatingTiene orden de borrado: corre el preStop, recibe SIGTERM y tiene hasta el plazo de gracia para salir.hasta 45 s en el gateway
EvictedEl 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
CampoDefaultEn el C01Por qué cambia
terminationGracePeriodSeconds30 sgateway 45 · Triton 60preStop + apagado del servidor + margen
probes: periodSeconds / timeoutSeconds / failureThreshold10 s / 1 s / 3readiness del gateway y de Triton: 5 s / 2 s / 2salir del Service en ≈ 10 s, con 2 s de holgura por chequeo
probes: initialDelaySeconds / successThreshold0 s / 1liveness (¿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.maxSurge25 %, redondea hacia arribagateway 25 % · Triton 1cada pod extra de Triton es una GPU extra
rollingUpdate.maxUnavailable25 %, redondea hacia abajo0 en los dosnunca bajar de la capacidad actual durante un deploy
revisionHistoryLimit105ReplicaSets viejos para rollout undo; 5 alcanzan
progressDeadlineSeconds600 sTriton 900un pod con nodo GPU nuevo tarda ≈ 4–11 min (estimación del curso)
minReadySeconds0sin cambiarel pod cuenta como disponible apenas está Ready
Espera entre reinicios1.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óngateway: baja 1 pod por minutono oscilar con el tráfico del día
Servicetype ClusterIP · targetPort = porttargetPort: http (por nombre)el número del puerto puede cambiar sin tocar el Service
ALB: deregistration delay / idle timeout300 s / 60 s30 s / 60 scon 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.

Los defaults que más aparecen al leer un manifest, junto a lo que fija el C01. Verificados contra Kubernetes 1.37 y la documentación de AWS, en octubre de 2026.

Ejemplo real · El apagado del gateway del C01: 45 = 15 + 20 + margen

YAMLreference/c01/k8s/gateway.yaml (extracto; # … marca líneas omitidas)
    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.
  • El 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.
  • Después llega el SIGTERM. A uvicorn le quedan 45 − 15 = 30 s y usa hasta 20 (--timeout-graceful-shutdown 20, en su Dockerfile): sobran 10 de margen.
  • Si a los 45 s el proceso sigue vivo, llega el SIGKILL: 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.
  • Es el mismo plazo que en Docker dabas con 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.

Laboratorio en tu Mac (opcional)7 pasos

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)

  1. Paso 1
    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.

  2. Paso 2
    sleep 20; kubectl get pods -n lab-k8s

    Qué deberías ver: ok en Completed y falla en Error, los dos con READY 0/1 y RESTARTS 0.

  3. Paso 3
    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.

  4. Paso 4
    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.

  5. Paso 5
    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-k8s

    Qué 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.

  6. Paso 6
    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-k8s

    Qué deberías ver: Uno o dos segundos: el shell atrapa el SIGTERM y sale solo, sin esperar el plazo (30 s por defecto).

  7. Paso 7
    kubectl delete namespace lab-k8s

    Qué 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í.

↳

La cadena: del YAML al pod que recibe tráfico

¿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 archivo

Guarda 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

VariableQué esEjemplo
archivomanifest (o carpeta) a aplicark8s/gateway.yaml
deploynombre del Deploymentgateway
namespacenamespace de los objetosclassifier
plazocuánto esperar antes de dar el deploy por fallidop. ej. 5m para el gateway; 25m para Triton (estimación del curso)
servicionombre del Servicegateway
tnodot_{\text{nodo}}esperar un nodo con espacio; si hay que crearlo, minutos≈ 2–5 min para un g4dn nuevo (estimación del curso)
tpullt_{\text{pull}}bajar la imagen al nodo; cero si ya estabasegundos para el gateway; minutos para los varios GB de Triton
tarranquet_{\text{arranque}}el proceso arranca y carga lo que necesitaTriton carga el engine; su startupProbe (¿ya arrancó?) le da hasta 5 min
treadinesst_{\text{readiness}}hasta que pasa la readinessProbe (¿le mando tráfico?)chequeo cada 5 s en el gateway

Fórmulas

thasta traˊfico  ≈  tnodo+tpull+tarranque+treadinesst_{\text{hasta tráfico}} \;\approx\; t_{\text{nodo}} + t_{\text{pull}} + t_{\text{arranque}} + t_{\text{readiness}}

Las piezas

PiezaQué haceEjemplo
kubeconfig y contextoArchivo con tus clusters y credenciales; el contexto dice a cuál le habla kubectl ahora.kubectl config current-context
kubectl applyManda 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
admissionPaso 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)
etcdLa base de datos del cluster: guarda el estado deseado de cada objeto.en EKS la opera AWS
Deployment controllerLee el Deployment y crea un ReplicaSet (mantiene N copias iguales) a partir de su template, el molde del pod.gateway-<hash>
ReplicaSet controllerCrea 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
kubeletEl 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
readinessProbeChequeo periódico de ¿le mando tráfico? Mientras falle, el pod no recibe requests del Service./readyz en el gateway
EndpointSlice controllerArma 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 Controllerkube-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

  1. Manifest (YAML)

    kubectl diff -f k8s/gateway.yaml

    Tu estado deseado, en git. diff muestra qué cambiaría en el cluster (exit 1 si hay cambios).

  2. kubectl apply

    kubectl apply -f k8s/gateway.yaml

    Manda cada objeto al API server con tus credenciales. Vuelve en segundos: no espera pods.

Control plane: guarda y decide

  1. API server

    kubectl apply --dry-run=server -f k8s/gateway.yaml

    Autentica, revisa permisos (RBAC), corre admission y valida el esquema. El objeto que rechaza no pasa a ningún eslabón más.

  2. etcd

    kubectl get deployment gateway -n classifier -o yaml

    El objeto queda guardado con su spec; el status lo irán llenando los controllers.

  3. Deployment controller

    kubectl get rs -n classifier -l app.kubernetes.io/name=gateway

    Crea (o escala) un ReplicaSet gateway-<hash> con el template.

  4. ReplicaSet controller

    kubectl get pods -n classifier -l app.kubernetes.io/name=gateway

    Crea los pods que faltan. Nacen Pending: todavía sin nodo.

  5. Scheduler

    kubectl get pods -n classifier -o wide

    Elige nodo según requests, taints y reparto por zona: se llena la columna NODE.

En el nodo: arranca el contenedor

  1. kubelet + containerd

    kubectl events -n classifier --for pod/POD

    En el nodo: baja la imagen, monta volúmenes y arranca el contenedor (Pulling, Pulled, Created, Started).

  2. Pod Ready

    kubectl get pods -n classifier -l app.kubernetes.io/name=gateway -w

    La 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

  1. EndpointSlice

    kubectl get endpointslices -n classifier -l kubernetes.io/service-name=gateway

    La IP del pod queda como lista para recibir tráfico.

  2. 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.

Once eslabones entre tu archivo y el primer request. kubectl apply termina en el cuarto, cuando etcd ya guardó el objeto. El resto lo hacen controllers, el scheduler y el kubelet, y cada uno mira al API server.

Ejemplo real · Los cuatro eslabones que unen el YAML con el tráfico (gateway del C01)

YAMLreference/c01/k8s/gateway.yaml (extracto del Deployment y del Service; # … marca líneas omitidas)
  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: http
  • Las labels del template se copian a cada pod. app.kubernetes.io/name: gateway es lo que buscan el ReplicaSet, el Service y el reparto por zona.
  • El puerto del contenedor tiene nombre (name: http) y el Service apunta a ese nombre (targetPort: http), no al número 8080.
  • Mientras /readyz falle (Triton no responde o el modelo no cargó), el pod corre pero no recibe requests: su endpoint queda marcado como no listo.
  • El 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.

Laboratorio en tu Mac (opcional)7 pasos

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

  1. Paso 1
    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

    Qué deberías ver: deployment.apps/web created y service/web created, en menos de un segundo: solo se guardó el estado deseado.

  2. Paso 2
    kubectl get deploy,rs,pods -n lab-k8s -o wide

    Qué 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.

  3. Paso 3
    kubectl events -n lab-k8s

    Qué 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.

  4. Paso 4
    kubectl rollout status deployment/web -n lab-k8s --timeout=2m

    Qué deberías ver: deployment "web" successfully rolled out: los dos pods pasaron la readiness.

  5. Paso 5
    kubectl get endpointslices -n lab-k8s -l kubernetes.io/service-name=web

    Qué deberías ver: Una fila con 80 en PORTS y las dos IPs de los pods en ENDPOINTS.

  6. Paso 6
    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=web

    Qué 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.

  7. Paso 7
    kubectl delete namespace lab-k8s

    Qué 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í.

1

Qué es Kubernetes y cómo se trabaja

¿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 label

Los nodos (las máquinas del cluster), con esa label (etiqueta clave=valor) como columna. En el C01: -L workload,topology.kubernetes.io/zone.

Variables

VariableQué esEjemplo
regiónregión de AWS del clusterus-east-1
clusternombre del cluster de EKSel de tu equipo (el C01 no lo fija)
contextonombre del contexto en tu kubeconfigdocker-desktop en tu Mac; en EKS, el ARN del cluster (su identificador en AWS)
kindtipo de objetodeployment
nombrenombre del objetotriton
namespacecarpeta lógica del objeto (namespace)classifier
labellabel de nodo que quieres ver como columnaworkload (vale gpu-t4 en los nodos GPU del C01)
CEKSC_{\text{EKS}}lo que cobra AWS por el control plane, aparte de los nodos0.10 US$/h en soporte estándar (14 meses por versión)

Fórmulas

CEKS  =  0.10 US$h×730 h  ≈  73 US$ al mes por cluster(0.60 US$/h en soporte extendido)C_{\text{EKS}} \;=\; 0.10\ \tfrac{\text{US\$}}{\text{h}} \times 730\ \text{h} \;\approx\; 73\ \text{US\$ al mes por cluster} \qquad (0.60\ \text{US\$/h en soporte extendido})

Las piezas

PiezaQué haceEjemplo
clusterUn 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 planeEl cerebro: guarda lo que pediste y decide qué hacer. No corre tus pods.en EKS lo opera AWS
kube-apiserverExpone la API HTTP del cluster. Todo pasa por aquí, también kubectl.kubectl get pods = un GET a la API
etcdBase de datos clave-valor, consistente y replicada, con todos los objetos.en EKS: 3 instancias en 3 zonas
kube-schedulerAsigna un nodo a cada pod que todavía no tiene.busca un nodo con una T4 libre para Triton
kube-controller-managerCorre los controllers: lazos que acercan lo real a lo deseado.Deployment, ReplicaSet, EndpointSlice
kubeletEl agente de cada nodo: arranca los contenedores de sus pods y los vigila.reinicia un Triton que falla la liveness (¿sigue vivo?)
container runtimeBaja imágenes y corre contenedores en el nodo.containerd en los nodos de EKS
kube-proxy y CNIkube-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-onSoftware extra que corre como pods del cluster: DNS, métricas, drivers.CoreDNS, NVIDIA device plugin, KEDA
controller propio u operatorPrograma 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)
kubectlHerramienta 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 EKSKubernetes 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

  • kube-apiserver (al menos 2 instancias en EKS)
  • etcd (3 instancias, en 3 zonas)
  • kube-scheduler
  • kube-controller-manager
  • cloud-controller-manager

CPUnodo CPU · us-east-1a

label topology.kubernetes.io/zone=us-east-1a

  • gateway
  • gateway
  • CoreDNS
  • KEDA
  • aws-node (VPC CNI)
  • kube-proxy

kubelet · containerd

CPUnodo CPU · us-east-1b

label topology.kubernetes.io/zone=us-east-1b

  • gateway
  • AWS LB Controller
  • Prometheus
  • aws-node (VPC CNI)
  • kube-proxy

kubelet · containerd

GPUnodo GPU g4dn.xlarge · us-east-1a

label workload=gpu-t4

taint nvidia.com/gpu=present:NoSchedule

  • triton
  • NVIDIA device plugin
  • aws-node (VPC CNI)
  • kube-proxy

kubelet · containerd

GPUnodo GPU g4dn.xlarge · us-east-1b

label workload=gpu-t4

taint nvidia.com/gpu=present:NoSchedule

  • triton
  • NVIDIA device plugin
  • aws-node (VPC CNI)
  • kube-proxy

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.

El cluster del C01 en EKS, simplificado. Arriba, el control plane que opera AWS; abajo, los nodos de tu cuenta, cada uno con kubelet, containerd, kube-proxy y su plugin de red. Los nodos GPU llevan label y taint para que solo Triton caiga ahí.

Ejemplo real · Lo que el C01 necesita en el cluster antes del primer apply

textoreference/c01/README.md (Desplegar en EKS, prerequisitos)
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`).
  • Nada de esta lista es parte del control plane que opera AWS: son nodos y add-ons que tu equipo instala o configura (con Helm, Terraform o como add-ons de EKS).
  • El NVIDIA device plugin es un DaemonSet (un pod por nodo; aquí, en cada nodo GPU) que le anuncia las GPUs al kubelet. Sin él, ningún nodo ofrece nvidia.com/gpu y Triton queda Pending.
  • La label y el taint (un veto que aleja a los demás pods) del grupo de nodos GPU son la mitad del acuerdo para ubicar a Triton. La otra mitad está en 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.
  • AWS Load Balancer Controller, KEDA y el Prometheus Operator son controllers: vigilan sus objetos (Ingress, ScaledObject, ServiceMonitor) y actúan. Los kinds nuevos que usa el C01 (ScaledObject, ServiceMonitor) llegan con CRDs: definiciones de tipos que se suman a la API.
  • Una NetworkPolicy necesita un plugin de red que la aplique. Sin él, el objeto se acepta y no hace nada.
  • ≥ 1.35 se cumple de sobra: en octubre de 2026 la versión más nueva es la 1.37. La 1.34 deja de recibir parches el 27 de octubre de 2026; en EKS, su soporte estándar termina el 2 de diciembre de 2026.

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.

Laboratorio en tu Mac (opcional)6 pasos

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

  1. Paso 1
    kubectl config current-context
    kubectl get nodes -o wide

    Qué deberías ver: docker-desktop (o kind-kind si usas kind) y al menos un nodo Ready, con su versión de Kubernetes.

  2. Paso 2
    kubectl get pods -n kube-system

    Qué 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.

  3. Paso 3
    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=2m

    Qué deberías ver: namespace/lab-k8s created, deployment.apps/web created y deployment "web" successfully rolled out.

  4. Paso 4
    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=web

    Qué 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.

  5. Paso 5
    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.

  6. Paso 6
    kubectl delete namespace lab-k8s

    Qué 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í.

2

Anatomía de un manifest

¿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 kind

Lo 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: valor

En 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=grupo

Qué 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

VariableQué esEjemplo
grupogrupo de la API; el core no tieneapps (Deployment), networking.k8s.io (Ingress), keda.sh (ScaledObject)
versiónversión dentro del grupov1; v1alpha1 en el ScaledObject de KEDA
Kindtipo de objeto, en CamelCaseDeployment
nombrenombre único por kind y namespace, en minúsculasgateway
namespacecarpeta lógica del objetoclassifier
claveclave de una labelapp.kubernetes.io/name
valorvalor de esa labelgateway

Fórmulas

{pares del selector}  ⊆  {labels del pod}  ⟹  el objeto gobierna (o apunta a) ese pod\{\text{pares del selector}\} \;\subseteq\; \{\text{labels del pod}\} \;\Longrightarrow\; \text{el objeto gobierna (o apunta a) ese pod}

Las piezas

PiezaQué haceEjemplo
apiVersionGrupo y versión de la API que define el kind. El core no lleva grupo.apps/v1 · v1 · keda.sh/v1alpha1
kindQué tipo de objeto es, en CamelCase.Deployment, Service, ScaledObject
metadata.nameNombre único dentro de su kind y su namespace, en minúsculas.gateway (Deployment) y gateway (Service): kinds distintos
metadata.namespaceCarpeta lógica. Si falta, el objeto va al namespace de tu contexto (casi siempre default).classifier
metadata.labelsEtiquetas clave-valor para buscar y agrupar; es lo que miran los selectors (filtros por label).app.kubernetes.io/name: gateway
metadata.annotationsNotas para herramientas y controllers; no sirven para seleccionar.alb.ingress.kubernetes.io/scheme: internal
specEl estado deseado: lo escribes tú.réplicas, imagen, puertos, probes
statusEl estado real: lo escribe el cluster. No va en tu YAML.readyReplicas, conditions
spec.selectorQué pods gobierna el objeto. En un Deployment no se puede cambiar después.matchLabels: app.kubernetes.io/name: gateway
spec.templateEl 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)
ownerReferencesQuié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)

1234¿spec= status?

Real · status

status: 2 réplicas listas, porque vaciaron un nodo para mantenimiento (drain) y desalojaron uno de los pods

  1. 1ve la diferencia: 3 deseadas, 2 que existen
  2. 2el ReplicaSet crea un pod nuevo con el mismo template
  3. 3el scheduler le busca nodo y el kubelet lo arranca
  4. 4pasa la readiness: status vuelve a 3 de 3 y el lazo queda quieto

Quién corre el lazo: Deployment y ReplicaSet controllers (dentro de kube-controller-manager)

spec es la temperatura que fijas en el termostato y status la del cuarto: el controller los compara sin parar y actúa hasta que coinciden. Tú solo escribes spec.

Ejemplo real · El Deployment del gateway: tres lugares con labels, tres trabajos distintos

YAMLreference/c01/k8s/gateway.yaml (líneas 4–27)
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: classifier
  • apps/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).
  • No hay 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.

Laboratorio en tu Mac (opcional)6 pasos

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

  1. Paso 1
    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" }
    EOF

    Qué 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.

  2. Paso 2
    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" }
    EOF

    Qué deberías ver: The Deployment "web" is invalid: spec.template.metadata.labels: Invalid value: … `selector` does not match template `labels`.

  3. Paso 3
    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" }
    EOF

    Qué 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.

  4. Paso 4
    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.

  5. Paso 5
    kubectl create configmap prueba --from-literal=a=1
    kubectl get configmap prueba -n lab-k8s
    kubectl delete configmap prueba

    Qué 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.

  6. Paso 6
    kubectl delete namespace lab-k8s

    Qué 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í.

3

Pods, ReplicaSets y Deployments

¿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: faltantes

Quita 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 namespace

Cambia 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 namespace

Las revisiones guardadas: una por ReplicaSet, el actual y hasta revisionHistoryLimit viejos. Es a lo que puede volver rollout undo.

Variables

VariableQué esEjemplo
nréplicas deseadas16 en el vLLM del c10; sin campo en el C01 (las deciden el HPA y KEDA)
extramaxSurge: pods de más durante el rollout (número o %)25 % en el gateway, 1 en Triton
faltantesmaxUnavailable: pods de menos durante el rollout0 en el C01
namespacenamespace del Deploymentclassifier
selectorlabel para filtrarapp.kubernetes.io/name=triton
deploynombre del Deploymenttriton
contenedornombre del contenedor dentro del templatetriton
imagenimagen nueva, con tag123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-triton:2026-09-15-resnet50-fp16
NNréplicas deseadas en ese momento2 (mínimo de KEDA para Triton)
s, us,\ umaxSurge y maxUnavailable como fracción0.25 por defecto

Fórmulas

surge=⌈s⋅N⌉faltantes=⌊u⋅N⌋(s=u=0.25 por defecto)\text{surge} = \lceil s \cdot N \rceil \qquad \text{faltantes} = \lfloor u \cdot N \rfloor \qquad (s = u = 0.25\ \text{por defecto})
disponibles  ≥  N−faltantespods totales  ≤  N+surge\text{disponibles} \;\ge\; N - \text{faltantes} \qquad \text{pods totales} \;\le\; N + \text{surge}
GPUs maˊx. durante el rollout=(N+surge)×GPUs por podTriton: (2+1)×1=3\text{GPUs máx. durante el rollout} = (N + \text{surge}) \times \text{GPUs por pod} \qquad \text{Triton: } (2 + 1) \times 1 = 3

Las piezas

PiezaQué haceEjemplo
PodLa 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 podNacen y mueren con el pod y se hablan por localhost.un agente de logs junto a la app
sidecar nativoContenedor 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
restartPolicyQué 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)
ReplicaSetMantiene N pods iguales: si falta uno, lo crea; si sobra, lo borra.triton-5c7d9b8f6d (ilustrativo)
DeploymentManeja ReplicaSets, uno por versión del template (el molde del pod), para cambiar de versión de a poco.triton, gateway
pod-template-hashLabel que el Deployment pone en cada ReplicaSet y sus pods para no mezclar versiones.triton-5c7d9b8f6d-q2x7k
RollingUpdateEstrategia por defecto: sube pods nuevos y baja viejos de a poco.gateway y Triton del C01
RecreateBorra todos los pods viejos antes de crear los nuevos: hay corte.dos versiones que no pueden convivir
maxSurge / maxUnavailableCuántos pods de más y de menos se permiten durante el rollout.Triton: 1 y 0
revisionHistoryLimitReplicaSets viejos (en 0 réplicas) que se guardan para volver atrás. Default 10.5 en el C01
progressDeadlineSecondsPlazo para avanzar. Si vence: Progressing=False con ProgressDeadlineExceeded. No revierte solo.900 s en Triton
minReadySecondsSegundos que un pod nuevo debe seguir Ready para contar como disponible. Default 0.el C01 no lo usa
StatefulSet · DaemonSet · JobOtros 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)

Cada nivel maneja al de abajo: el Deployment, a sus ReplicaSets (uno por versión del template), y cada ReplicaSet, a sus pods. Durante un rollout conviven dos ReplicaSets.

Ejemplo real · El Deployment de Triton: rolling update cuando cada pod es una GPU

YAMLreference/c01/k8s/triton.yaml (líneas 12–32)
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: 60
  • Sin replicas: 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.
  • El comentario del selector lo dice: es inmutable y tiene que coincidir con las labels del template. El Deployment, además, agrega 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.

Laboratorio en tu Mac (opcional)7 pasos

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

  1. Paso 1
    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-k8s

    Qué deberías ver: Un solo ReplicaSet web-<hash> con DESIRED, CURRENT y READY en 3.

  2. Paso 2
    kubectl get rs -n lab-k8s -w

    Qué deberías ver: Déjalo corriendo en otra terminal: vas a ver cómo cambian los números en el paso siguiente.

  3. Paso 3
    kubectl set image deployment/web nginx=nginx:1.31-alpine -n lab-k8s
    kubectl rollout status deployment/web -n lab-k8s --timeout=2m

    Qué 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.

  4. Paso 4
    kubectl rollout history deployment/web -n lab-k8s

    Qué deberías ver: Las revisiones 1 y 2: una por ReplicaSet. El viejo sigue ahí, en 0 réplicas.

  5. Paso 5
    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-k8s

    Qué 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.

  6. Paso 6
    kubectl rollout undo deployment/web -n lab-k8s
    kubectl rollout status deployment/web -n lab-k8s --timeout=2m

    Qué 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ó.

  7. Paso 7
    kubectl delete namespace lab-k8s

    Qué 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í.

4

Services, DNS e Ingress

¿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_pod

Un 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_puerto

Un 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 namespace

Un túnel desde tu laptop hasta un pod detrás del Service, para probar sin exponer nada. No sirve para pruebas de carga.

Variables

VariableQué esEjemplo
servicionombre del Service (y de su DNS)triton
claveclave de la label que busca el selectorapp.kubernetes.io/name
valorvalor de esa labeltriton
nombre_puertonombre del puerto; obligatorio si el Service tiene varioshttp
puertopuerto del Service, al que llamas8000
puerto_podpuerto del contenedor, por número o por nombre; si falta, igual a porthttp (= containerPort 8000)
namespacenamespace del Serviceclassifier
claseingressClassName: qué controller atiende el Ingressalb
rutapath de la regla/v1/classify
tipopathType: Exact, Prefix o ImplementationSpecificExact
puerto_localpuerto en tu laptop8000
tkeep-alive appt_{\text{keep-alive app}}cuánto deja la app abierta una conexión sin uso75 s (--timeout-keep-alive de uvicorn)
tidle ALBt_{\text{idle ALB}}cuánto deja el balanceador de AWS (ALB) abierta una conexión sin uso; default 60 s60 s en el C01

Fórmulas

tkeep-alive app  >  tidle ALB75 s>60 s(si no, el ALB reusa conexiones que la app ya cerroˊ: 502)t_{\text{keep-alive}}^{\,\text{app}} \;>\; t_{\text{idle}}^{\,\text{ALB}} \qquad 75\ \text{s} > 60\ \text{s} \qquad (\text{si no, el ALB reusa conexiones que la app ya cerró: 502})

Las piezas

PiezaQué haceEjemplo
ServiceNombre 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
NodePortAbre el mismo puerto (30000–32767 por defecto) en todos los nodos.lo necesita un ALB en modo instance
LoadBalancerLe 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
selectorLas labels que busca el Service; solo admite igualdad (clave = valor).app.kubernetes.io/name: triton
port / targetPortport: al que llamas. targetPort: el del contenedor; si falta, vale lo mismo que port.8000 → http
EndpointSliceLista 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
trafficDistributionPreferencia de ruteo: PreferSameZone manda a pods de la misma zona si hay (estable desde 1.35).Service triton: no cruzar de zona
IngressReglas HTTP (host, path) hacia Services. Su API está congelada: no recibe cambios nuevos./v1/classify → gateway:http
IngressClass y controllerQuién implementa el Ingress; sin controller, el Ingress no hace nada.ingressClassName: alb
Gateway APIEl reemplazo que recomienda Kubernetes para Ingress: Gateway + HTTPRoute.para diseños nuevos
NetworkPolicyFirewall 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

  1. Cliente en la VPCHTTPS 443

    el backend del marketplace u otro equipo; resuelve el DNS del ALB interno

  2. 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

  1. Service gateway (ClusterIP)8080 → targetPort http

    no está en el camino del ALB: el controller lee sus endpoints para registrar targets

  2. EndpointSlice del gatewayIP del pod:8080

    label kubernetes.io/service-name=gateway; un pod que falla la readiness queda como no listo

  1. target-type ip: el ALB va directo a la IP de cada pod

  2. Pods gatewaycontainerPort 8080 (http)

    readiness /readyz; preStop de 15 s mientras el ALB lo desregistra

  3. Service triton (ClusterIP, PreferSameZone)8000 → http

    TRITON_URL = triton.classifier.svc.cluster.local:8000; kube-proxy elige un pod listo

  4. 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

El camino de un request del C01. El ALB va directo a las IPs de los pods del gateway (target-type ip). El gateway llama a Triton por el DNS de su Service, y kube-proxy elige un pod listo, de su zona si hay.

Ejemplo real · El Ingress del gateway: un ALB interno que va directo a los pods

YAMLreference/c01/k8s/gateway.yaml (el Ingress completo, sin 3 líneas de comentario)
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: http
  • ingressClassName: 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.
  • Solo /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.

Laboratorio en tu Mac (opcional)6 pasos

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

  1. Paso 1
    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=2m

    Qué deberías ver: deployment.apps/web created, service/web created y deployment "web" successfully rolled out.

  2. Paso 2
    kubectl run cliente -n lab-k8s --rm -it --restart=Never --image=busybox:1.38 -- wget -qO- http://web.lab-k8s.svc.cluster.local

    Qué 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.

  3. Paso 3
    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=web

    Qué 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.

  4. Paso 4
    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.

  5. Paso 5
    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://web

    Qué 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.

  6. Paso 6
    kubectl delete namespace lab-k8s

    Qué 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í.

5

Probes y apagado ordenado

¿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: fallos

Una 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: espera

Apagado: 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

VariableQué esEjemplo
ruta_listoruta que da 200 solo cuando el modelo está cargado/v2/health/ready (Triton), /readyz (gateway)
ruta_vivoruta barata que solo dice si el proceso responde/v2/health/live (Triton), /healthz (gateway)
puertonúmero o nombre del puerto del contenedorhttp (= 8000 en Triton)
periodocada cuántos segundos pregunta (periodSeconds; default 10, mínimo 1)5
timeoutsegundos que espera la respuesta (timeoutSeconds; default 1)2
fallosfallos seguidos para declarar la falla (failureThreshold; default 3)60 en la startup de Triton; 2 en su readiness
graciaplazo total del apagado, preStop incluido (default 30)60 (Triton), 45 (gateway)
contenedornombre del contenedor dentro del podtriton
esperasegundos que el preStop retrasa el SIGTERM10 (Triton), 15 (gateway)
tarranquet_{\text{arranque}}lo que tu app tarda en quedar lista; tiene que caber en f × p de la startupcarga del engine (el modelo compilado) + warmup (inferencias de calentamiento) ≤ 60 × 5 s = 300 s
fffailureThreshold de esa probe60 (startup), 2 (readiness), 3 (liveness) en Triton
ppperiodSeconds de esa probe5, 5 y 10 s
tdetectart_{\text{detectar}}cuánto tarda en notarse una fallareadiness: 2 × 5 ≈ 10 s; liveness: 3 × 10 ≈ 30 s
tgraciat_{\text{gracia}}terminationGracePeriodSeconds60 s en Triton
tpreStopt_{\text{preStop}}duración del preStop; corre dentro del plazo10 s
tdrenadot_{\text{drenado}}lo que el servidor tarda en terminar lo que tiene en cursohasta 30 s (--exit-timeout-secs=30)
margen\text{margen}colchón para el resto del apagado20 s (60 − 10 − 30)

Fórmulas

tarranque≤f×pt_{\text{arranque}} \le f \times p
tdetectar≈f×pt_{\text{detectar}} \approx f \times p
tgracia≥tpreStop+tdrenado+margent_{\text{gracia}} \ge t_{\text{preStop}} + t_{\text{drenado}} + \text{margen}

Las piezas

PiezaQué haceEjemplo
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 · execCó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 · timeoutSecondsCada cuánto pregunta y cuánto espera la respuesta. Defaults: 10 s y 1 s.Triton: 5 s y 2 s
failureThreshold · successThresholdFallos seguidos para declarar la falla (default 3) y éxitos para volver (default 1; liveness y startup: siempre 1).readiness de Triton: 2 fallos
initialDelaySecondsEspera antes de la primera probe (default 0). Con una startupProbe casi nunca hace falta.liveness del gateway: 5
Condición ReadyPod 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
readinessGatesCondiciones 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.preStopCorre antes del SIGTERM y dentro del plazo. Mantiene el proceso atendiendo mientras el pod sale de los endpoints.sleep 10 (Triton), sleep 15 (gateway)
terminationGracePeriodSecondsPlazo 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 KillingLo que deja cada falla en kubectl describe pod y kubectl events.Readiness probe failed: HTTP probe failed with statuscode: 503
HEALTHCHECK del DockerfileKubernetes 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

startupreadinessliveness12340102030405060 sstartupreadinessliveness12340102030405060 s
  1. 10 sstartup · arranca el contenedor; startup cada 5 s: falla (connection refused)
  2. 240 sstartup OK: cabía en 60 × 5 = 300 s
  3. 340 sreadiness OK: Ready, entra a los endpoints
  4. 440 sliveness empieza: /v2/health/live cada 10 s

Apagado · t = 0: kubectl delete, scale-down, rollout o drain

apagadopreStopdrenamargenplazo: terminationGracePeriodSeconds = 60 s56780102030405060 sapagadodrenamargenplazo de gracia: 60 s56780102030405060 s
  1. 50 sborrado: Terminating; el endpoint pasa a no listo; corre el preStop (sleep 10)
  2. 610 sSIGTERM: Triton espera hasta 30 s a que terminen las inferencias en curso
  3. 740 sTriton sale a más tardar aquí
  4. 860 svence el plazo: SIGKILL a lo que quede (exit 137)

la startup prueba y fallapasa: Ready y en los endpointsespera a la startupSIGKILL

Arriba, el arranque de un pod de Triton (t = 0 al arrancar el contenedor; en este ejemplo el engine tarda ≈ 40 s). Abajo, el apagado (t = 0 al borrar el pod), con el plazo de 60 s de C01.

Las tres probes de un vistazo

ProbePreguntaSi fallaEn 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.

Ejemplo real · Las probes y el preStop de Triton en C01

YAMLreference/c01/k8s/triton.yaml (líneas 91–119, contenedor triton; sangría reducida)
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"]
  • La 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.
  • Startup y readiness preguntan lo mismo, /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.
  • La readiness saca al pod del Service en ≈ 2 × 5 s = 10 s. timeoutSeconds: 2 evita el default de 1 s, que bajo carga da falsos negativos.
  • La liveness pregunta /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.
  • El preStop de 10 s corre dentro de 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.

Laboratorio en tu Mac (opcional)5 pasos

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).

  1. Paso 1
    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 -w

    Qué 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.

  2. Paso 2
    kubectl exec -n lab-k8s probes -- rm /tmp/listo
    kubectl get pod probes -n lab-k8s -w

    Qué 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.

  3. Paso 3
    kubectl exec -n lab-k8s probes -- rm /tmp/vivo
    kubectl get pod probes -n lab-k8s -w

    Qué 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.

  4. Paso 4
    kubectl events -n lab-k8s --for pod/probes

    Qué 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.

  5. Paso 5
    time kubectl delete pod probes -n lab-k8s

    Qué 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).

6

Requests, limits y QoS

¿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: mem

El 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: gpus

La 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: mem

Clase 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

VariableQué esEjemplo
cpucores: "1", "2" (las comillas son opcionales), o milicores con m ("500m" = medio core)"1" (gateway), "2" (Triton)
membytes con sufijo: Mi y Gi son base 2; M y G, base 101Gi (gateway), 6Gi (Triton)
gpusGPUs 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 completa1 (una T4)
nodonombre del nodo, de kubectl get nodesdocker-desktop en tu Mac
podnombre del pod, de kubectl get podsoom (en el lab)
requestnuevo\text{request}_{\text{nuevo}}request del pod que el scheduler quiere colocar; se compara por recurso: CPU, memoria y GPUTriton: 2 CPU, 6Gi y 1 GPU
Allocatable\text{Allocatable}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 desalojouna g4dn.xlarge tiene 4 vCPU, 16 GiB y 1 T4 de capacidad; Allocatable es algo menor en CPU y memoria
tagotat_{\text{agota}}en qué momento de cada ventana de 100 ms se acaba la cuota de CPU25 ms
limit\text{limit}limits.cpu en cores1
nhilosn_{\text{hilos}}hilos usando CPU a la vez4 decodes en paralelo
uuutilización que mide el HPA (sección 9)0.6 cores / 1 core = 60 %

Fórmulas

∑pods del nodorequest+requestnuevo≤Allocatable\sum_{\text{pods del nodo}} \text{request} + \text{request}_{\text{nuevo}} \le \text{Allocatable}
tagota=limit×100 msnhilost_{\text{agota}} = \frac{\text{limit} \times 100\,\text{ms}}{n_{\text{hilos}}}
u=uso de CPUrequest de CPUu = \frac{\text{uso de CPU}}{\text{request de CPU}}

Las piezas

PiezaQué haceEjemplo
requests.cpuReserva 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.cpuCuota por ventana de 100 ms. Al agotarla, el kernel congela el contenedor hasta la siguiente (throttling). No lo mata.C01 no lo pone
requests.memoryReserva para elegir nodo. Si al nodo le falta memoria, salen primero los pods que usan más que su request.6Gi (Triton)
limits.memoryTecho duro. Si lo pasa, el kernel mata el proceso (OOMKilled, exit 137) y el kubelet reinicia el contenedor.= request en C01
UnidadesCPU 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 requestsKubernetes copia el limit como request, salvo que un LimitRange (defaults por namespace) ponga otro.limits.memory: 1Gi → request 1Gi
nvidia.com/gpuRecurso extendido que anuncia el device plugin de NVIDIA. Enteros, sin sobreasignar, request = limit.una T4 entera por pod de Triton
Allocatable · Allocated resourcesLo 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-storageDisco 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: MemoryCarpeta en RAM (tmpfs). Lo que escribes ahí cuenta contra el limit de memoria del contenedor./dev/shm de Triton, sizeLimit 1Gi
LimitRange · ResourceQuotaDefaults 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 · EvictedOOMKilled: 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

04 CPU del nodo

Si se pasa: sin limit: usa la CPU libre del nodo; nada lo congela

triton · memory

request 6 GiB · limit 6 GiB

016 GiB del nodo

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

04 CPU del nodo

Si se pasa: sin limit: sin throttling; el HPA mide contra este 1 core

gateway · memory

request 1 GiB · limit 1 GiB

08 GiB del nodo

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

Requests y limits de los dos contenedores de C01 contra la capacidad de sus nodos, y qué pasa al pasarse. La última fila es un contraejemplo: lo que pasaría con un limit de CPU.

Clases de QoS (solo CPU y memoria; la GPU no cuenta)

ClaseReglaSi al nodo le falta memoriaEjemplo
GuaranteedTodos los contenedores con request = limit de CPU y de memoriaAl 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
BurstableAl menos un request o limit de CPU o memoria, sin llegar a GuaranteedSi usa más de lo que pidió, compite con los BestEffort (decide la prioridad y cuánto se pasa); si no, sale al finalgateway y Triton de C01 (CPU sin limit)
BestEffortNingún request ni limit de CPU ni de memoriaEntre 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.

Ejemplo real · Requests y limits del gateway y de Triton

YAMLreference/c01/k8s/gateway.yaml (líneas 67–75) y reference/c01/k8s/triton.yaml (líneas 76–84); sangría reducida
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 latencia
  • Gateway: request de 1 CPU y ningún limit de CPU, a propósito. Con limit, una ráfaga de decodes agota la cuota a mitad de la ventana de 100 ms y el p99 (la latencia del 1 % de requests más lentos) sube aunque el promedio se vea bajo. El request ya le garantiza su parte.
  • Memoria del gateway: 1Gi de request y de limit, medido y no adivinado: ≈ 80 MB de Python + 2 decodes de ≈ 320 MB. La variable PREPROCESS_CONCURRENCY=2 del Deployment acota la concurrencia para que el pico quepa.
  • Triton pide 2 CPU, 6Gi y una GPU, con 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.
  • El /dev/shm de Triton es un emptyDir en RAM de hasta 1Gi (líneas 120–127): cuenta dentro de los 6Gi del limit.
  • Los dos pods son Burstable (CPU sin limit). Como su memoria nunca pasa del request, si al nodo le falta memoria salen después que cualquier pod que se pasó del suyo.

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.

Laboratorio en tu Mac (opcional)4 pasos

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.

  1. Paso 1
    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 -w

    Qué 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.

  2. Paso 2
    kubectl describe pod oom -n lab-k8s
    kubectl get pod oom -n lab-k8s -o jsonpath='{.status.qosClass}'; echo

    Qué 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).

  3. Paso 3
    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}'; echo

    Qué 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.

  4. Paso 4
    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.

7

ConfigMaps, Secrets, volúmenes y ServiceAccounts

¿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: CLAVE

Como 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ño

Como 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

VariableQué esEjemplo
cmnombre del ConfigMapapp-config (seg-worker-config en C04)
secretonombre del Secretapp-secret
CLAVEclave dentro del ConfigMap o del Secret; con envFrom, también es el nombre de la variableINFER_TIMEOUT_S
valorsu valor; en un Secret, la API lo muestra en base64 (codificado, no cifrado)0.15
VARnombre de la variable de entorno en el contenedorAPI_KEY
deployDeployment cuyos pods hay que recreargateway
volnombre del volumen; une volumes con volumeMountsconfig
rutacarpeta donde aparecen los archivos/config (C04)
tmpnombre del volumen temporaltmp
tamañotope del emptyDir; pasarlo desaloja el pod64Mi (gateway), 20Gi (C04)
saServiceAccount: la identidad del podseg-worker (C04)
nsnamespace de la ServiceAccountc04
cuentaID de la cuenta de AWS (12 dígitos)123456789012
rolrol de IAM con solo los permisos que ese pod necesitac04-seg-worker
clusternombre del cluster de EKSprod-ml (ejemplo)

Las piezas

PiezaQué haceEjemplo
ConfigMapPares clave–valor no secretos, hasta 1 MiB. Llegan como variables de entorno o como archivos.MODEL_NAME, INFER_TIMEOUT_S
SecretComo un ConfigMap, para datos sensibles. data va en base64 (codificado, no cifrado); stringData acepta texto plano. Tipo default: Opaque.kubectl create secret generic
envFrom · valueFromTodas 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 secretCada 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 restartRecrea 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 · optionalimmutable: true impide cambios (solo borrar y recrear). optional: true deja arrancar al pod aunque falte el objeto.configMapRef con optional: true
Cifrado en reposoCifra 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
emptyDirCarpeta 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
ServiceAccountIdentidad 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: falseNo 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 IdentityAsociació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)
CreateContainerConfigErrorLo 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)
PiezaPara quéCómo llega al contenedor¿Cambia sin recrear el pod?Dura hasta…
ConfigMapconfig no secreta: URLs, timeouts, nombre del modelovariables de entorno o archivosenv: no (rollout restart); archivo: sí, en hasta ≈ 90 s; con subPath: noque lo borres
Secretcredenciales de terceros y contraseñasvariables de entorno o archivos en tmpfs (RAM)igual que el ConfigMapque lo borres
emptyDirscratch, /tmp, /dev/shmvolumen vacío al crear el podno aplica: lo escribe el podque el pod deje el nodo (sobrevive a reinicios del contenedor)
PVCdatos que tienen que sobrevivir al podvolumen de un disco (EBS: un nodo a la vez, una zona)no aplicaque borres el PVC (con reclaimPolicy Delete, el default, se borra el disco)
ServiceAccountidentidad ante la API de Kubernetes y, con IRSA o Pod Identity, ante AWStoken montado y variables AWS_*el token se renueva solo; un rol nuevo exige recrear los podsque la borres
Cinco formas de darle algo a un pod: para qué sirve cada una, cómo llega y cuánto dura.

Ejemplo real · ServiceAccount con IRSA, ConfigMap montado y dos emptyDir (worker de C04)

YAMLreference/c04/k8s/seg-worker.yaml (líneas 15–20, 31 y 173–193; las líneas # … resumen lo omitido; sangría reducida en la parte del Deployment)
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-config
  • La ServiceAccount seg-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.
  • Con EKS Pod Identity sería la misma ServiceAccount sin anotación: la asociación se crea en EKS (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.
  • El ConfigMap 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.
  • Dos emptyDir: /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".
  • C01 no usa ConfigMaps: el gateway recibe su config como 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.

Laboratorio en tu Mac (opcional)5 pasos

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).

  1. Paso 1
    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; echo

    Qué 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.

  2. Paso 2
    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 -f

    Qué 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.

  3. Paso 3
    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 -f

    Qué 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.

  4. Paso 4
    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=2

    Qué 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.

  5. Paso 5
    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-config

    Qué 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.

8

Dónde cae cada pod: nodeSelector, affinity, taints/tolerations, GPUs y nodos que se crean solos

¿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: NoSchedule

El 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/zone

Etiquetar, 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: app

Reparte 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

VariableQué esEjemplo
labelclave de la label del nodoworkload
valorvalor de la labelgpu-t4
otro_valorotro valor aceptado: In acepta una listaus-east-1b, si la clave es topology.kubernetes.io/zone y el primer valor us-east-1a
clave_taintclave del taint; para GPUs, el nombre del recursonvidia.com/gpu
valor_taintvalor del taintpresent
nodonombre del nodo, de kubectl get nodesdocker-desktop en tu Mac
sesgomaxSkew: diferencia máxima de réplicas entre zonas1
si_no_cabeDoNotSchedule (default: queda Pending) o ScheduleAnyway (lo pone igual)DoNotSchedule (Triton), ScheduleAnyway (gateway)
applabel de las réplicas que se cuentantriton
ndestinon_{\text{destino}}réplicas que ya hay en la zona candidata1 en us-east-1a
nzn_{z}réplicas en cada zona elegible; cuenta la menor0 en us-east-1b
maxSkew\text{maxSkew}desbalance permitido1: la 2.ª réplica de Triton solo puede ir a 1b
GPUs del nodo\text{GPUs del nodo}nvidia.com/gpu en Allocatable (lo que el nodo ofrece a los pods)1 en una g4dn.xlarge; 4 en una g4dn.12xlarge
GPUs por pod\text{GPUs por pod}nvidia.com/gpu en limits1 (Triton)

Fórmulas

ndestino+1−min⁡z nz≤maxSkewn_{\text{destino}} + 1 - \min_{z}\, n_z \le \text{maxSkew}
pods por nodo=⌊GPUs del nodoGPUs por pod⌋\text{pods por nodo} = \left\lfloor \frac{\text{GPUs del nodo}}{\text{GPUs por pod}} \right\rfloor

Las piezas

PiezaQué haceEjemplo
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 nodoEtiquetas clave=valor. Kubernetes y la nube ponen algunas; tú agregas las tuyas.topology.kubernetes.io/zone, node.kubernetes.io/instance-type, workload=gpu-t4
nodeSelectorSolo nodos con todas estas labels. Lo más simple para fijar un tipo de nodo.workload: gpu-t4
nodeAffinitynodeSelector con operadores: required (obligatorio) o preferred (con peso). IgnoredDuringExecution: si la label cambia después, el pod sigue donde está.operator: In
podAffinity · podAntiAffinityReglas respecto de otros pods: juntos o separados por nodo o por zona (topologyKey).no dos réplicas en el mismo nodo
topologySpreadConstraintsReparte réplicas entre zonas o nodos con un desbalance máximo (maxSkew).Triton: una por zona
TaintMarca del nodo que rechaza pods sin toleration. NoSchedule; PreferNoSchedule, suave; NoExecute, además saca a los que corren.nvidia.com/gpu=present:NoSchedule
TolerationPermiso del pod para entrar a un nodo con ese taint. No lo obliga a ir ahí.key: nvidia.com/gpu, operator: Equal, value: present
ExtendedResourceTolerationAdmission 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áticosnot-ready y unreachable (NoExecute): por defecto el pod los tolera 300 s y después se desaloja.node.kubernetes.io/unreachable
PriorityClass y preemptionPrioridad 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)
FailedSchedulingEvento con el motivo de cada nodo descartado. El pod queda Pending, con PodScheduled=False y reason Unschedulable.0/6 nodes are available: …
Karpenter · Cluster AutoscalerVen 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
nodeNameAsigna el nodo a mano y se salta el scheduler, incluidos los taints NoSchedule. Casi nunca lo quieres.—

pods ↓ · nodos →

cpu-1a

c6i.xlarge · us-east-1a

cpu-1b

c6i.xlarge · us-east-1b

gpu-1a

g4dn.xlarge · us-east-1a

label workload=gpu-t4

taint nvidia.com/gpu=present:NoSchedule

GPU ocupada

ya corre la 1.ª réplica de Triton

gpu-1b

g4dn.xlarge · us-east-1b

label workload=gpu-t4

taint nvidia.com/gpu=present:NoSchedule

1 GPU libre

gateway

nodeSelector ninguno

toleration ninguna

pide cpu 1, memory 1Gi

✓cabe
✓cabe
✗untolerated taint
✗untolerated taint

triton (2.ª réplica)

nodeSelector workload=gpu-t4

toleration nvidia.com/gpu=present:NoSchedule

pide cpu 2, memory 6Gi, nvidia.com/gpu 1

spread maxSkew 1 por zona

✗no tiene la label
✗no tiene la label
✗Insufficient cpu y nvidia.com/gpu
✓cabe

triton (3.ª réplica)

nodeSelector workload=gpu-t4

toleration nvidia.com/gpu=present:NoSchedule

pide cpu 2, memory 6Gi, nvidia.com/gpu 1

spread maxSkew 1 por zona

✗no tiene la label
✗no tiene la label
✗Insufficient cpu y nvidia.com/gpu
✗Insufficient cpu y nvidia.com/gpu (ya está la 2.ª)

✓ 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).

Cuatro nodos de C01 en dos zonas y tres pods. ✓ = el scheduler puede ponerlo ahí; ✗ = el motivo que verías en FailedScheduling. La 3.ª réplica de Triton no cabe en ningún nodo: es la que despierta a Karpenter.

Pending: qué dice el FailedScheduling y qué haces

Mensaje (parte central)Qué significaQué haces
Insufficient nvidia.com/gpuNingún nodo tiene una GPU libre, o ninguno la anunciaEspera 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/selectorNingún nodo tiene la label que pide el podCorrige 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 constraintsPonerlo ahí rompería el maxSkewCapacidad en la otra zona, o ScheduleAnyway
Insufficient cpu · Insufficient memoryLos requests ya llenan los nodos, aunque el uso real sea bajoRequests 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.

Ejemplo real · Cómo C01 lleva a Triton a sus nodos GPU y lo reparte entre zonas

YAMLreference/c01/k8s/triton.yaml (líneas 33–54, spec del template; sangría reducida)
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: triton
  • nodeSelector: 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.
  • La toleration coincide con el taint 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.
  • En EKS está activo el admission controller (un paso del API server que revisa o modifica cada objeto al crearlo o cambiarlo) 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.
  • El spread con 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í.
  • Los comentarios marcan la letra chica: con 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.

Laboratorio en tu Mac (opcional)4 pasos

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).

  1. Paso 1
    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-k8s

    Qué 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.

  2. Paso 2
    kubectl events -n lab-k8s --for pod/quiere-gpu
    kubectl events -n lab-k8s --for pod/quiere-t4

    Qué 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).

  3. Paso 3
    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 wide

    Qué 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.

  4. Paso 4
    kubectl label nodes --all lab-k8s/workload-
    kubectl get pod quiere-t4 -n lab-k8s -o wide

    Qué 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.

9

Autoscaling: HPA y KEDA

¿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: porcentaje

Un 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: ventana

La 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

VariableQué esEjemplo
nombrenombre del HPA o del ScaledObjectgateway · triton
deploymentDeployment que escalagateway · triton
minpiso de réplicas3 (gateway), 2 (Triton)
maxtecho de réplicas12 (gateway), 4 (Triton)
porcentajeobjetivo: % del request de CPU, promedio de los pods60
pods_por_periodopods que puede agregar como mucho en cada período1 (Triton)
periodolargo del período de la política, en s (máximo 1800)60
ventanaventana de estabilización al bajar, en s (default 300)300 (gateway), 600 (Triton)
url_prometheusdirección del Prometheus (la base de métricas) del clusterhttp://prometheus-operated.monitoring.svc.cluster.local:9090
queryconsulta en PromQL (el lenguaje de Prometheus) que devuelve un solo númeroespera media en la cola de Triton, en ms
umbralobjetivo de la métrica; en KEDA va como texto10
nsnamespaceclassifier
deseadas\text{deseadas}réplicas que pide el HPA; después las recortan min, max y behavior8 (gateway); 5 → 4 (Triton)
actuales\text{actuales}réplicas actuales; con métricas Value de KEDA (External), las Ready5 (gateway); 2 (Triton)
meˊtrica\text{métrica}valor actual: % de CPU promedio o lo que devuelve la query90 %; 25 ms
objetivo\text{objetivo}el target del HPA o el threshold de KEDA60 %; 10 ms
meˊtrica total\text{métrica total}suma de la métrica en todos los pods (mensajes en cola, req/s)120 mensajes en una cola de SQS (M06)
objetivo por pod\text{objetivo por pod}cuánto de esa métrica le toca a cada réplica15 → ⌈120 / 15⌉ = 8 workers
tservirt_{\text{servir}}de que la carga sube a que el pod nuevo atiende≈ 5–13 min en C01 (M06)
tdetectart_{\text{detectar}}Prometheus lee la métrica (scrape) + ventana de la query + ciclo del HPA≈ 0.5–1.5 min
tnodot_{\text{nodo}}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
timagent_{\text{imagen}}descargar la imagen de Triton (≈ 9 GB)≈ 2–5 min
tmodelot_{\text{modelo}}arrancar Triton, cargar el engine y pasar la readiness≈ 15–75 s

Fórmulas

deseadas=⌈actuales×meˊtricaobjetivo⌉\text{deseadas} = \left\lceil \text{actuales} \times \frac{\text{métrica}}{\text{objetivo}} \right\rceil
∣meˊtricaobjetivo−1∣≤0.1  ⇒  no cambia\left| \frac{\text{métrica}}{\text{objetivo}} - 1 \right| \le 0.1 \;\Rightarrow\; \text{no cambia}
deseadas=⌈meˊtrica totalobjetivo por pod⌉(AverageValue)\text{deseadas} = \left\lceil \frac{\text{métrica total}}{\text{objetivo por pod}} \right\rceil \quad (\text{AverageValue})
tservir≈tdetectar+tnodo+timagen+tmodelot_{\text{servir}} \approx t_{\text{detectar}} + t_{\text{nodo}} + t_{\text{imagen}} + t_{\text{modelo}}

Las piezas

PiezaQué haceEjemplo
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 · maxReplicasPiso y techo. El HPA los impone aunque no consiga la métrica.3–12 (gateway), 2–4 (Triton)
Resource + UtilizationUso de CPU como % del request, promedio de los pods. Sin request, el HPA no puede medir ni actuar.averageUtilization: 60
Pods · Object · ExternalMétricas que no son CPU ni memoria: por pod, de un objeto del cluster o de afuera (colas, queries).KEDA publica External
metrics-serverPublica 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: policiesCuá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
stabilizationWindowSecondsAl bajar, usa la recomendación más alta de la ventana (default 300 s): baja despacio, sin oscilar. Al subir: 0.600 en Triton
ToleranciaSi 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 · AverageValueValue: proporcional a las réplicas Ready (tiempos). AverageValue: total ÷ objetivo por pod (colas, req/s).Triton: Value, umbral 10 ms
minReplicaCount · cooldownPeriod · pollingIntervalDefaults 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
ignoreNullValuesCon "false", una query vacía es un error visible; con el default (true), un 0 silencioso."false" en C01
Karpenter · Cluster AutoscalerVen 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 autoscalerDura 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

2 réplicas Ready · métrica 25 contra objetivo 10

El HPA pide

⌈2 × 25 / 10⌉ = 5

Recorta al máximo

máximo 4: queda en 4
  1. ⌈2 × 25 / 10⌉ = 5
  2. recortado al máximo: 4
  3. behavior: +1 pod cada 60 s → 3, después 4

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

  1. pods

    la cola pasa de ≈ 2 a 25 ms 0 min

  2. pods

    keda-hpa-triton: 2 → 3; el pod 3 queda Pending (Insufficient nvidia.com/gpu) 0.5–1.5 min

  3. nodos

    Karpenter ve el pod Unschedulable y lanza una g4dn.xlarge 0.5–1.5 min

  4. pods

    si la cola sigue alta: 3 → 4 (otro Pending, otro nodo) 1.5–2.5 min

  5. nodos

    nodo Ready; el device plugin anuncia nvidia.com/gpu: 1; el scheduler pone el pod 2.5–6.5 min

  6. pods

    imagen de Triton descargada 4.5–11.5 min

  7. pods

    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).

KEDA y el HPA deciden en segundos; el nodo GPU, la imagen y el modelo tardan minutos. Tiempos de M06 para C01, sin nodo libre, con el inicio y el fin de cada rango.

Quién decide qué, y cuánto tarda en C01

PiezaDecideCon quéTiempo en C01
KEDA (operador)Crear el HPA; pasar de 0 a 1 y de 1 a 0 réplicasLos triggers del ScaledObjectNo aplica: el mínimo es 2
HPA keda-hpa-tritonCuántas réplicas entre el mínimo y el máximo (2–4)⌈Ready × métrica / umbral⌉ y el behaviorCada 15 s; +1 pod por minuto
SchedulerEn qué nodo cae cada pod nuevoRequests, labels, taints y el reparto entre zonasSegundos; si no cabe, Pending
Karpenter (o Cluster Autoscaler)Cuántos nodosPods Unschedulable y las reglas del NodePool≈ 2–5 min por nodo GPU
kubelet, device plugin y TritonCuándo sirve el pod nuevoPull 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.

Ejemplo real · El ScaledObject de Triton: KEDA alimenta un HPA con el tiempo en cola

YAMLreference/c01/k8s/keda-triton.yaml (líneas 15–16 y 36–60; las líneas # … resumen lo omitido)
# … 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"
  • Con este ScaledObject, KEDA crea el HPA 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.
  • El behavior omitido (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.
  • El gateway usa un HPA normal (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.
  • Los dos lazos se encadenan así: el HPA pide la 3.ª réplica, no hay T4 libre y el pod queda Pending (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.

Laboratorio en tu Mac (opcional)4 pasos

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.

  1. Paso 1
    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 -w

    Qué 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.

  2. Paso 2
    kubectl get hpa web -n lab-k8s
    kubectl describe hpa web -n lab-k8s

    Qué 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.

  3. Paso 3
    kubectl scale deploy web -n lab-k8s --replicas=6
    kubectl get deploy web -n lab-k8s -w

    Qué 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.

  4. Paso 4
    kubectl apply -n lab-k8s -f web.yaml
    kubectl get deploy web -n lab-k8s -w

    Qué 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.

10

Rollouts, rollback, PodDisruptionBudget y canary

¿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

VariableQué esEjemplo
nombrenombre del Deployment o del PDBtriton, gateway
nsnamespace: la carpeta lógica donde vive el objetoclassifier
plazocuánto espera el comando antes de rendirse (no cambia el Deployment)25m (Triton: nodo GPU, imagen y engine, un pod a la vez)
nnúmero de revisión, sacado de rollout history3
disponiblespods que deben quedar listos (número o %)1
applabel que comparten los pods que protegestriton
pesoporcentaje del tráfico para la versión nueva5, después 25 (el canary de M11)
esperacuánto se queda en ese porcentaje4h
rrréplicas deseadas del Deployment2 en Triton
maxSurge\text{maxSurge}pods de más durante el rollout; si es %, se redondea hacia arriba1 en Triton (default 25 %)
maxUnavailable\text{maxUnavailable}pods de menos durante el rollout; si es %, se redondea hacia abajo0 en Triton (default 25 %)
sanos\text{sanos}pods del PDB con la condición Ready en True2
minAvailable\text{minAvailable}el mínimo que pide el PDB1 → 1 evicción permitida

Fórmulas

podsmaˊx=r+⌈r⋅maxSurge⌉listosmıˊn=r−⌊r⋅maxUnavailable⌋\text{pods}_{\text{máx}} = r + \lceil r \cdot \text{maxSurge} \rceil \qquad \text{listos}_{\text{mín}} = r - \lfloor r \cdot \text{maxUnavailable} \rfloor
evicciones permitidas=max⁡(0, sanos−minAvailable)\text{evicciones permitidas} = \max\left(0,\ \text{sanos} - \text{minAvailable}\right)

Las piezas

PiezaQué haceEjemplo
kubectl rollout statusEspera el fin del rollout. Sale con error si vence el plazo del Deployment o --timeout.deploy/triton --timeout=25m
kubectl rollout historyLista las revisiones guardadas; con --revision=N muestra el template de una.REVISION · CHANGE-CAUSE
kubectl rollout undoVuelve 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 restartRecrea todos los pods con la misma imagen, de a poco (rolling update).releer un Secret que se lee al arrancar
kubectl rollout pause · resumeCongela el Deployment: los cambios no arrancan rollout hasta resume. En pausa no hay undo.juntar varios cambios en uno
kubernetes.io/change-causeAnotación del Deployment que se copia a la revisión: llena la columna CHANGE-CAUSE."classifier 2026-09-15-resnet50-fp16"
revisionHistoryLimitCuántos ReplicaSets viejos guarda para volver atrás. Default 10; con 0 no hay undo.5 en C01
progressDeadlineSecondsPlazo para avanzar. Si vence: Progressing=False con ProgressDeadlineExceeded. Default 600 s. No revierte solo.900 en Triton
PodDisruptionBudgetMí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: AlwaysAllowDeja 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 NODOMarca 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
AnalysisTemplateConsulta métricas (Prometheus) durante el canary y lo aborta si un criterio falla.5xx > 0.5 % aborta
kubectl argo rollouts promote · abortpromote 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

máx.3 podsmín. 2listos1v1v1inicio: 2 podsviejos sirven2v1v1v2+1 pod nuevo:espera GPU,imagen yengine3v1v1v2el nuevo pasala readiness4v1v2sale un viejo5v1v2v2+1 pod nuevo6v1v2v2el segundonuevo quedalisto7v2v2fin: 2 podsnuevosmín. 2 listosmáx. 31v1v1inicio: 2 pods viejossirven2v1v1v2+1 pod nuevo: espera GPU,imagen y engine3v1v1v2el nuevo pasa la readiness4v1v2sale un viejo5v1v2v2+1 pod nuevo6v1v2v2el segundo nuevo quedalisto7v2v2fin: 2 pods nuevos

v1versión vieja (sirve)v2nueva, todavía no Readyv2nueva y Readymínimo de listosmáximo de pods

Triton en C01: 2 réplicas, maxSurge 1, maxUnavailable 0. Nunca hay menos de 2 pods listos ni más de 3 en total. Si el pod nuevo no llega a Ready, todo se queda en el paso 2: los viejos siguen sirviendo, a los 900 s aparece ProgressDeadlineExceeded y volver es kubectl rollout undo.

Ejemplo real · C01: cómo cambia de versión Triton y qué lo protege en un drain

YAMLreference/c01/k8s/triton.yaml (extracto: estrategia del Deployment y PodDisruptionBudget; # … marca líneas omitidas)
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: triton
  • maxSurge: 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.
  • El PDB frena un 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).
  • Con 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.
  • Con 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.

Laboratorio en tu Mac (opcional)9 pasos

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.

  1. Paso 1
    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-k8s

    Qué 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.

  2. Paso 2
    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-k8s

    Qué 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.

  3. Paso 3
    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.

  4. Paso 4
    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.

  5. Paso 5
    kubectl rollout history deploy/web -n lab-k8s

    Qué deberías ver: Tres revisiones: 1 con CHANGE-CAUSE <none>, 2 con nginx 1.30 y 3 con tag roto.

  6. Paso 6
    kubectl rollout undo deploy/web -n lab-k8s && kubectl rollout status deploy/web -n lab-k8s && kubectl rollout history deploy/web -n lab-k8s

    Qué 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ó.

  7. Paso 7
    kubectl create pdb web -n lab-k8s --selector=app=web --min-available=2 && sleep 3 && kubectl get pdb web -n lab-k8s

    Qué 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.

  8. Paso 8
    kubectl patch pdb web -n lab-k8s --type=merge -p '{"spec":{"minAvailable":1}}' && sleep 3 && kubectl get pdb web -n lab-k8s

    Qué 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.

  9. Paso 9
    kubectl delete namespace lab-k8s

    Qué 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í.

11

kubectl: mirar, entrar, depurar

¿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

VariableQué esEjemplo
verbolo que hacesget, describe, delete · en can-i: list, create
tipoclase de recurso, con nombre largo o cortodeployment (deploy), service (svc), pods (po)
nombrenombre del objetotriton
nsnamespaceclassifier
formatocómo imprime la respuestawide, yaml, json, name, jsonpath='…'
contextoentrada del kubeconfig: cluster + usuario + namespacedocker-desktop · en EKS, por defecto el ARN del cluster (su identificador en AWS)
rutacamino al campo dentro del objeto; empieza con punto.status.readyReplicas
campocamino al campo para explain, sin punto inicialspec.progressDeadlineSeconds
archivomanifest localk8s/triton.yaml
podnombre del pod, o deploy/NOMBRE para que elija unodeploy/triton
contenedorcontenedor dentro del podtriton
comandolo que corres adentronvidia-smi, sh
localpuerto en tu máquina8000
puertopuerto del Service (o del pod)8000

Las piezas

PiezaQué haceEjemplo
kubectl config get-contextsLista tus contextos; el asterisco marca el activo.docker-desktop · arn:aws:eks:…:cluster/NOMBRE
-n NS · -AUn namespace, o todos a la vez (--all-namespaces).-n classifier · -n keda
-o wideAgrega columnas: en pods, la IP y el nodo.en qué nodo GPU cayó cada Triton
-o yamlEl 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 · -LFiltra por label, muestra todas, o pone una label como columna.-l app.kubernetes.io/name=triton
-wSe queda mirando y muestra cada cambio.kubectl get pods -w
kubectl explainDocumentación de un campo, servida por tu cluster y su versión.explain deploy.spec.progressDeadlineSeconds
kubectl diff -fQué cambiaría si aplicas, calculado por el servidor. Exit 0 sin cambios, 1 con cambios.diff -f k8s/triton.yaml
kubectl api-resourcesTodos los tipos: nombre corto, grupo y si viven en un namespace.pdb, hpa, scaledobjects
kubectl exec -it … -- shAbre una terminal, o corre un comando, dentro de un contenedor que ya corre.-- nvidia-smi
kubectl port-forwardTúnel de tu máquina a un solo pod, sin exponer el servicio.svc/triton 8000:8000
kubectl debug -it --image --targetAgrega un contenedor temporal con herramientas a un pod que corre.imágenes sin shell
kubectl auth can-iPregunta si puedes hacer algo. Responde yes o no.create pods --subresource=exec
Quieres saberComandoQué mirar
¿En qué cluster estoy?kubectl config current-contextEl 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 wideSTATUS, READY, RESTARTS y NODE
¿Qué tiene de verdad el cluster?kubectl get deploy triton -n classifier -o yamlLa spec con defaults y el status
¿Qué significa este campo?kubectl explain deploy.spec.progressDeadlineSecondsTipo, descripción y default
¿Cómo se llama ese recurso?kubectl api-resourcesSHORTNAMES, APIVERSION y NAMESPACED
¿Qué cambiaría si aplico?kubectl diff -f k8s/triton.yamlLíneas con - y +; exit 0 o 1
¿Puedo hacerlo?kubectl auth can-i create pods --subresource=exec -n classifieryes o no
¿Qué puedo hacer en total?kubectl auth can-i --list -n classifierRecursos y verbos permitidos
¿Responde desde mi máquina?kubectl port-forward -n classifier svc/triton 8000:8000Forwarding from 127.0.0.1:8000 -> 8000
Pregunta → comando → qué mirar. De arriba hacia abajo, de lo que solo lee a lo que ya abre una conexión a un pod.

Ejemplo real · C01: una sesión de inspección de Triton, de menor a mayor riesgo

bash · zshComandos de ref-comandos (kubectl-basicos), con los nombres de reference/c01/k8s/triton.yaml
# ¿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-smi
  • Con set-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.

Laboratorio en tu Mac (opcional)11 pasos

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.

  1. Paso 1
    kubectl config current-context && kubectl config get-contexts

    Qué deberías ver: docker-desktop (o kind-kind con kind). En get-contexts, el * marca el activo.

  2. Paso 2
    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-k8s

    Qué deberías ver: namespace/lab-k8s created, deployment.apps/web created, service/web exposed y deployment "web" successfully rolled out.

  3. Paso 3
    kubectl get pods -n lab-k8s -o wide --show-labels

    Qué 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).

  4. Paso 4
    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ó.

  5. Paso 5
    kubectl explain deployment.spec.revisionHistoryLimit

    Qué deberías ver: GROUP: apps, KIND: Deployment, VERSION: v1, FIELD: revisionHistoryLimit <integer> y una descripción que termina en Defaults to 10.

  6. Paso 6
    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.

  7. Paso 7
    kubectl exec -n lab-k8s deploy/web -- nginx -v

    Qué deberías ver: nginx version: nginx/1.30.x, desde uno de los dos pods.

  8. Paso 8
    kubectl port-forward -n lab-k8s svc/web 8080:80 & sleep 2; curl -s localhost:8080 | grep -o '<title>.*</title>'; kill %1

    Qué 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.

  9. Paso 9
    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=ana

    Qué 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.

  10. Paso 10
    kubectl get pods -n lab-k8s --as=ana && kubectl delete pod -n lab-k8s -l app=web --as=ana

    Qué 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".

  11. Paso 11
    kubectl delete namespace lab-k8s; rm -f /tmp/web-lab.yaml

    Qué 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í.

12

Diagnóstico: el método y la tabla de estados

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

VariableQué esEjemplo
nsnamespace del podclassifier
podnombre del pod (sale de get pods)triton-5f6b…-7tq2m
contenedorcontenedor dentro del pod; hace falta si hay más de unotriton
exit code\text{exit code}lo que muestra describe en Last State137
nsen˜aln_{\text{señal}}número de la señal que terminó el proceso9 = SIGKILL (OOMKilled, o venció el plazo de gracia); 15 = SIGTERM
kknúmero de reinicio seguido1, 2, 3…
tesperat_{\text{espera}}espera antes de reiniciar en CrashLoopBackOff10, 20, 40… hasta 300 s; vuelve a 10 s tras 10 min corriendo bien

Fórmulas

exit code=128+nsen˜al137=128+9 (SIGKILL)143=128+15 (SIGTERM)\text{exit code} = 128 + n_{\text{señal}} \qquad 137 = 128 + 9\ (\text{SIGKILL}) \qquad 143 = 128 + 15\ (\text{SIGTERM})
tespera=min⁡(10 s⋅2 k−1, 300 s)t_{\text{espera}} = \min\left(10\ \text{s} \cdot 2^{\,k-1},\ 300\ \text{s}\right)

Las piezas

PiezaQué haceEjemplo
PendingEl pod existe, pero no tiene nodo todavía, o espera algo antes de crear contenedores.FailedScheduling: Insufficient nvidia.com/gpu
ContainerCreatingYa tiene nodo: baja la imagen, monta los volúmenes y crea el contenedor.la imagen de Triton, de varios GB
ErrImagePull · ImagePullBackOffNo pudo bajar la imagen; reintenta con esperas crecientes, hasta 5 min.tag inexistente, 403 de ECR
CreateContainerConfigErrorNo 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/1El 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/1Listo: está en los endpoints del Service, la lista de pods a los que manda tráfico.lo normal
CrashLoopBackOffEl 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 · CompletedEl contenedor terminó: con un código distinto de 0, o con 0.un CMD que no se queda sirviendo
RunContainerError · StartErrorcontainerd (crea los contenedores en EKS y kind) no encuentra o no puede ejecutar el comando: StartError, exit 128.executable file not found in $PATH
TerminatingSe borra: corre el preStop (paso previo al apagado), llega el SIGTERM y el plazo de gracia pone el tope.hasta 60 s en Triton
EvictedEl kubelet lo sacó porque al nodo le faltó memoria o disco. Fase Failed.The node was low on resource: memory
UnknownNo se pudo saber el estado del pod, casi siempre porque su nodo no responde.nodo NotReady
FASE PENDINGFASE RUNNINGFASE FAILEDPendingContainerCreatingImagePullBackOffCreateContainerConfigErrorRunning · 0/1Running · 1/1CrashLoopBackOffTerminatingEvicted12345678910111213FASE PENDINGFASE RUNNINGFASE FAILEDPendingContainerCreatingImagePullBackOffCreateContainerConfigErrorRunning · 0/1Running · 1/1CrashLoopBackOffTerminatingEvicted12345678910111213

esperandolistofallaapagándose

  1. 1Pending → ContainerCreating: el scheduler le asigna nodo
  2. 2ContainerCreating → ImagePullBackOff: no baja la imagen (antes, ErrImagePull)
  3. 3ImagePullBackOff → Running · 0/1: el pull por fin funciona
  4. 4ContainerCreating → CreateContainerConfigError: falta un Secret, ConfigMap o clave
  5. 5CreateContainerConfigError → Running · 0/1: creas lo que faltaba
  6. 6ContainerCreating → Running · 0/1: arranca el proceso
  7. 7Running · 0/1 → Running · 1/1: la readiness pasa
  8. 8Running · 1/1 → Running · 0/1: la readiness falla: sale del Service
  9. 9Running · 0/1 → CrashLoopBackOff: el contenedor termina (exit 1, OOMKilled; en un Deployment, también exit 0)
  10. 10Running · 1/1 → CrashLoopBackOff: muere y vuelve a morir
  11. 11CrashLoopBackOff → Running · 0/1: reinicio tras la espera
  12. 12Running · 1/1 → Terminating: delete, rollout o scale-down
  13. 13Running · 1/1 → Evicted: al nodo le falta memoria o disco
Pending, Running, Succeeded, Failed y Unknown son las cinco fases del pod. El resto es el motivo del contenedor que kubectl muestra en STATUS; READY dice si pasa la readiness.

Ejemplo real · C01: un deploy deja un Triton nuevo en CrashLoopBackOff

bash · zshComandos de ref-comandos (depurar) con los nombres de reference/c01/k8s/triton.yaml; salidas abreviadas, forma típica con kubectl 1.3x
# 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=25m
  • El orden importa: get dice dónde se trabó; describe, por qué (Last State, Exit Code y Events); logs --previous, qué dijo el proceso al morir.
  • Exit 1 con 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.
  • Con 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.
  • Guarda describe y logs --previous antes de borrar nada: los eventos duran 1 h por defecto, y borrar el pod se lleva su log.
  • Con containerd (EKS, kind) la tabla de exit codes no es la de Docker: un comando que no existe o no es ejecutable da 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.

Laboratorio en tu Mac (opcional)9 pasos

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.

  1. Paso 1
    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.

  2. Paso 2
    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}
    EOF

    Qué 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.

  3. Paso 3
    sleep 30; kubectl get pods -n lab-k8s

    Qué 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.

  4. Paso 4
    kubectl events -n lab-k8s --for pod/tag-roto

    Qué 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.

  5. Paso 5
    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.

  6. Paso 6
    kubectl events -n lab-k8s --for pod/cfg

    Qué deberías ver: Un Warning con Error: secret "no-existe" not found. El pod no arranca hasta que el Secret exista.

  7. Paso 7
    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.

  8. Paso 8
    kubectl create secret generic no-existe -n lab-k8s --from-literal=token=abc && sleep 20 && kubectl get pod cfg -n lab-k8s

    Qué 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).

  9. Paso 9
    kubectl delete namespace lab-k8s

    Qué 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í.

13

Errores que debes reconocer rápido

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

VariableQué esEjemplo
motivola razón corta, en CamelCaseForbidden, NotFound · FailedScheduling, BackOff · OOMKilled, Error
mensajeel texto que explica la razónpods is forbidden: User "ana" cannot list resource "pods"…
Kindel tipo de objetoDeployment
nombreel nombre del objetogateway
campola ruta del campo con problemaspec.selector
detallequé tiene de malofield is immutable
edadhace cuánto, y cuántas veces se repitió2m (x10 over 6m)
origenquién lo emitiódefault-scheduler, kubelet, cluster-autoscaler
codigoexit code del proceso137

Las piezas

PiezaQué haceEjemplo
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 serverEl API server no acepta tus credenciales: vencidas o de una identidad que no conoce.(Unauthorized)
The connection to the server … was refusedNo hay API server en esa dirección: cluster apagado o sin kubeconfig.localhost:8080
Unable to connect to the serverNo 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 FailedSchedulingEl scheduler no encontró nodo; cuenta los nodos por motivo.0/5 nodes are available
Warning Failed · BackOffEl kubelet no pudo bajar la imagen, armar o arrancar el contenedor, y reintenta.Back-off pulling image
Warning UnhealthyFalló una probe: Readiness, Liveness o Startup probe failed.HTTP probe failed with statuscode: 503
Last State: TerminatedCó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.
Quiz: el mensaje tal como lo ves; responde quién lo dijo, qué significa, qué corres primero y en qué sección está.

Los 26 mensajes

26 de 26

  1. 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'

    → Sección 8 · Dónde cae cada pod: nodeSelector, affinity, taints/tolerations, GPUs y nodos que se crean solos

  2. 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'

    → Sección 8 · Dónde cae cada pod: nodeSelector, affinity, taints/tolerations, GPUs y nodos que se crean solos

  3. 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

    → Sección 8 · Dónde cae cada pod: nodeSelector, affinity, taints/tolerations, GPUs y nodos que se crean solos

  4. 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

    → Sección 12 · Diagnóstico: el método y la tabla de estados

  5. 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

    → Sección 12 · Diagnóstico: el método y la tabla de estados

  6. 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

    → Sección 12 · Diagnóstico: el método y la tabla de estados

  7. 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}'

    → Sección 12 · Diagnóstico: el método y la tabla de estados

  8. 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

    → Sección 5 · Probes y apagado ordenado

  9. 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}'

    → Sección 5 · Probes y apagado ordenado

  10. 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

    → Sección 6 · Requests, limits y QoS

  11. 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

    → Sección 6 · Requests, limits y QoS

  12. 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

    → Sección 6 · Requests, limits y QoS

  13. 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

    → Sección 4 · Services, DNS e Ingress

  14. 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

    → Sección 4 · Services, DNS e Ingress

  15. 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

  16. 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

  17. 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

    → Sección 11 · kubectl: mirar, entrar, depurar

  18. 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

    → Sección 11 · kubectl: mirar, entrar, depurar

  19. 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

    → Sección 11 · kubectl: mirar, entrar, depurar

  20. 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

    → Sección 2 · Anatomía de un manifest

  21. 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

  22. 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

  23. 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

    → Sección 9 · Autoscaling: HPA y KEDA

  24. 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

    → Sección 9 · Autoscaling: HPA y KEDA

  25. 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

    → Sección 2 · Anatomía de un manifest

  26. 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

    → Sección 2 · Anatomía de un manifest

Ejemplo real · Cómo leer un FailedScheduling largo: Triton no encuentra GPU

textoForma típica del evento en Kubernetes 1.3x con Cluster Autoscaler; los nombres y la label son los de reference/c01/k8s/triton.yaml
$ 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)}]
  • Léelo por partes: 0/5 nodes are available es el total; después viene cuántos nodos fallaron por cada motivo. Esa lista es la causa.
  • Aquí, los 2 nodos GPU no tienen una T4 libre (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í.
  • Lo que sigue a 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).
  • Si el motivo fuera un taint (una marca del nodo que rechaza pods), el texto depende de la versión: hasta 1.33 (y en 1.34.0–1.34.1) nombra el taint ({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).

Laboratorio en tu Mac (opcional)6 pasos

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.

  1. Paso 1
    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; }; ronda

    Qué 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.

  2. Paso 2
    ronda

    Qué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.

  3. Paso 3
    kubectl get deploy triton -n default; kubectl get pods -n kube-system --as=ana; kubectl get scaledobjects

    Qué 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).

  4. Paso 4
    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.

  5. Paso 5
    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.

  6. Paso 6
    kubectl delete namespace lab-k8s

    Qué 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í.

⚙

Herramientas

Del síntoma al primer comando, y una revisión rápida de tus manifests antes del apply.

Diagnosticador

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.

Revisor de YAML

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.