AWS Cloud Operations & Migrations Blog

Configuring Serverless Applications Using AWS CloudFormation Custom Resources

AWS makes it easy for developers to get started developing applications in the cloud. With the extensive array of services available on AWS, developers might incorporate more than just a few components in their applications. Manually managing the resources needed for an application can become time consuming. In addition, applications usually require more than just infrastructure to function.

In this blog post, I’ll show you how to achieve additional configuration tasks such as data loading, compilation of templates, and deployment of static files using a completely serverless architecture and a single AWS CloudFormation template.

Configuration with CloudFormation

AWS CloudFormation is a service that helps you model and set up required resources (e.g., Amazon EC2, Amazon DynamoDB, and IAM roles). This frees you to focus on the application rather than on provisioning the infrastructure. With AWS CloudFormation, you can describe the resources you need using a template written in either JSON or YAML. These templates can then be used to create a stack, and AWS CloudFormation will handle the provisioning and even the updates to or deletions from the resources that you describe.

1-Click deploy buttons on Github

Example 1-click deploy buttons from https://github.com/awslabs/aws-lambda-zombie-workshop

CloudFormation templates only describe AWS resources. Additional configuration must be done using another method, such as a CI/CD pipeline. It’s common to see developers share templates designed for 1-click deployment on sites such as GitHub. A great example of this is the AWS Answers site, which contains many ready-to-deploy solutions, each with its own CloudFormation templates to help users get up and running quickly. How do these solutions achieve their configuration? A common solution is to use EC2 User Data scripts. But how can you do this if your application doesn’t contain any EC2 instances? Let’s look at another way to do additional configuration.

What are custom resources?

With serverless architectures becoming more common, I want to walk you through how to achieve the additional configuration needed to set up your application without using Amazon EC2 or separate scripts running in a CI/CD pipeline. In a serverless architecture, there is no concept of launching an instance. Serverless applications are event driven so we need some other way to trigger and run our setup code. A neat solution to this is to use the custom resources feature of AWS CloudFormation. With custom resources you can write your own logic and have AWS CloudFormation run this logic any time you create, update, or delete stacks.

A custom resource is defined using either the AWS::CloudFormation::CustomResource or Custom::<custom_name> type in CloudFormation. There are two methods of invoking your logic. The first method is to associate an Amazon Simple Notification Service (SNS) topic with your custom resource. In this scenario, you use SNS notifications to trigger your custom provisioning logic. After it’s done, your code sends a response (and any output data) that notifies AWS CloudFormation to proceed with the stack operation. The second method is to associate your custom resource with an AWS Lambda function, which is invoked directly by CloudFormation thus requiring less configuration.

Let’s take a look at an example

Imagine that you are tasked with writing a CloudFormation template that will deploy a simple three-tier serverless web application. This application includes a static HTML page hosted in S3, an API powered by AWS Lambda and Amazon API Gateway, and an Amazon DynamoDB database. The first configuration task is to deploy the HTML page. Let’s take a look at an example. The following is a Lambda-backed custom resource defined in CloudFormation to deploy an HTML page:

AppConfigurationLambda:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: ./frontend
    Environment:
      Variables:
        API_URL: !Join
          - ''
          - - https://
            - !Ref ServerlessRestApi
            - .execute-api.
            - !Ref 'AWS::Region'
            - .amazonaws.com
            - /Stage/
        DEST_BUCKET: !Ref WebsiteBucket
    Handler: handler.configure_application
    MemorySize: 128
    Role: !GetAtt AppConfigurationRole.Arn
    Runtime: python2.7
    Timeout: 300

DeploymentCustomResource:
  Type: Custom::AppConfiguration
  Properties:
    ServiceToken: !GetAtt AppConfigurationLambda.Arn

You can see that I have defined a Lambda function (using AWS SAM syntax to simplify the declaration). I then defined a second resource with type Custom::AppConfiguration. The only required property for this resource is ServiceToken which links to either an Amazon SNS topic ARN or a Lambda function ARN. In this case, I have referenced the previously defined Lambda function.

The purpose of our Lambda function is to update our frontend HTML page with the URL for our API before pushing it to Amazon S3. You can see that the API URL and S3 bucket are both passed into our function using references to other resources defined in the template (not shown), which allows our template to be entirely self-contained.

Now let’s take a look at the corresponding Lambda function:

import os
import json
import boto3
import pystache
import requests

s3 = boto3.resource('s3')
s3_client = boto3.client('s3')

def build_response(event, status):
    """A utility function used to build a response to CloudFormation"""

    response_data = {
        'Status': status,
        'Reason': 'Success',
        'PhysicalResourceId': 'myapp::{}'.format(event['LogicalResourceId']),
        'Data': {},
        'RequestId': event['RequestId'],
        "LogicalResourceId": event["LogicalResourceId"],
        "StackId": event["StackId"],
    }
    return response_data

def configure_application(event, context):
    """Responsible for app configuration during stack creation/update/deletion"""

    try:
        request_type = event['RequestType']

        api_url = os.environ.get('API_URL')
        dest_bucket = os.environ.get('DEST_BUCKET')

        if request_type == "Delete":
            # Delete contents of S3 bucket (else it will not delete)
            bucket = s3.Bucket(dest_bucket)
            for f in bucket.objects.all():
                f.delete()
        else:
            # Retrieve the template needed to generate final HTML
            s3_obj = s3.Object('mysourcebucket', 'index.mustache')
            mustache_template = s3_obj.get()["Body"].read()
            renderer = pystache.Renderer()
            rendered = renderer.render(mustache_template, {'API_URL': api_url})

            # Write updated static HTML to S3 for consumption
            s3_client.put_object(Bucket=dest_bucket, Key="index.html",
                Body=rendered, ACL='public-read', ContentType='text/html')

        response_data = build_response(event, 'SUCCESS')
    except Exception, exc:
        # Catch any exceptions and ensure we always return a response
        response_data = build_response(event, 'FAILED')

    # Respond to Cloudformation to let it know we are done
    response_url = event['ResponseURL']
    result = requests.put(response_url, data=json.dumps(response_data))

    return result

The key component here is the configure_application() function, which like any other Lambda function takes event and context as input parameters. I mentioned that custom resources can be triggered on create, update, and delete stack operations. A good template should handle all cases. For example, you don’t want to redeploy your HTML if you have chosen to delete the stack. In the previous snippet, our function first checks which type of operation is happening and responds appropriately. The more operations you can build into your template, the less you have to document as additional manual steps for the user of the template.

Notice that we retrieve the API URL and Destination bucket that were passed in from the template and use those to perform our configuration. In the case of stack deletion, you want to remove any objects in the bucket, otherwise the stack operation will fail when it attempts to delete the bucket itself. In all other cases (stack creation and updates) you want to read a template (here stored in S3) and insert your newly defined API URL. I’m using a Mustache template and the Pystache Python library here to do this (which I need to include with my code bundle). The function then writes the newly rendered template to the S3 bucket, completing the configuration.

It’s important that your custom resource always makes an explicit call back to the ResponseUrl provided in the event. Simply returning a response from your Lambda function won’t suffice. If you don’t make this call, CloudFormation won’t know that your custom resource has been configured, and it will wait until it times out. In our example, to give our function the best chance of always making this callback, the main logic of the function is wrapped in an exception handler which will build a failure response. ’s still possible for our function to fail (e.g., if imports fail or there are syntax errors elsewhere), however this function is covering common cases such as an invalid template or misconfigured IAM policy.

Other common configuration tasks

You can define multiple custom resources in a CloudFormation template. You might find it useful to build up a collection of reusable functions. The following example shows a few more functions you can consider integrating into your templates:

Populating a database with data

Sometimes your app will need initial data in order to function. This seed data can easily be inserted using a function such as the one in the following example. In this case, you pass in an environment variable called QUESTIONS_TABLE that references the DynamoDB table created by your table.

dynamodb_client = boto3.resource('dynamodb', region_name="us-east-1")

def setup_dynamo(event, context):

    try:
        table_name = os.environ.get('QUESTIONS_TABLE')
        table = dynamodb_client.Table(table_name)
        table.put_item(Item={
            'number': 1,
            'question': 'Which database offering is most suited to NoSQL?',
            'answers': '{
                "A": "RDS",
                "B": "DynamoDB",
                "C": "Redshift"}'})
        table.put_item(Item={
            'number': 2,
            'question': 'What is the recommended way of scaling compute resources?',
            'answers': '{
                "A": "Increase the instance size.",
                "B": "Use smaller instances but more of them",
                "C": "Not sure"}'})

        response_data = build_response(event, 'SUCCESS')
    except Exception, exc:
        response_data = build_response(event, 'FAILED')

    # Respond to Cloudformation to let it know we are done
    response_url = event['ResponseURL']
    result = requests.put(response_url, data=json.dumps(response_data))

    return result

Cleaning up CloudWatch Logs

Many services automatically generate logs. In our example, whenever any of our Lambda functions is first run, a log group is created in CloudWatch Logs. Some customers want to clean up these log groups when deleting the stack. , To automate the process you could include a custom resource, such as the one in the example that follows, to automatically delete any Lambda logs generated by functions spun up by your stack. This example would take an environment variable called STACK_NAME used to identify the logs we want and could use !Ref AWS::StackName to retrieve this from within the CloudFormation template.

logs_client = boto3.client('logs')

def delete_logs(event, context):
  
    try:
        delete_logs_with_prefix = '/aws/lambda/{0}'.format(os.environ.get('STACK_NAME'))
        output = client.describe_log_groups(logGroupNamePrefix=delete_logs_with_prefix)
    
        for record in output['logGroups']:
            client.delete_log_group(logGroupName=record['logGroupName'])
        
        response_data = build_response(event, 'SUCCESS')
    except Exception, exc:
        response_data = build_response(event, 'FAILED')
        
    # Respond to Cloudformation to let it know we are done
    response_url = event['ResponseURL']
    result = requests.put(response_url, data=json.dumps(response_data))

    return result

Tips

  • Make sure to handle exceptions and timeouts. If your Lambda function doesn’t successfully call back to CloudFormation, then the stack update will hang. You can use CloudWatch Logs to debug your Custom Resource Lambda functions. You might find it useful to disable automatic rollback on stack failure, otherwise the logs might be gone by the time you go to view them.
  • Ensure that your function is idempotent (handles retries etc.). Since updates can run multiple times, avoid unnecessary logic or add explicit handling for operations that might not be idempotent, such as database inserts.
  • You have the ability to return custom properties to be used later in your template. This can be useful for constructing custom resources that look up AMI IDs or custom IP address ranges, for example.
  • Use third-party libraries to assist you in writing your custom resource Lambda functions , for example, crhelper.py by awslabs (or similar) to make logging and event handling easier.

 

About the Author

Steven Challis is a Solutions Architect with AWS in New York and has a background in application development and enterprise software primarily within the media and entertainment sector. Outside of work, Steve has passion for cars, instruments and traveling.