Типова порада «увімкни 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. Він відповість на питання, чи взагалі ваш бутстрап є проблемою, ще до того, як ви почнете його оптимізувати.
Порядок дій
- Перевірте, що OPcache увімкнений і
max_accelerated_filesбільший за кількість PHP-файлів. - Поставте
validate_timestamps=0— це безкоштовно для незмінного образу. - Зніміть профіль запиту. Якщо бутстрап — не топ-1 у списку, зупиніться тут і йдіть оптимізувати запити до БД.
- Якщо бутстрап справді дорогий — переходьте на worker mode (FrankenPHP + Octane) і виділіть окремий спринт на аудит статичного стану.
- Preload додавайте після worker mode й лише якщо вас турбує час старту воркерів. Обов'язково перечитайте лог старту на предмет
Can't preload unlinked class. - JIT — останнім і тільки з вимірюванням конкретного CPU-bound сценарію.