Shared Application Load Balancer for multiple AWS Fargate services, in AWS Cloud Development Kit
Terminology
Amazon Elastic Container Service (ECS) deploys application containers on your behalf, and helps you connect them to a wide range of other AWS services.
Application Load Balancer (ALB) is a layer 7 load balancer that is HTTP aware. It can route HTTP requests to backend targets based on characteristics of the request such as hostname or request path.
ECS integrates ALB as an ingress that can send traffic to your application containers. You can configure a single public facing Application Load Balancer to route traffic to multiple different ECS services.
AWS Cloud Development Kit is an open source software framework that helps you create and configure AWS resources using familiar programming languages.
This pattern will demonstrate how to use AWS Cloud Development Kit to deploy two sample containers as ECS services, and configure an application load balancer as a shared ingress that does path based routing to the two services.
Why?
You may wish to provision a single shared ALB for multiple ECS services for the following reasons:
- Reduce cost - Each ALB has an hourly charge associated with it. Therefore it is more cost efficient to use a single shared ALB, backed by multiple different services, rather than provisioning a separate ALB for each service. Additionally, as each public facing ALB consumes public IPv4 addresses, you may wish to decrease the number of IPv4 addresses that your architecture utilizes.
- Service discovery - Rather than needing to keep track of different DNS names for each service you deploy, you can route all traffic to a single DNS name. The routing rules inside of the application load balancer will separate out the HTTP traffic to the appropriate service.
Architecture
The following diagram shows the architecture that you will be deploying:
- Two different ECS services are deployed in AWS Fargate:
service-one
andservice-two
. Each service can be independently scaled up and down if that particular service has more load than the other service. - A single, shared, internet-facing Application Load Balancer serves as ingress to both services.
- The ALB has two target groups, with HTTP routing rules that match HTTP paths
/service-one*
and/service-two*
. - Incoming web traffic is distributed to the appropriate backend service based on the path of the HTTP request.
This approach can be generalized to many types of deployment such as an API that is backed by multiple microservices that fulfill different business functions. For example an REST style API could be implemented as:
http://api.mycompany.com/user/*
routing to theuser
servicehttp://api.mycompany.com/payment/*
routing to thepayment
service
Setup the Cloud Development Kit environment
To use this pattern you need the Node.js JavaScript runtime.
Next you need to define some environment dependencies so that you can install AWS Cloud Development Kit. Create the following local files:
- package.json
- tsconfig.json
- cdk.json
{
"name": "fargate-shared-alb",
"version": "1.0.0",
"description": "Two fargate services with a shared Application Load Balancer",
"private": true,
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"cdk": "cdk"
},
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^8.10.38",
"aws-cdk": "2.126.0",
"typescript": "~4.6.0"
},
"dependencies": {
"aws-cdk-lib": "2.126.0",
"constructs": "^10.0.0"
}
}
The files above serve the following purpose:
package.json
- This file is used by NPM or Yarn to identify and install all the required dependencies:tsconfig.json
- Configures the TypeScript settings for the project:cdk.json
- Tells CDK what command to run, and provides a place to pass other contextual settings to CDK.
After creating these files, install all the required dependencies using the following command:
npm install
Create the application
Now create a file index.ts
which is the entrypoint for the CDK deployment:
import ecs = require('aws-cdk-lib/aws-ecs');
import ec2 = require('aws-cdk-lib/aws-ec2');
import elbv2 = require('aws-cdk-lib/aws-elasticloadbalancingv2');
import cdk = require('aws-cdk-lib');
import { Stack, StackProps, Size, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
const app = new cdk.App();
class SharedStack extends Stack {
public readonly vpc: ec2.Vpc;
public readonly cluster: ecs.Cluster;
public readonly lb: elbv2.ApplicationLoadBalancer;
public readonly listener: elbv2.ApplicationListener;
constructor(scope: Construct, id: string, props: StackProps = {}) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'vpc', { maxAzs: 2 });
this.cluster = new ecs.Cluster(this, 'cluster', { vpc: this.vpc });
this.lb = new elbv2.ApplicationLoadBalancer(this, 'lb', {
vpc: this.vpc,
internetFacing: true
});
this.listener = this.lb.addListener('listener', {
port: 80,
open: true,
});
this.listener.addAction('fixed-action', {
action: elbv2.ListenerAction.fixedResponse(200, {
contentType: 'text/plain',
messageBody: 'OK',
})
});
new cdk.CfnOutput(this, 'dns', { value: this.lb.loadBalancerDnsName });
}
}
interface ServiceProps extends StackProps {
cluster: ecs.Cluster;
listener: elbv2.ApplicationListener;
diskPath: string;
webPath: string;
priority: number;
}
class LoadBalancerAttachedService extends Stack {
public readonly taskDefinition: ecs.TaskDefinition;
public readonly container: ecs.ContainerDefinition;
public readonly service: ecs.FargateService;
constructor(scope: Construct, id: string, props: ServiceProps) {
super(scope, id, props);
this.taskDefinition = new ecs.FargateTaskDefinition(this, `${id}-task-def`);
this.container = this.taskDefinition.addContainer('web', {
image: ecs.ContainerImage.fromAsset(props.diskPath),
memoryLimitMiB: 256,
logging: ecs.LogDrivers.awsLogs({
streamPrefix: `${id}`,
mode: ecs.AwsLogDriverMode.NON_BLOCKING,
maxBufferSize: Size.mebibytes(25),
}),
});
this.container.addPortMappings({
containerPort: 8080,
protocol: ecs.Protocol.TCP
});
this.service = new ecs.FargateService(this, `${id}-service`, {
cluster: props.cluster,
taskDefinition: this.taskDefinition,
});
// Attach ALB to ECS Service
props.listener.addTargets(`${id}-target`, {
priority: props.priority,
conditions: [
elbv2.ListenerCondition.pathPatterns([props.webPath]),
],
port: 80,
targets: [this.service.loadBalancerTarget({
containerName: 'web',
containerPort: 8080
})],
healthCheck: {
interval: cdk.Duration.seconds(10),
path: "/",
timeout: cdk.Duration.seconds(5),
},
deregistrationDelay: Duration.seconds(10)
});
}
}
const shared = new SharedStack(app, 'shared-resources');
new LoadBalancerAttachedService(app, 'service-one', {
cluster: shared.cluster,
listener: shared.listener,
diskPath: './service-one',
webPath: '/service-one*',
priority: 1
})
new LoadBalancerAttachedService(app, 'service-two', {
cluster: shared.cluster,
listener: shared.listener,
diskPath: './service-two',
webPath: '/service-two*',
priority: 2
})
app.synth();
A few things to note in this code:
- The CDK application uses three stacks. The
SharedStack
defines shared resources like the cluster and the Application Load Balancer. Two instances of theLoadBalancerAttachedService
stack are used to attach ECS services to the load balancer elbv2.ListenerAction.fixedResponse
is a default fallback action. This allows the ALB to be created with no services attached, just a fixed response ofOK
. Later, the ECS services can be attached onto paths in the load balancer.elbv2.ListenerCondition.pathPatterns
is used to define HTTP paths that the service is attached topriority
is used to control the order that ALB routing rules evaluate in. Each service must have a unique priority number.
Define the placeholder services
The CDK application will build and deploy two placeholder services which are defined using the following files:
- service-one/Dockerfile
- service-two/Dockerfile
FROM public.ecr.aws/docker/library/busybox
# Create a non-root user to own the files and run our server
RUN adduser -D static
USER static
WORKDIR /home/static
RUN echo "Healthy" > index.html && \
mkdir service-one && \
echo "Hello from service one" > service-one/index.html
# Run BusyBox httpd
CMD ["busybox", "httpd", "-f", "-v", "-p", "8080"]
The Dockerfile
for both services, is just a lightweight busybox
container that runs an Apache HTTP server that listens on port 8080. The server hosts simple static content. In an actual deployment you would replace these placeholder containers with your real application.
Deploy
You should now have the following files:
index.ts
- The entrypoint for the CDK applicationpackage.json
- Defines the AWS Cloud Development Kit dependenciescdk.json
- Defines how to run the CDK applicationtsconfig.json
- TypeScript configurationservice-one/Dockerfile
- A placeholder application forservice-one
service-two/Dockerfile
- A placeholder application forservice-two
You can use the following command to preview what CDK will create on your AWS account:
npx cdk diff
Then use the following commands to deploy the CDK application to your AWS account:
npx cdk deploy \
--asset-parallelism=true \
--require-approval=never \
--concurrency 2 \
service-one service-two
TIP
A more simplistic deployment could be accomplished with npx cdk deploy --all
The command given above is designed to optimize overall deployment speed by deploying the base stack first, and then deploying the two service stacks concurrently, for maximum deploy speed.
Test it out
Grab the DNS name for the deployed service using the following command:
DNS_NAME=$(aws cloudformation describe-stacks --stack-name shared-resources --query "Stacks[0].Outputs[?OutputKey=='dns'].OutputValue" --output text) && echo $DNS_NAME
Now you can send requests to two different paths on this shared ALB, and see two different responses from the two different ECS services:
curl $DNS_NAME/service-one/
curl $DNS_NAME/service-two/
Tear it down
Tear down the deployment with the following command:
npx cdk destroy --all -f
Next steps
- Configure a
CnameRecord
for the load balancer, so that you can use a more friendly alias likeapi.mycompany.com/service-one
- Consider two tier load balancing: public facing network load balancer as ingress to an internal application load balancer that routes to your services.