diff --git a/bake/bake.go b/bake/bake.go index a319c59ee62e..02517be665df 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -606,6 +606,13 @@ func (c Config) newOverrides(v []string) (map[string]map[string]Override, error) override.Append = appendTo override.ArrValue = append(override.ArrValue, parts[1]) } + case "resources": + if len(keys) != 3 { + return nil, errors.Errorf("invalid key %s, resources requires name", parts[0]) + } + if len(parts) == 2 { + override.Value = parts[1] + } case "args": if len(keys) != 3 { return nil, errors.Errorf("invalid key %s, args requires name", parts[0]) @@ -745,32 +752,33 @@ type Target struct { // Inherits is the only field that cannot be overridden with --set Inherits []string `json:"inherits,omitempty" hcl:"inherits,optional" cty:"inherits"` - Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"` - Attest buildflags.Attests `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"` - Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"` - Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"` - Dockerfile *string `json:"dockerfile,omitempty" hcl:"dockerfile,optional" cty:"dockerfile"` - DockerfileInline *string `json:"dockerfile-inline,omitempty" hcl:"dockerfile-inline,optional" cty:"dockerfile-inline"` - Args map[string]*string `json:"args,omitempty" hcl:"args,optional" cty:"args"` - Labels map[string]*string `json:"labels,omitempty" hcl:"labels,optional" cty:"labels"` - Tags []string `json:"tags,omitempty" hcl:"tags,optional" cty:"tags"` - CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"` - CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"` - Target *string `json:"target,omitempty" hcl:"target,optional" cty:"target"` - Secrets buildflags.Secrets `json:"secret,omitempty" hcl:"secret,optional" cty:"secret"` - SSH buildflags.SSHKeys `json:"ssh,omitempty" hcl:"ssh,optional" cty:"ssh"` - Platforms []string `json:"platforms,omitempty" hcl:"platforms,optional" cty:"platforms"` - Outputs buildflags.Exports `json:"output,omitempty" hcl:"output,optional" cty:"output"` - Pull *bool `json:"pull,omitempty" hcl:"pull,optional" cty:"pull"` - NoCache *bool `json:"no-cache,omitempty" hcl:"no-cache,optional" cty:"no-cache"` - NetworkMode *string `json:"network,omitempty" hcl:"network,optional" cty:"network"` - NoCacheFilter []string `json:"no-cache-filter,omitempty" hcl:"no-cache-filter,optional" cty:"no-cache-filter"` - ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional" cty:"shm-size"` - Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"` - Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"` - Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"` - ExtraHosts map[string]*string `json:"extra-hosts,omitempty" hcl:"extra-hosts,optional" cty:"extra-hosts"` - Policy buildflags.PolicyConfigs `json:"policy,omitempty" hcl:"policy,optional" cty:"policy"` + Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"` + Attest buildflags.Attests `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"` + Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"` + Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"` + Dockerfile *string `json:"dockerfile,omitempty" hcl:"dockerfile,optional" cty:"dockerfile"` + DockerfileInline *string `json:"dockerfile-inline,omitempty" hcl:"dockerfile-inline,optional" cty:"dockerfile-inline"` + Args map[string]*string `json:"args,omitempty" hcl:"args,optional" cty:"args"` + Labels map[string]*string `json:"labels,omitempty" hcl:"labels,optional" cty:"labels"` + Tags []string `json:"tags,omitempty" hcl:"tags,optional" cty:"tags"` + CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"` + CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"` + Target *string `json:"target,omitempty" hcl:"target,optional" cty:"target"` + Secrets buildflags.Secrets `json:"secret,omitempty" hcl:"secret,optional" cty:"secret"` + SSH buildflags.SSHKeys `json:"ssh,omitempty" hcl:"ssh,optional" cty:"ssh"` + Platforms []string `json:"platforms,omitempty" hcl:"platforms,optional" cty:"platforms"` + Outputs buildflags.Exports `json:"output,omitempty" hcl:"output,optional" cty:"output"` + Pull *bool `json:"pull,omitempty" hcl:"pull,optional" cty:"pull"` + NoCache *bool `json:"no-cache,omitempty" hcl:"no-cache,optional" cty:"no-cache"` + NetworkMode *string `json:"network,omitempty" hcl:"network,optional" cty:"network"` + NoCacheFilter []string `json:"no-cache-filter,omitempty" hcl:"no-cache-filter,optional" cty:"no-cache-filter"` + ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional" cty:"shm-size"` + Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"` + Resources *buildflags.ResourcesConfig `json:"resources,omitempty" hcl:"resources,optional" cty:"resources"` + Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"` + Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"` + ExtraHosts map[string]*string `json:"extra-hosts,omitempty" hcl:"extra-hosts,optional" cty:"extra-hosts"` + Policy buildflags.PolicyConfigs `json:"policy,omitempty" hcl:"policy,optional" cty:"policy"` // IMPORTANT: if you add more fields here, do not forget to update newOverrides/AddOverrides and docs/bake-reference.md. // linked is a private field to mark a target used as a linked one @@ -944,6 +952,9 @@ func (t *Target) Merge(t2 *Target) { if t2.Ulimits != nil { // merge t.Ulimits = append(t.Ulimits, t2.Ulimits...) } + if t2.Resources != nil { // merge + t.Resources = t.Resources.Merge(t2.Resources) + } if t2.Description != "" { t.Description = t2.Description } @@ -1138,6 +1149,16 @@ func (t *Target) AddOverrides(overrides map[string]Override, ent *EntitlementCon } else { t.Ulimits = o.ArrValue } + case "resources": + if len(keys) != 2 { + return errors.Errorf("invalid format for resources, expecting resources.=") + } + if t.Resources == nil { + t.Resources = &buildflags.ResourcesConfig{} + } + if err := t.Resources.SetField(keys[1], value); err != nil { + return err + } case "network": t.NetworkMode = &value case "pull": @@ -1612,6 +1633,12 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { } bo.Ulimits = ulimits + resourceLimits, err := build.ParseResourceLimits(t.Resources.ToEntries()) + if err != nil { + return nil, err + } + bo.ResourceLimits = resourceLimits + bo.Allow = append(bo.Allow, t.Entitlements...) return bo, nil diff --git a/bake/bake_test.go b/bake/bake_test.go index 466c411d8a04..de3fb9636e06 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -27,6 +27,15 @@ target "webDEP" { no-cache = true shm-size = "128m" ulimits = ["nofile=1024:1024"] + resources = { + memory = "2g" + memory-swap = "4g" + cpu-shares = 1024 + cpu-period = 100000 + cpu-quota = 50000 + cpuset-cpus = "0-3" + cpuset-mems = "0,1" + } extra-hosts = { my_hostname = "8.8.8.8" } @@ -67,6 +76,14 @@ target "webapp" { require.Equal(t, true, *m["webapp"].NoCache) require.Equal(t, "128m", *m["webapp"].ShmSize) require.Equal(t, []string{"nofile=1024:1024"}, m["webapp"].Ulimits) + require.NotNil(t, m["webapp"].Resources) + require.Equal(t, ptrstr("2g"), m["webapp"].Resources.Memory) + require.Equal(t, ptrstr("4g"), m["webapp"].Resources.MemorySwap) + require.Equal(t, int64(1024), *m["webapp"].Resources.CPUShares) + require.Equal(t, int64(100000), *m["webapp"].Resources.CPUPeriod) + require.Equal(t, int64(50000), *m["webapp"].Resources.CPUQuota) + require.Equal(t, ptrstr("0-3"), m["webapp"].Resources.CPUSetCPUs) + require.Equal(t, ptrstr("0,1"), m["webapp"].Resources.CPUSetMems) require.Equal(t, map[string]*string{"my_hostname": ptrstr("8.8.8.8")}, m["webapp"].ExtraHosts) require.Nil(t, m["webapp"].Pull) @@ -226,6 +243,21 @@ target "webapp" { require.Equal(t, "256m", *m["webapp"].ShmSize) }) + t.Run("ResourceLimitsOverride", func(t *testing.T) { + m, _, err := ReadTargets(ctx, []File{fp}, []string{"webapp"}, []string{ + "webapp.resources.memory=512m", + "webapp.resources.cpu-quota=25000", + }, nil, nil, &EntitlementConf{}) + require.NoError(t, err) + require.Equal(t, ptrstr("512m"), m["webapp"].Resources.Memory) + require.Equal(t, int64(25000), *m["webapp"].Resources.CPUQuota) + }) + + t.Run("ResourceLimitsInvalidOverride", func(t *testing.T) { + _, _, err := ReadTargets(ctx, []File{fp}, []string{"webapp"}, []string{"webapp.resources.cpu-quota=notanumber"}, nil, nil, &EntitlementConf{}) + require.Error(t, err) + }) + t.Run("PullOverride", func(t *testing.T) { t.Parallel() m, g, err := ReadTargets(ctx, []File{fp}, []string{"webapp"}, []string{"webapp.pull=false"}, nil, nil, &EntitlementConf{}) @@ -1834,6 +1866,100 @@ func TestExtraHostsDeterministicOrder(t *testing.T) { } } +func TestResourceLimitsToBuildOpt(t *testing.T) { + newInt64 := func(v int64) *int64 { return &v } + bo, err := toBuildOpt(&Target{ + DockerfileInline: ptrstr("FROM scratch"), + Resources: &buildflags.ResourcesConfig{ + Memory: ptrstr("2g"), + MemorySwap: ptrstr("4g"), + CPUShares: newInt64(1024), + CPUPeriod: newInt64(100000), + CPUQuota: newInt64(50000), + CPUSetCPUs: ptrstr("0-3"), + CPUSetMems: ptrstr("0,1"), + }, + }, &Input{}) + require.NoError(t, err) + require.Equal(t, int64(2*1024*1024*1024), bo.ResourceLimits.Memory.Value()) + require.Equal(t, int64(4*1024*1024*1024), bo.ResourceLimits.MemorySwap.Value()) + require.Equal(t, int64(1024), bo.ResourceLimits.CPUShares) + require.Equal(t, int64(100000), bo.ResourceLimits.CPUPeriod) + require.Equal(t, int64(50000), bo.ResourceLimits.CPUQuota) + require.Equal(t, "0-3", bo.ResourceLimits.CPUSetCPUs) + require.Equal(t, "0,1", bo.ResourceLimits.CPUSetMems) +} + +func TestResourceLimitsUnlimitedSwap(t *testing.T) { + bo, err := toBuildOpt(&Target{ + DockerfileInline: ptrstr("FROM scratch"), + Resources: &buildflags.ResourcesConfig{MemorySwap: ptrstr("-1")}, + }, &Input{}) + require.NoError(t, err) + require.Equal(t, int64(-1), bo.ResourceLimits.MemorySwap.Value()) +} + +func TestResourceLimitsInvalid(t *testing.T) { + _, err := toBuildOpt(&Target{ + DockerfileInline: ptrstr("FROM scratch"), + Resources: &buildflags.ResourcesConfig{Memory: ptrstr("notabyte")}, + }, &Input{}) + require.Error(t, err) +} + +func TestResourcesObject(t *testing.T) { + ctx := context.TODO() + + cpuShares, cpuQuota := int64(1024), int64(50000) + want := &buildflags.ResourcesConfig{ + Memory: ptrstr("2g"), + MemorySwap: ptrstr("4g"), + CPUShares: &cpuShares, + CPUQuota: &cpuQuota, + CPUSetCPUs: ptrstr("0-3"), + } + + data := `target "app" { + resources = { + memory = "2g" + memory-swap = "4g" + cpu-shares = 1024 + cpu-quota = 50000 + cpuset-cpus = "0-3" + } + }` + + m, _, err := ReadTargets(ctx, []File{{Name: "docker-bake.hcl", Data: []byte(data)}}, []string{"app"}, nil, nil, nil, &EntitlementConf{}) + require.NoError(t, err) + require.Equal(t, want, m["app"].Resources) +} + +// TestResourcesInheritIsolation ensures merging resources from a shared base +// target doesn't leak a child's override into the base or a sibling. +func TestResourcesInheritIsolation(t *testing.T) { + ctx := context.TODO() + + data := `target "base" { + resources = { + memory = "1g" + } + } + target "child1" { + inherits = ["base"] + resources = { + memory = "2g" + } + } + target "child2" { + inherits = ["base"] + }` + + m, _, err := ReadTargets(ctx, []File{{Name: "docker-bake.hcl", Data: []byte(data)}}, []string{"child1", "child2"}, nil, nil, nil, &EntitlementConf{}) + require.NoError(t, err) + require.Equal(t, ptrstr("2g"), m["child1"].Resources.Memory) + require.Equal(t, ptrstr("1g"), m["child2"].Resources.Memory) +} + func TestAnnotations(t *testing.T) { fp := File{ Name: "docker-bake.hcl", diff --git a/build/build.go b/build/build.go index 51b035f6dcc2..4297fc2ce727 100644 --- a/build/build.go +++ b/build/build.go @@ -87,6 +87,7 @@ type Options struct { Tags []string Target string Ulimits *opts.UlimitOpt + ResourceLimits ResourceLimits Session []session.Attachable Linked bool // Linked marks this target as exclusively linked (not requested by the user). @@ -98,6 +99,18 @@ type Options struct { Policy []buildflags.PolicyConfig } +// ResourceLimits holds the cgroup resource constraints applied to individual +// build steps (RUN instructions). They don't affect the build cache key. +type ResourceLimits struct { + Memory opts.MemBytes + MemorySwap opts.MemSwapBytes + CPUShares int64 + CPUPeriod int64 + CPUQuota int64 + CPUSetCPUs string + CPUSetMems string +} + type CallFunc struct { Name string Format string diff --git a/build/opt.go b/build/opt.go index e4604502629e..8f0410aee112 100644 --- a/build/opt.go +++ b/build/opt.go @@ -610,6 +610,9 @@ func toSolveOpt(ctx context.Context, np *noderesolver.ResolvedNode, multiDriver so.FrontendAttrs["ulimit"] = ulimits } + // setup per-step resource limits + addResourceLimits(opt.ResourceLimits, so.FrontendAttrs) + // mark call request as internal if opt.CallFunc != nil { so.Internal = true diff --git a/build/utils.go b/build/utils.go index 2c9acaf53ddd..2cef546ca0b9 100644 --- a/build/utils.go +++ b/build/utils.go @@ -99,6 +99,97 @@ func toBuildkitUlimits(inp *opts.UlimitOpt) (string, error) { return strings.Join(ulimits, ","), nil } +// User-facing resource keys accepted in `--resource key=value` entries, mirroring docker run flag names. +const ( + resourceKeyMemory = "memory" + resourceKeyMemorySwap = "memory-swap" + resourceKeyCPUShares = "cpu-shares" + resourceKeyCPUPeriod = "cpu-period" + resourceKeyCPUQuota = "cpu-quota" + resourceKeyCPUSetCPUs = "cpuset-cpus" + resourceKeyCPUSetMems = "cpuset-mems" +) + +// Frontend attribute keys, must match those parsed by BuildKit's dockerui frontend. +const ( + attrMemory = "memory" + attrMemorySwap = "memswap" + attrCPUShares = "cpushares" + attrCPUPeriod = "cpuperiod" + attrCPUQuota = "cpuquota" + attrCPUSetCPUs = "cpusetcpus" + attrCPUSetMems = "cpusetmems" +) + +// ParseResourceLimits parses `key=value` entries from the `--resource` flag into ResourceLimits. +func ParseResourceLimits(entries []string) (ResourceLimits, error) { + var rl ResourceLimits + for _, entry := range entries { + k, v, ok := strings.Cut(entry, "=") + if !ok { + return rl, errors.Errorf("invalid resource %q, expected key=value", entry) + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + switch k { + case resourceKeyMemory: + if err := rl.Memory.Set(v); err != nil { + return rl, errors.Wrapf(err, "invalid value %q for resource %s", v, k) + } + case resourceKeyMemorySwap: + if err := rl.MemorySwap.Set(v); err != nil { + return rl, errors.Wrapf(err, "invalid value %q for resource %s", v, k) + } + case resourceKeyCPUShares, resourceKeyCPUPeriod, resourceKeyCPUQuota: + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return rl, errors.Wrapf(err, "invalid value %q for resource %s", v, k) + } + switch k { + case resourceKeyCPUShares: + rl.CPUShares = n + case resourceKeyCPUPeriod: + rl.CPUPeriod = n + case resourceKeyCPUQuota: + rl.CPUQuota = n + } + case resourceKeyCPUSetCPUs: + rl.CPUSetCPUs = v + case resourceKeyCPUSetMems: + rl.CPUSetMems = v + default: + return rl, errors.Errorf("unknown resource %q", k) + } + } + return rl, nil +} + +// addResourceLimits sets the frontend attributes for the resource limits. +// Only non-zero values are sent, so builds against a daemon without the feature keep working. +func addResourceLimits(rl ResourceLimits, attrs map[string]string) { + if v := rl.Memory.Value(); v > 0 { + attrs[attrMemory] = strconv.FormatInt(v, 10) + } + if v := rl.MemorySwap.Value(); v != 0 { + attrs[attrMemorySwap] = strconv.FormatInt(v, 10) + } + if rl.CPUShares > 0 { + attrs[attrCPUShares] = strconv.FormatInt(rl.CPUShares, 10) + } + if rl.CPUPeriod > 0 { + attrs[attrCPUPeriod] = strconv.FormatInt(rl.CPUPeriod, 10) + } + if rl.CPUQuota > 0 { + attrs[attrCPUQuota] = strconv.FormatInt(rl.CPUQuota, 10) + } + if rl.CPUSetCPUs != "" { + attrs[attrCPUSetCPUs] = rl.CPUSetCPUs + } + if rl.CPUSetMems != "" { + attrs[attrCPUSetMems] = rl.CPUSetMems + } +} + func notSupported(f driver.Feature, d *driver.DriverHandle, docs string) error { return errors.Errorf(`%s is not supported for the %s driver. Switch to a different driver, or turn on the containerd image store, and try again. diff --git a/build/utils_test.go b/build/utils_test.go index 3983af966881..d4a49ec47bbb 100644 --- a/build/utils_test.go +++ b/build/utils_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + dockeropts "github.com/docker/cli/opts" "github.com/stretchr/testify/require" ) @@ -150,3 +151,109 @@ func TestToBuildkitExtraHosts(t *testing.T) { }) } } + +func TestAddResourceLimits(t *testing.T) { + mustMemSwap := func(v string) dockeropts.MemSwapBytes { + var m dockeropts.MemSwapBytes + require.NoError(t, m.Set(v)) + return m + } + + tests := []struct { + name string + limits ResourceLimits + expected map[string]string + }{ + { + name: "empty", + limits: ResourceLimits{}, + expected: map[string]string{}, + }, + { + name: "all", + limits: ResourceLimits{ + Memory: dockeropts.MemBytes(2 * 1024 * 1024 * 1024), + MemorySwap: mustMemSwap("4g"), + CPUShares: 1024, + CPUPeriod: 100000, + CPUQuota: 50000, + CPUSetCPUs: "0-3", + CPUSetMems: "0,1", + }, + expected: map[string]string{ + "memory": "2147483648", + "memswap": "4294967296", + "cpushares": "1024", + "cpuperiod": "100000", + "cpuquota": "50000", + "cpusetcpus": "0-3", + "cpusetmems": "0,1", + }, + }, + { + name: "unlimited swap", + limits: ResourceLimits{MemorySwap: mustMemSwap("-1")}, + expected: map[string]string{ + "memswap": "-1", + }, + }, + { + name: "partial", + limits: ResourceLimits{Memory: dockeropts.MemBytes(512 * 1024 * 1024)}, + expected: map[string]string{ + "memory": "536870912", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + attrs := map[string]string{} + addResourceLimits(tc.limits, attrs) + require.Equal(t, tc.expected, attrs) + }) + } +} + +func TestParseResourceLimits(t *testing.T) { + t.Run("all", func(t *testing.T) { + rl, err := ParseResourceLimits([]string{ + "memory=2g", + "memory-swap=4g", + "cpu-shares=1024", + "cpu-period=100000", + "cpu-quota=50000", + "cpuset-cpus=0-3", + "cpuset-mems=0,1", + }) + require.NoError(t, err) + require.Equal(t, int64(2*1024*1024*1024), rl.Memory.Value()) + require.Equal(t, int64(4*1024*1024*1024), rl.MemorySwap.Value()) + require.Equal(t, int64(1024), rl.CPUShares) + require.Equal(t, int64(100000), rl.CPUPeriod) + require.Equal(t, int64(50000), rl.CPUQuota) + require.Equal(t, "0-3", rl.CPUSetCPUs) + require.Equal(t, "0,1", rl.CPUSetMems) + }) + + t.Run("unlimited swap", func(t *testing.T) { + rl, err := ParseResourceLimits([]string{"memory-swap=-1"}) + require.NoError(t, err) + require.Equal(t, int64(-1), rl.MemorySwap.Value()) + }) + + t.Run("missing value", func(t *testing.T) { + _, err := ParseResourceLimits([]string{"memory"}) + require.Error(t, err) + }) + + t.Run("unknown key", func(t *testing.T) { + _, err := ParseResourceLimits([]string{"bogus=1"}) + require.Error(t, err) + }) + + t.Run("invalid int", func(t *testing.T) { + _, err := ParseResourceLimits([]string{"cpu-shares=notanumber"}) + require.Error(t, err) + }) +} diff --git a/commands/build.go b/commands/build.go index 455d8143e1e8..b9a3750a36a7 100644 --- a/commands/build.go +++ b/commands/build.go @@ -89,6 +89,11 @@ type buildOptions struct { target string ulimits *dockeropts.UlimitOpt + resources []string + // legacyResources is kept separate from resources because the pflag + // StringArray for --resource resets its backing slice on first use. + legacyResources []string + attests []string sbom string provenance string @@ -133,6 +138,7 @@ func (o *buildOptions) toOptions() (*BuildOptions, error) { Tags: o.tags, Target: o.target, Ulimits: o.ulimits, + Resources: append(o.legacyResources, o.resources...), Builder: o.builder, NoCache: o.noCache, Pull: o.pull, @@ -234,6 +240,20 @@ func (o *buildOptions) toDisplayMode() (progressui.DisplayMode, error) { return progress, nil } +// legacyResourceValue is a pflag.Value that appends a "=value" entry to +// the --resource list, wiring the hidden legacy flags into the same code path. +type legacyResourceValue struct { + key string + resources *[]string +} + +func (v *legacyResourceValue) String() string { return "" } +func (v *legacyResourceValue) Type() string { return "string" } +func (v *legacyResourceValue) Set(s string) error { + *v.resources = append(*v.resources, v.key+"="+s) + return nil +} + const ( commandNameAttribute = attribute.Key("command.name") commandOptionsHash = attribute.Key("command.options.hash") @@ -583,6 +603,8 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOpt options.ulimits = dockeropts.NewUlimitOpt(nil) flags.Var(options.ulimits, "ulimit", "Ulimit options") + flags.StringArrayVar(&options.resources, "resource", []string{}, `Resource limits for build containers (format: "memory=2g", "cpu-quota=50000")`) + flags.StringArrayVar(&options.attests, "attest", []string{}, `Attestation parameters (format: "type=sbom,generator=image")`) flags.StringVar(&options.sbom, "sbom", "", `Shorthand for "--attest=type=sbom"`) flags.StringVar(&options.provenance, "provenance", "", `Shorthand for "--attest=type=provenance"`) @@ -595,12 +617,33 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOpt var ignore string var ignoreSlice []string var ignoreBool bool - var ignoreInt int64 flags.StringVar(&options.callFunc, "print", "", "Print result of information request (e.g., outline, targets)") cobrautil.MarkFlagsExperimental(flags, "print") flags.MarkHidden("print") + // Legacy per-resource flags, hidden and superseded by --resource. + flags.VarP(&legacyResourceValue{key: "memory", resources: &options.legacyResources}, "memory", "m", "Memory limit") + flags.MarkHidden("memory") + + flags.VarP(&legacyResourceValue{key: "memory-swap", resources: &options.legacyResources}, "memory-swap", "", `Swap limit equal to memory plus swap: "-1" to enable unlimited swap`) + flags.MarkHidden("memory-swap") + + flags.VarP(&legacyResourceValue{key: "cpu-shares", resources: &options.legacyResources}, "cpu-shares", "c", "CPU shares (relative weight)") + flags.MarkHidden("cpu-shares") + + flags.VarP(&legacyResourceValue{key: "cpu-period", resources: &options.legacyResources}, "cpu-period", "", "Limit the CPU CFS (Completely Fair Scheduler) period") + flags.MarkHidden("cpu-period") + + flags.VarP(&legacyResourceValue{key: "cpu-quota", resources: &options.legacyResources}, "cpu-quota", "", "Limit the CPU CFS (Completely Fair Scheduler) quota") + flags.MarkHidden("cpu-quota") + + flags.VarP(&legacyResourceValue{key: "cpuset-cpus", resources: &options.legacyResources}, "cpuset-cpus", "", `CPUs in which to allow execution ("0-3", "0,1")`) + flags.MarkHidden("cpuset-cpus") + + flags.VarP(&legacyResourceValue{key: "cpuset-mems", resources: &options.legacyResources}, "cpuset-mems", "", `MEMs in which to allow execution ("0-3", "0,1")`) + flags.MarkHidden("cpuset-mems") + flags.BoolVar(&ignoreBool, "compress", false, "Compress the build context using gzip") flags.MarkHidden("compress") @@ -617,27 +660,6 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOpt flags.SetAnnotation("squash", "flag-warn", []string{"experimental flag squash is removed with BuildKit. You should squash inside build using a multi-stage Dockerfile for efficiency."}) cobrautil.MarkFlagsExperimental(flags, "squash") - flags.StringVarP(&ignore, "memory", "m", "", "Memory limit") - flags.MarkHidden("memory") - - flags.StringVar(&ignore, "memory-swap", "", `Swap limit equal to memory plus swap: "-1" to enable unlimited swap`) - flags.MarkHidden("memory-swap") - - flags.Int64VarP(&ignoreInt, "cpu-shares", "c", 0, "CPU shares (relative weight)") - flags.MarkHidden("cpu-shares") - - flags.Int64Var(&ignoreInt, "cpu-period", 0, "Limit the CPU CFS (Completely Fair Scheduler) period") - flags.MarkHidden("cpu-period") - - flags.Int64Var(&ignoreInt, "cpu-quota", 0, "Limit the CPU CFS (Completely Fair Scheduler) quota") - flags.MarkHidden("cpu-quota") - - flags.StringVar(&ignore, "cpuset-cpus", "", `CPUs in which to allow execution ("0-3", "0,1")`) - flags.MarkHidden("cpuset-cpus") - - flags.StringVar(&ignore, "cpuset-mems", "", `MEMs in which to allow execution ("0-3", "0,1")`) - flags.MarkHidden("cpuset-mems") - flags.BoolVar(&ignoreBool, "rm", true, "Remove intermediate containers after a successful build") flags.MarkHidden("rm") @@ -990,6 +1012,7 @@ type BuildOptions struct { Tags []string Target string Ulimits *dockeropts.UlimitOpt + Resources []string Builder string NoCache bool Pull bool @@ -1014,6 +1037,11 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt contexts[name] = build.NamedContext{Path: path} } + resourceLimits, err := build.ParseResourceLimits(in.Resources) + if err != nil { + return nil, nil, err + } + opts := build.Options{ Inputs: build.Inputs{ ContextPath: in.ContextPath, @@ -1034,6 +1062,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt Tags: in.Tags, Target: in.Target, Ulimits: in.Ulimits, + ResourceLimits: resourceLimits, GroupRef: in.GroupRef, ProvenanceResponseMode: confutil.ParseMetadataProvenance(in.ProvenanceResponseMode), } diff --git a/commands/history/inspect.go b/commands/history/inspect.go index 6930a25da4de..d2fd1e6d5700 100644 --- a/commands/history/inspect.go +++ b/commands/history/inspect.go @@ -110,6 +110,13 @@ type configOutput struct { ShmSize string `json:",omitempty"` Ulimit string `json:",omitempty"` + Memory string `json:",omitempty"` + MemorySwap string `json:",omitempty"` + CPUShares string `json:",omitempty"` + CPUPeriod string `json:",omitempty"` + CPUQuota string `json:",omitempty"` + CPUSetCPUs string `json:",omitempty"` + CPUSetMems string `json:",omitempty"` CacheMountNS string `json:",omitempty"` DockerfileCheckConfig string `json:",omitempty"` SourceDateEpoch string `json:",omitempty"` @@ -391,6 +398,13 @@ workers0: readAttr(attrs, "shm-size", &out.Config.ShmSize, nil) readAttr(attrs, "ulimit", &out.Config.Ulimit, nil) + readAttr(attrs, "memory", &out.Config.Memory, nil) + readAttr(attrs, "memswap", &out.Config.MemorySwap, nil) + readAttr(attrs, "cpushares", &out.Config.CPUShares, nil) + readAttr(attrs, "cpuperiod", &out.Config.CPUPeriod, nil) + readAttr(attrs, "cpuquota", &out.Config.CPUQuota, nil) + readAttr(attrs, "cpusetcpus", &out.Config.CPUSetCPUs, nil) + readAttr(attrs, "cpusetmems", &out.Config.CPUSetMems, nil) readAttr(attrs, "build-arg:BUILDKIT_CACHE_MOUNT_NS", &out.Config.CacheMountNS, nil) readAttr(attrs, "build-arg:BUILDKIT_DOCKERFILE_CHECK", &out.Config.DockerfileCheckConfig, nil) readAttr(attrs, "build-arg:"+epoch.SourceDateEpochEnv, &out.Config.SourceDateEpoch, nil) @@ -570,6 +584,27 @@ workers0: if out.Config.Ulimit != "" { fmt.Fprintf(tw, "Resource Limits:\t%s\n", out.Config.Ulimit) } + if out.Config.Memory != "" { + fmt.Fprintf(tw, "Memory:\t%s\n", out.Config.Memory) + } + if out.Config.MemorySwap != "" { + fmt.Fprintf(tw, "Memory Swap:\t%s\n", out.Config.MemorySwap) + } + if out.Config.CPUShares != "" { + fmt.Fprintf(tw, "CPU Shares:\t%s\n", out.Config.CPUShares) + } + if out.Config.CPUPeriod != "" { + fmt.Fprintf(tw, "CPU Period:\t%s\n", out.Config.CPUPeriod) + } + if out.Config.CPUQuota != "" { + fmt.Fprintf(tw, "CPU Quota:\t%s\n", out.Config.CPUQuota) + } + if out.Config.CPUSetCPUs != "" { + fmt.Fprintf(tw, "CPUSet CPUs:\t%s\n", out.Config.CPUSetCPUs) + } + if out.Config.CPUSetMems != "" { + fmt.Fprintf(tw, "CPUSet Mems:\t%s\n", out.Config.CPUSetMems) + } if out.Config.CacheMountNS != "" { fmt.Fprintf(tw, "Cache Mount Namespace:\t%s\n", out.Config.CacheMountNS) } diff --git a/docs/bake-reference.md b/docs/bake-reference.md index a4f236fa5299..704c8d065114 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -239,6 +239,7 @@ The following table shows the complete list of attributes that you can assign to | [`policy`](#targetpolicy) | List | Policies to validate build sources and metadata | | [`platforms`](#targetplatforms) | List | Target platforms | | [`pull`](#targetpull) | Boolean | Always pull images | +| [`resources`](#targetresources) | Map | Resource limits for build containers | | [`secret`](#targetsecret) | List | Secrets to expose to the build | | [`shm-size`](#targetshm-size) | List | Size of `/dev/shm` | | [`ssh`](#targetssh) | List | SSH agent sockets or keys to expose to the build | @@ -939,6 +940,29 @@ target "default" { } ``` +### `target.resources` + +Sets cgroup resource limits for the containers that run `RUN` instructions +during the build. The supported keys are `memory`, `memory-swap`, `cpu-shares`, +`cpu-period`, `cpu-quota`, `cpuset-cpus`, and `cpuset-mems`. These map to the +equivalent `docker build` flags and to the +[`--resource`](https://docs.docker.com/reference/cli/docker/buildx/build/#resource) +flag for `docker buildx build`. + +```hcl +target "default" { + resources = { + memory = "2g" + memory-swap = "4g" + cpu-quota = 50000 + } +} +``` + +> [!NOTE] +> These limits require a BuildKit daemon that supports per-step resource limits +> and only take effect on Linux. They don't affect the build cache key. + ### `target.secret` Defines secrets to expose to the build target. diff --git a/docs/reference/buildx_build.md b/docs/reference/buildx_build.md index d7443e3abea1..41216ecc7401 100644 --- a/docs/reference/buildx_build.md +++ b/docs/reference/buildx_build.md @@ -44,6 +44,7 @@ Start a build | `--pull` | `bool` | | Always attempt to pull all referenced images | | [`--push`](#push) | `bool` | | Shorthand for `--output=type=registry,unpack=false` | | `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| [`--resource`](#resource) | `stringArray` | | Resource limits for build containers (format: `memory=2g`, `cpu-quota=50000`) | | [`--sbom`](#sbom) | `string` | | Shorthand for `--attest=type=sbom` | | [`--secret`](#secret) | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | | [`--shm-size`](#shm-size) | `bytes` | `0` | Shared memory size for build containers | @@ -1158,3 +1159,40 @@ $ docker buildx build --ulimit nofile=1024:1024 . > In most cases, it is recommended to let the builder automatically determine > the appropriate configurations. Manual adjustments should only be considered > when specific performance tuning is required for complex build scenarios. + +### Set CPU and memory limits for build containers (--resource) + +The `--resource` flag constrains the resources available to the containers that +run your `RUN` instructions during the build. It's repeatable and takes +`key=value` pairs, where `key` is one of: + +| Key | Description | +|:--------------|:-----------------------------------------------------------------------------| +| `memory` | Memory limit (format: ``, e.g. `512m`, `2g`). | +| `memory-swap` | Total memory plus swap limit. Set to `-1` to allow unlimited swap. | +| `cpu-shares` | CPU shares (relative weight). | +| `cpu-period` | Length of a CPU CFS (Completely Fair Scheduler) period, in microseconds. | +| `cpu-quota` | CPU CFS quota, in microseconds, within each `cpu-period`. | +| `cpuset-cpus` | CPUs in which to allow execution (`0-3`, `0,1`). | +| `cpuset-mems` | Memory nodes (MEMs) in which to allow execution (`0-3`, `0,1`). | + +```console +$ docker buildx build --resource memory=2g --resource cpu-quota=50000 --resource cpu-period=100000 . +``` + +These map to the cgroup resource limits of the legacy `docker build` API and +only apply to individual build steps. They don't affect the build cache key. + +> [!NOTE] +> These limits require a BuildKit daemon that supports per-step resource limits +> (the `exec.meta.linux.resources` capability) and only take effect on Linux. + +> [!NOTE] +> Because BuildKit can run build steps in parallel, these limits apply to each +> step in isolation rather than to the build as a whole. When the same step is +> requested with different limits, the most relaxed limits are used. + +> [!NOTE] +> In most cases, it is recommended to let the builder automatically determine +> the appropriate configurations. Manual adjustments should only be considered +> when specific performance tuning is required for complex build scenarios. diff --git a/docs/reference/buildx_dap_build.md b/docs/reference/buildx_dap_build.md index 60da585f86e6..b5d0307198bf 100644 --- a/docs/reference/buildx_dap_build.md +++ b/docs/reference/buildx_dap_build.md @@ -36,6 +36,7 @@ Start a build | `--pull` | `bool` | | Always attempt to pull all referenced images | | `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | | `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| `--resource` | `stringArray` | | Resource limits for build containers (format: `memory=2g`, `cpu-quota=50000`) | | `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | | `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | | `--shm-size` | `bytes` | `0` | Shared memory size for build containers | diff --git a/docs/reference/buildx_debug_build.md b/docs/reference/buildx_debug_build.md index 6b04c7c0cfee..4c64ccd67c85 100644 --- a/docs/reference/buildx_debug_build.md +++ b/docs/reference/buildx_debug_build.md @@ -40,6 +40,7 @@ Start a build | `--pull` | `bool` | | Always attempt to pull all referenced images | | `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | | `-q`, `--quiet` | `bool` | | Suppress the build output and print image ID on success | +| `--resource` | `stringArray` | | Resource limits for build containers (format: `memory=2g`, `cpu-quota=50000`) | | `--sbom` | `string` | | Shorthand for `--attest=type=sbom` | | `--secret` | `stringArray` | | Secret to expose to the build (format: `id=mysecret[,src=/local/secret]`) | | `--shm-size` | `bytes` | `0` | Shared memory size for build containers | diff --git a/util/buildflags/resources.go b/util/buildflags/resources.go new file mode 100644 index 000000000000..a700fb868bc0 --- /dev/null +++ b/util/buildflags/resources.go @@ -0,0 +1,110 @@ +package buildflags + +import ( + "strconv" + + "github.com/pkg/errors" +) + +// ResourcesConfig holds the cgroup resource limits applied to individual build +// steps (RUN instructions). It maps to the `--resource` build flags. +type ResourcesConfig struct { + Memory *string `json:"memory,omitempty"` + MemorySwap *string `json:"memory-swap,omitempty"` + CPUShares *int64 `json:"cpu-shares,omitempty"` + CPUPeriod *int64 `json:"cpu-period,omitempty"` + CPUQuota *int64 `json:"cpu-quota,omitempty"` + CPUSetCPUs *string `json:"cpuset-cpus,omitempty"` + CPUSetMems *string `json:"cpuset-mems,omitempty"` +} + +func (r *ResourcesConfig) Merge(other *ResourcesConfig) *ResourcesConfig { + if r == nil { + r = &ResourcesConfig{} + } + merged := *r + if other != nil { + if other.Memory != nil { + merged.Memory = other.Memory + } + if other.MemorySwap != nil { + merged.MemorySwap = other.MemorySwap + } + if other.CPUShares != nil { + merged.CPUShares = other.CPUShares + } + if other.CPUPeriod != nil { + merged.CPUPeriod = other.CPUPeriod + } + if other.CPUQuota != nil { + merged.CPUQuota = other.CPUQuota + } + if other.CPUSetCPUs != nil { + merged.CPUSetCPUs = other.CPUSetCPUs + } + if other.CPUSetMems != nil { + merged.CPUSetMems = other.CPUSetMems + } + } + return &merged +} + +// ToEntries renders the config as `key=value` entries for build.ParseResourceLimits. +func (r *ResourcesConfig) ToEntries() []string { + if r == nil { + return nil + } + var entries []string + if r.Memory != nil { + entries = append(entries, "memory="+*r.Memory) + } + if r.MemorySwap != nil { + entries = append(entries, "memory-swap="+*r.MemorySwap) + } + if r.CPUShares != nil { + entries = append(entries, "cpu-shares="+strconv.FormatInt(*r.CPUShares, 10)) + } + if r.CPUPeriod != nil { + entries = append(entries, "cpu-period="+strconv.FormatInt(*r.CPUPeriod, 10)) + } + if r.CPUQuota != nil { + entries = append(entries, "cpu-quota="+strconv.FormatInt(*r.CPUQuota, 10)) + } + if r.CPUSetCPUs != nil { + entries = append(entries, "cpuset-cpus="+*r.CPUSetCPUs) + } + if r.CPUSetMems != nil { + entries = append(entries, "cpuset-mems="+*r.CPUSetMems) + } + return entries +} + +// SetField sets a single resource field by name, used by bake `--set` overrides. +func (r *ResourcesConfig) SetField(name, value string) error { + switch name { + case "memory": + r.Memory = &value + case "memory-swap": + r.MemorySwap = &value + case "cpu-shares", "cpu-period", "cpu-quota": + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return errors.Errorf("invalid value %s for int64 key resources.%s", value, name) + } + switch name { + case "cpu-shares": + r.CPUShares = &n + case "cpu-period": + r.CPUPeriod = &n + case "cpu-quota": + r.CPUQuota = &n + } + case "cpuset-cpus": + r.CPUSetCPUs = &value + case "cpuset-mems": + r.CPUSetMems = &value + default: + return errors.Errorf("unknown resources key %s", name) + } + return nil +} diff --git a/util/buildflags/resources_cty.go b/util/buildflags/resources_cty.go new file mode 100644 index 000000000000..8449b179f7da --- /dev/null +++ b/util/buildflags/resources_cty.go @@ -0,0 +1,108 @@ +package buildflags + +import ( + "sync" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/gocty" +) + +var resourcesType = sync.OnceValue(func() cty.Type { + return cty.ObjectWithOptionalAttrs( + map[string]cty.Type{ + "memory": cty.String, + "memory-swap": cty.String, + "cpu-shares": cty.Number, + "cpu-period": cty.Number, + "cpu-quota": cty.Number, + "cpuset-cpus": cty.String, + "cpuset-mems": cty.String, + }, + []string{"memory", "memory-swap", "cpu-shares", "cpu-period", "cpu-quota", "cpuset-cpus", "cpuset-mems"}, + ) +}) + +func (r *ResourcesConfig) FromCtyValue(in cty.Value, p cty.Path) error { + conv, err := convert.Convert(in, resourcesType()) + if err != nil { + return p.NewError(err) + } + + if v := conv.GetAttr("memory"); !v.IsNull() && v.IsKnown() { + s := v.AsString() + r.Memory = &s + } + if v := conv.GetAttr("memory-swap"); !v.IsNull() && v.IsKnown() { + s := v.AsString() + r.MemorySwap = &s + } + if v := conv.GetAttr("cpu-shares"); !v.IsNull() && v.IsKnown() { + var n int64 + if err := gocty.FromCtyValue(v, &n); err != nil { + return p.NewError(err) + } + r.CPUShares = &n + } + if v := conv.GetAttr("cpu-period"); !v.IsNull() && v.IsKnown() { + var n int64 + if err := gocty.FromCtyValue(v, &n); err != nil { + return p.NewError(err) + } + r.CPUPeriod = &n + } + if v := conv.GetAttr("cpu-quota"); !v.IsNull() && v.IsKnown() { + var n int64 + if err := gocty.FromCtyValue(v, &n); err != nil { + return p.NewError(err) + } + r.CPUQuota = &n + } + if v := conv.GetAttr("cpuset-cpus"); !v.IsNull() && v.IsKnown() { + s := v.AsString() + r.CPUSetCPUs = &s + } + if v := conv.GetAttr("cpuset-mems"); !v.IsNull() && v.IsKnown() { + s := v.AsString() + r.CPUSetMems = &s + } + return nil +} + +func (r *ResourcesConfig) ToCtyValue() cty.Value { + if r == nil { + return cty.NullVal(resourcesType()) + } + + vals := map[string]cty.Value{ + "memory": cty.NullVal(cty.String), + "memory-swap": cty.NullVal(cty.String), + "cpu-shares": cty.NullVal(cty.Number), + "cpu-period": cty.NullVal(cty.Number), + "cpu-quota": cty.NullVal(cty.Number), + "cpuset-cpus": cty.NullVal(cty.String), + "cpuset-mems": cty.NullVal(cty.String), + } + if r.Memory != nil { + vals["memory"] = cty.StringVal(*r.Memory) + } + if r.MemorySwap != nil { + vals["memory-swap"] = cty.StringVal(*r.MemorySwap) + } + if r.CPUShares != nil { + vals["cpu-shares"] = cty.NumberIntVal(*r.CPUShares) + } + if r.CPUPeriod != nil { + vals["cpu-period"] = cty.NumberIntVal(*r.CPUPeriod) + } + if r.CPUQuota != nil { + vals["cpu-quota"] = cty.NumberIntVal(*r.CPUQuota) + } + if r.CPUSetCPUs != nil { + vals["cpuset-cpus"] = cty.StringVal(*r.CPUSetCPUs) + } + if r.CPUSetMems != nil { + vals["cpuset-mems"] = cty.StringVal(*r.CPUSetMems) + } + return cty.ObjectVal(vals) +}