Это практическое руководство по развертыванию высокопроизводительного стека Laravel с использованием Octane, FrankenPHP и полностью автоматизированного процесса сборки и деплоя на базе Docker Compose.

Laravel — очень производительный фреймворк, но у стандартной архитектуры есть один существенный недостаток, связанный с особенностями работы PHP: при каждом запросе приходится заново поднимать весь фреймворк.

Даже с оптимизациями этот процесс на моей машине с PHP 8.4 занимает от 40 до 60 мс. К счастью, в экосистеме PHP и Laravel уже много лет существует решение, которое радикально сокращает это время загрузки: Laravel Octane и FrankenPHP. Время загрузки (bootstrap/boot) фреймворка может снизиться до 4–6 мс на запрос. Впечатляет, не так ли?

Если вы только начинаете работать с Laravel Octane или FrankenPHP, может возникнуть вопрос: как это вообще возможно?

Краткий ответ — фреймворк остаётся в памяти. После запуска FrankenPHP Laravel постоянно готов обрабатывать запросы без полной повторной инициализации. Полное объяснение сложнее и выходит за рамки этой статьи. Если хотите разобраться детальнее, обратитесь к официальной документации Laravel Octane и FrankenPHP.

Прежде чем продолжить, стоит отметить, что FrankenPHP — не единственный сервер приложений для Laravel Octane. Однако именно его я попробовал в работе и остался настолько доволен функциональностью и производительностью, что не стал тестировать другие варианты, такие как Swoole или RoadRunner.

Проблема развертывания: от ручного запуска к автоматизации

Отлично, но как запустить это у себя на сервере?

Вопрос закономерный. Прежде всего, Laravel Octane предоставляет удобную команду: php artisan octane:start. Поскольку это долгоживущий процесс (то есть он может работать сутками и дольше), просто запустить его вручную и забыть о нём нельзя. Здесь на помощь приходит менеджер процессов, например Supervisor. Вы настраиваете его на запуск процесса, и он выполняет его в фоновом режиме, а также автоматически перезапускает в случае сбоя или перезагрузки системы.

Однако у использования таких решений, как Supervisor, на мой взгляд, есть серьёзный недостаток — «засорение» системы. Поэтому я выбрал другой подход: Docker Compose.

Почему Docker Compose — оптимальный выбор

Docker Compose даёт ряд существенных преимуществ, позволяющих максимально эффективно использовать Laravel Octane и сопутствующие инструменты.

Изоляция

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

Например, образ веб-сервера (я называю его просто app) содержит только всё необходимое для FrankenPHP. В нём даже нет Composer, поскольку каталог vendor копируется из отдельного этапа сборки. В свою очередь, образ worker включает только PHP CLI без FrankenPHP, потому что он там не нужен.

Управление процессами

Разнося каждый необходимый процесс (horizon, pulse, scheduler, redis, db, web) по отдельным контейнерам, мы добиваемся того, что проблема в одном компоненте не влияет напрямую на остальные. Конечно, если «умрёт» база данных, приложение тоже перестанет работать, но если упадёт планировщик, приложение может продолжить работу, возможно с урезанной функциональностью.

Кроме того, если контейнер завершится, Docker (при настроенной политике restart в Compose) автоматически перезапустит его без участия человека.

Простая интеграция с Traefik

Возможно, это и так очевидно по моему блогу, но я люблю Traefik. С ним я могу запускать 30 веб-приложений, состоящих почти из 80 контейнеров, с минимальными конфликтами и взаимным влиянием. Единственная заметная просадка бывает, когда я собираю новый Docker-образ прямо на сервере: это может сильно нагрузить процессор. С Traefik всё просто: достаточно добавить несколько меток (labels) контейнеру в файле docker-compose.yml, и Traefik автоматически начнёт проксировать сервис наружу через 80 и 443 (HTTP/HTTPS).

❗Я никогда не открываю доступ к базе данных из публичного интернета, и вам не рекомендую!

Стек

Теперь, когда я объяснил, что именно делаем и зачем, посмотрим на инфраструктуру, которую я развернул для своего сайта coz.jp.

Стек состоит из трёх основных частей.

1. Многоэтапный Dockerfile

Многоэтапный Dockerfile с несколькими целями сборками позволяет собирать лёгкие, специализированные образы для веб-сервера на FrankenPHP и для CLI-воркера (контейнера командной строки).

Dockerfile

Многоэтапный Dockerfile с поддержкой нескольких targets: frankenphp для веба и worker для процессов, не связанных с вебом.

# Этап 1: Vendor (общий для всех)
FROM composer:latest AS vendor
WORKDIR /app


# Устанавливаем расширения PHP (нужны для Composer)
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions gd bcmath intl pcntl redis pdo_mysql


ARG SPARK_USERNAME
ARG SPARK_API_TOKEN
ENV SPARK_USERNAME=${SPARK_USERNAME}
ENV SPARK_API_TOKEN=${SPARK_API_TOKEN}


COPY composer.json composer.lock ./
#RUN composer config http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"
#RUN composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress


# Временная авторизация Spark для приватных пакетов
RUN set -eux; \
    composer config --global http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"; \
    composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress; \
    composer config --global --unset http-basic.spark.laravel.com; \
    rm -f /root/.composer/auth.json || true; \
    rm -f /app/.composer/auth.json || true; \
    rm -f /tmp/* /var/tmp/* || true


# Этап 2: Assets (сборка фронтенда на Node 22 + Yarn)
FROM node:22-alpine AS assets
WORKDIR /app


# Копируем манифесты зависимостей и устанавливаем зависимости через Yarn (через Corepack)
COPY package.json yarn.lock ./
RUN corepack enable \
    && corepack prepare yarn@1.22.22 --activate \
    && yarn install --frozen-lockfile


# Копируем только то, что нужно для сборки ассетов
COPY vite.config.js ./
COPY tailwind.config.js ./
COPY postcss.config.js ./
COPY resources ./resources
COPY public ./public


ENV NODE_ENV=production
RUN yarn build


# Этап 3: Worker-образ (CLI)
FROM php:8.4-cli-alpine AS worker
COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis


ARG APP_ENV=production
WORKDIR /app
COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor


RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;


USER www-data
CMD ["php", "artisan", "queue:work", "--tries=3", "--sleep=1"]


# Этап 4: FrankenPHP-образ (Web)
FROM dunglas/frankenphp:latest AS frankenphp
WORKDIR /app


ARG APP_ENV=production
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis


COPY . /app
COPY ".env.${APP_ENV:-production}" .env
COPY --from=vendor /app/vendor /app/vendor


# Копируем собранные фронтенд-ассеты без установки Node/Yarn на этом этапе
COPY --from=assets /app/public/build /app/public/build


COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/install-php-extensions
COPY --from=vendor /usr/bin/composer /usr/bin/composer


RUN mkdir -p storage bootstrap/cache;
RUN chown -R www-data:www-data storage bootstrap/cache;
RUN chmod -R 775 storage bootstrap/cache;


#EXPOSE 80
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80"]

Это мой Dockerfile. Возможно, не всё в нём будет очевидно с первого взгляда, и наверняка есть пространство для улучшений, так что смело предлагайте свои идеи.

Как видите, я использую отдельный этап vendor, чтобы скачать все Composer-пакеты, включая приватные. Затем я явно удаляю учётные данные для аутентификации из образа, чтобы снизить риски безопасности. Два остальных PHP-этапа просто копируют каталог vendor, без необходимости выполнять composer install в каждом финальном образе.

Похожим образом устроен этап assets для фронтенда. Я устанавливаю всё через Node 22 и Yarn, собираю продакшен-ассеты, а затем копирую в финальный веб-образ только скомпилированные файлы. Это позволяет не тащить огромный каталог node_modules и сам Node в production-контейнер, что, по-моему, большой плюс.

Файл compose.yml

Этот файл координирует запуск сервисов, связывает их между собой и настраивает для продакшена с использованием Traefik.

# Настройка Docker Compose для локальной среды и продакшена (Traefik) с FrankenPHP
# - Собираем один образ и переиспользуем его для сервисов web, worker и scheduler
# - Для продакшена за Traefik корректно задайте labels и SERVER_NAME


x-env: &default-env
  env_file:
    - .env


x-volumes: &laravel-volumes
  volumes:
    - ./.storage/logs/:/app/storage/logs
    - ./.storage/app/:/app/storage/app
    - ./.storage/framework/:/app/storage/framework
# вспомогательная «карта» для объединения env и volumes в одном << для сервиса
x-common: &common
  <<: [*default-env, *laravel-volumes]


name: coz_jp_${APP_ENV}


services:
  app:
    container_name: coz_jp_web_${APP_ENV}
    image: coz_jp:frankenphp
    pull_policy: never
    build:
      context: ${CONTEXT_LOCATION:-.}
      dockerfile: docker/Dockerfile
      target: frankenphp
      args:
        APP_ENV: ${APP_ENV:-production}
        SPARK_USERNAME: ${SPARK_USERNAME}
        SPARK_API_TOKEN: ${SPARK_API_TOKEN}
    <<: *laravel-volumes
    #
    labels:
        - traefik.enable=true
        - traefik.http.routers.coz_jp_${APP_ENV}-https.rule=Host(`${APP_DOMAIN}`)
        - traefik.http.routers.coz_jp_${APP_ENV}-https.tls=true
        - traefik.http.services.coz_jp_${APP_ENV}-https.loadbalancer.server.port=80
        - traefik.http.routers.coz_jp_${APP_ENV}-https.tls.certresolver=cloudflare
        - traefik.http.routers.coz_jp_${APP_ENV}-https.entrypoints=websecure
        #- "traefik.http.routers.coz_jp_${APP_ENV}.middlewares=forward-auth"
        #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Proto=https"
        #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Host=${APP_URL}"
    environment:
      SERVER_NAME: ${APP_DOMAIN:-:80}
      SERVER_ROOT: /app/public
    depends_on:
      - redis
      - db
    networks:
      - internal
      - traefik # раскомментируйте в продакшене
    restart: unless-stopped
    command: ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80", "--workers=8", "--log-level=info"]
    healthcheck:
      test: [
        "CMD-SHELL",
        "curl -fsS http://127.0.0.1:80/up || { rc=$$?; echo \"[healthcheck] GET /up failed with code $$rc\" >&2; exit 1; }"
      ]
      interval: 15s
      timeout: 5s
      retries: 20
      start_period: 10s


  worker:
    container_name: coz_jp_worker_${APP_ENV}
    image: coz_jp:worker
    pull_policy: never
    build:
        context: ${CONTEXT_LOCATION:-.}
        dockerfile: docker/Dockerfile
        target: worker
        args:
          APP_ENV: ${APP_ENV:-production}
          SPARK_USERNAME: ${SPARK_USERNAME}
          SPARK_API_TOKEN: ${SPARK_API_TOKEN}
    <<: *common
    command: ["php", "artisan", "horizon"]
#     command: ["php", "artisan", "queue:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 2s
      retries: 10


  scheduler:
    container_name: coz_jp_scheduler_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "schedule:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 2s
      retries: 10


  pulse_check:
    container_name: coz_jp_pulse_check_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "pulse:check"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 2s
      retries: 10


  pulse_work:
    container_name: coz_jp_pulse_work_${APP_ENV}
    image: coz_jp:worker
    <<: *laravel-volumes
    command: ["php", "artisan", "pulse:work"]
    depends_on:
      - redis
      - db
    networks:
      - internal
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 2s
      retries: 10


  db:
    image: mysql:8.2
    container_name: coz_jp_db_${APP_ENV}
    <<: *default-env
    environment:
      MYSQL_DATABASE: ${DB_DATABASE:-laravel}
      MYSQL_USER: ${DB_USERNAME:-laravel}
      MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
    ports:
      - "${DB_EXPOSE_PORT:-13306}:3306" # измените или уберите в продакшене
    volumes:
      - ./.mysql-db/:/var/lib/mysql
    networks:
      - internal
    restart: always
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
      start_period: 30s


  redis:
    image: redis:alpine
    container_name: coz_jp_redis_${APP_ENV}
    volumes:
      - .redis/redis:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
    restart: unless-stopped


  # Опционально: бэкапы базы данных через tiredofit/db-backup
  # db_backup:
  #   image: tiredofit/db-backup
  #   container_name: db_backup_coz_jp
  #   depends_on:
  #     - db
  #   volumes:
  #     - ./backups/:/backup
  #   <<: *default-env
  #   networks:
  #     - internal
  #   restart: always


networks:
  internal:
  traefik:
     external: true
     name: traefik

Это мой текущий файл compose.yml. Смело предлагайте улучшения.

В compose.yml видно, что я вынес каждую логическую часть приложения (web, worker, scheduler, pulse) в отдельный сервис. Это соответствует причинам, которые я описал выше.

3. Скрипт деплоя run.sh

Наконец, чтобы запускать и разворачивать всё это, я сделал простой .sh-скрипт, который автоматизирует все шаги. Теперь мне достаточно выполнить bash run.sh, и код автоматически подтягивается, собирается и деплоится.

#!/bin/bash
# Определяем каталоги
GITFOLDER="../coz_jp"
LOCALFOLDER=$(pwd)


# Загружаем переменные окружения из .env
source "$LOCALFOLDER/.env"


echo "***Подтягиваем изменения из репозитория.";


# Переходим в каталог с git-репозиторием, выходим при ошибке
cd "$GITFOLDER" || exit 1


# Подтягиваем последние изменения
git pull


# Возвращаемся в локальный каталог
cd "$LOCALFOLDER" || exit 1


echo "***Копируем файлы";
# Копируем compose.yml из репозитория, перезаписывая локальный
cp -f "${GITFOLDER}/compose.yml" "$LOCALFOLDER/compose.yml"
cp -f "${LOCALFOLDER}/.env" "${GITFOLDER}/.env.${APP_ENV}"


# Убеждаемся, что владельцем папок является пользователь 82
sudo chown -R 82:82 .storage
sudo chown -R 999:999 .redis .mysql-db


echo "***Собираем docker";
# Собираем образы и пересоздаём контейнеры
docker compose build && \
  docker compose up -d --force-recreate && \
  # Меняем владельца /app внутри контейнера от root
  docker compose exec -u root coz_jp_web_${APP_ENV} chown -R www-data: /app && \
  # Выполняем команду оптимизации Laravel внутри контейнера
  docker compose exec coz_jp_web_${APP_ENV} php artisan optimize


# Звуковой сигнал в терминале
echo -en "\007"


# Отправка уведомления о деплое
#curl -d "Coz.jp Deployed ${APP_ENV}" https://ntfy update url

Структура папок

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

Такая структура означает, что я могу просто перейти (cd) в каталог нужного окружения и запустить скрипт, чтобы без лишних действий развернуть весь проект.

  - корень проекта
  - - папка с исходниками из Git (в моём случае — coz.jp)
  - - каталог окружения stage
  - - - .env
  - - - run.sh
  - - - .mysql-db
  - - - .redis
  - - - .storage
  - - каталог окружения prod
  - - - .env
  - - - run.sh
  - - - .mysql-db
  - - - .redis
  - - - .storage

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

Если Laravel у вас уже в проде (или скоро будет), стоит системно разобрать безопасность, тестирование, деплой, диагностику и встроенные инструменты — то, что обычно копится кусками. На курсе Framework Laravel эти темы проходят на практике, чтобы уверенно поддерживать коммерческие проекты и расти до middle+/senior+. Готовы к обучению? Пройдите вступительный тест.

Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:

  • 25 февраля, 20:00. «Разработка REST API на Laravel». Записаться

  • 10 марта, 20:00. «Мультитенантная архитектура в Laravel: как масштабировать проекты под десятки и сотни клиентов». Записаться

  • 19 марта, 20:00. «Создаем современный монолит на Inertia: Laravel + Vue.JS». Записаться