Auditer un code réactif

La programmation réactive n'utilise plus d'appels bloquants et se limite à un thread par cœur du processeur. Cela, c'est pour la théorie. En pratique, comment s'assurer qu'aucun appel bloquant n’est-il réalisé ? C'est à ce challenge que nous nous sommes attaqués pour des applications tournant sur une machine virtuelle Java
Par Philippe PRADOS - 2014
www.prados.fr
Qu'est-ce que la programmation réactive ?
Le reactive manifesto définit une application réactive autour de 4 piliers liés entre eux : event-driven, responsive, scalability et résilience.
Une application réactive est dirigée par les événements, capable d’offrir une expérience utilisateur optimale, de mieux utiliser la puissance des machines, de mieux tolérer les erreurs et les pannes.
Le concept le plus fort est l’event-driven. C’est lui qui détermine le reste. Un modèle réactif est un modèle de développement dirigé par les événements.
La technologie node.js est un exemple d'environnement réactif. Ce framework permet la réalisation de serveurs web codés uniquement en Javascript. Comme ce langage n'est pas multi-tâches, toutes les API proposées sont asynchrones. Un callback doit être indiqué pour reprendre la main après un traitement long comme un accès réseau ou disque. Un serveur node.js n'utilise qu'un seul et unique thread. Pour exploiter tous les cœurs du processeur, il faut lancer plusieurs instances de serveurs node.js. Une instance par cœur.
D'autres technologies préconisent ce modèle. Le framework Play permet la réalisation de sites web à l'aide du langage Scala (compatible java). C'est un framework léger, sans état et très rapide.
On retrouve également l'approche réactive dans les frameworks d'Acteurs comme Akka. Un acteur est un module logiciel isolé, recevant des messages via une file et les traitant les uns après les autres. Un acteur est très léger. Il est alors possible d'en créer des milliers et de les distribuer sur différents serveurs suivant les besoins. Pour exécuter tous les messages des différents acteurs, un pool de threads limité au nombre de cœurs est utilisé. Chaque message doit alors être exécuté le plus rapidement possible pour restituer le thread au pool, afin de pouvoir traiter un message d'un autre acteur. Cela entraîne que les acteurs doivent fonctionner en asynchrone pour chaque message. Si un acteur garde la main à cause d'une I/O capricieuse, cela peut bloquer tous les autres acteurs.
Java 8 propose de nouvelles API spécialisées dans l'utilisation de ce modèle d'exécution. Les traitements sont découpés en tranches et exécutés en parallèle via un pool de threads unique de taille fixe. Notre article précédent a traité de la classe CompletableFuture de Java 8. En gérant correctement toutes les I/O via les API asynchrones, il est plus facile d'optimiser l’utilisation de la CPU.
Le multi-tâche peut être utilisé dans deux approches :
- la distribution
- le parallélisme proprement dit
La distribution consiste à découper un traitement en plusieurs traitements plus petits pour pouvoir exécuter chaque morceau sur des cœurs différents. Cette approche permet d'exploiter les algorithmes parallèles. Java8 propose Collection.parallelStream() et d'autres API pour cela.
Le parallélisme consiste à exécuter des traitements indépendants sur les différents cœurs disponibles. C'est l'approche pratiquée par tous les serveurs Web. Plusieurs requêtes venant de plusieurs clients peuvent s'exécuter en même temps.
Ces deux concepts ne sont pas compatibles. En effet, vouloir utiliser la distribution pour gérer une requête d'un utilisateur dans un modèle parallèle est contre-productif. En CPU pure, nous avons démontré qu'aucune approche de distribution ne présente de meilleures performances en combinaison avec une approche parallèle.
Le modèle d’architecture réactif est très pertinent pour les applications interagissant en temps réel avec les utilisateurs. Cela comprend :
- Les documents partagés (Google docs, Office 360)
- Les réseaux sociaux (diffusion de flux, Like/+1)
- Les analyses financières (flux du marché, enchères)
- Les informations mutualisées (trafic routier ou de transports en commun, pollutions, bons plans, places de parking, etc.)
- Les jeux multijoueurs
- Les approches multi canals (le même utilisateur utilise son PC, son mobile et sa tablette en même temps)
- la synchronisation des applications mobiles (toutes au même moment sur les 3 devices de chaque utilisateur)
- Les API ouvertes ou privées (impossibles de prévoir l’usage)
- La gestion d’indicateurs (position GPS, capteurs sur le terrain, objets connectés)
- Les afflux massifs d’utilisateurs (manifestations sportives, soldes, fin de délai administratif, pub TV, Startup qui décolle, ouverture sur une nouvelle plate-forme mobile, etc.)
- Les communications directes (chat, hang-out)
- pour gérer plus efficacement des algorithmes complexes (réservation de places, gestion de graphes, web sémantique, etc.).
- etc.
Un des points clefs de toutes ces applications est la gestion de la latence. Pour qu’une application soit responsive, l’utilisateur doit percevoir une latence aussi faible que possible. C'est envisageable en n'étant jamais bloqué par une I/O.
Un cas particulier de l'utilisation de cette approche est présent dans les OS mobiles. Comme la latence réseau est très importante sur les mobiles, il n'est vraiment pas conseillé de faire un appel réseau dans le thread dédié à l'interface utilisateur. En effet, cela bloque l'utilisateur en attente d'un acquittement ou d'une donnée. De nombreux programmeurs ignorent cette contrainte, car ils développent en Wi-fi avec un bon réseau et non en 2G avec un réseau chaotique.
Les premières versions d'Android ne vérifiaient rien concernant cela. Maintenant, un mode strict interdit l'utilisation d'API bloquantes lorsqu'elles sont invoquées dans le thread de l'interface utilisateur. Pour cela, Google a ajouté un test dans la petite dizaine d'API bloquantes. Une exception est maintenant générée si le développeur ne respecte pas cette directive.
Comment réduire la latence ?
Les modèles de développement traditionnels s'appuient sur des API synchrones pour tout ce qui concerne les entrées /sorties. Par exemple, le programme ouvre un fichier, lit les premiers octets, les analyses, les envoie sur le réseau, attend l'acquittement, puis continue avec les octets suivants.
Tout cela est facile à comprendre, à coder, mais présente plusieurs problèmes d'optimisation. À chaque entrée/sortie, le programme est interrompu par le kernel. Le thread est éjecté de l'ordonnanceur, jusqu'à la lecture effective du premier secteur du fichier, ou l'acquittement réseau. Pendant ce temps, la CPU ne fait rien. Elle attend des I/O. Pour réduire cette perte de temps, on multiplie les threads avec l’espoir qu’un traitement s’exécutera pendant l’attente des autres threads. Ou bien, on ajoute des serveurs avec scalabilité horizontale pour multiplier le nombre de traitements en parallèle.
L'approche réactive consiste à mieux exploiter les capacités des processeurs en réduisant les temps d'attente où rien ne se passe. L'objectif ultime de ce modèle est de n'utiliser que des API non bloquantes lors de l'utilisation des entrées sorties, et de ne pas avoir plus de threads qu'il y a de cœurs dans le processeur.
Pour répondre à ce besoin, les dernières générations des langages de développement proposent un pool de threads centralisé, initialisé suivant le nombre de cœurs.
Scala propose un ExecutionContext, Java 8 propose un commonPool et .Net propose TaskPool.
Multiplier les threads est nuisible aux performances pour deux raisons principales :
- Il y a une perte de temps lors de switchs de contexte, lors du passage d'un thread à un autre
- le délai pour qu'un traitement reprenne la main est proportionnel au nombre de threads en attente.
En fait, en multipliant les threads, l'ordonnanceur du kernel a deux stratégies à sa disposition. Soit il réduit le temps accordé à chaque thread pour donner la main aux autres threads plus rapidement, soit il augmente le temps total permettant d'exécuter tous les threads.

Dans le premier cas, il y a plus de switchs de contexte. Dans le deuxième, il y a plus d'attente avant de reprendre la main.
Dans les faits, l'ordonnanceur utilise un mélange des deux approches pour essayer de réduire l'impact sur les threads les plus importants.
Idéalement, s'il n'y avait qu'un seul thread, il n'y aurait pas ces difficultés. C'est ce que propose le modèle réactif, fondé sur des callbacks lors de l'utilisation d'API non bloquantes.
Autre avantage de ce modèle, il n'est plus nécessaire de définir arbitrairement un nombre maximal de threads pouvant être traité en parallèle. Ce paramètre est souvent l'élément limitant l'exploitation de la CPU.
Quel est le risque d'un modèle réactif ?
Ce modèle de développement est très efficace, mais il est très sensible à la qualité du travail réalisé par les développeurs. En effet, si un développeur utilise une API bloquante, cela consomme le thread associé au cœur du processeur. Par exemple, lire un fichier HTML est généralement bloquant. Si le fichier est sur un disque SSD local, ce n'est pas trop un problème, mais si le fichier est sur un NAS dans un cloud, l'impact n'est pas négligeable. Il ne faut pas oublier que les disques sont deux fois plus lents que le réseau.
Comment s'assurer que les développeurs ne vont pas faire une bêtise qui risque de ruiner cette belle architecture ? L'approche de node.js est simple : ne proposer que des API non bloquantes.
Mais lorsque l'on utilise un langage plus ancien comme .NET Java ou Scala, il faut tenir compte du passé et des nombreuses API existantes. Idéalement, il faudrait supprimer les API bloquantes, ce qui n'est pas possible.
C'est à ce problème que nous nous sommes attelés, afin de pouvoir auditer une application réactive : nous souhaitons nous assurer que tout est en ordre en termes d’invocation d’API.
Comment auditer une application Java réactive ?
Notre objectif est d'identifier les invocations aux appels bloquants. En théorie, c'est simple. En pratique, c'est plus compliqué.
Trois stratégies se présentent :
- Modifier le SDK
- Générer les warnings lors de la compilation
- Instrumenter le code via un agent
Commençons par identifier les appels bloquants vis-à-vis des OS. Les appels bloquants sont de trois types : les API fichiers, les API réseaux et les API de synchronisation entre les threads. En ce qui concerne le kernel, elles ne sont pas très nombreuses.
Modification du SDK
Google a modifié le SDK Android pour interdire les API bloquantes dans l'UI Thread. Pourquoi ne pas faire de même ? Il suffit d'envoyer une exception si les conditions ne sont pas bonnes.
Mais, pour faire cela, il faudrait modifier en profondeur les API du JDK ! Une approche consiste à identifier le JRE utilisé par la JVM, à générer un fichier rt.jar patché automatiquement pour générer une exception.
Pour les API de bas niveau codés en Java, c'est jouable, via une manipulation du byte code. Pour les API natives, c'est plus compliqué. Il faudrait pouvoir générer de nouvelles versions des DLL pour les différents OS et les différentes versions de la JVM.
En cas de bug, il sera facile d'accuser l'outil et non le développeur.
Une autre approche consiste à annoter les API bloquantes. Les environnements de développements proposent des annotations externes aux classes compilées. Par exemple, IntelliJ utilise un fichier XML pour enrichir le SDK d'annotation @Nullable et @NotNull. Ainsi, bien que ces annotations ne soient pas présentes dans le SDK, des plugins à l'environnement IntelliJ sont capables de faire une analyse en profondeur du code à compiler pour identifier lorsqu'une méthode du SDK est invoquée avec une variable initialisée à null.
Cette idée est intéressante, mais ce n'est pas normalisé entre les différents environnements de développement. Des travaux sous Eclipses sont entrepris pour utiliser la même approche qu'IntelliJ pour la gestion du @NotNull et permettre une meilleure détection.
Pour le moment, cette approche intégrée aux environnements de développement ne nous semble pas pertinente. Proposer un plugin à chaque environnement de développement est un effort trop important.
Générer des warnings lors de la compilation
Le JSR 269 est une API qui permet d'intervenir lors de la compilation d'une classe Java. En implémentant cette API et avec quelques fichiers de paramètres, il est possible d'avoir la main sur l'arbre syntaxique du compilateur. C'est donc un bon candidat pour générer des alertes lors de l'invocation d'API bloquante.
Nous avons analysé cette approche, mais nous avons été confrontés à trois problèmes.
Pour comprendre la première difficulté, il faut bien identifier les différentes étapes de compilation et à quel moment le JSR 269 intervient.
La compilation d'un fichier source java s'effectue en plusieurs étapes. La première consiste à analyser la syntaxe du fichier. Cela génère un arbre syntaxique en mémoire avec tous les tokens, tous les mots-clefs, tous les points ou autres parenthèses. La deuxième étape consiste à enrichir cet arbre pour combler toutes les informations implicites de la syntaxe. Par exemple, indiquer le nom complet d'une méthode par l'analyse des imports, de l'héritage, de la navigation entre instances, des autres méthodes de la classe, etc. Enfin, la compilation peut intervenir.
Avec le JSR269, l'arbre syntaxique auquel nous avons accès n'a pas encore été traité par la deuxième phase du compilateur pour permettre d'avoir une analyse claire de la syntaxe. Par exemple, si dans une méthode, on utilise openConnection(), l'arbre décrit l'invocation, mais ne permet pas de savoir si la méthode openConnection() est une méthode de la classe ou d'une autre classe. Il faudrait ré-écrire l'analyse complète de l'arbre pour comprendre tous les imports, toutes les hiérarchies, etc., avant d'identifier précisément java.net.URL.openConnection().
Le JSR308 propose une nouvelle approche permettant d'avoir une vue de l'arbre syntaxique après la phase deux, mais le code correspondant n'a pas été intégré dans Java8.
La deuxième difficulté est que cette approche ne fonctionne que pour les classes que l'on compile et non pour les classes venant d'une librairie ou d'un framework. Il faudrait faire une analyse en profondeur des différentes archives, suivant la bonne stratégie utilisée par les classloaders (parent-first, parent-last), pour analyser tous les arbres d'appels.
La dernière difficulté est qu'il n'est pas possible à la simple invocation d'une méthode, d'identifier si elle est bloquante ou non. En effet, la méthode java.io.Reader.read() est bloquante si l'instance utilisée concerne un flux réseau ou fichier, mais elle n'est pas bloquante s'il s'agit d'un simple tampon en mémoire.
On pourrait utiliser la détection des invocations des appels sûrement bloquants lors de la compilation, en complément à une approche plus globale. Dans ce cas, seules les méthodes bloquantes sans ambiguïté pourraient générer un warning. Cela exclut les méthodes ambiguës et les invocations dans les frameworks utilisés par l'application.
Instrumenter le code
Il nous reste la dernière approche : instrumenter le code. L'idée est d'injecter des vérifications lors de l'invocation d'API. Ainsi, il est possible de vérifier lors de l'exécution, que l'instance utilisée est bien associée à un flux réseau ou un fichier sur disque et non à la mémoire.
Il est envisageable de faire cela lors ou juste après la compilation. Un patch des classes compilées n'est pas trop compliqué à réaliser. Mais cela entraîne une modification des classes produites et complexifie le processus de build des projets à auditer.
Nous préférons concevoir un outil qui ne nécessite pas de recompilation du code pour pouvoir être utilisé.
Nous avons alors choisi d'instrumenter le code lors du chargement des classes. L'idée est d'intervenir juste après le chargement des classes par le classloader, pour manipuler le byte code juste avant l'invocation de defineClass(). Le byte code original est alors complet vis-à-vis de l'invocation des méthodes cibles. Nous pouvons sans problème identifier les invocations aux méthodes bloquantes. Il faut alors ajouter du code juste avant pour analyser les paramètres et l'instance cible. Suivant les cas, soit le code continue son chemin tranquillement soit une ligne de log ou une exception est générée.
Instrumenter le code permet une analyse lors de l'exécution. Il est ainsi possible de spécifier :
- Les threads autorisés à utiliser des API bloquantes (les threads en dehors du thread pool, pour gérer les backups ou les timeouts par exemple) ;
- les threads ne devant pas les utiliser (les threads du thread pool) ;
- des méthodes déclarées comme étant autorisées à utiliser des API bloquantes, même dans les mauvais threads (le développeur en assume alors les conséquences) ;
- les méthodes annotées pour ne jamais être invoquées dans les threads réactifs ;
- et même retarder le début de l'analyse pour permettre aux différents frameworks de s'initialiser sans contrainte, avec des appels bloquant si nécessaire.
Quelle est l'approche la plus simple pour implémenter cette stratégie ? C'est indéniablement la programmation par Aspect.
Programmation par aspect
Nous avons maintenant une stratégie et une technologie nous permettant d'envisager sereinement la création d'un outil d'audit des applications réactives.
La programmation par aspect est une technologie permettant de définir des critères de recherche sur un code java, pour identifier des points de jonction. À chaque point, il est possible d'ajouter du code, soit avant l'invocation, soit après, soit autour de l'invocation. Si nécessaire, il est même possible d'ajouter des attributs ou de modifier l'héritage.
Le mécanisme d'identification des points de jonction et d'intégration avec du code complémentaire s'appelle le tissage. Le tissage peut s'effectuer lors de la compilation, juste après la génération du bytecode, ou bien au runtime via l'utilisation d'un agent à la JVM. Pour les raisons évoquées précédemment, nous avons choisi cette dernière approche.
Pour utiliser notre outil, il faudra invoquer la JVM en lui indiquant d'utiliser le moteur de programmation par aspect d'AspectJ, avec un tissage au runtime (Load Time Weaving).
Nous ne sommes pas au bout des difficultés. En effet, le tissage ne peut s'effectuer que sur les classes complémentaires au JDK. Toutes les classes des serveurs d'applications, de vos applications et librairies, peuvent être tissées, mais pas les classes du JDK. C'est logique. Comme AspectJ utilise le JDK, il ne peut modifier les classes qu'il utilise lui-même. Le fameux problème de la poule et de l’œuf (c'est l’œuf qui était avant).
Il ne suffit pas d'ajouter des règles lors de l'invocation des API du kernel. Il faut identifier toutes les API du JDK qui, en interne, invoquent les API bloquantes !
Par exemple, la méthode java.util.Properties.load(InputStream) va utiliser un flux. Ce dernier peut être un fichier, un socket réseau ou un tampon en mémoire. En interne, elle va utiliser la méthode InputStream.read(). Cela ne se voit pas au premier abord. Aucun aspect ne peut le savoir.
Notre analyse des API majeures (hors AWT, Swing, etc.) nous a permis d'identifier plus de cinq cents méthodes du JDK8 utilisant directement ou indirectement des API bloquantes. C'est un travail de fourmi, mais nécessaire à la réalisation d'un audit.
Certaines méthodes ont une latence faible (ouverture d'un fichier), d'autres une latence forte (écriture dans un fichier). Certaines méthodes ont des alternatives asynchrones, d'autres non. Par exemple, il est impossible de supprimer un fichier du disque en asynchrone. Il n'y a pas d'API pour cela. Pourtant, cela peut prendre un temps certain si le fichier est dans un serveur FTP à l'autre bout de la planète ou s’il s’agit d’une arborescence importante.
Comment savoir si l'invocation de la méthode java.util.Properties.load(InputStream) doit générer une alerte ?
Il faut capturer le paramètre, analyser le flux ou l’enchaînement des flux en profondeur pour vérifier le type du dernier. Est-ce un java.net.SocketInputStream ou java.io.FileInputStream ou autre chose ?
AspectJ nous permet de faire cela via quelques annotations.
@Aspect
public class PropertiesAudit extends AbstractFileAudit
{
@Before("call(* java.util.Properties.load(java.io.InputStream)) && args(in)")
public void advice_high(JoinPoint thisJoinPoint, InputStream in)
{
if (FileTools.isLastInputStreamWithLatency(in) != 0)
latency(HIGH, thisJoinPoint);
}
@Before("call(* java.util.Properties.load(java.io.Reader)) && args(in)")
public void advice_high(JoinPoint thisJoinPoint, Reader in)
{
if (FileTools.isLastReaderWithLatency(in) != 0)
latency(HIGH, thisJoinPoint);
}
}
AspectJ propose deux syntaxes pour décrire un aspect. Soit une syntaxe spécifique avec sa grammaire, ses mots-clefs, etc., soit l'exploitation astucieuse d'annotations. Attention, pour que l'aspect soit pris en compte, il faut utiliser un compilateur spécifique d'AspectJ. Le build Gradle du projet se charge de cela.
Les méthodes isLastInputStreamWithLatency() et isLastReaderWithLatency() analysent les flux en profondeur. Elles maîtrisent l’enchaînement des flux. En effet, on retrouve souvent un BufferInputStream ou un Reader qui délègue à un InputStream qui délègue à ...
Heureusement, le SDK n'est pas trop mal écrit. La classe FilterInputStream est utilisée lors de l’enchaînement de flux. Donc, en identifiant cette classe, il est possible de demander le flux suivant pour reprendre l'analyse et parcourir la chaîne. Sauf que cette classe utilise un attribut privé in pour le flux suivant. Nous devons alors utiliser l'introspection pour pouvoir y avoir accès
fieldInFilterInputStream = FilterInputStream.class.getDeclaredField("in");
fieldInFilterInputStream.setAccessible(true);
Pour les Readers, l'approche est similaire.
Il existe quelques flux cachés dans le SDK. Par exemple, l'ouverture d'une URL s'effectue via un flux du package sun.net.www. Le code d'analyse doit en tenir compte.
Tout cela permet de gérer les appels synchrones aux entrées sorties de type fichier ou réseau.
Pour les appels bloquants au niveau CPU, il faut analyser d'autres méthodes comme Object.wait(), Thread.sleep(), Thread.join(), etc.
Démarrage de l'application
Notre agent analyse et modifie si nécessaire toutes les classes chargées dans la JVM. Mais cela ne nous indique pas quand le programme commence. Pour résoudre cela, nous avons ajouté un aspect permettant de capturer la première invocation d'une méthode main().
@Aspect
public class MainAspect extends AbstractCPUAudit
{
@Before("execution(public static void *.main(..))")
public void startup(JoinPoint thisJoinPoint)
{
Runtime.getRuntime().addShutdownHook(new Thread()
{
@Override
public void run()
{
ReactiveAudit.config.shutdown();
}
});
ReactiveAudit.config.startup();
}
}
Nous en profitons pour capturer la fin du programme. Java permet d'enregistrer un hook pour cela.
Cela nous permet d'ajouter aux logs, des analyses complémentaires glanées pendant le test. Par exemple, nous pouvons indiquer le nombre de threads maximum en parallèle que nous avons constaté ou le nombre d'alertes par niveau de latence.
Cela semble simple à calculer, mais en fait c'est complexe si l'on souhaite travailler correctement. En effet, incrémenter un compteur est facile avec un seul thread. Avec plusieurs threads, il y a différentes approches. La plus classique avant Java8 consiste à utiliser un AtomicInteger. Cette classe a besoin de la synchronisation des threads à chaque incrémentation. Ce n'est pas efficace, surtout si l'on prétend proposer un code réactif.
Java8 vient alors à notre secours. La classe LongAdder permet d'incrémenter un compteur dans différents threads, sans nécessiter de verrous. C'est seulement lorsque l'on souhaite lire le résultat que les sommes partielles sont additionnées après obtention d'un verrou. Bien entendu, nous utilisons cette approche.
Signaler une méthode bloquante
Il est souvent préférable de signaler une méthode bloquante dès que possible dans un programme. Cela permet d'avoir une pile plus facile à analyser lors d'une alerte.
Nous proposons pour cela une annotation de méthode @WithLatency qui doit être valorisée avec un niveau de latence (LOW, MEDIUM, HIGH).
L'aspect pour gérer cela est l'un des plus simples.
@Aspect
public class WithLatencyAudit extends AbstractCPUAudit
{
@Before("execution(@com.octo.reactive.audit.lib.WithLatency * *(..) )")
public void with(JoinPoint thisJoinPoint)
throws ReactiveAuditException
{
WithLatency withLatency=
((MethodSignature) thisJoinPoint.getSignature())
.getMethod().getAnnotation(WithLatency.class);
latency(withLatency.value(), thisJoinPoint);
}
}
Suppression de l'audit sur annotation
Nous souhaitons permettre à l'utilisateur de signaler qu'une méthode et son contenu ne doivent pas générer d'alertes. C'est le développeur qui assume les conséquences, car il sait que la méthode est invoquée qu'au démarrage du programme, que la latence est faible ou pour toute autre raison.
Nous proposons l'annotation @AssumeLatency pour cela. Un aspect se charge alors d'incrémenter et de décrémenter une variable de thread pour signaler l'exclusion des audits.
@Aspect
public class AssumeLatency
{
@Before("execution(@com.octo.reactive.audit.lib.AssumeLatency * *(..) )")
public void beforeSuppress(JoinPoint thisJoinPoint) throws ReactiveAuditException
{
ReactiveAudit.config.incSuppress();
}
@After("execution(@com.octo.reactive.audit.lib.AssumeLatency * *(..))")
public void afterSuppress(JoinPoint thisJoinPoint) throws ReactiveAuditException
{
ReactiveAudit.config.decSuppress();
}
}
Gestion des logs
Afin de ne pas avoir de collision entre des API utilisées par notre agent, et les API utilisés par les projets, nous nous imposons de ne pas utiliser d'API tierce.
Nous ne pouvons donc pas utiliser un Log4j ou une autre librairie. Nous avons alors choisi d'utiliser l'API standard de log du JDK. Elle est bien plus puissante que l'on n'imagine. Avec un peu de code, nous pouvons avoir un fichier de log rotatif, indiquer le format des noms des fichiers et leurs localisations, indiquer si les logs doivent être au format texte ou XML.
Lors de la détection d'un appel bloquant, nous souhaitons générer une ligne de log avec la stack trace et le nom du thread associé.
Le premier problème à régler est d'afficher une stack trace qui soit facilement exploitable. L'ajout de l'agent AspectJ entraîne que la pile d'appel, lors de la création d'une exception, est polluée par les méthodes de notre outil. Heureusement, il est facile de régler cela. En effet, la stack trace est en réalité portée par un tableau de StackElement. Nous pouvons intervenir dans le constructeur de notre exception. Ainsi, la stacktrace pour l'utilisateur référence bien l'invocation de la méthode bloquante.
// Filter stack trace
StackTraceElement[] stack = getStackTrace();
int pos = 0;
for (StackTraceElement traceElement : stack)
{
if (!traceElement.getClassName().startsWith(auditPackageName)
|| traceElement.getClassName().endsWith("Test")) // For inner unit test
{
break;
}
++pos;
}
StackTraceElement[] newStack = new StackTraceElement[stack.length - pos];
System.arraycopy(stack, pos, newStack, 0, newStack.length);
setStackTrace(newStack);
Le deuxième problème à régler est un mécanisme permettant d'éviter les logs en double. En effet, comme nous générons une trace à chaque appel bloquant, si ce dernier est effectué dans une boucle, nous aurons une trace par itération. Ce n'est pas acceptable. Nous devons alors nous souvenir des contextes des traces que nous avons produites pour supprimer les doublons. Une même erreur venant d'une succession d'appels identiques ne doit pas être affichée. Par contre, si l'appel est détecté dans la même méthode, mais via une succession d'appels différents, la trace doit être affichée.
Il faut alors mémoriser les différents tableaux de StackElement et vérifier un à un si la trace que nous devons produire ne correspond pas à l'un d'entre eux.
Pour être plus efficaces, nous avons choisi une approche probabiliste. Parfois, très rarement, une trace n'est pas affichée. Pour cela, nous calculons un hashcode sur un long à partir de chaque StackElement. Le hashcode résultat sert à identifier rapidement si la stack actuelle est déjà connue. Nous n'effectuons pas de vérification finale pour comparer les deux piles d'appels. L'égalité des hashs est considérée comme suffisante pour identifier les doublons. Au pire, si deux stacks différents ont la même valeur de hash, la première est affichée mais pas la seconde. Lorsque le développeur aura corrigé la première, il pourra voir la deuxième lors du prochain audit.
L'autre avantage de cette approche est qu'il n'est alors plus nécessaire de garder en mémoire les piles ! Seul le hash est nécessaire. C'est plutôt une bonne idée pour un outil d'audit.
Paramétrage
Nous avons une stratégie et une implémentation. Il faut maintenant rendre tout cela utilisable facilement.
Nous avons fait le choix d'utiliser un classique fichier properties pour permettre le paramétrage de notre outil. Afin de faciliter son utilisation, les paramètres peuvent également être valorisés via des variables d'environnement de l'OS (set X=Y), ou via des variables d'environnement de la JVM (-DX=Y).
Lors de l'identification d'un appel bloquant à l'exécution, nous avons deux approches possibles : générer une exception, ce qui plante le programme, ou générer une ligne de log avec la stacktrace.
Le paramètre reactiveAudit.throwExceptions avec true ou false permet de définir cela. Lors des tests unitaires, générer une exception est une bonne idée. Lors des tests d'intégration, un simple fichier de log est préférable.
Nous avons choisi une approche au runtime, car il n'est pas possible d'identifier les appels réellement bloquants lors de la compilation. Néanmoins, certains appels bloquants sont nécessaires lors de phases d'initialisation ou dans certains threads particuliers. Le paramètre reactiveAudit.threadPattern permet d'exprimer une expression régulière permettant de valider l'analyse sur les threads dont le nom match.
# Regular expression to apply the
# rule for thread WITH a specific pattern.
reactiveAudit.threadPattern=^(ForkJoinPool-.*)
Pour exprimer une règle du type « tous les threads sauf… », vous pouvez utiliser un truc comme ceci :
reactiveAudit.threadPattern=(?!^main)(?!^sbt-web-scheduler-)(^.*$)
Afin de retarder l'analyse, le paramètre reactiveAudit.bootstrapDelay doit être indiqué en millisecondes.
# Delay in ms after the bootstrap
# before check the first rule.
# Then, it's possible to accept to
# violate some rules in the startup phase.
reactiveAudit.bootstrapDelay=0
Le paramètre reactiveAudit.logOutput nous permet d'indiquer le fichier de log. Par exemple, pour produire des logs rotatifs dans le répertoire home de l'utilisateur, il faut utiliser quelque chose comme ceci :
# The filename use to generate the log.
# Accept filename with xml suffix
# to generate an XML file.
# With XML file, it's possible to
# transform the data to HTML.
# Else, generate a text file.
# @see java.util.logging.FileHandler
reactiveAudit.logOutput=%h/reactive-audit-%u.log
Regardez la javadoc de java.util.logging.FileHandler pour plus de détails.
Le paramètre reactiveAudit.logSize permet d'indiquer à partir de quand il faut changer de fichier.
# The size in byte before start a new file.
# Zero is for no limit.
# See java.util.logging.FileHandler
reactiveAudit.logSize=0
Nous avons identifié trois familles de méthodes avec latences : les méthodes exploitant les fichiers ; les méthodes exploitant le réseau et les méthodes bloquant le thread courant. Chaque famille génère une exception différente. Parfois, une méthode peut produire une exception réseau ou une exception fichier.
Suivant les cas, le projet utilise un disque local SSD et souhaite ignorer toutes les alertes fichiers. Ou bien, le projet est très sensible à la latence et toutes les alertes doivent être signalées. Pour cela, nous proposons trois paramètres.
# Detect the file blocking api, but only
# if the logLatency is medium or high.
# Accept low, medium or high.
reactiveAudit.file=medium
# Detect all the network blocking api.
# Accept low, medium or high.
reactiveAudit.network=low
# Detect all the cpu blocking api.
# Accept low, medium or high.
reactiveAudit.cpu=low
Une méthode qui n'a pas d'alternative asynchrone sera déclarée comme ayant une latence faible ou moyenne. Une méthode qui possède une alternative sera déclarée comme ayant une latence forte.
Utilisation
Comme les approches réactives sont nouvelles, nous avons utilisé Java7 pour la réalisation de l'agent. Les tests utilisent Java8. Cela nous a permis d'identifier les appels bloquants de toutes les nouvelles API.
Comme l'agent fonctionne au runtime, en instrumentant les classes lors de leurs chargements en mémoire, il n'est pas nécessaire de modifier votre code. Par contre, il faut intervenir lors du lancement de la JVM.
En général, les différents frameworks permettent d'ajouter des paramètres à la JVM. Parfois c'est plus compliqué et il faut modifier les scripts de démarrage.
Nous nous sommes focalisés sur les frameworks réactifs en vogue Jetty, Tomcat Catalina, Play et Akka. Le script init-reactive-audit permet d'initialiser les variables d'environnement avant le lancement de la JVM.
Par exemple, pour auditer une application Play sous Windows :
c:> init-reactive-audit play
c:> activator run
Sous Linux, c'est légèrement différent
>source init-reactive-audit play
> activator run
Le script valorise la variable SBT_OPTS et utilise un jeu de paramètre spécifique.
Sans indiquer de frameworks en paramètres, c'est la variable AUDIT_OPTS qui est valorisé. Il suffit alors de l'utiliser lors du lancement de la JVM.
> source init-reactive-audit
> java %AUDIT_OPTS% …
Comme l'injection des très nombreuses règles s'effectue lors de l'installation des classes en mémoire, cela ralentit indéniablement le démarrage. Mais à l'exécution, nous avons fait tous les efforts pour éviter un impact négatif important.
Intégration au build
Dans le projet reactive-audit-integration, nous proposons un exemple de build pour Maven, Gradle et Sbt. Cela permet de lancer une JVM avec tous les paramètres, en récupérant les artifacts depuis le repo Maven.
Exemple
Nous avons testé le projet sur un des exemples Play+Akka, codé en Scala, proposé par l'entreprise TypeSafe. Et cela nous a révélé quelques surprises !
Après avoir installé activator (https://typesafe.com/activator), nous avons installé l’application de démonstration reactive-stock. Cette dernière simule et affiche l’évolution de cours de marché en utilisant les frameworks Play et Akka. Elle est codée en Scala.
Pour tester notre nouvel outil, nous nous positionnons dans le répertoire du projet et nous initialisons les variables d’environnements pour le framework Play. Enfin, nous lançons le projet.
> source init-reactive-audit.sh play
SBT_OPTS was set. You can use TypeSafe 'activator run'.
> activator run
Sep 23, 2014 8:34:34 AM com.octo.reactive.audit.ReactiveAudit startup
AVERTISSEMENT: Start audit reactive with ~/home/etc/play.properties
[info] Loading project definition from /reactive-stocks/project
[info] Set current project to reactive-stocks (in build file://reactive-stocks/)
--- (Running the application from SBT, auto-reloading is enabled) ---
[info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
(Server started, use Ctrl+D to stop and go back to the console...)
Nous pouvons alors lancer une page Web sur localhost:9000. Le fichier audit.log est alors alimenté des événements bloquant découverts dans le projet. Par exemple :
HIGH : Call method void java.io.OutputStream.write(int)
at thread "play-internal-execution-context-2"
at org.fusesource.jansi.AnsiOutputStream.write(AnsiOutputStream.java:82)
at java.io.FilterOutputStream.write(FilterOutputStream.java:125)
at java.io.PrintStream.write(PrintStream.java:480)
at java.io.FilterOutputStream.write(FilterOutputStream.java:97)
at ch.qos.logback.core.joran.spi.ConsoleTarget$1.write(ConsoleTarget.java:36)
...
Les logs semblent utiliser des API bloquantes pour écrire dans un fichier. À quoi sert d'attendre l'acquittement du disque ? Dans le Cloud, cela peut nuire gravement aux performances. Il serait préférable d’utiliser les API non bloquantes pour améliorer les performances.
L’alerte indique le thread et la méthode impliquée.
Conclusion
En une vingtaine de jours, nous avons réalisé un outil sympathique qui nous aidera lors de nos missions de conseil.
Finalement, nous avons utilisé AspectJ, une extension à Java, pour instrumenter du code généralement codé en Scala.
Vous trouverez tous les sources et la documentation sur le compte Github : https://github.com/octo-online/reactive-audit
Le moteur du projet n'est pas très gros. Il fait environ mille trois cents lignes de code. Les règles représentent trois mille lignes de code. Tout cela est vérifié via plus de six cents tests unitaires.
Il s'agit d'une version bêta. Le projet a besoin d'être testé sur un large éventail de projet. Nous attendons vos retours avec impatience.
