📚

supervisorもcronも使わずにLaravelのScheduleとQueueを実行する【Docker】

2024/08/16に公開

はじめに

laravelで動かしているプロジェクトをコンテナ化するときに、supervisorとcronで動かしているスケジューラやキューワーカをどうしようか迷っていました。
一応、1コンテナで複数プロセスを実行する方法としては、Laravel Sailではsupervisorを使用していたり[1]、公式ドキュメントにはsupervisorの使い方が書いていたり[2]するものの、公式ドキュメントのベストプラクティスには以下の記載があります。

Each container should have only one concern. Decoupling applications into multiple containers makes it easier to scale horizontally and reuse containers.
-- 各コンテナはただ1つだけの用途を持つべきです。アプリケーションを複数のコンテナに切り離すことで、水平スケールやコンテナの再利用がより簡単になります。
https://docs.docker.com/build/building/best-practices/#decouple-applications

また、ビルト時にsupervisorやcronをインストールしてイメージサイズが大きくなるのも嫌だったので、タスクスケジューラとキューワーカをアプリケーションとは別のコンテナに分け、それぞれ独立したコンテナで実行させるようにしました。

実行環境

  • Ubuntu 24.04 LTS
  • Docker version 27.1.1
  • Docker Compose version v2.29.1
  • Laravel 11.x

最終的な成果物

とりあえずコピペで動かしたい人向け
ディレクトリ構成
/home/ubuntu/my-app
├ compose.yaml
├ docker
| ├ etc
| | └ start.sh
| └ php
|   └ Dockerfile
└ src ............. Laravelのなかみ
   ├ app
/home/ubuntu/my-app/docker/php/Dockerfile
FROM dunglas/frankenphp

WORKDIR /app

RUN install-php-extensions \
    pcntl \
    intl \
    redis \
    pdo_mysql \
    bcmath \
    zip \
    opcache \
    @composer

COPY ./docker/etc/start.sh /usr/local/bin/start
RUN chmod u+x /usr/local/bin/start

CMD ["/usr/local/bin/start"]
/home/ubuntu/my-app/docker/etc/start.sh
#!/usr/bin/env bash
 
set -e

role=${CONTAINER_ROLE:-app}

if [ "$role" = "app" ]; then

    frankenphp php-server
 
elif [ "$role" = "scheduler" ]; then
 
    while [ true ]
    do
      php artisan schedule:run --verbose --no-interaction &
      sleep 60
    done
 
elif [ "$role" = "queue" ]; then
 
    echo "Running the queue..."
    php artisan queue:work --verbose --tries=3 --timeout=90

else
    echo "Could not match the container role \"$role\""
    exit 1
fi
/home/ubuntu/my-app/compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: app
    depends_on:
      - redis
      - mysql
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - $PWD/src:/app

  mysql:
    image: mysql:8.4
    ports:
       - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: myapp_user
      MYSQL_PASSWORD: secret

  redis:
    image: redis:7.4-alpine
    command: redis-server --appendonly yes --requirepass secret
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  scheduler:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: scheduler
    depends_on:
      - app
    volumes:
      - $PWD/src:/app

  queue:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: queue
    depends_on:
      - app
    volumes:
      - $PWD/src:/app

Laravel+Mysql+Redis

はじめに、プロジェクトのベースとなるコンテナ群を作成します。
Docker環境を作るためのファイル群を用意しておきます。

/home/ubuntu/my-app
touch compose.yml
mkdir -p docker/php
touch docker/php/Dockerfile
mkdir -p docker/etc
touch docker/etc/start.sh
mkdir -p src

compose.yaml

/home/ubuntu/my-app/compose.yml
services:
  app:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: app
    depends_on:
      - redis
      - mysql
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - $PWD/src:/app

  mysql:
    image: mysql:8.4
    ports:
       - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: myapp_user
      MYSQL_PASSWORD: secret

  redis:
    image: redis:7.4-alpine
    command: redis-server --appendonly yes --requirepass secret
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

appコンテナで環境変数にCONTAINER_ROLEをセットしています。
後述のキューワーカーやスケジューラのコンテナはこの値を変更することで、同じイメージを使用して別のコマンドを実行することになります。

また、ホストのsrcディレクトリは未だ空ですが、のちにlaravelプロジェクトのソースが入ります。
ホストのsrcディレクトリとコンテナのappディレクトリをマウントすることで、ソースの変更を反映させます。

Dockerfile

/home/ubuntu/my-app/docker/php/Dockerfile
FROM dunglas/frankenphp

WORKDIR /app

RUN install-php-extensions \
    pcntl \
    intl \
    redis \
    pdo_mysql \
    bcmath \
    zip \
    opcache \
    @composer

COPY ./docker/etc/start.sh /usr/local/bin/start
RUN chmod u+x /usr/local/bin/start

CMD ["/usr/local/bin/start"]

今回はfrankenphpを使用しています。
frankenphpはPHPを実行可能なWebサーバーかつ、PHPを実行可能なCLIを提供しています。よくあるphp-fpm+nginx(orApache)構成がfrankenphp一つでできて、しかも速い[3]です。

また、ホスト上のbashスクリプトをコンテナ上にコピーし、実行権限を追加、最後にコピーしたbashスクリプトをコンテナ上で実行しています。

start.sh

/home/ubuntu/my-app/docker/etc/start.sh
#!/usr/bin/env bash
 
set -e

role=${CONTAINER_ROLE:-app}

if [ "$role" = "app" ]; then

    echo "Running php-server..."
    frankenphp php-server

else
    echo "Could not match the container role \"$role\""
    exit 1
fi

bashスクリプトでは、web+phpサーバーを起動するコマンド(frankenphp php-server)を実行しています。
また、bashでは変数のデフォルト値を設定できるので、環境変数CONTAINER_ROLEが設定されていない場合はapp ロールをデフォルトにします(role=${CONTAINER_ROLE:-app})。この値は前述のcompose.ymlで設定しています。

今回は検証用に1分置きにHello!と出力するscheduleをあらかじめ登録します。

/home/ubuntu/my-app/src/app/Console/Kernel.php
/**
 * Define the application's command schedule.
 *
 * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
 * @return void
 */
protected function schedule(Schedule $schedule)
{
    $schedule->command(fn() {

        echo 'Hello!';

    })->everyMinute();
}

docker compose up --build

この状態で一度ビルドし、新しいlaravelプロジェクトを作ります。

/home/ubuntu/my-app
docker compose up --build
docker compose exec app composer create-project --prefer-dist laravel/laravel .
docker compose exec app php artisan key:generate
docker compose exec app php artisan storage:link
docker compose exec app chmod -R 777 storage bootstrap/cache
docker compose exec app php artisan migrate

https://localhostにアクセスすると、Laravel のウェルカム ページを取得できるはずです。

Laravel scheduler

次に、Laravelのスケジューラを実行するコンテナを作ります。
Laravelの公式ドキュメントでは、cronを使用して1 分ごとにコマンドを実行するcrontabを登録していました。[4]

従来のcronを使用するやり方
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

が、今回はcronを使用しないので、bashを使用してこれと同じ処理を実行させます。

start.sh

/home/ubuntu/my-app/docker/etc/start.sh
#!/usr/bin/env bash
 
set -e

role=${CONTAINER_ROLE:-app}

if [ "$role" = "app" ]; then

    frankenphp php-server

elif [ "$role" = "scheduler" ]; then
 
    while [ true ]
    do
      php artisan schedule:run --verbose --no-interaction &
      sleep 60
    done
 
else
    echo "Could not match the container role \"$role\""
    exit 1
fi

無限ループするwhileの中に、laravelのスケジューラを実行するコマンドを登録します。
また、sleep 60とすることで1分おきの実行を実現しています。

compose.yaml

/home/ubuntu/my-app/compose.yml
services:
  app:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: app
    depends_on:
      - redis
      - mysql
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - $PWD/src:/app

  mysql:
    image: mysql:8.4
    ports:
       - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: myapp_user
      MYSQL_PASSWORD: secret

  redis:
    image: redis:7.4-alpine
    command: redis-server --appendonly yes --requirepass secret
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  scheduler:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: scheduler
    depends_on:
      - app
    volumes:
      - $PWD/src:/app

laravelのスケジューラを実行するコンテナを追加します。
CONTAINER_ROLEにschedulerを指定することで、前述したstart.shのschedule:runを実行するようになります。
また、appコンテナと同じDockerfileを指定することで重複するDockerイメージをビルドしなくて済みます。

docker compose up --build

start.shの更新を反映させるため、もう一度ビルドし、コンテナを起動します。

/home/ubuntu/my-app
docker compose up --build -d

この状態でコンテナのログを起動すると、スケジューラが起動していることがわかります。

/home/ubuntu/my-app
docker compose logs scheduler
schedulerコンテナのログ
my-app-scheduler-1 | No scheduled commands are ready to run.
my-app-scheduler-1 | Running scheduled command: Closure Hello!

Laravel queue worker

最後に、キューワーカコンテナを作ります。

compose.yaml

/home/ubuntu/my-app/compose.yml
services:
  app:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: app
    depends_on:
      - redis
      - mysql
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - $PWD/src:/app

  mysql:
    image: mysql:8.4
    ports:
       - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_DATABASE: myapp
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: myapp_user
      MYSQL_PASSWORD: secret

  redis:
    image: redis:7.4-alpine
    command: redis-server --appendonly yes --requirepass secret
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  scheduler:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: scheduler
    depends_on:
      - app
    volumes:
      - $PWD/src:/app

  queue:
    build:
      context: .
      dockerfile: ./docker/php/Dockerfile
    environment:
      CONTAINER_ROLE: queue
    depends_on:
      - app
    volumes:
      - $PWD/src:/app

queueコンテナを追加し、CONTAINER_ROLEにqueueを設定しています。

start.sh

/home/ubuntu/my-app/docker/etc/start.sh
#!/usr/bin/env bash
 
set -e

role=${CONTAINER_ROLE:-app}

if [ "$role" = "app" ]; then

    frankenphp php-server
 
elif [ "$role" = "scheduler" ]; then
 
    while [ true ]
    do
      php artisan schedule:run --verbose --no-interaction &
      sleep 60
    done
 
elif [ "$role" = "queue" ]; then
 
    echo "Running the queue..."
    php artisan queue:work --verbose --tries=3 --timeout=90

else
    echo "Could not match the container role \"$role\""
    exit 1
fi

CONTAINER_ROLEにqueueであるコンテナの実行コマンドとしてphp artisan queue:work --verbose --tries=3 --timeout=90を指定しています。

docker compose up --build

start.shの更新を反映させるため、もう一度ビルドし、コンテナを起動します。

/home/ubuntu/my-app
docker compose up --build -d

この状態でコンテナのログを起動すると、キューワーカーが起動していることがわかります。

/home/ubuntu/my-app
docker compose logs queue
queueコンテナのログ
my-app-queue-1  | Running the queue...

おまけ

healthcheck

スケジューラとキューワーカコンテナが正常に作動しているかを確認するために、Dockerのhealthcheckを利用して1分ごとにキューの監視を行います。

yaml
services:
  ...
  scheduler:
    ...
    healthcheck:
      test: ["CMD", "php", "artisan", "queue:monitor", "redis:default", "--max=100"]
      interval: 60s
      timeout: 5s

  worker:
    ...
    healthcheck:
      test: ["CMD", "php", "artisan", "queue:monitor", "redis:default", "--max=100"]
      interval: 60s
      timeout: 5s
...

max=100を超えるとLaravelがQueueイベントを発行するので、このイベントが発行されたら公式ドキュメントのようにslackなどに通知するようにしてもいいと思います。[5]

参考

https://laravel-news.com/laravel-scheduler-queue-docker
https://www.aska-ltd.jp/jp/blog/203
https://github.com/ucan-lab/docker-laravel

脚注
  1. https://github.com/laravel/sail/blob/1.x/runtimes/8.3/start-container#L25 ↩︎

  2. https://docs.docker.com/engine/containers/multi-service_container/#use-a-process-manager ↩︎

  3. https://dev.to/dimdev/performance-benchmark-of-php-runtimes-2lmc ↩︎

  4. https://laravel.com/docs/11.x/scheduling#running-the-scheduler ↩︎

  5. https://laravel.com/docs/11.x/queues#monitoring-your-queues ↩︎

Discussion