RE:NODE

Веб-хостинг14 мин чтения

Деплой Laravel в продакшен: env, кэши, очереди и cron

Что нужно приложению Laravel на настоящем сервере: .env и ключ приложения, доступный для записи storage, кэши конфигурации и маршрутов, миграции, воркеры очередей и планировщик.

0 прочтений

Деплой Laravel - это шесть действий в строгом порядке: положить код на сервер, установить продакшен-зависимости, создать рядом настоящий .env с ключом приложения, сделать storage и bootstrap/cache доступными для записи, собрать кэши, выполнить миграции. Всё, что ломается потом, - это вариация на тему того, что одно из шести пропустили, сделали не в том порядке или выполнили на машине, у которой другие пути, чем у той, что отдаёт трафик. В этом руководстве - вся последовательность целиком, настройки, от которых зависит, будет сайт быстрым или просто работающим, и ошибки, которые иначе отнимут у вас вечер.

Предполагается сервер на Linux с nginx и PHP-FPM - именно так устроена большая часть shared- и панельного хостинга. Команды artisan одинаковы на любом хосте, в том числе там, где у вас есть только SFTP.

До кода: PHP, расширения и корневая папка сайта#

Для Laravel 10 нужен PHP 8.1 или новее. Для Laravel 11 и 12 - PHP 8.2 или новее. Если вы выбираете версию сейчас, берите самый новый PHP, который поддерживают ваши зависимости: переход с 7.4 на 8.x - самое крупное бесплатное ускорение, доступное PHP-приложению, а OPcache в 8.x лучше держит скомпилированный код в памяти.

Расширения, которые документация Laravel называет обязательными, - это ctype, curl, dom, fileinfo, filter, hash, mbstring, openssl, pcre, pdo, session, tokenizer и xml. Почти любому настоящему приложению также нужны intl для дат и форматирования чисел, zip для работы с архивами и gd или imagick, если оно трогает изображения. Проверьте, что у вас есть, до того как что-либо загружать:

bash
$ php -v$ php -m$ php -i | grep -E "opcache.enable|memory_limit|upload_max_filesize"

Корневая папка сайта (document root) - настройка, на которой спотыкаются все, кто пришёл из мира CMS. Она должна указывать на каталог public/ внутри проекта, а не на корень проекта. Всё, что выше public/ - ваш .env, vendor, storage, app, - должно быть недоступно по HTTP. Если корень выбран неверно, вы увидите один из двух симптомов: список файлов с вашим исходным кодом либо страницу, которая открывается, но на любом маршруте, кроме /, отдаёт 404. Ни то ни другое не скроешь, если знать, куда смотреть.

Если хост жёстко задаёт корневую папку и не даёт её менять, честных вариантов два: положить содержимое public/ в отдаваемый каталог и поправить index.php так, чтобы require указывал на настоящие пути уровнем выше, либо выбрать хост, где корнем управляете вы. Первый способ работает, хотя и некрасив; на нём живёт немало продакшен-сайтов.

Файл .env и ключ приложения#

.env не деплоится вместе с кодом. Его создают один раз на сервере и никогда не коммитят. Минимум для продакшена:

.env
APP_NAME="Example"APP_ENV=productionAPP_KEY=base64:...APP_DEBUG=falseAPP_URL=https://example.comLOG_CHANNEL=stackLOG_LEVEL=warningDB_CONNECTION=pgsqlDB_HOST=db.example.netDB_PORT=5432DB_DATABASE=appDB_USERNAME=appDB_PASSWORD=...SESSION_DRIVER=databaseCACHE_STORE=databaseQUEUE_CONNECTION=database

APP_DEBUG=false - не пожелание, а требование. Когда отладка включена, любое необработанное исключение выводит страницу с путями к вашим файлам, запросом и заметной частью окружения. Выставьте это значение, а затем проверьте, вызвав нарочную ошибку 500 и убедившись, что вы получили обычную страницу ошибки.

APP_KEY - это 32-байтовый ключ, которым шифруется каждый cookie и каждый вызов Crypt::. Сгенерируйте его на сервере один раз:

bash
$ php artisan key:generate --force

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

Laravel поставляется с драйверами баз данных mysql, mariadb, pgsql, sqlite и sqlsrv. Используйте тот, что соответствует вашей настоящей базе, а хост, порт, имя, пользователя и пароль берите оттуда, где их создал ваш хостинг, а не подбирайте наугад. На RE:NODE в тарифы для сайтов входят слоты баз данных, которые создаются из панели, а хост, пользователь и пароль генерируются за вас; есть и кнопка «Open in phpMyAdmin», которая входит по токену, действующему 60 секунд. Отдельная линейка баз данных - это PostgreSQL или MongoDB, доступные на своём хосте и порту.

Драйверы очереди, кэша и сессий стоит выбирать осознанно, а не оставлять по умолчанию. Для одного небольшого сервера database во всех трёх случаях - правильная отправная точка: ничего дополнительного не требуется, данные переживают перезапуск, и их легко просматривать. Сессии file работают, но оставляют тысячи файлов в storage/framework/sessions, а общий кэш перестаёт быть общим в тот момент, когда вы запускаете два процесса. Учтите, что в Laravel 11 ключ кэша переименовали из CACHE_DRIVER в CACHE_STORE; в 10 и более ранних он по-прежнему CACHE_DRIVER, и если указать не тот, вы молча останетесь на значении по умолчанию.

Права доступа, storage и символическая ссылка на public#

Два каталога должны быть доступны для записи пользователю, от имени которого работает PHP-FPM, и только они: storage и bootstrap/cache. Всё остальное может быть только для чтения.

bash
$ chmod -R ug+rwX storage bootstrap/cache$ chown -R www-data:www-data storage bootstrap/cache

Имя пользователя зависит от хоста. В контейнере это нередко вообще не www-data, а на shared-хостинге ваш SFTP-пользователь и пользователь PHP обычно один и тот же, так что этот шаг ничего не меняет. 777 - ответ, который находят на форумах, и он неверный: он делает каждый загруженный файл исполняемым для любого на машине.

Файлы, загруженные приложением, по умолчанию попадают в storage/app/public, а это не внутри корневой папки сайта. Открывает их символическая ссылка:

bash
$ php artisan storage:link

Она создаёт public/storage, указывающий на storage/app/public. Это настоящая символическая ссылка, поэтому она не переживает деплой, при котором свежий каталог public/ заливается поверх по SFTP, а некоторые архиваторы отказываются её создавать. Если после деплоя загруженные картинки отдают 404, хотя файлы на диске есть, причина именно в этом. Запустите команду снова или создайте ссылку вручную.

Если хост вообще не разрешает символические ссылки, задайте FILESYSTEM_DISK=public и в config/filesystems.php измените корень этого диска на каталог внутри public/. Неизящно, но лучше, чем сломанная медиатека.

Composer, ассеты и кэши, которые делают всё быстрым#

Ставьте зависимости так, как положено для продакшена:

bash
$ composer install --no-dev --optimize-autoloader --no-interaction

--no-dev пропускает всё из require-dev. Если сайт падает с «Class not found», называющим отладочный или тестовый пакет, значит, этот пакет используется в продакшен-коде и его место в require, а не в require-dev. --optimize-autoloader строит карту классов, чтобы PHP перестал искать по файловой системе при каждой автозагрузке, а на большом приложении это заметная доля времени отклика.

Фронтенд-ассеты собираются через Vite, результат попадает в public/build. На тарифе с 1 GB npm run build большого проекта вполне способен упереться в лимит памяти, после чего контейнер остановят, поэтому собирать локально или в CI и загружать public/build и быстрее, и безопаснее. При таком подходе Node на сервере не нужен вовсе.

Теперь кэши. Каждый из них превращает кучу чтений и разборов файлов в один подключаемый PHP-массив:

КомандаЧто кэшируетКогда безопасно запускать
config:cacheВсе файлы из config/ в один массивВсегда, в продакшене
route:cacheВсе определения маршрутовЕсли маршруты не используют замыкания
view:cacheШаблоны Blade, скомпилированные заранееВсегда
event:cacheОбнаружение событий и слушателейВсегда
optimizeЗапускает набор выше, зависит от версииВсегда, в продакшене
bash
$ php artisan optimize

В последних версиях php artisan optimize запускает кэши конфигурации, событий, маршрутов и представлений вместе; в старых он делал меньше. Выполните php artisan optimize --help на своей версии, а не полагайтесь на догадки. Обратное действие, когда нужно пересобрать, - php artisan optimize:clear.

Последствие, на котором обжигаются чаще всего: когда конфигурация закэширована, `env()` возвращает null везде, кроме файлов внутри `config/`. Кэшированный массив строится из файлов конфигурации, где env() уже раскрыт. Если ваше приложение вызывает env('STRIPE_KEY') в контроллере, локально это работает, а в продакшене вернёт null. Решение - добавить значение в файл конфигурации и вызывать config('services.stripe.key'). Это одна из двух главных причин фразы «у меня на машине работает».

Кэширование маршрутов громко падает, если какой-то маршрут использует замыкание вместо контроллера. Ошибка называет файл. Перенесите замыкание в контроллер либо пропустите route:cache и смиритесь с расходом.

Миграции, не ломающие сайт#

В продакшене миграции не должны задавать вопросов:

bash
$ php artisan migrate --force

Без --force artisan просит подтверждения, а в неинтерактивном скрипте деплоя либо зависает, либо прерывается. --force означает лишь «не спрашивать»; ничего он не пропускает.

Порядок важен. Деплой, который выкатывает новый код раньше, чем появится столбец, который тому нужен, даёт каждому посетителю 500 на всё время разрыва. Избежать этого можно двумя способами:

  1. Режим обслуживания - для небольших сайтов, где минута простоя приемлема. php artisan down --secret=some-long-string показывает всем страницу 503, но вас пропускает по адресу https://example.com/some-long-string. Выкатите код, выполните миграции, затем php artisan up.
  2. Обратно совместимые миграции - для всех остальных случаев. Добавьте столбец и выкатите код, которого не смущает null, заполните данные, затем выкатите код, который требует значение, а старый столбец удалите в одном из следующих релизов. Это три деплоя вместо одного, зато никто не видит ошибок. Приём подробно разобран в статье миграции без простоя.

Делайте резервную копию базы перед каждой миграцией, которая что-то удаляет или переименовывает. php artisan migrate:rollback работает только тогда, когда у миграции есть рабочий метод down(), а у удивительно многих его нет. На RE:NODE вкладка Backups создаёт копию по запросу или по расписанию и восстанавливает её кнопкой, причём копии хранятся вне той машины, которую защищают, - а помогает только такой вариант, когда проблема в самой машине. Остальное - в статье резервные копии баз данных и восстановление.

Очереди и планировщик#

Всё медленное в веб-запросе - отправка почты, изменение размера картинки, обращение к стороннему API - должно уходить в очередь. Очереди нужен процесс-воркер, живущий дольше запроса:

bash
$ php artisan queue:work --queue=high,default --tries=3 --max-time=3600 --sleep=3

queue:work - долгоживущий PHP-процесс, поэтому он держит ваш код в памяти: после деплоя он продолжает выполнять старый код, пока вы не скажете иначе. php artisan queue:restart подаёт каждому воркеру сигнал закончить текущую задачу и выйти, а ваш менеджер процессов запускает их снова уже с новым кодом. Добавьте эту строку в скрипт деплоя и больше о ней не вспоминайте.

--max-time=3600 заставляет воркер завершаться через час в любом случае, а это самая дешёвая защита от медленной утечки памяти в пакете, который вы не контролируете. --tries=3 не даёт постоянно падающей задаче повторяться бесконечно; упавшие задачи попадают в таблицу failed_jobs, а php artisan queue:retry all возвращает их в очередь. queue:listen используйте только в разработке: он перезагружает фреймворк для каждой задачи, что удобно, но в несколько раз медленнее.

Планировщик - второй процесс. Планировщик Laravel - это одна запись cron, которая запускается каждую минуту и сама решает, что пора выполнять:

bash
* * * * * cd /var/www/example && php artisan schedule:run >> /dev/null 2>&1

В контейнере без crontab то же самое делает php artisan schedule:work - процесс на переднем плане, который вы держите запущенным. Для панельного хостинга это обычно лучше подходит, потому что живым остаётся именно серверный процесс. Не запускайте одну и ту же задачу с двух машин: для этого в Laravel есть withoutOverlapping() и onOneServer(), а второму нужно общее хранилище кэша.

На RE:NODE вкладка Schedules принимает выражение cron и выполняет для сервера упорядоченные задачи - консольную команду, резервную копию, действие с питанием, - так что это подходящее место для ночного backup и еженедельного перезапуска. Если синтаксис вам незнаком, пять полей разобраны в статье выражения cron простыми словами.

HTTPS, прокси и генерация URL#

Почти любое приложение Laravel в продакшене стоит за чем-то, что завершает TLS: обратным прокси, балансировщиком, CDN. Эта машина говорит с посетителем по HTTPS, а с вашим приложением - по обычному HTTP, и отсюда два предсказуемых бага.

Первый - смешанное содержимое. Приложение видит HTTP-запрос, поэтому url(), asset() и route() строят ссылки с http://, и браузер блокирует их на странице HTTPS. Второй - каждый посетитель выглядит пришедшим с адреса прокси, что тихо ломает ограничение частоты запросов, журналирование и всё, что связано с географией.

Обе проблемы решаются тем, что вы доверяете прокси, чтобы Laravel читал X-Forwarded-Proto и X-Forwarded-For. В Laravel 11 и 12 это делается в bootstrap/app.php:

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {    $middleware->trustProxies(at: '*');})

В Laravel 10 и раньше это $proxies = '*'; в app/Http/Middleware/TrustProxies.php. Доверять * правильно, когда к приложению есть только один путь - через прокси, и неправильно, когда порт вашего исходного сервера доступен из интернета, потому что тогда кто угодно может подделать заголовок. Значение APP_URL=https://example.com обеспечивает генерацию URL в консольных командах и задачах очереди, у которых нет запроса, откуда можно прочитать схему.

В тарифы RE:NODE для приложений и сайтов входит слот прокси: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён за вас в окне в 21 день, а настоящий адрес клиента придёт в X-Forwarded-For. Путь запроса объясняет статья что делает обратный прокси, а почему выпуск не проходит, когда DNS не готов, - статья HTTPS и Let's Encrypt простыми словами.

Повторяемый деплой#

Запишите последовательность один раз и выполняйте одно и то же каждый раз. Ручные деплои расходятся друг с другом, и именно это расхождение ломается в полночь.

bash
$ php artisan down --secret="$(cat /var/www/deploy-secret)"$ git pull --ff-only origin main$ composer install --no-dev --optimize-autoloader --no-interaction$ php artisan migrate --force$ php artisan optimize$ php artisan queue:restart$ php artisan up

Там, где нет оболочки, те же шаги выполняются через SFTP и ту консоль, которую даёт хост, в том же порядке. Одно предостережение: собирайте кэши на той машине, которая ими пользуется. bootstrap/cache/packages.php и скомпилированные представления содержат абсолютные пути, поэтому кэши, созданные на вашем ноутбуке и загруженные на сервер, могут указывать на каталоги, которых там нет.

Сохраняйте последний рабочий релиз. Самый быстрый откат - это предыдущий каталог плюс migrate:rollback, который вы действительно проверяли, а второй по скорости - восстановление из копии, снятой пять минут назад. Когда хочется, чтобы сам деплой был незаметен, приём с релизами на символических ссылках описан в статье деплой без простоя на небольшом сервере.

Диагностика#

Пустая страница или голая ошибка 500 без подробностей. Это APP_DEBUG=false делает свою работу. Подробности лежат в storage/logs/laravel.log. Если файл пуст, PHP не смог в него писать: проверьте права, описанные выше, и журнал ошибок PHP-FPM.

«No application encryption key has been specified». APP_KEY отсутствует либо кэш конфигурации был собран до его появления. Выполните php artisan key:generate --force, затем снова php artisan config:cache.

Всё отдаёт 404, кроме главной страницы. Корневая папка сайта - не public/, либо в блоке location у nginx нет строки try_files $uri $uri/ /index.php?$query_string;.

419 Page Expired на каждой форме. Cookie сессии не возвращается. Обычные причины: в SESSION_DOMAIN указан не тот хост, SESSION_SECURE_COOKIE=true при том, что приложение всё ещё считает, что работает по HTTP, либо кэш конфигурации от другого окружения.

Изменение настройки ничего не делает. Кэш конфигурации устарел. Выполните php artisan optimize:clear, затем пересоберите. Это относится и к правке .env: при закэшированной конфигурации .env при запросе вообще не читается.

Загруженные картинки отдают 404, но лежат на диске. Символической ссылки public/storage нет. Запустите php artisan storage:link снова.

После деплоя очередь ничего не обрабатывает. Воркер умер, и никто его не перезапустил, либо он всё ещё держит старый код. Убедитесь, что процесс жив, затем выполните php artisan queue:restart.

Сайт замедляется под нагрузкой, а потом перезапускается. Вы упёрлись в лимит памяти. На RE:NODE контейнер при достижении лимита останавливается и запускается заново чистым, а не уходит в своп; восстанавливается это быстрее, но запросы, которые обрабатывались в этот момент, теряются. Прежде чем винить фреймворк, посчитайте число воркеров PHP-FPM, умноженное на их реальный расход памяти, и прочтите как рассчитать веб-приложение к дню запуска.

FAQ#

Нужен ли на сервере установленный Composer?

Строго говоря, нет. Можно выполнить composer install --no-dev --optimize-autoloader локально или в CI и загрузить каталог vendor, если версия PHP, под которую вы собирали, совпадает с версией на сервере. Запуск на сервере проще и избавляет от этого несовпадения.

Почему env() возвращает null в продакшене?

Потому что конфигурация закэширована. php artisan config:cache один раз раскрывает каждый вызов env() внутри config/ и записывает результат, а env() вне этих файлов потом читает окружение, которое уже не заполнено. Перенесите значение в файл конфигурации и читайте его через config().

Сколько памяти нужно сайту на Laravel?

Небольшому приложению вполне хватает 1 GB, включая воркеры PHP-FPM и базу данных на той же машине. Добавляйте память, когда растёт параллелизм, а не когда растёт функциональность: у каждого воркера PHP-FPM своя копия вашего приложения, так что четыре воркера по 80 MB - это 320 MB ещё до всего остального.

Можно ли запускать планировщик без cron?

Да. php artisan schedule:work работает на переднем плане и запускает подошедшие по времени задачи каждую минуту; это практичный ответ для хостинга, где нельзя править crontab. Его нужно держать запущенным, как любой другой долгоживущий процесс.

Должны ли воркер очереди и веб-сервер делить один тариф?

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

Что делать с .env при смене хоста?

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


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000