Guides / kubernetes
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.
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
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.
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:
brew install kind # or the binary from kind.sigs.k8s.iokind create cluster
# 3 nginx replicas and a Service in front of themkubectl create deployment web --image=nginx --replicas=3kubectl expose deployment web --port=80kubectl get pods -o wide
# open http://localhost:8080 in the browserkubectl port-forward service/web 8080:80Now watch reconciliation happen. In another terminal, delete a pod and watch another one come up in its place:
kubectl get pods --watchkubectl delete pod <name-of-a-pod>kubectl scale deployment web --replicas=5To clean everything up: kind delete cluster.
The main objects
| Object | What for |
|---|---|
| Pod | The smallest unit: one or more containers that share network and volumes |
| Deployment | Keeps N replicas of a stateless pod and rolls out updates gradually (via ReplicaSets) |
| StatefulSet | Like a Deployment, but with stable identity and disk per replica (databases, queues) |
| DaemonSet | One pod on every node (log collectors, monitoring agents) |
| Job / CronJob | Tasks that finish: once, or on a schedule (migrations, reports) |
| Service | A stable address in front of a group of pods |
| Gateway / Ingress | HTTP entry from outside the cluster, with routing by domain and path |
| ConfigMap / Secret | Configuration and sensitive data, kept separate from the image |
| PersistentVolumeClaim | A request for a disk that outlives the pod |
| Namespace | Logical 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/v1kind: Deploymentmetadata: name: apispec: 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:
kubectl rollout status deployment/api # follows the updatekubectl rollout undo deployment/api # goes back to the previous versionNetworking: 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:
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 type | Reachable from |
|---|---|
ClusterIP (default) | Only inside the cluster |
NodePort | A port on every node of the cluster |
LoadBalancer | A 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/v1kind: HTTPRoutemetadata: name: apispec: 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:
| Probe | Question | If it fails |
|---|---|---|
startupProbe | Has it finished starting? | Keeps waiting; the other probes only start afterwards |
readinessProbe | Can it receive traffic now? | Leaves the Service (without restarting) until it responds again |
livenessProbe | Is 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/v2kind: HorizontalPodAutoscalermetadata: name: apispec: 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 symptom | What it usually is |
|---|---|
Pending | No node has the requested resources (requests too high) or the volume couldn’t be created |
ImagePullBackOff | Wrong image name or tag, or missing credentials for a private registry |
CrashLoopBackOff | The container starts and dies right away; check kubectl logs --previous |
OOMKilled | It went over the memory limit; raise the limit or investigate the usage |
| Service doesn’t respond | The selector doesn’t match the pods’ labels, or no pod passes readiness (kubectl get endpoints) |
| Pods restarting nonstop | Liveness too strict, or dependent on something external |
| App slow without errors | CPU throttling from the limit |
The commands that settle most investigations:
kubectl get pods -o wide # status and which node it is onkubectl describe pod <pod> # events: why it wasn't scheduled, why it restartedkubectl logs <pod> --previous # logs from the run that diedkubectl get events --sort-by=.lastTimestampWhen to use it
| Use Kubernetes | Prefer something else |
|---|---|
| Many services, several teams, frequent deploys | One 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-premise | A small team with nobody to run the platform |
| Autoscaling and zero-downtime updates | Workloads 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
- Kubernetes Explained in 6 Minutes (ByteByteGo): the cluster architecture, visual and quick (the video embedded at the top).
References
- Kubernetes components: the official documentation.
- kind: quick start: installing and creating local clusters.
- Gateway API: the resources and the role-oriented model.
- Ingress NGINX Retirement: the retirement announcement and the alternatives.
- In-place pod resize GA: resizing pods without restarting (1.35).
- Supported versions: the current version and the support cycle.