INFERNO: Kubernetes-Native AI Inference Operator - 10-Day Expert Learning Plan A developer outlines a 10-day expert learning plan for building a Kubernetes-native AI inference operator using Kubebuilder. The plan covers required Go and Kubernetes knowledge, the problem of boilerplate in operator development, and the workflow of generating CRDs, controllers, and deployment manifests. Project: Kubernetes-Native AI Inference Operator Duration: 10 days Level: Expert Goal: Build a working Kubernetes controller that manages AI inference workloads Go Knowledge Required: - Basic syntax variables, functions, structs - Interfaces - Error handling with error type - Goroutines basics - Package management with go mod If you're weak on these , take 1 day to review: Quick Go refresher resources - https://gobyexample.com/ 30 min - Read: https://tour.golang.org/methods/1 interfaces, 20 min - Practice: Write a simple HTTP server Kubernetes Knowledge Required: - What Pods, Deployments, Services are - How to use kubectl get, apply, describe, logs - YAML basics - What a namespace is If weak, do: Quick k8s refresher - kubectl cheatsheet: https://kubernetes.io/docs/reference/kubectl/cheatsheet/ - Deploy a simple app: kubectl apply -f nginx.yaml - Check it: kubectl get pods, kubectl logs - Clean up: kubectl delete -f nginx.yaml The Problem It Solves: Building Kubernetes operators is hard. You need: - API groups, versions, kinds - CRD manifests - Reconciliation loops - RBAC rules - Deployment manifests - Testing setup All of this is boilerplate . Kubebuilder is a scaffolding tool that generates all this for you. What Kubebuilder Does: kubebuilder init Create project skeleton kubebuilder create api Generate API + controller template make manifests Generate CRD YAML make install Install CRD into cluster make run Run controller locally make deploy Deploy controller to cluster What Kubebuilder Generates: my-operator/ ├── api/v1alpha1/ Your API definitions the YAML structure │ ├── myresource types.go Go structs that become YAML │ └── myresource webhook.go Validation webhooks advanced ├── controllers/ Your business logic │ └── myresource controller.go The reconciliation loop ├── config/ │ ├── crd/ Kubernetes CRD manifests generated │ ├── manager/ Operator deployment YAML generated │ ├── rbac/ RBAC rules generated │ └── samples/ Example CRs you create ├── main.go Operator entry point ├── Dockerfile Container image definition ├── Makefile Build commands └── go.mod Go dependencies The Workflow: You Write: Kubebuilder Generates: ┌──────────────────────────┐ ┌──────────────────────────┐ │ 1. API types Go structs │ ────→ │ 1. CRD YAML manifests │ │ 2. Controller logic │ │ 2. RBAC rules │ │ 3. Reconcile function │ │ 3. OpenAPI docs │ │ 4. Tests │ │ 4. Deployment manifest │ └──────────────────────────┘ └──────────────────────────┘ Key Files You'll Edit: — Define what users can write in YAML api/v1alpha1/myresource types.go — Write the reconciliation logic controllers/myresource controller.go — Example YAML for testing config/samples/ .yaml Everything else is generated or boilerplate. This is the heart of every Kubernetes operator: ┌─────────────────────────────────────────────┐ │ Kubernetes API Server │ │ stores all resources │ └────────────────┬────────────────────────────┘ │ │ Watch: "Tell me when anything changes" ↓ ┌─────────────────────────────────────────────┐ │ Controller your code │ │ │ │ func r MyReconciler Reconcile ... { │ │ 1. Read the current CR │ │ 2. Check what's needed │ │ 3. Create/update Deployments, Services │ │ 4. Update the CR's status │ │ 5. Return no change or Requeue │ │ } │ └────────────────┬────────────────────────────┘ │ │ Create/Update Resources ↓ ┌─────────────────────────────────────────────┐ │ Kubernetes Cluster │ │ Deployments, Services, etc. │ └─────────────────────────────────────────────┘ When does Reconcile get called? - Someone creates/updates/deletes an InferenceService - Kubernetes notifies the controller: "Something changed " - Controller calls Reconcile - Controller reads the CR and creates/updates resources What should Reconcile do? func r MyReconciler Reconcile ctx, req Result, error { // 1. Read the resource cr := &MyResource{} r.Get ctx, req.NamespacedName, cr // 2. Check: Does desired state == actual state? deployment := &appsv1.Deployment{} err := r.Get ctx, deploymentName, deployment if err = nil { // 3a. Desired state missing: CREATE it newDeployment := r.constructDeployment cr r.Create ctx, newDeployment } else { // 3b. Desired state exists: UPDATE it r.Update ctx, deployment } // 4. Update status cr.Status.Ready = true r.Status .Update ctx, cr // 5. Tell k8s "I'm done, check again later" return Result{RequeueAfter: 10 time.Second}, nil } The Goal: Make actual state match desired state. Forever. This plan assumes you have basic Go and Kubernetes knowledge. If not, take 1-2 days to refresh using the links above. You'll build INFERNO in phases: Days 1-3 : Kubernetes controller fundamentals & Kubebuilder setup Days 4-6 : Core operator logic & InferenceService CRD Days 7-9 : Reconciliation, Deployment management, and testing Day 10 : Polish, integration testing, and documentation Each day has theory + hands-on code. By Day 10, you'll have a working operator that can deploy, scale, and manage fake inference workloads. Struct tags — How Go metadata works json:"field" , kubebuilder:validation: Interfaces — The client.Client interface implements Get, Create, Update Goroutines & channels — Controller-runtime uses these under the hood Error handling — Pattern of if err = nil { return err } Package management — Using import for external libraries - Understand how Kubernetes controllers work watch → reconcile → act - Learn the controller-runtime library - Set up your first Kubebuilder project - Understand CRDs Custom Resource Definitions Kubernetes Controller Pattern: ┌─────────────────────────────────┐ │ Watch: Is desired = actual? │ │ If yes → Reconcile │ │ Update cluster state │ │ Requeue if needed │ └─────────────────────────────────┘ controller-runtime : The library that manages this loop Reconciliation : The core function that makes changes Leader election : How operators stay safe in HA setups Finalizers : Cleanup when resources are deleted Create project structure go mod init github.com/yourusername/inferno kubebuilder init --domain inferno.io --repo github.com/yourusername/inferno Create the InferenceService API kubebuilder create api --group inference --version v1alpha1 --kind InferenceService api/v1alpha1/inferenceservice types.go — Your CRD schema controllers/inferenceservice controller.go — Your reconciliation logic config/crd/ — Kubernetes CRD manifests config/manager/ — Operator deployment manifests - Project initialized with kubebuilder - InferenceService CRD created empty, will fill tomorrow - Controller file exists and compiles - Understand the reconciliation loop by reading generated code Kubebuilder Book https://book.kubebuilder.io/ — Read chapters 1-3 controller-runtime Overview https://pkg.go.dev/sigs.k8s.io/controller-runtime - Watch: "How Kubernetes Controllers Work" 10 min explainer on YouTube Struct field tags — How json:"field" controls YAML marshalling Kubebuilder validation tags — +kubebuilder:validation:Required , Enum= , Min= Pointer vs value types — When to use int32 vs int Slice operations — Working with arrays TypeName Type embedding — How metav1.TypeMeta and metav1.ObjectMeta inherit fields - Design your CRD schema the YAML users will write - Understand Go struct tags for validation & OpenAPI - Implement status subresource - Write validation rules The user wants to write: apiVersion: inference.inferno.io/v1alpha1 kind: InferenceService metadata: name: llama spec: model: uri: s3://models/llama-3 framework: vllm version: "1" replicas: min: 1 max: 5 resources: requests: cpu: "4" memory: "16Gi" gpu: "1" autoscaling: enabled: true targetConcurrency: 10 traffic: - version: "1" weight: 100 You need to translate this into Go structs. Edit api/v1alpha1/inferenceservice types.go : package v1alpha1 import corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" // ModelSpec defines the model to deploy type ModelSpec struct { // URI is the location of the model s3://, http://, local path // +kubebuilder:validation:Required URI string json:"uri" // Framework is the inference runtime vllm, triton, etc // +kubebuilder:validation:Enum=vllm;triton;fake Framework string json:"framework" // Version is the model version tag // +kubebuilder:validation:Required Version string json:"version" } // ResourceRequirements mirrors k8s resource requests type ResourceRequirements struct { CPU string json:"cpu,omitempty" Memory string json:"memory,omitempty" GPU string json:"gpu,omitempty" } // AutoscalingSpec defines HPA behavior type AutoscalingSpec struct { Enabled bool json:"enabled" TargetConcurrency int json:"targetConcurrency,omitempty" MinReplicas int json:"minReplicas,omitempty" MaxReplicas int json:"maxReplicas,omitempty" } // TrafficWeight defines canary traffic split type TrafficWeight struct { Version string json:"version" Weight int json:"weight" // 0-100 } // InferenceServiceSpec defines the desired state type InferenceServiceSpec struct { Model ModelSpec json:"model" Replicas ReplicaConfig json:"replicas" Resources ResourceRequirements json:"resources,omitempty" Autoscaling AutoscalingSpec json:"autoscaling,omitempty" Traffic TrafficWeight json:"traffic,omitempty" } // ReplicaConfig defines scaling bounds type ReplicaConfig struct { Min int json:"min" Max int json:"max" } // InferenceServiceStatus defines the observed state type InferenceServiceStatus struct { // Phase: Pending, Running, Failed Phase string json:"phase,omitempty" // ReadyReplicas: how many pods are ready ReadyReplicas int json:"readyReplicas" // Message for debugging Message string json:"message,omitempty" // LastUpdateTime when the controller last reconciled LastUpdateTime metav1.Time json:"lastUpdateTime,omitempty" // Conditions for events Conditions metav1.Condition json:"conditions,omitempty" } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=isvc;scope=Namespaced type InferenceService struct { metav1.TypeMeta json:",inline" metav1.ObjectMeta json:"metadata,omitempty" Spec InferenceServiceSpec json:"spec,omitempty" Status InferenceServiceStatus json:"status,omitempty" } // +kubebuilder:object:root=true type InferenceServiceList struct { metav1.TypeMeta json:",inline" metav1.ListMeta json:"metadata,omitempty" Items InferenceService json:"items" } : Generate validation, OpenAPI docs, RBAC +kubebuilder: annotations Status subresource : Separate spec desired from status actual Validation tags : Required , Enum , Min , Max — enforced at API server Conditions : Standard k8s pattern for tracking async operations - Complete inferenceservice types.go with all structs - Run make generate to create CRD manifests - CRD installs without errors: kubectl apply -f config/crd/ - Can create a test InferenceService YAML Kubebuilder CRD Tutorial https://book.kubebuilder.io/cronjob-tutorial/api-design.html Go API Conventions https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md - Kubernetes API reference for metav1.Condition Context — How context.Context controls cancellation and timeouts The — client interface Get , Create , Update methods Error wrapping — if err = nil patterns Named return types — ctrl.Result, error convention Defer statements — Cleanup code not used yet, but will on Day 7 Type assertions — Checking error types apierrors.IsNotFound err - Understand the reconciliation function signature - Learn how to watch resources and trigger reconciliation - Implement basic reconciliation logic - Set up Deployment creation from InferenceService The controller's main job: Watch InferenceService CRs Reconcile : For each CR, ensure a Deployment exists with the right spec Update status with what it found Requeue if needed e.g., "check again in 10s" Edit controllers/inferenceservice controller.go : package controllers import "context" "fmt" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" inferencev1alpha1 "github.com/yourusername/inferno/api/v1alpha1" // InferenceServiceReconciler reconciles an InferenceService object type InferenceServiceReconciler struct { client.Client Scheme runtime.Scheme } // +kubebuilder:rbac:groups=inference.inferno.io,resources=inferenceservices,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=inference.inferno.io,resources=inferenceservices/status,verbs=get;update;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete func r InferenceServiceReconciler Reconcile ctx context.Context, req ctrl.Request ctrl.Result, error { log := log.FromContext ctx // Step 1: Fetch the InferenceService var inferenceService inferencev1alpha1.InferenceService if err := r.Get ctx, req.NamespacedName, &inferenceService ; err = nil { if apierrors.IsNotFound err { log.Info "InferenceService not found, ignoring" return ctrl.Result{}, nil } log.Error err, "Failed to fetch InferenceService" return ctrl.Result{}, err } log.Info "Reconciling InferenceService", "name", inferenceService.Name // Step 2: Create or update Deployment deployment := &appsv1.Deployment{} deploymentName := types.NamespacedName{ Name: inferenceService.Name, Namespace: inferenceService.Namespace, } if err := r.Get ctx, deploymentName, deployment ; err = nil { if apierrors.IsNotFound err { // Create new Deployment deployment = r.constructDeployment &inferenceService if err := controllerutil.SetControllerReference &inferenceService, deployment, r.Scheme ; err = nil { log.Error err, "Failed to set controller reference" return ctrl.Result{}, err } if err := r.Create ctx, deployment ; err = nil { log.Error err, "Failed to create Deployment" return ctrl.Result{}, err } log.Info "Created Deployment", "deployment", deployment.Name } else { log.Error err, "Failed to fetch Deployment" return ctrl.Result{}, err } } else { // Update existing Deployment deployment = r.constructDeployment &inferenceService if err := r.Update ctx, deployment ; err = nil { log.Error err, "Failed to update Deployment" return ctrl.Result{}, err } log.Info "Updated Deployment", "deployment", deployment.Name } // Step 3: Update status inferenceService.Status.Phase = "Running" inferenceService.Status.ReadyReplicas = int deployment.Spec.Replicas inferenceService.Status.LastUpdateTime = &metav1.Time{Time: time.Now } if err := r.Status .Update ctx, &inferenceService ; err = nil { log.Error err, "Failed to update status" return ctrl.Result{}, err } log.Info "Reconciliation complete" return ctrl.Result{}, nil } // constructDeployment builds the Deployment spec from InferenceService func r InferenceServiceReconciler constructDeployment isvc inferencev1alpha1.InferenceService appsv1.Deployment { replicas := int32 isvc.Spec.Replicas.Min labels := map string string{ "app": isvc.Name, "operator": "inferno", } deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: isvc.Name, Namespace: isvc.Namespace, }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{ MatchLabels: labels, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: labels, }, Spec: corev1.PodSpec{ Containers: corev1.Container{ { Name: "inference-server", Image: "inference-server:fake", // TODO: vLLM later Ports: corev1.ContainerPort{ { ContainerPort: 8000, Name: "http", }, }, Env: corev1.EnvVar{ { Name: "MODEL URI", Value: isvc.Spec.Model.URI, }, { Name: "MODEL VERSION", Value: isvc.Spec.Model.Version, }, }, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse isvc.Spec.Resources.CPU , corev1.ResourceMemory: resource.MustParse isvc.Spec.Resources.Memory , }, }, }, }, }, }, }, } return deployment } func r InferenceServiceReconciler SetupWithManager mgr ctrl.Manager error { return ctrl.NewControllerManagedBy mgr . For &inferencev1alpha1.InferenceService{} . Owns &appsv1.Deployment{} . // Watch Deployments we create WithEventFilter predicate.GenerationChangedPredicate{} . // Ignore status-only updates Complete r } : Called when an InferenceService changes or a watched resource changes Reconcile : Links Deployment to InferenceService for cleanup SetControllerReference : Watch Deployments we create; if one changes, re-reconcile the InferenceService Owns Status subresource : Updated separately with r.Status .Update RBAC : The +kubebuilder:rbac: comments generate Kubernetes RBAC rules - inferenceservice controller.go implements Reconcile - constructDeployment creates valid k8s Deployment specs - Controller compiles: make build - RBAC rules generate: make manifests - Understand the reconciliation loop flow Shell commands from Go — How to run kubectl and kind commands Logging — Using log.FromContext ctx and log.Info Testing with real Kubernetes — Integration testing concepts Environment setup — How KUBECONFIG and kubectl contexts work - Set up a local Kubernetes cluster with kind - Deploy your operator locally - Manually test reconciliation - Debug with logs and kubectl Create a local test environment where you can: - Deploy your operator - Create an InferenceService CR - Watch the controller reconcile it into a Deployment - Verify everything works Create a kind cluster kind create cluster --name inferno-dev Verify it works kubectl get nodes kubectl get pods -A Install your operator CRDs make install Deploy the operator make deploy Check it's running kubectl get deployment -n inferno-system kubectl logs -n inferno-system deployment/inferno-controller-manager -f Create config/samples/inference v1alpha1 inferenceservice.yaml : apiVersion: inference.inferno.io/v1alpha1 kind: InferenceService metadata: name: test-llama spec: model: uri: s3://models/llama-3 framework: fake version: "1" replicas: min: 1 max: 3 resources: cpu: "2" memory: "8Gi" gpu: "0" autoscaling: enabled: false traffic: - version: "1" weight: 100 Deploy and observe: Apply the CR kubectl apply -f config/samples/inference v1alpha1 inferenceservice.yaml Watch the controller work kubectl logs -n inferno-system deployment/inferno-controller-manager -f Check if Deployment was created kubectl get deployments kubectl get inferenceservices kubectl describe inferenceservice test-llama Check the status kubectl get inferenceservice test-llama -o yaml View controller logs kubectl logs -n inferno-system deployment/inferno-controller-manager --tail=100 -f Describe what happened kubectl describe inferenceservice test-llama kubectl describe deployment test-llama Check events kubectl get events --all-namespaces | grep inferno Interactive debugging if needed kubectl exec -it deployment/inferno-controller-manager -n inferno-system -- bash - kind cluster running locally - Operator deployed: make deploy - InferenceService CR created - Deployment automatically created from the CR - Status updated correctly - Logs show clean reconciliation Helper functions — Extracting common code into constructService Go interfaces as contracts — Services follow same pattern as Deployments Type conversion — Using intstr.FromString for port types Map creation — Building label maps for selectors - Create and manage Kubernetes Services - Expose inference endpoints - Handle traffic routing - Understand label selectors When an InferenceService is created, the operator should also create a Kubernetes Service so clients can reach the inference server. InferenceService user writes this ↓ Controller ├→ Deployment pods run the server └→ Service exposes the server Update constructDeployment and add a new constructService in your controller: // In Reconcile , after creating/updating Deployment: // Step 2b: Create or update Service service := &corev1.Service{} serviceName := types.NamespacedName{ Name: inferenceService.Name, Namespace: inferenceService.Namespace, } if err := r.Get ctx, serviceName, service ; err = nil { if apierrors.IsNotFound err { service = r.constructService &inferenceService if err := controllerutil.SetControllerReference &inferenceService, service, r.Scheme ; err = nil { log.Error err, "Failed to set service controller reference" return ctrl.Result{}, err } if err := r.Create ctx, service ; err = nil { log.Error err, "Failed to create Service" return ctrl.Result{}, err } log.Info "Created Service", "service", service.Name } else { log.Error err, "Failed to fetch Service" return ctrl.Result{}, err } } // constructService builds a Service for the InferenceService func r InferenceServiceReconciler constructService isvc inferencev1alpha1.InferenceService corev1.Service { labels := map string string{ "app": isvc.Name, "operator": "inferno", } service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: isvc.Name, Namespace: isvc.Namespace, }, Spec: corev1.ServiceSpec{ Selector: labels, Type: corev1.ServiceTypeClusterIP, Ports: corev1.ServicePort{ { Name: "http", Port: 80, TargetPort: intstr.FromString "http" , }, }, }, } return service } Deploy updated operator make deploy Create an InferenceService kubectl apply -f config/samples/inference v1alpha1 inferenceservice.yaml Verify Service was created kubectl get svc test-llama kubectl describe svc test-llama Test from inside the cluster kubectl run -it --rm debug --image=curlimages/curl --restart=Never -- \ curl http://test-llama/health - Service created automatically with InferenceService - Service selects the right Pods - Labels and selectors match Deployment - Service exposed at DNS name