fix(container): clean up interrupted deploy candidates
- commit
- b27b280ba0dc2690c678442ae303f0c3484397bb
- author
- brice · · 2026-08-10 14:20 UTC
- parent
- 8f4a170
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +64 -14
M internal/usecase/container/service.go +15 -11
| @@ -77,9 +77,10 @@ const ( | |||
| 77 | 77 | // readinessRecoveryWindow is an additional grace window used when a container | |
| 78 | 78 | // is briefly not running at the end of readiness delay. This avoids false | |
| 79 | 79 | // negatives during short startup flaps. | |
| 80 | - | readinessRecoveryWindow = 30 * time.Second | |
| 81 | - | internalPullMaxAttempts = 3 | |
| 82 | - | deployFailureLogLimit = 20 | |
| 80 | + | readinessRecoveryWindow = 30 * time.Second | |
| 81 | + | failedContainerCleanupTimeout = 30 * time.Second | |
| 82 | + | internalPullMaxAttempts = 3 | |
| 83 | + | deployFailureLogLimit = 20 | |
| 83 | 84 | ) | |
| 84 | 85 | ||
| 85 | 86 | // Service implements the ContainerService interface. | |
| @@ -1963,11 +1964,14 @@ func (s *Service) UpdateAttachments(attachments map[string][]string) { | |||
| 1963 | 1964 | ||
| 1964 | 1965 | // cleanupFailedContainer stops and removes a container that failed to start properly. | |
| 1965 | 1966 | func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) { | |
| 1966 | - | log := zerowrap.FromCtx(ctx) | |
| 1967 | - | if err := s.runtime.StopContainer(ctx, containerID); err != nil { | |
| 1967 | + | cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failedContainerCleanupTimeout) | |
| 1968 | + | defer cancel() | |
| 1969 | + | ||
| 1970 | + | log := zerowrap.FromCtx(cleanupCtx) | |
| 1971 | + | if err := s.runtime.StopContainer(cleanupCtx, containerID); err != nil { | |
| 1968 | 1972 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure") | |
| 1969 | 1973 | } | |
| 1970 | - | if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil { | |
| 1974 | + | if err := s.runtime.RemoveContainer(cleanupCtx, containerID, true); err != nil { | |
| 1971 | 1975 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure") | |
| 1972 | 1976 | } | |
| 1973 | 1977 | } | |
| @@ -2631,11 +2635,11 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||
| 2631 | 2635 | ||
| 2632 | 2636 | for _, c := range allContainers { | |
| 2633 | 2637 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | |
| 2634 | - | // Skip any container that is still active in the runtime, including | |
| 2635 | - | // restart backoff, regardless of name — a temp container may be | |
| 2636 | - | // serving traffic while the new container stabilizes, or may be from | |
| 2637 | - | // a concurrent deploy. | |
| 2638 | - | if c.Status == "running" || c.Status == "restarting" { | |
| 2638 | + | // Without a selected active container, preserve anything that may still | |
| 2639 | + | // be serving traffic. When an active container is selected, other active | |
| 2640 | + | // temp-name containers are stale candidates left by interrupted deploys. | |
| 2641 | + | isActive := c.Status == "running" || c.Status == "restarting" | |
| 2642 | + | if isActive && (skipContainerID == "" || c.Name == expectedName) { | |
| 2639 | 2643 | log.Debug(). | |
| 2640 | 2644 | Str(zerowrap.FieldEntityID, c.ID). | |
| 2641 | 2645 | Str("container_name", c.Name). | |
| @@ -77,9 +77,10 @@ const ( | |||||
| 77 | // readinessRecoveryWindow is an additional grace window used when a container | 77 | // readinessRecoveryWindow is an additional grace window used when a container | ||
| 78 | // is briefly not running at the end of readiness delay. This avoids false | 78 | // is briefly not running at the end of readiness delay. This avoids false | ||
| 79 | // negatives during short startup flaps. | 79 | // negatives during short startup flaps. | ||
| 80 | - | readinessRecoveryWindow = 30 * time.Second | 80 | + | readinessRecoveryWindow = 30 * time.Second |
| 81 | - | internalPullMaxAttempts = 3 | 81 | + | failedContainerCleanupTimeout = 30 * time.Second |
| 82 | - | deployFailureLogLimit = 20 | 82 | + | internalPullMaxAttempts = 3 |
| 83 | + | deployFailureLogLimit = 20 | |||
| 83 | ) | 84 | ) | ||
| 84 | 85 | ||||
| 85 | // Service implements the ContainerService interface. | 86 | // Service implements the ContainerService interface. | ||
| @@ -1963,11 +1964,14 @@ func (s *Service) UpdateAttachments(attachments map[string][]string) { | |||||
| 1963 | 1964 | ||||
| 1964 | // cleanupFailedContainer stops and removes a container that failed to start properly. | 1965 | // cleanupFailedContainer stops and removes a container that failed to start properly. | ||
| 1965 | func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) { | 1966 | func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) { | ||
| 1966 | - | log := zerowrap.FromCtx(ctx) | 1967 | + | cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failedContainerCleanupTimeout) |
| 1967 | - | if err := s.runtime.StopContainer(ctx, containerID); err != nil { | 1968 | + | defer cancel() |
| 1969 | + | ||||
| 1970 | + | log := zerowrap.FromCtx(cleanupCtx) | |||
| 1971 | + | if err := s.runtime.StopContainer(cleanupCtx, containerID); err != nil { | |||
| 1968 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure") | 1972 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure") | ||
| 1969 | } | 1973 | } | ||
| 1970 | - | if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil { | 1974 | + | if err := s.runtime.RemoveContainer(cleanupCtx, containerID, true); err != nil { |
| 1971 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure") | 1975 | log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure") | ||
| 1972 | } | 1976 | } | ||
| 1973 | } | 1977 | } | ||
| @@ -2631,11 +2635,11 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||||
| 2631 | 2635 | ||||
| 2632 | for _, c := range allContainers { | 2636 | for _, c := range allContainers { | ||
| 2633 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | 2637 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | ||
| 2634 | - | // Skip any container that is still active in the runtime, including | 2638 | + | // Without a selected active container, preserve anything that may still |
| 2635 | - | // restart backoff, regardless of name — a temp container may be | 2639 | + | // be serving traffic. When an active container is selected, other active |
| 2636 | - | // serving traffic while the new container stabilizes, or may be from | 2640 | + | // temp-name containers are stale candidates left by interrupted deploys. |
| 2637 | - | // a concurrent deploy. | 2641 | + | isActive := c.Status == "running" || c.Status == "restarting" |
| 2638 | - | if c.Status == "running" || c.Status == "restarting" { | 2642 | + | if isActive && (skipContainerID == "" || c.Name == expectedName) { |
| 2639 | log.Debug(). | 2643 | log.Debug(). | ||
| 2640 | Str(zerowrap.FieldEntityID, c.ID). | 2644 | Str(zerowrap.FieldEntityID, c.ID). | ||
| 2641 | Str("container_name", c.Name). | 2645 | Str("container_name", c.Name). | ||
M internal/usecase/container/service_test.go +49 -3
| @@ -610,6 +610,51 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing. | |||
| 610 | 610 | assert.ErrorContains(t, deployErr.Err, "last status: starting") | |
| 611 | 611 | } | |
| 612 | 612 | ||
| 613 | + | func TestService_Deploy_RequestCancellationStillCleansUpFailedCandidate(t *testing.T) { | |
| 614 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 615 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 616 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 617 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil) | |
| 618 | + | ||
| 619 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 620 | + | ID: "old-container", | |
| 621 | + | Name: "gordon-test.example.com", | |
| 622 | + | Status: "running", | |
| 623 | + | } | |
| 624 | + | route := domain.Route{Domain: "test.example.com", Image: "myapp:v2"} | |
| 625 | + | ||
| 626 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 627 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |
| 628 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 629 | + | runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil) | |
| 630 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 631 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 632 | + | ||
| 633 | + | candidate := &domain.Container{ID: "candidate", Name: "gordon-test.example.com-new", Status: "created"} | |
| 634 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(candidate, nil) | |
| 635 | + | ||
| 636 | + | ctx, cancel := context.WithCancel(testContext()) | |
| 637 | + | runtime.EXPECT().StartContainer(mock.Anything, candidate.ID).Run(func(context.Context, string) { | |
| 638 | + | cancel() | |
| 639 | + | }).Return(nil) | |
| 640 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, candidate.ID).Return(true, nil).Once() | |
| 641 | + | ||
| 642 | + | activeCleanupContext := mock.MatchedBy(func(ctx context.Context) bool { | |
| 643 | + | _, hasDeadline := ctx.Deadline() | |
| 644 | + | return ctx.Err() == nil && hasDeadline | |
| 645 | + | }) | |
| 646 | + | runtime.EXPECT().StopContainer(activeCleanupContext, candidate.ID).Return(nil).Once() | |
| 647 | + | runtime.EXPECT().RemoveContainer(activeCleanupContext, candidate.ID, true).Return(nil).Once() | |
| 648 | + | ||
| 649 | + | result, err := svc.Deploy(ctx, route) | |
| 650 | + | ||
| 651 | + | assert.Nil(t, result) | |
| 652 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 653 | + | tracked, exists := svc.Get(testContext(), route.Domain) | |
| 654 | + | require.True(t, exists) | |
| 655 | + | assert.Equal(t, "old-container", tracked.ID) | |
| 656 | + | } | |
| 657 | + | ||
| 613 | 658 | func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) { | |
| 614 | 659 | runtime := mocks.NewMockContainerRuntime(t) | |
| 615 | 660 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -2492,7 +2537,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 2492 | 2537 | assert.Equal(t, "new-container", tracked.ID) | |
| 2493 | 2538 | } | |
| 2494 | 2539 | ||
| 2495 | - | func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |
| 2540 | + | func TestService_Deploy_OrphanCleanupRemovesRunningStaleCandidate(t *testing.T) { | |
| 2496 | 2541 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2497 | 2542 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2498 | 2543 | eventBus := mocks.NewMockEventPublisher(t) | |
| @@ -2503,7 +2548,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 2503 | 2548 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2504 | 2549 | ctx := testContext() | |
| 2505 | 2550 | ||
| 2506 | - | // Tracked canonical container exists, but stale -new container should be removed. | |
| 2551 | + | // The tracked canonical container is active. A candidate left running by an | |
| 2552 | + | // interrupted request must be removed before its temporary name is reused. | |
| 2507 | 2553 | svc.containers["test.example.com"] = &domain.Container{ | |
| 2508 | 2554 | ID: "tracked-container-123", | |
| 2509 | 2555 | Name: "gordon-test.example.com", | |
| @@ -2524,7 +2570,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 2524 | 2570 | { | |
| 2525 | 2571 | ID: "stale-new-container", | |
| 2526 | 2572 | Name: "gordon-test.example.com-new", | |
| 2527 | - | Status: "exited", | |
| 2573 | + | Status: "running", | |
| 2528 | 2574 | }, | |
| 2529 | 2575 | }, nil) | |
| 2530 | 2576 | runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once() | |
| @@ -610,6 +610,51 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing. | |||||
| 610 | assert.ErrorContains(t, deployErr.Err, "last status: starting") | 610 | assert.ErrorContains(t, deployErr.Err, "last status: starting") | ||
| 611 | } | 611 | } | ||
| 612 | 612 | ||||
| 613 | + | func TestService_Deploy_RequestCancellationStillCleansUpFailedCandidate(t *testing.T) { | |||
| 614 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 615 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 616 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 617 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil) | |||
| 618 | + | ||||
| 619 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 620 | + | ID: "old-container", | |||
| 621 | + | Name: "gordon-test.example.com", | |||
| 622 | + | Status: "running", | |||
| 623 | + | } | |||
| 624 | + | route := domain.Route{Domain: "test.example.com", Image: "myapp:v2"} | |||
| 625 | + | ||||
| 626 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 627 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil) | |||
| 628 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 629 | + | runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:v2").Return(nil, nil) | |||
| 630 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 631 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 632 | + | ||||
| 633 | + | candidate := &domain.Container{ID: "candidate", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 634 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(candidate, nil) | |||
| 635 | + | ||||
| 636 | + | ctx, cancel := context.WithCancel(testContext()) | |||
| 637 | + | runtime.EXPECT().StartContainer(mock.Anything, candidate.ID).Run(func(context.Context, string) { | |||
| 638 | + | cancel() | |||
| 639 | + | }).Return(nil) | |||
| 640 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, candidate.ID).Return(true, nil).Once() | |||
| 641 | + | ||||
| 642 | + | activeCleanupContext := mock.MatchedBy(func(ctx context.Context) bool { | |||
| 643 | + | _, hasDeadline := ctx.Deadline() | |||
| 644 | + | return ctx.Err() == nil && hasDeadline | |||
| 645 | + | }) | |||
| 646 | + | runtime.EXPECT().StopContainer(activeCleanupContext, candidate.ID).Return(nil).Once() | |||
| 647 | + | runtime.EXPECT().RemoveContainer(activeCleanupContext, candidate.ID, true).Return(nil).Once() | |||
| 648 | + | ||||
| 649 | + | result, err := svc.Deploy(ctx, route) | |||
| 650 | + | ||||
| 651 | + | assert.Nil(t, result) | |||
| 652 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 653 | + | tracked, exists := svc.Get(testContext(), route.Domain) | |||
| 654 | + | require.True(t, exists) | |||
| 655 | + | assert.Equal(t, "old-container", tracked.ID) | |||
| 656 | + | } | |||
| 657 | + | ||||
| 613 | func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) { | 658 | func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) { | ||
| 614 | runtime := mocks.NewMockContainerRuntime(t) | 659 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 615 | envLoader := mocks.NewMockEnvLoader(t) | 660 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -2492,7 +2537,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 2492 | assert.Equal(t, "new-container", tracked.ID) | 2537 | assert.Equal(t, "new-container", tracked.ID) | ||
| 2493 | } | 2538 | } | ||
| 2494 | 2539 | ||||
| 2495 | - | func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | 2540 | + | func TestService_Deploy_OrphanCleanupRemovesRunningStaleCandidate(t *testing.T) { |
| 2496 | runtime := mocks.NewMockContainerRuntime(t) | 2541 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 2497 | envLoader := mocks.NewMockEnvLoader(t) | 2542 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 2498 | eventBus := mocks.NewMockEventPublisher(t) | 2543 | eventBus := mocks.NewMockEventPublisher(t) | ||
| @@ -2503,7 +2548,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 2503 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 2548 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 2504 | ctx := testContext() | 2549 | ctx := testContext() | ||
| 2505 | 2550 | ||||
| 2506 | - | // Tracked canonical container exists, but stale -new container should be removed. | 2551 | + | // The tracked canonical container is active. A candidate left running by an |
| 2552 | + | // interrupted request must be removed before its temporary name is reused. | |||
| 2507 | svc.containers["test.example.com"] = &domain.Container{ | 2553 | svc.containers["test.example.com"] = &domain.Container{ | ||
| 2508 | ID: "tracked-container-123", | 2554 | ID: "tracked-container-123", | ||
| 2509 | Name: "gordon-test.example.com", | 2555 | Name: "gordon-test.example.com", | ||
| @@ -2524,7 +2570,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 2524 | { | 2570 | { | ||
| 2525 | ID: "stale-new-container", | 2571 | ID: "stale-new-container", | ||
| 2526 | Name: "gordon-test.example.com-new", | 2572 | Name: "gordon-test.example.com-new", | ||
| 2527 | - | Status: "exited", | 2573 | + | Status: "running", |
| 2528 | }, | 2574 | }, | ||
| 2529 | }, nil) | 2575 | }, nil) | ||
| 2530 | runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once() | 2576 | runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once() | ||