July 31, 2015

AWS CloudFormation and Ansible

Written by

logos2

Senior DevOps Engineer John Heller takes a look at how Ansible can help you get the most out of CloudFormation…

AWS CloudFormation is a service that allows developers and businesses to create and manage AWS resources while keeping them organised. While CloudFormation is a valuable tool, its limitations are well known, including:

  • Stacks can become large and difficult to maintain.
  • Parameter passing is clumsy.
  • Chaining and cross-linking is cumbersome. CloudFormation stacks can only be nested, and templates must reside in S3.

The cumulus project addresses these limitations quite nicely. It offers an easy way to link multiple stacks together and uses YAML (YAML Ain’t Markup Language) for parameter definitions. It’s easier to read than JSON.

Another tool – Ansible – is gaining traction in the DevOps/Cloud orchestration world. Ansible is an automation tool designed for cloud provisioning and configuration management. It uses no agent, so it’s easy to deploy, and its playbooks are written in YAML, which is simple and relatively easy to read. It has a CloudFormation module that can also address the above shortcomings, and it brings with it other advantages that can take CloudFormation to a new level.

Chaining and cross-linking stacks

The Ansible CloudFormation module uses the AWS API via the Python boto library to run CloudFormation templates. It is usually run locally rather than on a remote host, but it will work either way. This makes it easy to run from a developer workstation, or from a CI platform such as Jenkins or Bamboo.

In the Ansible playbook example below, all parameters are kept in one place in the cf_vars.yml file. These are substituted into playbooks as required.

Any parameter can be overwritten from the command line with the –extra_vars option. For example, the state parameter takes a value of either ‘present’ (to create a stack) or ‘absent’ (to delete a stack). The variable stack_state can be defined as ‘present’ in cf_vars.yml, but changed on the command line. The same goes for any other variable.

ansible-playbook -i hosts.ini -vv pb-cloudformation.yml --extra-vars "stack_state=absent vpc_id=vpc-abcd1234"

CloudFormation module example
- name: Provision Stack
  hosts: localhost
  connection: local
  gather_facts: False
  vars_files:
    - "vars/cf_vars.yml"
 
  tasks:
    - name: Run my CloudFormation stack
      cloudformation:
        stack_name: "MyStack"
        region: "{{ aws_region }}"
        state: "{{ stack_state }}"
        template: "MyCFTemplate.json"
        template_parameters:
          VPCId: "{{ vpc_id }}"
          VPCCIDR: "{{ VPCCIDR }}"
          SubnetToolA1CIDR: "{{ SubnetA1CIDR}}"
          SubnetToolB1CIDR: "{{ SubnetB1CIDR }}"
        tags:
          Owner: "{{ owner }}"
          CostCentre: "{{ cost_centre }}"
      register: vpc_stack

 

Both stack parameters and tags applied to all resources are passed to the CloudFormation module. The values defined in the outputs section of the stack template are returned in a format that can be registered as a variable.

This is how stacks can be conveniently linked. A later task can now call another stack and use the outputs from this stack by referencing the vpc_stack variable.

Linking another stack
- name: Run my other CF stack
  cloudformation:
    stack_name: "MyOtherStack"
    region: "{{ aws_region }}"
    state: "{{ stack_state }}"
    template: "MyOtherCFTemplate.json"
    template_parameters:
      VPC: "{{ vpc_id }}"
      VPCCIDR: "{{ VPCCIDR }}"
      JumpHostSubnet: "{{ vpc_stack.stack_outputs.SubnetPubA1 }}"
      CommonSG: "{{ vpc_stack.stack_outputs.ManagementSG }}"
      ManagementSourceSG: "{{ vpc_stack.stack_outputs.ManagementSourceSG }}"

 

Ansible roles

The Ansible roles features work nicely with CloudFormation stacks. Create a role for each stack, and specify any other stacks it needs the outputs of as a dependancy role in the meta section.

Create a simple playbook that calls a role and any necessary stack outputs are automatically generated. Pass the role name on the command line and you have a master playbook that can be used for all of your stacks.

ansible-playbook -i hosts.ini -vv pb-cloudformation.yml --extra-vars "cf_stack=my_cf_role"

 

pb-cloudformation.yml
- name: Provision Stack
  hosts: localhost
  connection: local
  gather_facts: False
  vars_files:
    - ./vars/cf_vars.yml
  roles:
    - "{{ cf_stack }}"

 

Reversing the order for stack removal

When deleting stacks with chained dependencies, it is often necessary to reverse the order that the stacks deletes are run. YAML anchors and aliases and the ‘when’ conditional can help to simplify this. Place an anchor on a CloudFormation module call, and reference it later to call it again when deleting.

Reversing order with anchor and alias
- name: Run the first stack
  cloudformation: &my_anchor  # <-- the anchor label
    stack_name: "MyStack"
    state: "{{ stack_state }}"
    ...
  when: stack_state == "present"
 
- name: Run the second stack
  cloudformation:
    stack_name: "MySecondStack"
    ...
 
# Refer to the earlier cloudformation YAML without having to copy it down. 
- name: Run the first stack last when deleting
  cloudformation: *my_anchor  # <-- alias refers to anchored YAML above
  when: stack_state == "absent"

 

Ansible maths

The maths capabilities of Ansible are limited, but there are situations where it can be useful. Take this example where we want to divide up a CIDR range into 4 independent VPC, each with the same set of subnets. If the VPCs are numbered 0, 1, 2, and 3, the subnet CIDRs for each VPC can all be calculated. Changes made to the subnet CIDR ranges are calculated once and automatically applied to all VPC.
In the example, the total IP address range is 10.1.64.0/20. The 4 VPCs, identified by the build parameter are each /22 ranges. The playbook is called like this:

ansible-playbook -i hosts.ini pb-cloudformation.yml --extra-vars "build=1"

 

Note that when used like the build variable will be a string. It must be converted to int for the maths.

Ansible maths example
vpc_base: 64
VPCCIDR: "10.1.{{ vpc_base + build|int * 4 }}.0/22"
SubnetToolsA1CIDR: "10.1.{{ vpc_base + build|int * 4 }}.0/25"
SubnetToolsB1CIDR: "10.1.{{ vpc_base + build|int * 4 + 1 }}.0/25"
SubnetDbA1CIDR:  "10.1.{{ vpc_base + build|int * 4 }}.128/25"
SubnetDbB1CIDR:  "10.1.{{ vpc_base + build|int * 4 + 1 }}.128/25"
SubnetAppA1CIDR: "10.1.{{ vpc_base + build|int * 4 + 2 }}.0/25"
SubnetAppB1CIDR: "10.1.{{ vpc_base + build|int * 4 + 3 }}.0/25"
SubnetPubA1CIDR: "10.1.{{ vpc_base + build|int * 4 + 2 }}.128/25"
SubnetPubB1CIDR: "10.1.{{ vpc_base + build|int * 4 + 3 }}.128/25"

 

Jinja and CloudFormation

The Ansible template module uses the Jinja template engine. By running stack templates through Jinja first, new ways of working with CloudFormation are opened up.

CloudFormation parameters

The Ansible CloudFormation module can pass parameters into standard CloudFormation templates. This is arguably the best way to do it as the templates remain usable by other tools, such as the AWS console and the AWS CLI.

If you are prepared to forgo this however, you can make your stack templates more flexible by using Jinja to substitute parameters directly into the JSON file rather than passing them in. This reduces the size of the template, and removes the need to specify each parameter in the stack template. It makes it more readable as well without the cumbersome “Ref” calls.

A task to call to the Ansible template module is required first to transform the file before calling the cloudformation module using the resultant file.

YAML CloudFormation

Jinja can do conditionals and looping. Using these to loop over YAML dictionaries opens up a number of possibilities.

Resources as YAML dictionaries
YAML makes a highly readable way to specify a list of CloudFormation objects and their associated properties. It makes it very easy to see what is in the stack, and to easily add, remove or alter the properties of objects without repetitive editing of JSON templates.

For example, let’s define a set of EC2 instances and their properties as a YAML dictionary. We can even pull in outputs from another stack, so long as the stack has already run in the current play.

A YAML dictionary of instances
ec2:
  webA:
    ami: ami-1234abcd
    key: odecee-nonprod
    type: t2.micro
    subnet: "{{ vpc_stack.stack_outputs.SubnetWebA1 }}"  # pull in outputs from another stack
    security_groups:
      - "{{ vpc_stack.stack_outputs.SGweb }}"
      - sg-87654321
  webB:
    ami: ami-1234dcba
    key: odecee-nonprod
    type: m3.large
    subnet: "{{ vpc_stack.stack_outputs.SubnetWebB1 }}"

 

Now let’s take a Jinja template that will be part of a JSON CloudFormation template. This is a snippet that would reside in the Resources section of the JSON template.

It creates an EC2 resource object for each instance listed in the YAML dictionary ‘ec2’ and populates its properties.

Snippet from CloudFormation template

{% for instance in ec2 %}
   "{{ instance }}": {
      "Type": "AWS::EC2::Instance",
      "Properties": {
        "ImageId": "{{ ec2[instance].ami }}",
        "KeyName": "{{ ec2[instance].key }}",
        "InstanceType": "{{ ec2[instance].type }}",
        "SubnetId": "{{ ec2[instance].subnet }}",
{% if ec2[instance].security_groups is defined %}   

 

Some things to note:

  • The Jinja statements {% … %} should not have whitespace before or after them otherwise it will be left in the resultant output file. The linefeed after them is removed, so no evidence of the line appears in the final output.
  • This example does not deal with all possible properties for EC2 instances, but serves as a guide. Optional properties should have tests around them so that nothing is output if they are not present in the YAML.
  • JSON is finicky about commas, so a conditional print construct is used to put a comma after all but the last element of a loop.

Another excellent use for specifying stack objects through YAML would be for security groups and Network ACLs rules. This would give a much clearer and easier to read representation of the security rules that will be implemented by the stack.

Full YAML Stacks
The examples presented here take only parts of a stack template to specify in YAML. It may well be possible to define a generic Jinja CloudFormation template that will allow a YAML file to fully specify a complete stack. There are some interesting avenues to explore here for the DevOps engineer.

Further reading

Some of the ideas here are also mentioned in the AWS Advent blog; in this blog post, the writer suggests that Ansible could completely replace CloudFormation, but doing so would lose some of CloudFormation’s better features – particularly its dependency ordering of resource creation. The techniques described here still produce valid CloudFormation stack templates after Jinja processing. These can be used with other tools such as the AWS CLI.

Tags: , , , , ,

Categorised in: Devops

This post was written by John Heller