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

  1. Where metrics-server sits in the observability ecosystem
  2. Where the data comes from: kubelet, cAdvisor and the Summary API
  3. How it is published in the API: the aggregation layer and the APIService
  4. Installation: the minikube addon and the official manifest
  5. kubectl top nodes: reading each column correctly
  6. kubectl top pods: --containers, -A, --sort-by and their subtleties
  7. The essential limitations and why Prometheus is needed
  8. The central use case: recalibrating the Rutas Norte requests and limits
  9. Why the HPA and the VPA depend on this component
  10. Diagnosing the typical failures
  11. Common mistakes and tips
  12. Exercises

  1. 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.

  1. 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:

https://<node-ip>:10250/stats/summary

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, 187m in 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 in kubectl top. It is not the same as the RSS that ps gives 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:

  1. Discovers the nodes by querying the Kubernetes API.
  2. Queries the Summary API of every kubelet every 15 seconds (the --metric-resolution parameter).
  3. Keeps the last two points of each series in memory.
  4. Calculates the CPU rate by dividing the increment in accumulated nanocores by the elapsed time.
  5. 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.

  1. How it is published in the API: the aggregation layer and the APIService

Here 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, caBundle

This means that when you run:

kubectl top pods -n rutas-norte-pro

what happens underneath is a perfectly ordinary REST request to the Kubernetes API:

kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods" | jq

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 top exactly as it does to kubectl get pods. Anyone without get permission on pods.metrics.k8s.io will see nothing.
  • A single entry point: no need to open ports or expose additional services.
  • A specific fragility: if the APIService is False (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 as couldn't get resource list for metrics.k8s.io/v1beta1. It is an annoying side effect worth recognising.

Checking the state of the APIService:

kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        41d

AVAILABLE: True is the sign that everything is fine. If it says False (MissingEndpoints) or False (FailedDiscoveryCheck), go straight to section 10.

  1. 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-server
| metrics-server              | rutas-norte | enabled ✅   | Kubernetes |

The addon deploys the Deployment in kube-system with the configuration already adapted to minikube (including the flag from the next section).

kubectl -n kube-system get deploy,pod -l k8s-app=metrics-server
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          2m18s

Wait 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.yaml

That 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 question

Security warning. --kubelet-insecure-tls disables 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=true on the kubelet and approval of the kubernetes.io/kubelet-serving CSRs), 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

  1. kubectl top nodes: reading each column correctly

With metrics-server working, we can start looking.

kubectl top nodes
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). 1834m means 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 of requests. This is an important detail: allocatable = total capacity minus what is reserved for the operating system and for the kubelet.
  • MEMORY(bytes): the node's workingSetBytes. Repeating the subtlety from section 2: it does not include reclaimable page cache, so it is almost always lower than what free -m shows inside the node.
  • MEMORY%: percentage of allocatable.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"}'
4        7808036Ki

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-2 is at 89 % memory. Dangerous: when the kubelet detects memory pressure it starts evicting pods, beginning with those with BestEffort and Burstable QoS that exceed their requests (go back over 03-05). We need to investigate what runs there and probably rebalance.
  • rutas-norte-worker-3 is 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

  1. kubectl top pods: --containers, -A, --sort-by and their subtleties

kubectl top pods -n rutas-norte-pro
NAME                                      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          186Mi

A 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:

kubectl top pods -n rutas-norte-pro --containers
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         305Mi

Now 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.

  1. 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-api using last night at 03:00, when it went down?"
  • "Has bookings-postgres consumption 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.

  1. The central use case: recalibrating the Rutas Norte requests and limits

This 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 causes OOMKilled.

The recalibration method

You do not recalibrate requests by looking once. The honest procedure:

  1. 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.
  2. Take periodic samples, not a single reading. Since metrics-server keeps no history, this means a script that runs kubectl top every few minutes and accumulates the results.
  3. Calculate percentiles, not averages. The average lies when there are spikes.
  4. 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
done

An 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:

  1. bookings-api was requesting 100m and using 185m on average. With Burstable QoS, 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 the limits.cpu of 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.
  2. web-store was 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's ResourceQuota too (03-04).
  3. The occupancy-reports CronJob was suffering silent throttling. Its limits.cpu of 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.

  1. 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 limit

the HPA controller, which lives inside the kube-controller-manager, queries exactly the same API as kubectl top:

GET /apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods

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:

kubectl -n rutas-norte-pro get hpa bookings-api
NAME           REFERENCE                 TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
bookings-api   Deployment/bookings-api   <unknown>/70%        4         20        4          12m

That <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 resource

During 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.

  1. Diagnosing the typical failures

error: Metrics API not available

kubectl top nodes
error: Metrics API not available

It means the API Server cannot serve the metrics.k8s.io group. The diagnostic sequence:

# 1. Does the APIService exist and is it available?
kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE                  AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   False (MissingEndpoints)   3m
# 2. Is the pod running and ready?
kubectl -n kube-system get pods -l k8s-app=metrics-server
NAME                              READY   STATUS    RESTARTS   AGE
metrics-server-7bf7d58749-qk4hn   0/1     Running   4          3m

0/1 with restarts: the pod starts but its own readiness fails. Off to the log:

kubectl -n kube-system logs -l k8s-app=metrics-server --tail=30

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

kubectl top pods -n rutas-norte-pro
W0806 09:15:02.114 Metrics not available for pod rutas-norte-pro/bookings-api-7d9f8c4b5-x2klm, age: 22.4s
error: metrics not available yet

This 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.

args:
  - --kubelet-preferred-address-types=InternalIP,Hostname,InternalDNS,ExternalDNS

A specific pod does not appear

The usual causes, in order of frequency:

  1. The pod has been alive for less than 30 seconds.
  2. The pod is Pending: it has no running containers, so there is nothing to measure.
  3. The node the pod lives on is unreachable for metrics-server (check the logs).
  4. 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:

kubectl describe node rutas-norte-worker-2 | grep -A 8 "Allocated resources"
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:

  1. Enable the metrics-server addon and verify that the APIService ends up AVAILABLE: True.
  2. Obtain the metrics of bookings-api without using kubectl top, talking directly to the aggregated API.
  3. 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:          291Mi

Its current configuration:

resources:
  requests:
    cpu: "200m"
    memory: "256Mi"
  limits:
    cpu: "250m"
    memory: "256Mi"
  1. What QoS class does this pod have now? (go back over 03-05)
  2. Identify two serious problems in this configuration in the light of the data.
  3. 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
  1. What is the causal relationship between the three symptoms?
  2. Write the sequence of commands you would run to get to the root cause.
  3. Assuming the log shows x509: cannot validate certificate ... because it doesn't contain any IP SANs, what would you do in rutas-norte-dev and what would you do in rutas-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.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        58s

2. 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}'
{
  "pod": "bookings-api-7d9f8c4b5-x2klm",
  "cpu": "187m",
  "memory": "312476Ki"
}

You can also query a specific pod:

kubectl get --raw \
  "/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods/bookings-api-7d9f8c4b5-x2klm" | jq

3. The data's path, step by step.

  1. The Linux kernel accounts for the usage of the api container's cgroup in cpuacct.usage and memory.current.
  2. cAdvisor, built into the kubelet binary on the node where that pod lives, reads those cgroup files.
  3. The kubelet exposes the aggregate at https://<node-ip>:10250/stats/summary.
  4. 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).
  5. metrics-server serves the result in the metrics.k8s.io/v1beta1 API group, exposed through a Service in kube-system.
  6. The APIService v1beta1.metrics.k8s.io makes the API Server delegate to that Service any request to that group.
  7. kubectl asks 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.cpu drops from 200m to 100m → 100m per replica are freed in the namespace's ResourceQuota.
  • limits.cpu rises from 250m to 600m → the throttling disappears during the bursts.
  • requests.memory rises from 256Mi to 320Mi → above the observed maximum of 291Mi: the OOMKilleds stop.
  • limits.memory at 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 peak

An 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/healthz

3. 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 nodes

In rutas-norte-pro — the flag is not acceptable. The correct solution is for the kubelet certificate to be signed by the cluster CA:

  1. Enable server certificate rotation on each kubelet:
# /var/lib/kubelet/config.yaml on each node
serverTLSBootstrap: true
rotateCertificates: true
  1. Restart the kubelet on each node (systemctl restart kubelet), which generates one CSR per node.

  2. Approve the pending CSRs of type kubernetes.io/kubelet-serving:

kubectl get csr
kubectl certificate approve <csr-name>

In a real cluster this is automated with an approver (for example, kubelet-csr-approver), because the CSRs are renewed periodically.

  1. 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 nodes

An 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 top works is a pleasant consequence.
  • The data travels from the kernel cgroupscAdvisor 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 nodes gives percentages of the node's allocatable; kubectl top pods gives no percentages and the per-container breakdown with --containers has been essential ever since we got sidecars.
  • Its most valuable use today has been settling the debt from 03-04: we have recalibrated the requests and limits of the six components with real data, discovering that bookings-api was undersized and suffering throttling, that web-store was 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved