Τα image volumes έγιναν GA στο Kubernetes 1.36 - Αξίζει να διανέμεις τα μοντέλα σου ως OCI images;
~/posts/kubernetes-image-volumes-model-weights.el.md11 min · 2251 words

Τα image volumes έγιναν GA στο Kubernetes 1.36 - Αξίζει να διανέμεις τα μοντέλα σου ως OCI images;

// Το Kubernetes 1.36 έκανε GA τα image volumes, οπότε τα model weights μπορούν να ταξιδεύουν μέσα από το ίδιο registry με τα containers σου. Τα μέτρησα απέναντι σε downloads από το Hugging Face, σε ένα HTTP mirror μέσα στο cluster και σε ένα PVC γεμισμένο από πριν, με vLLM σε kind: στα warm nodes η παράδοση δεν κοστίζει τίποτα, στα cold nodes είναι 2.5 έως 4 φορές πιο αργά από ένα απλό download.

$ date

Κάθε model server που έχω τρέξει σε Kubernetes ξεκινάει με τον ίδιο τρόπο: ένα init container κατεβάζει από κάπου αρκετά gigabytes από weights, κάθε φορά που ξεκινάει ένα pod, σε κάθε node. Οι εναλλακτικές ήταν ένα PVC που το γεμίζεις και το καρφώνεις σε ένα node, ή weights ενσωματωμένα σε ένα runtime image 10 GB, που το ξαναχτίζεις σε κάθε νέα έκδοση του μοντέλου.

Τα image volumes έγιναν GA στο Kubernetes 1.36. Κάνεις push τα weights ως OCI image, το δηλώνεις στο pod spec, και το kubelet το κάνει mount read-only. Ίδιο registry, ίδια pull secrets, ίδια digests με τα containers σου. Η ερώτηση που ήθελα να απαντήσω είναι απλή: είναι πιο γρήγορο, και πότε αξίζει μια platform team να κάνει την αλλαγή;

Προέλευση: κάθε νούμερο παρακάτω βγήκε από ένα kind cluster (kindest/node:v1.37.0, containerd 2.3.4) σε laptop με Apple Silicon, με ένα τοπικό registry στο δίκτυο του kind, vLLM CPU v0.33.0 και τα Qwen2.5-Instruct 0.5B (1 GB) και 1.5B (3 GB). Τα απόλυτα νούμερα είναι νούμερα laptop. Το αποτέλεσμα είναι οι αναλογίες.

TL;DR#

  • Warm node: το image volume κοστίζει 0 δευτερόλεπτα παράδοσης, όσο και ένα PVC γεμισμένο από πριν, χωρίς populate Job και χωρίς storage class.
  • Cold node: το pull του weights image πήρε 5.5s για 1 GB και 17.8s για 3 GB. Ένα απλό HTTP download των ίδιων αρχείων από το ίδιο δίκτυο πήρε 2.2s και 4.7s. Τα cold image volumes είναι 2.5 έως 4 φορές πιο αργά.
  • Internet: το download από το Hugging Face Hub πήρε 85s και 259s. Οτιδήποτε τοπικό το κερδίζει κατά μία τάξη μεγέθους.
  • Στα μικρά μοντέλα κυριαρχεί το runtime: το engine init του vLLM πήρε 25 έως 40 δευτερόλεπτα σε κάθε run, περισσότερο από κάθε τοπική μέθοδο παράδοσης.
  • Το volume είναι το root του image. Χωρίς subPath, το vLLM δεν βρίσκει το μοντέλο σου.
  • Το pull δεν φαίνεται στα pod conditions. Γίνεται μετά το PodReadyToStartContainers και εμφανίζεται μόνο σε ένα event Pulled.

Τι είναι τα image volumes#

Ένα pod volume τύπου image δείχνει σε ένα OCI image reference. Το kubelet το κάνει pull μέσω του container runtime και κάνει mount το filesystem του, read-only, στα containers που το ζητάνε. Η πορεία του KEP-4639: alpha στο 1.31, beta στο 1.34, stable στο 1.36. Από την πλευρά του runtime, το containerd πρόσθεσε υποστήριξη στο v2.1.0 και subPath mounts στο v2.2.0.

Το KServe το χρησιμοποιεί ήδη: τα storage URIs oci+native:// έγιναν image volume mounts στο v0.20.0. Δεν χρειάζεσαι KServe για να το χρησιμοποιήσεις, και αυτό το post δεν το χρησιμοποιεί.

Τι ΔΕΝ είναι#

  • Δεν είναι lazy loading. Ολόκληρο το image γίνεται pull και unpack πριν ξεκινήσει οποιοδήποτε container.
  • Δεν είναι model registry. Είναι registry για bytes. Το versioning είναι η πειθαρχία σου στα tags και στα digests.
  • Δεν είναι cache που το ελέγχεις εσύ. Το cache είναι το containerd store του node, και το πότε θα σβηστεί το αποφασίζει το image GC του kubelet.

Οι επιλογές που σύγκρινα#

ΚριτήριοInit download (Hub)Init download (HTTP μέσα στο cluster)PVC γεμισμένο από πρινImage volume
Κόστος όταν είναι coldΤαχύτητα internet, σε κάθε εκκίνησηΤαχύτητα LAN, σε κάθε εκκίνησηPopulate Job, μία φορά ανά volumeRegistry pull + unpack, μία φορά ανά node
Κόστος όταν είναι warmΊδιο με το coldΊδιο με το cold00
Επιπλέον κινούμενα μέρηCredentials για το Hub, egressΈνα mirror που πρέπει να τρέχειςStorageClass, populate Job, node pinning με RWOΚανένα πέρα από το registry που ήδη έχεις
VersioningRevision του repoΌ,τι σερβίρει το mirrorΌ,τι γράφτηκεTag ή digest, immutable

Με δυο λόγια: με τα image volumes δέχεσαι μια πιο αργή πρώτη εκκίνηση ανά node και παίρνεις μηδενικό κόστος σε κάθε επόμενη, με το ίδιο supply chain που έχουν τα containers σου.

Το demo#

flowchart TB
    subgraph Host["Laptop (Docker Desktop)"]
        REG["kind-registry<br/>weights + vLLM images"]
        HUB["huggingface.co"]
    end
    subgraph Kind["kind: imgvol-demo (K8s 1.37.0, containerd 2.3.4)"]
        subgraph CP["control-plane"]
            FS["fileserver (nginx)<br/>same weights over HTTP"]
        end
        subgraph W["worker"]
            IV["vLLM pod<br/>image volume"]
            DL["vLLM pod<br/>init download"]
            PVC["vLLM pod<br/>PVC"]
        end
    end
    REG -->|pull| IV
    FS -->|HTTP| DL
    HUB -->|HTTPS| DL
    FS -->|populate Job| PVC

Το weights image δεν έχει μέσα τίποτα άλλο πέρα από τα αρχεία:

# data-only image: no shell, no runtime, just the weights under /models
FROM scratch
COPY model/ /models/

Το mirror μέσα στο cluster είναι ένα nginx pod που σερβίρει το ίδιο weights image μέσα από το δικό του image volume. Έτσι το download και το pull δουλεύουν πάνω στα ίδια bytes και στο ίδιο Docker network, οπότε η σύγκριση αφορά τη διαδρομή της παράδοσης, όχι την πηγή.

Παγίδα 1: το volume είναι το root του image#

Το πρώτο pod έκανε mount το image στο /models και έδειξε εκεί το vLLM. Έσκασε:

pydantic_core._pydantic_core.ValidationError: 1 validation error for ModelConfig
  Value error, Invalid repository ID or local directory specified: '/models'.
   - For Hugging Face models: ensure the presence of a 'config.json'.

Το image volume κάνει mount το root filesystem του image. Το Dockerfile έβαλε τα αρχεία κάτω από το /models, άρα μέσα στο pod βρίσκονταν στο /models/models/config.json. Η λύση είναι το subPath, που θέλει Kubernetes 1.33+ και containerd 2.2+:

      volumeMounts:
        - name: weights
          mountPath: /models
          # the volume is the image root, files live under /models inside it
          subPath: models
          readOnly: true
  volumes:
    - name: weights
      image:
        reference: localhost:5001/weights/qwen2.5-1.5b:v1
        pullPolicy: IfNotPresent

Το oci+native:// του KServe κάνει mount με subPath: models για τον ίδιο λόγο. Χτίσε τα weights images σου με ένα σταθερό directory και βάλε subPath παντού, ή κάνε copy στο / και ξέχνα το. Διάλεξε ένα convention για όλη την πλατφόρμα.

Όρισε ρητά το pullPolicy. Σύμφωνα με το documentation, το default για το :latest είναι Always, και το Always πετάει σε κάθε εκκίνηση το cache του node, που είναι όλο το νόημα του feature.

Αποτέλεσμα: μία γραμμή YAML, και ένα μήνυμα crash που δεν λέει λέξη για volumes.

Παγίδα 2: το pull κρύβεται ανάμεσα στα conditions#

Έγραψα ένα μικρό timeline.sh που τυπώνει κάθε pod condition σε σχέση με τη στιγμή της δημιουργίας, τα events Pulled του kubelet και τους χρόνους που μετράει το ίδιο το vLLM. Ένα cold run με το 1.5B:

== qwen-imagevolume-cold-15b: seconds since creation (API timestamps, 1s precision)
  scheduled          0
  sandbox_ready      0
  initialized        0
  container_started  20
  ready              85
== image pulls (kubelet events)
  localhost:5001/weights/qwen2.5-1.5b:v1       19.612s
  localhost:5001/vllm/vllm-openai-cpu:v0.33.0  cached
== runtime
  Loading weights took 0.66 seconds
  init engine (profile, create kv cache, warmup model) took 40.72 s

Τα PodReadyToStartContainers και Initialized είναι και τα δύο true στο δευτερόλεπτο 0. Το container ξεκινάει στο δευτερόλεπτο 20. Τα 19.6 δευτερόλεπτα ανάμεσα είναι το pull των weights, και κανένα condition δεν τα καλύπτει. Στο πρώτο run, όπου ήταν cold και το vLLM image, τα events δείχνουν τη σειρά: πρώτα το weights image, μετά το runtime image, μετά το container.

Αποτέλεσμα: αν τα startup dashboards σου είναι χτισμένα πάνω σε pod conditions, τα pulls των image volumes μοιάζουν με αργό container runtime. Στήσε καλύτερα τα alerts στη διάρκεια του event Pulled ή στα image pull metrics του kubelet.

Τα νούμερα#

Μόνο η παράδοση#

Για να μετρήσω την παράδοση χωρίς τον θόρυβο του vLLM, το delivery.sh ξεκινάει pods που αγγίζουν μόνο τα weights: ένα container που κάνει list το image volume, ή ένα που κατεβάζει τα αρχεία με curl και αναφέρει τον χρόνο. Cold σημαίνει ότι πριν από κάθε γύρο έσβησα τα weights images από τον worker με crictl rmi. Τρεις γύροι, δύο για το Hub:

Μέθοδος0.5B (1 GB)1.5B (3 GB)
Image volume, warm0s0s
PVC γεμισμένο από πριν0s0s
HTTP download μέσα στο cluster2.2s (1.3 έως 3.4)4.7s (4.5 έως 9.0)
Image volume, cold5.5s (5.2 έως 5.7)17.8s (17.6 έως 18.0)
Download από το Hugging Face Hub85.3s258.6s (256 έως 261)

Διάμεσοι, με το εύρος σε παρένθεση. Το populate Job για το PVC πήρε 9s και 15s, μία φορά.

Το cold image volume είναι η πιο σταθερή γραμμή του πίνακα και η πιο αργή από τις τοπικές. Το benchmark του KServe είδε το ίδιο σε x86 με artifacts από 2 έως 140 GB, περίπου 2x πιο αργό από το S3 στα 140 GB, και το εξηγεί: το containerd γράφει το layer blob και μετά το κάνει unpack σε ένα snapshot, δύο περάσματα πάνω στον δίσκο, ενώ ένα download κάνει stream μία φορά. Τα layers είναι επίσης gzip-compressed by default. Το event Pulled αναφέρει 776 MB για 954 MB αρχείων, άρα το node κάνει και αποσυμπίεση. Δεν απομόνωσα ποιο από αυτά κοστίζει περισσότερο.

Από το pod μέχρι το Ready#

Οι ίδιες μέθοδοι, με το vLLM να σερβίρει το μοντέλο 1.5B, ένα run η καθεμία:

ΜέθοδοςΠαράδοσηReady
PVC0s61s
Image volume, warm0s69s
HTTP download μέσα στο cluster~6s75s
Image volume, cold19.6s85s
Download από το Hugging Face Hub (0.5B)87s136s

Το PVC και το warm image volume δεν παραδίδουν τίποτα, και παρ’ όλα αυτά διαφέρουν κατά 8 δευτερόλεπτα. Αυτός είναι ο θόρυβος από run σε run στο engine init του vLLM: 25 έως 40 δευτερόλεπτα για profiling, δέσμευση του KV cache και warmup σε CPU. Το ίδιο το φόρτωμα των weights πήρε κάτω από 2.5 δευτερόλεπτα σε κάθε run. Για μικρά μοντέλα σε CPU, το cold start είναι το runtime. Η παράδοση μεγαλώνει μαζί με το μοντέλο, το runtime όχι: στο benchmark του KServe το cold path του image volume πήρε 443 δευτερόλεπτα στα 14 GB, ενώ το warm path έμεινε στα 11 δευτερόλεπτα από τα 2 GB μέχρι τα 140 GB.

Όσα έσπασαν και δεν έφταιγαν τα image volumes#

ΠρόβλημαΣύμπτωμαΛύση
Το /dev/shm είναι 64 MiBInsufficient space in /dev/shm for shared-memory allocation: 160 MiB required, 64 MiB freeemptyDir με medium: Memory στο /dev/shm
vLLM CPU v0.32.0Ready, και μετά HTTP 500 στο πρώτο request: assert block_size * BLOCKS_PER_KV_BLOCK == KV_CACHE_BLOCK_SIZEv0.33.0, διορθώθηκε με το vllm#61992
Το 1.5B με 8GiOOMKilled κατά το engine initLimit 12Gi
curl χωρίς -L προς το Hub3 GB «κατέβηκαν» σε 1.8 δευτερόλεπταΤο Hub απαντάει με redirect στο CDN του. Τα πρώτα μου νούμερα για το Hub ήταν 32 KB από σελίδες redirect
Το kind δεν καθαρίζει ποτέ imagesΟ δίσκος του laptop στο 100%Το kind ορίζει imageGCHighThresholdPercent: 100. Κάθε weights image μένει μέχρι να του κάνεις crictl rmi

Το τελευταίο είναι προειδοποίηση και για πραγματικά clusters. Τα weights images είναι μεγάλα, και πλέον το image GC του node αποφασίζει πόσο θα ζήσει το warm cache σου. Υπολόγισε τον δίσκο των nodes για τα μοντέλα που σκοπεύεις να κρατάς warm, όχι μόνο για τα container images.

Πότε να διανέμεις μοντέλα ως OCI images#

Χρησιμοποίησε image volumes ότανΜείνε σε downloads ή PVCs όταν
Το ίδιο μοντέλο ξαναπέφτει στα ίδια nodes: rollouts, restarts, autoscaling ξανά προς τα πάνωΚάθε pod πέφτει σε φρέσκο node και το image δεν ξαναχρησιμοποιείται ποτέ
Σήμερα κατεβάζεις από το Hub ή από ένα απομακρυσμένο bucketΈχεις ένα γρήγορο object store ή mirror δίπλα στα nodes και αυτό που βελτιστοποιείς είναι το cold start
Θέλεις τα weights versioned, υπογεγραμμένα και mirrored όπως τα containersΤα weights αλλάζουν κάθε ώρα, fine-tunes που δεν τα θέλεις σε registry
Τα RWO PVCs καρφώνουν τα pods σε nodes και τα populate Jobs είναι toilΤρέχεις ήδη RWX storage που σερβίρει καλά τα μοντέλα

Υπέρ και κατά#

ΥπέρΚατά
Μηδενικό κόστος παράδοσης σε warm nodes, για οποιοδήποτε μέγεθος μοντέλουCold pulls 2.5 έως 4x πιο αργά από ένα απλό download στο ίδιο δίκτυο
Ένα supply chain: registry, pull secrets, digests, mirrorsΑόρατα στα pod conditions, φαίνονται μόνο σε events και metrics
Χωρίς storage class, χωρίς populate Job, χωρίς node pinningΤο cache είναι ο δίσκος του node, και το κουμαντάρει το image GC
GA και ανοιχτό by default στο 1.36+, χωρίς feature gateΠαγίδες με subPath και pullPolicy, με error messages που δείχνουν αλλού

Το repository του demo#

Όλα τα παραπάνω βρίσκονται στο srekubecraft-demo/image-volumes:

task up                         # kind + local registry, both models, mirrors, PVCs
task run M=imagevolume          # one vLLM pod to Ready with the phase timeline
task run M=pvc SIZE=1.5b        # M=imagevolume|pvc|lan|hf
task delivery R=1 HF=1          # delivery-only round, appends to results/delivery.tsv
task cold                       # prints the crictl rmi for the next cold round

Το cluster θέλει περίπου 25 GB δίσκο και 16 GB μνήμη για τα runs με το 1.5B. Το task cold τυπώνει την εντολή του eviction αντί να την τρέξει, γιατί το να σβήσεις images από ένα node είναι απόφαση που πρέπει να τη βλέπεις.

Συμπέρασμα#

Τα image volumes είναι ο πρώτος native τρόπος να διανέμεις model weights μέσα από την υποδομή που ήδη τρέχεις για τα containers. Σε warm node δεν κοστίζουν τίποτα, και σε cold node κοστίζουν περισσότερο από ένα απλό download. Το αν συμφέρει αυτή η ανταλλαγή εξαρτάται από το πόσο συχνά ένα μοντέλο επιστρέφει στο ίδιο node.

Στις περισσότερες πλατφόρμες inference επιστρέφει: τα rollouts, τα restarts και τα scale-ups πέφτουν σε nodes που έχουν ήδη σερβίρει το μοντέλο. Αν σήμερα κατεβάζεις από το Hub σε κάθε εκκίνηση, πήγαινε σε ένα registry δίπλα στα nodes σου: στα δικά μου runs ακόμα και το cold pull ήταν 14 φορές πιο γρήγορο από το Hub. Αν σερβίρεις ήδη τα weights από storage δίπλα στα nodes, κάνε την αλλαγή για το supply chain, όχι για την ταχύτητα.

Πριν το κάνεις, όρισε ρητά τα subPath και pullPolicy, πρόσθεσε τη διάρκεια του event Pulled στα startup dashboards σου, και υπολόγισε τους δίσκους των nodes για τα μοντέλα που θέλεις να κρατάς warm.


Αν σου φάνηκε χρήσιμο, ίσως σε ενδιαφέρουν και τα σχετικά posts μου για AI workloads σε Kubernetes:

Kubernetes

EOF · 11 min · 2251 words
$ continue exploring
Red teaming στο δικό μου agent sandbox - 15 τεχνικές exfiltration εναντίον του OpenShell // Τρέχω έναν agent, ένα Telegram bot, μέσα σε sandbox του NVIDIA OpenShell. Οπότε πέρασα ένα απόγευμα χτυπώντας το: 15 τεχνικές exfiltration και απόδρασης πάνω σε ένα αναλώσιμο αντίγραφο, με τα πραγματικά OCSF logs για κάθε αποτέλεσμα. Τι κόβει το κλουβί χωρίς δεύτερη κουβέντα, ποια δύο κενά αφήνει, και το L7 rule που κλείνει το χειρότερο. #sre #security #openshell →
$ grep -r --related
// author
Nick Nikolakakis
Nick Nikolakakis Principal SRE, Platform & AI Engineer // Writing about Kubernetes, SRE practices, cloud-native infrastructure, and AI systems
$ exit█ logout connection closed. cd ~/home ↵
ESC
Type to search...