
20 мая в 06:01:55 МСК Watchtower по расписанию проверил 14 контейнеров на нашем VPS, нашёл 5 обновлений и пересоздал. Среди обновлённых - n8n, который крутит production-вебхуки нескольких проектов студии (в том числе SaaS aiseen.ru для GEO-аудита бренда в Яндекс Нейро и Google AIO). На новой версии 2.21.4 загрузчик нод стал строго проверять дубликаты регистрации, а в volume с сентября 2025 лежал n8n-nodes-base-1.14.1.tgz от какой-то старой ручной установки. n8n упал на инициализации, ушёл в crash-loop. Webhook'и перестали отвечать.
Discovery: 2 часа 48 минут спустя, когда юзер случайно открыл админку и увидел Failed to fetch. MTTR: 7 минут.
Главный урок не про :latest и не про Watchtower по отдельности - про то что 95% downtime - это «никто не знал». Собственно ремонт занял 5% времени. Разбираю кейс с точными таймстампами и выводами для observability.
Контекст
На VPS - общий n8n-инстанс, обслуживающий несколько production-проектов студии. На том же docker-compose рядом nginx-proxy с Let's Encrypt-companion, Postgres, статические лендинги, и Watchtower для автообновлений. Workflow'ы и credentials хранятся в Postgres - стандартная prod-конфигурация, апдейт самих данных не касается.
Затронутый проект - aiseen, сервис для GEO-аудита бренда. У него вебхуки в n8n обслуживают приём заказов, проверку промокодов, pipeline отчёта, админку. Лендинг - статика, от n8n не зависит.
Image n8n в compose стоял как n8nio/n8n:latest - то есть без фиксации на конкретной версии (без pin'а вроде n8nio/n8n:2.20.5). Это сознательное решение, про которое я отдельно писал в статье про n8n self-hosted в production 10 дней назад, и где целый раздел был про Watchtower как способ автообновляться без ручного обслуживания десятка клиентских инсталляций. На разборе её и этой статьи поверх есть отдельный раздел ниже.
Хронология
Всё в UTC, в скобках - МСК.
Time |
Событие |
|---|---|
2026-05-19 10:52 UTC (13:52 МСК) |
n8n 2.21.4 выкатили в Docker Hub как |
2026-05-19 12:08 UTC (15:08 МСК) |
n8n 2.22.0 выкатили туда же (минорный релиз с фичами, не hotfix для нашей дырки) |
2026-05-19 12:10 UTC (15:10 МСК) |
Тег |
2026-05-20 03:00 UTC (06:00 МСК) |
Watchtower по расписанию |
2026-05-20 03:01:55 UTC (06:01:55 МСК) |
SIGTERM старому контейнеру n8n |
2026-05-20 03:01:56 UTC (06:01:56 МСК) |
Создан новый контейнер на 2.21.4 |
2026-05-20 03:01:58 UTC (06:01:58 МСК) |
Старый образ удалён ( |
2026-05-20 03:01:58+ UTC (06:01:58+ МСК) |
Новый контейнер крашится: |
2026-05-20 03:02:10 UTC (06:02:10 МСК) |
Watchtower: |
2026-05-20 ~05:50 UTC (~08:50 МСК) |
Юзер случайно открыл админку проекта → |
2026-05-20 05:51 UTC (08:51 МСК) |
Начало диагностики |
2026-05-20 05:53 UTC (08:53 МСК) |
Root cause найден |
2026-05-20 05:55 UTC (08:55 МСК) |
Backup конфликтующих файлов |
2026-05-20 05:57:11 UTC (08:57:11 МСК) |
n8n стартовал на чистом volume, готов отвечать |
Real downtime: 2h 55min 16s. Структуру этих 175 минут (почему 95% времени никто не знал что упало) разбираю отдельно в правиле 95/5 ниже.
Момент про версии, который выглядел как «нам не повезло» - 2.22.0 вышел через 1ч 16м после 2.21.4. На первый взгляд классический hotfix, и кажется что попади Watchtower в окно после него, инцидента бы не было. Проверил release notes 2.22.0: это плановый минорный релиз с фичами (NVIDIA Nemotron, Crypto encrypt/decrypt, Snowflake OAuth2, security-фиксы vm2/protobufjs). Strict-check для дубликатов n8n-nodes-base в 2.22.0 никто не откатывал. То есть даже если бы Watchtower попал в окно после 2.22.0, мы бы упали ровно так же. Единственный реальный fix - починить причину в volume у себя, что мы и сделали.
Что видел юзер
В админке - TypeError: Failed to fetch на запросе к вебхуку. Наружный curl:
GET https://n8n.example.ru/webhook/admin-orders → HTTP 502 GET https://n8n.example.ru/webhook/order-status → HTTP 502 GET https://aiseen.ru/ → HTTP 200
Лендинг живой, n8n умер, nginx-proxy перед ним отдаёт 502 на всё. Юзер на главной увидел бы рабочий сайт, в админке - поломанно. Поэтому обнаружение случилось не через 5 минут, а через 3 часа, когда в админку случайно заглянули проверить наличие свежих заказов (повезло, что их не было...).
5 минут диагностики
Шаг 1 - docker compose ps
SERVICE STATUS ... n8n Up 16 seconds ← подозрительно
Контейнер «только что поднялся», но webhook'и продолжают падать. Значит он стартует, через секунды падает, и restart: unless-stopped его поднимает заново. Классический crash-loop, без error в compose ps (для compose «контейнер периодически рестартится» = валидное состояние).
Отдельная ловушка - этот crash-loop не светился на VPS-мониторинге Beget. Графики CPU за сутки показывают ровный 100% idle: n8n падал быстрее чем успевал создать нагрузку. Никаких всплесков user/system. Hosting-мониторинг такие сценарии видит абсолютно слепо - единственный пик появился утром, когда n8n уже подняли вручную после fix'а.
Шаг 2 - логи контейнера
docker compose logs n8n --tail 50
Вывод повторяется в цикле:
n8n-app | Initializing n8n process n8n-app | Error: Node loader n8n-nodes-base is already registered. n8n-app | at LoadNodesAndCredentials.init (/usr/local/lib/node_modules/n8n/src/load-nodes-and-credentials.ts:111:11) n8n-app | at Start.init (/usr/local/lib/node_modules/n8n/src/commands/base-command.ts:124:3) n8n-app | Exiting due to an error. n8n-app | Last session crashed
n8n-nodes-base - встроенный пакет n8n со всеми стандартными нодами (HTTP Request, Postgres, Code, и так далее). В нормальной инсталляции его дубликат снаружи не должен существовать. Ошибка already registered означает что загрузчик нашёл его дважды: один встроенный в образ + один где-то ещё.
Шаг 3 - версия и время апдейта
docker exec n8n-app n8n --version # → 2.21.4 docker inspect n8nio/n8n:latest --format 'Image Created: {{.Created}}' # → Image Created: 2026-05-19T10:32:37Z docker inspect n8n-app --format 'Container Started: {{.State.StartedAt}}' # → Container Started: 2026-05-20T05:52:31Z (последний рестарт цикла)
Версия - 2.21.4. До этого крутилась 2.20.x. Ручных docker pull ночью никто не делал, источник один - Watchtower по расписанию. Идём к корневой причине крэша.
Шаг 4 - содержимое volume
ls -la /var/lib/docker/volumes/n8n-stack_n8n_data/_data/nodes/ # n8n-nodes-base-1.14.1.tgz ← 5.4 MB, mtime: Sep 2 2025 ⚠️ # node_modules/ # package.json ls /var/lib/docker/volumes/n8n-stack_n8n_data/_data/nodes/node_modules/ # @apify/ # @tavily/ # n8n-nodes-telegram-mtproto/ # @telepilotco/ # n8n-nodes-base/ ← ⚠️ ДУБЛИКАТ встроенного, НЕ в package.json
package.json:
{ "name": "installed-nodes", "private": true, "dependencies": { "@tavily/n8n-nodes-tavily": "0.2.5", "n8n-nodes-telegram-client-api": "0.1.0", "n8n-nodes-telegram-mtproto": "0.1.19", "@telepilotco/n8n-nodes-telepilot": "0.5.2", "@apify/n8n-nodes-apify": "0.6.5" } }
n8n-nodes-base в dependencies нет, но физически лежит в node_modules/. mtime сентября 2025 - около 8 месяцев. Это осиротевший артефакт от старой ручной установки. Корневая причина найдена за 5 минут.
Корневая причина - strict-check в 2.21.4
До 2.21.4 загрузчик community-nodes при сканировании ~/.n8n/nodes/node_modules/ находил там пакет n8n-nodes-base и молча игнорировал (имя уже зарегистрировано глобально из встроенного образа). В 2.21.4 silent skip заменили на явную ошибку already registered. Изменение разумное (silent skip скрывает реальные конфликты), но оно ломает любые инсталляции где такой дубликат накопился из ручных экспериментов. В публичном changelog 2.21.4 это явно не описано, скорее всего побочный эффект рефакторинга загрузчика.
Пакет в volume - n8n-nodes-base-1.14.1, очень старая версия 2022 года. Кто-то в сентябре 2025 закатил его прямым npm install в ~/.n8n/nodes/ - даунгрейд, эксперимент, теперь не вспомнить. Мина лежала 8 месяцев и сработала ровно когда auto-update сделал strict-check. Это и есть мета-урок: укусило не само обновление, а хвосты от старых экспериментов. Auto-update просто нажал на спусковой крючок.
50 секунд fix
Принцип - минимально достаточное вмешательство. Удалить только конфликтующий пакет, остальные 4 community-nodes (которые реально используются другими проектами на этом n8n) - не трогать.
# 1. Backup на случай отката mkdir -p /root/backups tar czf /root/backups/n8n-nodes-base-conflict-$(date +%Y%m%d-%H%M).tar.gz \ -C /var/lib/docker/volumes/n8n-stack_n8n_data/_data/nodes \ n8n-nodes-base-1.14.1.tgz node_modules/n8n-nodes-base # 2. Остановить n8n cd /opt/n8n-stack && docker compose stop n8n # 3. Удалить ТОЛЬКО конфликтующие файлы rm /var/lib/docker/volumes/n8n-stack_n8n_data/_data/nodes/n8n-nodes-base-1.14.1.tgz rm -rf /var/lib/docker/volumes/n8n-stack_n8n_data/_data/nodes/node_modules/n8n-nodes-base # 4. Поднять docker compose up -d n8n
С момента docker compose stop n8n до полного старта (когда n8n уже отвечает на webhook'и) - 50 секунд. Сам fix оказался короче чем диагностика.
Что НЕ делали и почему
Соблазн «обнулить всё и пересоздать» при crash-loop большой. Не делали docker compose down -v (снёс бы volume и историю executions с bin-аттачами), не сносили nodes/ целиком (там 4 рабочих community-нода других проектов), не пинили версию на 2.20.x (это лечит симптом, мина в volume осталась бы до следующего мажора). Откат через docker tag тоже не делали - --cleanup Watchtower'а уже удалил старый образ локально, а re-pull из registry заняло бы дольше чем точечный fix.
В production-инцидентах минимальное вмешательство восстанавливает работу быстрее обнуления. Каждое лишнее «снесём, потом разберёмся» - это потенциально ещё один инцидент сверху.
Триада риска: :latest + Watchtower + --cleanup
Watchtower у нас запущен так:
watchtower: image: containrrr/watchtower command: --schedule "0 0 3 * * *" --cleanup volumes: - /var/run/docker.sock:/var/run/docker.sock
Важная деталь в логах сессии 20 мая - Session done Failed=0 Scanned=14 Updated=5. Для Watchtower'а «успех» = «контейнер создан и запущен через docker run». То что новый контейнер сразу упал в crash-loop - он не отслеживает. Известное архитектурное ограничение, не баг: без healthcheck и без хука «контейнер прожил X минут» Watchtower уходит с зелёным статусом, даже если хост остался без сервиса.
Каждый из этих трёх компонентов в отдельности - нормальный production-инструмент. Вместе они складываются в неприятную ситуацию:
Компонент |
Что делает |
Что добавляет в риск |
|---|---|---|
|
Тянет всегда самую свежую версию |
Между моими ручными |
Watchtower по cron |
Автоматически делает re-pull + recreate |
Подхватывает любые изменения |
|
Удаляет старые образы после успешного recreate |
Откатиться через локальный тег нельзя - нужен re-pull нужной prev-версии |
Деталь про n8n, важная для следующего пункта: floating-тегов на уровне мажора/минора (:2, :2.21 и т. п.) у n8nio/n8n нет. Только :latest, :stable, :next, :beta, :nightly и конкретные :X.Y.Z. Промежуточного «обновляться, но только в рамках 2.x» через имя тега не существует - реальных вариантов pin'а ровно два: либо конкретная :X.Y.Z с ручным апдейтом, либо :latest с риском авто-обновления.
Если из триады выпадает хотя бы один компонент - система становится управляемее:
Без
:latest(пинить:X.Y.Z) → обновления только когда вы сами поднимаете тег в compose. Полный контроль, но 100% ручного обслуживанияБез Watchtower →
:latestесть, но re-pull только при ручномdocker compose pull. Контроль над окном обновленияБез
--cleanup→ старый образ остаётся локально, откатdocker tag <old_id> n8nio/n8n:latest && docker compose up -d n8nзанимает секунды
У нас выпадал ноль - все три фактора были включены одновременно. Отдельно безобидные компоненты, вместе складывающиеся в ловушку.
Через 10 дней после статьи где я хвалил эту стратегию
10 дней назад я опубликовал на Habr статью про n8n self-hosted в production, где отдельно защищал подход :latest + Watchtower для агентств: «раз в полгода поднять упавший после автоапдейта workflow дешевле, чем 5-10 часов в месяц ручных обновлений десятка контейнеров». Через 10 дней эта стратегия поломала мой собственный production. Не катастрофой - fix 7 минут, потерь данных ноль. Но ровно тот сценарий, о котором я говорил абстрактно.
Теперь подход такой. :latest + Watchtower остаются - экономика на десятке клиентских инсталляций оправдывает. Но в обвязке три изменения: внешний пинг по публичному URL (discovery в минутах, а не в часах), --cleanup снимаю, если есть запас дискового пространства (откат через docker tag за секунды вместо re-pull), для самых критичных клиентов Watchtower переключу в --monitor-only - сканирует, шлёт «доступна версия X», обновляю уже я руками. Подробности в «Что меняем» ниже.
Правило 95/5: discovery latency vs MTTR
Разложим 2 часа 55 минут downtime'а на компоненты:
Фаза |
Длительность |
% |
Что происходило |
|---|---|---|---|
Discovery latency |
~2h 48min |
~95% |
Никто не знал. n8n рестартится в loop, webhook'и → 502, но никаких алертов в Telegram нет. Лендинг возвращает 200 OK - внешне «всё нормально» |
MTTR (mean time to repair) |
~7min |
~5% |
Сообщение → SSH → логи → root cause → backup → stop → rm → start → verify |
Это типичная картина для self-hosted-инфраструктуры без внешнего пинга. Мы обнаружили через 3 часа благодаря случайному заходу в админку. На клиентских инсталляциях с редкими заходами в админку/UI-n8n это могло бы тянуться сутками.
Контроль здоровья контейнера обязан жить вне самого n8n - в отдельном скрипте или сервисе. Иначе при crash-loop проверка молчит вместе с упавшим сервисом, и сценарий «n8n умер, nginx возвращает 502» не превращается ни в какой алерт.
К проблеме можно подойти с двумя степенями паранойи, и они складываются, а не выбираются:
Уровень 1 - cron на том же VPS. Бесплатно, 5 минут настройки. У нас на хосте упал только n8n - сам VPS и его cron-демон живы, лендинги и nginx тоже. Один /etc/cron.d/n8n-pinger с банальным curl закрывает 95% сценариев:
*/5 * * * * root \ curl -fsS --max-time 5 https://n8n.example.ru/healthz > /dev/null \ || curl -s -X POST "https://api.telegram.org/bot$TG_TOKEN/sendMessage" \ -d "chat_id=$TG_CHAT" \ -d "text=⚠️ n8n.example.ru HEALTH FAIL on $(hostname)"
Один файл, нулевые расходы, любая ошибка curl (502, timeout, ECONNREFUSED) превращается в сообщение в Telegram-чате поддержки. У нас Telegram-канал и токен бота уже настроены под другие алерты в admin_alerts, так что это буквально дописать пять строк.
Уровень 2 - алерты от самого хостинга. На случай «упал весь VPS» (плановые работы, kernel panic, OOM-killer по всему хосту) локальный cron из уровня 1 уже бесполезен - он сам умрёт вместе с хостом. У нас VPS на Beget - там встроенный мониторинг доступности VPS с Telegram-алертами, идёт прямо из админки хостинга и от падения самого VPS не зависит. Отдельный uptime-сервис подключать не нужно. На хостингах где такого нет (или мониторинг недоверчивого качества) - подключаем внешний: UptimeRobot, healthchecks.io, Better Stack, free-tier у любого хватает для пары серверов.
Оба уровня вместе занимают 15 минут на настройку и стоят 0 ₽. С ними discovery latency сжимается с 2h 48min до одного цикла мониторинга (5 минут), MTTR остаётся те же 7 минут.
Тупой пингер по публичному URL, который алертит при не-200 - то, что важнее в self-hosted production чем идеальный код и свежая версия. Внутренний healthcheck Docker'а помогает остановить crash-loop раньше, но разбудить дежурного может только что-то снаружи самого сервиса.
Что меняем после инцидента
В порядке приоритета:
Локальный cron + хостинг-алерты. Cron на том же VPS (5 минут, 0 ₽) - см. пример в разделе про 95/5. Поверх него - встроенный мониторинг Beget с Telegram-алертами на падение VPS целиком; на других хостингах вместо него UptimeRobot/healthchecks.io
Снимаем
--cleanupу Watchtower. Старые образы остаются локально, откат черезdocker tag <old_id> n8nio/n8n:latest && docker compose up -d n8n- секунды вместо re-pullTelegram-нотификации Watchtower на каждый update. Через
--notifications shoutrrr+WATCHTOWER_NOTIFICATION_URL=telegram://.... Сразу видим факт обновления, можем проверить руками-
Docker healthcheck на n8n.
restart: on-failure+ healthcheck останавливает бесконечный crash-loop. Не заменяет внешний пинг, но помогает Watchtower'у с--health-checkуважать состояние:n8n: healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:5678/healthz || exit 1"] interval: 30s retries: 3 start_period: 60s -
Аудит volume'ов n8n у остальных клиентов. Скрипт для каждого VPS:
ls n8n_data/nodes/node_modules/ | grep -v '^@' | sort > /tmp/installed.txt jq -r '.dependencies | keys[] | select(startswith("@") | not)' \ n8n_data/nodes/package.json | sort > /tmp/declared.txt comm -23 /tmp/installed.txt /tmp/declared.txtВывод
comm -23= пакеты, которые физически есть вnode_modules/, но не объявлены в package.json. Это и есть осиротевшие хвосты, кандидаты на удаление до того как очередной strict-check их подсветит Для самых критичных инсталляций -
--monitor-only. Watchtower сканирует и шлёт «доступна версия X», но не обновляет сам. Подход для клиентов где минута unscheduled downtime'а стоит дороже 15 минут плановых ручных обновлений
Полезные ссылки
n8n GitHub Releases - официальный changelog по версиям
n8n-nodes-base на npm - встроенный пакет, не должен дублироваться в volume
Containrrr Watchtower docs - конфигурация,
--cleanup,--monitor-only,--notificationsshoutrrr - нотификации для Watchtower (Telegram, Slack, Discord и т. д.)
UptimeRobot - простой внешний uptime-мониторинг с Telegram-нотификациями
Моя предыдущая статья на Habr - n8n self-hosted в production: docker-compose, nginx, ретраи и три грабли
Делаем интеграции и автоматизацию на n8n в BotKraft - напишите если нужно навести порядок с observability и change-management в существующем self-hosted-проекте.