Skip to content
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

feat(ecs-service-extensions): Target tracking policies for Service Extensions #17101

Merged
merged 9 commits into from Nov 6, 2021
67 changes: 46 additions & 21 deletions packages/@aws-cdk-containers/ecs-service-extensions/README.md
Expand Up @@ -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,
});
}
}
Expand Down
Expand Up @@ -8,16 +8,24 @@ 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.
*/
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.
Expand Down Expand Up @@ -55,10 +63,20 @@ 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,
});
}
}
}
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -75,6 +112,11 @@ export class Service extends Construct {
*/
public readonly environment: IEnvironment;

/**
* The scalable attribute representing task count.
*/
public readonly scalableTaskCount?: ecs.ScalableTaskCount;

/**
* The generated task definition for this service. It is only
* generated after .prepare() has been executed.
Expand Down Expand Up @@ -160,14 +202,17 @@ 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 = {
cluster: this.cluster,
taskDefinition: this.taskDefinition,
minHealthyPercent: 100,
maxHealthyPercent: 200,
desiredCount: 1,
desiredCount,
} as ServiceBuild;

for (const extensions in this.serviceDescription.extensions) {
Expand Down Expand Up @@ -219,6 +264,28 @@ 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,
});
}

if (props.autoScaleTaskCount.targetMemoryUtilization) {
const targetUtilizationPercent = props.autoScaleTaskCount.targetMemoryUtilization;
this.scalableTaskCount.scaleOnMemoryUtilization(`${this.id}-target-memory-utilization-${targetUtilizationPercent}`, {
targetUtilizationPercent,
});
}
}

// Now give all extensions a chance to use the service
for (const extensions in this.serviceDescription.extensions) {
if (this.serviceDescription.extensions[extensions]) {
Expand Down
Expand Up @@ -72,4 +72,67 @@ 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: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we send autoScaleTaskCount: {}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxTaskCount is a required field. If autoScaleTaskCount is undefined then we error out i guess? https://github.com/aws/aws-cdk/pull/17101/files#diff-77a32c641e0209aa8836c64a4f58132a141ad27eab31be695d247e8f4015a8a0R135

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But does it consider {} as undefined is the question

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{} is not undefined and it will be invalid input and not pass the static check i think

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm that makes sense 👍🏼

maxTaskCount: 5,
},
});

// THEN
expect(stack).toHaveResourceLike('AWS::ApplicationAutoScaling::ScalingPolicy', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we somehow print that maxTaskCount is 5 here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed probably can't tell from this resource but the other ones like ecs service

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I meant to say can we have an expect statement to print that maxTaskCount is 5

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm the unit test is usually meant to check if the CFN template we generate is expected, instead of if the construct itself. maxTaskCount is a field of the construct.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I didn't include a check for maxCapacity of the ScalableTarget here because we configure it in the service construct but it makes sense to add a check for it here as well! Will update!

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'./);
});

});