Local Kubernetes Dev — Part 12: Debugging and observability
The basic Kubernetes debugging toolkit — logs, describe, events, port-forward, exec, and k9s — plus where to grow into real observability.
Sooner or later, myapp will break. A Pod won't come up, an image won't pull, the app can't reach PostgreSQL — that's a normal part of life in Kubernetes. The good news: you already have everything you need to quickly figure out what exactly went wrong. In this chapter we'll cover the basic debugging toolkit — from reading logs to a handy terminal UI — and sketch out where to grow once the basic commands aren't enough.
The key takeaway of this chapter
Debugging in Kubernetes almost always follows the same route. First you look at the events (what happened at the cluster level), then describe (the details of a specific Pod), then the logs (what the application itself had to say). Memorize this chain — events → describe → logs — it will get you out of most situations.
Logs: kubectl logs, -f, --previous, and the Tilt dashboard
Logs are the first place you look when the application has started but isn't behaving as expected. The basic command:
1kubectl logs <pod> -n myappTo watch logs in real time (like tail -f), add -f (follow) — the stream keeps going until you interrupt it:
1kubectl logs -f <pod> -n myappThe -p flag (also --previous) deserves a special mention. It shows the logs of the previous, already-crashed container instance. This is the key to debugging CrashLoopBackOff: the current container hasn't started yet (or just crashed again), so a plain kubectl logs will show nothing or the start of a new run. The real cause of the crash lives in the logs of the previous instance:
1kubectl logs -p <pod> -n myappImportant: -f and -p don't logically combine — you can't "stream the logs of something that's already dead."
A few flags that save time:
1# a specific container in a multi-container Pod
2kubectl logs <pod> -c <container> -n myapp
3
4# the last 50 lines with timestamps
5kubectl logs <pod> --tail=50 --timestamps -n myapp
6
7# only the last 10 minutes
8kubectl logs <pod> --since=10m -n myapp
9
10# logs from all Pods with the label app=myapp, all containers
11kubectl logs -l app=myapp --all-containers -n myapp--since accepts values like 5s, 2m, 3h. By default --tail shows all lines (or the last 10 if you use the -l selector). For the full list of flags, see the official reference for kubectl logs.
A tip about multi-container Pods: if a Pod has several containers and you didn't specify -c, kubectl will pick the default container — and it can easily turn out to be not the one you want. When the logs look "wrong," the first thing to check is whether you're reading the right container.
Logs in Tilt
If you're already working through Tilt (see the chapter on Tilt), then in most cases you won't have to reach for kubectl logs by hand. Tilt collects the logs of all resources into its web UI at http://localhost:10350. There are two views there:
- Resource Overview — a list of all resources with their statuses (build status and runtime status separately), Pod ID, and endpoints.
- Resource Detail — focused on a single resource and its logs.
Tilt pulls in not just the application's stdout, but also Docker build errors and Kubernetes events — all in one place, with filters by source and search by substring/regex. This really speeds up the loop when you're iteratively editing myapp and want to see the reaction right away. You can change the UI port if needed with the --host/--port flags. For details, see the Tilt UI tutorial.
kubectl describe pod — reading the Events
If the Pod didn't start at all (which means there are no application logs yet), move on to describe:
1kubectl describe pod <pod> -n myappThis command gives you the full picture of the Pod: its configuration, current status, conditions, and — most valuable for debugging — the Events section at the bottom of the output. These events aren't in kubectl get; they're available only here (and through kubectl get events, see below).
Read the Events from the bottom up and look for lines with type Warning and reasons like Failed, BackOff, FailedScheduling. Typical lifecycle events:
Scheduled— the Pod has been assigned to a node;Pulling/Pulled— the image is being pulled / has been pulled;Failed— something didn't work out (for example, the image didn't pull);BackOff— Kubernetes is waiting before the next restart attempt.
The crucial nuance: describe tells you what happened at the Kubernetes level (for example, "the container is crashing, so BackOff"), but why the application itself is crashing — only the logs (kubectl logs -p) will tell you that. So there's exactly one connection: you see BackOff in the Events → you go to logs --previous for the real error. For more on debugging running Pods, see the official Kubernetes documentation.
kubectl get events, Pod statuses, and what they mean
To see events not for a single Pod but for the whole namespace or cluster, use kubectl get events. One important point: by default, events are not sorted chronologically, so almost always add --sort-by:
1kubectl get events --sort-by='.lastTimestamp' -n myapp
2
3# all namespaces, in watch mode
4kubectl get events -A --watchEvents are structured API objects with a type (Normal or Warning), a reason, and a message. Useful commands and flags are described in the cluster debugging guide.
An important caveat: events have a limited lifespan (a TTL on the order of an hour). If the incident happened yesterday, the events are already gone — you'll have to rely on logs and the state of the resources.
Pod phases versus what you see in STATUS
This is where newcomers often get confused, so let's go through it carefully. A Pod has five official phases, described in the Pod Lifecycle:
- Pending — the Pod has been accepted by the cluster, but the containers haven't started yet. This includes both waiting to be scheduled onto a node and pulling the image.
- Running — the Pod is bound to a node, and at least one container is running.
- Succeeded — all containers have terminated successfully and won't be restarted.
- Failed — all containers have terminated, and at least one of them with an error.
- Unknown — the Pod's state could not be obtained.
And now for the surprise: the familiar CrashLoopBackOff and ImagePullBackOff that you see in the STATUS column of kubectl get pods are NOT Pod phases. They're display fields that kubectl assembles from the container states for convenience. The phase, meanwhile, stays, for example, Pending (the Pod never made it to Running). Don't confuse the two — it'll save you some grief when reading the documentation.
What the most common statuses mean:
- CrashLoopBackOff — the container starts, crashes, Kubernetes waits and tries again, around and around. The pause between attempts grows (exponential backoff). The primary documentation guarantees only a minimum of
100msand a cap of5 minutes; treat the popular sequence like "10s, 20s, 40s…" as an illustration from blog posts, not an exact promise. Restart behavior depends on the Pod'srestartPolicy(Alwaysby default, orOnFailure/Never). Diagnose withkubectl logs -p+describe. - ImagePullBackOff — the image can't be pulled. For
myappthis is typical if you made a typo in the tag or didn't push the image to the local registryk3d-registry.localhost:5000(see the chapter on containerization). Look at the Events indescribe— the specific cause will be there (no such tag, registry unreachable, etc.). - Pending + FailedScheduling — the scheduler couldn't find a suitable node. Causes: not enough CPU/memory,
nodeSelector/affinity set, taints on the nodes, hitting a quota. In a local k3d cluster, this is most often a shortage of resources.
To quickly see the statuses and which node a Pod lives on:
1kubectl get pods -n myapp
2kubectl get pod <pod> -o wide -n myappport-forward and exec — checking from the inside
Sometimes a Pod is Running, but it's not clear whether the application is responding and whether it can see the database. Two commands help here.
kubectl port-forward creates a temporary TCP tunnel from a local port of yours to a Pod or Service — the traffic goes through the Kubernetes API server, without any LoadBalancer or Ingress. Handy for poking at myapp directly:
1# through the Service
2kubectl port-forward svc/myapp 8080:80 -n myapp
3
4# or straight into the Pod
5kubectl port-forward pod/<pod> 8080:8080 -n myappPay attention to the ports: when forwarding to the Service, the target port is the port of the Service itself (80), which is then proxied on to the container's targetPort: 8080 (see the chapter on manifests and the chapter on networking). When forwarding straight into the Pod, you hit the application's port 8080 directly — the same port we forwarded through Tilt in the Tilt chapter. The local port on the left (8080) is yours in both cases and can be any free port.
After this, curl http://localhost:8080/healthz will go straight into the Pod. For more on the tunnel model, see the port-forward guide. One quirk: the tunnel breaks if the Pod restarts or gets recreated — that's normal, just run the command again.
kubectl exec runs a command inside a container. The most common use is to open a shell and look around:
1kubectl exec -it <pod> -n myapp -- sh
2
3# a specific container + view the environment variables
4kubectl exec -it <pod> -c <container> -n myapp -- envFrom the inside, it's convenient to check the env variables (are DB_HOST/DB_PASSWORD etc. correct?), the presence of files, and network connectivity to PostgreSQL. As with logs, in a multi-container Pod specify -c, otherwise you'll end up in the wrong container.
When the container has no shell
If you built myapp on a distroless image (a minimal image with no shell or utilities — good practice for production), then kubectl exec ... -- sh simply won't work: there's no shell there. For this case there's kubectl debug — it attaches a temporary (ephemeral) container with the tools you need to the Pod, without restarting the Pod or changing its spec (stable as of Kubernetes 1.25):
1kubectl debug <pod> -it --image=busybox --target=<container> -n myapp--target connects the temporary container to the process namespace of the target one, so you can see its processes and network. All such actions are recorded in the API audit logs. For details and options (--share-processes, --copy-to), see the same Pod debugging documentation and the walkthrough on debugging distroless containers.
k9s: navigating the cluster in a single window
kubectl commands are fine, but switching between get pods, logs, describe, and port-forward dozens of times an hour gets tiring. k9s is a terminal UI that continuously watches the cluster and shows resources in real time, right in a single window. Starting it is trivial:
1k9s -n myappFrom there you navigate Pods, Deployments, and Services with the arrow keys, and you perform routine operations with hotkeys: view logs, describe a resource, open a shell, port-forward, restart, scale. The shortcuts adapt to the context — over a Pod one set is available, over a Deployment another. There's powerful filtering, navigation between related resources, and support for CRDs, RBAC, and theming (skins). For a beginner this is probably the fastest way to "get a feel" for the cluster, and it works perfectly with a local k3d. The project lives at github.com/derailed/k9s.
Under the hood, k9s does exactly the same thing you do by hand through kubectl — just without typing the commands. So you still need to understand the basic commands from this chapter: k9s speeds up the work but doesn't replace understanding what's going on.
Basic observability: where to grow
Everything discussed above is debugging: you react to a specific problem here and now. As myapp matures (especially after moving to production), you'll want to see the state of the service continuously, not just when something has already broken. That's observability, and it rests on three pillars:
- Metrics — numeric indicators over time: CPU, memory, network, request count, latency. They answer the question "is there a problem" (for example, latency is creeping up). The industry standard for collection is Prometheus, and for visualization, Grafana. The very first step in a local cluster is to install
metrics-serverso thatkubectl top podsworks. - Logs — what we've been covering throughout this chapter. They give you the details of a specific error.
- Traces — the path of a single request through several services. They answer the question "why" and where exactly the time was lost. The standard here is OpenTelemetry: the application produces spans, they go to the OTel Collector, and from there to a backend like Jaeger or Zipkin for viewing.
A short mnemonic: metrics — what, logs — details, traces — why. For an overview of the topic, take a look at the materials on observability in Kubernetes from 2026.
It's important not to overdo it. For local development of myapp, a full Prometheus + Grafana + OpenTelemetry stack is usually overkill — at this stage, logs, describe, events, and k9s are enough. Treat this section as a direction of growth: when the service heads to production and you have several interacting components, you'll know which way to dig. We'll touch on some of these topics again when we make the local setup more production-like.
Sources
- kubectl logs — official reference (flags -f, -p, -c, --tail, --since)
- Tilt UI — official tutorial
- Pod Lifecycle — Pod phases, restartPolicy, CrashLoopBackOff and backoff
- Debugging Running Pods — kubectl exec, kubectl debug, ephemeral containers
- Debugging clusters — kubectl get events, --sort-by, -A, --watch
- K9s — official site
- derailed/k9s — official repository
- Kubernetes Observability Guide (2026) — the three pillars of observability
- Debugging Distroless Containers — kubectl debug vs exec
- Kubernetes Port Forwarding guide — the tunnel model through the API server