# Le Déploiement en Zéro Downtime grâce à Terraform

Une des craintes lors du déploiement d'une nouvelle version d'une application est d'avoir un downtime (une indisponibilité temporaire) ou pire un bug dans le code de la nouvelle version. Pour éviter ces situations diverses solutions ont déjà vu le jour.

Aujourd'hui je voudrais vous parler du principe de "Blue-Green" et de comment l'outil Terraform peut nous permettre de le mettre en place, moyennant quelques ajustements.

# Blue-Green

D'abord un peut de définitions, de quoi parle-t-on lorsque que l'on évoque le terme "Blue-Green" ?

L'idée est d'appliquer une coloration à son infrastructure. Le "Bleu" représente la version courante de l'infrastructure et la "Verte" la version que l'on souhaite déployer.

infra blue

Voici une infrastructure classique, une base de données, deux frontaux et un load-balancer. Comme vous pouvez le voir les deux frontaux possèdent une petite pastille bleu qui indique sa version.

Ceux ci contiennent un serveur http, un php-fmp et le code.

Le déploiement "Blue-Green" s'applique en plusieurs phases.

Premièrement on ajoute les machines de l'infrastructure "verte". Ceux-ci contiennent le code à la nouvelle version que l'on souhaite déployer.

infra blue

Ensuite on réalise les connexions réseau vers la base de données. On pourrait considérer cette environnement comme du "staging", seuls les développeurs peuvent y accéder pour tester les fonctionnalités mais les utilisateurs non.

infra blue

Puis il faut indiquer au load-balancer l'existence de ces nouveaux serveurs. A partir de ce moment là le traffic commence à arriver sur ces 2 nouvelles machines. Nous avons donc une architecture hybride d'ancien et de nouveau code.

infra blue

Si l'on considère que le nouveau code est correct on peut débuter le démantèlement de l'infrastructure "bleu". On commence par la retirer du load-balancer et ses connexions aux bases de données.

infra blue

Finalement on détuit les machines de l'infrastructure "bleu"

infra blue

On se retrouve avec une infrastructure identique à l'ancienne mais avec un code mis à jour 😃

# Terraform

Il existe tout un tas de façon de réaliser ces opérations, la moins optimisée étant de tout réaliser à la main c'est faisable dans le contexte de un ou deux frontaux, mais au bout de trois ou quatre serveurs ça commence à être réellement fastidueux.

Heureusement il existe des outil pour automatiser tout cela. L'un deux se nomme Terraform.

Son rôle est de faciliter l'application du concept d' "Infra as code".

# Infra As Code

Derrière ces mots se cache toute une philosophie visant à décrire une infrastructure sous la forme de code. Et donc modulaire et réutilsable.

Il est donc ainsi possible de réaliser des recettes de création d'infrastructure, qui pourrait ressembler à " je veux un sous-réseau privé et deux machines de 1Go de RAM et 10 Go tournant sous Ubuntu 21.04 dans ce sous réseau"

# Provider

Un des concepts de base de Terraform est la notion de "provider". Un provider responsable des interactions avec les API des IaaS (AWS, OVH, GCP, OpenStack, Digital Ocean, Azure), des PaaS (Heroku, Clever Cloud) et des SaaS (Cloud Flare, Terraform Cloud).

Ces APIs permettant de gérer les ressources.

# Les ressources

Au sens de Terraform celles ci peuvent représenter à peu près n'importe quoi: des machines physiques, des VMs, des interfaces réseau, des load-balancers, des conatiners et bien plus encore. Elles constituent les ingrédients de nos recettes.

# Installation

La CLI de Terraform est disponible sur tous les systèmes d'exploitations courants sur cette page (opens new window), il est aussi possible de suivre les tutos spécifiques par OS.

comment installer (opens new window)

Bon je suis un peu fainéant et étant sur Mac je peux prendre un petit raccourci:

brew install terraform

# Créer une infrastructure

Pour les besoins du tuto j'utiliserai le provider Digital Ocean qui est un IaaS très simple d'utilisation.

La documentation complète est disponible ici (opens new window)

Pour créer un projet Terraform il suffit de créer un nouveau dossier et dedans un fichier .tf. Peut importe son nom. Nous allons l'appeller par exemple main.tf

provider "digitalocean" {
  token = "token"
}

resource "digitalocean_droplet" "web1" {
  image = "ubuntu-20-04-x64"
  name   = "web-1"
  region = "lon1"
  size   = "s-1vcpu-1gb"
}

Le token peut être créé à partir de la page d'adminisration de Digital Ocean dans l'onglet API.

Ce fichier est une recette qui créé une machine Ubuntu 20.04 de 1Go et de 1 virtual CPU sur Digital Ocean.

Si vous ne l'avez pas déjà fait installez Terraform. Normalement la commande terraform help devrait vous afficher

Usage: terraform [-version] [-help] <command> [args]

The available commands for execution are listed below.
The most common, useful commands are shown first, followed by
less common or more advanced commands. If you're just getting
started with Terraform, stick with the common commands. For the
other commands, please read the help and docs before usage.
...

Si c'est le cas félicitations, Terraform est correctement installé.

Vous pouvez désormais effectuer un terraform init


Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "digitalocean" (terraform-providers/digitalocean) 1.16.0...

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.16"

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.

Cette commande à comme vous venez de lire détecter que le provider était Digital Ocean et a automatiquement initialiser le projet en téléchargeant la dernière version du binaire.

La deuxième phase est de planifier une modification d'infrastructure. terraform plan

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.


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

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_droplet.web1 will be created
  + resource "digitalocean_droplet" "web1" {
      + backups              = false
      + created_at           = (known after apply)
      + disk                 = (known after apply)
      + id                   = (known after apply)
      + image                = "ubuntu-20-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   = (known after apply)
      + region               = "lon1"
      + resize_disk          = true
      + size                 = "s-1vcpu-1gb"
      + status               = (known after apply)
      + urn                  = (known after apply)
      + vcpus                = (known after apply)
      + volume_ids           = (known after apply)
      + vpc_uuid             = (known after apply)
    }

Plan: 1 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.

Comme énoncé Terraform a planifié la création d'une VM (droplet dans le vocabulaire de Digital Ocean). Et vous indique que vous pouvez appliquer ces modification avec terraform apply


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_droplet.web1 will be created
  + resource "digitalocean_droplet" "web1" {
      + backups              = false
      + created_at           = (known after apply)
      + disk                 = (known after apply)
      + id                   = (known after apply)
      + image                = "ubuntu-20-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   = (known after apply)
      + region               = "lon1"
      + resize_disk          = true
      + size                 = "s-1vcpu-1gb"
      + status               = (known after apply)
      + urn                  = (known after apply)
      + vcpus                = (known after apply)
      + volume_ids           = (known after apply)
      + vpc_uuid             = (known after apply)
    }

Plan: 1 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: 

Pour éviter tout malentendu Terraform vous récapitule une dernière fois ce qui va être effectuer sur le provider. Si vous rentrez yes. Terraform débute l'opération.

Puis:


digitalocean_droplet.web1: Creating...
digitalocean_droplet.web1: Still creating... [10s elapsed]
digitalocean_droplet.web1: Still creating... [20s elapsed]
digitalocean_droplet.web1: Still creating... [30s elapsed]
digitalocean_droplet.web1: Creation complete after 35s [id=190159268]

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

Tada 🤩. Une VM vient d'apparaître sur Digital Ocean.

infra blue

Pour l'opération inverse la commande est terraform destroy

Même chose que pour terraform apply, la commande nous récapitule les opérations qui vont être réalisées et attend un yes.

digitalocean_droplet.web1: Refreshing state... [id=190159268]

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_droplet.web1 will be destroyed
  - resource "digitalocean_droplet" "web1" {
      - backups            = false -> null
      - created_at         = "2020-04-27T15:04:05Z" -> null
      - disk               = 25 -> null
      - id                 = "190159268" -> null
      - image              = "ubuntu-20-04-x64" -> null
      - ipv4_address       = "161.35.37.180" -> 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             = "lon1" -> null
      - resize_disk        = true -> null
      - size               = "s-1vcpu-1gb" -> null
      - status             = "active" -> null
      - tags               = [] -> null
      - urn                = "do:droplet:190159268" -> null
      - vcpus              = 1 -> null
      - volume_ids         = [] -> null
    }

Plan: 0 to add, 0 to change, 1 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: 

Une fois celui-ci entré:

digitalocean_droplet.web1: Destroying... [id=190159268]
digitalocean_droplet.web1: Still destroying... [id=190159268, 10s elapsed]
digitalocean_droplet.web1: Still destroying... [id=190159268, 20s elapsed]
digitalocean_droplet.web1: Destruction complete after 22s

Et sur Digital Ocean, a plus la VM 🥺

infra blue

# Contruire notre infrastructure bleu

Pour rappel notre infra ressemble à ça infra blue

La recette Terraform pour arriver à un tel résultat pourrait ressembler à ceci:

// définition du provider 
provider "digitalocean" {
  token = "token"
}

// création d'une base de donnée
resource "digitalocean_database_cluster" "bdd" {
  name       = "mysql-001"
  engine     = "mysql"
  version    = "8"
  size       = "db-s-1vcpu-1gb"
  region     = "lon1"
  node_count = 1
}

// création d'un firewall vers la base de données pour 
// n'accepter que les connexions venant des frontaux
resource "digitalocean_database_firewall" "bdd-fw" {
  cluster_id = digitalocean_database_cluster.bdd.id
  rule {
    type = "tag"
    value = "front"
  }
}

// création de deux frontaux
resource "digitalocean_droplet" "web" {
  image  = "ubuntu-20-04-x64"
  name   = "web-${format("%03d",count.index + 1)}"
  region = "lon1"
  size   = "s-1vcpu-1gb"
  count  = 2
  private_networking = true
  tags = ["front"]
}

// création d'un loadbalancer connecté aux frontaux
resource "digitalocean_loadbalancer" "loadbalancer" {
  name = "load-001"
  region = "lon1"
  forwarding_rule {
    entry_port = 80
    entry_protocol = "http"
    target_port = 80
    target_protocol = "http"
  }

  healthcheck {
    port     = 22
    protocol = "tcp"
  }

  droplet_ids = digitalocean_droplet.web[*].id
}

// création d'un projet pour manager toutes ces ressources
resource "digitalocean_project" "playground" {
  name        = "playground"
  description = "A project to represent development resources."
  purpose     = "Web Application"
  environment = "Development"
  resources   = concat(
    digitalocean_droplet.web[*].urn,
    [digitalocean_database_cluster.bdd.urn],
    [digitalocean_loadbalancer.loadbalancer.urn]
  )
}

Et hop! Deux frontaux, une base de données MySQL, un load-balancer entre les frontaux et le tout dans un projet "playground", le compte est bon!

infra blue

# Spécialiser nos images

Bon c'est magnifique tout ça mais nos frontaux n'ont de frontaux que le nom, il est temps de leur donner une fonction parce que pour le moment c'est juste une Ubuntu. Pour cela nous allons utiliser deux outils: le premier Packer permet d'automatiser la création d'image dans le Cloud et le second Ansible permet d'automatiser la configuration de ces images.

# Packer

Packer est un logiciel conçu par la même société que Terraform: Hashicorp. Son rôle est de fabriquer des images de machines virtuelles qui pourront être bootées dans le Cloud par Terrform ( vous voyez il bien une logique ^^ )

# Installation

Pour l'installation c'est ici (opens new window) que ça se passe.

Comme pour Terraform je préfère la méthode simple de homebrew:

brew install packer

# Construire une image

Packer utilise un simple fichier json comme configuration, nous appelleront le notre config.json.

{
  "builders": [
    {
      "type": "digitalocean",
      "api_token": "token",
      "image": "ubuntu-20-04-x64",
      "region": "lon1",
      "size": "512mb",
      "ssh_username": "root",
      "snapshot_name": "base-image-ubuntu-20.04-x64-v1.0"
    }
  ]
}

La clef builders représente les providers de Terraform, pour nous on continue avec Digital Ocean

Pour vérifier que tout est en ordre:

packer validate config.json

Si tout es bon:

Template validated successfully.

Puis pour créer l'image sur Digital Ocean

packer build config.json
digitalocean: output will be in this color.

==> digitalocean: Creating temporary ssh key for droplet...
==> digitalocean: Creating droplet...
==> digitalocean: Waiting for droplet to become active...
==> digitalocean: Using ssh communicator to connect: 167.71.140.5
==> digitalocean: Waiting for SSH to become available...
==> digitalocean: Connected to SSH!
==> digitalocean: Gracefully shutting down droplet...
==> digitalocean: Creating snapshot: base-image-ubuntu-20.04-x64-v1.0
==> digitalocean: Waiting for snapshot to complete...
==> digitalocean: Destroying droplet...
==> digitalocean: Deleting temporary ssh key...
Build 'digitalocean' finished.

==> Builds finished. The artifacts of successful builds are:
--> digitalocean: A snapshot was created: 'base-image-ubuntu-20.04-x64-v1.0' (ID: 62816352) in regions 'lon1'

Félicitartions cette image ne sert absolument à rien 😄, je vous conseille de la supprimer car elle coûte de l'argent :p

Pour quelle est une quelconque utilité il faut la "provisionner". Et pour cela nous allons utiliser Ansible.

# Ansible

Ansible est un outil qui permet de s'assurer de l'état d'une ou plusieurs machines. Il se charge d'installer les paquets et de configurer les services, créer les utilisateur et leur donner des droits. Il peut aussi vérifier qu'un service est bien en train de tourner et le démarrer le cas échéant.

# Installation

Bon comme d'habitude il faut installer la CLI, ici (opens new window) ou

brew install ansible

# Créer une configuration Ansible

Ansible utilise un fichier YAML comme configuration:

À titre d'exemple voici à quoi cela peut ressembler un playbook.yml

- name: "Provision image"
  hosts: default
  become: true

  tasks:
  - name: install Nginx
    package:
      name: nginx
      state: present
  - name: start Nginx
    service:
      name: nginx
      state: started

# Utiliser Ansible avec Packer

Il suffit de rajouter une clef provisioners de type ansible

{
  "provisioners": [
    {
      "type": "ansible",
      "playbook_file": "./playbook.yml"
    }
  ],
  "builders": [...]
}

Le packer build applique les différentes modifications:

digitalocean: output will be in this color.

==> digitalocean: Creating temporary ssh key for droplet...
==> digitalocean: Creating droplet...
==> digitalocean: Waiting for droplet to become active...
==> digitalocean: Using ssh communicator to connect: 161.35.35.53
==> digitalocean: Waiting for SSH to become available...
==> digitalocean: Connected to SSH!
==> digitalocean: Provisioning with Ansible...
==> digitalocean: Executing Ansible: ansible-playbook --extra-vars packer_build_name=digitalocean packer_builder_type=digitalocean -o IdentitiesOnly=yes -i /var/folders/rz/ccsb98092xj9dhll8wv4jll80000gp/T/packer-provisioner-ansible681128243 /Users/yguern/Documents/lab/terraform/ansible/playbook.yml -e ansible_ssh_private_key_file=/var/folders/rz/ccsb98092xj9dhll8wv4jll80000gp/T/ansible-key770361892
    digitalocean:
    digitalocean: PLAY [Provision image] *********************************************************
    digitalocean:
    digitalocean: TASK [Gathering Facts] *********************************************************
    digitalocean: ok: [default]
    digitalocean:
    digitalocean: TASK [install Nginx] ***********************************************************
    digitalocean: changed: [default]
    digitalocean:
    digitalocean: PLAY RECAP *********************************************************************
    digitalocean: default                    : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
    digitalocean:
==> digitalocean: Gracefully shutting down droplet...
==> digitalocean: Creating snapshot: base-image-ubuntu-20.04-x64-v1.0
==> digitalocean: Waiting for snapshot to complete...
==> digitalocean: Destroying droplet...
==> digitalocean: Deleting temporary ssh key...
Build 'digitalocean' finished.

==> Builds finished. The artifacts of successful builds are:
--> digitalocean: A snapshot was created: 'base-image-ubuntu-20.04-x64-v1.0' (ID: 62816837) in regions 'lon1'

Bon on se rapproche de quelque chose on a maintenant une image contenant un serveur nginx. Cette image est appelée dans Digital Ocean une snapshot son nom est : base-image-ubuntu-20.04-x64-v1.0.

# Utiliser cette image comme frontal

Même si elle n'est pas complète on peut s'accorder la liberté de la tester en condition réelle 😃

Dans le main.tf on peut modifier notre schéma de contruction de notre frontal:

resource "digitalocean_droplet" "web" {
  image  = "base-image-ubuntu-20.04-x64-v1.0" # <--- image venant de Packer
  name   = "web-${format("%03d",count.index + 1)}"
  region = "lon1"
  size   = "s-1vcpu-1gb"
  count  = 2
  private_networking = true
  tags = ["front"]
}

L'image de contruction de la droplet est maintenant celle crée précédemment par Packer.

On terraform apply ça build. Et finalement:

infra blue

On récupère l'IP du loadbalancer 159.65.211.24 on le met dans un navigateur et tada!

infra blue

Ok on a un nginx qui tourne et qui retourne un site statique mais on est pas encore au bout de nos peine. On doit encore configuré nginx et surtout installer un php-fpm parce que je rappelle qu'à terme c'est une application Laravel et donc PHP qui voulu qui est voulue.

On va donc améliorer un peu la structure de notre dossier ansible.

# Rajouter PHP à notre image

Ansible nous permet de rajouter autant de services que l'on peut le désirer. De ce fait nous allons pouvoir rajouter PHP-FPM à notre image et configurer nginx pour être capable de comprendre la cgi PHP.

# Installer PHP

Il existe tout un tas de façon d'installer PHP via Ansible, j'ai choisi d'utiliser un rôle tout fait (opens new window).

Celui donne un moyen clef en main d'installer n'importe quelle version de PHP et ses packages les plus courants.

Ansible est fourni avec un package manager appelé ansible-galaxy

Pour installer le rôle PHP

ansible-galaxy install ansible-galaxy install sys_fs.php_fpm

Finalement on peut utiliser notre rôle:

  roles:
    - role: sys_fs.php_fpm
      vars:
        - php_fpm_packages:
            - php7.4
            - php7.4-cli
            - php7.4-common
            - php7.4-curl
            - php7.4-fpm
            - php7.4-gd
            - php7.4-json
            - php7.4-mbstring
            - php7.4-mysql
            - php7.4-opcache
            - php7.4-zip
        - php_fpm_pools:
            - name: web
              type: unix
              pm: static
              pm_max_children: 100
              php_admin_values:
                - name: memory_limit
                  value: '512M'

# Configurer Nginx en Fast-CGI

Lors de la précédente partie nous avions simplement installé Nginx sans le configurer. Il est temps de rétablir cela.

Cette fois ci on se passera de ansible-galaxy et nous allons créer de toute pièce notre rôle nginx.

Voici une structure possible pour notre rôle nginx

roles
  |_ nginx
       |
       |-tasks
       |   |
       |   |- main.yml # tâches d'installation et de configuration
       |
       |- templates`
           |
           |- ufw.j2 # configuration du firewall
           |
           |- default.conf.j2 # configuration du vhost

En plus d'installer nginx, nous configurons le firewall pour ouvrir le port 80.

---
# main.yml
---
- name: Install Nginx
  package:
    name: nginx
    state: present

- name: Add ufw config file
  template:
    dest: /etc/ufw/applications.d/nginx.ini
    src: ufw.j2


- name: Open firewall for Nginx
  ufw:
    rule: allow
    name: Nginx HTTP

- name: Add vhost
  template:
    dest: /etc/nginx/sites-enabled/default
    src: default.conf.j2
    owner: www-data
    group: www-data
    mode: '0644'

La configuration du vhost 

# default.conf.j2
server {
    listen 80;
    server_name _;
    root /srv/web;

    index index.html index.htm index.php;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        # permet de se connecter la pool PHP-FPM installé précédemment
        fastcgi_pass unix:/run/php/web-fpm.sock;
     }

    location ~ /\.ht {
        deny all;
    }

}

La configuration du firewall

# ufw.j2
[Nginx HTTP]
title=Web Server
description=Enable NGINX HTTP traffic
ports=80/tcp

[Nginx HTTPS] \
title=Web Server (HTTPS) \
description=Enable NGINX HTTPS traffic
ports=443/tcp

[Nginx Full]
title=Web Server (HTTP,HTTPS)
description=Enable NGINX HTTP and HTTPS traffic
ports=80,443/tcp

Le playbook complet ressemble à:

---
- name: Install and configure a webserver Nginx & PHP-FPM
  hosts: default
  become: true

  roles:
    - role: nginx
    - role: sys_fs.php_fpm
      vars:
        [...]