Есть типичная боль: ты вроде всё сделал правильно — контейнеры поднялись, API отвечает, UI открывается… а потом оказывается, что “не работает”. Причём не “сломано в пепел”, а именно “почти”: где-то 404, где-то таймаут, где-то UI открывается, но вкладки пустые, где-то один запрос проходит, другой — молчит.

И самое неприятное: когда начинаешь чинить “по ощущениям”, можно потратить часы, а потом выяснить, что причина была не в коде, а в порте, origin, IPv6, миграциях или в том, что UI ходит не туда.

UPD 2026-02-16. Перечитал статью и подправил пример скрипта, чтобы он был “честнее” как smoke:

  • проверку портов (у меня была путаница -SimpleMatch vs regex),

  • обработку ошибок нативных команд (теперь учитывается exit code, а не только try/catch),

  • curl-запросы для smoke (404/500 теперь не маскируются под “OK”).

Сам подход универсальный, но пример ниже ориентирован на Windows (curl.exe, NUL, netstat). Для Linux/macOS нужны небольшие замены — коротко написал ниже по тексту.

Я перестал спорить с реальностью и сделал себе простой подход evidence-first:

  • сначала фиксируем факты о состоянии системы (preflight),

  • затем делаем минимальный smoke (дышит/не дышит),

  • сохраняем артефакты (evidence),

  • и только потом лезем в код.

Ниже — как это устроено и как повторить у себя.

Что внутри (оглавление)

  1. Контекст стенда и типовые “почти-ошибки”

  2. Концепция: Preflight → Smoke → Evidence

  3. Структура evidence-папки (что сохраняем и зачем)

  4. PowerShell-скрипт (рабочий, без магии)

  5. Как запускать (3 команды)

  6. 3 мини-кейса: как оно ловит проблемы

  7. Что улучшить дальше


Контекст стенда

Стек максимально приземлённый:

  • 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 -anoss -lntp или lsof -i :8080 -i :8793
curl.execurl-4 тоже работает)


Как запускать (3 команды)

  1. Перейти в папку, где лежит docker-compose.yml:

Set-Location "C:\path\to\project"
  1. Прогон smoke + evidence:

.\Invoke-EvidenceSmoke.ps1 `
  -ApiBase  "http://127.0.0.1:8080" `
  -EdgeBase "http://127.0.0.1:8793" `
  -ComposeDir "." `
  -OutRoot ".\evidence" `
  -Open
  1. Открыть итог:

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 заставляет сначала ответить:

“что именно сломано: инфраструктура/маршрутизация/контракт/логика?”

И только потом править.


Что улучшить дальше (если делать “взрослее”)

  1. Добавить проверку Celery (inspect ping/registered) и сохранять ответы как отдельные файлы evidence.

  2. Добавить “контракт-проверки”: список ожидаемых роутов и падение, если роутера нет (fail fast).

  3. Паковать evidence в zip, чтобы одним файлом передавать “как есть”.

В FoxProFlow это дальше превращается в “accept-run”: фикс → smoke → evidence → только потом merge.


Заключение

Если вы часто ловите «вроде всё правильно, но не работает», попробуйте начать не с нового кода, а с доказуемости: один smoke-скрипт, одна папка evidence, один SUMMARY.md.

Через несколько прогонов вы заметите, что:

  • спорить с реальностью стало сложнее,

  • а чинить стало быстрее, потому что «место поломки» видно сразу.