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

AstroMLOps
Kubernetes
S3
MLOps
Jak udostępnić terabajty obrazów nieba kilkudziesięciu podom Kubernetesa naraz, tak żeby cały pipeline AI czytał je pod tą samą ścieżką, która będzie spójna też z innymi środowiskami?
Opublikowano

5 sierpnia 2026

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.

Zasięg przeglądów wykonanych teleskopem VST naniesiony na mapę całego nieba.

Zasięg przeglądów VST na tle całego nieba. Każde pole to setki kafelków.

© ESO · CC BY 4.0

Dla infrastruktury liczą się trzy własności tych danych obserwacyjnych — i spotkasz je w wielu domenach poza astronomią:

  1. 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.
  2. Są niezmienne. Kafelek raz opublikowany nigdy się nie zmienia. To upraszcza wszystko: spójność, cache, replikację.
  3. 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.

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

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ówaccessModes: 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: 200Gi

Weryfikacja, ż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 up

Minimum, 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].data

Koszt 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-only

Warunek 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_cache w 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

Infrastruktura

Narzędzia

Grafiki