How to Create EC2 Duplicate Instance with Ansible

Many companies like mine use AWS infrastructure as a service (IaaS) heavily. Sometimes we want to perform a potentially risky operation on an EC2 instance. As long as we do not work with immutable infrastructure it is imperative to be prepared for instant revert.

One of the solutions is to use a script that will perform instance duplication, but in modern environments, where unification is an essence it would be wiser to use more common known software instead of making up a custom script.

Here comes the Ansible!

Ansible is a simple automation software. It handles configuration management, application deployment, cloud provisioning, ad-hoc task execution, network automation, and multi-node orchestration. It is marketed as a tool for making complex changes like zero-downtime rolling patching, therefore we have used it for this straightforward snapshotting task.

Requirements

For this example we will only need an Ansible, in my case it was version 2.9 - in subsequent releases there is a major change with introducing collections so let's stick with this one for simplicity.

Due to working with AWS we require a minimal set of permissions, which include permissions to create:

  • AWS snapshots
  • Register images (AMI)
  • Start and stop EC2

Environment preparation

Since I am forced to work on Windows I have utilized Vagrant instances. Please find below a Vagrantfile content.

We are launching a virtual machine, with Centos 7 and Ansible installed.

For security reasons Ansible, by default, has disabled reading configuration from mounted location, therefore we have to implcity indicate path /vagrant/ansible.cfg.

Listing 1. Vagrantfile for our research

Vagrant.configure("2") do |config|
  config.vm.box = "geerlingguy/centos7"
  config.vm.hostname = "awx"
  config.vm.provider "virtualbox" do |vb|
    vb.name = "AWX"
    vb.memory = "2048"
    vb.cpus = 3
  end
  config.vm.provision "shell", inline: "yum install -y git python3-pip"
  config.vm.provision "shell", inline: "pip3 install ansible==2.9.10"
  config.vm.provision "shell", inline: "echo 'export ANSIBLE_CONFIG=/vagrant/ansible.cfg' >> /home/vagrant/.bashrc"
end

First tasks

In the first lines of the Ansible we specify few meta values. Most of them, like name, hosts and tasks are mandatory. Others provide auxiliary functions.

Listing 2. duplicate_ec2.yml playbook first lines

---
- name: yolo
  hosts: localhost
  connection: local
  gather_facts: false
  become: false
  vars:
    instance_id: i-deadbeef007

tasks:

    - name: Getting minimal set of facts for datetime
      setup:
        gather_subset: 'min'

    - set_fact:
        current_datetime: "{{ ansible_date_time.iso8601 }}"

    - name: Install required pip packages
      become: yes
      pip:
        name:
          - boto3
          - boto

 

From the top we assign a name, to determine what this playbook is about.

Since this will only connect to AWS we have to limit execution to run on localhost and, to avoid SSH attempts, we add connection type local, which actually means no-connection, straight execution on a machine.

Next we proceed with disabling facts gathering, to speed up our execution. Become keyword determines whether Ansible should use privileged account (e.g. sudo). Since we do not need it, it is a nice custom to disable rising privileges.

Vars section defines facts usable over the entire playbook, currently we have only one, an instance id.

Finally we begin actually working in the tasks section.

Firstly in the playbook we will need datetime value, so a minimal set of facts need to be collected. Possible values are:

- all - virtually all facts, default set if gather_facts in meta section is not specified, - min- greatly reduced information, that does not require digging into system setup - hardware, - network, - virtual, - ohai & facter - two common facts providers, read more about ohai in Chef documentation and Puppet for facter specification.

Now we can obtain the datetime, and since it will be used multiple times over the tasks, we register it as fact in our second task.

Finally, modules for AWS control require boto and boto3 Python modules to work, so we can ensure they are present by executing the pip module.

Please notice that we can overwrite global values, in this example: we escalate our privileges by setting become: true.

Authentication

AWS authentication may have different forms, straight one - just login and password or more advanced that require assuming roles.

Moreover we might be forced to use multi factor authentication, which complicates authentication further.

To overcome this we need to provide: user login, user password, MFA token serial and current MFA code.

Since the vault-encrypted secrets are quite long, in order to save, they have been truncated in all listings.

Listing 3. duplicate_ec2.yml enhanced with authentication elements

(...)
  vars:
    instance_id: i-deadbeef007
    aws_credentials:
      aws_region: eu-west-2
      aws_access_key: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          34613664316337623136383935636262353361643736666432666331623563636333363431626134
          6533636363383231...
      aws_secret_key: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          39386565376137663934333734316236346232643838623530386538303561393730373662626238
          6337636363353938663664...
      mfa_serial_number: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66306332383534343338373532633930373536663638303439633837613832643966303236396562
          393861366333333562336231666...

  tasks:

(...)
    - pause:
        prompt: "Please enter Your MFA code: "
        echo: yes
      register: mfa_code

    - sts_assume_role:
        role_arn: "arn:aws:iam::807777736438:role/User"
        aws_region: "{{ aws_credentials.aws_region }}"
        aws_access_key: "{{ aws_credentials.aws_access_key }}"
        aws_secret_key: "{{ aws_credentials.aws_secret_key }}"
        mfa_serial_number: "{{ aws_credentials.mfa_serial_number }}"
        mfa_token: "{{ mfa_code.user_input }}"
        role_session_name: "Snapshotting-{{ instance_id }}"
      register: assumed_role

    - set_fact:
        aws_secrets: &aws_secrets
          aws_access_key: "{{ assumed_role.sts_creds.access_key }}"
          aws_secret_key: "{{ assumed_role.sts_creds.secret_key }}"
          security_token: "{{ assumed_role.sts_creds.session_token }}"
          region: "{{ aws_credentials.aws_region }}"

First thing that catches our eye are the big blocks of digits. These are ansible-vault encrypted strings. We create them by calling `ansible-vault encrypt_string` and inserting required data.

Please be aware that ctrl+d must not be preceded with enter key, otherwise the new line character will be included in the secret's value!

Listing 4. ansible-vault usage example

[vagrant@awx ~]$ ansible-vault encrypt_string
New Vault password:
Confirm New Vault password:
Reading plaintext input from stdin. (ctrl-d to end input)
This is the secret content!vault |
          $ANSIBLE_VAULT;1.1;AES256
          34363966326337613933623331306331613939303661303530613466613036346336613032333637
          3632313064336133383036396266633761643664656664620a626165343439393832643236613438
          32623339396130323531643862366532623434343931613165633931663739353065396234313034
          6165373262393764610a373763623865356131383133316638333635616665313463343563646564
          35353161613039303437383135383165393661343132623133663231653035376338
Encryption successful

Now we need to obtain from the user the final element of authorization - the mfa code.

Since its lifetime is only 60 seconds it has be provided as late as possible, therefore we prompt for it, using module `pause` AFTER facts gathering and modules installation, just before it is needed. We register the value of this module into variable mfa_code for later reuse.

With module `sts_assume_role` we can finally assume our appropriate role. It requires a bunch of values, where some of them are static, some origin from vars section above and last one, "{{ mfa_code.user_input }}", from the previous step. So the task's result has to be stored in another variable.

Subsequent step is for out convenience, we assemble our variables, that will be further needed, into one fact.

Moreover: here we use a yaml feature called block referencing.

We can name a block, by using `&` character with a name and later use it where it will be needed.

Obtaining instance details

Since we want to make a clone of the instance we require to gather some information.

With Ansible it is just calling module ec2_instance_info (warning: this module was changing name like 3 times within the last 2 years).

Mandatory we need: aws_access_key, aws_secret_key:, security_token and region.

Luckily we have all of them under the Yaml block name `aws_secrets`. We can access it using `*` character before block's name. In order to use it, we type the block insert operator `<<` and refer to the block using asterisk. Voila! No more need to re-type all the information line by line. Of course we also need to clarify which instance we want.

For this we use filters keyword and provide which values we use for searching.

Listing 5. Collecting instance data

- name: Get data about ec2 instance {{ instance_id }}
  ec2_instance_info:
    <<: *aws_secrets
    filters:
      instance-id: "{{ instance_id }}"
  register: instance_facts

- set_fact:
    instance_data: "{{ instance_facts.instances[0] }}"

- debug:
    msg: "{{ instance_data | to_nice_json }}"

Once again for convenience we assign interesting data under the new fact. It is easier to type `instance_data` instead `instance_facts.instances[0]`.

Finally we print the interesting details to the screen.

For better readability I recommend using filter `to_nice_json` when printing JSON, as well in ansible.cfg we can define a value for stdout_callback as debug, which will provide pretty print for out errors.

Listing 6. ansible.cfg content

[defaults]
stdout_callback = debug
#This will make our output look much better.

Creating snapshots and AMI

In order to make snapshots we have to call module `ec2_snapshot` on each volume attached to the original instance.

Some time ago Ansible team introduced the keyword `loop`, while in older playbooks we can find `with_items`, `with_list`, etc. actually anything with `with_*`.

Loop has unified interface and with the help of filters can fulfill role of any `with_*`.

The most basic loop takes a list, e.g. of strings or dicts, and assigns subsequent values to variable `item` in each iteration.

In this task notable is also usage of asynchronous calls, by using keywords async - specifies wait time for each parallel execution and poll - defines interval between checks for completion. Both are defined in seconds.

Listing 7. Tasks for creating snapshots

- name: Create snapshot of volumes
  ec2_snapshot:
    instance_id: "{{ instance_id }}"
    device_name: "{{ item.device_name }}"
    <<: *aws_secrets
    snapshot_tags:
        Name: "{{ instance_data.tags.Name | default(instance_id, true) }}-{{ item.device_name }}"
        id_instance: "{{ instance_id }}"
        volume: "{{ item.device_name }}"
        date_created: "{{ current_datetime }}"
  loop: "{{ instance_data.block_device_mappings }}"
  register: snapshots_list
  async: 1200
  poll: 5

- debug:
    msg: "{{ snapshots_list.results | to_nice_json }}"

- name: Snapshot data modification
  set_fact:
    # the most ugly piece of code I ever wrote in Ansible
    snapshots_2_reuse: "{{ snapshots_2_reuse | default([]) +
      [ { 'device_name': item.item.device_name, 'snapshot': item.snapshot_id } ] }}"
  loop: "{{ snapshots_list.results }}"

- debug:
    msg: "{{ snapshots_2_reuse | to_nice_json }}"

- name: Create AMI from snapshot
  ec2_ami:
    <<: *aws_secrets
    name: "{{ instance_data.tags.Name | default(instance_id, true) | replace(' ','_') }}-{{ current_datetime | replace(':','-') }}"
    root_device_name: "{{ snapshots_2_reuse[0].device_name }}"
    device_mapping:
      - device_name: "{{ snapshots_2_reuse[0].device_name }}"
        snapshot_id: "{{ snapshots_2_reuse[0].snapshot }}"
        delete_on_termination: true
  register: created_ami

- debug:
    msg: "{{ created_ami | to_nice_json }}"

Afterwards we need to mangle the output about created snapshots, since we need only a subset of data in the form of dictionaries.

Let's break this expression into pieces for better understanding:

snapshots_2_reuse: "{{ snapshots_2_reuse | default([]) + ... - first we take current value of variable `snapshots_2_reuse` and if it is undefined, we substitute it with an empty list.

... + [ { 'device_name': item.item.device_name, 'snapshot': item.snapshot_id } ] }}

Next we add to the list a dynamically created map, with correctly named parameters.

Finally we assign the created object to variable `snapshots_2_reuse` and proceed to the next element in the original list.

Please note that `item.item.device_name` is correct as we iterate with variable `item` but each element has its own sub-element named `item` - see the output of previous debug task.

Because it is impossible to create an instance directly from a snapshot, we need to have an AMI base on root device snapshots.

Note the extensive usage of filters while obtaining value for AMI name.

We try to use value of tag `Name`, if it is undefined or empty (`true` second parameter in filter) we replace it with `instance_id`, which always has to be present.

Also we need to ensure there are no spaces, which are common in `Name`.

Furthermore in datetime we need to get rid of colons, since they cannot be put in AMI name as well.

Creating an instance with fallback

Next, in our journey through the playbook, we encounter a block / rescue construction.

It is an Ansible version of exception catching.

If any step fails in the `block` section, it will call `rescue` tasks below.

In this particular example in block we have two tasks: stop the original instance and launch the new one.

If Ansible fails to achieve any of them, it will immediately proceed to printing warning message and start the original instance - if stop task fails, Ansible will notify that instance is running and mark this rescue task as OK.

Of course blocks can be nested, e.g. the rescue section can have another block/resue construction as well.

It should be noted that the play continues if a rescue section completes successfully as it ‘erases’ the error status (but not the reporting), this means it won’t trigger max_fail_percentage nor any_errors_fatal configurations but will appear in the playbook statistics.

Listing 8. Instance creation

- name: Stop original instance and start new one
  block:
    - name: Stop original running instance
      ec2:
        <<: *aws_secrets
        state: stopped
        instance_id: "{{ instance_id }}"

    - name: Launch new instance
      ec2:
        <<: *aws_secrets
        key_name: "{{ instance_data.key_name }}"
        group_id: "{{ instance_data.network_interfaces[0].groups | map(attribute='group_id') | list }}"
        instance_type: "{{ instance_data.instance_type }}"
        image: "{{ created_ami.image_id }}"
        wait: yes
        wait_timeout: 600
        instance_tags: "{{ instance_data.tags }}"
        volumes: "{{ snapshots_2_reuse[1:] }}"
        vpc_subnet_id: "{{ instance_data.subnet_id }}"

  rescue:
    - debug:
        msg: "We've caught an error during new instance creation. Reverting by restoring previous instance"

    - name: Starting back the original running instance
      ec2:
        <<: *aws_secrets
        state: running
        instance_id: "{{ instance_id }}"

Summary

In this demo, we walked through setting up an Ansible playbook to log into AWS, create a snapshot of a given EC2 instance and create a new one based on the original one.

We also presented a few tips and tricks to enhance playbooks by improving their performance, stability and readability.

This paper proves that the Ansible, due to its simplicity, can outshine Chef or Puppet which for this simple task could be an overkill. In other words: Ansible just keeps it simple.

PhD student at the Wroclaw University of Science and Technology as well a Site Reliability Engineer at Vonage. He enjoys solving problems, learning new things and sharing the discoveries with his students and colleagues. His greatest passion is collecting achievements both in games and in real life.

Load Disqus comments