Skip to content

Commit 80d5a5e

Browse files
authored
Register cross-account TargetGroupBinding IP targets with the pod's availability zone (opt-in) (#4877)
1 parent 219bd9f commit 80d5a5e

23 files changed

Lines changed: 1036 additions & 56 deletions

‎apis/elbv2/v1alpha1/targetgroupbinding_types.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ type TargetGroupBindingSpec struct {
136136
// IAM Role ARN to assume when calling AWS APIs. Needed to assume a role in another account and prevent the confused deputy problem. https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html
137137
// +optional
138138
AssumeRoleExternalId string `json:"assumeRoleExternalId,omitempty"`
139+
140+
// registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
141+
// availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
142+
// when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
143+
// availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
144+
// +optional
145+
RegisterTargetsWithPodAvailabilityZone *bool `json:"registerTargetsWithPodAvailabilityZone,omitempty"`
139146
}
140147

141148
// TargetGroupBindingStatus defines the observed state of TargetGroupBinding

‎apis/elbv2/v1alpha1/zz_generated.deepcopy.go‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apis/elbv2/v1beta1/targetgroupbinding_types.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,13 @@ type TargetGroupBindingSpec struct {
171171
// IAM Role ARN to assume when calling AWS APIs. Needed to assume a role in another account and prevent the confused deputy problem. https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html
172172
// +optional
173173
AssumeRoleExternalId string `json:"assumeRoleExternalId,omitempty"`
174+
175+
// registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
176+
// availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
177+
// when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
178+
// availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
179+
// +optional
180+
RegisterTargetsWithPodAvailabilityZone *bool `json:"registerTargetsWithPodAvailabilityZone,omitempty"`
174181
}
175182

176183
// TargetGroupBindingStatus defines the observed state of TargetGroupBinding

‎apis/elbv2/v1beta1/zz_generated.deepcopy.go‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config/crd/bases/elbv2.k8s.aws_targetgroupbindings.yaml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ spec:
153153
type: object
154154
type: array
155155
type: object
156+
registerTargetsWithPodAvailabilityZone:
157+
description: |-
158+
registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
159+
availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
160+
when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
161+
availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
162+
type: boolean
156163
serviceRef:
157164
description: serviceRef is a reference to a Kubernetes Service and
158165
ServicePort.
@@ -397,6 +404,13 @@ spec:
397404
type: object
398405
type: object
399406
x-kubernetes-map-type: atomic
407+
registerTargetsWithPodAvailabilityZone:
408+
description: |-
409+
registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
410+
availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
411+
when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
412+
availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
413+
type: boolean
400414
serviceRef:
401415
description: serviceRef is a reference to a Kubernetes Service and
402416
ServicePort.

‎docs/guide/targetgroupbinding/targetgroupbinding.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ Use this feature when you need to manage TargetGroups in a different AWS account
143143
**Spec fields:**
144144
* `iamRoleArnToAssume`: The ARN of the role in the TGO account that the controller will assume
145145
* `assumeRoleExternalId`: External ID for the assume role operation (optional but recommended to prevent the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html))
146+
* `registerTargetsWithPodAvailabilityZone`: Register cross-account IP targets with the availability zone of the pod's node instead of `all`. See [Availability zones for cross-account targets](#availability-zones-for-cross-account-targets). Requires `iamRoleArnToAssume`.
146147

147148

148149
### Sample YAML
@@ -244,6 +245,47 @@ Add the following permission to the AWS Load Balancer Controller's IAM role. Thi
244245
}
245246
```
246247

248+
### Availability zones for cross-account targets
249+
250+
By default, cross-account IP targets are registered with `AvailabilityZone: all`. Every zonal node of
251+
the load balancer can then forward to every target, so turning off cross-zone load balancing has no
252+
effect and inter-AZ data transfer charges are unavoidable.
253+
254+
Set `registerTargetsWithPodAvailabilityZone: true` to register each target with the availability zone
255+
of the node its pod runs on. With cross-zone load balancing disabled, a zonal load balancer node then
256+
only forwards to targets in its own zone.
257+
258+
Availability zone *names* are randomized per AWS account — `us-west-2a` in the CO account is usually a
259+
different physical zone than `us-west-2a` in the TGO account. Availability zone *IDs* (for example
260+
`usw2-az1`) are stable, so the controller resolves the pod's zone name to a zone ID in the CO account
261+
and then back to the matching zone name in the TGO account. Both lookups use
262+
`ec2:DescribeAvailabilityZones`, so the TGO role from Step 2 needs it:
263+
264+
```json
265+
{
266+
"Effect": "Allow",
267+
"Action": [
268+
"ec2:DescribeAvailabilityZones"
269+
],
270+
"Resource": "*"
271+
}
272+
```
273+
274+
!!!note ""
275+
The zone must be one the load balancer has a subnet in. If a cluster runs nodes in a zone the load
276+
balancer does not use, registration is rejected and the TargetGroupBinding reports an error.
277+
278+
!!!note ""
279+
If the zone cannot be translated — for instance when the TGO role is missing
280+
`ec2:DescribeAvailabilityZones` — the controller logs the failure and falls back to
281+
`AvailabilityZone: all` rather than failing registration.
282+
283+
!!!note ""
284+
The setting only affects targets registered after it is set. Targets that are already registered
285+
keep their current availability zone (`all` or a zone name) until they are deregistered and
286+
registered again, for example when the pod is replaced. This avoids disrupting live traffic when
287+
the setting is toggled on an existing TargetGroupBinding.
288+
247289

248290
## MultiCluster TargetGroup
249291
TargetGroupBinding CR supports sharing the same TargetGroup ARN among multiple TargetGroupBindings. Setting this flag allows

‎helm/aws-load-balancer-controller/crds/crds.yaml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,13 @@ spec:
10411041
type: object
10421042
type: array
10431043
type: object
1044+
registerTargetsWithPodAvailabilityZone:
1045+
description: |-
1046+
registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
1047+
availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
1048+
when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
1049+
availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
1050+
type: boolean
10441051
serviceRef:
10451052
description: serviceRef is a reference to a Kubernetes Service and
10461053
ServicePort.
@@ -1285,6 +1292,13 @@ spec:
12851292
type: object
12861293
type: object
12871294
x-kubernetes-map-type: atomic
1295+
registerTargetsWithPodAvailabilityZone:
1296+
description: |-
1297+
registerTargetsWithPodAvailabilityZone specifies whether cross-account targets are registered with the
1298+
availability zone of the pod's node instead of "all". Requires iamRoleArnToAssume. Enables in-AZ routing
1299+
when cross-zone load balancing is disabled. Availability zone names are translated across accounts using
1300+
availability zone IDs, so the assumed role requires ec2:DescribeAvailabilityZones.
1301+
type: boolean
12881302
serviceRef:
12891303
description: serviceRef is a reference to a Kubernetes Service and
12901304
ServicePort.

‎main.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ func main() {
206206

207207
tgArnMapper := shared_utils.NewTargetGroupNameToArnMapper(cloud.ELBV2())
208208

209+
azIDTranslator := networking.NewDefaultAZIDTranslator(cloud.EC2(), ctrl.Log.WithName("az-id-translator"))
209210
tgbResManager := targetgroupbinding.NewDefaultResourceManager(mgr.GetClient(), cloud.ELBV2(),
210-
podInfoRepo, networkingManager, vpcInfoProvider, multiClusterManager, lbcMetricsCollector,
211+
podInfoRepo, networkingManager, vpcInfoProvider, azIDTranslator, multiClusterManager, lbcMetricsCollector,
211212
cloud.VpcID(), controllerCFG.FeatureGates.Enabled(config.EndpointsFailOpen), controllerCFG.EnableEndpointSlices,
212213
mgr.GetEventRecorderFor("targetGroupBinding"), ctrl.Log, controllerCFG.MaxTargetsPerTargetGroup, controllerCFG.TargetGroupBindingRequeueDuration)
213214
backendSGProvider := networking.NewBackendSGProvider(controllerCFG.ClusterName, controllerCFG.BackendSecurityGroup,

‎pkg/aws/cloud.go‎

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ func NewCloud(cfg CloudConfig, clusterName string, metricsCollector *aws_metrics
8989
return nil, errors.Wrap(err, "failed to create aws clients provider")
9090
}
9191
}
92-
ec2Service := services.NewEC2(awsClientsProvider)
92+
// Constructed without a Cloud back-reference, which does not exist yet. Only used for the VPC ID
93+
// lookup below, which never assumes a role. thisObj.ec2 below is the instance the controller uses.
94+
ec2Service := services.NewEC2(awsClientsProvider, nil)
9395

9496
vpcID, err := getVpcID(cfg, ec2Service, ec2Metadata, logger)
9597
if err != nil {
@@ -101,7 +103,6 @@ func NewCloud(cfg CloudConfig, clusterName string, metricsCollector *aws_metrics
101103
thisObj := &defaultCloud{
102104
cfg: cfg,
103105
clusterName: clusterName,
104-
ec2: ec2Service,
105106
route53: services.NewRoute53(awsClientsProvider),
106107
acm: services.NewACM(awsClientsProvider),
107108
wafv2: services.NewWAFv2(awsClientsProvider),
@@ -113,12 +114,14 @@ func NewCloud(cfg CloudConfig, clusterName string, metricsCollector *aws_metrics
113114
awsConfigGenerator: awsConfigGenerator,
114115

115116
assumeRoleElbV2Cache: cache.NewExpiring(),
117+
assumeRoleEc2Cache: cache.NewExpiring(),
116118

117119
awsClientsProvider: awsClientsProvider,
118120
logger: logger,
119121
}
120122

121123
thisObj.elbv2 = services.NewELBV2(awsClientsProvider, thisObj, lbStabilizationTime)
124+
thisObj.ec2 = services.NewEC2(awsClientsProvider, thisObj)
122125

123126
return thisObj, nil
124127
}
@@ -224,6 +227,11 @@ type defaultCloud struct {
224227
// assumeRoleElbV2CacheMutex protects assumeRoleElbV2Cache
225228
assumeRoleElbV2CacheMutex sync.RWMutex
226229

230+
// A cache holding ec2 clients that are assuming a role.
231+
assumeRoleEc2Cache *cache.Expiring
232+
// assumeRoleEc2CacheMutex protects assumeRoleEc2Cache
233+
assumeRoleEc2CacheMutex sync.RWMutex
234+
227235
awsClientsProvider provider.AWSClientsProvider
228236
logger logr.Logger
229237
}
@@ -255,10 +263,56 @@ func (c *defaultCloud) GetAssumedRoleELBV2(ctx context.Context, assumeRoleArn st
255263
}
256264
c.logger.Info("Constructing new elbv2 client", "AssumeRoleArn", assumeRoleArn, "externalId", externalId)
257265

266+
newAwsConfig, cacheTTL, err := c.generateAssumedRoleConfig(ctx, assumeRoleArn, externalId)
267+
if err != nil {
268+
return nil, err
269+
}
270+
271+
elbv2WithAssumedRole := services.NewELBV2FromStaticClient(c.awsClientsProvider.GenerateNewELBv2Client(newAwsConfig), c, DefaultLbStabilizationTime)
272+
273+
c.assumeRoleElbV2CacheMutex.Lock()
274+
defer c.assumeRoleElbV2CacheMutex.Unlock()
275+
c.assumeRoleElbV2Cache.Set(cacheKey, elbv2WithAssumedRole, cacheTTL-cacheTTLBufferTime)
276+
return elbv2WithAssumedRole, nil
277+
}
278+
279+
// GetAssumedRoleEC2 returns EC2 client for the given assumeRoleArn, or the default EC2 client if assumeRoleArn is empty
280+
func (c *defaultCloud) GetAssumedRoleEC2(ctx context.Context, assumeRoleArn string, externalId string) (services.EC2, error) {
281+
if assumeRoleArn == "" {
282+
return c.ec2, nil
283+
}
284+
285+
cacheKey := assumedRoleCacheKey{roleArn: assumeRoleArn, externalId: externalId}
286+
287+
c.assumeRoleEc2CacheMutex.RLock()
288+
assumedRoleEC2, exists := c.assumeRoleEc2Cache.Get(cacheKey)
289+
c.assumeRoleEc2CacheMutex.RUnlock()
290+
291+
if exists {
292+
return assumedRoleEC2.(services.EC2), nil
293+
}
294+
c.logger.Info("Constructing new ec2 client", "AssumeRoleArn", assumeRoleArn, "externalId", externalId)
295+
296+
newAwsConfig, cacheTTL, err := c.generateAssumedRoleConfig(ctx, assumeRoleArn, externalId)
297+
if err != nil {
298+
return nil, err
299+
}
300+
301+
ec2WithAssumedRole := services.NewEC2FromStaticClient(c.awsClientsProvider.GenerateNewEC2Client(newAwsConfig), c)
302+
303+
c.assumeRoleEc2CacheMutex.Lock()
304+
defer c.assumeRoleEc2CacheMutex.Unlock()
305+
c.assumeRoleEc2Cache.Set(cacheKey, ec2WithAssumedRole, cacheTTL-cacheTTLBufferTime)
306+
return ec2WithAssumedRole, nil
307+
}
308+
309+
// generateAssumedRoleConfig assumes the given role and returns an AWS config using the resulting
310+
// credentials, along with the remaining lifetime of those credentials.
311+
func (c *defaultCloud) generateAssumedRoleConfig(ctx context.Context, assumeRoleArn string, externalId string) (aws.Config, time.Duration, error) {
258312
stsClient, err := c.awsClientsProvider.GetSTSClient(ctx, "AssumeRole")
259313
if err != nil {
260314
// This should never happen, but let's be forward-looking.
261-
return nil, err
315+
return aws.Config{}, 0, err
262316
}
263317

264318
assumeRoleInput := &sts.AssumeRoleInput{
@@ -271,23 +325,17 @@ func (c *defaultCloud) GetAssumedRoleELBV2(ctx context.Context, assumeRoleArn st
271325
response, err := stsClient.AssumeRole(ctx, assumeRoleInput)
272326
if err != nil {
273327
c.logger.Error(err, "Unable to assume target role", "roleArn", assumeRoleArn)
274-
return nil, err
328+
return aws.Config{}, 0, err
275329
}
276330
assumedRoleCreds := response.Credentials
277331
newCreds := credentials.NewStaticCredentialsProvider(*assumedRoleCreds.AccessKeyId, *assumedRoleCreds.SecretAccessKey, *assumedRoleCreds.SessionToken)
278332
newAwsConfig, err := c.awsConfigGenerator.GenerateAWSConfig(config.WithCredentialsProvider(newCreds))
279333
if err != nil {
280334
c.logger.Error(err, "Create new service client config service client config", "roleArn", assumeRoleArn)
281-
return nil, err
335+
return aws.Config{}, 0, err
282336
}
283337

284-
cacheTTL := assumedRoleCreds.Expiration.Sub(time.Now())
285-
elbv2WithAssumedRole := services.NewELBV2FromStaticClient(c.awsClientsProvider.GenerateNewELBv2Client(newAwsConfig), c, DefaultLbStabilizationTime)
286-
287-
c.assumeRoleElbV2CacheMutex.Lock()
288-
defer c.assumeRoleElbV2CacheMutex.Unlock()
289-
c.assumeRoleElbV2Cache.Set(cacheKey, elbv2WithAssumedRole, cacheTTL-cacheTTLBufferTime)
290-
return elbv2WithAssumedRole, nil
338+
return newAwsConfig, assumedRoleCreds.Expiration.Sub(time.Now()), nil
291339
}
292340

293341
func (c *defaultCloud) EC2() services.EC2 {

0 commit comments

Comments
 (0)