Triggering a Lambda from SNS using CloudFormation
Amazon Web Services (AWS) provides many building blocks you can use to create just about anything in the world of web-connected services. A lot of the skill in using this toolkit is in figuring out how to make the various services work together. CloudFormation is critical to this effort, as it let's you write a config file that can be used to automate the creation of all the infrastructure you need to deliver your product. The big win from using CloudFormation is reproducibility. It let's you define your whole infrastructure requirement, put it in version control, and reproduce the same setup in a production environment without manually recreating everything.
CloudFormation is not without its problems, however. The Syntax is overly verbose and awkward to author. Some of the services it can create cannot be edited, and it is easy to get into circular reference hell with more complex configurations involving the interactions between multiple products. There are reams of documentation but most of it is useless noise without meaningful explanation. The emerging best-practices are to work in another format (like YAML) and convert it to CloudFormation's preferred format mechanically, or to pay a third party for their cloud automation tools.
I've been struggling to get an SNS topic to trigger a Lambda this week. This is really easy using the AWS console because it automates many of the tricky and barely documented steps required to get it working. Wiring it up in CloudFormation is another hell entirely. You need a few different pieces in the right order to make this work so I'll go through each one in turn. Each of the below can be pasted into the Resources
section of a CloudFormation template.
For completeness, I've included a basic template skeleton here.
{
"Description": "blah blah",
"Parameters": { ... },
"Outputs": { ... },
"Resources": { ... }
}
Lambda Execution Role
Before you can build a Lambda Function, you need to create some permissions for it to assume at runtime. Here I present a fairly minimal role suitable for a basic Lambda Function with no external integration points. Additional permissions (e.g. reading from an S3 Bucket) can be added to the list of Statement
s in the PolicyDocument
. This part is actually documented reasonably well.
{
"ExecutionRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"Path": "/",
"Policies": [
{
"PolicyName": "CloudwatchLogs",
"PolicyDocument": {
"Statement": [
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:GetLogEvents",
"logs:PutLogEvents"
],
"Resource": [ "arn:aws:logs:*:*:*" ],
"Effect": "Allow"
}
]
}
}
],
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": [ "sts:AssumeRole" ],
"Effect": "Allow",
"Principal": {
"Service": [ "lambda.amazonaws.com" ]
}
}
]
}
}
}
}
Lambda Function
The Lambda Function itself is quite easy to set up. I've hard-coded placeholders for the S3 Bucket and path to the zip file containing the code. It doesn't matter what the code is for the purpose of this article.
{
"Lambda": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "my-personal-bucket",
"S3Key": "lambdas/test/my-lambda.zip" }
},
"Description": "Some Lambda Function",
"MemorySize": 128,
"Handler": {"Ref": "LambdaHandler"},
"Role": {
"Fn::GetAtt": [ "ExecutionRole", "Arn" ]
},
"Timeout": 5,
"Runtime": "python2.7"
},
"DependsOn": [
"ExecutionRole"
]
}
}
SNS Topic
The SNS Topic is very easy to create but it cannot be modified using CloudFormation after it has been created. You also have to create all the subscriptions at the same time, so if you use CloudFormation for reproducibility, you can never change the subscriptions of a running event pipeline that relies on SNS. This is a major drawback of SNS and CloudFormation and should be considered with care before you rely too heavily on this set of tools.
The unmodifiable nature of SNS Topics created this way won't be a problem if you're creating subscriptions via an API at runtime, but it limits how flexible SNS can be for some workflows, like fanning out to SQS Queues or triggering Lambda Functions.
{
"Topic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"Subscription": [
{
"Endpoint": {
"Fn::GetAtt": [ "Lambda", "Arn" ]
},
"Protocol": "lambda"
}
]
},
"DependsOn": [ "Lambda" ]
}
}
Permission for the Topic to invoke the Lambda
Unfortunately, creating all the pieces isn't enough. We still need to grant our SNS topic permission to invoke the Lambda Function directly. This is really important and the documentation is almost completely useless so I'm putting a working example here.
It is absolutely critical to have the SourceArn
property refer to the Topic we created earlier. Without this, the Lambda Console will give you inexplicable errors, while the SNS Console claims that everything is correct and working. It can be very frustrating trying to get this right.
{
"LambdaInvokePermission": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"Principal": "sns.amazonaws.com",
"SourceArn": { "Ref": "Topic" },
"FunctionName": {
"Fn::GetAtt": [ "Lambda", "Arn" ]
}
}
}
}
Update: An observant reader spotted a bug in the above CloudFormation. I originally had a reference to the SourceAccount
in the Properties
block:
"SourceAccount": { "Ref": "AWS::AccountId" },
This was incorrect. It works after removing that line.
Conclusion
That should be enough to get it all working. There are some serious holes in the capabilities of CloudFormation for working with SNS, and the permission model is a poorly documented mess. The only thing I haven't covered is how to get the Lambda itself to run, but that's a topic for another day.