metrics-server
metrics-server is the small aggregated API server that gives Kubernetes its own opinion of how much CPU and memory every Pod and Node is using right now. It scrapes the /stats/summary endpoint every kubelet already exposes — data that traces back to cAdvisor — on a short interval, keeps only the newest sample for each object in memory, and republishes that number through a genuine Kubernetes API, metrics.k8s.io/v1beta1, registered into the API server's aggregation layer the same way a custom or external metrics adapter is. Two things depend on that API constantly: kubectl top, which is little more than a formatted request against it, and the HorizontalPodAutoscaler's Resource-type metrics, which cannot compute a utilization percentage without it. Almost every cluster running a CPU- or memory-based HPA has metrics-server quietly doing this underneath, whether someone installed it deliberately or a managed offering shipped it already running. This page covers the architecture, the Deployment and flags you actually configure, the commands you run against it daily, sizing and running it for real, where it silently breaks, and exactly why it was never meant to replace Prometheus.
Picture a school nurse who walks the halls once every few seconds with a thermometer, checks whoever she passes, and writes the single newest number on a little whiteboard by her door — then wipes it clean the instant she writes the next one. She never keeps a folder of what anyone's temperature was an hour ago; ask her for history and she genuinely has nothing to hand you, only "here's the number as of right now." That whiteboard is exactly what metrics-server is for a cluster: fast, current, and perfect for the two questions that only ever need "right now" — "is this Pod running hot this second" (that's kubectl top) and "should we call in more copies because everyone's temperature just spiked" (that's the autoscaler). If what you actually want is the folder — weeks of readings a doctor can spot a pattern in — that's a different job, done by a different specialist entirely.
What metrics-server actually is, and the API it serves
☺ Like you're 10: One small program scrapes every kubelet, keeps only the newest number it heard, and hands that out through a real Kubernetes API — not a side door, the front door.
Strip away the name and metrics-server is a single Deployment, almost always running as one Pod in kube-system, whose entire job is a loop: call every kubelet's /stats/summary endpoint (data the kubelet itself gets from cAdvisor, built into it), extract each Pod's and Node's current CPU and memory figures, and hold only the latest value per object in memory — nothing is written to etcd, nothing is written to disk. It re-runs that loop every --metric-resolution interval, 15 seconds by default in current releases, so "current" really does mean current, not an average smoothed over any longer window.
What makes it a genuine part of the Kubernetes API surface rather than a bolt-on dashboard is the aggregation layer. metrics-server registers an APIService object for the group metrics.k8s.io/v1beta1, and from that point on, kube-apiserver forwards any request for that group straight to metrics-server's own Service instead of handling it locally — the same mechanism Autoscaling: HPA, VPA & Cluster Autoscaler covers for the custom.metrics.k8s.io and external.metrics.k8s.io APIs a Prometheus Adapter or KEDA registers the same way. Because it's a real aggregated API, kubectl get, RBAC, and every other API mechanic apply to it exactly as they would to Pods or Deployments; it just happens that the "storage" backing those objects is one process's memory, refreshed every scrape.
Installing it, and the flags you actually configure
☺ Like you're 10: A handful of settings decide whether it can even reach the kubelets it's supposed to be watching — get those wrong and the whole thing runs but reports nothing.
The upstream components.yaml installs a ServiceAccount, a ClusterRole granting get/list/watch on pods, nodes, and the nodes/stats subresource, a ClusterRoleBinding to system:auth-delegator so metrics-server can ask kube-apiserver to authenticate the requests it receives, a RoleBinding in kube-system for the extension-apiserver-authentication ConfigMap it needs to validate the aggregation layer's own proxy identity, the Deployment and Service, and the APIService object itself. All of that is boilerplate you rarely touch — the part worth reading closely is the container's args:
# the flags that matter, as they sit in components.yaml's Deployment
containers:
- name: metrics-server
args:
- --cert-dir=/tmp
- --secure-port=10250
- --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
- --kubelet-use-node-status-port
- --metric-resolution=15s # how often every kubelet gets re-scraped
# - --kubelet-insecure-tls # lab-only escape hatch — see below
resources:
requests: { cpu: 100m, memory: 200Mi } # tune upward with node/pod count — see "Resource sizing"The flag that trips up almost everyone the first time: kubelets on kind, minikube, and most bare kubeadm-bootstrapped clusters serve their stats endpoint over a self-signed certificate, and metrics-server verifies TLS by default. Without either a trusted CA in front of that certificate or the escape hatch below, every scrape fails silently and kubectl top just returns nothing.
# the components.yaml install — one Deployment, one Service, matching RBAC
$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
# patch in the lab-only flag for self-signed kubelet certs
$ 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-serverIt skips certificate verification entirely between metrics-server and every kubelet it talks to — a fine, common choice on a disposable kind or minikube cluster, and exactly what most managed clusters (EKS, GKE, AKS) avoid needing at all because their kubelets already carry certificates signed by a CA metrics-server trusts. On a self-managed production cluster, the honest fix is provisioning proper kubelet serving certificates — typically via a CSR auto-approver — not shipping this flag as a permanent workaround.
Day-to-day commands
☺ Like you're 10: A handful of commands cover almost everything: read the numbers, check the pipe is actually connected, and look at the raw data with nothing formatting it for you.
# the two commands that read straight from metrics-server $ kubectl top nodes $ kubectl top pods -A --sort-by=memory $ kubectl top pod checkout-7d9f4c-x8k2q -n checkout --containers # per-container, not just the pod total # is the API actually registered and healthy? $ kubectl get apiservice v1beta1.metrics.k8s.io $ kubectl describe apiservice v1beta1.metrics.k8s.io # read Status.Conditions when AVAILABLE is False # the raw API response, with no kubectl top formatting between you and it $ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes" | jq . $ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/checkout/pods" | jq . # metrics-server's own health, for when the two commands above go quiet $ kubectl -n kube-system get pods -l k8s-app=metrics-server $ kubectl -n kube-system logs deploy/metrics-server --tail=50
That describe apiservice command is worth reaching for before anything more exotic: an APIService stuck at AVAILABLE: False means kube-apiserver can't reach metrics-server's Service at all — a networking or TLS problem, not a scraping problem — and no amount of debugging kubelets will fix it.
What actually reads this data: kubectl top and the HPA
☺ Like you're 10: This tool doesn't have an opinion of its own — it just answers whenever kubectl top or the autoscaler asks it "how much, right now?"
kubectl top has no logic of its own beyond formatting: it sends a request to metrics.k8s.io and prints whatever comes back. The far more consequential consumer is the HorizontalPodAutoscaler's control loop, which reads exactly this API for any metric of type: Resource — a plain --cpu-percent or a resource.target.averageUtilization block cannot be computed without it. CKA's Workloads & Scheduling domain expects you to stand up a basic HPA against exactly this data source.
The VerticalPodAutoscaler's Recommender is the subtle exception worth knowing: it does keep history, but not because metrics-server gave it any — metrics-server still only ever hands over the latest sample. The Recommender polls that same latest-only API repeatedly over time and builds its own in-memory histogram from what it collected itself. History exists downstream of metrics-server here, never inside it.
The one gap that catches people early: a Resource-type target of averageUtilization is a percentage, and a percentage needs a denominator. A container with no requests.cpu set has nothing to divide by, so its HPA's TARGETS column reads <unknown> forever, regardless of how healthy metrics-server itself is — set the request, and the number appears on the next scrape.
Why it is not a monitoring system
☺ Like you're 10: The nurse's whiteboard is genuinely useful, but nobody diagnoses a slow illness by looking at one number written down once.
metrics-server answers exactly one question — "how much CPU and memory, right now" — for exactly two consumers that only ever need that instant. It cannot answer "what was this Pod's memory an hour ago," cannot fire an alert on a sustained trend, and knows nothing about anything that isn't CPU or memory: no request rate, no queue depth, no application-defined counter, no record of whether a Deployment's rollout is actually progressing. Observability on Kubernetes covers this contrast against Prometheus and kube-state-metrics in full, and Platform Engineering's Prometheus guide and SRE's Monitoring & Observability cover the real stack and the alerting theory built on top of it — this page won't re-derive any of that, only flag the boundary clearly enough that nobody mistakes metrics-server for having crossed it.
Resource sizing and running it for real
☺ Like you're 10: The nurse has to walk every hallway in the building — on a huge school, that walk itself needs real time and real energy budgeted for it.
metrics-server's own footprint scales with the cluster it's watching — more nodes and more Pods means more objects to scrape and hold in memory every interval — and it's easy to leave the request/limit values at whatever a quick install left them and forget to revisit them as a cluster grows. The consequence of under-sizing it is not a loud crash: a metrics-server that's CPU-throttled or OOMKilled simply falls behind or restarts, kubectl top quietly returns nothing for the affected objects, and any Resource-type HPA reading a stale or missing value holds its last-known replica count instead of erroring — exactly the kind of silent stall the autoscaling deep dive warns about from the HPA side. Treat metrics-server's own resources block as production infrastructure, not an install-and-forget add-on, and re-check it whenever the node count meaningfully changes.
Because each replica independently scrapes every kubelet and holds its own in-memory snapshot, metrics-server needs no leader election or shared state to run with more than one replica — any replica can answer a request correctly on its own. That makes high availability a matter of ordinary Kubernetes plumbing rather than anything metrics-server-specific: two replicas, a PodAntiAffinity so they don't land on the same node, and a PodDisruptionBudget so a voluntary drain never takes both out together. Upstream ships exactly this as a high-availability overlay alongside the base manifest — a natural fit for Kustomize's component model if you're already layering it into your own manifests.
# the shape of metrics-server's HA overlay — two independent, unrelated replicas
spec:
replicas: 2
template:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector: { matchLabels: { k8s-app: metrics-server } }
topologyKey: kubernetes.io/hostname
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: metrics-server, namespace: kube-system }
spec:
minAvailable: 1
selector: { matchLabels: { k8s-app: metrics-server } }Gotchas
☺ Like you're 10: A few things catch almost everyone once: certificates, blocked doors, a missing baseline number, and a fresh start with nothing in it yet.
TLS against self-signed kubelet certs. Covered above, but worth repeating because it's the single most common first-timer failure: no data, no errors visible in kubectl top, just silence — check kubectl -n kube-system logs deploy/metrics-server for the actual x509 error before assuming anything else is wrong.
A NetworkPolicy can block the scrape path outright. metrics-server has to reach every kubelet's secure port (10250) directly — not through a Service, not through the CNI's Pod network in the usual sense — and a default-deny egress NetworkPolicy applied to kube-system without an explicit allow for that traffic will produce the exact same silent, dataless symptom as a certificate problem.
A missing CPU request looks like a metrics-server outage but isn't one. As covered above, <unknown> on an HPA's TARGETS column most often means the target container never declared requests.cpu, not that metrics-server has stopped reporting — check the Deployment's resource block before you go looking at metrics-server's Pod health.
A restart means starting from genuinely nothing. Because there's no persistence by design, a rolling restart or an OOMKill doesn't just lose the newest sample — it loses every sample, and needs one full scrape cycle across the entire cluster before kubectl top or the HPA have anything to read again. On a large cluster that gap is real, if usually brief; it's one more reason the resource sizing above matters more than it looks like it should.
metrics-server vs. the alternatives
☺ Like you're 10: Almost every cluster uses the same nurse — but a few swap her out for someone who happens to already keep a folder.
| Implementation | What actually backs metrics.k8s.io | Where you'll meet it |
|---|---|---|
| metrics-server (upstream default) | Live kubelet scrape, in-memory, latest sample only | What almost every cluster runs, installed by hand or bundled by a managed offering |
prometheus-adapter serving metrics.k8s.io | A PromQL query against Prometheus's own TSDB, reshaped into the same API | OpenShift's cluster-monitoring-operator does exactly this by default — you still run kubectl top, but Prometheus is answering |
| Managed metrics-server (EKS add-on, GKE, AKS) | The same upstream project, with the cloud provider owning install and upgrade | Nothing about the API or its behavior changes — only who patches it |
Only one implementation can register the metrics.k8s.io APIService at a time — running metrics-server alongside a separately installed prometheus-adapter that also claims this group produces a conflict, not a fallback. Pick one, and if you're already committed to Prometheus for everything else, letting its adapter serve this API too trades metrics-server's simplicity for HPA decisions backed by real history and smoothing — a genuine, if heavier, trade worth knowing exists.
On a kind cluster: install metrics-server, patch in --kubelet-insecure-tls, and confirm with kubectl top nodes. Then delete the metrics-server Pod outright and immediately run kubectl top pods -A — watch it fail, then start succeeding again once the replacement Pod has had time for a full scrape cycle. Deploy a small app with no requests.cpu set, create an HPA against it with kubectl autoscale, and confirm TARGETS reads <unknown> — then set the request and watch the number appear on the next sync. Finish with kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes" | jq . and read the raw JSON kubectl top was formatting for you the whole time.
"People sometimes ask why I don't just point them at metrics-server when they want to know if last Tuesday's incident was a slow memory leak. I can't answer that from here — by the time they've asked the question, the number that mattered is already gone, overwritten a few hundred times over. That's not a flaw I'm covering for; it's the honest shape of the tool. I keep the real record — the storage, the observability history — precisely because something has to, and metrics-server was never trying to be that something. It does one small job, does it in fifteen seconds flat, and gets out of the way. I respect that more than I resent it."
Sol the Sloth: Before I let the autoscaler touch anything, I check one number: is metrics-server actually reporting for this Deployment? No point doing careful arithmetic on a value that isn't arriving.
Remy the Rabbit: kubectl top pods -n checkout --sort-by=memory. Half a second. If that comes back empty, something upstream of the autoscaler is already broken.
Gizmo: Or — hear me out — skip installing Prometheus entirely. metrics-server's already running, it's already got numbers, just build the on-call dashboard off that. One less thing to maintain!
Ellie the Elephant: Build it off a number that gets erased every fifteen seconds? The dashboard would show "now," refresh, and show a completely different "now" — no trend line, no way to tell a spike from a slope. That's not a dashboard, Gizmo, that's a flashlight with no memory.
Timmy the Turtle: And when someone asks "when did this start," the honest answer under Gizmo's plan is "we genuinely don't know" — for a system you'd actually be paged for. That gap doesn't show up until the incident you needed it for.
Recon the Robot: The HPA and I are both fine with "now, only." Neither of us is deciding anything that needs yesterday. A human debugging an incident is a very different consumer with a very different requirement.
Ellie: Exactly that. Run both. metrics-server for the two jobs that only need this instant, a real history behind it for every job that needs to remember. Neither one is optional once you actually rely on the cluster.
1. What does metrics-server actually scrape to get its numbers, and how often does it re-scrape by default? 2. What mechanism lets metrics.k8s.io behave like a real Kubernetes API even though nothing is stored in etcd? 3. An HPA's TARGETS column reads <unknown> and metrics-server is healthy — what's the most likely cause, and how do you fix it? 4. Why can metrics-server run with two replicas and no leader election, unlike many other highly-available Kubernetes components? 5. What exactly does the VerticalPodAutoscaler's Recommender get from metrics-server, and where does its own historical memory actually come from? 6. Name two questions metrics-server structurally cannot answer, and name the tool that can.
Check your answers
- It scrapes every kubelet's
/stats/summaryendpoint (itself backed by cAdvisor), by default every 15 seconds (the--metric-resolutionflag). - metrics-server registers an
APIServicefor themetrics.k8s.iogroup, and kube-apiserver's aggregation layer forwards requests for that group straight to metrics-server's Service — the same mechanism used by custom and external metrics adapters. - Almost always a missing
requests.cpuon the target container:averageUtilizationis a percentage and needs that request as its denominator. Setting the request resolves it on the next scrape, independent of metrics-server's own health. - Each replica independently scrapes every kubelet and holds its own in-memory snapshot, so any replica can answer correctly on its own — there's no shared state to coordinate, which is exactly what leader election exists to protect elsewhere.
- Only the latest sample, same as any other consumer — metrics-server itself never stores history. The Recommender's memory comes from polling that latest-only API repeatedly over time and building its own histogram, not from anything metrics-server retained.
- "What was this Pod's memory an hour ago" and "alert me if this trend keeps rising" are both structurally impossible for metrics-server — it holds no history at all. Prometheus (paired with kube-state-metrics for object-state questions) is the tool built to answer both.