13 min read · Guide · Kubernetes
How it works · Kubernetes

What happens between kubectl apply and a running container

Eleven control-plane events happen between typing the command and the container running. Each is a small, narrowly scoped piece of work triggered by a change in watched state.

Parts01 – 08 Interactive11-step trace PrereqContainers / etcd

The Kubernetes control plane and what runs where

The API server, scheduler, controllers, and etcd, and their jobs.

Kubernetes pod creation is a sequence of eleven events involving the API server, etcd, the scheduler, the kubelet, and the container runtime. From kubectl apply to a running container typically takes 5–30 seconds. Each step is a separate watch-driven event, so knowing the sequence helps a lot when debugging.

Kubernetes' design is fairly flat. Four kinds of components matter for pod creation: the API server, etcd, a fleet of controllers (the scheduler being one), and per-node kubelets. They do not call each other directly, and every interaction goes through the API server.

apiserver

Validates and admits every request

The only thing that reads or writes etcd. Authenticates, authorizes, mutates, validates, persists, and then broadcasts. Every controller is a watch client.

etcd

Stores the cluster’s desired state

Strongly consistent key-value store, Raft-replicated across 3 or 5 members. Holds every cluster object. If etcd loses quorum, the cluster goes read-only.

controllers

Drive actual state toward desired

Independent loops watching specific resources and nudging reality toward the declared spec. The scheduler, deployment, replica-set, endpoint and kubelet controllers all have the same shape: watch, then reconcile.


How the watch protocol keeps components in sync

Components watch the API server for changes instead of polling.

Every controller in Kubernetes opens a streaming HTTP connection to the API server with ?watch=1 and stays connected forever. Each event (ADDED, MODIFIED, DELETED) streams down as it happens. Restart a controller? It does a full LIST first to bootstrap, then resumes the watch from the last resourceVersion it saw.

Because of this, Kubernetes reacts to changes quickly without polling. When the scheduler assigns a pod, the kubelet on that node usually learns of it within milliseconds. Nobody tells it; it was already watching, and the API server pushed the change down its open connection.

Field selectors keep it scalable

A 5,000-node cluster has 5,000 kubelets. None of them needs every pod event in the cluster. Each kubelet asks for fieldSelector=spec.nodeName=node-X, so the API server filters server-side and only pods bound to that node stream to that kubelet. Total watch traffic grows linearly with the number of pods rather than with pods times nodes.


The eleven events that create a pod, in order

From the API write to the container running.

The simulator below plays a single kubectl apply -f pod.yaml from the command landing on the API server through the container running on node-3. It has five lanes and eleven hops. Use + Show wire bytes to see the payload at each step.

request / response watch event internal
CLIENTkubectl~/.kube/config API SERVERkube-apiservercontrol-plane STOREetcdconsensus, durable SCHEDULERkube-schedulerpredicates + priorities NODE AGENTkubelet · node-3CRI + CNI 01REQUESTPOST /api/v1/.../pods 02INTERNALAuthN · AuthZ · Admission 03REQUEST put / pods / default / web-7d4c 04RESPONSE revision: 482137 05RESPONSE 201 Created · phase: Pending 06WATCH WATCH · pod added (no nodeName) 07INTERNALPredicates + priorities → node-3 08REQUEST PATCH spec.nodeName = "node-3" 09WATCH WATCH · pod assigned to node-3 10INTERNALCRI: pull image, create sandbox, start 11REQUEST PATCH status: phase=Running, podIP=10.244.3.42 ONE COMMAND · ELEVEN HOPS · ONE RUNNING POD
Step 01 of 11

Everything starts with one HTTP POST. kubectl is a thin client that turns YAML into JSON and pushes it to the API server. It does no scheduling or validation of its own, so every decision is made in the cluster.

kubectl  →  kube-apiserver

How the scheduler picks a node: filter, then score

First rule out nodes that can't fit, then rank the ones that can.

Every unscheduled pod goes through two passes in the default scheduler: filter (predicates that rule nodes out), then score (priorities that rank the rest). The highest-scoring node wins.

Predicates · hard filters

Is this node even possible?

Resource fit (does it have CPU/memory free?), volume attachment, taints, node selectors, affinity rules. A node that fails any predicate is removed from consideration.

Priorities · soft scoring

Among the eligible, which is best?

Least-requested (prefer less-loaded), image-locality (already-cached image saves a pull), topology-spread (keep replicas across zones), inter-pod affinity. Each scores 0–100; the weighted sum picks the winner.

Splitting the work into filter then score makes the scheduler pluggable. You can write a custom scheduler plugin and add it to either pass. The scheduling framework calls the plugins in order. A single plugin rejecting a node ends filtering for that node, while every plugin contributes to scoring.


The kubelet: the agent that runs pods on each node

It takes the assigned pod spec and makes the container real.

The kubelet is another controller: it watches pods and reconciles toward the desired state. Here, reconciling means making Linux processes exist. The kubelet does not start a container itself; it tells the container runtime to.

The runtime (containerd, cri-o, historically Docker) speaks a gRPC interface called the Container Runtime Interface. Behind that interface, runc (or kata, gvisor, youki) clone()s the process into a fresh set of Linux namespaces, applies cgroups, mounts the rootfs, and execs the container command.

  1. CRI

    RunPodSandbox

    Creates the pause container, a tiny process that holds the pod's network namespace. Every container in the pod joins this sandbox. The CNI plugin attaches the network here, and the IP belongs to the sandbox, not the workload.

  2. CRI

    PullImage · CreateContainer · StartContainer

    Three separate gRPC calls. The image is fetched and unpacked, a writable overlay is stacked on top of the read-only layers, and the runtime forks the entrypoint into the sandbox's namespaces. Stdout and stderr go back through the runtime and the kubelet to the API server's log endpoint.

  3. CNI

    Network setup

    The CNI plugin (Calico, Cilium, AWS VPC CNI, Flannel) is invoked once per sandbox. It allocates an IP from the pod CIDR, creates a veth pair, attaches one end to the sandbox's network namespace and the other to a host bridge / tunnel / eBPF program. The IP is returned to the kubelet for the status update.

  4. probes

    Liveness + readiness

    Once started, kubelet begins firing the configured probes. Readiness failure removes the pod from Service endpoints (no traffic) but leaves it running. Liveness failure kills and restarts the container. Misconfigured probes are a common cause of "my pod keeps restarting."


Reconciliation loops keep running

The eleven steps above are not a procedure, and nothing "calls" them in order. Each component watches some piece of state and does its small job whenever that state changes. Pod creation is one path through that watch graph.

When you delete a pod, the kubelet sees the deletion event, gracefully terminates the container, removes the network attachment and deletes the sandbox. When a node dies, the node-lifecycle controller sees the heartbeat stop, marks the node NotReady, and eventually evicts the pods. The deployment controller notices and recreates them, and the scheduler places them. It is the same loop with a different starting state.

Why reconcile instead of run a procedure

A procedural system (do A, then B, then C) has no clean resumption point when a step fails. A reconciler has no sequence to resume. Drift from desired state, in any direction, is simply the next reconcile loop's input.


Where pods get stuck, and how to debug it

The eleven-hop trace is also a debugging map. When a pod is "not running," it has stalled at one of those steps, and each stall has a recognizable signature.

Pending · no node

Scheduler can't place it

Stuck at step 7. Either no node has the requested resources, every eligible node has a taint the pod doesn't tolerate, or a volume can't attach. kubectl describe pod shows scheduler events.

ImagePullBackOff

The node can’t pull the image

Stuck at step 10·CRI·PullImage. Wrong image name, wrong tag, missing imagePullSecret for a private registry, or registry is down. The kubelet retries with exponential backoff. Scheduling itself succeeded.

CrashLoopBackOff

Container starts, then dies

Past step 10. The container starts and then exits, usually from bad config, a missing env var, a wrong entrypoint or an unreachable dependency. Logs are the main clue. kubectl logs --previous reads the dead instance.


Writing your own Kubernetes controller

The same watch-and-reconcile pattern is yours to use.

The control plane is a watch loop, a scheduler and a per-node reconciler, and the watch mechanism is available to your own code. Define a Custom Resource Definition, write a controller that watches it and reconciles toward the spec, and you have extended Kubernetes.

This is the operator pattern. cert-manager watches Certificate CRs and provisions TLS via ACME. prometheus-operator watches ServiceMonitor CRs and updates Prometheus configs. A Postgres operator watches Postgres CRs and runs an HA cluster. Each follows the same pattern as the pod flow above.

For what happens after the pod is running, see the K8s networking guide.

Pod startup latency: where the seconds go

From kubectl apply to the container's first byte served, the typical pod takes 5-30 seconds. Where the time goes:

API server admission + etcd write
~50-200ms. Validates the spec, runs admission webhooks, writes to etcd, broadcasts the watch event.
Scheduler decision
~100-500ms. Filter and score nodes. Typically the fastest hop; bottlenecks here usually mean an expensive admission webhook or scheduler plugin.
Image pull
~1-30s. Dominant for cold image cache. Can be cut with image-streaming (containerd's lazy image-pull, AWS Soci) or by warming the local cache via DaemonSet.
Container runtime sandbox creation
~100-500ms. The pause container, the network namespace, the cgroup tree, the storage mount. Usually fast, unless there are many init containers.
CNI plugin
~50-300ms. Allocate the pod IP, plumb the routes. AWS VPC CNI was historically slow (~1-2s) on cold ENIs; prefix-delegation (2021) cut this to ~100ms.
Container start
~50ms-N seconds. Depends entirely on application startup. JVM cold start ~5-10s; Go binary ~50ms; Python with imports ~200-500ms.
Readiness probe success
~initialDelaySeconds + periodSeconds. The pod becomes Ready after the first passing probe, so a large initialDelaySeconds or periodSeconds adds directly to rollout time even if the container started in 50ms. A common reason a deploy is slower than expected.

Scale issues. In rolling deploys of thousands of pods, etcd's update rate and the kubelet's image-pull throughput become bottlenecks, and pod-start times in clusters of more than 5,000 nodes can stretch to minutes. Mitigations: pre-pull images via DaemonSet, use container-runtime image streaming, cap image size aggressively (Distroless and Alpine help).



Summary

Kubernetes has a lot of surface area, but the core is small: one read-write path through the API server, a consistent store behind it, and watches between the components. Most other concepts are one of those four pieces in a different role. To take each piece apart on its own, the Kubernetes internals notes cover the API server, scheduler, kubelet and etcd one at a time.

Found this useful?

Published Updated