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 :
- s’assurer que vous êtes sur la branche main, à jour et sans modification locale ;
- demander la version des tags à créer en prenant par défaut la date du jour ;
- construire la stack Docker de dev si nécessaire (pour avoir les images de base utilisées) ;
- exécuter la commande Bake pour construire/taguer les images PHP ;
- exécuter les commandes Docker pour construire/taguer les images publiques (Postgres et Meilisearch) ;
- 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) ;
- 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.

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
Déploiement On-Premise – Partie 1 – Le socle Docker
Dans cet article, nous vous expliquons notre approche de déploiement hybride pour une application Symfony conteneurisée avec Docker. Ce système permet un déploiement à la fois sur des serveurs connectés à Internet…
par Loïck Piera
Nos articles sur le même sujet
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Castor
Découvrez Castor, l’outil PHP dédié à l’automatisation de tâches.
Ces clients ont profité de notre expertise
Ouibus a pour ambition de devenir la référence du transport en bus longue distance. Dans cette optique, les enjeux à venir de la compagnie sont nombreux (vente multi-produit, agrandissement du réseau, diminution du time-to-market, amélioration de l’expérience et de la satisfaction client) et ont des conséquences sur la structuration de la nouvelle…
Dans le cadre du renouveau de sa stratégie digitale, Orpi France a fait appel à JoliCode afin de diriger la refonte du site Web orpi.com et l’intégration de nombreux nouveaux services. Pour effectuer cette migration, nous nous sommes appuyés sur une architecture en microservices à l’aide de PHP, Symfony, RabbitMQ, Elasticsearch et Docker.
Nous avons entrepris une refonte complète du site, initialement développé sur Drupal, dans le but de le consolider et de jeter les bases d’un avenir solide en adoptant Symfony. La plateforme est hautement sophistiquée et propose une pléthore de fonctionnalités, telles que la gestion des abonnements avec Stripe et Paypal, une API pour l’application…