diff --git a/CHANGELOG.md b/CHANGELOG.md index f2567bf45a9..4fa35a255fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### libcontainer API ### +- `configs.ToCPUSet` now returns a `unix.CPUSetDynamic` instead of a + `*unix.CPUSet`, and the `Initial`/`Final` fields of `configs.CPUAffinity` and + the `Nodes` field of `configs.LinuxMemoryPolicy` have changed type + accordingly. This lifts the previous 1024 CPUs/nodes limit. (#5343) + ### Fixed ### - The poststart hooks are now executed after starting the user-specified process, fixing a runtime-spec conformance issue. (#4347, #5186) +### Changed ### +- The `cpuAffinity` and NUMA `memoryPolicy` settings are no longer limited + to 1024 CPUs/nodes, as runc now uses a dynamically-sized CPU mask. (#5343) + ## [1.5.0] - 2026-06-19 > Why do we even have that lever?! diff --git a/internal/linux/linux.go b/internal/linux/linux.go index 13713159328..73b0b60c53a 100644 --- a/internal/linux/linux.go +++ b/internal/linux/linux.go @@ -2,7 +2,6 @@ package linux import ( "os" - "unsafe" "golang.org/x/sys/unix" ) @@ -66,18 +65,10 @@ func Recvfrom(fd int, p []byte, flags int) (n int, from unix.Sockaddr, err error return n, from, err } -// SchedSetaffinity wraps sched_setaffinity syscall without unix.CPUSet size limitation. -func SchedSetaffinity(pid int, buf []byte) error { +// SchedSetaffinity wraps [unix.SchedSetaffinityDynamic]. +func SchedSetaffinity(pid int, aff unix.CPUSetDynamic) error { err := retryOnEINTR(func() error { - _, _, errno := unix.Syscall( - unix.SYS_SCHED_SETAFFINITY, - uintptr(pid), - uintptr(len(buf)), - uintptr((unsafe.Pointer)(&buf[0]))) - if errno != 0 { - return errno - } - return nil + return unix.SchedSetaffinityDynamic(pid, aff) }) return os.NewSyscallError("sched_setaffinity", err) } @@ -90,10 +81,10 @@ func Sendmsg(fd int, p, oob []byte, to unix.Sockaddr, flags int) error { return os.NewSyscallError("sendmsg", err) } -// SetMempolicy wraps set_mempolicy. -func SetMempolicy(mode int, mask *unix.CPUSet) error { +// SetMempolicy wraps [unix.SetMempolicyDynamic]. +func SetMempolicy(mode int, mask unix.CPUSetDynamic) error { err := retryOnEINTR(func() error { - return unix.SetMemPolicy(mode, mask) + return unix.SetMemPolicyDynamic(mode, mask) }) return os.NewSyscallError("set_mempolicy", err) } diff --git a/libcontainer/configs/config.go b/libcontainer/configs/config.go index 7caa4e824a0..c44d5ae4958 100644 --- a/libcontainer/configs/config.go +++ b/libcontainer/configs/config.go @@ -11,7 +11,6 @@ import ( "strconv" "strings" "time" - "unsafe" "github.com/sirupsen/logrus" "golang.org/x/sys/unix" @@ -305,26 +304,53 @@ func ToSchedAttr(scheduler *Scheduler) (*unix.SchedAttr, error) { type IOPriority = specs.LinuxIOPriority type CPUAffinity struct { - Initial, Final *unix.CPUSet + Initial, Final unix.CPUSetDynamic } -// ToCPUSet parses a string in list format into a unix.CPUSet, e.g. "0-3,5,7-9". -func ToCPUSet(str string) (*unix.CPUSet, error) { +// maxCPUSetCPU is the highest CPU/NUMA node ID that [ToCPUSet] accepts. It is +// an arbitrary sanity limit, used to avoid allocating an unreasonably large +// mask for a bogus input. +const maxCPUSetCPU = 64 * 1024 + +// ToCPUSet parses a string in list format into a [unix.CPUSetDynamic], +// e.g. "0-3,5,7-9". +func ToCPUSet(str string) (unix.CPUSetDynamic, error) { if str == "" { return nil, nil } - s := new(unix.CPUSet) - // Since (*CPUset).Set silently ignores too high CPU values, - // find out what the maximum is, and return an error. - maxCPU := uint64(unsafe.Sizeof(*s) * 8) + maxCPU, ranges, err := cpuStrToRanges(str) + if err != nil { + return nil, err + } + if len(ranges) == 0 { + return nil, fmt.Errorf("no members found in set %q", str) + } + + s := unix.NewCPUSet(maxCPU + 1) + for _, cr := range ranges { + for i := cr.start; i <= cr.end; i++ { + s.Set(i) + } + } + + return s, nil +} + +type cpuRange struct { + start, end int +} + +// cpuStrToRanges parses string (e.g. "0-3,5,7-9") into a slice of cpuRange +// values. It also returns the maximum value and an error, if any. +func cpuStrToRanges(str string) (maxCPU int, ranges []cpuRange, _ error) { toInt := func(v string) (int, error) { ret, err := strconv.ParseUint(v, 10, 32) if err != nil { return 0, err } - if ret >= maxCPU { - return 0, fmt.Errorf("values larger than %d are not supported", maxCPU-1) + if ret > maxCPUSetCPU { + return 0, fmt.Errorf("values larger than %d are not supported", maxCPUSetCPU) } return int(ret), nil } @@ -336,34 +362,27 @@ func ToCPUSet(str string) (*unix.CPUSet, error) { if r == "" { continue } - if r0, r1, found := strings.Cut(r, "-"); found { - start, err := toInt(r0) - if err != nil { - return nil, err - } - end, err := toInt(r1) - if err != nil { - return nil, err - } - if start > end { - return nil, errors.New("invalid range: " + r) + start, end, found := strings.Cut(r, "-") + cr := cpuRange{} + var err error + if cr.start, err = toInt(start); err != nil { + return 0, nil, err + } + if found { + if cr.end, err = toInt(end); err != nil { + return 0, nil, err } - for i := start; i <= end; i++ { - s.Set(i) + if cr.start > cr.end { + return 0, nil, errors.New("invalid range: " + r) } } else { - val, err := toInt(r) - if err != nil { - return nil, err - } - s.Set(val) + cr.end = cr.start } - } - if s.Count() == 0 { - return nil, fmt.Errorf("no members found in set %q", str) + maxCPU = max(maxCPU, cr.end) + ranges = append(ranges, cr) } - return s, nil + return maxCPU, ranges, nil } // ConvertCPUAffinity converts [specs.CPUAffinity] to [CPUAffinity]. diff --git a/libcontainer/configs/memorypolicy.go b/libcontainer/configs/memorypolicy.go index 81bff0d10e7..4ab1e2ee119 100644 --- a/libcontainer/configs/memorypolicy.go +++ b/libcontainer/configs/memorypolicy.go @@ -10,5 +10,5 @@ type LinuxMemoryPolicy struct { // Flags contains mode flags. Flags int `json:"flags,omitempty"` // Nodes contains NUMA nodes to which the mode applies. - Nodes *unix.CPUSet `json:"nodes,omitempty"` + Nodes unix.CPUSetDynamic `json:"nodes,omitempty"` } diff --git a/libcontainer/configs/tocpuset_test.go b/libcontainer/configs/tocpuset_test.go index bd0f1e047dd..f9c1734f618 100644 --- a/libcontainer/configs/tocpuset_test.go +++ b/libcontainer/configs/tocpuset_test.go @@ -1,47 +1,67 @@ package configs import ( + "slices" "testing" "golang.org/x/sys/unix" ) func TestToCPUSet(t *testing.T) { - set := func(cpus ...int) *unix.CPUSet { - r := &unix.CPUSet{} + set := func(cpus ...int) unix.CPUSetDynamic { + maxCPU := 0 + for _, cpu := range cpus { + maxCPU = max(maxCPU, cpu) + } + r := unix.NewCPUSet(maxCPU + 1) for _, cpu := range cpus { r.Set(cpu) } return r } + // trim removes trailing all-zero masks so that sets representing the + // same CPUs compare equal regardless of how they were allocated. + trim := func(s unix.CPUSetDynamic) unix.CPUSetDynamic { + for len(s) > 0 && s[len(s)-1] == 0 { + s = s[:len(s)-1] + } + return s + } + testCases := []struct { in string - out *unix.CPUSet + out unix.CPUSetDynamic isErr bool }{ {in: ""}, // Empty means unset. // Valid cases. - {in: "0", out: &unix.CPUSet{1}}, - {in: "1", out: &unix.CPUSet{2}}, - {in: "0-1", out: &unix.CPUSet{3}}, - {in: "0,1", out: &unix.CPUSet{3}}, - {in: ",0,1,", out: &unix.CPUSet{3}}, - {in: "0-3", out: &unix.CPUSet{0x0f}}, - {in: "0,1,2-3", out: &unix.CPUSet{0x0f}}, - {in: "4-7", out: &unix.CPUSet{0xf0}}, - {in: "0-7", out: &unix.CPUSet{0xff}}, - {in: "0-15", out: &unix.CPUSet{0xffff}}, - {in: "16", out: &unix.CPUSet{0x10000}}, + {in: "0", out: set(0)}, + {in: "1", out: set(1)}, + {in: "0-1", out: set(0, 1)}, + {in: "0,1", out: set(0, 1)}, + {in: ",0,1,", out: set(0, 1)}, + {in: "0-3", out: set(0, 1, 2, 3)}, + {in: "0,1,2-3", out: set(0, 1, 2, 3)}, + {in: "4-7", out: set(4, 5, 6, 7)}, + {in: "0-7", out: set(0, 1, 2, 3, 4, 5, 6, 7)}, + {in: "0-15", out: set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)}, + {in: "16", out: set(16)}, // Extra whitespace in between ranges are OK. - {in: "1, 2, 1-2", out: &unix.CPUSet{6}}, - {in: " , 1 , 3 , 5-7, ", out: &unix.CPUSet{0xea}}, - // Somewhat large values. The underlying type in unix.CPUSet - // can either be uint32 or uint64, so we have to use a helper. + {in: "1, 2, 1-2", out: set(1, 2)}, + {in: " , 1 , 3 , 5-7, ", out: set(1, 3, 5, 6, 7)}, + // Somewhat large values. {in: "0-3,32-33", out: set(0, 1, 2, 3, 32, 33)}, {in: "127-129, 1", out: set(1, 127, 128, 129)}, {in: "1023", out: set(1023)}, + // Values larger than what a (non-dynamic) unix.CPUSet can hold. + {in: "1024", out: set(1024)}, + {in: "8191", out: set(8191)}, + {in: "4096-4098", out: set(4096, 4097, 4098)}, + {in: "0,65536", out: set(0, 65536)}, + // Maximum allowed value. + {in: "65536", out: set(65536)}, // Error cases. {in: "-", isErr: true}, @@ -53,7 +73,9 @@ func TestToCPUSet(t *testing.T) { {in: "54-53", isErr: true}, // Extra spaces inside a range is not OK. {in: "1 - 2", isErr: true}, - {in: "1024", isErr: true}, // Too big for unix.CPUSet. + // Larger than the maximum supported value. + {in: "65537", isErr: true}, + {in: "0-65537", isErr: true}, } for _, tc := range testCases { @@ -80,7 +102,7 @@ func TestToCPUSet(t *testing.T) { if out == nil { t.Fatalf("want %v, got nil", tc.out) } - if *out != *tc.out { + if !slices.Equal(trim(out), trim(tc.out)) { t.Errorf("case %q: want %v, got %v", tc.in, tc.out, out) } }) diff --git a/libcontainer/process_linux.go b/libcontainer/process_linux.go index f7722535af2..efa3d62cafa 100644 --- a/libcontainer/process_linux.go +++ b/libcontainer/process_linux.go @@ -1,7 +1,6 @@ package libcontainer import ( - "bytes" "context" "encoding/json" "errors" @@ -204,7 +203,8 @@ func tryResetCPUAffinity(pid int) { // Instead, we use a huge buffer similarly to go 1.25 runtime in // getCPUCount(). const maxCPUs = 64 * 1024 - buf := bytes.Repeat([]byte{0xff}, maxCPUs/8) + buf := unix.NewCPUSet(maxCPUs) + buf.Fill() if err := linux.SchedSetaffinity(pid, buf); err != nil { logrus.WithError(err).Warnf("resetting the CPU affinity of pid %d failed -- the container process may inherit runc's CPU affinity", pid) return @@ -224,7 +224,7 @@ func (p *setnsProcess) startWithCPUAffinity() error { go func() { runtime.LockOSThread() // Command inherits the CPU affinity. - if err := unix.SchedSetaffinity(unix.Gettid(), aff.Initial); err != nil { + if err := linux.SchedSetaffinity(unix.Gettid(), aff.Initial); err != nil { errCh <- fmt.Errorf("error setting initial CPU affinity: %w", err) return } @@ -250,7 +250,7 @@ func (p *setnsProcess) setFinalCPUAffinity() error { if aff.Final == nil { return nil } - if err := unix.SchedSetaffinity(p.pid(), aff.Final); err != nil { + if err := linux.SchedSetaffinity(p.pid(), aff.Final); err != nil { return fmt.Errorf("error setting final CPU affinity: %w", err) } return nil