The Code Codex
Theme
← Guides

Kubernetes

Why Kubernetes exists, the declarative model and reconciliation, the cluster architecture, what happens on a kubectl apply, the main objects, networking, configuration, probes, resources and scaling, with a local lab.

31 Jan 2025· 12 min· 17 sections

In this guide · 17 sections

Why Kubernetes

Running a container on one machine is simple. Running hundreds of them on dozens of machines raises questions Docker alone doesn’t answer:

  • Which machine should each container run on, given free CPU and memory?
  • Who restarts a container that crashed, or moves the containers off a machine that died?
  • How do you ship a new version without taking down the old one, and roll back if it goes wrong?
  • How do services find each other, if containers change addresses all the time?
  • How do you scale from 3 to 30 replicas when traffic goes up?

Kubernetes (or K8s) is an orchestrator: a system that answers those questions automatically, from a description of what you want.

The declarative model: desired state and reconciliation

This is the most important idea in Kubernetes, and it explains everything else. You don’t give commands (“start 3 containers”); you declare a state (“I want 3 replicas of this image”), and Kubernetes works continuously to make reality match it.

different

same

Desired state

replicas: 3

Compares with

the current state

Observes the cluster

2 pods running

Acts

creates 1 pod

Waits for the

next change

different

same

Desired state

replicas: 3

Compares with

the current state

Observes the cluster

2 pods running

Acts

creates 1 pod

Waits for the

next change

This cycle is called the reconciliation loop, and every part of Kubernetes is a controller running its own. The consequences are what make the system resilient:

  • Self-healing: if a pod dies, the current state (2) differs from the desired one (3), and a new pod is created. Nobody needs to “tell” it to restart.
  • Idempotency: applying the same file ten times gives the same result as applying it once.
  • Infrastructure as code: the desired state lives in YAML files versioned in git, reviewed in pull requests like any code.

Cluster architecture

Node 2

Node 1

Control plane: the cluster's brain

kubectl / CI

kube-apiserver

etcd

kube-scheduler

controller-manager

kubelet

Pod

kube-proxy

kubelet

Pod

kube-proxy

Node 2

Node 1

Control plane: the cluster's brain

kubectl / CI

kube-apiserver

etcd

kube-scheduler

controller-manager

kubelet

Pod

kube-proxy

kubelet

Pod

kube-proxy

Control plane

Manages the cluster; in production, it runs on more than one machine so it isn’t a single point of failure. In managed services (EKS, GKE, AKS), the provider takes care of it.

  • kube-apiserver: the front door. Every read and change to the cluster goes through it, which authenticates, validates and stores the state. The other components don’t talk to each other: they all talk to the apiserver.
  • etcd: a consistent, highly available key-value store with the whole cluster state. It is the brain’s “memory”, and what needs backups.
  • kube-scheduler: decides which node each new pod runs on, based on the requested resources and constraints.
  • controller-manager: runs the reconciliation loops (Deployments, ReplicaSets, nodes, jobs…).
Nodes

The machines that run the applications, comparable to VMs in the cloud. Each one exposes CPU, memory, network and storage.

  • kubelet: the agent on each node. It receives from the apiserver the list of pods for that node and makes sure the containers are running as requested.
  • kube-proxy: handles the node’s networking, forwarding Service traffic to the right pods.
  • Container runtime: what actually runs the containers (containerd, CRI-O). Docker stopped being supported directly as a runtime in version 1.24, but images built with it keep working.

What happens on a kubectl apply

Putting the pieces together: the path from a new Deployment to a running container is a chain of controllers, each reacting to a change in the apiserver.

kubelet (node 2)SchedulerReplicaSet controllerDeployment controllerapiserver + etcdkubectlkubelet (node 2)SchedulerReplicaSet controllerDeployment controllerapiserver + etcdkubectlapplies Deployment (replicas: 3)new Deploymentcreates ReplicaSetnew ReplicaSetcreates 3 Pods (no node)Pods without a nodeassigns each Pod to a nodePod assigned to this nodepulls the image and starts the containerstatus: Running
kubelet (node 2)SchedulerReplicaSet controllerDeployment controllerapiserver + etcdkubectlkubelet (node 2)SchedulerReplicaSet controllerDeployment controllerapiserver + etcdkubectlapplies Deployment (replicas: 3)new Deploymentcreates ReplicaSetnew ReplicaSetcreates 3 Pods (no node)Pods without a nodeassigns each Pod to a nodePod assigned to this nodepulls the image and starts the containerstatus: Running

Nobody orchestrates the sequence: each controller only watches the kind of object it cares about and reacts. That is why the system recovers on its own from failures midway: when a component comes back, it observes the state and carries on from where it stopped.

Lab: a local cluster

With Docker installed, kind (Kubernetes in Docker, see the references) creates a real cluster inside containers:

Terminal window
brew install kind # or the binary from kind.sigs.k8s.io
kind create cluster
# 3 nginx replicas and a Service in front of them
kubectl create deployment web --image=nginx --replicas=3
kubectl expose deployment web --port=80
kubectl get pods -o wide
# open http://localhost:8080 in the browser
kubectl port-forward service/web 8080:80

Now watch reconciliation happen. In another terminal, delete a pod and watch another one come up in its place:

Terminal window
kubectl get pods --watch
kubectl delete pod <name-of-a-pod>
kubectl scale deployment web --replicas=5

To clean everything up: kind delete cluster.

The main objects

ObjectWhat for
PodThe smallest unit: one or more containers that share network and volumes
DeploymentKeeps N replicas of a stateless pod and rolls out updates gradually (via ReplicaSets)
StatefulSetLike a Deployment, but with stable identity and disk per replica (databases, queues)
DaemonSetOne pod on every node (log collectors, monitoring agents)
Job / CronJobTasks that finish: once, or on a schedule (migrations, reports)
ServiceA stable address in front of a group of pods
Gateway / IngressHTTP entry from outside the cluster, with routing by domain and path
ConfigMap / SecretConfiguration and sensitive data, kept separate from the image
PersistentVolumeClaimA request for a disk that outlives the pod
NamespaceLogical separation inside the cluster (by team, environment or application)

They are all declared the same way: a YAML with apiVersion, kind, metadata and spec (the desired state). Kubernetes adds the status (the current state).

Pods

A pod groups containers that must always be together on the same node: they share the same IP (they talk over localhost) and can share volumes. Most of the time, one container per pod.

  • Init containers run before the main one and must finish successfully (e.g. wait for the database, run a migration).
  • Sidecars run alongside the main container for the pod’s whole life (e.g. a service mesh proxy, a log collector). Since version 1.33 they are a stable feature, with guaranteed start and stop order.

Pods are disposable: they aren’t “fixed”, they are replaced, and every new pod gets a different IP. That is why nobody talks to pods directly, but to Services. It is also why the application should be stateless, as The Twelve-Factor App asks.

Deployments and updates

A complete Deployment, with the fields that matter in production:

apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3 # desired state: 3 pods
selector:
matchLabels: { app: api } # which pods belong to this Deployment
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template: # the template for each pod
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: ghcr.io/acme/api:1.4.0
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 512Mi }
readinessProbe:
httpGet: { path: /ready, port: 8080 }
livenessProbe:
httpGet: { path: /health, port: 8080 }
envFrom:
- configMapRef: { name: api-config }

When you change the image to 1.5.0, the Deployment does a rolling update: it creates a new pod, waits for it to be ready (readiness), removes an old one, and repeats. With maxUnavailable: 0, there are never fewer than 3 pods serving. If something goes wrong:

Terminal window
kubectl rollout status deployment/api # follows the update
kubectl rollout undo deployment/api # goes back to the previous version

Networking: Services and ingress

Pods change IP every time they are recreated. A Service gives a group of pods a stable address and DNS name, chosen by labels:

ignores

Another pod

http://api

Service api

selector: app=api

Pod

app=api

Pod

app=api

Pod

app=api

Pod

app=worker

ignores

Another pod

http://api

Service api

selector: app=api

Pod

app=api

Pod

app=api

Pod

app=api

Pod

app=worker

Inside the cluster, the Service is found by name: api in the same namespace, or api.payments.svc.cluster.local from anywhere. Pods join and leave the Service as they become ready or not (readiness).

Service typeReachable from
ClusterIP (default)Only inside the cluster
NodePortA port on every node of the cluster
LoadBalancerA cloud provider load balancer, with a public IP
HTTP ingress: Gateway API

To expose several HTTP services at the same address, with routing by domain and path and TLS, the classic resource was Ingress. The most used controller, Ingress NGINX, was retired in March 2026 and no longer receives fixes, not even security ones. The official recommendation is now the Gateway API, which splits responsibilities:

  • GatewayClass: the kind of gateway (which controller implements it), defined by the infrastructure.
  • Gateway: the entry point (address, ports, certificates), managed by the platform team.
  • HTTPRoute: each application’s routing rules, managed by the team that owns it.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api
spec:
parentRefs: [{ name: public-gateway }]
hostnames: ["api.example.com"]
rules:
- matches: [{ path: { type: PathPrefix, value: /v1 } }]
backendRefs: [{ name: api, port: 80 }]

Configuration and secrets

  • ConfigMap: non-sensitive configuration (URLs, flags, configuration files), injected as environment variables or as mounted files.
  • Secret: passwords, tokens and certificates. Watch out: by default, a Secret is only base64-encoded, not encrypted. Anyone with read permission in the namespace can read it. In production, enable etcd encryption, restrict access with RBAC and consider an external vault (Vault, cloud secret managers) synced with the cluster.

Keeping configuration out of the image lets you use the same image in every environment, changing only the ConfigMap.

Health: probes

Kubernetes has no way of knowing whether your application is fine; it asks, through probes:

ProbeQuestionIf it fails
startupProbeHas it finished starting?Keeps waiting; the other probes only start afterwards
readinessProbeCan it receive traffic now?Leaves the Service (without restarting) until it responds again
livenessProbeIs it alive, or stuck?The container is restarted

The classic mistake: making the liveness probe check the database. If the database goes down, every pod fails liveness and gets restarted in a loop, fixing nothing. External dependencies belong in readiness; liveness only checks whether the process itself responds.

Resources and scaling

Each container declares how much it needs and how much it may use:

  • requests: what the scheduler reserves on the node. A pod only goes to a node that has that much free.
  • limits: the ceiling. Going over the memory limit kills the container (OOMKilled). Going over the CPU limit doesn’t kill it: the container is throttled and becomes slow. That is why many teams set a memory limit but no CPU limit.

The combination defines the pod’s priority when the node runs out of resources (QoS): pods whose requests equal their limits (Guaranteed) are the last to be evicted.

Scaling
  • Horizontal (HPA): the HorizontalPodAutoscaler increases or decreases the number of replicas based on a metric (CPU, memory or application metrics, such as requests per second).
  • Vertical: adjusting requests and limits. Since version 1.35, you can resize a pod’s CPU and memory without restarting it.
  • Cluster: when pods no longer fit on the nodes, the cluster autoscaler (or Karpenter, on AWS) adds machines; when there are too many, it removes them.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }

Storage

A container’s disk disappears along with it. For data that must survive, the pod requests a volume through a PersistentVolumeClaim (“I want 10 GiB, read-write”), and a StorageClass provisions the disk at the provider (an EBS volume on AWS, a Persistent Disk on GCP). Databases and queues use StatefulSets, which give each replica a fixed name (db-0, db-1) and its own disk.

In practice, many teams prefer managed databases outside the cluster and leave Kubernetes to stateless applications.

Symptoms and causes

Status or symptomWhat it usually is
PendingNo node has the requested resources (requests too high) or the volume couldn’t be created
ImagePullBackOffWrong image name or tag, or missing credentials for a private registry
CrashLoopBackOffThe container starts and dies right away; check kubectl logs --previous
OOMKilledIt went over the memory limit; raise the limit or investigate the usage
Service doesn’t respondThe selector doesn’t match the pods’ labels, or no pod passes readiness (kubectl get endpoints)
Pods restarting nonstopLiveness too strict, or dependent on something external
App slow without errorsCPU throttling from the limit

The commands that settle most investigations:

Terminal window
kubectl get pods -o wide # status and which node it is on
kubectl describe pod <pod> # events: why it wasn't scheduled, why it restarted
kubectl logs <pod> --previous # logs from the run that died
kubectl get events --sort-by=.lastTimestamp

When to use it

Use KubernetesPrefer something else
Many services, several teams, frequent deploysOne or a few applications: a PaaS or managed containers (Cloud Run, App Runner, Fly.io) do it with much less effort
You need portability across clouds or on-premiseA small team with nobody to run the platform
Autoscaling and zero-downtime updatesWorkloads that fit in serverless functions

The cost of Kubernetes isn’t installing it, it is operating it: version upgrades every few months (the project only maintains the three most recent versions), security, observability and the team’s knowledge. A managed cluster reduces that cost, but doesn’t remove it.

See also: Understanding containers, Observability and System design.

Videos

References

1/17 · Why Kubernetes