Several commands that older kubectl cheat sheets still teach now fail or warn on a current cluster. The --generator flag on kubectl run and --export on kubectl get are gone, --record and --cpu-percent print deprecation notices, and kubectl get endpoints tells you to use EndpointSlices instead. So we rebuilt this kubectl cheat sheet on a live cluster and kept only the commands that work today, with the output they printed.
It covers the kubectl commands you will use daily, from context checks, pods and rollouts to RBAC, node maintenance, troubleshooting and JSONPath, plus the imperative shortcuts that save minutes in the CKA. The quick-reference tables at the top double as a one-page PDF. For this update we rebuilt the lab from scratch as a two-node kubeadm cluster on Rocky Linux 10.2 with containerd and Calico, and every command ran in page order against Kubernetes v1.37.1 in October 2026.
Kubectl cheat sheet at a glance
These are the commands we run most, grouped by job, and the PDF puts 45 of them on one printable page. Each one comes back later on the page with its real output and the flags that change how it behaves, so treat these tables as an index rather than the whole story. Names such as web, webapp, api and worker01 are the objects and node the walkthrough creates, so swap in your own.
Cluster and context
| Command | What it does |
|---|---|
kubectl version | Client, Kustomize and server versions |
kubectl cluster-info | API server and CoreDNS endpoints |
kubectl get nodes -o wide | Nodes with IP, kernel and runtime |
kubectl config get-contexts | Every context in your kubeconfig |
kubectl config set-context --current --namespace=cheatsheet | Change the default namespace |
kubectl auth whoami | User and groups the API server sees |
kubectl api-resources --namespaced=false | Cluster-scoped resource types |
Pods
| Command | What it does |
|---|---|
kubectl run web --image=nginx:stable --port=80 --labels=app=web | Start a single pod |
kubectl get pods -o wide | Pods with node and IP |
kubectl get pods -l app=web | Filter pods by label |
kubectl describe pod web | Spec, state and events |
kubectl logs web --tail=5 | Last lines of a pod’s log |
kubectl exec -it web -- bash | Shell inside the container |
kubectl port-forward pod/web 8080:80 | Reach a pod from localhost |
kubectl cp web:/etc/nginx/nginx.conf ./nginx.conf | Copy a file out of a pod |
kubectl delete pod stuck --grace-period=0 --force | Force-delete a stuck pod |
Generate and validate YAML
| Command | What it does |
|---|---|
kubectl run web2 --image=nginx:stable --port=80 --dry-run=client -o yaml > web2-pod.yaml | Pod manifest skeleton |
kubectl create deployment webapp --image=nginx:stable --replicas=3 --port=80 --dry-run=client -o yaml > webapp.yaml | Deployment manifest skeleton |
kubectl explain deployment.spec.strategy | Field docs from the API server |
kubectl apply -f webapp.yaml --dry-run=server | Validate against the API schema |
kubectl diff -f webapp.yaml | Preview what apply would change |
kubectl apply -k kust/ --dry-run=server | Check a Kustomize overlay |
Deployments and rollouts
| Command | What it does |
|---|---|
kubectl create deployment api --image=nginx:stable --replicas=3 | Create a deployment |
kubectl scale deployment api --replicas=5 | Change the replica count |
kubectl set image deployment/api nginx=nginx:mainline | Roll out a new image |
kubectl rollout status deployment/api | Wait for a rollout to finish |
kubectl rollout history deployment/api | List revisions |
kubectl rollout undo deployment/api | Back to the previous revision |
kubectl rollout restart deployment/api | Recreate pods without a spec change |
kubectl set env deployment/webapp LOG_LEVEL=debug | Set an environment variable |
kubectl autoscale deployment api --min=3 --max=6 --cpu=70% | Add a CPU-based HPA |
Services, config and storage
| Command | What it does |
|---|---|
kubectl expose deployment webapp --port=80 --target-port=80 --name=webapp-svc | ClusterIP Service for a deployment |
kubectl expose deployment webapp --type=NodePort --port=80 --target-port=80 --name=webapp-np | NodePort Service |
kubectl get endpointslices -l kubernetes.io/service-name=webapp-svc | Pod IPs behind a Service |
kubectl create configmap nginx-tuning --from-file=tuning.conf | ConfigMap from a file |
kubectl create secret tls web-tls --cert=tls.crt --key=tls.key | TLS Secret from a key pair |
kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d | Decode one Secret key |
kubectl get pv,pvc | Volumes and claims with status |
RBAC and access
| Command | What it does |
|---|---|
kubectl create serviceaccount app-reader -n rbac-demo | Create a service account |
kubectl create role pod-reader --verb=get,list,watch --resource=pods,pods/log -n rbac-demo | Namespaced role |
kubectl create rolebinding app-reader-pods --role=pod-reader --serviceaccount=rbac-demo:app-reader -n rbac-demo | Bind the role to the account |
kubectl auth can-i list pods -n rbac-demo --as=system:serviceaccount:rbac-demo:app-reader | Test one permission |
kubectl auth can-i --list -n rbac-demo --as=system:serviceaccount:rbac-demo:app-reader | Every permission it holds |
kubectl create token app-reader -n rbac-demo --duration=10m | Short-lived token for the account |
Nodes
| Command | What it does |
|---|---|
kubectl get pods -A -o wide --field-selector spec.nodeName=worker01 | Every pod on one node |
kubectl label node worker01 disktype=ssd | Label a node |
kubectl cordon worker01 | Stop new pods landing on it |
kubectl drain worker01 --ignore-daemonsets --delete-emptydir-data | Evict pods before maintenance |
kubectl uncordon worker01 | Make it schedulable again |
kubectl taint nodes worker01 dedicated=batch:NoSchedule | Repel pods without a toleration |
kubectl taint nodes worker01 dedicated=batch:NoSchedule- | Remove that taint |
Troubleshooting and output
| Command | What it does |
|---|---|
kubectl logs crashy --previous | Log of the container that crashed |
kubectl events --for pod/taint-test | Events for one object |
kubectl events --types=Warning | Only warning events |
kubectl get pods -A | grep -vE 'Running|Completed' | Pods that are not healthy |
kubectl get pods -A --sort-by=.metadata.creationTimestamp | Oldest pods first |
kubectl get pods -o jsonpath='{.items[*].metadata.name}' | Names only, on one line |
Imperative shortcuts for the CKA
| Command | What it does |
|---|---|
export do="--dry-run=client -o yaml" | YAML generator shortcut |
kubectl run sleeper --image=busybox:stable --restart=Never $do --command -- sleep 3600 | Pod with a command override |
kubectl create cronjob nightly --image=busybox:stable --schedule='0 2 * * *' -- date | CronJob in one line |
kubectl create job nightly-manual --from=cronjob/nightly | Run a CronJob now |
kubectl create quota cheatsheet-quota --hard=pods=30,services=10,configmaps=20 | Object-count quota |
kubectl create poddisruptionbudget webapp-pdb --selector=app=webapp --min-available=2 | Protect replicas from drains |
kubectl create ingress webapp --class=nginx --rule="webapp.example.com/*=webapp-svc:80" | Ingress without YAML |
kubectl expose pod web --port=80 --name=web-svc | Service for a bare pod |
If you only take one habit from these tables, make it --dry-run=server. A client-side dry run accepted a misspelled field in our lab without complaint, while the API server rejected the same file, and the YAML section below shows both results side by side.
Check which cluster you’re talking to
Every command on this page acts on whatever cluster and namespace your current context points at, so confirm that before you change anything. A kubeconfig with three contexts and a stale default is how a delete meant for staging lands on production. These checks are read-only, so they cost nothing to run, and you need only a few things in place first:
- a running cluster you are allowed to break
- kubectl within one minor version of the API server
- a kubeconfig in
~/.kube/configor$KUBECONFIG
If you do not have a practice cluster yet, our kubeadm guide for Rocky Linux and AlmaLinux builds the same control plane and worker layout that produced every output here. A throwaway cluster matters because the drain, taint and force-delete commands further down are disruptive by design, and you want to learn what they do somewhere that nobody depends on. Start the walkthrough with KUBERC=off, which tells kubectl to ignore any kuberc preferences file you may already have, so your aliases and default flags cannot change what these commands print. Also set NODE to one of your worker node names (kubectl get nodes lists them), because the node commands later on use it. Our lab had a single worker, so on a cluster with more workers the drain and taint steps behave differently: evicted pods move to another worker instead of going Pending, and the taint test pod schedules elsewhere, so its wait times out. Then check the versions.
export KUBERC=off
export NODE=worker01
kubectl version
The client and server versions match. The client also reports a Kustomize version, because kubectl bundles Kustomize, so kubectl apply -k works without a separate install:
Client Version: v1.37.1
Kustomize Version: v5.8.1
Server Version: v1.37.1
kubectl is supported within one minor release of the API server in either direction, so a client one release older or newer is fine. Anything further apart is outside the version skew policy, and that bites most when you manage an old cluster and a new one from the same laptop with one binary. Next, confirm the API endpoint and the nodes behind it. The wide output is the fastest way to spot a node that missed an upgrade, since the kubelet version, OS image, kernel and container runtime all sit on one line per node.
kubectl cluster-info
kubectl get nodes -o wide
Both nodes are Ready. They also run the same kubelet, kernel and containerd build, which is what you want to see:
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
cp01 Ready control-plane 27m v1.37.1 10.0.1.10 <none> Rocky Linux 10.2 (Red Quartz) 6.12.0-211.68.1.el10_2.x86_64 (amd64) containerd://2.4.2
worker01 Ready <none> 27m v1.37.1 10.0.1.11 <none> Rocky Linux 10.2 (Red Quartz) 6.12.0-211.68.1.el10_2.x86_64 (amd64) containerd://2.4.2
The worker shows <none> under ROLES because kubeadm does not put a role label on workers, and that’s cosmetic. What is not cosmetic is a mismatch in the VERSION or CONTAINER-RUNTIME columns, so read them after every upgrade. Then check which context is active and who the API server thinks you are. The auth whoami subcommand is the useful one, because it prints the user and the groups that RBAC actually evaluates:
kubectl config get-contexts
kubectl config current-context
kubectl auth whoami
On a kubeadm cluster, admin.conf authenticates as kubernetes-admin. Its group is one that kubeadm binds to the cluster-admin ClusterRole:
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
That’s full control of everything, and the kubeadm docs warn against sharing admin.conf with anyone. So treat it as a break-glass credential, and use the RBAC section below to hand out narrower ones. When you forget a resource’s short name, its API group or whether it is namespaced, ask the cluster instead of guessing. The full list also includes every CRD installed in the cluster, which makes it the quickest way to see what an operator added, and on ours Calico resources made up a good part of it.
kubectl api-resources --api-group=apps
kubectl api-resources --namespaced=false
kubectl get --raw='/readyz?verbose' | tail -6
You get the short forms from the SHORTNAMES column, such as deploy and sts. NAMESPACED tells you whether -n means anything for that type:
NAME SHORTNAMES APIVERSION NAMESPACED KIND
controllerrevisions apps/v1 true ControllerRevision
daemonsets ds apps/v1 true DaemonSet
deployments deploy apps/v1 true Deployment
replicasets rs apps/v1 true ReplicaSet
statefulsets sts apps/v1 true StatefulSet
The last command replaces the deprecated componentstatuses resource for control plane health. It lists each internal check of the API server, and on a healthy server the final line reads readyz check passed. For scripts, the Kubernetes docs recommend relying on the HTTP status code rather than the text, which is 200 when the server is ready, because the verbose wording can change between releases.
Set up completion, aliases and kuberc
Tab completion is the biggest speed-up kubectl offers, because it completes resource types, object names and namespaces straight from the API server, so you stop typing pod hashes by hand. It depends on the bash-completion package, which minimal Rocky Linux installs do not ship. On our control plane node, calling the completion function without that package returned no completions and printed this error instead:
/dev/fd/63: line 29: _get_comp_words_by_ref: command not found
Install the package, then add three lines to ~/.bashrc. The first loads completion, the second creates the k alias, and the third registers the completion function for the alias too, so k completes exactly like the full command. On Debian and Ubuntu the package has the same name, so swap dnf for apt.
sudo dnf install -y bash-completion
echo 'source <(kubectl completion bash)' >> ~/.bashrc
echo 'alias k=kubectl' >> ~/.bashrc
echo 'complete -o default -F __start_kubectl k' >> ~/.bashrc
source ~/.bashrc
alias k
complete -p k
The last two commands confirmed both the alias and its completion were registered. We then called the completion function the way Tab does for k get pods -n kube-sys, and it returned kube-system, the same as for the full command. If Tab still does nothing, you’re almost certainly in a shell that started before the package was installed, so open a fresh one rather than debugging the completion script.
Keep kubectl preferences in kuberc
Shell aliases only exist in the shell that defined them, so they do not follow you into scripts or onto a teammate’s terminal. kuberc is kubectl’s own preferences file, read from ~/.kube/kuberc by default. It holds command aliases and default flags, it never holds cluster credentials, and current kubectl releases turn it on by default. Because this walkthrough runs with KUBERC=off, the demo below uses a separate file and points kubectl at it per command, so nothing here changes the commands later on the page. Create the file with one default that makes every delete ask first:
vim ~/kuberc-demo.yaml
Add the preferences:
apiVersion: kubectl.config.k8s.io/v1beta1
kind: Preference
defaults:
- command: delete
options:
- name: interactive
default: "true"
You do not have to write every rule by hand, because kubectl kuberc set edits an existing file. Prefixing a command with KUBERC= and a path overrides the exported off for that one command. Add a getw alias that defaults to wide output, then view the result:
KUBERC=$HOME/kuberc-demo.yaml kubectl kuberc set --section aliases --name getw --command get --option output=wide
KUBERC=$HOME/kuberc-demo.yaml kubectl kuberc view
kubectl merged the alias into the file next to the delete default:
aliases:
- command: get
name: getw
options:
- default: wide
name: output
apiVersion: kubectl.config.k8s.io/v1beta1
defaults:
- command: delete
options:
- default: "true"
name: interactive
kind: Preference
The confirmation prompt is the reason we would set kuberc up at all. Interactive deletes turn a fat-fingered name into a question instead of an outage, while scripts can still pass --interactive=false explicitly. To see it without risking anything, create a throwaway namespace and ConfigMap and answer n:
KUBERC=$HOME/kuberc-demo.yaml kubectl getw nodes
kubectl create namespace kuberc-demo
kubectl create configmap demo -n kuberc-demo --from-literal=a=b
echo n | KUBERC=$HOME/kuberc-demo.yaml kubectl delete configmap demo -n kuberc-demo
kubectl delete namespace kuberc-demo
The delete listed what it was about to remove and cancelled on n. You’d type y to go ahead:
You are about to delete the following 1 resource(s):
configmap/demo
Do you want to continue? (y/N): deletion is cancelled
Two details tripped us up. The kubectl kuberc set command refuses to create a file at a --kuberc path that does not exist yet, and it errors on an empty file with expected pointer, but got invalid kind. Other commands, such as kubectl version, run fine with that empty file. So start from a file with at least the apiVersion and kind lines, or let kubectl create the default ~/.kube/kuberc. And KUBERC=off cannot be combined with the --kuberc flag, since kubectl rejects the pair as mutually exclusive, so the demo sets the variable per command instead. We would keep the delete rule on workstations and leave CI runners without a kuberc at all.
Create, inspect and debug pods
Pods are where most kubectl time goes, and you will mostly read them rather than create them, because Deployments create pods for you. Bare pods from kubectl run are still the fastest way to test an image, a DNS name or a network path, so learn their flags well. We kept every lab object in its own namespace and made it the context default, which means cleanup at the end is mostly a single delete.
kubectl create namespace cheatsheet
kubectl config set-context --current --namespace=cheatsheet
kubectl run web --image=nginx:stable --port=80 --labels=app=web
kubectl run debug-pod --image=busybox:stable --restart=Never -- sleep 3600
kubectl wait --for=condition=Ready pod/web pod/debug-pod --timeout=120s
Everything after -- becomes the container’s arguments, which is how the busybox pod stays alive for an hour. The wait subcommand returns as soon as both pods report Ready, so it beats re-running get pods in a loop, and it is what you want in scripts. Listing pods then has a handful of variations worth having in muscle memory. The -o wide flag adds the pod IP and node, and -l filters on labels with equality or set-based selectors. Add -w to keep the command open and print a line whenever a pod changes state, and press Ctrl+C to stop it.
kubectl get pods
kubectl get pods -o wide
kubectl get pods --show-labels
kubectl get pods -l app=web
kubectl get pods -l 'app in (web,api)' -o name
kubectl get pods -w
Both pods landed on worker01. Their addresses come from Calico’s pod range:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
debug-pod 1/1 Running 0 16s 10.244.5.3 worker01 <none> <none>
web 1/1 Running 0 16s 10.244.5.2 worker01 <none> <none>
Nothing landed on cp01, because kubeadm taints the control plane node against ordinary workloads. The labels column from --show-labels explains why a selector sometimes matches nothing. A pod made with kubectl run gets the label run=<name> unless you pass --labels, so web carried app=web while debug-pod carried run=debug-pod, and a Service written for one would never find the other. When a pod isn’t Running, describe is the first stop, because the Events at the bottom record each step the scheduler and the kubelet took.
kubectl describe pod web
A healthy start reads Scheduled, Pulling, Pulled, Created and Started. Anything that stops short of Started tells you where to look:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 24s default-scheduler Successfully assigned cheatsheet/web to worker01
Normal Pulling 24s kubelet spec.containers{web}: Pulling image "nginx:stable"
Normal Pulled 14s kubelet spec.containers{web}: Successfully pulled image "nginx:stable" in 9.531s (9.532s including waiting). Image size: 63609494 bytes.
Normal Created 14s kubelet spec.containers{web}: Container created
Normal Started 14s kubelet spec.containers{web}: Container started
The worker was freshly built, so it had to download nginx, and the Pulled line reports that it took 9.5 seconds for the 63 MB image. Once an image is cached, the same line just says it is already present on the machine. So if a pod sits in ContainerCreating for minutes, a slow registry is the first thing you should rule out. Logs come straight from the container’s stdout and stderr, and we lean on three forms: the last few lines, timestamps for lining logs up with events, and -f to follow the stream until you press Ctrl+C.
kubectl logs web --tail=5
kubectl logs web --timestamps --since=10m
kubectl logs -f web
kubectl exec web -- nginx -v
kubectl exec -it web -- bash
kubectl exec -it debug-pod -- bash
The -f stream runs until you press Ctrl+C, and the interactive bash session ends when you type exit, so run those two one at a time. To run something inside a container, put the command after --, and add -it when you want an interactive shell (our scripted replay gave these commands a pseudo-terminal). The nginx image ships bash and dropped us into a root prompt, but the busybox pod doesn’t have it, and asking for it there failed with an OCI runtime error:
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "144de796d99c750dfd9bc168fbb8c525f573d5c93c4095d2fc9c6765038110c4": OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH
The part to read is executable file not found in $PATH. Minimal images such as busybox and Alpine only have sh, so use kubectl exec -it debug-pod -- sh there. Images with no shell at all need an ephemeral debug container instead, which the troubleshooting section covers. Port forwarding is the other way into a pod: it tunnels a local port through the API server, so you can test a pod before any Service exists. It blocks the terminal it runs in, so either use a second terminal or background it with &, give it a few seconds to start listening, test, and kill the job.
kubectl port-forward pod/web 8080:80 &
sleep 3
curl -sI http://127.0.0.1:8080
kill %1
The sleep 3 matters, because curl -s stays silent if it runs before the forward is listening. Port-forward reported the tunnel on both loopback addresses and logged each connection into your terminal ahead of the curl result:
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80
Handling connection for 8080
HTTP/1.1 200 OK
Server: nginx/1.30.6
Date: Mon, 26 Oct 2026 07:51:08 GMT
The forward listens on the loopback addresses only, so it is reachable from your machine and nothing else on the network. The cp subcommand moves files in both directions by running tar inside the container, which means it only works on images that include tar. Copying out of the pod printed tar: Removing leading `/' from member names, and that’s tar being careful with absolute paths rather than an error.
printf 'server_tokens off;\n' > app.conf
kubectl cp app.conf web:/etc/nginx/conf.d/app.conf
kubectl cp web:/etc/nginx/nginx.conf ./nginx.conf
kubectl delete pod debug-pod
kubectl run stuck --image=busybox:stable --restart=Never -- sleep 3600
kubectl delete pod stuck --grace-period=0 --force
A normal delete lets the pod shut down within its grace period. Deleting debug-pod took about 30 seconds, because sleep running as PID 1 ignores SIGTERM, so the kubelet waited out the default 30-second grace period before it killed the container. Force deletion removes the API object immediately without waiting for the kubelet to confirm the container stopped, and kubectl warns about exactly that:
Warning: Immediate deletion does not wait for confirmation that the running resource has been terminated. The resource may continue to run on the cluster indefinitely.
pod "stuck" force deleted from cheatsheet namespace
Reach for --force only when a pod is stuck in Terminating on a node that’s gone. On a healthy node it can leave a container running that no API object tracks anymore, which is harder to clean up than the stuck pod was. Current releases also name the namespace in every delete confirmation, which helps when you switch contexts often.
Generate YAML instead of writing it
Writing manifests from memory is slow and easy to get wrong, so let kubectl produce the skeleton and edit only what you need. The --dry-run=client -o yaml pair builds the object locally without sending it anywhere, and it works for pods, deployments, services, jobs and cronjobs. You get the right apiVersion, kind and selector wiring for free, which are the parts people most often get wrong by hand.
kubectl run web2 --image=nginx:stable --port=80 --dry-run=client -o yaml > web2-pod.yaml
kubectl create deployment webapp --image=nginx:stable --replicas=3 --port=80 --dry-run=client -o yaml > webapp.yaml
kubectl create service clusterip webapp --tcp=80:80 --dry-run=client -o yaml
kubectl create job hello --image=busybox:stable --dry-run=client -o yaml -- echo hello
kubectl create cronjob nightly --image=busybox:stable --schedule='0 2 * * *' --dry-run=client -o yaml -- date
cat webapp.yaml
Nothing needs fixing in the generated deployment manifest. It’s valid as it stands:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: webapp
name: webapp
spec:
replicas: 3
selector:
matchLabels:
app: webapp
strategy: {}
template:
metadata:
labels:
app: webapp
spec:
containers:
- image: nginx:stable
name: nginx
ports:
- containerPort: 80
resources: {}
status: {}
The empty strategy, resources and status stubs are harmless, and you will usually add resource requests and probes before applying it. Note that the container is named nginx after the image rather than after the deployment, which matters later because kubectl set image addresses containers by that name. When you need a field name, kubectl explain reads the schema from the cluster you’re connected to, so it matches the version you actually run instead of whatever a blog post was written against.
kubectl explain deployment.spec.strategy
kubectl explain pod.spec.containers.livenessProbe
kubectl explain deployment.spec --recursive
For the strategy field it listed both types and what each one does, so it answered the question in one command. Add --recursive when you want the whole tree under a field, which is handy for finding where a setting lives when you only remember its name.
Validate with a server-side dry run
A client-side dry run doesn’t check your YAML against the API schema, and we confirmed it the hard way. We changed replicas to the typo replica and ran both kinds of dry run against the same file.
sed 's/replicas: 3/replica: 3/' webapp.yaml > webapp-typo.yaml
kubectl apply -f webapp-typo.yaml --dry-run=client
kubectl apply -f webapp-typo.yaml --dry-run=server
Client-side, kubectl reported success. It treated the broken file as fine:
deployment.apps/webapp created (dry run)
Server-side was different. Strict field validation caught the unknown field and refused it:
Error from server (BadRequest): error when creating "webapp-typo.yaml": Deployment in version "v1" cannot be handled as a Deployment: strict decoding error: unknown field "spec.replica"
So use --dry-run=server before applying anything you edited by hand. It runs validation and admission on the API server without saving the object, and it costs one round trip. The diff subcommand goes one step further and shows what an apply would change on the live object. We applied the manifest, bumped the replicas from 3 to 4 and diffed.
kubectl apply -f webapp.yaml
sed -i 's/replicas: 3/replicas: 4/' webapp.yaml
kubectl diff -f webapp.yaml
The second hunk holds the field we changed. An earlier hunk, trimmed here, bumps the generation counter from 1 to 2 alongside it:
@@ -15,7 +15,7 @@
uid: c110baba-adef-43c6-b8ac-9c695591de71
spec:
progressDeadlineSeconds: 600
- replicas: 3
+ replicas: 4
revisionHistoryLimit: 10
selector:
matchLabels:
The exit code is 1 when there are differences and 0 when there are none, which makes kubectl diff usable as a CI gate before an automated apply. Apply the change and diff again to see the clean case:
kubectl apply -f webapp.yaml
kubectl diff -f webapp.yaml; echo "diff exit code: $?"
The second diff printed nothing but its exit code. That’s the result you want to see before an automated apply:
diff exit code: 0
A CI job can branch on that code: 0 means the cluster already matches the file, 1 means apply would change something, and anything higher means diff itself failed.
Apply overlays with Kustomize
Kustomize is built into kubectl, and it’s the usual way to adjust the same base manifests per environment without copying them. This overlay adds a name prefix and an environment label, and swaps the image tag, while the base file stays untouched. Create a directory for it next to the base manifest and open the overlay file.
mkdir -p kust && cp webapp.yaml kust/
vim kust/kustomization.yaml
Add the following and save the file. The base manifest isn’t touched:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namePrefix: staging-
labels:
- pairs:
env: staging
includeSelectors: false
images:
- name: nginx
newTag: mainline
resources:
- webapp.yaml
Render the overlay to see what it produces. Then dry-run it on the server before you apply it.
kubectl kustomize kust/ | grep -E 'name: |image:|env:|replicas:'
kubectl apply -k kust/ --dry-run=server
Kustomize applied all three changes. You can see the prefixed name, the new label, the new image tag and the replica count from the base:
env: staging
name: staging-webapp
replicas: 4
- image: nginx:mainline
name: nginx
The server dry run accepted it as deployment.apps/staging-webapp created (server dry run). The includeSelectors: false line only spells out the default: the env label lands on the deployment’s own metadata and stays out of its selector and pod template. Add includeTemplates: true if you want the pods labelled too. Drop --dry-run=server to apply the overlay for real.
Update, scale and roll back deployments
Deployments manage ReplicaSets and ReplicaSets manage pods, so every template change creates a new ReplicaSet and a new revision you can return to. That is also why a rollback is fast: the old ReplicaSet still exists at zero replicas, and Kubernetes only has to scale it back up instead of rebuilding anything. Create one and watch it come up.
kubectl create deployment api --image=nginx:stable --replicas=3
kubectl rollout status deployment/api
kubectl get deploy,rs,pods -l app=api
You get one deployment and one ReplicaSet with a hash suffix. All three pods carry the same hash:
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/api 3/3 3 3 2s
NAME DESIRED CURRENT READY AGE
replicaset.apps/api-5cc68f685f 3 3 3 2s
NAME READY STATUS RESTARTS AGE
pod/api-5cc68f685f-cnxnk 1/1 Running 0 2s
pod/api-5cc68f685f-llvt2 1/1 Running 0 2s
pod/api-5cc68f685f-nlcdg 1/1 Running 0 2s
Scaling takes effect immediately, while changing the image triggers a rolling update. Since --record is deprecated, the way to get a useful CHANGE-CAUSE in the history is to set the annotation yourself right after the change, otherwise every revision reads <none> and you’re guessing during an incident.
kubectl scale deployment api --replicas=5
kubectl set image deployment/api nginx=nginx:mainline
kubectl annotate deployment/api kubernetes.io/change-cause="nginx stable to mainline"
kubectl rollout status deployment/api
kubectl rollout history deployment/api
Revision 2 carries the note. Revision 1 shows none, because nothing was annotated at creation:
deployment.apps/api
REVISION CHANGE-CAUSE
1 <none>
2 nginx stable to mainline
Roll back with rollout undo, either one step back or to a numbered revision. Check the history again after an undo, because it changes in a way most people don’t expect, and that surprise is how the wrong revision gets restored at 3 AM.
kubectl rollout undo deployment/api
kubectl rollout status deployment/api
kubectl rollout history deployment/api
kubectl rollout undo deployment/api --to-revision=3
Revision 1 is gone. Its template reappears as revision 3:
deployment.apps/api
REVISION CHANGE-CAUSE
2 nginx stable to mainline
3 <none>
Kubernetes gives the ReplicaSet it scaled back up the next revision number instead of reusing the old one. That renumbering matters for --to-revision, because the last command asked for revision 3, which was already the live template, and kubectl answered with a skip:
deployment.apps/api skipped rollback (current template already matches revision 3)
So list the history right before you roll back to a number, never from memory. Pause a deployment when you want several changes to ship as one rollout instead of two back-to-back ones. While it is paused, changes pile up in the spec but no new ReplicaSet is created, which get rs confirms before and after the resume.
kubectl rollout pause deployment/api
kubectl set image deployment/api nginx=nginx:stable-alpine
kubectl set resources deployment/api -c=nginx --requests=cpu=50m,memory=64Mi --limits=cpu=200m,memory=128Mi
kubectl get rs -l app=api
kubectl rollout resume deployment/api
kubectl rollout status deployment/api
kubectl get rs -l app=api
While paused, the list still held only the two earlier ReplicaSets. After the resume, both changes arrived together in one new ReplicaSet:
NAME DESIRED CURRENT READY AGE
api-5cc68f685f 0 0 0 37s
api-774bfb85f5 0 0 0 35s
api-c76b87566 5 5 5 15s
The rollout restart subcommand replaces every pod through a normal rolling update with no spec change, which is how you pick up a ConfigMap consumed as environment variables. A restart counts as a revision too, so after the pause and the restart the history ran to revision 5. Rolling back to revision 2 from there restored the mainline image:
kubectl rollout restart deployment/api
kubectl rollout status deployment/api
kubectl rollout history deployment/api
kubectl rollout undo deployment/api --to-revision=2
kubectl rollout status deployment/api
kubectl rollout history deployment/api
The template from revision 2 came back as revision 6 and took its change-cause with it, so revision 2 vanished from the list:
deployment.apps/api
REVISION CHANGE-CAUSE
3 <none>
4 <none>
5 <none>
6 nginx stable to mainline
The revisionHistoryLimit field defaults to 10, so only the ten most recent old ReplicaSets stay available to roll back to, and older revisions drop off silently. Logs work through a deployment or a label too, which saves hunting for pod names:
kubectl logs deploy/webapp --tail=2
kubectl logs -l app=webapp --prefix --tail=1
The deploy/webapp form picks one pod and tells you which, so it is useless when only one replica misbehaves. The label form with --prefix reads every replica and tags each line with its source:
[pod/webapp-74cbd6dff7-925xm/nginx] 2026/10/26 07:51:41 [notice] 1#1: start worker process 30
[pod/webapp-74cbd6dff7-m8tqx/nginx] 2026/10/26 07:51:40 [notice] 1#1: start worker process 30
[pod/webapp-74cbd6dff7-pfxrf/nginx] 2026/10/26 07:51:45 [notice] 1#1: start worker process 29
[pod/webapp-74cbd6dff7-vj2kz/nginx] 2026/10/26 07:51:40 [notice] 1#1: start worker process 30
For direct changes, the edit subcommand opens the live object in $KUBE_EDITOR or $EDITOR, and patch changes a field without an editor at all, which suits scripts. When the editor opens, set replicas to 2, then save and quit. Both drift the live object away from webapp.yaml, so the last two commands put it back the declarative way:
kubectl edit deployment webapp
kubectl patch deployment webapp -p '{"spec":{"replicas":3}}'
kubectl patch deployment webapp --type=json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"nginx:mainline"}]'
kubectl rollout status deployment/webapp
kubectl get deployment webapp -o jsonpath='{.spec.replicas}{" "}{.spec.template.spec.containers[0].image}{"\n"}'
kubectl apply -f webapp.yaml
kubectl rollout status deployment/webapp
kubectl get deployment webapp -o jsonpath='{.spec.replicas}{" "}{.spec.template.spec.containers[0].image}{"\n"}'
The first patch merges a JSON fragment into the object, while the JSON patch form addresses one field by path, which is cleaner for list items such as the first container’s image. After the patches the deployment ran 3 replicas of the mainline image, and apply brought it back to what the file says:
4 nginx:stable
Autoscaling takes --cpu now, with a percentage for utilization of the pods’ CPU requests or a quantity such as 500m for an average value. The older --cpu-percent flag still works but prints a deprecation notice.
kubectl autoscale deployment api --min=3 --max=6 --cpu=70%
kubectl get hpa
kubectl describe hpa api
kubectl delete hpa api
On a fresh kubeadm cluster the target reads unknown. Nothing reports CPU usage yet:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
api Deployment/api cpu: <unknown>/70% 3 6 5 0s
Describing the HPA spells it out with unable to fetch metrics from resource metrics API. The HPA object is valid, but it has nothing to scale on until you install metrics-server with Helm, and the same missing piece is behind the kubectl top error in the troubleshooting section. Install it before you rely on autoscaling, since an HPA that can’t read metrics holds the replica count where it is. We deleted the HPA at the end so it would not interfere with the rest of the walkthrough.
Use Services to reach deployments
A Service gives a set of pods one stable virtual IP and DNS name, picked by label selector, so clients never track pod IPs that change on every restart. The expose subcommand copies the selector from the deployment, which avoids the most common Service bug, a selector that matches nothing. Create a ClusterIP Service for in-cluster traffic and a NodePort one for access from outside.
kubectl expose deployment webapp --port=80 --target-port=80 --name=webapp-svc
kubectl expose deployment webapp --type=NodePort --port=80 --target-port=80 --name=webapp-np
kubectl get svc
kubectl get endpointslices -l kubernetes.io/service-name=webapp-svc
kubectl get endpoints webapp-svc
Our NodePort Service listens on port 31131 on every node. Kubernetes picked that port from its NodePort range because we didn’t ask for one:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
webapp-np NodePort 10.102.181.147 <none> 80:31131/TCP 0s
webapp-svc ClusterIP 10.110.211.42 <none> 80/TCP 0s
The EndpointSlice listed four pod IPs behind webapp-svc, one per replica, which is the check to run when a Service seems to send traffic nowhere. Plain kubectl get endpoints still answers, but it now prints a deprecation warning first, so move scripts over to EndpointSlices:
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
To test the Service from inside the cluster, start a throwaway pod that --rm deletes when it exits. You don’t have to clean anything up afterwards.
kubectl run svc-test --image=busybox:stable --rm -i --restart=Never -- sh -c 'sleep 2; wget -qO- -T 5 http://webapp-svc | grep -i title'
kubectl run dns-test --image=busybox:stable --rm -i --restart=Never -- sh -c 'sleep 2; nslookup webapp-svc.cheatsheet.svc.cluster.local'
The sleep 2 is not decoration. Busybox commands finish so fast that kubectl often can’t attach in time, and then it prints warning: couldn't attach to pod and replays the output from the logs, so a short pause gives you clean output:
All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
If you don't see a command prompt, try pressing enter.
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: webapp-svc.cheatsheet.svc.cluster.local
Address: 10.110.211.42
pod "dns-test" deleted from cheatsheet namespace
The name resolved to the ClusterIP from get svc, which confirms that CoreDNS and the Service both work. The first line above the answer is kubectl warning that attached sessions are recorded in the container logs, and the second is its standard attach hint. Use the fully qualified name with busybox nslookup, because a short name makes it walk every search domain in the pod’s resolver config and print an NXDOMAIN line for each miss, even when the lookup succeeded. Don’t pipe a --rm pod into head either, since in our lab that killed kubectl before its cleanup and left a Completed pod behind.
From outside the cluster, a NodePort Service answers on any node address. Read the port and the node’s InternalIP with JSONPath rather than relying on a hostname your machine may not resolve:
NODE_PORT=$(kubectl get svc webapp-np -o jsonpath='{.spec.ports[0].nodePort}')
NODE_IP=$(kubectl get node "${NODE}" -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}')
curl -sI http://${NODE_IP}:${NODE_PORT}
The worker answered with nginx’s headers on the first try, 10 seconds after the Service was created:
HTTP/1.1 200 OK
Server: nginx/1.30.6
Date: Mon, 26 Oct 2026 07:52:46 GMT
In an earlier run we curled the instant the Service was created and got nothing back, with curl exit code 7. The same request about 30 seconds later returned 200, because kube-proxy needs a moment to program a new node port on every node. So retry once before you start debugging firewalls, and drop -s if you want curl to tell you why it failed.
When a Service returns nothing at all, compare its selector with the pod labels before you touch any ports. A Service made with kubectl create service selects app=<service name>, so unless a pod carries exactly that label its EndpointSlice stays empty, and the Service looks healthy while it drops every request.
kubectl create service clusterip broken-svc --tcp=80:80
kubectl get endpointslices -l kubernetes.io/service-name=broken-svc
kubectl get pods -l app=broken-svc
kubectl delete svc broken-svc
Look at the ENDPOINTS column. Unset endpoints, plus “No resources found” for the selector, means traffic has nowhere to go:
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
broken-svc-mb7nj IPv4 <unset> <unset> 0s
Fix the selector or the pod labels and the slice fills in on its own, with no restart needed. When you’re ready to route outside traffic by hostname and path, our guide to moving from Ingress to the Gateway API picks up where Services stop.
Load config from ConfigMaps and Secrets
ConfigMaps and Secrets keep configuration out of the image, so one image can run in every environment with different settings. Create them from literals for small values and from files when the value is a whole config file. Secrets work the same way, but their values are only base64-encoded, not encrypted, so anyone allowed to read Secrets in the namespace can read the password.
kubectl create configmap app-config --from-literal=DATABASE_HOST=db01.example.com --from-literal=DATABASE_PORT=5432 --from-literal=LOG_LEVEL=info
printf 'server_tokens off;\n' > tuning.conf
kubectl create configmap nginx-tuning --from-file=tuning.conf
kubectl create secret generic db-credentials --from-literal=username=appuser --from-literal=password='S3cur3P@ss2026'
kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
kubectl get secret db-credentials -o go-template='{{range $k,$v := .data}}{{$k}}={{$v | base64decode}}{{"\n"}}{{end}}'
A go-template decodes every key in one pass. It printed both in plain text:
password=S3cur3P@ss2026
username=appuser
That’s why get on secrets belongs in RBAC rules you write on purpose, never in a broad read-only role. TLS certificates get their own Secret type, which ingress controllers and Gateway API implementations expect, so create those with the dedicated subcommand.
openssl req -x509 -nodes -newkey rsa:2048 -days 30 -keyout tls.key -out tls.crt -subj '/CN=web.example.com'
kubectl create secret tls web-tls --cert=tls.crt --key=tls.key
vim app-pod.yaml
Pods consume both kinds as environment variables or as files. Paste this manifest into app-pod.yaml. It loads every key from the ConfigMap and the Secret into the environment and mounts the nginx snippet as a single file:
apiVersion: v1
kind: Pod
metadata:
name: app-pod
spec:
containers:
- name: app
image: nginx:stable
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: db-credentials
volumeMounts:
- name: tuning
mountPath: /etc/nginx/conf.d/tuning.conf
subPath: tuning.conf
volumes:
- name: tuning
configMap:
name: nginx-tuning
Create the pod, wait for it, and check what the container sees. You want the keys in the environment and the file at its mount path.
kubectl apply -f app-pod.yaml
kubectl wait --for=condition=Ready pod/app-pod --timeout=120s
kubectl exec app-pod -- env | grep -E 'DATABASE|LOG_LEVEL|username'
kubectl exec app-pod -- cat /etc/nginx/conf.d/tuning.conf
All three ConfigMap keys showed up. So did the username from the Secret:
DATABASE_HOST=db01.example.com
DATABASE_PORT=5432
LOG_LEVEL=info
username=appuser
The cat confirmed the snippet sits where nginx loads extra config from. Updating a ConfigMap doesn’t touch environment variables in running pods, though, and we proved it by changing LOG_LEVEL with a dry run piped into apply, which is the clean way to update an object you first created imperatively.
kubectl create configmap app-config --from-literal=LOG_LEVEL=debug --dry-run=client -o yaml | kubectl apply -f -
kubectl exec app-pod -- printenv LOG_LEVEL
The apply warned about the missing last-applied annotation and patched it in, and it merged rather than replaced, so the two DATABASE keys survived. The running pod still printed the old value:
info
Variables are read once when the container starts, so recreate the pod, or run rollout restart when a deployment owns it:
kubectl delete pod app-pod
kubectl apply -f app-pod.yaml
kubectl wait --for=condition=Ready pod/app-pod --timeout=120s
kubectl exec app-pod -- printenv LOG_LEVEL
The recreated pod picked up the new value:
debug
ConfigMaps mounted as volumes do refresh on their own after a delay, except when mounted with subPath as we did here, which never updates. If you need live reloads, mount the whole ConfigMap as a directory instead of a single file.
Add persistent storage with PVs and PVCs
A PersistentVolumeClaim asks for storage, and either an existing PersistentVolume or a StorageClass provisioner satisfies it. A bare kubeadm cluster has no StorageClass, and kubectl get storageclass on ours returned No resources found. Write a 1 GiB claim anyway, because what happens without a provisioner is worth seeing once.
kubectl get storageclass
vim app-data-pvc.yaml
Add the claim:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
Submit the claim and read its events. They explain more than its status does.
kubectl apply -f app-data-pvc.yaml
kubectl get pvc
kubectl describe pvc app-data
Your claim stays Pending. The persistent volume controller says exactly why:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal FailedBinding 0s persistentvolume-controller no persistent volumes available for this claim and no storage class is set
Clusters with a default StorageClass never show this, because the provisioner creates a PV on demand. Without one, an administrator creates PVs by hand. The hostPath PV below is fine for a lab and wrong for production, since the data lives on whichever node the pod happens to land on. Treat it as a way to learn the binding rules, not as a storage design.
vim cheatsheet-pv.yaml
Add the volume definition. It deliberately starts with the Delete reclaim policy so the next step has something to change:
apiVersion: v1
kind: PersistentVolume
metadata:
name: cheatsheet-pv
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Delete
hostPath:
path: /tmp/cheatsheet-pv
type: DirectoryOrCreate
Apply it and let kubectl wait block until the claim binds. That’s cleaner than polling get pvc by hand.
kubectl apply -f cheatsheet-pv.yaml
kubectl wait --for=jsonpath='{.status.phase}'=Bound pvc/app-data --timeout=90s
kubectl get pv,pvc
Binding happened as soon as a matching PV existed. Each side now names the other:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
persistentvolume/cheatsheet-pv 1Gi RWO Delete Bound cheatsheet/app-data <unset> 10s
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
persistentvolumeclaim/app-data Bound cheatsheet-pv 1Gi RWO <unset> 11s
The reclaim policy decides what happens to the PV when its claim goes away. Delete would remove the volume, while Retain keeps the data and moves the PV to Released, and a Released PV won’t bind to a new claim until you clear its old claim reference. Switch this one to Retain before deleting the claim, then walk the PV back to Available:
kubectl patch pv cheatsheet-pv -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
kubectl delete pvc app-data
kubectl wait --for=jsonpath='{.status.phase}'=Released pv/cheatsheet-pv --timeout=90s
kubectl patch pv cheatsheet-pv --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]'
kubectl wait --for=jsonpath='{.status.phase}'=Available pv/cheatsheet-pv --timeout=90s
kubectl get pv cheatsheet-pv
The PV went back to Available with the Retain policy and no claim:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
cheatsheet-pv 1Gi RWO Retain Available <unset> 10s
Only clear claimRef once you’re sure the old data can go to whoever claims the volume next, because nothing wipes it for you under Retain. That’s the cost of the policy: it protects data from an accidental claim delete, but it also leaves the cleanup to you.
Switch namespaces and contexts
A context bundles a cluster, a user and a default namespace. Setting the namespace on the current context saves typing -n on every command, and it is also the usual reason a resource you just created seems to vanish, because you’re looking in a different namespace. Check the current one whenever that happens.
kubectl get namespaces
kubectl config set-context --current --namespace=cheatsheet
kubectl config view --minify -o jsonpath='{..namespace}'
kubectl get pods -n kube-system
kubectl config set-context ops --cluster=kubernetes --user=kubernetes-admin --namespace=kube-system
kubectl config get-contexts
kubectl config use-context ops
kubectl config use-context kubernetes-admin@kubernetes
kubectl config delete-context ops
For another cluster or another default namespace, create a named context and switch between them. Here both contexts point at the same cluster and user, and only the default namespace differs:
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* kubernetes-admin@kubernetes kubernetes kubernetes-admin cheatsheet
ops kubernetes kubernetes-admin kube-system
The cluster and user names come from your kubeconfig, so read yours with get-contexts before copying these. When you juggle several clusters every day, kubectx and kubens do the same switching with far less typing. Don’t trust kubectl get all for an inventory, though, because it covers pods, Services, deployments, ReplicaSets and a few other workload types, and skips ConfigMaps and Secrets. Compare it with an explicit list:
kubectl get all
kubectl get deploy,svc,cm,secret
Now you see what get all left out. That includes the kube-root-ca.crt ConfigMap that Kubernetes adds to every namespace:
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/api 5/5 5 5 81s
deployment.apps/webapp 4/4 4 4 86s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/webapp-np NodePort 10.102.181.147 <none> 80:31131/TCP 29s
service/webapp-svc ClusterIP 10.110.211.42 <none> 80/TCP 29s
NAME DATA AGE
configmap/app-config 3 18s
configmap/kube-root-ca.crt 1 2m30s
configmap/nginx-tuning 1 18s
NAME TYPE DATA AGE
secret/db-credentials Opaque 2 18s
secret/web-tls kubernetes.io/tls 2 18s
Name the types you care about whenever you audit a namespace, because get all skips ConfigMaps, Secrets, PVCs, Roles and most CRD-defined resources.
Grant access with RBAC
RBAC grants permissions by binding a Role, which covers one namespace, or a ClusterRole, which covers the whole cluster, to a user, group or service account. Requests are denied unless some binding allows them, so start narrow and add verbs only when something fails. This gives a service account read access to pods and their logs in one namespace, which is the shape most monitoring and CI accounts need.
kubectl create namespace rbac-demo
kubectl run reader-test --image=busybox:stable -n rbac-demo -- sleep 3600
kubectl create serviceaccount app-reader -n rbac-demo
kubectl create role pod-reader --verb=get,list,watch --resource=pods,pods/log -n rbac-demo
kubectl create rolebinding app-reader-pods --role=pod-reader --serviceaccount=rbac-demo:app-reader -n rbac-demo
kubectl auth can-i list pods -n rbac-demo --as=system:serviceaccount:rbac-demo:app-reader
kubectl auth can-i delete pods -n rbac-demo --as=system:serviceaccount:rbac-demo:app-reader
kubectl auth can-i list pods -n cheatsheet --as=system:serviceaccount:rbac-demo:app-reader
kubectl auth can-i --list -n rbac-demo --as=system:serviceaccount:rbac-demo:app-reader
The --as flag impersonates the account without needing its token, so you can test a Role before anyone uses it. The three single checks answered yes, no and no: the account can read pods where its Role lives, can’t delete them and cannot see any other namespace. The full list held a surprise, though:
Resources Non-Resource URLs Resource Names Verbs
globalnetworkpolicies.projectcalico.org [] [] [create update patch delete deletecollection]
networkpolicies.projectcalico.org [] [] [create update patch delete deletecollection]
stagedglobalnetworkpolicies.projectcalico.org [] [] [create update patch delete deletecollection]
stagednetworkpolicies.projectcalico.org [] [] [create update patch delete deletecollection]
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
pods/log [] [] [get list watch]
pods [] [] [get list watch]
clustertrustbundles.certificates.k8s.io [] [] [get list watch]
Those four Calico lines say this read-only account may create, update and delete Calico network policies, at least as far as Kubernetes RBAC is concerned. They come from a ClusterRole named calico-tiered-policy-passthrough that the Calico operator binds to the system:authenticated group on purpose. Our lab ran Calico v3.33.0 from operator v1.44.0 in native CRD mode, which became generally available and the default for new installs in 3.33, and in that mode a calico-webhooks admission webhook enforces Calico’s own tier permissions on creates, updates and deletes. On clusters where Calico runs its own API server instead, that server does the tier checks. Either way, the grant alone shouldn’t let the account write anything, so we tested it with a minimal policy:
vim calico-np.yaml
Add a policy that would block all ingress in the namespace. Any valid Calico policy works for this test:
apiVersion: projectcalico.org/v3
kind: NetworkPolicy
metadata:
name: deny-test
namespace: rbac-demo
spec:
selector: all()
types:
- Ingress
Then try to create it as the read-only account. You’re impersonating it with --as again:
kubectl create -f calico-np.yaml --as=system:serviceaccount:rbac-demo:app-reader
Calico’s tiered RBAC webhook refused it, because the account has no rights on the default tier:
Error from server (Forbidden): error when creating "calico-np.yaml": admission webhook "tiered-rbac.api.projectcalico.org" denied the request: Authorization failed: networkpolicies.projectcalico.org "deny-test" is forbidden: User "system:serviceaccount:rbac-demo:app-reader" cannot create networkpolicies.projectcalico.org in tier "default" and namespace "rbac-demo" (user cannot get tier)
So auth can-i --list shows what Kubernetes RBAC grants, not what an account can do once admission webhooks have had their say. The passthrough role also grants deletecollection, which is the verb behind CVE-2026-41187: before Calico 3.31.6 and 3.32.1, bulk deletes skipped the tier check, as the Tigera bulletin TTA-2026-006 describes. If you run an affected release, the bulletin’s advice is to upgrade to 3.31.6, 3.32.1 or later, and not to grant deletecollection or wildcard verbs on tier-scoped policy resources. On a patched Calico, leave the binding alone, since the operator creates it deliberately and restricts access through tier permissions instead. Clusters without Calico won’t show these lines at all, and calico-np.yaml fails there because the projectcalico.org API doesn’t exist.
Cluster-scoped resources such as nodes need a ClusterRole. Here a user named jane gets read access to nodes and nothing else.
kubectl create clusterrole cheatsheet-node-viewer --verb=get,list,watch --resource=nodes
kubectl create clusterrolebinding cheatsheet-node-viewer-jane --clusterrole=cheatsheet-node-viewer --user=jane
kubectl get nodes --as=jane
kubectl get pods -n kube-system --as=jane
Nodes listed fine. The pod request failed with the message you’ll see whenever RBAC refuses something:
Error from server (Forbidden): pods is forbidden: User "jane" cannot list resource "pods" in API group "" in the namespace "kube-system"
It names the user, the verb, the resource and the namespace, which is everything you need to write the missing rule. For a real client such as a CI job, issue a short-lived token with kubectl create token instead of copying a kubeconfig around. The token expires on its own, so a leaked one stops working. To test one, point kubectl at the API server with the cluster CA and the token alone, and an empty kubeconfig so nothing else leaks in:
kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
TOKEN=$(kubectl create token app-reader -n rbac-demo --duration=10m)
kubectl --kubeconfig=/dev/null --server="$SERVER" --certificate-authority=ca.crt --token="$TOKEN" get pods -n rbac-demo
kubectl --kubeconfig=/dev/null --server="$SERVER" --certificate-authority=ca.crt --token="$TOKEN" get pods -n default
kubectl --kubeconfig=/dev/null --server="$SERVER" --certificate-authority=ca.crt --token="$TOKEN" auth whoami
The token listed the reader-test pod in rbac-demo, which was still starting:
NAME READY STATUS RESTARTS AGE
reader-test 0/1 ContainerCreating 0 2s
It got the same Forbidden error in default, and auth whoami identified it as system:serviceaccount:rbac-demo:app-reader. Our guide to Roles, ClusterRoles and ServiceAccounts takes this further for production clusters.
Move pods off a node with cordon, drain and taints
Node maintenance runs in the same order every time, whether you’re patching the OS or upgrading the cluster with kubeadm, and skipping a step is how pods end up stranded:
- cordon the node so nothing new lands there
- drain it to evict the running pods
- patch, upgrade or reboot it
- uncordon it so the scheduler uses it again
Before draining, see what is running on the node. A field selector on spec.nodeName answers that across every namespace, and it is the query to use when someone asks which pods a node is carrying.
kubectl get pods -A -o wide --field-selector spec.nodeName="${NODE}"
The worker carried 12 of our pods plus three DaemonSet pods for calico-node, the Calico CSI driver and kube-proxy, which drain leaves in place when you pass --ignore-daemonsets. It also ran calico-typha, which is a Deployment pod, so drain evicts it like any of ours:

Labels and cordons take a second to apply and to undo. A trailing minus removes a label, and while cordoned, worker01 kept running its pods but showed Ready,SchedulingDisabled and accepted no new ones. Draining is the step that bites, and our first attempt stopped part way.
kubectl label node "${NODE}" disktype=ssd
kubectl get nodes -L disktype
kubectl label node "${NODE}" disktype-
kubectl cordon "${NODE}"
kubectl get nodes
kubectl uncordon "${NODE}"
kubectl drain "${NODE}" --ignore-daemonsets --delete-emptydir-data
Three pods had no controller behind them. Drain stopped on those:
node/worker01 cordoned
error: unable to drain node "worker01" due to error: cannot delete Pods that declare no controller (use --force to override): cheatsheet/app-pod, cheatsheet/web, rbac-demo/reader-test, continuing command...
There are pending nodes to be drained:
worker01
cannot delete Pods that declare no controller (use --force to override): cheatsheet/app-pod, cheatsheet/web, rbac-demo/reader-test
Drain refuses to delete bare pods because nothing would recreate them, and it leaves the node cordoned when it gives up, so a failed drain still changes the cluster. Either delete those pods yourself or accept losing them with --force. We forced it.
kubectl drain "${NODE}" --ignore-daemonsets --delete-emptydir-data --force
This time drain worked. It listed the DaemonSet pods it skipped and the bare pods it deleted, then evicted the rest:
node/worker01 already cordoned
Warning: ignoring DaemonSet-managed Pods: calico-system/calico-node-85w5g, calico-system/csi-node-driver-j88tb, kube-system/kube-proxy-hjnqr; deleting Pods that declare no controller: cheatsheet/app-pod, cheatsheet/web, rbac-demo/reader-test
evicting pod cheatsheet/api-774bfb85f5-cbjnd
evicting pod cheatsheet/api-774bfb85f5-rlhd4
evicting pod rbac-demo/reader-test
evicting pod cheatsheet/api-774bfb85f5-kwdcw
evicting pod cheatsheet/api-774bfb85f5-p87tj
evicting pod calico-system/calico-typha-777b94cf8-b7kkc
evicting pod cheatsheet/api-774bfb85f5-7xnmz
evicting pod cheatsheet/webapp-74cbd6dff7-98sn5
evicting pod cheatsheet/app-pod
evicting pod cheatsheet/web
evicting pod cheatsheet/webapp-74cbd6dff7-psvxw
evicting pod cheatsheet/webapp-74cbd6dff7-kcm4j
evicting pod cheatsheet/webapp-74cbd6dff7-znvxf
pod/api-774bfb85f5-p87tj evicted
pod/api-774bfb85f5-rlhd4 evicted
pod/webapp-74cbd6dff7-znvxf evicted
pod/api-774bfb85f5-cbjnd evicted
pod/webapp-74cbd6dff7-psvxw evicted
pod/api-774bfb85f5-kwdcw evicted
pod/webapp-74cbd6dff7-98sn5 evicted
pod/app-pod evicted
pod/web evicted
pod/api-774bfb85f5-7xnmz evicted
pod/webapp-74cbd6dff7-kcm4j evicted
pod/calico-typha-777b94cf8-b7kkc evicted
pod/reader-test evicted
node/worker01 drained
Every deployment pod then sat in Pending. The control plane node is tainted NoSchedule and the only worker was cordoned, so the scheduler had nowhere to put them. Draining your last schedulable node is an outage, not maintenance, so check capacity on the other nodes before you start.
kubectl get pods -o wide
kubectl uncordon "${NODE}"
kubectl wait --for=condition=Available deployment/webapp deployment/api --timeout=180s
All nine replicas were Pending. None had a node until we uncordoned the worker:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
api-774bfb85f5-4m2lm 0/1 Pending 0 31s <none> <none> <none> <none>
api-774bfb85f5-5bmwp 0/1 Pending 0 31s <none> <none> <none> <none>
api-774bfb85f5-6jm5r 0/1 Pending 0 31s <none> <none> <none> <none>
api-774bfb85f5-9jltk 0/1 Pending 0 31s <none> <none> <none> <none>
api-774bfb85f5-s2dds 0/1 Pending 0 30s <none> <none> <none> <none>
webapp-74cbd6dff7-bm2vc 0/1 Pending 0 31s <none> <none> <none> <none>
webapp-74cbd6dff7-f7pn5 0/1 Pending 0 30s <none> <none> <none> <none>
webapp-74cbd6dff7-vwlt9 0/1 Pending 0 31s <none> <none> <none> <none>
webapp-74cbd6dff7-x588m 0/1 Pending 0 30s <none> <none> <none> <none>
The kubectl wait line returned five seconds after the uncordon, once both deployments were Available again on worker01. Taints work the other way round: the node repels pods unless they tolerate the taint, which is how you reserve nodes for particular workloads. We tainted the worker, started a pod without a toleration, and waited for the scheduler to give up on it.
kubectl taint nodes "${NODE}" dedicated=batch:NoSchedule
kubectl run taint-test --image=busybox:stable -- sleep 600
kubectl wait --for=condition=PodScheduled=false pod/taint-test --timeout=60s
kubectl events --for pod/taint-test
Scheduling failed on both nodes. The scheduler counted the control plane taint and ours, and left the pod Pending:
LAST SEEN TYPE REASON OBJECT MESSAGE
0s Warning FailedScheduling Pod/taint-test 0/2 nodes are available: 2 node(s) had untolerated taint(s). preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
Remove a taint by repeating it with a trailing minus, and the pending pod schedules straight away:
kubectl taint nodes "${NODE}" dedicated=batch:NoSchedule-
kubectl wait --for=condition=Ready pod/taint-test --timeout=120s
kubectl delete pod taint-test
When you reserve nodes this way, pair the toleration with node affinity in those workloads, since a toleration lets a pod onto a tainted node but doesn’t send it there.
Find out why a pod is failing
Most broken pods tell you what is wrong in their status, their events or the logs of the container that crashed. Which one to read first depends on the status, so match it before running anything:
- ImagePullBackOff, the Events in
kubectl describe pod - CrashLoopBackOff,
kubectl logs --previous - Pending,
kubectl events --for pod/<name> - OOMKilled, the container’s last termination reason
Each subsection below is a failure we triggered on purpose in the lab, with the output it produced and the fix, because a real error message is easier to recognise than a description of one. Every demo pod is deleted when its subsection ends, so the lists further down only show what is still running.
ImagePullBackOff after a typo in the image tag
We created a deployment with the tag misspelled as stabel and waited for the pull to fail, which took 17 seconds.
kubectl create deployment broken --image=nginx:stabel
kubectl wait --for=jsonpath='{.status.containerStatuses[0].state.waiting.reason}'=ImagePullBackOff pod -l app=broken --timeout=120s
kubectl get pods -l app=broken
kubectl describe pod -l app=broken
Kubelet tried, backed off, and reported that the registry had no such tag:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 17s default-scheduler Successfully assigned cheatsheet/broken-664485fd5d-xnk5n to worker01
Warning Failed 14s kubelet spec.containers{nginx}: Failed to pull image "nginx:stabel": rpc error: code = NotFound desc = failed to pull and unpack image "docker.io/library/nginx:stabel": failed to resolve image: docker.io/library/nginx:stabel: not found
Warning Failed 14s kubelet spec.containers{nginx}: Error: ErrImagePull
Normal BackOff 14s kubelet spec.containers{nginx}: Back-off pulling image "nginx:stabel"
Warning Failed 14s kubelet spec.containers{nginx}: Error: ImagePullBackOff
Normal Pulling 0s (x2 over 17s) kubelet spec.containers{nginx}: Pulling image "nginx:stabel"
A not found from the registry means the tag doesn’t exist, while an authorization error points to a missing image pull secret instead, so the wording tells you which fix you need. Fix the image and the deployment rolls forward by itself. The newer kubectl events command reads the same data, sorts by time on its own and filters with --for and --types, and it has replaced most of our get events --sort-by habits.
kubectl set image deployment/broken nginx=nginx:stable
kubectl rollout status deployment/broken
kubectl events --for deployment/broken
kubectl events --types=Warning
kubectl get events --sort-by=.lastTimestamp
kubectl get events --field-selector type=Warning,reason=Failed
kubectl delete deployment broken
One thing to know: --for deployment/broken showed only the deployment’s own scaling events, because the pull errors belong to the pod, not the deployment:
LAST SEEN TYPE REASON OBJECT MESSAGE
18s Normal ScalingReplicaSet Deployment/broken Scaled up replica set broken-664485fd5d from 0 to 1
1s Normal ScalingReplicaSet Deployment/broken Scaled up replica set broken-8ccd78fbd from 0 to 1
0s Normal ScalingReplicaSet Deployment/broken Scaled down replica set broken-664485fd5d from 1 to 0
Point --for at the pod to see the failures, or use --types=Warning to see every warning in the namespace at once.
CrashLoopBackOff and “unable to retrieve container logs”
For a container that keeps crashing, kubectl logs --previous prints the last terminated attempt, which is usually where the error is. It works while the pod waits in CrashLoopBackOff and while a restarted attempt is running, but not for the stretch when STATUS reads Error. That stretch lasted about 10 to 30 seconds in our runs, and it grows as the back-off grows. We ran a container that logs a start line, waits 20 seconds and exits with an error, then waited for RESTARTS to read 1, which took 22 seconds in our run:
kubectl run crashy --image=busybox:stable --restart=Always -- sh -c 'echo "starting attempt at $(date +%T)"; sleep 20; echo "config file /etc/app.conf not found" >&2; exit 1'
kubectl wait --for=jsonpath='{.status.containerStatuses[0].restartCount}'=1 pod/crashy --timeout=120s
kubectl get pod crashy
kubectl logs crashy --previous
With the second attempt running, the flag showed the crashed first attempt with its error:
starting attempt at 07:54:49
config file /etc/app.conf not found
Then wait for that second attempt to exit, read the logs both ways, and delete the pod:
kubectl wait --for=jsonpath='{.status.containerStatuses[0].state.terminated.reason}'=Error pod/crashy --timeout=120s
kubectl get pod crashy
kubectl logs crashy --previous
kubectl logs crashy
kubectl delete pod crashy
With STATUS at Error, --previous failed. You’ll see this exact message whenever you catch a crash at the wrong moment:
unable to retrieve container logs for containerd://aca87e7a1074d48c99b3415fc7c54018ab24ed9483c8390535a8246b9aa67ce5
In that state the current container has terminated, so --previous looks one attempt further back, and the kubelet had already garbage collected that container. Plain kubectl logs showed the attempt that had just died, error included. So if the flag errors out, drop it rather than assuming the logs are lost, and ship anything you need beyond one restart off the node with a log collector.
Field selectors miss CrashLoopBackOff
A popular tip for finding broken pods is --field-selector=status.phase!=Running. It misses crash loops, because a pod in CrashLoopBackOff is still in the Running phase. We started a pod that exits immediately and waited for it to reach its back-off, which took about three seconds:
kubectl run crashloop --image=busybox:stable --restart=Always -- sh -c 'echo "config file /etc/app.conf not found" >&2; exit 1'
kubectl wait --for=jsonpath='{.status.containerStatuses[0].state.waiting.reason}'=CrashLoopBackOff pod/crashloop --timeout=180s
kubectl logs crashloop --previous
kubectl get pods -A --field-selector=status.phase!=Running
kubectl get pods -A | grep -vE 'Running|Completed'
kubectl get pod crashloop -o jsonpath='{.status.phase}{" "}{.status.containerStatuses[0].state.waiting.reason}{"\n"}'
kubectl delete pod crashloop
In the back-off, --previous returned the error line and the field selector said No resources found. Grep caught the pod straight away, because the STATUS column comes from container states rather than the phase:
NAMESPACE NAME READY STATUS RESTARTS AGE
cheatsheet crashloop 0/1 CrashLoopBackOff 1 (2s ago) 4s
The status fields themselves showed the phase as Running with a waiting reason of CrashLoopBackOff. Sorting by restart count, shown in the JSONPath section, catches the same pods from another angle, and it also finds pods that crash rarely enough to look Running every time you check.
OOMKilled
A container that exceeds its memory limit is killed by the kernel. We gave busybox a 32Mi limit, made it buffer 200 MB of zeros, waited for the pod to fail, then read the termination reason from the container status.
kubectl run memhog --image=busybox:stable --restart=Never --overrides='{"spec":{"containers":[{"name":"memhog","image":"busybox:stable","command":["sh","-c","head -c 200m /dev/zero | tail"],"resources":{"limits":{"memory":"32Mi"}}}]}}'
kubectl wait --for=jsonpath='{.status.phase}'=Failed pod/memhog --timeout=60s
kubectl get pod memhog
kubectl get pod memhog -o jsonpath='{.status.containerStatuses[0].state.terminated.reason}'
kubectl delete pod memhog
The pod was dead within seconds. STATUS said why:
NAME READY STATUS RESTARTS AGE
memhog 0/1 OOMKilled 0 3s
For a pod that restarts after an OOM kill, the reason moves to lastState.terminated.reason, so read it there once STATUS has moved on. Then raise the limit or find whatever is growing, since a limit that is too tight and a real leak look identical from outside the container.
Debug a container that has no shell
The debug subcommand adds an ephemeral container to a running pod, which is how you troubleshoot images that ship no shell or tools. With --target, it shares the target container’s process namespace, so you see its processes. The forced drain earlier deleted the bare web pod, so recreate it first. We then passed a short command instead of opening an interactive shell.
kubectl run web --image=nginx:stable --port=80 --labels=app=web
kubectl wait --for=condition=Ready pod/web --timeout=120s
kubectl debug -it web --image=busybox:stable --target=web -- sh -c 'sleep 2; ps; wget -qO- -T 3 127.0.0.1 | grep -i title'
kubectl exec web -- nginx -v
Busybox saw the nginx master and worker processes as if they were its own. It also reached nginx on localhost:
PID USER TIME COMMAND
1 root 0:00 nginx: master process nginx -g daemon off;
29 101 0:00 nginx: worker process
30 101 0:00 nginx: worker process
31 root 0:00 sh -c sleep 2; ps; wget -qO- -T 3 127.0.0.1 | grep -i titl
37 root 0:00 ps
<title>Welcome to nginx!</title>
Leave off everything after -- to get an interactive shell instead. Ephemeral containers cannot be removed once added, and the next kubectl exec into that pod printed Defaulted container "web" out of: web, debugger-vhb4r (ephem) before its output. Recreating the pod is the only way to clear them, so when you would rather experiment on a copy, run the copy form instead, which leaves the original pod untouched:
kubectl debug web --copy-to=web-copy --image=busybox:stable --share-processes -- sleep 120
kubectl wait --for=condition=Ready pod/web-copy --timeout=120s
kubectl get pod web-copy
kubectl delete pod web-copy
The copy came up with both containers ready. You can exec into it, change it or break it, and the original web pod keeps running untouched:
NAME READY STATUS RESTARTS AGE
web-copy 2/2 Running 0 1s
For node-level problems, kubectl debug node/ starts a pod on that node with the host filesystem mounted at /host. The default general profile couldn’t talk to systemd, and systemctl failed with Failed to connect to system scope bus via local transport: Operation not permitted, but the sysadmin profile works. The debug pod gets a random name, so capture it, wait for the command to finish, read the logs and delete the pod:
kubectl debug "node/${NODE}" --profile=sysadmin --image=busybox:stable -- chroot /host sh -c 'uname -r; cat /etc/redhat-release; systemctl is-active kubelet containerd'
POD=$(kubectl get pods -o name | grep node-debugger)
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded $POD --timeout=60s
kubectl logs $POD
kubectl delete $POD
It reported the worker’s kernel, OS release and both services active. You can swap in any host command the same way:
6.12.0-211.68.1.el10_2.x86_64
Rocky Linux release 10.2 (Red Quartz)
active
active
Without -it the debug pod runs the command and exits, and it stays behind afterwards, so the last command deletes it. If the command inside fails, the pod ends up Failed rather than Succeeded and the wait times out after a minute, which is what happened when we tried the general profile, so read the logs anyway. The sysadmin profile runs privileged in the host’s namespaces, so treat it like a root shell on the node and keep it away from anyone who should not have one.
error: Metrics API not available
The kubectl top command needs metrics-server, and kubeadm doesn’t install it. On our cluster both node and pod queries failed straight away.
kubectl top nodes
kubectl top pods
Both returned the same one-line error. Neither printed a hint about metrics-server:
error: Metrics API not available
Installing metrics-server, linked in the deployments section, fixes both kubectl top and the HPA that showed an unknown CPU target, so it is one of the first add-ons we put on any new kubeadm cluster.
Pull fields out with JSONPath and custom columns
JSONPath turns kubectl into a query tool, so scripts get exact fields instead of parsing table output with awk, which breaks the first time a column changes width. Look at an object with -o yaml once to find the path you want, then wrap it in a range to print one line per object.
kubectl get pods -o jsonpath='{.items[*].metadata.name}'
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.podIP}{"\t"}{.spec.nodeName}{"\n"}{end}'
You get one tab-separated line per pod from the range form. That’s ready for cut or a while loop:
api-774bfb85f5-96kms 10.244.5.9 worker01
api-774bfb85f5-kh477 10.244.5.3 worker01
api-774bfb85f5-p4hkm 10.244.5.60 worker01
api-774bfb85f5-sd8mp 10.244.5.8 worker01
api-774bfb85f5-x2vfn 10.244.5.62 worker01
web 10.244.5.20 worker01
webapp-74cbd6dff7-4stzb 10.244.5.11 worker01
webapp-74cbd6dff7-jxlmr 10.244.5.63 worker01
webapp-74cbd6dff7-kt65d 10.244.5.10 worker01
webapp-74cbd6dff7-mwnbh 10.244.5.61 worker01
Filters go inside [?()]. The next three commands pull each node’s internal IP and versions, then count every image running in the cluster, which is a quick audit before a registry change or a CVE sweep.
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}'
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.nodeInfo.kubeletVersion}{"\t"}{.status.nodeInfo.containerRuntimeVersion}{"\n"}{end}'
kubectl get pods -A -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' | tr ' ' '\n' | sort | uniq -c | sort -rn
Three commands, three answers. You get node addresses, matching kubelet and containerd builds, and a full image inventory:

Our cluster ran ten containers on the unified Calico image, all on one tag, and the control plane images on the same release as the kubelet. The nginx count split evenly: five mainline containers from the api rollback and five stable ones from webapp and the web pod. A filter on .spec.nodeName in the same pattern lists only the pods on one node. Custom columns give table output with exactly the fields you pick, which reads better than JSONPath when a person is looking at it.
kubectl get pods -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,STATUS:.status.phase,IP:.status.podIP,RESTARTS:.status.containerStatuses[0].restartCount
Each column header is whatever you put before the field path. A restart count column makes a crash loop easy to spot:
NAME NODE STATUS IP RESTARTS
api-774bfb85f5-96kms worker01 Running 10.244.5.9 0
api-774bfb85f5-kh477 worker01 Running 10.244.5.3 0
api-774bfb85f5-p4hkm worker01 Running 10.244.5.60 0
api-774bfb85f5-sd8mp worker01 Running 10.244.5.8 0
api-774bfb85f5-x2vfn worker01 Running 10.244.5.62 0
web worker01 Running 10.244.5.20 0
webapp-74cbd6dff7-4stzb worker01 Running 10.244.5.11 0
webapp-74cbd6dff7-jxlmr worker01 Running 10.244.5.63 0
webapp-74cbd6dff7-kt65d worker01 Running 10.244.5.10 0
webapp-74cbd6dff7-mwnbh worker01 Running 10.244.5.61 0
Any list sorts with --sort-by, which takes a JSONPath expression, and sorting by restart count puts the most-restarted pods at the bottom where tail finds them. For output another program will parse, jsonpath-as-json wraps the result in a JSON array, and -o name prints names in the pod/web form that feeds straight into another kubectl command.
kubectl get pods -A --sort-by=.metadata.creationTimestamp
kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount'
kubectl get pods -l app=webapp -o jsonpath-as-json='{.items[*].status.podIP}'
kubectl get pods -o name
Those four cover most of what people reach for jq to do. You don’t need to install anything on a locked-down jump host.
Get faster at kubectl for the CKA
The Certified Kubernetes Administrator (CKA) exam from the Linux Foundation is fully hands-on: 15 to 20 tasks in two hours, solved from a terminal on live clusters. Time is the constraint, so kubectl speed matters more than recall, and speed comes from repetition on a cluster you’re free to break. The kubeadm cluster linked at the top is a good practice target, because building and upgrading clusters with kubeadm is itself part of the curriculum.
The current CNCF curriculum on GitHub weights troubleshooting at 30%, cluster architecture and installation at 25%, services and networking at 20%, workloads and scheduling at 15% and storage at 10%. It names Helm, Kustomize and the Gateway API explicitly, and that’s why this page covers kubectl apply -k and EndpointSlices rather than older habits. During the CKA you may only browse a short list of sites, from the browser inside the CKA environment:
- kubernetes.io/docs and kubernetes.io/blog
- helm.sh/docs
- gateway-api.sigs.k8s.io
The search box on kubernetes.io is allowed, but opening external results from it isn’t. The Linux Foundation’s CKA and CKAD tips page also says kubectl comes with the k alias and Bash completion already set up, so you won’t need the bashrc lines from earlier. You reach each task’s host with ssh and exit back before the next one, since nested ssh is not supported, and copying in the terminal uses Ctrl+Shift+C and Ctrl+Shift+V.
Two shell variables save real time. One generates YAML, and the other deletes pods you’re about to recreate.
export do="--dry-run=client -o yaml"
export now="--force --grace-period=0"
kubectl create deployment cache --image=redis:alpine --replicas=2 $do > cache.yaml
kubectl run quick --image=busybox:stable --restart=Never -- sleep 300
kubectl delete pod quick $now
They are practice-cluster habits where speed matters more than safety, and $now skips graceful shutdown entirely, so keep it out of your production muscle memory.
Create objects without writing YAML
Most CKA tasks read like “create X with these settings”, and kubectl create has a subcommand for most of the kinds they ask for. Typing one line is faster than copying a manifest from kubernetes.io and fixing its indentation. The trap is kubectl run: anything after a bare -- becomes the container’s args, while --command -- replaces the image’s entrypoint. We generated the same pod both ways with $do to see the difference:
kubectl run sleeper --image=busybox:stable --restart=Never $do -- sleep 3600 | grep -A4 'containers:'
kubectl run sleeper --image=busybox:stable --restart=Never $do --command -- sleep 3600 | grep -A4 'containers:'
The first manifest passes sleep 3600 as args to the image’s own entrypoint, and the second sets command instead. With busybox both start the same process, but on an image whose entrypoint is a script, the args form passes sleep 3600 to that script as arguments, which is rarely what the task asked for:
containers:
- args:
- sleep
- "3600"
image: busybox:stable
containers:
- command:
- sleep
- "3600"
image: busybox:stable
CronJobs come up often, and so does “run it once now so I can check it works”. Create the CronJob, then start a Job from its template with --from, which saves you waiting for the schedule:
kubectl create cronjob nightly --image=busybox:stable --schedule='0 2 * * *' -- date
kubectl create job nightly-manual --from=cronjob/nightly
kubectl wait --for=condition=Complete job/nightly-manual --timeout=90s
kubectl logs job/nightly-manual
The Job completed within seconds of being created, and its log holds the single line that date printed inside the container:
Mon Oct 26 07:56:52 UTC 2026
Quotas, disruption budgets, Ingress objects and Services for bare pods all have one-line forms too. None of them needs a manifest, and the kubectl get at the end shows each object they created:
kubectl create quota cheatsheet-quota --hard=pods=30,services=10,configmaps=20
kubectl create poddisruptionbudget webapp-pdb --selector=app=webapp --min-available=2
kubectl create ingress webapp --class=nginx --rule="webapp.example.com/*=webapp-svc:80"
kubectl expose pod web --port=80 --name=web-svc
kubectl get quota,pdb,ingress,svc
Read the quota line as used against allowed, so the namespace was well inside its pod and Service limits. ALLOWED DISRUPTIONS reads 2 because four webapp replicas were running and the budget keeps at least two, so a drain could evict two of them and would wait on the rest. The Ingress has no ADDRESS because the lab runs no Ingress controller, and that is normal, since the object only takes effect once a controller with the nginx class picks it up:
NAME REQUEST LIMIT AGE
resourcequota/cheatsheet-quota configmaps: 3/20, pods: 10/30, services: 3/10 0s
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
poddisruptionbudget.policy/webapp-pdb 2 N/A 2 0s
NAME CLASS HOSTS ADDRESS PORTS AGE
ingress.networking.k8s.io/webapp nginx webapp.example.com 80 0s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/web-svc ClusterIP 10.106.82.73 <none> 80/TCP 0s
service/webapp-np NodePort 10.102.181.147 <none> 80:31131/TCP 4m19s
service/webapp-svc ClusterIP 10.110.211.42 <none> 80/TCP 4m19s
Keep the quota to object counts unless the task asks for CPU or memory. A quota on requests.cpu rejects every new pod that sets neither a CPU request nor a CPU limit, unless a LimitRange fills in a default, and in a namespace full of pods without requests the next rollout simply stops creating pods. Environment variables are the other frequent one-liner, and --list prints what the pod template now carries:
kubectl set env deployment/webapp LOG_LEVEL=debug
kubectl set env deployment/webapp --list
kubectl rollout status deployment/webapp
Changing an environment variable edits the pod template, so it triggers a rollout like any image change, and the PDB we just created didn’t block it, because a rollout replaces pods through the ReplicaSet instead of evicting them:
# Deployment webapp, container nginx
LOG_LEVEL=debug
A few kinds have no kubectl create form at all, namely DaemonSets, StatefulSets, PersistentVolumes, PersistentVolumeClaims and NetworkPolicies. For a DaemonSet or StatefulSet, generate a Deployment with $do, change the kind and delete the fields that don’t apply (replicas and strategy for a DaemonSet). For the rest, copy the example from kubernetes.io/docs, which is on the allowed list.
For study material beyond the cluster itself, Benjamin Muschko’s Certified Kubernetes Administrator (CKA) Study Guide (paid link) (2nd edition, O’Reilly, February 2026) is written for the 2025 curriculum. Every chapter ends with sample exercises you can run on this same cluster. Our roundup of the best CKA, CKAD and CKS study guides compares it with the other books worth working through alongside the labs.
Remove the practice objects
Everything this page created sits in two namespaces plus three cluster-scoped objects, a handful of local files, and a kubeconfig that still defaults to the cheatsheet namespace. Deleting a namespace removes every pod, Service, ConfigMap, Secret, Role and binding inside it, and that is the reason we kept the lab in its own namespaces.
kubectl config set-context --current --namespace=default
kubectl delete namespace cheatsheet rbac-demo
kubectl delete pv cheatsheet-pv --ignore-not-found
kubectl delete clusterrolebinding cheatsheet-node-viewer-jane --ignore-not-found
kubectl delete clusterrole cheatsheet-node-viewer --ignore-not-found
rm -f ~/kuberc-demo.yaml web2-pod.yaml webapp.yaml webapp-typo.yaml app.conf nginx.conf tuning.conf tls.crt tls.key ca.crt app-pod.yaml app-data-pvc.yaml cheatsheet-pv.yaml calico-np.yaml cache.yaml
rm -rf kust
kubectl get ns
unset KUBERC NODE
The --ignore-not-found flag keeps the cluster-scoped deletes quiet if you skipped those sections. Running unset KUBERC NODE last turns your own ~/.kube/kuberc back on for this shell, and the rm lines remove only the files this walkthrough created, including the kuberc demo file. The namespaces went first:
namespace "cheatsheet" deleted
namespace "rbac-demo" deleted
After that, kubectl get ns listed only the namespaces the cluster started with, and the context was back on default. The bashrc lines and bash-completion stay, since you’ll want them on any machine you run kubectl from.
Replace kubectl commands that no longer work
Every row below came from running the old form on a current kubectl, and the middle column is what it printed. An unknown flag stops a script outright, while a deprecation warning still runs the command and only tells you it’ll break in a later release.
| Old command | What kubectl prints now | Use instead |
|---|---|---|
kubectl run --generator=run-pod/v1 | error: unknown flag: --generator | kubectl run, which only creates pods |
kubectl run --replicas=3 | error: unknown flag: --replicas | kubectl create deployment --replicas=3 |
kubectl get -o yaml --export | error: unknown flag: --export | kubectl get -o yaml, then strip status and server-set metadata such as uid and resourceVersion |
kubectl version --short | error: unknown flag: --short | kubectl version |
kubectl set image ... --record | Flag --record has been deprecated | kubectl annotate with kubernetes.io/change-cause |
kubectl autoscale --cpu-percent=80 | Flag --cpu-percent has been deprecated | --cpu=80% |
kubectl get componentstatuses | Warning: v1 ComponentStatus is deprecated in v1.19+ | kubectl get --raw='/readyz?verbose' |
kubectl get endpoints | Warning: v1 Endpoints is deprecated in v1.33+ | kubectl get endpointslices |
--field-selector=status.phase!=Running | No resources found with a pod crash looping | grep STATUS or sort by restart count |
The warnings are easy to ignore because the commands still run, but shell aliases and CI scripts are exactly where --record and get endpoints survive for years. Grep your dotfiles and pipelines for these flags once, swap in the replacements from the last column, and the next kubectl upgrade won’t break anything you depend on.