Load Lab
Los dos servicios alojados bajo seis formas de carga, llevado como se lleva un encargo real. Objetivos primero, el proceso medido junto al cliente, un registro de hallazgos con re-pruebas, y los números versionados en el repositorio.
- k6
- .NET 10
- ASP.NET Core
- GitHub Actions
Objetivos antes que números
El plan se escribió antes de la primera rampa. Fija objetivos de nivel de servicio para cada servicio: un presupuesto de errores, una meta de latencia para peticiones aceptadas, qué aspecto puede tener el descarte de carga, qué tiene que recuperarse y cuán rápido, y qué debería poder mostrar una pasada de veinte minutos. Fija también un modelo de carga, con cada número marcado como suposición para que se pueda cuestionar.
No hay tráfico de producción del que derivar ese modelo, porque un portfolio recibe un visitante cada vez. El modelo es una meta de diseño: una publicación que llama la atención, cien visitantes en una hora, cinco minutos cada uno. La ley de Little da unas ocho personas en el sitio a la vez. El perfil de carga esperada ejecuta veinte, así que los objetivos se cumplen con margen. Junto a los editores, una tasa fija de espectadores abre un enlace compartido y solo lee, que es el tipo de tráfico que sigue llegando sin importar lo ocupado que ya esté el servicio.
Seis formas (smoke, load, stress, spike, soak, breakpoint) están definidas
en un solo archivo, así que “stress” significa la misma escalera para
cualquiera de los dos servicios. Los scripts son recorridos, no endpoints
sueltos. Un editor obtiene una sesión, crea un flujo, lo guarda contra su
versión, lo ejecuta y sigue la ejecución hasta el final, pausando entre pasos
como hace una persona. Cada pasada que importaba tuvo además dotnet-counters
en el proceso mientras k6 estaba en el socket. Un p95 plano puede significar
que el servicio tiene holgura, o que está cerca de una recolección de basura
que cambiaría el cuadro. Solo el proceso puede distinguir los dos casos.
Qué cuenta como fallo
Los dos servicios rechazan peticiones a propósito cuando están ocupados. Flow Studio tiene seis huecos de ejecución; el SQL lab ejecuta cuatro consultas a la vez y encola ocho más. Pasados esos límites la respuesta es un 429 inmediato, en vez de una petición esperando con un spinner.
Así que lo primero que el lab tiene que hacer bien es cómo cuenta. Un 429 es el servicio funcionando como se diseñó y se registra como rechazada. Un error es un 5xx, un timeout, un cuerpo que no parsea o una ejecución que nunca termina, y a esa tasa se aplican los umbrales. Una prueba de carga que tratara el descarte de carga como rotura suspendería al servicio por hacer lo que fue construido para hacer bajo carga. Una prueba que ignorara los 429 se perdería el punto en que el servicio empezó a rechazar.
El bug encontrado antes de cualquier carga
El perfil smoke corre primero, con dos usuarios. Su primera pasada contra Flow Studio reportó todas las ejecuciones como nunca terminadas.
Las ejecuciones estaban terminando. El endpoint de polling que informa su
progreso respondía 500 en el SDK de .NET 10: System.Text.Json ahora rechaza
una propiedad cuyo nombre coincide con el discriminador polimórfico, y el
[JsonIgnore] de la clase base no se lee en las sobrescrituras. El endpoint
de streaming tenía el mismo defecto y, como ya había enviado las cabeceras,
solo podía cerrar la conexión. La prueba existente habría reventado con un
stream vacío en vez de informar la causa. Ahora la informa.
El arreglo son cinco atributos y un comentario. La parte útil es que una prueba de carga con una etapa de smoke delante también comprueba la corrección del camino que va a recibir carga, y esta se pagó sola antes de que empezara la carga.
La escalera
Veinte, cuarenta, sesenta y ochenta editores, un minuto cada uno, con diez espectadores por segundo todo el tiempo. La pasada se corta en los límites de los escalones en vez de promediarse, porque la pregunta de una prueba de stress es qué cambió entre los escalones:
| editores | req/s | p95 | p99 | ejecuciones intentadas | aceptadas | aceptadas/s | rechazadas |
|---|---|---|---|---|---|---|---|
| 20 | 50 | 1,5ms | 2,2ms | 240 | 240 | 4,0 | 0% |
| 40 | 82 | 1,6ms | 2,5ms | 511 | 356 | 5,9 | 30% |
| 60 | 108 | 1,8ms | 2,9ms | 808 | 370 | 6,2 | 54% |
| 80 | 133 | 1,9ms | 2,9ms | 1.103 | 378 | 6,3 | 66% |
La columna de aceptadas es el resultado. Seis huecos, cada uno ocupado alrededor del segundo que tarda una ejecución cadenciada, dan un techo de unas seis ejecuciones por segundo. La escalera lo alcanzó entre cuarenta y sesenta editores y se quedó ahí. El p95 de la fila en la que dos tercios de las ejecuciones fueron rechazadas es el mismo que el de la fila en la que ninguna lo fue. El proceso, muestreado cada cinco segundos, usó menos del uno por ciento de la máquina en el escalón más alto. Bajo sobrecarga un servicio puede encolar, ralentizarse o rechazar, y este rechaza.
Lo que solo la carga mostró
El catálogo del SQL lab, GET /api/scenarios, es una lectura cacheada que
tarda unos dos milisegundos. Bajo el spike de ochenta visitantes su p95 fue
de 258ms, lo que suspendió el único objetivo escrito para valer sin
importar la carga.
El motivo era que el limitador global de concurrencia medía todos los
caminos menos /health, así que el catálogo esperaba en la misma cola que
las consultas de 800ms que lista. Una página que no puede cargar su lista de
escenarios porque otras personas están ejecutando escenarios no está
protegiendo nada útil. El tope ahora aplica solo a ejecuciones, y la nueva
pasada del mismo perfil lee 1,9ms.
El entorno varió más que el código
Tres pasadas de ese spike dieron resultados distintos. El p95 del lado lento fue 1,04s, 1,41s y luego 2,17s, y la segunda y la tercera usaron el mismo código. El tiempo de consulta que la API mide alrededor de su propia llamada creció al mismo paso que el viaje de ida y vuelta, lo que apunta debajo de la API: el contenedor de Postgres en la VM de Docker Desktop, sus 128MB de shared buffers contra un dataset de 273MB, y el portátil que toda la pila comparte con el generador de carga.
Ese cronómetro incluye el driver y el salto al contenedor, así que por sí solo no podía absolver al proceso. El script ahora registra el tiempo de ejecución de la propia base de datos en cada pasada, y un cuarto spike corrió con contadores en el proceso y estadísticas del contenedor. Los dos relojes coincidieron hasta la décima de milisegundo, el proceso de la API se quedó en 1,2% de CPU, y el contenedor de Postgres llegó a siete núcleos. La varianza vino de cuánto del portátil consiguió la base de datos. La conclusión del informe es que las latencias absolutas del SQL lab desde esta máquina no son números para citar; las formas y el orden entre los lados sí.
Veinte minutos
Del lado del cliente el soak no mostró ningún cambio: p95 de 1,8ms del primer minuto al último, nada rechazado, nada fallido. El proceso mostró dos cambios. El working set creció de 39 a 96MB a lo largo de todas las ventanas de cinco minutos. Eso no es una fuga. El registro de ejecuciones guarda cada ejecución terminada durante quince minutos y barre cada diez, así que 4.973 ejecuciones quedaron retenidas a unos 12KB cada una, y un soak de veinte minutos sobre una retención que puede llegar a veinticinco solo ve la subida. Lo que salió de ahí fue la cota: tasa de ejecuciones por retención por 12KB, unos 115MB sobre la base en el techo de seis huecos.
El segundo cambio fue la contención de locks, de 45 a 743 por ventana bajo carga constante. Cada inicio de ejecución contaba las ejecuciones en curso enumerando los valores de un diccionario concurrente, lo que toma todos sus locks internos, y el diccionario tenía cinco mil entradas más al final que al principio. Ahora es un contador, incrementado al empezar y decrementado al terminar, y un segundo soak confirmó que el crecimiento desapareció.
Contra producción
El bucket por visitante (doce ejecuciones por minuto, cuatro de vuelta cada
veinte segundos) es el único control entre un endpoint público que ejecuta
SQL y cualquiera con un for, así que se comprueba como aritmética y no como
tiempo: treinta y seis admitidas en dos minutos, Retry-After en cada
rechazo. En local se admitieron 35 de 235 peticiones, con la cabecera en los
200 rechazos. Contra el servicio desplegado en Fly.io, 35 de 197, con la
cabecera en los 162. La configuración que llegó a producción es la que se
probó.
El informe, el plan, el JSON detrás de cada tabla y las herramientas que los produjeron están en el repositorio. Cada pasada tuvo k6 y el servicio en el mismo portátil, así que el generador compitió con lo que estaba midiendo. Las latencias absolutas pertenecen a esa máquina; las formas pertenecen al servicio. La puerta de CI ejecuta smoke y load en cada cambio, comprueba el bucket exactamente, deja los umbrales de latencia un orden de magnitud holgados y compara con la pasada anterior en main, suspendiendo solo cuando un p95 se duplica, porque una puerta que suspende por ruido acaba ignorada.
Una suite de pruebas y sus resultados versionados, no un servicio alojado. Los dos servicios que mide informan su propia salud en sus propias páginas.