Tuesday, October 20, 2026

Walking Through NVIDIA Attribution

When a GPU job dies in the middle of the night, the first question is almost never "what happened?" It is "whose problem is this?" Is the node bad and does it need to be pulled out of rotation, or did someone submit a job that asked for more memory than the GPU has? NVIDIA Attribution is a service built to answer that question. It starts with a described workload, hand it the evidence collected (log lines from the nodes the job ran on, for example), and it matches that evidence against a ruleset to produce a verdict: the root symptom, the outcome and the failure domain it belongs to.

In this post I am going to walk through a small end to end demo of Attribution running on a single virtual machine. We will build the Attribution Blueprint container image, push it to a registry, deploy it into a MicroK8s cluster, and then call the API by hand with a simulated failed PyTorch job. The failure here will be a CUDA out of memory error, which makes for a nice example because the right answer is that the user owns the problem and the hardware is fine. I should be upfront that the workload here is made up and so is the single log line. It is a way to see the moving pieces, not a real incident. With all that said let's get started together on our journey.

Build and Push the Blueprint Image

The first step is building the container image from the Attribution repository. The virtual machine is an arm64 system so I am passing --platform linux/arm64, and I am tagging the image for the registry that runs in my lab environment on port 5000. This is a multi-stage build. One stage uses a Node 22 image to compile the web dashboard frontend with pnpm and Vite, and the final stage is Python 3.14 on Debian trixie where uv installs the blueprint, the service app and the attribution library. The build also installs gcc just long enough to compile a few dependencies and then purges it again to keep the image small.

$ docker build --platform linux/arm64 -f blueprint/docker/Dockerfile -t registry.schmaustech.com:5000/attribution-blueprint-web:latest . [+] Building 1.4s (32/32) FINISHED docker:default => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 3.07kB 0.0s => resolve image config for docker-image://docker.io/docker/dockerfile:1.7 0.4s => CACHED docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e 0.0s => [internal] load metadata for ghcr.io/astral-sh/uv:0.10.12 0.4s => [internal] load metadata for docker.io/library/node:22-bookworm-slim 0.6s => [internal] load metadata for docker.io/library/python:3.14.6-slim-trixie 0.7s => [internal] load build context 0.0s => => transferring context: 60.10kB 0.0s => [web-build 1/4] FROM docker.io/library/node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 0.0s => FROM ghcr.io/astral-sh/uv:0.10.12@sha256:72ab0aeb448090480ccabb99fb5f52b0dc3c71923bffb5e2e26517a1c27b7fec 0.0s => [stage-1 1/19] FROM docker.io/library/python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 0.0s => CACHED [stage-1 2/19] COPY --from=ghcr.io/astral-sh/uv:0.10.12 /uv /uvx /bin/ 0.0s => CACHED [stage-1 3/19] WORKDIR /opt/blueprint 0.0s => CACHED [stage-1 4/19] COPY blueprint/pyproject.toml blueprint/uv.lock ./ 0.0s => CACHED [stage-1 5/19] COPY blueprint/src/ ./src/ 0.0s => CACHED [web-build 2/4] WORKDIR /frontend 0.0s => CACHED [web-build 3/4] COPY blueprint/src/attribution_blueprint/webui/frontend/ ./ 0.0s => CACHED [web-build 4/4] RUN corepack enable && pnpm install --frozen-lockfile && pnpm exec tsc -b && pnpm exec vite build --outDir dist 0.0s => CACHED [stage-1 6/19] COPY --from=web-build /frontend/dist ./src/attribution_blueprint/webui/static/ 0.0s => CACHED [stage-1 7/19] COPY service/service-app/nvidia_attribution_client/ /opt/service/service-app/nvidia_attribution_client/ 0.0s => CACHED [stage-1 8/19] RUN uv sync --extra web --no-dev --no-editable --frozen 0.0s => CACHED [stage-1 9/19] WORKDIR /opt/attribution-service 0.0s => CACHED [stage-1 10/19] COPY service/attribution-lib/ /opt/attribution-lib/ 0.0s => CACHED [stage-1 11/19] COPY service/service-app/pyproject.toml service/service-app/uv.lock ./ 0.0s => CACHED [stage-1 12/19] COPY service/service-app/_build_version.py ./ 0.0s => CACHED [stage-1 13/19] COPY service/service-app/README.md service/service-app/LICENSE ./ 0.0s => CACHED [stage-1 14/19] COPY service/service-app/app/ ./app/ 0.0s => CACHED [stage-1 15/19] COPY service/service-app/nvidia_attribution_client/ ./nvidia_attribution_client/ 0.0s => CACHED [stage-1 16/19] RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && uv sync --no-dev --no-editable --frozen && apt-get purge -y --auto-remove gcc libc6-dev && rm -rf /var/lib/ap 0.0s => CACHED [stage-1 17/19] COPY blueprint/docker/start.sh /usr/local/bin/attribution-blueprint-start 0.0s => CACHED [stage-1 18/19] RUN chmod +x /usr/local/bin/attribution-blueprint-start 0.0s => CACHED [stage-1 19/19] WORKDIR /opt/blueprint 0.0s => exporting to image 0.0s => => exporting layers 0.0s => => writing image sha256:7417910ee0d5bc1d35e8030e63996939c804eff310e63c9209131f4cc443df96 0.0s => => naming to registry.schmaustech.com:5000/attribution-blueprint-web:latest

The above output we can observe almost every step says CACHED and the whole build finished in a little over a second. That is only because I had built this image before, so on a first run expect it to take considerably longer while the frontend and Python dependencies are pulled and built. At this point we have an image sitting in the local Docker image store, so let's push it to the registry so the cluster can pull it.

$ docker push registry.schmaustech.com:5000/attribution-blueprint-web:latest The push refers to repository [registry.schmaustech.com:5000/attribution-blueprint-web] 5f70bf18a086: Pushed b78e1eeee5c9: Pushed e5c73dd8dfa2: Pushed bd7bce39331b: Pushed 66a7a0b9edd3: Pushed 6a52fb04e724: Pushed 87fa1d674280: Pushed 199eb863db71: Pushed 9c5d441c4e95: Pushed fe962dda67f4: Pushed 56f16490e6f3: Pushed 5b91b81ea413: Pushed 740cf88824b2: Pushed 2a56874aabc8: Pushed a4a5b6e3a44e: Pushed d142ef99965e: Pushed c0c0660a42bc: Pushed 740399ee44b6: Pushed 5f35d866a418: Pushed 2e68013aee3d: Pushed 86d005034ff8: Pushed c01c35a040a2: Pushed local: digest: sha256:7c387a2c2750c7e780f9ce6d397c97ddc6193da287daf0827e69358753e84119 size: 4923

Twenty-two layers pushed and a digest at the bottom. If everything looks good we can move onto deploying it.

Deploy Attribution to Kubernetes

For the deployment I always prefer yaml manifests, so the following is a single file that holds everything Attribution needs. Let's look at it and then go through what each object does.

$ cat attribution-deployment.yaml apiVersion: v1 kind: Namespace metadata: name: attribution --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: attribution-data namespace: attribution spec: accessModes: - ReadWriteOnce storageClassName: local-path-persistent resources: requests: storage: 1Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: attribution namespace: attribution spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: attribution template: metadata: labels: app: attribution spec: containers: - name: attribution image: registry.schmaustech.com:5000/attribution-blueprint-web:latest imagePullPolicy: Always env: - name: ATTRIBUTION_DB value: /data/attributions.db - name: ATTRIBUTION_SERVICE_PORT value: "8000" - name: ATTRIBUTION_WEB_PORT value: "8080" ports: - name: api containerPort: 8000 - name: web containerPort: 8080 startupProbe: httpGet: path: / port: web periodSeconds: 5 failureThreshold: 36 readinessProbe: httpGet: path: / port: web periodSeconds: 10 livenessProbe: httpGet: path: /v2/healthz port: api periodSeconds: 20 failureThreshold: 3 resources: requests: cpu: 100m memory: 256Mi limits: memory: 1Gi volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: attribution-data --- apiVersion: v1 kind: Service metadata: name: attribution namespace: attribution spec: type: NodePort selector: app: attribution ports: - name: web port: 8080 targetPort: web nodePort: 30080 - name: api port: 8000 targetPort: api

The file starts with an attribution namespace and a one gigabyte PersistentVolumeClaim named attribution-data. That claim is what holds the SQLite database the web dashboard reads from, and I am using the local-path-persistent storage class since this is a single node lab. The Deployment runs one replica and uses the Recreate strategy rather than a rolling update. Since the volume is ReadWriteOnce and SQLite does not like two writers, we want the old pod gone before the new one starts.

The container exposes two ports: 8000 for the Attribution API and 8080 for the web dashboard, and the environment variables point the database at /data/attributions.db on the mounted volume. The probes are worth a quick look. The startup and readiness probes hit the web UI on /, while the liveness probe hits /v2/healthz on the API port. The startup probe gives the pod up to three minutes (5 second period times 36 failures) to come up, which is plenty of time for the first start. Finally the Service is a NodePort, with the web UI pinned to 30080 and the API port left for Kubernetes to assign.

With the manifest ready we can create everything efficiently in one shot.

$ kubectl create -f attribution-deployment.yaml namespace/attribution created persistentvolumeclaim/attribution-data created deployment.apps/attribution created service/attribution created

Now let's see if the pod comes up. Below we are asking for the pods, the claim and the service all at once so we can confirm each piece.

$ kubectl -n attribution get pods,pvc,service -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES pod/attribution-75f57d76d9-rw592 1/1 Running 0 45s 10.1.174.232 gpu-node <none> <none> NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE VOLUMEMODE persistentvolumeclaim/attribution-data Bound pvc-9e497dc7-2b0f-4cef-bb8e-e0d074241f75 1Gi RWO local-path-persistent <unset> 45s Filesystem NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR service/attribution NodePort 10.152.183.26 <none> 8080:30080/TCP,8000:30496/TCP 45s app=attribution

The pod is Running and 1/1 ready, the claim is Bound to a one gigabyte volume, and the service shows the web UI on 8080 mapped to node port 30080 and the API on 8000 mapped to node port 30496. We will use both of those node ports in a moment. A plain pod listing a bit later confirms it has stayed up with no restarts.

$ kubectl get pods -n attribution NAME READY STATUS RESTARTS AGE attribution-75f57d76d9-rw592 1/1 Running 0 74s

Validate the Service

Before sending any real requests I want to confirm both ports answer. We can verify this by calling the health endpoint on the API node port, using the IP address of the virtual machine.

$ curl --max-time 10 -fsS http://192.168.0.111:30496/v2/healthz null

A bare null looks odd, but it is what the health endpoint returns on success. What matters is that curl was run with -f, so it would have failed loudly on any HTTP error. Next up is the web dashboard, where I only care about the status code.

$ curl --max-time 10 -fsS -o /dev/null -w 'Web UI HTTP %{http_code}\n' http://192.168.0.111:30080/ Web UI HTTP 200

An HTTP 200 from the dashboard. At this point I feel pretty confident the deployment is healthy. To save some typing for the rest of the demo I am exporting the API address into a variable and calling the health endpoint once more through it.

$ export SVC="http://192.168.0.111:30496" $ curl --fail-with-body -sS "${SVC}/v2/healthz"

Create an Attributor and Submit an Observation

Now for the interesting part. Everything in the Attribution API hangs off an attributor, which is how I think about it: a record of one workload you want an answer about. To create one we POST to /v2/attributor with a description of the workload and some metadata about where it ran.

The workload here is a made up PyTorch job named curl-example, submitted through Kueue, that ran for thirty minutes on GPUs 0 and 1 of a node called node-a and ended in a FAILED status. The metadata identifies the cluster, the agent that reported it, a tenant and the ruleset to use, which is default. I am piping the response through jq to pull out just the attributor_id and store it in a variable called ATT so we can reuse it.

$ export ATT="$( curl --fail-with-body -sS \ -X POST \ -H 'Content-Type: application/json' \ "${SVC}/v2/attributor" \ --data-binary @- <<'JSON' | jq -er '.attributor_id' { "workload": { "id": "curl-example-001", "type": "kueue", "job_start_time": "2026-10-07T10:00:00Z", "job_end_time": "2026-10-07T10:30:00Z", "interest_start_time": "2026-10-07T10:00:00Z", "interest_end_time": "2026-10-07T10:30:00Z", "timezone": "UTC", "status": "FAILED", "nodes": { "node-a": [0, 1] }, "name": "curl-example", "username": "example-user", "framework": "pytorch" }, "metadata": { "cluster": "local-microk8s", "agent": "curl", "tenant": "example-tenant", "ruleset": "default" } } JSON )" $ 1094296642135612619

That large number at the bottom is the attributor ID. The attributor knows about the workload but it has not seen any evidence yet, so let's give it some. Evidence is submitted as an observation against the attributor. The observation has a context saying it came from an application log (applog), and a body that carries the log line along with the resource it came from. In this case that is a single line, [rank 0] RuntimeError: CUDA out of memory, from node-a. The body is itself a JSON string, which is why you see all of the escaped quotes.

$ curl --fail-with-body -sS \ -X POST \ -H 'Content-Type: application/json' \ "${SVC}/v2/attributor/${ATT}/observation" \ --data-binary @- <<'JSON' { "context": { "time_interval": { "start": null, "end": null }, "source": "applog" }, "body": "[{\"body\":\"[rank 0] RuntimeError: CUDA out of memory\",\"resources\":[{\"kind\":\"node\",\"hostname\":\"node-a\"}]}]" } JSON {"observation_id":-5460837407236178041,"model_type":"ObservationResponseV2"

The API hands back an observation ID, which tells us the line was accepted and recorded against our attributor.

Read the Verdict

With the evidence in place we can ask Attribution what it thinks happened.

$ curl --fail-with-body -sS "${SVC}/v2/attributor/${ATT}/attribution" | jq . { "verdict": { "root_symptom": { "symptom": "cudaError out of memory", "source": { "observation_id": -5460837407236178041, "source": "applog", "pos": 1, "line": "[rank 0] RuntimeError: CUDA out of memory", "rule": "cudaError out of memory", "time": null }, "catalog_id": "cudaerror-out-of-memory" }, "attribution_outcome": "Fail User", "detail": "Not Applicable", "terminal_states": [ "Fail" ], "failure_domains": [ "User" ], "cancellation_actors": [], "uncertainty_type": null }, "symptoms": { "cudaError out of memory": [ { "type": "NodeSource", "node": "node-a", "status": "bad", "source": { "observation_id": -5460837407236178041, "source": "applog", "pos": 1, "line": "[rank 0] RuntimeError: CUDA out of memory", "rule": "cudaError out of memory", "time": null } } ] }, "observation_context": [ -5460837407236178041 ], "collectors": { "nccl_version": "NA", "cuda_version": "NA", "watchdog_error_time": null, "watchdog_timeout_seconds": null, "runtime_args": "{}", "bad_pairs": "", "nvrx_straggler": {}, "megatron_straggler": {}, "xid_pci_mappings": "{}", "recovery_actions": "", "investigatory_actions": "" }, "model_type": "AttributionResponseV2" }

There is a lot in that response, so let's go through the parts that matter. The verdict is the answer. The root_symptom shows that the log line matched the rule cudaError out of memory, and it points back at the observation, the log source and the exact line that triggered it. The attribution_outcome is Fail User and the failure_domains list contains only User. In plain terms, Attribution is telling us this job failed because of something in the job itself and not because of the node it ran on. There is no reason to cordon node-a or open a hardware ticket for this one.

The symptoms section ties the symptom to the specific node and marks it bad, and the collectors section holds extra context that Attribution can gather for a job, such as NCCL and CUDA versions, watchdog timeouts and straggler data. Those are mostly NA or empty here since we only gave it one log line and there was no real job to collect from.

Save the Result to the Dashboard

This next part tripped me up a little at first, so it is worth calling out. The API calls we just made return the attribution result, but they do not populate the web dashboard's SQLite database. The Blueprint CLI normally handles that persistence. Since we are driving the API by hand with curl, I am going to do the same thing the CLI would do, in a few small steps.

First I save the workload definition to a file in /tmp. It is the same workload we posted earlier, just without the metadata block.

$ cat > /tmp/attribution-workload.json <<'JSON' { "workload": { "id": "curl-example-001", "type": "kueue", "job_start_time": "2026-10-07T10:00:00Z", "job_end_time": "2026-10-07T10:30:00Z", "interest_start_time": "2026-10-07T10:00:00Z", "interest_end_time": "2026-10-07T10:30:00Z", "timezone": "UTC", "status": "FAILED", "nodes": { "node-a": [0, 1] }, "name": "curl-example", "username": "example-user", "framework": "pytorch" } } JSON

Then I fetch the attribution result for our attributor and write it to a file.

$ export SVC="http://192.168.0.111:30496" $ curl --fail-with-body -sS "${SVC}/v2/attributor/${ATT}/attribution" -o attribution-result.json

With both pieces on disk, jq merges them into a single dashboard record that holds the workload and its result side by side.

$ jq -n --slurpfile request /tmp/attribution-workload.json --slurpfile result attribution-result.json '{workload: $request[0].workload, result: $result[0]}' > attribution-dashboard-record.json

The last step is writing that record into the database. I do this by running a short Python snippet inside the running Attribution pod with kubectl exec, so it uses the same code and the same /data/attributions.db file on the persistent volume that the dashboard reads. The snippet loads the record from standard input, checks that it really contains a verdict, builds the workload context the writer expects (ID, nodes, start and end times, workload type, state and so on), and uses the blueprint's SQLiteOutput writer to save it.

$ kubectl -n attribution exec -i deployment/attribution -- \ /opt/blueprint/.venv/bin/python -c ' import json import sys from datetime import datetime from attribution_blueprint.connectors.outputs.sqlite import SQLiteOutput from attribution_blueprint.result import enrich_attribution_result record = json.load(sys.stdin) w = record["workload"] result = record["result"] if not isinstance(result.get("verdict"), dict): raise ValueError("Input does not contain an attribution verdict") context = { "id": w["id"], "nodes": list(w["nodes"]), "start_time": datetime.fromisoformat(w["job_start_time"]), "end_time": datetime.fromisoformat(w["job_end_time"]), "timezone": w.get("timezone", "UTC"), "workload_type": w["type"], "state": w["status"], "name": w.get("name", ""), "user_name": w.get("username", ""), "workload_framework": w.get("framework", ""), } writer = SQLiteOutput("/data/attributions.db") try: writer.write(context, enrich_attribution_result(result, [])) finally: writer.close() print("Saved dashboard record:", w["id"]) ' < attribution-dashboard-record.json Saved dashboard record: curl-example-001

The pod confirms with Saved dashboard record: curl-example-001. At this point the record lives in the database the dashboard is reading from, so the failed job should now be visible in the web UI on node port 30080.

Clean Up

Since this was only a demo, the last step is deleting the attributor we created. A zero exit code tells us the delete went through.

$ curl --fail-with-body -sS -X DELETE "${SVC}/v2/attributor/${ATT}" $ echo $? 0

Summary

In this walkthrough we built the NVIDIA Attribution Blueprint image for arm64, pushed it to a local registry, and deployed it to MicroK8s with a namespace, a persistent volume, a single replica Deployment and a NodePort service. After validating the health endpoint and the web dashboard, we created an attributor for a simulated failed PyTorch job, submitted a CUDA out of memory log line as an observation, and asked for the verdict. Attribution traced the log line to a matching rule and concluded the failure belonged to the user and not the infrastructure. We finished by saving the result into the dashboard's SQLite database through the pod and cleaning up the attributor.

The part I like most is how small the contract is. A workload description plus a bit of evidence goes in, and a structured verdict with the rule, the line and the failure domain comes out. That is exactly what we want when you are trying to decide whether a failed job needs a user to fix their code or an operator to fix a node.

The Attribution project lives on GitHub if you want to try this yourself or dig into the rulesets: https://github.com/dsx-ai-factory/attribution

Hopefully this provides a clear example of how to deploy Attribution and walk a failed workload through to a verdict.

Tuesday, September 01, 2026

Validate with Cluster Readiness Engine

I have spent a fair amount of time over the years standing up GPU clusters and then, almost immediately, asking myself "okay, but how do I actually know this thing is healthy?" We can check that the GPU Operator is running, that Data Center GPU Manager(DCGM) is reporting metrics, and that nvidia-smi shows the right device count, but none of that tells us whether the NVLink and network fabric can actually move data at the bandwidth training jobs are going to need. That gap is exactly what the NVIDIA Cluster Readiness Engine (CRE) is built to close, and this post walks through standing it up on an example EKS cluster, working through a couple of hurdles along the way, and running an actual NCCL certification against real H100 nodes.

What is the Cluster Readiness Engine

The Cluster Readiness Engine is an OSS Kubernetes-native tool for validating that a GPU cluster is actually ready to run distributed training workloads before hand it off to a data science team. It does this by running certifications, which are Kubernetes custom resources that describe a category of test (communication, diagnostics, or training) and a target set of nodes, and then orchestrating real workloads like NCCL all-reduce tests or small-scale training runs against those nodes to prove the fabric and GPUs behave as expected. CRE builds on Kubeflow Trainer to actually schedule and run these workloads, and it ships its own controller, CRDs, and log profiles to interpret the results into a pass/fail report.

The reason this matters is that GPU cluster failures are rarely obvious. A node can report healthy, pass every liveness probe, and still have a degraded NVLink connection or a misconfigured NCCL environment variable that tanks all-reduce bandwidth. Multiply that by a few hundred nodes and a training job that stalls at 2am, and we start to appreciate having a repeatable, automated way to certify hardware before it becomes someone else's middle of the night problem. With that said let's get started.

Refreshing GitHub Credentials for the Image Pull Secret

CRE's controller image is currently distributed from GitHub Container Registry, which means the first thing we needed was a valid GitHub token with read:packages scope to use as an image pull secret.

gh auth login --web gh auth refresh -s read:packages gh auth token

The token we obtain from the output above can be exported into the GITHUB_TOKEN variable.

export GITHUB_TOKEN=<gh token from output above>

Now that we have an auth token we can proceed to the next steps.

Enabling DCGM in the GPU Operator's ClusterPolicy

CRE has an optional DCGM diagnostics category, and to use it we needed DCGM enabled in the GPU Operator's ClusterPolicy custom resource. By default the GPU Operator has this set to false.

% kubectl patch clusterpolicy cluster-policy --type=merge -p '{"spec":{"dcgm":{"enabled":true}}}' clusterpolicy.nvidia.com/cluster-policy patched

With that in place, DCGM shows up later as an installed, optional component when we check CRE's setup status.

Installing the CRE

CRE ships an installer script that drops a kubectl plugin binary onto our path, which is what gives us the kubectl nvcre and kubectl ncre commands used throughout the rest of this post. The plugin is available for x86 and arm64.

curl -sSL https://github.com/NVIDIA/cluster-readiness-engine/releases/latest/download/installer | bash

First Attempt at Setup Init

With the CLI installed, the next step is kubectl nvcre setup init, which handles installing Kubeflow Trainer as a dependency and then the CRE Helm chart itself. We passed GitHub token in as the image pull secret so the controller image could actually be pulled.

% kubectl nvcre setup init --image-pull-secret $GITHUB_TOKEN [preflight] Checking prerequisites... Target cluster Context: arn:aws:eks:us-east-1:590183901766:cluster/nvidia-cloud-tme-eks Server: https://E156C2041AEE7DC39F97C38F65D795B0.gr7.us-east-1.eks.amazonaws.com Phases: - [deps ] Kubeflow Trainer v2.2.1 - [helm ] CRE Helm chart (ghcr.io/nvidia/cluster-readiness-engine/manager:v0.1.0) Do you want to proceed? Only 'yes' will be accepted to confirm. Enter a value: yes [deps] Installing Kubeflow Trainer v2.2.1... [deps] Installing Kubeflow Trainer Helm release "kubeflow-trainer" in namespace kubeflow-system... [namespace] Creating namespace cluster-readiness-engine... [helm] Created image pull secret "ncrectl-pull-secret" in namespace cluster-readiness-engine. [helm] Installing CRE Helm release "cluster-readiness-engine" in namespace cluster-readiness-engine... Release "cluster-readiness-engine" does not exist. Installing it now. Pulled: ghcr.io/nvidia/cluster-readiness-engine:v0.1.0 Digest: sha256:e749fccca83f43b4107e2adec245f6951f8ffe439edc918bc2512fa622f10a17 Error: unable to build kubernetes objects from release manifest: resource mapping not found for name: "cluster-readiness-engine-metrics-monitor" namespace: "cluster-readiness-engine" from "": no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1" ensure CRDs are installed first

This is a good example of the kind of things we can run into with any Helm chart that ships a ServiceMonitor resource: the CRE chart includes a metrics monitor that depends on the Prometheus Operator's CRDs, and our EKS cluster didn't have the Prometheus Operator installed at all. The install rolled partway through before failing.

Cleaning Up After the Failed Install

Rather than trying to hand-patch a half-applied Helm release, I reset the environment back to a clean slate so the next install would be a proper first attempt.

kubectl nvcre setup reset

Installing the Prometheus Operator CRDs

The actual fix for the ServiceMonitor error is straightforward: install the Prometheus Operator's CRDs so the monitoring.coreos.com/v1 API group exists on the cluster, even if not running the full Prometheus Operator itself.

% kubectl create -f https://github.com/prometheus-operator/prometheus-operator/releases/download/v0.82.2/stripped-down-crds.yaml customresourcedefinition.apiextensions.k8s.io/alertmanagerconfigs.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/alertmanagers.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/podmonitors.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/probes.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/prometheusagents.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/prometheuses.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/prometheusrules.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/scrapeconfigs.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/servicemonitors.monitoring.coreos.com created customresourcedefinition.apiextensions.k8s.io/thanosrulers.monitoring.coreos.com created

The stripped-down-crds.yaml variant is worth calling out here, since it installs just the CRDs without the operator's webhook configurations, which is exactly what we want if all we need is the API types to satisfy a chart's ServiceMonitor object.

Scheduling the Manager onto a GPU Node

While the install was proceeding, we hit a second, unrelated snag: the CRE manager pod was stuck pending because the default scheduling didn't have any affinity toward GPU nodes on my cluster. In this example cluster all the nodes were GPU nodes and there were no available CPU nodes. We can write a small loop to catch the pod in its pending state and patch the deployment with a node affinity requiring nvidia.com/gpu.present as soon as it showed up. This will allow us to get the pod to a running state in our environment. We need to run this loop in another terminal before we start the installation.

% while true; do POD=$(kubectl get pods -n cluster-readiness-engine --field-selector status.phase=Pending -o jsonpath='{.items[0].metadata.name}' 2>/dev/null); if [ ! -z "$POD" ]; then echo "Found pending pod: $POD. Patching deployment..."; kubectl patch deployment cluster-readiness-engine-manager -n cluster-readiness-engine --type='strategic' -p '{"spec":{"template":{"spec":{"affinity":{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"nvidia.com/gpu.present","operator":"Exists"}]}]}}}}}}}'; break; fi; sleep 2; done Found pending pod: cluster-readiness-engine-manager-7fd75dfc9-vszks. Patching deployment... deployment.apps/cluster-readiness-engine-manager patched

This isn't something we need to script for every environment, it was specific to how node selection was configured on this particular EKS cluster, but it's a good illustration of how mixed-node-pool clusters can trip up a controller that has an affinity about where it should run.

Re-running Setup Init

With the Prometheus CRDs in place, the pull secret already cached from the failed attempt, and our one liner patch script running in another terminal, the running setup init again completed cleanly.

% kubectl nvcre setup init --image-pull-secret $GITHUB_TOKEN [preflight] Checking prerequisites... Target cluster Context: arn:aws:eks:us-east-1:590183901766:cluster/nvidia-cloud-tme-eks Server: https://E156C2041AEE7DC39F97C38F65D795B0.gr7.us-east-1.eks.amazonaws.com Phases: - [deps ] Kubeflow Trainer v2.2.1 - [helm ] CRE Helm chart (ghcr.io/nvidia/cluster-readiness-engine/manager:v0.1.0) Do you want to proceed? Only 'yes' will be accepted to confirm. Enter a value: yes [deps] Installing Kubeflow Trainer v2.2.1... [deps] Installing Kubeflow Trainer Helm release "kubeflow-trainer" in namespace kubeflow-system... [helm] Created image pull secret "ncrectl-pull-secret" in namespace cluster-readiness-engine. [helm] Installing CRE Helm release "cluster-readiness-engine" in namespace cluster-readiness-engine... CRE v0.1.0 initialized successfully.

At this point I feel pretty confident CRE is actually installed correctly.

Validating the Installation

CRE gives us a dedicated status command that checks each component it manages, rather than making us go hunt down CRDs, deployments, and Helm releases individually.

% kubectl nvcre setup status Component Status ───────────────────────── ────────── CRE CRDs ✓ installed CRE Controller ✓ installed Kubeflow Trainer ✓ installed Log Profiles ✓ installed GPU Operator ✓ installed DCGM (optional) ✓ installed Status: ready

Every component reports installed, including the optional DCGM row, which confirms that earlier GPU ClusterPolicy patch actually took effect. If everything looks good we can move onto actually asking CRE what it sees on the cluster.

Inspecting Cluster Topology

Before running any certifications, it's worth checking what CRE has discovered about the cluster's hardware and network topology, since that's what determines which certification categories even make sense to run.

% kubectl nvcre cluster info Platform: aws GPU: NVIDIA-H100-80GB-HBM3 (h100, 8 GPUs/node) Nodes: 3 ready Total GPUs: 24

Three nodes, eight H100s each, 24 GPUs total, all on a single network topology group. That single topology group matters for certification purposes, since it means all three nodes should be able to participate in the same all-reduce test without crossing a slower network boundary.

Listing Available Certification Categories

CRE organizes its tests into categories, each pairing a domain like communication, diagnostics, or training with a specific variant or variety of test to run. CRE also allows for custom test runs for those who have their own tests.

% kubectl nvcre certification list-categories DOMAIN VARIANT communication nccl-all-gather communication nccl-all-reduce communication nccl-alltoall communication nccl-loopback communication nccl-loopback-nvswitch diagnostics dcgm-level4 training nemotron5-56b training nemotron5-8b

The communication domain covers NCCL collective operations, which is what actually exercises the NVLink and network fabric. The diagnostics domain runs DCGM's built-in health checks. The training domain runs scaled-down versions of real model training jobs, in this case Nemotron variants, to catch issues that only surface under an actual training workload.

Running a Basic Certification

The simplest way to kick off a certification is the certification run command, pointed at a category, which handles creating the namespace and Certification resource.

% kubectl nvcre certification run --category communication/nccl-all-reduce Discovered GPU nodes with product: NVIDIA-H100-80GB-HBM3 [namespace] Creating namespace ncrectl-20260820-125722... Certification ncrectl-20260820-125722 created in namespace ncrectl-20260820-125722. Categories: - communication/nccl-all-reduce To check status: kubectl get certification ncrectl-20260820-125722 -n ncrectl-20260820-125722

Because this returns immediately, checking progress means separately polling the Certification resource, which is fine but a little clunky if we're just watching a single run.

Running a Certification with Watch

For a more interactive experience, the --wait flag streams the certification's progress in real time until it completes.

% kubectl nvcre certification run --category communication/nccl-all-reduce --wait Discovered GPU nodes with product: NVIDIA-H100-80GB-HBM3 [namespace] Creating namespace ncrectl-20260819-134139... Certification ncrectl-20260819-134139 created in namespace ncrectl-20260819-134139. Categories: - communication/nccl-all-reduce [watch] Watching certification progress... [watch] communication/nccl-all-reduce: InProgress (0s) [watch] communication/nccl-all-reduce: InProgress (15s) [watch] communication/nccl-all-reduce: InProgress (30s) [watch] communication/nccl-all-reduce: InProgress (45s) [watch] communication/nccl-all-reduce: InProgress (1m0s) [watch] communication/nccl-all-reduce: InProgress (1m15s) [watch] communication/nccl-all-reduce: InProgress (1m30s) [watch] communication/nccl-all-reduce: InProgress (1m45s) [watch] communication/nccl-all-reduce: InProgress (2m0s) [watch] communication/nccl-all-reduce: InProgress (2m15s) [watch] communication/nccl-all-reduce: InProgress (2m30s) [watch] communication/nccl-all-reduce: InProgress (2m45s) [watch] communication/nccl-all-reduce: InProgress (3m0s) [watch] communication/nccl-all-reduce: InProgress (3m15s) [watch] communication/nccl-all-reduce: InProgress (3m30s) [watch] communication/nccl-all-reduce: InProgress (3m45s) [watch] communication/nccl-all-reduce: InProgress (4m0s) [watch] communication/nccl-all-reduce: InProgress (4m15s) [watch] communication/nccl-all-reduce: InProgress (4m30s) [watch] communication/nccl-all-reduce: InProgress (4m45s) [watch] communication/nccl-all-reduce: InProgress (5m0s) [watch] communication/nccl-all-reduce: InProgress (5m15s) [watch] communication/nccl-all-reduce: InProgress (5m30s) [watch] communication/nccl-all-reduce: InProgress (5m45s) [watch] communication/nccl-all-reduce: InProgress (6m0s) [watch] communication/nccl-all-reduce: InProgress (6m15s) [watch] communication/nccl-all-reduce: InProgress (6m30s) [watch] communication/nccl-all-reduce: InProgress (6m45s) [watch] communication/nccl-all-reduce: InProgress (7m0s) [watch] communication/nccl-all-reduce: InProgress (7m15s) [watch] communication/nccl-all-reduce: InProgress (7m30s) [watch] communication/nccl-all-reduce: InProgress (7m45s) [watch] communication/nccl-all-reduce: InProgress (8m0s) [watch] communication/nccl-all-reduce: InProgress (8m15s) [watch] communication/nccl-all-reduce: InProgress (8m30s) [watch] communication/nccl-all-reduce: InProgress (8m45s) [watch] communication/nccl-all-reduce: InProgress (9m0s) [watch] communication/nccl-all-reduce: InProgress (9m15s) [watch] communication/nccl-all-reduce: InProgress (9m30s) [watch] communication/nccl-all-reduce: InProgress (9m45s) [watch] communication/nccl-all-reduce: InProgress (10m0s) [watch] communication/nccl-all-reduce: InProgress (10m15s) [watch] communication/nccl-all-reduce: InProgress (10m30s) [watch] communication/nccl-all-reduce: InProgress (10m45s) [watch] communication/nccl-all-reduce: InProgress (11m0s) [watch] communication/nccl-all-reduce: InProgress (11m15s) [watch] communication/nccl-all-reduce: InProgress (11m30s) [watch] communication/nccl-all-reduce: InProgress (11m45s) [watch] communication/nccl-all-reduce: InProgress (12m0s) [watch] communication/nccl-all-reduce: InProgress (12m15s) [watch] communication/nccl-all-reduce: InProgress (12m30s) [watch] communication/nccl-all-reduce: InProgress (12m45s) [watch] communication/nccl-all-reduce: InProgress (13m0s) [watch] communication/nccl-all-reduce: InProgress (13m15s) [watch] communication/nccl-all-reduce: Succeeded (13m22s) [watch] Certification succeeded. (13m22s) ╔════════════════════════════════════════════════════════════════╗ ║ Certification Report ║ ╚════════════════════════════════════════════════════════════════╝ Name: ncrectl-20260819-134139 Platform: aws GPU: h100 Nodes: 3 ┌────────────────────────────────────────────────────────────────┐ │ communication/nccl-all-reduce │ ├────────────────────────────────────────────────────────────────┤ │ Status: Succeeded │ └────────────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────────────┐ │ Summary │ ├────────────────────────────────────────────────────────────────┤ │ Categories: 1/1 passed │ │ Failed Nodes: none │ │ Result: PASSED │ └────────────────────────────────────────────────────────────────┘

After thirteen minutes for a full NCCL all-reduce sweep across all three nodes, the ending shows a clean PASSED result with zero failed nodes. That's the whole point of this exercise. A certification like this is what tells us the fabric between these three nodes is actually production-ready, not just that the nodes are individually healthy.

Pulling a Report After the Fact

If we want to look at the detailed report again later, or hand it to someone else without re-running the whole certification, the certification report command re-renders it from the stored resource.

% kubectl nvcre certification report ncrectl-20260819-134139 -n ncrectl-20260819-134139 ╔════════════════════════════════════════════════════════════════╗ ║ Certification Report ║ ╚════════════════════════════════════════════════════════════════╝ Name: ncrectl-20260819-134139 Platform: aws GPU: h100 Nodes: 3 ┌────────────────────────────────────────────────────────────────┐ │ communication/nccl-all-reduce │ ├────────────────────────────────────────────────────────────────┤ │ Status: Succeeded │ │ Runtime: 13m 21s │ │ Scale: full-scale │ │ Nodes/Job: 3 │ │ Jobs: 1 │ │ │ │ Bandwidth: │ │ Size AlgBW BusBW Samples │ │ 16 GB 180.33 GB/s 345.61 GB/s 13 │ └────────────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────────────┐ │ Summary │ ├────────────────────────────────────────────────────────────────┤ │ Categories: 1/1 passed │ │ Failed Nodes: none │ │ Result: PASSED │ └────────────────────────────────────────────────────────────────┘

This time the report includes the actual bandwidth numbers, 180.33 GB/s algorithmic bandwidth and 345.61 GB/s bus bandwidth on a 16 GB message size. Those are the numbers I'd actually want to compare against expected H100 NVLink and network fabric specs, and against future certification runs, to catch any regression before a training job does.

Writing a Custom Certification Manifest

While the built-in categories cover the common cases, CRE also lets us compose our own Certification resource targeting specific categories and a node selector. I put together a manifest targeting a set of nodes I'd tagged for NCCL debugging, running all three loopback and all-reduce variants together.

% cat test-render.yaml apiVersion: cre.nvidia.com/v1alpha1 kind: Certification metadata: name: nccl-debug-19-08 namespace: default spec: categories: - domain: communication variant: nccl-loopback - domain: communication variant: nccl-loopback-nvswitch - domain: communication variant: nccl-all-reduce target: nodeSelector: excalibur.nvidia.com/badnode: nccl nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3

I always prefer yaml manifests so the following steps will show that method, rather than composing everything through CLI flags.

Rendering the Certification Without Running It

Before actually applying a custom certification, CRE lets us render it to see exactly what Workflow resources it would generate underneath the hood, which is a great way to sanity-check a manifest without spinning up real jobs.

% kubectl nvcre certification render test-render.yaml apiVersion: cre.nvidia.com/v1alpha1 kind: Workflow metadata: labels: app.kubernetes.io/managed-by: cluster-readiness-engine cre.nvidia.com/category-domain: communication cre.nvidia.com/category-variant: nccl-loopback cre.nvidia.com/certification: nccl-debug-19-08 name: nccl-debug-19-08-communic-6b70d spec: dependencies: - apiVersion: trainer.kubeflow.org/v1alpha1 kind: TrainingRuntime metadata: labels: app: nccl-loopback name: nccl-loopback-runtime spec: mlPolicy: numNodes: 1 torch: numProcPerNode: 1 template: spec: replicatedJobs: - name: node template: metadata: labels: trainer.kubeflow.org/trainjob-ancestor-step: trainer spec: template: spec: containers: - image: nvcr.io/nvidia/pytorch:26.01-py3 name: node resources: limits: nvidia.com/gpu: "8" requests: nvidia.com/gpu: "8" securityContext: capabilities: add: - IPC_LOCK restartPolicy: OnFailure tolerations: - operator: Exists jobTemplate: spec: bandwidthMeasurement: logProfileRef: nccl-loopback sampleInterval: 30s testType: loopback_all_reduce nodeHealthMonitor: cel: expression: node.spec.unschedulable == true workload: trainJob: runtimePatches: - manager: cre.nvidia.com/catalog trainingRuntimeSpec: template: spec: replicatedJobs: - name: node template: spec: template: spec: containers: - name: node volumeMounts: - mountPath: /dev/shm name: dshm volumes: - emptyDir: medium: Memory name: dshm runtimeRef: kind: TrainingRuntime name: nccl-loopback-runtime trainer: args: - -b - "8" - -e - 16G - -f - "2" - -g - "8" - -n - "10" - -N - "2" command: - /usr/local/bin/all_reduce_perf_mpi env: - name: NCCL_DEBUG value: INFO - name: NCCL_SHM_DISABLE value: "1" - name: NCCL_P2P_DISABLE value: "1" - name: NCCL_MNNVL_ENABLE value: "0" image: nvcr.io/nvidia/pytorch:26.01-py3 numNodes: 1 numProcPerNode: 1 orchestration: execution: {} iterations: 1 target: nodeSelector: excalibur.nvidia.com/badnode: nccl nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3 --- apiVersion: cre.nvidia.com/v1alpha1 kind: Workflow metadata: labels: app.kubernetes.io/managed-by: cluster-readiness-engine cre.nvidia.com/category-domain: communication cre.nvidia.com/category-variant: nccl-loopback-nvswitch cre.nvidia.com/certification: nccl-debug-19-08 name: nccl-debug-19-08-communic-beb95 spec: dependencies: - apiVersion: trainer.kubeflow.org/v1alpha1 kind: TrainingRuntime metadata: labels: app: nccl-loopback-nvswitch name: nccl-loopback-nvswitch-runtime spec: mlPolicy: numNodes: 1 torch: numProcPerNode: 1 template: spec: replicatedJobs: - name: node template: metadata: labels: trainer.kubeflow.org/trainjob-ancestor-step: trainer spec: template: spec: containers: - image: nvcr.io/nvidia/pytorch:26.01-py3 name: node resources: limits: nvidia.com/gpu: "8" requests: nvidia.com/gpu: "8" securityContext: capabilities: add: - IPC_LOCK restartPolicy: OnFailure tolerations: - operator: Exists jobTemplate: spec: bandwidthMeasurement: logProfileRef: nccl-loopback sampleInterval: 30s testType: loopback_all_reduce nodeHealthMonitor: cel: expression: node.spec.unschedulable == true workload: trainJob: runtimePatches: - manager: cre.nvidia.com/catalog trainingRuntimeSpec: template: spec: replicatedJobs: - name: node template: spec: template: spec: containers: - name: node volumeMounts: - mountPath: /dev/shm name: dshm volumes: - emptyDir: medium: Memory name: dshm runtimeRef: kind: TrainingRuntime name: nccl-loopback-nvswitch-runtime trainer: args: - -b - "8" - -e - 16G - -f - "2" - -g - "8" - -n - "100" - -N - "10" command: - /usr/local/bin/all_reduce_perf_mpi env: - name: NCCL_DEBUG value: INFO - name: NCCL_MNNVL_ENABLE value: "0" image: nvcr.io/nvidia/pytorch:26.01-py3 numNodes: 1 numProcPerNode: 1 orchestration: execution: {} iterations: 1 target: nodeSelector: excalibur.nvidia.com/badnode: nccl nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3 --- apiVersion: cre.nvidia.com/v1alpha1 kind: Workflow metadata: labels: app.kubernetes.io/managed-by: cluster-readiness-engine cre.nvidia.com/category-domain: communication cre.nvidia.com/category-variant: nccl-all-reduce cre.nvidia.com/certification: nccl-debug-19-08 name: nccl-debug-19-08-communic-c2ad8 spec: dependencies: - apiVersion: trainer.kubeflow.org/v1alpha1 kind: TrainingRuntime metadata: labels: app: nccl-all-reduce trainer.kubeflow.org/framework: mpi name: nccl-all-reduce-runtime spec: mlPolicy: mpi: mpiImplementation: OpenMPI numProcPerNode: 8 sshAuthMountPath: /tmp/mpi-ssh-raw numNodes: 1 template: spec: network: publishNotReadyAddresses: true replicatedJobs: - name: node template: spec: template: spec: containers: - args: - |- set -x && apt-get update && apt-get install -y --no-install-recommends openssh-server && mkdir -p /var/run/sshd && chmod 0755 /var/run/sshd && mkdir -p /root/.ssh && chmod 700 /root/.ssh && cp /tmp/mpi-ssh-raw/* /root/.ssh/ && chmod 600 /root/.ssh/id_rsa && chmod 644 /root/.ssh/id_rsa.pub /root/.ssh/authorized_keys && /usr/sbin/sshd -De command: - sh - -c image: nvcr.io/nvidia/pytorch:26.01-py3 name: node readinessProbe: initialDelaySeconds: 5 tcpSocket: port: 22 resources: limits: nvidia.com/gpu: "8" requests: nvidia.com/gpu: "8" securityContext: capabilities: add: - IPC_LOCK volumeMounts: - mountPath: /tmp/mpi-ssh-raw name: mpi-ssh-auth readOnly: true restartPolicy: OnFailure - dependsOn: - name: node status: Ready name: launcher template: metadata: labels: trainer.kubeflow.org/trainjob-ancestor-step: trainer spec: template: spec: containers: - image: nvcr.io/nvidia/pytorch:26.01-py3 name: node resources: limits: cpu: "2" memory: 1Gi volumeMounts: - mountPath: /tmp/mpi-ssh-raw name: mpi-ssh-auth readOnly: true - mountPath: /root/.ssh name: ssh-keys initContainers: - args: - |- set -x && cp /tmp/mpi-ssh-raw/* /root/.ssh/ && chmod 600 /root/.ssh/id_rsa && chmod 644 /root/.ssh/id_rsa.pub /root/.ssh/authorized_keys command: - sh - -c image: nvcr.io/nvidia/pytorch:26.01-py3 name: fix-ssh-permissions volumeMounts: - mountPath: /tmp/mpi-ssh-raw name: mpi-ssh-auth readOnly: true - mountPath: /root/.ssh name: ssh-keys restartPolicy: OnFailure volumes: - emptyDir: {} name: ssh-keys successPolicy: operator: All targetReplicatedJobs: - launcher jobTemplate: spec: bandwidthMeasurement: logProfileRef: nccl-bandwidth sampleInterval: 30s testType: all_reduce nodeHealthMonitor: cel: expression: node.spec.unschedulable == true workload: trainJob: runtimePatches: - manager: cre.nvidia.com/catalog trainingRuntimeSpec: template: spec: replicatedJobs: - name: node template: spec: template: spec: containers: - name: node volumeMounts: - mountPath: /dev/shm name: dshm volumes: - emptyDir: medium: Memory name: dshm - name: launcher runtimeRef: kind: TrainingRuntime name: nccl-all-reduce-runtime trainer: args: - -N - "8" - --allow-run-as-root - --mca - plm_rsh_args - -o StrictHostKeyChecking=no - -x - NCCL_DEBUG=INFO - -x - NCCL_MNNVL_ENABLE=0 - /usr/local/bin/all_reduce_perf_mpi - -b - "8" - -e - 16G - -f - "2" - -n - "100" - -N - "10" command: - timeout - "3600" - /usr/local/mpi/bin/mpirun image: nvcr.io/nvidia/pytorch:26.01-py3 numNodes: 1 numProcPerNode: 8 orchestration: execution: timeoutPerJob: 1h0m0s iterations: 1 target: nodeSelector: excalibur.nvidia.com/badnode: nccl nvidia.com/gpu.product: NVIDIA-H100-80GB-HBM3

This is genuinely useful to look at even if we never run it, because it shows exactly how much CRE is doing on our behalf. The nccl-all-reduce variant, for instance, spins up an MPI cluster from scratch, including generating SSH keys, fixing permissions in an init container, and starting sshd in the worker pods so mpirun can launch across nodes, all before it ever gets to running all_reduce_perf_mpi. Writing that boilerplate by hand for every certification run is exactly the kind of toil CRE exists to eliminate.

Applying a Custom Certification and Watching It Fail

I also had another gpu-cluster-cert.yaml manifest targeting any node with nvidia.com/gpu.present: "true" but only 2 nodes used.

% cat gpu-cluster-cert.yaml apiVersion: cre.nvidia.com/v1alpha1 kind: Certification metadata: name: gpu-cluster-cert namespace: default spec: target: nodeSelector: nvidia.com/gpu.present: "true" nodesPerJob: 2 categories: - domain: communication variant: nccl-all-reduce % kubectl create -f gpu-cluster-cert.yaml certification.cre.nvidia.com/gpu-cluster-cert created

We can monitor the process by looking at the status conditions.

% kubectl get certification gpu-cluster-cert -o jsonpath='{.status.conditions}' | jq . [ { "lastTransitionTime": "2026-08-20T17:29:30Z", "message": "Certification in progress", "observedGeneration": 1, "reason": "WorkflowRunning", "status": "True", "type": "InProgress" }, { "lastTransitionTime": "2026-08-20T17:29:30Z", "message": "", "observedGeneration": 1, "reason": "NotApplicable", "status": "False", "type": "Succeeded" }, { "lastTransitionTime": "2026-08-20T17:29:30Z", "message": "", "observedGeneration": 1, "reason": "NotApplicable", "status": "False", "type": "Failed" } ]

This is a nice example of CRE's status conditions doing exactly what Kubernetes status conditions are supposed to do.

Checking Pods and Cleaning Up a Certification

While the certification was in progress, I checked in on the actual pods it spun up, and then confirmed there were no failed nodes before tearing it down.

% kubectl get pods NAME READY STATUS RESTARTS AGE gpu-cluster-cert-communic-8b9-78c54-workload-launcher-0-0-d868t 1/1 Running 0 68s gpu-cluster-cert-communic-8b9-78c54-workload-node-0-0-xp5cw 1/1 Running 0 81s gpu-cluster-cert-communic-8b9-78c54-workload-node-0-1-sc9fl 1/1 Running 0 81s gpu-devicequery 0/1 Completed 0 91d bschmaus@FVXW4M2W1Y aws-cluster % kubectl get certification gpu-cluster-cert -o jsonpath='{.status.failedNodes}' bschmaus@FVXW4M2W1Y aws-cluster % kubectl delete certification gpu-cluster-cert certification.cre.nvidia.com "gpu-cluster-cert" deleted from default namespace

We can see the launcher and two node pods that the MPI-based nccl-all-reduce workflow generates, matching exactly what we saw earlier in the rendered manifest. The empty response from failedNodes confirms nothing was flagged as unhealthy, so I deleted the Certification resource once I was satisfied, which cleans up the underlying Workflow, Job, and pod resources along with it.

Tearing Down CRE Entirely

Once we were done experimenting, we can reset the whole environment back to a clean state, which removes CRE's custom resources, the Helm release and CRDs, and the Kubeflow Trainer dependency it installed along the way.

% kubectl ncre setup reset [preflight] Checking prerequisites... Target cluster Context: arn:aws:eks:us-east-1:590183901766:cluster/nvidia-cloud-tme-eks Server: https://E156C2041AEE7DC39F97C38F65D795B0.gr7.us-east-1.eks.amazonaws.com Phases: - [cr ] CRE custom resources - [helm ] CRE Helm release (CRDs, controller, LogProfiles) - [deps ] Kubeflow Trainer v2.2.1 Do you want to proceed? Only 'yes' will be accepted to confirm. Enter a value: yes [cr] Deleting CRE custom resources... BandwidthMeasurement/gpu-cluster-cert-communic-8b9-78c54-bandwidth (namespace: default) deleted BandwidthMeasurement/gpu-cluster-cert-communic-8b9-f2d8e-bandwidth (namespace: default) deleted BandwidthMeasurement/ncrectl-20260820-115745-c-81371-job-bandwidth (namespace: ncrectl-20260820-115745) deleted BandwidthMeasurement/ncrectl-20260820-125722-c-ec346-job-bandwidth (namespace: ncrectl-20260820-125722) deleted Certification/gpu-cluster-cert (namespace: default) deleted Certification/ncrectl-20260820-115745 (namespace: ncrectl-20260820-115745) deleted Certification/ncrectl-20260820-125722 (namespace: ncrectl-20260820-125722) deleted Job/ncrectl-20260820-125722-c-ec346-job (namespace: ncrectl-20260820-125722) already terminating Workflow/ncrectl-20260820-125722-c-ec346 (namespace: ncrectl-20260820-125722) already terminating All CRE custom resources removed. [helm] Removing CRE Helm release "cluster-readiness-engine" from namespace cluster-readiness-engine... [helm] Removing CRE CRDs... Waiting for CRDs to be fully removed... All CRE CRDs removed. [deps] Removing Kubeflow Trainer v2.2.1... [deps] Removing Helm release "kubeflow-trainer" from namespace kubeflow-system... [deps] Removing Kubeflow Trainer CRDs... Waiting for CRDs to be fully removed... All Kubeflow Trainer CRDs removed. [deps] Removing JobSet CRDs... Waiting for CRDs to be fully removed... All JobSet CRDs removed. CRE reset successfully.

Notice the phase list here is different from the install: setup reset explicitly enumerates every leftover custom resource it finds, including a couple of BandwidthMeasurement and Certification objects from earlier runs I'd forgotten were still lying around, before working its way back down through the Helm release, CRE's CRDs, and finally Kubeflow Trainer's own CRDs including JobSet. That's a genuinely clean teardown, and it's reassuring that the tool tracks its own dependency chain well enough to unwind it in the right order.

Wrapping Up

Between the GitHub token refresh, the Prometheus Operator CRDs, and the node affinity patch for the manager pod, this wasn't a completely frictionless install, but none of those issues were CRE's fault so much as normal artifacts of running on an EKS cluster that wasn't purpose-built for it. Once past setup, the actual certification workflow, from listing categories to running a full NCCL all-reduce test to reading real bandwidth numbers out of the report, was exactly the kind of repeatable validation I'd want between a new cluster and a production training job. Hopefully this write-up gives a good sense of what the Cluster Readiness Engine does, why validating fabric health matters as much as validating hardware inventory, and what to expect the first time it runs on a cluster.

For more information please refer to the official documentation.