Módulo 1: Why Kubernetes And The Continuity Challenge

7. Manos a la obra: cargando la imagen de `andes-cargo-status-api` en el clúster

Descripción

La lección 6 te dejó con un problema concreto, confirmado con evidencia real: la imagen andes-cargo-status-api existe en el Docker de tu host, pero no en el containerd interno de ningún nodo de andes-cargo-cluster. Esta lección lo resuelve, de punta a punta, ejecutado de verdad: reconstruyes la imagen heredada —mismo Dockerfile, sin cambiar una sola línea, el que aws-serverless-and-containers-guide, Módulo 6, lección 5, escribió— y la cargas dentro del clúster con kind load docker-image, confirmando con crictl images que quedó disponible en los tres nodos.

Conexión con el módulo

Esta es la última pieza física del laboratorio de este módulo. La lección 8 —el proyecto— audita todo lo que construiste en las lecciones 4, 5 y 7 en un solo checklist, antes de pasar al Módulo 2, donde esta imagen por fin corre dentro de un Pod real.


Paso 1 — Recupera el código exacto: app.py, requirements.txt, Dockerfile

Si ya tienes el directorio andes-cargo-status-api/ de aws-serverless-and-containers-guide en tu máquina, úsalo tal cual —no copies nada nuevo—. Si no, recréalo exactamente como quedó en esa guía (Módulo 6, lección 5), sin ningún cambio:

mkdir -p andes-cargo-status-api && cd andes-cargo-status-api
# app.py
import os

import boto3
from flask import Flask, jsonify

app = Flask(__name__)

TABLE_NAME = os.environ.get("SHIPMENTS_TABLE_NAME", "Shipments")
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
DYNAMODB_ENDPOINT_URL = os.environ.get("DYNAMODB_ENDPOINT_URL")

dynamodb = boto3.client(
    "dynamodb",
    region_name=AWS_REGION,
    endpoint_url=DYNAMODB_ENDPOINT_URL,
)


@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok", "service": "andes-cargo-status-api"}), 200


@app.route("/shipments/<shipment_id>", methods=["GET"])
def get_shipment(shipment_id):
    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={"shipmentId": {"S": shipment_id}},
    )
    item = response.get("Item")
    if item is None:
        return jsonify({"error": "shipment not found", "shipmentId": shipment_id}), 404

    return jsonify(
        {
            "shipmentId": item["shipmentId"]["S"],
            "status": item["status"]["S"],
            "originCountry": item["originCountry"]["S"],
            "destinationCountry": item["destinationCountry"]["S"],
            "carrier": item["carrier"]["S"],
            "weightKg": int(item["weightKg"]["N"]),
            "processedAt": item["processedAt"]["S"],
        }
    ), 200


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)
# requirements.txt
flask==3.1.0
boto3==1.35.99
# Dockerfile
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8080

CMD ["python", "app.py"]

Tres archivos, cero cambios frente al original — exactamente lo que la lección 3 de este módulo estableció como regla dura: el Dockerfile se hereda, nunca se reescribe.


Paso 2 — Reconstruye la imagen

docker build -t andes-cargo-status-api:latest .

Fíjate en el tag: latest, no 1.0 como en la guía anterior. Esta guía usa latest a propósito desde este primer módulo, porque es lo que vas a referenciar en cada manifiesto de Kubernetes de los módulos siguientes — la lección 6 del Módulo 6 de esta guía, cuando llegues ahí, va a construir un Constraint de Gatekeeper que específicamente prohíbe depender de :latest en producción; usarlo aquí, en un laboratorio local de aprendizaje, es la excepción consciente que esa misma lección va a señalar.

Qué esperar (si ya tenías esta imagen construida —de esta guía o de aws-serverless-and-containers-guide—, Docker va a reusar cada capa cacheada, marcada CACHED; si es tu primera vez, vas a ver cada paso ejecutarse completo, con tiempos de instalación reales en vez de CACHED):

#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 205B done
#1 DONE 0.0s

#2 [internal] load metadata for docker.io/library/python:3.13-slim
#2 DONE 0.0s

#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s

#4 [1/5] FROM docker.io/library/python:3.13-slim
#4 DONE 0.0s

#5 [internal] load build context
#5 transferring context: 63B done
#5 DONE 0.0s

#6 [2/5] WORKDIR /app
#6 CACHED

#7 [3/5] COPY requirements.txt .
#7 CACHED

#8 [4/5] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED

#9 [5/5] COPY app.py .
#9 CACHED

#10 exporting to image
#10 exporting layers done
#10 writing image sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32 done
#10 naming to docker.io/library/andes-cargo-status-api:latest done

Este resultado —cuatro de cinco pasos marcados CACHED— no es un error ni un atajo: es exactamente el comportamiento que aws-serverless-and-containers-guide, Módulo 6, lección 5, explicó a fondo — Docker cachea cada instrucción como una capa independiente, y como ni requirements.txt ni app.py cambiaron una sola línea desde que se construyeron por primera vez, no hay nada nuevo que instalar. Si este es literalmente tu primer docker build de esta imagen —por ejemplo, si estás en una máquina nueva—, vas a ver en su lugar el log completo de instalación de pip, con tiempos reales, igual que en la guía anterior.

Confirma que la imagen quedó registrada:

docker images andes-cargo-status-api --format "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}"

Qué esperar (IMAGE ID y CREATED son tu valor variable; 179MB es literal para esta versión exacta de python:3.13-slim con estas dependencias):

REPOSITORY               TAG       IMAGE ID       CREATED          SIZE
andes-cargo-status-api   latest    d07c06907665   12 minutes ago   179MB

Paso 3 — Carga la imagen dentro del clúster

Aquí está el comando que resuelve el problema exacto que confirmaste en la lección 6: kind load docker-image copia una imagen desde el caché de tu Docker del host hacia el containerd interno de cada nodo del clúster que le indiques.

kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster

Qué esperar (literal, ejecutado — el sha256 completo es tu valor variable, generado a partir del contenido exacto de tu imagen; los tres nombres de nodo son literales, uno por cada nodo de andes-cargo-cluster):

Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-control-plane", loading...
Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-worker", loading...
Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-worker2", loading...

Tres líneas, una por nodo — kind load docker-image, sin ninguna bandera adicional, copia la imagen a todos los nodos del clúster por defecto, no solo al plano de control. Esto importa de verdad: en el Módulo 2, cuando declares un Deployment con varias réplicas, kube-scheduler puede decidir correr cada Pod en un nodo distinto — si la imagen solo estuviera disponible en uno de los tres nodos, cualquier Pod asignado a otro fallaría con ImagePullBackOff (el error exacto que vas a diagnosticar en el Módulo 2 si esto no está bien hecho).


Paso 4 — Verifica: la imagen, disponible en los tres nodos

docker exec andes-cargo-cluster-control-plane crictl images | grep andes-cargo-status-api

Qué esperar (IMAGE ID truncado es literal para este build específico; SIZE puede diferir levemente del tamaño reportado por docker imagescrictl y Docker calculan el tamaño de una imagen de formas ligeramente distintas, sin que eso indique ningún problema):

docker.io/library/andes-cargo-status-api        latest               d07c069076658       186MB

Repite la misma verificación en los otros dos nodos, para confirmar que de verdad se distribuyó a los tres, no solo al plano de control:

docker exec andes-cargo-cluster-worker crictl images | grep andes-cargo-status-api
docker exec andes-cargo-cluster-worker2 crictl images | grep andes-cargo-status-api

Qué esperar (idéntico en ambos nodos):

docker.io/library/andes-cargo-status-api        latest               d07c069076658       186MB

Tres confirmaciones, un mismo resultado: la imagen que aws-serverless-and-containers-guide construyó, corrió local, y nunca pudo desplegar en un orquestador real, ahora está disponible —de verdad, dentro del clúster, en los tres nodos— para que cualquier Pod que la referencie pueda arrancar sin depender de ningún registro externo.


Analogía: el camión de abastecimiento, antes de abrir las puertas

Si kind es la maqueta a escala del estadio (lección 5), cargar la imagen es exactamente lo que hace el camión de abastecimiento la noche antes del partido: lleva la comida y las bebidas a cada puesto de venta del estadio —no solo a uno—, para que ningún vendedor tenga que salir corriendo a buscar suministros a mitad del evento. docker build preparó la mercancía en tu bodega (el Docker de tu host); kind load docker-image es el camión que la reparte, puesto por puesto (nodo por nodo), antes de que se abran las puertas. Si un solo puesto se quedara sin abastecer, cualquier cliente que llegara ahí (un Pod asignado a ese nodo específico) se quedaría sin servicio — exactamente el escenario de ImagePullBackOff que este paso previene.


Errores comunes

Olvidar kind load docker-image después de reconstruir la imagen en un módulo posterior (de flujo, el más costoso porque el error aparece varios pasos después de la causa real). Qué pasa: en un módulo futuro, alguien modifica algo de la imagen (aunque esta guía casi nunca lo pida), corre docker build de nuevo, y despliega un Deployment esperando ver el cambio reflejado — pero el Pod sigue arrancando con el comportamiento viejo, o falla directamente con ImagePullBackOff. Por qué pasa: reconstruir la imagen solo actualiza el caché del Docker del host —confirmado en la lección 6—; el containerd interno de cada nodo sigue teniendo la versión anterior (o ninguna) hasta que se vuelve a cargar explícitamente. Cómo detectarlo: docker exec <nodo> crictl images muestra un IMAGE ID distinto al que acabas de construir con docker build. Cómo corregirlo: cada vez que reconstruyas la imagen, repite el Paso 3 de esta lección (kind load docker-image) antes de esperar ver el cambio reflejado en el clúster — no es automático, ni una sola vez.

Confundir el nombre de la imagen local con una referencia de registro remoto en un manifiesto de Kubernetes (de configuración, se vuelve relevante recién en el Módulo 2, pero el hábito empieza aquí). Qué pasa: alguien, acostumbrado a trabajar con imágenes públicas de Docker Hub, escribe en un futuro deployment.yaml algo como image: docker.io/andes-cargo-status-api:latest, con un dominio de registro explícito, y Kubernetes intenta —y falla— descargarla desde internet en vez de usar la que ya cargaste en el clúster. Por qué pasa: la mayoría de los ejemplos de Kubernetes que circulan en internet usan imágenes públicas con su registro completo. Cómo detectarlo: el Pod queda en ImagePullBackOff con un mensaje que menciona un intento de conexión a un registro remoto, no un problema local. Cómo corregirlo: cuando trabajas contra kind con una imagen cargada localmente, el nombre en el manifiesto debe coincidir exactamente con el nombre y tag que usaste en docker build y en kind load docker-image (andes-cargo-status-api:latest, sin ningún prefijo de registro) — vas a confirmar esto con evidencia real en el Módulo 2.

Verificar solo en el nodo control-plane y asumir que los demás también tienen la imagen (de disciplina, silencioso hasta que el scheduler asigna un Pod a otro nodo). Qué pasa: alguien corre la verificación del Paso 4 una sola vez, contra andes-cargo-cluster-control-plane, ve la imagen ahí, y da por cerrada la lección sin revisar los nodos worker. Por qué pasa: es fácil asumir que si funcionó en un nodo, funcionó en todos —sobre todo porque el mensaje de kind load docker-image del Paso 3 pasa rápido por la terminal—. Cómo detectarlo: si nunca corriste crictl images contra andes-cargo-cluster-worker ni andes-cargo-cluster-worker2 en esta lección. Cómo corregirlo: la verificación de esta lección incluye explícitamente los tres nodos por esta razón exacta — kube-scheduler puede asignar un Pod nuevo a cualquiera de los tres, y una verificación incompleta puede esconder un problema hasta que ya sea tarde para diagnosticarlo fácilmente.


Ejercicios

Ejercicio 1 — Reconstruye el flujo completo de memoria. Sin mirar la lección, enumera los cuatro pasos, en orden, que llevan desde "tengo el código fuente" hasta "la imagen está disponible en los tres nodos del clúster".

Ver solución
  1. Recuperar/recrear el código exacto (app.py, requirements.txt, Dockerfile), sin ningún cambio frente al original.
  2. docker build -t andes-cargo-status-api:latest . — construir (o reconstruir) la imagen en el Docker del host.
  3. kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster — copiar la imagen desde el Docker del host hacia el containerd interno de cada nodo.
  4. Verificar con docker exec <nodo> crictl images, repetido en los tres nodos, para confirmar que la imagen está disponible en todos, no solo en uno.

Ejercicio 2 — Explica el error ImagePullBackOff antes de haberlo visto. Con lo que aprendiste en esta lección y en la anterior, explica en dos o tres frases por qué un Pod entraría en estado ImagePullBackOff si alguien se saltara el Paso 3 de esta lección y fuera directo a crear un Deployment en el Módulo 2.

Ver solución

ImagePullBackOff significa que kubelet, en el nodo donde kube-scheduler asignó el Pod, intentó conseguir la imagen y no pudo. Si nunca se cargó la imagen dentro del clúster con kind load docker-image, el containerd interno de ese nodo no tiene la imagen en su propio caché —confirmado en la lección 6, son dos cachés separadas— y, como el nombre andes-cargo-status-api:latest no apunta a ningún registro remoto real, kubelet no tiene forma de conseguirla de ningún otro lado. El resultado es exactamente ese estado de error, reintentando sin éxito.

Ejercicio 3 — Predice qué pasaría con un clúster de un solo nodo. Si tu kind-config.yaml solo declarara un control-plane (sin ningún worker, el comportamiento por defecto de kind create cluster sin archivo de configuración), ¿seguiría siendo necesario el Paso 3 de esta lección? Justifica tu respuesta.

Ver solución

Sí, sigue siendo necesario — la distinción entre el Docker del host y el containerd interno de un nodo existe sin importar cuántos nodos tenga el clúster; incluso un clúster de un solo nodo (que en ese caso funciona a la vez como plano de control y como lugar donde corren los Pods) tiene su propio containerd interno, separado del Docker de tu host. Lo único que cambiaría es la cantidad de líneas en la salida del Paso 3 —una sola, en vez de tres—, porque solo habría un nodo al cual copiar la imagen.


Resumen y siguiente paso

En esta lección cerraste el último vacío técnico del laboratorio de este módulo: reconstruiste la imagen andes-cargo-status-api —mismo Dockerfile heredado de aws-serverless-and-containers-guide, sin ningún cambio— y la cargaste, de verdad, dentro de los tres nodos de andes-cargo-cluster con kind load docker-image. Confirmaste con crictl images, en cada uno de los tres nodos por separado, que la imagen está disponible — resolviendo con evidencia el problema exacto que la lección 6 te dejó planteado.

Antes de avanzar deberías poder: explicar por qué reconstruir la imagen solo actualiza el Docker del host, no el clúster; ejecutar de memoria el flujo completo de cuatro pasos; y diagnosticar un futuro ImagePullBackOff sabiendo exactamente qué revisar primero.

La lección 8 —el proyecto de este módulo— audita las tres piezas del laboratorio (herramientas instaladas, clúster corriendo, imagen cargada) en un solo checklist, y te deja con el mapa completo de los siete módulos que siguen.

Recursos

  1. kind — Working with clusters: Loading an Image Into Your Cluster — la documentación oficial de kind load docker-image, comando central de esta lección.
  2. Kubernetes — Debugging Kubernetes Nodes With crictl — referencia oficial de crictl images, usado en la verificación del Paso 4.
  3. aws-serverless-and-containers-guide (NIEVA), Módulo 6, lección 5 — el origen exacto del Dockerfile, app.py y requirements.txt reconstruidos en esta lección.
  4. Kubernetes — Images: Image pull policy — referencia oficial sobre cómo Kubernetes decide cuándo intentar descargar una imagen, relevante para entender ImagePullBackOff en el Módulo 2.