Referencing one subnet per AZ with Terraform
April 21, 2022
I recently had a Terraform use case in AWS where I needed to obtain a list of subnets from a VPC, but I only wanted one subnet per availability zone. Some quick searching around on DuckDuckGo didn’t yield an immediate solution to this problem, so I’m sharing my findings in the hope that it will help someone else.
If you’re interested in the solution, feel free to skip directly to “The Solution” below.
There are arguably better ways to architect this, such as adding a unique tag to only one subnet in an AZ. However, this provides one approach to solving the problem if that isn’t an option. It’s also an interesting thought experiment.
The Problem
I was using the Terraform VPC module to create a new VPC with public and private subnets. I then wanted to pass the public subnets to an AWS ALB resource. Ideally, the ALB would have a network mapping in each availability zone for fault tolerance.
The problem is that an ALB can only be attached to one subnet in each availability zone:
╷
│ Error: error creating application Load Balancer: InvalidConfigurationRequest: A load balancer cannot be attached to multiple subnets in the same Availability Zone
In my environment, each availability zone (us-west-1a
and us-west-1c
) has two subnets:
❯ aws --region us-west-1 ec2 describe-subnets --filters Name=vpc-id,Values=vpc-0db31fcb510846b2a Name=tag:public,Values=public | jq -r '.[][] | .AvailabilityZone, .SubnetId'
us-west-1a
subnet-0d8ee0c3b94df5735
us-west-1a
subnet-03e675359912ef292
us-west-1c
subnet-0167ac2ac63e71317
us-west-1c
subnet-03dc67203dac221c9
I needed a way to obtain only one subnet per AZ so that I could pass these subnets to the ALB resource. In my case, these subnets were provisioned with the VPC module, but that isn’t relevant to the problem: the VPC and subnets could be provisioned in any manner. As long as there is more than one subnet per AZ, it would cause a problem in my use case.
The Solution
The solution to this problem involves a few steps:
- Obtain a list of all subnets in each availability zone for a given VPC
- Filter those subnets such that there is only one per availability zone
- Ensure that created resources ignore changes to the provided subnets, as the ordering is non-determinstic
Obtaining a list of all subnets in each availabilty zone can be accomplished by combining the aws_availability_zone
data source and the aws_subnets
data source. The aws_subnets
data source supports the use of advanced filters that correspond to the underlying AWS API.
The example below performs the following:
- Obtain a list of all availabilty zones using the
aws_availability_zones
data source - Obtain a list of subnets using the
aws_subnets
data source and afor_each
for each availability zone. These subnets are filtered on three criteria:- The subnet must be in the desired VPC
- It must be tagged with the
public
tag - It must be in the availabilty zone being processed in the current loop iteration
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_subnets" "filtered_public" {
for_each = toset(data.aws_availability_zones.available.zone_ids)
filter {
name = "vpc-id"
values = [var.vpc_id]
}
filter {
name = "tag-key"
values = ["public"]
}
filter {
name = "availability-zone-id"
values = ["${each.value}"]
}
}
This yields a map data structure that looks like this (I have removed some of the fields for brevity):
filtered_public_subnets = {
"usw1-az1" = {
...
"ids" = tolist([
"subnet-0d8ee0c3b94df5735",
"subnet-03e675359912ef292",
])
...
}
"usw1-az3" = {
...
"ids" = tolist([
"subnet-0167ac2ac63e71317",
"subnet-03dc67203dac221c9",
])
...
}
}
The top-level keys for this map are the availability zones being processed in the for_each
(usw1-az1
and usw1-az3
). This is useful, but I am only interested in the ids
list within these maps, and I only want one element from each. These can be extracted using a local variable and a loop:
locals {
public_subnet_ids = [for k, v in data.aws_subnets.filtered_public : v.ids[0]]
}
This logic creates a list by iterating over each value in the map (usw1-az1
and usw1-az2
) and extracting the first element ([0]
) of the ids
list. The resulting data structure is a list of subnet IDs, one per AZ:
public_subnet_ids = [
"subnet-0d8ee0c3b94df5735",
"subnet-0167ac2ac63e71317",
]
This is now in a format that can be passed as a list of subnets to other resources, such as the aws_lb
that I was originally trying to create. It’s very important to note that the ids
use a tolist
, which means that ordering is not guaranteed across Terraform runs (a question about this was even raised on the AWS provider’s issue tracker). This makes the use of the lifecycle
meta-argument necessary to ensure that changes to the list of subnets do not cause a change to the resource itself:
resource "aws_lb" "alb" {
name = "example-alb"
...
subnets = local.public_subnet_ids
lifecycle {
ignore_changes = [ subnets ]
}
}
Wrapping Up
Looping and filtering are easy concepts in most programming languages, but they are more difficult to grok when using the Terraform DSL. This is a pretty simple use case, but it took me longer than I’d like to admit to figure it out. Hopefully this solution will help others in a similar position who need to filter the raw output of a data source so that it is more easily consumed by another resource. However, you must always keep in mind that values may not be deterministic, and be prepared to handle those scenarios accordingly.
If anyone has a better solution to this problem, then please get in touch!
Previous article: Introducing Rucksack: A place to store your one-liners
Next article: Built-In Locks for Kubernetes Workloads