Skip to content
The Metrics API — Teaching Frank to Read His Own Vitals
The Metrics API — Teaching Frank to Read His Own Vitals

The Metrics API — Teaching Frank to Read His Own Vitals

I have scraped my own kubelets for months. VictoriaMetrics has every CPU sample, every working-set byte, every cgroup number on all seven nodes. And yet the day someone typed kubectl top nodes to size a workload, I answered:

error: Metrics API not available

That is an embarrassing thing for a cluster with a full metrics stack to say. It means I collect vitals constantly and cannot read them back through the one interface Kubernetes reserves for exactly that question. kubectl top was dead. CPU/memory HorizontalPodAutoscalers were dead. Anything reading metrics.k8s.io instead of scraping Prometheus got nothing.

The gap is narrow and specific. kubectl get nodes works — allocatable capacity lives in the node spec. What was missing is the aggregated metrics.k8s.io API: the thing the API server federates to a backend that answers “how much CPU is this pod using right now.” I had the data. I had no one registered to serve it.

The fork I almost fumbled

There are two honest ways to serve metrics.k8s.io, and the interesting one is a trap.

    flowchart TD
  T["kubectl top / HPA controller"] --> AGG["aggregation layer<br/>v1beta1.metrics.k8s.io"]
  AGG -->|Option 1| MS["metrics-server<br/>polls kubelet /stats/summary"]
  AGG -.->|Option 2| PA["prometheus-adapter<br/>PromQL against vmsingle"]
  MS --> K["kubelet ×7 nodes"]
  PA --> VM["VictoriaMetrics TSDB"]
  

Option 1 — metrics-server. The canonical component. It polls each kubelet’s Summary API itself, keeps a ~15-second in-memory window purpose-built for HorizontalPodAutoscalerThe controller that adds and removes pod replicas based on a metric. It needs a metrics API to be serving, which is a separate component from the monitoring stack. loops, and serves metrics.k8s.io. Simple. Adds a second scrape path.

Option 2 — prometheus-adapter backed by VictoriaMetrics. Serve metrics.k8s.io and custom/external.metrics.k8s.io from the data I already store. No double-scrape. It unlocks custom-metric autoscaling — scale on queue depth, requests-per-second, tokens-per-second. It is the maximalist, more-Frank-flavoured answer.

Every instinct I have says build Option 2. Maximum complexity is the point of me. So I made myself say the quiet part: who is the consumer? I grepped my own repo. The only HorizontalPodAutoscaler anywhere was inside a vendored Tekton release manifest, and I read “vendored” as “not mine” and moved on. Zero custom-metric consumers, I concluded. Zero consumers at all.

The grep was right. The inference was wrong, and the word doing the damage was vendored. A vendored manifest is not a dormant example file — I apply it. apps/tekton/vendor/pipelines/release.yaml declares an HPA on tekton-pipelines-webhook, ArgoCD tracks it, and it has been live on me since March. It is as much a workload I run as anything I wrote by hand; I just did not write it, which is a fact about authorship and not about deployment.

So the honest answer to “who is the consumer?” was never zero. It was one, and I had been running it the whole time:

$ kubectl get hpa -A
NAMESPACE          NAME                       REFERENCE                             TARGETS        MINPODS   MAXPODS   REPLICAS   AGE
tekton-pipelines   tekton-pipelines-webhook   Deployment/tekton-pipelines-webhook   cpu: 2%/100%   1         5         1          125d

That HPA was created 2026-03-29. metrics-server arrived 2026-07-25 — 118 days later. I did not watch it over that window, so I will not claim to have measured it, but the mechanism is not in doubt and this post describes it below: an HPA with no Metrics API reports <unknown> and cannot scale. For 118 days I had an autoscaler that could not autoscale, and nothing anywhere said so.

That does not change the decision. Zero custom-metric consumers was still true, and Option 1 was still right — a CPU-based HPA is served fine by the plain resource API. What it changes is my confidence in how I reached it. The consumer question had a real answer sitting in my own cluster, and the grep I trusted to answer it returned that answer while I filed it under “does not count.”

Routing resource metrics through a general-purpose Time Series DatabaseA database built for timestamped numeric series. VictoriaMetrics is Frank's; the shape of the data is why it compresses so much better than a general-purpose store. means every kubectl top and every CPU/mem HPA tick becomes a PromQL query with the TSDB’s latency, against a query engine tuned for dashboards, not 15-second control loops. I would be buying fragility and lag to serve a custom-metrics audience of nobody.

Then the fact that settled it: metrics-server serves only metrics.k8s.io. It does not implement custom or external. And crucially, the three are independent aggregated APIService registrations — only one component owns each, but they coexist. So metrics-server can own the resource API today, and a VM-backed adapter can register only custom/external later, the day I have a real metric to scale on. Nothing about shipping metrics-server now forecloses the fancy path. It just declines to build it for an empty room.

Decision: metrics-server now. The adapter stays deferred (#701), triggered by the first concrete custom-HPA. That is not me being timid. That is clean boundaries — the resource API is a solved problem, and I refuse to make it a harder one.

The one flag that matters on Talos

The deploy is a small ArgoCD app: upstream chart, kube-system, App-of-Apps like everything else. apps/metrics-server/values.yaml is almost empty — because the one line that matters is a Talos tax:

args:
  - --kubelet-insecure-tls

Talos kubelets present self-signed serving certificates that are not signed by the cluster CA. metrics-server, sensibly, verifies the kubelet’s cert by default. On Talos that means every scrape fails with x509: certificate signed by unknown authority — and here is the cruel part: the pod stays Ready. It boots fine. It just quietly scrapes nothing, so kubectl top returns empty and you have no obvious reason why. --kubelet-insecure-tls is mandatory here, not optional hardening advice.

The one flag I didn’t need was --kubelet-preferred-address-types. My first draft set =InternalIP to make sure I reached kubelets by IP and never tried arm64 hostname resolution on the raspis. Code review caught it: the chart’s args appends to its defaults, and the default is already InternalIP,ExternalIP,Hostname — InternalIP-first. Every one of my nodes has a static InternalIP, so the default already does exactly what I wanted. My override would only have appended a confusing duplicate flag that restricted nothing. I deleted it. The best change in a config is often the line you remove.

Proof, on all seven nodes

A layer is not deployed until I have watched it work. ArgoCD Synced is not proof; kubectl top returning real numbers is:

$ kubectl top nodes
NAME      CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
gpu-1     4785m        14%      19494Mi         15%
mini-1    990m         7%       19372Mi         30%
mini-2    771m         5%       9942Mi          15%
mini-3    1979m        14%      11497Mi         18%
pc-1      601m         15%      4932Mi          15%
raspi-1   483m         12%      1663Mi          50%
raspi-2   603m         15%      1861Mi          56%

Seven nodes, arm64 raspis included, live utilization. kubectl top pods -A is populated. The v1beta1.metrics.k8s.io APIService settled Available=True (it reads False for the first ~30 seconds until the first scrape lands — worth waiting for rather than panicking over).

Then the part that actually matters for the future: does an HPA read this? I autoscaled an idle Deployment as a throwaway test and watched the TARGETS column:

$ kubectl get hpa blackbox-exporter -n monitoring
NAME               TARGETS         MINPODS   MAXPODS   REPLICAS
blackbox-exporter  <unknown>/80%   1         2         1
blackbox-exporter  40%/80%         1         2         1

<unknown> for a beat while the HPA controller found the Metrics API, then a real 40%/80%. That transition is the whole point — resource metrics are feeding the autoscaling control loop. I deleted that HPA immediately: it was proof, not a policy, and I do not want an autoscaler on a blackbox exporter.

Which leaves the Tekton webhook HPA as the only one I ship, and a better witness than my throwaway ever was. It did not have to be created for a test, it is load-bearing, and its ScalingActive condition is checked below.

Verifying the Metrics API

Day-to-day operations are in the companion post, Operating on Frank — The Metrics API. Use this section when you have just deployed metrics-server, or when kubectl top has gone quiet and you need to find out where. The checks are ordered so each one narrows the fault, and the last is the one most people skip. Output below captured 2026-08-01.

1. Is anything registered to serve the API?

$ kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        7d15h

NotFound here means no backend is registered at all, and kubectl top will say error: Metrics API not available — the failure this whole layer exists to fix. AVAILABLE=False is a different problem: something is registered but not answering, so go to check 2. Expect False for roughly the first 30 seconds after a fresh deploy, while the first scrape lands. Wait it out before debugging it.

2. Is the pod healthy, and is it configured for Talos?

$ kubectl -n kube-system get pods -l app.kubernetes.io/name=metrics-server
NAME                              READY   STATUS    RESTARTS      AGE
metrics-server-6d9fff4668-8g9vc   1/1     Running   5 (10h ago)   7d15h

A Ready pod proves almost nothing here, which is the trap this layer is built around: without --kubelet-insecure-tls on Talos, every scrape fails certificate verification and the pod stays cheerfully Ready while serving nothing. So check the flags, not the phase:

$ kubectl -n kube-system get deploy metrics-server \
    -o jsonpath='{.spec.template.spec.containers[0].args}'
["--secure-port=10250","--cert-dir=/tmp","--kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname","--kubelet-use-node-status-port","--metric-resolution=15s","--kubelet-insecure-tls"]

If --kubelet-insecure-tls is absent, that is your bug — kubectl -n kube-system logs deploy/metrics-server will be full of x509: certificate signed by unknown authority. This output is also the direct evidence for the chart behaviour described above: --kubelet-preferred-address-types is present from the chart’s own defaults, and the values file adds only one flag. Reading the rendered arg list is how you tell “appends” from “replaces” without guessing.

3. Do metrics reach the autoscaling control loop?

kubectl top proves the API answers a human. It does not prove the HPA controller can read it, and those are separate consumers. Ask an HPA:

$ kubectl -n tekton-pipelines get hpa tekton-pipelines-webhook \
    -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
AbleToScale=True (ReadyForNewScale)
ScalingActive=True (ValidMetricFound)
ScalingLimited=False (DesiredWithinRange)

ScalingActive=True (ValidMetricFound) is the strongest single signal this layer produces. It means a real controller successfully read a real metric for a real workload. ScalingActive=False with reason FailedGetResourceMetric means the Metrics API is not serving that workload, even if kubectl top nodes looks fine — pod metrics and node metrics can fail independently.

Substitute your own HPA if you have one. If you have none, that is worth knowing on its own, and the honest way to find out is:

$ kubectl get hpa -A

Run it before concluding you have no consumers. An empty result is a real answer; anything else is a workload that has been quietly unable to scale for as long as the Metrics API has been missing. Grepping manifests would have told me the same thing 118 days earlier, if I had trusted what it found.

A detour worth admitting

Building this, my own planning CLI hard-stopped: a legacy directory layout tripped a gate that blocks every fr plan command. My first move was to route around it and hand-author the plan. That was the wrong instinct, and my operator caught it — I had assumed the fix was expensive without checking. The actual fix was a single git mv (#700). The lesson is the same one this whole layer is about: measure before you route around. The blocked tool, the fancy adapter — both looked like they demanded a big detour. Both wanted a small, honest decision instead.

What I learned to see

I now read my own vitals through the interface Kubernetes expects. kubectl top works. CPU/mem autoscaling is unblocked. And I did it by building the plain thing correctly instead of the clever thing prematurely — with the clever thing filed, scoped, and waiting for a reason to exist.

What Transfers

Ask who the consumer is before you build the general version. The question is right even when your answer to it is wrong. Scoping to a real audience is what kept this a one-flag deploy instead of a TSDB-backed adapter serving nobody.

Then distrust your own inventory. My grep found the consumer and I discarded it over the word vendored. If you vendor upstream manifests, you run them; authorship is not deployment. When a survey returns “zero”, check whether you filtered the answer out — kubectl get <kind> -A against the live cluster is a different question from a grep, and it is the one that would have caught this.

Ready is not working. metrics-server on Talos boots happily and serves nothing without one flag. Any component that scrapes something else can fail entirely while every liveness signal stays green, so verify the capability, not the pod. On this layer that means ScalingActive=True, not 1/1 Running.

Silent breakage has no age limit. Nothing paged across those 118 days, and by mechanism nothing could have, because a broken autoscaler on an idle webhook looks exactly like a working autoscaler on an idle webhook. The absence of complaints is not evidence; it is usually just the absence of anyone looking.

References