hachiflow.com
Magazyn danych

Zabierz media z tej maszyny

Standardowy plik compose Buzza kładzie magazyn mediów na tym samym dysku co bazę zdarzeń. Działa to aż do dnia, w którym po cichu przestaje. Czego naprawdę uczy prowadzenie mediów Buzza na produkcji, z dowodami, i układ, na który przeszliśmy.

The setup everyone has

If you self-host Buzz, you are almost certainly running some version of the production compose bundle at deploy/compose/compose.yml. It starts five services: the relay, Postgres, Redis, MinIO, and a one-shot minio-init that creates a buzz-media bucket. MinIO is the media backend. Every image, voice note and video anyone pastes into chat becomes an S3 object, and the relay reaches it at http://minio:9000 over the compose network.

Look at the volumes block, because that is where this story lives. buzz-postgres-data and buzz-minio-data are both local Docker volumes, which means both sit on the same filesystem of the same machine. Your conversation history and your media archive share one disk, one pool of IOPS, and one free-space number. On day one this is fine. The failure modes arrive later, and none of them announce themselves.

deploy/compose/compose.yml
# backend mediów, na maszynie relaya minio: image: minio/minio:RELEASE.2025-09-07T16-13-09Z command: server /data --console-address ":9001" volumes: - buzz-minio-data:/data # relay dociera do niego przez sieć compose relay: environment: BUZZ_S3_ENDPOINT: http://minio:9000 BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media} # dwa magazyny, jedna maszyna, jeden dysk volumes: buzz-postgres-data: buzz-minio-data:

Produkcyjny pakiet compose w block/buzz, w skrócie: MinIO serwuje media z lokalnego wolumenu Dockera na tej samej maszynie, na której działają Postgres i relay.

Full before it is full

The two stores have different appetites. Postgres grows in kilobytes per message and touches the disk in small synchronous writes that care about latency. Media grows in megabytes per attachment and streams. Put them on one disk and every large upload competes with every fsync your event log needs, and the media curve, the steep one, decides when the disk fills for both of them.

The subtler part is that MinIO stops accepting writes before the disk is full, by design. In current MinIO (hasSpaceFor in cmd/object-api-utils.go), an incoming write is doubled for erasure-coding bookkeeping, a stream with no declared length is budgeted at 1 GiB flat (diskAssumeUnknownSize), and the write is refused if it would push the drive past diskFillFraction, hardcoded at 99 percent. The API answer when you get there reads: Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.

In practice, uploads start failing while df still shows gigabytes free. A 500 MB video needs a full gigabyte of headroom to be accepted, a stream of unknown length is charged a gigabyte regardless of its real size, and the last one percent of the disk was never yours. If your alert fires at 95 percent used, MinIO may get there first. Alarm on the storage backend's refusals, not on the disk graph.

The two-second upload

First thing production taught us: every media upload carried almost exactly two seconds of latency. A 40 KB screenshot, two seconds. A 3 MB photo, two seconds and a bit. The box was idle, bandwidth was fine, and the number was suspiciously round, which is the tell. Load produces jitter. A flat two seconds is a timer.

The timer is in the client. Buzz's media crates compile the rust-s3 crate with its fail-on-err feature (crates/buzz-media/Cargo.toml), which turns any non-2xx response into an error, including a perfectly legitimate 404. And before writing a fresh object, the upload path asks storage whether the metadata sidecar and the blob already exist: two head calls in upload.rs. On a first upload both answers are correctly 404, both 404s become errors, and the crate's retry logic sleeps a full second before retrying each one. Two existence checks, two sleeps, 2,000 ms of nothing before a single byte of your file moves.

Nothing was slow. Nothing was overloaded. The relay was politely waiting out a retry policy on a response that was never an error, twice per upload, on every upload. The lesson travels well beyond Buzz: when a latency number is round and constant, stop profiling the CPU and go read the retry policy of every client in the path.

The read that happens twice

Second thing production taught us. Buzz stores a small metadata sidecar next to every blob, and the serve path reads it to learn the canonical extension and MIME type, a sound design against content-type spoofing. But trace one media request through crates/buzz-relay/src/api/media.rs and you will find the sidecar read once for the MIME answer, then read again inside resolve_s3_key, which independently fetches the same object to rebuild the same information. Add the HEAD and the GET on the blob itself and one media request costs four sequential storage round trips.

Against loopback MinIO a round trip is a millisecond or two, so the duplicate is invisible, and that is exactly the problem: local storage forgives the pattern and hides it from you. Put real network latency under the same code and every request pays the duplicate at tens of milliseconds per hop. We absorb it with a small metadata cache in our storage layer, capped small enough that media bytes can never crowd it. The lesson: count your round trips while storage is still local, because the day you move it, the pattern is what you will be billed for, in milliseconds.

Why a bigger volume is not the fix

The reflexive fix is to mount a block-storage volume and move buzz-minio-data onto it. Now the media has room, and you have a new bill with a bad shape: block storage is priced per provisioned gigabyte, around $0.08 to $0.10 per GB-month at the large clouds (representative figures, your provider may differ), and you pay for the whole volume whether it is full or empty. The 99 percent floor makes it worse, because the headroom MinIO insists on is space you must provision and can never use.

You also now own a second stateful system. The volume has to be monitored, resized (a filesystem operation on live data that you will schedule carefully), snapshotted, and above all backed up, because your Postgres dump does not contain your media. A backup that skips the media volume restores into a chat log where every image, voice note and shared file is a broken link. Nobody discovers this on backup day. It is discovered on restore day.

What we run instead

We replaced the media storage layer under our hosted relays, so the relay still speaks S3 but media objects live in Cloudflare R2, off the box entirely. Each workspace writes under its own scoped namespace, and the relay's disk goes back to holding what a relay disk should hold: the event database and the software. Media growth stops being an input to how big a machine you rent.

It also changes the encryption-at-rest answer. The stock compose configures no encryption at rest for MinIO, so media objects sit on the VPS disk as plain files, and whoever can read that disk, or a snapshot or backup image of it, can read every attachment. R2 encrypts everything at rest, always. That is a comparative claim, not a magic one: the keys are the storage provider's, so it protects the bytes on disks and in snapshots, and it asks you to trust Cloudflare rather than everyone who ever touches your VPS's storage.

And it fixes what backup means. Our backups treat media as part of the artifact, not an appendix: a media object missing from a backup fails the backup, each object is verified against its content hash, and a restore proves itself by sweeping a HEAD request over every media key it claims to contain. A restore that cannot produce your files fails loudly at restore time, which is the only acceptable time to learn that.

standardowy compose jeden VPS buzz-postgres-data buzz-minio-data pod spodem jeden dysk zapisy odrzucane przy 99% nasz układ relay + Postgres dysk zostaje mały media Cloudflare R2 osobna przestrzeń nazw na workspace
W standardowym układzie baza zdarzeń i magazyn mediów dzielą jeden dysk, a MinIO przestaje przyjmować zapisy przy jego 99 procentach. Przeniesienie mediów do R2 zdejmuje stromą krzywą wzrostu z maszyny w całości.

The numbers

R2 Standard storage is $0.015 per GB-month with zero egress fees, per Cloudflare's published pricing as of this writing. 500 GB of team media costs $7.50 a month to store, and serving it out to your team costs nothing in transfer. Operations are metered separately, $4.50 per million writes and $0.36 per million reads, and a chat workload does not get near numbers where that line matters.

The same 500 GB on a block-storage volume at the representative $0.08 to $0.10 per GB-month is $40 to $50 a month, provisioned ahead of growth, plus the headroom the free-space floor demands, plus whatever your provider charges to serve those bytes out. The price gap is not the deep reason to move media off the box; the operational shape is. But the gap does pay for the move.

One more thing, about names

One short warning from the same production notebook: choose your relay hostname carefully before anyone joins, because your community's identity ends up tied to it, and you do not want to be discovering the mechanics of a rename after fifty people call the old name home.

Where this leaves you

We run all of the above as a managed service at hachiflow.com: $50 a month flat, your own relay at name.hachiflow.chat, unlimited people and agents, and media on R2 exactly as described here.

And if you would rather keep self-hosting, take the three fixes that matter and keep them: move media off the relay's disk, alarm on the storage backend's refusals rather than on df, and treat a backup without media as a failed backup. Those three cost nothing, and they are most of the value of this page.

← Wszystkie notatki