Why orchestration?

A single container on a single server is easy to run by hand. A real application quickly becomes dozens of containers on several servers, and then questions pile up: which server has room for this container? What happens when a container crashes at night, or a whole server fails? How do you run five copies instead of two during a busy period? How do you replace version 1.4 with 1.5 without downtime? And how do the containers find each other when their addresses keep changing?

A container orchestrator answers these questions automatically:

  • Scheduling: placing each container on a machine with enough free CPU (central processing unit) and memory.
  • Self-healing: restarting failed containers and moving work away from failed machines.
  • Scaling: running more or fewer copies, by hand or automatically based on load.
  • Rolling updates: replacing old versions a few at a time, and rolling back if the new version is unhealthy.
  • Service discovery and load balancing: one stable name and address in front of a changing group of containers.

Kubernetes (often written K8s: K, eight letters, s) is the open-source container orchestrator that became the de facto standard. It was started at Google and is now maintained under the CNCF (Cloud Native Computing Foundation). Every major cloud offers it as a managed service.

Clusters, control plane and nodes

A Kubernetes cluster is a set of machines managed together as one system: a control plane that makes decisions, and worker nodes (virtual or physical machines) that run the containers. You never tell a node what to run; you tell the control plane what you want, and it arranges the rest. The Containers & Kubernetes topic goes deeper, from How a Kubernetes Cluster Works to kubectl and Troubleshooting.

Kubernetes architecture: kubectl talks to the API server in the control plane, which also contains the scheduler, the controller manager and etcd. The API server talks to the kubelet on each of two worker nodes; each node has a container runtime and runs pods. kubectl Control plane API server Scheduler Controller manager etcd cluster state all parts talk via the API server Worker node 1 kubelet Container runtime Pod Pod Worker node 2 kubelet Container runtime Pod
You talk to the API server; the scheduler, controllers and kubelets also work only through it. etcd stores the cluster's state.

The control plane

  • The API server (kube-apiserver; API stands for application programming interface) is the front door. Every request — from you, from a pipeline, from other components — is an HTTPS (Hypertext Transfer Protocol Secure) call to it. It checks who you are and whether you are allowed (authentication and authorization), validates the object and stores it.
  • The scheduler watches for pods that have no node yet and picks one, based on the resources they request and any placement rules.
  • The controller manager runs the controllers: small control loops that each look after one kind of object and keep reality in line with what was requested (see desired state and reconciliation).
  • etcd is a consistent, distributed key-value store that holds the whole cluster state. Losing etcd without a backup means losing the cluster's configuration; in production it runs as three or five members.

Worker nodes

  • The kubelet is an agent on each node. It receives the pods assigned to its node, asks the container runtime to start them, runs their health checks and reports their status back to the API server.
  • The container runtime actually runs containers; today usually containerd or CRI-O, which run the same container images that Docker builds.
  • kube-proxy (or a networking plugin that replaces it) implements services on each node, so traffic for a service address reaches one of its pods.

The main objects

Everything in Kubernetes is an object of a certain kind, stored by the API server. Each object has a spec (what you want) and a status (what is actually the case, filled in by Kubernetes).

Pods

A pod is the smallest unit Kubernetes runs: one container, or a few containers that must always run together on the same node, sharing one IP (Internet Protocol) address and optionally storage volumes. Pods are disposable: when one dies it is not repaired but replaced by a new pod with a new name and a new IP address. That is why you almost never create pods directly.

Deployments

A deployment is an object that keeps a given number of identical pods (replicas) running from one pod template, and updates them in a controlled way. If a pod or its node fails, the deployment's controller creates a replacement. When you change the template — usually the image tag — it performs a rolling update: it starts new pods and removes old ones a few at a time, so the application stays available. kubectl rollout undo returns to the previous version.

Services

Because pods come and go, clients cannot use pod addresses. A Kubernetes service is a stable virtual address and DNS (Domain Name System) name in front of a changing set of pods, selected by their labels; traffic sent to the service is spread over the pods that are ready. The main types are:

Type Reachable from Typical use
ClusterIP (default) Inside the cluster only Internal services: an API used by the web front end
NodePort A fixed port (30000–32767 by default) on every node Testing, or behind an external load balancer you manage
LoadBalancer Outside, through a cloud load balancer Exposing one service; the cloud creates the load balancer
A client pod sends traffic to the service named web on port 80. The service selects pods with the label app=web and forwards to port 8080 on three such pods. A pod labeled app=api is not selected. Client pod Service web :80 selector app=web app=web :8080 app=web :8080 app=web :8080 app=api (not selected)
A service finds its pods by label, not by address, so pods can be replaced freely.

Ingress

An ingress is an object with rules for routing HTTP and HTTPS traffic from outside the cluster to services, by host name and path: shop.example.com/ to the web service, shop.example.com/api to the API service. The rules only work if the cluster runs an ingress controller (for example Traefik, HAProxy, F5's NGINX Ingress Controller or a cloud load balancer integration; the community ingress-nginx project was retired in March 2026), which does the actual routing and usually terminates TLS (Transport Layer Security). One ingress can serve many services behind one load balancer. The Ingress API is feature-frozen; for new clusters the Gateway API, with a more flexible model, is the recommended successor (see the Gateway API).

Namespaces

A namespace divides one cluster into named groups, such as shop, billing and monitoring. Names only need to be unique within a namespace, access rules (RBAC, role-based access control) and resource quotas can be set per namespace, and deleting a namespace deletes everything in it. Namespaces are an organizational boundary, not a strong security boundary.

ConfigMaps and Secrets

Configuration should not be baked into the image, so the same image can run in every environment. A ConfigMap holds ordinary settings (a log level, a feature switch); a Secret holds sensitive values (a database password, an API key). Both can be passed to containers as environment variables or as files.

The values of a Secret are stored base64-encoded, which is an encoding, not encryption: anyone can reverse it.

printf '%s' 'demo-password-123' | base64
echo 'ZGVtby1wYXNzd29yZC0xMjM=' | base64 --decode; echo
ZGVtby1wYXNzd29yZC0xMjM=
demo-password-123

A manifest, line by line

Objects are described in YAML (YAML Ain't Markup Language) files called manifests. This one file contains two objects, separated by ---: a deployment of three web server pods and a service in front of them.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  template:
    metadata:
      labels:
        app: web
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: web
          image: registry.example.com/shop/web:1.4.2
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 250m
              memory: 128Mi
            limits:
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shop
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080
  • apiVersion and kind say what kind of object this is: a Deployment from the apps/v1 API group; the service is in the core v1 group.
  • metadata gives the object its name, its namespace (shop, which must already exist) and labels.
  • replicas: 3 is the desired number of pods. selector.matchLabels tells the deployment which pods are its own: those labeled app: web. It must match the labels in the template.
  • strategy sets the rolling update: at most one pod below the desired count (maxUnavailable) and at most one extra (maxSurge) during an update.
  • template is the pod template: every pod gets these labels and this spec.
  • The pod-level securityContext refuses to start the container as root (runAsNonRoot), runs it as an unprivileged user ID, and applies the runtime's default seccomp (secure computing mode) profile, which blocks rarely needed system calls.
  • image is pinned to a specific version tag, 1.4.2, never latest, so every pod runs the same, known code and a rollback has something to return to. Pinning by digest (@sha256:…) is stricter still. The image is a fictitious example that listens on port 8080 as a non-root user.
  • resources.requests is what the scheduler reserves on a node: a quarter of a CPU core (250m, millicores) and 128 mebibytes of memory. limits.memory is a hard ceiling: a container that uses more is killed and restarted. Without requests the scheduler places pods blindly.
  • The readinessProbe is a health check: the pod only receives traffic from the service while GET /healthz on port 8080 succeeds.
  • The container-level securityContext forbids gaining more privileges and drops all Linux capabilities; this application needs none.
  • The Service of type ClusterIP selects the pods labeled app: web and forwards its port 80 to their port 8080. Inside the namespace other pods can now reach it at http://web.

kubectl basics

kubectl is the command-line tool for Kubernetes: it turns commands into calls to the API server. It reads which cluster to use and your credentials from a kubeconfig file (~/.kube/config), which you should protect like a password. The sessions below are illustrative.

$ kubectl create namespace shop
$ kubectl apply -f web.yaml
namespace/shop created
deployment.apps/web created
service/web created

apply -f sends the file's desired state; running it again after editing the file updates the objects (declarative), and running it unchanged reports unchanged.

$ kubectl get deployments,pods,services -n shop
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/web   3/3     3            3           52s

NAME                       READY   STATUS    RESTARTS   AGE
pod/web-6f8b9c7d54-2xkqp   1/1     Running   0          52s
pod/web-6f8b9c7d54-8mzvn   1/1     Running   0          52s
pod/web-6f8b9c7d54-tq4lw   1/1     Running   0          52s

NAME          TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/web   ClusterIP   10.96.41.120   <none>        80/TCP    52s

When something is wrong, describe shows an object's details and, at the bottom, its recent events — usually the fastest way to find out why a pod does not start. logs shows what the container wrote to standard output.

$ kubectl describe pod web-6f8b9c7d54-2xkqp -n shop
$ kubectl logs web-6f8b9c7d54-2xkqp -n shop
Name:         web-6f8b9c7d54-2xkqp
Namespace:    shop
Node:         worker-2/10.0.2.15
Status:       Running
...
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  60s   default-scheduler  Successfully assigned shop/web-6f8b9c7d54-2xkqp to worker-2
  Normal  Pulled     58s   kubelet            Container image "registry.example.com/shop/web:1.4.2" already present on machine
  Normal  Created    58s   kubelet            Created container web
  Normal  Started    58s   kubelet            Started container web

2026-10-01T09:14:03Z INFO listening on :8080

After changing the image tag in the manifest to 1.4.3 and applying it again, follow the rolling update:

$ kubectl apply -f web.yaml
$ kubectl rollout status deployment/web -n shop
deployment.apps/web configured
service/web unchanged
Waiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 1 old replicas are pending termination...
deployment "web" successfully rolled out

Desired state and reconciliation

The idea behind all of Kubernetes is the same as behind infrastructure as code: you declare the desired state, and software makes it true. The difference is that Kubernetes never stops checking. Each controller runs a reconciliation loop: observe the actual state, compare it with the desired state, act on the difference, and repeat. Nobody has to notice that a pod crashed at 03:00; the next pass of the loop sees two pods where three are wanted and starts a third.

This simulation shows the loop of a deployment controller with three desired replicas. One pod fails, and later someone scales the deployment down:

spec = {"replicas": 3}  # desired state, as stored by the API server
pods = {"web-1": "Running", "web-2": "Running", "web-3": "Running"}
counter = {"next": 4}


def reconcile(spec, pods):
    """One pass of the control loop: observe, compare, act."""
    for name in sorted(pods):
        if pods[name] == "Failed":
            del pods[name]
            print(f"  removed failed pod {name}")
    missing = spec["replicas"] - len(pods)
    if missing > 0:
        for _ in range(missing):
            name = f"web-{counter['next']}"
            counter["next"] += 1
            pods[name] = "Running"
            print(f"  created {name}")
    elif missing < 0:
        for name in sorted(pods)[missing:]:
            del pods[name]
            print(f"  deleted {name}")
    else:
        print("  actual state matches desired state")


for tick in range(1, 6):
    if tick == 2:
        pods["web-2"] = "Failed"
        print(f"tick {tick}: web-2 crashed")
    elif tick == 4:
        spec["replicas"] = 2
        print(f"tick {tick}: someone scaled the deployment to 2")
    else:
        print(f"tick {tick}:")
    reconcile(spec, pods)
    print(f"  running: {', '.join(sorted(pods))}")
tick 1:
  actual state matches desired state
  running: web-1, web-2, web-3
tick 2: web-2 crashed
  removed failed pod web-2
  created web-4
  running: web-1, web-3, web-4
tick 3:
  actual state matches desired state
  running: web-1, web-3, web-4
tick 4: someone scaled the deployment to 2
  deleted web-4
  running: web-1, web-3
tick 5:
  actual state matches desired state
  running: web-1, web-3

The controller does not care why the numbers differ — a crash, a failed node, a new desired count — it only closes the gap. Real controllers are event-driven rather than ticking on a timer, and a deployment actually manages pods through an intermediate object, the ReplicaSet, but the principle is exactly this.

Managed Kubernetes

Running the control plane yourself — keeping etcd backed up, upgrading every few months, securing the API server — is serious work. Managed Kubernetes services run and upgrade the control plane for you; you manage the worker nodes (or let the service manage those too) and your workloads.

Provider Managed Kubernetes service Hands-off node option
AWS (Amazon Web Services) Amazon EKS (Elastic Kubernetes Service) EKS Auto Mode; pods on AWS Fargate
Azure AKS (Azure Kubernetes Service) AKS Automatic
Google Cloud GKE (Google Kubernetes Engine) GKE Autopilot

Names and options are as at the time of writing. Pricing structures differ: some providers charge a fee per cluster for the control plane, some have a free tier for it, and you always pay for the nodes and load balancers. You remain responsible for what runs in the cluster: images, manifests, access rules, Secrets and keeping up with Kubernetes versions, which are only supported for a limited time.

When Kubernetes is overkill

Kubernetes solves the problems of many containers on many machines, and it brings its own: a large number of concepts, networking and security to configure, upgrades to plan, and people who need to understand it all. For a small team with a handful of services, that overhead often costs more than it saves.

Simpler options run containers without a cluster to manage:

  • Serverless container services: Google Cloud Run, Azure Container Apps, and Amazon ECS (Elastic Container Service) with AWS Fargate. You provide an image and settings; the provider handles placement, scaling and healing.
  • Platform services and functions: see service models and serverless.
  • A single virtual machine with Docker Compose, for small internal tools where a few minutes of downtime is acceptable (see Docker Compose).

Kubernetes pays off with many services, several teams sharing a platform, a need to run the same way on several clouds or on premises, or specific requirements the simpler services cannot meet.