The anatomy of a CloudFormation template with a simple Lambda function

Learn how to write a CloudFormation template with only a simple Lambda function

Author's image
Tamás Sallai
6 mins

Background

Managing a Lambda function with CloudFormation is a non-trivial task. You need to define several resources to make it work.

I'll use this monitoring script to show the building blocks of this simple function. The functionality is rather simple: have a function and automatically call it on a scheduled rate.

Why use CloudFormation?

When you need multiple resources for a single functionality, without a centralized descriptor they remain unrelated. Defining and handling them one-by-one makes it hard to reproduce the functionality, and harder still to clean everything up when they are not needed. With a CloudFormation template, you can deploy/update/remove all of them together.

Resources

Resources are the main building blocks of any CloudFormation template. As a rule of thumb, anything that you'd define on the Console manually is one Resource.

Lambda function

The function is the main functionality. To define it, we need a resource of type AWS::Lambda::Function:

Resources:
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      ...

Use the Properties block to define the Code, the Runtime, and the Handler (this defines which of the exported function to call):

Resources:
  LambdaFunction:
    ...
    Properties:
      Handler: handler.index
      Code: src/
      Runtime: nodejs8.10

The Code is referenced in a local path, but for deployment CloudFormation requires it to be in S3. But since managing the code in a remote location is burdensome (not to mention it's easy to accidentally overwrite existing versions), the AWS CLI provides a package function to do it just before deployment:

aws cloudformation package --template-file cloudformation.yml --s3-bucket <bucket> --output-template-file output.yml

This command zips then uploads the code, replacing the Code block with the S3 URL in the process. The resulting template is written to the output.yml file, as per the --output-template-file argument. This can be uploaded to CloudFormation directly.

Event Rule

Now that there is a function, we need a resource to invoke it regularly. The AWS::Events::Rule is our friend here:

Resources:
  ScheduledRule: 
    Type: AWS::Events::Rule
    Properties: 
      ScheduleExpression: "rate(1 minute)"
      State: "ENABLED"
      Targets: 
        - Arn: !GetAtt LambdaFunction.Arn
          Id: "TargetFunction"

Since we need to reference which function to call, we need a way to get the Arn (the resource identifier) of the LambdaFunction resource. To get a parameter of a resource, use the !GetAtt function:

Arn: !GetAtt LambdaFunction.Arn

How to know which properties are supported and required for a given resource?

Consult the documentation. There, you can see what you need to define, and also what return values you can reference with the !GetAtt. Especially, look at the examples provided by AWS to figure out how to use a given resource.

Permissions

The two resources above implement the main functionality. But we need another two, first, a permission to invoke the function, and second, an execution role that the function will use.

To give permission to the Events Rule to call the function, we need an AWS::Lambda::Permission resource:

Resources:
  InvokeLambdaPermission: 
    Type: AWS::Lambda::Permission
    Properties: 
      FunctionName: !GetAtt LambdaFunction.Arn
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt ScheduledRule.Arn

Same as before, we need to reference resources defined in the template for the FunctionName and the SourceArn properties. The Action defines the scope of the permission (lambda:InvokeFunction), and the Principal defines the entity to which the permission is granted.

In this case, the Principal is events.amazonaws.com as the function will be triggered by an event. If, for example, the Lambda were triggered by an S3 event, the Principal would be s3.amazonaws.com.

The other permission we need is the execution role. Contrary to the previous permission, this is a role that the Lambda function will assume when run. You can define what other AWS resources it has access to.

Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        ...
      Policies:
        ...

It has two important parts. The AssumeRolePolicyDocument is the so-called trust policy. This defines who can assume this role. For our Lambda function to work, we need to allow the lambda.amazonaws.com service to do the sts:AssumeRole action:

Resources:
  LambdaExecutionRole:
    ...
    Properties:
      ...
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
          Service:
          - lambda.amazonaws.com
          Action:
          - sts:AssumeRole

This part is mainly constant, you'd use this for any Lambda function.

But wait! Where is it specified that this role can only be assumed by this specific function?

It is not. In effect, this role can be assumed by any Lambda. You need to be very mindful whom you give access to the iam:PassRole action as that is what controls which roles can be specified by each user. See this article for more info.

The Policies part defines what the function can do:

Resources:
  LambdaExecutionRole:
    ...
    Properties:
      ...
      Policies:
        - PolicyName: allow-logs
          PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - 'logs:*'
            Resource: arn:aws:logs:*:*:*

This policy allows the function to write its logs to CloudWatch Logs. Since logging is usually an important part, you want to include this statement in every Lambda role.

If you need to give access to any other service you need to add those here.

And finally, associate the role with the Lambda function:

Resources:
  LambdaFunction:
    ...
    Properties:
      Role: !GetAtt LambdaExecutionRole.Arn
      ...

Parameters

To make parts of the template parameterizable, you can use the Parameters section. For example, if you want to make the interval configurable, you need to define a Parameter:

Parameters:
  ScheduleExpression:
    Type: String
    Default: "rate(1 minute)"

And to access it, use !Ref <parameter>:

Resources:
  ScheduledRule: 
    ...
    Properties: 
      ScheduleExpression: !Ref ScheduleExpression
      ...

Deploy

Now that everything is in place, let's see how you could deploy the template.

First, you need to run a package that zips the local Lambda code, uploads it to S3, and modifies the template file accordingly:

aws cloudformation package --template-file cloudformation.yml --s3-bucket <bucket> --output-template-file output.yml

Make sure to use the same region for the packaged code as you'll use for the template itself.

When you have the packaged template, deploy it:

aws cloudformation deploy --template-file output.yml --stack-name <stack name> --capabilities CAPABILITY_IAM

The --capabilities CAPABILITY_IAM is required since the template creates IAM resources, and the CLI issues an error if you don't explicitly acknowledge it.

Run it, wait a few minutes, and you have a function that is run every minute.

Conclusion

Writing a CloudFormation template is a time-consuming process. You might want to use some sort of a designer or a framework to generate it for you. But knowing how these templates work under the hood come handy when you need to debug a problem.

December 18, 2018
In this article