diff --git a/cmd/containerd/command/main.go b/cmd/containerd/command/main.go index 519e5849db44..61c4712b6e83 100644 --- a/cmd/containerd/command/main.go +++ b/cmd/containerd/command/main.go @@ -38,6 +38,7 @@ import ( "github.com/containerd/containerd/services/server" srvconfig "github.com/containerd/containerd/services/server/config" "github.com/containerd/containerd/sys" + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/containerd/version" ) @@ -132,6 +133,16 @@ can be used and modified as necessary as a custom configuration.` return err } + // Initialize block IO weight control (Linux only, no-op on other platforms) + blkioConfig := config.Cgroup.Blkio + if err := blkiorun.Init( + blkiorun.Config{Weight: uint16(blkioConfig.Weight)}, + blkioConfig.SlicePath, + blkioConfig.SliceName, + ); err != nil { + log.G(ctx).WithError(err).Warn("failed to initialize blkio control") + } + if config.GRPC.Address == "" { return fmt.Errorf("grpc address cannot be empty: %w", errdefs.ErrInvalidArgument) } diff --git a/pkg/unpack/unpacker.go b/pkg/unpack/unpacker.go index 773722a0886f..63eb77f2ad3d 100644 --- a/pkg/unpack/unpacker.go +++ b/pkg/unpack/unpacker.go @@ -45,6 +45,7 @@ import ( "github.com/containerd/containerd/pkg/cleanup" "github.com/containerd/containerd/pkg/kmutex" "github.com/containerd/containerd/snapshots" + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/containerd/tracing" ) @@ -374,7 +375,9 @@ func (u *Unpacker) unpack( case <-fetchC[i-fetchOffset]: } - diff, err := a.Apply(ctx, desc, mounts, unpack.ApplyOpts...) + diff, err := blkiorun.Local(func() (ocispec.Descriptor, error) { + return a.Apply(ctx, desc, mounts, unpack.ApplyOpts...) + }) if err != nil { cleanup.Do(ctx, abort) return fmt.Errorf("failed to extract layer %s: %w", diffIDs[i], err) @@ -474,7 +477,9 @@ func (u *Unpacker) fetch(ctx context.Context, h images.Handler, layers []ocispec return err } - _, err = h.Handle(ctx2, desc) + _, err = blkiorun.Local(func() ([]ocispec.Descriptor, error) { + return h.Handle(ctx2, desc) + }) unlock() u.release() diff --git a/pull.go b/pull.go index c5d3e969a9fc..5b2eab9c4a55 100644 --- a/pull.go +++ b/pull.go @@ -32,6 +32,7 @@ import ( "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" "github.com/containerd/containerd/remotes/docker/schema1" //nolint:staticcheck // Ignore SA1019. Need to keep deprecated package for compatibility. + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/containerd/tracing" ) @@ -53,6 +54,14 @@ func (c *Client) Pull(ctx context.Context, ref string, opts ...RemoteOpt) (_ Ima } } + // Run with configured IO weight (if enabled) + return blkiorun.Go(func() (Image, error) { + return c.pull(ctx, ref, pullCtx, span) + }) +} + +// pull is the internal implementation of Pull that performs the actual work. +func (c *Client) pull(ctx context.Context, ref string, pullCtx *RemoteContext, span *tracing.Span) (_ Image, retErr error) { if pullCtx.PlatformMatcher == nil { if len(pullCtx.Platforms) > 1 { return nil, errors.New("cannot pull multiplatform image locally, try Fetch") diff --git a/services/server/config/config.go b/services/server/config/config.go index e3fac1d82156..293969cfb964 100644 --- a/services/server/config/config.go +++ b/services/server/config/config.go @@ -166,6 +166,23 @@ type MetricsConfig struct { // CgroupConfig provides cgroup configuration type CgroupConfig struct { Path string `toml:"path"` + // Blkio configures block IO control for containerd operations (cgroups v2) + Blkio BlkioConfig `toml:"blkio"` +} + +// BlkioConfig provides block IO configuration for cgroups v2 +type BlkioConfig struct { + // Weight is the IO weight value (10-1000) for image pull, unpack, and commit operations. + // Set to 0 to disable IO weight control. Default is 0 (disabled). + Weight int `toml:"weight"` + // SlicePath is the path to an existing cgroup for IO weight control. + // If specified, this cgroup will be used directly (must already exist with io controller enabled). + // If empty, a transient systemd slice will be created via D-Bus using SliceName. + SlicePath string `toml:"slice_path"` + // SliceName is the systemd slice name for IO weight control (e.g., "containerdio.slice"). + // Only used when SlicePath is empty. The slice will be created as a transient unit via systemd D-Bus. + // Leave empty to use default: "containerdio.slice" + SliceName string `toml:"slice_name"` } // ProxyPlugin provides a proxy plugin configuration diff --git a/services/snapshots/service.go b/services/snapshots/service.go index fe368d6b7ab3..1c3d27ff722a 100644 --- a/services/snapshots/service.go +++ b/services/snapshots/service.go @@ -33,6 +33,7 @@ import ( "github.com/containerd/containerd/services" "github.com/containerd/containerd/services/warning" "github.com/containerd/containerd/snapshots" + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/log" ) @@ -165,7 +166,13 @@ func (s *service) Commit(ctx context.Context, cr *snapshotsapi.CommitSnapshotReq if cr.Labels != nil { opts = append(opts, snapshots.WithLabels(cr.Labels)) } - if err := sn.Commit(ctx, cr.Name, cr.Key, opts...); err != nil { + + // Run with configured IO weight (if enabled) + _, err = blkiorun.Go(func() (struct{}, error) { + return struct{}{}, sn.Commit(ctx, cr.Name, cr.Key, opts...) + }) + + if err != nil { return nil, errdefs.ToGRPC(err) } diff --git a/snapshots/devbox/devbox.go b/snapshots/devbox/devbox.go index bc0a26aceafd..58c18e5a490a 100644 --- a/snapshots/devbox/devbox.go +++ b/snapshots/devbox/devbox.go @@ -33,6 +33,7 @@ import ( "github.com/containerd/containerd/mount" "github.com/containerd/containerd/snapshots" "github.com/containerd/containerd/snapshots/overlay/overlayutils" + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/continuity/fs" "github.com/containerd/errdefs" "github.com/containerd/log" @@ -381,10 +382,12 @@ func (o *Snapshotter) RemoveDir(ctx context.Context, dir string) { return } } else { - if err1 := os.RemoveAll(dir); err1 != nil { - log.G(ctx).WithError(err1).WithField("path", dir).Warn("failed to remove directory") - return - } + blkiorun.Go(func() (struct{}, error) { + if err1 := os.RemoveAll(dir); err1 != nil { + log.G(ctx).WithError(err1).WithField("path", dir).Warn("failed to remove directory") + } + return struct{}{}, nil + }) } } diff --git a/snapshots/overlay/overlay.go b/snapshots/overlay/overlay.go index 369c2404dc21..a647a50664d5 100644 --- a/snapshots/overlay/overlay.go +++ b/snapshots/overlay/overlay.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/containerd/snapshots" "github.com/containerd/containerd/snapshots/overlay/overlayutils" "github.com/containerd/containerd/snapshots/storage" + "github.com/containerd/containerd/sys/blkiorun" "github.com/containerd/continuity/fs" "github.com/containerd/log" "github.com/sirupsen/logrus" @@ -352,11 +353,14 @@ func (o *snapshotter) Cleanup(ctx context.Context) error { return err } - for _, dir := range cleanup { - if err := os.RemoveAll(dir); err != nil { - log.G(ctx).WithError(err).WithField("path", dir).Warn("failed to remove directory") + blkiorun.Go(func() (struct{}, error) { + for _, dir := range cleanup { + if err := os.RemoveAll(dir); err != nil { + log.G(ctx).WithError(err).WithField("path", dir).Warn("failed to remove directory") + } } - } + return struct{}{}, nil + }) return nil } diff --git a/sys/blkiorun/blkiorun_linux.go b/sys/blkiorun/blkiorun_linux.go new file mode 100644 index 000000000000..444678307fde --- /dev/null +++ b/sys/blkiorun/blkiorun_linux.go @@ -0,0 +1,481 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package blkiorun + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + systemdDbus "github.com/coreos/go-systemd/v22/dbus" + "github.com/godbus/dbus/v5" + + "github.com/containerd/log" +) + +const ( + // DefaultSliceName is the default systemd slice name + DefaultSliceName = "containerdio.slice" + + // SystemdTimeout is the timeout for systemd operations + SystemdTimeout = 5 * time.Second + + // sliceWaitRetries is the number of retries when waiting for slice creation + sliceWaitRetries = 10 + + // sliceWaitInterval is the interval between retries + sliceWaitInterval = 100 * time.Millisecond + + // BFQWeightMin is the minimum BFQ IO weight + BFQWeightMin uint16 = 10 + // BFQWeightMax is the maximum BFQ IO weight + BFQWeightMax uint16 = 1000 + // BFQWeightDefault is the default BFQ IO weight + BFQWeightDefault uint16 = 100 + + // IOWeightMin is the minimum io.weight value + IOWeightMin uint64 = 1 + // IOWeightMax is the maximum io.weight value + IOWeightMax uint64 = 10000 + + // Conversion factors for BFQ <-> io.weight + // io.weight = 1 + (bfq - 10) * 9999 / 990 + // bfq = 10 + (io.weight - 1) * 990 / 9999 + conversionNumerator = 9999 + conversionDenominator = 990 +) + +var ( + state *globalState + stateOnce sync.Once + counter uint64 + + // BFQ support detection + bfqSupported bool + bfqSupportedOnce sync.Once + + cgroupV2 bool + cgroupV2Once sync.Once + + // ErrNotInitialized is returned when blkiorun is not initialized + ErrNotInitialized = errors.New("blkiorun not initialized, call Init first") +) + +// globalState stores the IO weight control state +type globalState struct { + slicePath string // cgroup path for the slice + containerdPath string // containerd's cgroup path + config Config // runtime config + initialized bool +} + +// Init initializes block IO weight control for containerd. +// Parameters: +// - cfg: Runtime config containing IO weight (10-1000). Set weight to 0 to disable. +// - slicePath: Path to existing cgroup (optional, uses systemd if empty) +// - sliceName: Systemd slice name (default: "containerdio.slice") +func Init(cfg Config, slicePath, sliceName string) error { + var initErr error + + stateOnce.Do(func() { + s := &globalState{} + defer func() { state = s }() + + if cfg.Weight == 0 { + log.L.Debug("blkiorun: disabled (weight=0)") + return + } + + if cfg.Weight < BFQWeightMin || cfg.Weight > BFQWeightMax { + log.L.Warnf("blkiorun: weight %d out of range [%d, %d], disabled", cfg.Weight, BFQWeightMin, BFQWeightMax) + return + } + + s.config = cfg + log.L.Infof("blkiorun: weight configured: %d", cfg.Weight) + + if !isCgroupV2() { + log.L.Warn("blkiorun: cgroups v2 not available") + } + + // Get containerd's cgroup path + var err error + s.containerdPath, err = getCurrentCgroupPath() + if err != nil { + initErr = fmt.Errorf("failed to get cgroup path: %w", err) + return + } + log.L.Debugf("blkiorun: containerd cgroup: %s", s.containerdPath) + + var cgroupPath string + if slicePath != "" { + cgroupPath = slicePath + log.L.Debugf("blkiorun: using configured path: %s", cgroupPath) + } else { + // Create systemd slice + if sliceName == "" { + sliceName = DefaultSliceName + } + if !strings.HasSuffix(sliceName, ".slice") { + sliceName += ".slice" + } + + ctx, cancel := context.WithTimeout(context.Background(), SystemdTimeout) + defer cancel() + + if err := createSlice(ctx, sliceName); err != nil { + log.L.WithError(err).Warnf("blkiorun: failed to create slice %s", sliceName) + return + } + + cgroupPath = sliceCgroupPath(sliceName) + for i := 0; i < sliceWaitRetries; i++ { + if _, err := os.Stat(cgroupPath); err == nil { + break + } + time.Sleep(sliceWaitInterval) + } + } + + // Verify io.weight is available + if _, err := os.Stat(filepath.Join(cgroupPath, "io.weight")); os.IsNotExist(err) { + log.L.Warn("blkiorun: io.weight not available") + return + } + + // Enable io controller for children + if err := enableIOController(cgroupPath); err != nil { + log.L.WithError(err).Warn("blkiorun: failed to enable io controller") + return + } + + // Apply default IO weight to slice + if err := applyConfig(cgroupPath, cfg); err != nil { + log.L.WithError(err).Warn("blkiorun: failed to apply config") + return + } + + s.slicePath = cgroupPath + s.initialized = true + log.L.Infof("blkiorun: initialized at %s with weight %d", cgroupPath, cfg.Weight) + }) + + return initErr +} + +// IsInitialized returns true if blkiorun is initialized +func IsInitialized() bool { + return state != nil && state.initialized +} + +// Go executes fn in a new goroutine with configured IO weight. +func Go[T any](fn func() (T, error)) (T, error) { + return GoWithConfig(state.config, fn) +} + +// GoWithConfig executes fn in a new goroutine with specified config. +func GoWithConfig[T any](cfg Config, fn func() (T, error)) (T, error) { + type result struct { + value T + err error + } + + ch := make(chan result, 1) + go func() { + v, err := LocalWithConfig(cfg, fn) + ch <- result{v, err} + }() + + res := <-ch + return res.value, res.err +} + +// Local executes fn in current goroutine with configured IO weight. +func Local[T any](fn func() (T, error)) (T, error) { + return LocalWithConfig(state.config, fn) +} + +// LocalWithConfig executes fn in current goroutine with specified config. +func LocalWithConfig[T any](cfg Config, fn func() (T, error)) (T, error) { + if cfg.Weight == 0 || !IsInitialized() { + return fn() + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cg, err := createCgroup(cfg) + if err != nil { + if errors.Is(err, ErrNotInitialized) { + return fn() + } + log.L.WithError(err).Error("blkiorun: failed to create cgroup") + return fn() + } + defer func(){ + err := cg.destroy() + if err != nil { + log.L.WithError(err).Error("blkiorun: failed to destroy cgroup") + } + }() + + if err := cg.enter(); err != nil { + log.L.WithError(err).Error("blkiorun: failed to enter cgroup") + return fn() + } + defer func(){ + err := cg.leave() + if err != nil { + log.L.WithError(err).Error("blkiorun: failed to leave cgroup") + } + }() + + return fn() +} + +// cgroup represents a temporary cgroup for IO weight control +type cgroup struct { + containerdCgroup string + path string +} + +func createCgroup(cfg Config) (*cgroup, error) { + if state == nil || !state.initialized { + return nil, ErrNotInitialized + } + + id := atomic.AddUint64(&counter, 1) + path := filepath.Join(state.slicePath, fmt.Sprintf("blkio-%d-%d", os.Getpid(), id)) + + if err := os.Mkdir(path, 0755); err != nil { + return nil, err + } + + cg := &cgroup{path: path, containerdCgroup: state.containerdPath} + if err := applyConfig(cg.path, cfg); err != nil { + errDestroy := cg.destroy() + if errDestroy != nil { + log.L.WithError(errDestroy).Error("blkiorun: failed to destroy cgroup after applyConfig failure") + } + return nil, err + } + + return cg, nil +} + +func applyConfig(path string, cfg Config) error { + if cfg.Weight > 0 { + return writeIOWeight(path, cfg.Weight) + } + return nil +} + +// isBFQSupported checks if BFQ IO scheduler is available in the given cgroup path. +func isBFQSupported(cgroupPath string) bool { + bfqSupportedOnce.Do(func() { + bfqPath := filepath.Join(cgroupPath, "io.bfq.weight") + if _, err := os.Stat(bfqPath); err == nil { + bfqSupported = true + } + }) + return bfqSupported +} + +// readIOWeight reads the current IO weight from cgroups. +// Returns BFQ weight if available, otherwise io.weight converted to BFQ range. +func readIOWeight(cgroupPath string) (uint16, error) { + // Try BFQ first + if isBFQSupported(cgroupPath) { + data, err := os.ReadFile(filepath.Join(cgroupPath, "io.bfq.weight")) + if err == nil { + fields := strings.Fields(string(bytes.TrimSpace(data))) + if len(fields) > 0 { + weight, err := strconv.ParseUint(fields[len(fields)-1], 10, 16) + if err == nil { + return uint16(weight), nil + } + } + } + } + + // Fallback to io.weight + data, err := os.ReadFile(filepath.Join(cgroupPath, "io.weight")) + if err != nil { + return BFQWeightDefault, nil // Return default if reading fails + } + + fields := strings.Fields(string(bytes.TrimSpace(data))) + if len(fields) > 0 { + ioWeight, err := strconv.ParseUint(fields[len(fields)-1], 10, 64) + if err == nil { + return ConvertIOWeightToBFQ(ioWeight), nil + } + } + + return BFQWeightDefault, nil +} + +// writeIOWeight writes IO weight to cgroups. +// Uses io.bfq.weight if available, otherwise io.weight with conversion. +func writeIOWeight(cgroupPath string, weight uint16) error { + // Try BFQ first + if isBFQSupported(cgroupPath) { + bfqPath := filepath.Join(cgroupPath, "io.bfq.weight") + if err := os.WriteFile(bfqPath, []byte(strconv.FormatUint(uint64(weight), 10)), 0644); err == nil { + return nil + } + } + + // Fallback to io.weight with conversion + ioWeight := ConvertBFQToIOWeight(weight) + return os.WriteFile(filepath.Join(cgroupPath, "io.weight"), []byte(strconv.FormatUint(ioWeight, 10)), 0644) +} + +func (cg *cgroup) enter() error { + log.L.Debugf("blkiorun: entering cgroup %s", cg.path) + return os.WriteFile(filepath.Join(cg.path, "cgroup.procs"), []byte(strconv.Itoa(syscall.Gettid())), 0644) +} + +func (cg *cgroup) leave() error { + log.L.Debugf("blkiorun: leaving cgroup %s", cg.path) + return os.WriteFile(filepath.Join(cg.containerdCgroup, "cgroup.procs"), []byte(strconv.Itoa(syscall.Gettid())), 0644) +} + +func (cg *cgroup) destroy() error { + log.L.Debugf("blkiorun: removing cgroup %s", cg.path) + return os.Remove(cg.path) +} + +// Helper functions + +func isCgroupV2() bool { + cgroupV2Once.Do(func() { + cgroupV2 = checkCgroupV2() + }) + return cgroupV2 +} + +func checkCgroupV2() bool { + stat, err := os.Stat("/sys/fs/cgroup/cgroup.controllers") + return err == nil && !stat.IsDir() +} + +func getCurrentCgroupPath() (string, error) { + data, err := os.ReadFile("/proc/self/cgroup") + if err != nil { + return "", err + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + parts := strings.SplitN(scanner.Text(), ":", 3) + if len(parts) == 3 && parts[0] == "0" { + p := parts[2] + if p == "" { + p = "/" + } + return filepath.Join("/sys/fs/cgroup", p), nil + } + } + return "", errors.New("cgroup v2 path not found") +} + +func enableIOController(path string) error { + ctrl := filepath.Join(path, "cgroup.subtree_control") + data, _ := os.ReadFile(ctrl) + if strings.Contains(string(data), "io") { + return nil + } + return os.WriteFile(ctrl, []byte("+io"), 0644) +} + +func createSlice(ctx context.Context, name string) error { + conn, err := systemdDbus.NewWithContext(ctx) + if err != nil { + return err + } + defer conn.Close() + + props := []systemdDbus.Property{ + systemdDbus.PropDescription("Containerd IO Weight Control"), + {Name: "DefaultDependencies", Value: dbus.MakeVariant(false)}, + {Name: "IOAccounting", Value: dbus.MakeVariant(true)}, + } + + ch := make(chan string, 1) + _, err = conn.StartTransientUnitContext(ctx, name, "replace", props, ch) + if err != nil { + if strings.Contains(err.Error(), "already") || strings.Contains(err.Error(), "loaded") { + return nil + } + return err + } + + select { + case <-ch: + case <-time.After(SystemdTimeout): + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +func sliceCgroupPath(name string) string { + n := strings.TrimSuffix(name, ".slice") + parts := strings.Split(n, "-") + if len(parts) == 1 { + return filepath.Join("/sys/fs/cgroup", name) + } + var pp []string + for i := 1; i <= len(parts); i++ { + pp = append(pp, strings.Join(parts[:i], "-")+".slice") + } + return filepath.Join("/sys/fs/cgroup", filepath.Join(pp...)) +} + +// ConvertBFQToIOWeight converts BFQ weight (10-1000) to io.weight (1-10000). +func ConvertBFQToIOWeight(bfqWeight uint16) uint64 { + if bfqWeight == 0 { + return 0 + } + return uint64(IOWeightMin) + (uint64(bfqWeight)-uint64(BFQWeightMin))*conversionNumerator/conversionDenominator +} + +// ConvertIOWeightToBFQ converts io.weight (1-10000) back to BFQ weight (10-1000). +func ConvertIOWeightToBFQ(ioWeight uint64) uint16 { + if ioWeight == 0 { + return 0 + } + if ioWeight <= IOWeightMin { + return BFQWeightMin + } + if ioWeight >= IOWeightMax { + return BFQWeightMax + } + return BFQWeightMin + uint16((ioWeight-IOWeightMin)*conversionDenominator/conversionNumerator) +} diff --git a/sys/blkiorun/blkiorun_other.go b/sys/blkiorun/blkiorun_other.go new file mode 100644 index 000000000000..3506d263a1e8 --- /dev/null +++ b/sys/blkiorun/blkiorun_other.go @@ -0,0 +1,49 @@ +//go:build !linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package blkiorun + +// Init is a no-op on non-Linux platforms. +func Init(cfg Config, slicePath, sliceName string) error { + return nil +} + +// IsInitialized always returns false on non-Linux platforms. +func IsInitialized() bool { + return false +} + +// Go simply executes fn on non-Linux platforms. +func Go[T any](fn func() (T, error)) (T, error) { + return fn() +} + +// GoWithConfig simply executes fn on non-Linux platforms. +func GoWithConfig[T any](cfg Config, fn func() (T, error)) (T, error) { + return fn() +} + +// Local simply executes fn on non-Linux platforms. +func Local[T any](fn func() (T, error)) (T, error) { + return fn() +} + +// LocalWithConfig simply executes fn on non-Linux platforms. +func LocalWithConfig[T any](cfg Config, fn func() (T, error)) (T, error) { + return fn() +} diff --git a/sys/blkiorun/blkiorun_test.go b/sys/blkiorun/blkiorun_test.go new file mode 100644 index 000000000000..0ef935698aee --- /dev/null +++ b/sys/blkiorun/blkiorun_test.go @@ -0,0 +1,283 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package blkiorun + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConvertBFQToIOWeight(t *testing.T) { + tests := []struct { + name string + bfqWeight uint16 + expected uint64 + }{ + { + name: "zero weight", + bfqWeight: 0, + expected: 0, + }, + { + name: "minimum BFQ weight", + bfqWeight: 10, + expected: 1, + }, + { + name: "normal BFQ weight", + bfqWeight: 100, + expected: 910, + }, + { + name: "maximum BFQ weight", + bfqWeight: 1000, + expected: 10000, + }, + { + name: "mid-range BFQ weight", + bfqWeight: 500, + expected: 4950, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertBFQToIOWeight(tt.bfqWeight) + assert.Equal(t, tt.expected, result, "BFQ weight %d should convert to io.weight %d", tt.bfqWeight, tt.expected) + }) + } +} + +func TestConvertIOWeightToBFQ(t *testing.T) { + tests := []struct { + name string + ioWeight uint64 + expected uint16 + }{ + { + name: "zero weight", + ioWeight: 0, + expected: 0, + }, + { + name: "minimum io.weight", + ioWeight: 1, + expected: 10, + }, + { + name: "mid-range io.weight", + ioWeight: 5000, + expected: 504, + }, + { + name: "maximum io.weight", + ioWeight: 10000, + expected: 1000, + }, + { + name: "above maximum", + ioWeight: 15000, + expected: 1000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertIOWeightToBFQ(tt.ioWeight) + assert.Equal(t, tt.expected, result, "io.weight %d should convert to BFQ weight %d", tt.ioWeight, tt.expected) + }) + } +} + +func TestConversionRoundTrip(t *testing.T) { + // Test that converting from BFQ to IO and back gives approximately the same value + tests := []uint16{10, 50, 100, 200, 500, 1000} + + for _, bfqWeight := range tests { + ioWeight := ConvertBFQToIOWeight(bfqWeight) + converted := ConvertIOWeightToBFQ(ioWeight) + + // Allow for small rounding errors in conversion + diff := int(bfqWeight) - int(converted) + if diff < 0 { + diff = -diff + } + assert.LessOrEqual(t, diff, 1, "Round-trip conversion for BFQ weight %d should be within 1", bfqWeight) + } +} + +func TestIsCgroupV2(t *testing.T) { + result := isCgroupV2() + // Just verify it doesn't panic and returns a boolean + t.Logf("cgroups v2 enabled: %v", result) +} + +func TestGetCurrentCgroupPath(t *testing.T) { + path, err := getCurrentCgroupPath() + if err != nil { + t.Skipf("Skipping test: cgroups v2 not available: %v", err) + } + + assert.True(t, strings.HasPrefix(path, "/sys/fs/cgroup"), "Path should start with /sys/fs/cgroup") + + info, err := os.Stat(path) + if err == nil { + assert.True(t, info.IsDir(), "Cgroup path should be a directory") + } +} + +func TestReadIOWeight(t *testing.T) { + if !isCgroupV2() { + t.Skip("Test requires cgroups v2") + } + + cgroupPath, err := getCurrentCgroupPath() + if err != nil { + t.Skipf("Cannot get cgroup path: %v", err) + } + + weight, err := readIOWeight(cgroupPath) + if err != nil { + t.Logf("readIOWeight failed (this may be expected): %v", err) + return + } + + assert.GreaterOrEqual(t, weight, uint16(BFQWeightMin), "Weight should be at least minimum") + assert.LessOrEqual(t, weight, uint16(BFQWeightMax), "Weight should be at most maximum") + t.Logf("Current IO weight: %d", weight) +} + +func TestWriteAndReadIOWeight(t *testing.T) { + if !isCgroupV2() { + t.Skip("Test requires cgroups v2") + } + + cgroupPath, err := getCurrentCgroupPath() + if err != nil { + t.Skipf("Cannot get cgroup path: %v", err) + } + + // Read original weight + originalWeight, err := readIOWeight(cgroupPath) + if err != nil { + t.Skipf("Cannot read IO weight: %v", err) + } + t.Logf("Original IO weight: %d", originalWeight) + + // Try to write a different weight + testWeight := uint16(200) + if originalWeight == testWeight { + testWeight = 300 + } + + err = writeIOWeight(cgroupPath, testWeight) + if err != nil { + t.Skipf("Cannot write IO weight (may need permissions): %v", err) + } + + // Verify weight was set + currentWeight, err := readIOWeight(cgroupPath) + if err != nil { + t.Fatalf("Failed to read IO weight after setting: %v", err) + } + t.Logf("Current IO weight after setting: %d", currentWeight) + + // Allow small variance due to conversion + diff := int(testWeight) - int(currentWeight) + if diff < 0 { + diff = -diff + } + assert.LessOrEqual(t, diff, 1, "IO weight should be approximately the set value") + + // Restore original weight + err = writeIOWeight(cgroupPath, originalWeight) + if err != nil { + t.Logf("Warning: Failed to restore original IO weight: %v", err) + } +} + +func TestLocalWithConfigReal(t *testing.T) { + if !isCgroupV2() { + t.Skip("Test requires cgroups v2") + } + + // Skip if not initialized (this is expected in unit tests) + if !IsInitialized() { + t.Skip("blkiorun not initialized") + } + + result, err := LocalWithConfig(Config{Weight: 150}, func() (string, error) { + return "test-result", nil + }) + + assert.NoError(t, err) + assert.Equal(t, "test-result", result) +} + +func TestGoWithConfigReal(t *testing.T) { + if !isCgroupV2() { + t.Skip("Test requires cgroups v2") + } + + // Skip if not initialized + if !IsInitialized() { + t.Skip("blkiorun not initialized") + } + + result, err := GoWithConfig(Config{Weight: 150}, func() (string, error) { + return "test-result", nil + }) + + assert.NoError(t, err) + assert.Equal(t, "test-result", result) +} + +func TestSliceCgroupPath(t *testing.T) { + tests := []struct { + name string + expected string + }{ + { + name: "simple.slice", + expected: "/sys/fs/cgroup/simple.slice", + }, + { + name: "containerdio.slice", + expected: "/sys/fs/cgroup/containerdio.slice", + }, + { + name: "containerd-io.slice", + expected: "/sys/fs/cgroup/containerd.slice/containerd-io.slice", + }, + { + name: "a-b-c.slice", + expected: "/sys/fs/cgroup/a.slice/a-b.slice/a-b-c.slice", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sliceCgroupPath(tt.name) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/sys/blkiorun/config.go b/sys/blkiorun/config.go new file mode 100644 index 000000000000..b498356a6a69 --- /dev/null +++ b/sys/blkiorun/config.go @@ -0,0 +1,22 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package blkiorun + +// Config holds configuration for block IO operations +type Config struct { + Weight uint16 // IO weight (10-1000, BFQ range) +}