Kubernetes logs & metrics with the OTel Collector
This guide runs an OpenTelemetry Collector on every node of a Kubernetes cluster. It sends everything to one Obzly project over OTLP/HTTP, authenticated with an API key:
- Pod logs: every container’s log files on the node, tagged with pod, namespace, node, workload and container names.
- Node metrics: CPU, memory, disk, filesystem, network and load for the node itself.
- Pod and container metrics: CPU, memory, network and filesystem from the node’s kubelet
(the same data
kubectl top poduses), with the workload (Deployment, StatefulSet, DaemonSet, Job or CronJob) each pod belongs to, and usage as a share of the pod’s and container’s limits. - Cluster state: with the cluster agent (on by default), pod phase, container restarts, replica counts and node readiness from the Kubernetes API.
- App telemetry: OTLP logs, traces and metrics that apps in the cluster push to the collector.
Infrastructure builds its inventory and health from this data.
You need a project in Obzly (see Create your first project),
kubectl access to the cluster, and an Obzly URL the cluster’s nodes can reach.
Generate the manifest
Section titled “Generate the manifest”The quickest route is the Kubernetes entry in the Data Sources catalog. It generates one manifest with a dedicated API key already in it.
-
In your project, open Data Sources and pick Kubernetes.
-
Leave API key empty to create a new key for this collector, or select an existing one and paste its value.
-
Set Obzly endpoint to a URL reachable from the cluster, for example
https://obzly.harborline.example. -
Fill in the options:
Field Default What it does Kubernetes namespace obzly-otelNamespace the collector is deployed into Cluster name empty Added to every log and metric as k8s.cluster.name, for exampleharbor-prod-1. A cluster can’t report its own name, so set it when one project has several clusters: Infrastructure tells clusters apart by itExclude namespaces empty Logs and pod metrics from these namespaces are not collected. Comma-separated, *and?wildcards allowed, for examplekube-system, monitoring-*Only these namespaces empty (all) If set, only these namespaces are collected, minus any excluded ones. Node-level metrics are always collected Exclude pods empty Skip logs and metrics from pods whose name matches, in any namespace, for example noisy-batch-*Cluster agent on Adds one small Deployment that reads pod phase, restarts, replica counts and node readiness from the Kubernetes API, read-only. Infrastructure uses them for health Use host network off Runs the collector on each node’s network. Needed on EKS Auto Mode, and on any node with an instance-metadata hop limit of 1, to get cloud and host attributes. Node network metrics then report the node’s interfaces. Ports 4317 and 4318 must be free on every node -
Generate the config and save it as
obzly-otel-collector.yaml.
Apply it
Section titled “Apply it”kubectl apply -f obzly-otel-collector.yamlCheck that one collector pod is running on each node, plus one cluster agent pod, and that neither is logging export errors:
kubectl -n obzly-otel get pods -o widekubectl -n obzly-otel logs daemonset/obzly-otel-collectorkubectl -n obzly-otel logs deployment/obzly-otel-cluster-agentPod logs appear in Search shortly after they are written. The first time the collector sees a log file it starts at the end, so lines written before it started are not sent. After that it keeps each file’s read offset on the node, so a restart or rollout of the collector neither skips nor re-sends lines. Metrics appear on the next 30-second scrape.
For ready-made views, create a dashboard from the Kubernetes Pods & Nodes or K8S Cluster Overview (Grafana 15661) template on the Dashboards page.
Update an existing collector
Section titled “Update an existing collector”When Obzly’s manifest changes, generate it again from Data Sources and re-apply it with
kubectl apply -f. Manifests generated from v0.55.0 on add:
- Saved file offsets. The collector keeps its read offsets in
/var/lib/otelcolon each node. A smallbusyboxinit container makes that directory writable for the collector. - Workload names on pod metrics. Pod and container metrics now carry the name of their Deployment, StatefulSet, DaemonSet, Job or CronJob. Because the labels change, each pod and container series starts afresh once after you re-apply. Dashboards are unaffected.
- Limit-utilization metrics.
k8s.pod.cpu_limit_utilization,k8s.pod.memory_limit_utilizationand theirk8s.container.*equivalents, for pods and containers that have limits. - The cluster agent, on by default. Clear Cluster agent to leave it out.
If one project receives several clusters, set Cluster name on each.
What the manifest deploys
Section titled “What the manifest deploys”The generated file contains a Namespace, a Secret holding the API key, a ConfigMap with the
collector config, a ServiceAccount with a ClusterRole and binding, a Service on ports 4317 and
4318, and the DaemonSet itself (image otel/opentelemetry-collector-contrib:0.161.0, requesting
100m CPU and 128Mi memory, limited to 512Mi memory). The DaemonSet mounts /var/lib/otelcol from
the node (created if missing) for file offsets, and an init container (busybox:1.37.0) hands it to
the collector’s user.
With the cluster agent, the file also contains a second ConfigMap, ServiceAccount, ClusterRole and
binding, and a one-replica Deployment named obzly-otel-cluster-agent (same collector image,
requesting 50m CPU and 128Mi memory, limited to 512Mi memory). It runs the k8s_cluster receiver
every 30 seconds and uses the same API key Secret.
The collector config, abridged. It omits the transform/severity statements, so generate the
full version from Data Sources rather than copying this one:
extensions: # Keeps file_log's read offsets on the node. file_storage: directory: /var/lib/otelcol/file_storage
receivers: # Tails every container's log files on this node; start_at applies only # to files with no saved offset yet. file_log: include: ["/var/log/pods/*/*/*.log"] exclude: ["/var/log/pods/obzly-otel_obzly-otel-collector*/*/*.log"] start_at: end storage: file_storage include_file_path: true include_file_name: false operators: - type: container
# OTLP pushed by apps in the cluster. otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318
# Node-level metrics, from the node's root filesystem mounted at /hostfs. host_metrics: root_path: /hostfs collection_interval: 30s scrapers: cpu: {} memory: {} disk: {} filesystem: {} network: {} load: {}
# Pod and container metrics from this node's kubelet. kubelet_stats: collection_interval: 30s auth_type: serviceAccount endpoint: "https://${env:KUBE_NODE_IP}:10250" insecure_skip_verify: true metric_groups: [container, pod, node, volume] # Usage as a share of the pod's or container's limit (when it has one). metrics: k8s.pod.cpu_limit_utilization: enabled: true k8s.pod.memory_limit_utilization: enabled: true k8s.container.cpu_limit_utilization: enabled: true k8s.container.memory_limit_utilization: enabled: true
processors: batch: send_batch_size: 2500 send_batch_max_size: 2500 # transform/severity: the generated config also fills in a missing log level from # JSON level/severity keys, klog prefixes, logfmt level=, [level] brackets or a bare # ERROR/WARN token. Lines with no level stay info. resource_detection: detectors: [env, eks, ec2, system] timeout: 5s override: false k8s_attributes: auth_type: serviceAccount passthrough: false extract: metadata: - k8s.pod.name - k8s.namespace.name - k8s.node.name - k8s.deployment.name - k8s.replicaset.name - k8s.statefulset.name - k8s.daemonset.name - k8s.job.name - k8s.cronjob.name - k8s.container.name pod_association: - sources: - from: resource_attribute name: k8s.pod.uid
exporters: otlp_http: endpoint: "https://obzly.harborline.example" headers: X-API-Key: "${env:OBZLY_API_KEY}"
service: extensions: [file_storage] pipelines: logs: receivers: [file_log, otlp] processors: [transform/severity, resource_detection, k8s_attributes, batch] exporters: [otlp_http] traces: receivers: [otlp] processors: [batch] exporters: [otlp_http] metrics: receivers: [otlp, host_metrics, kubelet_stats] processors: [resource_detection, k8s_attributes, batch] exporters: [otlp_http]When you set a cluster name or namespace and pod filters, the generator adds a
resource/cluster_name processor and a filter/namespaces processor to the logs and metrics
pipelines (and to the cluster agent’s), and narrows the file_log include and exclude paths so filtered log files are never
read.
A few details worth knowing:
- Batch size.
send_batch_max_size: 2500keeps each export under Obzly’s per-request batch limit. Obzly rejects an oversized request as a whole, so keep this cap if you edit the config. - Kubelet access. The ClusterRole grants
getonnodes/statsandnodes/proxyforkubelet_stats, andget/list/watchon pods, namespaces, nodes, ReplicaSets and Jobs fork8s_attributes(ReplicaSets resolvek8s.deployment.name, Jobs resolvek8s.cronjob.name). The kubelet is reached by the node’s IP because node names don’t always resolve in DNS, and its certificate is self-signed on most clusters, henceinsecure_skip_verify. - Cluster agent access. Its own ClusterRole is read-only:
get/list/watchon the core objects, workloads, Jobs, CronJobs and HorizontalPodAutoscalers thatk8s_clusterwatches. - EKS detection. The
eksdetector reads theaws-authConfigMap inkube-system, so the ClusterRole also allowsgeton that one ConfigMap. Off EKS the cloud detectors simply find nothing. - Self-signed Obzly certificate. If your Obzly endpoint uses an internal CA, uncomment the
tls: insecure_skip_verify: truelines under the exporter in the generated file.
In Obzly, attribute names are stored with underscores, so k8s.pod.name becomes k8s_pod_name
in searches and PromQL. See Attribute names.
Send app telemetry through the collector
Section titled “Send app telemetry through the collector”Apps instrumented with an OpenTelemetry SDK can export to the collector’s Service instead of to Obzly directly. The collector adds the API key, so the apps don’t need it:
# OTLP/gRPCOTEL_EXPORTER_OTLP_ENDPOINT=http://obzly-otel-collector.obzly-otel.svc.cluster.local:4317# or OTLP/HTTPOTEL_EXPORTER_OTLP_ENDPOINT=http://obzly-otel-collector.obzly-otel.svc.cluster.local:4318Replace obzly-otel with your namespace if you changed it. Logs pushed this way keep the
service name and attributes the SDK sets.
Zero-code traces with OBI
Section titled “Zero-code traces with OBI”OBI (OpenTelemetry eBPF Instrumentation) runs as a second, optional DaemonSet. It needs hostPID, hostNetwork and
eBPF capabilities, so it lives in its own namespace and adopting it is a separate decision.
- Network map. With OBI’s
networkmetrics feature, it exportsobi.network.flow.bytesandobi.network.flow.packets. Obzly’s Service Map uses them to draw edges for services that have no tracing SDK, such as a bare Postgres or Redis. OBI reports each flow twice per node (workload and Service), and Obzly de-duplicates this when building the map. - Application traces. OBI can also produce traces for your applications with no code changes (HTTP, gRPC, SQL, Redis, Kafka and similar calls), plus request-rate and latency metrics, over the same OTLP connection. This costs extra CPU on every node.
Generate the OBI bundle
Section titled “Generate the OBI bundle”In Data Sources, pick eBPF Network Map (OBI):
| Field | Default | What it does |
|---|---|---|
| Kubernetes namespace | obzly-obi |
Namespace OBI is deployed into |
| Cluster name | empty | Sets k8s.cluster.name. OBI often can’t detect it (it logs a warning), so setting it is recommended |
| Trace applications | No | Also generate application traces for the namespaces below |
| Namespaces to trace | empty (all except system namespaces) | Where your applications run, comma-separated with wildcards. Not OBI’s own namespace: pointing it there finds nothing to instrument, with no error |
| Trace permissions | Capabilities only | How much privilege the tracing DaemonSet gets (see below) |
| Deploy Kubernetes metadata cache | No | Adds OBI’s k8s-cache: one shared API-server watch instead of one per node. Recommended for large clusters |
Save the result as obzly-obi.yaml, review it, and apply it:
kubectl apply -f obzly-obi.yamlNetwork edges appear on the Service Map once obi.network.flow.bytes metrics arrive.
Trace permissions
Section titled “Trace permissions”Uprobe-based tracing needs more privilege than the network map. Pick the narrowest option that works on your nodes:
| Option | securityContext | Result |
|---|---|---|
| Capabilities only | adds DAC_OVERRIDE |
Works where SELinux isn’t enforcing. On Bottlerocket it hangs at “attaching uprobes” |
| SELinux label | adds seLinuxOptions: {type: super_t} |
Traces work. OBI’s Node.js injector fails, so no Node.js runtime metrics |
| SELinux label + SYS_ADMIN | also adds SYS_ADMIN |
Traces and the Node.js injector work |
| Privileged | privileged: true |
Everything works, with full host access |
Deploy OBI by hand
Section titled “Deploy OBI by hand”Plain manifests are in
deploy/kubernetes/obi:
-
In
daemonset.yaml, setOTEL_EXPORTER_OTLP_ENDPOINTto your Obzly URL, and optionallyOTEL_EBPF_KUBE_CLUSTER_NAME. -
Put an Obzly API key in
secret.yaml. -
Apply everything. The Secret needs the namespace to exist first:
Terminal window kubectl apply -k .kubectl apply -f secret.yamlkubectl apply -k .
To enable traces by hand, apply traces-config.yaml and edit its discovery.instrument list of
namespaces (k8s_namespace: "*" means everything except OBI’s built-in system exclusions). Then
add to the DaemonSet OTEL_EBPF_METRICS_FEATURES=application,network,network_flow_packets,
OTEL_EBPF_CONFIG_PATH=/config/obi-config.yml, a mount of the obi-config ConfigMap at
/config, and one of the permission levels above.
The optional metadata cache is in k8s-cache.yaml, which is not part of kustomization.yaml.
Apply it and set OTEL_EBPF_KUBE_META_CACHE_ADDRESS=obi-k8s-cache.obzly-obi.svc:50055 on the
DaemonSet.
Next steps
Section titled “Next steps”- Data sources lists the other generated configs.
- Alerts & notifications covers alerting on error logs or pod metrics.
- Grafana via the PromQL API lets existing Grafana dashboards query these metrics.