From 4c9f13d49bc7a6e0c0798c98484b165930361d86 Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Tue, 26 May 2026 17:38:58 +0530 Subject: [PATCH] container: add --health-cmd-mode for CMD healthcheck form docker run/create always wrapped --health-cmd in CMD-SHELL, which fails on scratch and other shell-less images. Add --health-cmd-mode=exec to produce the exec (CMD) form, matching Dockerfile HEALTHCHECK CMD behavior. The default "shell" preserves existing --health-cmd behavior unchanged. Fixes #3719 Signed-off-by: Lohit Kolluri --- cli/command/container/opts.go | 33 ++++++++- cli/command/container/opts_test.go | 67 +++++++++++++++---- .../reference/commandline/container_create.md | 1 + docs/reference/commandline/container_run.md | 1 + docs/reference/commandline/create.md | 1 + docs/reference/commandline/run.md | 1 + 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/cli/command/container/opts.go b/cli/command/container/opts.go index 1af0b64546a4..04be53914c45 100644 --- a/cli/command/container/opts.go +++ b/cli/command/container/opts.go @@ -22,6 +22,7 @@ import ( "github.com/docker/cli/internal/volumespec" "github.com/docker/cli/opts" "github.com/docker/go-connections/nat" + "github.com/google/shlex" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/network" @@ -131,6 +132,7 @@ type containerOptions struct { shmSize opts.MemBytes noHealthcheck bool healthCmd string + healthCmdMode string healthInterval time.Duration healthTimeout time.Duration healthStartPeriod time.Duration @@ -261,6 +263,7 @@ func addFlags(flags *pflag.FlagSet) *containerOptions { // Health-checking flags.StringVar(&copts.healthCmd, "health-cmd", "", "Command to run to check health") + flags.StringVar(&copts.healthCmdMode, "health-cmd-mode", "shell", `Healthcheck command mode: "shell" runs via CMD-SHELL, "exec" uses exec form (CMD) for shell-less images`) flags.DurationVar(&copts.healthInterval, "health-interval", 0, "Time between running the check (ms|s|m|h) (default 0s)") flags.IntVar(&copts.healthRetries, "health-retries", 0, "Consecutive failures needed to report unhealthy") flags.DurationVar(&copts.healthTimeout, "health-timeout", 0, "Maximum time to allow one check to run (ms|s|m|h) (default 0s)") @@ -559,6 +562,9 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con // Healthcheck var healthConfig *container.HealthConfig + if flags.Changed("health-cmd-mode") && copts.healthCmd == "" { + return nil, errors.New("--health-cmd-mode requires --health-cmd") + } haveHealthSettings := copts.healthCmd != "" || copts.healthInterval != 0 || copts.healthTimeout != 0 || @@ -573,7 +579,11 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con } else if haveHealthSettings { var probe []string if copts.healthCmd != "" { - probe = []string{"CMD-SHELL", copts.healthCmd} + var err error + probe, err = healthcheckProbe(copts.healthCmd, copts.healthCmdMode) + if err != nil { + return nil, err + } } if copts.healthInterval < 0 { return nil, errors.New("--health-interval cannot be negative") @@ -1130,6 +1140,27 @@ func validateLinuxPath(val string, validator func(string) bool) (string, error) return val, nil } +// healthcheckProbe builds the HealthConfig.Test slice for --health-cmd. +// mode must be "shell" (CMD-SHELL, the default) or "exec" (CMD exec form, +// required for images without a shell such as scratch or distroless). +func healthcheckProbe(cmd, mode string) ([]string, error) { + switch mode { + case "", "shell": + return []string{"CMD-SHELL", cmd}, nil + case "exec": + parts, err := shlex.Split(cmd) + if err != nil { + return nil, fmt.Errorf("--health-cmd: %w", err) + } + if len(parts) == 0 { + return nil, errors.New("--health-cmd: command must not be empty") + } + return append([]string{"CMD"}, parts...), nil + default: + return nil, fmt.Errorf("--health-cmd-mode: invalid value %q, must be one of \"shell\" or \"exec\"", mode) + } +} + // validateAttach validates that the specified string is a valid attach option. func validateAttach(val string) (string, error) { s := strings.ToLower(val) diff --git a/cli/command/container/opts_test.go b/cli/command/container/opts_test.go index 0781e99687c0..89866d18ded5 100644 --- a/cli/command/container/opts_test.go +++ b/cli/command/container/opts_test.go @@ -893,15 +893,6 @@ func TestParseHealth(t *testing.T) { } return config.Healthcheck } - checkError := func(expected string, args ...string) { - config, _, _, err := parseRun(args) - if err == nil { - t.Fatalf("Expected error, but got %#v", config) - } - if err.Error() != expected { - t.Fatalf("Expected %#v, got %#v", expected, err) - } - } health := checkOk("--no-healthcheck", "img", "cmd") if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" { t.Fatalf("--no-healthcheck failed: %#v", health) @@ -915,15 +906,67 @@ func TestParseHealth(t *testing.T) { t.Fatalf("--health-cmd: timeout = %s", health.Timeout) } - checkError("--no-healthcheck conflicts with --health-* options", - "--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd") - health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd") if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second { t.Fatalf("--health-*: got %#v", health) } } +func TestParseHealthCmdMode(t *testing.T) { + checkOk := func(args ...string) *container.HealthConfig { + config, _, _, err := parseRun(args) + if err != nil { + t.Fatalf("%#v: %v", args, err) + } + return config.Healthcheck + } + checkError := func(expected string, args ...string) { + config, _, _, err := parseRun(args) + if err == nil { + t.Fatalf("Expected error, but got %#v", config) + } + if err.Error() != expected { + t.Fatalf("Expected %#v, got %#v", expected, err) + } + } + + health := checkOk("--health-cmd=/healthcheck", "--health-cmd-mode=exec", "img", "cmd") + if len(health.Test) != 2 || health.Test[0] != "CMD" || health.Test[1] != "/healthcheck" { + t.Fatalf("--health-cmd-mode=exec single arg: got %#v", health.Test) + } + + health = checkOk("--health-cmd=/usr/bin/wget -q -O /dev/null http://localhost/", "--health-cmd-mode=exec", "img", "cmd") + want := []string{"CMD", "/usr/bin/wget", "-q", "-O", "/dev/null", "http://localhost/"} + if len(health.Test) != len(want) { + t.Fatalf("--health-cmd-mode=exec multi arg: got %#v, want %#v", health.Test, want) + } + for i := range want { + if health.Test[i] != want[i] { + t.Fatalf("--health-cmd-mode=exec multi arg: got %#v, want %#v", health.Test, want) + } + } + + health = checkOk("--health-cmd=/check.sh", "--health-cmd-mode=shell", "img", "cmd") + if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh" { + t.Fatalf("--health-cmd-mode=shell explicit: got %#v", health.Test) + } + + checkError("--health-cmd-mode: invalid value \"bad\", must be one of \"shell\" or \"exec\"", + "--health-cmd=/check.sh", "--health-cmd-mode=bad", "img", "cmd") + + checkError("--health-cmd-mode requires --health-cmd", + "--health-cmd-mode=exec", "img", "cmd") + + checkError("--health-cmd: EOF found when expecting closing quote", + "--health-cmd=unclosed 'quote", "--health-cmd-mode=exec", "img", "cmd") + + checkError("--health-cmd: command must not be empty", + "--health-cmd= ", "--health-cmd-mode=exec", "img", "cmd") + + checkError("--no-healthcheck conflicts with --health-* options", + "--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd") +} + func TestParseLoggingOpts(t *testing.T) { // logging opts ko if _, _, _, err := parseRun([]string{"--log-driver=none", "--log-opt=anything", "img", "cmd"}); err == nil || err.Error() != "invalid logging opts for driver none" { diff --git a/docs/reference/commandline/container_create.md b/docs/reference/commandline/container_create.md index 5bb5e18442db..01b4a9ced791 100644 --- a/docs/reference/commandline/container_create.md +++ b/docs/reference/commandline/container_create.md @@ -48,6 +48,7 @@ Create a new container | `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) | | `--group-add` | `list` | | Add additional groups to join | | `--health-cmd` | `string` | | Command to run to check health | +| `--health-cmd-mode` | `string` | `shell` | Healthcheck command mode: `shell` wraps the command in CMD-SHELL, `exec` uses the exec form (CMD) required for shell-less images | | `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) | | `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy | | `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) | diff --git a/docs/reference/commandline/container_run.md b/docs/reference/commandline/container_run.md index 1dcc0fd5387b..82b34f7401bc 100644 --- a/docs/reference/commandline/container_run.md +++ b/docs/reference/commandline/container_run.md @@ -50,6 +50,7 @@ Create and run a new container from an image | [`--gpus`](#gpus) | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) | | `--group-add` | `list` | | Add additional groups to join | | `--health-cmd` | `string` | | Command to run to check health | +| `--health-cmd-mode` | `string` | `shell` | Healthcheck command mode: `shell` wraps the command in CMD-SHELL, `exec` uses the exec form (CMD) required for shell-less images | | `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) | | `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy | | `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) | diff --git a/docs/reference/commandline/create.md b/docs/reference/commandline/create.md index 5a7390b7f26c..b54df719e951 100644 --- a/docs/reference/commandline/create.md +++ b/docs/reference/commandline/create.md @@ -48,6 +48,7 @@ Create a new container | `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) | | `--group-add` | `list` | | Add additional groups to join | | `--health-cmd` | `string` | | Command to run to check health | +| `--health-cmd-mode` | `string` | `shell` | Healthcheck command mode: `shell` wraps the command in CMD-SHELL, `exec` uses the exec form (CMD) required for shell-less images | | `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) | | `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy | | `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) | diff --git a/docs/reference/commandline/run.md b/docs/reference/commandline/run.md index d544a9cdf5fe..04c9cd18dedc 100644 --- a/docs/reference/commandline/run.md +++ b/docs/reference/commandline/run.md @@ -50,6 +50,7 @@ Create and run a new container from an image | `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) | | `--group-add` | `list` | | Add additional groups to join | | `--health-cmd` | `string` | | Command to run to check health | +| `--health-cmd-mode` | `string` | `shell` | Healthcheck command mode: `shell` wraps the command in CMD-SHELL, `exec` uses the exec form (CMD) required for shell-less images | | `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) | | `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy | | `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) |