Деплой и эксплуатация
Эксплуатация PHP в продакшене — конфигурация окружения через .env, логирование и мониторинг ошибок, фича-флаги для рискованных выкаток, стратегии деплоя без простоя и планировщик задач на нескольких серверах приложения.
10 вопросов
JuniorТеорияОчень частоЧто библиотека загрузки окружения vlucas/phpdotenv делает с файлом .env и почему .env не коммитят?
Что библиотека загрузки окружения vlucas/phpdotenv делает с файлом .env и почему .env не коммитят?
Она один раз читает .env на старте и кладёт каждую строку в окружение процесса, поэтому getenv() и $_ENV её возвращают, а секрет не зашит в исходники. В .env лежат значения, отличающиеся от машины к машине, — пароль базы, ключи API, — поэтому он не попадает в git: коммитят .env.example, а настоящие значения задают на каждом сервере.
Типичные ошибки
- ✗Закоммитить
.envс настоящими доступами и ничего не ротировать после попадания в историю git - ✗Звать хелпер
env()вне config-файла, из-за чего он возвращает null при закэшированной конфигурации - ✗Считать файл
.envна диске боевым хранилищем секретов вместо настоящих переменных окружения
Уточняющие вопросы
- →Что ломается, когда фреймворк закэшировал конфиг, а контроллер всё ещё зовёт хелпер
env()? - →Где место боевым секретам, когда файла
.envна диске уже недостаточно?
SeniorДизайнОчень частоЧетыре инстанса PHP-FPM стоят за балансировщиком и обслуживают трафик непрерывно, а следующий релиз ещё и меняет схему базы. Спроектируйте деплой без простоя: как новый релиз становится живым на одном инстансе так, чтобы ни один запрос не увидел наполовину обновлённое дерево, что должно произойти после раскладки кода, прежде чем инстанс снова годится под трафик, как балансировщик решает, что инстанс готов, и как выстроить изменение схемы, чтобы старый и новый код одновременно работали с одной базой. Скажите, как откатываться, когда миграция уже применена к живым данным.
Четыре инстанса PHP-FPM стоят за балансировщиком и обслуживают трафик непрерывно, а следующий релиз ещё и меняет схему базы. Спроектируйте деплой без простоя: как новый релиз становится живым на одном инстансе так, чтобы ни один запрос не увидел наполовину обновлённое дерево, что должно произойти после раскладки кода, прежде чем инстанс снова годится под трафик, как балансировщик решает, что инстанс готов, и как выстроить изменение схемы, чтобы старый и новый код одновременно работали с одной базой. Скажите, как откатываться, когда миграция уже применена к живым данным.
Каждый релиз собирайте в свой каталог и переключайте симлинк атомарно, затем сбрасывайте OPcache и перезагружайте FPM — иначе прогретый OPcache продолжит отдавать старый код. Выведите инстанс из пула, разложите релиз и верните его, только когда пройдёт health check; парк катите партиями. Схему меняйте по expand-then-contract, а новый путь держите за флагом, чтобы откат был переключением, а не миграцией.
Типичные ошибки
- ✗Переключить симлинк и оставить прогретый OPcache отдавать скомпилированный код прошлого релиза
- ✗Применить разрушающую миграцию в том же релизе, что и код, который перестаёт пользоваться колонкой
- ✗Вернуть инстанс в пул раньше, чем health check подтвердил, что он действительно способен обслуживать
Уточняющие вопросы
- →Что входит в фазу expand, что — в фазу contract и насколько далеко друг от друга они должны выкатываться?
- →Почему откат не может просто развернуть миграцию, уже отработавшую на живых данных?
JuniorТеорияЧастоЧто делает атомарный деплой PHP и почему переключение симлинка — не последний шаг?
Что делает атомарный деплой PHP и почему переключение симлинка — не последний шаг?
Релиз собирают в новый каталог — composer install, ассеты, конфиг, — пока предыдущий продолжает обслуживать трафик. Затем один ln -sfn атомарно переводит current на него, и запрос не попадает в наполовину скопированное дерево. Переключение — не конец: OPcache всё ещё держит скомпилированные файлы прошлого релиза, поэтому его сбрасывают и перезагружают FPM.
Типичные ошибки
- ✗Деплоить на месте, из-за чего запрос попадает в наполовину записанное дерево
- ✗Переключить симлинк и оставить прогретый OPcache отдавать прошлый релиз
- ✗Забыть, что долгоживущие воркеры очередей держат старый код в памяти до перезапуска
Уточняющие вопросы
- →Почему прогретый OPcache продолжает отдавать старый код, когда
currentуже смотрит на новый релиз? - →Что должно произойти с воркерами очередей, прежде чем деплой можно считать законченным?
JuniorТеорияЧастоЧем запуск PHP-скрипта из cron отличается от того же скрипта, отданного через PHP-FPM?
Чем запуск PHP-скрипта из cron отличается от того же скрипта, отданного через PHP-FPM?
Cron запускает CLI SAPI, который читает свой собственный php.ini — обычно с другим memory_limit — и где max_execution_time равен 0, поэтому долгий прогон никто не обрывает. Запроса нет вовсе: ни $_GET, ни $_SERVER['HTTP_*'], ни сессии. Скрипт работает от пользователя cron с его скудным PATH, а вывод теряется, если его не перенаправить.
Типичные ошибки
- ✗Считать, что лимиты
php.iniот FPM действуют и на прогон из cron, — у CLI SAPI свой файл - ✗Полагаться на бинарник или переменную, которые есть в вашей оболочке, но не в скудном окружении cron
- ✗Потерять вывод упавшего прогона, потому что stdout и stderr скрипта никуда не перенаправлены
Уточняющие вопросы
- →Почему
max_execution_timeне останавливает разогнавшийся cron-скрипт так, как останавливает веб-запрос? - →Что делает задачу планировщика безопасной для двойного запуска и почему это вообще важно?
JuniorТеорияЧастоКуда в проде попадают предупреждение PHP, непойманное исключение и строка лога приложения?
Куда в проде попадают предупреждение PHP, непойманное исключение и строка лога приложения?
Свои предупреждения и фаталы движок пишет в error_log, заданный в php.ini — под FPM это лог пула, — а display_errors выключен, поэтому непойманное исключение приходит пользователю пустой 500-й без тела. Строки приложения — отдельный поток: PSR-3-логгер вроде Monolog пишет их в stderr или в файл, а сборщик увозит оба потока с машины.
Типичные ошибки
- ✗Оставить
display_errorsвключённым в проде и отдать стек вызовов прямо пользователю - ✗Писать лог только в файл на каждом сервере, из-за чего по парку машин ничего не найти
- ✗Считать, что предупреждение PHP дойдёт до логгера приложения, —
error_logдвижка это отдельный поток
Уточняющие вопросы
- →Почему на боевой машине
display_errorsдолжен быть выключен, аlog_errors— включён? - →Что даёт сборщик логов такого, чего не даст
tail -fна каждом сервере?
MiddleТеорияЧастоЧто сервис мониторинга исключений Sentry даёт сверх лог-файла и что в нём обязательно настроить?
Что сервис мониторинга исключений Sentry даёт сверх лог-файла и что в нём обязательно настроить?
Он ловит исключение вместе со стеком и контекстом запроса, пользователя и релиза, группирует повторы по отпечатку — одна ошибка становится одной задачей, а не 40 000 строк, — и шлёт оповещение; лог-файл не делает ничего из этого. Проставьте теги release и environment из конфигурации, иначе не скажешь, какой деплой сломал, и вычищайте секреты и персональные данные до отправки.
Типичные ошибки
- ✗Выкатить без тега
release, из-за чего никто не скажет, какой деплой принёс ошибку - ✗Слать тела запросов и заголовки без вычистки, утекая паролями и токенами наружу
- ✗Считать трекер заменой логам, а не слоем, который группирует события и шлёт оповещения
Уточняющие вопросы
- →Как группировка по отпечатку превращает 40 000 событий в одну задачу, с которой можно работать?
- →Какие поля надо вычистить до того, как событие уйдёт с сервера, и где это настраивается?
MiddleДизайнИногдаВы готовитесь выкатить переписанный checkout на сайт, который принимает платежи круглосуточно. Команда хочет, чтобы новый код лежал в проде за несколько дней до того, как его увидит первый клиент, и чтобы его можно было выключить за секунды, если конверсия просядет, — не дожидаясь деплоя. Спроектируйте систему фича-флагов для этой выкатки: где хранится значение флага и как его читает работающее приложение, как разгонять с 1% до 50% пользователей, удерживая каждого конкретного пользователя на одной и той же стороне флага, как флаг выключают в горячий момент и что происходит с флагом, когда переписывание полностью приземлилось. Скажите, для чего фича-флаг использовать нельзя.
Вы готовитесь выкатить переписанный checkout на сайт, который принимает платежи круглосуточно. Команда хочет, чтобы новый код лежал в проде за несколько дней до того, как его увидит первый клиент, и чтобы его можно было выключить за секунды, если конверсия просядет, — не дожидаясь деплоя. Спроектируйте систему фича-флагов для этой выкатки: где хранится значение флага и как его читает работающее приложение, как разгонять с 1% до 50% пользователей, удерживая каждого конкретного пользователя на одной и той же стороне флага, как флаг выключают в горячий момент и что происходит с флагом, когда переписывание полностью приземлилось. Скажите, для чего фича-флаг использовать нельзя.
Держите значение флага вне выкаченного кода — в хранилище конфигурации, которое приложение читает на запросе, — тогда переключение не требует деплоя. Новый код выкатывайте тёмным, а затем разгоняйте, раскладывая пользователей по хешу их id, чтобы один пользователь всегда попадал на одну сторону. Держите аварийный выключатель на старый путь. Флаги временны, и флаг никогда не заменяет проверку прав.
Типичные ошибки
- ✗Раскладывать корзину случайно на каждом запросе, из-за чего пользователя кидает между старым и новым checkout
- ✗Хранить флаг в выкаченном конфиге, из-за чего выключение требует того самого деплоя, которого избегали
- ✗Оставлять мёртвые флаги в коде, из-за чего каждую ветку приходится вечно продумывать и тестировать
Уточняющие вопросы
- →Почему корзина должна считаться из хеша id пользователя, а не разыгрываться случайно на каждом запросе?
- →Чем фича-флаг отличается от проверки прав и почему их нельзя смешивать?
SeniorДизайнИногдаПриложение на Laravel работает на шести одинаковых серверах, собранных из одного образа, а ночная задача биллинга живёт в планировщике приложения, поэтому cron запускает её на каждом сервере. Клиентов списывают шесть раз. Спроектируйте задачи планировщика для парка серверов: как ровно один сервер оказывается исполнителем конкретной задачи, что происходит, когда держатель права на запуск умирает на середине, как задача остаётся корректной, если её всё-таки запустили дважды, и как наутро узнать, что задача молча не отработала вовсе. Скажите, почему назначить одну машину cron-сервером — это ещё не весь ответ.
Приложение на Laravel работает на шести одинаковых серверах, собранных из одного образа, а ночная задача биллинга живёт в планировщике приложения, поэтому cron запускает её на каждом сервере. Клиентов списывают шесть раз. Спроектируйте задачи планировщика для парка серверов: как ровно один сервер оказывается исполнителем конкретной задачи, что происходит, когда держатель права на запуск умирает на середине, как задача остаётся корректной, если её всё-таки запустили дважды, и как наутро узнать, что задача молча не отработала вовсе. Скажите, почему назначить одну машину cron-сервером — это ещё не весь ответ.
Выбирайте одного исполнителя через общую блокировку — onOneServer() в Laravel берёт её в общем кэше, а проигравшие выходят — и дайте ей TTL, чтобы умерший держатель не заблокировал задачу навсегда. Это всё ещё доставка не менее одного раза, поэтому задача обязана быть идемпотентной: ключуйте списание и пропускайте дубль. Шлите heartbeat при успехе и оповещение при пропуске: одна cron-машина — просто ненаблюдаемая точка отказа.
Типичные ошибки
- ✗Запускать cron планировщика на каждом сервере без выбора исполнителя, из-за чего задача идёт на каждой машине
- ✗Полагаться на блокировку как на ровно однократность и не сделать задаче ключ идемпотентности
- ✗Не иметь оповещения о задаче, которая не отработала, — тишина выглядит ровно как успех
Уточняющие вопросы
- →Почему блокировка даёт не более одного исполнителя, но никогда не даёт ровно однократное выполнение?
- →Каким должен быть TTL блокировки относительно худшего времени задачи и что ломается, если он короче?
SeniorДебаггингИногдаМассовое сохранение работает на staging, но в проде молча теряет строки. Определите причину.
Массовое сохранение работает на staging, но в проде молча теряет строки. Определите причину.
PHP перестаёт разбирать тело, как только построил max_input_vars переменных, — по умолчанию их 1000. Каждый items[i][id] и items[i][qty] — отдельная переменная, поэтому 900 строк это 1800, и всё после первых 500 строк отбрасывается ещё до контроллера; в error-лог FPM попадает лишь предупреждение. На staging лимит подняли руками. Шлите JSON, которого лимит не касается, и выкатывайте php.ini вместе с релизом.
Типичные ошибки
- ✗Считать, что обрезанный
$_POSTобязан выбросить исключение, — PHP молча отбрасывает лишний ввод - ✗Считать строки формы, а не входные переменные, из-за чего 900 строк кажутся ниже лимита в 1000
- ✗Оставлять
php.iniрукотворным состоянием сервера, из-за чего staging и прод незаметно расходятся
Уточняющие вопросы
- →Почему тело JSON, прочитанное из
php://input, вообще не попадает подmax_input_vars? - →Что происходит с
$_POST, когда тело большеpost_max_size, и чем это отличается от нашего случая?
SeniorДебаггингИногдаОколо 2% запросов отдают 500, всегда с одного хоста, и локально это не воспроизводится. Определите причину.
Около 2% запросов отдают 500, всегда с одного хоста, и локально это не воспроизводится. Определите причину.
Симлинк на app-03 смотрит на новый релиз, но мастер FPM там ни разу не перезапускали, а opcache.validate_timestamps равен 0, поэтому воркеры до сих пор выполняют скомпилированный Invoice прошлого релиза — класс, в котором нет totalWithTax(). Сброс OPcache после переключения молча не отработал на этом хосте. Сбросьте там OPcache или перезагрузите FPM и заставьте деплой падать, если хост не подтвердил сброс.
Типичные ошибки
- ✗Читать отказ, запертый на одном хосте, как проблему железа, а не как устаревший рантайм на этом хосте
- ✗Верить, что симлинк на новый релиз означает, что выполняется именно новый код
- ✗Позволять деплою рапортовать успех, когда шаг сброса OPcache упал на одной машине парка
Уточняющие вопросы
- →При
opcache.validate_timestampsравном 0 что вообще заставляет PHP перечитать изменившийся файл? - →Где в деплое место сбросу OPcache и как доказать, что он отработал на каждом хосте?