Topic 59

OTel in Kubernetes

OTel

The OpenTelemetry Operator does for telemetry plumbing what the Prometheus Operator did for scraping. Two CRDs — OpenTelemetryCollector and Instrumentation — replace the hand-run Collectors and hand-wired SDK bootstrap of Chapter 8, and a single pod annotation is enough to have auto-instrumentation injected into Harborline's Python services at admission time.

This topic is also the chapter's closing argument. After the migration, discovery is CRDs, agents are DaemonSets, and injection is a webhook — yet the signals, the trace of web → bookings → payments, and the checkout SLO are exactly what they were on the VMs. The platform changed underneath the stack; nothing the stack measures moved.

The Operator and Its Two CRDs

The operator installs by Helm chart, with its admission webhook's TLS backed by cert-manager — and the install order matters, because a webhook that cannot serve TLS never fires and never says so. From there it reconciles OpenTelemetryCollector resources into running Collectors and applies Instrumentation resources to workloads at admission. It is the Chapter 8 architecture restated declaratively, one reconcile loop per moving part.

Auto-Instrumentation by Annotation

An Instrumentation resource names the SDK images to inject and the OTLP endpoint they speak to — declared once per namespace, so no service hardcodes a Collector address.

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata: {name: harborline}
spec:
  exporter: {endpoint: "http://otel-agent:4317"}
  propagators: [tracecontext, baggage]

The annotation on a pod template is the trigger: at admission, the webhook adds an init container and the OTEL_* environment, and the pod starts emitting traces with zero code change. search, which never received the Chapter 8 manual instrumentation, gains spans exactly this way; Java, Node.js, and .NET inject the same, and Go gets an eBPF-based variant.

template:
  metadata:
    annotations:
      instrumentation.opentelemetry.io/inject-python: "true"

Auto and Manual, Composed

Injection instruments the frameworks — FastAPI routes, HTTP clients, database drivers — but it cannot know the business. bookings keeps its Chapter 8 manual spans and attributes, and the two compose cleanly because the SDK the webhook injects is the SDK the code already uses. One rule keeps this sane: one SDK owner per service. A service is either annotation-injected or code-instrumented, never both — both means duplicate spans on every request and dashboards reporting a traffic surge that is not there.

Collector Topology, Agent and Gateway

mode: daemonset puts a Collector on every node: the lowest-latency first hop for each pod, the place for kubelet and host receivers, and the home of the k8sattributes processor that stamps k8s.namespace.name and k8s.pod.name onto every span. mode: deployment builds a centrally scaled gateway pool that owns tail-sampling, heavy processing, and the egress credentials to the backends. Harborline runs both, agent to gateway — the Chapter 8 two-tier layout, now expressed as two CRs.

Collector topology — agent for locality, gateway for central policy
DaemonSetagent, one per node
Locality. The lowest-latency first hop for every pod on the node, the home of host and kubelet receivers, and the k8sattributes processor that stamps k8s.namespace.name and k8s.pod.name onto every span.
Deploymentgateway, centrally scaled
Central policy. A scaled pool that owns tail-sampling — it sees whole traces where a per-node agent sees only fragments — plus heavy processing and the single egress point to the backends.

Sidecar Mode, the Exception

mode: sidecar injects a Collector into each pod, which buys hard tenant isolation at the price of one Collector process per replica. The DaemonSet amortizes that cost across the node, which is why it stays the default — per-pod Collectors are a bill, not a baseline. Reach for sidecar only when isolation is a requirement someone can name.

What Changed and What Didn't

The migration's ledger, on one page. Changed: service discovery (ServiceMonitors instead of static scrape configs), log shipping (a DaemonSet instead of per-VM agents), SDK wiring (webhook injection instead of hand bootstrap), and metadata (k8s.* resource attributes on every signal).

Unchanged: the harborline_ metric names, the trace shape across web → bookings → payments, the Loki schema, and the checkout SLO — 99.9% availability, 95% of requests under 500 ms, 28-day window — with its Chapter 10 burn-rate alerts intact. The pipeline of instrument, collect, store, query, visualize, alert never named a platform, and that is why none of its outputs had to move.

Collector as DaemonSet vs Deployment

DaemonSet (agent) — one Collector per node: node-local OTLP endpoint, host and kubelet receivers, k8sattributes enrichment. The first hop for every pod on the node.

Deployment (gateway) — a centrally scaled pool: tail-sampling, heavy processing, and the single egress point to backends. Not a choice but a pipeline: apps → node agent → gateway → Tempo, Prometheus, Loki.

Common Mistakes
  • Installing the operator without cert-manager or configured webhook certificates — the admission webhook cannot serve TLS, injection never fires, and pods come up silently uninstrumented.
  • Annotating pods while no Instrumentation resource is visible in that namespace — the webhook has nothing to inject; annotation plus CR is the contract, and half of it produces nothing, not an error.
  • Running tail-sampling on the DaemonSet Collectors — spans of one trace land on many nodes, each node decides on a fragment, and the kept traces are incoherent; tail-sampling belongs to the gateway, which sees whole traces.
  • Double-instrumenting bookings, which already bootstraps the SDK in code — duplicate spans for every request, doubled span volume, and dashboards that appear to show a traffic surge.
  • Skipping the k8sattributes processor on the agent tier — traces arrive without namespace or pod identity, and the Chapter 8 metric-to-trace-to-log correlation loses the join keys that make it work on Kubernetes.
Best Practices
  • Instrument by annotation for services with no manual SDK setup, and keep manual instrumentation with injection off where business-level spans exist — one SDK owner per service, never two.
  • Run the two-tier topology deliberately: DaemonSet agents with k8sattributes for enrichment and locality, one gateway Deployment for tail-sampling and backend egress.
  • Declare the OTLP endpoint once in the Instrumentation resource, pointed at the node-local agent, so no service hardcodes a Collector address.
  • Keep OpenTelemetryCollector and Instrumentation resources in git and review them like PrometheusRule resources — the telemetry pipeline is code now.
Comparable toolsGKE Cloud Trace — OTLP accepted nativelyEKS AWS Distro for OpenTelemetry into X-RayAKS Azure Monitor — OTLP ingestSaaS Datadog agent / Grafana Alloy — the same node-agent role, speaking the same OTLP

Knowledge Check

Annotation-based injection lands on search. What does it instrument, and what does it still not know?

  • Everything the manual SDK could produce, including business attributes
  • Frameworks — routes, HTTP clients, DB drivers — but no business-level spans
  • Only outbound HTTP calls; inbound routes still need code
  • Nothing until the code imports the SDK explicitly

Why does tail-sampling run on the gateway Deployment rather than on the DaemonSet agents?

  • The DaemonSet lacks the CPU headroom for sampling decisions
  • Tail-sampling requires persistent storage that hostPath volumes cannot provide
  • A trace's spans scatter across nodes; only the gateway sees whole traces
  • Agents cannot reach the backends, so sampling must happen at egress

Pods carry inject-python: "true" but come up without instrumentation, and nothing logs an error. Which failure produces exactly this silence?

  • The injected init container is crash-looping on an image mismatch
  • The operator refuses annotations on Deployments and requires PodMonitors instead
  • The OTLP endpoint is unreachable, so the webhook rolls the injection back
  • No Instrumentation resource is visible in the namespace

After Harborline's migration to Kubernetes, which of these changed?

  • The harborline_ metric names
  • The trace shape across web → bookings → payments
  • The mechanism that wires the SDK into pods
  • The checkout SLO and its burn-rate alerts

You got correct