Skip to content

Airflow

The source version supports Airflow 3.4 on Python 3.11–3.14 and Airflow 2.11.2 on Python 3.11–3.12. Follow the installation instructions for the airflow extra; published releases can differ from the current source. Install it in the Airflow components that load and execute your DAGs, including the triggerer when using deferrable tasks. The package registers its provider automatically. If your DAGs run with a 0.12.x release, review the Airflow upgrade checklist first: some tasks that succeeded before now fail.

Connect to Kubernetes

Configure an Airflow connection of type Kubernetes, with ID kubernetes_default. To use another connection, set kubernetes_conn_id on the operator. Choose one authentication method. For Airflow running inside Kubernetes, this environment variable uses the Airflow pod's service account:

export AIRFLOW_CONN_KUBERNETES_DEFAULT='{"conn_type":"kubernetes","extra":{"in_cluster":true}}'

For an external cluster, use the connection's kube_config_path and, optionally, cluster_context fields instead. The kubeconfig must be available wherever the task or trigger executes. See the provider's Kubernetes connection reference.

The connection authenticates Airflow to Kubernetes. The operator's namespace and service_account choose where Spark runs and which service account its driver uses; they do not inherit the connection's namespace. Follow Getting Started to prepare these resources and permissions. The examples use namespace spark and service account spark.

Separate execution and task-tab permissions

Component/action Credential and authorization boundary
Worker submits, waits or performs configured cleanup The selected Airflow Kubernetes connection; grant the required pod/service and optional Secret/ConfigMap operations only in approved namespaces. A failure postmortem also lists pods and events and reads pod logs
Triggerer observes a deferred application The same connection ID resolved in the triggerer's own environment; it needs observation access, not permission to submit another job
Spark driver creates executors The submission's service_account in its application namespace, independently of the worker's identity
Native tab reads details/logs Airflow task/task-log access, verified task ownership, and the Spark API's own permissions
Native tab kills/deletes The corresponding Airflow write permission, verified ownership, SPARK_ON_K8S_AIRFLOW_ALLOW_WRITES=true, and the Spark API's permissions

Setting the tab's allow-writes flag to false only disables browser actions. It does not prevent a DAG from submitting through its Kubernetes connection or from performing configured cleanup. Install the same integration version in workers and triggerers, and restrict their resolved credentials to the intended scope. A connection that works on a worker may still be unavailable to the triggerer.

Load the example DAGs

The Airflow examples include spark_dag.py for a packaged PySpark job and spark_sql_dag.py for inline SQL and SQL files. Build the pyspark-job image using the Python example, then make it available on the cluster. image_pull_policy="Never" assumes the image is already loaded on the nodes. For a registry image, change image and use IfNotPresent or Always.

Deploy the DAG Python files to your Airflow DAG directory or DAG bundle. Keep query.sql and iceberg_query.sql beside spark_sql_dag.py, or set the DAG's template_searchpath to a directory containing them. That directory must also be available to task workers. The SQL templates use ds and ts; supply a logical date when triggering a manual run, or adapt those templates to your input values. Both example DAGs have schedule=None, so they run when triggered.

The SQL DAG creates four independent tasks with distinct application names. The inline example uses CREATE TABLE ... USING PARQUET AS SELECT; the multiple-query example renders its date as a SQL date literal. SparkSqlOnK8SOperator renders SQL before splitting it into statements and sends a JSON statement list to the Spark runner. Install the extra in Airflow; the Spark image does not need sqlparse.

Prepare the Iceberg example

Before running spark_iceberg_sql_application, replace spark.sql.catalog.prod.uri with a REST catalog endpoint reachable from Spark's pods. The sample localhost address refers to the driver pod itself. Configure any catalog credentials and warehouse access your deployment requires.

The example runtime package targets Spark 3.5 with Scala 2.12; use an image and Iceberg runtime that match each other. Create the prod.db namespace and the source table prod.db.another_sample with id, data, and category columns. The query creates the target prod.db.sample if needed, then merges from that source table. It does not provision a catalog, warehouse, or source dataset.

To enable the Spark Job UI operator link, set spark_on_k8s_service_url to the base URL of a deployed spark-on-k8s API that the browser can reach. Set ui_reverse_proxy=True for access to the running driver's UI. Enabling the reverse proxy alone, as in the examples, does not set an Airflow link URL.

The operator stores the live UI URL in XCom after submission or adoption. For tasks that wait for completion, it then replaces that URL with the history URL. The history destination requires Spark event logging, a Spark History Server, and the API's SPARK_ON_K8S_API_SPARK_HISTORY_HOST configuration. The link does not create that infrastructure. With app_waiter="no_wait", the operator returns after submission and leaves the live link in place.

With deferrable=True, waiting runs in the Airflow triggerer. If app_waiter="log" is also set, logs are read when the task resumes after completion.

Lifecycle, retries and cleanup

The table applies to app_waiter="wait" or "log" unless stated otherwise. startup_timeout bounds the driver's Pending phase; 0 disables that deadline. It is separate from Airflow's task execution timeout.

max_runtime (seconds, unset by default) sets the driver pod's Kubernetes activeDeadlineSeconds: Kubernetes stops the driver that long after its pod starts and marks it Failed with reason DeadlineExceeded, even when the worker, triggerer or on_kill never runs. A waiting task then follows the "Spark fails" row. Airflow's execution_timeout still needs a live worker to call on_kill. The deadline covers the driver's whole life, including time a retry spends adopting it; a retry that finds the driver stopped submits a new application with a new deadline. See driver lifetime limits.

Outcome or event Task outcome and application action
Spark succeeds Task succeeds; keep retains the application, delete and keep_if_failed delete it
Spark fails Task fails; keep and keep_if_failed retain it, delete deletes it
Driver startup deadline expires Task fails and deletes the application, including with on_finish_action="keep"; applies to synchronous and deferred waiting
Deferred trigger reports an observation error Task fails with the trigger error; keep retains the application, while delete and keep_if_failed delete it because an observation error is not a confirmed Spark Failed state
Synchronous observation raises a non-timeout API/transport error Error propagates before finish cleanup; no new submission or automatic cleanup is performed by that wait path
app_waiter="no_wait" Returns after submission/adoption; no finish cleanup or completion assertion
The driver disappears before a final status is observed Task fails because the outcome is unknown; no log replay, postmortem or cleanup
A retried or cleared attempt finds an earlier driver of the same task instance One Pending or Running driver is adopted instead of submitting; a Succeeded one only with reattach_succeeded=True; ambiguous or uncertain findings fail the attempt. See retries and reattachment
Finish cleanup finds the driver already deleted Treated as cleaned up; the task keeps its Spark outcome
Finish cleanup fails for another reason Task fails with the cleanup error as the cause of the Spark outcome: the failure or timeout, or Succeeded, so a retry is not mistaken for a Spark failure
Airflow invokes on_kill with a known driver on_kill_action="keep" retains, "delete" requests deletion, and "kill" requests process termination; default is delete

Do not assume every scheduler state change, worker loss or manual task-state edit invokes on_kill. A termination request also does not prove that application output was rolled back. The SDK/CLI retain applications after observation timeout or interruption; the Airflow startup-timeout deletion policy is intentionally different. See the automation contract. Before this cleanup, the task logs a bounded failure postmortem for an unsuccessful application.

For a daily DAG, choose the startup deadline and retention behavior explicitly. Replace the example image with your approved application artifact:

from datetime import UTC, datetime, timedelta
from airflow import DAG
from spark_on_k8s.airflow.operators import SparkOnK8SOperator

with DAG(
    "daily_transform", schedule="@daily", catchup=False,
    start_date=datetime(2026, 1, 1, tzinfo=UTC),
) as dag:
    SparkOnK8SOperator(
        task_id="transform",
        image="registry.example.com/team/daily-job:approved",
        app_path="local:///opt/spark/work-dir/job.py",
        namespace="analytics", service_account="spark-driver",
        app_arguments=["--run-date", "{{ ds }}"],
        driver_labels={"logical-date": "{{ ds_nodash }}"},
        executor_labels={"logical-date": "{{ ds_nodash }}"},
        deferrable=True, startup_timeout=180, poll_interval=10,
        on_finish_action="keep_if_failed", on_kill_action="delete",
        retries=2, retry_delay=timedelta(minutes=2),
    )

packages and driver/executor labels and annotations support Airflow templates, alongside the existing image, arguments and resource fields. Use valid Kubernetes label values after rendering. The reserved spark-on-k8s.io/airflow-task annotation and the task labels described below are generated from task identity and cannot be overridden by user annotations or labels. Previously literal Jinja expressions in these newly templated fields will now be rendered; review existing DAGs when upgrading.

Retries and reattachment

Each driver the operator submits carries the reserved spark-on-k8s.io/airflow-task ownership annotation and these labels:

Label Value
spark-on-k8s.io/airflow-task-key 32-character digest of the DAG ID, run ID, task ID, map index and, on Airflow 3, region; the same for every attempt of one task instance
spark-on-k8s.io/airflow-dag-id, spark-on-k8s.io/airflow-task-id Readable IDs for people and dashboards. Characters outside the label alphabet become _ and values are cut to 63 characters, so they can collide; never select one task instance by them

When an attempt starts, before submitting, the operator looks for an earlier driver of the same task instance in its namespace:

  1. If task XCom still records a driver, that driver is checked first. A Pending or Running driver is adopted. Status lookup failures and Unknown status fail the attempt. A confirmed absent (404) or terminal driver continues to step 2.
  2. With reattach_by_label=True, the default, it lists the drivers carrying the attempt's task key and acts as in the table below. Otherwise it submits a new application.

Airflow 2.11 and 3.4 delete a task instance's XCom when a new attempt starts; they keep it only when a deferred task resumes. On an ordinary retry, or after clearing the task, step 2 therefore finds the earlier driver; step 1 applies only where XCom survives into the next attempt. The labels are written at pod creation, so step 2 also covers a worker lost between creating the driver and recording it in XCom.

Drivers found by task key Attempt's action
None, or only Failed drivers Submits a new application; Failed drivers are logged and kept or deleted as on_finish_action already decided
One Pending or Running driver Adopts it and waits as for a new submission, synchronously or deferred, with a new startup deadline; terminal drivers beside it are ignored
More than one Pending or Running driver Fails without submitting or choosing; delete the extra drivers before retrying
Succeeded drivers and no active one, reattach_succeeded=False (default) Logs a warning naming them, then submits a new application
One Succeeded driver and no active one, reattach_succeeded=True Adopts it as the attempt's result without rerunning Spark, then applies on_finish_action
More than one Succeeded driver and no active one, reattach_succeeded=True Fails without submitting or choosing
Any driver with Unknown or missing phase Fails without submitting; an indeterminate driver may still be running
A driver whose deletion was already requested Ignored, for example after a startup-timeout deletion
The listing fails (permission, API, network or timeout error) Fails without submitting; restore access before the next attempt

reattach_succeeded=True also adopts a Succeeded driver found in retained XCom. It suits a job whose earlier success for the same DAG run is an acceptable result, such as a partition overwrite that should not run twice. The Succeeded driver must still exist: on_finish_action="keep" retains it, while "delete" and "keep_if_failed" delete it after success. With the option enabled, clearing a successful task does not rerun Spark while that driver exists; delete the driver first to force a rerun.

from spark_on_k8s.airflow.operators import SparkOnK8SOperator

SparkOnK8SOperator(
    task_id="publish_partition",
    image="registry.example.com/team/publish-job:approved",
    app_path="local:///opt/spark/work-dir/publish.py",
    namespace="analytics", service_account="spark-driver",
    app_arguments=["--partition", "{{ ds }}"],
    on_finish_action="keep",  # retain the Succeeded driver that a retry may adopt
    reattach_succeeded=True,
    retries=2,
)

To inspect a DAG's drivers and their task keys, list them with the readable labels:

kubectl get pods -n analytics -L spark-on-k8s.io/airflow-task-key \
  -l spark-role=driver,spark-on-k8s.io/airflow-dag-id=daily_transform

The worker's Kubernetes connection needs permission to list pods in the namespace; the edit role used in Getting Started includes it. Without that permission, every attempt fails before submitting. Set reattach_by_label=False to keep only the XCom check; drivers are still labelled.

Upgrading changes retries of existing DAGs. An attempt whose earlier driver of the same task instance is still Pending or Running now adopts that driver instead of starting a concurrent second application. This includes drivers retained by on_kill_action="keep" or left running when on_kill was not invoked. The adopted driver keeps the image, arguments and configuration it was submitted with; to run changed DAG code instead, delete or kill the earlier driver before clearing or retrying the task. Drivers submitted by earlier versions have no task-key label and are not found.

This is best-effort reattachment, not submission deduplication or an exactly-once data-write promise. Drivers that were deleted, are in another namespace, or lost their labels are not found, and two concurrent attempts are not serialized. Applications must define their own handling of a repeated logical date.

Verify the integration on Kubernetes

The source workflow .github/workflows/airflow-kubernetes.yml builds the wheel and runs Airflow 2.11.2 and 3.4.0 in service-account-bearing pods against the representative Spark 4.0.5 image. It checks successful work, deliberate Spark failure after real executor work, and an unschedulable startup timeout, each in synchronous and deferred mode. Deferred cases run and resume the real trigger. Each case also checks that the driver carries its task-key label.

This uses Airflow's DAG.test() engine, metadata database and XCom. It does not qualify a separately deployed scheduler, executor or triggerer process, worker-crash recovery, or a retry reattaching by label. The broader Spark runtime matrix remains in the Spark compatibility workflow. Follow the Airflow workflow's image-building and kind setup, then run its exact host command from the repository root:

.venv/bin/python -I tests/integration/airflow/run_kubernetes.py \
  --kubeconfig "$RUNNER_TEMP/airflow-kubeconfig" \
  --airflow-image "airflow-compat:$AIRFLOW_VERSION" \
  --airflow-version "$AIRFLOW_VERSION" \
  --spark-image "airflow-spark:$SPARK_VERSION" --spark-version "$SPARK_VERSION" \
  --artifacts "$RUNNER_TEMP/airflow-kubernetes-results"

The default context is kind-airflow-compat; the runner refuses non-kind contexts. Inspect all six cases in result.json, the Airflow log, cluster pods/events/logs and kind logs. A passing unit suite or an unexecuted workflow is not real-cluster evidence. See the development guide for installed-wheel checks and the complete validation policy.

Failure postmortem

When a waiting task's Spark application does not succeed, SparkOnK8SOperator and SparkSqlOnK8SOperator write a bounded postmortem to the Airflow task log before any cleanup can delete the driver, its logs and its Kubernetes evidence. This applies to synchronous and deferred waiting on Airflow 2.11 and 3. A deferred task writes it in the worker when the task resumes, not in the triggerer.

Event Postmortem
Spark Failed or Unknown Before finish cleanup, also when on_finish_action retains the application
Driver startup deadline expires Before the startup-timeout deletion
Deferred trigger reports an observation error Before finish cleanup
on_kill with on_kill_action="delete" Before the deletion; task log only, no XCom
Spark succeeds, app_waiter="no_wait", a synchronous non-timeout observation error, or on_kill with "keep" or "kill" None

The postmortem has two parts:

  • A diagnosis of the driver and its owned executors, from the same collector as spark-on-k8s app diagnose: each finding's severity, code, pod, container, summary, redacted evidence and next step, at most 20 findings. No findings means that no specific cause was observed, not that none exists. Set failure_diagnostics=False to skip it.
  • The driver container's last failure_log_tail_lines lines: 100 by default, at most 1000, and 0 skips them. They are skipped when the attempt already streamed the driver log with app_waiter="log" (a trigger observation error replays no log, so it still gets the tail) and when the driver container never started, for example while unschedulable. Kubernetes sends at most 1 MiB, lines longer than 2,000 characters are cut, and at most 64 KiB of the most recent lines are logged; the tail's header says when lines were left out.

All reads share one five-second budget, of which the diagnosis may use three, so the postmortem delays cleanup by about five seconds at most. It is best effort: if the driver cannot be read or the budget runs out, the task log names the failed part with an exception type and HTTP status, never a Kubernetes response body. The task then keeps its original outcome and error, and cleanup proceeds as configured.

The worker's Kubernetes connection reads the driver, lists pods and events, and reads the driver log in the application namespace. Without event or log access, the diagnosis is marked incomplete or the tail is reported unavailable. Like the streamed driver log, the tail contains what the application printed. URL credentials and password, token, secret, credential or access-key values written as key=value or key: value are replaced with <redacted>, and Airflow's secret masker applies as to any task log line. Use failure_log_tail_lines=0 when driver logs must not reach the task log. failure_diagnostics=False together with failure_log_tail_lines=0 disables the postmortem and its Kubernetes reads.

When a diagnosis is collected outside on_kill, the task also pushes a compact summary to the spark_failure_diagnosis XCom key (SparkOnK8SOperator.XCOM_FAILURE_DIAGNOSIS). Evidence and next steps stay in the task log:

{
  "version": 1,
  "status": "Failed",
  "namespace": "analytics",
  "app_id": "daily-transform-20260101",
  "driver_pod": "daily-transform-20260101-driver",
  "incomplete": false,
  "findings": [
    {
      "code": "oom_killed",
      "severity": "error",
      "resource": "daily-transform-20260101-driver",
      "container": "driver"
    }
  ],
  "omitted_findings": 0
}

status is the Spark status, startup_timeout or error (trigger observation error). A failure callback can read it with ti.xcom_pull(task_ids=ti.task_id, map_indexes=ti.map_index, key="spark_failure_diagnosis"). Airflow removes it when the next attempt starts.

Native task tab in Airflow 3

The optional Spark on Kubernetes tab is available for SparkOnK8SOperator and SparkSqlOnK8SOperator task instances, and for listed subclasses, on Airflow 3.4 or newer, below Airflow 4. It reuses the standalone console's React components for application details, logs, actions and history links. Airflow 2.11.2 continues to support the operators and Spark Job UI link, but does not load this tab.

This feature describes the current source, not an earlier PyPI release. Install from the checkout as described in Installation:

uv sync --python 3.12 --locked --no-default-groups --extra airflow-ui
# Or, in an activated virtual environment:
python -m pip install ".[airflow-ui]"

The airflow-ui extra includes the Airflow operator dependencies and the plugin's HTTP and WebSocket dependencies; it does not require the standalone api extra in the Airflow process. Install the same package version in the Airflow API server and task workers. Deploy the spark-on-k8s API from that version separately with its api extra, so its console endpoints can return the driver's task ownership metadata. Built JavaScript and CSS are included in the Python package.

Set these variables before starting the Airflow API server, then restart it:

export SPARK_ON_K8S_AIRFLOW_SERVICE_URL='http://spark-on-k8s.spark.svc.cluster.local:8000'
export SPARK_ON_K8S_AIRFLOW_PUBLIC_URL='https://spark.example.com'
export SPARK_ON_K8S_AIRFLOW_ALLOW_WRITES='false'
Variable Meaning Default
SPARK_ON_K8S_AIRFLOW_SERVICE_URL Administrator-configured Spark API base URL reachable from the Airflow API server. Required to register the tab. unset; tab disabled
SPARK_ON_K8S_AIRFLOW_PUBLIC_URL Browser-reachable base URL for external Spark UI and history links. service URL
SPARK_ON_K8S_AIRFLOW_ALLOW_WRITES Set to true to permit kill/delete requests after task ownership and Airflow permissions are checked. false
SPARK_ON_K8S_AIRFLOW_OPERATOR_SUBCLASSES Comma-separated class names of operator subclasses that also show the tab, for example TeamSparkOperator,TeamSqlOperator. unset
SPARK_ON_K8S_AIRFLOW_SNAPSHOT_TTL Seconds, from 0 to 60, for which viewers of one task attempt share an application snapshot. 0 disables sharing. 5

An invalid URL, subclass list or snapshot TTL disables the tab and logs a warning in the Airflow API server.

Both URLs may include a deployment path prefix and must be HTTP(S) URLs without embedded credentials, query parameters or fragments. The service URL is chosen by the administrator, not by browser input. The plugin does not forward the browser's Airflow credentials to that service. Keep the Spark API behind appropriate network access controls; Airflow authentication does not protect a separately exposed Spark API or its external links.

If spark_on_k8s_service_url is set on an operator, it must match the configured service or public URL. Use the public URL to keep the existing Spark Job UI link browser-accessible. Set ui_reverse_proxy=True on the operator to offer the live Spark UI link. Airflow deployments under a URL prefix are supported; no root-relative plugin URL needs to be configured.

The tab uses the selected DAG, run, task, mapped index or region, and current attempt to resolve a small metadata record in XCom. Live data is checked against the spark-on-k8s.io/airflow-task annotation written on the driver by the operator. A mismatched or missing ownership annotation is rejected. A retry may adopt its own earlier attempt's driver; another task's driver cannot be selected by editing XCom. Older task submissions without the new metadata retain their existing operator links but cannot use the task tab's live features.

Read requests require Airflow access to the task; log streaming additionally requires task-log access. Kill and delete also require the corresponding Airflow write permission and SPARK_ON_K8S_AIRFLOW_ALLOW_WRITES=true. Log connections and write requests must come from the Airflow page's origin. Behind a proxy that terminates TLS, Airflow must see the public scheme and host (for example --proxy-headers with a matching FORWARDED_ALLOW_IPS); otherwise the browser's https origin does not match and the tab's log stream and actions are refused. The Spark API's own permissions still apply.

After the driver pod is deleted, the tab shows only the saved task-local application ID with unknown status. It offers an external history link only when the Spark API reports a configured History Server. It disables live logs and actions, and does not fetch history records using XCom alone. The external history link requires the event logging and History Server setup described above. While a driver exists and ownership is verified, the tab can fetch that application's history metadata through the configured Spark API.

The native tab checks the Spark API configuration before enabling history. An unconfigured History Server does not produce a warning; a configured server that cannot be reached still does. Older API services without the capability endpoint retain live application features but omit optional history links until upgraded.

The native task log proxy accepts individual text messages up to 16 MiB, including long driver log lines. A larger message interrupts the stream rather than being silently truncated. The proxy uses a small receive queue to keep buffering bounded; the console's separate line-count limit still applies.

Operator subclasses

Airflow decides where to show a plugin tab by matching the task instance's exact operator class name; it does not see base classes. To show the tab for a platform wrapper, list the wrapper's class name and restart the Airflow API server:

from spark_on_k8s.airflow.operators import SparkOnK8SOperator


class TeamSparkOperator(SparkOnK8SOperator):
    """Applies the platform team's defaults."""
export SPARK_ON_K8S_AIRFLOW_OPERATOR_SUBCLASSES='TeamSparkOperator'

The list only decides where the tab appears. Airflow's serialized task definition records the operator's class name but not its base classes, so for any other operator the tab's backend looks in that definition for the provider's Spark Job UI operator link (SparkOnK8SOperatorLink) before it reads the task's XCom record. Both provider operators declare this link, and subclasses, including mapped tasks, inherit it. A subclass that replaces operator_extra_links must keep SparkOnK8SOperatorLink() among its links. Otherwise, or for a listed operator that does not inherit from a provider operator, the tab reports that the task has no Spark application binding and does not read a record that the task pushed to XCom.

Refresh and shared snapshots

By default, the tab refreshes every 10 seconds while the application is pending, running or in an unknown state. Once the application has succeeded or failed, live refresh ends and the tab says so; Refresh still loads the current state on demand. A driver log stream ends when the driver terminates and shows Stream ended; Reconnect requests the available logs again.

Viewers of the same task attempt share the application snapshot that the Spark API reads from Kubernetes for SPARK_ON_K8S_AIRFLOW_SNAPSHOT_TTL seconds, and concurrent requests share one lookup. Each request is still authorized by Airflow and resolves its own task binding before it can receive a shared snapshot, and different task attempts never share one. Kill and delete verify the driver's current application directly, then discard the shared snapshot. A failed lookup is not reused after it completes. Each Airflow API server process keeps its own bounded in-memory snapshots, so a deployment with several API server workers or replicas can still perform one lookup per process for each interval. History records, the History Server capability check and diagnostics are requested for each viewer.