GeoDNS programmable : migration d’AWS vers Bunny

Vincent Bernat

Pour op­ti­mi­ser les per­for­mances de ce blog, je m’ap­puyais sur Ama­zon CloudFront pour ser­vir les res­sources sta­tiques1 et sur Route 53 pour un DNS géo­lo­ca­lisé2 qui di­rige les lec­teurs vers le ser­veur le plus proche. Mais Route 53 coûte cher : 0,50 $ par zone et par mois, 0,70 $ par mil­lion de re­quêtes géo­lo­ca­li­sées et 0,75 $ par mois et par sonde de dis­po­ni­bi­lité, aux­quels s’ajoute 1 $ par mois pour la clé KMS né­ces­saire à la si­gna­ture des zones avec DNSSEC. Quand j’ai dé­cou­vert que Bunny DNS est presque gra­tuit jusqu’à 500 do­maines et pro­pose des ré­ponses pro­gram­mables, j’ai sauté sur l’oc­ca­sion! En prime, c’est une en­tre­prise eu­ro­péenne, ins­tal­lée en Slovénie.

Bunny CDN#

Bunny.net a dé­marré sous le nom BunnyCDN en 2015. L’en­tre­prise a ajouté la prise en charge d’IPv6 en 2017. La mi­gra­tion s’est dé­rou­lée sans en­combre. Je n’uti­lise pas de do­maine per­son­na­lisé : je sers le contenu de­puis le do­maine b-cdn.net. Comme je m’ap­puie sur la né­go­cia­tion de contenu pour ser­vir des images AVIF et WebP, je confi­gure le CDN pour mettre en cache chaque for­mat d’image sé­parément.

Je désac­tive la jour­na­li­sa­tion pour ne pas col­lec­ter de don­nées per­son­nelles. Si la page RGPD de bunny.net pro­met l’ano­ny­mi­sa­tion des don­nées, im­pos­sible de sa­voir ce que CloudFront conserve dans ses propres jour­naux quand la jour­na­li­sa­tion est désactivée.

Le seul point faible de l’offre CDN de bunny.net est l’ab­sence de HTTP/3. J’ai in­ter­rogé leur sup­port : ils m’ont ré­pondu en deux mi­nutes qu’ils y tra­vaillent, mais sans date précise.

Bunny DNS#

Bunny.net a lancé son offre DNS en 2022 et a ré­cem­ment ré­duit son prix à qua­si­ment zéro. Elle pro­pose le rou­tage géo­gra­phique, mais une fonc­tion­na­lité vrai­ment in­té­res­sante est le DNS pro­gram­mable : un bout de JavaScript per­son­na­lisé s’exé­cute à chaque re­quête pour cal­cu­ler la réponse.

Je fais tour­ner sept pe­tites ma­chines vir­tuelles NixOS pour hé­ber­ger ce blog aux quatre coins du monde : Pa­ris, Nu­rem­berg, Ash­burn, Sin­ga­pour, San José, São Paulo et Syd­ney. Le DNS pro­gram­mable de Bunny me per­met de ren­voyer les adresses IP des deux ser­veurs les plus proches. Le na­vi­ga­teur se ra­bat sur le se­cond si le pre­mier est en panne.

const servers = [
  {
    ipv4: "45.90.160.60",
    ipv6: "2a0c:8881:5:100::1",
    latitude: 48.88,
    longitude: 2.22,
  },
  /* […] */
  {
    ipv4: "139.180.162.114",
    ipv6: "2001:19f0:5800:1855:5400:6ff:fef0:4c0a",
    latitude: -33.87,
    longitude: 151.21,
  },
];
const families = { A: "ipv4", AAAA: "ipv6" };
const records = { A: ARecord, AAAA: AaaaRecord };

export default function handleQuery(query) {
  const type = String(query.request.queryType).toUpperCase();
  const family = families[type];
  if (!family) return [];
  // Position de l'utilisateur
  const location = query.request.geoLocation;
  // Liste des candidats
  let candidates = servers.map(
    (s) => new Server(s[family], s.latitude, s.longitude, 100),
  );
  // Choix des deux serveurs disponibles les plus proches
  const selected = [];
  while (selected.length < 2) {
    const server = RoutingEngine.getClosestServer(candidates, location, true);
    selected.push(server);
    candidates = candidates.filter((s) => s.ip !== server.ip);
  }
  // Renvoi de leurs adresses IP
  return selected.map((s) => new records[type](s.ip, 3600));
}

Ce script re­pose sur la fonc­tion RoutingEngine.getClosestServer(), qui ren­voie le ser­veur dis­po­nible3 le plus proche de l’uti­li­sa­teur4. Grâce à Doggo, vé­ri­fions que tout fonc­tionne comme prévu, au moins pour A5 :

$ doggo --gp-from="Amsterdam" A vincent.bernat.ch
LOCATION          NAME                TYPE  TTL    ADDRESS
Amsterdam, NL
                  vincent.bernat.ch.  A     3600s  45.90.160.60
                  vincent.bernat.ch.  A     3600s  116.203.18.48
$ doggo --gp-from="Texas" A vincent.bernat.ch
LOCATION          NAME                TYPE  TTL    ADDRESS
Houston (TX), US
                  vincent.bernat.ch.  A     3600s  5.161.44.145
                  vincent.bernat.ch.  A     3600s  149.28.219.155

Le dé­faut le plus gê­nant de cette mé­thode est qu’un uti­li­sa­teur à Bue­nos Aires ob­tient à la fois São Paulo (1 600 km, 30 ms de RTT) et Ash­burn (8 400 km, 140 ms de RTT). La plu­part des ré­sol­veurs mé­langent l’ordre des ré­ponses : l’uti­li­sa­teur risque donc de char­ger la page de­puis Ash­burn. Il se­rait pos­sible de ne ren­voyer que le ser­veur le plus proche, et même de rem­pla­cer l’en­re­gis­tre­ment pro­grammé par le rou­tage géo­gra­phique in­té­gré à Bunny DNS. Mais si ce ser­veur de­vient in­dis­po­nible, le na­vi­ga­teur n’a au­cune so­lu­tion de re­pli avant l’ex­pi­ra­tion du cache DNS. Même avec un TTL faible, une brève fe­nêtre d’in­dis­po­ni­bi­lité subsiste.

La solution de l’enregistrement HTTPS#

L’en­re­gis­tre­ment DNS HTTPS, dé­fini dans la RFC 9460, ré­sout à la fois le pro­blème de dis­po­ni­bi­lité et ce­lui de la­tence. Les na­vi­ga­teurs mo­dernes in­ter­rogent cet en­re­gis­tre­ment pour ob­te­nir des in­for­ma­tions sup­plé­men­taires afin de joindre un ser­veur HTTP, no­tam­ment un autre port et le pro­to­cole ap­pli­ca­tif (HTTP/2 ou HTTP/3). Chaque en­re­gis­tre­ment pos­sède une prio­rité. Il est ainsi pos­sible de de­man­der aux na­vi­ga­teurs de contac­ter d’abord le ser­veur le plus proche, puis de se ra­battre sur le sui­vant si le pre­mier est in­dis­ponible :

vincent    HTTPS  1 first.geo  alpn="h3,h2"
vincent    HTTPS  2 second.geo alpn="h3,h2"
vincent    HTTPS  3 .          alpn="h3,h2"

; Pour "first.geo" et "second.geo", Bunny DNS doit fournir les deux
; serveurs les plus proches via le routage géographique.
first.geo  A      45.90.160.60
first.geo  AAAA   2a0c:8881:5:100::1
second.geo A      116.203.18.48
second.geo AAAA   2a01:4f8:1c0c:5eb5::1

vincent    A      45.90.160.60
vincent    A      116.203.18.48
vincent    AAAA   2a0c:8881:5:100::1
vincent    AAAA   2a01:4f8:1c0c:5eb5::1

Cela semble la so­lu­tion idéale ! Mal­heu­reu­se­ment, Chrome ignore tout en­re­gis­tre­ment dont la cible n’est pas ., d’où le der­nier en­re­gis­tre­ment en prio­rité 3. De plus, contrai­re­ment à un CNAME, Bunny DNS ne ren­voie pas les adresses IP de first.geo et second.geo dans la sec­tion ad­di­tion­nelle. Le ré­sol­veur doit en­voyer une pre­mière re­quête pour ob­te­nir l’en­re­gis­tre­ment HTTPS, puis une se­conde pour ré­soudre first.geo.

Les en­re­gis­tre­ments HTTPS sont en­core trop ré­cents pour ce cas. Re­tour à la case départ ! 📐

La solution hybride#

En guise de com­pro­mis, le script ren­voie les deux ser­veurs les plus proches s’ils se trouvent à une dis­tance si­mi­laire de l’uti­li­sa­teur. Si­non, il ne ren­voie que le plus proche, avec un TTL ré­duit à 2 minutes.

export default function handleQuery(query) {
  const location = query.request.geoLocation;
  let candidates = servers.map(
    (s) => new Server(s.ipv4, s.latitude, s.longitude, 100),
  );

  // Choix des deux serveurs les plus proches
  const selected = [];
  while (selected.length < 2) {
    const server = RoutingEngine.getClosestServer(candidates, location, true);
    selected.push(server);
    candidates = candidates.filter((s) => s.ip !== server.ip);
  }

  // Calcul de la distance entre chacun d'eux et l'utilisateur
  const [first, second] = selected.map((s) =>
    GeoDistance.calculate(s, location),
  );

  // Si le second est deux fois plus loin et à plus de 1000 miles du
  // premier, ne renvoyer que le premier avec un TTL de 2 minutes.
  if (second > 2 * first && second - first > 1000) {
    return [new ARecord(selected[0].ip, 120)];
  }

  // Sinon, renvoyer les deux avec un TTL d'une heure.
  return selected.map((s) => new ARecord(s.ip, 3600));
}

Vé­ri­fions avec Doggo. De­puis Chi­cago, nous ob­te­nons uni­que­ment le ser­veur d’Ash­burn avec un TTL de 2 mi­nutes, tan­dis que de­puis le Mexique, nous ob­te­nons à la fois Ash­burn et San José.

$ doggo --gp-from="Chicago" A vincent.bernat.ch
LOCATION          NAME                TYPE  TTL    ADDRESS
Chicago (IL), US
                  vincent.bernat.ch.  A     120s   5.161.44.145
$ doggo --gp-from="Mexico" A vincent.bernat.ch
LOCATION          NAME                TYPE  TTL    ADDRESS
Queretaro, MX
                  vincent.bernat.ch.  A     3600s  5.161.44.145
                  vincent.bernat.ch.  A     3600s  149.28.219.155
$ doggo --reverse 5.161.44.145 149.28.219.155
NAME                          TYPE  TTL    ADDRESS
145.44.161.5.in-addr.arpa.    PTR   7187s  web05.luffy.cx.
155.219.28.149.in-addr.arpa.  PTR   3600s  web07.luffy.cx.

Note

Sur la page en ligne, la carte ci-des­sous est in­ter­ac­tive : poin­tez un en­droit pour ob­te­nir la ré­ponse DNS, ou désac­ti­vez des ser­veurs pour voir com­ment les ré­ponses changent.

Note

Ac­ti­vez JavaScript pour rendre la carte ci-des­sous in­ter­ac­tive : poin­tez un en­droit pour ob­te­nir la ré­ponse DNS, ou désac­ti­vez des ser­veurs pour voir com­ment les ré­ponses changent.

Cette page vous a été ser­vie par ???. Vé­ri­fiez sur la carte ci-des­sous si cela cor­res­pond à votre position !

Carte des serveurs renvoyés par Bunny DNS pour chaque endroit

Le code com­plet est un peu plus com­plexe. Il contourne la li­mi­ta­tion de RoutingEngine.getClosestServer() qui ne fonc­tionne qu’avec IPv4 et il gère le cas où il ne reste pas as­sez de ser­veurs dis­po­nibles pour construire une réponse.

Bunny.net pro­pose un four­nis­seur Ter­ra­form com­plet. J’uti­lise CDK Ter­rain pour construire l’état cible en TypeScript et OpenTofu pour le dé­ployer. Pour plus de dé­tails, consul­tez « Gé­rer une in­fra­struc­ture avec Ter­ra­form, CDKTF et NixOS6 ».

Défis ACME DNS avec Hickory DNS#

Route 53 cou­vrait un autre de mes be­soins : ser­vir acme.luffy.cx pour ré­soudre les dé­fis ACME DNS-01. Contrai­re­ment à Route 53, Bunny DNS ne per­met pas de res­treindre l’ac­cès à une seule zone avec une po­li­tique d’ac­cès fine. Comme cet usage n’exige ni haute dis­po­ni­bi­lité ni faible la­tence, j’ai rem­placé Route 53 par Hi­ckory, un ser­veur DNS écrit en Rust. Il prend en charge la si­gna­ture DNSSEC au­to­ma­tique et les mises à jour dy­na­miques. Sa confi­gu­ra­tion est simple :

[[zones]]
zone = "acme.luffy.cx"
zone_type = "Primary"

[zones.stores]
allow_update = true
type = "sqlite"
journal_path = "acme.luffy.cx.sqlite"
zone_path = "acme.luffy.cx.zone"

# Clé privée pour signer la zone avec DNSSEC. Générée avec :
#  openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
[[zones.keys]]
algorithm = "ECDSAP256SHA256"
key_path = "/etc/hickory-dns.acme.luffy.cx.pem"

# Clé TSIG pour signer les mises à jour de la zone. Générée avec :
#  openssl rand 32
[[zones.stores.tsig_keys]]
algorithm = "hmac-sha256"
key_file = "/etc/hickory-dns.acme.luffy.cx.update.acme.luffy.cx.tsig"
name = "update.acme.luffy.cx."

Le fi­chier de zone fourni sert uni­que­ment à ini­tia­li­ser la base au pre­mier dé­mar­rage. Je dé­lègue les dé­fis DNS-01 à cette zone via des en­re­gis­tre­ments CNAME :

_acme-challenge          CNAME bernat.ch.acme.luffy.cx.
_acme-challenge.vincent  CNAME vincent.bernat.ch.acme.luffy.cx.

Lego, le client ACME que j’uti­lise pour ob­te­nir des cer­ti­fi­cats au­près de Let’s En­crypt, ré­pond aux dé­fis DNS-01 en pu­bliant des en­trées avec le pro­to­cole DNS UPDATE.

Mal­heu­reu­se­ment, Bunny DNS ne peut pas pu­blier d’en­re­gis­tre­ment DS dans une zone7. J’en ai be­soin pour sé­cu­ri­ser la zone acme.luffy.cx et la li­vrai­son des cer­ti­fi­cats. J’hé­berge donc la zone luffy.cx chez deSEC8, un ser­vice d’hé­ber­ge­ment DNS gra­tuit géré par une as­so­cia­tion à but non lu­cra­tif ins­tal­lée à Ber­lin. Je cal­cule l’en­re­gis­tre­ment DS à pu­blier dans luffy.cx à par­tir de l’en­re­gis­tre­ment DNSKEY de acme.luffy.cx :

$ ZONE=acme.luffy.cx
$ dig @nsupdate.luffy.cx $ZONE DNSKEY +noall +answer \
>   | dnssec-dsfromkey -a SHA-256 -f - $ZONE
acme.luffy.cx. IN DS 55628 13 2 4946E7508F20E15F1B577A231104B029A1A82E04699A80CD46ADCE31227EE81F

Dans l’en­semble, je suis plu­tôt sa­tis­fait du pas­sage à bunny.net. Il reste quelques écueils, comme l’im­pos­si­bi­lité de pu­blier des en­re­gis­tre­ments DS, l’ab­sence de HTTP/3 et la fonc­tion RoutingEngine.getClosestServer() li­mi­tée à IPv4. Ma fac­ture est pas­sée de 5 $ à 1 $ par mois. Pour l’ins­tant, les per­for­mances sont équi­va­lentes à celles d’AWS, mais je n’ai fi­na­lisé la confi­gu­ra­tion ac­tuelle, dont le ser­veur de Syd­ney, qu’à la fin d’oc­tobre. J’ai be­soin d’un peu plus de temps pour com­pa­rer et je pu­blie­rai une mise à jour. 📈