Bootstrap: containerized targets (Java & Python)
Stand up real application targets — Java 25 + Quarkus and Python 3.14 + FastAPI, both as multi-stage UBI containers — and learn to observe a process that lives inside a container by scoping eBPF to its cgroup, with crun's role on Fedora.
So far we’ve probed bash and a tiny Rust binary. Real targets are services, and on Fedora they run in containers. This chapter bootstraps two you’ll reuse throughout the rest of the tutorial — a Java 25 + Quarkus service and a Python 3.14 + FastAPI service, both built as multi-stage UBI images — and teaches the thing that trips people up the first time: how to observe a process that lives inside a container with eBPF.
The code is in examples/16-container-targets/. ./demo.sh there builds, deploys, and runs it; its README.md covers what it does and how to drive it.
Where the containers run, and why
Until now, containers (the stack, the clients) ran on your host laptop and eBPF ran on the target VM. The observed application targets are different: to watch them with eBPF, they have to run where your eBPF program runs — on the target VM — because eBPF attaches to the kernel, and a process is only visible to the kernel it runs on.
So the topology for this chapter:
- Target VM: runs Podman, runs the Quarkus/FastAPI container, and
runs your
contraceAya loader observing it. - Host: runs the otel-lgtm stack and a load driver hitting the target’s published port.
(That’s why this iteration adds podman, crun, and dwarves to the
VM’s cloud-init — re-provision ebpf-target so they’re present.)
The targets, as multi-stage UBI containers
Both targets follow the container policy: multi-stage, UBI base, slim
runtime. The FastAPI one resolves dependencies into a venv on
ubi9/python-314, then copies just the venv into
ubi9/python-314-minimal. The Quarkus one builds the fast-jar with
Maven + JDK 25, then runs it on the UBI OpenJDK 25 runtime. Each exposes
a /work endpoint that does a little CPU and opens a file — so there’s
something for a probe to see under load.
Verify the OpenJDK image tag. The Quarkus
Containerfiletargetsregistry.access.redhat.com/ubi9/openjdk-25. If that tag isn’t published on your registry yet, use the fallback noted in the file’s header (a JDK-25 builder of your choice +ubi9/openjdk-21orubi9-minimal+ a JRE at runtime). Pins like these are exactly what the reconciliation plan tracks.
The key idea: the kernel sees every container
Here’s the mental unlock. A container is not a VM — it’s just processes with their own namespaces (PID, mount, network, …) and a cgroup for resource accounting, all sharing the host kernel. eBPF runs in that kernel. So your existing tracers from Chapters 9–12 already see processes inside containers — no container-specific API needed.
Two wrinkles come with that:
- PID namespace. The PID eBPF reports (
bpf_get_current_pid_tgid) is the host/VM PID. Inside the container,psshows a different PID (often the app is PID 1 in its namespace). When you correlate eBPF output with what you see inside the container, you must translate — the host PID is the source of truth for eBPF. - Everything at once. Without a filter, you see every container plus the host’s own processes. To watch one service you need to scope.
Scoping to one container by cgroup id
The clean way to scope is the cgroup. Every container gets its own
cgroup, and bpf_get_current_cgroup_id() returns the current task’s
cgroup id in-kernel. contrace uses it: user space writes the target
container’s cgroup id into a one-slot config map, and the eBPF program
drops any event whose cgroup doesn’t match.
let cgroup = bpf_get_current_cgroup_id();
let target = TARGET_CGROUP.get(0).copied().unwrap_or(0);
if target != 0 && cgroup != target {
return Ok(()); // not our container — ignore
}
The eBPF side is otherwise a cgroup-filtered opensnoop (Chapter 9):
it traces openat, so each /work request that opens a file becomes a
scoped event. A target of 0 means “don’t filter” — useful as a
fallback.
Finding a container’s cgroup id from user space is the fiddly part. On the VM:
[vm]$ cgpath=$(podman inspect --format '{{.State.CgroupPath}}' fastapi-target)
[vm]$ stat -c %i "/sys/fs/cgroup${cgpath}"
The cgroup id is the inode number of the container’s cgroup
directory. The exact CgroupPath layout varies between rootless and
rootful Podman and cgroup managers, which is why contrace’s demo
treats resolution as best-effort and falls back to unscoped if it can’t
find it.
crun: the runtime underneath
Fedora’s default OCI runtime is crun (pinned at 1.27.1 here). It’s what Podman actually calls to create these containers. Three things worth knowing for observation:
- crun puts each container in its own cgroup v2 group — the very
cgroups whose id
contracefilters on. - crun runs under SELinux confinement, and so do the containers it
starts; observing them needs no special SELinux handling because
you’re watching from the kernel side, not from inside a peer
container. If a future demo runs the observer itself in a container,
that’s when SELinux labels and extra capabilities (
CAP_BPF,CAP_PERFMON) come into play — called out where it happens. - crun supports eBPF-backed cgroup controllers itself; that’s separate from our tracing, but it’s why “eBPF” and “crun” show up in the same sentence in Fedora’s docs.
Build, deploy, observe
cd examples/16-container-targets && ./demo.sh # FastAPI target
cd examples/16-container-targets && TARGET=quarkus ./demo.sh # Quarkus target
The demo ships the target to the VM, builds and runs it under Podman
there, resolves its cgroup id, starts a host-side load driver hitting
/work, and runs contrace scoped to that container. You see only that
container’s file opens, and
ebpf_events_total{program="contrace",container="…"} in Grafana — one
series per container, which is exactly the per-service view you want
when several are running.
In Grafana (127.0.0.1:3000 → Explore), filter to the ebpf-contrace service and graph sum by (program) (rate(ebpf_events_total[1m])) — events captured from inside the container as a live rate, the same events your terminal lists, now plotted over time.
Cross-check
[vm]$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat /cgroup == CGID/ { @[comm] = count(); }'
bpftrace has a cgroup builtin and a cgroupid() helper; scoping its
count to the same cgroup id should track what contrace reports.
What you learned
- Observed targets run on the VM (Podman/crun) because eBPF attaches to that kernel; the kernel sees every container’s processes.
- eBPF reports host PIDs, not in-container PIDs — translate when correlating.
- Scope observation to one container by cgroup id
(
bpf_get_current_cgroup_id()+ a config map). - crun is Fedora’s runtime; its cgroup-v2 groups are what you filter on, and SELinux confinement is transparent when observing from the kernel.
These two targets return in later chapters — sslsniff against their
TLS, funclatency and javagc against the JVM, and the L7/HTTP
networking chapters. Next in this part: TLS data with sslsniff and
latency histograms with funclatency.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.