"CloudFront deployments with Lambda@Edge"
Tags: aws, devops, lambda
Published: 2021-05-26 06:45:16

---
title:  "CloudFront deployments with Lambda@Edge"
published: true
tags: aws, devops, lambda
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9uco0ab7c0l3atowphxu.png
---

A/B Testing, Blue/Green deployments, Canary releases. Different, but still so much in common. They all have different purpose but are using basically using the same technical solution under the hood. We do these kind of tests for several reasons. One is to do a A/B test and determine what version our users like the best. Sure it is possible to do A/B testing on the client side, but personally I find it easier to do server side.  
Blue / Green and Canary deployments are done to make sure a new version of the application work as we expect and give us an easy way to roll back to previous version in case of a problem. All of these are important practices in the DevOps culture.  
  

Many services from AWS offer this solution out of the box, one service that doesn't is CloudFront. Luckily CloudFront has the possibility to run Lambda@Edge which we can use to solve this.  
  
All source code for this setup is found on [GitHub][github-link]

## Introducing Lambda@Edge

Lambda can run in four different locations in the request flow.
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9uco0ab7c0l3atowphxu.png)

**Viewer request** - Is run when CloudFront receives a request from a viewer.  
**Origin request** - Is run before CloudFront forwards a request to the origin.  
**Origin response** - Is run when CloudFront receives a response from the origin.  
**Viewer response** - Is run before CloudFront returns the response to the viewer.  

There are several limitations when it comes to Lambda@Edge, it is only possible to create functions in Python and Node.js. Viewer request and response functions can only allocate 128mb of memory and only run for 3 seconds. You can't use environment variables, you can't use the _latest_ version alias, only fixed versions are supported. Logs are published to the edge region that you access and not to _us-east-1_ region, even if functions must be deployed to that region. Be sure to read the **[documentation][lambda-at-edge-doc-restrictions-link]** before you start working with Lambda@edge.


## Solution Overview

In this solution we are going to use two different Lambda functions for _viewer request_ and _origin response_ hooks, we will store configuration in Parameter Store, and  S3 is our origin.
  
  
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ztcxt37til2ruk3fvwj9.png)

In the _viewer request_ hook we will check for a special cookie, if the cookie is not set we will fetch a random version. In the _origin response_ we set the _set-cookie_ header to store the version we are using.  
By setting different values in Parameter Store we can control the weight of each version and we can also reset and have clients receive new random version.

## Viewer Request

Let's start with the Lambda function that will react to the Viewer Request Event. This Lambda function is responsible for checking for our cookie _X-Version-Name_ if an value is set the function will update the request path based on the cookie value. If there is no value set it will roll the dice and get a random version and update the request path. This function will also match the value in cookie _X-Version-Reset_ towards a value in Parameter Store and if the value is different it will ignore any set value in _X-Version-Name_.  If there is no value in _X-Version-Name_ or if the value is ignored the function throws the dice and picks a version at random. The weight, for the different versions, are controlled by a value in Parameter Store. Finally the function pass the cookie to the next step in the call chain.  

### The Viewer Request code

Full version is available in [GitHub][github-link].  

```python
def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    headers = request['headers']

    cookie_version_blue = 'X-Version-Name=Blue'
    cookie_version_green = 'X-Version-Name=Green'
    path_blue = '/Blue'
    path_green = '/Green'

    uri = ''

    if request['uri'].endswith('/'):
        request['uri'] = request['uri'] + 'index.html'

    if 'cookie' not in request['headers']:
        request['headers']['cookie'] = []

    # Reset weights, ignore already set cookie
    reset_weight, reset_cookie = do_weight_reset(headers)
    if not reset_weight:
        for cookie in headers.get('cookie', []):
            if cookie_version_blue in cookie['value']:
                uri = path_blue + request['uri']
                break
            elif cookie_version_green in cookie['value']:
                uri = path_green + request['uri']
                break
    request['headers']['cookie'].append(
        {'key': 'Cookie', 'value': reset_cookie})

    if not uri:
        weight = int(load_parameter('Weight'))
        cookie_value = ''

        if random.random() < float(weight / 100.0):
            uri = path_blue + request['uri']
            cookie_value = cookie_version_blue
        else:
            uri = path_green + request['uri']
            cookie_value = cookie_version_green
        request['headers']['cookie'].append(
            {'key': 'Cookie', 'value': cookie_value})

    request['uri'] = uri
    return request
``` 

## Viewer Response

The _Viewer Response_ function basically has one task, and that is to pass _set-cookie_ header to the client. This is needed so the client set the cookies _X-Version-Name_ and _X-Version-Reset_ so the client send them in the next request. This is important so the client doesn't jump between versions. The function has a small but very important job to do.  
  
### The Viewer Response code

Full version is available in [GitHub][github-link].  

```python
def lambda_handler(event, context):
    response = event['Records'][0]['cf']['response']
    request = event['Records'][0]['cf']['request']
    # Persist cookie, set the set-cookie header
    if 'set-cookie' not in response['headers']:
        response['headers']['set-cookie'] = []

    request_headers = request['headers']
    cookie_version_blue = 'X-Version-Name=Blue'
    cookie_version_green = 'X-Version-Name=Green'
    cookie_reset = 'X-Version-Reset'

    for cookie in request_headers.get('cookie', []):
        if cookie_version_blue in cookie['value']:
            response['headers']['set-cookie'].append(
                {'key': 'set-cookie', 'value': cookie_version_blue})
        elif cookie_version_green in cookie['value']:
            response['headers']['set-cookie'].append(
                {'key': 'set-cookie', 'value': cookie_version_green})
        elif cookie_reset in cookie['value']:
            response['headers']['set-cookie'].append(
                {'key': 'set-cookie', 'value': cookie['value']})

    return response
```

## Deploying the functions

The Lambda functions need to be deployed in us-east-1 region, since that is the region Lambda@Edge originates from. We must also use a fixed version and can't use the _latest_ alias. As normal AWS SAM is used to define and deploy Lambda Functions.  

### The SAM Template

Full version is available in [GitHub][github-link].  

```yaml
ViewerRequestFunction:
    Type: AWS::Serverless::Function
    Properties:
        AutoPublishAlias: "true"
        Runtime: python3.7
        MemorySize: 128
        Timeout: 3
        CodeUri: ./viewer-request
        Handler: handler.lambda_handler
        AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
            - Effect: Allow
            Principal:
                Service:
                - lambda.amazonaws.com
                - edgelambda.amazonaws.com
            Action:
                - sts:AssumeRole
        Policies:
        - SSMParameterReadPolicy:
            ParameterName: !Sub ${SsmConfigPath}/*
        - Version: "2012-10-17"
            Statement:
            Action:
                - lambda:GetFunction
            Effect: Allow
            Resource: "*"

ViewerResponseFunction:
    Type: AWS::Serverless::Function
    Properties:
        AutoPublishAlias: "true"
        Runtime: python3.7
        MemorySize: 128
        Timeout: 3
        CodeUri: ./viewer-response
        Handler: handler.lambda_handler
        AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
            - Effect: Allow
            Principal:
                Service:
                - lambda.amazonaws.com
                - edgelambda.amazonaws.com
            Action:
                - sts:AssumeRole
        Policies:
        - Version: "2012-10-17"
            Statement:
            Action:
                - lambda:GetFunction
            Effect: Allow
            Resource: "*"
```

## CloudFront setup

Finally we need to create the CloudFront distribution and set the Lambda functions for the Viewer Request and Response triggers. Normally CloudFront will not use and pass the headers to the cache. Since our setup is depending on two cookies we must make sure CloudFront pass them along. That is done by adding them to _WhitelistedNames_ section. Wildcards are supported so we just add _X-Version-*_ to that section.

### The CloudFormation Template

Full version is available in [GitHub][github-link].  

```yaml
CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: !Sub "Distribution for ${ProjectName}"
        DefaultCacheBehavior:
          AllowedMethods:
            - "GET"
            - "HEAD"
            - "OPTIONS"
          Compress: False
          DefaultTTL: 0
          MaxTTL: 0
          MinTTL: 0
          ForwardedValues:
            QueryString: False
            Cookies:
              Forward: whitelist
              WhitelistedNames:
                - "X-Version-*"
          LambdaFunctionAssociations:
            - !If
              - ViewerRequestLambdaArnSet
              - EventType: viewer-request
                LambdaFunctionARN: !Ref ViewerRequestLambdaArn
              - !Ref AWS::NoValue
            - !If
              - ViewerResponseLambdaArnSet
              - EventType: viewer-response
                LambdaFunctionARN: !Ref ViewerResponseLambdaArn
              - !Ref AWS::NoValue
          TargetOriginId: !Sub ${ProjectName}-origin
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: !Ref DefaultRootObject
        Enabled: True
        Origins:
          - DomainName: !Sub ${StorageBucket}.s3.amazonaws.com
            Id: !Sub ${ProjectName}-origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity}
        PriceClass: PriceClass_100
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}
```

## Conclusion

Even though CloudFront doesn't support these deployments techniques out of the box Lambda, as so many times before, come to the rescue. The versatility of AWS Lambda is truly a miracle! Download the code and take it for spin!  
  
Happy hacking!


[lambda-at-edge-doc-restrictions-link]: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide