SpringBoot application on AWS ECS - #1

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:

  1. Setup Fargate backed ECS cluster.
  2. VPC Setup.
  3. Task Definition.
  4. Setup ECS Service.

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.

Java Project Setup

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.

Config Setup

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

VPC Setup

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());
}

Create Fargate backed ECS cluster

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();
}

Task Definition

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.

ECS Service

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();
}

Deploy Stack

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.

  • You can check the status of the stack in the CloudFormation console.
  • A json file with the output of the stack will be created in the cdk.out directory.
  • A json file is created in the S3 bucket (cdk–assets–***) with a random name that contains the output of the stack.
  • The application can be accessed using the public IP of the Fargate service.

Destroy

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.