Accélerer votre Intégration Continue

Récemment, j’ai eu l’occasion de passer un peu de temps sur la configuration des CI de plusieurs de nos projets. En effet, après que Travis ait effectué un changement dans son modèle tarifaire, les builds des projets open source l’utilisant mettaient très longtemps avant même de démarrer (parfois plusieurs heures).

N’ayant encore jamais joué avec les GitHub Actions, je me suis dit qu’il était temps de s’y mettre. Et à cette occasion, j’ai pu tester différentes configurations pour faire en sorte que les builds soient les plus rapides possibles. Cet article a pour vocation de lister quelques idées vous permettant d’accélérer votre CI (que vous utilisiez GitHub Actions, CircleCI, TravisCI ou autre).

Pourquoi faire ?

Avant de commencer, j’espère que tout le monde a bien une CI qui tourne sur chacun de ses projets ?! Je sais que nous n’avons pas toujours les moyens, ni le temps, pour mettre en place une suite de tests fonctionnels et / ou unitaires. En revanche, faire tourner des outils d’analyse (tels que PHPStan, Symfony Insight ou Psalm par exemple) ou des outils de formatage du code (PHP-CS-Fixer ou PHP_CodeSniffer) permet de garantir un minimum de qualité et de cohérence dans votre projet, sans avoir à y passer beaucoup de temps.

Certains d’entre-vous peuvent également se demander s’il est vraiment utile de passer du temps pour gagner quelques minutes sur l’exécution de la CI. Ma réponse sera évidemment oui, et ce pour plusieurs raisons.

Premièrement, une CI rapide permet au contributeur d’avoir un feedback rapide sur sa Pull Request. Il est toujours plus facile et encourageant de faire tout de suite les modifications nécessaires, plutôt que de devoir le faire alors que l’on est déjà passé à autre chose.

Deuxièmement, la plupart des CI facturent en minutes consommées par les builds. Une CI rapide coûte donc moins cher.

Enfin, une CI qui tourne plus rapidement, c’est moins de ressources consommées et donc un impact moindre sur l’environnement.

Utilisation du cache

Généralement, les CI proposent un système pour sauvegarder des données qui pourront être réutilisées dans les builds successifs. C’est d’ailleurs sur cette fonctionnalité que la plupart des astuces mentionnées dans cet article vont se baser. Au début du build, le système cherche si un cache existe : si c’est le cas, alors il va restaurer le ou les chemins qui en faisaient partie. À la fin du build, ces chemins seront sauvegardés dans le cache. Chaque CI a sa propre façon de configurer le cache :

Sur GitHub Actions, il faut rajouter une action actions/cache au début :

# .github/workflows/ci.yml
jobs:
    ci:
        steps:
            - uses: actions/checkout@v2

            - name: Cache some directory
                uses: actions/cache@v2
                with:
                  path: some/dir/to/cache
                  key: my-cache

Sur CircleCI, c’est un peu plus fastidieux car il faut définir soi-même les étapes de restauration et de sauvegarde du cache :

# .circleci/config.yml
version: 2

jobs:
    ci:
        steps:
            - checkout

            - restore_cache:
                keys:
                    - my-cache

            # ...
 
            - save_cache:
                key: my-cache
                paths:
                    - some/dir/to/cache

Sur Travis et Gitlab, il suffit d’indiquer le(s) chemin(s) à sauvegarder :

# .travis.yml
cache:
    directories:
        - some/dir/to/cache
# .gitlab-ci.yml
cache:
  paths:
    - some/dir/to/cache

Il est à noter que les caches de GitHub Actions et de Circle CI sont légèrement différents des autres puisque ces plateformes proposent un cache immutable : une fois que des données ont été sauvegardées, impossible de les mettre à jour. La seule solution sera de changer la clé utilisée pour référencer ce cache. C’est la raison pour laquelle on trouve souvent des hash dans le nom des clefs : tant que le hash du fichier en question ne change pas, alors pas besoin de mettre à jour le contenu du cache. C’est particulièrement utile avec les fichiers de « lock » qui figent la version de vos dépendances, comme on va le voir dans la suite de cet article.

Note : il est tout à fait possible de forcer la mise à jour du cache à chaque build si vous préférez ou si vous n’avez pas de lock (comme c’est le cas pour une librairie). C’est ce que m’ont fait découvrir nos copains de chez Monsieur Biz qui utilisent l’identifiant du commit dans le nom de la clé de cache sur GitHub. Attention, en revanche, à la taille du registre de cache qui risque d’augmenter rapidement (GitHub autorise jusqu’à 5 Go de cache) et qui pourrait ralentir l’étape de restauration.

key: my-cache-${{ github.sha }}

Ces deux plateformes proposent également la possibilité de définir des clés additionnelles qui seront utilisées si la clé actuelle ne correspond à aucun contenu sauvegardé. Cela permettra de profiter d’un cache en début de build et de tout de même mettre à jour le cache

Sur GitHub, vous utiliserez pour cela les restore-keys :

- name: Cache Pipenv
  uses: actions/cache@v2
  with:
      path: some/dir/to/cache
      key: my-cache-${{ hashFiles('my.lock') }}
      restore-keys: my-cache-

Sur Circle CI, vous aurez simplement à définir plusieurs clés dans l’étape de restauration :

- restore_cache:
  keys:
    - my-cache-{{ checksum "my.lock" }}
    - my-cache-
...
- save_cache:
  key: my-cache-{{ checksum "my.lock" }}
  paths:
    - some/dir/to/cache

Composer

Si vous suivez nos aventures sur ce blog, il y a fort à parier que vous travaillez régulièrement avec PHP et que vos projets nécessitent d’installer des dépendances avec Composer. Résoudre les dépendances et les télécharger a toujours pris beaucoup de temps avec Composer (maintenant nettement moins avec Composer 2).

Comme la plupart des gestionnaires de dépendances modernes, Composer intègre un cache global qui lui évite de devoir re-télécharger des packages déjà téléchargés précédemment. Si votre CI propose un système de cache, vous avez donc 3 solutions pour pallier la lenteur d’un composer install ou composer update :

  1. mettre en cache le dossier vendor/ ;
  2. mettre en cache le cache global de composer ;
  3. solutions 1 et 2.

J’ai tendance à préférer la deuxième solution uniquement mais rien ne vous empêche d’utiliser les 2 en même temps. Avec elle, je laisse Composer travailler avec son propre cache et il se chargera d’installer les dépendances from scratch (évidemment sans la partie téléchargement). Je trouve que pour un projet client, c’est souvent plus proche de nos méthodes de déploiement à la Capistrano (build d’une application toute neuve à chaque fois). De plus, si jamais votre projet comporte plusieurs applications PHP, vous n’aurez qu’un seul cache à gérer pour l’ensemble du projet.

Note : le cache global de Composer se situe généralement dans ~/.composer/cache (mais cela peut être différent suivant votre OS). Vous pouvez utiliser la commande composer config cache-dir pour trouver l’emplacement exact (comme dans cet exemple).

Pour GitHub Actions, nous utilisons l’action actions/cache :

- name: Cache Composer
  uses: actions/cache@v2
  with:
      path: ~/.composer/cache/
      key: composer-${{ hashFiles('composer.lock') }}
      restore-keys: composer-

Sur Circle CI :

- restore_cache:
  keys:
    - composer-{{ checksum "composer.lock" }}
    - composer-

...
 
- save_cache:
  key: composer-{{ checksum "composer.lock" }}
  paths:
    - ~/.composer

Enfin, sur Travis, on indique juste les chemins à sauvegarder (la logique sera similaire sur Gitlab) :

cache:
    directories:
        - $HOME/.composer/cache/

Note : avec cette technique, vous risquez de garder en cache de vieilles versions de vos packages que vous n’utilisez plus. Après quelques mois/années, cela pourrait rendre le cache potentiellement gros et ralentir sa restauration. Si c’est le cas, n’hésitez pas à le vider de temps en temps. Sur GitHub Actions, cela pourrait se gérer automatiquement avec une variable d’environnement utilisée dans le nom des clés. Cette variable, qui contient un numéro de version, pourrait être mis à jour directement avec un cron dans votre workflow.

Utiliser Composer 2 (sur votre CI, mais aussi en local) vous permettra également de gagner beaucoup de temps car cette nouvelle version a mis l’accent sur l’amélioration des performances.

Environnement Python

Sur la plupart de nos projets, nous utilisons une stack Docker pour nos environnements de développement, basée sur docker-starter. Cette infrastructure utilise Invoke (une librairie Python) pour faciliter l’interaction avec Docker et Docker Compose. Et comme l’écosystème Python 2/3 est assez capricieux, il est nécessaire de passer par pip et des environnements virtuels gérés par pipenv. Avant même de démarrer la stack, il faut donc d’abord installer pipenv et lui faire installer l’environnement virtuel :

$ pip install pipenv
$ pipenv install --dev --deploy
  • --dev permet d’installer les dépendances python de dev
  • --deploy permet de s’assurer que le Pipfile.lock est à jour au lieu d’en régénérer un.

Et comme pour Composer, télécharger les dépendances Pip, c’est long. Si en plus, il faut rajouter la génération de l’environnement virtuel, on peut perdre plusieurs minutes « pour rien » à chaque build sur la CI. Pour éviter ça, on va sauvegarder les dossiers de cache de Pip et Pipenv, ainsi que l’emplacement des virtualenvs.

Sur GitHub Actions, ça donne ça :

- name: Cache Pipenv
  uses: actions/cache@v2
  with:
      path: |
          ~/.cache/pip
          ~/.cache/pipenv
          ~/.local/share/virtualenvs/
      key: pip-pipenv-${{ hashFiles('Pipfile.lock') }}
      restore-keys: pip-pipenv-
- uses: actions/setup-python@v2
  with:
      python-version: 3.7
- run: pip install pipenv
- run: pipenv install --deploy --dev

Sur certaines CI (j’ai eu le souci sur Gitlab CI en tout cas), il est compliqué d’arriver à sauvegarder l’emplacement des virtualenvs (normalement dans ~/.local/share/virtualenvs/) : difficultés à localiser le dossier, problèmes de permission, etc.

Pour régler ça, j’ai découvert une variable d’environnement PIPENV_VENV_IN_PROJECT qui configure Pipenv pour installer le virtualenv dans un dossier .venv à la racine du projet. Il nous suffit alors de mettre ce dossier en cache.

Sur Gitlab CI, cela ressemble à ça :

ci:
  ...
  before_script:
    - pip install pipenv --ignore-installed
    - python3 -V
    - pipenv install --deploy --dev

  variables:
    PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
    PIPENV_CACHE_DIR: $CI_PROJECT_DIR/.cache/pipenv
    PIPENV_VENV_IN_PROJECT: 'true'

  cache:
    paths:
      - $PIP_CACHE_DIR
      - $PIPENV_CACHE_DIR
      - .venv/
  ...

NPM et Yarn

Pour l’écosystème JavaScript, pas de surprise, c’est globalement la même logique qu’avec Composer. Nous avons également le choix entre mettre en cache le dossier node_modules/ ou le dossier de cache global de votre gestionnaire de package favoris, NPM ou Yarn. D’après leur documentation, ces dossiers se trouvent, par défaut, respectivement dans ~/.npm et ~/.config/yarn/global.

J’aurais, cette fois, tendance à mettre en cache le dossier node_modules/ car un projet JS moderne requiert généralement beaucoup de dépendances. En plus, il n’est pas rare d’avoir de la compilation lors de l’installation des dépendances JS (coucou node-sass). Toute optimisation est donc bonne à prendre de ce côté là.

La configuration du cache sur la CI pour ces dossiers sera identique à celle proposée dans le chapitre sur Composer, je vous épargne donc le YAML correspondant.

Dernier point concernant JavaScript : utiliser Yarn à la place de NPM pourrait vous faire gagner quelques secondes de plus. En effet depuis ses débuts, Yarn a privilégié notamment les performances et l’usage de cache agressif, rendant l’installation des dépendances plus efficace que celle de NPM, ce qui sera naturellement bon à prendre pour votre CI.

Layers Docker

Lors du build d’une image, Docker va mettre en cache le résultat de chaque couche qui compose vos Dockerfiles. Cela permet de gagner énormément de temps lors des builds suivants si rien ne change entre temps. Et vous commencez à le comprendre, si notre outil fournit du cache, autant s’en servir pour accélérer notre CI.

Ce cache s’avère en revanche un peu plus compliqué à manipuler. Mais heureusement pour nous, Docker est un outil utilisé par beaucoup de monde, et plein de gens talentueux se sont déjà penchés sur la question.

Sur GitHub Actions, nous pouvons utiliser directement une action custom :

- uses: actions/checkout@v2


- uses: satackey/action-docker-layer-caching@v0.0.8
  continue-on-error: true
  with:
      key: docker-{hash}
      restore-keys: docker-

...

Sur Circle, le cache docker n’est disponible que sur les plans payants. Pour l’utiliser, il faudra passer par l’executor machine et lui dire de mettre en cache les layers :

version: 2

jobs:
    ci:
        machine:
            docker_layer_caching: true

Pour information, le cache Docker est très long à restaurer et sauvegarder. Par exemple, sur la CI de notre dépôt docker-starter, cela prend 1min 15s sur les 2 min de build au total. Pour la petite histoire, nous n’avons pas encore réussi à configurer ce cache sur Gitlab Ci en utilisant docker-dind (Docker in Docker).

Évidemment, la meilleure solution, pour ne pas avoir à build les images en permanence (que ce soit sur la CI ou même en local), serait d’utiliser un Registry Docker. Nous avons plusieurs fois discuté de cette option en interne mais nous nous y sommes toujours refusés jusqu’à maintenant. Cela complexifierait beaucoup la maintenance du projet et la Developer eXperience. Mais ça peut être une option à garder en tête si vous travaillez sur une grosse application Docker tout au long de l’année.

Séparer les tâches autonomes

La plupart des CI permettent de configurer une suite de tâches sous forme de workflow. Et plus il y a de tâches qui peuvent être exécutées en parallèle, plus la durée totale du build sera courte.

Les tâches telles que la vérification du formatage de code ou l’analyse de code sont de parfaites candidates pour être exécutées en parallèle et offrir un feedback encore très rapidement (certains jobs pouvant donner leur résultat en quelques secondes).

Sur GitHub Actions, cela pourrait donner quelque chose comme :

jobs:
    check-python-cs:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
            - uses: actions/setup-python@v2
              with:
                  python-version: '3.x'
            - run: pip install pycodestyle
            - run: pycodestyle invoke.py tasks.py

     check-php-cs:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
            - uses: docker://oskarstark/php-cs-fixer-ga

     check-phpstan:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
            - uses: docker://oskarstark/phpstan-ga
              with:
                  args: analyse src/

    tests:
        ...

PHPStan

En parlant de PHPStan, saviez-vous qu’il était capable de gérer un dossier de cache ? En l’utilisant, PHPStan est capable d’analyser votre dossier beaucoup beaucoup plus rapidement. Là où l’analyse prenait auparavant des dizaines de secondes sur certains projets, l’utilisation du cache rend cette étape quasiment instantanée.

Pour mettre en place ce cache, il faut configurer la directive tmpDir dans votre fichier phpstan.neon.dist. Encore une fois, pour en tirer profit sur la CI également, il suffira de mettre en cache ce dossier. N’hésitez pas à mettre ce dossier dans votre .gitignore pour ne pas être embêté lorsque vous lancez PHPStan en local.

Un exemple pour GitHub Actions :

- name: Cache PHPStan
  uses: actions/cache@v2
  with:
      path: var/phpstan-tmp/
      key: phpstan-${{ github.sha }}
      restore-keys: phpstan-

PHPUnit

Si vous utilisez des mocks dans vos tests PHPUnit, vous les stockez probablement dans des propriétés de vos classes de test. Cela peut finir par peser lourd en mémoire et ralentir l’exécution de PHPUnit. Heureusement, il existe une solution rapide pour éviter ça, comme nous l’explique cet article de Kris Wallsmith. Il suffit de profiter du tearDown de vos tests pour nettoyer tous les objets inutiles en mémoire à l’aide d’un peu de Reflection.

Cet article n’est plus tout jeune (2012), mais nous vous confirmons que cette astuce nous fait toujours gagner un peu de temps sur certains de nos projets.

Fixtures et hashing de mot de passe

Un bon système de sécurité implique souvent un calcul de hash lent et intensif en ressources. C’est notamment le cas dans Symfony avec l’encoder par défaut et c’est tant mieux pour la sécurité des utilisateurs. Mais dis-donc Jamy, savais-tu que cela pouvait impacter les performances de la CI ?

Et oui, si votre suite de tests nécessite le chargement de beaucoup de fixtures d’utilisateur, alors il est fort probable que chaque entité nécessite le hashing de son mot de passe. Et plus il y a d’utilisateurs, plus vos tests seront longs. Une technique pour éviter ça est d’utiliser un algorithme faible uniquement en mode test. C’est ce que nous avions déjà remarqué il y a quelques temps sur un projet client et c’est également la conclusion à laquelle est arrivé Sylius récemment :

Avec Symfony, cela se fait facilement en créant un fichier config/packages/test/security.yml :

security:
    encoders:
        App\Entity\User: sha512

Charger un dump SQL au lieu de fixtures

Voici une dernière petite astuce pour terminer cet article. Je n’ai pas encore eu l’occasion de la mettre en place mais elle me parait assez intéressante eta tout à fait sa place ici. Et elle pourrait bien vous concerner si vous utilisez beaucoup de fixtures dans votre suite de tests.

Pour un de nos clients, un travail est en cours pour accélérer le chargement des fixtures. L’idée est de travailler avec un dump SQL généré à partir de fixtures provenant de plusieurs APIs. Les objectifs sont multiples :

  • accélérer le chargement des données en utilisant du SQL plutôt que des fixtures qu’il faudra in fine mettre en base ;
  • faire générer ce dump automatiquement par la CI pour éviter les conflits si chaque développeur devait modifier ce SQL ;
  • uploader ce dump dans un Amazon S3 ou équivalent pour pouvoir l’utiliser même en local.

J’ai déjà entendu, lors d’une ancienne conférence, que certains allaient même plus loin. Plutôt que de charger un dump SQL, ils utilisent directement un container Docker avec une base de données déjà chargée dans le volume. Il est ainsi beaucoup plus rapide de démarrer le conteneur que de charger un dump SQL qui effectue potentiellement énormément de requêtes SQL pour initialiser la base. Le gros inconvénient de cette technique est qu’elle nécessite de gérer une image Docker avec probablement un registry, ce qui est un no-go pour la plupart de nos projets.

Ces deux techniques pourront vous faire gagner beaucoup de temps, et d’autant plus si vos tests requièrent de recharger vos données plusieurs fois (ce qui va toutefois à l’encontre de la méthodologie FIRST).

Conclusion

À travers cet article, j’ai résumé les protips que j’ai appliqué lors des dernières semaines sur mes projets. Pour bien comprendre les étapes où nous pouvons gagner du temps dans une CI, il faut généralement commencer par comprendre d’où vient le ralentissement.

Un composer install, un npm install ou encore un pipenv install met de longues secondes à s’exécuter ? Est-ce que l’outil en question possède un cache ? Si oui, il pourrait suffire d’ajouter un dossier dans le cache de votre CI. Dans ce cas, il faut bien prendre le temps de tester les différents changements que vous faites sur le cache, pour être sûr que celui-ci fonctionne bien et qu’il permette effectivement de gagner du temps. Cela passe généralement par un premier build qui se charge de créer du cache et de le sauvegarder, puis par un deuxième build qui devra l’utiliser.

Des lenteurs se trouvent également dans votre suite de test ? Alors il faudra probablement passer par un profileur tel que Blackfire pour déterminer exactement la partie du code qui est lente.

J’espère vous avoir convaincu de l’importance d’une CI rapide et vous avoir donné des astuces pour gagner facilement du temps lors de vos builds. Dans plusieurs de nos projets, ces conseils nous ont permis d’améliorer considérablement nos builds, divisant souvent par deux, voire plus, la durée d’exécution de la CI.

blog comments powered by Disqus