Files
kube-vip/pkg/metrics
Maximilian Rink cfd86de936 fix(services): start the leader-election loop once per service (#1668)
* fix(lease): do not let a stale cleanup cancel a recreated lease

Every object that starts leader election also starts a goroutine that calls
Manager.Delete once its context is cancelled. Manager.Delete looked the lease up
by name only, so it acted on whatever lease held that name at the time it ran,
not the one the caller was given.

When a service is torn down and rebuilt straight away, the replacement lease is
already registered by the time the old cleanup goroutine runs, so the cleanup
cancels the live replacement and removes it from the manager. The service is
then never handled again: its election loop exits, the lease keeps an empty
holderIdentity, and the VIP is never re-advertised.

This is reachable from an ordinary service update. Flipping
externalTrafficPolicy makes serviceChanged cancel the service context and
rebuild it, which reproduced the stuck lease for minutes.

Pass the lease the caller owns to Manager.Delete and ignore a stale caller,
which keeps cleanup scoped to the instance it belongs to. Callers already hold
that lease. Passing nil keeps the previous behaviour of deleting whichever lease
currently holds the name, which is what the existing tests assert.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* fix(lease): retire a lease when its service is torn down

The instance guard in Delete stops a late cleanup from cancelling a replacement
lease, but it cannot help when the replacement *is* the same instance.

A service teardown cancels the service context and leaves the lease registered,
because the cleanup that removes it is deferred to a goroutine. The rebuild that
follows calls Add, which finds that lease still in the map and hands it straight
back, so the new service context is parented to a lease the pending cleanup is
about to cancel. The service then cycles: acquire, lose, re-acquire, every few
seconds, and never settles.

Add Manager.Retire, and call it from the serviceChanged teardown next to the
svcMap purge, so the lease is out of the map before the replacement context is
built. Add also refuses to hand out a lease whose context is already cancelled,
which closes the same hazard for any other path that cancels a lease directly.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* fix(lease): keep a common lease alive for the services still using it

Review feedback from Patryk on #1669: Retire cancelled the lease context
outright, so with a common lease a modification of one service would also tear
down every sibling sharing that lease.

Retire was only ever needed for its side effect of getting the lease out of the
map before the rebuild, and Delete already does exactly that once the last
object is gone. Drop Retire and have the teardown path call Delete with its own
object name, so siblings keep the lease alive and the manager API stays
Add/Delete/Get.

TestManager_LeaseLifetimeInvariant replaces the single-scenario test with the
rule for the whole surface: a lease stays usable for exactly as long as at least
one object holds it, and a rebuild afterwards gets a fresh one. It is table
driven over 1, 2 and 4 objects, and the 2 and 4 cases fail against the reviewed
behaviour with "lease was cancelled with N object(s) still holding it".

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* fix(services): start the leader-election loop once per service - #1665

startLeaderElection restarts itself until the service context is cancelled,
so it only needs to be started once per service lifetime. It was started from
startServiceHandlingIfNeeded on every AddOrModify call instead, and AddOrModify
runs on every EndpointSlice event, so endpoint churn accumulated duplicate
permanent loops for the same service, all contending on the same lease.

Guard the spawn with a sync.Once on the service context. A Once needs no
clearing: the loop is bound to the service context, which is replaced whenever
the service is recreated.

Also drop the les *atomic.Int64 parameter, which was only ever incremented.

Adds a unit test that drives AddOrModify three times and asserts the loop
starts once (fails pre-fix with 3), and an -endpointFlap service e2e test that
flaps the backend 1->0->1 five times and asserts the VIP serves traffic and the
lease has a holder again.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): fault leases and API server access in the e2e test - #1665

The endpointFlap e2e test only asserted that the service recovered after
endpoint churn, which the unfixed code also satisfies, so it did not prove
anything about the reported failure.

Inject faults that actually stress the per-service leader election and assert
convergence after each one:

- endpoint churn: backend scaled 1->0->1 five times
- lease faults: the lease is deleted, then its holderIdentity is blanked, which
  is the exact state reported in the issue
- API server faults: the apiserver is blocked from the leader for longer than
  the lease duration, so its election client loses its backend, then restored

The assertions are on the feature contract, a held lease and a served VIP,
rather than on election internals, so they stay meaningful if the
implementation changes. Duplicate loops are not observable from outside the
process; that part stays pinned by the unit test.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): assert election faults recover, via new election metrics - #1665

The previous e2e test only asserted the service recovered, which the unfixed
code also satisfies, so it proved nothing. The duplicate election loops are not
observable from outside the process, so there was nothing to assert on.

Export the missing state as metrics, replacing the write-only les *atomic.Int64
that used to sit in startLeaderElection with real instrumentation:

- kube_vip_service_election_loops{namespace,name}: live election loops, tracked
  for the lifetime of the goroutine. More than 1 per service means loops leaked.
- kube_vip_service_election_attempts_total{namespace,name}: election attempts,
  so a wedged restart loop is visible as a counter that stops advancing.
- kube_vip_service_election_errors_total{namespace,name,reason}: election
  failures, with reason="no_lease" for the service context and lease manager
  desync.

The e2e test now injects a fault per reported failure mode and asserts a signal
that is actually broken when that bug is present:

- endpoint churn, five debounce-separated flaps (#1665): loops stay <= 1
- endpointslice deletion (#1663 / #1664): no reason="no_lease" errors
- lease deletion and blanked holderIdentity (#1650): attempts keep advancing
- apiserver blocked from the leader past the lease duration: same loss path

Every fault is also followed by the feature contract: a held lease and a VIP
that serves traffic. The unit test asserts the loop gauge alongside the call
count so both layers agree.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): assert the election error counter settles, not that it is zero

Both the fixed and the unfixed branch recorded one reason="no_lease" increment
while the service was first set up, so asserting the counter is zero failed on
correct code too. The #1664 desync makes the counter climb for the lifetime of
the process, so assert it stops growing instead.

Also sample the election loop gauge a few times rather than once, since a loop
that is about to start may not be visible in a single scrape while a leaked one
never goes away.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): assert election progress after a real leadership loss

Deleting the lease object does not make the election client lose leadership, it
just recreates the lease on the next renew, so no new election attempt follows
and the progress assertion failed on correct code.

Move that assertion to the apiserver partition, which is the fault that actually
drives OnStoppedLeading and returns the election, and is therefore where the
#1650 WaitGroup deadlock would wedge the restart loop. The lease object faults
keep asserting convergence only.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): cover the remaining election fault scenarios - #1665

The suite only faulted the paths that the three known bugs live on. Add the
neighbouring ones so a regression in this subsystem is caught wherever it lands:

- VIP release on zero endpoints: with a local traffic policy and no endpoints
  anywhere, the address has to stop answering instead of black-holing traffic.
  Nothing asserted the yield half of the endpoint churn cycle before.
- externalTrafficPolicy flip: makes serviceChanged cancel the service context and
  drop it from svcMap, so the next event has to build a fresh context and lease.
  Same desync class as the endpointslice fault, reached by a different trigger.
- service event storm: 15 annotation patches, driving the spawn-once invariant
  from the service watch instead of the endpoint watch.
- follower partition: cutting a non-leader off from the apiserver must not move
  the lease, stop traffic, or leak a loop on the node that comes back.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): let the election loop count settle before asserting it

A fault that rebuilds the service context, such as the externalTrafficPolicy
flip, legitimately has the old and the new loop alive at the same moment, so a
single scrape saw 2 loops on correct code.

Poll until the count settles instead. A leaked loop only exits with its service
context, which outlives the test, so it never settles and is still caught: the
unfixed code reports 11 loops after endpoint flapping.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): fault the externalTrafficPolicy teardown path

With the lease retirement fix underneath, a service that is torn down and
rebuilt by a traffic policy change converges again, so this fault can be
asserted like the others: at most one election loop, a settled error counter, a
held lease and a served VIP.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): stop the fault suite exhausting the client rate limiter

The fault tests poll the API while waiting for convergence, and by the last
fault the shared clientset had spent client-go's default 5 QPS budget, so a
lease read failed with

    failed to get lease "kubevip-kube-vip-service": client rate limiter Wait
    returned an error: context deadline exceeded

which looked like a convergence failure but was the test's own client giving up.
Raise QPS for the harness and halve the lease polling rate.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

* test(services): fault a common lease sibling teardown

Cluster-level cover for the case Patryk raised in review of #1669: two services
share one lease, the first is deleted, and the second has to keep serving on that
same lease. A teardown that cancels the lease instead of just releasing the
leaving service takes the sibling down with it.

Adds a commonLease field to the Service helper, which sets the
kube-vip.io/serviceLease annotation along with the cluster traffic policy that a
common lease requires. Also initialises the annotation map unconditionally: it
was only allocated on the egress path, so setting any other annotation first
would have nil-panicked.

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>

---------

Signed-off-by: Maximilian Rink <maximilian.rink@telekom.de>
2026-08-06 21:48:59 +02:00
..