From 807d238717b11ef6ac5c5c080b1d7c7e0d356951 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Thu, 2 Jul 2026 14:23:22 +0200 Subject: [PATCH 1/2] fix(containerprofilemanager): cap identified call stacks per container Each distinct call site produces a distinct (deduped-by-hash) IdentifiedCallStack stored in the per-container callStacks map. A workload that launches many distinct executables (or a process-enumeration/fork-churn attack) grows this map unbounded within a reporting window, driving the node-agent OOM (observed on a live cluster: Go heap grew to ~900MB under churn, with the call-stack build/store path the top allocation). Cap at 512 distinct stacks per container - the profile only needs a representative set. Co-Authored-By: Claude Opus 4.8 (1M context) Docs-exempt: internal memory-safety cap, no API/behavior change --- .../v1/event_reporting.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 077875fe1..dee91b4a6 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -19,6 +19,13 @@ import ( var procRegex = regexp.MustCompile(`^/proc/\d+`) +// maxIdentifiedCallStacksPerContainer bounds the number of distinct identified +// call stacks retained per container between reporting flushes. Each distinct +// call site produces a distinct (deduped-by-hash) call stack; without a cap a +// workload that launches many distinct executables grows this map unbounded and +// drives the node-agent OOM. +const maxIdentifiedCallStacksPerContainer = 512 + // ReportCapability reports a capability event for a container func (cpm *ContainerProfileManager) ReportCapability(containerID, capability string) { err := cpm.withContainer(containerID, func(data *containerData) (int, error) { @@ -233,6 +240,15 @@ func (cpm *ContainerProfileManager) ReportIdentifiedCallStack(containerID string return 0, nil } + // Bound memory: a container that launches many distinct executables (or a + // process-enumeration/fork-churn attack) produces a distinct call stack per + // call site, which would otherwise grow this map unbounded within a + // reporting window and drive the node-agent OOM. The profile only needs a + // representative set, so stop retaining new call stacks past the cap. + if data.callStacks.Len() >= maxIdentifiedCallStacksPerContainer { + return 0, nil + } + // Add to call stacks map data.callStacks.Set(callStackIdentifier, callStack) return size.Of(callStack), nil From 29a033e8a5de737595d8e787147e134cde97540e Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Thu, 2 Jul 2026 14:53:30 +0200 Subject: [PATCH 2/2] fix(callstackcache): cap distinct call sites indexed in the search tree The per-container CallStackSearchTree (built from the profile's identified call stacks) stores one bidirectional tree + trie + path set per distinct CallID. A container that launches many distinct executables produces many distinct call sites, growing the tree unbounded - heap profiling showed buildBidirectionalTree/convertNodesToFrames as the top allocation (~57MB) once the identified-call-stack map was capped. Cap at 512 distinct call sites per container's tree, matching the identified-call-stack cap. Co-Authored-By: Claude Opus 4.8 (1M context) Docs-exempt: internal memory-safety cap, no API/behavior change --- pkg/objectcache/callstackcache/callstackcache.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/objectcache/callstackcache/callstackcache.go b/pkg/objectcache/callstackcache/callstackcache.go index c709281a8..ad86098ac 100644 --- a/pkg/objectcache/callstackcache/callstackcache.go +++ b/pkg/objectcache/callstackcache/callstackcache.go @@ -7,6 +7,12 @@ import ( "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" ) +// maxCallStacksInTree bounds the number of distinct call sites (CallIDs) indexed +// in a container's search tree. A container that launches many distinct +// executables produces many distinct call sites; without a cap the tree, trie and +// per-CallID paths grow unbounded and inflate node-agent memory. +const maxCallStacksInTree = 512 + // BidirectionalNode extends the call stack node with parent reference type BidirectionalNode struct { Frame v1beta1.StackFrame @@ -39,6 +45,12 @@ func (t *CallStackSearchTree) AddCallStack(stack v1beta1.IdentifiedCallStack) { return } + // Bound memory: stop indexing new call sites past the cap (see + // maxCallStacksInTree). The tree already retains a representative set. + if len(t.Roots) >= maxCallStacksInTree { + return + } + // Get all paths from the call stack paths := getCallStackPaths(stack.CallStack) if len(paths) == 0 {