feat: add support for healthcheck in Compose

This commit is contained in:
Pasha Sviderski
2026-02-25 14:54:24 +10:00
parent 7bfaf31138
commit 3056e2af11
8 changed files with 220 additions and 88 deletions
+16
View File
@@ -567,6 +567,22 @@ func (s *Server) CreateServiceContainer(
if spec.Mode == "" { if spec.Mode == "" {
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
} }
if hc := spec.Container.Healthcheck; hc != nil {
if hc.Disable {
config.Healthcheck = &container.HealthConfig{
Test: []string{"NONE"},
}
} else {
config.Healthcheck = &container.HealthConfig{
Test: hc.Test,
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
StartInterval: hc.StartInterval,
Retries: int(hc.Retries),
}
}
}
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB. // TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
var err error var err error
+44 -14
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time"
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -74,14 +75,6 @@ type ServiceSpec struct {
Configs []ConfigSpec Configs []ConfigSpec
} }
// UpdateConfig configures how a service is updated during a deployment.
type UpdateConfig struct {
// Order specifies the order of operations during an update.
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with volumes).
// Empty value means the strategy will determine the order based on service characteristics.
Order string `json:",omitempty"`
}
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined. // CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
func (s *ServiceSpec) CaddyConfig() string { func (s *ServiceSpec) CaddyConfig() string {
if s.Caddy == nil { if s.Caddy == nil {
@@ -252,6 +245,9 @@ type ContainerSpec struct {
Entrypoint []string Entrypoint []string
// Env defines the environment variables to set inside the container. // Env defines the environment variables to set inside the container.
Env EnvVars Env EnvVars
// Healthcheck defines the health check configuration for the container or overrides the health check options
// defined in the image. If nil, the image's default health check is used.
Healthcheck *HealthcheckSpec `json:",omitempty"`
Image string Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings. // Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool Init *bool
@@ -347,6 +343,17 @@ func (s *ContainerSpec) Clone() ContainerSpec {
spec.Entrypoint = make([]string, len(s.Entrypoint)) spec.Entrypoint = make([]string, len(s.Entrypoint))
copy(spec.Entrypoint, s.Entrypoint) copy(spec.Entrypoint, s.Entrypoint)
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Healthcheck != nil {
hc := *s.Healthcheck
hc.Test = slices.Clone(s.Healthcheck.Test)
spec.Healthcheck = &hc
}
if s.LogDriver != nil { if s.LogDriver != nil {
logDriver := *s.LogDriver logDriver := *s.LogDriver
if s.LogDriver.Options != nil { if s.LogDriver.Options != nil {
@@ -354,12 +361,6 @@ func (s *ContainerSpec) Clone() ContainerSpec {
} }
spec.LogDriver = &logDriver spec.LogDriver = &logDriver
} }
if s.Env != nil {
spec.Env = make(EnvVars, len(s.Env))
for k, v := range s.Env {
spec.Env[k] = v
}
}
if s.Volumes != nil { if s.Volumes != nil {
spec.Volumes = make([]string, len(s.Volumes)) spec.Volumes = make([]string, len(s.Volumes))
copy(spec.Volumes, s.Volumes) copy(spec.Volumes, s.Volumes)
@@ -389,6 +390,7 @@ func (s *ContainerSpec) Clone() ContainerSpec {
if s.Resources.DeviceReservations != nil { if s.Resources.DeviceReservations != nil {
spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations) spec.Resources.DeviceReservations = slices.Clone(s.Resources.DeviceReservations)
} }
return spec return spec
} }
@@ -406,6 +408,26 @@ func (e EnvVars) ToSlice() []string {
return env return env
} }
// HealthcheckSpec defines the health check configuration for a container.
type HealthcheckSpec struct {
// Test is the command used to check health.
// Formats: ["CMD", args...], ["CMD-SHELL", "command"], or ["NONE"] to disable.
Test []string `json:",omitempty"`
// Interval is the time between health checks.
// Zero means to inherit the value from the image or use the Docker default (30s) if not defined in the image.
Interval time.Duration `json:",omitempty"`
// Timeout is how long to wait before considering the checck to have hung.
Timeout time.Duration `json:",omitempty"`
// StartPeriod is the initialisation time for a container before the retries start to count down.
StartPeriod time.Duration `json:",omitempty"`
// StartInterval is the time between health checks during the start period.
StartInterval time.Duration `json:",omitempty"`
// Retries is the number of consecutive failures needed to consider a container unhealthy.
Retries uint `json:",omitempty"`
// Disable disables the health check defined in the image. true is equivalent to setting Test to ["NONE"].
Disable bool `json:",omitempty"`
}
type LogDriver struct { type LogDriver struct {
// Name of the logging driver to use. // Name of the logging driver to use.
Name string Name string
@@ -413,6 +435,14 @@ type LogDriver struct {
Options map[string]string Options map[string]string
} }
// UpdateConfig configures how a service is updated during a deployment.
type UpdateConfig struct {
// Order specifies the order of operations during an update.
// Valid values are "start-first" (default for stateless services) and "stop-first" (default for services with volumes).
// Empty value means the strategy will determine the order based on service characteristics.
Order string `json:",omitempty"`
}
type RunServiceResponse struct { type RunServiceResponse struct {
ID string ID string
Name string Name string
+30
View File
@@ -5,6 +5,7 @@ import (
"maps" "maps"
"os" "os"
"slices" "slices"
"time"
"github.com/compose-spec/compose-go/v2/types" "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -49,6 +50,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
Command: service.Command, Command: service.Command,
Entrypoint: service.Entrypoint, Entrypoint: service.Entrypoint,
Env: env, Env: env,
Healthcheck: healthcheckFromCompose(service.HealthCheck),
Image: service.Image, Image: service.Image,
Init: service.Init, Init: service.Init,
Privileged: service.Privileged, Privileged: service.Privileged,
@@ -135,6 +137,34 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return spec, nil return spec, nil
} }
func healthcheckFromCompose(hc *types.HealthCheckConfig) *api.HealthcheckSpec {
if hc == nil {
return nil
}
if hc.Disable {
return &api.HealthcheckSpec{Disable: true}
}
spec := &api.HealthcheckSpec{Test: hc.Test}
if hc.Interval != nil {
spec.Interval = time.Duration(*hc.Interval)
}
if hc.Timeout != nil {
spec.Timeout = time.Duration(*hc.Timeout)
}
if hc.StartPeriod != nil {
spec.StartPeriod = time.Duration(*hc.StartPeriod)
}
if hc.StartInterval != nil {
spec.StartInterval = time.Duration(*hc.StartInterval)
}
if hc.Retries != nil {
spec.Retries = uint(*hc.Retries)
}
return spec
}
func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources { func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
resources := api.ContainerResources{ resources := api.ContainerResources{
CPU: int64(service.CPUS * 1e9), CPU: int64(service.CPUS * 1e9),
+9
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
composecli "github.com/compose-spec/compose-go/v2/cli" composecli "github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -109,6 +110,14 @@ func TestServiceSpecFromCompose(t *testing.T) {
"EMPTY": "", "EMPTY": "",
"VAR": "value", "VAR": "value",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD", "curl", "-f", "http://localhost"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "nginx:latest", Image: "nginx:latest",
Init: &initTrue, Init: &initTrue,
LogDriver: &api.LogDriver{ LogDriver: &api.LogDriver{
+7
View File
@@ -11,6 +11,13 @@ services:
BOOL: "true" BOOL: "true"
EMPTY: "" EMPTY: ""
VAR: value VAR: value
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 1m30s
timeout: 10s
retries: 5
start_period: 15s
start_interval: 2s
image: nginx:latest image: nginx:latest
init: true init: true
logging: logging:
+30
View File
@@ -58,6 +58,36 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
assert.Contains(t, ctr.Config.Env, env) assert.Contains(t, ctr.Config.Env, env)
} }
// Healthcheck can only be compared if set in the spec. Otherwise, the container inherits it from the image.
if spec.Container.Healthcheck != nil {
hc := spec.Container.Healthcheck
require.NotNil(t, ctr.Config.Healthcheck)
if hc.Disable {
assert.Equal(t, []string{"NONE"}, ctr.Config.Healthcheck.Test)
} else {
// Only compare fields that are explicitly set in the spec as unset fields inherit their values
// from the image.
if hc.Test != nil {
assert.EqualValues(t, hc.Test, ctr.Config.Healthcheck.Test)
}
if hc.Interval != 0 {
assert.Equal(t, hc.Interval, ctr.Config.Healthcheck.Interval)
}
if hc.Timeout != 0 {
assert.Equal(t, hc.Timeout, ctr.Config.Healthcheck.Timeout)
}
if hc.StartPeriod != 0 {
assert.Equal(t, hc.StartPeriod, ctr.Config.Healthcheck.StartPeriod)
}
if hc.StartInterval != 0 {
assert.Equal(t, hc.StartInterval, ctr.Config.Healthcheck.StartInterval)
}
if hc.Retries != 0 {
assert.Equal(t, int(hc.Retries), ctr.Config.Healthcheck.Retries)
}
}
}
assert.Equal(t, spec.Container.Image, ctr.Config.Image) assert.Equal(t, spec.Container.Image, ctr.Config.Image)
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init) assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-")) assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))
+9
View File
@@ -1315,6 +1315,7 @@ func TestServiceLifecycle(t *testing.T) {
Name: "container-spec-full", Name: "container-spec-full",
Mode: api.ServiceModeGlobal, Mode: api.ServiceModeGlobal,
Container: api.ContainerSpec{ Container: api.ContainerSpec{
// TODO: Add the latest implemented fields to this spec and update assertContainerMatchesSpec.
Command: []string{"sleep", "infinity"}, Command: []string{"sleep", "infinity"},
// Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause. // Extra slashes is not a typo, it changes the spec but Linux ignores them and uses the default /pause.
Entrypoint: []string{"///pause"}, Entrypoint: []string{"///pause"},
@@ -1324,6 +1325,14 @@ func TestServiceLifecycle(t *testing.T) {
"BOOL": "true", "BOOL": "true",
"": "ignored", "": "ignored",
}, },
Healthcheck: &api.HealthcheckSpec{
Test: []string{"CMD-SHELL", "exit 0"},
Interval: 1*time.Minute + 30*time.Second,
Timeout: 10 * time.Second,
Retries: 5,
StartPeriod: 15 * time.Second,
StartInterval: 2 * time.Second,
},
Image: "portainer/pause:latest", Image: "portainer/pause:latest",
Init: &init, Init: &init,
LogDriver: &api.LogDriver{ LogDriver: &api.LogDriver{
@@ -4,7 +4,7 @@ Uncloud supports a subset of the [Compose specification](https://compose-spec.io
The following table shows the support status for main Compose features: The following table shows the support status for main Compose features:
| Feature | Support Status | Notes | | Feature | Support Status | Notes |
|--------------------|--------------------|------------------------------------------------------------------------------------------------| |--------------------|--------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| **Services** | | | | **Services** | | |
| `build` | ✅ Supported | Build context and Dockerfile | | `build` | ✅ Supported | Build context and Dockerfile |
| `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) | | `cap_add` | ✅ Supported | Additional kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) |
@@ -20,6 +20,7 @@ The following table shows the support status for main Compose features:
| `env_file` | ✅ Supported | Environment file | | `env_file` | ✅ Supported | Environment file |
| `environment` | ✅ Supported | Environment variables | | `environment` | ✅ Supported | Environment variables |
| `gpus` | ✅ Supported | GPU device access | | `gpus` | ✅ Supported | GPU device access |
| `healthcheck` | ✅ Supported | Health check configuration |
| `image` | ✅ Supported | Container image specification | | `image` | ✅ Supported | Container image specification |
| `init` | ✅ Supported | Run init process in container | | `init` | ✅ Supported | Run init process in container |
| `labels` | ❌ Not supported | | | `labels` | ❌ Not supported | |
@@ -30,7 +31,7 @@ The following table shows the support status for main Compose features:
| `mem_swappiness` | ❌ Not supported | | | `mem_swappiness` | ❌ Not supported | |
| `memswap_limit` | ❌ Not supported | | | `memswap_limit` | ❌ Not supported | |
| `networks` | ❌ Not supported | All containers share cluster network | | `networks` | ❌ Not supported | All containers share cluster network |
| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS | | `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
| `privileged` | ✅ Supported | Run containers in privileged mode | | `privileged` | ✅ Supported | Run containers in privileged mode |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | | `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `secrets` | ❌ Not supported | Use configs or environment variables | | `secrets` | ❌ Not supported | Use configs or environment variables |
@@ -42,7 +43,7 @@ The following table shows the support status for main Compose features:
| **Deploy** | | | | **Deploy** | | |
| `labels` | ❌ Not supported | | | `labels` | ❌ Not supported | |
| `mode` | ✅ Supported | Either `global` or `replicated` | | `mode` | ✅ Supported | Either `global` or `replicated` |
| `placement` | ❌ Not supported | Use `x-machines` extension | | `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
| `replicas` | ✅ Supported | Number of container replicas | | `replicas` | ✅ Supported | Number of container replicas |
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | | `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | | `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |