Exécuter une commande Symfony avec Cron et Docker

Aujourd’hui un article pour expliquer comment exécuter des commandes dans Symfony (ex : bin/console app:my-command) automatiquement et à intervalles réguliers avec Cron en utilisant Docker.

6 étapes pour comprendre comment utiliser Docker, Symfony, Cron et Shell pour faire tourner du code automatiquement en PHP.

  1. Pourquoi une commande ?
  2. Création de la commande
  3. Création du service docker avec Docker-compose
  4. Construction de l’image Docker
  5. Création du script shell Entrypoint
  6. Création du script Mycronjob

1/ Pourquoi une commande ?

Je vous montre ci-dessous le code que j’ai mis en place dans le cadre du développement d’un site internet. Le cas d’étude est simple. Quand un utilisateur s’inscrit sur le site, si celui-ci n’a pas encore créé d’aventure sportive au bout de 5 jours, on lui envoie un petit mail de rappel pour lui présenter le concept et l’inciter à créer une aventure.

Ce code peut être adapté pour faire tourner n’importe quel service (ici : envoyer un e-mail à certains utilisateurs) qui a besoin d’être lancé à intervalles réguliers (par exemple : toutes les 10 minutes, une fois par mois, tous les jeudis,…)

Si vous souhaitez exécuter du code « récurrent » dans Symfony, la solution est d’utiliser le composant « Console » pour lancer des commandes.

On pourrait imaginer une page d’administration (ex : /admin/send-email) restreinte aux administrateurs qu’on viendrait ouvrir (ou exécuter en cURL) chaque jour mais ça ne serait pas franchement optimal.

La meilleure solution que j’ai trouvée est d’intégrer notre service dans une commande qu’on pourra lancer soit manuellement soit embarquée dans un script shell qui sera intégré dans une tâche Cron.

2/ Création de la commande

Dans notre cas, la commande sera très simple. Sachant qu’elle sera exécutée chaque jour, elle ira chercher les utilisateurs qui doivent recevoir un email, et pour chacun d’entre eux, on appellera un service qui enverra effectivement cet email.

<?php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
// tous les autres use

#[AsCommand(
    name: 'app:cron:trigger-user-without-adventure',
    description: 'Send an email to users who don\' have created an adventure',
)]
class CronTriggerUserWithoutAdventureCommand extends Command
{

    //fonction __construct() pour appeler les différents services utilisés (Logger, UserRepository,...)


    protected function execute(InputInterface $input, OutputInterface $output): int
    {

        $this->logger->info('we are ready to execute command : trigger user without adventure');

        $io = new SymfonyStyle($input, $output);

        $users = $this->userRepository->findUsersWithoutAdventure();

        if (count($users) > 0) {
            foreach ($users as $user) {
                $this->logger->info('send trigger email user without adventure', ['userId' => $user->getId()]);
                $this->userWithoutAdventure->sendEmailTriggerUserWithoutAdventure($user);
            }
        } else {
            $this->logger->info('no user found for trigger email');
        }

        $io->success('trigger user without adventure executed');

        return Command::SUCCESS;
    }
}

Langage du code : PHP (php)

Lignes 9 & 10 on défini le nom de notre commande (bien utilisé app: pour le début du nom pour respecter les bonnes pratiques) et une petite description pour se rappeler de son utilité. Ici le code tourne sur PHP 8.1 donc on peut utiliser les attributs PHP ( #AsCommand) et ça permet de rendre notre commande « lazy » en définissant le nom et la description de cette manière.

Notre classe doit étendre de la classe Command. Avec la configuration de base Symfony, ça nous permet également que notre commande soit automatiquement enregistrée avec le bon tag (console.command) grâce à autoconfigure:true qui est défini dans services.yaml.

Ensuite on utilise une seule méthode, execute(), de la classe Command (c’est vraiment le basique du basique). Cette méthode execute() doit retourner un integer pour indiquer le statut d’execution de notre commande. Ici, on supposera que ça se passe toujours bien et on retourne la constante Command::SUCCESS (qui vaut 0). Les autres options sont FAILURE et INVALID.

Ligne 21, on logue juste qu’on est bien prêt à exécuter la commande, ça permet de vérifier dans var/log/app.log que notre script a bien tourné. Ligne 23, on initialise SymfonyStyle, c’est totalement optionnel, on s’en servira uniquement ligne 36 pour afficher un joli message vert de confirmation que tout est ok:

Entre les lignes 25 et 34 se trouve le code qui va effectivement faire le « travail ». On appelle une custom query de notre UserRepository pour chercher tous les utilisateurs inscrits il y a 5 jours qui n’ont pas encore d’aventure sportive. Ca pourrait être n’importe quel autre type de requête.

Si jamais on a des résultats, on boucle sur chaque utilisateur pour appeler notre service « UserWithoutAdventure » qui sera en charge d’envoyer l’email à l’utilisateur concerné via la méthode sendEmailTriggerUserWithoutAdventure.

Et voilà, notre commande (très simple), est fonctionnelle. Comme montrée dans la capture d’écran ci-dessus, on peut la lancer manuellement en se connectant à notre container PHP via la commande docker exec -it id_container sh.

Cette instruction nous connecte à « l’intérieur » de notre container Docker et nous place dans le répertoire qu’on a défini par défaut (ici /srv/app). Il faut ensuite lancer l’instruction bin/console app:nom-de-la-commande pour qu’elle soit exécutée. On pourrait tout à fait exécuter l’ensemble des commandes Symfony de cette façon. Pour les lister, il suffit de lancer bin/console sans argument derrière (ou avec list, qui est la commande par défaut).

C’est tout pour la partie Symfony.

3/ Création du service docker avec Docker-compose

Maintenant que notre commande est ok, l’étape suivante est de créer un service Docker qui sera en charge de la lancer automatiquement.

Pour cette étape, je suis reparti des scripts créés par offen pour faire du backup de volume Docker. Les sources sont ici : https://github.com/offen/docker-volume-backup

Notre stack Docker est composée de multiples services : Caddy pour le serveur Web, PHP pour faire tourner Symfony, Mysql pour la base de données,… tous ces services communiquent entre eux (ou non) pour former notre site web au sein d’un même réseau. J’utilise Docker Compose pour organiser tous ces services, définir les volumes et les propriétés de chaque service (port, volume, labels, build,…).

Pour mettre en place notre cron job, on va créer un nouveau service Docker (avec une custom image Docker) qui sera en charge d’exécuter un script shell. Voici le docker compose de ce nouveau service :

version: "3.8"

services: 

#tous les autres services Docker

  php:
    image: myUsernameDocker/MyImageName:php
    deploy:
      restart_policy:
        condition: on-failure
    labels:
      - docker-cron.need-cronjob=true
   #autres propriétées de l'image PHP

 cron:
    image: myUsernameDocker/MyImageName:cron
    deploy:
      restart_policy:
        condition: on-failure
    environment:
      CRON_EXPRESSION: "5 7 * * *"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - myNetworkLangage du code : YAML (yaml)

Le service « cron » est défini et il utilisera une custom image Docker (code à venir ci-dessous). On défini également une variable d’environnement « CRON_EXPRESSION » qui indiquera au script shell à quelle fréquence tourner (dans notre cas, tous les jours à 7h05).

Notre service PHP est également modifié en venant lui ajouter un label, le nom est totalement arbitraire. Ce label nous permettra d’identifier (dans le script shell) tous les containers qui ont besoin de lancer une commande. Pour nous il n’y aura qu’un seul container (celui avec PHP) mais c’est utile quand on veut faire du backup de volumes sur plusieurs containers en même temps par exemple.

4/ Construction de l’image Docker « Cron »

L’image Docker utilisée dans le service cron ci-dessus est construite comme suit. Voici le Dockerfile :

FROM alpine:3.14

WORKDIR /root

RUN apk add --update docker openrc
RUN rc-update add docker boot

COPY src/mycronjob.sh src/entrypoint.sh /root/
RUN chmod +x mycronjob.sh && mv mycronjob.sh /usr/bin/mycronjob \
  && chmod +x entrypoint.sh

ENTRYPOINT ["/root/entrypoint.sh"]Langage du code : Dockerfile (dockerfile)

Ici je suis vraiment reparti des scripts créés par offen qui sont disponibles sur Github. On construit notre image en partant d’une base alpine (c’est une image minimale et super légère). L’idée est de venir prendre 2 scripts shell (mycronjob.sh et entrypoint.sh) qui sont stockés dans le dossier /src (relativement au Dockerfile). Voici l’architecture du dossier cron :

On « COPY » ces 2 fichiers dans l’image Docker, on leur donne les droits d’exécution avec chmod +x et on indique de lancer le script entrypoint.sh au lancement du container via l’instruction ENTRYPOINT.

5/ Création du script shell Entrypoint

Quand notre docker-compose va lancer tous les services Docker, il va appeler le script défini avec l’instruction ENTRYPOINT pour chaque image Docker. Pour notre service cron, le script entrypoint.sh ressemble à ça :

#!/bin/sh

set -e

# Write cronjob env to file, fill in sensible defaults, and read them back in
cat <<EOF > env.sh
CRON_EXPRESSION="${CRON_EXPRESSION:-@daily}"
EOF
chmod a+x env.sh
source env.sh

# Add our cron entry, and direct stdout & stderr to Docker commands stdout
echo "Installing cron.d entry with expression $CRON_EXPRESSION."
echo "$CRON_EXPRESSION mycronjob 2>&1" | crontab -

# Let cron take the wheel
echo "Starting cron in foreground."
crond -f -l 8Langage du code : PHP (php)

Là aussi je suis reparti du script créé par Offen. On commence par créer un fichier env.sh où l’on utilise la variable d’environnement défini dans notre docker-compose (si jamais on l’avait oubliée, la valeur par défaut est @daily). Ensuite on chmod ce fichier pour l’exécution et on le charge (avec source) dans le script en cours.

Le deuxième bloc de code va créer le crontab en utilisant la variable d’environnement (pour lui dire quand tourner) et le script mycronjob.

Enfin la commande crond va lancer le daemon pour démarrer cron.

On a 2 echo dans ce script qui s’afficheront dans les logs du container au moment du démarrage de celui-ci :

La capture d’écran ci-dessus est obtenu en faisant docker logs id_container. Ça nous permet de voir que le cron est bien lancé et actif pour notre container.

6/ Création du script Mycronjob

La dernière étape est de dire quoi faire au cron. Car d’un côté on a notre commande Symfony qui ne demande qu’à être exécutée et de l’autre on a cron qui tourne tous les jours mais sans savoir trop quoi faire. Le script mycronjob.sh va faire le lien entre les deux.

#!/bin/sh

function info {
  echo -e "\n[INFO] $1\n"
}

info "Preparing executing cron"
DOCKER_SOCK="/var/run/docker.sock"

if [ -S "$DOCKER_SOCK" ]; then
  TEMPFILE="$(mktemp)"
  docker ps -q \
    --filter "label=docker-cron.need-cronjob=true" \
    > "$TEMPFILE"
  CONTAINERS_WITH_CRON="$(cat $TEMPFILE | tr '\n' ' ')"
  CONTAINERS_WITH_CRON_TOTAL="$(cat $TEMPFILE | wc -l)"
  rm "$TEMPFILE"
  echo "$CONTAINERS_WITH_CRON containers concerned by cronjob"
  echo "$CONTAINERS_WITH_CRON_TOTAL total containers concerned by cronjob"
else
  echo "Cannot access \"$DOCKER_SOCK\", won't look for containers to stop."
fi

if [ "$CONTAINERS_WITH_CRON_TOTAL" != "0" ]; then
  info "executing cron for php"
  docker exec $CONTAINERS_WITH_CRON bin/console app:cron:trigger-user-without-adventure
  #eventuellement autres commandes à lancer avec le même intervalle de temps
fi
Langage du code : PHP (php)

Ce script est également issu du Github d’Offen pour le backup de volume Docker.

Ligne 12, on va utiliser la commande docker ps pour retrouver nos containers avec le label qu’on a défini dans le docker-compose. Dans notre cas il n’y aura toujours qu’un seul conteneur. On stock 2 informations : CONTAINERS_WITH_CRON contient l’id du container PHP et CONTAINERS_WITH_CRON_TOTAL nous indique le total des conteneurs concernés.

Ensuite on fait quelques echo pour avoir des informations supplémentaires dans nos logs. Si on a trouvé au moins un container avec le label on lance la commande docker exec id_container bin/console …

Ce docker exec va tout simplement exécuter notre commande Symfony qui elle même ira chercher les utilisateurs concernés puis enverra le mail en cas de besoin. Ci-dessous un extrait des logs du service cron :

Et voilà ! Mission accomplie. En repartant des scripts créés par Offen, j’ai réussi à mettre en place une automatisation du lancement de commandes dans Symfony grâce à Docker et Cron. Je ne suis pas spécialiste shell / cron donc y a surement moyen de faire mieux, mais pour mon usage, c’est amplement suffisant. Notre commande sera exécutée chaque jour sans effort, les utilisateurs recevront un email et on pourra consulter les logs directement dans les stats du service Docker.

Retour en haut