Skip to content
13 changes: 0 additions & 13 deletions changelog/fragments/1786038287-fix-cache-container-memory.yaml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
kind: bug-fix

summary: Read cgroup memory limit for cache sizing when GOMEMLIMIT is not set

description: >
containerMemoryMB() now falls back to the cgroup memory limit (v2 then v1)
before host RAM. This ensures fleet-server is correctly sized for the
container even in deployments that do not explicitly set GOMEMLIMIT.

component: fleet-server
48 changes: 44 additions & 4 deletions internal/pkg/config/env_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (
"io"
"io/fs"
"math"
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -314,7 +316,7 @@ func loadLimits(log *zerolog.Logger, agentLimit int) *envLimits {
log.Info().Msgf("Using system limits for %d to %d agents for a configured value of %d agents", l.Agents.Min, l.Agents.Max, agentLimit)
ramSize := memMB()
if ramSize < l.RecommendedRAM {
log.Warn().Msgf("Detected %d MB of available memory, which is lower than the recommended amount (%d MB) for the configured agent limit", ramSize, l.RecommendedRAM)
log.Warn().Msgf("Detected %d MiB of available memory, which is lower than the recommended amount (%d MiB) for the configured agent limit", ramSize, l.RecommendedRAM)
}
return l
}
Expand All @@ -323,17 +325,55 @@ func loadLimits(log *zerolog.Logger, agentLimit int) *envLimits {
return defaultEnvLimits()
}

// containerMemoryMB returns available memory in MiB, preferring the GOMEMLIMIT
// runtime setting over host RAM so the ristretto cache is sized for the container,
// not the node. Falls back to memory.TotalMemory() when GOMEMLIMIT is unset.
// cgroupMemMB returns the cgroup memory limit in MiB.
// It is a var so that unit tests can replace it.
var cgroupMemMB func() (uint64, bool) = cgroupMemoryLimitMB

// containerMemoryMB returns available memory in MiB using this priority order:
// 1. GOMEMLIMIT, if explicitly set
// 2. cgroup memory limit (v2, then v1), for containers without an explicit GOMEMLIMIT
// 3. host total RAM, for non-containerised deployments
func containerMemoryMB() uint64 {
limit := debug.SetMemoryLimit(-1)
if limit > 0 && limit != math.MaxInt64 {
return uint64(limit) / 1024 / 1024
}
if mb, ok := cgroupMemMB(); ok {
return mb
}
return memory.TotalMemory() / 1024 / 1024
}

// cgroupMemoryLimitMB reads the container memory limit from cgroup files.
// It tries cgroup v2 first, then cgroup v1. Returns (0, false) when no
// applicable limit is found (unlimited, missing file, or parse error).
func cgroupMemoryLimitMB() (uint64, bool) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to add a comment here that this doesn't work with nested cgroups.

if mb, ok := readCgroupMemoryFile("/sys/fs/cgroup/memory.max"); ok {
return mb, true
}
return readCgroupMemoryFile("/sys/fs/cgroup/memory/memory.limit_in_bytes")
}

// readCgroupMemoryFile parses a cgroup memory limit file and returns the
// value in MiB. Returns (0, false) when the file doesn't exist, contains
// "max" (unlimited), or the value is at or above MaxInt64 (cgroup v1's
// sentinel for unlimited).
func readCgroupMemoryFile(path string) (uint64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
s := strings.TrimSpace(string(data))
if s == "max" {
return 0, false
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil || n >= math.MaxInt64 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this the sentinel value for cgroup v1 is not quite MaxInt64 because it's rounded. Double checked here.

return 0, false
}
return n / 1024 / 1024, true
}

// memMB returns available memory in MiB.
// It is a var so that unit tests can replace it.
var memMB func() uint64 = containerMemoryMB
Expand Down
61 changes: 59 additions & 2 deletions internal/pkg/config/env_defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"io/fs"
"math"
"os"
"reflect"
"runtime/debug"
"strings"
Expand Down Expand Up @@ -91,7 +92,7 @@ func TestDefaultLimitsYAMLKeys(t *testing.T) {
}

// TestContainerMemoryMB verifies that containerMemoryMB prefers GOMEMLIMIT over
// host RAM so the ristretto cache is sized for the container, not the node.
// host RAM so fleet-server is correctly sized for the container, not the node.
func TestContainerMemoryMB(t *testing.T) {
t.Run("uses GOMEMLIMIT when set", func(t *testing.T) {
const setLimit = int64(256 * 1024 * 1024) // 256 MiB
Expand All @@ -102,11 +103,67 @@ func TestContainerMemoryMB(t *testing.T) {
assert.Equal(t, uint64(256), got)
})

t.Run("falls back to host RAM when GOMEMLIMIT is unset", func(t *testing.T) {
t.Run("uses cgroup limit when GOMEMLIMIT is unset", func(t *testing.T) {
prev := debug.SetMemoryLimit(math.MaxInt64)
t.Cleanup(func() { debug.SetMemoryLimit(prev) })
prevCgroup := cgroupMemMB
cgroupMemMB = func() (uint64, bool) { return 128, true }
t.Cleanup(func() { cgroupMemMB = prevCgroup })

got := containerMemoryMB()
assert.Equal(t, uint64(128), got)
})

t.Run("falls back to host RAM when GOMEMLIMIT and cgroup are both unset", func(t *testing.T) {
prev := debug.SetMemoryLimit(math.MaxInt64)
t.Cleanup(func() { debug.SetMemoryLimit(prev) })
prevCgroup := cgroupMemMB
cgroupMemMB = func() (uint64, bool) { return 0, false }
t.Cleanup(func() { cgroupMemMB = prevCgroup })

got := containerMemoryMB()
assert.Equal(t, memory.TotalMemory()/1024/1024, got)
})
}

func TestReadCgroupMemoryFile(t *testing.T) {
writeFile := func(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "cgroup-memory-*")
require.NoError(t, err)
_, err = f.WriteString(content)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}

t.Run("returns MiB for a valid byte limit", func(t *testing.T) {
path := writeFile(t, "134217728\n") // 128 MiB
mb, ok := readCgroupMemoryFile(path)
assert.True(t, ok)
assert.Equal(t, uint64(128), mb)
})

t.Run("returns false for 'max' (unlimited)", func(t *testing.T) {
path := writeFile(t, "max\n")
_, ok := readCgroupMemoryFile(path)
assert.False(t, ok)
})

t.Run("returns false for MaxInt64 sentinel (cgroup v1 unlimited)", func(t *testing.T) {
path := writeFile(t, "9223372036854775807\n") // math.MaxInt64
_, ok := readCgroupMemoryFile(path)
assert.False(t, ok)
})

t.Run("returns false when file does not exist", func(t *testing.T) {
_, ok := readCgroupMemoryFile("/nonexistent/cgroup/memory.max")
assert.False(t, ok)
})

t.Run("returns false for invalid content", func(t *testing.T) {
path := writeFile(t, "not-a-number\n")
_, ok := readCgroupMemoryFile(path)
assert.False(t, ok)
})
}
Loading