Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/test-integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ jobs:
kubectl wait --for=condition=ready pod -l ray.io/node-type=head --timeout=300s

- name: Verify NebariApp (serve)
run: kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve --timeout=300s
run: kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve-pack --timeout=300s

- name: Verify NebariApp (dashboard)
run: kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve-dashboard --timeout=300s
run: kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve-pack-dashboard --timeout=300s

- name: Verify HTTPRoutes created
run: |
Expand All @@ -161,7 +161,7 @@ jobs:

- name: Health check Ray Dashboard via port-forward
run: |
kubectl port-forward svc/rayserve-nebari-rayserve-head-svc 8265:8265 &
kubectl port-forward svc/rayserve-nebari-rayserve-pack-head-svc 8265:8265 &
sleep 5
curl -sf http://localhost:8265/api/version
kill %1
Expand All @@ -171,6 +171,11 @@ jobs:
# ============================================================
- name: Upgrade with auth enabled
run: |
# Auth is the only intended delta. The sizing overrides from the
# install step must be repeated: dropping them doubles the head's
# requests and adds a worker, which rolls the Ray cluster into pods
# a 2-CPU runner cannot schedule, and --wait then times out on
# "generation is 2, but latest observed generation is 1".
helm upgrade rayserve chart/ \
--set kuberay-operator.enabled=false \
--set nebariapp.enabled=true \
Expand All @@ -179,6 +184,11 @@ jobs:
--set nebariapp.dashboard.enabled=true \
--set nebariapp.dashboard.hostname=ray-dashboard.nebari.local \
--set nebariapp.auth.enabled=true \
--set head.resources.requests.cpu=500m \
--set head.resources.requests.memory=512Mi \
--set head.resources.limits.cpu=1 \
--set head.resources.limits.memory=1Gi \
--set worker.replicas=0 \
--wait --timeout 5m

- name: Verify SecurityPolicies created
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:

- name: Health check Ray Dashboard
run: |
kubectl port-forward svc/rayserve-nebari-rayserve-head-svc 8265:8265 &
kubectl port-forward svc/rayserve-nebari-rayserve-pack-head-svc 8265:8265 &
sleep 5
curl -sf http://localhost:8265/api/version
kill %1
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Nebari Ray Serve Software Pack

A [Nebari Software Pack](https://github.com/nebari-dev/nebari-software-pack-template) that deploys [Ray Serve](https://docs.ray.io/en/latest/serve/index.html) on Kubernetes using the [RayService CRD](https://docs.ray.io/en/latest/serve/production-guide/kubernetes.html), with optional routing, TLS, and OIDC authentication via the [nebari-operator](https://github.com/nebari-dev/nebari-operator).
A [Nebari Software Pack](https://github.com/nebari-dev/software-pack-template) that deploys [Ray Serve](https://docs.ray.io/en/latest/serve/index.html) on Kubernetes using the [RayService CRD](https://docs.ray.io/en/latest/serve/production-guide/kubernetes.html), with optional routing, TLS, and OIDC authentication via the [nebari-operator](https://github.com/nebari-dev/nebari-operator).

## Overview

Expand Down Expand Up @@ -40,10 +40,10 @@ Access via port-forward:

```bash
# Ray Dashboard
kubectl port-forward svc/rayserve-nebari-rayserve-head-svc 8265:8265 -n rayserve
kubectl port-forward svc/rayserve-nebari-rayserve-pack-head-svc 8265:8265 -n rayserve

# Ray Serve endpoint
kubectl port-forward svc/rayserve-nebari-rayserve-serve-svc 8000:8000 -n rayserve
kubectl port-forward svc/rayserve-nebari-rayserve-pack-serve-svc 8000:8000 -n rayserve
```

### On a Nebari cluster (via ArgoCD)
Expand All @@ -63,7 +63,7 @@ metadata:
spec:
project: default
source:
repoURL: https://github.com/nebari-dev/nebari-rayserve-pack.git
repoURL: https://github.com/nebari-dev/rayserve-pack.git
targetRevision: main
path: chart
helm:
Expand Down Expand Up @@ -127,15 +127,15 @@ spec:

## Connecting from Jupyter

From a notebook running in the same cluster (e.g., via the [nebari-data-science-pack](https://github.com/nebari-dev/nebari-data-science-pack)):
From a notebook running in the same cluster (e.g., via the [nebari-data-science-pack](https://github.com/nebari-dev/data-science-pack)):

```python
import ray
from ray import serve
import requests

# Connect to the Ray cluster
ray.init("ray://rayserve-nebari-rayserve-head-svc.rayserve.svc.cluster.local:10001")
ray.init("ray://rayserve-nebari-rayserve-pack-head-svc.rayserve.svc.cluster.local:10001")

# Deploy a model
@serve.deployment
Expand All @@ -146,14 +146,14 @@ class Hello:
serve.run(Hello.bind(), name="hello", route_prefix="/hello")

# Run inference
resp = requests.get("http://rayserve-nebari-rayserve-serve-svc.rayserve.svc.cluster.local:8000/hello")
resp = requests.get("http://rayserve-nebari-rayserve-pack-serve-svc.rayserve.svc.cluster.local:8000/hello")
print(resp.text)
# Hello from Ray Serve!
```

No manual Serve initialization is needed — the RayService CRD starts the Serve proxy with `host: 0.0.0.0` automatically.

**Note:** The Ray and Python versions in your Jupyter environment must match the Ray cluster. This chart deploys Ray 2.43.0 with Python 3.9 by default. If using [Nebi](https://github.com/nebari-dev/nebari-nebi-pack) for environment management, create a workspace with:
**Note:** The Ray and Python versions in your Jupyter environment must match the Ray cluster. This chart deploys Ray 2.43.0 with Python 3.9 by default. If using [Nebi](https://github.com/nebari-dev/nebi-pack) for environment management, create a workspace with:

```toml
[workspace]
Expand All @@ -174,7 +174,7 @@ For production, bake your model code into a custom Docker image and declare appl
```yaml
image:
repository: your-registry/your-ray-image
tag: "2.43.0-custom"
tag: "2.43.0"

serveApplications:
- name: my-model
Expand All @@ -196,7 +196,7 @@ Key values in `chart/values.yaml`:
| Value | Default | Description |
|-------|---------|-------------|
| `nebariapp.enabled` | `false` | Create NebariApp resources for routing/TLS/auth |
| `nebariapp.serve.enabled` | `true` | Expose the serve endpoint externally (set `false` to keep internal-only) |
| `nebariapp.serve.enabled` | `false` | Expose the serve endpoint externally (set `false` to keep internal-only) |
| `nebariapp.hostname` | - | Hostname for the Ray Serve endpoint (required when serve.enabled) |
| `nebariapp.dashboard.enabled` | `true` | Create a separate NebariApp for the Ray Dashboard |
| `nebariapp.dashboard.hostname` | - | Hostname for the Ray Dashboard (required when dashboard enabled) |
Expand Down Expand Up @@ -258,7 +258,7 @@ orgCABundle:

> **⚠️ ArgoCD footgun — the CA bundle silently won't apply.** The ArgoCD `Application` shown under [On a Nebari cluster (via ArgoCD)](#on-a-nebari-cluster-via-argocd) sets `RespectIgnoreDifferences=true` together with an `ignoreDifferences` rule on `/spec/rayClusterConfig`. With server-side apply, that combination tells ArgoCD to **stop managing every field under `rayClusterConfig`** — which is exactly where this chart injects the initContainer, volumes, volumeMounts, and CA env vars for the head and worker pods. The result is a silent failure: ArgoCD reports a healthy, fully-synced `Application`, but the running RayService never gets the CA bundle, and TLS calls keep failing with `CERTIFICATE_VERIFY_FAILED`.
>
> If you enable `orgCABundle` on a cluster managed by ArgoCD with the example sync policy, you must **narrow the ignore rule** so the CA fields are still reconciled. The broad `/spec/rayClusterConfig` ignore exists only to suppress the autoscaler/runtime mutations KubeRay makes; replace it with targeted pointers (or drop it and ignore only the specific subpaths KubeRay rewrites). After changing it, confirm the head and worker pods actually carry `SSL_CERT_FILE` (`kubectl exec ... -- printenv SSL_CERT_FILE`) rather than trusting the ArgoCD sync status. See [#17](https://github.com/nebari-dev/nebari-rayserve-pack/issues/17) for details.
> If you enable `orgCABundle` on a cluster managed by ArgoCD with the example sync policy, you must **narrow the ignore rule** so the CA fields are still reconciled. The broad `/spec/rayClusterConfig` ignore exists only to suppress the autoscaler/runtime mutations KubeRay makes; replace it with targeted pointers (or drop it and ignore only the specific subpaths KubeRay rewrites). After changing it, confirm the head and worker pods actually carry `SSL_CERT_FILE` (`kubectl exec ... -- printenv SSL_CERT_FILE`) rather than trusting the ArgoCD sync status. See [#17](https://github.com/nebari-dev/rayserve-pack/issues/17) for details.

**Coverage caveat — httpx default `verify=True`:** httpx hardcodes its SSL context to `cafile=certifi.where()`, which means it **ignores** `SSL_CERT_FILE`. Application code making httpx calls that need to traverse a TLS-inspecting proxy must construct an explicit context:

Expand Down
8 changes: 5 additions & 3 deletions dev/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ up: cluster
helm dependency update $(CHART_DIR)
helm upgrade --install rayserve $(CHART_DIR) \
--set nebariapp.enabled=true \
--set nebariapp.serve.enabled=true \
--set nebariapp.hostname=$(HOSTNAME) \
--set nebariapp.dashboard.enabled=true \
--set nebariapp.dashboard.hostname=$(DASH_HOSTNAME) \
--wait --timeout 5m
kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve --timeout=180s
kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve-pack --timeout=180s
kubectl wait --for=condition=Ready nebariapp/rayserve-nebari-rayserve-pack-dashboard --timeout=180s
CLUSTER_NAME=$(CLUSTER_NAME) $(OPERATOR_REPO)/dev/scripts/networking/update-hosts.sh
@echo ""
@echo "Ray Serve deployed with NebariApp."
Expand All @@ -144,8 +146,8 @@ up-standalone:
@echo "Ray Serve deployed (standalone, no NebariApp)."
@echo ""
@echo "Access via port-forward:"
@echo " kubectl port-forward svc/rayserve-nebari-rayserve-serve-svc 8000:8000"
@echo " kubectl port-forward svc/rayserve-nebari-rayserve-head-svc 8265:8265"
@echo " kubectl port-forward svc/rayserve-nebari-rayserve-pack-serve-svc 8000:8000"
@echo " kubectl port-forward svc/rayserve-nebari-rayserve-pack-head-svc 8265:8265"

# --------------------------------------------------------------------------
# update-hosts - update /etc/hosts with all NebariApp hostnames
Expand Down
29 changes: 28 additions & 1 deletion docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,37 @@ export default defineConfig({
// GitHub social link) comes from the @nebari/starlight theme plugin. On the
// portal the header logo returns users to the pack catalog.
plugins: [nebari({ logoHref: 'https://packs.nebari.dev/' })],
editLink: {
// Starlight appends the source path (src/content/docs/<file>.md) to this
// base, so it must point at the Astro project root inside the repo.
baseUrl: 'https://github.com/nebari-dev/rayserve-pack/edit/main/docs/',
},
sidebar: [
{
label: 'Getting Started',
items: [{ label: 'Introduction', link: '/' }],
items: [
{ label: 'Introduction', link: '/' },
{ label: 'Getting started', link: '/getting-started/' },
{ label: 'Deploying on Nebari', link: '/deployment/' },
{ label: 'Connecting from Jupyter', link: '/jupyter/' },
{ label: 'Local development', link: '/local-development/' },
],
},
{
label: 'Guides',
items: [
{ label: 'Deploying models', link: '/serve-applications/' },
{ label: 'Scaling and GPUs', link: '/scaling/' },
{ label: 'Organization CA bundle', link: '/ca-bundle/' },
{ label: 'Troubleshooting', link: '/troubleshooting/' },
],
},
{
label: 'Reference',
items: [
{ label: 'Configuration', link: '/configuration/' },
{ label: 'Architecture', link: '/architecture/' },
],
},
],
}),
Expand Down
152 changes: 152 additions & 0 deletions docs/src/content/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
title: Architecture
description: How the KubeRay operator, RayService, Services, and NebariApps fit together.
---

```mermaid
flowchart TD
subgraph KO["KubeRay Operator"]
op["Manages RayService lifecycle"]
end

subgraph RS["RayService CRD"]
subgraph RC["RayCluster"]
head["Head Pod\n:8265 dashboard\n:8000 serve\n:10001 client"]
workers["Worker Pod(s)\nRay Workers"]
end
end

subgraph SVC["Kubernetes Services"]
headsvc["-head-svc\n:8265 :10001 :6379"]
servesvc["-serve-svc\n:8000"]
end

subgraph NB["NebariApp (optional)"]
route["HTTPRoute + OIDC auth\nvia Envoy Gateway"]
end

jupyter["Jupyter Notebook\n(in-cluster)"]
browser["Browser\n(external)"]

KO --> RS
head --- workers
head --> headsvc
head --> servesvc
servesvc --> route

jupyter -->|"ray:// :10001"| headsvc
jupyter -->|"HTTP :8000"| servesvc
browser -->|"HTTPS"| route

style KO fill:#fef0db,stroke:#e8952c,color:#7c4a03
style RS fill:#eeeef3,stroke:#4a4a6a,color:#1a1a2e
style RC fill:#e8faf8,stroke:#20aaa1,color:#0d5d57
style SVC fill:#d4f5f2,stroke:#20aaa1,color:#0d5d57
style NB fill:#f3e8fc,stroke:#c840e9,color:#6b21a8
```

## The chain

**KubeRay operator** watches `RayService` resources. It creates the underlying
`RayCluster`, deploys the Serve applications named in `serveConfigV2`, monitors their
health, and performs zero-downtime upgrades when the config changes.

**RayService** is the chart's central object. It carries two things: the Serve config, and
the cluster config. The chart writes both from values — `serve.proxyLocation` and
`serveApplications` into `serveConfigV2`, and the head/worker specs into
`rayClusterConfig`.

**The head pod** runs the GCS (Ray's metadata store), the dashboard, the Serve controller,
and — with `proxyLocation: EveryNode` — an HTTP proxy. It exposes four ports: `6379` GCS,
`8265` dashboard, `10001` Ray client, `8000` Serve HTTP.

**Worker pods** run Ray workers, and Serve replicas land on them. One group,
`groupName: workers`, sized by `worker.replicas` between `minReplicas` and `maxReplicas`.

## Why the chart renders its own Services

RayService creates its own stable Services — but only after every Serve application reports
healthy. With the default empty `serveApplications` that condition never holds, so the
dashboard and Serve endpoint would be unreachable on a fresh install.

The chart therefore renders both itself, selecting the head pod directly:

```yaml
selector:
ray.io/node-type: head
app.kubernetes.io/name: kuberay
```

They exist from the moment the chart installs, regardless of Serve state. Both carry
`argocd.argoproj.io/compare-options: IgnoreExtraneous`, which tells Argo CD to skip them
during comparison when they are live in the cluster but absent from the desired state — so
KubeRay adopting and rewriting them does not surface as drift or trigger a prune.

Note that `serve-svc` targets **only the head pod**, even under `proxyLocation: EveryNode`.
The per-node proxies serve direct-to-pod traffic; the Service does not load-balance across
them. This is also why worker readiness has no effect on user-visible HTTP routing — the
reason the chart can safely simplify the worker probes.

## Serve config

```yaml
serveConfigV2: |
proxy_location: {{ serve.proxyLocation }}
http_options:
host: "0.0.0.0"
port: 8000
applications: [...]
```

`host: "0.0.0.0"` is set here so the proxy binds all interfaces from the start — without it
Serve binds loopback and nothing outside the pod can reach it. That is why there is no
manual `serve start` step anywhere in this pack.

## Two access paths, by design

| | In-cluster | External |
|---|---|---|
| Client | notebooks, other pods | browsers, API clients |
| Route | ClusterIP Service | `NebariApp` → HTTPRoute → Envoy |
| Auth | none | OIDC at the gateway, when enabled |
| Protocols | `ray://` and HTTP | HTTPS |

The split is not an oversight. The Ray client protocol cannot traverse an OIDC redirect, so
notebooks must reach the head service directly. Access control on that path is
NetworkPolicy, not identity — anything permitted to reach `:10001` can submit arbitrary
code to the Ray cluster, which is worth scoping deliberately.

## The two NebariApps

Serve and dashboard get separate resources with separate hostnames, because they are
separate audiences with different exposure appetites. The recommended posture keeps the
serve endpoint internal (`serve.enabled: false`) and exposes only the dashboard.

Both inherit the same `auth` and `gateway` settings — there is no per-endpoint override.

## Conditional injections

Two features render nothing at all when unused, so the output is byte-identical to a plain
install:

- **[CA bundle](/ca-bundle/)** — an initContainer, volumes, mounts, and four environment
variables on both pod specs, only when `orgCABundle.configMapName` is set.
- **[GPU toleration](/scaling/#gpus)** — an `nvidia.com/gpu` toleration, only when that
group's `resources` mention the GPU resource, and only when you have not defined one
yourself.

Both live under `spec.rayClusterConfig`, which is what makes the Argo CD
`ignoreDifferences` rule on that path consequential rather than cosmetic. See
[Deploying on Nebari](/deployment/#why-ignoredifferences-is-there).

## What state lives where

| State | Where | Survives a cluster roll |
|---|---|---|
| Declarative Serve applications | `serveConfigV2` in the RayService | yes |
| Applications deployed via `serve.run()` | the running Ray cluster | no |
| Model code | the container image | yes |
| Anything written to a pod filesystem | the pod | no |

The chart provisions no persistent volumes. Anything that must survive belongs in the image
or in external storage.
Loading
Loading