Le contexte

Avec une équipe de SensioLabs, nous avons accompagné une entreprise dans la définition de l’architecture technique d’un projet jusqu’à sa mise en production.

Le projet consiste à déployer une API Rest et une interface en Angular. L’API doit pouvoir absorber beaucoup d’appels simultanés ayant une grande quantité de données chacun. Étant donné que les traitements métiers, pour les parties les plus critiques, n’ont pas besoin d’être traités en synchrone, il a été décidé de mettre en place un gestionnaire de file de messages. Ainsi les appels à l’API publient des messages dans des files et un identifiant unique est instantanément retourné pour que l’appelant puisse revenir par la suite afin de savoir si sa demande a été traitée et obtenir le résultat. Les files de messages sont consommées au fur et à mesure afin de réaliser les traitements métier de manière asynchrone.

Mais un problème est survenu dans une des fonctionnalités.

Un collègue Wael El Sawah, a traité la majeure partie de la résolution du problème. Je le remercie de m’avoir autorisé à décrire ce qu’il a effectué.

La fonctionnalité

Une entité Provision contient un champ statut ainsi que d’autres données. Lors des changements de statut, des vérifications doivent être faites pour autoriser le changement et, en fonction des cas, des modifications doivent être appliquées.

Afin de traiter le statut, les tests et les modifications, nous avons utilisé une state-machine modélisée grâce à Yohang/Finite. Cette bibliothèque permet de définir différents ensembles d’états et de transitions liés à une entité. Des événements permettent de compléter les tests pour les changements d’état, vérifier une date par exemple, et de compléter les actions lors des changements, remise à zéro d’un compteur par exemple.

Des actions extérieures réalisées sur des terminaux (portables, tablettes, etc…) provoquent l’arrivée de données sur l’API indiquant qu’une Provision spécifique doit essayer d’évoluer vers un nouveau statut. Dans un souci de performance, ces données sont d’abord stockées dans une file de messages puis dépilées par des consommateurs. Ces derniers se chargent d’orchestrer les changements.

Tests de montée en charge

En local sur la machine de développement et avec un jeu de données conséquent, une simulation de plusieurs terminaux générant des actions valide le fonctionnement mis en place et les performances. Le temps moyen est dans la fourchette des attentes.

En préproduction, qui est une réplique de l’environnement de production, l’application est alors déployée. Des données sont alors injectées lors de tests de montée en charge qui doivent valider les performances dans toutes les étapes de la vie de l’application.

Le test des actions
C’est là que le test des actions arrive

Un test de montée en charge avec 47 000 actions est effectué, la file de messages est traitée en … 14H.

Le problème
Allo Houston ? On a un problème !

Nous étions confiants, la moyenne de temps de traitement en local est correcte, mais la moyenne du temps de traitement en préproduction est largement plus lente, 2800% fois plus lente, et est problématique.

Le problème

Le problème est difficile à qualifier. En effet, les tests de montée en charge ont été effectués par une tierce personne et nous n’avons pas le détail des scénarios pour les reproduire ou les différents graphiques de l’évolution des ressources des briques logicielles.

Une analyse du code à la recherche des coupables usuels ne donne rien. Le fichier MySQL slow query n’indique pas de requête lente, ce qui tend à indiquer un problème côté PHP.

Exemple de coupables usuels :

  • un flush() de l’entity manager se trouvant dans une boucle sans clear()
  • une écriture de fichier intensive (exemple : logs applicatifs)
  • la configuration Symfony activant le cache qui n’est pas utilisée ou incorrecte
  • une extension PHP de débogage/profilage est toujours active et prend des ressources (Xdebug, xhprof, …)
  • une requête lente

L’analyse des logs applicatifs indique que les messages se dépilent bien, mais le traitement est anormalement long. Après calculs, on peut remarquer que les messages sont rapidement dépilés au début, puis le temps de traitement s’allonge rapidement pour devenir très long.

Meminfo

En discutant avec les personnes qui s’occupent de l’infrastructure, nous avons obtenu des courbes sur l’utilisation du CPU et de la mémoire. On observe alors que la courbe d’utilisation de la mémoire augmente rapidement.

Afin d’analyser l’utilisation de la mémoire, nous allons utiliser une extension PHP : meminfo.

Installation

En développement, nous utilisons Docker, voici la procédure pour ajouter l’extension dans un fichier Dockerfile qui étend l’image officielle PHP :

FROM php:5.6-apache

# [...]

# Add Meminfo
RUN curl -fsSL 'https://github.com/BitOne/php-meminfo/archive/master.tar.gz' -o meminfo.tar.gz \
    && mkdir -p meminfo \
    && tar -xf meminfo.tar.gz -C meminfo --strip-components=1 \
    && rm meminfo.tar.gz \
    && ( \
        cd meminfo/extension \
        && phpize \
        && ./configure --enable-meminfo \
        && make -j$(nproc) \
        && make install \
    ) \
    && rm -r meminfo \
    && docker-php-ext-enable meminfo

Pour l’installation en local, vous pouvez consulter la documentation.

Attention : cette extension traque ce qui se passe dans la mémoire des processus PHP. Elle a donc un impact sur les performances et n’est pas recommandée pour une utilisation en production.

Test du consommateur

Une fois installé, un test est effectué avec le consommateur. Le test porte sur 1200 messages. Pour obtenir les informations de l’extension, vous devez ajouter ce code dans votre script :

<?php

meminfo_objects_summary(fopen('php://stdout','w'));

Ici le résultat sera affiché dans la sortie standard (la console), mais vous pouvez aussi placer le contenu dans un fichier.

Voici ce que l’extension vous fournit (limité ici au top 20) :

Instances count by class:
rank         #instances   class
-----------------------------------------------------------------
1            15614        Symfony\Component\OptionsResolver\OptionsResolver
2            15613        Finite\Transition\Transition
3            13215        DateTime
4            13211        Symfony\Component\Stopwatch\StopwatchPeriod
5            13211        Finite\State\State
6            6006         Ramsey\Uuid\Uuid
7            1202         AppBundle\Entity\Base\Constraint
8            1202         Symfony\Component\PropertyAccess\PropertyAccessor
9            1202         AppBundle\Entity\Base\Round
10           1202         AppBundle\Entity\Base\Provision
11           1201         Proxies\__CG__\AppBundle\Entity\Base\ServiceManagement
12           1201         Proxies\__CG__\AppBundle\Entity\Base\Address
13           1201         Finite\State\Accessor\PropertyPathStateAccessor
14           1201         Proxies\__CG__\AppBundle\Entity\Base\Campaign
15           1201         Proxies\__CG__\AppBundle\Entity\Base\Service
16           1201         Finite\StateMachine\StateMachine
17           196          Symfony\Component\Console\Input\InputOption
18           143          ReflectionProperty
19           95           Doctrine\ORM\Mapping\Column
20           82           JMS\Serializer\Annotation\Groups

On remarque très rapidement le nombre d’instances de Finite\StateMachine\StateMachine qui est élevé et qui correspond au nombre de messages envoyés pendant le test. Les autres instances du top 20 sont des objets dont la chaine de références remonte à Finite\StateMachine\StateMachine.

En programmation, une référence est une valeur qui est un moyen d’accéder en lecture et/ou écriture à une donnée située soit en mémoire principale soit ailleurs. Une référence n’est pas la donnée elle-même mais seulement une information sur sa localisation.1.

Étant donné que les StateMachine, une pour chacune de nos entités Provision, chargent les transitions qu’il est possible d’effectuer, on obtient alors encore plus d’instances de Finite\Transition\Transition en mémoire.

L’analyse du code de Finite nous apprend que la fabrique Finite\Factory\AbstractFactory garde les objets dans une sorte de cache situé dans un attribut de la classe. Notre problème prend ses racines sur cette partie.

<?php

namespace Finite\Factory;

// [...]

abstract class AbstractFactory implements FactoryInterface
{
    /**
     * @var StateMachineInterface[]
     */
    protected $stateMachines = array();
    
    // [...]
    
}

Il est normal que la fabrique utilise un cache. PHP a plus l’habitude de traiter des requêtes très rapidement pour envoyer une réponse. Si l’on charge une fois la StateMachine pour faire une opération, il y a de grandes chances qu’on souhaite continuer à faire d’autres opérations pendant la même requête. Le cache permet donc d’éviter le coût de l’instanciation de la StateMachine et les éléments associé à chaque fois.

Pour notre cas d’utilisation, le consommateur reste ouvert sur une longue durée et dans les tests chaque message fait appel à des modifications sur un élément unique. On se retrouve alors avec beaucoup de monde en mémoire, c’est un premier problème. Le deuxième problème est qu’une mauvaise configuration lance le processus PHP du consommateur sans limite de mémoire, ce qui fait que PHP essaie de réquisitionner toute la mémoire de la machine sans faire d’alerte sur l’abus.

Mais pourquoi le processus PHP continu de tourner ? Il devrait continuer jusqu’à ce que la mémoire de la machine soit complètement saturée et qu’il ne puisse plus placer de nouvelles instances en mémoire. Ceci est dû au fait qu’il existe une limitation, codée en dur dans PHP, au nombre d’instances. Au-delà de celle-ci PHP va exécuter un garbage collector pour tenter de libérer certaines de ces instances.

Malheureusement, dans notre cas, la référence dans le cache de la fabrique n’est jamais retirée et la chaine de références bloque la libération des instances des autres objets. Nous nous retrouvons donc avec le garbage collector de PHP qui essaie de libérer de la mémoire en permanence, ce qui est impossible.

Ce problème a été très discuté il y a quelque temps. C’était par rapport à composer qui rencontrait ce problème quand la résolution des dépendances a beaucoup de ramifications. Vous pouvez consulter cet article de Anthony Ferrara.

Correction et nouveaux tests

Les corrections sont simples. Pour la configuration de PHP, on rajoute une limite claire d’utilisation de la mémoire. Pour le nombre d’instances, Doctrine nous montre l’exemple d’une méthode clear() qui permet de libérer les instances dont on ne se sert plus, nous avons rajouté une méthode clear() qui vide le cache de la fabrique :

<?php

    /**
     * Clears all the machines.
     */
    public function clear()
    {
       $this->stateMachines = array();
    }

Puis on effectue de nouveaux tests :

Instances count by class:
rank         #instances   class
-----------------------------------------------------------------
1            196          Symfony\Component\Console\Input\InputOption
2            133          ReflectionProperty
3            85           Doctrine\ORM\Mapping\Column
4            82           JMS\Serializer\Annotation\Groups
5            74           JMS\Serializer\Annotation\Expose
6            64           Symfony\Component\Console\Input\InputDefinition
7            44           Symfony\Component\Console\Input\InputArgument
8            29           Doctrine\ORM\Mapping\Index
9            23           JMS\Serializer\Annotation\Type
10           20           Doctrine\ORM\Mapping\ReflectionEmbeddedProperty
11           18           JMS\Serializer\Annotation\SerializedName
12           18           Symfony\Component\Validator\Constraints\NotNull
13           16           Closure
14           16           ReflectionClass
15           15           DateTime
16           14           Doctrine\Instantiator\Instantiator
17           14           Doctrine\ORM\Persisters\Entity\CachedPersisterContext
18           14           Doctrine\ORM\Query\ResultSetMapping
19           12           Gedmo\Mapping\Annotation\Timestampable
20           11           Doctrine\ORM\Utility\IdentifierFlattener

Qui sont confirmés lors de nouveau test de montée en charge, les messages sont dépilés presque instantanément :

Nouveau test des actions
Tout est bon

Conclusion

Sur les projets OpenSource, faites une PR pour parler de votre cas et de la solution, ça peut toujours aider : https://github.com/yohang/Finite/pull/128

N’hésitez pas à faire un point sur votre configuration pour valider qu’elle est effective. Pensez à utiliser des outils qui permettent de le vérifier.

Si les machines à état et associé vous intéressent, consultez le nouveau composant de Symfony : Workflow.

Pour effectuer des vérifications sur l’utilisation de la mémoire, du CPU et de l’IO, Blackfire peut vous aider.

Références

1. Article Référence sur Wikipedia https://fr.wikipedia.org/wiki/R%C3%A9f%C3%A9rence_(programmation)