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
+46 -16
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 {
@@ -251,8 +244,11 @@ type ContainerSpec struct {
// Entrypoint overrides the default ENTRYPOINT of the image. // Entrypoint overrides the default ENTRYPOINT of the image.
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
Image string // 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
// 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
// LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default. // LogDriver overrides the default logging driver for the container. Each Docker daemon can have its own default.
@@ -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
+42 -12
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"
@@ -44,18 +45,19 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec := api.ServiceSpec{ spec := api.ServiceSpec{
Container: api.ContainerSpec{ Container: api.ContainerSpec{
CapAdd: service.CapAdd, CapAdd: service.CapAdd,
CapDrop: service.CapDrop, CapDrop: service.CapDrop,
Command: service.Command, Command: service.Command,
Entrypoint: service.Entrypoint, Entrypoint: service.Entrypoint,
Env: env, Env: env,
Image: service.Image, Healthcheck: healthcheckFromCompose(service.HealthCheck),
Init: service.Init, Image: service.Image,
Privileged: service.Privileged, Init: service.Init,
PullPolicy: pullPolicy, Privileged: service.Privileged,
Resources: resourcesFromCompose(service), PullPolicy: pullPolicy,
Sysctls: service.Sysctls, Resources: resourcesFromCompose(service),
User: service.User, Sysctls: service.Sysctls,
User: service.User,
}, },
Name: serviceName, Name: serviceName,
Mode: api.ServiceModeReplicated, Mode: api.ServiceModeReplicated,
@@ -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{
@@ -3,67 +3,68 @@
Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations. Uncloud supports a subset of the [Compose specification](https://compose-spec.io/) with some extensions and limitations.
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) |
| `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop | | `cap_drop` | ✅ Supported | Which kernel [capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) to drop |
| `command` | ✅ Supported | Override container command | | `command` | ✅ Supported | Override container command |
| `configs` | ✅ Supported | File-based and inline configs | | `configs` | ✅ Supported | File-based and inline configs |
| `cpus` | ✅ Supported | CPU limit | | `cpus` | ✅ Supported | CPU limit |
| `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked | | `depends_on` | ⚠️ Limited | Services deployed in order but conditions not checked |
| `devices` | ✅ Supported | Device mappings | | `devices` | ✅ Supported | Device mappings |
| `dns` | ❌ Not supported | Built-in service discovery | | `dns` | ❌ Not supported | Built-in service discovery |
| `dns_search` | ❌ Not supported | Built-in service discovery | | `dns_search` | ❌ Not supported | Built-in service discovery |
| `entrypoint` | ✅ Supported | Override container entrypoint | | `entrypoint` | ✅ Supported | Override container entrypoint |
| `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 |
| `image` | ✅ Supported | Container image specification | | `healthcheck` | ✅ Supported | Health check configuration |
| `init` | ✅ Supported | Run init process in container | | `image` | ✅ Supported | Container image specification |
| `labels` | ❌ Not supported | | | `init` | ✅ Supported | Run init process in container |
| `links` | ❌ Not supported | Use service names for communication | | `labels` | ❌ Not supported | |
| `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver | | `links` | ❌ Not supported | Use service names for communication |
| `mem_limit` | ✅ Supported | Memory limit | | `logging` | ✅ Supported | Defaults to [local](https://docs.docker.com/engine/logging/drivers/local/) log driver |
| `mem_reservation` | ✅ Supported | Memory reservation | | `mem_limit` | ✅ Supported | Memory limit |
| `mem_swappiness` | ❌ Not supported | | | `mem_reservation` | ✅ Supported | Memory reservation |
| `memswap_limit` | ❌ Not supported | | | `mem_swappiness` | ❌ Not supported | |
| `networks` | ❌ Not supported | All containers share cluster network | | `memswap_limit` | ❌ Not supported | |
| `ports` | ⚠️ Limited | `mode: host` only, use `x-ports` for HTTP/HTTPS | | `networks` | ❌ Not supported | All containers share cluster network |
| `privileged` | ✅ Supported | Run containers in privileged mode | | `ports` | ⚠️ Limited | `mode: host` only, use [`x-ports`](#x-ports) for HTTP/HTTPS |
| `pull_policy` | ✅ Supported | `always`, `missing`, `never` | | `privileged` | ✅ Supported | Run containers in privileged mode |
| `secrets` | ❌ Not supported | Use configs or environment variables | | `pull_policy` | ✅ Supported | `always`, `missing`, `never` |
| `security_opt` | ❌ Not supported | | | `secrets` | ❌ Not supported | Use configs or environment variables |
| `storage_opt` | ❌ Not supported | | | `security_opt` | ❌ Not supported | |
| `sysctls` | ✅ Supported | Namespaced kernel parameters | | `storage_opt` | ❌ Not supported | |
| `user` | ✅ Supported | Set container user | | `sysctls` | ✅ Supported | Namespaced kernel parameters |
| `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs | | `user` | ✅ Supported | Set container user |
| **Deploy** | | | | `volumes` | ✅ Supported | Named volumes, bind mounts, tmpfs |
| `labels` | ❌ Not supported | | | **Deploy** | | |
| `mode` | ✅ Supported | Either `global` or `replicated` | | `labels` | ❌ Not supported | |
| `placement` | ❌ Not supported | Use `x-machines` extension | | `mode` | ✅ Supported | Either `global` or `replicated` |
| `replicas` | ✅ Supported | Number of container replicas | | `placement` | ❌ Not supported | Use [`x-machines`](#x-machines) extension |
| `resources` | ⚠️ Limited | CPU, memory limits and device reservations | | `replicas` | ✅ Supported | Number of container replicas |
| `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` | | `resources` | ⚠️ Limited | CPU, memory limits and device reservations |
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) | | `restart_policy` | ❌ Not supported | Defaults to `unless-stopped` |
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
| `update_config` | ⚠️ Limited | Only `order` supported (defaults to `start-first`). See [deployment strategies](../4-guides/1-deployments/4-deployment-strategies.md) | | `update_config` | ⚠️ Limited | Only `order` supported (defaults to `start-first`). See [deployment strategies](../4-guides/1-deployments/4-deployment-strategies.md) |
| **Volumes** | | | | **Volumes** | | |
| Named volumes | ✅ Supported | Docker volumes | | Named volumes | ✅ Supported | Docker volumes |
| Bind mounts | ✅ Supported | Host path binding | | Bind mounts | ✅ Supported | Host path binding |
| Tmpfs mounts | ✅ Supported | In-memory filesystems | | Tmpfs mounts | ✅ Supported | In-memory filesystems |
| Volume labels | ✅ Supported | Custom labels | | Volume labels | ✅ Supported | Custom labels |
| External volumes | ✅ Supported | Must exist before deployment | | External volumes | ✅ Supported | Must exist before deployment |
| Volume drivers | ⚠️ Limited | Local driver only | | Volume drivers | ⚠️ Limited | Local driver only |
| **Configs** | | | | **Configs** | | |
| File-based configs | ✅ Supported | Read from file | | File-based configs | ✅ Supported | Read from file |
| Inline configs | ✅ Supported | Defined in compose file | | Inline configs | ✅ Supported | Defined in compose file |
| External configs | ❌ Not supported | Not supported | | External configs | ❌ Not supported | Not supported |
| Short syntax | ❌ Not supported | Use long syntax only | | Short syntax | ❌ Not supported | Use long syntax only |
| **Extensions** | | | | **Extensions** | | |
| `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration | | `x-caddy` | ✅ Uncloud-specific | Custom Caddy configuration |
| `x-machines` | ✅ Uncloud-specific | Machine placement constraints | | `x-machines` | ✅ Uncloud-specific | Machine placement constraints |
| `x-ports` | ✅ Uncloud-specific | Service port publishing | | `x-ports` | ✅ Uncloud-specific | Service port publishing |
### Legend ### Legend