> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/aiperf/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/aiperf/_mcp/server.

# Production Deployments

This guide covers patterns for running AIPerf benchmarks in production environments -- CI/CD pipelines, Kueue-managed clusters, private registries, GitOps workflows, and multi-tenant setups.

---

## CI/CD Integration

### Detach Mode

In non-interactive environments (CI pipelines, cron jobs), AIPerf automatically detaches after deploying. You can also force this explicitly:

```bash
aiperf kube profile \
  --config benchmark.yaml \
  --image nvcr.io/nvidia/aiperf:latest \
  --detach
```

After deploying, poll for completion:

```bash
# Wait for the job to complete
while true; do
  PHASE=$(aiperf kube list my-benchmark 2>/dev/null | awk 'NR==2{print $3}')
  [ "$PHASE" = "Completed" ] || [ "$PHASE" = "Failed" ] && break
  sleep 30
done

# Download results
aiperf kube results my-benchmark --output ./artifacts
```

`aiperf kube results` exits nonzero when the job cannot be resolved or the
requested download is incomplete, so CI artifact steps fail instead of
silently publishing an empty or partial directory.

### JSON-Based Monitoring

For automated pipelines, use JSON output:

```bash
# Preflight check with exit code
aiperf kube preflight -o json
echo "Exit code: $?"    # 0 = all checks passed, 1 = failures

# Validation with structured output (takes AIPerfJob/AIPerfSweep CRs, not --config files)
aiperf kube validate -o json aiperfjob.yaml

```

### Example GitHub Actions Workflow

```yaml
jobs:
  benchmark:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Preflight check
        run: |
          aiperf kube preflight \
            --endpoint-url http://dynamo-agg-frontend.dynamo-server.svc:8000/v1 \
            -o json

      - name: Run benchmark
        run: |
          aiperf kube profile \
            --config benchmark.yaml \
            --image nvcr.io/nvidia/aiperf:${{ github.sha }} \
            --name "ci-${{ github.run_number }}" \
            --detach

      - name: Wait for completion
        run: |
          kubectl wait --for=condition=Complete \
            aiperfjob/ci-${{ github.run_number }} --timeout=60m

      - name: Collect results
        if: always()
        run: aiperf kube results ci-${{ github.run_number }} --output ./artifacts

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: benchmark-results
          path: ./artifacts/
```

---

## GitOps Workflows

### Generate Manifests for Version Control

Instead of deploying from the CLI, generate Kubernetes manifests and commit them to your GitOps repository:

```bash
# Generate an AIPerfJob CR (operator mode)
aiperf kube generate --operator \
  --config benchmark.yaml \
  --image nvcr.io/nvidia/aiperf:latest \
  > deploy/aiperfjob.yaml

# Or generate raw manifests (no operator needed)
aiperf kube generate --no-operator \
  --config benchmark.yaml \
  --image nvcr.io/nvidia/aiperf:latest \
  > deploy/manifests.yaml
```

Commit the generated YAML and let ArgoCD, Flux, or your GitOps tool apply it.

### Operator Mode vs. Raw Manifests

| Feature | Operator Mode | Raw Manifests |
|---------|--------------|---------------|
| Output | Single AIPerfJob CR | Namespace + RBAC + ConfigMap + JobSet |
| Requires | Operator installed | Only JobSet CRD |
| Monitoring | Operator tracks phase/progress | Manual pod watching |
| Results | Stored on operator PVC | Must retrieve before TTL |
| Cancellation | `spec.cancel: true` | Delete the JobSet |
| Conditions | Status conditions populated (`ConfigValid`, `EndpointReachable`, `PreflightPassed`, `ResourcesCreated`, `WorkersReady`, `BenchmarkRunning`, `ResultsAvailable`, `IndexUpdated`, `PreflightHasWarnings`, `Complete`, `Failed`) | None |

Operator mode also persists the active pod startup blocker in
`status.startupIssue`. This keeps the first-observed timestamp across operator
restarts and lets CI distinguish a retryable capacity delay from a stable
image, configuration, or placement failure before the terminal `Failed`
condition is set.

For production use, the operator mode is recommended.

---

## Kueue Gang-Scheduling

If your cluster uses [Kueue](https://kueue.sigs.k8s.io/) for resource management, AIPerf integrates with it for quota-managed gang-scheduling.

### Submit to a Kueue Queue

```bash
aiperf kube profile \
  --config benchmark.yaml \
  --image nvcr.io/nvidia/aiperf:latest \
  --queue-name gpu-benchmarks \
  --priority-class high-priority
```

Or in YAML:

```yaml
spec:
  scheduling:
    queueName: gpu-benchmarks
    priorityClass: high-priority
```

When a queue is specified:

1. The JobSet is created in a suspended state
2. Kueue evaluates quota availability
3. Once resources are available, Kueue unsuspends the JobSet
4. The operator detects the transition and monitors normally

`aiperf kube list` shows Kueue suspension status:

```bash
aiperf kube list
# Shows phase "Queued" while the JobSet awaits Kueue admission
```

---

## Private Registries

### Image Pull Secrets

If your AIPerf image is in a private registry:

```bash
# Create the pull secret
kubectl create secret docker-registry my-registry \
  --docker-server=nvcr.io \
  --docker-username='$oauthtoken' \
  --docker-password='YOUR_TOKEN' \
  -n my-benchmarks

# Reference it when deploying
aiperf kube profile ... --image-pull-secrets my-registry
```

Or in YAML:

```yaml
spec:
  podTemplate:
    imagePullSecrets:
      - {name: my-registry}
```

### API Keys and Secrets

Pass API keys to the benchmark pods without embedding them in the config:

```bash
# Create a secret with your API key
kubectl create secret generic llm-api-key \
  --from-literal=api-key='sk-...' \
  -n my-benchmarks

# Reference it as an environment variable. Name the pod variable, and pass
# `<secret>/<key>` as its value.
aiperf kube profile ... \
  --env-from-secrets.OPENAI_API_KEY llm-api-key/api-key
```

`--env-from-secrets` is a mapping flag and accepts three equivalent spellings.
`--annotations`, `--labels`, and `--env-vars` accept the same three:

```bash
--env-from-secrets.OPENAI_API_KEY llm-api-key/api-key   # dot-notation
--env-from-secrets OPENAI_API_KEY=llm-api-key/api-key   # KEY=VALUE
--env-from-secrets '{"OPENAI_API_KEY": "llm-api-key/api-key"}'  # JSON object
```

Repeat the flag to set more than one entry.

Endpoint credentials are transported out of band from the benchmark
ConfigMap. AIPerf writes only redacted placeholders to `run_config.json`, then
each service restores the real values from Secret-backed environment
variables at startup. A credentialed endpoint without the matching
`valueFrom.secretKeyRef` mapping is rejected before the JobSet is created.

| Endpoint credential | Secret-backed pod environment variable |
|---|---|
| `endpoint.apiKey` | `AIPERF_INJECTED_API_KEY` or `OPENAI_API_KEY` |
| Sensitive headers such as `Authorization` or `X-API-Key` | `AIPERF_INJECTED_HEADERS` containing a JSON object of header strings |
| URL userinfo such as `https://user:password@host` | `AIPERF_INJECTED_ENDPOINT_URLS` containing a JSON list of full URL strings |

Do not use `--env-vars` for these values: literal pod environment values are
not Secret-backed and fail the credential transport check.

Or in YAML:

```yaml
spec:
  podTemplate:
    env:
      - name: OPENAI_API_KEY
        valueFrom:
          secretKeyRef:
            name: llm-api-key
            key: api-key
```

### Mounting Secret Files

For secrets that need to be files (e.g., certificates, tokens):

```bash
aiperf kube profile ... \
  --secret-mounts '{"name": "tls-cert", "mount_path": "/certs/ca.pem", "sub_path": "ca.pem"}'
```

`--secret-mounts` accepts the same shapes `--tolerations` does: a single JSON
object, a JSON array covering several mounts in one token, or the flag repeated
once per mount.

---

## Node Placement

### Target Specific GPUs

```bash
aiperf kube profile ... \
  --node-selector '{"nvidia.com/gpu.product": "NVIDIA-A100-SXM4-80GB"}'
```

### Tolerate GPU Taints

Many clusters taint GPU nodes. Add tolerations so benchmark pods can schedule there:

```bash
aiperf kube profile ... \
  --tolerations '[{"key": "nvidia.com/gpu", "operator": "Exists", "effect": "NoSchedule"}]'
```

Or in YAML:

```yaml
spec:
  podTemplate:
    nodeSelector:
      nvidia.com/gpu.product: "NVIDIA-A100-SXM4-80GB"
    tolerations:
      - key: nvidia.com/gpu
        operator: Exists
        effect: NoSchedule
```

---

## Scaling Workers

AIPerf distributes workers across pods automatically. The `--total-workers` flag sets the total number of workers. The system places 10 workers per pod by default:

| `--total-workers` | Pods Created | Workers Per Pod |
|-----------------|-------------|-----------------|
| 10 | 1 | 10 |
| 50 | 5 | 10 |
| 100 | 10 | 10 |
| 200 | 20 | 10 |

A `--total-workers` value above the per-pod count that is not a multiple of it
cannot be expressed as a JobSet of identical pods, so it is rejected with an
error naming the two nearest usable totals. Keep the total a multiple of 10, or
set `benchmark.runtime.workersPerPod` to change the packing factor -- there is
no CLI flag for it. A total at or below the per-pod count runs on a single pod.

Only an authored total is rejected. When you omit `--total-workers` and
`benchmark.runtime.workers`, the count derived from `ceil(concurrency /
connectionsPerWorker)` is rounded up to a whole number of pods instead, since
failing on it would report a worker count you never chose.

Each worker maintains up to `connectionsPerWorker` concurrent requests (default: 100). When you pass `--total-workers` explicitly, the CLI derives this from your concurrency and worker count: `connectionsPerWorker = ceil(concurrency / workers)`. Left unset, the relationship inverts and the worker count is derived instead: `workers = ceil(concurrency / connectionsPerWorker)`.

For high-concurrency benchmarks, scale workers rather than increasing connections per worker. This distributes the load across pods and nodes.

---

## Results Server

The operator includes a results server sidecar that provides HTTP access to stored results. This powers `aiperf kube results` (which retrieves from the operator's PVC by default) and provides analytics endpoints backed by the SQLite runs index (`.aiperf_index.sqlite` on the results volume).

### Available Endpoints

After the operator is running:

```bash
# Port-forward to the results server
kubectl port-forward -n aiperf-system svc/aiperf-operator 8081:8081

# List stored results
curl localhost:8081/api/v1/results

# Get summary for a specific job
curl localhost:8081/api/v1/analytics/summary/my-benchmarks/my-benchmark

# Leaderboard across all benchmarks
curl localhost:8081/api/v1/analytics/leaderboard

# Compare two runs (repeat the jobs parameter, one per job)
curl "localhost:8081/api/v1/analytics/compare?jobs=run-a&jobs=run-b"

# Job history
curl "localhost:8081/api/v1/analytics/history?model=Qwen/Qwen3-0.6B"
```

### Storage Configuration

Results are stored on the operator's PVC. Configure retention in the Helm values:

```yaml
operator:
  env:
    resultsTtlDays: "30"           # auto-cleanup after 30 days
    resultsCompressOnDisk: "true"  # zstd compression

storage:
  enabled: true                    # default; backs results in a PVC so they survive pod restarts
  size: 1Ti                        # only used when enabled: true
  storageClassName: ""             # cluster default; only used when enabled: true
```

`spec.resultsTtlDays` overrides this default per AIPerfJob or AIPerfSweep. A
sweep persists the selected value in each epoch's aggregate, so cleanup remains
effective after Kubernetes deletes the parent CR. Cleanup also removes the
corresponding sweep index rows and repairs the sweep's `latest.txt` pointer.

### Exposing Results Outside the Cluster (Ingress)

By default the results server is reachable only via `ClusterIP + kubectl port-forward`. To expose it through an Ingress (e.g. for a shared dashboard link), enable `ingress.*` in the Helm values:

```yaml
ingress:
  enabled: true
  className: "nginx"          # empty uses the cluster default IngressClass
  annotations: {}
  hosts:
    - host: aiperf.example.com
      paths:
        - path: /
          pathType: Prefix    # backend port defaults to resultsServer.port (8081)
  tls: []                     # optional: list of {hosts: [...], secretName: ...}
```

The template lives at `deploy/helm/aiperf-operator/templates/ingress.yaml` and routes to the operator Service on `resultsServer.port`. Each path may override the backend port via `portNumber`.

---

## Environment Variables

Fine-tune benchmark behavior with environment variables in the pod template:

```yaml
spec:
  podTemplate:
    env:
      - name: AIPERF_HTTP_CONNECTION_LIMIT
        value: "200"
      - name: AIPERF_HTTP_KEEPALIVE_TIMEOUT
        value: "120"
```

Probe tunables such as `AIPERF_K8S_HEALTH_STARTUP_PERIOD_SECONDS` and
`AIPERF_K8S_HEALTH_STARTUP_FAILURE_THRESHOLD` do not belong here. They are read
by whichever process renders the JobSet -- the operator pod, or the CLI in
direct mode -- and baked into the pod spec's probe fields before the benchmark
container exists. Setting them under `spec.podTemplate.env` has no effect; set
them on the operator Deployment (or in the CLI's own environment) instead.

There is no `AIPERF_HTTP_TIMEOUT` variable; set the per-request timeout with
the `--request-timeout-seconds` CLI flag (or `request_timeout_seconds` in the
benchmark config) instead. See the
[Environment Variables Reference](/aiperf/dev/reference/environment-variables) for the full list.

---

## Multi-Tenant Clusters

### Operator Scope and Namespace Ownership

An operator install is one of two things, decided by `operator.id`:

| | `operator.id` | Namespaces watched | Writes a claim |
|---|---|---|---|
| **Global operator** | `""` (default) | all, or `operator.watchNamespaces` | no |
| **Scoped operator** | a unique string | `operator.watchNamespaces` | yes |

A scoped operator claims each namespace it watches by writing a
`coordination.k8s.io/v1` Lease named `aiperf-operator` into it, and renews that
Lease on a timer. Every operator — the global one included — skips any
namespace whose Lease is held by a different identity and still fresh. Nothing
is set on the AIPerfJob itself, so a job cannot be submitted to the wrong
operator by forgetting a label.

This is what lets a scoped build run beside the production operator:

```bash
helm install aiperf-test deploy/helm/aiperf-operator \
  --namespace aiperf-system-test --create-namespace \
  --set operator.id=my-branch \
  --set 'operator.watchNamespaces={aiperf-test}'
```

The global operator stops reconciling `aiperf-test` as soon as the claim lands,
and resumes when the claim lapses. `aiperf kube list` shows the holder in its
`OWNER` column; `-` means the global operator owns that namespace, and `?`
means the claim could not be read (usually missing RBAC on `leases`).

The global operator caches namespace ownership and refreshes it every
`AIPERF_OPERATOR_CLAIM_LEASE_SECONDS / 3` seconds (~100 s by default). During
that window after a scoped operator starts, the global operator may briefly act
on events in a newly-claimed namespace before its cache updates.

Two scoped operators cannot claim the same namespace: the second one fails at
startup naming the current holder, rather than silently double-reconciling.

The claim's duration (`AIPERF_OPERATOR_CLAIM_LEASE_SECONDS`, default 300s) is
deliberately long, so a crash-looping operator keeps its namespaces across
restarts. Uninstalling a scoped operator lets its claims expire and the
namespaces fall back to the global operator; nothing needs to be deleted by
hand for ownership to transfer.

The `aiperf-operator` Lease object itself does survive the uninstall in each
watched namespace. It is expired, has no effect on ownership, and is only
visible in `kubectl get leases`. Remove it manually if you want it gone:

```bash
kubectl delete lease aiperf-operator -n <namespace>
```

### Namespace Isolation

Benchmarks run in the namespace you name with `--namespace`. For multi-tenant setups, give each team its own:

```bash
aiperf kube profile ... --namespace team-a-benchmarks --operator
aiperf kube profile ... --namespace team-b-benchmarks --operator
```

By default the operator watches all namespaces for AIPerfJob CRs; set
`operator.watchNamespaces` in the Helm values to restrict it to a fixed list,
and `operator.id` to also claim those namespaces away from a global operator
(see [Operator Scope and Namespace Ownership](#operator-scope-and-namespace-ownership)).
Each job gets its own RBAC scoped to its namespace. Here `--operator` bypasses cluster-scoped CRD
discovery, and the namespaces must already exist -- the CLI never issues a
Namespace-create request. A tenant therefore needs permission
to create `AIPerfJob` resources in its namespace, not permission to read CRDs or
create namespaces.

The per-job Role grants no create, update, or delete verbs, so a compromised
benchmark pod cannot launch workloads of its own. What it retains inside its
namespace is read access to pod, ConfigMap, Service, Endpoint, Event, and Job
metadata, plus `patch` on its own AIPerfJob and JobSet — which is why a
namespace per team, rather than a shared benchmark namespace, is still the
tenancy boundary. See
[RBAC and Security](/aiperf/dev/kubernetes/rbac-and-security#operator-created-per-job-role).

If cluster policy forbids chart-managed cluster-scoped RBAC, install with
`rbac.create=false`, `serviceAccount.create=false`, an explicit
`serviceAccount.name`, and `tests.enabled=false`; that combination emits zero
`ClusterRole`/`ClusterRoleBinding` objects. See
[Eliminating all cluster-scoped RBAC](/aiperf/dev/kubernetes/rbac-and-security#eliminating-all-cluster-scoped-rbac).

### Resource Quotas

The preflight checker validates resource quotas before deploying:

```bash
aiperf kube preflight --namespace team-a-benchmarks --workers 20
```

If the namespace has a ResourceQuota, preflight projects the total CPU and memory requirements and warns if deployment would exceed the quota.

---

## Operator Management

### Upgrading

```bash
helm upgrade aiperf-operator deploy/helm/aiperf-operator \
  --namespace aiperf-system
```

> **WARNING:** Drain active benchmarks before upgrading from a chart older than
> 0.8.0. Those versions rendered the benchmark `Role` and `RoleBinding` into a
> chart-owned `aiperf-benchmarks` namespace; the upgrade deletes that RBAC and
> recreates it in the release namespace. A benchmark running in
> `aiperf-benchmarks` at that moment loses its RBAC mid-flight and starts
> failing its pod, ConfigMap, and JobSet reads. Wait for in-flight runs to
> reach a terminal phase (`aiperf kube list`) before running `helm upgrade`.

### Uninstalling

```bash
helm uninstall aiperf-operator --namespace aiperf-system
```

Both CRDs carry `helm.sh/resource-policy: keep`, so the `AIPerfJob` and `AIPerfSweep` CRDs -- and any existing custom resources -- survive the uninstall. To remove everything:

```bash
kubectl delete crd aiperfjobs.aiperf.nvidia.com
kubectl delete crd aiperfsweeps.aiperf.nvidia.com
kubectl delete namespace aiperf-system
```

### Monitoring the Operator

Check operator logs:

```bash
kubectl logs -n aiperf-system -l app.kubernetes.io/name=aiperf-operator -f
```

The operator emits structured logs with job events, phase transitions, and error details.

### Debugging Failed Jobs

By default the operator cleans up JobSet pods once a job terminates, which makes postmortems hard. Set `keepFailedPods: true` on the AIPerfJob spec to preserve failed pod attempts for inspection:

```yaml
spec:
  keepFailedPods: true   # default: false; retains failed JobSet pods for kubectl logs/exec
```

This changes JobSet lifecycle (completed pods still get reaped via `ttlSecondsAfterFinished`); enable it only while diagnosing, then remove for steady-state runs.

### Deploying Before the Endpoint Is Reachable

The operator runs a TCP/HTTP reachability probe against `spec.benchmark.endpoint` before spinning up workers. If the inference server isn't live yet at deploy time (e.g. GitOps applies the CR before the model pod is Ready), set `skipEndpointCheck: true` to bypass the probe:

```yaml
spec:
  skipEndpointCheck: true   # default: false; skips the operator-side endpoint reachability probe
```

Workers will still fail fast if the endpoint never comes up -- this only suppresses the upfront check.

---

## Related Documentation

- [Getting Started](/aiperf/dev/kubernetes/getting-started-on-kubernetes) -- First benchmark walkthrough
- [Deploy from a Source Checkout](/aiperf/dev/kubernetes/deploy-from-source-checkout) -- Build and push AIPerf, Helm install the operator, and run on a real cluster
- [Kubernetes Configuration](/aiperf/dev/kubernetes/kubernetes-configuration-reference) -- All CRD fields and deployment options
- [Monitoring and Troubleshooting](/aiperf/dev/kubernetes/monitoring-and-observability) -- Watch, debug, and diagnose issues
- [Environment Variables](/aiperf/dev/reference/environment-variables) -- All AIPERF_* environment variables