Module 2: Pods Deployments And Services

5. Hands-on: `andes-cargo-status-api`'s Deployment

Description

This is the lesson where andes-cargo-status-api runs, for the first time in this entire guide, inside real Kubernetes. You're going to create the andes-cargo namespace (the space everything you build for Andes Cargo from now on is going to live in), declare a Deployment with two replicas, and repeat lesson 4's same experiment — deleting a Pod on purpose — but this time with a real ReplicaSet behind it. The result is going to be the exact opposite of what you saw there. Everything that follows really ran against andes-cargo-cluster, with the andes-cargo-status-api:latest image Module 1 left loaded on all three nodes.

Connection to the module

This lesson closes the loop this module's lesson 1 opened — the cluster ready, with no Pod running, now has Andes Cargo's first real service. The Deployment you create here is the same one lesson 8 (this module's project) is going to scale to three replicas, the same one Module 3 is going to add ConfigMap/Secret/probes/HorizontalPodAutoscaler to, and the same one Module 4 is going to expose via Ingress — this guide's whole cumulative thread starts exactly here.


Before starting: confirm the image is still loaded

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

What to expect (if your lab is still in the same state Module 1 left it in; IMAGE ID is your variable value if you rebuilt the image between modules):

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

If you don't see any line, go back to Module 1, lesson 7, and repeat kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster before continuing.


Step 1 — The namespace: andes-cargo

So far, every Pod you created (lesson 4) lived in the default namespace — the one Kubernetes uses for anyone who doesn't specify one. Starting with this lesson, everything belonging to Andes Cargo lives in its own namespace, a logical organizational boundary you're going to use constantly for the rest of this guide — including NetworkPolicy policies (Module 4) and Gatekeeper/Kyverno policies (Module 6), which apply per namespace:

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: andes-cargo
kubectl apply -f namespace.yaml

What to expect:

namespace/andes-cargo created

A Namespace doesn't contain anything on its own yet — it's, literally, a scoping label you're going to reference in every manifest you declare from now on, with metadata.namespace: andes-cargo.


Step 2 — The Deployment: andes-cargo-status-api

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: andes-cargo-status-api
  namespace: andes-cargo
  labels:
    app: andes-cargo-status-api
spec:
  replicas: 2
  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

Every field in this file was already explained in depth in lesson 3 — the only real novelty is the value: the image Module 1 loaded onto all three nodes, and port 8080, the same one the Dockerfile inherited from aws-serverless-and-containers-guide declared with EXPOSE 8080. One detail that does deserve attention: imagePullPolicy: IfNotPresent. By default, Kubernetes tries to download (pull) an image tagged :latest every time it creates a new Pod, assuming "latest" changes often — but since this image doesn't live in any remote registry (you loaded it directly with kind load docker-image, Module 1, lesson 7), forcing that behavior would produce an error. IfNotPresent tells kubelet: use the image that's already in this node's containerd, without trying to download it from anywhere.

kubectl apply -f deployment.yaml

What to expect:

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

Step 3 — Verify: the Deployment and its Pods

kubectl get deployments -n andes-cargo

What to expect (literal, executed — AGE is your variable value):

NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
andes-cargo-status-api   2/2     2            2           6s

Four columns, each answering a different question: READY (2 of 2 Pods are ready to receive traffic), UP-TO-DATE (2 Pods run the Deployment's most recent template), AVAILABLE (2 Pods have been running without failing for the minimum time needed to be considered available — a configurable threshold this guide doesn't adjust, and which by default is practically immediate).

kubectl get pods -n andes-cargo -o wide

What to expect (literal, executed — each name's hash suffix, the IPs, and AGE are variable by design; the prefix andes-cargo-status-api-56856576d4- is literal for this exact Deployment build):

NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE                          NOMINATED NODE   READINESS GATES
andes-cargo-status-api-56856576d4-8lb5t   1/1     Running   0          7s    10.244.1.2   andes-cargo-cluster-worker2   <none>           <none>
andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          7s    10.244.2.4   andes-cargo-cluster-worker    <none>           <none>

The -o wide flag adds two columns that plain kubectl get pods doesn't show: IP (each Pod's internal address, assigned by the CNI — you're going to go deeper on this in Module 4) and NODE (which of the two workers each one ended up on). Notice something that isn't a coincidence: the scheduler put one Pod on andes-cargo-cluster-worker2 and the other on andes-cargo-cluster-worker — it spread them out, instead of putting both on the same node. This is exactly why Module 1, lesson 7, insisted on loading the image onto all three nodes, not just one: if the image had only been on one node, the Pod assigned to the other would have failed with ImagePullBackOff.

Also confirm the ReplicaSet the Deployment created automatically underneath — the same mechanism lesson 3 explained, now with real evidence:

kubectl get replicasets -n andes-cargo

What to expect (andes-cargo-status-api-56856576d4 is the literal name for this exact build — the hash suffix is derived from the Pod template's content, it isn't random in the sense of changing without reason; AGE is variable):

NAME                                DESIRED   CURRENT   READY   AGE
andes-cargo-status-api-56856576d4   2         2         2       13s

You never declared this ReplicaSet — the Deployment created it for you, exactly as lesson 3 predicted.


Step 4 — Delete a Pod on purpose, and observe the difference from lesson 4

This is this lesson's central moment: you literally repeat lesson 4's same command — kubectl delete pod — but this time on a Pod that does have a ReplicaSet behind it.

kubectl delete pod andes-cargo-status-api-56856576d4-8lb5t -n andes-cargo

Substitute the exact name for the one your own kubectl get pods -o wide from Step 3 showed you — your Pod's hash suffix is going to be different from this example's.

What to expect:

pod "andes-cargo-status-api-56856576d4-8lb5t" deleted from andes-cargo namespace

Confirm right away:

kubectl get pods -n andes-cargo -o wide

What to expect (andes-cargo-status-api-56856576d4-7ztk9 is a new Pod — different name and AGE from the one you deleted; vjc9k, the one you didn't touch, stays exactly the same):

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

You still see two Pods — it never dropped to one, not even for an instant you managed to catch with a single command. Lesson 3's ReplicaSet noticed, on its next pass through the reconciliation loop (which runs every few seconds), that the actual state (1 Pod with the app=andes-cargo-status-api label) didn't match the desired state (replicas: 2), and created a new one immediately — with a new name (different hash suffix, 7ztk9 instead of 8lb5t), but with the same label, the same image, the same port: the same template you declared in deployment.yaml.

Confirm the final result with the Deployment itself:

kubectl get deployments -n andes-cargo

What to expect (identical to Step 3's — the Deployment never stopped reporting 2 replicas available, because the replacement was practically immediate):

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

This is, in one sentence, this module's complete contrast: in lesson 4, deleting a Pod left the cluster with zero Pods, forever. Here, deleting a Pod left the cluster with two Pods, always — one of them different from before, but the declared count, intact.


Analogy: the thermostat in action, not just in theory

Lesson 3 described a Deployment as a building's thermostat; this lesson is the first time you see it really react. Deleting andes-cargo-status-api-56856576d4-8lb5t was, in that analogy, like suddenly closing one of the two offices the thermostat had instructions to keep occupied — the thermostat didn't ask why it closed, didn't wait for someone to report it: it noticed the gap between "2 offices should be occupied" and "now there's only 1," and opened a new office (with a different door number, but the same type of office) to get back to the correct count. That's exactly the work a Deployment does every time reality drifts from what's declared — without exception, with no one asking each time.


Common mistakes

Trying to delete the Deployment instead of the Pod, expecting to see the same replacement behavior (conceptual, a scope error). What happens: someone, confused about which object is being deleted, runs kubectl delete deployment andes-cargo-status-api instead of kubectl delete pod <specific-name>, and is surprised that all the Pods disappear, with no replacement whatsoever. Why it happens: it's easy to think "deleting something related to the Deployment" always produces the same self-healing result. How to spot it: if kubectl get pods -n andes-cargo comes back completely empty after a delete, and kubectl get deployments -n andes-cargo shows nothing either. How to fix it: the Deployment is the source of truth — deleting it also removes, in cascade, the ReplicaSet and all its Pods (unless you use kubectl delete deployment ... --cascade=orphan, an advanced case out of this lesson's scope). This lesson's self-healing behavior applies to deleting individual Pods, not to the Deployment that manages them. If this happens to you, simply apply deployment.yaml again with kubectl apply -f to recreate it from scratch.

Expecting the replacement Pod to have the same name as the one that was deleted (expectation, this lesson's most common specific confusion). What happens: someone looks, with kubectl get pods, for a Pod with exactly the same name as the one they just deleted, and doesn't find it — panics thinking the replacement didn't work — without noticing there is a new Pod, just with a different hash suffix. Why it happens: in other systems (for example, restarting a service with systemd) the "same service" usually keeps the same identifier after restarting. How to spot it: if you count the total number of Pods with the app=andes-cargo-status-api label (should still be 2) instead of looking for a specific name. How to fix it: remember this guide's honesty rule (explicitly named since this guide's design): a Pod's hash suffix, when created by a ReplicaSet, always varies — the only literal, predictable part is the prefix, which corresponds to the Deployment. Never search for a managed Pod's full name; search by its label with kubectl get pods -l app=andes-cargo-status-api.

Forgetting -n andes-cargo and looking for the Deployment in the wrong namespace (configuration, silent because kubectl doesn't throw an error, just an empty list). What happens: someone runs kubectl get deployments (without the -n flag), sees an empty list or one different from expected, and assumes the Deployment wasn't created. Why it happens: without -n, kubectl assumes the active context's namespace — which is still default, the same one you used in lesson 4, not andes-cargo. How to spot it: kubectl get deployments without the flag, compared against kubectl get deployments -n andes-cargo, shows different results. How to fix it: every command from this lesson onward that touches Andes Cargo resources needs explicit -n andes-cargo — you're going to see this pattern constantly for the rest of the guide. (There's a way to change the active context's default namespace with kubectl config set-context --current --namespace=andes-cargo, but this guide prefers to be explicit in each command, so it's clear which namespace each one operates in while you're learning.)


Exercises

Exercise 1 — Reconstruct the contrast with lesson 4. In a two-column table, without going back to either lesson, write what happened when deleting a Pod in lesson 4 (loose Pod) versus what happened when deleting a Pod in this lesson (Pod inside a Deployment).

See solution
Lesson 4 (loose Pod)Lesson 5 (Pod in a Deployment)
Objects before deleting1 Pod2 Pods
Objects after deleting0 Pods, forever2 Pods (one new, with a different name)
Who actedNo one — there was no controllerThe Deployment's ReplicaSet, with no one asking

Exercise 2 — Predict a case with three nodes, three replicas. If you scaled this same Deployment to replicas: 3 (the exact number lesson 8, this module's project, is going to use), how would you expect the scheduler to distribute the three Pods across andes-cargo-cluster's three nodes (one control-plane and two worker)? Keep in mind what you already know from Module 1 about each node type's role.

See solution

By default, kube-scheduler doesn't assign normal workload Pods to the control-plane node — that node has a special mark (a taint) reserving it for the control plane's own components (kube-apiserver, etcd, scheduler, controller-manager, already seen in Module 1, lesson 6). With only two worker nodes available and three desired replicas, the expected result is that both workers receive at least one Pod each, and the third joins either of the two — never the control-plane. You're going to confirm this with real evidence in lesson 8.

Exercise 3 — Explain imagePullPolicy: IfNotPresent to a colleague. A colleague, reviewing deployment.yaml, asks you why you didn't leave Kubernetes' default behavior for a :latest image. Explain, in two or three sentences, what would happen if you removed that line from the manifest.

See solution

Without imagePullPolicy: IfNotPresent, Kubernetes uses its default behavior for any image tagged :latest: try to download (pull) it from a remote registry every time it creates a new Pod, assuming "latest" might have changed since last time. Since andes-cargo-status-api:latest doesn't live in any remote registry — it was loaded directly into each node's containerd with kind load docker-image, in Module 1 — that download attempt would fail, and the Pod would get stuck in ImagePullBackOff, the same error Module 1, lesson 7, warned about in advance.


Summary and next step

In this lesson andes-cargo-status-api ran, for the first time in this guide, inside real Kubernetes: you created the andes-cargo namespace, declared a Deployment with two replicas, confirmed the scheduler spread them across the two worker nodes, and — the central point — deleted a Pod on purpose and watched the ReplicaSet replace it immediately, with no one asking. The contrast with lesson 4 (where the same command left the cluster with no Pod at all, forever) is, with real evidence, the complete difference between a loose Pod and a managed one.

Before moving on you should be able to: explain why this Deployment needs imagePullPolicy: IfNotPresent; predict which nodes a scaled Deployment is going to end up on, knowing the control-plane normally doesn't receive workload; and reconstruct, from memory, the full contrast between this lesson and lesson 4.

Next lesson: Services, ClusterIP, NodePort, and why a Pod isn't a stable address. andes-cargo-status-api is already running with two replicas — but you still have no way to talk to it from outside without knowing, by hand, a specific Pod's internal IP. That's where this module's last piece comes in.

Resources

  1. Kubernetes — Deployments — official reference, the same one from lesson 3, now confirmed with real evidence.
  2. Kubernetes — Namespaces — official reference for the Namespace object created in Step 1.
  3. Kubernetes — Images: Updating images — official reference for imagePullPolicy and its default behavior with the :latest tag.
  4. aws-serverless-and-containers-guide (NIEVA), Module 7 — the documented-ECS-only status-api-service this guide finally puts to work, starting with this lesson.