Неуловимая 502, или как сеньоры не могли найти ошибку
Какие ошибки в программировании страшнее всего? Я бы выделил два типа:
- те, из-за которых бизнес теряет много денег
- и те, которые встречаются реже всего.
Почему первые - понятно сразу, а что со вторыми? Дело в том, что чем реже мы встречаем какой-то тип ошибок - тем сложнее понять, чем они вызваны.
Так случилось и у меня на работе. Однажды утром тестировщики заметили, что часть запросов бэкенду возвращала ошибку 502 (Gateway Timeout). Эта ошибка тормозила релиз, и за неё взялись все старшие разработчики и devops-инженер. Поначалу считали, что эту ошибку возвращает Nginx, и бэкенд не при чём. Через некоторое время поняли, что виноват всё-таки PHP. На что только не грешили: отключали BlackFire, меняли настройки OpCache, пробовали разные патч-версии в PHP, и так далее. Однако, сама ошибка оказалась не в инфраструктуре, а непосредственно в коде приложения.
Предисловие
В качестве вводных данных расскажу, что приложение основано на фреймворке yii2 и изначально писалось не самыми толковыми разработчиками. Например, DI-контейнер не использовался, вместо него были компоненты, как это и было задумано изначально в фреймворке на заре его существования. Когда проект выстрелил, в него пришли специалисты выше уровнем, и к старому коду начали писать более выверенный архитектурно. Из-за одного такого изменения у нас и получилось ошибка, которая на фронтенде выглядела как 502, и по которой ни в каких логах не было записей. Первое, что пришло в голову: такая ситуация с логами возникает из-за переполнения памяти. То есть, когда php-процесс использует больше памяти, чем ему выделено системой. С этой ситуацией мы знакомы и умеем её распознать, почему и проверили её сразу же. Но это была не она.
Описание проблемы
Оказалась, что наша проблема была схожего характера: процесс отваливался из-за достижений другого системного ограничения, не памяти. В весьма неочевидном месте мы достигали лимита по максимальной вложенности вызовов функций. Другими словами, мы вошли в рекурсию. Причём рекурсия эта была неявной, то есть функция вызывала не сама себя, а другую функцию, которая в свою очередь вызывала первую функцию, а та снова вторую, а та снова первую…
Пришли мы к этому следующим образом. В конфигурации у нас был компонент (назовём его для ясности foo
). Конфигурация этого компонента выглядела вот так:
'foo' => [
'class' => FooClass::class,
'property1' => 'value1',
]
Один из наших разработчиков добавил определение этого класса в DI-контейнер, чтобы была возможность получать этот класс уже настроенным в конструтор других классов:
FooClass::class => static fn() => Yii::$app->get('foo'),
Что мы имеем в результате? Когда код проекта обращался к компоненту, фреймворк пытался создать этот компонент. Видя в определении компонента необходимость получить объект класса, он искал этот класс сначала в контейнере, где находил определение Yii::$app->get('foo')
, и снова пытался получить компонент. И так - по кругу. В качестве хотфикса мы удалили определение для DI-контейнера (оно нигде не использовалось). Правильным же решением было перенести настройку класса из описания компонента в определения DI-контейнера, в настройках компонентов оставив только 'foo' => FooClass::class
.
Вот так простейшая ошибка сумела обмануть разработчиков с опытом более 10 лет. Просто мы слишком редко ее встречаем.