You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@skywalking.apache.org by li...@apache.org on 2026/07/16 11:29:07 UTC

(skywalking-rover) branch main updated: Support monitoring short-lived processes in the access log module (#214)

This is an automated email from the ASF dual-hosted git repository.

mrproliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-rover.git


The following commit(s) were added to refs/heads/main by this push:
     new 6a23532  Support monitoring short-lived processes in the access log module (#214)
6a23532 is described below

commit 6a235327800ea84ab2062c8b1896d8188da895ff
Author: mrproliu <74...@qq.com>
AuthorDate: Thu Jul 16 19:29:01 2026 +0800

    Support monitoring short-lived processes in the access log module (#214)
---
 CHANGES.md                                    |   1 +
 bpf/accesslog/process/process.c               |  98 +++++++-
 bpf/accesslog/process/process.h               |  38 ++++
 pkg/accesslog/collector/process.go            |  62 ++++-
 pkg/accesslog/common/connection.go            | 226 ++++++++++++++++++-
 pkg/process/api.go                            |  25 +++
 pkg/process/api/process.go                    |  16 ++
 pkg/process/finders/base/finder.go            |  13 ++
 pkg/process/finders/base/template.go          |  29 ++-
 pkg/process/finders/kubernetes/finder.go      | 183 ++++++++++++++-
 pkg/process/finders/kubernetes/template.go    |   5 +-
 pkg/process/finders/manager.go                |  64 ++++++
 pkg/process/finders/manager_executing_test.go | 107 +++++++++
 pkg/process/module.go                         |  16 ++
 pkg/tools/cgroup/resolver.go                  | 205 +++++++++++++++++
 pkg/tools/cgroup/resolver_test.go             | 312 ++++++++++++++++++++++++++
 pkg/tools/host/file.go                        |  18 ++
 pkg/tools/host/file_test.go                   |  50 +++++
 18 files changed, 1440 insertions(+), 28 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 1f2ca99..12fd2a8 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -37,6 +37,7 @@ Release Notes.
 * Report a periodic info-level summary of un-resolved remote addresses, including the conntrack and ztunnel correlation statistics, in the access log module.
 * Attach all matched ztunnel `track_outbound` symbol copies when attaching the uprobe in the access log module.
 * Add success/failure counters to the conntrack real peer address queries.
+* Support monitoring short-lived processes in the access log module.
 
 #### Bug Fixes
 * Fix the base image cannot run in the arm64.
diff --git a/bpf/accesslog/process/process.c b/bpf/accesslog/process/process.c
index 3658c5c..dc45392 100644
--- a/bpf/accesslog/process/process.c
+++ b/bpf/accesslog/process/process.c
@@ -24,6 +24,13 @@ struct {
 
 struct process_execute_event {
     __u32 pid;
+    __u32 pad0;
+    __u64 cgroup_id;
+    // comm is the kernel's task name. User space prefers /proc/<pid>/cmdline for the process
+    // name and only falls back to this when the process is already gone - which is the whole
+    // point of reporting a short-lived process, so its identity must not depend on /proc still
+    // being readable by the time the event is consumed.
+    char comm[16];
 };
 
 struct trace_event_raw_sched_process_fork {
@@ -35,20 +42,95 @@ struct trace_event_raw_sched_process_fork {
         char __data[0];
 }  __attribute__((preserve_access_index)) ;
 
-SEC("tracepoint/sched/sched_process_fork")
-int tracepoint_sched_process_fork(struct trace_event_raw_sched_process_fork* ctx) {
-    // ctx->parent_pid is the forking task's TID (thread id), not the process id.
-    // For multi-threaded apps (e.g. JVM) a worker thread forking would report its
-    // thread TID, which the periodic /proc scan never lists, causing the process to
-    // flip between detected and dead. Use the real TGID instead.
+struct trace_event_raw_sched_process_exec {
+        struct trace_entry ent;
+        __u32 __data_loc_filename;
+        __u32 pid;
+        __u32 old_pid;
+        char __data[0];
+}  __attribute__((preserve_access_index)) ;
+
+// report_running_process starts tracing the current process and tells user space about it, unless
+// it is filtered out or already known. Shared by the fork and the exec tracepoint, which differ
+// only in *when* they notice a process, never in how it is judged.
+static __always_inline void report_running_process(void *ctx) {
     __u32 tgid = bpf_get_current_pid_tgid() >> 32;
-    // adding to the monitor
+
+    // Reject what we are not interested in before emitting anything. This is the check that keeps
+    // the machine-wide fork rate off the perf queue and out of user space, so it runs first, ahead
+    // of the process_monitor_control lookup below: the host processes it rejects fork far more than
+    // the monitored pods do, and this way they cost a single lookup rather than two.
+    __u64 cgroup_id = 0;
+    if (cgroup_filter_enabled) {
+        // Preferred: the cgroup is known up front, so a host process never reaches user space.
+        cgroup_id = bpf_get_current_cgroup_id();
+        if (!bpf_map_lookup_elem(&cgroup_monitor_allowlist, &cgroup_id)) {
+            return;
+        }
+    } else {
+        // Fallback: nothing is known up front, so every process gets reported once and only the
+        // ones user space rejected are filtered out from then on.
+        if (bpf_map_lookup_elem(&process_not_monitor, &tgid)) {
+            return;
+        }
+    }
+
+    // Already decided for this process - nothing new to tell user space. Without this a
+    // thread-happy process (a JVM spawning workers) would emit an event and force a user-space
+    // re-check on every single clone; with it, each process costs one event per lifetime.
+    if (bpf_map_lookup_elem(&process_monitor_control, &tgid)) {
+        return;
+    }
+
+    // Start tracing immediately, optimistically: the process may be gone long before the
+    // periodic discovery would have found it. User space still gets the final say and removes
+    // the entry if the process turns out not to be monitorable.
     __u32 v = 1;
     bpf_map_update_elem(&process_monitor_control, &tgid, &v, 0);
 
-    // send to the user-space to check the pid should monitor or not
     struct process_execute_event event = {};
     event.pid = tgid;
+    event.cgroup_id = cgroup_id;
+    bpf_get_current_comm(&event.comm, sizeof(event.comm));
     bpf_perf_event_output(ctx, &process_execute_queue, BPF_F_CURRENT_CPU, &event, sizeof(event));
+}
+
+// A process that execs is reported here rather than at fork, because only here is it the thing it
+// is going to be: the runtime forks a helper from its *own* cgroup (containerd-shim sits in a host
+// cgroup, e.g. /podruntime/docker), and that child only joins the container's cgroup and namespaces
+// afterwards, right before execve. Judging at fork would therefore test the shim's cgroup and
+// reject every process started through `kubectl exec` - exactly the short-lived kind this exists
+// for. At exec the process already carries its final cgroup, pid and name.
+SEC("tracepoint/sched/sched_process_exec")
+int tracepoint_sched_process_exec(struct trace_event_raw_sched_process_exec* ctx) {
+    report_running_process(ctx);
+    return 0;
+}
+
+SEC("tracepoint/sched/sched_process_fork")
+int tracepoint_sched_process_fork(struct trace_event_raw_sched_process_fork* ctx) {
+    // Reclaim any entry left behind by a dead task that used to own the child's pid.
+    //
+    // The kernel allocates every task's pid - thread (TID) and process (TGID) alike - from one
+    // global allocator, so a pid identifies at most one task at a time. ctx->child_pid was just
+    // handed out by copy_process(), which proves the pid was free a moment ago, which in turn
+    // proves any entry still keyed by it belongs to a task that has already exited: a live task
+    // never releases its pid, so the child could not have been given the number. Deleting here
+    // is therefore safe (it can never evict a live process) and sufficient (a pid can only be
+    // reused by going through a fork, so this runs before the new owner executes a single
+    // instruction). That removes the need for an exit tracepoint or a periodic sweep.
+    //
+    // This has to happen for every fork, before any of the filtering below can return early.
+    __u32 child = ctx->child_pid;
+    bpf_map_delete_elem(&process_monitor_control, &child);
+    bpf_map_delete_elem(&process_not_monitor, &child);
+
+    // Report the forking process itself. The exec tracepoint covers anything that starts a new
+    // program; this additionally catches a process that only ever forks - and, because
+    // bpf_get_current_pid_tgid() is read inside, it reports the real TGID rather than
+    // ctx->parent_pid, which for a multi-threaded app (e.g. a JVM worker thread forking) is the
+    // thread's TID that the periodic /proc scan never lists, making the process flip between
+    // detected and dead.
+    report_running_process(ctx);
     return 0;
 }
\ No newline at end of file
diff --git a/bpf/accesslog/process/process.h b/bpf/accesslog/process/process.h
index a9dd2f4..ee19d66 100644
--- a/bpf/accesslog/process/process.h
+++ b/bpf/accesslog/process/process.h
@@ -24,6 +24,44 @@ struct {
 	__type(value, __u32);
 } process_monitor_control SEC(".maps");
 
+// cgroup_monitor_allowlist holds the cgroup id of every cgroup a monitored process was found in.
+// User space fills it from the periodic process discovery (which reads /proc and is therefore
+// never gated by this map), and the fork tracepoint reads it to decide, entirely in kernel space,
+// whether a forking process is worth reporting. A node runs far more unmonitored host processes
+// (kubelet, containerd, systemd, ...) than monitored pod ones, so rejecting them here - before any
+// perf event is emitted and before user space touches /proc - is what makes the tracepoint cheap.
+//
+// Only consulted when cgroup_filter_enabled is set; see process_not_monitor for the fallback.
+struct {
+	__uint(type, BPF_MAP_TYPE_HASH);
+	__uint(max_entries, 10000);
+	__type(key, __u64);
+	__type(value, __u32);
+} cgroup_monitor_allowlist SEC(".maps");
+
+// process_not_monitor is the fallback filter for hosts where the cgroup allowlist cannot be built
+// (cgroup v1, or a cgroup tree user space failed to walk). There the tracepoint cannot tell a pod
+// process from a host one up front, so it reports every process once and user space records the
+// ones it rejected here, which keeps them from being reported again.
+//
+// It is weaker than the allowlist - every process costs one event and one /proc read instead of
+// zero - but still collapses the machine-wide *fork* rate down to one event per process lifetime,
+// which is the cost that made this tracepoint unaffordable before.
+//
+// LRU rather than HASH: entries accumulate for every unmonitored process on the node and nothing
+// enumerates them for removal, so eviction has to be the kernel's job. Evicting one only costs a
+// re-check, whereas a full map would silently stop recording rejections.
+struct {
+	__uint(type, BPF_MAP_TYPE_LRU_HASH);
+	__uint(max_entries, 10240);
+	__type(key, __u32);
+	__type(value, __u32);
+} process_not_monitor SEC(".maps");
+
+// cgroup_filter_enabled selects between the two filters above. User space sets it at startup after
+// probing the host, and it never changes afterwards.
+volatile __u32 cgroup_filter_enabled;
+
 volatile __u32 ztunnel_process_pid;
 
 static __inline bool tgid_should_trace(__u32 tgid) {
diff --git a/pkg/accesslog/collector/process.go b/pkg/accesslog/collector/process.go
index e4dbdce..27c2932 100644
--- a/pkg/accesslog/collector/process.go
+++ b/pkg/accesslog/collector/process.go
@@ -18,13 +18,33 @@
 package collector
 
 import (
+	"bytes"
+
 	"github.com/apache/skywalking-rover/pkg/accesslog/common"
+	"github.com/apache/skywalking-rover/pkg/logger"
 	"github.com/apache/skywalking-rover/pkg/module"
 )
 
+var processLog = logger.GetLogger("access_log", "collector", "process")
+
+// processExecuteQueueParallels reads the process events with more than one goroutine. The queue is
+// low volume by design - the kernel filters before emitting - but a process that is reported and
+// then handled late is a process whose access logs sit waiting, so keep the drain prompt.
+const processExecuteQueueParallels = 2
+
+// processExecuteQueuePerCPUBuffer sizes the per-CPU perf buffer. A burst of process starts (a pod
+// rolling out, a CronJob firing) must not be dropped, and the events are 32 bytes each.
+const processExecuteQueuePerCPUBuffer = 32 * 1024
+
 var processCollectInstance = NewProcessCollector()
 
-// ProcessCollector Management all processes which needs to be monitored
+// ProcessCollector notices processes as the kernel starts them, rather than waiting for the
+// periodic /proc scan to come round.
+//
+// The scan cannot see a process that starts and exits between two of its rounds, so anything short
+// lived - a `kubectl exec`, a CronJob, a CGI worker - produces socket traffic that is dropped in the
+// kernel because the process was never marked as monitored. This collector closes that window: the
+// kernel marks a process on sight and tells user space, which then confirms or removes it.
 type ProcessCollector struct {
 }
 
@@ -33,12 +53,25 @@ func NewProcessCollector() *ProcessCollector {
 }
 
 func (p *ProcessCollector) Start(_ *module.Manager, context *common.AccessLogContext) error {
-	// monitor process been execute
+	// Nothing is attached unless user space can actually judge what the kernel reports. Leaving the
+	// tracepoints off is not a degraded mode - it is exactly the behavior before this collector
+	// existed, with the periodic scan still discovering every process that lives long enough.
+	if !context.ConnectionMgr.ProcessExecuteMonitorReady() {
+		processLog.Info("not monitoring process execution; short-lived processes will only be " +
+			"discovered by the periodic process scan")
+		return nil
+	}
+
+	// exec is what catches a short-lived process: only there does it carry its own pid, its final
+	// cgroup and its final name. fork additionally keeps the monitor map free of pids left behind by
+	// dead processes, and catches a process that only ever forks.
+	context.BPF.AddTracePoint("sched", "sched_process_exec", context.BPF.TracepointSchedProcessExec)
 	context.BPF.AddTracePoint("sched", "sched_process_fork", context.BPF.TracepointSchedProcessFork)
 
-	context.BPF.ReadEventAsync(context.BPF.ProcessExecuteQueue, func(data interface{}) {
-		context.ConnectionMgr.OnNewProcessExecuting(int32(data.(*ProcessExecuteEvent).PID))
-	}, func() interface{} {
+	context.BPF.ReadEventAsyncWithBufferSize(context.BPF.ProcessExecuteQueue, func(data interface{}) {
+		event := data.(*ProcessExecuteEvent)
+		context.ConnectionMgr.OnNewProcessExecuting(int32(event.PID), event.CgroupID, event.CommString())
+	}, processExecuteQueuePerCPUBuffer, processExecuteQueueParallels, func() interface{} {
 		return &ProcessExecuteEvent{}
 	})
 
@@ -48,6 +81,23 @@ func (p *ProcessCollector) Start(_ *module.Manager, context *common.AccessLogCon
 func (p *ProcessCollector) Stop() {
 }
 
+// ProcessExecuteEvent must keep the exact memory layout of struct process_execute_event in
+// bpf/accesslog/process/process.c: the perf record is decoded with binary.Read, which lays the
+// fields out back to back and would silently shift every field after an implicit C alignment gap.
+// PAD0 is the explicit stand-in for the padding the compiler inserts before the 8-byte aligned
+// CgroupID.
 type ProcessExecuteEvent struct {
-	PID uint32
+	PID      uint32
+	PAD0     uint32
+	CgroupID uint64
+	Comm     [16]byte
+}
+
+// CommString returns the kernel task name as a string, dropping the NUL padding that
+// bpf_get_current_comm leaves in the fixed size buffer.
+func (e *ProcessExecuteEvent) CommString() string {
+	if idx := bytes.IndexByte(e.Comm[:], 0); idx >= 0 {
+		return string(e.Comm[:idx])
+	}
+	return string(e.Comm[:])
 }
diff --git a/pkg/accesslog/common/connection.go b/pkg/accesslog/common/connection.go
index decfce9..ad5d5ee 100644
--- a/pkg/accesslog/common/connection.go
+++ b/pkg/accesslog/common/connection.go
@@ -138,6 +138,20 @@ type ConnectionManager struct {
 	// monitoring process map in BPF
 	processMonitorMap   *ebpf.Map
 	activeConnectionMap *ebpf.Map
+	// processNotMonitorMap and cgroupAllowlistMap are the two ways the fork/exec tracepoints decide
+	// what to skip; which one is in force is settled once at startup, see processExecuteMonitorReady
+	processNotMonitorMap *ebpf.Map
+	cgroupAllowlistMap   *ebpf.Map
+	// processExecuteMonitorReady records whether the tracepoints were attached at all
+	processExecuteMonitorReady bool
+	// cgroupSeeder resolves a container to its cgroup id; nil when the host cannot resolve them
+	cgroupSeeder process.CgroupOperator
+	// allowedCgroups counts which monitored pids live in each allowed cgroup, so a cgroup stays
+	// allowed until the last of them is gone
+	allowedCgroups map[uint64]map[int32]bool
+	// processVerdicts remembers what was concluded about a process, so its access logs can outlive
+	// it just long enough to be flushed
+	processVerdicts *cache.Expiring
 
 	monitorFilter MonitorFilter
 
@@ -232,6 +246,10 @@ func NewConnectionManager(config *Config, moduleMgr *module.Manager, bpfLoader *
 		monitoringProcesses:        make(map[int32][]api.ProcessInterface),
 		processMonitorMap:          bpfLoader.ProcessMonitorControl,
 		activeConnectionMap:        bpfLoader.ActiveConnectionMap,
+		processNotMonitorMap:       bpfLoader.ProcessNotMonitor,
+		cgroupAllowlistMap:         bpfLoader.CgroupMonitorAllowlist,
+		processVerdicts:            cache.NewExpiring(),
+		allowedCgroups:             make(map[uint64]map[int32]bool),
 		monitorFilter:              filter,
 		flushListeners:             make([]FlusherListener, 0),
 		connectTracker:             track,
@@ -240,9 +258,52 @@ func NewConnectionManager(config *Config, moduleMgr *module.Manager, bpfLoader *
 		unresolvedByReason:         make(map[string]int64),
 		resolutionGracePeriod:      resolutionGraceFor(flushPeriod),
 	}
+	mgr.setupProcessExecuteMonitor(bpfLoader)
 	return mgr
 }
 
+// setupProcessExecuteMonitor settles, once, how the kernel should filter the processes it reports,
+// and whether reporting them is worth doing at all.
+//
+// The cgroup allowlist is the preferred filter because it rejects a host process without emitting
+// anything, but it needs the process discovery to resolve cgroup ids, which in turn needs a cgroup
+// v2 tree. Where that is missing the per-process rejection list still keeps the cost to one report
+// per process, which is what makes these tracepoints affordable in the first place.
+func (c *ConnectionManager) setupProcessExecuteMonitor(bpfLoader *bpf.Loader) {
+	// Nothing is reported unless some active finder can actually act on it. This is not a
+	// micro-optimisation: a finder that only ever monitors long-lived processes(the VM finder takes
+	// a process only if it holds a listening port, which one living for milliseconds never does)
+	// would reject every reported process *after* paying for a /proc read - all cost, no coverage.
+	// That cost is precisely why these tracepoints were removed from this agent once before.
+	if !c.processOP.ExecutingProcessesWanted() {
+		log.Infof("not monitoring process execution: no active process finder can use it, so " +
+			"short-lived processes stay discoverable only by the periodic scan")
+		return
+	}
+
+	seeder, canSeedCgroups := c.processOP.(process.CgroupOperator)
+	if canSeedCgroups {
+		canSeedCgroups = seeder.CgroupResolvable()
+	}
+	c.cgroupSeeder = seeder
+
+	enabled := uint32(0)
+	if canSeedCgroups {
+		enabled = 1
+	}
+	if err := bpfLoader.CgroupFilterEnabled.Set(enabled); err != nil {
+		log.Warnf("cannot select the process execution filter, not monitoring process execution: %v", err)
+		return
+	}
+	c.processExecuteMonitorReady = true
+	if canSeedCgroups {
+		log.Infof("monitoring process execution, filtering by the cgroups of the monitored containers")
+	} else {
+		log.Infof("monitoring process execution, filtering per process: no cgroup v2 tree is available, " +
+			"so every process is reported once before it can be skipped")
+	}
+}
+
 // resolutionGraceFor bounds the resolution-defer grace so it stays below connectionDeleteDelayTime
 // (with one flush period of headroom). A connection is removed connectionDeleteDelayTime after it
 // closes, and a deferred log can only be emitted at a flush cycle at or after its resolution
@@ -377,11 +438,159 @@ func (c *ConnectionManager) Stop() {
 	c.processOP.DeleteListener(c)
 }
 
-func (c *ConnectionManager) OnNewProcessExecuting(pid int32) {
-	// if the process should not be monitoring, then delete in the map
-	if !c.processOP.ShouldMonitor(pid) {
-		c.updateMonitorStatusForProcess(pid, false)
+// ProcessVerdict is what user space concluded about a process the kernel reported.
+type ProcessVerdict int
+
+const (
+	// ProcessVerdictUnknown means no conclusion has been reached yet, either because the report is
+	// still being handled or because the process vanished before it could be identified.
+	ProcessVerdictUnknown ProcessVerdict = iota
+	// ProcessVerdictMonitored means the process is currently monitored.
+	ProcessVerdictMonitored
+	// ProcessVerdictApproved means the process passed every filter and has since exited. Its logs
+	// are still worth sending: the process being short-lived is the reason it is interesting.
+	ProcessVerdictApproved
+	// ProcessVerdictRejected means the process was positively identified as one we must not report.
+	ProcessVerdictRejected
+)
+
+// processVerdictKeepTime is how long a verdict outlives the process. It only has to cover the gap
+// between a process exiting and its last buffered access logs being flushed.
+const processVerdictKeepTime = time.Minute
+
+// ProcessExecuteMonitorReady reports whether user space can judge the processes the kernel would
+// report, and therefore whether the fork/exec tracepoints are worth attaching at all.
+func (c *ConnectionManager) ProcessExecuteMonitorReady() bool {
+	return c.processExecuteMonitorReady
+}
+
+// OnNewProcessExecuting handles a process the kernel has just started and optimistically marked as
+// monitored. Everything the kernel could capture is passed in, because the process may already be
+// gone - that is precisely the case this exists for.
+func (c *ConnectionManager) OnNewProcessExecuting(pid int32, cgroupID uint64, comm string) {
+	// The cgroup id here is the kernel's own(bpf_get_current_cgroup_id), while the allowlist that
+	// let this event through was seeded from a cgroup id user space read as a directory inode. The
+	// two must be the same number for any of this to work, so log the kernel's side of it: paired
+	// with the "cgroup mapping: id=... path=..." lines from the walk, it shows whether they agree.
+	log.Debugf("process execution reported by the kernel: pid=%d cgroup=%d comm=%s", pid, cgroupID, comm)
+
+	if c.processOP.ShouldMonitorExecuting(&api.ProcessExecuteContext{Pid: pid, CgroupID: cgroupID, Comm: comm}) {
+		// the finder registered it; AddNewProcess arrives through the listener and records the
+		// verdict, so there is nothing to undo here
+		return
+	}
+
+	// Stop tracing it. Whether this counts as a positive rejection decides if its already buffered
+	// access logs may be sent, so be careful about the difference: a process we could still see is
+	// one we truly judged, while a process that vanished before we looked was never judged at all
+	// and its logs must not be trusted.
+	c.updateMonitorStatusForProcess(pid, false)
+	if !path.Exists(host.GetHostProcInHost(fmt.Sprintf("%d", pid))) {
+		log.Debugf("the process %d exited before it could be identified, its access logs will be dropped", pid)
+		return
+	}
+	c.recordProcessVerdict(pid, ProcessVerdictRejected)
+	// Remember the rejection in the kernel so the process stops being reported on every exec/fork.
+	// Only meaningful in the fallback mode; with the cgroup allowlist the process never gets here.
+	c.rejectProcessInBPF(pid)
+}
+
+// allowCgroupsOfProcesses lets the kernel report the processes that start inside these processes'
+// cgroups, which is how a short-lived sibling - a `kubectl exec`, a CronJob - gets monitored from
+// its very first syscall instead of waiting for the next discovery round to notice it.
+//
+// The pids sharing a cgroup are counted, because a container's cgroup must stay allowed until the
+// last of its processes is gone; dropping it when any one process exits would blind the rest.
+func (c *ConnectionManager) allowCgroupsOfProcesses(pid int32, processes []api.ProcessInterface) {
+	if c.cgroupAllowlistMap == nil || c.cgroupSeeder == nil {
+		return
+	}
+	for _, cgroupID := range c.cgroupIDsOf(processes) {
+		pids := c.allowedCgroups[cgroupID]
+		if pids == nil {
+			pids = make(map[int32]bool)
+			c.allowedCgroups[cgroupID] = pids
+			if err := c.cgroupAllowlistMap.Update(cgroupID, uint32(1), ebpf.UpdateAny); err != nil {
+				log.Warnf("failed to allow the cgroup %d, short-lived processes in it will only be "+
+					"found by the periodic scan: %v", cgroupID, err)
+				delete(c.allowedCgroups, cgroupID)
+				continue
+			}
+			log.Debugf("allowing process execution reports from cgroup %d", cgroupID)
+		}
+		pids[pid] = true
+	}
+}
+
+// disallowCgroupsOfProcesses drops a pid from its cgroups and stops the kernel reporting a cgroup
+// once nothing monitored is left in it.
+func (c *ConnectionManager) disallowCgroupsOfProcesses(pid int32, processes []api.ProcessInterface) {
+	if c.cgroupAllowlistMap == nil || c.cgroupSeeder == nil {
+		return
+	}
+	for _, cgroupID := range c.cgroupIDsOf(processes) {
+		pids := c.allowedCgroups[cgroupID]
+		if pids == nil {
+			continue
+		}
+		delete(pids, pid)
+		if len(pids) > 0 {
+			continue
+		}
+		delete(c.allowedCgroups, cgroupID)
+		if err := c.cgroupAllowlistMap.Delete(cgroupID); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) {
+			log.Warnf("failed to disallow the cgroup %d: %v", cgroupID, err)
+		}
+	}
+}
+
+// cgroupIDsOf resolves the cgroups the given processes live in. Only Kubernetes processes have a
+// container to resolve through, and a container whose cgroup is not mapped yet simply yields
+// nothing, leaving the periodic scan to cover it.
+func (c *ConnectionManager) cgroupIDsOf(processes []api.ProcessInterface) []uint64 {
+	ids := make([]uint64, 0, 1)
+	for _, p := range processes {
+		if p.DetectType() != api.Kubernetes {
+			continue
+		}
+		k8sProcess, ok := p.DetectProcess().(*kubernetes.Process)
+		if !ok {
+			continue
+		}
+		cgroupID, exist := c.cgroupSeeder.CgroupIDByContainer(k8sProcess.PodContainer().CGroupID())
+		if !exist {
+			continue
+		}
+		ids = append(ids, cgroupID)
+	}
+	return ids
+}
+
+// rejectProcessInBPF records a process as not worth reporting, so the tracepoints skip it from now
+// on. Without it a busy unmonitored process would be re-reported and re-checked forever.
+func (c *ConnectionManager) rejectProcessInBPF(pid int32) {
+	if c.processNotMonitorMap == nil {
+		return
+	}
+	if err := c.processNotMonitorMap.Update(uint32(pid), uint32(1), ebpf.UpdateAny); err != nil {
+		log.Warnf("failed to record the process %d as not monitored: %v", pid, err)
+	}
+}
+
+func (c *ConnectionManager) recordProcessVerdict(pid int32, verdict ProcessVerdict) {
+	c.processVerdicts.Set(pid, verdict, processVerdictKeepTime)
+}
+
+// ProcessVerdictOf reports what was concluded about a process, so a caller can tell a process that
+// is genuinely not ours from one we simply have not judged yet.
+func (c *ConnectionManager) ProcessVerdictOf(pid uint32) ProcessVerdict {
+	if c.ProcessIsMonitor(pid) {
+		return ProcessVerdictMonitored
+	}
+	if v, exist := c.processVerdicts.Get(int32(pid)); exist {
+		return v.(ProcessVerdict)
 	}
+	return ProcessVerdictUnknown
 }
 
 func (c *ConnectionManager) GetExcludeNamespaces() []string {
@@ -778,6 +987,11 @@ func (c *ConnectionManager) AddNewProcess(pid int32, entities []api.ProcessInter
 	}
 	c.monitoringProcesses[pid] = monitorProcesses
 	c.updateMonitorStatusForProcess(pid, true)
+	c.recordProcessVerdict(pid, ProcessVerdictMonitored)
+	// Seeding the allowlist here, and nowhere earlier, is deliberate: this is the one point both the
+	// finder and the monitor filter(exclude namespaces, clusters) have passed, so a cgroup can only
+	// be allowed once everything agrees its processes are ours to report.
+	c.allowCgroupsOfProcesses(pid, monitorProcesses)
 	for _, entity := range monitorProcesses {
 		for _, host := range entity.ExposeHosts() {
 			c.localIPWithPid[host] = pid
@@ -845,8 +1059,12 @@ func (c *ConnectionManager) RemoveProcess(pid int32, _ []api.ProcessInterface) {
 	c.monitoringProcessLock.Lock()
 	defer c.monitoringProcessLock.Unlock()
 	// delete monitoring process and IP addresses
+	c.disallowCgroupsOfProcesses(pid, c.monitoringProcesses[pid])
 	delete(c.monitoringProcesses, pid)
 	c.updateMonitorStatusForProcess(pid, false)
+	// The process passed every filter while it lived, so its access logs are still worth sending
+	// once it is gone - a short-lived process being gone is the normal case, not a reason to drop.
+	c.recordProcessVerdict(pid, ProcessVerdictApproved)
 	c.rebuildLocalIPWithPID()
 	c.printTotalAddressesWithPid("remove monitoring process")
 	for _, l := range c.processListeners {
diff --git a/pkg/process/api.go b/pkg/process/api.go
index ca11907..7964e46 100644
--- a/pkg/process/api.go
+++ b/pkg/process/api.go
@@ -32,6 +32,31 @@ type Operator interface {
 	DeleteListener(listener api.ProcessListener)
 	// ShouldMonitor check the process should be monitored
 	ShouldMonitor(pid int32) bool
+	// ShouldMonitorExecuting check a process the kernel has just started should be monitored,
+	// using what the kernel captured about it so the answer does not depend on /proc/<pid> still
+	// existing - a short-lived process may already be gone by the time it is asked.
+	ShouldMonitorExecuting(exec *api.ProcessExecuteContext) bool
+	// ExecutingProcessesWanted reports whether any active finder can act on a process reported the
+	// moment it starts, and therefore whether reporting them is worth its cost at all.
+	//
+	// It is false when every active finder only ever monitors long-lived processes: the VM finder,
+	// for instance, takes a process only if it holds a listening port, which a process that lives
+	// for milliseconds never does. Reporting to such a finder buys nothing and costs a /proc read
+	// per process, so the caller must not ask the kernel for the reports in the first place.
+	ExecutingProcessesWanted() bool
+}
+
+// CgroupOperator is an optional interface the process module satisfies when it can resolve the
+// cgroup of a container, which lets a caller filter processes in kernel space by cgroup id.
+//
+// It is optional because it needs a cgroup v2 tree the agent can read; a caller must handle a
+// missing implementation, or a CgroupResolvable of false, by filtering some other way rather than
+// by monitoring nothing.
+type CgroupOperator interface {
+	// CgroupResolvable reports whether cgroup ids can be resolved on this host at all
+	CgroupResolvable() bool
+	// CgroupIDByContainer returns the cgroup id of a container, false when it is not(yet) known
+	CgroupIDByContainer(containerID string) (uint64, bool)
 }
 
 type K8sOperator interface {
diff --git a/pkg/process/api/process.go b/pkg/process/api/process.go
index 75a09ba..a440141 100644
--- a/pkg/process/api/process.go
+++ b/pkg/process/api/process.go
@@ -86,6 +86,22 @@ type DetectedProcess interface {
 	ExposeHosts() []string
 }
 
+// ProcessExecuteContext carries what the kernel captured about a process the moment it started,
+// for finders that are asked to judge it before the periodic /proc scan would have seen it.
+//
+// Everything here is read in kernel space precisely because a short-lived process may already be
+// gone by the time the event is handled, taking its /proc entry with it. A finder should still
+// prefer /proc while it is there - it is richer - and only lean on these fields as the fallback.
+type ProcessExecuteContext struct {
+	// Pid of the process in the host pid namespace
+	Pid int32
+	// CgroupID as reported by bpf_get_current_cgroup_id, which is the inode of the process's
+	// cgroup v2 directory. Zero when the host could not provide one (cgroup v1).
+	CgroupID uint64
+	// Comm is the kernel's task name, a coarser stand-in for the command line
+	Comm string
+}
+
 // ProcessEntity is related to backend entity concept
 type ProcessEntity struct {
 	Layer        string
diff --git a/pkg/process/finders/base/finder.go b/pkg/process/finders/base/finder.go
index d4ba8b1..6fd8640 100644
--- a/pkg/process/finders/base/finder.go
+++ b/pkg/process/finders/base/finder.go
@@ -56,6 +56,19 @@ type ProcessFinder interface {
 	ParseProcessID(process api.DetectedProcess, downstream *v3.EBPFProcessDownstream) string
 }
 
+// ExecutingProcessFinder is an optional interface a ProcessFinder may implement to judge a process
+// the kernel has just started, using what the kernel captured about it.
+//
+// It exists because ShouldMonitor(pid) can only work while /proc/<pid> is readable, and the whole
+// reason to look at a process this early is that it may not live long enough for that. A finder
+// that cannot make use of the kernel-side data simply does not implement this, and the manager
+// falls back to ShouldMonitor.
+type ExecutingProcessFinder interface {
+	// ShouldMonitorExecuting validates a just-started process and, if true, adds it to the storage
+	// exactly like ShouldMonitor does.
+	ShouldMonitorExecuting(exec *api.ProcessExecuteContext) bool
+}
+
 // ProcessManager is an API work for help ProcessFinder synchronized process with backend
 type ProcessManager interface {
 	GetModuleManager() *module.Manager
diff --git a/pkg/process/finders/base/template.go b/pkg/process/finders/base/template.go
index e73c16a..4c3d7f1 100644
--- a/pkg/process/finders/base/template.go
+++ b/pkg/process/finders/base/template.go
@@ -60,7 +60,14 @@ func NewTemplateRover(manager *module.Manager) *TemplateRover {
 
 // NewTemplateProcess is generated the process context for render
 func NewTemplateProcess(_ *module.Manager, p *process.Process) *TemplateProcess {
-	return &TemplateProcess{p}
+	return &TemplateProcess{Process: p}
+}
+
+// NewTemplateProcessWithFallbackName is like NewTemplateProcess, but names the process from
+// fallbackName(the kernel task name) when its command line cannot be read - which is what happens
+// to a process that has already exited, the very case a short-lived process is reported for.
+func NewTemplateProcessWithFallbackName(_ *module.Manager, p *process.Process, fallbackName string) *TemplateProcess {
+	return &TemplateProcess{Process: p, fallbackName: fallbackName}
 }
 
 type TemplateRover struct {
@@ -97,6 +104,26 @@ func (t *TemplateRover) HostName() string {
 
 type TemplateProcess struct {
 	*process.Process
+	// fallbackName stands in for the command line when /proc no longer holds one
+	fallbackName string
+}
+
+// Cmdline is the process's command line, falling back to the kernel task name when /proc cannot
+// supply one. It shadows the embedded gopsutil method so that every caller - the periodic scan and
+// the kernel-driven one alike - names a process by the same rule, otherwise the same pid could be
+// registered under two different names and flip between detected and dead.
+//
+// An empty command line falls back too: a zombie still has a readable /proc entry but an empty
+// cmdline, which would otherwise name the process "".
+func (p *TemplateProcess) Cmdline() (string, error) {
+	cmdline, err := p.Process.Cmdline()
+	if err == nil && cmdline != "" {
+		return cmdline, nil
+	}
+	if p.fallbackName != "" {
+		return p.fallbackName, nil
+	}
+	return cmdline, err
 }
 
 // ExeFilePath Execute file path
diff --git a/pkg/process/finders/kubernetes/finder.go b/pkg/process/finders/kubernetes/finder.go
index 2edfbbf..a7380fd 100644
--- a/pkg/process/finders/kubernetes/finder.go
+++ b/pkg/process/finders/kubernetes/finder.go
@@ -51,6 +51,7 @@ import (
 	"github.com/apache/skywalking-rover/pkg/logger"
 	"github.com/apache/skywalking-rover/pkg/process/api"
 	"github.com/apache/skywalking-rover/pkg/process/finders/base"
+	"github.com/apache/skywalking-rover/pkg/tools/cgroup"
 	"github.com/apache/skywalking-rover/pkg/tools/host"
 )
 
@@ -59,10 +60,46 @@ var log = logger.GetLogger("process", "finder", "kubernetes")
 var (
 	kubepodsRegex      = regexp.MustCompile(`cri-containerd-(?P<Group>\w+)\.scope`)
 	openShiftPodsRegex = regexp.MustCompile(`crio-(?P<Group>\w+)\.scope`)
+	dockerPodsRegex    = regexp.MustCompile(`docker-(?P<Group>\w+)\.scope`)
 	ipExistTimeout     = time.Minute * 10
 	ipSearchParallel   = 10
+
+	// containerScopeRegexes is built once rather than per call: containerIDFromCgroupDir is the
+	// normalizer for a full cgroup tree walk, so a per-call slice would allocate once per directory.
+	containerScopeRegexes = []*regexp.Regexp{kubepodsRegex, openShiftPodsRegex, dockerPodsRegex}
 )
 
+// containerIDFromCgroupDir turns the base name of a cgroup directory into the id of the container
+// it holds, or "" when it holds none. It mirrors the naming GetProcessCGroup already parses out of
+// /proc/<pid>/cgroup, so that a container resolved by walking the tree and one resolved by reading
+// a process's cgroup line come out with the same id.
+func containerIDFromCgroupDir(dirName string) string {
+	for _, re := range containerScopeRegexes {
+		if m := re.FindStringSubmatch(dirName); len(m) > 1 {
+			return m[1]
+		}
+	}
+	// the cgroupfs driver, unlike the systemd one, names the directory after the container id
+	if isContainerID(dirName) {
+		return dirName
+	}
+	return ""
+}
+
+// isContainerID reports whether a bare cgroup directory name is a container id, which keeps the
+// host's own slices(system.slice, init.scope, ...) out of the mapping.
+func isContainerID(name string) bool {
+	if len(name) < 12 {
+		return false
+	}
+	for _, c := range name {
+		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+			return false
+		}
+	}
+	return true
+}
+
 type ProcessFinder struct {
 	conf *Config
 
@@ -74,6 +111,9 @@ type ProcessFinder struct {
 	stopChan     chan struct{}
 	processCache *lru.Cache
 
+	// cgroupResolver maps cgroup ids to containers; nil when the host has no usable cgroup v2 tree
+	cgroupResolver *cgroup.Resolver
+
 	// k8s clients
 	k8sConfig *rest.Config
 	registry  Registry
@@ -124,6 +164,24 @@ func (f *ProcessFinder) InitWithRegistry(ctx context.Context, conf base.FinderBa
 	}
 	f.processCache = processCache
 
+	// The cgroup mapping is what lets an already-exited process still be attributed to its pod, and
+	// what lets the access log module filter in kernel space. It needs a cgroup v2 tree; on a v1
+	// host the finder simply keeps working the way it always has, through the periodic /proc scan.
+	cgroupMountPoint := cgroup.DefaultMountPoint()
+	if cgroup.Available(cgroupMountPoint) {
+		f.cgroupResolver = cgroup.NewResolver(cgroupMountPoint, containerIDFromCgroupDir)
+		if err := f.cgroupResolver.Refresh(); err != nil {
+			log.Warnf("cannot read the cgroup tree at %s, falling back to process discovery alone: %v",
+				cgroupMountPoint, err)
+			f.cgroupResolver = nil
+		} else {
+			log.Infof("resolving containers by cgroup id, %d container cgroups mapped", f.cgroupResolver.Size())
+		}
+	} else {
+		log.Infof("no cgroup v2 hierarchy at %s, containers will only be discovered by the periodic scan",
+			cgroupMountPoint)
+	}
+
 	return nil
 }
 
@@ -172,6 +230,10 @@ func (f *ProcessFinder) Start() {
 }
 
 func (f *ProcessFinder) analyzeProcesses() error {
+	// keep the cgroup mapping in step with the containers that come and go on this node; this scan
+	// reads /proc directly, so it can never be held back by a stale mapping
+	f.RefreshCgroupResolver()
+
 	// find out all containers
 	containers := f.registry.BuildPodContainers()
 	if len(containers) == 0 {
@@ -240,6 +302,14 @@ func (f *ProcessFinder) buildProcess(p *process.Process, detectedProcesses []api
 }
 
 func (f *ProcessFinder) BuildProcesses(p *process.Process, pc *PodContainer) ([]*Process, error) {
+	return f.BuildProcessesWithFallbackName(p, pc, "")
+}
+
+// BuildProcessesWithFallbackName is BuildProcesses for a process whose /proc entry may already be
+// gone: fallbackName(the kernel task name) then stands in for the command line. Passing "" gives
+// exactly the behavior of BuildProcesses.
+func (f *ProcessFinder) BuildProcessesWithFallbackName(p *process.Process, pc *PodContainer,
+	fallbackName string) ([]*Process, error) {
 	// find builder
 	builders := make([]*ProcessBuilder, 0)
 	for _, b := range f.conf.Analyzers {
@@ -258,8 +328,16 @@ func (f *ProcessFinder) BuildProcesses(p *process.Process, pc *PodContainer) ([]
 	}
 
 	cmdline, err := p.Cmdline()
-	if err != nil {
-		return nil, err
+	if err != nil || cmdline == "" {
+		// the process is gone(or is a zombie, which keeps a readable but empty cmdline); the
+		// kernel task name is all that is left to identify it by
+		if fallbackName == "" {
+			if err != nil {
+				return nil, fmt.Errorf("cannot read the command line of process %d: %w", p.Pid, err)
+			}
+			return nil, fmt.Errorf("the command line of process %d is empty", p.Pid)
+		}
+		cmdline = fallbackName
 	}
 
 	// build process
@@ -267,9 +345,9 @@ func (f *ProcessFinder) BuildProcesses(p *process.Process, pc *PodContainer) ([]
 	for _, builder := range builders {
 		entity := &api.ProcessEntity{}
 		entity.Layer = builder.Layer
-		entity.ServiceName, err = f.buildEntity(err, p, pc, builder.ServiceNameBuilder)
-		entity.InstanceName, err = f.buildEntity(err, p, pc, builder.InstanceNameBuilder)
-		entity.ProcessName, err = f.buildEntity(err, p, pc, builder.ProcessNameBuilder)
+		entity.ServiceName, err = f.buildEntity(nil, p, pc, builder.ServiceNameBuilder, fallbackName)
+		entity.InstanceName, err = f.buildEntity(err, p, pc, builder.InstanceNameBuilder, fallbackName)
+		entity.ProcessName, err = f.buildEntity(err, p, pc, builder.ProcessNameBuilder, fallbackName)
 		entity.Labels = builder.Labels
 		if err != nil {
 			return nil, err
@@ -284,11 +362,12 @@ func (f *ProcessFinder) BuildProcesses(p *process.Process, pc *PodContainer) ([]
 	return processes, nil
 }
 
-func (f *ProcessFinder) buildEntity(err error, ps *process.Process, pc *PodContainer, entity *base.TemplateBuilder) (string, error) {
+func (f *ProcessFinder) buildEntity(err error, ps *process.Process, pc *PodContainer, entity *base.TemplateBuilder,
+	fallbackName string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return renderTemplate(entity, ps, pc, f)
+	return renderTemplate(entity, ps, pc, f, fallbackName)
 }
 
 func (f *ProcessFinder) GetProcessCGroup(pid int32) ([]string, error) {
@@ -423,6 +502,96 @@ func (f *ProcessFinder) ShouldMonitor(pid int32) bool {
 	return true
 }
 
+// ShouldMonitorExecuting judges a process the kernel has just started.
+//
+// It prefers the ordinary /proc based path, which is richer and is exactly what the periodic scan
+// does. Only when /proc has nothing left to say - the process already exited, which is the whole
+// reason it is worth catching this early - does it fall back to what the kernel handed us: the
+// cgroup id identifies the container, and the task name stands in for the command line.
+// This runs once per process the kernel reports, so the pod/container map is built once and shared
+// by both paths below. Calling ShouldMonitor here instead would rebuild it a second time for every
+// event, and rebuilding means walking every pod of every informer - the kind of per-event cost that
+// made these tracepoints too expensive to keep the last time round.
+func (f *ProcessFinder) ShouldMonitorExecuting(exec *api.ProcessExecuteContext) bool {
+	containers := f.registry.BuildPodContainers()
+	if len(containers) == 0 {
+		return false
+	}
+
+	// the ordinary path: the process is still alive, so /proc answers just as it does for the
+	// periodic scan
+	if alive, err := process.NewProcess(exec.Pid); err == nil {
+		if processes, monitor := f.buildProcess(alive, nil, containers); monitor && len(processes) > 0 {
+			f.manager.AddDetectedProcess(processes)
+			return true
+		}
+	}
+
+	if exec.CgroupID == 0 || f.cgroupResolver == nil {
+		// no kernel side identity to fall back on(cgroup v1, or the tree could not be walked)
+		return false
+	}
+	containerID, exist := f.cgroupResolver.ContainerByCgroupID(exec.CgroupID)
+	if !exist {
+		return false
+	}
+	pc, exist := containers[containerID]
+	if !exist || pc == nil {
+		return false
+	}
+	// the process is gone, so nothing can be read from /proc; a bare Process carries the pid, which
+	// is all the entity building still needs from it.
+	processes, err := f.BuildProcessesWithFallbackName(&process.Process{Pid: exec.Pid}, pc, exec.Comm)
+	if err != nil || len(processes) == 0 {
+		log.Debugf("cannot build the exited process %d in container %s: %v", exec.Pid, containerID, err)
+		return false
+	}
+	detected := make([]api.DetectedProcess, 0, len(processes))
+	for _, p := range processes {
+		detected = append(detected, p)
+	}
+	f.manager.AddDetectedProcess(detected)
+	return true
+}
+
+// RefreshCgroupResolver rebuilds the cgroup id -> container mapping. The caller drives it from the
+// periodic scan, which reads /proc and is therefore never blocked by whatever the mapping says.
+func (f *ProcessFinder) RefreshCgroupResolver() {
+	if f.cgroupResolver == nil {
+		return
+	}
+	if err := f.cgroupResolver.Refresh(); err != nil {
+		log.Warnf("cannot refresh the cgroup mapping, already started short-lived processes may not "+
+			"be attributed until the next refresh: %v", err)
+	}
+}
+
+// CgroupResolvable reports whether this host lets cgroup ids be resolved at all, which decides
+// whether a caller may filter by cgroup or has to filter some other way.
+func (f *ProcessFinder) CgroupResolvable() bool {
+	return f.cgroupResolver != nil
+}
+
+// ContainerByCgroupID names the container a cgroup id belongs to. This is the direction that lets
+// an already-exited process still be attributed: the cgroup id comes with the kernel event, so
+// nothing has to be read from the process's /proc entry.
+func (f *ProcessFinder) ContainerByCgroupID(cgroupID uint64) (string, bool) {
+	if f.cgroupResolver == nil {
+		return "", false
+	}
+	return f.cgroupResolver.ContainerByCgroupID(cgroupID)
+}
+
+// CgroupIDByContainer exposes the cgroup id of a container so the access log module can seed the
+// kernel side allowlist with it. The second result is false when the container's cgroup is not(yet)
+// mapped, which the caller must read as "cannot filter on this one", never as "monitor nothing".
+func (f *ProcessFinder) CgroupIDByContainer(containerID string) (uint64, bool) {
+	if f.cgroupResolver == nil {
+		return 0, false
+	}
+	return f.cgroupResolver.CgroupIDByContainer(containerID)
+}
+
 func (f *ProcessFinder) IsPodIP(ip string) (bool, error) {
 	val, exist := f.podIPChecker.Get(ip)
 	if exist {
diff --git a/pkg/process/finders/kubernetes/template.go b/pkg/process/finders/kubernetes/template.go
index 617f82b..ab73280 100644
--- a/pkg/process/finders/kubernetes/template.go
+++ b/pkg/process/finders/kubernetes/template.go
@@ -59,11 +59,12 @@ func executeFilter(builders []*base.TemplateBuilder, p *process.Process, pc *Pod
 	return true, nil
 }
 
-func renderTemplate(builder *base.TemplateBuilder, p *process.Process, pc *PodContainer, finder *ProcessFinder) (string, error) {
+func renderTemplate(builder *base.TemplateBuilder, p *process.Process, pc *PodContainer, finder *ProcessFinder,
+	fallbackName string) (string, error) {
 	moduleManager := finder.manager.GetModuleManager()
 	return builder.Execute(&EntityRenderContext{
 		Rover:     base.NewTemplateRover(moduleManager),
-		Process:   base.NewTemplateProcess(moduleManager, p),
+		Process:   base.NewTemplateProcessWithFallbackName(moduleManager, p, fallbackName),
 		Pod:       &TemplatePod{pc, finder},
 		Container: &TemplateContainer{pc},
 	})
diff --git a/pkg/process/finders/manager.go b/pkg/process/finders/manager.go
index a252416..dc8eb37 100644
--- a/pkg/process/finders/manager.go
+++ b/pkg/process/finders/manager.go
@@ -167,3 +167,67 @@ func (m *ProcessManager) ShouldMonitor(pid int32) bool {
 	}
 	return monitor
 }
+
+// cgroupResolvingFinder is what a finder must offer for cgroup ids to be resolvable; only the
+// Kubernetes finder knows about containers, so only it implements this.
+type cgroupResolvingFinder interface {
+	CgroupResolvable() bool
+	CgroupIDByContainer(containerID string) (uint64, bool)
+}
+
+// CgroupResolvable reports whether any finder can resolve cgroup ids on this host.
+func (m *ProcessManager) CgroupResolvable() bool {
+	for _, finder := range m.finders {
+		if resolver, ok := finder.(cgroupResolvingFinder); ok && resolver.CgroupResolvable() {
+			return true
+		}
+	}
+	return false
+}
+
+// CgroupIDByContainer returns the cgroup id of a container as resolved by whichever finder knows it.
+func (m *ProcessManager) CgroupIDByContainer(containerID string) (uint64, bool) {
+	for _, finder := range m.finders {
+		if resolver, ok := finder.(cgroupResolvingFinder); ok {
+			if id, exist := resolver.CgroupIDByContainer(containerID); exist {
+				return id, true
+			}
+		}
+	}
+	return 0, false
+}
+
+// ExecutingProcessesWanted reports whether any *active* finder implements ExecutingProcessFinder,
+// which is what makes reporting a process the instant it starts worth its cost.
+//
+// Implementing that interface is the finder's way of saying it can act on a process that may
+// already be gone; a finder without it can only fall back to the /proc based check, which for a
+// short-lived process answers "no" after having paid for the lookup. m.finders holds only the
+// active finders, so an inactive one cannot keep the reports switched on.
+func (m *ProcessManager) ExecutingProcessesWanted() bool {
+	for _, finder := range m.finders {
+		if _, ok := finder.(base.ExecutingProcessFinder); ok {
+			return true
+		}
+	}
+	return false
+}
+
+// ShouldMonitorExecuting judges a process the kernel has just started. Finders that can use the
+// kernel-side context take it; the rest fall back to the /proc based check, which is all they could
+// have done anyway.
+func (m *ProcessManager) ShouldMonitorExecuting(exec *api.ProcessExecuteContext) bool {
+	monitor := false
+	for _, finder := range m.finders {
+		if executing, ok := finder.(base.ExecutingProcessFinder); ok {
+			if executing.ShouldMonitorExecuting(exec) {
+				monitor = true
+			}
+			continue
+		}
+		if finder.ShouldMonitor(exec.Pid) {
+			monitor = true
+		}
+	}
+	return monitor
+}
diff --git a/pkg/process/finders/manager_executing_test.go b/pkg/process/finders/manager_executing_test.go
new file mode 100644
index 0000000..23aa636
--- /dev/null
+++ b/pkg/process/finders/manager_executing_test.go
@@ -0,0 +1,107 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package finders
+
+import (
+	"context"
+	"testing"
+
+	commonv3 "skywalking.apache.org/repo/goapi/collect/common/v3"
+	v3 "skywalking.apache.org/repo/goapi/collect/ebpf/profiling/process/v3"
+
+	"github.com/apache/skywalking-rover/pkg/process/api"
+	"github.com/apache/skywalking-rover/pkg/process/finders/base"
+)
+
+// plainFinder monitors only long-lived processes, the way the VM finder does: it has no use for a
+// process reported the instant it starts, so it does not implement ExecutingProcessFinder.
+type plainFinder struct{}
+
+func (p *plainFinder) Init(_ context.Context, _ base.FinderBaseConfig, _ base.ProcessManager) error {
+	return nil
+}
+func (p *plainFinder) Start()                                              {}
+func (p *plainFinder) Stop() error                                         { return nil }
+func (p *plainFinder) DetectType() api.ProcessDetectType                   { return api.Kubernetes }
+func (p *plainFinder) ShouldMonitor(_ int32) bool                          { return false }
+func (p *plainFinder) ValidateProcessIsSame(_, _ api.DetectedProcess) bool { return false }
+func (p *plainFinder) BuildEBPFProcess(_ *base.BuildEBPFProcessContext, _ api.DetectedProcess) *v3.EBPFProcessProperties {
+	return nil
+}
+func (p *plainFinder) BuildNecessaryProperties(_ api.DetectedProcess) []*commonv3.KeyStringValuePair {
+	return nil
+}
+func (p *plainFinder) ParseProcessID(_ api.DetectedProcess, _ *v3.EBPFProcessDownstream) string {
+	return ""
+}
+
+// executingFinder additionally takes processes as they start, the way the Kubernetes finder does.
+type executingFinder struct{ plainFinder }
+
+func (e *executingFinder) ShouldMonitorExecuting(_ *api.ProcessExecuteContext) bool { return true }
+
+// TestExecutingProcessesWanted pins the gate that decides whether the kernel is asked to report
+// processes at all.
+//
+// The cost of getting this wrong is asymmetric and silent. Reporting when nothing can use the
+// reports is what made these tracepoints unaffordable before: a finder that only monitors
+// long-lived processes rejects every reported one *after* a /proc read, paying the full price for
+// no coverage. Nothing fails; the agent just quietly burns CPU on every process the machine starts.
+func TestExecutingProcessesWanted(t *testing.T) {
+	tests := []struct {
+		name    string
+		finders []base.ProcessFinder
+		want    bool
+	}{
+		{
+			name:    "no finder can use the reports - do not ask for them",
+			finders: []base.ProcessFinder{&plainFinder{}},
+			want:    false,
+		},
+		{
+			name:    "a finder takes processes as they start",
+			finders: []base.ProcessFinder{&executingFinder{}},
+			want:    true,
+		},
+		{
+			name:    "one of several can use them",
+			finders: []base.ProcessFinder{&plainFinder{}, &executingFinder{}},
+			want:    true,
+		},
+		{
+			name:    "no active finder at all",
+			finders: nil,
+			want:    false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &ProcessManager{finders: make(map[base.FinderBaseConfig]base.ProcessFinder)}
+			for i, f := range tt.finders {
+				m.finders[&stubConfig{id: i}] = f
+			}
+			if got := m.ExecutingProcessesWanted(); got != tt.want {
+				t.Fatalf("ExecutingProcessesWanted() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+type stubConfig struct{ id int }
+
+func (s *stubConfig) ActiveFinder() bool { return true }
diff --git a/pkg/process/module.go b/pkg/process/module.go
index 6a62632..8081e9d 100644
--- a/pkg/process/module.go
+++ b/pkg/process/module.go
@@ -103,6 +103,22 @@ func (m *Module) ShouldMonitor(pid int32) bool {
 	return m.manager.ShouldMonitor(pid)
 }
 
+func (m *Module) ShouldMonitorExecuting(exec *api.ProcessExecuteContext) bool {
+	return m.manager.ShouldMonitorExecuting(exec)
+}
+
+func (m *Module) ExecutingProcessesWanted() bool {
+	return m.manager.ExecutingProcessesWanted()
+}
+
+func (m *Module) CgroupResolvable() bool {
+	return m.manager.CgroupResolvable()
+}
+
+func (m *Module) CgroupIDByContainer(containerID string) (uint64, bool) {
+	return m.manager.CgroupIDByContainer(containerID)
+}
+
 func (m *Module) NodeName() string {
 	return m.config.Kubernetes.NodeName
 }
diff --git a/pkg/tools/cgroup/resolver.go b/pkg/tools/cgroup/resolver.go
new file mode 100644
index 0000000..d4d89c4
--- /dev/null
+++ b/pkg/tools/cgroup/resolver.go
@@ -0,0 +1,205 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package cgroup resolves the cgroup ids that eBPF programs report(through
+// bpf_get_current_cgroup_id) to the container they belong to, and back.
+package cgroup
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"syscall"
+
+	"github.com/apache/skywalking-rover/pkg/logger"
+	"github.com/apache/skywalking-rover/pkg/tools/host"
+)
+
+var log = logger.GetLogger("tools", "cgroup")
+
+// DefaultMountPoint is where the unified(v2) cgroup hierarchy lives, as seen from inside the agent
+// container.
+//
+// It has to be the *host's* /sys: the agent's own only exposes its own cgroup subtree, and the
+// containers whose ids we need are not in it. Where the host's /sys is mounted is a property of the
+// deployment - the helm chart bind-mounts it at /sys, an environment where the node is itself a
+// container may put it elsewhere - so the prefix comes from ROVER_HOST_SYS_MAPPING rather than
+// being assumed, exactly as the /proc prefix does.
+func DefaultMountPoint() string {
+	return host.GetHostSysInHost("fs/cgroup")
+}
+
+const (
+	// controllersFile only exists on the unified(v2) hierarchy, which makes its presence the
+	// probe for "this host runs cgroup v2". On a v1-only host bpf_get_current_cgroup_id() reports
+	// the id from the unified hierarchy, where the containers are not, so every lookup would miss
+	// and the caller must disable the whole cgroup based path instead.
+	controllersFile = "cgroup.controllers"
+)
+
+// There is deliberately no bound on how deep the walk goes, because there is no depth to bound it
+// to. Where a container's cgroup sits is not fixed by anything:
+//
+//   - the cgroup driver decides both the naming and the number of levels(systemd:
+//     kubepods.slice/kubepods-<qos>.slice/kubepods-<qos>-pod<uid>.slice/<id>.scope, cgroupfs:
+//     kubepods/<qos>/pod<uid>/<id>)
+//   - the QoS class adds a level for burstable/besteffort that guaranteed pods do not have
+//   - wherever the kubelet itself runs inside a container(kind, docker-in-docker) the whole
+//     hierarchy hangs under that container's cgroup, e.g. /system.slice/docker-<node>.scope/
+//     kubelet.slice/kubelet-kubepods.slice/..., which is three levels further down
+//   - the kubelet can be pointed at an arbitrary cgroup root
+//
+// If the layout were knowable the path could simply be built and stat'ed, and none of this would
+// exist. It is not, which is why the tree is walked and matched by name - so what makes a directory
+// a container is its name, never how deep it happens to be. An earlier version guessed a depth that
+// fit a bare node; on a nested one it matched nothing at all and did so silently.
+//
+// The walk itself is cheap: the tree holds at most a few thousand directories, only their names are
+// read, and a directory is stat'ed only once its name says it is a container.
+
+// NameNormalizer turns the base name of a cgroup directory into the id of the container it holds,
+// or "" when the directory does not belong to a container. It is injected rather than implemented
+// here because the naming is container runtime specific and the process finder already owns those
+// rules.
+type NameNormalizer func(dirName string) string
+
+// Resolver maps cgroup ids to container ids and back, by walking the host's cgroup v2 tree.
+//
+// The kernel identifies a cgroup v2 by the inode of its directory, and that inode is precisely
+// what bpf_get_current_cgroup_id() returns, so the mapping is built by stat'ing the tree.
+//
+// Reading the tree from the mounted cgroupfs - rather than from /proc/<pid>/cgroup - is the whole
+// point: the paths in /proc/<pid>/cgroup are rendered relative to the cgroup namespace of the
+// *reading* process, so an agent living in its own namespace reads unresolvable paths such as
+// "0::/../<container-id>", whereas a bind-mounted host cgroupfs exposes the full tree with real
+// inodes no matter which namespace the reader sits in.
+type Resolver struct {
+	mountPoint string
+	normalize  NameNormalizer
+
+	mu            sync.RWMutex
+	idByContainer map[string]uint64
+	containerByID map[uint64]string
+}
+
+// NewResolver builds a Resolver over mountPoint(use DefaultMountPoint unless testing). It does not
+// walk anything yet; call Refresh for that.
+func NewResolver(mountPoint string, normalize NameNormalizer) *Resolver {
+	return &Resolver{
+		mountPoint:    mountPoint,
+		normalize:     normalize,
+		idByContainer: make(map[string]uint64),
+		containerByID: make(map[uint64]string),
+	}
+}
+
+// Available reports whether mountPoint holds a usable unified(v2) hierarchy. A caller must treat a
+// false here as "the cgroup id of a process cannot be resolved on this host" and fall back to
+// whatever it did before, never as a reason to fail.
+func Available(mountPoint string) bool {
+	st, err := os.Stat(filepath.Join(mountPoint, controllersFile))
+	return err == nil && !st.IsDir()
+}
+
+// Refresh rebuilds the mapping from the current state of the tree. It is meant to be driven by the
+// periodic process discovery: a cgroup that appears between two refreshes is simply resolved on the
+// next one, and until then the caller behaves as it did before this package existed.
+func (r *Resolver) Refresh() error {
+	idByContainer := make(map[string]uint64)
+	containerByID := make(map[uint64]string)
+
+	root := filepath.Clean(r.mountPoint)
+	scanned := 0
+	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			// A cgroup that vanished mid-walk is normal churn on a busy node, so skip it. Anything
+			// else - a permission or an IO error - must not be swallowed: it would leave the mapping
+			// quietly incomplete, and an incomplete mapping does not look like a failure. It looks
+			// exactly like the containers it missed having no short-lived processes. Fail instead,
+			// so the caller keeps its previous mapping, or falls back, knowingly.
+			if os.IsNotExist(err) {
+				return nil
+			}
+			return fmt.Errorf("reading %s: %w", path, err)
+		}
+		if !d.IsDir() {
+			return nil
+		}
+		scanned++
+		container := r.normalize(d.Name())
+		if container == "" {
+			return nil
+		}
+		info, err := d.Info()
+		if err != nil {
+			return nil //nolint:nilerr // the directory disappeared mid-walk
+		}
+		stat, ok := info.Sys().(*syscall.Stat_t)
+		if !ok {
+			return nil
+		}
+		id := stat.Ino
+		idByContainer[container] = id
+		containerByID[id] = container
+		// The path is logged with the id because this mapping is the one thing that has to agree
+		// with what the kernel reports: bpf_get_current_cgroup_id() returns the inode of exactly
+		// this directory. If the two ever disagree, the id and the path it was read from are what
+		// is needed to see why.
+		log.Debugf("cgroup mapping: id=%d container=%s path=%s",
+			id, container, strings.TrimPrefix(path, root))
+		return nil
+	})
+	if err != nil {
+		return fmt.Errorf("walking the cgroup tree at %s: %w", root, err)
+	}
+	log.Debugf("cgroup walk over %s finished: %d directories scanned, %d containers mapped",
+		root, scanned, len(containerByID))
+
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.idByContainer = idByContainer
+	r.containerByID = containerByID
+	return nil
+}
+
+// CgroupIDByContainer returns the cgroup id of a container, for seeding the BPF allowlist.
+func (r *Resolver) CgroupIDByContainer(containerID string) (uint64, bool) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	id, exist := r.idByContainer[containerID]
+	return id, exist
+}
+
+// ContainerByCgroupID returns the container a cgroup id belongs to. This is the direction that lets
+// a process which has already exited still be attributed: the cgroup id arrives with the kernel
+// event, so nothing has to be read from the process's /proc entry.
+func (r *Resolver) ContainerByCgroupID(id uint64) (string, bool) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	container, exist := r.containerByID[id]
+	return container, exist
+}
+
+// Size reports how many container cgroups are currently mapped, for logging.
+func (r *Resolver) Size() int {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	return len(r.containerByID)
+}
diff --git a/pkg/tools/cgroup/resolver_test.go b/pkg/tools/cgroup/resolver_test.go
new file mode 100644
index 0000000..2f06bf3
--- /dev/null
+++ b/pkg/tools/cgroup/resolver_test.go
@@ -0,0 +1,312 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package cgroup
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"syscall"
+	"testing"
+)
+
+// containerdScope mirrors the systemd-driver naming the Kubernetes finder already parses.
+var containerdScope = regexp.MustCompile(`cri-containerd-(\w+)\.scope`)
+
+// testNormalizer applies the same rules the Kubernetes finder uses: a systemd scope carries the
+// container id inside its name, while the cgroupfs driver names the directory after the id itself.
+func testNormalizer(dirName string) string {
+	if m := containerdScope.FindStringSubmatch(dirName); len(m) > 1 {
+		return m[1]
+	}
+	if len(dirName) == 64 && !strings.ContainsAny(dirName, ".-") {
+		return dirName
+	}
+	return ""
+}
+
+// inodeOf reads the inode the kernel gave a directory. On a real cgroup v2 tree this is the value
+// bpf_get_current_cgroup_id() reports, which is the equivalence the Resolver relies on.
+func inodeOf(t *testing.T, path string) uint64 {
+	t.Helper()
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatalf("stat %s: %v", path, err)
+	}
+	stat, ok := info.Sys().(*syscall.Stat_t)
+	if !ok {
+		t.Fatalf("no Stat_t for %s", path)
+	}
+	return stat.Ino
+}
+
+func mkdirAll(t *testing.T, parts ...string) string {
+	t.Helper()
+	path := filepath.Join(parts...)
+	if err := os.MkdirAll(path, 0o755); err != nil {
+		t.Fatalf("mkdir %s: %v", path, err)
+	}
+	return path
+}
+
+// buildTree lays out a cgroup v2 tree shaped like a real node: the kubepods slices a container
+// lives under, plus host slices that must never be mistaken for containers.
+func buildTree(t *testing.T) (root, systemdContainer, cgroupfsContainer string) {
+	t.Helper()
+	root = t.TempDir()
+	if err := os.WriteFile(filepath.Join(root, controllersFile), []byte("cpu memory\n"), 0o600); err != nil {
+		t.Fatalf("write controllers: %v", err)
+	}
+
+	// systemd driver: kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/cri-containerd-<id>.scope
+	systemdContainer = strings.Repeat("a", 64)
+	mkdirAll(t, root, "kubepods.slice", "kubepods-burstable.slice",
+		"kubepods-burstable-pod1234.slice", "cri-containerd-"+systemdContainer+".scope")
+
+	// cgroupfs driver: kubepods/besteffort/pod<uid>/<id>
+	cgroupfsContainer = strings.Repeat("b", 64)
+	mkdirAll(t, root, "kubepods", "besteffort", "pod5678", cgroupfsContainer)
+
+	// host slices - these must not show up as containers
+	mkdirAll(t, root, "system.slice", "kubelet.service")
+	mkdirAll(t, root, "init.scope")
+	return root, systemdContainer, cgroupfsContainer
+}
+
+func TestResolverMapsContainersToTheirCgroupInode(t *testing.T) {
+	root, systemdContainer, cgroupfsContainer := buildTree(t)
+
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+
+	// exactly the two containers, none of the host slices
+	if got := r.Size(); got != 2 {
+		t.Fatalf("expected 2 container cgroups, got %d", got)
+	}
+
+	cases := []struct {
+		name      string
+		container string
+		dir       string
+	}{
+		{"systemd driver", systemdContainer, filepath.Join(root, "kubepods.slice", "kubepods-burstable.slice",
+			"kubepods-burstable-pod1234.slice", "cri-containerd-"+systemdContainer+".scope")},
+		{"cgroupfs driver", cgroupfsContainer, filepath.Join(root, "kubepods", "besteffort", "pod5678", cgroupfsContainer)},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			want := inodeOf(t, c.dir)
+
+			// forward: container -> cgroup id, which is what seeds the BPF allowlist
+			id, exist := r.CgroupIDByContainer(c.container)
+			if !exist {
+				t.Fatalf("container %s not mapped", c.container)
+			}
+			if id != want {
+				t.Fatalf("cgroup id = %d, want the directory inode %d", id, want)
+			}
+
+			// reverse: cgroup id -> container, which is what attributes an already-exited process
+			container, exist := r.ContainerByCgroupID(want)
+			if !exist {
+				t.Fatalf("cgroup id %d not mapped back", want)
+			}
+			if container != c.container {
+				t.Fatalf("container = %s, want %s", container, c.container)
+			}
+		})
+	}
+}
+
+func TestResolverIgnoresHostSlices(t *testing.T) {
+	root, _, _ := buildTree(t)
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	// a host slice's inode must not resolve to any container: this is what keeps kubelet and
+	// friends out of the allowlist.
+	hostInode := inodeOf(t, filepath.Join(root, "system.slice", "kubelet.service"))
+	if container, exist := r.ContainerByCgroupID(hostInode); exist {
+		t.Fatalf("host slice resolved to container %s", container)
+	}
+}
+
+// TestResolverFindsNestedKubeletLayout is the regression guard for the layout that appears wherever
+// the kubelet itself runs inside a container(kind, docker-in-docker): the pod cgroups hang under
+// the node container's own cgroup, which puts a container seven levels down instead of four. A
+// depth bound sized for a bare node maps nothing here, and the failure is silent - the mapping is
+// simply empty, so nothing is ever attributed.
+func TestResolverFindsNestedKubeletLayout(t *testing.T) {
+	root := t.TempDir()
+	if err := os.WriteFile(filepath.Join(root, controllersFile), []byte("cpu memory\n"), 0o600); err != nil {
+		t.Fatalf("write controllers: %v", err)
+	}
+	container := strings.Repeat("d", 64)
+	dir := mkdirAll(t, root, "system.slice", "docker-"+strings.Repeat("e", 64)+".scope",
+		"kubelet.slice", "kubelet-kubepods.slice", "kubelet-kubepods-burstable.slice",
+		"kubelet-kubepods-burstable-pod6fc4a17a_9b0a_4ab4_833e_7004a16f89ce.slice",
+		"cri-containerd-"+container+".scope")
+
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	id, exist := r.CgroupIDByContainer(container)
+	if !exist {
+		t.Fatal("a container under a nested kubelet hierarchy must still be mapped")
+	}
+	if want := inodeOf(t, dir); id != want {
+		t.Fatalf("cgroup id = %d, want the directory inode %d", id, want)
+	}
+}
+
+// TestResolverIsIndifferentToDepth pins the invariant that replaced the old depth bound: what makes
+// a directory a container is its name, never how deep it sits. There is no layout to bound the walk
+// to - the driver, the QoS class and a containerised kubelet each move things around - so a resolver
+// that stops at some depth maps nothing on the layouts it did not anticipate, silently.
+func TestResolverIsIndifferentToDepth(t *testing.T) {
+	root := t.TempDir()
+	if err := os.WriteFile(filepath.Join(root, controllersFile), []byte("cpu memory\n"), 0o600); err != nil {
+		t.Fatalf("write controllers: %v", err)
+	}
+	// far deeper than any real hierarchy, to show the walk is not the thing deciding
+	container := strings.Repeat("f", 64)
+	parts := []string{root}
+	for i := 0; i < 20; i++ {
+		parts = append(parts, fmt.Sprintf("nest%d", i))
+	}
+	dir := mkdirAll(t, append(parts, "cri-containerd-"+container+".scope")...)
+
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	id, exist := r.CgroupIDByContainer(container)
+	if !exist {
+		t.Fatal("a container must be mapped no matter how deep it sits")
+	}
+	if want := inodeOf(t, dir); id != want {
+		t.Fatalf("cgroup id = %d, want the directory inode %d", id, want)
+	}
+}
+
+// TestRefreshFailsOnUnreadableSubtree pins the difference between the two kinds of walk error.
+//
+// A cgroup vanishing mid-walk is routine and must be skipped. Anything else - here an unreadable
+// directory - has to fail the refresh instead, because a mapping that is quietly missing entries is
+// indistinguishable from those containers simply having no short-lived processes: nothing errors,
+// nothing is attributed, and there is no signal anywhere. Failing lets the caller keep its previous
+// mapping, or fall back, knowing that it did.
+func TestRefreshFailsOnUnreadableSubtree(t *testing.T) {
+	if os.Geteuid() == 0 {
+		t.Skip("root ignores directory permissions, so the error cannot be provoked")
+	}
+	root, _, _ := buildTree(t)
+	locked := mkdirAll(t, root, "kubepods.slice", "locked.slice")
+	mkdirAll(t, locked, "cri-containerd-"+strings.Repeat("9", 64)+".scope")
+	if err := os.Chmod(locked, 0o000); err != nil {
+		t.Fatalf("chmod: %v", err)
+	}
+	// restored so t.TempDir can clean the tree up
+	t.Cleanup(func() { os.Chmod(locked, 0o755) }) //nolint:errcheck // best effort
+
+	r := NewResolver(root, testNormalizer)
+	err := r.Refresh()
+	if err == nil {
+		t.Fatal("an unreadable subtree must fail the refresh, not silently shrink the mapping")
+	}
+	if !strings.Contains(err.Error(), "locked.slice") {
+		t.Fatalf("the error should name the path it could not read, got: %v", err)
+	}
+}
+
+// TestRefreshKeepsPreviousMappingOnFailure guards what a failed refresh must NOT do: throw away a
+// mapping that was good. The periodic refresh only logs its error, so if a failure emptied the
+// mapping the agent would stop attributing anything until some later walk happened to succeed.
+func TestRefreshKeepsPreviousMappingOnFailure(t *testing.T) {
+	if os.Geteuid() == 0 {
+		t.Skip("root ignores directory permissions, so the error cannot be provoked")
+	}
+	root, systemdContainer, _ := buildTree(t)
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("first refresh: %v", err)
+	}
+	before, exist := r.CgroupIDByContainer(systemdContainer)
+	if !exist {
+		t.Fatal("the container should be mapped after a good refresh")
+	}
+
+	locked := mkdirAll(t, root, "kubepods.slice", "locked2.slice")
+	if err := os.Chmod(locked, 0o000); err != nil {
+		t.Fatalf("chmod: %v", err)
+	}
+	t.Cleanup(func() { os.Chmod(locked, 0o755) }) //nolint:errcheck // best effort
+
+	if err := r.Refresh(); err == nil {
+		t.Fatal("expected the refresh to fail")
+	}
+	after, exist := r.CgroupIDByContainer(systemdContainer)
+	if !exist || after != before {
+		t.Fatalf("a failed refresh must leave the previous mapping intact, got exist=%v id=%d want id=%d",
+			exist, after, before)
+	}
+}
+
+func TestAvailableProbesUnifiedHierarchy(t *testing.T) {
+	root, _, _ := buildTree(t)
+	if !Available(root) {
+		t.Fatal("a tree with cgroup.controllers must be reported available")
+	}
+
+	// a v1 tree has no cgroup.controllers at its root, and there the cgroup id reported by BPF
+	// would never match anything, so the caller must fall back instead.
+	v1 := t.TempDir()
+	mkdirAll(t, v1, "memory")
+	if Available(v1) {
+		t.Fatal("a tree without cgroup.controllers must not be reported available")
+	}
+}
+
+// TestRefreshReplacesPreviousState makes sure a container that goes away stops resolving, rather
+// than lingering and attributing a future process to a dead pod.
+func TestRefreshReplacesPreviousState(t *testing.T) {
+	root, systemdContainer, _ := buildTree(t)
+	r := NewResolver(root, testNormalizer)
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	if _, exist := r.CgroupIDByContainer(systemdContainer); !exist {
+		t.Fatal("container should be mapped before removal")
+	}
+
+	if err := os.RemoveAll(filepath.Join(root, "kubepods.slice")); err != nil {
+		t.Fatalf("remove: %v", err)
+	}
+	if err := r.Refresh(); err != nil {
+		t.Fatalf("refresh after removal: %v", err)
+	}
+	if _, exist := r.CgroupIDByContainer(systemdContainer); exist {
+		t.Fatal("a removed container must stop resolving after a refresh")
+	}
+}
diff --git a/pkg/tools/host/file.go b/pkg/tools/host/file.go
index b376288..0513ffc 100644
--- a/pkg/tools/host/file.go
+++ b/pkg/tools/host/file.go
@@ -26,6 +26,7 @@ var (
 	hostProcMappingPath       string
 	hostEtcMappingPath        string
 	hostVarLogPodsMappingPath string
+	hostSysMappingPath        string
 )
 
 func init() {
@@ -36,6 +37,7 @@ func init() {
 	}
 	hostEtcMappingPath = os.Getenv("ROVER_HOST_ETC_MAPPING")
 	hostVarLogPodsMappingPath = os.Getenv("ROVER_HOST_VAR_LOG_PODS_MAPPING")
+	hostSysMappingPath = os.Getenv("ROVER_HOST_SYS_MAPPING")
 }
 
 func GetHostProcInHost(procSubPath string) string {
@@ -65,6 +67,22 @@ func GetHostVarLogPodsInHost(subPath string) string {
 	return cleanPath("/var/log/pods/" + subPath)
 }
 
+// GetHostSysInHost resolves a path under the host's /sys as seen from inside the agent container.
+//
+// The cgroup tree lives under here, and reading the *host's* copy of it is what makes a cgroup id
+// resolvable at all: the agent's own /sys shows only its own cgroup subtree. Where the host's /sys
+// is mounted is a property of the deployment, not something that can be assumed - the helm chart
+// bind-mounts the host /sys at /sys, while an environment where the node is itself a container
+// (kind) has to reach it through another prefix - so the mount point is injected through
+// ROVER_HOST_SYS_MAPPING(the same pattern as ROVER_HOST_PROC_MAPPING); when unset the real host
+// path /sys is used.
+func GetHostSysInHost(subPath string) string {
+	if hostSysMappingPath != "" {
+		return cleanPath(hostSysMappingPath + "/" + subPath)
+	}
+	return cleanPath("/sys/" + subPath)
+}
+
 func cleanPath(p string) string {
 	return path.Clean(p)
 }
diff --git a/pkg/tools/host/file_test.go b/pkg/tools/host/file_test.go
new file mode 100644
index 0000000..354cd4d
--- /dev/null
+++ b/pkg/tools/host/file_test.go
@@ -0,0 +1,50 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package host
+
+import "testing"
+
+// TestGetHostSysInHost guards the prefix the cgroup tree is read through.
+//
+// This matters more than it looks: if the mapping were ignored and /sys used directly, the agent
+// would read its *own* cgroup subtree, where none of the node's containers are. Nothing would
+// error - the walk would just map no containers, and every cgroup lookup would miss - so the
+// symptom would be "no short-lived process is ever reported", which is indistinguishable from
+// there being no traffic.
+func TestGetHostSysInHost(t *testing.T) {
+	original := hostSysMappingPath
+	defer func() { hostSysMappingPath = original }()
+
+	tests := []struct {
+		name    string
+		mapping string
+		want    string
+	}{
+		{"unmapped, the real host path", "", "/sys/fs/cgroup"},
+		{"mapped by the deployment", "/host/sys", "/host/sys/fs/cgroup"},
+		{"mapping with a trailing slash", "/host/sys/", "/host/sys/fs/cgroup"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			hostSysMappingPath = tt.mapping
+			if got := GetHostSysInHost("fs/cgroup"); got != tt.want {
+				t.Fatalf("GetHostSysInHost = %s, want %s", got, tt.want)
+			}
+		})
+	}
+}