Есть типичная боль: ты вроде всё сделал правильно — контейнеры поднялись, API отвечает, UI открывается… а потом оказывается, что “не работает”. Причём не “сломано в пепел”, а именно “почти”: где-то 404, где-то таймаут, где-то UI открывается, но вкладки пустые, где-то один запрос проходит, другой — молчит.
И самое неприятное: когда начинаешь чинить “по ощущениям”, можно потратить часы, а потом выяснить, что причина была не в коде, а в порте, origin, IPv6, миграциях или в том, что UI ходит не туда.
UPD 2026-02-16. Перечитал статью и подправил пример скрипта, чтобы он был “честнее” как smoke:
проверку портов (у меня была путаница
-SimpleMatchvs regex),обработку ошибок нативных команд (теперь учитывается exit code, а не только try/catch),
curl-запросы для smoke (404/500 теперь не маскируются под “OK”).Сам подход универсальный, но пример ниже ориентирован на Windows (
curl.exe,NUL,netstat). Для Linux/macOS нужны небольшие замены — коротко написал ниже по тексту.
Я перестал спорить с реальностью и сделал себе простой подход evidence-first:
сначала фиксируем факты о состоянии системы (preflight),
затем делаем минимальный smoke (дышит/не дышит),
сохраняем артефакты (evidence),
и только потом лезем в код.
Ниже — как это устроено и как повторить у себя.
Что внутри (оглавление)
Контекст стенда и типовые “почти-ошибки”
Концепция: Preflight → Smoke → Evidence
Структура evidence-папки (что сохраняем и зачем)
PowerShell-скрипт (рабочий, без магии)
Как запускать (3 команды)
3 мини-кейса: как оно ловит проблемы
Что улучшить дальше
Контекст стенда
Стек максимально приземлённый:
FastAPI как backend
PostgreSQL
Redis
Celery worker + beat
Docker Compose
небольшой web-UI (статический HTML/JS), который ходит в
/api/*и/health/*
Хост — Windows, PowerShell 7.x. Подход (preflight/smoke/evidence) одинаково применим и на Linux/macOS, но конкретные команды проверки портов/NUL/netstat ниже — под Windows (замены дам в конце).
Ключевой нюанс: UI ожидает same-origin (то есть /api/* на том же origin, где открыт HTML). Если этот нюанс упустить — получаются “призраки”: UI есть, данных нет.
Внутри проекта FoxProFlow я упёрся в повторяющиеся “почти-ошибки” и вынес диагностику в отдельный smoke+evidence прогон.
Почему “вроде всё правильно” ломается на практике
Три причины, которые встречаются чаще всего.
1) localhost — это не всегда то, что вы думаете
На Windows localhost нередко резолвится в ::1 (IPv6). Если сервис слушает IPv4, часть запросов превращается в “Empty reply / Connection aborted”. В браузере это может выглядеть как “панель открылась, но вкладки не грузятся”.
Лечение простое: для диагностики и скриптов я использую http://127.0.0.1:8080 и curl -4, чтобы исключить IPv6-сюрпризы.
2) UI стучится в /api/*, а вы раздаёте HTML “как есть”
Если открыть operator_console.html через простой python -m http.server, то HTML отдаётся, но запросы в /api/* летят в этот же origin и получают 404 — потому что там нет прокси на backend.
Снаружи это выглядит как “вкладки не обновляются / данные пустые / кнопки не работают”.
Лечение: нужен локальный reverse-proxy/edge, который:
отдаёт HTML,
проксирует
/api/*и/health/*на backend,и (опционально) имеет
/local/health, чтобы быстро понять “жив ли edge”.
3) Compose поднялся, но “система” не поднялась
docker compose ps может быть зелёным, а внутри:
не применились миграции,
воркер не видит брокер,
роутер не зарегистрировал нужные эндпойнты,
упала одна из зависимостей.
Лечение: минимальный набор проверок, который подтверждает не “контейнер жив”, а “контур реально работает”.
Идея пайплайна: Preflight → Smoke → Evidence
Я разделил диагностику на два слоя.
Preflight (до любых правок)
Цель — поймать “глупости”, которые не имеют отношения к бизнес-логике:
docker compose config(валидация compose и env на fail-fast)docker compose ps(быстрая картина контейнеров)проверка занятых портов (когда внезапно “почему не стартует?”)
доступность API по IPv4 (
127.0.0.1+curl -4)
Smoke (самое важное “дышит?”)
Цель — за 30–90 секунд ответить:
backend жив?
нужные роуты на месте?
UI-origin действительно может достучаться до
/api/*через edge (если edge есть)?
Evidence (доказательства)
Цель — сохранить сырьё, чтобы потом:
сравнить “до/после”,
прикрепить к багу,
восстановить контекст через неделю (и не вспоминать “а что тогда было?”).
Как я сохраняю evidence
Каждый прогон создаёт папку вида:
evidence/ run_YYYYMMDD_HHMMSS/ meta.json 01_compose_config.txt 02_compose_ps.txt 03_ports.txt 10_health_extended.json 11_diag_routers.json 20_smoke_endpoints.txt 90_summary.md
Логика простая: не один гигантский лог, а набор маленьких файлов. Тогда глазами быстро видно, где именно сломалось: compose/env, порты, health, роуты или origin-прокси.
Реализация: один PowerShell-скрипт (preflight + smoke + evidence)
Ниже — рабочий скрипт. Он:
создаёт evidence-папку,
прогоняет preflight (
docker compose config,docker compose ps, порты),проверяет API (
/health/extended,/diag/routers),делает мини-smoke ключевых эндпойнтов (чтобы отличать “200/401/403” от “404/500”),
собирает SUMMARY.md.
Скрипт специально “приземлённый”: без модулей, без внешних зависимостей.
Важно: использую
curl.exe -4и127.0.0.1, чтобы исключить IPv6-сюрпризы.
#requires -Version 7.0 [CmdletBinding(PositionalBinding=$false)] param( [string]$ApiBase = "http://127.0.0.1:8080", [string]$EdgeBase = "", [string]$ComposeDir = ".", [string]$OutRoot = ".\evidence", [switch]$Open ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function _NowStamp { (Get-Date).ToString("yyyyMMdd_HHmmss") } function _WriteUtf8NoBom([string]$Path, [string]$Text) { $enc = [System.Text.UTF8Encoding]::new($false) $dir = Split-Path -Parent $Path if($dir -and -not (Test-Path -LiteralPath $dir)){ New-Item -ItemType Directory -Force -Path $dir | Out-Null } [System.IO.File]::WriteAllText($Path, $Text, $enc) } function _Run([string]$Label, [string]$File, [scriptblock]$Action) { try { # Важно: многие нативные команды (docker/curl/netstat) не "кидают" exception при ошибке, # они возвращают ненулевой exit code. Поэтому учитываем $LASTEXITCODE. $LASTEXITCODE = 0 $out = & $Action 2>&1 | Out-String $exit = $LASTEXITCODE _WriteUtf8NoBom $File $out if($exit -ne 0){ return @{ ok=$false; label=$Label; file=$File; err=("EXITCODE=" + $exit) } } return @{ ok=$true; label=$Label; file=$File; err=$null } } catch { $msg = $_ | Out-String _WriteUtf8NoBom $File ($msg + "`n") return @{ ok=$false; label=$Label; file=$File; err=$msg } } } function _CurlText([string]$Url, [string]$File) { # -f: сч��таем 404/500 ошибкой smoke (иначе curl вернёт 0 и это выглядит как "OK") _Run "curl $Url" $File { curl.exe -4 -sS -f $Url } } $ts = _NowStamp $ev = Join-Path $OutRoot ("run_" + $ts) New-Item -ItemType Directory -Force -Path $ev | Out-Null $meta = @{ ts_local = (Get-Date).ToString("o") api_base = $ApiBase edge_base = $EdgeBase compose_dir = (Resolve-Path -LiteralPath $ComposeDir).Path host = $env:COMPUTERNAME user = $env:USERNAME pwsh = $PSVersionTable.PSVersion.ToString() } | ConvertTo-Json -Depth 5 _WriteUtf8NoBom (Join-Path $ev "meta.json") $meta $results = @() # --- PRE-FLIGHT $results += _Run "docker compose config" (Join-Path $ev "01_compose_config.txt") { Push-Location $ComposeDir try { docker compose config } finally { Pop-Location } } $results += _Run "docker compose ps" (Join-Path $ev "02_compose_ps.txt") { Push-Location $ComposeDir try { docker compose ps } finally { Pop-Location } } $results += _Run "ports" (Join-Path $ev "03_ports.txt") { # полезно, когда внезапно занят порт # (Windows-ориентировано: netstat + поиск строк) netstat -ano | Select-String -SimpleMatch -Pattern @(":8080 ", ":8793 ") } # --- SMOKE: health/routers (считаем 404/500 ошибкой) $r1 = _CurlText "$ApiBase/health/extended" (Join-Path $ev "10_health_extended.json") $r2 = _CurlText "$ApiBase/diag/routers" (Join-Path $ev "11_diag_routers.json") $results += $r1 $results += $r2 # --- SMOKE: ключевые эндпойнты (быстро проверить 200/401/403 вместо 404/500) $smokeFile = Join-Path $ev "20_smoke_endpoints.txt" $sb = New-Object System.Text.StringBuilder function _Probe([string]$Name, [string]$Url) { $LASTEXITCODE = 0 $code = & curl.exe -4 -sS -o NUL -w "%{http_code}" $Url $exit = $LASTEXITCODE if($exit -ne 0 -or -not ($code -match '^\d{3}$') -or $code -eq "000"){ [void]$sb.AppendLine(("{0,-28} ERR {1}" -f $Name, $Url)) return } [void]$sb.AppendLine(("{0,-28} {1} {2}" -f $Name, $code, $Url)) } # API напрямую _Probe "api:health" "$ApiBase/health" _Probe "api:health_ext" "$ApiBase/health/extended" _Probe "api:diag_routers" "$ApiBase/diag/routers" # Edge (если есть): проверяем same-origin прокси /api/* и /health/* if($EdgeBase -and $EdgeBase.Trim().Length -gt 0){ _Probe "edge:local_health" "$EdgeBase/local/health" _Probe "edge:api_health" "$EdgeBase/api/health" _Probe "edge:health_ext" "$EdgeBase/health/extended" } _WriteUtf8NoBom $smokeFile $sb.ToString() # --- SUMMARY $bad = @($results | Where-Object { -not $_.ok }) $ok = ($bad.Count -eq 0) $fail = @($bad | ForEach-Object { $_.label }) $summary = @() $summary += "# Smoke summary ($ts)" $summary += "" $summary += "- API: $ApiBase" $summary += "- EDGE: $EdgeBase" $summary += "- Evidence: $((Resolve-Path $ev).Path)" $summary += "" $summary += "## Key files" $summary += "- meta.json" $summary += "- 01_compose_config.txt" $summary += "- 02_compose_ps.txt" $summary += "- 03_ports.txt" $summary += "- 10_health_extended.json" $summary += "- 11_diag_routers.json" $summary += "- 20_smoke_endpoints.txt" $summary += "" if($ok){ $summary += "## Result" $summary += "**PASS** (минимальный контур отвечает)" } else { $summary += "## Result" $summary += "**FAIL**: " + ($fail -join ", ") $summary += "" $summary += "Сначала открываю evidence-файлы выше и смотрю, где именно рвётся." } _WriteUtf8NoBom (Join-Path $ev "90_summary.md") ($summary -join "`n") if($Open){ Invoke-Item $ev } if($ok){ exit 0 } else { exit 1 }
Небольшая заметка для Linux/macOS (если будете адаптировать):
NUL → /dev/null
netstat -ano → ss -lntp или lsof -i :8080 -i :8793
curl.exe → curl (и -4 тоже работает)
Как запускать (3 команды)
Перейти в папку, где лежит
docker-compose.yml:
Set-Location "C:\path\to\project"
Прогон smoke + evidence:
.\Invoke-EvidenceSmoke.ps1 ` -ApiBase "http://127.0.0.1:8080" ` -EdgeBase "http://127.0.0.1:8793" ` -ComposeDir "." ` -OutRoot ".\evidence" ` -Open
Открыть итог:
notepad .\evidence\run_*\90_summary.md
Если edge не используете — запускайте без -EdgeBase (по умолчанию он пустой), тогда проверки edge:* пропустятся.
Что именно это ловит: 3 мини-кейса
Кейс A: localhost → ::1 и “пустые вкладки” в UI
Симптом: UI открывается, но вкладки не обновляются, запросы иногда “молчат”.
Когда вы используете curl.exe -4 и 127.0.0.1, вы резко уменьшаете количество “призрачных” фейлов. Это не лечит всё, но убирает целый класс странностей ещё до анализа логики.
Кейс B: UI раздаётся без прокси, /api/* даёт 404
Симптом: HTML грузится, но данные не приходят, кнопки “не работают”.
В 20_smoke_endpoints.txt это видно как контраст:
API напрямую отвечает (200/401/403 — зависит от авторизации),
а edge-эндпойнты возвращают 404.
Пример того, как это выглядит:
api:health 200 http://127.0.0.1:8080/health edge:api_health 404 http://127.0.0.1:8793/api/health
То есть backend жив, но origin UI не проксирует /api/*.
Кейс C: Compose “зелёный”, но роутов нет
Симптом: /health отвечает, но нужные эндпойнты отсутствуют.
Тогда 11_diag_routers.json помогает понять, что проблема не “во фронте” и не “в тайминге”, а в регистрации роутов/конфигурации приложения.
Почему это быстрее, чем “сразу чинить”
Главная выгода — снятие неоднозначности. Когда “не работает”, люди часто прыгают в код, но причина может быть в:
сетевом origin,
портах,
env-переменных,
несовпадении health-контуров,
или в том, что вы тестируете “не тот” входной URL.
Evidence-first заставляет сначала ответить:
“что именно сломано: инфраструктура/маршрутизация/контракт/логика?”
И только потом править.
Что улучшить дальше (если делать “взрослее”)
Добавить проверку Celery (
inspect ping/registered) и сохранять ответы как отдельные файлы evidence.Добавить “контракт-проверки”: список ожидаемых роутов и падение, если роутера нет (fail fast).
Паковать evidence в zip, чтобы одним файлом передавать “как есть”.
В FoxProFlow это дальше превращается в “accept-run”: фикс → smoke → evidence → только потом merge.
Заключение
Если вы часто ловите «вроде всё правильно, но не работает», попробуйте начать не с нового кода, а с доказуемости: один smoke-скрипт, одна папка evidence, один SUMMARY.md.
Через несколько прогонов вы заметите, что:
спорить с реальностью стало сложнее,
а чинить стало быстрее, потому что «место поломки» видно сразу.
