IaC using CloudFormation

CloudFormation is an AWS service for deploying infrastructure as code. As before, we are going to describe our infrastructure via templates containing parameters (variables), resources, and outputs.

CloudFormation calls each deployed template a Stack. Creating, listing, updating, and deleting stacks is possible via the AWS Console, CLI, or API. In a small setup, you would probably deploy each of your stacks individually, but as your architecture becomes more complex, you can start nesting stacks. You would have a top-level or a parent stack (template) that invokes a number of sub-stacks. Nested stacks allow you to pass variables between them and, of course, save you the time of having to deploy each one individually.

Configuration

CloudFormation provides a GUI via the AWS Console; we however, are going to focus on the AWS CLI since it is most suitable for automating tasks in the future.

Depending on the OS you run, you could download an installer from https://aws.amazon.com/cli/ or use Python PIP:

$ pip install awscli
$ aws --version
aws-cli/1.10.34 ...

We will need a set of API keys, so let's create a new IAM user called cloudformation with the following privileges:

    "cloudformation:CancelUpdateStack",
    "cloudformation:ContinueUpdateRollback",
    "cloudformation:Create*",
    "cloudformation:Describe*",
    "cloudformation:EstimateTemplateCost",
    "cloudformation:ExecuteChangeSet",
    "cloudformation:Get*",
    "cloudformation:List*",
    "cloudformation:PreviewStackUpdate",
    "cloudformation:SetStackPolicy",
    "cloudformation:SignalResource",
    "cloudformation:UpdateStack",
    "cloudformation:ValidateTemplate",
    "autoscaling:CreateAutoScalingGroup",
    "autoscaling:CreateLaunchConfiguration",
    "autoscaling:DeleteLaunchConfiguration",
    "autoscaling:Describe*",
    "autoscaling:UpdateAutoScalingGroup",
    "ec2:AllocateAddress",
    "ec2:AssociateAddress",
    "ec2:AssociateRouteTable",
    "ec2:AttachInternetGateway",
    "ec2:AuthorizeSecurityGroupEgress",
    "ec2:AuthorizeSecurityGroupIngress",
    "ec2:CreateInternetGateway",
    "ec2:CreateNatGateway",
    "ec2:CreateRoute",
    "ec2:CreateRouteTable",
    "ec2:CreateSecurityGroup",
    "ec2:CreateSubnet",
    "ec2:CreateTags",
    "ec2:CreateVpc",
    "ec2:Describe*",
    "ec2:Modify*",
    "ec2:RevokeSecurityGroupEgress",
    "elasticloadbalancing:CreateLoadBalancer",
    "elasticloadbalancing:CreateLoadBalancerListeners",
    "elasticloadbalancing:Describe*",
    "elasticloadbalancing:ModifyLoadBalancerAttributes",
    "elasticloadbalancing:SetLoadBalancerPoliciesOfListener",
    "rds:CreateDBInstance",
    "rds:CreateDBSubnetGroup",
    "rds:Describe*"

You have the choice of using aws configure, which will prompt you for the API credentials, or if you prefer not to store them permanently, you could use an environment variable:

$ export AWS_ACCESS_KEY_ID='user_access_key'
$ export AWS_SECRET_ACCESS_KEY='user_secret_access_key'

CloudFormation templates do not store any AWS region information, so to avoid specifying it on the command line each time. It can be exported as well:

$ export AWS_DEFAULT_REGION='us-east-1'

With those environment variables in place, awscli should be ready for use.

Template design

CloudFormation templates are written in JSON and usually contain at least three sections (in any order): parameters, resources and outputs.

Unfortunately it is not possible to store these into separate files (with the exception of parameter values), so in this example we will work with a single template file named main.json.

Templates can be used locally or imported from a remote location (an S3 bucket is a common choice).

Parameters

Parameters add flexibility and portability to our Stack by letting us pass variables to it such as instance types, AMI ids, SSH keypair names and similar values which it is best not to hard-code.

Each parameter takes an arbitrary logical name (alphanumeric, unique within the template), description, type, and an optional default value. The available types are String, Number, CommaDelimitedList, and the more special AWS-specific type, such as AWS::EC2::KeyPair::KeyName, as seen in the preceding code.

The latter is useful for validation, as CloudFormation will check whether a key pair with the given name actually exists in your AWS account.

Parameters can also have properties such as AllowedValues, Min/MaxLength, Min/MaxValue, NoEcho and other (please see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html).

There is a limit of 60 parameters per template.

Let us examine the parameters found at the top of our template:

"Parameters" : { 
"vpcCidr" : { 
"Description" : "VPC CIDR", 
"Type" : "String" 
}, 
"vpcName" : { 
"Description" : "VPC name", 
"Type" : "String" 
}, 
"awsAvailabilityZones" : { 
"Description" : "List of AZs", 
"Type" : "CommaDelimitedList" 
}, 
"publicCidr" : { 
"Description" : "List of public subnet CIDRs", 
"Type" : "CommaDelimitedList" 
},... 
"rdsInstanceClass" : { 
"Description" : "RDS instance class", 
"Type" : "String", 
"AllowedValues" : ["db.t2.micro", "db.t2.small", "db.t2.medium"] 
}, 
"rdsUsername" : { 
"Description" : "RDS username", 
"Type" : "String" 
}, 
"rdsPassword" : { 
"Description" : "RDS password", 
"Type" : "String", 
"NoEcho" : "true" 
}, 
... 
"autoscalingGroupKeyname" : { 
"Description" : "EC2 ssh key name", 
"Type" : "AWS::EC2::KeyPair::KeyName" 
}, 
"autoscalingGroupImageId" : { 
"Description" : "EC2 AMI ID", 
"Type" : "AWS::EC2::Image::Id" 
} 
} 

We have used the following:

  • CommaDelimitedList, which we will conveniently query later with a special function
  • AllowedValues and MinValue to enforce constraints
  • NoEcho for passwords or other sensitive data
  • Some AWS-specific types to have CloudFormation further validate input

You will notice that there are no values assigned to any of the preceding parameters.

To maintain a reusable template, we will store values in a separate file (parameters.json):

[ 
{ 
"ParameterKey": "vpcCidr", 
"ParameterValue": "10.0.0.0/16" 
}, 
{ 
"ParameterKey": "vpcName", 
"ParameterValue": "CloudFormation" 
}, 
{ 
"ParameterKey": "awsAvailabilityZones", 
"ParameterValue": "us-east-1b,us-east-1c"  
}, 
{ 
"ParameterKey": "publicCidr", 
"ParameterValue": "10.0.1.0/24,10.0.3.0/24" 
}, 
{ 
"ParameterKey": "privateCidr", 
"ParameterValue": "10.0.2.0/24,10.0.4.0/24" 
}, 
{ 
"ParameterKey": "rdsIdentifier", 
"ParameterValue": "cloudformation" 
}, 
{ 
"ParameterKey": "rdsStorageSize", 
"ParameterValue": "5" 
}, 
{ 
"ParameterKey": "rdsStorageType", 
"ParameterValue": "gp2" 
}, 
{ 
"ParameterKey": "rdsEngine", 
"ParameterValue": "postgres" 
},... 

Resources

You are already familiar with the concept of resources and how they are used to describe different pieces of infrastructure.

Regardless of how resources appear in a template, CloudFormation will follow its internal logic to decide the order in which these get provisioned.

The syntax for declaring a resource is as follows:

"Logical ID" : { 
"Type" : "", 
"Properties" : {} 
} 

IDs need to be alphanumeric and unique within the template.

The list of CloudFormation resource types and their properties can be found here: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html

The max number of resources a template can have is 200. Reaching that limit, you will need to split a template into smaller ones and possibly look into nested stacks.

Back to our example, as per tradition we start by creating a VPC and its supporting elements such as subnets, Internet gateway and NAT gateway:

"Resources" : { 
"vpc" : { 
"Type" : "AWS::EC2::VPC",   
"Properties" : { 
"CidrBlock" : { "Ref" : "vpcCidr" }, 
"EnableDnsSupport" : "true", 
"EnableDnsHostnames" : "true", 
"Tags" : [ { "Key" : "Name", "Value" : { "Ref" : "vpcName" } } ] 
} 
}, 
"publicSubnet1" : { 
"Type" : "AWS::EC2::Subnet", 
"Properties" : { 
"AvailabilityZone" : { "Fn::Select" : [ "0", {"Ref" : "awsAvailabilityZones"} ] }, 
"CidrBlock" : { "Fn::Select" : [ "0", {"Ref" : "publicCidr"} ] }, 
"MapPublicIpOnLaunch" : "true", 
"Tags" : [ { "Key" : "Name", "Value" : "Public" } ], 
"VpcId" : { "Ref" : "vpc" } 
} 
}, 
... 
"internetGateway" : { 
"Type" : "AWS::EC2::InternetGateway", 
"Properties" : { 
"Tags" : [ { "Key" : "Name", "Value" : { "Fn::Join" : [ " - ", [ { "Ref" : "vpcName" }, "IGW" ] ] } } ] 
} 
}, 
"internetGatewayAttachment" : { 
"Type" : "AWS::EC2::VPCGatewayAttachment", 
"Properties" : { 
"InternetGatewayId" : { "Ref" : "internetGateway" }, 
"VpcId" : { "Ref" : "vpc" } 
} 
}, 
"natEip" : { 
"Type" : "AWS::EC2::EIP", 
"Properties" : { 
"Domain" : "vpc" 
} 
}, 
"natGateway" : { 
"Type" : "AWS::EC2::NatGateway", 
"Properties" : { 
"AllocationId" : { "Fn::GetAtt" : ["natEip", "AllocationId"]}, 
"SubnetId" : { "Ref" : "publicSubnet1" } 
}, 
"DependsOn" : "internetGatewayAttachment" 
}, 

Note some of the CloudFormation functions used in the preceding code:

  • "Fn::Select" in "CidrBlock" : { "Fn::Select" : [ "0", {"Ref" : "publicCidr"} ] }, which allows us to query the CommaDelimitedList type parameters we set earlier
  • "Fn::Join", for concatenating strings
  • "Fn::GetAtt", for retrieving resource attributes

Also, the DependsOn property of the natGateway resource allows us to set explicit conditions on the order of execution. In this case, we are saying that the Internet Gateway resource needs to be ready (attached to the VPC) before the NAT Gateway is provisioned.

After the VPC, let's add RDS:

    "rdsInstance" : {
    "Type" : "AWS::RDS::DBInstance",
    "Properties" : {
    "DBInstanceIdentifier" : { "Ref" : "rdsIdentifier" },
    "DBInstanceClass" : { "Ref" : "rdsInstanceClass" },
    "DBSubnetGroupName" : { "Ref" : "rdsSubnetGroup" },
    "Engine" : { "Ref" : "rdsEngine" },
    "EngineVersion" : { "Ref" : "rdsEngineVersion" },
    "MasterUserPassword" : { "Ref" : "rdsPassword" },
    "MasterUsername" : { "Ref" : "rdsUsername" },
    "StorageType" : { "Ref" : "rdsStorageType" },
    "AllocatedStorage" : { "Ref" : "rdsStorageSize" },
    "VPCSecurityGroups" : [ { "Ref" : "rdsSecurityGroup" } ],
    "Tags" : [ { "Key" : "Name", "Value" : { "Ref" : "rdsIdentifier" } } ]
    }}

Then add the ELB:

    ...
    "elbInstance" : {
    "Type" : "AWS::ElasticLoadBalancing::LoadBalancer",
    "Properties" : {
    "LoadBalancerName" : "cloudformation-elb",
    "Listeners" : [ { "InstancePort" : "80", "InstanceProtocol" : "HTTP", "LoadBalancerPort" : "80", "Protocol" : "HTTP" } ],
    "SecurityGroups" : [ { "Ref" : "elbSecurityGroup" } ],
    "Subnets" : [ { "Ref" : "publicSubnet1" }, { "Ref" : "publicSubnet2" } ],
    "Tags" : [ { "Key" : "Name", "Value" : "cloudformation-elb" } ]
    }
    }

And add the EC2 resources:

    ...
    "launchConfiguration" : {
    "Type" : "AWS::AutoScaling::LaunchConfiguration",
    "Properties" : {
    "ImageId" : { "Ref": "autoscalingGroupImageId" },
    "InstanceType" : { "Ref" : "autoscalingGroupInstanceType" },
    "KeyName" : { "Ref" : "autoscalingGroupKeyname" },
    "SecurityGroups" : [ { "Ref" : "ec2SecurityGroup" } ]

We still use a UserData shell script to install the NGINX package; however, the presentation is slightly different this time. CloudFormation is going to concatenate the lines using a new line character as a delimiter then encode the result in Base64:

    "UserData" : {
    "Fn::Base64" : {
    "Fn::Join" : [
    "
",
    [
    "#!/bin/bash",
    "set -euf -o pipefail",
    "exec 1> >(logger -s -t $(basename $0)) 2>&1",
    "yum -y install nginx; chkconfig nginx on; service nginx start"
    ]
    ]
    }
    }
    }
    }

We use DependsOn to ensure the RDS instance goes in before autoScalingGroup:

"autoScalingGroup" : { 
"Type" : "AWS::AutoScaling::AutoScalingGroup", 
"Properties" : { 
"LaunchConfigurationName" : { "Ref" : "launchConfiguration" }, 
"DesiredCapacity" : "1", 
"MinSize" : "1", 
"MaxSize" : "1", 
"LoadBalancerNames" : [ { "Ref" : "elbInstance" } ], 
"VPCZoneIdentifier" : [ { "Ref" : "privateSubnet1" }, { "Ref" : "privateSubnet2" } ], 
"Tags" : [ { "Key" : "Name", "Value" : "cloudformation-asg", "PropagateAtLaunch" : "true" } ] 
},  
"DependsOn" : "rdsInstance" 
} 

Outputs

Again, we will use these to highlight some resource attributes following a successful deployment. Another important feature of Outputs, however, is that they can be used as input parameters for other templates (stacks). This becomes very useful with nested stacks.

Note

Once declared, Outputs cannot be subsequently updated on their own. You will need to modify at least one resource in order to trigger an Output update.

We add the VPC ID, NAT IP address and ELB DNS name as Outputs:

"Outputs" : { 
"vpcId" : { 
"Description" : "VPC ID", 
"Value" : { "Ref" : "vpc" } 
}, 
"natEip" : { 
"Description" : "NAT IP address", 
"Value" : { "Ref" : "natEip" } 
}, 
"elbDns" : { 
"Description" : "ELB DNS", 
"Value" : { "Fn::GetAtt" : [ "elbInstance", "DNSName" ] } 
} 
} 

Currently, a template can have no more than 60 Outputs.

Operations

If you have been following along, you should now have a main.json and a parameters.json in your current folder. It is time to put them to use, so here are a few operations we are going to perform:

  • Validate a template
  • Deploy a stack
  • Update a stack
  • Delete a stack

Template validation

First things first, a basic check of our JSON template with validate-template:

$ aws cloudformation validate-template --template-body file://main.json
{
"Description": "Provisions EC2, ELB, ASG and RDS resources",
"Parameters": [
{
"NoEcho": false,
"Description": "EC2 AMI ID",
"ParameterKey": "autoscalingGroupImageId"
}

If there's no errors, the CLI returns the parsed template. Note that we could have just as easily pointed to a remote location using --template-url instead of -template-body.

Deploying a Stack

To deploy our template (stack), we will use create-stack. It takes an arbitrary name, the location of the template, and the file containing parameter values:

$ aws cloudformation create-stack --stack-name cfn-test --template-body 
      file://main.json --parameters file://parameters.json
{
"StackId": "arn:aws:cloudformation:us-east-1:xxxxxx:stack/cfn-test/xxxxxx"
}

CloudFormation starts creating the stack and no further output is returned. To get progress information on the CLI, use describe-stacks:

$ aws cloudformation describe-stacks --stack-name cfn-test 
{ 
"Stacks": [ 
{ 
"StackId": "arn:aws:cloudformation:us-east-xxxxxx:stack/cfn-test/xxxxxx" 
... 
"CreationTime": "2016-05-29T20:07:17.813Z", 
"StackName": "cfn-test", 
"NotificationARNs": [], 
"StackStatus": "CREATE_IN_PROGRESS", 
"DisableRollback": false 
} 
] 
} 

And for even more details, use describe-stack-events.

After a few minutes (based on our small template) StackStatus changes from CREATE_IN_PROGRESS to CREATE_COMPLETE and we are provided the requested Outputs:

$ aws cloudformation describe-stacks --stack-name cfn-test
"Outputs": [
{
"Description": "VPC ID",
"OutputKey": "vpcId",
"OutputValue": "vpc-xxxxxx"
},
{
"Description": "NAT IP address",
"OutputKey": "natEip",
"OutputValue": "x.x.x.x"
},
{
"Description": "ELB DNS",
"OutputKey": "elbDns",
"OutputValue": "cloudformation-elb-xxxxxx.us-east-1.elb.amazonaws.com"
}
],
"CreationTime": "2016-05-29T20:07:17.813Z",
"StackName": "cfn-test",
"NotificationARNs": [],
"StackStatus": "CREATE_COMPLETE",
"DisableRollback": false

At this point, the elbDNS URL should return the nginx welcome page, as shown here:

Deploying a Stack

If not, you might need to allow some more time for the EC2 node to fully initialize.

Updating a stack

CloudFormation offers two ways of updating a deployed stack.

Note

Some update operations can be destructive (please refer to http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html). You should always check the CloudFormation documentation on the resource you are planning to modify to see whether a change is going to cause any interruption.

If you would like to quickly deploy a minor change, then all you need to do is modify the template file and deploy it directly with update-stack:

$ aws cloudformation update-stack --stack-name cfn-test 
      --template-body file://main.json 
      --parameters file://parameters.json

Otherwise, a good practice would be to use Change Sets to preview stack changes before deploying them. For example, let us update the rules in the ELB security group as we did before:

  1. Modify the main.json template (add another rule to elbSecurityGroup):
        "elbSecurityGroup" : {
        "Type" : "AWS::EC2::SecurityGroup",
        "Properties" : {
        "SecurityGroupIngress" : [ { "ToPort" : "80", "FromPort" : "80", 
        "IpProtocol" : "tcp", "CidrIp" : "0.0.0.0/0" },
        
          { "ToPort" : "443", "FromPort" : "443", "IpProtocol" : 
            "tcp", "CidrIp" : "0.0.0.0/0" } ]
       

  2. Create a Change Set:
    $ aws cloudformation create-change-set 
          --change-set-name updatingElbSecGroup 
          --stack-name cfn-test --template-body file://main.json 
          --parameters file://parameters.json
    

  3. Preview the Change Set:
    $ aws cloudformation describe-change-set 
          --change-set-name updatingSecGroup 
          --stack-name cfn-test
    

  4. Execute the Change Set:
    $ aws cloudformation execute-change-set --change-set-name 
          updatingSecGroup --stack-name cfn-test
    

    Tip

    Whether via a Change Set or updating directly, if you are simply modifying parameter values (parameters.json) you can skip re-uploading the template (main.json) with --use-previous-template.

Deleting a stack

In order to tidy up after our experiments, we will need to grant temporary Admin privileges to the CloudFormation IAM user (the same procedure as in the earlier TF section); run delete-stack:

$ aws cloudformation delete-stack --stack-name cfn-test

Then revoke the Admin privileges.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset