Using the Ruby SDK for Amazon Web Services to create any number of EC2 instances and configure their DNS

Amazon Web Services and the Ruby SDK

For the recent medical imaging workshop, I needed to provide each participant with a remote workstation: an AWS EC2 (remote server) Windows instance to which they would connect. The logistics of setting up these instances as remote workstations presented some interesting challenges. I wanted to be able to create, start, and stop up to 20 identical workstations, and have them accessible by DNS address rather than IP number. As these are on-demand EC2 images, billed by the hour, I did not want them to be running for too long, so I needed to be able to create, destroy, start and stop the instances using a script. In addition, I wanted each workstation to have a static IP for DICOM communication purposes, but on-demand EC2 instances are allocated a public IP number each time they are started up.

The design called for each instance to have two network interfaces: one with a configureable static IP number on a private subnet, and one with a dynamic public IP address that could be resolved by a systematic DNS address. The AWS API made satisfying these requirements quite easy. I use the SDK for Ruby, though it’s also available in Python, Java, .Net, Node, Go, PHP and other languages.

Using the SDK

The first step is to log in. Credentials are stored in a configuration file ~/.aws/credentials in the form of a key and ID. Multiple named profiles are permitted; as the course was held in Europe and the instances hosted in the eu-central-1 region, I created a profile named europe.

# .aws/credentials
[europe]
aws_access_key_id = AKIAI3F00F00F00F00
aws_secret_access_key = hiKopd9h8iKERBAABAABAABAA

Then you log in with these credentials and create a client (with methods corresponding to the API) or a resource, for an object-oriented interface (nice explanation here. I created both.

Aws.config.update(
  {
    region:      'eu-central-1',
    credentials: Aws::SharedCredentials.new(:profile_name => 'europe')
  }
)
@rsrc   = Aws::EC2::Resource.new
@client = Aws::EC2::Client.new

Creating EC2 Instances

Now we can do stuff. EC2 instances are created with the resource method create_instances which I put in a method, called with the index of the workstation to create. The image ID is the AMI I created by saving the pre-configured master workstation. EC2 provides a free-form tagging system, so to be able to locate the workstations by name, I create a tag ‘Name’ with value ‘workstation01’ for ordinal 1, IP address 1, and so on. The adddition of the domain name (idoimaging.com) in name_for is to accommodate DNS records later.

The private IP address is set to the base of the private subnet (which depends on the implementation, I’ve used 10.0.0.0 here for illustration), ending with the ordinal, or index, of the instance. Calling create_instance_resource(1) results in an instance being created with IP 10.0.0.1 and a Name tag of workshop01.idoimaging.com.

def name_for(ordinal, full = false)
  sprintf('workshop%02d', ordinal) + (full ? ".idoimaging.com" : "")
end

def create_instance_resource(ordinal)
  params = {
    image_id: 'ami-f00f000',
    min_count: 1,
    max_count: 1,
    instance_type: 't2.small',
    placement: { availability_zone: "eu-central-1b" },
    key_name: "my_key",
    security_groups: ["my_group"],
    private_ip_address: "10.0.0.#{ordinal}",
    tag_specifications: [
      {
        resource_type: "instance",
        tags: [
          {
            key:   'Name',
            value: name_for(ordinal)
          },
        ],
      },
    ]
  }
  @rsrc.create_instances(params)
end

DNS Records

Now we have an instance with a defined private IP, which is necessary for the PACS communications for the workshop. In order to connect with the instance using Remote Desktop, we need to either use the public IP of the instance, which is randomly assigned by AWS, or we need to assign a DNS record for this address. I chose the DNS approach, and created DNS records with names like workshop01.idoimaging.com to map to the public IP addresses. To create the DNS records, instances are located by the Name tag (based on the ordinal index of the workstation) and queried for their public IP in the method public_ip_of_instance():

def name_filter(ordinal)
  {
    name:   "tag:Name",
    values: [name_for(ordinal)],
  }
end

def instance(ordinal)
  filter = {
    filters: [name_filter(ordinal)]
  }
  instances = @rsrc.instances(filter)
  return instances.count.eql?(1) ? instances.first : nil
end

def public_ip_of_instance(ordinal)
  inst = instance(ordinal)
  return inst ? inst.public_ip_address : nil
end

Now we have the public IP number for the instance of a given ordinal, we can create a DNS record for it. This uses the AWS Route 53 service, so we need a Route 53 client. This code block has had all the error checking removed, along with the code that checks for existing DNS records, and just handles DNS record creation in the method create_dns_for(), which takes the ordinal of the workstation as a parameter. This method gets the public IP of the instance by calling public_ip_of_instance(), and passes this IP number to make_record_set(ordinal, ip). This method creates a hash of values (resource_record_set) with the fully qualified DNS name of the workstation in name, the type of DNS record (A) in type, and the public IP number in the value key of resource_records. This hash is in turn wrapped in a hash with action of UPSERT (update or insert), and returned from the method.

Finally, the hash is passed to the change_resource_record_sets method of the Route 53 object, and the A record mapping the DNS name to the public IP of the instance is created.

def setup_route53
  @route53 = Aws::Route53::Client.new(region: 'eu-central-1')
end

def get_record_sets
  filter = { hosted_zone_id: HOSTED_ZONE_ID }
  record_sets_list = @route53.list_resource_record_sets(filter)
  return record_sets_list.resource_record_sets
end

# Return record set with name containing given ordinal
def get_record_set(ordinal)
  record_sets = get_record_sets.select { |record_set| record_set.name.eql?("#{name_for(ordinal, true)}.") }
  return record_sets.count.eql?(1) ? record_sets.first : nil
end

# Update/insert record to have given name and IP number
def make_record_set(ordinal, ip)
  changes = {
    :action => 'UPSERT',
    :resource_record_set => {
      :name => name_for(ordinal, true),
      :type => "A",
      :ttl => 600,
      :resource_records => [{:value => ip}]
  }}
end

def create_dns_for(ordinal)
  setup_route53
  public_ip = public_ip_of_instance(ordinal)
  if public_ip
    changes = {
      change_batch: {
        changes: [make_record_set(ordinal, public_ip)],
      },
      hosted_zone_id: 'Z1690F00F00ZSK',
    }
    @route53.change_resource_record_sets(changes)
  end
end

The result of this call is the creation of a DNS record mapping workshop1.idoimaging.com to the public IP of that particular instance, which also has the previously-configured private IP address ending in .1.

The above is just the minimum code to create an EC2 instance and create a DNS record pointing to it. Further code provides error testing and recovery, checks for pre-existing EC2 instances and DNS records, and code to start and stop the instances once they are created.