Continuous Profiling in Production

DevOpsObservabilityPerformanceBackend
Share on LinkedIn

A deploy increased P99 latency from 200 ms to 800 ms. You SSH in, run perf record, capture 30 seconds of data, and find nothing—the spike happened four hours ago during peak traffic. Continuous profiling solves this by sampling stack traces every 10–100 milliseconds around the clock, storing them as time-series data. When latency spikes, you open a flame graph from that exact time window and see which function grew.

How continuous profiling works

Application process
  ↓ (every 10ms)
Sampler collects stack trace → Label with pod, version, region
  ↓
Compress and ship to profile store (Pyroscope, Parca, Datadog)
  ↓
Query by time range + labels → Render flame graph

eBPF profilers attach at the kernel level—no code changes, no agent per language. They read stack frames from /proc or DWARF debug info.

Pyroscope setup

# docker-compose.yml
services:
  pyroscope:
    image: grafana/pyroscope:latest
    ports:
      - "4040:4040"
    volumes:
      - pyroscope-data:/var/lib/pyroscope

  app:
    image: my-app:latest
    environment:
      PYROSCOPE_SERVER_ADDRESS: http://pyroscope:4040
      PYROSCOPE_APPLICATION_NAME: my-app

Go integration:

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

pyroscope.Start(pyroscope.Config{
    ApplicationName: "my-app",
    ServerAddress:   os.Getenv("PYROSCOPE_SERVER_ADDRESS"),
    Tags: map[string]string{
        "hostname": os.Getenv("HOSTNAME"),
        "version":  os.Getenv("APP_VERSION"),
    },
    ProfileTypes: []pyroscope.ProfileType{
        pyroscope.ProfileCPU,
        pyroscope.ProfileAllocObjects,
        pyroscope.ProfileInuseSpace,
    },
})

Python integration:

import pyroscope

pyroscope.configure(
    application_name="my-app",
    server_address="http://pyroscope:4040",
    tags={"region": "us-east-1", "version": "1.2.3"},
)

Reading flame graphs

┌──────────────────────────────────────────────────────────┐
│                      main()                               │  ← root
├──────────────────────┬───────────────────────────────────┤
│   handle_request()   │         startup()                 │
├──────────┬───────────┤                                   │
│ db_query │ json_parse│                                   │
│  (45%)   │   (30%)   │                                   │
└──────────┴───────────┴───────────────────────────────────┘

Parca with Kubernetes

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: parca-agent
spec:
  template:
    spec:
      containers:
        - name: parca-agent
          image: ghcr.io/parca-dev/parca-agent:v0.30.0
          args:
            - --remote-store-address=parca.parca.svc:7070
            - --node=$(NODE_NAME)
          securityContext:
            privileged: true  # required for eBPF

Parca runs as a DaemonSet—one agent per node profiles all pods on that node.

Turning profiles into fixes

Scenario: CPU spike after deploy.

  1. Open Pyroscope, filter version=1.2.3, time range = spike window.
  2. Flame graph shows json.Marshal at 35% (was 5% in version=1.2.2).
  3. Git diff shows new code serializing a 50 MB struct on every request.
  4. Fix: serialize only required fields. CPU returns to baseline.

Scenario: Memory leak over 48 hours.

  1. Query inuse_space profile type, compare hour 1 vs hour 48.
  2. cache.Store grows from 2% to 40% of heap.
  3. Cache has no TTL—entries accumulate.
  4. Fix: add LRU eviction with 1-hour TTL.

Overhead management

Strategy Overhead Coverage
eBPF 100 Hz on all pods 1–3% 100%
Agent on 20% of pods <1% effective Statistical
Profile only on canary ~0% on main Canary only
On-demand trigger 0% until triggered Point-in-time

Start with eBPF profiling on 100% of instances at 100 Hz. Reduce sampling rate to 20 Hz if overhead exceeds 5%.

Integrating with traces

Grafana pairs profiles with traces via exemplars:

Trace span (slow request) → "View profile at this timestamp" → Flame graph

This connects "this request was slow" to "this function was hot during that request."

Pyroscope and Parca setup

Deploy continuous profiling alongside existing observability stack:

# docker-compose addition
pyroscope:
  image: grafana/pyroscope:latest
  ports: ["4040:4040"]
  volumes: ["pyroscope-data:/data"]

# Application config (Go example)
import "github.com/grafana/pyroscope-go"

pyroscope.Start(pyroscope.Config{
    ApplicationName: "api-server",
    ServerAddress:   "http://pyroscope:4040",
    ProfileTypes:    []pyroscope.ProfileType{
        pyroscope.ProfileCPU,
        pyroscope.ProfileAllocObjects,
        pyroscope.ProfileInuseObjects,
    },
})

Zero instrumentation code for supported languages — agent samples automatically. Query by service, pod, and time range in Grafana.

Reading flame graphs

Flame graph interpretation for production debugging:

Width  = time spent in function (wider = more CPU)
Height = call stack depth (bottom = entry point, top = leaf)
Color  = package/namespace (usually random, ignore color)

Look for:

Compare profiles before and after deploy to catch performance regressions.

Profile types and when to use each

Profile type Shows Use for
CPU On-CPU time per function Hot path optimization
inuse_space Currently allocated memory Memory leak detection
alloc_space Total allocations (GC pressure) Allocation-heavy code paths
goroutines / threads Concurrent execution Concurrency bottlenecks
mutex / block Lock contention Deadlock and contention

Run CPU profiling continuously. Trigger inuse_space profiling when heap metrics trend upward.

Failure modes

Production checklist

Profile production at 1–5% sample rate continuously — episodic profiling during incidents captures the wrong code path because traffic patterns differ under stress.

Resources

Frequently asked questions

What is the performance overhead of continuous profiling?

Modern eBPF-based profilers (Parca, Pyroscope with eBPF) add 1–5% CPU overhead at default sampling rates (100 Hz). This is acceptable for most production workloads. Legacy instrumentation-based profilers can add 10–20% and should use lower sampling rates or profile subsets of instances.

How is continuous profiling different from ad-hoc profiling?

Ad-hoc profiling captures a snapshot when you manually trigger it—often after the performance problem has passed. Continuous profiling samples 24/7 and stores time-series profile data, letting you compare CPU usage before and after a deployment or during a latency spike hours ago.

Which languages support production profiling?

eBPF profilers work for any compiled language (Go, Rust, C++, Java) and natively compiled Python. Node.js and Ruby need runtime-specific agents. JVM profiling works via async-profiler or eBPF with frame pointer support.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →