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.
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.
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.
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.
- Pick a service you have deployed, or one you have seen deployed at work.
- For each of the five questions in the list above, write down who or what handles it today.
- Circle every answer that is "a person" or "a script someone wrote".
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:
- Look at the desired state for the objects I am responsible for.
- Look at the actual state.
- If they differ, take one step to make the actual state closer to the desired state.
- 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.
- 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.
- Now do the same for "three copies of a web server": desired state, how you would measure it, and the corrective action.
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.
kubectlThe command-line client. Every command becomes an HTTPS request to the API serverWhy 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.
kubectl is identical either way.
- 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.
- Trace one request with arrows: "I asked for a new pod". Which component notices it first, which picks the node, which starts the container?
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=webortier=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,stagingorteam-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.
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.
- In your own words, write one sentence each for pod, Deployment and Service.
- 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?
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.
# 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.
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.
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
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:
kubectl version --client
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.
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:
kind create cluster --name learn
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.
- Install kubectl and run
kubectl version --client. - Install kind (or minikube) and create a cluster named
learn. - Run
docker psand find the container namedlearn-control-plane.
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.
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
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:
kubectl get namespaces
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:
kubectl create namespace demo
kubectl config set-context --current --namespace=demo
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.
- Run
kubectl config get-contextsand find the asterisk. - Run
kubectl get pods -Aand count the pods inkube-system. Find the API server, etcd, the scheduler and the controller manager among them. - Create a namespace called
demoand make it your default withset-context. Runkubectl config get-contextsagain and look at the NAMESPACE column.
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.
# 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
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:
kubectl get deployment,replicaset,pods
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:
kubectl delete pod web-5d8c7f9b6d-2xkqp
kubectl get pods
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:
kubectl expose deployment web --port=80 --target-port=80
kubectl get service web
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:
kubectl port-forward service/web 8080:80
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
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.
- Create the
webDeployment with three replicas and watch the pods start withkubectl get pods -w. - In a second terminal, delete one of the pods while the watch is running.
- Expose the Deployment, port-forward to it and load the page in a browser.
- Scale to five, then to one, and watch what happens to the pod list each time.
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:
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/v1andkind: Deployment: Deployments live in theappsAPI group, versionv1. Core objects such as Pods, Services and ConfigMaps use plainv1.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 labelledapp: web.spec.template: the pod template, a complete pod description minus the name. It has its ownmetadata(the labels each pod gets) and its ownspec(the containers).containers[].name,image,ports: one container, callednginx, from thenginx:1.27image, listening on port 80.containerPortis informational, much likeEXPOSEin a Dockerfile; it does not open or publish anything by itself.
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:
kubectl apply -f deployment.yaml
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:
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:
# 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.
- . 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.
- Delete any existing
webDeployment, then savedeployment.yamlabove and apply it. - Apply it a second time and read the word at the end of the output.
- Change
replicasto 2, apply, and runkubectl get pods. - Change the template's label to
app: website(leave the selector alone) and apply. Read the error. - Run
kubectl get deployment web -o yamland find thestatusblock 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:
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:
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.
- With the
webDeployment running, list pods with--show-labels. - Relabel one pod to
app=debugwith--overwriteand list them again. - Run
kubectl get pods -l app=webandkubectl get pods -l app=debug. - Delete the orphan with
kubectl delete pod -l app=debug.
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.
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:
kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- sh
Then, at the prompt inside that pod:
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:
kubectl get endpointslices -l kubernetes.io/service-name=web
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).
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.
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.
- Save
service.yamlnext to your Deployment and apply the whole folder withkubectl apply -f .. - Start a busybox pod as above, run
wget -qO- http://webandnslookup web, then exit. - List the EndpointSlices, scale the Deployment to 1 and list them again.
- Change the Service's selector to
app: nope, apply, and list the EndpointSlices a third time. Change it back.
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
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
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
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
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
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
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.
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.
- Enable shell completion, then type
kubectl describe pod weband press Tab. - Run
kubectl describeon one of your pods and read the Events at the bottom from top to bottom. - Run
kubectl logs deploy/web, then load the page through a port-forward and run it again. - Run
kubectl execinto a pod andcat /etc/resolv.conf.
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.
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:
kubectl apply -f deployment.yaml
kubectl rollout status deploy/web
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:
kubectl set image deploy/web nginx=nginx:9.99-does-not-exist
kubectl get pods
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:
kubectl rollout history deploy/web
kubectl rollout undo deploy/web
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.
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.
- Update the image to
nginx:1.28withapplyand watchkubectl get pods -win a second terminal. - Set a non-existent tag with
kubectl set image, and confirm the old pods keep running. - Run
kubectl rollout history, thenkubectl rollout undo, thenkubectl get rs.
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:
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:
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:
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
envFromimports every key of the ConfigMap as an environment variable. Quick, but keys that are not valid variable names (such assettings.ini) are skipped.envwithvalueFrompicks one key and names the variable yourself. Use it for Secrets, so you know exactly which values land in the environment.- A
configMapvolume turns keys into files. Use it for whole configuration files.
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.
- Apply
config.yamland create theapp-secretsSecret. - Add the
envFrom,envand volume sections above to your Deployment's container and apply it. - Run
kubectl exec deploy/web -- env | grep -E 'LOG_LEVEL|MODEL_NAME|API_TOKEN'andkubectl exec deploy/web -- cat /etc/app/settings.ini. - Change
LOG_LEVELtodebug, apply, and read the variable again. Then runkubectl rollout restart deploy/weband read it once more.
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
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.
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 |
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.
startupProbe for slow starters rather than a huge initialDelaySeconds.
- Add the requests, limits and both probes above to your
webcontainer and apply. - Run
kubectl describe podon one pod and find theQoS Class,LivenessandReadinesslines. - Change the readiness probe's path to
/nopeand apply. Watch the READY column and the Service's EndpointSlice. - 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.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
A pod then mounts the claim by name:
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.
- Apply
pvc.yamland runkubectl get pvc. On kind the claim waits inPendinguntil a pod uses it. - 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 thevolumesblock above and a matchingvolumeMountsentry at/data, and apply it. - Delete its pod, wait for the replacement, and
kubectl execin tocat /data/log.
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
- Get
kubectl get pods. Read STATUS, READY and RESTARTS. A climbing RESTARTS count means the container starts and then dies. - Describe
kubectl describe pod <pod>. Scroll to Events at the bottom and read the last few lines. Also readStateandLast Statefor each container: the reason and exit code of the last crash live there. - Logs
kubectl logs <pod>, andkubectl logs <pod> --previousif the container has restarted. Application errors (a stack trace, a missing setting) show up here, not in Events. - Get inside
kubectl exec -it <pod> -- shif the container stays up long enough, or a throwawaybusyboxpod to test networking and DNS from next to it. - Widen the view
kubectl get events --sort-by=.lastTimestampfor the whole namespace, andkubectl 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 refusedmeans no kubeconfig was found.The connection to the server <host>:6443 was refusedmeans 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 checkkubectl 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 devtells you what you can do.Error from server (NotFound): deployments.apps "web" not foundmost often means you are in the wrong namespace. Add-nor 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 areapps/v1.
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.
- Create a pod with a typo in the image:
kubectl run broken --image=ngnix:1.27. Diagnose it withgetanddescribe. - 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 readkubectl logs crasher --previous. - 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. - Delete all three with
kubectl delete pod broken crasher greedy.
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:
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.
- Run
kubectl api-resources | grep -i -E 'ingress|gateway'on your kind cluster. - Run
kubectl explain ingress.spec.rules, then trykubectl explain httproute.spec.
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.
apiVersion: v1
kind: Namespace
metadata:
name: shop
labels:
app.kubernetes.io/part-of: shop
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>
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
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.
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:
kubectl delete namespace shop
- Build the four files by hand, create the Secret, and apply the folder. Get the page through a port-forward.
- Delete a pod, update the image, and change the page, confirming each result as described above.
- 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. - 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.
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
- Kubernetes documentation home
- Kubernetes releases
- Kubernetes v1.37 release announcement
- Version skew policy
- Kubernetes components
- Install tools
- Install and set up kubectl on Linux
- Install and set up kubectl on macOS
- Install and set up kubectl on Windows
- kubectl quick reference
- Pod lifecycle
- Resource management for pods and containers
- Secrets
- Good practices for Kubernetes Secrets
- Ingress
- Gateway API
- Node-pressure eviction
- Debug pods
- Deprecated API migration guide
- Kubernetes v1.36 release announcement
- Deprecation and removal of Service externalIPs