Tutorial: Writing a Drain Plugin
Tutorial: Writing a Drain Plugin
This tutorial walks you through building a drain plugin with
Kubebuilder — from kubebuilder init to a deployed controller that
drains a node on NVSentinel’s behalf.
By the end you will have:
- A Kubebuilder project whose controller reconciles
DrainRequestcustom resources and drains a node. - A custom drain policy the built-in drainers don’t have: it only drains on allowed weekdays and defers (requeues) otherwise — showing how a plugin gates a drain instead of just deleting pods.
- A container image for it, built and deployed with the generated
maketargets. - Verification of both paths — a deferred drain and a completed drain — by flipping one env var.
Who is this for? Teams already running NVSentinel who need NVSentinel’s drain step to hand off to a custom plugin (Slurm, NVCF, a custom workload manager, …) instead of evicting pods directly.
Just want the AI to do it? Jump to Appendix: One-shot AI prompt.
Prerequisites
go1.24+ — builds the controller.kubebuilder4.x — scaffolds the project. It vendorscontroller-genandkustomizethrough the generatedMakefile, so you don’t install those separately.docker— builds and pushes the container image (make docker-build docker-push).kubectlwith access to any Kubernetes cluster — deploys and verifies the plugin (make deploy).
Install Kubebuilder if you don’t have it:
1. What is a drain plugin
NVSentinel remediates a faulty node in stages: detect → quarantine (cordon) → drain →
remediate. The drain stage is owned by the node-drainer. By default node-drainer
evicts pods itself (built-in modes: Immediate, AllowCompletion, DeleteAfterTimeout).
That is not always enough. Some clusters need the drain coordinated by custom policies. A drain plugin lets you replace node-drainer’s eviction step with your own logic.
When custom drain is enabled, node-drainer no longer evicts pods. Instead it:
- Renders a Go template you ship (a drain-template ConfigMap) into a
DrainRequestcustom resource — one per health event - Polls that
DrainRequest’s status, waiting for a configured condition (DrainComplete=True). - Once the condition is set, marks the drain complete and deletes the
DrainRequest.
Your plugin is a standard Kubernetes controller that reconciles DrainRequest objects: do
whatever draining your environment needs, then set status.conditions[DrainComplete]=True.
You ship two things and flip one switch:
2. The contract — the DrainRequest CRD
The CRD is DrainRequest in group/version nvsentinel.nvidia.com/v1alpha1, namespaced
(node-drainer creates them in nvsentinel). You define this CRD yourself — its spec and
status fields are described below.
spec — node-drainer fills this in from the triggering HealthEvent via your template. A
plugin only has to define the fields it actually uses, so for this tutorial we keep the spec to
two fields:
node-drainer offers more optional context you can add if your drain logic needs it —
checkName,recommendedAction,errorCode,healthEventID,entitiesImpacted,metadata,reason. Fields your CRD doesn’t declare are simply pruned. node-drainer derives the CR’s name and namespace from the health event, not fromspec, so none of these are strictly required.
status — what your plugin writes:
The completion contract
node-drainer waits for a status.conditions[] entry whose Type and Status match its
configuration. The convention (and what this tutorial uses) is:
Setting that condition is the one thing your reconcile must guarantee once the node is truly drained. Everything else is up to you.
The golden rules
- Reconcile is idempotent. It can be called many times for the same
DrainRequest. Make it a no-op onceDrainComplete=True. - Only set
DrainComplete=Truewhen the drain is actually done. — requeue and re-check until then. - One
DrainRequestper health event. node-drainer deletes it after completion.
3. Scaffold the project with Kubebuilder
Create an empty directory and initialize a Kubebuilder project. The --domain becomes the CRD
group suffix and the --repo is your Go module path — neither needs to be inside the NVSentinel
repo.
Now scaffold the API and its controller. Using --group nvsentinel with the
nvidia.com domain produces the contract group nvsentinel.nvidia.com:
This generates the standard Kubebuilder layout:
You only edit two files (drainrequest_types.go, drainrequest_controller.go); the Makefile
regenerates everything else.
4. Define the API types
Open api/v1alpha1/drainrequest_types.go and replace the scaffolded DrainRequestSpec /
DrainRequestStatus with the contract fields. Leave the generated DrainRequest,
DrainRequestList, and SchemeBuilder.Register(...) as Kubebuilder wrote them.
Make sure the DrainRequest type keeps the status subresource marker Kubebuilder added:
Regenerate the deepcopy methods and the CRD manifest from these markers:
Using the standard
DrainRequestkind means node-drainer’s template shape renders straight into it. If you need a different schema, change these types and your template together — node-drainer is schema-agnostic and only cares about the GVK and the completion condition.
5. Implement the controller
Open internal/controller/drainrequest_controller.go. Replace the generated Reconcile body and
SetupWithManager, add two small helpers, and update the RBAC markers. Our worked example does
something the built-in drainers don’t: it only drains on allowed weekdays. When today is an
allowed day it evicts exactly the pods node-drainer listed in spec.podsToDrain; on any other day
it records why on the CR and requeues until the next allowed day without touching a single
pod. This is the key pattern for a gated drain — returning ctrl.Result\{RequeueAfter: …\} instead of
completing. The allowed days come from a DRAIN_DAYS environment variable (default
Mon,Tue,Wed,Thu,Fri), which also makes both paths easy to verify.
The pods RBAC marker means you must regenerate the ClusterRole:
Finally, feed the DRAIN_DAYS env var into the reconciler in cmd/main.go. Kubebuilder generated a
DrainRequestReconciler\{Client, Scheme\} literal — add the DrainDays field and a tiny env helper:
Build to confirm everything compiles:
That’s a complete, contract-correct drain plugin. Everything else is packaging and wiring.
6. Build and push the image
Kubebuilder already generated a multi-stage, distroless Dockerfile. Build and push it with the
Makefile targets, pointing IMG at a registry your cluster can pull from (Docker Hub here —
replace <dockerhub-user>):
Use a public repo so the cluster can pull without credentials. For a private repo, add an
imagePullSecretto the manager viaconfig/.
7. Deploy and verify
Use the generated kustomize harness to install the CRD, RBAC, and controller, then verify the plugin
two ways. §7b hands it a DrainRequest by hand — fast, and
needs no other NVSentinel components. §7c wires
the plugin into NVSentinel and drives the real end-to-end flow — inject a real GPU fault and watch
NVSentinel cordon the node, create the DrainRequest, and your plugin drain the workload.
7a. Deploy with make
make install applies the CRD; make deploy applies the RBAC and the manager Deployment
(kustomize puts the manager in the demo-drainer-system namespace and grants a cluster-wide
ClusterRole, so it reconciles DrainRequests in any namespace):
7b. Verify the plugin independently
Create a workload and hand the plugin a DrainRequest by hand. We verify both the weekday
gate — draining is deferred on a disallowed day, and completes once the day is allowed — by flipping
the DRAIN_DAYS env var (which restarts the manager and re-reconciles the request each time).
Path 1 — deferred (disallowed day). Point the policy at a day that isn’t today, then submit the request:
Expected (the message names today, which is not in the allowed set):
Path 2 — completed (allowed day). Now allow today. Restarting the manager re-reconciles the existing request immediately:
Expected:
In production node-drainer creates the
DrainRequestinnvsentinel; the cluster-wide ClusterRole means the same controller handles it there too.
7c. Verify the full remediation flow (end-to-end)
This wires the plugin into NVSentinel and drives the real path — detect → quarantine (cordon) →
drain → complete — observable as the node’s state label moving quarantined → draining → drain-succeeded.
Step 1 — Configure the cluster to use your plugin
node-drainer only creates DrainRequests when custom-drain mode is enabled and pointed at your
CR. Two config pieces do that, and both must be part of your NVSentinel deployment:
a. A drain-template ConfigMap — node-drainer renders this Go template into a DrainRequest. Our
minimal CRD defines nodeName and podsToDrain, so those are the only fields we render
(.PodsToDrain is exactly the set our controller acts on):
b. node-drainer Helm values pointing at your CR. customDrain is mutually exclusive with
userNamespaces (leave the latter empty), and the condition fields must match what your controller
sets:
With the two pieces above applied, node-drainer renders the template into a DrainRequest for any
node it decides to drain, polls its status, and — once your controller sets DrainComplete=True —
marks the node drained and deletes the CR.
c. Allow today in the plugin. The weekday gate must be open, or the plugin defers and node-drainer times out waiting:
Deploy NVSentinel with this configuration. The rest of this section needs a full NVSentinel install —
platform-connectors,fault-quarantine,node-drainer(with thecustomDrainvalues above), andgpu-health-monitor— all running, plus your plugin from §7a.
Step 2 — Run a workload on the node you’ll fault
Pick the GPU node you’ll inject the fault into, then pin the workload to it so the pods you expect to
be drained actually land there. node-drainer fills podsToDrain from non-system namespaces that
have evictable pods on the node:
Step 3 — Inject an XID error
Inject a fatal XID (95, an uncontained ECC error) into a GPU on $NODE through DCGM.
gpu-health-monitor watches the same DCGM and turns it into a fatal GPU health event:
-f 230isDCGM_FI_DEV_XID_ERRORS;-v 95injects XID 95;--gpuid 1picks the GPU.- Run it against the DCGM instance on
$NODE(e.g.kubectl execinto thenvidia-dcgmpod on that node)
Step 4 — Verify: cordon → DrainRequest → workload drained
Watch the node march through the stages:
Expected STATE progression: <none> → quarantined (fault-quarantine cordoned it) → draining
(node-drainer started) → drain-succeeded.
node-drainer creates the CR (drain-<node>-<eventID> in nvsentinel) and your plugin acts on it. The
CR is short-lived — node-drainer deletes it once your plugin sets DrainComplete=True — so watch
promptly or read the plugin’s logs:
Finally, confirm the workload was drained — the node reaches drain-succeeded and the victim pods are
gone (the Deployment schedules replacements, but the node stays cordoned so they don’t land back on
it):
Recover the node when you’re done:
Tear down with
make undeploy && make uninstall(andkubectl delete ns workload).
Appendix: One-shot AI prompt
Paste this to an AI coding agent. It is self-contained: the plugin is a standalone Kubebuilder project that can be created in any repository (it does not need to be inside the NVSentinel repo). Replace the bracketed parts.