Turorial - Using Docker with Terraform

imt

1. Objective

We will be deploying a small containerized application on our local Docker with Terraform.

2. Development setup

2.1. Verify your Terraform and Docker installation

docker --version
terraform version

It should be at least 1.6.0

2.2. Simplify your life

  • Install autocompletion for Terraform CLI.

    terraform -install-autocomplete
  • Add an alias into your local .bashrc (mandatory).

    alias tf="terraform"

3. First Terraform resources

We will create our first Terraform resources and inspect the state file.

3.1. Requiring the Docker provider

The Docker provider reference is available at https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs

In order to use it, we must require it in our configuration. Create a new file main.tf and copy the following content. Remember, the name of the file doesn’t matter.

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0.2"
    }
  }
}

The terraform block has an embedded block required_providers that contains a list of arguments. Each argument key is our identifier for a provider, the value contains source and version arguments. The source is the identifier on the registry registry.terraform.io/, and the version is a constraint on the version we require.

Then execute terraform init on the console, the output should resemble the following:

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0.2"...
- Installing kreuzwerker/docker v3.0.2...
- Installed kreuzwerker/docker v3.0.2 (self-signed, key ID BD080C4571C6104C)

Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

It has downloaded the required docker provider and created a lock file containing the version and hashes of our provider.

To see this more concretly, issue tree -a to list all files.

$ tree -a
.
├── main.tf
├── .terraform
│   └── providers
│       └── registry.terraform.io
│           └── kreuzwerker
│               └── docker
│                   └── 3.0.2
│                       └── linux_amd64
│                           ├── CHANGELOG.md
│                           ├── LICENSE
│                           ├── README.md
│                           └── terraform-provider-docker_v3.0.2
└── .terraform.lock.hcl

8 directories, 6 files

3.2. Create a redis docker image via Terraform

Bellow the terraform block, copy the following in order to create a new managed resource with type docker_image and name redis:

resource "docker_image" "redis" {
  name = "docker.io/redis:6.0.5"
}

The documentation of the docker_image resources is available at https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs/resources/image.

In the Schema section, we see that only the name argument is Required but many other Optional arguments can be configured. Read-Only attributes are not configurable in .tf files, their value is determined by the provider itself.

Issue now terraform plan.

$ terraform plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # docker_image.redis will be created
  + resource "docker_image" "redis" {
      + id          = (known after apply)
      + image_id    = (known after apply)
      + name        = "docker.io/redis:6.0.5"
      + repo_digest = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

The leading + sign means this resource will be created. The # is a comment.

We see here that provider-defined attributes will finally be part of the resource state, but their value is unknown at plan time. This is not always the case, provider-attributes can be absent form the state, or they can have a known value at plan time.

Now execute terraform apply. A deployment plan will appear as before, with an additional paragraph that prompts confirmation.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

Enter a plain yes to effectively execute the current plan.

docker_image.redis: Creating...
docker_image.redis: Still creating... [10s elapsed]
docker_image.redis: Creation complete after 14s [id=sha256:2355926154447ec75b25666ff5df14d1ab54f8bb4abf731be2fcb818c7a7f145docker.io/redis:6.0.5]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Check the state of Terraform resources.

$ terraform show
# docker_image.redis:
resource "docker_image" "redis" {
    id          = "sha256:23559...docker.io/redis:6.0.5"
    image_id    = "sha256:23559..."
    name        = "docker.io/redis:6.0.5"
    repo_digest = "redis@sha256:800f25..."
}

You can now guess why some attributes were computed by the provider itself.

Check that our image was actually created in our local Docker.

$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
redis        6.0.5     235592615444   3 years ago   104MB

3.3. Create a docker container running our image

Add the following resource block to main.tf.

resource "docker_container" "db" {
  name  = "redis_db"
  image = docker_image.redis.image_id
}

Here, both name and image are required. The value of image refers to our previously declared resource with its type docker_image and name redis, and query its image_id attribute.

Run terraform plan and terraform apply, then check with

$ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS      NAMES
5b3910075b61   235592615444   "docker-entrypoint.s…"   5 seconds ago   Up 4 seconds   6379/tcp   redis_db

3.4. Inspect the Terraform state

You can look directly in the JSON state file terraform.tfstate or use terraform show, but a more idiomatic way is to use the state command.

Running terraform state alone will output the help and show the available subcommands. Let’s list all resources that our state knows about.

$ terraform state list
docker_container.db
docker_image.redis

Another useful command is terraform state show to output the whole state of a given resource.

terraform state show docker_container.db

Do not hesitate to abuse those informational commands!

4. Deploy a 2-container application

We will be deploying an simple application that records "guests" (string from a text area) into a database, and show the list of all guests.

4.1. How it works

In addition to the previous redis database container, we have a frontend web server gb-frontend that needs to know about the database.

It does so by first looking into the environment variable GET_HOSTS_FROM which gives the access method, its value is either dns or env.

We will use the DNS method : the frontend will access the database through redis-leader and redis-follower hard-coded domain names. There is two names because redis can be distributed among multiple nodes organised with a leader and followers. Here we will only have one container that is both leader and follower.

Thus, we must make sure redis-leader and redis-follower domain names points to our database container. To do so, we will configure our frontend by adding a host in /etc/hosts. Fortunately, the docker_container resource have a host {} block that does exactly that. We provide a host name and attach an IP. Remember that nested blocks creates an array, so we can define multiple host blocks.

Note
Execute terraform state show docker_container.db and seek for an attribute that resemble the IP of the container. We will then refer to this IP by the path of the attribute.

Finally, we must expose the container port 80 to our local 8080 in order to access it in the browser with localhost:8080.

4.2. In practice

Download the archive gb-frontend.zip of the frontend source code and extract it in your current directory.

Add the following to your main.tf.

resource "docker_image" "guestbook-frontend" {
  name = "gb-frontend"
  build {
    context = "./gb-frontend/"
  }
}

resource "docker_container" "frontend" {
  name  = "guestbook_frontend"
  image = docker_image.guestbook-frontend.image_id
  ports {
    internal = "80"
    external = "8080"
  }
  env = [
    "GET_HOSTS_FROM=dns"
  ]
  host {
    host = "redis-leader"
    ip = docker_container.db.network_data[0].ip_address
  }
  host {
    host = "redis-follower"
    ip = docker_container.db.network_data[0].ip_address
  }
}
Important
Make sure you understand the code. Ask questions if this is not the case.

Plan and apply if everything seems good.

Access the application and test it : write something and Submit. It should print your string below.

5. Output variable

It would be nice if our root module (here it is just main.tf) would show us directly the endpoint of the application, once deployed.

For this purpose, add the following output block to your configuration.

output "app_endpoint" {
  value = "http://localhost:${docker_container.frontend.ports[0].external}"
  description = "The URL endpoint to access the Guestbook application"
}

Note the string interpolation with ${ } marker. An expression inside it will be interpreted by Terraform, here we retrieve the external port of our frontend container.

Plan and apply again.

The result of applying now have an additional section.

Outputs:

app_endpoint = "http://localhost:8080"

We can also show the output variables with

terraform output

We will see later how we can use outputs from other parts of our configuration.

6. Clean up

We are now done. Let’s delete our application with all its associated resource.

terraform destroy
Note
This command is somehow the reverse of apply. All managed resources in the configuration file (i.e. resource blocks in .tf files) are destroyed and removed from the state.

Verify with

docker state list
docker container ls -a
docker image ls -a.