Skip to content

Commit dfa9723

Browse files
fix: add additional safeguards to address persistent old Watchtower containers (#1743)
- Add `CleanupOldWatchtowerContainers` to remove lingering old Watchtower containers during each update cycle, catching those missed at startup - Add self-detection in `Update` to stop old Watchtower containers by setting restart policy to "no" and returning early - Add `ExcludeOldWatchtowerFilter` to prevent old containers from being updated or included in update cycles - Move `WatchtowerOldPrefix` to `pkg/types` as single source of truth and rename `IsOldNamedContainer` to `IsOldContainer` for clarity - Enhance `ShouldExitDueToInvalidRestart` to detect old container by name prefix in addition to container chain lineage - Update comments, log messages, and notification strings from "instances" to "containers" for consistency
1 parent 14e1090 commit dfa9723

17 files changed

Lines changed: 1133 additions & 59 deletions

File tree

cmd/root.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,33 @@ func preRun(cmd *cobra.Command, _ []string) {
443443
logrus.Info(
444444
"Detected invalid restart of old Watchtower container, stopping Watchtower container now",
445445
)
446+
447+
if currentWatchtowerContainer != nil {
448+
updateConfig := dockerContainer.UpdateConfig{
449+
RestartPolicy: dockerContainer.RestartPolicy{
450+
Name: "no",
451+
},
452+
}
453+
454+
ctx, cancel := context.WithTimeout(
455+
context.Background(),
456+
containerLookupTimeout,
457+
)
458+
defer cancel()
459+
460+
err := client.UpdateContainer(
461+
ctx,
462+
currentWatchtowerContainer,
463+
updateConfig,
464+
)
465+
if err != nil {
466+
logrus.WithError(err).
467+
Warn("Failed to update restart policy to 'no' for old Watchtower container")
468+
} else {
469+
logrus.Debug("Updated restart policy to 'no' for old Watchtower container")
470+
}
471+
}
472+
446473
logrus.Exit(0)
447474
}
448475

@@ -722,7 +749,7 @@ func runMain(cfg types.RunConfig) int {
722749
logrus.Warn("Current container not cached for cleanup")
723750
}
724751

725-
// Check for and cleanup excess Watchtower instances within scope.
752+
// Check for and cleanup old Watchtower containers within scope.
726753
totalRemovedInstances, err := actions.RemoveExcessWatchtowerInstances(
727754
ctx,
728755
client,
@@ -736,7 +763,7 @@ func runMain(cfg types.RunConfig) int {
736763
// The old container may still be stopping; forcing exit would leave
737764
// no Watchtower running. Continuing ensures the new instance operates
738765
// even if the old container couldn't be fully cleaned up.
739-
logrus.WithError(err).Warn("Failed to clean up excess Watchtower instances, continuing anyway")
766+
logrus.WithError(err).Warn("Failed to clean up old Watchtower containers, continuing anyway")
740767
}
741768

742769
// Check for and cleanup orphaned ephemeral orchestrator containers.
@@ -762,7 +789,7 @@ func runMain(cfg types.RunConfig) int {
762789
if cleanupOccurred {
763790
cfg.UpdateOnStart = false
764791

765-
logrus.Debug("Disabled update-on-start due to cleanup of excess Watchtower instances")
792+
logrus.Debug("Disabled update-on-start due to cleanup of old Watchtower containers")
766793
}
767794

768795
// Configure and start the HTTP API, handling any startup errors.

internal/actions/cleanup.go

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ const maxRemovalAttempts = 30
2929
// RemovalRetryDelay sets the delay before retrying removal operations.
3030
var RemovalRetryDelay = 1 * time.Second
3131

32-
// RemoveExcessWatchtowerInstances ensures a single Watchtower instance within the same scope.
32+
// RemoveExcessWatchtowerInstances ensures a single Watchtower container within the same scope.
3333
//
3434
// It identifies multiple Watchtower containers within the same scope, stops all but the current,
35-
// and collects removed images for deferred removal if enabled, preventing conflicts from concurrent instances.
35+
// and collects removed images for deferred removal if enabled, preventing conflicts from concurrent containers.
3636
// Chain identification uses the current container's labels to determine old containers to remove.
3737
// Scoped instances only remove other instances in the same scope, allowing coexistence with different scopes.
3838
// Removal operations respect scope boundaries to prevent cross-scope interference.
@@ -41,12 +41,12 @@ var RemovalRetryDelay = 1 * time.Second
4141
// - ctx: Context for cancellation and timeouts.
4242
// - client: Container client for Docker operations.
4343
// - cleanupImages: Remove images if true.
44-
// - watchtowerScope: Scope to filter Watchtower instances.
45-
// - removeImageInfos: Pointer to slice of images to remove after stopping excess instances.
44+
// - watchtowerScope: Scope to filter Watchtower containers.
45+
// - removeImageInfos: Pointer to slice of images to remove after stopping excess containers.
4646
// - currentContainer: The current running Watchtower container.
4747
//
4848
// Returns:
49-
// - int: Number of removed Watchtower instances.
49+
// - int: Number of removed Watchtower containers.
5050
// - error: Non-nil if removal fails, nil if single instance or successful removal.
5151
func RemoveExcessWatchtowerInstances(
5252
ctx context.Context,
@@ -66,15 +66,15 @@ func RemoveExcessWatchtowerInstances(
6666

6767
return ""
6868
}(),
69-
}).Debug("Starting removal of excess Watchtower instances")
69+
}).Debug("Starting removal of excess Watchtower containers")
7070

7171
// List all containers to find excess instances
7272
allContainers, err := client.ListContainers(ctx, filters.NoFilter)
7373
if err != nil {
7474
return 0, fmt.Errorf("failed to list containers: %w", err)
7575
}
7676

77-
// Retrieve containers that are excess Watchtower instances within the same scope
77+
// Retrieve containers that are excess Watchtower containers within the same scope
7878
excessWatchtowerContainers := getExcessContainers(
7979
scope,
8080
currentContainer,
@@ -104,6 +104,134 @@ func RemoveExcessWatchtowerInstances(
104104
return removed, nil
105105
}
106106

107+
// CleanupOldWatchtowerContainers removes old Watchtower containers that
108+
// linger from a previous self-update. Unlike RemoveExcessWatchtowerInstances
109+
// (which runs once at startup), this is designed to be called during each update
110+
// cycle to catch any old containers that the startup cleanup may have missed.
111+
//
112+
// It identifies containers matching the watchtower-old- prefix within the same
113+
// scope as the current container, stops them, and optionally cleans up their
114+
// images. This ensures that even if an old container survives the initial
115+
// cleanup (e.g., it was still stopping), it won't persist across update cycles.
116+
//
117+
// Parameters:
118+
// - ctx: Context for cancellation and timeouts.
119+
// - client: Container client for Docker operations.
120+
// - cleanupImages: Remove images if true.
121+
// - scope: Scope to filter Watchtower containers (empty for unscoped).
122+
// - currentContainerID: ID of the currently running Watchtower container.
123+
// - removeImageInfos: Pointer to slice of images to remove after stopping old containers.
124+
//
125+
// Returns:
126+
// - int: Number of removed old Watchtower containers.
127+
// - error: Non-nil if removal fails, nil if none found or successful removal.
128+
func CleanupOldWatchtowerContainers(
129+
ctx context.Context,
130+
client container.Client,
131+
cleanupImages bool,
132+
scope string,
133+
currentContainerID types.ContainerID,
134+
removeImageInfos *[]types.RemovedImageInfo,
135+
) (int, error) {
136+
logrus.WithFields(logrus.Fields{
137+
"scope": scope,
138+
"current_id": currentContainerID,
139+
"cleanup_images": cleanupImages,
140+
}).Debug("Checking for old Watchtower containers")
141+
142+
// Normalize empty scope to "none" for consistent comparison.
143+
if scope == "" {
144+
scope = "none"
145+
}
146+
147+
// List all containers to find old instances
148+
allContainers, err := client.ListContainers(ctx, filters.NoFilter)
149+
if err != nil {
150+
return 0, fmt.Errorf("failed to list containers: %w", err)
151+
}
152+
153+
// Find old Watchtower containers within the same scope
154+
var oldContainers []types.Container
155+
156+
// Iterate all containers to find old Watchtower containers that
157+
// should be cleaned up. Non-Watchtower containers and containers with
158+
// normal names are skipped immediately.
159+
for _, c := range allContainers {
160+
// Skip non-Watchtower containers
161+
if !c.IsWatchtower() {
162+
continue
163+
}
164+
165+
// Skip containers that are not old predecessors
166+
if !container.IsOldContainer(c.Name()) {
167+
continue
168+
}
169+
170+
// Scope check: only clean up old containers in the same scope
171+
containerScope, containerHasScope := c.Scope()
172+
if !containerHasScope || containerScope == "" {
173+
containerScope = "none"
174+
}
175+
176+
if containerScope != scope {
177+
logrus.WithFields(logrus.Fields{
178+
"container": c.Name(),
179+
"container_scope": containerScope,
180+
"current_scope": scope,
181+
}).Debug("Skipping old Watchtower container in different scope")
182+
183+
continue
184+
}
185+
186+
// The updater's self-detection handles the case where the current
187+
// container is old (it exits before reaching this point), but
188+
// we still guard here for safety in case this function is called from
189+
// a different code path.
190+
if c.ID() == currentContainerID {
191+
continue
192+
}
193+
194+
oldContainers = append(oldContainers, c)
195+
}
196+
197+
if len(oldContainers) == 0 {
198+
logrus.Debug("No old Watchtower containers found")
199+
200+
return 0, nil
201+
}
202+
203+
logrus.WithFields(logrus.Fields{
204+
"count": len(oldContainers),
205+
"containers": containerNames(oldContainers),
206+
}).Info("Found old Watchtower containers, cleaning up")
207+
208+
// Find the current container in the list so image collection works
209+
var currentContainerObj types.Container
210+
211+
for _, c := range allContainers {
212+
if c.ID() == currentContainerID {
213+
currentContainerObj = c
214+
215+
break
216+
}
217+
}
218+
219+
// Reuse the existing removal logic for old containers
220+
removed, err := removeExcessContainers(
221+
ctx,
222+
client,
223+
oldContainers,
224+
cleanupImages,
225+
currentContainerObj,
226+
removeImageInfos,
227+
)
228+
if err != nil {
229+
return removed, err
230+
}
231+
232+
return removed, nil
233+
}
234+
107235
// getExcessContainers retrieves a list of excess Watchtower containers that should be removed.
108236
//
109237
// It identifies containers that are duplicates within the same scope or part of a container chain,
@@ -186,9 +314,9 @@ func getFilteredContainers(
186314
var excessContainers []types.Container
187315

188316
currentID := string(currentContainer.ID())
189-
if container.IsOldNamedContainer(currentContainer.Name()) {
190-
// Detection selected an old-named predecessor. Resolve the true
191-
// successor by lineage: prefer a non-old-named container whose
317+
if container.IsOldContainer(currentContainer.Name()) {
318+
// Detection selected an old predecessor. Resolve the true
319+
// successor by lineage: prefer a non-old container whose
192320
// chain label contains the old container's ID, confirming it is
193321
// the direct successor in the self-update chain.
194322
currentScope, currentHasScope := currentContainer.Scope()
@@ -200,7 +328,7 @@ func getFilteredContainers(
200328
scopeMatchID := ""
201329

202330
for _, c := range filteredContainers {
203-
if container.IsOldNamedContainer(c.Name()) {
331+
if container.IsOldContainer(c.Name()) {
204332
continue
205333
}
206334

@@ -276,9 +404,9 @@ func getChainedContainers(
276404
var chainedContainers []types.Container
277405

278406
effectiveCurrent := currentContainer
279-
if currentContainer != nil && container.IsOldNamedContainer(currentContainer.Name()) {
280-
// Detection selected old-named. Resolve the true successor by
281-
// lineage: prefer a non-old-named Watchtower container whose chain
407+
if currentContainer != nil && container.IsOldContainer(currentContainer.Name()) {
408+
// Detection selected old. Resolve the true successor by
409+
// lineage: prefer a non-old Watchtower container whose chain
282410
// label contains the old container's ID. Fall back to scope-only
283411
// matching if no explicit lineage link exists.
284412
currentScope, currentHasScope := currentContainer.Scope()
@@ -289,7 +417,7 @@ func getChainedContainers(
289417
chainMatch := false
290418

291419
for _, c := range allContainers {
292-
if !c.IsWatchtower() || container.IsOldNamedContainer(c.Name()) {
420+
if !c.IsWatchtower() || container.IsOldContainer(c.Name()) {
293421
continue
294422
}
295423

@@ -544,7 +672,7 @@ func removeExcessContainers(
544672
}
545673

546674
logrus.WithField("removed_instances", excessInstancesRemoved).
547-
Info("Successfully removed all excess Watchtower instances")
675+
Info("Successfully removed all excess Watchtower containers")
548676

549677
return excessInstancesRemoved, nil
550678
}

0 commit comments

Comments
 (0)