Skip to content

Commit 4c9f13d

Browse files
committed
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 <lohitkolluri@gmail.com>
1 parent 9f16882 commit 4c9f13d

6 files changed

Lines changed: 91 additions & 13 deletions

File tree

cli/command/container/opts.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/docker/cli/internal/volumespec"
2323
"github.com/docker/cli/opts"
2424
"github.com/docker/go-connections/nat"
25+
"github.com/google/shlex"
2526
"github.com/moby/moby/api/types/container"
2627
"github.com/moby/moby/api/types/mount"
2728
"github.com/moby/moby/api/types/network"
@@ -131,6 +132,7 @@ type containerOptions struct {
131132
shmSize opts.MemBytes
132133
noHealthcheck bool
133134
healthCmd string
135+
healthCmdMode string
134136
healthInterval time.Duration
135137
healthTimeout time.Duration
136138
healthStartPeriod time.Duration
@@ -261,6 +263,7 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
261263

262264
// Health-checking
263265
flags.StringVar(&copts.healthCmd, "health-cmd", "", "Command to run to check health")
266+
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`)
264267
flags.DurationVar(&copts.healthInterval, "health-interval", 0, "Time between running the check (ms|s|m|h) (default 0s)")
265268
flags.IntVar(&copts.healthRetries, "health-retries", 0, "Consecutive failures needed to report unhealthy")
266269
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
559562

560563
// Healthcheck
561564
var healthConfig *container.HealthConfig
565+
if flags.Changed("health-cmd-mode") && copts.healthCmd == "" {
566+
return nil, errors.New("--health-cmd-mode requires --health-cmd")
567+
}
562568
haveHealthSettings := copts.healthCmd != "" ||
563569
copts.healthInterval != 0 ||
564570
copts.healthTimeout != 0 ||
@@ -573,7 +579,11 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
573579
} else if haveHealthSettings {
574580
var probe []string
575581
if copts.healthCmd != "" {
576-
probe = []string{"CMD-SHELL", copts.healthCmd}
582+
var err error
583+
probe, err = healthcheckProbe(copts.healthCmd, copts.healthCmdMode)
584+
if err != nil {
585+
return nil, err
586+
}
577587
}
578588
if copts.healthInterval < 0 {
579589
return nil, errors.New("--health-interval cannot be negative")
@@ -1130,6 +1140,27 @@ func validateLinuxPath(val string, validator func(string) bool) (string, error)
11301140
return val, nil
11311141
}
11321142

1143+
// healthcheckProbe builds the HealthConfig.Test slice for --health-cmd.
1144+
// mode must be "shell" (CMD-SHELL, the default) or "exec" (CMD exec form,
1145+
// required for images without a shell such as scratch or distroless).
1146+
func healthcheckProbe(cmd, mode string) ([]string, error) {
1147+
switch mode {
1148+
case "", "shell":
1149+
return []string{"CMD-SHELL", cmd}, nil
1150+
case "exec":
1151+
parts, err := shlex.Split(cmd)
1152+
if err != nil {
1153+
return nil, fmt.Errorf("--health-cmd: %w", err)
1154+
}
1155+
if len(parts) == 0 {
1156+
return nil, errors.New("--health-cmd: command must not be empty")
1157+
}
1158+
return append([]string{"CMD"}, parts...), nil
1159+
default:
1160+
return nil, fmt.Errorf("--health-cmd-mode: invalid value %q, must be one of \"shell\" or \"exec\"", mode)
1161+
}
1162+
}
1163+
11331164
// validateAttach validates that the specified string is a valid attach option.
11341165
func validateAttach(val string) (string, error) {
11351166
s := strings.ToLower(val)

cli/command/container/opts_test.go

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -893,15 +893,6 @@ func TestParseHealth(t *testing.T) {
893893
}
894894
return config.Healthcheck
895895
}
896-
checkError := func(expected string, args ...string) {
897-
config, _, _, err := parseRun(args)
898-
if err == nil {
899-
t.Fatalf("Expected error, but got %#v", config)
900-
}
901-
if err.Error() != expected {
902-
t.Fatalf("Expected %#v, got %#v", expected, err)
903-
}
904-
}
905896
health := checkOk("--no-healthcheck", "img", "cmd")
906897
if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" {
907898
t.Fatalf("--no-healthcheck failed: %#v", health)
@@ -915,15 +906,67 @@ func TestParseHealth(t *testing.T) {
915906
t.Fatalf("--health-cmd: timeout = %s", health.Timeout)
916907
}
917908

918-
checkError("--no-healthcheck conflicts with --health-* options",
919-
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
920-
921909
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd")
922910
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second {
923911
t.Fatalf("--health-*: got %#v", health)
924912
}
925913
}
926914

915+
func TestParseHealthCmdMode(t *testing.T) {
916+
checkOk := func(args ...string) *container.HealthConfig {
917+
config, _, _, err := parseRun(args)
918+
if err != nil {
919+
t.Fatalf("%#v: %v", args, err)
920+
}
921+
return config.Healthcheck
922+
}
923+
checkError := func(expected string, args ...string) {
924+
config, _, _, err := parseRun(args)
925+
if err == nil {
926+
t.Fatalf("Expected error, but got %#v", config)
927+
}
928+
if err.Error() != expected {
929+
t.Fatalf("Expected %#v, got %#v", expected, err)
930+
}
931+
}
932+
933+
health := checkOk("--health-cmd=/healthcheck", "--health-cmd-mode=exec", "img", "cmd")
934+
if len(health.Test) != 2 || health.Test[0] != "CMD" || health.Test[1] != "/healthcheck" {
935+
t.Fatalf("--health-cmd-mode=exec single arg: got %#v", health.Test)
936+
}
937+
938+
health = checkOk("--health-cmd=/usr/bin/wget -q -O /dev/null http://localhost/", "--health-cmd-mode=exec", "img", "cmd")
939+
want := []string{"CMD", "/usr/bin/wget", "-q", "-O", "/dev/null", "http://localhost/"}
940+
if len(health.Test) != len(want) {
941+
t.Fatalf("--health-cmd-mode=exec multi arg: got %#v, want %#v", health.Test, want)
942+
}
943+
for i := range want {
944+
if health.Test[i] != want[i] {
945+
t.Fatalf("--health-cmd-mode=exec multi arg: got %#v, want %#v", health.Test, want)
946+
}
947+
}
948+
949+
health = checkOk("--health-cmd=/check.sh", "--health-cmd-mode=shell", "img", "cmd")
950+
if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh" {
951+
t.Fatalf("--health-cmd-mode=shell explicit: got %#v", health.Test)
952+
}
953+
954+
checkError("--health-cmd-mode: invalid value \"bad\", must be one of \"shell\" or \"exec\"",
955+
"--health-cmd=/check.sh", "--health-cmd-mode=bad", "img", "cmd")
956+
957+
checkError("--health-cmd-mode requires --health-cmd",
958+
"--health-cmd-mode=exec", "img", "cmd")
959+
960+
checkError("--health-cmd: EOF found when expecting closing quote",
961+
"--health-cmd=unclosed 'quote", "--health-cmd-mode=exec", "img", "cmd")
962+
963+
checkError("--health-cmd: command must not be empty",
964+
"--health-cmd= ", "--health-cmd-mode=exec", "img", "cmd")
965+
966+
checkError("--no-healthcheck conflicts with --health-* options",
967+
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
968+
}
969+
927970
func TestParseLoggingOpts(t *testing.T) {
928971
// logging opts ko
929972
if _, _, _, err := parseRun([]string{"--log-driver=none", "--log-opt=anything", "img", "cmd"}); err == nil || err.Error() != "invalid logging opts for driver none" {

docs/reference/commandline/container_create.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Create a new container
4848
| `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) |
4949
| `--group-add` | `list` | | Add additional groups to join |
5050
| `--health-cmd` | `string` | | Command to run to check health |
51+
| `--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 |
5152
| `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) |
5253
| `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy |
5354
| `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) |

docs/reference/commandline/container_run.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Create and run a new container from an image
5050
| [`--gpus`](#gpus) | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) |
5151
| `--group-add` | `list` | | Add additional groups to join |
5252
| `--health-cmd` | `string` | | Command to run to check health |
53+
| `--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 |
5354
| `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) |
5455
| `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy |
5556
| `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) |

docs/reference/commandline/create.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Create a new container
4848
| `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) |
4949
| `--group-add` | `list` | | Add additional groups to join |
5050
| `--health-cmd` | `string` | | Command to run to check health |
51+
| `--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 |
5152
| `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) |
5253
| `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy |
5354
| `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) |

docs/reference/commandline/run.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Create and run a new container from an image
5050
| `--gpus` | `gpu-request` | | GPU devices to add to the container ('all' to pass all GPUs) |
5151
| `--group-add` | `list` | | Add additional groups to join |
5252
| `--health-cmd` | `string` | | Command to run to check health |
53+
| `--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 |
5354
| `--health-interval` | `duration` | `0s` | Time between running the check (ms\|s\|m\|h) (default 0s) |
5455
| `--health-retries` | `int` | `0` | Consecutive failures needed to report unhealthy |
5556
| `--health-start-interval` | `duration` | `0s` | Time between running the check during the start period (ms\|s\|m\|h) (default 0s) |

0 commit comments

Comments
 (0)