I converted my Lambda@Edge Function to CloudFront Functions

2021-09-14 How to function in the clouds.

When Lambda@Edge first came out, I added it to my blog’s CloudFront distribution in order to add security headers. Then, when Lambda@Edge added Python support, I converted my function from JavaScript to Python.

Now I’ve had the joy of converting back to JavaScript, to use the newer CloudFront Functions.

It seems Lambda@Edge was not fast enough, so the CloudFront team decided to take a mulligan and build a second way of running code within their CDN. The key difference is that CloudFront Functions truly run “at the edge”. Despite the name, Lambda@Edge functions only run at 18 regional cache locations. CloudFront Functions instead run on each of the 218+ edge locations, so there’s much less latency between the request starting and your code running.

This makes CloudFront Functions much more similar to CloudFlare Workers.

CloudFront Functions are also much cheaper than Lambda@Edge, at $0.10 per million invocations. Compare to Lambda@Edge’s $0.60 per million invocations, plus the CPU-Memory cost, which can be the same again or more.

I converted my Lambda@Edge function to a CloudFront Function to speed up my site, reduce complexity, and save a few pennies. You can see the Python code in the previous post. Here’s the new JavaScript code:

function handler(event) {
  var response = event.response;
  var headers = response.headers;

  headers["strict-transport-security"] = {
      "value": "max-age=31536000; includeSubdomains; preload"
  };
  headers["content-security-policy"] = {
    "value": (
        "default-src 'none';"
        + " font-src https://fonts.gstatic.com;"
        + " img-src 'self' https://www.gravatar.com;"
        + " script-src 'self';"
        + " style-src 'self' https://fonts.googleapis.com/"
    )
  };
  headers["x-content-type-options"] = {"value": "nosniff"};
  headers["x-frame-options"] = {"value": "DENY"};
  headers["x-xss-protection"] = {"value": "1; mode=block"};
  headers["referrer-policy"] = {"value": "same-origin"};
  headers["permissions-policy"] = {
    "value": (
        "accelerometer=()"
        + ", ambient-light-sensor=()"
        + ", autoplay=()"
        + ", camera=()"
        + ", encrypted-media=()"
        + ", focus-without-user-activation=()"
        + ", fullscreen=()"
        + ", geolocation=()"
        + ", gyroscope=()"
        + ", interest-cohort=()"
        + ", magnetometer=()"
        + ", microphone=()"
        + ", midi=()"
        + ", payment=()"
        + ", picture-in-picture=()"
        + ", speaker=()"
        + ", sync-xhr=()"
        + ", usb =()"
        + ", vr=()"
    )
  };
  headers["cross-origin-embedder-policy"] = {"value": "unsafe-none"};
  headers["cross-origin-opener-policy"] = {"value": "same-origin"};
  headers["cross-origin-resource-policy"] = {"value": "same-origin"};
  headers["expect-ct"] = {
    "value": 'enforce, max-age=86400, report-uri="https://dbbuddy.report-uri.com/r/d/ct/enforce"'
  };

  return response;
}

This is similar to the add-security-headers example from AWS.

I include the function source code directly within my CloudFormation template:

CloudfrontAddHeaders:
  Type: AWS::CloudFront::Function
  Properties:
    Name: AdamjEuAddHeaders
    AutoPublish: true
    FunctionCode: |
      function handler(event) {
        var response = event.response;
        var headers = response.headers;

        headers["strict-transport-security"] = {
            "value": "max-age=31536000; includeSubdomains; preload"
        };

        /* other headers here ... */

        return response;
      }
    FunctionConfig:
      Comment: Add headers.
      Runtime: cloudfront-js-1.0

After adding this, I modified my CloudFront distribution to use the CloudFront Function:

   CloudfrontDistribution:
     Type: AWS::CloudFront::Distribution
     Properties:
       DistributionConfig:
         Aliases:
         - adamj.eu
         Comment: adamj.eu
         DefaultCacheBehavior:
           AllowedMethods:
           - HEAD
           - GET
           # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
           CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
           Compress: true
           DefaultTTL: 60
           ForwardedValues:
             QueryString: false
-          LambdaFunctionAssociations:
+          FunctionAssociations:
           - EventType: viewer-response
-            LambdaFunctionARN: !Ref PythonLambdaFunctionVersion15
+            FunctionARN: !GetAtt CloudfrontAddHeaders.FunctionMetadata.FunctionARN
           MaxTTL: 31536000
           MinTTL: 0
           TargetOriginId: S3-adamj-eu
@@ -215,6 +215,68 @@ Resources:
           MinimumProtocolVersion: TLSv1.2_2019
           SslSupportMethod: sni-only

Getting the integration right in CloudFormation took me a few iterations. I was initially blind to the difference between the LambdaFunctionAssociations and FunctionAssociations keys in the distribution configuration—and I don’t think you can blame me.

But once everything was in place, I was impressed. Deploys took just a few minutes, and my code ran smoothly on CloudFront. My headers are there on every response, ensuring my A+ rating.

So what did I gain? I unfortunately don’t have any metrics on site speed, so I can’t say much there. But I can see my CloudFront bill has decreased - albeit from about $1.60 to $1.30. The biggest thing for me is the reduction in complexity.

Fin

May your CloudFront continue to function,

—Adam


🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Tags: aws, cloudformation, cloudfront