View on GitHub

Containers & Kubernetes Tutorial

Session 05 demos – Kubernetes: architecture, kubectl, Pods

Demos 1–3 belong to Block A (cluster architecture, the API, the first Pod), Demos 4–5 to Block B (Pod lifecycle, statuses, multi-container Pods). Files next to this one:

File Used in
namespace.yaml Demo 3 — the demo namespace
pod.yaml Demos 3–4 — pinboard-api as a single Pod
pod-bad-image.yaml Demo 4 — ImagePullBackOff on purpose
pod-sidecar.yaml Demo 5 — init container + native sidecar + emptyDir

Before the session

~10 minutes, with network. Create the cluster before the room fills up — the first kind create cluster pulls a ~1 GB node image.

cd ~/docker-kubernetes-training/demos/session05

kind get clusters                       # reuse "pinboard" if it is already there
kind create cluster --name pinboard --config ../../labs/kind-config.yaml

# The image students built in Lab 02, side-loaded into every node:
docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ../../src/pinboard-api
kind load docker-image pinboard-api:1.0 --name pinboard
docker exec pinboard-worker crictl pull docker.io/library/busybox:1.37

kubectl config use-context kind-pinboard
kubectl get nodes

If the demo cluster is broken and you must rebuild it live, do it while talking through the architecture slide — kind create cluster takes 60–90 s with the node image already pulled.


Demo 1: the cluster is containers all the way down (≈ 7 min)

What to show / say

kind is not a simulator: it is a real Kubernetes cluster whose nodes are Docker containers — which makes the architecture slide inspectable.

Steps

  1. Create the cluster (or show that it exists) and read the config aloud:

    cat ../../labs/kind-config.yaml
    kind get clusters
    kubectl cluster-info --context kind-pinboard
    
    Kubernetes control plane is running at https://127.0.0.1:39421
    CoreDNS is running at https://127.0.0.1:39421/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
    

    👉 point at extraPortMappings 80/443 — “that is how Ingress will be reachable at http://localhost in Session 07” — and at the two role: worker entries.

  2. The nodes:

    kubectl get nodes -o wide
    
    NAME                     STATUS   ROLES           AGE   VERSION   INTERNAL-IP   OS-IMAGE                      CONTAINER-RUNTIME
    pinboard-control-plane   Ready    control-plane   9m    v1.33.1   172.18.0.4    Debian GNU/Linux 12           containerd://2.0.x
    pinboard-worker          Ready    <none>          9m    v1.33.1   172.18.0.2    Debian GNU/Linux 12           containerd://2.0.x
    pinboard-worker2         Ready    <none>          9m    v1.33.1   172.18.0.3    Debian GNU/Linux 12           containerd://2.0.x
    

    👉 point at CONTAINER-RUNTIME: containerd — “not Docker. Kubernetes talks CRI to containerd, which talks OCI to runc. Docker as a runtime was removed in 1.24; the images we build with Docker still run here because they are OCI images.”

  3. Now the punchline:

    docker ps --format 'table \t\t'
    
    NAMES                    IMAGE                  STATUS
    pinboard-control-plane   kindest/node:v1.33.1   Up 9 minutes
    pinboard-worker          kindest/node:v1.33.1   Up 9 minutes
    pinboard-worker2         kindest/node:v1.33.1   Up 9 minutes
    

    👉 “Three ‘machines’ — three containers on my laptop. Kubernetes IN Docker. On AKS/EKS/GKE these are VMs; nothing else changes.”

  4. The control plane is itself Pods:

    kubectl get pods -A -o wide
    
    NAMESPACE      NAME                                             READY   STATUS    NODE
    kube-system    coredns-668d6bf9bc-7qk2m                         1/1     Running   pinboard-control-plane
    kube-system    etcd-pinboard-control-plane                      1/1     Running   pinboard-control-plane
    kube-system    kindnet-2xk9f                                    1/1     Running   pinboard-worker
    kube-system    kube-apiserver-pinboard-control-plane            1/1     Running   pinboard-control-plane
    kube-system    kube-controller-manager-pinboard-control-plane   1/1     Running   pinboard-control-plane
    kube-system    kube-proxy-8dhs4                                 1/1     Running   pinboard-worker
    kube-system    kube-scheduler-pinboard-control-plane            1/1     Running   pinboard-control-plane
    local-path-storage  local-path-provisioner-…                    1/1     Running   pinboard-control-plane
    

    👉 walk the slide’s boxes against this list: apiserver (the only thing that talks to etcd), etcd, scheduler, controller-manager — all on the control-plane node; kube-proxy and kindnet (the CNI) on every node, which is why there are several copies. “local-path-provisioner is kind’s default StorageClass — Session 08. And note: it is Pods watching Pods. There is no magic layer underneath.”

  5. One level deeper, for the infra people in the room (optional, 20 s):

    docker exec pinboard-control-plane crictl ps --name kube-apiserver
    

    👉 “crictl is the CRI-level docker ps. When kubelet cannot start a container, this is where the truth is.”


Demo 2: the API, explain, and how to find anything (≈ 5 min)

What to show / say

Kubernetes is an API with a typed schema. You never need to guess a field name — the cluster tells you.

Steps

  1. What this cluster can do:

    kubectl api-resources | head -15
    kubectl api-resources | wc -l
    
    NAME              SHORTNAMES   APIVERSION   NAMESPACED   KIND
    bindings                       v1           true         Binding
    configmaps        cm           v1           true         ConfigMap
    endpoints         ep           v1           true         Endpoints
    events            ev           v1           true         Event
    namespaces        ns           v1           false        Namespace
    nodes             no           v1           false        Node
    persistentvolumeclaims  pvc    v1           true         PersistentVolumeClaim
    pods              po           v1           true         Pod
    …
    62
    

    👉 point at the SHORTNAMES column (“po, ns, svc, deploy — that is where they come from”) and at NAMESPACED (“Nodes and Namespaces are cluster-scoped; -n does nothing for them”). “Sixty-two kinds, and you will use ten.”

  2. The schema, live:

    kubectl explain pod
    kubectl explain pod.spec.containers.resources
    
    KIND:       Pod
    VERSION:    v1
    
    FIELDS:
      apiVersion    <string>
      kind          <string>
      metadata      <ObjectMeta>
      spec          <PodSpec>
      status        <PodStatus>
    

    👉 “apiVersion, kind, metadata, spec, status — every object in Kubernetes has these five. spec is what you want, status is what the cluster observes. The whole system is a loop that tries to make the second match the first.”

  3. The whole tree when you are lost:

    kubectl explain pod.spec --recursive | head -30
    

    👉 “Every field of a PodSpec, from this cluster’s own OpenAPI schema — so it is never out of date with the docs of a different version. Pipe it to grep when you half-remember a field name: kubectl explain pod.spec --recursive | grep -i grace.”


Demo 3: the first Pod (≈ 8 min)

What to show / say

Imperative for exploring, declarative for keeping. Then the four commands you will use every day: get, describe, logs, port-forward.

Steps

  1. Imperative first — one line, no file:

    kubectl apply -f namespace.yaml
    kubectl -n demo run scratch --image=busybox:1.37 --restart=Never -- sleep 300
    kubectl -n demo get pods
    

    👉 “Fast to type, impossible to review, impossible to repeat. Fine for a debug Pod, never for anything that matters. Now the same thing as a file.”

  2. The manifest — read it on screen before applying:

    cat pod.yaml
    kubectl -n demo apply -f pod.yaml
    kubectl -n demo get pods -o wide
    
    NAME               READY   STATUS    RESTARTS   AGE   IP           NODE
    pinboard-api       1/1     Running   0          8s    10.244.1.4   pinboard-worker
    scratch            1/1     Running   0          40s   10.244.2.3   pinboard-worker2
    

    👉 point at the IP (“every Pod gets its own — that is the Kubernetes network model, Session 07”) and at the NODE (“I never said where. The scheduler picked it, using the requests in the file”). Also: imagePullPolicy: IfNotPresent is what makes kind load docker-image work — with Always it would try Docker Hub and fail.

  3. describe — the single most useful command in Kubernetes:

    kubectl -n demo describe pod pinboard-api | tail -25
    
    QoS Class:                   Burstable
    Events:
      Type    Reason     Age   From               Message
      ----    ------     ----  ----               -------
      Normal  Scheduled  35s   default-scheduler  Successfully assigned demo/pinboard-api to pinboard-worker
      Normal  Pulled     34s   kubelet            Container image "pinboard-api:1.0" already present on machine
      Normal  Created    34s   kubelet            Created container: api
      Normal  Started    34s   kubelet            Started container api
    

    👉 read the event list as a story: scheduled → image → created → started. “When a Pod misbehaves, this bottom section answers why nine times out of ten.” Point at QoS Class: Burstable — “requests set, limits not equal to requests. Session 09.”

  4. Logs — the same stdout we watched with docker logs:

    kubectl -n demo logs pinboard-api | tail -3
    
    {"time":"…","level":"WARN","msg":"DATABASE_URL not set — using in-memory store, notes will be lost on restart"}
    {"time":"…","level":"INFO","msg":"pinboard-api starting","version":"1.0","theme":"emerald","store":"memory","addr":":8080"}
    

    👉 “JSON, because the manifest sets LOG_FORMAT=json. Same container, same process, same stdout — only the collector changed.”

  5. Reach it without a Service (there is none yet):

    kubectl -n demo port-forward pod/pinboard-api 8080:8080 &
    sleep 2 && curl -s localhost:8080/api/info | python3 -m json.tool
    kill %1
    
    {
        "app": "pinboard-api",
        "greeting": "Hello from a Pod",
        "hostname": "pinboard-api",
        "store": "memory",
        "version": "1.0"
    }
    

    👉 point at "hostname": "pinboard-api" — “the Pod name is the hostname.” And: “port-forward is a tunnel through the API server to one Pod, for debugging only. It is not how users reach an app — that is Session 07.”

  6. A shell in a Pod that has no shell:

    kubectl -n demo exec -it pinboard-api -- sh
    
    error: Internal error occurred: failed to exec in container: … exec: "sh": executable file not found in $PATH
    

    👉 “Our distroless image has no shell — the thing we celebrated in Session 02 is now inconvenient. kubectl debug -it pinboard-api --image=busybox --target=api attaches a second container with a shell into the same namespaces. That is Session 09; just plant the flag.”


Demo 4: the status zoo — events and ImagePullBackOff (≈ 7 min)

What to show / say

Pods do not fail silently. Watch the events stream, then break one on purpose and read its status like a report.

Steps

  1. Start watching events in the right pane:

    kubectl -n demo get events --watch
    
  2. In the left pane, apply the broken Pod:

    kubectl -n demo apply -f pod-bad-image.yaml
    kubectl -n demo get pods -w
    
    NAME               READY   STATUS         RESTARTS   AGE
    pinboard-api-bad   0/1     Pending        0          0s
    pinboard-api-bad   0/1     ContainerCreating 0       1s
    pinboard-api-bad   0/1     ErrImagePull   0          4s
    pinboard-api-bad   0/1     ImagePullBackOff 0        18s
    

    Ctrl-C the watch. 👉 walk the states: “Pending = the scheduler has not placed it (or cannot). ContainerCreating = kubelet is pulling and mounting. ErrImagePull = the pull failed once. ImagePullBackOff = it is retrying with a growing delay. Note that Kubernetes never gives up — unlike --restart on-failure:3 in Docker.”

  3. The events pane, meanwhile:

    0s   Warning  Failed   pod/pinboard-api-bad   Failed to pull image "pinboard-api:9.9-does-not-exist": failed to resolve reference …
    0s   Warning  Failed   pod/pinboard-api-bad   Error: ErrImagePull
    0s   Normal   BackOff  pod/pinboard-api-bad   Back-off pulling image "pinboard-api:9.9-does-not-exist"
    

    👉 “docker told us this on the terminal. Kubernetes is asynchronous — nobody is watching your terminal, so it writes events instead. Events expire after an hour; that is why describe is your first stop, not your last.”

  4. Read the machine-readable version — the status block from the slide:

    kubectl -n demo get pod pinboard-api-bad -o yaml | sed -n '/^status:/,$p' | head -25
    
    status:
      conditions:
      - lastTransitionTime: "…"
        status: "True"
        type: PodScheduled
      - status: "False"
        type: Ready
        reason: ContainersNotReady
      containerStatuses:
      - image: pinboard-api:9.9-does-not-exist
        name: api
        ready: false
        restartCount: 0
        started: false
        state:
          waiting:
            message: 'Back-off pulling image "pinboard-api:9.9-does-not-exist"'
            reason: ImagePullBackOff
      phase: Pending
    

    👉 “This is the half of the object you never write. spec is your intent, status is the cluster reporting back — including exactly why it cannot comply. Every controller in the next four sessions works by reading a status like this and acting on it.”

  5. Targeted queries instead of eyeballing YAML:

    kubectl -n demo get pod pinboard-api-bad \
      -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}{"\n"}'
    kubectl -n demo get events --sort-by=.lastTimestamp | tail -5
    

    👉 “-o jsonpath for scripts, --sort-by=.lastTimestamp because the default event order is not chronological. Both come back in Session 09’s troubleshooting method.”


Demo 5: a Pod with more than one container (≈ 6 min)

What to show / say

A Pod is not “a container”. It is a group that shares a network namespace and volumes — which is what init containers and sidecars are built on.

Steps

  1. Read the file, pointing at the three containers before applying:

    kubectl -n demo apply -f pod-sidecar.yaml
    kubectl -n demo get pod pinboard-api-sidecar -w
    
    NAME                   READY   STATUS     RESTARTS   AGE
    pinboard-api-sidecar   0/2     Init:0/2   0          1s
    pinboard-api-sidecar   0/2     Init:1/2   0          3s
    pinboard-api-sidecar   1/2     Running    0          5s
    pinboard-api-sidecar   2/2     Running    0          7s
    

    👉 READY 2/2, not 1/1: a restartable init container (the sidecar) counts as a container of the Pod, while init-marker, which runs to completion, does not.

    👉 “Init:0/2 → Init:1/2: the first init container ran and exited, and only then did the next one start. Ordered, sequential, before the app.”

  2. Who is in there:

    kubectl -n demo get pod pinboard-api-sidecar \
      -o jsonpath='{range .spec.initContainers[*]}init: {.name} restartPolicy={.restartPolicy}{"\n"}{end}{range .spec.containers[*]}app:  {.name}{"\n"}{end}'
    
    init: init-marker restartPolicy=
    init: poller restartPolicy=Always
    app:  api
    

    👉 the one line that matters: “poller is declared under initContainers, but with restartPolicy: Always. That makes it a native sidecar: it starts before the app containers, it keeps running next to them, it is restarted if it dies, and it is shut down only after the app container. Before Kubernetes 1.29 you had to fake this with an ordinary container and it broke Jobs, because the Pod never finished.”

  3. Proof of the shared network namespace:

    kubectl -n demo logs pinboard-api-sidecar -c poller --tail 3
    
    api not answering yet
    {"app":"pinboard-api","greeting":"Hello from a multi-container Pod","hostname":"pinboard-api-sidecar","store":"memory","theme":"emerald","uptime":"12s","version":"1.0"}
    

    👉 “The sidecar called 127.0.0.1:8080 and reached the API container. Two containers, one network namespace, one IP — exactly the /proc/<pid>/ns picture from Session 01, only now Kubernetes builds the group for us. Also note the first line: the sidecar was up before the API. That is expected, and it is why sidecars must tolerate the app not being there yet.”

  4. Proof of the shared volume:

    kubectl -n demo exec pinboard-api-sidecar -c poller -- head -2 /shared/probe.log
    
    pod started at 2026-03-04T09:12:31Z
    {"app":"pinboard-api",…}
    

    👉 “Line one was written by init-marker, which exited before the sidecar started. The emptyDir volume is the Pod’s — it outlives individual containers and dies with the Pod. That is the log-shipper pattern, the config-renderer pattern and the service-mesh proxy pattern, all in eight lines of YAML.”

  5. Wrap up into the lab:

    kubectl -n demo get pods
    

    👉 “In the lab you will do this in the pinboard namespace with your own image — and you will notice that deleting a Pod is final. Nothing recreates it. Session 06 is about the controller that does.”


Reset

Lab 05 expects: the pinboard cluster running, pinboard-api:1.0 loaded into it, and no pinboard namespace (students create it).

cd ~/docker-kubernetes-training/demos/session05

kubectl delete -f pod-sidecar.yaml -f pod-bad-image.yaml -f pod.yaml --ignore-not-found
kubectl -n demo delete pod scratch --ignore-not-found
kubectl delete namespace demo --ignore-not-found        # removes everything above at once
kubectl delete namespace pinboard --ignore-not-found    # in case an earlier run created it

Verify:

kubectl get nodes                       # 3 Ready
kubectl get ns                          # no demo, no pinboard
docker exec pinboard-control-plane crictl images | grep pinboard-api   # 1.0 still loaded

Only if the cluster itself is broken: kind delete cluster --name pinboard and re-run the Before the session block. Never do this between the demo and the lab without warning the room — every student’s kubeconfig context is invalidated.