🔥 PHP-FPM: Почему твои файлы не закрываются
💀 Краткий ликбез для тех, кто до сих пор в 2008 году
Как работает PHP-FPM (для долбоёбов):
1. Nginx получает запрос → передаёт в PHP-FPM 2. PHP-FPM берёт свободный worker process 3. Worker выполняет твой скрипт 4. Скрипт завершился → worker ОЧИЩАЕТСЯ 5. Worker возвращается в пул (готов к новому запросу)
Ключевое слово: ОЧИЩАЕТСЯ.
Это значит:
- ✅ Переменные обнуляются
- ✅ Память освобождается (ну, по идее)
- ✅ Файловые дескрипторы ДОЛЖНЫ закрыться
🐕 Сравнение: Собака vs PHP-разработчик
Собака понимает: если хозяин дал команду "апорт" — надо вернуть мяч.
PHP-разработчик думает: если я открыл файл — PHP сам его закроет. Ну типа когда-нибудь. Авось.
⚠️ Но есть нюанс (всегда есть нюанс)
php
<?php
// Код Васи (PHP Developer, "знаю фреймворки"):
$handle = fopen('/var/log/app.log', 'a');
fwrite($handle, "Вася был здесь\n");
// fclose($handle); <-- А ВОТ ЭТОГО НЕТ
// "Ну PHP же сам закроет при завершении скрипта!" (с)Вася
Что происходит на самом деле:
- Обычный запрос → PHP закроет при shutdown → ✅ Пронесло
- Fatal error → Shutdown handler НЕ отработал → ⚠️ Дескриптор висит
- exit() в середине → Зависит от фазы луны → ⚠️ 50/50
- pm.max_requests достигнут → Worker убивается → дескрипторы теряются → ❌ Leak
- SIGKILL worker'а → Всё нахуй → ❌ Катастрофа
🔥 Диагноз: File Descriptor Leak
Симптомы:
bash
$ lsof | grep php-fpm | wc -l
5847
$ ls -la /proc/$(pidof php-fpm | awk '{print $1}')/fd | wc -l
1024 # <-- НА ПРЕДЕЛЕ
💀 Возможные причины (от частых к редким)
1. Незакрытые файлы (80% случаев)
php
// ПЛОХО:
function log_action($message) {
$fp = fopen('/tmp/actions.log', 'a');
fwrite($fp, $message . "\n");
// Вася забыл fclose()
}
// ХОРОШО:
function log_action($message) {
$fp = fopen('/tmp/actions.log', 'a');
if ($fp === false) {
throw new RuntimeException("Can't open log");
}
try {
fwrite($fp, $message . "\n");
} finally {
fclose($fp); // ВСЕГДА закрывай в finally
}
}
// ЛУЧШЕ:
function log_action($message) {
file_put_contents('/tmp/actions.log', $message . "\n", FILE_APPEND);
// PHP сам откроет и закроет
}
2. Незакрытые MySQL/PDO соединения (15% случаев)
php
// ПЛОХО:
function get_users() {
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$stmt = $pdo->query("SELECT * FROM users");
return $stmt->fetchAll();
// $pdo уйдёт в GC... когда-нибудь
}
// ХОРОШО:
function get_users() {
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
try {
$stmt = $pdo->query("SELECT * FROM users");
return $stmt->fetchAll();
} finally {
$pdo = null; // Явно закрываем
$stmt = null;
}
}
// ЛУЧШЕ (Singleton connection pool):
class DB {
private static $instance = null;
public static function getConnection() {
if (self::$instance === null) {
self::$instance = new PDO(...);
self::$instance->setAttribute(PDO::ATTR_PERSISTENT, true);
}
return self::$instance;
}
}
3. CURL без curl_close() (3% случаев)
php
// ПЛОХО:
$ch = curl_init('https://api.example.com');
curl_exec($ch);
// curl_close($ch); <-- Забыли
// ХОРОШО:
$ch = curl_init('https://api.example.com');
try {
$result = curl_exec($ch);
if ($result === false) {
throw new RuntimeException(curl_error($ch));
}
return $result;
} finally {
curl_close($ch); // ОБЯЗАТЕЛЬНО
}
4. Streams (fsockopen, stream_socket_client) (2% случаев)
php
// ПЛОХО:
$socket = fsockopen('example.com', 80);
fwrite($socket, "GET / HTTP/1.0\r\n\r\n");
$response = fread($socket, 8192);
// fclose($socket); <-- Утечка
// ХОРОШО:
$socket = fsockopen('example.com', 80, $errno, $errstr, 30);
if (!$socket) {
throw new RuntimeException("$errstr ($errno)");
}
try {
fwrite($socket, "GET / HTTP/1.0\r\n\r\n");
$response = stream_get_contents($socket);
return $response;
} finally {
fclose($socket);
}
🔍 Как найти утечку (для тех, кто не совсем идиот)
Шаг 1: Подтверди проблему
bash
# Запусти мониторинг открытых файлов watch -n 1 'lsof -p $(pgrep php-fpm | head -n 1) | wc -l' # Дай нагрузку ab -n 10000 -c 100 http://localhost/ # Если число растёт → у тебя leak
Шаг 2: Найди, ЧТО не закрывается
bash
# Посмотри, какие файлы висят lsof -p $(pgrep php-fpm | head -n 1) | sort | uniq -c | sort -rn # Пример вывода: # 847 /tmp/session_xyz123 # 423 socket:[12345] (MySQL connection) # 156 /var/log/app.log
Шаг 3: Включи XDebug профилирование
ini
; php.ini xdebug.mode = profile xdebug.output_dir = /tmp/xdebug xdebug.profiler_enable_trigger = 1
bash
# Запрос с профилированием curl "http://localhost/leak.php?XDEBUG_PROFILE=1" # Анализ (найди fopen без fclose) grep -A 20 "fopen" /tmp/xdebug/cachegrind.out.*
Шаг 4: Strace (ядерная опция)
bash
# Отслеживаем syscalls одного worker'а
strace -p $(pgrep php-fpm | head -n 1) -e trace=open,close,socket -o /tmp/trace.log
# Запускаем нагрузку
ab -n 100 -c 10 http://localhost/leak.php
# Ищем несимметричные open/close
awk '/open/ {opens[$NF]++} /close/ {closes[$NF]++} END {
for (fd in opens) {
if (opens[fd] != closes[fd]) {
print fd, "opened:", opens[fd], "closed:", closes[fd]
}
}
}' /tmp/trace.log
⚙️ PHP-FPM конфигурация: Как не выстрелить себе в ногу
pm (Process Manager) — критическая настройка
ini
; php-fpm.conf ; ===== DYNAMIC (для большинства) ===== pm = dynamic pm.max_children = 50 ; Максимум worker'ов pm.start_servers = 5 ; Стартовое количество pm.min_spare_servers = 5 ; Минимум в пуле pm.max_spare_servers = 10 ; Максимум в пуле pm.max_requests = 1000 ; Перезапуск worker после N запросов ; ===== ONDEMAND (для малой нагрузки) ===== ; pm = ondemand ; pm.max_children = 50 ; pm.process_idle_timeout = 10s ; ===== STATIC (для высокой нагрузки) ===== ; pm = static ; pm.max_children = 50
Типичная ошибка Васи:
ini
; "Больше = быстрее!" (с)Вася pm.max_children = 500 ; <-- На сервере 2GB RAM pm.max_requests = 0 ; <-- Worker'ы НИКОГДА не перезапускаются ; Результат: ; - Memory leak накапливается ; - File descriptor leak накапливается ; - OOM killer убивает весь сервер
Правильный расчёт:
Доступная RAM = 2GB = 2048MB RAM под систему = 512MB RAM под Nginx = 200MB RAM доступная для PHP = 1336MB Средний размер PHP процесса = 50MB (проверь через: ps aux | grep php-fpm) pm.max_children = 1336MB / 50MB = ~26 процессов Запас 20% → pm.max_children = 20
Формула:
Max_Children = (RAM_available - RAM_system - RAM_nginx) / RAM_per_php_process * 0.8
pm.max_requests: Твоя страховка от утечек
ini
; Даже если у тебя leak — worker умрёт через N запросов pm.max_requests = 1000 ; Для production с непроверенным кодом: pm.max_requests = 500 ; Чаще перезапускай ; Для stable кода без leaks: pm.max_requests = 5000
💊 Лечение: Конкретные действия
Экстренная помощь (сервер горит):
bash
# 1. Перезапусти PHP-FPM (временно поможет) systemctl restart php-fpm # 2. Увеличь лимит дескрипторов (ВРЕМЕННО) ulimit -n 65535 systemctl restart php-fpm # 3. Уменьши pm.max_requests (заставь worker'ы чаще умирать) # В php-fpm.conf: pm.max_requests = 100 # Да, это костыль
Долгосрочное решение:
1. Аудит кода
bash
# Найди все fopen без fclose
grep -rn "fopen" ./src | while read line; do
file=$(echo $line | cut -d: -f1)
line_num=$(echo $line | cut -d: -f2)
# Проверь, есть ли fclose в той же функции
awk -v start=$line_num '
NR >= start && /fclose/ {found=1; exit}
NR >= start && /^}/ {exit}
END {if (!found) print FILENAME ":" start " - NO FCLOSE"}
' "$file"
done
# То же для curl
grep -rn "curl_init" ./src | ... # Аналогично
2. Добавь мониторинг
php
<?php
// healthcheck.php
header('Content-Type: application/json');
$status = [
'open_fds' => (int) shell_exec("lsof -p " . getmypid() . " | wc -l"),
'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
'pid' => getmypid(),
];
// Alert если дескрипторов > 512
if ($status['open_fds'] > 512) {
$status['alert'] = 'FD_LEAK_DETECTED';
}
echo json_encode($status);
3. Включи логирование медленных запросов
ini
; php-fpm.conf slowlog = /var/log/php-fpm/slow.log request_slowlog_timeout = 5s request_terminate_timeout = 30s
💀 Суть проблемы
PHP-FPM — это не долгоживущий процесс в классическом смысле. Это пул переиспользуемых worker'ов, где каждый worker:
- Берёт запрос
- Выполняет скрипт
- Должен очиститься
- Возвращается в пул
И если на этапе (3) что-то остаётся — у тебя leak. А leak'и в PHP коварны, потому что большинство думает: "Ну PHP же всё сам убирает!"
Не убирает, если:
- Ты вызвал
exit()доfclose() - Extension имеет баг
- Ты используешь persistent connections неправильно
- pm.max_requests = 0 (worker'ы копят утечки вечно)
🔥 Советы
Для PHP-разрабов:
- ВСЕГДА используй try-finally для ресурсов
- Настрой pm.max_requests адекватно (500-2000)
- Мониторь открытые дескрипторы (Prometheus + node_exporter)
- Аудируй код регулярно (phpstan может найти некоторые утечки)
- Читай блять документацию по extension'ам, которые используешь
Для менеджеров:
Если ваш PHP-разработчик говорит: "Это баг в PHP-FPM!" — в 99% случаев это баг в его коде. Но в 1% случаев он прав, и тогда вам нужен action plan:
- Воспроизведение на dev
- Strace + lsof для диагностики
- Патч ИЛИ workaround
- Code review всех мест работы с ресурсами
🎯 Финальный чек-лист для PHP-FPM
- pm.max_requests > 0 (обязательно!)
- pm.max_children рассчитан по формуле выше
- Все fopen имеют парный fclose (желательно в finally)
- Все curl_init имеют curl_close
- PDO connections используют persistent правильно или закрываются
- Есть мониторинг open file descriptors
- Включён slowlog для отладки
- request_terminate_timeout настроен
- Код проходит phpstan/psalm/rector
- Load testing показал отсутствие роста дескриипторов
P.S. В PHP-FPM worker после выполнения скрипта должен освобождать все ресурсы. Если не освобождает — либо у тебя leak в коде (незакрытые fopen/curl/PDO), либо баг в extension'е, либо pm.max_requests = 0 и worker'ы живут вечно, накапливая утечки.
Действия:
- Проверь pm.max_requests (должен быть > 0)
- Найди незакрытые дескрипторы:
lsof -p $(pgrep php-fpm) - Аудируй код на fopen/curl_init без close
- Если не найдёшь — strace покажет