RSS php-fpm, который Laravel не покажет
memory_get_usage() в конце запроса падал. Хост всё равно рос. Copy-on-write делал общие страницы OPcache частным RSS после гидрации Eloquent.
nginx минуту отдавал 502 после того, как OOM killer забрал воркера php-fpm. У Docker-хоста один бюджет памяти на php-fpm, Redis, MySQL, Memcached и Node. В логе Laravel php-fpm выглядел нормально. memory_get_usage(true) в конце запроса списка заметок сидел около 12 МБ. Восемь воркеров всё равно забивали коробку на 2 ГБ.
Утечки в аллокаторе PHP не было. Хост рос, потому что copy-on-write копирует страницу в момент записи. OPcache общий после fork. Гидрация коллекции Eloquent с тремя локалями в JSON пишет много страниц. Эти страницы становятся частным RSS и остаются частными, пока воркер не умрёт. Пользователи видели 502. Хост видел RSS, который Laravel не печатал.
Telescope и лог сходились, что запрос маленький. smem на воркере нет.
Что держали воркеры
Эндпоинт списка кэшировал модели Eloquent в Redis. serialize() кладёт атрибуты, связи, имена классов и служебное состояние. На get PHP собирал этот граф заново. Каждая строка тащила en, ru и de, даже если запрос просил один язык.
Значение кэша стало списком массивов с четырьмя ключами: id, title, image, size. Title уже строка нужной локали. Redis хранит этот JSON. Куча воркера перестала клонировать весь граф модели.
Аксессоры, которые трогают связь, всё ещё пачкают страницы после перехода на DTO. Один список заметок грузил категорию для бейджа. Эта гидрация копировала ещё страницы рядом с OPcache в частный RSS. Поле бейджа я положил в кэшированный массив в момент записи. Запрос больше не будил Eloquent для сетки.
Лимиты, которые реально держат хост
pm.max_requests = 200, чтобы воркер умирал раньше, чем частный RSS станет машиной. memory_limit остаётся 128M. Этот лимит никогда не видел RSS хоста, потому что RSS это не аллокатор PHP. Лимит памяти Docker на сервисе php должен вмещать все воркеры плюс запас. Если Redis и MySQL на той же VM, считайте и их.
Меряю RSS после fork, после первого списка, после двухсот списков. Если первые два числа совпадают, а третье выросло на 100 МБ, следующая фича добавила аксессор, который трогает связь. Тест N+1 этого бы не поймал. Лог RSS поймал.
pm.max_children на общей коробке 2 ГБ это не то число, что хорошо смотрится на выделенном PHP-хосте. Восемь воркеров умножить на частный RSS, который рос после гидрации, это как OOM выбрал жертву. Детей срезал, пока у Redis и MySQL ещё было место после двухсот списков.
Какой опыт из этого
Меряю RSS воркера после fork и после N списков, не memory_get_usage() в конце запроса. 12 МБ аллокатора и растущий хост могут быть правдой одновременно.
Eloquent в кэше списка Redis и memory_limit как границе хоста я не верю. Кэшировать массив из четырёх ключей на плитку. Воркеров крутить через pm.max_requests.
Правило, которое оставляю: считать память Docker на всех воркеров плюс MySQL и Redis. Если RSS вырос на 100 МБ к 200-му запросу, аксессор разбудил связь. Убить воркера раньше коробки.
