AWS/Ansible – Provision an EC2 instance using Infrastructure as Code

Note: Some of this is a duplicate of the AWS Lightsail article; modified for EC2.

EC2 is the compute service in AWS. It is flexible, adaptable, scalable and is able to run Virtual Machine workloads to fit most every need.

In this article we will use Ansible (Infrastructure as Code) to swiftly bring up an AWS EC2 instance in us-east-1 on a static IP (Elastic IP), in a new VPC with an Internet Gateway, add a DNS Zone (Route 53) for the site in mention and install docker/docker-compose on it.

We will use ‘myweb’ as an example in this article, using the same base path of ‘dev’ that was previously created, the container-admin group (some of the IAM policy implemented there will be in use here) and using ~/.local/bin|lib for the binaries/libraries.

Please use AWS Free Tier prior to commencing with this article.

–>
Go in to the dev directory/link located within your home directory:

$ cd ~/dev

Install/Upgrade Ansible:

$ pip3 install ansible --upgrade --user && chmod 754 ~/.local/bin/ansible ~/.local/bin/ansible-playbook

Install/Upgrade Boto3:

$ pip3 install boto3 --upgrade --user

Install/Upgrade Boto (required by ec2_eip):

$ pip3 install boto --upgrade --user

Create a work folder and change in to it:

$ mkdir -p ansible/myweb/scripts && cd ansible/myweb

Add an IAM Policy to the container-admin group so it will have access to EC2 and related (EIP/VPC/Routes/IGW/Route 53/SG/KeyPair):
AWS UI Console -> Services -> Security, Identity, & Compliance -> IAM -> Policies -> Create Policy -> JSON (replace <AWS ACCOUNT ID> in the Resource arn with your Account’s ID (shown under the top right drop-down (of your name) within the My Account page next to the Account Id: under Account Settings)).

Note: This is identical to the section in the AWS/Terraform article, but adds an allowance for route53:ListHostedZones, ec2:DescribeInstanceStatus and ec2:UpdateSecurityGroupRuleDescriptionsEgress:

 {
     "Version": "2012-10-17",
     "Statement": [
         {
             "Effect": "Allow",
             "Action": [
                 "ec2:UpdateSecurityGroupRuleDescriptionsEgress",
                 "ec2:TerminateInstances",
                 "route53:GetChange",
                 "route53:GetHostedZone",
                 "route53:ChangeTagsForResource",
                 "route53:DeleteHostedZone",
                 "route53:ListTagsForResource" 
             ],
             "Resource": [
                "arn:aws:ec2:*:<AWS ACCOUNT ID>:security-group/",
                "arn:aws:ec2:*:<AWS ACCOUNT ID>:instance/*",
                "arn:aws:route53:::hostedzone/*",
                "arn:aws:route53:::change/*"
             ]      
         },
         {
             "Effect": "Allow",
             "Action": [
                 "ec2:DisassociateAddress",
                 "ec2:DeleteSubnet",
                 "ec2:DescribeAddresses",
                 "ec2:DescribeInstances",
                 "ec2:DescribeInstanceAttribute",
                 "ec2:CreateVpc",
                 "ec2:AttachInternetGateway",
                 "ec2:DescribeVpcAttribute",
                 "ec2:AssociateRouteTable",
                 "ec2:DescribeInternetGateways",
                 "ec2:DescribeNetworkInterfaces",
                 "ec2:CreateInternetGateway",
                 "ec2:CreateSecurityGroup",
                 "ec2:DescribeVolumes",
                 "ec2:DescribeAccountAttributes",
                 "ec2:ModifyVpcAttribute",
                 "ec2:DescribeKeyPairs",
                 "ec2:DescribeNetworkAcls",
                 "ec2:DescribeRouteTables",
                 "ec2:DescribeInstanceStatus",
                 "ec2:ReleaseAddress",
                 "ec2:ImportKeyPair",
                 "ec2:DescribeTags",
                 "ec2:DescribeVpcClassicLinkDnsSupport",
                 "ec2:CreateRouteTable",
                 "ec2:DetachInternetGateway",
                 "ec2:DisassociateRouteTable",
                 "ec2:AllocateAddress",
                 "ec2:DescribeInstanceCreditSpecifications",
                 "ec2:DescribeSecurityGroups",
                 "ec2:DescribeVpcClassicLink",
                 "ec2:DescribeImages",
                 "ec2:DescribeVpcs",
                 "ec2:DeleteVpc",
                 "ec2:AssociateAddress",
                 "ec2:CreateSubnet",
                 "ec2:DescribeSubnets",
                 "ec2:DeleteKeyPair",
                 "route53:CreateHostedZone",
                 "route53:ListHostedZones",
                 "sts:GetCallerIdentity"
             ],
             "Resource": "*"
         }
     ]
 }

Review Policy ->

Name: AllowEC2
Description: Allow access to EC2 and related.

Create Policy.

Groups -> container-admin -> Attach Policy -> Search for AllowEC2 -> Attach Policy.

Generate an SSH Key Pair (no password) and restrict permissions on it:

$ ssh-keygen -q -t rsa -b 2048 -N '' -f ~/.ssh/myweb && chmod 400 ~/.ssh/myweb

Create a hosts file and specify localhost:

$ cat << 'EOF' > hosts
> [local]
> localhost
> EOF

The following is performed with this script/code:

  • create a Route 53 DNS Zone of myweb.com (no A records will be added)
  • create a Virtual Private Cloud for network 10.0.0.0/16 (tenancy is default)
  • add a subnet of 10.0.1.0/24 within the VPC
  • allocate a static Public IP
  • create a Security Group and add a Security rule for allowing SSH (port 22) Inbound
  • create an Internet Gateway and add a route out to it
  • create a T3a.micro instance (tenancy is default) based off of Ubuntu 18_04, our public key added as authorized and reference an extraneous file for user_data (initialization script on Virtual Machine boot). Elastic/Root Block Store is GP2
  • DNS support is enabled but DNS host names is not
  • tag all resources

Note: assign_public_ip was needed (an assignment at boot from the Amazon pool) so as not to disrupt user_data execution, due to Elastic IP (Static) being bound a bit later:

$ cat << 'EOF' > aws_ec2.yml
> # Create an AWS EC2 instance and add a way to destroy it
> ---
> - hosts: local
>   connection: local
>
>   vars:
>     region: us-east-1
>     prefix: myweb
>     subnet_name: internal
>
>   tasks:
>   - name: Create a DNS Zone
>     route53_zone:
>       state: present
>       zone: "{{ prefix }}.com"
>       comment: "{{ prefix }}-dn"
>
>   - name: Create a Virtual Private Cloud
>     ec2_vpc_net:
>       state: present
>       name: "{{ prefix }}-vpc"
>       cidr_block: 10.0.0.0/16
>       region: "{{ region }}"
>       dns_hostnames: no
>       tenancy: default
>       tags:
>           Site: "{{ prefix }}.com"
>           Name: "{{ prefix }}-vpc"
>     register: vpc
>
>   - name: Create a Security Group and allow inbound port(s)
>     ec2_group:
>       state: present
>       name: "{{ prefix }}"
>       description: Allow Ports
>       vpc_id: "{{ vpc.vpc.id }}"
>       region: "{{ region }}"
>       rules:
>         - proto: tcp
>           from_port: 22
>           to_port: 22
>           cidr_ip: 0.0.0.0/0
>           rule_desc: SSH
>       rules_egress:
>         - proto: -1
>           from_port: 0
>           to_port: 0
>           cidr_ip: 0.0.0.0/0
>           rule_desc: All
>       tags:
>           Site: "{{ prefix }}.com"
>           Name: "{{ prefix }}-sg"
>     register: sg
>
>   - name: Add a Subnet
>     ec2_vpc_subnet:
>       state: present
>       vpc_id: "{{ vpc.vpc.id }}"
>       cidr: 10.0.1.0/24
>       region: "{{ region }}"
>       az: "{{ region }}a"
>       tags:
>           Site: "{{ prefix }}.com"
>           Name: "{{ subnet_name }}"
>     register: internal
>
>   - name: Create an Internet Gateway
>     ec2_vpc_igw:
>       state: present
>       vpc_id: "{{ vpc.vpc.id }}"
>       region: "{{ region }}"
>       tags:
>           Site: "{{ prefix }}.com"
>           Name: "{{ prefix }}-igw"
>     register: igw
>
>   - name: Add a route to the Internet Gateway
>     ec2_vpc_route_table:
>       state: present
>       vpc_id: "{{ vpc.vpc.id }}"
>       region: "{{ region }}"
>       subnets: "{{ internal.subnet.id }}"
>       routes:
>         - dest: 0.0.0.0/0
>           gateway_id: "{{ igw.gateway_id }}"
>       tags:
>           Site: "{{ prefix }}.com"
>           Name: "{{ prefix }}-rt"
>
>   - name: Add Public Key as authorized
>     ec2_key:
>       state: present
>       name: "{{ prefix }}"
>       key_material: "{{ lookup('file', '~/.ssh/{{ prefix }}.pub') }}"
>       region: "{{ region }}"
>
>   - name: Select Ubuntu 18.04
>     ec2_ami_info:
>       region: "{{ region }}"
>       owners: 099720109477 # Canonical
>       filters:
>         name: "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"
>     register: ec2_ami
>
>     # Get the latest Ubuntu 18.04 AMI
>   - set_fact:
>       ec2_ami_latest: "{{ ec2_ami.images | selectattr('name', 'defined') | sort(attribute='creation_date') | last }}"
>
>   - name: Create an Ubuntu Virtual Machine with key based access and run a script on boot
>     ec2_instance:
>       state: present
>       name: "{{ prefix }}-ec2"
>       key_name: "{{ prefix }}"
>       region: "{{ region }}"
>       instance_type: t3a.micro
>       image_id: "{{ ec2_ami_latest.image_id }}"
>       security_group: "{{ sg.group_id }}"
>       network:
>         assign_public_ip: true
>       wait: yes
>       wait_timeout: 500
>       vpc_subnet_id: "{{ internal.subnet.id }}"
>       tenancy: default
>       user_data: "{{ lookup('file', './scripts/install.sh') }}"
>       tags:
>           Site: "{{ prefix }}.com"
>     register: ec2
>
>   - name: Allocate and Associate a Static Public IP
>     ec2_eip:
>       state: present
>       region: "{{ region }}"
>       in_vpc: yes
>       reuse_existing_ip_allowed: yes
>       device_id: "{{ ec2.instance_ids[0] }}"
>     register: eip
>
>   - debug: msg="Public IP (Static) is {{ eip.public_ip }} for {{ ec2.instances[0].tags.Name }}"
>     when: eip.public_ip is defined
>
>   - debug: msg="Run this playbook for {{ ec2.instances[0].tags.Name }} shortly to Allocate, Associate and list the Static Public IP."
>     when: eip.public_ip is not defined
>
>   - name: Destroy the Elastic IP
>     # Gather EC2 info.
>     ec2_instance_info:
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ prefix }}-ec2"
>         instance-state-name: [ "running", "present", "started", "stopped" ]
>     register: ec2
>     tags: [ 'never', 'destroy' ]
>
>     # Gather EIP info.
>   - ec2_eip_info:
>       filters:
>         instance-id: "{{ ec2.instances[0].instance_id }}"
>     register: eip
>     when: ec2.instances[0].instance_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_eip:
>       state: absent
>       region: "{{ region }}"
>       device_id: "{{ eip.addresses[0].instance_id }}"
>       release_on_disassociation: yes
>     when: eip.addresses[0].instance_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Elastic Compute 2 instance
>     ec2_instance:
>       state: absent
>       instance_ids: "{{ ec2.instances[0].instance_id }}"
>     when: ec2.instances[0].instance_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Public Key
>     ec2_key:
>       state: absent
>       name: "{{ prefix }}"
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Route to the Internet Gateway
>     # Gather Route info.
>     ec2_vpc_route_table_info:
>       region: "{{ region }}"
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ prefix }}-rt"
>     register: rt
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_vpc_route_table:
>       state: absent
>       vpc_id: "{{ rt.route_tables[0].vpc_id }}"
>       region: "{{ region }}"
>       route_table_id: "{{ rt.route_tables[0].id }}"
>       lookup: id
>     when: rt.route_tables[0].vpc_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Subnet
>     # Gather Subnet info.
>     ec2_vpc_subnet_info:
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ subnet_name }}"
>     register: internal
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_vpc_subnet:
>       state: absent
>       vpc_id: "{{ internal.subnets[0].vpc_id }}"
>       cidr: "{{ internal.subnets[0].cidr_block }}"
>       when: internal.subnets[0].vpc_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Internet Gateway
>     # Gather IGW info.
>     ec2_vpc_igw_info:
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ prefix }}-igw"
>     register: igw
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_vpc_igw:
>       state: absent
>       vpc_id: "{{ igw.internet_gateways[0].attachments[0].vpc_id }}"
>       region: "{{ region }}"
>     when: igw.internet_gateways[0].attachments[0].vpc_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Security Group
>     # Gather SG info.
>     ec2_group_info:
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ prefix }}-sg"
>     register: sg
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_group:
>       state: absent
>       group_id: "{{ sg.security_groups[0].group_id }}"
>     when: sg.security_groups[0].group_id is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the Virtual Private Cloud
>     # Gather VPC info.
>     ec2_vpc_net_info:
>       filters:
>         tag:Site: "{{ prefix }}.com"
>         tag:Name: "{{ prefix }}-vpc"
>     register: vpc
>     tags: [ 'never', 'destroy' ]
>
>   - ec2_vpc_net:
>       state: absent
>       name: "{{ prefix }}-vpc"
>       cidr_block: "{{ vpc.vpcs[0].cidr_block }}"
>       region: "{{ region }}"
>     when: vpc.vpcs[0].cidr_block is defined
>     tags: [ 'never', 'destroy' ]
>
>   - name: Destroy the DNS Zone
>     route53_zone:
>       state: absent
>       zone: "{{ prefix }}.com"
>       tags: [ 'never', 'destroy' ]
> EOF 

Create the shell script for user_data.

Note: If you have gone through the AWS/Ansible against Lightsail article, then this can be disregarded:

$ cat << 'EOF' > scripts/install.sh
> #!/bin/bash
>
> MY_HOME="/home/ubuntu"
> export DEBIAN_FRONTEND=noninteractive
>
> # Install prereqs
> apt update
> apt install -y python3-pip apt-transport-https ca-certificates curl software-properties-common
> # Install docker
> curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
> add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
> apt update
> apt install -y docker-ce
> # Install docker-compose
> su ubuntu -c "mkdir -p $MY_HOME/.local/bin"
> su ubuntu -c "pip3 install docker-compose --upgrade --user && chmod 754 $MY_HOME/.local/bin/docker-compose"
> usermod -aG docker ubuntu
> # Add PATH
> printf "\nexport PATH=\$PATH:$MY_HOME/.local/bin\n" >> $MY_HOME/.bashrc
>
> exit 0
> EOF

Run the playbook:

$ ansible-playbook -i hosts aws_ec2.yml

Log on to the instance after a short while:

$ ssh -i ~/.ssh/myweb ubuntu@<The value of public_ip that was reported.  One can also re-run the playbook to print it again.>

Type yes and hit enter to accept.

On the host (a short while is needed for the run-once script to complete):

$ docker --version
$ docker-compose --version
$ logout

Tear down the instance:

$ ansible-playbook -i hosts aws_ec2.yml --tags "destroy"

<–

References:

Source:
ansible_myweb