DataLane
4 min readKubernetes

Kubernetes Operators for Data Jobs: Unbounded Pods, Then the Cluster Dies

Airflow and Spark can spawn unbounded pods, requests and limits are the difference between busy and evicted, crash loops hide in operators, and MWAA or Databricks is sometimes the right way to not run K8s.

By Dinesh Chandra

Illustrated overview of Kubernetes Operators for Data Jobs: Unbounded Pods, Then the Cluster Dies
Table of contents

Airflow KubernetesExecutor plus dynamic task mapping expanded a backfill to 2,400 pods. The node pool scaled to 180 nodes. CoreDNS fell over. The cluster API started timing out. Overnight compute was $6,200. The DAG was “just catching up March.” MWAA would have queued the tasks and billed a boring environment fee. We had chosen Kubernetes so we could scale. We scaled.

I do not think every data team should run a cluster. I do think everyone who already has one should treat Airflow, Spark, and Flink operators like production apps: requests, limits, quotas, and an alert on crash loops. Orchestration comparisons live in Step Functions vs Airflow. This post is the K8s-shaped failure: unbounded pods.

Operators spawn jobs. Jobs spawn pods.

An operator (Airflow, Spark, Flink, Strimzi) watches custom resources and creates Jobs, pods, or StatefulSets. Your DAG is not “a container.” It is a factory. Dynamic mapping, SparkApplication executor counts, and a mis-set parallelism are load generators aimed at the API server.

flowchart TD
  dag["Airflow DAG / SparkApplication"] --> op["Operator"]
  op --> pods["Executor / task pods"]
  pods --> q{"Quota + requests?"}
  q -->|no| scale["Node pool + API + DNS"]
  scale --> out["Cluster brownout"]
  q -->|yes| cap["Queued or rejected"]

Without a quota, “retry March” is a cluster-wide event. With one, it is a slow DAG.

# Namespace quota I wish we had before the 2,400-pod night.
apiVersion: v1
kind: ResourceQuota
metadata:
  name: airflow-tasks
  namespace: airflow
spec:
  hard:
    pods: "200"
    requests.cpu: "80"
    requests.memory: 160Gi
    limits.cpu: "160"
    limits.memory: 320Gi
---
# Every task pod. No limits = BestEffort or a noisy neighbor.
resources:
  requests:
    cpu: "500m"
    memory: 2Gi
  limits:
    cpu: "2"
    memory: 4Gi

Requests are what the scheduler believes. Limits are the ceiling. I have watched Spark executors without memory limits get OOM-killed in a crash loop while the driver reported “lost executor” for two hours. I have also watched a Python task with no request sit BestEffort and get evicted the moment the node filled. Both look like “Airflow is flaky.”

Crash loops are the outage

CrashLoopBackOff on the Airflow scheduler, the Spark operator, or the Kafka controller is not a cosmetic. The factory stopped. Tasks queue or vanish. I alert on restart count, not on “pod not ready for 30 seconds” during a rolling deploy.

# kubectl get pods -n airflow -o json | this filter in CI / a probe
def crash_looping(pods: list[dict]) -> list[str]:
    bad = []
    for p in pods:
        name = p["metadata"]["name"]
        for cs in p.get("status", {}).get("containerStatuses", []):
            wait = (cs.get("state") or {}).get("waiting") or {}
            if wait.get("reason") == "CrashLoopBackOff":
                bad.append(name)
            if cs.get("restartCount", 0) >= 5 and not cs.get("ready"):
                bad.append(name)
    return sorted(set(bad))

Read kubectl describe pod once. ImagePullBackOff is credentials. OOMKilled is the limit. CreateContainerConfigError is a missing secret. You do not need CKA. You need to stop guessing from the Airflow UI.

When not to run Kubernetes

If the company does not staff a platform team, I will argue for MWAA, Composer, or Cloud Composer-shaped Airflow, or for Databricks / EMR instead of Spark-on-K8s. You are buying someone else’s node pool, etcd, and upgrade train. The APIs look similar. The on-call does not. Spark-on-K8s is cheaper on paper until you count the people who understand PersistentVolumes and the night the node pool policy was max: 200.

# Spark operator: cap executors the way you cap Airflow mapping.
# A "temporary" executor count of 400 is the same incident.
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
  name: nightly-orders
  namespace: spark
spec:
  type: Python
  sparkVersion: "3.5.1"
  driver:
    cores: 1
    memory: 4g
  executor:
    cores: 2
    memory: 8g
    instances: 20   # not 400

I also cap max_active_tasks / pool slots in Airflow so mapping cannot outrun the quota. The quota is the last line. The DAG should not try 2,400 in the first place.

Pitfalls

No ResourceQuota on airflow and spark. The node pool max becomes the quota. Finance sees it on the invoice. DNS sees it first.

Default pod spec without requests. BestEffort tasks get evicted. The retry storm makes it worse. Pin a pod template on the executor.

Ignoring CrashLoopBackOff on the operator. The UI still shows DAGs. Nothing schedules. Page the operator, not the task.

Running the metadata database on a 20 GB disk. Scheduler crash loops from a full PVC look like “Airflow is down.” Size the metadata store like an app database.

Choosing K8s to save a Databricks bill with no platform rotation. You will spend the savings on the first upgrade weekend. Buy the product until you staff the cluster.

I still run operators when the org already has a cluster, quotas, and a platform rotation. Then the data team’s job is: default pod templates with requests/limits, a namespace quota, mapping caps (max_map_length / pool slots), and a page on crash loops. The March backfill should have been 200 pods and a four-hour DAG. It was 2,400 pods and a cluster incident. Scale was never free. It was unmetered.

Share this post:X / TwitterLinkedIn

Enjoyed this post?

Get the next one in your inbox — one email a week, no spam.

Newsletter signup is not live yet. Use the contact form if you want to be notified.

↑↓ navigate openesc close