Раздражающая проблема
Вы масштабировали кластер Kubernetes. EC2-инстансы запущены. Метрики CPU и памяти выглядят нормально. Но количество завершённых запусков (runs) Dagster упорно не растёт. Если вы оказывались в такой ситуации, вы знаете это особое раздражение от платформы, которая выглядит здоровой, но обрабатывает задачи каплями.
Первый инстинкт — добавить ещё вычислительных ресурсов. Но на деле проблема часто оказывается куда дешевле в починке и прячется в том углу стека, о котором вы ещё не подумали.
В этой статье мы пройдём весь путь диагностики — от очевидных настроек до скрытого узкого места в PostgreSQL, о котором, скорее всего, никто вас не предупреждал.
Очевидные подозреваемые
Прежде чем копать глубже, исключите быстрые улучшения. Эти три причины встречаются чаще всего и проверяются проще всего.
1. Слишком низкое значение max_concurrent_runs
Helm-чарт Dagster поставляется с консервативным ограничением параллельности. Если вы его не меняли, скорее всего, в вашем values.yaml есть что-то вроде:
dagster:
runLauncher:
config:
maxConcurrentRuns: 10
Если кластер рассчитан на 50 параллельных запусков, а значение равно 10 — вы искусственно ограничили себя. Увеличьте значение, перекатите деплой и снова измерьте. Это самая распространённая причина низкой пропускной способности на иначе здоровых кластерах.
2. Завышенные запросы и лимиты ресурсов
Каждый запуск Dagster порождает Pod в Kubernetes. У этого Pod есть запросы на CPU и память, которые планировщик (scheduler) обязан удовлетворить, прежде чем разместить Pod. Если запросы щедрые, а узлы умеренного размера, планировщик будет отклонять новые Pod-ы, даже когда реальная нагрузка вполне бы поместилась.
Проверьте фактическое потребление и сравните с запросами:
kubectl top pods -n dagster
Сопоставьте эти цифры с тем, что объявлено в конфигурации запусков. Типичная картина: пайплайны с лёгкими трансформациями получают «на всякий случай» 2 ядра CPU и 4 Гб памяти — и на узел помещается лишь горстка Pod-ов.
Приведите запросы в соответствие с реальным потреблением — планировщик скажет вам спасибо.
3. Завершённые Pod-ы не удаляются
По умолчанию Kubernetes не удаляет завершённые Pod-ы (со статусом Succeeded или Failed) автоматически. В высоконагруженной среде Dagster завершённые Pod-ы накапливаются и потребляют ресурсы API-сервера. Что важнее: если в кластере есть жёсткий лимит на общее количество Pod-ов (часто задаётся на уровне узла или через admission controller-ы), залежавшиеся Pod-ы съедают этот бюджет.
За это отвечает настройка ttlSecondsAfterFinished в спецификации Job. Kubernetes run launcher Dagster позволяет задать её через:
runLauncher.config:
dagster:
runLauncher:
config:
jobNamespace:
dagster envSecrets: []
ttlSecondsAfterFinished: 300
При ttlSecondsAfterFinished: 300 Kubernetes будет удалять завершённые Job-ы и их Pod-ы через пять минут после окончания. Без этой настройки кластер, обрабатывающий тысячи запусков в день, со временем накапливает десятки тысяч Pod-зомби, заметно деградируя работу планировщика и API-сервера. Эту настройку также можно задать через теги Job-а, по-разному для каждого задания.
Всё ещё медленно? Копаем глубже
Если вы устранили всё вышеперечисленное, а пропускная способность по-прежнему не устраивает — проблема скрыта на другом уровне.
1. Taint-ы на узлах незаметно блокируют планирование
Taint-ы (метки-ограничения) не позволяют планировщику размещать Pod-ы на узлах, если у Pod-а нет соответствующего toleration (допуска). Если кластер настроен с taint-ами для изоляции узлов (распространено в мультитенантных средах или при использовании выделенных групп узлов для конкретных нагрузок), Pod-ы запусков Dagster могут молча отклоняться.
Симптомы: Pod-ы остаются в статусе Pending с причиной SchedulingFailed или Unschedulable.
kubectl describe pod -n dagster | grep -A 10 "Events:"
kubectl describe pod -n dagster | grep "Taints"
Если вы видите 0/N nodes are available: N node(s) had untolerated taint — добавьте нужный toleration в конфигурацию Pod-ов запусков Dagster.
2. Pod-ы неэффективно распределяются по узлам
Связано с taint-ами, но это отдельная проблема: Pod-ы могут успешно планироваться, но распределяться по узлам так, что большая часть ресурсов каждого узла остаётся незанятой. Если у вас 5 узлов с 4 доступными ядрами CPU каждый, а Pod-ы запрашивают 3 ядра, на узел поместится только один Pod — и 5 ядер CPU будут простаивать в масштабах всего кластера.
Именно поэтому количество узлов — обманчивая метрика. Всегда считайте теоретическую максимальную ёмкость по Pod-ам: разделите выделяемые ресурсы узла на запросы Pod-а и сравните с maxConcurrentRuns.
3. Неподходящий тип EC2-инстанса для вашей нагрузки
Не все инстансы общего назначения одинаково хороши для задач с данными. Пайплайн с тяжёлыми трансформациями больших DataFrame-ов в Pandas ограничен по памяти, а не по CPU. Запускать его на c5.2xlarge (оптимизированном под вычисления, с меньшим объёмом памяти на vCPU) — значит нарваться на частые OOM-убийства или нехватку памяти, за которую планировщик Kubernetes будет штрафовать Pod-ы.
Для большинства ETL и трансформационных нагрузок семейства r6i или m6i в AWS, как правило, обходят c5 по производительности, несмотря на формально меньшее число vCPU. Главный вопрос: что именно нагружает ваша задача сильнее — CPU или память?
Профилируйте характерный запуск, а затем подбирайте семейство инстансов под него — не полагайтесь на инстанс общего назначения по умолчанию.
4. Повторные попытки умножают очередь
Это более тонкая проблема. Если в ваших заданиях Dagster настроены политики повторных попыток (retry policies) и значительная доля запусков завершается неудачей, фактическое количество «запусков для обработки» может быть куда больше, чем вы ожидаете.
Запуск, который трижды упал перед успехом, считается как 4 запуска против вашего лимита параллельности. Если 30% запусков падают с первой попытки, эффективная пропускная способность существенно снижается. Хуже того, неудачные запуски, поставленные на повтор, остаются в очереди — и она растёт быстрее, чем успешные завершения успевают её опустошить.
Проверьте долю отказов в интерфейсе Dagster в разделе Runs > фильтр по FAILURE. Если цифра заметная — сначала разберитесь и устраните первопричины, и только потом настраивайте параллельность. Ускорять выполнение поверх шторма повторных попыток только усугубит ситуацию. Такие запуски можно найти и напрямую во внутренней базе данных Postgres:
SELECT * FROM runs WHERE status = 'FAILURE'
ORDER BY update_timestamp DESC;
или через API Dagster:
from dagster import DagsterInstance, RunsFilter, DagsterRunStatus
instance = DagsterInstance.get() # требуется переменная DAGSTER_HOME
failed_runs = instance.get_runs(
filters=RunsFilter(statuses=[DagsterRunStatus.FAILURE]),
limit=50,
)
Скрытое узкое место: Queue Coordinator Daemon
Вы настроили всё вышеперечисленное. Узлы здоровы, Pod-ы планируются нормально, повторных попыток мало — а количество завершённых запусков всё равно меньше ожидаемого. Большинство руководств останавливаются именно здесь, хотя настоящая проблема чаще всего живёт именно тут.
Очередью запусков Dagster управляет Queue Coordinator Daemon (демон-координатор очереди) — фоновый процесс, который опрашивает очередь и решает, что запустить следующим. Он совершенно не зависит от ресурсов, которые вы настраивали выше, и имеет собственный потолок производительности.
Как работает Queue Coordinator
Queue Coordinator Daemon непрерывно обращается к базе данных PostgreSQL Dagster: проверяет очередь запусков, оценивает ограничения параллельности и запускает подходящие запуски через настроенный run launcher. Каждое принятое решение требует чтения и записи в базу данных (и, по моим наблюдениям, Dagster пишет в свою внутреннюю БД довольно активно).
Если PostgreSQL работает медленно — медленно работает и демон. Если демон медленный — новые запуски Dagster стартуют медленно, сколько бы вычислительных мощностей ни простаивало. Именно здесь и находится узкое место.
Диагностика медленного Queue Coordinator
Самый прямой способ диагностики — сердцебиение (heartbeat) демона. Queue Coordinator обновляет временну́ю метку heartbeat в PostgreSQL каждый раз, когда завершает цикл планирования. Проверить это можно в интерфейсе Dagster в разделе Deployment > Daemons или напрямую через запрос:
SELECT daemon_type, last_heartbeat_time, healthy FROM daemon_heartbeats ORDER BY last_heartbeat_time DESC;
У здорового Queue Coordinator heartbeat обновляется каждые несколько секунд. Если между обновлениями видны паузы в минуты или десятки минут — демон зависает. Именно из-за этого зависания узлы Kubernetes простаивают, хотя в очереди тысячи ожидающих запусков.
Причины медленной работы демона
а) База данных PostgreSQL слишком разрослась
Dagster хранит лог событий каждого запуска в PostgreSQL. В продакшен-среде, работающей несколько месяцев, эта таблица может разрастись до сотен миллионов строк. Запросы, которые раньше выполнялись за миллисекунды, теперь занимают секунды. Демон, прогоняющий эти запросы в плотном цикле, замедляется пропорционально.
Самый быстрый способ исправить ситуацию прямо сейчас — очистить (prune) старые данные о запусках. В перспективе стоит рассмотреть архивирование завершённых событий в объектное хранилище, оставив в рабочей базе только актуальные данные.
б) У самого PostgreSQL слишком мало ресурсов
Если ваш экземпляр PostgreSQL работает как Pod в Kubernetes с ограниченными CPU и памятью, он будет плохо справляться с одновременной нагрузкой. Деплой Dagster, обрабатывающий тысячи запусков в день, где все демоны, веб-сервер и рабочие процессы долбят один и тот же Pod базы данных, быстро исчерпает скромный лимит ресурсов.
Если CPU упёрся в потолок или память почти заполнена — либо увеличьте запросы/лимиты, либо — что лучше для продакшена — перейдите на управляемую базу данных, например Amazon RDS for PostgreSQL. Инстанс RDS db.t3.medium или db.m5.large справится с паттернами записи Dagster куда надёжнее, чем Pod внутри кластера, плюс вы получаете автоматические резервные копии, Multi-AZ failover и пул соединений прямо из коробки.
в) PostgreSQL нужен VACUUM
PostgreSQL использует механизм MVCC (Multi-Version Concurrency Control — многоверсионное управление параллельным доступом): удалённые или обновлённые строки не удаляются из хранилища немедленно — они помечаются как мёртвые кортежи и убираются фоновым процессом VACUUM. В нагруженной базе Dagster накопление мёртвых кортежей — распространённая проблема.
Раздутые таблицы приводят к более медленному последовательному сканированию и поиску по индексам, что напрямую бьёт по циклу опроса демона.
Вывод
Heartbeat Queue Coordinator — самая полезная единственная метрика для того, чтобы разграничить ситуации «мне нужно больше вычислений» и «у меня внутреннее узкое место». Если heartbeat здоровый, а масштабироваться по-прежнему не выходит — смотрите на вычисления. Если heartbeat вялый — сначала смотрите на PostgreSQL: в подавляющем большинстве случаев виновник именно там.
Хотите поэкспериментировать с полным стеком?
Если вы хотите разобраться в этих сценариях на практике — развернуть Dagster вместе с полноценным стеком дата-платформы и поиграть с настройками из этой статьи — загляните на https://dataplatform.dev.
Это бесплатный мастер-настройки на основе Django, который генерирует полную, готовую к запуску конфигурацию дата-платформы для вашей среды: чистый Python, Docker Compose или Kubernetes через Helm (Dagster доступен для всех трёх вариантов). Вы выбираете инструмент инжестии, оркестратор, уровень хранения, каталог и слой визуализации — и получаете готовый скрипт развёртывания, values.yaml и Chart.yaml для немедленного деплоя.
Это быстрый способ запустить среду Dagster-on-Kubernetes локально или в облачном кластере, чтобы воспроизвести описанные здесь паттерны производительности без необходимости строить всё с нуля.
Встречали похожее узкое место в своём деплое Dagster? Оставьте комментарий — особенно если нашли причину, которой нет в этом списке.