[Перевод] Во всём виноват PHP OPCache?
Когда я начинал карьеру разработчика, то очень удивился, прочитав фразу, которую приписывают Филу Карлтону (Phil Karlton): «В информатике есть лишь две сложности: инвалидация кеша и присвоение имён». Я отнёсся к этому недоверчиво, поскольку не понял сути фразы. Но немного позже я начал понимать.
Я хочу рассказать о проблеме, с которой мы столкнулись не так давно в нашей production-инфраструктуре. Сразу после успешного развёртывания при обновлении страниц, изменённых новым релизом, какое-то время не отображался новый код. Вообще-то такое далеко не редкость для веб-приложений, написанных на PHP. Мы сталкивались с подобным и раньше, а после перехода на новую production-среду проблема стала заметнее. Поэтому мы решили заняться расследованием.
Наша процедура деплоя
Наша технология по большей части написана на PHP, а также использует фреймворки Symfony и Zend. Для отправки кода в production мы применяем внутренний проект shark-do, его автор — лидер команды Luca.
Философия shark-do:
«Если ты можешь это сделать, то можешь сделать это в bash».
Проект представляет собой bash-скрипт, умеющий определять задачу и исполнять её по алгоритму. Для каждого проекта свой алгоритм управления разными этапами, например удалением ненужных файлов, генерированием конфигурационных файлов и т. д.
Например, больше пяти раз в день я с помощью команды shark-do deploy collaboratori
запускаю задачи по развёртыванию для проекта «collaboratori», над которым я работаю. Обычно развёртывание состоит из следующих этапов:
- Из мастер-ветки извлекается последний коммит.
- Настраиваются папки, удаляются ненужные файлы, начинается создание релиза.
- Устанавливаются параметры, запускается установка компоновщика, скачиваются и складываются ресурсы.
- Создаётся архив релиза, потом он перемещается на машину-бастион и распаковывается.
- Для запуска отката релиза с помощью REST API нашей инфраструктуры вызывается Ansible-процедура.
- Система переключается на новый релиз, старые релизы очищаются и удаляются с машины-бастиона.
- Новый релиз отмечается в 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.
Это лишь некоторые из способов борьбы с проблемами кеша настоящего пути. Не существует универсального, «правильного» способа. Вам придётся находить своё идеальное решение в зависимости от ваших требований и инфраструктуры.
Ссылки
- realpath_cache
- Atomic deploys at Etsy
- mod_realdoc
- Understanding OpCache
- PHP OPCache
- Climbing the Abstract Syntax Tree