De la latence Ceph élevée au patch du noyau avec eBPF / BCC



Il existe de nombreux outils pour déboguer le noyau et les programmes de l'espace utilisateur sous Linux. La plupart d'entre eux ont un impact sur les performances et ne peuvent pas être exécutés facilement dans des environnements de production. Il y a quelques années, eBPF a été développé , qui offre la possibilité de tracer le noyau et l'espace utilisateur avec une faible surcharge, sans avoir besoin de recompiler les programmes ou de charger les modules du noyau.

Il existe maintenant de nombreux outils qui utilisent eBPF et dans cet article, nous expliquerons comment écrire votre propre outil de profilage à l'aide de la bibliothèque PythonBCC . Cet article est basé sur un vrai problème de l'environnement de production. Nous vous expliquerons comment résoudre le problème et montrerons comment les outils Cci existants pourraient être utilisés dans certains cas.

Ceph est lent


Une nouvelle plateforme a été ajoutée à un cluster ceph. Après la migration de certaines données vers la plate-forme, la latence des demandes d'écriture était plus élevée que sur les autres serveurs.



Cette plate-forme a un nouveau périphérique virtuel de mise en cache - bcache, que nous n'avions pas utilisé dans ce cluster auparavant - et un nouveau noyau - 4.15, qui n'est toujours pas utilisé ailleurs dans ce cluster. La racine du problème pourrait être n'importe où, alors examinons plus en profondeur.

Enquête sur l'hôte


Voyons ce qui se passe à l'intérieur du processus ceph-osd. Nous utilisons l'outil de traçage perf et flamescope pour construire des graphiques de flamme:



Comme nous pouvons le voir sur le graphique de flamme, fdatasync () a passé beaucoup de temps à soumettre la bio dans la fonction generic_make_request () . Ainsi, la racine de notre problème est quelque part en dehors du démon ceph. Il peut s'agir d'un problème de noyau, de bcache ou de disque. La sortie iostat a montré une latence élevée pour les périphériques bcache.

Une autre découverte suspecte est que le démon systemd-udevd consomme du CPU; environ 20% sur plusieurs processeurs. C'est un comportement étrange, nous devons donc savoir ce qui se passe. Puisque systemd-udevd fonctionne avec uevents, nous devons utiliser udevadm monitor pour savoir s'il y a des uevents dans le système. Après vérification, nous avons vu que de nombreux événements de «changement» étaient générés pour chaque périphérique de bloc du système.

Ceci est inhabituel, nous allons donc découvrir ce qui provoque l'envoi de tous ces uevents.

Utilisation de la boîte à outils BCC


Comme nous le savons déjà, le noyau (et le démon ceph) passe beaucoup de temps à exécuter les fonctions generic_make_requst () . Mesurons sa latence en utilisant funclatency à partir de la boîte à outils BCC , juste pour nous assurer que nous sommes sur la bonne voie. Nous tracerons le PID du démon ceph (argument -p) à des intervalles de 1 seconde (-i) et afficherons la latence en millisecondes (-m).



Cette fonction fonctionne généralement très rapidement. Tout ce qu'il fait est de soumettre la structure biologique à la file d'attente du pilote de périphérique.

Bcache est un appareil complexe; en fait, il se compose de 3 appareils: un support, qui est un disque dur lent dans notre cas; un périphérique de mise en cache, qui est la partition du lecteur NVMe; et un périphérique virtuel bcache, qui est utilisé par l'application. Nous savons que la soumission est lente, mais pour quel appareil? C'est quelque chose que nous verrons un peu plus tard.

Pour l'instant, nous savons que les uevents causent des problèmes dans les démons ceph et nous devons trouver le logiciel déclenchant les uevents. Il n'est pas facile de trouver ce qui provoque leur génération. Nous supposons que c'est un logiciel qui ne fonctionne que périodiquement. Pour voir ce qui est exécuté sur le système, nous utilisons execsnoop de la boîte à outils BCC. Nous pouvons l'exécuter et rediriger stdout vers un fichier.

Par exemple:

/usr/share/bcc/tools/execsnoop | tee ./execdump 

Nous ne donnerons pas la sortie execsnoop complète ici, mais nous avons trouvé une chaîne intéressante:

 sh 1764905 5802 0 sudo arcconf getconfig 1 AD | grep Temperature | awk -F '[:/]' '{print $2}' | sed 's/^ \([0-9]*\) C.*/\1/' 

La troisième colonne est le PPID du processus. Nous avons vérifié ce qu'était le 5802 et avons vu qu'il s'agissait de l'un de nos threads de démon de surveillance. En regardant plus loin la configuration du système de surveillance, nous avons trouvé un paramètre défectueux. La température du HBA était récupérée toutes les 30 secondes, ce qui est trop souvent. Après avoir modifié l'intervalle de vérification à une valeur plus appropriée, nous avons vu que notre latence ceph correspondait aux autres plates-formes.

Mais nous ne savons toujours pas pourquoi la latence bcache était élevée. Nous avons mis en place une plate-forme de test avec la même configuration et essayé de reproduire le problème avec fio sur le périphérique bcache tout en déclenchant simultanément udev avec la commande udevadm trigger.

Écriture d'outils basés sur BCC


Ce que nous allons faire ici, c'est écrire un outil simple qui trace les appels generic_make_request () les plus lents et affiche le nom du disque pour lequel la fonction a été appelée.

Le plan est simple:

  • Enregistrez kprobe sur generic_make_request () :
    • Enregistrez le nom du disque disponible à partir de l'argument de la fonction
    • Enregistrer l'horodatage actuel
  • Enregistrez kretprobe sur l' instruction de retour generic_make_request () :
    • Récupérer l'horodatage actuel
    • Recherchez les horodatages précédemment enregistrés et comparez-les avec ceux actuels
    • Si le résultat est supérieur au seuil, recherchez les noms de disque précédemment enregistrés et imprimez-les sur le terminal avec des informations supplémentaires

Kprobes et kretprobes utilisent des points d'arrêt pour modifier le code d'une fonction lors de l'exécution. Vous pouvez trouver de la documentation ainsi qu'un bon article à ce sujet. Si vous regardez le code des différents outils BCC , vous verrez qu'ils ont tous une structure identique. Nous allons ignorer l'analyse des arguments et nous concentrer sur le programme BPF lui-même.

Le texte de notre programme sera défini en python comme suit:

 bpf_text = “”” # Here will be the bpf program code “”” 

Les programmes BPF utilisent des hashmaps pour partager des données entre différentes fonctions. Nous utiliserons le PID comme clé et la structure auto-définie comme valeur.

 struct data_t { u64 pid; u64 ts; char comm[TASK_COMM_LEN]; u64 lat; char disk[DISK_NAME_LEN]; }; BPF_HASH(p, u64, struct data_t); BPF_PERF_OUTPUT(events); 

Ici, nous enregistrons une table de hachage appelée p avec un type de clé u64 et un type de valeur struct data_t . Cette carte est accessible à partir de notre contexte de programme BPF. La macro BPF_PERF_OUTPUT enregistre une autre carte appelée événements , qui est utilisée pour pousser les données vers l'espace utilisateur.

Lorsque vous mesurez la latence entre l'appel de fonction et son retour ou entre un appel de fonction et un autre, vous devez vous assurer que les données que vous avez enregistrées et auxquelles vous accédez ultérieurement se rapportent au même contexte. En d'autres termes, vous devez être conscient de toute autre exécution parallèle de la même fonction. Il est possible de tracer la latence entre l'appel de fonction d'un processus et les retours de la même fonction d'un autre processus, mais cela ne nous aide pas. Un bon exemple est l' outil de biolatence où le pointeur vers la demande de structure est utilisé comme clé de hachage.

Ensuite, nous devons écrire un code qui sera exécuté lors des appels de fonction via un mécanisme kprobe:

 void start(struct pt_regs *ctx, struct bio *bio) { u64 pid = bpf_get_current_pid_tgid(); struct data_t data = {}; u64 ts = bpf_ktime_get_ns(); data.pid = pid; data.ts = ts; bpf_probe_read_str(&data.disk, sizeof(data.disk), (void*)bio->bi_disk->disk_name); p.update(&pid, &data); } 

Ici, nous avons le premier argument generic_make_request () comme deuxième argument de notre fonction. Ensuite, nous obtenons le PID et l'horodatage actuel en nanosecondes et l'écrivons dans les données struct data_t nouvellement allouées. Nous obtenons le nom du disque de la structure biologique, qui est passé à generic_make_request () , et l'enregistrons dans nos données . La dernière étape consiste à ajouter une entrée à la table de hachage que nous avons décrite précédemment.

Cette fonction sera exécutée sur les retours generic_make_request () :

 void stop(struct pt_regs *ctx) { u64 pid = bpf_get_current_pid_tgid(); u64 ts = bpf_ktime_get_ns(); struct data_t* data = p.lookup(&pid); if (data != 0 && data->ts > 0) { bpf_get_current_comm(&data->comm, sizeof(data->comm)); data->lat = (ts - data->ts)/1000; if (data->lat > MIN_US) { FACTOR data->pid >>= 32; events.perf_submit(ctx, data, sizeof(struct data_t)); } p.delete(&pid); } } 

Nous obtenons le PID et l'horodatage de la sortie précédente et recherchons la table de hachage pour la valeur où clé == PID actuel . S'il est trouvé, nous obtenons le nom du processus en cours et l'ajoutons à la structure de données . Ce que nous faisons avec data-> pid ici nous donne l'ID du groupe de threads. La fonction bpf_get_current_pid_tgid () précédemment appelée renvoie le thread GID et PID du processus dans la même valeur 64 bits.

Nous ne sommes pas intéressés par l'ID de chaque thread, mais nous voulons connaître le PID du thread principal. Après avoir vérifié que la latence est supérieure au seuil, nous soumettons notre structure de données à l'espace utilisateur via la carte des événements , puis supprimons l'entrée de la table de hachage à la fin.

Dans notre script python, nous devons remplacer MIN_US et FACTOR selon le seuil que nous voulons et l'unité de temps que nous voulons voir dans notre résultat:

 bpf_text = bpf_text.replace('MIN_US',str(min_usec)) if args.milliseconds: bpf_text = bpf_text.replace('FACTOR','data->lat /= 1000;') label = "msec" else: bpf_text = bpf_text.replace('FACTOR','') label = "usec" 

Ensuite, nous devons préparer le programme BPF avec une macro BPF () et enregistrer les sondes:

 b = BPF(text=bpf_text) b.attach_kprobe(event="generic_make_request",fn_name="start") b.attach_kretprobe(event="generic_make_request",fn_name="stop") 

Nous devons également définir la même structure que struct data_t dans notre script pour lire les données du programme BPF:

 TASK_COMM_LEN = 16 # linux/sched.h DISK_NAME_LEN = 32 # linux/genhd.h class Data(ct.Structure): _fields_ = [("pid", ct.c_ulonglong), ("ts", ct.c_ulonglong), ("comm", ct.c_char * TASK_COMM_LEN), ("lat", ct.c_ulonglong), ("disk",ct.c_char * DISK_NAME_LEN)] 

La dernière étape consiste à imprimer les données que nous voulons:

 def print_event(cpu, data, size): global start event = ct.cast(data, ct.POINTER(Data)).contents if start == 0: start = event.ts time_s = (float(event.ts - start)) / 1000000000 print("%-18.9f %-16s %-6d %-1s %s %s" % (time_s, event.comm, event.pid, event.lat, label, event.disk)) b["events"].open_perf_buffer(print_event) # format output start = 0 while 1: try: b.perf_buffer_poll() except KeyboardInterrupt: exit() 

Le script complet est disponible sur GitHub . Exécutons le script et déclenchons les événements udev pendant que fio écrit sur un périphérique bcache:



Succès! Nous voyons maintenant que ce qui ressemblait à une latence élevée pour bcache est vraiment une latence generic_make_request () pour son périphérique de support.

Creusez dans le noyau


Qu'est-ce qui traîne lors de la soumission des demandes? Nous constatons qu'un pic de latence s'est produit avant même que la comptabilité des demandes ne démarre. Cela pourrait être facilement vérifié en exécutant iostat pendant le problème ou le script BCC de biolatence , qui sont basés sur le démarrage de la demande de comptabilité, donc aucun outil n'affichera le problème du disque.

Si nous jetons un œil à generic_make_request () , nous voyons qu'il y a deux fonctions en cours d'exécution avant le début de la comptabilité. Le premier est generic_make_request_checks () , qui est léger et vérifie la bio en fonction des paramètres de l'appareil, etc. Le second est blk_queue_enter () , qui a un appel wait_event_interruptible () :

 ret = wait_event_interruptible(q->mq_freeze_wq, (atomic_read(&q->mq_freeze_depth) == 0 && (preempt || !blk_queue_preempt_only(q))) || blk_queue_dying(q)); 

Ici, le noyau attend que la file d'attente soit libérée. Mesurons la latence de blk_queue_enter ():

 ~# /usr/share/bcc/tools/funclatency blk_queue_enter -i 1 -m Tracing 1 functions for "blk_queue_enter"... Hit Ctrl-C to end. msecs : count distribution 0 -> 1 : 341 |****************************************| msecs : count distribution 0 -> 1 : 316 |****************************************| msecs : count distribution 0 -> 1 : 255 |****************************************| 2 -> 3 : 0 | | 4 -> 7 : 0 | | 8 -> 15 : 1 | | 

On dirait que nous sommes proches. Les fonctions utilisées pour geler / dégeler la file d'attente sont blk_mq_freeze_queue et blk_mq_unfreeze_queue . Ils sont utilisés pour modifier les paramètres de file d'attente, ce qui pourrait affecter les demandes io actuellement en cours. Lorsque blk_mq_freeze_queue () est appelé, q-> mq_freeze_depth est incrémenté dans blk_freeze_queue_start () . Après cela, le noyau attend que la file d'attente soit vide dans blk_mq_freeze_queue_wait () .

Ce temps d'attente est égal à la latence du disque, car le noyau doit attendre la fin de toutes les opérations io. Lorsque la file d'attente est vide, des modifications peuvent être apportées. La dernière étape consiste à appeler blk_mq_unfreeze_queue () , ce qui diminue le compteur freeze_depth .

Nous en savons maintenant suffisamment pour résoudre le problème. La commande de déclenchement udevadm modifie les paramètres des périphériques de bloc. Ces paramètres sont décrits dans les règles udev. Nous pouvons découvrir quels paramètres gèlent la file d'attente en les modifiant via sysfs ou en consultant le code source du noyau. Alternativement, nous pouvons appeler trace à partir de la boîte à outils BCC pour imprimer les piles de noyau et d'utilisateur pour chaque appel blk_freeze_queue :

 ~# /usr/share/bcc/tools/trace blk_freeze_queue -K -U PID TID COMM FUNC 3809642 3809642 systemd-udevd blk_freeze_queue blk_freeze_queue+0x1 [kernel] elevator_switch+0x29 [kernel] elv_iosched_store+0x197 [kernel] queue_attr_store+0x5c [kernel] sysfs_kf_write+0x3c [kernel] kernfs_fop_write+0x125 [kernel] __vfs_write+0x1b [kernel] vfs_write+0xb8 [kernel] sys_write+0x55 [kernel] do_syscall_64+0x73 [kernel] entry_SYSCALL_64_after_hwframe+0x3d [kernel] __write_nocancel+0x7 [libc-2.23.so] [unknown] 3809631 3809631 systemd-udevd blk_freeze_queue blk_freeze_queue+0x1 [kernel] queue_requests_store+0xb6 [kernel] queue_attr_store+0x5c [kernel] sysfs_kf_write+0x3c [kernel] kernfs_fop_write+0x125 [kernel] __vfs_write+0x1b [kernel] vfs_write+0xb8 [kernel] sys_write+0x55 [kernel] do_syscall_64+0x73 [kernel] entry_SYSCALL_64_after_hwframe+0x3d [kernel] __write_nocancel+0x7 [libc-2.23.so] [unknown] 

Les règles Udev ne changent pas souvent, donc même l'attribution de valeurs déjà existantes à certains paramètres provoque un pic de latence de soumission pour l'application. Bien sûr, générer des événements udev lorsqu'il n'y a aucun changement dans la configuration d'un périphérique (aucun périphérique n'est connecté ou détaché) n'est pas une bonne pratique. Néanmoins, nous pouvons empêcher le noyau de geler la file d'attente s'il n'y a aucune raison de le faire. Trois petits commits résolvent le problème.

Conclusion


eBPF est un instrument très flexible et puissant. Dans cet article, nous avons examiné un seul cas et démontré un peu de ses capacités. Si vous êtes intéressé par le développement d'outils basés sur BCC, vous devriez jeter un œil au didacticiel officiel , qui décrit ses concepts fondamentaux.

Il existe également d'autres outils intéressants basés sur eBPF disponibles pour le profilage et le débogage. L'un d'eux est bpftrace , qui vous permet d'écrire de puissants oneliners et de petits programmes dans un langage de type awk. Un autre est ebpf_exporter , qui peut collecter des mesures de haute résolution de bas niveau sur votre serveur prometheus avec ses excellentes capacités de visualisation et d'alerte.

Source: https://habr.com/ru/post/fr450818/


All Articles