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 как latest/stable

2026-05-19 12:08 UTC (15:08 МСК)

n8n 2.22.0 выкатили туда же (минорный релиз с фичами, не hotfix для нашей дырки)

2026-05-19 12:10 UTC (15:10 МСК)

Тег latest переехал на 2.22.0

2026-05-20 03:00 UTC (06:00 МСК)

Watchtower по расписанию 0 0 3 * * * начал сессию

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 МСК)

Старый образ удалён (--cleanup)

2026-05-20 03:01:58+ UTC (06:01:58+ МСК)

Новый контейнер крашится: Node loader n8n-nodes-base is already registered

2026-05-20 03:02:10 UTC (06:02:10 МСК)

Watchtower: Session done Failed=0 Scanned=14 Updated=5

2026-05-20 ~05:50 UTC (~08:50 МСК)

Юзер случайно открыл админку проекта → Failed to fetch

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-инструмент. Вместе они складываются в неприятную ситуацию:

Компонент

Что делает

Что добавляет в риск

:latest без pin'а

Тянет всегда самую свежую версию

Между моими ручными docker pull digest может смениться без моего ведома

Watchtower по cron

Автоматически делает re-pull + recreate

Подхватывает любые изменения :latest без моего решения

--cleanup

Удаляет старые образы после успешного 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 раньше, но разбудить дежурного может только что-то снаружи самого сервиса.

Что меняем после инцидента

В порядке приоритета:

  1. Локальный cron + хостинг-алерты. Cron на том же VPS (5 минут, 0 ₽) - см. пример в разделе про 95/5. Поверх него - встроенный мониторинг Beget с Telegram-алертами на падение VPS целиком; на других хостингах вместо него UptimeRobot/healthchecks.io

  2. Снимаем --cleanup у Watchtower. Старые образы остаются локально, откат через docker tag <old_id> n8nio/n8n:latest && docker compose up -d n8n - секунды вместо re-pull

  3. Telegram-нотификации Watchtower на каждый update. Через --notifications shoutrrr + WATCHTOWER_NOTIFICATION_URL=telegram://.... Сразу видим факт обновления, можем проверить руками

  4. 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
  5. Аудит 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 их подсветит

  6. Для самых критичных инсталляций - --monitor-only. Watchtower сканирует и шлёт «доступна версия X», но не обновляет сам. Подход для клиентов где минута unscheduled downtime'а стоит дороже 15 минут плановых ручных обновлений

Полезные ссылки

Делаем интеграции и автоматизацию на n8n в BotKraft - напишите если нужно навести порядок с observability и change-management в существующем self-hosted-проекте.

Комментарии (0)