How to use AWS Lambda with Application Load Balancer

In this article, we will discuss how to use AWS Lambda with Application Load Balancer.

💡
TLDR: Create a target group with lambda function in the load balancer and add action to the listener to forward the request to this newly created target group based on the path.

Read the article for more details.

Why do we need to use AWS Lambda with Application Load Balancer?

One of the popular integrations for AWS Lambda is API Gateway. With API Gateway, the architecture would be completely serverless- you might wonder why we need to use AWS Lambda with Application Load Balancer.

Let's assume you have an existing fargate-based application or EC2-based application running behind the application load balancer - as shown in the below picture

Fargate based application
Fargate based application

And, you may want to move the complete application to Lambda gradually or some parts of the application to AWS Lambda. In either case, you'll have Fargate/EC2 service and Lambda together. The architecture would look like below

Using Lambda and Fargate/EC2 together
Using Lambda and Fargate/EC2 together

How to mount AWS Lambda with an application load balancer

We'll use path based routing feature of the application load balancer. If the request comes for a specific path, it will be routed to AWS Lambda and all other paths would be routed to the existing Fargate service

Infrastructure

The infrastructure is pretty simple

  • VPC with public and private subnets
  • Fargate service is hosted in a private subnet of VPC - as it may communicate with RDS
  • Lambda function
  • Application Load Balancer

Infrastructure code:

We'll be using AWS CDK for creating all necessary AWS resources in this article. It's an open-source software development framework that lets you define cloud infrastructure. AWS CDK supports many languages including TypeScript, Python, C#, Java, and others. You can learn more about AWS CDK from a beginner's guide here. We're going to use TypeScript in this article.

VPC:

We're creating VPC with 2 subnets - a public subnet and a private subnet.

 const vpc = new ec2.Vpc(this, "alb-lambda-vpc", {
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "ingress",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "application",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });

As mentioned earlier, our fargate app is hosted in private subnets. Private subnets are subnets without any internet connectivity and this poses a problem. When you don't have internet connectivity, how we're going to pull the docker image and additional packages that are required for our application? NAT Gateway solves this problem. From our private subnet, using the NAT gateway, you can access the internet from the private subnet for outbound traffic, and still, all inbound traffic will be blocked.

Load Balancer:

This would be public facing load balancer as the users would be accessing our application through this load balancer

const loadbalancer = new elbv2.ApplicationLoadBalancer(
      this,
      "alb-lambda-lb",
      {
        vpc,
        internetFacing: true,
        vpcSubnets: vpc.selectSubnets({
          subnetType: ec2.SubnetType.PUBLIC,
        }),
      }
    );

Fargate Service

In the below code snippet, we're creating an ECS cluster for hosting the fargate service. And we're creating a role with the necessary permission for task execution.

 const cluster = new ecs.Cluster(this, "Cluster", {
      vpc,
      clusterName: "fargate-cluster",
    });

    const executionRole = new iam.Role(this, "ExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy"
        ),
      ],
    });

    const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      "FargateService",
      {
        cluster,
        taskImageOptions: {
          image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
          containerName: "fargate-app-container",
          family: "fargate-task-defn",
          containerPort: 80,
          executionRole,
        },
        cpu: 256,
        memoryLimitMiB: 512,
        desiredCount: 2,
        serviceName: "fargate-service",
        taskSubnets: vpc.selectSubnets({
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        }),
        loadBalancer: loadbalancer,
      }
    );
Fargate Service

Please note that for this article, we're going to use the image provided by amazon amazon/amazon-ecs-sample.

Lambda Function

We're creating a simple lambda function with Node 18 as its runtime environment.

const nodeJsFunctionProps: NodejsFunctionProps = {
      bundling: {
        externalModules: [
          "aws-sdk", // Use the 'aws-sdk' available in the Lambda runtime
        ],
      },
      runtime: Runtime.NODEJS_18_X,
      timeout: cdk.Duration.seconds(30), // Default is 3 seconds
      memorySize: 256,
    };

    const helloFn = new NodejsFunction(this, "hello", {
      entry: path.join(__dirname, "../lambdas", "hello.ts"),
      ...nodeJsFunctionProps,
      functionName: "helloFn",
    });

Target group for Lambda function:

It is important to note that the application load balancer will not route to specific lambda.  We're going to create a new target group for the lambda function. Then, based on the path, we can route the request to this target group which contains the lambda functions

const targetGroup = new elbv2.ApplicationTargetGroup(
      this,
      "alb-lambda-target-group",
      {
        vpc,
        targetType: elbv2.TargetType.LAMBDA,
        targets: [new targets.LambdaTarget(helloFn)],
      }
    );

Add Action to the existing listener of the load balancer:

Our fargate service and lambda function would listen to the requests from port 80. When creating the fargate service, a listener would have created.

We're going to use the existing listener which was created earlier  and add action to forward the request to the target group containing the lambda function - if it matches a specific path.

  const listener = loadbalancer.listeners[0];

    listener.addAction("alb-lambda-action", {
      action: elbv2.ListenerAction.forward([targetGroup]),
      conditions: [elbv2.ListenerCondition.pathPatterns(["/hello"])],
      priority: 1,
    });

Deployment

Once you deploy the infrastructure using cdk deploy command, you'll get the URL of the application load balancer

When you access the URL, you'll be able to see the sample app that we loaded in the fargate service

When you access the /hello path, the request would be forwarded to the lambda group containing lambda and you'll be able to see the response from the lambda.

Complete Code

Below is the complete code for your reference

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import {
  NodejsFunction,
  NodejsFunctionProps,
} from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import * as path from "path";
import * as targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ecs_patterns from "aws-cdk-lib/aws-ecs-patterns";

export class AlbLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, "alb-lambda-vpc", {
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "ingress",
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: "application",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });

    const loadbalancer = new elbv2.ApplicationLoadBalancer(
      this,
      "alb-lambda-lb",
      {
        vpc,
        internetFacing: true,
        vpcSubnets: vpc.selectSubnets({
          subnetType: ec2.SubnetType.PUBLIC,
        }),
      }
    );

    const nodeJsFunctionProps: NodejsFunctionProps = {
      bundling: {
        externalModules: [
          "aws-sdk", // Use the 'aws-sdk' available in the Lambda runtime
        ],
      },
      runtime: Runtime.NODEJS_18_X,
      timeout: cdk.Duration.seconds(30), // Default is 3 seconds
      memorySize: 256,
    };

    const helloFn = new NodejsFunction(this, "hello", {
      entry: path.join(__dirname, "../lambdas", "hello.ts"),
      ...nodeJsFunctionProps,
      functionName: "helloFn",
    });

    const cluster = new ecs.Cluster(this, "Cluster", {
      vpc,
      clusterName: "fargate-cluster",
    });

    const executionRole = new iam.Role(this, "ExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy"
        ),
      ],
    });

    const service = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      "FargateService",
      {
        cluster,
        taskImageOptions: {
          image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
          containerName: "fargate-app-container",
          family: "fargate-task-defn",
          containerPort: 80,
          executionRole,
        },
        cpu: 256,
        memoryLimitMiB: 512,
        desiredCount: 2,
        serviceName: "fargate-service",
        taskSubnets: vpc.selectSubnets({
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        }),
        loadBalancer: loadbalancer,
      }
    );

    const targetGroup = new elbv2.ApplicationTargetGroup(
      this,
      "alb-lambda-target-group",
      {
        vpc,
        targetType: elbv2.TargetType.LAMBDA,
        targets: [new targets.LambdaTarget(helloFn)],
      }
    );

    const listener = loadbalancer.listeners[0];

    listener.addAction("alb-lambda-action", {
      action: elbv2.ListenerAction.forward([targetGroup]),
      conditions: [elbv2.ListenerCondition.pathPatterns(["/hello"])],
      priority: 1,
    });
  }
}

Please let me know your thoughts in the comments.