31/10/2018 12 Minutes read Tech 

Quelques heures avec Terraform : outiller le provisioning Amazon Web Services

Depuis des années, les besoins d'industrialisation des composants du SI se sont étendus.

Nous avons, dans un premier temps, industrialisé le cycle de vie des applications (code) avec la création d’outils de build, test et déploiement (Rspecs, Jenkins, Capistrano, Fabric…). En parallèle, nous avons industrialisé le cycle de vie des plateformes au sens large avec l’émergence d’outils permettant une gestion déterministe des briques runtime et OS des serveurs (Puppet, Chef, Ansible, Salt…).

La dernière étape logique de cette industrialisation était donc la gestion du cycle de vie de l’infrastructure (facilitée par l’adoption massive du cloud et de la virtualisation) avec l’apparition d’outils permettant la description de composants devenus virtuels tels que le réseau, le stockage, les machines ou encore les pare-feus (Cloudformation, Heat, …).

C’est sur cette derniere étape, l’Infrastructure as Code, que nous allons nous pencher.

Le besoin est donc bien sur de pouvoir créer et supprimer des ressources, mais surtout gérer efficacement leur modification dans le temps : connaitre l’état actuel, comparer à l’état attendu et en déduire les actions de modifications à réaliser.

Par exemple : lire la configuration actuelle d’un pare-feu, analyser la configuration souhaitée, et en déduire qu’il faut ajouter le flux HTTPS à destination d’un serveur.

Par ailleurs, on souhaite généralement pouvoir travailler sur une base de fichiers texte, intégrables dans tout système de gestion de version (git, mercurial, tfs…), afin de bénéficier d’historisation, diff, …

Divers outils sont apparus sur le marché pour répondre à cette problématique, la plupart limités à un périmètre restreint ou à un service de cloud donné.

Un nouvel arrivant, Terraform,  tente d’unifier la gestion d’une infrastructure multi fournisseurs. Cet article a pour but de présenter le résultat de quelques heures de tests de Terraform sur Amazon Web Services.

Terraform & cas d’usage

Terraform est développé par Hashicorp, éditeur de 2 produits bien connus : Vagrant et Packer.

Il permet de gérer différents environnements cibles, appelés providers. À date, en version 0.3.5, ces providers incluent entre autres Amazon Web Services, Google Cloud, Microsoft Azure, Digital Ocean, et Heroku. Le produit étant relativement jeune, il ne permet pas encore de gérer l’intégralité des ressources de chaque provider.

Pour nos tests, nous avons choisi comme cas d’usage la gestion d’une plateforme web “type” sur AWS : le projet “Skynet” pour notre client “Cyberdyne”.

Avant de commencer

Un peu de terminologie Amazon Web Services :

  • VPC ou Virtual Private Cloud : Amazon Virtual Private Cloud (Amazon VPC) vous permet de mettre en service une section du cloud Amazon Web Services (AWS) qui a été isolée de manière logique et dans laquelle vous pouvez lancer des ressources AWS dans un réseau virtuel que vous définissez.
  • Subnet : le VPC est découpé en sous-réseaux (subnets), pouvant disposer de routages spécifiques (leur donnant par exemple accès à internet).
  • AZ ou Availability Zone : AWS est disponible sur plusieurs zones géographiques (Amérique du Nord, Irlande, Allemagne, …). Chaque zone géographique est découpée en 2 à 4 Availability Zones (AZ), elles mêmes constituées d’un ou plusieurs datacenters. Répartir ses ressources sur plusieurs AZ permet d’obtenir un meilleur taux de disponibilité des services.
  • Security group : un security group est un ensemble de règles d’ouvertures de flux réseau (ouverture du flux ssh depuis une IP source précise, ouverture du HTTP depuis tout internet, …).
  • RDS ou Relational Database Service : service de base de données managées. On provisionne un type de moteur (mysql, postgresql, …), une capacité. AWS gère backups, upgrades mineures, réplication …
  • ELB ou Elastic Load Balancing : service de partage de charge qui permet de répartir un flux entrant vers plusieurs serveurs (ou instances) cibles.

Dans la pratique

Notre plateforme sera composée de 3 niveaux :

  • niveau Load-Balancing,
  • niveau rendu avec 3 frontaux web répartis sur des AZ,
  • niveau de persistance avec les bases de données.
architecture AWS de la plateforme de test

Nous souhaitons également normer le nommage des ressources et utiliser les tags Amazon (très utiles pour l’identification des ressources, la gestion des droits et l’analyse de coûts). Nous avons rencontré quelques problèmes sur ce point et levé ainsi des incohérences dans les règles de nommage autorisées par AWS ; par exemple : la plupart des noms peuvent contenir des “_”, mais pas les ELB qui n’autorisent que les “-“.

Le code Terraform est de type déclaratif : il n’y a pas de contrainte d’ordonnancement. Cependant nous recommandons de garder une certaine logique dans l’écriture du code.

Terraform fonctionne avec des fichiers de configuration JSON, format très connu et réputé pour les interactions entre systèmes,  mais peu pour sa lisibilité par une personne physique. Ainsi Hashicorp introduit un langage de définition orienté utilisateur appelé HCL (Hashicorp Configuration Language). L’extension des fichier attendu pour ces fichier de configuration est .tf

Note: Un plugin de syntaxe HCL pour Sublime Text (editeur disponible sur Mac, Windows et Linux) est disponible ici : https://github.com/MerlinDMC/sublime-terraform

Définition de l’infrastructure

La définition se déroule en 5 temps :

  • Presets : Variables et Credentials
  • Network : VPC et Subnets
  • Firewall : Security Groups
  • Servers : EC2, ELB & RDS
  • DNS : Route53

 Variables et credentials

On définit ici un certain nombre de paramètres qui seront repris tout au long du code dont :

  • nom du client,
  • nom du projet,
  • régions de déploiement AWS,
  • second octet du réseau cible.
  • variables ==============================================================

    variable “customer” { default = “cyberdyne” }
    variable “project” { default = “skynet” }
    variable “platform” { default = “prod” }
    variable “region” { default = “eu-west-1” }
    variable “my_cidr_block” { default = “3” }
    variable “keyname” { default = “apavageau” }
    variable “db_username” {default = “skynetprod”}
    variable “db_password” {default = “*****“}

    credentials ==============================================================

    provider “aws” {
    access_key = “****
    secret_key = “****
    region = “${var.region}”
    }

Note :

Dans cette démonstration les credentials d’API AWS sont inclus dans le fichier de configuration. N’incluez jamais vos credentials dans le code source !

Pour un usage réel il conviendra de les externaliser dans un fichier séparé. Ce fichier pourra être ignoré par le systeme de versioning si le code y est soumis (ex: .gitignore).

Cette solution présente l’avantage de ne pas avoir à passer les variables en argument de chaque appel à Terraform.

VPC et subnets

Nous définissons ici :

  • 1 VPC,
  • 3 subnets front avec routage public pour les serveurs web (1 subnet par AZ),
  • 3 subnets back pour les bases de données (1 subnet par AZ).

Chaque composant Terraform doit avoir un nom. Celui ci doit être unique et n’est utilisé que par Terraform pour références interne entre les composants. Les noms n’ont aucun impact sur votre infrastructure cible !

Dans notre exemple, le VPC est nommé en interne « vpc33 ». Il sera utilisé lors de la création des Subnets pour référencer le VPC auquel ils appartiennent « vpc_id = « ${aws_vpc.vpc33.id} ».

# VPC ==============================================================
resource "aws_vpc" "vpc33" {
 cidr_block = "10.${var.my_cidr_block}.0.0/16"
 tags {
   Name = "vpc_${var.customer}_${var.project}-${var.platform}"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

# front subnets ==============================================================
resource "aws_subnet" "front-a" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.0.0/23"
 availability_zone = "${var.region}a"
 map_public_ip_on_launch = true
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_front_a"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

resource "aws_subnet" "front-b" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.2.0/23"
 availability_zone = "${var.region}b"
 map_public_ip_on_launch = true
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_front_b"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

resource "aws_subnet" "front-c" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.4.0/23"
 availability_zone = "${var.region}c"
 map_public_ip_on_launch = true
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_front_c"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

# back subnets ==============================================================
resource "aws_subnet" "back-a" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.6.0/23"
 availability_zone = "${var.region}a"
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_back_a"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

resource "aws_subnet" "back-b" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.8.0/23"
 availability_zone = "${var.region}b"
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_back_b"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

resource "aws_subnet" "back-c" {
 vpc_id = "${aws_vpc.vpc33.id}"
 cidr_block = "10.${var.my_cidr_block}.10.0/23"
 availability_zone = "${var.region}c"
 tags {
   Name = "subnet_${var.customer}_${var.project}-${var.platform}_back_c"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }
}

# routing ==============================================================
resource "aws_internet_gateway" "gw-to-internet33" {
 vpc_id = "${aws_vpc.vpc33.id}"
}

resource "aws_route_table" "route-to-gw33" {
 vpc_id = "${aws_vpc.vpc33.id}"
 route {
 cidr_block = "0.0.0.0/0"
   gateway_id = "${aws_internet_gateway.gw-to-internet33.id}"
 }
}

resource "aws_route_table_association" "front-a" {
 subnet_id = "${aws_subnet.front-a.id}"
 route_table_id = "${aws_route_table.route-to-gw33.id}"
}

resource "aws_route_table_association" "front-b" {
 subnet_id = "${aws_subnet.front-b.id}"
 route_table_id = "${aws_route_table.route-to-gw33.id}"
}

resource "aws_route_table_association" "front-c" {
 subnet_id = "${aws_subnet.front-c.id}"
 route_table_id = "${aws_route_table.route-to-gw33.id}"
}

Security groups

Nous définissons ici :

  • un SG autorisant le HTTPS entrant depuis internet vers les LB.
  • un SG autorisant le HTTP entrant depuis les LB vers les WEB.
  • un SG autorisant le SSH entrant et ICMP depuis une IP spécifique vers toutes les instances.
  • un SG autorisant le flux MySQL entrant depuis les WEB (permettant à l’application de se connecter à la base de données).

Note: Terraform ne permet que la définition des règle ingress; les règles egress ne sont pas encore gérées.

# public security groups ==============================================================
resource "aws_security_group" "sg_public_service" {
 name = "sg_${var.customer}_${var.project}-${var.platform}_public_service"
 description = "allow http inbound traffic"
 vpc_id = "${aws_vpc.vpc33.id}"

 tags {
   Name = "sg_${var.customer}_${var.project}-${var.platform}_public_service"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }

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

# front security groups ==============================================================
resource "aws_security_group" "sg_front_service" {
 name = "sg_${var.customer}_${var.project}-${var.platform}_front_service"
 description = "allow http inbound traffic from the public service sg; all tcp inside the SG"
 vpc_id = "${aws_vpc.vpc33.id}"
 tags {
 Name = "sg_${var.customer}_${var.project}-${var.platform}_front_service"
 Customer = "${var.customer}"
 Platform = "${var.platform}"
 }

 ingress {
   from_port = 80
   to_port = 80
   protocol = "tcp"
   security_groups = [
     "${aws_security_group.sg_public_service.id}"
   ]
 }

 ingress {
   from_port = 0
   to_port = 65535
   protocol = "tcp"
   self = "true"
 }
}
resource "aws_security_group" "sg_front_infra" {
 name = "sg_${var.customer}_${var.project}-${var.platform}_front_infra"
 description = "standard ssh & monitoring"
 vpc_id = "${aws_vpc.vpc33.id}"

 tags {
   Name = "sg_${var.customer}_${var.project}-${var.platform}_front_infra"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }

 ingress {
   from_port = 22
   to_port = 22
   protocol = "tcp"
   cidr_blocks = [
     "**************/32",
     "**************/32"
   ]
 }

 ingress {
   from_port = -1
   to_port = -1
   protocol = "icmp"
   cidr_blocks = [
     "130.193.24.141/32"
   ]
 }
}

# back security groups ==============================================================
resource "aws_security_group" "sg_back_service" {
 name = "sg_${var.customer}_${var.project}-${var.platform}_back_service"
 description = "allow inbound traffic from the front sg"
 vpc_id = "${aws_vpc.vpc33.id}"

 tags {
   Name = "sg_${var.customer}_${var.project}-${var.platform}_back_service"
   Customer = "${var.customer}"
   Platform = "${var.platform}"
 }

 ingress {
   from_port = 3306
   to_port = 3306
   protocol = "tcp"
   security_groups = [
     "${aws_security_group.sg_front_service.id}"
   ]
 }
}

Instances EC2, ELB & RDS

Nous définissons ici :

  • 1 LB (load balancer, endpoint SSL qui renvoie le traffic HTTP sur le port 80 des instances EC2).
  • 3 EC2 (machines virtuelles, pour lesquelles on indique capacité, taille de disque, etc…)
  • 1 RDS (base de donnée, ici MySQL).

    EC2 ==============================================================

    resource “aws_instance” “instance-front-a” {
    ami = “ami-2278cc55”
    instance_type = “m3.xlarge”
    key_name = “${var.keyname}”
    availability_zone = “eu-west-1a”
    ebs_optimized = true
    subnet_id = “${aws_subnet.front-a.id}”
    security_groups = [
    “${aws_security_group.sg_front_service.id}”,
    “${aws_security_group.sg_front_infra.id}”
    ]
    associate_public_ip_address = true

    tags {
    Name = “web1-${var.platform}-${var.customer}”
    Customer = “${var.customer}”
    Platform = “${var.platform}”
    }

    block_device {
    device_name = “/dev/sda”
    volume_type = “gp2”
    volume_size = “300”
    delete_on_termination = “false”
    }
    }

    resource “aws_instance” “instance-front-b” {
    ami = “ami-2278cc55”
    instance_type = “m3.xlarge”
    key_name = “${var.keyname}”
    availability_zone = “eu-west-1b”
    ebs_optimized = true
    subnet_id = “${aws_subnet.front-b.id}”
    security_groups = [
    “${aws_security_group.sg_front_service.id}”,
    “${aws_security_group.sg_front_infra.id}”
    ]
    associate_public_ip_address = true

    tags {
    Name = “web2-${var.platform}-${var.customer}”
    Customer = “${var.customer}”
    Platform = “${var.platform}”
    }

    block_device {
    device_name = “/dev/sda”
    volume_type = “gp2”
    volume_size = “300”
    delete_on_termination = “false”
    }
    }

    resource “aws_instance” “instance-front-c” {
    ami = “ami-2278cc55”
    instance_type = “m3.xlarge”
    key_name = “${var.keyname}”
    availability_zone = “eu-west-1c”
    ebs_optimized = true
    subnet_id = “${aws_subnet.front-c.id}”
    security_groups = [
    “${aws_security_group.sg_front_service.id}”,
    “${aws_security_group.sg_front_infra.id}”
    ]
    associate_public_ip_address = true
    tags {
    Name = “web3-${var.platform}-${var.customer}”
    Customer = “${var.customer}”
    Platform = “${var.platform}”
    }

    block_device {
    device_name = “/dev/sda”
    volume_type = “gp2”
    volume_size = “300”
    delete_on_termination = “false”
    }
    }

    ELB ==============================================================

    resource “aws_elb” “loadbalancer-to-publicweb” {
    name = “elb-${var.customer}-${var.project}-${var.platform}-web”

    listener {
    lb_port = 443
    lb_protocol = “https”
    instance_port = 80
    instance_protocol = “http”
    ssl_certificate_id = “arn:aws:iam:::server-certificate/
    }

    subnets = [“${aws_subnet.front-a.id}”,”${aws_subnet.front-b.id}”,”${aws_subnet.front-c.id}”]
    security_groups = [“${aws_security_group.sg_public_service.id}”]
    health_check {
    healthy_threshold = 2
    unhealthy_threshold = 2
    timeout = 3
    target = “TCP:80”
    interval = 30
    }

    instances = [“${aws_instance.instance-front-a.id}”,”${aws_instance.instance-front-b.id}”,”${aws_instance.instance-front-c.id}”]
    }

    RDS ==============================================================

    resource “aws_db_subnet_group” “mysql” {
    name = “dbsubnet${var.customer}${var.project}${var.platform}”
    description = “${var.customer} ${var.project} ${var.platform} RDS subnet group”
    subnet_ids = [
    “${aws_subnet.back-a.id}”,
    “${aws_subnet.back-b.id}”,
    “${aws_subnet.back-c.id}”
    ]
    }
    resource “aws_db_parameter_group” “mysql” {
    name = “parameter-group-${var.customer}${var.project}${var.platform}”
    family = “mysql5.6”
    description = “RDS parameter group for ${var.customer} ${var.project} ${var.platform}”
    }

    resource “aws_db_instance” “mysql” {
    identifier = “db-${var.customer}-${var.project}-${var.platform}”
    allocated_storage = 50
    engine = “mysql”
    engine_version = “5.6.21”

    instance_class = “db.m3.large”

    instance_class = “db.t2.micro”
    parameter_group_name = “${aws_db_parameter_group.mysql.id}”
    name = “db${var.customer}${var.project}${var.platform}”
    username = “${var.db_username}”
    password = “${var.db_password}”
    vpc_security_group_ids = [“${aws_security_group.sg_back_service.id}”]
    multi_az = true
    db_subnet_group_name = “${aws_db_subnet_group.mysql.id}”
    publicly_accessible = false
    backup_retention_period = “7”
    skip_final_snapshot = true
    }

DNS

Enfin, l’ensemble des ressources étant défini, il convient maintenant de prévoir quelques entrées DNS qui permettront de tester le service et de se connecter plus aisément aux serveurs :

# DNS ==============================================================
resource "aws_route53_record" "elb" {
 zone_id = "/hostedzone/**************"
 name = "lb-${var.customer}-${var.project}-${var.platform}.mondomaine.com"
 type = "CNAME"
 ttl = "60"
 records = ["${aws_elb.loadbalancer-to-publicweb.dns_name}"]
}

resource "aws_route53_record" "instance-front-a" {
 zone_id = "/hostedzone/**************"
 name = "web1-${var.platform}-${var.customer}.mondomaine.com"
 type = "A"
 ttl = "60"
 records = ["${aws_instance.instance-front-a.public_ip}"]
}

resource "aws_route53_record" "instance-front-b" {
 zone_id = "/hostedzone/**************"
 name = "web2-${var.platform}-${var.customer}.mondomaine.com"
 type = "A"
 ttl = "60"
 records = ["${aws_instance.instance-front-b.public_ip}"]
}

resource "aws_route53_record" "instance-front-c" {
 zone_id = "/hostedzone/**************"
 name = "web3-${var.platform}-${var.customer}.mondomaine.com"
 type = "A"
 ttl = "60"
 records = ["${aws_instance.instance-front-c.public_ip}"]
}

Notre configuration est prête, reste à l’exécuter !

Création

L’exécution se passe en 2 étapes.

Dans un premier temps :

terraform plan -out plan.out

Terraform va parser la configuration, relever d’éventuelles erreurs de syntaxe, vérifier l’état des ressources & configurations existantes, « calculer » les dépendances entre les ressources et définir un plan d’exécution.

Ce plan sera affiché à l’utilisateur sur la sortie standard (user-friendly) et dans le fichier plan.out (format terraform)

Il recense les actions à réaliser (création +, modification ~, suppression -) pour arriver à l’état cible.

Cette étape est obligatoire et permet à l’opérateur de contrôler les actions qui vont être effectuées si la configuration est effectivement appliquée.

Lorsque l’on connait les potentiels risques à modifier automatiquement toute une infrastructure, on apprécie cette approche “dry-run obligatoire”.

Un exemple de ce plan tel que rendu à l’utilisateur (sortie standard) :

$ terraform plan -out plan.out

var.customer
 Default: cyberdyne
 Enter a value:
[...]

Refreshing Terraform state prior to plan...
The Terraform execution plan has been generated and is shown below.
Resources are shown in alphabetical order for quick scanning. Green resources
will be created (or destroyed and then created if an existing resource
exists), yellow resources are being changed in-place, and red resources
will be destroyed.
Your plan was also saved to the path below. Call the "apply" subcommand
with this plan file and Terraform will exactly execute this execution
plan.
Path: plan.out
+ aws_db_instance.mysql
 address: "" => "<computed>"
 allocated_storage: "" => "50"
 backup_retention_period: "" => "7"
 db_subnet_group_name: "" => "${aws_db_subnet_group.mysql.id}"
 endpoint: "" => "<computed>"
 engine: "" => "mysql"
 engine_version: "" => "5.6.21"
 identifier: "" => "db-cyberdyne-skynet-prod"
 instance_class: "" => "db.t2.micro"
 multi_az: "" => "1"
 name: "" => "dbcyberdyneskynetprod"
 parameter_group_name: "" => "${aws_db_parameter_group.mysql.id}"
 password: "" => "*********"
 publicly_accessible: "" => "0"
 skip_final_snapshot: "" => "1"
 status: "" => "<computed>"
 username: "" => "skynetprod"
 vpc_security_group_ids.#: "" => "<computed>"

[...]
+ aws_elb.loadbalancer-to-publicweb
 dns_name: "" => "<computed>"
 health_check.#: "" => "1"
 health_check.0.healthy_threshold: "" => "2"
 health_check.0.interval: "" => "30"
 health_check.0.target: "" => "TCP:80"
 health_check.0.timeout: "" => "3"
 health_check.0.unhealthy_threshold: "" => "2"
 instances.#: "" => "<computed>"
 internal: "" => "<computed>"
 listener.#: "" => "1"
 listener.0.instance_port: "" => "80"
 listener.0.instance_protocol: "" => "http"
 listener.0.lb_port: "" => "443"
 listener.0.lb_protocol: "" => "https"
 listener.0.ssl_certificate_id: "" => "arn:aws:iam::*********:server-certificate/*********"
 name: "" => "elb-cyberdyne-skynet-prod-web"
 security_groups.#: "" => "<computed>"
 subnets.#: "" => "<computed>"
+ aws_instance.instance-front-a
 ami: "" => "ami-2278cc55"
 associate_public_ip_address: "" => "1"
 availability_zone: "" => "eu-west-1a"
 block_device.#: "" => "1"
 block_device.0.delete_on_termination: "" => "0"
 block_device.0.device_name: "" => "/dev/sda"
 block_device.0.encrypted: "" => ""
 block_device.0.snapshot_id: "" => ""
 block_device.0.virtual_name: "" => ""
 block_device.0.volume_size: "" => "300"
 block_device.0.volume_type: "" => "gp2"
 ebs_optimized: "" => "1"
 instance_type: "" => "m3.xlarge"
 key_name: "" => "apavageau"
 private_dns: "" => "<computed>"
 private_ip: "" => "<computed>"
 public_dns: "" => "<computed>"
 public_ip: "" => "<computed>"
 security_groups.#: "" => "<computed>"
 subnet_id: "" => "${aws_subnet.front-a.id}"
 tags.Customer: "" => "cyberdyne"
 tags.Name: "" => "web1-prod-cyberdyne"
 tags.Platform: "" => "prod"
 tenancy: "" => "<computed>"

[...]

Dans un second temps, nous lançons maintenant la création des ressources à proprement parler :

terraform apply plan.out

Un exemple d’exécution du plan :

terraform apply plan.out
aws_db_parameter_group.mysql: Creating...
 description: "" => "RDS parameter group for cyberdyne skynet prod"
 family: "" => "mysql5.6"
 name: "" => "parameter-group-cyberdyneskynetprod"
aws_vpc.vpc33: Creating...
 cidr_block: "" => "10.3.0.0/16"
 enable_dns_hostnames: "" => "<computed>"
 enable_dns_support: "" => "<computed>"
 main_route_table_id: "" => "<computed>"
 tags.Customer: "" => "cyberdyne"
 tags.Name: "" => "vpc_cyberdyne_skynet-prod"
 tags.Platform: "" => "prod"
aws_db_parameter_group.mysql: Creation complete
aws_vpc.vpc33: Creation complete

[...]

L’exécution prend quelques minutes en fonction des tâches à réaliser (la création des instances EC2 est asynchrone, mais pas celle des instances RDS)

Apply complete! Resources: 12 added, 3 changed, 0 destroyed.

The state of your infrastructure has been saved to the path
below. This state is required to modify and destroy your
infrastructure, so keep it safe. To inspect the complete state
use the `terraform show` command.

State path: terraform.tfstate

Comme indiqué par les dernières lignes de la commande, l’état post exécution est sauvé en local dans un fichier .tfstate. Ce fichier est essentiel car il permet de faire le diff pour les exécutions suivantes !

Modification

Nous allons maintenant modifier la configuration de l’ELB pour supprimer l’un des nœuds. Dans la configuration indiquée plus haut, la ligne :

instances = ["${aws_instance.instance-front-a.id}","${aws_instance.instance-front-b.id}","${aws_instance.instance-front-c.id}"]

devient :

instances = ["${aws_instance.instance-front-a.id}","${aws_instance.instance-front-b.id}""]

Exécutons maintenant un plan et un apply :

terraform plan -out plan.out

[...]
~ aws_elb.loadbalancer-to-publicweb
  instances.#: "3" => "2"
  instances.0: "i-4c862aaa" => "i-d9bf633e"
  instances.1: "i-d9bf633e" => "i-a6208042"
  instances.2: "i-a6208042" => ""
[...]
-/+ aws_db_instance.mysql
  address: "db-cyberdyne-skynet-prod.c2wg5wq0jgod.eu-west-1.rds.amazonaws.com" => "<computed>"
  allocated_storage: "50" => "50"
  availability_zone: "eu-west-1c" => ""
  backup_retention_period: "7" => "7"
  backup_window: "22:48-23:18" => ""
  db_subnet_group_name: "db_subnet_cyberdyne_skynet_prod" => "db_subnet_cyberdyne_skynet_prod"
  endpoint: "db-cyberdyne-skynet-prod.c2wg5wq0jgod.eu-west-1.rds.amazonaws.com:3306" => "<computed>"
  engine: "mysql" => "mysql"
  engine_version: "5.6.21" => "5.6.21"
  identifier: "db-cyberdyne-skynet-prod" => "db-cyberdyne-skynet-prod"
  instance_class: "db.m3.large" => "db.m3.large"
  maintenance_window: "fri:02:07-fri:02:37" => ""
  multi_az: "true" => "1"
  name: "dbcyberdyneskynetprod" => "dbcyberdyneskynetprod"
  parameter_group_name: "parameter-group-cyberdyneskynetprod" => "parameter-group-cyberdyneskynetprod"
  password: "" => "*****" (forces new resource)
  port: "3306" => ""
  publicly_accessible: "false" => "0"
  skip_final_snapshot: "true" => "1"
  status: "available" => "<computed>"
  username: "skynetprod" => "skynetprod"
  vpc_security_group_ids.#: "1" => "1"
  vpc_security_group_ids.0: "sg-816e0ae4" => "sg-816e0ae4"

Terraform indique les modifications qu’il va apporter, qu’elles soient ajouts, éditions ou suppression de ressources.

Ici, comme demandé, l’une des instances sera retirée du pool du load balancer mais…. il indique aussi qu’il va supprimer et recréer la base RDS !

La raison en est indiquée : c’est le paramètre password. Il s’agit d’un bug identifié : https://github.com/hashicorp/terraform/issues/689

password: "" => "*****" (forces new resource)

L’apply se déroule comme « prévu », en modifiant l’ELB et en supprimant / recréant la base de données RDS :

terraform apply plan.out
aws_elb.loadbalancer-to-publicweb: Modifying...
 instances.#: "3" => "2"
 instances.0: "i-4c862aaa" => "i-d9bf633e"
 instances.1: "i-d9bf633e" => "i-a6208042"
 instances.2: "i-a6208042" => ""

Destruction

Notre test (ou le projet) arrivant à sa fin, il est temps de détruire l’environnement. La commande est simple : terraform destroy.

Si l’intégralité de votre infrastructure n’est pas gérée par terraform vous pouvez avoir un doute sur les ressources qui vont effectivement être détruites.

Dans ce cas, il est aussi possible de passer par un plan intermédiaire :

terraform plan -destroy

Avant de détruire les ressources :

terraform destroy
Do you really want to destroy?
 Terraform will delete all your managed infrastructure.
 There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes

[...]
aws_route53_record.instance-front-c: Destroying...
aws_route53_record.instance-front-b: Destroying...
aws_route53_record.instance-front-a: Destroying...
[...]
aws_internet_gateway.gw-to-internet33: Error: Network vpc-9b7aaafe has some mapped public address(es). Please unmap those public address(es) before detaching the gateway. (DependencyViolation)
[...]
aws_subnet.front-a: Error: Error deleting subnet: The subnet 'subnet-efa9218a' has dependencies and cannot be deleted. (DependencyViolation)
[...]
aws_subnet.front-c: Error: Error deleting subnet: The subnet 'subnet-bd468ce4' has dependencies and cannot be deleted. (DependencyViolation)
[...]
aws_subnet.front-b: Error: Error deleting subnet: The subnet 'subnet-af57e6d8' has dependencies and cannot be deleted. (DependencyViolation)
[...]
aws_security_group.sg_public_service: Destruction complete
Error applying plan:
1 error(s) occurred:
* Network vpc-9b7aaafe has some mapped public address(es). Please unmap those public address(es) before detaching the gateway. (DependencyViolation)

Quelques messages d’erreur attirent notre attention. Il s’agit, là aussi, d’un type de bug que l’on rencontre actuellement sur le produit : les erreurs d’ordonnancement dans les opérations.

Si Terraform arrive sans problème à gérer l’ordre de création des ressources, il rencontre encore des soucis pour les suppressions. Heureusement les messages sont relativement explicites et la fin des suppressions pourra être réalisée en exécutant une seconde fois la commande ou encore manuellement.

Conclusion

Sur le principe, clairement, Terraform apporte un plus dans la gestion de l’écosystème “cloud”. Le produit, bien que jeune, est déjà prometteur. De plus, l’historique de Hashicorp sur Vagrant et Packer laisse espérer un produit de qualité à terme.

Quelques points rendent cependant son utilisation “en production” délicate pour le moment. Entre autres (en v0.3.5) :

  • une tendance à détruire et recréer les ressources lorsqu’elles pourraient être simplement modifiées ;
  • impossible d’utiliser les variables d’environnement $AWS_ACCESS_KEY et $AWS_SECRET_KEY alors qu’ils s’agit de standards pour l’utilisation des outils AWS en ligne de commande ;
  • pas de gestion des tags sur les bases RDS et sur les volumes EBS ;
  • impossible de créer une base RDS avec un disque SSD ;
  • des bugs de gestion des dépendances lors du destroy ;
  • sauvegarde de l’état en local (dans le fichier tfstate), au lieu d’une lecture systématique de l’état réel.

Reste qu’en l’état, pour à minima gérer la création rapide d’environnements AWS proprement normés et définis, son utilisation permet de gagner un temps et une qualité considérables ! C’est exactement ce que l’on attend de lui.

Nous suivrons donc de près les prochaines releases, et profiterons de l’occasion pour tester son utilisation sur les autres plateformes cloud majeures du marché.

Matthieu Fronton ( @frntn ) & Axel Pavageau ( @axelpavageau )