ALB with a Lambda Target

AWSTemplateFormatVersion: 2010-09-09
Description: A public ALB with a Lambda Function target.
Transform: AWS::Serverless-2016-10-31

Overview

This CloudFormation template creates an internet-facing ALB that invokes a Lambda Function. That is, you can curl the ALB and see a response from the Lambda Function.

Prerequisets

Parameters

Parameters:
Param Value
DeploymentName test

DeploymentName

  DeploymentName:
    Type: String
    Description: A name for this deployment

A deployment is a deployed application, potentially comprised of many CloudFormation stacks. This is sometimes called an "environment", but that is an overloaded and confusing term. Use the DeploymentName to indicate which logical deployment a stack belongs to.

If a deployment is completely specified by exactly one CloudFormation template, the DeploymentName and the AWS::StackName refer to the same things. In that case, consider not using a DeploymentName parameter.

Resources

Resources:

ALB (internet-facing)

If you want the ALB to be reachable from the internet, the ALB must:

  • Use an internet-facing scheme
  • Be associated with public subnets

Why can't we use private subnets?

ALBs can be associated with private subnets, but in this case that would break everything. In AWS-land, only an Internet Gateway can provide a route from the internet into a Subnet, not an ALB. Maybe that isn't the best design in the world, but it is what it is.

Some further explanation:

An ALB has a couple of IP addresses (try host $ALB_DNS_NAME). If you associate the ALB with two subnets, the ALB has at least 2 IP addresses: one for each subnet. When the ALB is associated with a subnet, AWS creates an Elastic Network Interface (ENI) for the ALB in that subnet, and the ENI is assigned a public IP address (if this is an internet-facing ALB). That IP becomes one of the ALB's IP addresses!

Now say those ENIs are in private subnets. This is the same situation as when you have an EC2 instance in a private subnet with a "public" (internet-valid) IP address. You can try to query that IP, but you will never reach the instance.

If the ENI is in a private subnet, network traffic to the ENI's IP has no route into the subnet. So, when an ALB is associated with private subnets, querying the ALB from the internet will always hang.

  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      IpAddressType: ipv4
      Scheme: internet-facing
      SecurityGroups: [!Ref AlbSecurityGroup]
      Subnets:
        - Fn::ImportValue: !Sub "${DeploymentName}-PublicSubnet1"
        - Fn::ImportValue: !Sub "${DeploymentName}-PublicSubnet2"
      Type: application
      Tags:
        - Key: Name
          Value: !Ref AWS::StackName
  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow HTTP on port 80
      VpcId: {Fn::ImportValue: !Sub "${DeploymentName}-VpcId"}
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Ref AWS::StackName

ALB Routing

  ALBListenerHTTP:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref AlbTargetGroupLambda
      LoadBalancerArn: !Ref ALB
      Port: 80
      Protocol: HTTP
  AlbTargetGroupLambda:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DependsOn: LambdaInvokePermissionElb
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt HelloWorldFunction.Arn

TODO: Restrict access from only the TargetGroup (using SourceArn). This is tricky. See: https://forums.aws.amazon.com/thread.jspa?threadID=307784

  LambdaInvokePermissionElb:
    Type: AWS::Lambda::Permission
    DependsOn: HelloWorldFunction
    Properties:
      FunctionName: !Ref HelloWorldFunction
      Action: lambda:InvokeFunction
      Principal: elasticloadbalancing.amazonaws.com

Lambda Function

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub "${DeploymentName}-Hello-World"
      InlineCode: |
        import json
        def handler(event, context) -> dict:
            message = {"hello": "world"}
            response = {
                "statusCode": 200,
                "statusDescription": "200 OK",
                "headers": {"Content-Type": "application/json"},
                "body": json.dumps(message),
            }
            return response
      Handler: index.handler
      Runtime: python3.8
      Timeout: 3

Outputs

Outputs:
  ALB:
    Description: DNS name for the ALB
    Value: !GetAtt ALB.DNSName
    Export:
      Name: !Sub "${DeploymentName}-ALB"

Testing

Send an HTTP GET request to the ALB.

(nth 1 (assoc "DeploymentName" Params))
ExportName=${DeploymentName}-ALB

alb=$(aws cloudformation list-exports \
    --query "Exports[?Name=='${DeploymentName}-ALB'].Value" \
    --output text)

curl http://$alb
#+RESULTS:
{"hello": "world"}