tl;dr : Nous avons vu dans l'article de la semaine dernière ce qu'est eBPF et pourquoi c'est une nouveauté intéressante. Aujourd'hui nous allons vous présenter un outil que nous avons fait chez Bearstech, basé sur le framework BCC également découvert la dernière fois. Cet outil nous permettra d'observer le comportement d'une application PHP sur les I/O, c'est-à-dire sa relation avec le disque et le réseau. On sera donc en mesure de tracer notre code et de découvrir des goulets d'étranglement.
Tracer du code métier
Avec une simple option, il est possible de compiler son interpréteur préféré, pour qu'il envoie des traces compatibles avec eBPF. Les spécifications viennent de DTrace, l'outil de feu Sun et sont implémentés un peu partout : PHP, Python, Ruby et bien sûr l'inévitable Java.
La vie d'une machine est un flot d'évènements. Le kernel et les applications (le plus simple étant d'instrumenter le runtime) fournissent un flot énorme d'événements, qu'il faut trier au maximum en amont (pour noyer personne), puis ranger pour fournir une information concise.
BCC pour tracer du code PHP
Nous avons créé un programme BCC, nommé avec une grande fantaisie : PHP Tracing Tool . Il permet de tracer et comprendre les utilisations des IO des fonctions d'une application PHP : les interactions avec le disque dur et le réseau.
Le code est libre et disponible sur notre github .
Cet outil en ligne de commande va instrumenter l'exécution du code PHP des processus passés en paramètre grâce aux tracepoints usdt du langage (cf. l'article de la semaine dernière pour comprendre les tracepoints). PHP va donc devoir être compilé avec l'option --enable-dtrace
pour les activer… Quelle chance, l'image php-fpm avec l'option de debug se trouve aussi dans notre repository !
Il y a également deux démos d'applications PHP dans le projet afin de tester, une basique qui fait un appel à une API tierce (google maps), ouvre un fichier et quitte. Et une seconde à peine plus grosse… Un Wordpress . Nous allons tester notre outil avec la démo simple.
Une fois les conteneurs lancés, reprenons la liste des processus que nous avions dans l'article de la semaine dernière :
Choisissons un PHP qui tourne dans notre conteneur (php-fpm dans notre cas) et lançons notre tool avec ce PID en paramètre et l'option -S
qui active l'affichage du détail des appels systèmes.
Nous avons choisis d'activer uniquement les appels systèmes réseaux et généralistes pour cet exemple, mais il est possible de tracer tous les appels systèmes comme nous le verrons un peu plus loin.
On peut donc voir, par exemple, que la méthode geocodeAddress
qui appartient à la classe GmapApi
s'est exécutée en 145475814ns . À l'intérieur de celle-ci se trouve les syscalls que l'on a choisi de suivre, avec leurs latences respectives et des informations les concernant, comme l'adresse jointe par connect
ou encore le filedescriptor retourné par socket
. On peut aussi repérer quand une opération write
est effectué sur ce filedescriptor. On a aussi un résumé du temps passé dans les appels systèmes que nous surveillons, du temps passé dans des appels systèmes réseau ou disque (il n'y en a pas ici), ainsi que le volume d'octets écrits et lus.
Comment ça marche?
L'outil prend en paramètre un ou plusieurs PIDs de processus PHP. Voyons cela en utilisant la commande pidof
pour récupérer tous les PIDs du processus php-fpm
et les passer en paramètre.
Le ps aux
juste avant nous montre les processus php-fpm qui tournent sur la machine. Le premier, 674 - master process, va répartir les requêtes sur les workers 1200, 1215 et 1216. Ce sont ceux là que nous voyons donc dans le rapport du tool.
Pour éviter la pollution de la sortie, le tool ne commence à capturer les événements qu'au véritable début de la page : le main
, puis arrête la capture et affiche le rapport pour un processus à la fin de celui ci. Le programme quitte une fois toutes les captures terminées.
Aussi, afin d'améliorer la lisibilité et éviter de perdre le contexte, les événements sont regroupés par PID et affichés groupés et triés. N'oublions pas que les workers php-fpm travaillent en parrallèle.
Aller plus loin (pour les plus courageux)
L'outil est conçu de manière à être ajusté facilement. On peut ajouter ou retirer des appels systèmes à tracer simplement en modifiant la liste au début du code.
SYSCALLS = ["socket", "socketpair", "bind", "listen", "accept", "accept4", "connect", "getsockname", "getpeername", "sendto", "recvfrom", "setsockopt", "getsockopt", "shutdown", "sendmsg", "sendmmsg", "recvmsg", "recvmmsg", "read", "write", "open", "openat", "creat", "close", "sendfile64"]
Chaque appel système se trouvant dans cette liste sera tracée, avec par défaut uniquement sa latence.
Il se peut que nous voulions plus d'informations sur un appel système précis, comme nous le faisons avec socket
pour récupérer le filedescriptor retourné ou encore connect
où nous affichons le filedescriptor sur lequel il écrit ainsi que l'adresse passée en paramètre. Cela va nécessiter de modifier le code C injecté dans eBPF. Heureusement, nous avons fait en sorte de pouvoir ajouter quelques lignes de C pour un besoin précis sans avoir à modifier tout le code à injecter. Comment faire?
La classe SyscallEvents
contient une méthode event
qui va prendre en paramètre trois choses. En premier, la liste des syscalls sur lesquels nous voulons ajouter notre code. Puis le code C à ajouter dans l’événement "entrée dans l'appel système", c'est ici que nous aurons accès aux paramètres de l'appel système. Et enfin le code qui concerne l’événement "sortie de l'appel système", où nous pourrons récupérer sa valeur de retour. Il suffit donc d'appeler la méthode event
dans la fonction qui génère le code C, c_program
, avec nos informations.
C'est un peu flou ? Voyons un exemple.
def c_program(pids): "Generate the C program" program = io.StringIO() program.write(PROGRAM) php = PHPEvents() php.probe(pids, "function__entry", "php_entry", "bpf_usdt_readarg(4, ctx, &clazz);", "bpf_usdt_readarg(1, ctx, &method);", "bpf_usdt_readarg(2, ctx, &file);", is_return=False) php.probe(pids, "function__return", "php_return", "bpf_usdt_readarg(4, ctx, &clazz);", "bpf_usdt_readarg(1, ctx, &method);", "bpf_usdt_readarg(2, ctx, &file);", is_return=True) program.write(php.generate(pids)) # trace syscalls s = SyscallEvents() # intercept when an open filedescriptor is read. get the fd for printing # and get the type for sort the latence in NET or DISK s.event(("read",), """ u64 fdarg = args->fd; fd.update(&pid, &fdarg); """, """ data.bytes_read = args->ret; u64 *fdarg = fd.lookup(&pid); if (fdarg) { data.fdr = *fdarg; fd.delete(&pid); u64 *fdt = filedescriptors.lookup(fdarg); if (fdt) { data.fd_type = *fdt; } } """)
Voici le début de la fonction c_program
. La Classe PHPEvents
va s'occuper d'ajouter le tracing des événements concernant les tracepoints de PHP. Passons à la partie concernant les appels systèmes, qui débute avec s = SyscallEvents()
. Le premier ajout d’événement concerne l'appel read
, si vous avez suivi jusqu'ici, vous aurez compris qu'il se fait grâce à la méthode event
de notre classe SyscallEvents
. Regardons de plus près, avec quelques commentaires en plus.
# intercept when an open filedescriptor is read. get the fd for printing # and get the type for sort the latence in NET or DISK # Premier argument, le nom du syscall : "read" # Deuxieme argument, le code à exécuter à l'entrée du syscall # Troisième argument, le code à exécuter à la sortie s.event(("read",), """ /* * Entree du syscall * On recupere l'argument "fd" de "read" dans une variable */ u64 fdarg = args->fd; /* * On le stocke dans une MAP eBPF "fd" pour le recuperer dans * le code de sortie */ fd.update(&pid, &fdarg); """, """ /* * On recupere la valeur de retour de "read" * et on la stocke dans notre structure (data) qui sera recupérée dans * le code python */ data.bytes_read = args->ret; /* * On recupere l'argument "fd", stocké dans la MAP "fd" * dans le code d'entree */ u64 *fdarg = fd.lookup(&pid); /* * eBPF (et bonne pratique) oblige, on vérifie notre pointeur */ if (fdarg) { data.fdr = *fdarg; fd.delete(&pid); u64 *fdt = filedescriptors.lookup(fdarg); /* On stocke le filedescriptor dans notre structure if (fdt) { data.fd_type = *fdt; } } """)
Le code qui s'exécutera à l'entrée de l'appel système read
récupère le filedescriptor passé en argument pour le stocker dans une MAP eBPF. Dans le code de sortie, nous récupérons le fd stocké dans la MAP, ainsi que le retour de read
auquel nous avons accès et nous envoyons le tout en userspace via la structure data
. Nous transposerons cette structure dans notre code python afin d'en assurer le traitement et l'affichage que nous voulons !
Conclusion
Le framework BCC, en s'appuyant sur Python et C, permet d'écrire rapidement du code hackable, qui va très simplement utiliser des options en ligne de commande, afficher des informations et plus exotique, agir sans risque au sein du kernel, tout ça sans trop perturber l'hôte. On pourra donc l'utiliser directement en production.
BCC fournit tout un ensemble d'outils génériques fort pratique, mais on vient de démontrer qu'il est aisé d'en créer un nouveau, adapté à un cas plus pointu.
In code we trust.