-
Notifications
You must be signed in to change notification settings - Fork 369
WIP: Add support for dual stack load balancers #1313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
69b6bb9
eb56af1
fa180ec
aa4d51b
573c4ca
5d22fe2
1dc0c03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,4 +85,66 @@ metadata: | |
| service.beta.kubernetes.io/aws-load-balancer-internal: true | ||
| service.beta.kubernetes.io/aws-load-balancer-target-group-attributes: preserve_client_ip.enabled=false,proxy_protocol_v2.enabled=true | ||
| [...] | ||
| ``` | ||
| ``` | ||
|
|
||
| ## Dual stack services with IPv6 | ||
|
|
||
| Services can be created using solely IPv4 networking (the default), or with dual stack support per the [Kubernetes Service specification](https://kubernetes.io/docs/concepts/services-networking/dual-stack/). | ||
| The service must be created with a Network Load Balancer, and the Kubernetes control plane must be configured to support IPv6 CIDRs. | ||
|
|
||
| Note: When using the [AWS Load Balancer Controller](https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/), Services will default to having the `spec.loadBalancerClass` field populated via a MutatingWebhookConfiguration. | ||
| This webhook must be disabled to allow the cloud controller manager to handle services. | ||
|
|
||
| Some limitations to be aware of when using dual stack load balancers: | ||
|
|
||
| - The `spec.ipFamilies` field can have a second family added or removed, but the first entry is immutable after Service creation. | ||
| - Load balanced targets are registered based on the instances, not their IP addresses. | ||
| - A Service cannot be IPv6 only; it must either be IPv4 or dual stack, even if IPv6 is the only IP family specified. | ||
|
|
||
| ### Usage Example 1 - creating a dual stack load balancer, requiring both stacks | ||
|
|
||
| ```yaml | ||
| apiVersion: v1 | ||
| kind: Service | ||
| metadata: | ||
| name: $SVC_NAME | ||
| namespace: ${APP_NAMESPACE} | ||
| annotations: | ||
| service.beta.kubernetes.io/aws-load-balancer-type: nlb | ||
| spec: | ||
| type: LoadBalancer | ||
| ipFamilies: | ||
| - IPv6 | ||
| - IPv4 | ||
|
Comment on lines
+117
to
+118
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: The list indentation is a lot more than usual. Let's format it a bit 😁 |
||
| ipFamilyPolicy: RequireDualStack # Require both stacks are present on the service. | ||
| selector: | ||
| app: myapp | ||
| ports: | ||
| - port: 80 | ||
| targetPort: 8080 | ||
| protocol: TCP | ||
| ``` | ||
|
|
||
| ### Usage Example 2 - creating a dual stack load balancer, falling back to IPv4 | ||
|
|
||
| ```yaml | ||
| apiVersion: v1 | ||
| kind: Service | ||
| metadata: | ||
| name: $SVC_NAME | ||
| namespace: ${APP_NAMESPACE} | ||
| annotations: | ||
| service.beta.kubernetes.io/aws-load-balancer-type: nlb | ||
| spec: | ||
| type: LoadBalancer | ||
| ipFamilies: | ||
| - IPv4 | ||
| - IPv6 | ||
| ipFamilyPolicy: PreferDualStack # If dual stack is not configured or present, fall back to IPv4. | ||
| selector: | ||
| app: myapp | ||
| ports: | ||
| - port: 80 | ||
| targetPort: 8080 | ||
| protocol: TCP | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -358,6 +358,7 @@ type ELBV2 interface { | |
| DescribeLoadBalancers(ctx context.Context, input *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) | ||
| DeleteLoadBalancer(ctx context.Context, input *elbv2.DeleteLoadBalancerInput, optFns ...func(*elbv2.Options)) (*elbv2.DeleteLoadBalancerOutput, error) | ||
| SetSecurityGroups(ctx context.Context, input *elbv2.SetSecurityGroupsInput, optFns ...func(*elbv2.Options)) (*elbv2.SetSecurityGroupsOutput, error) | ||
| SetIpAddressType(ctx context.Context, input *elbv2.SetIpAddressTypeInput, optFns ...func(*elbv2.Options)) (*elbv2.SetIpAddressTypeOutput, error) | ||
|
|
||
| ModifyLoadBalancerAttributes(ctx context.Context, input *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) | ||
| DescribeLoadBalancerAttributes(ctx context.Context, input *elbv2.DescribeLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancerAttributesOutput, error) | ||
|
|
@@ -2001,21 +2002,32 @@ func (c *Cloud) buildELBSecurityGroupList(ctx context.Context, serviceName types | |
| // - sgID: The ID of the security group to configure. | ||
| // - rules: An existing permission set of rules to be added to the security group. | ||
| // - ec2SourceRanges: A slice of *ec2.IpRange objects specifying the source IP ranges for the rules. | ||
| // - ec2Ipv6SourceRanges: A slice of *ec2.Ipv6Range objects specifying the source IPv6 ranges for the rules. | ||
| // | ||
| // Returns: | ||
| // - error: An error if any issue occurs while creating or applying the security group rules. | ||
| func (c *Cloud) createSecurityGroupRules(ctx context.Context, sgID string, rules IPPermissionSet, ec2SourceRanges []ec2types.IpRange) error { | ||
| func (c *Cloud) createSecurityGroupRules(ctx context.Context, sgID string, rules IPPermissionSet, ec2SourceRanges []ec2types.IpRange, ec2Ipv6SourceRanges []ec2types.Ipv6Range) error { | ||
| if len(sgID) == 0 { | ||
| return fmt.Errorf("security group ID cannot be empty") | ||
| } | ||
| // Allow ICMP fragmentation packets, important for MTU discovery | ||
| permission := ec2types.IpPermission{ | ||
| IpProtocol: aws.String("icmp"), | ||
| FromPort: aws.Int32(3), | ||
| ToPort: aws.Int32(4), | ||
| IpRanges: ec2SourceRanges, | ||
| // Allow ICMP fragmentation packets, important for IPv4 MTU discovery | ||
| if len(ec2SourceRanges) > 0 { | ||
| rules.Insert(ec2types.IpPermission{ | ||
| IpProtocol: aws.String("icmp"), | ||
| FromPort: aws.Int32(3), | ||
| ToPort: aws.Int32(4), | ||
| IpRanges: ec2SourceRanges, | ||
| }) | ||
| } | ||
| // Allow ICMPv6 "Packet Too Big" messages, important for IPv6 MTU discovery | ||
| if len(ec2Ipv6SourceRanges) > 0 { | ||
| rules.Insert(ec2types.IpPermission{ | ||
| IpProtocol: aws.String("icmpv6"), | ||
| FromPort: aws.Int32(2), | ||
| ToPort: aws.Int32(-1), | ||
| Ipv6Ranges: ec2Ipv6SourceRanges, | ||
| }) | ||
| } | ||
| rules.Insert(permission) | ||
|
|
||
| // Setup ingress rules | ||
| if _, err := c.setSecurityGroupIngress(ctx, sgID, rules); err != nil { | ||
|
|
@@ -2116,7 +2128,16 @@ func (c *Cloud) getSubnetCidrs(ctx context.Context, subnetIDs []string) ([]strin | |
|
|
||
| cidrs := make([]string, 0, len(subnets)) | ||
| for _, subnet := range subnets { | ||
| // Add IPv4 CIDR | ||
| cidrs = append(cidrs, aws.ToString(subnet.CidrBlock)) | ||
|
|
||
| // Add IPv6 CIDRs if present | ||
| for _, ipv6Association := range subnet.Ipv6CidrBlockAssociationSet { | ||
| if ipv6Association.Ipv6CidrBlockState != nil && | ||
| ipv6Association.Ipv6CidrBlockState.State == ec2types.SubnetCidrBlockStateCodeAssociated { | ||
| cidrs = append(cidrs, aws.ToString(ipv6Association.Ipv6CidrBlock)) | ||
| } | ||
| } | ||
| } | ||
| return cidrs, nil | ||
| } | ||
|
|
@@ -2278,34 +2299,74 @@ func (c *Cloud) ensureNLBSecurityGroup(ctx context.Context, loadBalancerName, cl | |
| return []string{securityGroupID}, nil | ||
| } | ||
|
|
||
| // separateIPv4AndIPv6CIDRs separates a list of CIDR strings into IPv4 and IPv6 ranges | ||
| // Returns EC2 IpRange and Ipv6Range slices for use in security group rules | ||
| func separateIPv4AndIPv6CIDRs(cidrs []string) ([]ec2types.IpRange, []ec2types.Ipv6Range) { | ||
| var ipv4Ranges []ec2types.IpRange | ||
| var ipv6Ranges []ec2types.Ipv6Range | ||
|
|
||
| for _, cidr := range cidrs { | ||
| _, ipNet, err := net.ParseCIDR(cidr) | ||
| if err != nil { | ||
| klog.Warningf("Failed to parse CIDR %q: %v", cidr, err) | ||
| continue | ||
| } | ||
|
|
||
| // Check if this is an IPv4 or IPv6 CIDR | ||
| if ipNet.IP.To4() != nil { | ||
| // IPv4 | ||
| ipv4Ranges = append(ipv4Ranges, ec2types.IpRange{CidrIp: aws.String(cidr)}) | ||
| } else { | ||
| // IPv6 | ||
| ipv6Ranges = append(ipv6Ranges, ec2types.Ipv6Range{CidrIpv6: aws.String(cidr)}) | ||
| } | ||
| } | ||
|
|
||
| return ipv4Ranges, ipv6Ranges | ||
| } | ||
|
|
||
| // ensureNLBSecurityGroupRules ensures the NLB frontend security group rules are created and configured | ||
| // for the specified security groups based on the load balancer port mappings (Load Balancer listeners), | ||
| // allowing traffic from the specified source ranges. | ||
| // | ||
| // Parameters: | ||
| // - ctx: The context for the request. | ||
| // - securityGroups: The security group IDs to configure rules for (only first SG is used). | ||
| // - ec2SourceRanges: The CIDR ranges allowed to access the load balancer. | ||
| // - sourceCIDRs: The CIDR ranges (IPv4 and/or IPv6) allowed to access the load balancer. | ||
| // - v2Mappings: The NLB port mappings defining frontend ports and protocols. | ||
| // | ||
| // Returns: | ||
| // - error: An error if any issue occurs while ensuring the NLB security group rules. | ||
| func (c *Cloud) ensureNLBSecurityGroupRules(ctx context.Context, securityGroups []string, ec2SourceRanges []ec2types.IpRange, v2Mappings []nlbPortMapping) error { | ||
| func (c *Cloud) ensureNLBSecurityGroupRules(ctx context.Context, securityGroups []string, sourceCIDRs []string, v2Mappings []nlbPortMapping) error { | ||
| if len(securityGroups) == 0 { | ||
| return nil | ||
| } | ||
| securityGroupID := securityGroups[0] | ||
|
|
||
| // Separate source CIDRs into IPv4 and IPv6 ranges | ||
| ec2SourceRanges, ec2Ipv6SourceRanges := separateIPv4AndIPv6CIDRs(sourceCIDRs) | ||
|
|
||
| ingressRules := NewIPPermissionSet() | ||
| for _, mapping := range v2Mappings { | ||
| ingressRules.Insert(ec2types.IpPermission{ | ||
| permission := ec2types.IpPermission{ | ||
| FromPort: aws.Int32(int32(mapping.FrontendPort)), | ||
| ToPort: aws.Int32(int32(mapping.FrontendPort)), | ||
| IpProtocol: aws.String(strings.ToLower(string((mapping.FrontendProtocol)))), | ||
| IpRanges: ec2SourceRanges, | ||
| }) | ||
| } | ||
|
|
||
| // Add IPv4 ranges if present | ||
| if len(ec2SourceRanges) > 0 { | ||
| permission.IpRanges = ec2SourceRanges | ||
| } | ||
|
|
||
| // Add IPv6 ranges if present | ||
| if len(ec2Ipv6SourceRanges) > 0 { | ||
| permission.Ipv6Ranges = ec2Ipv6SourceRanges | ||
| } | ||
|
|
||
| ingressRules.Insert(permission) | ||
| } | ||
| if err := c.createSecurityGroupRules(ctx, securityGroupID, ingressRules, ec2SourceRanges); err != nil { | ||
| if err := c.createSecurityGroupRules(ctx, securityGroupID, ingressRules, ec2SourceRanges, ec2Ipv6SourceRanges); err != nil { | ||
| return fmt.Errorf("error while updating rules to security group %q: %w", securityGroupID, err) | ||
| } | ||
| return nil | ||
|
|
@@ -2328,6 +2389,10 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| return nil, err | ||
| } | ||
|
|
||
| if !isNLB(annotations) && !canFallbackToIPv4(apiService) { | ||
| return nil, fmt.Errorf("classic load balancer for service %s does not support IPv6", apiService.Name) | ||
| } | ||
|
|
||
| if apiService.Spec.SessionAffinity != v1.ServiceAffinityNone { | ||
| // ELB supports sticky sessions, but only when configured for HTTP/HTTPS | ||
| return nil, fmt.Errorf("unsupported load balancer affinity: %v", apiService.Spec.SessionAffinity) | ||
|
|
@@ -2348,9 +2413,18 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| if err != nil { | ||
| return nil, err | ||
| } | ||
| ec2SourceRanges := []ec2types.IpRange{} | ||
| for _, srcRange := range sourceRanges.StringSlice() { | ||
| ec2SourceRanges = append(ec2SourceRanges, ec2types.IpRange{CidrIp: aws.String(srcRange)}) | ||
|
|
||
| sourceCIDRs := sourceRanges.StringSlice() | ||
|
|
||
| // If no source ranges specified, add defaults based on service IP families | ||
| // This should be populated by GetLoadBalancerSourceRanges most of the time. | ||
| if len(sourceCIDRs) == 0 { | ||
| sourceCIDRs = append(sourceCIDRs, "0.0.0.0/0") | ||
| } | ||
|
|
||
| // Add IPv6 default range if service supports IPv6. | ||
| if serviceRequestsIPv6(apiService) && !contains(sourceCIDRs, "::/0") { | ||
| sourceCIDRs = append(sourceCIDRs, "::/0") | ||
| } | ||
|
|
||
| sslPorts := getPortSets(annotations[ServiceAnnotationLoadBalancerSSLPorts]) | ||
|
|
@@ -2452,13 +2526,14 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| internalELB, | ||
| annotations, | ||
| securityGroups, | ||
| apiService, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if we are passing the entire service then we can skip passing other params like service name, annotation right? |
||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Ensure SG rules only if the LB reconciliator finished successfully. | ||
| if err := c.ensureNLBSecurityGroupRules(ctx, securityGroups, ec2SourceRanges, v2Mappings); err != nil { | ||
| if err := c.ensureNLBSecurityGroupRules(ctx, securityGroups, sourceCIDRs, v2Mappings); err != nil { | ||
| return nil, fmt.Errorf("error ensuring NLB security group rules: %w", err) | ||
| } | ||
|
|
||
|
|
@@ -2483,6 +2558,10 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| } | ||
| if len(sourceRangeCidrs) == 0 { | ||
| sourceRangeCidrs = append(sourceRangeCidrs, "0.0.0.0/0") | ||
| // Add IPv6 default range if service supports IPv6 | ||
| } | ||
| if serviceRequestsIPv6(apiService) && !contains(sourceRangeCidrs, "::/0") { | ||
| sourceRangeCidrs = append(sourceRangeCidrs, "::/0") | ||
| } | ||
|
|
||
| err = c.updateInstanceSecurityGroupsForNLB(ctx, loadBalancerName, instances, subnetCidrs, sourceRangeCidrs, v2Mappings) | ||
|
|
@@ -2629,6 +2708,9 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| } | ||
|
|
||
| if setupSg { | ||
| // Separate source CIDRs into IPv4 and IPv6 ranges for classic ELB | ||
| ec2SourceRanges, ec2Ipv6SourceRanges := separateIPv4AndIPv6CIDRs(sourceCIDRs) | ||
|
|
||
| permissions := NewIPPermissionSet() | ||
| for _, port := range apiService.Spec.Ports { | ||
| protocol := strings.ToLower(string(port.Protocol)) | ||
|
|
@@ -2642,7 +2724,7 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS | |
| permissions.Insert(permission) | ||
| } | ||
|
|
||
| if err = c.createSecurityGroupRules(ctx, securityGroupIDs[0], permissions, ec2SourceRanges); err != nil { | ||
| if err = c.createSecurityGroupRules(ctx, securityGroupIDs[0], permissions, ec2SourceRanges, ec2Ipv6SourceRanges); err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe, we should mention what would happen if the secondary family is removed? Like what will be the state of: