Observability Multi-Cloud

Continuous Profiling in Production with eBPF: Parca, Pyroscope, and Flame Graphs

Metrics tell you a service is burning 80% of a core. Traces tell you which request is slow. Neither tells you which line of code is on CPU when that happens. That gap used to be closed by attaching a profiler to one process, for one minute, after the incident — by which point the pathological allocation is gone. Continuous profiling closes it permanently: an eBPF agent on every node samples the on-CPU stack of every process, dozens to hundreds of times per second, for roughly 1% overhead and zero application changes. The output is a flame graph you can rewind to 03:00 last Tuesday, keyed by namespace, pod, container, node, and function.

This is a reference you will keep open mid-investigation, so it is deliberately table-dense: option matrices for sample rate and unwinding, decision tables for which symbolizer a language needs, a symptom-cause-confirm-fix playbook for the failure modes that make a flame graph lie to you, and real parca, pyroscope, alloy, and go tool pprof commands with expected output. By the end you will deploy an eBPF profiling agent as a DaemonSet, symbolize native and JIT code correctly, read a flame graph without fooling yourself, diff two windows to find a regression in seconds, correlate a profile with the exact slow span that produced it, size the pprof storage bill, and turn aggregate CPU-share into cores you can deprovision.

The hard truth this article keeps returning to is that a profiler does not watch every instruction. It interrupts the CPU at a fixed frequency and records the current stack. The relative width of a frame is an unbiased estimate of the fraction of CPU time spent there; absolute attribution of a single rare event is not what this tool is for. Every capability below — the DWARF unwinder, the build-ID symbolization, the differential view, the span labels — exists to make that statistical picture trustworthy and actionable at fleet scale.

What problem this solves

The three classic pillars answer “what”, “when”, and “across what”. Metrics say error rate rose at 03:00. Logs say which request threw. Traces say the process-cart span took 50 ms. None of them say that 40 ms of that span was a JSON serializer reallocating a buffer inside a loop, on line 214 of encode.go. Profiling answers “where in the code” — it is the missing fourth pillar, and until continuous profiling it was the one you only had after an incident, if you were lucky enough to be profiling when it struck.

What breaks without it: an engineer sees a p99 regression after a Graviton migration, cannot reproduce it in staging (which runs a different architecture), and has no code change to blame because CPU rose uniformly. Or a finance review asks “why are we running 400 cores for a service that handles 2,000 requests per second” and nobody can point at the specific functions eating the budget. Or a memory alert fires and the on-call restarts pods for a week because there is no way to see which allocation site drives the heap growth. Every one of these is a “where in the code” question, and every one is answerable in seconds if you already have always-on profiles keyed by time and labels.

Who hits this hardest: teams running large fleets (hundreds to thousands of pods) where the cost of CPU is real money; polyglot shops where a regression can hide in a shared vendored library across Go, Python, and Java services simultaneously; latency-sensitive services under change freeze where you cannot bolt a manual profiler onto a PCI-scoped process; and any platform team that has been asked to turn “we’re spending a lot on compute” into a ranked, defensible optimization backlog. The property that makes continuous profiling different from perf record on one box is that it is always-on, low-overhead, and fleet-wide — the question becomes a query (“show me the flame graph for payments between 03:00 and 03:05 UTC”) instead of a reproduction exercise.

To frame the whole field before the deep dive, here is the landscape of profiling approaches and where continuous eBPF profiling sits among them:

Approach Overhead Scope Needs code change? Off-CPU visible? When it’s the right tool
Manual perf record Moderate (ships every sample) One process, one window No With sched events Deep dive on one box you can ssh to
In-process profiler (Go pprof, JFR) Low–moderate One process Sometimes (endpoint/agent) Some (block/mutex profiles) Language-specific detail when you can add SDK
async-profiler (JVM) Low One JVM No (attach) Yes (wall-clock mode) JVM hot-path and allocation detail
eBPF continuous profiling ~0.2–2% Whole node, every process No With off-CPU eBPF probes Fleet-wide, always-on, polyglot, zero-touch
Sampling APM profilers (vendor) Low–moderate Instrumented services Agent/SDK Vendor-dependent When already paying for an APM suite

The eBPF row is the one that turns profiling from an incident-time reaction into a standing dataset. The rest of this article is how to run that row well.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable operating Kubernetes (DaemonSets, ServiceAccounts, securityContext, hostPID), reading YAML and Helm values, and running kubectl. You need a working mental model of what a stack trace is (a chain of return addresses from a leaf function back to a thread entry point) and roughly how a program’s memory is laid out (text/code, stack, the rbp/rsp registers on x86-64). Familiarity with the pprof format from Go (go tool pprof) helps but is not required — this article teaches it. You do not need to know eBPF internals; you need to know what the agent does on your behalf and how to tell when it fails.

This sits in the Observability track, specifically the profiling corner that most teams reach last, after metrics, logs, and traces are already in place. It assumes you have a metrics stack (Prometheus/Mimir) and ideally distributed tracing, because the highest-value workflow — span-scoped profiling — links profiles to traces. It pairs directly with eBPF Zero-Code Instrumentation with Grafana Beyla & OpenTelemetry (the same eBPF, applied to RED metrics and spans instead of stacks), with Distributed Tracing with Tempo & Jaeger: Exemplars & Correlation (the traces you correlate against), and with Cilium Hubble: Network Flow Observability & Service Map (eBPF at the network layer). If your telemetry pipeline runs through the collector, OpenTelemetry Collector Pipelines in Production is where the OTel eBPF profiler’s output lands.

A quick map of who owns what in a continuous-profiling deployment, so you route problems to the right person:

Layer What lives here Who usually owns it What breaks here
Kernel / node eBPF verifier, perf events, CAP_BPF/CAP_PERFMON, kernel version Platform / infra Program won’t load; unwinding unstable on old kernels
Agent (DaemonSet) Sampling, in-kernel aggregation, unwind tables Observability platform No samples; wrong PID attribution (missing hostPID)
Symbolization build-IDs, debuginfod, per-runtime unwinders Platform + app teams [unknown]/hex frames; missing debug info
Storage backend pprof profiles, object store, retention Observability platform Retention gaps; ingest limits; query timeouts
Correlation pprof labels, span_id/trace_id, SDK App teams No span-scoped view; label cardinality blowups
Consumers Flame graphs, diffs, cost reviews App + SRE + FinOps Misreading the axis; optimizing the wrong frame

Core concepts

Six mental models make every later section obvious.

A profiler samples; it does not trace every instruction. The agent arms a perf event that fires at a fixed frequency per CPU. On each fire the kernel runs a tiny eBPF program in the context of whatever was executing and records the current stack. Over a window, the count of samples whose leaf (or any frame) is function F divided by total samples is an unbiased estimate of the fraction of CPU time spent in F. This is why a flame graph’s x-axis is share of samples, not time, and why the tool is superb at “where do cycles go across a week” and poor at “what happened in this one 5 ms request” (that is a trace’s job).

Aggregation happens in the kernel, which is why it’s cheap. The naive approach (perf record) ships one event per sample to user space — thousands per second per CPU — then aggregates offline. An eBPF profiler instead increments a counter in a BPF hash map keyed by (pid, user_stack_id, kernel_stack_id). User space reads that map every few seconds and gets pre-counted stacks. You never move per-sample data across the kernel boundary, which is what keeps overhead near 1% even at fleet scale.

Unwinding the user stack is the hard part. Getting from “the CPU was at address 0x4f21a0” to a stack of frames requires walking parent frames. Frame-pointer walking follows the rbp chain and is trivial — but only works if the binary kept frame pointers (-fno-omit-frame-pointer), and most release/distro builds omit them to free a register. So modern agents parse the binary’s .eh_frame DWARF call-frame information (CFI), precompute a compact unwind table, load it into BPF maps, and unwind in-kernel without frame pointers. The kernel’s own stack uses ORC (a kernel-specific unwind format). No recompilation, no LD_PRELOAD, no sidecar.

Symbolization is a separate step from unwinding, with its own failure modes. Unwinding gives you a list of addresses. Symbolization maps each address to function + file:line. Where that mapping lives depends on the language: in-binary tables (Go’s .gopclntab), a separated .debug ELF fetched by build-ID from a debuginfod server, a per-runtime interpreter walk (CPython frame objects), or a JIT map file (perf-<pid>.map). A flame graph full of [unknown] or bare hex is almost always a symbolization failure (missing debug info for a build-ID), not a broken agent.

“On-CPU” is not “slow.” A CPU profile shows where cycles burn. It is blind to off-CPU time — blocked on locks, disk, network, epoll. If a service is slow but its CPU flame graph is nearly empty, the cost is off-CPU; you need off-CPU profiling (eBPF tracing sched_switch to measure time threads spend descheduled) or a trace, not a wider CPU flame graph.

pprof is the lingua franca. Parca, Pyroscope, the Go runtime, the OTel profiler — they all speak pprof (profile.proto), the compressed protobuf go tool pprof has read for a decade. A profile is a set of samples, each a stack of locations (each resolving to functions/lines) plus one or more values (nanoseconds of CPU, bytes allocated, objects) and a bag of labels. Because it’s one format, a Pyroscope profile, a Parca profile, and a net/http/pprof dump are the same shape and interoperate.

The vocabulary in one table

Pin down every moving part before the deep sections; the glossary at the end repeats these for lookup.

Concept One-line definition Where it lives Why it matters
Sampling profiler Interrupts CPU at a fixed rate, records the stack Agent + perf event Width = statistical CPU share, not time
perf event Kernel counter (sw clock or hw cycles) that fires the sampler Kernel perf_event_open The clock the profiler runs on
eBPF program Sandboxed code run in kernel on each sample Kernel, verified Walks stacks, aggregates in-kernel
Stack unwinding Reconstructing the call chain from a leaf address In-kernel (agent maps) Frame-pointer vs DWARF vs ORC
Frame pointer rbp chain used to walk frames Compiled into the binary Cheap unwind — if not omitted
DWARF CFI (.eh_frame) Table describing how to unwind at any PC ELF section Unwinds frame-pointer-less binaries
ORC Kernel’s own compact unwind format vmlinux Unwinds kernel stacks
Symbolization Address → function + file:line Binary / debuginfod / runtime Turns hex into readable frames
build-ID Content hash in NT_GNU_BUILD_ID ELF note ELF binary Key for fetching matching debug info
debuginfod HTTP server serving debug info by build-ID External / internal Central symbolization source
pprof The profile.proto sample format Wire + storage Portable, tool-compatible profile format
Flame graph Stacked rectangles of aggregated samples UI The primary read; width = share
Icicle / diff Top-down / before-vs-after flame graph UI Regression hunting
off-CPU profile Time threads spend descheduled eBPF sched_switch Finds lock/IO waits CPU profiles miss
Span-scoped profile Samples labeled with span_id/trace_id pprof labels Flame graph of one slow span

How sampling profilers work

A sampling profiler answers “where does the CPU spend time” by taking a large number of instantaneous snapshots of what is executing and counting them. The statistical claim is simple: if you sample the program counter and its call stack uniformly in time, the fraction of samples in which function F appears equals the fraction of wall-CPU-time spent in (or under) F, in expectation. Double the time in a function and you double its expected sample count. This is why you can trust relative widths and must not read absolute single events.

The perf event that drives it

The clock is a perf event created with perf_event_open(2). Two flavours matter:

Event Kind What it counts Pros Cons
PERF_COUNT_SW_CPU_CLOCK Software Elapsed CPU time (per-task/per-CPU) Always available, no PMU needed, works in VMs/containers Slightly less precise than hardware
PERF_COUNT_SW_TASK_CLOCK Software Per-task CPU time Good for per-thread attribution Same precision caveat
cpu-cycles (PERF_COUNT_HW_CPU_CYCLES) Hardware (PMU) Actual CPU cycles Cycle-accurate; catches stalls Needs PMU access; often unavailable in cloud VMs
PERF_COUNT_HW_INSTRUCTIONS Hardware Retired instructions Instruction-level attribution Same PMU caveat; different bias

Most fleet agents default to the software CPU clock at a modest frequency, because hardware PMU counters are frequently unavailable inside cloud VMs and containers. The event is configured with a sample frequency (e.g. freq = 19 meaning “aim for 19 samples per second per CPU”). On each fire the kernel raises an interrupt, and — because an eBPF program is attached to this perf event — runs that program synchronously in the interrupted context.

The interrupt-and-record loop

Here is the loop, concretely, for an eBPF CPU profiler:

  1. The perf event fires on CPU n. The kernel is now executing the eBPF program in the context of whatever task was on CPU n.
  2. The program reads the current task’s pid/tgid.
  3. It calls bpf_get_stackid() (or, in newer agents, bpf_get_stack() plus a custom unwinder) to capture the user stack and the kernel stack, returning numeric stack IDs that index into a BPF_MAP_TYPE_STACK_TRACE map.
  4. It increments a counter in a BPF_MAP_TYPE_HASH keyed by (pid, user_stack_id, kernel_stack_id).
  5. Every few seconds, user space reads the hash map, resolves stack IDs to address lists, symbolizes, and emits a pprof profile.

The genius is step 4: counting in the kernel. The alternative — one perf record per sample copied to a ring buffer and drained to user space — is what makes perf record expensive at high rates. By counting stacks in a map, the agent moves only distinct stacks and their counts, which compresses enormously because hot paths repeat.

Why width is share, not time (and what that forbids)

The x-axis of a flame graph is the count of samples in which a frame appears, normalized. It is not a timeline. Two consequences engineers get wrong:

The statistical resolution improves with sample count, which is frequency × duration × CPUs. A rare function that is on CPU 0.1% of the time needs a lot of samples before its width is stable — which is exactly why you crank frequency for a targeted, short investigation and keep it low fleet-wide. The confidence-interval trade-off is quantified later in the storage section.

The clearest way to keep the tool honest is to hold in mind what a sampling profile can and cannot tell you — most misreadings come from asking it a question from the wrong column:

A profile CAN tell you A profile CANNOT tell you
The fraction of CPU time spent in (or under) a function, over a window The exact duration of a single request or event
Which leaf functions burn the most cycles fleet-wide The ordering of calls over time (x-axis is not a timeline)
That one function got more/less expensive across a deploy (diff) Whether a user-facing latency changed (that needs metrics/traces)
Where allocation churn or retained heap concentrates (memory profiles) Time spent off-CPU (blocked on locks/IO) unless off-CPU profiling is on
Aggregate, statistically stable hot paths A rare, one-off pathological event with confidence

Stack unwinding: frame-pointer vs DWARF vs ORC

Capturing a program counter is trivial. Capturing the stack — the chain of callers back to the thread root — requires unwinding, and this is where naive profilers fail silently on modern binaries. There are three mechanisms, and an eBPF agent chooses per binary.

Frame-pointer unwinding

On x86-64, a function that keeps a frame pointer sets rbp to point at its stack frame, and the previous rbp and the return address sit at known offsets. Unwinding is then a trivial loop: read [rbp] for the caller’s rbp, read [rbp+8] for the return address, repeat until you reach the top. It costs a few memory reads per frame and is what bpf_get_stackid() uses by default.

The catch: compilers omit frame pointers by default at -O1 and above to free rbp as a general-purpose register (worth a small percentage of performance). So most release builds, most distro-shipped shared libraries, and the C library itself have no frame pointers, and frame-pointer unwinding stops at the first such frame — you get a truncated stack (often just the leaf, with everything below it missing). This is the number-one reason a naive eBPF profiler produces shallow, useless stacks.

DWARF CFI (.eh_frame) unwinding

The fix that needs no recompilation: the compiler emits Call Frame Information in the ELF .eh_frame section (present even in stripped binaries, because it’s needed for C++ exceptions and debuggers). CFI is a per-PC table that says, at this instruction, “the return address is at CFA-8, the caller’s rbp is at CFA-16, and the Canonical Frame Address is rsp+32.” An unwinder can reconstruct the full stack from CFI without frame pointers.

The complication: .eh_frame is a compact bytecode (a virtual machine of DWARF opcodes) that is expensive to interpret, and you cannot run a DWARF interpreter safely inside the eBPF verifier. So agents like Parca Agent and the Grafana/OTel eBPF profiler do this: in user space, parse .eh_frame into a flattened unwind table (sorted array of {pc, cfa_rule, rbp_rule, ra_rule} rows), load that table into BPF maps, and run a simple in-kernel unwinder that binary-searches the table by PC and applies the rule — no DWARF VM in the kernel. This is what lets a frame-pointer-less Go, Rust, or C++ release binary unwind fully at ~1% overhead.

ORC unwinding (the kernel side)

The Linux kernel does not use .eh_frame for itself; it uses ORC (Oops Rewind Capability), a purpose-built, compact, fast unwind format generated at kernel build time (CONFIG_UNWINDER_ORC). When your eBPF program captures the kernel portion of a stack (syscalls, kernel functions above the user frames), the kernel unwinds it with ORC internally and hands you resolved kernel frames. You do not manage ORC; it is why kernel frames in your flame graph are usually well-symbolized (against /proc/kallsyms) even when user frames are not.

Choosing per binary — the decision table

The agent inspects each mapped binary and picks a strategy:

Situation Unwinder used Works when Fails / degrades when
Binary built with frame pointers Frame-pointer walk -fno-omit-frame-pointer present (rare) hand-written asm without FP
Frame-pointer-less binary with .eh_frame DWARF CFI → flattened BPF table .eh_frame present (usual) Corrupt/absent CFI; very old agent
Kernel stack ORC (kernel-internal) CONFIG_UNWINDER_ORC=y (modern kernels) Ancient kernels with frame-pointer unwinder
Go binary Frame-pointer walk (Go keeps FP by default) Go 1.7+ on amd64/arm64 — (Go is the easy case)
Interpreted (Python/Ruby) native side DWARF/FP for C frames; runtime walk for interpreter frames Interpreter version supported Unsupported interpreter minor version
JIT (JVM/Node/.NET) FP/DWARF for native; JIT map for JIT frames Runtime emits a map Map missing → [unknown] JIT frames

The practical upshot: for your own services, keeping frame pointers on (-fno-omit-frame-pointer, ~1% runtime cost) makes unwinding bulletproof and cheap. For everything else — dependencies, base images, stripped release binaries — you rely on the agent’s DWARF-CFI path, which is why a modern agent and a modern kernel matter.

Kernel and capability requirements

Unwinding and stack capture depend on kernel features and privileges. The realistic floor:

Requirement Minimum Comfortable Why
Kernel version 5.4 5.10+ DWARF-based BPF unwinding stability; BTF/CO-RE maturity
BTF (CONFIG_DEBUG_INFO_BTF) Present Present CO-RE: one agent binary across kernels without headers
ORC unwinder CONFIG_UNWINDER_ORC=y default on modern distros Kernel-stack unwinding
Capabilities CAP_BPF + CAP_PERFMON + CAP_SYS_PTRACE Load programs, arm perf events, read other procs’ maps
Fallback privileged: true scoped caps When your platform can’t grant fine-grained caps
hostPID Required Required Resolve sampled PIDs to host processes/containers

eBPF whole-system profiling: the three agents

Three production-grade eBPF profilers dominate, and they share the same core (perf event → in-kernel aggregation → pprof) while differing in storage, symbolization model, and ecosystem. You will pick one; knowing how they differ prevents a costly re-platform later.

Parca (Agent + Server)

Parca is the CNCF-adjacent, pprof-native pair: Parca Agent (the eBPF DaemonSet) samples every process on a node and pushes to the Parca server, which stores profiles in FrostDB (an embeddable columnar store) and does server-side symbolization — agents upload build-IDs, and the server resolves them once, centrally, against uploaded debug info or debuginfod. The design philosophy is “pprof all the way down”: everything is pprof, the query language is label-selector-based, and the server exposes a pprof-compatible API you can point go tool pprof at.

Grafana Pyroscope (+ Alloy / eBPF)

Grafana Pyroscope is the object-store-backed, horizontally scalable profiling database modeled on the Grafana LGTM stack (same microservices shape as Loki/Mimir/Tempo). You feed it either from language SDKs (pyroscope-go, Python, Java, Ruby, .NET) or from Grafana Alloy running the pyroscope.ebpf component (the eBPF whole-system profiler). Storage is S3/GCS/ABS, so retention is an object-store bill, and it integrates tightly with Grafana’s flame-graph panel, Explore Profiles, and trace-to-profile links in Tempo.

The OpenTelemetry eBPF profiler

The OpenTelemetry eBPF profiler (donated from Elastic’s universal profiler) is the emerging vendor-neutral standard: a whole-system, multi-runtime eBPF profiler that emits the OTLP profiles signal, so profiles flow through the OpenTelemetry Collector alongside metrics/logs/traces and can be routed to any OTLP-profiles backend (including Pyroscope). It unwinds native (DWARF), Go, Python, JVM (via JIT integration), Ruby, PHP, Node/V8, and .NET, and is designed to be the eBPF profiling source once the OTel profiling signal stabilizes.

Agent comparison — the decision matrix

Dimension Parca Pyroscope (+ Alloy eBPF) OTel eBPF profiler
Wire/storage format pprof / FrostDB pprof-derived / object store OTLP profiles / backend-defined
Storage backend FrostDB (local/embedded) S3/GCS/ABS (object store) Backend-defined (often Pyroscope)
Symbolization Server-side, build-ID upload Agent-side + debuginfod; server assist Agent-side unwinders; debuginfod
SDK ingestion (non-eBPF) pprof push Rich SDKs (Go/Py/Java/Ruby/.NET) Via OTel SDKs (profiling)
Scaling model Single server / vertical Microservices, horizontal Depends on collector + backend
Ecosystem fit pprof-purist, standalone Grafana LGTM native OTel-native, vendor-neutral
Off-CPU support Limited/native focus CPU focus (eBPF); SDKs for more Growing
Best when You want pprof-native, self-contained You run Grafana stack + want object-store scale You’re standardizing on OTLP end to end

Agent configuration — the load-bearing knobs

Whichever agent you pick, the same set of knobs governs behaviour. Getting these right is the difference between trustworthy profiles and a fleet-wide overhead surprise:

Knob Typical default Range Effect Gotcha
Sample rate (Hz per CPU) 19 1–1000 Resolution vs overhead/storage (linear) 100 Hz fleet-wide wastes overhead on estimates you don’t read
Collect/push interval 10–15 s 1–60 s How often profiles flush Too short → many tiny profiles; too long → coarse time resolution
Unwinder mode DWARF+FP auto FP-only / DWARF / mixed Stack completeness FP-only truncates on release binaries
Symbolization mode debuginfod / upload local / remote Where hex → names happens Missing build-ID debug info → [unknown]
Target discovery K8s pods (relabel) all-procs / labeled Which processes to profile default_target=false means “only labeled targets”
Kernel-stack capture on on/off Include kernel frames Off hides syscall/lock cost in kernel
Per-target labels namespace/pod/container any relabel Query dimensions Every label is a query axis and a cardinality cost

Deploying the agent as a DaemonSet

One agent per node, profiling every container on it. Below is a Grafana Alloy DaemonSet running the eBPF profiler and remote-writing to Pyroscope — Alloy is the supported way to ship the Grafana eBPF profiler.

The Alloy configuration (a .alloy river file) discovers pods, relabels metadata into query labels, runs pyroscope.ebpf, and writes to a Pyroscope endpoint:

// alloy-config.alloy
discovery.kubernetes "pods" {
  role = "pod"
}

discovery.relabel "pods" {
  targets = discovery.kubernetes.pods.targets

  rule {
    source_labels = ["__meta_kubernetes_namespace"]
    target_label  = "namespace"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_name"]
    target_label  = "pod"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_container_name"]
    target_label  = "container"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_node_name"]
    target_label  = "node"
  }
}

pyroscope.ebpf "instance" {
  forward_to       = [pyroscope.write.endpoint.receiver]
  targets          = discovery.relabel.pods.output
  default_target   = "false"     // only profile discovered/labeled targets
  collect_interval = "15s"
  sample_rate      = 19          // Hz per CPU
}

pyroscope.write "endpoint" {
  endpoint {
    url = "http://pyroscope.monitoring.svc.cluster.local:4040"
  }
  external_labels = {
    "cluster" = "prod-eu-west-1",
  }
}

The agent needs host access and elevated privileges. The pod spec is the load-bearing part — get hostPID and the capabilities right or nothing works:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: alloy-profiler
  namespace: monitoring
spec:
  selector:
    matchLabels: { app: alloy-profiler }
  template:
    metadata:
      labels: { app: alloy-profiler }
    spec:
      hostPID: true                       # resolve sampled PIDs to host containers
      serviceAccountName: alloy-profiler
      containers:
        - name: alloy
          image: grafana/alloy:v1.5.0
          args:
            - run
            - /etc/alloy/config.alloy
            - --server.http.listen-addr=0.0.0.0:12345
          securityContext:
            privileged: true              # or the scoped caps below
            # capabilities:
            #   add: ["BPF", "PERFMON", "SYS_PTRACE", "SYS_RESOURCE"]
          volumeMounts:
            - { name: config, mountPath: /etc/alloy }
      volumes:
        - name: config
          configMap: { name: alloy-profiler-config }

hostPID: true is what lets the agent resolve a sampled PID back to the right container and binary on the host. Without it you get stacks you cannot attribute — the PID inside the agent’s namespace does not match the host PID the perf event reported.

If you prefer Parca, the model is identical — a DaemonSet of parca-agent pushing to a parca server:

# parca-agent container args (DaemonSet)
args:
  - /bin/parca-agent
  - --node=$(NODE_NAME)                              # from fieldRef spec.nodeName
  - --remote-store-address=parca.monitoring.svc:7070
  - --remote-store-insecure
  - --profiling-cpu-sampling-frequency=19
  - --dwarf-unwinding=enabled                        # DWARF CFI for FP-less binaries

For the OpenTelemetry eBPF profiler, you run it as a DaemonSet exporting OTLP profiles to a collector, which routes them onward:

# otel-ebpf-profiler args (DaemonSet), exporting OTLP profiles to a local collector
args:
  - -collection-agent=otel-collector.monitoring.svc:4317
  - -samples-per-second=19
  - -reporter-interval=15s

All three encode profiles as pprof (or OTLP profiles, which carries the same sample model) under the hood — the format go tool pprof has read for a decade. That portability is why a Pyroscope profile, a Parca profile, and a Go runtime profile are all the same shape, and why you can pull any of them into go tool pprof for diffing.

The RBAC and PodSecurity requirements are non-negotiable; if your cluster enforces a restricted Pod Security Standard, the profiler namespace needs a privileged label exception:

Requirement Why How
hostPID: true PID → container/binary resolution Pod spec
privileged or CAP_BPF+CAP_PERFMON+CAP_SYS_PTRACE Load BPF, arm perf events, read /proc/<pid>/maps of other procs securityContext
Host mounts (/proc, sometimes /sys, /) Read maps, binaries for symbolization volumeMounts (agent-dependent)
PodSecurity exception Restricted PSS blocks privileged pods Namespace label pod-security.kubernetes.io/enforce: privileged
Node selector / tolerations Run on every node incl. tainted DaemonSet tolerations

Symbolization: turning addresses into names

A raw sample is a list of virtual addresses. Symbolization maps each to function + file:line. Where that mapping lives is entirely language-dependent, and the failure modes differ, so treat symbolization as its own subsystem.

Native, stripped (Go, Rust, C/C++)

The binary on the node either has a symbol table (.symtab/.debug_*) or it does not. Release builds are usually stripped. Three options, in order of preference:

1. debuginfod. The agent extracts the binary’s build-ID (the hash in the ELF NT_GNU_BUILD_ID note) and fetches the matching separated debug info from a debuginfod server over HTTP. Point the agent (or your tooling) at one or more:

# Public elfutils federation + your internal server (comma-separated, tried in order)
export DEBUGINFOD_URLS="https://debuginfod.elfutils.org/ https://debuginfod.internal.corp/"

Parca runs its own symbolizer that uploads build-IDs and resolves them server-side, so symbolization happens once, centrally, not on every node.

2. Ship debug info to the server ahead of time. With Parca, parca-debuginfo upload pushes the .debug ELF for a build-ID into Parca’s store. CI is the right place — right after the build, before the artifact ships:

# In CI, after building the release binary
parca-debuginfo upload \
  --store-address=parca.monitoring.svc:7070 \
  ./bin/payments-service
# Parca now has debug info keyed by this binary's build-ID; any node running it
# will symbolize centrally with zero on-node debug data.

3. Don’t strip / keep frame pointers. For your own services, keep a separated .debug file and frame pointers. The runtime overhead of -fno-omit-frame-pointer is ~1% and it makes unwinding bulletproof.

JIT and managed runtimes — per-language unwinders

Managed runtimes don’t put native function names at fixed addresses; the agent needs a per-runtime strategy:

Runtime How the agent gets symbols What you must do Common failure
Go .gopclntab is in the binary even when “stripped” with default flags; frame pointers kept Nothing (Go rarely fails)
Python Agent walks CPython interpreter frame objects (PyFrameObject/_PyInterpreterFrame) in-kernel to recover Python-level stacks Use a supported CPython version Unsupported minor version → native-only stacks
Java/JVM JVM emits perf-<pid>.map for JIT code (-XX:+PreserveFramePointer + a perf map agent), or async-profiler-style integration; native frames via DWARF Enable perf map / frame pointers No map → JIT frames show as [unknown]
.NET Runtime emits maps; needs perf map support enabled DOTNET_PerfMapEnabled=1 (and stacks) Missing map → hex JIT frames
Node.js / V8 JIT map via --perf-basic-prof (or --perf-prof) Start Node with the flag Without it, JS frames are [unknown]
Rust / C++ Native path: DWARF .eh_frame unwind + build-ID debug info Keep .eh_frame (default); provide debug info Stripped, no debuginfod → hex leaves
Ruby Interpreter frame walk (agent-dependent) Supported Ruby version Unsupported version → native-only

The build-ID pipeline as a whole

Symbolization at fleet scale is a pipeline, not a per-node accident. The reliable pattern:

Stage Action Owner Result
Build Compile with -fno-omit-frame-pointer; keep/produce .debug; embed build-ID (default) App team CI Unwindable, identifiable binary
Publish debug info parca-debuginfo upload or push to internal debuginfod CI Debug info keyed by build-ID, centrally
Run Agent samples; extracts build-ID per binary Platform Addresses + build-IDs
Resolve Server/agent fetches debug info by build-ID (debuginfod/store) Platform function + file:line
Verify Confirm zero [unknown] on hot paths for each service App + platform Trustworthy flame graphs

The failure mode you see first is a flame graph full of bare hex addresses or [unknown] frames stacked under a recognizable native function. That always means missing debug info for that build-ID — not a broken agent. Resolve the build-ID (upload debug info, fix the debuginfod URL, enable the runtime’s perf map), not the agent config.

CPU vs memory/alloc vs off-CPU profiling

“Profiling” is not one thing. The value type recorded per sample defines the question you’re answering, and mixing them up leads to wrong conclusions (“I see a leak” when the panel shows allocation rate, not retained heap). Know exactly which profile you’re reading.

The profile types

Profile type pprof value(s) What it measures The question it answers How it’s collected
CPU (on-CPU) cpu (nanoseconds) or samples Where cycles burn “What’s hot on CPU?” Sampling perf event (eBPF or runtime)
Alloc (in space/objects) alloc_space (bytes), alloc_objects Total memory allocated over the window “What drives GC pressure / allocation churn?” Sampled at allocation (runtime hook)
In-use heap inuse_space (bytes), inuse_objects Live/retained memory right now “What’s holding the heap? Is there a leak?” Snapshot of live objects (runtime)
Off-CPU wait time (nanoseconds) Time threads spend descheduled “Why is it slow when CPU is idle?” eBPF sched_switch/finish_task_switch
Block / Mutex (Go) contention time / count Time blocked on sync primitives “Where is lock/channel contention?” Runtime instrumentation
Wall-clock (async-profiler) wall time On + off CPU together per thread “Total latency contribution per stack” Sampling incl. off-CPU

On-CPU: the default, and its blind spot

The eBPF CPU profile is what you get out of the box, and it is the right first look for compute-bound cost. Its blind spot is fundamental: it cannot see time spent off-CPU. A thread blocked on a mutex, a disk read, or epoll_wait is not on any CPU, so no perf-event interrupt catches it, so it contributes zero to the CPU flame graph. If a service’s p99 is 500 ms but its CPU flame graph is nearly empty, the latency is off-CPU — do not widen the CPU profile looking for it; switch to off-CPU or read the trace.

Memory: allocation rate vs retained heap (the trap)

Continuous memory profiling almost always defaults to allocation profiles — alloc_space / alloc_objects, the total bytes/objects allocated over the window. That is the right signal for GC pressure and allocation churn (the thing that makes a Go service spend 30% of CPU in the garbage collector). It is not the same as inuse_space (what is retained right now). A function can dominate alloc_space (allocates a lot, all short-lived) and contribute nothing to inuse_space — that is churn, not a leak. Conversely, a slow leak shows in inuse_space climbing over time, barely visible in per-window alloc_space. Before you say “leak,” confirm which value the panel shows:

You see… On which profile It means… Do this
One function huge on alloc_space Alloc High allocation churn → GC pressure Reduce allocations (pool/reuse buffers)
inuse_space growing over hours In-use heap Retained memory rising → possible leak Diff in-use over time; find the growing site
alloc_space high, inuse_space flat Both Churn, not a leak Optimize for GC cost, not retention
CPU profile shows heavy GC frames CPU GC is expensive → allocation-driven Attack allocation via the alloc profile

Off-CPU: seeing the waits

Off-CPU profiling answers “where is my program not running when it should be.” An eBPF program hooks sched_switch (the kernel scheduler descheduling a task) and records the stack at the moment the thread goes off-CPU plus the duration until it comes back on. Aggregate that and you get a flame graph of wait time — dominated by lock acquisition, blocking syscalls, and channel/queue waits. It is heavier than CPU profiling (context-switch events are frequent) and not every eBPF profiler ships it, but it is the missing half of latency analysis for I/O- or lock-bound services.

Which to reach for — the decision table

If the symptom is… Reach for Because
High CPU / high cloud bill CPU profile Directly attributes cycles to functions
GC using lots of CPU Alloc profile (alloc_space) Finds the allocation sites driving GC
Memory grows without bound In-use heap diffed over time Isolates the retaining site
Slow but CPU is idle Off-CPU profile or a trace Latency is in waits, invisible to CPU profile
Latency spikes under contention Off-CPU / Go mutex/block profile Contention is off-CPU time
“Total time in this stack” (Java) Wall-clock (async-profiler) Combines on+off CPU per thread

Reading flame graphs without lying to yourself

A flame graph is an aggregation of every sampled stack over a time window, drawn as a stack of rectangles. Reading it wrong wastes hours; reading it right finds the fix in seconds.

The axes and the rules

Flame vs icicle vs differential

The same data, oriented and colored differently, answers different questions:

View Orientation Color Best for
Flame graph Root at bottom, leaves on top By frame/module (aesthetic) “Where does the CPU go?” (single window)
Icicle graph Root at top, leaves at bottom Same Top-down reading; some UIs default to it
Differential (diff) flame Either By change in share (red = worse, blue = better) “What changed between two windows?”
Sandwich / caller-callee Focus one function, callers above, callees below Neutral “Who calls this hot function, and what does it call?”

The differential view — the single most valuable read

Pick two windows — before a deploy and after — and the renderer colors each frame by the change in its sample share. Red/wider = this function got more expensive; blue/narrower = cheaper. This is how you find a regression in seconds. Pull two pprof profiles and diff them locally with go tool pprof:

# Pull two pprof profiles from Parca's pprof-compatible API and diff them locally.
curl -s "http://parca:7070/api/v1/query?query=<selector>&time=<BEFORE_TS>" -o before.pb.gz
curl -s "http://parca:7070/api/v1/query?query=<selector>&time=<AFTER_TS>"  -o after.pb.gz

# Positive (red) nodes are the NEW cost introduced after the deploy.
go tool pprof -http=:8080 -diff_base=before.pb.gz after.pb.gz

pprof -diff_base produces exactly this: a graph where positive (red) nodes are the new cost. If a single function lit up red across a deploy boundary, that is your regression, with the commit pinned by the deploy timestamp. In Grafana/Pyroscope and Parca UIs the same diff is a first-class panel — select baseline and comparison ranges and it colors the flame graph for you.

Two more semantics people get wrong

Common misreadings, tabulated

Misreading Reality Correct move
“This wide bottom frame is the bottleneck” It’s just the entry point; cost is in children Follow width upward to the leaf
“The left frame runs first” X-axis isn’t time; order is not sequence Ignore left-right order as timing
“CPU flame graph is empty, so it’s fine” Cost may be off-CPU Switch to off-CPU / read the trace
“This function allocates most, so it’s the leak” Alloc ≠ retained Check inuse_space, diff over time
“One request took this long” (from the graph) It’s an aggregate over many samples Use a trace for single-request timing
“Diff shows red, so it’s slower for users” Diff shows CPU share change, not latency Correlate with latency metrics/traces

Correlating profiles with traces

The next leap is span-scoped profiling: instead of asking “what was hot in payments from 03:00 to 03:05”, ask “what was hot during this specific slow span.” The mechanism is a label. OpenTelemetry’s profiling integration / the Pyroscope SDK pushes the active span’s span_id and trace_id into the runtime’s pprof labels, so each CPU sample taken while that span is active carries the span it belongs to.

In Go, the runtime’s pprof.Labels/pprof.Do machinery is what the SDK rides on. With the Pyroscope Go SDK:

import "github.com/grafana/pyroscope-go"

// Attach span context as a profiling label for the duration of the work.
// Every CPU sample taken inside this closure is tagged with this span_id.
pyroscope.TagWrapper(
  ctx,
  pyroscope.Labels("span_id", span.SpanContext().SpanID().String()),
  func(c context.Context) {
    processCart(c)
  },
)

Or, at a lower level, the standard-library primitive the SDK builds on:

import "runtime/pprof"

pprof.Do(ctx, pprof.Labels("span_id", spanID, "trace_id", traceID), func(ctx context.Context) {
    processCart(ctx) // samples here carry span_id and trace_id in the pprof profile
})

Now in the trace UI (Tempo/Grafana) you click a 50 ms span and get the flame graph of just that span’s CPU time. The trace tells you which call was slow; the embedded profile tells you why, down to the function — one click, no reproduction. This is the payoff that justifies the whole stack.

The correlation is bidirectional and rests on a small set of conventions:

Direction Mechanism What you get Requirement
Span → profile span_id/trace_id in pprof labels Flame graph of one slow span SDK/runtime labels; label wired
Profile → span Same labels, queried in reverse Which traces hit a hot function Same labels stored/queried
Service-level Shared service.name/namespace labels Line up profiles with a service’s traces/metrics Consistent resource labels
Exemplar-style Trace exemplars on metrics + trace→profile Metric spike → trace → span → flame graph Exemplars + trace-to-profile links

Two caveats keep this from backfiring. First, cardinality: trace_id is unbounded, so you do not want it as a storage label on every profile series — it’s carried on the sample and used for lookup, not as a top-level series dimension you group by. Second, coverage: span labels only appear for services running an SDK that sets them (typically your Go/Java/etc. services), while the eBPF agent still profiles everything — so you get whole-fleet flame graphs from eBPF and span-scoped detail on the instrumented subset. Wire the labels for the latency-critical services first.

For the trace side of this correlation, see Distributed Tracing with Tempo & Jaeger: Exemplars & Correlation and OpenTelemetry Metrics: Exemplars & Prometheus Trace Correlation, which cover the exemplar links that let a metric spike jump straight to the trace and then the span-scoped flame graph.

The pprof storage model, retention, and the sampling trade-off

Profiles are bulkier than metrics but compress hard because stacks repeat. Understanding the storage model tells you what to tune and what the bill will be.

The pprof data model

A pprof profile (profile.proto) is a self-contained, gzip-compressed protobuf with these tables:

pprof table Contents Why it matters
sample_type The value types (e.g. cpu/nanoseconds, alloc_space/bytes) Declares what each sample’s numbers mean
sample List of {location_ids[], values[], labels[]} The actual data: a stack + its value(s) + labels
location {address, mapping_id, lines[]} A frame; may resolve to multiple inlined lines
function {name, filename, start_line} Symbolized function identity
mapping {memory range, file, build_id} The binary a location came from (for symbolization)
string_table Deduplicated strings Compression: every name stored once

Because locations, functions, and strings are deduplicated and referenced by index, and because hot stacks repeat across samples, a profile compresses far better than its logical size. Storage engines exploit this further: Parca’s FrostDB stores profiles columnar (stacks as columns, values as columns), and Pyroscope stores in an object-store-backed format that dedupes symbols across profiles.

Retention and backend configuration

Pyroscope’s object-store backend makes retention an S3/GCS bill rather than a node-local-disk problem:

# pyroscope values.yaml (microservices mode) — object store + limits
pyroscope:
  structuredConfig:
    storage:
      backend: s3
      s3:
        bucket_name: prod-pyroscope-profiles
        endpoint: s3.eu-west-1.amazonaws.com
    limits:
      max_query_lookback: 720h        # 30 days queryable
      ingestion_rate_mb: 32           # per-tenant ingest cap
      max_query_length: 24h           # cap a single query's time span

The knobs that govern retention, ingest, and query cost:

Setting What it controls Typical Raise when Watch-out
max_query_lookback How far back profiles are queryable 30 days You need long-horizon regressions Storage cost scales with it
ingestion_rate_mb Per-tenant ingest ceiling 32 MB/s Big fleets / high sample rates Too low → dropped profiles
max_query_length Longest single-query window 24 h Broad cost reviews Too long → slow/expensive queries
Compactor / retention Downsample / delete old blocks backend default Cost control Overzealous → lose history
Object-store class Hot vs infrequent-access Standard Old data → IA/Glacier tiers Retrieval latency for cold data

The sample-rate trade-off, quantified

The single dial that governs both overhead and statistical accuracy is sample rate:

Sample rate Approx. overhead Statistical resolution Use for
19 Hz ~0.2–0.5% Good for hot-path / aggregate analysis Fleet-wide default
49 Hz ~1% Better resolution of mid-cost functions Latency-sensitive tiers
100 Hz ~2–3% Sharp, but diminishing returns Targeted, short investigation windows
250–1000 Hz 5%+ Very fine, high cost Micro-optimizing one process, briefly

Higher frequency narrows the confidence interval on each frame’s width but linearly increases per-sample CPU work and storage volume. The relationship is statistical: the number of samples for a frame is rate × duration × CPUs × share, and the relative error on a frame’s width falls roughly as 1/sqrt(samples). So going from 19 to 100 Hz (5.3×) only halves the error (sqrt(5.3) ≈ 2.3), for 5× the cost — textbook diminishing returns. The sane posture: 19 Hz fleet-wide, with the ability to crank a single namespace to 100 Hz during an investigation. Resist 100 Hz everywhere; you pay overhead on every node to sharpen estimates you mostly aren’t reading.

Storage volume scales with sample rate, fleet size, stack depth, and label cardinality:

Driver Effect on storage Control
Sample rate Linear (more samples/sec) Keep 19 Hz fleet-wide
Number of profiled processes Linear Profile labeled targets, not literally everything
Stack depth / distinct stacks Sub-linear (dedup + compression) Nothing to do; the format handles it
Label cardinality Can be super-linear (series explosion) Keep labels bounded; don’t add trace_id as a series label
Retention (max_query_lookback) Linear in days Tier old data; set an explicit window

Driving cost optimization from CPU attribution

The most defensible ROI of continuous profiling is turning CPU-share into money. Fleet-wide attribution answers a question you cannot get any other way: which function, across all services, burns the most aggregate CPU-seconds. Query the aggregate flame graph across the whole cluster for a representative week, sort leaf functions by total self-CPU-seconds, and the top of that list is your optimization backlog ranked by cost.

A representative top-of-list from a real cluster review looks like this:

Top CPU consumers, cluster-wide, last 7d (by self CPU-seconds)
  1. compress/flate.(*compressor).deflate        18.4%   ~46 cores
  2. encoding/json.(*encodeState).string          9.1%   ~23 cores
  3. regexp.(*Regexp).doExecute                    6.7%   ~17 cores  <- compiled per-request
  4. crypto/sha256.block                           4.2%   ~11 cores
  5. runtime.mallocgc                              3.9%   ~10 cores  <- allocation churn

Item 3 — a regex recompiled on every request instead of once at init — is a five-line fix that returns ~17 cores across the fleet. You only see it because the profiler aggregated the same hot leaf across every service that imported the offending library. Item 5 (runtime.mallocgc) is a pointer to the allocation profile: heavy GC means an allocation-churn problem to chase in alloc_space.

A repeatable FinOps loop turns this into a standing practice:

Step Action Cadence Output
1 Pull cluster-wide aggregate CPU flame graph for a representative week Monthly Ranked self-CPU leaves
2 Convert self-CPU-share to cores (share × total provisioned cores) Monthly Cost per hot function
3 Triage top N by effort-to-fix vs cores returned Monthly Prioritized backlog
4 Fix; verify with a before/after diff across the deploy Per fix Confirmed core reduction
5 Deprovision the freed capacity (lower replicas / smaller nodes) Per fix Realized savings

The kinds of waste that reliably surface as fat cross-service frames — each translating directly to deprovisionable cores:

Waste pattern How it appears Typical fix Cores returned (order of magnitude)
Regex compiled per request regexp.*doExecute / Compile hot, spread across services Compile once at init Tens
Logging serializing dropped fields json/fmt under a log call that’s filtered out Guard with level check / structured logging Tens
Software crypto vs hardware path sha256.block/crc32 generic impl Enable CPU intrinsics (AES-NI, CRC) Tens
Allocation churn runtime.mallocgc, GC frames Pool/reuse buffers Tens
Redundant serialization Same struct marshaled twice Cache the encoded form Tens
Debug/verbose middleware left on Instrumentation frames in prod hot path Sample or disable in prod Tens

Architecture at a glance

There is no single diagram that captures continuous profiling honestly, because the system is a pipeline with a fan-in at the node and a fan-out at the query. Walk it as a flow instead.

At the bottom sits the node kernel. A perf event (software CPU clock at, say, 19 Hz per CPU) fires an interrupt; an eBPF program attached to that event runs in the interrupted task’s context, captures the user stack (unwinding via frame pointers where present, otherwise via a flattened DWARF/.eh_frame table loaded into BPF maps) and the kernel stack (unwound by the kernel’s ORC), and increments a counter in a BPF hash map keyed by (pid, user_stack_id, kernel_stack_id). This is the fan-in: thousands of interrupts per second per node collapse into a small set of distinct, pre-counted stacks. Crucially, hostPID lets the agent map each pid back to the container and binary on the host so the sample can be labeled namespace/pod/container/node.

Every few seconds the agent (Alloy pyroscope.ebpf, Parca Agent, or the OTel eBPF profiler — one per node, a DaemonSet) reads the map, symbolizes addresses (in-binary .gopclntab for Go; per-runtime interpreter walk for Python/JVM/Node/.NET; build-ID → debuginfod / Parca debug-info store for stripped native), and emits a pprof (or OTLP-profiles) payload of samples — each a stack, a value (cpu nanoseconds, or alloc_space bytes on a memory profile), and labels. Where an application SDK is present, samples taken inside a span also carry span_id/trace_id labels — the seam that later enables span-scoped flame graphs.

Those payloads flow to the storage backend: Parca’s FrostDB columnar store, or Pyroscope backed by S3/GCS (the retention bill lives here, governed by max_query_lookback), or an OTLP-profiles backend reached through the OpenTelemetry Collector. This is the fan-out point. On top of storage sit the consumers: a Grafana/Parca UI rendering a flame graph for {namespace="payments"} over a chosen window; a differential view diffing a before-deploy baseline against after (pprof -diff_base) to surface a red regression frame; a trace UI that, on click of a slow span, pulls the span-scoped flame graph via the span_id label; and a FinOps review querying the cluster-wide aggregate to rank self-CPU leaves into cores. The whole method is: sample and count in the kernel, symbolize by build-ID, store as pprof, and query the aggregate — turning “where in the code did the CPU go, last Tuesday at 03:00, in this exact span” from a reproduction exercise into a lookup.

Real-world scenario

Meridian Pay runs its settlement platform on three EKS clusters in eu-west-1: about 600 pods, a roughly even split of Go and Python services, fronted by an internal gateway, handling ~2,000 settlement requests per second at peak. The platform team is six engineers; the monthly compute bill for these clusters is about ₹34 lakh (~USD 41k). They already ran the Grafana eBPF profiler (Alloy pyroscope.ebpf) as a DaemonSet at 19 Hz, writing to a Pyroscope cluster on S3 with 30-day retention — so profiling was a standing dataset, not something they’d bolt on during an incident.

The incident began after a planned migration of the settlement service from m6i (amd64) to m7g (Graviton, arm64) nodes to cut cost. Within a day, p99 latency on settlement crept up ~12% with no code change and no obvious metric to blame — CPU was up a little, but uniformly across pods, so dashboards showed nothing actionable. The constraints were brutal: they could not reproduce it in staging (which still ran amd64), and they refused to add manual profiling hooks to a PCI-scoped service under an active change freeze. A traditional shop would have been stuck bisecting infrastructure.

Because the profiler was always-on, the investigation was a query, not a deployment. They pulled the cluster-wide differential flame graph, amd64 baseline (the week before the migration) versus arm64 (after), for the settlement service, and diffed with go tool pprof:

go tool pprof -http=:8080 \
  -diff_base=settlement-amd64.pb.gz \
  settlement-arm64.pb.gz

One frame lit up red across both Go and Python pods: a checksum routine in a shared vendored crypto library that fell back to a generic, byte-at-a-time implementation on arm64 because the build hadn’t been compiled with the ARM CRC32 intrinsics enabled. The diff made it unmistakable — the same leaf, fatter on arm64, in services that otherwise shared no code. On amd64 the library had used a hardware CRC path; on arm64, without the right build flags, it silently used the slow path. The uniform CPU rise was this one routine, spread everywhere the library was imported.

The fix was a build flag, not a rewrite — rebuild the dependency with the architecture’s CRC/LSE extensions enabled:

# arm64 build picks up the hardware CRC + LSE atomics path
ENV GOARCH=arm64 GOARM64=v8.1
RUN go build -trimpath -o /out/settlement ./cmd/settlement

p99 returned to baseline within a deploy. They verified with the reverse diff (arm64-before vs arm64-after) — the red frame went blue — and the Graviton migration’s cost savings (~18% on those nodes) were finally realized without the latency penalty. The lesson written on the wall: “An always-on profiler turns a vague, unreproducible, cross-language regression into a single red frame — no code change to a frozen service, no staging repro.” That capability is the entire reason the stack exists.

The incident as a timeline, because the order of moves is the lesson:

Time Symptom Move Effect
Day 0 Graviton migration ships (planned change) Cost down ~18% on migrated nodes
Day 1, 09:00 p99 up ~12%, CPU up uniformly Check dashboards Nothing actionable — uniform rise
Day 1, 10:30 Can’t reproduce in staging (amd64) Rule out a staging repro Dead end via traditional means
Day 1, 11:00 Change freeze blocks manual profiling Use the standing eBPF profiles Investigation becomes a query
Day 1, 11:20 Pull amd64-vs-arm64 diff flame graph pprof -diff_base One red frame across Go and Python
Day 1, 11:40 Root cause: generic CRC on arm64 Identify missing CRC intrinsics Build-flag fix, not a rewrite
Day 1, 15:00 Rebuild dependency with GOARM64=v8.1 Deploy p99 back to baseline; savings realized

Advantages and disadvantages

Continuous eBPF profiling is powerful but not free of trade-offs. Weigh it honestly.

Advantages (why it helps) Disadvantages (why it bites)
Zero code change — one DaemonSet profiles every process, any language Needs privileged/hostPID access — a real security surface to justify
Always-on — the answer is a query to any past window, no reproduction Sampling — poor for single rare events; you get statistics, not a trace
Low overhead (~1%) via in-kernel aggregation Off-CPU blind by default — misses lock/IO waits unless you add off-CPU probes
Fleet-wide attribution — rank cost across all services Symbolization is fragile — missing debug info → [unknown]/hex, a real ops burden
pprof-portable — same format as Go runtime, go tool pprof, interoperates Kernel-version-sensitive — DWARF unwinding wants 5.10+; old nodes degrade
Differential view finds regressions in seconds across a deploy Managed-cloud VMs often lack PMU — software clock only, slight precision loss
Trace correlation — span-scoped flame graphs pinpoint why a span is slow Cardinality risk — sloppy labels (e.g. trace_id as a series) explode storage
Storage is an object-store bill (Pyroscope), not node disk Profiles are bulkier than metrics; retention costs money at fleet scale

The model is right for large, polyglot fleets where CPU is real money and reproduction is expensive — payments, ad tech, streaming, big SaaS. It is overkill for a handful of low-traffic services where a one-off perf/pprof session suffices, and it is the wrong tool as a sole latency instrument for I/O-bound services (add off-CPU or lean on tracing). The disadvantages are all manageable — but only if you know they exist, which is the point of the troubleshooting section.

Hands-on lab

Stand up Grafana Pyroscope, run the eBPF profiler against a workload, read a flame graph, then diff two windows — all on a local kind/minikube cluster (free-tier-friendly; delete at the end). Where an eBPF agent can’t get privileges locally (some Docker Desktop kernels), a fallback using the Pyroscope Go SDK is included so the lab still works end to end.

Step 1 — Namespace and Pyroscope (single-binary mode).

kubectl create namespace monitoring
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
# Single-binary Pyroscope with local filesystem storage (lab only)
helm install pyroscope grafana/pyroscope -n monitoring \
  --set pyroscope.persistence.enabled=false
kubectl -n monitoring rollout status statefulset/pyroscope

Expected: the pyroscope StatefulSet reaches Ready. Port-forward the UI:

kubectl -n monitoring port-forward svc/pyroscope 4040:4040 &
# Open http://localhost:4040 — the Pyroscope UI loads (empty until data arrives).

Step 2 — Deploy a CPU-burning sample workload. A tiny Go service with a deliberately hot function (a per-request regex compile) makes the flame graph obvious:

kubectl -n monitoring create deployment hotpath \
  --image=grafana/pyroscope-rideshare-go:latest
kubectl -n monitoring rollout status deployment/hotpath

(The Grafana rideshare demo images are SDK-instrumented and push to Pyroscope directly, which also exercises the SDK path used in Step 5.)

Step 3 — Deploy the Alloy eBPF profiler DaemonSet. Apply the config and DaemonSet from the deployment section (ConfigMap + DaemonSet). Point pyroscope.write at http://pyroscope.monitoring.svc.cluster.local:4040:

kubectl -n monitoring create configmap alloy-profiler-config \
  --from-file=config.alloy=./alloy-config.alloy
kubectl -n monitoring apply -f ./alloy-daemonset.yaml
kubectl -n monitoring rollout status ds/alloy-profiler

Expected: one alloy-profiler pod per node, READY equal to node count.

Step 4 — Confirm the eBPF program loaded (no silent permission failure).

kubectl -n monitoring logs ds/alloy-profiler | grep -iE "ebpf|perf_event|loaded|unwind"
# Look for lines indicating perf events armed and (if native targets) unwind tables built.
# A "permission denied" / "operation not permitted" here means missing CAP_BPF/CAP_PERFMON
# or a kernel too old — on Docker Desktop, skip to Step 5's SDK fallback.

Step 5 — (Fallback / always works) SDK path. If the eBPF agent can’t get privileges locally, the SDK-instrumented hotpath/rideshare pods from Step 2 still push profiles. Either way, generate load if the demo doesn’t self-load:

kubectl -n monitoring port-forward deploy/hotpath 5000:5000 &
# hit the endpoint in a loop to create CPU work
for i in $(seq 1 2000); do curl -s localhost:5000/ >/dev/null; done

Step 6 — Read a flame graph. In the UI (http://localhost:4040): pick the process_cpu profile type and the workload’s app/service, set the time range to the last 15 minutes. Expected: a flame graph with a wide leaf at the hot function (the per-request compile / rideshare hot path). Confirm you see function names, not hex — symbolization is working.

Step 7 — Diff two windows. Change the workload (e.g. scale it, or in a real app deploy a fix), then use the UI’s comparison / diff view: set a baseline range (before) and a comparison range (after). Expected: frames that got busier are red, quieter are blue. This is the regression-hunting workflow in miniature.

Validation checklist. You deployed a profiling store, ran an eBPF DaemonSet (or the SDK fallback), confirmed the program loaded, read a flame graph with real symbols, and diffed two windows — the full loop.

Step What you did What it proves Real-world analogue
1 Pyroscope single-binary The store exists and queries Your profiling backend
3 Alloy eBPF DaemonSet One agent per node, zero code change Fleet-wide profiling rollout
4 Grep for ebpf/perf_event The program actually loaded Catching silent permission failures
6 Flame graph, real names Unwinding + symbolization work 90-second hot-path diagnosis
7 Diff baseline vs comparison Regression hunting is a query Before/after a deploy

Cleanup.

kubectl delete namespace monitoring
# If you created a kind cluster for this: kind delete cluster

Cost note. Entirely local — the only cost is your laptop’s CPU for the duration. In a real cluster, the ongoing cost is the object-store retention and the ~1% agent overhead, both quantified in Cost & sizing below.

Common mistakes & troubleshooting

This is the part you bookmark: the failure modes that make a profiler produce nothing, produce garbage, or — worst — produce a plausible-looking flame graph that lies. First as a scannable table, then the highest-impact entries expanded.

# Symptom Root cause Confirm (exact command / place) Fix
1 Flame graph is all [unknown] / hex frames under a native function Missing debug info for that build-ID UI shows hex leaves; check the binary’s build-ID has no debuginfod entry Upload debug info per build-ID (parca-debuginfo upload) or set DEBUGINFOD_URLS; enable runtime perf map
2 Stacks are shallow — just the leaf, nothing below Frame pointers omitted and DWARF unwinding off Agent logs show FP-only mode; binary built -O2 without -fno-omit-frame-pointer Enable DWARF unwinding (--dwarf-unwinding=enabled); or rebuild with frame pointers
3 No samples at all; agent running but store empty eBPF program failed to load (permissions/kernel) logs ds/... | grep -i "permission|not permitted|perf_event" Grant CAP_BPF+CAP_PERFMON(+SYS_PTRACE) or privileged; upgrade kernel to 5.10+
4 Samples arrive but can’t be attributed to pods/containers hostPID not set Pod spec missing hostPID: true; stacks labeled with wrong/empty container Set hostPID: true on the DaemonSet
5 Python/Java/Node frames are [unknown], native frames fine Missing per-runtime unwinder/perf map JIT frames show hex; runtime started without perf map flags Enable perf map (--perf-basic-prof Node; DOTNET_PerfMapEnabled=1; JVM frame pointers); use supported interpreter version
6 “This service is slow but its CPU flame graph is nearly empty” Cost is off-CPU (locks/IO), invisible to CPU profile CPU util low during the slow period; trace shows waiting Enable off-CPU profiling; or read the trace; don’t widen the CPU profile
7 “I found the leak” — but memory keeps growing after the fix Read alloc_space (churn), not inuse_space (retained) Panel sample_type is alloc_space/alloc_objects Switch to inuse_space; diff in-use over time to find the retaining site
8 Optimized the widest frame, nothing improved Optimized a low, wide caller, not the hot leaf Wide frame is main/router with expensive children Follow width upward; optimize the leaf
9 Overhead spiked after “improving resolution” Sample rate cranked fleet-wide (e.g. 100 Hz everywhere) Agent config sample_rate: 100; node CPU up 2–3% Return to 19 Hz fleet-wide; raise per-namespace only for investigations
10 Storage bill / cardinality exploded trace_id (or another unbounded value) added as a series label Series count spiked in the profiling DB metrics Carry trace_id on samples for lookup, not as a top-level series dimension
11 Diff view shows red but users didn’t get slower Diff shows CPU-share change, not latency Latency metrics/traces flat over the same window Correlate with latency; a CPU shift isn’t automatically a user-facing regression
12 Kernel frames present, user frames missing (or vice-versa) One unwinder path failing (user DWARF vs kernel ORC) Only [kernel] frames, or only user frames resolve Fix the failing side: user → debug info/unwinder; kernel → kallsyms/kernel config
13 Aggregate flame graph hides a tail pathology Window blends fast steady state with rare slow path Narrowing the window changes the shape dramatically Narrow the window, filter by label, or use span-scoped profiling
14 Profiles stop during load spikes Ingest rate limit hit Profiling DB logs “ingestion rate limit”; ingestion_rate_mb too low Raise ingestion_rate_mb; or lower sample rate on the noisy namespace

The expanded form for the entries that bite hardest:

1. Flame graph is all [unknown] / hex under a recognizable native function. Root cause: the binary is stripped and there is no debug info for its build-ID anywhere the symbolizer can reach. Confirm: the UI shows bare addresses at the leaves; extract the binary’s build-ID (readelf -n <binary> | grep -i build-id if you can reach the node/image) and confirm no debuginfod/store entry exists for it. Fix: in CI, parca-debuginfo upload the .debug ELF for that build-ID, or set DEBUGINFOD_URLS to a server that has it. For managed runtimes, enable the runtime’s perf map. This is a symbolization fix — never “restart the agent.”

2. Stacks are shallow — just the leaf, nothing below it. Root cause: the binary omitted frame pointers (release -O2) and the agent isn’t using DWARF unwinding, so the walk stops at the first FP-less frame. Confirm: agent logs indicate frame-pointer-only mode; the binary was built without -fno-omit-frame-pointer. Fix: turn on the agent’s DWARF/.eh_frame unwinding (--dwarf-unwinding=enabled on Parca; on by default in modern Alloy/OTel profilers), and for your services build with frame pointers so the cheap path also works.

3. No samples at all; agent is running but the store is empty. Root cause: the eBPF program failed to load — missing capabilities or a too-old kernel — and the agent kept running without profiling. Confirm: kubectl logs ds/alloy-profiler | grep -iE "permission|not permitted|perf_event|bpf" shows an EPERM/verifier/perf_event_open failure. Fix: grant CAP_BPF+CAP_PERFMON (and often CAP_SYS_PTRACE for reading other processes’ maps) or run privileged: true; ensure the kernel is 5.4+ (5.10+ for stable DWARF unwinding) and BTF is present.

4. Samples arrive but can’t be attributed to the right pod/container. Root cause: hostPID: true is missing, so the PID the perf event reports (host namespace) doesn’t match anything in the agent’s PID namespace. Confirm: the DaemonSet spec lacks hostPID; stacks are labeled with empty or wrong container/pod. Fix: set hostPID: true. This is the single most common “it runs but the data is useless” mistake.

6. A slow service with a nearly-empty CPU flame graph. Root cause: the latency is off-CPU — threads blocked on locks, disk, network, or epoll — which a CPU sampler cannot see by construction. Confirm: CPU utilization is low during the slow period; a trace of a slow request shows time in waits, not compute. Fix: enable off-CPU profiling (eBPF sched_switch) if your agent supports it, or use the language’s block/mutex profile, or read the trace. Do not keep widening the CPU profile — the answer isn’t there.

7. “I found the leak” but memory keeps climbing after the fix. Root cause: you optimized the top of the allocation profile (alloc_space, total bytes allocated — churn) when the problem is retention (inuse_space). Confirm: the panel’s sample_type is alloc_space/alloc_objects; the fixed site allocated a lot but held nothing. Fix: switch to inuse_space/inuse_objects and diff it over time — the site whose in-use bytes grow across the window is the leak.

Best practices

Security notes

The security controls that also improve reliability — they pull the same direction here:

Control Mechanism Secures against Also prevents
Scoped caps (not privileged) CAP_BPF+CAP_PERFMON(+SYS_PTRACE) Full-host compromise via the agent Over-broad access surprising audits
Dedicated namespace + PSS exception Namespace label + RBAC Privileged pods sprawling cluster-wide Accidental co-scheduling issues
Internal-only debuginfod/store Private endpoint + authn Source-structure exfiltration Symbolization depending on flaky public servers
Backend authn/RBAC + TLS Ingress auth, mTLS Public exposure of profiles Unauthorized ingest / tampering
Per-tenant ingest limits ingestion_rate_mb per tenant One tenant starving others Runaway cost from a misconfigured agent
Label allow-listing Relabel rules PII/secret leakage into labels Cardinality explosions

Cost & sizing

Two costs dominate: the agent overhead (compute you pay on every node) and the storage/retention (object-store bytes plus query capacity). Both are tunable, and the default posture is cheap.

Rough monthly picture for the 600-pod, 3-cluster fleet in the scenario:

Cost driver What you pay for Rough INR / month What it buys Watch-out
Agent overhead @ 19 Hz ~0.2–0.5% CPU across nodes effectively noise (fraction of a node) Zero-touch fleet-wide profiles Cranking rate fleet-wide multiplies it
Pyroscope object store (S3, 30d) Storage-GB + requests ~₹3,000–8,000 Queryable 30-day history Retention window drives it linearly
Pyroscope compute (queriers/compactor) Pods for ingest/query ~₹8,000–20,000 Query throughput Under-provision → slow/failed queries
debuginfod / debug-info store Small storage + serve ~₹1,000–3,000 Central symbolization Must stay internal/secured
Cores recovered by reviews (negative cost) −₹ lakhs possible Deprovisioned capacity Only if you act on the backlog

The sizing decision rule: start at 19 Hz, profile labeled targets only, 30-day retention on object storage, symbolize in CI. Scale query compute to your read pattern, and treat the whole thing as cost-saving infrastructure whose bill is dwarfed by the compute it lets you reclaim. Sample-rate escalation is the one knob that can turn cheap into expensive — keep it scoped.

Interview & exam questions

1. Why is a flame graph’s x-axis “share, not time,” and what does that forbid? A sampling profiler interrupts the CPU at a fixed rate and counts stacks; a frame’s width is the fraction of samples it appears in, an unbiased estimate of CPU-time share. It is not a timeline — left-to-right order is not sequence, and you cannot read a single request’s duration off it. Duration-of-one-request is a trace question; time-share-over-a-window is the profile’s answer.

2. Explain frame-pointer vs DWARF vs ORC unwinding and when each is used. Frame-pointer unwinding follows the rbp chain — cheap, but only works if the binary kept frame pointers (release builds usually omit them). DWARF/.eh_frame CFI is a per-PC table (present even in stripped binaries) that an agent flattens into BPF maps to unwind frame-pointer-less binaries in-kernel. ORC is the kernel’s own compact unwind format used for the kernel portion of a stack. eBPF agents use FP where available, DWARF-CFI otherwise for user stacks, and rely on ORC for kernel stacks.

3. A flame graph is full of [unknown]/hex frames under a native function. Broken agent? No — that is a symbolization failure: there is no debug info for that binary’s build-ID reachable by the symbolizer. Fix the build-ID (upload the .debug ELF in CI via parca-debuginfo upload, or set DEBUGINFOD_URLS, or enable the runtime’s perf map for JIT frames), not the agent. The agent unwound fine; it just can’t name the addresses.

4. Why does an eBPF profiler aggregate stacks in the kernel, and why does that matter? It increments a counter in a BPF hash map keyed by (pid, user_stack_id, kernel_stack_id) instead of shipping one event per sample to user space. This moves only distinct, pre-counted stacks across the kernel boundary, which is what keeps overhead near ~1% at high sample rates and fleet scale — the opposite of perf record, which copies every sample.

5. Difference between alloc_space and inuse_space, and the trap. alloc_space is total bytes allocated over the window — the driver of GC pressure/churn. inuse_space is live/retained memory right now — the leak signal. A function can dominate alloc_space (allocates lots of short-lived objects) yet contribute nothing to inuse_space. Concluding “leak” from an allocation profile is the classic mistake; confirm with inuse_space diffed over time.

6. A service is slow but its CPU flame graph is nearly empty. What’s happening? The cost is off-CPU — the threads are blocked on locks, disk, network, or epoll, and a CPU sampler by construction cannot see time when nothing is on-CPU. Switch to off-CPU profiling (eBPF sched_switch), a mutex/block profile, or read the trace. Widening the CPU profile won’t help; the latency isn’t in compute.

7. What is span-scoped profiling and how is it implemented? It’s a flame graph of just one span’s CPU time. Implementation: the SDK/runtime puts the active span_id/trace_id into the pprof labels (Go’s pprof.Do/pprof.Labels), so every CPU sample taken while that span is active carries it. Clicking a slow span in the trace UI then pulls the samples with that span_id. Caveat: trace_id is unbounded, so it’s carried on the sample for lookup, not used as a top-level storage series label.

8. Why 19 Hz fleet-wide instead of 100 Hz everywhere? Overhead and storage scale linearly with rate, but statistical error falls only as 1/sqrt(samples) — going 19→100 Hz (5.3×) roughly halves the error for 5× the cost, textbook diminishing returns. So run 19 Hz everywhere (cheap, good enough for hot-path and aggregate analysis) and crank a single namespace to 100 Hz only for a scoped, short investigation.

9. What does hostPID: true do for the profiler and what breaks without it? The perf event reports the host-namespace PID; hostPID: true lets the agent see the host PID namespace so it can map each sampled PID to the right container and binary. Without it, samples arrive but are unattributable — labeled with wrong or empty container/pod. It’s the most common “it runs but the data is useless” misconfiguration.

10. Why is pprof the format that ties this ecosystem together? pprof (profile.proto) is a compressed protobuf modeling a profile as samples (a stack + values + labels) over deduplicated location/function/string tables. Parca, Pyroscope, the Go runtime, and go tool pprof all speak it (OTLP profiles carry the same model), so a profile from any source interoperates — you can pull any of them into go tool pprof -diff_base to diff a regression.

11. How do you use continuous profiling to cut cloud cost? Query the cluster-wide aggregate CPU flame graph for a representative week, sort self-CPU leaves, convert share to cores (share × provisioned cores), and triage the top by cores-returned-vs-effort. Cross-service fat frames (a per-request regex compile, software crypto, allocation churn) are each deprovisionable cores. Fix, verify with a before/after diff, then lower replicas/node size. It’s the rare observability tool that reduces the bill.

12. What kernel and privilege requirements does an eBPF profiler have? Kernel 5.4+ (5.10+ comfortable for stable DWARF unwinding), BTF present for CO-RE (one agent across kernels), and either CAP_BPF+CAP_PERFMON(+CAP_SYS_PTRACE) or privileged: true to load programs, arm perf events, and read other processes’ maps — plus hostPID: true for attribution. Managed-cloud VMs often lack PMU access, so agents default to the software CPU clock.

These map to no single vendor exam but align with the CNCF observability body of knowledge (the CNCF OTel Certified Associate touches the profiling signal and correlation), and to SRE/platform-engineering interviews where performance analysis, eBPF, and cost optimization come up. The trace-correlation and OTLP-profiles material overlaps with OpenTelemetry certification content.

Quick check

  1. You’re told a wide frame at the bottom of a flame graph is “the bottleneck.” Why is that usually wrong, and where should you look instead?
  2. A stripped Rust service’s flame graph shows hex addresses at the leaves. Is the agent broken? What’s the fix?
  3. Your Go service spends 30% of CPU in the garbage collector. Which profile type do you open, and which value do you sort by?
  4. A service’s p99 is 400 ms but its CPU flame graph is almost empty. What kind of profiling (or signal) do you switch to, and why?
  5. You want a flame graph of exactly one slow span. What has to be attached to the samples, and what’s the cardinality caveat?

Answers

  1. The bottom frame is the entry point (main/thread root); it’s 100% wide because every sample descends through it, which says nothing. The cost is in its childrenfollow the width upward to the hot leaf and optimize that.
  2. Not broken — it’s a symbolization failure: no debug info for that binary’s build-ID is reachable. Fix by uploading the .debug ELF for the build-ID in CI (parca-debuginfo upload) or pointing the symbolizer at a debuginfod server that has it. The agent unwound correctly; it just can’t name the addresses.
  3. Open the allocation profile and sort by alloc_space (total bytes allocated) — GC pressure is driven by allocation churn, so the top allocation sites are what to attack (pool/reuse buffers). runtime.mallocgc prominent in the CPU profile is the pointer that sends you there.
  4. Switch to off-CPU profiling (eBPF sched_switch) or read the trace — the latency is off-CPU (locks/IO/epoll), which a CPU sampler cannot see because nothing is on-CPU during the wait. Widening the CPU profile won’t reveal it.
  5. The active span_id (and trace_id) must be attached to each CPU sample as pprof labels (via pprof.Do/the SDK). Caveat: trace_id is unbounded cardinality, so it’s carried on the sample for lookup — not used as a top-level storage series dimension you group by, or you explode the series count.

Glossary

Next steps

You can now deploy an eBPF profiler, symbolize correctly, read and diff flame graphs, correlate with traces, and turn CPU-share into cost savings. Build outward:

ebpfcontinuous-profilingparcapyroscopeflame-graphspprofopentelemetryobservability
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading