Разработка платформы координации ПСР во время реальной спасательной операции

в 14:37, , рубрики: SAR, SAR-Review, Курумду, Курумды, кыргызстан, Поисково-спасательные работы, экстренная разработка

12 августа в Алайском районе начали искать пропавших альпинистов. Дрон снимал склоны, материал копился часами, и команда ПСР разбирала его вручную, своими средствами.

Искали группу Максима Полянского, Николая Свиридова и Сергея Рубцова, не вернувшихся с пика Курумды (6613 м, Памир). Официальная страница поиска — kurumdy.by. 16 августа обнаружили ключевую находку, ознаменовавшую конец первой посково‑спасательной‑операции на Курумды.

Как примерно выглядит традиционный отсмотр материалов ПСР:
Волонтёр самостоятельно выбирает случайное видео, обнаруживает находку, останавливает видео, записывает таймкод, делает скриншот, открывает файл телеметрии, ищет в нём нужный момент, переписывает широту и долготу, выкладывает кадр в чат и заводит строку в таблице. Минуты на одно наблюдение.

15 августа я отдал организаторам платформу, где всё это собрано в одном месте. Смотреть и решать по‑прежнему человеку: видео, кадр, таймкод, телеметрия и обсуждение находки, — просто лежат рядом, и семь действий сворачиваются в одно. Но самой главной фичей было то, что на каждое видео прилагается его тепловая карта — какой процент видео просмотрен и на каких отрезках — это решающая функциональность платформы в организационном контексте поисково‑спасательных работ. Данный функционал позволяет координировать группы волонтеров анализировать только самые нужные видео: с платформой сразу понятно что ещё не отсмотрено и ждёт первой очереди к анализу.

В одну пару рук такой MVP за вечер не пишется, поэтому код я писал через Claude Code; это указано в каждом коммите, а в конце есть раздел о том, что при этом осталось за мной. Следующие семь дней я правил платформу прямо по ходу работы волонтёров. Дальше — про эти правки и про то, как всё уместилось в один ноутбук с домашним интернетом и забитым диском.

О чём статья

  • Ставка на автоматическую детекцию и почему она не сыграла: 183 234 рамки от модели против 31 пометки от людей.

  • Опознание волонтёров через телеграм‑бота вместо полноценной ролевой модели — за вечер, в разгар операции.

  • Архитектурные решения, которые позволяют чинить платформу, пока в ней работают люди.

  • Как 58,6 ГБ материала уместились в ноутбук с десятком ГБ свободного места.

  • Как бесплатный туннель держал нагрузку в 470% от своей ёмкости и ни разу не отказал.

  • Чего обойти не удалось: CGNAT, деньги на сервер и файл, залитый в облако не до конца.

  • Три отказа, которые прошли боевое применение незамеченными.

  • Где Claude Code не помогает вообще, и чем приходится платить за темп.

Что это было и чем это стало 15 августа

Хотя задумывал я её под другое. Основная ставка была на автоматическую детекцию: модель проходит по кадрам и отмечает подозрительное, человек смотрит только отмеченное. Ручная разметка находки шла довеском к этой схеме.

Детектор работал на YOLO‑World — модели с открытым словарём классов. Ей можно задать «человек», «рюкзак», «палатка» обычным текстом, без обучения на своих данных: своего размеченного датасета у меня не было и взяться ему было неоткуда. Кадр резался на тайлы с перекрытием — на 4K‑кадре с высоты человек занимает десяток пикселей, и целиком модель его не видит. Рядом работал отдельный детектор цветовых аномалий, в расчёте на яркое снаряжение среди серого склона.

Обработка шла очередью: воркер брал файл, запускал детектор отдельным процессом, писал прогресс в базу. На выходе получался самодостаточный HTML‑отчёт, который открывается и без сервера. Ручной плеер в этой схеме был подсобным инструментом — посмотреть то, что модель пропустила.

Вот что схема выдала на материале операции:

Разработка платформы координации ПСР во время реальной спасательной операции - 1

Полторы тысячи рамок на файл человеку не просмотреть. Группировка детекций в «сцены» появилась именно из‑за этого: тысячи срабатываний сворачивались в десятки карточек. Помогло, но схему это не вытянуло — отношение полезного к шуму осталось таким, что проверять модель дороже, чем смотреть самому.

Дальше по тексту детектор почти не появляется. Всё, что оказалось нужным людям в те дни, выросло вокруг ручного просмотра.

Что получилось за эти дни

С работой справились. Статистика такая:

Разработка платформы координации ПСР во время реальной спасательной операции - 2

Всё уложилось в двое суток: 15 августа смотрели 17 человек, 16-го — 12. На эти два дня пришлось 94% работы. Тогда же приходил и материал — 14 августа сняли 47 видео, 15-го ещё 25 и 42 снимка, 16-го пять. Дальше съёмка кончилась, и работа кончилась вместе с ней.

По свежему материалу тех дней — 77 видеофайлов, 186 минут — открывали 25 файлов, просмотра по ним набралось 142 минуты.

Чего это стоило каналу

Наружу платформа смотрит через бесплатный туннель Cloudflare. Вариантов было три: арендовать хостинг, открыть порт на домашнем роутере или пустить трафик через туннель. Хостинг стоит денег каждый месяц, а платформа некоммерческая и выручки не приносит. Порт открыть не удалось — провайдер держит абонента за общим NAT, но выяснилось это сильно позже, об этом ниже. Туннель оставался единственным, что поднимается за десять минут и ничего не стоит.

Вот что это за канал. Домашний интернет отдаёт 24 Мбит/с, через туннель реально доходит 18,5. Оригинал видео с дрона идёт в 30 Мбит/с — то есть один зритель на оригинале выбирает канал целиком и ещё не помещается.

В пиковые вечера в платформе одновременно сидело до десяти человек. Одновременно проигрывали видео, по журналам, до четырёх: остальные смотрели снимки, читали находки, размечали. Что при этом происходило с каналом:

Минут с активным просмотром

659

Медианная отдача в такую минуту

4,5 Мбит/с

90-й процентиль

18,3 Мбит/с

Пик

87 Мбит/с — 470% канала

Минут, в которые спрос превышал канал

63, то есть 10%

Девяностый процентиль — 18,3 Мбит/с при потолке 18,5. Совпадение неслучайное: канал и был ограничителем, спрос упёрся в него и там остановился. В десятой части активных минут просили больше, чем канал мог отдать, в пике — почти впятеро больше.

Что это значит на практике. Когда запросов больше, чем канал может отдать, ничего не падает: видео просто едет медленнее, чем проигрывается. Плеер замирает, добирает буфер, идёт дальше. Человек видит подтормаживание на пару секунд. В пике, когда просили вчетверо больше канала, минута видео едет около четырёх минут — там пауза уже на полминуты и больше.

Насколько это критично: каждая десятая минута работы шла с такими паузами. Для одного человека это раздражает. Для операции, где материал надо просмотреть быстро и пока он актуален, из часа за компьютером заметная часть уходит на ожидание вместо просмотра. Из‑за этого облегчённые копии и понадобились.

Отказа в обслуживании при этом не случилось ни разу. HTTP‑видео деградирует мягко: буфер наполняется медленнее, картинка подтормаживает, соединение не рвётся и страница не падает. Люди это чувствовали — в те два вечера тормозило всё, и говорили мне именно об этом.

Расширить канал было негде, поэтому пришлось уменьшить то, что по нему едет. Воркер стал делать облегчённую копию каждого видео: то же изображение, битрейт ниже в девять раз. На копии зритель стоит 2–3 Мбит/с, и десять человек перестают быть проблемой. Сделал я это 22 августа, уже после операции — то есть оба тяжёлых дня прошли на оригиналах, в самом дорогом режиме из возможных.

Всего через туннель за те дни прошло около 19 ГБ. Оценка: по каждому файлу беру долю просмотренного от его размера. На облегчённых копиях та же работа стоила бы примерно 2 ГБ.

Побочное следствие, которого я не планировал. Облегчённая копия самодостаточна и не зависит от того, где лежит оригинал. Как только просмотр перестал требовать исходного файла, материал стало можно вынести в Google Диск: платформа отдаёт копию со своего диска, оригинал живёт в облаке и скачивается только по явной просьбе человека. Вся облачная часть проекта выросла из решения про битрейт.

Что в цифры не попало. Люди сидели в платформе часами подряд в ситуации, где каждый час на счету. А когда операция закончилась, мне продолжили писать — с просьбами добавить то и это. Жалоб на платформу не было ни одной за всё время. Для инструмента, который в первый вечер ломался по шесть раз, я считаю это лучшим показателем, чем любая цифра выше.

Первый вечер

Между выдачей инструмента и первой правкой прошло два часа.

Первым сломалась очередь. Фотографии обрабатываются за минуту, видео — за часы, а очередь была общая и честная: в порядке поступления. Волонтёр залил снимки, они встали за трёхчасовым видео и не появились до ночи. Очевидно — но только когда увидишь.

Дальше пошла производительность, и здесь я недооценил всё сразу. Детектор на CPU разворачивается на все ядра по умолчанию. Пока он считал, у всех разом переставал отвечать интерфейс: веб‑слою не доставалось процессора. Помогла расстановка приоритетов: детектор запускается с пониженным приоритетом, на инференс поставлен потолок, веб‑слою зарезервированы ядра.

Третье было хуже: платформа бралась обрабатывать всё подряд. Материала 58,6 ГБ, детектор идёт медленнее реального времени — это недели счёта, причём не по тому, что нужно сейчас. Появился тумблер: обрабатываем только то, что выбрали руками.

Шесть правок за вечер, все мелкие. Но между ними есть общее: ни одну я не мог придумать заранее. У меня в голове была модель работы, у людей — сама работа, и в первый же вечер они разошлись по всем швам.

Чего стоит одно наблюдение

Прежде чем про инженерию — про то, ради чего всё это. Волонтёр увидел что‑то на склоне. Дальше начинается работа, которая к поиску отношения не имеет.

Шаг

Вручную

Платформа

Остановить, зафиксировать таймкод

~20 с

клавиша

Скриншот, сохранить, назвать

~30 с

рамка мышью

Строка в таблице

~45 с

та же форма

Найти координаты в SRT по таймкоду

2–4 мин

автоматически

Выложить в чат, связать со строкой

~90 с

не нужно

Итого

~5 мин

< 1 мин

Самая дорогая строка — координаты. Вручную — это значит открыть файл телеметрии, промотать до нужного таймкода, прочитать блок, перенести широту и долготу без опечатки. Платформа берёт их по таймкоду сама, а у восьми пометок из 31 ещё и считает вероятную точку объекта на земле по GPS, высоте и углам подвеса камеры. Вручную — это тригонометрия на каждое наблюдение.

Левая колонка — оценка, а не замер. Чем именно пользовались волонтёры, я не спрашивал: это реконструкция обычного ручного разбора, а не описание их работы. Правая колонка измерена: медианный интервал между двумя подряд идущими пометками одного человека — 58 секунд. Оговорка: таких пар в данных всего пять, так что это порядок величины, а не статистика.

На 31 наблюдении набегает около двух с половиной часов. Для сравнения: вся работа в платформе заняла 3 часа 18 минут человеко‑часов — это время в самом инструменте, а не весь разбор материала, его команда вела отдельно и куда дольше.

Как пускали людей внутрь

Первый вечер дал задачу, которую я не закладывал. Волонтёры подключались десятками, а у платформы был один общий пароль и поле «введите имя» — любое. Кто именно сейчас работает, платформа не знала, и узнать было неоткуда.

Настоящая ролевая модель тут избыточна. Учётные записи, пароли, их восстановление, группы, матрица прав — это недели работы, а операция шла сейчас. Нужно было то, что поднимается за вечер и при этом не открывает дверь настежь.

Решение придумалось почти сразу: опознавать людей через телеграм‑бота. Готового бота у поисков не было — я написал его сам, под платформу и прямо в те дни. Telegram выбран потому, что там уже шла вся переписка операции: человеку не нужно ставить ничего нового, он пишет боту из того же приложения, в котором получает сводки.

Дальше просто: человек пишет боту, координатор одобряет, бот присылает персональную ссылку входа. Ссылка несёт личный ключ, и платформа с этого момента знает, кто пришёл.

Две детали, без которых схема не работает:

  • Ссылка уходит отдельным сообщением и самоуничтожается через пять секунд. Ключ на предъявителя, оставшийся в переписке, живёт в истории Telegram, попадает в резервные копии телефона и виден любому, кто заглянет через плечо. Переслать такой чат означало бы передать доступ.

  • Общий пароль в переписку не отправляется никогда. Он остаётся запасным входом и живёт только в конфиге на машине.

Ролей вышло четыре, и устроены они лесенкой: смотреть и размечать находки; то же самое без права комментировать; плюс удалять любые комментарии; плюс загружать файлы и запускать обработку. Как это разошлось на практике:

Разработка платформы координации ПСР во время реальной спасательной операции - 3

Почти всем нужно ровно одно: открыть материал и отметить найденное. Ради трёх человек полноценную ролевую систему строить было незачем.

Почему за неделю никто ничего не сломал

Тут стоит быть точным, потому что напрашивается лестный ответ. Роли помогли, но главное лежит в другом месте: удаления материалов в интерфейсе нет вообще. Ни у кого, включая администратора.

Через веб можно убрать свою пометку. Модератор может убрать чужой комментарий или отметку на карте. На этом список заканчивается. Файл, отчёт, результат чужой работы не удаляется никак — такой кнопки не существует.

Права разграничивают доступ к тому, что уже построено. Функция, которой нет, прав не требует вовсе, и выдать её по ошибке невозможно.

Дыра при этом осталась, и я знаю какая. Сломать платформу можно, не удаляя ничего: достаточно забить диск. Загрузка файлов и запуск обработки доступны администратору, потолок на один запрос — 4 ГБ, а свободное место перед загрузкой не проверяется вовсе. Несколько загрузок подряд — и места не остаётся, причём первой пострадает база: ей нужно место под журнал.

Защита тут была организационная: администратор один, и это я. Ровно то же, что с метрикой покрытия в конце статьи — обошлось не потому, что предусмотрели. Проверка свободного места перед загрузкой это несколько строк кода, которых до сих пор нет.

Как бы то ни было, за неделю через платформу прошло 78 человек, и ни один материал, ни одна пометка, ни одно обсуждение не пропали.

Правило, оставшееся в проекте. Права не выдаются по догадке о том, кто человек. Совпало имя в чате с именем в боте — это не основание. Роль ставится явным действием координатора по идентификатору, а не по похожести ника.

Каждая фича тянула за собой опору

Дальше выяснилась закономерность, которой я не ждал. Почти каждая просьба волонтёров упиралась в то, чего под платформой ещё не было, и требовала нового куска инфраструктуры.

Просили

Пришлось построить

Понимать, кто что посмотрел, и выдать кому‑то модерацию

Отдельный процесс‑бот с персональными ссылками входа, роли, самоудаляющиеся сообщения с ключом

Разделить материал двух операций

Новая схема базы и миграция уже накопленного — на живой базе, под работающими людьми

Менять настройки обработки без перезапуска

Таблица настроек в базе с реестром границ: сервер их меняет, воркер применяет, а общаются они только через базу

Не терять работу людей

Резервное копирование со сверкой критичных таблиц

Видеть, что платформа жива

Метрики в формате Prometheus, дашборд в Grafana, отметки живости процессов, тревоги в бот

Каждая такая правка меняла что‑то под всеми остальными: схему базы, порядок запуска, права. В монолите любая из них означала бы остановку всего на время выкатки — а останавливать было нельзя, люди работали прямо сейчас.

К 15 августа платформа уже была разведена на три процесса, которые общаются только через SQLite и файловую систему. Именно это и позволило пережить неделю таких правок: миграция схемы шла в воркере, пока веб‑слой отдавал страницы; новый бот поднимался, не трогая ни того, ни другого. Цена обновления каждого куска — в таблице ниже, и там же видно, почему разделение окупилось.

Почему платформу можно было чинить, не останавливая

Правки первого вечера уехали к людям в тот же вечер. Это возможно из‑за единственного решения, принятого до операции: платформа — три процесса, которые общаются только через SQLite и файловую систему. Ни брокера, ни очередей, ни общей памяти. Воркер обрабатывает и о веб‑слое не знает вообще; сервер только читает базу и ничего не запускает; бот раздаёт доступ.

Что обновляем

Чем занят

Простой

Что прерывается у людей

Детектор

ищет объекты на кадрах

0 с

ничего: новый процесс на каждое видео

Веб‑слой

отдаёт страницы и API, только читает

1,1 с

ничего: обработка идёт отдельно

Воркер

очередь, облегчённые копии, превью, телеметрия

~3 с

одно видео пересчитается заново

На практике это выглядело так: я перезапускал веб‑слой десяток раз за вечер, а воркер в это время молотил 57 часов подряд и ни одного перезапуска не заметил. Детектор вообще обновляется без остановки чего‑либо — воркер запускает его отдельным процессом на каждый файл и подхватывает свежий код сам.

Что это дало в операции. Ни одно обновление не отняло у волонтёров работу. Человек, размечавший видео в момент выкатки, в худшем случае видел секундную заминку. Правка, требующая остановки обработки, не поехала бы до ночи — а ночью она уже никому не нужна.

Вывод из этого не «микросервисы хорошо». Граница между процессами окупилась не масштабом — до него не дошло, — а возможностью трогать работающее.

Два дня, на которые пришлось всё

15 августа в платформе работали 17 человек, 16-го — 12. Дальше единицы. На эти двое суток пришлось 94% всей работы, которую люди вообще в ней сделали.

Так выглядит волонтёрское внимание: короткое окно, в которое нужно попасть с первого раза. Инструмент, доведённый до ума к 20-му числу, опоздал бы ровно на всё.

В эти два дня вскрылась ещё одна вещь посерьёзнее вечерних мелочей: обсуждение уходило в чат и теряло связь с находкой. «Вот это на 14:22» — а в каком файле, через день уже не восстановить. Обсуждение переехало в карточку находки, к самому кадру.

Правка не про удобство. Обсуждение без привязки к кадру — это данные, которые через неделю уже нельзя восстановить, и никакая внимательность людей этого не заменяет: сохранять контекст обязан инструмент.

Карточка находки: таймкод 0:51, заголовок «подозрительная деталь», строка с координатами дрона и ссылкой на карту, кнопка раскрыть телеметрию кадра, выпадающий статус «аномалия (непонятно, но подозрительно)», ниже два комментария и поле для нового.

Та самая карточка находки. Кадр, таймкод, координаты из телеметрии, статус проверки и обсуждение — в одном месте, вместо сообщения «вот это на 14:22» в общем чате. Координаты на снимке закрыты: место настоящее.

Мелочи, из которых складывается скорость

Дальше пошли правки, каждая из которых экономит по минуте. По отдельности они выглядят необязательными, вместе — определяют, станут ли инструментом пользоваться.

Постоянная ссылка на находку идёт через бота

Находку надо показать другому: скинуть в чат, чтобы вторая пара глаз посмотрела. Очевидное решение — скопировать адрес страницы. Оно не работает.

Платформа смотрит наружу через туннель, и адрес у него меняется при каждом перезапуске. Ссылка, отправленная вечером, к утру ведёт в никуда. Это худший вид поломки: ошибки нет, просто открывается пустота или чужая страница, и человек решает, что находку удалили.

Поэтому постоянная ссылка ведёт не на платформу, а на бота:

https://t.me/<бот>?start=finding_42

Бот знает текущий адрес и перекидывает куда надо. Заодно проверяет доступ: кто ещё не внутри, получит заявку, а не белый экран с паролем. Мусор в параметре ошибкой не считается — человек мог поделиться чем угодно, и бот просто выдаёт обычный вход.

Это снова про ограничение, которое не обойти кодом. Адрес непостоянный, потому что нет своего сервера, а своего сервера нет по причинам из раздела выше. Ссылка через бота — обход, а не решение, но он держится уже месяц.

Разметка не выходя из полного экрана

Объект, ради которого всё затевается, на 4K‑кадре занимает десяток пикселей. Разглядывать такое в окне браузера рядом с панелями бесполезно, поэтому работа идёт в полном экране с увеличением. Размечать находку надо там же: выход из полноэкранного режима ради рамки означал бы терять место в видео каждый раз.

Поэтому весь цикл живёт на клавиатуре и не требует ни одной кнопки на экране:

F

полный экран

+ − 0

увеличить, уменьшить, сбросить масштаб

← →

перемотка на 5 секунд, с Shift на 10

Ctrl + ← →

по одному кадру

M

начать разметку — видео встаёт на паузу

Enter

сохранить пометку

Esc

бросить недорисованную рамку

пробел

пауза

F и M срабатывают и на русской раскладке, от букв «а» и «ь». Волонтёр смотрит видео и пишет комментарии по‑русски; требовать переключения раскладки ради горячей клавиши значит гарантировать, что ею не воспользуются.

Увеличение работает колесом, клавишами и щипком на тачпаде, кадр таскается мышью. Рамка рисуется в SVG с vector-effect: non-scaling-stroke — при зуме линия остаётся тонкой вместо того, чтобы расползаться вместе с картинкой.

Мелочь, стоившая отдельной правки. Проверка «человек сейчас печатает» сначала спрашивала просто «фокус в поле ввода?». Стоило чекбоксу «показать оригинал» получить фокус — и переставали работать все горячие клавиши разом, включая пробел и M. Теперь проверка смотрит на ТИП поля: текстовое поле и textarea забирают клавиши себе, чекбокс и кнопка — нет.

Остальное по мелочи

  • Клик по находке открывает плеер ровно на её кадре. А при разметке видео само встаёт на паузу: иначе таймкод уезжает, пока рисуешь рамку, и пометка встаёт не туда.

  • Телеметрия кадра копируется одним нажатием — видна на снимке выше. Раньше это означало открыть SRT и искать нужный блок глазами.

  • Поиск идёт по всей операции, а не по текущей папке. Человек ищет файл ровно тогда, когда не помнит, в какой тот папке. Список берётся один раз и фильтруется прямо в браузере: мгновенно, без запроса на каждую букву.

  • Выгрузка находок в KML и GPX. Координатор работает в своей карте или в навигаторе, и тащить его в платформу незачем. В выгрузке позиция дрона и расчётная точка объекта разведены по разным папкам: на боевых данных они расходятся на 653 метра, и подписать одно другим значит увести группу в соседнее ущелье.

  • Покадровая перемотка по Ctrl со стрелками. На мелком объекте один кадр решает, попал ты в него или нет.

Как всё это влезло в один ноутбук

Теперь про ограничения, потому что основная работа здесь была не алгоритмическая. Надо было уместить операцию в машину, которой для неё не хватает.

Исходные условия: материал операции — 58,6 ГБ, свободного места на ноутбуке — около 16 ГБ. Разница не покрывается ничем, кроме решения не хранить оригиналы вообще.

Для просмотра человеку не нужен исходник в 30 Мбит/с. Воркер фоном делает облегчённую копию (H.264, CRF 26, +faststart) — то же изображение, годное для поиска глазами:

Разработка платформы координации ПСР во время реальной спасательной операции - 5

Дальше пошла мелкая археология, и она дала больше, чем ожидалось. Разовый скрипт, написанный ещё до операции, убрал из отчётов 412 920 ложных детекций, но файлы кропов намеренно оставил — «дешевле места, чем риск удалить нужное». Месяц спустя это оказалось 2,81 ГБ мусора. Отдельно выяснилось, что кроп писался на каждую детекцию, а в отчёт попадает не больше 80 на сцену: медиана сцены — 9 детекций, максимум 1598. Всё сверх показываемого лежало мёртвым грузом.

Урок, за который заплачено дважды. Разовый скрипт, оставляющий за собой файлы, обязан оставлять и способ их убрать. Иначе через год никто не вспомнит, что эти файлы значат, и трогать их будет страшно.

Место кончалось не только у материала. Когда материал переехал в облако, появилась временная папка, куда файлы качаются перед обработкой, — и у неё жёсткий потолок с вытеснением по давности. Закреплённые (те, что обрабатываются прямо сейчас) не вытесняются никогда, а если освободить нечем — скачивание просто не начинается. Это не перестраховка: рядом лежит база, которой нужно место под журнал, и переполнить диск под ней нельзя. Потолок задаётся человеком, поэтому он же сверяется с реальным свободным местом и бережёт два гигабайта про запас.

Как всё это влезло в домашний канал

Со связью та же история. Материал в облаке, канал домашний, и каждое лишнее скачивание оплачивается временем людей.

Первое: превью не требует файла целиком. Кадр для списка — это один кадр, а раньше ради него качалось всё видео. Теперь ffmpeg читает удалённый файл по HTTP кусками и берёт только индекс (у DJI он в конце) и начало первого кадра:

Разработка платформы координации ПСР во время реальной спасательной операции - 6

Второе: скачивание и кодирование делят диск, но не канал и не процессор. Пока они ждали друг друга, платили сумму — 3,7 часа закачки плюс 3,1 часа кодирования. Следующий файл теперь качается, пока кодируется текущий, и платим за большее из двух: 6,8 ч превратились в 3,7.

Третье я привожу как пример гипотезы, которая не подтвердилась. Логично было предположить, что хранилище режет скорость на одно соединение, и качать надо в несколько потоков. Замерил:

Потоков

1

2

4

8

Мбит/с

30,2

33,6

34,8

36,1

Потолок в самом канале. Восемь потоков дают 20% прироста, а обычная загрузка и так выбирает 35 Мбит/с. То есть 3,7 часа — это физический пол, кодом его не пробить, и хорошо, что это выяснилось замером за десять минут, а не реализацией за два дня.

Что мешало и что удалось сделать

Почти всё развитие платформы упиралось в железо и канал, а не в задумки. Список препятствий и того, чем каждое закрылось:

Упёрлись в

Что сделали

Результат

Детектор занимал все ядра, интерфейс замирал у всех сразу

пониженный приоритет процесса, потолок на инференс, резерв ядер веб‑слою

интерфейс отвечает во время счёта

58,6 ГБ материала против 16 ГБ свободного места

облегчённые копии вместо оригиналов

6,4 ГБ

Превью одного кадра требовало скачать файл целиком

чтение удалённого файла по HTTP кусками

2684 МБ → 4,8 МБ

Скачивание и кодирование шли по очереди

конвейер: следующий файл качается, пока кодируется текущий

6,8 ч → 3,7 ч

Кроп писался на каждую детекцию, в отчёт идёт не больше 80 на сцену

удаление непоказываемых после группировки

экономия диска

Токен Google живёт час, а скачивание трёхгигабайтного файла — дольше

продление доступа внутри провайдера, в том числе посреди загрузки

загрузка переживает смену токена

Один файл, залитый в облако не до конца, занимал единственный слот сборки копий

отступ с паузой после неудачи, причина пишется в карточку материала

очередь из 80 файлов не встаёт за одним

Временная папка могла переполнить диск, на котором лежит база

жёсткий потолок со сверкой реального свободного места и резервом 2 ГБ

нечем освободить — скачивание не начинается

Общее у всех строк одно: ни одна не про алгоритмы. Это работа с ограничениями машины, на которой всё крутится.

Чего сделать не вышло

Проброс порта вместо туннеля. Упёрлось в CGNAT — физически неразрешимо на этом подключении, подробности выше.

Переезд на арендованный сервер. Технически это снимает разом всё: и потолок канала, и десять зрителей, и зависимость от туннеля. Упёрлось в деньги. Платформа некоммерческая, выручки у неё нет, а сервер с достаточным каналом стоит ежемесячно. Считать это инженерной задачей не получается — она экономическая, и пока не решена.

Десять одновременных зрителей. Следствие предыдущего: домашний канал отдаёт 24 Мбит/с, туннель 18,5, а нужно 34,5 постоянно и до 159 на пике. Никакой код этого не меняет.

Файл, залитый в облако не до конца. Источник неполон, чинить нечем. Удалось только не дать ему останавливать остальные.

Мониторинг

К 18 августа платформа работала третьи сутки, и я не мог ответить на простой вопрос: она сейчас вообще жива? Узнать, что обработка встала, можно было только зайдя и посмотрев глазами.

За день появилось следующее:

  • /healthz — состояние одним запросом, без обхода папки отчётов: считать её размер синхронно означало вешать проверку на секунды.

  • /metrics в формате Prometheus — счётчики и время ответа по разделам, возраст резервной копии, свободное место.

  • Отметки живости процессов в базе. Долгие операции вроде кодирования копии обязаны обновлять отметку в цикле, иначе мониторинг объявит воркер мёртвым прямо во время нормальной работы.

  • Prometheus и Grafana в docker‑compose, дашборд и источник данных заведены файлами, чтобы поднималось одной командой.

  • Тревоги в тот же телеграм‑бот, через который люди получают доступ.

Дашборд состояния платформы: плашки «онлайн сейчас», «платформа работает», возраст отметки воркера, свободное место, давность копии базы, число залипших файлов; ниже графики процессора и памяти и плашки RPS, доли ошибок, 95-го процентиля ответа и времени с перезапуска сервера.

Верх дашборда: всё, что нужно, чтобы за пять секунд понять, жива ли платформа. «Залипло в обработке» — счётчик файлов, застрявших в статусе «обрабатывается».
Полосы состояний за пять суток по шести проверкам: backup, disk, errors, stuck, tunnel, worker. У backup и disk есть красные и оранжевые участки, у worker несколько коротких оранжевых засечек, errors и tunnel зелёные целиком.

Та же неделя полосами. Красное у disk — место кончалось на самом деле; оранжевые засечки у worker — моменты, когда отметка живости не обновлялась дольше порога.

Через два дня добавился учёт нагрузки на API: сколько запросов и за сколько отвечает каждый раздел. Это оказалось полезнее, чем ожидалось, — именно оттуда стало видно, что список материалов собирается обходом диска на каждый запрос.

Четыре панели: ответы с ошибкой по кодам 404 и 416, самые медленные разделы API, число файлов по статусам done и idle, и свободное место на диске с отметкой занятого отчётами.

Ошибки по кодам, самые медленные разделы API, файлы по статусам и свободное место. Провал на графике места в середине недели — та самая чистка сирот.
Три панели: история числа людей онлайн за пять суток, накопительные счётчики ручных пометок, триаж-статусов и людей с доступом, и суммарные часы просмотра.

История присутствия и накопительные счётчики работы людей. Полка на всех трёх кривых — это и есть окончание активной фазы.

Сторож туннеля однажды повис на восемь суток. subprocess.run без таймаута ждёт вечно. Внешняя команда не вернулась, процесс замер, отметка живости перестала обновляться — но заметил я это только через неделю, потому что за самим сторожем никто не следил. Теперь все внешние вызовы идут через обёртку с таймаутом.

Чего нет до сих пор. Проверки снаружи, с другой машины. Всё, что описано выше, живёт на том же ноутбуке, что и платформа: если умрёт он, сообщить о недоступности будет некому. Телеграм‑бот тоже не отправляет отметку живости — он может молча упасть, и узнаю я об этом от людей.

Что ломается молча

Счётчик работающих людей на дашборде показывал ноль. Всегда. Причина — сравнение числа со строкой. Загрузка процессора показывала ноль тоже, причина другая: гонка за состояние. Тревоги о недоступности падали на боевой базе, то есть не приходили именно тогда, когда были нужны. Три индикатора, поставленные, чтобы видеть проблемы, сами были сломаны и об этом молчали. Каждый показывал правдоподобное значение: ноль онлайн вечером буднего дня подозрений не вызывает.

Тогда я счёл это случайностью. К 20 августа активная фаза кончилась, появилось время посмотреть на данные. Случайность на этом кончилась.

Отметки триажа не показывались. Ни одной. Волонтёры проставили 47 пометок «это важно» / «это не то» — их не видел никто, включая меня. Неверное имя колонки в ORDER BY, а рядом проглоченное исключение: запрос падал, ошибка гасилась, список приходил пустым. Пустой список отметок выглядит точно так же, как список, в котором отметок ещё не поставили.

Рамки детектора не рисовались вообще. Функция отдавала {w, h}, а отрисовка читала rect.width. setAttribute молча принимает строку «NaN» — ни ошибки в консоли, ни рамок на экране.

Полоса покрытия показывала 86% там, где реальное покрытие просмотрами было кратно меньше. Ни падения, ни строки в журнале — просто число, которое выглядело правдоподобно.

Это один и тот же баг, написанный трижды. Платформа не падала, кнопки нажимались, все три отказа прошли боевое применение незамеченными. Отсюда правило, которое с тех пор в проекте жёсткое: except: pass и пустой catch запрещены по умолчанию, а если исключение действительно можно проглотить, рядом стоит комментарий почему.

Что покрытие меряет на самом деле

Третий пункт неприятнее двух первых, и не потому, что это баг. Полоса покрытия — центральная метрика платформы, вся идея была в честном ответе на вопрос «эти секунды кто‑нибудь смотрел». Починить формулу оказалось проще, чем понять, что она означает.

Важное. К моменту, когда платформу показали волонтёрам, команда ПСР уже отсмотрела 100% материала. Непросмотренных файлов в операции не было. Значит, полоса меряет не «смотрел ли кто‑нибудь этот материал», а «смотрел ли кто‑нибудь его здесь».

Это разные вопросы. Файл с нулевым покрытием в платформе мог быть трижды разобран человеком вручную ещё до того, как она появилась. Отсюда правило, которое я теперь считаю частью самой метрики: покрытие осмысленно считать только для файлов, опубликованных 15 августа и позже — с момента, когда платформа стала рабочим местом, а не витриной уже сделанной работы.

Снято

Видео

Открывали здесь

Что означает ноль

до 15 августа

82

16

смотрели не здесь: платформы ещё не было

15 августа и позже

32

22

здесь ноль уже содержательный

Кто это сделал

Работа распределилась неровно даже среди тех, кто пришёл. Пятеро самых активных дали 151 минуту просмотра из 182 — восемьдесят три процента.

Минут просмотра

Файлов

Дней

Волонтёр А

64,1

15

2

Волонтёр Б

42,1

12

2

Волонтёр В

22,9

9

2

Волонтёр Г

11,7

9

1

Волонтёр Д

10,4

5

2

Имена скрыты не для красоты: в платформе есть режим обезличивания отчёта, сделанный ровно для того, чтобы такие таблицы можно было показывать наружу. От замены имён буквами структурный вывод остаётся прежним. Показывать посторонним, кто сколько отработал на поисках, незачем.

Внутри этой рамки видно то, чего при разборе вручную не узнать никак. Внимание распределяется крайне неровно: один файл открывали десять раз, большинство — ни разу. Это не пробел в разборе, это то, что происходит, когда никто не назначает, кто что берёт. Двое сделали шестьдесят процентов всех пометок — и это про то, кого не надо сжигать в следующий раз.

Что осталось за человеком

Обещанное в начале. Медиана от просьбы до боя — одиннадцать минут в работе, 91% правок уложились в час, ни одна не ушла в следующий день. Но интереснее не скорость, а то, где инструмент не помогает вовсе.

Решение

Человек

Claude Code

Что делать и в каком порядке

95%

5%

Архитектура и границы между частями

80%

20%

Постановка внутри фичи

60%

40%

Код и тесты

20%

80%

Обнаружение дефектов

10%

90%

Оценка моя, по ощущению от процесса, а не замер. Но решение отдать фото вперёд видео, решение разнести процессы, решение считать покрытие только с 15 августа — ни одно из них не следует из кода. Средняя строка самая интересная: формулировка «что именно должно происходить» делится пополам и чаще всего оказывается узким местом.

Платить за темп приходится конкретным классом ошибок — тем самым, что дал и мёртвые индикаторы, и невидимые отметки: правдоподобный код, который незаметно неверен. Я внёс такого достаточно. Резервную копию однажды адресовал буквой диска — внешний SSD вынули, букву занял следующий носитель, и повторный запуск отправил бы архив с ключами доступа на карту памяти камеры. Просмотры считал по общей ленте времени, отчего двое, смотревшие одновременно, слипались в один, и выходило «просмотров 6, людей 8».

Ни одну из этих ошибок нельзя было увидеть, перечитав код. Все поймались тестом, мутацией или замером. Отсюда и доля тестов в проекте: при таком темпе чтение кода перестаёт работать как контроль качества. Генерация ускоряет написание, но не проверку, и сэкономленное приходится возвращать обратно в проверку. Иначе экономия превращается в код, который врёт правдоподобно.

Цена ошибки

В этой операции цена всех трёх молчаливых отказов оказалась нулевой. Команда ПСР прошла весь материал руками, и врущая полоса покрытия ничего не скрыла.

Но это везение организационное, а не инженерное. Метрика покрытия существует ровно для того случая, когда материала больше, чем людей, и второго прохода руками не будет. Именно там она обязана быть точной — и именно там её никто не перепроверит.

Если интересны цифры: 42 657 строк, из них 27 599 исполняемых, 11 982 — тесты. 156 коммитов за девять рабочих дней, 1550 тестов на сегодня. Сравнивать по строкам кода некорректно, поэтому они здесь мелким шрифтом и в конце.

Платформа открыта под MIT. Она не заменяет поисковые протоколы и не решает, где искать, — она показывает, куда уже смотрели люди и что они там увидели. Остальное остаётся за координатором и наземными группами.

Автор: fedotov-dev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js