diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/README.md b/packages/@aws-cdk-containers/ecs-service-extensions/README.md index 0556debfc705f..b80f1539f4447 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/README.md +++ b/packages/@aws-cdk-containers/ecs-service-extensions/README.md @@ -154,43 +154,68 @@ const nameService = new Service(stack, 'name', { }); ``` +## Task Auto-Scaling + +You can configure the task count of a service to match demand. The recommended way of achieving this is to configure target tracking policies for your service which scales in and out in order to keep metrics around target values. + +You need to configure an auto scaling target for the service by setting the `minTaskCount` (defaults to 1) and `maxTaskCount` in the `Service` construct. Then you can specify target values for "CPU Utilization" or "Memory Utilization" across all tasks in your service. Note that the `desiredCount` value will be set to `undefined` if the auto scaling target is configured. + +If you want to configure auto-scaling policies based on resources like Application Load Balancer or SQS Queues, you can set the corresponding resource-specific fields in the extension. For example, you can enable target tracking scaling based on Application Load Balancer request count as follows: + +```ts +const stack = new cdk.Stack(); +const environment = new Environment(stack, 'production'); +const serviceDescription = new ServiceDescription(); + +serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('my-alb'), +})); + +// Add the extension with target `requestsPerTarget` value set +serviceDescription.add(new HttpLoadBalancerExtension({ requestsPerTarget: 10 })); + +// Configure the auto scaling target +new Service(stack, 'my-service', { + environment, + serviceDescription, + desiredCount: 5, + // Task auto-scaling constuct for the service + autoScaleTaskCount: { + maxTaskCount: 10, + targetCpuUtilization: 70, + targetMemoryUtilization: 50, + }, +}); +``` + +You can also define your own service extensions for [other auto-scaling policies](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-auto-scaling.html) for your service by making use of the `scalableTaskCount` attribute of the `Service` class. + ## Creating your own custom `ServiceExtension` In addition to using the default service extensions that come with this module, you can choose to implement your own custom service extensions. The `ServiceExtension` class is an abstract class you can implement yourself. The following example implements a custom service extension that could be added to a service in order to -autoscale it based on CPU: +autoscale it based on scaling intervals of SQS Queue size: ```ts export class MyCustomAutoscaling extends ServiceExtension { constructor() { super('my-custom-autoscaling'); - } - - // This function modifies properties of the service prior - // to construct creation. - public modifyServiceProps(props: ServiceBuild) { - return { - ...props, - - // Initially launch 10 copies of the service - desiredCount: 10 - } as ServiceBuild; + // Scaling intervals for the step scaling policy + this.scalingSteps = [{ upper: 0, change: -1 }, { lower: 100, change: +1 }, { lower: 500, change: +5 }]; + this.sqsQueue = new sqs.Queue(this.scope, 'my-queue'); } // This hook utilizes the resulting service construct // once it is created public useService(service: ecs.Ec2Service | ecs.FargateService) { - const scalingTarget = service.autoScaleTaskCount({ - minCapacity: 5, // Min 5 tasks - maxCapacity: 20 // Max 20 tasks - }); - - scalingTarget.scaleOnCpuUtilization('TargetCpuUtilization50', { - targetUtilizationPercent: 50, - scaleInCooldown: cdk.Duration.seconds(60), - scaleOutCooldown: cdk.Duration.seconds(60), + this.parentService.scalableTaskCount.scaleOnMetric('QueueMessagesVisibleScaling', { + metric: this.sqsQueue.metricApproximateNumberOfMessagesVisible(), + scalingSteps: this.scalingSteps, }); } } diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/http-load-balancer.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/http-load-balancer.ts index e0390c0ab617c..6db6a6c9616d4 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/http-load-balancer.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/http-load-balancer.ts @@ -8,6 +8,12 @@ import { ServiceExtension, ServiceBuild } from './extension-interfaces'; // eslint-disable-next-line no-duplicate-imports, import/order import { Construct } from '@aws-cdk/core'; +export interface HttpLoadBalancerProps { + /** + * The number of ALB requests per target. + */ + readonly requestsPerTarget?: number; +} /** * This extension add a public facing load balancer for sending traffic * to one or more replicas of the application container. @@ -15,9 +21,11 @@ import { Construct } from '@aws-cdk/core'; export class HttpLoadBalancerExtension extends ServiceExtension { private loadBalancer!: alb.IApplicationLoadBalancer; private listener!: alb.IApplicationListener; + private requestsPerTarget?: number; - constructor() { + constructor(props: HttpLoadBalancerProps = {}) { super('load-balancer'); + this.requestsPerTarget = props.requestsPerTarget; } // Before the service is created, go ahead and create the load balancer itself. @@ -55,10 +63,21 @@ export class HttpLoadBalancerExtension extends ServiceExtension { // After the service is created add the service to the load balancer's listener public useService(service: ecs.Ec2Service | ecs.FargateService) { - this.listener.addTargets(this.parentService.id, { + const targetGroup = this.listener.addTargets(this.parentService.id, { deregistrationDelay: cdk.Duration.seconds(10), port: 80, targets: [service], }); + + if (this.requestsPerTarget) { + if (!this.parentService.scalableTaskCount) { + throw Error(`Auto scaling target for the service '${this.parentService.id}' hasn't been configured. Please use Service construct to configure 'minTaskCount' and 'maxTaskCount'.`); + } + this.parentService.scalableTaskCount.scaleOnRequestCount(`${this.parentService.id}-target-request-count-${this.requestsPerTarget}`, { + requestsPerTarget: this.requestsPerTarget, + targetGroup, + }); + this.parentService.enableAutoScalingPolicy(); + } } } diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/service.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/service.ts index 4fdc8590c1dc0..957dcef280cd7 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/lib/service.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/service.ts @@ -30,6 +30,43 @@ export interface ServiceProps { * @default - A task role is automatically created for you. */ readonly taskRole?: iam.IRole; + + /** + * The desired number of instantiations of the task definition to keep running on the service. + * + * @default - When creating the service, default is 1; when updating the service, default uses + * the current task number. + */ + readonly desiredCount?: number; + + /** + * The options for configuring the auto scaling target. + */ + readonly autoScaleTaskCount?: AutoScalingOptions; +} + +export interface AutoScalingOptions { + /** + * The minimum number of tasks when scaling in. + * + * @default - 1 + */ + readonly minTaskCount?: number; + + /** + * The maximum number of tasks when scaling out. + */ + readonly maxTaskCount: number; + + /** + * The target value for CPU utilization across all tasks in the service. + */ + readonly targetCpuUtilization?: number; + + /** + * The target value for memory utilization across all tasks in the service. + */ + readonly targetMemoryUtilization?: number; } /** @@ -75,6 +112,17 @@ export class Service extends Construct { */ public readonly environment: IEnvironment; + /** + * The scalable attribute representing task count. + */ + public readonly scalableTaskCount?: ecs.ScalableTaskCount; + + /** + * The flag to track if auto scaling policies have been configured + * for the service. + */ + private autoScalingPoliciesEnabled: boolean = false; + /** * The generated task definition for this service. It is only * generated after .prepare() has been executed. @@ -160,6 +208,9 @@ export class Service extends Construct { } } + // Set desiredCount to `undefined` if auto scaling is configured for the service + const desiredCount = props.autoScaleTaskCount ? undefined : (props.desiredCount || 1); + // Give each extension a chance to mutate the service props before // service creation let serviceProps = { @@ -167,7 +218,7 @@ export class Service extends Construct { taskDefinition: this.taskDefinition, minHealthyPercent: 100, maxHealthyPercent: 200, - desiredCount: 1, + desiredCount, } as ServiceBuild; for (const extensions in this.serviceDescription.extensions) { @@ -219,12 +270,41 @@ export class Service extends Construct { throw new Error(`Unknown capacity type for service ${this.id}`); } + // Create the auto scaling target and configure target tracking policies after the service is created + if (props.autoScaleTaskCount) { + this.scalableTaskCount = this.ecsService.autoScaleTaskCount({ + maxCapacity: props.autoScaleTaskCount.maxTaskCount, + minCapacity: props.autoScaleTaskCount.minTaskCount, + }); + + if (props.autoScaleTaskCount.targetCpuUtilization) { + const targetUtilizationPercent = props.autoScaleTaskCount.targetCpuUtilization; + this.scalableTaskCount.scaleOnCpuUtilization(`${this.id}-target-cpu-utilization-${targetUtilizationPercent}`, { + targetUtilizationPercent, + }); + this.enableAutoScalingPolicy(); + } + + if (props.autoScaleTaskCount.targetMemoryUtilization) { + const targetUtilizationPercent = props.autoScaleTaskCount.targetMemoryUtilization; + this.scalableTaskCount.scaleOnMemoryUtilization(`${this.id}-target-memory-utilization-${targetUtilizationPercent}`, { + targetUtilizationPercent, + }); + this.enableAutoScalingPolicy(); + } + } + // Now give all extensions a chance to use the service for (const extensions in this.serviceDescription.extensions) { if (this.serviceDescription.extensions[extensions]) { this.serviceDescription.extensions[extensions].useService(this.ecsService); } } + + // Error out if the auto scaling target is created but no scaling policies have been configured + if (this.scalableTaskCount && !this.autoScalingPoliciesEnabled) { + throw Error(`The auto scaling target for the service '${this.id}' has been created but no auto scaling policies have been configured.`); + } } /** @@ -266,4 +346,12 @@ export class Service extends Construct { return this.urls[urlName]; } + + /** + * This helper method is used to set the `autoScalingPoliciesEnabled` attribute + * whenever an auto scaling policy is configured for the service. + */ + public enableAutoScalingPolicy() { + this.autoScalingPoliciesEnabled = true; + } } diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/http-load-balancer.test.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/http-load-balancer.test.ts index 5e0d0e36ff554..e06c1632d93b5 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/http-load-balancer.test.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/http-load-balancer.test.ts @@ -72,4 +72,72 @@ describe('http load balancer', () => { }); + test('allows scaling on request count for the HTTP load balancer', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + serviceDescription.add(new HttpLoadBalancerExtension({ requestsPerTarget: 100 })); + + new Service(stack, 'my-service', { + environment, + serviceDescription, + autoScaleTaskCount: { + maxTaskCount: 5, + }, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 5, + MinCapacity: 1, + }); + + expect(stack).toHaveResourceLike('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { + PredefinedMetricType: 'ALBRequestCountPerTarget', + }, + TargetValue: 100, + }, + }); + }); + + test('should error when adding scaling policy if scaling target has not been configured', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + serviceDescription.add(new HttpLoadBalancerExtension({ requestsPerTarget: 100 })); + + // THEN + expect(() => { + new Service(stack, 'my-service', { + environment, + serviceDescription, + }); + }).toThrow(/Auto scaling target for the service 'my-service' hasn't been configured. Please use Service construct to configure 'minTaskCount' and 'maxTaskCount'./); + }); + }); \ No newline at end of file diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/service.test.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/service.test.ts index eb21eec01b5f5..65807231679b7 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/service.test.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/service.test.ts @@ -1,3 +1,4 @@ +import { ABSENT } from '@aws-cdk/assert-internal'; import '@aws-cdk/assert-internal/jest'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; @@ -102,4 +103,117 @@ describe('service', () => { }); + test('allows scaling on a target CPU utilization', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + new Service(stack, 'my-service', { + environment, + serviceDescription, + desiredCount: 3, + autoScaleTaskCount: { + maxTaskCount: 5, + targetCpuUtilization: 70, + }, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ECS::Service', { + DesiredCount: ABSENT, + }); + + expect(stack).toHaveResourceLike('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 5, + MinCapacity: 1, + }); + + expect(stack).toHaveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: 'ECSServiceAverageCPUUtilization' }, + TargetValue: 70, + }, + }); + }); + + test('allows scaling on a target memory utilization', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + new Service(stack, 'my-service', { + environment, + serviceDescription, + desiredCount: 3, + autoScaleTaskCount: { + maxTaskCount: 5, + targetMemoryUtilization: 70, + }, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ECS::Service', { + DesiredCount: ABSENT, + }); + + expect(stack).toHaveResourceLike('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 5, + MinCapacity: 1, + }); + + expect(stack).toHaveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: 'ECSServiceAverageMemoryUtilization' }, + TargetValue: 70, + }, + }); + }); + + test('should error when no auto scaling policies have been configured after creating the auto scaling target', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + // THEN + expect(() => { + new Service(stack, 'my-service', { + environment, + serviceDescription, + autoScaleTaskCount: { + maxTaskCount: 5, + }, + }); + }).toThrow(/The auto scaling target for the service 'my-service' has been created but no auto scaling policies have been configured./); + }); + }); \ No newline at end of file