Если у вас несколько веб‑нод и сессии лежат на общем хранилище (NFS, NAS, GlusterFS и т.п.), периодически прилетают странные пики латентности: отдельные PHP‑запросы внезапно застывают на десятки секунд, а на самих файловых серверах видно резкий рост операций чтения каталогов. Часто причина — встроенная уборка старых сессий (GC), которая по умолчанию запускается случайно на пользовательских запросах. На локальном диске это терпимо, но на общем хранилище превращается в дорогой рекурсивный обход тысяч поддиректорий с миллионами файлов.
Как устроен GC PHP‑сессий и почему он ранит NFS
Модуль session в PHP, когда используется файловый обработчик (session.save_handler = files), хранит каждую сессию в отдельном файле вида sess_XXXXXXXX... в каталоге session.save_path. Удаление «протухших» сессий выполняется сборщиком мусора, который запускается с некоторой вероятностью на обычных HTTP‑запросах.
Упрощённо: при каждом старте сессии PHP бросает «кубик» и, если выпало удачно, проходит по дереву каталогов в session.save_path, удаляя файлы с mtime старше session.gc_maxlifetime. На локальном SSD такая операция почти незаметна. На NFS — наоборот: любой рекурсивный обход каталога означает сетевые вызовы, атрибут‑кэш и протокол блокировок, что ведёт к паузам и высокому latency хвоста распределения. Если только рассматриваете сетевое хранилище, пригодится обзор по сравнению протоколов в статье «NFS против SSHFS» — он помогает оценить накладные расходы на метаданные (подробности о NFS vs SSHFS).
В NFS‑окружении случайный GC на пользовательских запросах — источник непредсказуемых задержек. Его надо выключить на рабочих процессах и заменить на централизованный, управляемый процесс уборки.
Ключевые параметры GC
session.gc_probabilityиsession.gc_divisor: вероятность запуска GC равнаgc_probability / gc_divisor. Частый практический пресет на локальном диске — 1/1000. На общем хранилище — ставим 0.session.gc_maxlifetime: порог «протухания» в секундах. Обычно равен или немного превышает TTL вашего cookie‑сеанса.
; php.ini (или .user.ini, или pool.d/*.conf через php_admin_value)
session.save_handler = files
session.save_path = "3;/mnt/nfs/sessions"
session.gc_probability = 0
session.gc_divisor = 1000
session.gc_maxlifetime = 7200
; см. ниже про lazy_write — важно при внешней уборке
session.lazy_write = 0
session.save_path и хеш‑поддиректории
Для файлового обработчика формат session.save_path поддерживает префикс с количеством уровней хеш‑поддиректорий: "N;/path" или "N;MODE;/path". Пример "3;/mnt/nfs/sessions" создаёт три уровня каталогов по хешу SID и резко сокращает число файлов в одном каталоге. Для NFS это критично: операции readdir и stat на больших директориях особенно дорогие.
Рекомендуйте держать не более 1–2 тысяч файлов на каталог. На практике 2–3 уровня достаточно (зависит от трафика и gc_maxlifetime), чтобы избежать «миллионников».
session.lazy_write и почему у вас внезапно пропадают активные сессии
По умолчанию PHP включает session.lazy_write = 1: если содержимое сессии не менялось, файл не перезаписывается, и его mtime не обновляется. Это экономит I/O на локальных дисках — но ломает внешнюю уборку, которая ориентируется на тот самый mtime. В результате внешняя утилита (cron/systemd‑tmpfiles) может удалить «живую» сессию раньше времени.
Если вы делаете внешнюю уборку по времени модификации, выключите
session.lazy_writeили добавьте большой запас к порогу удаления. Иначе рискуете терять сессии пользователей.
Анти‑паттерны уборки на общем хранилище
- Оставлять дефолтный случайный GC на всех PHP‑процессах. Это создаёт «стампиды» рекурсивных обходов в самые неудобные моменты.
- Чистить по
atime. На многих прод‑серверах включёнrelatime/noatime, а на NFS время последнего доступа вообще может не отражать реальность. Ориентируйтесь наmtime. - Удалять «впритык» к TTL cookie. На сетевых FS задержки и кэш атрибутов приводят к дребезгу. Нужен запас.
- Очищать с помощью «жёсткого»
rm -rfвсего дерева. Это гарантированный I/O шторм и риски гонок с рабочими процессами.

Правильная стратегия: единый контролируемый GC
Базовая идея проста: полностью отключаем случайный GC на веб‑узлах (session.gc_probability = 0), а уборку выполняет один предсказуемый процесс по расписанию. Два удобных варианта: systemd‑tmpfiles или cron + find. Дополнительно можно запускать session_gc() из CLI‑скрипта, если хотите, чтобы логика совпадала с PHP‑внутренней.
Вариант A: systemd‑tmpfiles
Подходит для дистрибутивов с systemd. Создаём правило, которое будет удалять файлы старше заданного возраста. Добавьте запас к gc_maxlifetime (например, 20–30%), чтобы компенсировать сетевые и атрибутные задержки.
# /etc/tmpfiles.d/php-sessions.conf
d /mnt/nfs/sessions 1733 root root -
q /mnt/nfs/sessions 3h
Здесь строка d гарантирует наличие каталога с правами 1733 (sticky bit, чтобы пользователи не удаляли чужие файлы), а строка q удаляет содержимое старше 3 часов. Периодичность обрабатывает сам systemd. После добавления файла выполните:
systemd-tmpfiles --create
systemd-tmpfiles --clean
Вариант B: cron + find
Классика, работает везде. Один из веб‑узлов назначаем «уборщиком». Включаем на нём задание cron так, чтобы оно не пересекалось со снапшотами/бэкапами NFS. По выбору механизма расписаний и сравнительный разбор cron vs timers см. разбор в статье про расписания и автоматизацию (cron, crontab и systemd timers).
# Ежепятиминутный запуск со смещением. Удаляем сессии старше 3 часов (180 минут).
*/5 * * * * root find /mnt/nfs/sessions -type f -name 'sess_*' -mmin +180 -delete
Советы по настройке:
- Используйте
-mminдля гибкости и запас относительноgc_maxlifetime. - Чтобы снизить всплески I/O, можно ограничить параллелизм через
xargs -P(аккуратно) или запускать очистку чаще, но маленькими порциями, например, по 10–15 минут ретеншена за проход. - Иногда имеет смысл пропускать совсем «свежие» файлы:
-mmin +180 -and -not -mmin -5, снижая риск гонок с текущими запросами.
Вариант C: запуск внутреннего session_gc() из CLI
Можно вынести уборку в небольшой CLI‑скрипт на PHP. Его плюс — он использует ту же логику, что и встроенный GC, включая глубину поддиректорий в save_path. Планировщик удобно сделать через systemd‑timer (подробнее об автоматизации см. в заметке о systemd‑таймерах).
<?php
// gc.php
ini_set('session.save_handler', 'files');
ini_set('session.save_path', '3;/mnt/nfs/sessions');
ini_set('session.gc_maxlifetime', '7200');
$removed = session_gc();
fwrite(STDOUT, "removed={$removed}\n");
# systemd unit для периодической уборки
# /etc/systemd/system/php-sessions-gc.service
[Unit]
Description=PHP sessions garbage collection
[Service]
Type=oneshot
ExecStart=/usr/bin/php /opt/tools/gc.php
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
# /etc/systemd/system/php-sessions-gc.timer
[Unit]
Description=Timer for PHP sessions GC
[Timer]
Persistent=true
[Install]
WantedBy=timers.target
После добавления включите таймер: systemctl daemon-reload, затем systemctl enable --now php-sessions-gc.timer. Нагрузку и длительность контроля отслеживайте через journalctl -u php-sessions-gc.service.
Права, блокировки и безопасность
- Sticky bit: основной каталог сессий делайте
1733. Так пользователи не смогут удалять чужие файлы, а PHP‑процессы продолжат работать как ожидается. - Блокировки: файловый обработчик использует
flock()для сессионного файла. Внешняя очистка об этих блокировках не знает и может удалить файл, который кто‑то держит открытым. Обычно это безопасно для текущего процесса (именно он всё ещё пишет в «де‑факто удалённый» inode), но следующий запрос пользователя уже не найдёт файл. Лечится запасом по времени и пропуском «совсем свежих» файлов. - umask и владельцы: проверьте, что PHP‑FPM пишет сессии под правильным пользователем/группой пула. Если на NFS используется root‑squash, заранее согласуйте uid/gid.

Производительность: сколько уровней поддиректорий и как измерять
Цель — чтобы в каждом каталоге было не больше нескольких тысяч файлов. Начните с N=3 и замерьте фактическую кардинальность: команда ниже подскажет самые «толстые» каталоги.
find /mnt/nfs/sessions -type d -printf '%h\n' | sort | uniq -c | sort -nr | head
Следите за метриками:
- Количество файлов и занимаемое место:
find /mnt/nfs/sessions -type f | wc -l,du -sh /mnt/nfs/sessions. - Хвосты латентности PHP‑запросов при уборке.
- Нагрузка на NFS‑сервер в окна уборки.
Выбор места хранения сессий
Файловые сессии на общем хранилище — рабочий, но не самый быстрый вариант. Альтернативы:
- Локальный диск + балансировщик со «стикостью» по cookie. Просто и быстро, но чувствительно к отказам нод. На отдельном VDS такой вариант даёт предсказуемую производительность без сетевых накладных расходов NFS.
- In‑memory хранилище с TTL (Redis/Memcached) — быстрый и предсказуемый вариант без уборки по каталогам и проблем NFS. Требует отдельного сервиса и мониторинга.
Если остаетесь на NFS, централизованный GC обязателен.
Тест‑план перед включением в прод
- На стенде создайте нагрузку, которая генерирует 100–200 тысяч сессий с разной активностью.
- Выключите случайный GC (
gc_probability = 0), включите внешний, задав запас 20–30% кgc_maxlifetime. - Проверьте, что «живые» сессии не исчезают при активном клике и редких изменениях данных (особенно с
lazy_write). - Замерьте длительность одного прохода уборки и хвост p99 у веб‑запросов в это окно.
- Проведите фейловер «уборщика» на другой узел или вручную запустите задачу — убедитесь, что процесс переносим и детерминирован.
Чеклист и готовые пресеты
- Файловые сессии на NFS:
session.gc_probability = 0. session.save_path = "3;/mnt/nfs/sessions", права каталога1733.session.gc_maxlifetime= TTL cookie + небольшой запас.- Если уборка по
mtime—session.lazy_write = 0или добавьте 20–30% к порогу удаления. - Централизованный GC: systemd‑tmpfiles (рекомендуется) или cron + find. Чистим чаще, но маленькими порциями.
- Следим за количеством файлов, временем прохода уборки, нагрузкой на NFS и p99 веб‑запросов.
Мини‑FAQ
Нужно ли чистить подкаталоги? Да, но аккуратно. Используйте инструменты, которые рекурсивно удаляют только старые файлы, не трогая структуру. Нельзя просто «сносить» уровни — PHP ожидает их наличие.
GC в Apache mod_php и в PHP‑FPM отличается? Логика одна, но «случайный» запуск зависит от числа воркеров/процессов. В любом SAPI на NFS лучше отключать случайный GC.
Почему иногда сессии остаются дольше порога? Из‑за запаса в внешнем GC, атрибут‑кэша NFS, редких обновлений mtime и того, что уборка идёт пакетно.
Можно ли безопасно пропускать открытые файлы? Теоретически — да, через внешние утилиты, но на больших деревьях это дорого. Практичнее добавить 5–10 минут буфера и не трогать совсем свежие файлы.
Итоги
На локальном диске дефолтный GC PHP терпим. На NFS — нет: случайные рекурсивные обходы больших деревьев неизбежно добавят задержек. Переведите уборку в управляемый режим: отключите вероятностный GC на воркерах, включите централизованную чистку (systemd‑tmpfiles или cron), настройте session.save_path с хеш‑поддиректориями, добавьте запас по времени и проверьте, чтобы session.lazy_write не ломал алгоритм удаления. Это устранит неожиданные пики и сделает вашу платформу предсказуемой.


