Під PHP-FPM за памʼяттю стежила модель виконання: процес обробляв запит і скидав усе, що встиг наалокувати, а код до цього стосунку не мав. Витік у класичному сенсі там просто не встигав проявитись, максимум ви впиралися в memory_limit на одному важкому запиті. У worker mode (Octane зі Swoole чи FrankenPHP, RoadRunner, звичайний queue:work) бутстрап відбувається один раз, а далі той самий процес крутить сотні й тисячі ітерацій. Кожне посилання, що пережило ітерацію, стає постійним мешканцем купи. Refcount при цьому працює бездоганно, збирач циклів теж: памʼять не звільняється тому, що на неї хтось законно вказує.
Список цих «когось» короткий і повторюваний з проєкту в проєкт. Статична властивість, у яку memoization кладе результат із ключем від tenant, користувача або URL: під FPM це кеш на один запит, у воркері це масив, що росте разом із різноманіттям трафіку. Біндінги, зроблені через $app->instance() посеред обробки запиту: вони лишаються в контейнері назавжди. Сервіс-провайдер або middleware, який реєструє слухача на кожній ітерації, і масив слухачів росте лінійно, а разом із ним і кількість викликів на подію. FingersCrossedHandler у Monolog, який тримає записи, поки не настане тригерний рівень, і без reset() між запитами носить із собою чужі логи. Identity map ORM, яка під Doctrine чекає на EntityManager::clear(), а під Eloquent проявляється через кеш звʼязків у довгоживучих моделях. Правило, яке закриває більшість випадків: кеш у статиці має право на життя, якщо він не залежить від даних запиту або обмежений за розміром.
Збирач циклів закриває тут лише вузьку ділянку. Цикли створюються буденно: замикання, що тримає $this, звʼязок моделі з дочірніми, обʼєкт події з посиланням на агрегат. Поріг root buffer адаптивний з PHP 7.3, і після кількох проходів, що знайшли мало сміття, він росте, тому автоматичний запуск може статися вже після того, як памʼять піднялась на сотні мегабайтів. Звідси явний gc_collect_cycles() у безпечній точці між ітераціями. Шаблонний воркер FrankenPHP викликає його щоразу після frankenphp_handle_request(), Octane підходить економніше і запускає збирача, коли приріст памʼяті за запит перевищив garbage з config/octane.php (типово 50 МБ). Ціна виклику пропорційна кількості живих обʼєктів, тому на великому heap кожен запит платити не варто. Перевірити користь можна за одну хвилину: gc_status() до і після, дивитись на collected. Нуль означає, що ваша памʼять тримається не циклами.
Перезапуск за лімітом закриває решту, зокрема те, що ви ще не знайшли, і те, що витікає в чужому пакеті. Laravel звіряє memory_get_usage(true) між завданнями і виходить з кодом 12, коли перевищено --memory (типово 128 МБ), плюс є --max-jobs і --max-time; Horizon те саме задає ключем memory у супервізорі. Octane приймає --max-requests (типово 500), RoadRunner має pool.supervisor.max_worker_memory, Swoole max_request, воркер FrankenPHP читає MAX_REQUESTS. Перевірка при цьому відбувається між запитами, тому один job, що зʼїв гігабайт за раз, усе одно впаде на memory_limit, і ліміт воркера від цього не рятує. Мʼякий ліміт ставлять нижче і за memory_limit, і за ліміт контейнера, бо cgroup вбиває процес по RSS, якого PHP не бачить.
Далі починаються компроміси. Перезапуск, який спрацьовує раз на кілька тисяч запитів, коштує майже нічого; перезапуск кожні двадцять ітерацій повертає вам бутстрап і робить worker mode безглуздим, тому часта спрацьовка ліміту це сигнал іти шукати винного, а не привід знизити поріг. Плато теж треба вміти читати: процес, що виріс із 45 до 180 МБ і зупинився, нічого не тече, це робочий набір плюс чанки, які аллокатор не повернув системі. Остання арифметика, про яку часто забувають: памʼять планують як кількість воркерів, помножену на піковий RSS одного, а не на середній, бо OOM приходить саме по піку. Байт-код в OPcache при цьому спільний для всіх процесів і в цю арифметику не входить.
declare(strict_types=1);
// Статичний кеш переживає запит, тому ключ від даних запиту це витік за задумом
final class TaxRates
{
/** @var array<string, float> */
private static array $rates = [];
public static function forRegion(string $region): float
{
if (count(self::$rates) >= 100) { // без ліміту масив росте разом із трафіком
self::$rates = [];
}
return self::$rates[$region] ??= loadRate($region);
}
}
$app = bootstrap(); // бутстрап один раз на процес
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 500);
$softLimit = 192 * 1024 * 1024; // нижче за memory_limit і за ліміт контейнера
$baseline = 0;
for ($i = 0; $i < $maxRequests; $i++) {
$running = frankenphp_handle_request(static function () use ($app): void {
$app->handleRequest();
$app->forgetScopedInstances(); // стан запиту не має пережити ітерацію
});
if ($i === 0) {
$baseline = memory_get_usage(true); // зріз після прогріву, а не до нього
}
if ($i % 50 === 0) {
gc_collect_cycles(); // поріг root buffer росте, цикли чекають на нас
}
if (! $running || memory_get_usage(true) - $baseline > $softLimit) {
break; // виходимо самі, свіжий процес підніме супервізор
}
}
Скажіть уголос фразу, яка показує досвід: «під FPM памʼять прибирав кінець процесу, у worker mode прибирати треба самому». Далі назвіть три рівні: скидання стану між ітераціями, збирач циклів за порогом, перезапуск за лімітом. І одразу додайте, що перезапуск це страховка, а не діагноз: якщо він спрацьовує щогодини, шукати треба того, хто тримає посилання.