diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md index ac397ba62bd94..89baa6791ff2c 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md @@ -243,6 +243,33 @@ const group = listener.addTargets('AppFleet', { group.addTarget(asg2); ``` +### Sticky sessions for your Application Load Balancer + +By default, an Application Load Balancer routes each request independently to a registered target based on the chosen load-balancing algorithm. However, you can use the sticky session feature (also known as session affinity) to enable the load balancer to bind a user's session to a specific target. This ensures that all requests from the user during the session are sent to the same target. This feature is useful for servers that maintain state information in order to provide a continuous experience to clients. To use sticky sessions, the client must support cookies. + +Application Load Balancers support both duration-based cookies (`lb_cookie`) and application-based cookies (`app_cookie`). The key to managing sticky sessions is determining how long your load balancer should consistently route the user's request to the same target. Sticky sessions are enabled at the target group level. You can use a combination of duration-based stickiness, application-based stickiness, and no stickiness across all of your target groups. + +```ts +// Target group with duration-based stickiness with load-balancer generated cookie +const tg1 = new elbv2.ApplicationTargetGroup(stack, 'TG1', { + targetType: elbv2.TargetType.INSTANCE, + port: 80, + stickinessCookieDuration: cdk.Duration.minutes(5), + vpc, +}); + +// Target group with application-based stickiness +const tg2 = new elbv2.ApplicationTargetGroup(stack, 'TG2', { + targetType: elbv2.TargetType.INSTANCE, + port: 80, + stickinessCookieDuration: cdk.Duration.minutes(5), + stickinessCookieName: 'MyDeliciousCookie', + vpc, +}); +``` + +For more information see: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html#application-based-stickiness + ## Using Lambda Targets To use a Lambda Function as a target, use the integration class in the diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts index 1cd8a91c932aa..1844314e1f560 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts @@ -363,6 +363,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis protocol: props.protocol, slowStart: props.slowStart, stickinessCookieDuration: props.stickinessCookieDuration, + stickinessCookieName: props.stickinessCookieName, targetGroupName: props.targetGroupName, targets: props.targets, vpc: this.loadBalancer.vpc, @@ -813,6 +814,20 @@ export interface AddApplicationTargetsProps extends AddRuleProps { */ readonly stickinessCookieDuration?: Duration; + /** + * The name of an application-based stickiness cookie. + * + * Names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP, + * and AWSALBTG; they're reserved for use by the load balancer. + * + * Note: `stickinessCookieName` parameter depends on the presence of `stickinessCookieDuration` parameter. + * If `stickinessCookieDuration` is not set, `stickinessCookieName` will be omitted. + * + * @default - If `stickinessCookieDuration` is set, a load-balancer generated cookie is used. Otherwise, no stickiness is defined. + * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html + */ + readonly stickinessCookieName?: string; + /** * The targets to add to this target group. * diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts index a1d3de25bf82d..4196f370cc173 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts @@ -1,6 +1,6 @@ import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; import * as ec2 from '@aws-cdk/aws-ec2'; -import { Annotations, Duration } from '@aws-cdk/core'; +import { Annotations, Duration, Token } from '@aws-cdk/core'; import { IConstruct, Construct } from 'constructs'; import { ApplicationELBMetrics } from '../elasticloadbalancingv2-canned-metrics.generated'; import { @@ -57,6 +57,20 @@ export interface ApplicationTargetGroupProps extends BaseTargetGroupProps { */ readonly stickinessCookieDuration?: Duration; + /** + * The name of an application-based stickiness cookie. + * + * Names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP, + * and AWSALBTG; they're reserved for use by the load balancer. + * + * Note: `stickinessCookieName` parameter depends on the presence of `stickinessCookieDuration` parameter. + * If `stickinessCookieDuration` is not set, `stickinessCookieName` will be omitted. + * + * @default - If `stickinessCookieDuration` is set, a load-balancer generated cookie is used. Otherwise, no stickiness is defined. + * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html + */ + readonly stickinessCookieName?: string; + /** * The targets to add to this target group. * @@ -111,8 +125,8 @@ export class ApplicationTargetGroup extends TargetGroupBase implements IApplicat if (props.slowStart !== undefined) { this.setAttribute('slow_start.duration_seconds', props.slowStart.toSeconds().toString()); } - if (props.stickinessCookieDuration !== undefined) { - this.enableCookieStickiness(props.stickinessCookieDuration); + if (props.stickinessCookieDuration) { + this.enableCookieStickiness(props.stickinessCookieDuration, props.stickinessCookieName); } this.addTarget(...(props.targets || [])); } @@ -129,12 +143,31 @@ export class ApplicationTargetGroup extends TargetGroupBase implements IApplicat } /** - * Enable sticky routing via a cookie to members of this target group + * Enable sticky routing via a cookie to members of this target group. + * + * Note: If the `cookieName` parameter is set, application-based stickiness will be applied, + * otherwise it defaults to duration-based stickiness attributes (`lb_cookie`). + * + * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html */ - public enableCookieStickiness(duration: Duration) { + public enableCookieStickiness(duration: Duration, cookieName?: string) { + if (cookieName !== undefined) { + if (!Token.isUnresolved(cookieName) && (cookieName.startsWith('AWSALB') || cookieName.startsWith('AWSALBAPP') || cookieName.startsWith('AWSALBTG'))) { + throw new Error('App cookie names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP, and AWSALBTG; they\'re reserved for use by the load balancer.'); + } + if (cookieName === '') { + throw new Error('App cookie name cannot be an empty string.'); + } + } this.setAttribute('stickiness.enabled', 'true'); - this.setAttribute('stickiness.type', 'lb_cookie'); - this.setAttribute('stickiness.lb_cookie.duration_seconds', duration.toSeconds().toString()); + if (cookieName) { + this.setAttribute('stickiness.type', 'app_cookie'); + this.setAttribute('stickiness.app_cookie.cookie_name', cookieName); + this.setAttribute('stickiness.app_cookie.duration_seconds', duration.toSeconds().toString()); + } else { + this.setAttribute('stickiness.type', 'lb_cookie'); + this.setAttribute('stickiness.lb_cookie.duration_seconds', duration.toSeconds().toString()); + } } /** diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts index b9de0961423ec..231279ffb932e 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts @@ -71,7 +71,7 @@ describe('tests', () => { }); }); - test('Listener default to open - IPv4 and IPv6 (dualstack)', () => { + test('Listener default to open - IPv4 and IPv6 (dual stack)', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); @@ -316,7 +316,7 @@ describe('tests', () => { }); }); - test('Enable stickiness for targets', () => { + test('Enable alb stickiness for targets', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); @@ -349,6 +349,43 @@ describe('tests', () => { }); }); + test('Enable app stickiness for targets', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc }); + const listener = lb.addListener('Listener', { port: 80 }); + + // WHEN + const group = listener.addTargets('Group', { + port: 80, + targets: [new FakeSelfRegisteringTarget(stack, 'Target', vpc)], + }); + group.enableCookieStickiness(cdk.Duration.hours(1), 'MyDeliciousCookie'); + + // THEN + expect(stack).toHaveResource('AWS::ElasticLoadBalancingV2::TargetGroup', { + TargetGroupAttributes: [ + { + Key: 'stickiness.enabled', + Value: 'true', + }, + { + Key: 'stickiness.type', + Value: 'app_cookie', + }, + { + Key: 'stickiness.app_cookie.cookie_name', + Value: 'MyDeliciousCookie', + }, + { + Key: 'stickiness.app_cookie.duration_seconds', + Value: '3600', + }, + ], + }); + }); + test('Enable health check for targets', () => { // GIVEN const stack = new cdk.Stack(); @@ -823,7 +860,7 @@ describe('tests', () => { }); }); - test('Throws when specifying both target groups and fixed reponse', () => { + test('Throws when specifying both target groups and fixed response', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'VPC'); @@ -868,7 +905,7 @@ describe('tests', () => { })).toThrowError('Priority must have value greater than or equal to 1'); }); - test('Throws when specifying both target groups and redirect reponse', () => { + test('Throws when specifying both target groups and redirect response', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'VPC'); @@ -970,7 +1007,7 @@ describe('tests', () => { }); }); - test('Can add additional certificates via addCertficateArns to application listener', () => { + test('Can add additional certificates via addCertificateArns to application listener', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); @@ -1050,7 +1087,7 @@ describe('tests', () => { })).toThrowError('Both `pathPatterns` and `pathPattern` are specified, specify only one'); }); - test('Add additonal condition to listener rule', () => { + test('Add additional condition to listener rule', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); @@ -1244,7 +1281,7 @@ describe('tests', () => { }); }); - test('Can exist together legacy style conditions and modan style conditions', () => { + test('Can exist together legacy style conditions and modern style conditions', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts index 1d8df0a706d9c..f1b71756e43db 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts @@ -88,4 +88,106 @@ describe('tests', () => { UnhealthyThresholdCount: 27, }); }); + + test('Load balancer duration cookie stickiness', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Stack'); + const vpc = new ec2.Vpc(stack, 'VPC', {}); + + // WHEN + new elbv2.ApplicationTargetGroup(stack, 'TargetGroup', { + stickinessCookieDuration: cdk.Duration.minutes(5), + vpc, + }); + + // THEN + expect(stack).toHaveResource('AWS::ElasticLoadBalancingV2::TargetGroup', { + TargetGroupAttributes: [ + { + Key: 'stickiness.enabled', + Value: 'true', + }, + { + Key: 'stickiness.type', + Value: 'lb_cookie', + }, + { + Key: 'stickiness.lb_cookie.duration_seconds', + Value: '300', + }, + ], + }); + }); + + test('Load balancer app cookie stickiness', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Stack'); + const vpc = new ec2.Vpc(stack, 'VPC', {}); + + // WHEN + new elbv2.ApplicationTargetGroup(stack, 'TargetGroup', { + stickinessCookieDuration: cdk.Duration.minutes(5), + stickinessCookieName: 'MyDeliciousCookie', + vpc, + }); + + // THEN + expect(stack).toHaveResource('AWS::ElasticLoadBalancingV2::TargetGroup', { + TargetGroupAttributes: [ + { + Key: 'stickiness.enabled', + Value: 'true', + }, + { + Key: 'stickiness.type', + Value: 'app_cookie', + }, + { + Key: 'stickiness.app_cookie.cookie_name', + Value: 'MyDeliciousCookie', + }, + { + Key: 'stickiness.app_cookie.duration_seconds', + Value: '300', + }, + ], + }); + }); + + test('Bad stickiness cookie names', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Stack'); + const vpc = new ec2.Vpc(stack, 'VPC', {}); + const errMessage = 'App cookie names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP, and AWSALBTG; they\'re reserved for use by the load balancer'; + + // THEN + ['AWSALBCookieName', 'AWSALBstickinessCookieName', 'AWSALBTGCookieName'].forEach((badCookieName, i) => { + expect(() => { + new elbv2.ApplicationTargetGroup(stack, `TargetGroup${i}`, { + stickinessCookieDuration: cdk.Duration.minutes(5), + stickinessCookieName: badCookieName, + vpc, + }); + }).toThrow(errMessage); + }); + }); + + test('Empty stickiness cookie name', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Stack'); + const vpc = new ec2.Vpc(stack, 'VPC', {}); + + // THEN + expect(() => { + new elbv2.ApplicationTargetGroup(stack, 'TargetGroup', { + stickinessCookieDuration: cdk.Duration.minutes(5), + stickinessCookieName: '', + vpc, + }); + }).toThrow(/App cookie name cannot be an empty string./); + }); }); diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json index 8fd29b717c9d5..c1dfdcb095bec 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json @@ -438,6 +438,20 @@ "Properties": { "Port": 80, "Protocol": "HTTP", + "TargetGroupAttributes": [ + { + "Key": "stickiness.enabled", + "Value": "true" + }, + { + "Key": "stickiness.type", + "Value": "lb_cookie" + }, + { + "Key": "stickiness.lb_cookie.duration_seconds", + "Value": "300" + } + ], "Targets": [ { "Id": "10.0.128.4" @@ -454,6 +468,24 @@ "Properties": { "Port": 80, "Protocol": "HTTP", + "TargetGroupAttributes": [ + { + "Key": "stickiness.enabled", + "Value": "true" + }, + { + "Key": "stickiness.type", + "Value": "app_cookie" + }, + { + "Key": "stickiness.app_cookie.cookie_name", + "Value": "MyDeliciousCookie" + }, + { + "Key": "stickiness.app_cookie.duration_seconds", + "Value": "300" + } + ], "Targets": [ { "Id": "10.0.128.5" diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts index 251c730c2fa42..df716e80e0f4f 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts @@ -22,6 +22,7 @@ const listener = lb.addListener('Listener', { const group1 = listener.addTargets('Target', { port: 80, targets: [new elbv2.IpTarget('10.0.128.4')], + stickinessCookieDuration: cdk.Duration.minutes(5), }); const group2 = listener.addTargets('ConditionalTarget', { @@ -29,6 +30,8 @@ const group2 = listener.addTargets('ConditionalTarget', { hostHeader: 'example.com', port: 80, targets: [new elbv2.IpTarget('10.0.128.5')], + stickinessCookieDuration: cdk.Duration.minutes(5), + stickinessCookieName: 'MyDeliciousCookie', }); group1.metricTargetResponseTime().createAlarm(stack, 'ResponseTimeHigh1', {