Split web traffic between Amazon EC2 and AWS Fargate

CloudFormation example of how to setup an Application Load Balancer that distributes web traffic across an ECS service running on both EC2 and Fargate.

Nathan Peck
Nathan Peck
Senior Developer Advocate at AWS

About

Amazon ECS can orchestrate your application across a range of different capacity types. In this pattern you will learn how to use Amazon ECS to setup an Application Load Balancer that distributes traffic across both Amazon EC2 capacity, and AWS Fargate capacity.

The following diagram shows what will be deployed:

Build the sample application

The Node.js sample application grabs information from the ECS Task Metadata endpoint, and returns it to the requester on port 80.

Create the following three files:

File: index.js Language: js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const axios = require('axios');
const express = require('express');
const app = express();
const port = process.env.PORT || 80;

const metadataUrl = `${process.env.ECS_CONTAINER_METADATA_URI_V4}/task`;

app.get('*', async function (req, res) {
  const metadataResponse = await axios.get(metadataUrl);
  const formattedResponse = JSON.stringify(metadataResponse.data, null, 2)
  res.send(`<pre>
  Running on: ${metadataResponse.data.LaunchType}
  DNS: ${metadataResponse.data.Containers[0].Networks[0].PrivateDNSName}
  AvailabilityZone: ${metadataResponse.data.AvailabilityZone}
  </pre>
  <br />
  <br />
  <pre style='height: 400px; overflow: scroll'>${formattedResponse}</pre>`);
});

app.listen(port, () => console.log(`Listening on port ${port}!`));

// This causes the process to respond to "docker stop" faster
process.on('SIGTERM', function () {
  console.log('Received SIGTERM, shutting down');
  app.close();
});
File: package.json Language: json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "name": "ecs-metadata",
  "version": "1.0.0",
  "description": "Simple microservice that echoes the ECS metadata",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node index.js"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "express": "^4.16.3",
    "axios": "1.6.0"
  }
}
File: Dockerfile Language: Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Build stage, includings NPM and tools necessary for the build
FROM public.ecr.aws/docker/library/node:20 as build
WORKDIR /srv

# Install dependencies based on the `package.json` and `package-lock.json`
# files in the host folder
RUN --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev


# Production stage, only includes what is needed for production
FROM public.ecr.aws/docker/library/node:20-slim

ENV NODE_ENV production
USER node

COPY --from=build /srv .
ADD . .

# Specify the command to run when launching the container
EXPOSE 3000
CMD node index.js

You should have the following folder structure:

  • app - Folder containing the application code
  • app/index.js - The actual code for the sample application
  • app/package.json - A manifest files that lists some open source packages from NPM that the application depends on
  • app/Dockerfile - Instructions on how to build the application and package it up into a container image.

Now you can build and push the image to ECR using command like this (substitute your own ECR private repository URL):

REPO_URI=$(aws ecr create-repository --repository-name sample-app-repo --query 'repository.repositoryUri' --output text)
if [ -z "${REPO_URI}" ]; then
  REPO_URI=$(aws ecr describe-repositories --repository-names sample-app-repo --query 'repositories[0].repositoryUri' --output text)
fi
docker build -t ${REPO_URI}:ecs-metadata ./app
docker push ${REPO_URI}:ecs-metadata

Deploy an ECS cluster and environment

In order to run this sample template you will need an ECS cluster with an EC2 capacity provider attached to it. You can follow the EC2 capacity provider pattern to get an example CloudFormation template that will deploy the cluster and the capacity provider.

Deploy the sample application

The following template will deploy the sample ecs-metadata application (or any other image that you pass to it). The image will be deployed twice: once on EC2 and one of AWS Fargate. Finally an Application Load Balancer is provisioned which sends 50% of the traffic to the EC2 service, and 50% of the traffic to the AWS Fargate service.

File: service-across-ec2-and-fargate.yml Language: yml
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
AWSTemplateFormatVersion: '2010-09-09'
Description: An example task definition that can deployed onto both
             Amazon EC2 and AWS Fargate

Parameters:
  ImageURI:
    Type: String
    Description: The URI of the image to deploy
  Cluster:
    Type: String
    Description: The name of the ECS cluster to deploy into
  Ec2CapacityProvider:
    Type: String
    Description: The name of an EC2 capacity provider in the cluster.
  ServiceName:
    Type: String
    Default: ecs-metadata
    Description: Name of the service
  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: The virtual private network into which to launch all resources
  SubnetIds:
    Type: List<AWS::EC2::Subnet::Id>
    Description: List of subnet IDs where the EC2 instances will be launched

Resources:

  # This task definition has settings which allow it to
  # be used on both AWS Fargate and Amazon EC2 capacity
  SampleTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: ecs-metadata
      RequiresCompatibilities:
        - EC2
        - FARGATE
      ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
      NetworkMode: awsvpc
      Cpu: 256
      Memory: 512
      ContainerDefinitions:
        - Name: ecs-metadata
          Image: !Ref ImageURI
          PortMappings:
            - ContainerPort: 3000
          Environment:
            - Name: PORT
              Value: 3000
          LogConfiguration:
            LogDriver: 'awslogs'
            Options:
              mode: non-blocking
              max-buffer-size: 25m
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Ref ServiceName

  # Deploy the task definition as a service on EC2 capacity
  Ec2Service:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: !Sub '${ServiceName}-on-ec2'
      Cluster: !Ref 'Cluster'
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 75
      CapacityProviderStrategy:
        - Base: 0
          CapacityProvider: !Ref Ec2CapacityProvider
          Weight: 1
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            - !Ref ServiceSecurityGroup
          Subnets:
            - !Select [ 0, !Ref SubnetIds ]
            - !Select [ 1, !Ref SubnetIds ]
      DesiredCount: 1
      TaskDefinition: !Ref 'SampleTaskDefinition'
      LoadBalancers:
        - ContainerName: ecs-metadata
          ContainerPort: 80
          TargetGroupArn: !Ref Ec2TargetGroup

  # Deploy the task definition as a service on AWS Fargate capacity
  FargateService:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: !Sub '${ServiceName}-on-fargate'
      Cluster: !Ref 'Cluster'
      LaunchType: FARGATE
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 75
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - !Ref ServiceSecurityGroup
          Subnets:
            - !Select [ 0, !Ref SubnetIds ]
            - !Select [ 1, !Ref SubnetIds ]
      DesiredCount: 1
      TaskDefinition: !Ref 'SampleTaskDefinition'
      LoadBalancers:
        - ContainerName: ecs-metadata
          ContainerPort: 80
          TargetGroupArn: !Ref FargateTargetGroup

  # Keeps track of the list of tasks running on EC2 instances
  Ec2TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      HealthCheckIntervalSeconds: 6
      HealthCheckPath: /
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      TargetType: ip
      Name: 'ecs-metdata-on-ec2'
      Port: 3000
      Protocol: HTTP
      UnhealthyThresholdCount: 2
      VpcId: !Ref VpcId

  # Keeps track of the list of tasks running in AWS Fargate
  FargateTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      HealthCheckIntervalSeconds: 6
      HealthCheckPath: /
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      TargetType: ip
      Name: 'ecs-metadata-on-fargate'
      Port: 3000
      Protocol: HTTP
      UnhealthyThresholdCount: 2
      VpcId: !Ref VpcId

  # A public facing load balancer, this is used for accepting traffic from the public
  # internet
  PublicLoadBalancerSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to the public facing load balancer
      VpcId: !Ref VpcId
      SecurityGroupIngress:
          # Allow access to ALB from anywhere on the internet
          - CidrIp: 0.0.0.0/0
            IpProtocol: -1
  PublicLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Scheme: internet-facing
      LoadBalancerAttributes:
      - Key: idle_timeout.timeout_seconds
        Value: '30'
      Subnets:
        # The load balancer is placed into the public subnets, so that traffic
        # from the internet can reach the load balancer directly via the internet gateway
        - !Select [ 0, !Ref SubnetIds ]
        - !Select [ 1, !Ref SubnetIds ]
      SecurityGroups:
        - !Ref PublicLoadBalancerSG
  PublicLoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: 'forward'
          # Evenly split traffic across the app on EC2 and the app on Fargate
          # Can adjust weights as needed to balance traffic between the two
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref Ec2TargetGroup
                Weight: 50
              - TargetGroupArn: !Ref FargateTargetGroup
                Weight: 50
      LoadBalancerArn: !Ref 'PublicLoadBalancer'
      Port: 80
      Protocol: HTTP

  # Security group that limits network access
  # to the task
  ServiceSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for service
      VpcId: !Ref VpcId

  # The services' security group allows inbound
  # traffic from the public facing ALB
  ServiceIngressFromPublicALB:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      Description: Ingress from the public ALB
      GroupId: !Ref 'ServiceSecurityGroup'
      IpProtocol: -1
      SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG'

  # This role is used to setup the execution environment for
  # the task, in this case to connect to the Elastic File System
  TaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: [ecs-tasks.amazonaws.com]
            Action: ['sts:AssumeRole']
            Condition:
              ArnLike:
                aws:SourceArn: !Sub arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  # This log group stores the stdout logs from this service's containers
  LogGroup:
    Type: AWS::Logs::LogGroup

The template requires the following input parameters:

  • ImageURI - The URI of the image to deploy. This should match the image that you built and pushed above.
  • Cluster - The name of an ECS cluster on this account. This cluster should have EC2 capacity available in it. All ECS clusters come with AWS Fargate support already built-in. For an example of how to deploy an ECS cluster with EC2 capacity there is a pattern for an ECS cluster using a EC2 capacity provider.
  • Ec2CapacityProvider - The name of an EC2 capacity provider on this cluster. Again see the ECS cluster with EC2 capacity provider pattern.
  • VpcId - A virtual private cloud ID. This can be the default VPC that comes with your AWS account. Example: vpc-79508710
  • SubnetIds - A comma separated list of subnets from the VPC. Example: subnet-b4676dfe,subnet-c71ebfae

Deploy this template with a command like this:

aws cloudformation deploy \
  --template-file service-across-ec2-and-fargate.yml \
  --stack-name service-across-ec2-and-fargate \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
     ImageURI=${REPO_URI}:ecs-metadata \
     Cluster=capacity-provider-environment-BaseStack-18PANC6K9E7D8-ECSCluster-NNBNpIh5AkZO \
     Ec2CapacityProvider=capacity-provider-environment-BaseStack-18PANC6K9E7D8-CapacityProvider-FI323ISAaRbn \
     VpcId=vpc-79508710 \
     SubnetIds=subnet-b4676dfe,subnet-c71ebfae

Next Steps

The services will initially deploy with only one of each task: one task on EC2 and one task on AWS Fargate. Try scaling up both services to launch additional tasks.

Note that the Weight option inside of the AWS::ElasticLoadBalancingV2::Listener forwarding configuration is controlling the balance of how traffic is distributed across the EC2 and Fargate versions of the service. It assumes an even 50% distribution to both.

You can also choose to scale up the AWS Fargate service higher than the Amazon EC2 service, and adjust the balance of traffic to send more traffic to the AWS Fargate version of the service.

Test out sending traffic to the single endpoint for the application. You should see response that look something like these samples:

Running on: EC2
DNS: ip-172-31-4-140.us-east-2.compute.internal
AvailabilityZone: us-east-2a
Running on: FARGATE
DNS: ip-172-31-41-78.us-east-2.compute.internal
AvailabilityZone: us-east-2c

By reloading the endpoint a few times you will see it flip back and forth between EC2 and FARGATE as the load balancer distributes traffic evenly across both instances of the service.

Tear it down

You can use the following command to tear down the stack and delete the services:

# Tear down the CloudFormation
aws cloudformation delete-stack --stack-name service-across-ec2-and-fargate

# Empty and delete the Amazon ECR container registry we created
aws ecr delete-repository --repository-name sample-app-repo --force

You should also delete the container image that you uploaded to Amazon ECR.