
Medí Pingora 0.9.0 seis veces más lento que nginx. El culpable era una línea de mi código
Escribí un reverse proxy mínimo con Pingora 0.9.0 y en un contenedor de 2 vCPU medía 21 mil peticiones por segundo contra las 126 mil de nginx: seis veces más lento. El culpable no era Pingora sino un getaddrinfo bloqueante que mi upstream_peer ejecutaba en cada petición. Corregido, la brecha real es 1,70x, y perf la cuantifica: 1,70x en ciclos por petición, 119,4 mil contra 70,4 mil, sin decir dónde se gastan.
Pingora es la librería de Rust con la que Cloudflare reemplazó nginx en su borde, y mueve más de un billón de peticiones por día. La versión 0.9.0, etiquetada el 4 de septiembre, trae connection pooling reescrito y un pool de hilos para descargar los handshakes TLS. Quise medirla contra nginx en lo único que importa para mí: un pod chico, con límites de CPU, como corre cualquier cosa en producción.
Escribí el reverse proxy más simple posible con Pingora, lo puse en un contenedor con dos núcleos y lo golpeé. Medía 21 mil peticiones por segundo. nginx, en otro contenedor con los mismos límites, hacía 126 mil en esa primera corrida. Seis veces más lento.
Ese número era mentira, y la culpa era mía. Este post es cómo lo encontré, qué era, y qué mide Pingora 0.9.0 de verdad cuando el proxy está bien escrito.
Corregida una sola línea, la brecha real en un pod de 2 vCPU es 1,70x a carga moderada, y perf la cuantifica: 1,70x en ciclos por petición, 119,4 mil contra 70,4 mil de nginx.
Cómo lo medí
Los resultados principales salen de contenedores con límites de tipo pod, no del host libre: un número medido con dieciséis núcleos y treinta gigas libres no dice nada de cómo corre una cosa dentro de un pod acotado. Hubo mediciones sueltas sobre el host, y las señalo como tales.
- Fedora 43, Docker 29.8, cgroup v2.
- Cada pieza en su contenedor: backend, proxy y generador de carga separados.
- Backend:
nginx:alpinedevolviendo una respuesta fija de 1000 bytes, aislado en su propio proceso. - Los dos proxies con límites idénticos:
cpus=2,mem=256m,pids=300. - Pingora: crate 0.9.0, reverse proxy mínimo, hilos configurables.
- nginx:
worker_processes 2,keepalive 128hacia el upstream. - Carga:
oha1.16 en su propio contenedor, golpeando por nombre de servicio sobre la red de Docker. La ruta medida no toca el host.
Los mismos límites en pingora y nginx: la comparación es justa por construcción, no por promesa.
El 6x que no cerraba
Seis veces es demasiado. Cloudflare no habría reemplazado nginx con algo seis veces más lento. Antes de escribir una sola cifra, la primera regla es no creerle al número que te conviene menos entender.
Aislé el sistema pieza por pieza. No era throttling de CPU: el contador nr_throttled del cgroup estaba en cero. No era que Pingora abriera demasiados hilos: con threads=2 tenía exactamente cinco hilos, dos de trabajo. No era falta de reúso de conexiones: los dos proxies mantenían unas 130 conexiones abiertas al backend, ninguno abría una nueva por petición.
La pista estaba en la latencia. En el contenedor, la mediana de Pingora era 4,78 ms; sobre el host, con el backend en 127.0.0.1, el mismo binario daba 0,45 ms. Diez veces. Entre los dos entornos cambian varias cosas (red, aislamiento, límites), pero el contraste apuntaba a cómo nombraba yo al backend: backend (un nombre) en el contenedor, 127.0.0.1 (una IP) en el host. El getaddrinfo confirmó la causa.
Mi upstream_peer construía el destino así, en cada petición:
async fn upstream_peer(&self, _s: &mut Session, _c: &mut ()) -> Result<Box<HttpPeer>> {
// (&str, u16): esto resuelve el nombre EN CADA PETICIÓN
let peer = HttpPeer::new(("backend", 80), false, String::new());
Ok(Box::new(peer))
}
HttpPeer::new sobre un (&str, u16) llama a to_socket_addrs(), y eso es getaddrinfo: una resolución de nombre síncrona y bloqueante, ejecutada dentro del hilo de trabajo de tokio. Con un nombre, cada petición disparaba una consulta al resolutor DNS de Docker (127.0.0.11) y bloqueaba el hilo mientras esperaba. Con una IP, to_socket_addrs() es un parseo de texto sin syscalls, y por eso el host casi no lo notaba.
La nube de DNS es la misma línea de código. Con el bug, cada petición la visita y espera; arreglado, se resolvió una sola vez y el hilo nunca se detiene.
El arreglo es una línea: resolver el nombre una vez al arrancar, guardar el SocketAddr, y pasar eso (no el nombre) en cada petición.
// una vez, en main:
let addr = ("backend", 80).to_socket_addrs()?.next().unwrap();
// y en upstream_peer, sin resolución en el camino caliente:
Ok(Box::new(HttpPeer::new(self.addr, false, String::new())))
Con esa línea, Pingora pasó de 21 mil a 89 mil peticiones por segundo en el mismo pod. El 6x era el costo de un getaddrinfo por petición, no Pingora.
Lo que mide Pingora 0.9.0 bien escrito
Con el proxy arreglado, corrí la matriz completa: tres niveles de concurrencia, tres repeticiones cada uno, y el consumo de CPU y el throttling de cada corrida. (El salto de 21 a 89 mil de la sección anterior fue una prueba puntual antes de esto; la matriz de abajo, con tres repeticiones por punto, es la medición que vale.)
Carga moderada: nginx 1,70x, sin errores en ninguno de los dos.
Esta vuelta, sin errores en ninguno de los dos — a diferencia de la medición original (2026-09-10), que mostró 502 en Pingora en 2 de 3 corridas.
El que se desestabiliza acá es nginx, con ~20% de 502/500; Pingora entrega la mayoría de sus peticiones (menos del 1,1% de error).
A c=100 la diferencia es puro costo de CPU por petición: ninguno satura los dos núcleos. Re-medido el 23 de septiembre; datos crudos en public/data/pingora-reaudit-2026-09-23/.
A cien conexiones, nginx entrega 1,70 veces más que Pingora, sin errores en ninguno de los dos. A cuatrocientas conexiones, esta vuelta tampoco hubo errores en ninguno; sigue siendo pura diferencia de costo por petición. A mil conexiones el que se desestabiliza es nginx, con alrededor de 20% de respuestas 502/500; Pingora entrega la gran mayoría de las suyas, con menos de 1,1% de error. Es un resultado distinto al que había medido antes en este mismo punto (ver nota de reproducibilidad más abajo), y se reporta tal como salió, no ajustado para calzar con la medición anterior.
Por qué nginx gasta menos: el perfil
A cien conexiones ninguno satura la CPU, así que la diferencia de throughput es una diferencia de costo por petición. perf la mide sin ambigüedad.
La causa medida: Pingora ejecuta 1,88x más instrucciones por petición (121,9 mil vs 64,9 mil); su mejor IPC (1,02 vs 0,92) amortigua eso a 1,70x en ciclos, el mismo ratio del throughput. Los cambios de contexto y el futex existen pero son la señal del modelo multihilo, no la causa dominante de la brecha: el grueso está en las instrucciones ejecutadas, no en la sincronización. Re-medido el 23 de septiembre; datos crudos en public/data/pingora-reaudit-2026-09-23/.
Pingora gasta 119,4 mil ciclos por petición; nginx, 70,4 mil. La razón es 1,70, el mismo número que la diferencia de throughput. No hay misterio: a igual CPU disponible, quien gasta menos ciclos por petición sirve más peticiones.
¿En qué se van esos ciclos de más? La respuesta honesta es que Pingora ejecuta más código por petición: perf cuenta 1,88 veces más instrucciones (121,9 mil contra 64,9 mil), y solo su mejor uso del procesador (1,02 instrucciones por ciclo contra 0,92) amortigua esa diferencia hasta el 1,70 de ciclos. Dónde se ejecuta ese código de más, este perfil no lo resuelve: el 44 % de las muestras no traen pila. Lo que sí se descarta es epoll, que los dos usan (epoll_wait en Pingora, epoll_pwait en nginx). Y hay dos señales del modelo multihilo: Pingora hizo 31.209 cambios de contexto contra 1.415 de nginx, y 57.887 llamadas a futex, 18.303 con error, donde nginx no hizo ninguna. Que el sobrecosto venga del modelo de hilos con estado compartido es una hipótesis razonable; que estas dos señales por sí solas expliquen cuánto de la brecha de ciclos es algo que este perfil no prueba.
El flamegraph lo confirma en grueso: más de la mitad de las muestras (un 55 %) tocan el kernel de red del bridge de Docker (netfilter, conntrack, veth), que nginx atraviesa igual por construcción; el 44 % restante no trae pila, así que el reparto fino del resto queda abierto.
El default que cuesta un núcleo
Un tropiezo que vale contar: el default de hilos de Pingora es uno. ServerConf::default() trae threads: 1 y cada servicio lo hereda si no se toca. El flag existe y está en la guía de configuración, pero el valor por defecto no aparece en el inicio rápido, y con un hilo el proxy usa un solo núcleo por más que la máquina tenga dieciséis. La primera vez que medí sobre el host sin configurarlo, Pingora daba 46 mil; con los hilos igualados a los núcleos, 206 mil. Un 4,5x que no era del framework sino de una línea de configuración que nadie te obliga a escribir.
Pero cuidado con pasarse: en un pod con cuota de CPU, poner más hilos que núcleos es contraproducente.
No es tokio: es el CFS. Cuatro hilos queman los 200 ms de cuota en cincuenta y esperan el resto del período de cien. La regla en un pod: hilos = cuota de CPU.
Con dos vCPU de cuota, subir de dos a cuatro hilos apenas mueve el throughput (un 6 %) y el percentil 99 salta de 1,7 a 35 milisegundos; a ocho hilos, a 68, y ahí el throughput encima cae un 21 %. No es tokio: es el planificador CFS del kernel. Con dos hilos el contador de throttling se quedó en cero de 120 períodos; con cuatro, throttleó 119 de 120; con ocho, los 120: los hilos de más queman la cuota de CPU antes de que termine el período y esperan, y esa espera es la cola de latencia. La regla para este tipo de carga: hilos igual a la cuota de CPU, ni uno más.
Opinión honesta
El titular fácil, “nginx es seis veces más rápido que Pingora”, era falso, y lo escribí yo sin querer. La lección que me llevo no habla de Pingora. Habla de que un microbenchmark castiga sin piedad cualquier torpeza del que lo escribe, y que un getaddrinfo escondido en el camino caliente se disfraza de “el framework es lento”.
Con el proxy bien escrito, Pingora 0.9.0 en un pod de 2 vCPU gasta un 70% más de ciclos por petición que nginx en esta corrida de perf. No es poco, y para un borde donde cada núcleo se paga, importa. Pero Pingora no compite por ciclos por petición: compite por lo que se puede programar encima (lógica de enrutamiento, autenticación, reescritura) en Rust y con seguridad de memoria, en vez de módulos de C o Lua. Mi proxy mínimo no ejerce nada de eso; mide el piso, no el techo.
Nota de reproducibilidad (agregada el 23 de septiembre)
Un audit externo de todo el sitio encontró que los datos de esta sección citaban lab/2026-09-10-pingora/, un directorio de trabajo que ya no existe (los lab/ de este repo son efímeros a propósito). Los números eran internamente consistentes entre sí, pero no había manera de reproducirlos.
Volví a correr la medición completa el mismo día del audit, en el mismo host, con las mismas versiones. El resultado real esta vez fue 1,70x, no 1,49x, y el patrón de errores a 400 y 1000 conexiones tampoco fue idéntico al original (los números de arriba ya están actualizados con esta medición). Es un resultado distinto, de una fecha distinta — no la misma corrida disfrazada de nueva. Los datos crudos (JSON de oha, salidas de perf y strace, el código y las configs usadas) quedaron esta vez en el propio repositorio del sitio, no en un directorio de trabajo temporal, específicamente para que esto no vuelva a pasar.
Cuándo sí y cuándo no
- nginx si lo que necesitas es un proxy o balanceador con reglas de configuración y cada núcleo cuenta: es más barato por petición y su cola de latencia es más plana bajo presión.
- Pingora si vas a programar el borde (lógica que en nginx terminaría en Lua o en un módulo de C) y quieres hacerlo en Rust. El costo de CPU es real; si vale la pena depende de cuánto programes encima.
- En cualquier caso, en un pod: saca la resolución de nombres del camino caliente (por IP, o cacheada respetando el TTL), y pon los hilos iguales a la cuota de CPU. Esas dos líneas valen más que la elección de framework.
Fuentes
- Pingora 0.9.0, notas de versión y etiqueta, 4 de septiembre de 2026.
HttpPeer::newyto_socket_addrs()en el camino caliente:pingora-core0.9.0,src/upstreams/peer.rs.ServerConf::default()conthreads: 1, y el flag documentado en la guía de configuración.- Throttling del CFS por cuota de CPU, documentación del kernel de Linux.
BOLETÍN · MEDIDO
Una medición nueva, cuando la hay.
Cada post con sus números, sus fallos y los scripts para repetirlo. Entre uno y tres correos al mes. Te das de baja con un clic.
LO ÚLTIMO QUE LLEGÓ
- Tirón de la red cada minutopico ×42,6 menor al azar
- SMS de la muerteAndroid 12 a 17
- Unlimited-OCR 2 en 16 GBempate con Chandra 2, sin memoria a las 80 páginas
Uso Listmonk en mi propio servidor; tu correo no sale de ahí. Confirmas por correo antes de recibir nada.
Comentarios
Todavía no hay comentarios. El primero es tuyo.