У меня на 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=all
720 часов это тридцать дней. Дальше строки удаляются сами. Осознанный трейдофф, про который молчат в туториалах: с 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.yml
services: 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
