Module 1: Why Kubernetes And The Continuity Challenge
7. Hands-on: loading the `andes-cargo-status-api` image into the cluster
Description
Lesson 6 left you with a concrete problem, confirmed with real evidence: the andes-cargo-status-api image exists in your host's Docker, but not in any andes-cargo-cluster node's internal containerd. This lesson resolves it, end to end, actually executed: you rebuild the inherited image — same Dockerfile, without changing a single line, the one aws-serverless-and-containers-guide, Module 6, lesson 5, wrote — and load it into the cluster with kind load docker-image, confirming with crictl images that it became available on all three nodes.
Connection to the module
This is the last physical piece of this module's lab. Lesson 8 — the project — audits everything you built in lessons 4, 5, and 7 in a single checklist, before moving on to Module 2, where this image finally runs inside a real Pod.
Step 1 — Recover the exact code: app.py, requirements.txt, Dockerfile
If you already have the andes-cargo-status-api/ directory from aws-serverless-and-containers-guide on your machine, use it as-is — don't copy anything new. If not, recreate it exactly as it was left in that guide (Module 6, lesson 5), with no changes at all:
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"]
Three files, zero changes from the original — exactly what lesson 3 of this module established as a hard rule: the Dockerfile is inherited, never rewritten.
Step 2 — Rebuild the image
docker build -t andes-cargo-status-api:latest .
Notice the tag: latest, not 1.0 like in the previous guide. This guide uses latest on purpose starting with this first module, because that's what you're going to reference in every Kubernetes manifest in the modules that follow — lesson 6 of Module 6 of this guide, when you get there, is going to build a Gatekeeper Constraint that specifically forbids depending on :latest in production; using it here, in a local learning lab, is the conscious exception that same lesson is going to point out.
What to expect (if you already had this image built — from this guide or from aws-serverless-and-containers-guide — Docker is going to reuse every cached layer, marked CACHED; if this is your first time, you're going to see each step run in full, with real installation times instead of 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
This result — four of five steps marked CACHED — isn't an error or a shortcut: it's exactly the behavior aws-serverless-and-containers-guide, Module 6, lesson 5, explained in depth — Docker caches each instruction as an independent layer, and since neither requirements.txt nor app.py changed a single line since they were first built, there's nothing new to install. If this is literally your first docker build of this image — for example, if you're on a new machine — you're going to see the full pip install log instead, with real times, just like in the previous guide.
Confirm the image got registered:
docker images andes-cargo-status-api --format "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}"
What to expect (IMAGE ID and CREATED are your variable value; 179MB is literal for this exact version of python:3.13-slim with these dependencies):
REPOSITORY TAG IMAGE ID CREATED SIZE
andes-cargo-status-api latest d07c06907665 12 minutes ago 179MB
Step 3 — Load the image into the cluster
Here's the command that resolves the exact problem you confirmed in lesson 6: kind load docker-image copies an image from your host Docker's cache into the internal containerd of every node in the cluster you point it to.
kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster
What to expect (literal, executed — the full sha256 is your variable value, generated from your image's exact content; the three node names are literal, one per node in 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...
Three lines, one per node — kind load docker-image, with no additional flags, copies the image to all nodes in the cluster by default, not only the control plane. This matters for real: in Module 2, when you declare a Deployment with several replicas, kube-scheduler might decide to run each Pod on a different node — if the image were only available on one of the three nodes, any Pod assigned to another would fail with ImagePullBackOff (the exact error you're going to diagnose in Module 2 if this isn't done right).
Step 4 — Verify: the image, available on all three nodes
docker exec andes-cargo-cluster-control-plane crictl images | grep andes-cargo-status-api
What to expect (the truncated IMAGE ID is literal for this specific build; SIZE may differ slightly from the size docker images reports — crictl and Docker calculate an image's size in slightly different ways, with no problem indicated by that):
docker.io/library/andes-cargo-status-api latest d07c069076658 186MB
Repeat the same check on the other two nodes, to confirm it really was distributed to all three, not only the control plane:
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
What to expect (identical on both nodes):
docker.io/library/andes-cargo-status-api latest d07c069076658 186MB
Three confirmations, the same result: the image aws-serverless-and-containers-guide built, ran locally, and could never deploy to a real orchestrator, is now available — for real, inside the cluster, on all three nodes — so any Pod that references it can start without depending on any external registry.
Analogy: the supply truck, before the gates open
If kind is the stadium's scale model (lesson 5), loading the image is exactly what the supply truck does the night before the match: it delivers food and drinks to every concession stand in the stadium — not just one — so no vendor has to run out for supplies in the middle of the event. docker build prepared the goods in your warehouse (your host's Docker); kind load docker-image is the truck that distributes it, stand by stand (node by node), before the gates open. If a single stand went unstocked, any customer who showed up there (a Pod assigned to that specific node) would go unserved — exactly the ImagePullBackOff scenario this step prevents.
Common mistakes
Forgetting kind load docker-image after rebuilding the image in a later module (workflow, the costliest one because the error shows up several steps after the real cause). What happens: in a future module, someone modifies something about the image (though this guide almost never asks for that), runs docker build again, and deploys a Deployment expecting to see the change reflected — but the Pod keeps starting with the old behavior, or fails outright with ImagePullBackOff. Why it happens: rebuilding the image only updates the host Docker's cache — confirmed in lesson 6 — each node's internal containerd still has the previous version (or none) until it's explicitly reloaded. How to spot it: docker exec <node> crictl images shows a different IMAGE ID from the one you just built with docker build. How to fix it: every time you rebuild the image, repeat this lesson's Step 3 (kind load docker-image) before expecting to see the change reflected in the cluster — it isn't automatic, and it isn't one-time.
Confusing the local image name with a remote registry reference in a Kubernetes manifest (configuration, only becomes relevant in Module 2, but the habit starts here). What happens: someone, used to working with public Docker Hub images, writes in a future deployment.yaml something like image: docker.io/andes-cargo-status-api:latest, with an explicit registry domain, and Kubernetes tries — and fails — to download it from the internet instead of using the one already loaded into the cluster. Why it happens: most Kubernetes examples circulating online use public images with their full registry. How to spot it: the Pod stays in ImagePullBackOff with a message mentioning a connection attempt to a remote registry, not a local problem. How to fix it: when working against kind with an image loaded locally, the name in the manifest must match exactly the name and tag you used in docker build and kind load docker-image (andes-cargo-status-api:latest, with no registry prefix) — you're going to confirm this with real evidence in Module 2.
Verifying only on the control-plane node and assuming the others also have the image (discipline, silent until the scheduler assigns a Pod to another node). What happens: someone runs Step 4's verification only once, against andes-cargo-cluster-control-plane, sees the image there, and calls the lesson done without checking the worker nodes. Why it happens: it's easy to assume that if it worked on one node, it worked on all of them — especially because Step 3's kind load docker-image message scrolls by quickly. How to spot it: if you never ran crictl images against andes-cargo-cluster-worker or andes-cargo-cluster-worker2 in this lesson. How to fix it: this lesson's verification explicitly includes all three nodes for this exact reason — kube-scheduler can assign a new Pod to any of the three, and an incomplete verification can hide a problem until it's too late to easily diagnose.
Exercises
Exercise 1 — Reconstruct the whole flow from memory. Without looking at the lesson, list, in order, the four steps that go from "I have the source code" to "the image is available on all three nodes of the cluster."
See solution
- Recover/recreate the exact code (
app.py,requirements.txt,Dockerfile), with no changes from the original. docker build -t andes-cargo-status-api:latest .— build (or rebuild) the image in the host's Docker.kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster— copy the image from the host's Docker into each node's internalcontainerd.- Verify with
docker exec <node> crictl images, repeated on all three nodes, to confirm the image is available on all of them, not just one.
Exercise 2 — Explain the ImagePullBackOff error before having seen it. With what you learned in this lesson and the previous one, explain in two or three sentences why a Pod would enter ImagePullBackOff state if someone skipped this lesson's Step 3 and went straight to creating a Deployment in Module 2.
See solution
ImagePullBackOff means kubelet, on the node where kube-scheduler assigned the Pod, tried to get the image and couldn't. If the image was never loaded into the cluster with kind load docker-image, that node's internal containerd doesn't have the image in its own cache — confirmed in lesson 6, they're two separate caches — and, since the name andes-cargo-status-api:latest doesn't point to any real remote registry, kubelet has no way to get it from anywhere else. The result is exactly that error state, retrying without success.
Exercise 3 — Predict what would happen with a single-node cluster. If your kind-config.yaml only declared a control-plane (with no worker at all, kind create cluster's default behavior with no configuration file), would this lesson's Step 3 still be necessary? Justify your answer.
See solution
Yes, it's still necessary — the distinction between the host's Docker and a node's internal containerd exists no matter how many nodes the cluster has; even a single-node cluster (which in that case acts as both control plane and the place Pods run) has its own internal containerd, separate from your host's Docker. The only thing that would change is the number of lines in Step 3's output — just one, instead of three — because there would only be one node to copy the image to.
Summary and next step
In this lesson you closed the last technical gap in this module's lab: you rebuilt the andes-cargo-status-api image — same Dockerfile inherited from aws-serverless-and-containers-guide, with no changes — and loaded it, for real, into all three nodes of andes-cargo-cluster with kind load docker-image. You confirmed with crictl images, on each of the three nodes separately, that the image is available — resolving with evidence the exact problem lesson 6 left you with.
Before moving on you should be able to: explain why rebuilding the image only updates the host's Docker, not the cluster; run the full four-step flow from memory; and diagnose a future ImagePullBackOff knowing exactly what to check first.
Lesson 8 — this module's project — audits the lab's three pieces (installed tools, running cluster, loaded image) in a single checklist, and leaves you with the complete map of the seven modules that follow.
Resources
- kind — Working with clusters: Loading an Image Into Your Cluster — official documentation for
kind load docker-image, this lesson's central command. - Kubernetes — Debugging Kubernetes Nodes With crictl — official reference for
crictl images, used in Step 4's verification. aws-serverless-and-containers-guide(NIEVA), Module 6, lesson 5 — the exact origin of theDockerfile,app.py, andrequirements.txtrebuilt in this lesson.- Kubernetes — Images: Image pull policy — official reference on how Kubernetes decides when to try downloading an image, relevant for understanding
ImagePullBackOffin Module 2.