DEV Community

John McMillan
John McMillan

Posted on

Generating dynamic subnets & AZs with Terraform's VPC module

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)]

}
Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
  ]
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

... 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)
Enter fullscreen mode Exit fullscreen mode

... 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)))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)