Skip to content

Commit

Permalink
feat(ecs-service-extensions): Target tracking policies for Service Ex…
Browse files Browse the repository at this point in the history
…tensions (aws#17101)

----
This PR adds `desiredCount`, `targetCpuUtilization` and `targetMemoryUtilization` to the service construct. It also adds `requestsPerTarget` to the `HttpLoadBalancerExtension` props to allow adding target tracking policy based on the ALB request count.

It will be followed by another PR to configure queue auto scaling for the SQS Queues in the `QueueExtension`.

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
upparekh authored and TikiTDO committed Feb 21, 2022
1 parent 71b5b73 commit 4253723
Show file tree
Hide file tree
Showing 5 changed files with 338 additions and 24 deletions.
67 changes: 46 additions & 21 deletions packages/@aws-cdk-containers/ecs-service-extensions/README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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,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();
}
}
}
Original file line number Diff line number Diff line change
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,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.
Expand Down Expand Up @@ -160,14 +208,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,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.`);
}
}

/**
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'./);
});

});
Loading

0 comments on commit 4253723

Please sign in to comment.