Terraform 'zipmap' with 'count' to Improve on Code Maintainability
Often, Terraform modules, you develop, provide their outputs as lists. It could be OK for most cases, but sometimes it may create dependencies in your code that a developer could break accidentally by messing up with item positions in the output lists. Terraform offers some approaches to protect from such cases and improve code maintainability. A zipmap
function is one of them.
What is a zipmap Function?
According to Terraform documentation, a zimpmap
function constructs a map from a list of keys and a corresponding list of values:
zipmap(keylist, valueslist)
Both lists should be of the same length. The keylist
should contain strings. The valueslist
can contain values of any type.
Use case
The approach can be used when a Terraform module creates many similar resources of the same type but configured differently.
The module accepts a list of configuration parameters as an input variable to create these distinct resources. A resource inside the module iterates over these configuration parameters to create many instances of this resource type configured appropriately. A key requirement for this use case is that, for a reason, the resource uses the count
meta-argument for iteration. One of key features of count
meta-argumnet is that it indexes a collection of resources by a number starting from 0. It is different, for example, from for_each
meta-argument which does the indexing by a key.
The module outputs some reference information about the resource instances it has created. Usually, it comes in as a list of unique identifiers of the created resources.
The approach changes the output variable by combining the known information about the created resources with a piece of new information about them as a map by applying the zipmap
function. The known information is provided as the module input. The new information is generated by a system when it creates the resources.
The approach can be illustrated with the following diagram:
Example
A user-defined vpc
module for AWS Terraform provider creates several private networks as instances of the aws_subnet
resource. The vpc
module accepts a list of private subnet configurations (CIDR block, availability zone, and name) as its input parameter. The module iterates over the list of the configuration parameters and creates a private subnet per a configuration set. The module outputs identifiers of the created private subnets for further use in a top-level module.
You are creating a vpc
module with the following structure in the project folder:
infra/main.tf
for the top-level modulemodules/vpc/main.tf
for the code file of the modulemodules/vpc/variables.tf
for the file of module variablesmodules/vpc/outputs.tf
for the file of module outputs
To make it more efficient, you define the parameters of every private network as a list of objects, so your module can iterate over the list and create every network one by one:
modules/vpc/variables.tf
// This is the input variable that 'vpc' module will iterate over to create a set of private subnets
variable "private_subnets" {
description = "Configuration parameters for private subnets"
type = list(object({
zone = string
cidr_block = string
name = string
}))
}
Then in the module code file, you create a VPC and private subnets as follows:
modules/vpc/main.tf
// Create private subnets by iterating over the subnet configuration parameters
// in the var.private_subnets variable
resource "aws_subnet" "private" {
// We are using 'count' instead of 'for_each'
// because we would like to get a list of private subnet resources indexed from 0 (instead of a map indexed by key)
count = length(var.private_subnets)
vpc_id = aws_vpc.vpc.id
// Let's apply the configuration from our input variable.
cidr_block = element(var.private_subnets, count.index).cidr_block
availability_zone = element(var.private_subnets, count.index).zone
tags = {
"Name" = element(var.private_subnets[*].name, count.index)
}
}
Now, you need to pass the identifiers of the private subnet in the VPC to the top-level module, so you can use them to place other resources there. You could do this by specifying an output variable with the following value: value = aws_subnet.private[*].id
. However, the variable would be of a list
type and you would need to access its values by index. This could be easily messed up if you accidentally added another private subnet in the middle of the value of private_subnets
variable.
A better way is to convert the output list into a map and reference a subnet by a key then:
modules/vpc/output.tf
output "private_subnet_ids" {
description = "Private subnets"
//value = zipmap(var.private_subnets[*].name, aws_subnet.private[*].id)
value = zipmap(aws_subnet.private[*].tags["Name"], aws_subnet.private[*].id)
}
Now your vpc
module outputs a map
variable instead of the list
which you can use in your top-level module as follows:
infra/main.tf
// Now, let's create a VPC.
module "vpc" {
source = "../modules/vpc"
// We set name and CIDR block for our VPC.
vpc_name = "poc-vpc"
vpc_cidr_block = "172.16.0.0/16"
// Now we configure 3 private subnets.
private_subnets = [{
name = "private-zone-a"
cidr_block = "172.16.0.0/23"
zone = "eu-west-1a"
}, {
name = "private-zone-b"
cidr_block = "172.16.2.0/23"
zone = "eu-west-1b"
}, {
name = "private-zone-c"
cidr_block = "172.16.4.0/23"
zone = "eu-west-1c"
}]
}
Then, you create a lambda function and place it to 2 of 3 private subnets we’ve just created. You will reference the subnets by their names:
infra/main.tf
// Now let's place a lambda function to the private subnets in our VPC.
// This is the example of another resource referencing our new private subnets.
resource "aws_lambda_function" "this" {
function_name = "poc-test"
vpc_config {
// Now we can reference to the particular instances of private subnets by their names.
// This improves the maintainability of the code and makes it less sensitive to the configuration changes.
subnet_ids = [module.vpc.private_subnet_ids["private-zone-a"], module.vpc.private_subnet_ids["private-zone-c"]]
security_group_ids = [aws_security_group.this.id]
}
}
The complete example can be found in the repository on GitHub.
Conclusion
The approach in this post is for a niche case of using count
meta-argument for iteration. Usually, this meta-argument is the best option to create multiple copies of identical resources. Nowadays, a general recommendation for the creation of resource instances that varies by their input parameters is to use the for_each
meta-argument that already returns the result of iteration as a map.
However, historically count
was one of the first approaches to iteration in Terraform. It gave rise to various patterns of count
use and overuse. As a result, tons of different scripts over the Internet still utilize this approach. So, one day the zipmap
trick may come in handy.