AWS Lambda function to set Route53 DNS entries for Autoscaling lifecycle events

Some of our ECS Cluster machines need to have both public and private DNS entries So rather than update Route53 manually (super annoying), we modified the Lambda function we found here: https://objectpartners.com/2015/07/07/aws-tricks-updating-route53-dns-for-autoscalinggroup-using-lambda/ so that it works for both internal and external hosted zones. All you need to do to get this working is add an SNS Topic to the lifecycle events for an Autoscaling Group, and create the following Lambda and set it to subscribe to that SNS Topic.

There are two files for the Lambda, package.json

{
  "name": "lambda-route53-updater",
  "dependencies": { 
    "async": "latest",
    "aws-sdk": "latest"
  }
}

and the node function, index.js

/*
 Update Route53 Entries on Autoscale events with AWS Lambda.
 Code borrowed from https://objectpartners.com/2015/07/07/aws-tricks-updating-route53-dns-for-autoscalinggroup-using-lambda/
 */
 
 
var AWS = require('aws-sdk');
var async = require('async');
 
 
exports.handler = function (event, context) {
    var asgMsg = JSON.parse(event.Records[0].Sns.Message);
    var asgName = asgMsg.AutoScalingGroupName;
    var instanceId = asgMsg.EC2InstanceId;
    var asgEvent = asgMsg.Event;
 
    //console.log(asgEvent);
    if (asgEvent === "autoscaling:EC2_INSTANCE_LAUNCH" || asgEvent === "autoscaling:EC2_INSTANCE_TERMINATE") {
        console.log("Handling Launch/Terminate Event for " + asgName);
        var autoscaling = new AWS.AutoScaling({region: 'us-east-1'});
        var ec2 = new AWS.EC2({region: 'us-east-1'});
        var route53 = new AWS.Route53();
 
        async.waterfall([
            function describeTags(next) {
                console.log("Describing ASG Tags");
                autoscaling.describeTags({
                    Filters: [
                        {
                            Name: "auto-scaling-group",
                            Values: [
                                asgName
                            ]
                        },
                        {
                            Name: "key",
                            Values: ['DomainMeta']
                        }
                    ],
                    MaxRecords: 1
                }, next);
            },
            function processTags(response, next) {
                console.log("Processing ASG Tags");
                if (response.Tags.length == 0) {
                    next("ASG: " + asgName + " does not define Route53 DomainMeta tag.");
                }
                var tokens = response.Tags[0].Value.split(':');
                next(null, tokens[0], tokens[1], tokens[2]);
            },
            function handleEvent(hostedZoneId, zoneName, tagTokenName, next) {
                console.log("Processing Route53 records for zone " + hostedZoneId + " (" + zoneName + ")");
                var action = null;
                var fqdn = (tagTokenName || instanceId) + "." + zoneName + ".";
 
                if (asgEvent == "autoscaling:EC2_INSTANCE_LAUNCH") {
                    action = "UPSERT";
                    ec2.describeInstances({
                        DryRun: false,
                        InstanceIds: [instanceId]
                    }, function (err, data) {
                        next(err, action, hostedZoneId, fqdn, data);
                    });
                }
 
                if (asgEvent == "autoscaling:EC2_INSTANCE_TERMINATE") {
                    action = "DELETE";
                    route53.listResourceRecordSets(
                        {
                            HostedZoneId: hostedZoneId,
                            StartRecordName: fqdn
                        },
                        function (err, data) {
                            next(err, action, hostedZoneId, fqdn, data)
                        })
                }
            },
            function updateRecord(action, hostedZoneId, fqdn, awsResponse, next) {
                console.log("[" + action + "] record set for [" + fqdn + "]");
                var record,
                    fqdnParts = fqdn.split('.'),
                    lastFqdnPart = fqdnParts[fqdnParts.length - 2];
 
                if (action == "UPSERT") {
                    var recordValue = (lastFqdnPart == 'internal'
                            ? awsResponse.Reservations[0].Instances[0].NetworkInterfaces[0].PrivateIpAddress 
                            : awsResponse.Reservations[0].Instances[0].NetworkInterfaces[0].Association.PublicIp
                        ),
                        resourceRecords = [
                            {
                                Value: recordValue
                            }
                        ];
                    record = {
                        Name: fqdn,
                        Type: "A",
                        TTL: 10,
                        ResourceRecords: resourceRecords
                    }
                }
 
                // lambda's do not always execute in chronological order: (╯°□°)╯︵ ┻━┻
                // do not delete internal dns, only update
                if (action == "DELETE" && lastFqdnPart != 'internal') {
                    record = awsResponse.ResourceRecordSets.map(
                        function (recordSet) {
                            if (recordSet && recordSet.Name == fqdn) {
                                return recordSet
                            }
                        }
                    )[0];
                }
 
                if (typeof record === 'undefined') {
                    next('Unable to construct record, perhaps it was already deleted?')
                }
 
                var params = {
                    ChangeBatch: {
                        Changes: [
                            {
                                Action: action,
                                ResourceRecordSet: record
                            }
                        ]
                    },
                    HostedZoneId: hostedZoneId
                };
 
                console.log("Executing Route53 update: [ " + action + " ] " + fqdn);
                route53.changeResourceRecordSets(params, next)
 
            },
            function evaluteResponse(data, next) {
                if (data.ChangeInfo.Status == 'PENDING') {
                    console.log('Successfully updated DNS record id: ' + data.ChangeInfo.Id)
                    next()
                }
                else { next(data) }
            }
 
        ], function (err) {
            if (err) {
                console.error('Failed to process DNS updates for ASG event: ', err);
            } else {
                console.log("Successfully processed DNS updates for ASG event.");
            }
            context.done(err);
        })
    } else {
        console.log("Unsupported ASG event: " + asgName, asgEvent);
        context.done("Unsupported ASG event: " + asgName, asgEvent);
    }
};

Caveats… This Lambda requires that the EC2 instance have a tag named DomainMeta with the value containing colon delimited values for Hosted Zone ID, Zone Name and optionally sub domain name. Here is an example value: ABC1234DEFG:staging.internal:example which would result in the private IP of the EC2 instance being set to example.staging.internal in the Hosted Zone with ID ABC1234DEFG.

Also, all our internal Hosted Zones all end in .internal so any logic checking for the last part of the domain name “internal” will need to match your naming scheme.

Leave a Reply

You can use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>