Kubernetes jobs
On this page
Run a reserved queue job as a one-shot batch/v1 Job pod on Kubernetes
instead of in the worker's own process.
Behind the kubernetes feature on rainier-framework.
rainier-framework = { ..., features = ["kubernetes"] }
The queue is still the authority
Dispatch is unchanged. Queue::instance().dispatch(job) writes to
the ordinary queue backend regardless of whether this job will eventually
run on Kubernetes. That means every job that opts in still has:
- a
jobsrow (or Redis/SQS message) the moment it is dispatched; - the queue's own retries via [
Job::TRIES] and [WorkerOptions::tries]; - the queue's per-queue timeouts and per-queue concurrency ceilings;
failed_jobson terminal failure, with the same shape every other job's failure takes;QueueManager::fake()recording the dispatch for tests, with no Kubernetes machinery involved.
What changes is who executes handle(). The queue worker reserves
the job as usual, then — if the job's kubernetes() returns Some AND
the worker has a KubernetesDispatcher configured — launches a one-shot
batch/v1 Job pod, watches it to a terminal condition, and translates
the result back into the worker's own success/failure outcome. The
worker's concurrency slot is held for the pod's lifetime, so per-queue
caps still apply.
A Complete Job returns Ok(()), the queue row is acknowledged. A
Failed Job returns Err carrying the pod's log tail, which the
worker's existing retry logic treats like any other failure — released
for retry, then moved to failed_jobs on the final attempt.
When to use this
The ordinary queue path is the right default. A fixed pool of workers drains thousands of small deferred actions cheaply, and spinning a pod per message would add 5–15 s cold-start to every one and overwhelm the Kubernetes API server. Don't opt in by default.
Opt in when a job's resource profile would be a problem on a shared worker:
- It needs materially more memory than ordinary jobs. The example this mechanism was built for: an uploader that reads a multi-gigabyte file into memory to post it to an external API. On a general worker pod sized for ordinary traffic, one of those pushes past the memory limit and OOM-kills the pod, taking out every unrelated job it was draining. A one-shot pod sized to the file is bounded, exits cleanly, and never shares RAM with anything else.
- It handles sensitive content that must stay isolated. CSAM review,
payment-card tokens, PII cross-border transfer. Pair the one-shot pod
with a narrowly-scoped
ServiceAccount(IRSA), a tainted node pool (nodeSelector), andemptyDir { medium: Memory }for tempfiles — none of which the shared pool offers. - Its CPU shape is spiky and would starve neighbors. A one-off model run, a video thumbnail that briefly saturates the box, a compression pass that would make every other job in the pool miss its SLO.
- It can tolerate 5–15 s of pod cold-start. This is the hard trade: what you buy in isolation and sizing you pay in startup time. A job that should begin within milliseconds of being queued belongs on the queue.
Rule of thumb: a few jobs per application, not all of them.
How a job opts in
Implement Job::kubernetes:
use rainier_framework::prelude::*;
use rainier_framework::queue::KubernetesJobSpec;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadConfirmedCsamToNcmec {
pub report_case_id: u64,
pub post_id: u64,
}
#[async_trait]
impl Job for UploadConfirmedCsamToNcmec {
const NAME: &'static str = "ncmec.upload-confirmed-csam";
const QUEUE: &'static str = "notifications";
const TRIES: u32 = 5;
fn kubernetes(&self) -> Option<KubernetesJobSpec> {
Some(
KubernetesJobSpec::new("4Gi", "6Gi", "250m", "1")
.with_service_account("ncmec-uploader-sa")
.with_node_selector("role", "sensitive-content")
.with_tmpfs_scratch()
.with_ttl_seconds_after_finished(0),
)
}
async fn handle(&self, context: &JobContext) -> Result<()> {
/* reads the asset into memory and uploads it */
Ok(())
}
}
The default Job::kubernetes returns None. Every existing impl Job
compiles unchanged; opting in is one method.
What the spec covers
KubernetesJobSpec::new(memory_request, memory_limit, cpu_request, cpu_limit)
takes the four quantities in Kubernetes format ("4Gi", "250m", etc.)
and leaves everything else at a safe default. The builder methods layer
specifics on top:
| Method | What it sets on the pod |
|---|---|
.with_image(..) | Container image. Default: the worker's own image (via RAINIER_K8S_DEFAULT_IMAGE). |
.with_service_account(..) | spec.template.spec.serviceAccountName. Always set this for sensitive-content jobs. |
.with_namespace(..) | Namespace to create the Job in. Default: the worker's own namespace. |
.with_node_selector(k, v) | Chainable; pins the pod to tainted nodes. |
.with_ttl_seconds_after_finished(n) | How long the finished Job + its logs linger before GC. Default 60; CSAM pods should set 0. |
.with_tmpfs_scratch() | Mounts /scratch as emptyDir { medium: Memory }. For pods whose tempfiles must never touch disk. |
.with_backoff_limit(n) | Kubernetes-side retries. Default 0. Retries live in the queue worker — raising this gives Kubernetes a second attempt at the pod before Job::TRIES is consulted, which is almost never what you want. |
Resource sizing
The pod will be scheduled to a node with enough requests to fit, and
killed if it exceeds limits. Both should reflect the real max of the
job across a representative payload distribution; a tight limit means
one bad input OOM-kills the pod, a loose one wastes cluster capacity.
For a job that holds the whole file in memory, size memory to the 99th percentile file + a few hundred megabytes of request/response + runtime overhead. For a job that streams, memory stays small regardless of input size and can be a flat request.
CPU is a request, not a hard limit for most workloads — the pod gets at
least requests.cpu and can burst to limits.cpu. Set requests to the
steady-state CPU need (typical: 100m–500m for I/O-bound jobs, higher for
compute-bound).
How the worker drives it
- Worker reserves a job from the queue (ordinary path —
queue.reserve, nothing k8s-aware). - Worker asks the registry: "does this job's current payload return
Some(spec)fromkubernetes()?" The registry stores a per-type extractor set up whenregister::<J>()runs, so this is one hashmap lookup + oneserde_json::from_valueper reserved job. - If
Noneor the dispatcher is absent → the handler runs in-process, same as before. - If
Some(spec)and aKubernetesDispatcheris wired to the worker → the worker callsKubernetesDispatcher::run_and_watch(..), which:- computes a deterministic Job name from
(J::NAME, queue_row_id, attempt)— DNS-1123 safe, under 63 chars; - POSTs a
batch/v1 Jobto the API with that name, resources, service account, node selector, tmpfs, and env (job name, payload, queue row id, attempt); - watches the Job via
kube::runtime::wait::await_condition— a long-lived watch connection that fires on the Job's own state change rather than a poll tick, so a sub-second job doesn't sit out any poll interval before being noticed. Subject to the worker's effective timeout for this job (the usual four-level resolution: operator override →Job::TIMEOUT→ queue timeout → worker default); - on
Complete→ returnsOk(()); - on
Failed→ fetches the pod's log tail and returnsErrcarrying it; - on timeout → deletes the Job and returns an
Err; - on 409 Conflict at create (reattach — see next section) → skips straight to the watch.
- computes a deterministic Job name from
- The worker then runs its normal success/failure/retry/
failed_jobslogic against thatResult<()>. From the queue's point of view, a failed Kubernetes Job is indistinguishable from a failing in-process handler.
Reattach on worker restart
The Kubernetes Job's metadata.name is derived deterministically from
(J::NAME, queue_row_id, attempt). If a worker crashes while watching
a Job and another worker reserves the same queue row (after the
backend's retry_after), the second worker computes the same name,
tries to create the Job, gets a 409 AlreadyExists, and jumps straight
to the watch loop. No duplicate pod; the uniqueness is enforced by the
API server's own primary key.
This matters for anything with external side effects — an NCMEC upload you do not want to make twice.
Labels on every Job
For observability + ad-hoc queries, the dispatcher stamps:
| Label | Value |
|---|---|
app.kubernetes.io/managed-by | rainier-queue |
rainier.job/name | slug of J::NAME (ncmec-upload-confirmed-csam) |
rainier.job/queued-id | the queue row's id (slugified) |
rainier.job/instance | the derived metadata.name |
kubectl get jobs -l app.kubernetes.io/managed-by=rainier-queue enumerates
every outstanding one.
The pod wire protocol
When the worker launches a Job, the pod container runs the application's own image with:
args: ["--rainier-run-single-job"](rainier_queue::SINGLE_JOB_SUBCOMMAND)env.RAINIER_JOB_NAME = J::NAMEenv.RAINIER_JOB_PAYLOAD = <serialized JSON>env.RAINIER_QUEUED_JOB_ID = <queue row id>env.RAINIER_JOB_ATTEMPT = <attempt number>- resources, nodeSelector, serviceAccount, tmpfs, labels from the spec
The payload rides in an env var, which has a per-pod 1 MiB cap on Kubernetes. The dispatcher rejects payloads over ~900 KiB. The intended use is passing identifiers (match ids, upload ids — tens of bytes); for larger payloads a ConfigMap-based variant would be needed (not implemented).
Application plumbing — two pieces
1. Build and attach the dispatcher on the worker
The dispatcher is a property of the pod that runs queue:work. The
HTTP tier (octane/axum/whatever) does not need it — HTTP never launches
Kubernetes Jobs under this design; it dispatches to the queue as usual
and the worker is the one that executes.
use std::sync::Arc;
use rainier_framework::queue::{KubernetesDispatcher, Worker};
async fn build_worker(/* ... */) -> Result<Worker> {
let mut worker = Worker::new(queue, registry, container)
.with_options(worker_options);
#[cfg(feature = "kubernetes")]
match KubernetesDispatcher::try_in_cluster().await {
Ok(Some(dispatcher)) => {
tracing::info!(
namespace = dispatcher.default_namespace(),
"KubernetesDispatcher bound to worker"
);
// Optional but recommended: boot-time RBAC check.
match dispatcher.verify_rbac().await {
Ok(report) if report.is_dispatch_ready() => {
tracing::info!(summary = %report.summary(), "k8s RBAC check ok");
}
Ok(report) => tracing::warn!(
summary = %report.summary(),
"k8s dispatcher bound but current SA cannot create jobs — \
opt-in jobs will fail on reserve and release for retry; the ordinary \
queue path is unaffected"
),
Err(e) => tracing::warn!(
error = %e.message(),
"k8s RBAC check failed to reach the API"
),
}
worker = worker.with_kubernetes(Arc::new(dispatcher));
}
Ok(None) => tracing::debug!("not in a cluster; k8s-opted jobs run in-process"),
Err(e) => tracing::warn!(error = %e.message(), "k8s dispatcher unavailable"),
}
Ok(worker)
}
try_in_cluster returns Ok(None) outside Kubernetes
(KUBERNETES_SERVICE_HOST unset), so local dev, CI, and non-k8s
deployments work unchanged — the dispatcher is just not attached and
every job runs in the worker process.
What verify_rbac checks
The check runs SelfSubjectAccessReview against the worker's SA for
four verbs:
| Verb | On | Required for |
|---|---|---|
create | batch/jobs | launching any Kubernetes Job |
get | batch/jobs | polling the Job's status |
watch | batch/jobs | a future informer-based watch |
get | pods/log | capturing the pod's log tail on failure |
is_dispatch_ready() requires create + get. The log read is
optional — missing it means failed Jobs come back without the pod's
log tail, which is a diagnosis-quality bit, not a correctness one.
2. The CLI pre-check in main
The pod runs your binary with the sentinel arg. main has to catch it
before the application's own CLI parser runs:
#[tokio::main]
async fn main() -> Result<()> {
let application = boot(Mode::Running).await?;
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.first().map(String::as_str)
== Some(rainier_framework::queue::SINGLE_JOB_SUBCOMMAND)
{
let code = run_kubernetes_single_job(&application).await;
application.terminate();
std::process::exit(code);
}
// ... normal CLI dispatch ...
}
async fn run_kubernetes_single_job(app: &Application) -> i32 {
use rainier_framework::queue::{
run_single_job, JobContext, JobRegistry,
JOB_NAME_ENV, PAYLOAD_ENV, QUEUED_ID_ENV, ATTEMPT_ENV,
};
let name = std::env::var(JOB_NAME_ENV).expect("RAINIER_JOB_NAME set by dispatcher");
let payload: serde_json::Value = serde_json::from_str(
&std::env::var(PAYLOAD_ENV).expect("RAINIER_JOB_PAYLOAD set by dispatcher"),
).expect("valid JSON");
let queued_id = std::env::var(QUEUED_ID_ENV)
.unwrap_or_else(|_| "k8s-pod".into());
let attempt: u32 = std::env::var(ATTEMPT_ENV)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let registry = app.container().resolve::<JobRegistry>().unwrap();
let context = Arc::new(JobContext::new(
app.container().clone(),
queued_id,
"kubernetes".into(),
attempt,
attempt, // this pod is one attempt; worker owns the retry count
));
match run_single_job(®istry, &name, payload, context).await {
Ok(()) => 0,
Err(e) => {
eprintln!("job `{name}` failed: {}", e.message());
1
}
}
}
The pod runs the single job to completion and exits 0 (or non-zero on
error). The worker watching on the orchestrator side translates that
exit code into Ok(()) / Err and the queue handles the rest.
3. Dispatch sites don't change
Queue::instance().dispatch(job).await?; // or
Queue::instance().dispatch_on("notifications", job).await?;
Queue::instance().dispatch_after(TEN_MIN, job).await?;
That's the whole dispatch surface. All three go to the queue backend; whether the job eventually runs in-process or on a Kubernetes pod is decided on reserve by the worker. A reviewer sees the dispatch once at the trait impl; the call sites are the same they have always been.
Infrastructure prerequisites
The dispatcher doesn't configure your cluster — only you can decide what
ServiceAccount, Role, RoleBinding, and node pool are right for the
work. For a job to actually run on Kubernetes you need:
- RBAC on the WORKER pod's ServiceAccount. The pod that runs
queue:workneeds aRolewithcreate get list watchonjobs.batchplusgetonpods/login the target namespace. The HTTP pod does not — it only dispatches to the queue. - A
ServiceAccountper sensitive job. CSAM upload, payment handling, etc. — a narrow IRSA role scoped to just the S3 buckets / external endpoints the job touches. The spec names it; the SA has to exist. - Nodes with the taint the spec selects.
nodeSelector: role=sensitive-contentpins the pod to nodes with the matching label (plus a matching toleration if those nodes are tainted — add via atolerationsfield on the spec if your deployment needs one; the current prototype only carriesnodeSelector). - Image availability. The dispatcher runs the worker's current
image by default (via
RAINIER_K8S_DEFAULT_IMAGE, which the deployment should set — typically from a static env tracking the Deployment's.spec.template.spec.containers[].image). The target namespace needs pull access to that image. - DownwardAPI on the worker pod.
KUBERNETES_NAMESPACEfromfieldRef: metadata.namespaceso the dispatcher knows what namespace to create Jobs in. Without it the dispatcher logs a warning and falls back to"default"— which is almost certainly wrong.
Known limitations
- Env-var payload cap, ~900 KiB. Larger payloads need a ConfigMap-based variant (not implemented). The intended use case is identifiers; shrink the payload rather than growing it.
- Pod cold-start latency. 5–15 s between reserve and the pod starting to run, depending on image pull cache and node scheduling. Jobs with sub-second time budgets belong in-process.
- Kubernetes API rate limits. The worker creates one Job per
reserved opt-in row. A sudden flood (10k matches arriving at once)
would be gated first by per-queue concurrency (
WorkerOptions::queue_limit) and then by the API's rate limits. For high-volume opt-in jobs, pair with a KEDA-style ScaledJob operator rather than this worker-driven path. - The worker's slot is held for the pod's lifetime. A long-running pod ties up one of the worker's concurrency slots. That is the mechanism's whole point — it makes per-queue limits apply equally to k8s jobs — but it means a pod that takes an hour costs an hour of a slot. Size the worker's concurrency accordingly.
When not to opt in
Flip of the "when to use this" list:
- Jobs that complete in under a second — the pod cold-start dominates.
- Jobs that are dispatched at rates over ~1/s per worker replica — API rate-limits and slot pressure bite.
- Jobs where every retry would hurt (unique external side effects,
idempotency not well-established) — reattach-on-worker-restart
guards against duplicate launches within one attempt, but
Job::TRIESmeans the worker will launch a fresh Job for each retry. - Jobs where the simpler answer is a bigger shared worker — if your queue has a single hot job that's eating memory, upsize the pool before reaching for one-shot pods.
The ordinary queue is the right answer most of the time. Kubernetes dispatch is for the specific shape where its three affordances — per-job sizing, narrow IAM, strong isolation — are worth the three costs: cold start, API load, slot occupancy. A few jobs per application, not all of them.
This page is the framework’s docs/kubernetes-jobs.md at 7b8cb15.
Found a mistake? Edit it on GitHub.