От переводчика: разбираясь на днях с ошибкой, возникшей после деплоя сервиса, натолкнулся на эту замечательную статью про механизм кэширования файловых статусов в PHP. Предлагаю сообществу перевод.

Слышали ли вы про 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)


  1. annenkov
    15.09.2015 16:59
    +1

    Спасибо, полезная информация.

    Слышали ли вы про PHP-функции realpath_cache_get() и realpath_cache_size()?

    Phpstorm не слышал на удивление.


    1. nitso
      15.09.2015 17:42
      +2

  1. SailorMax
    15.09.2015 18:45

    Переводчику: не забывайте переводить слово «деплой».


    1. zelenin
      15.09.2015 23:14
      +2

      какое слово вы перевели словом «деплой»?)


      1. DaleMartinWatson
        16.09.2015 00:50
        -1

        Развертывание?


  1. 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
    


    1. Temirkhan
      15.09.2015 21:22
      +15

      Единственной «палкой в сторону PHP» считаю огромное количество низкоквалифицированных специалистов, обусловленное низким порогом вхождения. В остальном, PHP прекрасный инструмент для решения задач в своей сфере.


  1. landy
    16.09.2015 08:22
    +1

    Если это перевод, то где ссылка на оригинал?
    (или я немного ослеп..)


    1. Starche
      16.09.2015 13:33
      +1

      Под стрелочками для голосования какая-то странная иконка, возле неё надпись «перевод», и потом имя автора, которое является ссылкой. Да, ссылкой зеленого цвета, не удивляйтесь. Наверное так и задумано, я не знаю


      1. landy
        16.09.2015 13:34
        +1

        Ох, ну точно ослеп :)


  1. slonopotamus
    16.09.2015 09:01

    Сколько миллионов раз в секунду вы дергаете realpath() и зачем?


    1. xobotyi
      16.09.2015 09:56

      Да взять хотя бы автолоадеры… Я их пишу через realpath как раз из-за керишованного резолвинга путей.


    1. 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 :)


  1. symbix
    16.09.2015 10:21

    Достаточно выкладывать билды в новый каталог вида /path/to/application/$revisionNumber и обновлять конфигурацию веб-сервера.

    Это решает и множество других проблем — атомарность переключения (не получится, что в какой-то момент будут использованы два файла от разных версий), возможность отката на предыдущий билд.


    1. nitso
      16.09.2015 11:13

      Атомарности переключения вообще очень сложно добиться. Есть два варианта:
      1) Разорвать текущие соединения (restart), при этом будет атомарность, но клиенты получат ошибки
      2) Нормально завершить текущие соединения (reload), в этом случае атомарности не будет — параллельно будут работать и старые, и новые, но клиенты ошибок не получат.
      В зависимости от требований, выбирается подходящий вариант.

      Что касается кофигурации, решение со ссылкой решает одну очень важную проблему — права доступа. Для изменения ссылки не требуется привелегий, достаточно прав на запись в папку с проектом. Для перезапуска же сервера требуется привелегированный доступ (читай: root), да и возможности ошибиться там намного больше. Возможность отката сохраняется.

      У php-fpm есть проблемы с reload: bugs.php.net/bug.php?id=60961, поэтому в любом случае приходится искать решение уровнем выше.
      Чтобы сохранить окружение целым и не давать рутовых прав CI, для нас оптимальным вариантом стало отключать ноду на балансировщике, ждать завершения процессов, обновляться, включать ноду обратно.


      1. 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

        Проблему с релоадом знаю, да — потому без особых причин его стараюсь просто не делать. При смене пути он и не требуется.


        1. nitso
          16.09.2015 17:46

          Еще нужно конфигурацию поменять, чтобы было, что релоадить. А это — потенциальная брешь.


          1. symbix
            17.09.2015 00:19

            А зачем для конфигурации рутовые права? include /path/to/app/config/nginx/*.conf.

            Или про сам факт? Ну она ж автоматически генерируется, из ровно того же шаблона, который проверен на стейджинге.


      1. Jofr
        16.09.2015 17:22

        Проблема с релоадом решается, например, корректной конфигурацией nginx-а (т.е. уровнем выше, как вы и написали):

        fastcgi_param   DOCUMENT_ROOT           $realpath_root;
        fastcgi_param   SCRIPT_FILENAME         $realpath_root$fastcgi_script_name;
        

        В этом случае переключение атомарное, т.к. php-fpm работает уже с настоящим путем, а не ссылкой. И релоад вообще делать не надо.

        Впрочем, если при этом надо БД обновлять, тут уже вариантов нет =)


  1. romy4
    16.09.2015 14:22

    А что касаемо TTL устаревания, есть какие-то рекомендательные значения?