A fetch() nem a hálózatra várt: a DNS a Node négy munkaszálán áll sorban

Négy crypto.pbkdf2() hívás, és ugyanaz a fetch() 524 ms helyett 2217 ms. A dns.lookup() ugyanis nem hálózati I/O, hanem blokkoló getaddrinfo a libuv négyszálas threadpoolján – ugyanott, ahol az fs, a crypto és a zlib dolgozik. Megmértem, mi történik, és megnéztem, mit lehet tenni ellene.

Két mérés, ugyanabban a Node-processzben, ugyanarra az URL-re. Először üres processzben adtam ki egy fetch()-et: 524 ms, hideg TLS-kézfogással együtt. Aztán elindítottam a háttérben négy crypto.pbkdf2() hívást – jelszó-hash-elés, a világ legunalmasabb regisztrációs terhelése –, és megint kiadtam ugyanazt a fetch()-et: 2217 ms. A hálózat közben rezzenéstelen volt, és szabad CPU-mag is akadt bőven. A fetch() arra várt, hogy a DNS-feloldás egyáltalán elindulhasson, mert a Node-ban a névfeloldás ugyanazon a négy munkaszálon fut, mint a jelszó-hash-elés, a fájlolvasás és a gzip.

Diagram: a libuv threadpool négy szlotja crypto-, fs- és zlib-feladatokkal foglalt, a dns.lookup a bejáratnál várakozik, míg a dns.resolve4 a c-ares könyvtárral a threadpoolt megkerülve, közvetlenül a hálózat felé megy

Ez nem bug, hanem dokumentált, szándékos működés – ami nem akadályozta meg a nodejs/node#8436 issue szerzőjét, hogy már 2016-ban ezzel a címmel nyisson hibajegyet: „dns.lookup blocks filesystem I/O”.

Az event loop nem csinál mindent

A Node aszinkronitása két, nagyon különböző gépezetből áll össze. Az egyik az event loop: nem blokkoló szocketek, epoll/kqueue, ezen megy az összes tényleges hálózati I/O, és ebből egyetlen egy van. A másik a libuv threadpoolja: ide kerül minden, amihez az OS nem ad használható aszinkron API-t, és amit ezért egy worker szálon, blokkolva kell végrehajtani. A hivatalos lista meglepően hosszú: az összes aszinkron fs művelet, az aszinkron crypto (pbkdf2, scrypt, randomBytes, generateKeyPair…), az összes aszinkron zlib hívás – és a dns.lookup().

A poolban alapértelmezetten négy szál van. Nem magonként négy: összesen négy, akkor is, ha a géped 32 magos.

Hogy kerül a DNS a fájlműveletek közé?

Úgy, hogy a dns.lookup() valójában nem DNS-kliens. A libc getaddrinfo() függvényét hívja, vagyis ugyanúgy old fel nevet, ahogy a ping: /etc/hosts, nsswitch.conf, resolv.conf, mDNS – ami az adott gépen éppen be van kötve. A getaddrinfo() viszont blokkoló hívás, aszinkron változata nincs, ezért a libuv az egészet a threadpoolra tolja. A Node doksija egészen őszintén fogalmaz: a hívás csak JavaScript felől nézve aszinkron, valójában szinkron getaddrinfo() egy worker szálon, ami – idézem – „meglepő negatív teljesítmény-következményekkel” járhat.

És itt jön a rész, amit kevesebben tudnak: gyakorlatilag minden kimenő kapcsolat ezen megy át. A net.connect() alapértelmezett lookup függvénye a dns.lookup, a http.request() ugyanezt örökli, és a beépített fetch() az undicin keresztül szintén net.connect()/tls.connect()-et hív. Minden HTTP-hívásod névfeloldása ugyanabban a négyszemélyes liftben utazik, mint az fs.readFile().

Mérjük meg

A recept egyszerű: lefoglaljuk mind a négy szálat valami lassú threadpool-munkával, aztán feloldjuk ugyanazt a nevet kétféleképpen.

import { lookup, resolve4 } from 'node:dns';
import { pbkdf2 } from 'node:crypto';

// lefoglaljuk a threadpool mind a négy szálát egy-egy ~2 másodperces hash-sel
for (let i = 0; i < 4; i++) {
  pbkdf2('jelszo', 'so', 4_000_000, 64, 'sha512', () => {});
}

const t0 = performance.now();

lookup('jsblog.hu', (err, addr) => {
  console.log(`dns.lookup:   ${(performance.now() - t0).toFixed(0)} ms  → ${addr}`);
});

resolve4('jsblog.hu', (err, addrs) => {
  console.log(`dns.resolve4: ${(performance.now() - t0).toFixed(0)} ms  → ${addrs?.[0]}`);
});

Node 22 alatt, nálam:

dns.resolve4: 6 ms     → 172.67.128.229
dns.lookup:   1944 ms  → 104.21.2.77

A dns.resolve4() 6 ms alatt megjött, mert az a c-ares könyvtárral tényleges DNS-csomagot küld a resolvernek az event loopról – nem blokkol, nem kér szálat. A dns.lookup() addig ült a várósorban, amíg az egyik pbkdf2 el nem készült: közel két másodpercig. Pontosan ez történt a bevezetőben mért fetch()-csel is.

A dolog visszafelé is működik, és élesben az a fájdalmasabb irány: ha a resolver lassul be – egy terhelt CoreDNS a klaszterben, egy félig süket céges VPN-es DNS –, akkor négy beragadt getaddrinfo() az összes fájlműveletet, tömörítést és crypto-hívást maga mögé állítja. A szolgáltatásod olyan helyeken kezd akadozni, amiknek látszólag semmi közük a DNS-hez, és a grafikonokon az fs lesz a gyanúsított.

Akkor írjunk mindenhova dns.resolve-ot?

Csak óvatosan, mert a két API nem ugyanarra a kérdésre válaszol. A saját gépemen egy olyan névre, ami a /etc/hosts-ban is szerepel, ezt kaptam:

await dns.lookup('a-gepem-hosztneve');    // { address: '127.0.1.1', family: 4 }  ← /etc/hosts
await dns.resolve4('a-gepem-hosztneve');  // [ '84.2.242.147' ]                   ← publikus DNS-rekord

Ugyanaz a név, két különböző cím, és egyik API sem hazudik. A lookup az OS-t kérdezi, az OS pedig a /etc/hosts-ban talált bejegyzést; a resolve4 a c-ares-szel egyenesen a DNS-szervert kérdezte, ami a publikus rekordot adta vissza. A c-ares ugyanis a /etc/hosts-ot meg sem nézi. Kubernetes hostAliases, docker-compose extra_hosts, lokális hosts-hackek: mind láthatatlanok a resolve* családnak. Aki vaktában cserél, az egy teljesítményproblémát vált le egy sokkal szórakoztatóbb, környezetfüggő routing-problémára.

A csendes szorzó: nincs cache

A Node maga semmilyen DNS-cache-t nem tart: minden getaddrinfo() hívás elölről kezdi. Cache legfeljebb az OS szintjén van – systemd-resolved, nscd –, csakhogy egy tipikus, minimál image-ből futó konténerben ezekből semmi sem fut. Konténerben tehát minden új kimenő kapcsolat egy valódi DNS-kérdés a resolver felé, threadpool-szálon. A keep-alive ezt sokáig elrejti, mert élő kapcsolaton nincs újrafeloldás; aztán jön egy deploy, egy pool-bővülés vagy egy upstream-újraindulás, a kapcsolatok újraépülnek, és a felgyülemlett DNS-teher egyszerre zúdul rá ugyanarra a négy szálra.

Mit lehet tenni

  • UV_THREADPOOL_SIZE: környezeti változóként, még indulás előtt (futásidőben, process.env-ből állítva a doksi szerint nem garantált, hogy hat), a felső határ 1024. I/O-váró szálaknál nem drága 16–32-t adni. De ez csak szélesebb lift, nem megoldás.
  • Saját lookup függvény: a http.Agent és a net.connect() is fogad lookup opciót – ide bedugható például a cacheable-lookup, ami dns.resolve-val kérdez és TTL szerint cache-el.
  • fetch() alá: az undici interceptors.dns() interceptora pontosan erre való – originenként megjegyzi a feloldott címet a TTL lejártáig, és nem old fel újra minden kapcsolatnál.
  • Kevesebb feloldás: hosszú életű keep-alive kapcsolatok, közös Agent/Dispatcher – nem kapcsolat kérésenként.

Verdikt

Ha a szolgáltatásod egy-két upstreamet hív keep-alive-val, és nem hash-el, nem tömörít, fájlt is ritkán fog, ebből az egészből soha semmit nem fogsz látni. Hagyd békén.

Ha viszont konténerben futó, kifelé sokat hívó Node-szolgáltatásod van – pláne, ha közben fájlt olvas, gzipel vagy jelszót hash-el –, akkor ez a csatolás ott van a rendszeredben, és arra vár, hogy a resolver egyszer rossz napot fogjon ki. Én production-ben két dolgot tennék meg alapból: explicit UV_THREADPOOL_SIZE-t a threadpool-intenzív szolgáltatásokra, és DNS-cache-t (undici dns interceptor vagy cacheable-lookup) a kimenő forgalom alá. Nem azért, mert a DNS általában lassú, hanem azért, mert amikor egyszer lassú lesz, ez a hibamód a legalattomosabb fajta: a metrikákon a fájlrendszer lassul, a tettes meg egy UDP-csomagra váró worker szál.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük