La découverte du paquet de poison d'Apache ZooKeeper
ZooKeeper, pour ceux qui ne le savent pas, est un projet open source bien connu qui permet une coordination distribuée hautement fiable. De nombreuses personnes dans le monde lui font confiance, y compris PagerDuty. Il offre une haute disponibilité et une linéarisation grâce au concept de leader, qui peut être réélu de manière dynamique, et garantit la cohérence grâce à un quorum majoritaire.
Les mécanismes d'élection du leader et de détection des échecs sont assez matures et fonctionnent généralement… jusqu'à ce qu'ils ne fonctionnent plus. Comment est-ce possible ? Eh bien, après une longue enquête, nous avons réussi à découvrir quatre bugs différents qui se sont réunis pour conspirer contre nous, entraînant des blocages aléatoires à l'échelle du cluster. Deux de ces bugs se trouvaient dans ZooKeeper, et les deux autres se cachaient dans le noyau Linux. Voici notre histoire.
Contexte : l'utilisation de ZooKeeper chez PagerDuty
Chez PagerDuty, nous disposons de plusieurs services disparates qui alimentent notre pipeline d'alertes. Au fur et à mesure que les événements sont reçus, ils traversent ces services sous la forme d'une série de tâches qui sont récupérées dans diverses files d'attente de travail. Chacun de ces services exploite un cluster ZooKeeper dédié pour coordonner quel hôte d'application traite chaque tâche. En tant que tel, vous pouvez imaginer que les opérations de ZooKeeper sont absolument essentielles à la fiabilité de PagerDuty dans son ensemble.
Partie I : Les insectes gardiens du zoo
Trop de sessions client
L'année dernière, un ingénieur a remarqué que l'un des clusters ZooKeeper de notre environnement de test de charge était défaillant. Cela s'est manifesté par des dépassements de délai de verrouillage dans l'application dépendante. Nous avons confirmé que le cluster était accessible et à l'écoute, mais quelque chose clochait : chaque client avait des dizaines de sessions actives avec les membres de son cluster ZooKeeper respectif. Normalement, ils n'en ont que deux. En conséquence, nous avons atteint une limite sur le nombre de sessions actives autorisées par nœud, et une exception pertinente a été enregistrée.
Comment le client a-t-il pu être aussi stupide ? Peut-être qu'il y avait un bug dans le Bibliothèque ZooKeeper Nous utilisions à l'époque. Le redémarrage de l'ensemble du cluster ZooKeeper a résolu le problème et nous n'avons plus eu aucun moyen de le reproduire. Après avoir fouillé dans le code de la bibliothèque, nous n'avons pas pu trouver de condition susceptible de provoquer un empilement de sessions. Nous étions morts et le pire, c'est que nous n'avions aucune idée si cela pouvait se produire en production ou non.
Bogue n°1
Moins d'une semaine plus tard, la situation s'est reproduite dans notre environnement de test de charge. Cette fois, elle affectait un autre cluster ZooKeeper et se produisait alors que nous générions une charge importante. Nous avons réalisé que nous pouvions reproduire le problème en induisant une charge synthétique et en attendant simplement une heure ou deux.
Nous avons remarqué que le nombre de sessions augmentait de manière linéaire sur tous les nœuds ZooKeeper. Nous en avons donc déduit que même s'il s'agissait d'un problème avec le client, il existait probablement une condition dans ZooKeeper qui déclenchait le comportement. Nous avons commencé à creuser davantage dans les journaux ZooKeeper. Après un certain temps, nous avons trouvé quelque chose de prometteur dans le journal du leader :
java.lang.OutOfMemoryError : espace de tas Java à org.apache.jute.BinaryInputArchive.readString(BinaryInputArchive.java:81) à org.apache.zookeeper.data.Id.deserialize(Id.java:54) à org.apache.jute.BinaryInputArchive.readRecord(BinaryInputArchive.java:108) à org.apache.zookeeper.data.ACL.deserialize(ACL.java:56) à org.apache.jute.BinaryInputArchive.readRecord(BinaryInputArchive.java:108) à org.apache.zookeeper.proto.CreateRequest.deserialize(CreateRequest.java:91) ...
En parcourant la pile d'exécution, nous avons trouvé ceci : schéma=a_.readString('schéma');
. Hmph. Eh bien, le protocole ZooKeeper a un octet de quatre schéma_len
champ… peut-être que le client calcule mal la valeur. Quoi qu’il en soit, le protocole impose une taille maximale pour le schéma. Hélas, il n’y a pas de contrôle des limites sur ce champ.
Après de nombreuses captures de paquets, nous avons pu trouver un seul paquet problématique. Il contenait un schéma_len
de 0x6edd0b51
… ou environ 1,7 Go. L'absence de vérification des limites a conduit ZooKeeper à essayer d'allouer de la mémoire pour la longueur fictive, ce qui a provoqué une exception OutOfMemory, tuant le thread. Cool. Enfin, pas si cool, mais maintenant nous commençons à avancer. Il y a encore tellement de questions, mais le problème le plus pertinent est clair : si le leader est mort, pourquoi n'est-il pas réélu ?
Bogue n°2
Il s'avère que ZooKeeper ne détectait pas les exceptions non gérées de ses threads critiques, ce qui signifie que si l'une d'elles mourait, le processus ZooKeeper continuer à courir sans elle . Malheureusement, cela signifie que les mécanismes de battement de cœur continueraient également à fonctionner, trompant les suiveurs en leur faisant croire que le leader est en bonne santé. schéma_len
est géré par le thread de préprocesseur de requêtes (par lequel toutes les requêtes doivent passer), l'ensemble du cluster devient effectivement catatonique - apparemment vivant mais largement insensible.
Nous avons une surveillance des processus avec des contrôles de santé spécifiques à zk, mais en raison de la nature de l'échec, ces contrôles de santé ont continué à réussir (de manière ennuyeuse). Ainsi, le thread mourrait sur le leader et bloquerait toutes les opérations suivantes tout en échappant à plusieurs mécanismes de détection d'échec en même temps. Le résultat final est que nous avons découvert que nous pouvons faire basculer un cluster ZooKeeper entier avec un seul paquet de poison.
Partie II : Les bugs du noyau
Corruption de la charge utile TCP
Nous comprenons maintenant les échecs de ZooKeeper, mais une question beaucoup plus importante demeure : comment pouvons-nous voir ce genre de valeurs pour schéma_len
?? En décodant le paquet, nous avons pu voir qu'il ne s'agissait pas seulement schéma_len
qui a été affecté, mais un bloc entier de 16 octets était apparemment corrompu. Voici un extrait du premier mauvais paquet que nous avons localisé :
0170 00 00 00 03 00 00 00 b6 00 03 2a 67 00 00 00 01 ..........*g.... 0180 00 00 00 7e 2f 47 65 6d 69 6e 69 2f 63 6f 6d 2e ...~/Gémeaux/com. 0190 70 61 67 65 72 64 75 74 79 2e 77 6f 72 6b 71 75 pagerduty.workqu 01a0 65 75 65 2e 53 69 6d 70 6c 65 51 75 65 75 65 61 eue.SimpleQueuea 01b0 62 6c 65 2f 52 45 44 41 43 54 45 44 5f 52 45 44 ble/REDACTED_RED 01c0 41 43 54 45 44 5f 52 45 44 41 43 54 45 44 5f 52 ACTED_REDACTED_R 01d0 45 44 41 43 2f 5f 63 5f 35 66 62 65 61 34 37 62 EDAC/_c_5fbea47b 01e0 2d 61 65 62 31 2d 34 36 62 35 2d 62 32 32 33 2d -aeb1-46b5-b223- 01f0 38 65 34 65 31 37 38 34 32 31 34 39 2j 6c 6f 63 8e4e17842149-loc 0200 6b 2j 00 00 00 09 31 32 37 2e 30 2e 30 2e 31 00 k-....127.0.0.1. 0210 7c 0e 5b 86 df f3 fc 6e dd 0b 51 dd eb cb a1 a6 |.[....n..Q..... 0220 00 00 00 06 61 6e 79 6f 6e 65 00 00 00 03 ....n'importe qui....
La corruption ne correspond pas aux champs que l'on attendrait du protocole ZooKeeper, mais correspond plutôt aux limites de 16 octets du paquet lui-même (à partir du décalage 0210). Compte tenu de ces informations, il semble désormais assez improbable que ZooKeeper ait quelque chose à voir avec le problème. schéma_len
aucune valeur du tout.
Cette capture a été prise alors que le paquet sortait du réseau sur l'un des nœuds ZooKeeper. En d'autres termes, il était déjà corrompu au moment où il a atteint ZooKeeper. Cela signifie que soit le client a envoyé le paquet corrompu, soit un périphérique réseau l'a corrompu. En règle générale, si un périphérique intermédiaire est responsable de la corruption, un ensemble de sommes de contrôle sera invalidé et le système récepteur le rejettera. La charge utile TCP atteignait clairement ZooKeeper dans ce cas, donc les sommes de contrôle devaient toutes être correctes... mais à notre grande surprise, elles ne l'étaient pas !
IPSec
Avant d'aller plus loin, il est important de savoir que PagerDuty utilise IPSec dans Mode de transport pour sécuriser tout notre trafic inter-hôtes. Sans être trop verbeux, c'est fondamentalement la même chose que votre VPN IPSec classique, sans la partie VPN. La charge utile IP est cryptée, laissant les en-têtes IP derrière pour que le paquet puisse être acheminé à travers le réseau comme il le serait normalement. Vous obtenez ainsi un cryptage de type VPN sans avoir besoin de maintenir un espace d'adressage séparé. De plus, la surcharge est répartie, chaque hôte cryptant et décryptant son propre trafic.
Il est important de comprendre notre utilisation de cette technologie, car jusqu'à présent, toutes les captures que nous avons examinées ont été décryptées pour analyse. Étant donné que les en-têtes et la charge utile TCP sont cryptés dans IPSec, mais que les en-têtes IP ne le sont pas, cela signifie que nous avons une somme de contrôle à l'extérieur d'IPSec et une à l'intérieur. Cela signifie également que le décryptage réussi du paquet prouve qu'il n'a pas été corrompu après avoir été crypté.
Notre examen des sommes de contrôle a montré que la somme de contrôle IP était valide, mais pas la somme de contrôle TCP. La seule façon dont cela pourrait être possible est que la corruption se soit produite après la trame TCP a été formée, mais avant les en-têtes IP ont été générés. Indépendamment de la manière/pourquoi/où cette corruption de charge utile TCP pourrait se produire, elle doit absolument être rejetée par le récepteur car la somme de contrôle TCP n'est pas valide... qu'est-ce qui se passe ?
Bug n°3 – Comportement obscur
La seule chose qui ait du sens à ce stade est de se renseigner auprès du public. Un peu de recherche sur les sources Linux révèle les lignes suivantes, qui restent dans le Branche principale Linux au moment où j'écris ces lignes :
/* * 2) ignorer les sommes de contrôle UDP/TCP en cas * de NAT-T en mode transport, ou * effectuer d'autres correctifs de post-traitement * conformément à draft-ietf-ipsec-udp-encaps-06, * section 3.1.2 */ if (x->props.mode == XFRM_MODE_TRANSPORT) skb->ip_summed = CHECKSUM_UNNECESSARY;
DE LA COLÈRE !!! Cela semble si mal – comment est-ce possible ? RFC 3948 raconte l'histoire. Il stipule que lors de l'utilisation d'IPSec en mode de transport NAT-T, le client PEUT renoncer à la validation de la somme de contrôle TCP/UDP en supposant que l'intégrité du paquet est déjà protégée par ESP . Qui aurait pu imaginer qu'il puisse exister un cas dans lequel on ne valide pas les sommes de contrôle TCP ?? L'hypothèse des auteurs est invalide, car il existe clairement de nombreuses possibilités de corruption avant la formation de l'ESP/IP. Bien que la somme de contrôle soit un excellent moyen de détecter la corruption en cours de vol, elle peut également être utilisée comme outil pour détecter la corruption pendant la formation du paquet. C'est ce dernier point qui a été négligé, et cette optimisation est venue nous mordre. Le manque de validation ici a laissé passer notre mystérieuse corruption - donnant à ZooKeeper de mauvaises données qu'il croyait raisonnablement protégées par TCP. Nous affirmons qu'il s'agit d'un bug - intentionnel ou non.
Le test
Au cours de notre enquête, nous avons constaté qu'il était étrangement difficile de reproduire le problème, même si nous y parvenions encore de temps en temps. Nous avions besoin d'un moyen simple de détecter et d'analyser cette corruption sans compliquer les choses avec l'utilisation de ZooKeeper, le décryptage par capture de fil, etc. Après quelques essais, nous avons opté pour une approche extrêmement simple : utiliser chat réseau
pour canaliser les zéros de /dev/zéro
sur le fil, et les envoyer à xxd
(un outil hexadécimal en ligne de commande). Toute valeur différente de zéro lue par xxd
il s'agit d'une corruption évidente. Voici à quoi ressemblent certaines de nos charges utiles TCP corrompues en utilisant cette approche :
-- evan@hôteB:~ $ nc -l 8080 | xxd -a 0000000: 0000 0000 0000 0000 0000 0000 0000 0000 0000 ................ * 189edea0:0000 1e30 e75c a3ef ab8b 8723 781c a4eb ...0......#x... 189edeb0:6527 1e30 e75c a3ef ab8b 8723 781c a4eb e'.0......#x... 189edec0:6527 1e30 e75c a3ef ab8b 8723 781c a4eb e'.0......#x... 189eded0:6527 1e30 e75c a3ef ab8b 8723 781c a4eb e'.0......#x... 189edee0:6527 9d05 f655 6228 1366 5365 a932 2841 e'...Ub(.fSe.2(A 189edef0:2663 0000 0000 0000 0000 0000 0000 0000 0000 &c.............. 189edf00:0000 0000 0000 0000 0000 0000 0000 0000 0000 ................ * 4927d4e0:5762 b190 5b5d db75 cb39 accd 5b73 982b Wb..[].u.9..[s.+ 4927d4f0:5762 b190 5b5d db75 cb39 accd 5b73 982b Wb..[].u.9..[s.+ 4927d500:5762 b190 5b5d db75 cb39 accd 5b73 982b Wb..[].u.9..[s.+ 4927d510:5762 b190 5b5d db75 cb39 accd 5b73 982b Wb..[].u.9..[s.+ 4927d520:01db 332d cf4b 3804 6f9c a5ad b9c8 0932 ..3-.K8.o......2 4927d530:0000 0000 0000 0000 0000 0000 0000 0000 ................ * 4bb51110:0000 54f8 a1cb 8f0d e916 80a2 0768 3bd3 ..T.......... h;. 4bb51120:3794 54f8 a1cb 8f0d e916 80a2 0768 3bd3 7.T.......... h;. 4bb51130:3794 54f8 a1cb 8f0d e916 80a2 0768 3bd3 7.T.......... h;. 4bb51140:3794 54f8 a1cb 8f0d e916 80a2 0768 3bd3 7.T.......... h;. 4bb51150:3794 20a0 1e44 ae70 25b7 7768 7d1d 38b1 7. ..Dp%.wh}.8. 4bb51160:8191 0000 0000 0000 0000 0000 0000 0000 0000 ................ 4bb51170:0000 0000 0000 0000 0000 0000 0000 0000 ................ * 4de3d390:0000 0000 0000 ...... -- evan@hostB:~ $
Cela semble horrible au niveau du matériel, n'est-ce pas ? Cela se produit généralement aux limites de 16 octets avec répétition. Nous avons émis l'hypothèse que nous avions peut-être des hôtes sur du mauvais matériel, nous avons donc commencé à exécuter ce test sur différentes paires d'hôtes dans notre infrastructure pour tenter de les démasquer. Ce que nous avons découvert était intéressant.
Les personnes affectées
La première chose que nous avons remarquée était la version du noyau. Malgré tous nos efforts, nous n'avons pas réussi à reproduire la situation sous Linux 2.6. La réplication réussie dépendait d'un noyau Linux 3.0+. Bien que nous ayons pu constater que le problème se produisait sous Linux 3.0+, il était incohérent. Un ensemble d'hôtes affectés exécutant une version particulière de 3.0+ serait affecté, tandis qu'un autre ensemble d'hôtes avec la même version ne serait pas affecté. Nous avons donc commencé à rechercher d'autres facteurs.
Après quelques semaines de recherches et de tests infructueux, nous avons finalement fait une découverte inquiétante : la reproduction dépendait de la version de Xen sur laquelle nos hôtes s'exécutaient. Les invités Xen 4.4 n'étaient pas affectés, mais Xen 4.1 et Xen 3.4 étaient tous deux affectés. Ce détail explique pourquoi nos résultats de test étaient incohérents lors de l'utilisation de versions de noyau fixes. Nous avons donc constaté que tout hôte exécutant un noyau Linux 3.0+ sur Xen 4.1 ou 3.4 subit une corruption sporadique lors du chiffrement IPSec. Pour la première fois, nous avons pu reproduire le problème de manière fiable, ainsi que prédire quels hôtes seraient affectés. Il ne nous reste plus qu'à trouver comment résoudre ce problème !
À l'aide !
À ce stade, nous disposons de suffisamment d’informations pour étendre notre recherche au-delà des limites organisationnelles de PagerDuty . Nous avons laissé tomber un mail Nous avons consulté LKML pour voir si quelqu'un d'autre avait déjà rencontré ce problème. Quelques jours plus tard, nous avons reçu une réponse. Herbert Xu, l'un des responsables du sous-système de cryptographie, a répondu qu'il avait déjà vu quelque chose de similaire. Il a fait état de spéculations selon lesquelles le mode HVM de Xen pourrait ne pas être affecté, et a pointé du doigt une instruction Intel particulière : aes-ni
.
Bogue n°4 – aesni-intel
Le jeu d'instructions Intel x86 comprend un Enseignement AES utilisé pour effectuer des calculs AES dans le matériel. Un module de noyau, aesni-intel
, est responsable de l'exploitation de cette instruction dans les fonctions de chiffrement AES fournies par le noyau Linux. Étant donné que notre chiffrement IPSec utilise AES, ce module serait probablement utilisé pour le chiffrement du trafic en présence de aes-ni
sur le matériel Intel. Sur la base des informations de LKML, une vérification rapide révèle que nous avons effectivement le aesni-intel
module de noyau chargé sur les hôtes de notre flotte. En forçant le module à se décharger, la corruption disparaît ! Alléluia !!
Des tests plus poussés ont révélé que Herbert avait raison à propos de HVM – il n’a pas été affecté. Finalement, le problème semble résider dans une interaction entre aesni-intel
et le mode paravirtuel Xen.
Avec un bug aussi sérieux qui se cache dans le aesni-intel
module, vous vous demandez peut-être comment personne n'a remarqué cela avant maintenant ? Après tout, AES est également utilisé occasionnellement pour le trafic SSL. Eh bien, la réponse à cela se trouve dans le bogue n°3 : ce n'est qu'en mode de transport IPSec NAT-T que le noyau ne valide pas les sommes de contrôle TCP. Cela signifie que dans toute autre condition, la validation de la somme de contrôle échouera et le paquet sera abandonné, protégeant l'application des données corrompues. Cela, ajouté aux restrictions de version Xen et de type de virtualisation, rend ce problème extrêmement rare... une licorne AES exotique qui ne peut être vue que par ceux qui savent où elle se trouve. Nous avons de la chance :).
Partie III : La solution de contournement
Rétrospection
Après plus d'un mois de recherches et de tests inlassables, nous avons enfin percé le mystère de ZooKeeper. La corruption lors du chiffrement AES dans les invités paravirtuels Xen v4.1 ou v3.4 exécutant un noyau Linux 3.0+, combinée à l'absence de validation de la somme de contrôle TCP dans le mode de transport IPSec, conduit à l'admission de données TCP corrompues sur un nœud ZooKeeper, ce qui entraîne une exception non gérée dont ZooKeeper est incapable de récupérer. Bon sang. Parlons d'une aiguille dans une botte de foin... Même après tout cela, nous ne savons toujours pas exactement où se trouve le bug. Malgré cela, nous sommes toujours assez satisfaits du résultat de l'enquête. Il ne nous reste plus qu'à le contourner.
Ce que nous avons fait
Le déchargement du module s'est avéré contrecarrer les bugs que nous avons rencontrés, bien qu'une telle action ait un impact sur les performances. Nous avons mesuré l'impact et il s'avère qu'il ne s'agit d'un problème qu'à des débits très élevés, pour lesquels nous disposons d'autres moyens d'atténuation. Nous savons également que les invités Xen HVM ne sont pas affectés, pas plus que Linux 2.6. Sachant cela, nous pouvons commencer à élaborer un plan d'attaque.
Les invités paravirtuels Xen existants exécutant Linux 3.0+ ont été rétrogradés vers la version 2.6, mais uniquement s'ils étaient sur une version affectée de Xen. Nous avons écrit une recette Chef pour effectuer la détection Xen et mettre sur liste noire les aesni-intel
module lorsque les conditions problématiques existent. Nous avons également commencé à standardiser les hôtes HVM plutôt que paravirtuels afin de pouvoir toujours tirer parti de l'accélération matérielle AES, entre autres choses.
En conclusion
Malheureusement, nous n'avons toujours pas trouvé de solution appropriée. Les versions ultérieures de Xen n'étant pas affectées et l'adoption de HVM se développant, la communauté ne souhaite pas perdre le temps nécessaire pour isoler le code qui est à l'origine du problème. Nous sommes confrontés à un problème similaire avec ZooKeeper : la version 3.5 dispose d'un correctif qui empêche un thread critique de mourir sans que personne ne le remarque, bien que la version 3.5 soit encore en phase alpha et le sera pendant un certain temps. Il est question de rétroporter le correctif, bien que la position officielle soit que ce soit le cas. pas considéré comme un bloqueur pour les prochaines versions 3.4.x.
Nous tenons à remercier les membres de nos différentes équipes d’ingénierie pour leur aide et leurs contributions liées à l’isolement de ces problématiques. Chez PagerDuty, nous prenons la fiabilité très au sérieux, et extraire les causes profondes des problèmes, même les plus exotiques, est quelque chose dont nous sommes très fiers et heureux. Si vous êtes d'accord, nous serions ravis que vous puissiez le faire. Rejoignez-nous .