Skip to content

Compute

Where a notebook's code runs, and how big it may be.

Infrastructure defines the menu; you pick from it. Profiles live in the project file or the environment, and the app shows them without offering to edit them, because a size you can change from inside the app is a size the machine underneath may not have.

Runners

Runner Where a kernel runs Sizing Isolation
subprocess beside the app, in its own process none a scrubbed subprocess with the network denied
docker a container on the same host cgroup container, filesystem, and an egress proxy
kubernetes one pod per session cgroup, scheduled pod, plus the cluster's runtimeClassName

subprocess is the laptop and single-machine tier. It is deliberately unsized: the only mechanism available to a process, RLIMIT_AS, caps virtual address space rather than resident memory and counts file mappings, so it misfires in ordinary numpy and Arrow code long before real memory is exhausted. A limit that fires spuriously is worse than none. A cgroup is the only honest place to cap memory, so the deployed tiers have one and this one does not.

kubernetes is the one a real deployment should use. The reason is the self-hosting one: your cluster already has capacity, an autoscaler, GPU node pools and, through runtimeClassName, gVisor or Kata isolation, so a sandbox can be as strong as your platform allows without this product shipping a hypervisor.

Select it with SANDBOX_BACKEND=kubernetes, and name the namespace kernels run in with COMPUTE_NAMESPACE (optional; it is also where a ResourceQuota would go).

Agent-written derivation candidates run on this runner too, as a Job per candidate: restartPolicy: Never, backoffLimit: 0, a TTL for cleanup. Kubernetes' own guidance prefers a Job to a bare Pod even for a single pod, because a bare Pod on a failed node is simply gone; the zero retries are ours, because a candidate is deterministic and a retry reproduces the same failure at twice the cost. A candidate's inputs travel in the pod's environment, which bounds them at 96 KiB; past that the run is refused with a message saying to bind the data as a warehouse table instead of passing it.

Kernels attach over the Kubernetes API, so the app needs pod verbs in that namespace: a Role granting exactly pods, pods/attach and pods/exec, and nothing else. The kernel pod itself gets no identity at all: no service-account token, no service environment variables, every capability dropped, a read-only root filesystem and a non-root user. The grant belongs to the app, which is trusted; not to the sandbox, which is not.

Two things follow from the app reaching the API through kubectl rather than a client library, and a deployment that misses either is broken in a way it only discovers when somebody runs a cell. The app image ships kubectl, because kubectl attach is the only way to reach a pod's stdin, and stdin is what the kernel protocol speaks. And the app's pod must have its service-account token mounted: kubectl authenticates in-cluster with exactly that token, so a Role granted to a service account that cannot present itself is no grant at all.

Profiles

A profile is a named shape with its limits and its audience:

compute:
  profiles:
    - name: small
      cpu: "2"
      memory: 4Gi
    - name: large
      cpu: "8"
      memory: 32Gi
      idleTimeout: 3600
    - name: gpu
      cpu: "8"
      memory: 32Gi
      gpu: 1
      gpuType: nvidia-l4
      runtimeClass: gvisor
      allowedGroups: [ml]
      egress: none
  defaultProfile: small

Quantities are Kubernetes quantities ("2" or "500m" for CPU, 8Gi for memory) everywhere, including on the docker runner, which converts. Note the trap Kubernetes itself carries: a lowercase m on memory means millibytes, so memory: 512m is a request for half a byte. It is rejected rather than honoured.

Field Meaning
cpu, memory Kubernetes quantities. Requests equal limits, so a kernel gets the Guaranteed QoS class and is not evicted to make room.
gpu, gpuType Whole GPUs, and the node label selecting the accelerator.
image Overrides the deployment's kernel image for this profile, so a GPU profile can carry a CUDA userland without every profile paying for it.
idleTimeout Seconds with no execution and no attached client before the kernel is reaped. Default 30 minutes, and the cap a notebook's own setting is clamped to.
maxRuntime Seconds one cell may run. A separate limit, because a forgotten kernel and a runaway cell are different failures.
egress full, none, or a host allowlist. Intersected with the project's policy; never widens it.
allowedGroups Org roles whose members may select this profile. Empty means everyone.
spot Interruptible capacity: cheap, and an eviction loses the kernel. Right for batch, wrong for an interactive default.
runtimeClass gvisor on GKE Sandbox, kata-vm-isolation on AKS.
warmPoolSize Kernels kept started ahead of demand. Zero by default; the customer pays for idle capacity.
shared Put every notebook on this profile into one kernel. See Shared kernels.
hidden Fields to leave out of what the app shows. Changes the display, never the enforcement.

Set none of this and you get small, medium and large at sensible defaults.

A notebook's profile belongs to the notebook rather than to whoever opens it, so two collaborators do not get two different machines. Anyone who can edit the notebook can change it, and the change is audited.

A notebook may also shorten its own idle timeout, through its metadata, and never extend it: holding a kernel is a cost someone else pays, so the profile is the admin cap:

{"metadata": {"idle_timeout": 300}}

When a profile changes

Databricks documents the failure mode precisely: after a policy changes, compute created under it "aren't automatically updated". So an admin who tightens a memory limit believes they have and has not.

Each profile carries a version derived from its own content, a running kernel records the version it started with, and GET /api/compute/notebooks/{id} reports the difference:

{
  "profile": {"name": "small", "memory": "1Gi"},
  "startedWith": {"name": "small", "memory": "4Gi"},
  "drift": "running on small as it was defined at version a1b2c3…; restart the kernel to pick it up."
}

Restarting is what applies it. A profile edit does not kill live kernels, because resizing a profile should not destroy the state of everyone currently working.

Warm pools

A profile can keep kernels started ahead of demand, so opening a notebook does not wait for a pod:

compute:
  profiles:
    - name: small
      warmPoolSize: 2

Zero by default, and it should usually stay there: you pay for that idle capacity whether or not a notebook opens. The pool is keyed by the profile as defined at that moment and by the exact dependency set, so a resized profile never hands out a kernel built to the old size and a notebook with its own dependencies never gets one warmed without them. A per-notebook scratch workspace disqualifies a session from the pool, because a pooled kernel is started before any notebook is known.

Shared kernels

shared: true puts every notebook on a profile into one interpreter. It is opt-in because whatever one notebook leaves in the namespace is what the next one sees, and a shared kernel cannot carry per-notebook credentials. Databricks says the same of their equivalent, "data or internal credentials provisioned to that environment might be accessible to any code running within that environment", and has since made that mode legacy and off by default for new accounts.

The case it is good for is a cheap scratch tier, where a process per notebook is not worth paying for and nothing sensitive is in the room.

Environments

A notebook's dependencies are resolved to a lock and content-addressed by sha256(image + packages), so the same set is provisioned once and reused across runs and sessions. What changes per runner is only what the digest names: a docker volume, or a Kubernetes volume populated by an init container.

That init container checks for a completion sentinel first and writes it last, so an interrupted install is never mistaken for a finished one. With a ReadWriteMany storage class the install happens once per dependency set and is shared:

compute:
  depsStorageClass: efs-sc   # must support ReadWriteMany

Without one, each session installs into its own scratch. Slower, and correct on the block storage most clusters actually have, which a default requiring RWX would not be.

Egress

A profile's network policy is enforced by the cluster, not by the sandbox, so code running in a notebook cannot lift its own restriction. The runner labels each pod and the a deployment supplies the network policies that select on it: none denies every outbound connection, restricted permits DNS and the ranges you name, full is unselected and therefore unrestricted.

One honest limitation: a NetworkPolicy selects on IP and label, never on hostname, so a profile's host allowlist cannot be expressed there as written. State the equivalent ranges:

compute:
  networkPolicy:
    allowedCIDRs: ["10.0.0.0/8"]

Where SQL runs

Explore, metrics, dashboards and a notebook's sql() all funnel through one query path. DuckDB is an in-process engine, so executing there means the engine and the request handlers compete for the same pages, and a query that runs out of memory ends the web server rather than the query. DuckDB's own community is direct about it: embedded in a production server, "an out-of-memory crash takes down the entire service."

That is the same failure this design fixed for notebooks, so it gets the same answer.

compute:
  queryRunner: worker      # or `inprocess`; empty follows the kernel runner
  queryPoolSize: 2
  queryTimeoutSeconds: 300

worker runs queries in a small pool of persistent child processes that own DuckDB. A query's memory belongs to a worker; one that exhausts it dies and is replaced, and the app never notices. The pool is small because each worker is a whole DuckDB, and larger than one because a dashboard with several panels should not queue behind itself.

inprocess executes in the app. That is right for a laptop, where a worker pool buys isolation nobody needs, and wrong for anything serving more than one person. Left empty, the setting follows the kernel runner: subprocess gets in-process, anything else gets workers.

DuckDB 1.5.2 added a native client-server protocol, which is the eventual shape for a query service addressed over the network: a Deployment scaled on its own. The property that matters first is the boundary, not where the boundary lives.

Cost

Every finished session is attributed to (user, notebook, profile, duration) from the first one, because usage history cannot be backfilled; Databricks is explicit that missing tags "can't be added to past events".

compute:
  maxCostPerHour: "5"      # refuse to offer a profile estimated above this
  spendLimit: "500"        # alert when the window's attributed spend passes this
  costRates: '{"cpu_core_hour": 0.04, "memory_gib_hour": 0.005, "gpu_hour": 1.0}'

maxCostPerHour is checked when the menu loads, so a profile nobody may launch is never offered. spendLimit is an alert, not a cap: sessions keep running. Databricks is candid that its own compute spend limits are notification-only and that they do "not proactively terminate resources to maintain the limit", and ending someone's session to recover the overage destroys work to save cents.

The rates are an estimate for comparing profiles and enforcing a ceiling, not a bill. Databricks does the same thing with a synthetic unit rather than currency. The defaults are rough on-demand list prices and are wrong for anyone on reserved capacity.

GET /api/compute/usage reports the window, split by interactive and scheduled. Those mean different things: batch is work somebody scheduled, interactive is work somebody is doing, and only the second is worth chasing when it sits idle. Databricks bills them to different SKUs and does "not recommend" running production jobs on all-purpose compute.

What a pushdown cost

A cell records every query it sent to the warehouse (the statement, its duration, rows returned, whether the result was truncated, the error if it failed) and shows them under the cell. The fields follow Databricks' query history (statement, duration, rows produced), minus what a single-node engine cannot honestly report such as bytes scanned.

This exists because the scale story is pushdown, and a claim about where work happens should be checkable. A failed query is kept with its duration rather than dropped: one that failed after twelve seconds is the one worth seeing.

Storage credentials

A kernel fetches data on demand, so it needs to reach the warehouse. Configure a credential broker and each session gets a short-lived credential scoped to the prefixes it is entitled to; configure none and data reaches a cell through the app instead, and the sandbox holds no credential at all.

Scoping is the whole trade, and it is a place worth being careful: Apache Polaris, the reference implementation of this pattern, shipped two critical CVEs in one release from user-controlled names landing in a policy language. A quote in a table name closed a CEL literal and made a prefix restriction bucket-wide; a * in a table name became an IAM wildcard. Both defences here are structural: every value entering a CEL expression is escaped, and every prefix is normalised to end at a separator, refuses .., and refuses the wildcard characters IAM offers no way to escape.

On AWS, set SANDBOX_ROLE_ARN to a role the app may assume and whose own policy grants read of the warehouse bucket; the Terraform module creates one with create_sandbox_role = true. The warehouse has to live under a prefix (s3://bucket/warehouse), because a bucket root has nothing to scope to and the app refuses to vend rather than issue a credential covering the whole bucket.

A session's credential is scoped to the warehouse root and read-only, not to the tables one notebook has opened. A notebook can query any registered table, so a per-table credential would be reminted on every query and would still cover everything a cell could ask for by the end of the session, narrowing to the warehouse, to reads, for fifteen minutes is the bound that actually holds.

All three clouds vend, behind their own extras: elbi-core[aws], [gcp], [azure]. AWS assumes a role with an inline session policy; GCS downscopes the pod's own token with a Credential Access Boundary; Azure signs a user delegation SAS over one directory. On GCS and Azure there is no role to name, because the broker downscopes or delegates from the identity the pod already has, so vending is switched on with SANDBOX_VEND_CREDENTIALS=true rather than by supplying an ARN, and it is off by default because handing a sandbox a storage credential is a decision.

Two mechanism differences worth knowing. A downscoped GCS token has no lifetime of its own: it inherits the input token's expiry, so a short TTL comes from minting a fresh input token rather than asking for a short boundary. And an Azure SAS is capped at seven days by its delegation key, which the broker brackets slightly wider than the SAS so clock skew cannot expire the key before the token it signed.

What this does not do

Stated plainly, because each of these is a reasonable thing to expect.

No Spark. There is no spark object, and there is deliberately no shim over one. A spark.read that works beside a spark.sql window function that silently differs is a worse outcome than an honest absence: the partiality gets discovered at the worst possible moment. Push work down with sql(...), which runs against the warehouse where the engine and the data already are. If you have real Spark assets, the intended path is to keep running them on the cluster you have.

No multi-GPU, no distributed training. A GPU profile is one GPU on one node. This is the mainstream position rather than a compromise: Databricks' AI Runtime accelerators "provision a single node", Hex ships two profiles of one GPU each, and on Databricks a GPU forces dedicated access mode, so a GPU independently implies a single-user machine.

No autoscaling within a session. A Databricks cluster scales workers between a min and a max while running. A single Python kernel has nothing to scale out to; resizing means restarting, which loses state. The honest equivalents are picking a bigger profile and pushing work down.

No init scripts. Databricks supports them and now actively discourages them: "Databricks recommends using compute policies instead of init scripts to install libraries", and they do not run on serverless at all. The declarative dependency-plus-lock design is what they are steering people towards, so the absence of an arbitrary pre-start shell hook is the feature.

No snapshot and restore. A reaped kernel restarts cold.

SQL does not have a separately scaled service. Query work runs on its own processes (see Where SQL runs), which is the isolation boundary that matters, but not on compute you scale independently of the app the way a Databricks SQL warehouse is. Adding panels to a dashboard raises load on the app's own pod rather than on something you size for it.