DEV Community

loading...
Cover image for  "CloudFront deployments with Lambda@Edge"
AWS Community Builders

"CloudFront deployments with Lambda@Edge"

Jimmy Dahlqvist
Crazy cloud guy with a passion for serverless. Loves to share my knowledge and experience. Holds several AWS, GCP certificates and I'm always aiming for the next one. Notorious coffee drinker....
・5 min read

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

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 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

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].

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
Enter fullscreen mode Exit fullscreen mode

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].

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
Enter fullscreen mode Exit fullscreen mode

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].

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: "*"
Enter fullscreen mode Exit fullscreen mode

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].

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}
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)