Accéder au contenu principal

11min.

Déploiement On-Premise – Partie 2 – Castor à la rescousse

Dans le précédent article, nous avons vu toutes les étapes nécessaires pour préparer les images Docker qui seront utilisées en production. Mais nous allons maintenant aller plus loin pour automatiser et simplifier encore un peu plus cette étape grâce à Castor et les runners GitLab, le but étant de faciliter la procédure de déploiement de nouvelles versions de l’application afin que le client puisse être autonome.

Section intitulée creation-et-publication-des-imagesCréation et publication des images

Comme souvent quand nos projets nécessitent de lancer des commandes, nous mettons en place des tâches Castor pour simplifier la DX. Nous avons donc créé une task production:build qui applique tout ce que nous avons vu dans l’article précédent :

<?php

use Castor\Attribute\AsOption;
use Castor\Attribute\AsTask;
use Castor\Exception\ProblemException;
use Symfony\Component\Console\Input\InputOption;

use function Castor\capture;
use function Castor\check;
use function Castor\context;
use function Castor\fs;
use function Castor\io;
use function Castor\run;
use function Castor\variable;

const REGISTRY = '<url du registre>:4567/plancq/arsol/';
const DEFAULT_BRANCH = 'main';
const BAKE_FILE = __DIR__ . '/../infrastructure/production/bake.hcl';
const IMAGES_TO_TAG = [
    'postgres:16' => 'postgres',
    'getmeili/meilisearch:v1.16' => 'meilisearch',
];

#[AsTask(description: 'Build production docker images', namespace: 'production:docker')]
function build(
    #[AsArgument(description: 'Version of the images')]
    ?string $tagVersion = null,
    #[AsOption(description: 'Force the build whatever the current branch state')]
    bool $force = false,
    #[AsOption(description: 'Push the images to the registry', mode: InputOption::VALUE_NEGATABLE)]
    ?bool $push = null,
    #[AsOption(description: 'Update docker-compose.yml with current tag', mode: InputOption::VALUE_NEGATABLE)]
    ?bool $updateDockerCompose = null,
): void {
    $currentBranch = capture(['git', 'branch', '--show-current']);
    check('Checking current branch:', 'You must be on the main branch to build the production images. Change the current branch or use --force to bypass this check.', static fn () => DEFAULT_BRANCH === $currentBranch || $force);

    $currentChanges = capture(['git', 'status', '--porcelain']);
    check('Checking git working tree:', 'You have uncommitted changes. Git stash everything before building image or use --force to bypass this check.', static fn () => !$currentChanges || $force);

    if (!$force) {
        run(['git', 'pull', 'origin', DEFAULT_BRANCH]);
    }

    $validateVersion = static function (string $tagVersion): string {
        if (!$tagVersion) {
            throw new \RuntimeException('Version is required');
        }

        if (!preg_match('/^20\d{2}\.\d{2}\.\d{2}+(-\d+)?$/', $tagVersion)) {
            throw new \RuntimeException('Version must be in the format YYYY.MM.DD (eventually with a revision like YYYY.MM.DD-1), got: ' . $tagVersion);
        }

        return $tagVersion;
    };

    if ($tagVersion) {
        $tagVersion = $validateVersion($tagVersion);
    } else {
        $tagVersion = io()->ask('Please provide the version of the production images to build:', date('Y.m.d'), $validateVersion);
    }

    $tags = [$tagVersion, 'lastest'];

    io()->section('Building the local development images...');
    \docker\build();

    if (fs()->exists(context()->workingDirectory . '/.dockerignore')) {
        fs()->rename(context()->workingDirectory . '/.dockerignore', context()->workingDirectory . '/.dockerignore.tmp', true);
    }

    try {
        $command = [
            'docker', 'buildx', 'bake',
            '--file', BAKE_FILE,
            '--load',
        ];

        foreach (getImagesToBuild() as $targetName => $config) {
            foreach ($tags as $tag) {
                $command[] = '--set';
                $command[] = $targetName . '.tags+=' . REGISTRY . $targetName . ':' . $tag;
            }

            $command[] = '--set';
            $command[] = $targetName . '.args.PROJECT_NAME=' . variable('project_name');
        }

        run($command);
    } finally {
        if (file_exists(context()->workingDirectory . '/.dockerignore.tmp')) {
            fs()->rename(context()->workingDirectory . '/.dockerignore.tmp', context()->workingDirectory . '/.dockerignore', true);
        }
    }

    foreach (IMAGES_TO_TAG as $sourceImage => $image) {
        foreach ($tags as $tag) {
            run(['docker', 'image', 'pull', $sourceImage]);
            run(['docker', 'image', 'tag', $sourceImage, REGISTRY . $image . ':' . $tag]);
        }
    }

    io()->success('Production images are now built.');

    if ($updateDockerCompose || (null === $updateDockerCompose && io()->confirm('Do you want to update the docker-compose with the current version?', false))) {
        updateDockerCompose($tagVersion);
    } else {
        io()->info('You can update the production docker-compose.yml file by running the following command:');
        io()->info('castor production:docker:update-docker-compose ' . $tagVersion);
    }

    if ($push || (null === $push && io()->confirm('Do you want to push the images to the registry (you may want to play with the images before pushing)?', false))) {
        push($tagVersion);
    } else {
        io()->info('You can push the images to the registry by running the following command:');
        io()->info('castor production:docker:push');
    }

    io()->success('Great job! Everything is now done.');
}

#[AsTask(description: 'Push production docker images', namespace: 'production:docker')]
function push(?string $tag = null): void
{
    io()->info('Pushing image to the registry...');

    foreach ([...array_keys(getImagesToBuild()), ...IMAGES_TO_TAG] as $image) {
        io()->write(\sprintf('Pushing %s image...', $image));
        if ($tag) {
            run(['docker', 'image', 'push', '--disable-content-trust', REGISTRY . $image . ':' . $tag]);
        } else {
            run(['docker', 'image', 'push', '--disable-content-trust', '--all-tags', REGISTRY . $image]);
        }
    }

    io()->success(\sprintf('Production images have been pushed to the registry %s', REGISTRY));
}

function getImagesToBuild(): array
{
    $bakeConfigOutput = capture([
        'docker', 'buildx', 'bake',
        '--file', BAKE_FILE,
        '--print',
    ]);

    $bakeConfig = json_decode($bakeConfigOutput, true);

    if (null === $bakeConfig) {
        throw new ProblemException('Failed to parse bake.hcl output as JSON.');
    }

    if (!\is_array($bakeConfig) || !isset($bakeConfig['target']) || !\is_array($bakeConfig['target'])) {
        throw new ProblemException('Invalid bake.hcl structure or missing targets.');
    }

    return $bakeConfig['target'];
}

Info

Nous recommandons généralement de placer les tâches et fonctions du projets dans des fichiers .php dans le dossier .castor/ à la racine du dépôt.

Pour résumer, cette task tout-en-un va :

  1. s’assurer que vous êtes sur la branche main, à jour et sans modification locale ;
  2. demander la version des tags à créer en prenant par défaut la date du jour ;
  3. construire la stack Docker de dev si nécessaire (pour avoir les images de base utilisées) ;
  4. exécuter la commande Bake pour construire/taguer les images PHP ;
  5. exécuter les commandes Docker pour construire/taguer les images publiques (Postgres et Meilisearch) ;
  6. demander si vous voulez mettre à jour la version des images dans le docker-compose.yml final (si jamais vous voulez tester l’application en local avant de publier la version) ;
  7. demander si vous voulez publier les images sur le registry.

Chaque paramètre de cette task (la version à taguer, la confirmation de push ou de mise à jour du docker-compose.yml) est optionnel. S’ils ne sont pas spécifiés, la task les demandera de manière intéractive.

Il est maintenant très facile de publier une nouvelle version du projet pour n’importe quel intervenant du projet (tant qu’il a accès au registre Docker évidemment). Mais on peut aller encore un peu plus loin pour faciliter, cette fois, l’exploitation de l’infrastructure de production.

Section intitulée pilotage-de-l-infrastructurePilotage de l’infrastructure

Pour démarrer le projet (que ce soit en production, en pré-production ou sur du on-premise), nous avons besoin de 3 choses :

  • créer le fichier docker-compose.yml avec tous les services définis ;
  • créer un fichier .env pour configurer l’application Symfony ;
  • lancer la commande docker compose up -d.

Nous allons tout d’abord créer un nouvel ensemble de task Castor qui vont permettre de tout de piloter l’infrastructure docker :

<?php

namespace production;

use Castor\Attribute\AsTask;
use Castor\Context;
use Castor\Event\BeforeExecuteTaskEvent;
use Castor\Exception\ProblemException;
use Symfony\Component\Process\Exception\ProcessFailedException;

use function Castor\context;
use function Castor\fs;
use function Castor\io;
use function Castor\load_dot_env;
use function Castor\run;
use function Castor\wait_for_docker_container;

#[AsTask(description: 'Start production infrastructure')]
function start(): void
{
    try {
        production_docker_compose(['up', '-d']);
    } catch (ProcessFailedException $e) {
        if (preg_match(
            '/Bind for (.*):(?<port>\d+) failed: port is already allocated/mi',
            $e->getProcess()->getErrorOutput(),
            $matches
        )) {
            throw new ProblemException(\sprintf('It seems that port %s is already used on your machine. Please free this port and try again.', $matches['port']), previous: $e);
        }

        throw $e;
    }

    io()->success('Production infrastructure has been started. It should be available in a few seconds.');

    $url = 'https://' . $_ENV['SERVER_NAME'];

    wait_for_docker_container('arsol-' . $_ENV['ARSOL_INSTANCE'] . '-frontend-1', timeout: 60, intervalMs: 1000);

    io()->success(\sprintf('Production infrastructure is now ready at %s. Enjoy!', $url));
}

#[AsTask(description: 'Stop production infrastructure')]
function stop(): void
{
    io()->title('Stopping production infrastructure');

    production_docker_compose(['stop']);

    io()->success('Production infrastructure has been stopped.');
}

function production_docker_compose(array $arguments): void
{
    load_dot_env(context()->workingDirectory . '/.env');

    run(['docker', 'compose', '-f', 'docker-compose.yml', '-p', 'arsol-' . $_ENV['ARSOL_INSTANCE'], ...$arguments]);
}

Info

Cette fois, nous plaçons les tasks et fonctions destinées à être utilisées en production dans un dossier tools/production/castor.php. Nous expliquerons l’intérêt de séparer ces tasks dans un dossier à part dans le chapitre suivant.

Avec ces deux tasks, on peut maintenant lancer castor production:start et castor production:stop. Mais pour une meilleure DX, on va également créer automatiquement le fichier .env de base ainsi que le fichier docker-compose.yml s’ils n’existent pas encore. Cela se fait facilement avec le système de Listener intégré dans Castor :

use Castor\Attribute\AsListener;
use Castor\Event\BeforeExecuteTaskEvent;

use function Castor\context;
use function Castor\fs;
use function Castor\io;

const DOT_ENV_GENERABLES = [
    'APP_SECRET',
    'POSTGRES_PASSWORD',
    'MEILI_MASTER_KEY',
];

#[AsListener(BeforeExecuteTaskEvent::class)]
function configure(BeforeExecuteTaskEvent $event): void
{
    $context = context();

    $taskName = (string) $event->task->getName();
    if (!str_starts_with($taskName, 'production:')
        || str_starts_with($taskName, 'production:docker:')
        || 'production:repack' === $taskName) {
        return;
    }

    if (!fs()->exists($context->workingDirectory . '/docker-compose.yml')) {
        io()->info('No docker-compose.yml file found in the current directory. Creating one from the default template.');
        fs()->copy(__DIR__ . '/docker-compose.yml', $context->workingDirectory . '/docker-compose.yml');
    }

    if (!fs()->exists($context->workingDirectory . '/.env')) {
        io()->info('No .env file found in the current directory. Creating one from the default template.');
        fs()->copy(__DIR__ . '/.env.production', $context->workingDirectory . '/.env');
    }

    foreach (DOT_ENV_GENERABLES as $envVar) {
        $dotEnvContent = file_get_contents($context->workingDirectory . '/.env');

        if (preg_match('/^' . preg_quote($envVar, '/') . '=.+$/m', $dotEnvContent)) {
            continue;
        }

        $value = bin2hex(random_bytes(16));
        $dotEnvContent = preg_replace('/^' . preg_quote($envVar, '/') . '=.*/m', $envVar . '=' . $value, $dotEnvContent);

        file_put_contents($context->workingDirectory . '/.env', $dotEnvContent);
    }
}

Nous avons également ajouté quelques tasks supplémentaires pour afficher les logs du projet Docker compose, pour afficher l’état des conteneurs ou encore pour détruire complètement toute trace du projet (pratique quand on veut juste tester la stack).

Section intitulée creation-d-un-executableCréation d’un exécutable

Castor propose une fonctionnalité de repack qui permet de packager un projet Castor avec ses tasks dans un Phar autonome, ce qui permet de le partager et l’utiliser sans avoir besoin d’avoir le code du projet, ni même d’avoir Castor installé. C’est parfait pour notre environnement de production / On-Premise car hormis PHP, nous avons rien à installer à l’avance pour pouvoir démarrer l’application.

Dans le dossier tools/production/, nous allons donc placer plusieurs fichiers qui seront inclus dans le phar :

  • un fichier .env.production avec les vars d’env à définir obligatoirement ;
  • le fichier docker-compose.yml vu dans le précédent article ;
  • le fichier castor.php du chapitre précédent qui contient les tasks pour piloter la stack.

Nous allons donc maintenant pouvoir repacker le projet Castor situé dans ce dossier tools/production/. La commande à lancer en dev pour repacker notre projet sera évidemment encapsulée dans une task Castor (donc placée dans le dossier habituel .castor/) :

const PRODUCTION_DIRECTORY = __DIR__ . '/../tools/production/';

#[AsTask(description: 'Repack production application in a new phar')]
function repack(): void
{
    io()->title('Repacking production application.');

    // Castorception \o/
    run('castor repack --app-name=arsol --no-logo', context: context()->withWorkingDirectory(PRODUCTION_DIRECTORY));

    fs()->mkdir(PRODUCTION_DIRECTORY . '/build');
    fs()->remove(finder()->in(PRODUCTION_DIRECTORY . '/build')->ignoreDotFiles(false));
    fs()->rename(PRODUCTION_DIRECTORY . '/arsol.linux.phar', PRODUCTION_DIRECTORY . '/build/arsol.phar', true);
}

Il ne reste qu’à installer le fichier tools/production/build/arsol.phar sur le serveur, par exemple dans /usr/bin/local/arsol. On peut dès maintenant lancer la commande arsol production:start (notez le nom de l’exécutable 😎) pour initialiser et démarrer complètement l’infrastructure.

Info

Si nous le voulions, nous pourrions également compiler ce phar dans un binaire statique contenant PHP, ce qui aurait éliminé le besoin d’avoir PHP installé sur le serveur pour pouvoir utiliser le projet repacké. N’hésitez pas à jeter un œil à la documentation Castor qui explique comment compiler son projet.

Les tasks disponibles dans l'exécutable arsol

Section intitulée automatiser-le-build-depuis-gitlabAutomatiser le build depuis Gitlab

Dernière étape pour simplifier la vie du client, nous allons automatiser plusieurs choses directement depuis Gitlab :

  • Le build des images Docker de prod et le push sur le registre Docker ;
  • Le repack du projet tools/production dans un phar autonome ;
  • Ajout de ce phar dans les artefacts associés du job pour le rendre facilement accessible.

Voici un extrait du fichier .gitlab-ci.yml qui permet de faire cela :

stages:
  # ...
  - release

variables:
  CASTOR_CONTEXT: ci
  CASTOR_WORKING_DIR: /tmp/castor/${CI_PIPELINE_ID}

# ...

release:
  stage: release
  script:
    - export TAG=$(date +%Y.%m.%d)
    - export RELEASE_DIRECTORY=/tmp/castor-release/${TAG}
    - rm -rf ${RELEASE_DIRECTORY}
    - git clone . ${RELEASE_DIRECTORY}
    - cd $RELEASE_DIRECTORY
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
    - castor production:docker:build --push --update-docker-compose "$TAG" --force
    - castor production:repack
    # On déplace le phar à la racine, pour qu'il soit exposé en tant qu'artefact du job
    - cp tools/production/build/arsol.phar "$CI_PROJECT_DIR/"
    # On est sur un self-hosted runner donc on fait le ménage avant de terminer
    - castor docker:destroy --force
    - rm -rf $RELEASE_DIRECTORY

  # Cet artefact sera associé au job, et donc facilement récupérable depuis GitLab
  artifacts:
    paths:
      - arsol.phar
    expire_in: 1 week

  # Ce job est à lancer manuellement et n'est disponible que sur la branche main
  when: manual
  allow_failure: true
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
    - when: never

Avant de finir cet article, je voudrais revenir sur un point vu précédemment. Vous vous souvenez de la variable PROJECT_NAME qui était employée pour préfixer le nom de l’image de la stack de dev ? C’était nécessaire justement quand la stack de prod est construite dans la CI GitLab.

En effet, comme nous utilisons un runner self-hosté, les jobs ne sont pas isolés et tournent sur la même machine en parallèle. Pour ne pas avoir de conflits dans les noms et avoir une stack Docker indépendante entre chaque build de la CI, nous avons donc besoin que les projets Docker compose utilisent un nom unique pour chaque build. Notre template docker-starter est parfaitement compatible avec ce use-case et expose une variable dans le contexte qui permet d’adapter le nom du projet Docker compose à notre guise :

// castor.php à la racine du projet

function create_default_variables(): array
{
    return [
        // Nom du projet docker compose par défaut en local
        'project_name' => 'arsol',
        // ...
    ];
}
// .castor/context.php


#[AsContext(name: 'ci')]
function create_ci_context(): Context
{
    $pipelineId = $_SERVER['CI_PIPELINE_ID'] ?? throw new \RuntimeException('CI_PIPELINE_ID is not set. This context should only be used in a CI environment.');

    $c = create_test_context();

    return $c
        ->withData([
            'project_name' => 'arsol-ci-' . $pipelineId,
            'docker_compose_files' => [
                'docker-compose.yml',
                'docker-compose.ci.yml',
            ],
        ], recursive: false)
    ;
}

Comme nous forçons le context CASTOR_CONTEXT=ci dans GitLab, toutes les stacks construites dans cet environnement sont automatiquement nommées avec un suffixe qui reprend l’id de la pipeline actuelle.

Au passage, on rajoute un docker-compose.ci.yml au projet qui va rajouter quelques configurations spécifiques à la CI (comme des variables d’environnement ou un routeur adaptés)

Section intitulée conclusionConclusion

Pour conclure, grâce au template docker-starter et à Castor, nous avons pu mettre en place un déploiement d’ArSol qui convient à la fois pour la production et le On-Premise mais qui reste simple à l’usage :

  • Les images Docker pré-construites garantissent des déploiements fiables et déterministes ;
  • Les images sont optimisées pour ne contenir que ce qui est nécessaire, sans les outils de build ;
  • Tout passe par Castor, la complexité Docker/CI est masquée ;
  • Le repack Castor génère un seul exécutable (arsol.phar) pour tout gérer (démarrer, arrêter, mettre à jour) sur site, même sans être un expert système.

L’automatisation complète via Castor et GitLab CI permet au client d’être autonome pour préparer et déployer une mise à jour pour tous les environnements. Une preuve que Docker et Castor sont la bonne formule pour transformer des contraintes de terrain en solutions d’infrastructure efficaces et élégantes.

Commentaires et discussions

Nos articles sur le même sujet

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise