blob: fb69b910985b40f086563217aa2aca9b86bb564c [file] [view]
# Deployment guide
This guide takes you from "I have a Kubernetes cluster" to "PySpark
clients are landing on the right Spark Connect drivers and staying
there across gateway-pod restarts." It is written for the operator
who will own the deployment day-2 — the README explains *what* the
gateway is; this doc explains *how to run it well*.
## Prerequisites
| | Required | Why |
|---|---|---|
| Kubernetes | ≥ 1.24 | Chart uses `discovery.k8s.io/v1` EndpointSlices (GA in 1.21) and probe HTTP/2 fields stabilized in 1.24 |
| Helm | ≥ 4.0 | Chart uses Helm 4 schema. Earlier Helm versions will refuse to install. |
| Spark Connect servers | ≥ 1 reachable from the gateway pods | The gateway is a stateless proxy; it needs *something* to forward to |
| Redis | Optional but **strongly recommended** for HA | Multi-replica gateway needs shared affinity state — see [Redis decision](#picking-the-redis-backing) |
The gateway itself does not need any cluster-scoped permissions. The
only RBAC it ever needs is namespace-scoped read on `endpoints` /
`endpointslices`, and only when `backendDiscovery.type=k8s`. The
chart attaches that automatically.
## Day 0: a minimal install
Pick a namespace and use the chart defaults:
```bash
helm install scg ./deploy/helm/scg \
--namespace spark-connect --create-namespace
```
Defaults give you:
* 2 gateway replicas (the smallest meaningful HA configuration)
* 1 bundled Redis StatefulSet with AOF persistence + 1Gi PVC
* A static backend list pointing at
`spark-connect-1.svc.cluster.local:15002` and
`spark-connect-2.svc.cluster.local:15002`
* No authentication (every caller is `user_id="anonymous"`)
* No OTel tracing (structured JSON logs only)
This is **enough for a smoke test on a trusted in-cluster network**.
It is *not* a production configuration; see the rest of this guide
for how to harden it.
Verify the install:
```bash
kubectl -n spark-connect rollout status deployment/scg
kubectl -n spark-connect get pods -l app.kubernetes.io/name=scg
kubectl -n spark-connect port-forward svc/scg 9090:9090 &
curl http://localhost:9090/readyz # expect: 200 ready
curl http://localhost:9090/healthz # expect: 200 ok
```
If `/readyz` returns 503, the backend pool is empty. See the
[runbook](runbook.md#readyz-stuck-on-503).
## Picking the backend discovery mode
The gateway needs a list of Spark Connect server addresses. Two
ways:
### `static` — explicit list
```yaml
backendDiscovery:
type: static
static:
addresses:
- "spark-connect-1.svc.cluster.local:15002"
- "spark-connect-2.svc.cluster.local:15002"
```
Use this when:
* Spark Connect servers run *outside* this Kubernetes cluster (VMs,
another cluster, an EMR-managed driver).
* You're scripting the address list from outside Helm anyway (CI
produces it).
* You want zero RBAC on the gateway pod.
### `k8s` — Endpoints watch
```yaml
backendDiscovery:
type: k8s
k8s:
namespace: spark-connect
serviceName: spark-connect
port: 15002
```
Use this when Spark Connect servers run as pods in *this* cluster
and you scale them up/down — the gateway picks up additions and
removals within seconds without a Helm rollout. This is the natural
match for the
[`apache/spark-kubernetes-operator`](https://github.com/apache/spark-kubernetes-operator)
deployment pattern (see
[`deploy/examples/spark-connect-server/`](../deploy/examples/spark-connect-server/)
in this repo).
The chart automatically attaches a `Role` and `RoleBinding` granting
`endpoints/endpointslices` get/list/watch in the target namespace
when `type: k8s`. If your namespaced backend lives in a *different*
namespace from the gateway release, the chart puts the Role in the
backend namespace and the RoleBinding pointing at the gateway's
ServiceAccount in the release namespace.
## Picking the Redis backing
The gateway maps `(user_id, session_id) -> backend` and pins each
session to that backend forever. Where that map lives is the most
important production decision.
### `affinityStore.type: memory` — single-replica only
`HashMap` in the gateway process. Simple, fast, lost on pod
restart. **Only safe with `replicaCount: 1`** — multiple replicas
each have their own map, and a load balancer that fans out across
replicas will repeatedly re-pin sessions to different backends,
breaking Spark Connect's per-driver `SparkSession` invariant
(temp views, cached frames, conf settings disappear).
The chart enforces this at template time: `replicaCount > 1` with
`affinityStore.type: memory` causes `helm install` to fail.
Use case: dev / PoC / single-tenant on a single-pod box. Not for
HA.
### `affinityStore.type: redis` — recommended
The default. The map lives in Redis; all replicas read/write the
same dataset.
Two flavors:
**Bundled** (`redis.enabled: true`, default):
```yaml
redis:
enabled: true
persistence:
enabled: true
size: 1Gi
```
A single-replica StatefulSet with AOF persistence. Good for dev,
staging, and small-scale production. The dataset is tiny (one entry
per active Spark session, a few hundred bytes), 1Gi is over-sized
for any realistic workload.
Limitations:
* Single replica — Redis itself is a SPOF. Restart drops in-flight
reads briefly; AOF means the dataset survives.
* No auth, no TLS — fine on a private namespace, not for shared
clusters.
**External managed Redis** (`redis.enabled: false`):
```yaml
redis:
enabled: false
affinityStore:
type: redis
redis:
url: rediss://elasticache.aws.example:6379
keyPrefix: scg-prod
sessionTtlSecs: 3600
opTtlSecs: 900
```
Use this for any production deployment that takes uptime seriously
— ElastiCache, Cloud Memorystore, Bitnami Redis Helm chart with
Sentinel, etc. The gateway only requires basic single-node
semantics; no Redis Cluster features are used.
What URL format is supported:
* `redis://host:port`
* `redis://:password@host:port`
* `redis://:password@host:port/<db-index>`
* `rediss://host:port` (TLS)
### What happens when Redis is unreachable
The gateway logs a `warn!` per failed Redis call and **degrades
gracefully** — lookups return `None`, binds quietly drop. The
visible effect is that session stickiness stops working; sessions
land on whatever the pool picks each time, the same behaviour you'd
see with the in-memory store on a single replica.
Service stays up; HA stickiness recovers as soon as Redis does.
This is a deliberate design choice — failing requests because
Redis is slow would be worse than a brief stickiness gap.
## Picking the auth mode
| `auth.type` | When to use |
|---|---|
| `none` | Trusted in-cluster network only. Every caller is `user_id="anonymous"`. The gateway's own metrics are usable but you cannot tell tenants apart. |
| `static` | Dev / single-team. Bearer tokens listed in `values.yaml`. Tokens are sealed-secret-able but rotation requires `helm upgrade`. |
| `jwt` | You already issue your own JWTs and have the verification key handy (PEM file or HMAC secret). Audience + issuer claims are checked. |
| `oidc` | Production with an external IdP (Okta, Auth0, Google, Keycloak). The gateway fetches JWKS from `discovery_url`, caches keys, refreshes on `kid` miss with a configurable floor. |
For all four, the gateway *replaces* the client-supplied
`UserContext.user_id` on the forwarded request with the verified
identity, so a malicious client cannot impersonate another user just
by lying in `UserContext`.
Production rule of thumb: `oidc` if you have an IdP, `jwt` if you're
issuing JWTs in-house, `static` for a closed dev setup, `none` only
for namespace-isolated clusters.
## Multi-tenant: picking a tenant resolver
> **Looking for a one-page multi-tenant setup walkthrough?** See
> [`multitenancy.md`](multitenancy.md) for the decision matrix,
> three complete sample configs, and migration paths. The sections
> below are the deep reference for each individual knob.
The routing key the gateway uses for session affinity is
`(tenant, user_id, session_id)`. The tenant prefix lets two
tenants share a gateway without their `session_id` namespaces
colliding — `(team-a, alice, sess-1)` is a different key from
`(team-b, alice, sess-1)`.
The `tenantResolver` config block tells the gateway *how* to figure
out which tenant each RPC belongs to. Pick by deployment shape:
| Deployment shape | `source` | `onMissing` | Notes |
|---|---|---|---|
| **Single-tenant** (default) | `from_claim` | `use_default` | The chart default. Tenant comes from the auth claim if there is one, otherwise falls back to `"default"`. Every RPC ends up in `tenant="default"`. |
| **JWT/OIDC multi-tenant** with a `tenant` claim | `from_claim` | `reject` | Tenant from JWT. Reject any RPC whose verified identity has no tenant claim — that's almost always an IdP misconfiguration in SaaS-style deployments. |
| **No auth but client cooperates** via metadata | `from_metadata` | `use_default` or `reject` | Tenant from a gRPC metadata header (default `x-tenant`). Use `reject` if every client *must* send the header; `use_default` is appropriate when missing-header clients are legitimate internal tools that should land in a default pool. |
| **Single-tenant, no auth, no headers** | `always_default` | n/a | Ignore claim and header; every RPC goes to `defaultName`. |
The chart's default values are the first row above, so a fresh
install behaves as a single-tenant deployment with zero config
changes.
**Migration note**: when you switch a running deployment from
`use_default` to `reject`, any client whose token doesn't carry a
tenant claim starts getting `Unauthenticated`. Roll the auth-side
change (issue tokens with tenant claims) before flipping
`onMissing` to `reject`. Watch
`scg_rpcs_total{code="Unauthenticated"}` during the change.
## Per-tenant pools
Building on the tenant resolver above: each tenant can route to a
*different* backend pool. A SaaS deployment isolates team-A's
queries from team-B's by giving them different Spark Connect
clusters; a per-team deployment shares one cluster.
The `backendDiscovery:` setting at the top of `values.yaml` is the
*default* pool. Add per-tenant overrides under `tenantPools`:
```yaml
backendDiscovery:
type: static
static:
addresses:
- "spark-shared-1.svc.cluster.local:15002"
- "spark-shared-2.svc.cluster.local:15002"
tenantPools:
onUnknownTenant: reject # strict: only configured tenants
overrides:
team-a:
type: static
addresses:
- "spark-team-a-1.svc.cluster.local:15002"
- "spark-team-a-2.svc.cluster.local:15002"
team-b:
type: k8s
namespace: spark-team-b
serviceName: spark-connect
port: 15002
```
`onUnknownTenant` is the routing equivalent of the resolver's
`onMissing` policy:
| Setting | Behaviour for a tenant not in `overrides` |
|---|---|
| `use_default` (default) | Route through the default pool. Single-pool baseline — every tenant shares the same backends. |
| `reject` | `PermissionDenied` to the client. Use for strict SaaS-style isolation; pairs naturally with `tenantResolver.onMissing=reject`. |
Each per-tenant pool gets the same active health-check treatment
as the default pool (when `healthCheck.enabled: true`) — health
probing runs independently per pool, so an unhealthy
team-a backend doesn't affect team-b routing.
**What back-compat looks like**: leaving `tenantPools.overrides`
empty (the default) means every tenant — including the `default`
tenant from the resolver — routes through the deployment's single
pool. This is the single-pool baseline. You opt into multi-tenant
routing by listing tenants you want isolated.
## Per-tenant rate limiting
When a single tenant can monopolize the shared backends — bursts
of session creation, runaway PySpark notebooks, malicious
clients — rate limiting protects the rest. The gateway implements
a token bucket per tenant (and optionally per user inside the
tenant); every RPC takes a token, RPCs that find an empty bucket
fail with `RESOURCE_EXHAUSTED`.
**Two store backends:**
* `rateLimit.store: memory` (default) — bucket state in each
gateway replica. Simple, no extra infra, but the effective
cluster-wide quota is `N × rpcsPerSecond` for N replicas.
* `rateLimit.store: redis` — atomic token bucket in Redis via a
Lua script, shared across all replicas. Cluster-wide enforcement
matches the configured rate exactly. See
[`multitenancy.md`](multitenancy.md#distributed-rate-limiting)
for fail-mode semantics (`onFailure: open | closed`) and the
`scg_rate_limit_redis_errors_total` metric.
Off by default. Turn on with:
```yaml
rateLimit:
enabled: true
default:
rpcsPerSecond: 100 # default tenant refill rate
burst: 200 # max consecutive RPCs before throttling
overrides:
team-a:
rpcsPerSecond: 500
burst: 1000
enterprise-tenant:
rpcsPerSecond: 2000
burst: 5000
perUserRpcsPerSecond: 200 # tighter per-user cap inside the tenant
perUserBurst: 400
```
The per-user dimension is opt-in. Leave `perUserRpcsPerSecond: 0`
(the default) to skip it; only the per-tenant bucket applies. Turn
it on when one tenant has many users and you want to keep any
single user from using the tenant's entire quota.
Tuning starting points:
| Workload shape | Default RPS | Burst |
|---|---|---|
| Many short Spark queries per session | 200 | 400 |
| Long ExecutePlan streams, infrequent setup RPCs | 30 | 60 |
| Mixed / unknown | 100 | 200 |
Reading `scg_rate_limit_rejected_total{tenant, scope}` tells you
whether the limits are biting and which scope (`tenant` vs `user`)
is the bottleneck. See [`observability.md`](observability.md) for
PromQL examples.
## Audit logging
The gateway always knows *what* happened (metrics) and *how it
happened* (logs/traces). Audit logging adds a third stream tuned for
*who did what, when* — the events compliance and security teams ask
for. Configure under `audit:` in `values.yaml`:
```yaml
audit:
enabled: true # default
logSuccessfulRpcs: false # default; turn on only under strict policy
```
Default events (`session.create`, `session.release`, `auth.failure`,
`rpc.error`) are emitted as JSON log lines with
`"target": "scg::audit"` — operators filter on that target in
Loki/Splunk to materialise an audit stream distinct from operational
logs. See [`observability.md`](observability.md#audit-logging) for the
event schema, sample queries, and the rationale for reusing the log
pipeline instead of adding a separate sink.
When to flip `logSuccessfulRpcs: true`:
* The deployment falls under a policy that requires per-call records
(regulated industries, SOC 2 type-II audits scoped to data access).
* You have a log-retention budget that can absorb one extra event per
successful RPC — the audit stream then scales with request rate.
When to leave it off (most deployments): metric counts on
`scg_rpcs_total{code="OK"}` already provide aggregate success, and
filling the audit stream with every Config call dilutes the signal
the four default events are meant to provide.
Disable the whole stream (`audit.enabled: false`) only in dev/local
environments — the per-event cost is one structured log line and
there is rarely a good reason to keep it off in production.
## Active backend health checks
By default the gateway routes to whatever its pool reports as
healthy: a static-pool member is always assumed up; a K8s
service-watch pool reflects the EndpointSlice. Neither catches a
backend that is *running* but *wedged* — the pod responds to TCP
but its gRPC server is stuck. Active probing closes that gap.
Turn it on:
```yaml
healthCheck:
enabled: true
intervalSecs: 5 # probe every 5s
timeoutSecs: 2 # 2s deadline per probe
unhealthyThreshold: 3 # 3 consecutive failures → evict
healthyThreshold: 2 # 2 consecutive successes → re-admit
```
Each backend is probed via `grpc.health.v1.Health/Check` (the
[standard protocol](https://github.com/grpc/grpc/blob/master/doc/health-checking.md)).
A backend that doesn't ship the Health service responds with
`UNIMPLEMENTED`/`NOT_FOUND`; the gateway treats that as an
ambiguous signal and keeps the backend in rotation, since older
Spark Connect server builds don't register Health by default.
Defaults (5s × 3 = ~15s eviction window) are biased towards "don't
evict on a momentary glitch" rather than "evict instantly." Tighten
if your traffic is sensitive to a stuck-pod tail.
## Graceful shutdown
When the gateway pod gets SIGTERM (Helm rolling upgrade, K8s pod
eviction, scale-down), it does a two-step drain:
1. **Step 1 — readiness flips off.** `/readyz` starts returning
503; the K8s Service controller removes the pod from its
Endpoints within ~5s. New client traffic stops landing.
2. **Step 2 — wait for in-flight streams.** The gateway polls
`scg_active_streams`. As long as ExecutePlan / ReattachExecute
/ AddArtifacts streams are still flowing, the gRPC server stays
up. When `active_streams == 0` *or* `shutdown.deadlineSecs`
elapses, the gRPC + admin servers shut down.
Configure the deadline:
```yaml
shutdown:
deadlineSecs: 30
```
The chart's `terminationGracePeriodSeconds` is automatically set to
`deadlineSecs + 10` so K8s gives the gateway enough wall-clock to
finish draining before SIGKILL hits. **If you change one without the
other**, the smaller value wins:
* `deadlineSecs` smaller → drain force-quits early; in-flight
streams seen as `Cancelled` by clients.
* `terminationGracePeriodSeconds` smaller → K8s SIGKILLs mid-drain;
same client-side effect, plus `shutdown complete` log line never
appears.
For long-running ExecutePlan workloads, raise both. The chart's
30s default is enough for the gRPC handshake teardown, but a
multi-minute Spark query in flight will be cut.
## Day 1: hardening
The defaults give you function; production needs all of:
1. **Switch off `auth.type: none`.** External traffic to a
gateway running with `none` is a "we trust the network" bet that
ages badly.
2. **Enforce the trust boundary** — see
[the section below](#enforcing-the-trust-boundary). Auth at the
gateway means nothing if clients can dial the backends directly.
3. **Use external Redis.** The bundled Redis is a SPOF. Pointing at
ElastiCache / Memorystore costs little and removes the SPOF.
4. **Set resource requests/limits.** The chart defaults
(100m CPU / 128Mi RAM) are reasonable for low traffic; size up
to your real load.
5. **Turn on tracing if you have a collector.** The gateway exports
OTLP/gRPC; logs already include the same correlation ID, but
spans on a UI like Tempo or Jaeger make multi-hop investigations
fast. See [`tracing.md`](observability.md#distributed-tracing)
for the known limitation around inbound `traceparent`.
6. **Pin the image tag.** `image.tag: ""` resolves to the chart's
`appVersion`. For production, set it to a specific digest:
```yaml
image:
repository: ghcr.io/<your-mirror>/spark-connect-gateway
tag: "0.1.0@sha256:abc123..."
```
7. **Wire probes to your platform.** The chart already configures
`/healthz` and `/readyz` on the admin port; if your platform
does extra synthetic checks, point them at `/readyz` rather than
the gRPC port (the gRPC port has no HTTP health endpoint, by
gRPC convention).
## Enforcing the trust boundary
Every guarantee in this guide — auth, tenant isolation, rate
limits, audit — holds only while the gateway is the *only* path to
the backend Spark Connect servers. The backend trusts
`UserContext.user_id` as presented and keys sessions by
`(user_id, session_id)`; the gateway rewrites `user_id` with the
verified identity, which is only meaningful while the gateway is
the sole writer of it. Any pod that can dial a backend's gRPC port
directly can claim any identity and reach any session whose id it
knows or guesses.
Enforce the boundary at two layers; production should use both:
**Network layer — `backendNetworkPolicy`.** An opt-in NetworkPolicy
that restricts backend-pod ingress to the gateway pods:
```yaml
backendNetworkPolicy:
enabled: true
backendPodLabels:
app: spark-connect-server
grpcPort: 15002
# NetworkPolicy is default-deny once it selects a pod: whitelist
# any other legitimate ingress (executor→driver traffic on
# operator-managed clusters, Spark UI) via additionalIngress.
```
Requires a CNI that enforces NetworkPolicy (Calico, Cilium, …; some
managed-cluster defaults do not).
**Auth layer — `backendToken`.** Start every backend with
`spark.connect.authenticate.token` (Spark 4.0+) and give the token
only to the gateway; the backend itself then rejects any client
that bypasses the gateway with `UNAUTHENTICATED`, without relying
on the CNI to enforce network policy:
```bash
# The backends and the gateway share the token via a Secret:
kubectl -n spark-connect create secret generic scg-backend-token \
--from-literal=token="$(openssl rand -hex 32)"
```
```yaml
backendToken:
enabled: true
secretName: scg-backend-token
secretKey: token
```
The gateway then presents `authorization: Bearer <token>` on every
gateway→backend request. Per-tenant pools can carry their own token
via `backendToken.tenantOverrides` (see `values.yaml`). The token
reaches the gateway as an env var from the Secret — it never
appears in the rendered ConfigMap. Rotation requires restarting
both the backends and the gateway pods.
The
[`e2e-trust-boundary`](../deploy/examples/e2e-trust-boundary/)
walkthrough proves the boundary end to end on a kind cluster: a
direct-to-backend connection is refused with `UNAUTHENTICATED`,
the same client succeeds through the gateway, and (negative
control) a gateway with the token disabled is refused too.
### The token is only as private as the `Config` RPC
The auth layer rests on clients not learning the token, and that
is not something the backend guarantees on its own. Spark's
`Config` RPC reads back any key the session holds — including
`spark.connect.authenticate.token`, since `SQLConf.mergeSparkConf`
copies every `SparkConf` entry into the session config and the
read path (`handleGet` / `handleGetOption` / `handleGetWithDefault`
/ `handleGetAll`) applies no denylist. In Spark's own
client-to-server model that leaks nothing: the client had to
present the token to connect at all. Behind a gateway it would,
because there the token is a gateway-only secret and users
authenticate with something else.
**The gateway therefore withholds that key from every `Config`
response**, unconditionally — a proxy cannot assume every backend
it forwards to has been patched. A client asking for it sees the
key as unset (and `GetAll` omits it), and the attempt is recorded
in the audit stream as `config.redacted`, which is worth alerting
on: a legitimate client has no reason to read the gateway↔backend
credential.
Two consequences worth planning around:
* Fixing this in Spark itself (so the backend never discloses the
key) is the complementary half, and is being pursued upstream.
Until then the gateway is the only thing standing between a
client and the token, which is one more reason to keep the
NetworkPolicy layer on.
* If you deploy a **different** proxy, or let clients reach
backends directly for any reason, this reasoning does not carry
over — the token is readable by anyone who can complete a
`Config` call.
Credit: this was reported privately by an external reviewer, who
also verified it against 4.0.3, 4.1.2 and 4.2.0.
## Day 2: upgrades
`helm upgrade` re-renders templates and rolls the Deployment. Two
things that catch operators:
### ConfigMap changes don't always trigger pod rolls — but the chart
makes them.
The Deployment template includes a `checksum/config` annotation
that hashes the rendered ConfigMap. Any value change → annotation
change → pod template change → rolling restart. You should *not*
need to manually `kubectl rollout restart`.
### Redis affinity state survives rolling upgrades.
Because affinity lives in Redis (or the in-memory store, which
dies with the pod anyway), a `helm upgrade` rolling restart does
*not* reshuffle existing sessions — they stay pinned to their
original backends. The
[`session_ttl_secs`](https://github.com/.../scg-store-redis/src/lib.rs)
is refreshed on every read, so active sessions don't expire during
the rollout window.
Sessions that haven't been touched for `session_ttl_secs` *will*
expire and be re-picked on next access; tune this if your client
idle pattern is unusual.
### Rollback
`helm rollback scg <revision>` works as expected. The Redis
StatefulSet's PVC is **not** deleted on uninstall (intentional —
see below), so a rollback that brings back an older Helm release
finds its data.
If a config change broke things, prefer `helm rollback` over
`kubectl edit` of the Deployment — the latter doesn't roll the
ConfigMap back in lockstep.
## Uninstall
```bash
helm uninstall scg -n spark-connect
```
This removes the Deployment, Service, ConfigMap, ServiceAccount,
RBAC, and the Redis StatefulSet itself — but **leaves the Redis
PVC behind**. This is intentional: a re-install picks up where the
old one left off, and unwanted PVCs are easy to spot in
`kubectl get pvc`.
To wipe Redis state explicitly:
```bash
kubectl -n spark-connect delete pvc -l app.kubernetes.io/component=redis
```
If you used external Redis (`redis.enabled: false`), the data
isn't ours to clean up — flush the prefix yourself:
```bash
redis-cli -u "$REDIS_URL" --scan --pattern "scg:*" | xargs redis-cli -u "$REDIS_URL" del
```
## Validation: end-to-end test against your cluster
The repo ships [`crates/proxy/examples/ha_smoke.rs`](../crates/proxy/examples/ha_smoke.rs)
which validates the three multi-replica HA invariants (shared
state, failover after replica death, op-id reverse index across
replicas) against any reachable Redis. To run it against your
deployed cluster:
```bash
kubectl -n spark-connect port-forward svc/scg-redis 6399:6379 &
REDIS_URL=redis://127.0.0.1:6399 cargo run -p scg-proxy --example ha_smoke
```
This spins up *its own* in-process gateway pair against your
deployed Redis, which proves the Redis is well-configured and the
chart's value mapping is correct. It does not exercise the
deployed *gateway pods* themselves; for that, see
[`observability.md`](observability.md) for what to watch in
metrics during a soak test.