diff --git a/packages/@aws-cdk/aws-lambda/README.md b/packages/@aws-cdk/aws-lambda/README.md index 07cb929bae067..9c27790061093 100644 --- a/packages/@aws-cdk/aws-lambda/README.md +++ b/packages/@aws-cdk/aws-lambda/README.md @@ -272,6 +272,35 @@ const fn = new lambda.Function(this, 'MyFunction', { See [the AWS documentation](https://docs.aws.amazon.com/lambda/latest/dg/concurrent-executions.html) managing concurrency. +### AutoScaling + +You can use Application AutoScaling to automatically configure the provisioned concurrency for your functions. AutoScaling can be set to track utilization or be based on a schedule. To configure AutoScaling on a function alias: + +```ts +const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, +}); + +// Create AutoScaling target +const as = alias.addAutoScaling({ maxCapacity: 50 }) + +// Configure Target Tracking +as.scaleOnUtilization({ + utilizationTarget: 0.5, +}); + +// Configure Scheduled Scaling +as.scaleOnSchedule('ScaleUpInTheMorning', { + schedule: appscaling.Schedule.cron({ hour: '8', minute: '0'}), + minCapacity: 20, +}); +``` + +[Example of Lambda AutoScaling usage](test/integ.autoscaling.lit.ts) + +See [the AWS documentation](https://docs.aws.amazon.com/lambda/latest/dg/invocation-scaling.html) on autoscaling lambda functions. + ### Log Group Lambda functions automatically create a log group with the name `/aws/lambda/` upon first execution with diff --git a/packages/@aws-cdk/aws-lambda/lib/alias.ts b/packages/@aws-cdk/aws-lambda/lib/alias.ts index 638f52e11a09f..c740b6620a061 100644 --- a/packages/@aws-cdk/aws-lambda/lib/alias.ts +++ b/packages/@aws-cdk/aws-lambda/lib/alias.ts @@ -1,9 +1,13 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as iam from '@aws-cdk/aws-iam'; import { Construct } from '@aws-cdk/core'; import { EventInvokeConfigOptions } from './event-invoke-config'; import { IFunction, QualifiedFunctionBase } from './function-base'; import { extractQualifierFromArn, IVersion } from './lambda-version'; import { CfnAlias } from './lambda.generated'; +import { ScalableFunctionAttribute } from './private/scalable-function-attribute'; +import { AutoScalingOptions, IScalableFunctionAttribute } from './scalable-attribute-api'; export interface IAlias extends IFunction { /** @@ -129,6 +133,9 @@ export class Alias extends QualifiedFunctionBase implements IAlias { protected readonly canCreatePermissions: boolean = true; + private scalableAlias?: ScalableFunctionAttribute; + private readonly scalingRole: iam.IRole; + constructor(scope: Construct, id: string, props: AliasProps) { super(scope, id, { physicalName: props.aliasName, @@ -147,6 +154,15 @@ export class Alias extends QualifiedFunctionBase implements IAlias { provisionedConcurrencyConfig: this.determineProvisionedConcurrency(props), }); + // Use a Service Linked Role + // https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-service-linked-roles.html + this.scalingRole = iam.Role.fromRoleArn(this, 'ScalingRole', this.stack.formatArn({ + service: 'iam', + region: '', + resource: 'role/aws-service-role/lambda.application-autoscaling.amazonaws.com', + resourceName: 'AWSServiceRoleForApplicationAutoScaling_LambdaConcurrency', + })); + this.functionArn = this.getResourceArnAttribute(alias.ref, { service: 'lambda', resource: 'function', @@ -193,6 +209,26 @@ export class Alias extends QualifiedFunctionBase implements IAlias { }); } + /** + * Configure provisioned concurrency autoscaling on a function alias. Returns a scalable attribute that can call + * `scaleOnUtilization()` and `scaleOnSchedule()`. + * + * @param options Autoscaling options + */ + public addAutoScaling(options: AutoScalingOptions): IScalableFunctionAttribute { + if (this.scalableAlias) { + throw new Error('AutoScaling already enabled for this alias'); + } + return this.scalableAlias = new ScalableFunctionAttribute(this, 'AliasScaling', { + minCapacity: options.minCapacity ?? 1, + maxCapacity: options.maxCapacity, + resourceId: `function:${this.functionName}`, + dimension: 'lambda:function:ProvisionedConcurrency', + serviceNamespace: appscaling.ServiceNamespace.LAMBDA, + role: this.scalingRole, + }); + } + /** * Calculate the routingConfig parameter from the input props */ diff --git a/packages/@aws-cdk/aws-lambda/lib/index.ts b/packages/@aws-cdk/aws-lambda/lib/index.ts index b1d676e234a9b..3581a40cdf535 100644 --- a/packages/@aws-cdk/aws-lambda/lib/index.ts +++ b/packages/@aws-cdk/aws-lambda/lib/index.ts @@ -13,6 +13,7 @@ export * from './event-source'; export * from './event-source-mapping'; export * from './destination'; export * from './event-invoke-config'; +export * from './scalable-attribute-api'; export * from './log-retention'; diff --git a/packages/@aws-cdk/aws-lambda/lib/private/scalable-function-attribute.ts b/packages/@aws-cdk/aws-lambda/lib/private/scalable-function-attribute.ts new file mode 100644 index 0000000000000..21b09cd2c79e2 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/private/scalable-function-attribute.ts @@ -0,0 +1,41 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; +import { Construct, Token } from '@aws-cdk/core'; +import { IScalableFunctionAttribute, UtilizationScalingOptions } from '../scalable-attribute-api'; + +/** + * A scalable lambda alias attribute + */ +export class ScalableFunctionAttribute extends appscaling.BaseScalableAttribute implements IScalableFunctionAttribute{ + constructor(scope: Construct, id: string, props: ScalableFunctionAttributeProps){ + super(scope, id, props); + } + + /** + * Scale out or in to keep utilization at a given level. The utilization is tracked by the + * LambdaProvisionedConcurrencyUtilization metric, emitted by lambda. See: + * https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics.html#monitoring-metrics-concurrency + */ + public scaleOnUtilization(options: UtilizationScalingOptions) { + if ( !Token.isUnresolved(options.utilizationTarget) && (options.utilizationTarget < 0.1 || options.utilizationTarget > 0.9)) { + throw new Error(`Utilization Target should be between 0.1 and 0.9. Found ${options.utilizationTarget}.`); + } + super.doScaleToTrackMetric('Tracking', { + targetValue: options.utilizationTarget, + predefinedMetric: appscaling.PredefinedMetric.LAMBDA_PROVISIONED_CONCURRENCY_UTILIZATION, + ...options, + }); + } + + /** + * Scale out or in based on schedule. + */ + public scaleOnSchedule(id: string, action: appscaling.ScalingSchedule) { + super.doScaleOnSchedule(id, action); + } +} + +/** + * Properties of a scalable function attribute + */ +export interface ScalableFunctionAttributeProps extends appscaling.BaseScalableAttributeProps { +} diff --git a/packages/@aws-cdk/aws-lambda/lib/scalable-attribute-api.ts b/packages/@aws-cdk/aws-lambda/lib/scalable-attribute-api.ts new file mode 100644 index 0000000000000..e64fcbd5a8ca7 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/scalable-attribute-api.ts @@ -0,0 +1,46 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; +import { IConstruct } from '@aws-cdk/core'; + + +/** + * Interface for scalable attributes + */ +export interface IScalableFunctionAttribute extends IConstruct { + /** + * Scale out or in to keep utilization at a given level. The utilization is tracked by the + * LambdaProvisionedConcurrencyUtilization metric, emitted by lambda. See: + * https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics.html#monitoring-metrics-concurrency + */ + scaleOnUtilization(options: UtilizationScalingOptions): void; + /** + * Scale out or in based on schedule. + */ + scaleOnSchedule(id: string, actions: appscaling.ScalingSchedule): void; +} + +/** + * Options for enabling Lambda utilization tracking + */ +export interface UtilizationScalingOptions extends appscaling.BaseTargetTrackingProps { + /** + * Utilization target for the attribute. For example, .5 indicates that 50 percent of allocated provisioned concurrency is in use. + */ + readonly utilizationTarget: number; +} + +/** + * Properties for enabling Lambda autoscaling + */ +export interface AutoScalingOptions { + /** + * Minimum capacity to scale to + * + * @default 1 + */ + readonly minCapacity?: number; + + /** + * Maximum capacity to scale to + */ + readonly maxCapacity: number; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index d62071663eb02..a4274700c3e5a 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -83,6 +83,7 @@ "sinon": "^9.0.2" }, "dependencies": { + "@aws-cdk/aws-applicationautoscaling": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codeguruprofiler": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", @@ -99,6 +100,7 @@ }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-applicationautoscaling": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codeguruprofiler": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", diff --git a/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.expected.json b/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.expected.json new file mode 100644 index 0000000000000..39ff5f8f0e0fb --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.expected.json @@ -0,0 +1,164 @@ +{ + "Resources": { + "MyLambdaServiceRole4539ECB6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyLambdaCCE802FB": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async () => {\nconsole.log('hello world');\n};" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "MyLambdaServiceRole4539ECB6", + "Arn" + ] + }, + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyLambdaServiceRole4539ECB6" + ] + }, + "MyLambdaVersion16CDE3C40": { + "Type": "AWS::Lambda::Version", + "Properties": { + "FunctionName": { + "Ref": "MyLambdaCCE802FB" + }, + "Description": "integ-test" + } + }, + "Alias325C5727": { + "Type": "AWS::Lambda::Alias", + "Properties": { + "FunctionName": { + "Ref": "MyLambdaCCE802FB" + }, + "FunctionVersion": { + "Fn::GetAtt": [ + "MyLambdaVersion16CDE3C40", + "Version" + ] + }, + "Name": "prod" + } + }, + "AliasAliasScalingTarget7449FF0E": { + "Type": "AWS::ApplicationAutoScaling::ScalableTarget", + "Properties": { + "MaxCapacity": 50, + "MinCapacity": 3, + "ResourceId": { + "Fn::Join": [ + "", + [ + "function:", + { + "Fn::Select": [ + 6, + { + "Fn::Split": [ + ":", + { + "Ref": "Alias325C5727" + } + ] + } + ] + }, + ":prod" + ] + ] + }, + "RoleARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":role/aws-service-role/lambda.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_LambdaConcurrency" + ] + ] + }, + "ScalableDimension": "lambda:function:ProvisionedConcurrency", + "ServiceNamespace": "lambda", + "ScheduledActions": [ + { + "ScalableTargetAction": { + "MinCapacity": 20 + }, + "Schedule": "cron(0 8 * * ? *)", + "ScheduledActionName": "ScaleUpInTheMorning" + }, + { + "ScalableTargetAction": { + "MaxCapacity": 20 + }, + "Schedule": "cron(0 20 * * ? *)", + "ScheduledActionName": "ScaleDownAtNight" + } + ] + } + }, + "AliasAliasScalingTargetTrackingA7718D48": { + "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", + "Properties": { + "PolicyName": "awslambdaautoscalingAliasAliasScalingTargetTrackingD339330D", + "PolicyType": "TargetTrackingScaling", + "ScalingTargetId": { + "Ref": "AliasAliasScalingTarget7449FF0E" + }, + "TargetTrackingScalingPolicyConfiguration": { + "PredefinedMetricSpecification": { + "PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization" + }, + "TargetValue": 0.5 + } + } + } + }, + "Outputs": { + "FunctionName": { + "Value": { + "Ref": "MyLambdaCCE802FB" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.ts b/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.ts new file mode 100644 index 0000000000000..360dda2a1b772 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/integ.autoscaling.lit.ts @@ -0,0 +1,53 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; +import * as cdk from '@aws-cdk/core'; +import * as lambda from '../lib'; + +/** +* Stack verification steps: +* aws application-autoscaling describe-scalable-targets --service-namespace lambda --resource-ids function::prod +* has a minCapacity of 3 and maxCapacity of 50 +*/ +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string) { + super(scope, id); + + const fn = new lambda.Function(this, 'MyLambda', { + code: new lambda.InlineCode('exports.handler = async () => {\nconsole.log(\'hello world\');\n};'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'integ-test'); + + const alias = new lambda.Alias(this, 'Alias', { + aliasName: 'prod', + version, + }); + + const scalingTarget = alias.addAutoScaling({ minCapacity: 3, maxCapacity: 50 }); + + scalingTarget.scaleOnUtilization({ + utilizationTarget: 0.5, + }); + + scalingTarget.scaleOnSchedule('ScaleUpInTheMorning', { + schedule: appscaling.Schedule.cron({ hour: '8', minute: '0'}), + minCapacity: 20, + }); + + scalingTarget.scaleOnSchedule('ScaleDownAtNight', { + schedule: appscaling.Schedule.cron({ hour: '20', minute: '0'}), + maxCapacity: 20, + }); + + new cdk.CfnOutput(this, 'FunctionName', { + value: fn.functionName, + }); + } +} + +const app = new cdk.App(); + +new TestStack(app, 'aws-lambda-autoscaling'); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/test.alias.ts b/packages/@aws-cdk/aws-lambda/test/test.alias.ts index abbb8c1716b0f..af24cca911a5c 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.alias.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.alias.ts @@ -1,6 +1,7 @@ -import { beASupersetOfTemplate, expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { arrayWith, beASupersetOfTemplate, expect, haveResource, haveResourceLike, objectLike } from '@aws-cdk/assert'; +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; -import { Stack } from '@aws-cdk/core'; +import { Lazy, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; import * as lambda from '../lib'; @@ -430,4 +431,213 @@ export = { test.done(); }, + + 'can enable AutoScaling on aliases'(test: Test): void { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + }); + + // WHEN + alias.addAutoScaling({ maxCapacity: 5}); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + MinCapacity: 1, + MaxCapacity: 5, + ResourceId: objectLike({ + 'Fn::Join': arrayWith(arrayWith( + 'function:', + objectLike({ + 'Fn::Select': arrayWith( + {'Fn::Split': arrayWith( + {Ref: 'Alias325C5727' }), + }, + ), + }), + ':prod', + )), + }), + })); + + test.done(); + }, + + 'can enable AutoScaling on aliases with Provisioned Concurrency set'(test: Test): void { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + provisionedConcurrentExecutions: 10, + }); + + // WHEN + alias.addAutoScaling({ maxCapacity: 5}); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + MinCapacity: 1, + MaxCapacity: 5, + ResourceId: objectLike({ + 'Fn::Join': arrayWith(arrayWith( + 'function:', + objectLike({ + 'Fn::Select': arrayWith( + {'Fn::Split': arrayWith( + {Ref: 'Alias325C5727' }), + }, + ), + }), + ':prod', + )), + }), + })); + + expect(stack).to(haveResourceLike('AWS::Lambda::Alias', { + ProvisionedConcurrencyConfig: { + ProvisionedConcurrentExecutions: 10, + }, + })); + test.done(); + }, + + 'validation for utilizationTarget does not fail when using Tokens'(test: Test) { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + provisionedConcurrentExecutions: 10, + }); + + // WHEN + const target = alias.addAutoScaling({ maxCapacity: 5 }); + + target.scaleOnUtilization({utilizationTarget: Lazy.numberValue({ produce: () => 0.95 })}); + + // THEN: no exception + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: 'LambdaProvisionedConcurrencyUtilization' }, + TargetValue: 0.95, + }, + + })); + + test.done(); + }, + + 'cannot enable AutoScaling twice on same property'(test: Test): void { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + }); + + // WHEN + alias.addAutoScaling({ maxCapacity: 5 }); + + // THEN + test.throws(() => alias.addAutoScaling({ maxCapacity: 8 }), /AutoScaling already enabled for this alias/); + + test.done(); + }, + + 'error when specifying invalid utilization value when AutoScaling on utilization'(test: Test): void { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + }); + + // WHEN + const target = alias.addAutoScaling({ maxCapacity: 5 }); + + // THEN + test.throws(() => target.scaleOnUtilization({utilizationTarget: 0.95}), /Utilization Target should be between 0.1 and 0.9. Found 0.95/); + test.done(); + }, + + 'can autoscale on a schedule'(test: Test): void { + // GIVEN + const stack = new Stack(); + const fn = new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('hello()'), + handler: 'index.hello', + runtime: lambda.Runtime.NODEJS_10_X, + }); + + const version = fn.addVersion('1', undefined, 'testing'); + + const alias = new lambda.Alias(stack, 'Alias', { + aliasName: 'prod', + version, + }); + + // WHEN + const target = alias.addAutoScaling({ maxCapacity: 5}); + target.scaleOnSchedule('Scheduling', { + schedule: appscaling.Schedule.cron({}), + maxCapacity: 10, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ApplicationAutoScaling::ScalableTarget', { + ScheduledActions: [ + { + ScalableTargetAction: { MaxCapacity: 10 }, + Schedule: 'cron(* * * * ? *)', + ScheduledActionName: 'Scheduling', + }, + ], + })); + + test.done(); + }, };