Выберите продукт

PHP‑сессии под контролем: session.gc_* и уборка на NFS/общем хранилище

Файловые PHP‑сессии на общем хранилище нередко вызывают I/O‑штормы и длинные хвосты латентности. Разберём устройство GC в PHP, риски NFS, оптимальные значения session.gc_* и session.save_path, влияние lazy_write и перенос уборки в управляемый процесс через systemd‑tmpfiles или cron.
PHP‑сессии под контролем: session.gc_* и уборка на NFS/общем хранилище

Если у вас несколько веб‑нод и сессии лежат на общем хранилище (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 шторм и риски гонок с рабочими процессами.

Схема каталога с PHP‑сессиями с хеш‑поддиректориями на NFS

Правильная стратегия: единый контролируемый 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.

Схема планировщика: systemd‑таймер и cron для периодической уборки

Производительность: сколько уровней поддиректорий и как измерять

Цель — чтобы в каждом каталоге было не больше нескольких тысяч файлов. Начните с 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 обязателен.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Тест‑план перед включением в прод

  1. На стенде создайте нагрузку, которая генерирует 100–200 тысяч сессий с разной активностью.
  2. Выключите случайный GC (gc_probability = 0), включите внешний, задав запас 20–30% к gc_maxlifetime.
  3. Проверьте, что «живые» сессии не исчезают при активном клике и редких изменениях данных (особенно с lazy_write).
  4. Замерьте длительность одного прохода уборки и хвост p99 у веб‑запросов в это окно.
  5. Проведите фейловер «уборщика» на другой узел или вручную запустите задачу — убедитесь, что процесс переносим и детерминирован.

Чеклист и готовые пресеты

  • Файловые сессии на NFS: session.gc_probability = 0.
  • session.save_path = "3;/mnt/nfs/sessions", права каталога 1733.
  • session.gc_maxlifetime = TTL cookie + небольшой запас.
  • Если уборка по mtimesession.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 не ломал алгоритм удаления. Это устранит неожиданные пики и сделает вашу платформу предсказуемой.

Поделиться статьей

Вам будет интересно

Caddy + PHP-FPM на VDS: авто-HTTPS, HTTP/2/3, сжатие и разбор 502/504 OpenAI Статья написана AI (GPT 5)

Caddy + PHP-FPM на VDS: авто-HTTPS, HTTP/2/3, сжатие и разбор 502/504

Собираем на VDS связку Caddy + PHP-FPM: ставим пакеты, пишем рабочий Caddyfile для FastCGI, включаем Auto HTTPS и проверяем HTTP/2 ...
AppArmor vs SELinux в Debian/Ubuntu: что выбрать и как не сломать прод при hardening OpenAI Статья написана AI (GPT 5)

AppArmor vs SELinux в Debian/Ubuntu: что выбрать и как не сломать прод при hardening

Сравниваем AppArmor и SELinux в Debian/Ubuntu глазами админа: в чём разница моделей, что проще внедрить на проде, как включать мяг ...
systemd-resolved: NXDOMAIN, negative caching, TTL и DNSSEC (SERVFAIL) — диагностика и лечение OpenAI Статья написана AI (GPT 5)

systemd-resolved: NXDOMAIN, negative caching, TTL и DNSSEC (SERVFAIL) — диагностика и лечение

Частая проблема на Linux/VDS: внезапные NXDOMAIN в systemd-resolved, «залипание» из-за negative caching и stale cache, влияние SOA ...