Скачать CV
Назад к бизнес-кейсам

Память

Воркеры PHP-FPM, которые съели хост

RSS рос после каждого запроса в каталог. Утечкой оказался copy-on-write, а не забытый unset().

Во вторник днём на VPS кончилась память. Пошёл swap, потом OOM killer забрал воркер php-fpm, потом nginx минуту отдавал 502. Трафик был обычный. Страница, в которую все били, это листинг каталога.

Сначала искал утечку. Её не было. memory_get_usage() в конце запроса падал обратно. Хост всё равно рос. В этом зазоре и есть история: PHP освобождает память запроса, Linux не всегда возвращает страницы, а copy-on-write делает общий OPcache частным RSS в тот момент, когда строка перезаписывается.

Проблема была не в забытом unset(). Листинг гидрировал графы Eloquent в воркер, пока частный RSS не забил хост на 2 ГБ. Посетители магазина получали 502. Нужны были те же сорок плиток без копирования трёх локалей и модели на каждый запрос.

OPcache остаётся общим, пока воркер не пишет. После записи страница становится частной.
OPcache остаётся общим, пока воркер не пишет. После записи страница становится частной.

Что реально держали воркеры

Эндпоинт каталога кэшировал коллекции Eloquent в Redis. serialize() модели Product кладёт атрибуты, связи, имена классов и кучу служебного состояния. На get PHP собирал этот граф заново в воркере. Три локали жили в JSON-колонке, поэтому каждая строка тащила en, ru и de, даже если запрос просил один язык.

После двухсот запросов частный RSS одного воркера держался около 180 МБ. Восемь воркеров забивали хост на 2 ГБ. OPcache был здоров и общий. Частная куча это и был каталог.

Я сравнил /proc/self/status после fork и после листинга. VmRSS прыгал на гидрации, не на bootstrap. smaps показывал рост анонимных частных страниц с каждым хитом каталога. Общий OPcache стоял. Это copy-on-write, а не утечка, которую покажет аллокатор PHP.

Один воркер, три момента: после fork, после гидрации Eloquent, после компактного DTO.
Один воркер, три момента: после fork, после гидрации Eloquent, после компактного DTO.

Что поменяли

Значение кэша стало списком массивов с четырьмя ключами: id, slug, title, price. Title уже строка нужной локали. Memcached хранит этот JSON. Redis больше не видит payload каталога. Он по-прежнему держит очереди и сессии, и для этого он в стеке есть.

  • pm.max_requests = 200, чтобы воркер умирал раньше, чем RSS станет хостом.
  • opcache.preload грузит LaraBoom и host App на старте, чтобы первый запрос не копировал половину кода.
  • memory_limit остаётся 128M. Этот лимит никогда не ловил RSS хоста, потому что RSS это не аллокатор PHP.

Частный RSS воркера на том же листинге сел около 70 МБ. 502 пропали. Интересно не число. Интересно мерить RSS после fork, а не memory_get_usage() внутри запроса.

Какой опыт из этого

Я перестал доверять memory_get_usage() как мере давления на хост. Ядро видит RSS. PHP видит свой аллокатор. Эти числа расходятся, как только воркер пишет в общую страницу.

Eloquent это модель записи. Публичный листинг это список скаляров. Кэшировать модель значит убить VPS на 2 ГБ обычным трафиком.

pm.max_requests это страховка, не дизайн. Дизайн это компактный DTO в Memcached и Redis без блоба каталога.

Назад к бизнес-кейсам