Почему 200 OK врёт: что я переделал в проверке доступности сайтов

Задача казалась простой: раз в минуту сходить на сайт и сказать, работает он или нет. Первая версия уместилась в десяток строк. Делаем GET, смотрим на код ответа, если не 200, шлём уведомление в Telegram. Через неделю я понял, что почти каждая из этих строк врёт.

Расскажу, что пришлось переделать и почему. Пишу на Python (httpx, asyncio, SQLite), но грабли тут от языка не зависят.

«Не ответил» не всегда значит «сайт упал»

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

В итоге у проверки стало четыре исхода вместо двух. С ok всё понятно. fail значит, что сайт ответил, но плохо: 5xx, 4xx, пустое тело, нет нужного текста, или соединение открылось, а ответа мы так и не дождались. Только после него монитор уходит в DOWN. infra_error я ставлю, когда сломалось что-то у меня: умер прокси, кончились соединения в пуле, не получилось подключиться через прокси. Статус тогда не трогаю и проверяю ещё раз через минуту. И unknown: по ответу ничего нельзя сказать, статус тоже остаётся прежним.

Отсюда правило, которое я стараюсь не нарушать: infra_error и unknown никогда не превращаются в DOWN. Пусть лучше одна проверка пропадёт, чем человек проснётся ночью из-за моей же поломки.

Труднее всего было понять, где чья ошибка. В httpx это видно по типу исключения:

if isinstance(exc, httpx.ProxyError): return make(RESULT_INFRA, REASON_PROXY_DEAD)if isinstance(exc, httpx.TimeoutException): # Соединение уже есть, ждали ответ, значит «сайт не ответил». if headers_received or isinstance(exc, (httpx.ReadTimeout, httpx.WriteTimeout)): return make(RESULT_FAIL, REASON_TIMEOUT) return make(RESULT_INFRA, REASON_CONNECT)if isinstance(exc, httpx.RemoteProtocolError): # Сайт прислал пустой или битый ответ, это его проблема. return make(RESULT_FAIL, REASON_EMPTY_BODY)

Если TCP-соединение открылось, а потом сайт замолчал, виноват сайт. Если не удалось даже подключиться, виноват может быть и сайт, и наш маршрут до него. С этим случаем я до сих пор не разобрался до конца: «соединение отклонено» выдаёт и лежащий сервер, и сломанный прокси. Это место я ещё дорабатываю.

И ещё одна вещь, на которую я наткнулся не сразу. Таймаут в httpx ограничивает отдельные операции (подключение, чтение очередного куска), а не весь запрос целиком. Сайт, который отдаёт по байту в секунду, может «отвечать» вечно. Поэтому всю проверку я обернул в общий лимит:

return await asyncio.wait_for(_perform(), timeout=CHECK_TIMEOUT) # 10 с

200 OK ещё не значит «работает»

Хуже всего поломки, при которых сервер отдаёт 200. После обновления шаблона пропала форма заявки, а страница при этом открывается. Вместо сайта висит «Сайт на техническом обслуживании» или «Error establishing a database connection», и многие CMS отдают такие страницы с кодом 200. Хостинг подсунул свою заглушку. Выгрузка сломалась, и из карточек товаров исчезли цены. Если смотреть только на код ответа, все эти сайты работают.

Помогает проверка содержимого. Пользователь указывает фразу, которая точно есть на рабочей странице, а проверка ищет её в ответе. Казалось бы, if text in body, и всё. Не всё.

Во-первых, ответ может быть огромным. Кто-нибудь поставит на мониторинг ссылку на файл в 2 ГБ, и проверка начнёт его качать. Поэтому я читаю тело потоком и останавливаюсь на 256 КБ:

async for chunk in resp.aiter_bytes(): remaining = BODY_MAX_BYTES - len(data) if len(chunk) > remaining: data.extend(chunk[:remaining]) truncated = True break data.extend(chunk)

Но если тело обрезано, отсутствие фразы ещё ничего не доказывает. Страница длиннее, и фраза может быть дальше. Такой случай я помечаю как unknown, а не fail. Поэтому фразу лучше брать из верхней части страницы, например кнопку на первом экране или заголовок, а не что-то из футера.

Во-вторых, регистр и пробелы. На экране написано «Оставить заявку», а в HTML это может быть Оставитьn заявку с переносом и отступами из вёрстки или вообще «ОСТАВИТЬ ЗАЯВКУ». Поэтому и фразу, и страницу я перед сравнением привожу к одному виду:

_WS_RE = re.compile(r"s+")def _normalize_text(text: str) -> str: return _WS_RE.sub(" ", str(text or "")).strip().casefold()

casefold() стоит вместо lower(), чтобы сравнение работало и в языках, где с регистром всё сложнее, чем в русском и английском.

С кодировкой было проще. Тело декодируется по кодировке из заголовков, а если не вышло, битые символы заменяются (errors="replace"), и проверка не падает. Сайты на windows-1251 до сих пор попадаются.

А вот JavaScript проверка не выполняет, и с этим ничего не сделать. Она видит исходный HTML, как в «Просмотре кода страницы», а не то, что потом нарисовал скрипт. Если форма собирается на React, её текста в HTML может просто не быть. Так что фразу стоит сначала поискать в исходном коде страницы.

Ложные тревоги: повторять, но не ждать

У частых проверок есть обратная сторона. Сеть моргнула на секунду, и в три часа ночи приходит «САЙТ УПАЛ». После пары таких ночей уведомления перестают читать и пропускают уже настоящую аварию.

Обычно делают так: DOWN объявляют только после нескольких неудачных проверок подряд. Мне это не подошло. При интервале в 2 минуты три проверки займут 6 минут, и за это время о падении тебе скорее напишет клиент.

Я сделал по-другому: неудачная проверка сразу повторяется, без паузы. Проверки идут через пул прокси, и для повтора я беру другой прокси, если он есть. Так отсекается сбой конкретного маршрута. Вторая попытка прошла? Значит, это был сбой по дороге. Обе неудачные? DOWN объявляется в ту же минуту.

Второе, что убрало шум: уведомление привязано к смене состояния, а не к каждой проверке. Сайт лежал час, проверок прошло тридцать, а сообщение о падении пришло одно. «Сайт снова работает» приходит, только если перед этим было «сайт упал». Иначе каждый новый сайт присылал бы бессмысленное «UP» сразу после добавления. И отметку «уведомление отправлено» я ставлю, только когда Telegram подтвердил доставку. Если он был недоступен или сервис перезапустился посреди отправки, уведомление уйдёт на следующей проверке. Отдельная очередь сообщений для этого не понадобилась.

if last_alert_status == status: return # об этом состоянии уже сообщилиif status == 1 and last_alert_status != 0: return # UP без предшествующего DOWN не шлёмsent = await deliver_alert(...)if not sent: return # last_alert_status не трогаем, повторим на следующей проверке

Пользователь даёт URL, сервер делает запрос. Это SSRF

Мониторинг по своей природе ходит по адресам, которые ему прислали. Если их не проверять, кто-нибудь добавит http://127.0.0.1:8080/admin или http://169.254.169.254/ (адрес метаданных облака, откуда можно вытащить ключи) и будет читать ответы через сервис.

Начал я с простого: разрешил только http и https и запретил служебные порты вроде SSH, почты и баз данных. Но по схеме и порту не скажешь, куда на самом деле уйдёт запрос. Поэтому имя хоста я резолвлю сам, ещё до запроса, и сверяю все полученные адреса со списком запрещённых сетей: loopback, приватные диапазоны, link-local, CGNAT, multicast, адреса метаданных.

Сначала я резолвил адрес один раз, когда монитор добавляли. Это была ошибка. Владелец домена может указать нормальный IP, дождаться добавления и переключить DNS на 127.0.0.1 (это называется DNS rebinding). Теперь резолв идёт на каждой проверке. Результат кэшируется, но ненадолго: до 5 минут для нормальных адресов и до минуты для запрещённых.

Отдельно пришлось разбираться с редиректами. С follow_redirects=True httpx проверяет только исходный URL, а сайт вполне может ответить 302 Location: http://127.0.0.1/. Поэтому редиректы я обрабатываю вручную, и каждый новый адрес проходит ту же проверку до следующего запроса:

async with client.stream("GET", current, follow_redirects=False) as resp: location = resp.headers.get("location") if resp.status_code in redirect_codes and location: if redirects >= MAX_REDIRECTS: # 5 return too_many_redirects() target = urljoin(current, location.strip()) blocked = await _blocked_target_result(target, ...) if blocked is not None: return blocked current = target continue

Полной защитой от SSRF это не назовёшь, тонкостей там много. Но самые очевидные дыры закрыты.

Планировщик на SQLite

Брокера очередей у меня нет. Расписание лежит прямо в таблице мониторов, в поле next_check_at. Раз в несколько секунд планировщик выбирает просроченные записи и забирает их себе условным UPDATE:

UPDATE monitors SET next_check_at = :lease_until -- сейчас + 60 сWHERE id = :id AND (next_check_at IS NULL OR next_check_at <= :now)

Изменил UPDATE строку, значит монитор наш. Не изменил, значит его уже забрал соседний цикл. 60 секунд заметно больше самой долгой проверки. Если процесс упадёт посреди проверки, через минуту монитор снова окажется просроченным и проверится заново.

Главный урок с этой схемой: сеть должна быть вне транзакций. Транзакция забирает пачку мониторов, потом другая записывает результат, а запросы к сайтам идут между ними. Если держать блокировку SQLite, пока медленный сайт думает 10 секунд, встанет вообще всё.

Пригодились и две мелочи. К интервалу добавляется случайный разброс ±10%. Без него мониторы, добавленные в одну минуту, так и проверялись бы в одну секунду, и нагрузка шла бы пиками. А число одновременных проверок ограничено семафором, чтобы тысяча просроченных мониторов не превратилась в тысячу соединений разом.

Итого

Если бы я начинал заново, то с первого дня заложил бы несколько вещей. Отделять «сайт не ответил» от «у меня сломалась сеть» и никогда не превращать второе в аварию. Смотреть на содержимое страницы, а не только на код ответа, и сразу ограничить, сколько тела читать. С ложными тревогами бороться быстрым повтором, а не ожиданием нескольких неудачных проверок подряд: так не теряются минуты настоящей аварии. И проверять каждый адрес, включая адреса из редиректов, до запроса, а не один раз при добавлении.

Самыми дорогими ошибками у меня оказались не сложные, а очевидные задним числом: доверие к коду 200, резолв адреса один раз и follow_redirects=True по умолчанию.

Открытыми для меня остаются два вопроса. Как надёжно отличить «сайт лёг» от «у нас сеть», когда соединение просто отклонено? И какие ещё обходы стоит учесть в защите от SSRF, если сервис ходит по чужим URL? Если у вас есть опыт с этим, он точно пригодится.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев