John McMillan
Posted on January 22, 2024
The Challenge:
I often use the Terraform VPC module when creating environments in AWS. And I'm an advocate for writing reusable code where possible...
So it bugged me that my use of the VPC module relied on a hacky way to cater for setting up subnets in regions with differing numbers of Availability Zones.
So typically I'd done something like this:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = var.vpc-name
cidr = var.vpc-cidr
azs = ["${var.region}a", "${var.region}b", "${var.region}c"]
intra_subnets = [cidrsubnet(var.vpc-cidr, 8, 0), cidrsubnet(var.vpc-cidr, 8, 1), cidrsubnet(var.vpc-cidr, 8, 2)]
private_subnets = [cidrsubnet(var.vpc-cidr, 8, 10), cidrsubnet(var.vpc-cidr, 8, 11), cidrsubnet(var.vpc-cidr, 8, 12)]
}
As you can see, I'd define the region
and vpc-cidr
variables and then when it comes to defining things like intra_subnets
and private_subnets
I'd use the cidrsubnet function to carve up the CIDR into three subnets.
This approach is fine if you're only deploying to a region with three AZs.
But what if you want to deploy to ap-northeast-2 where there are 4 AZs, or us-east-1 where there are 6?
In those cases you'd need to update the code to add additional entries for each AZ - then consider what shifting up or down you had to do with the netnum
argument in the cidrsubnet function.
So overall I had functioning code that could be reasonably quickly amended to cater for other regions. But it still wasn't very dynamic and I suspected it could be improved to be so.
Making it better:
Introducing the aws_availability_zones data source, along with the index function, and terraform locals, allowed me to modify the code so it caters for differing numbers of availability zones dynamically - meaning no changes to the code are necessary when deploying into different regions.
The code now looks like this:
data "aws_availability_zones" "my_azs" {
state = "available"
}
locals {
azs = data.aws_availability_zones.my_azs.names
intra_subnets = [
for az in local.azs : cidrsubnet(var.vpc_cidr, var.intra_subnet_size, index(local.azs, az))
]
private_subnets = [
for az in local.azs : cidrsubnet(var.vpc_cidr, var.private_subnet_size, (index(local.azs, az) + length(local.azs)))
]
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = var.vpc_name
cidr = var.vpc_cidr
azs = local.azs
intra_subnets = local.intra_subnets
private_subnets = local.private_subnets
}
This is better, and if you just needed to see that example and you understand what's happening, the rest of this blog probably isn't useful to you.
If you want to understand more about how the code above works, read on.
Lets look at each section, in turn:
data "aws_availability_zones" "my_azs" {
state = "available"
}
This retrieves a list of all 'available' availability zones in the region you're deploying into.
This next section defining the locals is a little more complex, but it's not too bad when we break it down:
azs = data.aws_availability_zones.my_azs.names
azs
is straight forward enough, it simply the list of AZ's that our data resource discovered for us.
intra_subnets = [
for az in local.azs : cidrsubnet(var.vpc_cidr, var.intra_subnet_size, index(local.azs, az))
]
Defining intra_subnets
relies on a 'for' loop.
This loop will iterate over each of the AZ's in our list and use that list to produce a subnet in each AZ regardless of the number of available AZs in the current region.
As an example, given the following variable definitions:
vpc_cidr
= 10.0.0.0/16
intra_subnet_size
= 8 (This was introduced as a variable so you can specify different subnet sizes according to your need.)
azs
= [ eu-west-1a, eu-west-1b, eu-west-1c]
... the first pass of the 'for' loop would expand the cidrsubnet function like this:
cidrsubnet(10.0.0.0/16, 8, (index(local.azs, eu-west-1a)))
Hopefully the first half of the cidrsubnet function should be clear now, you can see that the first subnet will have a /24 netmask (16+8), and that the subnet will be somewhere in the wider 10.0.0.0/16 range. But what position in that range?
That's what index
is being used for, to determine the value of netnum
for the cidrsubnet function.
The index
function, given a list, and an element in that list, will return the numerical position of that element in that list. e.g. eu-west-1a in the example above is position '0', eu-west-1b is position '1', & eu-west-1c is position '2'.
Therefore the index portion in the example above evaluates to "0" giving us:
cidrsubnet(10.0.0.0/16, 8, 0)
... meaning the first intra_subnet, for eu-west-1a, will be defined as 10.0.0.0/24.
When the loop iterates over eu-west-1b it expands as before, but this time because eu-west-1b is position 1 in the list the 'for' loop will evaulate to
cidrsubnet(10.0.0.0/16, 8, 1)
... meaning the second intra_subnet, for eu-west-1b, will be defined as 10.0.1.0/24... and so on.
Moving onto the private_subnets
definition:
private_subnets = [
for az in local.azs : cidrsubnet(var.vpc_cidr, var.private_subnet_size, (index(local.azs, az) + length(local.azs)))
This works on exactly the same premise as before, but we need to prevent them overlapping with the intra_subnets
. This is why the length
function has been added.
This behaves as before, but the important thing to note here is that length(local.azs)
will return a numerical value, equal to the length of the 'azs' list.
It's used here to add to the index numerical vale to provide an offset equal to the number of AZs.
Therefore with the same assumptions as before, for eu-west-1, this section will evaluate to:
cidrsubnet(10.0.0.0/16, 8, 3)
cidrsubnet(10.0.0.0/16, 8, 4)
cidrsubnet(10.0.0.0/16, 8, 5)
Giving private subnet ranges of:
10.0.3.0/24
10.0.4.0/24
10.0.5.0/24
Because we use length
to count the number of AZ's in the list it will always offset by the right amount.
If you wanted to add a third set of subnets, for example public_subnets
, you could use something like:
public_subnets = [
for az in local.azs : cidrsubnet(var.vpc_cidr, var.public_subnet_size, (index(local.azs, az) + length(local.azs)*2))
i.e. multiply the value of 'length' by 2 to give a third offset value.
Pulling it together:
So having defined the locals you now need to feed those into the VPC module. You can do that with something like:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = var.vpc_name
cidr = var.vpc_cidr
azs = local.azs
intra_subnets = local.intra_subnets
private_subnets = local.private_subnets
}
The beauty of this is that regardless of whether we deploy to eu-west-1 or us-east-1, we've produced code that will deploy the right number of subnets. It will also offset by the correct amount in each region to prevent overlapping ranges.
Further reading:
Terraform Data Sources
Terraform locals
Posted on January 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.