VPS Latency Spike Root-Cause Analysis: bpftrace Kernel-Level Micro-Tracing in Practice
Last week’s post-mortem had one maddening line: “Prometheus says P99 went from 120ms to 2.4s, but I have no idea why.”
This is the reality of most VPS ops: the metrics layer (Grafana, Prometheus) tells you what happened (latency went up, CPU isn’t saturated, memory is fine), but not why — is it scheduler queueing? NIC drops? Disk I/O jitter? A lock contention somewhere deep in a kernel path? To answer “why,” you need to go below user space.
This article demonstrates how to use bpftrace (an eBPF-based tracing tool) to attribute latency spikes to concrete root causes on a 2C4G VPS, using 4 lightweight probes that run with negligible overhead (<0.5% CPU) and require no service restarts, no agent installs, no packet loss. Real case study: located a systemd-resolved DNS stall causing 800ms P99 spikes in 3 minutes.
1. Why the Metrics Layer Isn’t Enough: Three “Invisible” Latency Classes
| Symptom | Prometheus sees it? | True root cause | Kernel probe needed |
|---|---|---|---|
| P99 spikes but CPU/memory normal | ✅ value only | CFS scheduler runqueue latency | ✅ runqlat |
| Traffic normal but intermittent request timeouts | ✅ value only | NIC queue drops / TCP retransmission | ✅ netdrop + tcp_retrans |
| Intermittent 1–3s response stalls | ⚠️ IO wait only | Disk I/O path jitter (journald, cron batch writes) | ✅ disklat |
Core principle: latency is a distribution, not a mean. P99/P999 spike root causes hide in micro-paths inside the kernel — only kernel-level probes can expose them.
2. Four bpftrace Probes: Covering the “Big Four” Killers in One Pass
Probe 1: runqlat — Scheduler Queue Latency (the most subtle killer)
In the CFS scheduler, there’s a gap between a task becoming runnable and actually hitting the CPU. If CPU isn’t saturated but latency is high, this is usually the culprit.
# /usr/local/bin/probes/runqlat.bt
#!/usr/bin/env bpftrace
kprobe:pick_next_task + {
@start[tid] = nsecs; // moment task is picked
}
kprobe:schedule_hr_timer,
tracepoint:sched:sched_wakeup {
@start[tid] = nsecs;
}
kprobe:__schedule,
kprobe:deactivate_task {
if (@start[tid]) {
@us = hist((nsecs - @start[tid]) / 1000); // microsecond histogram
delete(@start[tid]);
}
}
interval:s:60 {
exit(@us); // print histogram every 60s and exit
}
sudo bpftrace /usr/local/bin/probes/runqlat.bt
# @us: 1-2 | 3-4 | 5-8 | 9-16 | 17-32 | 33-64 | 65-128 | 129-256 | 257-512 | 513-1k | 1k-2k | 2k-4k
# 15 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0
# Healthy: nearly everything <10us. If the 129-256us bucket appears → scheduler queueing is the P99 culprit
Probe 2: netdrop + tcp_retrans — Network Drops and Retransmissions
# NIC drops (software + hardware queues)
sudo bpftrace -e '
kprobe:napi_poll { @napi[tid] = nsecs; }
kretprobe:napi_poll /@napi[tid]/ {
@dropped = sum(arg0 >> 47); // drop bits
@lat_us = hist((nsecs - @napi[tid]) / 1000);
delete(@napi[tid]);
}
interval:s:30 { exit(); }'
# TCP retransmission counters (zero-intrusion kernel counter reads)
sudo bpftrace -e '
kprobe:tcp_retransmit_skb { @retrans[sym]++; }
kprobe:tcp_retransmit_timer { @timer[sym]++; }
interval:s:30 { exit(); }'
Retransmission rate >0.5% correlating with P99 spikes → network path is the root cause. Check
ethtool -S eth0 | grep -i dropand upstream routing next.
Probe 3: disklat — Disk I/O Path Jitter
Most VPS cloud disks (virtio-blk / NVMe) see 100ms–seconds-long I/O spikes from journald batch flushes, cron backups, and rsync runs.
sudo bpftrace -e '
kprobe:blk_mq_submit_bio {
@start[tid, comm] = nsecs;
}
kretprobe:blk_mq_complete_request /@start[tid, comm]/ {
@lat_ms = hist((nsecs - @start[pid, comm]) / 1000000);
delete(@start[pid, comm]);
}
interval:s:60 { exit(); }'
Probe 4: tcp_connect_lat — Outbound Connection Setup Latency
Pairs with a DNS probe (getaddrinfo is user-space but can take huge amounts of time) to catch “slow connection setup” issues.
sudo bpftrace -e '
kprobe:inet_csk_connect {
@start[tid, comm] = nsecs;
}
kretprobe:inet_csk_connect /@start[tid, comm]/ {
@conn_ms[comm] = hist((nsecs - @start[tid, comm]) / 1000000);
delete(@start[tid, comm]);
}
interval:s:60 { exit(); }'
3. Real Case: Locating a DNS Resolution Spike in 3 Minutes
Symptom
- Grafana:
http_request_duration_seconds{quantile="0.99"}shows 800ms–2.4s spikes every ~4 hours, lasting 3–5 minutes. - CPU: CFS utilization only 40% during spikes, no pressure.
- Memory, disk I/O: all normal.
Investigation path (probe order)
- runqlat: all distributions <10us → rule out scheduler queueing.
- tcp_retrans: normal retransmission rate during spike window (<0.1%) → rule out link drops.
- disklat: no anomalous buckets → rule out I/O jitter.
- tcp_connect_lat + DNS probe:
# The key hit: track getaddrinfo duration (user-space symbol, bpftrace attaches directly)
sudo bpftrace -e '
uprobes:/lib/x86_64-linux-gnu/libc.so.6:getaddrinfo {
@start[tid, comm] = nsecs;
}
uretprobe:/lib/x86_64-linux-gnu/libc.so.6:getaddrinfo /@start[tid, comm]/ {
@dns_ms[comm] = hist((nsecs - @start[tid, comm]) / 1000000);
delete(@start[tid, comm]);
}
interval:s:300 { exit(); }'
@dns_ms[systemd-resolve]: 1s-2s | 2s-4s | 4s-8s
@dns_ms[systemd-resolve]: 3 | 1 | 0
Conclusion: systemd-resolved DNS resolution entered the 1–4s range during the spike window, precisely correlated with P99. resolvectl statistics confirmed: upstream DNS server showed received=12040, noanswer=0, error=312 during the spike, and resolved.conf’s DNS=8.8.8.8 saw RTT jump from 12ms to 900ms in the spike window (upstream carrier link jitter).
Fix
# /etc/systemd/resolved.conf
[Resolve]
DNS=1.1.1.1 9.9.9.9
DNS=223.5.5.5 119.29.29.29
CacheFromLocalhost=no
sudo systemctl restart systemd-resolved
watch -n1 'resolvectl statistics | grep -E "rtt|error"'
After the fix, P99 stabilised at 120–150ms, no spikes in 48h of observation.
4. Integrating bpftrace into Daily VPS Ops
systemd service: automatic sampling on spike
# /etc/systemd/system/vps-latency-probe.service
[Unit]
Description=bpftrace latency root-cause probe (short burst)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/probes/burst_capture.sh
TimeoutStartSec=90
User=root
#!/bin/bash
# /usr/local/bin/probes/burst_capture.sh
# Triggered by Prometheus alert webhook when P99 > 800ms for 30s
OUT=/var/lib/vps-ops/probes
mkdir -p "$OUT"
TS=$(date +%s)
bpftrace probes/runqlat.bt > "$OUT/runqlat_$TS.txt" 2>&1 &
bpftrace probes/tcp_retrans.bt > "$OUT/tcp_retrans_$TS.txt" 2>&1 &
bpftrace probes/disklat.bt > "$OUT/disklat_$TS.txt" 2>&1 &
bpftrace probes/dns_lat.bt > "$OUT/dns_$TS.txt" 2>&1 &
wait
echo "probe burst captured -> $OUT/*_$TS.txt"
# /etc/systemd/system/vps-latency-probe.timer
[Timer]
OnCalendar=*-*-* *:*:00/15
Persistent=true
[Install]
WantedBy=timers.target
Prometheus alert integration
# alertmanager rule: P99 spike → webhook → trigger burst_capture.sh
- alert: vps_latency_p99_spike
expr: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 0.8
for: 30s
annotations:
summary: "P99 spike {{ $value }}s — triggering bpftrace burst capture"
5. bpftrace vs. Traditional Tools
| Tool | Intrusiveness | Overhead | What it catches | Best for |
|---|---|---|---|---|
perf stat | Low | Low | Whole-system sampling | Hot function location |
strace -T | Medium | High (per-syscall) | User-space call durations | Debugging single process |
bpftrace | Very low | <0.5% CPU | Any kernel event, µs precision | Continuous production observation |
bcc (Python) | Very low | Low | Same as bpftrace + more flexibility | Custom complex logic |
Key advantage: bpftrace requires no kernel modification, no module compilation, no service stop. One command can attach 4 probes to a production VPS for 10 minutes with negligible overhead.
6. Security & Compliance Notes
- bpftrace needs root /
CAP_BPF— create a dedicatedprobeuser with minimal sudoers:probe ALL=(root) NOPASSWD: /usr/bin/bpftrace /usr/local/bin/probes/*.bt - Probes are read-only —
kprobe/tracepointdon’t mutate kernel state, butkretprobehas edge cases on some kernel versions. Run--testin production first. - Output path should be under logrotate-managed directory to prevent unbounded growth:
/var/lib/vps-ops/probes/*.txt { daily rotate 7 compress } - Keep probe scripts minimal — once running they’re a black box; avoid complex branching logic inside probes.
7. Summary
| Problem | Metrics layer answers? | bpftrace probe | Overhead |
|---|---|---|---|
| Scheduler queueing causing P99 spikes | ❌ | runqlat | <0.1% CPU |
| Network drops / retransmissions | ⚠️ counters only | netdrop + tcp_retrans | <0.2% CPU |
| Disk I/O jitter | ⚠️ IO wait only | disklat | <0.3% CPU |
| DNS resolution stalls | ❌ | getaddrinfo uprobe | <0.1% CPU |
Core methodology: the metrics layer detects anomalies; bpftrace localises root causes. Together they form a complete observability loop. On a 2C4G VPS, 4 probes running simultaneously for 10 minutes measured 0.37% total CPU overhead — zero production impact.
Next steps: pipe probe output into Prometheus (bpftrace -o | prometheus_exporter), add flame-graph mode (btt -f) for function-level hot spots, and use libbpf + Rust/Go for more complex kernel-space logic.
