Repository: kubernetes-sigs/cluster-api
Pull request #4558 (2021-05-03 01:14:14)

✨ Add ipv6 support to capd
quickstart e2e test can be run with:
```
IP_FAMILY=IPv6 \
DOCKER_SERVICE_CIDRS="fd00:100:64::/108" \
DOCKER_POD_CIDRS="fd00:100:96::/48" \
make test-e2e
```
Note: `POD_SUBNET` in kindnet.yaml is now set automatically based on `DOCKER_POD_CIDRS`

`IPFamily()` method on `Cluster` determines the ip family based on the service CIDRs and pod CIDRs. This is used by the docker provider to configure the docker machines accordingly.

Related feature request: https://github.com/kubernetes-sigs/cluster-api/issues/4557

cc @christianang


k8s-ci-robot: Welcome @mcwumbly! <br><br>It looks like this is your first PR to <a href='https://github.com/kubernetes-sigs/cluster-api'>kubernetes-sigs/cluster-api</a> 🎉. Please refer to our [pull request process documentation](https://git.k8s.io/community/contributors/guide/pull-requests.md) to help your PR have a smooth ride to approval. <br><br>You will be prompted by a bot to use commands during the review process. Do not be afraid to follow the prompts! It is okay to experiment. [Here is the bot commands documentation](https://go.k8s.io/bot-commands). <br><br>You can also check if kubernetes-sigs/cluster-api has [its own contribution guidelines](https://github.com/kubernetes-sigs/cluster-api/tree/master/CONTRIBUTING.md). <br><br>You may want to refer to our [testing guide](https://git.k8s.io/community/contributors/devel/sig-testing/testing.md) if you run into trouble with your tests not passing. <br><br>If you are having difficulty getting your pull request seen, please follow the [recommended escalation practices](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#why-is-my-pull-request-not-getting-reviewed). Also, for tips and tricks in the contribution process you may want to read the [Kubernetes contributor cheat sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet/README.md). We want to make sure your contribution gets all the attention it needs! <br><br>Thank you, and welcome to Kubernetes. :smiley:
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **timothysc** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @timothysc` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/cluster-api/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["timothysc"]} -->
k8s-ci-robot: Hi @mcwumbly. Thanks for your PR.

I'm waiting for a [kubernetes-sigs](https://github.com/orgs/kubernetes-sigs/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://git.k8s.io/community/community-membership.md#member) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

mcwumbly: Note: Using serviceCIDRs to auto-detect whether a cluster is IPv4 or IPv6 is probably not the best approach since dual stack clusters _may_ only have one of these values set, per [KEP 563](https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/563-dual-stack#services):

> A dual-stack cluster can have this flag configured as either `--services-cluster-ip-range=<cidr>,<cidr>` or `--services-cluster-ip-range=<cidr>`

In the underlying KEP, based on my current reading so far, it seems like it is non-trivial (and perhaps impossible) to detect the IP Family of a cluster's pod network at config-time.

Perhaps we have more control over that in Cluster API. For example, we could use `spec.ClusterNetwork.Pods`. But that field is [also optional](https://github.com/kubernetes-sigs/cluster-api/blob/402a4524f00671eabe6441c71d749dfe258bc43f/api/v1alpha4/cluster_types.go#L78-L80), so perhaps we can't depend on it.

Maybe all of this is arguing for either:

1. A new, explicit flag at the Cluster level or...
2. Leave it to the user to wire the right values through at the provider level

Will need to think about this, but interested in others' thoughts... If we go with (2), in the case of CAPD, where might the best place be for that configuration?
mcwumbly: Thanks for all the comments @fabriziopandini! we'll plan to follow up with another revision shortly -- I think we have a way forward with the auto-detection for now, but will continue to discuss if there are other ideas in the meantime.
yastij: /cc
fabriziopandini: /ok-to-test
fabriziopandini: @mcwumbly I have made some thinking about how to setup E2E tests for ipv6, and assuming that for starting we are going to implement only a prepull job running ipv6 quick start for each PR.

In order to do so:
- It is required to change https://github.com/kubernetes-sigs/cluster-api/blob/eef7bb9227377c31df1d20a60c4d8d778346fa68/test/e2e/e2e_suite_test.go#L184-L188 adding a new `ipFamily` parameter, and this should flow down to the kind configuration
- the `ipFamily` parameter should come from a new `IP_FAMILY`  variable in `docker.yaml`, defaulting to ipv4; also `DOCKER_SERVICE_CIDRS` and `DOCKER_POD_CIDRS` should rollback to ipv4 values
- a new `pull-cluster-api-e2e-ipv6-main` prow job will override the `IP_FAMILY`  variable as well as the `DOCKER_SERVICE_CIDRS` and `DOCKER_POD_CIDRS` variables with ipv6 values (*)
- the `IP_FAMILY` variable should also determine which template we are going to use for the quick start job at https://github.com/kubernetes-sigs/cluster-api/blob/eef7bb9227377c31df1d20a60c4d8d778346fa68/test/e2e/quick_start.go#L82
- we should create a `cluster-template-ipv6` template flavour in `https://github.com/kubernetes-sigs/cluster-api/tree/master/test/e2e/data/infrastructure-docker/v1alpha4`

(*) In order to do so this PR should merge first, however you can temporary make CI to work in ipv6 mode by exporting ipv6 value overrides in `scripts/ci-e2e.sh`; for local testing, you can do the same in `test/e2e/Makefile`

I know this is a lot to take in, feel free to reach out to me so I can provide some context and also eventually help in breaking out this work in separated PRs.
mcwumbly: > I know this is a lot to take in, feel free to reach out to me so I can provide some context and also eventually help in breaking out this work in separated PRs.

Sounds good. I opened this initially as a draft with a spike so we could all see where we might need changes like what you're suggesting more easily. I plan to address these issues and will reach out if I think breaking some of it out into separate PRs makes sense. 

I hope to get some time to go through this all more tomorrow or early next week.
fabriziopandini: @sbueringer if you have some spare cycles, PTAL
sbueringer: > @sbueringer if you have some spare review cycles, PTAL

Yup, have some time now. I'll take a look.
mcwumbly: Thanks for the review @sbueringer - happy to just address the nits as you see fit. I think maintainers should have veto rights over these sort of things. Will update as you prefer, and if you change you or anyone else changes their mind in the future about things (that are non-breaking), please feel free to make any further changes as you see fit.

Will push an update with the changes requested by tomorrow sometime.
mcwumbly: @sbueringer @fabriziopandini - I addressed the feedback from today in two additional commits to aid in review. If things look good, I'll plan to squash and force push.
sbueringer: @mcwumbly Looks mostly good :). I answered one of your comments, I think the IP_FAMILY would be nice and we just made a change to our CAPD YAMLs to also set the cgroup-driver kubeletExtraArgs flag to `cgroupfs` (so we need this also here on this PR)
fabriziopandini: pending addressing last comment and squash commits, I'm ready to lgtm this PR
great work!
mcwumbly: cool, I'll make one more pass to address the outstanding feedback on Monday. have a great weekend!
mcwumbly: @sbueringer and @fabriziopandini  - I believe I have addressed all the remaining feedback. Branch has been rebased on master and squashed to a single commit.

Patch:
diff --git a/api/v1alpha4/cluster_types.go b/api/v1alpha4/cluster_types.go
index d1d407801..93ccadc73 100644
--- a/api/v1alpha4/cluster_types.go
+++ b/api/v1alpha4/cluster_types.go
@@ -21,6 +21,7 @@ import (
 	"net"
 	"strings"
 
+	"github.com/pkg/errors"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/pointer"
@@ -215,6 +216,87 @@ func (c *Cluster) SetConditions(conditions Conditions) {
 	c.Status.Conditions = conditions
 }
 
+func (c *Cluster) GetIPFamily() (ClusterIPFamily, error) {
+	var podCIDRs, serviceCIDRs []string
+	if c.Spec.ClusterNetwork != nil {
+		if c.Spec.ClusterNetwork.Pods != nil {
+			podCIDRs = c.Spec.ClusterNetwork.Pods.CIDRBlocks
+		}
+		if c.Spec.ClusterNetwork.Services != nil {
+			serviceCIDRs = c.Spec.ClusterNetwork.Services.CIDRBlocks
+		}
+	}
+	if len(podCIDRs) == 0 && len(serviceCIDRs) == 0 {
+		return IPv4IPFamily, nil
+	}
+
+	podsIPFamily, err := ipFamilyForCIDRStrings(podCIDRs)
+	if err != nil {
+		return InvalidIPFamily, fmt.Errorf("pods: %s", err)
+	}
+	if len(serviceCIDRs) == 0 {
+		return podsIPFamily, nil
+	}
+
+	servicesIPFamily, err := ipFamilyForCIDRStrings(serviceCIDRs)
+	if err != nil {
+		return InvalidIPFamily, fmt.Errorf("services: %s", err)
+	}
+	if len(podCIDRs) == 0 {
+		return servicesIPFamily, nil
+	}
+
+	if podsIPFamily == DualStackIPFamily {
+		return DualStackIPFamily, nil
+	} else if podsIPFamily != servicesIPFamily {
+		return InvalidIPFamily, errors.New("pods and services IP family mismatch")
+	}
+
+	return podsIPFamily, nil
+}
+
+func ipFamilyForCIDRStrings(cidrs []string) (ClusterIPFamily, error) {
+	if len(cidrs) > 2 {
+		return InvalidIPFamily, errors.New("too many CIDRs specified")
+	}
+	var foundIPv4 bool
+	var foundIPv6 bool
+	for _, cidr := range cidrs {
+		ip, _, err := net.ParseCIDR(cidr)
+		if err != nil {
+			return InvalidIPFamily, fmt.Errorf("could not parse CIDR: %s", err)
+		}
+		if ip.To4() != nil {
+			foundIPv4 = true
+		} else {
+			foundIPv6 = true
+		}
+	}
+	switch {
+	case foundIPv4 && foundIPv6:
+		return DualStackIPFamily, nil
+	case foundIPv4:
+		return IPv4IPFamily, nil
+	case foundIPv6:
+		return IPv6IPFamily, nil
+	default:
+		return InvalidIPFamily, nil
+	}
+}
+
+type ClusterIPFamily int
+
+const (
+	InvalidIPFamily ClusterIPFamily = iota
+	IPv4IPFamily
+	IPv6IPFamily
+	DualStackIPFamily
+)
+
+func (f ClusterIPFamily) String() string {
+	return [...]string{"InvalidIPFamily", "IPv4IPFamily", "IPv6IPFamily", "DualStackIPFamily"}[f]
+}
+
 // +kubebuilder:object:root=true
 
 // ClusterList contains a list of Cluster.


Test patch:
diff --git a/api/v1alpha4/cluster_types_test.go b/api/v1alpha4/cluster_types_test.go
new file mode 100644
index 000000000..82ed5a9af
--- /dev/null
+++ b/api/v1alpha4/cluster_types_test.go
@@ -0,0 +1,197 @@
+/*
+Copyright 2021 The Kubernetes 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 v1alpha4
+
+import (
+	"testing"
+
+	. "github.com/onsi/gomega"
+)
+
+func TestClusterIPFamily(t *testing.T) {
+	clusterWithNetwork := func(podCIDRs, serviceCIDRs []string) *Cluster {
+		return &Cluster{
+			Spec: ClusterSpec{
+				ClusterNetwork: &ClusterNetwork{
+					Pods: &NetworkRanges{
+						CIDRBlocks: podCIDRs,
+					},
+					Services: &NetworkRanges{
+						CIDRBlocks: serviceCIDRs,
+					},
+				},
+			},
+		}
+	}
+
+	validAndUnambiguous := []struct {
+		name      string
+		expectRes ClusterIPFamily
+		c         *Cluster
+	}{
+		{
+			name:      "pods: ipv4, services: ipv4",
+			expectRes: IPv4IPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, []string{"10.128.0.0/12"}),
+		},
+		{
+			name:      "pods: ipv4, services: nil",
+			expectRes: IPv4IPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, nil),
+		},
+		{
+			name:      "pods: ipv6, services: nil",
+			expectRes: IPv6IPFamily,
+			c:         clusterWithNetwork([]string{"fd00:100:96::/48"}, nil),
+		},
+		{
+			name:      "pods: ipv6, services: ipv6",
+			expectRes: IPv6IPFamily,
+			c:         clusterWithNetwork([]string{"fd00:100:96::/48"}, []string{"fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: dual-stack, services: nil",
+			expectRes: DualStackIPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16", "fd00:100:96::/48"}, nil),
+		},
+		{
+			name:      "pods: dual-stack, services: ipv4",
+			expectRes: DualStackIPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16", "fd00:100:96::/48"}, []string{"10.128.0.0/12"}),
+		},
+		{
+			name:      "pods: dual-stack, services: ipv6",
+			expectRes: DualStackIPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16", "fd00:100:96::/48"}, []string{"fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: dual-stack, services: dual-stack",
+			expectRes: DualStackIPFamily,
+			c:         clusterWithNetwork([]string{"192.168.0.0/16", "fd00:100:96::/48"}, []string{"10.128.0.0/12", "fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: nil, services: dual-stack",
+			expectRes: DualStackIPFamily,
+			c:         clusterWithNetwork(nil, []string{"10.128.0.0/12", "fd00:100:64::/108"}),
+		},
+	}
+
+	for _, tt := range validAndUnambiguous {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+			ipFamily, err := tt.c.GetIPFamily()
+			g.Expect(ipFamily).To(Equal(tt.expectRes))
+			g.Expect(err).NotTo(HaveOccurred())
+		})
+	}
+
+	validButAmbiguous := []struct {
+		name      string
+		expectRes ClusterIPFamily
+		c         *Cluster
+	}{
+		{
+			name: "pods: nil, services: nil",
+			// this could  be ipv4, ipv6, or dual-stack; assume ipv4 for now though
+			expectRes: IPv4IPFamily,
+			c:         clusterWithNetwork(nil, nil),
+		},
+		{
+			name: "pods: nil, services: ipv4",
+			// this could be a dual-stack; assume ipv4 for now though
+			expectRes: IPv4IPFamily,
+			c:         clusterWithNetwork(nil, []string{"10.128.0.0/12"}),
+		},
+		{
+			name: "pods: nil, services: ipv6",
+			// this could be dual-stack; assume ipv6 for now though
+			expectRes: IPv6IPFamily,
+			c:         clusterWithNetwork(nil, []string{"fd00:100:64::/108"}),
+		},
+	}
+
+	for _, tt := range validButAmbiguous {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+			ipFamily, err := tt.c.GetIPFamily()
+			g.Expect(ipFamily).To(Equal(tt.expectRes))
+			g.Expect(err).NotTo(HaveOccurred())
+		})
+	}
+
+	invalid := []struct {
+		name      string
+		expectErr string
+		c         *Cluster
+	}{
+		{
+			name:      "pods: ipv4, services: ipv6",
+			expectErr: "pods and services IP family mismatch",
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, []string{"fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: ipv6, services: ipv4",
+			expectErr: "pods and services IP family mismatch",
+			c:         clusterWithNetwork([]string{"fd00:100:96::/48"}, []string{"10.128.0.0/12"}),
+		},
+		{
+			name:      "pods: ipv6, services: dual-stack",
+			expectErr: "pods and services IP family mismatch",
+			c:         clusterWithNetwork([]string{"fd00:100:96::/48"}, []string{"10.128.0.0/12", "fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: ipv4, services: dual-stack",
+			expectErr: "pods and services IP family mismatch",
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, []string{"10.128.0.0/12", "fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: ipv4, services: dual-stack",
+			expectErr: "pods and services IP family mismatch",
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, []string{"10.128.0.0/12", "fd00:100:64::/108"}),
+		},
+		{
+			name:      "pods: bad cidr",
+			expectErr: "pods: could not parse CIDR",
+			c:         clusterWithNetwork([]string{"foo"}, nil),
+		},
+		{
+			name:      "services: bad cidr",
+			expectErr: "services: could not parse CIDR",
+			c:         clusterWithNetwork([]string{"192.168.0.0/16"}, []string{"foo"}),
+		},
+		{
+			name:      "pods: too many cidrs",
+			expectErr: "pods: too many CIDRs specified",
+			c:         clusterWithNetwork([]string{"192.168.0.0/16", "fd00:100:96::/48", "10.128.0.0/12"}, nil),
+		},
+		{
+			name:      "services: too many cidrs",
+			expectErr: "services: too many CIDRs specified",
+			c:         clusterWithNetwork(nil, []string{"192.168.0.0/16", "fd00:100:96::/48", "10.128.0.0/12"}),
+		},
+	}
+
+	for _, tt := range invalid {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+			ipFamily, err := tt.c.GetIPFamily()
+			g.Expect(err).To(HaveOccurred())
+			g.Expect(err).To(MatchError(ContainSubstring(tt.expectErr)))
+			g.Expect(ipFamily).To(Equal(InvalidIPFamily))
+		})
+	}
+}
diff --git a/test/e2e/Makefile b/test/e2e/Makefile
index bfbc7f61f..9fc73f270 100644
--- a/test/e2e/Makefile
+++ b/test/e2e/Makefile
@@ -69,6 +69,7 @@ cluster-templates-v1alpha4: $(KUSTOMIZE) ## Generate cluster templates for v1alp
 	$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-node-drain --load_restrictor none > $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-node-drain.yaml
 	$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-upgrades --load_restrictor none > $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-upgrades.yaml
 	$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-kcp-scale-in --load_restrictor none > $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-kcp-scale-in.yaml
+	$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-ipv6 --load_restrictor none > $(DOCKER_TEMPLATES)/v1alpha4/cluster-template-ipv6.yaml
 ## --------------------------------------
 ## Testing
 ## --------------------------------------
diff --git a/test/e2e/common.go b/test/e2e/common.go
index 8632bed0a..c1166e22d 100644
--- a/test/e2e/common.go
+++ b/test/e2e/common.go
@@ -40,6 +40,7 @@ const (
 	KubernetesVersionUpgradeTo   = "KUBERNETES_VERSION_UPGRADE_TO"
 	EtcdVersionUpgradeTo         = "ETCD_VERSION_UPGRADE_TO"
 	CoreDNSVersionUpgradeTo      = "COREDNS_VERSION_UPGRADE_TO"
+	IPFamily                     = "IP_FAMILY"
 )
 
 func Byf(format string, a ...interface{}) {
diff --git a/test/e2e/config/docker.yaml b/test/e2e/config/docker.yaml
index b99d5f486..7c3c9f65c 100644
--- a/test/e2e/config/docker.yaml
+++ b/test/e2e/config/docker.yaml
@@ -104,6 +104,7 @@ providers:
     - sourcePath: "../data/infrastructure-docker/v1alpha4/cluster-template-node-drain.yaml"
     - sourcePath: "../data/infrastructure-docker/v1alpha4/cluster-template-upgrades.yaml"
     - sourcePath: "../data/infrastructure-docker/v1alpha4/cluster-template-kcp-scale-in.yaml"
+    - sourcePath: "../data/infrastructure-docker/v1alpha4/cluster-template-ipv6.yaml"
     - sourcePath: "../data/shared/v1alpha4/metadata.yaml"
 
 variables:
@@ -115,8 +116,8 @@ variables:
   KUBERNETES_VERSION_UPGRADE_TO: "v1.19.1"
   KUBERNETES_VERSION_UPGRADE_FROM: "v1.18.2"
   DOCKER_SERVICE_DOMAIN: "cluster.local"
+  IP_FAMILY: "IPv4"
   DOCKER_SERVICE_CIDRS: "10.128.0.0/12"
-  # IMPORTANT! This values should match the one used by the CNI provider
   DOCKER_POD_CIDRS: "192.168.0.0/16"
   CNI: "./data/cni/kindnet/kindnet.yaml"
   EXP_CLUSTER_RESOURCE_SET: "true"
diff --git a/test/e2e/data/cni/kindnet/kindnet.yaml b/test/e2e/data/cni/kindnet/kindnet.yaml
index 8b8589a59..d298280d3 100644
--- a/test/e2e/data/cni/kindnet/kindnet.yaml
+++ b/test/e2e/data/cni/kindnet/kindnet.yaml
@@ -78,7 +78,7 @@ spec:
                 fieldRef:
                   fieldPath: status.podIP
             - name: POD_SUBNET
-              value: "192.168.0.0/16"
+              value: '${DOCKER_POD_CIDRS}'
           volumeMounts:
             - name: cni-cfg
               mountPath: /etc/cni/net.d
diff --git a/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kcp-ipv6.yaml b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kcp-ipv6.yaml
new file mode 100644
index 000000000..62f0a89c3
--- /dev/null
+++ b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kcp-ipv6.yaml
@@ -0,0 +1,22 @@
+---
+kind: KubeadmControlPlane
+apiVersion: controlplane.cluster.x-k8s.io/v1alpha4
+metadata:
+  name: "${CLUSTER_NAME}-control-plane"
+spec:
+  kubeadmConfigSpec:
+    clusterConfiguration:
+      apiServer:
+        # host.docker.internal is required by kubetest when running on MacOS because of the way ports are proxied.
+        certSANs: [localhost, "::", "::1", host.docker.internal]
+    initConfiguration:
+      localAPIEndpoint:
+        advertiseAddress: '::'
+        bindPort: 6443
+      nodeRegistration:
+        kubeletExtraArgs:
+          node-ip: "::"
+    joinConfiguration:
+      nodeRegistration:
+        kubeletExtraArgs:
+          node-ip: "::"
diff --git a/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kustomization.yaml b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kustomization.yaml
new file mode 100644
index 000000000..1a233e731
--- /dev/null
+++ b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/kustomization.yaml
@@ -0,0 +1,8 @@
+bases:
+  - ../bases/cluster-with-kcp.yaml
+  - ../bases/md.yaml
+  - ../bases/crs.yaml
+
+patchesStrategicMerge:
+  - ./md-ipv6.yaml
+  - ./kcp-ipv6.yaml
diff --git a/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/md-ipv6.yaml b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/md-ipv6.yaml
new file mode 100644
index 000000000..106f4968b
--- /dev/null
+++ b/test/e2e/data/infrastructure-docker/v1alpha4/cluster-template-ipv6/md-ipv6.yaml
@@ -0,0 +1,16 @@
+---
+apiVersion: bootstrap.cluster.x-k8s.io/v1alpha4
+kind: KubeadmConfigTemplate
+metadata:
+  name: "${CLUSTER_NAME}-md-0"
+spec:
+  template:
+    spec:
+      initConfiguration:
+        nodeRegistration:
+          kubeletExtraArgs:
+            node-ip: "::"
+      joinConfiguration:
+        nodeRegistration:
+          kubeletExtraArgs:
+            node-ip: "::"
diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go
index 13c1648ba..1df554f33 100644
--- a/test/e2e/e2e_suite_test.go
+++ b/test/e2e/e2e_suite_test.go
@@ -185,6 +185,7 @@ func setupBootstrapCluster(config *clusterctl.E2EConfig, scheme *runtime.Scheme,
 			Name:               config.ManagementClusterName,
 			RequiresDockerSock: config.HasDockerProvider(),
 			Images:             config.Images,
+			IPFamily:           config.GetVariable(IPFamily),
 		})
 		Expect(clusterProvider).ToNot(BeNil(), "Failed to create a bootstrap cluster")
 
diff --git a/test/e2e/quick_start.go b/test/e2e/quick_start.go
index 703db660f..e2e65ad72 100644
--- a/test/e2e/quick_start.go
+++ b/test/e2e/quick_start.go
@@ -72,6 +72,11 @@ func QuickStartSpec(ctx context.Context, inputGetter func() QuickStartSpecInput)
 	It("Should create a workload cluster", func() {
 		By("Creating a workload cluster")
 
+		flavor := clusterctl.DefaultFlavor
+		if input.E2EConfig.GetVariable(IPFamily) == "IPv6" {
+			flavor = "ipv6"
+		}
+
 		clusterctl.ApplyClusterTemplateAndWait(ctx, clusterctl.ApplyClusterTemplateAndWaitInput{
 			ClusterProxy: input.BootstrapClusterProxy,
 			ConfigCluster: clusterctl.ConfigClusterInput{
@@ -79,7 +84,7 @@ func QuickStartSpec(ctx context.Context, inputGetter func() QuickStartSpecInput)
 				ClusterctlConfigPath:     input.ClusterctlConfigPath,
 				KubeconfigPath:           input.BootstrapClusterProxy.GetKubeconfigPath(),
 				InfrastructureProvider:   clusterctl.DefaultInfrastructureProvider,
-				Flavor:                   clusterctl.DefaultFlavor,
+				Flavor:                   flavor,
 				Namespace:                namespace.Name,
 				ClusterName:              fmt.Sprintf("%s-%s", specName, util.RandomString(6)),
 				KubernetesVersion:        input.E2EConfig.GetVariable(KubernetesVersion),
diff --git a/test/framework/bootstrap/kind_provider.go b/test/framework/bootstrap/kind_provider.go
index 7b81bab13..783029232 100644
--- a/test/framework/bootstrap/kind_provider.go
+++ b/test/framework/bootstrap/kind_provider.go
@@ -22,6 +22,7 @@ import (
 
 	. "github.com/onsi/gomega"
 
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
 	"sigs.k8s.io/cluster-api/test/framework/internal/log"
 	kindv1 "sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
 	kind "sigs.k8s.io/kind/pkg/cluster"
@@ -58,6 +59,14 @@ func WithDockerSockMount() KindClusterOption {
 	})
 }
 
+// WithIPv6Family implements a New Option that instruct the kindClusterProvider to set the IPFamily to IPv6 in
+// the new kind cluster.
+func WithIPv6Family() KindClusterOption {
+	return kindClusterOptionAdapter(func(k *kindClusterProvider) {
+		k.ipFamily = clusterv1.IPv6IPFamily
+	})
+}
+
 // NewKindClusterProvider returns a ClusterProvider that can create a kind cluster.
 func NewKindClusterProvider(name string, options ...KindClusterOption) *kindClusterProvider {
 	Expect(name).ToNot(BeEmpty(), "name is required for NewKindClusterProvider")
@@ -77,6 +86,7 @@ type kindClusterProvider struct {
 	withDockerSock bool
 	kubeconfigPath string
 	nodeImage      string
+	ipFamily       clusterv1.ClusterIPFamily
 }
 
 // Create a Kubernetes cluster using kind.
@@ -100,10 +110,25 @@ func (k *kindClusterProvider) createKindCluster() {
 	kindCreateOptions := []kind.CreateOption{
 		kind.CreateWithKubeconfigPath(k.kubeconfigPath),
 	}
+
+	cfg := &kindv1.Cluster{
+		TypeMeta: kindv1.TypeMeta{
+			APIVersion: "kind.x-k8s.io/v1alpha4",
+			Kind:       "Cluster",
+		},
+	}
+
+	if k.ipFamily == clusterv1.IPv6IPFamily {
+		cfg.Networking.IPFamily = kindv1.IPv6Family
+	}
+	kindv1.SetDefaultsCluster(cfg)
+
 	if k.withDockerSock {
-		kindCreateOptions = append(kindCreateOptions, kind.CreateWithV1Alpha4Config(withDockerSockConfig()))
+		setDockerSockConfig(cfg)
 	}
 
+	kindCreateOptions = append(kindCreateOptions, kind.CreateWithV1Alpha4Config(cfg))
+
 	nodeImage := DefaultNodeImage
 	if k.nodeImage != "" {
 		nodeImage = k.nodeImage
@@ -114,15 +139,8 @@ func (k *kindClusterProvider) createKindCluster() {
 	Expect(err).ToNot(HaveOccurred(), "Failed to create the kind cluster %q")
 }
 
-// withDockerSockConfig returns a kind config for mounting /var/run/docker.sock into the kind node.
-func withDockerSockConfig() *kindv1.Cluster {
-	cfg := &kindv1.Cluster{
-		TypeMeta: kindv1.TypeMeta{
-			APIVersion: "kind.x-k8s.io/v1alpha4",
-			Kind:       "Cluster",
-		},
-	}
-	kindv1.SetDefaultsCluster(cfg)
+// setDockerSockConfig returns a kind config for mounting /var/run/docker.sock into the kind node.
+func setDockerSockConfig(cfg *kindv1.Cluster) {
 	cfg.Nodes = []kindv1.Node{
 		{
 			Role: kindv1.ControlPlaneRole,
@@ -134,7 +152,6 @@ func withDockerSockConfig() *kindv1.Cluster {
 			},
 		},
 	}
-	return cfg
 }
 
 // GetKubeconfigPath returns the path to the kubeconfig file for the cluster.
diff --git a/test/framework/bootstrap/kind_util.go b/test/framework/bootstrap/kind_util.go
index 698d81f21..35841b5bf 100644
--- a/test/framework/bootstrap/kind_util.go
+++ b/test/framework/bootstrap/kind_util.go
@@ -42,6 +42,9 @@ type CreateKindBootstrapClusterAndLoadImagesInput struct {
 
 	// Images to be loaded in the cluster (this is kind specific)
 	Images []clusterctl.ContainerImage
+
+	// IPFamily is either ipv4 or ipv6. Default is ipv4.
+	IPFamily string
 }
 
 // CreateKindBootstrapClusterAndLoadImages returns a new Kubernetes cluster with pre-loaded images.
@@ -55,6 +58,10 @@ func CreateKindBootstrapClusterAndLoadImages(ctx context.Context, input CreateKi
 	if input.RequiresDockerSock {
 		options = append(options, WithDockerSockMount())
 	}
+	if input.IPFamily == "IPv6" {
+		options = append(options, WithIPv6Family())
+	}
+
 	clusterProvider := NewKindClusterProvider(input.Name, options...)
 	Expect(clusterProvider).ToNot(BeNil(), "Failed to create a kind cluster")
 
diff --git a/test/infrastructure/docker/controllers/dockercluster_controller.go b/test/infrastructure/docker/controllers/dockercluster_controller.go
index a4e57f757..c9047c98d 100644
--- a/test/infrastructure/docker/controllers/dockercluster_controller.go
+++ b/test/infrastructure/docker/controllers/dockercluster_controller.go
@@ -72,7 +72,7 @@ func (r *DockerClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 	log = log.WithValues("cluster", cluster.Name)
 
 	// Create a helper for managing a docker container hosting the loadbalancer.
-	externalLoadBalancer, err := docker.NewLoadBalancer(cluster.Name)
+	externalLoadBalancer, err := docker.NewLoadBalancer(cluster)
 	if err != nil {
 		return ctrl.Result{}, errors.Wrapf(err, "failed to create helper for managing the externalLoadBalancer")
 	}
@@ -141,14 +141,14 @@ func (r *DockerClusterReconciler) reconcileNormal(ctx context.Context, dockerClu
 	}
 
 	// Set APIEndpoints with the load balancer IP so the Cluster API Cluster Controller can pull it
-	lbip4, err := externalLoadBalancer.IP(ctx)
+	lbIP, err := externalLoadBalancer.IP(ctx)
 	if err != nil {
 		conditions.MarkFalse(dockerCluster, infrav1.LoadBalancerAvailableCondition, infrav1.LoadBalancerProvisioningFailedReason, clusterv1.ConditionSeverityWarning, err.Error())
 		return ctrl.Result{}, errors.Wrap(err, "failed to get ip for the load balancer")
 	}
 
 	dockerCluster.Spec.ControlPlaneEndpoint = infrav1.APIEndpoint{
-		Host: lbip4,
+		Host: lbIP,
 		Port: 6443,
 	}
 
diff --git a/test/infrastructure/docker/controllers/dockermachine_controller.go b/test/infrastructure/docker/controllers/dockermachine_controller.go
index f82de477d..da7a7cf04 100644
--- a/test/infrastructure/docker/controllers/dockermachine_controller.go
+++ b/test/infrastructure/docker/controllers/dockermachine_controller.go
@@ -138,7 +138,7 @@ func (r *DockerMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 	}
 
 	// Create a helper for managing the docker container hosting the machine.
-	externalMachine, err := docker.NewMachine(cluster.Name, machine.Name, dockerMachine.Spec.CustomImage, nil)
+	externalMachine, err := docker.NewMachine(cluster, machine.Name, dockerMachine.Spec.CustomImage, nil)
 	if err != nil {
 		return ctrl.Result{}, errors.Wrapf(err, "failed to create helper for managing the externalMachine")
 	}
@@ -147,7 +147,7 @@ func (r *DockerMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 	// NB. the machine controller has to manage the cluster load balancer because the current implementation of the
 	// docker load balancer does not support auto-discovery of control plane nodes, so CAPD should take care of
 	// updating the cluster load balancer configuration when control plane machines are added/removed
-	externalLoadBalancer, err := docker.NewLoadBalancer(cluster.Name)
+	externalLoadBalancer, err := docker.NewLoadBalancer(cluster)
 	if err != nil {
 		return ctrl.Result{}, errors.Wrapf(err, "failed to create helper for managing the externalLoadBalancer")
 	}
diff --git a/test/infrastructure/docker/docker/kind_manager.go b/test/infrastructure/docker/docker/kind_manager.go
index 61776ccf2..99e497d4e 100644
--- a/test/infrastructure/docker/docker/kind_manager.go
+++ b/test/infrastructure/docker/docker/kind_manager.go
@@ -23,6 +23,8 @@ import (
 	"strings"
 
 	"github.com/pkg/errors"
+
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
 	"sigs.k8s.io/cluster-api/test/infrastructure/docker/docker/types"
 	"sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
 	"sigs.k8s.io/kind/pkg/cluster/constants"
@@ -34,7 +36,7 @@ const ControlPlanePort = 6443
 
 type Manager struct{}
 
-func (m *Manager) CreateControlPlaneNode(name, image, clusterLabel, listenAddress string, port int32, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string) (*types.Node, error) {
+func (m *Manager) CreateControlPlaneNode(name, image, clusterLabel, listenAddress string, port int32, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string, ipFamily clusterv1.ClusterIPFamily) (*types.Node, error) {
 	// gets a random host port for the API server
 	if port == 0 {
 		p, err := getPort()
@@ -51,7 +53,7 @@ func (m *Manager) CreateControlPlaneNode(name, image, clusterLabel, listenAddres
 		ContainerPort: KubeadmContainerPort,
 	})
 	node, err := createNode(
-		name, image, clusterLabel, constants.ControlPlaneNodeRoleValue, mounts, portMappingsWithAPIServer,
+		name, image, clusterLabel, constants.ControlPlaneNodeRoleValue, mounts, portMappingsWithAPIServer, ipFamily,
 		// publish selected port for the API server
 		append([]string{"--expose", fmt.Sprintf("%d", port)}, labelsAsArgs(labels)...)...,
 	)
@@ -62,11 +64,11 @@ func (m *Manager) CreateControlPlaneNode(name, image, clusterLabel, listenAddres
 	return node, nil
 }
 
-func (m *Manager) CreateWorkerNode(name, image, clusterLabel string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string) (*types.Node, error) {
-	return createNode(name, image, clusterLabel, constants.WorkerNodeRoleValue, mounts, portMappings, labelsAsArgs(labels)...)
+func (m *Manager) CreateWorkerNode(name, image, clusterLabel string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string, ipFamily clusterv1.ClusterIPFamily) (*types.Node, error) {
+	return createNode(name, image, clusterLabel, constants.WorkerNodeRoleValue, mounts, portMappings, ipFamily, labelsAsArgs(labels)...)
 }
 
-func (m *Manager) CreateExternalLoadBalancerNode(name, image, clusterLabel, listenAddress string, port int32) (*types.Node, error) {
+func (m *Manager) CreateExternalLoadBalancerNode(name, image, clusterLabel, listenAddress string, port int32, ipFamily clusterv1.ClusterIPFamily) (*types.Node, error) {
 	// gets a random host port for control-plane load balancer
 	// gets a random host port for the API server
 	if port == 0 {
@@ -84,7 +86,7 @@ func (m *Manager) CreateExternalLoadBalancerNode(name, image, clusterLabel, list
 		ContainerPort: ControlPlanePort,
 	}}
 	node, err := createNode(name, image, clusterLabel, constants.ExternalLoadBalancerNodeRoleValue,
-		nil, portMappings,
+		nil, portMappings, ipFamily,
 		// publish selected port for the control plane
 		"--expose", fmt.Sprintf("%d", port),
 	)
@@ -95,7 +97,7 @@ func (m *Manager) CreateExternalLoadBalancerNode(name, image, clusterLabel, list
 	return node, nil
 }
 
-func createNode(name, image, clusterLabel, role string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, extraArgs ...string) (*types.Node, error) {
+func createNode(name, image, clusterLabel, role string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, ipFamily clusterv1.ClusterIPFamily, extraArgs ...string) (*types.Node, error) {
 	runArgs := []string{
 		"--detach", // run the container detached
 		"--tty",    // allocate a tty for entrypoint logs
@@ -126,6 +128,13 @@ func createNode(name, image, clusterLabel, role string, mounts []v1alpha4.Mount,
 		"--label", fmt.Sprintf("%s=%s", nodeRoleLabelKey, role),
 	}
 
+	if ipFamily == clusterv1.IPv6IPFamily {
+		runArgs = append(runArgs,
+			"--sysctl=net.ipv6.conf.all.disable_ipv6=0",
+			"--sysctl=net.ipv6.conf.all.forwarding=1",
+		)
+	}
+
 	// pass proxy environment variables to be used by node's docker daemon
 	proxyDetails, err := getProxyDetails()
 	if err != nil || proxyDetails == nil {
diff --git a/test/infrastructure/docker/docker/loadbalancer.go b/test/infrastructure/docker/docker/loadbalancer.go
index 640dc22a8..50dbef75b 100644
--- a/test/infrastructure/docker/docker/loadbalancer.go
+++ b/test/infrastructure/docker/docker/loadbalancer.go
@@ -19,8 +19,10 @@ package docker
 import (
 	"context"
 	"fmt"
+	"net"
 
 	"github.com/pkg/errors"
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
 	"sigs.k8s.io/cluster-api/test/infrastructure/docker/docker/types"
 	"sigs.k8s.io/cluster-api/test/infrastructure/docker/third_party/forked/loadbalancer"
 	ctrl "sigs.k8s.io/controller-runtime"
@@ -28,37 +30,43 @@ import (
 )
 
 type lbCreator interface {
-	CreateExternalLoadBalancerNode(name, image, clusterLabel, listenAddress string, port int32) (*types.Node, error)
+	CreateExternalLoadBalancerNode(name, image, clusterLabel, listenAddress string, port int32, ipFamily clusterv1.ClusterIPFamily) (*types.Node, error)
 }
 
 // LoadBalancer manages the load balancer for a specific docker cluster.
 type LoadBalancer struct {
 	name      string
 	container *types.Node
-
+	ipFamily  clusterv1.ClusterIPFamily
 	lbCreator lbCreator
 }
 
 // NewLoadBalancer returns a new helper for managing a docker loadbalancer with a given name.
-func NewLoadBalancer(name string) (*LoadBalancer, error) {
-	if name == "" {
-		return nil, errors.New("name is required when creating a docker.LoadBalancer")
+func NewLoadBalancer(cluster *clusterv1.Cluster) (*LoadBalancer, error) {
+	if cluster.Name == "" {
+		return nil, errors.New("create load balancer: cluster name is empty")
 	}
 
 	// look for the container that is hosting the loadbalancer for the cluster.
 	// filter based on the label and the roles regardless of whether or not it is running.
 	// if non-running container is chosen, then it will not have an IP address associated with it.
 	container, err := getContainer(
-		withLabel(clusterLabel(name)),
+		withLabel(clusterLabel(cluster.Name)),
 		withLabel(roleLabel(constants.ExternalLoadBalancerNodeRoleValue)),
 	)
 	if err != nil {
 		return nil, err
 	}
 
+	ipFamily, err := cluster.GetIPFamily()
+	if err != nil {
+		return nil, fmt.Errorf("create load balancer: %s", err)
+	}
+
 	return &LoadBalancer{
-		name:      name,
+		name:      cluster.Name,
 		container: container,
+		ipFamily:  ipFamily,
 		lbCreator: &Manager{},
 	}, nil
 }
@@ -71,7 +79,12 @@ func (s *LoadBalancer) containerName() string {
 // Create creates a docker container hosting a load balancer for the cluster.
 func (s *LoadBalancer) Create(ctx context.Context) error {
 	log := ctrl.LoggerFrom(ctx)
+	log = log.WithValues("cluster", s.name, "ipFamily", s.ipFamily)
 
+	listenAddr := "0.0.0.0"
+	if s.ipFamily == clusterv1.IPv6IPFamily {
+		listenAddr = "::"
+	}
 	// Create if not exists.
 	if s.container == nil {
 		var err error
@@ -80,8 +93,9 @@ func (s *LoadBalancer) Create(ctx context.Context) error {
 			s.containerName(),
 			loadbalancer.Image,
 			clusterLabel(s.name),
-			"0.0.0.0",
+			listenAddr,
 			0,
+			s.ipFamily,
 		)
 		if err != nil {
 			return errors.WithStack(err)
@@ -110,17 +124,21 @@ func (s *LoadBalancer) UpdateConfiguration(ctx context.Context) error {
 
 	var backendServers = map[string]string{}
 	for _, n := range controlPlaneNodes {
-		controlPlaneIPv4, _, err := n.IP(ctx)
+		controlPlaneIPv4, controlPlaneIPv6, err := n.IP(ctx)
 		if err != nil {
 			return errors.Wrapf(err, "failed to get IP for container %s", n.String())
 		}
-		backendServers[n.String()] = fmt.Sprintf("%s:%d", controlPlaneIPv4, 6443)
+		if s.ipFamily == clusterv1.IPv6IPFamily {
+			backendServers[n.String()] = net.JoinHostPort(controlPlaneIPv6, "6443")
+		} else {
+			backendServers[n.String()] = net.JoinHostPort(controlPlaneIPv4, "6443")
+		}
 	}
 
 	loadBalancerConfig, err := loadbalancer.Config(&loadbalancer.ConfigData{
 		ControlPlanePort: 6443,
 		BackendServers:   backendServers,
-		IPv6:             false,
+		IPv6:             s.ipFamily == clusterv1.IPv6IPFamily,
 	})
 	if err != nil {
 		return errors.WithStack(err)
@@ -136,15 +154,21 @@ func (s *LoadBalancer) UpdateConfiguration(ctx context.Context) error {
 
 // IP returns the load balancer IP address.
 func (s *LoadBalancer) IP(ctx context.Context) (string, error) {
-	lbip4, _, err := s.container.IP(ctx)
+	lbIPv4, lbIPv6, err := s.container.IP(ctx)
 	if err != nil {
 		return "", errors.WithStack(err)
 	}
-	if lbip4 == "" {
+	var lbIP string
+	if s.ipFamily == clusterv1.IPv6IPFamily {
+		lbIP = lbIPv6
+	} else {
+		lbIP = lbIPv4
+	}
+	if lbIP == "" {
 		// if there is a load balancer container with the same name exists but is stopped, it may not have IP address associated with it.
 		return "", errors.Errorf("load balancer IP cannot be empty: container %s does not have an associated IP address", s.containerName())
 	}
-	return lbip4, nil
+	return lbIP, nil
 }
 
 // Delete the docker container hosting the cluster load balancer.
diff --git a/test/infrastructure/docker/docker/machine.go b/test/infrastructure/docker/docker/machine.go
index 71796c879..5f960b37f 100644
--- a/test/infrastructure/docker/docker/machine.go
+++ b/test/infrastructure/docker/docker/machine.go
@@ -33,6 +33,7 @@ import (
 	"sigs.k8s.io/kind/pkg/cluster/constants"
 	"sigs.k8s.io/kind/pkg/exec"
 
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4"
 	infrav1 "sigs.k8s.io/cluster-api/test/infrastructure/docker/api/v1alpha4"
 	"sigs.k8s.io/cluster-api/test/infrastructure/docker/cloudinit"
 	"sigs.k8s.io/cluster-api/test/infrastructure/docker/docker/types"
@@ -45,8 +46,8 @@ const (
 )
 
 type nodeCreator interface {
-	CreateControlPlaneNode(name, image, clusterLabel, listenAddress string, port int32, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string) (node *types.Node, err error)
-	CreateWorkerNode(name, image, clusterLabel string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string) (node *types.Node, err error)
+	CreateControlPlaneNode(name, image, clusterLabel, listenAddress string, port int32, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string, ipFamily clusterv1.ClusterIPFamily) (node *types.Node, err error)
+	CreateWorkerNode(name, image, clusterLabel string, mounts []v1alpha4.Mount, portMappings []v1alpha4.PortMapping, labels map[string]string, ipFamily clusterv1.ClusterIPFamily) (node *types.Node, err error)
 }
 
 // Machine implement a service for managing the docker containers hosting a kubernetes nodes.
@@ -54,6 +55,7 @@ type Machine struct {
 	cluster   string
 	machine   string
 	image     string
+	ipFamily  clusterv1.ClusterIPFamily
 	labels    map[string]string
 	container *types.Node
 
@@ -61,17 +63,20 @@ type Machine struct {
 }
 
 // NewMachine returns a new Machine service for the given Cluster/DockerCluster pair.
-func NewMachine(cluster, machine, image string, labels map[string]string) (*Machine, error) {
-	if cluster == "" {
+func NewMachine(cluster *clusterv1.Cluster, machine, image string, labels map[string]string) (*Machine, error) {
+	if cluster == nil {
 		return nil, errors.New("cluster is required when creating a docker.Machine")
 	}
+	if cluster.Name == "" {
+		return nil, errors.New("cluster name is required when creating a docker.Machine")
+	}
 	if machine == "" {
 		return nil, errors.New("machine is required when creating a docker.Machine")
 	}
 
 	filters := []string{
-		withLabel(clusterLabel(cluster)),
-		withName(machineContainerName(cluster, machine)),
+		withLabel(clusterLabel(cluster.Name)),
+		withName(machineContainerName(cluster.Name, machine)),
 	}
 	for key, val := range labels {
 		filters = append(filters, withLabel(toLabel(key, val)))
@@ -82,23 +87,32 @@ func NewMachine(cluster, machine, image string, labels map[string]string) (*Mach
 		return nil, err
 	}
 
+	ipFamily, err := cluster.GetIPFamily()
+	if err != nil {
+		return nil, fmt.Errorf("create docker machine: %s", err)
+	}
+
 	return &Machine{
-		cluster:     cluster,
+		cluster:     cluster.Name,
 		machine:     machine,
 		image:       image,
+		ipFamily:    ipFamily,
 		container:   container,
 		labels:      labels,
 		nodeCreator: &Manager{},
 	}, nil
 }
 
-func ListMachinesByCluster(cluster string, labels map[string]string) ([]*Machine, error) {
-	if cluster == "" {
+func ListMachinesByCluster(cluster *clusterv1.Cluster, labels map[string]string) ([]*Machine, error) {
+	if cluster == nil {
 		return nil, errors.New("cluster is required when listing machines in the cluster")
 	}
+	if cluster.Name == "" {
+		return nil, errors.New("cluster name is required when listing machines in the cluster")
+	}
 
 	filters := []string{
-		withLabel(clusterLabel(cluster)),
+		withLabel(clusterLabel(cluster.Name)),
 	}
 	for key, val := range labels {
 		filters = append(filters, withLabel(toLabel(key, val)))
@@ -109,12 +123,18 @@ func ListMachinesByCluster(cluster string, labels map[string]string) ([]*Machine
 		return nil, err
 	}
 
+	ipFamily, err := cluster.GetIPFamily()
+	if err != nil {
+		return nil, fmt.Errorf("list docker machines by cluster: %s", err)
+	}
+
 	machines := make([]*Machine, len(containers))
 	for i, container := range containers {
 		machines[i] = &Machine{
-			cluster:     cluster,
-			machine:     machineFromContainerName(cluster, container.Name),
+			cluster:     cluster.Name,
+			machine:     machineFromContainerName(cluster.Name, container.Name),
 			image:       container.Image,
+			ipFamily:    ipFamily,
 			labels:      labels,
 			container:   container,
 			nodeCreator: &Manager{},
@@ -164,11 +184,14 @@ func (m *Machine) ProviderID() string {
 }
 
 func (m *Machine) Address(ctx context.Context) (string, error) {
-	ipv4, _, err := m.container.IP(ctx)
+	ipv4, ipv6, err := m.container.IP(ctx)
 	if err != nil {
 		return "", err
 	}
 
+	if m.ipFamily == clusterv1.IPv6IPFamily {
+		return ipv6, nil
+	}
 	return ipv4, nil
 }
 
@@ -197,6 +220,7 @@ func (m *Machine) Create(ctx context.Context, role string, version *string, moun
 				kindMounts(mounts),
 				nil,
 				m.labels,
+				m.ipFamily,
 			)
 			if err != nil {
 				return errors.WithStack(err)
@@ -210,6 +234,7 @@ func (m *Machine) Create(ctx context.Context, role string, version *string, moun
 				kindMounts(mounts),
 				nil,
 				m.labels,
+				m.ipFamily,
 			)
 			if err != nil {
 				return errors.WithStack(err)
diff --git a/test/infrastructure/docker/examples/simple-cluster-ipv6.yaml b/test/infrastructure/docker/examples/simple-cluster-ipv6.yaml
new file mode 100644
index 000000000..eb79ebc01
--- /dev/null
+++ b/test/infrastructure/docker/examples/simple-cluster-ipv6.yaml
@@ -0,0 +1,132 @@
+# Creates a cluster with one control-plane node and one worker node
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+kind: DockerCluster
+metadata:
+  name: my-cluster
+  namespace: default
+---
+apiVersion: cluster.x-k8s.io/v1alpha4
+kind: Cluster
+metadata:
+  name: my-cluster
+  namespace: default
+spec:
+  clusterNetwork:
+    services:
+      cidrBlocks: ["fd00:100:64::/108"]
+    pods:
+      cidrBlocks: ["fd00:100:96::/48"]
+    serviceDomain: "cluster.local"
+  controlPlaneRef:
+    apiVersion: controlplane.cluster.x-k8s.io/v1alpha4
+    kind: KubeadmControlPlane
+    name: controlplane
+    namespace: default
+  infrastructureRef:
+    apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+    kind: DockerCluster
+    name: my-cluster
+    namespace: default
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+kind: DockerMachineTemplate
+metadata:
+  name: controlplane
+  namespace: default
+spec:
+  template:
+    spec: {}
+---
+apiVersion: controlplane.cluster.x-k8s.io/v1alpha4
+kind: KubeadmControlPlane
+metadata:
+  name: controlplane
+  namespace: default
+spec:
+  replicas: 1
+  version: v1.19.1
+  infrastructureTemplate:
+    apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+    kind: DockerMachineTemplate
+    name: controlplane
+    namespace: default
+  kubeadmConfigSpec:
+    clusterConfiguration:
+      apiServer:
+        certSANs:
+        - localhost
+        - 127.0.0.1
+        - 0.0.0.0
+        - "::"
+        - "::1"
+      controllerManager:
+        extraArgs:
+          enable-hostpath-provisioner: "true"
+    initConfiguration:
+      localAPIEndpoint:
+        advertiseAddress: '::'
+        bindPort: 6443
+      nodeRegistration:
+        kubeletExtraArgs:
+          # We have to pin the cgroupDriver to cgroupfs as kubeadm >=1.21 defaults to systemd
+          # kind will implement systemd support in: https://github.com/kubernetes-sigs/kind/issues/1726
+          cgroup-driver: cgroupfs
+          eviction-hard: nodefs.available<0%,nodefs.inodesFree<0%,imagefs.available<0%
+          node-ip: "::"
+    joinConfiguration:
+      nodeRegistration:
+        kubeletExtraArgs:
+          node-ip: "::"
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+kind: DockerMachineTemplate
+metadata:
+  name: worker
+  namespace: default
+spec:
+  template:
+    spec: {}
+---
+apiVersion: bootstrap.cluster.x-k8s.io/v1alpha4
+kind: KubeadmConfigTemplate
+metadata:
+  name: worker
+spec:
+  template:
+    spec:
+      initConfiguration:
+        nodeRegistration:
+          kubeletExtraArgs:
+            node-ip: "::"
+      joinConfiguration:
+        nodeRegistration:
+          kubeletExtraArgs:
+            # We have to pin the cgroupDriver to cgroupfs as kubeadm >=1.21 defaults to systemd
+            # kind will implement systemd support in: https://github.com/kubernetes-sigs/kind/issues/1726
+            cgroup-driver: cgroupfs
+            eviction-hard: nodefs.available<0%,nodefs.inodesFree<0%,imagefs.available<0%
+            node-ip: "::"
+---
+apiVersion: cluster.x-k8s.io/v1alpha4
+kind: MachineDeployment
+metadata:
+  name: worker-md-0
+spec:
+  clusterName: my-cluster
+  replicas: 1
+  selector:
+    matchLabels:
+      cluster.x-k8s.io/cluster-name: my-cluster
+  template:
+    spec:
+      version: v1.19.1
+      clusterName: my-cluster
+      bootstrap:
+        configRef:
+          apiVersion: bootstrap.cluster.x-k8s.io/v1alpha4
+          kind: KubeadmConfigTemplate
+          name: worker
+      infrastructureRef:
+        apiVersion: infrastructure.cluster.x-k8s.io/v1alpha4
+        kind: DockerMachineTemplate
+        name: worker
diff --git a/test/infrastructure/docker/exp/docker/nodepool.go b/test/infrastructure/docker/exp/docker/nodepool.go
index 00059fd03..5d634cf0f 100644
--- a/test/infrastructure/docker/exp/docker/nodepool.go
+++ b/test/infrastructure/docker/exp/docker/nodepool.go
@@ -83,7 +83,7 @@ func (np *NodePool) ReconcileMachines(ctx context.Context) (ctrl.Result, error)
 	for _, machine := range np.machines {
 		totalNumberOfMachines++
 		if totalNumberOfMachines > desiredReplicas || !np.isMachineMatchingInfrastructureSpec(machine) {
-			externalMachine, err := docker.NewMachine(np.cluster.Name, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
+			externalMachine, err := docker.NewMachine(np.cluster, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
 			if err != nil {
 				return ctrl.Result{}, errors.Wrapf(err, "failed to create helper for managing the externalMachine named %s", machine.Name())
 			}
@@ -148,7 +148,7 @@ func (np *NodePool) ReconcileMachines(ctx context.Context) (ctrl.Result, error)
 // Delete will delete all of the machines in the node pool.
 func (np *NodePool) Delete(ctx context.Context) error {
 	for _, machine := range np.machines {
-		externalMachine, err := docker.NewMachine(np.cluster.Name, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
+		externalMachine, err := docker.NewMachine(np.cluster, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
 		if err != nil {
 			return errors.Wrapf(err, "failed to create helper for managing the externalMachine named %s", machine.Name())
 		}
@@ -180,7 +180,7 @@ func (np *NodePool) machinesMatchingInfrastructureSpec() []*docker.Machine {
 // addMachine will add a new machine to the node pool and update the docker machine pool status.
 func (np *NodePool) addMachine(ctx context.Context) error {
 	instanceName := fmt.Sprintf("worker-%s", util.RandomString(6))
-	externalMachine, err := docker.NewMachine(np.cluster.Name, instanceName, np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
+	externalMachine, err := docker.NewMachine(np.cluster, instanceName, np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
 	if err != nil {
 		return errors.Wrapf(err, "failed to create helper for managing the externalMachine named %s", instanceName)
 	}
@@ -194,7 +194,7 @@ func (np *NodePool) addMachine(ctx context.Context) error {
 // refresh asks docker to list all the machines matching the node pool label and updates the cached list of node pool
 // machines.
 func (np *NodePool) refresh() error {
-	machines, err := docker.ListMachinesByCluster(np.cluster.Name, np.labelFilters)
+	machines, err := docker.ListMachinesByCluster(np.cluster, np.labelFilters)
 	if err != nil {
 		return errors.Wrapf(err, "failed to list all machines in the cluster")
 	}
@@ -241,7 +241,7 @@ func (np *NodePool) reconcileMachine(ctx context.Context, machine *docker.Machin
 		}
 	}()
 
-	externalMachine, err := docker.NewMachine(np.cluster.Name, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
+	externalMachine, err := docker.NewMachine(np.cluster, machine.Name(), np.dockerMachinePool.Spec.Template.CustomImage, np.labelFilters)
 	if err != nil {
 		return ctrl.Result{}, errors.Wrapf(err, "failed to create helper for managing the externalMachine named %s", machine.Name())
 	}