DEV Community

Cover image for Containers, Containers, Containers!
EduardoCusihuaman
EduardoCusihuaman

Posted on

Containers, Containers, Containers!

Yep, you already know how to build your container. But now you want to get that container running on AWS with as little fuss as possible, right? No problem.

In this quick guide, I'll show you how to deploy your container to AWS Fargate using CloudFormation. It’s fast, easy, and doesn’t require you to wade through endless steps. Just the essentials to get your container online.

SI

Before We Begin

We’re using CloudFormation here because it’s perfect for quickly spinning up your infrastructure with minimal hassle. Sure, there are other tools like Terraform or CDK, but for a straightforward ECS setup, CloudFormation is your go-to.

We’re keeping it simple: we’ll create a VPC, a couple of public subnets, an ECS cluster, and a load balancer to get your containers up and running. No need for extra complexity—just the basics to get your containerized app deployed fast. And yes, we’ve got IAM roles to keep everything secure. Let’s dive in!

Table of Contents

  1. Architecture Boring Stuff
  2. LET'S KICK IT OFF!
  3. Pricing
  4. Conclusion

This table of contents should make it easy to navigate through the guide.

Architecture Boring Stuff

aws-architecture

User Interaction

  1. User Request: A user sends an HTTP request to the Application Load Balancer (ALB) (ALBDNSName).
  2. ALB Routing: The ALB routes the request to the ECS service, which runs your containerized application.
  3. ECS Task Handling: The ECS tasks, running within the Fargate cluster, handle the request, with containers managed by ECS.

ECS and Load Balancer

  • ECS Fargate Cluster (ECSFargateCluster): Manages and scales the containers based on defined parameters.
  • Application Load Balancer (ALB): Distributes incoming traffic to the appropriate ECS tasks, ensuring availability and responsiveness.
  • Target Group (ALBTargetGroup): Monitors the health of your ECS tasks and directs traffic only to healthy instances.

IAM Roles

  • IAMRoleForECS: Grants permissions for ECS tasks to pull images from ECR, send logs to CloudWatch, and perform other necessary actions.
  • ECRCleanupLambdaRole: Allows the Lambda function to delete images from the ECR repository during the cleanup process.

Pipeline User for GitHub Actions

The GitHub Actions workflow uses an IAM user (PipelineUser) to deploy updates to the ECS service. The workflow includes steps for:

  • Checking out the code.
  • Building the container image.
  • Pushing the image to ECR.
  • Configuring AWS credentials.
  • Updating the ECS task definition with the new image.
  • Triggering a new deployment of the ECS service.

Flow

  1. User -> Application Load Balancer -> ECS Service -> ECS Task (Container)
  2. ECS Tasks -> Fargate Cluster (for running containers)
  3. Pipeline User -> GitHub Actions -> ECR and ECS (for deployment)

cloudformation-template.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: AWS ECS Infrastructure setup using a new VPC

Parameters:
    VpcCidr:
        Description: "CIDR block for the VPC"
        Type: String
        Default: "30.0.0.0/16"

    PublicSubnet1Cidr:
        Description: "CIDR block for the public subnet 1"
        Type: String
        Default: "30.0.1.0/24"

    PublicSubnet2Cidr:
        Description: "CIDR block for the public subnet 2"
        Type: String
        Default: "30.0.2.0/24"

    ECRRepositoryName:
        Description: The name of the ECR repository
        Type: String
        Default: "application-repository"

    ClusterName:
        Description: The name of the ECS Fargate Cluster
        Type: String
        Default: "EcsFargateCluster"

    ServiceName:
        Description: The name of the ECS Service
        Type: String
        Default: "EcsService"

    TaskFamilyName:
        Description: The family name of the ECS Task Definition
        Type: String
        Default: "TaskDefinition"

    ContainerName:
        Description: The name of the container in the ECS Task Definition
        Type: String
        Default: "application-container"

Resources:
    # VPC and Networking resources
    VPC:
        Type: AWS::EC2::VPC
        Properties:
            CidrBlock: !Ref VpcCidr
            EnableDnsSupport: true
            EnableDnsHostnames: true
            Tags:
                - Key: Name
                  Value: vpc

    # Internet Gateway
    InternetGateway:
        Type: AWS::EC2::InternetGateway
        Properties:
            Tags:
                - Key: Name
                  Value: igw

    AttachGateway:
        Type: AWS::EC2::VPCGatewayAttachment
        Properties:
            VpcId: !Ref VPC
            InternetGatewayId: !Ref InternetGateway

    # Public Subnets
    PublicSubnet1:
        Type: AWS::EC2::Subnet
        Properties:
            VpcId: !Ref VPC
            CidrBlock: !Ref PublicSubnet1Cidr
            AvailabilityZone: !Select
                - 0
                - Fn::GetAZs: !Ref "AWS::Region"
            MapPublicIpOnLaunch: true
            Tags:
                - Key: Name
                  Value: public-subnet-1

    PublicSubnet2:
        Type: AWS::EC2::Subnet
        Properties:
            VpcId: !Ref VPC
            CidrBlock: !Ref PublicSubnet2Cidr
            AvailabilityZone: !Select
                - 1
                - Fn::GetAZs: !Ref "AWS::Region"
            MapPublicIpOnLaunch: true
            Tags:
                - Key: Name
                  Value: public-subnet-2

    # Route Tables and Routes
    PublicRouteTable:
        Type: AWS::EC2::RouteTable
        Properties:
            VpcId: !Ref VPC
            Tags:
                - Key: Name
                  Value: public-rt

    PublicRoute:
        Type: AWS::EC2::Route
        Properties:
            RouteTableId: !Ref PublicRouteTable
            DestinationCidrBlock: 0.0.0.0/0
            GatewayId: !Ref InternetGateway

    PublicSubnet1RouteTableAssociation:
        Type: AWS::EC2::SubnetRouteTableAssociation
        Properties:
            SubnetId: !Ref PublicSubnet1
            RouteTableId: !Ref PublicRouteTable

    PublicSubnet2RouteTableAssociation:
        Type: AWS::EC2::SubnetRouteTableAssociation
        Properties:
            SubnetId: !Ref PublicSubnet2
            RouteTableId: !Ref PublicRouteTable

    # Security Group
    SecurityGroup:
        Type: AWS::EC2::SecurityGroup
        Properties:
            GroupDescription: "Default security group for the VPC"
            VpcId: !Ref VPC
            SecurityGroupIngress:
                - IpProtocol: tcp
                  FromPort: 80
                  ToPort: 80
                  CidrIp: 0.0.0.0/0
                - IpProtocol: tcp
                  FromPort: 443
                  ToPort: 443
                  CidrIp: 0.0.0.0/0
                - IpProtocol: tcp
                  FromPort: 22
                  ToPort: 22
                  CidrIp: 0.0.0.0/0
            SecurityGroupEgress:
                - IpProtocol: "-1"
                  CidrIp: 0.0.0.0/0
            Tags:
                - Key: Name
                  Value: default-sg

    # ECS Fargate Cluster
    ECSFargateCluster:
        Type: AWS::ECS::Cluster
        Properties:
            ClusterName: !Ref ClusterName

    # ECR Repository
    ECRRepository:
        Type: AWS::ECR::Repository
        Properties:
            RepositoryName: !Ref ECRRepositoryName

    # Custom Resource Hook for ECR Cleanup
    ECRCleanupHook:
        Type: "Custom::ECRCleanup"
        Properties:
            ServiceToken: !GetAtt ECRCleanupLambdaFunction.Arn
            RepositoryName: !Ref ECRRepositoryName
        DependsOn: ECRRepository

    # ECS Task Definition
    ECSTaskDefinition:
        Type: AWS::ECS::TaskDefinition
        Properties:
            Family: !Ref TaskFamilyName
            NetworkMode: awsvpc
            RequiresCompatibilities:
                - FARGATE
            Cpu: "512"
            Memory: "1024"
            ExecutionRoleArn: !Ref IAMRoleForECS
            TaskRoleArn: !Ref IAMRoleForECS
            ContainerDefinitions:
                - Name: !Ref ContainerName
                  Image: "nginx:latest"
                  PortMappings:
                      - ContainerPort: 80
                  Environment:
                      - Name: ENVIRONMENT
                        Value: "production"
                  LogConfiguration:
                      LogDriver: awslogs
                      Options:
                          awslogs-group: !Ref CloudWatchLogGroup
                          awslogs-region: !Ref "AWS::Region"
                          awslogs-stream-prefix: ecs

    # ECS Service
    ECSService:
        Type: AWS::ECS::Service
        DependsOn: ALB
        Properties:
            Cluster: !Ref ECSFargateCluster
            ServiceName: !Ref ServiceName
            DesiredCount: 2
            LaunchType: FARGATE
            TaskDefinition: !Ref ECSTaskDefinition
            NetworkConfiguration:
                AwsvpcConfiguration:
                    AssignPublicIp: ENABLED
                    SecurityGroups:
                        - !Ref SecurityGroup
                    Subnets:
                        - !Ref PublicSubnet1
                        - !Ref PublicSubnet2
            LoadBalancers:
                - ContainerName: !Ref ContainerName
                  ContainerPort: 80
                  TargetGroupArn: !Ref ALBTargetGroup

    # ALB
    ALB:
        Type: AWS::ElasticLoadBalancingV2::LoadBalancer
        Properties:
            Name: "ALB"
            Subnets:
                - !Ref PublicSubnet1
                - !Ref PublicSubnet2
            SecurityGroups:
                - !Ref SecurityGroup
            LoadBalancerAttributes:
                - Key: idle_timeout.timeout_seconds
                  Value: "60"

    ALBListener:
        Type: AWS::ElasticLoadBalancingV2::Listener
        Properties:
            LoadBalancerArn: !Ref ALB
            Port: 80
            Protocol: HTTP
            DefaultActions:
                - Type: forward
                  TargetGroupArn: !Ref ALBTargetGroup

    ALBTargetGroup:
        Type: AWS::ElasticLoadBalancingV2::TargetGroup
        Properties:
            VpcId: !Ref VPC
            Protocol: HTTP
            Port: 80
            TargetType: ip
            HealthCheckEnabled: true
            HealthCheckPath: "/" # Set to a valid endpoint in your application
            HealthCheckIntervalSeconds: 30
            HealthCheckTimeoutSeconds: 5
            HealthyThresholdCount: 2
            UnhealthyThresholdCount: 3
            Matcher:
                HttpCode: "200"
            Name: "TargetGroup"

    # IAM Role for ECS Task
    IAMRoleForECS:
        Type: AWS::IAM::Role
        Properties:
            AssumeRolePolicyDocument:
                Version: "2012-10-17"
                Statement:
                    - Effect: Allow
                      Principal:
                          Service:
                              - ecs-tasks.amazonaws.com
                      Action:
                          - sts:AssumeRole
            Policies:
                - PolicyName: "ECRPullPolicy"
                  PolicyDocument:
                      Version: "2012-10-17"
                      Statement:
                          - Effect: Allow
                            Action:
                                - ecr:GetAuthorizationToken
                                - ecr:GetDownloadUrlForLayer
                                - ecr:BatchGetImage
                                - ecr:BatchCheckLayerAvailability
                            Resource: "*"
                - PolicyName: "CloudWatchLogsPolicy"
                  PolicyDocument:
                      Version: "2012-10-17"
                      Statement:
                          - Effect: Allow
                            Action:
                                - logs:CreateLogStream
                                - logs:PutLogEvents
                                - logs:CreateLogGroup
                            Resource: "*"

    # CloudWatch Log Group
    CloudWatchLogGroup:
        Type: AWS::Logs::LogGroup
        Properties:
            LogGroupName: "/ecs/logs"
            RetentionInDays: 7

    # Lambda Function to Delete ECR Images
    ECRCleanupLambdaRole:
        Type: AWS::IAM::Role
        Properties:
            AssumeRolePolicyDocument:
                Version: "2012-10-17"
                Statement:
                    - Effect: Allow
                      Principal:
                          Service: lambda.amazonaws.com
                      Action: sts:AssumeRole
            Policies:
                - PolicyName: LambdaCloudWatchLogsPolicy
                  PolicyDocument:
                      Version: "2012-10-17"
                      Statement:
                          - Effect: Allow
                            Action:
                                - logs:CreateLogGroup
                                - logs:CreateLogStream
                                - logs:PutLogEvents
                            Resource:
                                - arn:aws:logs:*:*:*
                - PolicyName: ECRDeletePolicy
                  PolicyDocument:
                      Version: "2012-10-17"
                      Statement:
                          - Effect: Allow
                            Action:
                                - ecr:BatchDeleteImage
                                - ecr:ListImages
                            Resource: !Sub "arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${ECRRepositoryName}"

    ECRCleanupLambdaFunction:
        Type: AWS::Lambda::Function
        Properties:
            FunctionName: ecr-cleanup-lambda
            Handler: index.handler
            Runtime: python3.9
            Role: !GetAtt ECRCleanupLambdaRole.Arn
            Timeout: 60
            Code:
                ZipFile: |
                    import boto3
                    def handler(event, context):
                        if event['RequestType'] == 'Delete':
                            ecr = boto3.client('ecr')
                            images_to_delete = ecr.list_images(repositoryName=event['repository_name'])['imageIds']
                            if images_to_delete:
                                ecr.batch_delete_image(
                                    repositoryName=event['repository_name'],
                                    imageIds=images_to_delete
                                )
                        return 'Cleanup complete'

Outputs:
    VpcId:
        Value: !Ref VPC
        Description: The VPC Id where the ECS Cluster is deployed.
    ECSFargateCluster:
        Value: !Ref ECSFargateCluster
        Description: The ECS Fargate Cluster created by the template.
    ALBDNSName:
        Value: !GetAtt ALB.DNSName
        Description: The DNS name of the Application Load Balancer.
    ALBEndpoint:
        Value: !Sub "http://${ALB.DNSName}"
        Description: The HTTP endpoint of the Application Load Balancer.

Enter fullscreen mode Exit fullscreen mode

The same template without creating a new vpc:
cloudformation-template-with-existing-vpc.yaml

Note: It's important to edit the task's port if needed. By default, this is set up with a placeholder using nginx listening on port 80 and the health check is set to /. However, you can change these settings to fit your requirements.

LET'S KICK IT OFF!

Alright, buckle up! If you've got an AWS account and the AWS CLI locked and loaded, we're ready to roll. (Pro tip: jq is handy, but hey, we won't judge if you skip it.)

START

Step-01: Deploy CloudFormation Stack for ECS Fargate

export CLUSTER_NAME=EcsFargateCluster
export SERVICE_NAME=EcsService
export TASK_FAMILY_NAME=TaskDefinition
export REPOSITORY_NAME=application-repository
export CONTAINER_NAME=application-container
export STACK_NAME=aws-ecs-fargate-stack

aws cloudformation deploy \
    --stack-name $STACK_NAME \
    --template-file ecs-fargate-cloudformation.yaml \
    --parameter-overrides \
        ClusterName=$CLUSTER_NAME \
        ServiceName=$SERVICE_NAME \
        TaskFamilyName=$TASK_FAMILY_NAME \
        ECRRepositoryName=$REPOSITORY_NAME \
        ContainerName=$CONTAINER_NAME \
    --capabilities CAPABILITY_NAMED_IAM \
    --region us-east-1
Enter fullscreen mode Exit fullscreen mode

Note: The stack uses predefined default names for easy identification in the pipeline, u can override it:

  • ClusterName: EcsFargateCluster
  • ServiceName: EcsService
  • TaskFamilyName: TaskDefinition
  • ECRRepositoryName: application-repository
  • ContainerName: application-container

cloudformation-resources

Step-02: Create AWS IAM User for Pipeline

In this step, we'll create a Pipeline User that GitHub Actions will use. This user will have the necessary policies attached to push container images to ECR and update our ECS service. Here's how to do it:

# Set user name
export USER_NAME="codepipeline"

# Create IAM user
aws iam create-user --user-name $USER_NAME

# Attach necessary policies
# ⚠️ THIS IS NOT PRODUCTION READY - USE A LEAST PRIVILEGE ROLE INSTEAD
aws iam attach-user-policy --user-name $USER_NAME --policy-arn arn:aws:iam::aws:policy/AmazonECS_FullAccess
aws iam attach-user-policy --user-name $USER_NAME --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess

# Create access keys for the user
ACCESS_KEYS=$(aws iam create-access-key --user-name $USER_NAME)

# Extract AccessKeyId and SecretAccessKey
ACCESS_KEY_ID=$(echo $ACCESS_KEYS | jq -r '.AccessKey.AccessKeyId')
SECRET_ACCESS_KEY=$(echo $ACCESS_KEYS | jq -r '.AccessKey.SecretAccessKey')

# ⚠️ Output the Access Key details
echo "Access Key ID: $ACCESS_KEY_ID"
echo "Secret Access Key: $SECRET_ACCESS_KEY"
Enter fullscreen mode Exit fullscreen mode

Step-03: Add Secrets to GitHub Secrets

After creating the IAM user and generating the access keys, follow these steps to add these credentials to your GitHub
repository secrets for use in GitHub Actions:

  1. Navigate to Your GitHub Repository:

    • Go to the main page of your repository on GitHub.
  2. Access Repository Settings:

    • Click on the Settings tab at the top of the repository page.
  3. Navigate to Secrets:

    • In the left sidebar, click on Secrets and variables > Actions.
  4. Add New Repository Secrets:

    • Click New repository secret and add the following:
  • AWS_ACCESS_KEY_ID: Enter your AccessKeyId value.
  • AWS_SECRET_ACCESS_KEY: Enter your SecretAccessKey value.

secrets

By doing this, you'll securely store the necessary credentials for your GitHub Actions workflow.

Step-04: Github Actions

Time to automate the deployment with GitHub Actions! Here's how you can set up your pipeline:

Copy the following YAML content into .github/workflows/deploy.yml:

name: Build, Push to ECR, and Deploy to ECS Fargate

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout the latest code from the main branch
      - name: Checkout code
        uses: actions/checkout@v3

      # Step 2: Configure AWS credentials for the GitHub Actions runner
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1 # Change to your region

      # Step 3: Login to Amazon ECR to push Docker images
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # Step 4: Build the Docker image, tag it, and push it to Amazon ECR
      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: application-repository # Change this to your ECR repository name
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      # Step 5: Download the current ECS task definition to update it with the new image
      - name: Download current task definition
        run: |
          aws ecs describe-task-definition --task-definition TaskDefinition \ # Change this to your Task Family Name
          --query taskDefinition > ecs-task-definition.json

      # Step 6: Replace the image in the ECS task definition with the newly built image
      - name: Fill in the new image ID in the Amazon ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ecs-task-definition.json
          container-name: application-container # Change this to your container name
          image: ${{ steps.build-image.outputs.image }}

      # Step 7: Deploy the updated task definition to the ECS Fargate service
      - name: Deploy updated task definition to ECS Fargate
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: EcsService # Change this to your ECS service name
          cluster: EcsFargateCluster # Change this to your ECS Fargate cluster name
          wait-for-service-stability: true
Enter fullscreen mode Exit fullscreen mode

Important: The container image in the ECR repository is updated with each push to the main branch. The service name EcsService, cluster name EcsFargateCluster, and other values are predefined in your CloudFormation stack, ensuring smooth integration with your AWS infrastructure.

Now, commit and push your changes to trigger the pipeline:

git commit -am “feat(ci): deployment”
git push origin main
Enter fullscreen mode Exit fullscreen mode

Once pushed, the pipeline will automatically build, push your Docker image to ECR, and update your ECS Fargate service with the latest code. 🚀

github-action

Cleaning Up

To delete the CloudFormation stack, use the following command:

# Set the stack name
export STACK_NAME="aws-ecs-fargate-stack"

# Delete the CloudFormation stack
aws cloudformation delete-stack --stack-name $STACK_NAME

# Delete the IAM user
aws iam delete-user --user-name $USER_NAME
Enter fullscreen mode Exit fullscreen mode

BLAZINGGG ENJOYY 🎉🔥

Success GIF

You can see your pipeline triggered, the Docker image built, pushed to ECR, and your ECS Fargate service updated. This workflow will automatically deploy your changes to the Fargate cluster every time you push to the main branch. 🚀

ecs-service

To check if everything's working, grab the ALB DNS Name from the stack output or the AWS console and make an HTTP request to your application. 🌐

~/edu curl http://your-alb-dns-name/ # Replace with your actual ALB DNS name
Enter fullscreen mode Exit fullscreen mode

Pricing

Here’s an estimated cost breakdown for the AWS ECS infrastructure setup you provided, considering that you have a single ECS Fargate task running:

1. VPC and Subnets

  • VPC: No additional cost.
  • Subnets: No direct cost for subnets.

2. Internet Gateway

  • Cost: $0.045 per hour.

3. ECS Fargate

  • vCPU (0.5 vCPU): $0.04048 per vCPU-hour.
  • Memory (1 GB): $0.004445 per GB-hour.

For a single task running 24/7:

  • vCPU Cost (0.5 vCPU): 0.5 * 24 * 30 * $0.04048 = $14.58 per month.
  • Memory Cost (1 GB): 1 * 24 * 30 * $0.004445 = $3.20 per month.

4. Application Load Balancer (ALB)

  • ALB: $0.0225 per hour.
  • Data Processed by ALB: $0.008 per GB.

For 24/7 operation:

  • ALB Cost: 24 * 30 * $0.0225 = $16.20 per month.

5. ECR Storage

  • Cost: $0.10 per GB per month.

6. CloudWatch Logs

  • Storage: $0.03 per GB.
  • Ingested Logs: $0.50 per GB.

Total Estimated Monthly Cost

Adding up the individual components, here’s an estimated monthly cost:

  • ECS Fargate: ~$17.78
  • ALB: ~$16.20
  • Internet Gateway: ~$32.40 (if used)
  • ECR Storage: Variable depending on image size.
  • CloudWatch Logs: Variable depending on log volume.

Estimated Total: ~$66.38 per month (excluding data transfer and storage variations).

Alternative Pricing without Internet Gateway:
If you use the default AWS network configuration and don’t need an Internet Gateway:

  • Estimated Total: ~$34.58 per month (excluding Internet Gateway cost).

Conclusion

While serverless and Fargate offer great convenience, costs can escalate quickly. For instance, running a 0.5 vCPU and 1GB memory task costs around $18/month just for compute. Additional services like the Application Load Balancer (ALB) and CloudWatch Logs further increase expenses. If you're working on smaller projects or development environments, consider skipping features like the Internet Gateway or ALB to save costs. Always tailor your setup to your specific needs to optimize cloud spending.

And there you have it! A blazing fast, simple way to deploy your containers on AWS ECS Fargate. Enjoy!

Happy deploying :)

Top comments (1)

Collapse
 
luisparedes1 profile image
Luis

🐳🐋