Pendant la conférence Cloud Nord en novembre 2020, j’ai découvert le dernier né du monde de la CI/CD : GitHub Actions. Le but de cet article est de présenter l’outil via un exemple concret de déploiement d’une application Java sur AWS dans un monorepo qui contiendra :
- une application Java Hello World déployée dans un container Docker ;
- le code Terraform pour la gestion de l’infrastructure sur AWS ;
- les fichiers de configuration GitHub Actions permettant de garantir la qualité du code, de lancer les tests et d’orchestrer le déploiement.
Création d’un premier workflow pour créer l’infrastructure sur AWS
L’ensemble du code présenté ci-dessous se trouve sur ce repository GitHub.
Le premier module de notre monorepo se prénomme base-infrastructure
. Il contient le code Terraform décrivant notre infrastructure de base : un Virtual Private Cloud (VPC), un Application Load Balancer (ALB) et un cluster Elastic Container Service (ECS) Fargate.
Le workflow, c’est le point d’entrée dans GitHub Actions. C’est une procédure automatisée qui définit plusieurs étapes. Un workflow doit être mis dans le dossier .github/workflows/
d’un projet. Un projet peut avoir plusieurs workflows. Ces derniers seront lancés en parallèle.
Commençons donc par créer un premier workflow base-infrastructure.yml
:
name: base-infrastructure
on:
push:
branches:
- main
pull_request:
defaults:
run:
shell: bash
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Après avoir nommé notre workflow, nous définissons sur quels évènements (events) il se déclenche. Ils peuvent être :
- liés au cycle de vie du code : push, création d’une release, création/fermeture d’une Pull Request (PR), création d’un commentaire sur une PR, ajout d’un label...
- liés au cycle de vie d’un projet GitHub : création d’une issue, ajout d’un commentaire sur une issue, création d’un label, création d’un projet GitHub…
- programmés via un cron.
La liste de ces événements est présente sur la documentation. Dans cet exemple, le workflow se déclenche sur deux choses :
- un push sur la branche main
- n’importe quel event de type pull_request
On définit ensuite des attributs globaux à l’ensemble du workflow, on indique le shell par défaut à utiliser dans les jobs. Un job est un ensemble d’étapes (steps) qui sont lancées sur un même runner (machine virtuelle).
On utilise également la directive env
pour injecter des variables d’environnement. On y insère ici des secrets du repository préalablement enregistrés dans les paramètres du projet GitHub, à savoir les credentials AWS. Pour accéder à des informations du contexte GitHub de votre workflow, il faut utiliser une syntaxe spécifique pour évaluer des expressions, à savoir ${{ <expression> }}
(les noms des contextes sont nombreux, n’hésitez pas à aller les consulter).
Passons maintenant au premier job :
jobs:
terraform:
defaults:
run:
working-directory: base-infrastructure/.cloud/terraform
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v2
- name: Setup Terraform
id: setup
uses: hashicorp/setup-terraform@v1
with:
terraform_version: 0.14.7
terraform_wrapper: false
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
- name: Terraform init without backend
id: init-without-backend
run: terraform init -backend=false
- name: Terraform validate
id: validate
run: terraform validate
Ce premier job s’appelle terraform
, il sera lancé sur un runner Ubuntu avec la version la plus récente et le dossier de travail par défaut est base-infrastructure/.cloud/terraform
. Arrivent ensuite les différentes étapes du job.
C’est une bonne pratique de spécifier un name et un id pour chacune des étapes (steps) d’un job. Le name permettra de faciliter la lecture dans la sortie console de GitHub Actions et l’id permettra d’identifier un step de façon unique.
Deux des steps font appel à des actions et les trois autres lancent des commandes Terraform via le shell de l’Operating System (OS) du runner.
Les actions sont des modules externes (publiés sur le GitHub Marketplace) qui peuvent être de différents types : Docker, JavaScript ou encore Composite run steps. Nous ne rentrerons pas dans les détails de tous les types d’action, la documentation GitHub le fait très bien. Les deux actions que nous appelons ici sont des actions JavaScript. La première action (uses: actions/checkout@v2
) va permettre de récupérer le code du projet et la seconde (uses: hashicorp/setup-terraform@v1
) va configurer Terraform à l’endroit où s’exécute notre job (cela peut être le host GitHub Actions ou bien un container Docker).
Continuons avec les étapes suivantes :
- name: Terraform init
id: init
run: terraform init
- name: Terraform plan
id: plan
run: terraform plan -no-color -out=tfplan.out
- name: Terraform apply
id: apply
if: github.ref == 'refs/heads/main'
run: terraform apply -input=false tfplan.out
continue-on-error: true
L’étape apply
nous amène la directive if: github.ref == 'refs/heads/main'
. Elle permet de lancer une étape sur une condition donnée. Ici, on limite les terraform apply
à la branche git main
.
La directive continue-on-error: true
indique que l’étape peut être en échec sans mettre l’ensemble du workflow en échec.
Passons aux étapes suivantes :
- name: Create a new GitHub issue if the apply failed
id: create-github-issue-if-apply-failed
uses: actions/github-script@v3
if: github.ref == 'refs/heads/main' && steps.apply.outcome == 'failure'
env:
PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform plan which provoked a Terraform apply failure 📖 \`${{ steps.plan.outcome }}\`
<details><summary>Show Plan</summary>\n
\`\`\`${process.env.PLAN}\`\`\`
</details>\n
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
github.issues.create({
title: 'Terraform apply failed on main branch',
owner: context.repo.owner,
repo: context.repo.repo,
body: output,
labels: ['Triage', 'Bug']
})
- name: Terraform apply status
id: apply-status
if: github.ref == 'refs/heads/main' && steps.apply.outcome == 'failure'
run: exit 1
L’étape create-github-issue-if-apply-failed
est un cas d’utilisation intéressant d'interaction avec l’API GitHub. Cette étape est déclenchée si et seulement si on est sur la branche main
et si l’étape apply
précédemment jouée a échoué. Pour cela, on fait appel à l’action actions/github-script@v3
qui prend en paramètres le token GitHub ainsi qu’un script écrit en JavaScript (qui a dit que les consultants pratiquant le DevOps ne faisaient que du YAML ?) afin de créer une issue GitHub contenant le plan Terraform ayant fait échouer le workflow de la branche main. Enfin, l’étape apply-status
permet de faire échouer le workflow dans le cas où l’étape apply
aurait échoué.
Et voilà le résultat du workflow dans GitHub Actions :
Création de notre première action
Le second module du monorepo s’appelle gateway
. Il contient le code Java de notre application Hello World et deux autres sous-modules avec du code Terraform. Le premier sert à créer un repository Elastic Container Registry (ECR) pour héberger l’image Docker de l’application Java. Le second permet de déployer cette même image sur le cluster ECS Fargate.
Le premier workflow GitHub présenté précédemment contenait des tâches assez génériques pour valider et appliquer du code Terraform. Comment pourrions-nous rendre ce code réutilisable pour l’utiliser dans un nouveau workflow pour le second module gateway
? Créons une action de type Composite run steps pour résoudre cette problématique.
Comme nous ne souhaitons pas dans un premier temps partager cette action à la communauté, nous allons placer cette dernière dans le dossier .github/actions/
du repository de code. Ceci étant, si vous souhaitez partager votre action à la communauté, c’est tout à fait possible. Il faudra créer un nouveau repository sur GitHub afin de pouvoir versionner votre action.
Voici notre première action .github/actions/terraform-workflow/action.yml
:
name: 'Terraform workflow'
author: Timothée Aufort
description: 'A common workflow of Terraform commands'
inputs:
working-directory:
description: 'Working directory for this action'
required: false
default: '.'
apply:
description: 'Do you want to apply the Terraform plan at the end of the process?'
required: false
default: 'true'
runs:
using: "composite"
steps:
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Terraform init without backend
id: init-without-backend
run: terraform init -backend=false
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Terraform validate
id: validate
run: terraform validate
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Terraform init
id: init
run: terraform init
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Terraform plan
id: plan
run: terraform plan -no-color -out=tfplan.out
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Terraform apply
id: apply
run: |
if [ ${{ inputs.apply }} = "true" ]; then
terraform apply -input=false tfplan.out
else
echo 'User chose not to apply the Terraform plan stored in tfplan.out'
fi
shell: bash
working-directory: ${{ inputs.working-directory }}
Cette action est composée de plusieurs steps en grande partie repris de notre premier workflow. Le dernier step qui permet de lancer un terraform apply
diffère un peu, on a rendu cette étape conditionnelle grâce à un if...then...else
en bash.
Les actions peuvent avoir plusieurs inputs et outputs dans leur définition.
Notre action possède 2 inputs optionnels : working-directory
et apply
. Le premier permet de définir le dossier de travail dans lequel chaque step sera exécuté et le deuxième permet de rendre l’apply Terraform optionnel.
Les inputs des actions sont nécessairement de type string au moment de l’écriture de cet article.
Ce type d’action a, à mon sens, plusieurs défauts à l’heure actuelle :
- les steps de l’action ne peuvent pas utiliser les mêmes directives GitHub Actions que les steps d’un workflow. On ne pourra pas par exemple utiliser la directive
if
ou encorecontinue-on-error
comme dans notre premier workflow. De la même façon, on ne peut pas appeler une autre action avec la directiveuses
; - on est obligé de spécifier la directive
shell
à chaquestep
, ce qui est dommage car le shell à utiliser peut être défini dans le workflow appelant l’action et pourrait donc être implicitement utilisé ; - on ne peut pas définir un working-directory pour l’ensemble de l’action. Par défaut, chaque step s’exécutera à la racine de notre projet malgré le fait que le workflow appelant l’action définisse un espace de travail différent. On est donc obligé de surcharger ce paramètre pour chaque step.
Gestion du cycle de vie de notre application
Il est temps d’utiliser notre action dans un nouveau workflow .github/workflows/gateway.yml
et dans un premier job gateway-ecr
qui nous permettra de créer le repository ECR sur AWS :
jobs:
gateway-ecr:
defaults:
run:
working-directory: gateway/.cloud/terraform/10_ecr
runs-on: ubuntu-latest
outputs:
gateway-ecr-repository-url-output: ${{ steps.terraform-output.outputs.gateway-ecr-repository-url }}
steps:
...
- name: Run Terraform workflow
uses: ./.github/actions/terraform-workflow
with:
working-directory: 'gateway/.cloud/terraform/10_ecr'
- name: Store ECR repository URL in an output variable
id: terraform-output
run: echo "::set-output name=gateway-ecr-repository-url::$(terraform output gateway_ecr_repository_url)"
Le step terraform-output
permet d’exporter l’URL du repository Docker ECR qui sera utilisé dans le job suivant où l’on va construire l’image de notre application Java. On utilise pour cela la commande de workflow set-output
qui s’utilise comme suit :
::set-output name={name}::{value}
Les commandes de workflow sont nombreuses et sont décrites ici.
On déclare en outputs du job un paramètre qu’on appelle gateway-ecr-repository-url-output
qui prend sa valeur depuis le dernier step du job.
On arrive maintenant au job gateway-build
suivant qui va gérer le cycle de vie de notre application Java :
gateway-build:
defaults:
run:
working-directory: gateway
runs-on: ubuntu-latest
needs: gateway-ecr
container:
image: taufort/infrastructure-ubuntu-docker:latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v2
- name: Set environment variables
id: set-env-variables
run: |
echo "GATEWAY_ECR_REPOSITORY_URL=${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}}" >> $GITHUB_ENV
echo "ECR_REGISTRY_URI=$(echo ${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}} | cut -d/ -f1)" >> $GITHUB_ENV
- name: Print env variables
id: print-env-variables
run: |
echo GATEWAY_ECR_REPOSITORY_URL=${{ env.GATEWAY_ECR_REPOSITORY_URL }}
echo ECR_REGISTRY_URI=${{ env.ECR_REGISTRY_URI }}
- name: Run maven clean verify
id: maven-clean-verify
run: ./mvnw clean verify
- name: Build docker image
id: maven-build-image
run: ./mvnw -DskipTests spring-boot:build-image
- name: Login to ECR
id: ecr-login
run: aws ecr get-login-password --region eu-west-3 | docker login --username AWS --password-stdin ${ECR_REGISTRY_URI}
- name: Push the gateway docker image
id: docker-push
run: docker push ${GATEWAY_ECR_REPOSITORY_URL}:latest
On utilise la directive needs: gateway-ecr
pour indiquer que ce nouveau job dépend du job précédent, cela permet d’ordonner l’exécution des jobs.
A la différence des jobs précédents, celui-ci utilise la directive container
qui permet d’indiquer à GitHub Actions qu’on souhaite lancer les steps dans un container Docker dédié. J’ai utilisé ici l’une de mes images image: taufort/infrastructure-ubuntu-docker:latest
contenant plusieurs binaires utiles à l’exécution de ce job dont le CLI AWS, le CLI Docker ainsi que AdoptOpenJDK 15.
Si on ne spécifie pas de container
dans un job, les steps s'exécutent par défaut sur la machine hôte directement.
Le step set-env-variables
est intéressant car on utilise une nouvelle commande de workflow pour définir deux nouvelles variables d’environnement à partir d’un output du job précédent :
echo "GATEWAY_ECR_REPOSITORY_URL=${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}}" >> $GITHUB_ENV
On peut ensuite accéder à la variable d’environnement de plusieurs façons :
- via le contexte
env
fourni par GitHub Actions :${{ env.GATEWAY_ECR_REPOSITORY_URL }}
- ou bien directement via une variable shell :
${GATEWAY_ECR_REPOSITORY_URL}
L’image Docker de notre application Java est construite grâce à Buildpacks via le plugin Maven de Spring Boot spring-boot-maven-plugin
où on référence justement cette variable d’environnement :
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>${env.GATEWAY_ECR_REPOSITORY_URL}</name>
</image>
</configuration>
</plugin>
</plugins>
</build>
Le dernier job du workflow gateway
est assez simple et va se charger de déployer l’image Docker précédemment créée sur le cluster ECS Fargate via un service ECS :
gateway-deploy:
defaults:
run:
working-directory: gateway/.cloud/terraform/20_ecs
runs-on: ubuntu-latest
needs: gateway-build
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v2
- name: Setup Terraform
id: setup
uses: hashicorp/setup-terraform@v1
with:
terraform_version: 0.14.7
terraform_wrapper: false
- name: Run Terraform workflow
uses: ./.github/actions/terraform-workflow
with:
working-directory: 'gateway/.cloud/terraform/20_ecs'
Voilà le résultat de nos deux workflows et de leur 4 jobs dans une Pull Request (PR) :
Notre application a été déployée sur AWS avec succès :
Que retenir ?
GitHub Actions existe depuis fin 2019 et offre un beau lot de fonctionnalités pour un acteur si jeune dans le monde de la CI/CD. Il a l’avantage de s’intégrer très facilement avec GitHub si vous avez pour habitude d’utiliser ce gestionnaire de sources. Les actions sont un moyen simple et élégant de rendre du code réutilisable dans les workflows, quel que soit leur type (Docker, JavaScript ou Composite). Ce système souffre malgré tout de quelques limitations comme nous avons pu le voir avec le type d’action Composite. GitHub Actions est parfaitement adapté à la gestion des monorepos, contrairement à d’autres outils concurrents avec lesquels la mise en place est un peu plus compliquée voir impossible (coucou GitLab CI et Jenkins). On regrettera l’absence de possibilité d’ordonner les workflows à notre bon vouloir ou encore l’absence d’export d’artifacts qu’on pourrait se passer d’un job à l’autre. Malgré tout, GitHub Actions est un outil qui m’a semblé solide et prometteur et qui pourrait rapidement devenir l’un des référents dans le domaine de la CI/CD.