diff --git a/cmd/podman/containers/exec.go b/cmd/podman/containers/exec.go index 59ba853e672..a1f01a10d37 100644 --- a/cmd/podman/containers/exec.go +++ b/cmd/podman/containers/exec.go @@ -52,6 +52,13 @@ var ( execDetach bool execCidFile string execNoSession bool + // Resource limit CLI variables + execCPUs string + execMemory string + execCPUShares uint64 + execCPUSetCPUs string + execCPUSetMems string + execMemorySwap string ) func execFlags(cmd *cobra.Command) { @@ -108,6 +115,31 @@ func execFlags(cmd *cobra.Command) { if registry.IsRemote() { _ = flags.MarkHidden("preserve-fds") } + + // Resource limit flags + cpusFlagName := "cpus" + flags.StringVar(&execCPUs, cpusFlagName, "", "Number of CPUs for the exec session") + _ = cmd.RegisterFlagCompletionFunc(cpusFlagName, completion.AutocompleteNone) + + memoryFlagName := "memory" + flags.StringVarP(&execMemory, memoryFlagName, "m", "", "Memory limit for the exec session (format: [], where unit = b, k, m or g)") + _ = cmd.RegisterFlagCompletionFunc(memoryFlagName, completion.AutocompleteNone) + + cpuSharesFlagName := "cpu-shares" + flags.Uint64Var(&execCPUShares, cpuSharesFlagName, 0, "CPU shares (relative weight) for the exec session") + _ = cmd.RegisterFlagCompletionFunc(cpuSharesFlagName, completion.AutocompleteNone) + + cpusetCpusFlagName := "cpuset-cpus" + flags.StringVar(&execCPUSetCPUs, cpusetCpusFlagName, "", "CPUs in which to allow execution (0-3, 0,1)") + _ = cmd.RegisterFlagCompletionFunc(cpusetCpusFlagName, completion.AutocompleteNone) + + cpusetMemsFlagName := "cpuset-mems" + flags.StringVar(&execCPUSetMems, cpusetMemsFlagName, "", "Memory nodes (MEMs) in which to allow execution (0-3, 0,1)") + _ = cmd.RegisterFlagCompletionFunc(cpusetMemsFlagName, completion.AutocompleteNone) + + memorySwapFlagName := "memory-swap" + flags.StringVar(&execMemorySwap, memorySwapFlagName, "", "Swap limit equal to memory plus swap: '-1' to enable unlimited swap") + _ = cmd.RegisterFlagCompletionFunc(memorySwapFlagName, completion.AutocompleteNone) } func init() { @@ -155,6 +187,14 @@ func exec(cmd *cobra.Command, args []string) error { execOpts.Envs = envLib.Join(execOpts.Envs, cliEnv) + // Set resource limits from CLI flags + execOpts.CPUs = execCPUs + execOpts.Memory = execMemory + execOpts.CPUShares = execCPUShares + execOpts.CPUSetCPUs = execCPUSetCPUs + execOpts.CPUSetMems = execCPUSetMems + execOpts.MemorySwap = execMemorySwap + for _, fd := range execOpts.PreserveFD { if !rootless.IsFdInherited(int(fd)) { return fmt.Errorf("file descriptor %d is not available - the preserve-fd option requires that file descriptors must be passed", fd) diff --git a/docs/source/markdown/options/cpu-shares.md b/docs/source/markdown/options/cpu-shares.md index de2e809bd93..2b819bf8bb0 100644 --- a/docs/source/markdown/options/cpu-shares.md +++ b/docs/source/markdown/options/cpu-shares.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container clone, create, farm build, pod clone, pod create, run, update +####> podman build, container clone, create, exec, farm build, pod clone, pod create, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--cpu-shares**, **-c**=*shares* diff --git a/docs/source/markdown/options/cpus.container.md b/docs/source/markdown/options/cpus.container.md index 9cc9a1db32b..20f9e1b30c6 100644 --- a/docs/source/markdown/options/cpus.container.md +++ b/docs/source/markdown/options/cpus.container.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman create, run, update +####> podman create, exec, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--cpus**=*number* diff --git a/docs/source/markdown/options/cpuset-cpus.md b/docs/source/markdown/options/cpuset-cpus.md index 1728a9ab5a5..2a2fb9d3f93 100644 --- a/docs/source/markdown/options/cpuset-cpus.md +++ b/docs/source/markdown/options/cpuset-cpus.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container clone, create, farm build, pod clone, pod create, run, update +####> podman build, container clone, create, exec, farm build, pod clone, pod create, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--cpuset-cpus**=*number* diff --git a/docs/source/markdown/options/cpuset-mems.md b/docs/source/markdown/options/cpuset-mems.md index 57f37f4cafb..599e5ddeaf3 100644 --- a/docs/source/markdown/options/cpuset-mems.md +++ b/docs/source/markdown/options/cpuset-mems.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container clone, create, farm build, pod clone, pod create, run, update +####> podman build, container clone, create, exec, farm build, pod clone, pod create, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--cpuset-mems**=*nodes* diff --git a/docs/source/markdown/options/memory-swap.md b/docs/source/markdown/options/memory-swap.md index 1bf8542fbb1..14358a2da4d 100644 --- a/docs/source/markdown/options/memory-swap.md +++ b/docs/source/markdown/options/memory-swap.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container clone, create, farm build, pod clone, pod create, run, update +####> podman build, container clone, create, exec, farm build, pod clone, pod create, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--memory-swap**=*number[unit]* diff --git a/docs/source/markdown/options/memory.md b/docs/source/markdown/options/memory.md index dd53d8d8e6c..b2ffd871f6d 100644 --- a/docs/source/markdown/options/memory.md +++ b/docs/source/markdown/options/memory.md @@ -1,5 +1,5 @@ ####> This option file is used in: -####> podman build, container clone, create, farm build, pod clone, pod create, run, update +####> podman build, container clone, create, exec, farm build, pod clone, pod create, run, update ####> If file is edited, make sure the changes ####> are applicable to all of those. #### **--memory**, **-m**=*number[unit]* diff --git a/docs/source/markdown/podman-exec.1.md.in b/docs/source/markdown/podman-exec.1.md.in index 2e0215ce118..872199b68bc 100644 --- a/docs/source/markdown/podman-exec.1.md.in +++ b/docs/source/markdown/podman-exec.1.md.in @@ -17,6 +17,14 @@ podman\-exec - Execute a command in a running container Read the ID of the target container from the specified *file*. +@@option cpus.container + +@@option cpu-shares + +@@option cpuset-cpus + +@@option cpuset-mems + #### **--detach**, **-d** Start the exec session, but do not attach to it. The command runs in the background, and the exec session is automatically removed when it completes. The **podman exec** command prints the ID of the exec session and exits immediately after it starts. @@ -31,6 +39,10 @@ Start the exec session, but do not attach to it. The command runs in the backgro @@option latest +@@option memory + +@@option memory-swap + @@option no-session @@option preserve-fd @@ -95,6 +107,18 @@ Execute command but do not attach to the exec session leaving the command runnin $ podman exec -d ctrID find /path/to/search -name yourfile ``` +Execute command with resource limits (requires crun >= 1.9 or runc >= 1.2): +``` +$ podman exec --cpus=0.5 --memory=256m ctrID stress-ng --vm 1 --vm-bytes 200M +``` + +## NOTES + +Resource limit options (--cpus, --memory, --cpu-shares, --cpuset-cpus, +--cpuset-mems, --memory-swap) require OCI runtime support. These options +create a temporary sub-cgroup for the exec session with the specified +resource limits. Supported by crun >= 1.9 and runc >= 1.2. + ## SEE ALSO **[podman(1)](podman.1.md)**, **[podman-run(1)](podman-run.1.md)** diff --git a/libpod/container_exec.go b/libpod/container_exec.go index 409c40cfc78..d4f91133b4e 100644 --- a/libpod/container_exec.go +++ b/libpod/container_exec.go @@ -15,6 +15,7 @@ import ( "github.com/containers/podman/v6/libpod/define" "github.com/containers/podman/v6/libpod/events" "github.com/containers/podman/v6/pkg/pidhandle" + spec "github.com/opencontainers/runtime-spec/specs-go" "github.com/sirupsen/logrus" "go.podman.io/common/pkg/resize" "go.podman.io/common/pkg/util" @@ -80,6 +81,12 @@ type ExecConfig struct { // exiting, and the exit command being executed. If set to 0, there is // no delay. If set, ExitCommand must also be set. ExitCommandDelay uint `json:"exitCommandDelay,omitempty"` + // CgroupPath is a relative path to a sub-cgroup to create for the exec session. + // If empty, no cgroup will be created. The path is relative to the container's cgroup. + CgroupPath string `json:"cgroupPath,omitempty"` + // Resources are the resource limits to apply to the exec session's cgroup. + // Only used if CgroupPath is set. + Resources *spec.LinuxResources `json:"resources,omitempty"` } // ExecSession contains information on a single exec session attached to a given @@ -1188,6 +1195,12 @@ func prepareForExec(c *Container, session *ExecSession) (*ExecOptions, error) { opts.ExitCommandDelay = session.Config.ExitCommandDelay opts.Privileged = session.Config.Privileged + // Set resource limits and cgroup path for exec session + if session.Config.CgroupPath != "" && session.Config.Resources != nil { + opts.CgroupPath = session.Config.CgroupPath + opts.Resources = session.Config.Resources + } + return opts, nil } diff --git a/libpod/oci.go b/libpod/oci.go index 548490be3b4..e72af5306d6 100644 --- a/libpod/oci.go +++ b/libpod/oci.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/containers/podman/v6/libpod/define" - "github.com/opencontainers/runtime-spec/specs-go" + spec "github.com/opencontainers/runtime-spec/specs-go" "go.podman.io/common/pkg/resize" ) @@ -165,7 +165,7 @@ type OCIRuntime interface { //nolint:interfacebloat RuntimeInfo() (*define.ConmonInfo, *define.OCIRuntimeInfo, error) // UpdateContainer updates the given container's cgroup configuration. - UpdateContainer(ctr *Container, res *specs.LinuxResources) error + UpdateContainer(ctr *Container, res *spec.LinuxResources) error } // AttachOptions are options used when attached to a container or an exec @@ -230,6 +230,12 @@ type ExecOptions struct { ExitCommandDelay uint // Privileged indicates the execed process will be launched in Privileged mode Privileged bool + // CgroupPath is the path to a sub-cgroup to create for the exec session. + // If empty, no cgroup will be created. + CgroupPath string + // Resources are the resource limits to apply to the exec session's cgroup. + // Only used if CgroupPath is set. + Resources *spec.LinuxResources } // HTTPAttachStreams informs the HTTPAttach endpoint which of the container's diff --git a/libpod/oci_conmon_exec_common.go b/libpod/oci_conmon_exec_common.go index b562b493e33..31699b650c7 100644 --- a/libpod/oci_conmon_exec_common.go +++ b/libpod/oci_conmon_exec_common.go @@ -427,6 +427,15 @@ func (r *ConmonOCIRuntime) startExec(c *Container, sessionID string, options *Ex args = append(args, "--exec-attach") args = append(args, "--exec-process-spec", processFile.Name()) + // If a cgroup path is specified for resource limits, pass it to the runtime. + // The value of options.CgroupPath is interpreted as relative to the + // container's existing cgroup, not as an absolute cgroup path. The OCI + // runtime is expected to resolve this by joining the provided value with + // the container's cgroup path in the cgroup hierarchy. + if options.CgroupPath != "" && options.Resources != nil { + args = append(args, "--cgroup", options.CgroupPath) + } + if len(options.ExitCommand) > 0 { args = append(args, "--exit-command", options.ExitCommand[0]) for _, arg := range options.ExitCommand[1:] { diff --git a/pkg/domain/entities/containers.go b/pkg/domain/entities/containers.go index b0219ce87b4..1b5b4b91271 100644 --- a/pkg/domain/entities/containers.go +++ b/pkg/domain/entities/containers.go @@ -293,6 +293,13 @@ type ExecOptions struct { Tty bool User string WorkDir string + // Resource limits for the exec session + CPUs string // Number of CPUs (e.g., "0.5", "2") + Memory string // Memory limit (e.g., "256m", "1g") + CPUShares uint64 // CPU shares (relative weight) + CPUSetCPUs string // CPUs in which to allow execution (0-3, 0,1) + CPUSetMems string // Memory nodes (MEMs) in which to allow execution (0-3, 0,1) + MemorySwap string // Total memory (memory + swap) } // ContainerExistsOptions describes the cli values to check if a container exists diff --git a/pkg/domain/infra/abi/containers.go b/pkg/domain/infra/abi/containers.go index d9862472335..38f6bb61465 100644 --- a/pkg/domain/infra/abi/containers.go +++ b/pkg/domain/infra/abi/containers.go @@ -32,15 +32,24 @@ import ( "github.com/containers/podman/v6/pkg/specgen/generate" "github.com/containers/podman/v6/pkg/specgenutil" "github.com/containers/podman/v6/pkg/util" + "github.com/docker/go-units" "github.com/hashicorp/go-multierror" + spec "github.com/opencontainers/runtime-spec/specs-go" "github.com/sirupsen/logrus" "go.podman.io/common/pkg/config" "go.podman.io/image/v5/manifest" "go.podman.io/storage" + "go.podman.io/storage/pkg/stringid" "go.podman.io/storage/pkg/unshare" "go.podman.io/storage/types" ) +const ( + // execCgroupPathFormat is the format string for exec session cgroup paths. + // Format: exec-- + execCgroupPathFormat = "exec-%s-%s" +) + type getContainersOptions struct { all bool isPod bool @@ -872,7 +881,7 @@ func (ic *ContainerEngine) ContainerAttach(ctx context.Context, nameOrID string, return nil } -func makeExecConfig(options entities.ExecOptions, rt *libpod.Runtime, noSession bool) (*libpod.ExecConfig, error) { +func makeExecConfig(options entities.ExecOptions, rt *libpod.Runtime, ctr *libpod.Container, noSession bool) (*libpod.ExecConfig, error) { execConfig := new(libpod.ExecConfig) execConfig.Command = options.Cmd execConfig.Terminal = options.Tty @@ -885,6 +894,23 @@ func makeExecConfig(options entities.ExecOptions, rt *libpod.Runtime, noSession execConfig.PreserveFD = options.PreserveFD execConfig.AttachStdin = options.Interactive + // Parse and set resource limits if any are specified + resources, err := makeExecResources(options) + if err != nil { + return nil, fmt.Errorf("creating resource limits for exec session: %w", err) + } + if resources != nil { + execConfig.Resources = resources + // Generate a unique cgroup path for this exec session + // The cgroup will be created as a sub-cgroup of the container's cgroup + // Include container ID prefix for easier debugging + containerID := ctr.ID() + if len(containerID) > 12 { + containerID = containerID[:12] + } + execConfig.CgroupPath = fmt.Sprintf(execCgroupPathFormat, containerID, stringid.GenerateRandomID()) + } + // Only set up exit command for regular exec sessions, not no-session mode if !noSession { // Make an exit command @@ -904,6 +930,83 @@ func makeExecConfig(options entities.ExecOptions, rt *libpod.Runtime, noSession return execConfig, nil } +// makeExecResources converts entities.ExecOptions resource limit strings +// into an OCI LinuxResources struct for use in exec sessions. +func makeExecResources(options entities.ExecOptions) (*spec.LinuxResources, error) { + hasLimits := false + resources := &spec.LinuxResources{} + + // CPU limits + if options.CPUs != "" { + cpus, err := strconv.ParseFloat(options.CPUs, 64) + if err != nil { + return nil, fmt.Errorf("invalid value for --cpus: %w", err) + } + period, quota := util.CoresToPeriodAndQuota(cpus) + if resources.CPU == nil { + resources.CPU = &spec.LinuxCPU{} + } + resources.CPU.Period = &period + resources.CPU.Quota = "a + hasLimits = true + } + + if options.CPUShares > 0 { + if resources.CPU == nil { + resources.CPU = &spec.LinuxCPU{} + } + resources.CPU.Shares = &options.CPUShares + hasLimits = true + } + + if options.CPUSetCPUs != "" { + if resources.CPU == nil { + resources.CPU = &spec.LinuxCPU{} + } + resources.CPU.Cpus = options.CPUSetCPUs + hasLimits = true + } + + if options.CPUSetMems != "" { + if resources.CPU == nil { + resources.CPU = &spec.LinuxCPU{} + } + resources.CPU.Mems = options.CPUSetMems + hasLimits = true + } + + // Memory limits + if options.Memory != "" { + memLimit, err := units.RAMInBytes(options.Memory) + if err != nil { + return nil, fmt.Errorf("invalid value for --memory: %w", err) + } + if resources.Memory == nil { + resources.Memory = &spec.LinuxMemory{} + } + resources.Memory.Limit = &memLimit + hasLimits = true + } + + if options.MemorySwap != "" { + if resources.Memory == nil { + resources.Memory = &spec.LinuxMemory{} + } + memSwap, err := units.RAMInBytes(options.MemorySwap) + if err != nil { + return nil, fmt.Errorf("invalid value for --memory-swap: %w", err) + } + resources.Memory.Swap = &memSwap + hasLimits = true + } + + if !hasLimits { + return nil, nil + } + + return resources, nil +} + func checkExecPreserveFDs(options entities.ExecOptions) error { if options.PreserveFDs > 0 { entries, err := os.ReadDir(processFileDescriptorsPath) @@ -949,7 +1052,7 @@ func (ic *ContainerEngine) ContainerExec(ctx context.Context, nameOrID string, o util.ExecAddTERM(ctr.Env(), options.Envs) } - execConfig, err := makeExecConfig(options, ic.Libpod, false) + execConfig, err := makeExecConfig(options, ic.Libpod, ctr.Container, false) if err != nil { return ec, err } @@ -978,7 +1081,7 @@ func (ic *ContainerEngine) ContainerExecNoSession(_ context.Context, nameOrID st util.ExecAddTERM(ctr.Env(), options.Envs) } - execConfig, err := makeExecConfig(options, ic.Libpod, true) + execConfig, err := makeExecConfig(options, ic.Libpod, ctr.Container, true) if err != nil { return ec, err } @@ -1003,7 +1106,7 @@ func (ic *ContainerEngine) ContainerExecDetached(_ context.Context, nameOrID str } ctr := containers[0] - execConfig, err := makeExecConfig(options, ic.Libpod, false) + execConfig, err := makeExecConfig(options, ic.Libpod, ctr.Container, false) if err != nil { return "", err } diff --git a/test/e2e/exec_test.go b/test/e2e/exec_test.go index 82f3979d6df..ca6746e4215 100644 --- a/test/e2e/exec_test.go +++ b/test/e2e/exec_test.go @@ -670,4 +670,61 @@ RUN useradd -u 1000 auser`, fedoraMinimal) execSession.WaitWithDefaultTimeout() Expect(execSession).Should(ExitWithError(137, "")) }) + + It("podman exec with CPU limits", func() { + SkipIfRootless("setting CPU limits not fully supported for rootless users") + ctrName := "testCtrCPULimits" + setup := podmanTest.RunTopContainer(ctrName) + setup.WaitWithDefaultTimeout() + Expect(setup).Should(ExitCleanly()) + + // Test with --cpus flag + session := podmanTest.Podman([]string{"exec", "--cpus", "0.5", ctrName, "cat", "/sys/fs/cgroup/cpu.max"}) + session.WaitWithDefaultTimeout() + if session.ExitCode() != 0 { + Skip("Runtime does not support --cpus for exec (requires crun >= 1.9 or runc >= 1.2)") + } + // Verify CPU limit is set (cpu.max format: "$quota $period") + Expect(session.OutputToString()).To(MatchRegexp(`\d+ \d+`), "CPU limit should be set in cgroup") + }) + + It("podman exec with memory limits", func() { + SkipIfRootless("setting memory limits not fully supported for rootless users") + ctrName := "testCtrMemLimits" + setup := podmanTest.RunTopContainer(ctrName) + setup.WaitWithDefaultTimeout() + Expect(setup).Should(ExitCleanly()) + + // Test with --memory flag + session := podmanTest.Podman([]string{"exec", "--memory", "256m", ctrName, "cat", "/sys/fs/cgroup/memory.max"}) + session.WaitWithDefaultTimeout() + if session.ExitCode() != 0 { + Skip("Runtime does not support --memory for exec (requires crun >= 1.9 or runc >= 1.2)") + } + // Verify memory limit is set (should be 256MB = 256 * 1024 * 1024 bytes) + Expect(session.OutputToString()).To(Equal(fmt.Sprint(256*1024*1024)), "Memory limit should be 256m") + }) + + It("podman exec with multiple resource limits", func() { + SkipIfRootless("setting resource limits not fully supported for rootless users") + ctrName := "testCtrMultiLimits" + setup := podmanTest.RunTopContainer(ctrName) + setup.WaitWithDefaultTimeout() + Expect(setup).Should(ExitCleanly()) + + // Test with multiple resource flags + session := podmanTest.Podman([]string{ + "exec", + "--cpus", "0.5", + "--memory", "256m", + "--cpu-shares", "512", + ctrName, + "echo", "Resource limits test", + }) + session.WaitWithDefaultTimeout() + if session.ExitCode() != 0 { + Skip("Runtime does not support resource limits for exec (requires crun >= 1.9 or runc >= 1.2)") + } + Expect(session.OutputToString()).To(Equal("Resource limits test")) + }) }) diff --git a/test/system/075-exec.bats b/test/system/075-exec.bats index e9aaf74eccc..f1dfa7f9ca0 100644 --- a/test/system/075-exec.bats +++ b/test/system/075-exec.bats @@ -312,4 +312,55 @@ load helpers run_podman rm -f -t0 $cid } + +# bats test_tags=ci:parallel +@test "podman exec - resource limits" { + skip_if_rootless_cgroupsv1 "setting resource limits not supported on cgroupv1 for rootless" + + run_podman run -d $IMAGE top + cid="$output" + + # Test CPU limits (requires crun >= 1.9 or runc >= 1.2) + run_podman 0+125 exec --cpus=0.5 $cid echo "CPU limit test" + if [[ $status -eq 125 ]]; then + skip "Runtime does not support --cpus for exec" + fi + assert "$output" = "CPU limit test" "exec with --cpus should work" + + # Verify CPU limits are actually applied + run_podman exec --cpus=0.5 $cid cat /sys/fs/cgroup/cpu.max + # cpu.max format is "$quota $period" (e.g., "50000 100000" for 0.5 CPUs) + assert "$output" =~ "[0-9]+ [0-9]+" "CPU limit should be set in cgroup" + # Verify the actual values (0.5 CPUs should be 50000 quota / 100000 period) + if [[ "$output" =~ ^([0-9]+)\ ([0-9]+)$ ]]; then + quota="${BASH_REMATCH[1]}" + period="${BASH_REMATCH[2]}" + # Calculate CPUs: quota / period (should be approximately 0.5) + cpu_value=$(awk "BEGIN {print $quota / $period}") + assert "$cpu_value" =~ "0\.5" "CPU limit should be 0.5 CPUs" + fi + + # Test memory limits + run_podman 0+125 exec --memory=256m $cid echo "Memory limit test" + if [[ $status -eq 125 ]]; then + skip "Runtime does not support --memory for exec" + fi + assert "$output" = "Memory limit test" "exec with --memory should work" + + # Test CPU shares + run_podman 0+125 exec --cpu-shares=512 $cid echo "CPU shares test" + if [[ $status -eq 125 ]]; then + skip "Runtime does not support --cpu-shares for exec" + fi + assert "$output" = "CPU shares test" "exec with --cpu-shares should work" + + # Test multiple resource limits together + run_podman 0+125 exec --cpus=0.5 --memory=256m --cpu-shares=512 $cid echo "Combined limits test" + if [[ $status -eq 125 ]]; then + skip "Runtime does not support resource limits for exec" + fi + assert "$output" = "Combined limits test" "exec with multiple resource limits should work" + + run_podman rm -f -t0 $cid +} # vim: filetype=sh