Paul Brissaud

Déployer une VM GCloud avec Gitlab CI

Devops

#Gitlab#Ansible#Docker#Terraform

Source photo : Unsplash

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
Architecture des fichiers du projet
Architecture des fichiers du projet

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.

Les dépendances entre tous les jobs de la pipeline CI/CD
Les dépendances entre tous les jobs de la pipeline CI/CD

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 :

Les variables Gitlab de notre pipeline
Les variables Gitlab de notre pipeline

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.

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 :

 

https://storage.googleapis.com/paulbrissaud-blog-images/deployer-une-vm-gcloud-avec-gitlab-ci/gitlab-terraform.png