Il existe beaucoup d’outils pour publier du contenu sur le Web. Des plus connus comme Wordpress ou Drupal, aux services SaaS clés en main comme Medium ou Blogger (pour ne citer qu’eux). Quand les uns demandent une infrastructure à déployer et à maintenir, les autres n’offrent pas forcement la flexibilité attendue.

Nous avions quelques prérequis :

  • serverless, buzzword mais surtout nous n’avions pas envie de maintenir une infrastructure
  • full static, on veut que ce soit rapide
  • épuré, mettre en avant le contenu
  • un système collaboratif avec Git

Ces attentes n’étaient qu’un bon prétexte pour utiliser Hugo. Et Hugo est devenu un autre bon prétexte pour mettre en place un workflow de déploiement avec les pipelines de Gitlab.

Sommaire

Choix techniques

En plus de Hugo, nous avons décidé d’utiliser les outils et services suivants.

AWS S3

C’est un des bénéfices d’Hugo : un site statique qui ne nécessite pas grand-chose pour être publié. Pas la peine de déployer des serveurs, un simple bucket S3 configuré pour être accessible en Web suffit à rendre notre contenu accessible.

Avantages :

  • pas d’infrastructure à maintenir => pas de Patch Management \o/
  • pas de problème d’espace disque => pas de OnCall à 3h00 !
  • pas de problème de disponibilité (quoi que ?)
  • intégration avec les autres services AWS
  • cost-effective

AWS CloudFront

Pas de raison de se passer d’un distribution CloudFront :

  • best-pratice pour toute infrastructure Web
  • permet d’être encore plus cost-effective
  • on améliore la qualité de livraison du contenu (cache, edge)
  • permet de pouvoir bénéficier des services AWS associés comme l’anti DDoS, le WAF (pas très utile dans notre cas …)
  • avoir facilement et gratuitement du HTTPS avec AWS Certificat Manager

Gitlab pipeline

Les sources sont hébergées sur notre Gitlab local. Il apparait donc évident d’utiliser les pipelines pour construire et déployer le site :

  • automatisation des tâches de déploiement
  • parfaitement intégré avec un workflow de validation (merge request)

Nous exécutons nos différentes tâches du pipeline dans des containers Docker qui sont lancés sur un cluster Kubernetes interne. Une image Hugo a été construite pour les différentes tâches. Elle est disponible sur notre Hub Docker : https://hub.docker.com/r/oxalide/hugo/.

Workflow de déploiement

Allons droit au but avec un schéma de notre Workflow de déploiement.

Worflow

Workflow Git

C’est très classique, on recherche un système collaboratif avec une notion de validation (correction des erreurs par exemple).

  • nous avons des branches pour toutes les modifications
  • la branche master est protégée et elle est utilisée pour le déploiement en production
  • un push sur l’origine déclenche un pipeline Draft qui permet de voir en condition réel (bucket+CloudFront Draft) le rendu du site
  • si l’auteur des modifications est satisfait, il propose une merge request
  • une revue des modifications est effectuée et si elles sont valides, la requête est acceptée
  • lorsque le merge sur master est lancé, un pipeline Production se déclenche pour déployer le site sur l’environnement de production (bucket+CloudFront Production)

Voici les différents stage de notre pipeline:

stages:
  - buildpp
  - testpp
  - spell
  - deploypp
  - purgepp
  - build
  - test
  - deploy
  - purge

Pipeline

Déploiement du Draft

draf:build

Nous ignorons le répertoire public/ de Hugo pour éviter de stocker plusieurs fois les mêmes fichiers (notamment statique). La première chose à faire consiste donc à build le site. Voici le job:

draft:build:
  stage: buildpp
  image: oxalide/hugo:latest
  script:
    - hugo -t $HUGO_THEME -v --baseURL="https://draft.oxalide.io/$CI_ENVIRONMENT_SLUG/" -d public/$CI_ENVIRONMENT_SLUG
    - for i in `find public/$CI_ENVIRONMENT_SLUG/ -name "*.jpg"`; do jpegoptim -s --all-progressive $i; done
    - find public/$CI_ENVIRONMENT_SLUG/ -name "*.png" | xargs optipng
    - ( cd public/$CI_ENVIRONMENT_SLUG/css/ && for i in `ls *.css`; do yui-compressor $i -o $i; done )
  except:
    - master@oxalide/blog-tech
  tags:
    - kubernetes
  artifacts:
    name: "${CI_JOB_NAME}_${CI_COMMIT_REF_NAME}"
    untracked: true
    expire_in: 15 minutes
  environment:
    name: draft/$CI_COMMIT_REF_NAME

Cette tâche effectue plusieurs opérations:

  • générer le site statique
  • optimiser les images (webperf !)
  • minifier les fichiers css (webperf !)

Vous remarquerez l’usage de la variable $CI_ENVIRONMENT_SLUG. Cela permet de construire un site spécifique pour chaque branche. Ainsi, plusieurs personnes peuvent en même temps effectuer des modifications et publier sur l’environnement Draft (la preproduction) sans que chaque déploiement écrase le précédent. Pour que cela fonctionne parfaitement, il ne faut pas oublier d’utiliser canonifyURLs: true dans la configuration de Hugo.

Cette tâche n’est jamais executée sur master. Cela évite d’avoir un environnement ‘draft/master’ qui n’aurait pas de sens.

Le contenu statique généré est partagé avec les autres tâches via un artifact Gitlab.

draft:test

Tâche très importante. C’est l’essence même d’une bonne CI/CD : faire des tests. Ce n’est pas un élément que nous avons beaucoup travaillé pour le moment mais on va dire que la base est là :) Voici notre tâche:

draft:test:
  stage: testpp
  script:
   - test -s public/$CI_ENVIRONMENT_SLUG/index.html
   - test -s public/$CI_ENVIRONMENT_SLUG/sitemap.xml
  except:
    - master@oxalide/blog-tech
  dependencies:
    - draft:build
  tags:
    - kubernetes
  environment:
    name: draft/$CI_COMMIT_REF_NAME

Cela permet juste de s’assurer que la construction du site a été possible. A terme, il faut ajouter d’autres tests. On pourrait par exemple s’assurer que l’ensemble des liens sont opérationnels (pas de 404), que le score “webperf” est celui attendu (utilisation de l’API Google PageSpeed), etc …

Comme toute bonne CI, nous ferons évoluer les tests en fonction des problèmes que nous rencontrerons.

draft:spelling

Cette tâche est aujourd’hui optionnelle mais elle permet de montrer qu’on pourrait en envisager d’autres en fonction de nos attentes. Par exemple nous pourrions faire une vérification sur l’orthographe et s’assurer qu’il n’y a pas de grosse coquille qui serait passée entre les mailles du filet des relecteurs !

draft:spelling:
  stage: spell
  image: oxalide/hunspell:latest
  script:
    - hunspell -D
    - export HUNSPELL_FINDINGS=`hunspell -l -d fr_FR,en_US -H public/$CI_ENVIRONMENT_SLUG/**/*.html | sort | uniq`
    - if [ $? != 0 ]; then exit 1; fi
    - echo $HUNSPELL_FINDINGS
    - test "$HUNSPELL_FINDINGS" == ""
  except:
    - master@oxalide/blog-tech
  allow_failure: true
  dependencies:
    - draft:build
  tags:
    - kubernetes
  environment:
    name: draft/$CI_COMMIT_REF_NAME
  when: manual

Pour ce besoin, nous avons utilisé une image de hunspell avec le dictionnaire Français que vous pouvez retrouver sur notre Hub Docker : https://hub.docker.com/r/oxalide/hunspell.

draft:deploy

Tâche indispensable pour que le contenu généré soit disponible en public : le déploiement vers S3.

draft:deploy:
  stage: deploypp
  image: oxalide/hugo:latest
  script:
   - rclone --config .rclone.conf sync public/$CI_ENVIRONMENT_SLUG s3:$S3_DRAFT/$CI_ENVIRONMENT_SLUG
  except:
    - master@oxalide/blog-tech
  dependencies:
   - draft:build
  tags:
   - kubernetes
  environment:
    name: draft/$CI_COMMIT_REF_NAME
    url: https://draft.oxalide.io/$CI_ENVIRONMENT_SLUG/
    on_stop: delete_draft

Nous avons testé plusieurs méthodes:

  • la cli AWS
  • s3deploy
  • rclone

Nous avons opté au final pour rclone qui permet d’avoir un comportement de type rsync entre le contenu généré et le bucket distant. La cli AWS ne permet pas de faire cela facilement. s3deploy est presque parfait sur le papier mais il n’est pas capable de synchroniser vers un sous répertoire. Toutefois il permet de facilement gérer les headers des objets déposés sur S3 ce qui nous manque actuellement. Nous contournons le problème avec CloudFront (qui force le cache et gzip le contenu type ‘texte’).

draft:purge

Une fois le site en ligne, si on veut constater les modifications il est nécessaire de faire les bonnes requêtes d’invalidations sur CloudFront.

draft:purge:
  stage: purgepp
  image: oxalide/hugo:latest
  script:
   - git diff --name-only origin/master | python /usr/local/bin/hugo-cf-invalidation.py $CF_DRAFT --stsrole $CF_STS --prefix "/$CI_ENVIRONMENT_SLUG"
  except:
    - master@oxalide/blog-tech
  dependencies:
   - draft:build
  tags:
   - kubernetes
  environment:
    name: draft/$CI_COMMIT_REF_NAME
    url: https://draft.oxalide.io/$CI_ENVIRONMENT_SLUG/

La difficulté de cette opération consiste à identifier les nouveaux objets générés par Hugo. Comme nous n’avons pas dans git le contenu généré, il faut se référer aux fichiers qui ont été modifiés dans le ou les commits en cours de test. On peut voir ça très facilement avec la commande suivante:

git diff --name-only origin/master

Cela fonctionne dans le cas d’une différence entre une branche X et la branche master. Nous utilisons une autre méthode pour le purge en production (voir plus bas).

Une fois la liste des fichiers modifiés obtenus, il faut encore en déduire l’URL finale:

  • nous utilisons un prefix lié à l’environnement
  • le fichier modifié dans git n’est pas directement le fichier accessible

Nous avons developpé un petit script spécifique à Hugo qui permet de “filtrer” et “formater” les URL à invalider sur CloudFront. Il est disponible sur notre compte Github : https://github.com/Oxalide/hugo-cloudfront-invalidation. Il est intégré à notre image Docker Hugo.

Spécificité liée à notre organisation, la distribution CloudFront est sur un autre compte AWS. Nous utilisons donc STS pour “assumer” un rôle distant et avoir le droit de pousser notre demande d’invalidation. Le sts:assumeRole est géré directement par le script.

À l’issue des tâches effectuées pour un push d’une branche sur l’origine, le contenu est déployé et accessible à l’auteur. Il peut ainsi valider le rendu sur un environnement ISO production. Cela permet aussi au relecteur de s’assurer que tout est valide (la fameuse bonne vielle règle des quatre yeux !).

Déploiement en production

Gitlab Env

Lorsqu’une merge request est acceptée, c’est le pipeline de production qui se lance.

Il est très semblable au précédent.

prod:build:
  stage: build
  image: oxalide/hugo:latest
  script:
   - hugo -t $HUGO_THEME -v --baseURL="https://blog.oxalide.io/"
   - for i in `find public/ -name "*.jpg"`; do jpegoptim -s --all-progressive $i; done
   - find public/ -name "*.png" | xargs optipng
   - ( cd public/css/ && for i in `ls *.css`; do yui-compressor $i -o $i; done )
  only:
   - master@oxalide/blog-tech
  tags:
   - kubernetes
  artifacts:
   name: "Prod_${CI_JOB_NAME}_${CI_COMMIT_REF_NAME}"
   untracked: true
   expire_in: 60 minutes
  environment:
    name: production

prod:test:
  stage: test
  script:
   - test -s public/index.html
   - test -s public/sitemap.xml
  only:
    - master@oxalide/blog-tech
  dependencies:
    - prod:build
  tags:
    - kubernetes
  environment:
    name: production

prod:deploy:
  stage: deploy
  image: oxalide/hugo:latest
  script:
   - rclone --config .rclone.conf sync public/ s3:$S3_PROD/
  only:
   - master@oxalide/blog-tech
  tags:
   - kubernetes
  dependencies:
   - prod:build
  environment:
   name: production
   url: https://blog.oxalide.io

La seule différence notable se situe sur la tâche de purge CloudFront. En effet, la précédente commande git utilisée ne permet pas de déterminer les fichiers modifiés.

Pour y arriver, il faut d’abord regarder les modifications qui sont contenus dans le merge (le commit sur lequel le pipeline s’execute).

$ git show 3d005e2
commit 3d005e26530e13ff26d962181d623aa181447dcb
Merge: a9ae7fe d864ede
Author: Guillaume Leccese <xxxxxxxx>
Date:   Tue May 23 16:36:16 2017 +0200

    Merge branch 'master' into xxxxxx

Nous obtenons les deux “bornes” contenus dans ce commit (le merge). Il suffit de demander à git la liste des modifications effectuées entre ces deux commits:

$ git diff a9ae7fe..d864ede --name-only
.gitlab-ci.yml
README.md
config.toml
content/post/community-events-170522.md
content/post/gitlab-grand-master-plan.md
static/post/community-events-170522/season-1985856_640.jpg

Et voilà ! Ce qui donne pour notre tâche de purge :

prod:purge:
  stage: purge
  image: oxalide/hugo:latest
  script:
   - git diff --name-only `git show $CI_COMMIT_SHA | grep "^Merge:" | awk '{ print $2".."$3 }'` | python /usr/local/bin/hugo-cf-invalidation.py $CF_PROD --stsrole $CF_STS
  only:
   - master@oxalide/blog-tech
  dependencies:
   - prod:deploy
  tags:
   - kubernetes
  environment:
    name: production

Gestion des variables et des secrets

En plus des variables que nous utilisons dans les diffétents jobs, nous avons aussi besoin des credientials AWS.

Il est de bon goût de ne pas utiliser .gitlab-ci.yml avec les secrets en clair.

Gitlab Secret

On va aussi éviter de les enregistrer dans notre repository Git. La solution idéale serait d’utiliser Vault de Hashicorp. Mais pour le moment ce n’est pas encore le cas. Nous utilisons donc la gestion des secrets de Gitlab qui est actuellement le meilleur compromis.

Pour configurer vos variables “secretes”, il faut aller dans Settings -> CI/CD Pipelines de votre projet.

Ce qu’on pourrait faire de plus

Comme déjà évoqué lors du choix de l’outil pour synchroniser vers S3, il nous manque une gestion des headers lorsqu’on synchonise le contenu statique. Dans l’idéal, il faudrait:

  • gziper les fichiers texte (css, html, js, etc …)
  • positionner le bon encodage (gzip)
  • positionner les headers de cache pour piloter CloudFront

De plus, comme tout blog qui se respecte, il faudrait utiliser les réseaus sociaux pour gagner en visibilité et donc publier automatiquement sur Twitter, par exemple, le lien vers les nouveaux articles.

Enfin, pour aller plus loin dans les interactions entre S3 et CloudFront il faudrait limiter l’accès à nos buckets à CloudFront uniquement.

Le mot de la fin

On pourrait dire que nous avons utilisé un bazooka pour tuer une mouche. Ce n’est pas faux. Nous aurions pu faire plus simple. Toutefois ce Workflow permet d’assurer un déploiement conforme. Il rend les opérations très simples vu que tout est automatisé même la purge du CDN.

Il ne faut pas non plus bouder le plaisir du travail bien fait et d’une chaine de CI/CD qui nous simplifie la vie.

Le seul bèmole que nous avons pour le moment identifié est l’accessibilité du processus. Nous souhaitons au travers de ce modeste blog partager nos expériences y compris celles qui sont moins techniques. Devoir passer par du Markdown, du Git, etc. limite un peu l’accessibilité de nos collègues moins techniques.