sequenceDiagram
participant W as Worker pod
participant PG as Postgres
participant M as Mount (FUSE)
participant S3 as Object Storage
W->>PG: SELECT objects for this run
PG-->>W: /opt/cosmo_data/file_path... + metadata
W->>M: open() at object position
M->>S3: GET with byte range
S3-->>M: requested bytes only
M-->>W: requested image region
Jak podać terabajty galaktyk podom Kubernetesa
Object storage, katalog w Postgresie i jedna ścieżka do plików w każdym środowisku — żeby model AI widział to samo, co notebook

Czego szukamy w tych danych
Soczewkowanie grawitacyjne to efekt przewidziany przez ogólną teorię względności: masa zakrzywia czasoprzestrzeń, więc światło odległej galaktyki, przechodząc obok masywnego obiektu na pierwszym planie, biegnie po zakrzywionym torze. Z Ziemi widać wtedy tło rozciągnięte w łuk, pierścień albo kilka kopii tego samego obiektu. Skala zniekształcenia zależy od całej masy soczewki — także tej ciemnej. Takie układy są jednak rzadkie: na miliony galaktyk przypada ich rząd setek.
Materiałem jest KiDS DR4 (Kilo Degree Survey Data Release 4) — publiczny przegląd nieba ESO: 1006 kafelków (tiles) obserwacji nieba, każdy w czterech filtrach u, g, r, i. W jednym z ostatnich eksperymentów używałem podzbioru 221 kafelków, w których znajdowało się 5,5 miliona obiektów wybranych do klasyfikacji.
Kilo-Degree Survey miał pokryć 1500 stopni kwadratowych, sięgając 2,5 magnitudo głębiej niż SDSS i z wyraźnie lepszą jakością obrazu. Projektowano go pod soczewkowanie słabe — statystykę drobnych zniekształceń na milionach galaktyk. Tu te same dane służą do czegoś innego: do wyławiania pojedynczych przypadków soczewkowania silnego. Przegląd jest zakończony, dane publiczne dostępne (programy ESO 177.A-3016, 177.A-3017, 177.A-3018).
Instrument to OmegaCAM na VLT Survey Telescope w Paranal: mozaika 32 osobnych detektorów CCD po 8 megapikseli, razem 256 milionów pikseli na klatkę, pole widzenia 1° × 1° — dwa razy szerzej niż tarcza Księżyca w pełni. Sam instrument produkuje około 30 TB rocznie.
Dla infrastruktury liczą się trzy własności tych danych obserwacyjnych — i spotkasz je w wielu domenach poza astronomią:
- Są duże i nie wolno ich kompresować stratnie. Kompresja zniszczyłaby informację, na której opiera się pomiar. 256 megapikseli przy float32 to ponad gigabajt na jeden filtr.
- Są niezmienne. Kafelek raz opublikowany nigdy się nie zmienia. To upraszcza wszystko: spójność, cache, replikację.
- Nikt nie czyta ich w całości. Pojedyncza galaktyka zajmuje na kafelku obszar rzędu setek pikseli. Żeby się jej przyjrzeć, trzeba mieć dostęp do całego pliku.
Punkt trzeci jest architektonicznie najważniejszy: wzorzec dostępu to losowy odczyt małych fragmentów z bardzo dużych, niezmiennych plików.
Problem
Klasyfikacja jest zadaniem, które można zrównoleglić: kafelki nie zależą od siebie, klasyfikacja osobnych obiektów od siebie również nie zależy. Zatem sensowny sposób na skrócenie eksperymentu klasyfikacji to puścić więcej wątków naraz. Skalowanie idzie wszerz — dokładamy repliki workerów, a nie większą maszynę. Jednocześnie do danych potrzeba czasem podglądu z innych procesów — na przykład z notebooków, do debugowania albo sprawdzenia pewnych aspektów danych.
Każde z tych środowisk musi widzieć ten sam plik pod tą samą ścieżką. Bez tego ciężko reużywać kod i identyfikować pliki: kod działający w notebooku nie działałby w podzie z zadaniem klasyfikacji, a wynik przestaje być odtwarzalny.
Trzy podejścia, które przychodzą do głowy najpierw, przy tych wymaganiach odpadają:
- Kopiowanie danych do obrazu kontenera. Przy terabajtach nierealne — obraz musiałby ważyć tyle co dataset.
- Block storage. Pojemność rezerwuje się z góry i płaci niezależnie od zapełnienia, a wolumen w praktyce przypina się do jednego węzła. Pody rozsiane po kilku węzłach odpadają z definicji.
- Wspólny NFS. Problem współdzielenia między węzłami rozwiązuje, ale pod spodem i tak leży wolumen block storage: pojemność ustalona z góry, powiększanie to operacja administracyjna, a nie zmiana liczby w manifeście. Zbiór, który może urosnąć z 200 GB do 20 TB, wymusza wtedy albo rezerwowanie zapasu na wyrost, albo migrację w trakcie. Do tego dochodzi serwer, który trzeba utrzymać i który jest pojedynczym punktem awarii.
Rozwiązanie ma trzy warstwy: object storage trzyma binarki plików, katalog w Postgresie wie, co gdzie leży, a CSI driver podaje to podom pod jedną ścieżką. Poniżej droga pojedynczego odczytu — od zapytania o ścieżkę do fragmentu obrazu.
Warstwa 1: Object Storage S3
Object Storage wygrywa tu z block storage z trzech powodów.
Płacisz za to, co leży. Bez rezerwacji pojemności z góry. Przy zbiorze, który może urosnąć z 200 GB do 20 TB, to różnica między planowaniem a zgadywaniem.
Wielu czytelników naraz. Bucket obsługuje setki równoległych klientów bez dodatkowej konfiguracji. To jest dokładnie ten wymóg, którego block storage nie spełnia.
Replikacja w standardzie. Nie muszę projektować redundancji dla danych, których ponowne pobranie z archiwum ESO trwałoby tygodnie.
Do tego dochodzi lokalizacja, i to jest rzecz, którą łatwo przeoczyć na etapie projektowania. Maszyny wirtualne, Kubernetes i GPU stoją w CloudFerro w tej samej infrastrukturze co bucket — zoptymalizowanej pod przetwarzanie dużych danych. Dane nie opuszczają sieci: nie ma opłat za ingress ani egress, a przepustowość jest wewnętrzna. Przy pipelinie, który w jednym przebiegu czyta setki gigabajtów, transfer potrafi kosztować tyle co samo liczenie.
Jedno ograniczenie warto znać od początku: przestrzeń kluczy w S3 jest płaska, a „katalogi” to konwencja nazewnicza, nie struktura. Listowanie przy dużej liczbie obiektów jest kosztowne i odradzane jest wrzucanie więcej niż miliona obiektów do jednego bucketa. To prowadzi wprost do warstwy drugiej.
Warstwa 2: Katalog
Pipeline nigdy nie listuje bucketa. Pyta bazę.
Tabela observations, wiersz na parę tile+filtr:
| kids_tile | raj2000 | decj2000 | filter | date | file_path |
|---|---|---|---|---|---|
| KIDS_183.0_-0.5 | 183.0 | -0.5 | u | 2012-04-16 | /opt/cosmo_data/KiDS_DR4_images/KiDS_DR4.0_183.0_-0.5_u_sci.fits |
| KIDS_183.0_-0.5 | 183.0 | -0.5 | g | 2012-05-19 | /opt/cosmo_data/KiDS_DR4_images/KiDS_DR4.0_183.0_-0.5_g_sci.fits |
| KIDS_183.0_-0.5 | 183.0 | -0.5 | r | 2013-04-09 | /opt/cosmo_data/KiDS_DR4_images/KiDS_DR4.0_183.0_-0.5_r_sci.fits |
| KIDS_45.6_-29.2 | 45.57 | -29.181 | r | 2017-07-20 | /opt/cosmo_data/KiDS_DR4_images/KiDS_DR4.0_45.6_-29.2_r_sci.fits |
Ścieżka jest jedna i jest zwykłą ścieżką w systemie plików. Katalog nie wie, że pod spodem leży bucket — to, skąd biorą się bajty, jest sprawą warstwy montowania, nie schematu bazy. Dzięki temu ten sam wiersz obowiązuje w podzie, w notebooku i na maszynie deweloperskiej.
Ta warstwa daje trzy rzeczy, których system plików nie da.
Selekcja przed I/O. Zapytanie zwraca dokładnie te pliki, które wchodzą do runu. Reszta nigdy nie zostaje pobrana.
-- Resolve exactly which files a run needs, before touching storage.
SELECT kids_tile, filter, file_path
FROM observations
WHERE filter = 'r'
AND raj2000 BETWEEN 180.0 AND 186.0
AND file_available IS TRUE
ORDER BY kids_tile;Filtrowanie przestrzenne. Współrzędne w kolumnach oznaczają, że „daj mi kafelki pokrywające ten obszar nieba” jest zapytaniem SQL, a nie parsowaniem nazw plików. Ten sam wzorzec obsłuży dowolne dane geoprzestrzenne albo szeregi czasowe.
Pochodzenie wyniku. Każda detekcja wskazuje wierszem na plik źródłowy i moment obserwacji. Bez tego wynik modelu jest efektowny, ale nieodtwarzalny.
Tabela trzyma też parametry jakości poszczególnych klatek — w astronomii jest to seeing i głębokość, w innych domenach będzie to poziom szumu, pokrycie chmurami albo stan kalibracji czujnika. To nie są ozdobniki, tylko warunki brzegowe modelu. Odsianie ich SQL-em wychodzi taniej niż uczenie sieci odporności na dane, których i tak nie warto jej pokazywać.
Warstwa 3: Dostęp
Czym są CSI, PV i PVC
Kubernetes nie wie, czym jest S3. Umie jedno: podać kontenerowi katalog. Skąd biorą się bajty widoczne w tym katalogu, jest sprawą sterownika — i właśnie po to są trzy pojęcia, które wracają w każdym manifeście poniżej.
CSI (Container Storage Interface) to standardowy interfejs między Kubernetesem a systemami pamięci masowej: zamiast wbudowywać obsługę każdego dostawcy w rdzeń, dostawca pisze driver, a klaster go instaluje. Kubernetes wysyła do niego polecenia „przygotuj ten zasób na tym węźle” i „zamontuj go w tym katalogu” — co driver zrobi dalej, jest jego sprawą. Mountpoint uruchamia proces w przestrzeni użytkownika (FUSE), który zamienia zwykłe odczyty z katalogu na zapytania HTTP do S3.
PersistentVolume (PV) nie jest pamięcią masową, tylko jej opisem — obiektem mówiącym „istnieje taki zasób, obsługuje go ten driver, a oto parametry”. W manifeście poniżej nie ma ani bajtu danych: jest nazwa bucketa, endpoint i opcje montowania. PV „jest bucketem” w tym samym sensie, w jakim wizytówka jest człowiekiem. Stąd pola, które trzeba wypełnić, choć nic nie znaczą — capacity: 200Gi driver ignoruje, a API wymaga, bo projektowano je pod dyski.
PersistentVolumeClaim (PVC) jest po drugiej stronie: to zamówienie. Aplikacja mówi, czego potrzebuje, nie wiedząc, co jest pod spodem; Kubernetes dobiera pasujący PV i wiąże je w parę. Pod montuje PVC i nigdy nie sięga po PV bezpośrednio.
flowchart LR
P1["Worker pod"] -->|montuje| C
P2["Worker pod"] -->|montuje| C
P3["Worker pod ×N"] -->|montuje| C
C["PVC<br/>(namespace: lensing)"] -->|"binding 1:1"| V["PV<br/>(obiekt całego klastra)"]
V -->|"driver + parametry"| D["CSI driver<br/>(Mountpoint)"]
D -->|"FUSE, zakresy bajtów"| S[("Bucket S3")]
O kardynalności najłatwiej się potknąć: PVC wiąże się z jednym PV i claimRef przypina tę parę na sztywno, ale ten sam PVC montuje dowolnie wiele podów — accessModes: ReadWriteMany pozwala trzymać wolumen na wielu węzłach naraz, więc dokładanie workerów nie zmienia nic po stronie storage’u.
Konfiguracja
Bucket montujemy z pomocą Mountpoint for Amazon S3 CSI Driver — Apache-2.0, jeden z projektów open source AWS, działający z każdym S3-kompatybilnym endpointem, nie tylko z AWS-em. Wymaga Kubernetesa 1.30+ i implementuje CSI 1.9.0.
Instalacja i klucze:
# Install the driver from the official Helm repository.
helm repo add aws-mountpoint-s3-csi-driver \
https://awslabs.github.io/mountpoint-s3-csi-driver
helm repo update
helm upgrade --install aws-mountpoint-s3-csi-driver \
aws-mountpoint-s3-csi-driver/aws-mountpoint-s3-csi-driver \
--namespace kube-system
# S3 credentials live in a Kubernetes Secret, never in the manifest.
kubectl create secret generic aws-secret \
--namespace kube-system \
--from-literal=key_id="${S3_ACCESS_KEY_ID}" \
--from-literal=access_key="${S3_SECRET_ACCESS_KEY}"Sama para PV + PVC:
# The bucket is exposed as a read-only volume. The mount path is identical
# in every environment, so paths stored in the catalog stay valid everywhere.
apiVersion: v1
kind: PersistentVolume
metadata:
name: kids-dr4-pv-lensing
spec:
capacity:
storage: 200Gi # ignored by the driver, required by the API
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
claimRef: # pin this PV to one specific PVC
namespace: lensing
name: kids-dr4-pvc
mountOptions:
- --endpoint-url=https://s3.waw4-1.cloudferro.com
- --region=WAW4-1
- --read-only
- --metadata-ttl=indefinite # objects never change once published
csi:
driver: s3.csi.aws.com
volumeHandle: kids-dr4-pv-lensing
volumeAttributes:
bucketName: kids-dr4-tiles
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: kids-dr4-pvc
namespace: lensing
spec:
accessModes:
- ReadWriteMany
storageClassName: "" # required for static provisioning
volumeName: kids-dr4-pv-lensing
resources:
requests:
storage: 200GiWeryfikacja, że wolumen faktycznie działa — zanim odpalisz cały przebieg:
kubectl apply -f kids-dr4-pv.yaml
# The PVC must report Bound. Pending means the PV did not match —
# kubectl describe pvc kids-dr4-pvc -n lensing explains why.
kubectl get pv,pvc -n lensing
# Read a real file through the mount, from inside a pod.
kubectl exec -n lensing deploy/worker -- python -c \
"from astropy.io import fits; print(fits.getheader('/opt/cosmo_data/KiDS_DR4_images/KiDS_DR4.0_183.0_-0.5_r_sci.fits')['NAXIS1'])"Dlaczego mount jest tylko do odczytu
--read-only w mountOptions to decyzja, nie ograniczenie sterownika. Mountpoint część zapisów obsługuje — nowy obiekt, zapisany sekwencyjnie od początku do końca — ale nie nadpisze środka istniejącego pliku, nie zmieni nazwy i nie zrobi prawdziwego katalogu, bo S3 tego nie ma. To są rzeczy, które program zakłada po systemie plików w ciemno, a różnice są udokumentowane i realne.
Wolumen z połową semantyki zapisu jest gorszy niż taki, który zapisu nie udaje wcale — błąd wychodzi wtedy w losowym miejscu pipeline’u, zwykle po godzinie liczenia. Przy --read-only touch na mouncie kończy się natychmiast na Read-only file system, jeszcze w jądrze, zanim cokolwiek poleci do bucketa.
Stąd zasada: mount służy do czytania, zapis idzie przez SDK.
# Writes never go through the FUSE mount — use the SDK directly.
import boto3
s3 = boto3.client("s3", endpoint_url="https://s3.waw4-1.cloudferro.com")
def publish(local_path: str, key: str) -> None:
s3.upload_file(local_path, "results", key)Cache lokalny
Mountpoint potrafi trzymać pobrane fragmenty na lokalnym dysku, ale o tym, czy warto, decyduje wzorzec dostępu, a nie rozmiar danych: przy jednym przejściu po zbiorze cache nie ma czego trafiać. Zwraca się dopiero przy wielokrotnych przejściach po tym samym podzbiorze — kilku epokach treningu albo iteracyjnym strojeniu.
Jeśli go włączasz, licz się z domyślnym emptyDir: cache danych ląduje wtedy na dysku efemerycznym węzła, współdzielonym ze wszystkim, co na nim stoi. Kilkanaście podów ciągnących gigabajtowe pliki zapełnia go w kilkadziesiąt minut, a wtedy kubelet oznacza węzeł warunkiem DiskPressure i zaczyna eksmitować pody — także te liczące od godziny i niemające z cache’em nic wspólnego. Limit rozmiaru to absolutne minimum:
volumeAttributes:
bucketName: kids-dr4-tiles
cache: emptyDir
cacheEmptyDirSizeLimit: 20Gi # without this the node's disk fills upMinimum, ale nie zabezpieczenie — kubelet mierzy zużycie okresowo, więc da się ten limit przestrzelić między pomiarami. Docelowo cache wyprowadza się z dysku węzła na osobny wolumen (cache: ephemeral w sterowniku v2), z --max-cache-size poniżej jego pojemności.
Jest jeszcze trzecie miejsce na cache, którego nie ma w żadnym manifeście: warstwa aplikacji. Jeśli kafelek przetwarza się w czterech filtrach naraz, zbiór roboczy to w danej chwili dokładnie cztery pliki — a tyle mieści się w pamięci procesu. Wystarczy lru_cache na loaderze:
from functools import lru_cache
import numpy as np
from astropy.io import fits
@lru_cache(maxsize=4) # one tile = u, g, r, i — that is the whole working set
def load_band(path: str) -> np.ndarray:
with fits.open(path, memmap=False) as hdul:
return hdul[0].dataKoszt przenosi się wtedy z dysku na pamięć: cztery filtry po około gigabajcie to kilka GB RSS na pod, więc requests.memory musi to uwzględniać. W zamian nie trzeba konfigurować niczego w klastrze.
W opisywanym pipelinie jest jedno przejście po 221 kafelkach, więc cache danych na mouncie jest wyłączony. Z manifestu wyżej zostaje --metadata-ttl=indefinite — wyłącznie na informacje o plikach. Przy danych, które się nie zmieniają, nie ma powodu dopytywać o nie S3.
Czy to wystarcza, sprawdza się pomiarem. Jeden pod, bucket i compute w tym samym regionie:
| Operacja | Wynik |
|---|---|
open() na pliku dotykanym pierwszy raz |
45 ms (mediana z 8 plików) |
| ten sam plik ponownie | 17–20 ms |
| odczyt ciągły, jeden strumień | ~150 MiB/s |
| osiem strumieni równolegle | 1007 MiB/s — 22,7 GiB w 22,6 s |
| koszt po stronie procesu montującego | ~3 rdzenie, ~970 MiB RAM |
Bez cache’u danych na dysku odczyt i tak idzie z prędkością gigabajta na sekundę, bo bajty nie opuszczają regionu chmurowego — wyłączony cache nie jest tu kompromisem, tylko konsekwencją tego, że storage stoi obok compute.
Poza Kubernetesem
Ta sama warstwa danych obsługuje środowiska, które nie są podami. Mountpoint jest zwykłym programem — sterownik CSI tylko go opakowuje — więc maszyna wirtualna albo laptop montuje ten sam bucket samodzielnie, a katalog odpowiada na te same zapytania.
# Same bucket, same mount root — catalog paths stay valid outside the cluster.
mount-s3 kids-dr4-tiles /opt/cosmo_data/KiDS_DR4_images \
--endpoint-url https://s3.waw4-1.cloudferro.com \
--read-onlyWarunek jest jeden, za to bezwzględny: punkt montowania musi być wszędzie taki sam, bo file_path z katalogu jest ścieżką absolutną. Zapis to osobna droga, przez SDK, gdzie bucket i klucz podaje się wprost.
Kiedy to się skaluje, a kiedy nie
Opisana architektura daje efekt, który streszcza się w jednym zdaniu: 5,5 TiB widoczne dla każdego poda pod tą samą ścieżką, bez kopiowania danych, bez rezerwowania pojemności z góry i bez serwera plików do utrzymania. Ale jak daleko to sięga? Skaluje się horyzontalnie — liczbą plików, liczbą czytających procesów i wypełnionymi terabajtami. Są natomiast trzy sytuacje, których nie naprawi żadna liczba podów, bo nie są problemem skali, tylko wzorca dostępu.
Co rośnie liniowo
Liczba plików, liczba równoległych czytających procesów i objętość danych mogą rosnąć bez przeprojektowania czegokolwiek. Dwa razy więcej plików to dwa razy dłuższy przebieg, dwa razy więcej podów — dwa razy krótszy. Do momentu, w którym trafi się na jedną z dwóch granic.
Pierwsza granica leży w podzie, a nie w object storage. Jeden pod czytający ośmioma połączeniami naraz wyciągnął 1007 MiB/s, czyli 22,7 GiB w 22,6 sekundy. Ani bucket, ani sieć wewnątrz regionu tego nie zostały obciążone — najwięcej kosztował proces montujący, który w szczycie zajął około trzech rdzeni oraz około gigabajta pamięci RAM. Dlatego skutecznie przyspieszy się ten proces, dokładając replik, a nie wątki w jednym podzie.
Druga granica to liczba obiektów w buckecie. Powyżej miliona operacje na przestrzeni kluczy plików zaczynają spowalniać.
Czego nie naprawi więcej podów
Trzy przypadki, w których ten układ trzeba zamienić na inny, a nie dostroić.
Wielokrotne przejścia po tym samym zbiorze. Trening przez kilkanaście epok pobrałby te same bajty kilkanaście razy. To jest warstwa do przygotowania danych; żeby karmić nią GPU, trzeba je wcześniej zmaterializować bliżej obliczeń.
Miliony drobnych obiektów. Przy 45 ms na open() i około 150 MiB/s odczytu próg opłacalności wypada koło 7 MiB. Poniżej tego rozmiaru płaci się głównie za czekanie, więc zbiór złożony z milionów małych plików stoi w kolejce do sieci, zamiast liczyć — trzeba go najpierw upakować w większe obiekty.
Wiele zespołów w jednym klastrze. Klucze są wspólne dla całego sterownika, więc uprawnienia różnicuje się wyłącznie tym, który PVC gdzie się montuje.
Podsumowanie
- Wzorzec „duże niezmienne pliki, losowy odczyt fragmentów” należy do object storage, nie do block storage ani NFS.
- Indeks trzymaj w relacyjnej bazie. Listowanie bucketa w runtime nie skaluje się i nie daje ani selekcji, ani pochodzenia danych.
- Montuj read-only pod identyczną ścieżką we wszystkich środowiskach; zapisy kieruj przez SDK, bo mount na S3 nie jest pełnym systemem plików.
- Cache danych na mouncie włączaj tylko przy wielokrotnych przejściach po tym samym podzbiorze — i nigdy na dysku węzła bez limitu rozmiaru. Jeśli zbiór roboczy jest mały i znany z góry, tańszy jest
lru_cachew procesie. - Trzymaj compute i storage w jednej infrastrukturze. Transfer potrafi kosztować tyle, co przetwarzanie.
Ta architektura obsługuje etap przygotowania danych i ich dostępu do inferencji, nie pętlę treningową. Przywiązuje do jednej infrastruktury, ale w zamian daje wydajny proces i odtwarzalny wynik.
Źródła i linki
Dane i instrument
- KiDS — Kilo-Degree Survey · Data Release 4 — przegląd zakończony, dane publiczne; programy ESO 177.A-3016, 177.A-3017, 177.A-3018
- VLT Survey Telescope (VST) · OmegaCAM
- ESO — archiwum i materiały publiczne
- SDSS — przegląd, względem którego KiDS sięga 2,5 magnitudo głębiej
Infrastruktura
Narzędzia
- Mountpoint for Amazon S3 CSI Driver — Apache-2.0, działa z każdym endpointem S3-kompatybilnym
- Mountpoint — semantyka systemu plików — czego S3 nie potrafi udawać
- AWS Open Source
- Container Storage Interface (CSI) · Persistent Volumes — dokumentacja Kubernetesa
- boto3 · Astropy
Grafiki
- Nagłówek: Omega Centauri — VST/OmegaCAM, © ESO, CC BY 4.0
- Mapa nieba: zasięg przeglądów VST, © ESO, CC BY 4.0
