Network Load Balancer Ingress for Application Load Balancer fronted AWS Fargate service

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.

Network Load Balancer (NLB) is a layer 4 load balancer. It distributes TCP and UDP connections to targets.

NLB is designed to serve as a public facing ingress that accepts inbound connections from the public internet. You can configure an NLB to distribute inbound connections to an ALB. The ALB can then be configured by ECS to distribute individual HTTP requests to application containers in your deployment.

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 a sample container. Internet traffic will reach the container deployment via a dual layer load balancer setup. Public internet traffic will ingress via an NLB, which routes the connections to an ALB, which then distributes individual HTTP requests to application containers.

Why?

You may wish to use a dual load balancer setup for several reasons:

  • Stable, static IP addresses - For business to business services, a common request is for your service endpoint to publish a list of IP addresses on which it accepts inbound traffic. Other organizations that wish to communicate to your service endpoint will then build an "allow list" of those IP address for their own networking configuration.
  • Reduction in public IPv4 address usage - An NLB uses a single static IPv4 address per availability zone. Therefore you can use an NLB ingress to reduce the number of public IPv4 addresses in your networking architecture.
  • Internal traffic routing. If you have a microservice architecture, then this dual layer setup allows internal services to communicate with each other via an internal ALB. Such service to service traffic will remain entirely inside of the VPC. When there is a single, public facing load balancer, service to service traffic leaves the VPC via the internet gateway, then returns back into the VPC via the public facing load balancer endpoint.

Architecture

The following diagram depicts the architecture you will deploy via this pattern:

Public subnetPublic subnetPrivate subnetPrivate subnetVPCInternet gatewayTrafficAWS FargatePort 80ContainerPort 8080ContainerPort 8080NLBALB

  1. The VPC has both public subnets and private subnets. Resources launched into the public subnet use a public IP address. Resources launched into the private subnets do not have any public IP address, and they are only reachable from inside of the VPC.
  2. The public subnet hosts an NLB with a stable, static Elastic IP in each availability zone.
  3. The private subnet hosts an internal ALB, that serves as ingress to a container deployment in AWS Fargate.
  4. Traffic from the public internet enters the VPC through the NLB. The NLB then forwards that traffic to the ALB, which routes the traffic to a container in AWS Fargate.

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-nlb-alb",
  "version": "1.0.0",
  "description": "Fargate service with NLB ingress to an ALB",
  "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 * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
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 alb: elbv2.ApplicationLoadBalancer;
  public readonly nlb: elbv2.NetworkLoadBalancer;
  public readonly nlbListener: elbv2.NetworkListener;
  public readonly albListener: elbv2.ApplicationListener;

  constructor(scope: Construct, id: string, props: StackProps = {}) {
    super(scope, id, props);

    this.vpc = new ec2.Vpc(this, 'vpc', {
      maxAzs: 2,
      ipProtocol: ec2.IpProtocol.DUAL_STACK
    });
    this.cluster = new ecs.Cluster(this, 'cluster', { vpc: this.vpc });

    this.nlb = new elbv2.NetworkLoadBalancer(this, 'nlb', {
      vpc: this.vpc,
      internetFacing: true,
      ipAddressType: elbv2.IpAddressType.DUAL_STACK
    })

    // Configure a static EIP for each AZ.
    const cfnNlb = this.nlb.node.defaultChild as elbv2.CfnLoadBalancer;
    cfnNlb.subnetMappings = this.vpc.publicSubnets.map((publicSubnet) => {
      return {
        subnetId: publicSubnet.subnetId,
        allocationId: new ec2.CfnEIP(this, `nlb-eip-${publicSubnet.node.id}`).attrAllocationId
      } as elbv2.CfnLoadBalancer.SubnetMappingProperty;
    })
    cfnNlb.subnets = undefined; // Subnet Mappings remove need for the subnet list

    this.nlbListener = this.nlb.addListener('listener', {
      port: 80
    });

    this.alb = new elbv2.ApplicationLoadBalancer(this, 'alb', {
      vpc: this.vpc,
      internetFacing: false
    });
    this.albListener = this.alb.addListener('listener', {
      port: 80,
      open: true,
    });

    this.albListener.addAction('fixed-action', {
      action: elbv2.ListenerAction.fixedResponse(200, {
        contentType: 'text/plain',
        messageBody: 'OK',
      })
    });

    this.nlbListener.addTargets('alb-target', {
      targets: [new targets.AlbTarget(this.alb, 80)],
      port: 80,
    });

    new cdk.CfnOutput(this, 'dns', { value: this.nlb.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,
      desiredCount: 2
    });

    // 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', {
  cluster: shared.cluster,
  listener: shared.albListener,
  diskPath: './service',
  webPath: '*',
  priority: 1
})

app.synth();

A few things to note in this code:

  • The elbv2.NetworkLoadBalancer is configured in dual stack mode so that it can be reached via both IPv6 and IPv4 addresses.
  • The elbv2.NetworkLoadBalancer is being reconfigured to use static elastic IP addresses that are provisioned as part of the CDK application. This enables upstream users to build an "allow list" is IP addresses if they so desire.

TIP

This architecture does not configure an SSL certificate, so that it can be utilized even if you do not have a domain name at this time. In a production environment you should adjust the listener to use port 443, and use an attached Amazon Certificate Manager managed SSL certificate.

Define the placeholder service

The CDK application will build and deploy a placeholder service which is defined using the following file:

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 "Hello from AWS Fargate" > index.html

# Run BusyBox httpd
CMD ["busybox", "httpd", "-f", "-v", "-p", "8080"]

This Dockerfile is 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 this placeholder container with your real application.

Deploy

At this point you should 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/Dockerfile - A placeholder application to deploy

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

Language: sh
npx cdk diff

Use the following command to deploy the AWS Cloud Development Kit application.

Language: sh
npx cdk deploy \
  --all \
  --require-approval never

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 a request to the NLB endpoint to see a response from the AWS Fargate hosted container:

Language: sh
curl $DNS_NAME/

Tear it Down

Language: sh
npx cdk destroy --all -f

See Also