In the previous lesson we taught Kubernetes to tell a live container apart from a healthy application. Rutas Norte already knows whether each component works. What it still does not know is how much it consumes. And that figure is not a luxury: in 03-04 we set the requests and limits of each component practically by eye, promising that we would calibrate them by observing real consumption. Without that data, half our cluster is reserving memory nobody uses while the other half suffers CPU throttling in silence.
This lesson introduces the first source of real consumption data in the cluster: metrics-server, a lightweight component that gathers CPU and memory usage from nodes and pods and publishes it inside the Kubernetes API itself. It is the piece that makes kubectl top work, and also the one that will make module 9's autoscaling work. We are going to understand exactly what it measures, where it gets the data from, and — just as importantly — what it cannot do, because those limitations are precisely the reason we will install Prometheus in the next lesson.
Contents
- Where metrics-server sits in the observability ecosystem
- Where the data comes from: kubelet, cAdvisor and the Summary API
- How it is published in the API: the aggregation layer and the
APIService - Installation: the minikube addon and the official manifest
kubectl top nodes: reading each column correctlykubectl top pods:--containers,-A,--sort-byand their subtleties- The essential limitations and why Prometheus is needed
- The central use case: recalibrating the Rutas Norte
requestsandlimits - Why the HPA and the VPA depend on this component
- Diagnosing the typical failures
- Common mistakes and tips
- Exercises
- Where metrics-server sits in the observability ecosystem
Before installing anything it is worth placing the piece, because it is the component that causes the most confusion in the module. Many people install it expecting a monitoring system and end up disappointed.
metrics-server is not a monitoring system. It is an internal cluster infrastructure component with a very specific and very narrow purpose:
To provide the Kubernetes API with the current CPU and memory consumption of nodes and pods, so that Kubernetes' own autoscaling mechanisms can make decisions.
Everything else — the fact that you can look at it with kubectl top — is a useful side effect.
| Aspect | metrics-server | Prometheus (07-03) |
|---|---|---|
| Design purpose | Feed the HPA and the VPA | Observe, query and alert |
| Metrics collected | CPU and memory only | Any exposed metric |
| History | None (~1–2 min in memory) | Weeks or months on disk |
| Queries | No query language | Full PromQL |
| Alerts | No | Yes, with Alertmanager |
| Storage | Volatile memory | Time-series database |
| Resource consumption | Very low (~50 Mi) | High (GB of RAM and disk) |
| Is it mandatory? | Practically yes | No, but essential in production |
| Granularity | 15 s by default | Configurable, typically 15–30 s |
The two coexist in any serious cluster: metrics-server because the HPA needs it, Prometheus because you need to know what happened yesterday.
A third common confusion: metrics-server does not replace the probes from 07-01. Probes answer "is it healthy?"; metrics-server answers "how much does it spend?". A pod can consume 5 millicores and be completely hung.
- Where the data comes from: kubelet, cAdvisor and the Summary API
So as not to treat metrics-server as a black box, we need to follow the data's path from the kernel to your terminal.
The origin: the kernel cgroups
When the container runtime creates the bookings-api container, the Linux kernel puts it in a control group (cgroup). The kernel accounts there, among other things:
cpuacct.usage: nanoseconds of CPU accumulated by that cgroup since it started.memory.current: bytes of memory in use right now by that cgroup.
Those are the primary data. Everything else is transport layers.
cAdvisor: the cgroup reader
cAdvisor (Container Advisor) is a Google component that reads the cgroups and translates them into meaningful container metrics. For years it has been built into the kubelet binary: it is not a separate pod and does not have to be installed. Every kubelet carries its own cAdvisor that examines the containers on its node.
The kubelet Summary API
The kubelet exposes what cAdvisor gathers on an authenticated HTTPS endpoint:
It returns a JSON with the consumption of the node and of every pod and container. A simplified excerpt:
{
"node": {
"nodeName": "rutas-norte-worker-1",
"cpu": { "time": "2026-08-06T09:14:20Z", "usageNanoCores": 842000000 },
"memory": { "workingSetBytes": 3221225472 }
},
"pods": [
{
"podRef": { "name": "bookings-api-7d9f8c4b5-x2klm", "namespace": "rutas-norte-pro" },
"containers": [
{
"name": "api",
"cpu": { "usageNanoCores": 187000000 },
"memory": { "workingSetBytes": 312475648 }
}
]
}
]
}Two fields deserve special attention, because they explain everything you will see later:
usageNanoCores: nanocores consumed. One core = 1,000,000,000 nanocores. The 187,000,000 in the example are 187 millicores, that is,187min Kubernetes notation.workingSetBytes: the "working set". It is resident memory minus the file cache pages the kernel could reclaim without trouble. It is the metric the kubelet uses to decide who to evict under memory pressure, and it is the one you will see inkubectl top. It is not the same as the RSS thatpsgives you, which is why the numbers sometimes do not match what you see inside the container.
metrics-server: the aggregator
metrics-server is a single Deployment (one replica in small clusters) that:
- Discovers the nodes by querying the Kubernetes API.
- Queries the Summary API of every kubelet every 15 seconds (the
--metric-resolutionparameter). - Keeps the last two points of each series in memory.
- Calculates the CPU rate by dividing the increment in accumulated nanocores by the elapsed time.
- Serves those values through the Kubernetes API.
Key point: it writes nothing to disk and keeps no history. If the metrics-server pod restarts, kubectl top stops working for about 30 seconds and then comes back, with no data about the past.
That split also explains why CPU takes a while to appear: since it is a rate, two samples are needed. A newly created pod shows no CPU until metrics-server has two readings of it.
- How it is published in the API: the aggregation layer and the
APIService
APIServiceHere is the elegant part of the design. metrics-server does not expose a port of its own for you to connect to. Instead, it registers itself inside the Kubernetes API by means of the API Aggregation Layer.
The mechanism is the same one we saw in 06-06 for CRDs, but through a different route: instead of the API Server storing the objects in etcd, it delegates the requests of a particular API group to an external service.
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1beta1.metrics.k8s.io
spec:
group: metrics.k8s.io # the API group being delegated
version: v1beta1
groupPriorityMinimum: 100
versionPriority: 100
service:
name: metrics-server # which Service the requests are forwarded to
namespace: kube-system
port: 443
insecureSkipTLSVerify: true # in test clusters; in production, caBundleThis means that when you run:
what happens underneath is a perfectly ordinary REST request to the Kubernetes API:
The API Server sees that the path belongs to the metrics.k8s.io group, consults its APIService table, and forwards the request to the metrics-server Service in kube-system. The response comes back to the client as if the API Server itself had produced it.
flowchart TD
U["kubectl top pods"] --> API[API Server]
API -->|"metrics.k8s.io group<br/>delegated by APIService"| MS[metrics-server<br/>Deployment in kube-system]
MS -->|"HTTPS :10250<br/>/stats/summary<br/>every 15 s"| K1[kubelet node 1]
MS -->|"HTTPS :10250"| K2[kubelet node 2]
MS -->|"HTTPS :10250"| K3[kubelet node 3]
K1 --> CA1[built-in cAdvisor]
K2 --> CA2[built-in cAdvisor]
K3 --> CA3[built-in cAdvisor]
CA1 --> CG1[(kernel cgroups)]
CA2 --> CG2[(kernel cgroups)]
CA3 --> CG3[(kernel cgroups)]
API -.->|"same API"| HPA[HorizontalPodAutoscaler]
The practical consequences of this design are important:
- Unified authentication and authorization: the RBAC we will study in 08-01 applies to
kubectl topexactly as it does tokubectl get pods. Anyone withoutgetpermission onpods.metrics.k8s.iowill see nothing. - A single entry point: no need to open ports or expose additional services.
- A specific fragility: if the
APIServiceisFalse(for example, because the metrics-server pod will not start), some API discovery operations slow down for the whole cluster. You will see warnings such ascouldn't get resource list for metrics.k8s.io/v1beta1. It is an annoying side effect worth recognising.
Checking the state of the APIService:
AVAILABLE: True is the sign that everything is fine. If it says False (MissingEndpoints) or False (FailedDiscoveryCheck), go straight to section 10.
- Installation: the minikube addon and the official manifest
In our practice cluster
Since module 1 we have been working with minikube on the rutas-norte profile, and we already listed metrics-server among the required addons. If you have not enabled it yet:
# Enable the addon on our profile
minikube addons enable metrics-server -p rutas-norte
# Check that the addon appears as enabled
minikube addons list -p rutas-norte | grep metrics-serverThe addon deploys the Deployment in kube-system with the configuration already adapted to minikube (including the flag from the next section).
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/metrics-server 1/1 1 1 2m18s
NAME READY STATUS RESTARTS AGE
pod/metrics-server-7bf7d58749-qk4hn 1/1 Running 0 2m18sWait between 30 and 60 seconds before expecting data: metrics-server needs at least two collection cycles.
In a real cluster
In a cluster that is not minikube you install it by applying the project's official manifest:
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yamlThat manifest creates, in kube-system: a ServiceAccount, a ClusterRole with read permissions on nodes and pods, the corresponding ClusterRoleBindings (including the delegation of authentication to the API Server), the Service, the Deployment and the APIService we saw earlier.
The --kubelet-insecure-tls flag and its warning
This is the point that blocks an installation most often. metrics-server connects over HTTPS to port 10250 of each kubelet. By default it verifies the kubelet's certificate against the cluster CA. The problem: in many installations (minikube, kubeadm without kubelet server certificate rotation, some managed clusters) the kubelet uses a self-signed certificate that is not signed by the cluster CA, or whose name does not match the node's IP.
The result: metrics-server cannot connect and kubectl top returns nothing. The solution that appears on every forum:
# Fragment of the metrics-server Deployment
spec:
template:
spec:
containers:
- name: metrics-server
args:
- --cert-dir=/tmp
- --secure-port=10250
- --kubelet-preferred-address-types=InternalIP,Hostname,InternalDNS,ExternalDNS
- --kubelet-use-node-status-port
- --metric-resolution=15s
- --kubelet-insecure-tls # <-- the flag in questionSecurity warning.
--kubelet-insecure-tlsdisables verification of the kubelet's certificate. The connection remains encrypted, but metrics-server no longer checks that the other end really is the node's legitimate kubelet. An attacker able to get in the middle of the control plane network could impersonate a kubelet and return fake metrics. In a cluster with an HPA, that is a real attack vector: made-up CPU metrics can trigger massive scaling (cost) or prevent necessary scaling (denial of service).It is acceptable in minikube and in development environments. It is not in
rutas-norte-pro. The correct solution in production is to enable kubelet server certificate rotation (--rotate-server-certificates=trueon the kubelet and approval of thekubernetes.io/kubelet-servingCSRs), so that certificates are signed by the cluster CA and verification works. In a managed cluster (EKS, AKS, GKE, which we will see in 10-06), the provider has already solved this and the flag is not needed.
The remaining arguments, briefly:
| Argument | What it is for |
|---|---|
--metric-resolution=15s |
How often the kubelets are queried. Do not go below 10 s: it saturates the kubelets |
--kubelet-preferred-address-types |
In what order to try addressing the node. InternalIP first is usually right |
--kubelet-use-node-status-port |
Use the port the node itself declares in its status, instead of assuming 10250 |
--cert-dir=/tmp |
Where it stores its own serving certificate |
kubectl top nodes: reading each column correctly
kubectl top nodes: reading each column correctlyWith metrics-server working, we can start looking.
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
rutas-norte-control 412m 20% 1854Mi 48%
rutas-norte-worker-1 1834m 45% 5218Mi 67%
rutas-norte-worker-2 2701m 67% 6944Mi 89%
rutas-norte-worker-3 287m 7% 1102Mi 14%How to read this correctly, column by column:
CPU(cores): millicores consumed right now by everything running on the node (system pods included).1834mmeans 1.834 cores busy continuously.CPU%: the percentage of the node's allocatable capacity (node.status.allocatable.cpu), not of total capacity nor of the sum ofrequests. This is an important detail:allocatable= total capacity minus what is reserved for the operating system and for the kubelet.MEMORY(bytes): the node'sworkingSetBytes. Repeating the subtlety from section 2: it does not include reclaimable page cache, so it is almost always lower than whatfree -mshows inside the node.MEMORY%: percentage ofallocatable.memory.
Checking a node's allocatable to verify the calculation:
kubectl get node rutas-norte-worker-2 \
-o jsonpath='{.status.allocatable.cpu}{"\t"}{.status.allocatable.memory}{"\n"}'With 4 allocatable cores and 2701m consumed: 2701 / 4000 = 67.5 %. It adds up.
An operational reading of the table above for Rutas Norte:
rutas-norte-worker-2is at 89 % memory. Dangerous: when the kubelet detects memory pressure it starts evicting pods, beginning with those withBestEffortandBurstableQoS that exceed theirrequests(go back over 03-05). We need to investigate what runs there and probably rebalance.rutas-norte-worker-3is practically empty at 7 % and 14 %. That points to a scheduling problem: perhaps it has a taint we had not anticipated, or the pods have affinity rules that exclude it (06-05).
Useful flags:
# Sort by CPU consumption, descending
kubectl top nodes --sort-by=cpu
# Sort by memory
kubectl top nodes --sort-by=memory
# Only the nodes with a specific label
kubectl top nodes -l node-role.kubernetes.io/worker=
# Without headers, for script processing
kubectl top nodes --no-headers
kubectl top pods: --containers, -A, --sort-by and their subtleties
kubectl top pods: --containers, -A, --sort-by and their subtletiesNAME CPU(cores) MEMORY(bytes)
bookings-api-7d9f8c4b5-x2klm 187m 305Mi
bookings-api-7d9f8c4b5-mn8pq 203m 318Mi
bookings-api-7d9f8c4b5-vr4tz 174m 297Mi
bookings-postgres-0 340m 1720Mi
redis-cache-0 28m 412Mi
web-store-6c8b9d7f4-hj3ks 12m 38Mi
web-store-6c8b9d7f4-lp9wx 9m 36Mi
notifications-worker-5f7c8b9d4-tz2mv 45m 186MiA critical difference from top nodes: here there are NO percentage columns. kubectl top pods shows no % at all, because it would not know what to calculate it against (against the limit? against the request? against the node?). You have to make that comparison yourself, and in section 8 we will.
--containers: a per-container breakdown
With the arrival of sidecars in 06-04, a pod is no longer a container. bookings-postgres-0 has the PostgreSQL container and the metrics exporter; notifications-worker has the worker and the log adapter. Adding them together hides valuable information:
POD NAME CPU(cores) MEMORY(bytes)
bookings-postgres-0 postgres 327m 1698Mi
bookings-postgres-0 metrics-exporter 13m 22Mi
notifications-worker-5f7c8b9d4-tz2mv worker 41m 171Mi
notifications-worker-5f7c8b9d4-tz2mv log-adapter 4m 15Mi
bookings-api-7d9f8c4b5-x2klm api 187m 305MiNow we know that the exporter sidecar costs 13m of CPU and 22Mi: cheap, as we expected. And that the log adapter costs 4m and 15Mi. These numbers matter because, multiplied by the number of replicas, an apparently harmless sidecar can consume a whole node in a large cluster.
Other options
# All the namespaces in the cluster, sorted by memory descending
kubectl top pods -A --sort-by=memory
# Only the pods of a specific component (using the label selector from 02-07)
kubectl top pods -n rutas-norte-pro -l app=bookings-api
# Every platform component, across the three environments
kubectl top pods -A -l app.kubernetes.io/part-of=rutas-norte --sort-by=cpu
# A specific pod, broken down
kubectl top pod bookings-postgres-0 -n rutas-norte-pro --containers
# Without headers and sorted, for a reporting script
kubectl top pods -n rutas-norte-pro --no-headers --sort-by=memory--sort-by only accepts cpu or memory. Watch out for one counter-intuitive behaviour: the sorting is done on the numeric values, but the output is printed in Kubernetes units, and 1024Mi comes after 2Gi alphabetically even though it is smaller. Trust the order --sort-by gives you, not the visual reading.
Quick comparison of commands
| Command | What it answers |
|---|---|
kubectl top nodes |
Which nodes are saturated? Is there an imbalance? |
kubectl top pods -A --sort-by=memory |
Who is eating the cluster's memory? |
kubectl top pods -n X --containers |
How much does each sidecar cost? |
kubectl top pods -l app=bookings-api |
Do all the replicas of a component consume the same? |
That last question is more useful than it looks. If out of six bookings-api replicas one consumes 800m and the rest 190m, you have a traffic distribution problem or a pathological request being served by that particular pod.
- The essential limitations and why Prometheus is needed
This section is the most important in the lesson, because it defines when you should not use this tool.
Limitation 1: there is no history. None.
metrics-server keeps the last two points of each series in memory. There is no database, no file, no configurable retention. Questions you cannot answer with kubectl top:
- "How much memory was
bookings-apiusing last night at 03:00, when it went down?" - "Has
bookings-postgresconsumption been growing over the last two weeks?" - "What was the CPU peak during the May bank-holiday weekend?"
All of them are exactly the questions asked after an incident, and for all of them metrics-server's answer is silence.
Limitation 2: it is instantaneous data with coarse resolution
With --metric-resolution=15s and CPU rate calculation windows of tens of seconds, a 3-second CPU spike does not show up. It is averaged out and disappears. For bookings-api, whose latency spikes are measured in seconds, that resolution is not enough to diagnose anything.
It also means that two consecutive runs of kubectl top can give the same value: if both fall inside the same collection cycle, you are reading the same sample.
Limitation 3: CPU and memory only
Nothing about disk, nothing about network, no application metrics. You will not know how many requests per second bookings-api serves, nor how many bookings have been confirmed, nor how many connections PostgreSQL has open. Nothing about the business.
Limitation 4: you cannot alert
There are no queries, no thresholds, no notifications. kubectl top is an interactive tool: somebody has to be sitting there typing it. At three in the morning nobody is sitting there.
Limitation 5: it does not reveal throttling
In 03-04 we saw that a container reaching its limits.cpu is not killed: it is throttled. The symptom is high latency with CPU consumption stuck at the limit. kubectl top shows you the millicores, but not the throttled periods counter (container_cpu_cfs_throttled_periods_total), which is the figure that confirms the diagnosis. You will only see that counter in Prometheus.
Summary table: what to ask whom
| Question | metrics-server | Prometheus |
|---|---|---|
How much is bookings-api consuming now? |
✅ | ✅ |
| How much was it consuming yesterday at 03:00? | ❌ | ✅ |
| Is its consumption growing over time? | ❌ | ✅ |
| How many requests per second does it serve? | ❌ | ✅ |
| Is it suffering CPU throttling? | ❌ | ✅ |
| What is the 95th percentile of latency? | ❌ | ✅ |
| Let me know if it goes above X | ❌ | ✅ |
| Autoscale by CPU | ✅ | ✅ (via an adapter) |
| Which node is most loaded right now? | ✅ | ✅ |
| Installation and operation cost | Trivial | Considerable |
The honest conclusion: metrics-server is a photograph; Prometheus is the film. You need both, for different reasons. The photo is free and always there; the film costs money, disk and maintenance, but it is the only one that tells you what happened.
- The central use case: recalibrating the Rutas Norte
requests and limits
requests and limitsThis is the legitimate and most valuable use of kubectl top, and the one that fulfils the promise made in 03-04.
A reminder of why it matters
requests: what the scheduler reserves on the node. It determines where the pod fits and what QoS it gets. Over-reserving wastes cluster (nodes at 30 % real usage and "full" as far as the scheduler is concerned); under-reserving sends pods to nodes where they do not really fit and makes them suffer.limits: the ceiling. Exceeding it on CPU causes throttling; exceeding it on memory causesOOMKilled.
The recalibration method
You do not recalibrate requests by looking once. The honest procedure:
- Observe over a representative period: at least a week, including a peak. For Rutas Norte you have to include a bank-holiday weekend or the start of the holidays.
- Take periodic samples, not a single reading. Since metrics-server keeps no history, this means a script that runs
kubectl topevery few minutes and accumulates the results. - Calculate percentiles, not averages. The average lies when there are spikes.
- Apply the criteria:
| Resource | Calculation criterion |
|---|---|
requests.cpu |
50th–70th percentile of observed usage. There is no need to reserve the peak: CPU is compressible |
limits.cpu |
2–4 times the request, or no limit if the component tolerates variability well |
requests.memory |
95th–99th percentile of observed usage, plus headroom. Memory is not compressible |
limits.memory |
Equal to or very close to the request, plus 20–30 % headroom |
The asymmetry between CPU and memory is fundamental and must be understood: if you go over on CPU, the system slows you down and you survive. If you go over on memory, the kernel kills you. That is why memory is sized by the high percentile and CPU by the middle one.
A collection script for a week
#!/bin/bash
# collect-usage.sh
# Samples the consumption of the Rutas Norte pods every 2 minutes.
# A deliberate limitation: this is a workaround because metrics-server keeps no
# history. In 07-03 we will replace it with Prometheus, which does this natively.
OUTPUT="/var/log/rutasnorte/usage-$(date +%Y%m%d).csv"
echo "timestamp,namespace,pod,container,cpu_millicores,memory_mib" > "$OUTPUT"
while true; do
NOW=$(date --iso-8601=seconds)
kubectl top pods -A --containers --no-headers \
-l app.kubernetes.io/part-of=rutas-norte 2>/dev/null |
while read -r ns pod container cpu mem; do
# Strip the suffixes: 187m -> 187, 305Mi -> 305
cpu_num="${cpu%m}"
mem_num="${mem%Mi}"
echo "$NOW,$ns,$pod,$container,$cpu_num,$mem_num" >> "$OUTPUT"
done
sleep 120
doneAn important note for beginners: the command above with -A returns the namespace as the first column, which is why read takes five fields. If you run it without -A, drop the ns variable.
And the analysis of the data collected:
# 95th percentile of bookings-api memory during the week
awk -F, '$4=="api" {print $6}' /var/log/rutasnorte/usage-*.csv |
sort -n |
awk '{v[NR]=$1} END {print "p95 memory: " v[int(NR*0.95)] " Mi"}'
# 70th percentile of bookings-api CPU
awk -F, '$4=="api" {print $5}' /var/log/rutasnorte/usage-*.csv |
sort -n |
awk '{v[NR]=$1} END {print "p70 CPU: " v[int(NR*0.70)] "m"}'Rutas Norte before and after
After two weeks of observation (including the 1 May bank-holiday weekend), these are the figures and the decisions:
| Component | requests from 03-04 |
p50 usage | p95 usage | New requests |
Verdict |
|---|---|---|---|---|---|
web-store |
cpu 200m / mem 256Mi | 11m / 37Mi | 24m / 42Mi | cpu 50m / mem 64Mi | Oversized 4x. nginx serving static files spends very little |
bookings-api |
cpu 100m / mem 128Mi | 185m / 302Mi | 640m / 458Mi | cpu 200m / mem 512Mi | Undersized. It explains the throttling nobody knew how to diagnose |
bookings-postgres |
cpu 500m / mem 1Gi | 335m / 1710Mi | 1250m / 1980Mi | cpu 500m / mem 2560Mi | Memory far too tight: a real risk of OOMKilled at peaks |
redis-cache |
cpu 100m / mem 512Mi | 27m / 410Mi | 55m / 486Mi | cpu 50m / mem 768Mi | CPU to spare; memory tight and growing with the cached seats |
notifications-worker |
cpu 200m / mem 256Mi | 43m / 180Mi | 380m / 240Mi | cpu 100m / mem 320Mi | Very variable: bursts when sending batches of emails |
occupancy-reports (CronJob) |
cpu 500m / mem 512Mi | 890m / 720Mi | 1100m / 810Mi | cpu 1 / mem 1Gi | It was falling short: the reports took twice as long because of throttling |
Three findings worth commenting on, because they are the typical ones:
bookings-apiwas requesting 100m and using 185m on average. WithBurstableQoS, that works while the node has room, but the scheduler was placing pods as if each one took 100m: it was overselling the node. And thelimits.cpuof 500m caused throttling at the 640m peaks, which explains the latency spikes nobody could attribute to anything. This is the most valuable finding of the whole exercise.web-storewas reserving 4 times what it uses. Multiplied by its replicas and by three environments, that is several cores and several GB of cluster reserved for nothing, and counting against the namespace'sResourceQuotatoo (03-04).- The
occupancy-reportsCronJob was suffering silent throttling. Itslimits.cpuof 1000m against a desired usage of 1100m meant the nightly report took 40 minutes instead of 18. Nobody complained because nobody was awake.
The recalibrated manifests
# k8s/environments/pro/bookings-api-resources.yaml
# Values based on two weeks of observation with kubectl top
# CPU: observed p70 as the request; generous limit to absorb the peaks
# Memory: p95 + 12% headroom, with the limit equal to the request (QoS Guaranteed
# for the business-critical component)
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
template:
spec:
containers:
- name: api
resources:
requests:
cpu: "200m" # was 100m — observed p50 185m
memory: "512Mi" # was 128Mi — observed p95 458Mi
limits:
cpu: "1500m" # was 500m — it capped the 640m of the p95
memory: "512Mi" # same as the request → QoS Guaranteed# k8s/environments/pro/web-store-resources.yaml
# An oversized component: we free up cluster by lowering the requests
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-pro
spec:
template:
spec:
containers:
- name: nginx
resources:
requests:
cpu: "50m" # was 200m — observed p95 24m
memory: "64Mi" # was 256Mi — observed p95 42Mi
limits:
cpu: "300m"
memory: "128Mi"A word of warning about the process: recalibrate in rutas-norte-pre first, with representative synthetic load, and only then take the values to rutas-norte-pro. Lowering a requests.memory all at once in production can make the scheduler put more pods on a node than really fit, with evictions afterwards.
And do not do it by hand indefinitely. In 09-02 we will see the VPA (Vertical Pod Autoscaler), which performs exactly this analysis continuously and can recommend or apply the values automatically.
- Why the HPA and the VPA depend on this component
Although module 9 develops autoscaling, the dependency must be made clear here, because it is metrics-server's whole reason for being.
When in 09-01 we write a HorizontalPodAutoscaler like this one:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: bookings-api
namespace: rutas-norte-pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: bookings-api
minReplicas: 4
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # 70 % of the REQUEST, not of the limitthe HPA controller, which lives inside the kube-controller-manager, queries exactly the same API as kubectl top:
Every 15 seconds by default. With the result, it calculates the average CPU usage of the Deployment's pods, divides it by the sum of their requests to get the utilisation percentage, and decides how many replicas there should be.
Two consequences that must be burned into your memory:
Consequence 1: without metrics-server, the HPA does not work. It does not fail noisily: it sits in the unknown state and does not scale. The symptom:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
bookings-api Deployment/bookings-api <unknown>/70% 4 20 4 12mThat <unknown> is the signature of metrics-server being unavailable. And kubectl describe hpa confirms it:
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True SucceededGetScale the HPA controller was able to get the target's current scale
ScalingActive False FailedGetResourceMetric failed to get cpu utilization: unable to get metrics for
resource cpu: unable to fetch metrics from resource metrics
API: the server could not find the requested resourceDuring the May bank-holiday weekend, with the HPA "installed" but no metrics-server, bookings-api would have stayed at 4 replicas while traffic multiplied sixfold.
Consequence 2: the HPA percentage is calculated against the requests. An averageUtilization: 70 with requests.cpu: 100m means scaling when it reaches 70 millicores. If you recalibrate the request to 200m, the same HPA will scale when it reaches 140 millicores. Changing the requests changes the autoscaling behaviour. That is why the correct order is: calibrate with kubectl top first (this lesson), configure the HPA afterwards (09-01).
The VPA (09-02) uses the same source, but with a history of its own that it maintains itself, precisely because metrics-server does not have one.
- Diagnosing the typical failures
error: Metrics API not available
It means the API Server cannot serve the metrics.k8s.io group. The diagnostic sequence:
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server False (MissingEndpoints) 3m0/1 with restarts: the pod starts but its own readiness fails. Off to the log:
Kubelet certificate error
E0806 09:22:14.882134 1 scraper.go:149] "Failed to scrape node" err="Get
\"https://192.168.49.2:10250/metrics/resource\": tls: failed to verify certificate:
x509: cannot validate certificate for 192.168.49.2 because it doesn't contain any IP SANs"
node="rutas-norte-worker-1"This is the case described in section 4. The development fix:
kubectl -n kube-system patch deployment metrics-server --type=json \
-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'Remember the warning: in rutas-norte-pro, the correct solution is kubelet server certificate rotation, not this patch.
Empty values just after start-up
W0806 09:15:02.114 Metrics not available for pod rutas-norte-pro/bookings-api-7d9f8c4b5-x2klm, age: 22.4s
error: metrics not available yetThis is not a failure. It is the expected behaviour during the first 30–60 seconds after starting metrics-server or after creating a new pod: two samples are needed to calculate the CPU rate. Wait a minute and try again. An awful lot of people reinstall metrics-server unnecessarily because they did not wait.
The node is not reachable
E0806 09:24:01 "Failed to scrape node" err="Get \"https://rutas-norte-worker-3:10250/metrics/resource\":
dial tcp: lookup rutas-norte-worker-3 on 10.96.0.10:53: no such host" node="rutas-norte-worker-3"metrics-server is trying to reach the node by its hostname and DNS does not resolve it. The fix: force the use of the internal IP.
A specific pod does not appear
The usual causes, in order of frequency:
- The pod has been alive for less than 30 seconds.
- The pod is
Pending: it has no running containers, so there is nothing to measure. - The node the pod lives on is unreachable for metrics-server (check the logs).
- You do not have RBAC permissions on that namespace.
Diagnostic summary table
| Symptom | Probable cause | Check |
|---|---|---|
Metrics API not available |
Pod down or APIService unavailable |
kubectl get apiservice v1beta1.metrics.k8s.io |
metrics not available yet |
Less than 60 s since start-up | Wait and retry |
x509: cannot validate certificate |
Self-signed kubelet certificate | metrics-server logs; --kubelet-insecure-tls flag |
no such host when scraping a node |
DNS resolution of the node name | --kubelet-preferred-address-types=InternalIP,... |
HPA showing <unknown> |
metrics-server not responding | kubectl top pods in the same namespace |
| A specific pod is missing | New pod or Pending |
kubectl get pod <name> -o wide |
Error from server (Forbidden) |
Insufficient RBAC | kubectl auth can-i get pods.metrics.k8s.io -n <ns> |
Common Mistakes and Tips
1. Expecting metrics-server to be a monitoring system. This is confusion number one. It keeps no history, it does not alert and it measures only two things. Installing metrics-server and considering observability solved leaves the platform almost as blind as before.
2. Confusing kubectl top with kubectl describe node. They are completely different data and they answer different questions:
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 3200m (80%) 6500m (162%)
memory 6144Mi (78%) 9216Mi (118%)These are the declared requests and limits, that is, what the scheduler has reserved. kubectl top shows real consumption. A node can be at 80 % of reservations and 20 % of real usage: that is precisely what we are correcting in section 8.
3. Recalibrating from a single reading. A kubectl top on a Tuesday at 11:00 tells you nothing about the May bank-holiday weekend. Measure for at least a week and use percentiles.
4. Lowering --metric-resolution below 10 s. It saturates the kubelets' Summary API, which in large clusters is an expensive operation. It does not improve data quality and it can degrade the node.
5. Leaving --kubelet-insecure-tls in production. I repeat it because it happens constantly: it gets added to unblock the installation and nobody ever removes it. In rutas-norte-pro it is a real risk, not a theoretical one.
6. Running more than one metrics-server replica without adjusting the configuration. For high availability you need to add --enable-aggregator-routing=true on the API Server and tune the anti-affinity. With one replica, the only consequence of it going down is losing kubectl top for a few seconds: acceptable in most cases.
7. Interpreting MEMORY(bytes) as the process RSS. It is workingSetBytes, which excludes reclaimable page cache. It will not match ps or free inside the container, and that is normal.
8. Using kubectl top to investigate a past incident. There is no past. If the incident is over, kubectl top shows you a healthy present and contributes nothing. That is literally the reason for the next lesson.
9. Forgetting that requests affect the HPA. Changing requests.cpu without reviewing the HPA's averageUtilization silently changes the scaling threshold.
10. Not checking the sidecars with --containers. Since 06-04 we have sidecars in several components. Adding them to the main container hides their cost and complicates sizing.
Exercises
Exercise 1 — Install, verify and walk the data's path
On your rutas-norte minikube:
- Enable the
metrics-serveraddon and verify that theAPIServiceends upAVAILABLE: True. - Obtain the metrics of
bookings-apiwithout usingkubectl top, talking directly to the aggregated API. - Explain exactly where that number came from: which component read it, from where, and by what route it reached your terminal.
Exercise 2 — Spot the imbalance and propose a recalibration
These are two weeks of data for notifications-worker in rutas-norte-pro:
50th percentile CPU: 43m 50th percentile memory: 180Mi
70th percentile CPU: 95m 95th percentile memory: 240Mi
95th percentile CPU: 380m 99th percentile memory: 268Mi
99th percentile CPU: 520m Maximum memory: 291MiIts current configuration:
- What QoS class does this pod have now? (go back over 03-05)
- Identify two serious problems in this configuration in the light of the data.
- Propose a new configuration and justify every value.
Exercise 3 — Diagnose an HPA that does not scale
During a traffic peak, bookings-api stays at 4 replicas despite having an HPA configured. A colleague passes you this information:
$ kubectl -n rutas-norte-pro get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
bookings-api Deployment/bookings-api <unknown>/70% 4 20 4 2d
$ kubectl top pods -n rutas-norte-pro
error: Metrics API not available
$ kubectl -n kube-system get pods -l k8s-app=metrics-server
NAME READY STATUS RESTARTS AGE
metrics-server-7bf7d58749-qk4hn 0/1 Running 0 18m- What is the causal relationship between the three symptoms?
- Write the sequence of commands you would run to get to the root cause.
- Assuming the log shows
x509: cannot validate certificate ... because it doesn't contain any IP SANs, what would you do inrutas-norte-devand what would you do inrutas-norte-pro?
Solutions
Solution 1
1. Enabling and verifying.
minikube addons enable metrics-server -p rutas-norte
# Wait for the pod to become ready
kubectl -n kube-system wait --for=condition=Ready pod \
-l k8s-app=metrics-server --timeout=120s
# Verify the APIService
kubectl get apiservice v1beta1.metrics.k8s.io2. Querying the aggregated API directly.
kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods" \
| jq '.items[] | select(.metadata.name | startswith("bookings-api")) |
{pod: .metadata.name,
cpu: .containers[0].usage.cpu,
memory: .containers[0].usage.memory}'You can also query a specific pod:
kubectl get --raw \
"/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods/bookings-api-7d9f8c4b5-x2klm" | jq3. The data's path, step by step.
- The Linux kernel accounts for the usage of the
apicontainer's cgroup incpuacct.usageandmemory.current. - cAdvisor, built into the kubelet binary on the node where that pod lives, reads those cgroup files.
- The kubelet exposes the aggregate at
https://<node-ip>:10250/stats/summary. - metrics-server queries that endpoint every 15 seconds, keeps the last two samples in memory and calculates the CPU rate as
(nanocores_t2 - nanocores_t1) / (t2 - t1). - metrics-server serves the result in the
metrics.k8s.io/v1beta1API group, exposed through a Service inkube-system. - The
APIServicev1beta1.metrics.k8s.iomakes the API Server delegate to that Service any request to that group. kubectlasks the API Server for/apis/metrics.k8s.io/v1beta1/..., which acts as a proxy, and returns the JSON.
The essential detail: as far as kubectl is concerned this is indistinguishable from a kubectl get pods. Same endpoint, same authentication, same RBAC.
Solution 2
1. QoS class.
For a pod to be Guaranteed, requests and limits must match on every resource of every one of its containers. Here memory does match (256Mi = 256Mi), but CPU does not (200m ≠ 250m). The QoS class is therefore Burstable: under node memory pressure, this pod is a candidate for eviction before any Guaranteed pod.
2. The two serious problems.
Problem A: severe CPU throttling. The limits.cpu is 250m, but the 95th percentile of desired usage is 380m and the 99th percentile reaches 520m. That means 5 % of the time the worker is throttled, and that 5 % is precisely when it sends the bursts of confirmation emails. The real business effect: purchase confirmation emails are delayed exactly when there are most purchases. On top of that, kubectl top would show consumption stuck at 250m without saying there is throttling: Prometheus would be needed to confirm it.
Problem B: risk of OOMKilled and an inflated CPU request. The limits.memory is 256Mi and the observed maximum is 291Mi. The limit has already been exceeded: that worker is being OOM-killed periodically, probably without anybody connecting it to the emails that never arrive. And in the other direction, the requests.cpu: 200m is well above the p50 of 43m: 200m are reserved of which 43m are used on average, wasting cluster.
3. Proposed configuration.
resources:
requests:
# CPU: observed p70 = 95m. CPU is compressible: reserve the middle value
# and let the limit absorb the email-sending bursts.
cpu: "100m"
# Memory: p99 = 268Mi, maximum 291Mi. We reserve above the observed
# maximum because memory is NOT compressible.
memory: "320Mi"
limits:
# 6x the request: comfortably covers the p99 of 520m and leaves room for
# the bursts. Being compressible, there is no risk of it "overspending".
cpu: "600m"
# 20% above the request: headroom over the observed maximum (291Mi)
# without allowing an uncontrolled leak.
memory: "384Mi"Justification in brief:
requests.cpudrops from 200m to 100m → 100m per replica are freed in the namespace'sResourceQuota.limits.cpurises from 250m to 600m → the throttling disappears during the bursts.requests.memoryrises from 256Mi to 320Mi → above the observed maximum of 291Mi: theOOMKilleds stop.limits.memoryat 384Mi → 20 % headroom that absorbs an unexpected peak but cuts off a memory leak before it affects the node.
A note on process: apply it in rutas-norte-pre first and confirm with kubectl top pods --containers over a week that consumption stays within the expected range. Also confirm that the restart counter (RESTARTS) stays at zero, which is the proof that the OOMKilleds have stopped.
Solution 3
1. Causal relationship.
The three symptoms are the same problem seen from three places, and the chain runs bottom-up:
metrics-server 0/1 (not ready, cannot scrape the kubelets)
↓
The metrics-server Service has no Endpoints (readiness failing)
↓
The v1beta1.metrics.k8s.io APIService goes to AVAILABLE: False
↓
kubectl top → "Metrics API not available"
↓
The HPA controller cannot read the CPU metrics
↓
HPA with TARGETS <unknown> → IT DOES NOT SCALE
↓
bookings-api stays at 4 replicas throughout the traffic peakAn important detail: the HPA does not fail noisily. It generates no visible error event on the Deployment and does not leave the object in an obviously broken state. It simply does nothing. That is why in 07-04 we will configure a specific alert on the HPA's ScalingActive condition.
2. Diagnostic sequence.
# 1. Confirm the state of the APIService (the link between the symptoms)
kubectl get apiservice v1beta1.metrics.k8s.io
# 2. See why the pod is not ready: the events and the Ready condition
kubectl -n kube-system describe pod -l k8s-app=metrics-server | tail -25
# 3. The root cause is usually in the log
kubectl -n kube-system logs -l k8s-app=metrics-server --tail=50
# 4. Confirm exactly what the HPA is saying
kubectl -n rutas-norte-pro describe hpa bookings-api | grep -A 10 Conditions
# 5. Verify connectivity to the kubelet from inside the cluster (optional)
kubectl -n kube-system exec deploy/metrics-server -- \
wget -q -O- --no-check-certificate https://192.168.49.2:10250/healthz3. Different action per environment.
In rutas-norte-dev — unblock quickly, acceptable risk:
kubectl -n kube-system patch deployment metrics-server --type=json \
-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl -n kube-system rollout status deployment/metrics-server
sleep 45
kubectl top nodesIn rutas-norte-pro — the flag is not acceptable. The correct solution is for the kubelet certificate to be signed by the cluster CA:
- Enable server certificate rotation on each kubelet:
-
Restart the kubelet on each node (
systemctl restart kubelet), which generates one CSR per node. -
Approve the pending CSRs of type
kubernetes.io/kubelet-serving:
In a real cluster this is automated with an approver (for example, kubelet-csr-approver), because the CSRs are renewed periodically.
- Verify that metrics-server works without the insecure flag:
kubectl -n kube-system get deploy metrics-server \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n'
# --kubelet-insecure-tls must not appear
kubectl top nodesAn essential preventive measure: the real problem is that this failure went unnoticed for 18 minutes during a traffic peak. In 07-04 we will create an alert that fires when the metrics APIService is unavailable or when an HPA has spent more than five minutes with ScalingActive=False. Nobody should find out about this because traffic collapses.
Conclusion
In this lesson we have installed Rutas Norte's first source of real consumption data and learned to read it with judgement:
- metrics-server is not a monitoring system but an infrastructure component whose purpose is to feed the HPA and the VPA. The fact that
kubectl topworks is a pleasant consequence. - The data travels from the kernel cgroups → cAdvisor inside the kubelet → the Summary API on port 10250 → metrics-server every 15 seconds → the aggregation layer's
APIService→ your terminal. Understanding that path turns every error message into a diagnosis. kubectl top nodesgives percentages of the node'sallocatable;kubectl top podsgives no percentages and the per-container breakdown with--containershas been essential ever since we got sidecars.- Its most valuable use today has been settling the debt from 03-04: we have recalibrated the
requestsandlimitsof the six components with real data, discovering thatbookings-apiwas undersized and suffering throttling, thatweb-storewas reserving four times what it uses, and that the nightly CronJob was taking twice as long as necessary because of a limit set too low. - And we have seen its hard limits: no history, no queries, no alerts, CPU and memory only, with a resolution of tens of seconds. For "what happened last night at three?" it is useless.
That last limitation is exactly the hook for the next lesson. Rutas Norte needs a system that remembers, that lets you ask and that knows how to warn. In 07-03 we will deploy Prometheus with the operator we announced in 06-07, we will finally connect the bookings-postgres metrics exporter sidecar we added in 06-04, we will instrument bookings-api with business metrics, and we will learn PromQL to answer questions we cannot even formulate today: how many bookings per minute are confirmed, what the 95th percentile of latency is and how long until the database disk fills up.
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
