Reading instance tags in Amazon EC2

A useful but underdocumented procedure

One of the great things about Go as a cloud development language is that it’s very easy to ship everything as a single self-contained binary. At work we build a single cloud image which can be deployed in a number of configurations, depending on the functionality needed. It can be a staging server or a production server, eventually it’ll also support being a dedicated server or a self-hosted one; and of course, it can be run locally for development and debugging.

To handle this, the binary needs to look at its runtime metadata for information about what we expect it to do. While this is really easy on Cloud Foundry platforms — just look at environment variables — it turns out to be significantly more involved on Amazon Web Services (AWS) when running in Elastic Compute Cloud (EC2).

The first problem is picking an official SDK. There’s the official SDK for Go, which at the time of writing is at v1.35.26; but there’s also the developer preview of the v2 SDK, which is confusingly tagged as v0.29.0. The v0.29.0 SDK “v2” is more recent than the v1.35.26 SDK, and at first glance has more functionality. Unfortunately, although it was released back in 2017, they’re still making breaking changes in 2020. Documentation is missing or incorrect, examples don’t even compile. With that in mind, I decided to stick with the old SDK until things settle down. So, here’s how to get the tags for the running EC2 instance, using only the original AWS Go SDK.

I’ll start off by listing the libraries I used:

import (
	"errors"
	"fmt"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/ec2metadata"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/ec2"
)

The first step is to find out the region and instance ID of the running instance. It’s obvious enough why the instance ID is needed, but you might be wondering why the region. It turns out that the EC2 APIs don’t default to the current region; instead, they demand that you specify the current region explicitly for any operation, or else you get MissingRegion: could not find region configuration returned as an error.

The code actually works by connecting to the instance metadata service, which is available at http://169.254.169.254/ inside the instance. In the shell, you might query it via curl:

curl http://169.254.169.254/latest/meta-data/instance-id

So I could have used Go’s HTTP client, but I wanted to do things the official way. The official Go equivalent of the above curl command looks like this:

	sess := session.New()
	svc := ec2metadata.New(sess)
	if !svc.Available() {
		panic("ec2metadata service not available")
	}
	instance, err := svc.GetMetadata("instance-id")
	if err != nil {
		panic(fmt.Errorf("can't get instance ID: %w", err))
	}

The SDK uses an aws/client to make an HTTP call to get the info. The ec2metadata.GetMetadata call takes a string argument. It’s documented to be a path, but as you can see it’s not simply the entire path you would use with a curl command. Fortunately the SDK is open source, and delving into the code reveals that it’s the part of the service path that comes after /latest/meta-data/

Until quite recently, many people looked up the region by looking up the Availability Zone (AZ) and mapping that back to the region. That usually meant removing the last character — for example, AZ us-east-1c is region us-east-1 — but of course, there was no guarantee that rule would continue to hold true, so I ruled that approach out.

Fortunately, a few months ago someone at Amazon got the developers to add an API call to the ec2metadata service to fetch the region. It doesn’t look like there’s any documentation of it yet, but you can fetch the Region as http://169.254.169.254/latest/meta-data/placement/region, or by passing the path placement/region to ec2metadata.GetMetadata exactly as for instance-id above. So now we can write a utility function to get the region and instance ID:

func getRegionAndInstance() (string, string, error) {
	sess := session.New()
	svc := ec2metadata.New(sess)
	if !svc.Available() {
		return "","", errors.New("ec2metadata service not available")
	}
	instance, err := svc.GetMetadata("instance-id")
	if err != nil {
		return "", "", fmt.Errorf("can't get instance ID: %w", err)
	}
	region, err := svc.GetMetadata("placement/region")
	if err != nil {
		return "", "", fmt.Errorf("can't get region: %w", err)
	}
	return region, instance, nil
}

But there’s another way to do it. If you fetch the entire Instance Identity Document using ec2metadata.GetInstanceIdentityDocument, it contains both the instance ID and the region:

func getRegionAndInstance2() (string, string, error) {
	sess := session.New()
	svc := ec2metadata.New(sess)
	if !svc.Available() {
		return "","", errors.New("ec2metadata service not available")
	}
	idd, err := svc.GetInstanceIdentityDocument()
	if err != nil {
		return "", "", fmt.Errorf("can't get instance identity document: %w", err)
	}
	return idd.Region, idd.InstanceID, nil
}

Is that better? On the plus side, the SDK only has to make a single HTTP request; on the minus side, it has to fetch and deserialize an entire JSON object into a struct, most of which we don’t need. For my usage, running it once when our web application starts up, it really doesn’t matter which we use, both are fast.

Whichever you choose, you should now have the region and instance ID, so it’s time to move on to the difficult part, fetching the tags as a map of strings.

The first trick is that although tags are metadata, you can’t get them using the ec2metadata service; you need to go in through the main ec2 API instead, setting up the region first as mentioned above:

	sess, err := session.NewSession(&aws.Config{
		Region: aws.String(region)},
	)
	svc := ec2.New(sess)

(Thanks to Claes Mogren for pointing me in the right direction.)

The next problem is that there isn’t a GetInstanceTags method. Instead, there’s a general purpose DescribeTags method to fetch information about any resource of any type, and you have to set up filters to restrict its output to the actual object you want. Those filters are specified as a field of a DescribeTagsInput object. After examining some examples, I arrived at the following code:

	input := &ec2.DescribeTagsInput{
		Filters: []*ec2.Filter{
			{
				Name:   aws.String("resource-type"),
				Values: []*string{aws.String("instance")},
			},
			{
				Name:   aws.String("resource-id"),
				Values: []*string{aws.String(instance)},
			},
		},
	}

All this complexity is so that you can have filters on any property of any object, using single or multiple values, or presumably even null (nil) values. Not really the Go way; it reminds me of LDAP or JNDI. But, that’s the way the v1 Go SDK needs it done.

Given that the input was a DescribeTagsInput object, you might have already guessed that what you get back from DescribeTags is a DescribeTagsOutput object, which also implements an overly general system of structs and pointers so that keys and values, and even the entire list of tags returned, can be allowed to be nil. So the final task is to run through that, and turn it into an idiomatic Go map[string]string of the non-nil values, so we can easily query or range over our tags:

	tags := make(map[string]string)
	result, err := svc.DescribeTags(input)
	if err != nil {
		return tags, fmt.Errorf("ec2.DescribeTags failed: %w", err)
	}
	if result.Tags == nil {
	    return tags, nil
	}
	for _, t := range result.Tags {
	    if t != nil {
			k := t.Key
			v := t.Value
			if k != nil && v != nil {
				tags[*k] = *v
			}
		}
	}

Of course, if you need to support nil tag names and/or nil tag values, this won’t work for you, and you’ll have to deal with the []*TagDescription in all its glory.

The final code, with both options for fetching the region and instance ID, is in a gist. I hope it saves you from the frustration I went through trying to find this stuff out.