Repository: antrea-io/antrea
Pull request #3042 (2021-11-19 09:25:02)

[flexible-ipam] Support Pod IPPool/IP annotation and StatefulSet
Add support for Pod annotation "ipam.antrea.io/ippools" and
"ipam.antrea.io/pod-ips", which allows user to specify IPPool and IP for a
specified Pod.

Support persistent IP for StatefulSet. A StatefulSet Pod will keep its
allocated IP during restart process.

Signed-off-by: gran <gran@vmware.com>

gran-vmv: /test-flexible-ipam-e2e
codecov-commenter: # [Codecov](https://codecov.io/gh/antrea-io/antrea/pull/3042?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#3042](https://codecov.io/gh/antrea-io/antrea/pull/3042?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (2b36fa3) into [main](https://codecov.io/gh/antrea-io/antrea/commit/e0e2e07d91cf89055960d3d8f226b703554ded8b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (e0e2e07) will **decrease** coverage by `13.06%`.
> The diff coverage is `0.00%`.

[![Impacted file tree graph](https://codecov.io/gh/antrea-io/antrea/pull/3042/graphs/tree.svg?width=650&height=150&src=pr&token=TjWaLL2GoC&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)](https://codecov.io/gh/antrea-io/antrea/pull/3042?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@             Coverage Diff             @@
##             main    #3042       +/-   ##
===========================================
- Coverage   59.61%   46.55%   -13.07%     
===========================================
  Files         292      286        -6     
  Lines       24709    23887      -822     
===========================================
- Hits        14731    11121     -3610     
- Misses       8356    11337     +2981     
+ Partials     1622     1429      -193     
```

| Flag | Coverage Δ | |
|---|---|---|
| kind-e2e-tests | `46.55% <0.00%> (-0.15%)` | :arrow_down: |
| unit-tests | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/antrea-io/antrea/pull/3042?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [pkg/agent/cniserver/ipam/antrea\_ipam.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2NuaXNlcnZlci9pcGFtL2FudHJlYV9pcGFtLmdv) | `1.06% <0.00%> (-75.41%)` | :arrow_down: |
| [pkg/agent/cniserver/ipam/antrea\_ipam\_controller.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2NuaXNlcnZlci9pcGFtL2FudHJlYV9pcGFtX2NvbnRyb2xsZXIuZ28=) | `0.00% <0.00%> (-65.39%)` | :arrow_down: |
| [pkg/agent/controller/egress/id\_allocator.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2NvbnRyb2xsZXIvZWdyZXNzL2lkX2FsbG9jYXRvci5nbw==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/controller/networkpolicy/endpoint\_querier.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2NvbnRyb2xsZXIvbmV0d29ya3BvbGljeS9lbmRwb2ludF9xdWVyaWVyLmdv) | `2.85% <0.00%> (-88.58%)` | :arrow_down: |
| [pkg/ipam/ipallocator/allocator.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2lwYW0vaXBhbGxvY2F0b3IvYWxsb2NhdG9yLmdv) | `0.00% <0.00%> (-84.22%)` | :arrow_down: |
| [pkg/controller/egress/controller.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2NvbnRyb2xsZXIvZWdyZXNzL2NvbnRyb2xsZXIuZ28=) | `0.00% <0.00%> (-83.83%)` | :arrow_down: |
| [...g/agent/apiserver/handlers/featuregates/handler.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2FwaXNlcnZlci9oYW5kbGVycy9mZWF0dXJlZ2F0ZXMvaGFuZGxlci5nbw==) | `0.00% <0.00%> (-82.36%)` | :arrow_down: |
| [pkg/agent/util/iptables/lock.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L3V0aWwvaXB0YWJsZXMvbG9jay5nbw==) | `0.00% <0.00%> (-81.82%)` | :arrow_down: |
| [pkg/controller/ipam/validate.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2NvbnRyb2xsZXIvaXBhbS92YWxpZGF0ZS5nbw==) | `0.00% <0.00%> (-80.00%)` | :arrow_down: |
| [pkg/controller/networkpolicy/crd\_utils.go](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2NvbnRyb2xsZXIvbmV0d29ya3BvbGljeS9jcmRfdXRpbHMuZ28=) | `9.57% <0.00%> (-77.66%)` | :arrow_down: |
| ... and [109 more](https://codecov.io/gh/antrea-io/antrea/pull/3042/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

gran-vmv: /test-flexible-ipam-e2e
annakhm: LGTM
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
gran-vmv: /test-all
/test-flexible-ipam-e2e
gran-vmv: /test-integration
gran-vmv: /test-all
/test-flexible-ipam-e2e
/test-ipv6-all
/test-ipv6-only-all
gran-vmv: /test-e2e
gran-vmv: /test-ipv6-only-all
gran-vmv: /test-ipv6-only-all
/test-windows-all
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-integration
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
tnqn: Please add more details about what the patch does, e.g. what annotations it adds, how they are supposed to be used.
gran-vmv: > Please add more details about what the patch does to the commit message and PR description, e.g. what annotations it adds, how they are supposed to be used.

Added commit message and PR description.
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all-features-conformance
/test-conformance
/test-ipv6-only-e2e
/test-ipv6-only-networkpolicy
/test-multicluster-e2e
/test-networkpolicy
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e
gran-vmv: /test-all
/test-windows-all
/test-ipv6-all
/test-ipv6-only-all
/test-flexible-ipam-e2e

Patch:
diff --git a/build/yamls/antrea-aks.yml b/build/yamls/antrea-aks.yml
index a30942c96..3635ced04 100644
--- a/build/yamls/antrea-aks.yml
+++ b/build/yamls/antrea-aks.yml
@@ -4171,6 +4171,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4283,7 +4290,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-7f6684k7bk
+  name: antrea-config-mtdf878cgd
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4354,7 +4361,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-7f6684k7bk
+          value: antrea-config-mtdf878cgd
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4405,7 +4412,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-7f6684k7bk
+          name: antrea-config-mtdf878cgd
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4686,7 +4693,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-7f6684k7bk
+          name: antrea-config-mtdf878cgd
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-eks.yml b/build/yamls/antrea-eks.yml
index 6206ec43c..2b55aa736 100644
--- a/build/yamls/antrea-eks.yml
+++ b/build/yamls/antrea-eks.yml
@@ -4171,6 +4171,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4283,7 +4290,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-7f6684k7bk
+  name: antrea-config-mtdf878cgd
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4354,7 +4361,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-7f6684k7bk
+          value: antrea-config-mtdf878cgd
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4405,7 +4412,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-7f6684k7bk
+          name: antrea-config-mtdf878cgd
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4688,7 +4695,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-7f6684k7bk
+          name: antrea-config-mtdf878cgd
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-gke.yml b/build/yamls/antrea-gke.yml
index f95e21b54..5e479f8cf 100644
--- a/build/yamls/antrea-gke.yml
+++ b/build/yamls/antrea-gke.yml
@@ -4171,6 +4171,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4283,7 +4290,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-25dm52hdd6
+  name: antrea-config-f42d6k25t4
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4354,7 +4361,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-25dm52hdd6
+          value: antrea-config-f42d6k25t4
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4405,7 +4412,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-25dm52hdd6
+          name: antrea-config-f42d6k25t4
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4689,7 +4696,7 @@ spec:
           path: /home/kubernetes/bin
         name: host-cni-bin
       - configMap:
-          name: antrea-config-25dm52hdd6
+          name: antrea-config-f42d6k25t4
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml
index d4749a9d7..9d6f596f4 100644
--- a/build/yamls/antrea-ipsec.yml
+++ b/build/yamls/antrea-ipsec.yml
@@ -4176,6 +4176,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4288,7 +4295,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-f6c667c75f
+  name: antrea-config-24g5gkf4m9
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4368,7 +4375,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-f6c667c75f
+          value: antrea-config-24g5gkf4m9
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4419,7 +4426,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-f6c667c75f
+          name: antrea-config-24g5gkf4m9
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4735,7 +4742,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-f6c667c75f
+          name: antrea-config-24g5gkf4m9
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-kind.yml b/build/yamls/antrea-kind.yml
index 0367d860d..ebb410a90 100644
--- a/build/yamls/antrea-kind.yml
+++ b/build/yamls/antrea-kind.yml
@@ -4176,6 +4176,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4288,7 +4295,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-mcf8bf8b2m
+  name: antrea-config-h5hctb5f9b
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4359,7 +4366,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-mcf8bf8b2m
+          value: antrea-config-h5hctb5f9b
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4410,7 +4417,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-mcf8bf8b2m
+          name: antrea-config-h5hctb5f9b
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4687,7 +4694,7 @@ spec:
           type: CharDevice
         name: dev-tun
       - configMap:
-          name: antrea-config-mcf8bf8b2m
+          name: antrea-config-h5hctb5f9b
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml
index 7c74bdb43..f55c05e2a 100644
--- a/build/yamls/antrea.yml
+++ b/build/yamls/antrea.yml
@@ -4176,6 +4176,13 @@ data:
       # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
       # with Namespace (e.g. kube-system/kube-dns)
       #skipServices: []
+      # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+      # 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
+      # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+      #proxyLoadBalancerIPs: true
   antrea-cni.conflist: |
     {
         "cniVersion":"0.3.0",
@@ -4288,7 +4295,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-m8g64b5282
+  name: antrea-config-h9276948gc
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4359,7 +4366,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-m8g64b5282
+          value: antrea-config-h9276948gc
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4410,7 +4417,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-m8g64b5282
+          name: antrea-config-h9276948gc
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4691,7 +4698,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-m8g64b5282
+          name: antrea-config-h9276948gc
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/base/conf/antrea-agent.conf b/build/yamls/base/conf/antrea-agent.conf
index 6e3123ba4..ac373ce68 100644
--- a/build/yamls/base/conf/antrea-agent.conf
+++ b/build/yamls/base/conf/antrea-agent.conf
@@ -211,3 +211,10 @@ antreaProxy:
   # Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
   # with Namespace (e.g. kube-system/kube-dns)
   #skipServices: []
+  # When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+  # 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
+  # kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+  #proxyLoadBalancerIPs: true
diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go
index 0b2cee575..2686a2683 100644
--- a/cmd/antrea-agent/agent.go
+++ b/cmd/antrea-agent/agent.go
@@ -19,8 +19,12 @@ import (
 	"net"
 	"time"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/informers"
+	coreinformers "k8s.io/client-go/informers/core/v1"
+	"k8s.io/client-go/tools/cache"
 	"k8s.io/klog/v2"
 
 	"antrea.io/antrea/pkg/agent"
@@ -65,6 +69,10 @@ import (
 // https://github.com/kubernetes/kubernetes/blob/release-1.17/pkg/controller/apis/config/v1alpha1/defaults.go#L120
 const informerDefaultResync = 12 * time.Hour
 
+// resyncPeriodDisabled is 0 to disable resyncing.
+// UpdateFunc event handler will be called only when the object is actually updated.
+const resyncPeriodDisabled = 0 * time.Minute
+
 // The devices that should be excluded from NodePort.
 var excludeNodePortDevices = []string{"antrea-egress0", "kube-ipvs0"}
 
@@ -230,16 +238,17 @@ func run(o *Options) error {
 	if features.DefaultFeatureGate.Enabled(features.AntreaProxy) {
 		proxyAll := o.config.AntreaProxy.ProxyAll
 		skipServices := o.config.AntreaProxy.SkipServices
+		proxyLoadBalancerIPs := *o.config.AntreaProxy.ProxyLoadBalancerIPs
 
 		switch {
 		case v4Enabled && v6Enabled:
-			proxier = proxy.NewDualStackProxier(nodeConfig.Name, informerFactory, ofClient, routeClient, nodePortAddressesIPv4, nodePortAddressesIPv6, proxyAll, skipServices, v4GroupCounter, v6GroupCounter)
+			proxier = proxy.NewDualStackProxier(nodeConfig.Name, informerFactory, ofClient, routeClient, nodePortAddressesIPv4, nodePortAddressesIPv6, proxyAll, skipServices, proxyLoadBalancerIPs, v4GroupCounter, v6GroupCounter)
 			groupCounters = append(groupCounters, v4GroupCounter, v6GroupCounter)
 		case v4Enabled:
-			proxier = proxy.NewProxier(nodeConfig.Name, informerFactory, ofClient, false, routeClient, nodePortAddressesIPv4, proxyAll, skipServices, v4GroupCounter)
+			proxier = proxy.NewProxier(nodeConfig.Name, informerFactory, ofClient, false, routeClient, nodePortAddressesIPv4, proxyAll, skipServices, proxyLoadBalancerIPs, v4GroupCounter)
 			groupCounters = append(groupCounters, v4GroupCounter)
 		case v6Enabled:
-			proxier = proxy.NewProxier(nodeConfig.Name, informerFactory, ofClient, true, routeClient, nodePortAddressesIPv6, proxyAll, skipServices, v6GroupCounter)
+			proxier = proxy.NewProxier(nodeConfig.Name, informerFactory, ofClient, true, routeClient, nodePortAddressesIPv6, proxyAll, skipServices, proxyLoadBalancerIPs, v6GroupCounter)
 			groupCounters = append(groupCounters, v6GroupCounter)
 		default:
 			return fmt.Errorf("at least one of IPv4 or IPv6 should be enabled")
@@ -404,32 +413,51 @@ func run(o *Options) error {
 		networkPolicyController.SetDenyConnStore(flowExporter.GetDenyConnStore())
 	}
 
-	// Start the NPL agent.
+	// Initialize localPodInformer for NPLAgent and AntreaIPAMController
+	var localPodInformer cache.SharedIndexInformer
+	if features.DefaultFeatureGate.Enabled(features.NodePortLocal) && o.config.NodePortLocal.Enable || features.DefaultFeatureGate.Enabled(features.AntreaIPAM) {
+		listOptions := func(options *metav1.ListOptions) {
+			options.FieldSelector = fields.OneTermEqualSelector("spec.nodeName", nodeConfig.Name).String()
+		}
+		localPodInformer = coreinformers.NewFilteredPodInformer(
+			k8sClient,
+			metav1.NamespaceAll,
+			resyncPeriodDisabled,
+			cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, // NamespaceIndex is used in NPLController.
+			listOptions,
+		)
+	}
+	// Initialize the NPL agent.
 	if features.DefaultFeatureGate.Enabled(features.NodePortLocal) && o.config.NodePortLocal.Enable {
 		nplController, err := npl.InitializeNPLAgent(
 			k8sClient,
 			informerFactory,
 			o.nplStartPort,
 			o.nplEndPort,
-			nodeConfig.Name)
+			nodeConfig.Name,
+			localPodInformer)
 		if err != nil {
 			return fmt.Errorf("failed to start NPL agent: %v", err)
 		}
 		go nplController.Run(stopCh)
 	}
-
-	// Start the Antrea IPAM agent.
+	// Initialize the Antrea IPAM agent.
 	if features.DefaultFeatureGate.Enabled(features.AntreaIPAM) {
 		ipamController, err := ipam.InitializeAntreaIPAMController(
 			k8sClient,
 			crdClient,
 			informerFactory,
+			localPodInformer,
 			crdInformerFactory)
 		if err != nil {
 			return fmt.Errorf("failed to start Antrea IPAM agent: %v", err)
 		}
 		go ipamController.Run(stopCh)
 	}
+	//  Start the localPodInformer
+	if localPodInformer != nil {
+		go localPodInformer.Run(stopCh)
+	}
 
 	log.StartLogFileNumberMonitor(stopCh)
 
diff --git a/cmd/antrea-agent/options.go b/cmd/antrea-agent/options.go
index 01b59da11..31b351671 100644
--- a/cmd/antrea-agent/options.go
+++ b/cmd/antrea-agent/options.go
@@ -219,7 +219,12 @@ func (o *Options) setDefaults() {
 	if o.config.HostProcPathPrefix == "" {
 		o.config.HostProcPathPrefix = defaultHostProcPathPrefix
 	}
-	if !features.DefaultFeatureGate.Enabled(features.AntreaProxy) {
+	if features.DefaultFeatureGate.Enabled(features.AntreaProxy) {
+		if o.config.AntreaProxy.ProxyLoadBalancerIPs == nil {
+			o.config.AntreaProxy.ProxyLoadBalancerIPs = new(bool)
+			*o.config.AntreaProxy.ProxyLoadBalancerIPs = true
+		}
+	} else {
 		if o.config.ServiceCIDR == "" {
 			o.config.ServiceCIDR = defaultServiceCIDR
 		}
diff --git a/pkg/agent/cniserver/ipam/annotations.go b/pkg/agent/cniserver/ipam/annotations.go
index abd07e72f..6cd87772f 100644
--- a/pkg/agent/cniserver/ipam/annotations.go
+++ b/pkg/agent/cniserver/ipam/annotations.go
@@ -15,6 +15,9 @@
 package ipam
 
 const (
-	AntreaIPAMAnnotationKey       = "ipam.antrea.io/ippools"
+	// AntreaIPAMAnnotationKey annotation can be added to Namespace and PodTemplate of StatefulSet/Deployment
+	AntreaIPAMAnnotationKey = "ipam.antrea.io/ippools"
+	// AntreaIPAMPodIPAnnotationKey annotation can be added to Pod
+	AntreaIPAMPodIPAnnotationKey  = "ipam.antrea.io/pod-ips"
 	AntreaIPAMAnnotationDelimiter = ","
 )
diff --git a/pkg/agent/cniserver/ipam/antrea_ipam.go b/pkg/agent/cniserver/ipam/antrea_ipam.go
index 918f5b465..eee3f7c09 100644
--- a/pkg/agent/cniserver/ipam/antrea_ipam.go
+++ b/pkg/agent/cniserver/ipam/antrea_ipam.go
@@ -48,13 +48,19 @@ var antreaIPAMDriver *AntreaIPAM
 // Resource needs to be unique since it is used as identifier in Del.
 // Therefore Container ID is used, while Pod/Namespace are shown for visibility.
 // TODO: Consider multi-interface case
-func getAllocationOwner(args *invoke.Args, k8sArgs *argtypes.K8sArgs) crdv1a2.IPAddressOwner {
+func getAllocationOwner(args *invoke.Args, k8sArgs *argtypes.K8sArgs, reservedOwner *crdv1a2.IPAddressOwner) crdv1a2.IPAddressOwner {
+	podOwner := &crdv1a2.PodOwner{
+		Name:        string(k8sArgs.K8S_POD_NAME),
+		Namespace:   string(k8sArgs.K8S_POD_NAMESPACE),
+		ContainerID: args.ContainerID,
+	}
+	if reservedOwner != nil {
+		owner := *reservedOwner
+		owner.Pod = podOwner
+		return owner
+	}
 	return crdv1a2.IPAddressOwner{
-		Pod: &crdv1a2.PodOwner{
-			Name:        string(k8sArgs.K8S_POD_NAME),
-			Namespace:   string(k8sArgs.K8S_POD_NAMESPACE),
-			ContainerID: args.ContainerID,
-		},
+		Pod: podOwner,
 	}
 }
 
@@ -97,7 +103,7 @@ func (d *AntreaIPAM) setController(controller *AntreaIPAMController) {
 // Add allocates next available IP address from associated IP Pool
 // Allocated IP and associated resource are stored in IP Pool status
 func (d *AntreaIPAM) Add(args *invoke.Args, k8sArgs *argtypes.K8sArgs, networkConfig []byte) (bool, *current.Result, error) {
-	mine, allocator, err := d.owns(k8sArgs)
+	mine, allocator, ips, reservedOwner, err := d.owns(k8sArgs)
 	if err != nil {
 		return mine, nil, err
 	}
@@ -106,8 +112,17 @@ func (d *AntreaIPAM) Add(args *invoke.Args, k8sArgs *argtypes.K8sArgs, networkCo
 		return false, nil, nil
 	}
 
-	owner := getAllocationOwner(args, k8sArgs)
-	ip, subnetInfo, err := allocator.AllocateNext(crdv1a2.IPAddressPhaseAllocated, owner)
+	owner := getAllocationOwner(args, k8sArgs, reservedOwner)
+	var ip net.IP
+	var subnetInfo crdv1a2.SubnetInfo
+	if reservedOwner != nil {
+		ip, subnetInfo, err = allocator.AllocateReservedOrNext(crdv1a2.IPAddressPhaseAllocated, owner)
+	} else if len(ips) == 0 {
+		ip, subnetInfo, err = allocator.AllocateNext(crdv1a2.IPAddressPhaseAllocated, owner)
+	} else {
+		ip = ips[0]
+		subnetInfo, err = allocator.AllocateIP(ip, crdv1a2.IPAddressPhaseAllocated, owner)
+	}
 	if err != nil {
 		return true, nil, err
 	}
@@ -137,7 +152,7 @@ func (d *AntreaIPAM) Del(args *invoke.Args, k8sArgs *argtypes.K8sArgs, networkCo
 		return err
 	}
 
-	mine, allocator, err := d.owns(k8sArgs)
+	mine, allocator, _, _, err := d.owns(k8sArgs)
 	if !mine {
 		// pass this request to next driver
 		return false, nil
@@ -146,14 +161,14 @@ func (d *AntreaIPAM) Del(args *invoke.Args, k8sArgs *argtypes.K8sArgs, networkCo
 		return true, swallowNotFoundError(err)
 	}
 
-	owner := getAllocationOwner(args, k8sArgs)
+	owner := getAllocationOwner(args, k8sArgs, nil)
 	err = allocator.ReleaseContainerIfPresent(owner.Pod.ContainerID)
 	return true, swallowNotFoundError(err)
 }
 
 // Check verifues IP associated with resource is tracked in IP Pool status
 func (d *AntreaIPAM) Check(args *invoke.Args, k8sArgs *argtypes.K8sArgs, networkConfig []byte) (bool, error) {
-	mine, allocator, err := d.owns(k8sArgs)
+	mine, allocator, _, _, err := d.owns(k8sArgs)
 	if err != nil {
 		return mine, err
 	}
@@ -162,7 +177,7 @@ func (d *AntreaIPAM) Check(args *invoke.Args, k8sArgs *argtypes.K8sArgs, network
 		return false, nil
 	}
 
-	owner := getAllocationOwner(args, k8sArgs)
+	owner := getAllocationOwner(args, k8sArgs, nil)
 	found, err := allocator.HasContainer(owner.Pod.ContainerID)
 	if err != nil {
 		return true, err
@@ -179,27 +194,28 @@ func (d *AntreaIPAM) Check(args *invoke.Args, k8sArgs *argtypes.K8sArgs, network
 // Antrea IPAM annotation for the resource (only Namespace annotation is supported as
 // of today). If annotation is not present, or annotated IP Pool not found, the driver
 // will not own the request and fall back to next IPAM driver.
-func (d *AntreaIPAM) owns(k8sArgs *argtypes.K8sArgs) (bool, *poolallocator.IPPoolAllocator, error) {
+func (d *AntreaIPAM) owns(k8sArgs *argtypes.K8sArgs) (bool, *poolallocator.IPPoolAllocator, []net.IP, *crdv1a2.IPAddressOwner, error) {
 	if d.controller == nil {
 		klog.Warningf("Antrea IPAM driver failed to initialize due to inconsistent configuration. Falling back to default IPAM")
-		return false, nil, nil
+		return false, nil, nil, nil, nil
 	}
 
 	// As of today, only Namespace annotation is supported
 	// In future, Deployment, Statefulset and Pod annotations will be
 	// supported as well
 	namespace := string(k8sArgs.K8S_POD_NAMESPACE)
-	klog.V(2).InfoS("Inspecting IPAM annotation", "namespace", namespace)
-	allocator, err := d.controller.getPoolAllocatorByNamespace(namespace)
+	podName := string(k8sArgs.K8S_POD_NAME)
+	klog.V(2).InfoS("Inspecting IPAM annotation", "Namespace", namespace, "Pod", podName)
+	allocator, ips, reservedOwner, err := d.controller.getPoolAllocatorByPod(namespace, podName)
 	if err != nil {
 		// Failed to find pool - error should be returned from this driver
-		return true, nil, err
+		return true, nil, nil, nil, err
 	}
 	if allocator == nil {
 		// No pool annotation for this namespace
-		return false, nil, nil
+		return false, nil, nil, nil, nil
 	}
-	return true, allocator, nil
+	return true, allocator, ips, reservedOwner, nil
 }
 
 func init() {
diff --git a/pkg/agent/cniserver/ipam/antrea_ipam_controller.go b/pkg/agent/cniserver/ipam/antrea_ipam_controller.go
index a12ab3b84..a6db95164 100644
--- a/pkg/agent/cniserver/ipam/antrea_ipam_controller.go
+++ b/pkg/agent/cniserver/ipam/antrea_ipam_controller.go
@@ -16,6 +16,7 @@ package ipam
 
 import (
 	"fmt"
+	"net"
 	"strings"
 
 	"k8s.io/client-go/informers"
@@ -25,15 +26,19 @@ import (
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/klog/v2"
 
+	crdv1a2 "antrea.io/antrea/pkg/apis/crd/v1alpha2"
 	clientsetversioned "antrea.io/antrea/pkg/client/clientset/versioned"
 	"antrea.io/antrea/pkg/client/informers/externalversions"
 	crdinformers "antrea.io/antrea/pkg/client/informers/externalversions/crd/v1alpha2"
 	crdlisters "antrea.io/antrea/pkg/client/listers/crd/v1alpha2"
 	"antrea.io/antrea/pkg/ipam/poolallocator"
+	"antrea.io/antrea/pkg/util/k8s"
 )
 
 const (
 	controllerName = "AntreaIPAMController"
+	// Pod index name for IPPool cache.
+	podIndex = "pod"
 )
 
 // Antrea IPAM Controller maintains map of Namespace annotations using
@@ -47,15 +52,20 @@ type AntreaIPAMController struct {
 	ipPoolLister      crdlisters.IPPoolLister
 	namespaceInformer coreinformers.NamespaceInformer
 	namespaceLister   corelisters.NamespaceLister
+	podInformer       cache.SharedIndexInformer
+	podLister         corelisters.PodLister
 }
 
 func NewAntreaIPAMController(kubeClient clientset.Interface,
 	crdClient clientsetversioned.Interface,
 	informerFactory informers.SharedInformerFactory,
+	podInformer cache.SharedIndexInformer,
 	crdInformerFactory externalversions.SharedInformerFactory) *AntreaIPAMController {
 
 	namespaceInformer := informerFactory.Core().V1().Namespaces()
 	ipPoolInformer := crdInformerFactory.Crd().V1alpha2().IPPools()
+	ipPoolInformer.Informer().AddIndexers(cache.Indexers{podIndex: podIndexFunc})
+
 	c := AntreaIPAMController{
 		kubeClient:        kubeClient,
 		crdClient:         crdClient,
@@ -63,13 +73,29 @@ func NewAntreaIPAMController(kubeClient clientset.Interface,
 		ipPoolLister:      ipPoolInformer.Lister(),
 		namespaceInformer: namespaceInformer,
 		namespaceLister:   namespaceInformer.Lister(),
+		podInformer:       podInformer,
+		podLister:         corelisters.NewPodLister(podInformer.GetIndexer()),
 	}
 
 	return &c
 }
 
-func InitializeAntreaIPAMController(kubeClient clientset.Interface, crdClient clientsetversioned.Interface, informerFactory informers.SharedInformerFactory, crdInformerFactory externalversions.SharedInformerFactory) (*AntreaIPAMController, error) {
-	antreaIPAMController := NewAntreaIPAMController(kubeClient, crdClient, informerFactory, crdInformerFactory)
+func podIndexFunc(obj interface{}) ([]string, error) {
+	ipPool, ok := obj.(*crdv1a2.IPPool)
+	if !ok {
+		return nil, fmt.Errorf("obj is not IPPool: %+v", obj)
+	}
+	indexes := make([]string, len(ipPool.Status.IPAddresses))
+	for _, IPAddress := range ipPool.Status.IPAddresses {
+		if IPAddress.Owner.Pod != nil {
+			indexes = append(indexes, k8s.NamespacedName(IPAddress.Owner.Pod.Namespace, IPAddress.Owner.Pod.Name))
+		}
+	}
+	return indexes, nil
+}
+
+func InitializeAntreaIPAMController(kubeClient clientset.Interface, crdClient clientsetversioned.Interface, informerFactory informers.SharedInformerFactory, podInformer cache.SharedIndexInformer, crdInformerFactory externalversions.SharedInformerFactory) (*AntreaIPAMController, error) {
+	antreaIPAMController := NewAntreaIPAMController(kubeClient, crdClient, informerFactory, podInformer, crdInformerFactory)
 
 	// Order of init causes antreaIPAMDriver to be initialized first
 	// After controller is initialized by agent init, we need to make it
@@ -91,34 +117,100 @@ func (c *AntreaIPAMController) Run(stopCh <-chan struct{}) {
 	}()
 
 	klog.InfoS("Starting", "controller", controllerName)
-	if !cache.WaitForNamedCacheSync(controllerName, stopCh, c.namespaceInformer.Informer().HasSynced, c.ipPoolInformer.Informer().HasSynced) {
+	if !cache.WaitForNamedCacheSync(controllerName, stopCh, c.namespaceInformer.Informer().HasSynced, c.ipPoolInformer.Informer().HasSynced, c.podInformer.HasSynced) {
 		return
 	}
 
 	<-stopCh
 }
 
-func (c *AntreaIPAMController) getIPPoolsByNamespace(namespace string) []string {
+func (c *AntreaIPAMController) getIPPoolsByPod(namespace, name string) ([]string, []net.IP, *crdv1a2.IPAddressOwner, error) {
+	// Find IPPool by Pod
+	var ips []net.IP
+	var reservedOwner *crdv1a2.IPAddressOwner
+	pod, err := c.podLister.Pods(namespace).Get(name)
+	if err != nil {
+		// For CNI DEL case, getting Pod may fail. Try to get information from allocated IPs of all IPPools.
+		klog.ErrorS(err, "Getting pod failed", "namespace", namespace, "name", name)
+		ipPools, _ := c.ipPoolInformer.Informer().GetIndexer().ByIndex(podIndex, k8s.NamespacedName(namespace, name))
+		for _, item := range ipPools {
+			ipPool := item.(*crdv1a2.IPPool)
+			if ipPool.Spec.IPVersion != 4 {
+				continue
+			}
+			for _, IPAddress := range ipPool.Status.IPAddresses {
+				if IPAddress.Owner.Pod != nil && IPAddress.Owner.Pod.Namespace == namespace && IPAddress.Owner.Pod.Name == name {
+					// reservedOwner is nil, since this is not needed for CNI DEL case
+					return []string{ipPool.Name}, []net.IP{net.ParseIP(IPAddress.IPAddress)}, nil, nil
+				}
+			}
+		}
+		return nil, nil, nil, err
+	}
+	// Collect specified IPs if exist
+	ipStrings, _ := pod.Annotations[AntreaIPAMPodIPAnnotationKey]
+	ipStrings = strings.ReplaceAll(ipStrings, " ", "")
+	var ipErr error
+	if ipStrings != "" {
+		splittedIPStrings := strings.Split(ipStrings, AntreaIPAMAnnotationDelimiter)
+		for _, ipString := range splittedIPStrings {
+			ip := net.ParseIP(ipString)
+			if ipString != "" && ip == nil {
+				ipErr = fmt.Errorf("invalid IP annotation %s", ipStrings)
+				ips = nil
+				break
+			}
+			ips = append(ips, ip)
+		}
+	}
+
+ownerReferenceLoop:
+	for _, ownerReference := range pod.OwnerReferences {
+		if ownerReference.Controller != nil && *ownerReference.Controller == true {
+			switch ownerReference.Kind {
+			case "StatefulSet":
+				// Parse StatefulSet name/index from Pod name
+				statefulSetName, index, err := k8s.ParseStatefulSetName(name)
+				if err != nil {
+					// This should not occur unless user creates an invalid Pod manually
+					klog.Warningf("Invalid StatefulSet name: %s", name)
+					break ownerReferenceLoop
+				}
+				reservedOwner = &crdv1a2.IPAddressOwner{StatefulSet: &crdv1a2.StatefulSetOwner{
+					Name:      statefulSetName,
+					Namespace: namespace,
+					Index:     index,
+				}}
+				break ownerReferenceLoop
+			}
+		}
+	}
+
+	annotations, exists := pod.Annotations[AntreaIPAMAnnotationKey]
+	if exists {
+		return strings.Split(annotations, AntreaIPAMAnnotationDelimiter), ips, reservedOwner, ipErr
+	}
+
+	// Find IPPool by Namespace
 	ns, err := c.namespaceLister.Get(namespace)
 	if err != nil {
-		return nil
+		return nil, nil, nil, nil
 	}
-	annotations, exists := ns.Annotations[AntreaIPAMAnnotationKey]
+	annotations, exists = ns.Annotations[AntreaIPAMAnnotationKey]
 	if !exists {
-		return nil
+		return nil, nil, nil, nil
 	}
-	return strings.Split(annotations, AntreaIPAMAnnotationDelimiter)
+	return strings.Split(annotations, AntreaIPAMAnnotationDelimiter), ips, reservedOwner, ipErr
 }
 
-func (c *AntreaIPAMController) getPoolAllocatorByNamespace(namespace string) (*poolallocator.IPPoolAllocator, error) {
-	poolNames := c.getIPPoolsByNamespace(namespace)
-	if len(poolNames) < 1 {
-		// This namespace does not contain IP Pool annotation
-		return nil, nil
+func (c *AntreaIPAMController) getPoolAllocatorByPod(namespace, podName string) (*poolallocator.IPPoolAllocator, []net.IP, *crdv1a2.IPAddressOwner, error) {
+	poolNames, ips, reservedOwner, err := c.getIPPoolsByPod(namespace, podName)
+	if err != nil || len(poolNames) < 1 {
+		return nil, nil, nil, err
 	}
-
 	// Only one pool is supported as of today
 	// TODO - support a pool for each IP version
 	ipPool := poolNames[0]
-	return poolallocator.NewIPPoolAllocator(ipPool, c.crdClient, c.ipPoolLister)
+	allocator, err := poolallocator.NewIPPoolAllocator(ipPool, c.crdClient, c.ipPoolLister)
+	return allocator, ips, reservedOwner, err
 }
diff --git a/pkg/agent/nodeportlocal/k8s/npl_controller.go b/pkg/agent/nodeportlocal/k8s/npl_controller.go
index ea83497d8..67225cc38 100644
--- a/pkg/agent/nodeportlocal/k8s/npl_controller.go
+++ b/pkg/agent/nodeportlocal/k8s/npl_controller.go
@@ -125,7 +125,6 @@ func (c *NPLController) Run(stopCh <-chan struct{}) {
 	}()
 
 	klog.Infof("Starting %s", controllerName)
-	go c.podInformer.Run(stopCh)
 	if !cache.WaitForNamedCacheSync(controllerName, stopCh, c.podInformer.HasSynced, c.svcInformer.HasSynced) {
 		return
 	}
diff --git a/pkg/agent/nodeportlocal/npl_agent_init.go b/pkg/agent/nodeportlocal/npl_agent_init.go
index fc2c424bd..fb0336913 100644
--- a/pkg/agent/nodeportlocal/npl_agent_init.go
+++ b/pkg/agent/nodeportlocal/npl_agent_init.go
@@ -24,10 +24,7 @@ import (
 	nplk8s "antrea.io/antrea/pkg/agent/nodeportlocal/k8s"
 	"antrea.io/antrea/pkg/agent/nodeportlocal/portcache"
 
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/client-go/informers"
-	coreinformers "k8s.io/client-go/informers/core/v1"
 	clientset "k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/cache"
 )
@@ -45,30 +42,19 @@ func InitializeNPLAgent(
 	startPort int,
 	endPort int,
 	nodeName string,
+	podInformer cache.SharedIndexInformer,
 ) (*nplk8s.NPLController, error) {
 	portTable, err := portcache.NewPortTable(startPort, endPort)
 	if err != nil {
 		return nil, fmt.Errorf("error when initializing NodePortLocal port table: %v", err)
 	}
 
-	return InitController(kubeClient, informerFactory, portTable, nodeName)
+	return InitController(kubeClient, informerFactory, portTable, nodeName, podInformer)
 }
 
 // InitController initializes the NPLController with appropriate Pod and Service Informers.
 // This function can be used independently while unit testing without using InitializeNPLAgent function.
-func InitController(kubeClient clientset.Interface, informerFactory informers.SharedInformerFactory, portTable *portcache.PortTable, nodeName string) (*nplk8s.NPLController, error) {
-	// Watch only the Pods which belong to the Node where the agent is running.
-	listOptions := func(options *metav1.ListOptions) {
-		options.FieldSelector = fields.OneTermEqualSelector("spec.nodeName", nodeName).String()
-	}
-	podInformer := coreinformers.NewFilteredPodInformer(
-		kubeClient,
-		metav1.NamespaceAll,
-		resyncPeriod,
-		cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, // NamespaceIndex is used in NPLController.
-		listOptions,
-	)
-
+func InitController(kubeClient clientset.Interface, informerFactory informers.SharedInformerFactory, portTable *portcache.PortTable, nodeName string, podInformer cache.SharedIndexInformer) (*nplk8s.NPLController, error) {
 	svcInformer := informerFactory.Core().V1().Services().Informer()
 
 	c := nplk8s.NewNPLController(kubeClient,
diff --git a/pkg/agent/nodeportlocal/npl_agent_init_windows.go b/pkg/agent/nodeportlocal/npl_agent_init_windows.go
index 7746822d8..607c45011 100644
--- a/pkg/agent/nodeportlocal/npl_agent_init_windows.go
+++ b/pkg/agent/nodeportlocal/npl_agent_init_windows.go
@@ -22,6 +22,7 @@ import (
 
 	"k8s.io/client-go/informers"
 	clientset "k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/cache"
 )
 
 // InitializeNPLAgent starts NodePortLocal (NPL) agent.
@@ -32,6 +33,7 @@ func InitializeNPLAgent(
 	startPort int,
 	endPort int,
 	nodeName string,
+	podInformer cache.SharedIndexInformer,
 ) (*windowsCtrl, error) {
 	return nil, errors.New("Windows Platform not supported for NPL")
 }
diff --git a/pkg/agent/proxy/proxier.go b/pkg/agent/proxy/proxier.go
index dc15f7ad5..26e29ba0d 100644
--- a/pkg/agent/proxy/proxier.go
+++ b/pkg/agent/proxy/proxier.go
@@ -114,6 +114,7 @@ type proxier struct {
 	isIPv6               bool
 	proxyAll             bool
 	endpointSliceEnabled bool
+	proxyLoadBalancerIPs bool
 }
 
 func (p *proxier) SyncedOnce() bool {
@@ -159,7 +160,7 @@ func (p *proxier) removeStaleServices() {
 			}
 		}
 		// Remove LoadBalancer flows and configurations.
-		if len(svcInfo.LoadBalancerIPStrings()) > 0 {
+		if p.proxyLoadBalancerIPs && len(svcInfo.LoadBalancerIPStrings()) > 0 {
 			if err := p.uninstallLoadBalancerService(svcInfo.LoadBalancerIPStrings(), uint16(svcInfo.Port()), svcInfo.OFProtocol); err != nil {
 				klog.ErrorS(err, "Failed to remove flows and configurations of Service", "Service", svcPortName)
 				continue
@@ -415,15 +416,17 @@ func (p *proxier) installServices() {
 		}
 
 		var deletedLoadBalancerIPs, addedLoadBalancerIPs []string
-		if pSvcInfo != nil {
-			deletedLoadBalancerIPs = smallSliceDifference(pSvcInfo.LoadBalancerIPStrings(), svcInfo.LoadBalancerIPStrings())
-			addedLoadBalancerIPs = smallSliceDifference(svcInfo.LoadBalancerIPStrings(), pSvcInfo.LoadBalancerIPStrings())
-		} else {
-			deletedLoadBalancerIPs = []string{}
-			addedLoadBalancerIPs = svcInfo.LoadBalancerIPStrings()
-		}
-		if len(deletedLoadBalancerIPs) > 0 || len(addedLoadBalancerIPs) > 0 {
-			needUpdateService = true
+		if p.proxyLoadBalancerIPs {
+			if pSvcInfo != nil {
+				deletedLoadBalancerIPs = smallSliceDifference(pSvcInfo.LoadBalancerIPStrings(), svcInfo.LoadBalancerIPStrings())
+				addedLoadBalancerIPs = smallSliceDifference(svcInfo.LoadBalancerIPStrings(), pSvcInfo.LoadBalancerIPStrings())
+			} else {
+				deletedLoadBalancerIPs = []string{}
+				addedLoadBalancerIPs = svcInfo.LoadBalancerIPStrings()
+			}
+			if len(deletedLoadBalancerIPs) > 0 || len(addedLoadBalancerIPs) > 0 {
+				needUpdateService = true
+			}
 		}
 
 		// If neither the Service nor Endpoints of the Service need to be updated, we skip.
@@ -535,27 +538,29 @@ func (p *proxier) installServices() {
 				}
 			}
 
-			// Service LoadBalancer flows can be partially updated.
-			var toDelete, toAdd []string
-			if needRemoval {
-				toDelete = pSvcInfo.LoadBalancerIPStrings()
-				toAdd = svcInfo.LoadBalancerIPStrings()
-			} else {
-				toDelete = deletedLoadBalancerIPs
-				toAdd = addedLoadBalancerIPs
-			}
-			// Remove LoadBalancer flows and configurations.
-			if len(toDelete) > 0 {
-				if err := p.uninstallLoadBalancerService(toDelete, uint16(pSvcInfo.Port()), pSvcInfo.OFProtocol); err != nil {
-					klog.ErrorS(err, "Failed to remove flows and configurations of Service", "Service", svcPortName)
-					continue
+			if p.proxyLoadBalancerIPs {
+				// Service LoadBalancer flows can be partially updated.
+				var toDelete, toAdd []string
+				if needRemoval {
+					toDelete = pSvcInfo.LoadBalancerIPStrings()
+					toAdd = svcInfo.LoadBalancerIPStrings()
+				} else {
+					toDelete = deletedLoadBalancerIPs
+					toAdd = addedLoadBalancerIPs
 				}
-			}
-			// Install LoadBalancer flows and configurations.
-			if len(toAdd) > 0 {
-				if err := p.installLoadBalancerService(nGroupID, toAdd, uint16(svcInfo.Port()), svcInfo.OFProtocol, uint16(svcInfo.StickyMaxAgeSeconds()), svcInfo.NodeLocalExternal()); err != nil {
-					klog.ErrorS(err, "Failed to install LoadBalancer flows and configurations of Service", "Service", svcPortName)
-					continue
+				// Remove LoadBalancer flows and configurations.
+				if len(toDelete) > 0 {
+					if err := p.uninstallLoadBalancerService(toDelete, uint16(pSvcInfo.Port()), pSvcInfo.OFProtocol); err != nil {
+						klog.ErrorS(err, "Failed to remove flows and configurations of Service", "Service", svcPortName)
+						continue
+					}
+				}
+				// Install LoadBalancer flows and configurations.
+				if len(toAdd) > 0 {
+					if err := p.installLoadBalancerService(nGroupID, toAdd, uint16(svcInfo.Port()), svcInfo.OFProtocol, uint16(svcInfo.StickyMaxAgeSeconds()), svcInfo.NodeLocalExternal()); err != nil {
+						klog.ErrorS(err, "Failed to install LoadBalancer flows and configurations of Service", "Service", svcPortName)
+						continue
+					}
 				}
 			}
 		}
@@ -791,6 +796,7 @@ func NewProxier(
 	nodePortAddresses []net.IP,
 	proxyAllEnabled bool,
 	skipServices []string,
+	proxyLoadBalancerIPs bool,
 	groupCounter types.GroupCounter) *proxier {
 	recorder := record.NewBroadcaster().NewRecorder(
 		runtime.NewScheme(),
@@ -824,6 +830,7 @@ func NewProxier(
 		isIPv6:                   isIPv6,
 		proxyAll:                 proxyAllEnabled,
 		endpointSliceEnabled:     endpointSliceEnabled,
+		proxyLoadBalancerIPs:     proxyLoadBalancerIPs,
 	}
 
 	p.serviceConfig.RegisterEventHandler(p)
@@ -877,14 +884,15 @@ func NewDualStackProxier(
 	nodePortAddressesIPv6 []net.IP,
 	proxyAllEnabled bool,
 	skipServices []string,
+	proxyLoadBalancerIPs bool,
 	v4groupCounter types.GroupCounter,
 	v6groupCounter types.GroupCounter) *metaProxierWrapper {
 
 	// Create an IPv4 instance of the single-stack proxier.
-	ipv4Proxier := NewProxier(hostname, informerFactory, ofClient, false, routeClient, nodePortAddressesIPv4, proxyAllEnabled, skipServices, v4groupCounter)
+	ipv4Proxier := NewProxier(hostname, informerFactory, ofClient, false, routeClient, nodePortAddressesIPv4, proxyAllEnabled, skipServices, proxyLoadBalancerIPs, v4groupCounter)
 
 	// Create an IPv6 instance of the single-stack proxier.
-	ipv6Proxier := NewProxier(hostname, informerFactory, ofClient, true, routeClient, nodePortAddressesIPv6, proxyAllEnabled, skipServices, v6groupCounter)
+	ipv6Proxier := NewProxier(hostname, informerFactory, ofClient, true, routeClient, nodePortAddressesIPv6, proxyAllEnabled, skipServices, proxyLoadBalancerIPs, v6groupCounter)
 
 	// Create a meta-proxier that dispatch calls between the two
 	// single-stack proxier instances.
diff --git a/pkg/apis/crd/v1alpha2/types.go b/pkg/apis/crd/v1alpha2/types.go
index bb34ded54..906c3ea4b 100644
--- a/pkg/apis/crd/v1alpha2/types.go
+++ b/pkg/apis/crd/v1alpha2/types.go
@@ -346,6 +346,7 @@ type IPAddressPhase string
 const (
 	IPAddressPhaseAllocated    IPAddressPhase = "Allocated"
 	IPAddressPhasePreallocated IPAddressPhase = "Preallocated"
+	IPAddressPhaseReserved     IPAddressPhase = "Reserved"
 )
 
 type IPAddressState struct {
diff --git a/pkg/config/agent/config.go b/pkg/config/agent/config.go
index 07e5de472..24758045a 100644
--- a/pkg/config/agent/config.go
+++ b/pkg/config/agent/config.go
@@ -192,6 +192,14 @@ type AntreaProxyConfig struct {
 	// Services will not be load-balanced). Values can be a valid ClusterIP (e.g. 10.11.1.2) or a Service name
 	// with Namespace (e.g. kube-system/kube-dns)
 	SkipServices []string `yaml:"skipServices,omitempty"`
+	// When ProxyLoadBalancerIPs is set to false, AntreaProxy no longer load-balances traffic destined to the
+	// 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
+	// kube-proxy is removed from the cluser, otherwise kube-proxy will still load-balance this traffic.
+	// Defaults to true.
+	ProxyLoadBalancerIPs *bool `yaml:"proxyLoadBalancerIPs,omitempty"`
 }
 
 type WireGuardConfig struct {
diff --git a/pkg/ipam/poolallocator/allocator.go b/pkg/ipam/poolallocator/allocator.go
index 53da8b3a5..1ef8ad7ec 100644
--- a/pkg/ipam/poolallocator/allocator.go
+++ b/pkg/ipam/poolallocator/allocator.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"fmt"
 	"net"
+	"reflect"
 
 	"antrea.io/antrea/pkg/apis/crd/v1alpha2"
 	crdclientset "antrea.io/antrea/pkg/client/clientset/versioned"
@@ -147,24 +148,62 @@ func (a *IPPoolAllocator) appendPoolUsage(ipPool *v1alpha2.IPPool, ip net.IP, st
 		klog.Warningf("IP Pool %s update with status %+v failed: %+v", newPool.Name, newPool.Status, err)
 		return err
 	}
-	klog.InfoS("IP Pool update successful", "pool", newPool.Name, "allocation", newPool.Status)
+	klog.InfoS("IP Pool update succeeded", "pool", newPool.Name, "allocation", newPool.Status)
 	return nil
 
 }
 
-// Update pool status to delete released IP
-func (a *IPPoolAllocator) removePoolUsage(ipPool *v1alpha2.IPPool, ip net.IP) error {
+// updateIPAddressState updates the status of the specified IP in the provided IPPool. It requires the IP is already in the IPAddresses list of the IPPool's status.
+func (a *IPPoolAllocator) updateIPAddressState(ipPool *v1alpha2.IPPool, ip net.IP, state v1alpha2.IPAddressPhase, owner v1alpha2.IPAddressOwner) error {
+	newPool := ipPool.DeepCopy()
+	ipString := ip.String()
+	found := false
+
+	for i, ipAddress := range newPool.Status.IPAddresses {
+		if ipAddress.IPAddress == ipString {
+			newPool.Status.IPAddresses[i].Phase = state
+			newPool.Status.IPAddresses[i].Owner = owner
+			found = true
+			break
+		}
+	}
+	if !found {
+		return fmt.Errorf("ip %s usage not found in pool %s", ipString, newPool.Name)
+	}
+
+	_, err := a.crdClient.CrdV1alpha2().IPPools().UpdateStatus(context.TODO(), newPool, metav1.UpdateOptions{})
+	if err != nil {
+		klog.Warningf("IP Pool %s update with status %+v failed: %+v", newPool.Name, newPool.Status, err)
+		return err
+	}
+	klog.InfoS("IP Pool update succeeded", "pool", newPool.Name, "allocation", newPool.Status)
+	return nil
+
+}
+
+// removeIPAddressState updates ipPool status to delete released IP allocation, and keeps preallocation information
+func (a *IPPoolAllocator) removeIPAddressState(ipPool *v1alpha2.IPPool, ip net.IP) error {
 
 	ipString := ip.String()
 	newPool := ipPool.DeepCopy()
 	var newList []v1alpha2.IPAddressState
-	for _, entry := range ipPool.Status.IPAddresses {
+	allocated := false
+	for i := range ipPool.Status.IPAddresses {
+		entry := ipPool.Status.IPAddresses[i]
 		if entry.IPAddress != ipString {
 			newList = append(newList, entry)
+		} else {
+			allocated = true
+			if entry.Owner.StatefulSet != nil {
+				entry = *entry.DeepCopy()
+				entry.Owner.Pod = nil
+				entry.Phase = v1alpha2.IPAddressPhaseReserved
+				newList = append(newList, entry)
+			}
 		}
 	}
 
-	if len(newList) == len(ipPool.Status.IPAddresses) {
+	if !allocated {
 		return fmt.Errorf("IP address %s was not allocated from IP pool %s", ip, ipPool.Name)
 	}
 
@@ -175,7 +214,7 @@ func (a *IPPoolAllocator) removePoolUsage(ipPool *v1alpha2.IPPool, ip net.IP) er
 		klog.Warningf("IP Pool %s update failed: %+v", newPool.Name, err)
 		return err
 	}
-	klog.InfoS("IP Pool update successful", "pool", newPool.Name, "allocation", newPool.Status)
+	klog.InfoS("IP Pool update succeeded", "pool", newPool.Name, "allocation", newPool.Status)
 	return nil
 
 }
@@ -230,7 +269,7 @@ func (a *IPPoolAllocator) AllocateNext(state v1alpha2.IPAddressPhase, owner v1al
 	var subnetSpec v1alpha2.SubnetInfo
 	var ip net.IP
 	// Same resource can not ask for allocation twice without release
-	// This needs to be verified even at the expence of another API call
+	// This needs to be verified even at the expense of another API call
 	exists, err := a.HasContainer(owner.Pod.ContainerID)
 	if err != nil {
 		return ip, subnetSpec, err
@@ -272,6 +311,63 @@ func (a *IPPoolAllocator) AllocateNext(state v1alpha2.IPAddressPhase, owner v1al
 	return ip, subnetSpec, err
 }
 
+// AllocateReservedOrNext allocates the reserved IP if it exists, else allocates next available IP.
+// It returns error if pool is exhausted, or in case it fails to update IPPool's state. In case of
+// success, IP pool status is updated with allocated IP/state/resource/container.
+// AllocateReservedOrNext returns subnet details for the requested IP, as defined in IP pool spec.
+func (a *IPPoolAllocator) AllocateReservedOrNext(state v1alpha2.IPAddressPhase, owner v1alpha2.IPAddressOwner) (net.IP, v1alpha2.SubnetInfo, error) {
+	var subnetSpec v1alpha2.SubnetInfo
+	var ip net.IP
+
+	ip, err := a.getReservedIP(owner)
+	if err != nil {
+		return ip, subnetSpec, err
+	}
+	if ip == nil {
+		// ip is not reserved, allocate next available ip
+		return a.AllocateNext(state, owner)
+	}
+
+	// Same resource can not ask for allocation twice without release
+	// This needs to be verified even at the expense of another API call
+	exists, err := a.HasContainer(owner.Pod.ContainerID)
+	if err != nil {
+		return ip, subnetSpec, err
+	}
+	if exists {
+		return ip, subnetSpec, fmt.Errorf("container %s was already allocated an address from IP Pool %s", owner.Pod.ContainerID, a.ipPoolName)
+	}
+
+	// Retry on CRD update conflict which is caused by multiple agents updating a pool at same time.
+	err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
+		ipPool, allocators, err := a.getPoolAndInitIPAllocators()
+		if err != nil {
+			return err
+		}
+
+		index := -1
+		for i, allocator := range allocators {
+			if allocator.Has(ip) {
+				index = i
+				break
+			}
+		}
+
+		if index == -1 {
+			// Failed to find matching range
+			return fmt.Errorf("IP %v does not belong to IP pool %s", ip, a.ipPoolName)
+		}
+
+		subnetSpec = ipPool.Spec.IPRanges[index].SubnetInfo
+		return a.updateIPAddressState(ipPool, ip, state, owner)
+	})
+
+	if err != nil {
+		klog.ErrorS(err, "Failed to allocate IP address", "ip", ip, "ipPool", a.ipPoolName)
+	}
+	return ip, subnetSpec, err
+}
+
 // Release releases the provided IP. It returns error if the IP is not in the range or not allocated,
 // or in case CRD failed to update its state.
 // In case of success, IP pool CRD status is updated with released IP/state/resource.
@@ -291,7 +387,7 @@ func (a *IPPoolAllocator) Release(ip net.IP) error {
 			return fmt.Errorf("IP %v does not belong to IP pool %s", ip, a.ipPoolName)
 		}
 
-		return a.removePoolUsage(ipPool, ip)
+		return a.removeIPAddressState(ipPool, ip)
 	})
 
 	if err != nil {
@@ -315,7 +411,7 @@ func (a *IPPoolAllocator) ReleasePod(namespace, podName string) error {
 		// Mark allocated IPs from pool status as unavailable
 		for _, ip := range ipPool.Status.IPAddresses {
 			if ip.Owner.Pod != nil && ip.Owner.Pod.Namespace == namespace && ip.Owner.Pod.Name == podName {
-				return a.removePoolUsage(ipPool, net.ParseIP(ip.IPAddress))
+				return a.removeIPAddressState(ipPool, net.ParseIP(ip.IPAddress))
 
 			}
 		}
@@ -346,7 +442,7 @@ func (a *IPPoolAllocator) ReleaseContainerIfPresent(containerID string) error {
 		// Mark allocated IPs from pool status as unavailable
 		for _, ip := range ipPool.Status.IPAddresses {
 			if ip.Owner.Pod != nil && ip.Owner.Pod.ContainerID == containerID {
-				return a.removePoolUsage(ipPool, net.ParseIP(ip.IPAddress))
+				return a.removeIPAddressState(ipPool, net.ParseIP(ip.IPAddress))
 
 			}
 		}
@@ -378,7 +474,7 @@ func (a *IPPoolAllocator) HasPod(namespace, podName string) (bool, error) {
 	return false, nil
 }
 
-// HasResource checks whether an IP was associated with specified container. It returns error if the resource is crd fails to be retrieved.
+// HasContainer checks whether an IP was associated with specified container. It returns error if the resource crd fails to be retrieved.
 func (a *IPPoolAllocator) HasContainer(containerID string) (bool, error) {
 
 	ipPool, err := a.getPool()
@@ -394,3 +490,20 @@ func (a *IPPoolAllocator) HasContainer(containerID string) (bool, error) {
 	}
 	return false, nil
 }
+
+// getReservedIP checks whether an IP was reserved with specified owner. It returns error if the resource crd fails to be retrieved.
+func (a *IPPoolAllocator) getReservedIP(reservedOwner v1alpha2.IPAddressOwner) (net.IP, error) {
+	ipPool, err := a.getPool()
+	if err != nil {
+		return nil, err
+	}
+
+	if reservedOwner.StatefulSet != nil {
+		for _, ip := range ipPool.Status.IPAddresses {
+			if reflect.DeepEqual(ip.Owner.StatefulSet, reservedOwner.StatefulSet) {
+				return net.ParseIP(ip.IPAddress), nil
+			}
+		}
+	}
+	return nil, nil
+}
diff --git a/pkg/util/k8s/name.go b/pkg/util/k8s/name.go
index fda38d404..32d38669c 100644
--- a/pkg/util/k8s/name.go
+++ b/pkg/util/k8s/name.go
@@ -14,6 +14,12 @@
 
 package k8s
 
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
 // NamespacedName generates the conventional K8s resource name,
 // which connects namespace and name with "/".
 func NamespacedName(namespace, name string) string {
@@ -23,3 +29,17 @@ func NamespacedName(namespace, name string) string {
 	}
 	return namespace + "/" + name
 }
+
+func ParseStatefulSetName(name string) (statefulSetName string, index int, err error) {
+	splittedName := strings.Split(name, "-")
+	if len(splittedName) < 2 {
+		err = fmt.Errorf("invalid StatefulSet name: %s", name)
+		return
+	}
+	index, err = strconv.Atoi(splittedName[len(splittedName)-1])
+	if err != nil {
+		return
+	}
+	statefulSetName = strings.Join(splittedName[:len(splittedName)-1], "-")
+	return
+}


Test patch:
diff --git a/pkg/agent/cniserver/ipam/antrea_ipam_test.go b/pkg/agent/cniserver/ipam/antrea_ipam_test.go
index 197da04f4..7e6b8acf4 100644
--- a/pkg/agent/cniserver/ipam/antrea_ipam_test.go
+++ b/pkg/agent/cniserver/ipam/antrea_ipam_test.go
@@ -15,6 +15,7 @@
 package ipam
 
 import (
+	"fmt"
 	"regexp"
 	"testing"
 	"time"
@@ -26,10 +27,13 @@ import (
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
 	k8suuid "k8s.io/apimachinery/pkg/util/uuid"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/informers"
+	coreinformers "k8s.io/client-go/informers/core/v1"
 	"k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/tools/cache"
 
 	cniservertest "antrea.io/antrea/pkg/agent/cniserver/testing"
 	argtypes "antrea.io/antrea/pkg/agent/cniserver/types"
@@ -41,6 +45,7 @@ import (
 var (
 	testApple          = "apple"
 	testOrange         = "orange"
+	testPear           = "pear"
 	testNoAnnotation   = "empty"
 	testJunkAnnotation = "junk"
 )
@@ -88,11 +93,62 @@ func createIPPools(crdClient *fakepoolclient.IPPoolClientset) {
 			IPRanges: []crdv1a2.SubnetIPRange{subnetRangeOrange},
 		},
 	})
+
+	ipRangePear := crdv1a2.IPRange{
+		Start: "10.2.3.100",
+		End:   "10.2.3.200",
+	}
+	subnetInfoPear := crdv1a2.SubnetInfo{
+		Gateway:      "10.2.3.1",
+		PrefixLength: 24,
+	}
+	subnetRangePear := crdv1a2.SubnetIPRange{IPRange: ipRangePear,
+		SubnetInfo: subnetInfoPear}
+	crdClient.InitPool(&crdv1a2.IPPool{
+		ObjectMeta: metav1.ObjectMeta{Name: testPear},
+		Spec: crdv1a2.IPPoolSpec{
+			IPRanges:  []crdv1a2.SubnetIPRange{subnetRangePear},
+			IPVersion: 4,
+		},
+		Status: crdv1a2.IPPoolStatus{IPAddresses: []crdv1a2.IPAddressState{{
+			IPAddress: "10.2.3.198",
+			Phase:     crdv1a2.IPAddressPhaseReserved,
+			Owner: crdv1a2.IPAddressOwner{StatefulSet: &crdv1a2.StatefulSetOwner{
+				Name:      "pear-sts",
+				Namespace: testPear,
+				Index:     8,
+			}},
+		}, {
+			IPAddress: "10.2.3.197",
+			Phase:     crdv1a2.IPAddressPhaseReserved,
+			Owner: crdv1a2.IPAddressOwner{
+				Pod: &crdv1a2.PodOwner{
+					Name:        "pear-sts-9",
+					Namespace:   testPear,
+					ContainerID: "pear-sts-9-container",
+				},
+				StatefulSet: &crdv1a2.StatefulSetOwner{
+					Name:      "pear-sts",
+					Namespace: testPear,
+					Index:     9,
+				}},
+		}, {
+			IPAddress: "10.2.3.196",
+			Phase:     crdv1a2.IPAddressPhaseReserved,
+			Owner: crdv1a2.IPAddressOwner{
+				Pod: &crdv1a2.PodOwner{
+					Name:        "pear10",
+					Namespace:   testPear,
+					ContainerID: "pear10-container",
+				}},
+		}}},
+	})
 }
 
 func initTestClients() (*fake.Clientset, *fakepoolclient.IPPoolClientset) {
 	crdClient := fakepoolclient.NewIPPoolClient()
 
+	bTrue := true
 	k8sClient := fake.NewSimpleClientset(
 		&corev1.Namespace{
 			ObjectMeta: metav1.ObjectMeta{
@@ -106,6 +162,12 @@ func initTestClients() (*fake.Clientset, *fakepoolclient.IPPoolClientset) {
 				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testOrange},
 			},
 		},
+		&corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:        testPear,
+				Annotations: map[string]string{"junk": "garbage"},
+			},
+		},
 		&corev1.Namespace{
 			ObjectMeta: metav1.ObjectMeta{
 				Name:        testJunkAnnotation,
@@ -116,6 +178,125 @@ func initTestClients() (*fake.Clientset, *fakepoolclient.IPPoolClientset) {
 			ObjectMeta: metav1.ObjectMeta{
 				Name: testNoAnnotation,
 			},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "apple1",
+				Namespace: testApple,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "apple2",
+				Namespace: testApple,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:            "apple-sts-0",
+				Namespace:       testApple,
+				OwnerReferences: []metav1.OwnerReference{{Controller: &bTrue, Kind: "StatefulSet"}},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "orange1",
+				Namespace: testOrange,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "orange2",
+				Namespace: testOrange,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:        "pear1",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:        "pear2",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear, AntreaIPAMPodIPAnnotationKey: " "},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:        "pear3",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear, AntreaIPAMPodIPAnnotationKey: "10.2.3.199"},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				// conflict
+				Name:        "pear4",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear, AntreaIPAMPodIPAnnotationKey: "10.2.3.199"},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				// out of range
+				Name:        "pear5",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear, AntreaIPAMPodIPAnnotationKey: "10.2.4.199"},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				// invalid IP
+				Name:        "pear6",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testPear, AntreaIPAMPodIPAnnotationKey: "junk"},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				// invalid IPPool
+				Name:        "pear7",
+				Namespace:   testPear,
+				Annotations: map[string]string{"junk": "garbage", AntreaIPAMAnnotationKey: testJunkAnnotation},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:            "pear-sts-8",
+				Namespace:       testPear,
+				Annotations:     map[string]string{AntreaIPAMAnnotationKey: testPear},
+				OwnerReferences: []metav1.OwnerReference{{Controller: &bTrue, Kind: "StatefulSet"}},
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      testNoAnnotation,
+				Namespace: testNoAnnotation,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
+		},
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      testJunkAnnotation,
+				Namespace: testJunkAnnotation,
+			},
+			Spec: corev1.PodSpec{NodeName: "fakeNode"},
 		})
 
 	return k8sClient, crdClient
@@ -128,10 +309,21 @@ func TestAntreaIPAMDriver(t *testing.T) {
 
 	informerFactory := informers.NewSharedInformerFactory(k8sClient, 0)
 	crdInformerFactory := crdinformers.NewSharedInformerFactory(crdClient, 0)
-
-	antreaIPAMController, err := InitializeAntreaIPAMController(k8sClient, crdClient, informerFactory, crdInformerFactory)
+	listOptions := func(options *metav1.ListOptions) {
+		options.FieldSelector = fields.OneTermEqualSelector("spec.nodeName", "fakeNode").String()
+	}
+	localPodInformer := coreinformers.NewFilteredPodInformer(
+		k8sClient,
+		metav1.NamespaceAll,
+		0,
+		cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, // NamespaceIndex is used in NPLController.
+		listOptions,
+	)
+
+	antreaIPAMController, err := InitializeAntreaIPAMController(k8sClient, crdClient, informerFactory, localPodInformer, crdInformerFactory)
 	require.NoError(t, err, "Expected no error in initialization for Antrea IPAM Controller")
 	informerFactory.Start(stopCh)
+	go localPodInformer.Run(stopCh)
 
 	createIPPools(crdClient)
 
@@ -148,19 +340,19 @@ func TestAntreaIPAMDriver(t *testing.T) {
 
 	cniArgsMap := make(map[string]*invoke.Args)
 	k8sArgsMap := make(map[string]*argtypes.K8sArgs)
-	for _, test := range []string{"apple1", "apple2", "orange1", "orange2", testNoAnnotation, testJunkAnnotation} {
+	for _, test := range []string{"apple1", "apple2", "apple-sts-0", "orange1", "orange2", testNoAnnotation, testJunkAnnotation, "pear1", "pear2", "pear3", "pear4", "pear5", "pear6", "pear7", "pear-sts-8", "pear-sts-9", "pear10"} {
 		// extract Namespace by removing numerals
-		re := regexp.MustCompile("[0-9]$")
+		re := regexp.MustCompile("(-sts-)*[0-9]*$")
 		namespace := re.ReplaceAllString(test, "")
 		args := argtypes.K8sArgs{}
 		cnitypes.LoadArgs(cniservertest.GenerateCNIArgs(test, namespace, uuid.New().String()), &args)
 		k8sArgsMap[test] = &args
 		cniArgsMap[test] = &invoke.Args{
-			ContainerID: uuid.New().String(),
+			ContainerID: fmt.Sprintf("%s-container", test),
 		}
 	}
 
-	testAdd := func(test string, expectedIP string, expectedGW string, expectedMask string) {
+	testAdd := func(test string, expectedIP string, expectedGW string, expectedMask string, isReserved bool) {
 		owns, result, err := testDriver.Add(cniArgsMap[test], k8sArgsMap[test], networkConfig)
 		require.NoError(t, err, "expected no error in Add call")
 		assert.True(t, owns)
@@ -169,6 +361,28 @@ func TestAntreaIPAMDriver(t *testing.T) {
 		assert.Equal(t, expectedIP, result.IPs[0].Address.IP.String())
 		assert.Equal(t, expectedMask, result.IPs[0].Address.Mask.String())
 		assert.Equal(t, expectedGW, result.IPs[0].Gateway.String())
+
+		podNamespace := string(k8sArgsMap[test].K8S_POD_NAMESPACE)
+		podName := string(k8sArgsMap[test].K8S_POD_NAME)
+		err = wait.Poll(time.Millisecond*200, time.Second, func() (bool, error) {
+			ipPool, _ := antreaIPAMController.ipPoolLister.Get(podNamespace)
+			found := false
+			for _, ipAddress := range ipPool.Status.IPAddresses {
+				if expectedIP == ipAddress.IPAddress {
+					assert.Equal(t, ipAddress.Owner.StatefulSet != nil, isReserved)
+					if ipAddress.Owner.StatefulSet != nil {
+						assert.Equal(t, podName, fmt.Sprintf("%s-%d", ipAddress.Owner.StatefulSet.Name, ipAddress.Owner.StatefulSet.Index))
+						assert.Equal(t, podNamespace, ipAddress.Owner.StatefulSet.Namespace)
+					}
+					assert.Equal(t, podName, ipAddress.Owner.Pod.Name)
+					assert.Equal(t, podNamespace, ipAddress.Owner.Pod.Namespace)
+					found = true
+					break
+				}
+			}
+			return found == true, nil
+		})
+		assert.Nil(t, err)
 	}
 
 	testAddError := func(test string) {
@@ -177,10 +391,29 @@ func TestAntreaIPAMDriver(t *testing.T) {
 		require.Error(t, err, "expected error in Add call")
 	}
 
-	testDel := func(test string) {
+	testDel := func(test string, isReserved bool) {
 		owns, err := testDriver.Del(cniArgsMap[test], k8sArgsMap[test], networkConfig)
 		assert.True(t, owns)
 		require.NoError(t, err, "expected no error in Del call")
+
+		podNamespace := string(k8sArgsMap[test].K8S_POD_NAMESPACE)
+		podName := string(k8sArgsMap[test].K8S_POD_NAME)
+		err = wait.Poll(time.Millisecond*200, time.Second, func() (bool, error) {
+			ipPool, _ := antreaIPAMController.ipPoolLister.Get(podNamespace)
+			found := false
+			for _, ipAddress := range ipPool.Status.IPAddresses {
+				if ipAddress.Owner.Pod != nil && ipAddress.Owner.Pod.Name == podName && ipAddress.Owner.Pod.Namespace == podNamespace {
+					t.Logf("IP allocation is not removed")
+					return false, nil
+				}
+				if ipAddress.Owner.StatefulSet != nil && podName == fmt.Sprintf("%s-%d", ipAddress.Owner.StatefulSet.Name, ipAddress.Owner.StatefulSet.Index) && podNamespace == ipAddress.Owner.StatefulSet.Namespace {
+					found = true
+					break
+				}
+			}
+			return found == isReserved, nil
+		})
+		assert.Nil(t, err)
 	}
 
 	testCheck := func(test string, shouldExist bool) {
@@ -195,12 +428,17 @@ func TestAntreaIPAMDriver(t *testing.T) {
 
 	// Run several adds from two Namespaces that have pool annotations
 	ipv6Mask := "ffffffffffffffff0000000000000000"
-	testAdd("apple1", "10.2.2.100", "10.2.2.1", "ffffff00")
+	testAdd("apple1", "10.2.2.100", "10.2.2.1", "ffffff00", false)
 
 	// introduce new IP Pool in mid-action
-	testAdd("orange1", "20::2", "20::1", ipv6Mask)
-	testAdd("orange2", "20::3", "20::1", ipv6Mask)
-	testAdd("apple2", "10.2.2.101", "10.2.2.1", "ffffff00")
+	testAdd("orange1", "20::2", "20::1", ipv6Mask, false)
+	testAdd("orange2", "20::3", "20::1", ipv6Mask, false)
+	testAdd("apple2", "10.2.2.101", "10.2.2.1", "ffffff00", false)
+	testAdd("apple-sts-0", "10.2.2.102", "10.2.2.1", "ffffff00", true)
+	testAdd("pear1", "10.2.3.100", "10.2.3.1", "ffffff00", false)
+	testAdd("pear2", "10.2.3.101", "10.2.3.1", "ffffff00", false)
+	testAdd("pear3", "10.2.3.199", "10.2.3.1", "ffffff00", false)
+	testAdd("pear-sts-8", "10.2.3.198", "10.2.3.1", "ffffff00", true)
 
 	// Make sure the driver does not own request without pool annotation
 	owns, _, err := testDriver.Add(cniArgsMap[testNoAnnotation], k8sArgsMap[testNoAnnotation], networkConfig)
@@ -212,9 +450,34 @@ func TestAntreaIPAMDriver(t *testing.T) {
 	require.NotNil(t, err, "expected error in Add call due to non-existent pool")
 	assert.True(t, owns)
 
+	// Verify that annotation for conflict ip errors
+	owns, _, err = testDriver.Add(cniArgsMap["pear4"], k8sArgsMap["pear4"], networkConfig)
+	require.NotNil(t, err, "expected error in Add call due to conflict ip")
+	assert.True(t, owns)
+
+	// Verify that annotation for ip out of range errors
+	owns, _, err = testDriver.Add(cniArgsMap["pear5"], k8sArgsMap["pear5"], networkConfig)
+	require.NotNil(t, err, "expected error in Add call due to ip out of range")
+	assert.True(t, owns)
+
+	// Verify that annotation for invalid ip errors
+	owns, _, err = testDriver.Add(cniArgsMap["pear6"], k8sArgsMap["pear6"], networkConfig)
+	require.NotNil(t, err, "expected error in Add call due to invalid ip")
+	assert.True(t, owns)
+
+	// Verify that annotation for non existent pool errors out
+	owns, _, err = testDriver.Add(cniArgsMap["pear7"], k8sArgsMap["pear7"], networkConfig)
+	require.NotNil(t, err, "expected error in Add call due to non-existent pool")
+	assert.True(t, owns)
+
 	// Del two of the Pods
-	testDel("apple1")
-	testDel("orange2")
+	testDel("apple1", false)
+	testDel("orange2", false)
+	testDel("pear3", false)
+	testDel("apple-sts-0", true)
+	testDel("pear-sts-8", true)
+	testDel("pear-sts-9", true)
+	testDel("pear10", false)
 
 	// Verify last update was propagated to informer
 	err = wait.PollImmediate(100*time.Millisecond, 1*time.Second, func() (bool, error) {
@@ -232,6 +495,12 @@ func TestAntreaIPAMDriver(t *testing.T) {
 	testCheck("apple1", false)
 	testCheck("apple2", true)
 	testCheck("orange1", true)
+	testCheck("orange2", false)
+	testCheck("pear1", true)
+	testCheck("pear2", true)
+	testCheck("pear3", false)
+	testCheck("apple-sts-0", false)
+	testCheck("pear-sts-8", false)
 
 	// Make sure Del call with irrelevant container ID is ignored
 	cniArgsBadContainer := &invoke.Args{
@@ -243,8 +512,15 @@ func TestAntreaIPAMDriver(t *testing.T) {
 	require.NoError(t, err, "expected no error in Del call")
 
 	// Make sure repeated Add works for Pod that was previously released
-	testAdd("apple1", "10.2.2.100", "10.2.2.1", "ffffff00")
+	testAdd("apple1", "10.2.2.100", "10.2.2.1", "ffffff00", false)
+	testAdd("apple-sts-0", "10.2.2.102", "10.2.2.1", "ffffff00", true)
 
 	// Make sure repeated call for previous container results in error
 	testAddError("apple2")
+
+	// Make sure repeated Add works for pod that was previously released
+	testAdd("pear3", "10.2.3.199", "10.2.3.1", "ffffff00", false)
+
+	// Make sure repeated call without previous container results in error
+	testAddError("pear3")
 }
diff --git a/pkg/agent/nodeportlocal/npl_agent_test.go b/pkg/agent/nodeportlocal/npl_agent_test.go
index 86dbf010e..8d71e807b 100644
--- a/pkg/agent/nodeportlocal/npl_agent_test.go
+++ b/pkg/agent/nodeportlocal/npl_agent_test.go
@@ -31,11 +31,14 @@ import (
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/informers"
+	coreinformers "k8s.io/client-go/informers/core/v1"
 	k8sfake "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/tools/cache"
 
 	nplk8s "antrea.io/antrea/pkg/agent/nodeportlocal/k8s"
 	"antrea.io/antrea/pkg/agent/nodeportlocal/portcache"
@@ -237,17 +240,29 @@ func setUp(t *testing.T, tc *testConfig, objects ...runtime.Object) *testData {
 
 	// informerFactory is initialized and started from cmd/antrea-agent/agent.go
 	informerFactory := informers.NewSharedInformerFactory(data.k8sClient, resyncPeriod)
+	listOptions := func(options *metav1.ListOptions) {
+		options.FieldSelector = fields.OneTermEqualSelector("spec.nodeName", defaultNodeName).String()
+	}
+	localPodInformer := coreinformers.NewFilteredPodInformer(
+		data.k8sClient,
+		metav1.NamespaceAll,
+		resyncPeriod,
+		cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, // NamespaceIndex is used in NPLController.
+		listOptions,
+	)
 
-	c, err := InitController(data.k8sClient, informerFactory, data.portTable, defaultNodeName)
+	c, err := InitController(data.k8sClient, informerFactory, data.portTable, defaultNodeName, localPodInformer)
 	require.NoError(t, err)
 
 	data.runWrapper(c)
 	informerFactory.Start(data.stopCh)
+	go localPodInformer.Run(data.stopCh)
 
 	// Must wait for cache sync, otherwise resource creation events will be missing if the resources are created
 	// in-between list and watch call of an informer. This is because fake clientset doesn't support watching with
 	// resourceVersion. A watcher of fake clientset only gets events that happen after the watcher is created.
 	informerFactory.WaitForCacheSync(data.stopCh)
+	cache.WaitForNamedCacheSync("AntreaAgentNPLController", data.stopCh, localPodInformer.HasSynced)
 
 	return data
 }
diff --git a/pkg/agent/proxy/proxier_test.go b/pkg/agent/proxy/proxier_test.go
index baf0bcc85..3119939fa 100644
--- a/pkg/agent/proxy/proxier_test.go
+++ b/pkg/agent/proxy/proxier_test.go
@@ -99,7 +99,22 @@ func makeTestEndpoints(namespace, name string, eptFunc func(*corev1.Endpoints))
 	return ept
 }
 
-func NewFakeProxier(routeClient route.Interface, ofClient openflow.Client, nodePortAddresses []net.IP, isIPv6, proxyAllEnabled bool) *proxier {
+type proxyOptions struct {
+	proxyAllEnabled      bool
+	proxyLoadBalancerIPs bool
+}
+
+type proxyOptionsFn func(*proxyOptions)
+
+func withProxyAll(o *proxyOptions) {
+	o.proxyAllEnabled = true
+}
+
+func withoutProxyLoadBalancerIPs(o *proxyOptions) {
+	o.proxyLoadBalancerIPs = false
+}
+
+func NewFakeProxier(routeClient route.Interface, ofClient openflow.Client, nodePortAddresses []net.IP, isIPv6 bool, options ...proxyOptionsFn) *proxier {
 	hostname := "localhost"
 	eventBroadcaster := record.NewBroadcaster()
 	recorder := eventBroadcaster.NewRecorder(
@@ -112,6 +127,15 @@ func NewFakeProxier(routeClient route.Interface, ofClient openflow.Client, nodeP
 		ipFamily = corev1.IPv6Protocol
 	}
 
+	o := &proxyOptions{
+		proxyAllEnabled:      false,
+		proxyLoadBalancerIPs: true,
+	}
+
+	for _, fn := range options {
+		fn(o)
+	}
+
 	p := &proxier{
 		endpointsChanges:         newEndpointsChangesTracker(hostname, false, isIPv6),
 		serviceChanges:           newServiceChangesTracker(recorder, ipFamily, []string{"kube-system/kube-dns", "192.168.1.2"}),
@@ -126,7 +150,8 @@ func NewFakeProxier(routeClient route.Interface, ofClient openflow.Client, nodeP
 		serviceStringMap:         map[string]k8sproxy.ServicePortName{},
 		isIPv6:                   isIPv6,
 		nodePortAddresses:        nodePortAddresses,
-		proxyAll:                 proxyAllEnabled,
+		proxyAll:                 o.proxyAllEnabled,
+		proxyLoadBalancerIPs:     o.proxyLoadBalancerIPs,
 	}
 	p.runner = k8sproxy.NewBoundedFrequencyRunner(componentName, p.syncProxyRules, time.Second, 30*time.Second, 2)
 	return p
@@ -137,7 +162,7 @@ func testClusterIP(t *testing.T, svcIP net.IP, epIP net.IP, isIPv6 bool, extraSv
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, true)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, withProxyAll)
 
 	svcPort := 80
 	svcPortName := k8sproxy.ServicePortName{
@@ -183,12 +208,16 @@ func testClusterIP(t *testing.T, svcIP net.IP, epIP net.IP, isIPv6 bool, extraSv
 	fp.syncProxyRules()
 }
 
-func testLoadBalancer(t *testing.T, nodePortAddresses []net.IP, svcIP, ep1IP, ep2IP, loadBalancerIP net.IP, isIPv6, nodeLocalExternal bool) {
+func testLoadBalancer(t *testing.T, nodePortAddresses []net.IP, svcIP, ep1IP, ep2IP, loadBalancerIP net.IP, isIPv6, nodeLocalExternal, proxyLoadBalancerIPs bool) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nodePortAddresses, isIPv6, true)
+	options := []proxyOptionsFn{withProxyAll}
+	if !proxyLoadBalancerIPs {
+		options = append(options, withoutProxyLoadBalancerIPs)
+	}
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nodePortAddresses, isIPv6, options...)
 
 	svcPort := 80
 	svcNodePort := 30008
@@ -264,11 +293,17 @@ func testLoadBalancer(t *testing.T, nodePortAddresses []net.IP, svcIP, ep1IP, ep
 		groupID, _ = fp.groupCounter.Get(svcPortName, true)
 		mockOFClient.EXPECT().InstallServiceGroup(groupID, false, gomock.Any()).Times(1)
 	}
-	mockOFClient.EXPECT().InstallServiceFlows(groupID, loadBalancerIP, uint16(svcPort), bindingProtocol, uint16(0), nodeLocalExternal, corev1.ServiceTypeLoadBalancer).Times(1)
+	if proxyLoadBalancerIPs {
+		mockOFClient.EXPECT().InstallServiceFlows(groupID, loadBalancerIP, uint16(svcPort), bindingProtocol, uint16(0), nodeLocalExternal, corev1.ServiceTypeLoadBalancer).Times(1)
+	}
 	mockOFClient.EXPECT().InstallServiceFlows(groupID, gomock.Any(), uint16(svcNodePort), bindingProtocol, uint16(0), nodeLocalExternal, corev1.ServiceTypeNodePort).Times(1)
-	mockOFClient.EXPECT().InstallLoadBalancerServiceFromOutsideFlows(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	if proxyLoadBalancerIPs {
+		mockOFClient.EXPECT().InstallLoadBalancerServiceFromOutsideFlows(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+	}
 	mockRouteClient.EXPECT().AddClusterIPRoute(svcIP).Times(1)
-	mockRouteClient.EXPECT().AddLoadBalancer([]string{loadBalancerIP.String()}).Times(1)
+	if proxyLoadBalancerIPs {
+		mockRouteClient.EXPECT().AddLoadBalancer([]string{loadBalancerIP.String()}).Times(1)
+	}
 	mockRouteClient.EXPECT().AddNodePort(nodePortAddresses, uint16(svcNodePort), bindingProtocol).Times(1)
 
 	fp.syncProxyRules()
@@ -279,7 +314,7 @@ func testNodePort(t *testing.T, nodePortAddresses []net.IP, svcIP, ep1IP, ep2IP
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nodePortAddresses, isIPv6, true)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nodePortAddresses, isIPv6, withProxyAll)
 
 	svcPort := 80
 	svcNodePort := 31000
@@ -362,19 +397,23 @@ func testNodePort(t *testing.T, nodePortAddresses []net.IP, svcIP, ep1IP, ep2IP
 }
 
 func TestLoadBalancerIPv4(t *testing.T) {
-	testLoadBalancer(t, nodePortAddressesIPv4, svcIPv4, ep1IPv4, nil, loadBalancerIPv4, false, false)
+	testLoadBalancer(t, nodePortAddressesIPv4, svcIPv4, ep1IPv4, nil, loadBalancerIPv4, false, false, true)
 }
 
 func TestLoadBalancerIPv4ExternalLocal(t *testing.T) {
-	testLoadBalancer(t, nodePortAddressesIPv4, svcIPv4, ep1IPv4, ep2IPv4, loadBalancerIPv4, false, true)
+	testLoadBalancer(t, nodePortAddressesIPv4, svcIPv4, ep1IPv4, ep2IPv4, loadBalancerIPv4, false, true, true)
 }
 
 func TestLoadBalancerIPv6(t *testing.T) {
-	testLoadBalancer(t, nodePortAddressesIPv6, svcIPv6, ep1IPv6, nil, loadBalancerIPv6, true, false)
+	testLoadBalancer(t, nodePortAddressesIPv6, svcIPv6, ep1IPv6, nil, loadBalancerIPv6, true, false, true)
 }
 
 func TestLoadBalancerIPv6ExternalLocal(t *testing.T) {
-	testLoadBalancer(t, nodePortAddressesIPv6, svcIPv6, ep1IPv6, ep2IPv6, loadBalancerIPv6, true, true)
+	testLoadBalancer(t, nodePortAddressesIPv6, svcIPv6, ep1IPv6, ep2IPv6, loadBalancerIPv6, true, true, true)
+}
+
+func TestLoadBalancerIPv4NoExternalIPs(t *testing.T) {
+	testLoadBalancer(t, nodePortAddressesIPv4, svcIPv4, ep1IPv4, nil, loadBalancerIPv4, false, false, false)
 }
 
 func TestNodePortIPv4(t *testing.T) {
@@ -465,8 +504,8 @@ func TestDualStackService(t *testing.T) {
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fpv4 := NewFakeProxier(mockRouteClient, mockOFClient, nil, false, false)
-	fpv6 := NewFakeProxier(mockRouteClient, mockOFClient, nil, true, false)
+	fpv4 := NewFakeProxier(mockRouteClient, mockOFClient, nil, false)
+	fpv6 := NewFakeProxier(mockRouteClient, mockOFClient, nil, true)
 	metaProxier := k8sproxy.NewMetaProxier(fpv4, fpv6)
 
 	svcPort := 80
@@ -541,7 +580,7 @@ func testClusterIPRemoval(t *testing.T, svcIP net.IP, epIP net.IP, isIPv6 bool)
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, true)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, withProxyAll)
 
 	svcPort := 80
 	svcPortName := k8sproxy.ServicePortName{
@@ -607,7 +646,7 @@ func testClusterIPNoEndpoint(t *testing.T, svcIP net.IP, isIPv6 bool) {
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort := 80
 	svcNodePort := 3001
@@ -645,7 +684,7 @@ func testClusterIPRemoveSamePortEndpoint(t *testing.T, svcIP net.IP, epIP net.IP
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort := 80
 	svcPortName := k8sproxy.ServicePortName{
@@ -737,7 +776,7 @@ func testClusterIPRemoveEndpoints(t *testing.T, svcIP net.IP, epIP net.IP, isIPv
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort := 80
 	svcPortName := k8sproxy.ServicePortName{
@@ -797,7 +836,7 @@ func testSessionAffinityNoEndpoint(t *testing.T, svcExternalIPs net.IP, svcIP ne
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort := 80
 	svcNodePort := 3001
@@ -866,7 +905,7 @@ func testSessionAffinity(t *testing.T, svcExternalIPs net.IP, svcIP net.IP, isIP
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort := 80
 	svcNodePort := 3001
@@ -914,7 +953,7 @@ func testPortChange(t *testing.T, svcIP net.IP, epIP net.IP, isIPv6 bool) {
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, isIPv6)
 
 	svcPort1 := 80
 	svcPort2 := 8080
@@ -988,7 +1027,7 @@ func TestServicesWithSameEndpoints(t *testing.T) {
 	defer ctrl.Finish()
 	mockOFClient := ofmock.NewMockClient(ctrl)
 	mockRouteClient := routemock.NewMockInterface(ctrl)
-	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, false, false)
+	fp := NewFakeProxier(mockRouteClient, mockOFClient, nil, false)
 	epIP := net.ParseIP("10.50.60.71")
 	svcIP1 := net.ParseIP("10.180.30.41")
 	svcIP2 := net.ParseIP("10.180.30.42")