У меня на VPS в Docker живёт один инстанс n8n. На нём два боевых процесса: мониторинг вакансий (RSS, скоринг через Groq, дедупликация в базе, уведомление в Telegram) и приём заявок с сайта через вебхук. Стартовал я на дефолтной конфигурации, то есть на SQLite, и первое время всё работало. Проблемы начались, когда процессов стало больше и они начали пересекаться по времени.
Разбираю по порядку: что ломается, что я поменял, и где предел этой схемы. Конфиги, которые упоминаю, лежат в репозитории в конце статьи.
Почему SQLite умирает первым
SQLite пишет в один поток. Пока один workflow сохраняет результат выполнения, второй ждёт. На двух-трёх редких запусках это незаметно. На вебхуке, который прилетает пачками, и расписании, которое тикает каждые пять минут, ожидания складываются в очередь. В логах это выглядит как database is locked, в интерфейсе как подвисания при открытии истории выполнений.
Второй симптом менее очевидный: n8n по умолчанию сохраняет полные данные каждого запуска, включая успешные. Входные данные, выходные данные, прогресс по нодам. Таблица executions растёт линейно и никогда не уменьшается. Через месяц активной работы она весит больше, чем весь остальной инстанс вместе с воркфлоу.
Обе проблемы решаются конфигурацией, без единой строчки кода в самих воркфлоу
PostgreSQL вместо SQLite
Переезд делается переменными окружения. Отдельная схема под n8n обязательна: не смешивайте служебные таблицы с бизнес-данными, иначе первый же бэкап превратится в археологию.
services:
n8n:
image: n8nio/n8n:latest
restart: always
env_file: .env
ports:
- "5678:5678"
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- postgres
postgres:
image: postgres:15-alpine
restart: always
environment:
- POSTGRES_USER=${DB_POSTGRESDB_USER}
- POSTGRES_PASSWORD=${DB_POSTGRESDB_PASSWORD}
- POSTGRES_DB=${DB_POSTGRESDB_DATABASE}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
n8n_data:
postgres_data:Ключевое в .env:
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n_db
DB_POSTGRESDB_USER=n8n_user
DB_POSTGRESDB_PASSWORD=change_me
DB_POSTGRESDB_SCHEMA=n8n
DB_POSTGRESDB_POOL_SIZE=10Про пул подключений: дефолт там скромный, и на параллельных запусках вы упрётесь в него раньше, чем в CPU. Я поднял до десяти и смотрю на загрузку postgres. Больше не значит лучше: двадцать активных соединений на слабой VPS начинают драться за диск.
После переезда блокировки исчезли. Это не магия PostgreSQL, это просто нормальная многопользовательская база вместо файлового блокнота.
Pruning: перестать хранить всё
Самая полезная настройка из всех, что я трогал. Смысл: успешные запуски хранить не надо, ошибочные надо всегда.
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=720
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_SAVE_ON_FAIL=all
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=all720 часов это тридцать дней. Дальше строки удаляются сами. Осознанный трейдофф, про который молчат в туториалах: с SAVE_ON_SUCCESS=none вы не сможете открыть успешный запуск и посмотреть, что именно пришло на вход ноды. Отладка «а почему вчера заявка ушла не туда» превращается в гадание. Поэтому ручные запуски я сохраняю всегда, а для боевых процессов держу отдельное логирование ключевых шагов в свою таблицу: идентификатор заявки, вердикт скоринга, timestamp. Это дешевле, чем хранить весь payload каждого запуска, и полезнее для разбора инцидентов.
Рост таблицы executions после включения pruning перестаёт быть линейным и выходит на плато. Точные гигабайты зависят от вашего объёма и размера payload, поэтому цифры «было 50 стало 5» я приводить не буду: смотрите на форму графика, а не на абсолютные значения.
Таймауты и 429 от Groq
Зависший workflow держит слот выполнения вечно, если ему не сказать остановиться. Глобальный предохранитель:
EXECUTIONS_TIMEOUT=600
EXECUTIONS_TIMEOUT_MAX=1800
Первое значение в секундах, применяется по умолчанию. Второе ограничивает максимум, который можно выставить на конкретном воркфлоу. Десять минут для моих процессов с запасом, а всё что дольше почти всегда означает зависший внешний вызов, а не полезную работу.
Отдельная история это rate limit Groq. Он режет по запросам в минуту, а не по токенам, и на пачке заявок вы получаете 429 ровно в тот момент, когда скоринг нужнее всего. Лечится не таймаутом, а дисциплиной очереди: Split In Batches по одному элементу, Wait между итерациями, и Retry On Fail на ноде HTTP Request с паузой между попытками. Экспоненциальную задержку считаю маленькой функцией в Code node и кладу в Wait, чтобы не долбить API с одинаковым интервалом после каждого 429.
Таймаут на самой ноде HTTP Request ставьте отдельно от глобального: тридцать секунд на вызов LLM достаточно, дальше это уже не ответ, а проблема на той стороне.
Queue mode: где предел этой схемы
Всё выше живёт на одном процессе. У него есть потолок: тяжёлый воркфлоу на двадцать минут занимает слот, интерфейс начинает тормозить, вебхуки ждут. Когда запусков становится больше тысячи в день и они разной тяжести, один процесс перестаёт быть разумной архитектурой.
Queue mode разносит систему на три роли: main принимает вебхуки и расписание и отдаёт UI, Redis держит очередь, воркеры разбирают задачи. У меня до этого порога пока не дошло, поэтому ниже конфиг, который я подготовил как следующий шаг, а не как проверенный на моей нагрузке боевой опыт. Границу честно обозначаю.
docker-compose.queue.ymlservices:
n8n-main:
image: n8nio/n8n:latest
restart: always
env_file: .env
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
ports:
- "5678:5678"
depends_on:
- redis
- postgres
n8n-worker-1:
image: n8nio/n8n:latest
restart: always
command: worker --concurrency=5
env_file: .env
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
depends_on:
- redis
- postgres
n8n-worker-2:
image: n8nio/n8n:latest
restart: always
command: worker --concurrency=5
env_file: .env
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
depends_on:
- redis
- postgres
redis:
image: redis:7-alpine
restart: always
volumes:
- redis_data:/data
postgres:
image: postgres:15-alpine
restart: always
environment:
- POSTGRES_USER=${DB_POSTGRESDB_USER}
- POSTGRES_PASSWORD=${DB_POSTGRESDB_PASSWORD}
- POSTGRES_DB=${DB_POSTGRESDB_DATABASE}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
redis_data:
postgres_data:Два нюанса, которые сэкономят вам вечер. Первый: concurrency это число одновременных воркфлоу на воркер, а не число нод; пять на воркер при двух воркерах это десять параллельных выполнений, считайте память из этого числа. Второй: в queue mode вебхук ждёт ответа воркера, поэтому длинные процессы обязаны отвечать сразу через Respond to Webhook, а дальше работать асинхронно. Иначе клиент получит таймаут на своей стороне, хотя у вас всё ещё считается.
Что изменилось по факту
Что | Было | Стало |
|---|---|---|
Ответ на целевой лид | 15-20 минут вручную | около 30 секунд |
429 от Groq в пике | регулярные, часть заявок терялась | ретраи с backoff, потерь нет |
Рост таблицы executions | линейный, без потолка | плато после pruning |
Блокировки при параллельной записи | database is locked | исчезли после PostgreSQL |
Первая строка это бизнес-метрика всего пайплайна, а не бенчмарк ноды. Остальные три это то, что я наблюдаю на своём инстансе после перечисленных правок.
Чек-лист, если забираете конфиг себе
PostgreSQL и отдельная схема, пул подключений по мощности машины
Pruning включён, успешные запуски не хранятся, ручные хранятся
Своё логирование ключевых шагов боевых процессов, раз payload больше не сохраняется
EXECUTIONS_TIMEOUT и EXECUTIONS_TIMEOUT_MAX выставлены
Rate limit внешнего API обработан батчами и backoff, а не надеждой
Queue mode только когда один процесс реально упёрся, не заранее
Конфиги и чек-лист в репозитории: https://github.com/rmd1433/n8n-production-config
Комментарии 0