diff --git a/initialize/auto/auto.go b/initialize/auto/auto.go index 2e9ce89fa1..557b3c9757 100644 --- a/initialize/auto/auto.go +++ b/initialize/auto/auto.go @@ -12,6 +12,7 @@ var msgs = []func(context.Context){} func init() { CPU() + Memory() } // PrintLogs uses zlog to report any messages queued up from the runs of diff --git a/initialize/auto/cpu_linux_test.go b/initialize/auto/cpu_linux_test.go index 922cedbef6..ae764b5408 100644 --- a/initialize/auto/cpu_linux_test.go +++ b/initialize/auto/cpu_linux_test.go @@ -11,6 +11,27 @@ import ( "github.com/quay/zlog" ) +var ( + cgv1 = &fstest.MapFile{ + Data: []byte(`11:pids:/user.slice/user-1000.slice/session-4.scope +10:cpuset:/ +9:blkio:/user.slice +8:hugetlb:/ +7:perf_event:/ +6:devices:/user.slice +5:net_cls,net_prio:/ +4:cpu,cpuacct:/user.slice +3:freezer:/ +2:memory:/user.slice/user-1000.slice/session-4.scope +1:name=systemd:/user.slice/user-1000.slice/session-4.scope +0::/user.slice/user-1000.slice/session-4.scope +`), + } + cgv2 = &fstest.MapFile{ + Data: []byte("0::/\n"), + } +) + type cgTestcase struct { In fstest.MapFS Err error @@ -35,24 +56,11 @@ func (tc cgTestcase) Run(ctx context.Context, t *testing.T) { func TestCPUDetection(t *testing.T) { ctx := zlog.Test(context.Background(), t) t.Run("V1", func(t *testing.T) { - const cgmap = `11:pids:/user.slice/user-1000.slice/session-4.scope -10:cpuset:/ -9:blkio:/user.slice -8:hugetlb:/ -7:perf_event:/ -6:devices:/user.slice -5:net_cls,net_prio:/ -4:cpu,cpuacct:/user.slice -3:freezer:/ -2:memory:/user.slice/user-1000.slice/session-4.scope -1:name=systemd:/user.slice/user-1000.slice/session-4.scope -0::/user.slice/user-1000.slice/session-4.scope -` tt := []cgTestcase{ { Name: "NoLimit", In: fstest.MapFS{ - "proc/self/cgroup": &fstest.MapFile{Data: []byte(cgmap)}, + "proc/self/cgroup": cgv1, "sys/fs/cgroup/cpu,cpuacct/user.slice/cpu.cfs_quota_us": &fstest.MapFile{ Data: []byte("-1\n"), }, @@ -62,7 +70,7 @@ func TestCPUDetection(t *testing.T) { { Name: "Limit1", In: fstest.MapFS{ - "proc/self/cgroup": &fstest.MapFile{Data: []byte(cgmap)}, + "proc/self/cgroup": cgv1, "sys/fs/cgroup/cpu,cpuacct/user.slice/cpu.cfs_quota_us": &fstest.MapFile{ Data: []byte("100000\n"), }, @@ -75,7 +83,7 @@ func TestCPUDetection(t *testing.T) { { Name: "RootFallback", In: fstest.MapFS{ - "proc/self/cgroup": &fstest.MapFile{Data: []byte(cgmap)}, + "proc/self/cgroup": cgv1, "sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us": &fstest.MapFile{ Data: []byte("100000\n"), }, @@ -96,9 +104,7 @@ func TestCPUDetection(t *testing.T) { { Name: "NoLimit", In: fstest.MapFS{ - "proc/self/cgroup": &fstest.MapFile{ - Data: []byte("0::/\n"), - }, + "proc/self/cgroup": cgv2, "sys/fs/cgroup/cpu.max": &fstest.MapFile{ Data: []byte("max 100000\n"), }, @@ -108,9 +114,7 @@ func TestCPUDetection(t *testing.T) { { Name: "Limit4", In: fstest.MapFS{ - "proc/self/cgroup": &fstest.MapFile{ - Data: []byte("0::/\n"), - }, + "proc/self/cgroup": cgv2, "sys/fs/cgroup/cpu.max": &fstest.MapFile{ Data: []byte("400000 100000\n"), }, diff --git a/initialize/auto/memory.go b/initialize/auto/memory.go new file mode 100644 index 0000000000..4c4403eb46 --- /dev/null +++ b/initialize/auto/memory.go @@ -0,0 +1,6 @@ +//go:build !linux || (linux && !go1.19) + +package auto + +// Memory is a no-op on this platform. +func Memory() {} diff --git a/initialize/auto/memory_linux.go b/initialize/auto/memory_linux.go new file mode 100644 index 0000000000..54b3df382c --- /dev/null +++ b/initialize/auto/memory_linux.go @@ -0,0 +1,135 @@ +//go:build go1.19 + +package auto + +import ( + "bufio" + "bytes" + "context" + "errors" + "io/fs" + "os" + "path" + "runtime/debug" + "strconv" + + "github.com/quay/zlog" +) + +// Memory sets the runtime's memory limit based on information gleaned from the +// current process's cgroup. See [debug.SetMemoryLimit] for details on the effects +// of setting the limit. This does mean that attempting to run Clair in an aggressively +// constrained environment may cause excessive CPU time spent in garbage +// collection. Excessive GC can be prevented by increasing the resources allowed or +// pacing Clair as a whole by reducing the CPU allocation or limiting the number of +// concurrent requests. +// +// The process' "memory.max" limit (for cgroups v2) or +// "memory.limit_in_bytes" (for cgroups v1) are the values consulted. +func Memory() { + root := os.DirFS("/") + lim, err := memLookup(root) + switch { + case err != nil: + msgs = append(msgs, func(ctx context.Context) { + zlog.Error(ctx). + Err(err). + Msg("unable to guess memory limit") + }) + return + case lim == doNothing: + msgs = append(msgs, func(ctx context.Context) { + zlog.Info(ctx). + Msg("no memory limit configured") + }) + return + case lim == setMax: + msgs = append(msgs, func(ctx context.Context) { + zlog.Info(ctx).Msg("memory limit unset") + }) + return + } + // Following the GC guide and taking a haircut: https://tip.golang.org/doc/gc-guide#Suggested_uses + tgt := lim - (lim / 20) + debug.SetMemoryLimit(tgt) + msgs = append(msgs, func(ctx context.Context) { + zlog.Info(ctx). + Int64("lim", lim). + Int64("target", tgt). + Msg("set memory limit") + }) +} + +const ( + doNothing = -1 + setMax = -2 +) + +func memLookup(r fs.FS) (int64, error) { + b, err := fs.ReadFile(r, "proc/self/cgroup") + if err != nil { + return 0, err + } + s := bufio.NewScanner(bytes.NewReader(b)) + s.Split(bufio.ScanLines) + for s.Scan() { + sl := bytes.SplitN(s.Bytes(), []byte(":"), 3) + hid, ctls, pb := sl[0], sl[1], sl[2] + if bytes.Equal(hid, []byte("0")) && len(ctls) == 0 { // If cgroupsv2: + msgs = append(msgs, func(ctx context.Context) { + zlog.Debug(ctx).Msg("found cgroups v2") + }) + n := path.Join("sys/fs/cgroup", string(pb), "memory.max") + b, err := fs.ReadFile(r, n) + switch { + case errors.Is(err, nil): + case errors.Is(err, fs.ErrNotExist): + return doNothing, nil + default: + return 0, err + } + v := string(bytes.TrimSpace(b)) + if v == "max" { // No quota, so bail. + return setMax, nil + } + return strconv.ParseInt(v, 10, 64) + } + // If here, we're doing cgroups v1. + isMem := false + for _, b := range bytes.Split(ctls, []byte(",")) { + if bytes.Equal(b, []byte("memory")) { + isMem = true + break + } + } + if !isMem { // This line is not the memory group. + continue + } + msgs = append(msgs, func(ctx context.Context) { + zlog.Debug(ctx).Msg("found cgroups v1 and memory controller") + }) + prefix := path.Join("sys/fs/cgroup", string(ctls), string(pb)) + // Check for the existence of the named cgroup. If it doesn't exist, + // look at the root of the controller. The named group not existing + // probably means the process is in a container and is having remounting + // tricks done. If, for some reason this is actually the root cgroup, + // it'll be unlimited and fall back to the default. + if _, err := fs.Stat(r, prefix); errors.Is(err, fs.ErrNotExist) { + msgs = append(msgs, func(ctx context.Context) { + zlog.Debug(ctx).Msg("falling back to root hierarchy") + }) + prefix = path.Join("sys/fs/cgroup", string(ctls)) + } + + b, err = fs.ReadFile(r, path.Join(prefix, "memory.limit_in_bytes")) + if err != nil { + return 0, err + } + v := string(bytes.TrimSpace(b)) + return strconv.ParseInt(v, 10, 64) + } + if err := s.Err(); err != nil { + return 0, err + } + return 0, nil +} diff --git a/initialize/auto/memory_linux_test.go b/initialize/auto/memory_linux_test.go new file mode 100644 index 0000000000..6386664541 --- /dev/null +++ b/initialize/auto/memory_linux_test.go @@ -0,0 +1,110 @@ +//go:build linux && go1.19 + +package auto + +import ( + "context" + "fmt" + "testing" + "testing/fstest" + + "github.com/quay/zlog" +) + +type memTestcase struct { + In fstest.MapFS + Err error + Name string + Want int64 +} + +func (tc memTestcase) Run(ctx context.Context, t *testing.T) { + t.Helper() + t.Run(tc.Name, func(t *testing.T) { + t.Helper() + ctx := zlog.Test(ctx, t) + lim, err := memLookup(tc.In) + if err != tc.Err { + t.Error(err) + } + if got, want := lim, tc.Want; tc.Err == nil && got != want { + t.Errorf("got: %v, want: %v", got, want) + } + PrintLogs(ctx) + }) +} + +func TestMemoryDetection(t *testing.T) { + const ( + limInt = 268435456 + noLimInt = -1 + ) + var ( + lim = &fstest.MapFile{Data: []byte(fmt.Sprintln(limInt))} + noLim = &fstest.MapFile{Data: []byte(fmt.Sprintln(noLimInt))} + ) + ctx := zlog.Test(context.Background(), t) + t.Run("V1", func(t *testing.T) { + tt := []memTestcase{ + { + Name: "NoLimit", + In: fstest.MapFS{ + "proc/self/cgroup": cgv1, + "sys/fs/cgroup/memory/user.slice/user-1000.slice/session-4.scope/memory.limit_in_bytes": noLim, + }, + Want: noLimInt, + }, + { + Name: "RootFallback", + In: fstest.MapFS{ + "proc/self/cgroup": cgv1, + "sys/fs/cgroup/memory/memory.limit_in_bytes": noLim, + }, + Want: noLimInt, + }, + { + Name: "256MiB", + In: fstest.MapFS{ + "proc/self/cgroup": cgv1, + "sys/fs/cgroup/memory/user.slice/user-1000.slice/session-4.scope/memory.limit_in_bytes": lim, + }, + Want: limInt, + }, + } + ctx := zlog.Test(ctx, t) + for _, tc := range tt { + tc.Run(ctx, t) + } + }) + t.Run("V2", func(t *testing.T) { + tt := []memTestcase{ + { + Name: "NoLimit", + In: fstest.MapFS{"proc/self/cgroup": cgv2}, + Want: noLimInt, + }, + { + Name: "LimitMax", + In: fstest.MapFS{ + "proc/self/cgroup": cgv2, + "sys/fs/cgroup/memory.max": &fstest.MapFile{ + Data: []byte("max\n"), + }, + }, + Want: setMax, + }, + { + Name: "256MiB", + In: fstest.MapFS{ + "proc/self/cgroup": cgv2, + "sys/fs/cgroup/memory.max": lim, + }, + Want: limInt, + }, + } + ctx := zlog.Test(ctx, t) + for _, tc := range tt { + tc.Run(ctx, t) + } + }) +}