DEV Community

Cover image for Google Cloud Composer: Managed Airflow on GCP — Sizing, Tuning & Gotchas
Gowtham Potureddi
Gowtham Potureddi

Posted on

Google Cloud Composer: Managed Airflow on GCP — Sizing, Tuning & Gotchas

google cloud composer is the managed Apache Airflow service on Google Cloud: it runs the exact open-source Airflow you already know, but on a GKE cluster that Google provisions, patches, and keeps alive for you. You do not install Airflow, stand up a metadata database, or babysit a scheduler process. You create an environment, drop your DAG files into a Cloud Storage bucket, and Composer runs them — while you keep the levers that actually matter: how much CPU and memory each component gets, how far the worker pool autoscales, and which Airflow config values you override.

That managed shape is a genuinely different operational contract from the two things data engineers ran before it: a self-hosted Airflow on VMs or a raw Kubernetes chart that you patch, scale, and page yourself, or a fully proprietary orchestrator that hides the engine entirely. Composer sits in the middle — real Airflow, real DAGs, real operators, but the cluster is someone else's problem. This guide walks through the four ideas an interviewer will actually probe — the environment architecture and its GCS DAG bucket, per-component sizing with worker autoscaling, tuning concurrency through Airflow config overrides, and the three classic production gotchas — and pairs each with a worked example plus a Solution-Tail interview answer: code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.

PipeCode blog header for Google Cloud Composer — bold white headline 'Cloud Composer' with subtitle 'Managed Airflow on GCP, Sizing, Tuning, Gotchas' and a stylised GKE-cluster-with-DAG-bucket scene on a dark gradient with purple, green, orange, and blue accents and a small pipecode.ai attribution.

When you want hands-on reps immediately after reading, drill the pipeline-design practice library →, rehearse the scheduling trade-offs on the scheduling practice set →, and harden your retry logic on the fault-tolerance practice set →.


On this page


1. Why Cloud Composer changes managed Airflow on GCP in 2026

Composer is managed Apache Airflow on GKE — Google owns the cluster, you own the DAGs and the sizing knobs

The one-sentence invariant: Cloud Composer runs unmodified Apache Airflow on a Google-managed GKE cluster, so orchestration becomes a service you configure rather than a stack you operate. Everything that makes Composer attractive to a data platform team follows from that split. There is no scheduler to keep alive, no Postgres metadata DB to back up, no Kubernetes upgrade to schedule at midnight; a Composer environment is Airflow that Google runs, and your job shrinks to writing DAGs and choosing resources.

The managed split — what Google runs and what you keep.

  • Google runs the control plane. The GKE cluster, the Cloud SQL metadata database, the Redis Celery broker, the web server, Airflow upgrades, and OS patching all live in a Google-managed tenant project. You never SSH into a node.
  • You own the DAGs and config. Your DAG files, plugins, connections, variables, and Airflow configuration overrides are yours. Deploying is copying a Python file into a bucket — no image build, no helm upgrade.
  • You still own sizing and cost. Composer does not hide the resource dials. You choose CPU and memory per component, the worker autoscaling range, the number of schedulers, and the environment size. Get these wrong and you either overpay or throttle your pipelines.

Where Composer sits against the alternatives.

  • vs self-hosted Airflow on VMs/GKE. Rolling your own gives maximum control and minimum bill-per-hour, but you own upgrades, HA, the metadata DB, and every 2am scheduler crash. Composer trades a management premium for never touching that layer.
  • vs Composer 1. Composer 1 pinned you to a fixed pool of GKE nodes you sized by machine type — you paid for idle capacity. Composer 2 runs on GKE Autopilot and autoscales workers independently, so you pay closer to what actually runs.
  • vs a proprietary orchestrator (Dataflow-only, vendor schedulers). Those hide the engine; Composer is real Airflow, so your DAGs, operators, and the whole provider ecosystem port in and out without a rewrite.

What interviewers listen for.

  • Do you say "Composer is managed Airflow on GKE, not a different orchestrator" in the first sentence? — senior signal.
  • Do you know Composer 2 = GKE Autopilot + independent worker autoscaling, and can contrast it with Composer 1's fixed nodes? — required framing.
  • Do you reach for the environment bucket as the deploy mechanism ("I copy the DAG to gs://…/dags/") rather than describing a container build? — the whole point.
  • Do you name the sizing and concurrency knobs — per-component CPU/mem, worker min/max, parallelism, worker_concurrency — as things you tune, not things Google decides? — senior signal.

Worked example — create an environment and deploy one DAG

Detailed explanation. The canonical Composer "hello world" is two commands: create an environment, then copy a DAG into its bucket. It looks trivial, and that is the point — the same two-command shape that deploys a toy DAG deploys your production fleet, because Composer only ever sees a folder of Python files. Google provisions the GKE cluster, the metadata DB, and the scheduler; you provide the DAG.

Question. Stand up a Composer 2 environment named data-prod in us-central1 and deploy a DAG file hello.py, without building any container image.

Input.

artifact value
environment name data-prod
location us-central1
DAG file hello.py (a single @dag definition)

Code.

  # 1. Create a Composer 2 environment (GKE Autopilot under the hood)
gcloud composer environments create data-prod \
  --location us-central1 \
  --image-version composer-2.9.1-airflow-2.9.3

  # 2. Deploy a DAG: copy it into the environment's dags/ folder
gcloud composer environments storage dags import \
  --environment data-prod \
  --location us-central1 \
  --source hello.py
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. environments create provisions the whole managed stack — a GKE Autopilot cluster, a Cloud SQL metadata DB, a Redis broker, a scheduler, a web server, and a starter worker pool — and returns when Airflow is serving. --image-version pins both the Composer build and the Airflow version so upgrades are explicit. storage dags import copies hello.py into the environment's Cloud Storage bucket under dags/; Composer's gcsfuse sync mounts that folder into the scheduler and worker pods, so the DAG appears in the UI within seconds — no image rebuild, no redeploy.

Output.

Composer created value
GKE cluster Autopilot, in a Google-managed tenant project
metadata DB Cloud SQL (Postgres), Google-managed
environment bucket gs://<region>-data-prod-<hash>-bucket/dags/
DAG status hello visible in the Airflow UI, ready to schedule

Rule of thumb. If your orchestration can be expressed as "a folder of DAG files," Composer can run it — the toy environment and the production fleet differ only in sizing and the number of DAGs, never in the deploy mechanism.


2. Environment architecture & the GCS DAG bucket

A Composer environment is a GKE cluster plus a Cloud SQL DB plus a GCS bucket — learn those three planes and the rest is configuration

Cloud Composer has exactly three moving planes you reason about, and an interviewer who asks "walk me through a Composer environment" wants these three in order. Get the vocabulary crisp and the whole service snaps into focus.

The three planes.

  • Compute — the GKE cluster. Every Airflow component runs as a pod on a Google-managed GKE Autopilot cluster: the scheduler(s), the worker pool (Celery executor), the web server, and the triggerer (for deferrable operators). Google runs the nodes; you size the pods.
  • State — Cloud SQL + Redis. Airflow's metadata database is a managed Cloud SQL Postgres instance holding DAG runs, task instances, connections, and variables. A Redis instance is the Celery broker that hands queued tasks to workers.
  • Code & data — the environment bucket. A dedicated Cloud Storage bucket holds dags/, plugins/, and data/ folders. gcsfuse continuously syncs those folders into the component pods, so writing a file to the bucket is how you deploy code.

The tenant / customer project split.

  • Tenant project (Google-owned). The GKE control plane, Cloud SQL, and the web server live in a project Google manages. You never see or bill it directly beyond the Composer SKU.
  • Customer project (yours). The environment bucket, the worker nodes' networking, and the service account the workers run as live in your project, so IAM, VPC, and data residency stay under your control.
  • Why the split matters. It is what lets Google patch and upgrade Airflow without touching your data, and what keeps your DAGs and connections in your own project's IAM boundary.

Why the bucket is the powerful plane.

  • Deploying a DAG is gsutil cp (or a CI/CD dags import) — no image, no restart, so iteration is seconds not minutes.
  • plugins/ ships custom operators and hooks; data/ is a shared scratch space mounted at /home/airflow/gcs/data on every worker.
  • Because the bucket is the source of truth, blue/green DAG rollouts, rollbacks, and GitOps are just object operations.

Iconographic Cloud Composer architecture diagram — a Google-managed tenant project control plane, a customer-project GKE Autopilot cluster running scheduler, workers, web server and triggerer, a Cloud SQL metadata database, a Redis broker, and a GCS environment bucket with dags plugins data folders syncing into the pods via gcsfuse.

Worked example — the folders in the environment bucket

Detailed explanation. Real environments are driven entirely by three folders in one bucket. Here we map what each folder does and how a file lands in a pod. Understanding this is what lets you reason about "why is my DAG not showing up" or "where do I put a shared config file."

Question. Your environment bucket is gs://us-central1-data-prod-ab12-bucket. Show where a DAG, a custom operator, and a shared lookup CSV each go, and where they appear inside a worker pod.

Input. Three files to deploy: sales_dag.py, a plugins/ operator bq_export.py, and a country_lookup.csv.

Code.

  # DAG code -> dags/ folder
gsutil cp sales_dag.py \
  gs://us-central1-data-prod-ab12-bucket/dags/

  # Custom operator/plugin -> plugins/ folder
gsutil cp bq_export.py \
  gs://us-central1-data-prod-ab12-bucket/plugins/

  # Shared data file -> data/ folder (mounted, not on PYTHONPATH)
gsutil cp country_lookup.csv \
  gs://us-central1-data-prod-ab12-bucket/data/
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. Composer mounts the bucket into every component pod under /home/airflow/gcs/. A file in dags/ becomes /home/airflow/gcs/dags/sales_dag.py and is added to Airflow's DAG search path, so the scheduler parses it. A file in plugins/ lands in /home/airflow/gcs/plugins/ and is on the Python path, so from bq_export import BqExportOperator resolves. A file in data/ lands in /home/airflow/gcs/data/ and is readable by tasks but not imported as code — the right place for lookups and outputs shared across workers.

Output.

bucket path pod path role
dags/sales_dag.py /home/airflow/gcs/dags/sales_dag.py parsed as a DAG
plugins/bq_export.py /home/airflow/gcs/plugins/bq_export.py importable operator
data/country_lookup.csv /home/airflow/gcs/data/country_lookup.csv shared read/write data

Rule of thumb. Code that Airflow must import goes in dags/ or plugins/; anything a task merely reads or writes goes in data/. If you put a big CSV in dags/, you slow every DAG parse for no reason.

Cloud Composer interview question on the environment architecture

Question. An interviewer says: "A teammate pushed a new DAG to the bucket 20 seconds ago and it is not in the UI yet, and another DAG that references a helper module fails with ModuleNotFoundError. Explain the architecture that produces both symptoms and where each file should live." Walk through it.

Solution Using the bucket-to-pod sync model

Code.

  # Inspect what Composer actually mounted into the pods
gcloud composer environments storage dags list \
  --environment data-prod --location us-central1

  # Helper module belongs on the import path, not in data/
gsutil cp helpers/transform.py \
  gs://us-central1-data-prod-ab12-bucket/dags/helpers/transform.py
  # then in the DAG:  from helpers.transform import clean
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

symptom root cause fix
new DAG missing for ~seconds gcsfuse sync + scheduler parse interval wait for dag_dir_list_interval; it is eventual
ModuleNotFoundError on helper helper placed in data/ (not importable) move helper under dags/ or plugins/
helper still failing after move worker pod not yet resynced next sync tick mounts it into all pods
  1. The environment bucket is mounted, not pushed instantly — gcsfuse propagates bucket writes into pods within seconds, and the scheduler only re-lists the DAG folder every dag_dir_list_interval, so a brand-new DAG is visible eventually, not atomically.
  2. dags/ and plugins/ are on the Python import path; data/ is not. A helper module dropped in data/ is present on disk but un-importable, which is exactly ModuleNotFoundError.
  3. Moving the helper under dags/helpers/ (a package) puts it on the path; the next sync tick mounts it into the scheduler and every worker.
  4. Both symptoms are the same architecture seen from two angles: the bucket is the source of truth, and pods see it through an eventually-consistent mount.

Output:

file correct location outcome
new DAG dags/ appears after next sync + parse tick
helper module dags/helpers/ or plugins/ importable, no ModuleNotFoundError

Why this works — concept by concept:

  • Bucket as source of truth — deploy is an object write, so rollouts and rollbacks are just gsutil operations with no image build.
  • gcsfuse mount — the bucket is a mounted filesystem in each pod, which is why propagation is fast but not instantaneous and why huge files in dags/ hurt.
  • Import path vs data pathdags/ and plugins/ are on PYTHONPATH; data/ is scratch, so where a file lives decides whether Python can import it.
  • Eventual visibility — the scheduler re-scans on an interval, so "not in the UI yet" is normal for the first few seconds after a push, not a bug.
  • Cost — sync and parse are O(files in dags/) per interval, so keeping the folder small and free of heavy assets keeps every parse cheap.

Pipelines
Topic — pipelines
Pipeline-deploy and DAG-structure problems

Practice →

Scheduling Topic — scheduling DAG scheduling and interval problems

Practice →


3. Environment sizing — scheduler, web & worker autoscaling

Composer 2 sizes every component independently and autoscales workers — get the min/max range right and the bill and the throughput both fall into line

The feature that sells Composer 2 to a data engineer is that you set CPU and memory per component and let the worker pool autoscale between a floor and a ceiling, instead of paying for a fixed node pool that idles all night. This is the difference between a bill that tracks load and one that tracks your worst-case guess. The trade-off: get the min too low and morning jobs queue; get the max too low and a backfill never catches up.

The knobs Composer 2 exposes.

  • Per-component resources. Scheduler, web server, and worker each get their own CPU, memory (GB), and storage (GB). A parse-heavy environment wants a bigger scheduler; a memory-heavy transform wants bigger workers.
  • Worker autoscaling range. Workers scale between worker.min_count and worker.max_count. Composer watches the number of queued + running Celery tasks and adds or removes worker pods to keep up, scaling to min_count (often 1) when idle.
  • Scheduler count. You can run more than one scheduler (scheduler.count) for HA and faster DAG parsing; more schedulers parse the DAG folder in parallel and reduce task-scheduling latency.
  • Environment size preset. --environment-size (Small / Medium / Large) sets sane defaults for the Cloud SQL tier and Airflow database throughput; you then override individual component resources on top.

How autoscaling actually decides.

  • The target is driven by task backlog: if queued tasks exceed the slots the current workers expose, Composer adds a worker; when tasks drain, it removes workers down to min_count.
  • Each worker exposes worker_concurrency task slots (covered next section), so total capacity = worker_count × worker_concurrency, and autoscaling moves worker_count within your range.
  • Scale-up is not instant — a new Autopilot worker pod takes tens of seconds to become schedulable, so a min_count of 1 means the first burst of the day waits for warm-up.

Sizing failure modes interviewers probe.

  • Under-sized scheduler. Too little scheduler CPU/mem plus many DAGs means slow parsing and late task scheduling, even when workers are idle.
  • Under-sized worker memory. A task that loads a big DataFrame will OOM the worker if worker.memory is small and worker_concurrency is high — the two multiply.
  • min_count = 0 misconception. Composer keeps at least one worker; you cannot scale workers to zero, so idle cost has a floor.

Iconographic Cloud Composer sizing diagram — per-component CPU and memory cards for scheduler, web server and worker, a worker autoscaling track from min_count to max_count driven by queued plus running tasks, and Small, Medium, Large environment-size presets.

Worked example — set worker resources and an autoscaling range

Detailed explanation. The everyday sizing operation is updating a running environment's worker resources and its min/max range. Composer applies the change with a rolling update — no environment recreate. Here we give workers more memory and a 2–8 range so a nightly backfill can fan out and then scale back down.

Question. Your nightly backfill queues ~40 tasks at 02:00 and each task needs ~3 GB. Configure workers with 4 GB memory and let the pool scale from 2 to 8, so the backlog clears without OOM.

Input.

setting before target
worker memory 2 GB 4 GB
worker min_count 1 2
worker max_count 3 8

Code.

gcloud composer environments update data-prod \
  --location us-central1 \
  --update-airflow-worker-memory 4 \
  --update-airflow-worker-cpu 2 \
  --min-workers 2 \
  --max-workers 8
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. --update-airflow-worker-memory 4 raises each worker pod to 4 GB so a 3 GB task fits with headroom. --min-workers 2 keeps two warm workers so the 02:00 burst does not wait on a cold-start from a single pod. --max-workers 8 lets Composer add pods as the queued-task count climbs; with backlog present it scales toward 8, clears the 40 tasks, then drains back to 2 when the queue empties. Composer performs a rolling update on the GKE deployment, so running tasks are not killed.

Output.

phase worker_count behaviour
idle (daytime) 2 warm floor, low cost
02:00 backfill burst scales 2 → 8 backlog drains in parallel
post-backfill scales 8 → 2 pods removed, cost falls

Rule of thumb. Size worker.memory for your heaviest concurrent task, and set min_count high enough that your most latency-sensitive schedule does not wait on a cold worker — everything above that, let autoscaling handle.

Cloud Composer interview question on sizing

Question. Two environments run the same DAGs. Environment A has one scheduler and a 1..3 worker range; Environment B has two schedulers and a 2..12 range. At 09:00 both get a 200-task burst. A is late and B is not — but B costs more all day. How do you reason about which knob fixed the lateness and how to control B's idle cost?

Solution Using scheduler count plus a tuned worker range

Code.

  # Environment B: two schedulers for parse/schedule throughput,
  # a higher ceiling for the burst, a modest floor for idle cost
gcloud composer environments update env-b \
  --location us-central1 \
  --scheduler-count 2 \
  --min-workers 2 \
  --max-workers 12 \
  --update-airflow-scheduler-cpu 4 \
  --update-airflow-scheduler-memory 8
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

factor Environment A Environment B
scheduler count 1 2 → faster to schedule 200 tasks
worker ceiling 3 12 → 4x parallel execution
worker floor 1 2 → no cold-start on burst
idle cost driver low the min_count=2 floor + scheduler size
  1. Scheduling the 200 tasks is the scheduler's job: B's two schedulers parse and enqueue tasks faster, so tasks reach the queue sooner.
  2. Executing them is the workers' job: B's max=12 gives far more parallel slots, so the queue drains quickly instead of trickling through 3 workers.
  3. B's lateness fix was therefore two knobs — more schedulers (enqueue faster) and a higher worker ceiling (execute faster), not one.
  4. B's idle cost comes from the min_count=2 floor and the larger scheduler; lowering min_count toward 1 and trimming scheduler size off-peak reduces the all-day bill without touching the burst ceiling.

Output:

environment 09:00 burst outcome idle cost
A late (scheduler + ceiling bound) low
B (tuned) on time controllable via floor

Why this works — concept by concept:

  • Scheduler vs worker — the scheduler decides when a task runs, the worker decides how many run at once; latency and throughput are two different knobs.
  • Autoscaling rangemax_count sets peak throughput, min_count sets warm capacity and the idle-cost floor, so you tune burst and baseline separately.
  • Cold-start — a new Autopilot worker takes tens of seconds, so a higher min_count buys burst latency at the price of steady cost.
  • Right knob for the symptom — "late to schedule" points at scheduler count/size; "late to finish" points at the worker ceiling; naming the correct one is the senior signal.
  • Cost — steady spend is O(min_count × worker size + scheduler size); burst spend is O(peak worker_count) only while the backlog exists.

Capacity
Topic — capacity-planning
Capacity-planning and autoscaling problems

Practice →

Pipelines Topic — pipelines Pipeline throughput and sizing problems

Practice →


4. Tuning concurrency, config overrides & GCP operators

Three limits gate how many tasks run — env-wide parallelism, per-DAG max_active_tasks, and per-worker worker_concurrency — and you set them with Airflow config overrides

Once the workers are sized, throughput is gated by a stack of concurrency limits, and an interviewer who asks "why are only 16 tasks running when I have 8 workers?" wants you to name the limit that binds. Say it in one breath: parallelism caps the whole environment, max_active_tasks_per_dag caps one DAG, and worker_concurrency is the slot count on each worker — the smallest ceiling wins.

The three concurrency limits.

  • parallelism ([core]). The hard cap on task instances running across the entire environment at once. Nothing runs above it no matter how many workers you add.
  • max_active_tasks_per_dag ([core], was dag_concurrency). The cap on concurrently running tasks within a single DAG, so one wide DAG cannot starve every other DAG. max_active_runs_per_dag similarly caps concurrent runs of the same DAG.
  • worker_concurrency ([celery]). The number of task slots one worker pod exposes. Total execution capacity is worker_count × worker_concurrency, but it is still clamped by parallelism.

Setting them: Airflow config overrides.

  • Composer exposes Airflow's airflow.cfg as config overrides keyed by section-key. You never edit a file; you pass overrides to the environment and Composer applies them with a rolling restart.
  • Overrides are the supported way to tune parallelism, worker_concurrency, dagbag_import_timeout, dag_dir_list_interval, email/SMTP, and more. Some keys are Composer-blocked (it manages them), and it will reject those.
  • Because overrides restart components, batch them — do not push one override at a time during business hours.

Connecting to GCP: operators + service-account IAM.

  • Composer ships the Google provider, so BigQueryInsertJobOperator, GCSToBigQueryOperator, DataprocSubmitJobOperator, and friends are available out of the box.
  • Workers run as a service account; via Workload Identity the pods impersonate that GCP service account, so no key files are needed. You grant that SA IAM roles (roles/bigquery.dataEditor, roles/storage.objectAdmin) and the operators authenticate transparently.
  • Least privilege matters: give the worker SA only the roles its DAGs need, and use per-connection service accounts for cross-project access rather than one god-account.

Iconographic Cloud Composer concurrency diagram — an env-wide parallelism funnel, per-DAG max_active_tasks and max_active_runs cards, worker_concurrency slot grids on two worker pods with a queued overflow, and an Airflow config-override panel for the core and celery sections feeding the components.

Worked example — override concurrency and wire a BigQuery operator

Detailed explanation. The everyday tuning operation is raising throughput with config overrides and running a GCP operator that authenticates via the worker service account. Here we lift parallelism and worker_concurrency, then run a BigQuery load that needs no key file.

Question. Raise the environment to allow 48 concurrent tasks with 6 slots per worker, and run a BigQueryInsertJobOperator that authenticates as the worker service account.

Input.

override value
core-parallelism 48
celery-worker_concurrency 6
worker SA role roles/bigquery.dataEditor

Code.

  # Apply Airflow config overrides (rolling restart of components)
gcloud composer environments update data-prod \
  --location us-central1 \
  --update-airflow-configs=core-parallelism=48,celery-worker_concurrency=6

  # Grant the worker service account BigQuery write access (one-time)
gcloud projects add-iam-policy-binding my-proj \
  --member "serviceAccount:composer-worker@my-proj.iam.gserviceaccount.com" \
  --role roles/bigquery.dataEditor
Enter fullscreen mode Exit fullscreen mode
from airflow.providers.google.cloud.operators.bigquery import BigQueryInsertJobOperator

load = BigQueryInsertJobOperator(
    task_id="load_daily",
    configuration={"query": {
        "query": "INSERT INTO analytics.daily SELECT * FROM staging.raw",
        "useLegacySql": False,
    }},
    # no key file: Workload Identity impersonates the worker SA
)
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. --update-airflow-configs sets parallelism=48 and worker_concurrency=6; with an 8-worker ceiling, execution capacity is 8 × 6 = 48, matched to parallelism, so nothing is left on the table. The IAM binding grants the worker SA BigQuery write. When the operator runs, the worker pod uses Workload Identity to act as composer-worker@…, so the BigQuery job authenticates with no JSON key. Composer applies the config change with a rolling restart, so in-flight tasks finish.

Output.

lever before after
env-wide running tasks 32 48
slots per worker 4 6
BigQuery auth key file Workload Identity SA

Rule of thumb. Set parallelism to max_workers × worker_concurrency so the env cap and the worker slots agree; if parallelism is lower, you paid for worker slots you can never fill.

Cloud Composer interview question on the concurrency ceiling

Question. An engineer set worker_concurrency=8 and max_workers=10 expecting 80 concurrent tasks, but never sees more than 32 running. parallelism is 32 and one DAG has max_active_tasks=16. Explain what caps them and how to raise real throughput to 80.

Solution Using the smallest-ceiling-wins rule

Code.

  # The binding limit is env-wide parallelism, not the worker math
gcloud composer environments update data-prod \
  --location us-central1 \
  --update-airflow-configs=core-parallelism=80

  # And lift the per-DAG cap if a single DAG must fan out wide
  #   in the DAG:  @dag(max_active_tasks=40, max_active_runs=2)
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

limit value is it the binder?
worker math (8 × 10) 80 no — this is only the ceiling capacity
parallelism 32 yes — caps the whole env at 32
max_active_tasks_per_dag 16 binds that DAG to 16
result observed 32 = min(80, 32)
  1. Total worker capacity is worker_concurrency × worker_count = 80, but that is only an upper bound on slots, not a guarantee tasks run.
  2. parallelism=32 is an environment-wide hard cap, so at most 32 task instances run regardless of the 80 available slots — the smallest ceiling wins.
  3. Even after raising parallelism to 80, a single DAG with max_active_tasks=16 still tops out at 16 tasks from that DAG; you must raise the per-DAG cap for one wide DAG to fill the env.
  4. Raising parallelism to 80 and the per-DAG cap to ~40 lets the workers' 80 slots actually fill, provided enough runnable tasks exist.

Output:

after change running tasks
parallelism=80 only up to 80 across DAGs, but 16 per that DAG
parallelism=80 + max_active_tasks=40 that DAG can push ~40, env fills toward 80

Why this works — concept by concept:

  • Smallest ceiling wins — effective concurrency is min(parallelism, per-DAG cap, worker slots); naming the binding one is the whole skill.
  • parallelism is env-wide — it protects the metadata DB and cluster from overload, so it caps everything, not one DAG.
  • Per-DAG capsmax_active_tasks_per_dag fences one DAG so it cannot starve others; a wide DAG needs its own higher cap.
  • Worker slots are capacity, not a guaranteeworker_count × worker_concurrency is the most that could run; the Airflow caps decide what does.
  • Cost — raising ceilings is O(1) config, but real throughput is bounded by worker memory and the Cloud SQL DB's ability to track task state, so scale ceilings and worker size together.

Concurrency
Topic — concurrency
Concurrency-limit and throughput problems

Practice →

Scheduling Topic — scheduling Task-scheduling and DAG-limit problems

Practice →


5. Common gotchas — DAG parse, worker OOM, GCS sync latency

Three failure modes cause most Composer incidents — slow DAG parse, worker OOM, and GCS sync latency — and each has a named fix

Every seasoned Composer operator has been paged by the same three things, and an interviewer asking "what breaks in production?" wants you to name them and their fixes without hesitation. Say it in one breath: heavy top-level code slows DAG parse, over-packed workers OOM, and the bucket mount lags — expect each, and design for it.

Gotcha 1 — slow DAG parse.

  • Cause. Code at the top level of a DAG file runs on every parse, not just at task execution: an API call, a large import, or a DB query in module scope multiplies across all your DAGs and starves the scheduler.
  • Symptoms. DAGs appear late, tasks schedule with lag, the scheduler CPU pins. dagbag_import_timeout errors show a file that takes too long to parse.
  • Fixes. Move all real work inside task callables; keep the top level to imports and operator wiring. Raise min_file_process_interval so the scheduler re-parses less often, and dag_dir_list_interval so it re-lists the folder less often. Split monster files.

Gotcha 2 — worker OOM.

  • Cause. A task that loads a big DataFrame or pandas merge in-process, multiplied by worker_concurrency slots, exceeds worker.memory. Eight 2 GB tasks on a 8 GB worker with worker_concurrency=8 will OOM.
  • Symptoms. Tasks fail with SIGKILL / return code -9, the worker pod restarts, and Airflow may mark the task as a zombie.
  • Fixes. Lower worker_concurrency (fewer tasks share a worker's RAM) or raise worker.memory; the two multiply, so tune them together. Push heavy compute to BigQuery/Dataproc via an operator instead of doing it in-worker.

Gotcha 3 — GCS sync latency.

  • Cause. The environment bucket reaches pods via gcsfuse, which propagates writes within seconds, not instantly. A DAG or plugin you just uploaded is not visible atomically.
  • Symptoms. "I pushed it but it is not there," or a DAG that imports a just-uploaded helper fails on the first parse and succeeds on the next.
  • Fixes. Expect a few-seconds lag; in CI/CD, poll dags list until the DAG appears before asserting success. Do not tighten dag_dir_list_interval so far that parse cost explodes; treat visibility as eventual.

The reliability wrapper that ties them together.

  • Set task retries and retry_delay so a transient OOM or a not-yet-synced import self-heals on the next attempt.
  • Watch scheduler_health_check_threshold and Composer's zombie detection: a task whose worker died is marked a zombie and retried, which is why idempotent tasks matter.

Iconographic Cloud Composer gotchas diagram — three failure lanes for slow DAG parse from heavy top-level code, worker OOM from concurrency overcommit, and GCS sync latency between bucket upload and pod visibility, each lane showing the symptom and a named fix chip.

Worked example — kill a slow parse and a worker OOM at once

Detailed explanation. The clearest way to internalise the top two gotchas is to fix a DAG that commits both sins: it does real work at module scope (slow parse) and merges a huge frame in-process (OOM). The fix moves work into the task and trims concurrency.

Question. A DAG queries an API at the top level and does a 4 GB pandas merge inside one task on a worker with worker_concurrency=8 and 8 GB memory. Rewrite the shape and set the config so parse is cheap and the worker does not OOM.

Input.

problem before
API call location module top level (runs every parse)
in-task memory ~4 GB pandas merge
worker 8 GB, worker_concurrency=8

Code.

  # BAD: top-level work -> runs on every DAG parse, starves scheduler
  # rows = requests.get("https://api/x").json()      # <-- do NOT do this

  # GOOD: all work inside the task callable
from airflow.decorators import dag, task

@dag(schedule="@daily", max_active_tasks=4, catchup=False)
def sales():
    @task
    def extract():
        import requests
        return requests.get("https://api/x", timeout=30).json()  # runs at exec time
    extract()

sales()
Enter fullscreen mode Exit fullscreen mode
  # Stop the OOM: fewer tasks share a worker's RAM
gcloud composer environments update data-prod \
  --location us-central1 \
  --update-airflow-configs=celery-worker_concurrency=2 \
  --update-airflow-worker-memory 8
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. Moving the API call into the extract task means it runs only when the task executes, not on every parse tick, so the scheduler stops re-running it and parse time collapses. Dropping worker_concurrency from 8 to 2 means at most two tasks share the 8 GB worker, so a 4 GB merge fits (2 × 4 GB = 8 GB) instead of eight tasks fighting for 8 GB. Together the DAG parses fast and its heavy task no longer gets SIGKILLed.

Output.

metric before after
top-level work per parse 1 API call none
tasks sharing 8 GB worker 8 2
heavy task outcome OOM (-9) completes

Rule of thumb. Nothing but imports and wiring belongs at a DAG file's top level, and worker_concurrency × peak-task-memory must fit inside worker.memory — those two rules prevent most Composer incidents.

Cloud Composer interview question on production reliability

Question. Overnight, several tasks were marked as zombies and retried, and one DAG intermittently fails on a ModuleNotFoundError for a helper you deploy alongside it, then passes on retry. Diagnose the two gotchas and make the pipeline self-heal.

Solution Using retries plus a sync-aware deploy

Code.

from airflow.decorators import dag, task
from datetime import timedelta

default_args = {
    "retries": 3,                       # transient OOM / sync lag self-heals
    "retry_delay": timedelta(minutes=2),
}

@dag(schedule="@hourly", default_args=default_args, catchup=False)
def ingest():
    @task
    def load():
        from helpers.transform import clean   # helper lives under dags/helpers/
        return clean()
    load()

ingest()
Enter fullscreen mode Exit fullscreen mode
  # CI/CD: wait for gcsfuse sync before declaring the deploy done
gcloud composer environments storage dags import \
  --environment data-prod --location us-central1 --source dags/
until gcloud composer environments storage dags list \
        --environment data-prod --location us-central1 | grep -q ingest.py; do
  sleep 5
done
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

symptom gotcha mechanism fix
tasks marked zombie worker OOM / pod death worker died mid-task, scheduler reaped it retries + idempotent task
intermittent ModuleNotFoundError GCS sync latency helper not yet mounted on first parse wait-for-sync in deploy; retries catch the rest
passes on retry eventual consistency next parse sees the synced helper no code change needed
  1. A zombie is a task whose worker process vanished (often an OOM SIGKILL); the scheduler detects the missing heartbeat past scheduler_health_check_threshold and marks it failed, so retries are what let it recover.
  2. The intermittent import error is GCS sync latency: the helper and DAG upload near-simultaneously, and on the unlucky parse the DAG is mounted before the helper — retrying after the sync catches up succeeds.
  3. Setting retries=3 with a delay makes both self-heal: the retried task runs on a healthy worker and after the helper has synced.
  4. The CI/CD until loop makes the deploy sync-aware, so the pipeline is not asserted healthy until the file is actually visible — turning a race into a guarantee.

Output:

behaviour before after
zombie tasks fail the run retried, succeed
helper import race intermittent failure deploy waits for sync; retries cover the rest
operator burden manual reruns self-healing

Why this works — concept by concept:

  • Zombie detection — Airflow reaps tasks whose worker heartbeat stops, so a dead-worker task fails cleanly and retries restart it on a live worker.
  • Idempotent retries — retries only help if re-running the task is safe, which is why load tasks should be re-runnable without duplicating data.
  • Eventual visibility — gcsfuse propagation makes a fresh upload visible within seconds, so a sync-aware deploy plus retries removes the race entirely.
  • Right fix per gotcha — OOM zombies want lower concurrency or more memory and retries; sync races want a wait-for-sync deploy; naming the correct pairing is the senior signal.
  • Cost — retries add O(failed attempts) extra runtime, cheap insurance against transient infra faults compared with a paged on-call engineer.

Fault tolerance
Topic — fault-tolerance
Retry, zombie and self-healing problems

Practice →

Reliability Topic — reliability Pipeline-reliability and recovery problems

Practice →


Cheat sheet — Cloud Composer recipes

Create a Composer 2 environment.

gcloud composer environments create data-prod \
  --location us-central1 \
  --image-version composer-2.9.1-airflow-2.9.3 \
  --environment-size medium
Enter fullscreen mode Exit fullscreen mode

Deploy a DAG (no image build).

gcloud composer environments storage dags import \
  --environment data-prod --location us-central1 --source my_dag.py
Enter fullscreen mode Exit fullscreen mode

Override an Airflow config (rolling restart).

gcloud composer environments update data-prod --location us-central1 \
  --update-airflow-configs=core-parallelism=48,celery-worker_concurrency=6
Enter fullscreen mode Exit fullscreen mode

Set worker autoscaling + resources.

gcloud composer environments update data-prod --location us-central1 \
  --min-workers 2 --max-workers 8 \
  --update-airflow-worker-memory 4 --update-airflow-worker-cpu 2
Enter fullscreen mode Exit fullscreen mode

Grant the worker service account BigQuery access (Workload Identity).

gcloud projects add-iam-policy-binding my-proj \
  --member "serviceAccount:composer-worker@my-proj.iam.gserviceaccount.com" \
  --role roles/bigquery.dataEditor
Enter fullscreen mode Exit fullscreen mode

Keep top-level code cheap (avoid slow parse).

@dag(schedule="@daily", catchup=False)      # wiring only at top level
def p():
    @task
    def work():
        import heavy_lib                     # import inside the task
        return heavy_lib.run()
    work()
p()
Enter fullscreen mode Exit fullscreen mode

Concurrency picker.

Limit Scope Set via
parallelism whole environment core-parallelism override
max_active_tasks_per_dag one DAG's tasks @dag(max_active_tasks=...)
max_active_runs_per_dag one DAG's runs @dag(max_active_runs=...)
worker_concurrency slots per worker pod celery-worker_concurrency override

Frequently asked questions

What is Google Cloud Composer?

Google Cloud Composer is a fully managed workflow orchestration service built on Apache Airflow and running on Google Kubernetes Engine. You create an environment and Google provisions the GKE cluster, the Cloud SQL metadata database, the Redis broker, the scheduler, and the web server; you supply DAG files by copying them into a Cloud Storage bucket. It is the same open-source Airflow — same DAGs, operators, and providers — but Google runs the cluster, patches it, and upgrades Airflow for you.

How is Composer 2 different from Composer 1?

Composer 1 ran on a fixed pool of GKE Compute Engine nodes that you sized by machine type and paid for even when idle. Composer 2 runs on GKE Autopilot and autoscales the Airflow workers independently between a min_count and max_count based on the queued-plus-running task backlog, so you pay much closer to actual usage. Composer 2 also lets you set CPU, memory, and storage per component (scheduler, web server, worker) and run multiple schedulers for higher parse throughput.

How do I size a Cloud Composer environment?

Start from an --environment-size preset (Small / Medium / Large), then override the per-component resources. Size worker.memory for your heaviest concurrent task (remember it multiplies by worker_concurrency), set the worker min_count high enough that latency-sensitive schedules do not wait on a cold worker, and set max_count high enough to clear your biggest burst. If DAGs schedule late while workers sit idle, the scheduler is under-sized — add scheduler CPU/memory or a second scheduler rather than more workers.

What is the difference between parallelism and worker_concurrency?

parallelism is an environment-wide cap on how many task instances run at once across all DAGs; worker_concurrency is the number of task slots a single worker pod exposes. Total execution capacity is worker_count × worker_concurrency, but it is still clamped by parallelism and by each DAG's max_active_tasks_per_dag — the smallest ceiling wins. To actually get N concurrent tasks you must raise all the limits that bind, not just the worker math.

Why is my DAG slow to appear in Composer?

The environment bucket is mounted into the pods via gcsfuse, which propagates a new file within seconds rather than instantly, and the scheduler only re-lists the DAG folder every dag_dir_list_interval. So a freshly uploaded DAG is visible eventually, not atomically — a few-seconds delay is normal. If it never appears, check that the file is under dags/ (not data/), that it parses without error, and that no top-level code is exceeding dagbag_import_timeout.

How does Composer authenticate to BigQuery and GCS?

Composer worker pods run as a Google service account and use Workload Identity to impersonate it, so GCP operators such as BigQueryInsertJobOperator and GCSToBigQueryOperator authenticate with no key files. You grant that worker service account the IAM roles its DAGs need (for example roles/bigquery.dataEditor or roles/storage.objectAdmin) following least privilege, and use per-connection service accounts for cross-project access rather than one over-privileged account.

Practice on PipeCode

Pipecode.ai is Leetcode for Data Engineering — every Cloud Composer idea above, from sizing the worker autoscaling range to reasoning about the smallest-ceiling-wins concurrency stack and self-healing zombie tasks with retries, maps to a hands-on practice room where you build the pipeline against real graded inputs. PipeCode pairs each reading with 450+ DE-focused problems and a real-time scoring engine, so your answer to "how would you tune and harden a managed-Airflow environment?" holds up under a senior interviewer's depth probes.

Practice pipeline problems now →
Scheduling drills →

Top comments (0)