<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · SENIOR

Як тримати памʼять під контролем у довгоживучих PHP-процесах (воркери, Octane, FrankenPHP)?

Під FPM памʼять прибирає смерть процесу наприкінці запиту, у worker mode цього прибирання немає: усе, що лишилось у статиці, контейнері чи реєстрі слухачів, накопичується. Робочий набір це гігієна стану між ітераціями, явний запуск збирача циклів за порогом і перезапуск воркера за лімітом памʼяті або кількості запитів.

Воркер стартує на 45 МБ, а через годину займає 600. Що ви подивитесь першим?
Той самий код під PHP-FPM жив роками, а під Octane тече за пів години. Чому?
Куди в довгому процесі ставити `gc_collect_cycles()` і чи потрібен він узагалі?
`memory_get_usage()` каже 70 МБ, а RSS процесу 400 МБ. Хто з них помиляється?
Octane FrankenPHP воркери памʼять перезапуск

Під 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;                           // виходимо самі, свіжий процес підніме супервізор
    }
}
Розуміння, що під PHP-FPM охайність із памʼяттю забезпечував shutdown процесу, а не ваш код, і в worker mode ця страховка зникає.
Конкретні місця накопичення: статичні властивості з ключем від даних запиту, біндінги через `$app->instance()`, слухачі й middleware, які реєструються повторно, identity map ORM, буферні хендлери Monolog.
Що `gc_collect_cycles()` потрібен там, де адаптивний поріг root buffer не спрацьовує вчасно, і що його ставлять за порогом памʼяті або кожні N ітерацій, а не на кожен запит.
Перезапуск за лімітом як штатний механізм із назвами прапорців: `queue:work --memory --max-jobs --max-time`, `octane:start --max-requests`, `MAX_REQUESTS` у воркері FrankenPHP, `pool.supervisor.max_worker_memory` у RoadRunner.
Різницю між `memory_get_usage()`, `memory_limit` і RSS процесу, і що ліміт контейнера вбиває за RSS, який PHP не показує.
Вважати `--memory=256` у `queue:work` аналогом `memory_limit`: Laravel звіряє `memory_get_usage(true)` між завданнями, тому один ненажерливий job усе одно впаде на fatal error, а не зупиниться мʼяко.
Ставити `gc_collect_cycles()` на кожен запит «про всяк випадок»: повний обхід живих обʼєктів коштує тим більше, чим більший heap, а `gc_status()` часто показує нуль зібраного.
Тримати статичний кеш із ключем на tenant, user_id чи URL без обмеження розміру: під FPM це memoization на один запит, у воркері це масив, що росте разом із трафіком.
Лікувати ріст перезапуском кожні 20 запитів: ви повертаєте собі бутстрап на кожну двадцяту ітерацію і втрачаєте те, заради чого вмикали worker mode.
Дивитись лише на середню памʼять воркера: OOM killer і ліміт пода приходять по піку, тому в метриках потрібен `memory_get_peak_usage(true)` і VmRSS.
Плутати ріст із витоком: процес, що виріс до 180 МБ і став на плато, нічого не тече, це просто робочий набір плюс те, що аллокатор не повернув системі.
ПОРАДА

Скажіть уголос фразу, яка показує досвід: «під FPM памʼять прибирав кінець процесу, у worker mode прибирати треба самому». Далі назвіть три рівні: скидання стану між ітераціями, збирач циклів за порогом, перезапуск за лімітом. І одразу додайте, що перезапуск це страховка, а не діагноз: якщо він спрацьовує щогодини, шукати треба того, хто тримає посилання.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Під FPM охайність забезпечував shutdown процесу. У воркері його немає, тому все, що пережило запит, лишається живим: статика, контейнер, слухачі, буфери. Збирач циклів закриває лише частину проблеми (цикли), а перезапуск за лімітом це страховка, а не заміна гігієні стану.