metrics-server¶
metrics-server serves the
metrics.k8s.io API — the resource metrics the scheduler, kubectl top and
every HorizontalPodAutoscaler read from.
Nothing served that API before. kubectl top node returned an error, and any
HPA added to a workload would have sat at <unknown>/<target> indefinitely
without ever explaining why — one of those failures that produces no logs, no
events, and no clue, just a dash where a number should be.
Prometheus is not a substitute
It is easy to assume kube-prometheus-stack covers this. It does not. Prometheus scrapes into its own time-series store and answers PromQL. The resource metrics API is a separate, aggregated Kubernetes API that core controllers read directly. They are different consumers of the same underlying kubelet data.
At a glance¶
| Namespace | kube-system; kubelet-csr-approver in kubelet-csr-approver |
| Sync wave | 2; kubelet-csr-approver at 1, because the CSRs have to be approved first |
| Depends on | kubelet-csr-approver, or the kubelet certificates it verifies are never issued |
| If it is down | kubectl top and every HPA. Nothing else notices |
| Health check | kubectl top nodes → a row per node |
Verifying the kubelet instead of trusting it¶
metrics-server scrapes each kubelet over TLS. By default a kubelet serves a
self-signed certificate it generates itself, which metrics-server cannot
verify — so the near-universal shortcut is to run it with
--kubelet-insecure-tls and skip verification entirely. It is in every quickstart,
it is in most production clusters, and it makes the metrics path spoofable by
anything that can occupy a kubelet's address. It is also, to be fair, the flag
that makes the thing work in five minutes instead of an afternoon.
This cluster does it properly instead, which takes three pieces:
| Piece | Where | What it does |
|---|---|---|
serverTLSBootstrap: true |
ansible/templates/kubeadm.yaml.j2 |
The kubelet requests a serving certificate from the cluster CA instead of self-signing |
| kubelet-csr-approver | payload/platform/kubelet-csr-approver/ |
Approves those CSRs, under constraints |
--kubelet-certificate-authority |
payload/platform/metrics-server/ |
metrics-server verifies against the cluster CA |
Why the CSRs need an approver at all¶
Kubernetes deliberately does not auto-approve
kubernetes.io/kubelet-serving CSRs. Approving them blindly would let a
compromised node request a certificate for any name or address it liked, and
then impersonate another node. So the CSRs sit Pending forever unless
something decides — which is the correct default and also why so many clusters
end up reaching for --kubelet-insecure-tls and never looking back.
kubelet-csr-approver is that something, and it is constrained rather than permissive:
providerRegex: "^(odin|thor|loki|freya|heimdall|valkyrie)$"
providerIpPrefixes: [10.9.2.0/24]
maxExpirationSeconds: 86400
bypassDnsResolution: true
- Only those six node names are approvable. Anything else is denied, not ignored.
- The requested IP must be on the nodes' subnet.
- Certificates last a day, so a leaked one expires on its own.
- DNS resolution is bypassed because node names do not resolve here — they come from the Ansible inventory, not a resolver. The name regex and IP prefix are what constrain identity in its place.
Checking it worked¶
If CSRs are Pending, kubelet-csr-approver is not running or a node name does
not match the regex. If they are Denied, the regex or the IP prefix is wrong —
check the controller's logs before widening either.
Adding a node means editing the regex
A node whose name is not in providerRegex will have its CSR denied, keep its self-signed certificate, and go silently missing from kubectl top — the node is fine, the cluster is fine, and one row is simply absent from a table nobody reads carefully. Add it to the regex at the same time you add it to the inventory; the regex lives in payload/platform/kubelet-csr-approver/application.yaml.
Chart values¶
The kubelet flags are set by overriding the chart's defaultArgs rather than
appending through args. The default list already sets
--kubelet-preferred-address-types, and passing a flag twice leaves the winner
up to parsing order. That flag is InternalIP, because node hostnames do not
resolve here.
metrics-server runs two replicas spread across nodes, with a
PodDisruptionBudget of minAvailable: 1, so a node reboot does not take the
resource metrics API down with it.