Modelplane πέρα από το placement - Routing policy, το Dynamo stack και τέσσερα gotchas
~/posts/modelplane-beyond-placement-routing-dynamo-gotchas.el.md24 min · 4959 words

Modelplane πέρα από το placement - Routing policy, το Dynamo stack και τέσσερα gotchas

// Το δεύτερο μέρος της σειράς για το fleet inference με το Modelplane, αφιερωμένο στο layer πάνω από τον scheduler. Το ModelService είναι routing policy και όχι load balancer, το spec.stack Dynamo αλλάζει ολόκληρο τον μηχανισμό του gang scheduling, και τέσσερα τεκμηριωμένα gotchas θα σου φάνε από ένα απόγευμα το καθένα.

$ date

Το πρώτο post για το Modelplane είχε μία δουλειά: να αποδείξει τον fleet scheduler σε ένα laptop. Τρία Kind clusters, ψεύτικες DRA GPU, ένα mock engine, και ένα manifest που δεν ονόμαζε ούτε cluster ούτε region και παρ’ όλα αυτά έβαζε από ένα replica στο καθένα. Αυτό φτιάχτηκε να δείξει το demo, και αυτό έδειξε.

Ό,τι βρίσκεται πάνω από τον scheduler το άφησα στην άκρη, επίτηδες. Ένα post που προσπαθεί να καλύψει μονοκοπανιά placement, traffic policy, sizing, ένα εναλλακτικό serving stack και το storage path είναι ένα post που δεν το τελειώνει κανείς. Αυτό εδώ είναι λοιπόν το δεύτερο μισό: το κομμάτι του API που πιάνεις στα χέρια σου όταν το placement δουλεύει πια και πρέπει να στήσεις από πάνω του ένα release process.

Πριν το γράψω, ξαναπέρασα τα docs και τα XRDs γραμμή προς γραμμή, και τα στοιχεία του πρώτου post στέκουν. Κάθε κανόνας του scheduling, κάθε τύπος υπολογισμού του capacity και οι δύο γνωστοί περιορισμοί λένε ακόμα το ίδιο. Τίποτα εδώ δεν είναι ανάκληση.

Προέλευση: όλα τα παρακάτω προέρχονται από τα επίσημα docs (docs/content/ στο main) και από τους ορισμούς των XRD (apis/*/definition.yaml). Τα στοιχεία του πρώτου post ήταν hands-on. Εδώ είναι docs και schema, και η τελευταία ενότητα είναι ακριβώς όσα προσθέτω στο demo για να κλείσω αυτό το κενό.

Ποιος πρέπει να το διαβάσει;#

Αυτό το post είναι για:

  • Όποιον διάβασε το πρώτο post και θέλει το layer πάνω από τον fleet scheduler, που εκείνο το post επίτηδες δεν άνοιξε
  • Platform Engineers που κάνουν sizing σε ένα Modelplane deployment και χρειάζονται τρεις ανεξάρτητους αριθμούς, όχι μόνο αυτόν που εξήγησε το πρώτο post
  • SREs που σχεδιάζουν ένα canary ή ένα cluster drain σε GPU fleet, αφού ο μηχανισμός και για τα δύο είναι το ίδιο object
  • Όποιον αξιολογεί το Modelplane σε multi-node τοπολογία, όπου το Dynamo stack και η απαίτηση για NIXL κρίνουν αν το engine σου θα ξεκινήσει καν

TL;DR#

  • Το ModelService είναι routing policy, όχι load balancer. Τα endpoints φέρουν τα labels modelplane.ai/deployment και modelplane.ai/cluster, τα entries συνδυάζονται και τα weights είναι σχετικά. Έτσι παίρνεις canaries, drain ανά cluster χωρίς redeploy και SaaS overflow πίσω από το ίδιο URL.
  • Οι αριθμοί του sizing είναι τρεις, όχι δύο: spec.replicas, engines[].copies και worker.nodes. Μόνο ο πρώτος είναι άξονας scaling. Autoscaling των pods μέσα στο cluster δεν υπάρχει.
  • Το spec.stack: Dynamo αντικαθιστά το LeaderWorkerSet με Grove και KAI Scheduler και προσθέτει το ModelExpress, που τραβάει τα weights του μοντέλου από ένα peer replica μέσω RDMA αντί από το storage.
  • Τέσσερα gotchas: ωφέλιμη έναντι ονομαστικής μνήμης GPU, ένα mismatch στο revision που ξανακατεβάζει σιωπηλά το μοντέλο, το PrefillDecode που θέλει NIXL στο image σου, και ένα field fabric που δεν αφορά μόνο τη Nebius.
  • Το Traefik είναι το μόνο gateway backend για έναν τεκμηριωμένο λόγο: το path rewriting ανά backend στο Gateway API.

Τι άφησε το πρώτο post γι’ αυτό εδώ#

Τέσσερα νήματα, και τα τέσσερα μπήκαν στην άκρη για τον ίδιο λόγο: το demo δεν μπορούσε να τα δοκιμάσει, και το να περιγράψω ένα API που δεν είχα δουλέψει ο ίδιος θα αδυνάτιζε ένα post που όλο του το νόημα ήταν τα hands-on στοιχεία.

ΝήμαΠού σταμάτησε το πρώτο postΤι καλύπτει αυτό το post
Traffic policy«Το Traefik διαλέγει cluster, το workload gateway κάνει το model-aware κομμάτι». Σωστό για το data path, και το demo δεν χρησιμοποίησε τίποτα πέρα από το data pathΤο ModelService είναι routing policy: selectors ανά cluster, weighted splits, SaaS overflow
Sizingnodes = pods x copies, σαν τύπος για το capacityΑπό πού βγαίνει το copies, και γιατί το spec.replicas είναι ο μόνος άξονας που κάνει scale
Το serving stackLeaderWorkerSet, γιατί αυτό κάνει compose το default stackΤο spec.stack: Dynamo το αντικαθιστά με Grove και KAI Scheduler και αλλάζει τον τρόπο που φτάνουν τα weights σε ένα replica
Ο περιορισμός του gatewayΤο «gateway μόνο με Traefik» μπήκε στα κατά και έμεινε εκείΟ τεκμηριωμένος αρχιτεκτονικός λόγος, που είναι πιο χρήσιμη απάντηση από το παράπονο

Με δυο λόγια: το πρώτο post απέδειξε το layer που αποφασίζει πού τρέχει ένα μοντέλο. Αυτό εδώ ασχολείται με το layer που αποφασίζει τι γίνεται με ένα request από τη στιγμή που τρέχει, και εκεί αποδεικνύεται ότι βρίσκεται το πραγματικό κέρδος για το operations.

1. Το ModelService είναι routing policy, όχι load balancer#

Αυτή είναι η παράλειψη που μετράει στο operations.

Το Modelplane κάνει compose ένα ModelEndpoint ανά replica, μόνο αφού το replica γίνει Ready, και το αποσύρει όταν το replica γίνει unhealthy. Σε κάθε endpoint βάζει δύο labels που κουβαλούν την πρόθεση του routing:

LabelΤιμή
modelplane.ai/deploymentτο deployment στο οποίο ανήκει το replica
modelplane.ai/clusterτο cluster στο οποίο τρέχει το replica

Το spec.endpoints είναι λίστα, και τα entries συνδυάζονται: το service στέλνει traffic σε κάθε endpoint που ταιριάζει με οποιοδήποτε entry. Από εκεί βγαίνουν τρία patterns.

Routing σε ολόκληρο deployment#

Η περίπτωση που έδειξε το πρώτο post, και αυτή που γράφεις στο 90% των περιπτώσεων:

apiVersion: modelplane.ai/v1alpha1
kind: ModelService
metadata:
  name: qwen3
  namespace: ml-team
spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: qwen3-8b

Routing σε ένα μέρος του#

Βάλε δίπλα στο label του deployment και το label του cluster, και έβγαλες ένα cluster εκτός λειτουργίας χωρίς να αγγίξεις το deployment, χωρίς taint και χωρίς redeploy:

spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: qwen3-8b
        modelplane.ai/cluster: prod-us-east

Γιατί έχει σημασία: το πρώτο post κάλυψε το drain με taint, που είναι το βαρύ εργαλείο. Μετακινεί replicas. Αυτό εδώ είναι το ελαφρύ: τα replicas μένουν ακριβώς εκεί που είναι και απλώς παύουν να δέχονται traffic. Δύο διαφορετικές λειτουργίες, που το πρώτο post τις έκανε μία.

Split με weights#

Τα weights είναι σχετικά και ισχύουν για το entry συνολικά. Το μερίδιο μοιράζεται όσο πιο ισόποσα γίνεται σε κάθε endpoint που ταιριάζει με το entry, οπότε το scale up ή down ενός deployment δεν αλλάζει το μερίδιό του στο traffic. Ένα entry χωρίς weight παίρνει by default 1:

spec:
  endpoints:
  - weight: 95
    selector:
      matchLabels:
        modelplane.ai/deployment: qwen3-8b
  - weight: 5
    selector:
      matchLabels:
        modelplane.ai/deployment: qwen3-8b-v2

Αυτό είναι ένα canary σε ολόκληρο fleet, γραμμένο σε ένα namespaced object που ανήκει σε έναν developer.

Ένα entry μπορεί επίσης να επιλέξει ένα χειροποίητο ModelEndpoint που δείχνει σε έναν OpenAI-compatible SaaS provider, ώστε το overflow ή το break-glass traffic να βρίσκεται πίσω από το ίδιο URL με τα δικά σου replicas. Το πρώτο post ανέφερε ότι ένα ModelEndpoint μπορεί να φτιαχτεί με το χέρι. Δεν είπε ότι τα weights είναι αυτά που το κάνουν χρήσιμο.

Δύο σημεία που θέλουν ακρίβεια#

Μπερδεύονται εύκολα, και έτσι ακριβώς καταλήγεις να υπερπουλήσεις το σύστημα στην ίδια σου την ομάδα.

Το placement είναι provider-aware, το routing είναι policy που γράφεις εσύ. Ο fleet scheduler δεν έχει δικό του cost model, ούτε προτίμηση σε provider, ούτε cache weighting. Επειδή όμως κάθε endpoint φέρει το modelplane.ai/cluster, μπορείς να εκφράσεις το «90% Crusoe, 10% Nebius» σαν ModelService, και το Modelplane αποσύρει τα endpoints όσων replicas γίνουν unhealthy. Αυτό είναι στατικό split με απόσυρση βάσει health. Δεν είναι capacity-aware failover. Μην το πουλήσεις σαν τέτοιο.

Το disaggregated serving είναι η μόνη εξαίρεση στη διαφάνεια ως προς τη μορφή του API. Το routing για prefill και decode διαβάζει request bodies σε OpenAI format για να διαλέξει το ζευγάρι, οπότε ένα request σε άλλη μορφή API φτάνει μεν στο engine, αλλά παρακάμπτει αυτό το cache-aware routing. Το unified serving προωθεί κάθε μορφή με τον ίδιο τρόπο.

2. Ο τρίτος αριθμός του sizing: engines[].copies#

Το πρώτο post χρησιμοποίησε το nodes = pods x copies χωρίς να εξηγήσει από πού βγαίνει το copies. Υπάρχουν τρεις ανεξάρτητοι αριθμοί, και μόνο ένας από αυτούς κάνει scale:

FieldΤι παράγειAutoscaling
spec.replicasΟλόκληρα αντίγραφα όλης της τοπολογίας, συνήθως σε διαφορετικά clustersΟ μόνος άξονας scaling
engines[].copiesΠανομοιότυπα αντίγραφα ενός engine μέσα σε ένα replica, στο ίδιο clusterΌχι, ορίζεται μία φορά
worker.nodesΠόσα nodes πιάνει ένα gangΌχι

Το copies σου δίνει resilience μέσα στο cluster: αν πέσει ένα node, χάνεις ένα αντίγραφο και όχι ολόκληρο το replica. Στο disaggregated serving ορίζει και την αναλογία prefill προς decode, που είναι ο αριθμός που ρυθμίζεις στην πράξη για ένα συγκεκριμένο μείγμα traffic.

Αξίζει να ειπωθεί ξεκάθαρα, γιατί είναι από εκείνα που θεωρείς ότι δουλεύουν και το ανακαλύπτεις υπό φορτίο: autoscaling των pods μέσα στο cluster δεν υπάρχει. Το deployment εκθέτει το scale subresource του Kubernetes, άρα το kubectl scale και το KEDA δουλεύουν πάνω στο spec.replicas, αλλά τίποτα δεν κάνει scale τα pods μέσα σε ένα replica. Αν θέλεις περισσότερο throughput σε ένα cluster, προσθέτεις ένα replica και αφήνεις τον fleet scheduler να αποφασίσει πού θα πάει, ή ορίζεις σωστά το copies από την πρώτη μέρα.

3. Το Dynamo stack#

Λείπει εντελώς από το πρώτο post, και αλλάζει δύο από τα συμπεράσματά του.

Ένα InferenceCluster μπορεί να ορίσει spec.stack: Dynamo. Έτσι ο μηχανισμός του gang scheduling αλλάζει από LeaderWorkerSet σε Grove και KAI Scheduler, οπότε ένα ModelReplica κάνει compose ένα Grove PodCliqueSet αντί για LWS, και το serving stack τρέχει επιπλέον έναν ModelExpress server.

flowchart TB
    subgraph Default["spec.stack: default"]
        LWS["LeaderWorkerSet"] --> LP["gang pods"]
        LP -->|"--load-format default"| PVC1["ModelCache PVC"]
    end

    subgraph Dyn["spec.stack: Dynamo"]
        G["Grove PodCliqueSet"] --> KAI["KAI Scheduler"]
        KAI --> DP["gang pods"]
        DP -->|"--load-format modelexpress"| MX["ModelExpress server"]
        MX -->|"peer over RDMA"| DP
        MX -.->|"fallback"| PVC2["ModelCache PVC"]
    end

Το ModelExpress αλλάζει όσα έλεγε το πρώτο post για τη διανομή των weights. Με --load-format modelexpress, το πρώτο replica φορτώνει από το PVC seed του και δηλώνει τον εαυτό του ως source. Τα επόμενα replicas τραβάνε από ένα peer μέσω RDMA αντί να ξαναδιαβάσουν το storage.

Η παγίδα, και είναι αυτή που κρίνει τον λογαριασμό του storage: ένα replica που δεν βρίσκει συμβατό peer, ή δεν έχει fabric για να το φτάσει, κάνει fallback στο PVC. Άρα η cache πρέπει και πάλι να έχει το σωστό μέγεθος και να είναι staged για κάθε replica. Το ModelExpress είναι fast path, όχι τρόπος να γλιτώσεις το provisioning.

Μια operational λεπτομέρεια που θα σε μπερδέψει σε debug session: με το Dynamo, ένα pod δεν μαθαίνει το rank του από το MODELPLANE_RANK. Το command το βγάζει από το GROVE_PCLQ_POD_INDEX του ίδιου του Grove. Το MODELPLANE_LEADER_ADDRESS γίνεται resolve και στα δύο stacks, οπότε αυτό το μισό του ισχυρισμού «δεν κάνει inject σχεδόν τίποτα» από το πρώτο post μένει άθικτο.

4. Τέσσερα gotchas που αξίζει να ξέρεις#

Το πρώτο post είχε πέντε gotchas που τα βρήκα μπροστά μου τρέχοντας το πράγμα. Αυτά τα τέσσερα είναι τεκμηριωμένα, στα docs ή στο description κάποιου schema, και είναι αυτά που θα έβρισκα μπροστά μου στη συνέχεια.

Δήλωσε το ωφέλιμο capacity, όχι το ονομαστικό#

Μια H100 των 80GB αναφέρει περίπου 81559Mi ωφέλιμης μνήμης. Αν δηλώσεις 80Gi στο InferenceClass σου, ένα nodeSelector που ζητάει >= 80Gi θα ταιριάξει με το pool και μετά θα αποτύχει να κάνει bind τη GPU. Ο fleet scheduler λέει ναι, το DRA admission στο workload cluster λέει όχι, και σου μένει ένα placement που δεν γίνεται ποτέ Ready.

Αυτό είναι το πιο κοφτερό σημείο όταν γράφεις ένα InferenceClass, και σε αντίθεση με τα περισσότερα gotchas του πρώτου post είναι όντως τεκμηριωμένο. Δήλωσε αυτό που αναφέρει ο driver, όχι αυτό που γράφει η σελίδα του marketing.

Ένα mismatch στο revision κατεβάζει το μοντέλο δύο φορές#

Ένα σκέτο repo ID γίνεται resolve στο default branch. Ένα ModelCache καρφωμένο σε commit ή tag θέλει το engine να περνάει το ίδιο revision (--revision για το vLLM). Ένα engine που ζητάει το default branch δεν βρίσκει τίποτα staged εκεί και κατεβάζει αθόρυβα ολόκληρο το μοντέλο ξανά, δηλαδή ακριβώς το κόστος που υπήρχε η cache για να εξαλείψει.

Ούτε error, ούτε warning. Μόνο ένα cold start που κρατάει όσο κρατούσε πριν φτιάξεις την cache, και ένας λογαριασμός για egress.

Το PrefillDecode θέλει NIXL στο image σου#

Τόσο ο NixlConnector του vLLM όσο και το prefill και decode transfer του SGLang κάνουν import το package nixl. Σε image που δεν το έχει, τα disaggregated engines κρασάρουν στο startup με NIXL is not available.

Τα πρόσφατα vanilla tags του vllm/vllm-openai το περιλαμβάνουν. Το Modelplane δεν το πακετάρει, και αυτό συνάδει με το engine-neutrality που επαίνεσε το πρώτο post: το engine image είναι δικό σου, άρα και τα dependencies του είναι δικά σου.

Υπάρχει field fabric, και δεν αφορά μόνο τη Nebius#

Το nodePools[].fabric.type επιλέγει το node-to-node fabric για multi-node engines, «so a gang’s tensor-parallel traffic isn’t capped by TCP», με default το None. Το XRD έχει ένα validation rule που απαιτεί fabric.infiniband όταν το type είναι InfiniBand σε Nebius source, και στην πλευρά του AWS υπάρχει path με EFA DRA driver.

Αυτό είναι το πραγματικό αντίστοιχο του παραδείγματος InfiniBand με claim: Synthetic που χρησιμοποίησα στο πρώτο post. Το Synthetic περιγράφει ένα fabric για το placement. Το fabric.type ρυθμίζει αυτό που χρησιμοποιεί στην πράξη το gang. Έδειξα το πρώτο και δεν ανέφερα ποτέ το δεύτερο.

5. Γιατί το Traefik είναι το μόνο backend#

Το πρώτο post έβαλε το «gateway μόνο με Traefik» στα κατά και το άφησε εκεί. Τα architecture docs δίνουν τον λόγο, και ο λόγος είναι καλύτερη απάντηση από το παράπονο:

«Each hop rewrites the path: the control plane rewrites the public prefix to the replica’s path, and the workload gateway strips that down to what the engine serves. This per-backend path rewriting is the main thing the control-plane gateway has to support, and it narrows which Gateway API implementations can fill the role.»

Άρα το backend είναι enum επίτηδες. Ο περιορισμός είναι το path rewriting ανά backend στο Gateway API, όχι κάποια προτίμηση στο Traefik. Και τα δύο hops είναι Gateway API, και το ποια υλοποίηση κάθεται σε κάθε layer είναι ρητά εσωτερικό θέμα και όχι μέρος του API.

Έτσι το κατά υποβαθμίζεται από «vendor lock-in στο data path» σε «ένα στενό feature του Gateway API έχει λίγες υλοποιήσεις», και αυτό είναι κάτι ουσιαστικά διαφορετικό για να εκνευριστείς.

6. Τι άλλαξε από το v0.3.1#

Πρώτο post (2026-08-30)Στο main σήμερα
Τα workload clusters καρφωμένα σε k8s v1.34 για τα DRA APIsΤο faq.md απαιτεί v1.35+ στα inference clusters, για το DRA
16 provider packages, 14 Python composition functions13 providers, 15 functions στο crossplane-project.yaml
Το cache storage γίνεται auto-provision σε GKE, EKS, AKS και NebiusΤο inference-cluster.md εξακολουθεί να αναφέρει και τα τέσσερα. Το model-cache.md ονομάζει μόνο GKE και EKS και μετά παραπέμπει σε εκείνη την ενότητα ως την κανονική λίστα, άρα πρόκειται για συνοπτική περίληψη και όχι για αντίφαση

Η γραμμή για το v1.35 θέλει προσοχή, γιατί δεν λύνει το αδιέξοδο από το gotcha #3 του πρώτου post, όπως υπέθεσα αρχικά.

Εμπλέκονται δύο διαφορετικά clusters. Το faq.md λέει ότι κάθε inference cluster χρειάζεται «Dynamic Resource Allocation (DRA, Kubernetes v1.35+) to bind GPUs to pods». Όμως το installation.md, που στήνει το control cluster, εξακολουθεί να καρφώνει το kindest/node:v1.34.0 κάτω από το σχόλιο «kind v0.31+ ships containerd 2.2.0 which breaks Modelplane» (#315). Το control cluster δεν έχει GPU και δεν χρειάζεται DRA, οπότε τα δύο συνυπάρχουν χωρίς αντίφαση.

Η γωνία παραμένει κοφτερή για όποιον τρέχει inference clusters σε kind, δηλαδή ακριβώς αυτό που κάνει το demo μου. Το DRA θέλει v1.35+, και ένα kind node image v1.35 έρχεται με το containerd για το οποίο προειδοποιεί το install note. Αυτόν τον συνδυασμό δεν τον έχω δοκιμάσει, και είναι το πρώτο πράγμα που θα ρωτούσα upstream.

7. Το ένα σημείο όπου τα docs και το cluster διαφωνούν#

Το drain-cluster.md λέει για ένα drain που δεν μπορεί να ολοκληρωθεί:

«Its ReplicasScheduled condition reports the shortfall, so a drain that can’t finish is visible rather than silent.»

Το πρώτο post έλεγε ότι το status μένει True με reason ReplicasCreated και ότι το boolean δεν αλλάζει ποτέ. Τρέχοντας το canary είδα και τις δύο περιπτώσεις δίπλα δίπλα, και η αλήθεια είναι πιο συγκεκριμένη:

$ kubectl -n ml-team get modeldeployment \
    -o jsonpath='...ReplicasScheduled...'
mock-demo     status=True   reason=ReplicasCreated       msg=Scheduled 1 of 2 replicas
mock-demo-v2  status=False  reason=InsufficientCapacity  msg=0 of 1 replicas scheduled (checked 2 clusters)

Ίδιο cluster, ίδιο taint, ίδια στιγμή. Άρα:

Έλλειμμαstatusreason
Τοποθετήθηκαν κάποια replicas, όχι όλαTrueReplicasCreated
Τοποθετήθηκαν μηδέν replicasFalseInsufficientCapacity

Το boolean αλλάζει τελικά, αλλά μόνο στο μηδέν. Ένα deployment που τρέχει με τα μισά από τα replicas που ζήτησε αναφέρει ReplicasScheduled=True, και αυτή είναι η περίπτωση που συμβαίνει στην πράξη σε ένα drain και η περίπτωση που θα έχανε ένα alert. Το πρώτο μου post το υπερέβαλε λέγοντας «δεν αλλάζει ποτέ». Το ακριβές είναι ότι δεν βλέπει συγκεκριμένα το μερικό έλλειμμα.

Στήσε το alert στο status.replicas.ready σε σύγκριση με το spec.replicas, όχι στο condition. Το upstream issue αξίζει ακόμα να ανοίξει, με την πιο ακριβή διατύπωση.

Τι εξακολουθεί να ισχύει από το πρώτο post#

Για να μη διαβαστεί αυτό σαν ανάκληση. Επιβεβαιωμένα αμετάβλητα, με βάση τα docs και τα XRDs στο main:

  • Η θέση για το DRA contract. Είναι πλέον κυριολεκτικά ενότητα των docs με τίτλο «The device contract».
  • Το claim: Synthetic, με το ίδιο παράδειγμα InfiniBand, στο XRD του InferenceClass.
  • Crossplane v2 XRs, κανένας Go controller, Python composition functions.
  • Κάθε κανόνας του scheduling. Καθαρή συνάρτηση του observed state, τα υπάρχοντα replicas ως inputs και όχι ως αποφάσεις, matching σε δύο επίπεδα, nodes = pods x copies, claimless members με μηδενικό κόστος, το capacity ledger, ένα engine που δεν μοιράζεται ποτέ σε περισσότερα pools, retain-then-fill με spread πριν από το pack, delete και create σε κάθε μετακίνηση.
  • Και οι δύο γνωστοί περιορισμοί, με τα ίδια issue numbers: #172 και #149.
  • Η παγίδα με το BYO label modelplane.ai/pool, που ακόμα τεκμηριώνεται μόνο εν παρόδω.
  • Η Vultr ακόμα δεν έχει RWX class που να δουλεύει σε GPU nodes. Το HuggingFace παραμένει το μόνο cache source. Το Traefik παραμένει το μόνο gateway backend.
  • Αποκλειστικό ownership: «dedicate each cluster to Modelplane rather than sharing it with other workloads».
  • Drain με taint με NoSchedule και NoExecute, tolerations για να μείνει κάτι στη θέση του παρά το taint, και τίποτα που να γυρίζει πίσω αφού αφαιρεθεί το taint.

Η απόδειξη στο demo#

Όλα τα παραπάνω ήταν docs και schema όταν έγραψα το πρώτο draft. Μετά έβαλα το κομμάτι του routing πάνω στο cluster, γιατί ένα post που ο βασικός του ισχυρισμός είναι αδοκίμαστος είναι χειρότερο από ένα post που περιμένει μια μέρα.

Το demo στο srekubecraft-demo/modelplane-fleet απέδειξε το placement και τίποτα πάνω από αυτό. Να τι πρόσθεσα, και τι τύπωσε.

Πριν, το repo είχε ένα ModelDeployment και αυτό το ModelService:

# kubernetes/modelplane/50-model-service.yaml
spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: mock-demo

Ένα entry, χωρίς weight, χωρίς label για cluster. Αυτή είναι η μόνη μορφή routing που δοκιμάζει το demo.

1. Ένα δεύτερο deployment, για το weighted split#

Το canary χρειάζεται κάτι απέναντί του για να συγκριθεί, οπότε η πρώτη προσθήκη είναι το 41-model-deployment-v2.yaml: ένα αντίγραφο του mock με όνομα mock-demo-v2. Στο engine δεν χρειάζεται καμία αλλαγή. Το mock απαντάει ήδη served by <pod name>, και το όνομα ενός pod έχει για prefix το deployment του, άρα το mock-demo-v2- στο response body αρκεί για να ξεχωρίσει η καταμέτρηση τα δύο.

Μετά το 51-model-service-canary.yaml:

spec:
  endpoints:
  - weight: 90
    selector:
      matchLabels:
        modelplane.ai/deployment: mock-demo
  - weight: 10
    selector:
      matchLabels:
        modelplane.ai/deployment: mock-demo-v2

Εδώ δαγκώνει ο περιορισμός του capacity. Κάθε region είναι ένα μόνο control-plane node και το capacity χρεώνεται ανά node, άρα το fleet έχει ακριβώς δύο nodes. Δύο deployments με replicas: 2 δεν χωράνε.

Τρέξε και τα δύο με replicas: 1 και χωράνε ακριβώς, ένα node ανά region, οπότε το canary γίνεται, σαν bonus, και cross-region split. Αυτό κάνει το task canary: κατεβάζει το mock-demo στο 1 πριν κάνει apply το v2.

Η δελεαστική εναλλακτική, να προσθέσεις ένα worker node, είναι τρεις αλλαγές και όχι μία, και όποια παραλείψεις αποτυγχάνει αθόρυβα:

ΑλλαγήΑρχείοΑν την παραλείψεις
Πρόσθεσε το nodekind-eu.yaml, kind-us.yamlΚανένα νέο capacity
nodeCount: 1 σε 230-inference-clusters.yamlΤο ledger εξακολουθεί να διαβάζει 1. Αυτό είναι το δηλωμένο capacity, όχι το observed
Βάλε label στο νέο nodeTaskfile.yml, που βάζει label μόνο στο <cluster>-control-planeΤα pods μένουν Pending χωρίς error, δηλαδή η παγίδα με το BYO label από το πρώτο post

Πλήρωσε τη RAM μόνο αν θέλεις πάνω από ένα replica ανά region. Για να παρακολουθήσεις ένα weight, δεν χρειάζεται.

Πρώτα, τα labels των endpoints, πάνω στα οποία στηρίζεται όλη η ενότητα, όπως διαβάζονται από το control plane μόλις γίνουν και τα δύο Ready:

$ kubectl -n ml-team get modelendpoint \
    -o custom-columns=NAME:.metadata.name,DEPLOY:'.metadata.labels.modelplane\.ai/deployment',CLUSTER:'.metadata.labels.modelplane\.ai/cluster'
NAME                 DEPLOY         CLUSTER
mock-demo-be445      mock-demo      gpu-eu-west
mock-demo-v2-5ef67   mock-demo-v2   gpu-us-east

Και τα δύο labels, και στα δύο endpoints, ακριβώς όπως τα περιγράφουν τα docs. Και ο fleet scheduler έβαλε μόνος του το v2 στο άλλο region, οπότε το canary βγαίνει cross-region split χωρίς επιπλέον κόπο.

2. Ένα task που μετράει το split#

Το task curl στέλνει ένα request. Ένα weight θέλει καταμέτρηση, οπότε το task curl:split τρέχει ένα loop μέσα σε ένα μόνο in-cluster curl pod (η διεύθυνση είναι στο subnet του Kind, και το να ξεκινήσουν 200 pods θα κρατούσε περισσότερο από το ίδιο το test) και μετράει ανά deployment:

$ task curl:split N=200
==> 200 requests through http://172.18.255.200/ml-team/mock
mock-demo       180  ( 90.0%)
mock-demo-v2     20  ( 10.0%)
total           200

Ακριβώς 90.0 και 10.0 σε 200 requests. Ο βασικός ισχυρισμός αυτού του post δεν στηρίζεται πια μόνο στα docs.

3. Το πείραμα που τα docs δεν απαντούν#

Με την καταμέτρηση έτοιμη, το ανοιχτό ερώτημα γίνεται ένα test πέντε λεπτών. Ο προφανής τρόπος να το τρέξεις δεν δουλεύει:

$ kubectl -n ml-team scale modeldeployment mock-demo-v2 --replicas=0
The ModelDeployment "mock-demo-v2" is invalid: spec.replicas:
  Invalid value: 0: spec.replicas in body should be greater than or equal to 1

Ένα ModelDeployment δεν γίνεται scale στο μηδέν. Το XRD βάζει κάτω όριο το 1 στο spec.replicas, άρα το «πάρκαρε αυτό το deployment αλλά κράτα το ορισμένο» δεν είναι κατάσταση που έχει το API. Αξίζει να το ξέρεις και από μόνο του, και σημαίνει επίσης ότι το KEDA integration που αναφέρουν τα docs μπορεί να κάνει scale μέσα σε ένα εύρος, αλλά ποτέ στο μηδέν.

Το endpoint πρέπει λοιπόν να αποσυρθεί από την άλλη μεριά, κάνοντας το replica unschedulable. Βάλε taint στο cluster που φιλοξενεί το v2 και δεν έχει πού να πάει, αφού το μοναδικό node του άλλου region είναι ήδη πιασμένο:

task drain CLUSTER=gpu-us-east   # v2's replica is evicted, cannot be re-placed
task curl:split N=200            # the entry still exists, its endpoints do not

Το endpoint αποσύρεται μέσα σε 20 δευτερόλεπτα, και το weighted entry μένει να δείχνει στο τίποτα. Η απάντηση:

$ kubectl -n ml-team get modelendpoint
NAME              DEPLOY
mock-demo-be445   mock-demo

$ task curl:split N=200
mock-demo       200  (100.0%)
mock-demo-v2      0  (  0.0%)
total           200

Το weight ανακατανέμεται. 200 στα 200, και ούτε ένα αποτυχημένο request. Άρα το «failover» είναι τελικά δίκαιη λέξη για το pattern 90/10: το split είναι στατικό, αλλά ένα entry που έχασε όλα του τα endpoints σταματά να καταναλώνει το μερίδιό του αντί να το ρίχνει σε μαύρη τρύπα. Αυτή είναι η συμπεριφορά που θέλεις, και τα docs δεν τη δηλώνουν πουθενά.

4. Traffic drain, δίπλα στο drain με taint#

Το 52-model-service-drain.yaml καρφώνει το service σε ένα cluster:

spec:
  endpoints:
  - selector:
      matchLabels:
        modelplane.ai/deployment: mock-demo
        modelplane.ai/cluster: gpu-us-east

Τρέξε το δίπλα στο υπάρχον task drain και η αντίθεση είναι όλο το νόημα: το taint μετακινεί replicas, ο selector μετακινεί το traffic και αφήνει τα replicas να τρέχουν.

Ένα πράγμα που πρέπει να γίνει σωστά στο tooling, και που την πρώτη φορά το έκανα λάθος: μια καταμέτρηση ανά deployment δεν μπορεί να το δει αυτό. Και τα δύο replicas ανήκουν στο mock-demo, άρα μια καταμέτρηση ανά deployment δείχνει 100% σε κάθε περίπτωση και δεν αποδεικνύει τίποτα. Γι’ αυτό το task curl:split μετράει ανά replica, που κουβαλάει και το deployment και, μέσω του ModelReplica, το cluster:

$ task curl:split N=60
==> 60 requests through http://172.18.255.200/ml-team/mock
mock-demo-49b71           60  (100.0%)
total                     60
==> replica -> cluster:
REPLICA           CLUSTER
mock-demo-49b71   gpu-us-east
mock-demo-be445   gpu-eu-west

60 στα 60 στο replica του us-east. Το replica του eu-west είναι ακόμα εκεί, ακόμα Ready, ακόμα σου κοστίζει μια GPU, και δεν δέχεται τίποτα. Το task traffic:undrain επαναφέρει το service χωρίς το pin, και η μέτρηση ελέγχου βγαίνει καθαρή:

mock-demo-be445           30  ( 50.0%)
mock-demo-49b71           30  ( 50.0%)
total                     60

Αυτή είναι η διάκριση που το πρώτο post δεν μπορούσε να κάνει, γιατί είχε μόνο το taint.

5. Το bump σε v1.35#

Τα δύο workload clusters του demo είναι inference clusters, και το faq.md θέλει πλέον v1.35+ σε αυτά για το DRA. Αλλάζει λοιπόν το digest του node image στα kind-eu.yaml και kind-us.yaml, και ο έλεγχος είναι αυτός που χρησιμοποίησε ήδη το πρώτο post:

kubectl --context kind-modelplane-eu-west get resourceslices
kubectl --context kind-modelplane-eu-west get resourceclaims   # allocated,reserved

Αυτό είναι το ακριβό, όχι το φθηνό. Ένα kind image v1.35 έρχεται με το containerd για το οποίο προειδοποιεί το installation.md, και το UPSTREAM στο Taskfile είναι καρφωμένο στο tag του Modelplane, άρα από εκεί έρχεται και το manifest του DRA driver. Το bump του node image μάλλον σέρνει μαζί του και το MODELPLANE_VERSION. hypothesis: αδοκίμαστο, και ο λόγος που αυτή η γραμμή μένει ακόμα ανοιχτή.

6. Το copies, που είναι σχεδόν δωρεάν#

Βάλε engines[].copies: 2 στο mock και το capacity ledger θα πρέπει να ζητάει δύο nodes ανά replica αντί για ένα. Με ένα node ανά region, αυτό σημαίνει ότι ένα replica τοποθετείται και το δεύτερο αναφέρει έλλειμμα, που είναι ένας φθηνός τρόπος να δεις τον τύπο του πρώτου post να κάνει τις πράξεις του. Δεν χρειάζεται νέο manifest, μόνο ένα field και ένα task placement.

Πού βρίσκεται τώρα το post#

ΙσχυρισμόςΚατάσταση
Το ModelEndpoint φέρει τα labels deployment και clusterΕπιβεβαιώθηκε στο cluster
Ένα weighted entry μοιράζει το traffic σύμφωνα με το weight τουΕπιβεβαιώθηκε. 180/20 σε 200 requests, ακριβώς 90.0/10.0
Ένα weight ανακατανέμεται όταν χαθούν τα endpoints τουΕπιβεβαιώθηκε. 200/200, μηδέν αποτυχίες
Ένας cluster selector κάνει drain το traffic χωρίς να μετακινεί replicasΕπιβεβαιώθηκε. 60/0 με pin, 30/30 χωρίς pin
Το spec.replicas έχει κάτω όριο το 1Επιβεβαιώθηκε. Το API απορρίπτει το 0
Το ReplicasScheduled δεν βλέπει μόνο το μερικό έλλειμμαΕπιβεβαιώθηκε. Και οι δύο περιπτώσεις δίπλα δίπλα
Το DRA path κάνει bind σε k8s v1.35Αδοκίμαστο. Παραμένει το bump του digest παραπάνω
Η αριθμητική του engines[].copiesΑδοκίμαστο. Ένα field, πέντε λεπτά, απλώς δεν το έκανα ακόμα
Dynamo, ModelExpress, NIXL, ωφέλιμη έναντι ονομαστικής μνήμηςΔεν δοκιμάζεται εδώ

Η τελευταία γραμμή είναι το ταβάνι, και αξίζει να το ονομάσω με ακρίβεια. Το Grove και ο KAI Scheduler θα εγκαθίσταντο, αλλά για να τραβήξει το ModelExpress weights από ένα peer μέσω RDMA χρειάζεται ένα fabric που δεν υπάρχει σε Docker network, και το mock engine δεν έχει weights να τραβήξει. Το NIXL θέλει πραγματικό vLLM image. Το gotcha με τα 81559Mi θέλει πραγματική H100, όχι έναν ψεύτικο driver που αναφέρει ό,τι του είπα εγώ.

Αυτά τα τέσσερα μένουν μόνο στα docs μέχρι να τα τρέξει κάποιος σε πραγματικό hardware. Το routing layer, δηλαδή το κομμάτι που αλλάζει στην πράξη τον τρόπο που διαχειρίζεσαι ένα fleet, όχι πια.

Δύο πράγματα που μου κόστισε το run πριν βγάλει οτιδήποτε#

Και τα δύο είναι operational σημειώσεις για το Kind path και όχι bugs του Modelplane, και τα δύο μου κόστισαν μία ώρα, οπότε αξίζει να γραφτούν.

Τα clusters είχαν μείνει ανέγγιχτα δύο μέρες. Κάθε engine pod ήταν σε CreateContainerError:

CDI device injection failed: unresolvable CDI devices
k8s.gpu.example.com/gpu=89bece99-c997-4590-9f24-5f81e15d9df6-gpu-0

Ο DRA driver είχε κάνει κάποια στιγμή restart και είχε ξαναφτιάξει τα ονόματα των CDI devices του, ενώ τα ResourceClaim objects των pods εξακολουθούσαν να αναφέρονται σε UUIDs από το προηγούμενο instance του driver. Το claim γράφει allocated,reserved και το containerd δεν μπορεί να κάνει resolve το device. Το σβήσιμο των pods το καθαρίζει, γιατί το generated claim φεύγει μαζί τους.

Μετά, τα νέα pods κόλλησαν σε ContainerCreating με ένα μόνο event Scheduled και τον driver να γράφει numClaims=0 σε κάθε poll, δηλαδή ακριβώς το gotcha #3 του πρώτου post: το DRA deadlock του kubelet 1.34.0, που το προκαλεί το gRPC idle timeout των 30 λεπτών, στο οποίο πέφτει πάντα ένα cluster που έμεινε ανοιχτό όλη νύχτα. docker exec <node> systemctl restart kubelet και στα δύο workload nodes, και μέσα σε δύο λεπτά και τα δύο engines ήταν Running.

Ένα παρκαρισμένο Kind fleet δεν επανέρχεται μόνο του. Υπολόγισε το restart του kubelet πριν υπολογίσεις το πείραμα.

Συμπέρασμα#

Αν κρατήσεις ένα πράγμα από αυτό το μισό: το ModelService είναι το σημείο όπου ζει το traffic policy σου. Labels στα endpoints για deployment και cluster, entries που συνδυάζονται και σχετικά weights σου δίνουν canary, traffic drain ανά cluster και SaaS overflow, όλα σε ένα namespaced object που ανήκει σε έναν developer. Αυτό μετατρέπει το Modelplane από placement engine σε κάτι πάνω στο οποίο θα μπορούσες να τρέξεις ένα release process, και γι’ αυτό πήρε δικό του post αντί για μια παράγραφο στο προηγούμενο.

Τα υπόλοιπα είναι calibration. Το engines[].copies είναι ο αριθμός του sizing που δεν εξηγεί κανείς, το ότι το spec.replicas είναι ο μόνος άξονας scaling είναι από τους περιορισμούς που θέλεις να ξέρεις πριν σχεδιάσεις το capacity και όχι μετά, και έχει κάτω όριο το 1, άρα το να παρκάρεις ένα deployment στο μηδέν δεν είναι κατάσταση που έχει αυτό το API. Το Dynamo stack είναι πραγματική διακλάδωση στην αρχιτεκτονική και όχι ένα flag.

Και τα τέσσερα gotchas έχουν το ίδιο σχήμα με τα πέντε του πρώτου post: όλα τους αποτυγχάνουν αθόρυβα. Το ονομαστικό capacity δεν κάνει bind τίποτα, ένα mismatch στο revision ξανακατεβάζει το μοντέλο σιωπηλά, ένα NIXL που λείπει κρασάρει στο startup, και το ReplicasScheduled αναφέρει True ενώ υπάρχει μερικό έλλειμμα. Αυτό το τελευταίο είναι το pattern σε μικρογραφία. Το failure mode αυτού του project δεν είναι ένα error, είναι ένα πράσινο status δίπλα σε ένα μήνυμα που δεν διάβασε κανείς.

Για ένα project σε v1alpha1, η σύνοψη του πρώτου post ισχύει αυτούσια: αξιολόγησέ το τώρα, στο laptop path, για τις σχεδιαστικές του ιδέες, και μη βάλεις ακόμα production traffic πίσω του. Η διαφορά είναι στο πού θα ξόδευα την αξιολόγηση. Το placement συμπεριφέρεται ακριβώς όπως περιγράφουν τα docs και το demo το αποδεικνύει ήδη, οπότε αφιέρωσε το απόγευμά σου στο ModelService, με τις έξι προσθήκες παραπάνω. Οι πέντε τρέχουν στα ίδια τρία Kind clusters που έχεις ήδη.


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

Modelplane

EOF · 24 min · 4959 words
$ continue exploring
← Kubernetes 1.37 Gang Scheduling - Το Workload API, ένα field που χάνεται σιωπηλά και το σημείο όπου σταματά το preemption // Το KEP-4671 έφερε το gang scheduling σε beta στο Kubernetes 1.37, πίσω από ένα feature gate που είναι κλειστό by default. Ένα Kind cluster τεσσάρων nodes με έξι ψεύτικες DRA GPUs δείχνει το partial-placement deadlock, το ένα field που το λύνει, και τα δύο όρια που δεν τεκμηριώνει κανείς: το field του Job κόβεται σιωπηλά αν λείπει ένα δεύτερο, alpha gate, και το workload-aware preemption δεν μπορεί να ανακτήσει DRA devices. #sre #kubernetes #scheduling 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. #sre #kubernetes #modelplane →
$ 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...