tailscale/tailscale · error

failed to remove finalizer: %w

Error message

failed to remove finalizer: %w

What it means

After cleanup reported done, the reconciler strips the tailscale finalizer from the Ingress and calls a.Update. If that update fails — most commonly a 409 optimistic-lock conflict because the Ingress was modified concurrently (by a controller or kubectl apply), or an RBAC update denial — the finalizer stays and the Ingress cannot be garbage-collected.

Source

Thrown at cmd/k8s-operator/ingress.go:115

	if ix < 0 {
		logger.Debugf("no finalizer, nothing to do")
		a.mu.Lock()
		defer a.mu.Unlock()
		a.managedIngresses.Remove(ing.UID)
		gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
		return nil
	}

	if done, err := a.ssr.Cleanup(ctx, operatorTailnet, logger, childResourceLabels(ing.Name, ing.Namespace, "ingress"), proxyTypeIngressResource); err != nil {
		return fmt.Errorf("failed to cleanup: %w", err)
	} else if !done {
		logger.Debugf("cleanup not done yet, waiting for next reconcile")
		return nil
	}

	ing.Finalizers = append(ing.Finalizers[:ix], ing.Finalizers[ix+1:]...)
	if err := a.Update(ctx, ing); err != nil {
		return fmt.Errorf("failed to remove finalizer: %w", err)
	}

	// Unlike most log entries in the reconcile loop, this will get printed
	// exactly once at the very end of cleanup, because the final step of
	// cleanup removes the tailscale finalizer, which will make all future
	// reconciles exit early.
	logger.Infof("unexposed ingress from tailnet")
	a.mu.Lock()
	defer a.mu.Unlock()
	a.managedIngresses.Remove(ing.UID)
	gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
	return nil
}

// maybeProvision ensures that ing is exposed over tailscale, taking any actions
// necessary to reach that state.
//
// This function adds a finalizer to ing, ensuring that we can handle orderly

View on GitHub (pinned to cfe32b8be6)

Solutions

  1. If conflict: no action — the reconciler logs 'optimistic lock error, retrying' and the requeue retries the finalizer removal
  2. Verify update permission on ingresses.networking.k8s.io for the operator service account
  3. Reduce churn from other controllers writing the Ingress metadata during deletion
  4. If stuck, manually strip the finalizer: kubectl patch ingress <name> -n <ns> --type=json -p='[{"op":"remove","path":"/metadata/finalizers"}]' after confirming proxy resources are gone

Example fix

// before
if err := a.Update(ctx, ing); err != nil {
    return fmt.Errorf("failed to remove finalizer: %w", err)
}

// after (retry once on conflict before surfacing)
if err := a.Update(ctx, ing); apierrors.IsConflict(err) {
    // requeue immediately; someone else wrote the Ingress
    return reconcile.Result{Requeue: true}, nil
} else if err != nil {
    return fmt.Errorf("failed to remove finalizer: %w", err)
}
Defensive patterns

Strategy: retry

Try / catch

if err := a.Update(ctx, ing); err != nil {
    if apierrors.IsConflict(err) {
        // Ingress changed concurrently; requeue rebuilds from a fresh Get
        return reconcile.Result{Requeue: true}, nil
    }
    return fmt.Errorf("failed to remove finalizer: %w", err)
}

Prevention

When it happens

Trigger: a.Update(ctx, ing) right after ing.Finalizers is sliced. Fires on concurrent Ingress writes between the Get at reconcile start and this Update (conflict), missing update permission on ingresses, or apiserver errors. The parent Reconcile already special-cases optimistic-lock errors and retries them.

Common situations: Another controller (cert-manager, Istio, Argo) writing the Ingress at the same moment; kubectl apply racing the deletion; RBAC without ingresses/update.

Related errors


AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15). Data as JSON: /api/errors/138422cee21dc8d5. Report an issue: GitHub.