Runtime Environments¶
Ray RuntimeEnv lets one Ray cluster execute tasks with different Python packages, uploaded project code, environment variables, or container images. django-ray adds named profiles and a durable environment identity on top of Ray's native feature.
Cold-start warning: The first task using a new profile can be substantially slower while Ray downloads code and creates the environment on the selected node. RuntimeEnv caches are node-local, so the first use on every node is cold; later executions of the identical environment on a warm node are usually much faster.
RuntimeEnv Is More Than pip¶
django-ray passes the resolved profile to Ray as a native RuntimeEnv mapping. Use the environment mechanism that fits the workload:
| Field | Best suited to |
|---|---|
working_dir |
Shipping an application directory or remote ZIP archive |
py_modules |
Adding reusable modules, wheels, or remote archives to PYTHONPATH |
pip |
Installing Python packages with pip |
uv |
Installing Python packages with uv |
conda |
Selecting a named Conda environment or defining one inline |
env_vars |
Setting non-secret worker environment variables |
image_uri / container |
Running workers in a prebuilt container image |
py_executable |
Selecting an alternate Python executable or launcher |
config |
Controlling setup timeout and other RuntimeEnv behavior |
See Ray's complete RuntimeEnv API reference for supported fields, value formats, and version-specific constraints.
For example, profiles can use uv or Conda without changing django-ray's task API:
DJANGO_RAY = {
"RUNTIME_ENV_PROFILES": {
"analytics-uv": {
"working_dir": "s3://deployments/analytics/7f3a2c1.zip",
"uv": ["numpy==2.3.5", "polars==1.31.0"],
"env_vars": {"DJANGO_SETTINGS_MODULE": "config.settings"},
},
"analytics-conda": {
"working_dir": "s3://deployments/analytics/7f3a2c1.zip",
"conda": {
"channels": ["conda-forge"],
"dependencies": ["python=3.12", "numpy=2.3"],
},
"env_vars": {"DJANGO_SETTINGS_MODULE": "config.settings"},
},
},
}
Ray does not allow top-level pip and conda in the same RuntimeEnv. Its
container-based fields also have stricter combination rules: image_uri must
generally be used alone except for env_vars and config, so application code
and dependencies should already be present in that image. Consult the linked Ray
reference when mixing fields.
Define Profiles¶
Profiles live in DJANGO_RAY. A profile can be a direct Ray RuntimeEnv mapping:
# settings.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DJANGO_RAY = {
"RAY_ADDRESS": "ray://ray-head-svc:10001",
"RUNTIME_ENV_PROFILES": {
"project": {
"working_dir": os.environ.get(
"DJANGO_RAY_WORKING_DIR_URI",
str(BASE_DIR),
),
"excludes": [".git", ".venv"],
"pip": [
"django>=6.0.8",
"sqlparse>=0.6.0",
"psycopg[binary]>=3.1",
],
"env_vars": {
"DJANGO_SETTINGS_MODULE": "config.settings",
"PYTHONPATH": "src",
},
},
},
"DEFAULT_RUNTIME_ENV_PROFILE": "project",
}
Profiles may extend another profile. Dictionary fields such as env_vars are
merged; list fields pip, uv, py_modules, and excludes are appended.
Other fields are replaced:
DJANGO_RAY = {
"RAY_ADDRESS": "ray://ray-head-svc:10001",
"RUNTIME_ENV_PROFILES": {
"project": {
"working_dir": "s3://deployments/myapp/7f3a2c1.zip",
"pip": ["django>=6.0.8", "sqlparse>=0.6.0"],
"env_vars": {"DJANGO_SETTINGS_MODULE": "config.settings"},
},
"numpy-2-2": {
"extends": "project",
"runtime_env": {
"pip": ["numpy==2.2.6"],
"env_vars": {"APP_VARIANT": "numpy-2-2"},
},
},
"numpy-2-3": {
"extends": "project",
"runtime_env": {
"pip": ["numpy==2.3.5"],
"env_vars": {"APP_VARIANT": "numpy-2-3"},
},
},
},
}
Pin production dependencies and use immutable archive URIs for working_dir or
py_modules when reproducibility matters.
- Local mode and Ray Client task managers can content-address and upload a local
directory or archive before per-task submission. The path must be readable by
the task-manager process; Ray nodes receive the resulting
gcs://_ray_pkg_...artifact rather than the task manager's filesystem path. - Ray Job submission can upload its job-level local working directory.
https://,s3://,gs://, and sharedfile://archives remain useful when another system owns artifact distribution. Opaque URIs are not retry-safe unless their content identity is independently verifiable.
Local py_modules directories and files remain available to dynamic Ray tasks, but
version 1 rejects them for reusable strategies. Their import basename changes Python
semantics, while Ray 2.56's package URI is not a strong identity for every such archive.
The plan fingerprints the basename and content so retry pinning remains conservative;
prefer an independently verified immutable artifact before enabling future reuse.
Do not point a standalone Django pod at the head node's GCS port as a substitute for a
Ray node. A direct Ray Core driver also expects a local raylet. The repository's
KubeRay overlay demonstrates the shared file:///runtime-env/django-ray-source.zip
pattern for local testing.
Select a Profile for a Django Task¶
Django Tasks supports backend aliases. Bind each alias to one trusted profile:
TASKS = {
"default": {
"BACKEND": "django_ray.backends.RayTaskBackend",
"QUEUES": ["default"],
"OPTIONS": {"RUNTIME_ENV_PROFILE": "project"},
},
"numpy-2-3": {
"BACKEND": "django_ray.backends.RayTaskBackend",
"QUEUES": ["default"],
"OPTIONS": {"RUNTIME_ENV_PROFILE": "numpy-2-3"},
},
}
Select it with the standard Django API:
# myapp/tasks.py
from django.tasks import task
@task(queue_name="default")
def average(values: list[float]) -> float:
import numpy
return float(numpy.mean(values))
result = average.using(backend="numpy-2-3").enqueue([1.0, 2.0, 3.0])
The backend resolves the profile during enqueue and stores its canonical JSON and
SHA-256 identity on RayTaskExecution. Retries use that immutable snapshot even
if Django settings change after enqueue.
New writes pass through one storage boundary. The compatibility default stores canonical plaintext JSON. Deployments may instead opt into an authenticated AES-256-GCM envelope; readers always accept both supported plaintext and encrypted rows, independently of the current write mode. Before Sync, Ray Core, or Ray Job execution, django-ray decrypts when needed, canonicalizes the mapping, and verifies its matching plaintext SHA-256 hash. Manual and automatic retries repeat that verification while holding the task lifecycle lock, before archiving an attempt or resetting task metadata. Missing, malformed, unsupported, unknown-key, authentication-failed, noncanonical, or hash-mismatched snapshots fail permanently without reaching Ray; bulk retry operations skip the affected row and continue.
Rows created before the snapshot fields existed retain migration's exact canonical {}
payload and have neither a profile nor a hash. Only that complete legacy marker resolves
the current configured default because migration could not reconstruct the environment
used by the original task. Any other payload without identity metadata fails closed.
Canonical {} plus its hash is instead a valid identified empty RuntimeEnv.
RAY_RUNTIME_ENV remains supported as the unnamed default. A backend may also
provide an inline RAY_RUNTIME_ENV, but it cannot combine that option with
RUNTIME_ENV_PROFILE.
Encrypt Durable Snapshots¶
RuntimeEnv encryption is an opt-in protection for the runtime_env_json database
column. New installations and upgrades keep RUNTIME_ENV_STORAGE_MODE="plaintext"
until an operator completes the key-distribution rollout. Encrypted mode uses a fresh
random 12-byte nonce for every write and stores a strict canonical envelope:
{
"algorithm": "AES-256-GCM",
"ciphertext": "<canonical base64url>",
"format": "django-ray.runtime-env.encrypted",
"key_id": "runtime-env-2026-01",
"nonce": "<canonical base64url>",
"version": 1
}
The envelope contains no plaintext RuntimeEnv. AES-GCM authenticates canonical
associated data containing the format, version, algorithm, key ID, task ID,
RuntimeEnv profile, and public plaintext hash. Moving an envelope to another task or
changing any bound identity therefore fails closed. The unencrypted
runtime_env_hash remains the SHA-256 of canonical plaintext so workflow identity and
cache correlation do not change.
The django-ray.runtime-env.* format namespace and the exact six-field envelope
shape are reserved as the storage discriminator. Individual field names such as
version or nonce remain available to Ray custom RuntimeEnv plugins; a mapping is
not treated as encrypted merely because one generic name appears.
Prefer a dedicated key ring whose secret material is managed separately from the database and Django signing keys:
import os
DJANGO_RAY = {
"RAY_ADDRESS": "ray://ray-head-svc:10001",
"RUNTIME_ENV_STORAGE_MODE": "encrypted",
"RUNTIME_ENV_ENCRYPTION_KEYS": {
"runtime-env-2026-01": os.environ["DJANGO_RAY_RUNTIME_ENV_KEY_2026_01"],
# Retain older keys while any durable row may still name them.
"runtime-env-2025-10": os.environ["DJANGO_RAY_RUNTIME_ENV_KEY_2025_10"],
},
"RUNTIME_ENV_ENCRYPTION_ACTIVE_KEY": "runtime-env-2026-01",
}
Each dedicated key is an unpadded, canonical base64url encoding of exactly 32 random
bytes. A key ID is case-sensitive, contains at most 64 letters, numbers, dots,
underscores, or hyphens, and starts with a letter or number. django-secret is
reserved for the fallback described below. Invalid keys, IDs, modes, or active-key
selection fail at Django startup and enqueue without creating a task row or external
input object.
When a separate key is not practical, a deployment may explicitly derive an AES key from Django's signing secret:
DJANGO_RAY = {
"RAY_ADDRESS": "ray://ray-head-svc:10001",
"RUNTIME_ENV_STORAGE_MODE": "encrypted",
"RUNTIME_ENV_ENCRYPTION_ACTIVE_KEY": "django-secret",
"RUNTIME_ENV_ENCRYPTION_DJANGO_SECRET_FALLBACK": True,
}
This fallback is never automatic. django-ray derives a 32-byte key with HKDF-SHA256
and a versioned django-ray domain context; it does not use the raw SECRET_KEY as an
AES key. New writes use the current SECRET_KEY. Reads try that key and then
SECRET_KEY_FALLBACKS under the same stable django-secret key ID, without recording
which fallback succeeded. Dedicated keys remain preferable because Django signing-key
rotation and RuntimeEnv retention usually have different schedules.
Roll out encrypted writes¶
Use this order for a rolling deployment:
- Back up the complete RuntimeEnv key ring separately from the database. Deploy the
dual-read release to every web process, task producer, retry API, admin process, and
task manager while writes remain
plaintext. - Distribute every retained key to all of those processes and restart them, still in plaintext mode. Validate configuration everywhere before any encrypted row exists.
- Confirm no pre-encryption reader remains, then select the active key and switch new
writes to
encrypted. - Enqueue and execute a canary, inspect the raw database field without copying it into logs, and prove a tampered or unknown-key row fails before Ray submission.
Switching the same dual-read release back to plaintext changes only future writes.
Existing encrypted rows still require their keys. Downgrading to a binary that does
not understand encrypted envelopes is unsafe while any encrypted row remains: it
cannot be made safe merely by retaining the key. This delivery has no historical
rewrite or rewrap command.
For a dedicated-key rotation, distribute the new key to every reader first, then make
it active. Generate a new key ID for every new key: the binding from a dedicated key
ID to its bytes is immutable, and replacing material under an existing ID makes
historical envelopes that name it unrecoverable. Keep every retired key until an
independent inventory proves no queued, running, retryable, or retained row still
names it. The stable django-secret ID is the documented exception because readers
try the current derived key and retained fallback candidates. Add the old SECRET_KEY
to SECRET_KEY_FALLBACKS before rotating it and retain it for the full RuntimeEnv row
lifetime. Removing a required dedicated key or Django fallback makes the affected
snapshots unrecoverable.
Understand the threat boundary¶
Encryption protects RuntimeEnv plaintext from a party that can read only the database, a replica, dump, or backup, provided that party cannot also read the key store. It also detects changes to an existing encrypted envelope or its bound identity. It does not:
- protect against combined database-and-key access, Django or task-manager process compromise, or a database writer that deletes/corrupts rows to cause denial of service;
- provide write-integrity against a database writer: because readers deliberately accept both formats during this compatibility release, that actor can replace the complete envelope with canonical plaintext and recompute the public hash. Encrypted writes therefore protect confidentiality at rest, but do not authenticate row provenance, protect execution integrity from that actor, or enforce that every stored row remains encrypted;
- hide the profile or unkeyed
runtime_env_hash, which reveals equality and permits guessing low-entropy environment definitions; - encrypt task arguments, results, progress, workflow input, external input storage, or a RuntimeEnv value copied into one of those fields; or
- keep plaintext out of Django process memory, the Ray submission channel, Ray worker process memory and caches, or application-created logs.
Back up keys through a different access path from database backups and test restoring both. Encryption is defense in depth, not permission to place long-lived credentials in RuntimeEnv. Prefer workload identity, mounted secrets, and provider-specific credential mechanisms.
Select a Profile for a Workflow Step¶
Workflow leaves inherit the outer task environment unless they request another:
from django_ray.workflows import chain, step
def load_rows(values: list[float]) -> list[float]:
return values
def run_numpy_model(values: list[float]) -> dict[str, float]:
import numpy
return {"mean": float(numpy.mean(values))}
def store_summary(summary: dict[str, float]) -> dict[str, float]:
return summary
pipeline = chain(
step(load_rows),
step(run_numpy_model, runtime_env="numpy-2-3"),
step(store_summary),
)
Inline environments are also accepted:
def column_names(rows: list[dict[str, object]]) -> list[str]:
import pandas
return [str(name) for name in pandas.DataFrame(rows).columns]
step(
column_names,
runtime_env={"pip": ["pandas==2.3.0"]},
)
Use signature.with_runtime_env("profile-name") when constructing a workflow
dynamically. The older ray_options={"runtime_env": ...} form remains compatible.
RuntimeEnv identity in effective workflow plans¶
Named and inline step environments are resolved when WorkflowSignature.run() creates
its effective plan, before the first workflow leaf is submitted. Changing a package,
archive, image, working-directory snapshot, or named profile content therefore changes
the plan identity or produces an explicit reusable-strategy rejection. Mutating Django
settings after materialization cannot change the environment submitted by that run.
Credential variable values and URI credentials never enter the plan and are never
hashed into its fingerprint. Credential-looking names remain visible as schema
metadata and are covered by a non-secret provider revision. Ordinary environment
values such as MODE are also kept out of the fingerprint because a variable name is
not proof that its value is non-secret. Reusable strategies require one declared
environment_revision that covers the full non-secret configuration contract:
DJANGO_RAY = {
"WORKFLOW_PLAN_CODE_REVISION": "container:sha256:0123456789abcdef",
"WORKFLOW_PLAN_TRUST_IDENTITY": {
"trust_domain": "cluster:production",
"credential_provider": "kubernetes-service-account",
"credential_revision": "provider-v3",
"environment_revision": "namespace-sync-v8",
"scheduling_revision": "placement-v2",
"service_account_audience": "kubernetes.default.svc",
},
}
credential_revision identifies the provider contract, not a token. Rotating a token
inside the same declared provider revision does not churn a plan or leak a secret-derived
digest. environment_revision is an operator promise to change the revision whenever
any covered ordinary runtime value changes. Changing either revision, the provider,
trust domain, or audience invalidates the plan.
If an environment has secret-dependent behavior but no safe provider revision, dynamic
Ray tasks remain available and reusable strategies receive UNRESOLVED_RUNTIME_ENV.
Ray label selectors use the separate scheduling_revision; dashboard names and task
labels are execution-only annotations and never enter the plan fingerprint.
The identity transported to a Ray worker is a strict versioned envelope containing only
bounded diagnostics and digests of the safe projection. The full RuntimeEnv and its
secret-bearing execution values travel only through Ray's execution channel. Local
per-step code paths are snapshotted with Ray's packaging semantics and rebound before
the first leaf is submitted; fenced durable plans are pinned before package upload so
stale attempts cannot create artifacts. A source change during packaging fails before
leaf submission. Ray's current local-package cache key is not a strong end-to-end
verification of django-ray's SHA-256 snapshot, so local working_dir and py_modules
inputs remain dynamic-only and receive UNRESOLVED_RUNTIME_ENV until a worker-verifiable
artifact identity is available.
Reusable-strategy eligibility and durable retry safety are separate decisions. A local file or tree snapshot is not reusable because Ray does not yet verify django-ray's content digest end to end, but it is safe to retry after django-ray rechecks the same content before packaging. Conversely, a mutable package pin or opaque URI remains valid for dynamic execution yet is retry-unsafe: its raw value is intentionally absent from the secret-free plan, so a later attempt cannot prove that the execution binding stayed the same. The effective plan persists bounded retry-safety paths and the attempt that first pinned it; later attempts fail before submission when any binding is unsafe. Provider and environment revisions restore retry safety only for the values their documented contracts cover, without hashing raw credentials.
Caching and Performance¶
Ray caches RuntimeEnv artifacts on each node. Reusing identical canonical specs allows later tasks to avoid most environment setup work. django-ray's environment hash makes this identity visible in the database and admin.
Keep the number of environment variants bounded:
- prefer named, stable profiles over a unique inline environment per task;
- group related work under one outer task or actor when it can reuse an environment;
- compare the first run with a repeated run before drawing latency conclusions;
- prebuild system libraries and very large common dependencies into the base image.
RuntimeEnv removes application-image rebuilds; it does not make dependency installation free.
The cache is node-local. The first fan-out across four cold nodes may install the same environment four times in parallel. Warm each node before a latency-sensitive rollout, or keep large, common dependencies in the base image. See Performance.
Generic KubeRay Images¶
The KubeRay example uses the upstream rayproject/ray image for Ray head and
worker containers, plus a stock Python image for its dashboard-import helper.
The project profile uploads source and installs Python dependencies. The Django
web and task-manager images remain application-specific.
The deterministic recovery example uses a second deployment-built archive. Its source and locked task dependency closure are packaged reproducibly, mounted into the Django web/task-manager pods, hashed before enqueue/submission, and uploaded by the Ray Client task manager to Ray's content-addressed package store. The generic Ray image therefore remains generic while every durable attempt is bound to the same archive bytes.
For Ray Client submissions, django-ray serializes only its small outer bootstrap executor by value. The generic head can therefore deserialize the submission before the task-level RuntimeEnv installs and exposes the full project package.
This separation works well for a shared cluster within one trust boundary:
- The Django task manager resolves a named profile at enqueue time.
- The task row stores the resolved profile snapshot and its content hash.
- The generic Ray cluster creates that environment on the node selected for work.
- That node can reuse its cached copy for later tasks with the same environment.
Mount credentials, certificates, and shared data through the cluster deployment.
Avoid secrets in profile URIs or env_vars: plaintext is necessarily available to
the Django task manager and Ray runtime even when the durable database snapshot is
encrypted. The Django admin intentionally omits the raw snapshot and shows only its
profile and content hash. Plaintext storage remains the compatibility default, so
database access, dumps, and backups must be treated as able to reveal RuntimeEnv
values until encrypted writes are explicitly enabled and verified.
The hash detects accidental corruption and incomplete lifecycle state but remains unkeyed and visible in encrypted mode. AES-GCM binds the hash and task identity for authenticated storage; it does not turn a shared Ray cluster into a security boundary.
RuntimeEnv is packaging and dependency isolation, not a security boundary. Use separate Ray clusters for mutually untrusted teams or workloads.
Test Project¶
The sample project defines project, thin, numpy-2-2, numpy-2-3, and
recovery-showcase profiles and exposes:
POST /api/cluster/runtime-env/probe?profile=thin
POST /api/cluster/runtime-env/probe?profile=numpy-2-3&package=numpy
POST /api/cluster/runtime-env/benchmark?profile=numpy-2-3&package=numpy&repeats=3
GET /api/cluster/runtime-env/{task_id}
The GET poller selects only the public state, timing, RuntimeEnv identity, and bounded
current diagnostics. It does not transfer the RuntimeEnv snapshot, task input, workflow
data, completion envelope, or unrelated execution fields. Inline result and error
values are guarded at 16,384 bytes each before transfer, external result storage is
never loaded, and the complete response is at most 65,536 bytes. Result omission uses
external_result_not_loaded, stored_result_exceeds_poll_limit,
malformed_inline_result, or encoded_response_limit; error omission uses
stored_error_exceeds_poll_limit or encoded_response_limit. The response advertises
diagnostic_max_bytes=16384 and response_max_bytes=65536 so clients can enforce the
same ceiling.
The benchmark runs repeated workflow leaves with the same profile and reports
per-run elapsed time so cold setup and cache reuse are easy to compare.
recovery-showcase is intentionally not selectable through the generic probe
endpoint. It is reserved for the deterministic three-attempt example and resolves
to content-hashed local import roots for local/Compose use or the self-contained
Kubernetes recovery archive. The route verifies that this profile is retry-safe and
fails closed when the backend, profile, archive, or immutable identity is unavailable;
it never silently uses project. The sample project profile remains valid for
ordinary first-attempt dynamic execution, but its broad package constraints and
opaque shared archive URI deliberately remain retry-unsafe.