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 

# 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.