How To Write Code
That Doesn't Suck

wry observations from the deep end of the software cesspool

2013-03-13

Self-assigning sane domain names to EC2 Instances

Here's a quick background on the goal of this effort.

  • AWS is organized into several regions, each totally separate from the others
  • we want to be able to deploy software in any region
  • in each region we will create EC2 instance to create other instances and deploy software from
  • by default instances have an unfriendly public domain name e.g. ec2-54-234-123-12.us-west-2.compute.amazonaws.com
  • we want that instance to have an easy to remember domain name, e.g. deploy.region.example.com
  • we want that domain name to be assigned programatically from the instance itself

Understanding this journey will probably be clearest if we start with a peek at the end. Domain names can be assigned via Route53, which has an API. We're writing all our stuff for node.js so here's the Javascript code for the final request we want to make to Route53.

var AWS = require("aws-sdk");

AWS.config.update({
  region: "us-west-2",
  credentials: {
    accessKeyId: "XXXXXXXXXXXXXXXXXXXX",
    secretAccessKey: "XxXxxXxxx1xXxXXXxxXXX/XXXxx1XxxXx1XxXxXX",
    sessionToken: "XXxXX ...400 more characters...  XxxX='
  }
});

AWS.Route53.client.changeResourceRecordSets({
  "HostedZoneId": "/hostedzone/Z15D5CDO6LK4GG",
  "ChangeBatch": {
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "deploy.us-west-2.example.com.",
          "Type": "CNAME",
          "TTL": 300,
          "ResourceRecords": [
            {
              "Value": "ec2-54-234-123-12.us-west-2.compute.amazonaws.com"
            }
          ]
        }
      }
    ]
  }
});

Most of this is boiler plate, except for the region and credentials config values, and the HostedZoneId, Name (our desired domain name), and Value (the automatically assigned domain name) as parameters to Route53. To make this work, all we have to do is find a way for the instance to know each of these things.

Item 1: the new domain name

This is going to be composed of 3 pieces: the fixed string "deploy", the name of the region the instance is running in (will be "us-west-2" in these examples), and our organization's domain, "example.com". There are actually a lot of ways for EC2 instances to know their region (some hackier than others) so I'll focus on the other two fields for now. My first thought was "hey, can't you assign tags to EC2 instances when you launch them? I'll just add a tag that says what domain to use." This was easy to try out, and my tags show up nicely in the EC2 console, but how is code running on the instance going to access the tags?

Digression 1: EC2 instance meta-data

My naive hope that tags were somehow magically present on the instance as environment variables or in a config file was quickly dashed. However I knew that instances had access to some meta-data about themselves via making HTTP requests to a magic IP address (169.254.169.254). "Oh cool," I thought, "tags will be in there somewhere."

Digression 1.1: meta-data paths

The data available through this API is organized hierarchically, that is if you fetch a URL like /latest/dynamic/ it returns a list of "sub-folders" like instance-identity/, and you can then ask for /latest/dynamic/instance-identity/ and so on. The interesting bits are actually buried pretty deep, for example region is inside a JSON document found at /latest/dynamic/instance-identity/document (you can find the availability-zone in there too, but that's also available as a plain string at /latest/meta-data/placement/availability-zone). There are command line tools available that allow access to more commonly used values, and a variety of existing modules for node as well. None of them seemed to include tags, and I wasn't sure where'd those might be, so I thought "I'll write a module that starts at the root and spiders it's way down!". Exactly how much code-that-sucks was involved in that effort will require another blog post, but the result was a new module: ec2-instance-data. Read the readme for details, but the basic idea is you get an object that will have all of the instance's meta-data as nested objects, so you could then get the region via something like

var region = instance.latest.dynamic.instanceIdentity.document.region;

Ok, where were we? Oh, right, trying to get the value of an instances tags from the meta-data. You can probably guess the next part: they're not in there. Well how do you get them? Googling led me to this Stack Overflow question and from the answers I discovered there's an API for that: DescribeTags. Amazon recently released an "official" node module aws-sdk, which includes this API, so I thought, hey cool I can use that. Except, as pointed out in some comments on Stack Overflow, you need credentials to call that API.

Digression 2: IAM Roles for EC2 Instances

Hey, didn't I read something a while ago about automagically giving EC2 instances permissions to call AWS APIs? Why yes I did, it's called IAM Roles for EC2 Instances. In short an IAM Role is a named group of permissions (a "policy" in AWS-speak), and you can specify one when you create an instance. So I did the obvious, created a role with permissions to call DescribeTags (and the Route53 APIs), named it "my-role" and assigned it to my instance. (Well I would have, except you can only assign roles when you launch an instance, so I actually terminated my instance and started over with a new one.) Ok, goody, now I can automagically call the DescribeTags API right? Um no, at least not with the AWS SDK for Node. The AWS SDK for Ruby gets some IAM love however:

If the client constructor does not find credentials in AWS.config, or in the environment, it retrieves temporary credentials that have the same permissions as those associated with the IAM role. The credentials are retrieved from the Instance Meta Data Service (IMDS).
Who has two thumbs and just wrote a client for the "IMDS" (and no it's not called that anywhere else)? This guy. Which led to the following code:
var instance = require("ec2-instance-data");
var AWS = require("aws-sdk");

instance.init(function (error, metadata) {
    var credentials = new AWS.Credentials(metadata.iamSecurityCredentials());
    AWS.config.update({ credentials: credentials, region: metadata.region() });
    var ec2 = new AWS.EC2.Client();
    ec2.describeTags({}, console.log);
});

Which produces a whole ton of output because it gets all the tags for every instance you have in the region, not just the current instance's tags. To get just that you have to add a filter to describeTags() call. Which leads me to a classic moment of "how-many-names-for-the-same-thing-can-you-work-into-a-single-function-call":

  ec2.describeTags({
    Filters: [ {
      Name: "resource-id", // name 1
      Values: [
        data["latest"]["meta-data"]["instance-id"]] // name 2
      }]
    }, console.log);

>>>
{ Tags: [
     { ResourceId: 'i-528ed860', // name 3!!!
       ResourceType: 'instance',
       Key: 'Name',
       Value: 'deploy-test' },
     { ResourceId: 'i-528ed860',
       ResourceType: 'instance',
       Key: 'HostedZone',
       Value: 'example.com.' },
     { ResourceId: 'i-528ed860',
       ResourceType: 'instance',
       Key: 'purpose',
       Value: 'deploy' } ] }

Which actually has the tags!, but a quick transform makes it saner:

  ec2.describeTags(..., function (err, response) {
    var tags = {};
    response.Tags.forEach(function (tag) { tags[tag.Key] = tag.Value; });
    console.log(JSON.stringify(tags, null, "  "));
  });

>>>

{
  "Name": "deploy-test",
  "HostedZone": "watchfrog.co.",
  "purpose": "deploy"
}

Ok, making progress. Back to our domain name, we can now get the "deploy" part from the "purpose" tag, the domain from the "HostedZone" tag, and we've already pulled the region from the meta-data. Putting them all together yields

var ResourceRecordSetName = [tags.purpose, metadata.region()].concat(HostedZone.split('.')).join('.');
>>>
deploy.us-west-2.example.com.

Item 2: AWS Credentials

Actually already had to do this in order to call describeTags, but glossed over it a bit above. The magic is in metadata.iamSecurityCredentials(), which is defined in ec2-instance-data. The credentials can be found at /latest/meta-data/iam/security-credentials/my-role/. Except it turns out be not as simple as one would hope because the meta-data service actually sticks the name of the role into the path but the instance doesn't know it's role. This means we have to get it in two steps, first get all the roles, then using the name of the first one (currently there is only one), then get the credentials for that role. Here's that method, which also clocks in with 3 superfluous renamings.

// NOTE: maps meta-data names to the frustratingly similar ones expected by aws-sdk
self.iamSecurityCredentials = function () {
    var role = Object.keys(deep_get(self, "/latest/meta-data/iam/security-credentials"))[0];
    if (!role) return undefined;
    var securityCredentials = deep_get(self, "/latest/meta-data/iam/security-credentials")[role];
    return {
 accessKeyId: securityCredentials.AccessKeyId,
        secretAccessKey: securityCredentials.SecretAccessKey,
        sessionToken: securityCredentials.Token
    }
};

Item 3: HostedZoneId

While we now have the HostedZone (== domain) name, what we need for our call to Route53 is the id. Fortunately Route53 let's us look that up with AWS.Route53.client.listHostedZones(...), but for some inscrutable reason it doesn't accept a filter, so we're left looping over the result.

AWS.Route53.listHostedZones({}, function (err, zones) {
  var zone = null;
  // find the zone matching our HostedZone tag
  for (i = 0; i < zones.HostedZones.length; i++) {
    if (zones.HostedZones[i].Name === tags.HostedZone) {
      zone = zones.HostedZones[i];
      break;
    }
  }
  console.log(zone);
}
>>>
{ Id: '/hostedzone/Z1XXXXXXXXXXGG',
  Name: 'watchfrog.co.',
  CallerReference: '0A35A3DD-F107-E33C-89C9-D2F3D9967815',
  Config: { Comment: 'example.com. domain' },
  ResourceRecordSetCount: 11 }

Woot, there's our HostedZoneId.

Items 4 and 5: current domain name and region

The current domain name is readily available from the meta-data (/latest/meta-data/public-hostname), and we've already pulled the region from meta-data, so we're now good to go. Our final call looks like this

// note: credentials were already set for the call describe tags

AWS.Route53.client.changeResourceRecordSets({
  "HostedZoneId": zone.Id
  "ChangeBatch": {
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": ResourceRecordSetName, // the new domain name we defined above
          "Type": "CNAME",
          "TTL": 300,
          "ResourceRecords": [
            {
              "Value": metadata.latest["meta-data"]["public-hostname"];
            }
          ]
        }
      }
    ]
  }
});

Shortly after that command we can then log into our deploy server at deploy.us-west-2.example.com!