TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Automating LLM Serving Deployment with Kubernetes CRDs and Operators

Status: Draft (Updated Oct 7, 2026, augmented with scale test measurements on Oct 9, 2026). Descriptions verified against official documentation form the basis of this post, and only **the isolated environment test in Section 8, along with "Direct kopf Operator Testing" (including scale testing) and "Direct LWS Testing" at the end**, are values directly verified by me. The rest has not yet been measured in practice. Please read "Verified Versions and Dates" and "Unverified Items" at the end of the post first. Writing Rule: Measured data is the baseline, and unmeasured items are marked as "Unmeasured".

Table of Contents

LLM serving deployment involves a collection of requirements that Deployments cannot express: leaders and workers spanning multiple nodes, a preliminary weight-loading phase, rollouts that must respect ratios, GPU topologies, and scaling where the unit is a bundle of pods rather than a single pod. Kubernetes extends these through CRDs (Custom Resource Definitions) and operators. This post outlines the current shape of those APIs and the core patterns of operators, backed by behaviors verified in an isolated lab environment. I must clarify upfront that I do not have experience operating production-scale Go operators.

What You Can Explain in an Interview After Reading This Post

  1. Why it is difficult to express LLM serving with Deployments, and what abstractions LeaderWorkerSet (LWS), KServe, and Dynamo each add.
  2. Why reconciliation loop idempotency, level-triggered design, owner references and finalizers, status conditions, backoff, and leader election are designed the way they are.
  3. CRD version transition procedures, rollout strategies, and how to resolve issues where Argo CD and operators fight over the same fields.

1. Why a Single Deployment Falls Short

2. API Shapes of LWS, KServe, and Dynamo

Project (Verified Version) CRD (API Group/Version) Fields to Watch
LWS v0.11.1 LeaderWorkerSet (leaderworkerset.x-k8s.io/v1) replicas(number of groups), leaderWorkerTemplate.size, restartPolicy, rolloutStrategy.rollingUpdateConfiguration(maxUnavailable default 1, maxSurge default 0, partition), groupIdentity(immutable), scale subresource
LWS v0.11.1 DisaggregatedSet (disaggregatedset.x-k8s.io/v1) roles[]LWS per group, coordinates rollout across roles
KServe v0.21.0 InferenceService(v1beta1), ServingRuntime(v1alpha1) predictor , etc., runtime declares supported model formats
KServe v0.21.0 LLMInferenceService(v1alpha1, storage uses v1alpha2) model, template, worker(LWS for multi-node), prefill, router, rolloutStrategy, kvCacheOffloading(in v1alpha2 only)
Dynamo v1.5.0 DynamoGraphDeployment(nvidia.com, storage uses v1beta1) components[](in v1alpha1, services map), restart, topologyConstraint
Dynamo v1.5.0 DynamoComponentDeployment, DynamoGraphDeploymentRequest, DynamoGraphDeploymentScalingAdapter, DynamoModel Component deployment, SLA-based requests (generates DGD after profiling), scale for HPA, model registration

KServe documentation states that InferenceService is left for traditional predictive models, while LLMInferenceService handles prefill/decode separation, multi-node setups, and prefix-aware routing (I opened the 0.20 page). Dynamo's CRD names and fields were read directly from the CRD YAML of the v1.5.0 tag.

 DynamoGraphDeployment (dgd)  ← 사용자가 선언: 컴포넌트 목록, 복제 수, 재시작 id
        │ 오퍼레이터가 만듦
        ▼
 DynamoComponentDeployment (dcd)  ← 컴포넌트마다, 이름에 worker hash 접미사
        │ 배포 모드에 따라
        ├─▶ Deployment (단일 노드)
        └─▶ Grove PodCliqueSet 또는 LWS (다중 노드, 갱 스케줄링)

3. Core Patterns of Operators

 API Server ─watch▶ Informer(로컬 캐시) ─▶ WorkQueue(키 중복 제거, 백오프) ─▶ Reconcile(키)
      ▲                                                                        │
      └────── 변경(자식 생성, status 기록) ◀── 관측 → 비교 → 행동 → 보고 ◀────┘

Level-triggered reconciliation. Comments in the controller-runtime source code (v0.25.2) note that reconciliation is driven by the actual state read from the apiserver or cache, rather than events. Request contains only the name and namespace without specifying what changed, so even when receiving a pod deletion event, the reconciliation function figures it out by reading "pod does not exist." The Good practices chapter of the Kubebuilder book also warns that writing logic per event can clog resources and require human intervention. kopf uses a model where handlers are called based on causes like creation, modification, or deletion, so changes to un-watched children must be supplemented with timers (Section 9). However, changes during downtime are handled in a level-triggered manner by listing all objects upon startup and processing only the final state.

Idempotency. Written as "make it exist in the desired state" rather than "create it." If it already matches, writing nothing leaves sub-object resourceVersion intact and prevents unnecessary watch events. This is essential because leader election can momentarily result in two leaders.

Owner references and finalizers. According to Kubernetes documentation, a child's ownerReferences points to the owner name and UID in the same namespace; cross-namespace references are prohibited by design, and the garbage collector leaves OwnerRefInvalidNamespace warning events. When a finalizer is present, a deletion request only sets deletionTimestamp , and actual deletion happens only after the controller finishes cleanup and removes the finalizer. The Kubebuilder book advises writing both finalizer addition and cleanup logic idempotently because deletions turn into updates.

Status and conditions. status.conditions and observedGeneration expose state in a standard format. Dynamo's DGD also places conditions, observedGeneration, state, rollingUpdate in status. Argo CD documentation advises that since CRD status formats vary, custom health checks are needed, and using observedGeneration prevents misjudgments before reconciliation completes.

Retry and backoff. If a reconciliation function returns an error, the workqueue re-queues it with exponential backoff; RequeueAfter requeues after a specified duration, and the Requeue field is deprecated. TerminalError does not requeue. "Not ready yet" is properly expressed as a delayed requeue rather than an error.

Leader election. The controller-runtime manager acquires a Lease via the LeaderElection, LeaderElectionID, LeaderElectionNamespace option (confirmed in Dynamo's main.go , and the LMCache operator installation YAML also contains a leader-election Role). Kubernetes documentation explains that Leases are used for node heartbeats and control plane leader election.

Webhook validation. Simple rules are sufficient with CRD schema CEL (x-kubernetes-validations), while complex rules and injections require webhooks. Dynamo documented a rule in validation webhooks that aggregates and returns independent errors with field.ErrorList , and the LMCache operator injects connection settings into vLLM pods using mutating webhooks while issuing webhook certificates via cert-manager.

4. Go (controller-runtime/kubebuilder) vs. Python (kopf)

Category Go: controller-runtime v0.25.2, kubebuilder v4.16.0 Python: kopf 1.44.6 (2026-06-03)
Model Typed client, informer cache, workqueue, manager Decorator handlers, store state in object annotations or status
Code generation controller-gen generates CRDs (Dynamo uses v0.17.3) Write CRD YAML directly
Duplicate prevention Lease leader election KopfPeeringand priority
Ecosystem Dynamo operator README states it was built with Kubebuilder, conversion implemented via conversion.Convertible interface. LWS, KServe, and LMCache CRDs are also generated with controller-gen Keeps state in objects and provides tools like kopf.adopt , kopf explicitly states it is not a Kubernetes client library

This is my judgment. The Go ecosystem is the default for upstream-grade CRDs that require conversion webhooks and large-scale watching, whereas internal automation used by a couple of teams benefits most from the language the team knows best. Since I have no experience operating Go operators, the comparison above is the result of reading documentation and code.

5. CRD Schema Design Principles

6. Rollout Strategies

7. Conflicts with GitOps (Argo CD)

Conflicts occur when fields specified in Git are modified by an operator or HPA. replicas If you include this in Git, it fights with HPA (HPA scales it up, Argo reverts it, and HPA scales it up again). The remedies are as follows.

A common pitfall is health checks. In a structure where application health is determined by the state of child resources and synchronization waits for that health, if the built-in check evaluates a resource (e.g., a CronJob that failed once) as Degraded, synchronization may hang waiting for health and fail to apply new changes. Check rules can be solved by redefining them in Lua (I have not verified which version introduces this behavior). In ApplicationSet as well, turning off automation for child applications causes a conflict between two reconcilers where the parent template turns it back on; the solution is to narrow down the exception to a single field by name and path.

8. Behavior Verified in an Isolated Environment

The following content was verified with actual Kubernetes controllers in an isolated practice environment (a kwok cluster or personal VM) and is not a record of my production incidents.

Limitations. The above describes the behavior of Kubernetes itself verified through practice, and is not incident response for a specific operator implementation or a Go production operator.

9. Try It Yourself

Performed entirely within a single local kind cluster. All commands explicitly specify the kind-op-lab context, so they do not touch other clusters. When finished, delete the entire cluster using the final line.

kind create cluster --name op-lab
kubectl --context kind-op-lab create ns op-lab
cat <<'EOF' | kubectl --context kind-op-lab apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: modelcaches.labs.example.dev}
spec:
  group: labs.example.dev
  scope: Namespaced
  names: {kind: ModelCache, plural: modelcaches, singular: modelcache, shortNames: [mc]}
  versions:
  - name: v1
    served: true
    storage: true
    subresources: {status: {}}
    additionalPrinterColumns:
    - {name: Phase, type: string, jsonPath: .status.phase}
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            required: [model]
            properties:
              model: {type: string}
              replicas: {type: integer, default: 1, minimum: 0, maximum: 8}
            x-kubernetes-validations:
            - {rule: "self.model.size() > 0", message: "model must not be empty"}
          status:
            type: object
            x-kubernetes-preserve-unknown-fields: true
EOF

The minimal reconciliation loop (op.py) binds a single ConfigMap as an owner reference to a single CR. kopf is installed with pip install kopf kubernetes .

import kopf, kubernetes as k8s

@kopf.on.resume("labs.example.dev", "v1", "modelcaches")
@kopf.on.create("labs.example.dev", "v1", "modelcaches")
@kopf.on.update("labs.example.dev", "v1", "modelcaches")
@kopf.timer("labs.example.dev", "v1", "modelcaches", interval=30, idle=10)
def reconcile(spec, name, namespace, patch, **_):
    cm = {"apiVersion": "v1", "kind": "ConfigMap",
          "metadata": {"name": f"{name}-cfg"},
          "data": {"model": spec["model"], "replicas": str(spec.get("replicas", 1))}}
    kopf.adopt(cm)                      # ownerReferences: 이 CR의 이름과 uid
    api = k8s.client.CoreV1Api()
    try:
        api.create_namespaced_config_map(namespace, cm)
    except k8s.client.ApiException as e:
        if e.status != 409:
            raise
        api.patch_namespaced_config_map(f"{name}-cfg", namespace, cm)   # 멱등
    patch.status["phase"] = "Ready"

@kopf.on.delete("labs.example.dev", "v1", "modelcaches")
def cleanup(name, logger, **_):
    logger.info("%s: 외부 자원 정리 자리", name)     # 삭제 핸들러가 있으면 kopf 가 finalizer 를 붙인다
kubectl config current-context            # kind-op-lab 인지 확인(kind 가 만들면서 바꿉니다)
kopf run --standalone --namespace op-lab op.py &
kubectl --context kind-op-lab -n op-lab apply -f - <<'EOF'
apiVersion: labs.example.dev/v1
kind: ModelCache
metadata: {name: demo}
spec: {model: tiny-llm}
EOF
kubectl --context kind-op-lab -n op-lab get mc,cm -o wide
kubectl --context kind-op-lab -n op-lab get cm demo-cfg -o jsonpath='{.metadata.ownerReferences}'
# 에지 대 레벨 관찰: 자식을 지워도 CR 이벤트가 없으므로 타이머(30초 주기)가 되살립니다
kubectl --context kind-op-lab -n op-lab delete cm demo-cfg && sleep 40 && kubectl --context kind-op-lab -n op-lab get cm
# CEL 검증: spec.model 을 빈 문자열로 넣은 CR 을 적용하면 "model must not be empty" 로 거절됩니다
# 정리
kill %1; kind delete cluster --name op-lab

Things to try next: ownerReferences Check if a ConfigMap with a purposely mismatched UID gets deleted, and observe deletionTimestamp and the list of finalizers during CR deletion. Because this skeleton does not watch child changes, it is intended for learning and supplemented by a timer.

10. Potential Interview Questions

  1. Why use LWS instead of Deployment for LLM serving? Because replicas are bundles of leaders and workers, requiring scaling, rolling updates, disaster recovery, and topology placement to be handled at the group level. Since collective communication breaks if a single pod fails, the default policy is to recreate the entire group.
  2. Why is it okay even if a reconciliation loop misses an event? Because requests do not contain event details; instead, the current state is read every time to bridge the gap. Even if a controller crashes and restarts, running it from the beginning to finish only the remaining work requires all writes to be idempotent.
  3. What is the difference between ownerReference and finalizer? The former is a mechanism where the child points to the parent and lets the garbage collector handle cleanup, and the child is deleted if the uid does not match. The latter is a mechanism that delays deletion for external resource cleanup; if removed without a controller, cleanup is skipped.
  4. What is the procedure for promoting a CRD from v1alpha1 to v1beta1? Add the new version as served, attach a conversion webhook if necessary, migrate storage and rewrite all existing objects, then storedVersions clean up and turn off the old version. Strategy None is safe only when the field shapes are identical.
  5. What if Argo CD reverts a field changed by the operator? Remove that field from Git, or if that is difficult, exclude it from comparison using ignoreDifferences and from synchronization using RespectIgnoreDifferences . Health reflects observedGeneration via a custom Lua check for the CR.

11. Common Misconceptions

External Basis

Claim Material Verified Content
Reconciliation is driven by current state, not events controller-runtime pkg/reconcile/reconcile.go 's Request·Reconciler comment Request contains only the name and namespace with no event content. It states that reconciliation is level-driven and powered by the actual state read from the apiserver or cache, citing the example that even the fact that a pod was deleted is recognized while reading the state, not through a delete event. The Requeue field is deprecated; use RequeueAfter instead
Event-specific logic is an anti-pattern Kubebuilder book, Good practices Warns that writing reconciliation logic per event violates the operator pattern and can jam resources, requiring manual intervention; instructs writing code so that multiple reconciliation runs yield the same result
Controllers do not know how long they were turned off Kubernetes community documentation (sig-api-machinery, controllers.md) Must observe the current state rather than transitions, informers periodically resync, work queues only hold keys to deduplicate, and errors are requeued with exponential backoff
kopf's watch scope and timers kopf documentation (handlers, timers, continuity, errors) Handlers react only to intrinsic changes detected by diff, with no description of changes in unwatched children. Timers run periodically while the object exists, changes while turned off are listed upon restart to process only the final state, and the default retry interval for general errors is 60 seconds
Origin of the term "level-triggered" The controller-runtime comment and community documentation above The kubernetes.io controller concepts page describes control loops and "bringing the current state closer to the desired state" but does not use the term itself. This article attributes the term to controller-runtime and the Kubebuilder book

References (actually opened)

Direct testing of the kopf operator

Running the minimum reconciliation loop from Section 9 (op.py, unchanged) on a disposable single-node k3s (v1.36.5, 4 vCPU virtual machine) with kopf 1.44.6, I verified the pattern described in Section 3. A version with just one extra log line to count call counts (op2.py) was used for duplicate execution and error testing.

What Was Verified Result
From CR creation until the child ConfigMap appears, and status.phase=Ready until 0.1 seconds each
Child owner references CR's name and UID, and controller: true, blockOwnerDeletion: true
schema defaults replicas If omitted, 1 is populated into the ConfigMap "replicas":"1"
CEL validation Empty model is model must not be empty, model , omission Required value, replicas: 99 rejected by rule of 8 or less (API server stage)
finalizer kopf.zalando.org/KopfFinalizerMarker attached
Recovery time when deleting the child ConfigMap (5 runs, randomized start phases) 2.5, 15.9, 24.9, 17.8, 26.9 seconds
Restoring original values when child contents are changed 29.9 seconds
Until CR spec changes are reflected in the ConfigMap 0.1 seconds
During three reconciliation runs (95 seconds), the child and CR's resourceVersion Both immutable (idempotent)
Spec changes and child deletion while the operator was stopped, after restart ConfigMap restored in 0.6 seconds (replicas=5 reflected)
Deleting CR without the operator CR remains with only deletionTimestamp attached and the child ConfigMap intact. Turning on the operator made the CR disappear after 0.6 seconds, and the child disappeared via garbage collection after 0.1 seconds.

Error handling and retry intervals. A CR configured to always fail its reconciliation function was left for 100 seconds while call timestamps were recorded (kopf defaults).

Thrown exception Calls over 100 seconds Interval
General exception (RuntimeError) 4 times Creation handler fails and retries after 60 seconds; timer also fails separately and retries after 60 seconds (call times +0, +10, +60, +70 seconds)
kopf.TemporaryError(delay=5) 20 times Every 5 seconds
kopf.PermanentError 4 times Creation handler does not retry (0 succeeded; 1 failed). However, the timer keeps running and produces the same error every 30 seconds (+10, +40, +70 seconds)

Scale testing (10–300 CRs). Using a single-node kind cluster (Kubernetes v1.36.1) on Docker Desktop (10 VM CPUs, 8GB RAM) on a laptop (Apple M1 Max), the above op.py was run un-modified as an out-of-cluster process using kopf 1.44.6. Delay was measured from when the watcher first saw a CR until it received the first event carrying status.phase=Ready , showing the median of 3 runs after excluding 1 warm-up run per condition.

CR count Applied as single manifest: Total processing time Delay median / p95 / max kubectl 20개씩 병렬: 전체 처리 Delay median / p95 / max
10 0.17 seconds 0.030 / 0.041 / 0.043 seconds 0.22 seconds 0.082 / 0.095 / 0.096 seconds
50 0.56 seconds 0.029 / 0.043 / 0.044 seconds 1.08 seconds 0.127 / 0.187 / 0.196 seconds
100 1.96 seconds 0.049 / 0.064 / 0.086 seconds 2.10 seconds 0.073 / 0.127 / 0.180 seconds
300 3.61 seconds 0.032 / 0.063 / 0.115 seconds 7.28 seconds (2 runs) 0.102 / 0.188 / 0.254 seconds

Direct Testing of LWS

Installing LWS v0.11.1 on a disposable single-node k3s (v1.36.5, 4 vCPU, 4GiB virtual machine), group structures, recovery, rolling updates, and scaling were verified using busybox sleep pods. Since GPUs and models were not used, model loading times are excluded, and times were measured at 0.5-second intervals.

What Was Verified Result
Installation 17 seconds from manifest application to controller readiness. Along with CRDs, LeaderWorkerSet and DisaggregatedSet, DisaggregatedSetRoleScaler were installed
Creating a group replicas: 2, size: 3 resulted in all 6 pods becoming Running in 4.8 seconds. Names were leader demo-0, worker demo-0-1, demo-0-2, and second group demo-1, demo-1-1, demo-1-2
Created subresources One leader StatefulSet (demo, equal to the number of groups), one worker StatefulSet per group(demo-0, demo-1, and one headless service shared by all groups (demo). Pod owners are StatefulSet/demofor leaders and StatefulSet/demo-0
Pod labels leaderworkerset.sigs.k8s.io/ under name, group-index, worker-index, group-key(unique hash per group), template-revision-hash
Injected environment variables LWS_LEADER_ADDRESS(e.g., demo-1.demo.default, workers also receive their group leader's address), and LWS_GROUP_SIZE=3, LWS_WORKER_INDEX(starting from 0 for leaders, 1 for workers)
Defaults rolloutStrategy: RollingUpdate, maxSurge: 0, maxUnavailable: 1, partition: 0(consistent with Section 6 description). startupPolicy: LeaderCreated, networkConfig.subdomainPolicy: Shared, restartPolicy: RecreateGroupOnPodRestart

error. sleep, the task performed by pods is busybox, the images are pre-pulled

, and there are no readiness probes. Group recovery due to node failures and DisaggregatedSet were not tested (unmeasured). Verified versions and dates (2026-10-05)

LWS v0.11.1 (2026-10-01), KServe v0.21.0 (2026-09-25, documentation is for version 0.20), Dynamo v1.5.0 (2026-09-21), controller-runtime v0.25.2 (2026-10-01), kubebuilder v4.16.0 (2026-09-10), kopf 1.44.6 (2026-06-03), Argo CD v3.5.3 (2026-09-14), LMCache v0.5.5, Kubernetes v1.37.1 (2026-09-23).

Things that were not verified: StorageVersionMigration default activation status of (descriptions conflict within the Kubernetes documentation), performance limits of kopf with over 1,000 CRs across multiple nodes (tested up to 300 CRs in the scale test above), details of controller unit testing tools (envtest), pros and cons of ValidatingAdmissionPolicy versus webhooks, differences between the latest KServe documentation and version 0.21.0, and the exact sub-resource creation paths for Dynamo's DGD deployment modes (diagrams were drawn based on CRD descriptions and documentation, but code paths were not traced). The kopf skeleton in Section 9 was written by me based on documentation and has not been validated by running it on an actual cluster.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment