Paul Andrieux, 30/07/2018

Votre workflow sous stéroïdes avec GitLab CI

16 minutes de lecture

Savez-vous pourquoi un système d'intégration continue est un élément primordial pour un bon workflow ? Connaissez-vous plus particulièrement les possibilités qu'offre Gitlab ? Quelle utilisation peut-on faire d'un CI lorsque l'on bosse avec Symfony et en quoi peut-il nous permettre de franchir un cap en productivité et en qualité ?

Revenons déjà sur ce qu'est l'intégration.

Historiquement, lorsque l’on conçoit une solution, il y a une première étape de conception, où l’on s’efforce de créer la solution qui correspond à la demande.

Après cette phase, qui peut être plus ou moins longue, arrive le moment où tout est prêt, et où il faut rassembler le travail de chacun et connecter la solution au reste du SI. Et c’est le moment de la roulette russe. Soit tout se passe bien et l’intégration va très vite, soit les soucis s'enchaînent et l’intégration peut prendre plus longtemps que la conception de la solution, et même échouer lamentablement.

Phase d'intégration - allégorie

Dans les années 90, un nouveau paradigme pour le développement est apparu : l’eXtreme Programming. Parmi les différents outils, XP a apporté l’intégration continue pour pallier à ce type de problématiques. Avec cette pratique, il n’est plus question d’attendre la fin du projet pour vérifier sa conformité avec le reste du SI.

Le principe est de d’augmenter la fréquence des tâches d’intégration, afin de les réaliser au moindre changement du code source. Pour cela, il faut :

  • utiliser un outil de contrôle de version
  • automatiser le processus de build
  • rédiger des tests automatisés
  • effectuer les contrôles qualité de la base de code
  • non régression

De plus, le fait d’exécuter les tests de façon automatisée à chaque changement de la base de code permet de détecter les éventuelles régressions, qui étaient une des principales pertes de temps dans un projet informatique. Pour nous aider à automatiser ces tâches, bon nombre d’outils sont apparus, dont Gitlab. Voici les différentes phases que nous avons connu auparavant chez Troopers :

  • 2012 : Bitbucket (projets privés gratis, pas de CI)
  • mid-2012 : ajout de Jenkins (première version : UI dégueu et UX pas top, implémentation de CS et PHPunit)
  • 2014 : Stash (Bitbucket auto-hébergé)
  • mid-2014 : ajout de Bamboo, le CI propulsé par Atlassian (ajout du support des tests Behat)
  • mid-2014 : projets open source sur Github, setup de CircleCI qui permet la parallélisation des tests
  • 2016 : setup de Gitlab CE en auto-hébergé :hearts:

Attardons-nous sur Gitlab.

Gitlab est principalement un outil de contrôle des sources basé sur git. Il permet de collaborer plus efficacement avec le système de Merge Request (équivalent des PR sur GitHub). Pourquoi Gitlab ? En plus des fonctionnalités standards de ce type d’outil, les raisons qui nous ont fait adopter Gitlab sont :

  • le fait que l’on puisse auto-héberger la version CE ;
  • le fait que l’outil soit complètement open source ;
  • l’intégration des process DevOps, notamment au niveau de l’intégration continue.

En effet, là où l’intégration continue passe par des services tiers sur GitHub, Gitlab propose tous les outils pour automatiser cette partie via GitlabCI, avec la notion de pipeline.

Le concept CI de Gitlab est architecturé autour de jobs, qui sont des tâches uniques comme la création d’un build ou l’exécution de tests. Il est possible d’agglomérer des jobs en stages, qui sont un ensemble de jobs qui seront exécutés en parallèle. Une pipeline désigne en fait un ensemble de stages.

A quoi ressemble une pipeline "standard" ? En général on a une première stage de build/install, puis une de test et une dernière de deploy. Ici, par exemple, on a une pipeline basique pour un projet en GO.

Chez Troopers, nous avons décider d'installer Gitlab sur un petit serveur dédié, et d'avoir un gros serveur (256Go RAM, 2 Xéons, 1,5To SSD) secondaire pour lancer un maximum de runners en parallèle. Un runner étant un daemon externe à Gitlab qui pop sur demande et permet de lancer des jobs. Bien sûr, il n'est pas nécessaire de disposer de tout ça pour se lancer avec Gitlab ! La version gratuite permet déjà de lancer des runners, mais il faut savoir être patient car un nombre limité seulement est mis à disposition, et vers 18h, quand les français font leurs commits de fin de journée et les américains leurs commits pré-lunch, il ne faut pas être pressé...

Maintenant, quels outils peut-on utiliser avec Gitlab pour améliorer notre workflow de développement ?

Build build

Pour commencer, chez Troopers, on travaille beaucoup avec Docker. La première chose que font nos pipelines est de surveiller l’image sur laquelle on construit l’application. Si celle-ci a bougé, alors on la recompile. Comment ? On fait un hash du fichier Dockerfile et on regarde si notre registre connaît une image taguée avec ce hash. Sinon, on va build l’image et la push dans le registry sous ce tag. Ainsi, on a toujours à disposition une image à jour pour notre application.

/.gitlab-ci.yml

build:
    stage: install
    image: docker:latest
    services:
        - docker:dind
    before_script:
        - chmod a+x ci/*
        - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
        - docker info
    script:
        - ci/build_job.sh

/ci/build_job.sh

DOCKER_TAG=`md5sum ./Dockerfile | awk '{ print $1 }'`

docker pull registry.troopers.agency:4567/client/project:$DOCKER_TAG

if [ $? -eq 0  ]; then
    exit;
fi

docker build . -t registry.troopers.agency:4567/client/project:$DOCKER_TAG
docker push registry.troopers.agency:4567/client/project:$DOCKER_TAG

Contrôle qualité build

Le second job que l’on va lancer est lié aux contrôles qualité. Chez nous, nous faisons majoritairement du PHP avec le framework Symfony, ainsi que du React pour la partie front. Une des missions les plus faciles à mettre en place dans un CI est de vérifier que les conventions de codage et de qualité sont respectées. En PHP, on peut faire ça avec pas mal d’outils comme PHPMD, php-cs-fixer, php-metrics, etc. Nous avons donc créé une stage spécifique de contrôle qualité qui exécute chacun de ces tests dans un job dédié, et donc exécuté en parallèle. On peut voir si chaque build passe ou non, et nous pouvons mettre à disposition un patch de correctifs fourni par exemple par php-cs-fixer.

/.gitlab-ci.yml

phpstan:
    image: jakzal/phpqa
    stage: tests
    script: phpstan analyse --level 2 -c ./ci/phpstan.neon src
    allow_failure: true

php-metrics:
    image: jakzal/phpqa
    stage: tests
    script: phpmetrics --report-html=var/php-metrics src
    artifacts:
        paths:
           - var/php-metrics/
    allow_failure: true

php-phpmd:
    image: jakzal/phpqa
    stage: tests
    script: phpmd src text ./ci/phpmd.xml
    artifacts:
        paths:
            - var/phpmd.html
    allow_failure: true

php-deprecation-detector:
    image: jakzal/phpqa
    stage: tests
    script:
        - deprecation-detector check src vendor
    allow_failure: true

php-cs-fixer:
    image: jakzal/phpqa
    stage: tests
    script:
        - ci/php-cs-fixer.sh
    artifacts:
        paths:
            - var/patch.diff
        expire_in: 24 hrs
        when: on_failure
    allow_failure: true

Tests build

Une seconde stage est réservée à l'exécution des tests automatisés. Chez Troopers, nous utilisons beaucoup PHPUnit pour les tests unitaires, et aussi Behat pour les tests comportementaux. PHPUnit est extrêmement rapide, et nous permet d'avoir un retour quasiment instantané sur la validité de nos PRs. Par contre, les tests Behat sont beaucoup plus longs à s'exécuter. Le but étant de tester l'application comme le ferait un utilisateur réel, il faut booter l'application, lui brancher une base de donnée avec un jeu de test, émuler un navigateur pour valider le fonctionnement du javascript, etc.

Pour cela, nous nous basons sur Docker pour être capable de lancer un environnement complet. C'est de toute façon la technologie que l'on utilise pour travailler en local. Il suffit donc d'ajouter un job en début de pipeline pour compiler l'image de base, puis d'y envoyer le code source de la PR qui va être testée. Nous avons un docker-compose.yml dédié à la stack de test, qui embarque les images de Selenium.

/.gitlab-ci.yml

phpunit:
  stage: tests
  dependencies:
    - install
  variables:
      SYMFONY__DATABASE_TEST_DRIVER: pdo_mysql
  script:
    - cp -v app/config/parameters.yml.dist app/config/parameters.yml
    - service mysql start
    - mysql -u root -e "CREATE DATABASE db"
    - bin/console --env=test doctrine:schema:create
    - php -d memory_limit=2048M bin/console --env=test cache:warmup --no-debug
    - php -d memory_limit=2048M bin/phpunit -c app/ --testdox-html var/logs/phpunit/report.html
  artifacts:
    paths:
      - var/logs/phpunit/
    expire_in: 24 hrs

behat:1:
  stage: tests
  dependencies:
    - install
  script:
    - ./ci/services.sh
    - ./ci/parallelBehat.sh 1
  artifacts:
    paths:
      - var/fails/
      - var/logs/
    expire_in: 24 hrs
    when: on_failure

behat:2:
  stage: tests
  dependencies:
    - install
  script:
    - ./ci/services.sh
    - ./ci/parallelBehat.sh 2
  artifacts:
    paths:
      - var/fails/
      - var/logs/
    expire_in: 24 hrs
    when: on_failure

./ci/parrallelBehat.sh

Xvfb :99 -ac &
export DISPLAY=:99
nohup java -jar \
  /selenium-server-standalone-2.53.0.jar \
  2> /dev/null > /dev/null &
bin/console --env=test cache:warmup
bin/console --env=test doctrine:database:create
bin/console --env=test doctrine:schema:create
bin/console --env=test server:run 127.0.0.1:80 &

Push build

Une fois que les stages précédentes sont passées, alors le code est considéré comme valide, il est donc inclus dans notre image Docker et celle-ci est push dans le registry.

/.gitlab-ci.yml

push:
    stage: build
    image: docker:latest
    services:
        - docker:dind
    variables:
        DOCKER_DRIVER: overlay
    script:
        - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
        - DOCKER_TAG=`md5sum ./Dockerfile | awk '{ print $1 }'`
        - docker create --name $CI_COMMIT_REF_SLUG \
            registry.troopers.agency:4567/client/project:$DOCKER_TAG
        - docker cp "$PWD/." $CI_COMMIT_REF_SLUG:/app
        - docker commit $CI_COMMIT_REF_SLUG \
            registry.troopers.agency:4567/client/project:$CI_COMMIT_REF_SLUG
        - docker rm $CI_COMMIT_REF_SLUG
        - docker run --name="$CI_COMMIT_REF_SLUG" \
            registry.troopers.agency:4567/client/project:$CI_COMMIT_REF_SLUG ci/init.sh
        - docker commit $CI_COMMIT_REF_SLUG \
            registry.troopers.agency:4567/client/project:$CI_COMMIT_REF_SLUG
        - docker push registry.troopers.agency:4567/client/project:$CI_COMMIT_REF_SLUG
    only:
        - branches

Code Reviews et App Reviews

Parmi les bonnes pratiques de développement, il y en a une en particulier qui s’est imposée : les Code Reviews.

Étroitement lié au concept de Pull Request, il s’agit de faire en sorte qu’un développeur tiers relise le travail d’un collègue afin de détecter les points améliorables. Les Codes Reviews sont donc un super outil pour réduire la dette technique et améliorer la qualité globale d’une base de code. De plus, ils permettent de continuer à se former en continu en bénéficiant des retours des autres développeurs.

Pourquoi ne pas pousser ce concept plus loin ?

En effet, le Code Review permet de s’assurer de la qualité du code source livré, mais ne permet pas de vérifier que le changement dans la base de code soit cohérent avec la demande initiale. De plus, ça ne permet pas de vérifier que le design est respecté.

L’impact des Code Reviews est en fin de compte relativement limité. C’est pour ça que je vais vous parler des App Reviews.

Le principe est que, lorsque qu’une PR est ouverte, on puisse relire les changements de code, mais aussi accéder à la version proposée, déployée dans un environnement temporaire.

Le but est de faire intervenir un designer afin qu’il s’assure que les maquettes qu’il a conçu soient respectées et que l’ergonomie corresponde à ce qu’il avait imaginé.

Le product owner peut lui aussi s’impliquer, pour vérifier que la fonctionnalité correspond à l’user story qu’il a rédigé en amont.

Une fois qu’un développeur, un designer et un product owner ont review la PR et accepté celle-ci, on est sûr que la fonctionnalité est conforme, on peut donc merge la PR dans le tronc commun. Cette façon de procéder permet de se passer de recette en fin de sprint, tous les éléments ayant déjà été revus au fil de l’eau.

De pair avec tout cela, on a également le Stop Review qui a pour rôle de détruire la stack lorsque la PR est fermée, afin de libérer le serveur. Ce job est lancé automatiquement.

Maintenant, en quoi Gitlab nous aide à mettre en place ce type de procédé ?

Tout d’abord grâce aux jobs de déploiement. Il est possible de configurer un job dédié au déploiement de notre application. Par exemple chez Troopers, nous utilisons beaucoup Capistrano pour les déploiements.

Nous passons aussi de plus en plus sous docker pour la construction de l’infra. Du coup, il est possible de construire un container contenant notre code source, de le monter pour exécuter les tests, puis de le push dans un registry (par exemple celui fourni par Gitlab), pour le déployer via docker-compose, swarm, ou sur n’importe quel IAAS.

Comment accéder à ce container de l'exterieur ? Gitlab génère un sous-domaine dynamique mais personne ne pointe sur le port du container. On va utiliser un nginx sur lequel on va manipuler les vhost à distance pour activer notre environnement. Et grâce à la notion d’environnements, nous pouvons dire à Gitlab de déclarer un job de déploiement dans la section « environnements » sous la forme d’un nom et d’une url. Ainsi, nous avons accès à un bouton pour redéployer notre préprod/prod ou pour rollback.

/.gitlab-ci.yml

deploy_review: 
    stage: deploy
    image: ubuntu:16.04
    dependencies:
        - build
    before_script:
      - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
      - mkdir -p ~/.ssh
      - eval $(ssh-agent -s)
      - '[[ -f /.dockerenv ]] && echo -e "Host *\nStrictHostKeyChecking no\n" > ~/.ssh/config'
      - ssh-add <(echo "$STAGING_PRIVATE_KEY")
    script:
      - ssh $STAGING_USER@$STAGING_IP -o SendEnv="CI_COMMIT_REF_SLUG" "mkdir -p ~/docker/project/$CI_COMMIT_REF_SLUG"
      - scp docker-compose-deploy.yml $STAGING_USER@$STAGING_IP:~/docker/project/$CI_COMMIT_REF_SLUG
      - ssh $STAGING_USER@$STAGING_IP -o SendEnv="CI_JOB_TOKEN" -o SendEnv="CI_REGISTRY" \
          "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
      - export TAG=$CI_COMMIT_REF_SLUG
      - ssh $STAGING_USER@$STAGING_IP -o SendEnv="TAG" -o SendEnv="CI_COMMIT_REF_SLUG" "cd ~/docker/project/$CI_COMMIT_REF_SLUG \
          && TAG=$TAG docker-compose -f docker-compose-deploy.yml pull \
          && TAG=$TAG docker-compose -f docker-compose-deploy.yml up -d --build --force-recreate"
      - export PORT=$((ssh $STAGING_USER@$STAGING_IP -o SendEnv="TAG" -o SendEnv="CI_COMMIT_REF_SLUG" \
          "cd ~/docker/project/$CI_COMMIT_REF_SLUG \
          && TAG=$TAG docker-compose -f docker-compose-deploy.yml port project 8000") | sed 's/.*://')
      - scp ci/nginx_proxy.conf $STAGING_USER@$STAGING_IP:/tmp/docker-nginx.conf
      - ssh $STAGING_USER@$STAGING_IP -o SendEnv="CI_COMMIT_REF_SLUG" -o SendEnv="PORT" \
        "CI_COMMIT_REF_SLUG=$CI_COMMIT_REF_SLUG PORT=$PORT envsubst \
        < /tmp/docker-nginx.conf > /etc/nginx/conf-troopers.d/$CI_COMMIT_REF_SLUG.conf"
      - ssh $STAGING_USER@$STAGING_IP "sudo /usr/sbin/service nginx restart"
      - ssh $STAGING_USER@$STAGING_IP -o SendEnv="TAG" -o SendEnv="CI_COMMIT_REF_SLUG" \
          "cd ~/docker/project/$CI_COMMIT_REF_SLUG && TAG=$TAG docker-compose -f docker-compose-deploy.yml ps"
    environment:
        name: review/$CI_COMMIT_REF_NAME
        url: http://$CI_COMMIT_REF_SLUG.my.ci.domain.com
        on_stop: stop_review
    only:
        - branches
    except:
        - develop  
        - master

stop_review:
    stage: deploy
    before_script:
      - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
      - mkdir -p ~/.ssh
      - eval $(ssh-agent -s)
      - '[[ -f /.dockerenv ]] && echo -e "Host *\nStrictHostKeyChecking no\n" > ~/.ssh/config'
      - ssh-add <(echo "$STAGING_PRIVATE_KEY")
    script:
        - ssh $STAGING_USER@$STAGING_IP -o SendEnv="CI_JOB_TOKEN" -o SendEnv="CI_REGISTRY" \
            "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
        - export TAG=$CI_COMMIT_REF_SLUG
        - ssh $STAGING_USER@$STAGING_IP -o SendEnv="TAG" -o SendEnv="CI_COMMIT_REF_SLUG" \
            "cd ~/docker/vom/$CI_COMMIT_REF_SLUG \
            && TAG=$TAG docker-compose -f docker-compose-deploy.yml down"
    when: manual
    environment:
        name: review/$CI_COMMIT_REF_NAME
        action: stop
    only:
        - branches
    except:
        - prod
        - develop

/ ci/nginx_proxy.conf

server {
    listen       80;
    server_name  $CI_COMMIT_REF_SLUG.my.ci.domain.com;
    location / {
        proxy_pass http://127.0.0.1:$PORT;
    }
}

En conclusion, que peut-on dire de Gitlab ?

Pour conclure, Gitlab apporte des outils d'intégration continue assez complets. Tant pour les check qualité, de testing, les apps reviews et de déploiement automatisés.

Si Github répond bien à vos besoins, mon opinion est que le rachat par Microsoft n'est pas une raison suffisante pour en partir. Sachez que la version saas de Gitlab est hébergée sur azure, et que Github, même avant ce rachat, est une solution propriétaire et centralisée. Rien n'a changé.

Maintenant, si vous cherchez quand même à quitter Github, Gitlab est une excellente alternative que l'on utilise depuis plusieurs années et qui nous comble parfaitement chez Troopers.

comments powered by Disqus

Nos derniers articles