Aujourd’hui, on va discuter d’un outil que j’adore utiliser quotidiennement : Gitlab. En quelque années, il est devenu plus qu’un simple dépôts Git mais un véritable arsenal de technologies DevOps. On va ici s’intéresser plus particulièrement à sa pipeline CI/CD mais on va utiliser d’autres fonctionnalités comme son registre d’images Docker ou son backend de fichier d’états Terraform. Le projet est assez simple : déployer et provisionner une VM GCloud Engine avec Terraform et Ansible automatiquement à partir d’un simple commit Git.
Architecture du projet
Le dossier du projet va s’architecturer ainsi :
- Un dossier ansible comportant un playbook nommé main.yml. Vous pouvez aussi ajouter des rôles si vous le souhaitez
- Un dossier terraform contenant tous les fichiers Terraform nécessaires pour créer notre machine virtuelle : main.tf, outputs.tf, backend.tf, variables.tf et terraform.tfvars
- Un fichier .gitlab-ci.yml définissant toutes les étapes de notre pipeline CI/CD pour arriver à notre fin
- Un Dockerfile permettant de construire une image personnalisée Ansible
- Un fichier .gitignore pour ne pas pas commiter les fichiers d’états de Terraform ainsi que le fichier terraform.tfvars
Configuration de Terraform et d’Ansible
Le fichier main.tf contient une configuration assez basique pour déployer une VM sur Google Cloud. Beaucoup de valeurs sont variabilisées pour permettre une flexibilité lors de l’exécution de la pipeline :
variable "project" { }
variable "credentials_file" { }
variable "gcp_user" { }
variable "ssh_public_key" { }
variable "vm_name" { default = "virtualmachine" }
variable "vm_type" { default = "f1-micro" }
variable "region" { default = "europe-west1"}
variable "zone" { default = "europe-west1-b"}
variable "image" { default = "ubuntu-os-cloud/ubuntu-2004-focal-v20210623" }
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "3.5.0"
}
}
}
provider "google" {
credentials = file(var.credentials_file)
project = var.project
region = var.region
zone = var.zone
}
resource "google_compute_instance" "vm_instance" {
name = var.vm_name
machine_type = var.vm_type
tags = ["http-server"]
metadata_startup_script = "sudo apt-get update ; sudo apt-get install -yq python3"
metadata = {
ssh-keys = "${var.gcp_user}:${file(var.ssh_public_key)}"
}
boot_disk {
initialize_params {
image = var.image
}
}
network_interface {
network = "default"
access_config {
}
}
}
Ligne 32 : On installe au démarrage de la machine python3 pour que Ansible puisse effectuer ses actions sans problèmes
Ligne 34-36 : On importe une clé publique pour que Ansible puisse se connecter sans mot de passe à la machine virtuelle. A noter que la valeur de la variable gcp_user doit être votre nom d’utilisateur du compte Google.
Le fichier outputs.tf doit définir une variable de sortie qui sera l’IP publique de la machine. On utilisera cette variable pour préciser à Ansible sur quel hôte il doit exécuter son playbook.
output "ip" {
value = google_compute_instance.vm_instance.network_interface.0.access_config.0.nat_ip
}
Le fichier backend.tf crée un backend http pour Terraform :
terraform {
backend "http" {
}
}
Pour Ansible, c’est encore plus simple ! Je crée juste un playbook qui va afficher des variables appelées ansible_facts qu’Ansibe va récupérer lors de sa phase de récolte en se connectant à l’hôte .
- hosts: all
become: true
tasks:
- name: Test
debug:
msg: "{{ ansible_facts }}"
Création de l’image Docker Ansible
Les pipelines CI/CD sur les runners publics de gitlab.com utilisent des conteneurs Docker pour exécuter les commandes que l’on souhaite, il faut donc une image Terraform et une image Ansible. Pour la première, il en existe une fournie par Gitlab mais, bien étonnant qu’il puisse paraître, il n’existe aucune image officielle Docker pour Ansible. On aura donc besoin d’en créer exécuter notre playbook.
J’ai décidé de partir sur une archlinux en tant qu’image de base car mon ordinateur est sous Manjaro et je ne veux pas avoir de problèmes de compatibilité avec les versions d’Ansible. Mais, il est tout à fait possible de partir sur une autre base, il existe pleins d’exemples sur Internet.
En soit, le Dockerfile ne contient rien de bien folichon : j’installe les paquets nécessaires à Ansible, la librairie jumit_xml (vous verrez pourquoi plus tard), je désactive le host checking pour SSH et je crée un utilisateur générique qui exécutera le playbook. J’aurais pu rajouter des collections ou des modules Ansible ainsi que des dépendances en python si j’en avais eu le besoin.
FROM archlinux:latest
RUN pacman -Syu --noconfirm \
git \
ansible \
sshpass \
python3 \
python-pip
RUN pip install junit_xml
# Disable strict host checking
ENV ANSIBLE_HOST_KEY_CHECKING=False
WORKDIR /ansible
RUN useradd -ms /bin/bash ansible
USER ansible
Dans notre pipeline, nous allons construire automatiquement cette image, la déposer dans le registre intégré sur Gitlab puis l’utiliser dans les étapes ultérieures.
Pipeline Gitlab CI
Là on rentre dans le vif du sujet ! J’ai pris comme exemple le modèle que Gitlab fourni pour une pipeline Terraform disponible ici. A partir de cette exemple, j’ai enlevé le job effectuant un terraform destroy et j’ai mis le job « build » en automatique au lieu de manuel.
J’ai défini les étapes de ma pipeline comme ceci :
- Preparation: Étape de préparation pour le build qui va contenir les jobs initialisant l’espace de travail Terraform et construisant l’image Docker Ansible.
- Validation: Étape de validation effectuant des tests syntaxiques sur les fichiers Terraform et Ansible.
- Building: Étape effectuant un « terraform plan » pour créer l’infrastructure.
- Deploying: Étape construisant l’infrastructure et récupérant l’adresse IP de la machine.
- Provisioning: Étape exécutant le playbook Ansible.
J’ai utilisé le mot clé « needs » de Gitlab pour lui dire quelles tâches dépendent des autres. Cela permet de lancer par exemple le job ‘plan‘ après le job ‘validate‘ sans attendre que le job ‘lint‘ (le lint d’Ansible). Avec ce réagencement, le pipeline peut s’exécuter beaucoup plus rapidement.
De plus, dans le même but d’optimisation, j’ai indiqué à Gitlab de lancer le job ‘docker-build‘ uniquement si le fichier Dockerfile a été modifié :
build-image:
stage: preparation
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build --pull -t "$CI_REGISTRY_IMAGE" .
- docker push "$CI_REGISTRY_IMAGE"
rules:
- changes:
- Dockerfile
Dans cette pipeline, j’ai aussi utilisé les nouvelles fonctionnalités de Gitlab qui peut maintenant enregistré l’état de l’infrastructure Terraform pour avoir des builds consistents. Pour ce faire, il faut uniquement rajouter une variable TF_ADDRESS indiquant l’adresse du backend Terraform contenant l’état de l’infrastructure :
variables:
... #Autres variables
TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_PROJECT_NAME}
Pour permettre de passer la variable contenant l’adresse IP de la machine virtuelle créée dans le job ‘apply’ au job exécutant le playbook Ansible, il faut utiliser un report « dotenv » dans le partie « artifacts » de notre job :
apply:
stage: deploying
before_script:
- cd ${TF_ROOT}
script:
- gitlab-terraform apply
- echo "ANSIBLE_HOST=$(gitlab-terraform output ip | tr -d '\"')" > $CI_PROJECT_DIR/terraform.env
environment:
name: production
dependencies:
- plan
needs:
- plan
artifacts:
reports:
dotenv: terraform.env
Tous les jobs exécutés après celui pourront utiliser la variable d’environnement ANSIBLE_HOST.
Et enfin, je me suis servi de la fonctionnalité de Gitlab qui permet de créer des rapports de test Junit dans son interface. Pour cela, j’ai utilisé le module junit qui permet d’exporter les logs Ansible dans le bon format d’où le fait d’installer la librairie junit_xml. Ensuite, il faut définir les variables d’environnement ANSIBLE_STDOUT_CALLBACK et JUNIT_OUTPUT_DIR avant d’appeler le playbook. Dans la partie « artifacts », on précise le chemin des fichiers résultats au format XML.
run-playbook:
stage: provisioning
image: "$CI_REGISTRY_IMAGE"
before_script:
- cd ${ANSIBLE_ROOT}
script:
- ANSIBLE_STDOUT_CALLBACK=junit JUNIT_OUTPUT_DIR="${CI_PROJECT_DIR}/results" ansible-playbook -i $ANSIBLE_HOST, -u $TF_VAR_gcp_user main.yml -e ansible_ssh_private_key_file=${SSH_PRIVATE_KEY}
environment:
name: production
needs:
- linting
- apply
artifacts:
when: always
paths:
- results/*.xml
reports:
junit: results/*.xml
Voici le contenu final du fichier .gitlab-ci.yml:
image: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
variables:
TF_ROOT: ${CI_PROJECT_DIR}/terraform
ANSIBLE_ROOT: ${CI_PROJECT_DIR}/ansible
TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_PROJECT_NAME}
TF_VAR_vm_name: ${CI_PROJECT_NAME}-${CI_COMMIT_SHORT_SHA}
cache:
key: example-production
paths:
- ${TF_ROOT}/.terraform
stages:
- preparation
- validation
- building
- deploying
- provisioning
- cleanup
init:
stage: preparation
before_script:
- cd ${TF_ROOT}
script:
- gitlab-terraform init
build-image:
stage: preparation
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build --pull -t "$CI_REGISTRY_IMAGE" .
- docker push "$CI_REGISTRY_IMAGE"
rules:
- changes:
- Dockerfile
validate:
stage: validation
before_script:
- cd ${TF_ROOT}
script:
- gitlab-terraform init
- gitlab-terraform validate
needs:
- init
lint:
stage: validation
before_script:
- cd ${ANSIBLE_ROOT}
image: quay.io/ansible/toolset
script:
- ansible-lint
plan:
stage: building
before_script:
- cd ${TF_ROOT}
script:
- gitlab-terraform plan
- gitlab-terraform plan-json
needs:
- validate
artifacts:
name: plan
paths:
- ${TF_ROOT}/plan.cache
reports:
terraform: ${TF_ROOT}/plan.json
# Separate apply job for manual launching Terraform as it can be destructive
# action.
apply:
stage: deploying
before_script:
- cd ${TF_ROOT}
script:
- gitlab-terraform apply
- echo "ANSIBLE_HOST=$(gitlab-terraform output ip | tr -d '\"')" > $CI_PROJECT_DIR/terraform.env
environment:
name: production
dependencies:
- plan
needs:
- plan
artifacts:
reports:
dotenv: terraform.env
run-playbook:
stage: provisioning
image: "$CI_REGISTRY_IMAGE"
before_script:
- cd ${ANSIBLE_ROOT}
script:
- ANSIBLE_STDOUT_CALLBACK=junit JUNIT_OUTPUT_DIR="${CI_PROJECT_DIR}/results" ansible-playbook -i $ANSIBLE_HOST, -u $TF_VAR_gcp_user main.yml -e ansible_ssh_private_key_file=${SSH_PRIVATE_KEY}
environment:
name: production
needs:
- linting
- apply
artifacts:
when: always
paths:
- results/*.xml
reports:
junit: results/*.xml
L’heure du déploiement
Avant toute chose, générerons une paire de clé SSH pour que Ansible puisse se connecter à la VM GCloud avec la commande suivante :
ssh-keygen -t rsa -C « your_email@example.com » -f ~/.ssh/gitlab-ci-gcloud
De plus, sur Gitlab, il faut renseigner les variables d’environnement de notre pipeline CI/CD. J’ai utilisé la syntaxe TF_VAR_{var} pour déclarer les variables Terraform ; elles seront alors prises en compte à l’exécution. N’oubliez pas de bien spécifier le type Fichier pour les clés SSH et le fichier de credentials Gcloud :
On peut maintenant pusher notre code et voir la pipeline s’exécuter automatiquement ! Sur notre Gcloud, on peut voir la machine virtuelle se créer avec le nom du projet. On peut naviguer dans plusieurs menus sur Gitlab pour voir les actions qui ont été effectuées.
Menu Terraform dans l’onglet Infrastructure
On peut télécharger l’état de notre infrastructure Terraform ou même la bloquer pour éviter qu’elle soit modifiée :
Tests Junit dans notre pipeline
On peut consulter toutes les actions que notre playbook Ansible a effectuées, leur status et les logs détaillés :