Attaching Lambdas to an Existing ALB With Terraform

Jul 20, 2020 | By Matthew Karmazyn

At FloQast, we use AWS Lambda Functions behind an Application Load Balancer as our primary API platform. Each of our Lambda Functions are in a separate Git repository, and the supporting Terraform is in the repo along with the lambda source code. This is great because it gives engineers complete control over their slice of the application.

Scattering the source code across multiple repos solves some problems, but also adds a bit of complexity. We can't simply attach the lambda function to the ALB by referencing the resource directly, because we don't have the ALB resource in our local repository. Here is an example of what an ALB system might look like.

Sample AWS ALB Lambda API

ALB Data Sources

This is where we utilize Terraform Data Sources to look up the remote ALB that we will attach our lambda function to.

We actually need to perform two data source lookups here. First we need to get the ALB, so we can use that Resource ARN to look up the Listener. The Listener is what we are actually attaching our Target Group and Lambda function to.

data aws_lb alb {
  name  = var.alb_name
}

data aws_lb_listener alb443 {
  load_balancer_arn = data.aws_lb.alb.arn
  port              = 443
}

Lambda Function Resources

Now that we have the Application Load Balancer, we need to create our resources for the Lambda Function.

resource aws_lambda_function main {
  function_name = var.lambda_name
  ...
}

resource aws_lambda_alias live {
  name             = "live"
  description      = "Live alias"
  function_name    = aws_lambda_function.main.arn
  function_version = aws_lambda_function.main.version
}

resource aws_lb_target_group main {
  name        = "${substr(var.lambda_name, 0, 29)}-tg"
  target_type = "lambda"
  ...
}

resource aws_lambda_permission alb {
  statement_id  = "AllowExecutionFromALB"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.main.function_name
  principal     = "elasticloadbalancing.amazonaws.com"
  qualifier     = aws_lambda_alias.live.name
  source_arn    = aws_lb_target_group.main.arn
}

resource aws_lb_target_group_attachment main {
  target_group_arn = aws_lb_target_group.main.arn
  target_id        = aws_lambda_alias.live.arn

  depends_on = [
    aws_lambda_permission.alb
  ]
}

The substr is used to truncate the name of the Target Group due to the 32 character limit.

Creating the Connection

We have our ALB Data Sources, and we have our Lambda Function, Target Group, and other configuration. Next we need to connect it all together. We will do this using two final resources.

resource aws_lb_target_group_attachment main {
  target_group_arn = aws_lb_target_group.main.arn
  target_id        = aws_lambda_alias.live.arn

  depends_on = [
    aws_lambda_permission.alb,
    aws_lambda_alias.live
  ]
}

resource aws_lb_listener_rule main {
  listener_arn = data.aws_lb_listener.alb443.arn

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.main.arn
  }

  condition {
    path_pattern {
      values = local.alb_path
    }
  }

  condition {
    http_request_method {
      values = var.alb_request_methods
    }
  }
}

Note our use of local.alb_path, this will be covered in the next section.

Inputs

We try to abstract as much as possible so the Engineer developing the Lambda Function needs to input as few values as possible. Here are the input variables required for this example to function, but internally we make use of our deployment pipeline and Terraform Local Values to abstract even further.

variable lambda_name {
  description = "A unique name for your Lambda Function."
  type        = string
}

variable alb_name {
  description = "The name of the Application Load Balancer."
  type        = string
}

variable alb_path {
  description = "The path to be created on the ALB."
  type        = string
  default     = null
}

variable alb_request_methods {
  description = "The request methods to be created on the ALB."
  type        = list(string)
  default     = ["OPTIONS", "GET"]
}

locals {
  alb_path = var.alb_path != null ? var.alb_path ? var.lambda_name
}

Note that we use a local to default the alb_path to the Lambda Function name. This reduces the inputs required.

A New Problem - Modules to the rescue!

We've solved the original problem of attaching a new lambda function to an existing ALB that we don't have direct resource access to via Terraform, by utilizing Data Sources.

Now we have a new problem of Terraform code duplication across many application repositories. We solved this problem at FloQast by turning the terraform code above into a Terraform Module

By using a module, we are able to maintain the same set of Terraform code for all of our lambda functions, in a single repository. The only Terraform code that now sits in the application repository, is a single module.tf file that looks something this.

provider aws {
  region = "us-west-2"
}

module lambda-platform {
  source = "git@github.com:FloQast/terraform-module-lambda-platform.git?ref=v1.4.0"

  lambda_name = "sample-lambda"
  alb_path = ["/my-sample-path"]
}

Within our "Lambda Platform" Terraform Module, we expose a whole suite of customization options, but only a few inputs are actually required. This allows our engineers to quickly stand up new API endpoints without having to spend much time thinking about the underlying infrastructure, while still giving them the ability to customize as much as they need.

Matthew Karmazyn
Matthew Karmazyn
Matt is a Senior DevOps Engineer at FloQast specializing in Automation, Rapid Deployment, and Configuration Management.

Check out research, videos, case studies, and more!

Learn more about working at FloQast!