Kubernetes Observability On-Call: Six Tickets with Prometheus, Loki, and Jaeger
You're on call today at HotROD, a small ride-hailing company. Six support tickets are waiting for you. Each one is a real problem in the cluster in front of you. To solve them, you'll use metrics, logs and traces, and part of the job is choosing the right one.
This lab tests what you know. It doesn't teach the theory. If a term is new to you, read the companion post, How observability works in Kubernetes. Each ticket links to the section you need.
How a ticket works
- Read the message from the person who opened the ticket.
- Do the steps under See the problem. They tell you where to look and what you'll see while the problem is still there. Seeing it first is part of the job, and it gives you something to compare with later.
- Read the Done when line. It says exactly what the lab checks, and what you'll see once it's fixed.
- Investigate and fix the problem, any way you like.
- The lab checks the cluster every few seconds and closes the ticket by itself when the problem is fixed.
Two tickets ask a question instead. Type your answer and click Submit. If the box turns red, that isn't the answer yet, so look again and try another.
Tickets unlock one at a time, in order. If you get stuck, open the Nudge first, and the Solution only if you need it.
Where things run
Every command and query in this lab says where it goes:
- Terminal: the dev-machine tab. Commands in
shblocks, such askubectl, run here. - Prometheus tab, Query page: PromQL queries. Paste the query into the box at the top, click Execute, and read the Table view.
- Grafana tab, Explore: LogQL queries for logs. Pick the Loki data source and switch the editor to Code.
- Jaeger tab: traces. There are no queries to type; you search and click.
💡 Give the cluster a couple of minutes after it starts. The first ticket unlocks when everything is ready.
The System You're On Call For
HotROD, the main product
HotROD ("Rides on Demand") is the company's app. A customer asks for a ride, and HotROD finds the driver
who can get there first. It's Jaeger's demo application: four small services that run together in one
Deployment, hotrod, in the demo namespace.
Open the HotROD tab and click a customer to book a ride yourself.
💡 After each ride, HotROD shows find trace and open trace links. They point to localhost:16686,
so they don't work in this playground. To see a ride's trace, right-click open trace and copy the link.
The long ID at the end of the link is the trace ID. Paste it into the search box in the Jaeger tab.
Every ride request follows the same path:
customer ──▶ frontend GET /dispatch?customer=123
│
├─1─▶ customer GET /customer "where is this customer?"
│ └─▶ MySQL SQL SELECT
│
├─2─▶ driver FindNearest (gRPC) "which drivers are close?"
│ └─▶ Redis FindDriverIDs, then GetDriver for each one
│
├─3─▶ route GET /route "how long would this driver take?"
│ (one call per candidate driver)
│
└──▶ answer: the driver with the shortest ETA
A few things to know about HotROD:
- MySQL and Redis aren't real databases. HotROD simulates them. They appear in traces as the services
mysqlandredis-manual, but there are no Pods for them. - HotROD produces all three signals: Prometheus metrics, structured logs that include a trace ID, and a trace for every request.
- A Deployment called loadgen books a ride every 2 seconds, so there's always traffic to look at.
The other services
Three smaller services run next to HotROD in demo. They're stand-ins: each does just enough to act like
the real thing.
| Service | Its job at HotROD | What it produces |
|---|---|---|
checkout | Takes payment when a ride ends. Its team has just moved it to Kubernetes. | Prometheus metrics on /metrics |
api-gateway | Sends each payment to the card company and records whether it was accepted. | One JSON log line per payment |
image-resizer | Resizes the photos drivers upload to their profiles. It runs in the background and has no web page. | One JSON log line per image |
The observability stack
Everything in the observability namespace is there to watch the services above:
| Component | What it does |
|---|---|
| Prometheus | Collects and stores metrics, and checks alert rules. It's run by the Prometheus Operator, so what to scrape and when to alert are Kubernetes objects: ServiceMonitor and PrometheusRule. |
| Alertmanager | Receives alerts from Prometheus and would send them on to people. |
| kube-state-metrics, node-exporter, cAdvisor | Produce metrics about Kubernetes objects, nodes and containers. cAdvisor is built into the kubelet. |
| Loki and Alloy | Loki stores logs. Alloy runs on every node and sends every container's logs to Loki. |
| Jaeger | Stores traces and shows them. |
| Grafana | One web UI over all of the above, including a K8s Observability Playground dashboard. |
Before You Start: Your Tools
You'll switch between these tabs. Take a minute to find your way around them before the first ticket.
| Tab | Use it to | Where to click |
|---|---|---|
| dev-machine | Run kubectl. It's already set up with admin access. | Just type. |
| Prometheus | Run PromQL queries, see what's scraped, see alerts | Query to run a query (switch between Table and Graph). Status > Target health lists every scrape target. Alerts shows each rule's state. |
| Grafana | Query logs, look at dashboards | Explore (compass icon), then pick Loki as the data source and switch the editor to Code to type a LogQL query. Dashboards lists the dashboards. You're logged in as an anonymous admin. |
| Jaeger | Find and read traces | Pick a Service, click Find Traces, then click a trace to see its spans as a timeline. The search box at the top takes a trace ID. |
| HotROD | Use the product | Click a customer to book a ride. |
Ticket 1: "We can't see checkout's metrics"
From: Priya, lead developer of
checkoutWe deployed
checkouttodemoand a colleague added a ServiceMonitor for it. It's been an hour and there's nothing in Prometheus. The app serves metrics on/metrics. Can you get it scraped?
See the problem. In the Prometheus tab, Query page, run:
up{namespace="demo", service="checkout"}
You'll see Empty query result. up is the series Prometheus records for every target it scrapes, so an
empty result means Prometheus isn't scraping checkout at all. Under Status > Target health, there's no
checkout target either.
Done when: Prometheus scrapes checkout. Run the same query again: it returns one row with the value 1.
A new target can take a minute or two to appear.
Theory: how Prometheus finds what to scrape.
Nudge
A ServiceMonitor doesn't look at Pods. It finds Services by their labels, then uses the Service port with the name it gives. Compare the two objects. In the terminal:
kubectl -n demo get servicemonitor checkout -o yaml
kubectl -n demo get service checkout -o yaml
Solution
The Service has the label app.kubernetes.io/name: checkout, and its port is named http. Replace the
ServiceMonitor's selector and fix the port name. Use a JSON patch, because a merge patch would keep the old
app label in the selector. In the terminal:
kubectl -n demo patch servicemonitor checkout --type=json -p '[
{"op": "replace", "path": "/spec/selector/matchLabels", "value": {"app.kubernetes.io/name": "checkout"}},
{"op": "replace", "path": "/spec/endpoints/0/port", "value": "http"}
]'
The target appears in Status > Target health within a minute or two.
Ticket 2: "Checkout was down and nobody got paged"
From: Sam, engineering manager (after this morning's incident review)
Last night a bad rollout scaled
checkoutto zero for five minutes. TheCheckoutDownalert exists, but it never fired. We want it to fire within 2 minutes whenever checkout has no healthy target - including when there are no pods at all.
See the problem.
- In the Prometheus tab, open the Alerts page.
CheckoutDownis listed as inactive: its condition isn't true, so it isn't about to fire. Expand it to see its expression and itsfortime. - In the terminal, look at the rule as a Kubernetes object:
kubectl -n demo get prometheusrule checkout-alerts -o yaml - In the Prometheus tab, Query page, run the rule's expression,
up{job="checkout-svc"} == 0. You'll see Empty query result, and it stays empty whether checkout is up or down. Then runup{job="checkout"}and compare.
Done when: all three of these are true:
- you've fixed the
CheckoutDownalert rule, - you've scaled
checkoutto 0 replicas to test it, and CheckoutDownis firing. On the Alerts page it turns pending first, then firing.
You'll bring checkout back at the very end of the lab.
Theory: alerting on symptoms.
Nudge
Run up{job="checkout"} on the Prometheus tab's Query page twice: once with checkout running, and once
after scaling it to 0 in the terminal (kubectl -n demo scale deployment checkout --replicas=0).
What does it return when there are no pods?
Solution
In the terminal:
kubectl -n demo patch prometheusrule checkout-alerts --type=json -p '[
{"op": "replace", "path": "/spec/groups/0/rules/0/expr", "value": "absent(up{job=\"checkout\"} == 1)"},
{"op": "replace", "path": "/spec/groups/0/rules/0/for", "value": "1m"}
]'
kubectl -n demo scale deployment checkout --replicas=0
Prometheus loads the new rule in about a minute. The alert is then pending for 1 minute, and then firing.
Ticket 3: "Rides got slower after this morning's change"
From: Lena, HotROD backend team
Someone changed HotROD's configuration this morning. Since then, booking a ride takes about a second instead of 0.7 s. Nothing is failing and the logs look normal. Which service is now the bottleneck?
See the problem. In the Jaeger tab, choose the service frontend and click Find Traces. Each
frontend: GET /dispatch trace takes about 1 second. Click one to open its timeline: every bar is a span,
and its position shows when it started.
Done when: you type the name of the service that is causing the extra time into the answer box below, and the lab accepts it.
Theory: reading a trace.
Nudge
Open a recent frontend trace in Jaeger. Look at when each span starts, not only how long it takes.
Solution
In a recent GET /dispatch trace, the ten route spans don't overlap any more. Each one starts when the
previous one ends. The answer is route.
Ticket 4: "...and fix it, please"
From: Lena, HotROD backend team
Thanks! Can you undo whatever causes it?
See the problem. In the Prometheus tab, Query page, run this query. It's the average time HotROD took to answer a ride request over the last minute, in seconds:
sum(rate(hotrod_request_latency_sum{endpoint="GET_/dispatch"}[1m]))
/ sum(rate(hotrod_request_latency_count{endpoint="GET_/dispatch"}[1m]))
Right now it's about 1 (one second).
Done when: both of these are true:
- HotROD no longer runs with the setting that slowed it down, and
- the same query returns less than 0.85. Give it a minute after your fix, because it's an average over the last minute.
Nudge
The route calls behave as if only one may run at a time. Look at the options HotROD is started with. In the terminal:
kubectl -n demo get deploy hotrod -o jsonpath='{.spec.template.spec.containers[0].args}'
Solution
--fix-route-worker-pool-size=1 allows only one worker for route calls. The default is 3. Remove the option.
In the terminal:
kubectl -n demo patch deploy hotrod --type=json \
-p '[{"op": "replace", "path": "/spec/template/spec/containers/0/args", "value": ["all"]}]'
The latency is an average over the last minute, so it takes about a minute to come down.
Ticket 5: "Who keeps getting declined?"
From: Marco, customer support
api-gatewaylogs every payment as JSON. We want to call the customer with the most rejected payments in the last 15 minutes.
See the problem. In the Grafana tab, open Explore, pick the Loki data source, switch the editor to Code, and run:
{app="api-gateway"}
You'll see a stream of JSON lines, one per payment, with fields such as level, msg and customer.
There are far too many to count by eye.
Done when: you type that customer's ID into the answer box below, and the lab accepts it.
Theory: labels versus log content.
Nudge
Start from the query above, in the Grafana tab's Explore view. Build on it in three steps:
- pick the gateway's logs by label,
- keep only the rejected payments, and
- count them per customer with
count_over_timeandsum by.
Solution
In the Grafana tab's Explore view, with Loki selected:
topk(3, sum by (customer) (count_over_time({app="api-gateway"} | json | level="error" [15m])))
Run it as an Instant query (one result, for now) rather than a Range query (a graph over time).
The top result is customer 4711.
Ticket 6: "image-resizer is slow, but nothing's wrong with it"
From: Aisha, driver onboarding team (owners of
image-resizer)Resizing an image takes about 7 seconds. On a laptop it takes one. The Pod is
Running, it never restarts, and the logs are clean.
See the problem. In the terminal, read the last few log lines:
kubectl -n demo logs deploy/image-resizer --tail=5
Each line shows how long one image took, in the seconds field. Right now it's about 7.
Done when: the same command shows seconds of 2 or less on the last three lines, from a Pod that's
running your fix.
Theory: saturation and the USE method.
Nudge
Compare how much CPU the container uses with how much it's allowed. In the Prometheus tab, Query page, run
this. The result is in CPU cores, so 0.05 means 50m:
rate(container_cpu_usage_seconds_total{namespace="demo", container="resizer"}[1m])
Then compare the result with the container's CPU limit, and look at container_cpu_cfs_throttled_periods_total.
Solution
The CPU use stays flat at the 50m limit. Raise the limit, or remove it. In the terminal:
kubectl -n demo set resources deployment image-resizer -c resizer --limits=cpu=1
kubectl -n demo logs deploy/image-resizer -f # "seconds" drops to 0 or 1
The old Pod takes about 30 seconds to stop. Until it's gone, kubectl logs may still read from it and show
7-second jobs, so give it a moment before you look.
Close out
See the problem. checkout is still scaled to 0 from Ticket 2, so on the Prometheus tab's Alerts page,
CheckoutDown is still firing.
Done when: all three of these are true:
checkoutis running again,up{namespace="demo", service="checkout"}returns1on the Prometheus tab's Query page, andCheckoutDownis back to inactive on the Alerts page, by itself.
Solution
In the terminal:
kubectl -n demo scale deployment checkout --replicas=1
CheckoutDown resolves once Prometheus has scraped the new Pod.
Shift Report
| Ticket | What solved it | The trap |
|---|---|---|
| 1. Missing metrics | Prometheus targets | Two silent mismatches between the ServiceMonitor and the Service |
| 2. Silent alert | PromQL | No pods means no series: == 0 can't fire, but absent() can |
| 3. Slow rides | A trace | No span got slower: the calls stopped running at the same time |
| 4. The fix | HotROD's configuration | A worker pool of 1 |
| 5. Declined payments | A LogQL query | The busiest customer isn't the one with the most rejections |
| 6. Slow resizer | cAdvisor metrics | Saturation, with no error anywhere |
Want more? Run o11y-lab mystery in the terminal for a random incident with no ticket at all.