Modelplane - Fleet inference σε πολλά clusters, demo χωρίς ούτε μία GPU
// Μια hands-on γνωριμία με το Modelplane, το control plane για AI inference πάνω σε fleet από GPU clusters, χτισμένο σε Crossplane. Δύο regions σε Kind με ψεύτικες DRA GPU, το contract ανάμεσα σε platform team και developers γραμμένο στο λεξιλόγιο του ίδιου του DRA, drain σε επίπεδο fleet, και η παγίδα στο release που σου κοστίζει δύο APIs.
Το serving ενός μοντέλου σε ένα cluster είναι λυμένο πρόβλημα. Διαλέγεις engine, του δίνεις μια GPU, στέλνεις το traffic πάνω του. Γι’ αυτή τη διαδρομή έχω γράψει ήδη δύο φορές, με το KServe και με το llm-d.
Αυτό που δεν έχει λύσει κανείς καθαρά είναι το fleet. Οι GPU είναι δυσεύρετες, οπότε παίρνεις capacity απ’ όπου βρεις: λίγο σε EKS, λίγο σε GKE, ένα reserved block σε κάποιο neocloud, ένα rack που έχεις ήδη. Τώρα έχεις πέντε clusters που το καθένα θέλει ακριβώς το ίδιο serving stack, και ένα μόνιμο πρόβλημα matchmaking: ποιο μοντέλο τρέχει σε ποιο hardware, σε ποιο region. Από κάτω κρύβονται δύο διαφορετικές δουλειές που οι περισσότερες πλατφόρμες τις κάνουν μία: το platform team πρέπει να δημοσιεύει τι hardware υπάρχει, και το ML team να ζητάει το hardware που χρειάζεται.
Το Modelplane είναι η απάντηση της Upbound, και ο λόγος που του αφιέρωσα ένα Σαββατοκύριακο δεν είναι το προϊόν. Είναι ο μηχανισμός του contract. Το Modelplane χρησιμοποιεί το λεξιλόγιο του Dynamic Resource Allocation του Kubernetes σαν API ανάμεσα σε ένα platform team και σε όσους καταναλώνουν την πλατφόρμα του, πέρα από τα όρια ενός cluster. Η ιδέα αυτή πάει πολύ πιο μακριά από τις GPU.
Το έτρεξα ολόκληρο σε ένα laptop, χωρίς GPU και χωρίς cloud account.
Ποιον αφορά αυτό το post;#
Το post απευθύνεται σε:
- Platform Engineers που πρέπει να δώσουν στα ML teams self-service για GPU, χωρίς να γίνουν οι ίδιοι η ουρά των tickets για κάθε μοντέλο
- SREs που τρέχουν πάνω από ένα GPU cluster και βαρέθηκαν να στήνουν με το χέρι το ίδιο serving stack σε κάθε region
- Όσους αξιολογούν το DRA ως platform contract, και όχι απλώς ως τον αντικαταστάτη του NVIDIA device plugin
- Χρήστες του Crossplane που θέλουν να δουν πώς μοιάζει ένα σοβαρό v2 Configuration στην πράξη
TL;DR#
- Το Modelplane είναι ένα control plane με άδεια Apache-2.0 για AI inference πάνω σε fleet από clusters. Το έφτιαξε η Upbound και είναι χτισμένο εξ ολοκλήρου σε compositions του Crossplane v2. Δικό του operator δεν έχει.
- Το API χωρίζεται με βάση το ποιος έχει τι: τα cluster-scoped kinds ανήκουν στο platform team, τα namespaced στους developers. Αυτός ο διαχωρισμός είναι το ίδιο το όριο του RBAC, όχι απλώς documentation.
- Το platform team δημοσιεύει ένα λεξιλόγιο για devices με τους όρους του ίδιου του DRA, οι developers γράφουν πάνω του CEL σε μορφή DRA, και το πραγματικό DRA admission στο workload cluster έχει πάντα τον τελευταίο λόγο.
- Τρέχει χωρίς GPU σε Kind, με
source: Existingκαι τον upstreamdra-example-driver, οπότε αξιολογείς ολόκληρο το μοντέλο με κόστος λίγη RAM. - Είναι
v1alpha1και το λέει. Έπεσα σε μια παγίδα στο packaging του release που σου κοστίζει σιωπηλά δύο από τα δεκατέσσερα APIs.
Η μορφή του προβλήματος#
Δύο δεδομένα καθορίζουν όλο τον σχεδιασμό.
Οι GPU είναι σκόρπιες. Το capacity βρίσκεται εκεί που κατάφερες να το πάρεις: διαφορετικά clouds, διαφορετικά regions, on-prem, ένα neocloud με capacity reservation. Να τα μαζέψεις σε ένα μέρος δεν γίνεται.
Τα μοντέλα που αξίζει να σερβίρεις σε τέτοια κλίμακα δεν χωράνε σε ένα μηχάνημα. Άρα ένα «replica» δεν είναι ένα pod. Είναι ένα gang από pods που πρέπει να τοποθετηθούν μαζί και να συντονίζονται πάνω από ένα interconnect fabric.
Βάλε τα δύο μαζί, και το platform team κληρονομεί μια μόνιμη δουλειά matchmaking. Όλη η μορφή του Modelplane είναι χτισμένη γύρω από το να μένει αυτή η δουλειά χωριστά από τη δουλειά του developer.
Τι είναι το Modelplane;#
Το Modelplane είναι ένα control cluster που κάθεται πάνω από ένα fleet από GPU clusters, με ένα μοναδικό front door συμβατό με το OpenAI API. Άδεια Apache 2.0, δημιούργημα της Upbound, με δηλωμένη πρόθεση να περάσει σε ουδέτερο foundation. Το status είναι v1alpha1, και το README γράφει «early development» χωρίς περιστροφές.
Το API group είναι το modelplane.ai/v1alpha1, και κάθε kind είναι ένα Crossplane v2 XRD (apiextensions.crossplane.io/v2) με το αντίστοιχο composition κάτω από το apis/<plural>/. Go controller με _types.go δεν υπάρχει πουθενά στο repo. Το API είναι το composition layer, και όλη η λογική ζει σε δεκατέσσερα composition functions γραμμένα σε Python, πακεταρισμένα μέσα στο Configuration.
Το API, χωρισμένο ανά ρόλο#
| Kind | Short | Scope | Owner |
|---|---|---|---|
InferenceClass | icl | Cluster | platform |
InferenceCluster | ic | Cluster | platform |
InferenceGateway | ig | Cluster | platform |
ModelDeployment | md | Namespaced | developer |
ModelService | ms | Namespaced | developer |
ModelCache | mc | Namespaced | developer |
ModelReplica | mr | Namespaced | composed |
ModelEndpoint | me | Namespaced | composed |
ServingStack | ss | Namespaced | composed |
EKSCluster / GKECluster / AKSCluster / NebiusCluster / VultrCluster | Namespaced | composed |
Η ιεραρχία είναι σκόπιμα ό,τι ξέρεις από το core Kubernetes, ένα scope πιο πάνω: το ModelDeployment προς ModelReplica προς ModelService προς ModelEndpoint αντιστοιχεί στο Deployment προς Pod προς Service προς Endpoint, μόνο που απλώνεται σε ένα fleet αντί για το εσωτερικό ενός cluster.
Πρόσεξε τι είναι cluster-scoped. Ακριβώς τα kinds που ανήκουν στο platform team. Ό,τι αγγίζει ένας developer είναι namespaced. Το scope ταυτίζεται με το ποιος έχει τι, άρα το μοντέλο ιδιοκτησίας επιβάλλεται με RBAC που ξέρεις ήδη να γράφεις.
Με δυο λόγια: αν έχεις προσπαθήσει ποτέ να δώσεις στους developers ένα self-service API για GPU, έχεις γράψει ένα enum με t-shirt sizes (gpu: large) και μετά συντηρούσες έναν πίνακα που το μεταφράζει σε πραγματικό hardware. Το στοίχημα του Modelplane είναι ότι το λεξιλόγιο δεν χρειάζεται να το εφεύρεις καθόλου.
Το pattern του DRA, αυτό που αξίζει να κλέψεις#
Αυτή είναι η ιδέα που θα κρατούσα ακόμα κι αν το project εξαφανιζόταν αύριο.
Ένα InferenceClass είναι η δήλωση του platform team για το τι προσφέρει ένα pool. Κάθε εγγραφή αντιγράφει αυτό που δημοσιεύει ένας DRA driver σε ένα ResourceSlice, μία εγγραφή ανά είδος device και όχι ανά φυσικό device:
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
name: synthetic-gpu
spec:
description: "Fake GPU via dra-example-driver (no real hardware)"
devices:
- name: gpu
claim: DRA
driver: gpu.example.com
deviceClassName: gpu.example.com
count: 1
capacity:
memory: { value: "80Gi" }
Από την πλευρά του, ο developer γράφει CEL πάνω στα ίδια attributes και στο ίδιο capacity, μέσα σε ένα ModelDeployment:
nodeSelector:
devices:
- name: gpu
count: 1
selectors:
- cel: |
device.capacity["gpu.example.com"].memory.compareTo(quantity("20Gi")) >= 0
Το upstream documentation το λέει ρητά, αυτή είναι «the same expression an ML engineer would write in a DRA ResourceClaim». Άρα το pattern είναι:
Το platform team δημοσιεύει ένα λεξιλόγιο με τους όρους του ίδιου του DRA. Όσοι το καταναλώνουν γράφουν πάνω του CEL σε μορφή DRA. Το control plane ταιριάζει τα δύο, και το πραγματικό DRA admission στο workload cluster παραμένει αυτό που αποφασίζει.
Τρεις ιδιότητες το κάνουν καλύτερο από το enum:
- Το λεξιλόγιο δεν είναι επινοημένο. Είναι αυτό που αναφέρει ήδη ένας πραγματικός driver σε ένα πραγματικό node, οπότε το abstraction δεν μπορεί να ξεφύγει από το hardware και δεν χρειάζεται πίνακα μετάφρασης.
- Το contract είναι discoverable. Τα keys που βάζει ένα platform team σε ένα class είναι το contract. Ένα
nodeSelectorταιριάζει με ένα pool μόνο αν το class δημοσιεύει αυτό που ζητάει. Αν προσθέσεις ένα attribute, το contract επεκτείνεται χωρίς καμία αλλαγή στο API. - Η ίδια έκφραση δουλεύει και στα δύο επίπεδα. Ένα φίλτρο σε επίπεδο fleet και ένα
ResourceClaimσε επίπεδο cluster γράφονται πανομοιότυπα, οπότε έχεις να μάθεις ένα πράγμα.
claim: Synthetic, η τίμια έξοδος κινδύνου#
Ένα device μπορεί να δηλωθεί claim: Synthetic, που σημαίνει ότι ο scheduler το λαμβάνει υπόψη στο placement αλλά δεν το κάνει ποτέ claim. Υπάρχει για hardware που μετράει στο placement αλλά δεν έχει ακόμα DRA driver, με πιο προφανές παράδειγμα ένα InfiniBand fabric.
Αυτή είναι η πρακτική κίνηση που κάνει το pattern εφαρμόσιμο το 2026. DRA drivers δεν υπάρχουν για όλα, το placement όμως εξακολουθεί να εξαρτάται από αυτό το hardware. Αντί να κολλήσουν δίπλα έναν δεύτερο, παράλληλο μηχανισμό για το hardware χωρίς driver, κρατάνε ένα λεξιλόγιο και σημειώνουν ποιες εγγραφές γίνονται claim. Αξίζει να το αντιγράψεις: όταν υιοθετείς ένα upstream contract που ακόμα διαμορφώνεται, βάλε ένα flag για το «περιγράφεται αλλά δεν επιβάλλεται» αντί για δεύτερο schema.
Ένα σημείο που κόβει, και μου κόστισε την πρώτη προσπάθεια: ένα engine που το μόνο του device είναι Synthetic απορρίπτεται από τον scheduler. Χρειάζεσαι τουλάχιστον ένα device που να γίνεται claim.
Πώς κάνει scheduling#
Ο fleet scheduler είναι καθαρή συνάρτηση του state που παρατηρεί. Σε κάθε reconcile ξαναϋπολογίζει ολόκληρο το placement από το μηδέν, διαβάζοντας το deployment, κάθε InferenceCluster που έχει δημοσιεύσει capacity και κάθε ModelReplica που υπάρχει ήδη.
Η συνέπεια που μετράει επιχειρησιακά: τα υπάρχοντα replicas είναι δεδομένα εισόδου, όχι αποφάσεις. Ένα υγιές replica δεν μετακινείται ποτέ για να βελτιωθεί η συνολική εικόνα, ακόμα κι αν εμφανιστεί αργότερα καλύτερο cluster. Το placement δεν αλλάζει συνέχεια κάτω από ένα deployment που τρέχει.
Το matching γίνεται σε δύο στάδια:
- Τα clusters φιλτράρονται με το
clusterSelector.matchLabelsπάνω σε απλά Kubernetes labels κάθεInferenceCluster: region, tier, provider, compliance posture. Είναι οργανωτικά metadata, οπότε αρκεί ισότητα strings. - Τα pools φιλτράρονται με το DRA device matching που είδαμε παραπάνω.
Μετά, ο scheduler του ίδιου του workload cluster δένει πραγματικές GPU μέσω DRA. Αυτός ο διαχωρισμός είναι η βασική σχεδιαστική απόφαση. Ο scheduler του control plane απαντάει στο «θα μπορούσε αυτό το cluster να φιλοξενήσει αυτό το replica», όχι στο «ποια ακριβώς GPU παίρνει κάθε pod». Το contention σε επίπεδο device το αφήνει στο DRA admission του workload cluster, που έχει τον τελευταίο λόγο, και το επόμενο reconcile βλέπει το αποτέλεσμα.
Τέσσερις κανόνες που αξίζει να θυμάσαι απέξω:
- Το capacity χρεώνεται σε nodes, όχι σε GPU.
nodes = pods × copies. Ένα member που δεν κάνει claim κανένα DRA device κοστίζει μηδέν nodes. - Ένα engine δεν σπάει ποτέ σε περισσότερα από ένα pools. Τα μέλη ενός gang συντονίζονται πάνω από το fabric του pool τους, ο scheduler δεν μπορεί να συλλογιστεί για το fabric, άρα η ταυτότητα του pool είναι η πιο λεπτή διάκριση που έχει. Ένα σπασμένο gang κινδυνεύει να πέσει σε δύο fabrics, το collective δεν σχηματίζεται ποτέ, και κρεμάει χωρίς καθαρό error.
- Το placement είναι καρφωμένο, όχι σύσταση. Κάθε pod που γίνεται schedule παίρνει ένα
nodeSelectorπάνω στο node labelmodelplane.ai/pool. Χωρίς αυτό, ο scheduler του cluster θα μπορούσε να βάλει το pod σε οποιοδήποτε pool ταιριάζει, και η λογιστική του fleet θα ξέφευγε από την πραγματικότητα. - Retain, then fill. Τα υπάρχοντα replicas κρατάνε το cluster τους. Όσα λείπουν τοποθετούνται ένα ένα, στο επιλέξιμο cluster που φιλοξενεί τα λιγότερα replicas του συγκεκριμένου deployment: spread before pack.
Ένα replica δεν αλλάζει ποτέ cluster. Η μετακίνηση είναι πάντα delete-plus-create, όπως ακριβώς αντιμετωπίζει το Kubernetes ένα pod που το node του χάθηκε.
Γνωστοί περιορισμοί, και οι δύο fail-safe#
| Περιορισμός | Συνέπεια | Issue |
|---|---|---|
| Χρεώνεται ολόκληρο node ανά pod | Ένα pod που κάνει claim 1 από 8 GPU χρεώνει ολόκληρο το node, και οι υπόλοιπες GPU μένουν αναξιοποίητες σε engines μικρότερα από ένα node | #172 |
| Ένα engine δεν μπορεί να απλωθεί σε πολλά pools, ούτε καν πάνω στο ίδιο fabric | Αποκλείει GPU workers σε ένα pool με coordinator χωρίς GPU σε άλλο | #149 |
Και οι δύο υποεκτιμούν το capacity αντί να το κάνουν overcommit. Αν είναι να κάνεις λάθος, προς αυτή την κατεύθυνση το θέλεις.
Τι ΔΕΝ είναι το Modelplane#
- Δεν είναι inference engine. Τρέχει vLLM ή ό,τι άλλο του δείξεις, και δεν κάνει inject σχεδόν τίποτα: μόνο τη διεύθυνση στην οποία είναι προσβάσιμος ο leader ενός multi-node engine, για να μπορούν να συνδεθούν οι workers. Parallelism, quantisation και KV transfer είναι flags του engine που τα γράφεις εσύ.
- Δεν αντικαθιστά το KServe ή το llm-d. Εκείνα σερβίρουν μοντέλα σε ένα cluster. Αυτό τοποθετεί μοντέλα σε πολλά clusters και σου εγκαθιστά το αντίστοιχο stack.
- Δεν είναι αυτόνομος operator. Είναι ένα Crossplane Configuration. Χωρίς Crossplane, δεν έχει Modelplane.
- Δεν είναι production ready.
v1alpha1, όπως παραδέχεται και το ίδιο.
Το Modelplane απέναντι στις εναλλακτικές#
| Κριτήριο | Modelplane | KServe | llm-d | Kubernetes multi-cluster (Karmada και λοιπά) |
|---|---|---|---|---|
| Μονάδα του placement | replica σε ένα fleet | pod σε ένα cluster | pod σε ένα cluster | οποιοδήποτε workload σε πολλά clusters |
| Contract για το hardware | DRA attributes + CEL | resource requests | resource requests | γενικό |
| Model-aware routing | το αναθέτει στο serving stack | ενσωματωμένο | ο λόγος ύπαρξής του | κανένα |
| Κάνει provision clusters | ναι (EKS/GKE/AKS/Nebius/Vultr) | όχι | όχι | όχι |
| Runtime dependency | Crossplane v2 | κανένα | Gateway API | το δικό του control plane |
Με δυο λόγια: το Modelplane κάθεται ένα layer πάνω από το llm-d και το KServe, δεν τα ανταγωνίζεται. Αν έχεις ένα cluster, δεν το χρειάζεσαι. Από τη στιγμή που έχεις τρία, το πρόβλημα matchmaking που λύνει είναι αυτό που σου τρώει πραγματικά την εβδομάδα.
Το αποτύπωμα της εγκατάστασης, με ειλικρίνεια#
Εδώ κρύβεται το κόστος της υιοθέτησης. Το Modelplane έρχεται ως Crossplane Configuration (xpkg.upbound.io/modelplane/modelplane), και το documentation δηλώνει ξεκάθαρα ότι «Crossplane provides the reconciliation engine and package management». Το Configuration τραβάει δεκαέξι provider packages ως dependencies, και τα περισσότερα υπάρχουν μόνο για να κάνουν provision clusters που μπορεί να μην κάνεις provision ποτέ.
Το Crossplane χρειάζεται μόνο στο control cluster. Τα inference clusters δεν ξέρουν τίποτα γι’ αυτό. Παίρνουν συνηθισμένα Helm releases και Kubernetes objects μέσω του provider-helm και του provider-kubernetes, με authentication από ένα kubeconfig σε secretRef. Έτσι, σε ένα BYO fleet δεν σου επιβάλλεται Crossplane στα production clusters.
Το πραγματικό κόστος, λοιπόν, για έναν οργανισμό που δεν τρέχει ήδη Crossplane δεν είναι το inference layer. Είναι ότι αναλαμβάνεις μια ολόκληρη εγκατάσταση Crossplane v2, με packages, upgrades των providers και ένα δεύτερο reconciliation engine να λειτουργείς, δίπλα σε όποιο IaC και GitOps stack τρέχεις ήδη.
Για laptop, υπάρχει ένα κόψιμο που αξίζει να ξέρεις. Το upstream lean-control-plane.yaml κατεβάζει τους cloud providers στο μηδέν με ένα ImageConfig, και όλο το κόλπο είναι η σειρά: ένα ImageConfig δένει τη στιγμή που δημιουργείται το ProviderRevision, άρα πρέπει να γίνει apply πριν από το Configuration. Αν το κάνεις σωστά, ολόκληρο το control plane είναι 15 pods.
Πώς βρίσκει ένα request το replica του#
Το μοναδικό front door είναι το κομμάτι που ακούγεται μαγικό, οπότε ορίστε η πραγματική αλυσίδα, όπως τη διάβασα στα composed objects του δικού μου control plane και όχι στο documentation.
Το InferenceGateway είναι ένα singleton με όνομα default: ένα Traefik στο control cluster, με μια διεύθυνση LoadBalancer που γίνεται το host κάθε URL ενός ModelService, http://<address>/<namespace>/<service>. Αυτό είναι το string που δίνεις στα ML teams.
Από κάτω, για κάθε replica, το Modelplane συνθέτει ένα ModelEndpoint που το URL του είναι το gateway του ίδιου του workload cluster, όχι ένα pod:
$ kubectl -n ml-team get modelendpoint
NAME URL
mock-demo-49b71 http://172.18.255.120/ml-team/mock-demo-49b71/v1
mock-demo-be445 http://172.18.255.100/ml-team/mock-demo-be445/v1
Τα .100 και .120 είναι οι MetalLB διευθύνσεις των Envoy gateways στα EU και US clusters μου. Στο control plane, κάθε endpoint γίνεται ένα Service χωρίς selector μαζί με ένα χειροποίητο EndpointSlice, που το μοναδικό του endpoint είναι αυτή η απομακρυσμένη διεύθυνση:
$ kubectl -n ml-team get endpointslice -o custom-columns=NAME:.metadata.name,ENDPOINTS:.endpoints[*].addresses[*]
NAME ENDPOINTS
mock-demo-43164b07c3f8 172.18.255.100
mock-demo-46d87a8261b4 172.18.255.120
Ένα HTTPRoute στο Traefik μοιράζει μετά το /ml-team/mock σε αυτά τα Services. Η πλήρης διαδρομή, δηλαδή, είναι:
flowchart LR
C["client"] -->|"POST /ml-team/mock/v1/chat/completions"| T["Traefik<br/>control cluster<br/>.255.200"]
T -->|"HTTPRoute"| S1["selectorless Service<br/>EndpointSlice → .255.100"]
T -->|"HTTPRoute"| S2["selectorless Service<br/>EndpointSlice → .255.120"]
S1 --> E1["Envoy AI Gateway<br/>eu-west"]
S2 --> E2["Envoy AI Gateway<br/>us-east"]
E1 -->|"InferencePool + EPP"| P1["engine pod"]
E2 -->|"InferencePool + EPP"| P2["engine pod"]
Από αυτόν τον σχεδιασμό βγαίνουν δύο πράγματα:
- Δεν υπάρχει cluster mesh. Ούτε Cilium ClusterMesh, ούτε Submariner, ούτε Istio multi-primary. Η μόνη απαίτηση είναι το control cluster να φτάνει τη διεύθυνση του gateway κάθε workload cluster πάνω από απλό IP. Στο cloud αυτό είναι ένας routable load balancer. Στο laptop μου είναι τρία kind clusters που μοιράζονται ένα Docker network, και γι’ αυτό κάθε MetalLB pool έπρεπε να βρίσκεται μέσα στο πραγματικό subnet αυτού του network. Αν το κάνεις λάθος, το request απλώς κάνει timeout.
- Το inference-aware routing ζει στο workload cluster, όχι στο front door. Το Traefik διαλέγει μόνο cluster. Από τη στιγμή που φτάνει το request, το model-aware κομμάτι το αναλαμβάνουν το Envoy AI Gateway του serving stack και το Gateway API Inference Extension (
InferencePoolμαζί με ένα endpoint-picker pod ανά replica, που το βλέπεις να τρέχει δίπλα στο engine). Είναι ο ίδιος μηχανισμός που περιέγραψα στο post για το llm-d, και εξηγεί γιατί το Modelplane και το llm-d είναι layers και όχι αντίπαλοι.
Ένα ModelEndpoint μπορείς να το φτιάξεις και με το χέρι, ώστε να δείχνει σε έναν SaaS provider συμβατό με το OpenAI API, και το ModelService το αντιμετωπίζει ακριβώς το ίδιο. Έτσι αποκτάς overflow προς έναν provider όταν το fleet είναι φορτωμένο, ή ένα break-glass failover, πίσω από το ίδιο URL.
Από πού έρχονται τα weights#
Η προφανής ερώτηση για ένα fleet: όταν ο scheduler ρίχνει ένα replica σε cluster άλλου region, πώς φτάνουν εκεί 100 GB από weights;
Υπάρχουν δύο δρόμοι, και ο default είναι ο βαρετός. Χωρίς ModelCache, το engine κατεβάζει μόνο του το μοντέλο στο startup, κατευθείαν από την πηγή, με το credential ως HF_TOKEN στο env του engine. Κάθε εκκίνηση pod πληρώνει ολόκληρο το download.
Ένα ModelCache το αλλάζει σε μία φορά ανά cluster:
apiVersion: modelplane.ai/v1alpha1
kind: ModelCache
metadata:
name: qwen3-coder
namespace: ml-team
spec:
source: HuggingFace
huggingFace:
repo: Qwen/Qwen3-Coder-480B-A35B-Instruct
authSecret:
name: hf-token
sizeGiB: 1100
Το Modelplane γεμίζει ένα ReadWriteMany PVC σε κάθε cluster όπου στήνεται το cache, μεταφέρει το authSecret σε αυτά τα clusters, και κάνει mount το αποτέλεσμα στο /mnt/models σε κάθε serving pod που αναφέρεται στο cache. Το engine διαβάζει τοπικά: --model=/mnt/models. Για ένα multi-node gang αυτή είναι η διαφορά ανάμεσα σε ένα αντίγραφο ανά cluster και ένα αντίγραφο ανά pod, οπότε εκεί είναι ο προτεινόμενος δρόμος, ενώ για cold starts ενός μόνο node είναι προαιρετικό.
Οι λεπτομέρειες που θα σε δαγκώσουν στο production:
| Θέμα | Τι λέει το documentation |
|---|---|
| Πηγές | Το HuggingFace είναι η μόνη πηγή σήμερα. Ούτε S3, ούτε OCI artifacts ακόμα. |
| Storage ανά cloud | Σε GKE (Filestore Enterprise), EKS (EFS), AKS (Azure Files) και Nebius (shared FS) γίνεται provision αυτόματα. Το Vultr δεν έχει τίποτα που να δουλεύει σε GPU nodes, οπότε εκεί παράλειψε το cache. Το Existing είναι bring-your-own: ένα RWX StorageClass με dynamic provisioning (WekaIO, Trident, FSx for NetApp), που το ορίζεις στο cluster.existing.cache.storageClassName. |
| Σύζευξη με το placement | Το clusterSelector του cache περιορίζει το πού στήνεται, και ένα deployment που αναφέρεται σε αυτό θα τοποθετεί νέα replicas μόνο μέσα σε αυτό το αποτύπωμα. Ένα replica δεν καταλήγει ποτέ σε cluster όπου δεν έφτασε το cache. |
| Ο loader μετράει περισσότερο από το cache | Με τον default loader του vLLM, το διάβασμα ενός μεγάλου μοντέλου από shared storage μπορεί να είναι πιο αργό από το να το κατεβάσεις, οπότε το cache κάνει τα cold starts χειρότερα. Σε EFS, το --load-format=runai_streamer με --model-loader-extra-config={"concurrency":16,"distributed":true} είναι η διαφορά ανάμεσα σε λεπτά και δεκάδες λεπτά. Μέτρησε το δικό σου. |
Το demo μου παρακάμπτει εντελώς το cache, γιατί το mock engine δεν έχει weights, και αυτό δείχνει τίμια τι αποδεικνύει το demo και τι όχι. Τα στοιχεία για το scheduling και το routing παραπάνω είναι πραγματικά. Ο δρόμος του storage, εδώ, βασίζεται μόνο στο documentation.
Το demo χωρίς GPU: δύο regions σε Kind#
Το upstream έχει ένα e2e suite που τρέχει ολόκληρο το control plane χωρίς GPU και χωρίς cloud account, και θέλω να του το αναγνωρίσω: αυτό έκανε εφικτό το post με κόστος λίγη RAM. Χρησιμοποιεί όμως ένα workload cluster, οπότε δεν δοκιμάζει ποτέ αυτό για το οποίο υπάρχει το Modelplane. Το demo που ακολουθεί προσθέτει δεύτερο region και βάζει τον fleet scheduler να αποδείξει τι κάνει.
Η αρχιτεκτονική του demo#
flowchart TB
subgraph CP["kind: modelplane-control"]
XP["Crossplane 2.4.0"]
CFG["Configuration modelplane v0.3.1<br/>14 composition functions"]
TR["InferenceGateway<br/>Traefik + MetalLB .255.200"]
end
subgraph EU["kind: modelplane-eu-west (k8s 1.34)"]
SEU["ServingStack<br/>Envoy AI Gateway .255.100"]
DEU["dra-example-driver<br/>fake gpu.example.com, 80Gi"]
REU["ModelReplica"]
end
subgraph US["kind: modelplane-us-east (k8s 1.34)"]
SUS["ServingStack<br/>Envoy AI Gateway .255.120"]
DUS["dra-example-driver<br/>fake gpu.example.com, 80Gi"]
RUS["ModelReplica"]
end
DEV["ModelDeployment<br/>replicas: 2<br/>CEL: memory >= 20Gi"] --> CFG
CFG -->|"place"| REU
CFG -->|"place"| RUS
TR -->|"HTTPRoute"| SEU
TR -->|"HTTPRoute"| SUS
DEU -.->|"ResourceSlice"| REU
DUS -.->|"ResourceSlice"| RUS
Τρία kind clusters σε ένα Docker network. Δύο upstream primitives το κάνουν να δουλεύει χωρίς cloud:
| Primitive | Τι κάνει εδώ |
|---|---|
source: Existing | Το InferenceCluster καταχωρεί ένα bring-your-own cluster μέσω kubeconfig, αντί να κάνει provision EKS/GKE/AKS/Nebius/Vultr |
claim: DRA + dra-example-driver | Το InferenceClass διαφημίζει devices gpu.example.com που δημοσιεύει ένας ψεύτικος driver, οπότε το ResourceClaim του engine δένει ένα ψεύτικο device σε node χωρίς GPU και τρέχει το πραγματικό allocation path του DRA |
Το engine είναι mock: 30 γραμμές Python stdlib που απαντάνε στα /v1/chat/completions και /v1/messages όπως το vLLM, και επιστρέφουν το όνομα του δικού τους pod, ώστε κάθε request που περνάει από το front door να μπορείς να το ιχνηλατήσεις μέχρι το region.
Προαπαιτούμενα#
- Docker με 24 GB μνήμη. Τρία kind clusters και δύο πλήρη serving stacks (cert-manager, Envoy Gateway, Envoy AI Gateway, GAIE, LeaderWorkerSet, NFD, kube-prometheus-stack, ο NVIDIA DRA driver) δεν χωράνε στα default 8 GB. Το Taskfile αρνείται να ξεκινήσει κάτω από τα 20 GB.
kind,kubectl,helm,jq,task. ΟύτεcrossplaneCLI, ούτε Nix.- Workload clusters καρφωμένα σε Kubernetes v1.34, για τα APIs του
resource.k8s.io(DRA), που εκεί είναι GA.
Βήμα 1: Τρία clusters, τα δύο με ψεύτικες GPU#
task clusters # three kind clusters, node image pinned by digest
task workloads # MetalLB + dra-example-driver + pool label on both regions
Κάθε region παίρνει ένα MetalLB pool μέσα στο kind subnet που ανιχνεύεται εκείνη τη στιγμή, χωρίς επικάλυψη με το pool του άλλου region και με αυτό του gateway. Μετά μπαίνει ο ψεύτικος driver, και το ένα label που χρωστάει ένα BYO cluster στο Modelplane:
kubectl label node modelplane-eu-west-control-plane modelplane.ai/pool=gpu-eu-west
Αν παραλείψεις αυτό το label, κάθε engine pod του pool μένει Pending για πάντα, χωρίς error. Είναι το πιο κοφτερό σημείο του BYO path, και τεκμηριώνεται μόνο στο description του schema.
Η απόδειξη ότι το DRA path είναι πραγματικό, πριν το δει καν το Modelplane:
$ kubectl --context kind-modelplane-eu-west get resourceslices
NAME DRIVER NODE
00000-gpu.example.com-modelplane-eu-west-control-plane-2xc4f gpu.example.com modelplane-eu-west-control-plane
Βήμα 2: Το control plane, κομμένο στα απαραίτητα#
task controlplane
Δηλαδή: εγκατάσταση του Crossplane 2.4.0 με Helm, apply του upstream lean-control-plane.yaml πρώτα, μετά τα RBAC prerequisites, μετά το Configuration. Το κόψιμο είναι ένα ImageConfig που αντιστοιχίζει κάθε οικογένεια cloud provider σε ένα DeploymentRuntimeConfig με μηδέν replicas:
apiVersion: pkg.crossplane.io/v1beta1
kind: ImageConfig
metadata:
name: dormant-cloud-providers
spec:
matchImages:
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-aws- }
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-azure- }
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-gcp- }
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-family- }
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-nebius }
- { type: Prefix, prefix: xpkg.upbound.io/upbound/provider-vultr }
runtime:
configRef:
name: scale-to-zero
Η σειρά μετράει, γιατί ένα ImageConfig δένει τη στιγμή που δημιουργείται το ProviderRevision. Αν γίνει apply μετά το Configuration, οι δέκα cloud controllers τρέχουν ήδη και έτσι μένουν. Αν γίνει πριν, ολόκληρο το control plane είναι 15 pods: το Crossplane, ο RBAC manager του, τα 14 composition functions, και οι δύο providers που χρησιμοποιεί πραγματικά το BYO path.
Ένα πράγμα το κάνω διαφορετικά από το documentation: καρφώνω μόνος μου την έκδοση του Configuration αντί να κάνω apply το manifest του upstream. Το γιατί το εξηγεί η παγίδα #1.
apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
name: modelplane
spec:
package: xpkg.upbound.io/modelplane/modelplane:v0.3.1
Βήμα 3: Καταχώρηση του fleet#
task register
Η πλευρά του platform team στο contract, τρία objects. Το class:
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
name: synthetic-gpu
spec:
description: "Fake GPU via dra-example-driver (no real hardware)"
devices:
- name: gpu
claim: DRA
driver: gpu.example.com
deviceClassName: gpu.example.com
count: 1
capacity:
memory: { value: "80Gi" }
Δύο clusters που το δημοσιεύουν, και τα ξεχωρίζει μόνο ένα label:
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
name: gpu-eu-west
labels:
modelplane.ai/region: eu-west
spec:
cluster:
source: Existing
existing:
secretRef:
name: eu-west-kubeconfig
key: kubeconfig
nodePools:
- name: gpu-eu-west
className: synthetic-gpu
nodeCount: 1
---
# gpu-us-east is identical apart from the name, label and secret
Και το gateway, με loadBalancer: MetalLB, ώστε το composition να εγκαταστήσει MetalLB στο ίδιο το control plane. Τα kubeconfig secrets βγαίνουν από το kind get kubeconfig --internal, γιατί τα provider pods του control plane πρέπει να φτάνουν τους API servers των workload clusters μέσα από το Docker network, όχι μέσω 127.0.0.1.
Αυτό είναι το αργό βήμα. Σε κάθε InferenceCluster σπρώχνεται ολόκληρο το serving stack μέσω του provider-helm. Στο laptop μου τα δύο clusters πήγαν από 7 namespaces σε 15 και δήλωσαν Ready περίπου 25 λεπτά αργότερα:
$ kubectl get inferencecluster
NAME SOURCE GATEWAY SYNCED READY
gpu-eu-west Existing 172.18.255.100 True True
gpu-us-east Existing 172.18.255.120 True True
Αυτές οι διευθύνσεις των gateways είναι ακριβώς τα MetalLB pools που όρισα, και σε αυτές θα κάνει route το front door.
Βήμα 4: Η πλευρά του developer, και πού καταλήγει#
task deploy
apiVersion: modelplane.ai/v1alpha1
kind: ModelDeployment
metadata:
name: mock-demo
namespace: ml-team
spec:
replicas: 2
template:
spec:
engines:
- name: mock
members:
- role: Standalone
nodeSelector:
devices:
- name: gpu
count: 1
selectors:
- cel: |
device.capacity["gpu.example.com"].memory.compareTo(quantity("20Gi")) >= 0
template:
spec:
containers:
- name: engine
image: python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df
command: ["python", "-u", "-c"]
args: ["..."] # the mock server, in the repo
Διάβασε τι δεν υπάρχει εκεί μέσα: ούτε cluster, ούτε region, ούτε cloud, ούτε node pool. Μόνο το σχήμα του hardware, ως CEL πάνω στα ίδια attributes που δημοσίευσε το class. Το nodeSelector είναι σκόπιμα ένα επιλεγμένο υποσύνολο ενός pod spec: το port, το readiness probe και το DRA ResourceClaim τα στήνει το ίδιο το composition, και το XRD απορρίπτει τα ports ή τα resources αν δοκιμάσεις να τα προσθέσεις.
Τριάντα δευτερόλεπτα αργότερα:
$ task placement
==> Replicas by cluster:
REPLICA CLUSTER READY
mock-demo-49b71 gpu-us-east True
mock-demo-be445 gpu-eu-west True
==> ReplicasScheduled:
True ReplicasCreated Scheduled 2 of 2 replicas
==> ModelService address:
http://172.18.255.200/ml-team/mock
Ένα replica ανά region, από ένα manifest που δεν ονόμασε κανένα από τα δύο. Αυτός είναι ο κανόνας spread before pack από το documentation του scheduling, αυτή τη φορά από παρατήρηση και όχι από παράθεση. Από την πλευρά του workload cluster, το ResourceClaim δένει ένα ψεύτικο device μέσα από το πραγματικό allocation path:
$ kubectl --context kind-modelplane-eu-west get resourceclaims
NAME STATE AGE
mock-demo-be445-mock-47cef-6db546b4c9-cgm87-devices-hrk68 allocated,reserved 11m
Βήμα 5: Ένα front door, δύο regions#
$ for i in 1 2 3 4 5 6; do task curl; done
served by mock-demo-be445-mock-47cef-6db546b4c9-cgm87
served by mock-demo-49b71-mock-995c3-7fdcf68c88-pbbxl
served by mock-demo-be445-mock-47cef-6db546b4c9-cgm87
served by mock-demo-49b71-mock-995c3-7fdcf68c88-pbbxl
served by mock-demo-be445-mock-47cef-6db546b4c9-cgm87
served by mock-demo-49b71-mock-995c3-7fdcf68c88-pbbxl
Ίδιο URL, τα regions εναλλάσσονται. Ο client δεν μαθαίνει ποτέ ότι υπάρχουν δύο clusters. Το curl αυτό τρέχει από ένα pod στο control plane, γιατί η διεύθυνση βρίσκεται στο kind subnet, και ένα macOS host δεν έχει route προς τα εκεί.
Βήμα 6: Το drain, ο λόγος που έχεις fleet#
Ένα taint πάνω σε ένα InferenceCluster είναι το kubectl drain σε επίπεδο fleet. Το NoSchedule σταματάει τα νέα placements. Το NoExecute μετακινεί και ό,τι βρίσκεται ήδη εκεί. Πρώτα κατέβηκα σε ένα replica, ώστε η μετακίνηση να έχει πού να πάει, αφού κάθε region έχει ένα node και το capacity χρεώνεται ανά node.
$ kubectl -n ml-team scale modeldeployment mock-demo --replicas=1
$ task drain CLUSTER=gpu-eu-west
Tainted gpu-eu-west NoExecute.
Είκοσι δευτερόλεπτα αργότερα το replica του EU έχει φύγει και δημιουργείται καινούργιο στο US. Ένα λεπτό μετά, σερβίρει:
--- immediately after taint ---
REPLICA CLUSTER READY
mock-demo-49b71 gpu-us-east False
--- after move ---
REPLICA CLUSTER READY
mock-demo-49b71 gpu-us-east True
Delete-plus-create, ακριβώς όπως λέει το documentation, και με τον ίδιο τρόπο που αντιμετωπίζει το Kubernetes ένα pod που το node του χάθηκε. Το όνομα του replica που επέστρεψε ήταν ίδιο με αυτό που είχε φύγει στο scale-down, οπότε το όνομα φαίνεται να προκύπτει από το placement και όχι από κάποιον μετρητή.
Τώρα η τίμια αποτυχία. Ανεβαίνεις ξανά στα δύο όσο το EU έχει ακόμα το taint, και το δεύτερο replica δεν έχει πού να μπει:
$ kubectl -n ml-team scale modeldeployment mock-demo --replicas=2
--- with one cluster drained ---
REPLICA CLUSTER READY
mock-demo-49b71 gpu-us-east True
ReplicasScheduled=True ReplicasCreated: Scheduled 1 of 2 replicas
Το deployment τρέχει κάτω από το spec.replicas, και το condition το λέει. Μια ένσταση που αξίζει να ξέρεις: το message λέει την αλήθεια, αλλά το status του condition μένει True με reason ReplicasCreated. Αν κάνεις alert πάνω σε αυτό το boolean, θα σου ξεφύγει ένα drain που δεν μπορεί να ολοκληρωθεί. Κάνε parse το message ή σύγκρινε τον αριθμό των replicas.
Βγάζεις το taint, και η φάση του fill κάνει τη δουλειά της:
$ task undrain CLUSTER=gpu-eu-west
--- after undrain ---
REPLICA CLUSTER READY
mock-demo-49b71 gpu-us-east True
mock-demo-be445 gpu-eu-west True
ReplicasScheduled=True ReplicasCreated: Scheduled 2 of 2 replicas
Τίποτα δεν γύρισε πίσω. Το replica του US έμεινε στη θέση του, γιατί ένα taint αφορά μόνο τα νέα placements, και αυτό που έλειπε καλύφθηκε στο cluster που έγινε ξανά επιλέξιμο. Retain, then fill.
Οι παγίδες#
Πέντε, και έπεσα σε όλες. Δύο είναι του Modelplane, μία του Kubernetes, δύο του BYO path.
1. Το documentation της εγκατάστασης σου δίνει το προηγούμενο release#
Το docs/manifests/getting-started/configuration.yaml μένει ένα release πίσω σε κάθε tag. Το αντίγραφο στο tag v0.3.1 εξακολουθεί να καρφώνει το package v0.3.0, και μόνο το main καρφώνει το v0.3.1. Αν ακολουθήσεις τις οδηγίες εγκατάστασης από ένα tag, παίρνεις το release πριν από αυτό που έκανες checkout, χωρίς να σου το πει κανείς.
Έχει σημασία, γιατί το package του v0.3.0 περιέχει ένα CEL rule που ο API server απορρίπτει. Το regex για τα label keys γράφτηκε ως \\. μέσα σε YAML με διπλά εισαγωγικά, που φτάνει στο CEL ως \., δηλαδή ένα μη έγκυρο escape σε CEL string literal. Δύο από τα δεκατέσσερα XRDs δεν γίνονται ποτέ established:
EstablishComposite: cannot apply rendered composite resource CustomResourceDefinition:
"modelreplicas.modelplane.ai" is invalid: x-kubernetes-validations[1].rule:
compilation failed: ERROR: <input>:1:51: Syntax error: token recognition error
Η αποτυχία είναι σιωπηλή. Το XRD δεν έχει κανένα status condition, στα logs του Crossplane δεν φτάνει τίποτα, και το μόνο σύμπτωμα πιο κάτω είναι ένα InferenceCluster κολλημένο σε SYNCED=False με no matches for kind "ModelReplica". Τα στοιχεία βρίσκονται στα events:
kubectl get events -A --field-selector type=Warning | grep EstablishComposite
Διορθώθηκε στο #400 και έγινε backport στο #403. Τα release notes του v0.3.1 το χαρακτηρίζουν «a critical fix for XRD creation» και προσθέτουν, εύγλωττα, ότι το v0.3.1 «is now usable on fresh installations». Κάρφωσε μόνος σου την έκδοση του Configuration.
2. Μην κάνεις in-place upgrade από v0.3.0 σε v0.3.1#
Το «Fresh installations» κουβαλάει πολύ βάρος σε εκείνη τη φράση. Το v0.3.1 μετέφερε τους provider-helm και provider-kubernetes από το fork του modelplane στο org upbound (#390). Αν κάνεις upgrade in place, εγκαθίστανται και οι δύο:
upbound-provider-helm Healthy=False UnhealthyPackageRevision:
cannot establish control of object: releases.helm.m.crossplane.io is already
controlled by ProviderRevision modelplane-provider-helm-840f6120b7c2
Το παλιό fork κρατάει τα CRDs, οπότε ο νέος provider δεν ξεκινάει ποτέ, ενώ το RBAC στο prerequisites.yaml είναι δεμένο στο service account του νέου provider. Ο παλιός τρέχει χωρίς δικαιώματα, και το gateway αποτυγχάνει με namespaces is forbidden. Ξεκίνα από καθαρή εγκατάσταση.
3. Engine pods κολλημένα σε ContainerCreating χωρίς events: kubelet 1.34.0#
Αυτό μου κόστισε ένα απόγευμα και δεν φταίει το Modelplane. Και τα δύο engine pods έμειναν σε ContainerCreating για ώρες. Το ResourceClaim έλεγε allocated,reserved. Το pod είχε ακριβώς ένα event, το Scheduled. Το kubelet δεν έγραφε τίποτα στα logs. Ο DRA driver δεν είχε λάβει ποτέ κλήση NodePrepareResources με claim.
Ένα goroutine dump του kubelet (/debug/pprof/goroutine?debug=1 μέσω του node proxy) έδειξε τον pod worker μπλοκαρισμένο εδώ:
dra.(*Manager).PrepareResources
→ plugin.(*DRAPlugin).NodePrepareResources
→ grpc.invoke → idle.(*Manager).ExitIdleMode [blocked]
ενώ ένα δεύτερο goroutine κρατούσε τον ίδιο idle manager μέσα από το path που κλείνει το connection:
http2Client.Close
→ plugin.(*monitoredPlugin).HandleConn dra_plugin_manager.go:118
→ grpc.(*ClientConn).Connect → idle.(*Manager).ExitIdleMode [blocked]
Ο DRA plugin monitor του kubelet ξανασυνδέεται μέσα από το callback που εκτελείται όταν τελειώνει το connection, και όταν το connection τελειώνει επειδή έμεινε idle, το Connect() κάνει deadlock με τον idle manager. Το default idle timeout του gRPC είναι 30 λεπτά, οπότε ένα cluster που χρησιμοποιείται συνεχώς δεν το βλέπει ποτέ, και ένα cluster που έμεινε αναμμένο όλη νύχτα το βλέπει πάντα. Το upstream fix: kubernetes/kubernetes#133926, cherry-pick στο 1.34 με το #133934, μέσα στο v1.34.2.
Workaround σε node που έχει το πρόβλημα: docker exec <node> systemctl restart kubelet. Και τα δύο pods έγιναν Running μέσα σε 75 δευτερόλεπτα.
Το πρόβλημα είναι ότι το documentation του Modelplane καρφώνει το kindest/node:v1.34.0, επειδή τα νεότερα kind images έρχονται με containerd 2.2+, που κατά το upstream «breaks Modelplane» (#315, χωρίς λεπτομέρειες). Άρα το image που προτείνει το documentation έχει το bug του kubelet, και το διορθωμένο image έχει ένα μη τεκμηριωμένο θέμα με το containerd. Το v1.34.8 έχει containerd 2.3.1. Δεν το έχω δοκιμάσει ακόμα με το Modelplane.
4. Ένα engine μόνο με Synthetic απορρίπτεται#
Το claim: Synthetic υπάρχει για hardware που μετράει στο placement αλλά δεν έχει DRA driver, και για ένα δεύτερο device δουλεύει ακριβώς όπως υπόσχεται. Ένα engine όμως που το μόνο του device είναι Synthetic το απορρίπτει ο fleet scheduler. Χρειάζεσαι τουλάχιστον ένα device που να γίνεται claim, και αυτός είναι όλος ο λόγος που υπάρχει ο ψεύτικος driver στο demo.
5. Το BYO label, και το subnet#
Σε ένα BYO cluster το Modelplane δεν βάζει labels στα nodes σου. Χωρίς το modelplane.ai/pool=<nodePools[].name> στα nodes του pool, τα engine pods μένουν Pending χωρίς error. Επιπλέον, κάθε MetalLB pool και στα τρία clusters πρέπει να βρίσκεται μέσα στο πραγματικό subnet του kind Docker network, που το kind το μετακινεί από το 172.18 όταν το κρατάνε παλαιότερα networks. Τα pools εκτός subnet δεν βγάζουν error. Το request μέσα από το front door απλώς κάνει timeout.
Modelplane: Υπέρ και κατά#
Υπέρ#
| Πλεονέκτημα | Περιγραφή |
|---|---|
| Το contract είναι του DRA, όχι επινοημένο | Το platform team δημοσιεύει ό,τι αναφέρει ένας πραγματικός driver, και οι developers γράφουν το CEL που θα έγραφαν σε ένα ResourceClaim. Δεν υπάρχει πίνακας μετάφρασης για να ξεφύγει. |
| Το scope ταυτίζεται με την ιδιοκτησία | Τα cluster-scoped kinds ανήκουν στο platform team, τα namespaced στους developers. Το όριο του RBAC προκύπτει από το ίδιο το σχήμα του API. |
| Αδρός fleet scheduler, τοπικό admission με τον τελευταίο λόγο | Το control plane απαντάει στο «θα μπορούσε αυτό το cluster να το φιλοξενήσει», το DRA στο workload cluster απαντάει στο «ποια GPU». Ο διαχωρισμός κρατάει τον fleet scheduler απλό και δεν κάνει ποτέ overcommit. |
| Το drain είναι ρήμα πρώτης κατηγορίας | Taints σε ένα InferenceCluster με NoSchedule/NoExecute, tolerations στα deployments. Πραγματικό επιχειρησιακό primitive, όχι runbook. |
| Ουδέτερο ως προς το engine | Κάνει inject μόνο τη διεύθυνση του leader. vLLM σήμερα, ό,τι βγει του χρόνου αύριο, χωρίς αλλαγή στο API. |
| Αξιολογείται δωρεάν | Το source: Existing μαζί με έναν ψεύτικο DRA driver τρέχει ολόκληρο το μοντέλο σε ένα laptop. |
Κατά#
| Περιορισμός | Περιγραφή |
|---|---|
| Το Crossplane είναι υποχρεωτικό | Δεν υπάρχει αυτόνομος operator. Ένας οργανισμός που δεν τρέχει ήδη Crossplane v2 αναλαμβάνει packages, 16 provider dependencies και ένα δεύτερο reconciliation engine. |
v1alpha1, και φαίνεται | Ένα release που το install manifest του δείχνει στο προηγούμενο, χαλασμένο release. Τα in-place upgrades δεν υποστηρίζονται. Δύο forked providers στο critical path μέχρι πρόσφατα. |
| Capacity με ανάλυση node | Ένα pod με μία GPU χρεώνει ολόκληρο το node (#172). Ασφαλές, αλλά σπάταλο για engines μικρότερα από ένα node. |
| Αποκλειστική κυριότητα των workload clusters | Το documentation είναι ρητό: αφιέρωσε το cluster. Το capacity ledger υποθέτει ότι κανείς άλλος δεν τοποθετεί GPU workloads εκεί. |
| Caching μόνο από HuggingFace | Ούτε S3, ούτε OCI, και τίποτα που να δουλεύει στο Vultr. |
| Gateway μόνο με Traefik | Το backend: Traefik είναι η μόνη τιμή σήμερα. |
Πότε να χρησιμοποιήσεις το Modelplane#
Χρησιμοποίησέ το όταν:#
- Τρέχεις ήδη, ή πρόκειται να τρέξεις, τρία ή περισσότερα GPU clusters σε διαφορετικά regions, clouds ή σε κάποιο neocloud, και το matchmaking τρώει την εβδομάδα ενός platform engineer
- Θέλεις τα ML teams να ζητάνε hardware χωρίς να μάθουν infrastructure, και θέλεις αυτό το contract να μην χρειάστηκε να το εφεύρεις εσύ
- Το Crossplane v2 υπάρχει ήδη στο περιβάλλον σου, ή είσαι διατεθειμένος να το υιοθετήσεις ως engine του control plane
- Αξιολογείς το DRA ως platform contract, και όχι απλώς ως τον αντικαταστάτη του device plugin, και θέλεις ένα δουλεμένο παράδειγμα του pattern
Σκέψου εναλλακτικές όταν:#
- Έχεις ένα cluster. Το KServe ή το llm-d σερβίρουν μοντέλα, το Modelplane τα τοποθετεί. Με ένα cluster δεν υπάρχει τίποτα να τοποθετήσεις.
- Χρειάζεσαι σταθερότητα production σήμερα. Αυτό είναι
v1alpha1, και η διαδικασία των releases έχει κοφτερές γωνίες. - Τα GPU clusters σου είναι κοινόχρηστα με άλλα workloads. Το capacity ledger προϋποθέτει αποκλειστικότητα.
- Δεν μπορείς να τρέξεις Crossplane, ή ένα δεύτερο reconciliation engine είναι εκτός συζήτησης για το ops team σου.
Troubleshooting#
| Πρόβλημα | Συμπτώματα | Λύση |
|---|---|---|
| Το XRD δεν γίνεται ποτέ established | Το kubectl get xrd δείχνει κενό ESTABLISHED, κανένα status condition, το InferenceCluster λέει no matches for kind "ModelReplica" | Εγκατέστησες το v0.3.0. kubectl get events -A --field-selector type=Warning | grep EstablishComposite, και μετά κάρφωσε το modelplane:v0.3.1 στο Configuration |
Provider Healthy=False μετά από upgrade | cannot establish control of object ... already controlled by ProviderRevision | In-place upgrade από v0.3.0 σε v0.3.1. Ξεκίνα από καινούργιο control plane |
Engine pod σε ContainerCreating, μόνο ένα event Scheduled | Το claim είναι allocated,reserved, ο DRA driver δεν γράφει ποτέ στα logs κλήση με numClaims=1 | Το DRA deadlock του kubelet 1.34.0. systemctl restart kubelet στο node, και χρησιμοποίησε k8s v1.34.2+ |
Engine pod Pending, χωρίς error | BYO cluster, τα nodes του pool δεν έχουν label | kubectl label node <n> modelplane.ai/pool=<nodePools[].name> |
| Το front door κάνει timeout | Το ModelService έχει διεύθυνση, το curl κρεμάει | MetalLB pool εκτός του kind subnet. docker network inspect kind και ξαναφτιάξε τα pools μέσα σε αυτό |
Το ModelReplica απορρίπτεται | Ο scheduler αναφέρει ότι δεν υπάρχει επιλέξιμο pool | Το μόνο device του engine είναι claim: Synthetic. Πρόσθεσε ένα που να γίνεται claim |
| Pods των cloud providers τρέχουν σε laptop | 10+ pods upbound-provider-*, πίεση στη μνήμη | Το lean-control-plane.yaml έγινε apply μετά το Configuration. Πρέπει να μπει πρώτο |
Το ReplicasScheduled φαίνεται υγιές στη διάρκεια ενός drain | Status True, message Scheduled 1 of 2 replicas | Κάνε parse το message ή σύγκρινε το status.replicas με το spec.replicas. Το boolean δεν αλλάζει |
Το repository του demo#
Όλα τα παραπάνω αναπαράγονται από το srekubecraft-demo/modelplane-fleet:
modelplane-fleet/
Taskfile.yml
kubernetes/
clusters/ # three kind configs, node image pinned by digest
modelplane/
configuration.yaml # the package pin the docs get wrong
10-inference-gateway.yaml # Traefik front door, MetalLB pool templated
20-inference-class.yaml # platform side: the device vocabulary
30-inference-clusters.yaml # two BYO clusters, region-labelled
40-model-deployment.yaml # developer side: CEL over that vocabulary
50-model-service.yaml # one endpoint in front of every replica
task up # clusters -> workloads -> control plane -> register -> deploy
task placement # where did each replica land?
task curl # one request through the single front door
task drain CLUSTER=gpu-eu-west
task undrain CLUSTER=gpu-eu-west
task down # delete all three clusters
Το bootstrap RBAC, το κόψιμο των cloud providers και ο ψεύτικος DRA driver τραβιούνται από το upstream στο καρφωμένο tag, δεν είναι vendored. Υπολόγισε 24 GB μνήμη για το Docker και περίπου 40 λεπτά για το πρώτο task up, με το μεγαλύτερο μέρος να πηγαίνει στα δύο serving stacks.
Συμπέρασμα#
Το Modelplane είναι το πρώτο πράγμα που έχω τρέξει και αντιμετωπίζει το fleet ως τη μονάδα, και το κομμάτι που θα κρατούσα ακόμα κι αν το project χανόταν είναι ο μηχανισμός του contract. Δημοσιεύεις ένα λεξιλόγιο για devices με τους όρους του ίδιου του DRA, αφήνεις τους developers να γράψουν πάνω του CEL σε μορφή DRA, και ο τελευταίος λόγος μένει στο πραγματικό DRA admission του workload cluster: αυτό το pattern καταργεί τον πίνακα μετάφρασης που καταλήγει να συντηρεί κάθε platform team με GPU, και γενικεύεται σε οποιοδήποτε hardware μπορεί να περιγράψει ένας driver. Το flag claim: Synthetic είναι η πρακτική πινελιά που το κάνει εφαρμόσιμο όσο οι drivers ακόμα έρχονται.
Το μοντέλο του scheduling είναι σκόπιμα απλοϊκό, εκεί ακριβώς που πρέπει. Τα υπάρχοντα replicas είναι δεδομένα εισόδου, όχι αποφάσεις, οπότε τίποτα δεν μετακινείται άσκοπα. Το capacity χρεώνεται σε nodes, οπότε υποεκτιμά αντί να κάνει overcommit. Το drain είναι ένα taint. Καθεμία από αυτές τις επιλογές φάνηκε στο demo ακριβώς όπως την περιγράφει το documentation, κάτι που δεν μπορώ να πω για τα περισσότερα v1alpha1 projects.
Αυτό που έχει μείνει πίσω είναι το release engineering. Ένα tag που το install manifest του δείχνει στο προηγούμενο, χαλασμένο release είναι από αυτά που κοστίζουν μια μέρα σε όποιον το αξιολογεί, και εγώ αυτή τη μέρα την ξόδεψα. Πρόσθεσε και το DRA deadlock του kubelet 1.34.0, που είναι bug του Kubernetes αλλά το πληρώνει όποιος καρφώσει εκείνο το image, και η τίμια συμβουλή είναι: αξιολόγησέ το τώρα, στο laptop, για τις σχεδιαστικές του ιδέες. Production traffic μην βάλεις ακόμα πίσω του.
Αν έχεις ένα GPU cluster, μείνε στο KServe ή στο llm-d. Αν έχεις τρία, σε τρία μέρη, και η ερώτηση «ποιο μοντέλο τρέχει πού» είναι μια ουρά από tickets, έτσι μοιάζει η απάντηση.
Αν σου φάνηκε χρήσιμο, ίσως σε ενδιαφέρουν και τα σχετικά posts μου για AI infrastructure και platform design:
- llm-d - Kubernetes-native distributed LLM inference σε κλίμακα
- KServe - Model serving στο Kubernetes
- Kratix - Χτίζοντας platforms ως προϊόν
- Εισαγωγή στο Crossplane
