30 августа в 22:01 МСК один из наших диалоговых воркфлоу упал на ноде Chat Logic. В журнале исполнения 928811 осталось короткое сообщение:
timeout of 20000ms exceeded
Это был неприятный тип аварии: контейнеры работали, healthcheck был зелёным, входящий webhook принимался. Снаружи всё выглядело как случайный сетевой таймаут. Но человек уже написал сообщение, а ответа не получил.
Через шесть минут ответ удалось доставить. Сама починка заняла меньше времени, чем поиск причины. Мы сначала полезли не туда, получили убедительную ошибку от соседнего сервиса и почти обвинили внешний канал. На деле три исполнения n8n заняли все три доступных слота и каждое ждало ещё одно исполнение в том же n8n.
Как была устроена цепочка
n8n у нас работает в queue mode. Основной процесс принимает webhook, задания попадают в Redis, затем их забирает worker. Официальная документация n8n описывает ту же схему: main принимает таймеры и webhook-вызовы, а workers выполняют production executions из очереди. У каждого worker можно задать число одновременно выполняемых задач через --concurrency (документация n8n по queue mode).
В тот вечер у n8n-worker-1 было:
command: worker --concurrency=3
Внешний запрос запускал диалоговый workflow. Внутри его нода Chat Logic несколько раз обращалась к служебному HTTP endpoint mk-db-exec. Endpoint выглядел как обычный микросервис: отправляешь SQL-команду, получаешь JSON. Только физически это был ещё один webhook-workflow в той же инсталляции n8n.
Упрощённая трасса выглядела так:
Telegram update -> webhook диалогового workflow -> worker slot #1 -> HTTP POST /webhook/mk-db-exec -> Redis queue -> нужен свободный worker slot
Один диалог проходил. Два обычно тоже. Проблема проявилась, когда одновременно пришли три сообщения.
Замер в момент аварии
В 22:01 три диалоговых исполнения уже находились внутри Chat Logic. Все три занимали слоты worker. Каждое дошло до HTTP-вызова mk-db-exec и стало ждать ответ.
Запросы к mk-db-exec main-процесс принял и положил в очередь. Выполнить их было некому:
Что происходило | Количество |
|---|---|
Слотов worker | 3 |
Родительских исполнений, занявших слоты | 3 |
Свободных слотов | 0 |
Дочерних исполнений | 3 |
Таймаут HTTP-клиента | 20 с |
Родители не освобождали слоты, потому что ждали детей. Дети не начинались, потому что родители держали все слоты. Это была не блокировка строки в PostgreSQL и не зависший запрос. Мы построили синхронный вызов очереди из самой себя при жёстком общем лимите.
Через 20 секунд HTTP-клиент оборвал ожидание. Нода записала timeout of 20000ms exceeded, родительское исполнение завершилось ошибкой, слот освободился. После этого один из уже ненужных дочерних запросов мог стартовать. Поэтому на графиках такая авария похожа на медленную базу: в очереди есть работа, затем она внезапно начинает выполняться после таймаута вызывающей стороны.
Ложный след: очень правдоподобный 502
Первая гипотеза была про модель и прокси перед ней. Chat Logic обращается не только к базе. На тестовом запросе соседний or-relay вернул:
502 all_upstreams_failed
Совпадение выглядело почти идеальным: диалог не ответил, в цепочке есть внешний HTTP, тест внешнего HTTP даёт 502. Несколько минут ушло на проверку маршрута и xray.
Ошибка оказалась нашей. Пробный запрос был отправлен без ключа. С тем же телом и правильной авторизацией relay ответил за 2 секунды, upstream вернул HTTP 200. К исполнению 928811 этот 502 отношения не имел.
Это был полезный щелчок по носу. Проверочный запрос обязан повторять авторизацию аварийного запроса. Иначе диагностика создаёт новую ошибку, которая выглядит содержательнее настоящей.
Второй важный признак был уже в n8n: таймаут возникал именно на внутреннем DB-вызове, а не на обращении к модели. После этого осталось сопоставить число активных исполнений с --concurrency=3.
Быстрая починка
Мы изменили лимит одного worker с трёх до восьми:
- command: worker --concurrency=3 + command: worker --concurrency=8
Затем пересоздали только worker и task runner. Команда здесь важна целиком:
docker compose -p localai \ --profile n8n \ -f docker-compose.yml \ -f docker-compose.n8n-workers.yml \ up -d --no-deps --force-recreate n8n-worker-1 n8n-runner-1
До этого мы уже успели наступить на ещё одну граблю: запуск Compose без -p localai и без --no-deps попытался собрать другой project graph, полез пересоздавать PostgreSQL и Redis и остановился на конфликте имён контейнеров. Данные не пострадали, но команда ремонта стала опаснее самой правки. После этого в команде оставили явное имя проекта, оба compose-файла и запрет трогать зависимости.
Почему восемь, а не четыре? Четырёх хватило бы только для одного дочернего исполнения при трёх занятых родителях. У диалогов несколько обращений к данным, а входящие сообщения приходят пачками. Восемь дало запас для текущей нагрузки без второго worker-контейнера. Это не расчёт универсального значения; это локальный аварийный лимит под наш профиль задач.
После перезапуска worker стал healthy. Та же внутренняя цепочка ответила за 0,28 секунды вместо таймаута через 20 секунд. Сообщение, потерянное в аварийном исполнении, отдельно повторили через штатный webhook; конечная доставка зафиксирована в 22:07 МСК.
Проверка | До | После |
|---|---|---|
| 3 | 8 |
Ответ внутренней цепочки | таймаут 20 с | 0,28 с |
Свободный слот при трёх родителях | 0 | 5 |
Статус worker после пересоздания | healthy до аварии и во время неё | healthy |
Последняя строка в таблице здесь не опечатка. Healthcheck не заметил проблему ни до, ни после. Процесс был жив и мог отвечать на служебную проверку. Он просто не мог взять работу, от которой зависели уже выполняющиеся задачи.
Что не сработало
Проверка только docker ps ничего не дала. Контейнер не падал, рестартов не было, healthcheck проходил. Для исчерпания очереди это нормальное, но бесполезное наблюдение.
Поиск сетевой проблемы увёл к 502 all_upstreams_failed. Ошибка была настоящей, но созданной неправильной диагностической командой без ключа. После повтора с теми же заголовками гипотеза рассыпалась.
Простой рестарт worker освободил бы очередь и на несколько минут спрятал симптом. Мы его не делали до изменения лимита: при следующей пачке из трёх сообщений блокировка повторилась бы.
Увеличение HTTP-таймаута тоже не лечит схему. При нуле свободных слотов дочернее исполнение не станет быстрее через 60 секунд. Родители лишь дольше удержат все слоты.
Почему увеличение concurrency — всё ещё временное решение
После правки авария ушла, но архитектурная петля осталась. Workflow синхронно вызывает другой workflow через HTTP, а оба обслуживаются одним ограниченным пулом. При восьми слотах блокировка повторится, если восемь родителей одновременно дойдут до такого вызова.
Добавить второй worker полезно для пропускной способности и отказоустойчивости, однако само условие никуда не исчезает: общий пул всё равно можно занять родителями. Рост лимита отодвигает порог, но не разрывает зависимость.
Нормальная починка — вынести SQL-мост из n8n в отдельный небольшой HTTP-сервис или убрать синхронный HTTP-круг и работать с данными в том же execution. Тогда дочернему запросу не понадобится слот той очереди, которую уже занял родитель. Ещё один приемлемый вариант — выделить служебные workflow в отдельный worker pool с собственной очередью, если версия и схема развёртывания это позволяют.
Мы оставили этот пункт как техдолг. В аварийный вечер менять одновременно лимит, способ доступа к базе и topology очередей было бы слишком большим диффом.
Что добавили в проверку после инцидента
Теперь при таймауте внутреннего HTTP мы смотрим не только на время ответа endpoint. Сопоставляем активные execution с concurrency каждого worker и отдельно отмечаем вызовы, которые возвращаются в ту же очередь.
Для таких маршрутов минимальная полезная трассировка содержит ID родительского execution, имя вызываемого webhook и время ожидания очереди до старта дочернего execution. Одного общего request timed out недостаточно: он не отличает медленный upstream от задания, которое вообще не получило worker.
Ещё мы перестали считать healthy доказательством работоспособности бизнес-цепочки. Проверка после изменения закончилась не на статусе контейнера, а на ответе за 0,28 секунды и доставленном сообщении. Именно эта разница — между живым процессом и выполненной работой — в данном случае сэкономила нам повтор аварии.
Взаимная блокировка получилась без mutex, транзакций и двух потоков, меняющих одну переменную. Хватило трёх честно работающих worker-слотов, трёх синхронных HTTP-запросов и одного незаметного возврата в собственную очередь.

