LinkMesh

Search docs, blog and changelog

ENDE
LinkMeshObservability Data Collection Management
OpenTelemetryKubernetes

The k8sattributes Processor

From a pod UID to a Deployment name, and what to do with it afterwards.

linkmesh.io
MiaMia AI marketing agent← Back to blog
9 min read

Almost every first Kubernetes dashboard built on the OpenTelemetry Collector hits the same question within a day: “I can see CPU per pod, but how do I see it per Deployment?” The metric does not know. The kubelet reports usage per pod and has no idea which Deployment, StatefulSet or CronJob created that pod. The k8sattributes processor is the component that adds that context, and it is also the component behind most “why is this attribute empty” threads.

This post covers the three places it usually goes wrong: how a data point is matched to a pod, which RBAC permission each attribute needs, and what happens to the attributes after enrichment. It is part of our Kubernetes monitoring series. For the full layer-by-layer reference, see the Kubernetes monitoring guide on opensight.ch.

TL;DR

  • Kubelet metrics need an explicit pod_association rule on k8s.pod.uid. Without rules the processor matches by connection IP only, and a scraped kubelet metric has no pod connection to match.
  • The default k8s.deployment.name is derived from the ReplicaSet name and needs only pods and namespaces permissions. You need replicasets only for k8s.deployment.uid or for deployment labels.
  • Missing RBAC never fails the pipeline. The attribute stays empty and the only evidence is a “forbidden” line in the collector log.
  • Match on the pod UID, keep the stable names, delete the UID before export, and promote to metric labels only what you filter by.

Why kubelet metrics have no Deployment name

The kubeletstats receiver emits pod and container usage with a few resource attributes: k8s.pod.uid, k8s.pod.name and k8s.namespace.name. That is all the kubelet knows. Ownership lives in the Kubernetes API: the pod has an owner reference to a ReplicaSet, and the ReplicaSet has one to a Deployment.

The k8sattributes processor bridges that gap. It watches Pods through the API, keeps them in a local cache, and for every data point that passes through it does three things: find the pod the data point belongs to, read that pod’s metadata and owners, and write the result back as resource attributes.

A four-step flow. One: a kubeletstats data point arrives with k8s.pod.uid, k8s.pod.name and k8s.namespace.name, but no workload name. Two: a pod_association rule on the resource attribute k8s.pod.uid matches it to a pod; with no rules the processor uses the connection IP only, which a kubelet metric does not have. Three: the pod cache, a watch on Pods filtered to this node, returns the pod and its owning ReplicaSet, checkout-7d9f8b6c5. Four: the processor trims the pod-template hash and writes k8s.deployment.name checkout, the node name and any labels you extract.

Figure 1. The enrichment path for one kubelet data point. Step 2 is where most empty attributes start.

Pod association: tell the processor how to find the pod

Association is configured as an ordered list of pod_association rules. Each rule names one or more sources, and all sources in a rule must match. The two source types are resource_attribute (look up a named attribute on the incoming resource) and connection (use the IP address the data arrived from).

The default matters here. If you configure no rules at all, the processor associates by connection IP only. That works for an application pod sending OTLP straight to a node-local collector, because the connection IP is the pod IP. It does not work for kubelet metrics, which the collector scrapes itself. So the first rule is the pod UID, with the connection IP as the fallback for OTLP:

processors:
  k8sattributes:
    auth_type: serviceAccount
    filter:
      node_from_env_var: K8S_NODE_NAME   # agent: only pods on this node
    extract:
      metadata:
        - k8s.namespace.name
        - k8s.pod.name
        - k8s.pod.uid
        - k8s.deployment.name
        - k8s.statefulset.name
        - k8s.daemonset.name
        - k8s.node.name
      labels:
        - tag_name: app
          key: app.kubernetes.io/name
          from: pod
    pod_association:
      - sources:
          - from: resource_attribute
            name: k8s.pod.uid          # kubelet metrics
      - sources:
          - from: connection            # OTLP straight from application pods

Three details from the processor’s documentation are worth knowing before you debug anything:

  • The node filter is not optional in a DaemonSet. Without node_from_env_var, every agent watches every pod in the cluster. Inject the node name with the downward API (fieldPath: spec.nodeName) and point the filter at that variable.
  • A connection rule must come before any batching in the pipeline. The batch processor drops the connection context, so put k8sattributes ahead of batch.
  • Pods on the host network cannot be matched by IP. Every one of them shares the node’s address. Match them on a resource attribute instead.

If you run agents and a gateway, the documented pattern is to set passthrough: true on the agents. They then only stamp the pod IP on the data, make no API calls, and leave the lookup to the gateway. That keeps one pod cache per gateway instead of one per node, at the price of a gateway that watches the whole cluster.

RBAC: which attribute needs which permission

This is where the guides you will find disagree, partly because the processor changed. The Deployment name no longer needs ReplicaSet access by default. When k8s.deployment.name is in extract.metadata, the processor reads the ReplicaSet name from the pod’s owner reference and trims the pod-template hash. checkout-7d9f8b6c5 becomes checkout, with no ReplicaSet lookup at all.

ReplicaSet access comes back in two cases: when you ask for k8s.deployment.uid, or when you extract labels or annotations from: deployment or from: replicaset. Then the processor starts a ReplicaSet informer and resolves the name from the API. The same pattern applies to CronJobs: the name alone is derived from the Job name, and the Job informer starts only for the UID or for job and cronjob labels.

A table with three columns: what you extract, which resources the collector needs get, list and watch on, and the symptom when that permission is missing. Pod and namespace metadata, including the default Deployment name: pods and namespaces, otherwise nothing is enriched. Deployment UID or labels from deployment or replicaset: replicasets in the apps group, otherwise deployment fields are empty. Node UID or node labels: nodes, otherwise node fields are empty. CronJob UID or labels from job or cronjob: jobs, plus cronjobs for from cronjob, otherwise cronjob fields are empty. A note says the CronJob name on its own needs no jobs permission.

Figure 2. The permission each kind of attribute needs, per the processor’s RBAC section. Grant what your extract block asks for, not more.

The trap is not a missing permission as such. It is that incomplete RBAC does not break anything you would notice. The pipeline keeps running, data keeps arriving, and the attribute is simply absent. The only evidence is a “forbidden” error in the collector’s own log, which nobody reads while the dashboards are green.

Two habits catch it:

  1. After every change to the collector config or its ServiceAccount, query for pod metrics that lack k8s.deployment.name, k8s.statefulset.name and k8s.daemonset.name at the same time. Bare pods aside, that set should be empty.
  2. Alert on “forbidden” in the collector logs, the same way you alert on failed exports.

Match, keep, drop, promote

Once enrichment works, the next problem is having too much of it. An attribute can do three different jobs, and the one that is perfect for one job is often wrong for the next.

Three columns from left to right. Match on: k8s.pod.uid or the pod IP, used by pod_association; unique per pod and changed on every restart and rollout. Keep on the resource: k8s.namespace.name, k8s.deployment.name, k8s.node.name and an app label, stable across restarts. Promote to labels at the backend: namespace, deployment and node, only what you filter and group by; the rest stays in target_info. A red branch from the first column leads to delete before export: k8s.pod.uid, process.pid, process.command_line and container.image.id.

Figure 3. The pod UID is the best key to match on and one of the worst to keep.

Match on what is unique. k8s.pod.uid identifies exactly one pod, which is why association uses it.

Do not export what changes on every rollout. The same uniqueness makes the UID a poor thing to store: every restart creates a new value, and every new value is a new series in a metrics backend. Delete it once, after enrichment and before the last export hop, together with other high-churn process attributes:

processors:
  resource/drop_noisy:
    attributes:
      - { key: k8s.pod.uid, action: delete }
      - { key: process.pid, action: delete }
      - { key: process.command_line, action: delete }
      - { key: container.image.id, action: delete }

service:
  pipelines:
    metrics:
      receivers: [ kubeletstats, otlp ]
      processors: [ memory_limiter, k8sattributes, resource/drop_noisy, batch ]
      exporters: [ otlp ]

Keep the stable names. Namespace, Deployment, node and one or two labels such as app survive restarts and answer the questions people actually ask.

Promote to labels only what you filter by. This is the label trap. A resource attribute is not automatically a metric label. With the Prometheus and Prometheus remote write exporters, resource attributes land by default in a separate target_info metric that you join at query time. You can turn all of them into labels on every series, which works and multiplies cardinality. Or you can promote a short list: Prometheus 3.x does this for OTLP ingestion with promote_resource_attributes, and the transform processor can copy individual attributes into data point attributes. Option names move between versions, so check them against yours. Namespace, Deployment and node are usually the list.

A checklist before you roll it out

  • pod_association has a k8s.pod.uid rule for kubelet data and a connection rule for OTLP from pods.
  • Agents filter to their own node with node_from_env_var, fed by the downward API.
  • k8sattributes sits before batch in every pipeline that uses a connection rule.
  • RBAC grants exactly what the extract block needs (Figure 2).
  • A query for pod metrics with no workload owner returns nothing, and “forbidden” in the collector log raises an alert.
  • The pod UID and process attributes are deleted before export.
  • Only the attributes you filter by become metric labels.

Most of this is collector configuration that has to be identical on every node and every cluster. That is the part where a fleet drifts: one cluster gets the new RBAC, another keeps last quarter’s extract block. LinkMesh manages OpenTelemetry Collector configuration across a fleet, with staged rollout and rollback and drift detection, so a change like the one above lands everywhere or nowhere. For where the agent and the gateway sit in the first place, see Collector deployment patterns.

Sources