12°
Portada del artículo: Medí Pingora 0.9.0 seis veces más lento que nginx. El culpable era una línea de mi código
RustBenchmarksRedesSistemas

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,49x, y perf la cuantifica: 1,49x en ciclos por petición, 111,7 mil contra 74,9 mil, sin decir dónde se gastan.

Efrain Garay 10 de septiembre de 2026

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,49x a carga moderada, y perf la cuantifica: 1,49x en ciclos por petición, 111,7 mil contra 74,9 mil de nginx.

En 38 segundos: el 6x que era mi bug, la línea de getaddrinfo que lo causaba, y lo que mide Pingora de verdad en un pod de 2 vCPU. Todos los números son los medidos. Sin sonido por defecto: actívalo en los controles.Verlo en el visualizador de reels →

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:alpine devolviendo 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 128 hacia el upstream.
  • Carga: oha 1.16 en su propio contenedor, golpeando por nombre de servicio sobre la red de Docker. La ruta medida no toca el host.
El banco de pruebas: cuatro contenedores, límites de podRed de Docker. La ruta medida no toca el host. Los dos proxies con la misma cuota.
red de Docker · bench-netloadgenoha 1.16cpus 4pingora 0.9.0proxy mínimocpus 2 · 256 MBnginxworker×2cpus 2 · 256 MBbackendnginx:alpine · 1 KB

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.

El camino de una petición, en bucleCambia el interruptor: el hilo del worker se bloquea, o no.

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.

Peticiones por segundo en un pod de 2 vCPUoha, mediana de 3 corridas keepalive. Backend aislado, respuesta de 1000 bytes.
100 conexiones
pingora89.425p99 1,69msrango 89.157–89.429
nginx133.129p99 1,01msrango 133.109–135.034

Carga moderada: nginx 1,49x, y ninguno pasa de 1,56 de los 2 núcleos.

400 conexiones
pingora7.492p99 310,7msrango 1.389–39.195
nginx138.770p99 3,77msrango 136.823–139.074

Pingora devolvió 502 en 2 de 3 corridas (7,5k y 1,4k); no aislé la causa. nginx sigue liso.

1000 conexiones
pingora1.889p99 1506,9msrango 1.143–35.007
nginx3.009p99 873,8msrango 1.703–57.484

Los dos colapsan: el generador se queda sin descriptores de archivo y ambos devuelven 502, no los proxies.

A c=100 la diferencia es puro costo de CPU por petición: ninguno satura los dos núcleos.

A cien conexiones, nginx entrega 1,49 veces más que Pingora, y los dos van sobrados: ninguno pasa de 1,56 núcleos de los dos disponibles, con percentiles 99 por debajo de dos milisegundos. A cuatrocientas, nginx sigue liso; Pingora, en cambio, en dos de tres corridas empezó a devolver errores 502 hacia el backend: una corrida quedó limpia en 39 mil y las otras dos casi todas 502, con una mediana de 7,5 mil. No aislé la causa. A mil conexiones los dos colapsan, pero eso ya es el generador de carga quedándose sin descriptores de archivo (no puertos efímeros) y devolviendo ambos 502: ese punto no mide nada de Pingora ni de nginx.

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.

Dónde se van los ciclos, medido con perf y strace100 conexiones keepalive, 2 vCPU. Ninguno satura la CPU: la diferencia es costo por petición.
instrucciones por petición
Pingora 0.9.0114.600
nginx67.600
ciclos por petición
Pingora 0.9.0111.700
nginx74.900
cambios de contexto / 10s
Pingora 0.9.035.719
nginx765
futex (llamadas / con error)
Pingora 0.9.044.102 / 13.896
nginx0

La causa medida: Pingora ejecuta 1,7x más instrucciones por petición (114k vs 67k); su mejor IPC (1,03 vs 0,90) amortigua eso a 1,49x en ciclos, el ratio del throughput. Los cambios de contexto y el futex existen pero suman menos del 1% de la brecha: atribuirla al modelo multihilo es hipótesis, no medición.

Pingora gasta 111,7 mil ciclos por petición; nginx, 74,9 mil. La razón es 1,49, 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,7 veces más instrucciones (114,6 mil contra 67,6 mil), y solo su mejor uso del procesador (1,03 instrucciones por ciclo contra 0,90) amortigua esa diferencia hasta el 1,49 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 35.719 cambios de contexto contra 765 de nginx, y 44 mil llamadas a futex, trece mil con error, donde nginx no hizo ninguna. Pero son demasiado pocas para explicar la brecha de ciclos: el sobrecosto está en otra parte. Que el sobrecosto venga del modelo de hilos con estado compartido es una hipótesis razonable, no algo que estos números prueben.

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.

Hilos por encima de la cuota: throughput plano, p99 en llamasPingora en el pod de 2 vCPU. Mismo upstream, misma carga, distinto número de hilos.
2 hilos89.783 req/sp99 1,7 ms0 períodos throttled
4 hilos94.995 req/sp99 34,7 ms119 períodos throttled
8 hilos70.568 req/sp99 68 ms120 períodos throttled

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 49% 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.

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

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.