Skip to content
Back to student guides
KubernetesDevOpsContainers & Kubernetes3 levels120 sectionsCovers Kubernetes 1.37

The Complete Kubernetes Guide

Deploy, scale and operate containers with Kubernetes. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

Official docs AI-drafted · community review in progressHelp review it
20sections
61examples

This is part one of three. It covers everything you need to run real applications on Kubernetes, not a tour of the logo. By the end you can start a cluster on your laptop, deploy an application from a YAML file you wrote yourself, give it a stable address, configure it without rebuilding the image, update it with zero downtime, roll it back when the update is bad, and read the error messages well enough to fix the common failures on your own. Mid-level and Senior take the same topics further; nothing here is thrown away.

The guide is written against Kubernetes 1.37 (the current minor, released on 26 August 2026; the latest patch is v1.37.1). Everything a beginner touches has been stable for years, so if your cluster is a version or two older, the commands still work. Where something changed recently, the guide says so.

Each section ends with a Try it task. Do them as you go. Kubernetes only makes sense once you have deleted a pod with your own hands and watched another one appear in its place.

1.37current minor, Aug 2026
3minor releases a year
~1 yearpatch support per minor
kubectlthe one tool you install first

What Kubernetes is, and the problem it solves

Kubernetes is a system for running containers across a group of machines and keeping them running. You tell it what you want ("three copies of this image, reachable on port 80, with 256 MiB of memory each"), and it decides which machine each copy runs on, starts them, watches them, and replaces any copy that dies. You describe the result; Kubernetes does the work of getting there and staying there.

To see why that matters, look at what came before. The Docker guide ends with a project running under Docker Compose on one machine. That is a fine place to be for development. Now imagine taking it to production. You rent three servers so that one failure does not take the service down. You SSH into each and run docker run. Then the questions start.

  • One server reboots at 3 a.m. Who starts the containers again?
  • The app runs out of memory and exits. Who notices, and who restarts it?
  • Traffic doubles. Who decides which server has room for two more copies, and who updates the load balancer to include them?
  • You ship version 2. Who replaces the old containers one at a time so users never see an outage, and who puts version 1 back when version 2 turns out to be broken?
  • A server dies for good. Who moves its containers to the other two?

Before orchestrators, the answer to every one of those questions was "a person, or a script a person wrote". Teams built piles of shell scripts, cron jobs and runbooks around docker run. Every team's pile was different, and every pile had gaps that showed up during an incident. Kubernetes takes all of those jobs (placement, restarting, scaling, rolling updates, service discovery, load balancing) and makes them the platform's responsibility, done the same way in every company that runs it.

YOUwrite desired state
→
API SERVERstores it
→
CONTROLLERScompare and act
→
NODESrun the containers

Kubernetes came out of Google in 2014, drawing on its experience running containers internally, and is now maintained by a large open-source community under the Cloud Native Computing Foundation. It ships three minor versions a year, and each is supported with patches for about a year. The name is Greek for "helmsman", and people shorten it to K8s (K, eight letters, s).

Two consequences of the design explain most of what follows, so notice them now.

You describe the end state, not the steps. You never tell Kubernetes "start a container on server 2". You tell it "there should be three copies", and it works out the rest. That is called being declarative, and it is the single most important idea in this guide.

Individual containers are disposable. Kubernetes does not repair a broken container; it throws it away and starts a fresh one from the image. If you come from the world of hand-tended servers, this feels careless at first. It is the opposite: because nothing is precious, nothing needs rescuing at 3 a.m.

What teams use it for, especially in machine learning:

🚀

Serving models and APIs

Run several copies of an inference service behind one address, and update the model version without dropping requests.

📈

Scaling with load

Add copies when traffic rises and remove them when it falls, automatically, on the same pool of machines.

🧪

Batch and training jobs

Run a job to completion, retry it if it fails, and schedule it nightly, with GPUs requested like any other resource.

🏗️

One platform for many teams

Many services, many teams, one cluster, with separate namespaces, permissions and quotas.

Kubernetes does not replace Docker skills Kubernetes runs container images; it does not build them. You still write a Dockerfile, build an image and push it to a registry. Kubernetes starts where docker push ends. If images, tags and registries are new to you, work through the Docker guide first.

You need little to follow along: a laptop with 8 GB of RAM or more, Docker (or a compatible runtime) installed, a terminal and an internet connection. You will run a real cluster on your own machine, so every experiment is free and every mistake is private.

Try it
  1. Pick a service you have deployed, or one you have seen deployed at work.
  2. For each of the five questions in the list above, write down who or what handles it today.
  3. Circle every answer that is "a person" or "a script someone wrote".
a list where several jobs depend on a human noticing something. Each circled answer is a job Kubernetes takes over, and the reason teams accept its learning curve.

Desired state and the reconcile loop

Every part of Kubernetes follows one pattern, and once you have it, the rest of the system stops being mysterious.

You write down the desired state: what should exist. Kubernetes keeps observing the actual state: what does exist. Small programs called controllers each run a loop, forever:

  1. Look at the desired state for the objects I am responsible for.
  2. Look at the actual state.
  3. If they differ, take one step to make the actual state closer to the desired state.
  4. Go back to step 1.

This is called the reconcile loop (or control loop). A thermostat is the classic analogy: you set 21 °C, the thermostat measures the room, and it switches the heating on or off until the two match. You do not tell the thermostat "run the heater for ten minutes". You tell it the temperature you want.

Here is what that means in practice. Suppose you ask for three copies of a web server and one of them crashes. Nobody has to notice. The controller responsible for those copies sees "desired: 3, actual: 2" and starts a new one. Suppose a whole machine disappears. Same thing: the copies that were on it no longer exist, the numbers no longer match, and new copies start on the surviving machines. Suppose you change your mind and ask for five. The numbers differ again, and two more copies start. Recovery, scaling and deployment are all the same mechanism: change the desired state, or let reality drift from it, and the loop closes the gap.

Declarative (Kubernetes)

  • "There should be 3 copies of web:1.2"
  • Repeatable: applying it twice changes nothing
  • Self-healing: drift is corrected automatically
  • The file in Git is the truth

Imperative (scripts)

  • "Start a container on server 2"
  • Running it twice starts two containers
  • Drift stays until someone notices
  • The truth is whatever happens to be running

Three properties of this design will explain behaviour that otherwise looks strange.

It is eventually consistent. When you submit a change, the command returns as soon as the desired state is stored, not when the containers are running. kubectl apply saying deployment.apps/web created means "your request was accepted", not "your app is up". You check the actual state separately, and this guide shows you how.

It keeps trying. If a container cannot start (a wrong image name, a missing configuration value), Kubernetes does not give up and report failure once. It retries, with increasing delays, for as long as the desired state says the container should exist. That is why failures show up as statuses like ImagePullBackOff and CrashLoopBackOff: the "back-off" is the loop waiting longer between attempts.

Fighting it is pointless. If you delete a pod that a controller manages, the controller creates a new one, because you changed the actual state, not the desired state. To make something go away for good, you change or delete the object that declares it. Beginners who try to "kill" a misbehaving app by deleting its pods over and over are arguing with a thermostat.

One question solves most confusion When Kubernetes does something you did not expect, ask: "what desired state is this controller trying to reach?" A pod that keeps coming back, a Service that sends traffic nowhere, a rollout that will not finish: each is a controller faithfully chasing a desired state that is not the one you meant.
Try it
  1. Think of a thermostat, a cruise control or an autopilot. Write down its desired state, how it measures the actual state, and what action it takes when they differ.
  2. Now do the same for "three copies of a web server": desired state, how you would measure it, and the corrective action.
two descriptions with the same shape. That shape (desired, observed, correct, repeat) is the whole of Kubernetes. Every object you meet from here on is a desired state that some controller is looping over.

The cluster: control plane and nodes

A cluster is the set of machines Kubernetes manages, split into two roles. The control plane is the brain: it stores the desired state, makes decisions and runs the controllers. The nodes (worker machines, physical or virtual) are the muscle: they run your containers. On your laptop both roles live in one place; in production the control plane runs on its own dedicated machines, or your cloud provider runs it for you.

your laptop
kubectlThe command-line client. Every command becomes an HTTPS request to the API server
control plane: decides
kube-apiserverThe front door. Checks who you are, validates, stores. The only thing that talks to etcd
etcdThe key-value store holding every object: the desired and observed state
scheduler + controllersThe scheduler picks a node for each new pod; controllers run the reconcile loops
nodes: do the work
kubeletThe agent on each node. Makes sure the pods assigned to it are running and healthy
container runtimecontainerd or CRI-O. Pulls images and starts the containers
kube-proxyPrograms the node's networking so Service addresses reach the right pods

Why this matters: kubectl only ever talks to the API server. It never touches a node directly. When a command fails with "connection refused", the problem is between you and the API server, not in your app.

Meet each component once, briefly; Mid-level goes inside them.

  • kube-apiserver is the front door to everything. Every request, from you, from a controller or from a node, goes through it. It checks who is asking (authentication), whether they are allowed (authorization), whether the request is valid, and then stores the result.
  • etcd is a small, highly available database that holds the entire state of the cluster. Only the API server talks to it. If etcd is lost without a backup, the cluster's memory of what should be running is lost too, which is why operators back it up and why managed services hide it from you.
  • kube-scheduler watches for new pods that have no node yet, filters out nodes that cannot run them (not enough free CPU, for example), scores the rest and assigns the best one. It only decides; it starts nothing.
  • kube-controller-manager runs the built-in controllers: the one that keeps the right number of copies running, the one that tracks which pods belong behind which Service, the one that notices dead nodes, and many more.
  • kubelet runs on every node. It watches the API server for pods assigned to its node, asks the container runtime to start them, runs their health checks and reports their status back.
  • The container runtime actually pulls images and runs containers. Kubernetes talks to it through a standard interface called the CRI. Since Kubernetes 1.24 that is containerd or CRI-O rather than Docker Engine directly, which surprises people but changes nothing for you: images built with Docker run unchanged, because they are standard OCI images.
  • kube-proxy implements Service networking on each node. Some network plugins replace it entirely; you will rarely think about it at this level.

Notice how the reconcile loop from the previous section runs through this picture. You send a desired state to the API server; it lands in etcd; the scheduler notices an unplaced pod and assigns it; the kubelet on that node notices a pod assigned to it and starts it; everybody reports back through the API server. No component gives orders to another. Each watches the API server for the state it cares about and acts on its own. That is why the system survives any single part restarting.

Managed Kubernetes hides the top half On Amazon EKS, Google GKE or Azure AKS, the provider runs the control plane for you and you only see the nodes, if that. The providers operate regions in the Gulf, which matters when an employer in Saudi Arabia or the UAE has data-residency rules about where customer data may be processed. The way you use kubectl is identical either way.
Try it
  1. Without looking back, draw the two halves of a cluster and place the API server, etcd, the scheduler, the controllers, the kubelet and the container runtime on it.
  2. Trace one request with arrows: "I asked for a new pod". Which component notices it first, which picks the node, which starts the container?
a chain of API server, then scheduler, then kubelet, then runtime, with every arrow passing through the API server. If you drew an arrow from the scheduler straight to a node, fix it now; the scheduler only writes a decision back to the API server.

Pods, Deployments and Services: the core nouns

Kubernetes has dozens of object types. Three of them carry nearly all of a beginner's work, and a few supporting ideas hold them together.

Pod Deployment Service
Is One or more containers running together on one node A controller that keeps N identical pods running and updates them safely A stable name and IP address in front of a changing set of pods
Analogy One running copy of your app The manager who keeps the right number of copies staffed The front desk phone number that always reaches whoever is on shift
Lifetime Short. Replaced, never repaired Long. Lives until you delete it Long. Its address does not change
You create it Rarely by hand Almost always Almost always, next to each Deployment

A Pod is the smallest thing Kubernetes runs. Usually it holds exactly one container, your app. Sometimes it holds a main container plus a helper, such as a log shipper, that must live and die with it. All containers in a pod share one IP address and can share files through volumes, and they always land on the same node. The key fact about pods is that they are temporary. A pod that dies is not restarted as the same pod on another node; it is replaced by a new pod with a new name and a new IP address. Plan around that and most networking questions answer themselves.

A Deployment is how you actually run a stateless application. You tell it the pod you want (the image, ports and settings, called the pod template) and how many copies (replicas). It creates the pods, replaces any that die, and when you change the template it rolls out the new version gradually. Under the hood a Deployment manages an intermediate object called a ReplicaSet, whose only job is "keep exactly N pods matching this template". You will see ReplicaSets in command output, and you will almost never create one directly.

A Service solves the problem the Deployment creates. Pods come and go, and their IP addresses change every time. Anything that wants to call your app needs one address that never changes. A Service provides it: a fixed virtual IP and a DNS name, like web, that load-balances across whichever pods currently match. Pods are replaced; the Service stays put.

Four supporting ideas tie those three together.

  • Labels are key/value tags on any object, such as app=web or tier=frontend. They mean nothing to Kubernetes by themselves.
  • Selectors are queries over labels, such as "every pod with app=web". This is how a Deployment knows which pods are its own and how a Service knows where to send traffic. Objects are connected by labels, not by names, and a mismatched label is the most common reason a beginner's Service sends traffic nowhere.
  • Namespaces divide one cluster into named areas, such as dev, staging or team-a. Names must be unique within a namespace, not across the cluster. They are an organisational boundary, and the unit for permissions and quotas, but on their own they are not a security wall.
  • Nodes, as above, are the machines. You can list them like any other object.
DEPLOYMENTreplicas: 3
→
REPLICASETkeeps 3 alive
→
PODSapp=web × 3
←
SERVICEselects app=web
A bare pod has nobody looking after it You can create a pod directly, and tutorials often do. But a pod created on its own has no controller: if it dies or its node disappears, nothing replaces it. For anything you want to keep running, create a Deployment and let it create the pods.

A handful of other objects appear later in this guide and in the next two levels. ConfigMaps and Secrets hold configuration. Jobs run a task to completion, and CronJobs run one on a schedule. StatefulSets run databases and other apps that need stable identities and storage. DaemonSets run one pod on every node, typically for agents like log shippers. You can recognise each by what it controls, and every one of them follows the same reconcile loop.

Try it
  1. In your own words, write one sentence each for pod, Deployment and Service.
  2. Answer this without looking: a Deployment has three pods and one crashes. Which object notices, what does it do, and does the Service's address change?
the ReplicaSet (on the Deployment's behalf) notices the count dropped to two and creates a new pod with a new name and IP. The Service's address stays the same, and it starts sending traffic to the new pod once it matches the selector and is ready. If you got that, you have the model.

Installing kubectl and a local cluster

You need two things: kubectl, the command-line client, and a cluster to point it at. For learning, the cluster runs on your laptop.

Installing kubectl

kubectl is a single binary. Install it with your platform's package manager, or download it from the official release server.

BASH
# macOS (Homebrew)
brew install kubectl

# Windows (winget)
winget install -e --id Kubernetes.kubectl

On Linux, download the binary, check its checksum and install it. These are the official steps; the stable.txt file always names the current stable release, v1.37.1 at the time of writing.

BASH
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

The checksum line should print kubectl: OK. On an ARM machine, replace amd64 with arm64 in both URLs. If you prefer apt on Debian or Ubuntu, use the community package repository at pkgs.k8s.io, which has one repository per minor version.

BASH
sudo apt-get update && sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
sudo mkdir -p -m 755 /etc/apt/keyrings    # only needed on Debian < 12 and Ubuntu < 22.04
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update && sudo apt-get install -y kubectl
Old tutorials use a dead repository Guides written before late 2023 point apt at apt.kubernetes.io and yum at yum.kubernetes.io. Those legacy repositories were frozen on 13 September 2023 and receive nothing newer. Use pkgs.k8s.io, and note that moving to another minor version means editing the version in the repository URL.

Check what you installed:

BASH
kubectl version --client
TEXT
Client Version: v1.37.1
Kustomize Version: v5.x.x

The exact Kustomize line varies. What matters is the client version. kubectl supports API servers one minor version either side of itself, so a 1.37 kubectl works with 1.36, 1.37 and 1.38 clusters. When you use a managed cluster that runs an older version, match your kubectl to it.

Docker Desktop brings its own kubectl Docker Desktop puts a kubectl on your PATH, which may be a different version from the one you just installed. Run which -a kubectl (or where kubectl on Windows) and make sure the one you want comes first.

Choosing a local cluster

The Kubernetes project lists two tools for a learning cluster on your own machine, plus kubeadm for real multi-machine clusters. Either of the first two works for this whole guide.

Tool How it runs Start Good for
kind Each node is a Docker container kind create cluster Fast to create and destroy; multi-node on one laptop; CI
minikube A VM or a container, many drivers minikube start Built-in add-ons and helpers for reaching services
kubeadm Real Linux machines kubeadm init Learning how production clusters are built (Mid and Senior)

Docker Desktop and Rancher Desktop can also switch on a single-node cluster from their settings, which is convenient if you already use them. This guide uses kind in its examples because it needs nothing beyond Docker and creates a cluster in under a minute. Install it from its quick-start page (for example brew install kind on macOS), then create a cluster:

BASH
kind create cluster --name learn
TEXT
Creating cluster "learn" ...
 ✓ Ensuring node image (kindest/node:...)
 ✓ Preparing nodes
 ✓ Writing configuration
 ✓ Starting control-plane
 ✓ Installing CNI
 ✓ Installing StorageClass
Set kubectl context to "kind-learn"

Read the last line: kind has already configured kubectl to talk to the new cluster, under a context called kind-learn. The next section explains what that means. The Kubernetes version your kind cluster runs depends on your kind release; each kind release pins a default node image, and kubectl version will tell you which one you got.

If you chose minikube instead, minikube start does the equivalent and names the context minikube. Everything else in this guide is the same.

Try it
  1. Install kubectl and run kubectl version --client.
  2. Install kind (or minikube) and create a cluster named learn.
  3. Run docker ps and find the container named learn-control-plane.
a kubectl client version and a running cluster. The Docker container you found is the "machine" your whole cluster runs on. Deleting it is how you would throw the cluster away, and kind delete cluster --name learn does that cleanly.

Check your setup: kubeconfig, contexts and namespaces

Before deploying anything, confirm which cluster kubectl is talking to. This habit sounds fussy until the day you run a delete command against production because your terminal still pointed there from the morning.

kubectl reads its connection details from a file called the kubeconfig, by default ~/.kube/config. It holds three lists and one pointer:

Part Holds Example
clusters Where each API server is, and the certificate to trust it kind-learn at https://127.0.0.1:52341
users How to prove who you are: a certificate, a token or a login plugin kind-learn with a client certificate
contexts A named combination of cluster + user + default namespace kind-learn
current-context The context every command uses unless told otherwise kind-learn

A context is the thing you switch between. When you have a local cluster, a staging cluster and a production cluster, you have (at least) three contexts, and current-context decides which one your next command hits. You can point kubectl at a different file with the KUBECONFIG environment variable.

BASH
kubectl config get-contexts          # list contexts; * marks the current one
kubectl config current-context       # print just the current one
kubectl config use-context kind-learn
kubectl cluster-info                 # where is the API server, and is it answering?
kubectl get nodes                    # the machines in this cluster
TEXT
Kubernetes control plane is running at https://127.0.0.1:52341
CoreDNS is running at https://127.0.0.1:52341/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

NAME                  STATUS   ROLES           AGE   VERSION
learn-control-plane   Ready    control-plane   3m    v1.37.1

Read the node line from left to right: the node's name, its status (Ready means the kubelet is healthy and reporting in), its role, how long it has existed, and the kubelet's version. On a kind cluster you see one node that plays both roles; on a real cluster you see several, and any node that is not Ready is worth investigating.

kubectl version without --client prints both the client and the server version. The server line is the one that matters when you read documentation, because features depend on the cluster's version, not your laptop's.

Namespaces you already have

A fresh cluster is not empty. List its namespaces:

BASH
kubectl get namespaces
TEXT
NAME                 STATUS   AGE
default              Active   5m
kube-node-lease      Active   5m
kube-public          Active   5m
kube-system          Active   5m
local-path-storage   Active   5m

default is where your objects go when you do not say otherwise. kube-system holds the cluster's own components: DNS, the network plugin and, on kind, the control plane itself running as pods. Look inside it once with kubectl get pods -n kube-system so you know what "healthy" looks like, then leave it alone. local-path-storage is kind's storage helper and will not exist on other clusters.

Every command that reads or changes namespaced objects accepts -n <namespace> (or --namespace). -A (--all-namespaces) looks across all of them. To stop typing -n all day, set a default namespace for the current context:

BASH
kubectl create namespace demo
kubectl config set-context --current --namespace=demo
"connection refused" on localhost:8080 means no kubeconfig The connection to the server localhost:8080 was refused - did you specify the right host or port? is the most common first error. It does not mean something is broken on port 8080. It means kubectl found no kubeconfig at all and fell back to a default address. Start your local cluster, or set KUBECONFIG to the right file.
Try it
  1. Run kubectl config get-contexts and find the asterisk.
  2. Run kubectl get pods -A and count the pods in kube-system. Find the API server, etcd, the scheduler and the controller manager among them.
  3. Create a namespace called demo and make it your default with set-context. Run kubectl config get-contexts again and look at the NAMESPACE column.
a list of system pods whose names match the components from the architecture diagram, and a context that now defaults to demo. On kind the control plane runs as ordinary-looking pods, which is a good reminder that Kubernetes largely runs itself with its own machinery.

Your first Deployment, step by step

Now run something. This section uses kubectl's quick imperative commands, which create objects straight from the command line. They are the fastest way to see the moving parts. The next section shows the declarative YAML way you will use for real work.

BASH
# 1. Create a Deployment running three copies of nginx
kubectl create deployment web --image=nginx:1.27 --replicas=3

# 2. Watch the pods appear
kubectl get pods -w
TEXT
NAME                   READY   STATUS              RESTARTS   AGE
web-5d8c7f9b6d-2xkqp   0/1     ContainerCreating   0          2s
web-5d8c7f9b6d-8hj4w   0/1     ContainerCreating   0          2s
web-5d8c7f9b6d-tq7mz   0/1     ContainerCreating   0          2s
web-5d8c7f9b6d-2xkqp   1/1     Running             0          9s
web-5d8c7f9b6d-8hj4w   1/1     Running             0          10s
web-5d8c7f9b6d-tq7mz   1/1     Running             0          10s

Press Ctrl+C to stop watching. The pod names tell a story: web is the Deployment, 5d8c7f9b6d identifies the ReplicaSet (it is a hash of the pod template, so it changes when the template changes), and the last five characters are random, one per pod. READY 1/1 means one of one containers in the pod is ready. STATUS walks from Pending (waiting for a node) through ContainerCreating (pulling the image, starting) to Running.

Look at the three layers you just created:

BASH
kubectl get deployment,replicaset,pods
TEXT
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/web   3/3     3            3           40s

NAME                             DESIRED   CURRENT   READY   AGE
replicaset.apps/web-5d8c7f9b6d   3         3         3       40s

NAME                       READY   STATUS    RESTARTS   AGE
pod/web-5d8c7f9b6d-2xkqp   1/1     Running   0          40s
pod/web-5d8c7f9b6d-8hj4w   1/1     Running   0          40s
pod/web-5d8c7f9b6d-tq7mz   1/1     Running   0          40s

The ReplicaSet line is the reconcile loop in a table: DESIRED 3, CURRENT 3, READY 3. Now break it on purpose. Delete one pod and look again immediately:

BASH
kubectl delete pod web-5d8c7f9b6d-2xkqp
kubectl get pods
TEXT
NAME                   READY   STATUS    RESTARTS   AGE
web-5d8c7f9b6d-8hj4w   1/1     Running   0          2m
web-5d8c7f9b6d-tq7mz   1/1     Running   0          2m
web-5d8c7f9b6d-w9rcs   1/1     Running   0          3s

A new pod, with a new name, three seconds old. You changed the actual state; the desired state still said three; the controller fixed the difference. That is self-healing, and you just watched it.

Giving it an address

The three pods each have an IP address, but those addresses change whenever a pod is replaced. Put a Service in front of them:

BASH
kubectl expose deployment web --port=80 --target-port=80
kubectl get service web
TEXT
NAME   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
web    ClusterIP   10.96.142.17   <none>        80/TCP    5s

ClusterIP is the default Service type: an address reachable only from inside the cluster. To reach it from your laptop while developing, use port-forwarding, which tunnels a local port through the API server:

BASH
kubectl port-forward service/web 8080:80
TEXT
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

Open http://localhost:8080 and nginx answers. The command keeps running until you press Ctrl+C. Port-forwarding is a developer's tool for poking at a service, not a way to serve real users; you will meet the real ways in the Services section.

Scaling and cleaning up

BASH
kubectl scale deployment web --replicas=5
kubectl get pods
kubectl delete service web
kubectl delete deployment web

Scaling is just changing the desired count; two pods appear. Deleting the Deployment deletes its ReplicaSet and its pods with it, because Kubernetes tracks which objects own which (the pods carry an "owned by" reference). Deleting the pods alone would have been pointless, as you saw.

Try it
  1. Create the web Deployment with three replicas and watch the pods start with kubectl get pods -w.
  2. In a second terminal, delete one of the pods while the watch is running.
  3. Expose the Deployment, port-forward to it and load the page in a browser.
  4. Scale to five, then to one, and watch what happens to the pod list each time.
in the watch window, one pod goes to Terminating and a new one appears within seconds. Scaling down removes pods rather than stopping them. Leave the Deployment running if you want to reuse it in the next section, or delete it and start fresh from YAML.

Manifests: describing objects in YAML

The commands in the last section were quick, but they leave no record. Nobody can review them, nobody can re-run them exactly, and in a month you will not remember what flags you used. Real work uses manifests: YAML files that describe each object, stored in Git next to your code, and applied with kubectl apply. This is the declarative model in its practical form: the file is the desired state.

Every Kubernetes object, in every manifest you will ever read, has the same four top-level fields, plus a fifth that Kubernetes writes for you.

Field Means You write it?
apiVersion Which version of the API defines this kind, such as v1 or apps/v1 Yes
kind The object type: Pod, Deployment, Service… Yes
metadata Identity: name, namespace, labels, annotations Yes
spec The desired state: what you want Yes
status The observed state, written by controllers No, read-only for you

Here is the Deployment from the last section written as a manifest. Create a folder called k8s-hello and save this in it:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80

Read it top to bottom, because the nesting is the part that confuses people.

  • apiVersion: apps/v1 and kind: Deployment: Deployments live in the apps API group, version v1. Core objects such as Pods, Services and ConfigMaps use plain v1.
  • metadata.name: web: the Deployment's name, unique within its namespace.
  • spec.replicas: 3: how many pods.
  • spec.selector.matchLabels: which pods this Deployment owns. It claims every pod labelled app: web.
  • spec.template: the pod template, a complete pod description minus the name. It has its own metadata (the labels each pod gets) and its own spec (the containers).
  • containers[].name, image, ports: one container, called nginx, from the nginx:1.27 image, listening on port 80. containerPort is informational, much like EXPOSE in a Dockerfile; it does not open or publish anything by itself.
The selector must match the template's labels spec.selector.matchLabels and spec.template.metadata.labels must agree, or the API server rejects the Deployment with selector` does not match template `labels. The selector is also immutable: once the Deployment exists you cannot change it, and trying gives field is immutable. To change it, delete and recreate the Deployment.

Apply it:

BASH
kubectl apply -f deployment.yaml
TEXT
deployment.apps/web created

Run the same command again without changing the file and you get deployment.apps/web unchanged. Change replicas to 4, apply again, and you get configured. That is the heart of declarative management: apply means "make the cluster look like this file", however many times you run it, whatever state the cluster was in. You can apply a single file, a whole directory (kubectl apply -f k8s-hello/) or a URL.

A Pod on its own, for comparison

You will see bare Pod manifests in documentation and tutorials. They look like the inside of a Deployment's template, promoted to the top level:

pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello
  labels:
    app: hello
spec:
  containers:
    - name: hello
      image: busybox:1.36
      command: ["sh", "-c", "echo Hello from $(hostname); sleep 3600"]

Useful for a quick experiment; not for anything that must stay up, because nothing replaces it when it dies.

Let kubectl write the first draft

Nobody types manifests from memory. Two tricks produce a correct starting point:

BASH
# Generate YAML instead of creating the object
kubectl create deployment web --image=nginx:1.27 --replicas=3 --dry-run=client -o yaml > deployment.yaml

# Look up any field, with documentation, from the cluster itself
kubectl explain deployment.spec.replicas
kubectl explain deployment.spec.strategy --recursive

--dry-run=client -o yaml prints what the command would create and creates nothing. The output includes a few empty fields such as creationTimestamp: null and status: {} that you can delete. kubectl explain reads the schema from your cluster, so it is always right for your version, unlike a blog post from 2019.

YAML indentation is meaning YAML uses spaces, never tabs, and the indentation decides what belongs to what. Two spaces per level is the convention. A list item starts with - . If apply reports error converting YAML to JSON or an unknown field such as unknown field "spec.template.spec.containers[0].imagee", look at the line it names and at the indentation just above it.
Try it
  1. Delete any existing web Deployment, then save deployment.yaml above and apply it.
  2. Apply it a second time and read the word at the end of the output.
  3. Change replicas to 2, apply, and run kubectl get pods.
  4. Change the template's label to app: website (leave the selector alone) and apply. Read the error.
  5. Run kubectl get deployment web -o yaml and find the status block you never wrote.
created, then unchanged, then configured, then a rejection for the mismatched labels. The status section shows replicas, readyReplicas and a list of conditions: the controller's report on the actual state, sitting right next to your desired state.

Labels and selectors: how objects find each other

In the manifest, the Deployment found its pods through a label, not a name. That is not a detail of Deployments; it is how nearly everything in Kubernetes connects to everything else. Services find pods by label. Deployments find pods by label. Later, network policies, autoscalers and monitoring tools find pods by label. Get labels wrong and objects silently fail to connect, with no error message, because "a selector that matches nothing" is a perfectly valid state.

A label is a key/value pair in metadata.labels. Kubernetes gives no meaning to specific keys; teams agree on their own. Common choices include app, tier, env and version, and the project recommends a standard set with an app.kubernetes.io/ prefix, such as app.kubernetes.io/name and app.kubernetes.io/version, which many tools understand.

You can select by label from the command line:

BASH
kubectl get pods --show-labels                 # show every pod's labels
kubectl get pods -l app=web                    # equality
kubectl get pods -l 'env in (prod,staging)'    # set-based
kubectl get pods -l app=web,tier!=cache        # combine with a comma (AND)
kubectl label pod <pod-name> tier=frontend     # add a label to a live object
kubectl label pod <pod-name> tier-             # remove it (note the trailing dash)

Annotations look similar (key/value pairs under metadata.annotations) but are never used for selection. They hold notes for people and tools: a description, a Git commit, the reason for a change. Use a label if you will ever want to select on it; use an annotation for everything else.

Here is an experiment that shows how literal the matching is. Take a running pod from your web Deployment and change its app label:

BASH
kubectl label pod <one-web-pod> app=debug --overwrite
kubectl get pods --show-labels

The Deployment now counts only two pods with app=web, so it creates a third. The relabelled pod keeps running, orphaned, because nothing selects it any more. That is a real debugging technique: pull a misbehaving pod out of rotation to inspect it while its replacement serves traffic. It is also a warning: the connection between objects is exactly as strong as the labels.

Try it
  1. With the web Deployment running, list pods with --show-labels.
  2. Relabel one pod to app=debug with --overwrite and list them again.
  3. Run kubectl get pods -l app=web and kubectl get pods -l app=debug.
  4. Delete the orphan with kubectl delete pod -l app=debug.
the Deployment ends up with its full count of app=web pods plus one extra orphan that it ignores entirely. Nothing warned you; the selector simply stopped matching. Remember this when a Service later "has no endpoints".

Services: a stable address for pods that come and go

Pods are replaced all the time: during updates, when they crash, when a node is drained for maintenance. Each replacement gets a new IP address. If your frontend called your API by pod IP, it would break every few hours. A Service gives a group of pods one address that never changes and spreads traffic across whichever pods currently match its selector and are ready.

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

Two port fields, two different meanings:

Field Meaning
port The port the Service listens on. Clients connect to web:80
targetPort The port the container listens on. Traffic is forwarded there

They are often the same number, but they need not be. An app listening on 8000 behind a Service on port 80 is common and perfectly normal: port: 80, targetPort: 8000.

Finding a Service by name

Every cluster runs a DNS server (CoreDNS) that gives each Service a name. From any pod in the same namespace, the Service above is simply web. From another namespace it is web.demo (service name, then namespace), and its full name is web.demo.svc.cluster.local. This is what makes Kubernetes configuration portable: your app connects to http://web, and it works in every cluster and every environment where a Service by that name exists.

Test it from inside the cluster with a throwaway pod:

BASH
kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- sh

Then, at the prompt inside that pod:

BASH
wget -qO- http://web | head -n 4
nslookup web
exit

--rm -it --restart=Never means "run one interactive pod and delete it when I exit", the Kubernetes equivalent of docker run --rm -it. It is the quickest way to test networking from the inside.

Where the traffic actually goes

A Service keeps a live list of the pod addresses behind it, stored in objects called EndpointSlices. When a Service seems dead, this list is the first thing to check:

BASH
kubectl get endpointslices -l kubernetes.io/service-name=web
TEXT
NAME        ADDRESSTYPE   PORTS   ENDPOINTS                            AGE
web-7xk2p   IPv4          80      10.244.0.12,10.244.0.13,10.244.0.14   5m

Three addresses: three ready pods. An empty ENDPOINTS column means the selector matches no ready pods: either a label mismatch (the previous section) or pods that are not passing their readiness check (later in this guide).

Use EndpointSlices, not Endpoints Older tutorials run kubectl get endpoints. The v1 Endpoints API was deprecated in Kubernetes 1.33, and using it now prints a warning pointing you to discovery.k8s.io/v1 EndpointSlices. The information is the same; the command above is the current way to see it.

The four Service types

Type Reachable from Typical use
ClusterIP (default) Inside the cluster only Internal APIs, databases, anything other pods call
NodePort Every node's IP on a high port (default range 30000–32767) Quick external access on bare-metal or lab clusters
LoadBalancer A cloud load balancer with its own external IP Exposing a service to the internet on EKS, GKE, AKS
ExternalName A DNS alias to a name outside the cluster Pointing db at a managed database's hostname

On a cloud cluster, type: LoadBalancer makes the provider create a real load balancer and put its address in the EXTERNAL-IP column. On kind or minikube there is no cloud to ask, so the column stays at <pending> (minikube offers minikube tunnel to fill it in). For local learning, ClusterIP plus kubectl port-forward is all you need.

Do not use externalIPs Some old examples set spec.externalIPs on a Service. That field was deprecated in Kubernetes 1.36 because of a long-standing security issue (CVE-2020-8554), and its removal is planned. Use a LoadBalancer or NodePort Service, or the Gateway API described later, instead.
Try it
  1. Save service.yaml next to your Deployment and apply the whole folder with kubectl apply -f ..
  2. Start a busybox pod as above, run wget -qO- http://web and nslookup web, then exit.
  3. List the EndpointSlices, scale the Deployment to 1 and list them again.
  4. Change the Service's selector to app: nope, apply, and list the EndpointSlices a third time. Change it back.
the nginx welcome page from inside the cluster, a DNS answer with the Service's ClusterIP, and an endpoint list that shrinks from three to one and then to nothing. An empty endpoint list is the signature of a selector problem, and now you know exactly what it looks like.

The commands you will use, grouped by task

kubectl has a lot of subcommands. Day to day, you use about twenty, and they fall into a few groups by what you are trying to do. Nearly all of them follow the pattern kubectl <verb> <type> <name> [flags], and types have short forms: po for pods, deploy for deployments, svc for services, ns for namespaces, cm for configmaps. kubectl api-resources lists every type and its short name.

Looking around

BASH
kubectl get pods                         # list; add -o wide for node and IP
kubectl get deploy,svc,pods              # several types at once
kubectl get pods -A                      # every namespace
kubectl get pod <pod> -o yaml            # the full object, spec and status
kubectl get pods -w                      # watch for changes
kubectl describe pod <pod>               # human-readable detail plus recent Events

get answers "what exists and what state is it in". describe answers "why": it includes the Events at the bottom, a timeline of what the cluster did to that object (scheduled, pulled the image, started, failed a health check). When anything goes wrong, describe is almost always your second command.

Kubernetes 1.37 also made a newer output format, -o kyaml, generally available. It is a stricter YAML style designed to avoid YAML's classic ambiguities. Any KYAML output is still valid YAML. You do not need it yet, but you will start seeing it in newer documentation.

Reading what the app says

BASH
kubectl logs <pod>                       # the container's stdout and stderr
kubectl logs <pod> -f                    # follow live
kubectl logs <pod> --previous            # the PREVIOUS container, the one that crashed
kubectl logs <pod> --tail=100 --since=1h
kubectl logs deploy/web                  # logs from one pod of the Deployment
kubectl logs <pod> -c <container>        # pick a container when the pod has several

--previous is the flag that matters most during a crash loop. After a restart, plain kubectl logs shows the new container, which may have printed nothing yet. --previous shows the one that died, including its last words.

Getting inside

BASH
kubectl exec -it <pod> -- sh             # a shell in a running container
kubectl exec <pod> -- env                # run one command and print the result
kubectl port-forward svc/web 8080:80     # tunnel a local port to a Service
kubectl port-forward pod/<pod> 8080:80   # or straight to one pod
kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- sh   # a throwaway debug pod

The -- separates kubectl's own flags from the command to run inside the container. Leave it out and kubectl tries to interpret your command's flags as its own.

Creating and changing things

BASH
kubectl apply -f deployment.yaml         # create or update from a file
kubectl apply -f k8s/                    # a whole directory
kubectl delete -f deployment.yaml        # delete what the file describes
kubectl scale deploy/web --replicas=5
kubectl set image deploy/web nginx=nginx:1.28
kubectl create deployment web --image=nginx:1.27 --dry-run=client -o yaml   # generate YAML
kubectl explain pod.spec.containers      # field documentation from the cluster

For anything that lasts longer than an experiment, prefer apply with a file you commit. Commands such as scale and set image change the live object but not your file, so the next apply of an outdated file silently puts the old value back.

kubectl run -f is deprecated Kubernetes 1.37 deprecated kubectl run --filename (-f). Use kubectl run NAME --image=IMAGE for quick one-off pods, and kubectl apply -f for anything defined in a file.

Rollouts

BASH
kubectl rollout status deploy/web        # wait for an update to finish
kubectl rollout history deploy/web       # list revisions
kubectl rollout undo deploy/web          # back to the previous revision
kubectl rollout restart deploy/web       # replace every pod, gradually, same spec

The next section covers these properly.

Resource usage

BASH
kubectl top pods
kubectl top nodes

top shows live CPU and memory use, and needs an add-on called metrics-server. kind does not install it, so kubectl top fails there with Metrics API not available until you add it (minikube has minikube addons enable metrics-server). Its API, metrics.k8s.io/v1, became stable in Kubernetes 1.37.

Set up completion and an alias on day one source <(kubectl completion bash) (or zsh) in your shell profile gives you tab completion for commands, types and even pod names, so you never copy a random pod suffix by hand again. Many people also add alias k=kubectl. macOS still ships Bash 3.2, which is too old for kubectl's Bash completion, so use zsh there or install a newer Bash.
Try it
  1. Enable shell completion, then type kubectl describe pod web and press Tab.
  2. Run kubectl describe on one of your pods and read the Events at the bottom from top to bottom.
  3. Run kubectl logs deploy/web, then load the page through a port-forward and run it again.
  4. Run kubectl exec into a pod and cat /etc/resolv.conf.
Events that read like a diary (Scheduled, Pulling, Pulled, Created, Started), nginx access-log lines for your own requests, and a resolv.conf whose search list includes demo.svc.cluster.local. That search list is why the short name web resolves inside the namespace.

Updating an app: rolling updates and rollbacks

Shipping a new version is where Kubernetes pays for itself. When you change a Deployment's pod template (a new image tag, a new environment variable, anything under spec.template), the Deployment does not stop everything and restart. It performs a rolling update: it creates a new ReplicaSet for the new template and shifts pods over gradually, starting new pods and removing old ones, so that there are always enough ready pods to serve traffic.

OLD RS3 pods, v1
→
SURGE+1 new v2 pod
→
SWAPv2 ready, −1 v1
→
NEW RS3 pods, v2

Two settings control the pace, under spec.strategy.rollingUpdate. Both default to 25%:

  • maxSurge: how many pods above the desired count may exist during the update. More surge means a faster rollout and more temporary resource use.
  • maxUnavailable: how many pods below the desired count are allowed. Zero means "never drop below full capacity".

The alternative strategy, Recreate, deletes all old pods before starting new ones. That means downtime, and it exists for apps that genuinely cannot run two versions at once.

Watch an update happen. Change the image in deployment.yaml from nginx:1.27 to nginx:1.28, then:

BASH
kubectl apply -f deployment.yaml
kubectl rollout status deploy/web
TEXT
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

kubectl get rs now shows two ReplicaSets: the new one with three pods and the old one scaled to zero. The old one is kept on purpose, because it is how rollback works.

When the new version is broken

Now ship a mistake. Set the image to a tag that does not exist:

BASH
kubectl set image deploy/web nginx=nginx:9.99-does-not-exist
kubectl get pods
TEXT
NAME                   READY   STATUS             RESTARTS   AGE
web-6b9f5c8d47-5kzvn   1/1     Running            0          3m
web-6b9f5c8d47-m2l8q   1/1     Running            0          3m
web-6b9f5c8d47-x7rjt   1/1     Running            0          3m
web-7f6d4b9c88-qp2dd   0/1     ImagePullBackOff   0          40s

The rolling update protected you. The first new pod could not start, so it never became ready, so the Deployment never removed an old pod. Your three working pods are still serving. kubectl rollout status would wait here (and eventually report that the rollout exceeded its progress deadline). Undo it:

BASH
kubectl rollout history deploy/web
kubectl rollout undo deploy/web
TEXT
deployment.apps/web rolled back

undo switches the Deployment back to the previous ReplicaSet's template. kubectl rollout undo deploy/web --to-revision=2 picks a specific revision from the history. The CHANGE-CAUSE column in the history is empty unless you record why each change happened; set the kubernetes.io/change-cause annotation with kubectl annotate deploy/web kubernetes.io/change-cause="upgrade to nginx 1.28" and it appears there.

An undo does not change your file rollout undo changes the live Deployment. Your deployment.yaml still says the broken image, so the next kubectl apply reintroduces the bug. After a rollback, fix the file (or revert the commit) so Git and the cluster agree again.

kubectl rollout restart deploy/web is the third command worth knowing. It replaces every pod with a fresh one, using the same rolling process, without changing the spec. It is the standard way to make pods pick up a changed ConfigMap or Secret they only read at startup.

Try it
  1. Update the image to nginx:1.28 with apply and watch kubectl get pods -w in a second terminal.
  2. Set a non-existent tag with kubectl set image, and confirm the old pods keep running.
  3. Run kubectl rollout history, then kubectl rollout undo, then kubectl get rs.
pods replaced one at a time on the good update; a single stuck pod and three healthy old ones on the bad update; and after the undo, the healthy ReplicaSet back at three with the broken one at zero. At no point were fewer than three pods serving.

Configuration: ConfigMaps and Secrets

An image should be the same in development, staging and production. What differs between them (the database address, the log level, the model version to load, the API key) is configuration, and it belongs outside the image. Kubernetes gives you two objects for it. A ConfigMap holds non-confidential key/value settings or whole files. A Secret holds confidential values such as passwords and tokens. Both are limited to 1 MiB, and pods consume them in the same two ways: as environment variables or as files in a mounted volume.

Create them from the command line:

BASH
kubectl create configmap app-config \
  --from-literal=LOG_LEVEL=info \
  --from-literal=MODEL_NAME=sentiment-v1

kubectl create secret generic app-secrets \
  --from-literal=API_TOKEN=change-me-please

Or declare them in YAML, which is what you commit:

config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
  MODEL_NAME: "sentiment-v1"
  settings.ini: |
    [server]
    workers = 2
    timeout_seconds = 30

Notice that ConfigMap values are always strings, so quote numbers and booleans. The | on settings.ini starts a multi-line value: that key holds a whole file.

Using them in a pod

This fragment of a pod template shows all three common patterns at once:

deployment.yaml
    spec:
      containers:
        - name: app
          image: nginx:1.27
          envFrom:
            - configMapRef:
                name: app-config          # every key becomes an env var
          env:
            - name: API_TOKEN
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: API_TOKEN          # one key, one env var
          volumeMounts:
            - name: config-files
              mountPath: /etc/app         # settings.ini appears as /etc/app/settings.ini
              readOnly: true
      volumes:
        - name: config-files
          configMap:
            name: app-config
            items:
              - key: settings.ini
                path: settings.ini
  • envFrom imports every key of the ConfigMap as an environment variable. Quick, but keys that are not valid variable names (such as settings.ini) are skipped.
  • env with valueFrom picks one key and names the variable yourself. Use it for Secrets, so you know exactly which values land in the environment.
  • A configMap volume turns keys into files. Use it for whole configuration files.
A Secret is encoded, not encrypted Secret values are stored base64-encoded. Base64 is an encoding, not encryption: anyone who can read the Secret can decode it in one command, kubectl get secret app-secrets -o jsonpath='{.data.API_TOKEN}' | base64 -d. By default the values are not encrypted in etcd either, unless the cluster operator enables encryption at rest. Treat access to Secrets as access to the passwords, and never commit a Secret manifest with real values to Git.

What Secrets do give you is separation: a distinct object type with its own permissions, kept out of your image and out of your ConfigMaps, which Mid-level and Senior build on with encryption at rest, tight access control and external secret stores such as Vault. The type you create with generic is Opaque; others exist for specific jobs, such as kubernetes.io/tls for certificates and kubernetes.io/dockerconfigjson for registry credentials.

When changes take effect

This catches everyone once. Environment variables are read when the container starts. Edit a ConfigMap and running pods keep the old values until they are replaced. Files mounted from a ConfigMap volume are updated in place after a short delay, but that only helps if your app re-reads them. The reliable habit: after changing configuration, run kubectl rollout restart deploy/<name>.

If a pod references a ConfigMap or Secret that does not exist, it does not start at all. Its status shows CreateContainerConfigError, and kubectl describe names the missing object, for example secret "app-secrets" not found. You can mark a reference optional: true if the pod should start without it.

Try it
  1. Apply config.yaml and create the app-secrets Secret.
  2. Add the envFrom, env and volume sections above to your Deployment's container and apply it.
  3. Run kubectl exec deploy/web -- env | grep -E 'LOG_LEVEL|MODEL_NAME|API_TOKEN' and kubectl exec deploy/web -- cat /etc/app/settings.ini.
  4. Change LOG_LEVEL to debug, apply, and read the variable again. Then run kubectl rollout restart deploy/web and read it once more.
all three values and the file present inside the container; the old LOG_LEVEL still showing after the ConfigMap changed; and the new one appearing only after the restart replaced the pods. That delay is the most common "I changed the config and nothing happened" puzzle.

Resources and health checks

Two small additions to every container spec turn a demo into something a cluster can run responsibly: telling Kubernetes how much CPU and memory the container needs, and telling it how to check the container is healthy.

Requests and limits

deployment.yaml
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              memory: "256Mi"

Units first. CPU is measured in cores, and m means thousandths: 100m is a tenth of a core, 1 or 1000m is one core. Memory uses binary suffixes: Mi (mebibytes) and Gi (gibibytes). Write 128Mi, not 128M (which means megabytes) and certainly not 128m, which Kubernetes reads as 0.128 bytes.

The two fields do different jobs:

Requests Limits
Used by The scheduler, when choosing a node The node, while the container runs
Means "Reserve at least this much for me" "Never let me use more than this"
CPU over the value Fine, if spare CPU exists The container is throttled (slowed down)
Memory over the value Fine, if spare memory exists The container is killed (OOMKilled) and restarted

The scheduler places pods using requests, not actual usage. A node with 4 CPUs can hold pods requesting 4 CPUs in total, however idle they are. If no node has enough unrequested capacity, a new pod waits in Pending with an event like 0/3 nodes are available: 3 Insufficient cpu. That message means "nothing has room for what you asked", not that the cluster is busy.

Kubernetes also sorts pods into three quality of service classes based on these fields. Guaranteed pods set requests equal to limits for every container. Burstable pods set some values. BestEffort pods set none, and they are the first to be evicted when a node runs short of memory. A pod with no requests at all is therefore both invisible to the scheduler's planning and first in line to be thrown off a struggling node.

A sensible starting point Set CPU and memory requests on every container, based on what you have seen it use. Set a memory limit so one leaking container cannot take down its neighbours. Many teams leave out a CPU limit, because throttling hurts latency and spare CPU is otherwise wasted. Revisit the numbers once you have real usage data from kubectl top.

Probes: how Kubernetes knows your app is healthy

By default Kubernetes considers a container healthy as long as its process is running. That misses a lot: an app that is still loading a 2 GB model, an app that is deadlocked but still alive, an app that has lost its database connection. Probes let you describe health properly. There are three, and they do different things.

Probe Question On failure
readinessProbe "Can this pod take traffic right now?" Removed from the Service's endpoints until it passes again. Not restarted
livenessProbe "Is this container stuck beyond recovery?" The container is restarted
startupProbe "Has the app finished starting?" The other two probes wait until it passes; restarted if it never does
deployment.yaml
          readinessProbe:
            httpGet:
              path: /
              port: 80
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /
              port: 80
            periodSeconds: 10
            failureThreshold: 3

Each probe can make an HTTP request (httpGet, success is a 2xx or 3xx status), open a TCP connection (tcpSocket) or run a command inside the container (exec, success is exit code 0). periodSeconds sets how often it runs, and failureThreshold how many consecutive failures count as failure.

The readiness probe is the one that makes rolling updates safe. A new pod joins the Service only once it reports ready, and the Deployment only removes an old pod once the new one is ready. Without a readiness probe, "ready" simply means "the process started", so users can be sent to an app that is still warming up. For ML services that load a model at startup, a readiness probe on an endpoint that answers only once the model is loaded is essential.

A liveness probe can cause the outage it was meant to prevent If a liveness probe checks something outside the container (a database, another service), a problem there makes every pod fail its probe and restart at the same time, turning a slowdown into a total outage. If the probe fires before a slow app has finished starting, the app is killed in a loop and never comes up. Keep liveness checks cheap and local, and add a startupProbe for slow starters rather than a huge initialDelaySeconds.
Try it
  1. Add the requests, limits and both probes above to your web container and apply.
  2. Run kubectl describe pod on one pod and find the QoS Class, Liveness and Readiness lines.
  3. Change the readiness probe's path to /nope and apply. Watch the READY column and the Service's EndpointSlice.
  4. Put the path back.
QoS Class: Burstable and your probe settings in describe. With the broken path, new pods sit at 0/1 READY with Readiness probe failed: HTTP probe failed with statuscode: 404 in their events, the rollout stalls, and the old pods keep serving: the same protection you saw with the bad image.

Keeping data: volumes and PersistentVolumeClaims

A container's filesystem is temporary. When the container restarts, anything it wrote outside a volume is gone, exactly as in Docker. Kubernetes has two broad kinds of storage to fix that, and at this level you need to recognise both.

Pod-scoped volumes live as long as the pod. An emptyDir volume starts empty when the pod is created, survives container restarts, and is deleted with the pod. It is useful for scratch space, caches and files shared between containers in one pod. The configMap and secret volumes you already used are also this kind.

Persistent storage outlives pods. You ask for it with a PersistentVolumeClaim (PVC): "I need 1 GiB of storage that one node can write to". The cluster satisfies the claim with a PersistentVolume (PV), a real piece of storage such as a cloud disk. A StorageClass describes what kind of storage to create automatically when a claim arrives; kind and minikube ship a default one that uses a folder on the node.

pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

A pod then mounts the claim by name:

deployment.yaml
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: data

ReadWriteOnce means one node at a time can mount the volume read-write, which is what most cloud disks support. ReadWriteMany (many nodes) needs a shared file system and is not available everywhere. kubectl get pvc shows whether a claim is Bound (storage attached) or Pending.

Databases need more than a PVC A Deployment with a PVC works for one replica, but replicas of a Deployment are interchangeable and share one claim, which is wrong for a database. Stateful systems use a StatefulSet, which gives each replica a stable name and its own claim. That is Mid-level material; for learning, run your database in the cluster with one replica, or use a managed database outside it.
Try it
  1. Apply pvc.yaml and run kubectl get pvc. On kind the claim waits in Pending until a pod uses it.
  2. Generate a one-replica Deployment with kubectl create deployment logger --image=busybox:1.36 --dry-run=client -o yaml -- sh -c "date >> /data/log; sleep 3600", save it, add the volumes block above and a matching volumeMounts entry at /data, and apply it.
  3. Delete its pod, wait for the replacement, and kubectl exec in to cat /data/log.
a claim that becomes Bound once the pod is scheduled, and a log file with two timestamps: one from each pod. The pod was replaced; the data was not.

Reading pod status and fixing the common errors

Sooner or later a pod will not start, and the STATUS column will show a word you have not seen. The good news is that a beginner meets the same half-dozen failures over and over, and each has a recognisable signature. The skill to build is a fixed routine for reading them, so you never have to guess.

The debugging routine

  1. Getkubectl get pods. Read STATUS, READY and RESTARTS. A climbing RESTARTS count means the container starts and then dies.
  2. Describekubectl describe pod <pod>. Scroll to Events at the bottom and read the last few lines. Also read State and Last State for each container: the reason and exit code of the last crash live there.
  3. Logskubectl logs <pod>, and kubectl logs <pod> --previous if the container has restarted. Application errors (a stack trace, a missing setting) show up here, not in Events.
  4. Get insidekubectl exec -it <pod> -- sh if the container stays up long enough, or a throwaway busybox pod to test networking and DNS from next to it.
  5. Widen the viewkubectl get events --sort-by=.lastTimestamp for the whole namespace, and kubectl describe node <node> if the problem looks like capacity.

Events and logs answer different questions. Events are the cluster talking about your pod: scheduling, image pulls, probe failures, kills. Logs are your application talking. If the image never pulled, there are no logs to read; if the app crashed on a bad setting, the events only say "back-off", and the reason is in the logs. Check both before concluding anything.

The statuses, and what each one means

STATUS What is happening Where to look Usual fix
Pending No node has been assigned, or storage is not ready describe → FailedScheduling event Lower requests, add capacity, fix a node selector, or create the missing storage
ContainerCreating (for a long time) Pulling a big image, or waiting for a volume or Secret describe → Events Usually wait; otherwise read the event
ErrImagePull then ImagePullBackOff The image cannot be pulled describe → Failed to pull image Fix the name or tag; add credentials for a private registry
InvalidImageName The image string is malformed describe Fix the typo in the reference
CreateContainerConfigError A referenced ConfigMap, Secret or key is missing describe → message names it Create it, fix the name, or mark it optional
RunContainerError The container could not start, often a bad command describe → executable file not found in $PATH Fix the command or entrypoint
CrashLoopBackOff The container keeps starting and exiting logs --previous Fix what the app complains about
OOMKilled (in Last State) It exceeded its memory limit describe → Reason: OOMKilled, Exit Code: 137 Raise the limit, or fix the memory use
Running but 0/1 READY The readiness probe is failing describe → Readiness probe failed Fix the probe's path or port, or the app's health endpoint
Evicted The node ran short of memory or disk describe → The node was low on resource Set requests and limits; free node disk

Three of these deserve a closer look, because they account for most beginner time lost.

ImagePullBackOff. The events show the exact reason, and there are only three common ones. not found or manifest unknown means the name or tag is wrong; check for typos and remember that tags are case-sensitive. pull access denied or unauthorized means the registry is private and the node has no credentials: create a registry Secret with kubectl create secret docker-registry regcred --docker-server=... --docker-username=... --docker-password=... and reference it in the pod spec under imagePullSecrets. toomanyrequests means you have hit a registry's rate limit. One kind-specific trap: an image you built locally with docker build is not visible inside the kind cluster, because kind's node has its own image store. Load it with kind load docker-image myapp:dev --name learn, or push it to a registry.

CrashLoopBackOff. This is not an error in itself. It means "the container exited, Kubernetes restarted it, it exited again, and Kubernetes is now waiting before the next try". The wait starts at 10 seconds and doubles each time up to 5 minutes, which is why a crash-looping pod seems to sit still. The real error is always in the application output, and kubectl logs <pod> --previous is the command that shows it. The usual causes are a missing environment variable, a wrong command, a failed connection to a dependency at startup, or a liveness probe killing an app that was simply slow.

Pending. Read the FailedScheduling event word for word; it lists why each node was rejected. Insufficient cpu or Insufficient memory means the requests do not fit anywhere. node(s) had untolerated taint {node-role.kubernetes.io/control-plane: } means the only nodes available are control-plane nodes, which refuse ordinary workloads by default. node(s) didn't match Pod's node affinity/selector means you asked for a node label that no node has. pod has unbound immediate PersistentVolumeClaims means the storage is not ready.

Exit codes

When a container dies, describe shows its exit code under Last State: Terminated. The same codes you met in Docker apply:

Exit code Means
0 The process finished normally. In a Deployment that still counts as a crash, because a server is not supposed to finish
1 A generic application error. Read the logs
127 Command not found
137 Killed with SIGKILL: usually the memory limit (OOMKilled)
143 Terminated with SIGTERM: a normal shutdown request

Errors that come from kubectl itself

Not every error is about pods. These come back straight from the command line:

  • The connection to the server localhost:8080 was refused means no kubeconfig was found. The connection to the server <host>:6443 was refused means the API server at that address is not answering; for a local cluster, it is probably not running.
  • error: You must be logged in to the server (Unauthorized) means your credentials have expired or belong to another cluster. Refresh your login and check kubectl config current-context.
  • Error from server (Forbidden): pods is forbidden: User "jane" cannot list resource "pods" in API group "" in the namespace "dev" means you are authenticated but not permitted. kubectl auth can-i list pods -n dev tells you what you can do.
  • Error from server (NotFound): deployments.apps "web" not found most often means you are in the wrong namespace. Add -n or check your context's default.
  • no matches for kind "Deployment" in version "extensions/v1beta1" means you copied a manifest from an old tutorial. That API version was removed years ago; Deployments are apps/v1.
Read the whole message, slowly Kubernetes error messages are long, but they are precise. 0/3 nodes are available: 1 node(s) had untolerated taint, 2 Insufficient memory tells you exactly how many nodes were considered and why each was rejected. Most beginner debugging time is lost by reading the first four words and guessing the rest.
Try it
  1. Create a pod with a typo in the image: kubectl run broken --image=ngnix:1.27. Diagnose it with get and describe.
  2. Create one that crashes: kubectl run crasher --image=busybox:1.36 --restart=Always -- sh -c "echo missing DB_URL; exit 1". Watch RESTARTS climb and read kubectl logs crasher --previous.
  3. Create one that asks for too much: kubectl run greedy --image=nginx:1.27 --overrides='{"spec":{"containers":[{"name":"greedy","image":"nginx:1.27","resources":{"requests":{"cpu":"64"}}}]}}'. Read its scheduling event.
  4. Delete all three with kubectl delete pod broken crasher greedy.
three different statuses (ImagePullBackOff, CrashLoopBackOff, Pending), each explained in plain words either in the Events or in the previous container's logs. You found every cause without guessing, which is the skill this section is really about.

Letting users in: Ingress and the Gateway API

A ClusterIP Service is reachable only inside the cluster. For real users on the internet you need a way in. You have seen one already, type: LoadBalancer, which gives each Service its own cloud load balancer. That works, but one load balancer per service gets expensive, and it only understands ports, not URLs. Most teams want one entry point that routes by hostname and path: api.example.com/predict to one Service, example.com/ to another, with TLS handled in one place.

Kubernetes has two APIs for that HTTP-level routing, and you need to know which is which, because the landscape changed in 2026.

Ingress (networking.k8s.io/v1) is the older one. An Ingress object lists routing rules, and a separately installed Ingress controller reads them and does the actual routing. The API still works and is not being removed, but it is frozen: it gets no new features. The most widely used controller, the community's Ingress NGINX project, was retired on 24 March 2026, and it no longer receives releases or security fixes. Existing installations keep running, but you should not choose it for a new cluster, whatever older tutorials say.

Gateway API (gateway.networking.k8s.io/v1) is the successor, and the project's recommendation for new work. It splits the job into roles: a GatewayClass names the implementation (provided by your platform), a Gateway is the actual entry point (run by the cluster operator), and an HTTPRoute holds the routing rules for one application (written by the app team, which is you). Like Ingress, it needs an implementation installed in the cluster; several projects provide one.

A route for your web Service looks like this, assuming the platform team has already created a Gateway named public:

httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web
spec:
  parentRefs:
    - name: public
  hostnames:
    - "web.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: web
          port: 80

Read it as a sentence: "attach to the Gateway called public; for requests to web.example.com whose path starts with /, send them to the Service web on port 80." Setting up the Gateway itself, TLS certificates and choosing an implementation are Mid-level topics. At this level, it is enough to recognise both APIs, know that Ingress NGINX is retired, and know that new routing is written as an HTTPRoute.

Try it
  1. Run kubectl api-resources | grep -i -E 'ingress|gateway' on your kind cluster.
  2. Run kubectl explain ingress.spec.rules, then try kubectl explain httproute.spec.
the Ingress type is listed, because it is built in, but no Gateway types, and explain httproute fails. Gateway API is installed as an add-on (its object types are added to the cluster as custom resource definitions), which is exactly why a fresh cluster does not know about it until someone installs it.

Putting it all together

Everything above, in one small project. Nothing here is new. It is a web service with its own namespace, configuration kept out of the image, a Secret, resource requests, health checks, a Service in front and a rolling update strategy that never drops below full capacity. Read it as a whole and you should recognise every line and be able to say why it is there.

The app is deliberately simple: nginx serving a page that comes from a ConfigMap, so you can see configuration reach a running container without building an image. Swap in your own image later and the structure stays the same. Create a folder called k8s-project and put four files in it.

k8s-project/00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: shop
  labels:
    app.kubernetes.io/part-of: shop
k8s-project/10-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: web-config
  namespace: shop
data:
  LOG_LEVEL: "info"
  index.html: |
    <h1>Hello from Kubernetes</h1>
    <p>Served by a Deployment, configured by a ConfigMap.</p>
k8s-project/20-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          env:
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: web-config
                  key: LOG_LEVEL
            - name: API_TOKEN
              valueFrom:
                secretKeyRef:
                  name: web-secrets
                  key: API_TOKEN
          resources:
            requests:
              cpu: "100m"
              memory: "64Mi"
            limits:
              memory: "128Mi"
          readinessProbe:
            httpGet:
              path: /
              port: 80
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /
              port: 80
            periodSeconds: 10
            failureThreshold: 3
          volumeMounts:
            - name: site
              mountPath: /usr/share/nginx/html
              readOnly: true
      volumes:
        - name: site
          configMap:
            name: web-config
            items:
              - key: index.html
                path: index.html
k8s-project/30-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shop
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

The numeric prefixes are a convention, not a Kubernetes feature: kubectl apply -f on a directory processes files in name order, so the namespace exists before anything tries to go into it. The Secret is deliberately not in the folder, because a real one never belongs in Git. Create it by hand, then apply the rest.

BASH
kubectl apply -f k8s-project/00-namespace.yaml
kubectl create secret generic web-secrets -n shop --from-literal=API_TOKEN=change-me-please
kubectl apply -f k8s-project/
kubectl rollout status deploy/web -n shop
kubectl get deploy,rs,pods,svc -n shop
kubectl get endpointslices -n shop -l kubernetes.io/service-name=web
kubectl port-forward -n shop svc/web 8080:80

Open http://localhost:8080 and you should see the page from the ConfigMap. The second apply reports the namespace as unchanged and everything else as created, which is the declarative model showing through: applying the same file twice is harmless.

Now run the three exercises that prove the setup does its job. First, self-healing: delete a pod and watch a replacement appear. Second, a safe update: change the image to nginx:1.28 in 20-deployment.yaml, apply, and watch kubectl get pods -n shop -w; because maxUnavailable is 0, a new pod must pass its readiness probe before an old one goes. Third, a configuration change: edit the page in 10-config.yaml, apply, and reload. The mounted file updates by itself after a short delay, while LOG_LEVEL in the environment only changes after kubectl rollout restart deploy/web -n shop.

Ten decisions carry the lesson of this page, and each maps to a section above:

Line Why it is there
A dedicated shop namespace Keeps the project's objects together and deletable in one command
Manifests in files, applied with apply -f The folder is the desired state; it can be reviewed, versioned and re-applied
selector.matchLabels equal to the template labels The Deployment owns exactly these pods; a mismatch is rejected
Pinned image tag nginx:1.27 You always know what is running, and rollback has something to roll back to
ConfigMap for settings and the page One image, many environments; configuration changes without a rebuild
Secret created outside Git, read with secretKeyRef Credentials never land in the repository or the image
CPU and memory requests, a memory limit The scheduler can place the pod, and a leak cannot take down the node
Readiness probe Traffic only reaches pods that can answer; rollouts wait for it
maxUnavailable: 0 Updates never drop below full capacity
A ClusterIP Service selecting app: web One stable name, web, whatever happens to the pods

When you are done, one command removes the lot, because deleting a namespace deletes everything inside it:

BASH
kubectl delete namespace shop
Try it: the one that matters
  1. Build the four files by hand, create the Secret, and apply the folder. Get the page through a port-forward.
  2. Delete a pod, update the image, and change the page, confirming each result as described above.
  3. Break it on purpose three ways, one at a time: a typo in the image tag, the Secret's name misspelled, and the Service selector set to app: webb. Diagnose each with the debugging routine, then fix it.
  4. Replace nginx with an image you built yourself from the Docker guide, loading it into kind with kind load docker-image, and adjust the ports and probes to match.
a running service you described entirely in files, that heals itself, updates without downtime and takes its configuration from the cluster. The three breakages produce ImagePullBackOff, CreateContainerConfigError and an empty EndpointSlice, and you now know which command reveals each one. Once your own image runs here, the page becomes reference material.

What you can now do, and what comes next

You can explain what Kubernetes is for and what came before it, describe the reconcile loop and why it makes recovery, scaling and deployment the same mechanism, name the parts of a cluster and what each one does, install kubectl and run a local cluster, check which cluster and namespace you are talking to, write Deployment and Service manifests and apply them, connect objects with labels, reach a service by DNS name, configure an app with ConfigMaps and Secrets, set requests, limits and probes, keep data in a PersistentVolumeClaim, ship and roll back an update safely, and diagnose the common pod failures from their status, events and logs. That is enough to deploy and operate a real stateless service on a cluster someone else runs, and to hold your own in a team that uses Kubernetes every day.

Can you…
Say what "declarative" means in one sentence? You describe the end state; controllers work out the steps
Explain why a deleted pod comes back? Its controller still wants N pods, so it creates one
Name the only component that talks to etcd? The kube-apiserver
Say what the scheduler does and does not do? Picks a node for a pod; starts nothing
Explain pod versus Deployment versus Service? One running copy; the keeper of N copies; the stable address in front
Say why a Service can have no endpoints? The selector matches no ready pods
Tell port from targetPort? Where the Service listens; where the container listens
Reach a Service from another namespace? name.namespace, or the full name.namespace.svc.cluster.local
Make a config change reach env vars? kubectl rollout restart
Say whether a Secret is encrypted? Base64-encoded, not encrypted by default
Explain requests versus limits? Scheduling reservation versus enforced ceiling
Say what readiness does that liveness does not? Removes a pod from traffic without restarting it
Undo a bad release? kubectl rollout undo, then fix the file
Find why a pod crash-loops? kubectl logs <pod> --previous
Find why a pod is Pending? The FailedScheduling event in kubectl describe

Mid-level takes each of those topics further. It explains what happens inside the API server, the scheduler and the kubelet precisely enough to predict their behaviour. It covers packaging with Helm and Kustomize, server-side apply, StatefulSets, Jobs and CronJobs for ML batch work, autoscaling with the HorizontalPodAutoscaler, network policies, RBAC, Pod Security, setting up the Gateway API with TLS, and debugging beyond the basics with ephemeral containers and node-level tools. It also connects Kubernetes to CI/CD and the cloud.

Senior then covers owning a cluster as a platform for other teams: control-plane architecture and failure modes, which component breaks first as you scale, the security model from authentication to supply chain, multi-tenancy, cost, the upgrade treadmill of three releases a year, GPUs and Dynamic Resource Allocation for ML workloads, and when Kubernetes is the wrong tool.

In this catalogue, Kubernetes sits in a chain. Terraform is how teams create the clusters themselves, and Helm and Argo CD are how they package and deliver what runs on them. On the machine-learning side, KServe serves models on Kubernetes using exactly the Deployments, Services and probes you learned here.

Sources