Skip to content

Terraform

Best combination:

  • Terraform + Docker
  • Provisioning & Config Management: Terraform to provision servers and Ansible to configure each one
  • Provisioning & Server templating: Terraform to provision servers and Packer to package apps as VM images
  • Provisioning & Server templating & Orchestration: Terraform to provision servers and Packer to package apps as VM images and Docker/K8 agent installed in the VM images
  • Ansible is a procedural language while Terraform is declarative. Procedural does not fully capture the state of the infrastructure and limits reusability.

[!NOTE] Listening on any port less than 1024 requires root user privileges. This is a security risk since any attacker who manages to compromise your server would get root privileges, too.

Basic Terraform commands

[!NOTE] - Make sure that the AWS cred (ACCESS_KEY & SECRET_ACCESS_KEY) are in the env. or; - Do the following:

provider "aws" {
shared_config_files      = ["/Users/tf_user/.aws/conf"]
shared_credentials_files = ["/Users/tf_user/.aws/creds"]
profile                  = "customprofile"
    }

  • General syntax for creating a resource:
    resource "<PROVIDER>_<TYPE>" "<NAME>" {
        [CONFIG ...]
    }
    

1) Create main.tf with the following: (means the infra will be deployed in us-east-1)

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
    region = "us-east-1"
}

resource "aws_instance" "example" {
    ami           = "ami=3edh9eh12ihddss2"
    instance_type = "t2.micro"

    tags = {
        Name = "terraform-ex"
    }
}

2) terraform init: scans the code (the provider code is downloaded into a .terraform folder; better to add to .gitignore) 3) terraform plan: see all the changes to be performed - +: added - -: deleted - ~: modified 4) terraform fmt: format the code 5) terraform validate: validate the code 6) terraform apply: apply the changes 7) Add the following to .gitignore: - .terraform - *.tfstate - *.tfstate.backup 8) terraform show: show the current state 9) terraform destroy: to destroy everything created

Example 1 (deploying single web server)

1) Write a bash script (simple server returning Hello World):

#!/bin/bash
echo "Hello, World" > index.html
nohup busybox httpd -f -p 8080 &

2) Terraform code (for ec2):

resource "aws_instance" "example" {
    ami = "ami-3e12uddd9u"
    instance_type = "t2.micro"
    vpc_security_group_ids = [aws_security_group.instance.id]

    user_data = <<-EOF
                #!/bin/bash
                echo "Hello, World" > index.html
                nohup busybox httpd -f -p 8080 &
                EOF

    user_data_replace_on_change = true

    tags = {
        Name = "tf-ex"
    }
}

  • <<-EOF & EOF are tf way of allowing multi line text without using \n

3) Terraform code (for sg):

resource "aws_security_group" "instance" {
    name = "tf-ex-instance"

    ingress {
        from_port = 8080
        to_port = 8080
        protocol = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
    }
}

[!NOTE] We can reference other resources' attributes in tf by doing <PROVIDER>_<TYPE>.<NAME>.<ATTRIBUTE>.

4) terraform graph: Terraform smart enough to create an implicit dependency and creates a dependency graph to determine in which order it should create resources. Here, SG before EC2. 5) terraform apply 6) curl <public-ip>:8080 to get the output 7) terraform destroy

Variables

  • We can define vars using variable:
    variable "instance_name" {
      description = "Value of the Name tag for the EC2 instance"
      type        = string
      default     = "ExampleAppServerInstance"
    }
    
  • If default is not provided, it is asked interactively.
  • Then, we can use it as var.instance_name or set it somewhere inside as ${var.instance_name}.

[!TIP] We can also pass it at runtime by: terraform apply -var "instance_name=YetAnotherName"

Output

  • We can have a defined output to the user using output:
    output "instance_id" {
      description = "ID of the EC2 instance"
      value       = aws_instance.app_server.id
    }
    
    output "instance_public_ip" {
      description = "Public IP address of the EC2 instance"
      value       = aws_instance.app_server.public_ip
    }
    
  • It will be printed as the output.

[!TIP] Get the aws profile being used by: aws sts get-caller-identity

Terraform State

  • Working locally, terraform.tfstate file lives locally on our computer. But in real prod situation, we can run into problems like:

    • Shared storage for state files
    • Locking state files (to prevent race condition)
    • Isolating state files
    • Secrets protection
  • Solution: Remote backends. Solves the above issues. Ex. we can use S3 as remote storage solution.

S3 as a remote storage

provider "aws" {
    region = "us-east-2"
}

# bucket creation
resource "aws_s3_bucket" "terraform_state" {
    bucket = "tf-up-and-running-state-jb9h2hd22d"

    lifecycle {
        prevent_destroy = true # prevent accidental deletion
    }
}

# versioning
resource "aws_s3_bucket_versioning" "enabled" {
    bucket = aws_s3_bucket.terraform_state.id
    versioning_configuration {
        status = "Enabled"
    }
}

# server side encryption
resource "aws_s3_bucket_server_side_encryption" "default" {
    bucket = aws_s3_bucket.terraform_state.id
    rule {
        apply_server_side_encryption_by_default {
            sse_algorithm = "AES256"
        }
    }
}

# block public access
resource "aws_s3_bucket_public_access_block" "public_access" {
    bucket = aws_s3_bucket.terraform_state.id

    block_public_acls = true
    block_public_policy = true
    ignore_public_acls = true
    restrict_public_buckets = true
}

# dynamoDB for locking (strongly consistent)
resource "aws_dynamodb_table" "terraform_locks" {
    name = "terraform-up-and-running-locks"
    billing_mode = "PAY_PER_REQUEST"
    hash_key = "LockID" # PRIMARY_KEY; exact spelling here
    attribute {
        name = "LockID"
        type = "S"
    }
}

After init and apply, add the following backend:

terraform {
    backend "s3" {
        bucket = <name>
        key = "global/s3/terraform.tfstate" # location to store
        region = <region>

        dynamodb_table = <name>
        encrypt = true
    }
}

# output
output "s3_bucket_arn" {
  value       = aws_s3_bucket.terraform_state.arn
  description = "The ARN of the S3 bucket"
}

output "dynamodb_table_name" {
  value       = aws_dynamodb_table.terraform_locks.name
  description = "The name of the DynamoDB table"
}

Do init again.

Limitations with Terraform's backend

Creation - Need a two-step process: 1) Write tf code to create S3 and DynamoDB with a local back-end 2) Then go back to tf code, add remote backend config to use newly created S3 and DynamoDB and run init again to copy our local state to S3

Deletion - Go Reverse: 1) remove the backend config, rerun init to copy the tf state back to our local disk 2) run destroy to delete S3 and DynamoDB

No vars/ref The following doesn't work:

terraform {
  backend "s3" {
    bucket         = var.bucket
    region         = var.region
    dynamodb_table = var.dynamodb_table
    key            = "example/terraform.tfstate"
    encrypt        = true
  }
}
  • We need to manually enter or the following 2 ways:

    • Partial configs: omit certain params from the backend config in code and pass via backend-config arg during init.

      • Create a seperate backend.hcl:
        bucket         = "terraform-up-and-running-state"
        region         = "us-east-2"
        dynamodb_table = "terraform-up-and-running-locks"
        encrypt        = true
        
      • And then: terraform init -backend-config=backend.hcl
    • Terragrunt: an open source tool

Isolation

Can be achieved via: - Workspaces - File layout

Workspaces

  • default is the default
  • All the workspaces are stored in the same backend
  • Not visible in the code or terminal, do the following.
  • terraform workspace show: name of default workspace
  • terraform workspace new example1: new workspace
  • terraform workspace list: list all workspaces
  • terraform workspace select example1: switch workspaces

File Layout (much better way)

image

READ MORE ABOUT FILE LAYOUT AFTERWARDS

Terraform Modules

  • Providers should only be configured in root modules and not in reusable modules.
  • Syntax:
    module "<NAME>" {
        source = "<SOURCE>"
    
        [CONFIG ...]
    }
    

i.e. mention module by:

provider "aws" {
    region = "us-east-1"
}

module "webserver_cluster" {
    source = "../../../modules/services/webserver-cluster"
}

[!NOTE] Make sure that the specified values are not hardcoded into the modules. Otherwise, if we use modules multiple times, the same values will be initialized. To avoid that, configure the changing values to be variable so that they can be input at the runtime.

  • After making the values as variables, provide them as:
    module "webserver_client" {
        source = "../whatever-the-source-is"
    
        cluster_name = "webserver-stage" # these will be passed to that module
        db_remote_state_bucket = "bucket-name"
    }
    

Locals

  • We can define variable in a module but then it can be modified by the user during runtime.
  • We can use locals to make the code more readable but also not allow the end user to change the value at runtime. They are not asked during runtime.
    locals {
        http_port = 80
        any_port = 0
        all_ips = ["0.0.0.0/0"]
    }
    
  • They allow us to assign name to any tf expression within the module. Use as: local.<NAME>

Module output

  • Module can return values using output.
  • Can be accessed using: module.<MODULE_NAME>.<OUTPUT_NAME>

File paths & Inline Blocks

File Paths - By default, the relative path is the dir where terraform apply is being done. But it won't work in a module that's defined in a seperate folder. - We use path reference for that. i.e. - path.module: returns the fs path of the module where the exp is defined - path.root: fs path of the root module - path.cwd: current working dir

Module Versioning

  • If both our staging and production environment are pointing to the same module folder, as soon as we make a change in that folder, it will affect both environments on the very next deployment.
  • This sort of coupling makes it more difficult to test a change in staging without any chance of affecting production.
  • A better approach is to create versioned modules so that we can use one version in staging (e.g., v0.0.2) and a different version in production (e.g., v0.0.1)
  • Terraform supports various types of module sources:
    • local_paths
    • Git URLS
    • Mercurial URLS
    • HTTP URLS
  • Solution: Create a versioned module, put the code for the module in a seperate Git repo and version/tag it and set the source parameter to that repo's url.
  • So, our code will spread out across (atleast) 2 repos:
    • modules
    • live (defines live infra that we are running)
  • Then, the module becomes:
    module "webserver_cluster" {
      source = "github.com/foo/modules/services/webserver-cluster?ref=v0.0.1"
    
      cluster_name           = "webservers-stage"
      db_remote_state_bucket = "(YOUR_BUCKET_NAME)"
      db_remote_state_key    = "stage/data-stores/mysql/terraform.tfstate"
    
      instance_type = "t2.micro"
      min_size      = 2
      max_size      = 2
    }
    

[!NOTE] Semantic Versioning: MAJOR.MINOR.PATCH (eg 1.0.4). - Changes happen when: - MAJOR: incompatible API changes - MINOR: add funcs in backward-compat manner - PATCH: backward-compat bug fixes

Conditionals

Loops

  • Terraform offers:
    • count: to loop over resources and modules
    • for_each: to loop over resources, inline blocks within a source, and modules
    • for: expression to loop over lists and maps
    • for: string directive to loop overe lists and maps within a string