Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(compose): support healthcheck field in service config
Signed-off-by: Park jungtae <jtpark1957@gmail.com>
  • Loading branch information
opjt committed May 11, 2026
commit 520a3df953323f2cc948cca2ffc641fcf180c7f0
2 changes: 1 addition & 1 deletion docs/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ which was derived from [Docker Compose file version 3 specification](https://doc
- `services.<SERVICE>.deploy.resources.reservations`
- `services.<SERVICE>.deploy.placement`
- `services.<SERVICE>.deploy.endpoint_mode`
- `services.<SERVICE>.healthcheck`
- `services.<SERVICE>.healthcheck.start_interval`
- `services.<SERVICE>.stop_grace_period`
- `services.<SERVICE>.stop_signal`
- `configs.<CONFIG>.external`
Expand Down
57 changes: 57 additions & 0 deletions pkg/composer/serviceparser/serviceparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -78,6 +79,7 @@ func warnUnknownFields(svc types.ServiceConfig) {
"Extends", // handled by the loader
"Extensions",
"ExtraHosts",
"HealthCheck",
"Hostname",
"Image",
"Init",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Comment thread
opjt marked this conversation as resolved.
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 {
Comment thread
opjt marked this conversation as resolved.
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
Expand Down
66 changes: 66 additions & 0 deletions pkg/composer/serviceparser/serviceparser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (
"fmt"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"

"github.com/compose-spec/compose-go/v2/types"
Expand Down Expand Up @@ -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
}
Comment thread
opjt marked this conversation as resolved.
Outdated

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"))
}