Cómo implementar Traefik 2.1.2 con Docker usando Terraform en DigitalOcean

¡Pa! que título, cuatro tecnologías en un solo tutorial. Les cuento estoy jugando mucho con Terraform en estos últimos días y con Traefik, así que arme una receta para desplegar este Reverse Proxy en DigitalOcean.

Terraform, en mi opinión, es una herramienta maravillosa que te permite automatizar el deploy de ambientes y aplicaciones y te ayuda a que tengan consistencia entre lo que queremos y lo que efectivamente está corriendo.

Por estos días, estoy jugando mucho con esta herramienta, así como también con Traefik, que me parece una excelente solución de Reverse Proxy / Load Balancer. Así que me dije: ¿Por qué no armar una receta que me permita "automatizar" el despliegue de un host en DigitalOcean y que ya tenga Docker, Docker-Compose y Traefik con SSL con Let's Encrypt de manera totalmente funcional? ¿Y por qué no compartirla en el blog? ¿Te interesa? seguí leyendo...

Resumen de lo que vamos a hacer:

En este tutorial, que lo voy a tratar de dividir por secciones con la idea de que veamos lo siguiente:

  • Configuración de Terraform para "hablar" con DigitalOcean y desplegar una VM con Ubuntu en el DC de New York. (Esto incluye poner una llave SSH a esa VM, crear un dominio y generar registros que apunten a esa VM)
  • Armado del userdata.yml que instale paquetes y ya cree la configuración necesaria para Traefik.
  • Armado del Docker Compose para que finalmente se ejecute un contenedor con Traefik y que el mismo sea capaz de generar certificados válidos de Let's Encrypt para dos contenedores que se van a acceder a través de Internet, además de que esté configurada la redirección hacia HTTPS de manera que esta sea la única manera de acceder.

Manos a la obra.

Archivos necesarios

Los archivos necesarios, los pueden encontrar este proyecto de Gitlab:

Ignacio Van Droogenbroeck / Traefik and Docker with Terraform in DigitalOcean - v2
GitLab.com

Armemos la receta de Terraform

Vamos a arrancar a configurar Terraform, para este caso, tendremos cinco archivos dentro de una carpeta, quedando, más o menos de esta manera.

Terraform
|
|_ auth.tf
|_ dns.tf
|_ droplet.tf
|_ ssh.tf
|_ userdata.yaml

Primero debemos configurar el archivo auth.tf, el mismo tendrá el Token de DigitalOcean, el que nos va a autenticar y permitirnos crear todos los recursos con Terraform. Este token se genera desde este enlace. Una vez generado, abrimos el auth.th, el mismo se debe ver así y entre las comillas, ponemos nuestro token que acabamos de generar.

# Configure the DigitalOcean Provider
provider "digitalocean" {
  token = "acá-va-tu-token"
}

guardamos, cerramos y seguimos con el siguiente archivo: dns.tf. En este archivo vamos a definir nuestro dominio y los registros tipo A para dos subdominios. En este caso el ejemplo los subdominios son master.tudominio.com y lb.tudominio.com

# Create a new domain
resource "digitalocean_domain" "domain" {
  name = "yourdomain.com"
}

# Add a record for the domain for one container.
resource "digitalocean_record" "www" {
  domain = "${digitalocean_domain.yourdomain.name}"
  type   = "A"
  name   = "master"
  ttl    = "35"
  value  = "${digitalocean_droplet.web.ipv4_address}"
}

# Add a record for another container.
resource "digitalocean_record" "lb" {
  domain = "${digitalocean_domain.yourdomain.name}"
  type   = "A"
  name   = "lb"
  ttl    = "36"
  value  = "${digitalocean_droplet.web.ipv4_address}"
}

El otro archivo para retocar es el droplet.tf. Fijense, que acá especificamos el tamaño de la máquina, la región donde se va a alojar y que nombre que va a tener. También ya definimos el valor user_data y la invocación al archivo, llamado para este ejemplo: userdata.yaml.

También, fijense que ya especificamos la llave ssh que vamos a configurar para la máquina (¿Aún usas usuario y contraseña para conectarte a tu servidor?)

# Creamos el droplet

resource "digitalocean_droplet" "web" {
  image     = "ubuntu-18-04-x64"
  name      = "web-1"
  region    = "nyc1"
  size      = "s-1vcpu-1gb"
  user_data = "${file("userdata.yaml")}"
  ssh_keys  = ["${digitalocean_ssh_key.yours.fingerprint}"]
}

Lo siguiente que vamos a configurar es, justamente, la llave ssh en el archivo ssh.tf. Tengan en cuenta que el archivo id_rsa.pub debe estar dentro de la carpeta de terraform.

#Llevamos nuestra llave a DO

# Create a new SSH key
resource "digitalocean_ssh_key" "nacho" {
  name       = "yourname"
  public_key = "${file("id_rsa.pub")}"
}

Por último, vamos a armar el userdata.yaml. Acá fíjense lo siguiente, es un yaml común y corriente y es, de alguna manera, la configuración que le vamos a pasar a la máquina virtual una vez que la misma este lista.

#cloud-config
package_update: true
packages:
  - docker.io
  - docker-compose
runcmd:
  - touch /acme.json
  - chmod 600 /acme.json
  - cd /
  - wget url:/docker-compose.yml
  - docker-compose up -d

Básicamente, lo que va a hacer es actualizar los paquetes de la VM, instalar Docker y Docker Compose y luego va a correr ciertos comandos.

El comando touch /acme.json es requerimiento para que luego Traefik sea capaz de almacenar los certificados de Let's Encrypt para nuestros contenedores. El chmod 600 /acme.json le da los permisos que necesita para que lo pueda hacer.

Luego se mueve a / y hace un wget a la url donde tengamos guardado nuestro docker-compose.yml.

Por último, ejecutará docker-compose up -d para que levante los contenedores que tengamos definidos en ese docker-compose.yml, que para este tutorial es un contenedor de Traefik v2.1.2 y dos contenedores más, uno con un NGINX y otro con un Hello World.

Ahora veamos lo que sigue, qué es, analizar ese docker-compose.yml

Cocinemos un docker-compose.yml

Este archivo, también lo pueden encontrar en el proyecto de Gitlab para este artículo.

Ignacio Van Droogenbroeck / Traefik and Docker with Terraform in DigitalOcean - v2
GitLab.com

Este archivo docker-compose, es el que usara el userdata.yaml que mencioné más arriba para levantar los contenedores que necesitemos. La config es más o menos así:

version: "3.3"

services:
  traefik:
    image: "traefik:v2.1.2"
    command:
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --providers.docker
      - --api
      - --certificatesresolvers.leresolver.acme.httpchallenge=true
      - --certificatesresolvers.leresolver.acme.email=nombre@domain.com
      - --certificatesresolvers.leresolver.acme.storage=/acme.json
      - --certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./acme.json:/acme.json"
    labels:
      # global redirect to https
      - "traefik.http.routers.http-catchall.rule=hostregexp(`{host:.+}`)"
      - "traefik.http.routers.http-catchall.entrypoints=web"
      - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"

      # middleware redirect
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"

  my-app:
    image: tutum/hello-world
    labels:
      - "traefik.http.routers.my-app.rule=Host(`lb.tudominio.com`)"
      - "traefik.http.routers.my-app.entrypoints=websecure"
      - "traefik.http.routers.my-app.tls=true"
      - "traefik.http.routers.my-app.tls.certresolver=leresolver"

  nginx:
    image: nginx:latest
    labels:
      - "traefik.http.routers.my-app2.rule=Host(`master.tudominio.com`)"
      - "traefik.http.routers.my-app2.entrypoints=websecure"
      - "traefik.http.routers.my-app2.tls=true"
      - "traefik.http.routers.my-app2.tls.certresolver=leresolver"

En este archivo, vemos que va a ver un contenedor que va a correr Traefik v2.1.2 y con determinada configuración, asegurate de cambiar por tu email en este label:

- --certificatesresolvers.leresolver.acme.email=nombre@domain.com

Y asegurar que estás bajando las imágenes que necesitas, con la configuraciones que necesitas y sobre todo, actualizar con tus nombre de dominio estos labels que van a con cada contenedor.

- "traefik.http.routers.my-app.rule=Host(`lb.tudominio.com`)"

Algo importante con respecto al archivo, que una vez que lo terminas de armar, debemos subirlo a algún lado en donde, luego el userdata.yaml lo pueda bajar. Para el caso de una máquina de DigitalOcean, yo uso, justamente un Bucket de Spaces de DigitalOcean pero podrías hostearlo en cualquier otro lugar siempre y cuando el mismo sea accesible. Esto es mucho muy importante.

Si esta todo ok, veamos como nos va...

Vamos a cocinar estas recetas...

Una vez que estamos seguros que esta todo bien, vamos a ejecutar lo siguiente:

terraform init

Si ya trabajaste antes con DigitalOcean, te va a aparecer este mensaje pero sino, va a descargar el conector para poder hacerlo:

Initializing the backend...

Initializing provider plugins...

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.digitalocean: version = "~> 1.7"

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.

Luego, ejecutamos

terraform plan

Y nos devolverá algo como esto:

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # digitalocean_domain.populus will be created
  + resource "digitalocean_domain" "populus" {
      + id   = (known after apply)
      + name = "tudominio.com"
      + urn  = (known after apply)
    }

  # digitalocean_droplet.web will be created
  + resource "digitalocean_droplet" "web" {
      + backups              = false
      + created_at           = (known after apply)
      + disk                 = (known after apply)
      + id                   = (known after apply)
      + image                = "ubuntu-18-04-x64"
      + ipv4_address         = (known after apply)
      + ipv4_address_private = (known after apply)
      + ipv6                 = false
      + ipv6_address         = (known after apply)
      + ipv6_address_private = (known after apply)
      + locked               = (known after apply)
      + memory               = (known after apply)
      + monitoring           = false
      + name                 = "web-1"
      + price_hourly         = (known after apply)
      + price_monthly        = (known after apply)
      + private_networking   = false
      + region               = "nyc1"
      + resize_disk          = true
      + size                 = "s-1vcpu-1gb"
      + ssh_keys             = (known after apply)
      + status               = (known after apply)
      + urn                  = (known after apply)
      + user_data            = "ffb33dcc93aea3c64b53f83f5a62d4d3f6ec5395"
      + vcpus                = (known after apply)
      + volume_ids           = (known after apply)
    }

  # digitalocean_record.lb will be created
  + resource "digitalocean_record" "lb" {
      + domain = "tudominio.com"
      + fqdn   = (known after apply)
      + id     = (known after apply)
      + name   = "lb"
      + ttl    = 36
      + type   = "A"
      + value  = (known after apply)
    }

  # digitalocean_record.www will be created
  + resource "digitalocean_record" "www" {
      + domain = "tudominio.com"
      + fqdn   = (known after apply)
      + id     = (known after apply)
      + name   = "master"
      + ttl    = 35
      + type   = "A"
      + value  = (known after apply)
    }

  # digitalocean_ssh_key.nacho will be created
  + resource "digitalocean_ssh_key" "nacho" {
      + fingerprint = (known after apply)
      + id          = (known after apply)
      + name        = "nacho"
      + public_key  = "tu-llave ssh"
    }

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

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

Esto básicamente es el resumen de lo que Terraform va a hacer. Lo siguiente para ejecutar es...

terraform apply

Y acá veremos algo similar a plan, pero nos pedirá la confirmación para comenzar a ejecutar, se debería ver algo así:

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # digitalocean_domain.populus will be created
  + resource "digitalocean_domain" "populus" {
      + id   = (known after apply)
      + name = "tudominio.com"
      + urn  = (known after apply)
    }

  # digitalocean_droplet.web will be created
  + resource "digitalocean_droplet" "web" {
      + backups              = false
      + created_at           = (known after apply)
      + disk                 = (known after apply)
      + id                   = (known after apply)
      + image                = "ubuntu-18-04-x64"
      + ipv4_address         = (known after apply)
      + ipv4_address_private = (known after apply)
      + ipv6                 = false
      + ipv6_address         = (known after apply)
      + ipv6_address_private = (known after apply)
      + locked               = (known after apply)
      + memory               = (known after apply)
      + monitoring           = false
      + name                 = "web-1"
      + price_hourly         = (known after apply)
      + price_monthly        = (known after apply)
      + private_networking   = false
      + region               = "nyc1"
      + resize_disk          = true
      + size                 = "s-1vcpu-1gb"
      + ssh_keys             = (known after apply)
      + status               = (known after apply)
      + urn                  = (known after apply)
      + user_data            = "ffb33dcc93aea3c64b53f83f5a62d4d3f6ec5395"
      + vcpus                = (known after apply)
      + volume_ids           = (known after apply)
    }

  # digitalocean_record.lb will be created
  + resource "digitalocean_record" "lb" {
      + domain = "tudominio.com"
      + fqdn   = (known after apply)
      + id     = (known after apply)
      + name   = "lb"
      + ttl    = 36
      + type   = "A"
      + value  = (known after apply)
    }

  # digitalocean_record.www will be created
  + resource "digitalocean_record" "www" {
      + domain = "tudominio.com"
      + fqdn   = (known after apply)
      + id     = (known after apply)
      + name   = "master"
      + ttl    = 35
      + type   = "A"
      + value  = (known after apply)
    }

  # digitalocean_ssh_key.nacho will be created
  + resource "digitalocean_ssh_key" "nacho" {
      + fingerprint = (known after apply)
      + id          = (known after apply)
      + name        = "nacho"
      + public_key  = "tullavessh"
    }

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

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

  Enter a value: yes

Fijense que cuando agrega un "+" al lado de cada recurso, esto quiere decir que lo va a crear, también, en otro escenario, podría ser un cambio o una eliminación de recursos, para este último caso, en vez de un "+", el simbolo será "-".

Si todo sale bien, desde que confirmamos escribiendo yes, comenzará a desplegar y se verá más o menos así:

digitalocean_domain.populus: Creating...
digitalocean_ssh_key.nacho: Creating...
digitalocean_domain.populus: Creation complete after 2s [id=populus.live]
digitalocean_ssh_key.nacho: Creation complete after 3s [id=26284572]
digitalocean_droplet.web: Creating...
digitalocean_droplet.web: Still creating... [10s elapsed]
digitalocean_droplet.web: Still creating... [20s elapsed]
digitalocean_droplet.web: Creation complete after 25s [id=175895947]
digitalocean_record.www: Creating...
digitalocean_record.lb: Creating...
digitalocean_record.lb: Creation complete after 1s [id=87753500]
digitalocean_record.www: Creation complete after 2s [id=87753501]

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

Cuando completó, podríamos inspeccionar el archivo terraform.tfstate y ver que dirección IP nos asignó a la VM. Para este ejemplo, me dió la siguiente IP

"ipv4_address": "64.227.12.34",

Probamos entrar por ssh

ssh root@64.227.12.34

y correr para ver si efectivamente nuestros contenedores están corriendo.

docker ps

En mi caso, los tres contenedores especificados en el docker-compose.yml están corriendo.

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                                              NAMES
834d8f0e892c        tutum/hello-world   "/bin/sh -c 'php-fpm…"   About an hour ago   Up About an hour    80/tcp                                                             default_my-app_1
804797d0a373        nginx:latest        "nginx -g 'daemon of…"   About an hour ago   Up About an hour    80/tcp                                                             default_nginx_1
222e1fd48a9a        traefik:v2.1.2      "/entrypoint.sh --en…"   About an hour ago   Up About an hour    0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:8080->8080/tcp   default_traefik_1

Entonces, si están corriendo, veamos si, efectivamente funciona todo bien y tenemos un certificado válido.

Si siguen este caso al pie de la letra y configuraron dos subdominios que apuntan a dos contenedores diferentes, deberían ver las pantallas de bienvenida de NGINX y el Hello World de Tutum.

¡Funciona!
Hello World

Y el certificado de Let's Encrypt válido.

Así que como pueden ver, está receta de Terraform para tener andando Traefik en DigitalOcean quedó bien cocinada.

Por último, si estuviste probando, anduvo y no lo necesitas más, solo basta con ejecutar lo siguiente:

terraform destroy

Nos va a dar un resumen de lo que va a destruir:

digitalocean_domain.populus: Refreshing state... [id=populus.live]
digitalocean_ssh_key.nacho: Refreshing state... [id=26284572]
digitalocean_droplet.web: Refreshing state... [id=175895947]
digitalocean_record.lb: Refreshing state... [id=87753500]
digitalocean_record.www: Refreshing state... [id=87753501]

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # digitalocean_domain.populus will be destroyed
  - resource "digitalocean_domain" "populus" {
      - id   = "populus.live" -> null
      - name = "populus.live" -> null
      - urn  = "do:domain:populus.live" -> null
    }

  # digitalocean_droplet.web will be destroyed
  - resource "digitalocean_droplet" "web" {
      - backups            = false -> null
      - created_at         = "2020-01-16T15:51:23Z" -> null
      - disk               = 25 -> null
      - id                 = "175895947" -> null
      - image              = "ubuntu-18-04-x64" -> null
      - ipv4_address       = "64.227.12.34" -> null
      - ipv6               = false -> null
      - locked             = false -> null
      - memory             = 1024 -> null
      - monitoring         = false -> null
      - name               = "web-1" -> null
      - price_hourly       = 0.00744 -> null
      - price_monthly      = 5 -> null
      - private_networking = false -> null
      - region             = "nyc1" -> null
      - resize_disk        = true -> null
      - size               = "s-1vcpu-1gb" -> null
      - ssh_keys           = [
          - "f4:45:e0:c0:dd:18:a1:70:6b:24:88:56:fe:42:b5:f7",
        ] -> null
      - status             = "active" -> null
      - tags               = [] -> null
      - urn                = "do:droplet:175895947" -> null
      - user_data          = "ffb33dcc93aea3c64b53f83f5a62d4d3f6ec5395" -> null
      - vcpus              = 1 -> null
      - volume_ids         = [] -> null
    }

  # digitalocean_record.lb will be destroyed
  - resource "digitalocean_record" "lb" {
      - domain   = "populus.live" -> null
      - flags    = 0 -> null
      - fqdn     = "lb.populus.live" -> null
      - id       = "87753500" -> null
      - name     = "lb" -> null
      - port     = 0 -> null
      - priority = 0 -> null
      - ttl      = 36 -> null
      - type     = "A" -> null
      - value    = "64.227.12.34" -> null
      - weight   = 0 -> null
    }

  # digitalocean_record.www will be destroyed
  - resource "digitalocean_record" "www" {
      - domain   = "populus.live" -> null
      - flags    = 0 -> null
      - fqdn     = "master.populus.live" -> null
      - id       = "87753501" -> null
      - name     = "master" -> null
      - port     = 0 -> null
      - priority = 0 -> null
      - ttl      = 35 -> null
      - type     = "A" -> null
      - value    = "64.227.12.34" -> null
      - weight   = 0 -> null
    }

  # digitalocean_ssh_key.nacho will be destroyed
  - resource "digitalocean_ssh_key" "nacho" {
      - fingerprint = "f4:45:e0:c0:dd:18:a1:70:6b:24:88:56:fe:42:b5:f7" -> null
      - id          = "26284572" -> null
      - name        = "nacho" -> null
      - public_key  = "llavessh" -> null
    }

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

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

Fijense que, como comenté más arriba, cuando se especifica una eliminación la va a marcar con un "-".

Para finalizar, veamos la confirmación que nos arroja y listo. Borramos el ambiente.

digitalocean_record.www: Destroying... [id=87753501]
digitalocean_record.lb: Destroying... [id=87753500]
digitalocean_record.lb: Destruction complete after 2s
digitalocean_record.www: Destruction complete after 2s
digitalocean_domain.populus: Destroying... [id=populus.live]
digitalocean_droplet.web: Destroying... [id=175895947]
digitalocean_domain.populus: Destruction complete after 2s
digitalocean_droplet.web: Still destroying... [id=175895947, 10s elapsed]
digitalocean_droplet.web: Still destroying... [id=175895947, 20s elapsed]
digitalocean_droplet.web: Destruction complete after 24s
digitalocean_ssh_key.nacho: Destroying... [id=26284572]
digitalocean_ssh_key.nacho: Destruction complete after 1s

Espero que este tutorial, les haya resultado de utilidad y los anime a meterse con estas lindas tecnologías que hacen la vida más fácil a cualquier SysAdmin.

¿Lo probaste? ¿Funcionó? ¿No? Deja un comentario y lo vamos viendo.