Managing AWS Route 53 Hosted Zones with AWS Lambda

On AWS, I use a Route 53 private hosted zone for Amazon VPC to allow me to conveniently address EC2 instances and other resources. While all EC2 instances are automatically assigned a private DNS entry, it is usually something fairly unintelligable such as “ip-172-31-51-229.us-west-2.compute.internal.” An entry like “website-production.atomic.aws” is much more helpful, especially when trying to configure communication between various EC2 instances that comprise a larger system.

I constructed an AWS Lambda function to automatically update the DNS records in my Route 53 private hosted zone whenever new instances are created. This ensures that the private hosted zone is up-to-date and can be relied upon for communication between EC2 instances.

AWS Lambda

As I have written about before, AWS Lambda allows you to execute functions in a few different languages (Python, Java, and Node.js) in response to events. One of these events can be an AWS EC2 instance changing state. In this case, I created a trigger so that my lambda function is executed whenever an EC2 instance enters the “running” state.

The Heavy Lifting

The major action item in my code is calling the change_resource_record_sets function on an instance of the Route53 class representing my private hosted zone using the boto3 library for AWS. AWS Lambda automatically makes the boto3 module available for Python lambda functions. It just needs to be loaded with import boto3.

CloudWatch_Management_Console

This code snippet does an “UPSERT” on the A record for ‘website-production.atomic.aws’, setting the value to ‘172.31.51.229’:


route53.change_resource_record_sets(
    HostedZoneId='ZGFQPFMAZZZZZ',
    ChangeBatch={
        'Changes': [
            {
                'Action': 'UPSERT',
                'ResourceRecordSet': {
                    'Name': "website-production.atomic.aws.",
                    'Type': 'A',
                    'ResourceRecords': [
                        {
                            'Value': '172.31.51.229'
                        }
                    ],
                    'TTL': 300
                }
            }
        ]
    }
)

When the EC2 instance state change event is triggered, details about the event are passed to the lambda function. This includes details about the affected EC2 instance, such as its identifier. This allows me to look up details about the instance, including its private IP address, and the tags applied to it:


def lambda_handler(event, context):
    ec2 = boto3.resource('ec2')
    route53 = boto3.client('route53')

    instance_id = event['detail']['instance-id']
    instance = ec2.Instance(instance_id)
    instance_ip = instance.private_ip_address
    instance_tags = instance.tags

In my case, I want to use the “Name” tag of the instance to be the hostname, with the DNS suffix of “atomic.aws”:


"{0}.atomic.aws.".format(instance_name)

The AWS Lambda Function

I wrote two helper functions to assist with extracting a specific tag by name from the set of tags applied to an AWS instance, and to ensure that the value of the extracted tag is a valid hostname. By convention, all of the “Name” tags on EC2 instances in the AWS clusters I manage are of the form “[name]-[environment]”. For example: “website-production,” “website-staging,” etc. Generally these are valid hostnames, but if they are not, I don’t want to try and register them as a DNS record.

Search for value of tag with given key:


def search(dicts, search_for):
    for item in dicts:
        if search_for == item['Key']:
            return item['Value']
    return None

Return valid portion of given hostname:


def is_valid_hostname(hostname):
    if len(hostname) > 255:
        return False
    if hostname[-1] == ".":
        hostname = hostname[:-1]
    allowed = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
    return all(allowed.match(x) for x in hostname.split("."))

The full lambda function is written as a standard lambda_handler for AWS. While I don’t make use of the context parameter, I do use the event parameter to extract data about the event which triggered the lambda function:


from __future__ import print_function

import boto3, json, re

HOSTED_ZONE_ID = 'ZGFQPFMAZZZZZ'


def search(dicts, search_for):
    for item in dicts:
        if search_for == item['Key']:
            return item['Value']
    return None


def is_valid_hostname(hostname):
    if len(hostname) > 255:
        return False
    if hostname[-1] == ".":
        hostname = hostname[:-1]
    allowed = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
    return all(allowed.match(x) for x in hostname.split("."))


def lambda_handler(event, context):
    ec2 = boto3.resource('ec2')
    route53 = boto3.client('route53')

    instance_id = event['detail']['instance-id']
    instance = ec2.Instance(instance_id)
    instance_ip = instance.private_ip_address
    instance_name = search(instance.tags, 'Name')

    print("Processing: {0}".format(instance_id))

    if not is_valid_hostname("{0}.atomic.aws.".format(instance_name)):
        print("Invalid hostname! No changes made.")
        return {'status': "Invalid hostname"}

    dns_changes = {
        'Changes': [
            {
                'Action': 'UPSERT',
                'ResourceRecordSet': {
                    'Name': "{0}.atomic.aws.".format(instance_name),
                    'Type': 'A',
                    'ResourceRecords': [
                        {
                            'Value': instance_ip
                        }
                    ],
                    'TTL': 300
                }
            }
        ]
    }

    print("Updating Route53 to create:")
    print("{0} IN A {1}".format(instance_name, instance_ip))

    response = route53.change_resource_record_sets(
        HostedZoneId=HOSTED_ZONE_ID,
        ChangeBatch=dns_changes
    )

    return {'status':response['ChangeInfo']['Status']}

Note that I defined the hosted zone identifier (ZGFQPFMAZZZZZ) which corresponds to my Route 53 privated hosted zone.

The Security Policy

My lamdba function needs permissions to be able to change the specified private hosted zone. These permissions can be configured with an AWS Identity and Access Management (IAM) policy, which is applied to the role in which the lamdba function executes.


{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets"
            ],
            "Resource": "arn:aws:route53:::hostedzone/ZGFQPFMAZZZZZ"
        },
        {
            "Effect": "Allow",
            "Action": [
                "route53:GetChange"
            ],
            "Resource": "arn:aws:route53:::change/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances"
            ],
            "Resource": "*"
        }
    ]
}

Note that I specifically allow the role to perform “ChangeResourceRecordSets” on the Route 53 zone with specified identifier, and to perform “DescribeInstances” on all EC2 instances. The other actions related to logging are required for AWS CloudWatch logging streams to work correctly.

Putting It All Together

Getting all of the different components connected can be a little tricky. The Route 53 private hosted zone must exist and be referenced by ID in both the lambda function and in the associated security policy. The security policy is applied to the lambda function using a scoped IAM role. This lambda function presumes that all EC2 instances are given a tag with a key of “Name” before they are launched as the function responds to an EC2 instance entering the “running” state. It does not handle situations where the value of the tag with a key of “Name” is changed after EC2 instances have entered the “running state.”

Let me know how that works for you!

Conversation
  • pvidal says:

    Very useful.

    I look forward to manage reverse zone and if a public zone is associated with the plateform to update it aswell if necessary (check for public IP on instance)… :)

  • Laurie Kepford says:

    Does this create a record if it does not exist? Or does it just update an existing one?

    Also can it delete the record when the instance is terminated?

    • I answered this for myself. I created a second Lambda function, modified it to DELETE the records set. Then set a rule to trigger on instance state “stopping” and it worked. However, it does not work when instance is terminated. This is because it does not have an IP address when its terminated and therefore the code cannot match the A record. According to AWS and BOTO documentation the following lines are not strictly needed. However, the code fails without it.

      ‘ResourceRecords’: [
      {
      ‘Value’: instance_ip
      }

      • rashcuva says:

        Did you manage to resolve the delete part of this puzzle ?

    • Bekzot Azimov says:

      UPSERT does create if it does not exist.

  • Ab says:

    I want to trigger lambda with reverse DNS. Help me

  • Ab says:

    I want to trigger lambda with reverse DNS on private hosted zone. Help me

  • Comments are closed.