Shared Application Load Balancer for multiple AWS Fargate services, in AWS Cloud Development Kit

Nathan Peck profile picture
Nathan Peck
Senior Developer Advocate at AWS

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:

VPCservice-oneInternet gatewayApplication Load Balancer (Publicfacing)TrafficAWS FargatePort 80Port 8080service-twoPort 8080Path rule:/service-one*Path rule:/service-two*service-onePort 8080

  • Two different ECS services are deployed in AWS Fargate: service-one and service-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 the user service
  • http://api.mycompany.com/payment/* routing to the payment 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
File: package.jsonLanguage: 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:

Language: sh
npm install

Create the application

Now create a file index.ts which is the entrypoint for the CDK deployment:

File: index.tsLanguage: ts
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 the LoadBalancerAttachedService 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 of OK. 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 to
  • priority 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
File: DockerfileLanguage: 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 application
  • package.json - Defines the AWS Cloud Development Kit dependencies
  • cdk.json - Defines how to run the CDK application
  • tsconfig.json - TypeScript configuration
  • service-one/Dockerfile - A placeholder application for service-one
  • service-two/Dockerfile - A placeholder application for service-two

You can use the following command to preview what CDK will create on your AWS account:

Language: sh
npx cdk diff

Then use the following commands to deploy the CDK application to your AWS account:

Language: sh
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:

Language: sh
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:

Language: sh
curl $DNS_NAME/service-one/
curl $DNS_NAME/service-two/

Tear it down

Tear down the deployment with the following command:

Language: sh
npx cdk destroy --all -f

Next steps

  • Configure a CnameRecord for the load balancer, so that you can use a more friendly alias like api.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.

See Also