El montaje que tiene todo el mundo
Si autoalojas Buzz, casi seguro que corres alguna versión del paquete compose de producción en deploy/compose/compose.yml. Arranca cinco servicios: el relay, Postgres, Redis, MinIO y un minio-init de un solo uso que crea un bucket buzz-media. MinIO es el backend de medios. Cada imagen, nota de voz o vídeo que alguien pega en el chat se convierte en un objeto S3, y el relay llega a él en http://minio:9000 por la red de compose.
Fíjate en el bloque de volúmenes, porque ahí vive esta historia. buzz-postgres-data y buzz-minio-data son volúmenes locales de Docker, lo que significa que ambos están en el mismo sistema de archivos de la misma máquina. Tu historial de conversaciones y tu archivo de medios comparten un disco, un mismo caudal de IOPS y una misma cifra de espacio libre. El primer día no pasa nada. Los modos de fallo llegan después, y ninguno avisa.
El paquete compose de producción de block/buzz, abreviado: MinIO sirve los medios desde un volumen local de Docker en la misma máquina donde corren Postgres y el relay.
Lleno antes de estar lleno
Los dos almacenes tienen apetitos distintos. Postgres crece kilobytes por mensaje y toca el disco con escrituras pequeñas y síncronas, sensibles a la latencia. Los medios crecen megabytes por adjunto y llegan en flujo. Ponlos en un mismo disco y cada subida grande compite con cada fsync que necesita tu registro de eventos, y la curva de los medios, la empinada, decide cuándo se llena el disco para los dos.
Lo más sutil es que MinIO deja de aceptar escrituras antes de que el disco esté lleno, por diseño. En el MinIO actual (hasSpaceFor en cmd/object-api-utils.go), una escritura entrante se cuenta doble por la contabilidad del erasure coding, un flujo sin longitud declarada se presupuesta en 1 GiB fijo (diskAssumeUnknownSize), y la escritura se rechaza si llevaría la unidad más allá de diskFillFraction, fijado en el código al 99 por ciento. Cuando llegas ahí, la respuesta de la API dice: Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.
En la práctica, las subidas empiezan a fallar mientras df todavía muestra gigabytes libres. Un vídeo de 500 MB necesita un gigabyte entero de margen para ser aceptado, a un flujo de longitud desconocida se le cobra un gigabyte sea cual sea su tamaño real, y el último uno por ciento del disco nunca fue tuyo. Si tu alerta salta al 95 por ciento de uso, puede que MinIO llegue antes. Pon la alarma sobre los rechazos del backend de almacenamiento, no sobre la gráfica del disco.
La subida de dos segundos
Lo primero que nos enseñó producción: cada subida de medios cargaba casi exactamente dos segundos de latencia. Una captura de 40 KB, dos segundos. Una foto de 3 MB, dos segundos y poco más. La máquina estaba ociosa, el ancho de banda iba bien y la cifra era sospechosamente redonda, y esa es la pista. La carga produce variación. Dos segundos planos son un temporizador.
El temporizador está en el cliente. Los crates de medios de Buzz compilan el crate rust-s3 con su feature fail-on-err (crates/buzz-media/Cargo.toml), que convierte cualquier respuesta que no sea 2xx en un error, incluido un 404 perfectamente legítimo. Y antes de escribir un objeto nuevo, la ruta de subida pregunta al almacenamiento si el sidecar de metadatos y el blob ya existen: dos llamadas head en upload.rs. En una primera subida, ambas respuestas son, correctamente, 404; ambos 404 se convierten en errores, y la lógica de reintento del crate duerme un segundo entero antes de reintentar cada uno. Dos comprobaciones de existencia, dos esperas, 2000 ms de nada antes de que se mueva un solo byte de tu archivo.
Nada iba lento. Nada estaba sobrecargado. El relay esperaba educadamente a que pasara una política de reintentos sobre una respuesta que nunca fue un error, dos veces por subida, en cada subida. La lección vale mucho más allá de Buzz: cuando una cifra de latencia es redonda y constante, deja de perfilar la CPU y ve a leer la política de reintentos de cada cliente en la ruta.
La lectura que ocurre dos veces
Lo segundo que nos enseñó producción. Buzz guarda un pequeño sidecar de metadatos junto a cada blob, y la ruta de servicio lo lee para conocer la extensión canónica y el tipo MIME, un diseño sensato frente a la suplantación del content-type. Pero sigue una petición de medios a través de crates/buzz-relay/src/api/media.rs y verás que el sidecar se lee una vez para la respuesta MIME y otra vez dentro de resolve_s3_key, que por su cuenta vuelve a pedir el mismo objeto para reconstruir la misma información. Suma el HEAD y el GET sobre el propio blob y una petición de medios cuesta cuatro viajes de ida y vuelta secuenciales al almacenamiento.
Contra un MinIO en loopback, un viaje de ida y vuelta es un milisegundo o dos, así que el duplicado es invisible, y ese es justo el problema: el almacenamiento local perdona el patrón y te lo esconde. Pon latencia de red real debajo del mismo código y cada petición paga el duplicado a decenas de milisegundos por salto. Nosotros lo absorbemos con una pequeña caché de metadatos en nuestra capa de almacenamiento, con un tope lo bastante bajo para que los bytes de medios nunca la desplacen. La lección: cuenta tus viajes de ida y vuelta mientras el almacenamiento sigue siendo local, porque el día que lo muevas, ese patrón es lo que te van a cobrar, en milisegundos.
Por qué un volumen más grande no es el arreglo
El arreglo reflejo es montar un volumen de almacenamiento en bloque y mover buzz-minio-data a él. Ahora los medios tienen sitio, y tú tienes una factura nueva con mala forma: el almacenamiento en bloque se cobra por gigabyte aprovisionado, alrededor de $0,08 a $0,10 por GB al mes en las grandes nubes (cifras representativas, tu proveedor puede variar), y pagas el volumen entero esté lleno o vacío. El suelo del 99 por ciento lo empeora, porque el margen que MinIO exige es espacio que tienes que aprovisionar y que nunca puedes usar.
Además, ahora eres dueño de un segundo sistema con estado. El volumen hay que vigilarlo, redimensionarlo (una operación de sistema de archivos sobre datos vivos que vas a programar con cuidado), sacarle snapshots y, sobre todo, respaldarlo, porque tu volcado de Postgres no contiene tus medios. Un respaldo que se salta el volumen de medios restaura un historial de chat donde cada imagen, nota de voz y archivo compartido es un enlace roto. Nadie lo descubre el día del respaldo. Se descubre el día de la restauración.
Lo que operamos en su lugar
Sustituimos la capa de almacenamiento de medios bajo nuestros relays alojados, así que el relay sigue hablando S3, pero los objetos de medios viven en Cloudflare R2, fuera de la máquina por completo. Cada espacio de trabajo escribe bajo su propio espacio de nombres acotado, y el disco del relay vuelve a guardar lo que debe guardar el disco de un relay: la base de datos de eventos y el software. El crecimiento de los medios deja de ser un factor para decidir cuánta máquina alquilas.
También cambia la respuesta sobre el cifrado en reposo. El compose estándar no configura cifrado en reposo para MinIO, así que los objetos de medios quedan en el disco del VPS como archivos sin cifrar, y quien pueda leer ese disco, o un snapshot o una imagen de respaldo suya, puede leer cada adjunto. R2 cifra todo en reposo, siempre. Es una afirmación comparativa, no mágica: las claves son del proveedor de almacenamiento, así que protege los bytes en los discos y en los snapshots, y te pide confiar en Cloudflare en lugar de en cualquiera que alguna vez toque el almacenamiento de tu VPS.
Y arregla lo que significa un respaldo. Nuestros respaldos tratan los medios como parte del artefacto, no como un apéndice: si falta un objeto de medios en un respaldo, el respaldo falla; cada objeto se verifica contra su hash de contenido, y una restauración se demuestra a sí misma pasando una petición HEAD por cada clave de medios que dice contener. Una restauración que no puede entregar tus archivos falla de forma ruidosa en el momento de restaurar, que es el único momento aceptable para enterarse.
Los números
El almacenamiento R2 Standard cuesta $0,015 por GB al mes, sin tarifas de egreso, según los precios publicados por Cloudflare en el momento de escribir esto. Guardar 500 GB de medios del equipo cuesta $7,50 al mes, y servirlos a tu equipo no cuesta nada en transferencia. Las operaciones se miden aparte, $4,50 por millón de escrituras y $0,36 por millón de lecturas, y una carga de chat no se acerca a cifras donde esa línea importe.
Los mismos 500 GB en un volumen de almacenamiento en bloque, a los representativos $0,08 a $0,10 por GB al mes, son de $40 a $50 al mes, aprovisionados antes del crecimiento, más el margen que exige el suelo de espacio libre, más lo que tu proveedor cobre por servir esos bytes. La diferencia de precio no es la razón de fondo para sacar los medios de la máquina; lo es la forma de la operación. Pero la diferencia paga la mudanza.
Una cosa más, sobre los nombres
Un aviso breve del mismo cuaderno de producción: elige con cuidado el nombre de host de tu relay antes de que se una nadie, porque la identidad de tu comunidad acaba atada a él, y no quieres estar descubriendo cómo funciona un cambio de nombre cuando cincuenta personas ya llaman hogar al nombre viejo.
Dónde te deja esto
Operamos todo lo anterior como servicio administrado en hachiflow.com: $50 al mes, precio plano, tu propio relay en name.hachiflow.chat, personas y agentes ilimitados, y los medios en R2 exactamente como se describe aquí.
Y si prefieres seguir autoalojando, quédate con los tres arreglos que importan: saca los medios del disco del relay, pon la alarma sobre los rechazos del backend de almacenamiento y no sobre df, y trata un respaldo sin medios como un respaldo fallido. Esos tres no cuestan nada, y son la mayor parte del valor de esta página.