Spark on Kubernetes fails differently from YARN. Pods are OOMKilled because the container limit is lower than Spark thinks it is. Shuffle data lives on executors that the autoscaler removes. Logs vanish with the pod. Use this checklist before a Spark workload goes to production on a shared cluster. Most items are --conf settings, so they work with spark-submit and with a SparkApplication spec.
Identity and images
- Dedicated namespace and service account for drivers. The driver creates executor pods, services and config maps, so its service account needs those verbs, and only in its own namespace.
kubectl create namespace spark-jobs
kubectl -n spark-jobs create serviceaccount spark-driver
kubectl -n spark-jobs create rolebinding spark-driver-edit \
--clusterrole=edit --serviceaccount=spark-jobs:spark-driver
--conf spark.kubernetes.namespace=spark-jobs
--conf spark.kubernetes.authenticate.driver.serviceAccountName=spark-driver
- Pinned image tags, never
latest. Setspark.kubernetes.container.imageto an immutable tag or digest, and setspark.kubernetes.container.image.pullPolicy=IfNotPresent. Large images pulled on every executor start slow down scale-out. - Cloud storage access through workload identity (IRSA or EKS Pod Identity, Entra Workload ID, GKE Workload Identity), not access keys in Spark conf. Spark conf is visible in the UI and event logs.
Pod memory and CPU
- Size the container, not just the heap. Executor pod memory =
spark.executor.memory+ overhead +spark.executor.pyspark.memory(if set). Overhead isspark.executor.memoryOverhead, orspark.executor.memoryOverheadFactortimes the heap. The default factor is 0.10 for JVM jobs, and Spark on Kubernetes uses 0.40 for non-JVM (PySpark, R) jobs. Older releases usedspark.kubernetes.memoryOverheadFactor; check your version's docs. - Know what lives in overhead: Netty buffers, off-heap (
spark.memory.offHeap.sizeis added separately when enabled), Python workers, native libraries, andtmpfslocal dirs if you use them. If pods are OOMKilled while the Spark UI shows heap to spare, overhead is too small.
kubectl -n spark-jobs get pod <executor-pod> \
-o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
# OOMKilled = the container limit was hit (fix overhead), not a JVM OutOfMemoryError
- CPU requests and limits are deliberate.
spark.kubernetes.executor.request.corescan be fractional, so it packs pods tighter thanspark.executor.cores, which sets task slots. Apply a CPU limit withspark.kubernetes.executor.limit.coresonly if your platform requires one. CFS throttling on a tight limit shows up as slow GC and long task times. - Driver sized for the plan, not the data. Broadcasts,
collect()and large file listings land on the driver. Give it its own overhead withspark.driver.memoryOverhead.
Local storage for shuffle and spill
- Shuffle and spill go to real disks. By default, Spark local dirs are
emptyDirvolumes on the node's root disk, where they compete with images and logs. That can lead to evictions for ephemeral-storage pressure. Mount NVMe or a dedicated volume instead. Volumes named with thespark-local-dir-prefix are used as local dirs:
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.claimName=OnDemand
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.storageClass=gp3
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.options.sizeLimit=200Gi
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.mount.path=/data/spark-local
--conf spark.kubernetes.executor.volumes.persistentVolumeClaim.spark-local-dir-1.mount.readOnly=false
-
spark.kubernetes.local.dirs.tmpfs=trueonly when you have accounted for the RAM. It makes shuffle memory-backed, and that usage counts against the pod's memory limit. - PVC reuse for churny jobs.
spark.kubernetes.driver.ownPersistentVolumeClaim=trueandspark.kubernetes.driver.reusePersistentVolumeClaim=true(3.2+) let replacement executors reattach existing on-demand claims instead of provisioning new volumes.
Warning
Ephemeral-storage evictions kill executors in the middle of a shuffle and look like
FetchFailedExceptionstorms. Set an ephemeral-storage request on executor pods through a pod template, so the scheduler places them on nodes with room.
Dynamic allocation without an external shuffle service
- Shuffle tracking on. Kubernetes has no node-level external shuffle service by default, so dynamic allocation depends on tracking which executors hold shuffle data:
--conf spark.dynamicAllocation.enabled=true
--conf spark.dynamicAllocation.shuffleTracking.enabled=true
--conf spark.dynamicAllocation.shuffleTracking.timeout=30min
--conf spark.dynamicAllocation.minExecutors=2
--conf spark.dynamicAllocation.maxExecutors=50
--conf spark.dynamicAllocation.executorIdleTimeout=120s
- A shuffle-tracking timeout is set. Without one, an executor that holds shuffle files for a cached or long-lived DataFrame is never released, so a job that has been idle for a while still holds its peak executor count.
- Executor pod creation is throttled for big jobs.
spark.kubernetes.allocation.batch.size(default 10) andspark.kubernetes.allocation.batch.delaycontrol how fast pods are requested. Raising them speeds up scale-out, but it can flood the API server and the cluster autoscaler. - Stateful streaming queries keep a fixed executor count. Losing executors forces state stores to reload from the checkpoint.
Spot nodes and graceful decommissioning
- Decommissioning migrates shuffle and cached blocks before the pod dies:
--conf spark.decommission.enabled=true
--conf spark.storage.decommission.enabled=true
--conf spark.storage.decommission.shuffleBlocks.enabled=true
--conf spark.storage.decommission.rddBlocks.enabled=true
--conf spark.storage.decommission.fallbackStorage.path=s3a://spark-fallback/shuffle/
- The pod's grace period covers the migration. Set
terminationGracePeriodSecondsin the executor pod template. The default of 30 seconds is rarely enough to move gigabytes of shuffle data. The node termination handler or Karpenter must drain the node, rather than hard-stop it, so the pods actually receive SIGTERM. - Drivers never run on spot. Losing the driver loses the whole application. Use
spark.kubernetes.driver.node.selector.<label>(3.3+), or a driver pod template with node affinity to on-demand nodes, and allow executors onto spot with tolerations. - The fallback storage path has a lifecycle rule. It is not cleaned up if the application dies.
Pod templates and scheduling
- Pod templates for what Spark conf can't express: tolerations, affinity, ephemeral-storage requests, sidecars and
priorityClassName. Set them withspark.kubernetes.driver.podTemplateFileandspark.kubernetes.executor.podTemplateFile. Spark overrides some fields, such as the memory request and the container image. - Gang scheduling on shared clusters. Without it, ten jobs that each have their driver running can starve one another of executors. Use Volcano or Apache YuniKorn with
spark.kubernetes.scheduler.name, and set queues and quotas per team. - A ResourceQuota per namespace, so one runaway
maxExecutorscan't take the whole cluster.
Logs, history and cleanup
- Event logs go to object storage (
spark.eventLog.enabled=true, withspark.eventLog.dirset to ans3a://,abfss://orgs://path), with a Spark History Server reading them. The driver UI disappears when the driver pod completes. - Container logs are shipped by a node-level agent such as Fluent Bit. Executor pods are deleted on completion by default (
spark.kubernetes.executor.deleteOnTermination=true). Set it tofalseonly temporarily, while you debug. - Completed driver pods are cleaned up. They stay in
Completedstate until something removes them. The Spark Operator has TTL settings; with plainspark-submit, run a scheduled cleanup:
kubectl -n spark-jobs delete pod --field-selector=status.phase==Succeeded -l spark-role=driver
Quick failure map
| Symptom | Likely cause | Check |
|---|---|---|
Executors OOMKilled, heap looks fine | Overhead too small, PySpark workers, tmpfs | Container termination reason, overhead factor |
Executors evicted, FetchFailedException | Ephemeral storage on the root disk | kubectl describe pod events, local dir volumes |
| Job holds peak executors when idle | Shuffle tracking with no timeout | shuffleTracking.timeout |
Pods stuck Pending while drivers wait | No gang scheduling, or quota exhausted | kubectl describe pod, ResourceQuota usage |
| Spot interruption kills the job | Driver on spot, or decommissioning off | Driver node, spark.decommission.enabled |
Driver can't create executors (Forbidden) | Service account RBAC | kubectl auth can-i create pods --as=system:serviceaccount:spark-jobs:spark-driver -n spark-jobs |
Setting names and defaults change between Spark minor versions. Check the "Running on Kubernetes" page for the version in your image before you copy a conf block.