Repository: antrea-io/antrea
Pull request #6488 (2024-06-26 08:13:14)

Support deploying one FRR container in Kind network
This commit introduces a new parameter `--deploy-external-frr`
in `ci/kind/kind-setup.sh`, enabling to deploy one FRR container in
the Kind cluster network. This serves as the foundation for running
BGPPolicy e2e tests.

tnqn: > > How will we ensure that normal Pods used for testing (toolbox, agnhost, nginx, ...) are not scheduled on this extra Node? I guess it will never be Ready and so Pods won't be scheduled there unless they have the appropriate toleration?
> > Unless I am mistaken, with this change we effectively go from a 3-Node K8s cluster for testing to a 4-Node K8s cluster? This is not an insignificant change as the Github runners have limited resources and an extra Node may put a strain on these resources. Would like @tnqn to review this PR as well.
> 
> I think we should only enable this option in the test that enables all features. I am planning to use it to test BGP only (install a host network FRR Pod on the Node without Antrea to mock remote BGP router).

I have the same concern as @antoninbas. In my understanding the test just needs a BGP router, right? Why it needs a NotReady worker Node to run it, instead of just running the router directly via `docker run` like the existing "external-server"? we could even add the functionaliy to the existing container. The current approach that sets up another node introduces more code to deal with Node configuration, Pod affinity, overhead to run kubelet, containerd on that node, and the topology may look confusing as NotReady Node normally means something wrong.
hongliangl: > > > How will we ensure that normal Pods used for testing (toolbox, agnhost, nginx, ...) are not scheduled on this extra Node? I guess it will never be Ready and so Pods won't be scheduled there unless they have the appropriate toleration?
> > > Unless I am mistaken, with this change we effectively go from a 3-Node K8s cluster for testing to a 4-Node K8s cluster? This is not an insignificant change as the Github runners have limited resources and an extra Node may put a strain on these resources. Would like @tnqn to review this PR as well.
> > 
> > 
> > I think we should only enable this option in the test that enables all features. I am planning to use it to test BGP only (install a host network FRR Pod on the Node without Antrea to mock remote BGP router).
> 
> I have the same concern as @antoninbas. In my understanding the test just needs a BGP router, right? Why it needs a NotReady worker Node to run it, instead of just running the router directly via `docker run` like the existing "external-server"? we could even add the functionaliy to the existing container. The current approach that sets up another node introduces more code to deal with Node configuration, Pod affinity, overhead to run kubelet, containerd on that node, and the topology may look confusing as NotReady Node normally means something wrong.

Got that. If so, I will try to enhance the current external server to mock BGP server.  I think we need to add some framework code to operate the external server, for example:

- Install FRR
- start FRR BGP
- Add BGP peer, and the peer is the k8s Node selected by the test BGPPolicy.
- Run wget/curl to verify the advertised routes from k8s cluster.

Maybe more details should be considered. How do you think about this proposal? @tnqn @antoninbas 

tnqn: > Got that. If so, I will try to enhance the current external server to mock BGP server. I think we need to add some framework code to operate the external server, for example:
> 
> * Install FRR
> * start FRR BGP
> * Add BGP peer, and the peer is the k8s Nodes selected by the test BGPPolicy.
> * Run wget/curl to verify the advertised routes from k8s cluster.
> 
> Maybe more details should be considered. How do you think about this proposal? @tnqn @antoninbas

Not sure what you mean by framework code. In my mind you just need an image for 1, customize `docker run` command to start BGP for 2 and 3, figure out a way to validate 4 in tests.
hongliangl: > > Got that. If so, I will try to enhance the current external server to mock BGP server. I think we need to add some framework code to operate the external server, for example:
> > 
> > * Install FRR
> > * start FRR BGP
> > * Add BGP peer, and the peer is the k8s Nodes selected by the test BGPPolicy.
> > * Run wget/curl to verify the advertised routes from k8s cluster.
> > 
> > Maybe more details should be considered. How do you think about this proposal? @tnqn @antoninbas
> 
> Not sure what you mean by framework code. In my mind you just need an image for 1, customize `docker run` command to start BGP for 2 and 3, figure out a way to validate 4 in tests.

I didn't make it clear. I just meant that I may need to add some fundamental functions because I'm not sure if we have some existing convenient functions to configure the external server you added. @tnqn
tnqn: > I didn't make it clear. I just meant that I may need to add some fundamental functions because I'm not sure if we have some existing convenient functions to configure the external server you added. @tnqn

I think there is no problem to have another parameter to deploy a new bgp router (not based on the external server), given they don't have many things in common, and there isn't much code to bring up the external server.
hongliangl: @tnqn @luolanzone Do we have any more comments about this? If not, could we merge this PR? Thanks

Patch:
diff --git a/build/charts/antrea-windows/conf/antrea-agent.conf b/build/charts/antrea-windows/conf/antrea-agent.conf
index 5193ef5fd..8aa3a2817 100644
--- a/build/charts/antrea-windows/conf/antrea-agent.conf
+++ b/build/charts/antrea-windows/conf/antrea-agent.conf
@@ -119,7 +119,7 @@ antreaProxy:
   # To disable AntreaProxy, set this to false. It should be enabled on Windows, otherwise NetworkPolicy will
   # not take effect on Service traffic.
   enable: true
-  # ProxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
+  # proxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
   # Therefore, running kube-proxy is no longer required. This requires the AntreaProxy feature to be enabled.
   # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
   # apiserver directly.
diff --git a/build/charts/antrea/conf/antrea-agent.conf b/build/charts/antrea/conf/antrea-agent.conf
index e140f3c07..24eaf7df4 100644
--- a/build/charts/antrea/conf/antrea-agent.conf
+++ b/build/charts/antrea/conf/antrea-agent.conf
@@ -342,7 +342,7 @@ antreaProxy:
 {{- with .Values.antreaProxy }}
   # To disable AntreaProxy, set this to false.
   enable: {{.enable}}
-  # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+  # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
   # regardless of where they come from. Therefore, running kube-proxy is no longer required.
   # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
   # apiserver directly.
@@ -365,7 +365,7 @@ antreaProxy:
   # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
   # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
   # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-  # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+  # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
   # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
   proxyLoadBalancerIPs: {{ .proxyLoadBalancerIPs }}
   # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
diff --git a/build/yamls/antrea-aks.yml b/build/yamls/antrea-aks.yml
index 1d9c4c57e..64564b556 100644
--- a/build/yamls/antrea-aks.yml
+++ b/build/yamls/antrea-aks.yml
@@ -4038,7 +4038,7 @@ data:
     antreaProxy:
       # To disable AntreaProxy, set this to false.
       enable: true
-      # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+      # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
       # regardless of where they come from. Therefore, running kube-proxy is no longer required.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -4055,7 +4055,7 @@ data:
       # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
       # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
       # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
       # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
       proxyLoadBalancerIPs: true
       # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
@@ -5110,7 +5110,7 @@ spec:
         kubectl.kubernetes.io/default-container: antrea-agent
         # Automatically restart Pods with a RollingUpdate if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: f976029accf54258d01ad907fe19b50ac671eee014cd8aea968c6a0bc7e8f95a
+        checksum/config: 71bf05ff579aa9bea7b360669c5e2ce2830ca88dc4ab54480638ce006eaeaf11
       labels:
         app: antrea
         component: antrea-agent
@@ -5348,7 +5348,7 @@ spec:
       annotations:
         # Automatically restart Pod if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: f976029accf54258d01ad907fe19b50ac671eee014cd8aea968c6a0bc7e8f95a
+        checksum/config: 71bf05ff579aa9bea7b360669c5e2ce2830ca88dc4ab54480638ce006eaeaf11
       labels:
         app: antrea
         component: antrea-controller
diff --git a/build/yamls/antrea-eks.yml b/build/yamls/antrea-eks.yml
index 62e00bc5f..4cf276c99 100644
--- a/build/yamls/antrea-eks.yml
+++ b/build/yamls/antrea-eks.yml
@@ -4038,7 +4038,7 @@ data:
     antreaProxy:
       # To disable AntreaProxy, set this to false.
       enable: true
-      # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+      # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
       # regardless of where they come from. Therefore, running kube-proxy is no longer required.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -4055,7 +4055,7 @@ data:
       # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
       # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
       # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
       # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
       proxyLoadBalancerIPs: true
       # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
@@ -5110,7 +5110,7 @@ spec:
         kubectl.kubernetes.io/default-container: antrea-agent
         # Automatically restart Pods with a RollingUpdate if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: f976029accf54258d01ad907fe19b50ac671eee014cd8aea968c6a0bc7e8f95a
+        checksum/config: 71bf05ff579aa9bea7b360669c5e2ce2830ca88dc4ab54480638ce006eaeaf11
       labels:
         app: antrea
         component: antrea-agent
@@ -5349,7 +5349,7 @@ spec:
       annotations:
         # Automatically restart Pod if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: f976029accf54258d01ad907fe19b50ac671eee014cd8aea968c6a0bc7e8f95a
+        checksum/config: 71bf05ff579aa9bea7b360669c5e2ce2830ca88dc4ab54480638ce006eaeaf11
       labels:
         app: antrea
         component: antrea-controller
diff --git a/build/yamls/antrea-gke.yml b/build/yamls/antrea-gke.yml
index 22665a948..3e37bf206 100644
--- a/build/yamls/antrea-gke.yml
+++ b/build/yamls/antrea-gke.yml
@@ -4038,7 +4038,7 @@ data:
     antreaProxy:
       # To disable AntreaProxy, set this to false.
       enable: true
-      # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+      # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
       # regardless of where they come from. Therefore, running kube-proxy is no longer required.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -4055,7 +4055,7 @@ data:
       # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
       # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
       # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
       # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
       proxyLoadBalancerIPs: true
       # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
@@ -5110,7 +5110,7 @@ spec:
         kubectl.kubernetes.io/default-container: antrea-agent
         # Automatically restart Pods with a RollingUpdate if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: 5299e6235e262daf606758cf900766470fcb8dd21a0d707a3ae284548bd8c2b2
+        checksum/config: 91ff2b609519e4aaead6ab850252a49bbe674dec17f6f239c4d0fa6c7b5705f6
       labels:
         app: antrea
         component: antrea-agent
@@ -5346,7 +5346,7 @@ spec:
       annotations:
         # Automatically restart Pod if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: 5299e6235e262daf606758cf900766470fcb8dd21a0d707a3ae284548bd8c2b2
+        checksum/config: 91ff2b609519e4aaead6ab850252a49bbe674dec17f6f239c4d0fa6c7b5705f6
       labels:
         app: antrea
         component: antrea-controller
diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml
index 0c9afaa98..c17048997 100644
--- a/build/yamls/antrea-ipsec.yml
+++ b/build/yamls/antrea-ipsec.yml
@@ -4051,7 +4051,7 @@ data:
     antreaProxy:
       # To disable AntreaProxy, set this to false.
       enable: true
-      # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+      # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
       # regardless of where they come from. Therefore, running kube-proxy is no longer required.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -4068,7 +4068,7 @@ data:
       # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
       # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
       # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
       # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
       proxyLoadBalancerIPs: true
       # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
@@ -5123,7 +5123,7 @@ spec:
         kubectl.kubernetes.io/default-container: antrea-agent
         # Automatically restart Pods with a RollingUpdate if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: ba93df141f512a1f8483114b5994444c7231b298e7e9133483ddc1f4210ec395
+        checksum/config: 2d75956786eb552eaba94f89dfa5c6bab570bf662b82449e9af31a57ca138750
         checksum/ipsec-secret: d0eb9c52d0cd4311b6d252a951126bf9bea27ec05590bed8a394f0f792dcb2a4
       labels:
         app: antrea
@@ -5405,7 +5405,7 @@ spec:
       annotations:
         # Automatically restart Pod if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: ba93df141f512a1f8483114b5994444c7231b298e7e9133483ddc1f4210ec395
+        checksum/config: 2d75956786eb552eaba94f89dfa5c6bab570bf662b82449e9af31a57ca138750
       labels:
         app: antrea
         component: antrea-controller
diff --git a/build/yamls/antrea-windows-with-ovs.yml b/build/yamls/antrea-windows-with-ovs.yml
index a3b0987f0..181af1890 100644
--- a/build/yamls/antrea-windows-with-ovs.yml
+++ b/build/yamls/antrea-windows-with-ovs.yml
@@ -246,7 +246,7 @@ data:
       # To disable AntreaProxy, set this to false. It should be enabled on Windows, otherwise NetworkPolicy will
       # not take effect on Service traffic.
       enable: true
-      # ProxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
+      # proxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
       # Therefore, running kube-proxy is no longer required. This requires the AntreaProxy feature to be enabled.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -306,7 +306,7 @@ spec:
     metadata:
       annotations:
         checksum/agent-windows: 86f999cb18501659a52d982f20b3df5cdf666ffd849f50ed183c366e75d01ac5
-        checksum/windows-config: 10ad2be0a04b1752abc224fed0124f7b1da36efc5e7323e193eb38e11b25e798
+        checksum/windows-config: 4f07164f32afc61e20b4aef984a8781142e5d99f7c58f7581e4ccfeabb34855f
         microsoft.com/hostprocess-inherit-user: "true"
       labels:
         app: antrea
diff --git a/build/yamls/antrea-windows.yml b/build/yamls/antrea-windows.yml
index 00d330405..2a3837084 100644
--- a/build/yamls/antrea-windows.yml
+++ b/build/yamls/antrea-windows.yml
@@ -174,7 +174,7 @@ data:
       # To disable AntreaProxy, set this to false. It should be enabled on Windows, otherwise NetworkPolicy will
       # not take effect on Service traffic.
       enable: true
-      # ProxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
+      # proxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
       # Therefore, running kube-proxy is no longer required. This requires the AntreaProxy feature to be enabled.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -234,7 +234,7 @@ spec:
     metadata:
       annotations:
         checksum/agent-windows: 63f16e1fadb6b1354efda21c73702b4290400181136d4d47d4b1cd6a5f82d037
-        checksum/windows-config: 10ad2be0a04b1752abc224fed0124f7b1da36efc5e7323e193eb38e11b25e798
+        checksum/windows-config: 4f07164f32afc61e20b4aef984a8781142e5d99f7c58f7581e4ccfeabb34855f
         microsoft.com/hostprocess-inherit-user: "true"
       labels:
         app: antrea
diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml
index b938d9f83..97fd9640a 100644
--- a/build/yamls/antrea.yml
+++ b/build/yamls/antrea.yml
@@ -4038,7 +4038,7 @@ data:
     antreaProxy:
       # To disable AntreaProxy, set this to false.
       enable: true
-      # ProxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
+      # proxyAll tells antrea-agent to proxy all Service traffic, including NodePort, LoadBalancer, and ClusterIP traffic,
       # regardless of where they come from. Therefore, running kube-proxy is no longer required.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
@@ -4055,7 +4055,7 @@ data:
       # External IPs of LoadBalancer Services. This is useful when the external LoadBalancer provides additional
       # capabilities (e.g. TLS termination) and it is desirable for Pod-to-ExternalIP traffic to be sent to the
       # external LoadBalancer instead of being load-balanced to an Endpoint directly by AntreaProxy.
-      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when ProxyAll is set to true and
+      # Note that setting ProxyLoadBalancerIPs to false usually only makes sense when proxyAll is set to true and
       # kube-proxy is removed from the cluster, otherwise kube-proxy will still load-balance this traffic.
       proxyLoadBalancerIPs: true
       # The value of the "service.kubernetes.io/service-proxy-name" label for AntreaProxy to match. If it is set,
@@ -5110,7 +5110,7 @@ spec:
         kubectl.kubernetes.io/default-container: antrea-agent
         # Automatically restart Pods with a RollingUpdate if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: aca23e21519e0fc112647f23d3ce6f92a3dea0bc7ebf1c6d7a7eed2dbe80f0a3
+        checksum/config: ebc0be79b0fc65db51609f5c9185ca8a0533e265811d14c687f577cf93497a58
       labels:
         app: antrea
         component: antrea-agent
@@ -5346,7 +5346,7 @@ spec:
       annotations:
         # Automatically restart Pod if the ConfigMap changes
         # See https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments
-        checksum/config: aca23e21519e0fc112647f23d3ce6f92a3dea0bc7ebf1c6d7a7eed2dbe80f0a3
+        checksum/config: ebc0be79b0fc65db51609f5c9185ca8a0533e265811d14c687f577cf93497a58
       labels:
         app: antrea
         component: antrea-controller
diff --git a/ci/kind/kind-setup.sh b/ci/kind/kind-setup.sh
index e7646ea3b..1aa6bbc66 100755
--- a/ci/kind/kind-setup.sh
+++ b/ci/kind/kind-setup.sh
@@ -38,7 +38,8 @@ KUBE_PROXY_MODE="iptables"
 PROMETHEUS=false
 K8S_VERSION=""
 KUBE_NODE_IPAM=true
-DEPLOY_EXTERNAL_SERVER=false
+DEPLOY_EXTERNAL_AGNHOST=false
+DEPLOY_EXTERNAL_FRR=false
 positional_args=()
 options=()
 
@@ -79,8 +80,9 @@ where:
     network. Note, '--extra-networks' and '--subnets' cannot be specified together.
   --ip-family: specify the ip-family for the kind cluster, default is $IP_FAMILY.
   --k8s-version: specify the Kubernetes version of the kind cluster, kind's default K8s version will be used if empty.
-  --deploy-external-server: deploy a container running as an external server for the cluster.
-  --all: delete all kind clusters
+  --deploy-external-agnhost: deploy a container running agnhost as an external server for the cluster, default is $DEPLOY_EXTERNAL_AGNHOST.
+  --deploy-external-frr: deploy a container running FRR as an external router for the cluster, default is $DEPLOY_EXTERNAL_FRR.
+  --all: delete all kind clusters.
   --until: delete kind clusters that have been created before the specified duration.
 "
 
@@ -411,7 +413,7 @@ EOF
   configure_networks
   configure_extra_networks
   configure_vlan_subnets
-  setup_external_server
+  setup_external_servers
   load_images
 
   if [[ $ANTREA_CNI == true ]]; then
@@ -441,7 +443,7 @@ function destroy {
   else
       kind delete cluster --name $CLUSTER_NAME
   fi
-  destroy_external_server
+  destroy_external_servers
   delete_networks
   delete_vlan_subnets
 }
@@ -455,15 +457,31 @@ function printUnixTimestamp {
     fi
 }
 
-function setup_external_server {
-  if [[ $DEPLOY_EXTERNAL_SERVER == true ]]; then
-    docker run -d --name antrea-external-server-$RANDOM --network kind -it --rm registry.k8s.io/e2e-test-images/agnhost:2.40 netexec &> /dev/null
+function setup_external_servers {
+  if [[ $DEPLOY_EXTERNAL_AGNHOST == true ]]; then
+    docker run -d --name antrea-external-agnhost-$RANDOM --network kind -it --rm registry.k8s.io/e2e-test-images/agnhost:2.40 netexec &> /dev/null
   fi
+
+  if [[ $DEPLOY_EXTERNAL_FRR == true ]]; then
+    docker run -d \
+      --name antrea-external-frr-$RANDOM \
+      --network kind --cap-add=NET_BIND_SERVICE \
+      --cap-add=NET_ADMIN \
+      --cap-add=NET_RAW \
+      --cap-add=SYS_ADMIN \
+      -it \
+      --rm \
+      frrouting/frr:v8.4.0 \
+      bash -c "/bin/sed -i s/bgpd=no/bgpd=yes/g /etc/frr/daemons && /sbin/tini -- /usr/lib/frr/docker-start" &> /dev/null
+    fi
 }
 
-function destroy_external_server {
-  echo "Deleting external server"
-  cid=$(docker ps -f name="^antrea-external-server" --format '{{.ID}}')
+function destroy_external_servers {
+  echo "Deleting external servers"
+  cid=$(docker ps -f name="^antrea-external-agnhost" --format '{{.ID}}')
+  docker rm -f $cid &> /dev/null || true
+
+  cid=$(docker ps -f name="^antrea-external-frr" --format '{{.ID}}')
   docker rm -f $cid &> /dev/null || true
 }
 
@@ -583,9 +601,14 @@ while [[ $# -gt 0 ]]
       K8S_VERSION="$2"
       shift 2
       ;;
-    --deploy-external-server)
-      add_option "--deploy-external-server" "create"
-      DEPLOY_EXTERNAL_SERVER=true
+    --deploy-external-agnhost)
+      add_option "--deploy-external-agnhost" "create"
+      DEPLOY_EXTERNAL_AGNHOST=true
+      shift
+      ;;
+    --deploy-external-frr)
+      add_option "--deploy-external-frr" "create"
+      DEPLOY_EXTERNAL_FRR=true
       shift
       ;;
     --all)
diff --git a/docs/antrea-ipam.md b/docs/antrea-ipam.md
index 3e4a41244..41baa073e 100644
--- a/docs/antrea-ipam.md
+++ b/docs/antrea-ipam.md
@@ -278,8 +278,8 @@ where the underlay router will route the traffic to the destination VLAN.
 ### Requirements for this Feature
 
 As of now, this feature is supported on Linux Nodes, with IPv4, `system` OVS datapath
-type, `noEncap`, `noSNAT` traffic mode, and `AntreaProxy` feature enabled. Configuration
-with `ProxyAll` feature enabled is not verified.
+type, `noEncap`, `noSNAT` traffic mode, and Antrea Proxy enabled. Configuration
+with `proxyAll` enabled is not verified.
 
 The IPs in the `IPPools` without VLAN must be in the same underlay subnet as the Node
 IP, because inter-Node traffic of AntreaIPAM Pods is forwarded by the Node network.
diff --git a/docs/antrea-network-policy.md b/docs/antrea-network-policy.md
index 96ed35c1f..db4e27e22 100644
--- a/docs/antrea-network-policy.md
+++ b/docs/antrea-network-policy.md
@@ -1490,7 +1490,7 @@ Kubernetes](https://kubernetes.io/docs/concepts/services-networking/dns-pod-serv
 Services. The reason is that Antrea will use the information included in A or
 AAAA DNS records to implement FQDN based policies. In the case of "normal" (not
 headless) Services, the DNS name resolves to the ClusterIP for the Service, but
-policy rules are enforced after AntreaProxy Service Load-Balancing and at that
+policy rules are enforced after Antrea Proxy Service Load-Balancing and at that
 stage the destination IP address has already been rewritten to the address of an
 endpoint backing the Service. For headless Services, a ClusterIP is not
 allocated and, assuming the Service has a selector, the DNS server returns A /
@@ -1571,8 +1571,8 @@ A combination of Service name and Service Namespace can be used in `toServices`
 by this field. A sample policy can be found [here](#acnp-for-toservices-rule).
 
 Since `toServices` represents a combination of IP+port, it cannot be used with `to` or `ports` within the same egress rule.
-Also, since the matching process relies on the groupID assigned to Service by AntreaProxy, this field can only be used when
-AntreaProxy is enabled.
+Also, since the matching process relies on the groupID assigned to Service by Antrea Proxy, this field can only be used when
+Antrea Proxy is enabled.
 
 This clusterIP-based match has one caveat: direct access to the Endpoints of this Service is not affected by
 `toServices` rules. To restrict access towards backend Endpoints of a Service, define a `ClusterGroup` with `ServiceReference`
@@ -1952,11 +1952,11 @@ Similar RBAC is applied to the ClusterGroup resource.
   won't be blocked by new rules.
 - For hairpin Service traffic, when a Pod initiates traffic towards the Service it
   provides, and the same Pod is selected as the Endpoint, NetworkPolicies will
-  consistently permit this traffic during ingress enforcement if AntreaProxy is enabled,
+  consistently permit this traffic during ingress enforcement if Antrea Proxy is enabled,
   irrespective of the ingress rules defined by the user. In the presence of ingress rules
   preventing access to the Service from Pods providing the Service, accessing the Service
   from one of these Pods will succeed if traffic is hairpinned back to the source Pod, and
-  will fail if a different Endpoint is selected by AntreaProxy. However, when AntreaProxy
+  will fail if a different Endpoint is selected by Antrea Proxy. However, when Antrea Proxy
   is disabled, NetworkPolicies may not function as expected for hairpin Service traffic.
   This is due to kube-proxy performing SNAT, which conceals the original source IP from
   Antrea. Consequently, NetworkPolicies are unable to differentiate between hairpin
diff --git a/docs/antrea-proxy.md b/docs/antrea-proxy.md
index 5164367db..ccdf1ba31 100644
--- a/docs/antrea-proxy.md
+++ b/docs/antrea-proxy.md
@@ -1,10 +1,10 @@
-# AntreaProxy
+# Antrea Proxy
 
 ## Table of Contents
 
 <!-- toc -->
 - [Introduction](#introduction)
-- [AntreaProxy with proxyAll](#antreaproxy-with-proxyall)
+- [Antrea Proxy with proxyAll](#antrea-proxy-with-proxyall)
   - [Removing kube-proxy](#removing-kube-proxy)
     - [Windows Nodes](#windows-nodes)
   - [Configuring load balancer mode for external traffic](#configuring-load-balancer-mode-for-external-traffic)
@@ -16,26 +16,26 @@
 
 ## Introduction
 
-AntreaProxy was first introduced in Antrea v0.8 and has been enabled by default
-on all platforms since v0.11. AntreaProxy enables some or all of the cluster's
+Antrea Proxy was first introduced in Antrea v0.8 and has been enabled by default
+on all platforms since v0.11. Antrea Proxy enables some or all of the cluster's
 Service traffic to be load-balanced as part of the OVS pipeline, instead of
 depending on kube-proxy. We typically observe latency improvements for Service
-traffic when AntreaProxy is used.
+traffic when Antrea Proxy is used.
 
-While AntreaProxy can be disabled on Linux Nodes by setting the `AntreaProxy`
+While Antrea Proxy can be disabled on Linux Nodes by setting the `AntreaProxy`
 Feature Gate to `false`, it should remain enabled on all Windows Nodes, as it is
 needed for correct NetworkPolicy implementation for Pod-to-Service traffic.
 
-By default, AntreaProxy will only handle Service traffic originating from Pods
+By default, Antrea Proxy will only handle Service traffic originating from Pods
 in the cluster, with no support for NodePort. However, starting with Antrea
-v1.4, a new operating mode was introduced in which AntreaProxy can handle all
+v1.4, a new operating mode was introduced in which Antrea Proxy can handle all
 Service traffic, including NodePort. See the following
-[section](#antreaproxy-with-proxyall) for more information.
+[section](#antrea-proxy-with-proxyall) for more information.
 
-## AntreaProxy with proxyAll
+## Antrea Proxy with proxyAll
 
 The `proxyAll` configuration parameter can be enabled in the Antrea
-configuration if you want AntreaProxy to handle all Service traffic, with the
+configuration if you want Antrea Proxy to handle all Service traffic, with the
 possibility to remove kube-proxy altogether and have one less DaemonSet running
 in the cluster. This is particularly interesting on Windows Nodes, since until
 the introduction of `proxyAll`, Antrea relied on userspace kube-proxy, which is
@@ -43,19 +43,19 @@ no longer actively maintained by the K8s community and is slower than other
 kube-proxy backends.
 
 Note that on Linux, before Antrea v2.1, when `proxyAll` is enabled, kube-proxy
-will usually take priority over AntreaProxy and will keep handling all kinds of
+will usually take priority over Antrea Proxy and will keep handling all kinds of
 Service traffic (unless the source is a Pod, which is pretty unusual as Pods
 typically access Services by ClusterIP). This is because kube-proxy rules typically
-come before the rules installed by AntreaProxy to redirect traffic to OVS. When
-kube-proxy is not deployed or is removed from the cluster, AntreaProxy will then
+come before the rules installed by Antrea Proxy to redirect traffic to OVS. When
+kube-proxy is not deployed or is removed from the cluster, Antrea Proxy will then
 handle all Service traffic.
 
-Starting with Antrea v2.1, when `proxyAll` is enabled, AntreaProxy will handle
+Starting with Antrea v2.1, when `proxyAll` is enabled, Antrea Proxy will handle
 Service traffic destined to NodePort, LoadBalancerIP and ExternalIP, even if
 kube-proxy is present. This benefits users who want to take advantage of
-AntreaProxy's advanced features, such as Direct Server Return (DSR) mode, but
+Antrea Proxy's advanced features, such as Direct Server Return (DSR) mode, but
 lack control over kube-proxy's installation. This is accomplished by
-prioritizing the rules installed by AntreaProxy over those installed by
+prioritizing the rules installed by Antrea Proxy over those installed by
 kube-proxy, thus it works only with kube-proxy iptables mode. Support for other
 kube-proxy modes may be added in the future.
 
@@ -141,7 +141,7 @@ kube-proxy:
 
 Starting with Antrea v1.13, the `defaultLoadBalancerMode` configuration
 parameter and the `service.antrea.io/load-balancer-mode` Service annotation
-can be used to specify how you want AntreaProxy to handle external traffic
+can be used to specify how you want Antrea Proxy to handle external traffic
 destined to LoadBalancerIPs and ExternalIPs of Services. Specifically, the mode
 determines how external traffic is processed when it's load balanced across
 Nodes. Currently, it has two options: `nat` (default) and `dsr`.
@@ -209,8 +209,8 @@ configured to bind to that address and can therefore intercept queries. In case
 of a cache miss, queries can be sent to the cluster CoreDNS Pods thanks to a
 "shadow" Service which will expose CoreDNS Pods via a new ClusterIP.
 
-When AntreaProxy is enabled (default), Pod DNS queries to the kube-dns ClusterIP
-will be load-balanced directly by AntreaProxy to a CoreDNS Pod endpoint. This
+When Antrea Proxy is enabled (default), Pod DNS queries to the kube-dns ClusterIP
+will be load-balanced directly by Antrea Proxy to a CoreDNS Pod endpoint. This
 means that NodeLocal DNSCache is completely bypassed, which is probably not
 acceptable for users who want to leverage this feature to improve DNS
 performance in their clusters. While these users can update the Pod
@@ -219,8 +219,8 @@ interface, this is not always ideal in the context of CaaS, as it can require
 everyone running Pods in the cluster to be aware of the situation.
 
 This is the reason why we initially introduced the `skipServices` configuration
-option for AntreaProxy in Antrea v1.4. By adding the kube-dns Service (which
-exposes CoreDNS) to the list, you can ensure that AntreaProxy will "ignore" Pod
+option for Antrea Proxy in Antrea v1.4. By adding the kube-dns Service (which
+exposes CoreDNS) to the list, you can ensure that Antrea Proxy will "ignore" Pod
 DNS queries, and that they will be forwarded to NodeLocal DNSCache. You can edit
 the `antrea-config` ConfigMap as follows:
 
@@ -241,11 +241,11 @@ data:
 In some cases, the external LoadBalancer for a cluster provides additional
 capabilities (e.g., TLS termination) and it is desirable for Pods to access
 in-cluster Services through the external LoadBalancer. By default, this is not
-the case as both kube-proxy and AntreaProxy will install rules to load-balance
+the case as both kube-proxy and Antrea Proxy will install rules to load-balance
 this traffic directly at the source Node (even when the destination IP is set to
 the external `loadBalancerIP`). To circumvent this behavior, we introduced the
-`proxyLoadBalancerIPs` configuration option for AntreaProxy in Antrea v1.5. This
-option defaults to `true`, but when setting it to `false`, AntreaProxy will no
+`proxyLoadBalancerIPs` configuration option for Antrea Proxy in Antrea v1.5. This
+option defaults to `true`, but when setting it to `false`, Antrea Proxy will no
 longer load-balance traffic destined to external `loadBalancerIP`s, hence
 ensuring that this traffic can go to the external LoadBalancer. You can set it
 to `false` by editing the `antrea-config` ConfigMap as follows:
@@ -262,7 +262,7 @@ data:
       proxyLoadBalancerIPs: false
 ```
 
-With the above configuration, AntreaProxy will ignore all external `loadBalancerIP`s.
+With the above configuration, Antrea Proxy will ignore all external `loadBalancerIP`s.
 Starting with K8s v1.29, feature [LoadBalancerIPMode](https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-ip-mode)
 was introduced, providing users with a more fine-grained mechanism to control how
 every external `loadBalancerIP` behaves in a LoadBalancer Service.
@@ -274,12 +274,12 @@ every external `loadBalancerIP` behaves in a LoadBalancer Service.
   destined for the corresponding external `loadBalancerIP` should be sent to the
   external LoadBalancer.
 
-Starting with Antrea v2.0, AntreaProxy will respect `LoadBalancerIPMode` in LoadBalancer
+Starting with Antrea v2.0, Antrea Proxy will respect `LoadBalancerIPMode` in LoadBalancer
 Services when the configuration option `proxyLoadBalancerIPs` is set to `true`
-(default). In this case, AntreaProxy will serve only the external `loadBalancerIP`s
+(default). In this case, Antrea Proxy will serve only the external `loadBalancerIP`s
 configured with `LoadBalancerIPModeVIP`, and those configured with
-`LoadBalancerIPModeProxy` will bypass AntreaProxy. If the configuration option
-`proxyLoadBalancerIPs` is set to `false`, AntreaProxy will ignore the external
+`LoadBalancerIPModeProxy` will bypass Antrea Proxy. If the configuration option
+`proxyLoadBalancerIPs` is set to `false`, Antrea Proxy will ignore the external
 `loadBalancerIP`s even if configured with `LoadBalancerIPModeVIP`.
 
 There are two important prerequisites for this feature:
diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index 140a46e90..156719528 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -205,7 +205,7 @@ so their source IP will be rewritten to the Node's IP before going out.
 ### ClusterIP Service
 
 Antrea supports two ways to implement Services of type ClusterIP - leveraging
-`kube-proxy`, or AntreaProxy that implements load balancing for ClusterIP
+`kube-proxy`, or Antrea Proxy that implements load balancing for ClusterIP
 Service traffic with OVS.
 
 When leveraging `kube-proxy`, Antrea Agent adds OVS flows to forward the packets
@@ -222,12 +222,12 @@ the tunnel.
 See the [Kubernetes Service Proxies documentation](https://kubernetes.io/docs/reference/networking/virtual-ips)
 for more details.
 
-When AntreaProxy is enabled, Antrea Agent will add OVS flows that implement
+When Antrea Proxy is enabled, Antrea Agent will add OVS flows that implement
 load balancing and DNAT for the ClusterIP Service traffic. In this way, Service
 traffic load balancing is done inside OVS together with the rest of the
 forwarding, and it can achieve better performance than using `kube-proxy`, as
 there is no extra overhead of forwarding Service traffic to the host's network
-stack and iptables processing. The AntreaProxy implementation in Antrea Agent
+stack and iptables processing. The Antrea Proxy implementation in Antrea Agent
 leverages some `kube-proxy` packages to watch and process Service Endpoints.
 
 ### NetworkPolicy
diff --git a/docs/design/ovs-pipeline.md b/docs/design/ovs-pipeline.md
index 0dcf9ff7e..5f3aecd2f 100644
--- a/docs/design/ovs-pipeline.md
+++ b/docs/design/ovs-pipeline.md
@@ -257,7 +257,7 @@ Like K8s NetworkPolicy, several tables of the pipeline are dedicated to [Kuberne
 Service](https://kubernetes.io/docs/concepts/services-networking/service/) implementation (tables [NodePortMark],
 [SessionAffinity], [ServiceLB], and [EndpointDNAT]).
 
-By enabling `proxyAll`, ClusterIP, NodePort, LoadBalancer, and ExternalIP are all handled by AntreaProxy. Otherwise,
+By enabling `proxyAll`, ClusterIP, NodePort, LoadBalancer, and ExternalIP are all handled by Antrea Proxy. Otherwise,
 only in-cluster ClusterIP is handled. In this document, we use the sample K8s Services below. These Services select Pods
 with the label `app: web` as Endpoints.
 
@@ -788,12 +788,13 @@ Flow 1 is for case 1, matching packets received on the local Antrea gateway port
 addresses. There are some cases where the source IP of the packets through the local Antrea gateway port is not the local
 Antrea gateway IP address:
 
-- When Antrea is deployed with kube-proxy, and `AntreaProxy` is not enabled, packets from local Pods destined for Services
-  will first go through the gateway port, get load-balanced by the kube-proxy data path (undergoes DNAT) then re-enter
-  the OVS pipeline through the gateway port (through an "onlink" route, installed by Antrea, directing the DNAT'd packets
-  to the gateway port), resulting in the source IP being that of a local Pod.
-- When Antrea is deployed without kube-proxy, and both `AntreaProxy` and `proxyAll` are enabled, packets from the external
-  network destined for Services will be routed to OVS through the gateway port without masquerading source IP.
+- When Antrea is deployed with kube-proxy, and the feature `AntreaProxy` is not enabled, packets from local Pods destined
+  for Services will first go through the gateway port, get load-balanced by the kube-proxy data path (undergoes DNAT)
+  then re-enter the OVS pipeline through the gateway port (through an "onlink" route, installed by Antrea, directing the
+  DNAT'd packets to the gateway port), resulting in the source IP being that of a local Pod.
+- When Antrea is deployed without kube-proxy, and both the feature `AntreaProxy` and option `proxyAll` are enabled,
+  packets from the external network destined for Services will be routed to OVS through the gateway port without
+  masquerading source IP.
 - When Antrea is deployed with kube-proxy, packets from the external network destined for Services whose
   `externalTrafficPolicy` is set to `Local` will get load-balanced by the kube-proxy data path (undergoes DNAT with a
   local Endpoint selected by the kube-proxy) and then enter the OVS pipeline through the gateway (through a "onlink"
@@ -1303,7 +1304,7 @@ through the local Antrea gateway. In other words, these are connections for whic
 (SYN packet for TCP) was received through the local Antrea gateway. It rewrites the destination MAC address to
 that of the local Antrea gateway, loads `ToGatewayRegMark`, and forwards them to table [L3DecTTL]. This ensures that
 reply packets can be forwarded back to the local Antrea gateway in subsequent tables. This flow is required to handle
-the following cases when AntreaProxy is not enabled:
+the following cases when Antrea Proxy is not enabled:
 
 - Reply traffic for connections from a local Pod to a ClusterIP Service, which are handled by kube-proxy and go through
   DNAT. In this case, the destination IP address of the reply traffic is the Pod which initiated the connection to the
diff --git a/docs/design/windows-design.md b/docs/design/windows-design.md
index 0d80658c8..592d0f2f2 100644
--- a/docs/design/windows-design.md
+++ b/docs/design/windows-design.md
@@ -210,7 +210,7 @@ Kube-proxy userspace mode is configured to provide NodePort Service function. A
 "HNS Internal NIC" is provided to kube-proxy to configure Service addresses. The OpenFlow entries for the
 NodePort Service traffic on Windows are the same as those on Linux.
 
-AntreaProxy implements the ClusterIP Service function. Antrea Agent installs routes to send ClusterIP Service
+Antrea Proxy implements the ClusterIP Service function. Antrea Agent installs routes to send ClusterIP Service
 traffic from host network to the OVS bridge. For each Service, it adds a route that routes the traffic via a
 virtual IP (169.254.0.253), and it also adds a route to indicate that the virtual IP is reachable via
 antrea-gw0. The reason to add a virtual IP, rather than routing the traffic directly to antrea-gw0, is that
diff --git a/docs/feature-gates.md b/docs/feature-gates.md
index 287d9dacc..8efb4388e 100644
--- a/docs/feature-gates.md
+++ b/docs/feature-gates.md
@@ -64,18 +64,15 @@ edit the Agent configuration in the
 
 ### AntreaProxy
 
-`AntreaProxy` implements Service load-balancing for ClusterIP Services as part of the OVS pipeline, as opposed to
-relying on kube-proxy. This only applies to traffic originating from Pods, and destined to ClusterIP Services. In
-particular, it does not apply to NodePort Services. Please note that due to some restrictions on the implementation of
-Services in Antrea, the maximum number of Endpoints that Antrea can support at the moment is 800. If the number of
-Endpoints for a given Service exceeds 800, extra Endpoints will be dropped.
+`AntreaProxy` enables Antrea Proxy which implements Service load-balancing for ClusterIP Services as part of the OVS
+pipeline, as opposed to relying on kube-proxy. By default, this only applies to traffic originating from Pods, and
+destined to ClusterIP Services. However, it can be configured to support all types of Services, replacing kube-proxy
+entirely. Please refer to this [document](antrea-proxy.md) for extra information on Antrea Proxy and how it can be configured.
 
 Note that this feature must be enabled for Windows. The Antrea Windows YAML manifest provided as part of releases
 enables this feature by default. If you edit the manifest, make sure you do not disable it, as it is needed for correct
 NetworkPolicy implementation for Pod-to-Service traffic.
 
-Please refer to this [document](antrea-proxy.md) for extra information on AntreaProxy and how it can be configured.
-
 #### Requirements for this Feature
 
 When using the OVS built-in kernel module (which is the most common case), your kernel version must be >= 4.6 (as
@@ -83,9 +80,9 @@ opposed to >= 4.4 without this feature).
 
 ### EndpointSlice
 
-`EndpointSlice` enables Service EndpointSlice support in AntreaProxy. The EndpointSlice API was introduced in Kubernetes
+`EndpointSlice` enables Service EndpointSlice support in Antrea Proxy. The EndpointSlice API was introduced in Kubernetes
 1.16 (alpha) and it is enabled by default in Kubernetes 1.17 (beta), promoted to GA in Kubernetes 1.21. The EndpointSlice
-feature will take no effect if AntreaProxy is not enabled. Refer to this [link](https://kubernetes.io/docs/tasks/administer-cluster/enabling-endpointslices/)
+feature will take no effect if Antrea Proxy is not enabled. Refer to this [link](https://kubernetes.io/docs/tasks/administer-cluster/enabling-endpointslices/)
 for more information about EndpointSlice. If this feature is enabled but the EndpointSlice v1 API is not available
 (Kubernetes version is lower than 1.21), Antrea Agent will log a message and fallback to the Endpoints API.
 
@@ -96,7 +93,7 @@ for more information about EndpointSlice. If this feature is enabled but the End
 
 ### TopologyAwareHints
 
-`TopologyAwareHints` enables TopologyAwareHints support in AntreaProxy. For AntreaProxy, traffic can be routed to the
+`TopologyAwareHints` enables TopologyAwareHints support in Antrea Proxy. For Antrea Proxy, traffic can be routed to the
 Endpoint which is closer to where it originated when this feature is enabled. Refer to this [link](https://kubernetes.io/docs/concepts/services-networking/topology-aware-hints/)
 for more information about TopologyAwareHints.
 
@@ -111,7 +108,7 @@ for more information about TopologyAwareHints.
 mode determines how external traffic destined to LoadBalancerIPs and ExternalIPs of Services is processed when it's load
 balanced across Nodes. In DSR mode, external traffic is never SNAT'd and backend Pods running on Nodes that are not the
 ingress Node can reply to clients directly, bypassing the ingress Node. Therefore, DSR mode can preserve client IP of
-requests, and usually has lower latency and higher throughput. It's only meaningful to use this feature when AntreaProxy
+requests, and usually has lower latency and higher throughput. It's only meaningful to use this feature when Antrea Proxy
 is enabled and configured to proxy external traffic (proxyAll=true). Refer to this [link](
 antrea-proxy.md#configuring-load-balancer-mode-for-external-traffic) for more information about load balancer mode.
 
@@ -124,7 +121,7 @@ antrea-proxy.md#configuring-load-balancer-mode-for-external-traffic) for more in
 
 ### CleanupStaleUDPSvcConntrack
 
-`CleanupStaleUDPSvcConntrack` enables support for cleaning up stale UDP Service conntrack connections in AntreaProxy.
+`CleanupStaleUDPSvcConntrack` enables support for cleaning up stale UDP Service conntrack connections in Antrea Proxy.
 
 #### Requirements for this Feature
 
@@ -155,7 +152,7 @@ for both Linux and Windows). In v0.11, this feature was graduated to Beta (enabl
 lifted.
 
 In order to support cluster Services as the destination for tracing requests, option `antreaProxy.enable` should be set
-to true to enable AntreaProxy.
+to true to enable Antrea Proxy.
 
 ### Flow Exporter
 
diff --git a/docs/noencap-hybrid-modes.md b/docs/noencap-hybrid-modes.md
index ab0c3b311..0c96e121c 100644
--- a/docs/noencap-hybrid-modes.md
+++ b/docs/noencap-hybrid-modes.md
@@ -9,10 +9,10 @@ are in different subnets, but does not encapsulate when the source and the
 destination Nodes are in the same subnet. This document describes how to
 configure Antrea with the `NoEncap` and `Hybrid` modes.
 
-The NoEncap and Hybrid traffic modes require AntreaProxy to support correct
-NetworkPolicy enforcement, which is why trying to disable AntreaProxy in these
+The NoEncap and Hybrid traffic modes require Antrea Proxy to support correct
+NetworkPolicy enforcement, which is why trying to disable Antrea Proxy in these
 modes will normally cause the Antrea Agent to fail. It is possible to override
-this behavior and force AntreaProxy to be disabled by setting the
+this behavior and force Antrea Proxy to be disabled by setting the
 ALLOW_NO_ENCAP_WITHOUT_ANTREA_PROXY environment variable to true for the Antrea
 Agent in the [Antrea deployment yaml](../build/yamls/antrea.yml).
 For example:
diff --git a/docs/prometheus-integration.md b/docs/prometheus-integration.md
index 25a49b9b3..47a4daf7a 100644
--- a/docs/prometheus-integration.md
+++ b/docs/prometheus-integration.md
@@ -207,15 +207,15 @@ duration of syncing internal-networkpolicy
 #### Antrea Proxy Metrics
 
 - **antrea_proxy_sync_proxy_rules_duration_seconds:** SyncProxyRules duration
-of AntreaProxy in seconds
+of Antrea Proxy in seconds
 - **antrea_proxy_total_endpoints_installed:** The number of Endpoints
-installed by AntreaProxy
+installed by Antrea Proxy
 - **antrea_proxy_total_endpoints_updates:** The cumulative number of Endpoint
-updates received by AntreaProxy
+updates received by Antrea Proxy
 - **antrea_proxy_total_services_installed:** The number of Services installed
-by AntreaProxy
+by Antrea Proxy
 - **antrea_proxy_total_services_updates:** The cumulative number of Service
-updates received by AntreaProxy
+updates received by Antrea Proxy
 
 ### Common Metrics Provided by Infrastructure
 
diff --git a/docs/service-loadbalancer.md b/docs/service-loadbalancer.md
index 7f8235da0..3210ab513 100644
--- a/docs/service-loadbalancer.md
+++ b/docs/service-loadbalancer.md
@@ -36,7 +36,7 @@ LoadBalancer
 ## Service external IP management by Antrea
 
 Antrea supports external IP management for Services of type LoadBalancer
-since version 1.5, which can work together with `AntreaProxy` or
+since version 1.5, which can work together with Antrea Proxy or
 `kube-proxy` to implement Services of type LoadBalancer, without requiring an
 external load balancer. With the external IP management feature, Antrea can
 allocate an external IP for a Service of type LoadBalancer from an
@@ -44,7 +44,7 @@ allocate an external IP for a Service of type LoadBalancer from an
 based on the ExternalIPPool's NodeSelector to host the external IP. Antrea
 configures the Service's external IP on the selected Node, and thus Service
 requests to the external IP will get to the Node, and they are then handled by
-`AntreaProxy` or `kube-proxy` on the Node and distributed to the Service's
+Antrea Proxy or `kube-proxy` on the Node and distributed to the Service's
 Endpoints. Antrea also implements a Node failover mechanism for Service
 external IPs. When Antrea detects a Node hosting an external IP is down, it
 will move the external IP to another available Node of the ExternalIPPool.
@@ -56,7 +56,7 @@ enabled in the `kube-proxy` configuration. For more information about how to
 configure `kube-proxy`, please refer to the [Interoperability with kube-proxy
 IPVS mode](#interoperability-with-kube-proxy-ipvs-mode) section.
 
-If you are using `kube-proxy` iptables mode or [`AntreaProxy` with `proxyAll`](antrea-proxy.md#antreaproxy-with-proxyall),
+If you are using `kube-proxy` iptables mode or [Antrea Proxy with `proxyAll`](antrea-proxy.md#antrea-proxy-with-proxyall),
 no extra configuration change is needed.
 
 ### Configuration
@@ -84,15 +84,15 @@ data:
       ServiceExternalIP: true
 ```
 
-The feature works with both `AntreaProxy` and `kube-proxy`, including the
+The feature works with both Antrea Proxy and `kube-proxy`, including the
 following configurations:
 
-- `AntreaProxy` without `proxyAll` enabled - this is `antrea-agent`'s default
+- Antrea Proxy without `proxyAll` enabled - this is `antrea-agent`'s default
 configuration, in which `kube-proxy` serves the request traffic for Services
-of type LoadBalancer (while `AntreaProxy` handles Service requests from Pods).
-- `AntreaProxy` with `proxyAll` enabled - in this case, `AntreaProxy` handles
+of type LoadBalancer (while Antrea Proxy handles Service requests from Pods).
+- Antrea Proxy with `proxyAll` enabled - in this case, Antrea Proxy handles
 all Service traffic, including Services of type LoadBalancer.
-- `AntreaProxy` disabled - `kube-proxy` handles all Service traffic, including
+- Antrea Proxy disabled - `kube-proxy` handles all Service traffic, including
 Services of type LoadBalancer.
 
 #### Create an ExternalIPPool custom resource
@@ -312,7 +312,7 @@ As MetalLB will allocate external IPs for all Services of type LoadBalancer,
 once it is running, the Service external IP management feature of Antrea should
 not be enabled to avoid conflicts with MetalLB. You can deploy Antrea with the
 default configuration (in which the `ServiceExternalIP` feature gate of
-`antrea-agent` is set to `false`). MetalLB can work with both `AntreaProxy` and
+`antrea-agent` is set to `false`). MetalLB can work with both Antrea Proxy and
 `kube-proxy` configurations of `antrea-agent`.
 
 ### Configure MetalLB with layer 2 mode
diff --git a/docs/traceflow-guide.md b/docs/traceflow-guide.md
index 0a3f55310..bf4c2c060 100644
--- a/docs/traceflow-guide.md
+++ b/docs/traceflow-guide.md
@@ -30,7 +30,7 @@ graph.
 ## Prerequisites
 
 The Traceflow feature is enabled by default since Antrea version v0.11. In order
-to use a Service as the destination in traces, AntreaProxy (also enabled by
+to use a Service as the destination in traces, Antrea Proxy (also enabled by
 default since v0.11) is required.
 
 ## Start a New Traceflow
diff --git a/docs/windows.md b/docs/windows.md
index a2a31197a..871de3474 100644
--- a/docs/windows.md
+++ b/docs/windows.md
@@ -142,10 +142,10 @@ configuration file as the Antrea Proxy `proxyAll` mode is enabled by default.
     # Defaults to "". It must be a host string, a host:port pair, or a URL to the base of the apiserver.
     kubeAPIServerOverride: "10.10.1.1:6443"
 
-    # Option antreaProxy contains AntreaProxy related configuration options.
+    # Option antreaProxy contains Antrea Proxy related configuration options.
     antreaProxy:
-      # ProxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
-      # Therefore, running kube-proxy is no longer required. This requires the AntreaProxy feature to be enabled.
+      # Option proxyAll tells antrea-agent to proxy ClusterIP Service traffic, regardless of where they come from.
+      # Therefore, running kube-proxy is no longer required. This requires the Antrea Proxy feature to be enabled.
       # Note that this option is experimental. If kube-proxy is removed, option kubeAPIServerOverride must be used to access
       # apiserver directly.
       proxyAll: true
diff --git a/hack/windows/Install-OVS.ps1 b/hack/windows/Install-OVS.ps1
index a648582c2..3f17c974b 100644
--- a/hack/windows/Install-OVS.ps1
+++ b/hack/windows/Install-OVS.ps1
@@ -175,7 +175,14 @@ function CheckAndInstallOVSDriver {
     $ExportType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert
     $Cert = (Get-AuthenticodeSignature $DriverFile).SignerCertificate
     [System.IO.File]::WriteAllBytes($CertificateFile, $Cert.Export($ExportType))
-    Import-Certificate -FilePath "$CertificateFile" -CertStoreLocation cert:\LocalMachine\TrustedPublisher
+    # Use certstore.Add to import cert into trusted publishers instead of Import-Certificate,
+    # otherwise an error "Access is denied. (Exception from HRESULT: 0x80070005 (E_ACCESSDENIED))"
+    # may occur when `Import-Certificate` is used to import a certificate to the trusted publisher
+    # store for the first time on a fresh Windows 2022 Node. See issue #6530.
+    $CertStore = Get-Item cert:\LocalMachine\TrustedPublisher
+    $CertStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]"ReadWrite")
+    $CertStore.Add($(Get-Item $CertificateFile).FullName)
+    $CertStore.Close()
     Import-Certificate -FilePath "$CertificateFile" -CertStoreLocation cert:\LocalMachine\Root
 
     # Install the OVSext driver with the desired version
diff --git a/pkg/agent/nodeportlocal/k8s/annotations.go b/pkg/agent/nodeportlocal/k8s/annotations.go
index 8601d73b7..62dba91a6 100644
--- a/pkg/agent/nodeportlocal/k8s/annotations.go
+++ b/pkg/agent/nodeportlocal/k8s/annotations.go
@@ -22,6 +22,7 @@ import (
 	"sort"
 
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	clientset "k8s.io/client-go/kubernetes"
@@ -62,7 +63,7 @@ func patchPod(value []npltypes.NPLAnnotation, pod *corev1.Pod, kubeClient client
 
 	payloadBytes, _ := json.Marshal(newPayload)
 	if _, err := kubeClient.CoreV1().Pods(pod.Namespace).Patch(context.TODO(), pod.Name, types.MergePatchType,
-		payloadBytes, metav1.PatchOptions{}, "status"); err != nil {
+		payloadBytes, metav1.PatchOptions{}, "status"); err != nil && !errors.IsNotFound(err) {
 		return fmt.Errorf("unable to update NodePortLocal annotation for Pod %s/%s: %v", pod.Namespace,
 			pod.Name, err)
 	}
diff --git a/pkg/agent/nodeportlocal/k8s/npl_controller.go b/pkg/agent/nodeportlocal/k8s/npl_controller.go
index 9469af9ad..60d24a644 100644
--- a/pkg/agent/nodeportlocal/k8s/npl_controller.go
+++ b/pkg/agent/nodeportlocal/k8s/npl_controller.go
@@ -18,13 +18,13 @@ import (
 	"encoding/json"
 	"fmt"
 	"reflect"
-	"sync"
 	"time"
 
 	"antrea.io/antrea/pkg/agent/nodeportlocal/portcache"
 	"antrea.io/antrea/pkg/agent/nodeportlocal/rules"
 	"antrea.io/antrea/pkg/agent/nodeportlocal/types"
 	"antrea.io/antrea/pkg/agent/nodeportlocal/util"
+	"antrea.io/antrea/pkg/util/k8s"
 	utilsets "antrea.io/antrea/pkg/util/sets"
 
 	corev1 "k8s.io/api/core/v1"
@@ -57,9 +57,7 @@ type NPLController struct {
 	podInformer cache.SharedIndexInformer
 	podLister   corelisters.PodLister
 	svcInformer cache.SharedIndexInformer
-	podToIP     map[string]string
 	nodeName    string
-	podIPLock   sync.RWMutex
 }
 
 func NewNPLController(kubeClient clientset.Interface,
@@ -73,7 +71,6 @@ func NewNPLController(kubeClient clientset.Interface,
 		podInformer: podInformer,
 		podLister:   corelisters.NewPodLister(podInformer.GetIndexer()),
 		svcInformer: svcInformer,
-		podToIP:     make(map[string]string),
 		nodeName:    nodeName,
 	}
 
@@ -294,10 +291,13 @@ func (c *NPLController) getPodsFromService(svc *corev1.Service) []string {
 	return pods
 }
 
-func (c *NPLController) getTargetPortsForServicesOfPod(obj interface{}) (sets.Set[string], sets.Set[string]) {
+func (c *NPLController) getTargetPortsForServicesOfPod(pod *corev1.Pod) (sets.Set[string], sets.Set[string]) {
 	targetPortsInt := sets.New[string]()
 	targetPortsStr := sets.New[string]()
-	pod := obj.(*corev1.Pod)
+	// If the Pod is already terminated, its NodePortLocal ports should be released.
+	if k8s.IsPodTerminated(pod) {
+		return targetPortsInt, targetPortsStr
+	}
 	services, err := c.svcInformer.GetIndexer().ByIndex(NPLEnabledAnnotationIndex, "true")
 	if err != nil {
 		klog.Errorf("Got error while listing Services with annotation %s: %v", types.NPLEnabledAnnotationKey, err)
@@ -377,27 +377,8 @@ func (c *NPLController) processNextWorkItem() bool {
 	return true
 }
 
-func (c *NPLController) getPodIPFromCache(key string) (string, bool) {
-	c.podIPLock.RLock()
-	defer c.podIPLock.RUnlock()
-	podIP, found := c.podToIP[key]
-	return podIP, found
-}
-
-func (c *NPLController) addPodIPToCache(key, podIP string) {
-	c.podIPLock.Lock()
-	defer c.podIPLock.Unlock()
-	c.podToIP[key] = podIP
-}
-
-func (c *NPLController) deletePodIPFromCache(key string) {
-	c.podIPLock.Lock()
-	defer c.podIPLock.Unlock()
-	delete(c.podToIP, key)
-}
-
-func (c *NPLController) deleteAllPortRulesIfAny(podIP string) error {
-	return c.portTable.DeleteRulesForPod(podIP)
+func (c *NPLController) deleteAllPortRulesIfAny(podKey string) error {
+	return c.portTable.DeleteRulesForPod(podKey)
 }
 
 // handleRemovePod removes rules from port table and
@@ -405,18 +386,11 @@ func (c *NPLController) deleteAllPortRulesIfAny(podIP string) error {
 // This also removes Pod annotation from Pods that are not selected by Service annotation.
 func (c *NPLController) handleRemovePod(key string) error {
 	klog.V(2).Infof("Got delete event for Pod: %s", key)
-	podIP, found := c.getPodIPFromCache(key)
-	if !found {
-		klog.Infof("IP address not found for Pod: %s", key)
-		return nil
-	}
 
-	if err := c.deleteAllPortRulesIfAny(podIP); err != nil {
+	if err := c.deleteAllPortRulesIfAny(key); err != nil {
 		return err
 	}
 
-	c.deletePodIPFromCache(key)
-
 	return nil
 }
 
@@ -430,9 +404,8 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 		klog.Infof("IP address not set for Pod: %s", key)
 		return nil
 	}
-	c.addPodIPToCache(key, podIP)
 
-	targetPortsInt, targetPortsStr := c.getTargetPortsForServicesOfPod(obj)
+	targetPortsInt, targetPortsStr := c.getTargetPortsForServicesOfPod(pod)
 	klog.V(2).Infof("Pod %s is selected by a Service for which NodePortLocal is enabled", key)
 
 	var nodePort int
@@ -474,7 +447,7 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 	// Pod have to be cleaned up. If a Service uses a named target port that doesn't match any named container port
 	// for the current Pod, no corresponding entry will be added to the targetPortsInt set by the code above.
 	if len(targetPortsInt) == 0 {
-		if err := c.deleteAllPortRulesIfAny(podIP); err != nil {
+		if err := c.deleteAllPortRulesIfAny(key); err != nil {
 			return err
 		}
 		if _, exists := pod.Annotations[types.NPLAnnotationKey]; exists {
@@ -492,13 +465,13 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 			return fmt.Errorf("failed to parse port number and protocol from %s for Pod %s: %v", targetPortProto, key, err)
 		}
 		podPorts[targetPortProto] = struct{}{}
-		portData := c.portTable.GetEntry(podIP, port, protocol)
+		portData := c.portTable.GetEntry(key, port, protocol)
 		// Special handling for a rule that was previously marked for deletion but could not
 		// be deleted properly: we have to retry now.
 		if portData != nil && portData.Defunct() {
 			klog.InfoS("Deleting defunct rule for Pod to prevent re-use", "pod", klog.KObj(pod), "podIP", podIP, "port", port, "protocol", protocol)
-			if err := c.portTable.DeleteRule(podIP, port, protocol); err != nil {
-				return fmt.Errorf("failed to delete defunct rule for Pod IP %s, Pod Port %d, Protocol %s: %w", podIP, port, protocol, err)
+			if err := c.portTable.DeleteRule(key, port, protocol); err != nil {
+				return fmt.Errorf("failed to delete defunct rule for Pod %s, Pod Port %d, Protocol %s: %w", key, port, protocol, err)
 			}
 			portData = nil
 		}
@@ -506,7 +479,7 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 			if hport, ok := hostPorts[targetPortProto]; ok {
 				nodePort = hport
 			} else {
-				nodePort, err = c.portTable.AddRule(podIP, port, protocol)
+				nodePort, err = c.portTable.AddRule(key, port, protocol, podIP)
 				if err != nil {
 					return fmt.Errorf("failed to add rule for Pod %s: %v", key, err)
 				}
@@ -529,12 +502,12 @@ func (c *NPLController) handleAddUpdatePod(key string, obj interface{}) error {
 
 	// second, delete any existing rule that is not needed based on the current Pod
 	// specification.
-	entries := c.portTable.GetDataForPodIP(podIP)
+	entries := c.portTable.GetDataForPod(key)
 	for _, data := range entries {
 		proto := data.Protocol
 		if _, exists := podPorts[util.BuildPortProto(fmt.Sprint(data.PodPort), proto.Protocol)]; !exists {
-			if err := c.portTable.DeleteRule(podIP, int(data.PodPort), proto.Protocol); err != nil {
-				return fmt.Errorf("failed to delete rule for Pod IP %s, Pod Port %d, Protocol %s: %w", podIP, data.PodPort, proto.Protocol, err)
+			if err := c.portTable.DeleteRule(key, data.PodPort, proto.Protocol); err != nil {
+				return fmt.Errorf("failed to delete rule for Pod %s, Pod Port %d, Protocol %s: %w", key, data.PodPort, proto.Protocol, err)
 			}
 		}
 	}
@@ -576,6 +549,7 @@ func (c *NPLController) waitForRulesInitialization() {
 		//   if yes, verifiy validity of the Node port, update the port table and add a rule to the
 		//   rules buffer.
 		pod := podList[i]
+		podKey := podKeyFunc(pod)
 		annotations := pod.GetAnnotations()
 		nplAnnotation, ok := annotations[types.NPLAnnotationKey]
 		if !ok {
@@ -599,6 +573,7 @@ func (c *NPLController) waitForRulesInitialization() {
 				continue
 			}
 			allNPLPorts = append(allNPLPorts, rules.PodNodePort{
+				PodKey:   podKey,
 				NodePort: npl.NodePort,
 				PodPort:  npl.PodPort,
 				PodIP:    pod.Status.PodIP,
diff --git a/pkg/agent/nodeportlocal/portcache/port_table.go b/pkg/agent/nodeportlocal/portcache/port_table.go
index b1856861b..29894c201 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table.go
@@ -29,7 +29,7 @@ import (
 const (
 	NodePortIndex    = "nodePortIndex"
 	PodEndpointIndex = "podEndpointIndex"
-	PodIPIndex       = "podIPIndex"
+	PodKeyIndex      = "podKeyIndex"
 )
 
 type ProtocolSocketData struct {
@@ -38,6 +38,8 @@ type ProtocolSocketData struct {
 }
 
 type NodePortData struct {
+	// PodKey is the namespaced name of the Pod.
+	PodKey   string
 	NodePort int
 	PodPort  int
 	PodIP    string
@@ -69,7 +71,7 @@ type PortTable struct {
 
 func GetPortTableKey(obj interface{}) (string, error) {
 	npData := obj.(*NodePortData)
-	key := fmt.Sprintf("%d:%s:%d:%s", npData.NodePort, npData.PodIP, npData.PodPort, npData.Protocol.Protocol)
+	key := fmt.Sprintf("%d:%s:%d:%s", npData.NodePort, npData.PodKey, npData.PodPort, npData.Protocol.Protocol)
 	return key, nil
 }
 
@@ -103,9 +105,9 @@ func (pt *PortTable) getPortTableCacheFromPodEndpointIndex(index string) (*NodeP
 	return objs[0].(*NodePortData), true
 }
 
-func (pt *PortTable) getPortTableCacheFromPodIPIndex(index string) ([]*NodePortData, bool) {
+func (pt *PortTable) getPortTableCacheFromPodKeyIndex(index string) ([]*NodePortData, bool) {
 	var npData []*NodePortData
-	objs, _ := pt.PortTableCache.ByIndex(PodIPIndex, index)
+	objs, _ := pt.PortTableCache.ByIndex(PodKeyIndex, index)
 	if len(objs) == 0 {
 		return nil, false
 	}
@@ -133,13 +135,13 @@ func NodePortIndexFunc(obj interface{}) ([]string, error) {
 
 func PodEndpointIndexFunc(obj interface{}) ([]string, error) {
 	npData := obj.(*NodePortData)
-	podEndpointTuple := podIPPortProtoFormat(npData.PodIP, npData.PodPort, npData.Protocol.Protocol)
+	podEndpointTuple := podKeyPortProtoFormat(npData.PodKey, npData.PodPort, npData.Protocol.Protocol)
 	return []string{podEndpointTuple}, nil
 }
 
-func PodIPIndexFunc(obj interface{}) ([]string, error) {
+func PodKeyIndexFunc(obj interface{}) ([]string, error) {
 	npData := obj.(*NodePortData)
-	return []string{npData.PodIP}, nil
+	return []string{npData.PodKey}, nil
 }
 
 func NewPortTable(start, end int) (*PortTable, error) {
@@ -147,7 +149,7 @@ func NewPortTable(start, end int) (*PortTable, error) {
 		PortTableCache: cache.NewIndexer(GetPortTableKey, cache.Indexers{
 			NodePortIndex:    NodePortIndexFunc,
 			PodEndpointIndex: PodEndpointIndexFunc,
-			PodIPIndex:       PodIPIndexFunc,
+			PodKeyIndex:      PodKeyIndexFunc,
 		}),
 		StartPort:       start,
 		EndPort:         end,
@@ -167,48 +169,48 @@ func (pt *PortTable) CleanupAllEntries() {
 	pt.releaseDataFromPortTableCache()
 }
 
-func (pt *PortTable) GetDataForPodIP(ip string) []*NodePortData {
+func (pt *PortTable) GetDataForPod(podKey string) []*NodePortData {
 	pt.tableLock.RLock()
 	defer pt.tableLock.RUnlock()
-	return pt.getDataForPodIP(ip)
+	return pt.getDataForPod(podKey)
 }
 
-func (pt *PortTable) getDataForPodIP(ip string) []*NodePortData {
-	allData, exist := pt.getPortTableCacheFromPodIPIndex(ip)
+func (pt *PortTable) getDataForPod(podKey string) []*NodePortData {
+	allData, exist := pt.getPortTableCacheFromPodKeyIndex(podKey)
 	if exist == false {
 		return nil
 	}
 	return allData
 }
 
-func (pt *PortTable) GetEntry(ip string, port int, protocol string) *NodePortData {
+func (pt *PortTable) GetEntry(podKey string, port int, protocol string) *NodePortData {
 	pt.tableLock.RLock()
 	defer pt.tableLock.RUnlock()
 	// Return pointer to copy of data from the PodEndpointTable.
-	if data := pt.getEntryByPodIPPortProto(ip, port, protocol); data != nil {
+	if data := pt.getEntryByPodKeyPortProto(podKey, port, protocol); data != nil {
 		dataCopy := *data
 		return &dataCopy
 	}
 	return nil
 }
 
-// podIPPortProtoFormat formats the ip, port and protocol to string ip:port:protocol.
-func podIPPortProtoFormat(ip string, port int, protocol string) string {
-	return fmt.Sprintf("%s:%d:%s", ip, port, protocol)
+// podKeyPortProtoFormat formats the podKey, port and protocol to string key:port:protocol.
+func podKeyPortProtoFormat(podKey string, port int, protocol string) string {
+	return fmt.Sprintf("%s:%d:%s", podKey, port, protocol)
 }
 
-func (pt *PortTable) getEntryByPodIPPortProto(ip string, port int, protocol string) *NodePortData {
-	data, ok := pt.getPortTableCacheFromPodEndpointIndex(podIPPortProtoFormat(ip, port, protocol))
+func (pt *PortTable) getEntryByPodKeyPortProto(podKey string, port int, protocol string) *NodePortData {
+	data, ok := pt.getPortTableCacheFromPodEndpointIndex(podKeyPortProtoFormat(podKey, port, protocol))
 	if !ok {
 		return nil
 	}
 	return data
 }
 
-func (pt *PortTable) RuleExists(podIP string, podPort int, protocol string) bool {
+func (pt *PortTable) RuleExists(podKey string, podPort int, protocol string) bool {
 	pt.tableLock.RLock()
 	defer pt.tableLock.RUnlock()
-	data := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	data := pt.getEntryByPodKeyPortProto(podKey, podPort, protocol)
 	return data != nil
 }
 
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_others.go b/pkg/agent/nodeportlocal/portcache/port_table_others.go
index c13eaf1d7..e8c55ce91 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_others.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_others.go
@@ -70,10 +70,10 @@ func (pt *PortTable) getFreePort(podIP string, podPort int, protocol string) (in
 	return 0, ProtocolSocketData{}, fmt.Errorf("no free port found")
 }
 
-func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, error) {
+func (pt *PortTable) AddRule(podKey string, podPort int, protocol string, podIP string) (int, error) {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	npData := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	npData := pt.getEntryByPodKeyPortProto(podKey, podPort, protocol)
 	exists := (npData != nil)
 	if !exists {
 		nodePort, protocolData, err := pt.getFreePort(podIP, podPort, protocol)
@@ -81,6 +81,7 @@ func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, e
 			return 0, err
 		}
 		npData = &NodePortData{
+			PodKey:   podKey,
 			NodePort: nodePort,
 			PodIP:    podIP,
 			PodPort:  podPort,
@@ -125,10 +126,10 @@ func (pt *PortTable) deleteRule(data *NodePortData) error {
 	return nil
 }
 
-func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) error {
+func (pt *PortTable) DeleteRule(podKey string, podPort int, protocol string) error {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	data := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	data := pt.getEntryByPodKeyPortProto(podKey, podPort, protocol)
 	if data == nil {
 		// Delete not required when the PortTable entry does not exist
 		return nil
@@ -136,10 +137,10 @@ func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) erro
 	return pt.deleteRule(data)
 }
 
-func (pt *PortTable) DeleteRulesForPod(podIP string) error {
+func (pt *PortTable) DeleteRulesForPod(podKey string) error {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	podEntries := pt.getDataForPodIP(podIP)
+	podEntries := pt.getDataForPod(podKey)
 	for _, podEntry := range podEntries {
 		return pt.deleteRule(podEntry)
 	}
@@ -155,6 +156,7 @@ func (pt *PortTable) syncRules() error {
 	for _, obj := range objs {
 		npData := obj.(*NodePortData)
 		nplPorts = append(nplPorts, rules.PodNodePort{
+			PodKey:   npData.PodKey,
 			NodePort: npData.NodePort,
 			PodPort:  npData.PodPort,
 			PodIP:    npData.PodIP,
@@ -185,6 +187,7 @@ func (pt *PortTable) RestoreRules(allNPLPorts []rules.PodNodePort, synced chan<-
 		}
 
 		npData := &NodePortData{
+			PodKey:   nplPort.PodKey,
 			NodePort: nplPort.NodePort,
 			PodPort:  nplPort.PodPort,
 			PodIP:    nplPort.PodIP,
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_windows.go b/pkg/agent/nodeportlocal/portcache/port_table_windows.go
index e084bde18..5190987a0 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_windows.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_windows.go
@@ -69,10 +69,10 @@ func (pt *PortTable) addRuleforFreePort(podIP string, podPort int, protocol stri
 	return 0, ProtocolSocketData{}, fmt.Errorf("no free port found")
 }
 
-func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, error) {
+func (pt *PortTable) AddRule(podKey string, podPort int, protocol string, podIP string) (int, error) {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	npData := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	npData := pt.getEntryByPodKeyPortProto(podKey, podPort, protocol)
 	exists := (npData != nil)
 	if !exists {
 		nodePort, protocolData, err := pt.addRuleforFreePort(podIP, podPort, protocol)
@@ -81,6 +81,7 @@ func (pt *PortTable) AddRule(podIP string, podPort int, protocol string) (int, e
 			return 0, err
 		}
 		npData = &NodePortData{
+			PodKey:   podKey,
 			NodePort: nodePort,
 			PodIP:    podIP,
 			PodPort:  podPort,
@@ -110,6 +111,7 @@ func (pt *PortTable) RestoreRules(allNPLPorts []rules.PodNodePort, synced chan<-
 		}
 
 		npData := &NodePortData{
+			PodKey:   nplPort.PodKey,
 			NodePort: nplPort.NodePort,
 			PodPort:  nplPort.PodPort,
 			PodIP:    nplPort.PodIP,
@@ -122,10 +124,10 @@ func (pt *PortTable) RestoreRules(allNPLPorts []rules.PodNodePort, synced chan<-
 	return nil
 }
 
-func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) error {
+func (pt *PortTable) DeleteRule(podKey string, podPort int, protocol string) error {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	data := pt.getEntryByPodIPPortProto(podIP, podPort, protocol)
+	data := pt.getEntryByPodKeyPortProto(podKey, podPort, protocol)
 	if data == nil {
 		// Delete not required when the PortTable entry does not exist
 		return nil
@@ -133,20 +135,20 @@ func (pt *PortTable) DeleteRule(podIP string, podPort int, protocol string) erro
 
 	data.defunct = true
 	// Calling DeleteRule is idempotent.
-	if err := pt.PodPortRules.DeleteRule(data.NodePort, podIP, podPort, protocol); err != nil {
+	if err := pt.PodPortRules.DeleteRule(data.NodePort, data.PodIP, podPort, protocol); err != nil {
 		return err
 	}
 	pt.deletePortTableCache(data)
 	return nil
 }
 
-func (pt *PortTable) DeleteRulesForPod(podIP string) error {
+func (pt *PortTable) DeleteRulesForPod(podKey string) error {
 	pt.tableLock.Lock()
 	defer pt.tableLock.Unlock()
-	podEntries := pt.getDataForPodIP(podIP)
+	podEntries := pt.getDataForPod(podKey)
 	for _, podEntry := range podEntries {
 		protocolSocketData := podEntry.Protocol
-		if err := pt.PodPortRules.DeleteRule(podEntry.NodePort, podIP, podEntry.PodPort, protocolSocketData.Protocol); err != nil {
+		if err := pt.PodPortRules.DeleteRule(podEntry.NodePort, podEntry.PodIP, podEntry.PodPort, protocolSocketData.Protocol); err != nil {
 			return err
 		}
 		pt.deletePortTableCache(podEntry)
diff --git a/pkg/agent/nodeportlocal/rules/types.go b/pkg/agent/nodeportlocal/rules/types.go
index 29768957d..c242a71c4 100644
--- a/pkg/agent/nodeportlocal/rules/types.go
+++ b/pkg/agent/nodeportlocal/rules/types.go
@@ -14,8 +14,10 @@
 
 package rules
 
-// PodNodePort contains the Node Port, Pod IP, Pod Port and Protocols for NodePortLocal.
+// PodNodePort contains the Pod namespaced name, Node Port, Pod IP, Pod Port and Protocol for NodePortLocal.
 type PodNodePort struct {
+	// PodKey is the namespaced name of the Pod.
+	PodKey   string
 	NodePort int
 	PodPort  int
 	PodIP    string
diff --git a/pkg/agent/proxy/metrics/metrics.go b/pkg/agent/proxy/metrics/metrics.go
index a14df1512..ea71dc05e 100644
--- a/pkg/agent/proxy/metrics/metrics.go
+++ b/pkg/agent/proxy/metrics/metrics.go
@@ -37,7 +37,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v4"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "sync_proxy_rules_duration_seconds",
-			Help:           "SyncProxyRules duration of AntreaProxy in seconds",
+			Help:           "SyncProxyRules duration of Antrea Proxy in seconds",
 		},
 	)
 	ServicesInstalledTotal = kmetrics.NewGauge(
@@ -47,7 +47,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v4"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_services_installed",
-			Help:           "The number of Services installed by AntreaProxy",
+			Help:           "The number of Services installed by Antrea Proxy",
 		},
 	)
 	EndpointsInstalledTotal = kmetrics.NewGauge(
@@ -57,7 +57,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v4"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_endpoints_installed",
-			Help:           "The number of Endpoints installed by AntreaProxy",
+			Help:           "The number of Endpoints installed by Antrea Proxy",
 		},
 	)
 	ServicesUpdatesTotal = kmetrics.NewCounter(
@@ -67,7 +67,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v4"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_services_updates",
-			Help:           "The cumulative number of Service updates received by AntreaProxy",
+			Help:           "The cumulative number of Service updates received by Antrea Proxy",
 		},
 	)
 	EndpointsUpdatesTotal = kmetrics.NewCounter(
@@ -77,7 +77,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v4"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_endpoints_updates",
-			Help:           "The cumulative number of Endpoint updates received by AntreaProxy",
+			Help:           "The cumulative number of Endpoint updates received by Antrea Proxy",
 		},
 	)
 
@@ -88,7 +88,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v6"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "sync_proxy_rules_duration_seconds",
-			Help:           "SyncProxyRules duration of AntreaProxy in seconds",
+			Help:           "SyncProxyRules duration of Antrea Proxy in seconds",
 		},
 	)
 	ServicesInstalledTotalV6 = kmetrics.NewGauge(
@@ -98,7 +98,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v6"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_services_installed",
-			Help:           "The number of Services installed by AntreaProxy",
+			Help:           "The number of Services installed by Antrea Proxy",
 		},
 	)
 	EndpointsInstalledTotalV6 = kmetrics.NewGauge(
@@ -108,7 +108,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v6"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_endpoints_installed",
-			Help:           "The number of Endpoints installed by AntreaProxy",
+			Help:           "The number of Endpoints installed by Antrea Proxy",
 		},
 	)
 	ServicesUpdatesTotalV6 = kmetrics.NewCounter(
@@ -118,7 +118,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v6"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_services_updates",
-			Help:           "The cumulative number of Service updates received by AntreaProxy",
+			Help:           "The cumulative number of Service updates received by Antrea Proxy",
 		},
 	)
 	EndpointsUpdatesTotalV6 = kmetrics.NewCounter(
@@ -128,7 +128,7 @@ var (
 			ConstLabels:    map[string]string{"ip_family": "v6"},
 			StabilityLevel: kmetrics.ALPHA,
 			Name:           "total_endpoints_updates",
-			Help:           "The cumulative number of Endpoint updates received by AntreaProxy",
+			Help:           "The cumulative number of Endpoint updates received by Antrea Proxy",
 		},
 	)
 )


Test patch:
diff --git a/ci/kind/test-e2e-kind.sh b/ci/kind/test-e2e-kind.sh
index 6ae15def1..1e91c95c8 100755
--- a/ci/kind/test-e2e-kind.sh
+++ b/ci/kind/test-e2e-kind.sh
@@ -24,20 +24,21 @@ function echoerr {
 
 _usage="Usage: $0 [--encap-mode <mode>] [--ip-family <v4|v6|dual>] [--coverage] [--help|-h]
         --encap-mode                  Traffic encapsulation mode. (default is 'encap').
-        --ip-family                   Configures the ipFamily for the KinD cluster.
+        --ip-family                   Configure the ipFamily for the KinD cluster.
         --feature-gates               A comma-separated list of key=value pairs that describe feature gates, e.g. AntreaProxy=true,Egress=false.
         --run                         Run only tests matching the regexp.
-        --proxy-all                   Enables Antrea proxy with all Service support.
+        --proxy-all                   Enable Antrea proxy with all Service support.
         --no-kube-proxy               Don't deploy kube-proxy.
         --load-balancer-mode          LoadBalancer mode.
-        --node-ipam                   Enables Antrea NodeIPAM.
-        --multicast                   Enables Multicast.
+        --node-ipam                   Enable Antrea NodeIPAM.
+        --multicast                   Enable Multicast.
+        --bgp-policy                  Enable Antrea BGPPolicy.
         --flow-visibility             Only run flow visibility related e2e tests.
-        --networkpolicy-evaluation    Configures additional NetworkPolicy evaluation level when running e2e tests.
-        --extra-network               Creates an extra network that worker Nodes will connect to. Cannot be specified with the hybrid mode.
-        --extra-vlan                  Creates an subnet-based VLAN that worker Nodes will connect to.
+        --networkpolicy-evaluation    Configure additional NetworkPolicy evaluation level when running e2e tests.
+        --extra-network               Create an extra network that worker Nodes will connect to. Cannot be specified with the hybrid mode.
+        --extra-vlan                  Create an subnet-based VLAN that worker Nodes will connect to.
         --skip                        A comma-separated list of keywords, with which tests should be skipped.
-        --coverage                    Enables measure Antrea code coverage when running e2e tests on kind.
+        --coverage                    Enable measure Antrea code coverage when running e2e tests on kind.
         --setup-only                  Only perform setting up the cluster and run test.
         --cleanup-only                Only perform cleaning up the cluster.
         --test-only                   Only run test on current cluster. Not set up/clean up the cluster.
@@ -77,6 +78,7 @@ no_kube_proxy=false
 load_balancer_mode=""
 node_ipam=false
 multicast=false
+bgp_policy=false
 flow_visibility=false
 np_evaluation=false
 extra_network=false
@@ -124,6 +126,10 @@ case $key in
     multicast=true
     shift
     ;;
+    --bgp-policy)
+    bgp_policy=true
+    shift
+    ;;
     --ip-family)
     ipfamily="$2"
     shift 2
@@ -236,6 +242,9 @@ fi
 if $multicast; then
     manifest_args="$manifest_args --multicast"
 fi
+if $bgp_policy; then
+    manifest_args="$manifest_args --feature-gates BGPPolicy=true"
+fi
 if $flow_visibility; then
     manifest_args="$manifest_args --feature-gates FlowExporter=true,L7FlowExporter=true --extra-helm-values-file $FLOW_VISIBILITY_HELM_VALUES"
 fi
@@ -314,8 +323,12 @@ function setup_cluster {
   if $extra_network && [[ "$mode" != "hybrid" ]]; then
     args="$args --extra-networks \"20.20.30.0/24\""
   fi
-  # Deploy an external server which could be used when testing Pod-to-External traffic.
-  args="$args --deploy-external-server $vlan_args"
+  # Deploy an external agnhost which could be used when testing Pod-to-External traffic.
+  args="$args --deploy-external-agnhost $vlan_args"
+  # Deploy an external FRR which could be used when testing BGPPolicy.
+  if $bgp_policy; then
+    args="$args --deploy-external-frr"
+  fi
 
   echo "creating test bed with args $args"
   eval "timeout 600 $TESTBED_CMD create kind $args"
@@ -379,9 +392,15 @@ function run_test {
     np_evaluation_flag="--networkpolicy-evaluation"
   fi
 
-  external_server_cid=$(docker ps -f name="^antrea-external-server" --format '{{.ID}}')
-  external_server_ips=$(docker inspect $external_server_cid -f '{{.NetworkSettings.Networks.kind.IPAddress}},{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')
-  EXTRA_ARGS="$vlan_args --external-server-ips $external_server_ips"
+  external_agnhost_cid=$(docker ps -f name="^antrea-external-agnhost" --format '{{.ID}}')
+  external_agnhost_ips=$(docker inspect $external_agnhost_cid -f '{{.NetworkSettings.Networks.kind.IPAddress}},{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')
+  EXTRA_ARGS="$vlan_args --external-agnhost-ips $external_agnhost_ips"
+
+  if $bgp_policy; then
+    external_frr_cid=$(docker ps -f name="^antrea-external-frr" --format '{{.ID}}')
+    external_frr_ips=$(docker inspect $external_frr_cid -f '{{.NetworkSettings.Networks.kind.IPAddress}},{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')
+    EXTRA_ARGS="$EXTRA_ARGS --external-frr-cid $external_frr_cid --external-frr-ips $external_frr_ips"
+  fi
 
   go test -v -timeout=$timeout $RUN_OPT antrea.io/antrea/test/e2e $flow_visibility_args -provider=kind --logs-export-dir=$ANTREA_LOG_DIR $np_evaluation_flag --skip-cases=$skiplist $coverage_args $EXTRA_ARGS
 
diff --git a/pkg/agent/nodeportlocal/npl_agent_test.go b/pkg/agent/nodeportlocal/npl_agent_test.go
index bcdd90d92..e5bb5b123 100644
--- a/pkg/agent/nodeportlocal/npl_agent_test.go
+++ b/pkg/agent/nodeportlocal/npl_agent_test.go
@@ -52,6 +52,7 @@ const (
 	defaultPodName        = "test-pod"
 	defaultSvcName        = "test-svc"
 	defaultNS             = "default"
+	defaultPodKey         = defaultNS + "/" + defaultPodName
 	defaultNodeName       = "test-node"
 	defaultHostIP         = "10.10.10.10"
 	defaultPodIP          = "192.168.32.10"
@@ -69,7 +70,7 @@ func newPortTable(mockIPTables rules.PodPortRules, mockPortOpener portcache.Loca
 		PortTableCache: cache.NewIndexer(portcache.GetPortTableKey, cache.Indexers{
 			portcache.NodePortIndex:    portcache.NodePortIndexFunc,
 			portcache.PodEndpointIndex: portcache.PodEndpointIndexFunc,
-			portcache.PodIPIndex:       portcache.PodIPIndexFunc,
+			portcache.PodKeyIndex:      portcache.PodKeyIndexFunc,
 		}),
 		StartPort:       defaultStartPort,
 		EndPort:         defaultEndPort,
@@ -299,7 +300,7 @@ func setUpWithTestServiceAndPod(t *testing.T, tc *testConfig, customNodePort *in
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(&nodePort, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 
 	return testData, testSvc, testPod
 }
@@ -377,7 +378,7 @@ func TestSvcNamespaceUpdate(t *testing.T) {
 	// Check that annotation and the rule are removed.
 	_, err = testData.pollForPodAnnotation(testPodDefaultNS.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestSvcTypeUpdate updates Service type from ClusterIP to NodePort
@@ -393,7 +394,7 @@ func TestSvcTypeUpdate(t *testing.T) {
 	// Check that annotation and the rule are removed.
 	_, err := testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 
 	// Update Service type to ClusterIP.
 	testSvc.Spec.Type = corev1.ServiceTypeClusterIP
@@ -401,7 +402,7 @@ func TestSvcTypeUpdate(t *testing.T) {
 
 	_, err = testData.pollForPodAnnotation(testPod.Name, true)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestSvcUpdateAnnotation updates the Service spec to disabled NPL. It then verifies that the Pod's
@@ -417,7 +418,7 @@ func TestSvcUpdateAnnotation(t *testing.T) {
 	// Check that annotation and the rule is removed.
 	_, err := testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 
 	// Enable NPL back.
 	testSvc.Annotations = map[string]string{types.NPLEnabledAnnotationKey: "true"}
@@ -425,7 +426,7 @@ func TestSvcUpdateAnnotation(t *testing.T) {
 
 	_, err = testData.pollForPodAnnotation(testPod.Name, true)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestSvcRemoveAnnotation is the same as TestSvcUpdateAnnotation, but it deletes the NPL enabled
@@ -439,7 +440,7 @@ func TestSvcRemoveAnnotation(t *testing.T) {
 
 	_, err := testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestSvcUpdateSelector updates the Service selector so that it no longer selects the Pod, and
@@ -453,14 +454,14 @@ func TestSvcUpdateSelector(t *testing.T) {
 
 	_, err := testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 
 	testSvc.Spec.Selector = map[string]string{defaultAppSelectorKey: defaultAppSelectorVal}
 	testData.updateServiceOrFail(testSvc)
 
 	_, err = testData.pollForPodAnnotation(testPod.Name, true)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestPodUpdateSelectorLabel updates the Pod's labels so that the Pod is no longer selected by the
@@ -475,7 +476,7 @@ func TestPodUpdateSelectorLabel(t *testing.T) {
 
 	_, err := testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestSvcDelete deletes the Service. It then verifies that the Pod's NPL annotation is removed and
@@ -490,7 +491,7 @@ func TestSvcDelete(t *testing.T) {
 
 	_, err = testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestPodDelete verifies that when a Pod gets deleted, the corresponding entry gets deleted from
@@ -504,7 +505,7 @@ func TestPodDelete(t *testing.T) {
 	t.Logf("Successfully deleted Pod: %s", testPod.Name)
 
 	err = wait.PollUntilContextTimeout(context.Background(), time.Second, 20*time.Second, false, func(ctx context.Context) (bool, error) {
-		return !testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP), nil
+		return !testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP), nil
 	})
 	assert.NoError(t, err, "Error when polling for port table update")
 }
@@ -523,8 +524,8 @@ func TestAddMultiPortPodSvc(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, newPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, newPort, protocolTCP))
 
 	// Remove the second target port.
 	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
@@ -534,7 +535,7 @@ func TestAddMultiPortPodSvc(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations = newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, newPort, protocolTCP))
 }
 
 // TestPodAddMultiPort creates a Pod with multiple ports and a Service with only one target port.
@@ -560,8 +561,8 @@ func TestAddMultiPortPodSinglePortSvc(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, newPort2, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, newPort2, protocolTCP))
 }
 
 // TestPodAddHostPort creates a Pod with host ports and verifies that the Pod's NPL annotation
@@ -582,7 +583,7 @@ func TestPodAddHostPort(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(&hostPort, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestPodAddHostPort creates a Pod with multiple host ports having same value but different protocol.
@@ -607,7 +608,7 @@ func TestPodAddHostPortMultiProtocol(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(&hostPort, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestPodAddHostPortWrongProtocol creates a Pod with a host port but with protocol UDP instead of TCP.
@@ -627,7 +628,7 @@ func TestPodAddHostPortWrongProtocol(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestTargetPortWithName creates a Service with target port name in string.
@@ -646,13 +647,13 @@ func TestTargetPortWithName(t *testing.T) {
 
 	_, err := testData.pollForPodAnnotation(testPod.Name, true)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.True(t, testData.portTable.RuleExists(testPod.Status.PodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 
 	testSvc = getTestSvcWithPortName("wrongPort")
 	testData.updateServiceOrFail(testSvc)
 	_, err = testData.pollForPodAnnotation(testPod.Name, false)
 	require.NoError(t, err, "Poll for annotation check failed")
-	assert.False(t, testData.portTable.RuleExists(testPod.Status.PodIP, defaultPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 // TestMultiplePods creates multiple Pods and verifies that NPL annotations for both Pods are
@@ -661,10 +662,12 @@ func TestMultiplePods(t *testing.T) {
 	testSvc := getTestSvc()
 	testPod1 := getTestPod()
 	testPod1.Name = "pod1"
+	testPod1Key := testPod1.Namespace + "/" + testPod1.Name
 	testPod1.Status.PodIP = "192.168.32.1"
 	testPod2 := getTestPod()
 	testPod2.Name = "pod2"
 	testPod2.Status.PodIP = "192.168.32.2"
+	testPod2Key := testPod2.Namespace + "/" + testPod2.Name
 	testData := setUp(t, newTestConfig(), testSvc, testPod1, testPod2)
 	defer testData.tearDown()
 
@@ -672,14 +675,67 @@ func TestMultiplePods(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotationsPod1 := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotationsPod1.Check(t, pod1Value)
-	assert.True(t, testData.portTable.RuleExists(testPod1.Status.PodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(testPod1Key, defaultPort, protocolTCP))
 
 	pod2Value, err := testData.pollForPodAnnotation(testPod2.Name, true)
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotationsPod2 := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotationsPod2.Check(t, pod2Value)
 	assert.NotEqual(t, pod1Value[0].NodePort, pod2Value[0].NodePort)
-	assert.True(t, testData.portTable.RuleExists(testPod2.Status.PodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(testPod2Key, defaultPort, protocolTCP))
+}
+
+// TestPodIPRecycle creates two Pods that have the same IP to simulate Pod IP recycle case.
+// It verifies that NPL annotations and rules for a Pod is not affected by another Pod's lifecycle events.
+func TestPodIPRecycle(t *testing.T) {
+	ctx := context.Background()
+	testSvc := getTestSvc()
+	// pod1 and pod2 have the same IP, pod1 will run into terminated phase eventually.
+	testPod1 := getTestPod()
+	testPod1.Name = "pod1"
+	testPod1Key := testPod1.Namespace + "/" + testPod1.Name
+	testPod2 := getTestPod()
+	testPod2.Name = "pod2"
+	testPod2Key := testPod2.Namespace + "/" + testPod2.Name
+	testData := setUp(t, newTestConfig(), testSvc, testPod1, testPod2)
+	defer testData.tearDown()
+
+	pod1Value, err := testData.pollForPodAnnotation(testPod1.Name, true)
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotationsPod1 := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
+	expectedAnnotationsPod1.Check(t, pod1Value)
+	assert.True(t, testData.portTable.RuleExists(testPod1Key, defaultPort, protocolTCP))
+
+	pod2Value, err := testData.pollForPodAnnotation(testPod2.Name, true)
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotationsPod2 := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
+	expectedAnnotationsPod2.Check(t, pod2Value)
+	assert.NotEqual(t, pod1Value[0].NodePort, pod2Value[0].NodePort)
+	assert.True(t, testData.portTable.RuleExists(testPod2Key, defaultPort, protocolTCP))
+
+	// After pod1 runs into succeeded phase, its NPL rule and annotation should be removed, while pod2 shouldn't be affected.
+	updatedTestPod1 := testPod1.DeepCopy()
+	updatedTestPod1.Status.Phase = corev1.PodSucceeded
+	_, err = testData.k8sClient.CoreV1().Pods(updatedTestPod1.Namespace).UpdateStatus(ctx, updatedTestPod1, metav1.UpdateOptions{})
+	require.NoError(t, err)
+	_, err = testData.pollForPodAnnotation(testPod1.Name, false)
+	require.NoError(t, err, "Poll for annotation check failed")
+	assert.False(t, testData.portTable.RuleExists(testPod1Key, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(testPod2Key, defaultPort, protocolTCP))
+
+	// Deleting pod1 shouldn't delete pod2's NPL rule and annotation.
+	require.NoError(t, testData.k8sClient.CoreV1().Pods(testPod1.Namespace).Delete(ctx, testPod1.Name, metav1.DeleteOptions{}))
+	_, err = testData.pollForPodAnnotation(testPod2.Name, true)
+	require.NoError(t, err, "Poll for annotation check failed")
+	expectedAnnotationsPod2 = newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
+	expectedAnnotationsPod2.Check(t, pod2Value)
+	assert.True(t, testData.portTable.RuleExists(testPod2Key, defaultPort, protocolTCP))
+
+	// Deleting pod2 should delete pod2's NPL rule.
+	require.NoError(t, testData.k8sClient.CoreV1().Pods(testPod2.Namespace).Delete(ctx, testPod2.Name, metav1.DeleteOptions{}))
+	assert.Eventually(t, func() bool {
+		return !testData.portTable.RuleExists(testPod2Key, defaultPort, protocolTCP)
+	}, 5*time.Second, 50*time.Millisecond)
 }
 
 // TestMultipleProtocols creates multiple Pods with multiple protocols and verifies that
@@ -699,6 +755,7 @@ func TestMultipleProtocols(t *testing.T) {
 	testPod2.Name = "pod2"
 	testPod2.Status.PodIP = "192.168.32.2"
 	testPod2.Labels = udpSvcLabel
+	testPod2Key := testPod2.Namespace + "/" + testPod2.Name
 
 	// Create TCP/80 testSvc1 for pod1.
 	testSvc1 := getTestSvc()
@@ -726,7 +783,7 @@ func TestMultipleProtocols(t *testing.T) {
 	expectedAnnotationsPod2 := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolUDP)
 	expectedAnnotationsPod2.Check(t, pod2Value)
 	assert.NotEqual(t, pod1Value[0].NodePort, pod2Value[0].NodePort)
-	assert.True(t, testData.portTable.RuleExists(testPod2.Status.PodIP, defaultPort, protocolUDP))
+	assert.True(t, testData.portTable.RuleExists(testPod2Key, defaultPort, protocolUDP))
 
 	// Update testSvc2 to serve TCP/80 and UDP/81 both, so pod2 is
 	// exposed on both TCP and UDP, with different NodePorts.
@@ -767,8 +824,8 @@ func TestMultipleServicesSameBackendPod(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, 9090, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(testPod.Status.PodIP, defaultPort, protocolTCP))
-	assert.True(t, testData.portTable.RuleExists(testPod.Status.PodIP, 9090, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, 9090, protocolTCP))
 }
 
 // TestInitInvalidPod simulates an agent reboot case. A Pod with an invalid NPL annotation is
@@ -789,7 +846,7 @@ func TestInitInvalidPod(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(testPod.Status.PodIP, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
 }
 
 var (
@@ -854,8 +911,8 @@ func TestSingleRuleDeletionError(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, newPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, newPort, protocolTCP))
 
 	// Remove the second target port, to force one mapping to be deleted.
 	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
@@ -866,7 +923,7 @@ func TestSingleRuleDeletionError(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations = newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.False(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+	assert.False(t, testData.portTable.RuleExists(defaultPodKey, newPort, protocolTCP))
 }
 
 func TestPreventDefunctRuleReuse(t *testing.T) {
@@ -919,8 +976,8 @@ func TestPreventDefunctRuleReuse(t *testing.T) {
 	require.NoError(t, err, "Poll for annotation check failed")
 	expectedAnnotations := newExpectedNPLAnnotations().Add(nil, defaultPort, protocolTCP).Add(nil, newPort, protocolTCP)
 	expectedAnnotations.Check(t, value)
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, defaultPort, protocolTCP))
-	assert.True(t, testData.portTable.RuleExists(defaultPodIP, newPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, defaultPort, protocolTCP))
+	assert.True(t, testData.portTable.RuleExists(defaultPodKey, newPort, protocolTCP))
 
 	// Remove the second target port, to force one mapping to be deleted.
 	testSvc.Spec.Ports = testSvc.Spec.Ports[:1]
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_others_test.go b/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
index c5e1e4245..842f21187 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_others_test.go
@@ -99,6 +99,7 @@ func TestDeleteRule(t *testing.T) {
 	closer := &mockCloser{}
 
 	data := &NodePortData{
+		PodKey:   podKey,
 		NodePort: nodePort1,
 		PodPort:  podPort,
 		PodIP:    podIP,
@@ -112,20 +113,20 @@ func TestDeleteRule(t *testing.T) {
 	assert.False(t, data.Defunct())
 
 	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol).Return(fmt.Errorf("iptables error"))
-	require.ErrorContains(t, portTable.DeleteRule(podIP, podPort, protocol), "iptables error")
+	require.ErrorContains(t, portTable.DeleteRule(podKey, podPort, protocol), "iptables error")
 
 	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol)
 	closer.closeErr = fmt.Errorf("close error")
-	require.ErrorContains(t, portTable.DeleteRule(podIP, podPort, protocol), "close error")
+	require.ErrorContains(t, portTable.DeleteRule(podKey, podPort, protocol), "close error")
 	assert.True(t, data.Defunct())
 
 	closer.closeErr = nil
 
 	// First successful call to DeleteRule.
 	mockIPTables.EXPECT().DeleteRule(nodePort1, podIP, podPort, protocol)
-	assert.NoError(t, portTable.DeleteRule(podIP, podPort, protocol))
+	assert.NoError(t, portTable.DeleteRule(podKey, podPort, protocol))
 
 	// Calling DeleteRule again will return immediately as the NodePortData entry has been
 	// removed from the cache.
-	assert.NoError(t, portTable.DeleteRule(podIP, podPort, protocol))
+	assert.NoError(t, portTable.DeleteRule(podKey, podPort, protocol))
 }
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_test.go b/pkg/agent/nodeportlocal/portcache/port_table_test.go
index 7a1fe3b86..b7b93084b 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_test.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_test.go
@@ -24,6 +24,7 @@ const (
 	startPort = 61000
 	endPort   = 65000
 	podIP     = "10.0.0.1"
+	podKey    = "default/test-pod"
 	nodePort1 = startPort
 	nodePort2 = startPort + 1
 )
@@ -33,7 +34,7 @@ func newPortTable(mockIPTables rules.PodPortRules, mockPortOpener LocalPortOpene
 		PortTableCache: cache.NewIndexer(GetPortTableKey, cache.Indexers{
 			NodePortIndex:    NodePortIndexFunc,
 			PodEndpointIndex: PodEndpointIndexFunc,
-			PodIPIndex:       PodIPIndexFunc,
+			PodKeyIndex:      PodKeyIndexFunc,
 		}),
 		StartPort:       startPort,
 		EndPort:         endPort,
diff --git a/pkg/agent/nodeportlocal/portcache/port_table_windows_test.go b/pkg/agent/nodeportlocal/portcache/port_table_windows_test.go
index 18b3022b3..43855473e 100644
--- a/pkg/agent/nodeportlocal/portcache/port_table_windows_test.go
+++ b/pkg/agent/nodeportlocal/portcache/port_table_windows_test.go
@@ -36,18 +36,21 @@ func TestRestoreRules(t *testing.T) {
 	portTable := newPortTable(mockPortRules, mockPortOpener)
 	allNPLPorts := []rules.PodNodePort{
 		{
+			PodKey:   podKey,
 			NodePort: nodePort1,
 			PodPort:  1001,
 			PodIP:    podIP,
 			Protocol: "tcp",
 		},
 		{
+			PodKey:   podKey,
 			NodePort: nodePort1,
 			PodPort:  1001,
 			PodIP:    podIP,
 			Protocol: "udp",
 		},
 		{
+			PodKey:   podKey,
 			NodePort: nodePort2,
 			PodPort:  1002,
 			PodIP:    podIP,
@@ -70,6 +73,7 @@ func TestDeleteRule(t *testing.T) {
 	mockPortOpener := portcachetesting.NewMockLocalPortOpener(mockCtrl)
 	portTable := newPortTable(mockPortRules, mockPortOpener)
 	npData := &NodePortData{
+		PodKey:   podKey,
 		NodePort: startPort,
 		PodIP:    podIP,
 		PodPort:  1001,
@@ -80,7 +84,7 @@ func TestDeleteRule(t *testing.T) {
 
 	portTable.addPortTableCache(npData)
 	mockPortRules.EXPECT().DeleteRule(startPort, podIP, 1001, "tcp")
-	err := portTable.DeleteRule(podIP, 1001, "tcp")
+	err := portTable.DeleteRule(podKey, 1001, "tcp")
 	require.NoError(t, err)
 }
 
@@ -92,6 +96,7 @@ func TestDeleteRulesForPod(t *testing.T) {
 
 	npData := []*NodePortData{
 		{
+			PodKey:   podKey,
 			NodePort: startPort,
 			PodIP:    podIP,
 			PodPort:  1001,
@@ -100,6 +105,7 @@ func TestDeleteRulesForPod(t *testing.T) {
 			},
 		},
 		{
+			PodKey:   podKey,
 			NodePort: startPort + 1,
 			PodIP:    podIP,
 			PodPort:  1002,
@@ -114,7 +120,7 @@ func TestDeleteRulesForPod(t *testing.T) {
 		mockPortRules.EXPECT().DeleteRule(data.NodePort, podIP, data.PodPort, data.Protocol.Protocol)
 	}
 
-	err := portTable.DeleteRulesForPod(podIP)
+	err := portTable.DeleteRulesForPod(podKey)
 	require.NoError(t, err)
 }
 
@@ -127,11 +133,11 @@ func TestAddRule(t *testing.T) {
 
 	// Adding the rule the first time should succeed.
 	mockPortRules.EXPECT().AddRule(startPort, podIP, podPort, "udp")
-	gotNodePort, err := portTable.AddRule(podIP, podPort, "udp")
+	gotNodePort, err := portTable.AddRule(podKey, podPort, "udp", podIP)
 	require.NoError(t, err)
 	assert.Equal(t, startPort, gotNodePort)
 
 	// Add the same rule the second time should fail.
-	_, err = portTable.AddRule(podIP, podPort, "udp")
+	_, err = portTable.AddRule(podKey, podPort, "udp", podIP)
 	assert.ErrorContains(t, err, "existing Windows Nodeport entry for")
 }
diff --git a/test/e2e/fixtures.go b/test/e2e/fixtures.go
index 55297af4d..b8aa86746 100644
--- a/test/e2e/fixtures.go
+++ b/test/e2e/fixtures.go
@@ -685,7 +685,9 @@ func testMain(m *testing.M) int {
 	flag.StringVar(&testOptions.skipCases, "skip-cases", "", "Key words to skip cases")
 	flag.StringVar(&testOptions.linuxVMs, "linuxVMs", "", "hostname of Linux VMs")
 	flag.StringVar(&testOptions.windowsVMs, "windowsVMs", "", "hostname of Windows VMs")
-	flag.StringVar(&testOptions.externalServerIPs, "external-server-ips", "", "IP addresses of external server, at most one IP per IP family")
+	flag.StringVar(&testOptions.externalAgnhostIPs, "external-agnhost-ips", "", "IP addresses of external agnhost, at most one IP per IP family")
+	flag.StringVar(&testOptions.externalFRRIPs, "external-frr-ips", "", "IP addresses of external FRR, at most one IP per IP family")
+	flag.StringVar(&testOptions.externalFRRCID, "external-frr-cid", "", "Container ID of external FRR")
 	flag.StringVar(&testOptions.vlanSubnets, "vlan-subnets", "", "IP subnets of the VLAN network the Nodes reside in, at most one subnet per IP family")
 	flag.IntVar(&testOptions.vlanID, "vlan-id", 0, "ID of the VLAN network the Nodes reside in")
 	flag.Parse()
diff --git a/test/e2e/framework.go b/test/e2e/framework.go
index e752434be..39d43adcb 100644
--- a/test/e2e/framework.go
+++ b/test/e2e/framework.go
@@ -190,6 +190,10 @@ type ExternalInfo struct {
 	vlanSubnetIPv6  string
 	vlanGatewayIPv6 string
 	vlanID          int
+
+	externalFRRIPv4 string
+	externalFRRIPv6 string
+	externalFRRCID  string
 }
 
 var clusterInfo ClusterInfo
@@ -213,9 +217,16 @@ type TestOptions struct {
 	// the home directory of the control-plane Node. Note it doesn't affect the tests that redeploy Antrea themselves.
 	deployAntrea bool
 
-	externalServerIPs string
-	vlanSubnets       string
-	vlanID            int
+	externalAgnhostIPs string
+	vlanSubnets        string
+	vlanID             int
+
+	externalFRRIPs string
+	// FRR cannot currently be configured remotely over networking. As a result, the e2e tests for BGPPolicy can only
+	// be run in a Kind cluster, where the FRR container can be configured using Docker exec with the container ID.
+	// TODO: Introduce a BGP router implementation that can be configured remotely over networking to replace FRR.
+	// This would allow the e2e tests for BGPPolicy to be run in environments other than just a Kind cluster.
+	externalFRRCID string
 }
 
 type flowVisibilityTestOptions struct {
@@ -498,14 +509,14 @@ func (data *TestData) RunCommandOnNodeExt(nodeName, cmd string, envs map[string]
 }
 
 func (data *TestData) collectExternalInfo() error {
-	ips := strings.Split(testOptions.externalServerIPs, ",")
+	ips := strings.Split(testOptions.externalAgnhostIPs, ",")
 	for _, ip := range ips {
 		if ip == "" {
 			continue
 		}
 		parsedIP := net.ParseIP(ip)
 		if parsedIP == nil {
-			return fmt.Errorf("invalid external server IP %s", ip)
+			return fmt.Errorf("invalid external agnhost IP %s", ip)
 		}
 		if parsedIP.To4() != nil {
 			externalInfo.externalServerIPv4 = ip
@@ -532,6 +543,25 @@ func (data *TestData) collectExternalInfo() error {
 		}
 	}
 	externalInfo.vlanID = testOptions.vlanID
+
+	frrIPs := strings.Split(testOptions.externalFRRIPs, ",")
+	for _, ip := range frrIPs {
+		if ip == "" {
+			continue
+		}
+		parsedIP := net.ParseIP(ip)
+		if parsedIP == nil {
+			return fmt.Errorf("invalid external FRR IP %s", ip)
+		}
+		if parsedIP.To4() != nil {
+			externalInfo.externalFRRIPv4 = ip
+		} else {
+			externalInfo.externalFRRIPv6 = ip
+		}
+	}
+
+	externalInfo.externalFRRCID = testOptions.externalFRRCID
+
 	return nil
 }