SpringBoot application on AWS ECS - #2

How to deploy a SpringBoot docker image to ECR from GitLab pipeline?

This is the second installment of our series on deploying a Spring Boot application on AWS Elastic Container Service (ECS) using the AWS Cloud Development Kit (CDK) and Gitlab CI/CD. In the previous post, we created an ECS infrastructure using the AWS CLI and AWS CDK capable of hosting our application.

In this post, we will automate the deployment step using a GitLab pipeline for continuous deployment.

Maven Build

Assuming all code quality, unit tests, and vulnerability scan jobs are configured as required, the following job will run a Maven build using a Java 17 image.

maven-build:
  stage: build
  image: maven:3.9.6-eclipse-temurin-17-alpine
  script:
    - mvn -s ci-settings.xml -Dmaven.repo.local=.m2/repository clean install -DskipTests
  artifacts:
    expire_in: 2 days
    paths:
      - ./target/

The ci-settings.xml contains an entry to provide authentication details to interact with the GitLab hosted repository for storing the generated artifacts (in case of Maven deploy).

<servers>
  <server>
    <id>gitlab-maven</id>
    <configuration>
      <httpHeaders>
        <property>
            <name>Job-Token</name>
            <value>${CI_JOB_TOKEN}</value>
        </property>
      </httpHeaders>
    </configuration>
  </server>
</servers>

Image Build

Once the Maven build is complete and an executable Spring Boot jar is created, the next step is to build a Docker image for the same and upload it to the Amazon Elastic Container Registry (ECR).

This step expects that you have configured the following values as pipeline variables:

  1. AWS_ACCESS_KEY_ID: Used to programmatically access AWS services.
  2. AWS_SECRET_ACCESS_KEY: Used to programmatically access AWS services.
  3. AWS_ACCOUNT_ID: Unique identifier for your account.
  4. AWS_DEFAULT_REGION: Default deployment region.
docker-build:
  # Only on master branch, the container image would be generated
  # if the variable is set true or the commit message contains
  # 'CI BUILD IMAGE'.
  rules:
    - if: $CI_COMMIT_REF_NAME == "master" && ( $CREATE_CONTAINER_IMAGE == "true" || $CI_COMMIT_MESSAGE =~ /CI BUILD IMAGE/i )
      when: always
  image: docker:stable
  needs:
    - maven-build
  stage: build
  services:
    - docker:dind
  before_script:
    - apk add --no-cache python3 py3-pip
    - pip3 install --no-cache-dir awscli
  script:
    - aws ecr get-login-password --region $AWS_DEFAULT_REGION |
      docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
    - docker pull $TAG_LATEST || true
    - docker build --cache-from $TAG_LATEST -t $TAG_LATEST .
    - docker push $TAG_LATEST

ECS Deploy

The next step is to trigger a deployment on ECS. This will recreate our container, possibly changing the public IP address. To handle this, we reset the Route53 record to point to the new IP address.

Following are the high level steps performed in the ecs-deploy job executed using amazon/aws-cli image:

  1. Update the ECS service and force a new deployment with the specified desired instance count.
  2. Wait for some time to let the container health-check turn green.
  3. Fetch the new public IP address of the container.
  4. Update the Route53 entry to point to new IP.
ecs-deploy:
  stage: deploy
  # Only on master branch, the container image would be generated
  # if the variable is set true or the commit message contains
  # 'CI DEPLOY IMAGE'.
  rules:
    - if: $CI_COMMIT_REF_NAME == "master" && ( $DEPLOY_CONTAINER_IMAGE == "true" || $CI_COMMIT_MESSAGE =~ /CI DEPLOY IMAGE/i )
      when: always
  needs:
    - docker-build
  image:
    name: amazon/aws-cli
    entrypoint: [""]
  before_script:
    - yum -y install jq
    - aws configure set region $AWS_DEFAULT_REGION
  script:
    # update the service with the new task definition and desired count
    - echo "Updating the service to use latest IMAGE..."
    - aws ecs update-service --region $AWS_DEFAULT_REGION --cluster "$ECS_CLUSTER_NAME" --service "$ECS_SERVICE_NAME"  --task-definition "$ECS_TASK_DEFINITION_NAME" --force-new-deployment --desired-count 1

    - echo sleeping for 180 seconds to allow the service to be deployed
    - sleep 180

    - TASK_ARN=$(aws ecs list-tasks --cluster "$ECS_CLUSTER_NAME" --service-name "$ECS_SERVICE_NAME" --query 'taskArns[0]' --output text)
    - TASK_DETAILS=$(aws ecs describe-tasks --task "$TASK_ARN" --cluster=$ECS_CLUSTER_NAME --query 'tasks[0].attachments[0].details')
    - ENI=$(echo $TASK_DETAILS | jq -r '.[] | select(.name=="networkInterfaceId").value')
    - PUBLIC_IP=$(aws ec2 describe-network-interfaces --network-interface-ids $ENI --query 'NetworkInterfaces[0].Association.PublicIp' --output text)
    - echo "Public IP address is $PUBLIC_IP"

    - echo "Updating DNS record with the new IP address"
    - EXISTING_IP=$(aws route53 list-resource-record-sets --hosted-zone-id $HOSTED_ZONE_ID --query "ResourceRecordSets[?Name == '"$R53_HOSTNAME."'].ResourceRecords[0].Value" --output text)
    - echo "Existing IP address is $EXISTING_IP"

    - aws route53 change-resource-record-sets --hosted-zone-id $HOSTED_ZONE_ID --change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"'$R53_HOSTNAME'","Type":"A","TTL":300,"ResourceRecords":[{"Value":"'$PUBLIC_IP'"}]}}]}'
    - echo "DNS record updated successfully to $PUBLIC_IP; Please wait for sometime for the DNS to propagate"

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.