Утечки памяти в долгоживущем PHP-процессе: что ломается первым
Воркер на Swoole или RoadRunner держит 40 воркеров, к вечеру каждый занимает по 400 МБ, OOM-киллер начинает отстреливать процессы. Разбираем, что течёт первым и как это найти.

Классический сценарий: PHP-приложение переехало с PHP-FPM на долгоживущий рантайм — RoadRunner, Swoole, ReactPHP или самописный воркер на очереди. В FPM каждый запрос завершался, и вся память освобождалась вместе с процессом. Теперь процесс живёт часами, обрабатывает десятки тысяч запросов, и потребление памяти растёт монотонно: 80 МБ на старте, 250 МБ через час, 400 МБ к вечеру. Дальше memory_limit или OOM-киллер ядра завершает воркер посреди обработки запроса, менеджер поднимает новый — и цикл повторяется. Разбираем, какие структуры протекают первыми, почему сборщик циклических ссылок не спасает и чем это мерить.
Почему в долгоживущем процессе течёт то, что в FPM не течёт
PHP считает ссылки: как только счётчик ссылок на значение падает до нуля, память освобождается сразу. Проблема не в счётчике, а в двух других вещах.
Первая — циклические ссылки. Объект A держит ссылку на B, B держит ссылку на A. Даже когда обе переменные вышли из области видимости, счётчик каждого объекта остаётся равным единице. За такими циклами приходит сборщик мусора PHP (garbage collector), но запускается он не по таймеру, а по заполнению внутреннего буфера корней (root buffer). В долгоживущем воркере между запросами буфер может не успевать заполниться до порога, и циклы копятся.
Вторая, и куда более частая причина — ссылки, которые не должны были пережить запрос, но пережили. Статические свойства классов, синглтоны в контейнере, глобальные регистры, замыкания, захватившие $this. В FPM всё это умирало вместе с процессом после каждого запроса. В долгоживущем рантайме оно живёт, пока жив воркер.
В PHP-FPM плохой код с утечкой работал годами, потому что процесс жил ровно один запрос. Долгоживущий рантайм не создаёт утечки — он делает видимыми те, что были всегда.
Что ломается первым: порядок отказов
Под растущим потреблением памяти отказы приходят в предсказуемом порядке.
- Отдельный воркер упирается в
memory_limit. PHP выбрасывает fatal errorAllowed memory size of N bytes exhaustedпрямо посреди обработки запроса. Клиент получает 500 или обрыв соединения. Менеджер рантайма (RoadRunner, Swoole) перезапускает воркер — это маскирует проблему: сервис вроде живой, но часть запросов регулярно падает. - Растёт время отклика из-за GC. Когда живых объектов в куче становится много, каждый проход сборщика циклических ссылок обходит больше корней и занимает больше времени. Появляются периодические всплески latency, которые не коррелируют с нагрузкой на CPU по бизнес-логике.
- OOM-киллер ядра завершает процесс. Если суммарная память всех воркеров превышает доступную на хосте (особенно в контейнере с жёстким лимитом cgroup), Linux убивает процесс по SIGKILL. Здесь нет graceful shutdown: запрос в обработке теряется, а в логах приложения вообще ничего нет — надо смотреть
dmesgи события cgroup.
Первым почти всегда ломается пункт 1, но команды его не замечают, потому что рантайм перезапускает воркеры и внешне сервис работает. Проблему находят на пункте 3, когда падает уже весь под.
Кто протекает: типичные источники
Статические свойства и синглтоны
Самый частый случай — статический массив, который что-то накапливает: кеш вычислений, лог событий, буфер, куда «на всякий случай» складывают обработанные объекты.
<?php
// Проблемный паттерн: статический кеш без ограничения размера
class PriceCalculator
{
// Живёт всё время жизни воркера, растёт с каждым уникальным ключом
private static array $cache = [];
public static function calculate(int $productId, float $base): float
{
$key = $productId . ':' . $base;
if (!isset(self::$cache[$key])) {
self::$cache[$key] = self::heavyCompute($productId, $base);
}
return self::$cache[$key];
}
}
В FPM такой кеш жил один запрос и был безобиден. В долгоживущем воркере при большом числе уникальных ключей массив растёт без потолка. Лечится ограничением размера (LRU) или сбросом между запросами.
Контейнер зависимостей, накапливающий инстансы
DI-контейнер, который регистрирует объекты как shared (синглтоны), удерживает их до конца жизни воркера. Если внутри такого сервиса копится состояние — например, коллектор метрик складывает каждый замер в свойство, — оно течёт. В FPM контейнер пересоздавался на каждый запрос, в воркере — нет.
Замыкания, захватившие крупный контекст
Замыкание, переданное в обработчик событий или в очередь колбэков, держит по ссылке весь захваченный контекст, включая $this. Если обработчики регистрируются на каждый запрос, но не снимаются, список растёт, и вместе с ним — вся память, на которую ссылаются захваченные переменные.
Циклические ссылки в ORM и графах объектов
Двусторонние связи в ORM (сущность держит коллекцию, элементы коллекции держат родителя) образуют циклы. Пока их не соберёт GC, они висят. В связке с identity map, которую ORM не очищает между запросами в воркере, это классическая утечка: карта идентичности накапливает все загруженные сущности.
Чем мерить
Не гадайте по общему графику RSS — он показывает факт роста, но не источник. Нужны точечные замеры.
| Инструмент | Что показывает | Когда применять |
|---|---|---|
memory_get_usage(true) | Память, выделенную PHP под кучу, с учётом блоков аллокатора | Замер до/после обработки запроса внутри воркера |
memory_get_peak_usage(true) | Пик за время жизни процесса | Понять, был ли одиночный всплеск или монотонный рост |
gc_status() (PHP 7.3+) | Число прогонов GC, собранных корней, размер буфера | Проверить, справляется ли сборщик циклов |
RSS процесса из /proc/PID/status | Реальная память процесса глазами ОС | Сравнить с тем, что видит PHP: разница — фрагментация или расширения |
Ключевой приём для долгоживущего воркера — логировать дельту памяти на каждый обработанный запрос:
<?php
// Замер до и после обработки одного запроса в воркере (PHP 8.x)
$before = memory_get_usage(true);
// ... здесь обработка запроса ...
$after = memory_get_usage(true);
$delta = $after - $before;
// Если delta стабильно > 0 запрос за запросом при одинаковой нагрузке —
// что-то не освобождается. Логируем с идентификатором маршрута.
if ($delta > 0) {
error_log(sprintf(
'route=%s delta_bytes=%d total_mb=%.1f gc_runs=%d',
$route,
$delta,
$after / 1048576,
gc_status()['runs']
));
}
Разовые всплески delta — нормально (запрос обрабатывал крупный ответ). Опасен устойчивый положительный дрейф на однотипных запросах: он означает, что каждый запрос оставляет после себя память.
Профилирование кучи
Когда рост подтверждён, но источник неясен, снимают дамп кучи и смотрят, каких объектов больше всего. Для этого подходят расширения-профайлеры и связка с внешним анализатором. Конкретные имена методов и флаги зависят от версии расширения — сверяйтесь с документацией того профайлера, который у вас установлен, а не с примером из статьи годичной давности.
Граничные случаи и что легко спутать с утечкой
- Фрагментация вместо утечки. Аллокатор PHP (emalloc) резервирует память блоками и не всегда возвращает их ОС. RSS процесса растёт, а
memory_get_usage(true)держится на месте. Это не утечка объектов, а фрагментация арены — лечится не поиском ссылок, а перезапуском воркера по счётчику запросов. - OPcache и preloading. Прелоаженный код и таблицы классов занимают память один раз при старте. Не считайте это ростом — это плоская полка, не наклон.
- Расширения на C. Утечка в расширении (например, в драйвере БД или в модуле работы с изображениями) не видна инструментам PHP-уровня:
memory_get_usageеё не покажет, а RSS будет расти. Здесь помогает только сравнение с системными счётчиками и профилировщик уровня C (valgrind в тестовом окружении). - GC отключён. Если где-то вызвали
gc_disable()ради ускорения (частая «оптимизация»), циклические ссылки не собираются вообще. Проверьтеgc_enabled()в рантайме.
Смягчение, пока ищете причину
Пока источник не найден, рост памяти сдерживают ротацией воркеров. У RoadRunner и Swoole есть настройка максимального числа запросов на воркер: после лимита воркер graceful-перезапускается со свежей кучей. Это не лечение, а страховка от OOM-киллера — точные имена параметров смотрите в документации вашего рантайма и версии.
Второй приём — принудительный gc_collect_cycles() в конце обработки запроса. Он гарантированно собирает накопившиеся циклы, но стоит CPU-времени, поэтому вызывать его на каждый запрос имеет смысл только если замеры показали рост именно из-за неубранных циклов, а не из-за живых статических ссылок (последние GC не тронет).
Что делать дальше
Порядок работы, который окупается:
- Включите логирование дельты памяти на запрос по маршрутам — это укажет, какой эндпоинт течёт.
- Разделите утечку и фрагментацию: сравните
memory_get_usage(true)с RSS из/proc. - Если течёт PHP-куча — снимите дамп и найдите класс-лидер по числу живых экземпляров.
- Проверьте статические свойства, shared-сервисы контейнера и identity map ORM в первую очередь: 80% утечек в воркерах именно там.
- Настройте ротацию воркеров по числу запросов как постоянную страховку, даже когда причину устраните.
Инвалидация кеша приложения и настройка лимитов cgroup под воркеры — отдельные темы; здесь мы разбирали только рост памяти внутри самого PHP-процесса.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: PHP Manual: Garbage Collection, PHP Manual: memory_get_usage
Частые вопросы
У меня обычный PHP-FPM, а не воркер. Мне это грозит?
gc_collect_cycles() в конце каждого запроса решит проблему?
У меня другая версия PHP, примеры сработают?
RSS растёт, а memory_get_usage стоит на месте — где искать?
Как понять, что проблема именно в проде, а не в тесте?
Стоит ли просто поднять memory_limit и не искать причину?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.