In the previous lesson we installed metrics-server and at last found out how much every Rutas Norte component consumes right now. We also discovered its hard limit: it remembers nothing. The question "what happened last night at three in the morning?" is still unanswered, and with it all the ones that really matter: how many bookings per minute are we confirming? has latency gone up since the last deployment? how long until the bookings-postgres disk fills up?
This lesson deploys Prometheus, the de facto standard for monitoring Kubernetes and the second CNCF graduated project after Kubernetes itself. We are going to understand its data model, where a cluster's metrics really come from (one of the biggest sources of confusion for newcomers), how to instrument bookings-api with business metrics, how to deploy the stack with the operator we announced in 06-07, and how to write PromQL that answers real Rutas Norte questions. By the end, the platform will have a memory.
Contents
- What Prometheus adds compared with metrics-server
- The pull model and the
/metricsendpoints - The data model: series, labels and the four metric types
- Where a cluster's metrics come from: the table that clears up the confusion
- Instrumenting
bookings-apiwith business metrics - Deploying the kube-prometheus-stack with Helm
- The Prometheus Operator and its custom resources
- The Rutas Norte
ServiceMonitorand target diagnostics - PromQL from scratch and with purpose
- The four golden signals applied to Rutas Norte
- Storage, retention and the limits of Prometheus
- Common mistakes and tips
- Exercises
- What Prometheus adds compared with metrics-server
Three things, and all three are what we were missing.
History. Prometheus stores every sample in a time-series database on disk. With the stack's default retention we will have several days; by configuring storage, weeks or months. The three-in-the-morning question has an answer.
Queries. PromQL is a complete language for aggregating, filtering, deriving and comparing series. It is not "look at a number": it is "give me the 5xx error rate of bookings-api in the production environment, grouped by route, over the last hour".
Alerts. Prometheus evaluates expressions periodically and, when they hold for a sustained period, fires alerts that Alertmanager routes to whoever needs them. That is what makes somebody find out at three in the morning without staring at a screen.
And a fourth one, less obvious but decisive: anything can expose metrics. metrics-server only knows about CPU and memory. Prometheus collects whatever an HTTP endpoint offers it: requests per route, confirmed bookings, the email queue depth, PostgreSQL's active connections, certificates about to expire.
| Capability | metrics-server | Prometheus |
|---|---|---|
| History | ~1 minute in memory | Days or months on disk |
| Metrics | CPU and memory only | Anything exposed at /metrics |
| Query language | No | PromQL |
| Alerts | No | Yes (with Alertmanager, 07-04) |
| Business metrics | No | Yes |
| Operating cost | Trivial | Considerable (RAM and disk) |
| Does the HPA use it? | Yes, natively | Yes, through an adapter (09-04) |
The two live side by side. We are not replacing metrics-server: we are complementing it.
- The pull model and the
/metrics endpoints
/metrics endpointsHere is the design decision that shapes everything else.
Most traditional monitoring systems work by push: the application sends its metrics to a central server. Prometheus does the opposite: pull. Prometheus keeps a list of targets and, at a set interval (typically 30 seconds), issues a GET to each one.
flowchart LR
P[Prometheus] -->|"GET /metrics every 30s"| A["bookings-api :8080/metrics"]
P -->|"GET /metrics every 30s"| B["metrics-exporter :9187/metrics"]
P -->|"GET /metrics every 30s"| C["kube-state-metrics :8080/metrics"]
P -->|"GET /metrics every 30s"| D["node-exporter :9100/metrics"]
P --> TSDB[(TSDB on disk<br/>2 h blocks)]
Why is pulling better than receiving?
- Prometheus knows whether a target is down. If the
GETfails, the synthetic metricupis 0. With push, the absence of data is ambiguous: is the application down or has it simply not sent anything? - The monitoring system controls the pace, not the applications. A hundred badly written applications cannot flood it.
- Automatic discovery. Prometheus queries the Kubernetes API to find out which pods exist. A new pod shows up as a target automatically, with nothing configured in the application.
- Trivial debugging. You can
curlthe/metricsendpoint from your laptop and see exactly what Prometheus sees.
The exception is very short-lived processes, such as our occupancy-reports CronJob: it may finish before Prometheus scrapes it. For those cases there is the Pushgateway, an intermediary the job pushes its metrics to and that Prometheus scrapes afterwards. It is the only legitimate exception and it should be used sparingly.
What a /metrics endpoint looks like
kubectl -n rutas-norte-pro port-forward deploy/bookings-api 8080:8080 &
curl -s localhost:8080/metrics | head -30# HELP rutasnorte_requests_total Total HTTP requests served
# TYPE rutasnorte_requests_total counter
rutasnorte_requests_total{route="/api/routes",method="GET",code="200"} 184203
rutasnorte_requests_total{route="/api/routes",method="GET",code="500"} 47
rutasnorte_requests_total{route="/api/bookings",method="POST",code="201"} 9821
rutasnorte_requests_total{route="/api/bookings",method="POST",code="422"} 318
# HELP rutasnorte_bookings_confirmed_total Bookings confirmed and paid for
# TYPE rutasnorte_bookings_confirmed_total counter
rutasnorte_bookings_confirmed_total{source="web"} 8742
rutasnorte_bookings_confirmed_total{source="mobile"} 1079
# HELP rutasnorte_pool_connections_active Connections in use from the PostgreSQL pool
# TYPE rutasnorte_pool_connections_active gauge
rutasnorte_pool_connections_active 7It is plain text. Each line is name{labels} value. The # HELP and # TYPE lines are metadata: description and type. That deliberately simple format is the entire contract between an application and Prometheus.
- The data model: series, labels and the four metric types
Time series and labels
A time series in Prometheus is uniquely identified by its metric name plus the exact set of its labels. This is fundamental:
rutasnorte_requests_total{route="/api/routes", method="GET", code="200"} → series A
rutasnorte_requests_total{route="/api/routes", method="GET", code="500"} → series B (different)
rutasnorte_requests_total{route="/api/bookings", method="POST", code="201"} → series C (different)Changing the value of a single label creates a completely new series. Each series takes up memory and disk, and that is where the most frequently cited danger of Prometheus comes from: cardinality.
The golden rule of cardinality: never use as a label a value with many possible values. No user, booking or session identifiers, no client IP addresses, no timestamps and no full error messages.
A real example of the danger. If bookings-api labelled every request with the customer's ID number:
With 200,000 customers and 15 routes, you would have 3 million series from that metric alone. Prometheus would run out of memory within minutes. And, on top of that, you would be putting personal data into the monitoring system, with the implications we will see in 07-05.
Labels Prometheus adds by itself to everything it collects in Kubernetes (and that we will use constantly):
| Label | Example | Origin |
|---|---|---|
job |
bookings-api |
Name of the scrape job |
instance |
10.244.2.17:8080 |
IP and port of the specific target |
namespace |
rutas-norte-pro |
Kubernetes metadata |
pod |
bookings-api-7d9f8c4b5-x2klm |
Kubernetes metadata |
container |
api |
Kubernetes metadata |
node |
rutas-norte-worker-2 |
Kubernetes metadata |
The four metric types
| Type | It can | Example in Rutas Norte | Typical function to query it |
|---|---|---|---|
counter |
Only go up (or reset to 0) | rutasnorte_requests_total |
rate(), increase() |
gauge |
Go up and down | rutasnorte_pool_connections_active |
Direct value, avg, max |
histogram |
Count into configurable buckets | rutasnorte_request_duration_seconds |
histogram_quantile() |
summary |
Percentiles computed on the client | Rarely advisable in general | Direct value |
counter. A value that only grows: requests served, errors, bytes sent, bookings confirmed. It is never queried directly. The absolute value 184203 says nothing: is that a lot? since when? It is always queried with rate() or increase(), which calculate how much it has grown per unit of time. By convention, its name ends in _total.
Prometheus automatically detects counter resets (when a pod is recreated, the counter goes back to 0) and rate() compensates for them. You do not have to worry about it.
gauge. A value that goes up and down: active connections, temperature, memory in use, the size of a queue. It is queried directly. It is the type of almost everything cAdvisor exposes about memory.
histogram. The most powerful type and the worst understood. Instead of storing each individual value, the application classifies each observation into predefined cumulative buckets. A histogram metric actually exposes three things:
# Cumulative buckets: "how many requests took LESS than X seconds"
rutasnorte_request_duration_seconds_bucket{route="/api/bookings",le="0.05"} 8140
rutasnorte_request_duration_seconds_bucket{route="/api/bookings",le="0.1"} 9210
rutasnorte_request_duration_seconds_bucket{route="/api/bookings",le="0.5"} 9780
rutasnorte_request_duration_seconds_bucket{route="/api/bookings",le="1"} 9812
rutasnorte_request_duration_seconds_bucket{route="/api/bookings",le="+Inf"} 9821
# Sum of every observed value (to calculate the mean)
rutasnorte_request_duration_seconds_sum{route="/api/bookings"} 743.28
# Total number of observations
rutasnorte_request_duration_seconds_count{route="/api/bookings"} 9821Reading it: 8140 requests took less than 50 ms; 9210 took less than 100 ms (including the 8140 above: they are cumulative); 9821 in total, so 9 requests took more than 1 second.
With that data, the histogram_quantile() function can estimate any percentile. The huge advantage over summary: because the buckets are ordinary counters, they can be summed across pods. You can calculate the 95th percentile of latency for the whole of bookings-api by aggregating its six replicas. With a summary that is mathematically impossible.
The cost: each bucket is a series. A histogram with 10 buckets and 15 routes is 150 series per pod. Choose the buckets with judgement, matched to your service's real latency.
summary. The application computes the percentiles itself and exposes them. Cheap to query, but not aggregatable across instances: the 95th percentile of pod A and that of pod B cannot be combined to get the service's. In Kubernetes, where everything has several replicas, this rules it out almost always. Use it only if you need an exact percentile for a single instance.
Naming conventions
Prometheus has conventions worth respecting because tools and dashboards assume them:
- Prefix with the name of the system:
rutasnorte_. - The base unit in the name and always in SI base units:
_secondsnot_ms,_bytesnot_mb. - Counters end in
_total. - Names in
snake_case, no capitals.
- Where a cluster's metrics come from: the table that clears up the confusion
This is the section that resolves the doubt everybody has in their first week: "why do I need four different things to monitor Kubernetes?".
The short answer: because they are four different layers of reality and none of them can see the others.
| Source | What it observes | Example metric | Does it need installing? |
|---|---|---|---|
| kubelet / cAdvisor | The real usage of the containers (cgroups) | container_memory_working_set_bytes |
No: it comes with the kubelet |
| node-exporter | The node's operating system | node_filesystem_avail_bytes |
Yes, as a DaemonSet |
| kube-state-metrics | The state of the API objects | kube_deployment_status_replicas_unavailable |
Yes, as a Deployment |
| Application exporters | The innards of a specific piece of software | pg_stat_database_numbackends |
Yes, one per piece of software |
| Your own instrumentation | The business logic | rutasnorte_bookings_confirmed_total |
Yes, in the code |
Let us go through them one by one, because the distinction matters enormously when writing queries.
kubelet / cAdvisor: what the containers consume
It is the same source that feeds metrics-server (07-02), but Prometheus reads it directly and in far more detail. Key metrics:
container_cpu_usage_seconds_total{pod="bookings-api-...", container="api"}
container_memory_working_set_bytes{pod="bookings-api-...", container="api"}
container_cpu_cfs_throttled_periods_total{pod="bookings-api-...", container="api"}
container_network_receive_bytes_total{pod="bookings-api-..."}
container_fs_writes_bytes_total{pod="bookings-postgres-0"}Look at the third one: the throttled periods counter that in 07-02 we said kubectl top could not give us. Here it is, and it is the one that will confirm the bookings-api throttling diagnosis.
A practical warning: these metrics also appear with container="" (the pod-level aggregate, including the infrastructure pause container). You will almost always want to filter with container!="" so as not to count twice.
node-exporter: what is happening to the machine
It is a DaemonSet (one pod per node, as we studied in 06-02) that reads the host system's /proc and /sys. It sees things no container can see:
node_filesystem_avail_bytes{mountpoint="/var/lib/kubelet"}
node_memory_MemAvailable_bytes
node_load1
node_cpu_seconds_total{mode="idle"}
node_network_transmit_bytes_totalIt is the one that tells you the node's disk is filling up, that the load average is through the roof or that the network card is saturated. No container can tell you that about itself.
kube-state-metrics: what the Kubernetes API believes
This is the hardest one to grasp and the most necessary. It measures nobody's consumption. It connects to the Kubernetes API and turns the state of the objects into metrics:
kube_deployment_spec_replicas{deployment="bookings-api"} 6
kube_deployment_status_replicas_available{deployment="bookings-api"} 4
kube_pod_container_status_restarts_total{pod="bookings-api-...", container="api"} 7
kube_pod_status_phase{pod="bookings-postgres-0", phase="Running"} 1
kube_job_status_failed{job_name="occupancy-reports-28934520"} 1
kube_persistentvolumeclaim_status_phase{persistentvolumeclaim="data-bookings-postgres-0", phase="Bound"} 1
kube_pod_container_resource_requests{pod="bookings-api-...", resource="cpu"} 0.2With these metrics you can alert on things none of the other sources know about: a Deployment with fewer available replicas than desired, a pod restarting in a loop, a Job that has failed, a PVC that has been Pending for twenty minutes.
And something very useful: kube_pod_container_resource_requests exposes the declared requests. Cross-referencing it with cAdvisor's container_memory_working_set_bytes gives you, in a single PromQL query, exactly the recalibration analysis that in 07-02 we had to do with a bash script.
Exporters: translators for third-party software
PostgreSQL does not speak the Prometheus format. An exporter is a process that connects to the software, queries its internal statistics and translates them into /metrics.
And here we connect back to 06-04: we already have it deployed. When we added the metrics exporter sidecar to the bookings-postgres pod, we said explicitly that Prometheus would consume it in this lesson. The moment has arrived.
# A reminder of the sidecar we added in 06-04, inside the bookings-postgres
# StatefulSet. Note that it is a native sidecar (an initContainer with
# restartPolicy: Always), as we saw in that lesson.
initContainers:
- name: metrics-exporter
image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0
restartPolicy: Always # native sidecar 1.29+
ports:
- name: metrics
containerPort: 9187
env:
- name: DATA_SOURCE_URI
value: "127.0.0.1:5432/bookings?sslmode=disable"
- name: DATA_SOURCE_USER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: metrics-user
- name: DATA_SOURCE_PASS
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: metrics-password
resources:
requests:
cpu: "20m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"What it exposes, and that we are going to need:
pg_stat_database_numbackends{datname="bookings"} # open connections
pg_stat_database_xact_commit{datname="bookings"} # committed transactions
pg_stat_database_deadlocks{datname="bookings"} # deadlocks
pg_database_size_bytes{datname="bookings"} # database size
pg_stat_replication_replay_lag # replica lag
pg_up # is PostgreSQL responding?There are exporters for practically everything: redis_exporter for redis-cache, nginx-prometheus-exporter for web-store, blackbox_exporter to check from the outside that https://www.rutasnorte.example responds.
Your own instrumentation: the only thing that knows about the business
None of the four previous sources can tell you how many tickets you have sold. Only your code knows that. It is the next section.
The visual summary
flowchart TB
subgraph Node["Node rutas-norte-worker-2"]
subgraph Pod1["Pod bookings-api"]
APP["api container<br/>its own /metrics"]
end
subgraph Pod2["Pod bookings-postgres-0"]
PG["postgres container"]
EXP["exporter sidecar<br/>:9187/metrics"]
EXP -.queries.-> PG
end
KUBELET["kubelet + cAdvisor<br/>real cgroup usage"]
NE["node-exporter<br/>DaemonSet<br/>/proc and /sys"]
end
KSM["kube-state-metrics<br/>state of the objects"]
API[(API Server)]
KSM -.queries.-> API
PROM[Prometheus]
APP --> PROM
EXP --> PROM
KUBELET --> PROM
NE --> PROM
KSM --> PROM
- Instrumenting
bookings-api with business metrics
bookings-api with business metricsYour own instrumentation is what separates an infrastructure dashboard from one the business cares about. "The pod is using 300 Mi of RAM" is of no interest to the Rutas Norte director. "We are confirming 12 bookings a minute, 40 % fewer than yesterday at this time" is.
bookings-api is in Node.js, so we use the official client library prom-client. There are equivalent libraries for Java, Go, Python, .NET and practically any language.
// metrics.js — instrumentation for bookings-api
const client = require('prom-client');
// The registry is the container for all this process's metrics.
const registry = new client.Registry();
// Labels added to ALL of this process's metrics.
// They come from the Downward API (03-03), so each pod identifies itself.
registry.setDefaultLabels({
component: 'bookings-api',
environment: process.env.ENVIRONMENT || 'dev',
version: process.env.APP_VERSION || 'unknown',
});
// Default runtime metrics: heap memory, GC, event loop, and so on.
// Very useful and free: a single line.
client.collectDefaultMetrics({ register: registry });
// ---------------------------------------------------------------------------
// 1. COUNTER: HTTP requests served.
// LOW-cardinality labels: normalised route (not the real URL),
// method and code. Never the booking id or the customer's ID number.
// ---------------------------------------------------------------------------
const requestsTotal = new client.Counter({
name: 'rutasnorte_requests_total',
help: 'Total HTTP requests served by bookings-api',
labelNames: ['route', 'method', 'code'],
registers: [registry],
});
// ---------------------------------------------------------------------------
// 2. HISTOGRAM: request duration.
// The buckets are chosen for OUR real latency: most requests take
// between 20 and 200 ms, and the SLO is 500 ms.
// Badly chosen buckets give useless percentiles.
// ---------------------------------------------------------------------------
const requestDuration = new client.Histogram({
name: 'rutasnorte_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['route', 'method'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
registers: [registry],
});
// ---------------------------------------------------------------------------
// 3. BUSINESS COUNTER: bookings confirmed and paid for.
// This is THE metric the business looks at. If it drops to zero, it does
// not matter that every pod is Running: the platform is not selling.
// ---------------------------------------------------------------------------
const bookingsConfirmed = new client.Counter({
name: 'rutasnorte_bookings_confirmed_total',
help: 'Bookings confirmed and paid for successfully',
labelNames: ['source', 'journey_type'], // 'web'|'mobile', 'national'|'regional'
registers: [registry],
});
// ---------------------------------------------------------------------------
// 4. GAUGE: state of the PostgreSQL connection pool.
// It is exactly the figure that in 07-01 caused the invisible incident.
// Now, as well as readiness detecting it, it will be recorded.
// ---------------------------------------------------------------------------
const poolConnections = new client.Gauge({
name: 'rutasnorte_pool_connections_active',
help: 'Connections currently in use from the PostgreSQL pool',
registers: [registry],
collect() {
// collect() runs at scrape time: always a fresh value.
this.set(pgPool.totalCount - pgPool.idleCount);
},
});
// ---------------------------------------------------------------------------
// 5. COUNTER for the external payment gateway.
// It will let us tell "our code is failing" from "the provider is failing".
// ---------------------------------------------------------------------------
const gatewayCalls = new client.Counter({
name: 'rutasnorte_payment_gateway_calls_total',
help: 'Calls to the external payment gateway',
labelNames: ['result'], // 'ok' | 'error' | 'timeout'
registers: [registry],
});
module.exports = {
registry, requestsTotal, requestDuration,
bookingsConfirmed, poolConnections, gatewayCalls,
};And the middleware that wires it into Express:
// server.js
const express = require('express');
const m = require('./metrics');
const app = express();
// Middleware that measures EVERY request.
app.use((req, res, next) => {
// IMPORTANT: we use the router path (/api/bookings/:id), not the real
// URL (/api/bookings/48213). If we used the real URL, every booking
// would create a new series: guaranteed cardinality explosion.
const endTimer = m.requestDuration.startTimer();
res.on('finish', () => {
const route = req.route ? req.baseUrl + req.route.path : 'unknown';
endTimer({ route, method: req.method });
m.requestsTotal.inc({ route, method: req.method, code: res.statusCode });
});
next();
});
// The endpoint Prometheus will scrape.
app.get('/metrics', async (req, res) => {
res.set('Content-Type', m.registry.contentType);
res.end(await m.registry.metrics());
});
// In the business logic, when confirming a booking:
async function confirmBooking(booking) {
await saveToDatabase(booking);
m.bookingsConfirmed.inc({
source: booking.source,
journey_type: booking.isNational ? 'national' : 'regional',
});
}
app.listen(8080);Two decisions worth highlighting:
- Normalised route, not the real URL.
req.route.pathgives/api/bookings/:id, so every booking shares a series. Usingreq.originalUrlwould give/api/bookings/48213,/api/bookings/48214... one series per booking. It is the most frequent cardinality mistake in the world. /metricson the same port as the application, or on a separate one. Here we leave it on 8080 for simplicity. In production it is preferable to expose it on a different port (9090, say) that is not published on the Ingress, so that the metrics are not reachable from the internet. Therutas-norte-proNetworkPolicies (04-06) will have to allow traffic from the monitoring namespace to that port: remember that every new conversation needs its policy.
- Deploying the kube-prometheus-stack with Helm
Installing Prometheus by hand in Kubernetes means managing about twenty objects across Deployments, ConfigMaps, Services, RBAC and storage. The community has packaged all that into a Helm chart called kube-prometheus-stack.
Note: Helm is the Kubernetes package manager and we will study it thoroughly in 10-03. Here we use it as an installation tool; for now it is enough to understand that a chart is a parameterisable template of manifests and that
helm installgenerates and applies them.
# Add the community chart repository
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
# A dedicated namespace for all monitoring
kubectl create namespace monitoring
kubectl label namespace monitoring app.kubernetes.io/part-of=rutas-norteA values file adapted to Rutas Norte:
# k8s/base/monitoring/prometheus-values.yaml
prometheus:
prometheusSpec:
# Retention: 30 days or 45 GiB, whichever comes first. A size limit
# is essential: without it, the disk fills up and Prometheus dies.
retention: 30d
retentionSize: "45GiB"
# CRITICAL: by default, the operator ONLY collects ServiceMonitors that
# carry the label release=<release-name>. Setting this to false makes it
# collect those in any namespace with no special label.
# It is cause number 1 of "my ServiceMonitor does not show up".
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
ruleSelectorNilUsesHelmValues: false
# Persistent storage with the fast StorageClass from 05-04.
# Without this, Prometheus writes to an emptyDir and loses EVERYTHING on
# restart: exactly the problem we came here to solve.
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: rutasnorte-fast
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
resources:
requests:
cpu: "500m"
memory: "3Gi"
limits:
memory: "6Gi"
# External labels: they identify THIS Prometheus. Essential if one day
# we federate several clusters (11-05).
externalLabels:
cluster: rutas-norte
region: eu-west
grafana:
enabled: true # we configure it in 07-04
adminPassword: change-in-production
alertmanager:
enabled: true # we configure it in 07-04
# node-exporter: one pod per node, like the log collector from 06-02
nodeExporter:
enabled: true
kubeStateMetrics:
enabled: true
# In minikube, some control plane components are not reachable the way they
# are in a real cluster; we disable them to avoid targets showing up red.
kubeControllerManager:
enabled: false
kubeScheduler:
enabled: false
kubeEtcd:
enabled: false
kubeProxy:
enabled: falsehelm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--values k8s/base/monitoring/prometheus-values.yaml \
--wait --timeout 10mWhat we have just installed:
| Component | Type | Function |
|---|---|---|
| Prometheus Operator | Deployment | Translates the custom resources into Prometheus configuration |
| Prometheus | StatefulSet (created by the operator) | The server: it collects, stores and evaluates rules |
| Alertmanager | StatefulSet (created by the operator) | Routes and groups the alerts (07-04) |
| Grafana | Deployment | Visualization (07-04) |
| node-exporter | DaemonSet | Operating system metrics for each node |
| kube-state-metrics | Deployment | Metrics about the state of the API objects |
| Rules and dashboards | PrometheusRule and ConfigMaps |
A very complete set of ready-written alerts |
NAME READY STATUS RESTARTS AGE
alertmanager-monitoring-kube-pr-alertmanager-0 2/2 Running 0 3m
monitoring-grafana-6d84b8c7f9-w2xnk 3/3 Running 0 3m
monitoring-kube-pr-operator-7c9b4d5f68-hj4kp 1/1 Running 0 3m
monitoring-kube-state-metrics-59d7b8c644-pl3mv 1/1 Running 0 3m
monitoring-prometheus-node-exporter-4kx8n 1/1 Running 0 3m
monitoring-prometheus-node-exporter-9wq2t 1/1 Running 0 3m
monitoring-prometheus-node-exporter-mz7bd 1/1 Running 0 3m
prometheus-monitoring-kube-pr-prometheus-0 2/2 Running 0 3mAccess to the Prometheus interface:
kubectl -n monitoring port-forward svc/monitoring-kube-pr-prometheus 9090:9090
# Open http://localhost:9090
- The Prometheus Operator and its custom resources
Here is where what we announced in 06-07 materialises. The Prometheus Operator is the canonical example of the operator pattern: a controller that watches custom resources and reconciles the real state until it matches.
Without an operator, adding a new target to Prometheus means editing a prometheus.yml file, putting it into a ConfigMap, reloading the configuration and waiting. A manual, centralised and error-prone process.
With an operator, the team that deploys bookings-api creates a ServiceMonitor object alongside their own application, in their own namespace, and the operator detects it and regenerates the Prometheus configuration automatically. Monitoring becomes a decentralised responsibility, versioned in Git alongside the rest of the manifests.
flowchart LR
DEV["bookings-api team<br/>k8s/base/bookings-api/servicemonitor.yaml"] -->|kubectl apply| API[(API Server)]
API -->|watch| OP[Prometheus Operator]
OP -->|generates and updates| SEC["Secret with<br/>prometheus.yaml"]
SEC -->|mounted in| PROM[Prometheus pod]
OP -->|reloads| PROM
PROM -->|"GET /metrics"| SVC[Service bookings-api]
The five custom resources
| Resource | What it is for |
|---|---|
Prometheus |
Declares a Prometheus instance: replicas, retention, storage, which monitors it collects |
ServiceMonitor |
"Scrape the pods behind this Service" — the usual approach |
PodMonitor |
"Scrape these pods directly" — when there is no Service |
PrometheusRule |
Alerting rules and recording rules (we develop them in 07-04) |
Alertmanager |
Declares an Alertmanager instance (07-04) |
And AlertmanagerConfig, to configure alert routing per namespace. Also in 07-04.
alertmanagerconfigs.monitoring.coreos.com 2026-08-06T09:31:12Z
alertmanagers.monitoring.coreos.com 2026-08-06T09:31:12Z
podmonitors.monitoring.coreos.com 2026-08-06T09:31:13Z
prometheuses.monitoring.coreos.com 2026-08-06T09:31:13Z
prometheusrules.monitoring.coreos.com 2026-08-06T09:31:13Z
servicemonitors.monitoring.coreos.com 2026-08-06T09:31:14ZAs we saw in 06-06, all of these are CRDs. And like cert-manager's Certificate or VolumeSnapshot, they are handled with kubectl exactly like a Deployment.
- The Rutas Norte
ServiceMonitor and target diagnostics
ServiceMonitor and target diagnosticsThe Service with a named metrics port
An essential requirement: the ServiceMonitor references the port by name, so the Service must have it named.
# k8s/base/bookings-api/service.yaml
apiVersion: v1
kind: Service
metadata:
name: bookings-api
namespace: rutas-norte-pro
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
selector:
app: bookings-api # only app and environment in the selector,
environment: pro # as our convention dictates
ports:
- name: http # a name is mandatory for the ServiceMonitor
port: 80
targetPort: 8080
- name: metrics # the port Prometheus will scrape
port: 9090
targetPort: 9090The bookings-api ServiceMonitor
# k8s/base/bookings-api/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: bookings-api
namespace: rutas-norte-pro # it lives with the application, not with Prometheus
labels:
app: bookings-api
app.kubernetes.io/part-of: rutas-norte
spec:
# Which Services it selects. CAREFUL: these labels are compared with those
# of the Service object, NOT with those of the pods. It is the commonest error.
selector:
matchLabels:
app: bookings-api
environment: pro
# In which namespaces to look for those Services.
namespaceSelector:
matchNames:
- rutas-norte-pro
endpoints:
- port: metrics # the NAME of the Service port, not the number
path: /metrics
interval: 30s # how often it is scraped
scrapeTimeout: 10s # always lower than the interval
# Carries pod labels across to the metrics, so we can filter later
# by environment or by component in PromQL.
relabelings:
- sourceLabels: [__meta_kubernetes_pod_label_environment]
targetLabel: environment
- sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
# Drops metrics we are not interested in and that take up space.
# nodejs_gc_duration_seconds has high cardinality and little value.
metricRelabelings:
- sourceLabels: [__name__]
regex: 'nodejs_gc_duration_seconds.*'
action: dropThe ServiceMonitor for the bookings-postgres exporter
Since bookings-postgres is a StatefulSet with a headless Service (06-01), we need an additional Service specifically for metrics. A headless Service (clusterIP: None) works just as well as the source of a ServiceMonitor, because the operator uses the Endpoints, not the ClusterIP.
# k8s/base/bookings-postgres/service-metrics.yaml
apiVersion: v1
kind: Service
metadata:
name: bookings-postgres-metrics
namespace: rutas-norte-pro
labels:
app: bookings-postgres
environment: pro
type: metrics
spec:
clusterIP: None # headless: we do not need load balancing
selector:
app: bookings-postgres
environment: pro
ports:
- name: metrics
port: 9187
targetPort: 9187
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: bookings-postgres
namespace: rutas-norte-pro
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels:
app: bookings-postgres
environment: pro
type: metrics # tells it apart from the data Service (5432)
namespaceSelector:
matchNames:
- rutas-norte-pro
endpoints:
- port: metrics
interval: 30s
scrapeTimeout: 10sWith this, the sidecar we added in 06-04 starts feeding Prometheus. The promise is kept.
The NetworkPolicy you need
Our convention is clear: in rutas-norte-pro there is a deny-all and each conversation is authorised one at a time. Prometheus lives in monitoring and wants to talk to the metrics ports in rutas-norte-pro. Without a policy, every target will show up as down.
# k8s/environments/pro/netpol-allow-prometheus.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scrape
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app.kubernetes.io/part-of: rutas-norte
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
ports:
- protocol: TCP
port: 9090 # bookings-api metrics
- protocol: TCP
port: 9187 # bookings-postgres exporterVerifying that the target appears
In the Prometheus interface, Status → Targets. Or from the command line:
kubectl -n monitoring port-forward svc/monitoring-kube-pr-prometheus 9090:9090 &
curl -s localhost:9090/api/v1/targets | jq -r '
.data.activeTargets[] |
select(.labels.job | test("bookings-api|postgres")) |
"\(.labels.job)\t\(.scrapeUrl)\t\(.health)\t\(.lastError)"'bookings-api http://10.244.2.17:9090/metrics up
bookings-api http://10.244.1.23:9090/metrics up
bookings-postgres http://10.244.2.31:9187/metrics upDiagnosis: my target does not show up
This is the question of this lesson. A checklist in order of frequency:
| # | Cause | How to confirm it |
|---|---|---|
| 1 | The ServiceMonitor selector does not match the Service labels |
kubectl get svc bookings-api --show-labels |
| 2 | The namespaceSelector does not include the Service's namespace |
Check matchNames |
| 3 | The operator ignores the ServiceMonitor because the release label is missing |
Look at the Prometheus resource's serviceMonitorSelector |
| 4 | The endpoint's port name does not exist in the Service |
kubectl get svc bookings-api -o jsonpath='{.spec.ports}' |
| 5 | The Service has no Endpoints (readiness failing, 07-01) | kubectl get endpointslices -l kubernetes.io/service-name=bookings-api |
| 6 | A NetworkPolicy blocks the traffic from monitoring |
The target appears but with health: down and a connection error |
| 7 | The application does not expose /metrics on that port |
kubectl exec -it <pod> -- curl -s localhost:9090/metrics |
Point 3 deserves detail because it is treacherous. The Prometheus resource has a serviceMonitorSelector. If the chart left it as release: monitoring, it will only collect ServiceMonitors with that label, and yours will be ignored silently: no event, no error, it simply does not show up.
# See what the deployed Prometheus is selecting
kubectl -n monitoring get prometheus -o yaml | grep -A 8 serviceMonitorSelectorTwo fixes: set serviceMonitorSelectorNilUsesHelmValues: false in the values (which is what we did), or add the label release: monitoring to all your ServiceMonitors.
Checking directly whether the target is being scraped:
# Does the synthetic metric up exist for our job?
curl -s 'localhost:9090/api/v1/query?query=up{job="bookings-api"}' | jq '.data.result'An empty array means the target is not configured (problems 1–4). A result with value: ["...", "0"] means it is configured but does not respond (problems 5–7).
- PromQL from scratch and with purpose
PromQL is frightening at first and is simpler than it looks if you learn it in layers.
Layer 1: series selectors
The simplest query is a metric name:
It returns every series with that name: one for each label combination on each pod. To filter, you use braces:
The four label comparison operators:
| Operator | Meaning | Example |
|---|---|---|
= |
Exactly equal | {environment="pro"} |
!= |
Not equal | {container!=""} |
=~ |
Matches the regular expression | {code=~"5.."} |
!~ |
Does not match the regular expression | {route!~"/health|/ready"} |
They can be combined, with an implicit AND:
Reading it: requests to /api/bookings in production that returned a 5xx code.
Layer 2: range selectors
By adding [5m] you get, instead of one value per series, every value from the last 5 minutes:
This is called a range vector and cannot be plotted directly: it is raw material for the functions of the next level.
Layer 3: the functions you actually use
rate() — the most important function in PromQL.
It calculates the increment per second of a counter within a window. It is what turns a useless counter (184203) into a meaningful figure (12.4 requests per second).
Three things you need to know about rate():
- It only works with counters. Applying it to a gauge gives meaningless results.
- It automatically compensates for counter resets when a pod is recreated.
- The window must contain at least 4 samples. With
interval: 30s, the minimum reasonable window is[2m];[5m]is the usual choice. With[1m]and 30 s scrapes you would have two samples and erratic results.
increase() — the same as rate() but giving the total increment over the window instead of per second. It is literally rate() * window_seconds. More readable for human questions:
increase(rutasnorte_bookings_confirmed_total[1h])
# → "how many bookings have been confirmed in the last hour"sum by and sum without — aggregation.
rate() returns one series per pod and per label combination. You almost never want that: you want the service total.
# Requests per second for the whole service, aggregating every pod,
# but keeping the breakdown by response code
sum by (code) (rate(rutasnorte_requests_total{environment="pro"}[5m]))sum by (a, b) keeps only the a and b labels. sum without (pod, instance) keeps all but those. The second form tends to be more robust against change.
Other aggregations: avg, min, max, count, stddev, topk, bottomk.
topk() — the top N. Perfect for "who is eating the cluster?":
histogram_quantile() — percentiles from histograms.
histogram_quantile(
0.95,
sum by (le, route) (rate(rutasnorte_request_duration_seconds_bucket{environment="pro"}[5m]))
)Breaking it down, from the inside out:
rate(..._bucket[5m])→ the rate of increase of each bucket.sum by (le, route)→ sums the buckets of all the pods, keepingle(the bucket boundary) androute. Thelelabel is mandatory: without it,histogram_quantilecannot work. It is the commonest error with this function.histogram_quantile(0.95, ...)→ estimates the 95th percentile.
The result is in seconds: 0.412 means that 95 % of requests are resolved in under 412 ms.
Accuracy: the result is an estimate by linear interpolation within the bucket. If your buckets are [0.1, 0.5, 1] and the real p95 is at 0.42 s, the estimate can be quite far off. That is why we chose buckets matched to our real latency in section 5.
Arithmetic operators and comparison between metrics.
# Memory usage as a fraction of the configured limit
container_memory_working_set_bytes{namespace="rutas-norte-pro", container!=""}
/
kube_pod_container_resource_limits{namespace="rutas-norte-pro", resource="memory"}Prometheus matches series on both sides by their common labels. If the labels do not match, the result is empty: it is the cause of half the queries that "return nothing". on() and ignoring() let you control the matching.
The specific Rutas Norte queries
Requests per second by route:
Error rate (the proportion of 5xx over the total):
sum(rate(rutasnorte_requests_total{environment="pro", code=~"5.."}[5m]))
/
sum(rate(rutasnorte_requests_total{environment="pro"}[5m]))It returns a number between 0 and 1. Multiplied by 100 it is the percentage. A warning: if the denominator is 0 (no traffic), the result is NaN and disappears from the graph. That is correct behaviour: with no traffic there is no error rate to measure.
95th percentile of latency by route:
histogram_quantile(
0.95,
sum by (le, route) (rate(rutasnorte_request_duration_seconds_bucket{environment="pro"}[5m]))
)Bookings confirmed per minute (the business metric):
CPU saturation against the configured limit:
sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="rutas-norte-pro", container!=""}[5m]))
/
sum by (pod) (kube_pod_container_resource_limits{namespace="rutas-norte-pro", resource="cpu"})A value of 0.95 means the pod is using 95 % of its limit: an immediate candidate for throttling.
Confirming the throttling we could not see in 07-02:
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{namespace="rutas-norte-pro"}[5m]))
/
sum by (pod) (rate(container_cpu_cfs_periods_total{namespace="rutas-norte-pro"}[5m]))It is the fraction of scheduling periods in which the container was throttled. Above 0.25 (25 %) there is a real performance problem. This is exactly the query that would have diagnosed the bookings-api problem in 07-02 in ten seconds.
Comparing real consumption with the requests: the 07-02 recalibration, now automated:
# How much is used against how much is reserved. Values well below 1
# indicate oversizing; above 1, undersizing.
sum by (pod, container) (
container_memory_working_set_bytes{namespace="rutas-norte-pro", container!=""}
)
/
sum by (pod, container) (
kube_pod_container_resource_requests{namespace="rutas-norte-pro", resource="memory"}
)With Prometheus, this query over 30 days of history completely replaces the bash script from 07-02, and it also lets you ask for the real percentile using quantile_over_time.
Unavailable replicas (uses kube-state-metrics):
A target that is down:
Pods restarting in a loop:
PostgreSQL connections against the maximum (from the 06-04 sidecar):
External payment gateway failures:
sum(rate(rutasnorte_payment_gateway_calls_total{result=~"error|timeout"}[5m]))
/
sum(rate(rutasnorte_payment_gateway_calls_total[5m]))This query lets you tell "our platform is failing" from "the external provider is failing", which is a very valuable distinction at three in the morning.
- The four golden signals applied to Rutas Norte
The four golden signals, popularised by Google's SRE book, are the framework that avoids the mistake of monitoring everything and understanding nothing.
| Signal | What it measures | Question it answers |
|---|---|---|
| Latency | How long a request takes | Is it slow? |
| Traffic | How much demand there is | How much work are we doing? |
| Errors | What fraction fails | Is it failing? |
| Saturation | How full the scarcest resource is | How much headroom is left? |
One essential subtlety about latency: the latency of failing requests must be measured separately. A 500 error returned in 3 ms artificially lowers the average latency and can make a service that is failing massively look lightning fast.
Component-by-component application
| Component | Latency | Traffic | Errors | Saturation |
|---|---|---|---|---|
web-store |
nginx p95 | requests/s | 5xx ratio | CPU vs limit |
bookings-api |
p95 and p99 by route | requests/s | 5xx ratio + gateway failures | CPU vs limit, pool connections |
bookings-postgres |
transaction duration | transactions/s | deadlocks + errors | connections/max, size on disk |
redis-cache |
command latency | operations/s | cache miss ratio | memory vs maxmemory |
notifications-worker |
processing time per email | emails/min | failed emails | queue depth |
occupancy-reports |
run duration | runs/day | failed jobs | (not applicable) |
The specific queries for bookings-api, which will be the basis of the 07-04 dashboard:
# LATENCY (excluding errors, so as not to skew the figure)
histogram_quantile(0.95,
sum by (le) (rate(rutasnorte_request_duration_seconds_bucket{environment="pro"}[5m])))
# TRAFFIC
sum(rate(rutasnorte_requests_total{environment="pro"}[5m]))
# ERRORS
sum(rate(rutasnorte_requests_total{environment="pro", code=~"5.."}[5m]))
/ sum(rate(rutasnorte_requests_total{environment="pro"}[5m]))
# SATURATION: two axes, CPU and the connection pool
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{namespace="rutas-norte-pro"}[5m]))
/ sum by (pod) (rate(container_cpu_cfs_periods_total{namespace="rutas-norte-pro"}[5m]))
max(rutasnorte_pool_connections_active{environment="pro"}) / 20That last query closes a circle in the module: it is exactly the phenomenon that in 07-01 left bookings-api alive but useless. Back then we only had a readiness probe that took the pod out of rotation; now we also have the figure recorded and queryable after the fact.
Recording rules
Queries such as the p95 are expensive: they traverse many series. If a dashboard runs one every 15 seconds with 8 panels open, Prometheus suffers.
Recording rules precompute a query periodically and store the result as a new metric:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: rutas-norte-recording
namespace: monitoring
labels:
app.kubernetes.io/part-of: rutas-norte
spec:
groups:
- name: rutasnorte.golden-signals
interval: 30s
rules:
# Naming convention: level:metric:operation
- record: bookingsapi:latency_p95:5m
expr: |
histogram_quantile(0.95,
sum by (le, route, environment) (
rate(rutasnorte_request_duration_seconds_bucket[5m])))
- record: bookingsapi:requests:rate5m
expr: sum by (environment, route) (rate(rutasnorte_requests_total[5m]))
- record: bookingsapi:error_ratio:5m
expr: |
sum by (environment) (rate(rutasnorte_requests_total{code=~"5.."}[5m]))
/
sum by (environment) (rate(rutasnorte_requests_total[5m]))Now the dashboards and the alerts query bookingsapi:latency_p95:5m, which is a single already-computed series. In 07-04 we will use them heavily.
- Storage, retention and the limits of Prometheus
How it stores the data
Prometheus writes to its own time-series database (TSDB):
- New samples go into an in-memory block and into a write-ahead log (WAL) on disk, which protects against crashes.
- Every 2 hours, that block is persisted as an immutable directory on disk.
- A compaction process merges small blocks into larger ones.
- Blocks beyond the retention are deleted whole.
An important consequence: retention does not delete individual samples, it deletes complete blocks. With retention: 30d you may occasionally have a little more than 30 days.
Estimating the space
The accepted rule of thumb: between 1 and 2 bytes per sample after compression.
Bytes ≈ retention_seconds × active_series × bytes_per_sample / scrape_interval
Rutas Norte, estimate:
30 days = 2,592,000 s
~120,000 active series (the three environments + the system)
1.7 bytes per sample
30 s interval
2,592,000 × 120,000 × 1.7 / 30 ≈ 17.6 GBWith a 50 GiB PVC we have plenty, even with room for compaction (which needs temporary space). That is also why we set retentionSize: 45GiB: it is the emergency brake if cardinality grows more than expected.
Seeing the real cardinality:
# Total number of active series
prometheus_tsdb_head_series
# The 10 metrics with most series: the list of cardinality suspects
topk(10, count by (__name__)({__name__=~".+"}))This second query is the first one to run when Prometheus starts consuming too much memory.
Why Prometheus is not an eternal database
Three design limitations, all deliberate:
- Local storage, not distributed. The data lives on a pod's disk. There is no replication between instances.
- It does not scale horizontally by itself. Two Prometheus instances do not share data: each has its own.
- Long retention is expensive. Keeping two years locally means hundreds of GB on a fast disk and considerable memory consumption when querying.
For long retention and a multi-cluster view there are two projects that extend Prometheus:
| Project | Approach |
|---|---|
| Thanos | A sidecar uploads the blocks to object storage (S3); a query component transparently queries both local and remote data |
| Mimir (Grafana) | Receives the data via remote_write into a distributed, multi-tenant system |
Both allow years of cheap retention and querying several clusters at once. For Rutas Norte with a single cluster, 30 days is enough today; when module 11-05 tackles multi-cluster management, Thanos will be the natural evolution.
Configuring remote_write from the operator is straightforward:
prometheus:
prometheusSpec:
remoteWrite:
- url: https://mimir.rutasnorte.example/api/v1/push
writeRelabelConfigs:
# Send only what we really want to keep for years:
# the business metrics, not the infrastructure ones.
- sourceLabels: [__name__]
regex: 'rutasnorte_.*'
action: keepCommon Mistakes and Tips
1. Cardinality explosion. The most serious and most expensive mistake. Labelling with booking identifiers, ID numbers, client IPs or unnormalised URLs multiplies the series by thousands and brings Prometheus down for lack of memory. Before adding a label, ask yourself how many distinct values it can have. If the answer is "I don't know", do not add it.
2. Querying a counter without rate(). Plain rutasnorte_requests_total returns a value accumulated since the pod started. It is a graph that only goes up and means nothing. Always rate() or increase().
3. A rate() window that is too short. With interval: 30s, a rate(...[1m]) has two samples and produces noise or gaps. Rule: the window must be at least 4 times the scrape interval.
4. Forgetting le in the sum by before histogram_quantile. If you aggregate without keeping the le label, the function cannot reconstruct the histogram and returns NaN or nothing. It is failure number one with percentiles.
5. The ServiceMonitor selecting pod labels instead of Service labels. A ServiceMonitor's selector is compared with the labels of the Service object. Because in Rutas Norte the Services carry the same labels as the pods, it is easy not to notice until a Service has different labels.
6. The operator's release label. If the Prometheus resource has serviceMonitorSelector: {matchLabels: {release: monitoring}}, your ServiceMonitors without that label are ignored with no error message at all. Checking it is the first thing to do when a target does not show up.
7. Prometheus without persistent storage. Without storageSpec, the chart uses an emptyDir and every pod restart wipes the whole history. It is exactly the problem we came here to solve.
8. No retentionSize. With only retention: 90d, if cardinality grows the disk fills up, Prometheus goes into CrashLoopBackOff and you lose your monitoring exactly when you need it most. Always set a size limit, at 80–85 % of the PVC.
9. Alerting on summary metrics. They are not aggregatable across pods. If your alert uses the p99 of a summary with six replicas, the number means nothing useful.
10. Monitoring the node with container metrics. cAdvisor cannot see the node's filesystem or its load average. That is what node-exporter is for. Confusing them leads to alerts that never fire.
11. Forgetting the NetworkPolicies. In rutas-norte-pro there is a deny-all. If Prometheus cannot reach the metrics ports, every target shows up as down and the error (context deadline exceeded) is not remotely obvious.
12. Instrumenting too late. Business instrumentation is added in the code, so it requires a development cycle. The sooner it is built into "ready for production", the better. Improvising it during an incident is impossible.
Exercises
Exercise 1 — Diagnose a ServiceMonitor that does not work
The team has deployed redis-cache with an exporter and has created these manifests. Nothing shows up on the Prometheus targets page.
apiVersion: v1
kind: Service
metadata:
name: redis-cache-metrics
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: redis-cache
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
app: redis-cache
environment: pro
ports:
- port: 9121
targetPort: 9121
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: redis-cache
namespace: monitoring
spec:
selector:
matchLabels:
app: redis-cache
environment: pro
endpoints:
- port: metrics
interval: 30s- Identify three distinct errors.
- Write the corrected manifests.
- Write the PromQL or
curlcommand that confirms the problem is solved.
Exercise 2 — Write the queries for an incident
On the Saturday of the bank-holiday weekend, between 12:00 and 13:30, customers complained that the website was extremely slow and that some purchases failed. It is over, nobody took notes, and the director wants explanations on Monday.
Write the PromQL queries that answer each question. State which source (cAdvisor, kube-state-metrics, node-exporter, an exporter or your own instrumentation) each metric comes from.
- How many requests per second was
bookings-apiserving in that interval, compared with the previous Saturday? - What was the 99th percentile of
/api/bookingslatency during the incident? - Was
bookings-apisuffering CPU throttling? - Did
bookings-postgresrun out of available connections? - Did the external payment gateway fail, or was the failure ours?
- How many bookings were not confirmed compared with what was expected?
Exercise 3 — Instrument notifications-worker
notifications-worker consumes from a Redis queue and sends confirmation emails. At present it exposes no metrics. Design its instrumentation:
- List the metrics it should expose, with their full name, their type (
counter,gauge,histogram) and their labels, justifying the cardinality of each label. - Write the corresponding
ServiceMonitor(the worker has no Service because it receives no traffic: solve that problem). - Write the PromQL queries for the four golden signals for this component.
Solutions
Solution 1
1. The three errors.
Error A — The ServiceMonitor selector does not match the Service labels. The ServiceMonitor looks for Services with app: redis-cache and environment: pro, but the Service has app.kubernetes.io/name: redis-cache and app.kubernetes.io/part-of: rutas-norte. Not a single one matches. The Service's spec.selector (which does use app/environment) selects pods; it is not what the ServiceMonitor looks at.
Error B — The Service port has no name, and the endpoint references metrics. The ServiceMonitor looks for a port called metrics in the Service; the Service defines port 9121 with no name field. The operator cannot find the port and discards the endpoint.
Error C — The ServiceMonitor is in monitoring with no namespaceSelector. By default, a ServiceMonitor looks for Services in its own namespace. With it in monitoring and the Service in rutas-norte-pro, they will never find each other. Two valid fixes: move the ServiceMonitor to the application's namespace (preferable, it keeps monitoring next to the code) or add a namespaceSelector.
2. Corrected manifests.
apiVersion: v1
kind: Service
metadata:
name: redis-cache-metrics
namespace: rutas-norte-pro
labels:
app: redis-cache # the label the ServiceMonitor will look for
environment: pro
app.kubernetes.io/part-of: rutas-norte
type: metrics # tells it apart from the data Service (6379)
spec:
clusterIP: None
selector: # this selects PODS: only app and environment
app: redis-cache
environment: pro
ports:
- name: metrics # a NAME is mandatory
port: 9121
targetPort: 9121
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: redis-cache
namespace: rutas-norte-pro # alongside the application
labels:
app: redis-cache
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels: # compared with the SERVICE labels
app: redis-cache
environment: pro
type: metrics
namespaceSelector:
matchNames:
- rutas-norte-pro
endpoints:
- port: metrics # matches the port's name
path: /metrics
interval: 30s
scrapeTimeout: 10sAnd the NetworkPolicy, because in rutas-norte-pro there is a deny-all:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-redis-cache-scrape
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: redis-cache
environment: pro
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 91213. Verification.
kubectl -n monitoring port-forward svc/monitoring-kube-pr-prometheus 9090:9090 &
# Does the target exist and does it respond?
curl -s 'localhost:9090/api/v1/query?query=up{job="redis-cache-metrics"}' | jq '.data.result'[
{
"metric": {"__name__": "up", "job": "redis-cache-metrics",
"namespace": "rutas-norte-pro", "pod": "redis-cache-0"},
"value": [1754472000, "1"]
}
]"1" is the confirmation. If it gave "0", the target is configured but does not respond: check the NetworkPolicy and that the exporter is listening on 9121.
An additional check that the operator has picked it up:
Solution 2
A general note: every query uses the offset modifier or an explicit time range, since the incident is over. Here is the decisive advantage over kubectl top from 07-02: this data exists.
1. Traffic during the incident, compared with the previous Saturday.
Source: your own instrumentation in bookings-api.
# Current traffic (viewed over the incident's range in Grafana)
sum(rate(rutasnorte_requests_total{environment="pro"}[5m]))
# Comparison with exactly a week ago, overlaid
sum(rate(rutasnorte_requests_total{environment="pro"}[5m] offset 7d))
# Multiplication factor
sum(rate(rutasnorte_requests_total{environment="pro"}[5m]))
/
sum(rate(rutasnorte_requests_total{environment="pro"}[5m] offset 7d))A result of 5.8 means that on the bank-holiday Saturday there was almost six times more traffic than on a normal Saturday: consistent with what we know about bank-holiday peaks.
2. 99th percentile of /api/bookings latency.
Source: your own instrumentation (histogram).
histogram_quantile(0.99,
sum by (le) (
rate(rutasnorte_request_duration_seconds_bucket{
environment="pro", route="/api/bookings"}[5m])))Compare it with the p95 and with the mean (_sum / _count) to see whether the problem affected every request or only the tail:
rate(rutasnorte_request_duration_seconds_sum{environment="pro", route="/api/bookings"}[5m])
/
rate(rutasnorte_request_duration_seconds_count{environment="pro", route="/api/bookings"}[5m])If the mean is fine and the p99 is through the roof, the problem affects a subset (for example, the requests that touch the payment gateway). If both go up, it is systemic.
3. CPU throttling.
Source: cAdvisor / kubelet.
sum by (pod) (
rate(container_cpu_cfs_throttled_periods_total{
namespace="rutas-norte-pro", pod=~"bookings-api-.*"}[5m]))
/
sum by (pod) (
rate(container_cpu_cfs_periods_total{
namespace="rutas-norte-pro", pod=~"bookings-api-.*"}[5m]))A value sustained above 0.30 during the incident confirms that the CPU limit was holding the application back. A complementary one, to see whether it was pinned against the ceiling:
sum by (pod) (rate(container_cpu_usage_seconds_total{
namespace="rutas-norte-pro", pod=~"bookings-api-.*", container="api"}[5m]))
/
sum by (pod) (kube_pod_container_resource_limits{
namespace="rutas-norte-pro", pod=~"bookings-api-.*", resource="cpu"})(The second part comes from kube-state-metrics: it is the perfect example of why both sources are needed.)
4. PostgreSQL connections.
Source: the exporter sidecar from 06-04, plus the pool's own metrics.
# Connections in use against the configured maximum
pg_stat_database_numbackends{datname="bookings"} / pg_settings_max_connections
# The application pool, which is the one that ran out in the 07-01 case
max(rutasnorte_pool_connections_active{environment="pro"})
# Deadlocks: a symptom of severe contention
increase(pg_stat_database_deadlocks{datname="bookings"}[5m])A numbackends / max_connections close to 1 confirms that the database was saturated with connections.
5. Did the external gateway fail or did we?
Source: your own instrumentation.
# Gateway failure rate
sum(rate(rutasnorte_payment_gateway_calls_total{result=~"error|timeout"}[5m]))
/
sum(rate(rutasnorte_payment_gateway_calls_total[5m]))
# Breakdown: error or timeout? A timeout points to slowness at the provider
sum by (result) (rate(rutasnorte_payment_gateway_calls_total[5m]))Interpretation: if the gateway failure rate goes up to 40 % while our general error_ratio also rises, the failure is the provider's and they should be held to account. If the gateway is at 0.1 % failures and we are returning 5xx, the problem is ours. That distinction, impossible without instrumentation, is what you take to Monday's meeting.
6. Bookings that were not confirmed.
Source: your own instrumentation (business metric).
# Bookings confirmed during the hour and a half of the incident
increase(rutasnorte_bookings_confirmed_total{environment="pro"}[90m])
# The same on the previous Saturday at the same time
increase(rutasnorte_bookings_confirmed_total{environment="pro"}[90m] offset 7d)
# Estimated loss: bookings per minute now vs. a week earlier
sum(rate(rutasnorte_bookings_confirmed_total{environment="pro"}[5m])) * 60And a particularly revealing cross-check: if traffic multiplied by 5.8 but confirmed bookings only by 1.3, the difference between those two factors is a direct estimate of the business lost. That number, in euros, is the one that justifies the infrastructure budget.
Solution 3
1. The component's metrics.
| Name | Type | Labels | Cardinality | Rationale |
|---|---|---|---|---|
rutasnorte_emails_sent_total |
counter |
type, result |
3 × 3 = 9 | Volume and failures. type: confirmation, cancellation, reminder. result: ok, error, rejected |
rutasnorte_email_duration_seconds |
histogram |
type |
3 × 9 buckets = 27 | Send latency, including the call to the SMTP server |
rutasnorte_queue_pending |
gauge |
none | 1 | The key saturation metric: if the queue grows without stopping, the worker cannot keep up |
rutasnorte_queue_wait_seconds |
gauge |
none | 1 | Age of the oldest message in the queue: it measures the delay the customer perceives |
rutasnorte_retries_total |
counter |
reason |
4 | Retries by failure type: smtp_timeout, smtp_rejected, network, unknown |
rutasnorte_worker_loop_active |
gauge |
none | 1 | 1 if the consumption loop is alive. It also feeds the liveness probe from 07-01 |
Total cardinality: about 43 series per pod. Perfectly manageable.
Labels deliberately dropped, and why:
recipient(the customer's email address): unbounded cardinality and, above all, personal data in the monitoring system. Forbidden. We will come back to this in 07-05.booking_id: unbounded cardinality. That data belongs in the logs, not in the metrics. Metrics aggregate; logs give detail.- The full
error_message: unpredictable cardinality. It is replaced byreason, a closed set of categories.
2. A ServiceMonitor for a component with no Service.
notifications-worker receives no traffic, so it has no Service. Two valid options:
Option A (recommended): PodMonitor. It is exactly the resource designed for this case.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: notifications-worker
namespace: rutas-norte-pro
labels:
app: notifications-worker
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels: # here they ARE compared with POD labels
app: notifications-worker
environment: pro
namespaceSelector:
matchNames:
- rutas-norte-pro
podMetricsEndpoints:
- port: metrics # the port name in the CONTAINER spec
path: /metrics
interval: 30s
scrapeTimeout: 10sWith the port declared in the Deployment:
containers:
- name: worker
image: registry.rutasnorte.example/notifications-worker:3.4.2
ports:
- name: metrics
containerPort: 9100Option B: create a headless Service just for metrics and use a normal ServiceMonitor. It works, but it creates an object that adds nothing else. The PodMonitor is cleaner.
And the corresponding NetworkPolicy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-notifications-worker-scrape
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: notifications-worker
environment: pro
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- protocol: TCP
port: 91003. The four golden signals.
# ---- LATENCY: p95 of the time taken to send an email ----
histogram_quantile(0.95,
sum by (le, type) (
rate(rutasnorte_email_duration_seconds_bucket{environment="pro"}[5m])))
# ---- TRAFFIC: emails sent per minute ----
sum by (type) (rate(rutasnorte_emails_sent_total{environment="pro"}[5m])) * 60
# ---- ERRORS: fraction of failed sends ----
sum(rate(rutasnorte_emails_sent_total{environment="pro", result!="ok"}[5m]))
/
sum(rate(rutasnorte_emails_sent_total{environment="pro"}[5m]))
# ---- SATURATION: the queue, from three complementary angles ----
# a) Current queue depth
max(rutasnorte_queue_pending{environment="pro"})
# b) Is it growing? The derivative of the depth over the last half hour.
# Sustained positive = the worker cannot keep up and we need to scale.
deriv(rutasnorte_queue_pending{environment="pro"}[30m])
# c) The indicator the customer cares about most: how long the oldest email
# has been waiting. A customer who paid 10 minutes ago and has received
# nothing starts to doubt whether the purchase went through.
max(rutasnorte_queue_wait_seconds{environment="pro"})An additional coverage query, very valuable for the business: it relates two different components to detect lost emails.
# Bookings confirmed minus confirmation emails sent in the last hour.
# It should be practically zero. A large positive value means there are
# customers who have paid and received nothing.
increase(rutasnorte_bookings_confirmed_total{environment="pro"}[1h])
-
increase(rutasnorte_emails_sent_total{environment="pro", type="confirmation", result="ok"}[1h])This kind of query — one that cross-references metrics from two components to verify a business invariant — is among the most useful you can write, and in 07-04 we will turn it into an alert.
Conclusion
Rutas Norte has gone from remembering nothing to having memory, a language and the ability to ask. In this lesson we have:
- Understood the pull model and why it is superior to push in an environment as dynamic as Kubernetes.
- Learned the data model: series identified by name and labels, the permanent threat of cardinality, and the four metric types, with
histogramas the only one that allows percentiles to be aggregated across replicas. - Cleared up the classic confusion about where the metrics come from: cAdvisor measures the containers' real consumption, node-exporter the node's operating system, kube-state-metrics the state of the API objects, exporters translate third-party software — including the
bookings-postgressidecar we added in 06-04, now finally connected — and your own instrumentation is the only thing that knows about the business. - Instrumented
bookings-apiwith requests by route and code, latency as a histogram, confirmed bookings and the state of the connection pool, minding cardinality in every decision. - Deployed the kube-prometheus-stack and worked with the Prometheus Operator we announced in 06-07, creating
ServiceMonitors that live alongside each application and knowing how to diagnose why a target does not show up. - Written real PromQL:
rate,increase,sum by,topk,histogram_quantile, and the specific queries for each component's four golden signals. Among them, the one that confirms the throttling we could only suspect in 07-02. - Sized the storage and retention, and understood why Prometheus is not an eternal archive and when it will be time to look towards Thanos or Mimir.
But we still have a practical problem: all this lives in a spartan web interface where you have to type queries by hand, and nobody is looking at it at three in the morning. Having the data is worth nothing if nobody sees it and nobody gets the warning.
In 07-04 we will take that leap: with Grafana we will build the Rutas Norte dashboard, panel by panel, with variables that work for all three environments; we will write the platform's alert catalogue with PrometheusRule — including the one that predicts when the bookings-postgres disk will fill up before it happens; and we will configure Alertmanager so that the right alert reaches the right person, grouped, without noise and with a link to the procedure to follow.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
