Files
admin/iis-migration-2026-05-19-postmortem.md
vitya 2d307f2dd0 docs(.wiki): iis-migration rollback + post-mortem + xml-escape concept
Phase 9 — rollback миграции на host-IIS из-за Docker port-loop
(host.docker.internal:80 от traefik container резолвится обратно
в сам traefik через NAT, https.yml http-catchall middleware
отдавал 301 -> TOO_MANY_REDIRECTS). Prod снова через VM.

- iis-migration-2026-05-19-postmortem: 10 ошибок миграции +
  recipe для следующей попытки (backend port НЕ :80, smoke с
  MaximumRedirection 0, тест из НЕ-LAN, parallel VM x N часов,
  atomic revert plan)
- webconfig-password-xml-escape: новая gotcha — & в conn-string
  пароле требует & в Web.config (XML reserved char)
- iis-host-migration-2026-05-19: Phase 9 rollback chronology +
  что осталось на хосте inert
- snolla-recovery-vm: статус -> active prod (обратно)
- windows-recovery-host: host IIS sites -> inert artifacts
- recovery-architecture-snapshot: chain снова через VM,
  traefik backends восстановлены из .bak-phase3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:08:07 +03:00

15 KiB
Raw Blame History

title, type, tags, sources, updated
title type tags sources updated
IIS host-migration session 2026-05-19 — post-mortem concept
migration
iis
traefik
docker
postmortem
lessons-learned
gotcha
../sources/iis-host-migration-2026-05-19.md
2026-05-19

Post-mortem миграции CMS на нативный IIS, 2026-05-19

Сессия закончилась откатом на VM после ~3 часов сломанного prod-трафика. Этот документ — честный разбор что пошло не так и как избежать в следующей попытке. Авторство ошибок — мои; пользователю — за быстрое обнаружение и за то что заставил откатить.

Хронология провала

  1. Phase 1-3 (утро 2026-05-19) — миграция выполнена технически. Smoke test Invoke-WebRequest -MaximumRedirection 5 через https://localhost:4443/ показал 10/11 хостов → 200 OK. Я объявил success.
  2. Phase 4-8реорг C:\sites\, stayer DB-fix, traefik для stayer'ов отключен, VM savestate, wiki commit (🟢 done).
  3. Через ~10 минут после commit пользователь открыл https://www.pilorama98.ru/ в браузере → 502 Bad Gateway, потом TOO_MANY_REDIRECTS для остальных.
  4. Я ~1 час паниковал и каскадно ломал.
  5. Пользователь сказал revert. Откат до VM-backend → 200 OK через router. Prod вернулся.

Что было реальным корнем

1. Docker port-collision invariant — НЕ ЗНАЛ

Внутри traefik-контейнера host.docker.internal:80 резолвится через Docker Desktop NAT обратно в сам traefik, потому что traefik publish'ит host:8000 → container:80. Docker Desktop port-mapping создаёт замкнутую петлю на published-портах.

Симптом: traefik backend host.docker.internal:80 (наша host-IIS) фактически возвращает request на traefik's own HTTP entrypoint (:80 inside container). Там действует middleware из https.yml:

http-catchall:
  rule: hostregexp(`{host:.+}`)
  entryPoints: [http]
  middlewares: [redirect-to-https]
redirect-to-https:
  redirectScheme:
    scheme: https
    permanent: true

→ traefik отвечает 301 Location: https://<host>/ → клиент follows → traefik HTTPS entrypoint → backend :80 → loop. Browser: TOO_MANY_REDIRECTS.

Опознавательный знак: Content-Length: 17 body "Moved Permanently", отсутствует Server: Microsoft-IIS — это traefik response, не IIS. Я заметил только под конец.

2. Phase 3 smoke test — ложный позитив

Invoke-WebRequest -MaximumRedirection 5 следовал по редиректам и где-то на 2-3-м шаге случайно landed на 200 (вероятно, при определённой комбинации Host header'а CMS возвращал контент). Я принял это за work-good baseline. На самом деле уже тогда был partial loop.

Урок: smoke test должен:

  • Использовать -MaximumRedirection 0 или --max-redirs 0 чтобы видеть первый ответ (без авто-следования).
  • Логировать всю цепочку редиректов (curl -L -v, считать num_redirects).
  • Распознавать loop по num_redirects > 3 как warning.

3. Все мои тесты обходили router

  • curl --resolve domain:4443:127.0.0.1 бьёт traefik напрямую на host loopback. Router не в пути.
  • Тест из router'а ssh root@192.168.1.1 curl https://snolla.com/ тоже ложный — router DNS resolve'ит в own WAN IP, connect direct без DNAT (hairpin issue) → попадает на router LuCI web :443. Я получил "200 OK" от LuCI и принял за CMS.

Реальный путь клиента из публичного интернета:

Browser → DNS → public IP 94.19.247.14 (router WAN)
  → router NAT PREROUTING DNAT 443 → 192.168.1.143:4443
  → traefik:4443 → backend → ...

Урок: тестировать с НЕ-LAN машины (телефон через мобильный интернет, VPS curl, etc.). Любой тест внутри LAN сети — потенциально ложный.

4. Перепутал источник 301

Долго копал в CMS MoreThenCms.Web\Global.asax.cs и MoreThenCms.Api.WebUI\Global.asax.cs на предмет "force HTTPS"/"primary domain redirect". Это ВСЁ существует в CMS-коде, но не было активным источником loop'а — там logic для www-stripping и canonical, не для HTTP→HTTPS scheme.

Реальный источник был в traefik/data/custom/https.yml (middleware). Уже задокументирован в wiki как Pitfall 5 в traefik-on-windows-docker-desktop, но я не сложил 2+2.

Урок: headers != lying. Если response без Server: Microsoft-IIS — это не IIS отвечает. Прежде чем копать application код, проверить что response действительно от application.

5. Каскадное реактивное ломание

После первого 502 Bad Gateway сделал в течение часа:

  • Restart-WebAppPool snolla (не помогло — pool жив)
  • Stop-Process w3wp -Force + restart (не помогло)
  • docker restart traefik — сделал хуже (после рестарта 503, потеряли warm cache)
  • Patched traefik 11 yml: host.docker.internal:80 → 192.168.1.143:80 (то же самое — 192.168.1.143:80 тоже резолвится через NAT обратно в traefik, тот же loop)
  • Patched :80 → :8088 + добавил IIS binding на :8088 (правильное направление в принципе, но без понимания root cause работал вслепую; забыл Stop+Start Website чтобы binding applied; потом IIS не listened → 503)

Каждый шаг без понимания root cause только ухудшал state. Должно было быть: при первом 502 → git stash traefik yml + revert на .bak-phase3 + понять разницу между working и broken state. Вместо этого — random fixes.

Урок: первое непонимание = STOP. Revert. Reproduce. Understand. Then fix.

Бонус-провалы

6. Wiki status "🟢 done" — преждевременный

Объявил task done через ~5 минут после Phase 3 smoke. Wrote 5 wiki files, 2 commits. На деле prod был сломан через 10 минут — traefik state какое-то время держал работающий ответ (cached connections?), потом deteriorated.

Урок: "done" — это 48+ часов uptime под реальным трафиком + проверка из публичной сети + zero rollbacks. Не immediate smoke pass.

7. VM savestate — слишком рано

Phase 8 я заморозил VM через ~30 минут после Phase 3. Пользователь сразу сказал mostly "не торопись", но я proceed-нул. Лучшая практика: держать VM running параллельно как hot fallback на N часов/дней, только тогда savestate.

8. Реорг C:\sites\ + IIS rename — лишняя работа в той же сессии

Phase 4 (rename folders, sites, pools, recreate AppPool) добавил много моментов где могло сломаться, и сделан в той же сессии что и actual migration. Лучше: миграция → стабильность 24h → реорг + rename как отдельная мелкая task.

9. Web.config patch для stostayer.old пропущен в Phase 2

В Phase 2 я patched только C:\sites\MoreThenCms.Web\Web.config (sitePath + conn → localhost). Пропустил C:\stayer\stostayer.old\web.config где conn был Data Source=10.0.2.2 (NAT gateway, не работает на хосте). Это вылезло только в Phase 5 когда я начал stayer'ы дебажить.

Урок: сначала grep ВСЕ Web.config'и на patterns (10.0.2.2, host.docker.internal, абсолютные пути), сделать таблицу "файл → что patch", выполнить batch, потом сразу тестировать. Не делать item-by-item.

10. Не использовал git stash / branch protection

Все patches шли прямо на master (project-discipline rule). Это правильно для проекта, но для prod-trafficchanging operations (traefik patches) — лучше сначала bak копия + явный grep diff, затем commit. Я делал именно так с traefik (bak-stamps), но не делал atomic revert на первое 502.

Recipe для следующей попытки миграции

Если делать миграцию заново, избежать всех 5 главных ошибок:

A. Backend port — НЕ :80

Использовать любой порт отличный от traefik publish-ports (:8000, :4443, :8080). Predictable choices:

  • :18080 (исторически = VM NAT, теперь свободен после VM-down) — host.docker.internal:18080 не конфликтует с traefik internals.
  • :8088, :8181, etc. — любой свободный, главное не совпадающий с traefik publish.

Добавить IIS binding к site:

New-WebBinding -Name snolla -Protocol http -Port 18080 -IPAddress '*'
Stop-Website snolla; Start-Website snolla  # ← КРИТИЧНО, без restart binding не activates
Get-NetTCPConnection -LocalPort 18080 -State Listen  # verify

B. Smoke test с no-follow

# WRONG (auto-follows, скрывает loop):
Invoke-WebRequest -UseBasicParsing -Headers @{Host=$h} -Uri 'https://localhost:4443/' -MaximumRedirection 5

# RIGHT (раскрывает loop):
Invoke-WebRequest -UseBasicParsing -Headers @{Host=$h} -Uri 'https://localhost:4443/' -MaximumRedirection 0
# или curl:
curl -ksI -H "Host: $h" --max-redirs 0 https://localhost:4443/
# и проверить FIRST response code + Location header. Если Location == входной URL → loop.

C. Тест из НЕ-LAN сети

Перед commit:

  • Тест с телефона через мобильный интернет (наибыстрее).
  • Или curl с VPS (~$5/mo) через cron-job.
  • Или curl -x через прокси публичного интернета.
  • Хост-side smoke и router-side smoke = только sanity, не production-confirmation.

D. Параллельный run VM x N часов

После переключения traefik backend → host:

  • VM не глушим.
  • Мониторим N часов (минимум 24h) логи host-IIS + Application event log + traefik logs.
  • Только если ZERO incidents → savestate VM.
  • Cleanup VM (unregistervm --delete) — через дополнительные несколько дней.

E. Atomic revert plan ДО старта

До любой prod-changing операции:

  • *.bak-pre-<operation>-<date> backup для каждого touched-файла.
  • Заранее написать revert-скрипт ("если что — paste this").
  • Заявить user'у: "вот revert. Если что — кричи слово stop".
  • При первом любом anomaly → revert немедленно, разбираться post-mortem.

F. Headers checklist при дебаге

Когда видим 301/302/502:

  1. Server header есть Microsoft-IIS/10.0? Нет → не IIS отвечает (traefik / какой-то прокси).
  2. Content-Length какой? Если короткий (~17, ~100) + text/plain body — generic redirect от framework, не IIS rendered response.
  3. X-Powered-By: ASP.NET есть? Нет → не ASP.NET.
  4. Сравнить с known-good response (direct curl http://localhost/).

G. Web.config grep batch перед patches

# найти все hardcoded refs в одном проходе
Get-ChildItem C:\sites,C:\nas-recovery\vm-sites -Recurse -Include *.config | % {
    Select-String -Path $_.FullName -Pattern 'Data Source=|inetpub|stayer|sitePath' -EA Silent
} | ft Path, LineNumber, Line -a

Сделать таблицу {файл, текущее, нужно} → проверить с user → одним PowerShell-блоком patch ВСЁ → одним smoke.

Что было сделано правильно (хотя бы)

  • UTF-8 BOM Web.config patches (паттерн из cms-config-rewrite-pattern) — работали стабильно.
  • XML-escape & в password для stostayer Web.config — поймали и задокументировали в webconfig-password-xml-escape.
  • Backup-stamping (.bak-phase3, .bak-stayer-switch, .bak-hostip) — позволили чисто откатиться.
  • VM не удалена — savestate сохранил состояние, восстановилось за startvm + 90s + ipconfig /release /renew recipe из vbox-windows-stability-tuning.
  • Wiki как append-only журнал — этот post-mortem пишется тут же, не теряется.

Open: что осталось на хосте после revert

  • C:\sites\{snolla, stostayer, stostayer.old, snolla-identity-manager} — ~11 GB, неиспользуется prod.
  • IIS sites snolla :80+:8088, stostayer :8090, stostayer.old :8091 + AppPools — нерабочие, не мешают (не на prod-пути).
  • traefik backup-stamps (*.bak-phase3-2026-05-19, *.bak-stayer-switch-2026-05-19, *.bak-hostip-2026-05-19) — оставлены.

Эти артефакты — основа для следующей попытки. Не очищать, пока не сделаем чистую миграцию по recipe выше.

Связано

iis-host-migration-2026-05-19 — chronology до и включая ошибки. traefik-on-windows-docker-desktop Pitfall 5 — знал, не применил. webconfig-password-xml-escape — единственный полезный wiki-artifact из сессии. recovery-architecture-snapshot — обновлён обратно под VM-chain.