Módulo 2: Pods Deployments And Services

8. Proyecto: `andes-cargo-status-api` con N réplicas

Descripción

Este proyecto junta, en un solo sistema verificado de punta a punta, las tres primitivas que construiste en este módulo: escalas el Deployment de andes-cargo-status-api a tres réplicas, confirmas que las tres responden a través del mismo status-api-service —el balanceo de carga que la lección 6 explicó en teoría, ahora con evidencia real—, y repites, una última vez, el experimento central de este módulo: borrar un Pod a propósito y verlo reponerse solo, esta vez con el sistema completo funcionando junto. Todo lo que sigue corrió de verdad contra andes-cargo-cluster.

Conexión con el módulo

Cada lección de este módulo construyó una pieza: el concepto de Pod (lecciones 2 y 4), el concepto de Deployment/ReplicaSet (lecciones 3 y 5), el concepto de Service (lecciones 6 y 7). Este proyecto es la primera vez que ves las tres funcionando juntas, bajo una condición que ninguna lección individual probó todavía: más de dos réplicas, con tráfico real repartido entre todas.


Paso 1 — Escala el Deployment a 3 réplicas

Modifica deployment.yaml (el mismo archivo de la lección 5), cambiando replicas: 2 a replicas: 3:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: andes-cargo-status-api
  namespace: andes-cargo
  labels:
    app: andes-cargo-status-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: andes-cargo-status-api
  template:
    metadata:
      labels:
        app: andes-cargo-status-api
    spec:
      containers:
        - name: andes-cargo-status-api
          image: andes-cargo-status-api:latest
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
kubectl apply -f deployment.yaml

Qué esperar (configured, no created — el Deployment ya existía desde la lección 5, esta es una actualización de un campo, no una creación):

deployment.apps/andes-cargo-status-api configured

Este único cambio —un número, de 2 a 3— es todo lo que hizo falta. No recreaste el Service, no tocaste el namespace, no reconstruiste la imagen. El bucle de reconciliación de la lección 3 hace el resto solo.


Paso 2 — Verifica: tres réplicas, repartidas entre los nodos disponibles

kubectl get deployments -n andes-cargo

Qué esperar (AGE es tu valor variable):

NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
andes-cargo-status-api   3/3     3            3           119s
kubectl get pods -n andes-cargo -o wide

Qué esperar (sufijos hash, IPs y AGE son tus valores variables — el prefijo del nombre y el patrón de dos nodos son literales para esta arquitectura de clúster):

NAME                                      READY   STATUS    RESTARTS   AGE    IP           NODE                          NOMINATED NODE   READINESS GATES
andes-cargo-status-api-56856576d4-7ztk9   1/1     Running   0          106s   10.244.1.3   andes-cargo-cluster-worker2   <none>           <none>
andes-cargo-status-api-56856576d4-fqn9j   1/1     Running   0          6s     10.244.2.5   andes-cargo-cluster-worker    <none>           <none>
andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          119s   10.244.2.4   andes-cargo-cluster-worker    <none>           <none>

Tres Pods, repartidos entre andes-cargo-cluster-worker y andes-cargo-cluster-worker2 — el control-plane no recibe ninguno, exactamente lo que predijiste en el Ejercicio 2 de la lección 5. Con solo dos nodos worker disponibles para tres réplicas, uno de los dos necesariamente termina con dos Pods — en este caso, andes-cargo-cluster-worker.


Paso 3 — Confirma el balanceo: las tres réplicas responden

Aquí está la verificación central de este proyecto: ¿de verdad el Service reparte tráfico entre las tres réplicas, no solo entre dos, ni siempre hacia la misma? kubectl port-forward (lección 7) no sirve para esto — mantiene la conexión fija a un solo Pod. En su lugar, lanza un Pod temporal dentro del clúster, y desde ahí hazle varias solicitudes al Service por su nombre DNS interno:

kubectl run curl-client --image=curlimages/curl:latest --restart=Never -n andes-cargo -- sleep 3600

Qué esperar:

pod/curl-client created

Espera unos segundos a que arranque, y confirma:

kubectl get pod curl-client -n andes-cargo

Qué esperar:

NAME          READY   STATUS    RESTARTS   AGE
curl-client   1/1     Running   0          5s

Ahora, desde dentro de ese Pod, haz nueve solicitudes seguidas contra el nombre DNS completo del Service —el mismo patrón <service>.<namespace>.svc.cluster.local que la lección 6 explicó—:

kubectl exec curl-client -n andes-cargo -- sh -c 'for i in $(seq 1 9); do curl -s http://status-api-service.andes-cargo.svc.cluster.local/health; echo; done'

Qué esperar (literal, ejecutado — las nueve respuestas son idénticas en contenido, porque /health no depende de qué Pod específico responda, pero eso no significa que las nueve las haya atendido el mismo Pod):

{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}
{"service":"andes-cargo-status-api","status":"ok"}

Nueve respuestas 200, todas con el mismo cuerpo — como esperabas. La pregunta real es cuál Pod atendió cada una, y la respuesta no está en el curl, está en los logs de cada Pod. Cuenta cuántas líneas de GET /health tiene cada uno:

for p in $(kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o jsonpath='{.items[*].metadata.name}'); do
  echo "--- $p ---"
  kubectl logs "$p" -n andes-cargo | grep -c "GET /health"
done

Qué esperar (los nombres de Pod son tu valor variable; los conteos exactos también varían según cuántas veces hayas repetido el curl —el mecanismo de reparto de kube-proxy no garantiza una distribución perfectamente pareja en pocas solicitudes—, pero el patrón de fondo —ningún Pod en cero— es lo que confirma el balanceo):

--- andes-cargo-status-api-56856576d4-7ztk9 ---
3
--- andes-cargo-status-api-56856576d4-fqn9j ---
4
--- andes-cargo-status-api-56856576d4-vjc9k ---
3

Ahí está la confirmación: los tres Pods recibieron solicitudes (3, 4, 3 — suman las nueve del curl), no solo uno o dos. kube-proxy, el componente que ya nombró la lección 6, repartió las solicitudes entre las tres IPs de la lista de Endpoints, sin que nadie le dijera explícitamente cómo hacerlo — el mismo mecanismo de la lección 6, ahora confirmado con tres réplicas en vez de dos.


Paso 4 — Borra un Pod a propósito, una última vez

Repite, por última vez en este módulo, el experimento central: borrar un Pod y observar la reposición — pero ahora con tres réplicas y tráfico real fluyendo por el Service.

kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o wide

Qué esperar (tus valores de nombre/IP/AGE van a diferir):

NAME                                      READY   STATUS    RESTARTS   AGE     IP           NODE                          NOMINATED NODE   READINESS GATES
andes-cargo-status-api-56856576d4-7ztk9   1/1     Running   0          2m16s   10.244.1.3   andes-cargo-cluster-worker2   <none>           <none>
andes-cargo-status-api-56856576d4-fqn9j   1/1     Running   0          36s     10.244.2.5   andes-cargo-cluster-worker    <none>           <none>
andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          2m29s   10.244.2.4   andes-cargo-cluster-worker    <none>           <none>
kubectl delete pod andes-cargo-status-api-56856576d4-fqn9j -n andes-cargo

Sustituye por el nombre exacto de uno de tus propios Pods.

Qué esperar:

pod "andes-cargo-status-api-56856576d4-fqn9j" deleted from andes-cargo namespace
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o wide

Qué esperar (andes-cargo-status-api-56856576d4-2slnk es un Pod nuevo, con IP nueva; los otros dos, sin cambios):

NAME                                      READY   STATUS    RESTARTS   AGE     IP           NODE                          NOMINATED NODE   READINESS GATES
andes-cargo-status-api-56856576d4-2slnk   1/1     Running   0          36s     10.244.2.6   andes-cargo-cluster-worker    <none>           <none>
andes-cargo-status-api-56856576d4-7ztk9   1/1     Running   0          2m52s   10.244.1.3   andes-cargo-cluster-worker2   <none>           <none>
andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          3m5s    10.244.2.4   andes-cargo-cluster-worker    <none>           <none>
kubectl get deployment andes-cargo-status-api -n andes-cargo

Qué esperar (nunca cayó de 3/3):

NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
andes-cargo-status-api   3/3     3            3           3m5s

Paso 5 — Confirma que el sistema completo sigue sano después del reemplazo

Una última verificación: con el Pod nuevo ya corriendo, confirma que el Service lo incluyó automáticamente en su lista de Endpoints —sin que nadie se lo pidiera— y que sigue balanceando correctamente:

kubectl exec curl-client -n andes-cargo -- sh -c 'for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code}\n" http://status-api-service.andes-cargo.svc.cluster.local/health; done'

Qué esperar:

200
200
200
200
200
200
for p in $(kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o jsonpath='{.items[*].metadata.name}'); do
  echo "--- $p ---"
  kubectl logs "$p" -n andes-cargo | grep -c "GET /health"
done

Qué esperar (los conteos son acumulados desde que cada Pod arrancó — el Pod de reemplazo, más joven, puede mostrar un número distinto a los otros dos; lo que importa es que ninguno quede en cero):

--- andes-cargo-status-api-56856576d4-2slnk ---
3
--- andes-cargo-status-api-56856576d4-7ztk9 ---
3
--- andes-cargo-status-api-56856576d4-vjc9k ---
6

El Pod nuevo (2slnk) ya está recibiendo tráfico, con nada especial que hayas tenido que configurar — el selector del Service lo encontró en cuanto nació, exactamente el mecanismo automático que la lección 6 describió.

Limpia el Pod temporal de prueba, ya no lo necesitas:

kubectl delete pod curl-client -n andes-cargo

Qué esperar:

pod "curl-client" deleted from andes-cargo namespace

El checklist final: el sistema completo de este módulo

kubectl get all -n andes-cargo

Qué esperar (nombres de Pod e IPs son variables; la forma general —un Deployment, un ReplicaSet, tres Pods, un Service— es literal):

NAME                                          READY   STATUS    RESTARTS   AGE
pod/andes-cargo-status-api-56856576d4-2slnk   1/1     Running   0          48s
pod/andes-cargo-status-api-56856576d4-7ztk9   1/1     Running   0          3m4s
pod/andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          3m17s

NAME                         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/status-api-service   ClusterIP   10.96.78.1   <none>        80/TCP    2m1s

NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/andes-cargo-status-api   3/3     3            3           3m17s

NAME                                                DESIRED   CURRENT   READY   AGE
replicaset.apps/andes-cargo-status-api-56856576d4   3         3         3       3m17s

Cuatro tipos de objeto, una sola jerarquía: el Deployment que declaraste (lección 5, escalado en el Paso 1 de este proyecto), el ReplicaSet que creó por debajo (lección 3, nunca lo tocaste directamente), los tres Pods que mantiene vivos (confirmados sanos y balanceados en este proyecto), y el Service que les da una dirección estable (lección 7). Ningún objeto de este sistema depende de que recuerdes ningún IP ni ningún nombre de Pod específico — esa es, en una sola pantalla, la tesis completa de este módulo.


Errores comunes

Verificar el balanceo con curl directo desde tu máquina en vez de desde dentro del clúster, y no ver nada distribuido (de flujo, específico de este proyecto). Qué pasa: alguien intenta repetir la verificación del Paso 3 usando kubectl port-forward (el patrón de la lección 7) en un bucle, y nota que todas las solicitudes las atiende el mismo Pod, sin importar cuántas veces repita el curl. Por qué pasa: kubectl port-forward mantiene una conexión fija hacia un Pod específico durante toda la vida del túnel — no reparte solicitudes individuales entre los Pods disponibles, a diferencia de una solicitud real hecha directamente contra la IP del Service desde dentro del clúster. Cómo detectarlo: si ves el mismo Pod respondiendo a GET /health en sus logs, una y otra vez, mientras otros Pods sanos quedan en cero. Cómo corregirlo: para verificar balanceo real entre réplicas, la solicitud tiene que llegar directo a la IP del Service (o a su nombre DNS) desde dentro del clúster —el patrón exacto de este proyecto, con un Pod temporal (curl-client) haciendo las solicitudes—, no a través de un túnel de port-forward, que fija la conexión a un solo backend.

Esperar una distribución perfectamente pareja (3/3/3) en solo nueve solicitudes, y sospechar de un problema si no la ves (conceptual). Qué pasa: alguien corre la verificación del Paso 3, ve un reparto como 3/4/2 en vez de 3/3/3, y asume que algo está mal con el Service o con kube-proxy. Por qué pasa: "balanceo de carga" suena a "reparto matemáticamente exacto", pero el mecanismo real de kube-proxy (basado en reglas de iptables por defecto) no garantiza una distribución perfecta en una muestra pequeña — sí garantiza, con un volumen de tráfico suficiente, que ningún Pod sano queda permanentemente sin recibir solicitudes. Cómo detectarlo: si tu propio reparto de nueve solicitudes no salió exactamente 3/3/3. Cómo corregirlo: lo que importa verificar no es la proporción exacta, sino que ningún Pod sano quede en cero — eso es lo que confirma que el Service los está considerando a los tres, no que el reparto sea matemáticamente perfecto en una muestra tan pequeña.

Olvidar borrar curl-client al terminar, y confundirlo en una lección o módulo futuro con un objeto real de Andes Cargo (de disciplina). Qué pasa: alguien termina este proyecto sin correr el kubectl delete pod curl-client del Paso 5, y en un módulo posterior (por ejemplo, el Módulo 6, cuando Gatekeeper empiece a exigir límites de recursos en todo el namespace andes-cargo) se encuentra con un Pod sin ninguna relación con andes-cargo-status-api violando una política, sin recordar de dónde salió. Por qué pasa: es fácil olvidar un Pod de depuración temporal una vez que cumplió su propósito inmediato. Cómo detectarlo: kubectl get pods -n andes-cargo muestra un Pod llamado curl-client, sin ninguna etiqueta app=andes-cargo-status-api, mucho después de que este proyecto terminó. Cómo corregirlo: el Paso 5 de este proyecto incluye la limpieza explícita por esta razón — cualquier Pod de depuración temporal que crees en el resto de esta guía (vas a repetir este patrón) debería borrarse en cuanto termine de cumplir su propósito, no quedar corriendo indefinidamente dentro de andes-cargo.


Ejercicios

Ejercicio 1 — Reconstruye el proyecto completo de memoria. Sin volver a la lección, enumera los cinco pasos de este proyecto, en orden, y qué confirmó cada uno.

Ver solución
  1. Escalar deployment.yaml de replicas: 2 a replicas: 3, y aplicarlo — confirma que un solo número cambia todo lo necesario.
  2. Verificar con kubectl get deployments/kubectl get pods -o wide que las tres réplicas están sanas, repartidas entre los dos nodos worker.
  3. Lanzar un Pod temporal (curl-client) dentro del clúster, y hacer varias solicitudes contra el nombre DNS del Service — confirma, con los logs de cada Pod, que las tres réplicas reciben tráfico, no solo una o dos.
  4. Borrar un Pod a propósito, y confirmar que el Deployment nunca cae de 3/3 — el ReplicaSet repone la réplica perdida de inmediato.
  5. Reverificar el balanceo después del reemplazo, confirmando que el Pod nuevo se integra automáticamente a la lista de Endpoints del Service, sin ninguna configuración adicional.

Ejercicio 2 — Explica por qué port-forward no sirve para verificar balanceo. Sin volver a "Errores comunes", explica en dos o tres frases por qué kubectl port-forward no es la herramienta correcta para confirmar que un Service reparte tráfico entre varias réplicas, y qué herramienta sí lo es.

Ver solución

kubectl port-forward abre un túnel fijo hacia un solo backend (un Pod específico, elegido una vez al iniciar el túnel) y mantiene esa misma conexión para todas las solicitudes que pasen por él durante su vida — no vuelve a consultar la lista de Endpoints del Service en cada solicitud individual. Para ver balanceo real, la solicitud tiene que originarse dentro del clúster y llegar directo a la IP (o el nombre DNS) del Service, dejando que kube-proxy decida, solicitud por solicitud, a cuál Pod enrutarla — el patrón que este proyecto usó con el Pod temporal curl-client.

Ejercicio 3 — Diseña una verificación para cinco réplicas. Si este mismo Deployment tuviera replicas: 5 en vez de 3, y quisieras confirmar que las cinco reciben tráfico, ¿cuántas solicitudes mínimas correrías en el bucle del Paso 3 para tener una probabilidad razonable de que ninguna réplica sana quede en cero, y por qué más que cinco?

Ver solución

Correr exactamente cinco solicitudes no sería suficiente para tener confianza razonable — como el reparto de kube-proxy no garantiza una distribución perfectamente uniforme (confirmado en "Errores comunes" de esta lección), con una muestra del mismo tamaño que el número de réplicas existe una probabilidad real de que, por simple azar de la secuencia de reparto, alguna réplica quede en cero solo por mala suerte estadística, no por ningún problema real. Una regla práctica razonable es correr un múltiplo claro del número de réplicas —por ejemplo, quince o veinte solicitudes para cinco réplicas (tres o cuatro veces el número de backends)— para que la probabilidad de que una réplica sana quede en cero por simple variación estadística sea baja, sin necesitar un número exageradamente grande.


Resumen y siguiente paso

Este proyecto cerró el Módulo 2 con el sistema completo funcionando junto: escalaste andes-cargo-status-api a tres réplicas con un solo cambio de número, confirmaste con evidencia real —conteos de logs por Pod, no solo la palabra "balanceo"— que las tres reciben tráfico a través de status-api-service, y repetiste, una última vez, el experimento central de este módulo: borrar un Pod a propósito, viendo al ReplicaSet reponerlo de inmediato mientras el Service seguía respondiendo sin interrupción visible. El checklist final (kubectl get all -n andes-cargo) te deja con las cuatro piezas de este módulo —Deployment, ReplicaSet, tres Pod, un Service— en una sola pantalla.

Antes de avanzar deberías poder: escalar un Deployment existente sin recrear ningún otro objeto; verificar balanceo real entre réplicas usando un Pod temporal dentro del clúster, distinguiendo esa técnica de port-forward; y reconstruir, de memoria, por qué borrar un Pod administrado nunca reduce el número de réplicas disponibles por más de un instante.

Siguiente módulo: configuración, secretos, salud y autoscaling. El Módulo 3 toma exactamente este mismo Deployment —sano, con tres réplicas, balanceado— y resuelve lo que la lección 7 de este módulo dejó honestamente pendiente: ConfigMap y Secret para que andes-cargo-status-api tenga la configuración que le falta, liveness/readiness/startup probes para que Kubernetes sepa distinguir un Pod sano de uno que solo parece estarlo, y un HorizontalPodAutoscaler que escale las réplicas por métricas reales, no por un número fijo que tú decides a mano cada vez.

Recursos

  1. Kubernetes — Scaling a Deployment — la guía oficial de escalar un Deployment, la operación central del Paso 1 de este proyecto.
  2. Kubernetes — Debug Services — referencia oficial de diagnóstico de Service/Endpoints, la base técnica de la verificación de balanceo de este proyecto.
  3. Kubernetes — kube-proxy — referencia oficial del componente responsable del reparto de tráfico entre réplicas, mencionado en esta lección y desarrollado a fondo en el Módulo 4.
  4. aws-serverless-and-containers-guide (NIEVA), Módulo 7, lección 8 — el status-api-service ECS-solo-documentado que este módulo terminó de reemplazar con un Service de Kubernetes real, corriendo con réplicas balanceadas.