Repository: antrea-io/antrea
Pull request #3093 (2021-12-06 20:17:19)

Extend IPPool to support StatfulSet owner
Signed-off-by: Anna Khmelnitsky <akhmelnitsky@vmware.com>

codecov-commenter: # [Codecov](https://codecov.io/gh/antrea-io/antrea/pull/3093?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#3093](https://codecov.io/gh/antrea-io/antrea/pull/3093?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (78195b3) into [main](https://codecov.io/gh/antrea-io/antrea/commit/486491482647c59157a22d68c6251474c22194e7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (4864914) will **decrease** coverage by `27.29%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/antrea-io/antrea/pull/3093/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/3093?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@             Coverage Diff             @@
##             main    #3093       +/-   ##
===========================================
- Coverage   58.52%   31.23%   -27.30%     
===========================================
  Files         292      286        -6     
  Lines       24721    23919      -802     
===========================================
- Hits        14469     7472     -6997     
- Misses       8701    15310     +6609     
+ Partials     1551     1137      -414     
```

| Flag | Coverage Δ | |
|---|---|---|
| kind-e2e-tests | `31.23% <ø> (-10.70%)` | :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/3093?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [pkg/util/sets/int32.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL3V0aWwvc2V0cy9pbnQzMi5nbw==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/util/sets/string.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL3V0aWwvc2V0cy9zdHJpbmcuZ28=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/agent/controller/egress/id\_allocator.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/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: |
| [...kg/agent/controller/networkpolicy/audit\_logging.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2NvbnRyb2xsZXIvbmV0d29ya3BvbGljeS9hdWRpdF9sb2dnaW5nLmdv) | `0.00% <0.00%> (-96.16%)` | :arrow_down: |
| [.../agent/flowexporter/priorityqueue/priorityqueue.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2Zsb3dleHBvcnRlci9wcmlvcml0eXF1ZXVlL3ByaW9yaXR5cXVldWUuZ28=) | `0.00% <0.00%> (-92.60%)` | :arrow_down: |
| [pkg/controller/networkpolicy/endpoint\_querier.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/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/agent/controller/networkpolicy/reject.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2FnZW50L2NvbnRyb2xsZXIvbmV0d29ya3BvbGljeS9yZWplY3QuZ28=) | `0.00% <0.00%> (-87.91%)` | :arrow_down: |
| [pkg/controller/networkpolicy/crd\_utils.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-cGtnL2NvbnRyb2xsZXIvbmV0d29ya3BvbGljeS9jcmRfdXRpbHMuZ28=) | `0.00% <0.00%> (-87.24%)` | :arrow_down: |
| [pkg/controller/egress/controller.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/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%> (-84.82%)` | :arrow_down: |
| [pkg/ipam/ipallocator/allocator.go](https://codecov.io/gh/antrea-io/antrea/pull/3093/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: |
| ... and [148 more](https://codecov.io/gh/antrea-io/antrea/pull/3093/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |


Patch:
diff --git a/.github/workflows/lifecycle_management.yml b/.github/workflows/lifecycle_management.yml
index b150def54..36f38596e 100644
--- a/.github/workflows/lifecycle_management.yml
+++ b/.github/workflows/lifecycle_management.yml
@@ -12,12 +12,12 @@ jobs:
       - uses: actions/stale@v4
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
-          stale-issue-message: 'This issue is stale because it has been open 180 days with no activity. Remove stale label or comment, or this will be closed in 180 days'
-          stale-pr-message: 'This PR is stale because it has been open 180 days with no activity. Remove stale label or comment, or this will be closed in 180 days'
+          stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment, or this will be closed in 90 days'
+          stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment, or this will be closed in 90 days'
           stale-issue-label: 'lifecycle/stale'
           stale-pr-label: 'lifecycle/stale'
-          days-before-stale: 180
-          days-before-close: 180
+          days-before-stale: 90
+          days-before-close: 90
           exempt-issue-labels: 'lifecycle/frozen'
           exempt-pr-labels: 'lifecycle/frozen'
           remove-stale-when-updated: true
diff --git a/build/images/scripts/start_ovs b/build/images/scripts/start_ovs
index ab1fee925..e4efd6031 100755
--- a/build/images/scripts/start_ovs
+++ b/build/images/scripts/start_ovs
@@ -111,7 +111,7 @@ function quit {
     log_info $CONTAINER_NAME "Stopping OVS before quit"
     # sleep until uplink is removed from OVS during antrea-agent shutdown, and initial host network configuration has
     # been restored. The uplink is moved to the OVS bridge to support AntreaFlexibleIPAM mode.
-    while [ "`ovsdb-client dump Port|grep \"{antrea-uplink=\\\"true\\\"}\"`" != "" ]; do
+    while [ "`ovsdb-client dump Port|grep antrea-type=uplink`" != "" ]; do
       log_info $CONTAINER_NAME "Uplink found on OVS, wait 1s and retry..."
       sleep 1 &
       SLEEP_PID=$!
diff --git a/build/yamls/antrea-aks.yml b/build/yamls/antrea-aks.yml
index f6902ce09..ceacd2489 100644
--- a/build/yamls/antrea-aks.yml
+++ b/build/yamls/antrea-aks.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4269,7 +4283,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-6dtt4hkmmm
+  name: antrea-config-c5h558tg5g
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4340,7 +4354,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-6dtt4hkmmm
+          value: antrea-config-c5h558tg5g
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4391,7 +4405,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-6dtt4hkmmm
+          name: antrea-config-c5h558tg5g
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4672,7 +4686,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-6dtt4hkmmm
+          name: antrea-config-c5h558tg5g
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-eks.yml b/build/yamls/antrea-eks.yml
index 68224f307..d24ef08b3 100644
--- a/build/yamls/antrea-eks.yml
+++ b/build/yamls/antrea-eks.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4269,7 +4283,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-6dtt4hkmmm
+  name: antrea-config-c5h558tg5g
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4340,7 +4354,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-6dtt4hkmmm
+          value: antrea-config-c5h558tg5g
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4391,7 +4405,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-6dtt4hkmmm
+          name: antrea-config-c5h558tg5g
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4674,7 +4688,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-6dtt4hkmmm
+          name: antrea-config-c5h558tg5g
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-gke.yml b/build/yamls/antrea-gke.yml
index f876b3103..44b440a13 100644
--- a/build/yamls/antrea-gke.yml
+++ b/build/yamls/antrea-gke.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4269,7 +4283,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-25kc566684
+  name: antrea-config-h72246h8f2
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4340,7 +4354,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-25kc566684
+          value: antrea-config-h72246h8f2
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4391,7 +4405,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-25kc566684
+          name: antrea-config-h72246h8f2
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4675,7 +4689,7 @@ spec:
           path: /home/kubernetes/bin
         name: host-cni-bin
       - configMap:
-          name: antrea-config-25kc566684
+          name: antrea-config-h72246h8f2
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml
index f2b8c9e54..cb3334278 100644
--- a/build/yamls/antrea-ipsec.yml
+++ b/build/yamls/antrea-ipsec.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4274,7 +4288,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-tc6kb6tbkh
+  name: antrea-config-557h844gdh
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4354,7 +4368,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-tc6kb6tbkh
+          value: antrea-config-557h844gdh
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4405,7 +4419,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-tc6kb6tbkh
+          name: antrea-config-557h844gdh
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4721,7 +4735,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-tc6kb6tbkh
+          name: antrea-config-557h844gdh
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea-kind.yml b/build/yamls/antrea-kind.yml
index 767c5be14..292d3d89c 100644
--- a/build/yamls/antrea-kind.yml
+++ b/build/yamls/antrea-kind.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4274,7 +4288,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-d4hfbm5d8h
+  name: antrea-config-f7dbh45mc5
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4345,7 +4359,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-d4hfbm5d8h
+          value: antrea-config-f7dbh45mc5
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4396,7 +4410,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-d4hfbm5d8h
+          name: antrea-config-f7dbh45mc5
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4673,7 +4687,7 @@ spec:
           type: CharDevice
         name: dev-tun
       - configMap:
-          name: antrea-config-d4hfbm5d8h
+          name: antrea-config-f7dbh45mc5
         name: antrea-config
       - hostPath:
           path: /etc/cni/net.d
diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml
index b8a9b83cf..893961c9e 100644
--- a/build/yamls/antrea.yml
+++ b/build/yamls/antrea.yml
@@ -927,6 +927,8 @@ spec:
                           namespaces:
                             properties:
                               match:
+                                enum:
+                                - Self
                                 type: string
                             type: object
                           podSelector:
@@ -1842,6 +1844,15 @@ spec:
                             namespace:
                               type: string
                           type: object
+                        statefulSet:
+                          properties:
+                            index:
+                              type: integer
+                            name:
+                              type: string
+                            namespace:
+                              type: string
+                          type: object
                       type: object
                     phase:
                       type: string
@@ -3989,6 +4000,9 @@ data:
     # Deployments and StatefulSets via IP Pool annotation.
     #  AntreaIPAM: false
 
+    # Enable multicast traffic. This feature is supported only with noEncap mode.
+    #  Multicast: false
+
     # Name of the OpenVSwitch bridge antrea-agent will create and use.
     # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
     #ovsBridge: br-int
@@ -4274,7 +4288,7 @@ metadata:
   annotations: {}
   labels:
     app: antrea
-  name: antrea-config-786ttm24c7
+  name: antrea-config-gg2666bbdt
   namespace: kube-system
 ---
 apiVersion: v1
@@ -4345,7 +4359,7 @@ spec:
             fieldRef:
               fieldPath: spec.serviceAccountName
         - name: ANTREA_CONFIG_MAP_NAME
-          value: antrea-config-786ttm24c7
+          value: antrea-config-gg2666bbdt
         image: projects.registry.vmware.com/antrea/antrea-ubuntu:latest
         imagePullPolicy: IfNotPresent
         livenessProbe:
@@ -4396,7 +4410,7 @@ spec:
         key: node-role.kubernetes.io/master
       volumes:
       - configMap:
-          name: antrea-config-786ttm24c7
+          name: antrea-config-gg2666bbdt
         name: antrea-config
       - name: antrea-controller-tls
         secret:
@@ -4677,7 +4691,7 @@ spec:
         operator: Exists
       volumes:
       - configMap:
-          name: antrea-config-786ttm24c7
+          name: antrea-config-gg2666bbdt
         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 658a91472..2f37a19dd 100644
--- a/build/yamls/base/conf/antrea-agent.conf
+++ b/build/yamls/base/conf/antrea-agent.conf
@@ -35,6 +35,9 @@ featureGates:
 # Deployments and StatefulSets via IP Pool annotation.
 #  AntreaIPAM: false
 
+# Enable multicast traffic. This feature is supported only with noEncap mode.
+#  Multicast: false
+
 # Name of the OpenVSwitch bridge antrea-agent will create and use.
 # Make sure it doesn't conflict with your existing OpenVSwitch bridges.
 #ovsBridge: br-int
diff --git a/build/yamls/base/crds.yml b/build/yamls/base/crds.yml
index 7344447e5..e116d637f 100644
--- a/build/yamls/base/crds.yml
+++ b/build/yamls/base/crds.yml
@@ -291,6 +291,15 @@ spec:
                               containerID:
                                 type: string
                             type: object
+                          statefulSet:
+                            properties:
+                              name:
+                                type: string
+                              namespace:
+                                type: string
+                              index:
+                                type: integer
+                            type: object
                         type: object
                       phase:
                         type: string
@@ -942,6 +951,8 @@ spec:
                               type: object
                               properties:
                                 match:
+                                  enum:
+                                    - Self
                                   type: string
                             ipBlock:
                               type: object
diff --git a/ci/jenkins/jobs/job-templates.yaml b/ci/jenkins/jobs/job-templates.yaml
index e69767d5e..f89cef368 100644
--- a/ci/jenkins/jobs/job-templates.yaml
+++ b/ci/jenkins/jobs/job-templates.yaml
@@ -47,6 +47,61 @@
         started-status: '{started_status}'
     wrappers: '{wrappers}'
 
+- job-template:
+    name: '{name}-{test_name}-integration-for-pull-request'
+    node: '{node}'
+    block-downstream: false
+    block-upstream: false
+    builders: '{builders}'
+    concurrent: false
+    description: '{description}'
+    project-type: freestyle
+    properties:
+    - build-discarder:
+        artifact-days-to-keep: -1
+        artifact-num-to-keep: -1
+        days-to-keep: 7
+        num-to-keep: 30
+    - github:
+        url: 'https://github.com/{org_repo}'
+    - throttle:
+        max-per-node: 1
+        max-total: 0
+        categories: '{throttle_concurrent_builds_category}'
+        option: category
+        enabled: '{throttle_concurrent_builds_enabled}'
+    publishers: '{publishers}'
+    scm:
+    - git:
+        branches: '{branches}'
+        credentials-id: '{git_credentials_id}'
+        name: origin
+        refspec: +refs/heads/*:refs/remotes/origin/* +refs/pull/${{ghprbPullId}}/*:refs/remotes/origin/pr/${{ghprbPullId}}/*
+        url: 'https://github.com/{org_repo}'
+        wipe-workspace: true
+    triggers:
+    - github-pull-request:
+        admin-list: '{admin_list}'
+        allow-whitelist-orgs-as-admins: '{allow_whitelist_orgs_as_admins}'
+        auth-id: '{ghpr_auth}'
+        auto-close-on-fail: false
+        build-desc-template: null
+        github-hooks: true
+        only-trigger-phrase: '{only_trigger_phrase}'
+        org-list: '{org_list}'
+        permit-all: '{trigger_permit_all}'
+        trigger-phrase: '{trigger_phrase}'
+        white-list-target-branches: '{white_list_target_branches}'
+        white-list: '{white_list}'
+        status-context: '{status_context}'
+        status-url: '{status_url}'
+        success-status: '{success_status}'
+        failure-status: '{failure_status}'
+        error-status: '{error_status}'
+        triggered-status: '{triggered_status}'
+        started-status: '{started_status}'
+    wrappers: '{wrappers}'
+
 - job-template:
       name: '{name}-{test_name}-no-scm'
       node: '{node}'
diff --git a/ci/jenkins/jobs/macros.yaml b/ci/jenkins/jobs/macros.yaml
index ee7a85926..b58dbb2fb 100644
--- a/ci/jenkins/jobs/macros.yaml
+++ b/ci/jenkins/jobs/macros.yaml
@@ -15,6 +15,16 @@
             DOCKER_REGISTRY="$(head -n1 ci/docker-registry)"
             ./ci/jenkins/test-vmc.sh --testcase integration --coverage --codecov-token "${CODECOV_TOKEN}" --registry ${DOCKER_REGISTRY}
 
+
+- builder:
+      name: builder-multicluster-integration
+      builders:
+        - shell: |-
+            #!/bin/bash
+            set -e
+            DOCKER_REGISTRY="$(head -n1 ci/docker-registry)"
+            ./ci/jenkins/test-vmc.sh --testcase multicluster-integration --coverage --codecov-token "${CODECOV_TOKEN}" --registry ${DOCKER_REGISTRY}
+
 - builder:
     name: builder-eks-cluster-cleanup
     builders:
diff --git a/ci/jenkins/jobs/projects.yaml b/ci/jenkins/jobs/projects.yaml
index f7745ba3b..cfc9b55b3 100644
--- a/ci/jenkins/jobs/projects.yaml
+++ b/ci/jenkins/jobs/projects.yaml
@@ -139,22 +139,18 @@
           publishers: []
           included_regions:
           - ci/jenkins/jobs/.*
-      - '{name}-{test_name}-for-pull-request':
-          test_name: integration
+      - '{name}-{test_name}-integration-for-pull-request':
+          test_name: auto
           node: 'antrea-test-node'
-          description: 'This is the {test_name} test for {name}.'
+          description: 'This is the {test_name} integration test for {name}.'
           branches:
           - ${{sha1}}
           builders:
           - builder-integration
-          properties:
-          - throttle:
-              max-per-node: 1
-              max-total: 0
-              categories: integration
-              option: category
-              enabled: true
           trigger_phrase: null
+          throttle_concurrent_builds_category:
+            - integration
+          throttle_concurrent_builds_enabled: 'true'
           white_list_target_branches: []
           allow_whitelist_orgs_as_admins: false
           admin_list: []
@@ -163,7 +159,7 @@
           - nobody123_nobody123_
           only_trigger_phrase: false
           trigger_permit_all: true
-          status_context: Integration tests
+          status_context: jenkins-integration-tests
           status_url: null
           success_status: Build finished.
           failure_status: Failed.
@@ -189,22 +185,18 @@
                   credential-id: CODECOV_TOKEN # Jenkins secret that stores codecov token
                   variable: CODECOV_TOKEN
           publishers: []
-      - '{name}-{test_name}-for-pull-request':
-          test_name: integration-manual
+      - '{name}-{test_name}-integration-for-pull-request':
+          test_name: manual
           node: 'antrea-test-node'
-          description: 'This is the {test_name} test for {name}.'
+          description: 'This is the {test_name} integration test for {name}.'
           branches:
           - ${{sha1}}
           builders:
           - builder-integration
-          properties:
-          - throttle:
-              max-per-node: 1
-              max-total: 0
-              categories: integration
-              option: category
-              enabled: true
           trigger_phrase: ^(?!Thanks for your PR).*/test-integration.*
+          throttle_concurrent_builds_category:
+            - integration
+          throttle_concurrent_builds_enabled: 'true'
           white_list_target_branches: []
           allow_whitelist_orgs_as_admins: true
           admin_list: '{antrea_admin_list}'
@@ -212,7 +204,7 @@
           white_list: '{antrea_white_list}'
           only_trigger_phrase: true
           trigger_permit_all: true
-          status_context: Integration tests
+          status_context: jenkins-integration-tests
           status_url: null
           success_status: Build finished.
           failure_status: Failed. Add comment /test-integration to re-trigger.
@@ -238,6 +230,51 @@
                   credential-id: CODECOV_TOKEN # Jenkins secret that stores codecov token
                   variable: CODECOV_TOKEN
           publishers: []
+      - '{name}-{test_name}-integration-for-pull-request':
+          test_name: multicluster-manual
+          node: 'antrea-test-node'
+          description: 'This is the {test_name} integration test for {name}.'
+          branches:
+          - ${{sha1}}
+          builders:
+          - builder-multicluster-integration
+          trigger_phrase: ^(?!Thanks for your PR).*/test-mc-integration.*
+          throttle_concurrent_builds_category:
+            - integration
+          throttle_concurrent_builds_enabled: 'true'
+          white_list_target_branches: []
+          allow_whitelist_orgs_as_admins: true
+          admin_list: '{antrea_admin_list}'
+          org_list: '{antrea_org_list}'
+          white_list: '{antrea_white_list}'
+          only_trigger_phrase: true
+          trigger_permit_all: true
+          status_context: jenkins-mc-integration-tests
+          status_url: null
+          success_status: Build finished.
+          failure_status: Failed. Add comment /test-mc-integration to re-trigger.
+          error_status: Failed. Add comment /test-mc-integration to re-trigger.
+          triggered_status: null
+          started_status: null
+          wrappers:
+          - timeout:
+              fail: true
+              timeout: 20
+              type: absolute
+          - credentials-binding:
+              - text:
+                  credential-id: GOVC_URL
+                  variable: GOVC_URL
+              - text:
+                  credential-id: GOVC_USERNAME
+                  variable: GOVC_USERNAME
+              - text:
+                  credential-id: GOVC_PASSWORD
+                  variable: GOVC_PASSWORD
+              - text:
+                  credential-id: CODECOV_TOKEN # Jenkins secret that stores codecov token
+                  variable: CODECOV_TOKEN
+          publishers: []
       - '{name}-{test_name}-no-scm':
           test_name: e2e-pending-label
           node: null
diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go
index d775d05a4..50dd3841a 100644
--- a/cmd/antrea-agent/agent.go
+++ b/cmd/antrea-agent/agent.go
@@ -35,7 +35,10 @@ import (
 	"antrea.io/antrea/pkg/agent/flowexporter"
 	"antrea.io/antrea/pkg/agent/flowexporter/exporter"
 	"antrea.io/antrea/pkg/agent/interfacestore"
+	"antrea.io/antrea/pkg/agent/ipassigner"
+	"antrea.io/antrea/pkg/agent/memberlist"
 	"antrea.io/antrea/pkg/agent/metrics"
+	"antrea.io/antrea/pkg/agent/multicast"
 	npl "antrea.io/antrea/pkg/agent/nodeportlocal"
 	"antrea.io/antrea/pkg/agent/openflow"
 	"antrea.io/antrea/pkg/agent/proxy"
@@ -45,6 +48,7 @@ import (
 	"antrea.io/antrea/pkg/agent/stats"
 	"antrea.io/antrea/pkg/agent/types"
 	crdinformers "antrea.io/antrea/pkg/client/informers/externalversions"
+	"antrea.io/antrea/pkg/controller/externalippool"
 	"antrea.io/antrea/pkg/features"
 	"antrea.io/antrea/pkg/log"
 	"antrea.io/antrea/pkg/monitor"
@@ -115,7 +119,8 @@ func run(o *Options) error {
 		features.DefaultFeatureGate.Enabled(features.Egress),
 		features.DefaultFeatureGate.Enabled(features.FlowExporter),
 		o.config.AntreaProxy.ProxyAll,
-		connectUplinkToBridge)
+		connectUplinkToBridge,
+		features.DefaultFeatureGate.Enabled(features.Multicast))
 
 	_, serviceCIDRNet, _ := net.ParseCIDR(o.config.ServiceCIDR)
 	var serviceCIDRNetv6 *net.IPNet
@@ -291,10 +296,25 @@ func run(o *Options) error {
 	} else {
 		return fmt.Errorf("invalid Node Transport IPAddr in Node config: %v", nodeConfig)
 	}
+
+	var externalIPPoolController *externalippool.ExternalIPPoolController
+	var memberlistCluster *memberlist.Cluster
+	var localIPDetector ipassigner.LocalIPDetector
+
 	if features.DefaultFeatureGate.Enabled(features.Egress) {
+		externalIPPoolController = externalippool.NewExternalIPPoolController(
+			crdClient, externalIPPoolInformer,
+		)
+		localIPDetector = ipassigner.NewLocalIPDetector()
+		memberlistCluster, err = memberlist.NewCluster(o.config.ClusterMembershipPort,
+			nodeConfig.Name, nodeInformer, externalIPPoolInformer,
+		)
+		if err != nil {
+			return fmt.Errorf("error creating new MemberList cluster: %v", err)
+		}
 		egressController, err = egress.NewEgressController(
 			ofClient, antreaClientProvider, crdClient, ifaceStore, routeClient, nodeConfig.Name, nodeTransportIP,
-			o.config.ClusterMembershipPort, egressInformer, nodeInformer, externalIPPoolInformer,
+			memberlistCluster, egressInformer, nodeInformer, localIPDetector,
 		)
 		if err != nil {
 			return fmt.Errorf("error creating new Egress controller: %v", err)
@@ -426,6 +446,9 @@ func run(o *Options) error {
 	go networkPolicyController.Run(stopCh)
 
 	if features.DefaultFeatureGate.Enabled(features.Egress) {
+		go externalIPPoolController.Run(stopCh)
+		go localIPDetector.Run(stopCh)
+		go memberlistCluster.Run(stopCh)
 		go egressController.Run(stopCh)
 	}
 
@@ -455,6 +478,14 @@ func run(o *Options) error {
 		}
 	}
 
+	if features.DefaultFeatureGate.Enabled(features.Multicast) {
+		mcastController := multicast.NewMulticastController(ofClient, nodeConfig, ifaceStore)
+		if err := mcastController.Initialize(); err != nil {
+			return err
+		}
+		go mcastController.Run(stopCh)
+	}
+
 	agentQuerier := querier.NewAgentQuerier(
 		nodeConfig,
 		networkConfig,
diff --git a/cmd/antrea-controller/controller.go b/cmd/antrea-controller/controller.go
index a2ce52e3a..4eb32e3b9 100644
--- a/cmd/antrea-controller/controller.go
+++ b/cmd/antrea-controller/controller.go
@@ -42,6 +42,7 @@ import (
 	"antrea.io/antrea/pkg/controller/crdmirroring/crdhandler"
 	"antrea.io/antrea/pkg/controller/egress"
 	egressstore "antrea.io/antrea/pkg/controller/egress/store"
+	"antrea.io/antrea/pkg/controller/externalippool"
 	"antrea.io/antrea/pkg/controller/grouping"
 	"antrea.io/antrea/pkg/controller/metrics"
 	"antrea.io/antrea/pkg/controller/networkpolicy"
@@ -232,8 +233,12 @@ func run(o *Options) error {
 	controllerMonitor := monitor.NewControllerMonitor(crdClient, legacyCRDClient, nodeInformer, controllerQuerier)
 
 	var egressController *egress.EgressController
+	var externalIPPoolController *externalippool.ExternalIPPoolController
 	if features.DefaultFeatureGate.Enabled(features.Egress) {
-		egressController = egress.NewEgressController(crdClient, groupEntityIndex, egressInformer, externalIPPoolInformer, egressGroupStore)
+		externalIPPoolController = externalippool.NewExternalIPPoolController(
+			crdClient, externalIPPoolInformer,
+		)
+		egressController = egress.NewEgressController(crdClient, groupEntityIndex, egressInformer, externalIPPoolController, egressGroupStore)
 	}
 
 	var traceflowController *traceflow.Controller
@@ -368,7 +373,9 @@ func run(o *Options) error {
 			go eeMirroringController.Run(stopCh)
 		}
 	}
+
 	if features.DefaultFeatureGate.Enabled(features.Egress) {
+		go externalIPPoolController.Run(stopCh)
 		go egressController.Run(stopCh)
 	}
 
diff --git a/docs/antrea-network-policy.md b/docs/antrea-network-policy.md
index ad65f9bd3..e6b6e5a72 100644
--- a/docs/antrea-network-policy.md
+++ b/docs/antrea-network-policy.md
@@ -318,7 +318,7 @@ spec:
       enableLogging: false
     - action: Drop
       from:
-        - namespaceSelector: {}   # Drop from Pods from other all Namespaces
+        - namespaceSelector: {}   # Drop from Pods from all other Namespaces
       name: DropFromAllOtherNS
       enableLogging: true
   egress:
@@ -349,12 +349,8 @@ spec:
     - namespaceSelector: {}       # Selects all Namespaces in the cluster
   ingress:
     - action: Drop
-      from:
-        - namespaceSelector: {}
   egress:
     - action: Drop
-      to:
-        - namespaceSelector: {}
 ```
 
 #### ACNP for toServices rule
diff --git a/docs/network-flow-visibility.md b/docs/network-flow-visibility.md
index a5a37df64..3bc185063 100644
--- a/docs/network-flow-visibility.md
+++ b/docs/network-flow-visibility.md
@@ -262,10 +262,10 @@ value for this parameter in the following snippet.
 
 * If you have deployed the [go-ipfix collector](#deployment-steps),
 then please use the address:  
-`<Ipfix-Collector Cluster IP>:<port>:<TCP|UDP>`
+`<Ipfix-Collector Cluster IP>:<port>:<tcp|udp>`
 * If you have deployed the [ELK
 flow collector](#deployment-steps-1), then please use the address:  
-`<Logstash Cluster IP>:4739:<TCP|UDP>` for sending IPFIX messages, or `<Logstash Cluster IP>:4736:<TCP|UDP>`
+`<Logstash Cluster IP>:4739:<tcp|udp>` for sending IPFIX messages, or `<Logstash Cluster IP>:4736:<tcp|udp>`
 for sending JSON format records. Record format is specified with `recordFormat` (defaults
 to IPFIX) and must match the format expected by the collector.
 
diff --git a/docs/node-port-local.md b/docs/node-port-local.md
index 352aa6c5b..03c2ab064 100644
--- a/docs/node-port-local.md
+++ b/docs/node-port-local.md
@@ -9,6 +9,8 @@
   - [Usage pre Antrea v1.4](#usage-pre-antrea-v14)
   - [Usage pre Antrea v1.2](#usage-pre-antrea-v12)
 - [Limitations](#limitations)
+- [Integrations with External Load Balancers](#integrations-with-external-load-balancers)
+  - [AVI](#avi)
 <!-- /toc -->
 
 ## What is NodePortLocal?
@@ -119,6 +121,11 @@ metadata:
 This annotation indicates that port 8080 of the Pod can be reached through port
 61002 of the Node with IP Address 10.10.10.10.
 
+NodePortLocal can only be used with Services of type `ClusterIP` or
+`LoadBalancer`. The `nodeportlocal.antrea.io` annotation has no effect for
+Services of type `NodePort` or `ExternalName`. The annotation also has no effect
+for Services with an empty or missing Selector.
+
 ### Usage pre Antrea v1.4
 
 Prior to the Antrea v1.4 minor release, the `nodePortLocal` option group in the
@@ -169,3 +176,15 @@ mapped.
 
 This feature is currently only supported for Nodes running Linux with IPv4
 addresses. Only TCP & UDP Service ports are supported (not SCTP).
+
+## Integrations with External Load Balancers
+
+### AVI
+
+When using AVI and the AVI Kubernetes Operator (AKO), the AKO `serviceType`
+configuration parameter can be set to `NodePortLocal`. After that, annotating
+Services manually with `nodeportlocal.antrea.io` is no longer required. AKO will
+automatically annotate Services of type `LoadBalancer`, along with backend
+ClusterIP Services used by Ingress resources (for which AVI is the Ingress
+class). For more information refer to the [AKO
+documentation](https://avinetworks.com/docs/ako/1.5/handling-objects/).
diff --git a/hack/update-codegen-dockerized.sh b/hack/update-codegen-dockerized.sh
index e349a3a32..362d0d7ec 100755
--- a/hack/update-codegen-dockerized.sh
+++ b/hack/update-codegen-dockerized.sh
@@ -164,7 +164,7 @@ MOCKGEN_TARGETS=(
   "pkg/agent/proxy Proxier testing"
   "pkg/agent/querier AgentQuerier testing"
   "pkg/agent/route Interface testing"
-  "pkg/agent/controller/egress/ipassigner IPAssigner testing"
+  "pkg/agent/ipassigner IPAssigner testing"
   "pkg/antctl AntctlClient ."
   "pkg/controller/networkpolicy EndpointQuerier testing"
   "pkg/controller/querier ControllerQuerier testing"
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
index 340639b05..9ec70abf7 100644
--- a/pkg/agent/agent.go
+++ b/pkg/agent/agent.go
@@ -226,50 +226,103 @@ func (i *Initializer) initInterfaceStore() error {
 		return err
 	}
 
+	parseGatewayInterfaceFunc := func(port *ovsconfig.OVSPortData, ovsPort *interfacestore.OVSPortConfig) *interfacestore.InterfaceConfig {
+		intf := &interfacestore.InterfaceConfig{
+			Type:          interfacestore.GatewayInterface,
+			InterfaceName: port.Name,
+			OVSPortConfig: ovsPort}
+		if intf.InterfaceName != i.hostGateway {
+			klog.Warningf("The discovered gateway interface name %s is different from the configured value: %s",
+				intf.InterfaceName, i.hostGateway)
+			// Set the gateway interface name to the discovered name.
+			i.hostGateway = intf.InterfaceName
+		}
+		return intf
+	}
+	parseUplinkInterfaceFunc := func(port *ovsconfig.OVSPortData, ovsPort *interfacestore.OVSPortConfig) *interfacestore.InterfaceConfig {
+		return &interfacestore.InterfaceConfig{
+			Type:          interfacestore.UplinkInterface,
+			InterfaceName: port.Name,
+			OVSPortConfig: ovsPort,
+		}
+	}
+	parseTunnelInterfaceFunc := func(port *ovsconfig.OVSPortData, ovsPort *interfacestore.OVSPortConfig) *interfacestore.InterfaceConfig {
+		intf := noderoute.ParseTunnelInterfaceConfig(port, ovsPort)
+		if intf != nil && port.OFPort == config.DefaultTunOFPort &&
+			intf.InterfaceName != i.nodeConfig.DefaultTunName {
+			klog.Infof("The discovered default tunnel interface name %s is different from the default value: %s",
+				intf.InterfaceName, i.nodeConfig.DefaultTunName)
+			// Set the default tunnel interface name to the discovered name.
+			i.nodeConfig.DefaultTunName = intf.InterfaceName
+		}
+		return intf
+	}
 	ifaceList := make([]*interfacestore.InterfaceConfig, 0, len(ovsPorts))
-	uplinkIfName := i.nodeConfig.UplinkNetConfig.Name
 	for index := range ovsPorts {
 		port := &ovsPorts[index]
 		ovsPort := &interfacestore.OVSPortConfig{
 			PortUUID: port.UUID,
 			OFPort:   port.OFPort}
 		var intf *interfacestore.InterfaceConfig
-		switch {
-		case port.OFPort == config.HostGatewayOFPort:
-			intf = &interfacestore.InterfaceConfig{
-				Type:          interfacestore.GatewayInterface,
-				InterfaceName: port.Name,
-				OVSPortConfig: ovsPort}
-			if intf.InterfaceName != i.hostGateway {
-				klog.Warningf("The discovered gateway interface name %s is different from the configured value: %s",
-					intf.InterfaceName, i.hostGateway)
-				// Set the gateway interface name to the discovered name.
-				i.hostGateway = intf.InterfaceName
+		interfaceType, ok := port.ExternalIDs[interfacestore.AntreaInterfaceTypeKey]
+		if !ok {
+			interfaceType = interfacestore.AntreaUnset
+		}
+		if interfaceType != interfacestore.AntreaUnset {
+			switch interfaceType {
+			case interfacestore.AntreaGateway:
+				intf = parseGatewayInterfaceFunc(port, ovsPort)
+			case interfacestore.AntreaUplink:
+				intf = parseUplinkInterfaceFunc(port, ovsPort)
+			case interfacestore.AntreaTunnel:
+				intf = parseTunnelInterfaceFunc(port, ovsPort)
+			case interfacestore.AntreaHost:
+				// Not load the host interface, because it is configured on the OVS bridge port, and we don't need a
+				// specific interface in the interfaceStore.
+				intf = nil
+			case interfacestore.AntreaContainer:
+				// The port should be for a container interface.
+				intf = cniserver.ParseOVSPortInterfaceConfig(port, ovsPort, true)
+			default:
+				klog.InfoS("Unknown Antrea interface type", "type", interfaceType)
+			}
+		} else {
+			// Antrea Interface type is not saved in OVS port external_ids in earlier Antrea versions, so we use
+			// the old way to decide the interface type for the upgrade case.
+			uplinkIfName := i.nodeConfig.UplinkNetConfig.Name
+			var antreaIFType string
+			switch {
+			case port.OFPort == config.HostGatewayOFPort:
+				intf = parseGatewayInterfaceFunc(port, ovsPort)
+				antreaIFType = interfacestore.AntreaGateway
+			case port.Name == uplinkIfName:
+				intf = parseUplinkInterfaceFunc(port, ovsPort)
+				antreaIFType = interfacestore.AntreaUplink
+			case port.IFType == ovsconfig.GeneveTunnel:
+				fallthrough
+			case port.IFType == ovsconfig.VXLANTunnel:
+				fallthrough
+			case port.IFType == ovsconfig.GRETunnel:
+				fallthrough
+			case port.IFType == ovsconfig.STTTunnel:
+				intf = parseTunnelInterfaceFunc(port, ovsPort)
+				antreaIFType = interfacestore.AntreaTunnel
+			case port.Name == i.ovsBridge:
+				intf = nil
+				antreaIFType = interfacestore.AntreaHost
+			default:
+				// The port should be for a container interface.
+				intf = cniserver.ParseOVSPortInterfaceConfig(port, ovsPort, true)
+				antreaIFType = interfacestore.AntreaContainer
 			}
-		case port.Name == uplinkIfName:
-			intf = &interfacestore.InterfaceConfig{
-				Type:          interfacestore.UplinkInterface,
-				InterfaceName: port.Name,
-				OVSPortConfig: ovsPort,
+			updatedExtIDs := make(map[string]interface{})
+			for k, v := range port.ExternalIDs {
+				updatedExtIDs[k] = v
 			}
-		case port.IFType == ovsconfig.GeneveTunnel:
-			fallthrough
-		case port.IFType == ovsconfig.VXLANTunnel:
-			fallthrough
-		case port.IFType == ovsconfig.GRETunnel:
-			fallthrough
-		case port.IFType == ovsconfig.STTTunnel:
-			intf = noderoute.ParseTunnelInterfaceConfig(port, ovsPort)
-			if intf != nil && port.OFPort == config.DefaultTunOFPort &&
-				intf.InterfaceName != i.nodeConfig.DefaultTunName {
-				klog.Infof("The discovered default tunnel interface name %s is different from the default value: %s",
-					intf.InterfaceName, i.nodeConfig.DefaultTunName)
-				// Set the default tunnel interface name to the discovered name.
-				i.nodeConfig.DefaultTunName = intf.InterfaceName
+			updatedExtIDs[interfacestore.AntreaInterfaceTypeKey] = antreaIFType
+			if err := i.ovsBridgeClient.SetPortExternalIDs(port.Name, updatedExtIDs); err != nil {
+				klog.ErrorS(err, "Failed to set Antrea interface type on OVS port", "port", port.Name)
 			}
-		default:
-			// The port should be for a container interface.
-			intf = cniserver.ParseOVSPortInterfaceConfig(port, ovsPort, true)
 		}
 		if intf != nil {
 			ifaceList = append(ifaceList, intf)
@@ -556,7 +609,10 @@ func (i *Initializer) setupGatewayInterface() error {
 	gatewayIface, portExists := i.ifaceStore.GetInterface(i.hostGateway)
 	if !portExists {
 		klog.V(2).Infof("Creating gateway port %s on OVS bridge", i.hostGateway)
-		gwPortUUID, err := i.ovsBridgeClient.CreateInternalPort(i.hostGateway, config.HostGatewayOFPort, nil)
+		externalIDs := map[string]interface{}{
+			interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaGateway,
+		}
+		gwPortUUID, err := i.ovsBridgeClient.CreateInternalPort(i.hostGateway, config.HostGatewayOFPort, externalIDs)
 		if err != nil {
 			klog.Errorf("Failed to create gateway port %s on OVS bridge: %v", i.hostGateway, err)
 			return err
@@ -683,7 +739,10 @@ func (i *Initializer) setupDefaultTunnelInterface() error {
 			tunnelPortName = defaultTunInterfaceName
 			i.nodeConfig.DefaultTunName = tunnelPortName
 		}
-		tunnelPortUUID, err := i.ovsBridgeClient.CreateTunnelPortExt(tunnelPortName, i.networkConfig.TunnelType, config.DefaultTunOFPort, shouldEnableCsum, localIPStr, "", "", nil)
+		externalIDs := map[string]interface{}{
+			interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaTunnel,
+		}
+		tunnelPortUUID, err := i.ovsBridgeClient.CreateTunnelPortExt(tunnelPortName, i.networkConfig.TunnelType, config.DefaultTunOFPort, shouldEnableCsum, localIPStr, "", "", externalIDs)
 		if err != nil {
 			klog.Errorf("Failed to create tunnel port %s type %s on OVS bridge: %v", tunnelPortName, i.networkConfig.TunnelType, err)
 			return err
diff --git a/pkg/agent/agent_linux.go b/pkg/agent/agent_linux.go
index 8ec39a7d7..33c189f59 100644
--- a/pkg/agent/agent_linux.go
+++ b/pkg/agent/agent_linux.go
@@ -114,7 +114,10 @@ func (i *Initializer) prepareOVSBridge() error {
 	} else {
 		// OVS does not receive "ofport_request" param when creating local port, so here use config.AutoAssignedOFPort=0
 		// to ignore this param.
-		if _, err = i.ovsBridgeClient.CreateInternalPort(brName, config.AutoAssignedOFPort, nil); err != nil {
+		externalIDs := map[string]interface{}{
+			interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost,
+		}
+		if _, err = i.ovsBridgeClient.CreateInternalPort(brName, config.AutoAssignedOFPort, externalIDs); err != nil {
 			return err
 		}
 	}
@@ -208,7 +211,7 @@ func (i *Initializer) BridgeUplinkToOVSBridge() error {
 		return err
 	}
 	// Create uplink port.
-	uplinkPortUUId, err := i.ovsBridgeClient.CreateUplinkPort(uplink, config.UplinkOFPort, map[string]interface{}{"antrea-uplink": "true"})
+	uplinkPortUUId, err := i.ovsBridgeClient.CreateUplinkPort(uplink, config.UplinkOFPort, map[string]interface{}{interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaUplink})
 	if err != nil {
 		return fmt.Errorf("failed to add uplink port %s: err=%w", uplink, err)
 	}
diff --git a/pkg/agent/agent_windows.go b/pkg/agent/agent_windows.go
index c932fa0b2..187d5268b 100644
--- a/pkg/agent/agent_windows.go
+++ b/pkg/agent/agent_windows.go
@@ -144,7 +144,10 @@ func (i *Initializer) prepareOVSBridge() error {
 	} else {
 		// OVS does not receive "ofport_request" param when creating local port, so here use config.AutoAssignedOFPort=0
 		// to ignore this param.
-		if _, err = i.ovsBridgeClient.CreateInternalPort(brName, config.AutoAssignedOFPort, nil); err != nil {
+		externalIDs := map[string]interface{}{
+			interfacestore.AntreaInterfaceTypeKey: interfacestore.AntreaHost,
+		}
+		if _, err = i.ovsBridgeClient.CreateInternalPort(brName, config.AutoAssignedOFPort, externalIDs); err != nil {
 			return err
 		}
 	}
diff --git a/pkg/agent/cniserver/interface_configuration_windows.go b/pkg/agent/cniserver/interface_configuration_windows.go
index 0500c133d..e3efdc5a9 100644
--- a/pkg/agent/cniserver/interface_configuration_windows.go
+++ b/pkg/agent/cniserver/interface_configuration_windows.go
@@ -137,7 +137,7 @@ func (ic *ifConfigurator) configureContainerLink(
 	containerIface, err := attachContainerLink(endpoint, containerID, containerNetNS, containerIFDev)
 	if err != nil {
 		klog.V(2).Infof("Failed to attach HNS Endpoint to the container, remove it.")
-		if isInfraContainer(containerID) {
+		if isInfraContainer(containerNetNS) {
 			ic.removeHNSEndpoint(endpoint, containerID)
 		}
 		return fmt.Errorf("failed to configure container IP: %v", err)
diff --git a/pkg/agent/cniserver/pod_configuration.go b/pkg/agent/cniserver/pod_configuration.go
index 5b3ff95bc..0f8300761 100644
--- a/pkg/agent/cniserver/pod_configuration.go
+++ b/pkg/agent/cniserver/pod_configuration.go
@@ -131,6 +131,7 @@ func BuildOVSPortExternalIDs(containerConfig *interfacestore.InterfaceConfig) ma
 	externalIDs[ovsExternalIDIP] = getContainerIPsString(containerConfig.IPs)
 	externalIDs[ovsExternalIDPodName] = containerConfig.PodName
 	externalIDs[ovsExternalIDPodNamespace] = containerConfig.PodNamespace
+	externalIDs[interfacestore.AntreaInterfaceTypeKey] = interfacestore.AntreaContainer
 	return externalIDs
 }
 
diff --git a/pkg/agent/controller/egress/egress_controller.go b/pkg/agent/controller/egress/egress_controller.go
index 37be9269f..29f317030 100644
--- a/pkg/agent/controller/egress/egress_controller.go
+++ b/pkg/agent/controller/egress/egress_controller.go
@@ -37,8 +37,8 @@ import (
 	"k8s.io/klog/v2"
 
 	"antrea.io/antrea/pkg/agent"
-	"antrea.io/antrea/pkg/agent/controller/egress/ipassigner"
 	"antrea.io/antrea/pkg/agent/interfacestore"
+	"antrea.io/antrea/pkg/agent/ipassigner"
 	"antrea.io/antrea/pkg/agent/memberlist"
 	"antrea.io/antrea/pkg/agent/openflow"
 	"antrea.io/antrea/pkg/agent/route"
@@ -121,7 +121,7 @@ type EgressController struct {
 	queue              workqueue.RateLimitingInterface
 
 	// Use an interface for IP detector to enable testing.
-	localIPDetector LocalIPDetector
+	localIPDetector ipassigner.LocalIPDetector
 	ifaceStore      interfacestore.InterfaceStore
 	nodeName        string
 	idAllocator     *idAllocator
@@ -152,12 +152,11 @@ func NewEgressController(
 	routeClient route.Interface,
 	nodeName string,
 	nodeTransportIP net.IP,
-	clusterPort int,
+	cluster *memberlist.Cluster,
 	egressInformer crdinformers.EgressInformer,
 	nodeInformer coreinformers.NodeInformer,
-	externalIPPoolInformer crdinformers.ExternalIPPoolInformer,
+	localIPDetector ipassigner.LocalIPDetector,
 ) (*EgressController, error) {
-	localIPDetector := NewLocalIPDetector()
 	c := &EgressController{
 		ofClient:             ofClient,
 		routeClient:          routeClient,
@@ -175,6 +174,7 @@ func NewEgressController(
 		egressBindings:       map[string]*egressBinding{},
 		localIPDetector:      localIPDetector,
 		idAllocator:          newIDAllocator(minEgressMark, maxEgressMark),
+		cluster:              cluster,
 	}
 	ipAssigner, err := ipassigner.NewIPAssigner(nodeTransportIP, egressDummyDevice)
 	if err != nil {
@@ -182,12 +182,6 @@ func NewEgressController(
 	}
 	c.ipAssigner = ipAssigner
 
-	cluster, err := memberlist.NewCluster(clusterPort, nodeName, nodeInformer, externalIPPoolInformer)
-	if err != nil {
-		return nil, fmt.Errorf("initializing memberlist cluster failed: %v", err)
-	}
-	c.cluster = cluster
-
 	c.egressInformer.AddIndexers(cache.Indexers{egressIPIndex: func(obj interface{}) ([]string, error) {
 		egress, ok := obj.(*crdv1a2.Egress)
 		if !ok {
@@ -214,7 +208,7 @@ func NewEgressController(
 		},
 		resyncPeriod,
 	)
-	localIPDetector.AddEventHandler(c.onLocalIPUpdate)
+	c.localIPDetector.AddEventHandler(c.onLocalIPUpdate)
 	c.cluster.AddClusterEventHandler(c.enqueueEgressesByExternalIPPool)
 	return c, nil
 }
@@ -304,8 +298,6 @@ func (c *EgressController) Run(stopCh <-chan struct{}) {
 
 	c.removeStaleEgressIPs()
 
-	go c.cluster.Run(stopCh)
-
 	go wait.NonSlidingUntil(c.watchEgressGroup, 5*time.Second, stopCh)
 
 	for i := 0; i < defaultWorkers; i++ {
@@ -618,7 +610,7 @@ func (c *EgressController) syncEgress(egressName string) error {
 		eState = c.newEgressState(egressName, egress.Spec.EgressIP)
 	}
 
-	localNodeSelected, err := c.cluster.ShouldSelectEgress(egress)
+	localNodeSelected, err := c.cluster.ShouldSelectIP(egress.Spec.EgressIP, egress.Spec.ExternalIPPool)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/agent/interfacestore/interface_cache.go b/pkg/agent/interfacestore/interface_cache.go
index 51deb3d92..0f91949fc 100644
--- a/pkg/agent/interfacestore/interface_cache.go
+++ b/pkg/agent/interfacestore/interface_cache.go
@@ -15,6 +15,7 @@
 package interfacestore
 
 import (
+	"fmt"
 	"sync"
 
 	"k8s.io/client-go/tools/cache"
@@ -40,6 +41,8 @@ const (
 	// interfaceIPIndex is the index built with InterfaceConfig.IP
 	// Only the interfaces with IP get indexed.
 	interfaceIPIndex = "ip"
+	// ofPortIndex is the index built with InterfaceConfig.OFPort
+	ofPortIndex = "ofPort"
 )
 
 // Local cache for interfaces created on node, including container, host gateway, and tunnel
@@ -221,6 +224,18 @@ func (c *interfaceCache) GetNodeTunnelInterface(nodeName string) (*InterfaceConf
 	return obj.(*InterfaceConfig), true
 }
 
+// GetInterfaceByOFPort retrieves InterfaceConfig by the given ofPort number.
+func (c *interfaceCache) GetInterfaceByOFPort(ofPort uint32) (*InterfaceConfig, bool) {
+	c.RLock()
+	defer c.RUnlock()
+	ofportStr := fmt.Sprintf("%d", ofPort)
+	interfaceConfigs, _ := c.cache.ByIndex(ofPortIndex, ofportStr)
+	if len(interfaceConfigs) == 0 {
+		return nil, false
+	}
+	return interfaceConfigs[0].(*InterfaceConfig), true
+}
+
 func interfaceNameIndexFunc(obj interface{}) ([]string, error) {
 	interfaceConfig := obj.(*InterfaceConfig)
 	return []string{interfaceConfig.InterfaceName}, nil
@@ -260,6 +275,15 @@ func interfaceIPIndexFunc(obj interface{}) ([]string, error) {
 	return intfIPs, nil
 }
 
+func interfaceOFPortIndexFunc(obj interface{}) ([]string, error) {
+	interfaceConfig := obj.(*InterfaceConfig)
+	if interfaceConfig.OFPort < 0 {
+		// If interfaceConfig OFport is not valid, we return empty key.
+		return []string{}, nil
+	}
+	return []string{fmt.Sprintf("%d", interfaceConfig.OFPort)}, nil
+}
+
 func NewInterfaceStore() InterfaceStore {
 	return &interfaceCache{
 		cache: cache.NewIndexer(getInterfaceKey, cache.Indexers{
@@ -268,6 +292,7 @@ func NewInterfaceStore() InterfaceStore {
 			containerIDIndex:   containerIDIndexFunc,
 			podIndex:           podIndexFunc,
 			interfaceIPIndex:   interfaceIPIndexFunc,
+			ofPortIndex:        interfaceOFPortIndexFunc,
 		}),
 	}
 }
diff --git a/pkg/agent/interfacestore/types.go b/pkg/agent/interfacestore/types.go
index 5bfdd8455..c100909ff 100644
--- a/pkg/agent/interfacestore/types.go
+++ b/pkg/agent/interfacestore/types.go
@@ -31,6 +31,14 @@ const (
 	TunnelInterface
 	// UplinkInterface is used to mark current interface is for uplink port
 	UplinkInterface
+
+	AntreaInterfaceTypeKey = "antrea-type"
+	AntreaGateway          = "gateway"
+	AntreaContainer        = "container"
+	AntreaTunnel           = "tunnel"
+	AntreaUplink           = "uplink"
+	AntreaHost             = "host"
+	AntreaUnset            = ""
 )
 
 type InterfaceType uint8
@@ -88,6 +96,7 @@ type InterfaceStore interface {
 	GetContainerInterfacesByPod(podName string, podNamespace string) []*InterfaceConfig
 	GetInterfaceByIP(interfaceIP string) (*InterfaceConfig, bool)
 	GetNodeTunnelInterface(nodeName string) (*InterfaceConfig, bool)
+	GetInterfaceByOFPort(ofPort uint32) (*InterfaceConfig, bool)
 	GetContainerInterfaceNum() int
 	GetInterfacesByType(interfaceType InterfaceType) []*InterfaceConfig
 	Len() int
diff --git a/pkg/agent/controller/egress/ipassigner/ip_assigner.go b/pkg/agent/ipassigner/ip_assigner.go
similarity index 100%
rename from pkg/agent/controller/egress/ipassigner/ip_assigner.go
rename to pkg/agent/ipassigner/ip_assigner.go
diff --git a/pkg/agent/controller/egress/ipassigner/ip_assigner_linux.go b/pkg/agent/ipassigner/ip_assigner_linux.go
similarity index 100%
rename from pkg/agent/controller/egress/ipassigner/ip_assigner_linux.go
rename to pkg/agent/ipassigner/ip_assigner_linux.go
diff --git a/pkg/agent/controller/egress/ipassigner/ip_assigner_windows.go b/pkg/agent/ipassigner/ip_assigner_windows.go
similarity index 100%
rename from pkg/agent/controller/egress/ipassigner/ip_assigner_windows.go
rename to pkg/agent/ipassigner/ip_assigner_windows.go
diff --git a/pkg/agent/controller/egress/local_ip_detector.go b/pkg/agent/ipassigner/local_ip_detector.go
similarity index 89%
rename from pkg/agent/controller/egress/local_ip_detector.go
rename to pkg/agent/ipassigner/local_ip_detector.go
index 108ed1383..df6c9b80b 100644
--- a/pkg/agent/controller/egress/local_ip_detector.go
+++ b/pkg/agent/ipassigner/local_ip_detector.go
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package egress
+package ipassigner
 
-type eventHandler func(ip string, added bool)
+type LocalIPEventHandler func(ip string, added bool)
 
 type LocalIPDetector interface {
 	IsLocalIP(ip string) bool
@@ -24,7 +24,7 @@ type LocalIPDetector interface {
 
 	// AddEventHandler registers an eventHandler of IP address update. It's not thread-safe and should be called before
 	// starting the detector.
-	AddEventHandler(handler eventHandler)
+	AddEventHandler(handler LocalIPEventHandler)
 
 	// HasSynced returns true if the cache has been initialized with the full lists of IP addresses.
 	HasSynced() bool
diff --git a/pkg/agent/controller/egress/local_ip_detector_linux.go b/pkg/agent/ipassigner/local_ip_detector_linux.go
similarity index 97%
rename from pkg/agent/controller/egress/local_ip_detector_linux.go
rename to pkg/agent/ipassigner/local_ip_detector_linux.go
index a26336d91..c4e41dcf6 100644
--- a/pkg/agent/controller/egress/local_ip_detector_linux.go
+++ b/pkg/agent/ipassigner/local_ip_detector_linux.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package egress
+package ipassigner
 
 import (
 	"sync"
@@ -31,7 +31,7 @@ type localIPDetector struct {
 	mutex         sync.RWMutex
 	localIPs      sets.String
 	cacheSynced   bool
-	eventHandlers []eventHandler
+	eventHandlers []LocalIPEventHandler
 }
 
 func NewLocalIPDetector() *localIPDetector {
@@ -51,7 +51,7 @@ func (d *localIPDetector) HasSynced() bool {
 	return d.cacheSynced
 }
 
-func (d *localIPDetector) AddEventHandler(handler eventHandler) {
+func (d *localIPDetector) AddEventHandler(handler LocalIPEventHandler) {
 	d.eventHandlers = append(d.eventHandlers, handler)
 }
 
diff --git a/pkg/agent/controller/egress/local_ip_detector_windows.go b/pkg/agent/ipassigner/local_ip_detector_windows.go
similarity index 91%
rename from pkg/agent/controller/egress/local_ip_detector_windows.go
rename to pkg/agent/ipassigner/local_ip_detector_windows.go
index 99a21931c..5aaf32166 100644
--- a/pkg/agent/controller/egress/local_ip_detector_windows.go
+++ b/pkg/agent/ipassigner/local_ip_detector_windows.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package egress
+package ipassigner
 
 // Not implemented yet. The feature gate verification will protect this from being run.
 type localIPDetector struct{}
@@ -25,7 +25,7 @@ func (d *localIPDetector) Run(stopCh <-chan struct{}) {
 	return
 }
 
-func (d *localIPDetector) AddEventHandler(handler eventHandler) {
+func (d *localIPDetector) AddEventHandler(handler LocalIPEventHandler) {
 	return
 }
 
diff --git a/pkg/agent/memberlist/cluster.go b/pkg/agent/memberlist/cluster.go
index 5e54eba05..ab6c9b5c5 100644
--- a/pkg/agent/memberlist/cluster.go
+++ b/pkg/agent/memberlist/cluster.go
@@ -458,22 +458,25 @@ func (c *Cluster) aliveNodes() sets.String {
 	return nodes
 }
 
-// ShouldSelectEgress returns true if the local Node selected as the owner Node of the Egress,
-// the local Node in the cluster holds the same consistent hash ring for each ExternalIPPool,
-// consistentHash.Get gets the closest item (Node name) in the hash to the provided key(egressIP),
-// if the name of the local Node is equal to the name of the selected Node, returns true.
-func (c *Cluster) ShouldSelectEgress(egress *v1alpha2.Egress) (bool, error) {
-	eipName := egress.Spec.ExternalIPPool
-	if eipName == "" || egress.Spec.EgressIP == "" {
+// ShouldSelectIP returns true if the local Node selected as the owner Node of the IP in the specific
+// ExternalIPPool. The local Node in the cluster holds the same consistent hash ring for each ExternalIPPool,
+// consistentHash.Get gets the closest item (Node name) in the hash to the provided key (IP), if the name of
+// the local Node is equal to the name of the selected Node, returns true.
+func (c *Cluster) ShouldSelectIP(ip, externalIPPool string) (bool, error) {
+	if externalIPPool == "" || ip == "" {
 		return false, nil
 	}
 	c.consistentHashRWMutex.RLock()
 	defer c.consistentHashRWMutex.RUnlock()
-	consistentHash, ok := c.consistentHashMap[eipName]
+	consistentHash, ok := c.consistentHashMap[externalIPPool]
 	if !ok {
-		return false, fmt.Errorf("local Node consistentHashMap has not synced, ExternalIPPool %s", eipName)
+		return false, fmt.Errorf("local Node consistentHashMap has not synced, ExternalIPPool %s", externalIPPool)
 	}
-	return consistentHash.Get(egress.Spec.EgressIP) == c.nodeName, nil
+	node := consistentHash.Get(ip)
+	if node == "" {
+		klog.Warningf("No valid Node chosen for IP %s in externalIPPool %s", ip, externalIPPool)
+	}
+	return node == c.nodeName, nil
 }
 
 func (c *Cluster) notify(objName string) {
diff --git a/pkg/agent/multicast/mcast_controller.go b/pkg/agent/multicast/mcast_controller.go
new file mode 100644
index 000000000..875d5626b
--- /dev/null
+++ b/pkg/agent/multicast/mcast_controller.go
@@ -0,0 +1,365 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package multicast
+
+import (
+	"net"
+	"sync"
+	"time"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/util/workqueue"
+	"k8s.io/klog/v2"
+
+	"antrea.io/antrea/pkg/agent/config"
+	"antrea.io/antrea/pkg/agent/interfacestore"
+	"antrea.io/antrea/pkg/agent/openflow"
+)
+
+type eventType uint8
+
+const (
+	groupJoin eventType = iota
+	groupLeave
+
+	// Use IGMPv3 for IGMP query message because it is the main version for IGMP.
+	// TODO: support sending IGMP query with both v2 and v3 messages
+	queryVersion      = 3
+	podInterfaceIndex = "podInterface"
+
+	// How long to wait before retrying the processing of a multicast group change.
+	minRetryDelay = 5 * time.Second
+	maxRetryDelay = 300 * time.Second
+)
+
+var (
+	workerCount uint8 = 2
+)
+
+type mcastGroupEvent struct {
+	group net.IP
+	eType eventType
+	time  time.Time
+	iface *interfacestore.InterfaceConfig
+}
+
+type GroupMemberStatus struct {
+	group net.IP
+	// localMembers is a set for the local Pod's interface name which has joined in the multicast group.
+	localMembers   sets.String
+	lastIGMPReport time.Time
+	mutex          sync.RWMutex
+}
+
+// eventHandler process the multicast Group membership report or leave messages.
+func (c *Controller) eventHandler(stopCh <-chan struct{}) {
+	for {
+		select {
+		case e := <-c.groupEventCh:
+			c.addOrUpdateGroupEvent(e)
+		case <-stopCh:
+			return
+		}
+	}
+}
+
+// addGroupMemberStatus adds the new group into groupCache.
+func (c *Controller) addGroupMemberStatus(e *mcastGroupEvent) {
+	status := &GroupMemberStatus{
+		group:          e.group,
+		lastIGMPReport: e.time,
+		localMembers:   sets.NewString(e.iface.InterfaceName),
+	}
+	c.groupCache.Add(status)
+	c.queue.Add(e.group.String())
+	klog.InfoS("Added new multicast group to cache", "group", e.group, "interface", e.iface.InterfaceName)
+	return
+}
+
+// updateGroupMemberStatus updates the group status in groupCache. If a "join" message is sent from an existing member,
+// only updates the lastIGMPReport time. If a "join" message is sent from an "unknown" member, updates the lastIGMPReport time and
+// adds the new member into the group's local member set. If a "leave" message is sent from an existing member, removes
+// it from the group's local member set, and if the member is the last one in local cache, a query message on the group
+// is sent out to check if there are still local members in the group.
+func (c *Controller) updateGroupMemberStatus(obj interface{}, e *mcastGroupEvent) {
+	status := obj.(*GroupMemberStatus)
+	status.mutex.Lock()
+	defer status.mutex.Unlock()
+	newStatus := &GroupMemberStatus{
+		group:          status.group,
+		localMembers:   status.localMembers.Union(nil),
+		lastIGMPReport: status.lastIGMPReport,
+	}
+	exist := status.localMembers.Has(e.iface.InterfaceName)
+	switch e.eType {
+	case groupJoin:
+		newStatus.lastIGMPReport = e.time
+		if !exist {
+			newStatus.localMembers.Insert(e.iface.InterfaceName)
+			klog.InfoS("Added member to multicast group", "group", e.group.String(), "member", e.iface.InterfaceName)
+		}
+		c.groupCache.Update(newStatus)
+		c.queue.Add(newStatus.group.String())
+	case groupLeave:
+		if exist {
+			newStatus.localMembers.Delete(e.iface.InterfaceName)
+			c.groupCache.Update(newStatus)
+			klog.InfoS("Deleted member from multicast group", "group", e.group.String(), "member", e.iface.InterfaceName)
+			if len(newStatus.localMembers) == 0 {
+				klog.InfoS("Check last member in multicast group", "group", e.group.String(), "member", e.iface.InterfaceName)
+				c.checkLastMember(e.group)
+			}
+		}
+	}
+	return
+}
+
+// checkLastMember sends out a query message on the group to check if there are still members in the group. If no new
+// membership report is received in the max response time, the group is removed from groupCache.
+func (c *Controller) checkLastMember(group net.IP) {
+	err := c.igmpSnooper.queryIGMP(group, queryVersion)
+	if err != nil {
+		klog.ErrorS(err, "Failed to send IGMP query message", "group", group.String())
+		return
+	}
+	c.queue.AddAfter(group.String(), igmpMaxResponseTime)
+}
+
+// clearStaleGroups checks the stale groups which have not been updated for mcastGroupTimeout, and then notifies worker
+// to remove them from groupCache.
+func (c *Controller) clearStaleGroups() {
+	now := time.Now()
+	for _, obj := range c.groupCache.List() {
+		status := obj.(*GroupMemberStatus)
+		status.mutex.RLock()
+		diff := now.Sub(status.lastIGMPReport)
+		status.mutex.RUnlock()
+		if diff > mcastGroupTimeout {
+			c.queue.Add(status.group.String())
+		}
+	}
+}
+
+type Controller struct {
+	ofClient     openflow.Client
+	nodeConfig   *config.NodeConfig
+	igmpSnooper  *IGMPSnooper
+	groupEventCh chan *mcastGroupEvent
+	groupCache   cache.Indexer
+	queue        workqueue.RateLimitingInterface
+	// installedGroups saves the groups which are configured on both OVS and the host.
+	installedGroups      sets.String
+	installedGroupsMutex sync.RWMutex
+}
+
+func NewMulticastController(ofClient openflow.Client, nodeConfig *config.NodeConfig, ifaceStore interfacestore.InterfaceStore) *Controller {
+	eventCh := make(chan *mcastGroupEvent, workerCount)
+	groupSnooper := newSnooper(ofClient, ifaceStore, eventCh)
+	groupCache := cache.NewIndexer(getGroupEventKey, cache.Indexers{
+		podInterfaceIndex: podInterfaceIndexFunc,
+	})
+	return &Controller{
+		ofClient:        ofClient,
+		nodeConfig:      nodeConfig,
+		igmpSnooper:     groupSnooper,
+		groupEventCh:    eventCh,
+		groupCache:      groupCache,
+		installedGroups: sets.NewString(),
+		queue:           workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "multicastgroup"),
+	}
+}
+
+func (c *Controller) Initialize() error {
+	// Install flows on OVS for IGMP packets and multicast traffic forwarding:
+	// 1) send the IGMP report messages to Antrea Agent,
+	// 2) forward the IMGP query messages to all local Pods,
+	// 3) forward the multicast traffic to antrea-gw0 if no local Pods have joined in the group, and this is to ensure
+	//    local Pods can access the external multicast receivers.
+	err := c.ofClient.InstallMulticastInitialFlows(uint8(openflow.PacketInReasonMC))
+	if err != nil {
+		klog.ErrorS(err, "Failed to install multicast initial flows")
+		return err
+	}
+	return nil
+}
+
+func (c *Controller) Run(stopCh <-chan struct{}) {
+	// Periodically query Multicast Groups on OVS.
+	go wait.NonSlidingUntil(func() {
+		if err := c.igmpSnooper.queryIGMP(net.IPv4zero, queryVersion); err != nil {
+			klog.ErrorS(err, "Failed to send IGMP query")
+		}
+	}, queryInterval, stopCh)
+
+	// Periodically check the group member status, and remove the groups in which no members exist
+	go wait.NonSlidingUntil(c.clearStaleGroups, queryInterval, stopCh)
+
+	go c.eventHandler(stopCh)
+
+	for i := 0; i < int(workerCount); i++ {
+		// Process multicast Group membership report or leave messages.
+		go wait.Until(c.worker, time.Second, stopCh)
+	}
+}
+
+func (c *Controller) worker() {
+	for c.processNextWorkItem() {
+	}
+}
+
+// getGroupMemberStatusByGroup returns the GroupMemberStatus according to the given group.
+func (c *Controller) getGroupMemberStatusByGroup(group net.IP) *GroupMemberStatus {
+	status, ok, _ := c.groupCache.GetByKey(group.String())
+	if ok {
+		return status.(*GroupMemberStatus)
+	}
+	return nil
+}
+
+// getGroupMemberStatusesByPod returns all GroupMemberStatus that the given podInterface is included in its localMembers.
+func (c *Controller) getGroupMemberStatusesByPod(podInterface string) []*GroupMemberStatus {
+	groupMembers := make([]*GroupMemberStatus, 0)
+	statuses, _ := c.groupCache.ByIndex(podInterfaceIndex, podInterface)
+	for _, s := range statuses {
+		groupMembers = append(groupMembers, s.(*GroupMemberStatus))
+	}
+	return groupMembers
+}
+
+func (c *Controller) processNextWorkItem() bool {
+	obj, quit := c.queue.Get()
+	if quit {
+		return false
+	}
+	defer c.queue.Done(obj)
+
+	// We expect string (multicast group) to come off the workqueue.
+	if key, ok := obj.(string); !ok {
+		// As the item in the workqueue is actually invalid, we call Forget here else we'd
+		// go into a loop of attempting to process a work item that is invalid.
+		// This should not happen.
+		c.queue.Forget(obj)
+		klog.Errorf("Expected string in work queue but got %#v", obj)
+		return true
+	} else if err := c.syncGroup(key); err == nil {
+		// If no error occurs we Forget this item so it does not get queued again until
+		// another change happens.
+		c.queue.Forget(key)
+	} else {
+		// Put the item back on the workqueue to handle any transient errors.
+		c.queue.AddRateLimited(key)
+		klog.Errorf("Error syncing multicast group %s, requeuing. Error: %v", key, err)
+	}
+	return true
+
+}
+
+func (c *Controller) syncGroup(groupKey string) error {
+	startTime := time.Now()
+	defer func() {
+		klog.V(4).Infof("Finished syncing GroupMemberStatus for %s. (%v)", groupKey, time.Since(startTime))
+	}()
+	obj, exists, err := c.groupCache.GetByKey(groupKey)
+	if err != nil {
+		klog.ErrorS(err, "Failed to get GroupMemberStatus", "group", groupKey)
+		return err
+	}
+	if !exists {
+		klog.InfoS("multicast group not found in the cache", "group", groupKey)
+		return nil
+	}
+	status := obj.(*GroupMemberStatus)
+	if c.groupHasInstalled(groupKey) {
+		status.mutex.Lock()
+		defer status.mutex.Unlock()
+		if c.groupIsStale(status) {
+			// Remove the multicast flow entry if no local Pod is in the group.
+			if err := c.ofClient.UninstallMulticastFlow(status.group); err != nil {
+				klog.ErrorS(err, "Failed to uninstall multicast flows", "group", groupKey)
+				return err
+			}
+			c.delInstalledGroup(groupKey)
+			c.groupCache.Delete(status)
+			klog.InfoS("Removed multicast group from cache after all members left", "group", groupKey)
+		}
+		return nil
+	}
+	if err := c.ofClient.InstallMulticastFlow(status.group); err != nil {
+		klog.ErrorS(err, "Failed to install multicast flows", "group", status.group)
+		return err
+	}
+	c.addInstalledGroup(groupKey)
+	return nil
+}
+
+// groupIsStale returns true if no local members in the group, or there is no IGMP report received after mcastGroupTimeout.
+func (c *Controller) groupIsStale(status *GroupMemberStatus) bool {
+	membersCount := len(status.localMembers)
+	diff := time.Now().Sub(status.lastIGMPReport)
+	if membersCount == 0 || diff > mcastGroupTimeout {
+		return true
+	}
+	return false
+}
+
+func (c *Controller) groupHasInstalled(groupKey string) bool {
+	c.installedGroupsMutex.RLock()
+	defer c.installedGroupsMutex.RUnlock()
+	return c.installedGroups.Has(groupKey)
+}
+
+func (c *Controller) addInstalledGroup(groupKey string) {
+	c.installedGroupsMutex.Lock()
+	c.installedGroups.Insert(groupKey)
+	c.installedGroupsMutex.Unlock()
+}
+
+func (c *Controller) delInstalledGroup(groupKey string) {
+	c.installedGroupsMutex.Lock()
+	c.installedGroups.Delete(groupKey)
+	c.installedGroupsMutex.Unlock()
+}
+
+func (c *Controller) addOrUpdateGroupEvent(e *mcastGroupEvent) {
+	obj, ok, _ := c.groupCache.GetByKey(e.group.String())
+	switch e.eType {
+	case groupJoin:
+		if !ok {
+			c.addGroupMemberStatus(e)
+		} else {
+			c.updateGroupMemberStatus(obj, e)
+		}
+	case groupLeave:
+		if ok {
+			c.updateGroupMemberStatus(obj, e)
+		}
+	}
+}
+
+func podInterfaceIndexFunc(obj interface{}) ([]string, error) {
+	groupState := obj.(*GroupMemberStatus)
+	podInterfaces := make([]string, 0, len(groupState.localMembers))
+	for podInterface := range groupState.localMembers {
+		podInterfaces = append(podInterfaces, podInterface)
+	}
+	return podInterfaces, nil
+}
+
+func getGroupEventKey(obj interface{}) (string, error) {
+	groupState := obj.(*GroupMemberStatus)
+	return groupState.group.String(), nil
+}
diff --git a/pkg/agent/multicast/mcast_discovery.go b/pkg/agent/multicast/mcast_discovery.go
new file mode 100644
index 000000000..9b83ee593
--- /dev/null
+++ b/pkg/agent/multicast/mcast_discovery.go
@@ -0,0 +1,239 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package multicast
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"time"
+
+	"antrea.io/libOpenflow/openflow13"
+	"antrea.io/libOpenflow/protocol"
+	"antrea.io/libOpenflow/util"
+	"antrea.io/ofnet/ofctrl"
+	"k8s.io/klog/v2"
+
+	"antrea.io/antrea/pkg/agent/interfacestore"
+	"antrea.io/antrea/pkg/agent/openflow"
+)
+
+const (
+	IGMPProtocolNumber = 2
+)
+
+const (
+	// queryInterval is the interval to send IGMP query messages.
+	queryInterval = time.Second * 125
+	// mcastGroupTimeout is the timeout to detect a group as stale if no IGMP report is received within the time.
+	mcastGroupTimeout = queryInterval * 3
+)
+
+var (
+	// igmpMaxResponseTime is the maximum time allowed before sending a responding report which is used for the
+	// "Max Resp Code" field in the IGMP query message. It is also the maximum time to wait for the IGMP report message
+	// when checking the last group member.
+	igmpMaxResponseTime = time.Second * 10
+	// igmpQueryDstMac is the MAC address used in the dst MAC field in the IGMP query message
+	igmpQueryDstMac, _ = net.ParseMAC("01:00:5e:00:00:01")
+	mcastAllHosts      = net.ParseIP("224.0.0.1").To4()
+)
+
+type IGMPSnooper struct {
+	ofClient   openflow.Client
+	ifaceStore interfacestore.InterfaceStore
+	eventCh    chan *mcastGroupEvent
+}
+
+func (s *IGMPSnooper) HandlePacketIn(pktIn *ofctrl.PacketIn) error {
+	matches := pktIn.GetMatches()
+	// Get custom reasons in this packet-in.
+	match := matches.GetMatchByName(openflow.CustomReasonField.GetNXFieldName())
+	customReasons, err := getInfoInReg(match, openflow.CustomReasonField.GetRange().ToNXRange())
+	if err != nil {
+		klog.ErrorS(err, "Received error while unloading customReason from OVS reg", "regField", openflow.CustomReasonField.GetName())
+		return err
+	}
+	if customReasons&openflow.CustomReasonIGMP == openflow.CustomReasonIGMP {
+		return s.processPacketIn(pktIn)
+	}
+	return nil
+}
+
+func getInfoInReg(regMatch *ofctrl.MatchField, rng *openflow13.NXRange) (uint32, error) {
+	regValue, ok := regMatch.GetValue().(*ofctrl.NXRegister)
+	if !ok {
+		return 0, errors.New("register value cannot be retrieved")
+	}
+	if rng != nil {
+		return ofctrl.GetUint32ValueWithRange(regValue.Data, rng), nil
+	}
+	return regValue.Data, nil
+}
+
+func (s *IGMPSnooper) parseSrcInterface(pktIn *ofctrl.PacketIn) (*interfacestore.InterfaceConfig, error) {
+	matches := pktIn.GetMatches()
+	ofPortField := matches.GetMatchByName("OXM_OF_IN_PORT")
+	if ofPortField == nil {
+		return nil, errors.New("in_port field not found")
+	}
+	ofPort := ofPortField.GetValue().(uint32)
+	ifaceConfig, found := s.ifaceStore.GetInterfaceByOFPort(ofPort)
+	if !found {
+		return nil, errors.New("target Pod not found")
+	}
+	return ifaceConfig, nil
+}
+
+func (s *IGMPSnooper) queryIGMP(group net.IP, version uint8) error {
+	igmp, err := generateIGMPQueryPacket(group, version)
+	if err != nil {
+		return err
+	}
+	if err := s.ofClient.SendIGMPQueryPacketOut(igmpQueryDstMac, mcastAllHosts, openflow13.P_NORMAL, igmp); err != nil {
+		return err
+	}
+	klog.V(2).InfoS("Sent packetOut form IGMP query", "group", group.String(), "version", version)
+	return nil
+}
+
+func (s *IGMPSnooper) processPacketIn(pktIn *ofctrl.PacketIn) error {
+	now := time.Now()
+	iface, err := s.parseSrcInterface(pktIn)
+	if err != nil {
+		return err
+	}
+	klog.V(2).InfoS("Received PacketIn for IGMP packet", "in_port", iface.OFPort)
+	igmp, err := parseIGMPPacket(pktIn.Data)
+	if err != nil {
+		return err
+	}
+	switch igmp.GetMessageType() {
+	case protocol.IGMPv1Report:
+		fallthrough
+	case protocol.IGMPv2Report:
+		mgroup := igmp.(*protocol.IGMPv1or2).GroupAddress
+		klog.InfoS("Received IGMPv1or2 Report message", "group", mgroup.String(), "interface", iface.PodName)
+		event := &mcastGroupEvent{
+			group: mgroup,
+			eType: groupJoin,
+			time:  now,
+			iface: iface,
+		}
+		s.eventCh <- event
+	case protocol.IGMPv3Report:
+		msg := igmp.(*protocol.IGMPv3MembershipReport)
+		for _, gr := range msg.GroupRecords {
+			mgroup := gr.MulticastAddress
+			klog.InfoS("Received IGMPv3 Report message", "group", mgroup.String(), "interface", iface.PodName)
+			event := &mcastGroupEvent{
+				group: mgroup,
+				eType: groupJoin,
+				time:  now,
+				iface: iface,
+			}
+			s.eventCh <- event
+		}
+
+	case protocol.IGMPv2LeaveGroup:
+		mgroup := igmp.(*protocol.IGMPv1or2).GroupAddress
+		klog.InfoS("Received IGMPv2 Leave message", "group", mgroup.String(), "interface", iface.PodName)
+		event := &mcastGroupEvent{
+			group: mgroup,
+			eType: groupLeave,
+			time:  now,
+			iface: iface,
+		}
+		s.eventCh <- event
+	}
+	return nil
+}
+
+func generateIGMPQueryPacket(group net.IP, version uint8) (util.Message, error) {
+	// The max response time field in IGMP protocol uses a value in units of 1/10 second.
+	// See https://datatracker.ietf.org/doc/html/rfc2236 and https://datatracker.ietf.org/doc/html/rfc3376
+	respTime := uint8(igmpMaxResponseTime.Seconds() * 10)
+	switch version {
+	case 1:
+		return &protocol.IGMPv1or2{
+			Type:            protocol.IGMPQuery,
+			MaxResponseTime: 0,
+			Checksum:        0,
+			GroupAddress:    group,
+		}, nil
+	case 2:
+		return &protocol.IGMPv1or2{
+			Type:            protocol.IGMPQuery,
+			MaxResponseTime: respTime,
+			Checksum:        0,
+			GroupAddress:    group,
+		}, nil
+	case 3:
+		return &protocol.IGMPv3Query{
+			Type:                     protocol.IGMPQuery,
+			MaxResponseTime:          respTime,
+			GroupAddress:             group,
+			SuppressRouterProcessing: false,
+			RobustnessValue:          0,
+			IntervalTime:             uint8(queryInterval.Seconds()),
+			NumberOfSources:          0,
+		}, nil
+	}
+	return nil, fmt.Errorf("unsupported IGMP version %d", version)
+}
+
+func parseIGMPPacket(pkt protocol.Ethernet) (protocol.IGMPMessage, error) {
+	if pkt.Ethertype != protocol.IPv4_MSG {
+		return nil, errors.New("not IPv4 packet")
+	}
+	ipPacket, ok := pkt.Data.(*protocol.IPv4)
+	if !ok {
+		return nil, errors.New("failed to parse IPv4 packet")
+	}
+	if ipPacket.Protocol != IGMPProtocolNumber {
+		return nil, errors.New("not IGMP packet")
+	}
+	data, _ := ipPacket.Data.MarshalBinary()
+	igmpLength := ipPacket.Length - uint16(4*ipPacket.IHL)
+	if igmpLength == 8 {
+		igmp := new(protocol.IGMPv1or2)
+		if err := igmp.UnmarshalBinary(data); err != nil {
+			return nil, err
+		}
+		return igmp, nil
+	}
+	switch data[0] {
+	case protocol.IGMPQuery:
+		igmp := new(protocol.IGMPv3Query)
+		if err := igmp.UnmarshalBinary(data); err != nil {
+			return nil, err
+		}
+		return igmp, nil
+	case protocol.IGMPv3Report:
+		igmp := new(protocol.IGMPv3MembershipReport)
+		if err := igmp.UnmarshalBinary(data); err != nil {
+			return nil, err
+		}
+		return igmp, nil
+	default:
+		return nil, errors.New("unknown igmp packet")
+	}
+}
+
+func newSnooper(ofClient openflow.Client, ifaceStore interfacestore.InterfaceStore, eventCh chan *mcastGroupEvent) *IGMPSnooper {
+	d := &IGMPSnooper{ofClient: ofClient, ifaceStore: ifaceStore, eventCh: eventCh}
+	ofClient.RegisterPacketInHandler(uint8(openflow.PacketInReasonMC), "MulticastGroupDiscovery", d)
+	return d
+}
diff --git a/pkg/agent/openflow/client.go b/pkg/agent/openflow/client.go
index bf9407f0e..0b807d67e 100644
--- a/pkg/agent/openflow/client.go
+++ b/pkg/agent/openflow/client.go
@@ -20,6 +20,7 @@ import (
 	"net"
 
 	"antrea.io/libOpenflow/protocol"
+	ofutil "antrea.io/libOpenflow/util"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/klog/v2"
 
@@ -299,6 +300,20 @@ type Client interface {
 	AddAddressToDNSConjunction(id uint32, addrs []types.Address) error
 	// DeleteAddressFromDNSConjunction removes addresses from the toAddresses of the dns packetIn conjunction.
 	DeleteAddressFromDNSConjunction(id uint32, addrs []types.Address) error
+	// InstallMulticastInitialFlows installs OpenFlow to packetIn the IGMP messages and output the Multicast traffic to
+	// antrea-gw0 so that local Pods could access external Multicast servers.
+	InstallMulticastInitialFlows(pktInReason uint8) error
+	// InstallMulticastFlow installs the flow to forward Multicast traffic normally, and output it to antrea-gw0
+	// to ensure it can be forwarded to the external addresses.
+	InstallMulticastFlow(multicastIP net.IP) error
+	// UninstallMulticastFlow removes the flow matching the given multicastIP.
+	UninstallMulticastFlow(multicastIP net.IP) error
+	// SendIGMPQueryPacketOut sends the IGMPQuery packet as a packet-out to OVS from the gateway port.
+	SendIGMPQueryPacketOut(
+		dstMAC net.HardwareAddr,
+		dstIP net.IP,
+		outPort uint32,
+		igmp ofutil.Message) error
 }
 
 // GetFlowTableStatus returns an array of flow table status.
@@ -1196,3 +1211,47 @@ func (c *client) SendUDPPacketOut(
 	packetOutObj := packetOutBuilder.Done()
 	return c.bridge.SendPacketOut(packetOutObj)
 }
+
+func (c *client) InstallMulticastInitialFlows(pktInReason uint8) error {
+	flows := c.igmpPktInFlows(pktInReason)
+	flows = append(flows, c.externalMulticastReceiverFlow())
+	cacheKey := fmt.Sprintf("multicast")
+	c.replayMutex.RLock()
+	defer c.replayMutex.RUnlock()
+	return c.addFlows(c.mcastFlowCache, cacheKey, flows)
+}
+
+func (c *client) InstallMulticastFlow(multicastIP net.IP) error {
+	flows := c.localMulticastForwardFlow(multicastIP)
+	cacheKey := fmt.Sprintf("multicast_%s", multicastIP.String())
+	c.replayMutex.RLock()
+	defer c.replayMutex.RUnlock()
+	return c.addFlows(c.mcastFlowCache, cacheKey, flows)
+}
+
+func (c *client) UninstallMulticastFlow(multicastIP net.IP) error {
+	c.replayMutex.RLock()
+	defer c.replayMutex.RUnlock()
+	cacheKey := fmt.Sprintf("multicast_%s", multicastIP.String())
+	return c.deleteFlows(c.mcastFlowCache, cacheKey)
+}
+
+func (c *client) SendIGMPQueryPacketOut(
+	dstMAC net.HardwareAddr,
+	dstIP net.IP,
+	outPort uint32,
+	igmp ofutil.Message) error {
+	// Generate a base IP PacketOutBuilder.
+	srcMAC := c.nodeConfig.GatewayConfig.MAC.String()
+	srcIP := c.nodeConfig.GatewayConfig.IPv4.String()
+	dstMACStr := dstMAC.String()
+	dstIPStr := dstIP.String()
+	packetOutBuilder, err := setBasePacketOutBuilder(c.bridge.BuildPacketOut(), srcMAC, dstMACStr, srcIP, dstIPStr, config.HostGatewayOFPort, outPort)
+	if err != nil {
+		return err
+	}
+	// Set protocol and L4 message.
+	packetOutBuilder = packetOutBuilder.SetIPProtocol(binding.ProtocolIGMP).SetL4Packet(igmp)
+	packetOutObj := packetOutBuilder.Done()
+	return c.bridge.SendPacketOut(packetOutObj)
+}
diff --git a/pkg/agent/openflow/fields.go b/pkg/agent/openflow/fields.go
index ef1d41bd5..d2611fbd8 100644
--- a/pkg/agent/openflow/fields.go
+++ b/pkg/agent/openflow/fields.go
@@ -56,15 +56,17 @@ var (
 	DispositionPassRegMark  = binding.NewRegMark(APDispositionField, DispositionPass)
 	// reg0[24..27]: Field to indicate the reasons of sending packet to the controller.
 	// Marks in this field include,
-	//   - 0b0001: logging
-	//   - 0b0010: reject
-	//   - 0b0100: deny (used by Flow Exporter)
-	//   - 0b1000: DNS packet (used by FQDN)
-	CustomReasonField          = binding.NewRegField(0, 24, 27, "PacketInReason")
+	//   - 0b00001: logging
+	//   - 0b00010: reject
+	//   - 0b00100: deny (used by Flow Exporter)
+	//   - 0b01000: DNS packet (used by FQDN)
+	//   - 0b10000: IGMP packet (used by Multicast)
+	CustomReasonField          = binding.NewRegField(0, 24, 28, "PacketInReason")
 	CustomReasonLoggingRegMark = binding.NewRegMark(CustomReasonField, CustomReasonLogging)
 	CustomReasonRejectRegMark  = binding.NewRegMark(CustomReasonField, CustomReasonReject)
 	CustomReasonDenyRegMark    = binding.NewRegMark(CustomReasonField, CustomReasonDeny)
 	CustomReasonDNSRegMark     = binding.NewRegMark(CustomReasonField, CustomReasonDNS)
+	CustomReasonIGMPRegMark    = binding.NewRegMark(CustomReasonField, CustomReasonIGMP)
 
 	// reg1(NXM_NX_REG1)
 	// Field to cache the ofPort of the OVS interface where to output packet.
diff --git a/pkg/agent/openflow/packetin.go b/pkg/agent/openflow/packetin.go
index 7874f120c..b51e534ac 100644
--- a/pkg/agent/openflow/packetin.go
+++ b/pkg/agent/openflow/packetin.go
@@ -42,7 +42,12 @@ const (
 
 	// PacketIn reasons
 	PacketInReasonTF ofpPacketInReason = 1
+	// PacketInReasonNP is used for the custom packetIn reasons for Network Policy, including: Logging, Reject, Deny.
+	// It is also used to mark the DNS Response packet.
 	PacketInReasonNP ofpPacketInReason = 0
+	// PacketInReasonMC shares PacketInReasonNP for IGMP packet_in message. This is because OVS "controller" action
+	// only correctly supports reason 0 or 1. Change to another value after the OVS action is corrected.
+	PacketInReasonMC = PacketInReasonNP
 	// PacketInQueueSize defines the size of PacketInQueue.
 	// When PacketInQueue reaches PacketInQueueSize, new packet-in will be dropped.
 	PacketInQueueSize = 200
@@ -111,6 +116,7 @@ func (c *client) parsePacketIn(featurePacketIn *featureStartPacketIn) {
 		}
 		// Use corresponding handlers subscribed to the reason to handle PacketIn
 		for name, handler := range c.packetInHandlers[featurePacketIn.reason] {
+			klog.V(2).InfoS("Received packetIn", "reason", featurePacketIn.reason, "handler", name)
 			err := handler.HandlePacketIn(pktIn)
 			if err != nil {
 				klog.Errorf("PacketIn handler %s failed to process packet: %+v", name, err)
diff --git a/pkg/agent/openflow/pipeline.go b/pkg/agent/openflow/pipeline.go
index 6c39f519b..e0365fada 100644
--- a/pkg/agent/openflow/pipeline.go
+++ b/pkg/agent/openflow/pipeline.go
@@ -46,6 +46,7 @@ var (
 	SpoofGuardTable              = binding.NewOFTable(10, "SpoofGuard")
 	arpResponderTable            = binding.NewOFTable(20, "ARPResponder")
 	IPv6Table                    = binding.NewOFTable(21, "IPv6")
+	MulticastTable               = binding.NewOFTable(22, "Multicast")
 	ServiceHairpinTable          = binding.NewOFTable(23, "ServiceHairpin")
 	ServiceConntrackTable        = binding.NewOFTable(24, "ServiceConntrack") // serviceConntrackTable use a new ct_zone to transform SNAT'd connections.
 	ConntrackTable               = binding.NewOFTable(30, "ConntrackZone")
@@ -234,6 +235,7 @@ const (
 	// policy rules.
 	CustomReasonDeny = 0b100
 	CustomReasonDNS  = 0b1000
+	CustomReasonIGMP = 0b10000
 )
 
 var DispositionToString = map[uint32]string{
@@ -255,6 +257,10 @@ var (
 	GlobalVirtualMAC, _ = net.ParseMAC("aa:bb:cc:dd:ee:ff")
 	hairpinIP           = net.ParseIP("169.254.169.252").To4()
 	hairpinIPv6         = net.ParseIP("fc00::aabb:ccdd:eeff").To16()
+
+	mcastAllRouters = net.ParseIP("224.0.0.2")
+	igmpV3ReportDst = net.ParseIP("224.0.0.22")
+	_, mcastCIDR, _ = net.ParseCIDR("224.0.0.0/4")
 )
 
 type OFEntryOperations interface {
@@ -290,6 +296,7 @@ type client struct {
 	enableDenyTracking    bool
 	enableEgress          bool
 	enableWireGuard       bool
+	enableMulticast       bool
 	connectUplinkToBridge bool
 	roundInfo             types.RoundInfo
 	cookieAllocator       cookie.Allocator
@@ -297,7 +304,7 @@ type client struct {
 	egressEntryTable      uint8
 	ingressEntryTable     uint8
 	// Flow caches for corresponding deletions.
-	nodeFlowCache, podFlowCache, serviceFlowCache, snatFlowCache, tfFlowCache *flowCategoryCache
+	nodeFlowCache, podFlowCache, serviceFlowCache, snatFlowCache, tfFlowCache, mcastFlowCache *flowCategoryCache
 	// "fixed" flows installed by the agent after initialization and which do not change during
 	// the lifetime of the client.
 	gatewayFlows, defaultServiceFlows, defaultTunnelFlows, hostNetworkingFlows []binding.Flow
@@ -2542,8 +2549,11 @@ func (c *client) genPacketInMeter(meterID binding.MeterIDType, rate uint32) bind
 
 func (c *client) generatePipeline() {
 	c.createOFTable(ClassifierTable, SpoofGuardTable.GetID(), binding.TableMissActionDrop)
+	c.createOFTable(SpoofGuardTable, ConntrackTable.GetID(), binding.TableMissActionDrop)
+	c.createOFTable(IPv6Table, ConntrackTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(arpResponderTable, binding.LastTableID, binding.TableMissActionDrop)
 	c.createOFTable(ConntrackTable, ConntrackStateTable.GetID(), binding.TableMissActionNone)
+	c.createOFTable(ConntrackStateTable, DNATTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(EgressRuleTable, EgressDefaultTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(EgressDefaultTable, EgressMetricTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(EgressMetricTable, L3ForwardingTable.GetID(), binding.TableMissActionNext)
@@ -2554,10 +2564,12 @@ func (c *client) generatePipeline() {
 	c.createOFTable(IngressRuleTable, IngressDefaultTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(IngressDefaultTable, IngressMetricTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(IngressMetricTable, ConntrackCommitTable.GetID(), binding.TableMissActionNext)
+	c.createOFTable(ConntrackCommitTable, L2ForwardingOutTable.GetID(), binding.TableMissActionNext)
 	c.createOFTable(L2ForwardingOutTable, binding.LastTableID, binding.TableMissActionDrop)
 	if c.enableProxy {
-		SpoofGuardTable = c.createOFTable(SpoofGuardTable, ServiceHairpinTable.GetID(), binding.TableMissActionDrop)
-		IPv6Table = c.createOFTable(IPv6Table, ServiceHairpinTable.GetID(), binding.TableMissActionNext)
+		SpoofGuardTable.SetNext(ServiceHairpinTable.GetID())
+		IPv6Table.SetNext(ServiceHairpinTable.GetID())
+		ConntrackStateTable.SetNext(EndpointDNATTable.GetID())
 		if c.proxyAll {
 			ServiceHairpinTable = c.createOFTable(ServiceHairpinTable, ServiceConntrackTable.GetID(), binding.TableMissActionNext)
 			ServiceConntrackTable = c.createOFTable(ServiceConntrackTable, ConntrackTable.GetID(), binding.TableMissActionNext)
@@ -2566,18 +2578,13 @@ func (c *client) generatePipeline() {
 		} else {
 			ServiceHairpinTable = c.createOFTable(ServiceHairpinTable, ConntrackTable.GetID(), binding.TableMissActionNext)
 		}
-		ConntrackStateTable = c.createOFTable(ConntrackStateTable, EndpointDNATTable.GetID(), binding.TableMissActionNext)
 		SessionAffinityTable = c.createOFTable(SessionAffinityTable, binding.LastTableID, binding.TableMissActionNone)
 		ServiceLBTable = c.createOFTable(ServiceLBTable, EndpointDNATTable.GetID(), binding.TableMissActionNext)
 		EndpointDNATTable = c.createOFTable(EndpointDNATTable, c.egressEntryTable, binding.TableMissActionNext)
-		ConntrackCommitTable = c.createOFTable(ConntrackCommitTable, HairpinSNATTable.GetID(), binding.TableMissActionNext)
 		HairpinSNATTable = c.createOFTable(HairpinSNATTable, L2ForwardingOutTable.GetID(), binding.TableMissActionNext)
+		ConntrackCommitTable.SetNext(HairpinSNATTable.GetID())
 	} else {
-		c.createOFTable(SpoofGuardTable, ConntrackTable.GetID(), binding.TableMissActionDrop)
-		c.createOFTable(IPv6Table, ConntrackTable.GetID(), binding.TableMissActionNext)
-		c.createOFTable(ConntrackStateTable, DNATTable.GetID(), binding.TableMissActionNext)
 		c.createOFTable(DNATTable, c.egressEntryTable, binding.TableMissActionNext)
-		c.createOFTable(ConntrackCommitTable, L2ForwardingOutTable.GetID(), binding.TableMissActionNext)
 	}
 	// The default SNAT is implemented with OVS on Windows.
 	if c.enableEgress || runtime.IsWindowsPlatform() {
@@ -2590,6 +2597,13 @@ func (c *client) generatePipeline() {
 		c.createOFTable(AntreaPolicyEgressRuleTable, EgressRuleTable.GetID(), binding.TableMissActionNext)
 		c.createOFTable(AntreaPolicyIngressRuleTable, IngressRuleTable.GetID(), binding.TableMissActionNext)
 	}
+	if c.enableMulticast {
+		SpoofGuardTable.SetNext(MulticastTable.GetID())
+		c.createOFTable(MulticastTable, ConntrackTable.GetID(), binding.TableMissActionNext)
+		if c.enableProxy {
+			MulticastTable.SetNext(ServiceHairpinTable.GetID())
+		}
+	}
 }
 
 // createOFTable sets the missAction and the next table ID of the given table according to the pipeline. Then it creates the table on the bridge. At last, it adds the table into the ofTableCache.
@@ -2599,6 +2613,52 @@ func (c *client) createOFTable(table binding.Table, nextID uint8, missAction bin
 	return table
 }
 
+// igmpPktInFlows sets reg0[28] to mark the IGMP packet in MulticastTable and sends it to antrea-agent on MulticastTable.
+func (c *client) igmpPktInFlows(reason uint8) []binding.Flow {
+	var flows []binding.Flow
+	for _, dstIP := range []net.IP{mcastAllRouters, igmpV3ReportDst} {
+		// Set a custom reason for the IGMP packets, and then send it to antrea-agent and forward it normally in the
+		// OVS bridge, so that the OVS multicast db cache can be updated, and antrea-agent can identify the local multicast
+		// group and its members in the meanwhile.
+		flows = append(flows, MulticastTable.BuildFlow(priorityHigh).
+			MatchProtocol(binding.ProtocolIGMP).
+			MatchRegMark(FromLocalRegMark).
+			MatchDstIP(dstIP).
+			Action().LoadRegMark(CustomReasonIGMPRegMark).
+			Action().SendToController(reason).
+			Action().Normal().
+			Done())
+	}
+	return flows
+}
+
+// localMulticastForwardFlow forwards the multicast traffic with OVS action "normal", and outputs it to antrea-gw0 in
+// the meanwhile. So that the packet could be forwarded to local Pods which have joined the Multicast group and to the
+// external receivers. For the external multicast traffic accessing to the given multicastIP also hits this flow, and
+// the packet is not sent back to antrea-gw0 because OVS datapath will drop it when it finds the output port is the same
+// as the input port.
+func (c *client) localMulticastForwardFlow(multicastIP net.IP) []binding.Flow {
+	return []binding.Flow{
+		MulticastTable.BuildFlow(priorityNormal).
+			MatchProtocol(binding.ProtocolIP).
+			MatchDstIP(multicastIP).
+			Action().Output(config.HostGatewayOFPort).
+			Action().Normal().
+			Done(),
+	}
+}
+
+// externalMulticastReceiverFlow outputs the multicast traffic to antrea-gw0, so that local Pods can send multicast traffic
+// to access the external receivers. For the case that one or more local Pods have joined the target multicast group,
+// it is handled by the flows created by function "localMulticastForwardFlow" after local Pods report the IGMP membership.
+func (c *client) externalMulticastReceiverFlow() binding.Flow {
+	return MulticastTable.BuildFlow(priorityLow).
+		MatchProtocol(binding.ProtocolIP).
+		MatchDstIPNet(*mcastCIDR).
+		Action().Output(config.HostGatewayOFPort).
+		Done()
+}
+
 // NewClient is the constructor of the Client interface.
 func NewClient(bridgeName string,
 	mgmtAddr string,
@@ -2608,7 +2668,8 @@ func NewClient(bridgeName string,
 	enableEgress bool,
 	enableDenyTracking bool,
 	proxyAll bool,
-	connectUplinkToBridge bool) Client {
+	connectUplinkToBridge bool,
+	enableMulticast bool) Client {
 	bridge := binding.NewOFBridge(bridgeName, mgmtAddr)
 	policyCache := cache.NewIndexer(
 		policyConjKeyFunc,
@@ -2621,6 +2682,7 @@ func NewClient(bridgeName string,
 		enableAntreaPolicy:       enableAntreaPolicy,
 		enableDenyTracking:       enableDenyTracking,
 		enableEgress:             enableEgress,
+		enableMulticast:          enableMulticast,
 		connectUplinkToBridge:    connectUplinkToBridge,
 		nodeFlowCache:            newFlowCategoryCache(),
 		podFlowCache:             newFlowCategoryCache(),
@@ -2643,6 +2705,9 @@ func NewClient(bridgeName string,
 	if enableEgress {
 		c.snatFlowCache = newFlowCategoryCache()
 	}
+	if enableMulticast {
+		c.mcastFlowCache = newFlowCategoryCache()
+	}
 	c.generatePipeline()
 	return c
 }
diff --git a/pkg/antctl/command_list.go b/pkg/antctl/command_list.go
index 94e8352d3..905562831 100644
--- a/pkg/antctl/command_list.go
+++ b/pkg/antctl/command_list.go
@@ -114,8 +114,8 @@ func (cl *commandList) validate() []error {
 	return errs
 }
 
-// GetDebugCommands returns all commands supported by Controller or Agent that
-// are used for debugging purpose.
+// GetDebugCommands returns all commands supported by Controller, Agent or Flow Aggregator
+// that are used for debugging purpose.
 func (cl *commandList) GetDebugCommands(mode string) [][]string {
 	var allCommands [][]string
 	for _, def := range cl.definitions {
diff --git a/pkg/apis/controlplane/v1beta2/marshal.go b/pkg/apis/controlplane/v1beta2/marshal.go
new file mode 100644
index 000000000..622de9b7b
--- /dev/null
+++ b/pkg/apis/controlplane/v1beta2/marshal.go
@@ -0,0 +1,23 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1beta2
+
+import (
+	"net"
+)
+
+func (a IPAddress) MarshalYAML() (interface{}, error) {
+	return net.IP(a).String(), nil
+}
diff --git a/pkg/apis/crd/v1alpha2/types.go b/pkg/apis/crd/v1alpha2/types.go
index 7c3cd18ba..bb34ded54 100644
--- a/pkg/apis/crd/v1alpha2/types.go
+++ b/pkg/apis/crd/v1alpha2/types.go
@@ -359,15 +359,24 @@ type IPAddressState struct {
 }
 
 type IPAddressOwner struct {
-	Pod *PodOwner `json:"pod,omitempty"`
+	Pod         *PodOwner         `json:"pod,omitempty"`
+	StatefulSet *StatefulSetOwner `json:"statefulSet,omitempty"`
 }
 
+// Pod owner
 type PodOwner struct {
 	Name        string `json:"name"`
 	Namespace   string `json:"namespace"`
 	ContainerID string `json:"containerID"`
 }
 
+// StatefulSet owner
+type StatefulSetOwner struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+	Index     int    `json:"index"`
+}
+
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
 type IPPoolList struct {
diff --git a/pkg/apis/crd/v1alpha2/zz_generated.deepcopy.go b/pkg/apis/crd/v1alpha2/zz_generated.deepcopy.go
index daae31c32..e4696b5e2 100644
--- a/pkg/apis/crd/v1alpha2/zz_generated.deepcopy.go
+++ b/pkg/apis/crd/v1alpha2/zz_generated.deepcopy.go
@@ -528,6 +528,11 @@ func (in *IPAddressOwner) DeepCopyInto(out *IPAddressOwner) {
 		*out = new(PodOwner)
 		**out = **in
 	}
+	if in.StatefulSet != nil {
+		in, out := &in.StatefulSet, &out.StatefulSet
+		*out = new(StatefulSetOwner)
+		**out = **in
+	}
 	return
 }
 
@@ -727,6 +732,22 @@ func (in *ServiceReference) DeepCopy() *ServiceReference {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *StatefulSetOwner) DeepCopyInto(out *StatefulSetOwner) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatefulSetOwner.
+func (in *StatefulSetOwner) DeepCopy() *StatefulSetOwner {
+	if in == nil {
+		return nil
+	}
+	out := new(StatefulSetOwner)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *SubnetIPRange) DeepCopyInto(out *SubnetIPRange) {
 	*out = *in
diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go
index b49ed894a..777a7b0a3 100644
--- a/pkg/apiserver/apiserver.go
+++ b/pkg/apiserver/apiserver.go
@@ -54,6 +54,7 @@ import (
 	"antrea.io/antrea/pkg/apiserver/registry/system/supportbundle"
 	"antrea.io/antrea/pkg/apiserver/storage"
 	"antrea.io/antrea/pkg/controller/egress"
+	"antrea.io/antrea/pkg/controller/externalippool"
 	"antrea.io/antrea/pkg/controller/ipam"
 	controllernetworkpolicy "antrea.io/antrea/pkg/controller/networkpolicy"
 	"antrea.io/antrea/pkg/controller/querier"
@@ -101,6 +102,7 @@ type ExtraConfig struct {
 	endpointQuerier               controllernetworkpolicy.EndpointQuerier
 	networkPolicyController       *controllernetworkpolicy.NetworkPolicyController
 	egressController              *egress.EgressController
+	externalIPPoolController      *externalippool.ExternalIPPoolController
 	caCertController              *certificate.CACertController
 	statsAggregator               *stats.Aggregator
 	networkPolicyStatusController *controllernetworkpolicy.StatusController
@@ -308,8 +310,8 @@ func installHandlers(c *ExtraConfig, s *genericapiserver.GenericAPIServer) {
 	}
 
 	if features.DefaultFeatureGate.Enabled(features.Egress) {
-		s.Handler.NonGoRestfulMux.HandleFunc("/validate/externalippool", webhook.HandlerForValidateFunc(c.egressController.ValidateExternalIPPool))
 		s.Handler.NonGoRestfulMux.HandleFunc("/validate/egress", webhook.HandlerForValidateFunc(c.egressController.ValidateEgress))
+		s.Handler.NonGoRestfulMux.HandleFunc("/validate/externalippool", webhook.HandlerForValidateFunc(c.externalIPPoolController.ValidateExternalIPPool))
 	}
 
 	if features.DefaultFeatureGate.Enabled(features.AntreaIPAM) {
diff --git a/pkg/apiserver/handlers/featuregates/handler.go b/pkg/apiserver/handlers/featuregates/handler.go
index 2587d8e2d..924440484 100644
--- a/pkg/apiserver/handlers/featuregates/handler.go
+++ b/pkg/apiserver/handlers/featuregates/handler.go
@@ -30,7 +30,7 @@ import (
 )
 
 var controllerGates = sets.NewString("Traceflow", "AntreaPolicy", "Egress", "NetworkPolicyStats", "NodeIPAM")
-var agentGates = sets.NewString("AntreaPolicy", "AntreaProxy", "Egress", "EndpointSlice", "Traceflow", "FlowExporter", "NetworkPolicyStats", "NodePortLocal", "AntreaIPAM")
+var agentGates = sets.NewString("AntreaPolicy", "AntreaProxy", "Egress", "EndpointSlice", "Traceflow", "FlowExporter", "NetworkPolicyStats", "NodePortLocal", "AntreaIPAM", "Multicast")
 
 type (
 	Config struct {
diff --git a/pkg/controller/egress/controller.go b/pkg/controller/egress/controller.go
index 2f96f0dc9..d7df808c0 100644
--- a/pkg/controller/egress/controller.go
+++ b/pkg/controller/egress/controller.go
@@ -17,24 +17,21 @@ package egress
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"net"
 	"reflect"
 	"sync"
 	"time"
 
-	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
-	"k8s.io/client-go/util/retry"
 	"k8s.io/client-go/util/workqueue"
 	"k8s.io/klog/v2"
-	utilnet "k8s.io/utils/net"
 
 	"antrea.io/antrea/pkg/apis/controlplane"
 	egressv1alpha2 "antrea.io/antrea/pkg/apis/crd/v1alpha2"
@@ -42,11 +39,9 @@ import (
 	clientset "antrea.io/antrea/pkg/client/clientset/versioned"
 	egressinformers "antrea.io/antrea/pkg/client/informers/externalversions/crd/v1alpha2"
 	egresslisters "antrea.io/antrea/pkg/client/listers/crd/v1alpha2"
+	"antrea.io/antrea/pkg/controller/externalippool"
 	"antrea.io/antrea/pkg/controller/grouping"
-	"antrea.io/antrea/pkg/controller/metrics"
 	antreatypes "antrea.io/antrea/pkg/controller/types"
-	"antrea.io/antrea/pkg/ipam/ipallocator"
-	iputil "antrea.io/antrea/pkg/util/ip"
 )
 
 const (
@@ -64,10 +59,6 @@ const (
 	externalIPPoolIndex = "externalIPPool"
 )
 
-var (
-	externalIPPoolNotFound = errors.New("ExternalIPPool not found")
-)
-
 // ipAllocation contains the IP and the IP Pool which allocates it.
 type ipAllocation struct {
 	ip     net.IP
@@ -76,12 +67,9 @@ type ipAllocation struct {
 
 // EgressController is responsible for synchronizing the EgressGroups selected by Egresses.
 type EgressController struct {
-	crdClient                  clientset.Interface
-	externalIPPoolLister       egresslisters.ExternalIPPoolLister
-	externalIPPoolListerSynced cache.InformerSynced
-	// ipAllocatorMap is a map from ExternalIPPool name to MultiIPAllocator.
-	ipAllocatorMap   map[string]ipallocator.MultiIPAllocator
-	ipAllocatorMutex sync.RWMutex
+	crdClient clientset.Interface
+
+	externalIPAllocator externalippool.ExternalIPAllocator
 
 	// ipAllocationMap is a map from Egress name to ipAllocation, which is used to check whether the Egress's IP has
 	// changed and to release the IP after the Egress is removed.
@@ -97,8 +85,6 @@ type EgressController struct {
 	egressGroupStore storage.Interface
 	// queue maintains the EgressGroup objects that need to be synced.
 	queue workqueue.RateLimitingInterface
-	// poolQueue maintains the ExternalIPPool objects that need to be synced.
-	poolQueue workqueue.RateLimitingInterface
 	// groupingInterface knows Pods that a given group selects.
 	groupingInterface grouping.Interface
 	// Added as a member to the struct to allow injection for testing.
@@ -109,23 +95,20 @@ type EgressController struct {
 func NewEgressController(crdClient clientset.Interface,
 	groupingInterface grouping.Interface,
 	egressInformer egressinformers.EgressInformer,
-	externalIPPoolInformer egressinformers.ExternalIPPoolInformer,
+	externalIPAllocator externalippool.ExternalIPAllocator,
 	egressGroupStore storage.Interface) *EgressController {
 	c := &EgressController{
-		crdClient:                  crdClient,
-		egressInformer:             egressInformer,
-		egressLister:               egressInformer.Lister(),
-		egressListerSynced:         egressInformer.Informer().HasSynced,
-		egressIndexer:              egressInformer.Informer().GetIndexer(),
-		externalIPPoolLister:       externalIPPoolInformer.Lister(),
-		externalIPPoolListerSynced: externalIPPoolInformer.Informer().HasSynced,
-		egressGroupStore:           egressGroupStore,
-		queue:                      workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "egress"),
-		poolQueue:                  workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "externalIPPool"),
-		groupingInterface:          groupingInterface,
-		groupingInterfaceSynced:    groupingInterface.HasSynced,
-		ipAllocatorMap:             map[string]ipallocator.MultiIPAllocator{},
-		ipAllocationMap:            map[string]*ipAllocation{},
+		crdClient:               crdClient,
+		egressInformer:          egressInformer,
+		egressLister:            egressInformer.Lister(),
+		egressListerSynced:      egressInformer.Informer().HasSynced,
+		egressIndexer:           egressInformer.Informer().GetIndexer(),
+		egressGroupStore:        egressGroupStore,
+		queue:                   workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "egress"),
+		groupingInterface:       groupingInterface,
+		groupingInterfaceSynced: groupingInterface.HasSynced,
+		ipAllocationMap:         map[string]*ipAllocation{},
+		externalIPAllocator:     externalIPAllocator,
 	}
 	// Add handlers for Group events and Egress events.
 	c.groupingInterface.AddEventHandler(egressGroupType, c.enqueueEgressGroup)
@@ -148,143 +131,55 @@ func NewEgressController(crdClient clientset.Interface,
 		}
 		return []string{egress.Spec.ExternalIPPool}, nil
 	}})
-	externalIPPoolInformer.Informer().AddEventHandlerWithResyncPeriod(
-		cache.ResourceEventHandlerFuncs{
-			AddFunc:    c.addExternalIPPool,
-			UpdateFunc: c.updateExternalIPPool,
-			DeleteFunc: c.deleteExternalIPPool,
-		},
-		resyncPeriod,
-	)
+	c.externalIPAllocator.AddEventHandler(func(ipPool string) {
+		c.enqueueEgresses(ipPool)
+	})
 	return c
 }
 
 // Run begins watching and syncing of the EgressController.
 func (c *EgressController) Run(stopCh <-chan struct{}) {
 	defer c.queue.ShutDown()
-	defer c.poolQueue.ShutDown()
 
 	klog.Infof("Starting %s", controllerName)
 	defer klog.Infof("Shutting down %s", controllerName)
 
-	cacheSyncs := []cache.InformerSynced{c.egressListerSynced, c.externalIPPoolListerSynced, c.groupingInterfaceSynced}
+	cacheSyncs := []cache.InformerSynced{c.egressListerSynced, c.groupingInterfaceSynced, c.externalIPAllocator.HasSynced}
 	if !cache.WaitForNamedCacheSync(controllerName, stopCh, cacheSyncs...) {
 		return
 	}
-
-	// Initialize the ipAllocatorMap and ipAllocationMap with the existing ExternalIPPools and Egresses.
-	ipPools, _ := c.externalIPPoolLister.List(labels.Everything())
-	for _, ipPool := range ipPools {
-		c.createOrUpdateIPAllocator(ipPool)
-	}
 	egresses, _ := c.egressLister.List(labels.Everything())
-	for _, egress := range egresses {
-		c.updateIPAllocation(egress)
-	}
-
+	c.restoreIPAllocations(egresses)
 	for i := 0; i < defaultWorkers; i++ {
 		go wait.Until(c.egressGroupWorker, time.Second, stopCh)
-		go wait.Until(c.externalIPPoolWorker, time.Second, stopCh)
 	}
 	<-stopCh
 }
 
-// updateIPAllocation sets the EgressIP of an Egress as allocated in the specified ExternalIPPool and records the
-// allocation in ipAllocationMap.
-func (c *EgressController) updateIPAllocation(egress *egressv1alpha2.Egress) {
-	// Ignore Egress that is not associated to ExternalIPPool or doesn't have EgressIP assigned.
-	if egress.Spec.ExternalIPPool == "" || egress.Spec.EgressIP == "" {
-		return
-	}
-	ipAllocator, exists := c.getIPAllocator(egress.Spec.ExternalIPPool)
-	if !exists {
-		klog.ErrorS(externalIPPoolNotFound, "Failed to allocate EgressIP", "egress", egress.Name, "ip", egress.Spec.EgressIP, "pool", egress.Spec.ExternalIPPool)
-		return
-	}
-	ip := net.ParseIP(egress.Spec.EgressIP)
-	err := ipAllocator.AllocateIP(ip)
-	if err != nil {
-		klog.ErrorS(err, "Failed to allocate EgressIP", "egress", egress.Name, "ip", egress.Spec.EgressIP, "pool", egress.Spec.ExternalIPPool)
-		return
-	}
-	// Record the valid IP allocation.
-	c.setIPAllocation(egress.Name, ip, egress.Spec.ExternalIPPool)
-	klog.InfoS("Allocated EgressIP", "egress", egress.Name, "ip", egress.Spec.EgressIP, "pool", egress.Spec.ExternalIPPool)
-}
-
-// createOrUpdateIPAllocator creates or updates the IP allocator based on the provided ExternalIPPool.
-// Currently it's assumed that only new ranges will be added and existing ranges should not be deleted.
-// TODO: Use validation webhook to ensure it.
-func (c *EgressController) createOrUpdateIPAllocator(ipPool *egressv1alpha2.ExternalIPPool) bool {
-	changed := false
-	c.ipAllocatorMutex.Lock()
-	defer c.ipAllocatorMutex.Unlock()
-
-	existingIPRanges := sets.NewString()
-	multiIPAllocator, exists := c.ipAllocatorMap[ipPool.Name]
-	if !exists {
-		multiIPAllocator = ipallocator.MultiIPAllocator{}
-		changed = true
-	} else {
-		existingIPRanges.Insert(multiIPAllocator.Names()...)
-	}
-
-	for _, ipRange := range ipPool.Spec.IPRanges {
-		ipAllocator, err := func() (*ipallocator.SingleIPAllocator, error) {
-			if ipRange.CIDR != "" {
-				_, ipNet, err := net.ParseCIDR(ipRange.CIDR)
-				if err != nil {
-					return nil, err
-				}
-				// Must use normalized IPNet string to check if the IP range exists. Otherwise non-strict CIDR like
-				// 192.168.0.1/24 will be considered new even if it doesn't change.
-				// Validating or normalizing the input CIDR should be a better solution but the externalIPPools that
-				// have been created will still have this issue, so we just normalize the CIDR when using it.
-				if existingIPRanges.Has(ipNet.String()) {
-					return nil, nil
-				}
-				// Don't use the IPv4 network's broadcast address.
-				var reservedIPs []net.IP
-				if utilnet.IsIPv4CIDR(ipNet) {
-					reservedIPs = append(reservedIPs, iputil.GetLocalBroadcastIP(ipNet))
-				}
-				return ipallocator.NewCIDRAllocator(ipNet, reservedIPs)
-			} else {
-				if existingIPRanges.Has(fmt.Sprintf("%s-%s", ipRange.Start, ipRange.End)) {
-					return nil, nil
-				}
-				return ipallocator.NewIPRangeAllocator(net.ParseIP(ipRange.Start), net.ParseIP(ipRange.End))
-			}
-		}()
-		if err != nil {
-			klog.ErrorS(err, "Failed to create IPAllocator", "ipRange", ipRange)
+// restoreIPAllocations restores the existing EgressIPs of Egresses and records the successful ones in ipAllocationMap.
+func (c *EgressController) restoreIPAllocations(egresses []*egressv1alpha2.Egress) {
+	var previousIPAllocations []externalippool.IPAllocation
+	for _, egress := range egresses {
+		// Ignore Egress that is not associated to ExternalIPPool or doesn't have EgressIP assigned.
+		if egress.Spec.ExternalIPPool == "" || egress.Spec.EgressIP == "" {
 			continue
 		}
-		// The IP range already exists in multiIPAllocator.
-		if ipAllocator == nil {
-			continue
+		ip := net.ParseIP(egress.Spec.EgressIP)
+		allocation := externalippool.IPAllocation{
+			ObjectReference: v1.ObjectReference{
+				Name: egress.Name,
+				Kind: egress.Kind,
+			},
+			IPPoolName: egress.Spec.ExternalIPPool,
+			IP:         ip,
 		}
-		multiIPAllocator = append(multiIPAllocator, ipAllocator)
-		changed = true
+		previousIPAllocations = append(previousIPAllocations, allocation)
+	}
+	succeededAllocations := c.externalIPAllocator.RestoreIPAllocations(previousIPAllocations)
+	for _, alloc := range succeededAllocations {
+		c.setIPAllocation(alloc.ObjectReference.Name, alloc.IP, alloc.IPPoolName)
+		klog.InfoS("Restored EgressIP", "egress", alloc.ObjectReference.Name, "ip", alloc.IP, "pool", alloc.IPPoolName)
 	}
-	c.ipAllocatorMap[ipPool.Name] = multiIPAllocator
-	c.poolQueue.Add(ipPool.Name)
-	return changed
-}
-
-// deleteIPAllocator deletes the IP allocator of the given IP pool.
-func (c *EgressController) deleteIPAllocator(ipPoolName string) {
-	c.ipAllocatorMutex.Lock()
-	defer c.ipAllocatorMutex.Unlock()
-	delete(c.ipAllocatorMap, ipPoolName)
-}
-
-// getIPAllocator gets the IP allocator of the given IP pool.
-func (c *EgressController) getIPAllocator(ipPoolName string) (ipallocator.MultiIPAllocator, bool) {
-	c.ipAllocatorMutex.RLock()
-	defer c.ipAllocatorMutex.RUnlock()
-	ipAllocator, exists := c.ipAllocatorMap[ipPoolName]
-	return ipAllocator, exists
 }
 
 func (c *EgressController) egressGroupWorker() {
@@ -337,57 +232,18 @@ func (c *EgressController) setIPAllocation(egressName string, ip net.IP, poolNam
 	}
 }
 
-func (c *EgressController) updateExternalIPPoolStatus(poolName string) error {
-	eip, err := c.externalIPPoolLister.Get(poolName)
-	if err != nil {
-		if apierrors.IsNotFound(err) {
-			return nil
-		}
-		return err
-	}
-
-	ipAllocator, exists := c.getIPAllocator(eip.Name)
-	if !exists {
-		return externalIPPoolNotFound
-	}
-	total, used := ipAllocator.Total(), ipAllocator.Used()
-	toUpdate := eip.DeepCopy()
-	var getErr error
-	if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
-		actualStatus := eip.Status
-		usage := egressv1alpha2.ExternalIPPoolUsage{Total: total, Used: used}
-		if actualStatus.Usage == usage {
-			return nil
-		}
-		klog.V(2).InfoS("Updating ExternalIPPool status", "ExternalIPPool", poolName, "usage", usage)
-		toUpdate.Status.Usage = usage
-		if _, updateErr := c.crdClient.CrdV1alpha2().ExternalIPPools().UpdateStatus(context.TODO(), toUpdate, metav1.UpdateOptions{}); updateErr != nil && apierrors.IsConflict(updateErr) {
-			toUpdate, getErr = c.crdClient.CrdV1alpha2().ExternalIPPools().Get(context.TODO(), poolName, metav1.GetOptions{})
-			if getErr != nil {
-				return getErr
-			}
-			return updateErr
-		}
-		return nil
-	}); err != nil {
-		return fmt.Errorf("updating ExternalIPPool %s status error: %v", poolName, err)
-	}
-	klog.V(2).InfoS("Updated ExternalIPPool status", "ExternalIPPool", poolName)
-	metrics.AntreaExternalIPPoolStatusUpdates.Inc()
-	return nil
-}
-
 // syncEgressIP is responsible for releasing stale EgressIP and allocating new EgressIP for an Egress if applicable.
 func (c *EgressController) syncEgressIP(egress *egressv1alpha2.Egress) (net.IP, error) {
 	prevIP, prevIPPool, exists := c.getIPAllocation(egress.Name)
 	if exists {
-		_, ipAllocatorExists := c.getIPAllocator(prevIPPool)
 		// The EgressIP and the ExternalIPPool don't change, do nothing.
-		if prevIP.String() == egress.Spec.EgressIP && prevIPPool == egress.Spec.ExternalIPPool && ipAllocatorExists {
+		if prevIP.String() == egress.Spec.EgressIP && prevIPPool == egress.Spec.ExternalIPPool && c.externalIPAllocator.IPPoolExists(egress.Spec.ExternalIPPool) {
 			return prevIP, nil
 		}
 		// Either EgressIP or ExternalIPPool changes, release the previous one first.
-		c.releaseEgressIP(egress.Name, prevIP, prevIPPool)
+		if err := c.releaseEgressIP(egress.Name, prevIP, prevIPPool); err != nil {
+			return nil, err
+		}
 	}
 
 	// Skip allocating EgressIP if ExternalIPPool is not specified and return whatever user specifies.
@@ -395,15 +251,14 @@ func (c *EgressController) syncEgressIP(egress *egressv1alpha2.Egress) (net.IP,
 		return net.ParseIP(egress.Spec.EgressIP), nil
 	}
 
-	ipAllocator, exists := c.getIPAllocator(egress.Spec.ExternalIPPool)
-	if !exists {
+	if !c.externalIPAllocator.IPPoolExists(egress.Spec.ExternalIPPool) {
 		// The IP pool has been deleted, reclaim the IP from the Egress API.
 		if egress.Spec.EgressIP != "" {
 			if err := c.updateEgressIP(egress, ""); err != nil {
 				return nil, err
 			}
 		}
-		return nil, externalIPPoolNotFound
+		return nil, fmt.Errorf("ExternalIPPool %s not exists", egress.Spec.ExternalIPPool)
 	}
 
 	var ip net.IP
@@ -412,22 +267,24 @@ func (c *EgressController) syncEgressIP(egress *egressv1alpha2.Egress) (net.IP,
 	// TODO: Use validation webhook to ensure the requested IP matches the pool.
 	if egress.Spec.EgressIP != "" {
 		ip = net.ParseIP(egress.Spec.EgressIP)
-		if err := ipAllocator.AllocateIP(ip); err != nil {
+		if err := c.externalIPAllocator.UpdateIPAllocation(egress.Spec.ExternalIPPool, ip); err != nil {
 			return nil, fmt.Errorf("error when allocating IP %v for Egress %s from ExternalIPPool %s: %v", ip, egress.Name, egress.Spec.ExternalIPPool, err)
 		}
 	} else {
 		var err error
 		// User doesn't specify the Egress IP, allocate one.
-		if ip, err = ipAllocator.AllocateNext(); err != nil {
+		if ip, err = c.externalIPAllocator.AllocateIPFromPool(egress.Spec.ExternalIPPool); err != nil {
 			return nil, err
 		}
 		if err = c.updateEgressIP(egress, ip.String()); err != nil {
-			ipAllocator.Release(ip)
+			if rerr := c.externalIPAllocator.ReleaseIP(egress.Spec.ExternalIPPool, ip); rerr != nil &&
+				rerr != externalippool.ErrExternalIPPoolNotFound {
+				klog.ErrorS(rerr, "Failed to release IP", "ip", ip, "pool", egress.Spec.ExternalIPPool)
+			}
 			return nil, err
 		}
 	}
 	c.setIPAllocation(egress.Name, ip, egress.Spec.ExternalIPPool)
-	c.poolQueue.Add(egress.Spec.ExternalIPPool)
 	klog.InfoS("Allocated EgressIP", "egress", egress.Name, "ip", ip, "pool", egress.Spec.ExternalIPPool)
 	return ip, nil
 }
@@ -451,19 +308,20 @@ func (c *EgressController) updateEgressIP(egress *egressv1alpha2.Egress, ip stri
 }
 
 // releaseEgressIP removes the Egress's ipAllocation in the cache and releases the IP to the pool.
-func (c *EgressController) releaseEgressIP(egressName string, egressIP net.IP, poolName string) {
-	c.deleteIPAllocation(egressName)
-	allocator, exists := c.getIPAllocator(poolName)
-	if !exists {
-		klog.ErrorS(externalIPPoolNotFound, "Failed to release EgressIP", "egress", egressName, "ip", egressIP, "pool", poolName)
-		return
-	}
-	if err := allocator.Release(egressIP); err != nil {
-		klog.ErrorS(err, "Failed to release EgressIP", "egress", egressName, "ip", egressIP, "pool", poolName)
-		return
+func (c *EgressController) releaseEgressIP(egressName string, egressIP net.IP, poolName string) error {
+	if err := c.externalIPAllocator.ReleaseIP(poolName, egressIP); err != nil {
+		if err == externalippool.ErrExternalIPPoolNotFound {
+			// Ignore the error since the external IP Pool could be deleted.
+			klog.Warningf("Failed to release IP %s because IP Pool %s does not exist", egressIP, poolName)
+		} else {
+			klog.ErrorS(err, "Failed to release IP", "ip", egressIP, "pool", poolName)
+			return err
+		}
+	} else {
+		klog.InfoS("Released EgressIP", "egress", egressName, "ip", egressIP, "pool", poolName)
 	}
-	c.poolQueue.Add(poolName)
-	klog.InfoS("Released EgressIP", "egress", egressName, "ip", egressIP, "pool", poolName)
+	c.deleteIPAllocation(egressName)
+	return nil
 }
 
 func (c *EgressController) syncEgress(key string) error {
@@ -535,31 +393,6 @@ func (c *EgressController) syncEgress(key string) error {
 	return nil
 }
 
-func (c *EgressController) externalIPPoolWorker() {
-	for c.processNextExternalIPPoolWorkItem() {
-	}
-}
-
-func (c *EgressController) processNextExternalIPPoolWorkItem() bool {
-	key, quit := c.poolQueue.Get()
-	if quit {
-		return false
-	}
-	defer c.poolQueue.Done(key)
-
-	err := c.updateExternalIPPoolStatus(key.(string))
-	if err != nil {
-		// Put the item back in the workqueue to handle any transient errors.
-		c.poolQueue.AddRateLimited(key)
-		klog.ErrorS(err, "Failed to sync ExternalIPPool status", "ExternalIPPool", key)
-		return true
-	}
-	// If no error occurs we Forget this item so it does not get queued again until
-	// another change happens.
-	c.poolQueue.Forget(key)
-	return true
-}
-
 func (c *EgressController) enqueueEgressGroup(key string) {
 	klog.V(4).Infof("Adding new key %s to EgressGroup queue", key)
 	c.queue.Add(key)
@@ -607,15 +440,6 @@ func (c *EgressController) deleteEgress(obj interface{}) {
 	c.queue.Add(egress.Name)
 }
 
-// addExternalIPPool processes ExternalIPPool ADD events. It creates an IPAllocator for the pool and triggers
-// reconciliation of Egresses that refer to the pool.
-func (c *EgressController) addExternalIPPool(obj interface{}) {
-	pool := obj.(*egressv1alpha2.ExternalIPPool)
-	klog.InfoS("Processing ExternalIPPool ADD event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
-	c.createOrUpdateIPAllocator(pool)
-	c.enqueueEgresses(pool.Name)
-}
-
 // enqueueEgresses enqueues all Egresses that refer to the provided ExternalIPPool.
 func (c *EgressController) enqueueEgresses(poolName string) {
 	objects, _ := c.egressIndexer.ByIndex(externalIPPoolIndex, poolName)
@@ -624,23 +448,3 @@ func (c *EgressController) enqueueEgresses(poolName string) {
 		c.queue.Add(egress.Name)
 	}
 }
-
-// updateExternalIPPool processes ExternalIPPool UPDATE events. It updates the IPAllocator for the pool and triggers
-// reconciliation of Egresses that refer to the pool if the IPAllocator changes.
-func (c *EgressController) updateExternalIPPool(_, cur interface{}) {
-	pool := cur.(*egressv1alpha2.ExternalIPPool)
-	klog.InfoS("Processing ExternalIPPool UPDATE event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
-	if c.createOrUpdateIPAllocator(pool) {
-		c.enqueueEgresses(pool.Name)
-	}
-}
-
-// deleteExternalIPPool processes ExternalIPPool DELETE events. It deletes the IPAllocator for the pool and triggers
-// reconciliation of Egresses that refer to the pool.
-func (c *EgressController) deleteExternalIPPool(obj interface{}) {
-	pool := obj.(*egressv1alpha2.ExternalIPPool)
-	klog.InfoS("Processing ExternalIPPool DELETE event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
-	c.deleteIPAllocator(pool.Name)
-	// Enqueue the Egresses to reclaim the IPs allocated from the pool.
-	c.enqueueEgresses(pool.Name)
-}
diff --git a/pkg/controller/egress/validate.go b/pkg/controller/egress/validate.go
index d77129202..5769dd985 100644
--- a/pkg/controller/egress/validate.go
+++ b/pkg/controller/egress/validate.go
@@ -21,64 +21,11 @@ import (
 
 	admv1 "k8s.io/api/admission/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/klog/v2"
 
 	crdv1alpha2 "antrea.io/antrea/pkg/apis/crd/v1alpha2"
 )
 
-func (c *EgressController) ValidateExternalIPPool(review *admv1.AdmissionReview) *admv1.AdmissionResponse {
-	var result *metav1.Status
-	var msg string
-	allowed := true
-
-	klog.V(2).Info("Validating ExternalIPPool", "request", review.Request)
-	var newObj, oldObj crdv1alpha2.ExternalIPPool
-	if review.Request.Object.Raw != nil {
-		if err := json.Unmarshal(review.Request.Object.Raw, &newObj); err != nil {
-			klog.ErrorS(err, "Error de-serializing current ExternalIPPool")
-			return newAdmissionResponseForErr(err)
-		}
-	}
-	if review.Request.OldObject.Raw != nil {
-		if err := json.Unmarshal(review.Request.OldObject.Raw, &oldObj); err != nil {
-			klog.ErrorS(err, "Error de-serializing old ExternalIPPool")
-			return newAdmissionResponseForErr(err)
-		}
-	}
-
-	switch review.Request.Operation {
-	case admv1.Create:
-		// This shouldn't happen with the webhook configuration we include in the Antrea YAML manifests.
-		klog.V(2).Info("Validating CREATE request for ExternalIPPool")
-		// Always allow CREATE request.
-	case admv1.Update:
-		klog.V(2).Info("Validating UPDATE request for ExternalIPPool")
-
-		oldIPRangeSet := getIPRangeSet(oldObj.Spec.IPRanges)
-		newIPRangeSet := getIPRangeSet(newObj.Spec.IPRanges)
-		deletedIPRanges := oldIPRangeSet.Difference(newIPRangeSet)
-		if deletedIPRanges.Len() > 0 {
-			allowed = false
-			msg = fmt.Sprintf("existing IPRanges %v cannot be deleted", deletedIPRanges.List())
-		}
-	case admv1.Delete:
-		// This shouldn't happen with the webhook configuration we include in the Antrea YAML manifests.
-		klog.V(2).Info("Validating DELETE request for ExternalIPPool")
-		// Always allow DELETE request.
-	}
-
-	if msg != "" {
-		result = &metav1.Status{
-			Message: msg,
-		}
-	}
-	return &admv1.AdmissionResponse{
-		Allowed: allowed,
-		Result:  result,
-	}
-}
-
 func (c *EgressController) ValidateEgress(review *admv1.AdmissionReview) *admv1.AdmissionResponse {
 	var result *metav1.Status
 	var msg string
@@ -112,12 +59,10 @@ func (c *EgressController) ValidateEgress(review *admv1.AdmissionReview) *admv1.
 		if ip == nil {
 			return false, fmt.Sprintf("IP %s is not valid", newEgress.Spec.EgressIP)
 		}
-		ipAllocator, exists := c.getIPAllocator(newEgress.Spec.ExternalIPPool)
-		// The ExternalIPPool doesn't exist, cannot determine whether the IP is in the pool.
-		if !exists {
+		if !c.externalIPAllocator.IPPoolExists(newEgress.Spec.ExternalIPPool) {
 			return false, fmt.Sprintf("ExternalIPPool %s does not exist", newEgress.Spec.ExternalIPPool)
 		}
-		if !ipAllocator.Has(ip) {
+		if !c.externalIPAllocator.IPPoolHasIP(newEgress.Spec.ExternalIPPool, ip) {
 			return false, fmt.Sprintf("IP %s is not within the IP range", newEgress.Spec.EgressIP)
 		}
 		return true, ""
@@ -147,18 +92,6 @@ func (c *EgressController) ValidateEgress(review *admv1.AdmissionReview) *admv1.
 	}
 }
 
-func getIPRangeSet(ipRanges []crdv1alpha2.IPRange) sets.String {
-	set := sets.NewString()
-	for _, ipRange := range ipRanges {
-		ipRangeStr := ipRange.CIDR
-		if ipRangeStr == "" {
-			ipRangeStr = fmt.Sprintf("%s-%s", ipRange.Start, ipRange.End)
-		}
-		set.Insert(ipRangeStr)
-	}
-	return set
-}
-
 func newAdmissionResponseForErr(err error) *admv1.AdmissionResponse {
 	return &admv1.AdmissionResponse{
 		Result: &metav1.Status{
diff --git a/pkg/controller/externalippool/controller.go b/pkg/controller/externalippool/controller.go
new file mode 100644
index 000000000..6941ab1e1
--- /dev/null
+++ b/pkg/controller/externalippool/controller.go
@@ -0,0 +1,447 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package externalippool
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/util/retry"
+	"k8s.io/client-go/util/workqueue"
+	"k8s.io/klog/v2"
+	utilnet "k8s.io/utils/net"
+
+	antreacrds "antrea.io/antrea/pkg/apis/crd/v1alpha2"
+	clientset "antrea.io/antrea/pkg/client/clientset/versioned"
+	antreainformers "antrea.io/antrea/pkg/client/informers/externalversions/crd/v1alpha2"
+	antrealisters "antrea.io/antrea/pkg/client/listers/crd/v1alpha2"
+	"antrea.io/antrea/pkg/controller/metrics"
+	"antrea.io/antrea/pkg/ipam/ipallocator"
+	iputil "antrea.io/antrea/pkg/util/ip"
+)
+
+const (
+	controllerName = "ExternalIPPoolController"
+	// Set resyncPeriod to 0 to disable resyncing.
+	resyncPeriod time.Duration = 0
+	// How long to wait before retrying the processing of an ExternalIPPool change.
+	minRetryDelay = 5 * time.Second
+	maxRetryDelay = 300 * time.Second
+	// Default number of workers processing an ExternalIPPool change.
+	defaultWorkers = 4
+)
+
+var (
+	ErrExternalIPPoolNotFound = errors.New("ExternalIPPool not found")
+)
+
+// IPAllocation contains the IP and the IP Pool which allocates it.
+type IPAllocation struct {
+	// ObjectReference is useful to track the owner of this IP allocation.
+	ObjectReference corev1.ObjectReference
+	// IPPoolName is the name of the IP pool.
+	IPPoolName string
+	// IP is the allocated IP.
+	IP net.IP
+}
+
+// ExternalIPPoolEventHandler defines a consumer to subscribe for external ExternalIPPool events.
+type ExternalIPPoolEventHandler func(externalIPPool string)
+
+type ExternalIPAllocator interface {
+	// AddEventHandler adds a consumer for ExternalIPPool events. It will block other consumers from allocating new IPs by
+	// AllocateIPFromPool() or AllocateIP() until it calls RestoreIPAllocations().
+	AddEventHandler(handler ExternalIPPoolEventHandler)
+	// RestoreIPAllocations is used to restore the previous allocated IPs after controller restarts. It will return the
+	// succeeded IP Allocations.
+	RestoreIPAllocations(allocations []IPAllocation) []IPAllocation
+	// AllocateIPFromPool allocates an IP from the given IP pool.
+	AllocateIPFromPool(externalIPPool string) (net.IP, error)
+	// AllocateIP allocates an IP from an available IP pool.
+	AllocateIP() (string, net.IP, error)
+	// IPPoolExists checks whether the IP pool exists.
+	IPPoolExists(externalIPPool string) bool
+	// IPPoolHasIP checks whether the IP pool contains the given IP.
+	IPPoolHasIP(externalIPPool string, ip net.IP) bool
+	// LocateIP finds which IP Pool contains the given IP.
+	LocateIP(ip net.IP) (string, error)
+	// UpdateIPAllocation marks the IP in the specified ExternalIPPool as occupied.
+	UpdateIPAllocation(externalIPPool string, ip net.IP) error
+	// ReleaseIP releases the IP to the IP pool.
+	ReleaseIP(externalIPPool string, ip net.IP) error
+	// HasSynced indicates ExternalIPAllocator has finished syncing all ExternalIPPool resources.
+	HasSynced() bool
+}
+
+var _ ExternalIPAllocator = (*ExternalIPPoolController)(nil)
+
+// ExternalIPPoolController is responsible for synchronizing the ExternalIPPool resources.
+type ExternalIPPoolController struct {
+	crdClient                  clientset.Interface
+	externalIPPoolLister       antrealisters.ExternalIPPoolLister
+	externalIPPoolListerSynced cache.InformerSynced
+
+	// ipAllocatorMap is a map from ExternalIPPool name to MultiIPAllocator.
+	ipAllocatorMap   map[string]ipallocator.MultiIPAllocator
+	ipAllocatorMutex sync.RWMutex
+
+	// ipAllocatorInitialized stores a boolean value, which tracks if the ipAllocatorMap has been initialized
+	// with the full list of ExternalIPPool.
+	ipAllocatorInitialized *atomic.Value
+
+	// handlers is an array of handlers will be notified when ExternalIPPool updates.
+	handlers          []ExternalIPPoolEventHandler
+	handlersWaitGroup sync.WaitGroup
+
+	// queue maintains the ExternalIPPool objects that need to be synced.
+	queue workqueue.RateLimitingInterface
+}
+
+// NewExternalIPPoolController returns a new *ExternalIPPoolController.
+func NewExternalIPPoolController(crdClient clientset.Interface, externalIPPoolInformer antreainformers.ExternalIPPoolInformer) *ExternalIPPoolController {
+	c := &ExternalIPPoolController{
+		crdClient:                  crdClient,
+		externalIPPoolLister:       externalIPPoolInformer.Lister(),
+		externalIPPoolListerSynced: externalIPPoolInformer.Informer().HasSynced,
+		queue:                      workqueue.NewNamedRateLimitingQueue(workqueue.NewItemExponentialFailureRateLimiter(minRetryDelay, maxRetryDelay), "externalIPPool"),
+		ipAllocatorInitialized:     &atomic.Value{},
+		ipAllocatorMap:             make(map[string]ipallocator.MultiIPAllocator),
+	}
+	externalIPPoolInformer.Informer().AddEventHandlerWithResyncPeriod(
+		cache.ResourceEventHandlerFuncs{
+			AddFunc:    c.addExternalIPPool,
+			UpdateFunc: c.updateExternalIPPool,
+			DeleteFunc: c.deleteExternalIPPool,
+		},
+		resyncPeriod,
+	)
+	c.ipAllocatorInitialized.Store(false)
+	return c
+}
+
+func (c *ExternalIPPoolController) HasSynced() bool {
+	return c.ipAllocatorInitialized.Load().(bool)
+}
+
+func (c *ExternalIPPoolController) AddEventHandler(handler ExternalIPPoolEventHandler) {
+	c.handlers = append(c.handlers, handler)
+	c.handlersWaitGroup.Add(1)
+}
+
+func (c *ExternalIPPoolController) RestoreIPAllocations(allocations []IPAllocation) []IPAllocation {
+	var succeeded []IPAllocation
+	for _, allocation := range allocations {
+		if err := c.UpdateIPAllocation(allocation.IPPoolName, allocation.IP); err != nil {
+			klog.ErrorS(err, "Failed to restore IP allocation", "ip", allocation.IP, "ipPool", allocation.IPPoolName)
+		} else {
+			succeeded = append(succeeded, allocation)
+		}
+	}
+	c.handlersWaitGroup.Done()
+	return succeeded
+}
+
+// Run begins watching and syncing of the ExternalIPPoolController.
+func (c *ExternalIPPoolController) Run(stopCh <-chan struct{}) {
+	defer c.queue.ShutDown()
+
+	klog.Infof("Starting %s", controllerName)
+	defer klog.Infof("Shutting down %s", controllerName)
+
+	cacheSyncs := []cache.InformerSynced{c.externalIPPoolListerSynced}
+	if !cache.WaitForNamedCacheSync(controllerName, stopCh, cacheSyncs...) {
+		return
+	}
+
+	// Initialize the ipAllocatorMap with the existing ExternalIPPools.
+	ipPools, _ := c.externalIPPoolLister.List(labels.Everything())
+	for _, ipPool := range ipPools {
+		c.createOrUpdateIPAllocator(ipPool)
+	}
+
+	c.ipAllocatorInitialized.Store(true)
+
+	for i := 0; i < defaultWorkers; i++ {
+		go wait.Until(c.worker, time.Second, stopCh)
+	}
+	<-stopCh
+}
+
+// createOrUpdateIPAllocator creates or updates the IP allocator based on the provided ExternalIPPool.
+// Currently it's assumed that only new ranges will be added and existing ranges should not be deleted.
+// TODO: Use validation webhook to ensure it.
+func (c *ExternalIPPoolController) createOrUpdateIPAllocator(ipPool *antreacrds.ExternalIPPool) bool {
+	changed := false
+	c.ipAllocatorMutex.Lock()
+	defer c.ipAllocatorMutex.Unlock()
+
+	existingIPRanges := sets.NewString()
+	multiIPAllocator, exists := c.ipAllocatorMap[ipPool.Name]
+	if !exists {
+		multiIPAllocator = ipallocator.MultiIPAllocator{}
+		changed = true
+	} else {
+		existingIPRanges.Insert(multiIPAllocator.Names()...)
+	}
+
+	for _, ipRange := range ipPool.Spec.IPRanges {
+		ipAllocator, err := func() (*ipallocator.SingleIPAllocator, error) {
+			if ipRange.CIDR != "" {
+				_, ipNet, err := net.ParseCIDR(ipRange.CIDR)
+				if err != nil {
+					return nil, err
+				}
+				// Must use normalized IPNet string to check if the IP range exists. Otherwise non-strict CIDR like
+				// 192.168.0.1/24 will be considered new even if it doesn't change.
+				// Validating or normalizing the input CIDR should be a better solution but the externalIPPools that
+				// have been created will still have this issue, so we just normalize the CIDR when using it.
+				if existingIPRanges.Has(ipNet.String()) {
+					return nil, nil
+				}
+				// Don't use the IPv4 network's broadcast address.
+				var reservedIPs []net.IP
+				if utilnet.IsIPv4CIDR(ipNet) {
+					reservedIPs = append(reservedIPs, iputil.GetLocalBroadcastIP(ipNet))
+				}
+				return ipallocator.NewCIDRAllocator(ipNet, reservedIPs)
+			} else {
+				if existingIPRanges.Has(fmt.Sprintf("%s-%s", ipRange.Start, ipRange.End)) {
+					return nil, nil
+				}
+				return ipallocator.NewIPRangeAllocator(net.ParseIP(ipRange.Start), net.ParseIP(ipRange.End))
+			}
+		}()
+		if err != nil {
+			klog.ErrorS(err, "Failed to create IPAllocator", "ipRange", ipRange)
+			continue
+		}
+		// The IP range already exists in multiIPAllocator.
+		if ipAllocator == nil {
+			continue
+		}
+		multiIPAllocator = append(multiIPAllocator, ipAllocator)
+		changed = true
+	}
+	c.ipAllocatorMap[ipPool.Name] = multiIPAllocator
+	c.queue.Add(ipPool.Name)
+	return changed
+}
+
+// deleteIPAllocator deletes the IP allocator of the given IP pool.
+func (c *ExternalIPPoolController) deleteIPAllocator(poolName string) {
+	c.ipAllocatorMutex.Lock()
+	defer c.ipAllocatorMutex.Unlock()
+	delete(c.ipAllocatorMap, poolName)
+}
+
+// getIPAllocator gets the IP allocator of the given IP pool.
+func (c *ExternalIPPoolController) getIPAllocator(poolName string) (ipallocator.MultiIPAllocator, bool) {
+	c.ipAllocatorMutex.RLock()
+	defer c.ipAllocatorMutex.RUnlock()
+	ipAllocator, exists := c.ipAllocatorMap[poolName]
+	return ipAllocator, exists
+}
+
+// AllocateIPFromPool allocates an IP from the the given IP pool.
+func (c *ExternalIPPoolController) AllocateIPFromPool(ipPoolName string) (net.IP, error) {
+	c.handlersWaitGroup.Wait()
+	ipAllocator, exists := c.getIPAllocator(ipPoolName)
+	if !exists {
+		return nil, ErrExternalIPPoolNotFound
+	}
+	ip, err := ipAllocator.AllocateNext()
+	if err != nil {
+		return ip, err
+	}
+	c.queue.Add(ipPoolName)
+	return ip, nil
+}
+
+// AllocateIP allocate an IP from an available IP pool.
+func (c *ExternalIPPoolController) AllocateIP() (string, net.IP, error) {
+	c.handlersWaitGroup.Wait()
+	c.ipAllocatorMutex.RLock()
+	defer c.ipAllocatorMutex.RUnlock()
+	for pool, allocator := range c.ipAllocatorMap {
+		ip, err := allocator.AllocateNext()
+		if err == nil {
+			c.queue.Add(pool)
+			return pool, ip, nil
+		}
+	}
+	return "", nil, errors.New("no ExternalIPPool available")
+}
+
+// UpdateIPAllocation sets the IP in the specified ExternalIPPool.
+func (c *ExternalIPPoolController) UpdateIPAllocation(poolName string, ip net.IP) error {
+	ipAllocator, exists := c.getIPAllocator(poolName)
+	if !exists {
+		return ErrExternalIPPoolNotFound
+	}
+	err := ipAllocator.AllocateIP(ip)
+	if err != nil {
+		return err
+	}
+	c.queue.Add(poolName)
+	return nil
+}
+
+func (c *ExternalIPPoolController) updateExternalIPPoolStatus(poolName string) error {
+	eip, err := c.externalIPPoolLister.Get(poolName)
+	if err != nil {
+		if apierrors.IsNotFound(err) {
+			return nil
+		}
+		return err
+	}
+	ipAllocator, exists := c.getIPAllocator(eip.Name)
+	if !exists {
+		return ErrExternalIPPoolNotFound
+	}
+	total, used := ipAllocator.Total(), ipAllocator.Used()
+	toUpdate := eip.DeepCopy()
+	var getErr error
+	if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
+		actualStatus := eip.Status
+		usage := antreacrds.ExternalIPPoolUsage{Total: total, Used: used}
+		if actualStatus.Usage == usage {
+			return nil
+		}
+		klog.V(2).InfoS("Updating ExternalIPPool status", "ExternalIPPool", poolName, "usage", usage)
+		toUpdate.Status.Usage = usage
+		if _, updateErr := c.crdClient.CrdV1alpha2().ExternalIPPools().UpdateStatus(context.TODO(), toUpdate, metav1.UpdateOptions{}); updateErr != nil && apierrors.IsConflict(updateErr) {
+			toUpdate, getErr = c.crdClient.CrdV1alpha2().ExternalIPPools().Get(context.TODO(), poolName, metav1.GetOptions{})
+			if getErr != nil {
+				return getErr
+			}
+			return updateErr
+		}
+		return nil
+	}); err != nil {
+		return fmt.Errorf("updating ExternalIPPool %s status error: %v", poolName, err)
+	}
+	klog.V(2).InfoS("Updated ExternalIPPool status", "ExternalIPPool", poolName)
+	metrics.AntreaExternalIPPoolStatusUpdates.Inc()
+	return nil
+}
+
+// ReleaseIP releases the IP to the pool.
+func (c *ExternalIPPoolController) ReleaseIP(poolName string, ip net.IP) error {
+	allocator, exists := c.getIPAllocator(poolName)
+	if !exists {
+		return ErrExternalIPPoolNotFound
+	}
+	if err := allocator.Release(ip); err != nil {
+		return err
+	}
+	c.queue.Add(poolName)
+	return nil
+}
+
+func (c *ExternalIPPoolController) IPPoolHasIP(poolName string, ip net.IP) bool {
+	allocator, exists := c.getIPAllocator(poolName)
+	if !exists {
+		return false
+	}
+	return allocator.Has(ip)
+}
+
+func (c *ExternalIPPoolController) IPPoolExists(pool string) bool {
+	_, exists := c.getIPAllocator(pool)
+	return exists
+}
+
+func (c *ExternalIPPoolController) LocateIP(ip net.IP) (string, error) {
+	c.ipAllocatorMutex.RLock()
+	defer c.ipAllocatorMutex.RUnlock()
+	for pool, allocator := range c.ipAllocatorMap {
+		if allocator.Has(ip) {
+			return pool, nil
+		}
+	}
+	return "", ErrExternalIPPoolNotFound
+}
+
+func (c *ExternalIPPoolController) worker() {
+	for c.processNextWorkItem() {
+	}
+}
+
+func (c *ExternalIPPoolController) processNextWorkItem() bool {
+	key, quit := c.queue.Get()
+	if quit {
+		return false
+	}
+	defer c.queue.Done(key)
+
+	err := c.updateExternalIPPoolStatus(key.(string))
+	if err != nil {
+		// Put the item back in the workqueue to handle any transient errors.
+		c.queue.AddRateLimited(key)
+		klog.ErrorS(err, "Failed to sync ExternalIPPool status", "ExternalIPPool", key)
+		return true
+	}
+	// If no error occurs we Forget this item so it does not get queued again until
+	// another change happens.
+	c.queue.Forget(key)
+	return true
+}
+
+// addExternalIPPool processes ExternalIPPool ADD events. It creates an IPAllocator for the pool and triggers
+// reconciliation of consumers that refer to the pool.
+func (c *ExternalIPPoolController) addExternalIPPool(obj interface{}) {
+	pool := obj.(*antreacrds.ExternalIPPool)
+	klog.InfoS("Processing ExternalIPPool ADD event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
+	c.createOrUpdateIPAllocator(pool)
+	for _, h := range c.handlers {
+		h(pool.Name)
+	}
+}
+
+// updateExternalIPPool processes ExternalIPPool UPDATE events. It updates the IPAllocator for the pool and triggers
+// reconciliation of consumers that refer to the pool if the IPAllocator changes.
+func (c *ExternalIPPoolController) updateExternalIPPool(_, cur interface{}) {
+	pool := cur.(*antreacrds.ExternalIPPool)
+	klog.InfoS("Processing ExternalIPPool UPDATE event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
+	if c.createOrUpdateIPAllocator(pool) {
+		for _, h := range c.handlers {
+			h(pool.Name)
+		}
+	}
+}
+
+// deleteExternalIPPool processes ExternalIPPool DELETE events. It deletes the IPAllocator for the pool and triggers
+// reconciliation of all consumers that refer to the pool.
+func (c *ExternalIPPoolController) deleteExternalIPPool(obj interface{}) {
+	pool := obj.(*antreacrds.ExternalIPPool)
+	klog.InfoS("Processing ExternalIPPool DELETE event", "pool", pool.Name, "ipRanges", pool.Spec.IPRanges)
+	c.deleteIPAllocator(pool.Name)
+	// Call consumers to reclaim the IPs allocated from the pool.
+	for _, h := range c.handlers {
+		h(pool.Name)
+	}
+}
diff --git a/pkg/controller/externalippool/validate.go b/pkg/controller/externalippool/validate.go
new file mode 100644
index 000000000..bb104fbb8
--- /dev/null
+++ b/pkg/controller/externalippool/validate.go
@@ -0,0 +1,98 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package externalippool
+
+import (
+	"encoding/json"
+	"fmt"
+
+	admv1 "k8s.io/api/admission/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+
+	crdv1alpha2 "antrea.io/antrea/pkg/apis/crd/v1alpha2"
+)
+
+func (c *ExternalIPPoolController) ValidateExternalIPPool(review *admv1.AdmissionReview) *admv1.AdmissionResponse {
+	var result *metav1.Status
+	var msg string
+	allowed := true
+
+	klog.V(2).Info("Validating ExternalIPPool", "request", review.Request)
+	var newObj, oldObj crdv1alpha2.ExternalIPPool
+	if review.Request.Object.Raw != nil {
+		if err := json.Unmarshal(review.Request.Object.Raw, &newObj); err != nil {
+			klog.ErrorS(err, "Error de-serializing current ExternalIPPool")
+			return newAdmissionResponseForErr(err)
+		}
+	}
+	if review.Request.OldObject.Raw != nil {
+		if err := json.Unmarshal(review.Request.OldObject.Raw, &oldObj); err != nil {
+			klog.ErrorS(err, "Error de-serializing old ExternalIPPool")
+			return newAdmissionResponseForErr(err)
+		}
+	}
+
+	switch review.Request.Operation {
+	case admv1.Create:
+		// This shouldn't happen with the webhook configuration we include in the Antrea YAML manifests.
+		klog.V(2).Info("Validating CREATE request for ExternalIPPool")
+		// Always allow CREATE request.
+	case admv1.Update:
+		klog.V(2).Info("Validating UPDATE request for ExternalIPPool")
+
+		oldIPRangeSet := getIPRangeSet(oldObj.Spec.IPRanges)
+		newIPRangeSet := getIPRangeSet(newObj.Spec.IPRanges)
+		deletedIPRanges := oldIPRangeSet.Difference(newIPRangeSet)
+		if deletedIPRanges.Len() > 0 {
+			allowed = false
+			msg = fmt.Sprintf("existing IPRanges %v cannot be deleted", deletedIPRanges.List())
+		}
+	case admv1.Delete:
+		// This shouldn't happen with the webhook configuration we include in the Antrea YAML manifests.
+		klog.V(2).Info("Validating DELETE request for ExternalIPPool")
+		// Always allow DELETE request.
+	}
+
+	if msg != "" {
+		result = &metav1.Status{
+			Message: msg,
+		}
+	}
+	return &admv1.AdmissionResponse{
+		Allowed: allowed,
+		Result:  result,
+	}
+}
+func getIPRangeSet(ipRanges []crdv1alpha2.IPRange) sets.String {
+	set := sets.NewString()
+	for _, ipRange := range ipRanges {
+		ipRangeStr := ipRange.CIDR
+		if ipRangeStr == "" {
+			ipRangeStr = fmt.Sprintf("%s-%s", ipRange.Start, ipRange.End)
+		}
+		set.Insert(ipRangeStr)
+	}
+	return set
+}
+
+func newAdmissionResponseForErr(err error) *admv1.AdmissionResponse {
+	return &admv1.AdmissionResponse{
+		Result: &metav1.Status{
+			Message: err.Error(),
+		},
+	}
+}
diff --git a/pkg/controller/networkpolicy/validate.go b/pkg/controller/networkpolicy/validate.go
index ec74a77d5..2cd5e687c 100644
--- a/pkg/controller/networkpolicy/validate.go
+++ b/pkg/controller/networkpolicy/validate.go
@@ -17,6 +17,7 @@ package networkpolicy
 import (
 	"encoding/json"
 	"fmt"
+	"reflect"
 	"regexp"
 	"strconv"
 	"strings"
@@ -461,6 +462,11 @@ func (v *antreaPolicyValidator) validateAppliedTo(ingress, egress []crdv1alpha1.
 	if numAppliedToInRules > 0 && (numAppliedToInRules != len(ingress)+len(egress)) {
 		return "appliedTo field should either be set in all rules or in none of them", false
 	}
+	for _, eachSpecAppliedTo := range specAppliedTo {
+		if eachSpecAppliedTo.Group != "" && numFieldsSetInPeer(eachSpecAppliedTo) > 1 {
+			return "group cannot be set with other peers in appliedTo", false
+		}
+	}
 	return "", true
 }
 
@@ -472,10 +478,7 @@ func (v *antreaPolicyValidator) validatePeers(ingress, egress []crdv1alpha1.Rule
 			if peer.NamespaceSelector != nil && peer.Namespaces != nil {
 				return "namespaces and namespaceSelector cannot be set at the same time for a single NetworkPolicyPeer", false
 			}
-			if peer.Group == "" {
-				continue
-			}
-			if peer.PodSelector != nil || peer.IPBlock != nil || peer.NamespaceSelector != nil {
+			if peer.Group != "" && numFieldsSetInPeer(peer) > 1 {
 				return "group cannot be set with other peers in rules", false
 			}
 		}
@@ -504,6 +507,18 @@ func (v *antreaPolicyValidator) validatePeers(ingress, egress []crdv1alpha1.Rule
 	return "", true
 }
 
+// numFieldsSetInPeer returns the number of fields in use of a peer.
+func numFieldsSetInPeer(peer crdv1alpha1.NetworkPolicyPeer) int {
+	num := 0
+	v := reflect.ValueOf(peer)
+	for i := 0; i < v.NumField(); i++ {
+		if !v.Field(i).IsZero() {
+			num++
+		}
+	}
+	return num
+}
+
 // validateTierForPolicy validates whether a referenced Tier exists.
 func (v *antreaPolicyValidator) validateTierForPolicy(tier string) (string, bool) {
 	// "tier" must exist before referencing
diff --git a/pkg/features/antrea_features.go b/pkg/features/antrea_features.go
index 113214952..d81eae4fe 100644
--- a/pkg/features/antrea_features.go
+++ b/pkg/features/antrea_features.go
@@ -76,6 +76,10 @@ const (
 	// alpha: v1.4
 	// Enable flexible IPAM for Pods.
 	AntreaIPAM featuregate.Feature = "AntreaIPAM"
+
+	// alpha: v1.5
+	// Enable Multicast.
+	Multicast featuregate.Feature = "Multicast"
 )
 
 var (
@@ -100,6 +104,7 @@ var (
 		NetworkPolicyStats: {Default: true, PreRelease: featuregate.Beta},
 		NodePortLocal:      {Default: true, PreRelease: featuregate.Beta},
 		NodeIPAM:           {Default: false, PreRelease: featuregate.Alpha},
+		Multicast:          {Default: false, PreRelease: featuregate.Alpha},
 	}
 
 	// UnsupportedFeaturesOnWindows records the features not supported on
@@ -116,6 +121,7 @@ var (
 		NodePortLocal: {},
 		Egress:        {},
 		AntreaIPAM:    {},
+		Multicast:     {},
 	}
 )
 
diff --git a/pkg/ovs/openflow/interfaces.go b/pkg/ovs/openflow/interfaces.go
index c40e6d98b..38ed64472 100644
--- a/pkg/ovs/openflow/interfaces.go
+++ b/pkg/ovs/openflow/interfaces.go
@@ -18,6 +18,7 @@ import (
 	"net"
 	"time"
 
+	"antrea.io/libOpenflow/util"
 	"antrea.io/ofnet/ofctrl"
 )
 
@@ -46,6 +47,7 @@ const (
 	ProtocolSCTPv6 Protocol = "sctpv6"
 	ProtocolICMP   Protocol = "icmp"
 	ProtocolICMPv6 Protocol = "icmpv6"
+	ProtocolIGMP   Protocol = "igmp"
 )
 
 const (
@@ -382,6 +384,7 @@ type PacketOutBuilder interface {
 	AddLoadAction(name string, data uint64, rng *Range) PacketOutBuilder
 	AddLoadRegMark(mark *RegMark) PacketOutBuilder
 	AddResubmitAction(inPort *uint16, table *uint8) PacketOutBuilder
+	SetL4Packet(packet util.Message) PacketOutBuilder
 	Done() *ofctrl.PacketOut
 }
 
diff --git a/pkg/ovs/openflow/ofctrl_builder.go b/pkg/ovs/openflow/ofctrl_builder.go
index 4a07782ad..83da4be30 100644
--- a/pkg/ovs/openflow/ofctrl_builder.go
+++ b/pkg/ovs/openflow/ofctrl_builder.go
@@ -487,6 +487,9 @@ func (b *ofFlowBuilder) MatchProtocol(protocol Protocol) FlowBuilder {
 	case ProtocolICMPv6:
 		b.Match.Ethertype = 0x86dd
 		b.Match.IpProto = 58
+	case ProtocolIGMP:
+		b.Match.Ethertype = 0x0800
+		b.Match.IpProto = 2
 	}
 	b.protocol = protocol
 	return b
diff --git a/pkg/ovs/openflow/ofctrl_packetout.go b/pkg/ovs/openflow/ofctrl_packetout.go
index e0ecb5106..7ad37c20c 100644
--- a/pkg/ovs/openflow/ofctrl_packetout.go
+++ b/pkg/ovs/openflow/ofctrl_packetout.go
@@ -20,6 +20,7 @@ import (
 	"net"
 
 	"antrea.io/libOpenflow/protocol"
+	"antrea.io/libOpenflow/util"
 	"antrea.io/ofnet/ofctrl"
 	"k8s.io/klog/v2"
 )
@@ -103,6 +104,8 @@ func (b *ofPacketOutBuilder) SetIPProtocol(proto Protocol) PacketOutBuilder {
 		b.pktOut.IPHeader.Protocol = 0x84
 	case ProtocolICMP:
 		b.pktOut.IPHeader.Protocol = protocol.Type_ICMP
+	case ProtocolIGMP:
+		b.pktOut.IPHeader.Protocol = protocol.Type_IGMP
 	default:
 		b.pktOut.IPHeader.Protocol = 0xff
 	}
@@ -286,6 +289,13 @@ func (b *ofPacketOutBuilder) SetOutport(outport uint32) PacketOutBuilder {
 	return b
 }
 
+// SetL4Packet sets the L4 packet of the packetOut message. It provides a generic function to create a packet
+// of protocol other than TCP/UDP/ICMP.
+func (b *ofPacketOutBuilder) SetL4Packet(packet util.Message) PacketOutBuilder {
+	b.pktOut.IPHeader.Data = packet
+	return b
+}
+
 // AddLoadAction loads the data to the target field at specified range when the packet is received by OVS Switch.
 func (b *ofPacketOutBuilder) AddLoadAction(name string, data uint64, rng *Range) PacketOutBuilder {
 	act, _ := ofctrl.NewNXLoadAction(name, data, rng.ToNXRange())
@@ -330,6 +340,18 @@ func (b *ofPacketOutBuilder) Done() *ofctrl.PacketOut {
 			b.pktOut.UDPHeader.Length = b.pktOut.UDPHeader.Len()
 			b.pktOut.UDPHeader.Checksum = b.udpHeaderChecksum()
 			b.pktOut.IPHeader.Length = 20 + b.pktOut.UDPHeader.Len()
+		} else if b.pktOut.IPHeader.Protocol == protocol.Type_IGMP {
+			if igmpv1or2, ok := b.pktOut.IPHeader.Data.(*protocol.IGMPv1or2); ok {
+				igmpv1or2.Checksum = 0
+				igmpv1or2.Checksum = b.igmpHeaderChecksum()
+			} else if igmpv3Query, ok := b.pktOut.IPHeader.Data.(*protocol.IGMPv3Query); ok {
+				igmpv3Query.Checksum = 0
+				igmpv3Query.Checksum = b.igmpHeaderChecksum()
+			} else if igmpv3Report, ok := b.pktOut.IPHeader.Data.(*protocol.IGMPv3MembershipReport); ok {
+				igmpv3Report.Checksum = 0
+				igmpv3Report.Checksum = b.igmpHeaderChecksum()
+			}
+			b.pktOut.IPHeader.Length = 20 + b.pktOut.IPHeader.Data.Len()
 		}
 		if b.pktOut.IPHeader.Id == 0 {
 			// #nosec G404: random number generator not used for security purposes
@@ -421,6 +443,12 @@ func (b *ofPacketOutBuilder) udpHeaderChecksum() uint16 {
 	return checksum
 }
 
+func (b *ofPacketOutBuilder) igmpHeaderChecksum() uint16 {
+	data, _ := b.pktOut.IPHeader.Data.MarshalBinary()
+	checksum := checksum(data)
+	return checksum
+}
+
 func (b *ofPacketOutBuilder) generatePseudoHeader(length uint16) []byte {
 	var pseudoHeader []byte
 	if b.pktOut.IPv6Header == nil {
diff --git a/pkg/ovs/ovsconfig/interfaces.go b/pkg/ovs/ovsconfig/interfaces.go
index 9fde8260e..b49fbc4c0 100644
--- a/pkg/ovs/ovsconfig/interfaces.go
+++ b/pkg/ovs/ovsconfig/interfaces.go
@@ -55,4 +55,5 @@ type OVSBridgeClient interface {
 	IsHardwareOffloadEnabled() bool
 	GetOVSDatapathType() OVSDatapathType
 	SetInterfaceType(name, ifType string) Error
+	SetPortExternalIDs(portName string, externalIDs map[string]interface{}) Error
 }
diff --git a/pkg/ovs/ovsconfig/ovs_client.go b/pkg/ovs/ovsconfig/ovs_client.go
index 695158b25..936dfff1a 100644
--- a/pkg/ovs/ovsconfig/ovs_client.go
+++ b/pkg/ovs/ovsconfig/ovs_client.go
@@ -911,3 +911,20 @@ func (br *OVSBridge) SetInterfaceType(name, ifType string) Error {
 	}
 	return nil
 }
+
+func (br *OVSBridge) SetPortExternalIDs(portName string, externalIDs map[string]interface{}) Error {
+	tx := br.ovsdb.Transaction(openvSwitchSchema)
+	tx.Update(dbtransaction.Update{
+		Table: "Port",
+		Where: [][]interface{}{{"name", "==", portName}},
+		Row: map[string]interface{}{
+			"external_ids": helpers.MakeOVSDBMap(externalIDs),
+		},
+	})
+	_, err, temporary := tx.Commit()
+	if err != nil {
+		klog.Error("Transaction failed", err)
+		return NewTransactionError(err, temporary)
+	}
+	return nil
+}
diff --git a/pkg/support/dump.go b/pkg/support/dump.go
index 33eb0dd50..f60e9b201 100644
--- a/pkg/support/dump.go
+++ b/pkg/support/dump.go
@@ -16,7 +16,6 @@ package support
 
 import (
 	"bufio"
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
@@ -28,6 +27,7 @@ import (
 	"time"
 
 	"github.com/spf13/afero"
+	"gopkg.in/yaml.v2"
 	"k8s.io/utils/exec"
 
 	agentquerier "antrea.io/antrea/pkg/agent/querier"
@@ -216,8 +216,8 @@ func directoryCopy(fs afero.Fs, targetDir string, srcDir string, prefixFilter st
 	})
 }
 
-// writeFile writes the given data to the specified filePath. Param "resource" is used to identify the type of the given
-// data in the error message.
+// writeFile writes the given data to the specified filePath. Param "resource" is used to identify
+// the type of the given data in the error message.
 func writeFile(fs afero.Fs, filePath string, resource string, data []byte) error {
 	err := afero.WriteFile(fs, filePath, data, 0644)
 	if err != nil {
@@ -226,6 +226,22 @@ func writeFile(fs afero.Fs, filePath string, resource string, data []byte) error
 	return nil
 }
 
+// writeYAMLFile writes the given data to the specified filePath in YAML format. Param "resource" is
+// used to identify the type of the given data in the error message.
+func writeYAMLFile(fs afero.Fs, filePath string, resource string, data interface{}) error {
+	f, err := fs.Create(filePath)
+	if err != nil {
+		return fmt.Errorf("error when creating file %s to write %s: %w", filePath, resource, err)
+	}
+	defer f.Close()
+	encoder := yaml.NewEncoder(f)
+	defer encoder.Close()
+	if err := encoder.Encode(data); err != nil {
+		return fmt.Errorf("error when writing %s to %s in YAML format: %w", resource, filePath, err)
+	}
+	return nil
+}
+
 type controllerDumper struct {
 	fs       afero.Fs
 	executor exec.Interface
@@ -268,28 +284,14 @@ type agentDumper struct {
 }
 
 func (d *agentDumper) DumpAgentInfo(basedir string) error {
-	ci := new(clusterinformationv1beta1.AntreaAgentInfo)
-	d.aq.GetAgentInfo(ci, false)
-	f, err := d.fs.Create(filepath.Join(basedir, "agentinfo"))
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-	encoder := json.NewEncoder(f)
-	encoder.SetIndent("", "  ")
-	return encoder.Encode(ci)
+	ai := new(clusterinformationv1beta1.AntreaAgentInfo)
+	d.aq.GetAgentInfo(ai, false)
+	return writeYAMLFile(d.fs, filepath.Join(basedir, "agentinfo"), "agentinfo", ai)
 }
 
 func (d *agentDumper) DumpNetworkPolicyResources(basedir string) error {
 	dump := func(o interface{}, name string) error {
-		f, err := d.fs.Create(filepath.Join(basedir, "agentinfo"))
-		if err != nil {
-			return err
-		}
-		defer f.Close()
-		encoder := json.NewEncoder(f)
-		encoder.SetIndent("", "  ")
-		return encoder.Encode(o)
+		return writeYAMLFile(d.fs, filepath.Join(basedir, name), name, o)
 	}
 	if err := dump(d.npq.GetAddressGroups(), "addressgroups"); err != nil {
 		return err


Test patch:
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
index bf97ce2de..0d4f8570f 100644
--- a/pkg/agent/agent_test.go
+++ b/pkg/agent/agent_test.go
@@ -107,6 +107,23 @@ func TestInitstore(t *testing.T) {
 	if !found2 {
 		t.Errorf("Failed to load OVS port into local store")
 	}
+
+	// OVS port external_ids should be updated to set AntreaInterfaceTypeKey if it doesn't exist in OVSPortData.
+	delete(ovsPort1.ExternalIDs, interfacestore.AntreaInterfaceTypeKey)
+	delete(ovsPort2.ExternalIDs, interfacestore.AntreaInterfaceTypeKey)
+	initOVSPorts2 := []ovsconfig.OVSPortData{ovsPort1, ovsPort2}
+	mockOVSBridgeClient.EXPECT().GetPortList().Return(initOVSPorts2, nil)
+	updateExtIDsFunc := func(p ovsconfig.OVSPortData) map[string]interface{} {
+		extIDs := make(map[string]interface{})
+		for k, v := range p.ExternalIDs {
+			extIDs[k] = v
+		}
+		extIDs[interfacestore.AntreaInterfaceTypeKey] = interfacestore.AntreaContainer
+		return extIDs
+	}
+	mockOVSBridgeClient.EXPECT().SetPortExternalIDs(ovsPort1.Name, updateExtIDsFunc(ovsPort1)).Return(nil)
+	mockOVSBridgeClient.EXPECT().SetPortExternalIDs(ovsPort2.Name, updateExtIDsFunc(ovsPort2)).Return(nil)
+	initializer.initInterfaceStore()
 }
 
 func TestPersistRoundNum(t *testing.T) {
diff --git a/pkg/agent/controller/egress/egress_controller_test.go b/pkg/agent/controller/egress/egress_controller_test.go
index 31e54b7cf..5d43a4227 100644
--- a/pkg/agent/controller/egress/egress_controller_test.go
+++ b/pkg/agent/controller/egress/egress_controller_test.go
@@ -33,8 +33,9 @@ import (
 	k8stesting "k8s.io/client-go/testing"
 	"k8s.io/client-go/util/workqueue"
 
-	ipassignertest "antrea.io/antrea/pkg/agent/controller/egress/ipassigner/testing"
 	"antrea.io/antrea/pkg/agent/interfacestore"
+	"antrea.io/antrea/pkg/agent/ipassigner"
+	ipassignertest "antrea.io/antrea/pkg/agent/ipassigner/testing"
 	openflowtest "antrea.io/antrea/pkg/agent/openflow/testing"
 	routetest "antrea.io/antrea/pkg/agent/route/testing"
 	"antrea.io/antrea/pkg/agent/util"
@@ -65,7 +66,7 @@ func (d *fakeLocalIPDetector) Run(stopCh <-chan struct{}) {
 	<-stopCh
 }
 
-func (d *fakeLocalIPDetector) AddEventHandler(handler eventHandler) {
+func (d *fakeLocalIPDetector) AddEventHandler(handler ipassigner.LocalIPEventHandler) {
 	return
 }
 
@@ -73,7 +74,7 @@ func (d *fakeLocalIPDetector) HasSynced() bool {
 	return true
 }
 
-var _ LocalIPDetector = &fakeLocalIPDetector{}
+var _ ipassigner.LocalIPDetector = &fakeLocalIPDetector{}
 
 type antreaClientGetter struct {
 	clientset versioned.Interface
diff --git a/pkg/agent/interfacestore/testing/mock_interfacestore.go b/pkg/agent/interfacestore/testing/mock_interfacestore.go
index 0d480cc34..3e8aa50fb 100644
--- a/pkg/agent/interfacestore/testing/mock_interfacestore.go
+++ b/pkg/agent/interfacestore/testing/mock_interfacestore.go
@@ -160,6 +160,21 @@ func (mr *MockInterfaceStoreMockRecorder) GetInterfaceByName(arg0 interface{}) *
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInterfaceByName", reflect.TypeOf((*MockInterfaceStore)(nil).GetInterfaceByName), arg0)
 }
 
+// GetInterfaceByOFPort mocks base method
+func (m *MockInterfaceStore) GetInterfaceByOFPort(arg0 uint32) (*interfacestore.InterfaceConfig, bool) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetInterfaceByOFPort", arg0)
+	ret0, _ := ret[0].(*interfacestore.InterfaceConfig)
+	ret1, _ := ret[1].(bool)
+	return ret0, ret1
+}
+
+// GetInterfaceByOFPort indicates an expected call of GetInterfaceByOFPort
+func (mr *MockInterfaceStoreMockRecorder) GetInterfaceByOFPort(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInterfaceByOFPort", reflect.TypeOf((*MockInterfaceStore)(nil).GetInterfaceByOFPort), arg0)
+}
+
 // GetInterfaceKeysByType mocks base method
 func (m *MockInterfaceStore) GetInterfaceKeysByType(arg0 interfacestore.InterfaceType) []string {
 	m.ctrl.T.Helper()
diff --git a/pkg/agent/controller/egress/ipassigner/testing/mock_ipassigner.go b/pkg/agent/ipassigner/testing/mock_ipassigner.go
similarity index 96%
rename from pkg/agent/controller/egress/ipassigner/testing/mock_ipassigner.go
rename to pkg/agent/ipassigner/testing/mock_ipassigner.go
index 0569cb0f8..4baecb05c 100644
--- a/pkg/agent/controller/egress/ipassigner/testing/mock_ipassigner.go
+++ b/pkg/agent/ipassigner/testing/mock_ipassigner.go
@@ -14,7 +14,7 @@
 //
 
 // Code generated by MockGen. DO NOT EDIT.
-// Source: antrea.io/antrea/pkg/agent/controller/egress/ipassigner (interfaces: IPAssigner)
+// Source: antrea.io/antrea/pkg/agent/ipassigner (interfaces: IPAssigner)
 
 // Package testing is a generated GoMock package.
 package testing
diff --git a/pkg/agent/memberlist/cluster_test.go b/pkg/agent/memberlist/cluster_test.go
index e82079464..ce8e58dff 100644
--- a/pkg/agent/memberlist/cluster_test.go
+++ b/pkg/agent/memberlist/cluster_test.go
@@ -53,7 +53,6 @@ func newFakeCluster(nodeConfig *config.NodeConfig, stopCh <-chan struct{}, i int
 	informerFactory := informers.NewSharedInformerFactory(clientset, 0)
 
 	nodeInformer := informerFactory.Core().V1().Nodes()
-
 	crdClient := fakeversioned.NewSimpleClientset([]runtime.Object{}...)
 	crdInformerFactory := crdinformers.NewSharedInformerFactory(crdClient, 0)
 	ipPoolInformer := crdInformerFactory.Crd().V1alpha2().ExternalIPPools()
@@ -159,7 +158,7 @@ func TestCluster_Run(t *testing.T) {
 			}))
 
 			tCase.egress.Spec.ExternalIPPool = eip.Name
-			res, err := fakeCluster.cluster.ShouldSelectEgress(tCase.egress)
+			res, err := fakeCluster.cluster.ShouldSelectIP(tCase.egress.Spec.EgressIP, eip.Name)
 			// Cluster should hold the same consistent hash ring for each ExternalIPPool.
 			assert.NoError(t, err)
 			allMembers, err := fakeCluster.cluster.allClusterMembers()
@@ -259,7 +258,7 @@ func TestCluster_RunClusterEvents(t *testing.T) {
 		t.Run(tCase.name, func(t *testing.T) {
 			localNode.Labels = tCase.newNodeLabels
 			updateNode(localNode)
-			res, err := fakeCluster.cluster.ShouldSelectEgress(tCase.egress)
+			res, err := fakeCluster.cluster.ShouldSelectIP(tCase.egress.Spec.EgressIP, tCase.egress.Spec.ExternalIPPool)
 			assert.NoError(t, err)
 			assert.Equal(t, tCase.expectEgressSelectResult, res, "select Node for Egress result not match")
 		})
@@ -305,7 +304,7 @@ func TestCluster_RunClusterEvents(t *testing.T) {
 				newEIP, _ := fakeCluster.cluster.externalIPPoolLister.Get(fakeEIP1.Name)
 				return reflect.DeepEqual(fakeEIP1, newEIP), nil
 			}))
-			res, err := fakeCluster.cluster.ShouldSelectEgress(fakeEgress1)
+			res, err := fakeCluster.cluster.ShouldSelectIP(fakeEgress1.Spec.EgressIP, fakeEgress1.Spec.ExternalIPPool)
 			assert.NoError(t, err)
 			assert.Equal(t, tCase.expectEgressSelectResult, res, "select Node for Egress result not match")
 		})
@@ -332,7 +331,7 @@ func TestCluster_RunClusterEvents(t *testing.T) {
 		return reflect.DeepEqual(newEIP, fakeEIP2), nil
 	}))
 	assertEgressSelectResult := func(egress *crdv1a2.Egress, expectedRes bool, hasSyncedErr bool) {
-		res, err := fakeCluster.cluster.ShouldSelectEgress(egress)
+		res, err := fakeCluster.cluster.ShouldSelectIP(egress.Spec.EgressIP, egress.Spec.ExternalIPPool)
 		if !hasSyncedErr {
 			assert.NoError(t, err)
 		}
@@ -466,7 +465,7 @@ func TestCluster_ConsistentHashDistribute(t *testing.T) {
 						ObjectMeta: metav1.ObjectMeta{Name: "fakeEgress"},
 						Spec:       crdv1a2.EgressSpec{ExternalIPPool: fakeEIPName, EgressIP: fmt.Sprintf("10.1.1.%d", i)},
 					}
-					selected, err := fakeCluster.ShouldSelectEgress(fakeEgress)
+					selected, err := fakeCluster.ShouldSelectIP(fakeEgress.Spec.EgressIP, fakeEgress.Spec.ExternalIPPool)
 					assert.NoError(t, err)
 					if selected {
 						selectedNodes = append(selectedNodes, i)
@@ -535,7 +534,7 @@ func TestCluster_ShouldSelectEgress(t *testing.T) {
 			for i := 0; i < tCase.nodeNum; i++ {
 				node := fmt.Sprintf("node-%d", i)
 				fakeCluster.nodeName = node
-				selected, err := fakeCluster.ShouldSelectEgress(fakeEgress)
+				selected, err := fakeCluster.ShouldSelectIP(fakeEgress.Spec.EgressIP, fakeEgress.Spec.ExternalIPPool)
 				assert.NoError(t, err)
 				assert.Equal(t, node == tCase.expectedNode, selected, "Selected Node for Egress not match")
 			}
@@ -574,7 +573,7 @@ func BenchmarkCluster_ShouldSelect(b *testing.B) {
 		b.Run(fmt.Sprintf("%s-nodeSelectedForEgress", bc.name), func(b *testing.B) {
 			b.ResetTimer()
 			for i := 0; i < b.N; i++ {
-				fakeCluster.ShouldSelectEgress(fakeEgress)
+				fakeCluster.ShouldSelectIP(fakeEgress.Spec.EgressIP, fakeEgress.Spec.ExternalIPPool)
 			}
 		})
 	}
diff --git a/pkg/agent/multicast/mcast_controller_test.go b/pkg/agent/multicast/mcast_controller_test.go
new file mode 100644
index 000000000..29abc37ad
--- /dev/null
+++ b/pkg/agent/multicast/mcast_controller_test.go
@@ -0,0 +1,251 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package multicast
+
+import (
+	"net"
+	"sync"
+	"testing"
+	"time"
+
+	"antrea.io/libOpenflow/openflow13"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/assert"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/tools/cache"
+
+	"antrea.io/antrea/pkg/agent/config"
+	"antrea.io/antrea/pkg/agent/interfacestore"
+	ifaceStoretest "antrea.io/antrea/pkg/agent/interfacestore/testing"
+	openflowtest "antrea.io/antrea/pkg/agent/openflow/testing"
+)
+
+var (
+	mockOFClient   *openflowtest.MockClient
+	mockIfaceStore *ifaceStoretest.MockInterfaceStore
+	mgroup         = net.ParseIP("224.96.1.3")
+	if1            = &interfacestore.InterfaceConfig{
+		Type:          interfacestore.ContainerInterface,
+		InterfaceName: "if1",
+		IPs:           []net.IP{net.ParseIP("192.168.1.1")},
+	}
+	if2 = &interfacestore.InterfaceConfig{
+		Type:          interfacestore.ContainerInterface,
+		InterfaceName: "if2",
+		IPs:           []net.IP{net.ParseIP("192.168.1.2")},
+	}
+)
+
+func TestAddGroupMemberStatus(t *testing.T) {
+	event := &mcastGroupEvent{
+		group: mgroup,
+		eType: groupJoin,
+		time:  time.Now(),
+		iface: if1,
+	}
+	mctrl := newMockMulticastController(t)
+	mctrl.addGroupMemberStatus(event)
+	groupCache := mctrl.groupCache
+	compareGroupStatus(t, groupCache, event)
+	obj, _ := mctrl.queue.Get()
+	key, ok := obj.(string)
+	assert.True(t, ok)
+	assert.Equal(t, mgroup.String(), key)
+	mockOFClient.EXPECT().InstallMulticastFlow(mgroup).Times(1)
+	err := mctrl.syncGroup(key)
+	assert.Nil(t, err)
+	mctrl.queue.Forget(obj)
+}
+
+func TestUpdateGroupMemberStatus(t *testing.T) {
+	mctrl := newMockMulticastController(t)
+	igmpMaxResponseTime = time.Second * 1
+	event := &mcastGroupEvent{
+		group: mgroup,
+		eType: groupJoin,
+		time:  time.Now(),
+		iface: if1,
+	}
+	mctrl.addGroupMemberStatus(event)
+	obj, _, _ := mctrl.groupCache.GetByKey(event.group.String())
+	mockOFClient.EXPECT().SendIGMPQueryPacketOut(igmpQueryDstMac, mcastAllHosts, uint32(openflow13.P_NORMAL), gomock.Any()).Times(1)
+	for _, e := range []*mcastGroupEvent{
+		{group: mgroup, eType: groupJoin, time: event.time.Add(time.Second * 20), iface: if1},
+		{group: mgroup, eType: groupJoin, time: event.time.Add(time.Second * 40), iface: if1},
+		{group: mgroup, eType: groupJoin, time: event.time.Add(time.Second * 60), iface: if2},
+		{group: mgroup, eType: groupLeave, time: event.time.Add(time.Second * 61), iface: if1},
+		{group: mgroup, eType: groupLeave, time: event.time.Add(time.Second * 62), iface: if2},
+	} {
+		mctrl.updateGroupMemberStatus(obj, e)
+		groupCache := mctrl.groupCache
+		compareGroupStatus(t, groupCache, e)
+		groups := mctrl.getGroupMemberStatusesByPod(e.iface.InterfaceName)
+		if e.eType == groupJoin {
+			assert.True(t, len(groups) > 0)
+		} else {
+			assert.True(t, len(groups) == 0)
+		}
+	}
+}
+
+func TestCheckLastMember(t *testing.T) {
+	mctrl := newMockMulticastController(t)
+	workerCount = 1
+	igmpMaxResponseTime = time.Second * 1
+	lastProbe := time.Now()
+	testCheckLastMember := func(ev *mcastGroupEvent, expExist bool) {
+		status := &GroupMemberStatus{
+			localMembers:   sets.NewString(),
+			lastIGMPReport: lastProbe,
+		}
+		if ev != nil {
+			status.group = ev.group
+		} else {
+			status.group = mgroup
+		}
+		_ = mctrl.groupCache.Add(status)
+		mctrl.addInstalledGroup(status.group.String())
+		mockOFClient.EXPECT().SendIGMPQueryPacketOut(igmpQueryDstMac, mcastAllHosts, uint32(openflow13.P_NORMAL), gomock.Any()).AnyTimes()
+		var wg sync.WaitGroup
+		wg.Add(1)
+		go func() {
+			mctrl.checkLastMember(status.group)
+			// Wait igmpMaxResponseTime to ensure the group is added into mctrl.queue.
+			time.Sleep(igmpMaxResponseTime)
+			wg.Done()
+		}()
+		if ev != nil {
+			mctrl.addOrUpdateGroupEvent(ev)
+		}
+		wg.Wait()
+		obj, _ := mctrl.queue.Get()
+		key, ok := obj.(string)
+		assert.True(t, ok)
+		assert.Equal(t, status.group.String(), key)
+		err := mctrl.syncGroup(key)
+		assert.Nil(t, err)
+		_, exists, err := mctrl.groupCache.GetByKey(key)
+		assert.Nil(t, err)
+		assert.Equal(t, expExist, exists)
+		// Clear cache to avoid affecting the next test.
+		if _, ok, _ := mctrl.groupCache.GetByKey(key); ok {
+			_ = mctrl.groupCache.Delete(status)
+		}
+		mctrl.queue.Forget(obj)
+	}
+	mockOFClient.EXPECT().UninstallMulticastFlow(gomock.Any()).Times(2)
+	for _, tc := range []struct {
+		ev     *mcastGroupEvent
+		exists bool
+	}{
+		{ev: &mcastGroupEvent{group: net.ParseIP("224.96.1.5"), eType: groupJoin, time: lastProbe.Add(time.Second * 1), iface: if1}, exists: true},
+		{ev: &mcastGroupEvent{group: net.ParseIP("224.96.1.6"), eType: groupLeave, time: lastProbe.Add(time.Second * 1), iface: if1}, exists: false},
+		{ev: nil, exists: false},
+	} {
+		testCheckLastMember(tc.ev, tc.exists)
+	}
+}
+
+func TestClearStaleGroups(t *testing.T) {
+	mctrl := newMockMulticastController(t)
+	workerCount = 1
+
+	mockOFClient.EXPECT().InstallMulticastInitialFlows(gomock.Any()).Times(1)
+	err := mctrl.Initialize()
+	assert.Nil(t, err)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		mctrl.worker()
+		wg.Done()
+	}()
+
+	now := time.Now()
+	validGroups := []*GroupMemberStatus{
+		{
+			group:          net.ParseIP("224.96.1.2"),
+			localMembers:   sets.NewString("p1", "p2"),
+			lastIGMPReport: now.Add(-queryInterval),
+		},
+		{
+			group:          net.ParseIP("224.96.1.3"),
+			localMembers:   sets.NewString(),
+			lastIGMPReport: now.Add(-queryInterval),
+		},
+	}
+	staleGroups := []*GroupMemberStatus{
+		{
+			group:          net.ParseIP("224.96.1.4"),
+			localMembers:   sets.NewString("p1", "p3"),
+			lastIGMPReport: now.Add(-mcastGroupTimeout - time.Second),
+		},
+		{
+			group:          net.ParseIP("224.96.1.5"),
+			localMembers:   sets.NewString(),
+			lastIGMPReport: now.Add(-mcastGroupTimeout - time.Second),
+		},
+	}
+	for _, g := range validGroups {
+		err := mctrl.groupCache.Add(g)
+		assert.Nil(t, err)
+		mctrl.addInstalledGroup(g.group.String())
+	}
+	for _, g := range staleGroups {
+		err := mctrl.groupCache.Add(g)
+		assert.Nil(t, err)
+		mctrl.addInstalledGroup(g.group.String())
+	}
+	mockOFClient.EXPECT().UninstallMulticastFlow(gomock.Any()).Times(len(staleGroups))
+	mctrl.clearStaleGroups()
+	mctrl.queue.ShutDown()
+	wg.Wait()
+	assert.Equal(t, len(validGroups), len(mctrl.groupCache.List()))
+	for _, g := range validGroups {
+		_, exists, _ := mctrl.groupCache.GetByKey(g.group.String())
+		assert.True(t, exists)
+	}
+	for _, g := range staleGroups {
+		_, exists, _ := mctrl.groupCache.GetByKey(g.group.String())
+		assert.False(t, exists)
+	}
+}
+
+func compareGroupStatus(t *testing.T, cache cache.Indexer, event *mcastGroupEvent) {
+	obj, exits, err := cache.GetByKey(event.group.String())
+	assert.Nil(t, err)
+	assert.Truef(t, exits, "failed to add group to cache")
+	status, ok := obj.(*GroupMemberStatus)
+	assert.Equal(t, true, ok)
+	assert.Equal(t, event.group, status.group)
+	if event.eType == groupJoin {
+		assert.True(t, status.lastIGMPReport.Equal(event.time) || status.lastIGMPReport.After(event.time))
+		exists := status.localMembers.Has(event.iface.InterfaceName)
+		assert.Truef(t, exists, "member is not added into cache")
+	} else {
+		assert.True(t, status.lastIGMPReport.Before(event.time))
+		exists := status.localMembers.Has(event.iface.InterfaceName)
+		assert.Falsef(t, exists, "member is not removed from cache")
+	}
+}
+
+func newMockMulticastController(t *testing.T) *Controller {
+	controller := gomock.NewController(t)
+	mockOFClient = openflowtest.NewMockClient(controller)
+	mockIfaceStore = ifaceStoretest.NewMockInterfaceStore(controller)
+	nodeConfig := &config.NodeConfig{}
+	mockOFClient.EXPECT().RegisterPacketInHandler(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
+	return NewMulticastController(mockOFClient, nodeConfig, mockIfaceStore)
+}
diff --git a/pkg/agent/openflow/client_test.go b/pkg/agent/openflow/client_test.go
index bd7b5f911..18207b1db 100644
--- a/pkg/agent/openflow/client_test.go
+++ b/pkg/agent/openflow/client_test.go
@@ -103,7 +103,7 @@ func TestIdempotentFlowInstallation(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			defer ctrl.Finish()
 			m := oftest.NewMockOFEntryOperations(ctrl)
-			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false)
+			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false, false)
 			client := ofClient.(*client)
 			client.cookieAllocator = cookie.NewAllocator(0)
 			client.ofEntryOperations = m
@@ -132,7 +132,7 @@ func TestIdempotentFlowInstallation(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			defer ctrl.Finish()
 			m := oftest.NewMockOFEntryOperations(ctrl)
-			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false)
+			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false, false)
 			client := ofClient.(*client)
 			client.cookieAllocator = cookie.NewAllocator(0)
 			client.ofEntryOperations = m
@@ -174,7 +174,7 @@ func TestFlowInstallationFailed(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			defer ctrl.Finish()
 			m := oftest.NewMockOFEntryOperations(ctrl)
-			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false)
+			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false, false)
 			client := ofClient.(*client)
 			client.cookieAllocator = cookie.NewAllocator(0)
 			client.ofEntryOperations = m
@@ -209,7 +209,7 @@ func TestConcurrentFlowInstallation(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			defer ctrl.Finish()
 			m := oftest.NewMockOFEntryOperations(ctrl)
-			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false)
+			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, false, false, false, false, false, false)
 			client := ofClient.(*client)
 			client.cookieAllocator = cookie.NewAllocator(0)
 			client.ofEntryOperations = m
@@ -400,7 +400,7 @@ func Test_client_SendTraceflowPacket(t *testing.T) {
 }
 
 func prepareTraceflowFlow(ctrl *gomock.Controller) *client {
-	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false)
+	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false, false)
 	c := ofClient.(*client)
 	c.cookieAllocator = cookie.NewAllocator(0)
 	c.nodeConfig = nodeConfig
@@ -418,7 +418,7 @@ func prepareTraceflowFlow(ctrl *gomock.Controller) *client {
 }
 
 func prepareSendTraceflowPacket(ctrl *gomock.Controller, success bool) *client {
-	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false)
+	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false, false)
 	c := ofClient.(*client)
 	c.nodeConfig = nodeConfig
 	m := ovsoftest.NewMockBridge(ctrl)
@@ -506,7 +506,7 @@ func Test_client_setBasePacketOutBuilder(t *testing.T) {
 }
 
 func prepareSetBasePacketOutBuilder(ctrl *gomock.Controller, success bool) *client {
-	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false)
+	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, true, true, false, false, false, false, false)
 	c := ofClient.(*client)
 	m := ovsoftest.NewMockBridge(ctrl)
 	c.bridge = m
diff --git a/pkg/agent/openflow/network_policy_test.go b/pkg/agent/openflow/network_policy_test.go
index b46dd9d06..d1c983d44 100644
--- a/pkg/agent/openflow/network_policy_test.go
+++ b/pkg/agent/openflow/network_policy_test.go
@@ -507,7 +507,7 @@ func TestBatchInstallPolicyRuleFlows(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			defer ctrl.Finish()
 			mockOperations := oftest.NewMockOFEntryOperations(ctrl)
-			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, false, true, false, false, false, false)
+			ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, false, true, false, false, false, false, false)
 			c = ofClient.(*client)
 			c.cookieAllocator = cookie.NewAllocator(0)
 			c.ofEntryOperations = mockOperations
@@ -575,7 +575,7 @@ func BenchmarkBatchInstallPolicyRuleFlows(b *testing.B) {
 	ctrl := gomock.NewController(b)
 	defer ctrl.Finish()
 	mockOperations := oftest.NewMockOFEntryOperations(ctrl)
-	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, false, true, false, false, false, false)
+	ofClient := NewClient(bridgeName, bridgeMgmtAddr, ovsconfig.OVSDatapathSystem, false, true, false, false, false, false, false)
 	c = ofClient.(*client)
 	c.cookieAllocator = cookie.NewAllocator(0)
 	c.ofEntryOperations = mockOperations
diff --git a/pkg/agent/openflow/testing/mock_openflow.go b/pkg/agent/openflow/testing/mock_openflow.go
index b69cbdefa..7647ebe10 100644
--- a/pkg/agent/openflow/testing/mock_openflow.go
+++ b/pkg/agent/openflow/testing/mock_openflow.go
@@ -25,6 +25,7 @@ import (
 	openflow "antrea.io/antrea/pkg/ovs/openflow"
 	ip "antrea.io/antrea/pkg/util/ip"
 	proxy "antrea.io/antrea/third_party/proxy"
+	util "antrea.io/libOpenflow/util"
 	gomock "github.com/golang/mock/gomock"
 	v1 "k8s.io/api/core/v1"
 	net "net"
@@ -378,6 +379,34 @@ func (mr *MockClientMockRecorder) InstallLoadBalancerServiceFromOutsideFlows(arg
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallLoadBalancerServiceFromOutsideFlows", reflect.TypeOf((*MockClient)(nil).InstallLoadBalancerServiceFromOutsideFlows), arg0, arg1, arg2)
 }
 
+// InstallMulticastFlow mocks base method
+func (m *MockClient) InstallMulticastFlow(arg0 net.IP) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "InstallMulticastFlow", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// InstallMulticastFlow indicates an expected call of InstallMulticastFlow
+func (mr *MockClientMockRecorder) InstallMulticastFlow(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallMulticastFlow", reflect.TypeOf((*MockClient)(nil).InstallMulticastFlow), arg0)
+}
+
+// InstallMulticastInitialFlows mocks base method
+func (m *MockClient) InstallMulticastInitialFlows(arg0 byte) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "InstallMulticastInitialFlows", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// InstallMulticastInitialFlows indicates an expected call of InstallMulticastInitialFlows
+func (mr *MockClientMockRecorder) InstallMulticastInitialFlows(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallMulticastInitialFlows", reflect.TypeOf((*MockClient)(nil).InstallMulticastInitialFlows), arg0)
+}
+
 // InstallNodeFlows mocks base method
 func (m *MockClient) InstallNodeFlows(arg0 string, arg1 map[*net.IPNet]net.IP, arg2 *ip.DualStackIPs, arg3 uint32, arg4 net.HardwareAddr) error {
 	m.ctrl.T.Helper()
@@ -612,6 +641,20 @@ func (mr *MockClientMockRecorder) SendICMPPacketOut(arg0, arg1, arg2, arg3, arg4
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendICMPPacketOut", reflect.TypeOf((*MockClient)(nil).SendICMPPacketOut), arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
 }
 
+// SendIGMPQueryPacketOut mocks base method
+func (m *MockClient) SendIGMPQueryPacketOut(arg0 net.HardwareAddr, arg1 net.IP, arg2 uint32, arg3 util.Message) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SendIGMPQueryPacketOut", arg0, arg1, arg2, arg3)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// SendIGMPQueryPacketOut indicates an expected call of SendIGMPQueryPacketOut
+func (mr *MockClientMockRecorder) SendIGMPQueryPacketOut(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendIGMPQueryPacketOut", reflect.TypeOf((*MockClient)(nil).SendIGMPQueryPacketOut), arg0, arg1, arg2, arg3)
+}
+
 // SendTCPPacketOut mocks base method
 func (m *MockClient) SendTCPPacketOut(arg0, arg1, arg2, arg3 string, arg4, arg5 uint32, arg6 bool, arg7, arg8 uint16, arg9 uint32, arg10 byte, arg11 func(openflow.PacketOutBuilder) openflow.PacketOutBuilder) error {
 	m.ctrl.T.Helper()
@@ -708,6 +751,20 @@ func (mr *MockClientMockRecorder) UninstallLoadBalancerServiceFromOutsideFlows(a
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UninstallLoadBalancerServiceFromOutsideFlows", reflect.TypeOf((*MockClient)(nil).UninstallLoadBalancerServiceFromOutsideFlows), arg0, arg1, arg2)
 }
 
+// UninstallMulticastFlow mocks base method
+func (m *MockClient) UninstallMulticastFlow(arg0 net.IP) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UninstallMulticastFlow", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UninstallMulticastFlow indicates an expected call of UninstallMulticastFlow
+func (mr *MockClientMockRecorder) UninstallMulticastFlow(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UninstallMulticastFlow", reflect.TypeOf((*MockClient)(nil).UninstallMulticastFlow), arg0)
+}
+
 // UninstallNodeFlows mocks base method
 func (m *MockClient) UninstallNodeFlows(arg0 string) error {
 	m.ctrl.T.Helper()
diff --git a/pkg/apiserver/handlers/featuregates/handler_test.go b/pkg/apiserver/handlers/featuregates/handler_test.go
index 062f014f1..3256dd8f4 100644
--- a/pkg/apiserver/handlers/featuregates/handler_test.go
+++ b/pkg/apiserver/handlers/featuregates/handler_test.go
@@ -54,6 +54,7 @@ func Test_getGatesResponse(t *testing.T) {
 				{Component: "agent", Name: "FlowExporter", Status: "Disabled", Version: "ALPHA"},
 				{Component: "agent", Name: "NetworkPolicyStats", Status: "Enabled", Version: "BETA"},
 				{Component: "agent", Name: "NodePortLocal", Status: "Enabled", Version: "BETA"},
+				{Component: "agent", Name: "Multicast", Status: "Disabled", Version: "ALPHA"},
 			},
 		},
 	}
diff --git a/pkg/controller/egress/controller_test.go b/pkg/controller/egress/controller_test.go
index 578babe1d..025e3af37 100644
--- a/pkg/controller/egress/controller_test.go
+++ b/pkg/controller/egress/controller_test.go
@@ -32,6 +32,7 @@ import (
 	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/tools/cache"
 
 	"antrea.io/antrea/pkg/apis/controlplane"
 	"antrea.io/antrea/pkg/apis/crd/v1alpha2"
@@ -40,6 +41,7 @@ import (
 	fakeversioned "antrea.io/antrea/pkg/client/clientset/versioned/fake"
 	crdinformers "antrea.io/antrea/pkg/client/informers/externalversions"
 	"antrea.io/antrea/pkg/controller/egress/store"
+	"antrea.io/antrea/pkg/controller/externalippool"
 	"antrea.io/antrea/pkg/controller/grouping"
 )
 
@@ -123,11 +125,12 @@ func newPod(namespace, name string, labels map[string]string, nodeName string, i
 
 type egressController struct {
 	*EgressController
-	client             kubernetes.Interface
-	crdClient          versioned.Interface
-	informerFactory    informers.SharedInformerFactory
-	crdInformerFactory crdinformers.SharedInformerFactory
-	groupingController *grouping.GroupEntityController
+	client              kubernetes.Interface
+	crdClient           versioned.Interface
+	informerFactory     informers.SharedInformerFactory
+	crdInformerFactory  crdinformers.SharedInformerFactory
+	groupingController  *grouping.GroupEntityController
+	externalIPAllocator *externalippool.ExternalIPPoolController
 }
 
 // objects is an initial set of K8s objects that is exposed through the client.
@@ -136,15 +139,15 @@ func newController(objects, crdObjects []runtime.Object) *egressController {
 	crdClient := fakeversioned.NewSimpleClientset(crdObjects...)
 	informerFactory := informers.NewSharedInformerFactory(client, resyncPeriod)
 	crdInformerFactory := crdinformers.NewSharedInformerFactory(crdClient, resyncPeriod)
+	externalIPAllocator := externalippool.NewExternalIPPoolController(crdClient, crdInformerFactory.Crd().V1alpha2().ExternalIPPools())
 	egressGroupStore := store.NewEgressGroupStore()
 	egressInformer := crdInformerFactory.Crd().V1alpha2().Egresses()
-	externalIPPoolInformer := crdInformerFactory.Crd().V1alpha2().ExternalIPPools()
 	groupEntityIndex := grouping.NewGroupEntityIndex()
 	groupingController := grouping.NewGroupEntityController(groupEntityIndex,
 		informerFactory.Core().V1().Pods(),
 		informerFactory.Core().V1().Namespaces(),
 		crdInformerFactory.Crd().V1alpha2().ExternalEntities())
-	controller := NewEgressController(crdClient, groupEntityIndex, egressInformer, externalIPPoolInformer, egressGroupStore)
+	controller := NewEgressController(crdClient, groupEntityIndex, egressInformer, externalIPAllocator, egressGroupStore)
 	return &egressController{
 		controller,
 		client,
@@ -152,6 +155,7 @@ func newController(objects, crdObjects []runtime.Object) *egressController {
 		informerFactory,
 		crdInformerFactory,
 		groupingController,
+		externalIPAllocator,
 	}
 }
 
@@ -304,6 +308,8 @@ func TestAddEgress(t *testing.T) {
 			controller.crdInformerFactory.Start(stopCh)
 			controller.informerFactory.WaitForCacheSync(stopCh)
 			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.externalIPAllocator.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.externalIPAllocator.HasSynced))
 			go controller.groupingInterface.Run(stopCh)
 			go controller.groupingController.Run(stopCh)
 			go controller.Run(stopCh)
@@ -341,11 +347,7 @@ func TestAddEgress(t *testing.T) {
 			require.NoError(t, err)
 			assert.Equal(t, tt.expectedEgressIP, gotEgress.Spec.EgressIP)
 			if gotEgress.Spec.ExternalIPPool != "" && gotEgress.Spec.EgressIP != "" {
-				poolName := gotEgress.Spec.ExternalIPPool
-				eip, err := controller.crdClient.CrdV1alpha2().ExternalIPPools().Get(context.TODO(), poolName, metav1.GetOptions{})
-				require.NoError(t, err)
-				usage := eip.Status.Usage
-				assert.Equal(t, 1, usage.Used, "Expected one used IP in EgressIPPool Status")
+				checkExternalIPPoolUsed(t, controller, gotEgress.Spec.ExternalIPPool, 1)
 			}
 		})
 	}
@@ -359,6 +361,8 @@ func TestUpdateEgress(t *testing.T) {
 	controller.crdInformerFactory.Start(stopCh)
 	controller.informerFactory.WaitForCacheSync(stopCh)
 	controller.crdInformerFactory.WaitForCacheSync(stopCh)
+	go controller.externalIPAllocator.Run(stopCh)
+	require.True(t, cache.WaitForCacheSync(stopCh, controller.externalIPAllocator.HasSynced))
 	go controller.groupingInterface.Run(stopCh)
 	go controller.groupingController.Run(stopCh)
 	go controller.Run(stopCh)
@@ -467,8 +471,6 @@ func TestUpdateEgress(t *testing.T) {
 		return !exists, nil
 	})
 	assert.NoError(t, err, "IP allocation was not deleted after the ExternalIPPool was deleted")
-	_, exists := controller.getIPAllocator(eipFoo2.Name)
-	assert.False(t, exists, "IP allocator was not deleted after the ExternalIPPool was deleted")
 	assert.Equal(t, "", gotEgressIP(), "EgressIP was not deleted after the ExternalIPPool was deleted")
 
 	// Recreate the ExternalIPPool. An EgressIP should be allocated.
@@ -690,10 +692,9 @@ func TestSyncEgressIP(t *testing.T) {
 			controller.crdInformerFactory.Start(stopCh)
 			controller.informerFactory.WaitForCacheSync(stopCh)
 			controller.crdInformerFactory.WaitForCacheSync(stopCh)
-			controller.createOrUpdateIPAllocator(tt.existingExternalIPPool)
-			for _, egress := range tt.existingEgresses {
-				controller.updateIPAllocation(egress)
-			}
+			go controller.externalIPAllocator.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.externalIPAllocator.HasSynced))
+			controller.restoreIPAllocations(tt.existingEgresses)
 			gotEgressIP, err := controller.syncEgressIP(tt.inputEgress)
 			if tt.expectErr {
 				assert.Error(t, err)
@@ -706,60 +707,15 @@ func TestSyncEgressIP(t *testing.T) {
 	}
 }
 
-func TestCreateOrUpdateIPAllocator(t *testing.T) {
-	stopCh := make(chan struct{})
-	defer close(stopCh)
-	controller := newController(nil, nil)
-	controller.informerFactory.Start(stopCh)
-	controller.crdInformerFactory.Start(stopCh)
-	controller.informerFactory.WaitForCacheSync(stopCh)
-	controller.crdInformerFactory.WaitForCacheSync(stopCh)
-
-	ipPool := newExternalIPPool("ipPoolA", "1.1.1.0/30", "", "")
-	changed := controller.createOrUpdateIPAllocator(ipPool)
-	assert.True(t, changed)
-	allocator, exists := controller.getIPAllocator(ipPool.Name)
-	require.True(t, exists)
-	assert.Equal(t, 1, len(allocator))
-	assert.Equal(t, 2, allocator.Total())
-
-	// Append a non-strict CIDR, it should handle it correctly.
-	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, corev1a2.IPRange{CIDR: "1.1.2.1/30"})
-	changed = controller.createOrUpdateIPAllocator(ipPool)
-	assert.True(t, changed)
-	allocator, exists = controller.getIPAllocator(ipPool.Name)
-	require.True(t, exists)
-	assert.Equal(t, 2, len(allocator))
-	assert.Equal(t, 4, allocator.Total())
-
-	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, corev1a2.IPRange{Start: "1.1.3.1", End: "1.1.3.10"})
-	changed = controller.createOrUpdateIPAllocator(ipPool)
-	assert.True(t, changed)
-	allocator, exists = controller.getIPAllocator(ipPool.Name)
-	require.True(t, exists)
-	assert.Equal(t, 3, len(allocator))
-	assert.Equal(t, 14, allocator.Total())
-
-	// IPv6 CIDR shouldn't exclude broadcast address, so total should be increased by 15.
-	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, corev1a2.IPRange{CIDR: "2021:3::aaa1/124"})
-	changed = controller.createOrUpdateIPAllocator(ipPool)
-	assert.True(t, changed)
-	allocator, exists = controller.getIPAllocator(ipPool.Name)
-	require.True(t, exists)
-	assert.Equal(t, 4, len(allocator))
-	assert.Equal(t, 29, allocator.Total())
-
-	// When there is no change, the method should do nothing and the return value should be false.
-	changed = controller.createOrUpdateIPAllocator(ipPool)
-	assert.False(t, changed)
-	allocator, exists = controller.getIPAllocator(ipPool.Name)
-	require.True(t, exists)
-	assert.Equal(t, 4, len(allocator))
-	assert.Equal(t, 29, allocator.Total())
-}
-
 func checkExternalIPPoolUsed(t *testing.T, controller *egressController, poolName string, used int) {
-	ipAllocator, exists := controller.getIPAllocator(poolName)
+	exists := controller.externalIPAllocator.IPPoolExists(poolName)
 	require.True(t, exists)
-	assert.Equal(t, used, ipAllocator.Used())
+	err := wait.PollImmediate(50*time.Millisecond, 2*time.Second, func() (found bool, err error) {
+		eip, err := controller.crdClient.CrdV1alpha2().ExternalIPPools().Get(context.TODO(), poolName, metav1.GetOptions{})
+		if err != nil {
+			return false, err
+		}
+		return eip.Status.Usage.Used == used, nil
+	})
+	assert.NoError(t, err)
 }
diff --git a/pkg/controller/egress/validate_test.go b/pkg/controller/egress/validate_test.go
index 3b8008ad3..e5496d679 100644
--- a/pkg/controller/egress/validate_test.go
+++ b/pkg/controller/egress/validate_test.go
@@ -19,9 +19,11 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	admv1 "k8s.io/api/admission/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/tools/cache"
 
 	crdv1alpha2 "antrea.io/antrea/pkg/apis/crd/v1alpha2"
 )
@@ -31,68 +33,6 @@ func marshal(object runtime.Object) []byte {
 	return raw
 }
 
-func TestEgressControllerValidateExternalIPPool(t *testing.T) {
-	tests := []struct {
-		name             string
-		request          *admv1.AdmissionRequest
-		expectedResponse *admv1.AdmissionResponse
-	}{
-		{
-			name: "CREATE operation should be allowed",
-			request: &admv1.AdmissionRequest{
-				Name:      "foo",
-				Operation: "CREATE",
-				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
-			},
-			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
-		},
-		{
-			name: "Deleting IPRange should not be allowed",
-			request: &admv1.AdmissionRequest{
-				Name:      "foo",
-				Operation: "UPDATE",
-				OldObject: runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "10.10.20.1", "10.10.20.2"))},
-				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
-			},
-			expectedResponse: &admv1.AdmissionResponse{
-				Allowed: false,
-				Result: &metav1.Status{
-					Message: "existing IPRanges [10.10.20.1-10.10.20.2] cannot be deleted",
-				},
-			},
-		},
-		{
-			name: "Adding IPRange should be allowed",
-			request: &admv1.AdmissionRequest{
-				Name:      "foo",
-				Operation: "UPDATE",
-				OldObject: runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
-				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "10.10.20.1", "10.10.20.2"))},
-			},
-			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
-		},
-		{
-			name: "DELETE operation should be allowed",
-			request: &admv1.AdmissionRequest{
-				Name:      "foo",
-				Operation: "DELETE",
-				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
-			},
-			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			c := newController(nil, nil)
-			review := &admv1.AdmissionReview{
-				Request: tt.request,
-			}
-			gotResponse := c.ValidateExternalIPPool(review)
-			assert.Equal(t, tt.expectedResponse, gotResponse)
-		})
-	}
-}
-
 func TestEgressControllerValidateEgress(t *testing.T) {
 	tests := []struct {
 		name                   string
@@ -192,14 +132,24 @@ func TestEgressControllerValidateEgress(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			c := newController(nil, nil)
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var objs []runtime.Object
 			if tt.existingExternalIPPool != nil {
-				c.createOrUpdateIPAllocator(tt.existingExternalIPPool)
+				objs = append(objs, tt.existingExternalIPPool)
 			}
+			controller := newController(nil, objs)
+			controller.informerFactory.Start(stopCh)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.informerFactory.WaitForCacheSync(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.externalIPAllocator.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.externalIPAllocator.HasSynced))
+			controller.externalIPAllocator.RestoreIPAllocations(nil)
 			review := &admv1.AdmissionReview{
 				Request: tt.request,
 			}
-			gotResponse := c.ValidateEgress(review)
+			gotResponse := controller.ValidateEgress(review)
 			assert.Equal(t, tt.expectedResponse, gotResponse)
 		})
 	}
diff --git a/pkg/controller/externalippool/controller_test.go b/pkg/controller/externalippool/controller_test.go
new file mode 100644
index 000000000..f74b284b7
--- /dev/null
+++ b/pkg/controller/externalippool/controller_test.go
@@ -0,0 +1,791 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package externalippool
+
+import (
+	"context"
+	"net"
+	"sort"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/tools/cache"
+
+	antreacrds "antrea.io/antrea/pkg/apis/crd/v1alpha2"
+	"antrea.io/antrea/pkg/client/clientset/versioned"
+	fakeversioned "antrea.io/antrea/pkg/client/clientset/versioned/fake"
+	crdinformers "antrea.io/antrea/pkg/client/informers/externalversions"
+)
+
+func newExternalIPPool(name, cidr, start, end string) *antreacrds.ExternalIPPool {
+	pool := &antreacrds.ExternalIPPool{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+	if len(cidr) > 0 {
+		pool.Spec.IPRanges = append(pool.Spec.IPRanges, antreacrds.IPRange{CIDR: cidr})
+	}
+	if len(start) > 0 && len(end) > 0 {
+		pool.Spec.IPRanges = append(pool.Spec.IPRanges, antreacrds.IPRange{Start: start, End: end})
+	}
+	return pool
+}
+
+type controller struct {
+	*ExternalIPPoolController
+	crdClient          versioned.Interface
+	crdInformerFactory crdinformers.SharedInformerFactory
+}
+
+// objects is an initial set of K8s objects that is exposed through the client.
+func newController(crdObjects []runtime.Object) *controller {
+	crdClient := fakeversioned.NewSimpleClientset(crdObjects...)
+	crdInformerFactory := crdinformers.NewSharedInformerFactory(crdClient, resyncPeriod)
+	externalIPPoolController := NewExternalIPPoolController(crdClient, crdInformerFactory.Crd().V1alpha2().ExternalIPPools())
+	return &controller{
+		externalIPPoolController,
+		crdClient,
+		crdInformerFactory,
+	}
+}
+
+func TestAllocateIP(t *testing.T) {
+	tests := []struct {
+		name        string
+		ipPools     []*antreacrds.ExternalIPPool
+		allocatedIP []struct {
+			ip   string
+			pool string
+		}
+		allocateFrom         string
+		expectedIP           string
+		expectError          bool
+		expectedIPPoolStatus []antreacrds.ExternalIPPoolUsage
+	}{
+		{
+			name: "allocate from proper IP pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP:  nil,
+			allocateFrom: "eip1",
+			expectedIP:   "10.10.10.2",
+			expectError:  false,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "allocate from exhausted IP pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP: []struct {
+				ip   string
+				pool string
+			}{
+				{"10.10.10.2", "eip1"},
+				{"10.10.10.3", "eip1"},
+			},
+			allocateFrom: "eip1",
+			expectedIP:   "",
+			expectError:  true,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 2},
+			},
+		},
+		{
+			name: "allocate from non existing IP pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP:  nil,
+			allocateFrom: "eip2",
+			expectedIP:   "",
+			expectError:  true,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 0},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			for _, alloc := range tt.allocatedIP {
+				require.NoError(t, controller.UpdateIPAllocation(alloc.pool, net.ParseIP(alloc.ip)))
+			}
+			ipGot, err := controller.AllocateIPFromPool(tt.allocateFrom)
+			assert.Equal(t, tt.expectError, err != nil)
+			assert.Equal(t, net.ParseIP(tt.expectedIP), ipGot)
+			for idx, pool := range tt.ipPools {
+				checkExternalIPPoolStatus(t, controller, pool.Name, tt.expectedIPPoolStatus[idx])
+			}
+		})
+	}
+}
+
+func TestAllocateIPFromPool(t *testing.T) {
+	tests := []struct {
+		name        string
+		ipPools     []*antreacrds.ExternalIPPool
+		allocatedIP []struct {
+			ip   string
+			pool string
+		}
+		expectedIPPool       string
+		expectedIP           string
+		expectError          bool
+		expectedIPPoolStatus []antreacrds.ExternalIPPoolUsage
+	}{
+		{
+			name: "allocate from proper IP pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP:    nil,
+			expectedIPPool: "eip1",
+			expectedIP:     "10.10.10.2",
+			expectError:    false,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "allocate from IP pools and one is full",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			allocatedIP: []struct {
+				ip   string
+				pool string
+			}{
+				{"10.10.10.2", "eip1"},
+				{"10.10.10.3", "eip1"},
+			},
+			expectedIPPool: "eip2",
+			expectedIP:     "10.10.11.2",
+			expectError:    false,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 2},
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "allocate from IP pools and all are full",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			allocatedIP: []struct {
+				ip   string
+				pool string
+			}{
+				{"10.10.10.2", "eip1"},
+				{"10.10.10.3", "eip1"},
+				{"10.10.11.2", "eip2"},
+				{"10.10.11.3", "eip2"},
+			},
+			expectedIPPool: "",
+			expectedIP:     "",
+			expectError:    true,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 2},
+				{Total: 2, Used: 2},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			for _, alloc := range tt.allocatedIP {
+				require.NoError(t, controller.UpdateIPAllocation(alloc.pool, net.ParseIP(alloc.ip)))
+			}
+			ipPoolGot, ipGot, err := controller.AllocateIP()
+			assert.Equal(t, tt.expectError, err != nil)
+			assert.Equal(t, net.ParseIP(tt.expectedIP), ipGot)
+			assert.Equal(t, tt.expectedIPPool, ipPoolGot)
+			for idx, pool := range tt.ipPools {
+				checkExternalIPPoolStatus(t, controller, pool.Name, tt.expectedIPPoolStatus[idx])
+			}
+		})
+	}
+}
+
+func TestReleaseIP(t *testing.T) {
+	tests := []struct {
+		name        string
+		ipPools     []*antreacrds.ExternalIPPool
+		allocatedIP []struct {
+			ip   string
+			pool string
+		}
+		ipPoolToRelease      string
+		ipToRelease          string
+		expectError          bool
+		expectedIPPoolStatus []antreacrds.ExternalIPPoolUsage
+	}{
+		{
+			name: "release IP to pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP: []struct {
+				ip   string
+				pool string
+			}{
+				{"10.10.10.2", "eip1"},
+				{"10.10.10.3", "eip1"},
+			},
+			ipPoolToRelease: "eip1",
+			ipToRelease:     "10.10.10.2",
+			expectError:     false,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "release unknown IP to pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			allocatedIP: []struct {
+				ip   string
+				pool string
+			}{
+				{"10.10.10.2", "eip1"},
+				{"10.10.10.3", "eip1"},
+			},
+			ipPoolToRelease: "eip1",
+			ipToRelease:     "10.10.11.2",
+			expectError:     true,
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 2},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			for _, alloc := range tt.allocatedIP {
+				require.NoError(t, controller.UpdateIPAllocation(alloc.pool, net.ParseIP(alloc.ip)))
+			}
+			err := controller.ReleaseIP(tt.ipPoolToRelease, net.ParseIP(tt.ipToRelease))
+			assert.Equal(t, tt.expectError, err != nil)
+			for idx, pool := range tt.ipPools {
+				checkExternalIPPoolStatus(t, controller, pool.Name, tt.expectedIPPoolStatus[idx])
+			}
+		})
+	}
+}
+
+func TestCreateOrUpdateIPAllocator(t *testing.T) {
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+	controller := newController(nil)
+	controller.crdInformerFactory.Start(stopCh)
+	controller.crdInformerFactory.WaitForCacheSync(stopCh)
+
+	ipPool := newExternalIPPool("ipPoolA", "1.1.1.0/30", "", "")
+	changed := controller.createOrUpdateIPAllocator(ipPool)
+	assert.True(t, changed)
+	allocator, exists := controller.getIPAllocator(ipPool.Name)
+	require.True(t, exists)
+	assert.Equal(t, 1, len(allocator))
+	assert.Equal(t, 2, allocator.Total())
+
+	// Append a non-strict CIDR, it should handle it correctly.
+	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, antreacrds.IPRange{CIDR: "1.1.2.1/30"})
+	changed = controller.createOrUpdateIPAllocator(ipPool)
+	assert.True(t, changed)
+	allocator, exists = controller.getIPAllocator(ipPool.Name)
+	require.True(t, exists)
+	assert.Equal(t, 2, len(allocator))
+	assert.Equal(t, 4, allocator.Total())
+
+	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, antreacrds.IPRange{Start: "1.1.3.1", End: "1.1.3.10"})
+	changed = controller.createOrUpdateIPAllocator(ipPool)
+	assert.True(t, changed)
+	allocator, exists = controller.getIPAllocator(ipPool.Name)
+	require.True(t, exists)
+	assert.Equal(t, 3, len(allocator))
+	assert.Equal(t, 14, allocator.Total())
+
+	// IPv6 CIDR shouldn't exclude broadcast address, so total should be increased by 15.
+	ipPool.Spec.IPRanges = append(ipPool.Spec.IPRanges, antreacrds.IPRange{CIDR: "2021:3::aaa1/124"})
+	changed = controller.createOrUpdateIPAllocator(ipPool)
+	assert.True(t, changed)
+	allocator, exists = controller.getIPAllocator(ipPool.Name)
+	require.True(t, exists)
+	assert.Equal(t, 4, len(allocator))
+	assert.Equal(t, 29, allocator.Total())
+
+	// When there is no change, the method should do nothing and the return value should be false.
+	changed = controller.createOrUpdateIPAllocator(ipPool)
+	assert.False(t, changed)
+	allocator, exists = controller.getIPAllocator(ipPool.Name)
+	require.True(t, exists)
+	assert.Equal(t, 4, len(allocator))
+	assert.Equal(t, 29, allocator.Total())
+}
+
+func TestIPPoolEvents(t *testing.T) {
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+	context, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	controller := newController(nil)
+	consumerCh := make(chan string)
+	controller.AddEventHandler(
+		func(ippool string) {
+			consumerCh <- ippool
+		})
+	controller.crdInformerFactory.Start(stopCh)
+	controller.crdInformerFactory.WaitForCacheSync(stopCh)
+	go controller.Run(stopCh)
+	require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+	// ADD event
+	eip, err := controller.crdClient.CrdV1alpha2().ExternalIPPools().Create(context,
+		newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+		metav1.CreateOptions{},
+	)
+	require.NoError(t, err)
+	assert.Equal(t, "eip1", <-consumerCh)
+	// UPDATE event
+	eip.Spec.IPRanges[0].End = "10.10.10.4"
+	eip, err = controller.crdClient.CrdV1alpha2().ExternalIPPools().Update(context,
+		eip,
+		metav1.UpdateOptions{},
+	)
+	require.NoError(t, err)
+	assert.Equal(t, "eip1", <-consumerCh)
+	// DELETE event
+	err = controller.crdClient.CrdV1alpha2().ExternalIPPools().Delete(context,
+		eip.Name,
+		metav1.DeleteOptions{},
+	)
+	require.NoError(t, err)
+	assert.Equal(t, "eip1", <-consumerCh)
+}
+
+func TestConsumersRestoreIPAllocation(t *testing.T) {
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+	eip := newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.10")
+	controller := newController([]runtime.Object{eip})
+	controller.AddEventHandler(func(ippool string) {})
+	controller.AddEventHandler(func(ippool string) {})
+	controller.crdInformerFactory.Start(stopCh)
+	controller.crdInformerFactory.WaitForCacheSync(stopCh)
+	go controller.Run(stopCh)
+	require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+	allocatedIPCh := make(chan string)
+	go func() {
+		allocatedIPs := []IPAllocation{
+			{
+				IPPoolName: "eip1",
+				IP:         net.ParseIP("10.10.10.2"),
+			},
+		}
+		restored := controller.RestoreIPAllocations(allocatedIPs)
+		assert.Equal(t, allocatedIPs, restored)
+		ip, err := controller.AllocateIPFromPool("eip1")
+		assert.NoError(t, err)
+		allocatedIPCh <- ip.String()
+	}()
+	go func() {
+		allocatedIPs := []IPAllocation{
+			{
+				IPPoolName: "eip1",
+				IP:         net.ParseIP("10.10.10.3"),
+			},
+		}
+		restored := controller.RestoreIPAllocations(allocatedIPs)
+		assert.Equal(t, allocatedIPs, restored)
+		ip, err := controller.AllocateIPFromPool("eip1")
+		assert.NoError(t, err)
+		allocatedIPCh <- ip.String()
+	}()
+	var allocated [2]string
+	for idx := 0; idx < len(allocated); idx++ {
+		allocated[idx] = <-allocatedIPCh
+	}
+	sort.Strings(allocated[:])
+	assert.Equal(t, "10.10.10.4", allocated[0])
+	assert.Equal(t, "10.10.10.5", allocated[1])
+}
+
+func TestIPPoolExists(t *testing.T) {
+	tests := []struct {
+		name           string
+		ipPools        []*antreacrds.ExternalIPPool
+		ipPoolToCheck  string
+		expectedExists bool
+	}{
+		{
+			name: "check for existing IPPool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			ipPoolToCheck:  "eip1",
+			expectedExists: true,
+		},
+		{
+			name: "check for non-existing IPPool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			ipPoolToCheck:  "eip2",
+			expectedExists: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			exists := controller.IPPoolExists(tt.ipPoolToCheck)
+			assert.Equal(t, tt.expectedExists, exists)
+		})
+	}
+}
+
+func TestIPPoolHasIP(t *testing.T) {
+	tests := []struct {
+		name           string
+		ipPools        []*antreacrds.ExternalIPPool
+		ipPoolToCheck  string
+		ipToCheck      net.IP
+		expectedExists bool
+	}{
+		{
+			name: "check for existing IP in IPPool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			ipPoolToCheck:  "eip1",
+			ipToCheck:      net.ParseIP("10.10.10.2"),
+			expectedExists: true,
+		},
+		{
+			name: "check for non-existing IP in IPPool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+			},
+			ipPoolToCheck:  "eip1",
+			ipToCheck:      net.ParseIP("10.10.10.1"),
+			expectedExists: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			exists := controller.IPPoolHasIP(tt.ipPoolToCheck, tt.ipToCheck)
+			assert.Equal(t, tt.expectedExists, exists)
+		})
+	}
+}
+
+func TestLocateIP(t *testing.T) {
+	tests := []struct {
+		name           string
+		ipPools        []*antreacrds.ExternalIPPool
+		ipToCheck      net.IP
+		expectedIPPool string
+		expectedError  bool
+	}{
+		{
+			name: "check for known IP 1",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			ipToCheck:      net.ParseIP("10.10.10.2"),
+			expectedIPPool: "eip1",
+			expectedError:  false,
+		},
+		{
+			name: "check for known IP 2",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			ipToCheck:      net.ParseIP("10.10.11.2"),
+			expectedIPPool: "eip2",
+			expectedError:  false,
+		},
+		{
+			name: "check for unknown IP",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			ipToCheck:      net.ParseIP("10.10.13.1"),
+			expectedIPPool: "",
+			expectedError:  true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			pool, err := controller.LocateIP(tt.ipToCheck)
+			assert.Equal(t, tt.expectedIPPool, pool)
+			assert.Equal(t, tt.expectedError, err != nil)
+		})
+	}
+}
+
+func checkExternalIPPoolStatus(t *testing.T, controller *controller, poolName string, expectedStatus antreacrds.ExternalIPPoolUsage) {
+	exists := controller.IPPoolExists(poolName)
+	require.True(t, exists)
+	err := wait.PollImmediate(50*time.Millisecond, 2*time.Second, func() (found bool, err error) {
+		eip, err := controller.crdClient.CrdV1alpha2().ExternalIPPools().Get(context.TODO(), poolName, metav1.GetOptions{})
+		if err != nil {
+			return false, err
+		}
+		return eip.Status.Usage == expectedStatus, nil
+	})
+	assert.NoError(t, err)
+}
+
+func TestExternalIPPoolController_RestoreIPAllocations(t *testing.T) {
+	tests := []struct {
+		name                 string
+		ipPools              []*antreacrds.ExternalIPPool
+		allocations          []IPAllocation
+		allocationsToRestore []IPAllocation
+		expectedSucceeded    []IPAllocation
+		expectedIPPoolStatus []antreacrds.ExternalIPPoolUsage
+	}{
+		{
+			name: "restore all IP successfully",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			allocations: nil,
+			allocationsToRestore: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-1",
+					},
+					"eip1",
+					net.ParseIP("10.10.10.2"),
+				},
+				{
+					v1.ObjectReference{
+						Name: "egress-2",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+			},
+			expectedSucceeded: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-1",
+					},
+					"eip1",
+					net.ParseIP("10.10.10.2"),
+				},
+				{
+					v1.ObjectReference{
+						Name: "egress-2",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+			},
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 1},
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "restore IP conflict",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			allocations: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "other-service-1",
+					},
+					"eip1",
+					net.ParseIP("10.10.10.2"),
+				},
+			},
+			allocationsToRestore: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-1",
+					},
+					"eip1",
+					net.ParseIP("10.10.10.2"),
+				},
+				{
+					v1.ObjectReference{
+						Name: "egress-2",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+			},
+			expectedSucceeded: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-2",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+			},
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 1},
+				{Total: 2, Used: 1},
+			},
+		},
+		{
+			name: "restore IP from unknown IP pool",
+			ipPools: []*antreacrds.ExternalIPPool{
+				newExternalIPPool("eip1", "", "10.10.10.2", "10.10.10.3"),
+				newExternalIPPool("eip2", "", "10.10.11.2", "10.10.11.3"),
+			},
+			allocations: nil,
+			allocationsToRestore: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-1",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+				{
+					v1.ObjectReference{
+						Name: "egress-2",
+					},
+					"eip3",
+					net.ParseIP("10.10.12.2"),
+				},
+			},
+			expectedSucceeded: []IPAllocation{
+				{
+					v1.ObjectReference{
+						Name: "egress-1",
+					},
+					"eip2",
+					net.ParseIP("10.10.11.2"),
+				},
+			},
+			expectedIPPoolStatus: []antreacrds.ExternalIPPoolUsage{
+				{Total: 2, Used: 0},
+				{Total: 2, Used: 1},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			var fakeCRDObjects []runtime.Object
+			for _, p := range tt.ipPools {
+				fakeCRDObjects = append(fakeCRDObjects, p)
+			}
+			controller := newController(fakeCRDObjects)
+			controller.AddEventHandler(
+				func(ippool string) {
+				})
+			controller.crdInformerFactory.Start(stopCh)
+			controller.crdInformerFactory.WaitForCacheSync(stopCh)
+			go controller.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, controller.HasSynced))
+			for _, alloc := range tt.allocations {
+				err := controller.UpdateIPAllocation(alloc.IPPoolName, alloc.IP)
+				require.NoError(t, err)
+			}
+			succeeded := controller.RestoreIPAllocations(tt.allocationsToRestore)
+			assert.Equal(t, tt.expectedSucceeded, succeeded)
+			for idx, pool := range tt.ipPools {
+				checkExternalIPPoolStatus(t, controller, pool.Name, tt.expectedIPPoolStatus[idx])
+			}
+		})
+	}
+}
diff --git a/pkg/controller/externalippool/validate_test.go b/pkg/controller/externalippool/validate_test.go
new file mode 100644
index 000000000..fd45823cb
--- /dev/null
+++ b/pkg/controller/externalippool/validate_test.go
@@ -0,0 +1,100 @@
+// Copyright 2021 Antrea Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package externalippool
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	admv1 "k8s.io/api/admission/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/tools/cache"
+)
+
+func marshal(object runtime.Object) []byte {
+	raw, _ := json.Marshal(object)
+	return raw
+}
+
+func TestControllerValidateExternalIPPool(t *testing.T) {
+	tests := []struct {
+		name             string
+		request          *admv1.AdmissionRequest
+		expectedResponse *admv1.AdmissionResponse
+	}{
+		{
+			name: "CREATE operation should be allowed",
+			request: &admv1.AdmissionRequest{
+				Name:      "foo",
+				Operation: "CREATE",
+				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
+			},
+			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
+		},
+		{
+			name: "Deleting IPRange should not be allowed",
+			request: &admv1.AdmissionRequest{
+				Name:      "foo",
+				Operation: "UPDATE",
+				OldObject: runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "10.10.20.1", "10.10.20.2"))},
+				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
+			},
+			expectedResponse: &admv1.AdmissionResponse{
+				Allowed: false,
+				Result: &metav1.Status{
+					Message: "existing IPRanges [10.10.20.1-10.10.20.2] cannot be deleted",
+				},
+			},
+		},
+		{
+			name: "Adding IPRange should be allowed",
+			request: &admv1.AdmissionRequest{
+				Name:      "foo",
+				Operation: "UPDATE",
+				OldObject: runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
+				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "10.10.20.1", "10.10.20.2"))},
+			},
+			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
+		},
+		{
+			name: "DELETE operation should be allowed",
+			request: &admv1.AdmissionRequest{
+				Name:      "foo",
+				Operation: "DELETE",
+				Object:    runtime.RawExtension{Raw: marshal(newExternalIPPool("foo", "10.10.10.0/24", "", ""))},
+			},
+			expectedResponse: &admv1.AdmissionResponse{Allowed: true},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			c := newController(nil)
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			c.crdInformerFactory.Start(stopCh)
+			c.crdInformerFactory.WaitForCacheSync(stopCh)
+			go c.Run(stopCh)
+			require.True(t, cache.WaitForCacheSync(stopCh, c.HasSynced))
+			review := &admv1.AdmissionReview{
+				Request: tt.request,
+			}
+			gotResponse := c.ValidateExternalIPPool(review)
+			assert.Equal(t, tt.expectedResponse, gotResponse)
+		})
+	}
+}
diff --git a/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go b/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go
index 9dc0216e4..fd09f4a1e 100644
--- a/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go
+++ b/pkg/ovs/ovsconfig/testing/mock_ovsconfig.go
@@ -423,3 +423,17 @@ func (mr *MockOVSBridgeClientMockRecorder) SetInterfaceType(arg0, arg1 interface
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetInterfaceType", reflect.TypeOf((*MockOVSBridgeClient)(nil).SetInterfaceType), arg0, arg1)
 }
+
+// SetPortExternalIDs mocks base method
+func (m *MockOVSBridgeClient) SetPortExternalIDs(arg0 string, arg1 map[string]interface{}) ovsconfig.Error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetPortExternalIDs", arg0, arg1)
+	ret0, _ := ret[0].(ovsconfig.Error)
+	return ret0
+}
+
+// SetPortExternalIDs indicates an expected call of SetPortExternalIDs
+func (mr *MockOVSBridgeClientMockRecorder) SetPortExternalIDs(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPortExternalIDs", reflect.TypeOf((*MockOVSBridgeClient)(nil).SetPortExternalIDs), arg0, arg1)
+}
diff --git a/test/e2e/antctl_test.go b/test/e2e/antctl_test.go
index 612824968..2a7d37908 100644
--- a/test/e2e/antctl_test.go
+++ b/test/e2e/antctl_test.go
@@ -222,7 +222,7 @@ func testAntctlVerboseMode(t *testing.T, data *TestData) {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
-			t.Logf("Running commnand `%s` on pod %s", tc.commands, podName)
+			t.Logf("Running command `%s` on pod %s", tc.commands, podName)
 			stdout, stderr, err := runAntctl(podName, tc.commands, data)
 			assert.Nil(t, err, antctlOutput(stdout, stderr))
 			if !tc.hasStderr {
diff --git a/test/e2e/antreapolicy_test.go b/test/e2e/antreapolicy_test.go
index 22e4ca4ac..13b46cde8 100644
--- a/test/e2e/antreapolicy_test.go
+++ b/test/e2e/antreapolicy_test.go
@@ -427,6 +427,46 @@ func testInvalidACNPAppliedToNotSetInAllRules(t *testing.T) {
 	}
 }
 
+func testInvalidACNPAppliedToGroupSetWithPodSelector(t *testing.T) {
+	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with appliedTo set group with podSelector")
+	appTo := ACNPAppliedToSpec{
+		PodSelector: map[string]string{"pod": "b"},
+		Group:       "cgA",
+	}
+	builder := &ClusterNetworkPolicySpecBuilder{}
+	builder = builder.SetName("acnp-appto-set-group-with-psel").
+		SetPriority(1.0).
+		SetAppliedToGroup([]ACNPAppliedToSpec{appTo})
+	builder = builder.AddIngress(v1.ProtocolTCP, &p80, nil, nil, nil, map[string]string{"pod": "b"}, map[string]string{"ns": "x"},
+		nil, nil, false, nil, crdv1alpha1.RuleActionAllow, "", "")
+	acnp := builder.Get()
+	log.Debugf("creating ACNP %v", acnp.Name)
+	if _, err := k8sUtils.CreateOrUpdateACNP(acnp); err == nil {
+		// Above creation of ACNP must fail as it is an invalid spec.
+		failOnError(invalidNpErr, t)
+	}
+}
+
+func testInvalidACNPAppliedToGroupSetWithNSSelector(t *testing.T) {
+	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with appliedTo set group with nsSelector")
+	appTo := ACNPAppliedToSpec{
+		NSSelector: map[string]string{"ns": "x"},
+		Group:      "cgA",
+	}
+	builder := &ClusterNetworkPolicySpecBuilder{}
+	builder = builder.SetName("acnp-appto-set-group-with-nssel").
+		SetPriority(1.0).
+		SetAppliedToGroup([]ACNPAppliedToSpec{appTo})
+	builder = builder.AddIngress(v1.ProtocolTCP, &p80, nil, nil, nil, map[string]string{"pod": "b"}, map[string]string{"ns": "x"},
+		nil, nil, false, nil, crdv1alpha1.RuleActionAllow, "", "")
+	acnp := builder.Get()
+	log.Debugf("creating ACNP %v", acnp.Name)
+	if _, err := k8sUtils.CreateOrUpdateACNP(acnp); err == nil {
+		// Above creation of ACNP must fail as it is an invalid spec.
+		failOnError(invalidNpErr, t)
+	}
+}
+
 func testInvalidACNPIngressPeerCGSetWithPodSelector(t *testing.T) {
 	cgA := "cgA"
 	selectorA := metav1.LabelSelector{MatchLabels: map[string]string{"foo1": "bar1"}}
@@ -489,6 +529,71 @@ func testInvalidACNPIngressPeerCGSetWithIPBlock(t *testing.T) {
 	}
 }
 
+func testInvalidACNPEgressPeerCGSetWithFQDN(t *testing.T) {
+	cgA := "cgA"
+	selectorA := metav1.LabelSelector{MatchLabels: map[string]string{"foo1": "bar1"}}
+	k8sUtils.CreateCG(cgA, &selectorA, nil, nil)
+	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with group and fqdn in NetworkPolicyPeer set")
+	builder := &ClusterNetworkPolicySpecBuilder{}
+	builder = builder.SetName("acnp-egress-group-fqdn-set").
+		SetPriority(1.0).
+		SetAppliedToGroup([]ACNPAppliedToSpec{{PodSelector: map[string]string{"pod": "b"}}})
+	builder = builder.AddEgress(v1.ProtocolTCP, &p80, nil, nil, nil, nil, nil,
+		nil, nil, false, nil, crdv1alpha1.RuleActionAllow, "cgA", "")
+	builder.Spec.Egress[0].To[0].FQDN = "google.com"
+
+	acnp := builder.Get()
+	log.Debugf("creating ACNP %v", acnp.Name)
+	if _, err := k8sUtils.CreateOrUpdateACNP(acnp); err == nil {
+		// Above creation of ACNP must fail as it is an invalid spec.
+		failOnError(invalidNpErr, t)
+	}
+	failOnError(k8sUtils.CleanCGs(), t)
+}
+
+func testInvalidACNPIngressPeerCGSetWithNamespace(t *testing.T) {
+	cgA := "cgA"
+	selectorA := metav1.LabelSelector{MatchLabels: map[string]string{"foo1": "bar1"}}
+	k8sUtils.CreateCG(cgA, &selectorA, nil, nil)
+	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with group and namespace in NetworkPolicyPeer set")
+	builder := &ClusterNetworkPolicySpecBuilder{}
+	builder = builder.SetName("acnp-ingress-group-ns-set").
+		SetPriority(1.0).
+		SetAppliedToGroup([]ACNPAppliedToSpec{{PodSelector: map[string]string{"pod": "b"}}})
+	builder = builder.AddIngress(v1.ProtocolTCP, &p80, nil, nil, nil, nil, nil,
+		nil, nil, true, nil, crdv1alpha1.RuleActionAllow, "cgA", "")
+	acnp := builder.Get()
+	log.Debugf("creating ACNP %v", acnp.Name)
+	if _, err := k8sUtils.CreateOrUpdateACNP(acnp); err == nil {
+		// Above creation of ACNP must fail as it is an invalid spec.
+		failOnError(invalidNpErr, t)
+	}
+	failOnError(k8sUtils.CleanCGs(), t)
+}
+
+func testInvalidACNPIngressPeerCGSetWithEESelector(t *testing.T) {
+	cgA := "cgA"
+	selectorA := metav1.LabelSelector{MatchLabels: map[string]string{"foo1": "bar1"}}
+	k8sUtils.CreateCG(cgA, &selectorA, nil, nil)
+	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with group and external entity selector in NetworkPolicyPeer set")
+	builder := &ClusterNetworkPolicySpecBuilder{}
+	builder = builder.SetName("acnp-ingress-group-eeselector-set").
+		SetPriority(1.0).
+		SetAppliedToGroup([]ACNPAppliedToSpec{{PodSelector: map[string]string{"pod": "b"}}})
+	builder = builder.AddIngress(v1.ProtocolTCP, &p80, nil, nil, nil, nil, nil,
+		nil, nil, true, nil, crdv1alpha1.RuleActionAllow, "cgA", "")
+	builder.Spec.Ingress[0].From[0].ExternalEntitySelector = &metav1.LabelSelector{
+		MatchLabels: map[string]string{"foo2": "bar2"},
+	}
+	acnp := builder.Get()
+	log.Debugf("creating ACNP %v", acnp.Name)
+	if _, err := k8sUtils.CreateOrUpdateACNP(acnp); err == nil {
+		// Above creation of ACNP must fail as it is an invalid spec.
+		failOnError(invalidNpErr, t)
+	}
+	failOnError(k8sUtils.CleanCGs(), t)
+}
+
 func testInvalidACNPIngressPeerNamespacesSetWithNSSelector(t *testing.T) {
 	invalidNpErr := fmt.Errorf("invalid Antrea ClusterNetworkPolicy with namespaces and namespaceSelector in NetworkPolicyPeer set")
 	builder := &ClusterNetworkPolicySpecBuilder{}
@@ -2989,9 +3094,14 @@ func TestAntreaPolicy(t *testing.T) {
 		t.Run("Case=ACNPIngressPeerCGSetWithIPBlock", func(t *testing.T) { testInvalidACNPIngressPeerCGSetWithIPBlock(t) })
 		t.Run("Case=ACNPIngressPeerCGSetWithPodSelector", func(t *testing.T) { testInvalidACNPIngressPeerCGSetWithPodSelector(t) })
 		t.Run("Case=ACNPIngressPeerCGSetWithNSSelector", func(t *testing.T) { testInvalidACNPIngressPeerCGSetWithNSSelector(t) })
+		t.Run("Case=ACNPEgressPeerCGSetWithFQDN", func(t *testing.T) { testInvalidACNPEgressPeerCGSetWithFQDN(t) })
+		t.Run("Case=ACNPIngressPeerCGSetWithNamespace", func(t *testing.T) { testInvalidACNPIngressPeerCGSetWithNamespace(t) })
+		t.Run("Case=ACNPIngressPeerCGSetWithEESelector", func(t *testing.T) { testInvalidACNPIngressPeerCGSetWithEESelector(t) })
 		t.Run("Case=ACNPIngressPeerNamespaceSetWithNSSelector", func(t *testing.T) { testInvalidACNPIngressPeerNamespacesSetWithNSSelector(t) })
 		t.Run("Case=ACNPSpecAppliedToRuleAppliedToSet", func(t *testing.T) { testInvalidACNPSpecAppliedToRuleAppliedToSet(t) })
 		t.Run("Case=ACNPAppliedToNotSetInAllRules", func(t *testing.T) { testInvalidACNPAppliedToNotSetInAllRules(t) })
+		t.Run("Case=ACNPAppliedToGroupSetWithPodSelector", func(t *testing.T) { testInvalidACNPAppliedToGroupSetWithPodSelector(t) })
+		t.Run("Case=ACNPAppliedToGroupSetWithNSSelector", func(t *testing.T) { testInvalidACNPAppliedToGroupSetWithNSSelector(t) })
 		t.Run("Case=ANPNoPriority", func(t *testing.T) { testInvalidANPNoPriority(t) })
 		t.Run("Case=ANPRuleNameNotUniqueDenied", func(t *testing.T) { testInvalidANPRuleNameNotUnique(t) })
 		t.Run("Case=ANPTierDoesNotExistDenied", func(t *testing.T) { testInvalidANPTierDoesNotExist(t) })
diff --git a/test/e2e/flowaggregator_test.go b/test/e2e/flowaggregator_test.go
index d409b3d44..c1ceb307b 100644
--- a/test/e2e/flowaggregator_test.go
+++ b/test/e2e/flowaggregator_test.go
@@ -15,6 +15,7 @@
 package e2e
 
 import (
+	"encoding/json"
 	"fmt"
 	"net"
 	"regexp"
@@ -120,6 +121,7 @@ const (
 	testIngressRuleName            = "test-ingress-rule-name"
 	testEgressRuleName             = "test-egress-rule-name"
 	iperfTimeSec                   = 12
+	protocolIdentifierTCP          = 6
 )
 
 var (
@@ -183,36 +185,6 @@ func TestFlowAggregator(t *testing.T) {
 		t.Run("IPv6", func(t *testing.T) { testHelper(t, data, podAIPs, podBIPs, podCIPs, podDIPs, podEIPs, true) })
 	}
 
-	t.Run("testFlowAggregatorAntctl", func(t *testing.T) {
-		testFlowAggregatorAntctl(t, data)
-	})
-}
-
-// testFlowAggregatorAntctl ensures antctl is accessible in a Flow Aggregator Pod.
-func testFlowAggregatorAntctl(t *testing.T, data *TestData) {
-	flowAggPod, err := data.getFlowAggregator()
-	if err != nil {
-		t.Fatalf("Error when getting flow-aggregator Pod name: %v", err)
-	}
-	podName := flowAggPod.Name
-	for _, c := range antctl.CommandList.GetDebugCommands(runtime.ModeFlowAggregator) {
-		args := []string{}
-		if testOptions.enableCoverage {
-			antctlCovArgs := antctlCoverageArgs("antctl-coverage")
-			args = append(antctlCovArgs, c...)
-		} else {
-			args = append([]string{"antctl", "-v"}, c...)
-		}
-		t.Logf("args: %s", args)
-
-		cmd := strings.Join(args, " ")
-		t.Run(cmd, func(t *testing.T) {
-			stdout, stderr, err := runAntctl(podName, args, data)
-			if err != nil {
-				t.Fatalf("Error when running `antctl %s` from %s: %v\n%s", c, podName, err, antctlOutput(stdout, stderr))
-			}
-		})
-	}
 }
 
 func testHelper(t *testing.T, data *TestData, podAIPs, podBIPs, podCIPs, podDIPs, podEIPs *PodIPs, isIPv6 bool) {
@@ -532,6 +504,101 @@ func testHelper(t *testing.T, data *TestData, podAIPs, podBIPs, podCIPs, podDIPs
 			checkRecordsForFlows(t, data, podAIPs.ipv4.String(), svcC.Spec.ClusterIP, isServiceIPv6, false, true, false, false, checkBandwidth)
 		}
 	})
+
+	// Antctl tests ensure antctl is available in a Flow Aggregator Pod
+	// and check the output of antctl commands.
+	t.Run("Antctl", func(t *testing.T) {
+		skipIfNotRequired(t, "mode-irrelevant")
+		flowAggPod, err := data.getFlowAggregator()
+		if err != nil {
+			t.Fatalf("Error when getting flow-aggregator Pod: %v", err)
+		}
+		podName := flowAggPod.Name
+		for _, args := range antctl.CommandList.GetDebugCommands(runtime.ModeFlowAggregator) {
+			command := []string{}
+			if testOptions.enableCoverage {
+				antctlCovArgs := antctlCoverageArgs("antctl-coverage")
+				command = append(antctlCovArgs, args...)
+			} else {
+				command = append([]string{"antctl", "-v"}, args...)
+			}
+			t.Logf("Run command: %s", command)
+
+			t.Run(strings.Join(command, " "), func(t *testing.T) {
+				stdout, stderr, err := runAntctl(podName, command, data)
+				require.NoErrorf(t, err, "Error when running 'antctl %s' from %s: %v\n%s", args, podName, err, antctlOutput(stdout, stderr))
+			})
+		}
+		t.Run("GetFlowRecordsJson", func(t *testing.T) {
+			checkAntctlGetFlowRecordsJson(t, data, podName, podAIPs, podBIPs, isIPv6)
+		})
+	})
+}
+
+func checkAntctlGetFlowRecordsJson(t *testing.T, data *TestData, podName string, podAIPs, podBIPs *PodIPs, isIPv6 bool) {
+	// A shorter iperfTime that provides stable test results, at which the first record ready in the AggregationProcess but not sent.
+	const iperfTimeSecShort = 5
+	var cmdStr, srcIP, dstIP string
+	// trigger a flow with iperf
+	if !isIPv6 {
+		srcIP = podAIPs.ipv4.String()
+		dstIP = podBIPs.ipv4.String()
+		cmdStr = fmt.Sprintf("iperf3 -c %s -t %d", dstIP, iperfTimeSecShort)
+	} else {
+		srcIP = podAIPs.ipv6.String()
+		dstIP = podBIPs.ipv6.String()
+		cmdStr = fmt.Sprintf("iperf3 -6 -c %s -t %d", dstIP, iperfTimeSecShort)
+	}
+	stdout, _, err := data.runCommandFromPod(testNamespace, "perftest-a", "perftool", []string{"bash", "-c", cmdStr})
+	require.NoErrorf(t, err, "Error when running iperf3 client: %v", err)
+	_, srcPort, dstPort := getBandwidthAndPorts(stdout)
+
+	// run antctl command on flow aggregator to get flow records
+	var command []string
+	args := []string{"get", "flowrecords", "-o", "json", "--srcip", srcIP, "--srcport", srcPort}
+	if testOptions.enableCoverage {
+		antctlCovArgs := antctlCoverageArgs("antctl-coverage")
+		command = append(antctlCovArgs, args...)
+	} else {
+		command = append([]string{"antctl"}, args...)
+	}
+	t.Logf("Run command: %s", command)
+	stdout, stderr, err := runAntctl(podName, command, data)
+	require.NoErrorf(t, err, "Error when running 'antctl get flowrecords -o json' from %s: %v\n%s", podName, err, antctlOutput(stdout, stderr))
+
+	var records []map[string]interface{}
+	err = json.Unmarshal([]byte(stdout), &records)
+	require.NoErrorf(t, err, "Error when parsing flow records from antctl: %v", err)
+	require.Len(t, records, 1)
+
+	checkAntctlRecord(t, records[0], srcIP, dstIP, srcPort, dstPort, isIPv6)
+}
+
+func checkAntctlRecord(t *testing.T, record map[string]interface{}, srcIP, dstIP, srcPort, dstPort string, isIPv6 bool) {
+	assert := assert.New(t)
+	if isIPv6 {
+		assert.Equal(srcIP, record["sourceIPv6Address"], "The record from antctl does not have correct sourceIPv6Address")
+		assert.Equal(dstIP, record["destinationIPv6Address"], "The record from antctl does not have correct destinationIPv6Address")
+	} else {
+		assert.Equal(srcIP, record["sourceIPv4Address"], "The record from antctl does not have correct sourceIPv4Address")
+		assert.Equal(dstIP, record["destinationIPv4Address"], "The record from antctl does not have correct destinationIPv4Address")
+	}
+	srcPortNum, err := strconv.Atoi(srcPort)
+	require.NoErrorf(t, err, "error when converting the iperf srcPort to int type: %s", srcPort)
+	assert.EqualValues(srcPortNum, record["sourceTransportPort"], "The record from antctl does not have correct sourceTransportPort")
+	assert.Equal("perftest-a", record["sourcePodName"], "The record from antctl does not have correct sourcePodName")
+	assert.Equal("antrea-test", record["sourcePodNamespace"], "The record from antctl does not have correct sourcePodNamespace")
+	assert.Equal(controlPlaneNodeName(), record["sourceNodeName"], "The record from antctl does not have correct sourceNodeName")
+
+	dstPortNum, err := strconv.Atoi(dstPort)
+	require.NoErrorf(t, err, "error when converting the iperf dstPort to int type: %s", dstPort)
+	assert.EqualValues(dstPortNum, record["destinationTransportPort"], "The record from antctl does not have correct destinationTransportPort")
+	assert.Equal("perftest-b", record["destinationPodName"], "The record from antctl does not have correct destinationPodName")
+	assert.Equal("antrea-test", record["destinationPodNamespace"], "The record from antctl does not have correct destinationPodNamespace")
+	assert.Equal(controlPlaneNodeName(), record["destinationNodeName"], "The record from antctl does not have correct destinationNodeName")
+
+	assert.EqualValues(ipfixregistry.FlowTypeIntraNode, record["flowType"], "The record from antctl does not have correct flowType")
+	assert.EqualValues(protocolIdentifierTCP, record["protocolIdentifier"], "The record from antctl does not have correct protocolIdentifier")
 }
 
 func checkRecordsForFlows(t *testing.T, data *TestData, srcIP string, dstIP string, isIPv6 bool, isIntraNode bool, checkService bool, checkK8sNetworkPolicy bool, checkAntreaNetworkPolicy bool, checkBandwidth bool) {
@@ -542,10 +609,8 @@ func checkRecordsForFlows(t *testing.T, data *TestData, srcIP string, dstIP stri
 		cmdStr = fmt.Sprintf("iperf3 -6 -c %s -t %d", dstIP, iperfTimeSec)
 	}
 	stdout, _, err := data.runCommandFromPod(testNamespace, "perftest-a", "perftool", []string{"bash", "-c", cmdStr})
-	if err != nil {
-		t.Errorf("Error when running iperf3 client: %v", err)
-	}
-	bwSlice, srcPort := getBandwidthAndSourcePort(stdout)
+	require.NoErrorf(t, err, "Error when running iperf3 client: %v", err)
+	bwSlice, srcPort, _ := getBandwidthAndPorts(stdout)
 	require.Equal(t, 2, len(bwSlice), "bandwidth value and / or bandwidth unit are not available")
 	// bandwidth from iperf output
 	bandwidthInFloat, err := strconv.ParseFloat(bwSlice[0], 64)
@@ -1058,12 +1123,12 @@ func deletePerftestServices(t *testing.T, data *TestData) {
 	}
 }
 
-// getBandwidthAndSourcePort parses iperf commands output and returns bandwidth
-// and source port. Bandwidth is returned as a slice containing two strings (bandwidth
-// value and bandwidth unit).
-func getBandwidthAndSourcePort(iperfStdout string) ([]string, string) {
+// getBandwidthAndPorts parses iperf commands output and returns bandwidth,
+// source port and destination port. Bandwidth is returned as a slice containing
+// two strings (bandwidth value and bandwidth unit).
+func getBandwidthAndPorts(iperfStdout string) ([]string, string, string) {
 	var bandwidth []string
-	var srcPort string
+	var srcPort, dstPort string
 	outputLines := strings.Split(iperfStdout, "\n")
 	for _, line := range outputLines {
 		if strings.Contains(line, "sender") {
@@ -1073,9 +1138,10 @@ func getBandwidthAndSourcePort(iperfStdout string) ([]string, string) {
 		if strings.Contains(line, "connected") {
 			fields := strings.Fields(line)
 			srcPort = fields[5]
+			dstPort = fields[10]
 		}
 	}
-	return bandwidth, srcPort
+	return bandwidth, srcPort, dstPort
 }
 
 func matchSrcAndDstAddress(srcIP string, dstIP string, isDstService bool, isIPv6 bool) (string, string) {
diff --git a/test/e2e/infra/vagrant/Vagrantfile b/test/e2e/infra/vagrant/Vagrantfile
index ca5da2d3e..49d36ea7a 100644
--- a/test/e2e/infra/vagrant/Vagrantfile
+++ b/test/e2e/infra/vagrant/Vagrantfile
@@ -35,9 +35,20 @@ end
 NODE_NETWORK_V4_PREFIX = "192.168.77."
 NODE_NETWORK_V6_PREFIX = "fd3b:fcf5:3e92:d732::"
 
-MEMORY = 2048
 if ENV['K8S_NODE_LARGE'] == "true"
   MEMORY = 4096
+else
+  MEMORY = 2048
+end
+
+KUBE_PROXY_MODE = ENV['KUBE_PROXY_MODE'] || "iptables"
+if KUBE_PROXY_MODE == "iptables"
+  KUBE_PROXY_IPVS_STRICT_ARP = false
+elsif KUBE_PROXY_MODE == "ipvs"
+  # For now, we always enable strict ARP, which is required to use MetalLB
+  KUBE_PROXY_IPVS_STRICT_ARP = true
+else
+  raise "KUBE_PROXY_MODE env variable should be one of 'iptables' or 'ipvs'"
 end
 
 Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
@@ -58,12 +69,11 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
     node.vm.hostname = "k8s-node-control-plane"
     node_ipv4 = NODE_NETWORK_V4_PREFIX + "100"
     node_ipv6 = NODE_NETWORK_V6_PREFIX + "100"
-    if MODE != "v6"
-      node.vm.network "private_network", ip: node_ipv4
-    end
-    if MODE != "v4"
-      node.vm.network "private_network", ip: node_ipv6
-    end
+    # The network will be configured using the Ansible playbook
+    # Despite setting auto_config to false, it seems that it is necessary to
+    # provide an IP address, even though it won't be used.
+    # See https://github.com/hashicorp/vagrant/issues/7583
+    node.vm.network "private_network", ip: node_ipv4, auto_config: false
     if MODE == "v4"
       node_ip = node_ipv4
     elsif MODE == "v6"
@@ -72,11 +82,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
       node_ip = node_ipv4 + "," + node_ipv6
     end
 
-    if MODE != "v4"
-      # add a fake default route for IPv6: required for ClusterIP traffic even though it is DNATed
-      node.vm.provision :shell, privileged: true, inline: "ip -6 route replace default via " + NODE_NETWORK_V6_PREFIX + "200"
-    end
-
     node.vm.provision :ansible  do |ansible|
       ansible.playbook = "playbook/k8s.yml"
       ansible.groups = groups
@@ -84,12 +89,16 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
         # Ubuntu bionic does not ship with python2
         ansible_python_interpreter:"/usr/bin/python3",
         node_ip: node_ip,
+        node_ipv4: (MODE != "v6") ? node_ipv4 : "",
+        node_ipv6: (MODE != "v4") ? node_ipv6 : "",
         node_name: "k8s-node-control-plane",
         k8s_pod_network_cidr: K8S_POD_NETWORK_CIDR,
         k8s_service_network_cidr: K8S_SERVICE_NETWORK_CIDR,
         k8s_api_server_ip: (MODE == "v6") ? node_ipv6 : node_ipv4,
         k8s_ip_family: MODE,
         k8s_antrea_gw_ip: K8S_NODE_CP_GW_IP,
+        kube_proxy_mode: KUBE_PROXY_MODE,
+        kube_proxy_ipvs_strict_arp: KUBE_PROXY_IPVS_STRICT_ARP,
       }
     end
   end
@@ -99,12 +108,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
       node.vm.hostname = "k8s-node-worker-#{node_id}"
       node_ipv4 = NODE_NETWORK_V4_PREFIX + "#{100 + node_id}"
       node_ipv6 = NODE_NETWORK_V6_PREFIX + "#{100 + node_id}"
-      if MODE != "v6"
-        node.vm.network "private_network", ip: node_ipv4
-      end
-      if MODE != "v4"
-        node.vm.network "private_network", ip: node_ipv6
-      end
+      node.vm.network "private_network", ip: node_ipv4, auto_config: false
       if MODE == "v4"
         node_ip = node_ipv4
       elsif MODE == "v6"
@@ -113,17 +117,14 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
         node_ip = node_ipv4 + "," + node_ipv6
       end
 
-      if MODE != "v4"
-        # add a fake default route for IPv6: required for ClusterIP traffic even though it is DNATed
-        node.vm.provision :shell, privileged: true, inline: "ip -6 route replace default via " + NODE_NETWORK_V6_PREFIX + "200"
-      end
-
       node.vm.provision :ansible do |ansible|
         ansible.playbook = "playbook/k8s.yml"
         ansible.groups = groups
         ansible.extra_vars = {
           ansible_python_interpreter:"/usr/bin/python3",
           node_ip: node_ip,
+          node_ipv4: (MODE != "v6") ? node_ipv4 : "",
+          node_ipv6: (MODE != "v4") ? node_ipv6 : "",
           node_name: "k8s-node-worker-#{node_id}",
         }
       end
diff --git a/test/e2e/infra/vagrant/playbook/roles/common/tasks/containerd.yml b/test/e2e/infra/vagrant/playbook/roles/common/tasks/containerd.yml
index 146120fab..3c2bd51c7 100644
--- a/test/e2e/infra/vagrant/playbook/roles/common/tasks/containerd.yml
+++ b/test/e2e/infra/vagrant/playbook/roles/common/tasks/containerd.yml
@@ -14,7 +14,7 @@
     state: present
 
 - name: Loading required kernel modules on boot
-  template: 
+  template:
     src: templates/containerd_modules.conf.j2
     dest: /etc/modules-load.d/containerd.conf
 
@@ -35,7 +35,7 @@
     state: present
     sysctl_file: /etc/sysctl.d/99-kubernetes-cri.conf
     reload: yes
-  with_items: 
+  with_items:
     - { name: net.bridge.bridge-nf-call-iptables, value: 1 }
     - { name: net.ipv4.ip_forward, value: 1 }
     - { name: net.bridge.bridge-nf-call-ip6tables, value: 1 }
diff --git a/test/e2e/infra/vagrant/playbook/roles/common/tasks/main.yml b/test/e2e/infra/vagrant/playbook/roles/common/tasks/main.yml
index 782279418..fe182863c 100644
--- a/test/e2e/infra/vagrant/playbook/roles/common/tasks/main.yml
+++ b/test/e2e/infra/vagrant/playbook/roles/common/tasks/main.yml
@@ -2,6 +2,8 @@
   assert:
     that: ansible_version.full is version_compare('2.4.0', '>=')
 
+- import_tasks: netplan.yml
+
 - import_tasks: base.yml
 
 - import_tasks: containerd.yml
diff --git a/test/e2e/infra/vagrant/playbook/roles/common/tasks/netplan.yml b/test/e2e/infra/vagrant/playbook/roles/common/tasks/netplan.yml
new file mode 100644
index 000000000..29c1a4671
--- /dev/null
+++ b/test/e2e/infra/vagrant/playbook/roles/common/tasks/netplan.yml
@@ -0,0 +1,9 @@
+- name: Install netplan config file
+  template:
+    src: templates/netplan.conf.j2
+    dest: /etc/netplan/10-networks.yaml
+  register: netplan_config
+
+- name: Apply netplan
+  shell: netplan apply
+  when: netplan_config.changed
diff --git a/test/e2e/infra/vagrant/playbook/roles/common/templates/netplan.conf.j2 b/test/e2e/infra/vagrant/playbook/roles/common/templates/netplan.conf.j2
new file mode 100644
index 000000000..8b68cfe24
--- /dev/null
+++ b/test/e2e/infra/vagrant/playbook/roles/common/templates/netplan.conf.j2
@@ -0,0 +1,20 @@
+---
+network:
+  version: 2
+  renderer: networkd
+  ethernets:
+    # this assumes that the private network created by Vagrant uses this inteface name
+    enp0s8:
+      addresses:
+{% if node_ipv4 != "" %}
+      - {{ node_ipv4 }}/24
+{% endif %}
+{% if node_ipv6 != "" %}
+      - {{ node_ipv6 }}/64
+{% endif %}
+{% if node_ipv6 != "" %}
+      routes:
+      # add a fake default route for IPv6: required for ClusterIP traffic even though it is DNATed
+      - to: ::/0
+        via: fd3b:fcf5:3e92:d732::200
+{% endif %}
diff --git a/test/e2e/infra/vagrant/playbook/roles/control-plane/templates/kubeadm.conf.j2 b/test/e2e/infra/vagrant/playbook/roles/control-plane/templates/kubeadm.conf.j2
index 04b9aee61..c2307c02e 100644
--- a/test/e2e/infra/vagrant/playbook/roles/control-plane/templates/kubeadm.conf.j2
+++ b/test/e2e/infra/vagrant/playbook/roles/control-plane/templates/kubeadm.conf.j2
@@ -15,3 +15,11 @@ apiServer:
   - "{{ k8s_api_server_ip }}"
   extraArgs:
     feature-gates: "NetworkPolicyEndPort=true"
+---
+apiVersion: kubeproxy.config.k8s.io/v1alpha1
+kind: KubeProxyConfiguration
+mode: "{{ kube_proxy_mode }}"
+{% if kube_proxy_ipvs_strict_arp %}
+ipvs:
+  strictARP: true
+{% endif %}
diff --git a/test/e2e/infra/vagrant/provision.sh b/test/e2e/infra/vagrant/provision.sh
index 2b881c765..d7a5b0b52 100755
--- a/test/e2e/infra/vagrant/provision.sh
+++ b/test/e2e/infra/vagrant/provision.sh
@@ -3,13 +3,15 @@
 function usage() {
     echo "Usage: provision.sh [--ip-family <v4|v6>] [-l|--large] [-h|--help]
     Provisions the Vagrant VMs.
-    --ip-family <v4|v6|dual>  Deploy IPv4, IPv6 or dual-stack Kubernetes cluster.
-    --large                   Deploy large vagrant VMs with 2 vCPUs and 4096MB memory.
-                              By default, we deploy VMs with 2 vCPUs and 2048MB memory."
+    --ip-family <v4|v6|dual>           Deploy IPv4, IPv6 or dual-stack Kubernetes cluster.
+    --large                            Deploy large vagrant VMs with 2 vCPUs and 4096MB memory.
+                                       By default, we deploy VMs with 2 vCPUs and 2048MB memory.
+    --kube-proxy-mode <iptables|ipvs>  Which mode to use for kube-proxy (default is iptables)."
 }
 
 K8S_IP_FAMILY="v4"
 K8S_NODE_LARGE=false
+KUBE_PROXY_MODE="iptables"
 while [[ $# -gt 0 ]]
 do
 key="$1"
@@ -23,6 +25,10 @@ case $key in
     K8S_NODE_LARGE=true
     shift 1
     ;;
+    --kube-proxy-mode)
+    KUBE_PROXY_MODE="$2"
+    shift 2
+    ;;
     -h|--help)
     usage
     exit 0
@@ -39,6 +45,7 @@ pushd $THIS_DIR
 
 export K8S_NODE_LARGE
 export K8S_IP_FAMILY
+export KUBE_PROXY_MODE
 
 # A few important considerations for IPv6 clusters:
 # * there is no assumption that the host machine supports IPv6.
diff --git a/test/integration/agent/ip_assigner_linux_test.go b/test/integration/agent/ip_assigner_linux_test.go
index bf3190140..9d10b090e 100644
--- a/test/integration/agent/ip_assigner_linux_test.go
+++ b/test/integration/agent/ip_assigner_linux_test.go
@@ -24,7 +24,7 @@ import (
 	"github.com/vishvananda/netlink"
 	"k8s.io/apimachinery/pkg/util/sets"
 
-	"antrea.io/antrea/pkg/agent/controller/egress/ipassigner"
+	"antrea.io/antrea/pkg/agent/ipassigner"
 )
 
 const dummyDeviceName = "antrea-dummy0"
diff --git a/test/integration/agent/openflow_test.go b/test/integration/agent/openflow_test.go
index 4a67af351..a2f1c30db 100644
--- a/test/integration/agent/openflow_test.go
+++ b/test/integration/agent/openflow_test.go
@@ -115,7 +115,7 @@ func TestConnectivityFlows(t *testing.T) {
 		antrearuntime.WindowsOS = runtime.GOOS
 	}
 
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge: %v", err))
 	defer func() {
@@ -159,7 +159,7 @@ func TestAntreaFlexibleIPAMConnectivityFlows(t *testing.T) {
 	// Initialize ovs metrics (Prometheus) to test them
 	metrics.InitializeOVSMetrics()
 
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, true)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, true, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge: %v", err))
 	defer func() {
@@ -201,7 +201,7 @@ func TestAntreaFlexibleIPAMConnectivityFlows(t *testing.T) {
 }
 
 func TestReplayFlowsConnectivityFlows(t *testing.T) {
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge: %v", err))
 
@@ -237,7 +237,7 @@ func TestReplayFlowsConnectivityFlows(t *testing.T) {
 }
 
 func TestReplayFlowsNetworkPolicyFlows(t *testing.T) {
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge: %v", err))
 
@@ -423,7 +423,7 @@ func TestNetworkPolicyFlows(t *testing.T) {
 	// Initialize ovs metrics (Prometheus) to test them
 	metrics.InitializeOVSMetrics()
 
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge %s", br))
 
@@ -533,7 +533,7 @@ func TestIPv6ConnectivityFlows(t *testing.T) {
 	// Initialize ovs metrics (Prometheus) to test them
 	metrics.InitializeOVSMetrics()
 
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, true, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge: %v", err))
 
@@ -575,7 +575,7 @@ type svcConfig struct {
 }
 
 func TestProxyServiceFlows(t *testing.T) {
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, true, false, false, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge %s", br))
 
@@ -1514,7 +1514,7 @@ func prepareSNATFlows(snatIP net.IP, mark, podOFPort, podOFPortRemote uint32, vM
 }
 
 func TestSNATFlows(t *testing.T) {
-	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, false, false, true, false, false, false)
+	c = ofClient.NewClient(br, bridgeMgmtAddr, ovsconfig.OVSDatapathNetdev, false, false, true, false, false, false, false)
 	err := ofTestUtils.PrepareOVSBridge(br)
 	require.Nil(t, err, fmt.Sprintf("Failed to prepare OVS bridge %s", br))