Kubernetes for beginners. Part 1: Why Kubernetes
All parts of the series “Kubernetes for beginners”Kubernetes is a system that runs containers on a group of machines and makes sure that exactly what has been described is running. It sounds simple, but behind that phrase lies a change in how you think about servers: instead of "log in to a machine and start a process", the administrator writes down what the state should be, and from then on dozens of independent loops in the cluster bring the world in line with that description and keep it that way. This part is about where this model came from, how the reconciliation loop works, how declarative management differs from imperative management, and what Kubernetes does not do, even though people often expect it to. There aren't many commands here: we'll set up the lab cluster in part 4, and at the end of this part we'll look at how everything described looks on it.
The series is aimed at readers who know Linux and networking. The network protocols the cluster relies on are covered in the "Networking for beginners" series — for example, VXLAN, which the network plugin uses to connect the nodes, is described in its part 18 — and there will be links to it wherever they're needed. A highly available control plane on three masters with BGP is the subject of a separate series, "Highly available Kubernetes on BGP"; the cluster here is simpler — one master and two worker nodes — but each of its components is examined in detail.
One server and processes
The classic setup: a server runs Ubuntu, and on it there's nginx, a Python application under gunicorn (an HTTP server for Python applications), a queue worker, and cron with jobs. Each application is a systemd unit, the configuration lives in /etc, the code is deployed by a script or by Ansible (a system that brings servers to the desired configuration over SSH based on a YAML description), and restarts after a crash are handled by systemd (Restart=on-failure). For one or two servers this is a good setup, and it needs nothing more.
The problems begin as things grow:
- Dependencies. Two applications want different versions of Python, OpenSSL or libc. The package manager keeps one version per system, and upgrading for the sake of one breaks the other. Virtual environments help for Python, but not for system libraries.
- Reproducibility. A server that has been configured by hand for three years is unique. Building a second one just like it is hard, and saying exactly how it differs from its Ansible description is even harder.
- Placement. A human decides which application runs on which server. When there are ten servers and forty applications, the layout lives in a spreadsheet or in someone's head, and when a new application is added, someone goes looking for where there's memory left.
- Machine failure. systemd will restart a crashed process, but it won't move it to another server if the server itself goes down. That's done by the on-call engineer, at night.
- Zero-downtime updates. To roll out a new version without interrupting requests, you have to take instances out of the load balancer one by one, update them, check them and put them back. That's a script written for each application.
Virtual machines solve the first two problems at the cost of an entire OS per application; they don't solve the other three.
Container and image
A container is an ordinary Linux process running in its own namespaces (its own process list, its own network, its own mount points, its own hostname) and in its own cgroup (a group with limits on CPU, memory and I/O). All containers share the kernel; there's no separate OS inside. The process's root file system comes from an image.
An image is a set of file system layers (tar archives, each adding or removing files on top of the previous one) plus a run configuration: the command, environment variables, working directory and user. The format is defined by the OCI specification (the Open Container Initiative — the organization that standardized the image format, the way a container is run and the registry protocol), so an image built with Docker runs on anything that understands OCI. Images are stored in a registry — an HTTP service from which they're downloaded by name and tag (nginx:1.29) or by digest, the SHA-256 of the image manifest, which uniquely identifies the contents.
Containers are started by runtimes. The lowest level is runc: a small program that, given a description in OCI format, creates the namespaces and cgroup, switches the root and starts the process. Above it is containerd: a service that downloads images, unpacks the layers, prepares the file system and calls runc. Docker is a developer tool on top of the same containerd: it adds image building, a convenient CLI and single-host networking. There will be no Docker in the lab cluster: Kubernetes talks to containerd directly — how, in the next part. Image building and the registry are covered in detail in part 6.
A container solves the problem of dependencies and reproducibility: everything the application needs, except the kernel, is in the image, and the image is immutable. The same registry.example.com/demo/hello:1.0 runs identically on a developer's laptop and on a server. But placement, failover and zero-downtime updates remain: docker run works on a single machine.
From containers to a cluster
If you take ten servers with containerd, you'll need something that:
- chooses which server to run the next container on, taking into account free resources and constraints ("no two instances on the same machine");
- notices that a server has disappeared and starts its containers on others;
- gives containers on different servers addresses at which they can see each other, and a stable name for a group of identical instances that hides load balancing behind it;
- updates the application one instance at a time, checking that the new one is ready to accept requests;
- distributes configuration and secrets, and attaches storage volumes to whichever machine the container ended up on.
Such a system is called an orchestrator. There were several: Docker Swarm (a cluster mode built into Docker: simple, but not rich in features), HashiCorp Nomad (a job scheduler that can run not only containers but also plain binaries and virtual machines), Apache Mesos (a cluster resource manager on top of which separate schedulers ran; it's barely developed anymore). Kubernetes grew out of Google's experience with its internal systems Borg and Omega, which ran all of the company's services for a decade; it was open-sourced in 2014, version 1.0 came out in 2015, and the project was handed over to the CNCF (the Cloud Native Computing Foundation, part of the Linux Foundation, which develops Kubernetes and the projects around it). Today it's the de facto standard: clouds sell it as a service, and all the surrounding tools — monitoring, storage, load balancers — are written for it first.
A Kubernetes cluster consists of two kinds of machines. The control plane stores the description of the cluster and makes decisions: what to run where. Worker nodes run containers. In the lab cluster it looks like this:
| Node | Address | Role |
|---|---|---|
k8s-master-1.example.com |
192.168.10.17 | control plane |
k8s-node-1.example.com |
192.168.10.20 | worker node |
k8s-node-2.example.com |
192.168.10.21 | worker node |
In Kubernetes containers aren't run one by one but in groups — pods (Pod): a pod is one or more containers that are always run on the same node, share a network address and can share volumes. Most often a pod has a single container, and for now you can think of a pod as a "container with an address". More on this in part 7.
Desired state
The main idea of Kubernetes: the administrator doesn't say "run a container on node 1", they describe what should be: "three instances of such-and-such image with such-and-such parameters should be running". The description is stored in the cluster as an object — a record in a database with fields. Here is such an object:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.29
ports:
- containerPort: 80
kind: Deployment is the object type, "a stateless application that must be kept running in several identical instances". spec is the desired state: three replicas, each with the label app: web and one container from the nginx:1.29 image. Not a word about which nodes to run them on or what to do if one fails.
An object has a second half — status, the observed state. It isn't written by a human but by the cluster: how many replicas are running right now, how many are ready to accept requests, when things changed. Kubernetes's job is to drive status towards spec and keep them matching. The structure of an object (apiVersion, metadata, spec, status) is covered in detail in part 3.
Objects are stored in etcd — a distributed key-value store — but no one except kube-apiserver accesses it directly. kube-apiserver is the HTTP service through which all objects are read and changed: kubectl, controllers, agents on the nodes — they all talk only to it.
The reconciliation loop
Who turns spec: replicas: 3 into three running containers? Controllers — programs, each responsible for one type of object and running the same loop over and over, which is called the reconciliation loop:
┌──────────────┐ ┌───────────────┐ ┌─────────────────┐
│ observe: │ │ compare │ │ act: │
│ what exists ├────►│ with desired ├────►│ create, │
│ now │ │ (spec) │ │ delete, │
│ (via API) │ │ │ │ write status │
└──────────────┘ └───────────────┘ └────────┬────────┘
▲ ▼
└───────────── wait for changes ─────────────┘
In pseudocode, the replica controller looks like this:
for {
desired := rs.Spec.Replicas // how many there should be
pods := listPods(rs.Spec.Selector) // pods with the matching labels
actual := countNotTerminating(pods) // how many there are now
if actual < desired {
createPods(rs.Spec.Template, desired-actual) // too few: create
} else if actual > desired {
deletePods(pods, actual-desired) // too many: delete
}
rs.Status.Replicas = actual // record what we see
updateStatus(rs)
waitForChange() // an event from the API or a periodic resync
}
Three properties of this loop explain almost all of Kubernetes's behavior.
The controller looks at state, not at events. It doesn't react to "pod deleted" as a command; each time it compares "how many there should be" with "how many there are" from scratch. This approach is called level-triggered — reacting to the level rather than to the edge of a signal (edge-triggered). If the controller missed an event, was restarted, or two events arrived at the same time, nothing breaks: on the next pass it will see the same difference and take the same action. Hence the resilience: you can restart any cluster component at any moment, and it will catch up with reality.
Each controller does one thing. The Deployment controller doesn't create pods — it creates a ReplicaSet object (a set of replicas) with the right template. The ReplicaSet controller doesn't start containers — it creates Pod objects. The scheduler doesn't start containers — it writes a node name into the pod. And only kubelet, the agent on each node, seeing a pod with its own name, asks containerd to start the containers. Each link reads objects from the API and writes objects to the API; they don't know about one another.
administrator
│ kubectl apply -f web.yaml
▼
┌────────────────┐ only it reads ┌────────────┐
│ kube-apiserver │◄── and writes etcd ─►│ etcd │
└────────────────┘ └────────────┘
▲ ▲
│ watch │ watch
┌─┴───────────┐ ┌┴────────────────────────┐
│ controllers │ │ kubelet on every node │
│ scheduler │ │ → containerd → runc │
└─────────────┘ └─────────────────────────┘
Changes are delivered by subscription. Controllers don't poll the API every second: they open a long-lived watch connection to kube-apiserver and receive "object added, changed, deleted" events as they happen. That's why the reaction to a change is almost instant and the load on the API is small. How watch works is covered in part 3.
Let's see how the chain handles a node failure. Suppose k8s-node-2 is suddenly switched off.
- Every 10 seconds, the kubelet on each node renews its "lease" — a small Lease object with a timestamp. From
k8s-node-2the renewals stop. - After 50 seconds without a renewal, the node lifecycle controller marks the node
NotReadyand puts the taintnode.kubernetes.io/unreachableon it. - By default every pod has a toleration for this taint for 300 seconds: the cluster waits five minutes in case the node comes back after a reboot. After that, the pods on the node are marked for deletion.
- The ReplicaSet controller sees that there are now two pods not being deleted instead of three and creates a new Pod object.
- The scheduler sees a pod without a node and assigns it to
k8s-node-1. - The kubelet on
k8s-node-1sees a new pod on its node and starts the container.
Nobody issued a "move the pod" command. Each component took its own small step after seeing a discrepancy in its area of responsibility. The timings (10 seconds, 50 seconds, 5 minutes) are configurable, and why you shouldn't reduce them without need will be covered in part 14.
Declarative and imperative
There are three ways to manage objects, and kubectl supports all three.
| Method | Example | What is passed |
|---|---|---|
| Imperative commands | kubectl create deployment web --image=nginx:1.29 --replicas=3, kubectl scale deployment web --replicas=5 |
an operation on an object; the description isn't saved anywhere |
| Imperative work with files | kubectl create -f web.yaml, kubectl replace -f web.yaml, kubectl delete -f web.yaml |
the whole object from a file; the operation is chosen by a human |
| Declarative work with files | kubectl apply -f web.yaml, kubectl apply -f directory/ |
the desired state; kubectl and the server decide themselves whether to create, change or do nothing |
The imperative approach answers the question "what to do": create, change, delete. It's convenient for one-off actions and experiments, but the result isn't recorded anywhere: a month later no one can say why the application has five replicas or who changed the image. Running kubectl create again fails with an "already exists" error.
The declarative approach answers the question "what should be". The web.yaml file describes the outcome; kubectl apply compares it with what's currently in the cluster and sends only the difference. Running it again changes nothing — the operation is idempotent. The files live in Git, go through review, the change history is visible in git log, and kubectl diff shows the difference between the repository and the cluster.
An important consequence: in the declarative model the cluster always returns to the file. If someone manually ran kubectl scale --replicas=5, the next kubectl apply with replicas: 3 will bring it back to three. That's not a bug but the whole point of the approach: the source of truth is the files, not the current state of the cluster. Hence the rule: all permanent changes go into the manifests, and manual commands are only for diagnostics and experiments. The ultimate form of this rule is GitOps, where changes are applied to the cluster not by a human but by a controller that watches a Git repository; that's the subject of the final part of the series.
Note that declarativeness in Kubernetes has two levels. At the kubectl apply level, the administrator declares an object. And at the cluster level, the object itself is a declaration for the controllers: a Deployment isn't "a command to start three pods" but a standing requirement that is checked again and again.
What Kubernetes does and doesn't do
It does:
- Place pods on nodes, taking into account resources, labels, taints and affinity rules (part 14).
- Recover from failures: restart containers, recreate pods on other nodes, and keep traffic away from instances that aren't ready (parts 7 and 8).
- Provide networking and service discovery: each pod has its own address, and a group of pods gets a stable virtual address and DNS name with load balancing across them (parts 15–17).
- Update without downtime: gradual replacement of pods with readiness checks, and rollback (part 8).
- Distribute configuration and secrets as files and environment variables (part 13).
- Attach storage: request a volume from an external system and mount it on whichever node the pod ended up on (parts 22–26).
- Scale: change the number of replicas based on load (part 33).
- Extend: your own object types and your own controllers for them (part 29) — that's how almost all add-ons work, from Calico to cert-manager, which issues and renews TLS certificates.
It doesn't:
- It isn't a PaaS. A PaaS (platform as a service) such as Heroku — a cloud platform to which a developer pushes code with
git push— takes source code and decides on its own how to build it, run it, and hook up a database and logs. Kubernetes runs ready-made images and provides the building blocks from which such a platform can be assembled, but it isn't one itself. - It doesn't build code or images. Building is the job of a CI system (continuous integration: a service that builds and tests the code on every commit) and of tools such as Docker or Buildah — an OCI image builder that doesn't need a running daemon (part 6). Kubernetes only pulls the image from the registry.
- It doesn't store application data. etcd stores only cluster objects — descriptions, not data. Application files and databases live in external storage: on NFS and SMB file servers (network file system protocols from the Unix and Windows worlds), on block devices, in distributed storage systems, and Kubernetes merely attaches it to pods through CSI drivers (Container Storage Interface — a standard interface between an orchestrator and a storage system). Without storage, a pod's data lives exactly as long as the pod itself.
- It doesn't implement networking itself. The network model is specified, but it's implemented by a CNI network plugin (Container Network Interface — the standard by which the runtime calls an external program to connect a container to the network). In the lab cluster that's Calico — a network plugin that assigns addresses to pods, sets up routes between nodes and enforces network policies. Without a plugin, pods won't get addresses.
- It doesn't provide an external load balancer on bare metal. In the cloud, a LoadBalancer-type service gets an external address from the cloud; on your own network you need a separate component for that — in our case MetalLB (part 18).
- It doesn't make an application fault-tolerant. It will restart an instance, but if the application keeps sessions in memory or the database can't do replicas, three pods won't help. Fault tolerance of state is the concern of the application or of the operator that manages it.
- It doesn't manage the nodes themselves. Updating the kernel and packages and configuring the OS are still the job of the administrator and of tools like Ansible.
- It doesn't collect logs and metrics into storage. It exposes container logs and basic metrics, but storing and displaying them is the job of Prometheus (a system for collecting and storing metrics), Loki (a log store) and similar systems (parts 33 and 34).
And an honest note about the cost. Kubernetes means at least three to five control services, a network plugin, certificates, upgrades every few months and a new way of thinking for the team. For a single service on a single machine, systemd or Docker Compose (a description of several containers on one host in a single file) is simpler and more reliable. Kubernetes pays off when there are more machines and applications than you can comfortably keep in your head, or when you need self-healing and zero-downtime updates.
Hands-on
Below is how this looks on the lab cluster we'll build in part 4. The commands are run on the administrator's workstation with kubectl, in the default namespace.
Create the Deployment from the manifest above and see what appeared:
$ kubectl apply -f web.yaml
deployment.apps/web created
$ kubectl get deployment,replicaset,pod -l app=web -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
deployment.apps/web 3/3 3 3 18s nginx nginx:1.29 app=web
NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR
replicaset.apps/web-7c5ddbdf54 3 3 3 18s nginx nginx:1.29 app=web,pod-template-hash=7c5ddbdf54
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
pod/web-7c5ddbdf54-4xkqp 1/1 Running 0 18s 172.20.109.66 k8s-node-1 <none> <none>
pod/web-7c5ddbdf54-9mtzr 1/1 Running 0 18s 172.20.140.3 k8s-node-2 <none> <none>
pod/web-7c5ddbdf54-qh2lw 1/1 Running 0 18s 172.20.109.67 k8s-node-1 <none> <none>
The chain is visible in the names: the Deployment web created the ReplicaSet web-7c5ddbdf54 (the suffix is a hash of the pod template), and it in turn created three pods with one more random suffix. The scheduler spread them across the two worker nodes, and the addresses were assigned by Calico from the pod network 172.20.0.0/16.
Let's delete one pod and watch the replica controller restore the count. In a second terminal, kubectl get pods -l app=web -w is watching:
$ kubectl delete pod web-7c5ddbdf54-9mtzr
pod "web-7c5ddbdf54-9mtzr" deleted
$ kubectl get pods -l app=web -w
NAME READY STATUS RESTARTS AGE
web-7c5ddbdf54-4xkqp 1/1 Running 0 2m
web-7c5ddbdf54-9mtzr 1/1 Running 0 2m
web-7c5ddbdf54-qh2lw 1/1 Running 0 2m
web-7c5ddbdf54-9mtzr 1/1 Terminating 0 2m
web-7c5ddbdf54-z8v6d 0/1 Pending 0 0s
web-7c5ddbdf54-z8v6d 0/1 Pending 0 0s
web-7c5ddbdf54-z8v6d 0/1 ContainerCreating 0 0s
web-7c5ddbdf54-z8v6d 1/1 Running 0 2s
The new pod z8v6d appeared in the same second the old one was marked for deletion: the controller doesn't wait for the old one to finish, it counts only pods that aren't being deleted. The first Pending line means the pod was created, the second means the scheduler wrote in a node, and ContainerCreating means kubelet is starting the container. What exactly the controller did is visible in the ReplicaSet's events:
$ kubectl describe replicaset web-7c5ddbdf54 | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 2m10s replicaset-controller Created pod: web-7c5ddbdf54-4xkqp
Normal SuccessfulCreate 2m10s replicaset-controller Created pod: web-7c5ddbdf54-9mtzr
Normal SuccessfulCreate 2m10s replicaset-controller Created pod: web-7c5ddbdf54-qh2lw
Normal SuccessfulCreate 12s replicaset-controller Created pod: web-7c5ddbdf54-z8v6d
Now an imperative change and the return to the file. Let's increase the number of replicas with a command and then ask how the cluster differs from the manifest:
$ kubectl scale deployment web --replicas=5
deployment.apps/web scaled
$ kubectl diff -f web.yaml
diff -u -N /tmp/LIVE-2817364092/apps.v1.Deployment.default.web /tmp/MERGED-1650433871/apps.v1.Deployment.default.web
--- /tmp/LIVE-2817364092/apps.v1.Deployment.default.web 2026-10-21 10:42:17.351825902 +0300
+++ /tmp/MERGED-1650433871/apps.v1.Deployment.default.web 2026-10-21 10:42:17.355825946 +0300
@@ -6,7 +6,7 @@
deployment.kubernetes.io/revision: "1"
creationTimestamp: "2026-10-21T07:38:02Z"
- generation: 2
+ generation: 3
labels:
app: web
name: web
@@ -14,7 +14,7 @@
uid: 0b6f4f0e-3d5a-4a51-9a3c-7e1d2f8c6b10
spec:
progressDeadlineSeconds: 600
- replicas: 5
+ replicas: 3
revisionHistoryLimit: 10
selector:
matchLabels:
$ kubectl apply -f web.yaml
deployment.apps/web configured
kubectl diff asks the server what the object would become after apply and shows the difference from the current one: the file wants three replicas, the cluster has five. After apply the two extra pods will be deleted. The manual change didn't survive the declarative description — exactly as intended.
The last experiment is a node failure. Let's stop kubelet on k8s-node-2, simulating the node's disappearance for the control plane (the containers keep running meanwhile: kubelet is just an agent, not their parent process):
user@k8s-node-2:~$ sudo systemctl stop kubelet
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-master-1 Ready control-plane 12d v1.36.5
k8s-node-1 Ready <none> 12d v1.36.5
k8s-node-2 NotReady <none> 12d v1.36.5
$ kubectl describe node k8s-node-2 | grep -A2 Taints
Taints: node.kubernetes.io/unreachable:NoExecute
node.kubernetes.io/unreachable:NoSchedule
Unschedulable: false
Five minutes after the node is marked NotReady, the pod on k8s-node-2 goes into Terminating, and a replacement appears on k8s-node-1:
$ kubectl get pods -l app=web -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web-7c5ddbdf54-4xkqp 1/1 Running 0 21m 172.20.109.66 k8s-node-1 <none> <none>
web-7c5ddbdf54-qh2lw 1/1 Running 0 21m 172.20.109.67 k8s-node-1 <none> <none>
web-7c5ddbdf54-r5n7c 1/1 Running 0 40s 172.20.109.71 k8s-node-1 <none> <none>
web-7c5ddbdf54-z8v6d 1/1 Terminating 0 19m 172.20.140.4 k8s-node-2 <none> <none>
The pod z8v6d will hang in Terminating until the kubelet on k8s-node-2 comes back and confirms that the container has stopped: the cluster doesn't know whether the node is really alive and can't delete the record of a pod that may still be running. Let's bring the node back:
user@k8s-node-2:~$ sudo systemctl start kubelet
A few seconds later the node is Ready again, kubelet stops the old container, and the pod record disappears. From the cluster's point of view there were three replicas all along, but for five minutes one of them was effectively unavailable; the length of this pause can be set for each pod. Let's clean up after ourselves: kubectl delete -f web.yaml.
What comes next in the series
The series goes from the bottom up. First, how the cluster is built: what components it consists of, what its API looks like from the inside, how to install a cluster with kubeadm on three virtual machines, and how lightweight distributions for a laptop differ from such an installation. Then images and the registry, then workloads: Pod, Deployment, StatefulSet (replicas with stable names and volumes), DaemonSet (one pod on each node), Job (a task that runs to completion), resources and QoS classes — levels of guarantees for memory and CPU. After that, configuration and scheduling, then networking from the model to certificates and network policies, storage with separate parts on NFS and SMB, packaging manifests with Kustomize (overlaying patches on a shared set of manifests) and Helm (packages of parameterized templates), custom resources and operators, security, observability, etcd maintenance and cluster upgrades. The series ends with GitOps, which brings everything before it together into delivering changes from Git.
Each object appears as a YAML manifest and kubectl commands, and every part has a section where we look at what changed in the API, on the nodes and in the network.
Next — the cluster architecture: which components run on the master and on the nodes, who talks to whom and on which ports, and what happens between kubectl run and a running container.
Only registered users can leave a comment.
Sign in or register to leave a comment.