☸️ Kubernetes Guide

A how-to manual: how a cluster works, running and updating workloads, services and ingress, configuration, storage, resource limits, probes, scheduling, autoscaling, network policy, access control, backups and upgrades, with every example run for real on a two-node lab.

Video: Kubernetes guide (DevOps video)
Prefer to watch? This short video runs this page's main commands on a real two-node k3s cluster, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
kubectl: talk to a Kubernetes cluster. Describe what you want, and the cluster makes it so.
kubectl apply -f app.yaml
kubectl get pods -o wide
kubectl describe pod NAME
kubectl rollout undo deployment NAME

Kubernetes runs containers across a group of machines and keeps them in the state you declared. You write down what you want (three copies of this web server, reachable on port 80) and a set of controllers keeps making that true: they start the copies, replace the ones that die, move them off a machine that fails and spread the traffic. It is a very large system, and most of the trouble comes from a few places: a pod that never gets scheduled, a Service that finds no pods, a limit that kills a container, a volume that lives on one node, a certificate that quietly expires, an upgrade done in the wrong order.

This guide is how to run it. It installs a cluster, runs workloads, gives them configuration, storage and a network, limits and protects them, schedules and scales them, and operates the cluster: backup and restore, node maintenance, certificates and upgrades. The kubectl Day-to-Day page on this site covers the command line and the failure gallery in depth, and the cheat sheets for Helm, Kustomize, K9s and Popeye cover those tools. The older cheat sheet that stood on this page is replaced by this guide, and its one-line commands are in the cheat sheet section.

Every example ran for real, on two Ubuntu 24.04 machines (containers with their own container runtime inside) running k3s, a small certified Kubernetes distribution: first v1.35.9, then upgraded to v1.36.5 in the upgrade section. One node was the server (control plane with embedded etcd), and one was a worker. Real clusters have more control-plane nodes, separate machines and a real load balancer. Those, cloud-managed clusters and everything that needs them were not run, and the section "What was not run in the lab" lists them.

What Kubernetes is, and the lab used here

Kubernetes runs containers across a group of machines and keeps them in the state you declared. You write down what you want ("three copies of this web server, reachable on port 80"), and Kubernetes keeps making that true: it starts the copies, replaces the ones that die, moves them off a machine that fails, and spreads traffic over them. You do not log in to a machine and start things. You describe, and a set of controllers does the work, continuously.

The parts, in the order a request meets them:

API server        the front door. Every kubectl command, and every other component, talks to it. It checks who you are and what you may do.
etcd              the database behind the API server. It holds the whole cluster state: if etcd is lost, the cluster has forgotten everything.
scheduler         picks a node for every new pod, from its resource requests, its rules and the node's room.
controller manager  runs the loops that make reality match the declared state (the Deployment controller, the node controller and so on).
kubelet           an agent on every node. It starts the containers the API says should run on that node, and reports back.
container runtime  what the kubelet asks to start containers (containerd, in most clusters).
kube-proxy / CNI  the network: a flat pod network, and the rules that make Services work.
control plane     the first four together. Worker nodes run the last three.

The words you meet next:

pod           the smallest thing Kubernetes runs: one or more containers that share a network address and storage.
deployment    keeps N identical pods running and does rolling updates.
service       a stable name and address in front of a changing set of pods.
namespace     a folder for objects, with its own names, quotas and permissions.
label         a key and value on an object. Selectors (in services, deployments, policies) find objects by label.
manifest      the YAML that describes an object.

This guide is how to run it: install a cluster, run workloads, give them configuration, storage and a network, limit and protect them, schedule them, scale them, and operate the cluster (backup, upgrade, drain a node). The tool for reading and changing objects day to day is covered by the kubectl Day-to-Day page on this site, which has the failure gallery (CrashLoopBackOff, Pending, ImagePullBackOff and the others, one by one). The older cheat sheet that stood on this page is replaced by this guide, and its one-line commands are in the cheat sheet section.

Every example below ran for real, on a lab of two Ubuntu 24.04 machines (containers, with their own container runtime inside) running k3s, a small certified Kubernetes distribution from Rancher/SUSE. The lab started on Kubernetes v1.35.9 (k3s v1.35.9+k3s1), and the upgrade section moves it to v1.36.5. One node is the server (control plane with embedded etcd, named k8s) and one is a worker (named k8w1). Real clusters have three or five control-plane nodes, separate machines and a real load balancer. Those, cloud-managed clusters (EKS, AKS, GKE) and everything that needs them were not run, and the section "What was not run in the lab" lists them.

$ kubectl version
Client Version: v1.35.9+k3s1
Kustomize Version: v5.7.1
Server Version: v1.35.9+k3s1
$ kubectl get nodes -o wide
NAME   STATUS   ROLES                AGE   VERSION        INTERNAL-IP   EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
k8s    Ready    control-plane,etcd   21s   v1.35.9+k3s1   172.25.0.2    <none>        Ubuntu 24.04.5 LTS   7.0.0-38-generic   containerd://2.2.7-k3s1
k8w1   Ready    <none>               15s   v1.35.9+k3s1   172.25.0.3    <none>        Ubuntu 24.04.5 LTS   7.0.0-38-generic   containerd://2.2.7-k3s1
$ kubectl get pods -A
NAMESPACE     NAME                                     READY   STATUS      RESTARTS   AGE
kube-system   coredns-b644fd95b-rfzv8                  1/1     Running     0          16s
kube-system   helm-install-traefik-crd-758p4           0/1     Completed   0          16s
kube-system   helm-install-traefik-nxwfm               0/1     Completed   2          16s
kube-system   local-path-provisioner-d9d8769b6-582zr   1/1     Running     0          16s
kube-system   metrics-server-7f4b6d9bd7-mcfwk          0/1     Running     0          16s
kube-system   svclb-traefik-5192b2cf-4xrns             2/2     Running     0          2s
kube-system   svclb-traefik-5192b2cf-dlvkh             2/2     Running     0          2s
kube-system   traefik-5869c87968-fszlq                 1/1     Running     0          2s

A cluster on Ubuntu with k3s

There are many ways to get a cluster. Use the one that fits the job:

k3s            one binary, installs in a minute, runs on a Raspberry Pi or a VM. Used here. Bundles Traefik (ingress), a local storage provisioner, CoreDNS and metrics-server.
kubeadm        the upstream tool for building a cluster from separate machines. You install the runtime, the kubelet and the network add-on yourself. More work, nothing hidden. Not run in the lab.
kind, minikube clusters inside containers or a VM, for development and tests. Not run in the lab.
EKS, AKS, GKE  the cloud provider runs the control plane for you; you manage the nodes (or not, in autopilot modes). Not run in the lab.

The k3s way, from its own documentation, is one command on each machine. On the first one (the server):

$ curl -sfL https://get.k3s.io | sh -
$ sudo kubectl get nodes

and on a machine that should join as a worker, with the server's address and its token:

$ curl -sfL https://get.k3s.io | K3S_URL=https://SERVER:6443 K3S_TOKEN=TOKEN sh -
$ sudo cat /var/lib/rancher/k3s/server/node-token     # on the server: where the token is

The script installs a systemd service. The lab machines are containers with no systemd, so it could not be used (it stopped with "Can not find systemd or openrc"), and the lab ran the same binary by hand instead: k3s server and k3s agent, which is exactly what the service runs. Everything after the install is the same. The server was started with --cluster-init, so that it uses embedded etcd (the default is SQLite on a single server, which has no snapshot command, and the backup section needs etcd).

$ k3s --version
k3s version v1.35.9+k3s1 (58877f27)
go version go1.26.8
$ ps -eo args | grep -E '^k3s (server|agent)'     # run as root on the server node
k3s server
$ ps -eo args | grep -E '^k3s (server|agent)' | cut -c1-70     # run as root on the worker node
k3s agent
$ kubectl get nodes -o wide
NAME   STATUS   ROLES                AGE   VERSION        INTERNAL-IP   EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
k8s    Ready    control-plane,etcd   22s   v1.35.9+k3s1   172.25.0.2    <none>        Ubuntu 24.04.5 LTS   7.0.0-38-generic   containerd://2.2.7-k3s1
k8w1   Ready    <none>               16s   v1.35.9+k3s1   172.25.0.3    <none>        Ubuntu 24.04.5 LTS   7.0.0-38-generic   containerd://2.2.7-k3s1
$ kubectl get pods -A
NAMESPACE     NAME                                     READY   STATUS      RESTARTS   AGE
kube-system   coredns-b644fd95b-rfzv8                  1/1     Running     0          17s
kube-system   helm-install-traefik-crd-758p4           0/1     Completed   0          17s
kube-system   helm-install-traefik-nxwfm               0/1     Completed   2          17s
kube-system   local-path-provisioner-d9d8769b6-582zr   1/1     Running     0          17s
kube-system   metrics-server-7f4b6d9bd7-mcfwk          0/1     Running     0          17s
kube-system   svclb-traefik-5192b2cf-4xrns             2/2     Running     0          3s
kube-system   svclb-traefik-5192b2cf-dlvkh             2/2     Running     0          3s
kube-system   traefik-5869c87968-fszlq                 1/1     Running     0          3s

Four things are running that you did not ask for: coredns (cluster DNS), metrics-server (what kubectl top reads), local-path-provisioner (storage) and traefik (the ingress controller, with an svclb pod per node that opens port 80 and 443 on the node). The two helm-install-traefik pods are Jobs that installed Traefik from a chart, and they show Completed, which is what a finished Job looks like.

kubectl needs to know where the API server is and who you are. That comes from a kubeconfig file. k3s writes one at /etc/rancher/k3s/k3s.yaml (readable by everyone here, because the server was started with --write-kubeconfig-mode 644):

$ echo $KUBECONFIG
/etc/rancher/k3s/k3s.yaml
$ kubectl config view --minify | grep -E 'server:|name:|user:'
    server: https://127.0.0.1:6443
  name: default
    user: default
  name: default
- name: default
  user:
$ kubectl config get-contexts
CURRENT   NAME      CLUSTER   AUTHINFO   NAMESPACE
*         default   default   default
$ kubectl cluster-info
Kubernetes control plane is running at https://127.0.0.1:6443
CoreDNS is running at https://127.0.0.1:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Metrics-server is running at https://127.0.0.1:6443/api/v1/namespaces/kube-system/services/https:metrics-server:https/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

A kubeconfig holds clusters, users (credentials) and contexts, which pair one cluster with one user and a default namespace. With several clusters, kubectl config use-context NAME switches, and the safest habit is to show the current context in your shell prompt. Copy the file to ~/.kube/config on your own workstation (and replace 127.0.0.1 in it with the server's address) to use the cluster from there. The kubeconfig is a credential: anyone with it is as powerful as it says.

The API itself is just HTTPS. kubectl is one client, and curl works the same way (with the credentials). Ask what kinds of objects this cluster knows:

$ kubectl api-resources | head -12
NAME                                SHORTNAMES   APIVERSION                        NAMESPACED   KIND
bindings                                         v1                                true         Binding
componentstatuses                   cs           v1                                false        ComponentStatus
configmaps                          cm           v1                                true         ConfigMap
endpoints                           ep           v1                                true         Endpoints
events                              ev           v1                                true         Event
limitranges                         limits       v1                                true         LimitRange
namespaces                          ns           v1                                false        Namespace
nodes                               no           v1                                false        Node
persistentvolumeclaims              pvc          v1                                true         PersistentVolumeClaim
persistentvolumes                   pv           v1                                false        PersistentVolume
pods                                po           v1                                true         Pod
$ kubectl api-resources | wc -l
105
$ kubectl get --raw /version | head -5
{
  "major": "1",
  "minor": "35",
  "emulationMajor": "1",
  "emulationMinor": "35",

Pods, ReplicaSets and Deployments

A pod runs one or more containers. The simplest thing is to start one by hand:

$ kubectl run solo --image=nginx:alpine
pod/solo created
$ kubectl get pod solo -o wide
NAME   READY   STATUS              RESTARTS   AGE   IP       NODE   NOMINATED NODE   READINESS GATES
solo   0/1     ContainerCreating   0          1s    <none>   k8w1   <none>           <none>
$ kubectl delete pod solo
pod "solo" deleted from default namespace
$ kubectl get pod solo
Error from server (NotFound): pods "solo" not found
(exit status 1)

The pod ran on a node, and when it was deleted it was gone for good, because nothing owned it. That is why you almost never create pods yourself. A Deployment owns a ReplicaSet, which owns the pods and keeps the count right:

$ kubectl create deployment web --image=nginx:alpine --replicas=2
deployment.apps/web created
$ kubectl get deploy,rs,pods -o wide
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES         SELECTOR
deployment.apps/web   2/2     2            2           2s    nginx        nginx:alpine   app=web

NAME                             DESIRED   CURRENT   READY   AGE   CONTAINERS   IMAGES         SELECTOR
replicaset.apps/web-5d4f7c4d94   2         2         2       2s    nginx        nginx:alpine   app=web,pod-template-hash=5d4f7c4d94

NAME                       READY   STATUS    RESTARTS   AGE   IP          NODE   NOMINATED NODE   READINESS GATES
pod/web-5d4f7c4d94-hpdcg   1/1     Running   0          2s    10.42.0.8   k8s    <none>           <none>
pod/web-5d4f7c4d94-qpsp7   1/1     Running   0          2s    10.42.1.5   k8w1   <none>           <none>
$ kubectl get pod -l app=web -o jsonpath='{range .items[*]}{.metadata.name}{" owned by "}{.metadata.ownerReferences[0].kind}{"/"}{.metadata.ownerReferences[0].name}{"\n"}{end}'
web-5d4f7c4d94-hpdcg owned by ReplicaSet/web-5d4f7c4d94
web-5d4f7c4d94-qpsp7 owned by ReplicaSet/web-5d4f7c4d94

The chain is deployment web, then ReplicaSet web-HASH, then pods web-HASH-SUFFIX, and each object records its owner. Now delete a pod, and look at what happens to the count:

$ kubectl delete pod -l app=web --wait=false | head -2
pod "web-5d4f7c4d94-hpdcg" deleted from default namespace
pod "web-5d4f7c4d94-qpsp7" deleted from default namespace
$ kubectl get pods -l app=web
NAME                   READY   STATUS              RESTARTS   AGE
web-5d4f7c4d94-d4g7n   0/1     ContainerCreating   0          0s
web-5d4f7c4d94-hpdcg   1/1     Terminating         0          3s
web-5d4f7c4d94-qpsp7   1/1     Terminating         0          3s
web-5d4f7c4d94-xzpgl   0/1     ContainerCreating   0          0s
$ kubectl get pods -l app=web
NAME                   READY   STATUS    RESTARTS   AGE
web-5d4f7c4d94-d4g7n   1/1     Running   0          6s
web-5d4f7c4d94-xzpgl   1/1     Running   0          6s

Two new pods took their place within seconds: the ReplicaSet saw "2 wanted, 0 running" and made new ones. This is the loop that Kubernetes is built from: compare what is wanted with what exists, and act on the difference. Scale by changing the number you want:

$ kubectl scale deployment web --replicas=4
deployment.apps/web scaled
$ kubectl get pods -l app=web -o wide
NAME                   READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
web-5d4f7c4d94-d4g7n   1/1     Running   0          8s    10.42.1.6    k8w1   <none>           <none>
web-5d4f7c4d94-sc6p2   1/1     Running   0          1s    10.42.0.10   k8s    <none>           <none>
web-5d4f7c4d94-xzpgl   1/1     Running   0          8s    10.42.0.9    k8s    <none>           <none>
web-5d4f7c4d94-zcl7c   1/1     Running   0          1s    10.42.1.7    k8w1   <none>           <none>
$ kubectl scale deployment web --replicas=2
deployment.apps/web scaled
$ kubectl get deploy web
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
web    2/2     2            2           12s

The pods were spread over the two nodes by the scheduler (the NODE column). The labels app=web on the pods are what the ReplicaSet and, later, a Service use to find them:

$ kubectl get pods --show-labels | head -3
NAME                   READY   STATUS        RESTARTS   AGE   LABELS
web-5d4f7c4d94-d4g7n   1/1     Running       0          9s    app=web,pod-template-hash=5d4f7c4d94
web-5d4f7c4d94-sc6p2   1/1     Terminating   0          2s    app=web,pod-template-hash=5d4f7c4d94
$ kubectl get pods -l app=web --no-headers | wc -l
4
$ kubectl get pods -l 'app in (web,other)' --no-headers | wc -l
4

Manifests: declare the state and apply it

kubectl create deployment is good for a first look. Real work is kept as YAML files in git, and applied. A manifest has four top-level parts: apiVersion and kind (what sort of object), metadata (its name, namespace and labels) and spec (what you want).

$ cat shop.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop
  labels:
    app: shop
spec:
  replicas: 2
  selector:
    matchLabels:
      app: shop
  template:
    metadata:
      labels:
        app: shop
    spec:
      containers:
      - name: nginx
        image: nginx:1.27-alpine
        ports:
        - containerPort: 80
$ kubectl apply -f shop.yaml
deployment.apps/shop created
$ kubectl get deploy shop
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
shop   2/2     2            2           1s

Note that spec.selector must match the labels in spec.template, because that is how the Deployment finds its pods. apply is declarative: it compares the file with the object, and changes only the difference. Run it again, and nothing happens:

$ kubectl apply -f shop.yaml
deployment.apps/shop unchanged

Change the file, and look at what would change before you change it. kubectl diff shows a diff against the live object, and --dry-run=server sends the change to the API server to be validated (including by admission controllers) without saving it:

$ sed -i 's/replicas: 2/replicas: 3/' shop.yaml
$ kubectl diff -f shop.yaml | grep -E '^[+-] '
-  generation: 1
+  generation: 2
-  replicas: 2
+  replicas: 3
$ kubectl apply -f shop.yaml --dry-run=server
deployment.apps/shop configured (server dry run)
$ kubectl apply -f shop.yaml
deployment.apps/shop configured
$ kubectl get deploy shop
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
shop   2/3     3            2           3s

Use kubectl explain when you do not remember a field, instead of searching the web. It reads the schema from the cluster itself, so it matches the version you run:

$ kubectl explain deployment.spec.strategy | head -14
GROUP:      apps
KIND:       Deployment
VERSION:    v1

FIELD: strategy <DeploymentStrategy>


DESCRIPTION:
    The deployment strategy to use to replace existing pods with new ones.
    DeploymentStrategy describes how to replace existing pods with new ones.

FIELDS:
  rollingUpdate	<RollingUpdateDeployment>
    Rolling update config params. Present only if DeploymentStrategyType =
$ kubectl explain pod.spec.containers.resources --recursive | head -8
KIND:       Pod
VERSION:    v1

FIELD: resources <ResourceRequirements>


DESCRIPTION:
    Compute Resources required by this container. Cannot be updated. More info:

A validation error is caught before anything changes. Spell a field wrong:

$ sed 's/name: shop/name: shop2/; s/replicas: 3/replicas: three/' shop.yaml | kubectl apply -f - 2>&1 | head -3 | cut -c1-170
Error from server (BadRequest): error when creating "STDIN": Deployment in version "v1" cannot be handled as a Deployment: json: cannot unmarshal string into Go struct fi
$ sed 's/kind: Deployment/kind: Deployent/' shop.yaml | kubectl apply -f - 2>&1 | head -3
error: resource mapping not found for name: "shop" namespace: "" from "STDIN": no matches for kind "Deployent" in version "apps/v1"
ensure CRDs are installed first

The differences between the commands that write:

kubectl create -f     fails if the object already exists. For things you make once.
kubectl apply -f      creates, or changes only what differs. The normal way.
kubectl replace -f    swaps the whole object for the file (it must exist).
kubectl delete -f     deletes what the file describes.
kubectl apply -k DIR  applies a kustomization (a later section).

The file is the truth, not the cluster. Anything changed by hand with kubectl edit or scale and not put back in the file is lost at the next apply of the old file, or worse, kept and forgotten. Keep the YAML in git, and change the cluster only by applying it. Delete what the file describes:

$ kubectl delete -f shop.yaml
deployment.apps "shop" deleted from default namespace
$ kubectl delete deployment web
deployment.apps "web" deleted from default namespace

Rolling updates and rollbacks

When the pod template changes (a new image, a new environment variable), a Deployment replaces its pods gradually, so the application stays up. Two numbers control the pace: maxSurge (how many extra pods may exist during the update) and maxUnavailable (how many may be missing).

$ cat site.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: site
spec:
  replicas: 4
  selector:
    matchLabels:
      app: site
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: site
    spec:
      containers:
      - name: nginx
        image: nginx:1.27-alpine
$ kubectl apply -f site.yaml
deployment.apps/site created
$ kubectl get deploy site
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
site   4/4     4            4           1s

maxUnavailable 0 and maxSurge 1 mean: start one new pod, wait until it is ready, then stop one old one, and repeat. Never fewer than 4 pods ready. Start an update and watch it:

$ kubectl set image deployment/site nginx=nginx:1.28-alpine
deployment.apps/site image updated
$ kubectl rollout status deployment site --timeout=120s | tail -2
Waiting for deployment "site" rollout to finish: 1 old replicas are pending termination...
deployment "site" successfully rolled out
$ kubectl get rs -l app=site
NAME              DESIRED   CURRENT   READY   AGE
site-557fdfc647   4         4         4       3s
site-fc4cccc76    0         0         0       5s
$ kubectl rollout history deployment site
deployment.apps/site
REVISION  CHANGE-CAUSE
1         <none>
2         <none>

The old ReplicaSet is kept at zero pods. That is what makes a rollback instant: it is only changing the counts back. Now an update that cannot work, because the image tag does not exist:

$ kubectl set image deployment/site nginx=nginx:no-such-tag
deployment.apps/site image updated
$ kubectl get pods -l app=site
NAME                    READY   STATUS             RESTARTS   AGE
site-557fdfc647-bk7zl   1/1     Running            0          19s
site-557fdfc647-gtdrt   1/1     Running            0          17s
site-557fdfc647-h5dc6   1/1     Running            0          18s
site-557fdfc647-sld8h   1/1     Running            0          19s
site-65f5cc8fcb-fv2df   0/1     ImagePullBackOff   0          15s
$ kubectl rollout status deployment site --timeout=10s
Waiting for deployment "site" rollout to finish: 1 out of 4 new replicas have been updated...
error: timed out waiting for the condition
(exit status 1)

The new pod is in ErrImagePull or ImagePullBackOff and will never be ready, and because maxUnavailable is 0 Kubernetes did not remove any good pod: the site is still served by the four old ones. That is the point of a rolling update. Roll back:

$ kubectl rollout undo deployment site
deployment.apps/site rolled back
$ kubectl rollout status deployment site --timeout=120s
deployment "site" successfully rolled out
$ kubectl get pods -l app=site
NAME                    READY   STATUS        RESTARTS   AGE
site-557fdfc647-bk7zl   1/1     Running       0          30s
site-557fdfc647-gtdrt   1/1     Running       0          28s
site-557fdfc647-h5dc6   1/1     Running       0          29s
site-557fdfc647-sld8h   1/1     Running       0          30s
site-65f5cc8fcb-fv2df   0/1     Terminating   0          26s
$ kubectl get deployment site -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
nginx:1.28-alpine

Two settings you will want to know. minReadySeconds makes a pod count as ready only after it has run that long without crashing. progressDeadlineSeconds (600 by default) is how long a stuck update may stay stuck before the Deployment reports "ProgressDeadlineExceeded". Neither stops the update by itself: a stuck rollout waits until you roll it back, which is why the check in a pipeline is kubectl rollout status with a timeout, and an undo on failure. Restart every pod without changing anything (to pick up a changed ConfigMap, say) with:

$ kubectl rollout restart deployment site
deployment.apps/site restarted
$ kubectl rollout status deployment site --timeout=120s | tail -1
deployment "site" successfully rolled out
$ kubectl delete deployment site
deployment.apps "site" deleted from default namespace

Services and cluster DNS

Pods come and go, and their addresses change every time. A Service gives a set of pods one stable address and one name, and spreads the traffic over whichever pods match its selector.

$ cat app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
---
apiVersion: v1
kind: Service
metadata:
  name: app
spec:
  selector:
    app: app
  ports:
  - port: 80
    targetPort: 80
$ kubectl apply -f app.yaml
deployment.apps/app created
service/app created
$ kubectl get svc app
NAME   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
app    ClusterIP   10.43.145.78   <none>        80/TCP    1s
$ kubectl get endpointslices -l kubernetes.io/service-name=app
NAME        ADDRESSTYPE   PORTS   ENDPOINTS               AGE
app-rwcvt   IPv4          80      10.42.0.18,10.42.1.17   2s
$ kubectl get pods -l app=app -o wide
NAME                 READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
app-5497957c-ljmzb   1/1     Running   0          2s    10.42.0.18   k8s    <none>           <none>
app-5497957c-lwfvq   1/1     Running   0          2s    10.42.1.17   k8w1   <none>           <none>

The Service has a virtual address (the CLUSTER-IP), which belongs to no machine. Its endpoints are the real pod addresses, and the list is kept up to date by Kubernetes: a pod that is not ready is taken out of it. A pod reaches the Service by name, because the cluster runs DNS. Run a test pod:

$ kubectl run dns --image=busybox:1.36 --restart=Never -- sleep 3600
pod/dns created
$ kubectl exec dns -- nslookup app 2>&1 | grep -A1 '^Name'
Name:	app.default.svc.cluster.local
Address: 10.43.145.78
$ kubectl exec dns -- wget -qO- http://app | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ kubectl exec dns -- cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.43.0.10
options ndots:5

The other lines that nslookup printed (not shown) were NXDOMAIN answers for the search-domain guesses, such as app.svc.cluster.local, which do not exist. The short name app works because the pod's search domains include its own namespace (default.svc.cluster.local), so app is expanded to app.default.svc.cluster.local. From another namespace, use app.default or the full name. The five pieces of the name are service.namespace.svc.cluster-domain.

Types of Service:

ClusterIP     the default. A virtual address reachable only inside the cluster.
NodePort      also opens the same port (30000-32767) on every node. Reachable from outside at NODEIP:PORT.
LoadBalancer  asks the environment for an external load balancer. In k3s, a small built-in one (svclb) opens the port on the nodes. In a cloud, a real one is made (and billed).
ExternalName  a DNS alias (a CNAME) to a name outside the cluster. No proxying.
headless      clusterIP: None. No virtual address: DNS returns the pod addresses themselves. Used with StatefulSets (a later section).

Make the Service a NodePort and use it from the worker node, which is not where all the pods are:

$ kubectl patch svc app -p '{"spec":{"type":"NodePort","ports":[{"port":80,"nodePort":30080}]}}'
service/app patched
$ kubectl get svc app
NAME   TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
app    NodePort   10.43.145.78   <none>        80:30080/TCP   4s
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://k8s:30080     # run as root on the worker node
HTTP 200

The worker node reached the Service through the other node's address. NodePort opens the port on every node, whichever node the pods are on. A Service that finds no pods, because the selector and the labels disagree, is the most common networking fault, and the sign is an empty endpoint list:

$ kubectl patch svc app -p '{"spec":{"selector":{"app":"typo"}}}'
service/app patched
$ kubectl get endpointslices -l kubernetes.io/service-name=app
NAME        ADDRESSTYPE   PORTS     ENDPOINTS   AGE
app-rwcvt   IPv4          <unset>   <unset>     5s
$ kubectl exec dns -- wget -qO- -T 3 http://app 2>&1 | head -1
wget: can't connect to remote host (10.43.133.144): Connection refused
$ kubectl patch svc app -p '{"spec":{"selector":{"app":"app"},"type":"ClusterIP"}}'
service/app patched
$ kubectl get svc app
NAME   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
app    ClusterIP   10.43.145.78   <none>        80/TCP    12s

Ingress: one front door for many sites

A Service of type NodePort or LoadBalancer opens one port per Service. For websites that is wasteful: you want one address on ports 80 and 443 that sends app.example.com to one Service and blog.example.com to another. That is what an Ingress is. It is a set of rules, and an ingress controller (a program running in the cluster, here Traefik, which k3s installs) reads them and does the routing. An Ingress without a controller does nothing at all.

$ kubectl get ingressclass
NAME      CONTROLLER                      PARAMETERS   AGE
traefik   traefik.io/ingress-controller   <none>       74s
$ kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
NAME                       READY   STATUS    RESTARTS   AGE
traefik-5869c87968-fszlq   1/1     Running   0          74s
$ kubectl get svc -n kube-system traefik
NAME      TYPE           CLUSTER-IP      EXTERNAL-IP             PORT(S)                      AGE
traefik   LoadBalancer   10.43.216.143   172.25.0.2,172.25.0.3   80:32521/TCP,443:31548/TCP   74s

Traefik is exposed as a LoadBalancer Service, and in k3s the small svclb pods on each node answer on ports 80 and 443 of the node itself (the EXTERNAL-IP column shows the node addresses). The Service named app is still there from the last section. Add a second site, and the Ingress that routes by host name:

$ cat sites.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: blog
spec:
  replicas: 1
  selector:
    matchLabels:
      app: blog
  template:
    metadata:
      labels:
        app: blog
    spec:
      containers:
      - name: echo
        image: hashicorp/http-echo:1.0
        args: ["-text=this is the blog"]
---
apiVersion: v1
kind: Service
metadata:
  name: blog
spec:
  selector:
    app: blog
  ports:
  - port: 80
    targetPort: 5678
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: sites
spec:
  rules:
  - host: app.lab
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: app
            port:
              number: 80
  - host: blog.lab
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: blog
            port:
              number: 80
$ kubectl apply -f sites.yaml
deployment.apps/blog created
service/blog created
ingress.networking.k8s.io/sites created
$ kubectl get ingress sites
NAME    CLASS     HOSTS              ADDRESS                 PORTS   AGE
sites   traefik   app.lab,blog.lab   172.25.0.2,172.25.0.3   80      7s

The ADDRESS column fills in with the node addresses once Traefik has picked the Ingress up. Now ask the front door (port 80 of this node) for each site. There is no DNS for app.lab and blog.lab in the lab, so the Host header is sent by hand, which is exactly what a browser does with a real name:

$ curl -s -o /dev/null -w 'no host name: HTTP %{http_code}\n' http://localhost/
no host name: HTTP 404
$ curl -s -H 'Host: app.lab' http://localhost/ | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ curl -s -H 'Host: blog.lab' http://localhost/
this is the blog
$ curl -s -H 'Host: blog.lab' http://k8s/     # run as root on the worker node
this is the blog

The first answer is a 404 from Traefik, because no rule matches a request for no host. The next two reach different Services through the same port. The last one came from the worker node. Any node answers, because every node runs the front door.

For real names, point the DNS records of app.example.com and blog.example.com at the node (or load balancer) addresses. HTTPS needs a certificate. The usual way is cert-manager with Let's Encrypt, and the Ingress then carries a tls: section naming a Secret. That was not run in the lab, since it needs real DNS names and a reachable address. Traefik and most other controllers read extra settings from annotations on the Ingress, and those differ from controller to controller, so keep to the standard fields where you can. The newer Gateway API (HTTPRoute and friends) is replacing Ingress, and was not run either.

$ kubectl delete -f sites.yaml
deployment.apps "blog" deleted from default namespace
service "blog" deleted from default namespace
ingress.networking.k8s.io "sites" deleted from default namespace
$ kubectl delete -f app.yaml
deployment.apps "app" deleted from default namespace
service "app" deleted from default namespace
$ kubectl delete pod dns --wait=false
pod "dns" deleted from default namespace

ConfigMaps and Secrets

A container image should not carry settings that change between environments. A ConfigMap holds non-secret configuration (a file, a few keys), and a Secret holds the confidential kind (passwords, tokens, keys). Both are objects in the API, and a pod gets them as environment variables or as files.

$ kubectl create configmap app-config --from-literal=GREETING=hello --from-literal=LEVEL=info
configmap/app-config created
$ cat settings.conf
mode=production
retries=3
$ kubectl create configmap app-files --from-file=settings.conf
configmap/app-files created
$ kubectl get configmap app-config -o yaml | sed -n '1,10p'
apiVersion: v1
data:
  GREETING: hello
  LEVEL: info
kind: ConfigMap
metadata:
  creationTimestamp: "2026-10-19T21:29:51Z"
  name: app-config
  namespace: default
  resourceVersion: "1792"

A pod uses the first as environment variables (every key becomes a variable) and the second as a mounted directory (every key becomes a file):

$ cat config-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: cfg
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "sleep 3600"]
    envFrom:
    - configMapRef:
        name: app-config
    volumeMounts:
    - name: files
      mountPath: /etc/app
  volumes:
  - name: files
    configMap:
      name: app-files
$ kubectl apply -f config-pod.yaml
pod/cfg created
$ kubectl exec cfg -- sh -c 'echo "GREETING=$GREETING LEVEL=$LEVEL"; ls /etc/app; cat /etc/app/settings.conf'
GREETING=hello LEVEL=info
settings.conf
mode=production
retries=3

Change the ConfigMap, and look at which of the two sees the change:

$ kubectl patch configmap app-config -p '{"data":{"GREETING":"goodbye"}}'
configmap/app-config patched
$ echo 'mode=staging' > settings.conf && kubectl create configmap app-files --from-file=settings.conf --dry-run=client -o yaml | kubectl apply -f -
Warning: resource configmaps/app-files is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/app-files configured
$ kubectl exec cfg -- sh -c 'echo "GREETING=$GREETING"; cat /etc/app/settings.conf'
GREETING=hello
mode=staging

The environment variable still says hello, and the file shows the new value. Environment variables are fixed when the container starts, and a changed ConfigMap reaches them only when the pod is recreated (kubectl rollout restart for a Deployment). The mounted files are refreshed by the kubelet in the background, after a delay (a minute or so: 63 seconds in a separate timing test, and the example above waited for it). A file that is mounted with subPath is never refreshed. Many applications do not re-read their files anyway, so a restart is the dependable way in both cases.

Secrets are used the same way. Create one, and look at it:

$ kubectl create secret generic db --from-literal=username=app --from-literal=password='S3cr3t-lab-only'
secret/db created
$ kubectl get secret db
NAME   TYPE     DATA   AGE
db     Opaque   2      0s
$ kubectl get secret db -o yaml | sed -n '1,8p'
apiVersion: v1
data:
  password: UzNjcjN0LWxhYi1vbmx5
  username: YXBw
kind: Secret
metadata:
  creationTimestamp: "2026-10-19T21:31:12Z"
  name: db
$ kubectl get secret db -o jsonpath='{.data.password}' | base64 -d; echo
S3cr3t-lab-only

The values are base64 encoded, which is a way to carry binary data and is not encryption. Anyone who can read the Secret object can read the password in one command. What protects Secrets is who may read them (RBAC, a later section) and whether the database underneath them is encrypted:

$ k3s secrets-encrypt status     # run as root on the server node
Encryption Status: Disabled, no configuration file found

In k3s, encryption of Secrets at rest in the database is off by default, as the status shows, and it is turned on with the server flag --secrets-encryption (not run in the lab). In other distributions it is a setting of the API server (an EncryptionConfiguration) or the cloud provider's key service. Even with it on, the Secret is plain text inside the pod, and anyone with exec access to the pod can read it.

Use the Secret in a pod as variables from one key, or as files:

$ cat secret-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: sec
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "sleep 3600"]
    env:
    - name: DB_USER
      valueFrom:
        secretKeyRef:
          name: db
          key: username
    volumeMounts:
    - name: creds
      mountPath: /etc/db
      readOnly: true
  volumes:
  - name: creds
    secret:
      secretName: db
      defaultMode: 0400
$ kubectl apply -f secret-pod.yaml
pod/sec created
$ kubectl exec sec -- sh -c 'echo "DB_USER=$DB_USER"; ls -l /etc/db; cat /etc/db/password; echo'
DB_USER=app
total 0
lrwxrwxrwx    1 root     root            15 Oct 19 21:31 password -> ..data/password
lrwxrwxrwx    1 root     root            15 Oct 19 21:31 username -> ..data/username
S3cr3t-lab-only

The files in the listing are links into a hidden directory that Kubernetes swaps in one step when the Secret changes. Files from a Secret are held in memory (tmpfs) on the node and not written to its disk. Never put a Secret in a manifest that goes into git. The usual answers are sealed secrets (Sealed Secrets), an operator that reads from a secrets manager (External Secrets, Vault), or SOPS-encrypted files, and none of them were run in the lab. Clean up:

$ kubectl delete pod cfg sec --wait=false
pod "cfg" deleted from default namespace
pod "sec" deleted from default namespace
$ kubectl delete configmap app-config app-files
configmap "app-config" deleted from default namespace
configmap "app-files" deleted from default namespace
$ kubectl delete secret db
secret "db" deleted from default namespace

Storage: volumes, claims and what survives

A container's own file system disappears with the container. Data that must survive lives in a volume. For durable data the pod does not name a disk. It asks for storage with a PersistentVolumeClaim (PVC): "10 GB, readable and writable by one node", and the cluster finds or makes a PersistentVolume (PV) that fits, through a StorageClass. The same manifest then works on a laptop, in a data centre and in a cloud, with different StorageClasses behind it.

$ kubectl get storageclass
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  3m3s
$ kubectl get pv,pvc
No resources found

k3s ships the local-path provisioner: a PV is a directory on the node's disk. It is not replicated, so the data lives and dies with that node, which is fine for a lab and wrong for a database you care about (use a network or cloud disk class for that, not run here). Make a claim:

$ cat claim.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 1Gi
$ kubectl apply -f claim.yaml
persistentvolumeclaim/data created
$ kubectl get pvc data
NAME   STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data   Pending                                      local-path     <unset>                 3s

The claim is Pending, and that is correct: the StorageClass has volumeBindingMode WaitForFirstConsumer, which means the volume is only made once a pod that uses the claim is scheduled, so it can be put on that pod's node. Start a pod that uses it:

$ cat writer.yaml
apiVersion: v1
kind: Pod
metadata:
  name: writer
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "echo 'written by the first pod' > /data/note.txt; sleep 3600"]
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data
$ kubectl apply -f writer.yaml
pod/writer created
$ kubectl get pvc data
NAME   STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data   Bound    pvc-dc67754a-7e9b-4e5f-b418-ea792fa94803   1Gi        RWO            local-path     <unset>                 9s
$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM          STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-dc67754a-7e9b-4e5f-b418-ea792fa94803   1Gi        RWO            Delete           Bound    default/data   local-path     <unset>                          3s
$ kubectl get pod writer -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
writer   1/1     Running   0          5s    10.42.1.22   k8w1   <none>           <none>

The claim is Bound to a new volume. Now delete the pod, and start another one that mounts the same claim:

$ kubectl delete pod writer
pod "writer" deleted from default namespace
$ cat reader.yaml
apiVersion: v1
kind: Pod
metadata:
  name: reader
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "sleep 3600"]
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data
$ kubectl apply -f reader.yaml
pod/reader created
$ kubectl exec reader -- cat /data/note.txt
written by the first pod
$ kubectl get pod reader -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
reader   1/1     Running   0          1s    10.42.1.23   k8w1   <none>           <none>

The file survived, and the new pod ran on the same node as the old one, because a local-path volume exists on one node only. The PV says so (nodeAffinity), and the pod can only be scheduled where the data is. The directory on that node:

$ kubectl get pv -o jsonpath='{.items[0].spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}{"\n"}'
k8w1
$ kubectl get pv -o jsonpath='{.items[0].spec.local.path}{.items[0].spec.hostPath.path}{"\n"}'
/var/lib/rancher/k3s/storage/pvc-dc67754a-7e9b-4e5f-b418-ea792fa94803_default_data

Access modes say how many nodes may mount the volume at the same time: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany (needs a file system such as NFS or CephFS, not run here) and ReadWriteOncePod (one pod). What happens to the data when the claim is deleted is the reclaim policy of the StorageClass. For local-path it is Delete:

$ kubectl get storageclass local-path -o jsonpath='{.reclaimPolicy}{"\n"}'
Delete
$ kubectl delete pod reader
pod "reader" deleted from default namespace
$ kubectl delete pvc data
persistentvolumeclaim "data" deleted from default namespace
$ kubectl get pv,pvc
No resources found

The volume and its data are gone together with the claim. For data that matters, use a class with reclaimPolicy: Retain (the PV is kept and needs a human), and back it up, because no replica exists. Deleting a Namespace deletes its claims too.

StatefulSet: pods with a name and a disk of their own

A Deployment treats pods as interchangeable cattle: any of them can be replaced by any other. Some programs are not like that, such as a database with a primary and replicas, or a cluster that needs member 0 to start first. A StatefulSet gives each pod a stable name (web-0, web-1), a stable network name through a headless Service, its own volume that follows it, and an orderly start and stop.

$ cat stateful.yaml
apiVersion: v1
kind: Service
metadata:
  name: db
spec:
  clusterIP: None
  selector:
    app: db
  ports:
  - port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
spec:
  serviceName: db
  replicas: 2
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
      - name: main
        image: busybox:1.36
        command: ["sh", "-c", "echo \"I am $(hostname)\" > /data/id; sleep 36000"]
        volumeMounts:
        - name: data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 100Mi
$ kubectl apply -f stateful.yaml
service/db created
statefulset.apps/db created
$ kubectl get pods -l app=db -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
db-0   1/1     Running   0          9s    10.42.1.26   k8w1   <none>           <none>
db-1   1/1     Running   0          4s    10.42.0.21   k8s    <none>           <none>
$ kubectl get pvc
NAME        STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-db-0   Bound    pvc-efa8129f-90bf-4055-98c7-051cfa4b59db   100Mi      RWO            local-path     <unset>                 9s
data-db-1   Bound    pvc-54b2833c-71e5-4522-b797-5c258942b964   100Mi      RWO            local-path     <unset>                 4s

The pods came up in order, db-0 first, and each has its own claim named after the template and the pod (data-db-0, data-db-1). The headless Service (clusterIP: None) has no virtual address: its DNS name returns the pod addresses, and every pod also gets its own name (busybox's nslookup does not apply the search list to a name that has a dot in it, so it needs the full name, while ping and most programs use the system resolver and accept the short db-0.db):

$ kubectl run dns2 --image=busybox:1.36 --restart=Never -- sleep 3600
pod/dns2 created
$ kubectl exec dns2 -- nslookup db 2>&1 | grep -E '^(Name|Address)' | head -6
Address:	10.43.0.10:53
Name:	db.default.svc.cluster.local
Address: 10.42.0.21
Name:	db.default.svc.cluster.local
Address: 10.42.1.26
$ kubectl exec dns2 -- nslookup db-0.db.default.svc.cluster.local 2>&1 | grep -A1 '^Name'
Name:	db-0.db.default.svc.cluster.local
Address: 10.42.1.26
$ kubectl exec dns2 -- ping -c1 db-0.db | head -1
PING db-0.db (10.42.1.26): 56 data bytes

Now delete db-0, and see what comes back:

$ kubectl exec db-0 -- cat /data/id
I am db-0
$ kubectl get pod db-0 -o jsonpath='{.status.podIP}{"\n"}'
10.42.1.26
$ kubectl delete pod db-0
pod "db-0" deleted from default namespace
$ kubectl exec db-0 -- cat /data/id
I am db-0
$ kubectl get pod db-0 -o jsonpath='{.status.podIP}{"\n"}'
10.42.1.28

The pod came back with the same name and the same disk (the file is still there), but not necessarily the same IP address, which is why you talk to db-0.db and never to a pod IP. Scaling down removes the highest number first, and keeps the volume:

$ kubectl scale statefulset db --replicas=1
statefulset.apps/db scaled
$ kubectl get pods -l app=db
NAME   READY   STATUS        RESTARTS   AGE
db-0   1/1     Running       0          7s
db-1   1/1     Terminating   0          45s
$ kubectl get pvc
NAME        STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-db-0   Bound    pvc-efa8129f-90bf-4055-98c7-051cfa4b59db   100Mi      RWO            local-path     <unset>                 50s
data-db-1   Bound    pvc-54b2833c-71e5-4522-b797-5c258942b964   100Mi      RWO            local-path     <unset>                 45s

The claim data-db-1 is still there, so that scaling back up gives db-1 its data again. It is also how a forgotten claim keeps costing money in a cloud: when you remove a StatefulSet for good, delete its claims too.

$ kubectl delete statefulset db
statefulset.apps "db" deleted from default namespace
$ kubectl delete pvc -l app=db
persistentvolumeclaim "data-db-0" deleted from default namespace
persistentvolumeclaim "data-db-1" deleted from default namespace
$ kubectl delete svc db
service "db" deleted from default namespace
$ kubectl delete pod dns2 --wait=false
pod "dns2" deleted from default namespace

A StatefulSet gives the identity and the disks. It does not give you replication, failover or backups, and a database running in one still needs an operator or the database's own clustering to do that. Updates go one pod at a time, from the highest number down, and the partition field of updateStrategy holds back an update to test it on one pod first (not run).

Jobs, CronJobs and DaemonSets

Not everything runs forever. A Job runs a task to completion, retries it if it fails, and keeps the record. A CronJob makes a Job on a schedule. A DaemonSet runs one pod on every node (log collectors, monitoring agents, node tools).

$ cat job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: count
spec:
  completions: 3
  parallelism: 2
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: main
        image: busybox:1.36
        command: ["sh", "-c", "echo working on it; sleep 3; echo done"]
$ kubectl apply -f job.yaml
job.batch/count created
$ kubectl get job count
NAME    STATUS     COMPLETIONS   DURATION   AGE
count   Complete   3/3           12s        12s
$ kubectl get pods -l job-name=count
NAME          READY   STATUS      RESTARTS   AGE
count-7nc5s   0/1     Completed   0          6s
count-rhmhz   0/1     Completed   0          12s
count-v9hc7   0/1     Completed   0          12s
$ kubectl logs -l job-name=count --tail=1
done
done
done

Three pods ran to completion, two at a time, and stayed (Completed) so that their logs can be read. They are removed with the Job, or by ttlSecondsAfterFinished. A Job that fails is retried up to backoffLimit times, with growing pauses, and then marked failed:

$ cat failing.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: broken
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: main
        image: busybox:1.36
        command: ["sh", "-c", "echo trying; exit 1"]
$ kubectl apply -f failing.yaml
job.batch/broken created
$ kubectl get job broken
NAME     STATUS   COMPLETIONS   DURATION   AGE
broken   Failed   0/1           32s        32s
$ kubectl get pods -l job-name=broken
NAME           READY   STATUS   RESTARTS   AGE
broken-kkgh5   0/1     Error    0          23s
broken-nrp2h   0/1     Error    0          3s
broken-xsds9   0/1     Error    0          33s
$ kubectl get job broken -o jsonpath='{.status.conditions[0].reason}{": "}{.status.conditions[0].message}{"\n"}'
BackoffLimitExceeded: Job has reached the specified backoff limit

Three pods (the first try and two retries) all failed, and the Job gave up with BackoffLimitExceeded. Now a CronJob. It makes a Job on a schedule written like crontab (minute, hour, day of month, month, day of week), in UTC unless timeZone is set:

$ cat cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: main
            image: busybox:1.36
            command: ["sh", "-c", "date; echo hello from a cron job"]
$ kubectl apply -f cron.yaml
cronjob.batch/hello created
$ kubectl create job manual --from=cronjob/hello
job.batch/manual created
$ kubectl get cronjob hello
NAME    SCHEDULE    TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
hello   * * * * *   <none>     False     0        58s             71s
$ kubectl get jobs
NAME             STATUS     COMPLETIONS   DURATION   AGE
broken           Failed     0/1           104s       104s
count            Complete   3/3           12s        117s
hello-29874095   Complete   1/1           3s         58s
manual           Complete   1/1           3s         71s
$ kubectl logs job/manual
Mon Oct 19 21:34:48 UTC 2026
hello from a cron job

kubectl create job --from=cronjob/NAME runs a CronJob once now, which is the way to test it. After a minute the schedule itself made a Job (named hello-NUMBER). concurrencyPolicy: Forbid skips a run if the last one is still going, and the two history limits keep the number of old Jobs down. A CronJob that is suspended (spec.suspend: true) makes nothing. If the cluster was down at the time, a run that was missed by more than startingDeadlineSeconds is skipped, so a critical schedule needs that set and the job should be able to catch up.

A DaemonSet has no replica count, because the count is the number of matching nodes:

$ cat daemon.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: agent
spec:
  selector:
    matchLabels:
      app: agent
  template:
    metadata:
      labels:
        app: agent
    spec:
      containers:
      - name: main
        image: busybox:1.36
        command: ["sh", "-c", "echo agent on $NODE; sleep 36000"]
        env:
        - name: NODE
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
$ kubectl apply -f daemon.yaml
daemonset.apps/agent created
$ kubectl get daemonset agent
NAME    DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
agent   2         2         2       2            2           <none>          1s
$ kubectl get pods -l app=agent -o wide
NAME          READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
agent-2rg4p   1/1     Running   0          2s    10.42.1.34   k8w1   <none>           <none>
agent-bq4zb   1/1     Running   0          2s    10.42.0.27   k8s    <none>           <none>
$ kubectl logs -l app=agent --tail=1
agent on k8w1
agent on k8s

One pod ran on each of the two nodes, and a third node that joined later would get one automatically. Clean up:

$ kubectl delete job count broken manual
job.batch "count" deleted from default namespace
job.batch "broken" deleted from default namespace
job.batch "manual" deleted from default namespace
$ kubectl delete cronjob hello
cronjob.batch "hello" deleted from default namespace
$ kubectl delete daemonset agent
daemonset.apps "agent" deleted from default namespace

Requests, limits and what happens when they are wrong

Every container can say two things about CPU and memory. A request is the amount it needs: the scheduler places the pod on a node that has that much unclaimed, and the kubelet reserves it. A limit is the most it may use: past it, CPU is throttled, and memory use is fatal (the kernel kills the process). The two together decide the pod's quality of service (QoS) class, which decides who is killed first when a node runs out of memory:

Guaranteed  every container has requests equal to limits (for CPU and memory). Killed last.
Burstable   at least one request or limit is set, but not as above. The common case.
BestEffort  nothing set. Killed first, and the scheduler counts it as asking for nothing.

Make one of each and look at the class Kubernetes gave it:

$ cat qos.yaml
apiVersion: v1
kind: Pod
metadata:
  name: guaranteed
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
    resources:
      requests: {cpu: 100m, memory: 64Mi}
      limits: {cpu: 100m, memory: 64Mi}
---
apiVersion: v1
kind: Pod
metadata:
  name: burstable
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
    resources:
      requests: {cpu: 50m, memory: 32Mi}
---
apiVersion: v1
kind: Pod
metadata:
  name: besteffort
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
$ kubectl apply -f qos.yaml
pod/guaranteed created
pod/burstable created
pod/besteffort created
$ kubectl get pods guaranteed burstable besteffort -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass
NAME         QOS
guaranteed   Guaranteed
burstable    Burstable
besteffort   BestEffort

CPU is measured in cores, and 100m means a tenth of a core ("millicores"). Memory is in bytes (Mi is mebibytes). Now a limit that is too low. This container allocates about 50 MB, and its limit is 20 MiB:

$ cat oom.yaml
apiVersion: v1
kind: Pod
metadata:
  name: oom
spec:
  restartPolicy: OnFailure
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "x=$(head -c 50000000 /dev/zero | tr '\\0' a); sleep 3600"]
    resources:
      limits:
        memory: 20Mi
$ kubectl apply -f oom.yaml
pod/oom created
$ kubectl get pod oom
NAME   READY   STATUS      RESTARTS      AGE
oom    0/1     OOMKilled   2 (23s ago)   25s
$ kubectl get pod oom -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{" exit code "}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
OOMKilled exit code 137
$ kubectl describe pod oom | grep -E 'Reason:|Exit Code|Restart Count|Limits|memory'
      Reason:       OOMKilled
      Exit Code:    137
      Reason:       OOMKilled
      Exit Code:    137
    Restart Count:  2
    Limits:
      memory:  20Mi
      memory:     20Mi

OOMKilled with exit code 137 (128 plus the signal 9) is the memory limit doing its job, and the pod restarts, grows, and is killed again, with growing pauses (CrashLoopBackOff). The fix is a bigger limit, or fixing the program. A CPU limit never kills, it only slows the container down.

The other side is a request that cannot be met. Ask for more CPU than any node has:

$ cat big.yaml
apiVersion: v1
kind: Pod
metadata:
  name: big
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
    resources:
      requests:
        cpu: "64"
$ kubectl apply -f big.yaml
pod/big created
$ kubectl get pod big
NAME   READY   STATUS    RESTARTS   AGE
big    0/1     Pending   0          5s
$ kubectl describe pod big | grep -A3 -E '^Events:'
Events:
  Type     Reason            Age              From               Message
  ----     ------            ----             ----               -------
  Warning  FailedScheduling  5s               default-scheduler  0/2 nodes are available: 2 Insufficient cpu. no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.

The pod is Pending, and the event says FailedScheduling and why ("Insufficient cpu"). A pod stays Pending until something changes, with no error, so Pending is the status to look at first when something does not start. How much room each node has, and how much of it is claimed by requests:

$ kubectl describe node k8w1 | grep -A8 'Allocated resources'
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests    Limits
  --------           --------    ------
  cpu                150m (1%)   100m (1%)
  memory             116Mi (0%)  84Mi (0%)
  ephemeral-storage  0 (0%)      0 (0%)
  hugepages-1Gi      0 (0%)      0 (0%)
  hugepages-2Mi      0 (0%)      0 (0%)
$ kubectl top nodes
NAME   CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
k8s    712m         8%       5764Mi          18%
k8w1   684m         8%       5765Mi          18%
$ kubectl top pods -A --sort-by=memory | head -5
NAMESPACE     NAME                                     CPU(cores)   MEMORY(bytes)
kube-system   metrics-server-7f4b6d9bd7-mcfwk          8m           22Mi
kube-system   traefik-5869c87968-fszlq                 1m           18Mi
kube-system   coredns-b644fd95b-rfzv8                  3m           14Mi
kube-system   local-path-provisioner-d9d8769b6-582zr   1m           9Mi

The numbers in "Allocated resources" are requests and limits, which is what the scheduler counts. kubectl top shows what is really being used, from metrics-server. A node can be full of requests and nearly idle, or the other way round, and both are problems. Set requests from what the program really uses (look at kubectl top over a week), set a memory limit a little above the peak, and think hard before setting a CPU limit (throttling can slow a program that has plenty of idle CPU on the node).

$ kubectl delete pod guaranteed burstable besteffort oom big --wait=false
pod "guaranteed" deleted from default namespace
pod "burstable" deleted from default namespace
pod "besteffort" deleted from default namespace
pod "oom" deleted from default namespace
pod "big" deleted from default namespace

Probes: tell Kubernetes when a container is alive and ready

A container that is running is not necessarily working. Three probes let Kubernetes ask, and each answers a different question:

readiness   "can it take traffic now?" If it fails, the pod is removed from the Service's endpoints, and not restarted.
liveness    "is it stuck for good?" If it fails, the container is restarted.
startup     "has it finished starting?" Holds off the other two for slow starters.

A probe is a command to run in the container, an HTTP request, or a TCP connection. This pod is ready only while /tmp/ready exists, and alive only while /tmp/alive exists, and a Service selects it:

$ cat probes.yaml
apiVersion: v1
kind: Pod
metadata:
  name: probed
  labels:
    app: probed
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sh", "-c", "touch /tmp/alive /tmp/ready; sleep 36000"]
    readinessProbe:
      exec:
        command: ["test", "-f", "/tmp/ready"]
      periodSeconds: 3
    livenessProbe:
      exec:
        command: ["test", "-f", "/tmp/alive"]
      periodSeconds: 3
      failureThreshold: 2
---
apiVersion: v1
kind: Service
metadata:
  name: probed
spec:
  selector:
    app: probed
  ports:
  - port: 80
$ kubectl apply -f probes.yaml
pod/probed created
service/probed created
$ kubectl get pod probed
NAME     READY   STATUS    RESTARTS   AGE
probed   1/1     Running   0          2s
$ kubectl get endpointslices -l kubernetes.io/service-name=probed -o jsonpath='{.items[0].endpoints[0].conditions.ready}{"\n"}'
true

Break readiness, and look at the pod and the Service:

$ kubectl exec probed -- rm /tmp/ready
$ kubectl get pod probed
NAME     READY   STATUS    RESTARTS   AGE
probed   0/1     Running   0          11s
$ kubectl get endpointslices -l kubernetes.io/service-name=probed -o jsonpath='{.items[0].endpoints[0].conditions.ready}{"\n"}'
false
$ kubectl describe pod probed | grep -E 'Readiness|Unhealthy' | head -3
    Readiness:      exec [test -f /tmp/ready] delay=0s timeout=1s period=3s #success=1 #failure=3
  Warning  Unhealthy  2s (x4 over 8s)  kubelet            spec.containers{main}: Readiness probe failed:

The pod is Running but 0/1 ready, and the Service no longer sends it traffic: the address is still in the endpoint list, but its ready condition is now false (the second answer above), and the old v1 Endpoints object shows an empty list. The container was not touched. That is the right response for "busy" or "warming up": take it out of rotation until it is ready again. Put the file back, and it returns:

$ kubectl exec probed -- touch /tmp/ready
$ kubectl get pod probed
NAME     READY   STATUS    RESTARTS   AGE
probed   1/1     Running   0          14s

Now break liveness. This time Kubernetes restarts the container:

$ kubectl exec probed -- rm /tmp/alive
$ kubectl get pod probed
NAME     READY   STATUS    RESTARTS     AGE
probed   1/1     Running   1 (1s ago)   49s
$ kubectl describe pod probed | grep -E 'Liveness probe failed|Killing|Restart Count' | head -4
    Restart Count:  1
  Warning  Unhealthy  32s (x2 over 35s)  kubelet            spec.containers{main}: Liveness probe failed:
  Normal   Killing    32s                kubelet            spec.containers{main}: Container main failed liveness probe, will be restarted

The restart count went to 1, and the events say that the liveness probe failed and the container was killed and started again. Setting probes badly causes outages of its own, so keep to these rules:

a liveness probe checks only that the process itself is not stuck, never a dependency (if the database is down, restarting every web pod does not help, and it makes the outage bigger)
a readiness probe may check dependencies, because "not ready" only removes a pod from traffic
make probes cheap and fast, set timeoutSeconds for slow ones, and leave the failureThreshold at 3 or more so that one slow answer does not restart a pod
use a startupProbe for programs that take minutes to start, instead of a long initialDelaySeconds on the liveness probe
a pod with no readiness probe is "ready" as soon as its process starts, which sends it traffic too early

$ kubectl delete -f probes.yaml
pod "probed" deleted from default namespace
service "probed" deleted from default namespace

Scheduling: where pods go, and moving them off a node

The scheduler puts each pod on a node by two steps: it throws out the nodes that cannot run it (not enough room, a taint it does not tolerate, a label it does not match), and then it picks the best of the rest. You steer it with labels and rules.

Label the nodes, and ask for a node by label with nodeSelector. The simplest control of all:

$ kubectl label node k8w1 disk=ssd
node/k8w1 labeled
$ kubectl get nodes -L disk
NAME   STATUS   ROLES                AGE     VERSION        DISK
k8s    Ready    control-plane,etcd   9m50s   v1.35.9+k3s1
k8w1   Ready    <none>               9m44s   v1.35.9+k3s1   ssd
$ cat pinned.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pinned
spec:
  replicas: 3
  selector:
    matchLabels:
      app: pinned
  template:
    metadata:
      labels:
        app: pinned
    spec:
      nodeSelector:
        disk: ssd
      containers:
      - name: main
        image: busybox:1.36
        command: ["sleep", "3600"]
$ kubectl apply -f pinned.yaml
deployment.apps/pinned created
$ kubectl get pods -l app=pinned -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP           NODE   NOMINATED NODE   READINESS GATES
pinned-5596c6bbdf-bwzc9   1/1     Running   0          2s    10.42.1.42   k8w1   <none>           <none>
pinned-5596c6bbdf-rt9dm   1/1     Running   0          2s    10.42.1.41   k8w1   <none>           <none>
pinned-5596c6bbdf-xdgh2   1/1     Running   0          2s    10.42.1.40   k8w1   <none>           <none>

All three pods went to the one node with the label. Richer rules exist (nodeAffinity with "prefer" and "require" and expressions, podAffinity and podAntiAffinity to keep pods together or apart), and topologySpreadConstraints, which spread replicas evenly across zones or nodes, are usually the better tool for "do not put all my replicas in one place":

$ cat spread.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spread
spec:
  replicas: 4
  selector:
    matchLabels:
      app: spread
  template:
    metadata:
      labels:
        app: spread
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: spread
      containers:
      - name: main
        image: busybox:1.36
        command: ["sleep", "3600"]
$ kubectl apply -f spread.yaml
deployment.apps/spread created
$ kubectl get pods -l app=spread -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      2 k8s
      2 k8w1
$ kubectl delete -f pinned.yaml -f spread.yaml
deployment.apps "pinned" deleted from default namespace
deployment.apps "spread" deleted from default namespace

maxSkew 1 means that the number of pods on any two nodes may differ by at most one: two and two. The opposite of a label is a taint. A taint on a node repels every pod that does not tolerate it, which reserves nodes for special work (GPUs, a database tier, the control plane):

$ kubectl taint node k8w1 dedicated=db:NoSchedule
node/k8w1 tainted
$ kubectl describe node k8w1 | grep Taints
Taints:             dedicated=db:NoSchedule
$ kubectl create deployment plain --image=busybox:1.36 --replicas=3 -- sleep 3600
deployment.apps/plain created
$ kubectl get pods -l app=plain -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      3 k8s

No pod landed on k8w1. Pods that tolerate the taint may go there (and are not forced to):

$ cat tolerant.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tolerant
spec:
  replicas: 4
  selector:
    matchLabels:
      app: tolerant
  template:
    metadata:
      labels:
        app: tolerant
    spec:
      tolerations:
      - key: dedicated
        operator: Equal
        value: db
        effect: NoSchedule
      nodeSelector:
        disk: ssd
      containers:
      - name: main
        image: busybox:1.36
        command: ["sleep", "3600"]
$ kubectl apply -f tolerant.yaml
deployment.apps/tolerant created
$ kubectl get pods -l app=tolerant -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      4 k8w1
$ kubectl taint node k8w1 dedicated=db:NoSchedule-
node/k8w1 untainted
$ kubectl delete deployment plain tolerant
deployment.apps "plain" deleted from default namespace
deployment.apps "tolerant" deleted from default namespace

Taint effects are NoSchedule (new pods are kept away), PreferNoSchedule (a soft version) and NoExecute (running pods that do not tolerate it are evicted as well). The same mechanism is what Kubernetes uses on its own: a node that is not ready gets a taint, and pods are moved off it after a delay.

For maintenance, take a node out of service on purpose. cordon only stops new pods, and drain also moves the existing ones, politely (they are deleted and the controllers make new ones elsewhere):

$ kubectl create deployment keep --image=nginx:alpine --replicas=4
deployment.apps/keep created
$ kubectl get pods -l app=keep -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      2 k8s
      2 k8w1
$ kubectl cordon k8w1
node/k8w1 cordoned
$ kubectl get nodes
NAME   STATUS                     ROLES                AGE     VERSION
k8s    Ready                      control-plane,etcd   9m59s   v1.35.9+k3s1
k8w1   Ready,SchedulingDisabled   <none>               9m53s   v1.35.9+k3s1
$ kubectl drain k8w1 --ignore-daemonsets --delete-emptydir-data 2>&1 | tail -4
pod/tolerant-9587fdc6-kx266 evicted
pod/tolerant-9587fdc6-74dd5 evicted
pod/tolerant-9587fdc6-f48hd evicted
node/k8w1 drained
$ kubectl get pods -l app=keep -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      4 k8s

All four pods are now on k8s, and the worker is free to be rebooted, upgraded or replaced. drain refuses to remove pods that no controller owns (a bare pod would be lost) unless you force it, and the DaemonSet pods are left, because a DaemonSet would put them straight back. When the work is done, bring it back with uncordon. Nothing moves back by itself:

$ kubectl uncordon k8w1
node/k8w1 uncordoned
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   10m   v1.35.9+k3s1
k8w1   Ready    <none>               10m   v1.35.9+k3s1
$ kubectl get pods -l app=keep -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      4 k8s

A PodDisruptionBudget limits how many pods of an application a voluntary disruption (a drain, a cluster upgrade) may take down at once. Without one, a drain can remove every replica of a service together, if they all sit on that node. With one, the drain waits:

$ cat pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: keep
spec:
  minAvailable: 3
  selector:
    matchLabels:
      app: keep
$ kubectl apply -f pdb.yaml
poddisruptionbudget.policy/keep created
$ kubectl get pdb keep
NAME   MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
keep   3               N/A               1                     0s
$ kubectl scale deployment keep --replicas=3
deployment.apps/keep scaled
$ kubectl get pdb keep
NAME   MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
keep   3               N/A               0                     0s
$ kubectl get pods -l app=keep -o wide --no-headers | awk '{print $7}' | sort | uniq -c
      4 k8s
$ kubectl cordon k8s
node/k8s cordoned
$ timeout 25 kubectl drain k8s --ignore-daemonsets --delete-emptydir-data 2>&1 | grep -iE 'cannot evict|disruption budget|evicting' | sort | uniq -c | head -4
      5 error when evicting pods/"keep-9459bb8ff-7mdmj" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
      5 error when evicting pods/"keep-9459bb8ff-8rf2p" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
      5 error when evicting pods/"keep-9459bb8ff-fzc98" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
      5 evicting pod default/keep-9459bb8ff-7mdmj

The drain was stopped by the budget: it tried to evict a pod, was told that doing so would leave fewer than 3 available, and kept retrying until the timeout cut it off. That is the budget protecting the service. Scale up so that the budget allows it, or relax it, and the drain goes through. Put everything back:

$ kubectl uncordon k8s
node/k8s uncordoned
$ kubectl delete -f pdb.yaml
poddisruptionbudget.policy "keep" deleted from default namespace
$ kubectl delete deployment keep
deployment.apps "keep" deleted from default namespace

Autoscaling with the HPA and metrics-server

The HorizontalPodAutoscaler (HPA) changes the number of replicas of a Deployment from a measured value, usually CPU. It needs two things: metrics (metrics-server, which k3s runs) and a CPU request on the pods, because the percentage it uses is a percentage of the request. A pod with no request gives the HPA nothing to divide by.

$ kubectl top nodes
NAME   CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
k8s    1401m        17%      5699Mi          17%
k8w1   1243m        15%      5704Mi          17%
$ kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        10m
$ cat hpa-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hpa
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hpa
  template:
    metadata:
      labels:
        app: hpa
    spec:
      containers:
      - name: main
        image: registry.k8s.io/hpa-example
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: 100m
          limits:
            cpu: 200m
---
apiVersion: v1
kind: Service
metadata:
  name: hpa
spec:
  selector:
    app: hpa
  ports:
  - port: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: hpa
  minReplicas: 1
  maxReplicas: 4
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50
$ kubectl apply -f hpa-app.yaml
deployment.apps/hpa created
service/hpa created
horizontalpodautoscaler.autoscaling/hpa created
$ kubectl get hpa hpa
NAME   REFERENCE        TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
hpa    Deployment/hpa   cpu: <unknown>/50%   1         4         1          42s

The target shows the current use over the target (cpu: 1%/50% here, once the first measurement is in; <unknown> before that). Now send load to the Service from a pod in the cluster, and watch the HPA react:

$ kubectl run load --image=busybox:1.36 --restart=Never -- sh -c 'while true; do wget -q -O- http://hpa > /dev/null; done'
pod/load created
$ kubectl get hpa hpa
NAME   REFERENCE        TARGETS        MINPODS   MAXPODS   REPLICAS   AGE
hpa    Deployment/hpa   cpu: 54%/50%   1         4         4          2m13s
$ kubectl top pods -l app=hpa
NAME                   CPU(cores)   MEMORY(bytes)
hpa-75ff459d5b-556js   85m          12Mi
hpa-75ff459d5b-74hll   41m          11Mi
hpa-75ff459d5b-qrd9x   37m          11Mi
hpa-75ff459d5b-stxj5   56m          11Mi
$ kubectl get pods -l app=hpa
NAME                   READY   STATUS    RESTARTS   AGE
hpa-75ff459d5b-556js   1/1     Running   0          58s
hpa-75ff459d5b-74hll   1/1     Running   0          58s
hpa-75ff459d5b-qrd9x   1/1     Running   0          2m13s
hpa-75ff459d5b-stxj5   1/1     Running   0          58s

The measured CPU went well over 50% of the request, so the HPA raised the replica count, to 4, which is the maximum. It checks every 15 seconds, and scales up quickly. Scaling down is slow on purpose: by default it waits 5 minutes of lower load before removing pods, so that a short dip does not cause flapping. That wait was not run in the lab. Stop the load. The measured value falls only when the next metrics window is in (about a minute), and the replicas then stay for the stabilisation window:

$ kubectl delete pod load --wait=false
pod "load" deleted from default namespace
$ kubectl get hpa hpa
NAME   REFERENCE        TARGETS       MINPODS   MAXPODS   REPLICAS   AGE
hpa    Deployment/hpa   cpu: 5%/50%   1         4         4          3m33s
$ kubectl describe hpa hpa | grep -E 'Min replicas|Max replicas|Deployment pods|ScalingActive' | head -4
Min replicas:                                          1
Max replicas:                                          4
Deployment pods:                                       4 current / 4 desired
  ScalingActive   True    ValidMetricFound     the HPA was able to successfully calculate a replica count from cpu resource utilization (percentage of request)

Notes for real use. The HPA and a fixed replicas: number in a manifest fight each other, so leave replicas out of a Deployment that has an HPA, or the next apply resets it. Scaling on CPU only suits programs whose load shows in CPU. For queue length or request rate you need custom metrics (Prometheus adapter or KEDA), which were not run. The HPA only adds pods: if the nodes are full, the new pods stay Pending, and adding nodes is the job of the cluster autoscaler or Karpenter in a cloud (not run). The vertical autoscaler, which changes requests, is a separate add-on and was not run either.

$ kubectl delete -f hpa-app.yaml
deployment.apps "hpa" deleted from default namespace
service "hpa" deleted from default namespace
horizontalpodautoscaler.autoscaling "hpa" deleted from default namespace

NetworkPolicy: who may talk to whom

By default every pod can reach every other pod in the cluster, in any namespace. A NetworkPolicy limits that. It selects pods and lists what may come in (ingress) and go out (egress). Two facts first. A policy only works if the network plug-in enforces it (k3s does, with an embedded kube-router controller, as the next test shows). And as soon as any policy selects a pod for a direction, everything else in that direction is denied for it.

$ cat np-demo.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: shopping
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shopping
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shopping
spec:
  selector:
    app: web
  ports:
  - port: 80
---
apiVersion: v1
kind: Pod
metadata:
  name: client
  namespace: shopping
  labels:
    role: client
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
---
apiVersion: v1
kind: Pod
metadata:
  name: stranger
  namespace: shopping
spec:
  containers:
  - name: main
    image: busybox:1.36
    command: ["sleep", "3600"]
$ kubectl apply -f np-demo.yaml
namespace/shopping created
deployment.apps/web created
service/web created
pod/client created
pod/stranger created
$ kubectl -n shopping exec client -- wget -qO- -T 3 http://web | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ kubectl -n shopping exec stranger -- wget -qO- -T 3 http://web | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>

Both pods can reach the web server. First deny everything coming into the namespace, which is the usual starting point:

$ cat np-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: shopping
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
$ kubectl apply -f np-deny.yaml
networkpolicy.networking.k8s.io/default-deny-ingress created
$ kubectl -n shopping exec client -- wget -qO- -T 3 http://web 2>&1 | head -1
wget: can't connect to remote host (10.43.64.74): Connection refused
$ kubectl -n shopping exec stranger -- wget -qO- -T 3 http://web 2>&1 | head -1
wget: can't connect to remote host (10.43.64.74): Connection refused

podSelector: {} selects every pod in the namespace, and the policy lists no allowed sources, so nothing may reach them. Then allow only what is needed: web accepts traffic on port 80 from pods labelled role=client.

$ cat np-allow.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-client-to-web
  namespace: shopping
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes: ["Ingress"]
  ingress:
  - from:
    - podSelector:
        matchLabels:
          role: client
    ports:
    - protocol: TCP
      port: 80
$ kubectl apply -f np-allow.yaml
networkpolicy.networking.k8s.io/allow-client-to-web created
$ kubectl -n shopping exec client -- wget -qO- -T 3 http://web | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ kubectl -n shopping exec stranger -- wget -qO- -T 3 http://web 2>&1 | head -1
wget: can't connect to remote host (10.43.64.74): Connection refused
$ kubectl -n shopping get networkpolicy
NAME                   POD-SELECTOR   AGE
allow-client-to-web    app=web        8s
default-deny-ingress   <none>         17s

The client works again and the stranger is still blocked. Policies add up: a connection is allowed if any policy allows it. Remember these points. A denied connection fails with an error from wget (the first line printed above), and which error depends on the plug-in. Egress is not limited unless you write an egress policy, and one that denies egress must also allow DNS (UDP and TCP 53 to the cluster DNS), or names stop resolving. namespaceSelector allows whole namespaces. And a policy cannot match by host name or by anything above TCP, UDP and SCTP, so it is a coarse tool: for finer control, use a service mesh or a plug-in with its own policy language (Cilium, Calico), not run in the lab.

$ kubectl delete namespace shopping
namespace "shopping" deleted

RBAC, service accounts, quotas and namespaces

Who may do what in the cluster is decided by RBAC (role-based access control). Four objects make it: a Role (a list of allowed verbs on resources, in one namespace), a ClusterRole (the same, cluster-wide), and the bindings that attach them to a user, group or service account (RoleBinding and ClusterRoleBinding). Nothing is allowed unless a role says so, and there is no "deny".

A service account is the identity a pod runs as, and what a program inside the cluster uses to call the API. Make a namespace, a service account that may only read pods in it, and test it with can-i:

$ cat rbac.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: team-a
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: reader
  namespace: team-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: team-a
rules:
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: reader-reads-pods
  namespace: team-a
subjects:
- kind: ServiceAccount
  name: reader
  namespace: team-a
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io
$ kubectl apply -f rbac.yaml
namespace/team-a created
serviceaccount/reader created
role.rbac.authorization.k8s.io/pod-reader created
rolebinding.rbac.authorization.k8s.io/reader-reads-pods created
$ kubectl auth can-i list pods -n team-a --as=system:serviceaccount:team-a:reader
yes
$ kubectl auth can-i delete pods -n team-a --as=system:serviceaccount:team-a:reader
no
(exit status 1)
$ kubectl auth can-i list pods -n default --as=system:serviceaccount:team-a:reader
no
(exit status 1)
$ kubectl auth can-i list secrets -n team-a --as=system:serviceaccount:team-a:reader
no
(exit status 1)
$ kubectl auth can-i --list -n team-a --as=system:serviceaccount:team-a:reader | head -6
Resources                                       Non-Resource URLs                      Resource Names   Verbs
selfsubjectreviews.authentication.k8s.io        []                                     []               [create]
selfsubjectaccessreviews.authorization.k8s.io   []                                     []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                     []               [create]
pods/log                                        []                                     []               [get list watch]
pods                                            []                                     []               [get list watch]

The account may read pods in team-a and nothing else, not even the same pods in another namespace, and not Secrets. auth can-i, with --as, is how you test a role before you hand it out (it prints yes or no, and its exit status is 1 for no, which the listing above shows). Use the identity for real, with a short-lived token:

$ kubectl run sample -n team-a --image=nginx:alpine
pod/sample created
$ TOKEN=$(kubectl create token reader -n team-a --duration=10m); curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:6443/api/v1/namespaces/team-a/pods | jq -r '.items[].metadata.name'
sample
$ TOKEN=$(kubectl create token reader -n team-a --duration=10m); curl -sk -H "Authorization: Bearer $TOKEN" https://127.0.0.1:6443/api/v1/namespaces/default/pods | jq -r '.reason, .message'
Forbidden
pods is forbidden: User "system:serviceaccount:team-a:reader" cannot list resource "pods" in API group "" in the namespace "default"

The first request worked and the second was refused with Forbidden, and the message names the account, the verb and the namespace. This is how to read an RBAC denial: "User X cannot VERB RESOURCE in the namespace Y" says exactly which rule to add. Kubernetes never makes a token that lives for ever by itself anymore: kubectl create token gives one that expires, and a pod's own token is refreshed and bound to that pod. Rules of thumb:

give each application its own service account, and only the verbs it needs (never use the default account, and never bind cluster-admin to an application)
prefer namespaced Roles to ClusterRoles, and groups to individual users in bindings
the verbs list, get on Secrets and create on pods/exec are as powerful as they sound: whoever has them can read credentials or run code
audit regularly: kubectl auth can-i --list --as=..., or tools such as kubectl-who-can and rbac-lookup (not run)

Users (people) are not objects in Kubernetes. They come from the client certificate in the kubeconfig or from an external identity provider (OIDC), and the lab used the certificate that k3s made for its admin. Setting up OIDC login was not run.

A namespace is also where quotas live. A ResourceQuota caps what a namespace may consume in total, and a LimitRange gives containers defaults and limits so that nothing runs without requests:

$ cat quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: compute
  namespace: team-a
spec:
  hard:
    pods: "3"
    requests.cpu: "1"
    requests.memory: 1Gi
---
apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: team-a
spec:
  limits:
  - type: Container
    defaultRequest:
      cpu: 100m
      memory: 64Mi
    default:
      cpu: 200m
      memory: 128Mi
$ kubectl apply -f quota.yaml
resourcequota/compute created
limitrange/defaults created
$ kubectl delete pod sample -n team-a
pod "sample" deleted from team-a namespace
$ kubectl create deployment filler -n team-a --image=nginx:alpine --replicas=5
deployment.apps/filler created
$ kubectl get deployment filler -n team-a
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
filler   3/5     3            3           15s
$ kubectl describe quota compute -n team-a | tail -5
Resource         Used   Hard
--------         ----   ----
pods             3      3
requests.cpu     300m   1
requests.memory  192Mi  1Gi
$ kubectl get events -n team-a --field-selector reason=FailedCreate | tail -2 | cut -c1-180
15s         Warning   FailedCreate   replicaset/filler-65bcb6646   Error creating: pods "filler-65bcb6646-pzjgb" is forbidden: exceeded quota: compute, requested: pods=1, used: pod
14s         Warning   FailedCreate   replicaset/filler-65bcb6646   (combined from similar events): Error creating: pods "filler-65bcb6646-5knjl" is forbidden: exceeded quota: compu
$ kubectl get pod -n team-a -o jsonpath='{.items[0].spec.containers[0].resources}{"\n"}'
{"limits":{"cpu":"200m","memory":"128Mi"},"requests":{"cpu":"100m","memory":"64Mi"}}

The quota let three pods run and refused the other two, and the Deployment's events say "exceeded quota". The LimitRange put its default requests and limits on the pods that had none (the last line), which also means they count against the cpu and memory quota. A namespace is a boundary for names, quotas and permissions. It is not a security boundary for the network (policies do that) or for the nodes (pods of every namespace share them), so tenants that do not trust each other need more than namespaces.

$ kubectl delete namespace team-a
namespace "team-a" deleted

Operating the cluster: etcd backups and restore

Everything the cluster knows is in etcd: every object, every Secret, every ConfigMap, the state of every Deployment. The pods and their volumes are not in it, but without etcd the cluster does not know they exist. So the backup that matters is a snapshot of etcd, taken regularly and kept off the machine.

k3s takes snapshots by itself on a schedule (by default every 12 hours, keeping 5) and can take one on demand. It can only do that when it runs the embedded etcd database, which is why the lab server was started with --cluster-init. The default on a single server is SQLite, which is backed up by copying its file. Make something worth saving, take a snapshot, and then "lose" it:

$ kubectl create namespace precious
namespace/precious created
$ kubectl create deployment keepme --image=nginx:alpine -n precious
deployment.apps/keepme created
$ kubectl create configmap important -n precious --from-literal=answer=42
configmap/important created
$ k3s etcd-snapshot save --name before-disaster 2>&1 | grep -E 'saved'     # run as root on the server node
time="2026-10-19T21:44:12Z" level=info msg="Snapshot before-disaster-k8s-1792446253 saved."
$ k3s etcd-snapshot ls 2>&1 | tail -2 | cut -c1-150     # run as root on the server node
Name                           Location                                                                       Size     Created
before-disaster-k8s-1792446253 file:///var/lib/rancher/k3s/server/db/snapshots/before-disaster-k8s-1792446253 14245920 2026-10-19T21:44:13Z
$ ls -l /var/lib/rancher/k3s/server/db/snapshots/     # run as root on the server node
total 13916
-rw------- 1 root root 14245920 Oct 19 21:44 before-disaster-k8s-1792446253

A snapshot is one file. Copy it to another machine (the same machine's disk does not survive the same disaster) and test restoring it regularly: a backup that has never been restored is a hope. k3s can send snapshots to S3 storage with the --etcd-s3 flags, which was not run in the lab. Now the disaster, someone deletes the namespace:

$ kubectl delete namespace precious
namespace "precious" deleted
$ kubectl get ns | grep precious || echo "namespace precious is gone"
namespace precious is gone

To restore, stop k3s on the server, run it once with the cluster-reset flags that point at the snapshot (it restores, resets the etcd membership and exits), then start it normally:

$ pkill -x k3s-server; sleep 6; pgrep -x k3s-server || echo "k3s server stopped"     # run as root on the server node
k3s server stopped
$ k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/$(ls /var/lib/rancher/k3s/server/db/snapshots | head -1) --node-name k8s 2>&1 | grep -E 'Managed etcd cluster membership has been reset' | cut -c1-120     # run as root on the server node
time="2026-10-19T21:44:37Z" level=error msg="Shutdown request received: \"Managed etcd cluster membership has been reset
$ kubectl get ns | grep precious
precious          Active   35s
$ kubectl get deployment,configmap -n precious
NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/keepme   0/1     1            0           35s

NAME                         DATA   AGE
configmap/important          1      35s
configmap/kube-root-ca.crt   1      35s
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   16m   v1.35.9+k3s1
k8w1   Ready    <none>               16m   v1.35.9+k3s1

The namespace, the Deployment and the ConfigMap are back, with everything else that was in the cluster at the time of the snapshot, and what was created after it is gone. While the server was down the worker kept running its pods: the data plane carries on without the control plane, you just cannot change anything. With several servers, the others must be stopped, the restore is done on one, and the rest are removed from etcd and rejoin (see the k3s documentation for the exact steps, not run in the lab). In other distributions the same job is etcdctl snapshot save and restore, or the vendor's tool. In a managed cloud cluster the provider backs up etcd and you do not get to, so back up your manifests (in git) and your volumes instead.

What a snapshot does not hold: the contents of volumes, the state of anything outside the cluster (DNS records, load balancers, cloud disks), and the node-level files such as the server token and certificates under /var/lib/rancher/k3s/server/tls (copy those too).

$ kubectl delete namespace precious
namespace "precious" deleted

Operating the cluster: certificates, nodes and the container runtime

Kubernetes uses TLS certificates between its own parts and for the admin login, and they expire. This is the classic outage of an unattended cluster: after a year, kubectl and the nodes suddenly say "x509: certificate has expired". Look at the dates:

$ k3s certificate check --output table 2>&1 | grep -E 'FILENAME|client-admin|serving-kube-apiserver|server-ca' | cut -c1-150     # run as root on the server node
FILENAME                          SUBJECT                            USAGES                  EXPIRES                  RESIDUAL TIME   STATUS
kube-controller-manager.crt       k3s-server-ca@1792445280           CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
kube-scheduler.crt                k3s-server-ca@1792445280           CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
serving-kube-apiserver.crt        kube-apiserver                     ServerAuth              Oct 19, 2027 20:28 UTC   1 year          OK
serving-kube-apiserver.crt        k3s-server-ca@1792445280           CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
client-admin.crt                  system:admin                       ClientAuth              Oct 19, 2027 20:28 UTC   1 year          OK
client-admin.crt                  k3s-client-ca@1792445280           CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
client.crt                        etcd-server-ca@1792445280          CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
server-client.crt                 etcd-server-ca@1792445280          CertSign                Oct 16, 2036 20:28 UTC   10 years        OK
serving-kubelet.crt               k3s-server-ca@1792445280           CertSign                Oct 16, 2036 20:28 UTC   10 years        OK

The client and serving certificates last one year, and the certificate authorities ten years. k3s renews certificates on its own at start-up when they have less than 90 days left (from its documentation, not run in the lab), so a server that is restarted for upgrades at least once a year never runs into this. A server that is never restarted can. Put the expiry in your monitoring: k3s certificate check is one command, and the Prometheus metric apiserver_client_certificate_expiration_seconds (kube-apiserver) is another. For a kubeadm cluster the command is kubeadm certs check-expiration (not run).

Adding a worker was the join command from the second section: the same k3s agent command with the server's address and token. To take a worker out for good, do it in order. Drain it, delete the node object, and uninstall k3s on the machine:

$ kubectl drain k8w1 --ignore-daemonsets --delete-emptydir-data 2>&1 | tail -1
node/k8w1 drained
$ kubectl delete node k8w1
node "k8w1" deleted
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   17m   v1.35.9+k3s1

The node object is gone from the list, and it did not come back within 40 seconds, although the k3s agent was still running on the worker machine. Deleting the object only removes the record: the machine and its agent are untouched, and the pods it was running were already moved by the drain. To retire a machine for good, also stop and remove k3s on it, using the uninstall script that the installer makes (k3s-agent-uninstall.sh, not available in the lab because the installer was not used). To bring a machine back, restart its agent. A node that joins again is a new object: it is not cordoned and has none of the labels you added. The join command is the one from the second section, with the server's address and the token that sits on the server in /var/lib/rancher/k3s/server/node-token (a credential, so treat it like a password):

$ k3s agent --server https://k8s:6443 --token TOKEN --node-name k8w1
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   18m   v1.35.9+k3s1
k8w1   Ready    <none>               4s    v1.35.9+k3s1

On a node, kubectl is not the only tool. The container runtime can be asked directly with crictl, which shows the containers that the kubelet started, even when the API server is down. That is how you look at a node whose control plane is gone:

$ k3s crictl ps | head -4 | cut -c1-120     # run as root on the worker node
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT
0d1210026de4b       eaa1212a2b456       2 seconds ago       Running             lb-tcp-443          0
e11c0ff5d6198       eaa1212a2b456       2 seconds ago       Running             lb-tcp-80           0
$ k3s crictl images | head -4     # run as root on the worker node
IMAGE                                        TAG                 IMAGE ID            SIZE
docker.io/hashicorp/http-echo                1.0                 04fa556e62bdd       4.63MB
docker.io/library/busybox                    1.36                b116e15507444       2.22MB
docker.io/library/nginx                      1.27-alpine         6769dc3a703c7       21MB

The kubelet's own log is the first place to look when a node is NotReady, and so is the machine itself: disk space, memory, the clock and the network. In a normal install they are in journalctl -u k3s and journalctl -u k3s-agent (the lab wrote them to /var/log/k3s.log):

$ grep -c . /var/log/k3s.log     # run as root on the server node
1063
$ grep -E 'level=(error|fatal)' /var/log/k3s.log | tail -2 | cut -c1-150     # run as root on the server node
time="2026-10-19T21:44:39Z" level=error msg="Sending HTTP/1.1 503 response to 127.0.0.1:33592: runtime core not ready"
time="2026-10-19T21:44:40Z" level=error msg="Sending HTTP/1.1 503 response to 127.0.0.1:33650: runtime core not ready"

The two error lines are from the moment of the restore in the previous section, when the server was starting and the API answered 503 for a few seconds. They show the kind of line to look for.

Upgrading the cluster

A Kubernetes release comes every few months, and each minor version (1.35, 1.36) is supported for about a year. Staying current is part of running it. The rules that keep an upgrade safe:

one minor version at a time (1.35 to 1.36, never 1.34 to 1.36), and patch releases freely
control plane first, then the nodes. The control plane may be one minor version newer than the kubelets (and recent releases allow up to three), but never older
kubectl may differ from the server by one minor version either way
read the release notes for removed APIs before you start: an API that was deprecated can disappear in a later version, and manifests that use it stop working
back up first: an etcd snapshot (the section above), and your manifests are in git
drain a node, upgrade it, uncordon it, one at a time, so that the workloads keep running

Take the snapshot, and look at where the cluster is:

$ k3s etcd-snapshot save --name before-upgrade 2>&1 | grep -E 'saved'     # run as root on the server node
time="2026-10-19T21:46:12Z" level=info msg="Snapshot before-upgrade-k8s-1792446372 saved."
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   18m   v1.35.9+k3s1
k8w1   Ready    <none>               5s    v1.35.9+k3s1
$ kubectl version | grep -E 'Client|Server'
Client Version: v1.35.9+k3s1
Server Version: v1.35.9+k3s1

To find manifests that use an API that is going away, ask the API server which deprecated APIs have been called since it started (the counts are from this server's own metrics):

$ kubectl get --raw /metrics | grep '^apiserver_requested_deprecated_apis' | head -3
apiserver_requested_deprecated_apis{group="",removed_release="",resource="endpoints",subresource="",version="v1"} 1

(The one line says that something called the v1 Endpoints API, which is deprecated in favour of EndpointSlices, once since the server started, and removed_release is empty because no removal is planned yet. k3s's own components make such calls. No lines at all, or lines with a removed_release you are about to reach, are what to act on. Tools such as kubent and pluto scan your manifests and Helm releases for the same thing, and were not run.) With k3s, the installer script upgrades by running again with a new version, which replaces the binary and restarts the service:

$ curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.36.5+k3s1 sh -

The lab has no systemd, so it did the same thing by hand: replaced the binary on the server (k3s v1.36.5+k3s1 is the stable release in the k3s release channel at the time of the test), and restarted the process. First the server:

$ pkill -x k3s-server; sleep 6; mv -f /usr/local/bin/k3s.new /usr/local/bin/k3s; k3s --version | head -1     # run as root on the server node
k3s version v1.36.5+k3s1 (3dd98cc5)
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   18m   v1.36.5+k3s1
k8w1   Ready    <none>               24s   v1.35.9+k3s1

The control plane is on 1.36.5 and the worker is still on 1.35.9, which is allowed and the right order. The pods on the worker kept running during the server's restart. Now the worker: drain it, replace its binary, restart it, and let it take work again:

$ kubectl drain k8w1 --ignore-daemonsets --delete-emptydir-data 2>&1 | tail -1
node/k8w1 drained
$ pkill -x k3s-agent; sleep 6; mv -f /usr/local/bin/k3s.new /usr/local/bin/k3s; k3s --version | head -1     # run as root on the worker node
k3s version v1.36.5+k3s1 (3dd98cc5)
$ kubectl uncordon k8w1
node/k8w1 uncordoned
$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   18m   v1.36.5+k3s1
k8w1   Ready    <none>               37s   v1.36.5+k3s1
$ kubectl get pods -A | grep -vE 'Running|Completed|^NAMESPACE' || echo "all pods Running or Completed"
all pods Running or Completed

Both nodes are on v1.36.5 and the system pods are healthy. A drain before an upgrade also tests your PodDisruptionBudgets and your replica counts: an application with one replica goes down during the drain, and no setting changes that. In a managed cloud cluster the provider upgrades the control plane with one click or command, and you upgrade the node pools in the same order. With kubeadm the steps are kubeadm upgrade plan and apply on the first server, kubeadm upgrade node on the others, then the kubelet package on every node (not run in the lab). Rolling back a Kubernetes upgrade is not supported in general: the safe plan is a snapshot restore onto the old version, so test the upgrade on a copy of the cluster first.

Kustomize and Helm: packaging manifests

Plain YAML repeats itself: the same Deployment for dev, test and production, with a different image tag, replica count and name. Two tools deal with that. Kustomize patches plain YAML without templates, and is built into kubectl. Helm packages YAML as templated charts with values, and keeps a numbered history of installs. The site has separate cheat sheets for both (Kustomize, Helm); this is how they fit into running a cluster.

Kustomize. A base holds the plain manifests. An overlay says how one environment differs:

$ cat kz/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:1.27-alpine
$ cat kz/base/kustomization.yaml
resources:
- deployment.yaml
$ cat kz/prod/kustomization.yaml
resources:
- ../base
namePrefix: prod-
namespace: production
replicas:
- name: web
  count: 3
images:
- name: nginx
  newTag: 1.28-alpine
labels:
- pairs:
    env: production
  includeSelectors: true
$ kubectl kustomize kz/prod | grep -E 'name:|namespace:|replicas:|image:|env:'
    env: production
  name: prod-web
  namespace: production
  replicas: 3
      env: production
        env: production
      - image: nginx:1.28-alpine
        name: nginx
$ kubectl create namespace production
namespace/production created
$ kubectl apply -k kz/prod
deployment.apps/prod-web created
$ kubectl get deploy -n production --show-labels
NAME       READY   UP-TO-DATE   AVAILABLE   AGE   LABELS
prod-web   3/3     3            3           4s    env=production
$ kubectl delete -k kz/prod
deployment.apps "prod-web" deleted from production namespace

kubectl kustomize only prints the result, so you can read exactly what an overlay will do. Apply it with -k. The base was not changed, and a "dev" overlay beside "prod" would give a second variant. Kustomize cannot loop or compute, and that limit is also its good point: what you read is what you get.

Helm. A chart is a folder of templates and a values.yaml with the defaults, and an install of a chart is a release with its own name and history. Helm is a separate program. The lab downloaded the release tarball of Helm 4.3.1 from get.helm.sh:

$ curl -sfL https://get.helm.sh/helm-v4.3.1-linux-amd64.tar.gz | tar -xz -C /tmp && install -m 755 /tmp/linux-amd64/helm /usr/local/bin/helm && helm version --short     # run as root on the server node
v4.3.1+gc0d08d0
$ helm create mychart > /dev/null && ls mychart mychart/templates | head -20
mychart:
Chart.yaml
charts
templates
values.yaml

mychart/templates:
NOTES.txt
_helpers.tpl
deployment.yaml
hpa.yaml
httproute.yaml
ingress.yaml
service.yaml
serviceaccount.yaml
tests
$ helm template demo ./mychart --set image.tag=1.28-alpine | grep -E '^kind:|image:' | head -8
kind: ServiceAccount
kind: Service
kind: Deployment
          image: "nginx:1.28-alpine"
kind: Pod
      image: busybox

helm template renders the templates to YAML without touching the cluster, and is the same idea as kubectl kustomize: look before you leap. Install it, change a value, and look at the history:

$ helm install demo ./mychart --set image.tag=1.28-alpine --wait --timeout 120s | head -4
NAME: demo
LAST DEPLOYED: Mon Oct 19 21:46:58 2026
NAMESPACE: default
STATUS: deployed
$ helm list
NAME	NAMESPACE	REVISION	UPDATED                                	STATUS  	CHART        	APP VERSION
demo	default  	1       	2026-10-19 21:46:58.093794033 +0000 UTC	deployed	mychart-0.1.0	1.16.0
$ helm upgrade demo ./mychart --set image.tag=1.28-alpine --set replicaCount=2 --wait --timeout 120s | head -2
Release "demo" has been upgraded. Happy Helming!
NAME: demo
$ helm history demo
REVISION	UPDATED                 	STATUS    	CHART        	APP VERSION	DESCRIPTION
1       	Mon Oct 19 21:46:58 2026	superseded	mychart-0.1.0	1.16.0     	Install complete
2       	Mon Oct 19 21:47:00 2026	deployed  	mychart-0.1.0	1.16.0     	Upgrade complete
$ kubectl get deploy demo-mychart
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
demo-mychart   2/2     2            2           4s

The release is at revision 2 with two replicas. Roll it back to revision 1, and remove it:

$ helm rollback demo 1 --wait --timeout 120s
Rollback was a success! Happy Helming!
$ helm history demo | tail -2
2       	Mon Oct 19 21:47:00 2026	superseded	mychart-0.1.0	1.16.0     	Upgrade complete
3       	Mon Oct 19 21:47:02 2026	deployed  	mychart-0.1.0	1.16.0     	Rollback to 1
$ kubectl get deploy demo-mychart
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
demo-mychart   1/1     1            1           5s
$ helm uninstall demo
release "demo" uninstalled

Helm keeps each revision as a Secret in the release's namespace (look with kubectl get secrets), which is why rollback works and why deleting those Secrets loses the history. Charts from public repositories save a great deal of work for common software (ingress controllers, databases, monitoring), and they also bring in templates that you did not write. Pin the chart version, read the values, run helm template and diff it before you install, and never install a chart from a source you have not looked at into a cluster that matters. Public chart repositories were not used in the lab. Helm and Kustomize are often combined (Helm renders, Kustomize patches), and GitOps tools such as Argo CD and Flux apply either from git, none of which were run.

Troubleshooting by symptom

Nearly every problem shows up in the same few places. Work from the outside in: what is the object's status, what do its events say, what do the logs say, and what is the node doing. These are the symptoms met in this guide, with what the real output looked like:

STATUS / SYMPTOM                 WHAT IT MEANS                                      FIRST COMMANDS
Pending                          not scheduled: no room, an unmet taint, label      kubectl describe pod (the Events: FailedScheduling), kubectl describe node
                                 or unbound volume claim
ContainerCreating (stuck)        image pull or volume mount in progress or failing  kubectl describe pod, kubectl get events
ImagePullBackOff, ErrImagePull   wrong image name or tag, or no access              kubectl describe pod, check the name and any imagePullSecrets
CrashLoopBackOff                 the container starts and exits, again and again    kubectl logs POD --previous, kubectl describe pod (exit code)
OOMKilled (exit code 137)        the memory limit was exceeded                      kubectl describe pod, raise the limit or fix the leak
Running but 0/1 Ready            the readiness probe fails                          kubectl describe pod, kubectl logs, curl the probe path
Service answers nothing          the selector matches no pod, or no pod is ready    kubectl get endpointslices, compare labels with the selector
Ingress gives 404                no rule matches the host or path                   kubectl describe ingress, curl with the right Host header
Terminating (stuck)              a finalizer or a node that is gone                 kubectl get pod -o yaml (finalizers), the node's status
NotReady node                    the kubelet cannot reach the API or the runtime    kubectl describe node, the kubelet log, disk, memory, time
Forbidden                        RBAC does not allow it                             kubectl auth can-i VERB RESOURCE --as=..., read the message
rollout stuck                    the new pods never become ready                    kubectl rollout status, kubectl describe, then rollout undo

Look at the events of the whole namespace, newest last, which often names the problem outright, and at the previous container's log when a container keeps restarting:

$ kubectl run crash --image=busybox:1.36 --restart=Always -- sh -c 'echo starting; echo "fatal: config file missing" >&2; exit 3'
pod/crash created
$ kubectl get pod crash
NAME    READY   STATUS   RESTARTS      AGE
crash   0/1     Error    2 (24s ago)   25s
$ kubectl logs crash
starting
fatal: config file missing
$ kubectl get pod crash -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
3
$ kubectl get events --sort-by=.lastTimestamp | grep crash | tail -3 | cut -c1-150
14s         Normal    Created                          pod/crash                                                      Container created
14s         Normal    Pulled                           pod/crash                                                      Container image "busybox:1.36" a
13s         Warning   BackOff                          pod/crash                                                      Back-off restarting failed conta
$ kubectl delete pod crash --wait=false
pod "crash" deleted from default namespace

The log of the crashed attempt (add --previous to read the one before the latest restart) and its exit code (3) are what the program itself said, and they are the first place to look. The kubectl Day-to-Day page on this site goes through each of these failures in more depth, with the commands to find the cause. A pod you cannot read the cause of is often one that cannot be scheduled (no log exists yet), so describe comes before logs when the pod never started.

Two last checks that save time. Is the node really healthy, and is the system itself running:

$ kubectl get nodes
NAME   STATUS   ROLES                AGE   VERSION
k8s    Ready    control-plane,etcd   19m   v1.36.5+k3s1
k8w1   Ready    <none>               83s   v1.36.5+k3s1
$ kubectl get pods -n kube-system
NAME                                     READY   STATUS    RESTARTS   AGE
coredns-b644fd95b-kl9h2                  1/1     Running   0          2m14s
local-path-provisioner-d9d8769b6-pzcd2   1/1     Running   0          2m14s
metrics-server-7f4b6d9bd7-vtcqp          1/1     Running   0          2m14s
svclb-traefik-5192b2cf-dlvkh             2/2     Running   0          19m
svclb-traefik-5192b2cf-mthvx             2/2     Running   0          82s
traefik-5869c87968-mmqzf                 1/1     Running   0          2m14s
$ kubectl get --raw='/readyz?verbose' | tail -4
[+]poststarthook/apiservice-openapi-controller ok
[+]poststarthook/apiservice-openapiv3-controller ok
[+]shutdown ok
readyz check passed

The API server's /readyz endpoint lists its own checks, and the last line says ok when all pass. If kubectl itself cannot connect, the problem is below Kubernetes: the API server process, its certificate, the network or the clock, and the place to look is the server's own log.

Safe habits

What a team that runs Kubernetes for a year ends up doing, and what each habit guards against.

keep every manifest in git, and apply from git         the file is the truth; hand edits are lost or forgotten
pin image tags (and digests for important ones)        :latest changes under you and cannot be rolled back
set requests on every container, limits on memory      the scheduler and the OOM killer have something to work from
add readiness probes to everything that takes traffic  traffic goes only to pods that can serve it
run at least two replicas of anything that matters     a drain or a node loss does not take the service down
add a PodDisruptionBudget to those                     voluntary disruptions cannot remove every replica
spread replicas over nodes (topology spread)           one node failure is not an outage
use one namespace per team or app, with quotas         one bad deployment cannot eat the cluster
one service account per application, least privilege   a leaked token is a small problem
default-deny NetworkPolicy, then allow what is needed  a compromised pod cannot reach everything
do not run as root; drop capabilities                  see the security context of the pod (not run in the lab)
never put Secrets in git; encrypt them at rest         base64 is not encryption
back up etcd and the volumes, and test a restore       the only backup is one that has been restored
take snapshots before an upgrade, upgrade one minor    a failed upgrade cannot be undone any other way
watch certificate expiry and node disk space           the two quiet causes of most outages
read kubectl diff and --dry-run=server before apply    the cluster answers what would change

The list of kubectl commands that bite: kubectl delete with a label selector and no namespace, kubectl delete -f on a file that also holds a Namespace, kubectl apply --force (it deletes and recreates), kubectl drain on a node that holds the only replica, kubectl edit in production (it changes the live object and leaves git behind), and any command typed against the wrong context. Show the context and namespace in your shell prompt, and keep separate kubeconfig files for production.

What was not run in the lab

The lab was a two-node k3s cluster (v1.35.9, then v1.36.5) in two containers on one machine. Everything in the sections above ran there. These did not:

multi-node control planes, high availability, and a restore on a multi-server cluster; separate machines, real networks and real node failures (a node that disappears, a network split)
cloud-managed clusters (EKS, AKS, GKE), their load balancers, disks, identity integration and node pools; kubeadm clusters; kind and minikube
cloud storage classes, ReadWriteMany volumes (NFS, CephFS), volume snapshots, volume expansion, and the CSI drivers behind them; moving a volume between nodes
HTTPS for the Ingress (cert-manager, Let's Encrypt), the Gateway API, other ingress controllers (nginx, HAProxy), and real DNS names
other network plug-ins (Calico, Cilium, Flannel in the real world) and their policies, egress policies, a service mesh
custom metrics for the HPA (KEDA, the Prometheus adapter), the vertical autoscaler, and node autoscaling (Cluster Autoscaler, Karpenter)
OIDC login, admission controllers (Pod Security Admission, Kyverno, OPA Gatekeeper), securityContext and seccomp settings, and encryption of Secrets at rest
operators and custom resources (CRDs) beyond the ones k3s installs, GitOps tools (Argo CD, Flux), and public Helm repositories
monitoring and logging stacks (Prometheus, Grafana and Loki, covered on other pages of this site, were not connected to this cluster)
upgrade paths other than k3s v1.35.9 to v1.36.5, the removal of deprecated APIs, kubeadm and managed upgrades, and the k3s system-upgrade-controller
Windows nodes, GPUs, ARM machines, and Kubernetes versions other than the two above; Ubuntu versions other than 24.04
the details of the old kubectl cheat sheet that this guide replaces are in the next section, and the kubectl command line itself is on the kubectl Day-to-Day page

The cheat sheet

Context and cluster
kubectl config get-contexts                   list contexts (the * is the current one)
kubectl config use-context NAME               switch cluster
kubectl config set-context --current --namespace=NS     change the default namespace
kubectl cluster-info                          where the API server is
kubectl version                               client and server versions
kubectl api-resources                         every kind this cluster knows (add --namespaced=false for cluster-wide ones)
kubectl explain pod.spec.containers           the schema of any field

Look at things
kubectl get nodes|pods|svc|deploy|ing|pvc -A       one kind, every namespace
kubectl get pods -o wide                      with node and IP
kubectl get pods -l app=web --show-labels     filter by label
kubectl get pod NAME -o yaml                  the whole object
kubectl get pod NAME -o jsonpath='{.status.podIP}'    one field
kubectl describe pod NAME                     status, conditions and Events: the first place to look
kubectl get events --sort-by=.lastTimestamp   what just happened
kubectl top nodes|pods                        real use (needs metrics-server)
kubectl logs POD [-c CONTAINER] [-f] [--previous] [--tail=50]
kubectl exec -it POD -- sh                    a shell in the container
kubectl port-forward svc/NAME 8080:80         reach a service from your machine
kubectl cp POD:/path ./local                  copy files

Change things
kubectl apply -f FILE|DIR|URL                 create or update (the normal way)
kubectl apply -k DIR                          a kustomization
kubectl diff -f FILE                          what would change
kubectl apply -f FILE --dry-run=server        validate on the server
kubectl create deployment web --image=nginx:alpine --replicas=2
kubectl scale deployment web --replicas=5
kubectl set image deployment/web nginx=nginx:1.28-alpine
kubectl rollout status|history|undo|restart deployment web
kubectl label|annotate pod NAME key=value
kubectl patch deployment web -p '{"spec":{"replicas":3}}'
kubectl delete -f FILE | kubectl delete pod NAME [--grace-period=0 --force]
kubectl autoscale deployment web --cpu=50% --min=2 --max=10
kubectl create job NAME --from=cronjob/CRON   run a CronJob now

Nodes and maintenance
kubectl cordon NODE        no new pods         kubectl uncordon NODE    allow them again
kubectl drain NODE --ignore-daemonsets --delete-emptydir-data     move the pods off
kubectl taint node NODE key=value:NoSchedule  (add a trailing - to remove)
kubectl label node NODE key=value
kubectl describe node NODE                    capacity, allocated, conditions, taints

Access
kubectl auth can-i VERB RESOURCE [-n NS] [--as=USER]
kubectl create token SERVICEACCOUNT [-n NS] --duration=10m
kubectl create secret generic NAME --from-literal=k=v
kubectl create configmap NAME --from-file=FILE

k3s
k3s etcd-snapshot save --name NAME            snapshot (embedded etcd only)
k3s server --cluster-reset --cluster-reset-restore-path=FILE     restore
k3s certificate check --output table          certificate expiry
k3s crictl ps                                 containers on this node
k3s secrets-encrypt status                    encryption of Secrets at rest

Object types in one line each
Pod  one or more containers   Deployment  N replicas and rolling updates   StatefulSet  named pods with their own disks
DaemonSet  one pod per node   Job / CronJob  run to completion / on a schedule   Service  stable name and address
Ingress  HTTP routing in   ConfigMap / Secret  settings   PVC / PV / StorageClass  storage   Namespace  a folder with quotas
NetworkPolicy  who may talk to whom   Role / RoleBinding  who may do what   HPA  autoscaling   PDB  limits on voluntary disruptions

Common problems and tips

A pod is Pending and nothing says why. Run kubectl describe pod and read the Events at the bottom. It is nearly always FailedScheduling (not enough room, a taint, a node selector) or a claim that cannot bind.

Changing a ConfigMap did nothing. Environment variables only change when the pod restarts (kubectl rollout restart), and files take about a minute to refresh and are not refreshed at all with subPath. Many applications do not re-read their files either.

A Service has no endpoints. The selector does not match the pods' labels (compare kubectl get svc -o yaml with kubectl get pods --show-labels), or the pods are not ready.

An Ingress returns 404. There is no rule for that host or path, or there is no ingress controller, or the name in the browser is not the one in the rule.

A rollout hangs. The new pods never become ready (image, probe, resources). The old ones keep serving while maxUnavailable is 0, so look at the new ones with kubectl describe, and kubectl rollout undo when in doubt.

A node is NotReady. Look at kubectl describe node (the conditions), then on the node itself: the kubelet and runtime logs, the disk, the memory and the clock.

The cluster stops accepting commands after a year. The certificates expired. Check k3s certificate check (or kubeadm certs check-expiration) well before, and restart the server in time.

The disk of a node fills up. Container logs, unused images and local volumes. Kubernetes cleans up images and dead containers by itself under pressure, but a full disk evicts pods first.

Deleting a namespace hangs in Terminating. Something in it has a finalizer that never finishes (often a custom resource whose controller is gone). Find it with kubectl api-resources --verbs=list --namespaced -o name and look at what is left.

Tips:
  Work in a namespace of your own while learning, and delete it afterwards: kubectl delete namespace NAME removes everything in it.
  Turn on shell completion (source <(kubectl completion bash)) and the short alias k, and use kubectl explain instead of searching.
  Prefer declarative: kubectl apply a file, even for a quick test, so that you can see and repeat what you did.
  kubectl get ... -o yaml, then remove the status and the generated fields, is a fair way to start a manifest. kubectl create ... --dry-run=client -o yaml is better.
  Read events and logs before restarting anything. A restart that "fixes" it also destroys the evidence.
  Keep clusters small and boring: few namespaces, few add-ons, regular upgrades. Most of the pain comes from clusters that nobody dared to touch for two years.