Repository: metallb/metallb
Pull request #2198 (2023-12-05 09:57:33)

Implement ServiceL2Status CRD to expose service announcing status
This is an implement of what I was assigned in  #2158.
The core implementation have almost been done and I have test the  feature in my local dev-env. 
I will continue doing some detailed work such as the helm chart files, etc.
Please do some early code review if possible, any suggestions would be appreciated.

/kind feature

```release-note
Implement ServiceL2Status CRD to expose service announcing status in layer2 mode
```

<!--Thanks for sending a pull request! A few things before we get started:

1. If this is your first time, please read the [contributing guide](https://metallb.universe.tf/community/#contributing)
2. For non-trivial pull requests, please [file an
   issue](https://github.com/metallb/metallb/issues/new) first, and get
   agreement that the change is a good idea, and a general guideline
   for how it should be implemented, before sending code. Large PRs
   that weren't first discussed and agreed upon in an issue won't be
   accepted.
3. If the PR fixes a particular bug, please include the words "Fixed
   #<issue number>" in the PR text, so that the bug auto-closes when
   the PR is merged.
-->

oribon: Thanks a lot for opening this!
I think we should take a bit different approach regarding how we manage these resources.
Instead of an imperative approach where this is embedded in the existing layer2_controller and we take action according to the current step, we should go for a more declarative one where we have a new k8s controller that can react to general events, fetching the desired state of a service and building the status accordignly.
More specifically - we can make the layer2_controller expose the state of advertised services (e.g by a func that accepts a namespaced name for a service and return its advertisement state), add a new k8s controller that listens to a channel and feed that channel from the layer2_controller every time a state of an advertised service changes with an event that service x has changed. When the new k8s controller gets the event that service x has changed it queries its state and builds the relevant status.
You can take inspiration from how we did it in frr-k8s, where the nodestate controller fetches the different pieces to create the state from other components:
https://github.com/metallb/frr-k8s/blob/main/internal/controller/frrstate_controller.go
https://github.com/metallb/frr-k8s/blob/8ea5c2e33beaab49af944d14452c85993095e844/cmd/main.go#L197

This will allow us to delegate the concerns of managing the statuses to a single point which should be easy to follow.
Also, there are still some corners we need to make sure we're covering such as not leaving stale statuses around and making sure the user doesn't edit them but we can leave them for later once we settle on the general picture. wdyt?

Again, thanks a lot for your efforts and please let me know if you need any clarifications or help!
lwabish: @oribon Please review the new implement
lwabish: Thanks for your reviewing and sugguestions.I'll keep working on it.
lwabish: > Thanks a lot for the PR! did a first pass, can you please add an e2e as well so we have a general sense if it's working? Please let me know if you need anything :)

I met this problem while trying to process go module dependencies in e2etest directory.

```
❯ cd e2etest
❯ go mod download
go: k8s.io/endpointslice@v0.0.0: invalid version: unknown revision v0.0.0
❯ go mod why k8s.io/endpointslice
go: downloading k8s.io/endpointslice v0.0.0
# k8s.io/endpointslice
(main module does not need package k8s.io/endpointslice)
```

Somehow this didn't affect `inv e2etest`.
Adding `k8s.io/endpointslice => k8s.io/endpointslice v0.28.2` to go.mod could solve this problem but I'm not quite sure if this is a env specific problem.
Should I commit my e2etest/go.mod and related go module files?
Of course this is not a fatal or urgent problem but a little confusing.
oribon: it's a bit hard for me to say, but in general committing whatever `go mod tidy` changes should be fine 😅 
lwabish: @oribon I have made the changes according to your sugguestions, please review them
oribon: > @oribon I have made the changes according to your sugguestions, please review them

thanks a lot, I'll get to this in the following days :) 
in the meanwhile I ran CI (and will rerun it again whenever you push)
lwabish: I ran some git rebase commands according to git help page to fix DCO test.Now The DCO test is ok.
But It seems that my commit log is mixed with some other commits.Not sure if this is a problem.
Besides, the CI workflow seems to be waiting for approval to run
@oribon 

lwabish: > > I ran some git rebase commands according to git help page to fix DCO test.Now The DCO test is ok. But It seems that my commit log is mixed with some other commits.Not sure if this is a problem. Besides, the CI workflow seems to be waiting for approval to run @oribon
> 
> approved again, about the commit log it might cause some problems but anyways I'll ask you sooner or later to clean it up a bit 😅 the easiest way imo would be squashing your commits together (for example using `rebase -i`, cherry-picking them on top of main and overriding this branch. Also, it'll be easier to review when the commits are organized into a few (I guess it shouldn't be more than ~4-5 commits for this PR: API / Manifests / implementation / E2E)

No problem.This is what I planned to to after solving other problems.
lwabish: @oribon All done except for the proposal to add advertise ips. Please rerun the ci workflow 😄 
lwabish: @oribon  
I think there's something about the reconcile funtion logic we should dive a little deeper.
Here are the two different approaches I have organized regarding how the reconcile function works.
1. The way that I wrote before:
With an eventfilter that filter out all serviceL2Status objects, the reconciler only got triggered by the channel elements, which means only the relavent speaker reconcile for a single service, no confilcts occur. But just like you mentioned recently, if some other controller or even users change the serviceL2Status, our reconciler can't get triggered.
This is also how the frr controller got implemented.
2. Without an eventfilter:
Every speaker can reconcile for a single service and external operations to the serviceL2Status object is considerd, which is more cloud-native. The difficulty lies in: the most direct status is stored only in one particular  speaker, others can't determine if a serviceL2Status should be delete or not from the status inside itself.
In my latest reconcile funtion, besides removing the eventfilter, I added some conditions to help reconciler determine if a serviceL2Status should be deleted, which solved the confilcts and things seems ok right now according to the unit tests and e2e tests. 
But I'm not sure if there is any other edge situation about this approach, wdyt ? 
oribon: > @oribon I think there's something about the reconcile funtion logic we should dive a little deeper. Here are the two different approaches I have organized regarding how the reconcile function works.
> 
> 1. The way that I wrote before:
>    With an eventfilter that filter out all serviceL2Status objects, the reconciler only got triggered by the channel elements, which means only the relavent speaker reconcile for a single service, no confilcts occur. But just like you mentioned recently, if some other controller or even users change the serviceL2Status, our reconciler can't get triggered.
>    This is also how the frr controller got implemented.
> 2. Without an eventfilter:
>    Every speaker can reconcile for a single service and external operations to the serviceL2Status object is considerd, which is more cloud-native. The difficulty lies in: the most direct status is stored only in one particular  speaker, others can't determine if a serviceL2Status should be delete or not from the status inside itself.
>    In my latest reconcile funtion, besides removing the eventfilter, I added some conditions to help reconciler determine if a serviceL2Status should be deleted, which solved the confilcts and things seems ok right now according to the unit tests and e2e tests.
>    But I'm not sure if there is any other edge situation about this approach, wdyt ?

I see what you're saying, our problem is that currently a speaker can't know if it's responsible for a given status resource. I think a good approach (and the most bulletproof one) would be embedding the node's name as part of the resource object like we intend doing in the [bgp status resource](https://github.com/metallb/metallb/blob/main/design/crd-status.md#service-announcement---bgp).
That way the question "is this speaker responsible for the object" boils down to "is the resource's name has the node's name as a suffix" (leveraging the fact that a resource's name is immutable) and reflecting this in the event filter.
Doing that, we can revert to your original implementation without the new auxiliary function, deleting the object if it has 0 advs but still having the advantage of listening for objects changes.
wdyt? cc @fedepaol 
lwabish: > @oribon @lwabish haven't checked the implementation yet.
> 
> My gut feeling is, if a given l2 status is shared among different speakers (i.e. the service moves from one speaker to the other, for example because an endpoint moved or a node selector changed), the old owner only knows it doesn't own the service anymore unless we add extra logic to understand if no one should advertise the service at all. So a given speaker will have to choose wether delete the status or not, just because it's not advertising the service, and getting this right looking at the current status is not easy (what if the speaker that should delete crashes for example)?
> 
> So, given we are doing the same for BGP, I am not against to not naming the resource as the service but having a "per node" l2status instance (where the service name / node name can be added as labels as in BGP). The name can be the concatenation of service and node, or something autogenerated, but by doing this any speaker will take care of the lifecycle of a single instance and its life (and ours) will be easier.
> 
> Hope I got things right

With this design, I think the controller logic could be simpler.
As a user , if I want to investigate my service annoucement status, my command would be like:
```bash
kubectl get servicel2status -n $NS --selector=metallb.io/service=$SVC-NAME
```
whose output would contains a couple of  servicel2status objects.
If I am understanding this right, maybe we should optimize the servicel2status status object design.For example the `status.node` as a string would be empty except for the only one which is advertising, in this case a boolean type may be more clear?
fedepaol: > > @oribon @lwabish haven't checked the implementation yet.
> > My gut feeling is, if a given l2 status is shared among different speakers (i.e. the service moves from one speaker to the other, for example because an endpoint moved or a node selector changed), the old owner only knows it doesn't own the service anymore unless we add extra logic to understand if no one should advertise the service at all. So a given speaker will have to choose wether delete the status or not, just because it's not advertising the service, and getting this right looking at the current status is not easy (what if the speaker that should delete crashes for example)?
> > So, given we are doing the same for BGP, I am not against to not naming the resource as the service but having a "per node" l2status instance (where the service name / node name can be added as labels as in BGP). The name can be the concatenation of service and node, or something autogenerated, but by doing this any speaker will take care of the lifecycle of a single instance and its life (and ours) will be easier.
> > Hope I got things right
> 
> With this design, I think the controller logic could be simpler. As a user , if I want to investigate my service annoucement status, my command would be like:
> 
> ```shell
> kubectl get servicel2status -n $NS --selector=metallb.io/service=$SVC-NAME
> ```

And this would be the same as we are proposing for BGP (because then, we'd have multiple nodes advertising the service)

> 
> whose output would contains a couple of servicel2status objects. If I am understanding this right, maybe we should optimize the servicel2status status object design.For example the `status.node` as a string would be empty except for the only one which is advertising, in this case a boolean type may be more clear?

What I am suggesting here is, if a given speaker is not advertising the service anymore, then it should delete its own instance. So at a given time, there would be only one l2 adv per service (assuming one speaker is advertising).
lwabish: > > > @oribon @lwabish haven't checked the implementation yet.
> > > My gut feeling is, if a given l2 status is shared among different speakers (i.e. the service moves from one speaker to the other, for example because an endpoint moved or a node selector changed), the old owner only knows it doesn't own the service anymore unless we add extra logic to understand if no one should advertise the service at all. So a given speaker will have to choose wether delete the status or not, just because it's not advertising the service, and getting this right looking at the current status is not easy (what if the speaker that should delete crashes for example)?
> > > So, given we are doing the same for BGP, I am not against to not naming the resource as the service but having a "per node" l2status instance (where the service name / node name can be added as labels as in BGP). The name can be the concatenation of service and node, or something autogenerated, but by doing this any speaker will take care of the lifecycle of a single instance and its life (and ours) will be easier.
> > > Hope I got things right
> > 
> > 
> > With this design, I think the controller logic could be simpler. As a user , if I want to investigate my service annoucement status, my command would be like:
> > ```shell
> > kubectl get servicel2status -n $NS --selector=metallb.io/service=$SVC-NAME
> > ```
> 
> And this would be the same as we are proposing for BGP (because then, we'd have multiple nodes advertising the service)
> 
> > whose output would contains a couple of servicel2status objects. If I am understanding this right, maybe we should optimize the servicel2status status object design.For example the `status.node` as a string would be empty except for the only one which is advertising, in this case a boolean type may be more clear?
> 
> What I am suggesting here is, if a given speaker is not advertising the service anymore, then it should delete its own instance. So at a given time, there would be only one l2 adv per service (assuming one speaker is advertising).

Got it.Sounds good to me. I'll start implement it. By the way, do we need to update the design doc before implementing?
fedepaol: > Got it.Sounds good to me. I'll start implement it. By the way, do we need to update the design doc before implementing?

I'd say it's not necessary. It's totally natural for an implementation to drift from a design, as reality differs from our speculations :P
lwabish: @oribon Hi, the latest ci workflow got an [error](https://github.com/metallb/metallb/actions/runs/7675640366/job/20932986745?pr=2198#step:10:250) because of lacking my new crd in metallb operator, which is another repo.

I'm not sure what is the best approach to deal with this situation: Skip some specific test, add my new crd to operator repo, or anything else? 
oribon: > @oribon Hi, the latest ci workflow got an [error](https://github.com/metallb/metallb/actions/runs/7675640366/job/20932986745?pr=2198#step:10:250) because of lacking my new crd in metallb operator, which is another repo.
> 
> I'm not sure what is the best approach to deal with this situation: Skip some specific test, add my new crd to operator repo, or anything else?

We can ignore it for now (this needs to be changed in the operator repo, known chicken-egg problem), is this ready for another round of review other than that?
lwabish: > > @oribon Hi, the latest ci workflow got an [error](https://github.com/metallb/metallb/actions/runs/7675640366/job/20932986745?pr=2198#step:10:250) because of lacking my new crd in metallb operator, which is another repo.
> > I'm not sure what is the best approach to deal with this situation: Skip some specific test, add my new crd to operator repo, or anything else?
> 
> We can ignore it for now (this needs to be changed in the operator repo, known chicken-egg problem), is this ready for another round of review other than that (if so can you please rebase your commits like last time)?

@oribon Rebase done.
By the way, I found metallb imports lint rules seems a little different from what I usually do. I'm used to  linting imports with [goimports-reviser](https://github.com/incu6us/goimports-reviser), which splits imports to three zones(go packages/external packages/project packages). This leads to my commits contain some imports modifications, which increased the likelihood of code conflicts.
So I wonder what is the best practice of go imports you guys are following. I guess `inv lint` is enough?
oribon: right `inv lint` should be enough (and we're forcing it in ci iirc)
thanks, I'll give this a look in the following days :) 
lwabish: @oribon Done :) 
lwabish: @oribon Wonderful experience working with you guys and I learned a lot from your reviews.Thank you, too.
Should I still rebase the commits to four like before or squash them all?
fedepaol: Folks, this looks really neat!
Sending to merge queue. Thanks a lot @lwabish for doing this, and @oribon for assisting!
fedepaol: @lwabish I think you need to regenerate the manifests?
lwabish: > @lwabish I think you need to regenerate the manifests?

Sorry I will fix that tonight
fedepaol: Please rebase to the right commit
lwabish: > Please rebase to the right commit

Ok, just finished
fedepaol: @lwabish apologies, I was sure I sent this to be merged.
Would you be so kind to rebase again and fix the conflicts? Thanks a lot!
lwabish: > @lwabish apologies, I was sure I sent this to be merged. Would you be so kind to rebase again and fix the conflicts? Thanks a lot!

No problem.
lwabish: > > @lwabish apologies, I was sure I sent this to be merged. Would you be so kind to rebase again and fix the conflicts? Thanks a lot!
> 
> No problem.

@fedepaol Just finished.

Patch:
diff --git a/api/v1beta1/servicel2status_types.go b/api/v1beta1/servicel2status_types.go
new file mode 100644
index 00000000..30ca0476
--- /dev/null
+++ b/api/v1beta1/servicel2status_types.go
@@ -0,0 +1,68 @@
+/*
+
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1beta1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +kubebuilder:object:generate=true
+
+// MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+type MetalLBServiceL2Status struct {
+	// Node indicates the node that receives the directed traffic
+	Node string `json:"node,omitempty"`
+	// Interfaces indicates the interfaces that receive the directed traffic
+	Interfaces []InterfaceInfo `json:"interfaces,omitempty"`
+}
+
+// InterfaceInfo defines interface info of layer2 announcement.
+type InterfaceInfo struct {
+	// Name the name of network interface card
+	Name string `json:"name,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:storageversion
+// +kubebuilder:printcolumn:name="Allocated Node",type=string,JSONPath=`.status.node`
+
+// ServiceL2Status reveals the actual traffic status of loadbalancer services in layer2 mode.
+type ServiceL2Status struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   ServiceL2StatusSpec    `json:"spec,omitempty"`
+	Status MetalLBServiceL2Status `json:"status,omitempty"`
+}
+
+// ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+type ServiceL2StatusSpec struct {
+}
+
+// +kubebuilder:object:root=true
+
+// ServiceL2StatusList contains a list of ServiceL2Status.
+type ServiceL2StatusList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ServiceL2Status `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&ServiceL2Status{}, &ServiceL2StatusList{})
+}
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 28e0fd4d..70f901b9 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -589,6 +589,21 @@ func (in *IPAddressPoolStatus) DeepCopy() *IPAddressPoolStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InterfaceInfo) DeepCopyInto(out *InterfaceInfo) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceInfo.
+func (in *InterfaceInfo) DeepCopy() *InterfaceInfo {
+	if in == nil {
+		return nil
+	}
+	out := new(InterfaceInfo)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *L2Advertisement) DeepCopyInto(out *L2Advertisement) {
 	*out = *in
@@ -722,6 +737,26 @@ func (in *MatchExpression) DeepCopy() *MatchExpression {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MetalLBServiceL2Status) DeepCopyInto(out *MetalLBServiceL2Status) {
+	*out = *in
+	if in.Interfaces != nil {
+		in, out := &in.Interfaces, &out.Interfaces
+		*out = make([]InterfaceInfo, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetalLBServiceL2Status.
+func (in *MetalLBServiceL2Status) DeepCopy() *MetalLBServiceL2Status {
+	if in == nil {
+		return nil
+	}
+	out := new(MetalLBServiceL2Status)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *NodeSelector) DeepCopyInto(out *NodeSelector) {
 	*out = *in
@@ -784,3 +819,77 @@ func (in *ServiceAllocation) DeepCopy() *ServiceAllocation {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServiceL2Status) DeepCopyInto(out *ServiceL2Status) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceL2Status.
+func (in *ServiceL2Status) DeepCopy() *ServiceL2Status {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceL2Status)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ServiceL2Status) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServiceL2StatusList) DeepCopyInto(out *ServiceL2StatusList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ServiceL2Status, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceL2StatusList.
+func (in *ServiceL2StatusList) DeepCopy() *ServiceL2StatusList {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceL2StatusList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ServiceL2StatusList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServiceL2StatusSpec) DeepCopyInto(out *ServiceL2StatusSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceL2StatusSpec.
+func (in *ServiceL2StatusSpec) DeepCopy() *ServiceL2StatusSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceL2StatusSpec)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/charts/metallb/charts/crds/templates/crds.yaml b/charts/metallb/charts/crds/templates/crds.yaml
index e61a2731..9b81cb57 100644
--- a/charts/metallb/charts/crds/templates/crds.yaml
+++ b/charts/metallb/charts/crds/templates/crds.yaml
@@ -857,3 +857,62 @@ spec:
       storage: true
       subresources:
         status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+    - additionalPrinterColumns:
+        - jsonPath: .status.node
+          name: Allocated Node
+          type: string
+      name: v1beta1
+      schema:
+        openAPIV3Schema:
+          description: ServiceL2Status reveals the actual traffic status of loadbalancer services in layer2 mode.
+          properties:
+            apiVersion:
+              description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+              type: string
+            kind:
+              description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+              type: string
+            metadata:
+              type: object
+            spec:
+              description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+              type: object
+            status:
+              description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+              properties:
+                interfaces:
+                  description: Interfaces indicates the interfaces that receive the directed traffic
+                  items:
+                    description: InterfaceInfo defines interface info of layer2 announcement.
+                    properties:
+                      name:
+                        description: Name the name of network interface card
+                        type: string
+                    type: object
+                  type: array
+                node:
+                  description: Node indicates the node that receives the directed traffic
+                  type: string
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources:
+        status: {}
diff --git a/charts/metallb/templates/rbac.yaml b/charts/metallb/templates/rbac.yaml
index 2f7d835d..914ff82a 100644
--- a/charts/metallb/templates/rbac.yaml
+++ b/charts/metallb/templates/rbac.yaml
@@ -58,6 +58,9 @@ rules:
 - apiGroups: [""]
   resources: ["events"]
   verbs: ["create", "patch"]
+- apiGroups: ["metallb.io"]
+  resources: ["servicel2statuses","servicel2statuses/status"]
+  verbs: ["*"]
 {{- if .Values.prometheus.secureMetricsPort }}
 - apiGroups: ["authentication.k8s.io"]
   resources: ["tokenreviews"]
diff --git a/config/crd/bases/metallb.io_servicel2statuses.yaml b/config/crd/bases/metallb.io_servicel2statuses.yaml
new file mode 100644
index 00000000..1cd14a4a
--- /dev/null
+++ b/config/crd/bases/metallb.io_servicel2statuses.yaml
@@ -0,0 +1,65 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 54d7c84c..d5641373 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -7,6 +7,7 @@ resources:
 - bases/metallb.io_bgpadvertisements.yaml
 - bases/metallb.io_l2advertisements.yaml
 - bases/metallb.io_communities.yaml
+- bases/metallb.io_servicel2statuses.yaml
 
 patches:
 - path: patches/crd-conversion-patch-bgppeers.yaml
diff --git a/config/manifests/metallb-frr-k8s-prometheus.yaml b/config/manifests/metallb-frr-k8s-prometheus.yaml
index ee27e97d..3d87004f 100644
--- a/config/manifests/metallb-frr-k8s-prometheus.yaml
+++ b/config/manifests/metallb-frr-k8s-prometheus.yaml
@@ -1557,6 +1557,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -2037,6 +2102,13 @@ rules:
   - tokenreviews
   verbs:
   - create
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/manifests/metallb-frr-k8s.yaml b/config/manifests/metallb-frr-k8s.yaml
index 1c961846..c41ea233 100644
--- a/config/manifests/metallb-frr-k8s.yaml
+++ b/config/manifests/metallb-frr-k8s.yaml
@@ -1552,6 +1552,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -1991,6 +2056,13 @@ metadata:
     app: metallb
   name: metallb-system:speaker
 rules:
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/manifests/metallb-frr-prometheus.yaml b/config/manifests/metallb-frr-prometheus.yaml
index d44cd236..81d092ba 100644
--- a/config/manifests/metallb-frr-prometheus.yaml
+++ b/config/manifests/metallb-frr-prometheus.yaml
@@ -1087,6 +1087,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -1407,6 +1472,13 @@ rules:
   - tokenreviews
   verbs:
   - create
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/manifests/metallb-frr.yaml b/config/manifests/metallb-frr.yaml
index ebde45ef..711d2b85 100644
--- a/config/manifests/metallb-frr.yaml
+++ b/config/manifests/metallb-frr.yaml
@@ -1087,6 +1087,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -1366,6 +1431,13 @@ metadata:
     app: metallb
   name: metallb-system:speaker
 rules:
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/manifests/metallb-native-prometheus.yaml b/config/manifests/metallb-native-prometheus.yaml
index 0175cd4f..a6d5936c 100644
--- a/config/manifests/metallb-native-prometheus.yaml
+++ b/config/manifests/metallb-native-prometheus.yaml
@@ -1087,6 +1087,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -1407,6 +1472,13 @@ rules:
   - tokenreviews
   verbs:
   - create
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/manifests/metallb-native.yaml b/config/manifests/metallb-native.yaml
index f7cd18cd..947c35b3 100644
--- a/config/manifests/metallb-native.yaml
+++ b/config/manifests/metallb-native.yaml
@@ -1087,6 +1087,71 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: servicel2statuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceL2Status
+    listKind: ServiceL2StatusList
+    plural: servicel2statuses
+    singular: servicel2status
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Allocated Node
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceL2Status reveals the actual traffic status of loadbalancer
+          services in layer2 mode.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: ServiceL2StatusSpec defines the desired state of ServiceL2Status.
+            type: object
+          status:
+            description: MetalLBServiceL2Status defines the observed state of ServiceL2Status.
+            properties:
+              interfaces:
+                description: Interfaces indicates the interfaces that receive the
+                  directed traffic
+                items:
+                  description: InterfaceInfo defines interface info of layer2 announcement.
+                  properties:
+                    name:
+                      description: Name the name of network interface card
+                      type: string
+                  type: object
+                type: array
+              node:
+                description: Node indicates the node that receives the directed traffic
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -1366,6 +1431,13 @@ metadata:
     app: metallb
   name: metallb-system:speaker
 rules:
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicel2statuses
+  - servicel2statuses/status
+  verbs:
+  - '*'
 - apiGroups:
   - ""
   resources:
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 15e6008e..01066398 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -98,6 +98,13 @@ metadata:
     app: metallb
   name: metallb-system:speaker
 rules:
+  - apiGroups:
+      - "metallb.io"
+    resources:
+      - "servicel2statuses"
+      - "servicel2statuses/status"
+    verbs:
+      - "*"
   - apiGroups:
       - ""
     resources:
diff --git a/internal/k8s/controllers/layer2_status_controller.go b/internal/k8s/controllers/layer2_status_controller.go
new file mode 100644
index 00000000..16ba6597
--- /dev/null
+++ b/internal/k8s/controllers/layer2_status_controller.go
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package controllers
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/go-kit/log"
+	"github.com/go-kit/log/level"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+
+	"go.universe.tf/metallb/api/v1beta1"
+	"go.universe.tf/metallb/internal/layer2"
+)
+
+type StatusFetcher func(types.NamespacedName) []layer2.IPAdvertisement
+
+type l2StatusEvent struct {
+	metav1.TypeMeta
+	metav1.ObjectMeta
+}
+
+func (evt *l2StatusEvent) DeepCopyObject() runtime.Object {
+	res := new(l2StatusEvent)
+	res.Name = evt.Name
+	res.Namespace = evt.Namespace
+	return res
+}
+
+func NewL2StatusEvent(namespace, name string) event.GenericEvent {
+	evt := l2StatusEvent{}
+	evt.Name = name
+	evt.Namespace = namespace
+	return event.GenericEvent{Object: &evt}
+}
+
+type Layer2StatusReconciler struct {
+	client.Client
+	Logger        log.Logger
+	NodeName      string
+	ReconcileChan <-chan event.GenericEvent
+	// fetch ipAdv object to get interface info
+	StatusFetcher StatusFetcher
+}
+
+func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	level.Info(r.Logger).Log("controller", "Layer2StatusReconciler", "start reconcile", req.NamespacedName.String())
+	defer level.Info(r.Logger).Log("controller", "Layer2StatusReconciler", "end reconcile", req.NamespacedName.String())
+
+	svcName := strings.TrimSuffix(req.Name, fmt.Sprintf("-%s", r.NodeName))
+	ipAdvS := r.StatusFetcher(types.NamespacedName{Name: svcName, Namespace: req.Namespace})
+
+	if len(ipAdvS) == 0 {
+		err := r.Client.Delete(ctx, &v1beta1.ServiceL2Status{ObjectMeta: metav1.ObjectMeta{Name: req.Name, Namespace: req.Namespace}})
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	state := &v1beta1.ServiceL2Status{ObjectMeta: metav1.ObjectMeta{
+		Name:      req.Name,
+		Namespace: req.Namespace,
+	}}
+	err := r.Client.Get(ctx, types.NamespacedName{Name: req.Name, Namespace: req.Namespace}, state)
+	if err != nil && !errors.IsNotFound(err) {
+		return ctrl.Result{}, err
+	}
+
+	desiredStatus := r.buildDesiredStatus(ipAdvS)
+	if reflect.DeepEqual(state.Status, desiredStatus) {
+		return ctrl.Result{}, nil
+	}
+
+	var result controllerutil.OperationResult
+	result, err = controllerutil.CreateOrPatch(ctx, r.Client, state, func() error {
+		state.Labels = map[string]string{LabelAnnounceNode: r.NodeName, LabelServiceName: svcName}
+		state.Status = desiredStatus
+		return nil
+	})
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if result == controllerutil.OperationResultCreated {
+		// According to https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrPatch
+		// If the object is created for the first time, we have to requeue it to ensure that the status is updated.
+		return ctrl.Result{Requeue: true}, nil
+	}
+
+	level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "updated state", dumpResource(state))
+
+	return ctrl.Result{}, nil
+}
+
+func (r *Layer2StatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	p := predicate.NewPredicateFuncs(func(object client.Object) bool {
+		if s, ok := object.(*v1beta1.ServiceL2Status); ok {
+			return strings.HasSuffix(s.Name, r.NodeName)
+		}
+		return true
+	})
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1beta1.ServiceL2Status{}).
+		WatchesRawSource(&source.Channel{Source: r.ReconcileChan}, handler.EnqueueRequestsFromMapFunc(
+			func(ctx context.Context, object client.Object) []reconcile.Request {
+				evt, ok := object.(*l2StatusEvent)
+				if !ok {
+					level.Error(r.Logger).Log("controller", "Layer2StatusReconciler", "received an object that is not a l2StatusEvent from channel", "object", object)
+					return []reconcile.Request{}
+				}
+				level.Debug(r.Logger).Log("controller", "Layer2StatusReconciler", "enqueueing", "object", evt)
+				// unify req.Name to svcName-nodeName
+				return []reconcile.Request{{NamespacedName: types.NamespacedName{
+					Name:      fmt.Sprintf("%s-%s", evt.Name, r.NodeName),
+					Namespace: evt.Namespace}}}
+			})).
+		WithEventFilter(p).
+		Complete(r)
+}
+
+func (r *Layer2StatusReconciler) buildDesiredStatus(advertisements []layer2.IPAdvertisement) v1beta1.MetalLBServiceL2Status {
+	// todo: add advertise ip or not?
+	s := v1beta1.MetalLBServiceL2Status{
+		Node: r.NodeName,
+	}
+	// multiple advertisement objects share all fields except lb ip, so we use the first one
+	adv := advertisements[0]
+	if !adv.IsAllInterfaces() {
+		for inf := range adv.GetInterfaces() {
+			s.Interfaces = append(s.Interfaces, v1beta1.InterfaceInfo{Name: inf})
+		}
+	}
+	return s
+}
diff --git a/internal/k8s/controllers/values.go b/internal/k8s/controllers/values.go
index 172e3df2..ffe41339 100644
--- a/internal/k8s/controllers/values.go
+++ b/internal/k8s/controllers/values.go
@@ -29,4 +29,9 @@ const (
 	EndpointSlices
 )
 
+const (
+	LabelAnnounceNode = "metallb.io/node"
+	LabelServiceName  = "metallb.io/service"
+)
+
 var errRetry = errors.New("event handling failed, retrying")
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
index 6183aef5..5bf85137 100644
--- a/internal/k8s/k8s.go
+++ b/internal/k8s/k8s.go
@@ -117,6 +117,8 @@ type Config struct {
 	WebhookWithHTTP2    bool
 	WithFRRK8s          bool
 	Listener
+	Layer2StatusChan    <-chan event.GenericEvent
+	Layer2StatusFetcher controllers.StatusFetcher
 }
 
 // New connects to masterAddr, using kubeconfig to authenticate.
@@ -275,6 +277,19 @@ func New(cfg *Config) (*Client, error) {
 		}
 	}
 
+	// metallb controller doesn't need this reconciler
+	if cfg.Layer2StatusChan != nil {
+		if err = (&controllers.Layer2StatusReconciler{
+			Client:        mgr.GetClient(),
+			Logger:        cfg.Logger,
+			NodeName:      cfg.NodeName,
+			ReconcileChan: cfg.Layer2StatusChan,
+			StatusFetcher: cfg.Layer2StatusFetcher,
+		}).SetupWithManager(mgr); err != nil {
+			level.Error(c.logger).Log("error", err, "unable to create controller", "layer2Status")
+		}
+	}
+
 	startListeners := make(chan struct{})
 	go func(l log.Logger) {
 		// We start the webhooks and the metric at the same time so the readiness probe will
diff --git a/internal/layer2/announcer.go b/internal/layer2/announcer.go
index c63101e6..863b814b 100644
--- a/internal/layer2/announcer.go
+++ b/internal/layer2/announcer.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/go-kit/log"
 	"github.com/go-kit/log/level"
+	"k8s.io/apimachinery/pkg/types"
 )
 
 // Announce is used to "announce" new IPs mapped to the node's MAC address.
@@ -321,6 +322,13 @@ func (a *Announce) AnnounceName(name string) bool {
 	return ok
 }
 
+// GetStatus expose adv status.
+func (a *Announce) GetStatus(meta types.NamespacedName) []IPAdvertisement {
+	a.RLock()
+	defer a.RUnlock()
+	return a.ips[meta.String()]
+}
+
 // GetInterfaces returns current interfaces list.
 func (a *Announce) GetInterfaces() []string {
 	a.Lock()
diff --git a/internal/layer2/ip_advertisement.go b/internal/layer2/ip_advertisement.go
index 298a895c..1760d723 100644
--- a/internal/layer2/ip_advertisement.go
+++ b/internal/layer2/ip_advertisement.go
@@ -63,3 +63,10 @@ func (i *IPAdvertisement) matchInterface(intf string) bool {
 	}
 	return i.interfaces.Has(intf)
 }
+func (i *IPAdvertisement) IsAllInterfaces() bool {
+	return i.allInterfaces
+}
+
+func (i *IPAdvertisement) GetInterfaces() sets.Set[string] {
+	return i.interfaces
+}
diff --git a/speaker/layer2_controller.go b/speaker/layer2_controller.go
index 778c875f..175ad808 100644
--- a/speaker/layer2_controller.go
+++ b/speaker/layer2_controller.go
@@ -22,13 +22,16 @@ import (
 
 	"github.com/go-kit/log"
 	"github.com/go-kit/log/level"
+	v1 "k8s.io/api/core/v1"
+	discovery "k8s.io/api/discovery/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/tools/cache"
+
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s/epslices"
 	k8snodes "go.universe.tf/metallb/internal/k8s/nodes"
 	"go.universe.tf/metallb/internal/layer2"
-	v1 "k8s.io/api/core/v1"
-	discovery "k8s.io/api/discovery/v1"
-	"k8s.io/apimachinery/pkg/util/sets"
 )
 
 type layer2Controller struct {
@@ -36,6 +39,7 @@ type layer2Controller struct {
 	myNode          string
 	ignoreExcludeLB bool
 	sList           SpeakerList
+	onStatusChange  func(types.NamespacedName)
 }
 
 func (c *layer2Controller) SetConfig(log.Logger, *config.Config) error {
@@ -128,6 +132,7 @@ func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce
 
 func (c *layer2Controller) SetBalancer(l log.Logger, name string, lbIPs []net.IP, pool *config.Pool, client service, svc *v1.Service) error {
 	ifs := c.announcer.GetInterfaces()
+	updateStatus := false
 	for _, lbIP := range lbIPs {
 		ipAdv := ipAdvertisementFor(lbIP, c.myNode, pool.L2Advertisements)
 		if !ipAdv.MatchInterfaces(ifs...) {
@@ -137,6 +142,10 @@ func (c *layer2Controller) SetBalancer(l log.Logger, name string, lbIPs []net.IP
 			continue
 		}
 		c.announcer.SetBalancer(name, ipAdv)
+		updateStatus = true
+	}
+	if updateStatus {
+		c.onStatusChange(types.NamespacedName{Name: svc.Name, Namespace: svc.Namespace})
 	}
 	return nil
 }
@@ -146,6 +155,13 @@ func (c *layer2Controller) DeleteBalancer(l log.Logger, name, reason string) err
 		return nil
 	}
 	c.announcer.DeleteBalancer(name)
+
+	svcNamespace, svcName, err := cache.SplitMetaNamespaceKey(name)
+	if err != nil {
+		level.Warn(l).Log("op", "DeleteBalancer", "protocol", "layer2", "service", name, "msg", "failed to split key", "err", err)
+		return err
+	}
+	c.onStatusChange(types.NamespacedName{Name: svcName, Namespace: svcNamespace})
 	return nil
 }
 
diff --git a/speaker/main.go b/speaker/main.go
index fd81f024..a032aa99 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -28,19 +28,21 @@ import (
 	"github.com/go-kit/log"
 	"github.com/go-kit/log/level"
 	"github.com/prometheus/client_golang/prometheus"
+	v1 "k8s.io/api/core/v1"
+	discovery "k8s.io/api/discovery/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/yaml"
+
 	"go.universe.tf/metallb/internal/bgp"
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s"
 	"go.universe.tf/metallb/internal/k8s/controllers"
 	k8snodes "go.universe.tf/metallb/internal/k8s/nodes"
-
 	"go.universe.tf/metallb/internal/layer2"
 	"go.universe.tf/metallb/internal/logging"
 	"go.universe.tf/metallb/internal/speakerlist"
 	"go.universe.tf/metallb/internal/version"
-	v1 "k8s.io/api/core/v1"
-	discovery "k8s.io/api/discovery/v1"
-	"sigs.k8s.io/yaml"
 )
 
 var announcing = prometheus.NewGaugeVec(prometheus.GaugeOpts{
@@ -148,7 +150,7 @@ func main() {
 		level.Error(logger).Log("op", "startup", "msg", "failed to parse announcedInterfacesToExclude from configMap", "error", err)
 		os.Exit(1)
 	}
-
+	statusNotifyChan := make(chan event.GenericEvent)
 	// Setup all clients and speakers, config decides what is being done runtime.
 	ctrl, err := newController(controllerConfig{
 		MyNode:                 *myNode,
@@ -159,6 +161,9 @@ func main() {
 		bgpType:                bgpImplementation(bgpType),
 		InterfaceExcludeRegexp: interfacesToExclude,
 		IgnoreExcludeLB:        *ignoreLBExclude,
+		Layer2StatusChange: func(namespacedName types.NamespacedName) {
+			statusNotifyChan <- controllers.NewL2StatusEvent(namespacedName.Namespace, namespacedName.Name)
+		},
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create MetalLB controller")
@@ -195,6 +200,9 @@ func main() {
 		ValidateConfig:    validateConfig,
 		LoadBalancerClass: *loadBalancerClass,
 		WithFRRK8s:        listenFRRK8s,
+
+		Layer2StatusChan:    statusNotifyChan,
+		Layer2StatusFetcher: ctrl.layer2StatusFetchFunc,
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create k8s client")
@@ -225,6 +233,8 @@ type controller struct {
 	svcIPs           map[string][]net.IP              // service name -> assigned IPs
 
 	protocols []config.Proto
+
+	layer2StatusFetchFunc controllers.StatusFetcher
 }
 
 type controllerConfig struct {
@@ -243,6 +253,7 @@ type controllerConfig struct {
 	AnnouncedInterfacesToExclude []string `yaml:"announcedInterfacesToExclude"`
 	InterfaceExcludeRegexp       *regexp.Regexp
 	IgnoreExcludeLB              bool
+	Layer2StatusChange           func(types.NamespacedName)
 }
 
 func newController(cfg controllerConfig) (*controller, error) {
@@ -258,8 +269,10 @@ func newController(cfg controllerConfig) (*controller, error) {
 	}
 	protocols := []config.Proto{config.BGP}
 
+	layer2StatusFetcher := func(types.NamespacedName) []layer2.IPAdvertisement { return nil }
 	if !cfg.DisableLayer2 {
 		a, err := layer2.New(cfg.Logger, cfg.InterfaceExcludeRegexp)
+		layer2StatusFetcher = a.GetStatus
 		if err != nil {
 			return nil, fmt.Errorf("making layer2 announcer: %s", err)
 		}
@@ -268,17 +281,19 @@ func newController(cfg controllerConfig) (*controller, error) {
 			myNode:          cfg.MyNode,
 			sList:           cfg.SList,
 			ignoreExcludeLB: cfg.IgnoreExcludeLB,
+			onStatusChange:  cfg.Layer2StatusChange,
 		}
 		protocols = append(protocols, config.Layer2)
 	}
 
 	ret := &controller{
-		myNode:           cfg.MyNode,
-		bgpType:          cfg.bgpType,
-		protocolHandlers: handlers,
-		announced:        map[config.Proto]map[string]bool{},
-		svcIPs:           map[string][]net.IP{},
-		protocols:        protocols,
+		myNode:                cfg.MyNode,
+		bgpType:               cfg.bgpType,
+		protocolHandlers:      handlers,
+		announced:             map[config.Proto]map[string]bool{},
+		svcIPs:                map[string][]net.IP{},
+		protocols:             protocols,
+		layer2StatusFetchFunc: layer2StatusFetcher,
 	}
 	ret.announced[config.BGP] = map[string]bool{}
 	ret.announced[config.Layer2] = map[string]bool{}
diff --git a/website/content/apis/_index.md b/website/content/apis/_index.md
index 5bef6db0..3674536b 100644
--- a/website/content/apis/_index.md
+++ b/website/content/apis/_index.md
@@ -181,6 +181,8 @@ _Appears in:_
 | `serviceAllocation` _[ServiceAllocation](#serviceallocation)_ | AllocateTo makes ip pool allocation to specific namespace and/or service. The controller will use the pool with lowest value of priority in case of multiple matches. A pool with no priority set will be used only if the pools with priority can't be used. If multiple matching IPAddressPools are available it will check for the availability of IPs sorting the matching IPAddressPools by priority, starting from the highest to the lowest. If multiple IPAddressPools have the same priority, choice will be random. |
 
 
+
+
 #### L2Advertisement
 
 
@@ -234,6 +236,8 @@ _Appears in:_
 
 
 
+
+
 ## metallb.io/v1beta2
 
 
diff --git a/website/content/release-notes/_index.md b/website/content/release-notes/_index.md
index def2fe17..d44af8d5 100644
--- a/website/content/release-notes/_index.md
+++ b/website/content/release-notes/_index.md
@@ -61,6 +61,7 @@ controller ([PR 2004](https://github.com/metallb/metallb/pull/2004), [Issue 1984
 - Implement NodeExcludeBalancers to exclude nodes as external loadbalancer ([PR 2073](https://github.com/metallb/metallb/pull/2073), [ISSUE 2021](https://github.com/metallb/metallb/issues/2021))
 - Metrics: add ipv4/6 addresses_in_use_total and addresses_total ([PR 2151](https://github.com/metallb/metallb/pull/2151))
 - E2E: Verify autoAssign=False works([PR 2264](https://github.com/metallb/metallb/pull/2264),[ISSUE 2221](https://github.com/metallb/metallb/issues/2221))
+- Implement ServiceL2Status CRD to expose service announcing status in layer2 mode ([PR 2198](https://github.com/metallb/metallb/pull/2198))
 
 Bug Fixes:
 


Test patch:
diff --git a/e2etest/go.mod b/e2etest/go.mod
index 28095890..1adac1a5 100644
--- a/e2etest/go.mod
+++ b/e2etest/go.mod
@@ -19,6 +19,7 @@ replace (
 	k8s.io/cri-api => k8s.io/cri-api v0.28.2
 	k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.28.2
 	k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.28.2
+	k8s.io/endpointslice => k8s.io/endpointslice v0.28.2
 	k8s.io/kms => k8s.io/kms v0.28.2
 	k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.28.2
 	k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.28.2
diff --git a/e2etest/l2tests/interface_selector.go b/e2etest/l2tests/interface_selector.go
index 625f6c81..ead45dbc 100644
--- a/e2etest/l2tests/interface_selector.go
+++ b/e2etest/l2tests/interface_selector.go
@@ -21,9 +21,9 @@ import (
 	"go.universe.tf/e2etest/pkg/executor"
 	"go.universe.tf/e2etest/pkg/k8s"
 	"go.universe.tf/e2etest/pkg/mac"
-
 	"go.universe.tf/e2etest/pkg/metallb"
 	"go.universe.tf/e2etest/pkg/service"
+	"go.universe.tf/e2etest/pkg/status"
 )
 
 var (
@@ -83,6 +83,61 @@ var _ = ginkgo.Describe("L2-interface selector", func() {
 			framework.ExpectNoError(err)
 		})
 
+		ginkgo.It("Validate ServiceL2Status interface", func() {
+			ginkgo.By("use the 1st interface for announcing")
+			resources := config.Resources{
+				L2Advs: []metallbv1beta1.L2Advertisement{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "with-interfaces",
+						},
+						Spec: metallbv1beta1.L2AdvertisementSpec{
+							Interfaces: []string{NodeNics[0]},
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			svc, _ := service.CreateWithBackend(cs, f.Namespace.Name, "lb-service")
+			defer func() {
+				err := cs.CoreV1().Services(svc.Namespace).Delete(context.TODO(), svc.Name, metav1.DeleteOptions{})
+				framework.ExpectNoError(err)
+			}()
+
+			allNodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
+			framework.ExpectNoError(err)
+
+			var l2Status *metallbv1beta1.ServiceL2Status
+			gomega.Eventually(func() error {
+				var ss []*metallbv1beta1.ServiceL2Status
+				if ss, err = status.GetSvcPossibleL2Status(ConfigUpdater.Client(), svc, allNodes); err == nil {
+					if len(ss) != 1 {
+						return fmt.Errorf("")
+					}
+					l2Status = ss[0]
+				}
+				return err
+			}, 2*time.Minute, 1*time.Second).ShouldNot(gomega.HaveOccurred())
+
+			gomega.Consistently(func() error {
+				l2Status, err = status.GetL2Status(ConfigUpdater.Client(), svc, l2Status.Status.Node)
+				if err != nil || len(l2Status.Status.Interfaces) == 0 {
+					return fmt.Errorf("")
+				}
+				return nil
+			}, 5*time.Second).ShouldNot(gomega.HaveOccurred())
+
+			gomega.Consistently(func() error {
+				l2Status, err = status.GetL2Status(ConfigUpdater.Client(), svc, l2Status.Status.Node)
+				if err != nil || l2Status.Status.Interfaces[0].Name != NodeNics[0] {
+					return fmt.Errorf("")
+				}
+				return nil
+			}, 5*time.Second)
+		})
+
 		ginkgo.It("Validate the LB IP's mac", func() {
 			resources := config.Resources{
 				L2Advs: []metallbv1beta1.L2Advertisement{
diff --git a/e2etest/l2tests/l2.go b/e2etest/l2tests/l2.go
index e44290df..4e478d87 100644
--- a/e2etest/l2tests/l2.go
+++ b/e2etest/l2tests/l2.go
@@ -29,6 +29,16 @@ import (
 	"github.com/onsi/ginkgo/v2"
 	"github.com/onsi/gomega"
 	"github.com/openshift-kni/k8sreporter"
+	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
+	corev1 "k8s.io/api/core/v1"
+	pkgerr "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+	clientset "k8s.io/client-go/kubernetes"
+	"k8s.io/kubernetes/test/e2e/framework"
+	e2eservice "k8s.io/kubernetes/test/e2e/framework/service"
+	admissionapi "k8s.io/pod-security-admission/api"
+
 	"go.universe.tf/e2etest/pkg/config"
 	"go.universe.tf/e2etest/pkg/executor"
 	"go.universe.tf/e2etest/pkg/iprange"
@@ -37,18 +47,9 @@ import (
 	"go.universe.tf/e2etest/pkg/metallb"
 	"go.universe.tf/e2etest/pkg/metrics"
 	"go.universe.tf/e2etest/pkg/service"
+	"go.universe.tf/e2etest/pkg/status"
 	"go.universe.tf/e2etest/pkg/udp"
-	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
-
 	"go.universe.tf/e2etest/pkg/wget"
-	corev1 "k8s.io/api/core/v1"
-	pkgerr "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/intstr"
-	clientset "k8s.io/client-go/kubernetes"
-	"k8s.io/kubernetes/test/e2e/framework"
-	e2eservice "k8s.io/kubernetes/test/e2e/framework/service"
-	admissionapi "k8s.io/pod-security-admission/api"
 )
 
 var (
@@ -128,6 +129,33 @@ var _ = ginkgo.Describe("L2", func() {
 			gomega.Eventually(func() error {
 				return service.ValidateL2(svc)
 			}, 2*time.Minute, 1*time.Second).ShouldNot(gomega.HaveOccurred())
+
+			ginkgo.By("checking correct serviceL2Status object is populated")
+
+			allNodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
+			framework.ExpectNoError(err)
+
+			var l2Statuses []*metallbv1beta1.ServiceL2Status
+			gomega.Eventually(func() error {
+				var ss []*metallbv1beta1.ServiceL2Status
+				if ss, err = status.GetSvcPossibleL2Status(ConfigUpdater.Client(), svc, allNodes); err == nil {
+					l2Statuses = ss
+				}
+				return err
+			}, 2*time.Minute, 1*time.Second).ShouldNot(gomega.HaveOccurred())
+			gomega.Expect(l2Statuses).To(gomega.HaveLen(1))
+
+			ginkgo.By("validating the node in ServiceL2Status is the one who is announcing for the service")
+			l2Status := l2Statuses[0]
+			refreshL2StatusNodeFunc := func() string {
+				node, err := k8s.GetSvcNode(cs, svc.Namespace, svc.Name, allNodes)
+				if err != nil {
+					return err.Error()
+				}
+				return node.Name
+			}
+			gomega.Eventually(refreshL2StatusNodeFunc, time.Minute, time.Second).Should(gomega.Equal(l2Status.Status.Node))
+			gomega.Consistently(refreshL2StatusNodeFunc, 5*time.Second).Should(gomega.Equal(l2Status.Status.Node))
 		})
 
 		ginkgo.It("should work for ExternalTrafficPolicy=Local", func() {
diff --git a/e2etest/pkg/status/l2status.go b/e2etest/pkg/status/l2status.go
new file mode 100644
index 00000000..7d683191
--- /dev/null
+++ b/e2etest/pkg/status/l2status.go
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package status
+
+import (
+	"context"
+	"fmt"
+
+	"go.universe.tf/metallb/api/v1beta1"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+func GetL2Status(cs client.Client, svc *v1.Service, nodeName string) (*v1beta1.ServiceL2Status, error) {
+	s := &v1beta1.ServiceL2Status{}
+	if err := cs.Get(context.TODO(), types.NamespacedName{
+		Namespace: svc.Namespace,
+		Name:      fmt.Sprintf("%s-%s", svc.Name, nodeName),
+	}, s); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+func GetSvcPossibleL2Status(cs client.Client, svc *v1.Service, nodes *v1.NodeList) ([]*v1beta1.ServiceL2Status, error) {
+	var l2Statuses []*v1beta1.ServiceL2Status
+	for _, node := range nodes.Items {
+		s, err := GetL2Status(cs, svc, node.Name)
+		if err != nil && errors.IsNotFound(err) {
+			continue
+		}
+		if err != nil {
+			return nil, err
+		}
+		l2Statuses = append(l2Statuses, s)
+	}
+	return l2Statuses, nil
+}
diff --git a/internal/k8s/controllers/reconciliation_test.go b/internal/k8s/controllers/reconciliation_test.go
index 4891510f..ffcb027a 100644
--- a/internal/k8s/controllers/reconciliation_test.go
+++ b/internal/k8s/controllers/reconciliation_test.go
@@ -4,6 +4,8 @@ package controllers
 
 import (
 	"context"
+	"fmt"
+	"net"
 	"path/filepath"
 	"sync"
 	"sync/atomic"
@@ -14,22 +16,26 @@ import (
 	frrv1beta1 "github.com/metallb/frr-k8s/api/v1beta1"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	v1beta1 "go.universe.tf/metallb/api/v1beta1"
-	v1beta2 "go.universe.tf/metallb/api/v1beta2"
-	frrk8s "go.universe.tf/metallb/internal/bgp/frrk8s"
-	"go.universe.tf/metallb/internal/config"
 	corev1 "k8s.io/api/core/v1"
 	discovery "k8s.io/api/discovery/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/client-go/rest"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/envtest"
+	"sigs.k8s.io/controller-runtime/pkg/event"
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+
+	v1beta1 "go.universe.tf/metallb/api/v1beta1"
+	v1beta2 "go.universe.tf/metallb/api/v1beta2"
+	frrk8s "go.universe.tf/metallb/internal/bgp/frrk8s"
+	"go.universe.tf/metallb/internal/config"
+	"go.universe.tf/metallb/internal/layer2"
 )
 
 /*
@@ -52,11 +58,21 @@ var (
 	nodeMutex        sync.Mutex
 	nodeConfigUpdate int
 
-	frrk8sReconciler *FRRK8sReconciler
+	frrk8sReconciler       *FRRK8sReconciler
+	layer2StatusReconciler *Layer2StatusReconciler
+	layer2StatusUpdateChan = make(chan event.GenericEvent)
+	layer2ServiceAdvs      []layer2.IPAdvertisement
+	layer2ServiceAdvLock   sync.Mutex
+	updateLayer2Advs       = func(advs []layer2.IPAdvertisement) {
+		layer2ServiceAdvLock.Lock()
+		defer layer2ServiceAdvLock.Unlock()
+		layer2ServiceAdvs = advs
+	}
 )
 
 const (
-	testNodeName = "testnode"
+	testNodeName    = "testnode"
+	testServiceName = "test-service"
 )
 
 func TestManager(t *testing.T) {
@@ -147,6 +163,25 @@ var _ = BeforeSuite(func() {
 	err = frrk8sReconciler.SetupWithManager(k8sManager)
 	Expect(err).ToNot(HaveOccurred())
 
+	layer2StatusReconciler = &Layer2StatusReconciler{
+		Client:        k8sManager.GetClient(),
+		Logger:        log.NewNopLogger(),
+		NodeName:      testNodeName,
+		ReconcileChan: layer2StatusUpdateChan,
+		StatusFetcher: func(nn types.NamespacedName) []layer2.IPAdvertisement {
+			layer2ServiceAdvLock.Lock()
+			defer layer2ServiceAdvLock.Unlock()
+			// only advertise for the test service to prevent
+			// controller from reconcile infinitely
+			if nn.Name == testServiceName {
+				return layer2ServiceAdvs
+			}
+			return []layer2.IPAdvertisement{}
+		},
+	}
+	err = layer2StatusReconciler.SetupWithManager(k8sManager)
+	Expect(err).ToNot(HaveOccurred())
+
 	ctx, cancel = context.WithCancel(context.TODO())
 
 	go func() {
@@ -426,3 +461,51 @@ var _ = Describe("FRRK8S Controller", func() {
 		})
 	})
 })
+
+var _ = Describe("Layer2 Status Controller", func() {
+	Context("SetupWithManager", func() {
+		It("Should Reconcile correctly", func() {
+
+			statusObjName := fmt.Sprintf("%s-%s", testServiceName, layer2StatusReconciler.NodeName)
+
+			// simulate some service is advertised
+			layer2ServiceAdvs = append(layer2ServiceAdvs,
+				layer2.NewIPAdvertisement(net.IP("127.0.0.1"), true, sets.Set[string]{}))
+			// notify reconciler to reconcile
+			layer2StatusUpdateChan <- NewL2StatusEvent(testNamespace, testServiceName)
+			Eventually(func() string {
+				toCheck := v1beta1.ServiceL2Status{}
+				err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: statusObjName, Namespace: testNamespace}, &toCheck)
+				if err != nil {
+					return ""
+				}
+				return toCheck.Status.Node
+			}, 5*time.Second, 200*time.Millisecond).Should(Equal(testNodeName))
+
+			// simulate the service advertisement interface changed
+			interfaces := sets.Set[string]{}
+			newInterface := "eth0"
+			interfaces.Insert(newInterface)
+			updateLayer2Advs([]layer2.IPAdvertisement{layer2.NewIPAdvertisement(net.IP("127.0.0.1"), false, interfaces)})
+			layer2StatusUpdateChan <- NewL2StatusEvent(testNamespace, testServiceName)
+			Eventually(func() string {
+				toCheck := v1beta1.ServiceL2Status{}
+				err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: statusObjName, Namespace: testNamespace}, &toCheck)
+				if err != nil || len(toCheck.Status.Interfaces) != 1 {
+					return ""
+				}
+				return toCheck.Status.Interfaces[0].Name
+			}, 5*time.Second, 200*time.Millisecond).Should(Equal(newInterface))
+
+			// simulate the service is not advertised anymore
+			updateLayer2Advs([]layer2.IPAdvertisement{})
+			// notify the reconciler again
+			layer2StatusUpdateChan <- NewL2StatusEvent(testNamespace, testServiceName)
+			Eventually(func() bool {
+				toCheck := v1beta1.ServiceL2Status{}
+				err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: statusObjName, Namespace: testNamespace}, &toCheck)
+				return apierrors.IsNotFound(err)
+			}, 5*time.Second, 200*time.Millisecond).Should(Equal(true))
+		})
+	})
+})