daemon: support per-device blkio throttle in ContainerUpdate

The POST /containers/{id}/update API accepts BlkioWeightDevice,
BlkioDeviceReadBps, BlkioDeviceWriteBps, BlkioDeviceReadIOps, and
BlkioDeviceWriteIOps in its Resources body, but these five fields were
silently ignored when updating a running container.

The root cause was in toContainerdResources (daemon/update_linux.go):
only BlkioWeight was mapped into specs.LinuxBlockIO; the per-device
fields were never converted, so tsk.UpdateResources never wrote to
cgroupv2 io.max or the cgroupv1 blkio throttle files.

Fix by calling the existing getBlkioWeightDevices and
getBlkioThrottleDevices helpers (already used in oci_linux.go for
container creation) to populate all five fields. The function signature
is extended to return an error so that stat(2) failures on invalid
device paths are surfaced to the caller instead of being silently
dropped.

The API makes distinction between nil and zero-length slices while
doing. A nil per-device blkio field means the caller did not request an
update for that setting, while a non-nil empty slice means the caller
explicitly requested the setting to be cleared.

The Windows stub is updated to match the new signature.

Signed-off-by: Alexis Couvreur <alexiscouvreur.pro@gmail.com>
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Alexis Couvreur authored and Paweł Gronowski committed 2026-06-12 15:52:14 +02:00
1 parent 5eda928bb8
commit ca30e0d8ec
6 files changed
+200 -9

No files matched your search

+15
View File
@@ -269,6 +269,21 @@ func (container *Container) UpdateContainer(hostConfig *containertypes.HostConfi
if resources.BlkioWeight != 0 {
cResources.BlkioWeight = resources.BlkioWeight
}
if resources.BlkioWeightDevice != nil {
cResources.BlkioWeightDevice = resources.BlkioWeightDevice
}
if resources.BlkioDeviceReadBps != nil {
cResources.BlkioDeviceReadBps = resources.BlkioDeviceReadBps
}
if resources.BlkioDeviceWriteBps != nil {
cResources.BlkioDeviceWriteBps = resources.BlkioDeviceWriteBps
}
if resources.BlkioDeviceReadIOps != nil {
cResources.BlkioDeviceReadIOps = resources.BlkioDeviceReadIOps
}
if resources.BlkioDeviceWriteIOps != nil {
cResources.BlkioDeviceWriteIOps = resources.BlkioDeviceWriteIOps
}
if resources.CPUShares != 0 {
cResources.CPUShares = resources.CPUShares
}
+6 -1
View File
@@ -93,7 +93,12 @@ func (daemon *Daemon) update(name string, hostConfig *container.HostConfig) erro
return err
}
if err := tsk.UpdateResources(context.TODO(), toContainerdResources(hostConfig.Resources)); err != nil {
resources, err := toContainerdResources(hostConfig.Resources)
if err != nil {
restoreConfig = true
return errCannotUpdate(ctr.ID, err)
}
if err := tsk.UpdateResources(context.TODO(), resources); err != nil {
restoreConfig = true
// TODO: it would be nice if containerd responded with better errors here so we can classify this better.
return errCannotUpdate(ctr.ID, errdefs.System(err))
+51 -5
View File
@@ -8,13 +8,59 @@ import (
"github.com/opencontainers/runtime-spec/specs-go"
)
func toContainerdResources(resources container.Resources) *libcontainerdtypes.Resources {
func toContainerdResources(resources container.Resources) (*libcontainerdtypes.Resources, error) {
var r libcontainerdtypes.Resources
if resources.BlkioWeight != 0 {
r.BlockIO = &specs.LinuxBlockIO{
Weight: &resources.BlkioWeight,
// little helper to lazily initialize the BlockIO struct only if needed
blockIO := func() *specs.LinuxBlockIO {
if r.BlockIO == nil {
r.BlockIO = &specs.LinuxBlockIO{}
}
return r.BlockIO
}
weightDevices, err := getBlkioWeightDevices(resources)
if err != nil {
return nil, err
}
if resources.BlkioWeightDevice != nil {
blockIO().WeightDevice = weightDevices
}
readBpsDevices, err := getBlkioThrottleDevices(resources.BlkioDeviceReadBps)
if err != nil {
return nil, err
}
if resources.BlkioDeviceReadBps != nil {
blockIO().ThrottleReadBpsDevice = readBpsDevices
}
writeBpsDevices, err := getBlkioThrottleDevices(resources.BlkioDeviceWriteBps)
if err != nil {
return nil, err
}
if resources.BlkioDeviceWriteBps != nil {
blockIO().ThrottleWriteBpsDevice = writeBpsDevices
}
readIOpsDevices, err := getBlkioThrottleDevices(resources.BlkioDeviceReadIOps)
if err != nil {
return nil, err
}
if resources.BlkioDeviceReadIOps != nil {
blockIO().ThrottleReadIOPSDevice = readIOpsDevices
}
writeIOpsDevices, err := getBlkioThrottleDevices(resources.BlkioDeviceWriteIOps)
if err != nil {
return nil, err
}
if resources.BlkioDeviceWriteIOps != nil {
blockIO().ThrottleWriteIOPSDevice = writeIOpsDevices
}
if resources.BlkioWeight != 0 {
blockIO().Weight = &resources.BlkioWeight
}
cpu := specs.LinuxCPU{
@@ -68,5 +114,5 @@ func toContainerdResources(resources container.Resources) *libcontainerdtypes.Re
}
r.Pids = getPidsLimit(resources)
return &r
return &r, nil
}
+5 -1
View File
@@ -7,5 +7,9 @@ import (
)
func TestToContainerdResources_Defaults(t *testing.T) {
checkResourcesAreUnset(t, toContainerdResources(container.Resources{}))
r, err := toContainerdResources(container.Resources{})
if err != nil {
t.Fatal(err)
}
checkResourcesAreUnset(t, r)
}
+2 -2
View File
@@ -5,7 +5,7 @@ import (
libcontainerdtypes "github.com/moby/moby/v2/daemon/internal/libcontainerd/types"
)
func toContainerdResources(resources container.Resources) *libcontainerdtypes.Resources {
func toContainerdResources(resources container.Resources) (*libcontainerdtypes.Resources, error) {
// We don't support update, so do nothing
return nil
return nil, nil
}
+121
View File
@@ -2,16 +2,20 @@ package container
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/moby/moby/api/types/blkiodev"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/moby/moby/v2/integration/internal/container"
"github.com/moby/moby/v2/internal/testutil"
"github.com/moby/moby/v2/internal/testutil/request"
"golang.org/x/sys/unix"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
@@ -213,3 +217,120 @@ func TestUpdatePidsLimit(t *testing.T) {
})
}
}
// blkioTestDevice finds a usable block device on the host by examining the
// device backing the root filesystem. The returned path (e.g. "/dev/sda")
// and the decimal major:minor string are suitable for docker update flags and
// cgroup file lookups respectively.
func blkioTestDevice(t *testing.T) (devPath, majMin string) {
t.Helper()
var st unix.Stat_t
assert.NilError(t, unix.Stat("/", &st), "stat /")
major := unix.Major(st.Dev)
minor := unix.Minor(st.Dev)
// Resolve the device name via sysfs so we get a real /dev/… path.
uevent, err := os.ReadFile(fmt.Sprintf("/sys/dev/block/%d:%d/uevent", major, minor))
if err != nil {
t.Skipf("cannot resolve block device for / from sysfs: %v", err)
}
var name string
for _, line := range strings.Split(string(uevent), "\n") {
if after, ok := strings.CutPrefix(line, "DEVNAME="); ok {
name = strings.TrimSpace(after)
break
}
}
if name == "" {
t.Skip("DEVNAME not found in sysfs uevent for / block device")
}
return "/dev/" + name, fmt.Sprintf("%d:%d", major, minor)
}
// parseIOMax returns the value of field (rbps/wbps/riops/wiops) for the given
// major:minor device from a cgroup v2 io.max file content.
func parseIOMax(content, majMin, field string) string {
for _, line := range strings.Split(content, "\n") {
if !strings.HasPrefix(line, majMin+" ") {
continue
}
for _, part := range strings.Fields(line[len(majMin)+1:]) {
if k, v, ok := strings.Cut(part, "="); ok && k == field {
return v
}
}
}
return ""
}
// TestUpdateBlkioThrottleDevices verifies that docker update correctly applies
// per-device blkio throttle limits to the container's cgroup.
//
// On cgroup v2 the limits are reflected in /sys/fs/cgroup/io.max inside the
// container. On cgroup v1 only the API-level values are verified (the blkio
// throttle files are not bind-mounted into the container namespace).
func TestUpdateBlkioThrottleDevices(t *testing.T) {
skip.If(t, testEnv.DaemonInfo.OSType == "windows")
skip.If(t, testEnv.DaemonInfo.CgroupDriver == "none")
devPath, majMin := blkioTestDevice(t)
ctx := setupTest(t)
apiClient := testEnv.APIClient()
cID := container.Run(ctx, t, apiClient)
const (
readBps uint64 = 5 * 1024 * 1024 // 5 MiB/s
writeBps uint64 = 2 * 1024 * 1024 // 2 MiB/s
readIops uint64 = 100
writeIops uint64 = 50
)
_, err := apiClient.ContainerUpdate(ctx, cID, client.ContainerUpdateOptions{
Resources: &containertypes.Resources{
BlkioDeviceReadBps: []*blkiodev.ThrottleDevice{{Path: devPath, Rate: readBps}},
BlkioDeviceWriteBps: []*blkiodev.ThrottleDevice{{Path: devPath, Rate: writeBps}},
BlkioDeviceReadIOps: []*blkiodev.ThrottleDevice{{Path: devPath, Rate: readIops}},
BlkioDeviceWriteIOps: []*blkiodev.ThrottleDevice{{Path: devPath, Rate: writeIops}},
},
})
assert.NilError(t, err)
// Verify API-level values are persisted.
inspect, err := apiClient.ContainerInspect(ctx, cID, client.ContainerInspectOptions{})
assert.NilError(t, err)
assert.Assert(t, is.Len(inspect.Container.HostConfig.BlkioDeviceReadBps, 1))
assert.Check(t, is.Equal(inspect.Container.HostConfig.BlkioDeviceReadBps[0].Rate, readBps))
assert.Assert(t, is.Len(inspect.Container.HostConfig.BlkioDeviceWriteBps, 1))
assert.Check(t, is.Equal(inspect.Container.HostConfig.BlkioDeviceWriteBps[0].Rate, writeBps))
assert.Assert(t, is.Len(inspect.Container.HostConfig.BlkioDeviceReadIOps, 1))
assert.Check(t, is.Equal(inspect.Container.HostConfig.BlkioDeviceReadIOps[0].Rate, readIops))
assert.Assert(t, is.Len(inspect.Container.HostConfig.BlkioDeviceWriteIOps, 1))
assert.Check(t, is.Equal(inspect.Container.HostConfig.BlkioDeviceWriteIOps[0].Rate, writeIops))
// On cgroup v2 verify the limits landed in io.max inside the container.
if testEnv.DaemonInfo.CgroupVersion != "2" {
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
res, err := container.Exec(ctx, apiClient, cID, []string{"cat", "/sys/fs/cgroup/io.max"})
assert.NilError(t, err)
assert.Assert(t, is.Len(res.Stderr(), 0))
assert.Equal(t, 0, res.ExitCode)
ioMax := res.Stdout()
assert.Check(t, is.Equal(parseIOMax(ioMax, majMin, "rbps"), strconv.FormatUint(readBps, 10)),
"io.max rbps for %s (io.max content: %q)", majMin, ioMax)
assert.Check(t, is.Equal(parseIOMax(ioMax, majMin, "wbps"), strconv.FormatUint(writeBps, 10)),
"io.max wbps for %s (io.max content: %q)", majMin, ioMax)
assert.Check(t, is.Equal(parseIOMax(ioMax, majMin, "riops"), strconv.FormatUint(readIops, 10)),
"io.max riops for %s (io.max content: %q)", majMin, ioMax)
assert.Check(t, is.Equal(parseIOMax(ioMax, majMin, "wiops"), strconv.FormatUint(writeIops, 10)),
"io.max wiops for %s (io.max content: %q)", majMin, ioMax)
}