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

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

Airflow on Kubernetes spawns a pod per task - and 400 pods on 3 nodes collapsed our cluster.

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
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • A working Kubernetes cluster and kubectl.
  • Understanding of executors (see the executors article).
  • Comfort with Helm and YAML values.
  • Basic familiarity with pod, Deployment, and ConfigMap concepts.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • KubernetesExecutor runs one pod per task instance: isolation is the point, cold starts are the cost.
  • Unbounded pods are a bomb: no requests/limits means 400 task pods can pack onto 3 nodes and get evicted.
  • Set resources.requests and resources.limits on every task pod: requests guarantee, limits bound.
  • KEDA scales the scheduler on queue depth, so worker capacity follows backpressure instead of guesswork.
  • The official Helm chart deploys the whole stack; pin the chart version or upgrades silently change pod templates.
  • Git-sync sidecars deliver DAGs into scheduler and worker pods straight from the repo.
✦ Definition~90s read
What is Airflow on Kubernetes?

Airflow on Kubernetes runs task instances as isolated pods via KubernetesExecutor, deployed through the official Helm chart, sized with resource requests and limits, and scaled with KEDA on queue depth.

Imagine a hotel with three rooms and no booking limits.
Plain-English First

Imagine a hotel with three rooms and no booking limits. A wedding party arrives and checks 400 guests in: everyone piles into three rooms, the building sags, and the fire marshal evicts half the guests. Kubernetes does the same with task pods. Requests and limits are the booking policy: how much room each guest promises and never exceeds. KEDA is the manager who watches the lobby line and hires more staff when it grows.

KubernetesExecutor is Airflow's most isolated execution model: every task instance gets its own pod. No shared filesystems, no neighbor's cron job, no cross-worker state. It's a superpower and a trap.

The trap is resources. Kubernetes schedules pods, but it doesn't size them. A task pod with no requests and no limits is an unbounded bet on a finite cluster, and the cluster always collects.

The official Helm chart gets you a working stack in one command. The problem is everything after the command: resources, scaling, DAG delivery, and the version you pinned to.

Treat the cluster like a finite budget. Every pod is a line item.

1. KubernetesExecutor: A Pod Per Task

KubernetesExecutor runs every task instance in its own pod. The scheduler creates the pod, the pod runs the operator code, and the pod disappears. Isolation per task is the whole design. You enable it in airflow.cfg with executor = KubernetesExecutor, set parallelism, and let the cluster do the rest.

That isolation buys you a lot: a memory-hungry task can't kill its neighbors, resource-hungry jobs get dedicated requests, and the cluster handles retries by creating fresh pods.

It costs you startup time. Every task pays a pod scheduling and image-pull warm-up, seconds per task that Celery workers don't pay. High-frequency short tasks bleed throughput here.

The trade-off is clean: isolation and elastic capacity against cold starts and cluster complexity. Choose it when tasks are heavy, varied, or untrustworthy.

The pod is created when the task is queued and terminates when the task completes. You don't have to pick one executor either: CeleryKubernetesExecutor runs both on one cluster — tasks on the kubernetes queue get their own pod, everything else goes to Celery workers — and KubernetesPodOperator gives you a per-task pod under any executor.

📊 Production Insight
One task, one pod: isolation is the design.
Cold starts add seconds to every task.
Rule: pod-per-task pays off for heavy, varied workloads.
🎯 Key Takeaway
KubernetesExecutor trades startup time for isolation.
Fresh pods per task keep failures contained.
Punchline: pick it for heavy tasks, not for 5-second pokes.
KubernetesExecutor: A Pod Per Task KubernetesExecutor: A Pod Per Task Scheduler-created pods: isolation in, cold starts out Scheduler creates a pod one pod per task instance Pod runs the operator code isolated from every neighbor Pod disappears after the run retries spawn fresh pods Buys: isolation failures stay contained Costs: cold start seconds per task: pull + schedule THECODEFORGE.IO
thecodeforge.io
Airflow Kubernetes

2. Helm Install of the Official Chart

The official Apache Airflow Helm chart deploys the entire stack: scheduler, webserver, workers, postgres, and redis, all as one release. One helm install, one values.yaml, and a production-grade cluster appears.

The chart's real value is templating: workers, resource settings, git-sync, and KEDA support are all values. You configure the platform in YAML instead of hand-rolling manifests.

Pin the version. Unpinned charts drift: a minor bump silently changed our scheduler pod template and the fleet behaved differently overnight. Chart upgrades deserve the same review as code changes.

Install once, pin forever, diff every upgrade.

install.shBASH
1
2
3
4
5
6
7
8
9
10
11
helm repo add apache-airflow https://airflow.apache.org
helm repo update

helm install airflow apache-airflow/airflow \
  --namespace airflow --create-namespace \
  --version 1.15.0 \
  --values values.yaml

# Verify the release and the running pods
helm list -n airflow
kubectl get pods -n airflow
Output
NAME READY STATUS
airflow-scheduler-7f8 1/1 Running
airflow-webserver-2k9 1/1 Running
airflow-worker-0 2/2 Running
⚠ Pin the Chart Version
An unpinned chart upgrade silently rewrote our pod template once. Track the version, review the diff, and roll out chart changes like application releases.
📊 Production Insight
Helm deploys the whole stack from one values.yaml.
Unpinned charts drift and rewrite pod templates silently.
Rule: pin the chart and review every upgrade diff.
🎯 Key Takeaway
The official chart deploys the full Airflow stack.
Values.yaml is the config surface for the platform.
Punchline: a chart upgrade is a release, treat it like one.
Helm: One Chart, One Release Helm: One Chart, One Release The official chart deploys the whole stack from values.yaml Official Airflow Helm chart one release from one values.yaml Scheduler Webserver Workers Postgres Redis Pin the chart version a bump once rewrote our pod template Diff every upgrade like a code change THECODEFORGE.IO
thecodeforge.io
Airflow Kubernetes

3. Requests and Limits: The OOM and Eviction Lesson

Requests and limits are the two numbers every pod needs. requests are a promise: the scheduler only places the pod where that capacity is free. limits are a ceiling: the pod never exceeds them, even under pressure.

No requests means the scheduler sees zero cost and packs pods anywhere. No limits means a pod can consume a whole node and get OOM-killed when the kubelet pushes back.

Evictions are the painful half of the story. When a node runs out of memory, kubelet evicts pods, often the very ones doing real work. A 400-pod backfill on 3 nodes is the perfect eviction machine.

Set both, per task. Requests keep scheduling honest; limits keep one pod from eating the node.

For per-task settings the lighter lever than pod_override is executor_config with the KubernetesExecutor keys request_cpu, limit_cpu, request_memory, limit_memory. Two field notes: the scheduler always overwrites metadata.name and containers[0].args on any V1Pod you supply, and tight CPU or memory limits make pod startup slower — size limits from measured usage, not guesses.

market_etl.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from kubernetes.client import models as k8s
from airflow.operators.python import PythonOperator

pod = k8s.V1Pod(
    spec=k8s.V1PodSpec(
        containers=[
            k8s.V1Container(
                name="base",
                resources=k8s.V1ResourceRequirements(
                    requests={"cpu": "250m", "memory": "512Mi"},
                    limits={"cpu": "2", "memory": "4Gi"},
                ),
            )
        ],
    )
)


def flatten_market_data():
    pass


flatten = PythonOperator(
    task_id="flatten_market_data",
    python_callable=flatten_market_data,
    pod_override=pod,
    dag=dag,
)
Output
Task pod: requests 250m/512Mi, limits 2 CPU/4Gi
📊 Production Insight
Requests guarantee placement; limits cap consumption.
No limits means OOM kills; no requests means bad packing.
Rule: every task pod gets both, tuned to its real usage.
🎯 Key Takeaway
Requests and limits are the pod's contract with the cluster.
Unbounded pods get evicted, evictions kill real work.
Punchline: measure actual usage, then set both numbers.
Requests and Limits: The Pod Contract Requests and Limits: The Pod Contract Unbounded pods vs the 400-pod backfill on 3 nodes Before: No Requests or Limits After: Requests + Limits Placement Packs pods anywhere Places only where free Consumption Pod can eat a whole node Capped at the limit ceiling Failure mode OOM kills + evictions No OOM, no eviction storm Incident check 400 pods on 3 nodes Bounded pods fit the nodes THECODEFORGE.IO
thecodeforge.io
Airflow Kubernetes

4. KEDA: Scale Workers on Queue Depth

Static scheduler replicas are a gamble: too few, and a backfill starves; too many, and you pay for idle pods. KEDA closes the loop by scaling on the thing that matters: queue depth.

A ScaledObject watches the Celery queue and adjusts the scheduler or worker Deployment between minReplicaCount and maxReplicaCount. Deep queue, more replicas. Empty queue, fewer. No human, no guesswork.

This is queue-based autoscaling, which is different from HPA's CPU targeting. HPA reacts to resource pressure after it happens; KEDA reacts to work actually waiting.

Set the trigger threshold to your queue budget. A threshold of 10 means ten queued tasks add a replica, and the fleet follows the backlog.

keda-scaledobject.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: airflow-scheduler
  namespace: airflow
spec:
  scaleTargetRef:
    kind: Deployment
    name: airflow-scheduler
  minReplicaCount: 1
  maxReplicaCount: 4
  triggers:
    - type: redis
      metadata:
        address: redis.airflow.svc:6379
        listName: airflow.executor.celery
        listLength: "10"
Output
Scales scheduler 1-4 replicas based on Celery queue depth
📊 Production Insight
KEDA scales on queue depth; HPA scales on CPU.
Queue-based scaling follows actual backlog.
Rule: set the threshold to your queue budget, not your hopes.
🎯 Key Takeaway
KEDA watches the Celery queue and scales replicas to match.
Scaling on work waiting beats scaling on guessed load.
Punchline: let queue depth, not the calendar, drive capacity.

5. Config via Helm Values vs ConfigMaps

Two config surfaces exist on Kubernetes: Helm values for chart-managed settings, and ConfigMaps for Airflow's own configuration files.

Helm values own the deployment: executor, resource limits, git-sync, KEDA, replica counts. Change them, helm upgrade, and the chart reconciles the fleet. A values.yaml entry like executor: "KubernetesExecutor" or scheduler.replicas: 2 reshapes the release.

ConfigMaps own Airflow's config: airflow.cfg overrides and environment variables. Mount them into pods, and the running Airflow reads them without a chart cycle. Airflow-specific settings travel as AIRFLOW__SECTION__KEY variables.

The rule is separation of concerns: deployment shape in values, application config in ConfigMaps, secrets in Secrets, never in either. Mixing them turns every upgrade into archaeology.

💡ConfigMap for App Config, Values for Shape
Deployment shape belongs in Helm values; Airflow application settings belong in ConfigMaps or environment keys. Secrets go in Kubernetes Secrets, mounted as files, never as plain env.
📊 Production Insight
Values drive deployment shape; ConfigMaps drive Airflow config.
Secrets live in Secrets, mounted as files.
Rule: keep config surfaces separate or upgrades become archaeology.
🎯 Key Takeaway
Helm values shape the deployment; ConfigMaps feed the app.
Secrets never ride along in values or ConfigMaps.
Punchline: three config surfaces, three homes.

6. Git-Sync DAGs on Kubernetes

Pods are ephemeral; DAGs must not be. Git-sync solves delivery: a sidecar container clones your DAG repository into the shared DAG folder on an interval.

The scheduler and worker pods each run a git-sync sidecar alongside the main container. Merge to the configured branch, and DAGs propagate without rebuilding images or copying files.

The price is discipline: git-sync ships whatever is on the branch, including half-written files. Parse-safety gates in CI are the only thing standing between a bad commit and a dead scheduler.

Add validation to CI before merge: dag loading tests catch the broken imports that otherwise take the whole fleet down at once.

values.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
dags:
  gitSync:
    enabled: true
    repo: https://github.com/acme/data-platform-dags.git
    branch: main
    rev: HEAD
    subPath: dags
    syncIntervalSec: 60

  persistence:
    enabled: true
Output
DAGs sync from acme/data-platform-dags:main every 60s
📊 Production Insight
Git-sync ships DAGs from the repo into every pod.
What lands on the branch lands in production.
Rule: gate merges with parse tests or the branch runs you.
🎯 Key Takeaway
Git-sync sidecars keep pods ephemeral and DAGs durable.
Merge-to-deploy speed cuts both ways.
Punchline: CI parse gates are the price of git-sync freedom.

7. Cost and Cold-Start Trade-offs

Pod-per-task pays a cold-start tax every run: image pull, scheduling, container start. A fleet of short tasks pays that tax constantly, and the cluster bill grows with every backfill.

Cost follows the same curve. Each pod consumes CPU and memory while it runs, and idle scheduler replicas burn money for nothing. KEDA shrinks the idle fleet, but pod overhead remains.

The honest pattern: KubernetesExecutor for heavy, isolated, or bursty tasks; CeleryExecutor for the steady stream of small ones. Both can live under one scheduler.

Match the executor to the task, and the bill follows the work.

Pods also survive deploys: if you roll out while a task runs, KubernetesExecutor lets the pod finish, while Celery only waits out the configured grace period before terminating in-flight tasks.

📊 Production Insight
Cold starts tax every pod: scheduling, image pull, start.
KEDA kills idle cost; pod overhead stays.
Rule: heavy tasks on pods, steady small tasks on Celery.
🎯 Key Takeaway
KubernetesExecutor pays cold-start and pod overhead per task.
Hybrid fleets route heavy work to pods, light work to Celery.
Punchline: the cheapest executor is the one matched to the task.
● Production incidentPOST-MORTEMseverity: high

400 Pods, 3 Nodes, Zero Limits

Symptom
A backfill launched 400 task pods onto a 3-node cluster. Memory hit 100%, kubelet evicted half the pods, the rest crashed on startup, and the namespace entered a crash-loop that took the whole platform with it.
Assumption
The team assumed Kubernetes would size pods sensibly and schedule around node capacity on its own.
Root cause
Every task pod was created with no resource requests or limits. The scheduler packed hundreds of unbounded pods onto three nodes, node memory blew up, and OOM kills plus evictions took down healthy work along with the overload.
Fix
Added resources.requests and resources.limits to every task pod, enabled KEDA to scale the scheduler's workers on queue depth, pinned the Helm chart version, and wired pod-eviction and memory alerts into the monitoring stack.
Key lesson
  • Kubernetes will not size your pods. Unbounded pods are a bomb.
  • requests guarantee capacity; limits cap it. Set both on every task.
  • KEDA scales workers on queue depth, not on guesses.
  • Pin your Helm chart; an unpinned upgrade silently rewrote our pod template.
Production debug guideSymptom to Action5 entries
Symptom · 01
Task pods stuck in Pending
Fix
Describe the pod and read the events: Insufficient memory or cpu means the node can't fit the request. Check requests against node capacity.
Symptom · 02
Pods OOM-killed or evicted mid-run
Fix
Check pod limits against actual memory use with kubectl top. Raise limits or cut concurrency; evictions mean the node ran out of allocatable memory.
Symptom · 03
Backfill spawns hundreds of pods instantly
Fix
Cap parallelism and backfill concurrency. KEDA scales workers on queue depth, but a runaway backfill outruns any scaling loop.
Symptom · 04
No DAGs visible in scheduler and worker pods
Fix
Check the git-sync sidecar logs and the dags.gitSync values. A bad branch or subPath leaves every pod with an empty DAG folder.
Symptom · 05
Behavior changed after a Helm upgrade
Fix
Check the chart version and diff the rendered manifests. Chart upgrades have silently changed pod templates and scheduler settings for us.
★ Quick Debug Cheat SheetCommon KubernetesExecutor issues and immediate actions
Task pods stuck in Pending
Immediate action
Read pod events
Commands
kubectl get pods -n airflow
kubectl describe pod <task-pod> -n airflow
Fix now
Raise node capacity or lower task pod requests; check node allocatable memory.
Pods evicted or OOM-killed+
Immediate action
Compare limits to real usage
Commands
kubectl top pods -n airflow
kubectl get events -n airflow --sort-by=.lastTimestamp | tail -30
Fix now
Set sane limits on task pods and cut scheduler parallelism.
Runaway backfill spawns 400 pods+
Immediate action
Stop the run and cap concurrency
Commands
kubectl get pods -n airflow | wc -l
airflow dags backfill market_etl --start-date 2026-01-01 --end-date 2026-01-03 --no-on-failure-callback
Fix now
Pause the DAG, cap parallelism and max_active_runs, then rerun with a bounded backfill.
No DAGs in scheduler pods+
Immediate action
Check the git-sync sidecar
Commands
kubectl logs -n airflow deployment/airflow-scheduler -c git-sync | tail -30
kubectl exec -n airflow deploy/airflow-scheduler -c scheduler -- ls /opt/airflow/dags
Fix now
Fix the git-sync repo, branch, or subPath in Helm values and roll the scheduler.
CeleryExecutor vs KubernetesExecutor
AspectCeleryExecutorKubernetesExecutor
Execution unitLong-lived Celery worker processesOne ephemeral pod per task instance
IsolationShared host between tasksPer-task pod: failures and memory are contained
Startup costNear zero per taskSeconds per task: scheduling and image pull
ScalingWorker fleet via --scale or autoscaleKEDA on queue depth; elastic by design
Cost modelSteady worker footprintPod-per-task; bursts are expensive, idle is cheap
Best fitSteady nightly batch, many small tasksHeavy, bursty, or heterogeneous workloads
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
install.shhelm repo add apache-airflow https://airflow.apache.org2. Helm Install of the Official Chart
market_etl.pyfrom kubernetes.client import models as k8s3. Requests and Limits
keda-scaledobject.yamlapiVersion: keda.sh/v1alpha14. KEDA
values.yamldags:6. Git-Sync DAGs on Kubernetes

Key takeaways

1
KubernetesExecutor runs one pod per task
isolation by design, cold starts as the cost.
2
Requests and limits on every task pod are non-negotiable; unbounded pods get evicted.
3
KEDA scales scheduler replicas on queue depth, following real backlog.
4
Pin the Helm chart version; unpinned upgrades rewrite pod templates silently.
5
Git-sync delivers DAGs into ephemeral pods, so CI parse gates protect the fleet.

Common mistakes to avoid

4 patterns
×

Task pods without requests or limits.

Symptom
Cluster memory hits 100%, pods get OOM-killed and evicted mid-run.
Fix
Set resources.requests and resources.limits on every task pod, tuned to measured usage.
×

Running fixed scheduler replicas with no KEDA.

Symptom
Queue grows under backfill while replicas stay flat; idle replicas burn money at night.
Fix
Add a KEDA ScaledObject on the Celery queue so capacity follows backlog.
×

Flying the Helm chart unpinned.

Symptom
A minor upgrade silently rewrites the pod template; behavior changes overnight.
Fix
Pin the chart version and review the manifest diff before every upgrade.
×

High parallelism with no backfill concurrency control.

Symptom
A backfill spawns 400 pods at once and starves the nodes.
Fix
Cap parallelism and max_active_runs, and run backfills with bounded date ranges.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is KubernetesExecutor and how does it differ from CeleryExecutor?
Q02SENIOR
Why does a pod without resource limits get evicted?
Q03SENIOR
A nightly backfill spawns 400 pods onto 3 nodes and they keep getting ev...
Q01 of 03JUNIOR

What is KubernetesExecutor and how does it differ from CeleryExecutor?

ANSWER
KubernetesExecutor creates a new pod for every task instance, so each task runs in total isolation. CeleryExecutor runs tasks on long-lived Celery workers. KubernetesExecutor pays a cold-start cost per task but isolates failures and resources; Celery is cheaper for steady streams of small tasks.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is KEDA the same as Kubernetes HPA?
02
Do I still need Celery workers if I use KubernetesExecutor?
03
What happens when a task pod is evicted?
04
How do DAGs get into scheduler and worker pods?
05
Is KubernetesExecutor slower than CeleryExecutor?
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
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

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

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