mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
deploy.update_config.order support to start-first or stop-first when replacing containers (#248)
This commit is contained in:
@@ -19,6 +19,13 @@ const (
|
|||||||
ServiceModeReplicated = "replicated"
|
ServiceModeReplicated = "replicated"
|
||||||
ServiceModeGlobal = "global"
|
ServiceModeGlobal = "global"
|
||||||
|
|
||||||
|
// UpdateOrderStartFirst starts the new container before stopping the old one.
|
||||||
|
// This minimizes downtime but briefly runs both containers.
|
||||||
|
UpdateOrderStartFirst = "start-first"
|
||||||
|
// UpdateOrderStopFirst stops the old container before starting the new one.
|
||||||
|
// This prevents data corruption for stateful services but causes brief downtime.
|
||||||
|
UpdateOrderStopFirst = "stop-first"
|
||||||
|
|
||||||
// PullPolicyAlways means the image is always pulled from the registry.
|
// PullPolicyAlways means the image is always pulled from the registry.
|
||||||
PullPolicyAlways = "always"
|
PullPolicyAlways = "always"
|
||||||
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
|
// PullPolicyMissing means the image is pulled from the registry only if it's not available on the machine where
|
||||||
@@ -59,12 +66,22 @@ 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"`
|
||||||
|
// UpdateConfig configures how the service is updated during a deployment.
|
||||||
|
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 is list of configuration objects that can be mounted into the container.
|
||||||
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 {
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
default:
|
default:
|
||||||
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
return spec, fmt.Errorf("unsupported deploy mode: '%s'", service.Deploy.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse update_config.order
|
||||||
|
if cfg := service.Deploy.UpdateConfig; cfg != nil {
|
||||||
|
switch cfg.Order {
|
||||||
|
case "":
|
||||||
|
// No order specified, use default behavior.
|
||||||
|
case "start-first":
|
||||||
|
spec.UpdateConfig.Order = api.UpdateOrderStartFirst
|
||||||
|
case "stop-first":
|
||||||
|
spec.UpdateConfig.Order = api.UpdateOrderStopFirst
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("unsupported update_config.order: '%s'", cfg.Order)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
|
||||||
|
|||||||
@@ -787,6 +787,112 @@ services:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
composeYAML string
|
||||||
|
expected api.UpdateConfig
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no update_config",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
`,
|
||||||
|
expected: api.UpdateConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_config with stop-first order",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: postgres
|
||||||
|
deploy:
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
`,
|
||||||
|
expected: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_config with start-first order",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
deploy:
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
|
`,
|
||||||
|
expected: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_config with invalid order",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
deploy:
|
||||||
|
update_config:
|
||||||
|
order: invalid-order
|
||||||
|
`,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_config with empty order",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
deploy:
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
`,
|
||||||
|
expected: api.UpdateConfig{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_config with replicas and order",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: nginx
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
`,
|
||||||
|
expected: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
spec, err := ServiceSpecFromCompose(project, "test")
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, spec.UpdateConfig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -158,6 +158,66 @@ func (o *CreateVolumeOperation) String() string {
|
|||||||
o.MachineID, o.VolumeSpec.DockerVolumeName())
|
o.MachineID, o.VolumeSpec.DockerVolumeName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceContainerOperation replaces an old container with a new one based on the specified update order.
|
||||||
|
// For start-first: starts new container, then removes old container.
|
||||||
|
// For stop-first: stops old container, starts new container, then removes old container.
|
||||||
|
type ReplaceContainerOperation struct {
|
||||||
|
ServiceID string
|
||||||
|
Spec api.ServiceSpec
|
||||||
|
MachineID string
|
||||||
|
OldContainer api.ServiceContainer
|
||||||
|
// Order specifies the update order: "start-first" or "stop-first".
|
||||||
|
Order string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ReplaceContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
|
|
||||||
|
stopFirst := o.Order == api.UpdateOrderStopFirst
|
||||||
|
|
||||||
|
if stopFirst {
|
||||||
|
if err := cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("stop old container: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Rollback support - if new container fails to start, restart old container (#24)
|
||||||
|
// TODO: When parallelism is added, rollback becomes more complex - need to track which containers
|
||||||
|
// were stopped and restore them all on failure
|
||||||
|
resp, err := cli.CreateContainer(ctx, o.ServiceID, o.Spec, o.MachineID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create container: %w", err)
|
||||||
|
}
|
||||||
|
if err = cli.StartContainer(ctx, o.ServiceID, resp.ID); err != nil {
|
||||||
|
return fmt.Errorf("start container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For start-first, we need to stop before removing.
|
||||||
|
// For stop-first, the container is already stopped.
|
||||||
|
if !stopFirst {
|
||||||
|
if err := cli.StopContainer(ctx, o.ServiceID, o.OldContainer.ID, container.StopOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("stop old container: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.RemoveContainer(ctx, o.ServiceID, o.OldContainer.ID, container.RemoveOptions{
|
||||||
|
RemoveVolumes: true,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("remove old container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ReplaceContainerOperation) Format(resolver NameResolver) string {
|
||||||
|
return fmt.Sprintf("%s: Replace container [id=%s image=%s order=%s]",
|
||||||
|
resolver.MachineName(o.MachineID), o.OldContainer.ShortID(), o.Spec.Container.Image, o.Order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ReplaceContainerOperation) String() string {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// SequenceOperation is a composite operation that executes a sequence of operations in order.
|
// SequenceOperation is a composite operation that executes a sequence of operations in order.
|
||||||
type SequenceOperation struct {
|
type SequenceOperation struct {
|
||||||
Operations []Operation
|
Operations []Operation
|
||||||
|
|||||||
@@ -160,29 +160,16 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
|
||||||
|
|
||||||
conflictingPorts, portsErr := ctr.ConflictingServicePorts(spec.Ports)
|
|
||||||
if portsErr != nil || len(conflictingPorts) > 0 {
|
|
||||||
// Stop the malformed container or the container with conflicting ports.
|
|
||||||
plan.Operations = append(plan.Operations, &StopContainerOperation{
|
|
||||||
ServiceID: plan.ServiceID,
|
|
||||||
ContainerID: ctr.ID,
|
|
||||||
MachineID: m.Id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a new container.
|
// Replace the old container with a new one.
|
||||||
plan.Operations = append(plan.Operations, &RunContainerOperation{
|
order := determineUpdateOrder(ctr, spec)
|
||||||
ServiceID: plan.ServiceID,
|
plan.Operations = append(plan.Operations, &ReplaceContainerOperation{
|
||||||
Spec: spec,
|
ServiceID: plan.ServiceID,
|
||||||
MachineID: m.Id,
|
Spec: spec,
|
||||||
})
|
MachineID: m.Id,
|
||||||
|
OldContainer: ctr,
|
||||||
// Remove the old container.
|
Order: order,
|
||||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
|
||||||
MachineID: m.Id,
|
|
||||||
Container: ctr,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,43 +291,83 @@ func reconcileGlobalContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The machine has containers but none of them match the new spec.
|
// The machine has containers but none of them match the new spec.
|
||||||
// Stop the old running containers that have conflicting ports with the new spec before running a new one.
|
// Find the first running container to replace (there should typically be only one).
|
||||||
for _, c := range containers {
|
var containerToReplace *api.MachineServiceContainer
|
||||||
|
for i, c := range containers {
|
||||||
if c.Container.State.Running {
|
if c.Container.State.Running {
|
||||||
conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
|
containerToReplace = &containers[i]
|
||||||
if err != nil {
|
break
|
||||||
return nil, fmt.Errorf("check conflicting ports: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(conflictingPorts) > 0 {
|
|
||||||
// Stop the running container with conflicting ports.
|
|
||||||
ops = append(ops, &StopContainerOperation{
|
|
||||||
ServiceID: serviceID,
|
|
||||||
ContainerID: c.Container.ID,
|
|
||||||
MachineID: c.MachineID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a new container.
|
if containerToReplace != nil {
|
||||||
ops = append(ops, &RunContainerOperation{
|
// Replace the running container with a new one.
|
||||||
ServiceID: serviceID,
|
order := determineUpdateOrder(containerToReplace.Container, spec)
|
||||||
Spec: spec,
|
ops = append(ops, &ReplaceContainerOperation{
|
||||||
MachineID: machineID,
|
ServiceID: serviceID,
|
||||||
})
|
Spec: spec,
|
||||||
|
MachineID: machineID,
|
||||||
// Remove the old containers.
|
OldContainer: containerToReplace.Container,
|
||||||
for _, c := range containers {
|
Order: order,
|
||||||
ops = append(ops, &RemoveContainerOperation{
|
|
||||||
MachineID: c.MachineID,
|
|
||||||
Container: c.Container,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Remove any other containers (there shouldn't be any in normal operation).
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.Container.ID == containerToReplace.Container.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ops = append(ops, &RemoveContainerOperation{
|
||||||
|
MachineID: c.MachineID,
|
||||||
|
Container: c.Container,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No running containers, create a new one and remove all stopped containers.
|
||||||
|
ops = append(ops, &RunContainerOperation{
|
||||||
|
ServiceID: serviceID,
|
||||||
|
Spec: spec,
|
||||||
|
MachineID: machineID,
|
||||||
|
})
|
||||||
|
for _, c := range containers {
|
||||||
|
ops = append(ops, &RemoveContainerOperation{
|
||||||
|
MachineID: c.MachineID,
|
||||||
|
Container: c.Container,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// determineUpdateOrder determines the update order for replacing a container based on the service spec
|
||||||
|
// and current container state. The order can be explicitly set in UpdateConfig, or automatically determined:
|
||||||
|
// - If the user explicitly set order, respect it
|
||||||
|
// - Services with port conflicts require stop-first (ports must be freed first)
|
||||||
|
// - Single-replica services with data volumes default to stop-first (prevents data corruption)
|
||||||
|
// - Multi-replica services use start-first (concurrent access already happening)
|
||||||
|
// - All other services default to start-first (minimizes downtime)
|
||||||
|
func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpec) string {
|
||||||
|
// User explicitly set order - respect it
|
||||||
|
if spec.UpdateConfig.Order != "" {
|
||||||
|
return spec.UpdateConfig.Order
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port conflicts require stop-first
|
||||||
|
conflictingPorts, err := oldContainer.ConflictingServicePorts(spec.Ports)
|
||||||
|
if err != nil || len(conflictingPorts) > 0 {
|
||||||
|
return api.UpdateOrderStopFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-replica services with data volumes default to stop-first to prevent data corruption.
|
||||||
|
// Multi-replica services already have concurrent access, so start-first is safe.
|
||||||
|
if spec.Replicas <= 1 && len(spec.MountedDockerVolumes()) > 0 {
|
||||||
|
return api.UpdateOrderStopFirst
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: start-first for minimal downtime
|
||||||
|
return api.UpdateOrderStartFirst
|
||||||
|
}
|
||||||
|
|
||||||
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name.
|
// newEmptyPlan creates a new empty plan for a service deployment with initialised service ID and name.
|
||||||
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
func newEmptyPlan(svc *api.Service, spec api.ServiceSpec) (Plan, error) {
|
||||||
var plan Plan
|
var plan Plan
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package deploy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetermineUpdateOrder(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
oldContainer api.ServiceContainer
|
||||||
|
spec api.ServiceSpec
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit stop-first order",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
UpdateConfig: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit start-first order",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
UpdateConfig: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit start-first overrides volume default",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
UpdateConfig: api.UpdateConfig{
|
||||||
|
Order: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "data",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "data",
|
||||||
|
ContainerPath: "/data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single-replica service with volume defaults to stop-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Replicas: 1,
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "db-data",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "db-data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "db-data",
|
||||||
|
ContainerPath: "/var/lib/postgresql/data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-replica service with volume defaults to start-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Replicas: 3,
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "app-data",
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: "app-data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "app-data",
|
||||||
|
ContainerPath: "/data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service with bind mount defaults to start-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "config",
|
||||||
|
Type: api.VolumeTypeBind,
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: "/etc/app/config",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "config",
|
||||||
|
ContainerPath: "/config",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service with tmpfs mount defaults to start-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "tmp",
|
||||||
|
Type: api.VolumeTypeTmpfs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "tmp",
|
||||||
|
ContainerPath: "/tmp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stateless service defaults to start-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "nginx:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStartFirst,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port conflict forces stop-first",
|
||||||
|
oldContainer: api.ServiceContainer{
|
||||||
|
Container: api.Container{
|
||||||
|
InspectResponse: container.InspectResponse{
|
||||||
|
Config: &container.Config{
|
||||||
|
Labels: map[string]string{
|
||||||
|
api.LabelServicePorts: `[{"container_port":8080,"published_port":8080,"protocol":"tcp","mode":"host"}]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: api.ServiceSpec{
|
||||||
|
Ports: []api.PortSpec{
|
||||||
|
{
|
||||||
|
ContainerPort: 8080,
|
||||||
|
PublishedPort: 8080,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Mode: api.PortModeHost,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: api.UpdateOrderStopFirst,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := determineUpdateOrder(tt.oldContainer, tt.spec)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Deployment strategies
|
||||||
|
|
||||||
|
When you run `uc deploy`, Uncloud updates your services without taking them offline. This page explains how deployments
|
||||||
|
work and how to configure them for different types of services.
|
||||||
|
|
||||||
|
## Rolling deployments
|
||||||
|
|
||||||
|
Uncloud uses rolling deployments: it replaces containers one at a time, waiting for each new container to start before
|
||||||
|
removing the old one. This keeps your service available throughout the update.
|
||||||
|
|
||||||
|
For a service with three replicas, the deployment looks like this:
|
||||||
|
|
||||||
|
1. Start new container #1
|
||||||
|
2. Remove old container #1
|
||||||
|
3. Start new container #2
|
||||||
|
4. Remove old container #2
|
||||||
|
5. Start new container #3
|
||||||
|
6. Remove old container #3
|
||||||
|
|
||||||
|
At every step, at least two containers are serving traffic.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Rolling is currently the only supported deployment strategy.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Update order
|
||||||
|
|
||||||
|
The **update order** controls whether Uncloud starts the new container before or after stopping the old one.
|
||||||
|
|
||||||
|
| Order | What happens | Best for |
|
||||||
|
|-------|--------------|----------|
|
||||||
|
| `start-first` | Start new container, then stop old | Stateless services (web apps, APIs) |
|
||||||
|
| `stop-first` | Stop old container, then start new | Stateful services (databases) |
|
||||||
|
|
||||||
|
### Default behavior
|
||||||
|
|
||||||
|
Uncloud picks the safest default based on your service:
|
||||||
|
|
||||||
|
- **Services with host port conflicts** use `stop-first` because ports must be freed first
|
||||||
|
- **Services with named volumes** (not bind mounts or tmpfs):
|
||||||
|
- **Single replica** uses `stop-first` to prevent data corruption
|
||||||
|
- **Multiple replicas** uses `start-first` since concurrent access is already happening
|
||||||
|
- **All other services** use `start-first` for zero downtime
|
||||||
|
|
||||||
|
### Overriding the default
|
||||||
|
|
||||||
|
Set `deploy.update_config.order` to override:
|
||||||
|
|
||||||
|
```yaml title="compose.yaml"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: myapp
|
||||||
|
deploy:
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
This single-replica service has a volume, so Uncloud would normally use `stop-first`. Setting `order: start-first`
|
||||||
|
overrides that—useful if your app handles concurrent access safely (like SQLite in WAL mode).
|
||||||
|
|
||||||
|
### Choosing the right order
|
||||||
|
|
||||||
|
**Use `start-first`** when your service can run multiple instances simultaneously:
|
||||||
|
|
||||||
|
- Web applications and API servers
|
||||||
|
- Background workers processing independent jobs
|
||||||
|
- Read-heavy services with shared caches
|
||||||
|
|
||||||
|
**Use `stop-first`** when your service needs exclusive access:
|
||||||
|
|
||||||
|
- Databases (PostgreSQL, MySQL, Redis)
|
||||||
|
- Services with file locks
|
||||||
|
- Anything that writes to a volume without coordination
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
|
||||||
|
Two containers writing to the same volume can corrupt your data. Uncloud defaults to `stop-first` for single-replica
|
||||||
|
services with volumes, but if you override this or use multiple replicas, make sure your application handles concurrent
|
||||||
|
access correctly.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Deploy an app](1-deploy-app.md): Build and deploy from source or pre-built images
|
||||||
|
- [Compose support matrix](../../8-compose-file-reference/1-support-matrix.md): Supported Compose features
|
||||||
@@ -46,7 +46,7 @@ The following table shows the support status for main Compose features:
|
|||||||
| `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` |
|
||||||
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
| `rollback_config` | ❌ Not supported | See [#151](https://github.com/psviderski/uncloud/issues/151) |
|
||||||
| `update_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) |
|
||||||
| **Volumes** | | |
|
| **Volumes** | | |
|
||||||
| Named volumes | ✅ Supported | Docker volumes |
|
| Named volumes | ✅ Supported | Docker volumes |
|
||||||
| Bind mounts | ✅ Supported | Host path binding |
|
| Bind mounts | ✅ Supported | Host path binding |
|
||||||
|
|||||||
Reference in New Issue
Block a user