From 98cb59cd2e2c45cb10c222f0cc38389ef17d7ff6 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 14 Aug 2026 10:42:16 +0200 Subject: [PATCH] feat(memfill): join target cgroup without cgexec memfill placed its process into the target's memory cgroup via 'cgexec -g memory:', which depends on libcgroup-tools (cgroup-tools / libcgroup-tools). That package does not exist for Enterprise Linux 9 and never supported cgroup v2, so the extensions using memfill (extension-host, extension-container) could not be installed on RHEL 9 / Rocky 9 / Alma 9, and fill memory could not charge the right cgroup on cgroup-v2 hosts. Join the cgroup directly instead: a tiny sh wrapper writes its own PID to /cgroup.procs (cgroup v1 memory controller preferred, v2 unified fallback) and then execs memfill. The move happens before exec so nothing is allocated in the wrong cgroup under v2. This removes the cgexec runtime dependency entirely, letting the rpm packages drop it from both Requires and Recommends. --- .../memfill/memfill_process.go | 45 ++++++++++++++++--- go/action_kit_commons/memfill/memfill_test.go | 26 +++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/go/action_kit_commons/memfill/memfill_process.go b/go/action_kit_commons/memfill/memfill_process.go index f78d8285..9a8cdc27 100644 --- a/go/action_kit_commons/memfill/memfill_process.go +++ b/go/action_kit_commons/memfill/memfill_process.go @@ -15,21 +15,52 @@ import ( "time" ) +// joinCgroupScript moves the current shell into the target's memory cgroup and then execs the +// remaining arguments. It replaces the previous `cgexec -g memory:` invocation, which +// depended on libcgroup-tools (the cgroup-tools/libcgroup-tools package). That package does not +// exist for Enterprise Linux 9 and never supported cgroup v2, so the extension could neither be +// installed nor fill memory correctly on modern hosts. +// +// The move must happen before exec: under cgroup v2 a process keeps the memory already charged to +// it when migrated, so anything memfill allocates before joining would be accounted to the wrong +// cgroup. Writing $$ and then `exec`ing keeps the same PID, so memfill starts already inside the +// target cgroup with nothing allocated yet. +// +// The first argument is the target cgroup path (as read from /proc//cgroup); the remaining +// arguments are the command to exec. cgroup v1 (memory controller) is preferred over v2 unified to +// match the historical `memory:` semantics on hybrid hosts. +const joinCgroupScript = `cg="$1"; shift +if [ -e "/sys/fs/cgroup/memory${cg}/cgroup.procs" ]; then + procs="/sys/fs/cgroup/memory${cg}/cgroup.procs" +elif [ -e "/sys/fs/cgroup${cg}/cgroup.procs" ]; then + procs="/sys/fs/cgroup${cg}/cgroup.procs" +else + echo "memfill: no cgroup.procs found for ${cg} (looked under cgroup v1 memory and v2 unified)" >&2 + exit 1 +fi +printf '%s\n' "$$" > "$procs" || { echo "memfill: failed to join cgroup $procs" >&2; exit 1; } +exec "$@"` + type memfillRunc struct { cmd *exec.Cmd state *utils.BackgroundState args []string } -func NewMemfillProcess(targetProcess ociruntime.LinuxProcessInfo, opts Opts) (Memfill, error) { - args := append([]string{ +// memfillCommandArgs builds the argument vector that runs memfill inside the target's memory +// cgroup and PID namespace. It enters the host mount namespace (nsenter -t 1 -C), joins the target +// cgroup via joinCgroupScript, then enters the target PID namespace and execs memfill. +func memfillCommandArgs(targetProcess ociruntime.LinuxProcessInfo, opts Opts) []string { + args := []string{ "nsenter", "-t", "1", "-C", "--", - //when util-linux package >= 2.39 is broadly available we could also the cgroup change using nsenter, - "cgexec", "-g", fmt.Sprintf("memory:%s", targetProcess.CGroupPath), + "sh", "-c", joinCgroupScript, "sh", targetProcess.CGroupPath, "nsenter", "-t", strconv.Itoa(targetProcess.Pid), "-p", "-F", "--", - }, - opts.processArgs()..., - ) + } + return append(args, opts.processArgs()...) +} + +func NewMemfillProcess(targetProcess ociruntime.LinuxProcessInfo, opts Opts) (Memfill, error) { + args := memfillCommandArgs(targetProcess, opts) cmd := utils.RootCommandContext(context.Background(), args[0], args[1:]...) diff --git a/go/action_kit_commons/memfill/memfill_test.go b/go/action_kit_commons/memfill/memfill_test.go index c05f3079..1d4a85a9 100644 --- a/go/action_kit_commons/memfill/memfill_test.go +++ b/go/action_kit_commons/memfill/memfill_test.go @@ -8,9 +8,35 @@ import ( "testing" "time" + "github.com/steadybit/action-kit/go/action_kit_commons/ociruntime" "github.com/stretchr/testify/assert" ) +func TestMemfillCommandArgs(t *testing.T) { + t.Setenv("STEADYBIT_EXTENSION_MEMFILL_PATH", "/usr/bin/memfill") + + target := ociruntime.LinuxProcessInfo{Pid: 4242, CGroupPath: "/system.slice/target.scope"} + args := memfillCommandArgs(target, Opts{Size: 80, Mode: ModeUsage, Unit: UnitPercent, Duration: 30 * time.Second, IgnoreCgroup: true}) + + // Enters the host mount namespace, joins the target cgroup via the sh wrapper, then enters the + // target PID namespace and execs memfill. No cgexec anywhere. + assert.Equal(t, []string{ + "nsenter", "-t", "1", "-C", "--", + "sh", "-c", joinCgroupScript, "sh", "/system.slice/target.scope", + "nsenter", "-t", "4242", "-p", "-F", "--", + "/usr/bin/memfill", "80%", "usage", "30", "--ignore-cgroup", + }, args) + + for _, arg := range args { + assert.NotContains(t, arg, "cgexec", "cgexec must no longer be used") + } + + // The cgroup path is passed as a positional argument (not interpolated into the script), + // so it cannot break out of the shell. + assert.Contains(t, joinCgroupScript, "cgroup.procs") + assert.Contains(t, joinCgroupScript, "exec \"$@\"") +} + func TestProcessArgs(t *testing.T) { t.Setenv("STEADYBIT_EXTENSION_MEMFILL_PATH", "/usr/bin/memfill")