How to use Lambda@Edge with Terraform

The differences between Lambda@Edge and regular functions

Author's image
Tamás Sallai
5 mins

Lambda@Edge

Lambda@Edge is advertised mainly as a tool that brings processing closer to the users thus increasing speed. But as a developer, I see a different use-case: to influence how CloudFront works. Without functions, CloudFront offers only a handful of configuration options. You can add origins and cache behaviors to set up routing, but you'll run out of options as soon as you need anything beyond the basics.

This is when Lambda comes handy. You can modify the requests and the responses any way you'd like, which opens up ways to fix most of the shortcomings of CloudFront config.

There are some differences from a regular Lambda function though, and some new limitations you should know about before you start using edge functions.

Differences from a regular Lambda function

The usual Lambda resources are needed: an archive_file to hold the code, an aws_iam_role for the execution role, an aws_iam_policy_document for the function's permissions and an aws_iam_role_policy to wire the last two together. This is what you need for any Lambda function, so let's concentrate on the differences!

aws_iam_role

The aws_iam_role's assume role policy must include both lambda.amazonaws.com and edgelambda.amazonaws.com:

resource "aws_iam_role" "lambda_edge_exec" {
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": ["lambda.amazonaws.com", "edgelambda.amazonaws.com"]
      },
      "Effect": "Allow"
    }
  ]
}
EOF
}

aws_lambda_function

Since Lambda@Edge requires a specific version to be referenced, you need to instruct Terraform to publish a new version for every change. To do this, use publish = true:

resource "aws_lambda_function" "lambda_edge" {
	# ...

	publish  = true

The function must be deployed in the us-east-1 region. If you deploy the whole stack there then it's not a problem, but it's better to make sure that is not a requirement. Fortunately, Terraform can deploy resources to multiple regions, which is exactly what we need here.

First, define a provider with the us-east-1 region specified:

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

Then make the function use this provider:

resource "aws_lambda_function" "lambda_edge" {
	# ...

  provider = aws.us_east_1
}

This makes sure this function goes to us-east-1 no matter where the rest of the resources are deployed.

CloudFront

To associate this function with a distribution, add a lambda_function_association to the cache_behavior:

resource "aws_cloudfront_distribution" "distribution" {
	# ...

	# or default_cache_behavior
	ordered_cache_behavior {
		# ...

		lambda_function_association {
      event_type = "origin-request"
      lambda_arn = "${aws_lambda_function.lambda_edge.qualified_arn}"
    }
	}
}

The lambda_arn must include the version, that's why the qualified_arn has to be used here.

The event_type must be one of the 4 defined trigger point: viewer-request, origin-request, viewer-response, and origin-response.

In this case, I want to change how CloudFront calls the origin, so I specify the origin-request trigger.

Example code

Now that we have all the resources in place, let's see an example!

CloudFront appends the full path to the origin request which can be a problem, for example, when your API expects requests starting from the root (/) instead of some other path.

With a fairly common configuration of an API Gateway with the /api/* pattern, a request to /api/users goes to, well, /api/users. But then you need to make sure your API is able to handle this path and does not expect /users instead.

It would be better to just strip the /api path from the request sent to the origin. And that's when Lambda@Edge comes useful.

The code is straightforward. The event.Records[0].cf.request.uri contains the path /api/users and we need to strip the /api part from the start:

module.exports.handler = (event, context, callback) => {
	const request = event.Records[0].cf.request;
	request.uri = request.uri.replace(/^\/api/, "");
	callback(null, request);
};

Update: With CloudFront Functions it is now possible to solve this without the complicated setup of Lambda@Edge.

Pricing

Seems like the pricing is deliberately made to make it hard to compare traditional and Lambda@Edge pricing. The request charges are straightforward: $0.2 vs $0.6.

But for duration prices, one is in GB-seconds and 100ms increments the other one is 128MB-seconds and 50ms increments. In GB-seconds it's $0.0000166667 vs $0.00005001, which is again three times the price.

In total, Lambda@Edge is three times as expensive as a normal Lambda.

But for simple cases, like transforming a request, the math is a bit different. Lambda@Edge is metered at 50ms increments and if you don't use any external services then it's likely you'll never exceed that. That means for every 1 million requests you'll pay ~$0.9 extra.

Destroying

When you try to destroy a stack with Lambda@Edge functions, you'll see this error message:

Error: Error deleting Lambda Function: InvalidParameterValueException: Lambda was unable to delete <arn> because it is a replicated function. Please see our documentation for Deleting Lambda@Edge Functions and Replicas.

When CloudFront starts using the Lambda function it replicates it to the global network. This happens during that ~20 minutes the distribution is deploying.

But when you delete the distribution, while it still takes ~20 minutes, the replicated functions are not deleted but only scheduled for deletion. In effect, Lambda complains about the replicas and refuses to delete the function.

You need to wait a few hours (!) after you delete the distribution to delete the function. Keep trying to terraform destroy until you succeed.

It's funny to see that even this was an improvement over how it originally worked. AWS devs did not think people would want to delete Lambda@Edge functions, like, ever.

Conclusion

Lambda@Edge gives you the missing piece of CloudFront configurations. Apart from request rewriting, you can add cache or security headers, define more advanced routing policies, optimize content, and a lot more. It is a valuable tool when working with CloudFront.

October 1, 2019
In this article