Learning Platform
Глоссарий Troubleshooting
Урок 18.01 · 28 мин
Средний
dockerdata-engineeringairflowcomposepostgres

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 начинает именно с этого варианта.

Airflow compose-стенд: 4 сервиса + 3 volumes
postgres:16metadata DBPostgres хранит всё состояние Airflow: dag_run, task_instance, xcom, connection, variable. Без неё ни один компонент не стартует. Volume pg-data сохраняет БД между перезапусками.
depends_on
airflow-initdb migrate + userInit-контейнер. Делает airflow db migrate (создаёт таблицы) и airflow users create. Выходит с кодом 0 после успеха. Запускается один раз при первом up.
airflow-webserver:8080 UIFlask-приложение. Видит DAG-файлы из volume ./dags, читает статусы из Postgres. Не парсит DAG-и (это делает scheduler), но импортирует их для отображения структуры.
airflow-schedulerLocalExecutorГлавный процесс. Каждые N секунд сканирует ./dags, парсит DAG-файлы, ставит готовые таски в Postgres-очередь, и запускает их через subprocess (LocalExecutor).
vol: dagsBind mount ./dags -> /opt/airflow/dags. Сюда кладёшь свои Python-файлы с DAG'ами. И webserver, и scheduler видят их.
vol: logsBind mount ./logs -> /opt/airflow/logs. Airflow пишет логи каждой таски в файл task_id/run_id.log. Полезно смотреть в UI и debug'ить вручную.
vol: pg-dataNamed volume pg-data. Сохраняет /var/lib/postgresql/data между docker compose down/up. Без него БД обнуляется и admin-юзера придётся создавать заново.

Шаг 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-data named 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’и.
WARNING

ВНИМАНИЕ: 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'

Логика:

  1. airflow db migrate — Alembic-миграции. Создаёт все нужные таблицы (dag, dag_run, task_instance, xcom, …).
  2. airflow users create — добавляет admin/admin. В проде так делать нельзя — используй секреты или OIDC.
  3. 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: webserver vs command: 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:
Порядок запуска: postgres -> init -> webserver/scheduler
1. postgres upCompose v2 запускает postgres первым (никто не зависит от других сервисов). Ждёт healthcheck.
healthy
2. init completedairflow-init стартует только когда postgres healthy. Выполняет db migrate и создаёт user. Завершается успешно.
3. webserver + schedulerwebserver и scheduler стартуют параллельно после успешного завершения init. Оба видят БД и DAG-файлы.
ready
4. UI readyhttp://localhost:8080 -- логин admin/admin. DAG-и из ./dags появляются в UI через 30-60 секунд (scheduler парсинг).

Подготовка хост-директорий

Перед первым запуском нужно создать локальные папки с правильными правами:

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
TIP

Когда сменишь 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’у это обычно не нужно, но знать, куда расти, важно.


Проверка знанийKnowledge check
Ты запустил compose-стенд, postgres healthy, но webserver падает с ошибкой "relation 'dag' does not exist". В чём проблема и как починить без потери данных?
ОтветAnswer
Не отработал airflow-init: миграции БД не выполнились, и таблицы Airflow не созданы. Проверь docker compose logs airflow-init -- скорее всего там ошибка (например, неправильный SQL_ALCHEMY_CONN или old Postgres version). Починить: docker compose run --rm airflow-init -- это перезапустит init-контейнер, который выполнит airflow db migrate. Volume pg-data при этом не трогается, существующие данные сохранятся. Если миграция пройдёт — webserver и scheduler автоматически встанут.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Зачем в compose-стенде Airflow с LocalExecutor нужен отдельный airflow-init контейнер, а не просто webserver и scheduler?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5