Skip to content

Kubernetes agent

Install EZGH Obs Agent with Helm to send a cluster's container logs, node, pod and container metrics, cluster state, events and application telemetry.

Updated View as Markdown

EZGH Obs Agent is a Helm chart that sends a Kubernetes cluster’s telemetry to a project. It runs the OpenTelemetry Collector contrib distribution, version 0.162.0: a DaemonSet with one agent on every Linux node, and a single-replica Deployment that reads cluster state and events. It needs no operator, CRDs or changes to your applications. Observability’s Kubernetes section shows what it sends.

In the console, Observability > Send data > Kubernetes creates the ingest key and writes the commands below for your cluster. The chart’s source, values and changelog are at github.com/ezgamehost/ezgh-obs-agent.

Requirements

  • Helm 3.17 or later, or Helm 4.
  • Kubernetes 1.28 or later, with IPv4 Linux nodes that write container logs to /var/log/pods. Request and limit utilization needs 1.33 or later (see Pod specs and Kubernetes 1.33).
  • Permission to create a DaemonSet, a Deployment and cluster RBAC.
  • An ingest key that allows obs.logs.ingest, obs.metrics.ingest and obs.traces.ingest (the signals you send). Use one key per cluster, so you can revoke it on its own.

Windows nodes, serverless nodes without host paths, and namespaces with the restricted Pod Security level aren’t supported.

Install

Save the ingest key in a file named ingest-key.txt, without a trailing newline, and store it as a Secret. Then install the chart from GHCR:

kubectl create namespace ezgh-obs
kubectl -n ezgh-obs create secret generic ezgh-obs-ingest \
  --from-file=ingest-key=./ingest-key.txt

helm upgrade --install ezgh-obs-agent \
  oci://ghcr.io/ezgamehost/charts/ezgh-obs-agent --version 0.3.1 \
  --namespace ezgh-obs \
  --set-string cluster.name=production-us-west \
  --set-string ezgh.environment=production \
  --set-string auth.existingSecret=ezgh-obs-ingest \
  --wait --timeout 5m
  • cluster.name is stored with every record as k8s.cluster.name. Keep it unique in the project and the same across upgrades: the Kubernetes section lists clusters by these names.
  • ezgh.environment sets ezgh.metadata.environment on every record, which chooses the dataset; see Datasets are environments.
  • The chart never creates a Secret or keeps the key in Helm values. Delete ingest-key.txt once the Secret exists, or create the Secret with your secret manager.
  • Helm returns once the agents run. Data arriving is checked separately: open Send data > Kubernetes and select Verify, or search the environment’s logs.

For networks that can’t reach ghcr.io, each GitHub release has the same package: replace the oci:// reference and --version with the release’s .tgz URL.

What it collects

Source Collected by What
Container logs DaemonSet Every container’s log files on the node, with the level read from the line (JSON, logfmt, klog or a leading level word) when the runtime records none. The agent’s own logs are left out.
Kubelet DaemonSet Node, pod, container and volume metrics every 30 seconds
Cluster state Deployment Pod phase, container restarts and readiness, requests and limits, workload replicas, node conditions and allocatable capacity, every 30 seconds (with metrics on)
Events Deployment Kubernetes events, as logs (with logs on)
Applications DaemonSet OTLP logs, metrics and traces your applications send to the agent’s Service
The agents Both Their own Collector metrics, every 60 seconds (with metrics on)

Telemetry without a service.name is named after its workload: its Deployment, StatefulSet, DaemonSet, CronJob or Job, or else its container. A Deployment’s container logs and pod metrics arrive as one service, with no change to the application. A service.name the application set is kept.

Cluster state

The chart’s cluster-state collector is a Deployment with one replica (<release>-cluster). It reads the Kubernetes API for what the kubelet doesn’t report: pod phase and status reasons, container restarts and readiness, why a container is waiting or terminated (such as CrashLoopBackOff or OOMKilled), CPU and memory requests and limits, Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob and HorizontalPodAutoscaler replica counts, node conditions, allocatable CPU, memory and pods, namespace phase, resource quotas and PersistentVolumeClaims. It also watches events.

  • One replica sends each series once. It’s replaced, not rolled, on upgrade, so cluster state has a gap of about 30 seconds. Its export queue is kept in the pod (an emptyDir), so it survives a restart of the collector but not a new pod: data queued when the pod is replaced is lost, and events that happen meanwhile aren’t sent.
  • Its own RBAC. It runs as a separate ServiceAccount with a ClusterRole that allows only get, list and watch: on namespaces, nodes, pods, services, replication controllers, resource quotas and persistent volume claims, on Deployments, ReplicaSets, StatefulSets and DaemonSets, on Jobs and CronJobs, and on HorizontalPodAutoscalers (with metrics on), and on events.k8s.io events (with logs on). It can’t read Secrets or ConfigMaps, and cluster state adds nothing to the DaemonSet’s permissions.
  • Turn it off with --set clusterState.enabled=false. That removes the Deployment, its ServiceAccount and its RBAC. The DaemonSet keeps sending logs and kubelet metrics, and the Kubernetes section’s panels that need cluster state say so.

Events

Each Kubernetes event (events.k8s.io) is one log record:

Field Value
service.name kubernetes-events
ezgh.metadata.application kubernetes
Resource k8s.cluster.name, k8s.namespace.name The cluster, and the event’s namespace
k8s.event.reason Such as BackOff, FailedScheduling or Pulled
k8s.event.type Normal or Warning
k8s.object.kind, k8s.object.name The object the event is about, such as Pod and checkout-7d9f8-x2kq
k8s.event.count How many times it happened
Level WARN for Warning, INFO for Normal
Body The event’s message, or its reason when it has none
  • An event that repeats is sent again, with its higher count.
  • An event’s time is when the agent received it.
  • Node events are usually in the default namespace.

Search them like any logs:

service:kubernetes-events level:warn resource.k8s.namespace.name:payments
service:kubernetes-events k8s.event.reason:FailedScheduling

Kubernetes labels on metrics

The agent copies these resource attributes onto every metric point whose resource has them, so PromQL can filter and group by them. It does this for kubelet, cluster-state and agent metrics, and for application metrics sent to the agent.

Resource attribute PromQL label
k8s.cluster.name k8s_cluster_name
k8s.namespace.name k8s_namespace_name
k8s.node.name k8s_node_name
k8s.pod.name k8s_pod_name
k8s.container.name k8s_container_name
k8s.deployment.name, k8s.statefulset.name, k8s.daemonset.name, k8s.replicaset.name, k8s.job.name, k8s.cronjob.name k8s_deployment_name, k8s_statefulset_name, k8s_daemonset_name, k8s_replicaset_name, k8s_job_name, k8s_cronjob_name
k8s.hpa.name, k8s.persistentvolumeclaim.name, k8s.volume.name, k8s.volume.type k8s_hpa_name, k8s_persistentvolumeclaim_name, k8s_volume_name, k8s_volume_type
k8s.resourcequota.name, k8s.replicationcontroller.name k8s_resourcequota_name, k8s_replicationcontroller_name
sum by (k8s_namespace_name) (k8s_pod_cpu_usage{k8s_cluster_name="production-us-west"})
  • A point that already has the attribute keeps its own value.
  • Resource attributes stay as they are, so logs and traces don’t change: filter them with resource.k8s.pod.name:… as before.
  • Each node, pod and container is its own series. Data from agents before 0.3.0 has no such labels: series that differ only in their pod or node read as one series (see Names and labels).
  • Every metric the agent sends has k8s_cluster_name, application metrics included. They get the pod and workload labels when their resource has them: when the application sets them, or when the agent adds them, which it does with otlp.internalTrafficPolicy: Local.
  • job is the workload, as the agent names its service, on both kubelet and cluster-state pod and container metrics, so it means the same workload in both. A Deployment’s or other workload’s own metrics (k8s_deployment_desired, …) have job set to it. Node, namespace, HorizontalPodAutoscaler, ReplicaSet, quota and claim metrics have no job. Of the agent’s own metrics, only agent telemetry has instance.

In your own Collector

A Collector you run yourself, with k8s_attributes (k8sattributes before Collector Contrib 0.146), gets the same labels from this transform processor. Add it to your metrics pipeline after k8s_attributes:

processors:
  transform/k8slabels:
    error_mode: ignore
    metric_statements:
      - context: datapoint
        statements:
          - set(attributes["k8s.cluster.name"], resource.attributes["k8s.cluster.name"]) where resource.attributes["k8s.cluster.name"] != nil and attributes["k8s.cluster.name"] == nil
          - set(attributes["k8s.namespace.name"], resource.attributes["k8s.namespace.name"]) where resource.attributes["k8s.namespace.name"] != nil and attributes["k8s.namespace.name"] == nil
          - set(attributes["k8s.node.name"], resource.attributes["k8s.node.name"]) where resource.attributes["k8s.node.name"] != nil and attributes["k8s.node.name"] == nil
          - set(attributes["k8s.pod.name"], resource.attributes["k8s.pod.name"]) where resource.attributes["k8s.pod.name"] != nil and attributes["k8s.pod.name"] == nil
          - set(attributes["k8s.container.name"], resource.attributes["k8s.container.name"]) where resource.attributes["k8s.container.name"] != nil and attributes["k8s.container.name"] == nil
          - set(attributes["k8s.deployment.name"], resource.attributes["k8s.deployment.name"]) where resource.attributes["k8s.deployment.name"] != nil and attributes["k8s.deployment.name"] == nil
          - set(attributes["k8s.statefulset.name"], resource.attributes["k8s.statefulset.name"]) where resource.attributes["k8s.statefulset.name"] != nil and attributes["k8s.statefulset.name"] == nil
          - set(attributes["k8s.daemonset.name"], resource.attributes["k8s.daemonset.name"]) where resource.attributes["k8s.daemonset.name"] != nil and attributes["k8s.daemonset.name"] == nil
          - set(attributes["k8s.replicaset.name"], resource.attributes["k8s.replicaset.name"]) where resource.attributes["k8s.replicaset.name"] != nil and attributes["k8s.replicaset.name"] == nil
          - set(attributes["k8s.job.name"], resource.attributes["k8s.job.name"]) where resource.attributes["k8s.job.name"] != nil and attributes["k8s.job.name"] == nil
          - set(attributes["k8s.cronjob.name"], resource.attributes["k8s.cronjob.name"]) where resource.attributes["k8s.cronjob.name"] != nil and attributes["k8s.cronjob.name"] == nil
          - set(attributes["k8s.hpa.name"], resource.attributes["k8s.hpa.name"]) where resource.attributes["k8s.hpa.name"] != nil and attributes["k8s.hpa.name"] == nil
          - set(attributes["k8s.persistentvolumeclaim.name"], resource.attributes["k8s.persistentvolumeclaim.name"]) where resource.attributes["k8s.persistentvolumeclaim.name"] != nil and attributes["k8s.persistentvolumeclaim.name"] == nil
          - set(attributes["k8s.volume.name"], resource.attributes["k8s.volume.name"]) where resource.attributes["k8s.volume.name"] != nil and attributes["k8s.volume.name"] == nil
          - set(attributes["k8s.volume.type"], resource.attributes["k8s.volume.type"]) where resource.attributes["k8s.volume.type"] != nil and attributes["k8s.volume.type"] == nil
          - set(attributes["k8s.resourcequota.name"], resource.attributes["k8s.resourcequota.name"]) where resource.attributes["k8s.resourcequota.name"] != nil and attributes["k8s.resourcequota.name"] == nil
          - set(attributes["k8s.replicationcontroller.name"], resource.attributes["k8s.replicationcontroller.name"]) where resource.attributes["k8s.replicationcontroller.name"] != nil and attributes["k8s.replicationcontroller.name"] == nil

service:
  pipelines:
    metrics:
      processors: [memory_limiter, k8s_attributes, resource, transform/k8slabels]

The Kubernetes section also needs k8s.cluster.name on every resource; set it with the resource processor (key: k8s.cluster.name, action: insert), before transform/k8slabels as above, so it’s copied too.

Agent telemetry

Each agent sends its own Collector metrics every 60 seconds, through its normal metrics pipeline, as service.name ezgh-obs-agent. In PromQL they’re job="ezgh-obs-agent", with instance the node’s name, or cluster-state for the cluster-state collector:

Metric Meaning
otelcol_exporter_sent_log_records_total, …_metric_points_total, …_spans_total Sent to EZGH
otelcol_exporter_send_failed_log_records_total, …_metric_points_total, …_spans_total Failed to send. These appear after the first failure
otelcol_exporter_queue_size, otelcol_exporter_queue_capacity The export queue’s fill, in bytes. A rising queue means EZGH is unreachable or limiting the agent’s rate
otelcol_receiver_accepted_*_total, otelcol_receiver_refused_*_total Received, and refused
otelcol_processor_memory_limiter_refused_*_total Refused because the agent was near its memory limit. These appear after the first refusal
otelcol_process_memory_rss_bytes, otelcol_process_cpu_seconds_total, otelcol_process_uptime_seconds_total The agent process
count(otelcol_process_uptime_seconds_total{job="ezgh-obs-agent", instance!="cluster-state"})

counts the agents reporting. A cluster’s Agent tab charts them. Each agent also serves the same metrics in Prometheus format on its pod’s port 8888, for a scraper of your own.

Metrics reference

Names are as PromQL reads them (see Names and labels). Every series also has the Kubernetes labels that apply to it.

Family Metrics From
Node k8s_node_cpu_usage, k8s_node_cpu_time_seconds_total, k8s_node_memory_{available,usage,rss,working_set}_bytes, k8s_node_memory_{page_faults,major_page_faults}_ratio, k8s_node_filesystem_{available,capacity,usage}_bytes, k8s_node_filesystem_inode_{count,free}, k8s_node_network_io_bytes_total and k8s_node_network_errors_total (with interface and direction), k8s_node_uptime_seconds_total Kubelet
Pod k8s_pod_cpu_usage, k8s_pod_cpu_time_seconds_total, k8s_pod_memory_{available,usage,rss,working_set}_bytes, k8s_pod_memory_{page_faults,major_page_faults}_ratio, k8s_pod_filesystem_{available,capacity,usage}_bytes, k8s_pod_network_io_bytes_total, k8s_pod_network_errors_total, k8s_pod_uptime_seconds_total, and with pod specs k8s_pod_{cpu,memory}_{request,limit}_utilization_ratio Kubelet
Container container_cpu_usage, container_cpu_time_seconds_total, container_memory_{available,usage,rss,working_set}_bytes, container_memory_{page_faults,major_page_faults}_ratio, container_filesystem_{available,capacity,usage}_bytes, and with pod specs k8s_container_{cpu,memory}_{request,limit}_utilization_ratio Kubelet
Volume k8s_volume_{available,capacity}_bytes, k8s_volume_inodes_ratio, k8s_volume_inodes_free_ratio, k8s_volume_inodes_used_ratio. The inode metrics are counts: their _ratio suffix comes from their unit, 1. k8s_volume_type and k8s_persistentvolumeclaim_name need pod specs Kubelet
Pod state k8s_pod_phase (1 Pending, 2 Running, 3 Succeeded, 4 Failed, 5 Unknown), k8s_pod_status_reason (1 Evicted, 2 NodeAffinity, 3 NodeLost, 4 Shutdown, 5 UnexpectedAdmissionError, 6 any other or none) Cluster state
Container state k8s_container_restarts (the restart count, a gauge), k8s_container_ready (1 or 0), k8s_container_{cpu_request,cpu_limit}, k8s_container_{memory,ephemeralstorage,storage}_{request,limit}_bytes (for containers that set them), k8s_container_status_reason (one series per reason, such as CrashLoopBackOff or OOMKilled: 1 for the container’s current reason to be waiting or terminated, else 0) Cluster state
Node state k8s_node_condition (one series per condition: 1 true, 0 false, -1 unknown), k8s_node_condition_{ready,memory_pressure,disk_pressure,pid_pressure}, k8s_node_allocatable_cpu, k8s_node_allocatable_memory_bytes, k8s_node_allocatable_pods Cluster state
Workloads k8s_deployment_{desired,available}, k8s_replicaset_{desired,available}, k8s_statefulset_{desired,ready,current,updated}_pods, k8s_daemonset_{desired_scheduled,current_scheduled,ready,misscheduled}_nodes, k8s_job_{active,desired_successful,failed,max_parallel,successful}_pods, k8s_cronjob_active_jobs, k8s_replication_controller_{desired,available}, k8s_hpa_{current,desired,min,max}_replicas Cluster state
Namespaces, quotas and claims k8s_namespace_phase (1 active, 0 terminating), k8s_resource_quota_{hard_limit,used} (with resource), k8s_persistentvolumeclaim_status_phase (one series per phase: 1 for the current one), k8s_persistentvolumeclaim_storage_{capacity,request}_bytes Cluster state
Agent otelcol_* (see Agent telemetry) Each agent

k8s_container_restarts is a gauge: increase() works on it, and delta() doesn’t warn that it might not be a counter. CPU usage (*_cpu_usage) is computed from two readings, so an agent’s first 30 seconds have none.

Pod specs and Kubernetes 1.33

The request and limit utilization metrics, and the volume type and claim labels, come from pod specs the agent reads from each node’s kubelet. Kubelets allow that with the nodes/pods permission from Kubernetes 1.33. Earlier versions require nodes/proxy, which also allows running commands in pods, so the chart never grants it: on Kubernetes 1.28 to 1.32 these metrics and labels are missing.

metrics.kubelet.podSpecs is auto: on when Helm reports Kubernetes 1.33 or later at install or upgrade. A cluster upgraded to 1.33 afterwards needs another helm upgrade to turn it on. Pod specs and volume metrics are read separately from the rest, so a kubelet that refuses them, such as a node pool still behind its upgraded control plane, loses only the utilization and volume metrics (and the agent logs a 403 each time), never its node, pod and container metrics. Set false if your kubelets have the KubeletFineGrainedAuthz feature gate off, which keeps volume metrics without their type and claim labels, and true on an earlier cluster only if its kubelets have it on.

Settings

Set these with --set or a values file. The chart’s values.yaml lists every setting, and its schema refuses unknown ones.

Setting Default Purpose
cluster.name, ezgh.environment, auth.existingSecret Required: the cluster’s name, the environment, and the Secret with the ingest key
auth.secretKey ingest-key The Secret’s entry
logs.enabled, metrics.enabled, traces.enabled true Turn a signal off entirely. At least one stays on
clusterState.enabled true The cluster-state Deployment: cluster state metrics (with metrics on) and events (with logs on)
clusterState.collectionInterval 30s How often cluster state is read
clusterState.resources, clusterState.memoryLimiter 128 MiB requested, 512 MiB limit The cluster-state collector’s memory; raise both for large clusters
clusterState.nodeSelector, clusterState.tolerations, clusterState.affinity Where the cluster-state collector runs
logs.collectContainerLogs true Read container log files. Off still receives application OTLP logs
logs.excludeNamespaces, logs.exclude Namespaces and log file patterns not to read
logs.parseSeverity true Read a log line’s level when the runtime records none
metrics.collectKubeletMetrics true Read kubelet metrics
metrics.collectionInterval 30s How often kubelet metrics are read
metrics.kubelet.podSpecs auto Read pod specs from the kubelet; see Pod specs and Kubernetes 1.33
kubernetesMetadata.enabled true Read pod and workload metadata, and name services after workloads
otlp.internalTrafficPolicy Cluster Local keeps each application’s OTLP on its own node’s agent, which then adds its pod and workload metadata
resources, memoryLimiter 256 MiB requested, 512 MiB limit Each node agent’s memory; raise both for busy nodes
nodeSelector, tolerations, affinity Which nodes run an agent

Send application telemetry

Applications in the cluster send OTLP to the agent’s Service. They don’t need the ingest key:

OTEL_EXPORTER_OTLP_ENDPOINT=http://ezgh-obs-agent.ezgh-obs.svc:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SERVICE_NAME=checkout

For gRPC, use port 4317 and protocol grpc. The Service is reachable only inside the cluster, without authentication: any pod that can reach it can send to the project. Restrict callers with a NetworkPolicy.

Upgrade from 0.2

Run the install command again with the new version, and the same release name, namespace, cluster.name, environment and Secret:

helm upgrade --install ezgh-obs-agent \
  oci://ghcr.io/ezgamehost/charts/ezgh-obs-agent --version 0.3.1 \
  --namespace ezgh-obs \
  --set-string cluster.name=production-us-west \
  --set-string ezgh.environment=production \
  --set-string auth.existingSecret=ezgh-obs-ingest \
  --wait --timeout 5m

Pass the other settings you installed with too, or keep them with --reset-then-reuse-values. Don’t use --reuse-values: it leaves out settings that are new in 0.3.

0.3.1 changes nothing you configure: it uses the Collector’s current component names (k8s_attributes, file_log), so the agents start without deprecation warnings. Log positions carry over, so an upgrade from 0.3.0 doesn’t send any log line twice.

0.3.0 adds the cluster-state Deployment and its RBAC (on by default), events, the Kubernetes labels on metrics, more kubelet metrics (uptime, node inodes, and on Kubernetes 1.33 and later request and limit utilization) and agent telemetry. On 1.33 and later, the DaemonSet’s ClusterRole also gets get on nodes/pods. The Kubernetes section needs 0.3.0.

Volume. 0.3.0 sends more metric series: about 50% more for a typical cluster. A 10-node cluster with 300 pods goes from about 12,000 series to about 18,000, at 30 seconds. The Kubernetes labels add no series of their own. Events are one log record per event, usually far fewer than container logs. A dataset’s active series shows the change. Turning cluster state off keeps the kubelet additions and agent telemetry.

Operate

kubectl -n ezgh-obs get daemonset,deployment -l app.kubernetes.io/instance=ezgh-obs-agent
kubectl -n ezgh-obs logs -l app.kubernetes.io/instance=ezgh-obs-agent --tail=50
  • Ready pods mean the agents run, not that EZGH accepted data. The cluster’s Agent tab in Kubernetes shows what each agent sends and fails to send.
  • Delivery is at least once: each node agent queues to the node’s disk (64 MiB per signal by default), the cluster-state collector to its pod, and both retry rate limits and outages. Node loss, a full disk or a long outage can lose records.
  • No utilization or volume metrics, and the agents log 403 for the kubelet’s /pods: the kubelets don’t accept nodes/pods. Upgrade the node pool, or set metrics.kubelet.podSpecs=false to get volume metrics without their type.
  • No cluster state or events: check that the ezgh-obs-agent-cluster Deployment is running and clusterState.enabled is on.
  • After rotating the key, restart the agents, which read it at startup: kubectl -n ezgh-obs rollout restart daemonset/ezgh-obs-agent deployment/ezgh-obs-agent-cluster.
  • Uninstall with helm uninstall ezgh-obs-agent -n ezgh-obs. The Secret and the agents’ node directories stay; revoke the ingest key when you retire the cluster.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close