feat: support compose stop_grace_period to change timeout before sending SIGKILL

This commit is contained in:
Pasha Sviderski
2026-03-04 14:07:22 +10:00
parent 1f328e99d5
commit d9934b9dba
7 changed files with 125 additions and 90 deletions
+5 -2
View File
@@ -55,6 +55,8 @@ type ServiceSpec struct {
// Caddy is the optional Caddy reverse proxy configuration for the service. // Caddy is the optional Caddy reverse proxy configuration for the service.
// Caddy and Ports cannot be specified simultaneously. // Caddy and Ports cannot be specified simultaneously.
Caddy *CaddySpec `json:",omitempty"` Caddy *CaddySpec `json:",omitempty"`
// Configs is list of configuration objects that can be mounted into the container.
Configs []ConfigSpec
// Container defines the desired state of each container in the service. // Container defines the desired state of each container in the service.
Container ContainerSpec Container ContainerSpec
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
@@ -67,12 +69,13 @@ type ServiceSpec struct {
Ports []PortSpec Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service. // Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"` Replicas uint `json:",omitempty"`
// StopGracePeriod is how long to wait after SIGTERM before sending SIGKILL when stopping a container.
// Default is 10 seconds if not specified.
StopGracePeriod *time.Duration `json:",omitempty"`
// UpdateConfig configures how the service is updated during a deployment. // UpdateConfig configures how the service is updated during a deployment.
UpdateConfig UpdateConfig `json:",omitempty"` UpdateConfig UpdateConfig `json:",omitempty"`
// Volumes is list of data volumes that can be mounted into the container. // Volumes is list of data volumes that can be mounted into the container.
Volumes []VolumeSpec Volumes []VolumeSpec
// Configs is list of configuration objects that can be mounted into the container.
Configs []ConfigSpec
} }
// 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.
+5
View File
@@ -85,6 +85,11 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
} }
} }
if service.StopGracePeriod != nil {
d := time.Duration(*service.StopGracePeriod)
spec.StopGracePeriod = &d
}
if service.Scale != nil { if service.Scale != nil {
spec.Replicas = uint(*service.Scale) spec.Replicas = uint(*service.Scale)
} }
+1
View File
@@ -206,6 +206,7 @@ func TestServiceSpecFromCompose(t *testing.T) {
Machines: []string{"machine-1", "machine-2"}, Machines: []string{"machine-1", "machine-2"},
}, },
Replicas: 3, Replicas: 3,
StopGracePeriod: api.AsPtr(30 * time.Second),
UpdateConfig: api.UpdateConfig{ UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst, Order: api.UpdateOrderStopFirst,
MonitorPeriod: &api.DefaultHealthMonitorPeriod, MonitorPeriod: &api.DefaultHealthMonitorPeriod,
+1
View File
@@ -37,6 +37,7 @@ services:
privileged: true privileged: true
pull_policy: always pull_policy: always
scale: 3 scale: 3
stop_grace_period: 30s
sysctls: sysctls:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
ulimits: ulimits:
+23 -7
View File
@@ -3,6 +3,7 @@ package operation
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -56,10 +57,11 @@ type StopContainerOperation struct {
ServiceID string ServiceID string
ContainerID string ContainerID string
MachineID string MachineID string
StopGracePeriod *time.Duration
} }
func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error { func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil { if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, stopOptions(o.StopGracePeriod)); err != nil {
return fmt.Errorf("stop container: %w", err) return fmt.Errorf("stop container: %w", err)
} }
return nil return nil
@@ -80,13 +82,16 @@ func (o *StopContainerOperation) String() string {
type RemoveContainerOperation struct { type RemoveContainerOperation struct {
MachineID string MachineID string
Container api.ServiceContainer Container api.ServiceContainer
StopGracePeriod *time.Duration
} }
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error { func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil { err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, stopOptions(o.StopGracePeriod))
if err != nil {
return fmt.Errorf("stop container: %w", err) return fmt.Errorf("stop container: %w", err)
} }
if err := cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
if err = cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
// Remove anonymous volumes created by the container. // Remove anonymous volumes created by the container.
RemoveVolumes: true, RemoveVolumes: true,
}); err != nil { }); err != nil {
@@ -119,6 +124,7 @@ type ReplaceContainerOperation struct {
Order string Order string
// SkipHealthMonitor skips the monitoring period and health checks after starting a new container. // SkipHealthMonitor skips the monitoring period and health checks after starting a new container.
SkipHealthMonitor bool SkipHealthMonitor bool
StopGracePeriod *time.Duration
} }
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error { func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
@@ -133,7 +139,8 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
} }
wasRunning = ctr.Container.State.Running wasRunning = ctr.Container.State.Running
if wasRunning { if wasRunning {
if err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil { err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, stopOptions(o.StopGracePeriod))
if err != nil {
return fmt.Errorf("stop old container: %w", err) return fmt.Errorf("stop old container: %w", err)
} }
} }
@@ -156,7 +163,7 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
// Use context without progress to not overwrite the container Unhealthy status with Stopped. // Use context without progress to not overwrite the container Unhealthy status with Stopped.
ctxWithoutProgress := progress.WithContextWriter(ctx, nil) ctxWithoutProgress := progress.WithContextWriter(ctx, nil)
_ = cli.StopContainer(ctxWithoutProgress, o.ServiceID, resp.ID, container.StopOptions{}) _ = cli.StopContainer(ctxWithoutProgress, o.ServiceID, resp.ID, stopOptions(o.StopGracePeriod))
newCtr := fmt.Sprintf("%s/%s", o.Spec.Name, stringid.TruncateID(resp.ID)) newCtr := fmt.Sprintf("%s/%s", o.Spec.Name, stringid.TruncateID(resp.ID))
healthErr := fmt.Errorf( healthErr := fmt.Errorf(
@@ -186,12 +193,12 @@ func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) err
// There still might be a brief downtime (for a 1 replica service) when Caddy doesn't know about // There still might be a brief downtime (for a 1 replica service) when Caddy doesn't know about
// the new container but we're stopping the old container. We should somehow ensure Caddy is updated // the new container but we're stopping the old container. We should somehow ensure Caddy is updated
// with the new container before we stop the old one to avoid this downtime. // with the new container before we stop the old one to avoid this downtime.
if err := cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil { if err = cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, stopOptions(o.StopGracePeriod)); err != nil {
return fmt.Errorf("stop old container: %w", err) return fmt.Errorf("stop old container: %w", err)
} }
} }
if err := cli.RemoveContainer(ctx, o.ServiceID, o.OldContainer.ID, container.RemoveOptions{ if err = cli.RemoveContainer(ctx, o.ServiceID, o.OldContainer.ID, container.RemoveOptions{
RemoveVolumes: true, RemoveVolumes: true,
}); err != nil { }); err != nil {
return fmt.Errorf("remove old container: %w", err) return fmt.Errorf("remove old container: %w", err)
@@ -209,3 +216,12 @@ func (o *ReplaceContainerOperation) String() string {
return fmt.Sprintf("ReplaceContainerOperation[machine_id=%s service_id=%s old_container_id=%s order=%s]", return fmt.Sprintf("ReplaceContainerOperation[machine_id=%s service_id=%s old_container_id=%s order=%s]",
o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order) o.MachineID, o.ServiceID, o.OldContainer.ID, o.Order)
} }
// stopOptions converts a stop grace period duration to Docker container stop options.
func stopOptions(gracePeriod *time.Duration) container.StopOptions {
if gracePeriod == nil {
return container.StopOptions{}
}
t := int(gracePeriod.Seconds())
return container.StopOptions{Timeout: &t}
}
+8
View File
@@ -175,6 +175,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
OldContainer: ctr, OldContainer: ctr,
Order: order, Order: order,
SkipHealthMonitor: s.SkipHealthMonitor, SkipHealthMonitor: s.SkipHealthMonitor,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
@@ -184,6 +185,7 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: mid, MachineID: mid,
Container: c, Container: c,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
} }
@@ -236,6 +238,7 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{ plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
} }
@@ -288,6 +291,7 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: old.MachineID, MachineID: old.MachineID,
Container: old.Container, Container: old.Container,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
break break
@@ -322,6 +326,7 @@ func reconcileGlobalContainer(
ServiceID: serviceID, ServiceID: serviceID,
ContainerID: c.Container.ID, ContainerID: c.Container.ID,
MachineID: machineID, MachineID: machineID,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
} }
@@ -335,6 +340,7 @@ func reconcileGlobalContainer(
OldContainer: containerToReplace.Container, OldContainer: containerToReplace.Container,
Order: order, Order: order,
SkipHealthMonitor: skipHealthCheck, SkipHealthMonitor: skipHealthCheck,
StopGracePeriod: spec.StopGracePeriod,
}) })
// Remove any other containers (there shouldn't be any in normal operation). // Remove any other containers (there shouldn't be any in normal operation).
@@ -345,6 +351,7 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
} else { } else {
@@ -359,6 +366,7 @@ func reconcileGlobalContainer(
ops = append(ops, &operation.RemoveContainerOperation{ ops = append(ops, &operation.RemoveContainerOperation{
MachineID: c.MachineID, MachineID: c.MachineID,
Container: c.Container, Container: c.Container,
StopGracePeriod: spec.StopGracePeriod,
}) })
} }
} }
@@ -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) |
@@ -36,6 +36,7 @@ The following table shows the support status for main Compose features:
| `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 |
| `security_opt` | ❌ Not supported | | | `security_opt` | ❌ Not supported | |
| `stop_grace_period` | ✅ Supported | Time to wait after SIGTERM before SIGKILL |
| `storage_opt` | ❌ Not supported | | | `storage_opt` | ❌ Not supported | |
| `sysctls` | ✅ Supported | Namespaced kernel parameters | | `sysctls` | ✅ Supported | Namespaced kernel parameters |
| `user` | ✅ Supported | Set container user | | `user` | ✅ Supported | Set container user |