Monitoring Kubernetes
MetricHarbor reads a cluster through its API server — the same endpoints kubectl uses. It
needs a read-only account with get and list on nodes and pods, and nothing else. No
secrets, no configmaps, no exec, no write verbs anywhere.
Every pod is its own series, labelled by namespace and name. There is no "average pod CPU" in this product, because an average across pods answers no question anybody has.
Which side should the agent run on?
Two arrangements, and the right one depends on whether you want a long-lived token on disk.
| In the cluster | Outside the cluster | |
|---|---|---|
| How | the Helm chart, agent/deploy/helm/ |
an agent you already run, reaching the API server over the network |
| Credential | the pod's own ServiceAccount token, rotated hourly by the kubelet | a long-lived token you create and store |
| Needs | a container image and somewhere to pull it from | nothing new |
In-cluster is the better arrangement and the reason is the token. Kubernetes projects a ServiceAccount token into the pod and rewrites it roughly every hour; the agent re-reads it on every request, so no token is ever stored anywhere. Outside the cluster there is no kubelet doing that, so you create a token that does not expire and put it in the agent's credential store. That is a real tradeoff, not a formality.
This page covers the outside arrangement, because it needs nothing you do not already have.
For in-cluster, see the chart's values.yaml (agent/deploy/helm/metricharbor-agent/);
the RBAC below is the same either way.
1. Create the read-only account
Run this with a kubeconfig that can create cluster roles — your own admin one.
kubectl apply -f - <<'YAML'
apiVersion: v1
kind: Namespace
metadata:
name: metricharbor
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: mh-collector
namespace: metricharbor
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mh-collector
rules:
- apiGroups: [""]
resources: ["nodes", "pods", "persistentvolumeclaims"]
verbs: ["get", "list"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["get", "list"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mh-collector
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mh-collector
subjects:
- kind: ServiceAccount
name: mh-collector
namespace: metricharbor
---
apiVersion: v1
kind: Secret
metadata:
name: mh-collector-token
namespace: metricharbor
annotations:
kubernetes.io/service-account.name: mh-collector
type: kubernetes.io/service-account-token
YAML
That is the whole grant. get and list, on two resource types and their metrics. A
monitoring account that can read secrets is a credential store with extra steps, and this one
cannot read them, cannot write anything, and cannot exec into a pod.
The Secret at the end is what produces a long-lived token. Since Kubernetes 1.24 a
ServiceAccount no longer gets one automatically — kubectl create token issues short-lived
ones instead, which is the right default and the wrong fit for an agent outside the cluster
that nothing is rotating for. Creating the Secret explicitly is the supported way to ask for a
token that does not expire.
Check the grant is what you think it is:
kubectl -n metricharbor get secret mh-collector-token -o jsonpath='{.data.token}' | head -c 20
kubectl auth can-i --as=system:serviceaccount:metricharbor:mh-collector list pods -A
kubectl auth can-i --as=system:serviceaccount:metricharbor:mh-collector get secrets -A
The last one must print no. If it prints yes, something else in the cluster is granting
more than this role does, and the account is not read-only.
2. Build a kubeconfig for it
Run this where
kubectlworks, with the admin kubeconfig from step 1. That is usually your workstation or the control plane node — not the agent host, which is a different machine and the reason the steps below say which is which.
The agent takes a kubeconfig, so assemble one from the token and the cluster's CA.
SERVER=https://172.30.139.201:6443 # your API server, as the AGENT host can reach it
CA=$(kubectl -n metricharbor get secret mh-collector-token -o jsonpath='{.data.ca\.crt}')
TOKEN=$(kubectl -n metricharbor get secret mh-collector-token \
-o jsonpath='{.data.token}' | base64 -d)
cat > /tmp/mh-kubeconfig.yaml <<YAML
apiVersion: v1
kind: Config
current-context: metricharbor
clusters:
- name: cluster
cluster:
server: ${SERVER}
certificate-authority-data: ${CA}
users:
- name: mh-collector
user:
token: ${TOKEN}
contexts:
- name: metricharbor
context:
cluster: cluster
user: mh-collector
YAML
chmod 600 /tmp/mh-kubeconfig.yaml
The CA goes straight from the Secret into the kubeconfig: both want it base64-encoded, so decoding it to a file and encoding it again achieved nothing except a second file to clean up.
SERVER is the part that catches people. A k3s kubeconfig at
/etc/rancher/k3s/k3s.yaml says https://127.0.0.1:6443, which is correct on the control
plane node and useless anywhere else. Use the address the agent host can actually reach.
Prove it works before involving MetricHarbor at all:
kubectl --kubeconfig /tmp/mh-kubeconfig.yaml get nodes
If that lists your nodes, the agent will succeed. If it does not, nothing downstream will, and the error here is far more direct than the one you would get later.
3. Give it to the agent
Two machines in this step. The kubeconfig is created where
kubectlruns and used where the agent runs. Each cleans up its own copy; neither can shred a file on the other.
Copy it across:
scp /tmp/mh-kubeconfig.yaml root@AGENT-HOST:/tmp/mh-kubeconfig.yaml
shred -u /tmp/mh-kubeconfig.yaml # on THIS machine, now that it is sent
Then, on the agent host, hand it over by file — never as an argument, since argv is
world-readable in /proc and lands in shell history:
# The agent runs as the metricharbor user and this file arrived owned by root at mode 0600.
# Without this you get "permission denied" on a file you can see perfectly well.
chown metricharbor:metricharbor /tmp/mh-kubeconfig.yaml
runuser -u metricharbor -- metricharbor-agent cred add k8s-monitor \
--type kubeconfig --kubeconfig-file /tmp/mh-kubeconfig.yaml
shred -u /tmp/mh-kubeconfig.yaml
The agent encrypts it under the machine key. Delete the plaintext afterwards — it has served its purpose and the copy in the store is the one that matters.
runuser -u metricharbor -- metricharbor-agent cred list
4. Create the target and add the check
In the UI:
- Credentials → Declare credential → Kubernetes kubeconfig, named exactly
k8s-monitor. The name is the join key between MetricHarbor and the agent's own store; the secret stays on the agent. - Targets → Add target → Kubernetes cluster.
- API server URL — the same
SERVERas above. - Cluster credential —
k8s-monitor. - Namespaces — leave empty for the whole cluster.
- Collected by — the agent you installed the credential on.
- API server URL — the same
- On the target, Add a check → Kubernetes cluster.
The agent picks it up on its next config poll, within a minute.
What it reads
| Endpoint | For |
|---|---|
/api/v1/nodes |
node readiness, cordon state, pressure conditions, kubelet version |
/api/v1/pods or /api/v1/namespaces/{ns}/pods |
pod phase, readiness, restarts, waiting containers |
/apis/metrics.k8s.io/v1beta1/pods |
per-pod CPU and memory |
Four GET requests against stable, versioned endpoints. The agent speaks plain HTTP to them
rather than embedding client-go, which would add tens of megabytes to a binary installed on
customer machines and tie the agent's releases to Kubernetes' own.
What you get
| Metric | Labels |
|---|---|
k8s.up |
— |
k8s.nodes_total, k8s.nodes_ready |
— |
k8s.pods_total |
— |
k8s.node.ready, k8s.node.unschedulable |
node |
k8s.node.pressure |
node, kind |
k8s.pod.ready |
namespace, pod |
k8s.pod.phase |
namespace, pod, phase |
k8s.pod.restarts |
namespace, pod |
k8s.pod.containers_waiting |
namespace, pod |
k8s.pod.cpu_cores, k8s.pod.memory_bytes |
namespace, pod |
k8s.deployment.replicas_wanted, …_ready, …complete |
namespace, deployment |
k8s.statefulset.replicas_wanted, …_ready, …complete |
namespace, statefulset |
k8s.daemonset.replicas_wanted, …_ready, …complete |
namespace, daemonset |
k8s.pvc.bound, k8s.pvc.capacity_bytes |
namespace, claim |
k8s.pvc.phase |
namespace, claim, phase |
k8s.service.endpoints_ready, k8s.service.has_endpoints |
namespace, service |
Workloads answer what pods cannot. "Three of twelve pods are not ready" is a fact that has to be translated before anyone can act on it; "the payments Deployment is running on two of its four replicas" is the same event, already in the terms you deployed it in. A Deployment scaled to zero on purpose counts as complete, not degraded, because the comparison is against what it was asked for rather than against nothing.
A Service is included for one reason: a Service with no ready endpoints is a black hole. Requests to it fail and nothing about the Service object says so — it is a name and a selector, and it exists whether or not anything answers. The count comes from EndpointSlices, summed per Service, so several slices backing one Service give one series and not three.
There is no discovery step. Tools whose datapoints have no label dimension need one — a
script that enumerates pods, then token substitution to create a datapoint per pod. Here one
collection emits every pod as its own series in one pass, because the series identity already
includes the labels. If you have used Active Discovery and ##WILDVALUE## elsewhere and are
looking for the equivalent: there is nothing to configure, and that is not an omission.
Options on the check
| Namespaces | Empty means every namespace. Narrow it on a large cluster: one series per pod is a lot of pods. |
| Namespaces for pods / Deployments / StatefulSets / DaemonSets / Services / volume claims | Each overrides the list above for that resource alone. Empty means "use the list above". |
| Per-pod metrics | Off leaves the cluster-wide counts only. |
| CPU and memory usage | Needs metrics-server. Off if you do not run it. |
Scoping each resource separately
The usual shape is not one namespace list but several: every Deployment in the cluster, because that is where the meaning is, and pods from one namespace only, because that is where the series count explodes. The per-resource fields exist for exactly that:
| Field | Set to |
|---|---|
| Namespaces | (empty — every namespace) |
| Namespaces for pods | prod |
| Namespaces for StatefulSets | databases |
Deployments, DaemonSets, Services and claims fall through to the general list, so they are
collected cluster-wide; pods come from prod and StatefulSets from databases.
There is no scoping expression — no deployments:*, statefulsets:prod. It would be shorter and
it would also be a small language to parse, document and get wrong, which is the thing this
product is deliberately not growing. Six dull fields need learning once, and only by people
who need them.
When it is wrong
| What you see | What it means |
|---|---|
connection naming the API server |
Wrong address, a firewall, or 127.0.0.1 copied from a k3s kubeconfig. |
permission_denied on nodes or pods |
The ClusterRoleBinding did not apply, or names the wrong namespace. |
permission_denied naming one resource — deployments, endpointslices, claims |
A role written for an earlier agent. The message names the grant to add. The check keeps working: nodes and pods still collect, and only the resource named is missing. |
missing_dependency, CPU and memory unavailable |
metrics-server is not installed. The check is still working — phase, readiness and restarts are collected. Install metrics-server or turn the option off, so the warning stops being noise. |
k8s.up is 1 but no pod series |
Per-pod metrics turned off, or the namespace list names namespaces that do not exist. |
| 401 after weeks of working | The token was a short-lived one. kubectl create token issues those; the Secret in step 1 is what produces a lasting one. |
A node that is NotReady is not an error — it is the collector working. k8s.nodes_ready
drops below k8s.nodes_total, and k8s.node.ready is 0 for that node specifically.
Verified
The RBAC, endpoints, metric names and labels on this page were read from the collector's
source. The grant was checked with kubectl auth can-i against k3s v1.34.5.
Not yet verified end to end: no agent has collected a live cluster through this path at the
time of writing. If something here is wrong, it is in the steps between a working
kubectl --kubeconfig and a graph on screen.