Swarm gave you a cluster with just enough ideas and a gentle learning curve. Kubernetes gives you the industry standard in exchange for a new vocabulary. The good news is that you already have the concepts: desired state, reconciliation, replicas, networking between nodes and secrets mounted as files. This lesson puts new names on familiar ideas and adds the ones that are missing, without deploying the whole of Aurora Libros just yet.

Contents

  1. Why Kubernetes exists and what it adds
  2. The declarative model and controllers
  3. Cluster architecture
  4. The runtime after dockershim: CRI and OCI
  5. The fundamental objects at a glance
  6. Pod: the unit of deployment
  7. ReplicaSet and Deployment
  8. Service, its types and Ingress
  9. ConfigMap and Secret
  10. Namespace
  11. PersistentVolume, PersistentVolumeClaim and StorageClass
  12. Job, CronJob, StatefulSet and DaemonSet
  13. Anatomy of a manifest: labels and selectors
  14. Essential kubectl, translated from Docker
  15. A local cluster to practice on
  16. Docker → Kubernetes glossary

  1. Why Kubernetes exists and what it adds

Kubernetes was born at Google out of the experience with Borg and was donated to the CNCF in 2015. Its goal was not to improve on Swarm but to solve orchestration at the scale of thousands of nodes and teams, which is why its design is more ambitious and also more complex.

Capability Swarm Kubernetes
Minimum unit One container per task Pod: several tightly coupled containers
Extensibility Fixed CRDs and operators: objects of your own
Autoscaling No HPA, VPA and node autoscaling (06-06)
Storage Local volumes or plugins CSI, PVCs and dynamic provisioning
Access control Roles: manager/worker RBAC by user, resource and verb
HTTP routing Nginx you set up yourself Native Ingress and Gateway API
Stateful workloads No specific support StatefulSet with a stable identity
Learning curve Days Weeks
Ecosystem Small Enormous: Helm, Argo, Prometheus, meshes

The last row is what decides it in most organizations. The first is what changes your way of thinking the most, and it is the one we will look at in depth.

  1. The declarative model and controllers

The conceptual core is identical to Swarm's: you describe the desired state and the system maintains it. The difference is that Kubernetes generalizes it: everything is an object in a database (etcd), and for every type of object there is a controller running the same loop.

observe the actual state → compare it with the desired one → act to close the gap → repeat

That pattern is called the reconciliation loop, and it explains behaviors that are baffling at first:

  • You delete a Pod created by a Deployment and it comes back: its controller sees that replicas are missing.
  • You edit a Pod by hand and your change disappears at the next deployment: the source of truth is the Deployment's template.
  • You apply a manifest twice and nothing happens: you are not describing actions, you are describing destinations. The operation is idempotent.

  1. Cluster architecture

flowchart TB
    U["kubectl / CI"] -->|REST API| API
    subgraph CP["Control plane"]
        API[kube-apiserver] <--> ETCD[(etcd)]
        SCH[kube-scheduler] --> API
        CM[kube-controller-manager] --> API
    end
    API --> K1[kubelet · node-1]
    API --> K2[kubelet · node-2]
    K1 --> R1["containerd (CRI) → runc"]
    K2 --> R2["containerd (CRI) → runc"]
    style API fill:#e8f0fe,stroke:#3367d6
Component Where Responsibility
kube-apiserver Control plane The only way in; it validates, authenticates and writes to etcd
etcd Control plane A key-value database holding all the state. Its backup is the cluster's backup
kube-scheduler Control plane Decides which node each new Pod goes on based on resources, affinities and taints
kube-controller-manager Control plane Runs the controllers (Deployment, ReplicaSet, Node...)
kubelet Every node Talks to the runtime to make the assigned Pods exist and reports their state
kube-proxy Every node Programs the network rules that make Services work
Runtime (CRI) Every node Runs the containers: containerd, CRI-O

Notice the property that makes the design robust: nobody talks to anybody except the kube-apiserver. The scheduler does not call the kubelet; it writes into the API that a Pod belongs on a certain node, and the kubelet on that node, which is watching the API, acts. Everything goes through the same point of authentication and auditing.

  1. The runtime after dockershim: CRI and OCI

In 2022, Kubernetes 1.24 removed dockershim, the adapter that let it use Docker Engine as a runtime. That produced "Kubernetes drops Docker" headlines that confused everybody.

What actually happened is far less dramatic:

  • Kubernetes talks to runtimes through an interface called CRI (Container Runtime Interface).
  • Docker Engine does not implement CRI, so an adapter (dockershim) maintained by the project itself was needed.
  • That adapter was retired, and nodes use containerd or CRI-O directly, both of which do speak CRI. And containerd is, precisely, the same component Docker Engine has been using internally since 01-03.

Your images are still exactly as valid as before. What you build with docker build is an OCI image, a standard format, not a "Docker" format. The ghcr.io/auroralibros/aurora-api:2.0.0 your pipeline produced runs on containerd with no modification whatsoever. The only thing that changed is which program starts it on the node.

  1. The fundamental objects at a glance

Object What for Do you create it by hand?
Pod One or several containers sharing network and storage Almost never
ReplicaSet Keeps N identical Pods No: the Deployment creates it
Deployment Manages ReplicaSets and rolling updates Yes
Service A stable IP and DNS name in front of a set of Pods Yes
Ingress Inbound HTTP/HTTPS routing by host and path Yes
ConfigMap Non-sensitive configuration Yes
Secret Sensitive configuration (base64-encoded) Yes
Namespace A logical name space inside the cluster Yes
PersistentVolumeClaim A request for persistent storage Yes
StorageClass How that storage is provisioned The cluster supplies it
Job / CronJob A task that finishes / a periodic task Yes
StatefulSet Pods with a stable identity and disk Yes
DaemonSet One Pod per node Yes

  1. Pod: the unit of deployment

A Pod is a group of containers that share a network namespace (same IP, they talk over localhost), an IPC namespace and, optionally, volumes. It is the unit the scheduler places and the unit that scales: in Kubernetes you do not replicate containers, you replicate Pods.

apiVersion: v1
kind: Pod
metadata:
  name: aurora-cache
  labels: { app: aurora-cache }
spec:
  containers:
    - name: redis
      image: redis:7-alpine
      args: ["--maxmemory", "200mb", "--maxmemory-policy", "allkeys-lru"]
      ports: [{ containerPort: 6379 }]
      resources:
        requests: { memory: 64Mi, cpu: 50m }
        limits:   { memory: 256Mi, cpu: 500m }

You almost never create a Pod by hand, for one compelling reason: a Pod does not recover. If the node dies, the Pod dies with it and nobody recreates it; it is the exact equivalent of a docker run without --restart. What you create is a Deployment, which through its ReplicaSet guarantees there are always as many Pods as you asked for.

Most Pods have a single container. The multi-container case is the sidecar pattern: a helper container accompanying the main one —a log collector, a service mesh proxy, a file synchronizer— that benefits from sharing the network and volumes.

  1. ReplicaSet and Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: aurora-cache
spec:
  replicas: 1
  selector:
    matchLabels: { app: aurora-cache }     # which Pods belong to me
  template:                                # the Pod template (no apiVersion, no kind)
    metadata:
      labels: { app: aurora-cache }        # MUST match the selector
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports: [{ containerPort: 6379 }]

The chain of responsibility has three links, and understanding it avoids a lot of confusion:

Deployment  →  ReplicaSet  →  Pods
(versions)     (how many)     (processes)

The ReplicaSet only knows how to count: it keeps N Pods matching its selector. The Deployment manages ReplicaSets: when the image changes it creates a new one, fills it while draining the old one, and keeps the old one at zero replicas so the change can be undone. That empty ReplicaSet you see in kubectl get rs is not garbage: it is your rollback history (06-07).

A warning: a Deployment's selector is immutable. If you change it, the object has to be deleted and recreated. It is worth choosing the labels well the first time.

  1. Service, its types and Ingress

Pods are cattle, not pets: they are born and die with different IPs. A Service provides a stable virtual IP and DNS name in front of a changing set of Pods, selected —again— by labels.

apiVersion: v1
kind: Service
metadata:
  name: aurora-cache
spec:
  type: ClusterIP
  selector: { app: aurora-cache }    # any Pod with this label joins the balancing
  ports:
    - port: 6379          # the Service's port
      targetPort: 6379    # the container's port

Pods reach it over DNS as aurora-cache inside the namespace, or aurora-cache.aurora.svc.cluster.local from outside it: the equivalent of the DNS-by-service-name you had in Compose and Swarm.

Type What it exposes Reach Use in Aurora Libros
ClusterIP (default) An internal virtual IP Inside the cluster only aurora-db, aurora-cache, aurora-api
NodePort A port (30000-32767) on every node External, crude Local testing
LoadBalancer A cloud provider load balancer External, one per service aurora-web only
ExternalName A CNAME to an external domain Migrating to a managed DB
Headless (clusterIP: None) No virtual IP: DNS to each Pod Internal aurora-db in a StatefulSet

One LoadBalancer per service gets expensive fast: each one is a billed load balancer at the provider. The standard answer is an Ingress: a single entry point that routes by host and path towards several Services.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: aurora
  annotations: { nginx.ingress.kubernetes.io/proxy-body-size: "8m" }
spec:
  ingressClassName: nginx            # which controller serves it
  rules:
    - host: libros.aurora.example
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend: { service: { name: aurora-api, port: { number: 3000 } } }
          - path: /
            pathType: Prefix
            backend: { service: { name: aurora-web, port: { number: 80 } } }

A detail that throws people the first time: the Ingress object does nothing on its own. It is a statement of intent that needs an ingress controller installed in the cluster (ingress-nginx, Traefik, HAProxy...) to read it and configure the real proxy. Without a controller, the object exists, raises no error and the traffic goes nowhere.

  1. ConfigMap and Secret

apiVersion: v1
kind: ConfigMap
metadata: { name: aurora-config }
data:
  DB_HOST: aurora-db
  DB_NAME: aurora_books
  CACHE_TTL: "60"
  SHUTDOWN_TIMEOUT_MS: "15000"
---
apiVersion: v1
kind: Secret
metadata: { name: aurora-secrets }
type: Opaque
stringData:                          # stringData accepts plain text; Kubernetes encodes it
  DB_PASSWORD: aurora-dummy-secret

Both are consumed the same way, and there are two forms with different implications:

      envFrom:                                   # every key as a variable
        - configMapRef: { name: aurora-config }
      env:                                       # one specific key
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef: { name: aurora-secrets, key: DB_PASSWORD }
      volumeMounts:                              # or as files: the _FILE pattern from 06-01
        - { name: secrets, mountPath: /run/secrets, readOnly: true }

Important warning about Secret. A Kubernetes Secret is in base64, which is not encryption: anybody with read permission on the object sees the value with a base64 -d. On top of that, etcd does not encrypt at rest unless that is explicitly enabled. For production you have to enable encryption at rest, restrict access with RBAC and consider an external manager (Vault, Sealed Secrets, your cloud provider's secret manager via the CSI driver). This is a decision you must agree with the security officer in your organization, not an acceptable default.

And one practical difference from Swarm: mounted as a volume, an updated Secret or ConfigMap propagates to the Pod's file without restarting it (with a delay of up to a minute); injected as an environment variable, it never updates until the Pod is recreated.

  1. Namespace

apiVersion: v1
kind: Namespace
metadata:
  name: aurora
  labels: { environment: production }

A Namespace is a logical partition of the cluster. It gives you three things: names that do not collide (there can be an aurora-api in aurora and another in aurora-staging), a scope for RBAC and quotas, and a network boundary if you use NetworkPolicy. It is not a strong security boundary in itself: it isolates the API, not the kernel.

With kubectl config set-context --current --namespace=aurora you save yourself typing -n aurora on every command.

  1. PersistentVolume, PersistentVolumeClaim and StorageClass

Kubernetes separates who asks for storage from who provides it, which is exactly what Swarm was missing.

Object Who writes it What it says
StorageClass The platform How the disk is created (type, deletion policy)
PersistentVolumeClaim (PVC) You "I want 10 GiB with ReadWriteOnce access"
PersistentVolume (PV) The provisioner The real disk, already created
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: aurora-data }
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: standard
  resources: { requests: { storage: 10Gi } }
Access mode Meaning Who supports it
ReadWriteOnce (RWO) Writing from one node Block disks: EBS, PD, iSCSI
ReadOnlyMany (ROX) Reading from several nodes NFS, object storage
ReadWriteMany (RWX) Writing from several nodes NFS, CephFS, EFS

Almost every cloud provider's block disk is RWO, and from that comes a very real limit: a PVC of that kind cannot be mounted simultaneously in Pods on different nodes. It is the technical reason aurora-db does not scale by replicating Pods, and we will deal with it in 06-06.

  1. Job, CronJob, StatefulSet and DaemonSet

Object Guarantee In Aurora Libros
Job Runs until it completes successfully N times Schema migration before a deployment
CronJob Creates Jobs on a cron expression The nightly pg_dump backup
StatefulSet A stable name and identity (-0, -1), its own PVC per Pod, ordered startup and shutdown aurora-db
DaemonSet One Pod on every node (and on any that get added) Promtail, node-exporter

The DaemonSet is literally Swarm's mode: global. The StatefulSet is what Swarm did not have: each replica gets a stable name and its own volume, survives a restart with the same identity and starts and stops in order. It is what anything stateful needs, and you will use it in 06-05.

  1. Anatomy of a manifest: labels and selectors

Every object shares four top-level fields:

Field What it is Example
apiVersion API group and version apps/v1, v1, networking.k8s.io/v1
kind The object type Deployment, Service
metadata Name, namespace, labels, annotations name: aurora-api
spec The desired state replicas: 3
status The actual state (written by the system, not by you) readyReplicas: 3

And now the most important thing in the whole lesson. In Kubernetes objects are not referenced by name, they are referenced by labels. A Service does not say "send traffic to the Pods of the aurora-api Deployment"; it says "send traffic to every Pod with the label app: aurora-api".

flowchart LR
    S["Service<br/>selector: app=aurora-api"] -.->|selects| P1["Pod app=aurora-api"]
    S -.-> P2["Pod app=aurora-api"]
    S -.-x P3["Pod app=aurora-web"]
    D["Deployment<br/>selector: app=aurora-api"] -->|creates| P1
    D --> P2

That loose coupling is both powerful and fragile. Powerful because it allows things like directing traffic to Pods from two different Deployments at once, which is the basis of the blue-green and canary deployments in 06-07. Fragile because a typo in a label produces no error at all: the Service simply finds no Pods, ends up with no endpoints and the traffic returns 503. It is the most common silent failure, and it is always diagnosed the same way: kubectl get endpoints <service>.

The labels recommended by the community, which are worth adopting from the start:

  labels:
    app.kubernetes.io/name: aurora-api
    app.kubernetes.io/component: backend
    app.kubernetes.io/part-of: aurora-libros
    app.kubernetes.io/version: "2.0.0"

  1. Essential kubectl, translated from Docker

Task Docker / Compose kubectl
Create or update docker compose up -d kubectl apply -f manifests/
List docker ps kubectl get pods
See everything docker compose ps kubectl get all -n aurora
Detail and events docker inspect kubectl describe pod <p>
Logs docker logs -f kubectl logs -f <p>
Logs from the previous attempt kubectl logs <p> --previous
A shell inside docker exec -it c sh kubectl exec -it <p> -- sh
Reach a port -p 8080:3000 kubectl port-forward svc/aurora-api 8080:3000
Delete docker compose down kubectl delete -f manifests/
Scale docker service scale kubectl scale deploy/aurora-api --replicas=3
Resource usage docker stats kubectl top pods
Query the schema docker run --help kubectl explain deployment.spec.strategy
kubectl get pods -o wide                       # adds node and IP
kubectl get deploy aurora-api -o yaml          # the whole object, with its status
kubectl get pods -o jsonpath='{.items[*].spec.containers[*].image}'
kubectl get pods -w                            # watch changes live
kubectl describe pod aurora-api-7d9f -n aurora # the Events section is 80 % of the diagnosis

Two commands deserve highlighting. kubectl explain is the official documentation without leaving the terminal, and it works for any field of any object, including ones you install later. And kubectl describe ends with a list of events that almost always contains the exact cause of the problem: why the scheduler cannot find a node, why the image pull fails or why a probe is not passing.

  1. A local cluster to practice on

Tool How it works Startup Multi-node Advantage
kind Nodes as Docker containers ~30 s Yes, trivially Ideal for CI; loads local images
minikube A VM or a container ~60 s Limited Built-in addons (Ingress, dashboard)
k3d k3s (lightweight Kubernetes) in containers ~20 s Yes The lightest; very fast
Docker Desktop A built-in single-node cluster One click No Zero extra installation
# kind-aurora.yaml — three nodes and port 80 mapped for the Ingress
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs: { node-labels: "ingress-ready=true" }
    extraPortMappings:
      - { containerPort: 80, hostPort: 8080, protocol: TCP }
  - role: worker
  - role: worker
kind create cluster --name aurora --config kind-aurora.yaml
kubectl get nodes
# NAME                   STATUS  ROLES          AGE  VERSION
# aurora-control-plane   Ready   control-plane  48s  v1.31.0
# aurora-worker          Ready   <none>         31s  v1.31.0
# aurora-worker2         Ready   <none>         31s  v1.31.0

# First contact, with the simplest service in Aurora Libros
kubectl create namespace aurora
kubectl run aurora-cache --image=redis:7-alpine -n aurora --port=6379 --labels=app=aurora-cache
kubectl exec -it aurora-cache -n aurora -- redis-cli ping     # PONG

  1. Docker → Kubernetes glossary

What you already know In Kubernetes Nuance
Container Pod A Pod can hold several containers
compose.yaml YAML manifests One per object, or several separated by ---
docker compose up -d kubectl apply -f . Declarative in both cases
docker compose down kubectl delete -f .
The Compose network / DNS by name Service The DNS comes from the Service, not the network
A published port Service + Ingress Exposing and routing are separated
A named volume PVC + StorageClass It is requested, not created
Swarm's deploy.replicas The Deployment's spec.replicas Identical concept
mode: global DaemonSet Identical
A Swarm secret Secret Base64, not encrypted by default
docker service ps kubectl get pods + describe The events are in describe
healthcheck livenessProbe + readinessProbe + startupProbe The three from 06-01
A Compose project Namespace A name space

Common Mistakes and Tips

  • Creating standalone Pods. Nobody recreates them if the node dies. Except for a thirty-second test, always use a Deployment.
  • A selector and labels that do not match. The most frequent and the most silent error: the Deployment creates Pods its Service cannot find. Check with kubectl get endpoints.
  • Believing a Secret is encrypted. It is base64. Turn on encryption at rest and protect it with RBAC.
  • Expecting an Ingress to work with no controller. The object gets created without complaint and routes nothing.
  • Forgetting the namespace. kubectl get pods looks at the default namespace and it seems as if there is nothing there. Set the context or use -n.
  • Defining limits without requests. Kubernetes copies the limit into the request and you reserve far more than you need.
  • Editing Pods with kubectl edit. The change lasts until the next deployment. Modify the Deployment instead.
  • Tip: kubectl describe before kubectl logs. If the Pod does not start, there are no logs; the cause is in the events.
  • Tip: kubectl apply --dry-run=server -f . validates the manifests against the real API without creating anything. Use it in your pipeline.

Exercises

Exercise 1. Set up a three-node kind cluster, deploy aurora-cache with a Deployment and its ClusterIP Service, and check from another Pod that the name aurora-cache resolves and responds.

Exercise 2. Demonstrate the reconciliation loop and the difference between a standalone Pod and a Deployment: create a Pod by hand and another managed by a Deployment, delete both and observe what happens to each.

Exercise 3. Trigger the silent label failure on purpose: change a Service's selector so it matches no Pod and diagnose it without looking at the manifest.

Solutions

Solution 1.

kind create cluster --name aurora --config kind-aurora.yaml
kubectl create namespace aurora
kubectl config set-context --current --namespace=aurora
kubectl apply -f k8s/cache.yaml     # the Deployment and Service from sections 7 and 8
kubectl get deploy,rs,pods,svc
# deployment.apps/aurora-cache             1/1  1  1                   12s
# replicaset.apps/aurora-cache-6b8d4c9f7   1    1  1                   12s
# pod/aurora-cache-6b8d4c9f7-x2klp         1/1  Running  0             12s
# service/aurora-cache  ClusterIP  10.96.184.22  <none>  6379/TCP      12s

A single output contains the whole chain from section 7: a Deployment that created a ReplicaSet whose name carries the suffix 6b8d4c9f7 —the hash of the Pod template—, and that ReplicaSet created a Pod that inherits the hash and adds a random suffix. That hash is the key piece of the rolling update: changing the image produces a different hash and therefore a new ReplicaSet.

kubectl run client --rm -it --image=redis:7-alpine --restart=Never -- sh -c '
  nslookup aurora-cache | tail -2
  redis-cli -h aurora-cache ping
  redis-cli -h aurora-cache.aurora.svc.cluster.local set book "Rayuela"'
# Name:    aurora-cache.aurora.svc.cluster.local
# Address: 10.96.184.22
# PONG
# OK

The short name and the full one resolve to the same IP, which is the Service's virtual IP, not the Pod's. That distinction is what lets Pods die and be reborn with different addresses without anybody having to find out: the client always talks to the Service. It is the same service the internal DNS of Compose and Swarm gave you, with one important difference: here it is provided by an explicit object you can inspect and modify.

Solution 2.

kubectl run standalone --image=redis:7-alpine --labels=app=test
kubectl create deployment managed --image=redis:7-alpine --replicas=2
kubectl get pods -o custom-columns=NAME:.metadata.name,OWNER:.metadata.ownerReferences[0].kind
# NAME                      OWNER
# standalone                <none>
# managed-5f7c8d9b4-hq2vn   ReplicaSet
# managed-5f7c8d9b4-t8plw   ReplicaSet

kubectl delete pod standalone managed-5f7c8d9b4-hq2vn && sleep 5 && kubectl get pods
# NAME                      READY  STATUS   RESTARTS  AGE
# managed-5f7c8d9b4-t8plw   1/1    Running  0         2m
# managed-5f7c8d9b4-vk9xz   1/1    Running  0         5s

You deleted two Pods and only one came back, with a different name and five seconds of age. The OWNER column explains exactly why: the standalone Pod has no ownerReferences, so when you deleted it nobody missed anything; the others belong to a ReplicaSet whose controller watches the API, saw it had one Pod where there should be two and created a new one.

That is the practical reason behind the rule in section 6. A standalone Pod is equivalent to a docker run with no restart policy: it survives as long as nothing bothers it, and it disappears along with its node. The detail of the different name matters too, because it reveals that the Pod is not "restarted": another one is created, with another IP, and that is why no configuration can depend on the identity of one specific Pod. When that identity is genuinely needed —as with aurora-db— there is the StatefulSet.

Solution 3.

kubectl patch svc aurora-cache -p '{"spec":{"selector":{"app":"aurora-cach"}}}'   # typo
kubectl run client --rm -it --image=redis:7-alpine --restart=Never -- \
  redis-cli -h aurora-cache -t 3 ping
# Could not connect to Redis at aurora-cache:6379: Connection refused

The diagnosis, without opening a single file:

kubectl get endpoints aurora-cache
kubectl get svc aurora-cache -o jsonpath='{.spec.selector}'; echo
kubectl get pods --show-labels
NAME           ENDPOINTS   AGE
aurora-cache   <none>      14m
{"app":"aurora-cach"}
aurora-cache-6b8d4c9f7-x2klp   1/1  Running  app=aurora-cache,pod-template-hash=6b8d4c9f7

The ENDPOINTS reading <none> is the decisive symptom, and the three-command sequence is the one to memorize: the Service exists, it has its virtual IP, the DNS resolves perfectly... and there is not one Pod behind it. Comparing the selector (app=aurora-cach) with the actual labels (app=aurora-cache) reveals the missing letter.

kubectl patch svc aurora-cache -p '{"spec":{"selector":{"app":"aurora-cache"}}}'
kubectl get endpoints aurora-cache
# aurora-cache   10.244.1.7:6379   15m

What is instructive about the exercise is what did not happen: neither the kubectl patch nor the kubectl get svc nor the Deployment's describe gave the slightest warning. Kubernetes does not validate that a selector finds anything, because a Service that does not yet have Pods is a legitimate situation —it will have them once they are deployed. The price of that loose coupling, which in 06-07 will let you switch traffic between two versions by changing a label, is that a typo shows up as a 503 in production rather than as an error when applying the manifest.

Hence the operating rule: after every kubectl apply that touches a Service, check its endpoints. It is one command and it avoids the most frustrating class of incident on this platform.

Conclusion

You speak Kubernetes now. You know its heart is the same reconciliation loop as Swarm's, generalized to every object in etcd, and that this explains why a deleted Pod comes back and why a repeated apply does nothing. You know the architecture —kube-apiserver as the only door, etcd as the state, the scheduler, the controllers, and on every node the kubelet and the kube-proxy— and the property that makes it robust: nobody talks to anybody except the API. And you have the dockershim misunderstanding settled: Kubernetes runs containerd over CRI, but your images are just as valid because they are OCI.

You can handle the catalog of objects with their minimal YAML: the Pod as the unit and why you do not create one by hand, the Deployment → ReplicaSet → Pods chain where the empty ReplicaSet is your rollback history, the Service with its five variants and the Ingress that routes nothing without an installed controller, ConfigMap and Secret —with the warning that base64 is not encryption—, the Namespace, the PVC/PV/StorageClass triad with the access modes that will explain in 06-06 why a database cannot simply be replicated, and the four remaining controllers, among them the DaemonSet that is Swarm's mode: global and the StatefulSet that Swarm did not have.

Above all, you have internalized the central piece: labels and selectors. Everything connects by labels, never by name, and you proved it by breaking a selector on purpose to see the platform's most silent failure —a perfectly healthy Service with ENDPOINTS <none>— and diagnosing it with three commands. You can translate each Docker operation into its kubectl equivalent, you have a three-node kind cluster with aurora-cache running inside it, and a glossary that turns what you already knew into what you have just learned.

In the next lesson, Deploying Docker Containers in Kubernetes, the whole application arrives. You will write the real manifests for the four services: the StatefulSet for aurora-db with its volumeClaimTemplates and its headless Service, the Deployment for aurora-api with the three probes from 06-01, its hardened securityContext and its injected configuration, the Ingress for aurora-web, and you will organize them with Kustomize into bases and per-environment overlays, along with the table of Pod states —Pending, ImagePullBackOff, CrashLoopBackOff, OOMKilled— and the diagnostic command for each, ready for when something refuses to start.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved