Publié par
Il y a 3 années · 14 minutes · DevOps

Introduction à Terraform

Lancé en juillet dernier, l’outil d’infra as code Terraform offre désormais un niveau de fonctionnalité permettant la configuration d’une infrastructure cloud complète.

Voyons dans le cadre de l’architecture d’une plateforme web comment créer une plateforme complète sur Amazon en utilisant Terraform.

Terraform est un outil open source d’infrastructure as code, écrit en go, dont l’approche est d’autoriser la définition d’une architecture aussi hétérogène que possible et ainsi faire cohabiter des instances Amazon EC2 et Google Cloud Engine, gérer son DNS avec DNSimple ou encore envoyer les mailings avec Mailgun. Sur la page d’introduction du projet, Terraform se compare avec d’autres solutions du marché, assumant pleinement ne pas remplir les mêmes fonctions que Puppet ou Chef. Le duel avec Ansible est malheureusement absent. Saluons cependant la démarche.

Si les développements se sont jusqu’à présent principalement focalisés sur les services Amazon, d’autres providers sont supportés officiellement et la liste augmente au fil des versions. Il est possible de créer son propre module et les initiatives pour ajouter les providers d’autres solutions émergent de la communauté (Openstack, VMware, …)

Intégration avec le monde extérieur

L’ensemble d’un projet Terraform s’organise autour de fichiers de configuration, lesquels sont de simples fichiers texte qui peuvent être écrits au format spécifique de Terraform (.tf) ou en JSON (.tf.json). Si le premier est préférable, autorisant les commentaires et utilisant une syntaxe plus concise, le second offre la flexibilité de JSON, interprétable par la grande majorité des langages.

Il est possible d’exporter toutes les valeurs de son infrastructure dans des fichiers texte en utilsant la commande output. Nous pouvons donc, par exemple générer un fichier d’inventaire pour une utilisation ultérieure.

Lors de la création de l’infrastructure, Terraform génère un fichier d’état (terraform.tfstate). Depuis la version 0.3, ce fichier est au format JSON, lisible humainement à des fins de debug mais plus certainement pour s’interfacer avec d’autres outils. Notons dans cette veine le projet terraform-inventory qui transforme un état terraform, en fichier utilisable par Ansible. En pratique, parcourir le fichier d’état est suffisamment simple pour se permettre de créer son propre parser. Voici un exemple de parser écrit en javascript.

Terraformons par l’exemple

Architecture d’une plateforme web

Prenons le cas simple d’une architecture web dans laquelle le service est accessible via un loadbalancer (lb) qui répartit la charge sur plusieurs serveurs hébergeant l’application (web), lesquels communiquent avec une base de données (db). Cette plateforme sera intégralement hébergée sur AWS.

Les différents fichiers d’un projet Terraform

Tous les fichiers sont regroupés dans un même répertoire, à l’heure actuelle il n’est pas possible d’organiser son projet en sous-dossier. Sachez cependant qu’il est possible d’organiser un projet en modules, lesquels peuvent être hébergés localement ou sur un dépôt git distant.

├── database.tf
├── loadbalancer.tf
├── main.tf
├── terraform.tfstate
├── terraform.tfstate.backup
├── terraform.tfvars
├── variables.tf
└── webserver.tf

 

  • *.tf : les fichiers d’extension tf définissent l’infrastructure à générer. La configuration dans son ensemble peut être répartie dans plusieurs fichiers. Ces derniers seront alors lus par ordre alphabétique et analysés avant d’être appliqués, nous économisant ainsi la gestion des dépendances entre les éléments.
  • terraform.tfstate : fichier d’état de l’infrastructure. Il est généré lors de l’application des fichiers et est indispensable au bon fonctionnement de l’application. Des informations sensibles y sont présentes, il faut donc éviter de l’intégrer au gestionnaire de source.
  • terraform.tfstate.backup : version n-1 du fichier tsfate
  • terraform.tfvars : fichier de configuration. Si aucun fichier de configuration n’est spécifié, terraforn recherche alors terraform.tfvars. Ce fichier peut contenir les identifiants aux différents providers, auquel cas il ne devrait pas être enregistré dans le gestionnaire de source. Il est conseillé d’utiliser un autre fichier pour les valeurs non sensibles, variables.tf par exemple.

Les variables

Les variables sont déclarées par l’instruction variable, et peuvent prendre une valeur par défaut et une description

variable "access_key" {}
variable "secret_key" {}
variable "key_name" {}
variable "key_path" {}
variable "www_count" {
  description = "Number of web servers"
  default = "2"
}
variable "aws_region" {
  default = "eu-west-1"
}

# Using ubuntu amis
variable "aws_amis" {
  default = {
    eu-west-1 = "ami-b1cf19c6"
    us-east-1 = "ami-de7ab6b6"
    us-west-1 = "ami-3f75767a"
    us-west-2 = "ami-21f78e11"
  }
}
variable "project" {
  default = "tf-demo"
}

Il est ensuite possible de définir leur valeurs dans le fichier de configuration par défaut terraform.tfvars ou en précisant le fichier à utiliser en ligne de commande avec le paramètre -var-file. Notons ici la définition des credentials Amazon access_key et secret_key

access_key = "your_access_key"
secret_key = "your_secret_key"
key_name = "keyname"
key_path = "/path/to/the/private-key.pem"

Il est également possible de définir la valeur des variables à l’éxécution avec le paramètre -var.

L’utilisation d’une variable NOM_VARIABLE se fait directement par ${var.NOM_VARIABLE}.

# AWS provider
provider "aws" {
    access_key = "${var.access_key}"
    secret_key = "${var.secret_key}"
    region = "${var.aws_region}"
}
# Security group
resource "aws_security_group" "default" {
    name = "tf-demo-sg"
    description = "Allow ssh and web access"
    # SSH access from anywhere
    ingress {
      from_port = 22
      to_port = 22
      protocol = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
    # HTTP access from anywhere
    ingress {
      from_port = 80
      to_port = 80
      protocol = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
}
# DNS
resource "aws_route53_zone" "primary" {
   name = "tfdemo.com"
}

Le provider AWS a besoin d’être configuré avec les identifiants et la région. Cette configuration étant globale, il n’est pas possible, à ce jour, de configurer plusieurs providers du même type et donc d’utiliser différentes régions en parallèle.

La configuration d’un security group requiert les attributs name et description. Chaque règle de sécurité est définie par les attributs ingress, lesquels peuvent être répétés autant que souhaité.

Le provider AWS fournit les ressources nécessaire à la gestion des DNS en utilisant route53. Déclarons dans un premier temps la zone de notre exemple qui sera référencée par d’autres ressources.

Server web

resource "aws_instance" "www" { 
  key_name = "${var.key_name}"
  ami = "${lookup(var.aws_amis, var.aws_region)}"
  instance_type = "t1.micro"
  count = "${var.www_count}"
  tags {
    Name = "tfdemo-www-${count.index}"
    Project = "tf-demo"
  }
  
  security_groups = ["${aws_security_group.default.name}"]

 connection {
    # The default username for our AMI
    user = "ubuntu"
    # The path to your keyfile
    key_file = "${var.key_path}"
  }

  provisioner "remote-exec" {
    inline = [
    "sudo apt-get -y update",
    "sudo apt-get -y install nginx",
    "sudo service nginx start"
    ]
  }
}

resource "aws_route53_record" "tf-demo-www-record" {
  name = "${concat("www", count.index,".", aws_route53_zone.primary.name)}"
  count = "${var.www_count}"
  zone_id = "${aws_route53_zone.primary.zone_id}"
  type = "A"
  records = ["${element(aws_instance.www.*.public_ip, count.index)}"]
  ttl = "1"
}

Examinons les instructions utilisées :

resource "aws_instance" "www"

Nous demandons la création d’une instance amazon nommée "www". Le nommage des ressources respecte la format <PROVIDER>_<RESOURCE>, aws_instance est donc une resource de type instance du provider aws. Le nom de la ressource est arbitraire et servira d’identifiant pour tout autre ressource y faisant référence.

key_name = "${var.key_name}"

La paramètre key_name défini la clé ssh à importer dans l’instance. La valeur est récupérée depuis la variable également nommée key_name par simplicité. Notons que la variable est remplacée avant interpretation, les guillemets doubles sont donc ici nécessaires dans la mesure où key_name est une chaîne de caractère.

ami = "${lookup(var.aws_amis, var.aws_region)}"

Le paramètre ami représente l’id de l’AMI qui sera utilisé. Introduisons la fonction lookup qui récupère une valeur à partir de sa clé dans un dictionnaire. La valeur de l’AMI à utiliser est ici lue depuis la variable dictionnaire aws_amis à partir de la variable aws_region.

count = "${var.www_count}"

Certaines ressources acceptent un paramètre count qui renseigne le nombre d’instances à démarrer.

tags {
    Name = "tfdemo-www-${count.index}"
    Project = "tf-demo"
  }

Les tags ont réellement manqué dans les premières versions de terraform. Ils ont la même signification que les tags dans Amazon. Il est ainsi aisé de nommer son instance en utilisant le tag "Name" dont le nom est conventionnel, il est également possible de définir librement d’autres tags, tel qu’un tag "Projet" par exemple.

connection {
    # The default username for our AMI
    user = "ubuntu"
    # The path to your keyfile
    key_file = "${var.key_path}"
  }

L’instruction connection définit comment se connecter en ssh à l’instance. Ici, la connexion se fait via l’utilisateur par défaut ubuntu en utilisant la clé ssh spécifiée par keyname

 security_groups = ["${aws_security_group.default.name}"]

security_groups définit la liste des "security groups" Amazon. ${aws_security_group.default.name} fait référence à une ressource aws_security_group définie dans un autre fichier. Terraform se chargeant pour nous de résoudre les dépendances entre les ressources, nous n’avons pas à nous soucier de l’endroit où il est défini.

provisioner "remote-exec" {
    inline = [
    "sudo apt-get -y update",
    "sudo apt-get -y install nginx",
    "sudo service nginx start"
    ]
  }

Terraform aborde la notion de provisionning même si cela se cantonne aujourd’hui au transfert de fichier et l’exécution de commandes locales ou distantes. Cependant cela suffit à appeler des outils de provisionning tels que Chef, Puppet ou Ansible lesquels pourront prendre la main en utilisant les données exportées. Le provisionner remote-exec utilise la connection déclarée dans la même instance pour installer et démarrer nginx.

resource "aws_route53_record" "tf-demo-www-record" {
  name = "${concat("www", count.index,".", aws_route53_zone.primary.name)}"
  count = "${var.www_count}"
  zone_id = "${aws_route53_zone.primary.zone_id}"
  type = "A"
  records = ["${element(aws_instance.www.*.public_ip, count.index)}"]
  ttl = "1"
}

Pour chaque instance "www", nous associons une entrée DNS dans la zone "primary". La fonction concat permet de créer simplement une nomenclature, la première instance aura ainsi le nom de domaine "www0.tfdemo.com". La version 0.3.5 introduit la fonction element qui retourne le Nième élément d’une liste. Le nom de domaine fraichement créé sera alors affecté à l’ip public de l’instance par un champ de type A.

Base de données

resource "aws_instance" "db" {
  key_name = "${var.key_name}"
  ami = "${lookup(var.aws_amis, var.aws_region)}"
  instance_type = "t1.micro"
  tags {
    Name = "tf-demo-db"
    Project = "${var.project}"
  }
  security_groups = ["${aws_security_group.default.name}"]
}

resource "aws_route53_record" "tf-demo-db-record" {
  name = "${concat("db",".", aws_route53_zone.primary.name)}"
  zone_id = "${aws_route53_zone.primary.zone_id}"
  type = "A"
  records = [ "${aws_instance.db.public_ip}" ]
  ttl = "1"
}

Il n’y a qu’un seul serveur de base de données, l’attribut count est absent. Cependant, l’absence de count n’a pas exactement le même comportement que count = 1 (. En conséquence, si une instance est prévue pour être scalable, il est préférable d’utiliser le paramètre count avec la valeur 1. Notons également que count peut être nul, fournissant ainsi un levier pour désactiver, dans certain cas, tout un pan de la configuration.

Loadbalancer

# Load balancing
resource "aws_elb" "www" {
  name = "tf-demo-elb"
  # The same availability zone as our instance
  availability_zones = ["${element(aws_instance.www.*.availability_zone, count.index)}"]
  listener {
    instance_port = 80
    instance_protocol = "http"
    lb_port = 80
    lb_protocol = "http"
  }
  instances = ["${aws_instance.www.*.id}"]
}

resource "aws_route53_record" "tf-demo-lb-record" {
  name = "${concat("www",".", aws_route53_zone.primary.name)}"
  zone_id = "${aws_route53_zone.primary.zone_id}"
  type = "CNAME"
  records = [ "${aws_elb.www.dns_name}" ]
  ttl = "1"
}

La gestion du loadbalancing est assez simple, le port 80 du loadbalancer est ici redirigé sur le port 80 des membres de la resource aws_instance.www et une entrée DNS pointe vers www.tf-demo.com.

Prêt au lancement ?

L’infrastructure intégralement décrite, installons terraform. Le binaire est distribué pour chaque plateforme sous la forme d’une archive contenant le client en ligne de commande et les modules pour les providers. Pour les utilisateurs de OS X, il est disponible sur homebrew.

brew update && brew install terraform
terraform -version

La version utilisée dans cet article est la 0.3.5 et certaines ressources ne fonctionneront pas avec une version inférieure.

terrafom plan 

Avant de se lancer, prévisualisons l’impact de notre configuration : terrafom plan affiche le plan d’exécution sans l’appliquer. Cette commande montre par la suite les écarts de configuration avec l’existant.

terraform apply

La configuration peut désormais être appliquer sereinement avec terraform apply qui crée l’ensemble de la plateforme la première fois puis applique les modifications par la suite. Le fichier terraform.tfstate est alors créé. Ce fichier est primordial, il contient l’état du projet indispensable pour définir les opérations de mise à jour. Il est donc important de le stocker en dehors du projet et de s’assurer de sa synchronisation. Dans ce but, terraform propose de stocker cette état sur un serveur distant, http par exemple, en utilisant les commandes terraform push et terraform pull. Par défaut l’état est conservé sur disque, dans le répertoire du projet.

terraform destroy

Lorsque la plateforme n’est plus utile, vous pouvez la détruire avec la commande destroy. Attention néanmoins, les instances EC2 sont détruites et non suspendues, les données locales sont perdues.

Conclusion

Terraform se concentre sur un objectif bien défini, la création d’infrastructure, et le fait bien. Le choix a clairement été fait de déléguer le provisionning aux outils déjà existant. Reconnaissons cependant que l’interaction avec ces outils est pour le moment ténue, se limitant à une simple exécution de commande. L’utilisation de scripts ou d’outils tiers est encore nécessaire.

Chaque nouvelle version de Terraform apporte son lot de nouveautés et comble petit à petit les manques les plus bloquants, suffisamment du reste pour que l’ensemble soit utilisable. Les premières versions souffraient d’un état parfois instable, les dernières ont trouvé le remède. D’une part les plugins ont gagné en stabilité, d’autre part le choix d’un fichier d’état au format JSON assure de pouvoir lire et corriger un éventuel problème.

Parmi les fonctionnalités à venir, notons l’adoption d’une DSL pour les variables en remplacement de l’interpolation permettant par exemple l’utilisation de fonctions mathématiques ou l’ajout dans l’API des providers d’une méthode vérifiant l’existence à priori d’une ressource.

Les sources de l’exemple sont disponibles sur github : https://github.com/tauffredou/terraform

 

 

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *