Creating reusable templates
We already know that we are going to deploy a three-tier application, but we're going to have more than three stacks.
The first stack we will create is our core stack.
This stack will consist of the following:
- Network (VPC, subnets, and so on)
- IAM (roles and users)
We will have two environments for our application: test and production. These environments will differ in terms of size, the amount of resources, and security settings.
The code of our templates is going to be huge, so you will only see blocks that are specific for the topic; the entire source code can be found in Packt's repository.
Before we start, let's think about how we are going to organize the template and its parameters. Since we are going to reuse the same template for two different stacks (production and test), we will need to separate the network ranges and use different naming conventions.
In terms of network, our stack will have the following:
- 1 VPC
- 3 public subnets
- 3 WebTier subnets
- 3 middleware subnets
- 3 database subnets
- 1 internet gateway
- 1 NAT gateway
- 1 public route table
- 1 private route table
- 2 IAM roles (for administrators and developers)
These resources will have tags such as Name and Env.
In this case, we want to parametrize the properties for our resources, such as CIDR ranges and tags, so we will have the following parameters:
- VPC CIDR range
- Public subnet 1 CIDR range
- Public subnet 2 CIDR range
- Public subnet 3 CIDR range
- WebTier subnet 1 CIDR range
- WebTier subnet 2 CIDR range
- WebTier subnet 3 CIDR range
- Middleware subnet 1 CIDR range
- Middleware subnet 2 CIDR range
- Middleware subnet 3 CIDR range
- Database subnet 1 CIDR range
- Database subnet 2 CIDR range
- Database subnet 3 CIDR range
- Environment
All these parameters will have a type String. Let's write our Parameters section:
Parameters:
VpcCidr:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
PublicSubnetCidr1:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
PublicSubnetCidr2:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
PublicSubnetCidr3:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
# And so on...
DatabaseSubnetCidr1:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
DatabaseSubnetCidr2:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
DatabaseSubnetCidr3:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
Environment:
Type: String
AllowedValues: ['prod', 'test']
This is it for our core stack. Now, in our resources, we will need to use the intrinsic function Fn::Ref to use the value of the parameter in the resource property. We will also use another intrinsic function, Fn::Join, to concatenate a nice string name for our VPC:
Resources:
Vpc:
Type: "AWS::EC2::VPC"
Properties:
CidrBlock: !Ref 'VpcCidr'
EnableDnsHostnames: True
EnableDnsSupport: True
InstanceTenancy: Default
Tags:
- Key: 'Name'
Value: !Join ['-', [ !Ref 'Environment', 'vpc' ]]
- Key: 'Env'
Value: !Ref 'Environment'
A similar pattern will be used for the remaining resources.
Important note
The Fn::Ref function can also be used to refer to certain attributes from one resource in the stack to another. In this case, we supply Fn::Ref with the logical name of the resource, instead of the parameter.
In the following example, we use Fn::Ref to refer to VpcId with the subnet we will create next:
PublicSubnet1:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Ref 'PublicSubnetCidr1'
VpcId: !Ref Vpc
Note that I didn't specify the default values for parameters in the template file. In this case, I'm forced to provide them within the create-stack command; otherwise, the validation will not pass.
There are two ways to supply CloudFormation with parameters: as a JSON file or as a positional argument.
For large templates with plenty of parameters, you are advised to use JSON files:
[
{
"ParameterKey": "...",
"ParameterValue": "..."
}
]
In our case, it will look like the following:
testing.json
[
{
"ParameterKey": "Environment",
"ParameterValue": "Testing"
},
{
"ParameterKey": "VpcCidr",
"ParameterValue": "10.0.0.0/16"
},
{
"ParameterKey": "PublicSubnetCidr1",
"ParameterValue": "10.0.1.0/24"
},
{
"ParameterKey": "PublicSubnetCidr2",
"ParameterValue": "10.0.2.0/24"
},
// And so on...
]
When we create a template, we specify the parameter file in the argument:
$ aws cloudformaiton create-stack \
--stack-name core \
--template-body file://core.yaml \
--parameters file://testing.json
In the case that we have default values and want to make a few changes, we can specify the parameters one by one as positional arguments:
$ aws cloudformation create-stack \
--stack-name core \
--template-body file://core.yaml \
--parameters \ ParameterKey="Environment",ParameterValue="Testing"ParameterKey ="VpcCid",ParameterValue="10.0.0.0/16"
This is suitable if we have a few parameters, but is too complex for a large set of parameters.
Important note
Parameters are provided as a list and elements are split by spaces. Pay attention to the List type parameters, where you will have to use double backslashes:
ParameterKey=List,ParameterValue=Element1\\,Element2
Let's make a small change to our parameters. Since we have multiple CIDR ranges for each subnet per tier, let's use List parameters instead of Strings:
Parameters:
VpcCidr:
Type: String
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
PublicSubnetCidrs:
Type: List<String>
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
# and so on...
MiddlewareSubnetCidrs:
Type: List<String>
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
DatabaseSubnetCidrs:
Type: List<String>
AllowedPattern: '((\d{1,3})\.){3}\d{1,3}/\d{1,2}'
Environment:
Type: String
AllowedValues: ['prod', 'test']
Since we use lists, we cannot use Fn::Ref anymore (technically we can, but we will pass the whole list). Instead, we will use Fn::Select:
PublicSubnet1:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [0, 'PublicSubnetCidrs']
VpcId: !Ref Vpc
PublicSubnet2:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [1, 'PublicSubnetCidrs']
VpcId: !Ref Vpc
PublicSubnet3:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [2, 'PublicSubnetCidrs']
VpcId: !Ref Vpc
We can still use AllowedPattern, thereby making sure that we use the correct variables. Now, we need to change our parameters file a bit:
{
"ParameterKey": "PublicSubnetCidr1",
"ParameterValue": [
"10.0.1.0/24",
"10.0.2.0/24",
"10.0.3.0/24"
]
}
Looks much better now, right?
On the other hand, it might be tricky and too much effort to specify CIDR ranges one by one, especially when you have lots of subnets. For subnet ranges specifically, we can use an intrinsic function called Fn::Cidr.
The Fn::Cidr function is used to break bigger subnet ranges into smaller ones. This might sound a bit hard at the beginning, but if you understand how to calculate subnet masks, you will benefit from making your parameter file smaller while keeping the logic you need in the template.
Let's get back to our VPC. We specify the VPC CIDR range in the parameter. Our parameter file should now look like the following:
[
{
"ParameterKey": "VpcCidr",
"ParameterValue": "10.0.0.0/16"
}
]
In order to break our big subnet into smaller ones (/24), we need to provide the Fn::Cidr function with the following arguments:
- ipBlock: Our CIDR range.
- count: How many CIDR ranges we want to generate.
- cidrBits: This will tell CFN which ranges to generate. For a /24 CIDR range, we need to specify 8.
We know that we will have nine subnets (three for each public, middleware, and database subnet), so our intrinsic function will appear as follows:
Fn::Cidr:
- !RefVpcCidr
- 9
- 8
Or, short form syntax, it will look as follows:
!Cidr [ !Ref VpcCidr, 9, 8 ]
This function returns a list of CIDR ranges, so we need to combine it with Fn::Select when we declare our subnets. This is how our template will look:
PublicSubnet1:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [0, !Cidr [ !GetAttVpcCidr, 9, 8 ]]
VpcId: !Ref Vpc
PublicSubnet2:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [1, !Cidr [ !GetAttVpcCidr, 9, 8 ]]
VpcId: !Ref Vpc
PublicSubnet3:
Type: "AWS::EC2::Subnet"
Properties:
CidrBlock: !Select [2, !Cidr [ !GetAttVpcCidr, 9, 8 ]]
VpcId: !Ref Vpc
Using Fn::Cidr will allow you to avoid specifying CIDR ranges in the parameters one by one.
Important note
You should be careful when using this function because it expects you to prepare and design your network really well.
You will use Fn::Cidr each time you create a subnet, and each time, you need to make sure that the count of CIDR range blocks is the same; otherwise, you will have a subnetting issue and your stack deployment will fail.
If you are creating a network stack separately, your architecture is well designed, and you know what you are doing, then stick to using Fn::Cidr.
We will use parameters and intrinsic functions a lot, but let's now learn about another important feature of CloudFormation, called Conditions.