Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion docs/service_controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Copy Markdown

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:

  • The Load Balancer
  • The Security Groups (those for LB and those for instances)
  • Target Group
  • (Optional) Listeners

- 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
```
120 changes: 101 additions & 19 deletions pkg/providers/v1/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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])
Expand Down Expand Up @@ -2452,13 +2526,14 @@ func (c *Cloud) EnsureLoadBalancer(ctx context.Context, clusterName string, apiS
internalELB,
annotations,
securityGroups,
apiService,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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
}
}
Expand Down
10 changes: 9 additions & 1 deletion pkg/providers/v1/aws_fakes.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
elb "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing"
elbtypes "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types"
elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types"
"github.com/aws/aws-sdk-go-v2/service/kms"
"k8s.io/klog/v2"

Expand Down Expand Up @@ -681,7 +682,8 @@ func (e *FakeELB) ModifyLoadBalancerAttributes(ctx context.Context, input *elb.M

// FakeELBV2 is a fake ELBV2 client used for testing
type FakeELBV2 struct {
aws *FakeAWSServices
aws *FakeAWSServices
IpAddressType elbv2types.IpAddressType
}

// AddTags is not implemented but is required for interface conformance
Expand Down Expand Up @@ -715,6 +717,12 @@ func (elb *FakeELBV2) SetSecurityGroups(ctx context.Context, input *elbv2.SetSec
panic("Not implemented")
}

// SetIpAddressType stores the given IpAddressType for later inspection and returns success.
func (elb *FakeELBV2) SetIpAddressType(ctx context.Context, input *elbv2.SetIpAddressTypeInput, optFns ...func(*elbv2.Options)) (*elbv2.SetIpAddressTypeOutput, error) {
elb.IpAddressType = input.IpAddressType
return &elbv2.SetIpAddressTypeOutput{IpAddressType: input.IpAddressType}, nil
}

// ModifyLoadBalancerAttributes is not implemented but is required for interface conformance
func (elb *FakeELBV2) ModifyLoadBalancerAttributes(ctx context.Context, input *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) {
panic("Not implemented")
Expand Down
Loading
Loading