The anatomy of a CloudFormation template with a simple Lambda function
Learn how to write a CloudFormation template with only a simple Lambda function
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.