Le déploiement continu en production pour Symfony 7 avec Docker et CircleCI

Arrêtez de transpirer à chaque mise en production, automatisez votre workflow et soyez plus agile avec CircleCI !

Le déploiement continu en production pour Symfony 7 avec Docker et CircleCI

Vous avez une super app Symfony qui tourne (pas encore ?) avec Docker et vous voulez déclencher automatiquement sa mise en production lorsque vous faites un push dans une branche de votre repo. Respirez un grand coup, détendez-vous, vous êtes au bon endroit.

Avant de commencer, vérifions les bases. Simple, basique. Basique, simple.

Le déploiement continu, c'est quoi ?

Accueillez à nouveau le mot CD dans votre vocabulaire. Pas pour désigner le CD du groupe de rock de votre enfance, non, les choses ont bien changé. On parle ici de Continuous Deployment, l'automatisation du déploiement de vos apps sur vos serveurs de production en appuyant sur un gros bouton rouge.

Bouton déploiement continu
Elle est pas belle la vie ?

Le cas d'étude

Sans plus attendre, on va s'intéresser à la stack technique suivante : Docker, Symfony 7, PHP 8.2, Bootstrap et React. Pour pimenter un peu le tout, j'ai utilisé Webpack avec le bundle Symfony Encore pour me rapprocher d'un cas concret. J'ai donc des dépendances JS et SCSS à installer avec Yarn, à concaténer, à minifier, etc.

Démo labs.sainthillier.fr
La magnifique démo de cet article : 👉https://labs.silarhi.fr/

L'idée est qu'à chaque push sur une branche git (master en l'occurence), le serveur de CD va build la nouvelle version de l'image Docker qui correspond à notre app, le déployer sur le Hub Docker et appeler un script de déploiement sur notre serveur via SSH. En gros, le workflow ressemble à ça :

Diagramme de CI/CD
Crédits : OpenClassrooms x Paint

Sans plus attendre, voyons à quoi ressemble le Dockerfile de production et/ou staging de notre app :

# Dockerfile

# 1st stage : build js & css
FROM node:18-alpine as builder

ENV NODE_ENV=production
WORKDIR /app

ADD package.json yarn.lock webpack.config.js ./
ADD assets ./assets

RUN mkdir -p public && \
    NODE_ENV=development yarn install && \
    yarn run build

FROM silarhi/php-apache:8.2-symfony

# 2nd stage : build the real app container
EXPOSE 80
WORKDIR /app

# Default APP_VERSION, real version will be given by the CD server
ARG APP_VERSION=dev
ARG GIT_COMMIT=master
ENV APP_VERSION="${APP_VERSION}"
ENV GIT_COMMIT="${GIT_COMMIT}"

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/

RUN install-php-extensions exif gd imagick

COPY . /app
COPY --from=builder /app/public/build /app/public/build

RUN mkdir -p var var/storage && \
    APP_ENV=prod composer install --prefer-dist --optimize-autoloader --classmap-authoritative --no-interaction --no-ansi --no-dev && \
    APP_ENV=prod bin/console cache:clear --no-warmup && \
    APP_ENV=prod bin/console cache:warmup && \
    # We don't use DotEnv component as docker-compose will provide real environment variables
    echo "<?php return [];" > .env.local.php && \
    chown -R www-data:www-data var && \
    # Reduce container size
    rm -rf .git assets /root/.composer /tmp/*

Avec ce Dockerfile, on a un container tout chaud prêt pour la production. Le cache est déjà généré, les fichiers JS et CSS sont compilés et minifiés (premier stage du Dockerfile), prêts à être délivrés à vos millions de visiteurs par votre nouveau Raspberry Pi 4 flambant neuf.

L'objectif est maintenant de réussir à build cette image automatiquement lors d'un nouveau push dans git, et à déployer l'app dans la foulée.

Dans le vif du sujet de la CD

Il existe plétoire d'outils pour arriver à nos fins :

Aujourd'hui, on va aborder CircleCI. N'hésitez pas à laisser un commentaire si vous voulez un tuto avec un autre outil de CI !

La CD avec CircleCI

CircleCI a l'avantage d'être gratuit dans son offre de base. On peut faire pas mal de chose avec cet outil, mais on va se concentrer sur les 4 opérations clés de notre flow :

  1. Redescendre du code dans une VM éphémère
  2. Build l'image Docker
  3. Push l'image sur le registre docker
  4. Appeler un script shell de déploiement sur le serveur de production

Pour ça, on va créer un fichier .circleci/config.yml à la racine du projet :

# .circleci/config.yml
version: 2
jobs:
    build:
        docker:
            - image: cimg/base:2022.06
              environment:
                  IMAGE_NAME: silarhi/symfony-docker-ci
        steps:
            - checkout # Étape 1
            - setup_remote_docker:
                version: 20.10.11

            - run: # Étapes 2 & 3
                name: "Build and push Docker image"
                command: |
                    IMAGE_TAG="1.${CIRCLE_BUILD_NUM}"
                    APP_VERSION="${IMAGE_TAG}"
                    GIT_COMMIT="${CIRCLE_SHA1:0:7}"
                    docker build -t ${IMAGE_NAME}:${IMAGE_TAG} --build-arg APP_VERSION=${APP_VERSION} --build-arg GIT_COMMIT=${GIT_COMMIT} .

                    if [ "${CIRCLE_BRANCH}" == "main" ]; then
                        docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest
                    fi
                    echo ${DOCKER_PWD} | docker login -u ${DOCKER_LOGIN} --password-stdin
                    docker push ${IMAGE_NAME}
    deploy: # Étape 4
        machine:
            image: ubuntu-2004:202111-02
        steps:
            - add_ssh_keys:
                fingerprints:
                    - "7c:58:a5:54:56:36:ee:bc:7d:d6:07:38:a0:52:31:cb"
            - run:
                name: "Deploy image to production"
                command: |
                    ssh root@${PRODUCTION_SERVER_IP} "cd ${PRODUCTION_SERVER_PATH} && ./deploy.sh"

# On éxécute ces étapes lors d'un commit sur la branche master uniquement
workflows:
    version: 2
    build-and-deploy:
        jobs:
            - build
            - deploy:
                  requires:
                      - build
                  filters:
                      branches:
                          only: main

Ne fuyez pas en voyant ce fichier, on va le détailler un peu :

  • A chaque push, un container Docker est créé par CircleCI. On spécifie l'image qu'on veut utiliser avec la directive image: cimg/base:2022.06. CircleCI nous laisse le choix d'utiliser des images déjà toutes prêtes conçues spécialement pour la CI/CD, autant les utiliser.
  • Étape 1 : checkout. Il faut bien sûr redescendre le code de Github dans le container si on veut l'utiliser après.
  • Étapes 2 & 3 : On utilise les commandes classiques de Docker docker build, docker tag et docker push pour parvenir à nos fins. Il y a cependant 2 choses à remarquer :
    • L'utilisation de l'argument --build-arg dans la commande docker build. Cet argument est complètement optionnel, c'est juste pour vous montrer qu'on peut utiliser le numéro de commit dans notre app Symfony via une variable d'environnement. Ça peut être utile par exemple si vous utilisez les releases de Sentry pour tracker quels commits sont particulièrement générateurs de problèmes.
    • L'utilisation de variables non déclarées telles que PRODUCTION_SERVER_IP. En fait, pour des raisons de sécurité, CircleCI permet de définir des variables d'environnement secrètes qui ne seront pas affichées dans les logs de CircleCI. Vous pouvez les définir dans l'interface de CircleCI :
Les variables d'environnement dans CircleCI
Les variables d'environnement dans CircleCI
  • Étape 4 :
    1. fingerprints permet d'utiliser la clé privée de votre serveur de production dans le container éphémère pour pouvoir se connecter par SSH sans utiliser de password. Veillez à bien sécuriser vos accès à CircleCI, car celui-ci devient désormais un élément critique de votre infra. Celui qui a accès à votre CircleCI peut avoir accès à votre serveur de production.
    2. On éxécute le script de déploiement sur le serveur de prod via la commande cd ${PRODUCTION_SERVER_PATH} && ./deploy.sh

Le script de déploiement

#!/bin/bash
set -e

#Download new image version
docker compose pull

#Set maintenance mode to perform critical operations (database, upgrades, ...)
APP_MAINTENANCE=1 docker compose up -d
#i.e: docker compose exec -T app bin/console doctrine:migration:migrate -n
sleep 10; #for demo purpose

#back to prod
docker compose up -d

L'hébergement sur la production

# /apps/labs.silarhi.fr/ci/docker-compose.yml
version: '3'

services:
    app:
        image: silarhi/symfony-docker-ci:latest
        container_name: lab_ci_app
        env_file:
            - app.env
        environment:
            - APP_MAINTENANCE
        volumes:
            - /app/var/logs
        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.labs-ci.rule=Host(`labs.silarhi.fr`)"
            - "traefik.http.routers.labs-ci.entrypoints=websecure"
        restart: always
        networks:
            - web

networks:
    web:
        external: true

🤓 « C'est quoi ces labels bizarres ?! »

J'utilise Traefik pour gérer les instances de Docker sur la production. Si ça vous intéresse, n'hésitez pas à jeter un oeil à l'article que j'ai écris à ce sujet.

La gestion de la mise à jour

Durant le déploiement, on définit une variable d'environnement APP_MAINTENANCE. Un petit tweek sur le fichier du contrôleur frontal Symfony (index.php) permet d'afficher une super page de maintenance durant la mise en prod et le tour est joué.

// public/index.php
use App\Kernel;

require_once \dirname(__DIR__) . '/vendor/autoload_runtime.php';

return function (array $context) {
    if ($context['APP_MAINTENANCE'] ?? false) {
        echo '<html><body><h1>Upgrade in progress</h1></body></html>';
        exit(1);
    }

    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

Voilà ! Votre app est désormais fin prête à être déployée automatiquement pour votre plus grand bonheur. N'hésitez pas à laisser un commentaire si vous utilisez un autre workflow pour vos déploiements !

Pour aller plus loin