En mi red de casa uso Unbound como resolver, corriendo en el ZimaBlade. Si habéis seguido las entradas anteriores ya sabéis por dónde va la cosa: primero lo puse como DNS central de la red, luego le añadí caché persistente con Redis y desde entonces no he parado de afinarlo.

Con el tiempo me hice una regla mental muy sencilla: si una consulta que ya está en caché tarda más de 1 ms, algo va mal. Nunca me había fallado.

Hasta que empezó a aparecer un 3 ms de vez en cuando. Siempre 3. Y siempre sobre dominios que estaban en caché.

kaotchi Avatar
Kaotchi
¿Tres milisegundos? Si está en caché no tiene que ir a ninguna parte, ¡solo tiene que leer de la RAM!
rita Avatar
Rita
Oye causa, tres milisegundos… ¿eso no es menos que un pestañeo, pe?
kaotchi Avatar
Kaotchi
Lo es. Pero cuando algo que debería tardar cero tarda tres, no me molesta el retraso: me molesta no saber por qué.

🔍 Lo primero: dig no sirve para medir esto

El síntoma aparecía «cada cierto tiempo», sin patrón aparente. Ese es exactamente el tipo de problema que no se puede diagnosticar a mano, y por dos razones.

La primera es que dig redondea a milisegundos enteros. Una respuesta de 0,6 ms sale como 0 msec y una de 2,6 ms como 3 msec. Estás midiendo décimas con una regla marcada en milímetros.

La segunda es más importante: el evento resultó afectar al 0,3–0,6 % de las consultas. Con esa frecuencia necesitas unos 200 dig seguidos para esperar cazar uno. Lanzando comandos a mano puedes pasarte semanas sin verlo y concluir tranquilamente que no existe.

Así que lo primero fue tirar el dig y escribirme un medidor: consultas UDP crudas con perf_counter(), 1190 muestras en 60 segundos, y percentiles en vez de medias.

rita Avatar
Rita
Oye, ¿y por qué percentiles? ¿Con el promedio de siempre no te bastaba, causa?
adrian Avatar
Adrian
¡Buena pregunta, Rita! Es que el promedio esconde justo lo que Kaotchi está buscando. Los percentiles salen de ordenar todas las mediciones de la más rápida a la más lenta y mirar qué valor cae en cada posición.
adrian Avatar
Adrian
El p50 es la mediana, o sea lo normal. El p99 es el valor que solo supera 1 de cada 100 consultas. Y el p99.9 es la peor de cada 1000.
adrian Avatar
Adrian
Con una media, una sola medición de 82 ms te infla el promedio y parece que todo va fatal, cuando el 99 % iba a 0,5 ms. Y al revés: si el pico es muy raro, la media lo tapa. Los percentiles te enseñan dónde empieza la cola, que es exactamente el dato que hace falta aquí.
rita Avatar
Rita
¡Ah, ya! Muéstranos esa primera tanda pues.
// primera medición, 1190 muestras
p50    0,269 ms
p99    0,562 ms
p99.9  3,081 ms   ← aquí está
máx   82,132 ms

Y de paso el propio dato explicaba el comportamiento: la frontera entre «normal» y «pico» caía entre el p99 y el p99.9, lo que sitúa el evento por debajo del 1 % de las consultas. Por eso nunca lo pillaba a mano.

❌ Descartando lo obvio

Los picos se agrupaban de forma muy compacta entre 2,9 y 3,1 ms. Eso ya es información valiosa: el ruido aleatorio no se comporta así. Algo tardaba una cantidad concreta y repetible.

Fui descartando por medición, no por intuición:

  • ¿Las lecturas a Redis? Lo uso como caché secundaria vía cachedb, y era mi sospechoso número uno. Pero al correlacionar había consultas con GET a Redis que salían a 0 ms, y picos sin ningún GET. Sin correlación.
  • ¿La carga de otros servicios? La máquina corría Open WebUI y SearXNG. Los paré del todo y volví a medir: idéntico, p99.9 de 2,95 ms. Además solo generaban 0,43 consultas por segundo.
  • ¿El transporte hacia el upstream? Imposible, y lo demostraba el propio síntoma: el TTL bajaba de 288 a 287, o sea que la respuesta salía de caché. Lo que pasa aguas arriba no está en ese camino.
rinn Avatar
Rinn
Me gusta el orden. Nada de «yo creo que será tal cosa»: cada sospechoso, su medición.
rita Avatar
Rita
Ya se quedó sin culpables la Kaotchi. ¿Y ahora qué, le echamos la culpa al gato?

🐛 El culpable estaba en el código fuente

Estaba en Unbound. En util/log.c, cada línea de log se escribe así:

lock_basic_lock(&log_lock);
fprintf(logfile, ...);
fflush(logfile);        // fuerza el write() en CADA línea
lock_basic_unlock(&log_lock);
adrian Avatar
Adrian
¡Ahí está! Son dos decisiones que por separado parecen razonables y que juntas salen carísimas. ¿Me dejáis explicarlo?
adrian Avatar
Adrian
La primera es el fflush en cada línea. No acumula nada en un búfer: escribe ya, ahora mismo. Normalmente eso va a la caché de páginas del kernel y tarda microsegundos, así que no se nota.
adrian Avatar
Adrian
Pero de vez en cuando el kernel está vaciando páginas sucias, o el sistema de ficheros tiene que reservar un bloque nuevo y anotarlo en el journal… y entonces ese write() se bloquea unos milisegundos.
adrian Avatar
Adrian
Y la segunda: todo eso ocurre dentro de un mutex global, uno solo para los cuatro hilos. Cuando un hilo se atasca escribiendo, los otros tres se quedan esperando el candado. Y esos tres estaban respondiendo consultas.
kaotchi Avatar
Kaotchi
Entonces el disco tropieza… ¡y quien se cae es el DNS!
rinn Avatar
Rinn
Exacto. Y fíjate en el matiz, que es lo bonito del hallazgo: no es que escribir sea caro. Es que es síncrono y compartido.

La confirmación fue apagar log-queries y log-replies y volver a medir:

Configuración Mediana p99.9 Máx Por encima de 1 ms
Log encendido 0,269 ms 3,081 ms 82,1 ms 0,6 %
Log apagado 0,173 ms 0,411 ms 0,72 ms 0 %

⚠️ La trampa de la tanda única

Aquí casi meto la pata bien gorda. Probé una solución intermedia —dejar solo log-replies— y la primera tanda salió limpia: cero picos. Estuve a un pelo de darlo por bueno y escribir la entrada.

Repetí la medición por pura costumbre. La segunda tanda: cinco picos, uno de 13 ms.

kaotchi Avatar
Kaotchi
Si hubiera publicado la primera tanda, os habría contado exactamente lo contrario de la realidad.
adrian Avatar
Adrian
Es lo esperable, no te martirices: cuando persigues un evento que ocurre en el 0,4 % de los casos, una sola tanda de 1190 muestras puede perderlo por pura suerte. Toda medición que sirva para decidir algo tiene que repetirse.
rita Avatar
Rita
¡Esa es buena, causa! Medir una vez es opinión, medir dos veces ya es chisme confirmado.

💡 La solución: el log en RAM

La disyuntiva parecía clarísima: o latencia limpia, o log de consultas. Y el log no era prescindible, porque de él comen dos herramientas mías: un generador de listas para precacheo y un TUI de monitorización en vivo.

Pero la disyuntiva era falsa. El problema no es escribir: es escribir a un disco. Y /dev/shm es tmpfs, o sea RAM.

logfile: /dev/shm/unbound.log

Dos tandas por configuración, 1190 muestras cada una:

Configuración p99.9 (dos tandas) Máx Por encima de 1 ms
Log en disco 3,08 / 2,95 ms hasta 82 ms 0,3–0,6 %
Log en RAM 0,476 / 0,497 ms 0,889 ms 0 %
Log apagado 0,411 / 0,364 ms 0,383 ms 0 %

Prácticamente indistinguible de apagarlo, y con el log completo funcionando. El coste en memoria es irrelevante: 165 bytes por consulta, unos 44 MB al día, en un tmpfs de 7,8 GB.

linda Avatar
Linda
Buena idea, pero cuidado con la rotación: logrotate no puede usar olddir hacia otro dispositivo, porque por debajo hace un rename().
kaotchi Avatar
Kaotchi
Cierto, ya me topé con eso. Se arregla con copytruncate, que copia en vez de renombrar, más una directiva su root root porque /dev/shm es 1777.
linda Avatar
Linda
¡Y los reinicios! tmpfs se vacía al arrancar, así que perderías todo lo acumulado desde la última rotación.
kaotchi Avatar
Kaotchi
Para eso tengo una unit de systemd que fuerza la rotación al apagar. Mira:
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=/usr/sbin/logrotate -f /etc/logrotate.d/unbound

Conviene verificar una cosa más de copytruncate: que el proceso no siga escribiendo en el offset antiguo y deje un agujero de ceros en el fichero. Unbound abre el log en modo append, así que tras el truncado vuelve al principio limpiamente. Comprobado: tamaño aparente igual al real, cero bytes nulos.

sb16 Avatar
Songbird ṣoḍaśa
61 68 6F 72 61 20 65 6C 20 6C 6F 67 20 76 69 76 65 20 65 6E 20 6C 61 20 52 41 4D 2C 20 64 6F 6E 64 65 20 6E 69 6E 67 75 6E 20 64 69 73 63 6F 20 70 75 65 64 65 20 74 72 61 69 63 69 6F 6E 61 72 6C 6F
rita Avatar
Rita
¿Y este qué dijo? Yo solo veo numeritos, pe.

📌 Colateral: Redis, DNSSEC y las entradas caducadas

De camino apareció otra cosa que no había visto documentada en ningún sitio, y que afecta directamente a lo que monté en la entrada anterior.

Uso Redis como caché persistente vía el módulo cachedb, para que un reinicio no se lleve la caché por delante. Y sí, sobrevive: tras reiniciar tenía 11 138 claves cargadas del volcado. Pero las consultas seguían siendo lentas.

Las entradas con TTL vivo se servían perfectamente, en 0 ms. Las caducadas no, y con serve-expired activado deberían devolverse al instante como respuesta rancia mientras se refrescan por detrás. En vez de eso, resolución completa: 250–430 ms.

El log con verbosidad alta lo decía literalmente:

cachedb msg expired
Serve expired: Trying to reply with expired data
Serve expired: unchecked entry needs validation   ← aborta aquí

Y el código, en cachedb.c:

/* By setting this to unchecked, bogus data is not returned
 * as non-bogus. */
if(qstate->env->cfg->cachedb_check_when_serve_expired)
        qstate->return_msg->rep->security = sec_status_unchecked;
adrian Avatar
Adrian
Fijaos en el comentario del código: es deliberado, no un fallo. Redis guarda el mensaje DNS en crudo y dos marcas de tiempo, pero ningún estado de validación. Unbound no tiene forma de saber si ese mensaje se validó alguna vez, así que se niega a darlo por bueno y mesh.c bloquea la respuesta rancia.
adrian Avatar
Adrian
Y el detalle importante es que esto solo ocurre cuando la caché local está vacía, o sea justo después de reiniciar. En marcha normal la copia validada local tiene preferencia, e Unbound incluso se niega explícitamente a pisarla.
kaotchi Avatar
Kaotchi
Que traducido: la caché en Redis es mucho menos útil de lo que parece justo cuando más la necesitas. En mi caso solo el 8,1 % de las claves tenía TTL vivo tras el reinicio; el resto pagaba resolución completa.
rinn Avatar
Rinn
Y sin arreglo limpio, salvo renunciar a la validación DNSSEC. Que no es plan.

🚨 Y para terminar, un apagón autoinfligido

En mitad de todo esto, el DNS de casa se cayó del todo. Chrome daba DNS_PROBE_FINISHED_NXDOMAIN y todo lo que no estaba cacheado devolvía SERVFAIL.

La causa: tengo en el router unas reglas anti-bypass, para que los cacharros de la red no se salten mi resolver usando DNS cifrado.

iptables -I FORWARD -p tcp --dport 853 -j REJECT
iptables -I FORWARD -p udp --dport 853 -j REJECT

Un bloqueo sin excepciones. Que atrapa también a mi propio resolver, que necesita el puerto 853 para hablar con su upstream por DNS-over-TLS. Lo había ejecutado justo después de reiniciar el router. Y como tenía forward-first: no —sin respaldo a resolución propia, para no filtrar consultas en claro— el fallo del upstream se convirtió en caída total.

ssymbian Avatar
Soldado Symbian
Inexplicablemente este soldado ya ha visto esta batalla antes. Una muralla que no distingue amigos de enemigos deja fuera también a los propios.
kaotchi Avatar
Kaotchi
Esa es la lección obvia, sí. Pero hay otra que me incomoda más.
kaotchi Avatar
Kaotchi
Antes de quitar el respaldo había medido 15 minutos de tráfico y comprobado que no se disparaba nunca. La medición era correcta; la conclusión que saqué de ella no lo era.
ssymbian Avatar
Soldado Symbian
Quince minutos de calma no dicen nada sobre lo que pasa cuando algo falla de verdad. Este soldado lo aprendió en tiempos que ya nadie recuerda.
rita Avatar
Rita
Uy, se puso profundo el soldadito. Pero tiene razón, pe.

📝 Lo que aprendí

  • Mide con la herramienta adecuada. dig redondea a milisegundos y toma una sola muestra. Para eventos raros hacen falta microsegundos, muchas muestras y percentiles.
  • Repite toda medición que decida algo. Una tanda me dijo justo lo contrario que la siguiente.
  • Un resultado negativo solo vale si la prueba funcionaba. Arrastré media sesión un «paquete misterioso» que resultó no existir: era un artefacto de contar la salida de tcpdump con una tubería y un timeout, que se come el búfer al morir. Capturando a fichero .pcap, y con un control que demostraba que el método sí capturaba: cero paquetes.
  • Comprueba quién depende de lo que vas a tocar. Apagar el log parecía inocuo. Alimentaba dos herramientas.
  • Y desconfía de las disyuntivas. «O latencia o log» resultó ser mentira en cuanto pregunté por qué escribir era caro, en vez de aceptar que lo era.

Así se queda el ZimaBlade: Unbound 1.25.1 sobre Debian, cuatro hilos, caché de mensajes de 256 MB, Redis por socket unix como cachedb y DoT a Quad9 con validación DNSSEC.

Y el resumen de toda la cacería, de dónde salimos y dónde acabamos:

Métrica Antes Después
Mediana 0,269 ms 0,19 ms
p99.9 3,081 ms 0,45 ms
Máximo 82,132 ms 0,889 ms
Consultas por encima de 1 ms 0,6 % 0 %
kaotchi Avatar
Kaotchi
Tres milisegundos menos y varias semanas de dolor de cabeza. Yo lo llamo victoria.
rita Avatar
Rita
¡Chévere! Y con eso, este blog carga tres milisegundos más rápido en tu casa. De nada. -3-

Nos vamos viendo por aquí, ¡gracias por leer!

Compartir
Avatar de KAOTCHI
ESCRITO POR:

Amo la música, los juegos y aprender idiomas.

Me encanta descubrir nuevas cosas sobre diseño web, y sueño con lograr todo lo que tengo planeado en ese mundo.