Articles

Histoire de la guerre de débogage : le mystère de NXDOMAIN

Le billet de blog suivant décrit une aventure de débogage sur le cluster basé sur Mesos de Cloudflare. Ce cluster interne est principalement utilisé pour traiter les informations des fichiers journaux afin que les clients Cloudflare disposent d’analyses, ainsi que pour nos systèmes qui détectent et répondent aux attaques.

Le problème rencontré n’a eu aucun effet sur nos clients, mais les ingénieurs se sont grattés la tête…

Le problème

À un moment donné dans l’un de nos clusters, nous avons commencé à voir des erreurs comme celle-ci (un NXDOMAIN pour un domaine existant sur notre DNS interne) :

lookup some.existing.internal.host on 10.1.0.9:53: no such host

Cela semblait très bizarre, car le domaine existait bel et bien. C’était l’un de nos domaines internes ! Les ingénieurs avaient mentionné qu’ils avaient vu ce comportement, alors nous avons décidé d’enquêter plus en profondeur. Les requêtes déclenchant cette erreur étaient variées et allaient des enregistrements SRV dynamiques gérés par mesos-dns aux domaines externes recherchés depuis l’intérieur du cluster.

Notre première tentative naïve a été d’exécuter ce qui suit en boucle:

while true; do dig some.existing.internal.host > /tmp/dig.txt || break; done

L’exécution de ceci pendant un certain temps sur un serveur n’a pas reproduit le problème: toutes les recherches ont réussi. Ensuite, nous avons pris nos journaux de service pendant une journée et avons fait un grep pour « aucun hôte de ce type » et des messages similaires. Des erreurs se produisaient sporadiquement. Il y a eu des heures entre les erreurs et aucun schéma évident qui pourrait nous conduire à une conclusion. Notre enquête a écarté la possibilité que l’erreur se trouve dans Go, que nous utilisons pour beaucoup de nos services, car les erreurs provenaient également des services Java.

Dans le trou du lapin

Nous avions l’habitude d’exécuter Non lié sur une seule adresse IP sur quelques machines pour notre résolveur DNS de cluster. BGP est alors responsable de l’annonce des routes internes des machines au routeur. Nous avons décidé d’essayer de trouver un modèle en envoyant beaucoup de demandes de différentes machines et en enregistrant des erreurs. Voici à quoi ressemblait notre programme de test de charge au début:

package mainimport ("flag""fmt""net""os""time")func main() {n := flag.String("n", "", "domain to look up")p := flag.Duration("p", time.Millisecond*10, "pause between lookups")flag.Parse()if *n == "" {flag.PrintDefaults()os.Exit(1)}for {_, err := net.LookupHost(*n)if err != nil {fmt.Println("error:", err)}time.Sleep(*p)}}

Nous exécutons net.LookupHostdans une boucle avec de petites pauses et des erreurs de journal; c’est tout. Empaqueter cela dans un conteneur Docker et courir sur Marathon était un choix évident pour nous, car c’est de toute façon comme ça que nous gérons d’autres services. Les journaux sont expédiés à Kafka puis à Kibana, où nous pouvons les analyser. L’exécution de ce programme sur 65 machines effectuant des recherches toutes les 50 ms montre la répartition des erreurs suivante (de haut en bas) entre les hôtes:

Nous n’avons vu aucune corrélation forte avec les racks ou les machines spécifiques. Des erreurs se sont produites sur de nombreux hôtes, mais pas sur tous et dans différentes fenêtres temporelles, des erreurs se produisent sur différentes machines. Mettre le temps sur l’axe des abscisses et le nombre d’erreurs sur l’axe des ordonnées a montré ce qui suit :

Pour voir si un récurseur DNS particulier était devenu fou, nous avons arrêté tous les générateurs de charge sur les machines ordinaires et démarré l’outil de génération de charge sur les récurseurs eux-mêmes. Il n’y a pas eu d’erreurs en quelques heures, ce qui suggérait que Unbound était parfaitement sain.

Nous avons commencé à soupçonner que la perte de paquets était le problème, mais pourquoi « aucun hôte de ce type » ne se produirait-il? Cela ne devrait se produire que lorsqu’une erreur NXDOMAIN se trouve dans une réponse DNS, mais notre théorie était que les réponses ne revenaient pas du tout.

Le

Manquant Pour tester l’hypothèse selon laquelle la perte de paquets peut entraîner une erreur « pas d’hôte de ce type », nous avons d’abord essayé de bloquer le trafic sortant sur le port 53 :

sudo iptables -A OUTPUT -p udp --dport 53 -j DROP

Dans ce cas, les outils dig et similaires arrivent juste à expiration, mais ne renvoient pas « pas d’hôte de ce type » :

; <<>> DiG 9.9.5-9+deb8u3-Debian <<>> cloudflare.com;; global options: +cmd;; connection timed out; no servers could be reached

Go est un peu plus intelligent et vous en dit plus sur ce qui se passe, mais ne renvoie pas non plus « aucun hôte de ce type »:

error: lookup cloudflare.com on 10.1.0.9:53: write udp 10.1.14.20:47442->10.1.0.9:53: write: operation not permitted

Comme le noyau Linux indique à l’expéditeur qu’il a laissé tomber des paquets, nous avons dû pointer le serveur de noms vers un trou noir du réseau qui ne fait rien avec les paquets pour imiter la perte de paquets. Toujours pas de chance:

error: lookup cloudflare.com on 10.1.2.9:53: read udp 10.1.14.20:39046->10.1.2.9:53: i/o timeout

Pour continuer à blâmer le réseau, nous devions soutenir nos hypothèses d’une manière ou d’une autre, nous avons donc ajouté des informations de synchronisation à nos recherches:

s := time.Now()_, err := net.LookupHost(*n)e := time.Now().Sub(s).Seconds()if err != nil { log.Printf("error after %.4fs: %s", e, err)} else if e > 1 { log.Printf("success after %.4fs", e)}

Pour être honnête, nous avons commencé par des erreurs de synchronisation et ajouté un timing de réussite plus tard. Des erreurs se produisaient après 10 secondes, comparativement de nombreuses réponses réussies arrivaient après 5 secondes. Cela ressemble à une perte de paquets, mais ne nous dit toujours pas pourquoi « un tel hôte » ne se produit pas.

Comme nous étions maintenant à un endroit où nous savions quels hôtes étaient les plus susceptibles d’être affectés par cela, nous avons exécuté les deux commandes suivantes en parallèle dans deux sessions screen :

while true; do dig cloudflare.com > /tmp/dig.log || break; done; date; sudo killall tcpdumpsudo tcpdump -w /state/wtf.cap port 53

Le but était d’obtenir un vidage réseau avec des résolutions échouées. Là, nous avons vu les requêtes suivantes:

00.00s A cloudflare.com05.00s A cloudflare.com10.00s A cloudflare.com.in.our.internal.domain

Deux requêtes expirent sans aucune réponse, mais la troisième a de la chance et réussit. Naturellement, nous n’avons pas cloudflare.com dans notre domaine interne, donc Unbound donne à juste titre NXDOMAIN en réponse, 10 secondes après le lancement de la recherche.

Bingo

Regardons /etc/resolv.conf pour mieux comprendre:

nameserver 10.1.0.9search in.our.internal.domain

L’utilisation du mot-clé de recherche nous permet d’utiliser des noms d’hôte courts au lieu du nom de domaine complet, ce qui rend myhost équivalent de manière transparente à myhost.in.our.internal.domain.

Pour le résolveur DNS, cela signifie ce qui suit : pour toute requête DNS, demandez au serveur de noms 10.1.0.9, si cela échoue, ajoutez .in.our.internal.domain à la requête et réessayez. Peu importe l’échec de la requête DNS d’origine. Habituellement, il s’agit de NXDOMAIN, mais dans notre cas, il s’agit d’un délai de lecture dû à une perte de paquets.

Les événements suivants semblaient devoir se produire pour qu’une erreur  » no such host » apparaisse:

  1. La requête DNS d’origine doit être perdue
  2. La nouvelle tentative envoyée après 5 secondes doit être perdue
  3. La requête suivante pour le domaine interne (causée par l’option search) doit réussir et renvoyer NXDOMAIN

D’autre part, pour observer une requête DNS expirée au lieu de NXDOMAIN, vous devez perdre quatre paquets envoyé 5 secondes l’une après l’autre (2 pour la requête d’origine et 2 pour la version interne de votre domaine), ce qui est une probabilité beaucoup plus faible. En fait, nous n’avons vu un NXDOMAIN qu’une seule fois après 15 secondes et nous n’avons jamais vu d’erreur après 20 secondes.

Pour valider cette hypothèse, nous avons construit un serveur DNS de preuve de concept qui supprime toutes les requêtes pour cloudflare.com , mais envoie un NXDOMAIN pour les domaines existants :

package mainimport ("github.com/miekg/dns""log")func main() {server := &dns.Server{Addr: ":53", Net: "udp"}dns.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {m := &dns.Msg{}m.SetReply(r)for _, q := range r.Question {log.Printf("checking %s", q.Name)if q.Name == "cloudflare.com." {log.Printf("ignoring %s", q.Name)// just ignorereturn}}w.WriteMsg(m)})log.Printf("listening..")if err := server.ListenAndServe(); err != nil {log.Fatalf("error listening: %s", err)}}

Enfin, nous avons trouvé ce qui se passait et avons trouvé un moyen de répliquer de manière fiable ce comportement.

Solutions

Réfléchissons à la façon dont nous pouvons améliorer notre client pour mieux gérer ces problèmes de réseau transitoires, le rendant plus résilient. La page de manuel pour resolv.conf vous indique que vous avez deux boutons : les options timeout et retries. Les valeurs par défaut sont respectivement 5 et 2.

À moins que vous ne gardiez votre serveur DNS très occupé, il est très peu probable qu’il lui faille plus d’une seconde pour répondre. En fait, si vous avez un appareil réseau sur la Lune, vous pouvez vous attendre à ce qu’il réponde en 3 secondes. Si votre serveur de noms vit dans le rack suivant et est accessible via un réseau haut débit, vous pouvez supposer en toute sécurité que s’il n’y a pas de réponse après 1 seconde, votre serveur DNS n’a pas reçu votre requête. Si vous voulez avoir des erreurs moins étranges « pas de domaine de ce type » qui vous font vous gratter la tête, autant augmenter les tentatives. Plus vous réessayez avec une perte de paquets transitoire, moins vous risquez d’échouer. Plus vous réessayez souvent, plus vous avez de chances de terminer plus vite.

Imaginez que vous avez une perte de paquets vraiment aléatoire de 1%.

  • 2 tentatives, délai d’attente de 5 secondes : 10 secondes d’attente maximum avant l’erreur, 0,001% de chance d’échec
  • 5 tentatives, délai d’attente de 1 seconde: 5 secondes d’attente maximum avant l’erreur, 0,000001% de chance d’échec

Dans la vraie vie, la distribution serait différente du fait que la perte de paquets n’est pas aléatoire, mais vous pouvez vous attendre à attendre beaucoup moins pour que le DNS réponde avec ce type de changement.

Comme vous le savez, de nombreuses bibliothèques système qui fournissent une résolution DNS comme glibc, nscd, systemd-resolved ne sont pas durcies pour gérer le fait d’être sur Internet ou dans un environnement avec des pertes de paquets. Nous avons relevé le défi de créer un environnement de résolution DNS fiable et rapide à plusieurs reprises au fur et à mesure de notre croissance, pour découvrir plus tard que la solution n’est pas parfaite.

À vous

Compte tenu de ce que vous avez lu dans cet article sur la perte de paquets et l’espace de noms split-DNS / privé, comment concevriez-vous une configuration de résolution rapide et fiable ? Quel logiciel utiliseriez-vous et pourquoi? Quels changements de réglage par rapport à la configuration standard utiliseriez-vous?