Skip to content

L2Status controller: refactor to leader node manages resources - #2938

Merged
oribon merged 1 commit into
metallb:mainfrom
oribon:another_status_fix
Jan 28, 2026
Merged

oribon merged 1 commit into
metallb:mainfrom
oribon:another_status_fix

Conversation

@oribon

@oribon oribon commented Jan 26, 2026 •

Copy link
Copy Markdown
Member

Previously each controller managed only its own resources, allowing:

  1. nodes could potentially "steal" eachother's resources
    (a bug caused by the controller using the wrong index)
  2. on abrupt shutdown of a node its resource stays dangling
    (because the pod is never deleted until next boot, ownerref
    gc doesn't kick in)

Here, we refactor the logic so that the leader node for a service
makes sure there is only one resource for the service (that it manages).
This solves (1) because the leader enforces only one resource,
and solves (2) because if a new leader is elected for the service
(through memberlist) then it deletes the dangling resource.

In the case a leader node is abruptly shutdown but it can be the only
node announcing the service (e.g due to nodeSelectors on the L2Adv)
we would still have a dangling resource.

Is this a BUG FIX or a FEATURE ?:

Uncomment only one, leave it on its own line:

/kind bug

/kind cleanup
/kind feature
/kind design
/kind flake
/kind failing
/kind documentation
/kind regression

What this PR does / why we need it:
Attempts to fix #2930
Special notes for your reviewer:

Release note:

L2Status controller: fix a scenario where two speakers try to modify the same status resource

@oribon
oribon requested a review from fedepaol as a code owner January 26, 2026 08:45
@oribon oribon changed the title L2Status controller: add node name to index WIP: L2Status controller: add node name to index Jan 26, 2026
@oribon oribon changed the title WIP: L2Status controller: add node name to index L2Status controller: add node name to index Jan 26, 2026
@pavel-jancik

Copy link
Copy Markdown

Thanks you started to work on fix so fast.

I believe you found the root cause of the issue. Yesterday I spot an ServiceL2Status instance with 2 owning pods on one of mine clusters.

@andreaskaris

Copy link
Copy Markdown
Contributor

@oribon Can we somehow add an E2E test for that .. or at the bare minimum a unit test?

@oribon
oribon force-pushed the another_status_fix branch 2 times, most recently from beceb5b to 1aedeaa Compare January 27, 2026 10:50
@oribon

oribon commented Jan 27, 2026

Copy link
Copy Markdown
Member Author

@andreaskaris added

@andreaskaris

andreaskaris commented Jan 27, 2026 •

Copy link
Copy Markdown
Contributor

So I tinkered with this a little bit, with:

inv dev-env -p layer2
kubectl edit ds -n metallb-system speaker
# switch log-level to debug
deployment.yaml:apiVersion: apps/v1
deployment.yaml:kind: Deployment
deployment.yaml:metadata:
deployment.yaml:  labels:
deployment.yaml:    testid: external-local-lb
deployment.yaml:  name: external-local-lb
deployment.yaml:  namespace: l2
deployment.yaml:spec:
deployment.yaml:  replicas: 1
deployment.yaml:  selector:
deployment.yaml:    matchLabels:
deployment.yaml:      testid: external-local-lb
deployment.yaml:  template:
deployment.yaml:    metadata:
deployment.yaml:      labels:
deployment.yaml:        testid: external-local-lb
deployment.yaml:    spec:
deployment.yaml:      containers:
deployment.yaml:      - args:
deployment.yaml:        - netexec
deployment.yaml:        - --http-port=80
deployment.yaml:        - --udp-port=80
deployment.yaml:        image: registry.k8s.io/e2e-test-images/agnhost:2.45
deployment.yaml:        imagePullPolicy: IfNotPresent
deployment.yaml:        name: netexec
deployment.yaml:        readinessProbe:
deployment.yaml:          failureThreshold: 3
deployment.yaml:          httpGet:
deployment.yaml:            path: /hostName
deployment.yaml:            port: 80
deployment.yaml:            scheme: HTTP
deployment.yaml:          periodSeconds: 3
deployment.yaml:          successThreshold: 1
deployment.yaml:          timeoutSeconds: 1
deployment.yaml:        resources: {}
deployment.yaml:        terminationMessagePath: /dev/termination-log
deployment.yaml:        terminationMessagePolicy: File
deployment.yaml:      dnsPolicy: ClusterFirst
deployment.yaml:      restartPolicy: Always
deployment.yaml:      schedulerName: default-scheduler
deployment.yaml:      securityContext: {}
deployment.yaml:      terminationGracePeriodSeconds: 3
ipaddresspool.yaml:apiVersion: metallb.io/v1beta1
ipaddresspool.yaml:kind: IPAddressPool
ipaddresspool.yaml:metadata:
ipaddresspool.yaml:  name: l2-test
ipaddresspool.yaml:  namespace: metallb-system
ipaddresspool.yaml:spec:
ipaddresspool.yaml:  addresses:
ipaddresspool.yaml:  - 172.18.0.9-172.18.0.19
ipaddresspool.yaml:  - fc00:f853:ccd:e793::9-fc00:f853:ccd:e793::13
ipaddresspool.yaml:  autoAssign: true
ipaddresspool.yaml:  avoidBuggyIPs: false
l2adv.yaml:apiVersion: metallb.io/v1beta1
l2adv.yaml:kind: L2Advertisement
l2adv.yaml:metadata:
l2adv.yaml:  name: empty
l2adv.yaml:  namespace: metallb-system
l2adv.yaml:spec: {}
service.yaml:apiVersion: v1
service.yaml:kind: Service
service.yaml:metadata:
service.yaml:  name: external-local-lb
service.yaml:  namespace: l2
service.yaml:spec:
service.yaml:  allocateLoadBalancerNodePorts: true
service.yaml:  externalTrafficPolicy: Cluster
service.yaml:  internalTrafficPolicy: Cluster
service.yaml:  ipFamilies:
service.yaml:  - IPv4
service.yaml:  ipFamilyPolicy: SingleStack
service.yaml:  ports:
service.yaml:  - nodePort: 30622
service.yaml:    port: 80
service.yaml:    protocol: TCP
service.yaml:    targetPort: 80
service.yaml:  selector:
service.yaml:    testid: external-local-lb
service.yaml:  sessionAffinity: None
service.yaml:  type: LoadBalancer
kubectl delete l2advertisement -n metallb-system   empty
kubectl delete ipaddresspool -n metallb-system   dev-env-l2
sleep 5
kubectl create ns l2
kubectl apply -f .

Check with:

curl 172.18.0.9
kubectl get servicel2status -A

Then, stop the worker that the l2advertisement originates from:

docker stop kind-worker

With the fix, it looks like no cleanup is happening (expected, according to the code):

[akaris@metallb l2test]$ kubectl get servicel2status -A
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qf627   kind-worker      external-local-lb   l2
metallb-system   l2-xwfrh   kind-worker2     external-local-lb   l2
[akaris@metallb l2test]$ sleep 600; kubectl get servicel2status -A
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qf627   kind-worker      external-local-lb   l2
metallb-system   l2-xwfrh   kind-worker2     external-local-lb   l2

@oribon oribon changed the title L2Status controller: add node name to index WIP: L2Status controller: add node name to index Jan 27, 2026
@oribon
oribon force-pushed the another_status_fix branch from 5b4c2d1 to 0e1a968 Compare January 27, 2026 14:33
@andreaskaris

Copy link
Copy Markdown
Contributor

I checked with the old code, and there are 2 scenarios. Note that in both scenarios, because it's a hard shutdown, the speaker pod on the shut down worker node is not evacuated (but the node reports as NotReady). Therefore, the API server will not clean up via ownerReference.

In both scenarios, the bug is triggered, and the servicel2status gets 2 ownerreferences (old and new speaker pod), and then tries to update the immutable .status.node field without success.

Difference between scenario a) and b):
In scenario a) , the layer2controller's failover works instantaneously, and the bug is persisted.
In scenario b), the layer2controller does for some reason not do a clean failover, the service is unreachable via curl. When the node goes NotReady, something triggers the layer2controller again which now notices that something is wrong and it withdraws the service announcement. That's communicated to the layer2statusreconciler which pulls the existing, buggy servicel2status. After that CR is gone, a bit more than 5 mins later, the layer2_controller is triggered again, it announces the L2 IP, and because the original, buggy servicel2status was removed earlier, a new, clean one can now be created.

a) Instantaneous failover, bug reproduced, no cleanup:

[akaris@metallb metallb ((dcbb668ca...))]$ date; docker stop kind-worker
Tue Jan 27 07:36:10 AM EST 2026
kind-worker
$ while true; do echo "=== $(date) ==="; kubectl get servicel2status -A; echo ---; curl --connect-ti
meout 2 172.18.0.9; echo; kubectl get pods -n metallb-system -o wide; kubectl get nodes; echo; sleep 3; done
=== Tue Jan 27 08:51:05 AM EST 2026 ===
No resources found
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          111s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          74s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          32s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          53s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m19s   v1.31.0
kind-worker          Ready    <none>          2m8s    v1.31.0
kind-worker2         Ready    <none>          2m8s    v1.31.0

=== Tue Jan 27 08:51:10 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:10.997173026 +0000 UTC m=+1.371377776
NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          115s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          78s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          36s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          57s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m23s   v1.31.0
kind-worker          Ready    <none>          2m12s   v1.31.0
kind-worker2         Ready    <none>          2m12s   v1.31.0

=== Tue Jan 27 08:51:14 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:14.120086799 +0000 UTC m=+4.494291547
NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          118s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          81s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          39s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          60s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m26s   v1.31.0
kind-worker          Ready    <none>          2m15s   v1.31.0
kind-worker2         Ready    <none>          2m15s   v1.31.0

=== Tue Jan 27 08:51:17 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m3s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          86s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          44s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          65s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m31s   v1.31.0
kind-worker          Ready    <none>          2m20s   v1.31.0
kind-worker2         Ready    <none>          2m20s   v1.31.0

=== Tue Jan 27 08:51:22 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:22.377243747 +0000 UTC m=+12.751448496
NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m6s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          89s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          47s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          68s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m34s   v1.31.0
kind-worker          Ready    <none>          2m23s   v1.31.0
kind-worker2         Ready    <none>          2m23s   v1.31.0

=== Tue Jan 27 08:51:25 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:25.494884602 +0000 UTC m=+15.869089345
NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m9s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          92s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          50s    172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          71s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m37s   v1.31.0
kind-worker          Ready    <none>          2m26s   v1.31.0
kind-worker2         Ready    <none>          2m26s   v1.31.0

=== Tue Jan 27 08:51:28 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:28.619004173 +0000 UTC m=+18.993208920
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m12s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          95s     172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          53s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          74s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m40s   v1.31.0
kind-worker          Ready    <none>          2m29s   v1.31.0
kind-worker2         Ready    <none>          2m29s   v1.31.0

=== Tue Jan 27 08:51:31 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:31.740421327 +0000 UTC m=+22.114626076
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m15s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          98s     172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          56s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          77s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m43s   v1.31.0
kind-worker          Ready    <none>          2m32s   v1.31.0
kind-worker2         Ready    <none>          2m32s   v1.31.0

=== Tue Jan 27 08:51:34 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:34.862689396 +0000 UTC m=+25.236894145
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m18s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          101s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          59s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          80s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m46s   v1.31.0
kind-worker          Ready    <none>          2m35s   v1.31.0
kind-worker2         Ready    <none>          2m35s   v1.31.0

=== Tue Jan 27 08:51:37 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:37.983518259 +0000 UTC m=+28.357723003
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m22s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          105s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          63s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          84s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m50s   v1.31.0
kind-worker          Ready    <none>          2m39s   v1.31.0
kind-worker2         Ready    <none>          2m39s   v1.31.0

=== Tue Jan 27 08:51:41 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:41.11117535 +0000 UTC m=+31.485380098
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m25s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          108s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          66s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          87s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m53s   v1.31.0
kind-worker          Ready    <none>          2m42s   v1.31.0
kind-worker2         Ready    <none>          2m42s   v1.31.0

=== Tue Jan 27 08:51:44 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:44.236895897 +0000 UTC m=+34.611100652
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m28s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          111s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          69s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          90s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m56s   v1.31.0
kind-worker          Ready    <none>          2m45s   v1.31.0
kind-worker2         Ready    <none>          2m45s   v1.31.0

=== Tue Jan 27 08:51:47 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:47.357976142 +0000 UTC m=+37.732180886
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m31s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          114s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          72s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          93s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m59s   v1.31.0
kind-worker          Ready    <none>          2m48s   v1.31.0
kind-worker2         Ready    <none>          2m48s   v1.31.0

=== Tue Jan 27 08:51:50 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:50.480792146 +0000 UTC m=+40.854996894
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m34s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          117s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          75s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          96s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   3m2s    v1.31.0
kind-worker          Ready    <none>          2m51s   v1.31.0
kind-worker2         Ready    <none>          2m51s   v1.31.0

=== Tue Jan 27 08:51:53 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:53.606733653 +0000 UTC m=+43.980938396
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m37s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          2m      172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          78s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          99s     172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   3m5s    v1.31.0
kind-worker          Ready    <none>          2m54s   v1.31.0
kind-worker2         Ready    <none>          2m54s   v1.31.0

=== Tue Jan 27 08:51:56 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-qjx55   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:51:56.741105413 +0000 UTC m=+47.115310159
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-pz446   1/1     Running   0          2m40s   10.244.1.2   kind-worker          <none>           <none>
speaker-7995k                4/4     Running   0          2m3s    172.18.0.2   kind-control-plane   <none>           <none>
speaker-fnhqq                4/4     Running   0          81s     172.18.0.3   kind-worker          <none>           <none>
speaker-nbmq6                4/4     Running   0          102s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS     ROLES           AGE     VERSION
kind-control-plane   Ready      control-plane   3m8s    v1.31.0
kind-worker          NotReady   <none>          2m57s   v1.31.0
kind-worker2         Ready      <none>          2m57s   v1.31.0

Labels show the new node, and 2 owner references, whereas the status was not updated:

[akaris@metallb metallb ((dcbb668ca...))]$ kubectl get servicel2status -n metallb-system l2-qjx55 -o y
aml
apiVersion: metallb.io/v1beta1
kind: ServiceL2Status
metadata:
  creationTimestamp: "2026-01-27T13:51:09Z"
  generateName: l2-
  generation: 1
  labels:
    metallb.io/node: kind-worker2
    metallb.io/service-name: external-local-lb
    metallb.io/service-namespace: l2
  name: l2-qjx55
  namespace: metallb-system
  ownerReferences:
  - apiVersion: v1
    kind: Pod
    name: speaker-fnhqq
    uid: 98eb5c23-81ae-4f0a-9b5e-70e7943b8eba
  - apiVersion: v1
    kind: Pod
    name: speaker-nbmq6
    uid: d2908f89-e9f6-43b6-a082-a30fd65f004d
  resourceVersion: "1129"
  uid: 6999197e-3aaa-4562-a713-1c09b6db8a66
spec: {}
status:
  node: kind-worker
  serviceName: external-local-lb
  serviceNamespace: l2

b) Failover very slow - however, we have resource cleanup, as soon as the node goes into NotReady state:

[akaris@metallb l2test]$ while true; do echo "=== $(date) ==="; kubectl get servicel2status -A; echo 
---; curl --connect-timeout 2 172.18.0.9; echo; kubectl get pods -n metallb-system -o wide; kubectl g
et nodes; echo; sleep 3; done
=== Tue Jan 27 08:36:22 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:36:22.632581262 +0000 UTC m=+2.510370847
NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          80s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          48s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          48s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          48s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   109s   v1.31.0
kind-worker          Ready    <none>          98s    v1.31.0
kind-worker2         Ready    <none>          98s    v1.31.0

=== Tue Jan 27 08:36:25 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:36:25.757582034 +0000 UTC m=+5.635371619
NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          83s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          51s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          51s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          51s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   112s   v1.31.0
kind-worker          Ready    <none>          101s   v1.31.0
kind-worker2         Ready    <none>          101s   v1.31.0

=== Tue Jan 27 08:36:28 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:36:28.88796809 +0000 UTC m=+8.765757682
NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          86s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          54s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          54s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          54s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   115s   v1.31.0
kind-worker          Ready    <none>          104s   v1.31.0
kind-worker2         Ready    <none>          104s   v1.31.0

=== Tue Jan 27 08:36:31 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 13:36:32.016219436 +0000 UTC m=+11.894009028
NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          90s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          58s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          58s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          58s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   119s   v1.31.0
kind-worker          Ready    <none>          108s   v1.31.0
kind-worker2         Ready    <none>          108s   v1.31.0

=== Tue Jan 27 08:36:35 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          95s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          63s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          63s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          63s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   2m4s   v1.31.0
kind-worker          Ready    <none>          113s   v1.31.0
kind-worker2         Ready    <none>          113s   v1.31.0

=== Tue Jan 27 08:36:40 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          100s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          68s    172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          68s    172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          68s    172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE    VERSION
kind-control-plane   Ready    control-plane   2m9s   v1.31.0
kind-worker          Ready    <none>          118s   v1.31.0
kind-worker2         Ready    <none>          118s   v1.31.0

=== Tue Jan 27 08:36:45 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          105s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          73s    172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          73s    172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          73s    172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m14s   v1.31.0
kind-worker          Ready    <none>          2m3s    v1.31.0
kind-worker2         Ready    <none>          2m3s    v1.31.0

=== Tue Jan 27 08:36:50 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2001 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          110s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          78s    172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          78s    172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          78s    172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m19s   v1.31.0
kind-worker          Ready    <none>          2m8s    v1.31.0
kind-worker2         Ready    <none>          2m8s    v1.31.0

=== Tue Jan 27 08:36:55 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          115s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          83s    172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          83s    172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          83s    172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m24s   v1.31.0
kind-worker          Ready    <none>          2m13s   v1.31.0
kind-worker2         Ready    <none>          2m13s   v1.31.0

=== Tue Jan 27 08:37:00 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2000 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          2m    10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          88s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          88s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          88s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m29s   v1.31.0
kind-worker          Ready    <none>          2m18s   v1.31.0
kind-worker2         Ready    <none>          2m18s   v1.31.0

=== Tue Jan 27 08:37:05 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-c7s6l   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2001 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE    IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          2m5s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          93s    172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          93s    172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          93s    172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS   ROLES           AGE     VERSION
kind-control-plane   Ready    control-plane   2m34s   v1.31.0
kind-worker          Ready    <none>          2m23s   v1.31.0
kind-worker2         Ready    <none>          2m23s   v1.31.0

=== Tue Jan 27 08:37:11 AM EST 2026 ===
No resources found
---
curl: (28) Connection timed out after 2002 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          2m11s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          99s     172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          99s     172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          99s     172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS     ROLES           AGE     VERSION
kind-control-plane   Ready      control-plane   2m40s   v1.31.0
kind-worker          NotReady   <none>          2m29s   v1.31.0
kind-worker2         Ready      <none>          2m29s   v1.31.0
(...)
=== Tue Jan 27 08:42:23 AM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-gfcj7   kind-worker2     external-local-lb   l2
---
NOW: 2026-01-27 13:42:23.809677714 +0000 UTC m=+1.567482913
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-gzlbc   1/1     Running   0          7m21s   10.244.2.2   kind-worker2         <none>           <none>
speaker-ppq64                4/4     Running   0          6m49s   172.18.0.2   kind-worker          <none>           <none>
speaker-vcwtb                4/4     Running   0          6m49s   172.18.0.4   kind-worker2         <none>           <none>
speaker-zlc97                4/4     Running   0          6m49s   172.18.0.3   kind-control-plane   <none>           <none>
NAME                 STATUS     ROLES           AGE     VERSION
kind-control-plane   Ready      control-plane   7m50s   v1.31.0
kind-worker          NotReady   <none>          7m39s   v1.31.0
kind-worker2         Ready      <none>          7m39s   v1.31.0
(...)

Using the following debug statements to analyze the behavior:

diff --git a/internal/k8s/controllers/layer2_status_controller.go b/internal/k8s/controllers/layer2_status_controller.go
index 5acb7a407..8af6a186b 100644
--- a/internal/k8s/controllers/layer2_status_controller.go
+++ b/internal/k8s/controllers/layer2_status_controller.go
@@ -71,19 +71,27 @@ func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request
        serviceName, serviceNamespace := req.Name, req.Namespace
 
        ipAdvS := r.StatusFetcher(types.NamespacedName{Name: serviceName, Namespace: serviceNamespace})
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris ipAdvS", fmt.Sprintf("%+v", ipAdvS))
        var serviceL2statuses v1beta1.ServiceL2StatusList
        if err := r.List(ctx, &serviceL2statuses, client.MatchingFields{
                serviceIndexName: types.NamespacedName{Name: serviceName, Namespace: serviceNamespace}.String(),
        }); err != nil {
                return ctrl.Result{}, err
        }
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris serviceL2statuses", fmt.Sprintf("%+v", serviceL2statuses))
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris client.MatchingFields", fmt.Sprintf("%+v", client.MatchingFields{
+               serviceIndexName: types.NamespacedName{Name: serviceName, Namespace: serviceNamespace}.String(),
+       }))
 
        var errs []error
        if len(ipAdvS) == 0 {
                for key, item := range serviceL2statuses.Items {
+                       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler - len(ipAdvS) == 0", "-----> akaris r.NodeName", r.NodeName, "item.Labels", item.Labels)
+                       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler - len(ipAdvS) == 0", "-----> akaris r.NodeName", r.NodeName, "item.Labels[LabelAnnounceNode]", item.Labels[LabelAnnounceNode])
                        if item.Labels[LabelAnnounceNode] != r.NodeName {
                                continue
                        }
+                       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler - len(ipAdvS) == 0 - key - DELETE", "-----> akaris", fmt.Sprintf("i: %d", key))
                        if err := r.Delete(ctx, &serviceL2statuses.Items[key]); err != nil && !errors.IsNotFound(err) {
                                errs = append(errs, err)
                        }
@@ -95,6 +103,7 @@ func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request
        if len(serviceL2statuses.Items) > 1 {
                // We shouldn't get here, just in case the controller created redundant resources
                for i := range serviceL2statuses.Items[1:] {
+                       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler - redundant resources:", "-----> akaris", fmt.Sprintf("i: %d", i))
                        if serviceL2statuses.Items[i+1].Labels[LabelAnnounceNode] != r.NodeName {
                                continue
                        }
@@ -119,11 +128,15 @@ func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request
        if len(serviceL2statuses.Items) > 0 {
                state = &serviceL2statuses.Items[0]
        }
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris state", fmt.Sprintf("%+v", state))
 
        desiredStatus := r.buildDesiredStatus(ipAdvS, serviceName, serviceNamespace)
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris state.Status", fmt.Sprintf("%+v", state.Status))
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris desiredStatus", fmt.Sprintf("%+v", desiredStatus))
        if reflect.DeepEqual(state.Status, desiredStatus) {
                return ctrl.Result{}, nil
        }
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris status", "NOT EQUAL")
 
        var result controllerutil.OperationResult
        var err error
@@ -138,8 +151,10 @@ func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request
                if err != nil {
                        return err
                }
+               level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris CreateOrPatch", fmt.Sprintf("%+v", state))
                return nil
        })
+       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris result", fmt.Sprintf("%+v", result), "err", fmt.Sprintf("%+v", err))
        if err != nil {
                return ctrl.Result{}, err
        }
@@ -201,6 +216,9 @@ func (r *Layer2StatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
                                level.Error(r.Logger).Log("controller", "fieldindexer", "error", "received ServiceL2Status without label", "meta", fmt.Sprintf("%s/%s", s.Name, s.Namespace))
                                return nil
                        }
+                       level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "-----> akaris fieldindexer", fmt.Sprintf("%+v", types.NamespacedName{
+                               Name:      label[LabelServiceName],
+                               Namespace: label[LabelServiceNamespace]}.String()))
                        return []string{types.NamespacedName{
                                Name:      label[LabelServiceName],
                                Namespace: label[LabelServiceNamespace]}.String()}

case a):

It seems that the memberlist suspect failed detection works - we see this here on both remaining pods:

speaker-7995k (kind-control-plane):

{"caller":"state.go:521","component":"Memberlist","level":"info","msg":"memberlist: Suspect kind-worker has failed, no acks received","ts":"2026-01-27T13:51:21Z"}

speaker-nbmq6 (kind-worker2):

{"caller":"state.go:521","component":"Memberlist","level":"info","msg":"memberlist: Suspect kind-worker has failed, no acks received","ts":"2026-01-27T13:51:18Z"}

kind-worker2 wins the new election:

speaker-7995k (kind-control-plane):

{"caller":"layer2_controller.go:104","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","nodes":["kind-control-plane","kind-worker2"],"pool":"l2-test","protocol":"l2","service":"l2/external-local-
lb","ts":"2026-01-27T13:51:21Z"}

speaker-nbmq6 (kind-worker2):

{"caller":"layer2_controller.go:104","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","nodes":["kind-control-plane","kind-worker2"],"pool":"l2-test","protocol":"l2","service":"l2/external-local-lb","ts":"2026-01-27T13:51:21Z"}
{"caller":"main.go:444","event":"serviceAnnounced","ips":["172.18.0.9"],"level":"info","msg":"service has IP, announcing","pool":"l2-test","protocol":"layer2","ts":"2026-01-27T13:51:21Z"}

The speaker on kind-worker2 has advertisements:

{"-----> akaris ipAdvS":"[{ip:[0 0 0 0 0 0 0 0 0 0 255 255 172 18 0 9] interfaces:map[] allInterfaces:true}]","caller":"layer2_status_controller.go:74","controller":"Layer2StatusReconciler","level":"debug","t
s":"2026-01-27T13:51:21Z"}

And it can list the existing servicel2status, which isn't owned by it, though:

{"-----> akaris serviceL2statuses":"{TypeMeta:{Kind: APIVersion:} ListMeta:{SelfLink: ResourceVersion: Continue:continue-not-supported RemainingItemCount:<nil>} Items:[{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-qjx55 GenerateName:l2- Namespace:metallb-system SelfLink: UID:6999197e-3aaa-4562-a713-1c09b6db8a66 ResourceVersion:1108 Generation:1 CreationTimestamp:2026-01-27 13:51:09 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-fnhqq UID:98eb5c23-81ae-4f0a-9b5e-70e7943b8eba Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"98eb5c23-81ae-4f0a-9b5e-70e7943b8eba\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}]}","caller":"layer2_status_controller.go:81","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}
{"-----> akaris client.MatchingFields":"map[status.serviceName:l2/external-local-lb]","caller":"layer2_status_controller.go:82","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}

Now, it creates a new state:

s.io/[email protected]/tools/record/event.go:418"}
{"-----> akaris state":"&{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-qjx55 GenerateName:l2- Namespace:metallb-system SelfLink: UID:6999197e-3aaa-4562-a713-1c09b6db8a66 ResourceVersion:1108 Generation:1 CreationTimestamp:2026-01-27 13:51:09 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-fnhqq UID:98eb5c23-81ae-4f0a-9b5e-70e7943b8eba Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"98eb5c23-81ae-4f0a-9b5e-70e7943b8eba\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}","caller":"layer2_status_controller.go:131","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}
{"-----> akaris state.Status":"{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}","caller":"layer2_status_controller.go:134","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}
{"-----> akaris desiredStatus":"{Node:kind-worker2 ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}","caller":"layer2_status_controller.go:135","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}

Ignoring the fact that we do not want this pod to manage this resource, the first problem here is that the old owner reference isn't removed, and later on we will see that this
pod will add its own reference in addition to the old one.

Existing and desired status aren't equal:

{"-----> akaris status":"NOT EQUAL","caller":"layer2_status_controller.go:139","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}

So, we run CreateOrPatch now:

{"-----> akaris CreateOrPatch":"&{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-qjx55 GenerateName:l2- Namespace:metallb-system SelfLink: UID:6999197e-3aaa-4562-a713-1c09b6db8a66 ResourceVersion:1108 Generation:1 CreationTimestamp:2026-01-27 13:51:09 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker2 metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-fnhqq UID:98eb5c23-81ae-4f0a-9b5e-70e7943b8eba Controller:<nil> BlockOwnerDeletion:<nil>} {APIVersion:v1 Kind:Pod Name:speaker-nbmq6 UID:d2908f89-e9f6-43b6-a082-a30fd65f004d Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"98eb5c23-81ae-4f0a-9b5e-70e7943b8eba\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:51:09 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker2 ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}","caller":"layer2_status_controller.go:154","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:51:21Z"}

With 2 ownerReferences, and the updated status.

However, the status value is immutable:

{"-----> akaris result":"updated","caller":"layer2_status_controller.go:157","controller":"Layer2StatusReconciler","err":"ServiceL2Status.metallb.io \"l2-qjx55\" is invalid: status.node: Invalid value: \"string\": Value is immutable","level":"debug","ts":"2026-01-27T13:51:21Z"}
(...)
{"level":"error","ts":"2026-01-27T13:51:21Z","msg":"Reconciler error","controller":"servicel2status","namespace":"l2","name":"external-local-lb","reconcileID":"11665622-e400-4c2f-bf4b-49fabdd2253a","error":"ServiceL2Status.metallb.io \"l2-qjx55\" is invalid: status.node: Invalid value: \"string\": Value is immutable","stacktrace":"sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).reconcileHandler\n\t/go/pkg/mod/sigs.k8s.io/[email protected]/pkg/internal/controller/controller.go:474\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).processNextWorkItem\n\t/go/pkg/mod/sigs.k8s.io/[email protected]/pkg/internal/controller/controller.go:421\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func1.1\n\t/go/pkg/mod/sigs.k8s.io/[email protected]/pkg/internal/controller/controller.go:296"}

That immutable error comes from the CEL validation which doesn't allow changes to Node, ServiceName or ServiceNamespace in the status field.

Note that this now repeats with an expontential backoff:

{"-----> akaris result":"unchanged","caller":"layer2_status_controller.go:157","controller":"Layer2StatusReconciler","err":"ServiceL2Status.metallb.io \"l2-qjx55\" is invalid: status.node: Invalid value: \"string\": Value is immutable","level":"debug","ts":"2026-01-27T13:54:05Z"}

case b):

Both nodes detect that kind-worker failed:

speaker-zlc97 (kind-control-plane):

{"caller":"state.go:521","component":"Memberlist","level":"info","msg":"memberlist: Suspect kind-worker has failed, no acks received","ts":"2026-01-27T13:36:35Z"}

speaker-vcwtb (kind-worker2):

{"caller":"state.go:521","component":"Memberlist","level":"info","msg":"memberlist: Suspect kind-worker has failed, no acks received","ts":"2026-01-27T13:36:36Z"}

Both check if they should announce, kind-worker2 announces:

speaker-zlc97 (kind-control-plane):

{"caller":"layer2_controller.go:104","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","nodes":["kind-control-plane","kind-worker2"],"pool":"l2-test","protocol":"l2","service":"l2/external-local-lb","ts":"2026-01-27T13:36:39Z"}

speaker-vcwtb (kind-worker2):

{"caller":"layer2_controller.go:104","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","nodes":["kind-worker2","kind-control-plane"],"pool":"l2-test","protocol":"l2","service":"l2/external-local-lb","ts":"2026-01-27T13:36:39Z"}
{"caller":"main.go:444","event":"serviceAnnounced","ips":["172.18.0.9"],"level":"info","msg":"service has IP, announcing","pool":"l2-test","protocol":"layer2","ts":"2026-01-27T13:36:39Z"}

So now, kind-worker2 runs the layer2 status controller - it has advertisements:

{"-----> akaris ipAdvS":"[{ip:[0 0 0 0 0 0 0 0 0 0 255 255 172 18 0 9] interfaces:map[] allInterfaces:true}]","caller":"layer2_status_controller.go:74","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}

And it can list the existing servicel2status, which isn't owned by it, though:

{"-----> akaris serviceL2statuses":"{TypeMeta:{Kind: APIVersion:} ListMeta:{SelfLink: ResourceVersion: Continue:continue-not-supported RemainingItemCount:<nil>} Items:[{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-c7s6l GenerateName:l2- Namespace:metallb-system SelfLink: UID:27063a98-bc79-4f7e-8912-bf9711690d1f ResourceVersion:1057 Generation:1 CreationTimestamp:2026-01-27 13:36:20 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-ppq64 UID:8644070f-a983-4a90-b9ad-dfd20bc71275 Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"8644070f-a983-4a90-b9ad-dfd20bc71275\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}]}","caller":"layer2_status_controller.go:81","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}
{"-----> akaris client.MatchingFields":"map[status.serviceName:l2/external-local-lb]","caller":"layer2_status_controller.go:82","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}

Now, it creates a new state:

{"-----> akaris state":"&{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-c7s6l GenerateName:l2- Namespace:metallb-system SelfLink: UID:27063a98-bc79-4f7e-8912-bf9711690d1f ResourceVersion:1057 Generation:1 CreationTimestamp:2026-01-27 13:36:20 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-ppq64 UID:8644070f-a983-4a90-b9ad-dfd20bc71275 Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"8644070f-a983-4a90-b9ad-dfd20bc71275\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}","caller":"layer2_status_controller.go:131","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}
{"-----> akaris state.Status":"{Node:kind-worker ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}","caller":"layer2_status_controller.go:134","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}
{"-----> akaris desiredStatus":"{Node:kind-worker2 ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}","caller":"layer2_status_controller.go:135","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}

Existing and desired status aren't equal:

{"-----> akaris status":"NOT EQUAL","caller":"layer2_status_controller.go:139","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}

So, we run a CreateOrPatch now, with both speakers listed as owner ref, and other fields set to kind-worker2:

{"-----> akaris CreateOrPatch":"&{TypeMeta:{Kind:ServiceL2Status APIVersion:metallb.io/v1beta1} ObjectMeta:{Name:l2-c7s6l GenerateName:l2- Namespace:metallb-system SelfLink: UID:27063a98-bc79-4f7e-8912-bf9711690d1f ResourceVersion:1057 Generation:1 CreationTimestamp:2026-01-27 13:36:20 +0000 UTC DeletionTimestamp:<nil> DeletionGracePeriodSeconds:<nil> Labels:map[metallb.io/node:kind-worker2 metallb.io/service-name:external-local-lb metallb.io/service-namespace:l2] Annotations:map[] OwnerReferences:[{APIVersion:v1 Kind:Pod Name:speaker-ppq64 UID:8644070f-a983-4a90-b9ad-dfd20bc71275 Controller:<nil> BlockOwnerDeletion:<nil>} {APIVersion:v1 Kind:Pod Name:speaker-vcwtb UID:60b70ffe-3590-4702-9f56-e9f211233b1c Controller:<nil> BlockOwnerDeletion:<nil>}] Finalizers:[] ManagedFields:[{Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:metadata\":{\"f:generateName\":{},\"f:labels\":{\".\":{},\"f:metallb.io/node\":{},\"f:metallb.io/service-name\":{},\"f:metallb.io/service-namespace\":{}},\"f:ownerReferences\":{\".\":{},\"k:{\\\"uid\\\":\\\"8644070f-a983-4a90-b9ad-dfd20bc71275\\\"}\":{}}},\"f:spec\":{}} Subresource:} {Manager:speaker Operation:Update APIVersion:metallb.io/v1beta1 Time:2026-01-27 13:36:20 +0000 UTC FieldsType:FieldsV1 FieldsV1:{\"f:status\":{\".\":{},\"f:node\":{},\"f:serviceName\":{},\"f:serviceNamespace\":{}}} Subresource:status}]} Spec:{} Status:{Node:kind-worker2 ServiceName:external-local-lb ServiceNamespace:l2 Interfaces:[]}}","caller":"layer2_status_controller.go:154","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:36:39Z"}

The update to status.node fails:

{"-----> akaris result":"updated","caller":"layer2_status_controller.go:157","controller":"Layer2StatusReconciler","err":"ServiceL2Status.metallb.io \"l2-c7s6l\" is invalid: status.node: Invalid value: \"string\": Value is immutable","level":"debug","ts":"2026-01-27T13:36:39Z"}

That same sequence is now triggered for a while, in a loop, because the controller cannot update the resource.

After a while, the controller notices that something went wrong with the servie announcement (we can see that the curl didn't work) - that coincides with the node NotReady of kind-worker, so I imagine that
the node not ready triggers that check:

{"caller":"layer2_controller.go:84","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","message":"failed no active endpoints","pool":"l2-test","protocol":"l2","service":"l2/external-local-lb","ts":"2026-01-27T13:37:11Z"}
{"caller":"main.go:488","event":"serviceWithdrawn","ip":["172.18.0.9"],"ips":["172.18.0.9"],"level":"info","msg":"withdrawing service announcement","pool":"l2-test","protocol":"layer2","reason":"notOwner","ts":"2026-01-27T13:37:11Z"}

Therefore, we now delete the servicel2status:

{"-----> akaris client.MatchingFields":"map[status.serviceName:l2/external-local-lb]","caller":"layer2_status_controller.go:82","controller":"Layer2StatusReconciler","level":"debug","ts":"2026-01-27T13:37:11Z"}
{"-----> akaris r.NodeName":"kind-worker2","caller":"layer2_status_controller.go:89","controller":"Layer2StatusReconciler - len(ipAdvS) == 0","item.Labels":{"metallb.io/node":"kind-worker2","metallb.io/service-name":"external-local-lb","metallb.io/service-namespace":"l2"},"level":"debug","ts":"2026-01-27T13:37:11Z"}
{"-----> akaris r.NodeName":"kind-worker2","caller":"layer2_status_controller.go:90","controller":"Layer2StatusReconciler - len(ipAdvS) == 0","item.Labels[LabelAnnounceNode]":"kind-worker2","level":"debug","ts":"2026-01-27T13:37:11Z"}
{"-----> akaris":"i: 0","caller":"layer2_status_controller.go:94","controller":"Layer2StatusReconciler - len(ipAdvS) == 0 - key - DELETE","level":"debug","ts":"2026-01-27T13:37:11Z"}

Later, the laer2_controller announces the l2 service again:

{"caller":"layer2_controller.go:104","event":"shouldannounce","ips":["172.18.0.9"],"level":"debug","nodes":["kind-worker2","kind-control-plane"],"pool":"l2-test","protocol":"l2","service":"l2/external-local-lb","ts":"2026-01-27T13:42:22Z"}
{"caller":"main.go:444","event":"serviceAnnounced","ips":["172.18.0.9"],"level":"info","msg":"service has IP, announcing","pool":"l2-test","protocol":"layer2","ts":"2026-01-27T13:42:22Z"}

And now everything converges correctly, with no redundant servicel2status CRs.

// and keep/create only our own.
var existingStatus *v1beta1.ServiceL2Status
for i := range serviceL2statuses.Items {
item := &serviceL2statuses.Items[i]

@andreaskaris andreaskaris Jan 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: with newer go versions making copies of loop variables, wouldn't simply for _, item := range serviceL2statuses.Items work? followed by r.Delete(ctx, &item)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if err := r.Delete(ctx, &serviceL2statuses.Items[i+1]); err != nil && !errors.IsNotFound(err) {
deleteRedundantErrs = append(deleteRedundantErrs, err)
}
// We are the (sole) leader for this service - delete statuses from other nodes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the comment is mislreading: the comment below is correct and says "// Delete statuses from other nodes / redundant statuses belonging to our node". Also, it's redundant to have the 'delete statuses from other nodes ..' stuff both in line 97 and line 106

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if len(serviceL2statuses.Items) > 0 {
state = &serviceL2statuses.Items[0]
if existingStatus != nil {
state = existingStatus

@andreaskaris andreaskaris Jan 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit / thinking out loud: what about turning this on its head?

line 99:

var state *v1beta1.ServiceL2Status

line 102:

	if item.Labels[LabelAnnounceNode] == r.NodeName && state == nil {
			state = item

line 116:

if state == nil {
   state = &v1beta1.ServiceL2Status{
		ObjectMeta: metav1.ObjectMeta{
			GenerateName: "l2-",
			Namespace:    r.Namespace,
		},
	}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@andreaskaris

andreaskaris commented Jan 27, 2026 •

Copy link
Copy Markdown
Contributor

I tested the newest version of the PR and it looks good with the newest proposal:

[akaris@metallb l2test]$  while true; do echo "=== $(date) ==="; kubectl get servicel2status -A; echo ---; curl --connect-timeout 2 172.18.0.9; echo; kubectl get pods -n metallb-system -o wide; ku
bectl get nodes; echo; sleep 3; done

(...)

=== Tue Jan 27 04:34:51 PM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-kblrg   kind-worker      external-local-lb   l2
---
NOW: 2026-01-27 21:34:51.375397503 +0000 UTC m=+25.140045318
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-wtpjl   1/1     Running   0          19m     10.244.2.2   kind-worker          <none>           <none>
speaker-6q8ht                4/4     Running   0          2m27s   172.18.0.3   kind-worker          <none>           <none>
speaker-m6xvt                4/4     Running   0          2m48s   172.18.0.2   kind-control-plane   <none>           <none>
speaker-tckrf                4/4     Running   0          2m6s    172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE   VERSION
kind-control-plane   Ready    control-plane   20m   v1.31.0
kind-worker          Ready    <none>          20m   v1.31.0
kind-worker2         Ready    <none>          20m   v1.31.0

=== Tue Jan 27 04:34:54 PM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-kblrg   kind-worker      external-local-lb   l2
---
curl: (28) Connection timed out after 2001 milliseconds

NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-wtpjl   1/1     Running   0          19m     10.244.2.2   kind-worker          <none>           <none>
speaker-6q8ht                4/4     Running   0          2m32s   172.18.0.3   kind-worker          <none>           <none>
speaker-m6xvt                4/4     Running   0          2m53s   172.18.0.2   kind-control-plane   <none>           <none>
speaker-tckrf                4/4     Running   0          2m11s   172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE   VERSION
kind-control-plane   Ready    control-plane   20m   v1.31.0
kind-worker          Ready    <none>          20m   v1.31.0
kind-worker2         Ready    <none>          20m   v1.31.0

=== Tue Jan 27 04:34:59 PM EST 2026 ===
NAMESPACE        NAME       ALLOCATED NODE   SERVICE NAME        SERVICE NAMESPACE
metallb-system   l2-xb7nl   kind-worker2     external-local-lb   l2
---
NOW: 2026-01-27 21:34:59.640386354 +0000 UTC m=+33.405034166
NAME                         READY   STATUS    RESTARTS   AGE     IP           NODE                 NOMINATED NODE   READINESS GATES
controller-545fb745d-wtpjl   1/1     Running   0          20m     10.244.2.2   kind-worker          <none>           <none>
speaker-6q8ht                4/4     Running   0          2m35s   172.18.0.3   kind-worker          <none>           <none>
speaker-m6xvt                4/4     Running   0          2m56s   172.18.0.2   kind-control-plane   <none>           <none>
speaker-tckrf                4/4     Running   0          2m14s   172.18.0.4   kind-worker2         <none>           <none>
NAME                 STATUS   ROLES           AGE   VERSION
kind-control-plane   Ready    control-plane   20m   v1.31.0
kind-worker          Ready    <none>          20m   v1.31.0
kind-worker2         Ready    <none>          20m   v1.31.0
(...)
$ kubectl get servicel2status -n metallb-system  -o yaml
apiVersion: v1
items:
- apiVersion: metallb.io/v1beta1
  kind: ServiceL2Status
  metadata:
    creationTimestamp: "2026-01-27T21:34:59Z"
    generateName: l2-
    generation: 1
    labels:
      metallb.io/node: kind-worker2
      metallb.io/service-name: external-local-lb
      metallb.io/service-namespace: l2
    name: l2-xb7nl
    namespace: metallb-system
    ownerReferences:
    - apiVersion: v1
      kind: Pod
      name: speaker-tckrf
      uid: 5792e7b6-3d61-4a36-af36-cba373422201
    resourceVersion: "2774"
    uid: 336525ba-3ffe-4260-a5ca-6f7f69fa744f
  spec: {}
  status:
    node: kind-worker2
    serviceName: external-local-lb
    serviceNamespace: l2
kind: List
metadata:
  resourceVersion: ""

@andreaskaris

Copy link
Copy Markdown
Contributor

Please check the nits, squash, and I can give my lgtm :-) Sorry for the lengthy posts

@oribon
oribon force-pushed the another_status_fix branch from 0e1a968 to 0055ebb Compare January 28, 2026 08:39
@andreaskaris

Copy link
Copy Markdown
Contributor

/lgtm

Previously each controller managed only its own resources, allowing:
1. nodes could potentially "steal" eachother's resources
(a bug caused by the controller using the wrong index)
2. on abrupt shutdown of a node its resource stays dangling
(because the pod is never deleted until next boot, ownerref
gc doesn't kick in)

Here, we refactor the logic so that the leader node for a service
makes sure there is only one resource for the service (that it manages).
This solves (1) because the leader enforces only one resource,
and solves (2) because if a new leader is elected for the service
(through memberlist) then it deletes the dangling resource.

In the case a leader node is abruptly shutdown but it can be the only
node announcing the service (e.g due to nodeSelectors on the L2Adv)
we would still have a dangling resource.

Signed-off-by: Ori Braunshtein <[email protected]>
Assisted-by: Cursor
@oribon
oribon force-pushed the another_status_fix branch from 0055ebb to 730a4d4 Compare January 28, 2026 09:19
@andreaskaris

Copy link
Copy Markdown
Contributor

/lgtm still subject to CI

@oribon oribon changed the title WIP: L2Status controller: add node name to index L2Status controller: refactor to leader node manages resources Jan 28, 2026
@oribon
oribon added this pull request to the merge queue Jan 28, 2026
Merged via the queue into metallb:main with commit c501ad7 Jan 28, 2026
32 checks passed
@kvaps

kvaps commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

Hi! Are there any plans to cut a new release that includes this fix? The latest release (v0.15.3) was published before this PR was merged, so the fix is currently only available on main. We've been hitting this issue in production — after node reboots, ServiceL2Status objects get stuck and LoadBalancer VIPs become unreachable. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Speaker - error log - ServiceL2Status.metallb.io XXX is invalid: status.node: Invalid value: \"string\": Value is immutable

4 participants