Безопасно

Урок о размышлениях об определении конкретных моделей поведения при неудачах

Этим сонным воскресным утром я проснулся с неприятным похмельем от чириканья моего телефона:

Одна из наиболее важных систем, за которую мы несем ответственность, заключалась в том, что она не отвечала на запросы HTTP с ожидаемым результатом. Точнее, он возвращал HTTP 500 или HTTP 502:.

Ложный флаг № 1: соединения MySQL

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

Система не выглядела так, как будто она находится в состоянии какой-либо конкуренции между ЦП и вводом-выводом; действительно, он работал только примерно на ~ 15% своей мощности. Это означает, что проблема, скорее всего, связана с конкретными ресурсами приложения. Глядя на журналы приложений, стало ясно, что по крайней мере некоторые из ошибок были связаны с ограничением максимального числа подключений к MySQL:

SQLSTATE[08004] [1040] Too many connections";i:1;s:3490:"#0 /var/www/__FILEPATH__ /web/lib/Zend/Db/Adapter/Pdo/Mysql.php(111): Zend_Db_Adapter_Pdo_Abstract->_connect()

Система поддерживала около 1000 PHP-воркеров, но по какой-то причине имела максимум 200 подключений к MySQL. Быстрая проверка документов показала, что машина, по крайней мере теоретически, может поддерживать гораздо больше, и мы увеличили это.

Это удалило ошибки MySQL из журнала, но приложение по-прежнему не работало. Глядя на show processlist, я увидел что-то вроде:

# show full processlist; # edited for brevity
+------+---------+---------+------+-------+------------------+
| Id   | db      | Command | Time | State | Info             |
+------+---------+---------+------+-------+------------------+
| 2047 | myDB    | Sleep   |   81 |       | NULL             |
...
| 2049 | myDB    | Sleep   |   81 |       | NULL             |
| 2050 | myDB    | Sleep   |   81 |       | NULL             |
+------+---------+---------+------+-------+------------------+

Все наши PHP-воркеры из одного приложения застряли, делая… ничего?

Более пристальный взгляд на сбойные процессы PHP

Следующим шагом было взглянуть на один из этих процессов PHP. Запустив strace над одним из зависших процессов, сразу же стала видна ошибка:

lstat("/var/www/__FILEPATH__/__RUN_DIR__/locks/flock_f717dc660f746b4945d4aa0ec882b7d9", {st_mode=S_IFREG|0664, st_size=0, ...}) = 0

strace продолжал перебирать этот файл, пытаясь получить flock, но безуспешно. Итак, PHP-воркеры устанавливают соединение с базой данных, а затем блокируют flock. Тайна MySQL раскрыта. А зачем вообще блокировать?

В предыдущей работе это приложение попадало в состояние, в котором оно не разблокировалось, если оно неизящно давало сбой. Учитывая, что система была забита на 100%, стоило просто rm заблокировать и посмотреть, окупилась ли система. Это и перезапуск PHP для уничтожения заблокированных соединений действительно разблокировали приложение, но блокировки были быстро восстановлены, и приложение снова заблокировалось.

Именно в этот момент картина отказа была ясна;

  1. Приложение работало нормально для большинства страниц, но конкретная страница была заблокирована, и, поскольку другие работники также попадали в этот запрос, он также блокировался.
  2. На сервере закончатся работники, и он попадет в состояние критического сбоя.
  3. Сервер не сможет разблокироваться.

Итак, чтобы починить сервер, нам нужно выяснить, почему он падает во время flock.

Ложный флаг № 2: стадо

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

В частности, приложение использовало примитив Linux flock. Кроме того, была зарегистрирована функция очистки __destruct, которая разблокировала и удалила сгенерированный файл lock. Не было никаких других ссылок на flock, кроме этого правильно сгенерированного пути, и в php_errors.log не было ничего, что указывало бы на то, что процессы PHP умерли очень некрасивым образом, например, SIGSEGV (segfault). Несмотря на это, PHP должен выпустить flock даже в случае SIGSEGV.

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

Настоящий флаг: сбой стороннего сервиса

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

Core: Exception handler (WEB): Uncaught TYPO3 Exception: #1: PHP Warning: file_get_contents(http://third.party.service/api/bork.php?x=1,y=2)

Этот сервис является общедоступным, и обращение к нему в браузере сразу выявило проблему. Запрос займет 60 секунд, а затем завершится ошибкойHTTP 502.

Это объясняло весь блок ошибок:

  1. Рабочие попытаются получить данные от стороннего сервиса. Они flock предотвращают спам службы и кэшируют данные. Но сервис занимает 60 секунд, блокируя всех остальных воркеров, а потом никакие данные не возвращаются.
  2. Даже с несколькими сотнями пользователей все работники в конечном итоге нажмут на этот URL и заблокируют.
  3. Система падает.

Решить, как потерпеть неудачу

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

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

Это было достигнуто довольно просто, добавив:

default_socket_timeout = 5

to /etc/php/7.0/fpm/php.ini

После перезапуска PHP потоки освобождались намного быстрее, и система оставалась стабильной (хотя и неэффективной).

Уроки выучены

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

К моему разочарованию, после того, как я потратил около 2 часов на изучение этого, сторонняя система была исправлена ​​примерно через 15 минут после того, как исправление было установлено, и система работала стабильно. Тем не менее, это было интересное расследование, и оно дало некоторые уроки, которые стоит усвоить:

Осторожно относитесь к неудачам

К сожалению, коду, отвечающему за связь со сторонним API, уже несколько лет, и он не был написан ни участниками текущей, ни предыдущей команды проекта — мы унаследовали его. Тем не менее, мы по-прежнему несем ответственность за это.

Код не учитывал отказ двумя способами:

  • Сторонняя служба вернула ошибки, и
  • Сторонний сервис просто не ответил

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

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

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

Будьте активны в заявлении о неудаче

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

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

В этом случае быстрый провал стоит больше, чем возможный успех.

Сеть ненадежная

В обязательном порядке критические зависимости от приложений в сети неизбежно в какой-то момент сломаются. №1 по теме Заблуждения распределенных вычислений:

Сеть надежна.

Обычно это применяется в локальных сетях между такими приложениями, как Redis, MySQL и другими вспомогательными службами, но риск сбоя в сети (приблизительно) увеличивается на количество сетевых переходов.

Сторонние сервисы определенно попадают в группу «высокого риска». Между ними часто бывает 10–15 сетевых переходов, и даже когда эти сети стабильны, сторонняя служба может выйти из строя на ${REASONS}.

В заключении

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

Спасибо

  • Сайты. Хорошо, что я могу написать об этом — не всем это удается.
  • Моя команда. ❤

Дальнейшее чтение