So, you’ve deployed a new test instance of your application …
There’s nothing unique about it. The application serves your web client and static assets from an S3 Bucket via a CloudFront Distribution and publishes an API for your web client via API Gateway.
Except for a depdendency on the Route53 Hosted Zone, each application environment is isolated and deployed in a single CloudFormation stack. Keen to present endpoints for your various environments in a consistent manner, you authored two Route53 RecordSet resources in your CloudFormation template:
- A RecordSet that resolves
test002-app.myproduct.com
via a Route53 alias to a CloudFront Distribution, which is configured withtest002-app.myproduct.com
as an Alternate Domain Name (CNAME). - A RecordSet that resolves
test002-api.myproduct.com
via a Route53 alias to the API, which is configured withtest002-api.myproduct.com
as a Custom Domain Name.
Problem
Your operating environment has changed. Perhaps you were working in a sandbox account and are migrating to your client’s operating platform. Perhaps your organisation has finally implemented a Landing Zone or Control Tower solution.
Either way, it is a commendable move towards best-practice cloud operations so you deploy your app to the new TEST account that has been created for your team.
However, there’s a problem! Your stack won’t deploy because your two RecordSet resources reference the Route53 Hosted Zone for myproduct.com
and this is no longer configured in the same account as your application deployment.
Of course, there can be only one Hosted Zone globally for a given domain and it makes no sense to put this in the TEST account when it will also be required in DEV and PROD accounts. Besides, your Ops and Security people are reticent to provide Route53 change permissions to everyone that may need to deploy an instance of the app.
You concur that managing the Hosted Zone in the Shared Services account is the right architecture and that restricting permissions to your organisation’s DNS configuration is appropriate but the constrained options for managing RecordSets threaten the automaticity of your solution.
Solution
You could accept a compromised solution that couples your deployments to a service request for new RecordSets. Or, you could workaround by pre-provisioning RecordSets for the anticipated range of environment names. That would be a DevOps fail.
There is, however, a solution that preserves your automated deployment whilst maintaining the principle of least privilege for your organisation’s DNS configuration: CloudFormation Custom Resources.
Custom Resources remain agnostic of the implementation of the physical resource they represent; they simply act as a proxy for the resource, keeping it informed of the state of a given CloudFormation stack so that the resource can independently maintain a consistent state.
In this solution, you create two new CloudFormation Custom Resources: AppAliasRecord
to represent the RecordSet for your CloudFront Distribution and APIAliasRecord
to represent the RecordSet for your API. You may specify the resource Type
as
AWS::CloudFormation::CustomResource
. Alternatively, you can specify the resource Type
as Custom::<YourCustomResourceName>
to more meaningfully describe the form/function of your Custom Resource. For reasons we’ll elaborate shortly, the Type
is specified as Custom:MyProductRoute53RequestTopic
in this instance.
AWSTemplateFormatVersion: "2010-09-09"
Resources:
AppAliasRecord:
Type: "Custom::MyProductRoute53RequestTopic"
Version: "1.0"
Properties:
ServiceToken: "arn:aws:sns:ap-southeast-2:012345678912:MyProductRoute53RequestTopic"
AliasTargetDNSName: "test002-app.myproduct.com"
AliasTargetHostedZoneId: "Z2FDTNDATAQYW2"
APIAliasRecord:
Type: "Custom::MyProductRoute53RequestTopic"
Version: "1.0"
Properties:
ServiceToken: "arn:aws:sns:ap-southeast-2:012345678912:MyProductRoute53RequestTopic"
AliasTargetDNSName: "test002-api.myproduct.com"
AliasTargetHostedZoneId: "<API hosted zone ID>"
The resource definition allows us to pass an arbitrary number of string, array or map parameters (defined as Properties
) to the Custom Resource. It also enables us to specify outputs that would be returned from the underyling resource but none are required here. In this instance, you pass AliasTargetDNSName
to supply the DNS name of the alias (i.e. the Alternate Domain Name of the CloudFront Distribution and the Custom Domain Name of the API) and AliasTargetHostedZoneId
to supply the Hosted Zone IDs of your CloudFront Distribution and API.
You will also note a property that must be supplied for all Custom Resources: ServiceToken
. This defines the endpoint that CloudFormation will invoke when a stack operation is performed: passing a Create message if a CreateStack
operation has been invoked, an Update message if an UpdateStack
operation has been invoked or a Delete message if a DeleteStack
operation has been invoked. See the documentation for details but by way of example, invoking CreateStack
would result in the following being sent to the ServiceToken
endpoint.
/**
* Example of a 'Create' message sent by CloudFormation to the
* actual resource when a 'CreateStack' operation has been invoked.
*/
{
"RequestType" : "Create",
"ResponseURL" : "http://<CloudFormation-generated pre-signed S3 URL for response>",
"StackId" : "arn:aws:cloudformation:ap-southeast-2:123456789012:stack/test002-myproduct-stack/guid",
"RequestId" : "<CloudFormation-generated unique ID for this request>",
"ResourceType" : "Custom::MyProductRoute53RequestTopic",
"LogicalResourceId" : "AppAliasRecord",
"ResourceProperties" : {
"AliasTargetDNSName" : "test002-app.myproduct.com",
"AliasTargetHostedZoneId" : "Z2FDTNDATAQYW2"
}
}
In the same way CloudFormation issues a create request for Non-Custom Resources and waits for confirmation of success or failure, CloudFormation will also wait for a success or fail message to be returned from the Custom Resource. This is received via the pre-signed ResponseURL
that was sent in the request and should look similar to the following.
{
"Status" : "SUCCESS",
"StackId" : "arn:aws:cloudformation:ap-southeast-2:012345678912:stack/test002-myproduct-stack/guid",
"RequestId" : "<CloudFormation-generated unique ID for this create request>",
"LogicalResourceId" : "AppAliasRecord"
}
In this way you ensure your stack is atomically deployed or destroyed regardless of whether the resources contained are orthodox CloudFormation resources or Custom Resources.
The infrastructure required to manage the actual resource in response to change requests from the Custom Resource is not complex. The ServiceToken
endpoint can reference a Lambda function directly but configuration (including cross-account access) is generally simpler if you configure an SNS Topic as the endpoint and subscribe a Lambda to the Topic that will create or delete Route53 RecordSets per the details supplied in requests from CloudFormation. I won’t repeat the guidance provided in this AWS Blog Post.
Applying the Pattern
CloudFormation Custom Resources are a useful tool in situations where you may have a bespoke component (e.g. a proprietary subsystem hosted in EC2) that must be integrated with a stack deployment or you wish to manage a resource that is not supported by CloudFormation.
Custom Resources are also an option for managing resources that may be shared across application environments (regardless whether these environments are collocated or distributed across accounts). Take, for example, an RDS instance that provides a multi-tenant backend for the stacks you deploy on a per-customer basis. You could use a Custom Resource to provision a new customer schema or new customer master data to this shared database. You might also use a Custom Resource to register/deregister application environments with a billing system or manage other shared resources such as Cognito User Pools.
However, Custom Resources are particularly suited to integrating components and services across multi-account structures. In addition to DNS configuration you may find the pattern useful for integrating other shared services offered by your multi-account operating platform such as SSL certificate management. It may also be useful for deploy-time invocation and output handling of data processing pipelines.
Addendum (1/10/2019)
Thanks to Byron for suggesting an alternative approach. It is possible to create a new Hosted Zone (or Zones) in the same account as your application environment specifically for the subdomains required by your application endpoints (see the Route53 Documentation).
The Hosted Zone for the subdomain could then become part of the application stack and would benefit from a security configuration that restricts the scope of DNS impact a single account could have.
This may be sufficient for some deployments but if you want to reserve the option to deploy the application using an arbitrary environment name, you will need to consider that each new Hosted Zone for a subdomain requires an NS RecordSet to be created in the parent domain’s Hosted Zone. A facility for dynamically creating these NS RecordSets in the domain’s Hosted Zone would, therefore, still be required.
In addition, multiple levels of subdomain can make SSL certificate management somewhat awkward because AWS-issued certificates cannot support two levels of wildcarding. You will, therefore, either need to create a new certificate for every environment or create two Hosted Zones per environment to support one endpoint each wth a single-level subdomain (i.e. following the original scheme of test002-app.myproduct.com
and test002-api.myproduct.com
).