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 <[email protected]>
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)
+                       }
+               })
+       }
+}

Reply via email to