Lesson 01 of 66 · 16 min read · Updated

Kubernetes Architecture — Control Plane, Worker Nodes and the Life of a Pod

You can use Kubernetes for a long time by treating it as a black box: you hand it YAML, Pods appear. That works until something goes wrong. A Pod stays Pending, a node goes NotReady, a Service returns nothing, and suddenly you need to know which part of the cluster is responsible.

This article opens the box. We look at every component of a cluster, what it owns, and how they cooperate. Then we follow one kubectl apply of a Deployment through the whole system on a real four-node cluster, using the cluster's own events, its etcd keys and its container runtime as evidence.

We will cover the following topics:

  1. The control plane and the worker nodes, and why they are split
  2. The API server, etcd, the scheduler and the controller manager
  3. The kubelet, the container runtime, kube-proxy and the CNI plugin
  4. The declarative model and reconciliation loops
  5. CoreDNS and add-ons
  6. The life of a Pod, traced step by step on a live cluster

Technical Requirements

  1. A multi-node cluster. The examples use Docker Desktop's Kubernetes with the kind provisioner: one control-plane node and three workers, Kubernetes v1.36. The next article shows how to create exactly this cluster. A plain kind cluster with the same shape works the same way.
  2. kubectl configured for the cluster. The context in the examples is docker-desktop.
  3. Docker, because the kind nodes are containers and we will open a shell inside them.

Core Concepts

Two Halves: Control Plane and Worker Nodes

A Kubernetes cluster is a set of machines split into two roles.

The control plane makes decisions. It stores the desired state of the cluster, decides where Pods should run, and notices when reality drifts away from what you asked for.

The worker nodes do the work. They run your containers, report their health, and wire up networking so that Pods can reach each other.

RoleComponentsQuestion it answers
Control planekube-apiserver, etcd, kube-scheduler, kube-controller-manager, cloud-controller-managerWhat should be running, and where?
Every nodekubelet, container runtime, kube-proxy, CNI pluginIs what should run here actually running?
Add-onsCoreDNS, metrics-server, ingress/gateway controllers, storage provisionersCluster-wide services built on top of the core

In managed services such as AKS, EKS and GKE you never see the control plane machines. The provider runs them. On a local kind cluster you can see everything, which makes it the best place to learn.

Here is the cluster used in this article:

kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE CONTAINER-RUNTIME
desktop-control-plane Ready control-plane 7d13h v1.36.1 172.18.0.7 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker Ready <none> 7d13h v1.36.1 172.18.0.5 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker2 Ready <none> 7d13h v1.36.1 172.18.0.10 Debian GNU/Linux 13 (trixie) containerd://2.3.1
desktop-worker3 Ready <none> 7d13h v1.36.1 172.18.0.6 Debian GNU/Linux 13 (trixie) containerd://2.3.1

Four nodes, one of them with the control-plane role. Every node, including the control plane, runs containerd as its container runtime.

kube-apiserver: The Front Door

The API server is the only component that talks to etcd, and the only one every other component talks to. kubectl, the scheduler, the controllers, the kubelets: all of them are clients of the API server.

It does three jobs on every request:

  1. Authentication: who are you? (client certificates, tokens, OIDC)
  2. Authorization: are you allowed to do this? (RBAC)
  3. Admission: should this object be changed or rejected before it is stored? (mutating and validating admission, including policies)

Only after these three steps does the object land in etcd. The API server is stateless, so production clusters run several copies behind a load balancer.

The other job of the API server is just as important: watches. Components do not poll. They open a long-lived watch and the API server streams every change to the objects they care about. This is what makes the whole system react within milliseconds.

etcd: The Single Source of Truth

etcd is a consistent, distributed key-value store. Every Kubernetes object you create is stored there as a key under /registry. If etcd is lost and you have no backup, the cluster's state is gone, even if every container is still running.

Two things to remember about etcd:

  1. Only the API server reads and writes it. Nothing else touches it directly.
  2. It uses the Raft consensus algorithm, so production clusters run an odd number of members (3 or 5) to survive failures.

kube-scheduler: Choosing a Node

The scheduler watches for Pods that have no node assigned. For each one it does two things:

  1. Filtering: drop nodes that cannot run the Pod. Not enough CPU or memory, a taint the Pod does not tolerate, a node selector that does not match.
  2. Scoring: rank the remaining nodes. Spread replicas, prefer nodes that already have the image, balance resource usage.

The winner is written into the Pod's spec.nodeName through the API server. That is all the scheduler does. It never starts a container itself.

kube-controller-manager: The Reconciliation Engine

The controller manager is one binary that runs dozens of controllers: the Deployment controller, the ReplicaSet controller, the Node controller, the Job controller, the EndpointSlice controller and many more.

Every controller follows the same loop:

  1. Watch the objects it owns.
  2. Compare the desired state (the spec) with the actual state (the status and the related objects).
  3. Act to close the gap.
  4. Repeat, forever.

This is the declarative model. You never tell Kubernetes "start three Pods". You tell it "there should be three Pods", and a controller keeps making that true. If a Pod dies, the gap reappears and the controller closes it again.

cloud-controller-manager

The cloud-controller-manager contains the controllers that need to talk to a cloud provider: creating load balancers for type: LoadBalancer Services, attaching routes, labelling nodes with their zone. On AKS, EKS or GKE it is always there. On bare metal or kind there is no cloud, so either nothing fills this role, or a small replacement does. Docker Desktop's kind mode ships a component called desktop-cloud-provider-kind, which is why LoadBalancer Services get an external address there.

kubelet: The Node Agent

The kubelet runs on every node as a normal system service, not as a Pod. It watches the API server for Pods assigned to its node, and then:

  1. asks the container runtime to pull images and start containers,
  2. mounts volumes and injects Secrets and ConfigMaps,
  3. runs liveness, readiness and startup probes,
  4. reports Pod and node status back to the API server.

The kubelet also runs static Pods: Pods defined by files in a directory on the node, usually /etc/kubernetes/manifests. This is how the control plane components themselves run on a kubeadm or kind cluster.

The Container Runtime and CRI

The kubelet does not run containers itself. It talks to a container runtime through the Container Runtime Interface (CRI), a gRPC API. containerd and CRI-O are the common runtimes. Docker Engine is no longer used directly by the kubelet; the dockershim was removed in Kubernetes 1.24.

crictl is the debugging client for the CRI. It is to the runtime what kubectl is to the API server, and it is already installed inside every kind node.

kube-proxy and the CNI Plugin

Two different components handle networking, and people often confuse them.

ComponentResponsibilityOn this cluster
CNI pluginGives every Pod an IP address and makes Pod-to-Pod traffic work across nodeskindnet
kube-proxyTurns Service virtual IPs into rules that forward to Pod IPsiptables mode

The CNI plugin is called by the container runtime whenever a Pod sandbox is created. Popular plugins are Calico, Cilium, Flannel and Azure CNI. kind ships its own small plugin, kindnet.

kube-proxy runs on every node and watches Services and EndpointSlices. In iptables mode it writes NAT rules so that traffic to a ClusterIP is redirected to one of the backing Pods. Some CNI plugins, such as Cilium, can replace kube-proxy entirely.

CoreDNS and Other Add-ons

CoreDNS is not part of the control plane, but almost nothing works without it. It runs as an ordinary Deployment in kube-system and answers queries such as hello-api.arch-demo.svc.cluster.local. Other common add-ons are metrics-server, a storage provisioner (kind uses local-path-provisioner) and an ingress or gateway controller.

Leader Election

The scheduler and the controller manager can run several replicas for high availability, but only one copy may act at a time, otherwise two schedulers would place the same Pod twice. They agree on a leader by competing for a Lease object through the API server:

kubectl get lease -n kube-system
NAME HOLDER AGE
apiserver-vpqqjsh2llykgcqohqziw5r7bq apiserver-vpqqjsh2llykgcqohqziw5r7bq_02d709b3-1259-481a-aa8f-a1d5fffa2545 86m
kube-controller-manager desktop-control-plane_3e4a3a2e-1386-4e6c-9ec1-e9c5fb2e81fa 7d13h
kube-scheduler desktop-control-plane_499f6e95-0755-4769-8896-0f6620910f33 7d13h

Hands-On: Kubernetes Commands

Check the API server's health

The /readyz endpoint runs every internal readiness check of the API server.

kubectl get --raw='/readyz?verbose' | tail -3

Find the control plane endpoint

Shows the address of the API server and of the CoreDNS proxy.

kubectl cluster-info

List the static Pod manifests

On a kind node the control plane runs as static Pods. The node is a container, so docker exec gets you inside it.

docker exec desktop-control-plane ls /etc/kubernetes/manifests

See containers through the CRI

crictl ps shows what the container runtime is running, independently of the API server.

docker exec desktop-control-plane crictl ps

Check which mode kube-proxy uses

The mode is part of the kube-proxy ConfigMap.

kubectl get cm -n kube-system kube-proxy -o jsonpath='{.data.config\.conf}' | grep '^mode'

Read events in time order

kubectl events is the newer, sorted alternative to kubectl get events.

kubectl events -n arch-demo

Check that kubelet and containerd are running on a node

Both are system services on the node, not Pods.

docker exec desktop-worker systemctl is-active kubelet containerd

Step-by-Step Example

The Scenario

We deploy a small .NET sample web app with three replicas and follow the request through every component: from kubectl to the API server, into etcd, through the Deployment and ReplicaSet controllers, to the scheduler, to the kubelet and containerd on each node, and finally through kube-proxy and CoreDNS when we add a Service.

Step 1 — Look at the Control Plane

The four control plane components are static Pods on the control-plane node.

docker exec desktop-control-plane ls /etc/kubernetes/manifests
etcd.yaml
kube-apiserver.yaml
kube-controller-manager.yaml
kube-scheduler.yaml

Open one of them. It is an ordinary Pod definition, which the kubelet starts directly from the file without asking the API server.

docker exec desktop-control-plane cat /etc/kubernetes/manifests/kube-scheduler.yaml | head -20
apiVersion: v1
kind: Pod
metadata:
labels:
component: kube-scheduler
tier: control-plane
name: kube-scheduler
namespace: kube-system
spec:
containers:
- command:
- kube-scheduler
- --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
- --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
- --bind-address=127.0.0.1
- --kubeconfig=/etc/kubernetes/scheduler.conf
- --leader-elect=true

Notice --kubeconfig: the scheduler is just another API client with its own credentials, and --leader-elect=true turns on the Lease mechanism described above.

Now look at the same components through the container runtime:

docker exec desktop-control-plane crictl ps
CONTAINER IMAGE STATE NAME POD NAMESPACE
3976c004fb308 fe81a497e85f1 Running coredns coredns-589f44dc88-6ttqx kube-system
b1efe0d901d9a f2ede2b789a61 Running kindnet-cni kindnet-r2jrx kube-system
6a5ef5c5a094f 01ad784c02283 Running kube-proxy kube-proxy-l2nbk kube-system
053d0d0d42f58 76e62361b06b5 Running kube-scheduler kube-scheduler-desktop-control-plane kube-system
c776ddb5db09c 4923943f21256 Running kube-apiserver kube-apiserver-desktop-control-plane kube-system
fa47790424db9 39d983367f38c Running kube-controller-manager kube-controller-manager-desktop-control-plane kube-system
6ceb7ca6bd9c2 6da6ea097b384 Running etcd etcd-desktop-control-plane kube-system

The output is trimmed to the important columns. Every component we discussed is here: the API server, etcd, the scheduler, the controller manager, plus kube-proxy, the kindnet CNI agent and CoreDNS.

Step 2 — Confirm the Node Agents

The kubelet and containerd are not in that list, because they are not containers. They are system services of the node:

docker exec desktop-worker systemctl is-active kubelet containerd
active
active

If the kubelet on a node stops, the Node controller marks the node NotReady after a grace period of a few tens of seconds, and eventually evicts its Pods.

Step 3 — Apply a Deployment

Save this as arch-deploy.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: arch-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-api
namespace: arch-demo
spec:
replicas: 3
selector:
matchLabels:
app: hello-api
template:
metadata:
labels:
app: hello-api
spec:
containers:
- name: hello-api
image: mcr.microsoft.com/dotnet/samples:aspnetapp
ports:
- containerPort: 8080
kubectl apply -f arch-deploy.yaml
namespace/arch-demo created
deployment.apps/hello-api created

kubectl sent two requests to the API server. Each was authenticated, authorized, passed admission, and was stored in etcd. At this moment no Pod exists yet. The API server has simply recorded your intent.

Step 4 — Follow the Events

Every component that acts writes an Event. Reading them in order shows the whole chain:

kubectl events -n arch-demo
LAST SEEN TYPE REASON OBJECT MESSAGE
49s Normal Scheduled Pod/hello-api-b5c8fcf6c-9hqnm Successfully assigned arch-demo/hello-api-b5c8fcf6c-9hqnm to desktop-worker3
49s Normal Scheduled Pod/hello-api-b5c8fcf6c-pwjx2 Successfully assigned arch-demo/hello-api-b5c8fcf6c-pwjx2 to desktop-worker
49s Normal Scheduled Pod/hello-api-b5c8fcf6c-w7xwt Successfully assigned arch-demo/hello-api-b5c8fcf6c-w7xwt to desktop-worker2
49s Normal SuccessfulCreate ReplicaSet/hello-api-b5c8fcf6c Created pod: hello-api-b5c8fcf6c-pwjx2
49s Normal SuccessfulCreate ReplicaSet/hello-api-b5c8fcf6c Created pod: hello-api-b5c8fcf6c-w7xwt
49s Normal SuccessfulCreate ReplicaSet/hello-api-b5c8fcf6c Created pod: hello-api-b5c8fcf6c-9hqnm
49s Normal ScalingReplicaSet Deployment/hello-api Scaled up replica set hello-api-b5c8fcf6c from 0 to 3
48s Normal Pulling Pod/hello-api-b5c8fcf6c-9hqnm Pulling image "mcr.microsoft.com/dotnet/samples:aspnetapp"
48s Normal Pulling Pod/hello-api-b5c8fcf6c-pwjx2 Pulling image "mcr.microsoft.com/dotnet/samples:aspnetapp"
48s Normal Pulling Pod/hello-api-b5c8fcf6c-w7xwt Pulling image "mcr.microsoft.com/dotnet/samples:aspnetapp"

Events created in the same second are not strictly ordered, so read them by reason. The real sequence is:

StepWhoReasonWhat happened
1Deployment controllerScalingReplicaSetSaw a Deployment without a ReplicaSet and created one with 3 replicas
2ReplicaSet controllerSuccessfulCreateSaw a ReplicaSet with 0 of 3 Pods and created 3 Pod objects
3SchedulerScheduledSaw 3 Pods without a node and assigned each to a worker
4kubelet on each workerPulling, Pulled, Created, StartedSaw a Pod assigned to its node and asked containerd to run it

Notice that no component called another directly. Each one watched the API server, saw an object it owns, and wrote a new object back. That is the entire architecture in one table.

Also notice that the scheduler spread the three replicas over three different workers and skipped the control-plane node. The control-plane node has the taint node-role.kubernetes.io/control-plane:NoSchedule, so the filtering step removed it.

Step 5 — See the Objects in etcd

Every object you just watched being created is a key in etcd. The etcd Pod contains etcdctl, so you can list the keys under /registry that mention the namespace. This is a read-only command. Never write to etcd by hand.

kubectl exec -n kube-system etcd-desktop-control-plane -- etcdctl \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
get /registry --prefix --keys-only | grep arch-demo
/registry/configmaps/arch-demo/kube-root-ca.crt
/registry/deployments/arch-demo/hello-api
/registry/events/arch-demo/hello-api-b5c8fcf6c-9hqnm.18e07d7954611af5
...
/registry/namespaces/arch-demo
/registry/pods/arch-demo/hello-api-b5c8fcf6c-9hqnm
/registry/pods/arch-demo/hello-api-b5c8fcf6c-pwjx2
/registry/pods/arch-demo/hello-api-b5c8fcf6c-w7xwt
/registry/replicasets/arch-demo/hello-api-b5c8fcf6c
/registry/serviceaccounts/arch-demo/default

The Deployment, the ReplicaSet, the three Pods and even the Events are there. You also see two objects you never created: the default ServiceAccount and the kube-root-ca.crt ConfigMap. Two more controllers added them the moment the namespace appeared.

Step 6 — See the Containers on the Node

Check where the Pods landed:

kubectl get pods -n arch-demo -o wide
NAME READY STATUS RESTARTS AGE IP NODE
hello-api-b5c8fcf6c-9hqnm 1/1 Running 0 86s 10.244.4.6 desktop-worker3
hello-api-b5c8fcf6c-pwjx2 1/1 Running 0 86s 10.244.2.8 desktop-worker
hello-api-b5c8fcf6c-w7xwt 1/1 Running 0 86s 10.244.1.6 desktop-worker2

Each worker has its own Pod address range: 10.244.2.x on desktop-worker, 10.244.1.x on desktop-worker2, 10.244.4.x on desktop-worker3. The kindnet CNI plugin assigned these addresses and routes them between nodes.

Now ask containerd on desktop-worker directly, bypassing the API server completely:

docker exec desktop-worker crictl pods --namespace arch-demo
POD ID CREATED STATE NAME NAMESPACE ATTEMPT RUNTIME
c57229100ea25 About a minute ago Ready hello-api-b5c8fcf6c-pwjx2 arch-demo 0 (default)

docker exec desktop-worker crictl ps --name hello-api
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD
e7224e94b8e3d 9acd8db4292ce 16 seconds ago Running hello-api 0 c57229100ea25 hello-api-b5c8fcf6c-pwjx2

A Pod in containerd is a sandbox: a set of shared Linux namespaces. The application container runs inside it. This is the level the kubelet works at.

Step 7 — Watch Reconciliation Fix a Failure

Delete one Pod and list the Pods immediately:

kubectl delete pod -n arch-demo hello-api-b5c8fcf6c-pwjx2 --wait=false
pod "hello-api-b5c8fcf6c-pwjx2" deleted from arch-demo namespace

kubectl get pods -n arch-demo
NAME READY STATUS RESTARTS AGE
hello-api-b5c8fcf6c-9hqnm 1/1 Running 0 96s
hello-api-b5c8fcf6c-nppvt 1/1 Running 0 4s
hello-api-b5c8fcf6c-w7xwt 1/1 Running 0 96s

Within four seconds a replacement Pod (nppvt) is running. You did not ask for it. The ReplicaSet controller noticed that the actual count (2) no longer matched the desired count (3) and closed the gap. The scheduler placed the new Pod, and a kubelet started it. This loop is why Kubernetes is described as self-healing.

Step 8 — Add a Service: kube-proxy and CoreDNS

kubectl expose deployment hello-api -n arch-demo --port 80 --target-port 8080
service/hello-api exposed

kubectl get svc,endpointslice -n arch-demo
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/hello-api ClusterIP 10.96.205.50 <none> 80/TCP 0s

NAME ADDRESSTYPE PORTS ENDPOINTS AGE
endpointslice.discovery.k8s.io/hello-api-sb5ws IPv4 8080 10.244.4.6,10.244.1.6,10.244.2.13 0s

Two more controllers just worked. The Service controller allocated the ClusterIP, and the EndpointSlice controller collected the addresses of the three ready Pods.

kube-proxy turned that into iptables rules on every node:

docker exec desktop-worker sh -c 'iptables -t nat -S KUBE-SERVICES | grep hello-api'
-A KUBE-SERVICES -d 10.96.205.50/32 -p tcp -m comment --comment "arch-demo/hello-api cluster IP" -m tcp --dport 80 -j KUBE-SVC-5RZSL2NI5IMA2TP7

Any packet to 10.96.205.50:80 jumps to a chain that picks one of the three Pod IPs at random. The ClusterIP is not bound to any network interface. It exists only as this rule.

Finally CoreDNS gives the Service a name:

kubectl run dns-test -n arch-demo --image=busybox:1.37 --restart=Never --rm -i --quiet -- \
nslookup hello-api.arch-demo.svc.cluster.local
Name: hello-api.arch-demo.svc.cluster.local
Address: 10.96.205.50

Step 9 — Clean Up

Deleting the namespace deletes everything inside it. The garbage collector, yet another controller, removes the ReplicaSet and the Pods that the Deployment owned.

kubectl delete namespace arch-demo

Summary

  1. A cluster is split into the control plane, which decides, and the worker nodes, which run containers.
  2. The API server is the only component that talks to etcd. Every other component is an API client that watches for changes.
  3. The scheduler only writes spec.nodeName. The kubelet on that node does the actual work, through the container runtime via the CRI.
  4. Controllers in the controller manager run reconciliation loops: compare desired and actual state, act, repeat. Deleting a Pod proved it: a replacement appeared in four seconds.
  5. The CNI plugin gives Pods their IPs. kube-proxy turns Service IPs into forwarding rules. CoreDNS gives Services names.
  6. On a kind cluster the control plane runs as static Pods, and docker exec plus crictl let you inspect every layer.

In the next article we build exactly this kind of local multi-node cluster ourselves, with Docker Desktop and with the kind CLI.