CloudFormation stacks are very nice for setting up complete server environments. But, there are a few problems.
- Keeping all resources in one stack may not be possible since some resources, such as Kineses, may be needed by multiple stacks.
- If your stack depends on external resources you have to maintain these resources separately and send them as parameters when creating new stacks.
- If you have multiple accounts or environments you have to maintain one configuration for each.
Using Custom Resources to extend the native functionality of CloudFormation solves these problems.
At Sony Mobile in Lund we make heavy use of Lambda-backed Custom Resources. We use them for depending on other stacks, getting info about VPC, Route53, certificates and AMIs. We also have a resource for getting Elasticache endpoints to our Redis services since CloudFormation does not provide it.
The code in this article is based on code from Amazon Lambda-backed Custom Resources
The Custom Resource Invocation
When CloudFormation invokes a Custom Resource it send a request which looks like this:
{ "RequestType": "Create", "ServiceToken": "arn:aws:lambda:...:function:route53Dependency", "ResponseURL": "https://cloudformation-custom-resource...", "StackId": "arn:aws:cloudformation:eu-west-1:...", "RequestId": "afd8d7c5-9376-4013-8b3b-307517b8719e", "LogicalResourceId": "Route53", "ResourceType": "Custom::Route53Dependency", "ResourceProperties": { "ServiceToken": "arn:aws:lambda:...:function:route53Dependency", "DomainName": "example.com" } }
The dots, (...), are random digits and account numbers and will be different per request and account. The interesting parts of this request are:
RequestType
- can have the valuesCreate
,Update
andDelete
.ResponseURL
- the URL toPUT
the response to.ResourceProperties
- the properties sent by the configuration in the CloudFormation resource declaration. You can ignore theServiceToken
, it is used internally by CloudFormation to find your function.DomainName
is interesting since it contains the name of the domain we want to lookup.
The Custom Resource Declaration
The Custom Resource Declaration corresponding to the above invocation looks like this:
"Resources": { "Route53": { "Type": "Custom::Route53Dependency", "Properties": { "ServiceToken": { "Fn::Join": [ "", [ "arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":function:route53Dependency" ] ] }, "DomainName": { "Ref": "DomainName" } } } }
The interesting parts here are:
Route53
- the name of the resource inside the template.Type
- TheCustom::
part of the type identifies this as a Custom Resource, the rest, Route53Dependency, is documentation.ServiceToken
- identifies the function with the current region, account and the nameroute53Dependency
. This function name corresponds to the name of the function used when creating the function withaws lambda create-function --function-name route53Dependency
DomainName
- is a custom parameter which we will read from the event and use to lookup the domain in Route53.
The Lambda Custom Resource Function
A Custom Resource Lambda can be split into three major parts.
- The handler handles the event and invokes the domain specific function.
- The domain specific function calls the AWS function and gets the requested information.
- The response is sent back to CloudFormation by
PUT
ing to the providedResponseURL
The handler and response code is almost the same for all our Custom Resources.
The handler
// The handler route53Dependency.handler = function(event, context) { console.log(JSON.stringify(event, null, ' ')); if (event.RequestType == 'Delete') { return sendResponse(event, context, "SUCCESS"); } route53Dependency(event.ResourceProperties, function(err, result) { var status = err ? 'FAILED' : 'SUCCESS'; return sendResponse(event, context, status, result, err); }); };
The handler starts by logging the event, nice to have for debugging. It ignores
Delete
-events since our resources are not creating anything, only looking
things up. It then invokes the domain function with event.ResourceProperties
,
checks the result and uses sendResponse
to send the reply.
Lambda functions have to be invoked as properties, in this case handler
.
I usually set the handler as a property on the domain function. This is just my
preference, it is not required.
The Domain Function
// The domain function function route53Dependency(properties, callback) { if (!properties.DomainName) callback("Domain name not specified"); var aws = require("aws-sdk"); var route53 = new aws.Route53(); console.log('route53Dependency', properties); route53.listHostedZones({}, function(err, data) { console.log('listHostedZones', err, data); if (err) return callback(err); var zones = data.HostedZones; var matching = zones.filter(function(zone) { return zone.Name == properties.DomainName + '.'; }); if (matching.length != 1) return callback('Exactly one matching zone is allowed ' + zones); var match = matching[0]; delete match.Config; delete match.CallerReference; match.Id = match.Id.split('/')[2]; match.Name = match.Name.substring(0, match.Name.length-1); return callback(null, match); }); }
The domain function starts out with checking for required properties and calls the callback with an error in case they are not valid or available.
We require the SDK and invoke listHostedZones
, check that exactly one domain
matches and return the object after cleaning it up. Custom Resources only
supports returning string values and if you try to return objects such as
Config
above. In this case we don't care about this value and just delete it.
The Response
To send the response back to CloudFormation we PUT some JSON to the
ResponseURL
provided in the request. The response looks like this:
{ "StackId": "arn:aws:cloudformation:eu-west-1:...", "RequestId": 'e4d1ab88-1b2c-402f-b083-1966f5806064', "LogicalResourceId": 'Route53', "PhysicalResourceId": '2015/05/28/00395b017f72444791fb12b988f4aeab', "Status": 'SUCCESS', "Reason": ' Details in CloudWatch Log: 2015/05/28/...', "Data": { "Id": 'ZEHTTI1S7FAPK', "Name": 'backend.lifelog-dev.sonymobile.com', "ResourceRecordSetCount": "22" } }
The relevant parts of the response are the last three:
- Status - signals
SUCCESS
ofFAILED
. - Reason - information about the request, such as where to find the log or the error if one occurred.
- Data - is the data provided by our domain function.
We don't have to care about the rest of the parameters since we can just echo back the parameters which were passed to us in the request.
// sendResponse function sendResponse(event, context, status, data, err) { var reason = err ? err.message : ''; var responseBody = { StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, PhysicalResourceId: context.logStreamName, Status: status, Reason: reason + " See details in CloudWatch Log: " + context.logStreamName, Data: data }; console.log("RESPONSE:\n", responseBody); var json = JSON.stringify(responseBody); var https = require("https"); var url = require("url"); var parsedUrl = url.parse(event.ResponseURL); var options = { hostname: parsedUrl.hostname, port: 443, path: parsedUrl.path, method: "PUT", headers: { "content-type": "", "content-length": json.length } }; var request = https.request(options, function(response) { console.log("STATUS: " + response.statusCode); console.log("HEADERS: " + JSON.stringify(response.headers)); context.done(null, data); }); request.on("error", function(error) { console.log("sendResponse Error:\n", error); context.done(error); }); request.on("end", function() { console.log("end"); }); request.write(json); request.end(); }
We start out by creating the response object by using the values from the
request and our values for status
, data
and err
.
We parse the ResponseURL
and send the request with https.request
back to
CloudFormation.
Using the Properties in CloudFormation
To use the properties in a CloudFormation template, we use the built-in
Fn::GetAtt
function.
// Example Usage "Outputs": { "Route53Id": { "Value": { "Fn::GetAtt": [ "Route53", "Id" ] }, "Description": "Route53 Id" }, "Route53Name": { "Value": { "Fn::GetAtt": [ "Route53", "Name" ] }, "Description": "Route53 Name" }, "Route53Count": { "Value": { "Fn::GetAtt": [ "Route53", "ResourceRecordSetCount" ] }, "Description": "Route53 Count" } }
Available Custom Resources
We have implemented the following Custom Resources.
Elasticache Dependency
When CloudFormation creates a Redis-backed Elasticache Cluster it does not
provide the endpoints to the stack. This forces us to write logic in the client
to look up the endpoints or to look them up manually and provide them as
configuration. elasticacheDependency
gets information about Elasticache
clusters including endpoints.
Image Dependency
imageDependency
looks up information about an AMI by name. It is much easier to
read an image name instead of an AMI ID.
Route53 Dependency
route53Dependency
looks up information about hosted zone by domain name.
Again, nicer to have than a cryptic zone id.
VPC Dependency
vpcDependency
looks up information about a VPC by name including ID and subnet
information.
Certificate Dependency
certificateDependency
looks up a certificate by name.
Stack Dependency
stackDependency
looks up the outputs from another stack by name. It provides
the outputs as variables to the resources and also includes an extra property
called Environment
.
The Environment
property contains all the outputs from the stack formatted as
a Unix env-file
, (Property1=Value\nProperty2=Value\n)
. This can be used to
provide the parameters to the instance by saving them to an environment file
and, if you use Docker, to provide them to the container with docker run
--env-file
Open Source
All our custom resources are open sourced and can be accessed at the Sony Experia Dev Github account.
The following script are available for each resource:
create-role.sh
- creates the necessary roles for using the Lambda.deploy-lambda.sh
- creates or updates the Lambda as needed.invoke-lambda.sh
- invokes the lambda from the command line.invoke-test-stack.sh
- creates or deletes a test-stack showing how to use the function with CloudFormation.
Conclusion
CloudFormation extended with Custom Resources is a powerful tool that enables us to setup almost all our AWS resources. Currently we are not deploying our Lambdas via CloudFormation but we are working on it. Stay tuned!
No comments:
Post a Comment