🔥 PHP-FPM: Почему твои файлы не закрываются


Yegor Karpachev

💀 Краткий ликбез для тех, кто до сих пор в 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:

  1. Берёт запрос
  2. Выполняет скрипт
  3. Должен очиститься
  4. Возвращается в пул

И если на этапе (3) что-то остаётся — у тебя leak. А leak'и в PHP коварны, потому что большинство думает: "Ну PHP же всё сам убирает!"

Не убирает, если:

  • Ты вызвал exit() до fclose()
  • Extension имеет баг
  • Ты используешь persistent connections неправильно
  • pm.max_requests = 0 (worker'ы копят утечки вечно)

🔥 Советы

Для PHP-разрабов:

  1. ВСЕГДА используй try-finally для ресурсов
  2. Настрой pm.max_requests адекватно (500-2000)
  3. Мониторь открытые дескрипторы (Prometheus + node_exporter)
  4. Аудируй код регулярно (phpstan может найти некоторые утечки)
  5. Читай блять документацию по extension'ам, которые используешь

Для менеджеров:

Если ваш PHP-разработчик говорит: "Это баг в PHP-FPM!" — в 99% случаев это баг в его коде. Но в 1% случаев он прав, и тогда вам нужен action plan:

  1. Воспроизведение на dev
  2. Strace + lsof для диагностики
  3. Патч ИЛИ workaround
  4. 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'ы живут вечно, накапливая утечки.

Действия:

  1. Проверь pm.max_requests (должен быть > 0)
  2. Найди незакрытые дескрипторы: lsof -p $(pgrep php-fpm)
  3. Аудируй код на fopen/curl_init без close
  4. Если не найдёшь — strace покажет