Comment vérifier le contenu d’une image docker

Au travail nous avons pour politique de builder et de maintenir nos propres images docker. Mais pour un projet perso j’utilise des images docker publiques. Je m’efforce d’utiliser au maximum des images officielles mais certaines contributions de la communauté sont parfaites pour mes besoins. Le problème est de savoir avec certitude ce que contient un image. Une image téléchargée sur un hub est une boîte noire qu’il faut inspecter avant de l’utiliser.

Cas d’images corrompues

Il y a l’exemple de l’utilisateur docker123321 qui a diffusé 17 images contenant des backdoors sur dockerhub. Parmi les images il y a tomcat, mysql ou encore cron. Avec quasiment 5 millions de téléchargements docker123321 a réussi à miner plus de 500 Moneros en plus des portes d’entrées qu’il a créées sur le serveur.

Contenu d’une image docker

Une image docker est une succession de couches (layers) qui contiennent une liste de modifications du système de fichiers. On peut faire une analogie avec GIT où chaque layer serait un commit. Au moment de la création d’un container un layer est ajouté au-dessus de ceux de l’image. Mis à part ce layer « applicatif » les layers sont en read only.

Inspecter les layers avec Dive

Dive est un programme écrit en Go qui va vous permettre d’en savoir plus sur une image avant de l’utiliser. Il ne va pas permettre de s’assurer à 100% que l’image n’est pas corrompue mais il va pouvoir nous donner des information précieuses. 

Pour l’installation je vous laisse vous référer au readme du projet.

Voici un exemple d’utilisation sur l’image jonbaldie/varnish que j’utilise pour un projet perso. 

➜ dive jonbaldie/varnish

Sur l’image on a 5 layers :

  1. L’image de base
  2. L’ajout d’un script d’installation (/install.sh)
  3. Le résultat de l’execution du script d’install
  4. L’ajout du script de boot (/start.sh)
  5. Le chmod du script de boot

Vérifier les scripts présents dans l’image

Sur la base des informations données par Dive, il ne nous reste plus qu’à vérifier, si possible, le contenu des fichiers install.sh et start.sh

Pour un fichier toujours présent dans le dernier layer

Le fichier start.sh n’est pas supprimé après l’installation. Il est donc simple de le consulter:

➜  docker run  --rm -ti jonbaldie/varnish cat /start.sh

Pour un fichier non présent dans le dernier layer

Pour le fichier install.sh il ne va malheureusement pas être possible de l’afficher car il est supprimé pendant le build. Le docker history de l’image ne me donne pas d’image Id correspondant à ce layer car je n’ai pas buildé l’image sur ma machine.

Docker history sur jonbaldie/varnish

Il n’est pas possible de lancer un container sur une image qui contient le install.sh. Et c’est bien là le problème ! C’est une boîte noire.

Pour arriver à consulter ce fichier il va falloir exporter l’image et naviguer dans les layers « à la main ».

Pour voir le fichier on va récupérer le tar id du layer qui nous intéresse via Dive :

On sait donc maintenant qu’il faut regarder le contenu du tar « c06fe384a155fd3501bdb5689a4d79a18c80a63243038184f457793490b7ddde » pour trouver mon fichier install.sh.

docker save jonbaldie/varnish -o layers.tar
tar xvjf layers.tar
cd c06fe384a155fd3501bdb5689a4d79a18c80a63243038184f457793490b7ddde
tar xvjf layer.tar
cat install.sh
view raw explore layers hosted with ❤ by GitHub
Récupérer un fichier dans un layer docker

Vérifier l’image de base

Dans notre cas l’image est construite à partir d’une debian officielle. Mais comment s’en assurer ? Est-ce que cette debian est à jour ?

Dans un premier temps on va chercher la version de debian installée :

➜  varnish docker run --rm jonbaldie/varnish cat /etc/os-release
Trouver la version de debian utilisée dans une image docker

Malheureusement je ne sais pas comment vérifier que cette debian est bien une version officielle. Je ne peux pas non plus m’assurer que les mises à jour de sécurité sont faites.

Le seul indicateur de qualité à ce niveau est de regarder les builds. Cette image est buildée automatiquement sur la Docker Cloud’s infrastructure. Ce point est primordial dans le choix d’une image non officielle car cela nous donne l’assurance que le docker file affiché est bien celui qui est utilisé pour le build.

Sur le dernier build par exemple nous avons plusieurs informations à disposition : 

Building in Docker Cloud's infrastructure...
Cloning into '.'...
KernelVersion: 4.4.0-93-generic
Arch: amd64
BuildTime: 2017-08-17T22:50:04.828747906+00:00
ApiVersion: 1.30
Version: 17.06.1-ce
MinAPIVersion: 1.12
GitCommit: 874a737
Os: linux
GoVersion: go1.8.3
Starting build of index.docker.io/jonbaldie/varnish:latest...

Step 1/9 : FROM debian
 ---> 874e27b628fd

Step 2/9 : MAINTAINER Jonathan Baldie "jon@jonbaldie.com"
 ---> Running in 3691027bb23a
 ---> 6e1fd6e2af21
Removing intermediate container 3691027bb23a

Step 3/9 : ADD install.sh install.sh
 ---> 2d9d517255c6
Removing intermediate container 1882f5bc4a22

Step 4/9 : RUN chmod +x install.sh && sh ./install.sh && rm install.sh
 ---> Running in 77dd464fc5be
Ign:1 http://deb.debian.org/debian stretch InRelease
//...
Get:8 http://security.debian.org stretch/updates/main amd64 Packages [227 kB]
Fetched 10.0 MB in 4s (2119 kB/s)
Reading package lists...
Reading package lists...
Building dependency tree...
Reading state information...

The following additional packages will be installed:
  binutils cpp cpp-6 gcc gcc-6 libasan3 libatomic1 libbsd0 libc-dev-bin
  libc6-dev libcc1-0 libcilkrts5 libedit2 libgcc-6-dev libgmp10 libgomp1
  libgpm2 libisl15 libitm1 libjemalloc1 liblsan0 libmpc3 libmpfr4 libmpx2
  libncurses5 libquadmath0 libtsan0 libubsan0 libvarnishapi1 linux-libc-dev
  manpages manpages-dev
Suggested packages:
  binutils-doc cpp-doc gcc-6-locales gcc-multilib make autoconf automake
  libtool flex bison gdb gcc-doc gcc-6-multilib gcc-6-doc libgcc1-dbg
  libgomp1-dbg libitm1-dbg libatomic1-dbg libasan3-dbg liblsan0-dbg
  libtsan0-dbg libubsan0-dbg libcilkrts5-dbg libmpx2-dbg libquadmath0-dbg
  glibc-doc gpm man-browser varnish-doc

The following NEW packages will be installed:
  binutils cpp cpp-6 gcc gcc-6 libasan3 libatomic1 libbsd0 libc-dev-bin
  libc6-dev libcc1-0 libcilkrts5 libedit2 libgcc-6-dev libgmp10 libgomp1
  libgpm2 libisl15 libitm1 libjemalloc1 liblsan0 libmpc3 libmpfr4 libmpx2
  libncurses5 libquadmath0 libtsan0 libubsan0 libvarnishapi1 linux-libc-dev
  manpages manpages-dev varnish

0 upgraded, 33 newly installed, 0 to remove and 1 not upgraded.
Need to get 30.5 MB of archives.
After this operation, 123 MB of additional disk space will be used.
Get:1 http://deb.debian.org/debian stretch/main amd64 libbsd0 amd64 0.8.3-1 [83.0 kB]
//...
Get:33 http://deb.debian.org/debian stretch/main amd64 varnish amd64 5.0.0-7+deb9u1 [690 kB]

 [91mdebconf: delaying package configuration, since apt-utils is not installed
 [0m
Fetched 30.5 MB in 1s (18.9 MB/s)

Selecting previously unselected package libbsd0:amd64.
Preparing to unpack .../00-libbsd0_0.8.3-1_amd64.deb ...
//...
Setting up varnish (5.0.0-7+deb9u1) ...
Processing triggers for libc-bin (2.24-11+deb9u1) ...
vcl 4.0;
backend default {
    .host = "127.0.0.1";
    .port = "8080";
}
 ---> 0953378c3b8d
Removing intermediate container 77dd464fc5be

Step 5/9 : VOLUME /var/lib/varnish /etc/varnish
 ---> Running in c56c31df17fb
 ---> e7f1ae57b0f0
Removing intermediate container c56c31df17fb

Step 6/9 : EXPOSE 80
 ---> Running in e1f015e6366e
 ---> 6000a2d9d149
Removing intermediate container e1f015e6366e

Step 7/9 : ADD start.sh /start.sh
 ---> 085cec1148a7

Removing intermediate container 73e8ab753261
Step 8/9 : RUN chmod +x /start.sh
 ---> Running in 8ab33da3607a
 ---> 06f67d6546ac
Removing intermediate container 8ab33da3607a

Step 9/9 : CMD /start.sh
 ---> Running in 3910129787df
 ---> afa9f74c8d64

Removing intermediate container 3910129787df
Successfully built afa9f74c8d64
Successfully tagged jonbaldie/varnish:latest
Pushing index.docker.io/jonbaldie/varnish:latest...
Done!
Build finished

Première information, l’image est bien construite à partir de l’image debian officielle comme on peut le voir à la step 1: « Step 1/9 : FROM debian ».

Seconde information, l’image a été buildée le 17 Août 2017. Toutes les failles détectées depuis cette date sur varnish et debian stretch ne sont donc pas patchées.

Pour les images qui ne sont pas buildées automatiquement sur la plateforme je ne sais pas si c’est possible de vérifier l’intégrité de l’image de base. J’ai tenté plusieurs approches mais comme l’image utilise une « latest » et qu’elle a été remplacée par une nouvelle version je ne vois pas comment faire pour retrouver mon layer. Si vous avez une solution, je suis preneur.

 Builder ses propres images

La solution qui présente le moins de risques est de builder soi-même ses images à partir des Dockerfiles mis à disposition par les contributeurs. De cette manière on peut faire une revue complète du code et des dépendances.  En buildant moi-même mon image je pourrais avoir une version plus à jour de debian et de varnish.

➜ git clone https://github.com/jonbaldie/varnish.git
➜ cd varnish
➜ docker build -t alahaxe/varnish:latest .
Builder une image docker depuis les sources github

Conclusion

Il faut absolument se méfier des images mises à disposition sur le Dockerhub. Avant d’utiliser une image on doit se poser au minimum les questions suivantes :

  1. Est-ce que l’image est officielle ou buildée automatiquement ?
    • Si non, est-ce que les sources sont disponibles ?
      • Si oui, est-ce que le dockerfile présenté est vraiment celui qui a servi à builder l’image ?
  2. De quand date la dernière build ?

Au moindre doute il est préférable de builder sa propre version de l’image ou d’en utiliser une autre.

Intervention à Epitech Nancy

Avec le boulot, on a décidé de partager notre expérience dans les écoles et événements autour du WEB sur la région Nancéienne. Il n’est pas exclu, par la suite, de participer à des événements plus grands comme le forum PHP, le PHP Tour, le blendmix web ou autres événements du genre. Mais comme nous ne sommes pas très habitués à ce genre d’événements autant commencer plus modestement. Depuis des années nous assistons aux conférences, mais passer de l’autre côté du pupitre n’est pas une chose que l’on improvise.

Le 15 octobre nous avons fait notre première présentation technique « en extérieur » dans les locaux d’Epitech Nancy. Nous avons parlé de l’utilisation que nous faisons du cache HTTP sur notre infrastructure SOA.

C’est une très bonne expérience qui nous oblige à vulgariser notre travail pour pouvoir l’expliquer correctement à un groupe. En construisant notre présentation, nous avons dû nous replonger dans la documentation de varnish et des ESI afin d’éviter les approximations ou les contre-vérités. Il est parfois simple d’utiliser une technologie, avec une connaissance partielle, mais pour expliquer le fonctionnement, il faut la maîtriser totalement.

Côté étudiants, nous avons eu des retours plutôt positifs. Nous sommes repartis avec une liste de thématiques, propres à notre infra, qui pourraient être intéressantes à présenter la prochaine fois.

Une première expérience que l’on va essayer de répéter rapidement avec les écoles du secteur.

Symfony Messenger et rabbitmq

Symfony 4 apporte un nouveau composant qui va nous permettre de brancher notre application sur un, ou des, brokers de messages. Grâce à ce composant, on va pouvoir accélérer notre application en traitant de façon asynchrone tout ce qui n’est pas strictement nécessaire à l’affichage de nos pages. Ce module maintenu par la Core Team Symfony va à terme remplacer les bundles existants. 

Use case

Un exemple couramment utilisé pour expliquer pourquoi c’est nécessaire d’utiliser ce genre de technique est le suivant:

Un utilisateur passe une commande sur mon site de e-commerce. Il a payé sa commande et attend juste le message de confirmation. Si je traite tout en synchrone je vais devoir réaliser plusieurs traitements avant de lui rendre la main:

  1. Changer le statut de sa commande
  2. Mettre à jour les stocks et invalider les caches qui correspondent
  3. Envoyer un email de confirmation au client
  4. Prévenir le service de préparation de cette nouvelle commande
  5. Envoyer des metrics pour suivre le volume des ventes

Sur les 5 tâches citées, le client n’a besoin que de la première de façon immédiate. Les autres peuvent se faire dans les secondes ou minutes qui suivent. 

Au-delà du simple point de performance il y a aussi le problème de responsabilité unique. Si toute cette logique est dans le contrôleur, vous allez devoir gérer tout un ensemble de règles disparates. Le contrôleur va forcement devenir inmaintenable au bout d’un moment.

AMQP

Pour pouvoir différer les autres tâches, il va falloir que je pousse des messages dans des queues (type rabbitmq, SQS) et que des workers viennent les consommer pour réaliser les traitements.

Sur cet exemple, de Microsoft, on voit bien le principe d’un broker de message. Un ou plusieurs producteurs (senders) envoient des messages dans une queue et ils sont dépilés par un ou plusieurs consommateurs (receivers).

Dans le meilleur des mondes, vous n’avez qu’une technologie de service de queue et peut-être même qu’un seul serveur à interroger. Dans ce cas, c’est assez simple et vous utilisez sûrement déjà un client php pour le faire.

Mais si vous êtes à cheval sur plusieurs technos/ serveurs, c’est vite un casse-tête car il va falloir gérer plusieurs protocoles et serializers.

Symfony Messenger

Le composant Messenger va vous permettre de gérer les problèmes de techno et de serveurs grâce à de la configuration YML. Dans votre implémentation vous n’aurez pas à vous soucier de comment va partir votre message, ni de comment le sérializer. Le composant est compatible avec les brokers de messages AMQP (la plupart).

  • Message : Un objet PHP serializable
  • Bus : Le composant qui va s’occuper de la communication avec le queue manager et d’appliquer les middlewares que l’on aura pu enregistrer (logs, debug,…)
  • Handler : La classe qui va recevoir un message à exécuter. C’est cette classe qui va tenir la logique métier.
  • Receiver : Déserialize le message reçu via le bus et le transmet au bon handler.
  • Sender : Sérialize le message et le transmet au queue manager via le bus.

Nous allons appliquer ce principe pour le cas de l’envoi du mail de confirmation de commande. Il faudra répéter le pattern pour chacun des autres types d’action à effectuer.

Mise en application

Installation du composant

Le composant s’installe à l’aide de composer via la commande :

composer require symfony/messenger

Symfony flex va s’occuper automatiquement d’enregistrer le bundle et créer le fichier de configuration par défaut.  Le composant vient avec son intégration à la Symfony toolbar et au profiler. Il est donc possible de voir en détail les messages dispatchés lors d’un hit.

Configuration du composant

Je vais prendre le cas d’un rabbitmq. Il va falloir veiller à installer et activer l’extension php amqp.

Une fois que c’est fait, nous pouvons éditer le fichier de configuration de messenger pour y ajouter nos transports.

parameters:
messenger.transport.default_serialization_context: {groups: [messenger]}
framework:
messenger:
transports:
# les Data Source Name pour mes différents transports
amqp_mailer: '%env(MESSENGER_TRANSPORT_DSN_MAILER)%'
amqp_default: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
# En fonction du type de message on peut router le message
# pour ma part j'utilise des interface
'App\Message\Interfaces\Mailer': amqp_mailer
# Tout ce qui n'a pas matché avance passera ici
'*': amqp_default

Dans votre fichier .env à la racine de votre dépôt, il faudra ajouter les différents Data Source Name.

###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN_MAILER=amqp://LOGIN:PASSWORD@HOST:5672/%2f/mails
MESSENGER_TRANSPORT_DSN=amqp://LOGIN:PASSWORD@HOST:5672/%2f/default
###< symfony/messenger ###
view raw .env hosted with ❤ by GitHub

Création de notre message

Le message est un simple objet php.

namespace App\Message\Interfaces;
interface Message
{
}
view raw message hosted with ❤ by GitHub
L’interface par défaut de mes messages (Optionnel mais pratique à l’usage)
<?php
namespace App\Message\Interfaces;
interface Mailer extends Message
{
}
view raw mailer hosted with ❤ by GitHub
L’interface des objets de type mail
<?php
namespace App\Message;
use App\Message\Interfaces\Mailer;
class ConfirmCommandMailer implements Mailer
{
/**
* @var int
*/
protected $commandId;
/**
* ConfirmCommandMailer constructor.
* @param int $commandId
*/
public function __construct(int $commandId)
{
$this->commandId = $commandId;
}
/**
* @return int
*/
public function getCommandId() :int
{
return $this->commandId;
}
}

Dans mon message j’envoie les id des différentes entités. Selon vos besoins vous pouvez directement mettre les entités.

Envoie du message dans le broker

Maintenant que nous avons notre objet de message nous allons pouvoir le pousser.

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Message\ConfirmCommandMailer;
class CommandController extends Controller
{
/**
* @var MessageBusInterface
*/
private $bus;
/**
* CommandController constructor.
*/
public function __construct(MessageBusInterface $bus)
{
$this->bus = $bus;
}
/**
* @Route("/customer/{idCustomer}/command/{idCommand}/confirm", name="confirm_command", methods={"POST"})
* @param $name
*/
public function index($idCustomer, $idCommand)
{
// check des params
// changement du statut de la commande
//…
// on envoie notre message dans le broker
$this->bus->dispatch(new ConfirmCommandMailer($idCommand));
// rendu du tpl
}
}

Traitement du message

Pour le moment, votre application ne fonctionnera pas car Symfony Messenger refusera de prendre en compte un message dont il ne connait pas le handler.

<?php
namespace App\Message\Handler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Message\ConfirmCommandMailer;
use \Swift_Mailer;
/**
* Class ConfirmCommandMailerHandler
*/
class ConfirmCommandMailerHandler
{
/**
* @var Swift_Mailer
*/
protected $mailer;
/**
* @var EntityManagerInterface
*/
protected $entityManager;
/**
* ConfirmCommandMailerHandler constructor.
* @param \Swift_Mailer $mailer
* @param EntityManagerInterface $entityManager
*/
public function __construct(Swift_Mailer $mailer, EntityManagerInterface $entityManager)
{
$this->mailer = $mailer;
$this->entityManager = $entityManager;
}
/**
* @param ConfirmCommandMailer $message
*/
public function __invoke(ConfirmCommandMailer $message)
{
$command = $this->entityManager->getRepository(Command::class)->find($message->getCommandId());
// verifier que les données sont cohérantes
$message = (new \Swift_Message('Commande confirmée'))
->setFrom('no-reply@example.com')
->setTo($command->getCustomer()->getEmail())
->setBody(
"Commande #" . $command->getId() . " confirmée"
)
;
$this->mailer->send($message);
}
}

Il faut maintenant enregistrer notre handler dans le container avec le tag ‘messenger.message_handler’.

App\Message\Handler\ConfirmCommandMailerHandler:
tags: ['messenger.message_handler']
view raw service.yml hosted with ❤ by GitHub

À partir de ce moment, Symfony va vous autoriser à dispatcher les messages ConfirmCommandMailer. Pour cet exemple, j’ai utilisé  Swift Mailer mais libre à vous d’utiliser une autre librairie.

Le composant va utiliser la reflection PHP pour détecter le handler qui doit être utilisé pour un message. Il va regarder le type du paramètre passé à la fonction __invoke. 

Lancer le worker

Maintenant que l’on a tout ce qu’il nous faut, il ne reste plus qu’à lancer notre consommateur. Il faudra lancer au minimum autant de workers que de channels.

bin/console messenger:consume-messages amqp_mailer

Il y a pas mal d’options disponibles pour limiter la durée de vie du daemon, la mémoire allouée, le temps de pause entre chaque message traité… Pour ma part je lance toujours au moins deux consommateurs pour un type de message et je fais en sorte qu’ils se tuent automatiquement tous les n messages traités.

Voici une démonstration sous forme de GIF de ce que l’on vient de faire. Pour simplifier la démonstration, j’ai fait une seconde commande Symfony qui pousse notre message dans la queue.

En production

En production il faut automatiser le lancement du daemon et le relancer en cas de crash. Pour ce faire vous pouvez utiliser supervisor avec la config suivante :

[program:amqp_mailer]
command=php /symfony/bin/console messenger:consume-messages amqp_mailer
startsecs = 0
stdout_logfile=/tmp/supervisord-amqp.log
stdout_logfile_maxbytes=10MB
view raw gistfile1.txt hosted with ❤ by GitHub

Généralement je fais tourner les workers dans des conteners docker dans un cluster swarm. De cette manière je peux gérer le nombre de consommateurs par type de message à la volée. Voici un exemple de Docker file qui peut faire tourner un worker symfony messenger. Je l’ai fait pour un projet perso, il n’est donc pas parfaitement optimisé pour de la vraie prod. 

FROM php:7.2
# Composer
RUN php -r "copy('https://getcomposer.org/installer&#39;, 'composer-setup.php');" \
&& php -r "if (hash_file('SHA384', 'composer-setup.php') === '544e09ee996cdf60ece3804abc52599c22b1f40f4323403c44d44fdfdd586475ca9813a858088ffbc1f233e9b180f061') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \
&& php composer-setup.php –install-dir=/bin –filename=composer \
&& php -r "unlink('composer-setup.php');"
# amqp
RUN apt-get update && apt-get install -y librabbitmq-dev libssh-dev \
&& docker-php-ext-install opcache bcmath sockets \
&& pecl install amqp \
&& docker-php-ext-enable amqp
# core tools
RUN apt-get install -y git-core
# php exts
RUN docker-php-ext-install pdo pdo_mysql zip mbstring
RUN mkdir /app
WORKDIR /app
env QUEUE_NAME amqp_default
ENTRYPOINT ["sh", "-c", "/app/bin/console messenger:consume-messages ${QUEUE_NAME} –limit=100 –time-limit=900 -n"]
Exemple de docker file simple pour un worker Symfony messenger

Et voici comment l’utiliser dans le cadre d’un docker compose :

version: '3'
services:
worker_mailer:
image: ma-super-image-de-worker:latest
environment:
– QUEUE_NAME=amqp_mailer
volumes:
– "/PATH/VERS/MON/APP/SF:/app:rw"
deploy:
mode: replicated
replicas: 2

Vous pouvez maintenant faire un docker-compose up ou le lancer sur un cluster swarm via la commande docker stack deploy.

Limitation

Pour le moment il n’y a pas de solution « out of the box » pour gérer le re-jeux des messages en cas d’erreur. C’est à vous de catcher les erreurs et soit de les pousser dans une autre queue, d’écrire un log, une métrique, ou de les stocker en base pour les identifier au besoin. C’est une lacune assez importante de la librairie qui devrait être corrigée.

Les problèmes possibles

Que veux dire l’erreur : Attempted to load class « AMQPConnection » from the global namespace.
Did you forget a « use » statement?

Ce message vous signale que vous n’avez pas l’extension amqp d’activée sur votre machine. Il faut installer et activer l’extension php-amqp.

Comment résoudre l’erreur: [ErrorException] Declaration of SymfonyFlexParallelDownloader::getRemoteContents($originUrl, $fileUrl, $context) should be compatible with ComposerUtilRemoteFilesystem::getRemoteContents($originUrl, $fileUrl, $context, ?array & $responseHeaders = NULL)

Il faut exécuter composer update –no-plugins pour mettre Symfony flex à jour.

Comment corriger l’erreur : [SymfonyComponentMessengerExceptionNoHandlerForMessageException]
No handler for message « AppMessageConfirmCommandMailer ».

Il faut veiller à deux points pour trouver la source de cette erreur. Premièrement que vous avez bel et bien créé un handler avec une méthode __invoke qui prend un objet de type AppMessageConfirmCommandMailer en premier et unique paramètre. Et dans un second temps que vous avez bien ajouté votre handler dans votre fichier services.yml avec le tag « messenger.message_handler »

Du machine learning dans mes cocktails

Dans cet article, nous allons parler Mojitos, machine learning et PHP, trois sujets que l’on mélange assez rarement, mais qui dans le cas du site Cocktailand sont rassemblés.

Use case

Je souhaite afficher la recette de cocktail qui ressemble le plus à celle que le visiteur est en train de lire. Comme je suis finalement quelqu’un d’assez fainéant, je n’ai pas envie de maintenir des listes de cocktails associés manuellement. Il faut donc trouver un moyen de calculer automatiquement cette liste pour les quelques 600 recettes et que cette liste soit mise à jour automatiquement pour intégrer les nouvelles recettes.

Données à disposition:

  • Les ingrédients de la recette, avec les volumes pour chacun
  • Est-ce que le cocktail est alcoolisé ou non
  • La catégorie du cocktail
  • Le nombre de vues de la recette de cocktail
Vision simplifiée de la base de données 

KNearestNeighbors

Afin de résoudre ce problème, je vais utiliser un algorithme de classification nommé KNearestNeighbors mis à disposition en PHP via la libraire php-ml avec une méthode de calcul de distance custom entre les cocktails.

Cet algo, aussi appelé le K-NN, permet de classifier des objets en fonction de la classe des autres objets déjà classifiés à proximité. La proximité est calculée par une méthode du type Euclidienne, Hamming,  Manhattan ou encore selon des règles spécifiques au domaine. Il suffit que la méthode soit idempotente et qu’elle retourne une valeur numérique pour qu’elle soit utilisable.

 Le K de cet algorithme est la valeur de validation croisée qui va permettre de choisir la classe à associer à l’objet. 

Pour déterminer la classe de l’objet vert, on va regarder la classe des objets à proximité, selon la valeur de K.

Pour K=1 on va choisir la classe de l’élément le plus proche.

Pour K=n on va choisir la classe la plus représentée dans les éléments sélectionnés.

Méthodologie

Plusieurs étapes sont nécessaires pour utiliser cet algorithme sur un jeu de données :

  1. Normaliser les données pour construire notre modèle
  2. Construire le modèle
  3. Ecrire ou Choisir la méthode de calcul de distance
  4. Choisir notre K
  5. Utiliser le modèle pour faire de la prédiction

Normalisation des données

Cette étape va nous permettre de supprimer tout ce qui n’apporte pas de sens et transformer toutes les données qui composent un cocktail par des entiers. Pour mon modèle j’ai choisi de prendre en compte seulement les informations suivantes :

  • Les ingrédients, en les représentant par leur id en base
  • Le fait de savoir si le cocktail est alcoolisé ou non (0 ou 1)

Afin de limiter le bruit dans le modèle j’ai décidé de supprimer des ingrédients non discriminants comme l’eau, la glace et le sucre. L’idée est de ne pas laisser l’algorithme penser que deux cocktails sont similaires car on y met des glaçons. Une fois que j’ai ma liste d’ingrédients nettoyée, il faut normaliser les données afin de les transformer en tableau d’entier. C’est un prérequis de l’implémentation de php-ml.

Pour le moment, je vais mettre de côté la popularité du cocktail, mais je pourrais la segmenter en 3 groupes peu/ moyen/ très populaire. Pour ce classement, il faudrait simplement ramener le nombre de vues en un nombre de 1 à 3 (via les percentiles 25% et 75% par exemple) et de valoriser cette information au moment du calcul de distance.

Construire le modèle

Pour cette étape, je vais créer une matrice qui aura en nombre de lignes le nombre de cocktails du site et en nombre de colonnes le nombre maximum d’ingrédients plus un d’un cocktail sur le site. Cette étape peut être particulièrement coûteuse en mémoire.
Une ligne de la matrice contient donc en premier index 0 ou 1 selon si le cocktail contient de l’alcool et dans les colonnes suivantes les ids des ingrédients qui composent le cocktail. Les cellules vides de la matrice sont remplies par des 0 car il est nécessaire d’avoir des lignes de la même taille.
Sur l’implémentation proposée, il y a un second tableau qui doit contenir les labels (classes) associées aux différentes lignes. J’ai choisi d’y mettre l’id (casté en string) du cocktail.
Comme cette étape est un peu coûteuse, je serialize la matrice dans un fichier pour pouvoir m’en resservir au besoin.

Une fois la matrice générée nous pouvons entrainer notre modèle (avec la méthode « train » ou « fit » selon vos librairies) sur notre jeu de données.

Choisir notre K

Dans notre cas, chaque élément est porteur de sa propre classe. C’est un cas un peu particulier, mais qui ne pose pas spécialement de problème pour cet algo. Mais au vu du fonctionnement de la valeur de validation croisée, il faut que nous utilisions un K à 1. Une valeur supérieure n’aurait pas de sens, car nous aurions autant d’ex aequo que la valeur de K.

Ecrire la méthode de calcul de distance

Comme notre modèle est assez spécifique et que faire une ACP sur notre matrice n’aurait pas de sens, j’ai pris le parti d’écrire ma propre méthode de calcul de distance.
La méthode de calcul de distance prend en paramètre deux lignes de la matrice et doit retourner un float (la distance). Libre à nous de réfléchir à la meilleure façon de faire ce calcul ou d’utiliser une méthode disponible de base.
Voici mon implémentation :

<?php
namespace App\Services;
use Phpml\Math\Distance;
class CocktailsDistance implements Distance
{
/**
* @param array $a
* @param array $b
*
* @return float
*/
public function distance(array $a, array $b):float
{
// un sans et un avec alcool ?
$distance = array_shift($a) !== array_shift($b)?10:0;
$count = count($a);
for ($i = 0; $i < $count; ++$i) {
// +2 de distance par différence de nombre d'ingrédient
if ($a[$i] === 0 && $b[$i] !== 0) {
$distance += 2;
continue;
}
// +3 si un ingrédient de a n'est pas présent dans le cocktail b
$distance += in_array($a[$i], $b, true) ? 0 : 3;
}
return $distance;
}
}

On remarque que j’utilise des nombres magiques dans mon algorithme (2, 3, 5, 10) . Les valeurs me permettent de donner plus ou moins d’importance à un type de différence. Une grande discrimination est donnée pour une différence au niveau de la présence ou non d’alcool.

Exemple sur un jeu de données

[table id=2 /]

Voici la matrice qui est générée pour ce jeu de données avec sur la droite le nom des cocktails associés à chaque ligne:

Dans les colonnes du premier tableau, nous avons dans la première colonne la présence ou non d’alcool et dans les autres les ids des ingrédients qui composent la recette.

Nous allons maintenant utiliser notre modèle pour trouver le cocktail le plus proche du Mojito.

// récupération de mon entité, avec doctrine ici
$mojito = $this->entityManager->getRepository(Cocktail::class)->find(666);
// nous génère la matrice en enlevant le mojito
$matrices = $this->cocktailMachineLearning->generateMatricesWithoutCocktails([$mojito]);
// initialisation de l'algo
$classifier = new KNearestNeighbors(1, new CocktailsDistance());
// entrainement de l'algo sur les données normalisées
$classifier->train($matrices['samples'], $matrices['labels']);
// normalisation du mojito
$normalizeMojito = [
intval($mojito->isAlcohol())
];
foreach($mojito->getIngredients() as $ingredientWithMeasure) {
$normalizeMojito[] = $ingredientWithMeasure->getIngredient()->getId();
}
sort($normalizeMojito);
while (count($normalizeMojito) < $model['maxNbIngredients']) {
$normalizeMojito[] = 0;
}
// prediction de la recette la plus proche
$cocktailLabel = $classifier->predict($normalizeMojito);
dump(cocktailLabel);
view raw prediction hosted with ❤ by GitHub

La sortie en console est la suivante:

C’est bien le résultat que l’on aurait imaginé, mais pour comprendre pourquoi ce résultat, il faut s’intéresser aux distances que l’algo a calculé pour chaque cocktail. Voici ce que l’on obtient :

On remarque que la discrimination sur l’absence d’alcool est forte. C’est une volonté de ma part pour éviter, si possible, de proposer des recettes avec alcool pour un cocktail sans alcool.

Sur le site le volume de données à traiter est beaucoup plus gros évidement mais le principe reste le même. 

Voici le résultat en production sur la recette du mojito:

Pour arriver à ce résultat sur un grand jeu de données, je répète l’algorithme n fois en retirant du modèle les recettes déjà sélectionnées.

Automatisation

La génération de la matrice est assez coûteuse, il faut quasiment dumper toute la base de données et travailler dessus. Il n’est donc pas possible de le faire « à la demande ». J’ai pris le parti de faire une commande Symfony qui est exécutée plusieurs fois par jour pour mettre à jour les données normalisées dans un fichier plat. Et pour la partie entraînement et prédiction, le résultat est mis en cache HTTP à l’aide d’un ESI pour 24h. Cela signifie qu’un nouveau cocktail ne remontera dans les suggestions que maximum 24h après son ajout à moins de flusher le cache varnish manuellement. Dans le cadre de Cocktailand cette latence est tout à fait acceptable.