Airflow стек: webserver + scheduler + Postgres
Каждый Junior Data Engineer рано или поздно открывает свой первый Airflow. И почти всегда это происходит в Docker — потому что официальная установка Airflow на голую машину означает Python virtualenv, миграции БД, конфиги, демоны и десятки точек, где что-то пойдёт не так. С compose-стендом ты получаешь рабочий Airflow за две команды: docker compose up -d и open http://localhost:8080.
В этом уроке мы соберём пошагово реальный compose.yml с Airflow 2.10 на LocalExecutor + Postgres. LocalExecutor — простой стартовый вариант: scheduler сам запускает таски через subprocess’ы в том же контейнере. Никаких Celery, Redis, отдельных worker’ов — это потом, когда DAG’ов станет много.
Что такое Kafka — messaging, streaming, log
Какие сервисы нужны Airflow
Минимум для работы Airflow с LocalExecutor — это три сервиса:
- postgres — metadata DB. Airflow хранит здесь определения DAG’ов, runs, taskinstances, XCom’ы, connections, variables. Без БД Airflow не запустится.
- airflow-init — одноразовый init-контейнер. Запускается, делает
airflow db migrate, создаёт admin-юзера и выходит. Без него scheduler упадёт с ошибкой “no tables”. - airflow-webserver — Flask-app на порту 8080. UI, REST API. Smотрит DAG’и из общего volume и читает их статусы из БД.
- airflow-scheduler — главный процесс. Парсит DAG-файлы, ставит таски в очередь, запускает их (LocalExecutor — через subprocess). Тоже смотрит DAG’и из общего volume.
Для CeleryExecutor добавились бы Redis (broker) и airflow-worker (один или больше). Но для LocalExecutor — всё. Junior начинает именно с этого варианта.
Шаг 1: Postgres как metadata DB
Начнём с самого нижнего слоя — БД. Airflow поддерживает Postgres, MySQL и SQLite (только для dev). Production-выбор — Postgres.
services:
postgres:
image: postgres:16
container_name: airflow-postgres
environment:
POSTGRES_USER: airflow
POSTGRES_PASSWORD: airflow
POSTGRES_DB: airflow
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "airflow"]
interval: 5s
timeout: 3s
retries: 10
networks:
- airflow-net
volumes:
pg-data:
networks:
airflow-net:
Что здесь важно:
POSTGRES_USER/PASSWORD/DB— все три задают одинаково (airflow). Это будет подставлено в connection string ниже.pg-datanamed volume — данные БД переживаютdocker compose down. Без него каждый рестарт обнуляет БД и нужно заново мигрировать + создавать админа.- healthcheck с pg_isready — Postgres считается готовым только когда принимает соединения.
airflow-initбудет ждать этого статуса передdb migrate. - dedicated network — изолируем стек от других compose-проектов. Сервисы внутри сети находят друг друга по имени (
postgres,airflow-webserver).
Шаг 2: общий блок переменных для Airflow
У airflow-init, airflow-webserver и airflow-scheduler одинаковый image и одинаковые env-переменные. Чтобы не дублировать YAML, используем YAML anchors (это валидный синтаксис compose):
x-airflow-common: &airflow-common
image: apache/airflow:2.10.3-python3.11
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
AIRFLOW__CORE__LOAD_EXAMPLES: 'false'
AIRFLOW__WEBSERVER__SECRET_KEY: 'change-me-in-prod'
AIRFLOW__CORE__FERNET_KEY: 'change-me-in-prod-32bytes-base64'
AIRFLOW_UID: '50000'
volumes:
- ./dags:/opt/airflow/dags
- ./logs:/opt/airflow/logs
- ./plugins:/opt/airflow/plugins
networks:
- airflow-net
depends_on:
postgres:
condition: service_healthy
Разберём ключевые переменные:
AIRFLOW__CORE__EXECUTOR— какой executor использовать.LocalExecutor— scheduler сам запускает таски в subprocess’ах.CeleryExecutor— таски идут в Redis, worker’ы их забирают.AIRFLOW__DATABASE__SQL_ALCHEMY_CONN— connection string к metadata DB. Формат:postgresql+psycopg2://user:pass@host/dbname. Хостpostgres— это имя compose-сервиса (DNS внутри сети).AIRFLOW__CORE__LOAD_EXAMPLES: 'false'— отключаем демо-DAG’и. Иначе UI будет завален десяткамиexample_*DAG’ов.AIRFLOW__WEBSERVER__SECRET_KEY— для подписи сессий webserver’а. В проде — длинная случайная строка из секрет-стора. Для dev — любая.AIRFLOW__CORE__FERNET_KEY— для шифрования connections и variables в БД. Тоже случайная строка, 32 байта в base64. Генерится так:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())".AIRFLOW_UID: '50000'— UID пользователя внутри контейнера. Совпадает с UID-ом owner’а файлов ./dags на хосте, иначе scheduler не сможет читать DAG’и.
ВНИМАНИЕ: format переменных Airflow — AIRFLOW__SECTION__KEY (двойное подчёркивание). Это маппинг на airflow.cfg: секция [core] ключ executor -> AIRFLOW__CORE__EXECUTOR. Одинарное подчёркивание не сработает.
Шаг 3: init-контейнер
Init выполняет миграции и создаёт admin-пользователя. Запускается один раз — после первого up его можно не трогать.
services:
airflow-init:
<<: *airflow-common
container_name: airflow-init
entrypoint: /bin/bash
command:
- -c
- |
airflow db migrate &&
airflow users create \
--username admin \
--password admin \
--firstname Admin \
--lastname User \
--role Admin \
--email [email protected]
restart: 'no'
Логика:
airflow db migrate— Alembic-миграции. Создаёт все нужные таблицы (dag,dag_run,task_instance,xcom, …).airflow users create— добавляет admin/admin. В проде так делать нельзя — используй секреты или OIDC.restart: 'no'— init-контейнер не должен перезапускаться. Он отработал и вышел.
<<: *airflow-common — YAML-конструкция merge: подставляет все ключи из anchor (image, environment, volumes, depends_on).
Шаг 4: webserver и scheduler
services:
airflow-webserver:
<<: *airflow-common
container_name: airflow-webserver
command: webserver
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
airflow-scheduler:
<<: *airflow-common
container_name: airflow-scheduler
command: scheduler
healthcheck:
test: ["CMD", "airflow", "jobs", "check", "--job-type", "SchedulerJob", "--hostname", "$$(hostname)"]
interval: 30s
timeout: 10s
retries: 5
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
Ключевое:
command: webservervscommand: scheduler— один и тот же образ запускает разные процессы. Это передаётся в entrypoint Airflow.depends_on: airflow-init: condition: service_completed_successfully— webserver и scheduler стартуют только после успешного выхода init. Compose v2 умеет это нативно.healthcheckдля webserver — curl на/health. Возвращает 200, если DB-connection и scheduler heartbeat в порядке.healthcheckдля scheduler — проверяет, что есть свежий heartbeat в БД.$$экранирует$в compose-YAML (иначе compose попытается подставить переменную окружения).
Шаг 5: полный compose.yml
Соберём всё вместе:
x-airflow-common: &airflow-common
image: apache/airflow:2.10.3-python3.11
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
AIRFLOW__CORE__LOAD_EXAMPLES: 'false'
AIRFLOW__WEBSERVER__SECRET_KEY: 'change-me-in-prod'
AIRFLOW__CORE__FERNET_KEY: 'change-me-in-prod-32bytes-base64'
AIRFLOW_UID: '50000'
volumes:
- ./dags:/opt/airflow/dags
- ./logs:/opt/airflow/logs
- ./plugins:/opt/airflow/plugins
networks:
- airflow-net
depends_on:
postgres:
condition: service_healthy
services:
postgres:
image: postgres:16
container_name: airflow-postgres
environment:
POSTGRES_USER: airflow
POSTGRES_PASSWORD: airflow
POSTGRES_DB: airflow
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "airflow"]
interval: 5s
retries: 10
networks:
- airflow-net
airflow-init:
<<: *airflow-common
container_name: airflow-init
entrypoint: /bin/bash
command:
- -c
- |
airflow db migrate &&
airflow users create --username admin --password admin \
--firstname Admin --lastname User --role Admin \
--email [email protected]
restart: 'no'
airflow-webserver:
<<: *airflow-common
container_name: airflow-webserver
command: webserver
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
retries: 5
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
airflow-scheduler:
<<: *airflow-common
container_name: airflow-scheduler
command: scheduler
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
volumes:
pg-data:
networks:
airflow-net:
Подготовка хост-директорий
Перед первым запуском нужно создать локальные папки с правильными правами:
mkdir -p ./dags ./logs ./plugins
# Linux: chown под UID 50000 (airflow user в контейнере)
sudo chown -R 50000:0 ./dags ./logs ./plugins
# macOS / Windows WSL: достаточно chmod
chmod -R 775 ./dags ./logs ./plugins
На Linux важен chown 50000:0 — иначе scheduler внутри контейнера (UID 50000) не сможет писать в ./logs. На macOS с Docker Desktop / OrbStack file permissions работают через осмос (osxfs / virtiofs) и обычно проблем нет.
Первый запуск
# Запуск
docker compose up -d
# Смотрим статусы
docker compose ps
# NAME IMAGE STATUS PORTS
# airflow-postgres postgres:16 Up (healthy)
# airflow-init apache/airflow:2.10.3 Exited (0)
# airflow-webserver apache/airflow:2.10.3 Up (healthy) 0.0.0.0:8080->8080/tcp
# airflow-scheduler apache/airflow:2.10.3 Up
# Логи init -- проверь что миграции прошли
docker compose logs airflow-init | tail -20
# Открыть UI
open http://localhost:8080
# Login: admin / admin
Простой DAG для проверки
Создай файл ./dags/hello_world.py:
from datetime import datetime
from airflow import DAG
from airflow.operators.bash import BashOperator
with DAG(
dag_id="hello_world",
start_date=datetime(2026, 1, 1),
schedule="@daily",
catchup=False,
tags=["junior-de"],
) as dag:
echo = BashOperator(
task_id="echo_hello",
bash_command="echo Hello from Airflow on $(date)",
)
Через 30-60 секунд DAG появится в UI. Нажми Unpause и Trigger DAG — таска отработает, в логах увидишь Hello from Airflow on Thu May 15 ....
Попробуй сам
Поставь стенд и поэксперементируй:
# 1. Скачай образ заранее (большой, ~1GB)
docker pull apache/airflow:2.10.3-python3.11
# 2. Подготовь папки
mkdir -p dags logs plugins
# 3. Сохрани compose.yml выше, запусти
docker compose up -d
# 4. Подожди ~30 секунд и открой UI
open http://localhost:8080
# 5. Положи hello_world.py в ./dags
# 6. Запусти DAG из UI, посмотри логи таски
# 7. Зайди внутрь scheduler'а
docker compose exec airflow-scheduler bash
# airflow dags list
# airflow tasks list hello_world
# airflow tasks test hello_world echo_hello 2026-05-15
# exit
# 8. Cleanup (но БД остаётся в pg-data)
docker compose down
# 9. Полный cleanup с данными
docker compose down -v
Когда сменишь Fernet key в running стенде, все существующие connections и variables перестанут расшифровываться. Поэтому Fernet key генерится ОДИН раз для проекта и кладётся в .env. Не путать с secret_key (он переподписывает сессии — потеря Fernet’а критичнее).
Альтернатива: CeleryExecutor (когда понадобится)
LocalExecutor хорош для dev и небольших стендов. Когда DAG-ов станет много (50+), а таски будут долгими (Spark-jobs, ML-trainings), LocalExecutor станет узким горлом — scheduler-процесс не справится. Тогда переходим на CeleryExecutor:
# Добавляется:
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
airflow-worker:
<<: *airflow-common
command: celery worker
depends_on:
redis:
condition: service_healthy
# В x-airflow-common меняется:
# AIRFLOW__CORE__EXECUTOR: CeleryExecutor
# AIRFLOW__CELERY__BROKER_URL: redis://redis:6379/0
# AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://airflow:airflow@postgres/airflow
worker’ов можно скейлить горизонтально (docker compose up --scale airflow-worker=3). Junior’у это обычно не нужно, но знать, куда расти, важно.