Слышали ли вы про PHP-функции
realpath_cache_get()
и realpath_cache_size()
? А может быть про параметры realpath_cache_size
и realpath_cache_ttl
в php.ini?Кэш realpath — довольно важный механизм PHP, который нужно иметь в виду. Особенно, когда приходится работать с символическими ссылками, например, при деплое проекта. Настройка кэширования realpath может значительно влиять на быстродействие сервера и нагрузку на дисковую подсистемы сервера. Этот параметр был введен в версии 5.1, когда начали появляться первые PHP-фреймворки.
Далее мы разберемся, как все это работает под капотом, и как с этим жить. Под катом много ссылок на исходники.
Вспоминаем о системном вызове stat()
Вы знаете, как работает ваша система? Давайте я освежу вашу память. Когда вы работаете с путём, системное ядро и файловая система должны понимать, что вы от них хотите. Когда вы используете путь для доступа к файлу, ваша библиотека или ядро системы должны разрешить его. Разрешение пути — это получение информации о нем: это файл, директория или, может быть ссылка?
Один из способов сделать это — спросить систему о типе файла. В случае, если попалась ссылка, узнать о целевом файле. Когда вы используете относительные пути (вроде "
../hey/./you/../foobar
"), необходимо сначала получить абсолютный путь, а уже потом получать информацию о конечном файле.Обычно для разрешения относительного пути используется C-функция realpath(). Она, в свою очередь, делает системный вызов stat().
Вызов stat() достаточно тяжелый. Во-первых, это системный вызов, влекущий за собой прерывание и переключение конекста. Во-вторых, работает с данными на медленном диске. В коде можно найти обращения к файловой системе inode->getattr(). Обычно ядро использует собственный кэш (buffer-cache), поэтому влияние на производительность должно быть незначительным. Однако, на нагруженном сервере кэш может не содержать необходимую информацию, что влечет за собой повышенную нагрузку на дисковую подсистему. Поэтому, в наших же интересах предупреждать такое поведение.
Что делает PHP?
Проекты, написанные на PHP, обычно хранятся во множестве файлов. Сегодня мы используем тонны классов, означающих наличие тонны файлов (поскольку используем по файлу на каждый класс). Вне зависимости от того, используем мы механизм автоматической загрузки (autoload) или нет, мы должны подключать все эти файлы, чтобы прочитать код внутри них, а для этого сделать вызов
stat()
для получения информации о файле. Поэтому, когда мы получаем доступ к файлу из PHP, он сначала разрешает пути и ссылки, потом получает информацию о файле через системный вызов stat()
, а потом сохраняет полученный результат в свой собственный кэш, называемый realpath cache
.PHP использует данный кэш только при работе функции
realpath()
. Вся остальная информация о файле вроде владельца, группы, прав доступа и временных меток сохраняется в отдельный кэш — access cache
. Давайте посмотрим в исходники: когда происходит обращение к файлу, вызывается функция php_resolve_path(). Эта функция делает вызов tsrm_reapath(), которая внутри выполняет virtual_file_ex() и tsrm_realpath_r().И в этом месте происходят интересные вещи: вызываются функции вроде realpath_cache_find() для поиска сохраненных в кеше данных для запрашиваемого файла. Для хранения информации используется структура realpath_cache_bucket, которая инкапсулирует большой пакет данных:
typedef struct _realpath_cache_bucket {
unsigned long key;
char *path;
int path_len;
char *realpath;
int realpath_len;
int is_dir;
time_t expires;
#ifdef PHP_WIN32
unsigned char is_rvalid;
unsigned char is_readable;
unsigned char is_wvalid;
unsigned char is_writable;
#endif
struct _realpath_cache_bucket *next;
} realpath_cache_bucket;
Если данные в кэше не найдены, вызывается функция php_sys_lstat(), которая является прокси для системного вызова lastat(). Результат этого вызова сохраняется в realpath cache.
Настройки PHP
Итак, со стороны PHP нам необходимо знать несколько вещей про realpath cache. Для начала, настройки
php.ini
:realpath_cache_size
realpath-cache-ttl
В документации есть ремарка про увеличение этих параметров на серверах, где исходный код меняется редко. Так же стоит учесть, что стандартный размер кэша 16КБ ничтожно маленький. Он весь исчерпается одним запросом с фреймворком вроде Symfony2. Для поддержания настройки размера кэша в актуальном состоянии стоит следить за выводом функции realpath_cache_get(). Если доступный объем быстро исчерпывается — это явный повод увеличить размер кэша вплоть до 1МБ. В случае, если кэш переполнится, PHP начнет злоупотреблять вызовами stat(), что напрямую скажется на производительности. Требуемый размер кэша сложно посчитать с достаточной точностью. Покопавшись в исходниках, можно сделать вывод, что каждая сущность в кэше занимает место, равное:
`sizeof(realpath_cache_bucket) + кол-во символов разрешенного пути + 1`
Для 64-битной системы (LP64) sizeof(realpath_cache_bucket) = 56 байт.
Есть еще другая особенность. PHP разрешает каждый путь, с которым сталкивается во время работы, разбивая его на части. Если вы запросите файл
/home/julien/www/fooproject/app/web/entry.php
, PHP разобъет его на максимальное кол-во доступных путей, начиная от корня. Таким образом, он сначала сохранит в кэш /home
, потом /home/julien
, потом /home/julien/www
и т.д.Почему? Для начала, это требуется для проверки доступа к каждому уровню пути. Во-вторых, многие пользователи формируют пути, используя конкатенацию, поэтому, PHP может проверять пути по частям, каждый раз запрашивая уже закэшированную сущность. Доступ к кэш очень быстрый, детали можно посмотреть в исходниках tsrm_realpath_r(). Это
рекурсивная функция, вызываемая по умолчанию на каждый элемент пути.
Итого, первый вывод из предыдущего параграфа: кэш — это хорошо!
Второй — «дернуть» несколько страница сайта после выкладки — необходимая задача перед открытием публичного доступа к сайту. Это не только сбросит OPcode cache, но так же актуализирует realpath cache и page cache ядра системы.
Как очистить кэш realpath? Функция, выполняющая эту задачу, спрятана от посторонних глаз.
realpath_cache_clear()
? Нет, такой функции не существует :(. Зато, в лучших традициях PHP, есть clearstatcache(true). Параметр true
очень важный и зовется он $clear_realpath_cache
. Очевидно, что он как раз и служит поставленным целям.Пример
Возьмем с потолка простой пример^
<?php
$f = @file_get_contents('/tmp/bar.php');
echo "hello";
var_dump(realpath_cache_get());
hello
array(5) {
["/home/julien.pauli/www/realpath_example.php"]=>
array(4) {
["key"]=>
float(1.7251638834424E+19)
["is_dir"]=>
bool(false)
["realpath"]=>
string(43) "/home/julien.pauli/www/realpath_example.php"
["expires"]=>
int(1404137986)
}
["/home"]=>
array(4) {
["key"]=>
int(4353355791257440477)
["is_dir"]=>
bool(true)
["realpath"]=>
string(5) "/home"
["expires"]=>
int(1404137986)
}
["/home/julien.pauli"]=>
array(4) {
["key"]=>
int(159282770203332178)
["is_dir"]=>
bool(true)
["realpath"]=>
string(18) "/home/julien.pauli"
["expires"]=>
int(1404137986)
}
["/tmp"]=>
array(4) {
["key"]=>
float(1.6709564980243E+19)
["is_dir"]=>
bool(true)
["realpath"]=>
string(4) "/tmp"
["expires"]=>
int(1404137986)
}
["/home/julien.pauli/www"]=>
array(4) {
["key"]=>
int(5178407966190555102)
["is_dir"]=>
bool(true)
["realpath"]=>
string(22) "/home/julien.pauli/www"
["expires"]=>
int(1404137986)
Что мы видим? Полный путь до скрипта разрешается по частям, с самого начала. Так как файл
/tmp/bar.php
не существует, записи о нем нет в кэше. Однако, путь до /tmp
разрешен, поэтому каждый следующий запрос во вложенные файлы будет немного быстрее, чем в первый раз.В возвращаемом функцией
realpath_cache_get()
массиве можно посмотреть такую важную информацию, как время устаревания записи. Это значение посчитано на основе времени доступа к файлу и настройки realpath_cache_ttl
.Поле
key
— хэш разрешенного пути. Используется вариант алгоритма FNV. Это внутренняя информация, которая вряд ли понадобится в практическом смысле. Хэш может быть как int, так и float, в зависимости от размера INT_MAX
.Если сейчас вызвать
clearstatcache(true)
, этот массив обнулится и PHP будет снова делать системный вызов stat()
на каждый запрашиваемый файл, который раньше уже был закэширован.Поговорим про кэш OPcode
Готовы к очередному подводному камню?
Кэш realpath привязан к конкретному процессу и не сохраняется в разедляемую память (shared memory).
Это означает, что каждый раз, когда элемент кэша устаревает, изменяется или кэш очищается вручную, это необходимо делать для каждого запущенного процесса. Именно из-за этого пользователи часто испытывают трудности при развертывании приложения на серверах, использующих кэш OPCode.
Что обычно происходит во время выкладки проекта? Чаще всего мы просто заменяем символическую ссылку с одной версии на другую, например, с
/www/deploy-a
на /www/deploy-b
. И тут все обычно забывают, что кэш OPcode (по крайней мере OPCache и APC) полагаются на внутрений кэш realpath. Поэтому, механизмы кэширования OPcode не видят изменений символических ссылок и обновляют кэш только по мере его устаревания. Ну а дальше вы и так все знаете :)Лучшим найденным решением для предотвращения этого побочного эффекта стало подготовка отдельного пула воркеров PHP и переключения балансировщика на него, позволяя старым воркерам нормально завершить работу. Это позволяет изолировать две версии друг от друга, тем самым, предотвратив использование неактуального кэша. Все окружение, включая кэш realpath и кэш OPCode, будет новым. Этот прием доступен как минимум при использовании Lighttpd и Nginx. И он успешно работает в продакшне.
Конец
Меня попросили написать несколько строк о кэше realpath. Скорее всего из-за проблем, возникающих при выкладке кода. Ну, теперь вы знаете, как это работает и как этим управлять.
P.S. от переводчика:
Из древних мейл-листов php-internals:
Just a thought, should clearstatcache() force the reset of the cache? I cant think of many situations where you would re-build directory tree's on the fly, but you never know what to expect from PHP users :)
Комментарии (20)
m1el
15.09.2015 19:32-7Содержание статьи — еще одна палка в сторону PHP.
m1el@m1el:/tmp$ cat symlink.php <?php chdir("/tmp"); system("rm lnk; ln -s dir1 lnk"); print(realpath("lnk/file.txt") . "\n"); system("rm lnk; ln -s dir2 lnk"); print(realpath("lnk/file.txt") . "\n"); m1el@m1el:/tmp$ php symlink.php /tmp/dir1/file.txt /tmp/dir1/file.txt
Temirkhan
15.09.2015 21:22+15Единственной «палкой в сторону PHP» считаю огромное количество низкоквалифицированных специалистов, обусловленное низким порогом вхождения. В остальном, PHP прекрасный инструмент для решения задач в своей сфере.
slonopotamus
16.09.2015 09:01Сколько миллионов раз в секунду вы дергаете realpath() и зачем?
xobotyi
16.09.2015 09:56Да взять хотя бы автолоадеры… Я их пишу через realpath как раз из-за керишованного резолвинга путей.
nitso
16.09.2015 10:49Все намного проще. Сценарий получения ошибки такой:
Веб-сервер смотрит в папку/var/www/htdocs
Структура папки www следующая:
$ ls -al /var/www htdocs -> version-71928 version-59189 version-71928
Новая версия кода выкладыватется в отдельную папку, ссылка htdocs переключается на новую версию.
В коде в новой версии добавляется инклюд (новая библиотека, класс, служебный файл, любая сущность на диске). PHP разрешил путь до/var/www/htdocs
в виде/var/www/version-71928
и закешировал. После переключения ссылки старый кэш будет актуален вплоть до 2 минут (по умолчанию, в зависимости от realpath_cache_ttl) и все новые файлы будут искаться в прошлой версии.
Итого: версию выложили, переключили ссылки, и около 2 минут сыпятся фаталы
Unknown: Failed opening required '/var/www/htdocs/include.php'
Хуже, если старая версия удалена — фаталы будут сыпаться гуще. А в случае, когда необходимо синхронное переключение, могут возникнуть неведомые ошибки, которые никогда больше не воспроизведутся и могут принести сильнуюпопаголовную боль.
Дергать миллион раз ничего не нужно, достаточно использовать Composer Autoload :)
symbix
16.09.2015 10:21Достаточно выкладывать билды в новый каталог вида /path/to/application/$revisionNumber и обновлять конфигурацию веб-сервера.
Это решает и множество других проблем — атомарность переключения (не получится, что в какой-то момент будут использованы два файла от разных версий), возможность отката на предыдущий билд.nitso
16.09.2015 11:13Атомарности переключения вообще очень сложно добиться. Есть два варианта:
1) Разорвать текущие соединения (restart), при этом будет атомарность, но клиенты получат ошибки
2) Нормально завершить текущие соединения (reload), в этом случае атомарности не будет — параллельно будут работать и старые, и новые, но клиенты ошибок не получат.
В зависимости от требований, выбирается подходящий вариант.
Что касается кофигурации, решение со ссылкой решает одну очень важную проблему — права доступа. Для изменения ссылки не требуется привелегий, достаточно прав на запись в папку с проектом. Для перезапуска же сервера требуется привелегированный доступ (читай: root), да и возможности ошибиться там намного больше. Возможность отката сохраняется.
У php-fpm есть проблемы с reload: bugs.php.net/bug.php?id=60961, поэтому в любом случае приходится искать решение уровнем выше.
Чтобы сохранить окружение целым и не давать рутовых прав CI, для нас оптимальным вариантом стало отключать ноду на балансировщике, ждать завершения процессов, обновляться, включать ноду обратно.symbix
16.09.2015 12:36Зачем рута?
# grep nginx /etc/sudoers
%wwwctl ALL = NOPASSWD: /usr/sbin/service nginx configtest
%wwwctl ALL = NOPASSWD: /usr/sbin/service nginx reload
Проблему с релоадом знаю, да — потому без особых причин его стараюсь просто не делать. При смене пути он и не требуется.nitso
16.09.2015 17:46Еще нужно конфигурацию поменять, чтобы было, что релоадить. А это — потенциальная брешь.
symbix
17.09.2015 00:19А зачем для конфигурации рутовые права? include /path/to/app/config/nginx/*.conf.
Или про сам факт? Ну она ж автоматически генерируется, из ровно того же шаблона, который проверен на стейджинге.
Jofr
16.09.2015 17:22Проблема с релоадом решается, например, корректной конфигурацией nginx-а (т.е. уровнем выше, как вы и написали):
fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
В этом случае переключение атомарное, т.к. php-fpm работает уже с настоящим путем, а не ссылкой. И релоад вообще делать не надо.
Впрочем, если при этом надо БД обновлять, тут уже вариантов нет =)
annenkov
Спасибо, полезная информация.
Phpstorm не слышал на удивление.
nitso
github.com/JetBrains/phpstorm-stubs добавьте :)