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.
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.
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 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 functionAllowedValues
and MinValue
to enforce constraintsNoEcho
for passwords or other sensitive dataYou 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" },...
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 attributesAlso, 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" }
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.
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.
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:
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
.
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:
If not, you might need to allow some more time for the EC2 node to fully initialize.
CloudFormation
offers two ways of updating a deployed stack.
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:
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" } ]
:
$ aws cloudformation create-change-set
--change-set-name updatingElbSecGroup
--stack-name cfn-test --template-body file://main.json
--parameters file://parameters.json
$ aws cloudformation describe-change-set
--change-set-name updatingSecGroup
--stack-name cfn-test
$ aws cloudformation execute-change-set --change-set-name
updatingSecGroup --stack-name cfn-test