Repository: metallb/metallb
Pull request #1536 (2022-07-26 08:42:31)

Support L2 mode specify interfaces for announcing LB IP
This PR implement the feature that MetalLB announce IP from specified interfaces for layer2. Related design is https://github.com/metallb/metallb/pull/1359.

I has add two basic e2etest about interface selector.


fedepaol: Overall looks good. I need to sleep a bit over the select loop and look at it carefully.
Also, I'd be more comfortable in having at least one or two basic e2e tests as part of this PR. This will give us confidence that we are merging something that works.
fedepaol: Couple of extra notes: I'd change the commit message (we are trying to adhere to https://cbea.ms/git-commit/ as guidelines)
For later, when we converge on the review:
I'd add also a commit changing the release notes (0.13.5 is not there yet, you can add it) to mention this feature.
I'd add a commit expanding the "advanced l2 configuration" section of the doc with this change
fedepaol: Another round, mostly nits. It's converging :-)
fedepaol: Just nits, I like this a lot!
One extra thought: if we are able to maintain a "allInterfacesForAllIPs" sort of map (maybe with a better name) we could even avoid creating the responders here, providing a better optimization.
That list is updated every 10 seconds.
fedepaol: Also, I'd like to get an extra :eyes: from @oribon, to see if I missed something
fedepaol: > Just nits, I like this a lot! One extra thought: if we are able to maintain a "allInterfacesForAllIPs" sort of map (maybe with a better name) we could even avoid creating the responders here, providing a better optimization. That list is updated every 10 seconds.

Just to be clear: please note that we can have this in without the optimization I wrote about ^^^^
The behaviour will be similar to what we have today, and I'd rather measure the benefit before even adding the extra logic.
qingwusunny: @fedepaol I have modified the func SetBalancer in announcer.go，review again?
qingwusunny: > Couple of extra notes: I'd change the commit message (we are trying to adhere to https://cbea.ms/git-commit/ as guidelines) For later, when we converge on the review: I'd add also a commit changing the release notes (0.13.5 is not there yet, you can add it) to mention this feature. I'd add a commit expanding the "advanced l2 configuration" section of the doc with this change

Add two commit for release notes and doc in this PR?
qingwusunny: > @fedepaol I have modified the func SetBalancer in announcer.go，review again?

And @oribon 
fedepaol: > > Couple of extra notes: I'd change the commit message (we are trying to adhere to https://cbea.ms/git-commit/ as guidelines) For later, when we converge on the review: I'd add also a commit changing the release notes (0.13.5 is not there yet, you can add it) to mention this feature. I'd add a commit expanding the "advanced l2 configuration" section of the doc with this change
> 
> Add two commit for release notes and doc in this PR?

Yes exactly, so when we cut a new release we won't have to add a new pr for that.
fedepaol: > @fedepaol I have modified the func SetBalancer in announcer.go，review again?

Sorry for the delay, I was off. Reviewed again!
fedepaol: > > > Couple of extra notes: I'd change the commit message (we are trying to adhere to https://cbea.ms/git-commit/ as guidelines) For later, when we converge on the review: I'd add also a commit changing the release notes (0.13.5 is not there yet, you can add it) to mention this feature. I'd add a commit expanding the "advanced l2 configuration" section of the doc with this change
> > 
> > 
> > Add two commit for release notes and doc in this PR?
> 
> Yes exactly, so when we cut a new release we won't have to add a new pr for that.

As a second thought, let's leave them out for now. We'll add documentation and declare the feature when we have enough coverage to assure us the feature is working (I am assuming we'll add new tests in another PR)
fedepaol: A couple of nits around the code, which is getting to look really good in my opinion, plus a comment about the e2e tests which I think needs to be sorted out.
fedepaol: Thanks for taking the time to iterate over this! The result is really neat.
LGTM

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2b460efa..836aa401 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -301,7 +301,7 @@ jobs:
           spec:
             logLevel: debug
           EOF
-          sudo -E env "PATH=$PATH" inv e2etest --skip "IPV6|DUALSTACK|metrics" -e /tmp/kind_logs
+          sudo -E env "PATH=$PATH" inv e2etest --skip "IPV6|DUALSTACK|metrics|L2-interface selector" -e /tmp/kind_logs
 
       - name: Collect Logs
         if: ${{ failure() }}
diff --git a/api/v1beta1/l2advertisement_types.go b/api/v1beta1/l2advertisement_types.go
index 1cc8909c..d54f5bff 100644
--- a/api/v1beta1/l2advertisement_types.go
+++ b/api/v1beta1/l2advertisement_types.go
@@ -35,6 +35,10 @@ type L2AdvertisementSpec struct {
 	// NodeSelectors allows to limit the nodes to announce as next hops for the LoadBalancer IP. When empty, all the nodes having  are announced as next hops.
 	// +optional
 	NodeSelectors []metav1.LabelSelector `json:"nodeSelectors,omitempty"`
+	// A list of interfaces to announce from. The LB IP will be announced only from these interfaces.
+	// If the field is not set, we advertise from all the interfaces on the host.
+	// +optional
+	Interfaces []string `json:"interfaces,omitempty"`
 }
 
 // L2AdvertisementStatus defines the observed state of L2Advertisement.
diff --git a/charts/metallb/charts/crds/templates/crds.yaml b/charts/metallb/charts/crds/templates/crds.yaml
index 3de9e7de..1cc88898 100644
--- a/charts/metallb/charts/crds/templates/crds.yaml
+++ b/charts/metallb/charts/crds/templates/crds.yaml
@@ -906,6 +906,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/config/crd/bases/metallb.io_l2advertisements.yaml b/config/crd/bases/metallb.io_l2advertisements.yaml
index 2fdb5a08..f3db5e28 100644
--- a/config/crd/bases/metallb.io_l2advertisements.yaml
+++ b/config/crd/bases/metallb.io_l2advertisements.yaml
@@ -37,6 +37,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/config/manifests/metallb-frr-prometheus.yaml b/config/manifests/metallb-frr-prometheus.yaml
index 816eb5b6..e64856d2 100644
--- a/config/manifests/metallb-frr-prometheus.yaml
+++ b/config/manifests/metallb-frr-prometheus.yaml
@@ -976,6 +976,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/config/manifests/metallb-frr.yaml b/config/manifests/metallb-frr.yaml
index 116cf705..0a5c9b90 100644
--- a/config/manifests/metallb-frr.yaml
+++ b/config/manifests/metallb-frr.yaml
@@ -976,6 +976,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/config/manifests/metallb-native-prometheus.yaml b/config/manifests/metallb-native-prometheus.yaml
index 83f8844b..80861ea2 100644
--- a/config/manifests/metallb-native-prometheus.yaml
+++ b/config/manifests/metallb-native-prometheus.yaml
@@ -976,6 +976,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/config/manifests/metallb-native.yaml b/config/manifests/metallb-native.yaml
index 44f0bf7c..27b5599f 100644
--- a/config/manifests/metallb-native.yaml
+++ b/config/manifests/metallb-native.yaml
@@ -976,6 +976,13 @@ spec:
           spec:
             description: L2AdvertisementSpec defines the desired state of L2Advertisement.
             properties:
+              interfaces:
+                description: A list of interfaces to announce from. The LB IP will
+                  be announced only from these interfaces. If the field is not set,
+                  we advertise from all the interfaces on the host.
+                items:
+                  type: string
+                type: array
               ipAddressPoolSelectors:
                 description: A selector for the IPAddressPools which would get advertised
                   via this advertisement. If no IPAddressPool is selected by this
diff --git a/internal/config/config.go b/internal/config/config.go
index b228a638..8e710a29 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -30,8 +30,8 @@ import (
 	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
 	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/util/sets"
 )
 
 type ClusterResources struct {
@@ -150,6 +150,10 @@ type BGPAdvertisement struct {
 type L2Advertisement struct {
 	// The map of nodes allowed for this advertisement
 	Nodes map[string]bool
+	// The interfaces in Nodes allowed for this advertisement
+	Interfaces []string
+	// AllInterfaces tells if all the interfaces are allowed for this advertisement
+	AllInterfaces bool
 }
 
 // BFDProfile describes a BFD profile to be applied to a set of peers.
@@ -500,7 +504,7 @@ func addressPoolFromLegacyCR(p metallbv1beta1.AddressPool, bgpCommunities map[st
 		if len(p.Spec.BGPAdvertisements) > 0 {
 			return nil, errors.New("cannot have bgp-advertisements configuration element in a layer2 address pool")
 		}
-		ret.L2Advertisements = []*L2Advertisement{{Nodes: allNodes}}
+		ret.L2Advertisements = []*L2Advertisement{{Nodes: allNodes, AllInterfaces: true}}
 	case BGP:
 		ads, err := bgpAdvertisementsFromLegacyCR(p.Spec.BGPAdvertisements, cidrsPerAddresses, bgpCommunities, allNodes)
 		if err != nil {
@@ -639,9 +643,14 @@ func l2AdvertisementFromCR(crdAd metallbv1beta1.L2Advertisement, nodes []corev1.
 	if err != nil {
 		return nil, errors.Wrapf(err, "Failed to parse node selector for %s", crdAd.Name)
 	}
-	return &L2Advertisement{
-		Nodes: selected,
-	}, nil
+	l2 := &L2Advertisement{
+		Nodes:      selected,
+		Interfaces: crdAd.Spec.Interfaces,
+	}
+	if len(crdAd.Spec.Interfaces) == 0 {
+		l2.AllInterfaces = true
+	}
+	return l2, nil
 }
 
 func bgpAdvertisementFromCR(crdAd metallbv1beta1.BGPAdvertisement, communities map[string]uint32, nodes []corev1.Node) (*BGPAdvertisement, error) {
@@ -947,9 +956,16 @@ func validateDuplicateBGPAdvertisements(ads []metallbv1beta1.BGPAdvertisement) e
 
 func containsAdvertisement(advs []*L2Advertisement, toCheck *L2Advertisement) bool {
 	for _, adv := range advs {
-		if reflect.DeepEqual(adv.Nodes, toCheck.Nodes) {
-			return true
+		if adv.AllInterfaces != toCheck.AllInterfaces {
+			continue
 		}
+		if !reflect.DeepEqual(adv.Nodes, toCheck.Nodes) {
+			continue
+		}
+		if !sets.NewString(adv.Interfaces...).Equal(sets.NewString(toCheck.Interfaces...)) {
+			continue
+		}
+		return true
 	}
 	return false
 }
diff --git a/internal/layer2/announcer.go b/internal/layer2/announcer.go
index de4f7be1..b6fa3b7f 100644
--- a/internal/layer2/announcer.go
+++ b/internal/layer2/announcer.go
@@ -21,12 +21,12 @@ type Announce struct {
 	sync.RWMutex
 	arps     map[int]*arpResponder
 	ndps     map[int]*ndpResponder
-	ips      map[string][]net.IP // svcName -> IPs
-	ipRefcnt map[string]int      // ip.String() -> number of uses
+	ips      map[string][]IPAdvertisement // svcName -> IPAdvertisements
+	ipRefcnt map[string]int               // ip.String() -> number of uses
 
 	// This channel can block - do not write to it while holding the mutex
 	// to avoid deadlocking.
-	spamCh chan net.IP
+	spamCh chan IPAdvertisement
 }
 
 // New returns an initialized Announce.
@@ -35,9 +35,9 @@ func New(l log.Logger) (*Announce, error) {
 		logger:   l,
 		arps:     map[int]*arpResponder{},
 		ndps:     map[int]*ndpResponder{},
-		ips:      map[string][]net.IP{},
+		ips:      map[string][]IPAdvertisement{},
 		ipRefcnt: map[string]int{},
-		spamCh:   make(chan net.IP, 1024),
+		spamCh:   make(chan IPAdvertisement, 1024),
 	}
 	go ret.interfaceScan()
 	go ret.spamLoop()
@@ -140,33 +140,37 @@ func (a *Announce) updateInterfaces() {
 
 func (a *Announce) spamLoop() {
 	// Map IP to spam stop time.
-	m := map[string]time.Time{}
+	type timedSpam struct {
+		until time.Time
+		IPAdvertisement
+	}
+	m := map[string]timedSpam{}
 	// We can't create a stopped ticker, so create one with a big period to avoid ticking for nothing
 	ticker := time.NewTicker(time.Hour)
 	ticker.Stop()
 	for {
 		select {
-		case ip := <-a.spamCh:
+		case s := <-a.spamCh:
 			if len(m) == 0 {
 				// See https://github.com/metallb/metallb/issues/172 for the 1100 choice.
 				ticker.Reset(1100 * time.Millisecond)
 			}
-			ipStr := ip.String()
+			ipStr := s.ip.String()
 			_, ok := m[ipStr]
 			// Set spam stop time to 5 seconds from now.
-			m[ipStr] = time.Now().Add(5 * time.Second)
+			m[ipStr] = timedSpam{time.Now().Add(5 * time.Second), s}
 			if !ok {
 				// Spam right away to avoid waiting up to 1100 milliseconds even if
 				// it means we call gratuitous() twice in a row in a short amount of time.
-				a.gratuitous(ip)
+				a.gratuitous(s)
 			}
 		case now := <-ticker.C:
-			for ipStr, until := range m {
-				if now.After(until) {
+			for ipStr, tSpam := range m {
+				if now.After(tSpam.until) {
 					// We have spammed enough - remove the IP from the map.
 					delete(m, ipStr)
 				} else {
-					a.gratuitous(net.ParseIP(ipStr))
+					a.gratuitous(tSpam.IPAdvertisement)
 				}
 			}
 			if len(m) == 0 {
@@ -176,14 +180,15 @@ func (a *Announce) spamLoop() {
 	}
 }
 
-func (a *Announce) doSpam(ip net.IP) {
-	a.spamCh <- ip
+func (a *Announce) doSpam(adv IPAdvertisement) {
+	a.spamCh <- adv
 }
 
-func (a *Announce) gratuitous(ip net.IP) {
+func (a *Announce) gratuitous(adv IPAdvertisement) {
 	a.RLock()
 	defer a.RUnlock()
 
+	ip := adv.ip
 	if a.ipRefcnt[ip.String()] <= 0 {
 		// We've lost control of the IP, someone else is
 		// doing announcements.
@@ -192,12 +197,20 @@ func (a *Announce) gratuitous(ip net.IP) {
 
 	if ip.To4() != nil {
 		for _, client := range a.arps {
+			if !adv.matchInterface(client.intf) {
+				level.Debug(a.logger).Log("op", "gratuitousAnnounce", "skip interfaces", client.intf)
+				continue
+			}
 			if err := client.Gratuitous(ip); err != nil {
 				level.Error(a.logger).Log("op", "gratuitousAnnounce", "error", err, "ip", ip, "msg", "failed to make gratuitous ARP announcement")
 			}
 		}
 	} else {
 		for _, client := range a.ndps {
+			if !adv.matchInterface(client.intf) {
+				level.Debug(a.logger).Log("op", "gratuitousAnnounce", "skip interfaces", client.intf)
+				continue
+			}
 			if err := client.Gratuitous(ip); err != nil {
 				level.Error(a.logger).Log("op", "gratuitousAnnounce", "error", err, "ip", ip, "msg", "failed to make gratuitous NDP announcement")
 			}
@@ -205,47 +218,55 @@ func (a *Announce) gratuitous(ip net.IP) {
 	}
 }
 
-func (a *Announce) shouldAnnounce(ip net.IP) dropReason {
+func (a *Announce) shouldAnnounce(ip net.IP, intf string) dropReason {
 	a.RLock()
 	defer a.RUnlock()
-	for _, ips := range a.ips {
-		for _, i := range ips {
-			if i.Equal(ip) {
-				return dropReasonNone
+	ipFound := false
+	for _, ipAdvertisements := range a.ips {
+		for _, i := range ipAdvertisements {
+			if i.ip.Equal(ip) {
+				ipFound = true
+				if i.matchInterface(intf) {
+					return dropReasonNone
+				}
 			}
 		}
 	}
+	if ipFound {
+		return dropReasonNotMatchInterface
+	}
 	return dropReasonAnnounceIP
 }
 
 // SetBalancer adds ip to the set of announced addresses.
-func (a *Announce) SetBalancer(name string, ip net.IP) {
+func (a *Announce) SetBalancer(name string, adv IPAdvertisement) {
 	// Call doSpam at the end of the function without holding the lock
-	defer a.doSpam(ip)
+	defer a.doSpam(adv)
 	a.Lock()
 	defer a.Unlock()
 
 	// Kubernetes may inform us that we should advertise this address multiple
 	// times, so just no-op any subsequent requests.
-	if serviceIPs, ok := a.ips[name]; ok {
-		for _, serviceIP := range serviceIPs {
-			if serviceIP.Equal(ip) {
+	if ipAdvertisements, ok := a.ips[name]; ok {
+		for i := range ipAdvertisements {
+			if adv.ip.Equal(a.ips[name][i].ip) {
+				a.ips[name][i] = adv // override in case the interface list changed
 				return
 			}
 		}
 	}
-	a.ips[name] = append(a.ips[name], ip)
+	a.ips[name] = append(a.ips[name], adv)
 
-	a.ipRefcnt[ip.String()]++
-	if a.ipRefcnt[ip.String()] > 1 {
+	a.ipRefcnt[adv.ip.String()]++
+	if a.ipRefcnt[adv.ip.String()] > 1 {
 		// Multiple services are using this IP, so there's nothing
 		// else to do right now.
 		return
 	}
 
 	for _, client := range a.ndps {
-		if err := client.Watch(ip); err != nil {
-			level.Error(a.logger).Log("op", "watchMulticastGroup", "error", err, "ip", ip, "msg", "failed to watch NDP multicast group for IP, NDP responder will not respond to requests for this address")
+		if err := client.Watch(adv.ip); err != nil {
+			level.Error(a.logger).Log("op", "watchMulticastGroup", "error", err, "ip", adv.ip, "interface", client.intf, "msg", "failed to watch NDP multicast group for IP, NDP responder will not respond to requests for this address")
 		}
 	}
 }
@@ -255,26 +276,26 @@ func (a *Announce) DeleteBalancer(name string) {
 	a.Lock()
 	defer a.Unlock()
 
-	ips, ok := a.ips[name]
+	advs, ok := a.ips[name]
 	if !ok {
 		return
 	}
 	delete(a.ips, name)
-	for _, ip := range ips {
-		a.ipRefcnt[ip.String()]--
-		if a.ipRefcnt[ip.String()] > 0 {
+
+	for _, cur := range advs {
+		a.ipRefcnt[cur.ip.String()]--
+		if a.ipRefcnt[cur.ip.String()] > 0 {
 			// Another service is still using this IP, don't touch any
 			// more things.
 			continue
 		}
 
 		for _, client := range a.ndps {
-			if err := client.Unwatch(ip); err != nil {
-				level.Error(a.logger).Log("op", "unwatchMulticastGroup", "error", err, "ip", ip, "msg", "failed to unwatch NDP multicast group for IP")
+			if err := client.Unwatch(cur.ip); err != nil {
+				level.Error(a.logger).Log("op", "unwatchMulticastGroup", "error", err, "ip", cur.ip, "interface", client.intf, "msg", "failed to unwatch NDP multicast group for IP")
 			}
 		}
 	}
-
 }
 
 // AnnounceName returns true when we have an announcement under name.
@@ -299,4 +320,5 @@ const (
 	dropReasonNoSourceLL
 	dropReasonEthernetDestination
 	dropReasonAnnounceIP
+	dropReasonNotMatchInterface
 )
diff --git a/internal/layer2/arp.go b/internal/layer2/arp.go
index 9c7e1985..e609aec7 100644
--- a/internal/layer2/arp.go
+++ b/internal/layer2/arp.go
@@ -14,7 +14,7 @@ import (
 	"github.com/mdlayher/ethernet"
 )
 
-type announceFunc func(net.IP) dropReason
+type announceFunc func(net.IP, string) dropReason
 
 type arpResponder struct {
 	logger       log.Logger
@@ -97,7 +97,11 @@ func (a *arpResponder) processRequest() dropReason {
 	}
 
 	// Ignore ARP requests that the announcer tells us to ignore.
-	if reason := a.announce(pkt.TargetIP); reason != dropReasonNone {
+	reason := a.announce(pkt.TargetIP, a.intf)
+	if reason == dropReasonNotMatchInterface {
+		level.Debug(a.logger).Log("op", "arpRequestIgnore", "ip", pkt.TargetIP, "interface", a.intf, "reason", "notMatchInterface")
+	}
+	if reason != dropReasonNone {
 		return reason
 	}
 
diff --git a/internal/layer2/ip_advertisement.go b/internal/layer2/ip_advertisement.go
new file mode 100644
index 00000000..90895f31
--- /dev/null
+++ b/internal/layer2/ip_advertisement.go
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package layer2
+
+import (
+	"net"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+// IPAdvertisement is the advertisement Info about LB IP.
+type IPAdvertisement struct {
+	ip            net.IP
+	interfaces    sets.String
+	allInterfaces bool
+}
+
+func NewIPAdvertisement(ip net.IP, allInterfaces bool, interfaces sets.String) IPAdvertisement {
+	return IPAdvertisement{
+		ip:            ip,
+		interfaces:    interfaces,
+		allInterfaces: allInterfaces,
+	}
+}
+
+func (i1 *IPAdvertisement) Equal(i2 *IPAdvertisement) bool {
+	if i1 == nil && i2 == nil {
+		return true
+	}
+	if i1 == nil || i2 == nil {
+		return false
+	}
+	if !i1.ip.Equal(i2.ip) {
+		return false
+	}
+	if i1.allInterfaces != i2.allInterfaces {
+		return false
+	}
+	if i1.allInterfaces == true {
+		return true
+	}
+	return i1.interfaces.Equal(i2.interfaces)
+}
+
+func (i *IPAdvertisement) matchInterface(intf string) bool {
+	if i == nil {
+		return false
+	}
+	if i.allInterfaces {
+		return true
+	}
+	return i.interfaces.Has(intf)
+}
diff --git a/internal/layer2/ndp.go b/internal/layer2/ndp.go
index 592e749d..a4bb0725 100644
--- a/internal/layer2/ndp.go
+++ b/internal/layer2/ndp.go
@@ -135,7 +135,11 @@ func (n *ndpResponder) processRequest() dropReason {
 	}
 
 	// Ignore NDP requests that the announcer tells us to ignore.
-	if reason := n.announce(ns.TargetAddress); reason != dropReasonNone {
+	reason := n.announce(ns.TargetAddress, n.intf)
+	if reason == dropReasonNotMatchInterface {
+		level.Debug(n.logger).Log("op", "ndpRequestIgnore", "ip", ns.TargetAddress, "interface", n.intf, "reason", "notMatchInterface")
+	}
+	if reason != dropReasonNone {
 		return reason
 	}
 
@@ -143,7 +147,7 @@ func (n *ndpResponder) processRequest() dropReason {
 	level.Debug(n.logger).Log("interface", n.intf, "ip", ns.TargetAddress, "senderIP", src, "senderLLAddr", nsLLAddr, "responseMAC", n.hardwareAddr, "msg", "got NDP request for service IP, sending response")
 
 	if err := n.advertise(src, ns.TargetAddress, false); err != nil {
-		level.Error(n.logger).Log("op", "arpReply", "interface", n.intf, "ip", ns.TargetAddress, "senderIP", src, "senderLLAddr", nsLLAddr, "responseMAC", n.hardwareAddr, "error", err, "msg", "failed to send ARP reply")
+		level.Error(n.logger).Log("op", "ndpReply", "interface", n.intf, "ip", ns.TargetAddress, "senderIP", src, "senderLLAddr", nsLLAddr, "responseMAC", n.hardwareAddr, "error", err, "msg", "failed to send ARP reply")
 	} else {
 		stats.SentResponse(ns.TargetAddress.String())
 	}
diff --git a/speaker/layer2_controller.go b/speaker/layer2_controller.go
index 49ec39fe..c07d2bee 100644
--- a/speaker/layer2_controller.go
+++ b/speaker/layer2_controller.go
@@ -26,6 +26,7 @@ import (
 	"go.universe.tf/metallb/internal/k8s/epslices"
 	"go.universe.tf/metallb/internal/layer2"
 	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/util/sets"
 )
 
 type layer2Controller struct {
@@ -135,7 +136,8 @@ 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) error {
 	for _, lbIP := range lbIPs {
-		c.announcer.SetBalancer(name, lbIP)
+		ipAdv := ipAdvertisementFor(lbIP, c.myNode, pool.L2Advertisements)
+		c.announcer.SetBalancer(name, ipAdv)
 	}
 	return nil
 }
@@ -153,6 +155,20 @@ func (c *layer2Controller) SetNode(log.Logger, *v1.Node) error {
 	return nil
 }
 
+func ipAdvertisementFor(ip net.IP, localNode string, l2Advertisements []*config.L2Advertisement) layer2.IPAdvertisement {
+	ifs := sets.NewString()
+	for _, l2 := range l2Advertisements {
+		if matchNode := l2.Nodes[localNode]; !matchNode {
+			continue
+		}
+		if l2.AllInterfaces {
+			return layer2.NewIPAdvertisement(ip, true, sets.NewString())
+		}
+		ifs = ifs.Insert(l2.Interfaces...)
+	}
+	return layer2.NewIPAdvertisement(ip, false, ifs)
+}
+
 // nodesWithActiveSpeakers returns the list of nodes with active speakers.
 func nodesWithActiveSpeakers(speakers map[string]bool) []string {
 	var ret []string
diff --git a/tasks.py b/tasks.py
index df94bc1d..aea3b4bb 100644
--- a/tasks.py
+++ b/tasks.py
@@ -24,6 +24,8 @@ all_architectures = set(["amd64",
                          "arm64",
                          "ppc64le",
                          "s390x"])
+default_network = "kind"
+extra_network = "network2"
 
 def _check_architectures(architectures):
     out = set()
@@ -92,16 +94,22 @@ def _is_podman():
     return 'podman' in os.path.realpath(shutil.which('docker'))
 
 
-# Get the list of subnets for the kind nework.
-def _get_network_subnets():
+def _is_network_exist(network):
+    try:
+        run("docker network inspect {network}".format(network=network))
+    except:
+        print("docker bridge {} doesn't exist".format(network))
+        return False
+    return True
+
+# Get the list of subnets for the nework.
+def _get_network_subnets(network):
     if _is_podman():
-        cmd = ('podman network inspect kind -f "'
-               '{{ range (index .plugins 0).ipam.ranges}}'
-               '{{ (index . 0).subnet }} {{end}}"')
+        cmd = ('podman network inspect {network} '.format(network=network) +
+        '-f "{{ range (index .plugins 0).ipam.ranges}}{{ (index . 0).subnet }} {{end}}"')
     else:
-        cmd = ('docker network inspect kind -f "'
-               '{{ range .IPAM.Config}}{{.Subnet}} {{end}}"'
-               )
+        cmd = ('docker network inspect {network} '.format(network=network) +
+        '-f "{{ range .IPAM.Config}}{{.Subnet}} {{end}}"')
     return run(cmd, echo=True).stdout.strip().split(' ')
 
 
@@ -133,6 +141,38 @@ def _get_subnets_allocated_ips():
 
     return sorted(v4_ips), sorted(v6_ips)
 
+def _add_nic_to_nodes(cluster_name):
+    nodes = run("kind get nodes --name {name}".format(name=cluster_name)).stdout.strip().split("\n")
+    run("docker network create --ipv6 --subnet {ipv6_subnet} -d bridge {bridge_name}".format(bridge_name=extra_network, ipv6_subnet="fc00:f853:ccd:e791::/64"))
+    for node in nodes:
+        run("docker network connect {bridge_name} {node}".format(bridge_name=extra_network, node=node))
+
+# Get the nics of kind cluster node
+def _get_node_nics(node):
+    default_nic = run('docker exec -i {container} ip r | grep default | cut -d " " -f 5'.format(container=node)).stdout.strip()
+    if not _is_network_exist(extra_network):
+        return default_nic
+    extra_subnets = _get_network_subnets(extra_network)
+    ip = ipaddress.ip_network(extra_subnets[0])
+    if ip.version == 4:
+        extra_nic = run('docker exec -i {container} ip r | grep {dst} | cut -d " " -f 3'.format(container=node, dst=extra_subnets[0])).stdout.strip()
+    else:
+        extra_nic = run('docker exec -i {container} ip -6 r | grep {dst} | cut -d " " -f 3'.format(container=node, dst=extra_subnets[0])).stdout.strip()
+    return default_nic + "," + extra_nic
+
+def _get_local_nics():
+    nics = []
+    for net in [default_network, extra_network]:
+        if not _is_network_exist(net):
+            continue
+        subnets = _get_network_subnets(net)
+        ip = ipaddress.ip_network(subnets[0])
+        if ip.version == 4:
+            nic = run('ip r | grep {dst} | cut -d " " -f 3'.format(dst=subnets[0])).stdout.strip()
+        else:
+            nic = run('ip -6 r | grep {dst} | cut -d " " -f 3'.format(dst=subnets[0])).stdout.strip()
+        nics.append(nic)
+    return ','.join(nics)
 
 @task(iterable=["binaries", "architectures"],
       help={
@@ -319,6 +359,7 @@ def dev_env(ctx, architecture="amd64", name="kind", protocol=None, frr_volume_di
             tmp.write(config)
             tmp.flush()
             run("kind create cluster --name={} --config={} {}".format(name, tmp.name, extra_options), pty=True, echo=True)
+        _add_nic_to_nodes(name)
 
     binaries = ["controller", "speaker", "mirror-server"]
     if build_images:
@@ -471,14 +512,12 @@ def bgp_dev_env(ip_family, frr_volume_dir):
     # Apply the MetalLB ConfigMap
     run_with_retry("kubectl apply -f %s/config.yaml" % dev_env_dir)
 
-
 def get_available_ips(ip_family=None):
     if ip_family is None or (ip_family != 4 and ip_family != 6):
         raise Exit(message="Please provide network version: 4 or 6.")
 
     v4, v6 = _get_subnets_allocated_ips()
-
-    for i in _get_network_subnets():
+    for i in _get_network_subnets(default_network):
         network = ipaddress.ip_network(i)
         if network.version == ip_family:
             used_list = v4 if ip_family == 4 else v6
@@ -530,6 +569,8 @@ def dev_env_cleanup(ctx, name="kind", frr_volume_dir=""):
     dev_env_dir = os.getcwd() + "/dev-env/layer2"
     run('rm -f "%s"/config.yaml' % dev_env_dir)
 
+    # cleanup extra bridge
+    run('docker network rm {bridge_name}'.format(bridge_name=extra_network))
 
 @task(help={
     "version": "version of MetalLB to release.",
@@ -704,8 +745,10 @@ def helmdocs(ctx, env="container"):
     "ipv4_service_range": "a range of IPv4 addresses for MetalLB to use when running in layer2 mode.",
     "ipv6_service_range": "a range of IPv6 addresses for MetalLB to use when running in layer2 mode.",
     "prometheus_namespace": "the namespace prometheus is deployed to, to validate metrics against prometheus.",
+    "node_nics": "a list of node's interfaces separated by comma, default is kind",
+    "local_nics": "a list of bridges related node's interfaces separated by comma, default is kind",
 })
-def e2etest(ctx, name="kind", export=None, kubeconfig=None, system_namespaces="kube-system,metallb-system", service_pod_port=80, skip_docker=False, focus="", skip="", ipv4_service_range=None, ipv6_service_range=None, prometheus_namespace=""):
+def e2etest(ctx, name="kind", export=None, kubeconfig=None, system_namespaces="kube-system,metallb-system", service_pod_port=80, skip_docker=False, focus="", skip="", ipv4_service_range=None, ipv6_service_range=None, prometheus_namespace="", node_nics="kind", local_nics="kind"):
     """Run E2E tests against development cluster."""
     if skip_docker:
         opt_skip_docker = "--skip-docker"
@@ -719,7 +762,7 @@ def e2etest(ctx, name="kind", export=None, kubeconfig=None, system_namespaces="k
     ginkgo_focus = ""
     if focus:
         ginkgo_focus = "--ginkgo.focus=\"" + focus + "\""
-    
+
     if kubeconfig is None:
         validate_kind_version()
         clusters = run("kind get clusters", hide=True).stdout.strip().splitlines()
@@ -731,11 +774,18 @@ def e2etest(ctx, name="kind", export=None, kubeconfig=None, system_namespaces="k
             raise Exit(message="Unable to find cluster named: {}".format(name))
     else:
         os.environ['KUBECONFIG'] = kubeconfig
-
+        
     namespaces = system_namespaces.replace(' ', '').split(',')
     for ns in namespaces:
         run("kubectl -n {} wait --for=condition=Ready --all pods --timeout 300s".format(ns), hide=True)
 
+    if node_nics == "kind":
+        nodes = run("kind get nodes --name {name}".format(name=name)).stdout.strip().split("\n")
+        node_nics = _get_node_nics(nodes[0])
+
+    if local_nics == "kind":
+        local_nics = _get_local_nics()
+
     ips_for_containers_v4 = ""
     if ipv4_service_range is None:
         ipv4_service_range, ips = get_available_ips(4)
@@ -758,7 +808,7 @@ def e2etest(ctx, name="kind", export=None, kubeconfig=None, system_namespaces="k
     os.makedirs(report_path, exist_ok=True)
 
     testrun = run("cd `git rev-parse --show-toplevel`/e2etest &&"
-            "KUBECONFIG={} go test -timeout 3h {} {} --provider=local --kubeconfig={} --service-pod-port={} {} {} -ipv4-service-range={} -ipv6-service-range={} {} --report-path {} {}".format(kubeconfig, ginkgo_focus, ginkgo_skip, kubeconfig, service_pod_port, ips_for_containers_v4, ips_for_containers_v6, ipv4_service_range, ipv6_service_range, opt_skip_docker, report_path, prometheus_namespace), warn="True")
+            "KUBECONFIG={} go test -timeout 3h {} {} --provider=local --kubeconfig={} --service-pod-port={} {} {} -ipv4-service-range={} -ipv6-service-range={} {} --report-path {} {} -node-nics {} -local-nics {}".format(kubeconfig, ginkgo_focus, ginkgo_skip, kubeconfig, service_pod_port, ips_for_containers_v4, ips_for_containers_v6, ipv4_service_range, ipv6_service_range, opt_skip_docker, report_path, prometheus_namespace, node_nics, local_nics), warn="True")
 
     if export != None:
         run("kind export logs {}".format(export))
diff --git a/website/content/apis/_index.md b/website/content/apis/_index.md
index 8e5ff20a..98d51fd7 100644
--- a/website/content/apis/_index.md
+++ b/website/content/apis/_index.md
@@ -621,6 +621,19 @@ If no IPAddressPool is selected by this or by the list, the advertisement is app
 <p>NodeSelectors allows to limit the nodes to announce as next hops for the LoadBalancer IP. When empty, all the nodes having  are announced as next hops.</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>interfaces</code><br/>
+<em>
+[]string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>A list of interfaces to announce from. The LB IP will be announced only from these interfaces.
+If the field is not set, we advertise from all the interfaces on the host.</p>
+</td>
+</tr>
 </table>
 </td>
 </tr>


Test patch:
diff --git a/e2etest/e2etest_suite_test.go b/e2etest/e2etest_suite_test.go
index 3378c342..728b925a 100644
--- a/e2etest/e2etest_suite_test.go
+++ b/e2etest/e2etest_suite_test.go
@@ -56,6 +56,8 @@ var (
 	updater             testsconfig.Updater
 	updaterOtherNS      testsconfig.Updater
 	prometheusNamespace string
+	nodeNics            string
+	localNics           string
 )
 
 // handleFlags sets up all flags and parses the command line.
@@ -78,6 +80,8 @@ func handleFlags() {
 	flag.StringVar(&ipv6ForContainers, "ips-for-containers-v6", "0", "a comma separated list of IPv6 addresses available for containers")
 	flag.StringVar(&l2tests.IPV4ServiceRange, "ipv4-service-range", "0", "a range of IPv4 addresses for MetalLB to use when running in layer2 mode")
 	flag.StringVar(&l2tests.IPV6ServiceRange, "ipv6-service-range", "0", "a range of IPv6 addresses for MetalLB to use when running in layer2 mode")
+	flag.StringVar(&nodeNics, "node-nics", "", "node's interfaces list separated by comma and used when running in interface selector")
+	flag.StringVar(&localNics, "local-nics", "", "local interfaces list separated by comma and used when running in interface selector")
 	flag.BoolVar(&useOperator, "use-operator", false, "set this to true to run the tests using operator custom resources")
 	flag.StringVar(&reportPath, "report-path", "/tmp/report", "the path to be used to dump test failure information")
 	flag.StringVar(&prometheusNamespace, "prometheus-namespace", "monitoring", "the namespace prometheus is running in (if running)")
@@ -168,6 +172,8 @@ var _ = ginkgo.BeforeSuite(func() {
 	webhookstests.Reporter = reporter
 	bgptests.PrometheusNamespace = prometheusNamespace
 	l2tests.PrometheusNamespace = prometheusNamespace
+	l2tests.NodeNics = strings.Split(nodeNics, ",")
+	l2tests.LocalNics = strings.Split(localNics, ",")
 })
 
 var _ = ginkgo.AfterSuite(func() {
diff --git a/e2etest/l2tests/interface_selector.go b/e2etest/l2tests/interface_selector.go
new file mode 100644
index 00000000..1aba4e85
--- /dev/null
+++ b/e2etest/l2tests/interface_selector.go
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package l2tests
+
+import (
+	"context"
+	"time"
+
+	"github.com/onsi/ginkgo"
+	"github.com/onsi/gomega"
+	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
+	internalconfig "go.universe.tf/metallb/internal/config"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	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/metallb/e2etest/pkg/executor"
+	"go.universe.tf/metallb/e2etest/pkg/k8s"
+	"go.universe.tf/metallb/e2etest/pkg/mac"
+	"go.universe.tf/metallb/e2etest/pkg/metallb"
+	"go.universe.tf/metallb/e2etest/pkg/service"
+)
+
+var (
+	NodeNics  []string
+	LocalNics []string
+)
+
+var _ = ginkgo.Describe("L2-interface selector", func() {
+	var cs clientset.Interface
+
+	var f *framework.Framework
+	ginkgo.AfterEach(func() {
+		if ginkgo.CurrentGinkgoTestDescription().Failed {
+			k8s.DumpInfo(Reporter, ginkgo.CurrentGinkgoTestDescription().TestText)
+		}
+
+		// Clean previous configuration.
+		err := ConfigUpdater.Clean()
+		framework.ExpectNoError(err)
+	})
+
+	f = framework.NewDefaultFramework("l2")
+	f.NamespacePodSecurityEnforceLevel = admissionapi.LevelPrivileged
+
+	ginkgo.BeforeEach(func() {
+		if len(NodeNics) < 2 {
+			framework.Fail("Cluster nodes don't have multi-interfaces to test L2-interface selector")
+		}
+		if len(NodeNics) != len(LocalNics) {
+			framework.Fail("Local interfaces can't correspond to cluster node's interfaces")
+		}
+		cs = f.ClientSet
+		ginkgo.By("Clearing any previous configuration")
+
+		err := ConfigUpdater.Clean()
+		framework.ExpectNoError(err)
+	})
+
+	ginkgo.Context("Interface Selector", func() {
+		ginkgo.BeforeEach(func() {
+			resources := internalconfig.ClusterResources{
+				Pools: []metallbv1beta1.IPAddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "l2-test",
+						},
+						Spec: metallbv1beta1.IPAddressPoolSpec{
+							Addresses: []string{
+								IPV4ServiceRange,
+								IPV6ServiceRange},
+						},
+					},
+				},
+			}
+
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+		})
+
+		ginkgo.It("Validate the LB IP's mac", func() {
+			resources := internalconfig.ClusterResources{
+				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 svcNode *corev1.Node
+			gomega.Eventually(func() error {
+				svcNode, err = k8s.GetSvcNode(cs, svc.Namespace, svc.Name, allNodes)
+				return err
+			}, 1*time.Minute, 1*time.Second).Should(gomega.Not(gomega.HaveOccurred()))
+			speakerPod, err := metallb.SpeakerPodInNode(cs, svcNode.Name)
+			framework.ExpectNoError(err)
+			selectorMac, err := mac.GetIfaceMac(NodeNics[0], executor.ForPod(speakerPod.Namespace, speakerPod.Name, "speaker"))
+			framework.ExpectNoError(err)
+
+			ingressIP := e2eservice.GetIngressPoint(&svc.Status.LoadBalancer.Ingress[0])
+			err = mac.FlushIPNeigh(ingressIP, executor.Host)
+			framework.ExpectNoError(err)
+
+			gomega.Eventually(func() string {
+				err := mac.RequestAddressResolutionFromIface(ingressIP, LocalNics[0], executor.Host)
+				if err != nil {
+					return err.Error()
+				}
+				err = service.ValidateL2(svc)
+				if err != nil {
+					return err.Error()
+				}
+				ingressMac, err := mac.ForIP(ingressIP, executor.Host)
+				if err != nil {
+					return err.Error()
+				}
+				return ingressMac.String()
+			}, 1*time.Minute, 1*time.Second).Should(gomega.Equal(selectorMac.String()))
+		})
+
+		ginkgo.It("Modify L2 interface", func() {
+			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)
+			}()
+
+			ingressIP := e2eservice.GetIngressPoint(&svc.Status.LoadBalancer.Ingress[0])
+
+			for i := range NodeNics {
+				resources := internalconfig.ClusterResources{
+					L2Advs: []metallbv1beta1.L2Advertisement{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "with-interfaces",
+							},
+							Spec: metallbv1beta1.L2AdvertisementSpec{
+								Interfaces: []string{NodeNics[i]},
+							},
+						},
+					},
+				}
+				err := ConfigUpdater.Update(resources)
+				framework.ExpectNoError(err)
+				for j := range LocalNics {
+					if j == i {
+						continue
+					}
+					gomega.Eventually(func() error {
+						return mac.RequestAddressResolutionFromIface(ingressIP, LocalNics[j], executor.Host)
+					}, 10*time.Second, 1*time.Second).Should(gomega.HaveOccurred())
+				}
+				gomega.Eventually(func() error {
+					return mac.RequestAddressResolutionFromIface(ingressIP, LocalNics[i], executor.Host)
+				}, 10*time.Second, 1*time.Second).Should(gomega.Not(gomega.HaveOccurred()))
+			}
+		})
+	})
+})
diff --git a/e2etest/pkg/k8s/services.go b/e2etest/pkg/k8s/services.go
index d0425908..a54d77ff 100644
--- a/e2etest/pkg/k8s/services.go
+++ b/e2etest/pkg/k8s/services.go
@@ -3,8 +3,14 @@
 package k8s
 
 import (
+	"context"
 	"fmt"
+	"strings"
 
+	"github.com/pkg/errors"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clientset "k8s.io/client-go/kubernetes"
 	"k8s.io/kubernetes/test/e2e/framework"
 )
 
@@ -15,3 +21,29 @@ func DescribeSvc(ns string) {
 		ns, "describe", "svc", fmt.Sprintf("--namespace=%v", ns))
 	framework.Logf(desc)
 }
+
+// GetSvcNode returns the node that the LB Service announcing from.
+func GetSvcNode(cs clientset.Interface, svcNS string, svcName string, allNodes *corev1.NodeList) (*corev1.Node, error) {
+	events, err := cs.CoreV1().Events(svcNS).List(context.Background(), metav1.ListOptions{FieldSelector: "reason=nodeAssigned"})
+	if err != nil {
+		return nil, err
+	}
+
+	msg := ""
+	for _, e := range events.Items {
+		if e.InvolvedObject.Name == svcName {
+			msg = e.Message
+			break
+		}
+	}
+	if msg == "" {
+		return nil, errors.New("service doesn't be assigned node")
+	}
+
+	for _, node := range allNodes.Items {
+		if strings.Contains(msg, "\""+node.Name+"\"") {
+			return &node, nil
+		}
+	}
+	return nil, errors.New("can't find the node that service be assigned")
+}
diff --git a/e2etest/pkg/mac/mac.go b/e2etest/pkg/mac/mac.go
index de9de52d..a1cfca4e 100644
--- a/e2etest/pkg/mac/mac.go
+++ b/e2etest/pkg/mac/mac.go
@@ -129,6 +129,15 @@ func RequestAddressResolution(ip string, exc executor.Executor) error {
 		return err
 	}
 
+	return RequestAddressResolutionFromIface(ip, iface, exc)
+}
+
+// RequestAddressResolutionFromIface does an ARP/NS request for the given IP from the given interface.
+func RequestAddressResolutionFromIface(ip string, iface string, exec executor.Executor) error {
+	netIP := net.ParseIP(ip)
+	if netIP == nil {
+		return fmt.Errorf("failed to convert %s to net.IP", ip)
+	}
 	var cmd string
 	var args []string
 	if netIP.To4() != nil {
@@ -139,7 +148,7 @@ func RequestAddressResolution(ip string, exc executor.Executor) error {
 		args = []string{netIP.String(), iface}
 	}
 
-	out, err := exc.Exec(cmd, args...)
+	out, err := exec.Exec(cmd, args...)
 	if err != nil {
 		return errors.Wrapf(err, out)
 	}
@@ -147,6 +156,39 @@ func RequestAddressResolution(ip string, exc executor.Executor) error {
 	return nil
 }
 
+// FlushIPNeigh flush the ip from ip neighbor table.
+func FlushIPNeigh(ip string, exec executor.Executor) error {
+	out, err := exec.Exec("ip", "neigh", "flush", "to", ip)
+	if err != nil {
+		return errors.Wrapf(err, out)
+	}
+	return nil
+}
+
+// GetIfaceMac returns the mac of the iface.
+func GetIfaceMac(iface string, exec executor.Executor) (net.HardwareAddr, error) {
+	out, err := exec.Exec("ifconfig")
+	if err != nil {
+		return nil, errors.Wrapf(err, out)
+	}
+
+	macRe := regexp.MustCompile("([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})")
+
+	res := strings.Split(string(out), "\n")
+	for _, line := range res {
+		cur := strings.Split(line, " ")
+		if cur[0] == iface {
+			m := macRe.FindString(line)
+			if m != "" {
+				mac, err := net.ParseMAC(m)
+				return mac, err
+			}
+		}
+	}
+
+	return nil, errors.Errorf("failed to find the mac of interface %s", iface)
+}
+
 // ifaceForIPNetwork returns the interface name that is in the same network as the IP.
 func ifaceForIPNetwork(ip net.IP, exec executor.Executor) (string, error) {
 	ifaces := []ipIface{}
diff --git a/e2etest/pkg/metallb/metallb.go b/e2etest/pkg/metallb/metallb.go
index b877c251..65d8c858 100644
--- a/e2etest/pkg/metallb/metallb.go
+++ b/e2etest/pkg/metallb/metallb.go
@@ -60,3 +60,17 @@ func ControllerPod(cs clientset.Interface) (*corev1.Pod, error) {
 	}
 	return &pods.Items[0], nil
 }
+
+// SpeakerPodInNode returns the speaker pod running in the given node.
+func SpeakerPodInNode(cs clientset.Interface, node string) (*corev1.Pod, error) {
+	speakerPods, err := SpeakerPods(cs)
+	if err != nil {
+		return nil, err
+	}
+	for _, pod := range speakerPods {
+		if pod.Spec.NodeName == node {
+			return pod, nil
+		}
+	}
+	return nil, errors.Errorf("no speaker pod run in the node %s", node)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 6cecd4ff..8f2acd29 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -264,7 +264,8 @@ func TestParse(t *testing.T) {
 							},
 						},
 						L2Advertisements: []*L2Advertisement{&L2Advertisement{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 					},
 					"pool2": {
@@ -279,7 +280,8 @@ func TestParse(t *testing.T) {
 							},
 						},
 						L2Advertisements: []*L2Advertisement{&L2Advertisement{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 					},
 					"pool3": {
@@ -298,14 +300,16 @@ func TestParse(t *testing.T) {
 							ipnet("40.0.0.250/32"),
 						},
 						L2Advertisements: []*L2Advertisement{&L2Advertisement{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 						AutoAssign: true,
 					},
 					"pool4": {
 						CIDR: []*net.IPNet{ipnet("2001:db8::/64")},
 						L2Advertisements: []*L2Advertisement{&L2Advertisement{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 						AutoAssign: true,
 					},
@@ -1657,7 +1661,8 @@ func TestParse(t *testing.T) {
 					"legacyl2pool1": {
 						CIDR: []*net.IPNet{ipnet("10.21.0.0/16"), ipnet("10.51.0.0/24")},
 						L2Advertisements: []*L2Advertisement{{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 					},
 				},
@@ -1866,7 +1871,8 @@ func TestParse(t *testing.T) {
 							},
 						},
 						L2Advertisements: []*L2Advertisement{{
-							Nodes: map[string]bool{},
+							Nodes:         map[string]bool{},
+							AllInterfaces: true,
 						}},
 					},
 					"pool2": {
@@ -1887,6 +1893,55 @@ func TestParse(t *testing.T) {
 				BFDProfiles: map[string]*BFDProfile{},
 			},
 		},
+		{
+			desc: "specify interfaces",
+			crs: ClusterResources{
+				Pools: []v1beta1.IPAddressPool{
+					{
+						ObjectMeta: v1.ObjectMeta{
+							Name:   "pool1",
+							Labels: map[string]string{"test": "pool1"},
+						},
+						Spec: v1beta1.IPAddressPoolSpec{
+							Addresses: []string{
+								"10.20.0.0/16",
+							},
+						},
+					},
+				},
+				L2Advs: []v1beta1.L2Advertisement{
+					{
+						ObjectMeta: v1.ObjectMeta{
+							Name: "l2adv1",
+						},
+						Spec: v1beta1.L2AdvertisementSpec{
+							IPAddressPoolSelectors: []metav1.LabelSelector{
+								{
+									MatchLabels: map[string]string{
+										"test": "pool1",
+									},
+								},
+							},
+							Interfaces: []string{"eth0"},
+						},
+					},
+				},
+			},
+			want: &Config{
+				Pools: map[string]*Pool{
+					"pool1": {
+						CIDR:       []*net.IPNet{ipnet("10.20.0.0/16")},
+						AutoAssign: true,
+						L2Advertisements: []*L2Advertisement{{
+							Nodes:         map[string]bool{},
+							AllInterfaces: false,
+							Interfaces:    []string{"eth0"},
+						}},
+					},
+				},
+				BFDProfiles: map[string]*BFDProfile{},
+			},
+		},
 		{
 			desc: "use duplicate match labels in ip pool selectors - in BGP adv",
 			crs: ClusterResources{
@@ -2247,6 +2302,7 @@ func TestParse(t *testing.T) {
 								"first":  true,
 								"second": true,
 							},
+							AllInterfaces: true,
 						}},
 					},
 
@@ -2265,6 +2321,7 @@ func TestParse(t *testing.T) {
 							Nodes: map[string]bool{
 								"first": true,
 							},
+							AllInterfaces: true,
 						}},
 					},
 					"pool2": {
@@ -2511,6 +2568,7 @@ func TestParse(t *testing.T) {
 								"first":  true,
 								"second": true,
 							},
+							AllInterfaces: true,
 						}},
 					},
 				},
@@ -2659,3 +2717,114 @@ func TestParse(t *testing.T) {
 		})
 	}
 }
+
+func TestContainsAdvertisement(t *testing.T) {
+	tests := []struct {
+		desc    string
+		advs    []*L2Advertisement
+		toCheck *L2Advertisement
+		expect  bool
+	}{
+		{
+			desc: "Contain",
+			advs: []*L2Advertisement{
+				&L2Advertisement{
+					Nodes: map[string]bool{
+						"nodeA": true,
+						"nodeB": true,
+					},
+					Interfaces:    []string{"eth0", "eth1"},
+					AllInterfaces: false,
+				},
+				&L2Advertisement{
+					Nodes: map[string]bool{
+						"nodeB": true,
+					},
+					Interfaces:    []string{},
+					AllInterfaces: true,
+				},
+			},
+			toCheck: &L2Advertisement{
+				Nodes: map[string]bool{
+					"nodeB": true,
+					"nodeA": true,
+				},
+				Interfaces:    []string{"eth1", "eth0"},
+				AllInterfaces: false,
+			},
+			expect: true,
+		},
+		{
+			desc: "Not contain: Nodes don't equal",
+			advs: []*L2Advertisement{
+				&L2Advertisement{
+					Nodes: map[string]bool{
+						"nodeA": true,
+						"nodeB": true,
+					},
+					Interfaces:    []string{"eth0", "eth1"},
+					AllInterfaces: false,
+				},
+			},
+			toCheck: &L2Advertisement{
+				Nodes: map[string]bool{
+					"nodeB": true,
+					"nodeC": true,
+				},
+				Interfaces:    []string{"eth1", "eth0"},
+				AllInterfaces: false,
+			},
+			expect: false,
+		},
+		{
+			desc: "Not contain: Interfaces don't equal",
+			advs: []*L2Advertisement{
+				&L2Advertisement{
+					Nodes: map[string]bool{
+						"nodeA": true,
+						"nodeB": true,
+					},
+					Interfaces:    []string{"eth0", "eth1"},
+					AllInterfaces: false,
+				},
+			},
+			toCheck: &L2Advertisement{
+				Nodes: map[string]bool{
+					"nodeA": true,
+					"nodeB": true,
+				},
+				Interfaces:    []string{"eth1"},
+				AllInterfaces: false,
+			},
+			expect: false,
+		},
+		{
+			desc: "Not contain: AllInterfaces doesn't equal",
+			advs: []*L2Advertisement{
+				&L2Advertisement{
+					Nodes: map[string]bool{
+						"nodeA": true,
+						"nodeB": true,
+					},
+					Interfaces:    []string{"eth1"},
+					AllInterfaces: false,
+				},
+			},
+			toCheck: &L2Advertisement{
+				Nodes: map[string]bool{
+					"nodeA": true,
+					"nodeB": true,
+				},
+				Interfaces:    []string{"eth1"},
+				AllInterfaces: true,
+			},
+			expect: false,
+		},
+	}
+	for _, test := range tests {
+		result := containsAdvertisement(test.advs, test.toCheck)
+		if result != test.expect {
+			t.Errorf("%s: expect is %v, but result is %v", test.desc, test.expect, result)
+		}
+	}
+}
diff --git a/internal/layer2/announcer_test.go b/internal/layer2/announcer_test.go
index 22913f02..73d3811b 100644
--- a/internal/layer2/announcer_test.go
+++ b/internal/layer2/announcer_test.go
@@ -5,35 +5,49 @@ package layer2
 import (
 	"net"
 	"testing"
+
+	"k8s.io/apimachinery/pkg/util/sets"
 )
 
 func Test_SetBalancer_AddsToAnnouncedServices(t *testing.T) {
 	announce := &Announce{
-		ips:      map[string][]net.IP{},
+		ips:      map[string][]IPAdvertisement{},
 		ipRefcnt: map[string]int{},
-		spamCh:   make(chan net.IP, 1),
+		spamCh:   make(chan IPAdvertisement, 1),
 	}
 
 	services := []struct {
 		name string
-		ip   net.IP
+		adv  IPAdvertisement
 	}{
 		{
 			name: "foo",
-			ip:   net.IPv4(192, 168, 1, 20),
+			adv: IPAdvertisement{
+				ip:            net.IPv4(192, 168, 1, 20),
+				interfaces:    sets.NewString(),
+				allInterfaces: true,
+			},
 		},
 		{
 			name: "foo",
-			ip:   net.ParseIP("1000::1"),
+			adv: IPAdvertisement{
+				ip:            net.ParseIP("1000::1"),
+				interfaces:    sets.NewString("eth0"),
+				allInterfaces: true,
+			},
 		},
 		{
 			name: "bar",
-			ip:   net.IPv4(192, 168, 1, 20),
+			adv: IPAdvertisement{
+				ip:            net.IPv4(192, 168, 1, 20),
+				interfaces:    sets.NewString("eth1"),
+				allInterfaces: false,
+			},
 		},
 	}
 
 	for _, service := range services {
-		announce.SetBalancer(service.name, service.ip)
+		announce.SetBalancer(service.name, service.adv)
 		// We need to empty spamCh as spamLoop() is not started.
 		<-announce.spamCh
 
diff --git a/internal/layer2/arp_test.go b/internal/layer2/arp_test.go
index 04e56231..a390193a 100644
--- a/internal/layer2/arp_test.go
+++ b/internal/layer2/arp_test.go
@@ -44,7 +44,7 @@ func TestARPResponder(t *testing.T) {
 		},
 		{
 			name: "shouldAnnounce denies request",
-			shouldAnnounce: func(ip net.IP) dropReason {
+			shouldAnnounce: func(ip net.IP, intf string) dropReason {
 				if net.IPv4(192, 168, 1, 20).Equal(ip) {
 					return dropReasonNone
 				}
@@ -55,7 +55,7 @@ func TestARPResponder(t *testing.T) {
 		{
 			name:   "shouldAnnounce allows request",
 			arpTgt: net.IPv4(192, 168, 1, 20),
-			shouldAnnounce: func(ip net.IP) dropReason {
+			shouldAnnounce: func(ip net.IP, intf string) dropReason {
 				if net.IPv4(192, 168, 1, 20).Equal(ip) {
 					return dropReasonNone
 				}
@@ -69,7 +69,7 @@ func TestARPResponder(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			shouldAnnounce := tt.shouldAnnounce
 			if shouldAnnounce == nil {
-				shouldAnnounce = func(net.IP) dropReason {
+				shouldAnnounce = func(net.IP, string) dropReason {
 					return dropReasonNone
 				}
 			}
diff --git a/speaker/layer2_controller_test.go b/speaker/layer2_controller_test.go
index ef06ded9..cf2874d3 100644
--- a/speaker/layer2_controller_test.go
+++ b/speaker/layer2_controller_test.go
@@ -13,12 +13,14 @@ import (
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s/controllers"
 	"go.universe.tf/metallb/internal/k8s/epslices"
+	"go.universe.tf/metallb/internal/layer2"
 	"go.universe.tf/metallb/internal/pointer"
 
 	"github.com/go-kit/log"
 	v1 "k8s.io/api/core/v1"
 	discovery "k8s.io/api/discovery/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/sets"
 )
 
 type fakeSpeakerList struct {
@@ -2498,3 +2500,127 @@ func TestClusterPolicy(t *testing.T) {
 		t.Fatalf("All services assigned to speaker1")
 	}
 }
+
+func TestIPAdvertisementFor(t *testing.T) {
+	tests := []struct {
+		desc             string
+		ip               net.IP
+		localNode        string
+		l2Advertisements []*config.L2Advertisement
+		expect           layer2.IPAdvertisement
+	}{
+		{
+			desc:      "Not specify the IPPool related interfaces",
+			ip:        net.IP{192, 168, 10, 3},
+			localNode: "nodeA",
+			l2Advertisements: []*config.L2Advertisement{
+				{
+					Nodes: map[string]bool{
+						"nodeB": true,
+						"nodeA": true,
+					},
+					Interfaces:    []string{},
+					AllInterfaces: true,
+				},
+			},
+			expect: layer2.NewIPAdvertisement(net.IP{192, 168, 10, 3}, true, sets.NewString()),
+		}, {
+			desc:      "LocalNode doesn't match L2Advertisement",
+			ip:        net.IP{192, 168, 10, 3},
+			localNode: "nodeA",
+			l2Advertisements: []*config.L2Advertisement{
+				{
+					Nodes: map[string]bool{
+						"nodeB": true,
+					},
+					Interfaces: []string{"eth0", "eth1"},
+				}, {
+					Nodes: map[string]bool{
+						"nodeA": false,
+						"nodeC": true,
+					},
+					Interfaces: []string{"eth3", "eth4"},
+				},
+			},
+			expect: layer2.NewIPAdvertisement(net.IP{192, 168, 10, 3}, false, sets.NewString()),
+		}, {
+			desc:      "LocalNode only match one L2Advertisement",
+			ip:        net.ParseIP("2000:12"),
+			localNode: "nodeA",
+			l2Advertisements: []*config.L2Advertisement{
+				{
+					Nodes: map[string]bool{
+						"nodeB": true,
+						"nodeA": false,
+					},
+					Interfaces: []string{"eth0"},
+				}, {
+					Nodes: map[string]bool{
+						"nodeC": true,
+						"nodeA": true,
+					},
+					Interfaces: []string{"eth1", "eth2"},
+				},
+			},
+			expect: layer2.NewIPAdvertisement(net.ParseIP("2000:12"), false, sets.NewString("eth1", "eth2")),
+		}, {
+			desc:      "LocalNode match multi-L2Advertisement",
+			ip:        net.IP{192, 168, 10, 3},
+			localNode: "nodeA",
+			l2Advertisements: []*config.L2Advertisement{
+				{
+					Nodes: map[string]bool{
+						"nodeB": true,
+						"nodeA": true,
+					},
+					Interfaces: []string{"eth0", "eth1"},
+				}, {
+					Nodes: map[string]bool{
+						"nodeC": true,
+						"nodeA": true,
+					},
+					Interfaces: []string{"eth0", "eth2"},
+				}, {
+					Nodes: map[string]bool{
+						"nodeC": true,
+						"nodeD": true,
+					},
+					Interfaces: []string{"eth4", "eth3"},
+				},
+			},
+			expect: layer2.NewIPAdvertisement(net.IP{192, 168, 10, 3}, false, sets.NewString("eth0", "eth1", "eth2")),
+		}, {
+			desc:      "LocalNode match multi-L2Advertisement, and one of them not specify interfaces",
+			ip:        net.IP{192, 168, 10, 3},
+			localNode: "nodeA",
+			l2Advertisements: []*config.L2Advertisement{
+				{
+					Nodes: map[string]bool{
+						"nodeB": true,
+						"nodeA": true,
+					},
+					Interfaces: []string{"eth0", "eth1"},
+				}, {
+					Nodes: map[string]bool{
+						"nodeC": true,
+						"nodeA": true,
+					},
+					AllInterfaces: true,
+				}, {
+					Nodes: map[string]bool{
+						"nodeC": true,
+						"nodeD": true,
+					},
+					Interfaces: []string{"eth4", "eth3"},
+				},
+			},
+			expect: layer2.NewIPAdvertisement(net.IP{192, 168, 10, 3}, true, sets.NewString()),
+		},
+	}
+	for _, test := range tests {
+		r := ipAdvertisementFor(test.ip, test.localNode, test.l2Advertisements)
+		if !r.Equal(&test.expect) {
+			t.Errorf("%s: expect interfaces is %+v, but the result is %+v", test.desc, test.expect, r)
+		}
+	}
+}