How to create AWS ECS cluster using amazon cloud development kit (CDK)?
Welcome to the first installment in a series on deploying a SpringBoot application on AWS Elastic Container Service (ECS) using the AWS Cloud Development Kit (CDK) and GitLab CI/CD. In this article, we’ll walk through the necessary steps to set up the underlying infrastructure to run our SpringBoot application efficiently.
This includes:
We will be using AWS CDK for Java to automate the platform setup process so that we can re-create exact same setup every single time without having to manually repeat all the required steps.
To kickstart the process, assuming you already have AWS CDK and AWS CLI installed on your machine, run the following command to create a new java project:
cdk init app --language java my-app-infra
Please refer to respective documentations for latest versions and setup details.
We manage various configurable values in a YAML file mapped to corresponding POJOs using an instance of ObjectMapper
and YamlFactory
.
ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());
objectMapper.readValue(this.getClass()
.getClassLoader()
.getResourceAsStream("application.yml"),
DeploymentConfig.class);
Where DeploymentConfig
maps to the below mentioned yaml file’s structure.
infra:
vpc:
id: vpc-xxxxxx
ecr:
name: my-ecr # container registry name
tag: latest # image tag to be used
ecs:
cluster:
name: my-cdk-cluster
taskDefinition:
name: my-cdk-task
executionRole: ecsTaskExecutionRole
executionRoleArn: arn:aws:iam::****:role/ecsTaskExecutionRole
cpu: 512
memoryReservation: 512
memory: 1024
container:
name: my-cdk-app
port: 8081
healthCheck:
command:
- CMD-SHELL
- curl -f http://localhost:8081/ping/ || exit 1
interval: 5
timeout: 5
retries: 3
service:
name: my-cdk-svc
desiredCount: 1
launchType: FARGATE
deploymentConfiguration:
maxHealthyPercent: 100
minHealthyPercent: 100
networkConfiguration:
subnets:
- subnet-xxxx
- subnet-xxxx
- subnet-xxxx
securityGroups:
- sg-xxxxxx
Instead of creating a new VPC, we’ll utilize the existing default VPC associated with our account to save time. The VPC ID
can be obtained from the AWS console and is configured under infra.vpc.id: vpc-xxxxxx
key in the yaml config file.
private IVpc getVpc() {
String id = infra.vpc().id();
return Vpc.fromLookup(this, id, VpcLookupOptions.builder().vpcId(id).build());
}
Next, we create a Fargate-backed ECS cluster, binding the VPC details obtained earlier to the same. The cluster name is mapped to infra.ecs.cluster.name
:
@NotNull
private Cluster createCluster(IVpc iVpc) {
String clusterName = infra.ecs().cluster().name();
return Cluster.Builder.create(this, clusterName)
.clusterName(clusterName)
.vpc(iVpc)
.enableFargateCapacityProviders(true)
.build();
}
The task definition provides a blueprint of our execution environment, including CPU and memory allocation, container definition, health checks, and logging configurations. The corresponding configurations are mapped under key infra.ecs.taskDefinition
in the yaml config file.
Additionally, this step expects that you already have a SpringBoot application (any runnable image for that matter) docker image pushed and tagged (infra.ecr.tag
) in the ECR repository (infra.ecr.name
).
Note: In the next post, we will create a GitLab CI pipeline that will push updated image to the ECR repository on every new commit. But for now, please upload it manually as a one time step to continue.
@NotNull
private FargateTaskDefinition createTaskDefinition() {
var ecsTaskDefinition = infra.ecs().taskDefinition();
var taskDefinition = FargateTaskDefinition.Builder.create(this, ecsTaskDefinition.name())
.cpu(ecsTaskDefinition.cpu())
.memoryLimitMiB(ecsTaskDefinition.memory())
.taskRole(null)
.family(ecsTaskDefinition.name())
.runtimePlatform(RuntimePlatform.builder()
.operatingSystemFamily(OperatingSystemFamily.LINUX)
.cpuArchitecture(CpuArchitecture.X86_64)
.build())
.executionRole(Role.fromRoleArn(this, ecsTaskDefinition.executionRole(), ecsTaskDefinition.executionRoleArn()))
.build();
taskDefinition.addContainer(ecsTaskDefinition.container().name(), buildContainerDefinitionOptions().build());
return taskDefinition;
}
@NotNull
private ContainerDefinitionOptions.Builder buildContainerDefinitionOptions() {
var ecr = infra.ecr();
var ecsTaskDefinition = infra.ecs().taskDefinition();
var container = ecsTaskDefinition.container();
var containerHealthCheck = container.healthCheck();
return ContainerDefinitionOptions.builder()
.essential(true)
.image(ContainerImage.fromEcrRepository(Repository.fromRepositoryName(this, ecr.name(), ecr.name()), ecr.tag()))
.portMappings(List.of(PortMapping.builder().containerPort(container.port()).hostPort(container.port()).build()))
.cpu(ecsTaskDefinition.cpu())
.memoryReservationMiB(ecsTaskDefinition.memoryReservation())
.memoryLimitMiB(ecsTaskDefinition.memory())
.healthCheck(HealthCheck.builder()
.command(containerHealthCheck.command())
.interval(Duration.seconds(containerHealthCheck.interval()))
.timeout(Duration.seconds(containerHealthCheck.timeout()))
.retries(containerHealthCheck.retries())
.build())
.containerName(container.name())
.logging(LogDriver.awsLogs(AwsLogDriverProps.builder()
.streamPrefix(container.name())
.logRetention(RetentionDays.ONE_DAY)
.mode(AwsLogDriverMode.NON_BLOCKING)
.build()));
}
The image specification Repository.fromRepositoryName(this, ecr.name(), ecr.name()), ecr.tag())
expects that the details provided are valid and necessary permissions to access the same are in place.
Additionally, the healthcheck query as mentioned in the yaml file is used to identify the startup status.
As a final step, we bind together the FargateTaskDefinition
and Cluster
instances to create an ECS service instance. The .assignPublicIp(true)
ensures that the cluster is assigned a public IP address which is reachable from the internet.
private void createService(Cluster cluster, FargateTaskDefinition taskDefinition) {
var service = infra.ecs().service();
var ecsServiceDeploymentConfig = service.deploymentConfiguration();
FargateService.Builder.create(this, service.name())
.serviceName(service.name())
.securityGroups(service.networkConfiguration()
.securityGroups()
.stream()
.map(sg -> SecurityGroup.fromSecurityGroupId(this, sg, sg))
.toList())
.cluster(cluster)
.taskDefinition(taskDefinition)
.desiredCount(service.desiredCount())
.minHealthyPercent(ecsServiceDeploymentConfig.minHealthyPercent())
.maxHealthyPercent(ecsServiceDeploymentConfig.maxHealthyPercent())
// Public subnets if `assignPublicIp` is set, otherwise the first
// available one of Private, Isolated, Public, in that order.
.assignPublicIp(true)
.capacityProviderStrategies(List.of(CapacityProviderStrategy.builder()
.capacityProvider(service.launchType())
.base(0)
.weight(1)
.build()))
.build();
}
Assuming CDK and AWS CLI are already installed, run the following command(s) to deploy the stack from within the my-app-infra
java project:
# export the AWS profile
export CDK_DEFAULT_REGION=*********
export CDK_DEFAULT_ACCOUNT=*********
# deploy the stack
cdk synth
cdk bootstrap --force
cdk deploy
This will create the required infrastructure in the AWS account.
To destroy the stack, run the following command:
cdk destroy --force
That is all for this post. If you want to share any feedback, please drop me an email, or contact me on any social platforms. I’ll try to respond at the earliest. Also, please consider subscribing feed for regular updates.
Be notified of new posts. Subscribe to the RSS feed.