Τα 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.
Κάθε 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και εμφανίζεται μόνο σε ένα eventPulled.
Τι είναι τα 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, μία φορά ανά volume | Registry pull + unpack, μία φορά ανά node |
| Κόστος όταν είναι warm | Ίδιο με το cold | Ίδιο με το cold | 0 | 0 |
| Επιπλέον κινούμενα μέρη | Credentials για το Hub, egress | Ένα mirror που πρέπει να τρέχεις | StorageClass, populate Job, node pinning με RWO | Κανένα πέρα από το registry που ήδη έχεις |
| Versioning | Revision του 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, warm | 0s | 0s |
| PVC γεμισμένο από πριν | 0s | 0s |
| HTTP download μέσα στο cluster | 2.2s (1.3 έως 3.4) | 4.7s (4.5 έως 9.0) |
| Image volume, cold | 5.5s (5.2 έως 5.7) | 17.8s (17.6 έως 18.0) |
| Download από το Hugging Face Hub | 85.3s | 258.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 |
|---|---|---|
| PVC | 0s | 61s |
| Image volume, warm | 0s | 69s |
| HTTP download μέσα στο cluster | ~6s | 75s |
| Image volume, cold | 19.6s | 85s |
| Download από το Hugging Face Hub (0.5B) | 87s | 136s |
Το 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 MiB | Insufficient space in /dev/shm for shared-memory allocation: 160 MiB required, 64 MiB free | emptyDir με medium: Memory στο /dev/shm |
| vLLM CPU v0.32.0 | Ready, και μετά HTTP 500 στο πρώτο request: assert block_size * BLOCKS_PER_KV_BLOCK == KV_CACHE_BLOCK_SIZE | v0.33.0, διορθώθηκε με το vllm#61992 |
| Το 1.5B με 8Gi | OOMKilled κατά το engine init | Limit 12Gi |
curl χωρίς -L προς το Hub | 3 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:
- KServe - ML serving σε production πάνω σε Kubernetes, από sklearn μέχρι LLMs
- llm-d - Kubernetes-native distributed LLM inference σε κλίμακα
- Immutable Kubernetes Nodes ως OCI Images - Μια ματιά από κοντά στο bootc
