Tuesday 24 May 2022

Terraform Provisioners

 


Terraform provisioners allow running commands or scripts on provisioned resources or local host. To run a bootstrap script upon resource is provisioned we can use remote-exec provisioner:

 resource "aws_instance" "my-web-server" {
    ...
    provisioner "remote-exec" {
        inline = [
                     "sudo apt update"
                     "sudo apt -y install nginx"
                     "systemctl enable nginx"
                     "systemctl start nginx"
        ]
    }

    vpc_security_group_ids = [ aws_security_group.ssh-access.id ]
    key_name = aws_key_pair.my-webserver.id
    ...
}

resource "aws_security_group" "ssh-access" {
    name = "ssh-access"
    description = "Allows SSH connection from anywhere"
    ingress = {
        from_port = 22
        to_port = 22
        protocol = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
    }
}

resource "aws_key_pair" "my-webserver" {
    public_key = ...
}

For this to work there must be:
  • Network connectivity between local machine and that remote EC2 instance: SSH for Linux and WinRM for Windows. This can be achieved by using proper security groups while creating remote resources
  • Authentication (SSH private key)
Connection to the resource can be defined in connection block:

 resource "aws_instance" "my-web-server" {
    ...
    connection = {
        type = "ssh"
        host = self.public_ip
        user = "ubuntu"
        private_key = file(pathexpand("~/.ssh/my-webserver.pub"))
    }
    ...
}

self.public_ip will contain the public IP address of the provisioned instance.

terraform apply will establish this SSH connection and then execute commands in remote-exec provisioner.

To run tasks on the local machine where Terraform runs we need to use local-exec provisioner. This can be useful if e.g. we want to gather some data from the provisioned resource and write it into a local file. local-exec does not require connection block.

 resource "aws_instance" "my-web-server" {
    ...
    provisioner "local-exec" {
        command = "echo ${aws_instance.my-web-server.public_ip} >> /tmp/ip.txt"
    }
    ...
}

After terraform apply we'll have the file created and populated.

Example #2: Upon provisioning elastic IP resource we want its public_dns to be saved in a local file:

resource "aws_eip" "my-web-server-eip" {
    vpc = true
    instance = aws_instance.my-web-server.id
    provisioner "local-exec" {
        command = "echo ${aws_eip.my-web-server-eip.public_dns} >> /root/my-web-server-eip_public_dns.txt"
    }
}

Example #3: Instead of manually executing sudo chown 400 command on a private key created by TF script and also adding it to the local keychain, we can use local-exec to automate this:

resource "tls_private_key" "rsa-4096-private-key" {
    algorithm = "RSA"
    rsa_bits  = 4096
}

...

resource "local_file" "ec2-key" {
    content  = tls_private_key.rsa-4096-private-key.private_key_pem
    filename = "${path.module}/temp/ec2-key"
    file_permission = "400"
    provisioner "local-exec" {
        command = "ssh-add ${self.filename}"
    }
}

By default, provisioners are run after resources are created. These are so called creation-time provisioners.

destroy-time provisioners run before resources are destroyed and they are made as such by using setting when attribute to a value destroy:

 resource "aws_instance" "my-web-server" {
    ...
    provisioner "local-exec" {
        command = "echo Instance ${instance.my-web-server.public_ip} created! > /tmp/state.txt"
    }

    provisioner "local-exec" {
        command = "echo Instance ${instance.my-web-server.public_ip} removed! > /tmp/state.txt"
        when = destroy
    }
    ...
}

By default, if any of the provisioners' tasks fails, the complete terraform apply also fails. This can explicitly be set by using on_failure attribute and setting it to fail. If we want to make the success of the provisioner's command not to determine the success of the provisioning the whole infrastructure, we can set on_failure to continue:

 resource "aws_instance" "my-web-server" {
    ...
    provisioner "local-exec" {
        command = ...
        on_failure = fail
    }

    provisioner "local-exec" {
        command = ...
        on_failure = continue

    }
    ...
}

Provisioners should be used as the last resort, sparingly.
  • Provisioners add to configuration complexity.
  • Terraform plan does not keep information on provisioners.
  • connection block needs to be defined for some provisioners to work. This network connectivity between local host and remote resource and authentication might not be always be desirable.

Provisioners that are native to resource should be used. 

We should try first to use options natively available for the resource type for the provider used. E.g. user_data is a native feature for EC2 instances and when using it we don't need to define connection block.

Here is the list of resources and their native options (attributes) for some infrastructure providers, in form Provider - Resource - Option: 
  • AWS - aws_instance - user_data
  • Azure - azurerm_virtual_machine - custom data
  • GCP - google_compute_instance - meta_data
  • Vmware vSphere - vsphere_virtual_machine - user_data.txt
It is recommended to keep the post-provisioning task to the minimum. Instead of using AMI with only OS installed, we should build in advance custom AMIs that contain software and configuration for a resources and then use these AMIs.

Example: we can create a custom AMI which already has Nginx installed. 

Tools like Packer can help in creating a custom AMI in a declarative way. We specify what we want to have installed in a json file (e.g. nginx.json) and Packer creates a custom AMI with Nginx. This way we don't need to use provisioners at all.


user_data 


We can list commands directly in configuration file:

main.tf:
 
resource "aws_instance" "my-instance" {
    ...
    user_data = << EOF
        #! /bin/bash
        sudo yum update
        sudo yum install -y htop
    EOF
    ...
}

Better approach is to keep all commands in the bash script which then gets loaded into TF script:

bootstrap.sh:
 
#! /bin/bash
sudo yum update
sudo yum install -y htop

main.tf:

resource "aws_instance" "my-instance" {
    ...
    user_data = "${file(bootstrap.sh)}"
    ...
}




---

Resources:


No comments: