Tiered Subnet Calculator in Terraform

I’ve been thinking about adding support for building tiered subnets of any valid size for the next iteration of the Dynamic VPC Module (which ended up being called Tiered VPC-NG). It occurred to me that auto subnet generation inside the module actually makes the subnetting less dynamic.

Furthermore, auto subnet calculation should be in assistance to the process of allocating subnets and should not be fed directly as input to the VPC module. This is due to the fact that order matters only for the subnetting calculation. So when tiers or AZs are changed, added or removed, the calculation will shift for none, some or all tiers.

Removing the subnet generation will simplify the module itself and it will reinforce the notion that engineers should know their subnetting when architecting networks. With all that in mind, I created a Tiered Subnet Calculator module to assist with allocating subnets per AZ per network tier.

tiers.auto.tfvars

base_cidr_block = "10.0.0.0/16"

tiers = [
  {
    name   = "app"
    acl    = "public"
    newbit = 4
  },
  {
    name   = "db"
    acl    = "private"
    newbit = 4
  },
  {
    name   = "worker"
    acl    = "private"
    newbit = 4
  },
  {
    name   = "lbs"
    acl    = "public"
    newbit = 4
  }
]

az_newbits = {
  a = 4
  b = 4
  c = 4
  d = 4
}

variables.tf

variable "base_cidr_block" {
  description = "Large starting CIDR block ie 10.0.0.0/16"
  type        = string
}

variable "tiers" {
  description = "Networking tiers"
  type = list(object({
    name   = string
    acl    = string
    newbit = number
  }))
}

variable "az_newbits" {
  description = "New bits to add to calculated cidr blocks for subnets per AZ"
  type        = map(number)
}

main.tf

locals {
  # generate top level networks for each tier based on tier newbit + base_cidr_block mask ie /4 + /16 = /20
  tier_networks = zipmap(var.tiers[*].name, cidrsubnets(var.base_cidr_block, var.tiers[*].newbit...))

  # generate a subnet based on each az newbit per tier network ie /4 + /20 = /24
  tier_subnets = { for t, n in local.tier_networks : t => cidrsubnets(n, values(var.az_newbits)...) }

  # generate azs to subnet map per tier
  tier_az_subnets = { for t, s in local.tier_subnets : t => zipmap(keys(var.az_newbits), s) }

  # build new tiers list with their associated network and az to subnets map
  tiers_with_subnets_per_az = [
    for t in var.tiers : {
      name    = t.name
      acl     = t.acl
      network = lookup(local.tier_networks, t.name)
      azs     = lookup(local.tier_az_subnets, t.name)
  }]
}

output "calculated_tiers" {
  value = local.tiers_with_subnets_per_az
}

terraform apply

$ terraform apply
...

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes


Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

calculated_tiers = [
  {
    "acl" = "public"
    "azs" = tomap({
      "a" = "10.0.0.0/24"
      "b" = "10.0.1.0/24"
      "c" = "10.0.2.0/24"
      "d" = "10.0.3.0/24"
    })
    "name" = "app"
    "network" = "10.0.0.0/20"
  },
  {
    "acl" = "private"
    "azs" = tomap({
      "a" = "10.0.16.0/24"
      "b" = "10.0.17.0/24"
      "c" = "10.0.18.0/24"
      "d" = "10.0.19.0/24"
    })
    "name" = "db"
    "network" = "10.0.16.0/20"
  },
  {
    "acl" = "private"
    "azs" = tomap({
      "a" = "10.0.32.0/24"
      "b" = "10.0.33.0/24"
      "c" = "10.0.34.0/24"
      "d" = "10.0.35.0/24"
    })
    "name" = "worker"
    "network" = "10.0.32.0/20"
  },
  {
    "acl" = "public"
    "azs" = tomap({
      "a" = "10.0.48.0/24"
      "b" = "10.0.49.0/24"
      "c" = "10.0.50.0/24"
      "d" = "10.0.51.0/24"
    })
    "name" = "lbs"
    "network" = "10.0.48.0/20"
  },
]

If you want to see each tier transform you can open the terraform console and call them to see their output.

$ terraform console
> local.tier_networks
{
  "app" = "10.0.0.0/20"
  "db" = "10.0.16.0/20"
  "lbs" = "10.0.48.0/20"
  "worker" = "10.0.32.0/20"
}
> local.tier_subnets
{
  "app" = tolist([
    "10.0.0.0/24",
    "10.0.1.0/24",
    "10.0.2.0/24",
    "10.0.3.0/24",
  ])
  "db" = tolist([
    "10.0.16.0/24",
    "10.0.17.0/24",
    "10.0.18.0/24",
    "10.0.19.0/24",
  ])
  "lbs" = tolist([
    "10.0.48.0/24",
    "10.0.49.0/24",
    "10.0.50.0/24",
    "10.0.51.0/24",
  ])
  "worker" = tolist([
    "10.0.32.0/24",
    "10.0.33.0/24",
    "10.0.34.0/24",
    "10.0.35.0/24",
  ])
}
> local.tier_az_subnets
{
  "app" = tomap({
    "a" = "10.0.0.0/24"
    "b" = "10.0.1.0/24"
    "c" = "10.0.2.0/24"
    "d" = "10.0.3.0/24"
  })
  "db" = tomap({
    "a" = "10.0.16.0/24"
    "b" = "10.0.17.0/24"
    "c" = "10.0.18.0/24"
    "d" = "10.0.19.0/24"
  })
  "lbs" = tomap({
    "a" = "10.0.48.0/24"
    "b" = "10.0.49.0/24"
    "c" = "10.0.50.0/24"
    "d" = "10.0.51.0/24"
  })
  "worker" = tomap({
    "a" = "10.0.32.0/24"
    "b" = "10.0.33.0/24"
    "c" = "10.0.34.0/24"
    "d" = "10.0.35.0/24"
  })
}

Filtering tiers easy too.

locals {
  private_tiers = [for t in local.tier_subnets_per_az : t if t.acl == "private"]
}

Now I can start tweaking the tiers list of objects and az_newbits map to generate different tiered network configurations.

tiers.auto.tfvars

base_cidr_block = "10.0.0.0/16"

tiers = [
  {
    name   = "app"
    acl    = "public"
    newbit = 6
  },
  {
    name   = "db"
    acl    = "private"
    newbit = 6
  },
  {
    name   = "worker"
    acl    = "private"
    newbit = 4
  },
]

az_newbits = {
  b = 2
  c = 4
  d = 4
}
$ terraform apply
...

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes


Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

calculated_tiers = [
  {
    "acl" = "public"
    "azs" = tomap({
      "b" = "10.0.0.0/24"
      "c" = "10.0.1.0/26"
      "d" = "10.0.1.64/26"
    })
    "name" = "app"
    "network" = "10.0.0.0/22"
  },
  {
    "acl" = "private"
    "azs" = tomap({
      "b" = "10.0.4.0/24"
      "c" = "10.0.5.0/26"
      "d" = "10.0.5.64/26"
    })
    "name" = "db"
    "network" = "10.0.4.0/22"
  },
  {
    "acl" = "private"
    "azs" = tomap({
      "b" = "10.0.16.0/22"
      "c" = "10.0.20.0/24"
      "d" = "10.0.21.0/24"
    })
    "name" = "worker"
    "network" = "10.0.16.0/20"
  },
]

I’m able to take this output, add or remove AZs and subnets that may have not been in the original calculation. I can chop up networks as I see fit.

tiers = [
  {
    "acl" = "public"
    "azs" = {
      "b" = "10.0.0.0/24"
      "c" = "10.0.1.0/26"
    }
    "name" = "app"
    "network" = "10.0.0.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "c" = "10.0.5.0/26"
      "d" = "10.0.5.64/26"
    }
    "name" = "db"
    "network" = "10.0.4.0/22"
  },
  {
    "acl" = "private"
    "azs" = {
      "b" = "10.0.16.0/22"
      "d" = "10.0.21.0/24"
      "c" = "10.0.22.0/24"
    }
    "name" = "worker"
    "network" = "10.0.16.0/20"
  },
]

Also, I can further validate tiered network ranges with ipcalc.

$ ipcalc 10.0.16.0/20

Address:   10.0.16.0            00001010.00000000.0001 0000.00000000
Netmask:   255.255.240.0 = 20   11111111.11111111.1111 0000.00000000
Wildcard:  0.0.15.255           00000000.00000000.0000 1111.11111111
=>
Network:   10.0.16.0/20         00001010.00000000.0001 0000.00000000
HostMin:   10.0.16.1            00001010.00000000.0001 0000.00000001
HostMax:   10.0.31.254          00001010.00000000.0001 1111.11111110
Broadcast: 10.0.31.255          00001010.00000000.0001 1111.11111111
Hosts/Net: 4094                  Class A, Private Internet

A more detailed break down of subnets within a tiered network.

$ ipcalc 10.0.16.0/20 /24

Address:   10.0.16.0            00001010.00000000.0001 0000.00000000
Netmask:   255.255.240.0 = 20   11111111.11111111.1111 0000.00000000
Wildcard:  0.0.15.255           00000000.00000000.0000 1111.11111111
=>
Network:   10.0.16.0/20         00001010.00000000.0001 0000.00000000
HostMin:   10.0.16.1            00001010.00000000.0001 0000.00000001
HostMax:   10.0.31.254          00001010.00000000.0001 1111.11111110
Broadcast: 10.0.31.255          00001010.00000000.0001 1111.11111111
Hosts/Net: 4094                  Class A, Private Internet

Subnets after transition from /20 to /24

Netmask:   255.255.255.0 = 24   11111111.11111111.11111111. 00000000
Wildcard:  0.0.0.255            00000000.00000000.00000000. 11111111

 1.
Network:   10.0.16.0/24         00001010.00000000.00010000. 00000000
HostMin:   10.0.16.1            00001010.00000000.00010000. 00000001
HostMax:   10.0.16.254          00001010.00000000.00010000. 11111110
Broadcast: 10.0.16.255          00001010.00000000.00010000. 11111111
Hosts/Net: 254                   Class A, Private Internet

 2.
Network:   10.0.17.0/24         00001010.00000000.00010001. 00000000
HostMin:   10.0.17.1            00001010.00000000.00010001. 00000001
HostMax:   10.0.17.254          00001010.00000000.00010001. 11111110
Broadcast: 10.0.17.255          00001010.00000000.00010001. 11111111
Hosts/Net: 254                   Class A, Private Internet

...

 15.
Network:   10.0.30.0/24         00001010.00000000.00011110. 00000000
HostMin:   10.0.30.1            00001010.00000000.00011110. 00000001
HostMax:   10.0.30.254          00001010.00000000.00011110. 11111110
Broadcast: 10.0.30.255          00001010.00000000.00011110. 11111111
Hosts/Net: 254                   Class A, Private Internet

 16.
Network:   10.0.31.0/24         00001010.00000000.00011111. 00000000
HostMin:   10.0.31.1            00001010.00000000.00011111. 00000001
HostMax:   10.0.31.254          00001010.00000000.00011111. 11111110
Broadcast: 10.0.31.255          00001010.00000000.00011111. 11111111
Hosts/Net: 254                   Class A, Private Internet


Subnets:   16
Hosts:     4064

The moral of the story is Know Thy Subnetting.

~jq1


Update May 6th 2023:

Things have changed for the Tiered Subnet Calculator module since Terraform v0.13.0 like the resulting order (lexical sorting) of a set of objects with for.

variable "tiers" {
  description = "Networking tiers"
  type = set(object({
    name   = string
    acl    = string
    newbit = number
  }))
}

Using a list of objects will keep it’s current order so I can use the var.base_cidr_block as a starting point for generating network subnets for AWS.

variable "tiers" {
  description = "Networking tiers"
  type = list(object({
    name   = string
    acl    = string
    newbit = number
  }))
}

Updated the article with newer outputs from Terraform v1.3.9+, examples using terraform apply instead of terraform refresh (deprecated), and fixes from the PR.

~jq1 #StayUp


Update:

I know I said auto subnet calculation should not be fed directly as input to the VPC module but that is just my opinion.

It doesn’t mean you shouldn’t try or that it hasn’t already been looked into.

The goal is to challenge our own ideas.

Feedback

What did you think about this post? jude@jq1.io