Staging Continuous Profiling: Collector Choice and Rollback
Covers the system/alloy (active) and system/otel-ebpf-profiler (blocked, kept inert)
profiling collectors, the system/pyroscope backend, and the monitoring surface that
observes them.
Current state
| Piece | State | Why |
|---|---|---|
system/alloy |
ACTIVE — Alloy 1.20.1 (chart 1.13.1), pyroscope.ebpf → pyroscope.write over Pyroscope's native /ingest |
Validated end-to-end on grigri, prusik and k8s-amd64-1 |
system/otel-ebpf-profiler |
INERT — in the ApplicationSet excludes list, manifests retained in git |
OTLP profiles path is blocked upstream (see below). Not the rollback target |
system/pyroscope |
ACTIVE — v2 single-binary, filesystem backend, retention_period: 1w (168h) |
Shared backend for whichever collector is active |
Why OTel-first is blocked (do not "roll back" to it)
The OTel eBPF profiler (otel/opentelemetry-collector-ebpf-profiler:0.163.1) emits an OTLP
resource attribute with an empty key. Pyroscope's OTLP ingest
(POST /v1development/profiles) copies resource attribute keys straight into Prometheus-style
label names without sanitising, so every profile is rejected with
HTTP 400 invalid label name ''.
- Transport works; the data is invalid. Not fixable from our config: no collector knob can
target an empty key, no newer profiler or Pyroscope release fixes it, and Pyroscope's
ingestion_relabeling_rulesdo not apply to the OTLP profiles path. - Alloy is structurally immune: labels come from Alloy's own
discovery.relabeloutput and travel over the pprof-based native/ingestpath, so the OTLP attribute→label copy never runs. - Full analysis:
research_notes/Calypso OTel profiling feasibility/empty-label-verdict.md.
Therefore rollback of the profiling stack means "stop collecting", not "go back to OTel". Both collectors are switched off by excluding both directories.
Rollback: stop all profiling (git only, no kubectl)
Both collectors are children of the system ApplicationSet, so removing a directory from
discovery prunes its Application and everything it owns. Edit bootstrap/root/values.yaml:
- name: system
excludes:
- alloy # stops eBPF collection on every node
- otel-ebpf-profiler # already excluded; keep it excluded
git add bootstrap/root/values.yaml
git commit -m "fix(monitoring): Exclude alloy profiler to stop eBPF collection"
git push origin master
ArgoCD removes alloy from the ApplicationSet's generated list, deletes the alloy
Application, and prunes its DaemonSet, Service, ServiceMonitor, ConfigMap, ServiceAccount and
ClusterRole/ClusterRoleBinding. Nothing to do on the cluster.
Verify it stopped:
kubectl --context=grigri -n argocd get application alloy # NotFound
kubectl --context=grigri -n alloy get pods,ds,svc,sm 2>&1 # No resources / not found
# Ingestion must go flat:
# sum(rate(pyroscope_distributor_received_compressed_bytes_count{type="process_cpu"}[5m])) == 0
To stop only one node's collection instead of the whole stack, narrow the DaemonSet with
controller.nodeSelector or add a controller.tolerations/affinity change in
system/alloy/values.yaml and push — again git only.
What rollback does NOT do: profile data
Deleting the pyroscope Application prunes the PVC, but do not treat reclaimPolicy: Retain
as a guarantee that data survives. Retain protects the volume from the provisioner; it does
not protect against a kubectl delete pv, a pool/dataset destruction, a restore that overwrites
it, or a human pruning the orphaned PV later. The PV is left in Released and is invisible to
the next PVC until someone reclaims it by hand.
Also note the PVC carries argocd.argoproj.io/sync-options: Prune=false, so an ArgoCD prune of
the pyroscope app leaves the PVC in place deliberately.
Consequences:
- Stopping collection (exclude
alloy) never touches stored profiles. - Deleting the pyroscope app leaves a
ReleasedPV holding data that nothing reads, and a re-created PVC will not rebind to it automatically. Treat that as data loss unless the PV is deliberately re-bound. - Before any destructive step, confirm what is actually on the dataset rather than inferring it from the storage class policy:
kubectl --context=grigri -n pyroscope get pvc data-pyroscope-0 \
-o jsonpath='{.spec.volumeName} {.spec.storageClassName}{"\n"}'
kubectl --context=grigri get pv <PV> \
-o jsonpath='{.spec.persistentVolumeReclaimPolicy} {.status.phase}{"\n"}'
Retention is 168h, enforced by the compaction worker, so old data ages out on its own; there
is rarely a reason to delete the volume manually.
Verifying after any collector change
Read-only checks, all from git-applied state:
# 1. App health
kubectl --context=grigri -n argocd get application alloy pyroscope
# 2. Per-node component health (expect unhealthy: 0 on every node)
for p in $(kubectl --context=grigri -n alloy get pods -o name | cut -d/ -f2); do
kubectl --context=grigri -n alloy port-forward pod/$p 13099:12345 >/dev/null 2>&1 &
sleep 2
curl -s --max-time 5 "http://localhost:13099/api/v0/web/components" \
| python3 -c 'import json,sys;cs=json.load(sys.stdin);print([c["localID"] for c in cs if c["health"]["state"]!="healthy"] or "healthy")'
kill %1 2>/dev/null
done
# 3. Coverage and volume, per node (Prometheus, job=alloy)
# pyroscope_ebpf_active_targets / pyroscope_ebpf_profiling_sessions_failing_total
# pyroscope_ebpf_pprofs_total{service_name=...} vs pyroscope_write_sent_profiles_total
Confirm the eBPF programs actually loaded, per node:
kubectl --context=grigri -n alloy logs <pod> -c alloy | grep -E "eBPF tracer loaded|Attached tracer program|failed to load"
Known limitation: newest-kernel interpreter unwinders
On kernel 7.0.0-34-generic (k8s-amd64-1, Ubuntu 26.04) the perf_unwind_python program fails
to load, which made the whole pyroscope.ebpf component exit and lose all coverage on that
node. Mitigation is the documented per-interpreter flag in system/alloy/values.yaml:
pyroscope.ebpf "profiles" {
python_enabled = false # targets are Rust binaries, served by the native unwinder
...
}
This is deliberately narrow: it drops Python stack unwinding only, not native/CPU profiling, and
changes nothing host-wide. If a future kernel breaks another interpreter unwinder, disable that
one flag the same way. Do not reach for no_kernel_version_check — it skips the safety check
rather than removing the failing program, and can yield incomplete profiles.
Because interpreter flags are read when the component is built, a ConfigMap reload is not enough after changing them: the pods must be recreated (delete the pods and let the DaemonSet recreate them, or bump the DaemonSet pod template in git).
Cardinality and privacy guardrails
Deliberately not emitted, and pinned so a chart/app bump cannot quietly start emitting them:
pid_label = false— no process PID labelcomm = "none"— no process command-name label- no
podlabel indiscovery.relabeloutput (a rollout changes the pod name every deploy, which would multiply series without adding signal); attribution usesnamespace+node - no
labelmapof arbitrary pod labels
service_name is ebpf/<namespace>/<container_name>, e.g. ebpf/calypso/control-plane,
ebpf/calypso/auth-proxy, ebpf/calypso/git-proxy.
Targets are restricted by container name (control-plane|git-proxy|auth-proxy|runner) across two
discovery components, one per namespace: calypso and calypso-runners (combined with
array.concat() since 97cf7191). The three first-party services run under runc and are profiled;
the runner entry matches a target that can never produce samples — see below.
Runners are not eBPF-profiled, by decision (gVisor)
Decision: Calypso runners are not continuously profiled. They execute arbitrary, untrusted,
agent-generated code, so putting profiling instrumentation inside that boundary is not wanted.
gVisor keeping pyroscope.ebpf from seeing them is the sandbox working as intended — an accepted
consequence of that choice, not a defect and not something to fix.
Mechanism, measured on grigri 2026-10-10:
- Runner pods use
runtimeClassName: gvisor(runscshows in/system.slice/k3s.service). pyroscope.ebpfattributes a process to a target by the container ID read from/proc/*/cgroup. Under the runsc sandbox the host sees only the pause/sandbox ID: the runner's app container IDaa34a944…appears in 0 cgroup paths while all 67 host-visible runner processes sit under sandbox ID54833b7b…. Control (runc, same node): control-plane's container ID52d1c305…matches 1 path and profiles normally.- So discovery succeeds and profiling stays empty.
pyroscope_ebpf_active_targetswent 6 → 10 onprusikwhen the runner started, yetcalypso-runneris absent frompyroscope_ebpf_pprofs_total,_samples_total,_pprof_bytes_totaland fromlabel_values(...service_name). ASelectMergeProfileforcalypso-runnerreturns HTTP 200 with a 2-byte{}; the control-plane query in the same command returns 291,741 bytes. This held during a run that consumed 1.963 cores, so it is structural, not idleness.
Consequences for whoever reads this next:
- Do not treat an
active_targetsrise at runner start with nocalypso-runnerseries as a bug. It is the expected signature of a matched-but-never-sampled target, and it is why the inertrunnerentry is kept in the keep rule (discovery symmetry, no samples, no cost). - Moving runners to runc/crun to enable eBPF profiling is rejected. It would trade away the sandbox that exists precisely because the code is untrusted, in exchange for flamegraphs of untrusted code.
- eBPF profiling remains correct and useful for the first-party long-lived services
(
calypso-control-plane,calypso-git-proxy,calypso-auth-proxy), which run under runc. - Separate, still-open gap for those services: their Rust symbol names arrive mangled —
::occurs 0 times while 5,399_R…names appear over 24h — and nothing in the current chain demangles them. Tracked separately and under research; no solution is proposed here.
Profiling scope: where git-proxy actually runs
Determined from cluster state (read-only), not from assumptions:
- git-proxy is a standalone single-container pod in namespace
calypso, created bycalypso-control-planeper runner session as agp-<name>-<id>Deployment plus a matchinggp-<name>Service (port 8443/https).kube_pod_container_info{namespace="calypso", pod=~"gp-.*"}showscontainer="git-proxy"for 12+ distinct pods over 24h, and its own scrape job iscalypso/calypso-git-proxywithcontainer="git-proxy". - The gVisor runner pod is a separate object in namespace
calypso-runners. - Therefore git-proxy runs under the default (runc) runtime and is host-profileable, so the
Alloy keep rule (
namespace=calypso+ containergit-proxy) matches it correctly. No config change is needed or warranted. ebpf/calypso/git-proxywas absent from the profile stream purely becausegp-*pods are ephemeral: every inspection window happened to have zero of them running (they are created with a runner and deleted with it), and git-proxy is a low-CPU MITM proxy, so even while alive it can sit below the sampler's per-interval sample threshold.
Net effect on the claim "profiles the 3 Calypso services": scope is correct as configured, but
observed coverage so far is control-plane and auth-proxy; git-proxy is matched-by-config,
not yet matched-by-observation. To close it, catch a window where a runner session is live.
Scraped self-metrics job names
kube-prometheus-operator derives the job label from the matched Service name, not the
ServiceMonitor name. So the profiles-adjacent scrape jobs are:
| ServiceMonitor | actual job label |
|---|---|
alloy/alloy |
alloy |
pyroscope/pyroscope |
pyroscope |
tempo/tempo |
tempo |
otel-collector/otel-collector |
otel-collector-opentelemetry-collector |
Querying up{job="otel-collector"} returns nothing even when the target is up — use the Service
name. Tempo's and the collector's endpoints were inert until b2cc15fb (wrong ServiceMonitor port
name, and ports.metrics left disabled); a ServiceMonitor whose endpoint port matches no Service
port yields zero targets silently — no error, no up series at all.
Alerts
system/pyroscope/resources/prometheus-rules.yaml (labels release: monitoring, required by the
Prometheus CR's ruleSelector). Every metric name in it was read off the live endpoints; the
earlier speculative pyroscope_ingester_push_errors_total (a metric that does not exist) was
replaced with pyroscope_discarded_samples_total. PyroscopeDiscardingProfiles firing with
reason="invalid_labels" is the signature of the OTLP label bug reappearing.