diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index ffd919e5b3693..fbcd349cd36d4 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -123,6 +123,23 @@ cluster.addAutoScalingGroup(autoScalingGroup); If you omit the property `vpc`, the construct will create a new VPC with two AZs. +### Spot Instances + +To add spot instances into the cluster, you must specify the `spotPrice` in the `ecs.AddCapacityOptions` and optionally enable the `spotInstanceDraining` property. + +```ts +// Add an AutoScalingGroup with spot instances to the existing cluster +cluster.addCapacity('AsgSpot', { + maxCapacity: 2, + minCapacity: 2, + desiredCapacity: 2, + instanceType: new ec2.InstanceType('c5.xlarge'), + spotPrice: '0.0735', + // Enable the Automated Spot Draining support for Amazon ECS + spotInstanceDraining: true, +}); +``` + ## Task definitions A task Definition describes what a single copy of a **task** should look like. diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index 522881c43171a..ad59fa21c1ec3 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -1,12 +1,12 @@ import autoscaling = require('@aws-cdk/aws-autoscaling'); -import cloudwatch = require ('@aws-cdk/aws-cloudwatch'); +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); import ec2 = require('@aws-cdk/aws-ec2'); import iam = require('@aws-cdk/aws-iam'); import cloudmap = require('@aws-cdk/aws-servicediscovery'); import ssm = require('@aws-cdk/aws-ssm'); -import {Construct, Duration, IResource, Resource, Stack} from '@aws-cdk/core'; -import {InstanceDrainHook} from './drain-hook/instance-drain-hook'; -import {CfnCluster} from './ecs.generated'; +import { Construct, Duration, IResource, Resource, Stack } from '@aws-cdk/core'; +import { InstanceDrainHook } from './drain-hook/instance-drain-hook'; +import { CfnCluster } from './ecs.generated'; /** * The properties used to define an ECS cluster. @@ -195,6 +195,10 @@ export class Cluster extends Resource implements ICluster { autoScalingGroup.addUserData('echo ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config'); } + if (autoScalingGroup.spotPrice && options.spotInstanceDraining) { + autoScalingGroup.addUserData('echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config'); + } + // ECS instances must be able to do these things // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html autoScalingGroup.addToRolePolicy(new iam.PolicyStatement({ @@ -252,7 +256,7 @@ export class Cluster extends Resource implements ICluster { * @default average over 5 minutes */ public metricMemoryReservation(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('MemoryReservation', props ); + return this.metric('MemoryReservation', props); } /** @@ -350,12 +354,12 @@ export class EcsOptimizedAmi implements ec2.IMachineImage { // set the SSM parameter name this.amiParameterName = "/aws/service/ecs/optimized-ami/" - + ( this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX ? "amazon-linux/" : "" ) - + ( this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 ? "amazon-linux-2/" : "" ) - + ( this.windowsVersion ? `windows_server/${this.windowsVersion}/english/full/` : "" ) - + ( this.hwType === AmiHardwareType.GPU ? "gpu/" : "" ) - + ( this.hwType === AmiHardwareType.ARM ? "arm64/" : "" ) - + "recommended/image_id"; + + (this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX ? "amazon-linux/" : "") + + (this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 ? "amazon-linux-2/" : "") + + (this.windowsVersion ? `windows_server/${this.windowsVersion}/english/full/` : "") + + (this.hwType === AmiHardwareType.GPU ? "gpu/" : "") + + (this.hwType === AmiHardwareType.ARM ? "arm64/" : "") + + "recommended/image_id"; } /** @@ -380,14 +384,14 @@ export class EcsOptimizedImage implements ec2.IMachineImage { * @param hardwareType ECS-optimized AMI variant to use */ public static amazonLinux2(hardwareType = AmiHardwareType.STANDARD): EcsOptimizedImage { - return new EcsOptimizedImage({generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, hardwareType}); + return new EcsOptimizedImage({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, hardwareType }); } /** * Construct an Amazon Linux AMI image from the latest ECS Optimized AMI published in SSM */ public static amazonLinux(): EcsOptimizedImage { - return new EcsOptimizedImage({generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX}); + return new EcsOptimizedImage({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX }); } /** @@ -396,7 +400,7 @@ export class EcsOptimizedImage implements ec2.IMachineImage { * @param windowsVersion Windows Version to use */ public static windows(windowsVersion: WindowsOptimizedVersion): EcsOptimizedImage { - return new EcsOptimizedImage({windowsVersion}); + return new EcsOptimizedImage({ windowsVersion }); } private readonly generation?: ec2.AmazonLinuxGeneration; @@ -421,12 +425,12 @@ export class EcsOptimizedImage implements ec2.IMachineImage { // set the SSM parameter name this.amiParameterName = "/aws/service/ecs/optimized-ami/" - + ( this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX ? "amazon-linux/" : "" ) - + ( this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 ? "amazon-linux-2/" : "" ) - + ( this.windowsVersion ? `windows_server/${this.windowsVersion}/english/full/` : "" ) - + ( this.hwType === AmiHardwareType.GPU ? "gpu/" : "" ) - + ( this.hwType === AmiHardwareType.ARM ? "arm64/" : "" ) - + "recommended/image_id"; + + (this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX ? "amazon-linux/" : "") + + (this.generation === ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 ? "amazon-linux-2/" : "") + + (this.windowsVersion ? `windows_server/${this.windowsVersion}/english/full/` : "") + + (this.hwType === AmiHardwareType.GPU ? "gpu/" : "") + + (this.hwType === AmiHardwareType.ARM ? "arm64/" : "") + + "recommended/image_id"; } /** @@ -614,6 +618,14 @@ export interface AddAutoScalingGroupCapacityOptions { * @default Duration.minutes(5) */ readonly taskDrainTime?: Duration; + + /** + * Specify whether to enable Automated Draining for Spot Instances running Amazon ECS Services. + * For more information, see [Using Spot Instances](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/container-instance-spot.html). + * + * @default false + */ + readonly spotInstanceDraining?: boolean } /** diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.expected.json new file mode 100644 index 0000000000000..e7c49b9ab1f4e --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.expected.json @@ -0,0 +1,1298 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTableAssociation97140677": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + } + } + }, + "VpcPublicSubnet1DefaultRoute3DA9E72A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet2Subnet691E08A3": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTableAssociationDD5762D8": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + } + }, + "VpcPublicSubnet2DefaultRoute97F91067": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PrivateSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableAssociation70C59FA6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + } + } + }, + "VpcPrivateSubnet1DefaultRouteBE02A9ED": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + } + }, + "VpcPrivateSubnet2Subnet3788AAA1": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.192.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PrivateSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableAssociationA89CAD56": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + } + }, + "VpcPrivateSubnet2DefaultRoute060D2087": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "EcsCluster97242B84": { + "Type": "AWS::ECS::Cluster" + }, + "EcsClusterasgSpotInstanceSecurityGroupEA17787D": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ-spot/EcsCluster/asgSpot/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "EcsClusterasgSpotInstanceRole84AB6F93": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ] + } + }, + "EcsClusterasgSpotInstanceRoleDefaultPolicyB1E3ABFA": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgSpotInstanceRoleDefaultPolicyB1E3ABFA", + "Roles": [ + { + "Ref": "EcsClusterasgSpotInstanceRole84AB6F93" + } + ] + } + }, + "EcsClusterasgSpotInstanceProfile0D6DD08D": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "EcsClusterasgSpotInstanceRole84AB6F93" + } + ] + } + }, + "EcsClusterasgSpotLaunchConfig75BCA823": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "c5.xlarge", + "IamInstanceProfile": { + "Ref": "EcsClusterasgSpotInstanceProfile0D6DD08D" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "EcsClusterasgSpotInstanceSecurityGroupEA17787D", + "GroupId" + ] + } + ], + "SpotPrice": "0.0735", + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "EcsCluster97242B84" + }, + " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config\necho ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config" + ] + ] + } + } + }, + "DependsOn": [ + "EcsClusterasgSpotInstanceRoleDefaultPolicyB1E3ABFA", + "EcsClusterasgSpotInstanceRole84AB6F93" + ] + }, + "EcsClusterasgSpotASG0D77F041": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "2", + "MinSize": "2", + "DesiredCapacity": "2", + "LaunchConfigurationName": { + "Ref": "EcsClusterasgSpotLaunchConfig75BCA823" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "EcsClusterasgSpotDrainECSHookFunctionServiceRole8EEDDFE0": { + "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" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ] + } + }, + "EcsClusterasgSpotDrainECSHookFunctionServiceRoleDefaultPolicy96377D7C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeHosts" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "autoscaling:CompleteLifecycleAction", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":autoscaling:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":autoScalingGroup:*:autoScalingGroupName/", + { + "Ref": "EcsClusterasgSpotASG0D77F041" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgSpotDrainECSHookFunctionServiceRoleDefaultPolicy96377D7C", + "Roles": [ + { + "Ref": "EcsClusterasgSpotDrainECSHookFunctionServiceRole8EEDDFE0" + } + ] + } + }, + "EcsClusterasgSpotDrainECSHookFunction969F1553": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" + }, + "Handler": "index.lambda_handler", + "Role": { + "Fn::GetAtt": [ + "EcsClusterasgSpotDrainECSHookFunctionServiceRole8EEDDFE0", + "Arn" + ] + }, + "Runtime": "python3.6", + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "EcsCluster97242B84" + } + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "EcsClusterasgSpotDrainECSHookFunctionServiceRoleDefaultPolicy96377D7C", + "EcsClusterasgSpotDrainECSHookFunctionServiceRole8EEDDFE0" + ] + }, + "EcsClusterasgSpotDrainECSHookFunctionAllowInvokeawsecsintegspotEcsClusterasgSpotLifecycleHookDrainHookTopic92E2845E8BD3FE4E": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "EcsClusterasgSpotDrainECSHookFunction969F1553", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "EcsClusterasgSpotLifecycleHookDrainHookTopic6212EC83" + } + } + }, + "EcsClusterasgSpotDrainECSHookFunctionTopic9648CAD4": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "EcsClusterasgSpotLifecycleHookDrainHookTopic6212EC83" + }, + "Endpoint": { + "Fn::GetAtt": [ + "EcsClusterasgSpotDrainECSHookFunction969F1553", + "Arn" + ] + } + } + }, + "EcsClusterasgSpotLifecycleHookDrainHookRole1B427C77": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgSpot" + } + ] + } + }, + "EcsClusterasgSpotLifecycleHookDrainHookRoleDefaultPolicyFC0E3482": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "EcsClusterasgSpotLifecycleHookDrainHookTopic6212EC83" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgSpotLifecycleHookDrainHookRoleDefaultPolicyFC0E3482", + "Roles": [ + { + "Ref": "EcsClusterasgSpotLifecycleHookDrainHookRole1B427C77" + } + ] + } + }, + "EcsClusterasgSpotLifecycleHookDrainHookTopic6212EC83": { + "Type": "AWS::SNS::Topic" + }, + "EcsClusterasgSpotLifecycleHookDrainHook91178D34": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "EcsClusterasgSpotASG0D77F041" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "EcsClusterasgSpotLifecycleHookDrainHookTopic6212EC83" + }, + "RoleARN": { + "Fn::GetAtt": [ + "EcsClusterasgSpotLifecycleHookDrainHookRole1B427C77", + "Arn" + ] + } + }, + "DependsOn": [ + "EcsClusterasgSpotLifecycleHookDrainHookRoleDefaultPolicyFC0E3482", + "EcsClusterasgSpotLifecycleHookDrainHookRole1B427C77" + ] + }, + "EcsClusterasgOdInstanceSecurityGroup301DFBED": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ-spot/EcsCluster/asgOd/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "EcsClusterasgOdInstanceRoleC8290533": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ] + } + }, + "EcsClusterasgOdInstanceRoleDefaultPolicy0AE7FAB2": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgOdInstanceRoleDefaultPolicy0AE7FAB2", + "Roles": [ + { + "Ref": "EcsClusterasgOdInstanceRoleC8290533" + } + ] + } + }, + "EcsClusterasgOdInstanceProfileE5B88756": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "EcsClusterasgOdInstanceRoleC8290533" + } + ] + } + }, + "EcsClusterasgOdLaunchConfigD3B9E271": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t3.large", + "IamInstanceProfile": { + "Ref": "EcsClusterasgOdInstanceProfileE5B88756" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "EcsClusterasgOdInstanceSecurityGroup301DFBED", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "EcsCluster97242B84" + }, + " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config" + ] + ] + } + } + }, + "DependsOn": [ + "EcsClusterasgOdInstanceRoleDefaultPolicy0AE7FAB2", + "EcsClusterasgOdInstanceRoleC8290533" + ] + }, + "EcsClusterasgOdASG0E5C30EC": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "2", + "MinSize": "1", + "DesiredCapacity": "1", + "LaunchConfigurationName": { + "Ref": "EcsClusterasgOdLaunchConfigD3B9E271" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "EcsClusterasgOdDrainECSHookFunctionServiceRoleFC088D55": { + "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" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ] + } + }, + "EcsClusterasgOdDrainECSHookFunctionServiceRoleDefaultPolicyE54F1794": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeHosts" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "autoscaling:CompleteLifecycleAction", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":autoscaling:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":autoScalingGroup:*:autoScalingGroupName/", + { + "Ref": "EcsClusterasgOdASG0E5C30EC" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgOdDrainECSHookFunctionServiceRoleDefaultPolicyE54F1794", + "Roles": [ + { + "Ref": "EcsClusterasgOdDrainECSHookFunctionServiceRoleFC088D55" + } + ] + } + }, + "EcsClusterasgOdDrainECSHookFunction962490E0": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" + }, + "Handler": "index.lambda_handler", + "Role": { + "Fn::GetAtt": [ + "EcsClusterasgOdDrainECSHookFunctionServiceRoleFC088D55", + "Arn" + ] + }, + "Runtime": "python3.6", + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "EcsCluster97242B84" + } + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "EcsClusterasgOdDrainECSHookFunctionServiceRoleDefaultPolicyE54F1794", + "EcsClusterasgOdDrainECSHookFunctionServiceRoleFC088D55" + ] + }, + "EcsClusterasgOdDrainECSHookFunctionAllowInvokeawsecsintegspotEcsClusterasgOdLifecycleHookDrainHookTopicB293D7D8B41B2D12": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "EcsClusterasgOdDrainECSHookFunction962490E0", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "EcsClusterasgOdLifecycleHookDrainHookTopic673CE202" + } + } + }, + "EcsClusterasgOdDrainECSHookFunctionTopicE6BE4000": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "EcsClusterasgOdLifecycleHookDrainHookTopic673CE202" + }, + "Endpoint": { + "Fn::GetAtt": [ + "EcsClusterasgOdDrainECSHookFunction962490E0", + "Arn" + ] + } + } + }, + "EcsClusterasgOdLifecycleHookDrainHookRole695B2DF1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-spot/EcsCluster/asgOd" + } + ] + } + }, + "EcsClusterasgOdLifecycleHookDrainHookRoleDefaultPolicy85FA949A": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "EcsClusterasgOdLifecycleHookDrainHookTopic673CE202" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterasgOdLifecycleHookDrainHookRoleDefaultPolicy85FA949A", + "Roles": [ + { + "Ref": "EcsClusterasgOdLifecycleHookDrainHookRole695B2DF1" + } + ] + } + }, + "EcsClusterasgOdLifecycleHookDrainHookTopic673CE202": { + "Type": "AWS::SNS::Topic" + }, + "EcsClusterasgOdLifecycleHookDrainHook03AC5A9E": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "EcsClusterasgOdASG0E5C30EC" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "EcsClusterasgOdLifecycleHookDrainHookTopic673CE202" + }, + "RoleARN": { + "Fn::GetAtt": [ + "EcsClusterasgOdLifecycleHookDrainHookRole695B2DF1", + "Arn" + ] + } + }, + "DependsOn": [ + "EcsClusterasgOdLifecycleHookDrainHookRoleDefaultPolicy85FA949A", + "EcsClusterasgOdLifecycleHookDrainHookRole695B2DF1" + ] + }, + "TaskTaskRoleE98524A1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "Task79114B6B": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "Image": "amazon/amazon-ecs-sample", + "Memory": 512, + "Name": "PHP", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 0, + "Protocol": "tcp" + } + ] + } + ], + "Family": "awsecsintegspotTask1789BE14", + "NetworkMode": "bridge", + "RequiresCompatibilities": [ + "EC2" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskTaskRoleE98524A1", + "Arn" + ] + } + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "TaskDefinition": { + "Ref": "Task79114B6B" + }, + "Cluster": { + "Ref": "EcsCluster97242B84" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 1, + "EnableECSManagedTags": false, + "LaunchType": "EC2", + "SchedulingStrategy": "REPLICA" + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.ts new file mode 100644 index 0000000000000..59acbb2bbd8c7 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.spot-drain.ts @@ -0,0 +1,44 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/core'); +import ecs = require('../../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ-spot'); + +const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + +const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + +cluster.addCapacity('asgSpot', { + maxCapacity: 2, + minCapacity: 2, + desiredCapacity: 2, + instanceType: new ec2.InstanceType('c5.xlarge'), + spotPrice: '0.0735', + spotInstanceDraining: true, +}); + +cluster.addCapacity('asgOd', { + maxCapacity: 2, + minCapacity: 1, + desiredCapacity: 1, + instanceType: new ec2.InstanceType('t3.large'), +}); + +const taskDefinition = new ecs.TaskDefinition(stack, 'Task', { + compatibility: ecs.Compatibility.EC2 +}); + +taskDefinition.addContainer('PHP', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + memoryLimitMiB: 512, +}).addPortMappings({ + containerPort: 80, +}); + +new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts index 9ee77c1021011..8f604e84a60b7 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -10,7 +10,7 @@ export = { "When creating an ECS Cluster": { "with no properties set, it correctly sets default properties"(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new cdk.Stack(); const cluster = new ecs.Cluster(stack, 'EcsCluster'); cluster.addCapacity('DefaultAutoScalingGroup', { @@ -110,7 +110,7 @@ export = { })); expect(stack).to(haveResource("AWS::IAM::Role", { - AssumeRolePolicyDocument: { + AssumeRolePolicyDocument: { Statement: [ { Action: "sts:AssumeRole", @@ -153,7 +153,7 @@ export = { "with only vpc set, it correctly sets default properties"(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc, @@ -256,7 +256,7 @@ export = { })); expect(stack).to(haveResource("AWS::IAM::Role", { - AssumeRolePolicyDocument: { + AssumeRolePolicyDocument: { Statement: [ { Action: "sts:AssumeRole", @@ -299,7 +299,7 @@ export = { "multiple clusters with default capacity"(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); // WHEN @@ -315,7 +315,7 @@ export = { 'lifecycle hook is automatically added'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc, @@ -333,7 +333,7 @@ export = { DefaultResult: "CONTINUE", HeartbeatTimeout: 300, NotificationTargetARN: { Ref: "EcsClusterDefaultAutoScalingGroupLifecycleHookDrainHookTopicACD2D4A4" }, - RoleARN: { "Fn::GetAtt": [ "EcsClusterDefaultAutoScalingGroupLifecycleHookDrainHookRoleA38EC83B", "Arn" ] } + RoleARN: { "Fn::GetAtt": ["EcsClusterDefaultAutoScalingGroupLifecycleHookDrainHookRoleA38EC83B", "Arn"] } })); expect(stack).to(haveResource('AWS::Lambda::Function', { @@ -444,7 +444,7 @@ export = { "with capacity and cloudmap namespace properties set"(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); new ecs.Cluster(stack, 'EcsCluster', { vpc, @@ -459,9 +459,9 @@ export = { // THEN expect(stack).to(haveResource("AWS::ServiceDiscovery::PrivateDnsNamespace", { Name: 'foo.com', - Vpc: { - Ref: 'MyVpcF9F0CA6F' - } + Vpc: { + Ref: 'MyVpcF9F0CA6F' + } })); expect(stack).to(haveResource("AWS::ECS::Cluster")); @@ -557,7 +557,7 @@ export = { })); expect(stack).to(haveResource("AWS::IAM::Role", { - AssumeRolePolicyDocument: { + AssumeRolePolicyDocument: { Statement: [ { Action: "sts:AssumeRole", @@ -803,7 +803,7 @@ export = { const stack = new cdk.Stack(); test.equal(ecs.EcsOptimizedImage.amazonLinux().getImage(stack).osType, - ec2.OperatingSystemType.LINUX); + ec2.OperatingSystemType.LINUX); test.done(); }, @@ -813,7 +813,7 @@ export = { const stack = new cdk.Stack(); test.equal(ecs.EcsOptimizedImage.amazonLinux2().getImage(stack).osType, - ec2.OperatingSystemType.LINUX); + ec2.OperatingSystemType.LINUX); test.done(); }, @@ -823,7 +823,7 @@ export = { const stack = new cdk.Stack(); test.equal(ecs.EcsOptimizedImage.windows(ecs.WindowsOptimizedVersion.SERVER_2019).getImage(stack).osType, - ec2.OperatingSystemType.WINDOWS); + ec2.OperatingSystemType.WINDOWS); test.done(); }, @@ -957,6 +957,39 @@ export = { test.done(); }, + "allows specifying automated spot draining"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { + instanceType: new ec2.InstanceType('c5.xlarge'), + spotPrice: '0.0735', + spotInstanceDraining: true + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { + UserData: { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + Ref: "EcsCluster97242B84" + }, + " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config\necho ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config" + ] + ] + } + } + })); + + test.done(); + }, + "allows containers access to instance metadata service"(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -1006,10 +1039,10 @@ export = { // THEN expect(stack).to(haveResource("AWS::ServiceDiscovery::PrivateDnsNamespace", { - Name: 'foo.com', - Vpc: { - Ref: 'MyVpcF9F0CA6F' - } + Name: 'foo.com', + Vpc: { + Ref: 'MyVpcF9F0CA6F' + } })); test.done(); @@ -1033,7 +1066,7 @@ export = { // THEN expect(stack).to(haveResource("AWS::ServiceDiscovery::PublicDnsNamespace", { - Name: 'foo.com', + Name: 'foo.com', })); test.equal(cluster.defaultCloudMapNamespace!.type, cloudmap.NamespaceType.DNS_PUBLIC);