AWS Management Tools Blog

Multi-Account Strategy: Using AWS CloudFormation Custom Resources to Create Amazon Route 53 Resources in Another Account

Today, most customers have more than one AWS account. While a multi-account strategy brings many benefits―simplified billing, security isolation, decentralized control, etc., it also introduces new challenges. One challenge is that the users in one account occasionally need to create resources in another.

In this post, I will show you how to use a custom resource from AWS CloudFormation to create Amazon Route 53 resource records in another account.

Multi-account scenario

A common driver for adopting a multi-account strategy is to give autonomy and agility to individual business units.

Imagine that you work for a company that has adopted this strategy. There is a centralized IT team that manages consolidated billing and shared resources such as AWS Direct Connect connections. In addition, they manage the Domain Name System (DNS) for the company. They have chosen to host DNS in Amazon Route 53. Various business units have their own accounts and operate with relatively little oversight.

The marketing team regularly creates short-lived websites for various marketing campaigns. They use CloudFormation to launch and manage these sites, but CloudFormation cannot create resources in other accounts. Therefore, the marketing team has to submit a request to central IT to update DNS. This often takes hours or even days to complete. They want a simple way to create a CNAME record in the central account from a CloudFormation template in their own account.

CloudFormation custom resources

One way to create resources in another account is to use a CloudFormation custom resource, which allows you to execute custom code from a CloudFormation template. CloudFormation supports two types of custom resources:

  • The first invokes an AWS Lambda function, allowing you to execute custom code.
  • The second sends a message to an Amazon SNS topic to which you have subscribed.

Here’s an example. The Figure below outlines a solution to the problem scenario described earlier. Marketing is launching a CloudFormation stack and wants to create a CNAME in Amazon Route 53 hosted in another account.

The high-level workflow goes like this:

  1. CloudFormation sends a message to an SNS topic in the central account.
  2. A Lambda function is invoked in response to the message.
  3. Lambda creates the Amazon Route 53 CNAME record.
  4. Lambda calls an Amazon S3 presigned URL, indicating that it completed successfully.
  5. CloudFormation marks the custom resource complete.


You might be asking yourself why I used SNS when CloudFormation custom resources can invoke Lambda directly. I could have invoked Lambda directly, but SNS simplifies cross account permissions and makes the configuration easier.

Configuring the custom resource

Begin by configuring the services in the central account. I will assume that Amazon Route 53 is already configured, so you need to configure SNS and Lambda.

I have included a CloudFormation template to configure these services in the central account for you. The template requires two inputs:

  • The ID of the Amazon Route 53 hosted zone in which to allow the business units to create records.
  • A list of the account IDs that are authorized to create resource records in Amazon Route 53.

This restricts access so that only the accounts that you specify can create resource records in the hosted zone that you specify. Obviously, you don’t want to allow everyone to create―or worse, change―records anywhere in your DNS system.

After the CloudFormation template completes, it outputs the ARN of the SNS topic used to request new resources. Make note of this, as you use it to configure the custom resource later in this post.

In the Lambda console, you see a new function called CreateRoute53CNAME. This is the logic for the custom resource. The primary method, lambda_handler, is shown in the code below.

Example: Lambda function snippet

def lambda_handler(event, context):
  #SNS events contain a wrapper around the Lambda event. Unpack the
  #Lambda event from SNS. Not needed if you’re calling Lambda directly.
  print("SNS Event: " + json.dumps(event))
  event = json.loads(event['Records'][0]['Sns']['Message'])            
  print("Lambda Event: " + json.dumps(event))

  try: 
    hostedzone = 'ZXAOMNFL85JIZ'
    type = event['RequestType']
    source = event['ResourceProperties']['Source']
    target = event['ResourceProperties']['Target']
    
    if type == 'Create':
      print "Creating CNAME " + source + "->" + target + " in " + hostedzone
      change_resource_record_sets('UPSERT', hostedzone, source, target)
    elif type == 'Update':
      oldsource = event['OldResourceProperties']['Source']
      oldtarget = event['OldResourceProperties']['Target']
      print "Deleting old CNAME " + oldsource + "->" + oldtarget + " in " + hostedzone
      change_resource_record_sets('DELETE', hostedzone, oldsource, oldtarget)
      print "Creating new CNAME " + source + "->" + target + " in " + hostedzone
      change_resource_record_sets('UPSERT', hostedzone, source, target)
    elif type == 'Delete':
      print "Deleting CNAME " + source + "->" + target + " in " + hostedzone
      change_resource_record_sets('DELETE', hostedzone, source, target)
    else:
      print "Unexpected Request Type"
      raise Exception("Unexpected Request Type")
    
    print "Completed successfully"
    responseStatus = 'SUCCESS'
    responseData = {}
    sendResponse(event, context, responseStatus, responseData)
      
  except: 
    print("Error:", sys.exc_info()[0])
    responseStatus = 'FAILED'
    responseData = {}
    sendResponse(event, context, responseStatus, responseData)

As you can see, the first thing the Lambda function does is unpack the SNS event to get the properties that were passed from CloudFormation. In this example, you pass in a source (for example, www.example.com) and a target (for example, my-loadbalancer-1234567890.us-east-1.elb.amazonaws.com) for a CNAME record.

In addition, the event always includes a RequestType value of Create, Update, or Delete. The code below is an example of an Update event. In the case of an Update, you get an additional set of OldResourceProperties values that are not included in Create and Delete events.

Example: Sample update event in SNS

{ "Records": [ 
  { "EventVersion": "1.0", 
    "EventSubscriptionArn": "arn:aws:sns:us-east-1:…:RequestRoute53CNAME:…", 
    "EventSource": "aws:sns", 
    "Sns": { 
       "MessageId": "6ae3f7a1-2772-568c-9175-a603bc40bf03", 
       "Message": {
          "RequestType":“Update",
          "ResponseURL":" https://cloudformation-custom-resource-response-useast1...",
          "ResourceType":"Custom::CNAME",
          "OldResourceProperties":{
             “Target":"my-first-loadbalancer.us-east-1.elb.amazonaws.com",
             “Source":"test.example.com“
          },
          "ResourceProperties":{
             “Target":"my-second-loadbalancer.us-east-1.elb.amazonaws.com",
             “Source":"test.example.com“
          }
        }
      } 
    } 
  } 
]}

Depending on the type of event received, call the Amazon Route 53 change_resource_record_sets API operation to create or delete the appropriate records. Finally, you must send the result of the operation to CloudFormation so it can mark the resource complete. The code below reports status.

Example: Reporting results to CloudFormation

def sendResponse(event, context, responseStatus, responseData):
  data = json.dumps({
    'Status': responseStatus,
    'Reason': 'See the details in CloudWatch Log Stream: ' + context.log_stream_name,
    'PhysicalResourceId': context.log_stream_name,
    'StackId': event['StackId'],
    'RequestId': event['RequestId'],
    'LogicalResourceId': event['LogicalResourceId'],
    'Data': responseData
  })
  opener = urllib2.build_opener(urllib2.HTTPHandler)
  request = urllib2.Request(url=event['ResponseURL'], data=data)
  request.add_header('Content-Type', '')
  request.get_method = lambda: 'PUT'
  url = opener.open(request)

As you can see, CloudFormation expects the Lambda function to provide a JSON document. This document must include a status of either SUCCESS or FAILED. The original request (such as the SNS update event) included a response URL, an S3 presigned URL. I use the urllib2 module to PUT the JSON document to the presigned URL.

Using the custom resource

Now that you have your Lambda function created in the central account, you can invoke it from a custom resource in one of the accounts owned by your authorized business units. The code below shows a simple example stack that uses the custom resource.

Pass three things to your custom resource. First, pass the ARN of the SNS topic used to initiate the custom resource. The ARN was an output from the template used earlier to create the custom resource in the central account. Second and third, pass the source and target values for the CNAME.

Example: Using the custom resource

Parameters:
  Queue: 
    ServiceToken: The ARN of the SNS topic used to request a CNAME record. 
    Type: String
    Default: arn:aws:sns:us-east-1:999999999999:RequestRoute53CNAME
  Source: 
    Description: The pretty name for the CNAME record.
    Type: String
    Default: www.example.com 
  Target: 
    Description: The target of the CNAME record.
    Type: String
    Default: my-loadbalancer-1234567890.us-east-1.elb.amazonaws.com
 
Resources: 
  CNAME: 
      Type: Custom::CNAME
      Properties: 
        ServiceToken: !Ref Queue
        Source: !Ref Source
        Target: !Ref Target

As you can see, the custom resource is easy to use but not valuable on its own. Here’s how to incorporate this into a larger solution. In the code below, I create an Elastic Beanstalk stack (using the PHP sample application stack) and use the custom resource to create a CNAME record (for example, beanstalk.example.com) and a friendly name for the stack.

Example: Using the custom resource in a larger solution

Resources: 
  sampleApplication:
    Type: AWS::ElasticBeanstalk::Application
    Properties:
      Description: AWS Elastic Beanstalk Sample Application
  sampleApplicationVersion:
    Type: AWS::ElasticBeanstalk::ApplicationVersion
    Properties:
      ApplicationName:
        Ref: sampleApplication
      Description: AWS Elastic Beanstalk Sample Application Version
      SourceBundle:
        S3Bucket: !Sub "elasticbeanstalk-samples-${AWS::Region}"
        S3Key: php-sample.zip
  sampleConfigurationTemplate:
    Type: AWS::ElasticBeanstalk::ConfigurationTemplate
    Properties:
      ApplicationName:
        Ref: sampleApplication
      Description: AWS Elastic Beanstalk Sample Configuration Template
      OptionSettings:
      - Namespace: aws:autoscaling:asg
        OptionName: MinSize
        Value: '2'
      - Namespace: aws:autoscaling:asg
        OptionName: MaxSize
        Value: '6'
      - Namespace: aws:elasticbeanstalk:environment
        OptionName: EnvironmentType
        Value: LoadBalanced
      SolutionStackName: 64bit Amazon Linux running PHP 5.3
  sampleEnvironment:
    Type: AWS::ElasticBeanstalk::Environment
    Properties:
      ApplicationName:
        Ref: sampleApplication
      Description: AWS Elastic Beanstalk Sample Environment
      TemplateName:
        Ref: sampleConfigurationTemplate
      VersionLabel:
        Ref: sampleApplicationVersion
  CNAME: 
      Type: Custom::CNAME
      Properties: 
        ServiceToken: arn:aws:sns:us-east-1:999999999999:RequestRoute53CNAME
        Source: beanstalk.example.com
        Target: !GetAtt sampleEnvironment.EndpointURL

This CloudFormation stack waits for the Elastic Beanstalk environment to launch and then creates a CNAME record in the central IT account.

Conclusion

In this post, you learned how to create a CloudFormation custom resource, which allows you to execute custom logic in a CloudFormation template. You also learned how to invoke custom code in one AWS account, from a CloudFormation stack in another account.

About the Author

Brian Beach is a Solutions Architect on the World Wide Public Sector team where he focuses on higher education. Brian is excited by the growth of cloud computing and enjoys teaching others about technology. He is a frequent author and speaker. In his free time, Brian can be found playing with his three children in Raleigh, NC.