- OpenRun es una plataforma de despliegue de apps web para herramientas internas que almacena archivos estáticos, código de la app y archivos de configuración en SQLite en lugar del sistema de archivos, gestionando el estado del despliegue con un enfoque centrado en la base de datos
- El objetivo principal es procesar como una sola transacción las actualizaciones de apps en las que varios archivos cambian juntos, para evitar que se sirvan páginas web rotas durante el cambio de versión
- Al usar el hash SHA256 previo a la compresión como clave primaria, reduce el almacenamiento duplicado de archivos entre versiones de una app y entre apps de staging, preview y production
- El enfoque de almacenamiento con SQLite simplifica los rollbacks, los backups, el almacenamiento de hashes para ETag y el almacenamiento con compresión Brotli; si hace falta, también puede manejar datos GZip y sin comprimir agregando columnas
- Actualmente funciona en un solo nodo y, cuando haya soporte multinodo, se planea reducir la latencia usando Postgres compartido junto con un caché local de archivos en SQLite
Cómo OpenRun almacena archivos
- OpenRun es una plataforma open source de despliegue para herramientas internas code-first, que despliega apps web en un solo nodo o en un clúster de Kubernetes con un enfoque GitOps
- En lugar de poner el contenido estático en el sistema de archivos como un servidor web típico, OpenRun almacena datos de la app, como archivos estáticos, código de la app y archivos de configuración, en SQLite
- Como los metadatos de la app se generan dinámicamente, almacenarlos en una base de datos resulta natural, y manejar también los archivos en la misma capa de almacenamiento facilita gestionar el estado del despliegue en conjunto
- Al crear y actualizar apps, los archivos se suben desde GitHub o desde el disco local a la base de datos SQLite
- Solo en modo de desarrollo se usa el sistema de archivos local
Por qué se eligió SQLite
- Las actualizaciones transaccionales son la mayor ventaja
- Varios cambios de archivos pueden agruparse y procesarse como una sola transacción
- Gracias al aislamiento, no se sirve una app web rota durante una actualización
- Si ocurre un error de despliegue, se puede hacer rollback a nivel de transacción de la base de datos
- Incluso cuando varias apps se actualizan al mismo tiempo, se puede revertir todo de una vez
- Es más simple que buscar y limpiar archivos modificados en el sistema de archivos
- OpenRun gestiona automáticamente versiones para todas las actualizaciones, y los datos de archivos se almacenan en una tabla con el siguiente esquema
CREATE TABLE files (sha text, compression_type text, content blob, create_time datetime, PRIMARY KEY(sha));
- Como se usa el hash SHA256 del contenido antes de la compresión como clave primaria, el mismo contenido de archivo se almacena una sola vez entre múltiples versiones
- Cada app de production tiene una app de staging y puede tener varias apps de preview, por lo que pueden generarse duplicados de archivos
- El almacenamiento basado en SQLite permite no almacenar de forma duplicada archivos con el mismo contenido incluso entre apps
Backups, caché y manejo de compresión
- El estado completo del sistema, los metadatos y los archivos pueden respaldarse con herramientas de backup para SQLite como Litestream
- Si se guarda una vez, al subir el archivo, el SHA del contenido necesario para el encabezado ETag usado por la caché del navegador, no hace falta recalcularlo después
- El contenido de los archivos se almacena en la tabla de SQLite en forma comprimida con Brotli
- Con el enfoque de base de datos, también se pueden almacenar datos comprimidos con GZip o datos sin comprimir agregando columnas a la tabla
files
Rendimiento y planes multinodo
- En OpenRun, el enfoque de base de datos SQLite ofrece buen rendimiento
- No se realizaron benchmarks comparativos directos porque no existe una implementación equivalente basada en el sistema de archivos
- Según los benchmarks del equipo de SQLite, en algunas cargas de trabajo SQLite puede ofrecer mejor rendimiento que usar directamente el sistema de archivos
- OpenRun actualmente se ejecuta en un solo nodo
- Cuando se agregue soporte multinodo en el futuro, se planea usar una base de datos Postgres compartida en lugar de SQLite local para almacenar metadatos y datos de archivos
- Este enfoque puede generar problemas de latencia
- Para evitar la latencia de acceso a Postgres, se planea usar una base de datos SQLite local como caché de archivos
Por qué el enfoque de sistema de archivos es más común
- Una de las razones por las que la mayoría de los servidores web usan el sistema de archivos es la comodidad
- Los archivos pueden copiarse y actualizarse con herramientas existentes del sistema de archivos como rsync y tar
- Otra razón es el contexto histórico
- El sistema de archivos se usaba desde antes de que existieran buenas bases de datos relacionales en proceso
- Para usar una base de datos como almacenamiento de archivos, se necesita una interfaz API para subir archivos, y no siempre es un enfoque viable
1 comentarios
Opiniones de Hacker News
Hace unos años experimenté con esta idea, inspirándome en parte en el artículo “35% Faster Than The Filesystem”: https://www.sqlite.org/fasterthanfs.html
Mis notas de entonces están aquí: https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sq...
Creé https://datasette.io/plugins/datasette-media, un plugin para servir archivos estáticos desde SQLite en Datasette, y funciona bien, pero, sinceramente, no lo he usado mucho desde que lo hice.
Un concepto relacionado es servir tiles de mapas desde SQLite, y https://datasette.io/plugins/datasette-tiles hace eso. Resulta que el formato MBTiles era una base de datos SQLite llena de PNG.
Si quieres experimentar con SQLite para servir archivos, la herramienta CLI “sqlite-utils insert-files” puede ser útil para la configuración inicial de la base de datos: https://sqlite-utils.datasette.io/en/stable/cli.html#inserti...
El hash del contenido solo hay que generarlo una vez al subir el archivo, sin tener que generarlo cada vez que se reinicia el servidor web ni incluir un paso de build que cambie los nombres reales de los archivos. También se puede aplicar dinámicamente a archivos del sistema de archivos (ver la implementación de embedFS en https://github.com/benbjohnson/hashfs), pero una base de datos lo hace un poco más fácil.
requests-cache, si no recuerdo mal, cachea las solicitudes en SQLite por
(date, URI): https://github.com/requests-cache/requests-cache/blob/main/r...Búsqueda de pyfilesystem SQLite: https://www.google.com/search?q=pyfilesystem+sqlite
Búsqueda de sendfile mmap SQLite: https://www.google.com/search?q=sendfile+mmap+sqlite
https://github.com/adamobeng/wddbfs es un “proveedor webdavfs que permite leer el contenido de una base de datos sqlite”.
También parece que podría haber una buena forma de implementar un sistema de archivos sobre SQLite, poniendo encima permisos de archivos Unix y permisos de atributos extendidos xattrs.
¿SQLite será más rápido o más cómodo que, por ejemplo, ngx_http_memcached_module.c? Me pregunto si SQLite también tiene ACL a nivel de celda.
Al leer archivos estáticos, en cada solicitud hay que abrir, leer y cerrar el archivo, así que hay más cambios de contexto aunque la capa del sistema de archivos haya cacheado el contenido. Si quieres hacer esto rápido, lo adecuado no es convertir todo en una base de datos, sino ponerle un frontend de caché. Será más rápido que SQLite y también más fácil de mantener y depurar.
Eso incluye sistemas de archivos que corren completamente en espacio de usuario. FUSE queda fuera, porque las llamadas pasan por el kernel.
Decir que las “actualizaciones transaccionales” son el beneficio principal tiene sus límites. Ya sea que el servidor use SQLite o el sistema de archivos, eso por sí solo no evita una webapp rota durante una actualización.
Cada página en el navegador es un árbol de recursos obtenidos mediante solicitudes HTTP separadas, así que no es objeto de un sistema de actualizaciones transaccionales/atómicas del lado del servidor. Aunque en el servidor todos los recursos se cambien dentro de una transacción, el navegador puede ver una combinación de recursos viejos y nuevos.
La solución habitual es que todos los subrecursos de la página (bundles de JavaScript, hojas de estilo, medios, etc.) tengan nombres (URL) que incluyan un hash de contenido o una versión. Si el documento HTML raíz carga la versión X, todos los subrecursos también deben cargar la versión X correspondiente.
Además, al actualizar de X a Y, después de empezar a servir la página Y hay que seguir ofreciendo los subrecursos de X durante un tiempo. Si no se mantienen hasta estar razonablemente seguro de que ya no hay navegadores cargando una página X, la página X puede romperse.
Por eso, si uno quiere poner el HTML raíz y los subrecursos en un único paquete que se reemplace atómicamente, en realidad no conviene. Porque se terminarían eliminando subrecursos anteriores que todavía pueden estar siendo referenciados.
Según el caso, quizá también quieras versionar algunos subrecursos, como archivos multimedia, por separado del documento HTML. Para actualizarlos sin invalidar toda la caché de elementos estructurales de la app, como bloques de JavaScript u hojas de estilo, el sistema de build de la página también puede tener que contemplarlo.
Cuando una empresa grande experimentó con esto (en esa época veíamos una parte considerable de la web), la mayoría de los usuarios (más del 80%) permanecía en la webapp unos 2 a 3 días. Es probable que hubiera sesgo por personas que dejaban pestañas abiertas durante el fin de semana.
El percentil 95 era de unas 2 semanas, y el 100% era de unos 600 días. Es decir, hubo un usuario que dejó una pestaña abierta durante casi 2 años.
Si apuntas al 100%, tienes que esperar bastante. Todas estas cifras dependen de mi memoria, y ya no trabajo en esa empresa.
El escenario en el que un usuario permanece mucho tiempo en una página y luego recibe un enlace roto se parece más a un problema de las SPA.
En general estoy de acuerdo, pero las actualizaciones transaccionales solo evitan una categoría de problemas relacionados con actualizaciones. Otros problemas a nivel de la app también pueden producir una experiencia rota.
Es posible seguir sirviendo versiones anteriores de contenido estático referenciado por hashes de contenido, pero actualmente no está implementado en Clace.
El truco clave es subir los cambios que no son HTML antes que los cambios de HTML, para no referenciar archivos antes de que existan. Si quieres complicar la app al máximo, puedes aplicar una búsqueda en profundidad a la carga. Pero si valoras tu salud mental, es mejor relajar el problema y hacer que la app suba primero los assets.
Cuando trabajaba en un pequeño estudio de juegos en 2011/2012, recomendé mover todos los assets de menos de 100 KB a una base de datos sqlite3, y crear “archivos pak” guardando los offsets de esos archivos dentro de la base de datos sqlite3.
Esta decisión estuvo influida por una charla post mortem de Richard Hipp, donde dijo que, en retrospectiva, habría preferido tratar los BLOB como inodes, ubicándolos en offsets más adelante dentro de la base de datos y anexando los BLOB al archivo.
La carga de assets era increíblemente rápida. Como era un juego móvil, solo una parte muy pequeña de los assets no estaba en la base de datos. También es interesante ver que más gente adoptó este enfoque después.
Otra ventaja fácil de pasar por alto es que puedes adjuntar una cantidad casi ilimitada de metadatos junto al contenido y encontrar archivos “similares” mediante consultas a la base de datos.
Pusimos muchísimos metadatos en la DB, y creo que el archivo pak final era de 200 MB y la base de datos de unos 20 MB. De nuevo: era un juego móvil.
Lo peor del lado del cliente fue un doble join interno que no pudimos eliminar por la complejidad del lado del servidor. Era frustrante que no pudiéramos implementar el servidor nosotros; la otra parte con la que trabajábamos era muy mala desarrollando software y cambiaba cosas sin comunicar toda la especificación del backend, lo que hacía que de pronto se rompiera el build.
Para las repeticiones de partidas también usamos una base de datos sqlite3 separada, y después de que terminaba una partida se podía reproducir todo el juego y ver qué había hecho cada rival. También fue excelente para pruebas automatizadas.
En el sistema de control de cambios lix también terminamos poniendo archivos en SQLite en lugar de lidiar con el sistema de archivos y git. Este artículo trata los problemas que tuvimos: https://opral.substack.com/i/150054233/breaking-git-compatib...
SQLite resuelve problemas como bloqueo de archivos y concurrencia.
Usar SQLite permite consultar archivos con SQL en vez de usar APIs de sistema de archivos específicas de cada plataforma.
Las consultas SQL pueden escribirse con seguridad de tipos usando Kysely https://kysely.dev/ sin necesidad de un ORM.
Eso sí, hay que tener cuidado con que una base de datos SQLite no se reduce si no se ejecuta vacuum. Básicamente, eso consiste en copiar los datos a un archivo separado y borrar el original.
Es algo que hay que hacer manualmente en un momento que tenga sentido dentro de la aplicación, así que al usarlo para escribir y borrar datos binarios hay que vigilar el uso de disco.
Curiosamente, el CMS generador de sitios estáticos que hice funciona exactamente al revés de este enfoque.
Mientras se desarrolla/actualiza el sitio web, todas las páginas y posts son entradas en una base de datos SQLite y se manipulan mediante una interfaz web que muestra una versión editable del sitio.
Luego vuelca el sitio web como páginas estáticas al sistema de archivos para desplegarlo directamente, o permite descargarlo como zip y subirlo a otro lugar, incluidos servicios de hosting completamente estático.
Según “Appropriate Uses For SQLite” de SQLite https://www.sqlite.org/whentouse.html, el tráfico web que SQLite puede manejar depende de qué tan intensivamente use la base de datos el sitio.
En general, los sitios con menos de 100K hits por día deberían funcionar bien con SQLite. 100K/día es una estimación conservadora, no un límite superior estricto. Ha habido casos en los que SQLite manejó 10 veces ese tráfico.
El sitio web de SQLite (https://www.sqlite.org/) obviamente también usa SQLite y, para 2015, procesaba alrededor de 400K~500K solicitudes HTTP por día, de las cuales entre 15% y 20% eran páginas dinámicas que tocaban la base de datos. El contenido dinámico usa unas 200 sentencias SQL por página web.
Esta configuración corre en una sola VM que comparte un servidor físico con otras 23 VM y aun así mantiene el load average por debajo de 0.1 la mayor parte del tiempo. Referencia: https://news.ycombinator.com/item?id=33975635
Para cargas de trabajo mayormente de lectura, como servir archivos estáticos, SQLite puede manejar mucho más. Si están configurados los headers de caché de contenido, el navegador cachea el contenido, así que las solicitudes al servidor solo son necesarias para clientes nuevos.
En la mayoría de los casos de uso, no parece que SQLite vaya a ser el cuello de botella.
La idea de servir contenido estático con SQLite basándose solo en la página de 2017 “35% Faster Than The Filesystem” parece, por decirlo suavemente, poco madura.
Los servidores web modernos como Nginx usan estrategias optimizadas para manejar archivos estáticos. Empiezan con sendfile y llegan hasta operaciones con io_uring y splice, funcionando dentro de un thread pool bien diseñado sobre la base que haga falta entre epoll, kqueue o eventport.
En cambio, lo mejor que SQLite puede ofrecer de forma predeterminada es, más o menos, soporte para I/O con mapeo en memoria (https://www.sqlite.org/mmap.html).
Este enfoque puede encajar bien para un servicio de un solo cliente, como una webapp alojada localmente (ver también https://github.com/electron/asar). Pero en un sitio web grande, como dicen otros comentarios, sería intentar resolver un problema que no existe.
Hago bastante cómputo científico de alto rendimiento y, especialmente al acceder a datos en paralelo, muchas veces la forma más flexible y rápida resultó ser una base de datos SQLite de solo lectura sobre un ramdisk.
Se siente bastante hacky, pero es más fácil de configurar y más rápido que cualquier otro método que haya encontrado hasta ahora.
Una vez vi a un amigo del área de astronomía decir que mucha gente en ciencias debería familiarizarse con las bases de datos. Si no, terminan, sin darse cuenta, invirtiendo un esfuerzo enorme en crear su propia base de datos pésima.
La razón por la que este enfoque no es más común es que los sistemas de archivos son excelentes para manejar archivos.
Si necesitas actualizaciones atómicas, puedes hacer checkout en un directorio nuevo y cambiar un enlace simbólico.
He visto varias versiones de usar una base de datos como si fuera un sistema de archivos; tiene cosas buenas, pero cuando algo sale mal también puede volverse una pesadilla.
Así también podrías usar algo como btrfs para hacer deduplicación a nivel del sistema de archivos.
Hay un problema con el argumento de que, como pueden cambiar muchos archivos durante una actualización de la app, usar una base de datos permitiría manejar todos los cambios de forma atómica con transacciones y evitar servir páginas web rotas durante un cambio de versión.
Se debe a que los archivos SQLite se bloquean para lecturas durante escrituras con el fin de lograr aislamiento serializable. Entonces la conclusión es que conviene hacer las operaciones de la base de datos en un archivo offline y reemplazar el archivo nuevo por el existente en producción.
Eso, al final, se parece más a usar un archivo tar o a usar un directorio separado que se reemplaza con el contenido nuevo.
Servir archivos estáticos como estáticos es mucho más simple. No hace falta servirlos desde un programa que gestione conexiones SQLite en tiempo real e intente lograr una extraña magia de “concurrencia de actualizaciones”. Este problema se puede resolver sin ninguna dificultad.
Está bien gestionar un CMS con una base de datos SQLite, pero si el contenido es estático y se sirve en tiempo real, conviene usar archivos estáticos.