Home DevOps Airflow on Kubernetes: 400 Pods, 3 Nodes, 1 Nightmare
Advanced 3 min · September 04, 2026

Airflow on Kubernetes: 400 Pods, 3 Nodes, 1 Nightmare

Airflow on Kubernetes packed 400 pods onto 3 nodes with no limits.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 35 min
  • You run Airflow on Celery or Local and feel isolation limits
  • You operate Kubernetes workloads with Helm basics
  • You understand requests, limits, quotas, and autoscaling
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • KubernetesExecutor runs each Airflow task in its own pod with isolated image, env, and resources
  • Key components: official Helm chart, per-task requests/limits, KEDA queue-depth scaling, git-sync or baked DAG delivery
  • Performance insight: unconstrained pods packed 130 per node and evicted in cascades; bounded pods at 30 per node held 99% success with 25% headroom
  • Production insight: pin Helm versions and gate DAG sync, because a broken commit or floating chart stops scheduling fleet-wide
✦ Definition~90s read
What is Airflow on Kubernetes?

KubernetesExecutor runs each Airflow task as an isolated Kubernetes pod deployed via the official Helm chart, scaled by KEDA on queue depth and bounded by requests, limits, and quotas.

Think of Celery as a hotel with permanent staff handling every guest.
Plain-English First

Think of Celery as a hotel with permanent staff handling every guest. Kubernetes is building a tiny pop-up hotel room for each guest, then demolishing it at checkout. Perfect isolation for VIPs, absurd overhead for a busload of one-night stays.

Four hundred pods met three nodes on a Tuesday night. The cluster didn't degrade gracefully; it fell over sideways.

Every task had launched a pod with no resource requests and no limits. Kubernetes packed them wherever they fit until nothing fit anywhere.

Pod-per-task is powerful precisely because it's expensive. You'll learn to price that power here.

KubernetesExecutor: A Pod Per Task

KubernetesExecutor trades warm reuse for clean rooms. Every task instance becomes a pod: fresh filesystem, declared image, explicit env, bounded resources. When it finishes, the pod dies and takes its mess with it.

That isolation kills entire failure classes: dependency conflicts between tasks, leaked /tmp state, noisy neighbors hogging RAM. It replaces them with a new cost: 20-60 seconds of pod startup per task.

Use it where isolation pays: untrusted code, conflicting dependencies, spiky resource needs. Avoid it where it taxes: thousands of two-minute tasks that warm Celery workers finish faster.

📊 Production Insight
200 two-minute tasks paid 60s startup each on Kubernetes.
Same tasks on Celery finished 40% faster overall.
Rule: short and steady stays warm.
🎯 Key Takeaway
Clean-room pods kill dependency and state leakage.
Cold starts tax every task 20-60 seconds.
Isolation pays for spiky work, not steady short tasks.

Helm Install of the Official Chart

Install the official chart with a pinned version, a private registry mirror, and values in Git. Chart versions drift fast; floating tags turn routine deploys into template-rename incidents. One-command install looks like helm repo add apache-airflow https://airflow.apache.org followed by helm upgrade --install airflow apache-airflow/airflow --namespace airflow --create-namespace. The provider path needs apache-airflow-providers-cncf-kubernetes>=7.4.0 (or pip install 'apache-airflow[cncf.kubernetes]'), and the scheduler needs a non-sqlite backend plus cluster API access.

Values carry executor choice, image tags, git-sync config, scheduler replicas, and resource blocks. Review them like code because they are code: a wrong indentation can drop your resource limits silently. GitOps tools (Argo CD, Flux, Terraform) must set createUserJob/migrateDatabaseJob useHelmHooks=false with applyCustomEnv=false or migrations never run.

Rehearse upgrades in staging with airflow dags test on a canary DAG. Roll back with helm rollback before debugging forward in prod.

helm/airflow-values.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 airflowVersion: "3.3.1"
# values.yaml (pinned, production)
executor: "KubernetesExecutor"

images:
  airflow:
    repository: my-registry/airflow
    tag: "3.3.1-python3.12"  # never floating

dags:
  gitSync:
    enabled: true
    repo: "https://github.com/acme/airflow-dags.git"
    branch: prod
    syncInterval: 60

scheduler:
  replicas: 2
  resources:
    requests: {cpu: "500m", memory: "1Gi"}
    limits: {cpu: "2000m", memory: "4Gi"}
📊 Production Insight
Floating chart tag renamed a template and broke parsing fleet-wide.
Pinned 1.16.0 plus staged rehearsal ended surprise breakage.
Rule: pin charts like dependencies.
🎯 Key Takeaway
Pinned chart, mirrored images, values in Git.
Staging rehearsals precede every prod bump.
Rollback first, debug second.

Requests and Limits: The OOM Lesson

Requests tell the scheduler what you need; limits tell the kubelet when to kill you. Without requests, pods pack densely until nodes OOM. Without limits, one greedy task eats the node and evicts neighbors. The executor's pod_template_file carries the base image and pod name Airflow requires; everything else layers per task through executor_config (volumes, sidecars, affinity, tolerations) without forking the template.

Set requests to steady-state usage measured over a week, limits to 1.5-2x for bursts. Memory limits kill with OOMKilled; CPU limits throttle. Both beat node-wide eviction cascades. Debug the rendered pod before blaming Airflow: airflow kubernetes generate-dag-yaml dumps exactly what the executor will submit.

Backstop with namespace quotas capping total CPU and pod counts. Quotas turn a runaway backfill into pending pods (visible, bounded) instead of dead nodes (invisible, catastrophic).

📊 Production Insight
Zero bounds packed 130 pods per node until OOM cascades.
Bounded pods at 30 per node held 99% success.
Rule: no task ships without requests and limits.
🎯 Key Takeaway
Requests schedule honestly, limits contain greed.
Measure a week, then set requests at p50 and limits near p95 burst.
Quotas convert node death into visible pending.

KEDA: Scale Workers on Queue Depth

KEDA scales capacity on queue depth instead of CPU. When queued tasks cross thresholds, it adds workers or nodes; when the queue drains, it scales back to near-zero. You pay for burst only during burst.

Point triggers at scheduler queue metrics, not pod CPU. CPU lags task pressure by minutes; queue depth leads it. Thresholds around 50 queued tasks per replica keep waits under 5 minutes without flapping.

Test scale-down as hard as scale-up. Aggressive downscaling that kills warm nodes mid-burst recreates the cold-start storm you installed KEDA to avoid.

k8s/airflow-keda.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: airflow-worker-scale
spec:
  scaleTargetRef:
    name: airflow-worker-pool
  minReplicaCount: 2
  maxReplicaCount: 20
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: airflow_scheduler_queued_tasks
        threshold: "50"  # add capacity per 50 queued tasks
📊 Production Insight
Static 10 nodes idled at 15% for 20 hours daily.
KEDA 2-to-20 on queue depth cut compute spend 45%.
Rule: scale on queued tasks, not CPU.
🎯 Key Takeaway
Queue depth leads, CPU lags; trigger on the queue.
Near-zero idle with burst headroom cuts cost.
Test scale-down before it strands a burst.

Config via Helm Values vs ConfigMaps

Config arrives via Helm values, ConfigMaps, and secrets, in that precedence order. Values carry structure, ConfigMaps carry files like airflow.cfg snippets, secrets carry connections and keys. Mixing them randomly creates drift nobody can audit. Pod mutation hooks in airflow_local_settings.py add last-mile tweaks (labels, sidecars) without forking the chart.

Git-sync delivers DAGs by polling a branch every 60 seconds. It's flexible across branches but syncs broken commits fleet-wide in one interval. Baked images ship DAGs inside the digest: slower iteration, atomic rollback, zero mid-write parses. Workers read DAG files plus the metadata DB, so either path must reach every scheduler and worker identically.

Gate both with parse checks, and persist logs outside the pod. Without a mounted volume or remote logging, a terminated task pod takes its logs with it. A sync that can't parse shouldn't reach the scheduler, whether it arrives by git or by image.

📊 Production Insight
A mid-write git-sync synced half a DAG and halted all scheduling.
Parse-gated sync cut bad deploys to zero in 3 months.
Rule: never let unparseable DAGs reach the scheduler.
🎯 Key Takeaway
Values structure, ConfigMaps files, secrets keys; keep the split clean.
Git-sync flexes, images harden; gate both on parse.
Audit config like code because it is.

Cost and Cold-Start Trade-Offs

Cold starts include image pull, pod scheduling, sidecar init, and Airflow task bootstrap. Warm node pools with pre-pulled images cut pulls; slim images cut the rest. Still, 20 seconds is a good day. Fault tolerance leans on a watcher thread: it tails Kubernetes events, marks crashed pods failed, and stores the resourceVersion in the DB so a restarted scheduler resumes the stream instead of rerunning finished tasks. Running tasks report straight to the DB, so scheduler crashes don't fail them.

Cost follows the same curve: per-pod overhead plus node time versus Celery's shared workers. Bursty ML with 30-minute tasks barely notices; hourly 2-minute ETL bleeds money. The official chart can scale Celery workers to zero on queue depth, so the old always-on cost gap narrows when you use it.

Route deliberately with multi-executor setups: steady ETL on Celery queues, spiky training on Kubernetes. One control plane, two cost profiles. Default to the kubernetes queue for pod-bound tasks and keep the rest on Celery workers.

🔥Price the Cold Start
Cold starts are a per-task tax, not a rounding error. At 45 seconds startup on 2-minute tasks, you pay 37% overhead before any work runs. Keep short steady tasks on warm executors.
📊 Production Insight
Hourly 2-minute tasks paid 37% overhead in pod startup.
Routing them to Celery saved 30% monthly compute.
Rule: measure startup tax per workload before choosing.
🎯 Key Takeaway
Slim images and warm pools trim but never erase startup.
Long tasks absorb overhead; short tasks drown in it.
Route steady work warm, spiky work isolated.
● Production incidentPOST-MORTEMseverity: high

400 Pods, 3 Nodes, 1 Nightmare. Unconstrained pods evicted the entire cluster.

Symptom
Pods cycled through Pending, Running, and Evicted faster than engineers could describe them. Node memory hit 98%, the kubelet started killing pods to survive, and Airflow retried each killed task into a fresh pod that landed on the same drowning nodes. Task durations tripled while success rates fell below half.
Assumption
The team assumed Kubernetes would protect itself: the scheduler would spread pods sensibly and the cluster autoscaler would add nodes before pressure. They shipped tasks with no requests or limits because defaults felt like someone else's problem.
Root cause
Each task spawned a pod with no CPU or memory requests or limits. The Kubernetes scheduler, lacking any signal about real needs, packed pods densely onto 3 nodes. Memory pressure triggered mass evictions, retried tasks spawned replacement pods, and the retry storm finished what the first wave started.
Fix
They set resources.requests and limits on every task pod, added namespace quotas as a backstop, and enabled KEDA scaling on scheduler queue depth. The Helm chart got pinned to a tested version with upgrades rehearsed in staging. Pod counts per node dropped from 130 to a sustainable 30, and evictions stopped.
Key lesson
  • Pod-per-task without resource bounds is a self-inflicted DDoS on your own cluster.
  • Autoscaling reacts to signals you must configure; defaults don't know your queue.
Production debug guideTrace evictions, pending pods, chart drift, and dead autoscaling.4 entries
Symptom · 01
Nodes OOM and pods evict in cascades
Fix
Run kubectl top nodes and kubectl get pods --sort-by=.status.phase | head -30. If nodes sit above 90% memory with hundreds of running pods, add requests/limits per task and a namespace quota immediately. Cordon the worst node and drain before it OOMs.
Symptom · 02
Tasks stuck pending as pods that never start
Fix
Run kubectl describe pod <pending-pod> and read Events for FailedScheduling or ImagePullBackOff. Insufficient CPU means quotas or node pool too small; image stalls mean registry auth or tag drift. Dump the rendered spec with airflow kubernetes generate-dag-yaml to confirm executor_config merged correctly. Fix the event reason, not Airflow.
Symptom · 03
Scheduler stops parsing after a chart upgrade
Fix
Run helm list -A and compare chart version to your pinned value. If it floated, helm rollback airflow <prev-revision> then pin the version in CI. Verify with airflow dags test on a canary DAG before re-upgrading.
Symptom · 04
Queue explodes but no new nodes appear
Fix
Run kubectl logs -l app=keda-operator --tail 100 and check ScaledObject triggers on queue depth. If KEDA never fires, verify metrics-server and the Airflow queue exporter. Scale one node pool manually while fixing the trigger.
Celery vs Kubernetes Executor Compared
DimensionCeleryExecutorKubernetesExecutor
Unit of scaleWorker process, many tasksOne pod per task
Startup costSeconds on warm workers20-60s pod cold start
IsolationShared host, noisy neighborsFull pod with requests/limits
DependenciesShared worker imagePer-task image and resources
Ops burdenBroker plus fleetCluster, Helm, KEDA, quotas
Best forSteady short ETLBursty, spiky, isolated tasks
LogsSurvive via remote/volumeLost with podBoth need remote logging
Failure recoveryBroker redeliveryWatcher + resourceVersion resumeK8s survives scheduler restarts
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
helmairflow-values.yamlairflowVersion: "3.3.1"Helm Install of the Official Chart
k8sairflow-keda.yamlapiVersion: keda.sh/v1alpha1KEDA

Key takeaways

1
KubernetesExecutor launches one isolated pod per task with distinct images and resources.
2
Requests and limits are mandatory plus pod_template_file discipline; generate-dag-yaml proves the rendered spec.
3
Pin Helm chart versions and gate DAG sync with parse checks.
4
KEDA scales capacity on queue depth for bursty workloads.
5
Cold starts tax short tasks; route steady ETL to warmer executors.

Common mistakes to avoid

4 patterns
×

Launching pods with no requests or limits

Symptom
Nodes OOM, pods evict randomly, and 400 tasks land on 3 nodes until the cluster tips over.
Fix
Set resources.requests equal to steady-state usage and limits 1.5-2x above it per task. Use namespace quotas as a backstop. Review OOMKilled events weekly and adjust the worst offenders.
×

Floating the Helm chart version across deploys

Symptom
A chart bump renames templates, dag-processor misses config, and the scheduler stops parsing.
Fix
Pin the chart version in CI (e.g., 1.16.0), mirror images internally, and test upgrades in staging with airflow dags test. Read the chart changelog before bumping.
×

Syncing DAGs without parse-safety gates

Symptom
A broken commit syncs mid-write and the scheduler stops scheduling every DAG, not just the broken one.
Fix
Mount DAGs via git-sync with a parse gate, or bake them into the image digest. Never let tasks import DAG files from a half-synced volume.
×

Ignoring pod cold-start cost on short tasks

Symptom
Two-minute tasks pay 60-second pod startup each, doubling effective runtime and warehouse slot holds.
Fix
Right-size images, use warm node pools for steady DAGs, and tolerate cold starts only for rare heavy tasks. Alert on pending-pod age above 5 minutes.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why did 400 pods on 3 nodes collapse the cluster?
Q02SENIOR
How do you deploy Airflow on Kubernetes with Helm?
Q03SENIOR
When does KubernetesExecutor beat Celery, and when does it lose?
Q01 of 03JUNIOR

Why did 400 pods on 3 nodes collapse the cluster?

ANSWER
Each task spawned a pod with no requests or limits, so the scheduler packed 400 pods onto 3 nodes. Nodes OOMed and pods evicted in cascades. The fix is per-task requests/limits, namespace quotas, KEDA scaling on queue depth, and a pinned Helm chart.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How does KubernetesExecutor run a task?
02
Why do requests and limits matter so much?
03
What does KEDA do for Airflow?
04
Git-sync or baked images for DAGs on Kubernetes?
05
Tasks stay pending as pods. Where do I look?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow Celery Executor Setup
24 / 37 · Airflow
Next
Airflow Configuration and Environment