Terraform - AWS
Basic VM - Simple Example
E.g. to create a VM in the AWS Cloud we would create a new file main.tf in a new directory, e.g. lab0. Instead of using terraform variables we use a yaml document to define the values as it is much easier to understand. At the same time we separate logic from data which makes our code reusable and easy to test. E.g. to test the code in a staging environment before we put it in the production environment we would create a config specific for the staging environment and another config for the production environment.
# lab0/main.tf
locals { config = yamldecode(file("./config.yml")) }
provider "aws" {
profile = local.config.project.profile
region = local.config.project.region
}
resource "aws_vpc" "net1" {
cidr_block = local.config.network[0].cidr
tags = { Name = local.config.network[0].name }
}
resource "aws_subnet" "sub1" {
vpc_id = aws_vpc.net1.id
cidr_block = local.config.network[0].subnet[0].cidr
availability_zone = local.config.project.zone
tags = { Name = local.config.network[0].subnet[0].name }
}
resource "aws_network_interface" "eth0" {
subnet_id = aws_subnet.sub1.id
tags = { Name = "eth0" }
}
resource "aws_instance" "vm1" {
instance_type = local.config.vms[0].type
ami = local.config.vms[0].image
tags = { Name = local.config.vms[0].name }
network_interface {
network_interface_id = aws_network_interface.eth0.id
device_index = 0
}
}
---
# lab0/config.yml
project:
id : my-lab-vpc-1
profile: default
region : us-west-1
zone : us-west-1a
network:
- name: net1
cidr: 192.168.0.0/16
subnet:
- name: net1-lan1
cidr: 192.168.21.0/24
vms:
- name : vm-1
type : t2.micro
image: ami-066d8f152c8209022
net : net1-lan1
Scalability
If we take the example from above we notice that it doesn't scale well. To add a VM we would need to add another aws_instance resource and carefully add the correct indices which makes it quite error prone to work with.
# lab0a/main.tf
locals { config = yamldecode(file("./config.yml")) }
provider "aws" {
profile = local.config.project.profile
region = local.config.project.region
}
resource "aws_vpc" "net1" {
cidr_block = local.config.network[0].cidr
tags = { Name = local.config.network[0].name }
}
resource "aws_subnet" "sub1" {
vpc_id = aws_vpc.net1.id
cidr_block = local.config.network[0].subnet[0].cidr
availability_zone = local.config.project.zone
tags = { Name = local.config.network[0].subnet[0].name }
}
resource "aws_network_interface" "vm1_eth0" {
subnet_id = aws_subnet.sub1.id
tags = { Name = "vm1_eth0" }
}
resource "aws_network_interface" "vm2_eth0" {
subnet_id = aws_subnet.sub1.id
tags = { Name = "vm2_eth0" }
}
resource "aws_instance" "vm1" {
instance_type = local.config.vms[0].type
ami = local.config.vms[0].image
tags = { Name = local.config.vms[0].name }
network_interface {
network_interface_id = aws_network_interface.vm1_eth0.id
device_index = 0
}
}
resource "aws_instance" "vm2" {
instance_type = local.config.vms[1].type
ami = local.config.vms[1].image
tags = { Name = local.config.vms[1].name }
network_interface {
network_interface_id = aws_network_interface.vm2_eth0.id
device_index = 0
}
}
---
# lab0a/config.yml
project:
id : my-lab-vpc-1a
profile: default
region : us-west-1
zone : us-west-1a
network:
- name: net1
cidr: 192.168.0.0/16
subnet:
- name: net1-lan2
cidr: 192.168.22.0/24
vms:
- name : vm-1
type : t2.micro
image: ami-066d8f152c8209022
net : net1-lan2
- name : vm-2
type : t2.micro
image: ami-0d289675b4e4750f6
net : net1-lan2
To fix that we need to be able to somehow loop over our configuration and create the resource code dynamically. To do so we use the for_each meta-argument. We will also add support for multiple networks and subnets within the networks which means that we have to adjust our config.yml a little so subnets have their own section as it is not possible (at least not trivially) to use nested loops with terraform. To tell subnets apart easily, we just add the network name at the beginning of the subnet name, e.g. if the network is called net1 then the subnet will be called net1-lan1.
# lab0b/main.tf
locals { config = yamldecode(file("./config.yml")) }
provider "aws" {
profile = local.config.project.profile
region = local.config.project.region
}
resource "aws_vpc" "net" {
for_each = { for net in local.config.network : net.name => net }
cidr_block = each.value.cidr
tags = { Name = each.value.name }
}
resource "aws_subnet" "sub" {
for_each = { for sub in local.config.subnet : sub.name => sub }
vpc_id = aws_vpc.net[split( "-", each.value.name )[0]].id
cidr_block = each.value.cidr
availability_zone = local.config.project.zone
tags = { Name = each.value.name }
}
resource "aws_network_interface" "nic" {
for_each = { for vm in local.config.vms : vm.name => vm }
subnet_id = aws_subnet.sub[each.value.net].id
tags = { Name = "${each.value.name}_eth0" }
}
resource "aws_instance" "vm" {
for_each = { for vm in local.config.vms : vm.name => vm }
instance_type = each.value.type
ami = each.value.image
tags = { Name = each.value.name }
network_interface {
network_interface_id = aws_network_interface.nic[each.value.name].id
device_index = 0
}
}
---
# lab0b/config.yml
project:
id : my-lab-vpc-1b
profile : default
region : us-west-1
zone : us-west-1a
network:
- name: net1
cidr: 192.168.0.0/16
- name: net2
cidr: 192.168.0.0/16
- name: net3
cidr: 192.168.0.0/16
subnet:
- name: net1-lan1
net : net1
cidr: 192.168.11.0/24
- name: net2-lan1
net : net2
cidr: 192.168.21.0/24
- name: net2-lan2
net : net2
cidr: 192.168.22.0/24
- name: net3-lan1
net : net3
cidr: 192.168.31.0/24
vms:
- name : vm-1
type : t2.micro
image: ami-066d8f152c8209022
net : net1-lan1
- name : vm-2
type : t2.micro
image: ami-0d289675b4e4750f6
net : net3-lan1
- name : vm-3
type : t2.micro
image: ami-066d8f152c8209022
net : net2-lan1
- name : vm-4
type : t2.micro
image: ami-0d289675b4e4750f6
net : net2-lan2
Modules
Now that we have some code that also scales nicely we might want to reuse it in other projects. We could just copy the file main.tf to every project to do so, but in case we would like to add/change features that should effect all the projects (e.g. we want to add the os-login feature to the VMs ) we would need to go to every copy and edit or replace it. A better solution is to create a module out of our code that can be reused in different projects by just including it via a module block.
We will now transform our resource configuration into modules. To do so we create a new directory structure modules/aws/.
lab0a/
lab0b/
+- main.tf # <<< resource config we will transform
+ config.yml # <<< yaml configuration we will keep
lab0c/
+- main.tf # <<< new resource config that uses the lab module
+- config.yml # <<< copied yaml configuration from lab0b
modules/
+- aws/
| +- net/
| | +- main.tf # <<< new net module, from the network resource of lab0b
| | +- variables.tf # <<< to pass the yaml config to the module
| +- subnet/
| | +- main.tf # <<< new subnet module, from the subnet resource of lab0b
| | +- variables.tf # <<< to pass the yaml config to the module
| +- vm/
| | +- main.tf
| | +- variables.tf
| +- lab/ # <<< this is the new the lab module
| +- main.tf
| +- variables.tf
+- gcp/
...
Net Module
We start with the net module. We just copy the resource block from lab0b and change the variable from local.config.network to var.nets as the module doesn't have access to the local itself. We will see later that we need to pass the configuration to the module as a variable which we defined as nets as we can see in the variables.tf file.
# modules/aws/net/main.tf
resource "aws_vpc" "net" {
for_each = { for net in var.nets : net.name => net }
cidr_block = each.value.cidr
tags = { Name = each.value.name }
}
# modules/aws/net/variables.tf
variable nets { type = any }
Subnet Modul
Next we create the subnet modul. We need to create 2 variables as we have to pass 2 sections of our yaml config, namely
subnet
project
# modules/aws/subnet/main.tf
data "aws_vpc" "net" {
for_each = { for sub in var.subnets : sub.name => sub }
tags = { Name = each.value.net }
}
resource "aws_subnet" "sub" {
for_each = { for sub in var.subnets : sub.name => sub }
vpc_id = data.aws_vpc.net[each.value.name].id
cidr_block = each.value.cidr
availability_zone = var.project.zone
tags = { Name = each.value.name }
}
# modules/aws/subnet/variables.tf
variable subnets { type = any }
variable project { type = any }
VM Module
Now we create the vm module.
# modules/aws/vm/main.tf
data "aws_subnet" "sub" {
for_each = { for vm in var.vms : vm.name => vm }
tags = { Name = each.value.net }
}
resource "aws_network_interface" "nic" {
for_each = { for vm in var.vms : vm.name => vm }
subnet_id = data.aws_subnet.sub[each.value.name].id
tags = { Name = "${each.value.name}_eth0" }
}
resource "aws_instance" "vm" {
for_each = { for vm in var.vms : vm.name => vm }
instance_type = each.value.type
ami = each.value.image
tags = { Name = each.value.name }
network_interface {
network_interface_id = aws_network_interface.nic[each.value.name].id
device_index = 0
}
}
# modules/aws/vm/variables.tf
variable vms { type = any }
Lab Module
Now we can create a dedicated lab module that can be reused by just changing the yaml config. The lab module uses the modules we just creates, i.e. net, subnet, vm. This way we can resuse those modules o create other composite module, e.g. a db-lab module, which uses an additional sqldb module.
# modules/aws/lab/main.tf
# PROJECT
provider "aws" {
profile = var.config.project.profile
region = var.config.project.region
}
# NETWORKS
module "net" {
source = "../net"
nets = var.config.network
}
#
# SUBNETS
module "subnet" {
source = "../subnet"
project = var.config.project
subnets = var.config.subnet
depends_on = [module.net]
}
# VMs
module "vm" {
source = "../vm"
vms = var.config.vms
depends_on = [module.subnet]
}
# modules/aws/lab/variables.tf
variable config { type = any }
To use our newly created lab module we create a module block and reference the folder where the lab module is located in the source variable. We also pass our yaml config.
# lab0c/main.tf
locals { config = yamldecode( file("./config.yml") ) }
module "lab0c" {
source = "../modules/aws/lab"
config = local.config
}
--
# lab0b/config.yml
project:
id : my-lab-vpc-1b
profile : default
region : us-west-1
zone : us-west-1a
network:
- name: net1
cidr: 192.168.0.0/16
- name: net2
cidr: 192.168.0.0/16
- name: net3
cidr: 192.168.0.0/16
subnet:
- name: net1-lan1
net : net1
cidr: 192.168.11.0/24
- name: net2-lan1
net : net2
cidr: 192.168.21.0/24
- name: net2-lan2
net : net2
cidr: 192.168.22.0/24
- name: net3-lan1
net : net3
cidr: 192.168.31.0/24
vms:
- name : vm-1
type : t2.micro
image: ami-066d8f152c8209022
net : net1-lan1
- name : vm-2
type : t2.micro
image: ami-0d289675b4e4750f6
net : net3-lan1
- name : vm-3
type : t2.micro
image: ami-066d8f152c8209022
net : net2-lan1
- name : vm-4
type : t2.micro
image: ami-0d289675b4e4750f6
net : net2-lan2
To apply the lab0c configuration, we issue the command terraform apply in the lab0c folder.