hachiflow.com

Ten wpis dotyczy Buzz, naszego wcześniejszego produktu. Dziś tworzymy Waggle.

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.

Konfiguracja, którą ma każdy

Jeśli hostujesz Buzz samodzielnie, niemal na pewno uruchamiasz jakąś wersję produkcyjnego pakietu compose z deploy/compose/compose.yml. Startuje on pięć usług: relay, Postgresa, Redisa, MinIO oraz jednorazowy minio-init, który tworzy bucket buzz-media. MinIO jest backendem mediów. Każdy obraz, notatka głosowa i film, które ktokolwiek wklei na czat, stają się obiektem S3, a relay dociera do nich pod http://minio:9000 przez sieć compose.

Spójrz na blok volumes, bo to w nim rozgrywa się ta historia. buzz-postgres-data i buzz-minio-data to lokalne wolumeny Dockera, co oznacza, że oba leżą w tym samym systemie plików tej samej maszyny. Twoja historia rozmów i Twoje archiwum mediów dzielą jeden dysk, jedną pulę IOPS i jedną liczbę wolnego miejsca. Pierwszego dnia to nie problem. Awarie przychodzą później i żadna z nich się nie zapowiada.

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.

Pełny, zanim się zapełni

Te dwa magazyny mają różne apetyty. Postgres rośnie o kilobajty na wiadomość i dotyka dysku małymi, synchronicznymi zapisami, dla których liczy się opóźnienie. Media rosną o megabajty na załącznik i płyną strumieniowo. Połóż je na jednym dysku, a każdy duży upload konkuruje z każdym fsync, którego potrzebuje Twój log zdarzeń, a krzywa mediów, ta stroma, decyduje, kiedy dysk zapełni się dla obu.

Subtelniejsze jest to, że MinIO przestaje przyjmować zapisy, zanim dysk się zapełni, i to celowo. W obecnym MinIO (hasSpaceFor w cmd/object-api-utils.go) przychodzący zapis jest liczony podwójnie na potrzeby rozliczeń erasure codingu, strumień bez zadeklarowanej długości dostaje z góry budżet 1 GiB (diskAssumeUnknownSize), a zapis zostaje odrzucony, jeśli przepchnąłby dysk ponad diskFillFraction, zaszyte na sztywno na 99 procentach. Odpowiedź API, gdy tam dotrzesz, brzmi: Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.

W praktyce uploady zaczynają się sypać, gdy df wciąż pokazuje gigabajty wolnego miejsca. Film 500 MB potrzebuje pełnego gigabajta zapasu, żeby został przyjęty, strumień nieznanej długości jest liczony jako gigabajt bez względu na prawdziwy rozmiar, a ostatni procent dysku nigdy nie był Twój. Jeśli Twój alert odpala się przy 95 procentach zajętości, MinIO może dotrzeć do ściany pierwsze. Ustaw alarm na odmowy backendu magazynu, a nie na wykres dysku.

Dwusekundowy upload

Pierwsza lekcja z produkcji: każdy upload mediów niósł niemal dokładnie dwie sekundy opóźnienia. Zrzut ekranu 40 KB, dwie sekundy. Zdjęcie 3 MB, dwie sekundy z kawałkiem. Maszyna była bezczynna, przepustowość w porządku, a liczba podejrzanie okrągła, i to jest właśnie trop. Obciążenie daje rozrzut. Równe dwie sekundy to timer.

Timer siedzi w kliencie. Crate'y mediów w Buzz kompilują crate rust-s3 z włączoną opcją fail-on-err (crates/buzz-media/Cargo.toml), która zamienia każdą odpowiedź spoza 2xx w błąd, łącznie z całkowicie prawidłowym 404. A przed zapisaniem nowego obiektu ścieżka uploadu pyta magazyn, czy sidecar z metadanymi i sam blob już istnieją: dwa wywołania head w upload.rs. Przy pierwszym uploadzie obie odpowiedzi to słusznie 404, oba 404 stają się błędami, a logika retry w tym crate'cie śpi pełną sekundę przed ponowieniem każdego z nich. Dwa sprawdzenia istnienia, dwa uśpienia, 2000 ms niczego, zanim ruszy choćby jeden bajt Twojego pliku.

Nic nie było wolne. Nic nie było przeciążone. Relay grzecznie odczekiwał politykę retry na odpowiedź, która nigdy nie była błędem, dwa razy na upload, przy każdym uploadzie. Ta lekcja sięga daleko poza Buzz: gdy liczba opisująca opóźnienie jest okrągła i stała, przestań profilować CPU i przeczytaj politykę retry każdego klienta na ścieżce.

Odczyt, który dzieje się dwa razy

Druga lekcja z produkcji. Buzz trzyma obok każdego bloba mały sidecar z metadanymi, a ścieżka serwowania czyta go, żeby poznać kanoniczne rozszerzenie i typ MIME, co jest rozsądnym zabezpieczeniem przed podszywaniem się pod content-type. Prześledź jednak jedno żądanie o media przez crates/buzz-relay/src/api/media.rs, a zobaczysz, że sidecar jest czytany raz po odpowiedź o MIME, a potem jeszcze raz wewnątrz resolve_s3_key, która niezależnie pobiera ten sam obiekt, żeby odbudować te same informacje. Dodaj HEAD i GET na samym blobie, a jedno żądanie o media kosztuje cztery kolejne round tripy do magazynu.

Przy MinIO na pętli zwrotnej round trip to milisekunda czy dwie, więc duplikat jest niewidoczny, i właśnie w tym problem: lokalny magazyn wybacza ten wzorzec i ukrywa go przed Tobą. Podłóż pod ten sam kod prawdziwe opóźnienie sieciowe, a każde żądanie płaci za duplikat dziesiątki milisekund na każdym skoku. My pochłaniamy to małym cache'em metadanych w naszej warstwie magazynu, ograniczonym na tyle, że bajty mediów nigdy go nie wypchną. Lekcja: licz round tripy, póki magazyn jest jeszcze lokalny, bo w dniu, w którym go przeniesiesz, zapłacisz właśnie za ten wzorzec, w milisekundach.

Dlaczego większy wolumen to nie rozwiązanie

Odruchowa poprawka to podmontować wolumen block storage i przenieść na niego buzz-minio-data. Media mają teraz miejsce, a Ty masz nowy rachunek o złym kształcie: za block storage płaci się od przydzielonego gigabajta, około $0,08 do $0,10 za GB miesięcznie u dużych chmur (wartości orientacyjne, u Twojego dostawcy mogą być inne), i płacisz za cały wolumen, pełny czy pusty. Próg 99 procent pogarsza sprawę, bo zapas, na który upiera się MinIO, to miejsce, które musisz przydzielić i którego nigdy nie użyjesz.

Masz też teraz drugi system ze stanem. Wolumen trzeba monitorować, powiększać (operacja na systemie plików z żywymi danymi, którą zaplanujesz bardzo ostrożnie), robić mu snapshoty, a przede wszystkim objąć go kopią zapasową, bo Twój zrzut Postgresa nie zawiera Twoich mediów. Kopia zapasowa, która pomija wolumen mediów, przywraca się do logu czatu, w którym każdy obraz, notatka głosowa i udostępniony plik jest zerwanym linkiem. Nikt nie odkrywa tego w dniu robienia kopii. Odkrywa się to w dniu przywracania.

Co prowadzimy zamiast tego

Wymieniliśmy warstwę magazynu mediów pod naszymi hostowanymi relayami, więc relay nadal mówi S3, ale obiekty mediów leżą w Cloudflare R2, całkowicie poza maszyną. Każda przestrzeń robocza zapisuje we własnej, wydzielonej przestrzeni nazw, a dysk relaya wraca do trzymania tego, co dysk relaya powinien trzymać: bazy zdarzeń i oprogramowania. Przyrost mediów przestaje wpływać na to, jak dużą maszynę wynajmujesz.

Zmienia to też odpowiedź na pytanie o szyfrowanie w spoczynku. Standardowy compose nie konfiguruje dla MinIO żadnego szyfrowania w spoczynku, więc obiekty mediów leżą na dysku VPS jako zwykłe pliki, a każdy, kto może odczytać ten dysk albo jego snapshot czy obraz kopii zapasowej, może odczytać każdy załącznik. R2 szyfruje wszystko w spoczynku, zawsze. To twierdzenie porównawcze, a nie magiczne: klucze należą do dostawcy magazynu, więc chroni to bajty na dyskach i w snapshotach, a wymaga zaufania do Cloudflare zamiast do każdego, kto kiedykolwiek dotknie magazynu Twojego VPS.

I naprawia to, co znaczy kopia zapasowa. Nasze kopie traktują media jako część artefaktu, a nie aneks: brak obiektu mediów w kopii oznacza nieudaną kopię, każdy obiekt jest weryfikowany względem hasza swojej treści, a przywrócenie dowodzi samo siebie, przechodząc żądaniem HEAD po każdym kluczu mediów, który rzekomo zawiera. Przywrócenie, które nie potrafi oddać Twoich plików, zawodzi głośno w chwili przywracania, a to jedyny akceptowalny moment, żeby się o tym dowiedzieć.

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.

Liczby

R2 Standard kosztuje $0,015 za GB miesięcznie, bez opłat za egress, według cennika opublikowanego przez Cloudflare w chwili pisania. Przechowywanie 500 GB mediów zespołu kosztuje $7,50 miesięcznie, a serwowanie ich Twojemu zespołowi nie kosztuje nic za transfer. Operacje są liczone osobno, $4,50 za milion zapisów i $0,36 za milion odczytów, a obciążenie czatu nie zbliża się do wartości, przy których ta pozycja ma znaczenie.

Te same 500 GB na wolumenie block storage przy orientacyjnych $0,08 do $0,10 za GB miesięcznie to $40 do $50 miesięcznie, przydzielone z wyprzedzeniem względem wzrostu, plus zapas, którego wymaga próg wolnego miejsca, plus to, ile Twój dostawca liczy za wysyłanie tych bajtów na zewnątrz. Różnica w cenie nie jest głębokim powodem, by zabrać media z maszyny; jest nim kształt operacyjny. Ale ta różnica pokrywa koszt przeprowadzki.

Jeszcze jedno, o nazwach

Krótkie ostrzeżenie z tego samego produkcyjnego notatnika: wybierz nazwę hosta relaya starannie, zanim ktokolwiek dołączy, bo tożsamość Twojej społeczności zostaje z nią związana, a nie chcesz poznawać mechaniki zmiany nazwy wtedy, gdy dla pięćdziesięciu osób stara nazwa jest już domem.

Co z tego wynika dla Ciebie

Wszystko powyższe prowadzimy jako usługę zarządzaną na hachiflow.com: stałe $50 miesięcznie, Twój własny relay pod name.hachiflow.chat, nieograniczona liczba osób i agentów, a media w R2 dokładnie tak, jak opisaliśmy tutaj.

A jeśli wolisz dalej hostować samodzielnie, weź trzy poprawki, które mają znaczenie, i trzymaj się ich: zabierz media z dysku relaya, ustaw alarm na odmowy backendu magazynu zamiast na df i traktuj kopię zapasową bez mediów jak nieudaną kopię. Te trzy nic nie kosztują, a stanowią większość wartości tej strony.

← Wszystkie notatki