Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]
Comment thread
kolyshkin marked this conversation as resolved.

### 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?!
Expand Down
21 changes: 6 additions & 15 deletions internal/linux/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package linux

import (
"os"
"unsafe"

"golang.org/x/sys/unix"
)
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see you mentioned it;

note we're losing retry-on-eintr but I don't think it's a problem

Is this something we should still add in golang.org/x/sys ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

By definition, x/sys/unix is a low-level thing so I guess they will not accept it.

I also think (but not sure) that sched_setaffinity is safe wrt EINTR, as it's not a blocking call (like a syscall doing I/O).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I mean, in principle almost any syscall can EINTR? It seems like we could just replace the function we're wrapping here (i.e., just replace unix.Syscall with the new unix.SchedSetAffinityDynamic) but I don't really mind too much and we were already calling into unix before...

_, _, 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)
}
Expand All @@ -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)
}
Expand Down
83 changes: 51 additions & 32 deletions libcontainer/configs/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"strconv"
"strings"
"time"
"unsafe"

"github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
Expand Down Expand Up @@ -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 {
Comment thread
rata marked this conversation as resolved.
return 0, fmt.Errorf("values larger than %d are not supported", maxCPUSetCPU)
}
return int(ret), nil
}
Expand All @@ -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].
Expand Down
2 changes: 1 addition & 1 deletion libcontainer/configs/memorypolicy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
62 changes: 42 additions & 20 deletions libcontainer/configs/tocpuset_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
rata marked this conversation as resolved.
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},
Expand All @@ -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 {
Expand All @@ -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)
}
})
Expand Down
8 changes: 4 additions & 4 deletions libcontainer/process_linux.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package libcontainer

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading