<? phpukraine СТАТТІ
Пошук по платформі
ПРОДУКТИВНІСТЬ 4 вересня 2026 · 8 хв читання

OPcache, preload і FrankenPHP: що реально прискорює Laravel у контейнері

Розбираємо по кроках, що відбувається з PHP-кодом на кожному запиті в Docker-образі: які параметри OPcache справді важать, що дає opcache.preload і де він ламається, чому worker mode FrankenPHP знецінює половину цих налаштувань і як виміряти ефект так, щоб цифри щось означали.

РP
Редакція phpukraine
Редакція платформи

Типова порада «увімкни OPcache і буде швидше» в контейнері працює наполовину: OPcache у більшості офіційних образів уже увімкнений, але з дефолтами, розрахованими на локальну машину з файлами, що змінюються. Нижче — механіка того, що відбувається з кодом на кожному запиті, і які саме опції на що впливають. Без бенчмарків: замість чужих цифр наприкінці є розділ про те, як зняти свої.

Що коштує часу на кожному запиті

PHP на кожен запит проходить чотири етапи: читання файлу з диска, парсинг у AST, компіляція в опкоди, виконання. OPcache прибирає перші три — скомпільовані опкоди лежать у shared memory і перевикористовуються між процесами. Preload робить наступний крок: тримає класи вже зв'язаними (linked) у пам'яті, тож не потрібно навіть діставати їх із SHM-кешу й проводити linking. Worker mode (FrankenPHP, Octane, RoadRunner) прибирає найбільший шматок — бутстрап самого фреймворку, який виконується заново на кожному запиті навіть із ідеально налаштованим OPcache.

Важливо розуміти порядок величин у вашому конкретному застосунку. Якщо запит робить п'ять SQL-запитів і два HTTP-виклики до зовнішнього API, економія на компіляції може виявитись у межах похибки. Тому перед тим, як крутити php.ini, варто подивитись, скільки часу йде на бутстрап, а скільки на I/O.

OPcache: параметри, які справді важать у контейнері

Мінімальний робочий набір для production-образу:

opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1

opcache.validate_timestamps=0 — головна відмінність контейнера від VPS. Код в образі незмінний, тож stat() на кожен файл на кожному запиті не має сенсу. Ціна: після зміни файлів кеш не оновиться сам. У Docker це не проблема (новий образ — новий процес), але пам'ятайте, що php artisan config:cache всередині вже запущеного контейнера після цього не підхопиться без рестарту чи opcache_reset().

opcache.max_accelerated_files — не «скільки хочеться», а «скільки файлів реально є». Порахуйте:

// tools/count-php-files.php
$count = 0;
$dir = new RecursiveDirectoryIterator(__DIR__.'/..', FilesystemIterator::SKIP_DOTS);
foreach (new RecursiveIteratorIterator($dir) as $file) {
    if ($file->getExtension() === 'php') {
        $count++;
    }
}
echo $count, PHP_EOL;

Значення округлюється вгору до простого числа з внутрішнього списку, тож 20000 фактично стане 24593. Якщо файлів більше за ліміт, OPcache почне робити hash restart — повне скидання кешу під навантаженням, найгірший сценарій із можливих.

opcache.save_comments=1 не чіпайте. Doctrine-анотації Laravel не використовує, але коментарі потрібні багатьом бібліотекам, які читають docblock через Reflection, і PHPUnit/Pest.

opcache.enable_cli=0 для веб-образу логічний, але якщо ви ганяєте черги й планувальник тими самими бінарниками, кожен php artisan queue:work стартує з нуля. Для довгоживучих CLI-процесів це разова плата, для коротких schedule:run кожну хвилину — щоразу. Тут допомагає opcache.file_cache: скомпільовані опкоди складаються на диск і перечитуються між процесами.

opcache.enable_cli=1
opcache.file_cache=/tmp/opcache
opcache.file_cache_only=0

Каталог має бути writable і, бажано, на tmpfs.

Preload: що дає і де ламається

opcache.preload вказує на PHP-скрипт, який виконується один раз при старті SAPI (php-fpm master, FrankenPHP) і завантажує класи в пам'ять назавжди — до перезапуску процесу. Вони не просто скомпільовані, вони зв'язані: батьківські класи, інтерфейси, трейти вже розв'язані.

// preload.php
require __DIR__.'/vendor/autoload.php';

$files = require __DIR__.'/vendor/composer/autoload_classmap.php';

foreach ($files as $class => $file) {
    if (str_contains($file, '/tests/') || str_contains($file, '/Tests/')) {
        continue;
    }

    opcache_compile_file($file);
}

Три речі, які ламають preload на практиці.

Перше: unlinked classes. Якщо клас успадковує щось, чого немає серед preloaded-файлів, PHP при старті виведе Can't preload unlinked class X і просто пропустить його. Це попередження, не помилка — застосунок працюватиме, але половина того, що ви думали, що завантажили, туди не потрапить. Дивіться логи старту контейнера.

Друге: класи, які не можна завантажувати заздалегідь. Усе, що при завантаженні читає env, конектиться до БД або залежить від стану запиту, у preload не місце. opcache_compile_file() безпечніший за require, бо не виконує код на верхньому рівні файлу — але клас-константи з env() все одно можуть вистрелити.

Третє: opcache.preload_user. Якщо master-процес стартує від root, PHP відмовиться робити preload без цієї опції:

opcache.preload=/app/preload.php
opcache.preload_user=www-data

Перевірити результат:

$status = opcache_get_status(false);
echo count($status['scripts'] ?? []), ' scripts', PHP_EOL;
echo $status['preload_statistics']['num_scripts'] ?? 0, ' preloaded', PHP_EOL;
echo round($status['memory_usage']['used_memory'] / 1048576), ' MB used', PHP_EOL;

Preload з'їдає пам'ять з того самого memory_consumption, тож після його ввімкнення перевірте, що free_memory не впав у нуль.

FrankenPHP і worker mode: де preload втрачає сенс

FrankenPHP — це Caddy зі вбудованим PHP. Класичний режим нічим принципово не відрізняється від php-fpm: кожен запит проходить повний бутстрап Laravel. Цікаве починається у worker mode, де скрипт бутстрапить фреймворк один раз, а потім у циклі приймає запити.

Через Laravel Octane:

composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500

Або напряму через Caddyfile:

{
	frankenphp {
		worker {
			file /app/public/frankenphp-worker.php
			num 4
		}
	}
}

Коли бутстрап відбувається раз на 500 запитів, а не 500 разів, виграш від preload зменшується до майже нуля: класи й так уже в пам'яті процесу. Preload тут лишається корисним хіба що для скорочення часу старту воркера після octane:reload — і це реально важить під час деплою, а не в стаціонарному режимі.

Натомість з'являється нова категорія проблем — стан, що протікає між запитами:

final class ReportBuilder
{
    /** @var array<string, Report> */
    private static array $cache = [];   // житиме між запитами
}

Синглтони, які тримають посилання на Request або на авторизованого користувача, статичні властивості, глобальні змінні, Carbon::setTestNow() — усе це тепер переживає запит. У config/octane.php є масив flush для сервісів, які треба викидати з контейнера після кожного запиту, і warm для тих, що навпаки мають бути готові заздалегідь. Ставте --max-requests не тому, що так модно, а як страховку від повільних витоків пам'яті.

Окремо: у worker mode opcache.validate_timestamps уже майже нічого не вирішує — код перечитується лише при старті воркера.

JIT: не вмикайте «про всяк випадок»

У PHP 8.4 JIT за замовчуванням вимкнений (opcache.jit_buffer_size=0). Увімкнення виглядає так:

opcache.jit=tracing
opcache.jit_buffer_size=64M

JIT прискорює обчислювальні цикли на скалярах. Типовий Laravel-запит — це роутинг, гідрація Eloquent-моделей, серіалізація і чекання на БД, тобто робота з масивами, об'єктами і I/O. Тут JIT дає мало, а іноді (через додаткову пам'ять і час компіляції трейсів) не дає нічого. Якщо у вас є справді CPU-bound шматок — обробка зображень чистим PHP, великі математичні агрегації — міряйте саме його, а не середній час відповіді по всьому застосунку.

Як виміряти, щоб цифри щось означали

Головне правило: одна змінна за один прохід. Спочатку зафіксуйте базу, потім міняйте один параметр.

Що зафіксувати перед вимірюванням:

  • APP_DEBUG=false, APP_ENV=production;
  • виконані config:cache, route:cache, view:cache, composer dump-autoload --optimize --classmap-authoritative;
  • Xdebug вимкнений (php -m | grep -i xdebug має бути порожній) — його присутність спотворює все;
  • ліміти CPU контейнера зафіксовані, інакше сусідні контейнери зіпсують вибірку;
  • прогрів: перші сотні запитів ідуть у смітник, поки OPcache наповнюється.

Навантаження знімайте однаковою командою з фіксованою кількістю з'єднань, наприклад wrk -t4 -c50 -d60s, і дивіться на p95/p99, а не на середнє — середнє ховає саме те, що відчувають користувачі. Тестуйте ендпоінт, який не ходить у зовнішні сервіси, інакше ви міряєте чужу мережу.

Паралельно знімайте стан OPcache:

$stats = opcache_get_status(false)['opcache_statistics'];

printf(
    "hits=%d misses=%d hit_rate=%.2f%% oom=%d hash=%d manual=%d\n",
    $stats['hits'],
    $stats['misses'],
    $stats['opcache_hit_rate'],
    $stats['oom_restarts'],
    $stats['hash_restarts'],
    $stats['manual_restarts'],
);

Будь-яке ненульове значення oom_restarts або hash_restarts означає, що кеш скидається під навантаженням — це треба лікувати збільшенням memory_consumption чи max_accelerated_files до будь-яких інших оптимізацій. misses після прогріву мають перестати рости; якщо ростуть — щось генерує PHP-файли в рантаймі.

Для розуміння, куди пішов час усередині запиту, потрібен профайлер, а не навантажувач: підійде семплінговий Excimer або будь-який APM. Він відповість на питання, чи взагалі ваш бутстрап є проблемою, ще до того, як ви почнете його оптимізувати.

Порядок дій

  1. Перевірте, що OPcache увімкнений і max_accelerated_files більший за кількість PHP-файлів.
  2. Поставте validate_timestamps=0 — це безкоштовно для незмінного образу.
  3. Зніміть профіль запиту. Якщо бутстрап — не топ-1 у списку, зупиніться тут і йдіть оптимізувати запити до БД.
  4. Якщо бутстрап справді дорогий — переходьте на worker mode (FrankenPHP + Octane) і виділіть окремий спринт на аудит статичного стану.
  5. Preload додавайте після worker mode й лише якщо вас турбує час старту воркерів. Обов'язково перечитайте лог старту на предмет Can't preload unlinked class.
  6. JIT — останнім і тільки з вимірюванням конкретного CPU-bound сценарію.
ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті