Kamal2でLaravelとRedis、ジョブワーカーをデプロイするまで

設定に自信がないためScrap。削除予定:なし
目標
VPSに、自力でLaravel、Redisのジョブワーカー、cronスケジューラを立ち上げる。1台当たり月額1000円までを目安にする。
VPSの利点として、共用レンタルサーバーでは常駐が難しいLaravel Horizonを使える点がある。最近のLaravelはPaaSでエコシステムを持続させる方向性なので、「設定が難しい?Cloudあるよ」みたいなドキュメントになりつつあるが、LaravelのPaaS(ForgeやCloud)は避ける。
下記の記事を参考にする。

dotenvのgemを使うので、Kamalは直接インストールする。
gem install bundler
bundler install dotenv kamal
bundler exec kamal init
基本的にdotenvでKamalに環境変数を渡す。後述の記事でマクロにする。
bundle exec dotenv -f .env.kamal.<環境> kamal <コマンド> -d <環境>

Dockerfile
serversideup/phpとFrankenPHPから迷ったが、serversideup/phpのalpine (nginx+fpm) ver.を使用することにした。
Tony Messias氏のDockerfileから以下を変更
- NODE_VERSION指定追加
- node, npmはalpineのnodeイメージから引っ張ってくる
serversideup/phpの特筆すべき点として、Laravelのコマンドを勝手に実行してくれるスクリプトがついている。
# プロダクション用のDockerfile (サイズ目安:350MB)
# Original Author: Tony Messias
# https://world.hey.com/tonysm/deploying-laravel-apps-with-kamal-2-0-6143d288
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS node
# ====================================================================
FROM ghcr.io/serversideup/php:8.4-fpm-nginx-alpine AS base
# Laravelのキャッシュ系コマンドを自動で発火する
# https://serversideup.net/open-source/docker-php/docs/reference/environment-variable-specification
ENV AUTORUN_ENABLED=true
ENV PHP_OPCACHE_ENABLE=1
USER root
RUN apk add --update busybox-suid && \
install-php-extensions bcmath gd exif
COPY /usr/bin/composer /usr/bin/composer
USER www-data
# ====================================================================
FROM base AS dependencies
WORKDIR /var/www/html/
COPY composer.json composer.lock /var/www/html/
# ディレクトリを作ってautoloadの警告を抑制しつつインストール
RUN mkdir -p app && \
mkdir -p database/{factories,seeders} && \
composer install --no-interaction --prefer-dist --no-scripts
USER root
COPY ./package.json ./vite.config.js ./package-lock.json /var/www/html/
COPY ./public/ /var/www/html/public
COPY ./resources/ /var/www/html/resources
COPY /usr/lib /usr/lib
COPY /usr/local/lib /usr/local/lib
COPY /usr/local/include /usr/local/include
COPY /usr/local/bin /usr/local/bin
RUN npm ci && npm run build
# ====================================================================
FROM base
# ソースコード
COPY . /var/www/html
# vendor
COPY /var/www/html/vendor /var/www/html/vendor
# ビルドアーティファクト (config/deploy.ymlのasset_pathに指定する)
COPY /var/www/html/public/build /var/www/html/public/build
# スクリプトとoptimize-autoloaderつきで改めてインストール
RUN composer install --no-interaction --prefer-dist --optimize-autoloader
USER root
RUN rm -rf /usr/bin/composer
USER www-data

ローカル
redis, mailpit付きのsail環境を立ち上げる
kamal-laravel-example
とする。
curl -s "https://laravel.build/kamal-laravel-example?with=mailpit,redis&php=84" | bash
ローカル用Dokerfile
touch Dockerfile.local dockerfile-local.start.sh
chmod +x dockerfile-local.start.sh
以下、プロダクション用Dockerfileにsailを使うためだけのユーザーをつけてしまっている
FROM ghcr.io/serversideup/php:8.4-cli-alpine
# Laravelのキャッシュ系コマンドを自動で発火する
# https://serversideup.net/open-source/docker-php/docs/reference/environment-variable-specification
ENV AUTORUN_ENABLED=true
# マイグレーションはしない
ENV AUTORUN_LARAVEL_MIGRATION=false
# ローカルでOPcache使うなよ!
ENV PHP_OPCACHE_ENABLE=0
ARG WWWUSER=1000
ARG WWWGROUP=1000
USER root
# serversideup/phpでsailユーザーを使えるようにする
# https://github.com/serversideup/docker-php/discussions/262
RUN usermod -ou $WWWUSER www-data \
&& groupmod -og $WWWGROUP www-data \
&& useradd -mNo -g www-data -u $(id -u www-data) sail
# あとはtonysm氏のイメージを参考に構築、ただしローカルなのでステージは分けない
# https://world.hey.com/tonysm/deploying-laravel-apps-with-kamal-2-0-6143d288
RUN apk add --update busybox-suid && \
install-php-extensions bcmath gd exif \
# ローカルなので追加
pcov xdebug
RUN echo "xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "pcov.enabled=1" >> /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini
EXPOSE 9003
WORKDIR /var/www/html/
# これはserversideup/phpに最初からあるステージ
COPY /usr/bin/composer /usr/bin/composer
COPY . .
RUN composer install --no-interaction --prefer-dist
# 最後にwww-dataに戻すことを忘れずに
USER www-data
COPY ./dockerfile-local.start.sh /usr/local/bin/start
RUN chmod 0755 /usr/local/bin/start
CMD ["/usr/local/bin/start"]
ran氏のスクリプトを使用し、cron、horizonの実行にも対応。cronはぴったり0秒になるようsleepを調整。
#!/bin/sh
set -e
# Kamalのcronの挙動をスクリプトで再現する
# Original Author: ran
# https://zenn.dev/ransakata/articles/972271ebd76fe3
role=${CONTAINER_ROLE:-web}
if [ "$role" = "web" ]; then
php artisan serve --host=0.0.0.0 --port=8080
elif [ "$role" = "horizon" ]; then
php artisan horizon
elif [ "$role" = "cron" ]; then
while true; do
php artisan schedule:run --verbose --no-interaction
# 2回目以降の実行が「0秒」になるよう計算
now=$(date +%s)
past=$(( now % 60 ))
sleep $(( 60 - past ))
done
else
exit 1
fi
docker-compose
sailのデフォルトから以下を変更
- Dockerfile.localに変更
- cron, horizonに
CONTAINER_ROLE
を与えて挙動を分岐 -
DBのデプロイをKamalでする自信がないため、削除
- accessoriesとしてデプロイすれば使えるが、デプロイ後の接続が面倒
- よって、ローカルでsupabase CLIを使ってDBを立ち上げることにした。
- supabaseのセットアップ云々は割愛。
- KVストアにはRedisとクライアント互換性のあるValkeyを使用。
- パスワードを指定する必要性は薄いが、デプロイ時にも同じ方法で設定しているので、動作確認のため設定している
services:
laravel.test: # sail CLIの仕様でこれを変えてはいけない
container_name: app
build:
context: '.'
# serversideup/phpを使用するよう変更
dockerfile: Dockerfile.local
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:8080'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
LARAVEL_SAIL: 1
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- redis
- mailpit
# メモリ節約
# cron:
# build:
# context: '.'
# dockerfile: Dockerfile.local
# environment:
# CONTAINER_ROLE: cron
# extra_hosts:
# - 'host.docker.internal:host-gateway'
# volumes:
# - '.:/var/www/html'
# networks:
# - sail
# depends_on:
# - laravel.test
# healthcheck:
# test: ["CMD", "healthcheck-schedule"]
horizon:
build:
context: '.'
dockerfile: Dockerfile.local
environment:
CONTAINER_ROLE: horizon
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- laravel.test
healthcheck:
test: ["CMD", "healthcheck-horizon"]
start_period: 10s
# DBコンテナはsupabaseとかで代替してください
redis:
# ホスト名はredisのままvalkeyを使用
image: 'valkey/valkey:8'
restart: always
environment:
- REDIS_PASSWORD: ${REDIS_PASSWORD}
command: /bin/sh -c "valkey-server --requirepass $$REDIS_PASSWORD"
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
retries: 3
timeout: 5s
mailpit:
image: 'axllent/mailpit:latest'
ports:
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
networks:
- sail
networks:
sail:
driver: bridge
volumes:
sail-redis:
driver: local

deploy.ymlの設定がだるすぎる
secretが分かりづらく、めちゃくちゃ時間を浪費した。
下記の記事のように設定する。
# Kamal2でLaravelをデプロイするための設定
# https://world.hey.com/tonysm/deploying-laravel-apps-with-kamal-2-0-6143d288
service: kamal-laravel-example
require_destination: true
<% require 'dotenv'; Dotenv.load(".kamal/secrets-common") %>
<% require 'dotenv'; Dotenv.load(".kamal/secrets.#{ENV['KAMAL_DESTINATION']}") %>
image: <%= ENV['KAMAL_REGISTRY_USERNAME'] %>/app
servers:
web:
hosts:
- <%= ENV['KAMAL_SERVER_IP_ADDRESS'] %>
env:
# 開始時に `php artisan migrate --force --isolated` を実行
AUTORUN_LARAVEL_MIGRATION: "true"
AUTORUN_LARAVEL_MIGRATION_ISOLATION: "true"
CONTAINER_ROLE: "web"
cron:
hosts:
- <%= ENV['KAMAL_SERVER_IP_ADDRESS'] %>
cmd: php artisan schedule:work
options:
health-cmd: healthcheck-schedule
env:
AUTORUN_LARAVEL_MIGRATION: "false"
CONTAINER_ROLE: "cron"
horizon:
hosts:
- <%= ENV['KAMAL_SERVER_IP_ADDRESS'] %>
cmd: php artisan horizon
options:
health-cmd: healthcheck-horizon
env:
AUTORUN_LARAVEL_MIGRATION: "false"
CONTAINER_ROLE: "horizon"
proxy:
ssl: true
host: '環境ごとに上書き'
app_port: 8080
registry:
server: <%= ENV['KAMAL_REGISTRY_SERVER'] %>
username: <%= ENV['KAMAL_REGISTRY_USERNAME'] %>
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
cache:
type: gha
env:
clear:
APP_ENV: '環境ごとに上書き'
APP_DEBUG: false
APP_URL: '環境ごとに上書き'
PHP_CLI_SERVER_WORKERS: 4
BCRYPT_ROUNDS: 12
LOG_LEVEL: '環境ごとに上書き'
REDIS_HOST: kamal-laravel-example-redis
VITE_APP_NAME: "kamal-laravel-example"
secret:
- APP_KEY
- DB_URL
- REDIS_PASSWORD
aliases:
shell: app exec --interactive --reuse "sh"
tinker: app exec --interactive --reuse "php artisan tinker"
asset_path: /var/www/html/public/build
accessories:
redis: # kamalの仕様で、ホスト名は「<サービス名>-redis」になる
image: redis/redis-stack-server:7.4.0-v6
host: <%= ENV['KAMAL_SERVER_IP_ADDRESS'] %>
port: 6379
env:
secret:
- REDIS_ARGS
directories:
- data:/data
# production環境の上書き部分のみ記述
proxy:
host: <本番ドメイン>
env:
clear:
APP_ENV: production
APP_URL: <本番URL>
LOG_LEVEL: error
# staging環境の上書き部分のみ記述
proxy:
host: <ステージングドメイン>
env:
clear:
APP_ENV: staging
APP_URL: <ステージングURL>
LOG_LEVEL: warning

25/8/11追記
Redisのパスワードをシークレットとして渡したいが、redis/redis-stack-server
にはREDIS_ARGSとして渡す必要がある。そのため、secrets-commonファイルではあらかじめ展開してある。
REDIS_PASSWORD=$REDIS_PASSWORD
REDIS_ARGS="--requirepass $REDIS_PASSWORD"

デプロイ
Makefile
define KAMAL_CMD
@echo "→ running kamal on '$(1)' : $(2)"
bundle exec dotenv -f .env.kamal.$(1) kamal $(2) -d $(1)
endef
# 初回デプロイ
kamal-%-first-deploy:
$(call KAMAL_CMD,$*,setup)
# 2回目以降のデプロイ
kamal-%-redeploy:
$(call KAMAL_CMD,$*,redeploy --verbose)
SSHでrootが必要な部分があり、仕方なくkamalにrootを使わせている... VPSは完全にkamal専用になるな。
これでweb, cron, workerが起動する。
serversideup/phpの力でマイグレーションが自動で走る。
なんか環境変数が増える
// 追加 以下はデプロイ時に明示していないが、自動で設定される
'version' => env('KAMAL_VERSION'),
'container_name' => env('KAMAL_CONTAINER_NAME'),

Redisを使ったジョブワーカーの動作確認
laravel horizonをインストールして、適当なジョブを追加する。
また、キューのコネクションをRedisに変更する。
環境変数は面倒なので、デフォルト値を変ええればいい。ローカルにもRedisあるし。
sail artisan make:job ジョブ
sail artisan make:command ジョブディスパッチするコマンド
デプロイしたら、2窓でこういう風に監視すれば動作確認できる。
bundle exec dotenv -f .env.kamal.staging \
kamal app exec -d staging -r web \
--interactive --reuse "php artisan app:ジョブディスパッチするコマンド"
bundle exec dotenv -f .env.kamal.staging \
kamal app exec -d staging -r horizon \
--interactive --reuse "php artisan pail -vv"
ポストデプロイフック
例えばデプロイ通知とか?
touch .kamal/hooks/post-deploy
chmod +x .kamal/hooks/post-deploy
マイグレーションはDockerfileでやってしまっている。
#!/bin/bash
kamal app exec -d $KAMAL_DESTINATION -r web --reuse "php artisan app:send-deploy-notification"
exit 0

モニタリング
さくらのVPSで2core、RAM1GB。cron/horizonのデプロイ後に激重になった。
Kamal proxy含むコンテナの動作に対し、全然足りていない