From 520a3df953323f2cc948cca2ffc641fcf180c7f0 Mon Sep 17 00:00:00 2001 From: Park jungtae Date: Mon, 27 Apr 2026 20:46:49 +0900 Subject: [PATCH 1/2] feat(compose): support healthcheck field in service config Signed-off-by: Park jungtae --- docs/compose.md | 2 +- pkg/composer/serviceparser/serviceparser.go | 57 ++++++++++++++++ .../serviceparser/serviceparser_test.go | 66 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/compose.md b/docs/compose.md index a07c91a5207..02e59be61e9 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -28,7 +28,7 @@ which was derived from [Docker Compose file version 3 specification](https://doc - `services..deploy.resources.reservations` - `services..deploy.placement` - `services..deploy.endpoint_mode` -- `services..healthcheck` +- `services..healthcheck.start_interval` - `services..stop_grace_period` - `services..stop_signal` - `configs..external` diff --git a/pkg/composer/serviceparser/serviceparser.go b/pkg/composer/serviceparser/serviceparser.go index afd665fca6f..ad5c32c3e3b 100644 --- a/pkg/composer/serviceparser/serviceparser.go +++ b/pkg/composer/serviceparser/serviceparser.go @@ -32,6 +32,7 @@ import ( "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/healthcheck" "github.com/containerd/nerdctl/v2/pkg/identifiers" "github.com/containerd/nerdctl/v2/pkg/reflectutil" ) @@ -78,6 +79,7 @@ func warnUnknownFields(svc types.ServiceConfig) { "Extends", // handled by the loader "Extensions", "ExtraHosts", + "HealthCheck", "Hostname", "Image", "Init", @@ -121,6 +123,21 @@ func warnUnknownFields(svc types.ServiceConfig) { } } + if svc.HealthCheck != nil { + if unknown := reflectutil.UnknownNonEmptyFields(svc.HealthCheck, + "Test", + "Timeout", + "Interval", + "Retries", + "StartPeriod", + "Disable", + "Extensions", + // TODO: add support 'StartInterval' + ); len(unknown) > 0 { + log.L.Warnf("Ignoring: service %s: healthcheck: %+v", svc.Name, unknown) + } + } + for depName, dep := range svc.DependsOn { if unknown := reflectutil.UnknownNonEmptyFields(&dep, "Condition", @@ -747,6 +764,46 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e c.RunArgs = append(c.RunArgs, "-w="+svc.WorkingDir) } + if svc.HealthCheck != nil { + hc := svc.HealthCheck + disabled := hc.Disable + + if !disabled && len(hc.Test) > 0 { + switch hc.Test[0] { + case healthcheck.CmdNone: + disabled = true + case healthcheck.CmdShell: + if len(hc.Test) >= 2 { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-cmd=%s", hc.Test[1])) + } + case healthcheck.Cmd: + // CMD exec form is converted to CMD-SHELL because --health-cmd always stores + // the command as CMD-SHELL (see pkg/cmd/container/create.go: withHealthcheck). + // This means the command will be executed via /bin/sh -c instead of exec directly. + if len(hc.Test) >= 2 { + log.L.Warnf("service %s: healthcheck: CMD exec form is not supported, converting to CMD-SHELL", svc.Name) + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-cmd=%s", strings.Join(hc.Test[1:], " "))) + } + } + } + if disabled { + c.RunArgs = append(c.RunArgs, "--no-healthcheck") + } else { + if hc.Interval != nil { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-interval=%s", time.Duration(*hc.Interval).String())) + } + if hc.Timeout != nil { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-timeout=%s", time.Duration(*hc.Timeout).String())) + } + if hc.Retries != nil { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-retries=%d", *hc.Retries)) + } + if hc.StartPeriod != nil { + c.RunArgs = append(c.RunArgs, fmt.Sprintf("--health-start-period=%s", time.Duration(*hc.StartPeriod).String())) + } + } + } + c.RunArgs = append(c.RunArgs, parsed.Image) // NOT svc.Image c.RunArgs = append(c.RunArgs, svc.Command...) return &c, nil diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 856d732cbf4..6754546d7b1 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -20,7 +20,9 @@ import ( "fmt" "path/filepath" "runtime" + "slices" "strconv" + "strings" "testing" "github.com/compose-spec/compose-go/v2/types" @@ -623,3 +625,67 @@ services: c = getContainersFromService("unless_stopped")[0] assert.Assert(t, in(c.RunArgs, "--restart=unless-stopped")) } + +func TestParseHealthCheck(t *testing.T) { + t.Parallel() + const dockerComposeYAML = ` +services: + cmd_shell: + image: alpine:3.14 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + cmd_exec: + image: alpine:3.14 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost"] + interval: 1m + disabled_flag: + image: alpine:3.14 + healthcheck: + disable: true + test: ["CMD", "curl", "-f", "http://localhost"] + disabled_none: + image: alpine:3.14 + healthcheck: + test: ["NONE"] +` + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) + assert.NilError(t, err) + + getContainersFromService := func(svcName string) []Container { + svcConfig, err := project.GetService(svcName) + assert.NilError(t, err) + svc, err := Parse(project, svcConfig) + assert.NilError(t, err) + return svc.Containers + } + + var c Container + + c = getContainersFromService("cmd_shell")[0] + assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost || exit 1")) + assert.Assert(t, in(c.RunArgs, "--health-interval=30s")) + assert.Assert(t, in(c.RunArgs, "--health-timeout=10s")) + assert.Assert(t, in(c.RunArgs, "--health-retries=3")) + assert.Assert(t, in(c.RunArgs, "--health-start-period=5s")) + + c = getContainersFromService("cmd_exec")[0] + assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost")) + assert.Assert(t, in(c.RunArgs, "--health-interval=1m0s")) + + c = getContainersFromService("disabled_flag")[0] + assert.Assert(t, in(c.RunArgs, "--no-healthcheck")) + assert.Assert(t, !slices.ContainsFunc(c.RunArgs, func(s string) bool { + return strings.HasPrefix(s, "--health-cmd=") + })) + + c = getContainersFromService("disabled_none")[0] + assert.Assert(t, in(c.RunArgs, "--no-healthcheck")) +} From e51bc852ec0f1c6a463f6369cc6c7c1034345b98 Mon Sep 17 00:00:00 2001 From: Park jungtae Date: Mon, 11 May 2026 09:08:12 +0900 Subject: [PATCH 2/2] test(compose): add test for healthcheck config Signed-off-by: Park jungtae --- cmd/nerdctl/compose/compose_up_linux_test.go | 109 ++++++++++++++++++ .../serviceparser/serviceparser_test.go | 42 +++---- 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/cmd/nerdctl/compose/compose_up_linux_test.go b/cmd/nerdctl/compose/compose_up_linux_test.go index 6e0476b859c..97f06b37fe0 100644 --- a/cmd/nerdctl/compose/compose_up_linux_test.go +++ b/cmd/nerdctl/compose/compose_up_linux_test.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/docker/go-connections/nat" "gotest.tools/v3/assert" @@ -1314,3 +1315,111 @@ services: testCase.Run(t) } + +func TestComposeUpHealthcheck(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` +services: + web: + image: %s + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 2s +`, testutil.NginxAlpineImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + projectName := filepath.Base(filepath.Dir(composePath)) + containerName := serviceparser.DefaultContainerName(projectName, "web", "1") + + data.Labels().Set("composePath", composePath) + data.Labels().Set("containerName", containerName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, containerName) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "inspect", data.Labels().Get("containerName")) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) { + assert.Equal(t, 1, len(dc), "unexpected number of containers") + hc := dc[0].Config.Healthcheck + assert.Assert(t, hc != nil, "healthcheck config should not be nil") + assert.Assert(t, len(hc.Test) >= 2, "healthcheck test should have at least 2 elements") + assert.Equal(t, "CMD-SHELL", hc.Test[0]) + assert.Equal(t, "curl -f http://localhost", hc.Test[1]) + assert.Equal(t, 10*time.Second, hc.Interval) + assert.Equal(t, 5*time.Second, hc.Timeout) + assert.Equal(t, 3, hc.Retries) + assert.Equal(t, 2*time.Second, hc.StartPeriod) + }), + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composePath") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composePath"), "down", "-v") + } + } + + testCase.Run(t) +} + +func TestComposeUpHealthcheckDisabled(t *testing.T) { + testCase := nerdtest.Setup() + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` +services: + web: + image: %s + command: sleep infinity + healthcheck: + disable: true +`, testutil.CommonImage) + + composePath := data.Temp().Save(composeYAML, "compose.yaml") + projectName := filepath.Base(filepath.Dir(composePath)) + containerName := serviceparser.DefaultContainerName(projectName, "web", "1") + + data.Labels().Set("composePath", composePath) + data.Labels().Set("containerName", containerName) + + helpers.Ensure("compose", "-f", composePath, "up", "-d") + nerdtest.EnsureContainerStarted(helpers, containerName) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("container", "inspect", data.Labels().Get("containerName")) + } + + testCase.Expected = func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.JSON([]dockercompat.Container{}, func(dc []dockercompat.Container, t tig.T) { + assert.Equal(t, 1, len(dc), "unexpected number of containers") + hc := dc[0].Config.Healthcheck + assert.Assert(t, hc != nil, "healthcheck config should not be nil") + assert.Assert(t, len(hc.Test) >= 1, "healthcheck test should have at least 1 element") + assert.Equal(t, "NONE", hc.Test[0]) + }), + } + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + if data.Labels().Get("composePath") != "" { + helpers.Anyhow("compose", "-f", data.Labels().Get("composePath"), "down", "-v") + } + } + + testCase.Run(t) +} diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 6754546d7b1..8b567512097 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -33,6 +33,15 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil" ) +func getContainersFromService(t *testing.T, project *types.Project, svcName string) []Container { + t.Helper() + svcConfig, err := project.GetService(svcName) + assert.NilError(t, err) + svc, err := Parse(project, svcConfig) + assert.NilError(t, err) + return svc.Containers +} + func TestServicePortConfigToFlagP(t *testing.T) { t.Parallel() type testCase struct { @@ -603,26 +612,17 @@ services: project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) - getContainersFromService := func(svcName string) []Container { - svcConfig, err := project.GetService(svcName) - assert.NilError(t, err) - svc, err := Parse(project, svcConfig) - assert.NilError(t, err) - - return svc.Containers - } - var c Container - c = getContainersFromService("onfailure_no_count")[0] + c = getContainersFromService(t, project, "onfailure_no_count")[0] assert.Assert(t, in(c.RunArgs, "--restart=on-failure")) - c = getContainersFromService("onfailure_with_count")[0] + c = getContainersFromService(t, project, "onfailure_with_count")[0] assert.Assert(t, in(c.RunArgs, "--restart=on-failure:10")) - c = getContainersFromService("onfailure_ignore")[0] + c = getContainersFromService(t, project, "onfailure_ignore")[0] assert.Assert(t, !in(c.RunArgs, "--restart=on-failure:3.14")) - c = getContainersFromService("unless_stopped")[0] + c = getContainersFromService(t, project, "unless_stopped")[0] assert.Assert(t, in(c.RunArgs, "--restart=unless-stopped")) } @@ -659,33 +659,25 @@ services: project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) assert.NilError(t, err) - getContainersFromService := func(svcName string) []Container { - svcConfig, err := project.GetService(svcName) - assert.NilError(t, err) - svc, err := Parse(project, svcConfig) - assert.NilError(t, err) - return svc.Containers - } - var c Container - c = getContainersFromService("cmd_shell")[0] + c = getContainersFromService(t, project, "cmd_shell")[0] assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost || exit 1")) assert.Assert(t, in(c.RunArgs, "--health-interval=30s")) assert.Assert(t, in(c.RunArgs, "--health-timeout=10s")) assert.Assert(t, in(c.RunArgs, "--health-retries=3")) assert.Assert(t, in(c.RunArgs, "--health-start-period=5s")) - c = getContainersFromService("cmd_exec")[0] + c = getContainersFromService(t, project, "cmd_exec")[0] assert.Assert(t, in(c.RunArgs, "--health-cmd=curl -f http://localhost")) assert.Assert(t, in(c.RunArgs, "--health-interval=1m0s")) - c = getContainersFromService("disabled_flag")[0] + c = getContainersFromService(t, project, "disabled_flag")[0] assert.Assert(t, in(c.RunArgs, "--no-healthcheck")) assert.Assert(t, !slices.ContainsFunc(c.RunArgs, func(s string) bool { return strings.HasPrefix(s, "--health-cmd=") })) - c = getContainersFromService("disabled_none")[0] + c = getContainersFromService(t, project, "disabled_none")[0] assert.Assert(t, in(c.RunArgs, "--no-healthcheck")) }