Tekko

Language

Get in Touch →

Usually respond within 24 hours

Back to BlogBackend

Continuous Profiling: Using eBPF and Pyroscope for Go Microservices

7 min read
OpenTelemetryGoeBPFPerformanceObservability
Continuous Profiling: Using eBPF and Pyroscope for Go Microservices

For years, the industry has rallied around the 'three pillars of observability': metrics, logs, and traces. While these signals are essential for understanding the health and flow of a distributed system, they often leave a critical question unanswered: Why exactly is this specific line of code consuming so much CPU?

In a production Go microservice environment, you might see a spike in CPU usage via Prometheus (metrics) and identify the slow request via Jaeger (tracing), but pinpointing the exact function responsible usually involves guesswork or local reproduction. This is where continuous profiling comes in. By integrating OpenTelemetry with eBPF-based profiling via Pyroscope, we can gain deep, code-level visibility into production workloads with near-zero overhead.

The Evolution of Profiling: From pprof to eBPF

Go developers are likely familiar with net/http/pprof. It is a powerful tool that allows you to collect runtime profiles (CPU, memory, goroutines) by hitting an HTTP endpoint. However, traditional pprof has limitations in a modern microservices architecture:

  1. Manual Intervention: You typically trigger a profile only when you suspect a problem, often missing transient spikes.
  2. Instrumentation Overhead: While pprof is efficient, it still requires importing the package and exposing an endpoint, which might be a security concern if not properly gated.
  3. The 'Heisenberg' Effect: The act of profiling can sometimes alter the performance characteristics of the application.

Enter eBPF

Extended Berkeley Packet Filter (eBPF) changes the game. It allows us to run sandboxed programs in the Linux kernel without changing kernel source code or loading kernel modules. For profiling, this means we can sample the stack traces of running processes directly from the kernel.

Because eBPF operates at the kernel level, it can profile any process—including your Go binary, the C libraries it links against, and even the kernel itself—without requiring any changes to your application code. This is 'zero-instrumentation' profiling.

Why Continuous Profiling Matters for Go

Go's runtime is sophisticated. It manages its own scheduling (goroutines) and memory (garbage collection). While this makes developers productive, it creates 'black boxes' in production.

Continuous profiling allows you to see:

  • GC Pressure: Is a specific function triggering excessive allocations, leading to frequent Stop-The-World (STW) events?
  • Lock Contention: Are goroutines blocking on a sync.Mutex for extended periods?
  • Inefficient Serialization: Is json.Marshal consuming 40% of your CPU cycles during peak load?

By having a historical record of these profiles, you can compare 'last Wednesday's' performance with 'today's' to identify regressions introduced by a specific commit.

The Architecture: OpenTelemetry, Pyroscope, and eBPF

To implement this, we use a three-tier architecture:

  1. The Agent (eBPF): A Pyroscope agent runs on the host (or as a DaemonSet in Kubernetes). It uses eBPF to sample stack traces from all running processes.
  2. The Collector (OpenTelemetry): The OTel Collector receives profiling data. OpenTelemetry is currently standardizing the profiling signal (OTEP 239), making it interoperable with traces and metrics.
  3. The Backend (Pyroscope/Grafana): Pyroscope stores the profiles in a time-series database optimized for stack traces and provides the 'Flame Graph' visualization.

Implementing the Solution

Let's walk through setting up continuous profiling for a Go microservice.

1. Deploying the Pyroscope eBPF Profiler

In a Kubernetes environment, the most effective way to deploy the profiler is as a DaemonSet. This ensures every node in your cluster is monitored. The agent discovers containers and automatically associates profiles with the correct metadata (pod name, namespace, etc.).

apiVersion: apps/v1 kind: DaemonSet metadata: name: pyroscope-ebpf-agent spec: template: spec: hostPID: true # Required to see processes on the host containers: - name: agent image: grafana/pyroscope-ebpf-agent:latest securityContext: privileged: true # Required for eBPF syscalls env: - name: PYROSCOPE_SERVER_ADDRESS value: "http://pyroscope:4040"

2. Identifying CPU Hotspots with Flame Graphs

Once the data flows into Pyroscope, you are presented with a Flame Graph. For a Go engineer, this is the 'Source of Truth.'

Imagine a service that processes incoming webhooks. You notice high CPU usage. Looking at the Flame Graph, you see a wide bar for regexp.(*Regexp).MatchString. This indicates that a significant portion of CPU time is spent on regular expression evaluation.

The Fix: You realize the regex is being compiled inside the function call instead of being defined as a global variable.

// Inefficient: Compiles regex on every call func validateInput(input string) bool { re := regexp.MustCompile(`^[a-z0-9]+$`) return re.MatchString(input) } // Efficient: Compiled once at startup var inputRegex = regexp.MustCompile(`^[a-z0-9]+$`) func validateInput(input string) bool { return inputRegex.MatchString(input) }

Without continuous profiling, you might have spent hours looking at database latency or network I/O, when the bottleneck was a simple CPU-bound logic error.

Integrating with OpenTelemetry Traces

One of the most powerful features of the OpenTelemetry ecosystem is Span-to-Profile linking. While a profile tells you what the CPU was doing on average, linking it to a trace tells you what the CPU was doing for a specific request.

By injecting the SpanID and TraceID into the profiling metadata, Pyroscope can filter profiles to show only the CPU cycles consumed by a specific trace. This allows you to answer: "Why was this specific 5-second request so slow?"

To achieve this in Go, you can use the Pyroscope Go SDK to wrap your tracer:

import ( "github.com/grafana/pyroscope-go" ) func main() { pyroscope.Start(pyroscope.Config{ ApplicationName: "order-service", ServerAddress: "http://pyroscope:4040", }) // Your application logic }

Real-World Example: The JSON Trap

Go's standard encoding/json library is reflection-based. In high-throughput microservices, this is a common CPU hotspot.

During a recent performance audit of a production service, we used Pyroscope to analyze a service that was struggling to maintain its p99 latency targets. The Flame Graph showed that 35% of the total CPU time was spent in runtime.mallocgc and reflect.Value.Interface. Deep in the stack, these were called by json.Unmarshal.

By switching to a code-generation-based library like easyjson or ffjson, we reduced the CPU usage of that specific service by 25% and lowered the p99 latency by 150ms. The key takeaway here isn't just that 'reflection is slow,' but that we had the data to prove exactly how much it was costing us in production dollars.

Managing Overhead and Security

Engineers are often wary of running profilers in production. However, eBPF-based profiling is designed for this.

  • CPU Overhead: Typically, sampling at 100Hz (100 times per second) results in less than 1-2% CPU overhead. This is a negligible price to pay for the visibility gained.
  • Storage: Pyroscope uses specialized compression and downsampling. Profiles are aggregated over time, so you don't need to store every single stack trace indefinitely.
  • Security: The eBPF agent requires CAP_SYS_ADMIN or privileged: true to load its programs. In highly regulated environments, this requires a security review. However, the agent only reads stack traces and does not intercept sensitive data payloads.

Best Practices for Continuous Profiling

  1. Tag Everything: Ensure your profiles are tagged with service.name, service.version, and deployment.environment. This allows you to filter profiles by 'canary' vs. 'production.'
  2. Compare Profiles: Use the 'diff' view in Pyroscope. Compare the profile of a healthy pod against a struggling one to see the delta in function execution time.
  3. Monitor the Profiler: Keep an eye on the CPU usage of the Pyroscope agent itself. If it's consuming too much, reduce the sampling frequency.
  4. Baseline Your Service: Establish a baseline profile during a period of normal load. This makes it much easier to spot anomalies during a traffic surge.

Conclusion

Continuous profiling is no longer a luxury; it is the natural progression of the observability stack. By combining the vendor-neutral standards of OpenTelemetry with the low-overhead power of eBPF and the visualization capabilities of Pyroscope, you can stop guessing about performance bottlenecks.

For Go microservices, where concurrency and runtime behavior can be complex, this visibility is transformative. It allows you to move from reactive firefighting to proactive optimization, ensuring your infrastructure is not just running, but running efficiently.

Actionable Next Steps:

  • Deploy the Pyroscope eBPF agent to a staging Kubernetes cluster.
  • Identify the top three CPU-consuming functions in your primary Go service.
  • Link your OpenTelemetry spans to your profiles to correlate latency with CPU hotspots.