- One Million Checkboxes, lanzado el 26 de junio de 2024, era un sitio donde todos manipulaban en tiempo real el mismo millón de casillas de verificación; antes de cerrar dos semanas después, procesó más de 650 millones de checks
- El estado en sí era de apenas 1 millón de bits, es decir, 125 KB, pero la arquitectura inicial con nginx, Flask/gunicorn y Redis pubsub llegó rápidamente a su límite ante un tráfico inesperado
- Cuando llegaron decenas de miles de personas desde Hacker News, Reddit, Mastodon y Twitter, aparecieron en cadena problemas como agotamiento de conexiones de Redis, explosión de ancho de banda, falta de validación de entrada y aplicación de actualizaciones obsoletas
- La respuesta se concentró en medidas que podían aplicarse rápido: ampliar servidores y Redis, procesar actualizaciones por lotes, reducir el formato de transmisión, imponer un límite de ancho de banda de 250 Mbit/s con
tcde Linux y scripts para reiniciar procesos - Luego se estabilizó migrando el backend a Go y, al final, la lógica de congelamiento de casillas se manejó de forma atómica con scripts Lua en Redis; el sitio terminó el 11 de julio de 2024 a las 4:35 PM, hora del Este
El sitio y el diseño inicial
- One Million Checkboxes (OMCB) fue un sitio web lanzado el 26 de junio de 2024 que ofrecía 1 millón de casillas de verificación globales
- Cuando una persona activaba o desactivaba una casilla, el cambio se reflejaba de inmediato en la pantalla de todos los usuarios
- Su creación tomó 2 días y se esperaba que lo usaran, como mucho, unos pocos cientos de personas
- La respuesta real fue mucho mayor de lo esperado
- En pocas horas desde el lanzamiento, entraron decenas de miles de personas y manipularon millones de casillas
- El tráfico llegó desde Hacker News, /r/InternetIsBeautiful, Mastodon y Twitter
- Unos días después también apareció en Washington Post y New York Times
- Parte de los logs del inicio del primer día no se conservan
- Al principio solo se guardaban los 1 millón de registros más recientes de cada día
- A partir del segundo día empezó la estabilización, y ese día se hicieron más de 50 millones de checks
- Antes del cierre del sitio, la cantidad acumulada de checks superó los 650 millones
Arquitectura original centrada en Redis
- El estado de las casillas se representaba con 1 millón de bits
- Las casillas marcadas eran
1y las no marcadas eran0 - El tamaño total del estado era de 125 KB
- El cliente almacenaba el bitset y lo consultaba al renderizar
- Las casillas marcadas eran
- El cliente estaba construido para evitar carga en el DOM
- No insertaba los 1 millón de elementos completos en el DOM
- Usaba react-window para renderizar solo las casillas visibles en la pantalla y un pequeño búfer
- La configuración del servidor era simple y pensada para una expansión horizontal
- nginx servía el contenido estático y reenviaba las solicitudes de API y las conexiones websocket a servidores Flask
- Los servidores Flask eran dos instancias ejecutadas con gunicorn
- Redis se encargaba de almacenar el estado de las casillas y de funcionar como cola de mensajes
- El uso de Redis también era directo
- Se modificaba el estado de cada casilla con las primitivas de manipulación de bits de Redis
- Cuando el cliente enviaba un evento de check, Flask invertía el bit en Redis y registraba el evento en pubsub
- Los dos servidores Flask leían pubsub y notificaban los cambios a los clientes conectados a cada uno
- Las snapshots del estado completo eran un mecanismo para corregir actualizaciones perdidas
- Servían para sincronizar clientes que se habían perdido actualizaciones porque la pestaña estaba en segundo plano
- La implementación inicial enviaba el estado completo cada 30 segundos
Principios de escalamiento
- Los costos debían tener un límite calculable
- Se evitó un esquema de escalamiento automático ilimitado que disparara los costos
- Si llegaba una carga mayor a la esperada, se eligió permitir que el sistema se rompiera
- Se asumió que la popularidad duraría poco
- Se priorizaron respuestas que pudieran construirse en horas antes que soluciones más completas que tomaran días o semanas
- Se aceptó la deuda técnica generada en el proceso
- Se prefirieron elecciones tecnológicas simples y operables de forma directa
- Se eligió una configuración donde fuera posible conectarse directamente al servidor, ejecutar comandos y depurar
- Se usaron principalmente dependencias que pudieran operarse y depurarse internamente
- La experiencia central del sitio era la sincronización global
- Debía ser posible ver cambios inmediatos sin importar hacia dónde se navegara
- No se escaló enviando solo las casillas que cada usuario estaba viendo
Primer día: más servidores y cuello de botella en Redis
- En los primeros 30 minutos desde el lanzamiento, la carga se disparó; el sitio seguía vivo, pero estaba en un estado difícil de sostener por mucho tiempo
- La mejora más clara era sumar servidores
- nginx podía hacer reverse proxy fácilmente hacia instancias Flask en otras VM, y el estado ya estaba en Redis
- El segundo servidor se agregó cerca de las 12:30 PM y de inmediato llegó al 100% de carga
- Al principio se esperaba que agregar uno o dos servidores fuera suficiente
- En la práctica, el tráfico aumentaba tanto como la capacidad
- Llegó al primer puesto de Hacker News y la actividad en Twitter también se disparó
- Las conexiones entre los servidores Flask y Redis se convirtieron en un cuello de botella
- No había pool de conexiones para Redis y Redis estaba cerca de quedarse sin conexiones
- Se cambió a un esquema que enviaba las actualizaciones agrupadas en lotes
- No se consideró la compatibilidad con clientes existentes; se asumió que los usuarios recargarían la página
- También se agregó un pool de conexiones para Redis, pero no funcionó limpiamente con la combinación de gunicorn y Flask
- Aun así, parece que ayudó a reducir la cantidad de conexiones a Redis
- Después no se profundizó en este problema y se pasó a la migración a Go
- Se eliminó el rate limit de creación de sesiones
- El estado del rate limit estaba guardado en Redis, y las conexiones de Redis se estaban agotando
- El problema no era una explosión de sesiones nuevas, sino que una sola sesión enviara muchos datos
- A corto plazo era una medida riesgosa, pero se consideró aceptable
- La instancia de Redis también se subió a una especificación mayor
- Se estaba usando Redis administrado de Digital Ocean
- Se pasó de una instancia pequeña con 1 CPU compartida y 2 GB de RAM a una con 4 CPU dedicadas y 32 GB de RAM
- El redimensionamiento tomó unos 30 minutos
Problemas de ancho de banda y reducción del volumen transmitido
- Al inicio no se había considerado lo suficiente el costo de ancho de banda
- Digital Ocean cobra $0.01 por GB al superar el ancho de banda gratuito
- Había 1 TB de ancho de banda gratuito por trabajos anteriores, y se pensó que OMCB no tendría un gran impacto
- Las snapshots del estado completo podían consumir ancho de banda muy rápido
- 1 millón de bits equivale a 1 Mbit
- Enviarlo cada 30 segundos a 1,000 personas equivale a unos 2 GB por minuto, o 120 GB por hora
- Esta cifra no incluye las actualizaciones incrementales
- La verificación del ancho de banda y la definición de un límite de costos se manejaron en la máquina de nginx
- Se revisaban los bytes enviados con
ip -s link show dev eth0 - Como se usaba un único reverse proxy nginx, era fácil inferir el origen del ancho de banda
- Se revisaban los bytes enviados con
- La reducción del volumen transmitido se abordó en dos frentes
- Se redujo la frecuencia de las snapshots del estado completo
- Se redujo el formato de las actualizaciones incrementales
- El formato de actualizaciones por lote se comprimió mucho
- El formato anterior era una lista de diccionarios como
{ "index": 123, "value": true } - El formato final era un par de arreglos con índices true e índices false, como
[[123, 125], [124]] - Este formato era 5 veces más corto que la implementación original
- El formato anterior era una lista de diccionarios como
- Se aplicó un hard cap con
tcde Linux para evitar que los costos se dispararan- El tráfico de la interfaz pública
eth0se limitó a 250 Mbit/s - Eso equivale a unos 2 GB por minuto, o poco menos de 3 TB por día
- Con una tarifa de $0.01 por GB, esto evitaba que los costos crecieran de forma incontrolable durante la noche
- El tráfico de la interfaz pública
Segundo día: falta de validación de entrada y réplica de Redis
- A la mañana siguiente, el sitio estaba caído, y la causa fue la falta de validación de entrada
- No se bloqueaban casillas con índices superiores a 1 millón
- Alguien manipuló casillas con índices de cientos de millones
- Esto hizo que pareciera que la cantidad de casillas marcadas había llegado a 1 millón y que el sitio interpretara que había terminado
- Los datos en Redis también crecieron innecesariamente
- Se agregaron millones de ceros entre el bit número 1 millón y el bit número 100 millones
- Los datos enviados al cliente se volvieron 100 veces más grandes
- La recuperación se hizo rápidamente
- Se detuvo nginx
- Se copiaron solo los primeros 1 millón de bits del bitset existente a un bitset nuevo
- El bitset anterior se conservó para depuración
- Se cambió el código para que apuntara al bitset nuevo y se agregó validación de entrada
- La carga inicial de la página también se volvió lenta
- Redis estaba bajo mucha carga y, por un bug del pool de conexiones, también se estaban creando demasiadas conexiones
- En lugar de depurar el problema del pool de conexiones, se agregó una réplica de Redis para distribuir la carga y las conexiones del primary
- La IP privada de la réplica tuvo que encontrarse manualmente
- Según la documentación de Digital Ocean, el prefijo
replica-funcionaba en el DNS público, pero no en el DNS privado - Usar la IP pública implicaba riesgo de pasar por internet público y generar cobros de ancho de banda
- Se intentó conectar a direcciones cercanas a las IP privadas del primary y de los otros servidores, y en el tercer o cuarto intento se encontró la IP privada de la réplica
- Luego esa IP se hardcodeó
- Según la documentación de Digital Ocean, el prefijo
Reinicio de procesos y corrección de stale updates
- Los procesos Flask seguían crasheando, y la causa parecía ser la falta de conexiones a Redis
- En vez de depurar en detalle, se creó un script bash que revisaba la cantidad de procesos Flask en ejecución
- Si había menos de 3 procesos ejecutándose, reiniciaba la unidad de systemd
- El script se agregó al crontab
- También se ajustó la configuración de nginx
- Se cambió para que los servidores caídos se excluyeran temporalmente de la rotación
- Después de ese cambio, el sitio se estabilizó
- Había un bug de stale updates en la sincronización del estado del cliente
- El cliente recibía tanto actualizaciones incrementales como snapshots del estado completo
- Como esas dos actualizaciones no tenían timestamp, podía aplicarse una actualización incremental vieja después de recibir una snapshot nueva
- Como resultado, se podía ver un estado completamente incorrecto hasta la siguiente snapshot del estado completo
- Se agregó una mitigación basada en timestamps
- Se añadió un timestamp a las snapshots del estado completo
- También se añadió un timestamp a cada update registrada en Redis pubsub
- El batch enviado al cliente incluía el timestamp máximo de las actualizaciones incrementales incluidas
- El cliente pasó a descartar batches más antiguos que la última snapshot del estado completo
- Esta solución no era perfecta
- Si dentro de un batch había al menos una update nueva, podían aplicarse la mayoría de updates aunque fueran viejas
- Aun así, era una gran mejora respecto de lo anterior
Reescritura en Go y estabilización
- A la mañana siguiente, el sitio seguía vivo, y luego la atención se centró en reescribir el backend
- También había llegado un correo de Washington Post
- En paralelo se pensó un plan para cerrar el sitio
- El plan de cierre consistía en que las casillas se congelaran si no se desmarcaban rápido
- Este cambio podía provocar un pico de actividad y más trabajo de servidores
- No había certeza de que la arquitectura existente basada en Flask pudiera soportarlo
- Junto con su amigo Eliot, el backend se reescribió en Go
- Desde el domingo a las 2 PM hasta las 2 AM discutieron la implementación y portaron todo el backend
- Se migró sin cambiar demasiado la estructura
- Algunas partes fueron obstáculos, como encontrar una librería
socketiopara Go que soportara el protocolo más reciente
- La mejora de rendimiento fue enorme
- Escalaba tan bien que los bots podían inyectar tráfico excesivo
- Se volvió necesario un mejor rate limit
- El domingo por la noche también hubo un DDoS
- Se respondió poniendo el sitio detrás de Cloudflare y ajustando un poco la configuración de nginx
Lógica de cierre del sitio
- Después de la reescritura en Go, el sitio funcionó de manera estable
- Durante la semana siguiente se respondieron entrevistas y atención del público
- Luego comenzó el trabajo de cierre del sitio
- El método de cierre fue congelar casillas
- Si una casilla marcada no se desmarcaba rápidamente, pasaba a estado frozen
- Con el tiempo, todo el sitio quedaría completamente frozen
- Se agregó estado adicional en Redis
- Se añadió una hashtable para almacenar la hora en que cada casilla fue marcada por última vez
- Era un estado grande para pasarlo al cliente, pero razonable para guardarlo en Redis
- También se almacenó el valor
time_to_freeze
- Al momento de hacer un uncheck se decidía si correspondía congelar
- Si
now - last_checked > time_to_freeze, no se hacía el uncheck - En su lugar, se actualizaba
frozen_bitsetpara marcar esa casilla como frozen frozen_bitsetse distribuía al cliente de la misma forma que el estado checked- El cliente deshabilitaba las casillas cuyo bit frozen estuviera activado
- Si
- Se agregó una tarea separada para que también se congelaran aunque nadie hiciera uncheck
- Periódicamente buscaba bits que debían estar congelados pero aún no estaban marcados como tales, y los pasaba a estado frozen
- La lógica correspondiente se puso en un script Lua de Redis para ejecutarla de forma atómica
- Así era más fácil evitar race conditions
- El cambio de cierre se aplicó 2 semanas y 1 día después del lanzamiento
- El 11 de julio de 2024 a las 4:35 PM, hora del Este, se marcó la casilla 491915 y el sitio llegó a su fin
Costos y aprendizajes
- El costo de operar el sitio fue de unos $850
- Las donaciones se acercaron bastante a cubrir ese costo
- Se concluyó que no fue una gran pérdida
- La elección de Redis y nginx resultó satisfactoria
- Redis y nginx fueron evaluadas como tecnologías muy útiles
- Operarlas directamente facilitó la depuración y las correcciones
- Sin embargo, fue algo incómodo no tener control completo sobre la instancia administrada de Redis
- La decisión de no diseñar desde el inicio un escalamiento masivo y prolongado se evaluó de forma positiva
- Se considera difícil predecir qué funcionará bien en internet
- Si se hubieran dedicado semanas a pensar en la escala desde el principio, quizá el sitio nunca se habría lanzado
- La llegada de muchos usuarios ayudó a definir la motivación de mantenimiento y las prioridades
- También se confirmó que existe demanda por interacciones anónimas limitadas
- La gente mostró interés en sitios donde se interactúa con desconocidos de una manera acotada
- Aumentó la confianza para seguir creando este tipo de sitios
1 comentarios
Opiniones en Hacker News
Fue un artículo con mucho por aprender, junto con conocimiento histórico sobre sistemas distribuidos.
Parece que experimentó casi todo tipo de interrupciones y puntos de falla, salvo el almacenamiento, y fue bueno poder ver el proceso de solución.
No sabía que Redis soportaba Lua, y al ver esto me dieron ganas de probarlo como almacén de estado alternativo.
El ancho de banda es una de mis mayores quejas con los servicios en la nube, porque no tienen un límite estricto para evitar excederse en la facturación.
Pero ninguno de los dos fue un gran problema, y fue bastante curioso que en este proyecto el almacenamiento no fuera un problema relevante. Para mí fue una experiencia nueva.
El ancho de banda sí fue un verdadero dolor de cabeza. Durante unos dos días estuve en tensión, revisando los bytes enviados por la NIC y recalculando, y daba miedo no tener un límite duro. Y eso que Digital Ocean tiene precios bastante razonables.
No he usado servicios serverless populares, pero tengo entendido que ahí el costo del ancho de banda puede pegar bastante fuerte.
Y Lua dentro de Redis es realmente potente: si puedes aceptar una pequeña pérdida de rendimiento, te permite saltarte muchos problemas difíciles y llenos de condiciones de carrera, y fue muy agradable trabajar con eso.
Fue un excelente artículo, y el sitio web también merece felicitaciones.
Pero, personalmente, creo que este artículo escrito es lo que más debería enorgullecer al autor.
Creo que el punto clave es la parte de “haber creado el sitio en dos días casi sin preocuparse por la escalabilidad fue una buena decisión”.
Es algo que deberían aprender especialmente los ingenieros al inicio de su carrera. La escalabilidad no es un problema hasta que lo es.
Y cuando llega a serlo, en realidad es un buen problema, y no suele ser tan difícil de arreglar como uno piensa.
He visto muchos sistemas donde los microservicios se volvieron la “opción obvia” no por necesidad de escalar o separar equipos, sino porque a los desarrolladores simplemente les daban ganas de hacerlo así.
Escalar esos sistemas es una verdadera tortura.
Artículo relacionado reciente: One Million Checkboxes - https://news.ycombinator.com/item?id=40800869 - junio de 2024, 305 comentarios
Este tipo de proyectos son divertidos.
Hace unos 6 años lancé Pixmap en Android, una pequeña app colaborativa de edición de píxeles que soportaba cuadrículas más grandes, como 1024x1024.
Tenía una cola que aplicaba cada evento a una imagen PNG; al conectarse, el cliente cargaba el PNG inicial y luego cada evento de dibujo de píxel solo recibía un objeto pequeño.
Así se puede aprovechar la compresión de imágenes en la carga inicial, y luego los conjuntos de cambios son muy pequeños. Además, como todos los eventos quedan guardados en un log, también se puede “rebobinar” la imagen [0].
[0] 22mb: https://blog.winricklabs.com/images/pixmap-rewind-demo.gif
Así que estoy jugando con un canvas direccionable mediante llamadas a una API.
https://x.com/RussTheMagic/status/1816749136487588311
Buen artículo. Me da curiosidad saber cuánto terminó costando.
El costo total fue de unos 850 dólares, y las donaciones casi lo cubrieron.
Cometí el error de no bajar correctamente la infraestructura después de migrar a Go, y también podría haber eliminado la segunda réplica de Redis que levanté. Si me hubiera enfocado en el costo, creo que podría haberlo reducido a la mitad.
Pero las donaciones estaban cubriendo casi todo el costo, y había demasiadas otras cosas pasando, así que no me enfoqué mucho en eso.
Incluso después de cerrar el sitio mantuve la infraestructura durante un tiempo para preparar gráficos y demás, así que se fue un poco más de dinero; ahora estoy ligeramente en números rojos, pero no es algo grande.
Como alguien que está aprendiendo backend, me pregunto si existe una arquitectura alternativa más simple para este proyecto.
Me gustaría que hubiera una forma más fácil de alojar el estado de 1 millón de bits y sincronizarlo con los clientes. Algunas de las soluciones del artículo me costaron entenderlas.
Los proyectos del autor son excelentes.
Quería explicar con más detalle las tecnologías que usé, pero el artículo ya era muy largo y sentí que era difícil agregar más.
Si tienes preguntas, con gusto las respondo.
Sinceramente, no sé bien cómo simplificar mucho más la arquitectura. Seguramente hay servicios que podrían usarse para esto, pero lo veo más como pasarle la complejidad a alguien más.
Al final, lo que necesitas es una base de datos que rastree las casillas marcadas, una forma de meter los datos en la base de datos, una forma de informar al cliente el estado actual, una forma de que el cliente avise al servidor cuando marca una casilla y se actualice el estado, una forma de avisar al cliente cuando una casilla se marca o desmarca, y una forma de no renderizar siempre 1 millón de elementos DOM.
Aquí usé Redis para guardar el estado de marcado; por simplicidad guardé directamente 1 millón de bits y envié el millón de bits completo al cliente. Estuvo bien porque los datos no eran tan grandes.
Con Flask y WebSocket manejé los eventos de marcado y las actualizaciones; envié tanto actualizaciones de casillas individuales como actualizaciones completas del millón de casillas, y usé react-window para evitar el problema de renderizado.
El resto, contenido estático con nginx y reverse proxy, fue principalmente para facilitar el escalado; se podría implementar sin esos detalles y el sitio funcionaría, aunque no soportaría la misma carga.
En vez de una base de datos, guardas el conjunto de bits en un archivo y lo mapeas con mmap. En vez de un reverse proxy, la aplicación podría manejar directamente las solicitudes HTTP y las conexiones WebSocket.
Son unos cuantos servidores web con una caché y una cola de publicación/suscripción detrás.
También se podría haber manejado todo en memoria en un host grande, pero si no alcanzaba para la demanda o fallaba por cualquier motivo, quedabas completamente bloqueado.
salvo algo no escalable como tener una lista global de 1 millón de booleanos dentro del mismo proceso de la API backend.
(checked, start_x, start_y, end_x, end_y). ¿No es una forma bastante obvia?Genial.
Me pregunto si el próximo artículo será un análisis estadístico de qué checkboxes fueron marcados menos o más veces.
Recuerdo que me dio un poco de tristeza cuando hice scroll bastante hacia abajo, elegí una casilla y la desmarcaron casi de inmediato.
Antes de eso, tengo una historia más que contar sobre el sitio.
Me pregunto si el juego sigue vivo.
Si entro a https://onemillioncheckboxes.com/, no aparece nada marcado, y en la consola de JS solo veo esto:
{"total":0,"totalGold":0,"totalRed":0,"totalGreen":0,"totalPurple":0,"totalOrange":0,"recentlyChecked":false}Como ejemplo totalmente opuesto a una implementación escalable, existe una implementación de 1 millón de checkboxes en menos de 1000 caracteres. Es una versión en Deno.
https://gist.github.com/jeff-hykin/4cdebafd8698298d021f103e2...