company_banner

Во всём виноват PHP OPCache?

https://engineering.facile.it/blog/eng/realpath-cache-is-it-all-php-opcache-s-fault/
  • Перевод


Когда я начинал карьеру разработчика, то очень удивился, прочитав фразу, которую приписывают Филу Карлтону (Phil Karlton): «В информатике есть лишь две сложности: инвалидация кеша и присвоение имён». Я отнёсся к этому недоверчиво, поскольку не понял сути фразы. Но немного позже я начал понимать.


Я хочу рассказать о проблеме, с которой мы столкнулись не так давно в нашей production-инфраструктуре. Сразу после успешного развёртывания при обновлении страниц, изменённых новым релизом, какое-то время не отображался новый код. Вообще-то такое далеко не редкость для веб-приложений, написанных на PHP. Мы сталкивались с подобным и раньше, а после перехода на новую production-среду проблема стала заметнее. Поэтому мы решили заняться расследованием.


Наша процедура деплоя


Наша технология по большей части написана на PHP, а также использует фреймворки Symfony и Zend. Для отправки кода в production мы применяем внутренний проект shark-do, его автор — лидер команды Luca.


Философия shark-do:


«Если ты можешь это сделать, то можешь сделать это в bash».

Проект представляет собой bash-скрипт, умеющий определять задачу и исполнять её по алгоритму. Для каждого проекта свой алгоритм управления разными этапами, например удалением ненужных файлов, генерированием конфигурационных файлов и т. д.


Например, больше пяти раз в день я с помощью команды shark-do deploy collaboratori запускаю задачи по развёртыванию для проекта «collaboratori», над которым я работаю. Обычно развёртывание состоит из следующих этапов:


  1. Из мастер-ветки извлекается последний коммит.
  2. Настраиваются папки, удаляются ненужные файлы, начинается создание релиза.
  3. Устанавливаются параметры, запускается установка компоновщика, скачиваются и складываются ресурсы.
  4. Создаётся архив релиза, потом он перемещается на машину-бастион и распаковывается.
  5. Для запуска отката релиза с помощью REST API нашей инфраструктуры вызывается Ansible-процедура.
  6. Система переключается на новый релиз, старые релизы очищаются и удаляются с машины-бастиона.
  7. Новый релиз отмечается в New Relic, а в нашем Slack-канале появляется уведомление об окончании задачи развёртывания.

Рассмотрим пятый шаг. Ansible-сценарий отвечает:


  • за копирование нового релиза с хоста-бастиона на все целевые машины (фронтендную, пакетную (batch) и т. д.);
  • за настройку всех папок и разрешений;
  • за прогрев кеша и переключение релиза.

Каждая процедура развёртывания состоит из многих нужных операций, но поворотная точка — изменение текущей папки проекта: это делается с помощью symlink-передачи из предыдущей папки релиза в новую. Текущая папка проекта — это корневое расположение документов конкретного веб-приложения.


Например:


ln -sf /var/www/{APP_NAME}/releases/@YYYYMMDDHHIISS /var/www/{APP_NAME}/current

Опция -s используется для создания символьной ссылки, а -f — для принудительного создания такой ссылки, если целевой объект уже существует. {APP_NAME} — название проекта.


Мы применяем стандартную для PHP стратегию развёртывания. Релизы одного приложения хранятся на production-серверах, а к текущей версии мы обращаемся по символьной ссылке. Это позволяет развёртывать атомарно и безопасно, не влияя на рабочий трафик.


Наконец, за балансировщиком с карусельной (round-robin) политикой у нас стоит 15 фронтенд-серверов (в два с лишним раза больше, чем раньше). Вопрос: что происходит после переключения релиза?


Во всём виноват PHP OPCache (?)


Некоторые оговорки: мы не будем углубляться в поток выполнения PHP-скриптов, а обсудим основные вещи, чтобы вам было легче понять мои рассуждения о проблеме. Также мы станем рассматривать только PHP 7.


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



Первая фаза управляется лексическим анализатором PHP. Он отвечает за сопоставление ключевых слов языка вроде function, return и static с отдельными частями, которые обычно называются токенами. Каждый токен зачастую дополняется метаданными, необходимыми для следующей фазы.


Вторая фаза управляется парсером PHP. Он отвечает за анализ одного или нескольких токенов, а также за сопоставление их с шаблонами языковых структур. Например, $foo + 5 распознаётся как двоичная операция «сложения», а переменная $foo и число 5 распознаются как операнды. Парсер рекурсивно строит абстрактное дерево синтаксиса (AST). Обычно работа лексического анализатора и парсера считается одной задачей.


Третья фаза — компилирование. AST преобразуется в упорядоченную последовательность инструкций-опкодов. Каждый опкод можно считать низкоуровневой операцией виртуальной машины Zend. Полный список поддерживаемых опкодов можно посмотреть здесь.


Наконец, последняя фаза — исполнение. ВМ Zend выполняет каждую задачу, описанную в опкодах, и генерирует результат.


Первые три фазы (лексический анализатор, парсер и компилятор) объединены в «конвейер» (pipeline). Причём третья фаза занимает гораздо больше времени и потребляет больше ресурсов (памяти и процессора). Чтобы снизить вес фазы компилирования, в PHP 5.5 ввели расширение Zend OPCache. Оно кеширует выходные данные фазы компилирования (опкоды) в общей памяти (shm, mmap и т. д.), так что каждый PHP-скрипт компилируется только один раз, а разные запросы могут исполняться без фазы компилирования. Если в среде, не предназначенной для разработки, код меняется редко, то скорость исполнения PHP увеличивается как минимум вдвое.


Расширение OPCache также отвечает за оптимизацию опкодов, но это уже выходит за рамки статьи.


В связи со сказанным выше логично предположить, что в странном поведении, с которым мы столкнулись в нашей production-среде, виноват OPCache. Для проверки этого предположения я сделал простенькую демонстрационную среду из контейнера Docker, PHP 7.0 и Apache 2.4. Полный код можно скачать отсюда.


Для упрощения работы я написал несколько скриптов:


  • start.sh запускает контейнер Docker в правильной конфигурации.
  • release-switcher.sh каждые 10 секунд подгружает символьную ссылку на текущий релиз.
  • release-watcher.sh каждую секунду отправляет HTTP-запрос, проверяя текущий релиз, обслуживаемый Apache.

Можете просто клонировать GitHub-репозиторий, и всё готово к проверке, если у вас уже установлен Docker.


git clone https://github.com/salvatorecordiano/facile-it-realpath_cache
cd facile-it-realpath_cache
docker pull salvatorecordiano/realpath_cache

Чтобы воспроизвести проблему с кешем, нужно параллельно запустить эти команды в трёх разных командных строках:


# start the container with production configuration
./start.sh production 
# start switching the current release
./release-switcher.sh
# start watching the current web server response 
./release-watcher.sh

Результат исполнения:



Исполнение с конфигурацией production.


Повторилась проблема с кешем: после переключения релиза мы не видим правильный код после выполнения HTTP-запроса.


Теперь отключим OPCache и повторим тест.


# start the container with production configuration and opcache disabled
./start.sh production-no-opcache 
# start switching the current release
./release-switcher.sh
# start watching the current web server response 
./release-watcher.sh


Исполнение с конфигурацией production-no-opcache.


Удивительно, но проблема осталась, так что предположение было ошибочным: OPCache ни в чём не виноват.


realpath_cache: настоящий виновник


Пожалуй, при использовании функции include/require или автозагрузки PHP нужно вспомнить о realpath_cache. Кеш настоящего пути (real path cache) позволяет кешировать разрешения путей для файлов и папок, чтобы реже тратить время на поиск по диску и улучшить производительность. Это очень полезно при работе со многими сторонними библиотеками или фреймворками вроде Symfony, Zend и Laravel, поскольку они используют огромное количество файлов.


Механизм кеширования появился в PHP 5.1.0. Сегодня эта возможность в официальных документах не упоминается, если не считать функций realpath_cache_get(), realpath_cache_size(), clearstatcache() и php.ini-параметров realpath_cache_size и realpath_cache_ttl. Из внешних источников я смог найти только старый пост, написанный Джульеном Поли в 2014-м. Поли, широко известный разработчик PHP, объясняет, как работает механизм разрешения путей.


Когда мы обращаемся к файлу, PHP пытается разрешить его путь с помощью stat(), системного вызова Unix: он возвращает атрибуты файла (разрешения, расширение и прочие метаданные) применительно к индексному дескриптору (inode). В мире Unix индексный дескриптор — это структура данных, используемая для описания объекта файловой системы, например файла или директории. PHP кладёт результат системного вызова в структуру данных под названием realpath_cache_bucket, за исключением таких вещей, как разрешения и владельцы. Так что если попытаться второй раз обратиться к тому же файлу, то при поиске в bucket в памяти (bucket lookup) нас избавят ещё от одного медленного системного вызова. Если хотите узнать больше, изучите исходный код PHP.


Функция realpath_cache_get появилась в PHP 5.3.2. Она позволяет получать массив, состоящий из записей кеша настоящих путей. В каждом элементе массива ключом является разрешённый путь (resolved path), а значением — другой массив с данными вроде key, is_dir, realpath, expires.


Дальше идут выходные данные print_r(realpath_cache_get()); в нашей тестовой Docker-среде:


Array
(
    [/var/www/html] => Array
        (
            [key] => 1438560323331296433
            [is_dir] => 1
            [realpath] => /var/www/html
            [expires] => 1504549899
        )
    [/var/www] => Array
        (
            [key] => 1.5408950988325E+19
            [is_dir] => 1
            [realpath] => /var/www
            [expires] => 1504549899
        )
    [/var] => Array
        (
            [key] => 1.6710127960665E+19
            [is_dir] => 1
            [realpath] => /var
            [expires] => 1504549899
        )
    [/var/www/html/release1] => Array
        (
            [key] => 7631224517412515240
            [is_dir] => 1
            [realpath] => /var/www/html/release1
            [expires] => 1504549899
        )
    [/var/www/current] => Array
        (
            [key] => 1.7062595747834E+19
            [is_dir] => 1
            [realpath] => /var/www/html/release1
            [expires] => 1504549899
        )
    [/var/www/current/index.php] => Array
        (
            [key] => 6899135167081162414
            [is_dir] => 0
            [realpath] => /var/www/html/release1/index.php
            [expires] => 1504549899
        )
)

Здесь:


  • key — число с плавающей запятой, оно является хешем пути.
  • is_dir — булево значение, равно true, если разрешённый путь является директорией; в противном случае равно false.
  • realpath — разрешённый путь, строковое.
  • expires — целое число, обозначает время, кеш пути будет инвалидирован. Это значение строго связано с параметром realpath_cache_ttl.

В предыдущем примере у нас было шесть путей, но все они связаны с разрешением пути /var/www/current/index.php. PHP создал шесть кеш-ключей для разрешения лишь одного пути. Так что путь разбивается на части, каждая из которых поочерёдно разрешается. В нашем случае «настоящий» путь — это /var/www/html/release1/index.php, потому что /var/www/current — символьная ссылка на папку /var/www/html/release1.


В посте Джульена Паули также говорится:


«Кеш настоящего пути привязан к процессу и не помещается в общую память».

Это значит, что кеш должен устаревать для каждого процесса. Если для очистки всего веб-сервера мы используем PHP-FPM, то придётся ждать, когда кеш устареет для каждого воркера в пуле. Это помогает понять, что происходит во время тестирования с использованием конфигурации production-no-opcache. Даже если отключить OPCache после получения символьной ссылки, PHP неторопливо уведомит все процессы об устаревании путей.


В нашей реальной production-среде пришлось учитывать, что у нас 15 фронтенд-серверов, на которых хостится много веб-приложений. На каждом сервере по одном пулу PHP-FPM, каждый из которых состоит из 35 воркеров и одного мастер-процесса. Это объясняет, почему «странное поведение» стало заметнее в новой среде. Можно скорректировать влияние кеша настоящего пути на наше веб-приложение, воспользовавшись параметрами realpath_cache_size и realpath_cache_ttl: первый определяет размер bucket, которым будет пользоваться PHP. Это целое число, и увеличить его полезно для веб-приложений, работающих с огромным количеством файлов. Второй параметр realpath_cache_ttl, как уже говорилось, представляет собой длительность кеширования информации о настоящем пути (в секундах).


Теперь всё понятно, можно снова включить OPCache и отключить кеш настоящего пути, настроив его размер и время жизни:


realpath_cache_size=0k
realpath_cache_ttl=-1

Снова запустим тест:


# start the container with production configuration, opcache enabled and realpath_cache disabled
./start.sh production-no-realpath-cache 
# start switching the current release
./release-switcher.sh
# start watching the current web server response 
./release-watcher.sh


Исполнение с конфигурацией production-no-realpath-cache.


Хочу отметить, что нашу последнюю конфигурацию настоятельно не рекомендуется использовать в production-среде, потому что PHP вынужден разрешать каждый встреченный путь, что плохо влияет на производительность.


Заключение


Я хотел рассказать о решении таинственной проблемы с кешем, о том, что узнал об OPCache и кеше настоящего пути, а также об их различиях. Сценарий, описанный в начале статьи, выдуман, но, к примеру, если запрос начинается при одной версии кода, затем во время исполнения пытается обратиться к другим файлам, а их обновили, переместили или удалили в последующих версиях кода, то могут возникнуть реальные проблемы. В худшем случае придётся обеспечивать совместимость двух последовательных релизов, но в описанных условиях этого очень трудно достичь.


Необходимо внедрять стратегию атомарного развёртывания (в строгом смысле). Например, можно использовать контейнеры или новый изолированный пул памяти PHP-FPM для каждого развёрнутого релиза. В последнем случае нужно как минимум удвоить объём памяти, чтобы можно было держать побольше одновременно работающих FPM-пулов.


Также для поддержки атомарных развёртываний можно использовать Apache-модуль под названием mod_realdoc. Его написал Расмус Лердорф (Rasmus Lerdorf). В этом модуле реализована хитрость: в начале запроса вызывается настоящий путь по символьной ссылке DOCUMENT_ROOT, при этом абсолютный путь для всего запроса устанавливается как настоящий корневой каталог документов (document root). Поэтому запросы, которые начинаются до изменения символьной ссылки, будут исполняться применительно к предыдущему целевому объекту символьной ссылки. Главный недостаток модуля — необходимо использовать префорк Apache Multi-Processing Module (MPM). Этот префорк реализует беспоточный (non-threaded) сервер, использующий форкинг (forking based). Сервер плодит новые процессы и держит их для обслуживания запросов. Это лучший MPM для изолирования каждого запроса, так что при проблемы одного запроса не затронут другие запросы. Но когда сервер под высокой нагрузкой, MPM скорее повредит, потому что он использует по одному процессу на запрос, и в результате одновременным запросам не будет хватать ресурсов, им придётся ждать, пока освободится серверный процесс. Таких же результатов, как и с mod_realdoc, можно достичь на PHP-уровне во фронт-контроллере (front controller) приложения, если в realpath(__FILE__) определить основную корневую папку.


Если перед PHP вы используете nginx, то вам повезло! Чтобы избежать обновления символьных ссылок при выполнении запросов, нужно заставить nginx разрешать символьные ссылки и присваивать их DOCUMENT_ROOT. Достаточно изменить несколько строк кода в серверных блоках:


# default configuration
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
# configuration with real path resolution
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;

В результате nginx будет разрешать символьные ссылки, пряча их от PHP.


Это лишь некоторые из способов борьбы с проблемами кеша настоящего пути. Не существует универсального, «правильного» способа. Вам придётся находить своё идеальное решение в зависимости от ваших требований и инфраструктуры.


Ссылки


Mail.Ru Group 896,64
Строим Интернет
Поделиться публикацией
Похожие публикации
Комментарии 25
  • +1

    Можно ещё использовать подход с многоверсионным деплоем: https://www.youtube.com/watch?v=qMu4YHJV1Z8

    • +5
      reload php-fpm решает задачу полностью.
    • 0
      Столкнулся с такой ерундой, когда настраивал автоматический деплой на сервер. Так как проект небольшой и всего один, то проблема была решена перезагрузкой апача.
      • +1
        Очистка opcache 1 файлом через fcgi прямо при деплое gist.github.com/asp24/7767888
        • +2
          if ( isset($_GET['__new_release') ) {
            clearstatcache( true );
            opcache_reset();
          }
          

          php.net/manual/ru/function.clearstatcache.php
          php.net/manual/ru/function.opcache-reset.php
          • 0
            Главное чтобы никто не знал этой строки запроса, или ввести другие методы проверки, а то так легко можно заддосить сервер, постоянно очищая кеш.
            • 0
              я специально с ошибкой код закоментил, именно поэтому
          • 0
            старые релизы очищаются и удаляются

            Хотя бы один предыдущий релиз хранится для быстрого роллбэка?
            • –7
              Зачем я вообще зашёл в этот пост.

              Как увидел эти $, да и жутчайшие объявления массивов, сразу хлынули жуткие воспоминания, кошмары, унижения и страдания.

              Предупреждать надо.

              Всем удачных итераций по пустым массивам, что в пхп нормально.
              • 0

                А где вы тут увидели "жутчайшие объявления массивов"? Неужели это вы так отладочный вывод обозвали?

                • +1
                  Каждому своё. Но настоящему профессионалу язык не проблема, а всего инструмент.
                • +1
                  Странно, что подобная статья всплыла только сейчас. Уже давненько во все ansible задачи по деплою добавил шаг по очистке opcache через cachetool github.com/mlanin/ansible-laravel/blob/master/config/steps/clear_opcache.yml
                  • 0
                    А они не словят race condition, если симлинк на каталог с проектом поменяется в момент подгрузки пачки инклудов? Тогда один файл может прочитаться из старой версии, а следующий — из другой. Не очень-то и атомарненько.
                    • 0
                      При использовании атомарного развёртывания при помощи вебсервера, как это указано в заключении, вебсервер передаёт реальный адрес PHP скрипту, и он никак не может изменится во время выполнения одного запроса.
                      • 0
                        Хм, если в PHP использовать только относительные пути для инклудов, то прокатит.
                        • 0
                          Абсолютные пути? Инклюды? А автолоадеры уже запретили?
                          • +1

                            А по вашему "автолоадеры", что под капотом подключают файлы не иначе как через include/require конструкции, не столкнуться с проблемой такого рода? А что, вдруг, не так с "инклюдами" по путям? — или есть какой-то иной способ подтянуть файл, содержащий не класс? а с какой строчки начинается подключение того же composer в проекте?

                            • 0
                              Это проблема автолоадеров, которые пишут грамотные люди, и они там уже давно решены. Я вообще не вижу смысла в использовании абсолютных путей где бы то ни было. Я ни разу не использовал инклюд по абсолютным путям и не видел его использования в популярных скриптах.
                              А что, вдруг, не так с «инклюдами» по путям? — или есть какой-то иной способ подтянуть файл, содержащий не класс?

                              Если вдруг у вас завалялась такая старая библиотека, то использование относительных путей при включении является лучшим выбором.
                              а с какой строчки начинается подключение того же composer в проекте?

                              require('../vendor/autoload.php');

                              Первый и единственный инклюд в современных проектах.
                              • +2
                                @sumanal вы — тролль? о_0

                                1) Laravel: github.com/laravel/laravel/blob/master/public/index.php#L24-L38
                                2) Symfony: github.com/symfony/symfony-standard/blob/3.3/web/app.php#L5-L7
                                3) Yii2: github.com/yiisoft/yii2-app-advanced/blob/master/requirements.php#L14
                                4) Да вообще что угодно, кроме Zend

                                • 0
                                  Ну хорошо, два инклюда, а не один )
                                  А использование __DIR__ в данном случае ничем не отличается от относительного инклюда. Даже не понятно, зачем оно вообще так сделано.
                                  • +1
                                    Для того, чтобы проблем не было. Запуск может происходить из любой части системы, например в юнитах (а точнее функциональных\интеграционных тестах) или через прокси сервер. Короче, не суть. Абсолютные пути всегда гарантируют надёжность и однозначность «отношений», а относительные зависят от настроения разработчика, переходной фазы луны и формы шапочки из фольги на головах у котиков.

                                    По-этому, всегда указываются абсолютные пути. И это, кстати, касается не только PHP, но, например, и html с подключением внешних ресурсов через src/href.
                                    • 0
                                      По моему опыту наоборот, абсолютные пути приводят к проблемам. Например, при том же подключении внешних ресурсов в html с абсолютными путями приходится их править при переходе на HTTPS и вообще смене домена, с относительными такой проблемы нет.
                                      • +1

                                        1) src="//yoursite.ru/path/to/file.jpg"
                                        — Это абсолютный путь?
                                        — Да.
                                        — Смена протокола влияет?
                                        — Не влияет.
                                        — Нет проблем?
                                        — Да, точно, домен! Это проблема!


                                        2) src="/path/to/file.jpg"
                                        — Это абсолютный путь?
                                        — Да.
                                        — Смена протокола влияет?
                                        — Не влияет.
                                        — Может домен?
                                        — Какой домен?


                                        ...


                                        Настоятельно рекомендую почитать мануал по основам путей =)) Всё же странно не знать такое.

                                • –1

                                  В современных это каких? Вот laravel это современный фреймворк? — вы наблюдали как он конфиги грузит и какого они формата? грузит через автолоад? через композер? а кэширование некоторыми фреймворками yaml/xml файлов в php формат, подтягивается как-то иначе?
                                  А то получается, как-то вы вместо того, чтобы рассматривать "современный" код, на который сами так делаете акцент, в.т.ч. указывая на "старые" библиотеки, — вы посмотрели только то, как "современный" код выглядит в вашем приложении, словно это такая вещь в себе, не содержащая исходников и никогда не ссылающаяся на код вне классов.


                                  Речь идёт ведь и о том, что и относительный путь и абсолютный пройдут через real path cache — будь то autoload, будь то простой include/require — под капотом вы будете иметь разжёванный в реальный путь симлинк, который отстаёт от реального мира на X-сконфигурированное время. При чём тут тенденции и какая разница какой путь — я вообще не пойму...

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое