Профиль alerts включили, а файл целей `alertmanager_targets.yml` остался
плейсхолдером `[]`. Снаружи всё зелёное: alertmanager и alert-ack Up/healthy,
деплой зелёный, в логах ни одной ошибки — при этом `activeAlertmanagers: []`
и 1568 уведомлений в `prometheus_notifications_dropped_total`. Горящий с 26.08
`Watchdog` не доехал никуда, как и HostAgentDown с RemoteWriteStalled.
Причина в том, что включатель профиля и цель для Prometheus лежали в разных
местах: профиль поднимает deploy-metrics.yml по наличию токена и чата, а файл
целей правился руками. Разъезд не ловится ничем — `[]` штатен при выключенном
профиле, поэтому ни валидация, ни healthcheck, ни лог на него не реагируют.
Файл становится производным (`alertmanager_targets.gen.yml`, в .gitignore) и
рендерится деплоем тем же условием, что включает профиль: цель при включённых
алертах, `[]` при выключенных. Рендер идёт до `up` и пишет усечением на месте,
поэтому инод сохраняется и работающий Prometheus подхватывает цель сам — та же
ловушка одиночного бинд-маунта, что уже описана в этом workflow у Alertmanager.
Пустой список пишется явно, а не удалением файла: несуществующий путь docker
подменяет каталогом, и Prometheus не стартует вовсе.
Closes#3155
Follow-up #2451 (эпик #2445, B2/B3): тот же класс — object-literal lookup по
значению, пришедшему из API, без runtime-guard. Значение вне закрытого union'а
(schema drift / частичный деплой) даёт `undefined`, и дальше по-разному плохо:
- `BestLayoutsBlock` (DataQualityCard) — обращение `.fg`/`.bg` к undefined
РОНЯЕТ рендер всей карточки качества данных;
- `ForecastChart.confidenceByHorizon` — подпись уверенности для горизонта
пропадает МОЛЧА, при том что прогноз для него есть;
- `compare/CompareTable` и `ptica/compare/PticaCompare` — рядом с процентом
остаётся «73% · » и обрыв: число выглядит недосказанным, а не неизвестным.
Везде нейтральный fallback по образцу VelocityBlock/ParcelDrawer: серый + «—»,
намеренно НЕ семантический цвет — неизвестное качество не должно читаться ни
как хорошее, ни как плохое.
Фон бейджа взят из существующего `--border-soft`: список токенов закрыт
(ui-tokens.md — «НЕ выдумывать новые»), нейтральной заливки в нём нет.
`DataQualityCard`, `confidenceByHorizon` и `METRIC_ROWS` (ptica) экспортированы
ради тестов — тем же приёмом, что уже применён к `isDeficitDegenerate`.
Тесты: 12 новых в 4 файлах. Проверены на слом — снял все четыре fallback'а,
упало ровно 5 проверок out-of-union, остальные 7 (известные значения,
отсутствующие данные) остались зелёными.
`vitest run src/components/site-finder` — 145 passed, `tsc --noEmit` чисто.
Closes#2452
Refs #2445, #2451
`run_geocode_missing_listings` рекламирует `budget_sec` как максимальное время
прогона, но проверка стояла после возврата из батча. Внутри батча цикл шёл по
всем 200 адресам и часов не смотрел, то есть фактический потолок был
`budget_sec + один полный батч`.
Замер: прогон 5017 (27.08, `budget_sec=1800`) шёл 3150 с — 175 % бюджета, и
вышел не по бюджету, а по дренажу: бюджетная ветка за 52 минуты не выполнилась
ни разу.
Пока адрес стоил ~1.9 с это терялось в шуме. После общего ограничителя темпа
Nominatim (#2953) средняя цена 4.5 с, а на трудном хвосте (tier-1 + до 4
typo-вариантов под паузой 1 с, плюс retry×3) — до 33 с. Полный батч из таких
адресов уезжает на ~110 минут поверх бюджета, при окне расписания 06:00–09:00.
Дедлайн теперь передаётся В батч и проверяется на каждом адресе. Оборванный
батч — штатный исход: `geocode_tried_at` проставлен только у обработанных пар,
остальные попадут в выборку следующего прогона.
Отдельный флаг `budget_exhausted` нужен потому, что `addresses_total` на
оборванном батче равен размеру ВЫБОРКИ (== batch_size) — ветка дренажа
`addresses_total < batch_size` не сработала бы, и обёртка крутила бы цикл
дальше. Тест на это падает без флага (проверено снятием ветки).
Тесты: 5 новых, все три несущие проверки падают без соответствующей правки.
37 passed локально.
Closes#3151
Оборванный CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
планировщик им не пользуется, ошибки нет, а re-run миграции с IF NOT EXISTS
видит его как существующий и молча пропускает. До сих пор это ловил только
шаг 3b деплоя — то есть после раскатки на прод, ценой красного деплоя и
ручного DROP INDEX CONCURRENTLY в окне.
Тот же запрос теперь стоит в CI сразу после сборки схемы из 252 миграций.
Проверка в деплое ОСТАЁТСЯ: CI собирает схему с нуля и по построению не
может воспроизвести оборванный CIC на живой базе — это разные детекторы,
не дубликаты. CI ловит миграцию, которая рождает невалидный индекс из
чистой схемы; деплой ловит след аварии на проде.
Оба пути проверены на реальной базе (prod tradein-postgres): штатный
предикат → invalid=0, гейт зелёный; инвертированный → 358, ветка падения
срабатывает и печатает список idx/tbl.
Refs #2990
Ruff E501 на трёх строках, которые удлинились от замены литерала на
`DEFAULT_IMPERSONATE` в докстроках. Абзацы перевёрстаны целиком, а не
разорваны по месту переполнения — рваный перенос читался бы как опечатка.
Прогон: `ruff check app tests` — All checks passed.
Refs #3148
#3034 свёл impersonate к единственной константе внутри scraper_kit и поднял
профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока
объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a.
Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был
закрыт, то есть отсылка вела в никуда.
На chrome120 оставались:
app/services/cian_session.py:164 верификация куки Циана
app/services/yandex_address_backfill.py:153 бэкфилл адресов
app/tasks/yandex_detail_backfill.py:303 detail-бэкфилл
Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке
User-Agent, поэтому сменой прокси он не лечится.
ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли,
ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки,
всё это время представлялся площадке браузером двухлетней давности. Причину
этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли»
от «нас узнали по рукопожатию» нечем.
ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём
профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к
устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE.
Сторож литерала расширен на backend/app — без этого периметр возвращается
молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/**
(номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/**
(разовые инструменты, в прод-путях не участвуют).
Исторические замеры в комментариях сохранены как замеры: «curl_cffi с
kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории.
Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном
литерале краснеет; все изменённые модули компилируются.
Refs #3148
Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt, ключ
к ним лежит на самой машине и никуда не уезжает: потеря машины означает «дампы
есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось.
КУДА И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, отдельным префиксом env/, и только
зашифрованным. Разобранные варианты:
- рядом с дампами открытым — нельзя: ключ рядом с шифротекстом обнуляет
шифрование, одна утечка доступа к бакету отдаёт и то и другое;
- на соседний хост по SSH — потребовало бы завести доверие между машинами,
которого нет (проверено: Permission denied (publickey)), то есть РАСШИРИТЬ
периметр ровно тогда, когда #3075 его сужает;
- отдельный бакет с отдельными ключами — правильнее всего, но требует новых
учётных данных.
Выбран единственный исполнимый без расширения доступа: шифротекст в S3,
парольная фраза — вне S3.
FAIL-CLOSED. Без фразы скрипт не выгружает файл открытым, а падает с явным
сообщением и алертом. Молчаливая выгрузка ключа в бакет с дампами хуже
отсутствия копии: создаёт ложное чувство защищённости.
Фраза уходит через дескриптор, а не аргументом — иначе видна в ps любому
пользователю машины. После шифрования файл проверяется обратной расшифровкой:
без этого можно годами возить нечитаемый мусор и узнать в тот момент, когда он
понадобился.
Прогон на хосте 27.08 (подставной исходник, боевой не трогался):
без фразы → код 1, файлов создано 0
с фразой → «Зашифровано и проверено расшифровкой», 110 байт
своей фразой читается, чужой — нет
ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ, и он неустраним: фраза обязана жить там, где переживёт
смерть машины, иначе копия бесполезна — расшифровать будет нечем. Сгенерировать
её здесь и оставить на хосте нельзя по построению. Инструкция — в шапке скрипта.
Пять тестов сторожат ровно те свойства, ради которых всё сделано.
Прогон: 85 ops-тестов зелёные, ruff чист.
Refs #2203
`assert_called_once_with(source=..., endpoint=...)` требует, чтобы других
аргументов у вызова НЕ БЫЛО. Тем самым тест закреплял ровно тот дефект, который
чинит этот PR: браузерный фетчер обязан был строиться без проводки пула, иначе
CI краснел.
Заменено на проверку вхождения: source и endpoint по-прежнему сверяются, плюс
явно требуется наличие proxy_provider/use_pool/environment — то, без чего
прогон уходит мимо пула (proxy_lease_id=None, 5 блоков из 5 при здоровом пуле).
Прогон: 277 тестов зелёные, ruff чист.
Ветка browser_mode в avito_detail_backfill конструировала BrowserFetcher без
proxy_provider/use_pool/environment. Без них фетчер не кладёт "proxy" в тело
POST /fetch, сайдкар берёт свой env-прокси, и прогон уходит мимо пула целиком:
ни выбора узла по affinity, ни учёта scrape_proxy_source_bans, ни ротации при
блоке. В логе это ровно `proxy_lease_id=None`.
Ровно этот дефект чинили рядом — #2698 в house_imv_backfill, где он держал
35 отказов из 35 попыток в каждом прогоне полтора месяца, пока соседние свипы
через ТОТ ЖЕ сайдкар тянули сотни объявлений. Здесь он остался.
Замер 27.08, прогон 5098:
mode=browser, proxy_lease_id=None
BLOCKED #1..#5 подряд — firewall/soft-block (browser-mode)
ABORT — 5 consecutive blocks, enriched=0 attempted=5
При этом пул здоров — 4 узла, все ok, ни один не занят, браузерная проверка
пройдена в то же утро. А тот же URL Авито через прокси отдаёт 200 и 3.3 МБ
страницы. То есть площадка нас пускала, запрос шёл не оттуда.
Это объясняет, почему предыдущая правка (#3143, прокси в повторах curl-пути)
не восстановила сбор: боевой режим бэкфилла — browser, и он до curl-веток
вообще не доходит.
Три теста: конструктор на месте (страховка от проверки пустоты), все три
аргумента проводки передаются, use_pool читается из конфига а не зашит
константой (зашитый True отнял бы у владельца выключатель, зашитый False вернул
бы дефект незаметно). Проверил красноту на коде без проводки.
Прогон: 113 тестов зелёные, ruff чист.
Refs #3045, #3034, #2698
Версия v0.16.0 при КАЖДОЙ неудаче сбора печатала полный DSN вместе с паролем:
msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors"
dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable"
Строки уходят в Loki — около 210 в сутки с двух хостов, при ретенции 30 дней
это тысячи паролей в хранилище логов (#3114).
Проверял опытом, а не документацией. Стенд на скретч-контейнерах: чистый
постгрес, роль без прав, тот же queries.yml что на проде — то есть ровно та
ошибка, что случалась в бою.
v0.16.0 → строк с паролем: 1
v0.18.0 → строк с паролем: 0
ОБХОДНОЙ ПУТЬ ИЗ ISSUE НЕ РАБОТАЕТ, и это важнее самой правки. Предлагалось
передавать параметры через DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS
вместо единой строки — «тогда в лог попадать нечему». Проверил на том же
стенде: экспортер собирает DSN внутри и печатает его целиком точно так же,
1 строка с паролем. Реализация этого варианта была бы работой вхолостую при
полном ощущении, что дыра закрыта.
Паритет метрик проверен там же: все пять пользовательских запросов из
queries.yml отдаются обеими версиями одинаково (PG_EXPORTER_EXTEND_QUERY_PATH
в v0.18 работает), v0.18 добавляет три встроенные метрики и не теряет ни одной.
Два теста сторожат нижнюю границу версии на всех трёх экспортерах.
Прогон: 80 ops-тестов зелёные, ruff чист.
Refs #3114
Обе ветки повтора в `fetch_detail` — 403/firewall и 429 — пересоздавали
эфемерную сессию вызовом `_build_detail_session()` БЕЗ `config`. Прокси
kit-версия читает только из `config.scraper_proxy_url`, поэтому такая сессия
уходила напрямую.
Замысел ветки прямо обратный, он записан в её же комментарии: «эфемерная
свежая сессия (новый CONNECT-туннель = свежий exit-IP)». Ветка вообще
исполняется только при backconnect=True, а он вычисляется как «задан
config.scraper_proxy_url» — то есть в момент вызова достоверно известно, что
прокси есть, и он терялся.
ПОЧЕМУ НЕ БЫЛО ВИДНО. Пока адрес самой машины не был заблокирован, прямой
повтор часто срабатывал, и подмена канала выглядела как успех. Замер 27.08 из
прод-контейнера, один и тот же URL Авито:
через прокси — 200, 3.3 МБ страницы
напрямую — 429, «доступ ограничен», firewall
С этого момента каждый повтор после блока обречён. Обогащение
avito_detail_backfill по суткам: 21-25.08 — 178/129/147/138/111, 26.08 — 23,
27.08 — 0 при 25 блоках. Обвал начинается ровно с окна, в котором сменился
адрес машины.
Тот же класс ошибки чинили в #2330 для build_warmed_session; в пути повтора он
оставался.
Три теста: обе ветки на месте (страховка от проверки пустоты), ни одна не
строит сессию без config, и отдельно доказано, что без config прокси в сессии
действительно нет. Проверил красноту на старом коде — падает с точным текстом.
Прогон: 107 тестов scrapers зелёные, ruff чист.
Refs #3034, #3045
Пойман на проде 27.08 сразу после мержа #3136. На диске лежал новый конфиг —
`webhook_configs` на сервис кнопки подтверждения, — `amtool check-config` его
одобрил, деплой зелёный. А контейнер продолжал слать алерты напрямую:
на диске: webhook_configs: url http://alert-ack:8080/alertmanager
в контейнере: telegram_configs:
Конфиг подключён бинд-маунтом ФАЙЛА, а рендер делает `rm` и создаёт файл
заново — иначе не перезаписать: после chown он принадлежит 65534 с правами
600, а каталог принадлежит деплой-пользователю. `rm` + создание даёт НОВЫЙ
инод, тогда как открытый дескриптор внутри работающего контейнера продолжает
смотреть на прежний, уже удалённый. `up -d` контейнер не трогает: он
сравнивает описание сервиса, а содержимое бинд-маунта в сравнение не входит.
Отказ беззвучный — ни одного красного признака нигде. Значит и все прежние
правки маршрутизации применялись лишь тогда, когда контейнер пересоздавался
по совпадению.
Перезагрузка по SIGHUP/API не лечит: она перечитывает тот же открытый инод.
Лечит только пересоздание контейнера — его и добавляю, под флагом, который
выставляется ПОСЛЕ успешной проверки конфига. Порядок важен: при обратном
битый конфиг убивал бы работающий Alertmanager вместо того, чтобы оставить
прежний работать.
Прод уже приведён в соответствие вручную — контейнер пересоздан, маршрут
клиентских инцидентов теперь идёт через кнопку. Эта правка нужна, чтобы
следующая правка конфига доехала сама.
Три теста: пересоздание есть, оно закрыто проверкой флага (безусловное рвало
бы доставку на каждом деплое метрик), флаг выставляется после проверки.
Прогон: 78 ops-тестов зелёные, ruff чист.
Дрель восстановления стоит в cron с #3085, но её строка берёт только
`gendesign_*`. База tradein — клиентские оценки, листинги, дома, сделки —
автоматически на восстановимость не проверялась ни разу: дампы снимались,
уезжали в S3, и никто не знал, разворачиваются ли они.
Сам `ops/restore-drill.sh` менять не пришлось — он с самого начала умеет оба
проекта: находит globals по своей схеме имён (`tradein-globals-<ts>`),
подставляет свой список таблиц для сверки. Не хватало ровно строки в cron.
Прогнал на боевом дампе 27.08 перед тем, как добавлять (оповещение заглушено,
чтобы не слать ложную тревогу):
tradein-20260827-111102.sql.gz, 343 МБ → 58 c
GRANT-ы применены, материализованные представления обновлены
PostGIS 3.4.3, таблиц в public: 76
listings 109978 · listing_sources 106704 · deals 108623
houses 10400 · trade_in_estimates 1121
Сверка с боевой базой: houses, listings, deals, osm_poi сходятся точно;
trade_in_estimates и user_events больше на проде ровно на строки, созданные
ПОСЛЕ снятия дампа. То есть дамп верен.
Еженедельно, а не раз в месяц: две минуты работы против месяца, в течение
которого битый дамп остаётся незамеченным. Понедельник 03:00 — после дрели
gendesign (02:15 первого числа) и до ночных бэкапов в 03:30.
Замер на проде 27.08 (окно 1512×900): плашка стояла в левом нижнем углу
(`left:16; bottom:16`), а туда же приходит липкая кнопка «ОЦЕНИТЬ КВАРТИРУ»
из панели параметров. Прямоугольники: кнопка 59…484 по X и 859…904 по Y,
плашка 16…284 и 859…884 — перекрытие по всей высоте плашки.
Следствий два, и второе хуже первого. Надпись на кнопке читалась разорванной.
И `document.elementFromPoint` в центре кнопки возвращал плашку: клик по левой
трети главного действия продукта не доходил до кнопки вообще.
Плашка переезжает в правый нижний угол, НАД кнопку поддержки (44px от низа,
z-index 25). Там свободно: ниже только пассивная «Сводка объекта» без органов
управления — перекрыть её краем ничего не стоит.
Вдобавок плашка становится сквозной для указателя, а ссылка «История версий»
внутри — нет. Это страхует от повторения: что бы под плашкой ни оказалось в
будущем, клик достанется ему, а не ей.
Три теста закрепляют оба следствия разбора: якорь не левый, указатель
проходит насквозь, ссылка жива. Прогон: 68 тестов зелёные, tsc чист.
`SLF001` (обращение к приватному члену) в конфиге ruff не включён, поэтому
`# noqa: SLF001` — подавление того, что и так не проверяется. Ruff ловит это
правилом RUF100 и валит проверку.
Тест намеренно лезет в приватные `_tg`, `_PENDING`, `_send_alert`: подмена
единственного шва до сети — и есть смысл этих тестов. Пояснение, которое
стояло после директивы, сохранено обычным комментарием.
Оба дефекта найдены проходом клиентского пути на проде 27.08.
ПОДСКАЗКА АДРЕСА печатала одно и то же дважды. Строка списка состоит из
главной строки (`label`) и пояснительной (`full_address`), но подсказчик
для дома отдаёт их совпадающими буква в букву — проверено на проде:
{"label":"Свердловская область, г. Екатеринбург, ул. Малышева, д. 51",
"full_address":"Свердловская область, г. Екатеринбург, ул. Малышева, д. 51"}
Человек видел адрес продублированным в каждой строке выпадающего списка.
Показываем вторую строку только когда она отличается: замысел (короткое имя
сверху, полный адрес под ним) сохраняется, если подсказчик когда-нибудь
начнёт их различать.
ЧИСЛА В РЕЗУЛЬТАТЕ спорили друг с другом. Плитка сверху: «30 похожих
квартир продаётся в радиусе 1 км». Плитка под ней: «столько в среднем висит
объявление из этих 6». Слово «этих» указывает на 30, а стоит рядом 6 —
читается как ошибка в расчёте.
Шесть — это те объявления, у которых известна дата публикации; медиана
считается только по ним. Теперь доля названа явно («дата известна у 6 из
30»), а когда известна у всех — не упоминается вовсе, чтобы «30 из 30» не
шумело. Честное имя величины сохранено: это возраст активного объявления,
а не срок продажи (тест на это как был, так и остался).
Проверено: 27 тестов mera-public зелёные, tsc --noEmit чист.
A-записи www.meraocenka.ru / www.merahome.ru / www.meraotsenka.ru заведены и
указывают на прод, но site-блоков под них в Caddy не было. Caddy матчит строго
по имени хоста и на неизвестное имя сертификата не выпускает, поэтому клиент,
набравший привычное «www.», получал обрыв рукопожатия (TLS alert 80) — для
браузера это «сайт не открывается». В логах доступа при этом ни строки: до
HTTP-слоя запрос не доходил, так что молчали и метрики.
www.gendsgn.ru такой блок имел с самого начала и потому работал — здесь ровно
тот же приём, три отдельных блока с 301 на канонический meraocenka.ru.
Найдено сквозным аудитом периметра 27.08. Проверено: три www-имени резолвятся
в 188.124.37.140, curl до правки возвращал пустой код (000) на всех трёх,
www.gendsgn.ru — 301.
Смоук периметра дополнен проверкой 5b: отсутствие блока проявляется НЕ как
404, а как пустой код ответа, и обычная проверка «спутники отдают 301» такой
регресс не ловила.
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.
ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.
ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.
БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.
ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.
Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
Замер по метрикам за сутки:
продуктовый хост: 21 → 165 МиБ (стабильно) → после перезагрузки 305 →
359 МиБ из 384, то есть 93 % лимита
инфраструктурный: 105 МиБ стабильно → за два часа 228 → 291 МиБ
До OOM не дошло, но запас оставался 25 МиБ. Причина видна в собственном
логе cadvisor: «fs: disk usage and inodes count on following dirs took
1.3-1.8s» — обход overlay-слоёв docker на каждом цикле housekeeping.
Память растёт вместе с числом слоёв, а слои копятся от сборок образов,
то есть тем быстрее, чем активнее идёт разработка.
Отключаем disk/diskIO. Теряем использование диска ПО КОНТЕЙНЕРАМ —
проверено, что этих метрик не используют ни правила Prometheus, ни
дашборды: алерты по диску (DiskSpaceLow / DiskSpaceCritical /
DiskWillFillIn24h) построены на node_exporter, на уровне файловой системы
хоста. Это и есть нужное измерение — кончающееся место видно именно там,
а не в разбивке по контейнерам.
Остальные метрики контейнеров (CPU, память, сеть, рестарты) не тронуты:
на них держатся ContainerRestartLoop и ContainerNearMemoryLimit.
--web.external-url=…/alertmanager заставляет Alertmanager обслуживать ВСЁ
под этим префиксом, включая служебные ручки. Проверено на проде:
/-/healthy → 404 Not Found
/alertmanager/-/healthy → OK
Контейнер при этом работал и рассылал алерты, но docker держал его
unhealthy бессрочно. Само по себе безобидно — и ровно поэтому вредно:
статус, который всегда красный, приучают не смотреть. А любая автоматика
вида «перезапусти нездоровое» устроила бы из этого вечный перезапуск
канала оповещений — то есть выбивала бы именно то, что должно работать
в аварию.
Три правки, две причины — обе найдены прогоном по живому проду 27.08 под
аккаунтом админа.
## Карта поверх всего (два симптома, один корень)
Контейнер мини-карты в HeroBar имел position: relative БЕЗ z-index. Такой
блок контекст наложения не создаёт, поэтому внутренние слои Leaflet (тайлы
200, оверлеи 400, маркер 600, атрибуция 800) и карточка адреса (750)
конкурировали не между собой, а со всей страницей. Отсюда:
• меню аккаунта (UserMenu, z-index 200) открывалось ПОД картой;
• карточка «АДРЕС» всплывала поверх таблицы раздела «Продажи в доме».
Лечится изоляцией контейнера (isolation: isolate), а не гонкой чисел:
перебивать 800 у атрибуции пришлось бы в каждом новом элементе, и гонка
возвращалась бы. Меню шапки заодно поднято до 1000 — оно обязано быть
сверху по замыслу, а не по совпадению.
## Страница не помещалась в окно
Масштаб артборда 1536×1024 считался от window.innerWidth, который ВКЛЮЧАЕТ
вертикальную полосу прокрутки. На рабочей области 1425px артборд выходил
1440px — страница получала постоянный горизонтальный скролл. Воспроизводится
на любом десктопе: страница длинная, вертикальная полоса есть всегда.
Считаем от document.documentElement.clientWidth. Пороги isMobile/
isSmallViewport намеренно оставлены на innerWidth: это классификация
устройства, а не расчёт геометрии.
Критерий (размечен 21.08 ДО окна) выполнен, окно склеено через переезд:
замороженный Beget — 4 116 сканов против 217 712 у geog за всю историю
(прирост +164/4д — вырожденные IS NOT NULL-планы, паттерн #3020); живой
Poincare — 0 сканов за двое суток; EXPLAIN горячего пути — geog; свип кода —
ни одного пространственного оператора по голому listings.geom.
Экономия 115 МБ + минус одна индексная запись на каждый не-HOT апдейт
(их 20.86 млн). CONCURRENTLY по образцу 270, идемпотентно, откат — CREATE
INDEX CONCURRENTLY (в шапке миграции).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27.08, пилот «Практика». У пользователя ВСЕ 75 оценок оказались
недоступны, включая позавчерашние: список показывал их со статусом
«устарел», а открытие отдавало «Ссылка устарела, отчёт удалён или у вас нет
к нему доступа». Выглядело как пропажа данных — на деле строки целы, истёк
expires_at = created_at + 24ч.
Совпадение двух вещей и создало впечатление аварии: сутки не работал вход
(упало право CONNECT на базу auth), и ровно за это время истекли последние
живые отчёты. Люди зашли и увидели, что не открывается ничего.
Обоснование «оценка живёт сессию клиента, не архив» писалось под анонимный
B2C. «Практика» — пилот-юрлицо: менеджер возвращается к оценке через
день-два, когда клиент перезванивает. Суточный срок для такого сценария
означает, что работа исчезает раньше, чем её успевают использовать.
Настройка одна на оба контура, и это осознанно НЕ маскируется: юридический
мотив 152-ФЗ относится к анонимному B2C, разделение сроков по контурам —
отдельная задача. Здесь поднят общий срок, а не сделан вид, что контуры уже
разведены.
27.08, инцидент с пилотом «Практика». После починки доступа к базе auth
сотрудники начали получать «Слишком много попыток» — при том, что многие
ещё вообще не пробовали войти.
Причина в форме лимита, а не в его величине. Пилот — ОФИС: все выходят
из-под одного NAT, и пять попыток за пять минут делились на всю компанию
сразу. Одного человека, перепутавшего пароль, хватало, чтобы запереть
остальных.
Защита от перебора не ослабевает. Настоящий предохранитель — счёт по
ЛОГИНУ (login_username_fail_threshold, 20 за час), он не тронут. Лимит по
адресу существует против всплеска с одной машины, а не против офиса, и
двадцать попыток за пять минут эту роль выполняют.
Канал включили — и алерты бэкапов посыпались в ОБЩУЮ тему форума. В форуме
Telegram адрес сообщения это пара «чат + тема»: без message_thread_id всё
попадает в General, причём без единой ошибки. sendMessage возвращает 200,
доставка «успешна», просто не туда.
Отказ того же класса, что и всё остальное сегодня: зелено везде, а человек,
которому адресован алерт, его не видит.
Оба отправителя (ops/lib-backup.sh и ops/uptime-healthcheck.sh) получили
условную подстановку ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=..."}.
Условная намеренно: пустой message_thread_id= Telegram отвергает вместе со
всем сообщением, а молчащий алерт хуже алерта не в той теме. Нет переменной —
нет параметра, поведение прежнее бит в бит.
Тест ИСПОЛНЯЕТ настоящий notify(), извлечённый из файла построчно, подсовывая
подставной curl и проверяя, что реально ушло бы в сеть. Проверять подстроку в
файле бессмысленно: она может стоять в мёртвой ветке. Копировать функцию в
тест — тоже: копия разойдётся с оригиналом на первой правке. Сорсить файл
целиком нельзя: у uptime-healthcheck.sh нет guard'а по BASH_SOURCE, и сорсинг
запустил бы настоящие сетевые проверки.