Laravel本番デプロイの実践ガイド
デプロイは製品を世に送り出す最後のステップです—1つのミスが全ユーザーに500エラーページを見せることになります。
1. 学ぶこと
- Dockerコンテナ化:マルチステージビルドとDocker Composeオーケストレーション
- Nginx + PHP-FPM設定:リバースプロキシとSSL証明書
- CI/CDパイプライン:GitHub Actionsによる自動テストとデプロイ
- デプロイチェックリスト:.env / config:cache / migrate / queue:restart
- ゼロダウンタイムデプロイ戦略:Blue-Green / ローリングアップデート
2. リリース夜の実話
(1) 痛み:手動デプロイで常に問題が発生する
Bobが毎回ShopMetricsをデプロイする際に行うこと:サーバーにSSH → git pull → composer install → php artisan migrate → php artisan config:cache → キューワーカーの再起動 → PHP-FPMの再起動。5台のサーバーで, この手動プロセスに40分かかります。先月, マイグレーション実行を1回忘れ, サイト全体が500エラーになり2,000ドルの損失が出ました。Charlieは言いました。「あなたは2024年のSaaS製品を1990年代の方法でデプロイしている。」
(2) Docker + CI/CDによる解決策
Dockerコンテナ化によりすべての環境が完全に一致することが保証され, CI/CDパイプラインによりデプロイが「git push」1つに削減されます—テスト, ビルド, デプロイがすべて完全に自動化されます。
git push → GitHub Actions → テスト → Dockerビルド → デプロイ → ヘルスチェック → 完了
(3) 成果
BobがCI/CDを実装した後, デプロイ時間は40分から3分に短縮され, エラー率は10%から0%に低下し, 深夜にサーバーにSSHする必要もなくなりました。
3. Dockerコンテナ化
(1) マルチステージDockerfile
# Dockerfile
# ステージ1:依存関係のビルド
FROM composer:2.7 AS build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress
COPY . .
RUN php artisan route:cache && \
php artisan config:cache && \
php artisan view:cache
# ステージ2:本番イメージ
FROM php:8.3-fpm-alpine AS production
WORKDIR /var/www/html
RUN apk add --no-cache \
nginx \
supervisor \
pdo_mysql \
gd \
zip \
redis
RUN docker-php-ext-install pdo_mysql gd zip opcache redis
COPY --from=build /app /var/www/html
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 8080
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
(2) Docker Composeオーケストレーション
# docker-compose.prod.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8080:8080"
env_file:
- .env.production
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- storage:/var/www/html/storage
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
queue:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php artisan queue:work --queue=high,default --sleep=3 --tries=3
env_file:
- .env.production
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- storage:/var/www/html/storage
scheduler:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php artisan schedule:work
env_file:
- .env.production
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:
redis_data:
storage:
(1) ▶ サンプル:ShopMetrics Docker開発環境
# docker-compose.yml (開発用)
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8000:8000"
volumes:
- .:/var/www/html
environment:
DB_HOST: mysql
REDIS_HOST: redis
depends_on:
- mysql
- redis
- mailpit
mysql:
image: mysql:8.0
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: shopmetrics
MYSQL_USER: shopmetrics
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: secret
volumes:
- mysql_dev:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
mailpit:
image: axllent/mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
volumes:
mysql_dev:
出力:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
4. Nginx + PHP-FPM設定
(1) Nginx設定
# docker/nginx.conf
server {
listen 8080;
server_name shopmetrics.io api.shopmetrics.io;
root /var/www/html/public;
index index.php;
charset utf-8;
# セキュリティヘッダー
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# 最大アップロードサイズ
client_max_body_size 20M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
# 大きなペイロード用のバッファ
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
# タイムアウト
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
# 静的コンテンツへのアクセスを拒否
location ~ /\.(?!well-known).* {
deny all;
}
}
(2) SSL設定 (Let's Encrypt)
# リバースプロキシまたはロードバランサーでのSSLターミネーション
server {
listen 443 ssl http2;
server_name shopmetrics.io;
ssl_certificate /etc/letsencrypt/live/shopmetrics.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shopmetrics.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTPからHTTPSへリダイレクト
server {
listen 80;
server_name shopmetrics.io;
return 301 https://$server_name$request_uri;
}
(1) ▶ サンプル:ShopMetrics Nginxマルチテナントサブドメインルーティング
# テナントサブドメインのルーティング
server {
listen 443 ssl http2;
server_name ~^(?<tenant>[^\.]+)\.shopmetrics\.io$;
ssl_certificate /etc/letsencrypt/live/shopmetrics.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shopmetrics.io/privkey.pem;
# テナントslugをLaravelに渡す
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Tenant $tenant;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
出力:
// 実行成功
5. CI/CDパイプライン
(1) GitHub Actionsワークフロー
# .github/workflows/deploy.yml
name: Deploy ShopMetrics
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: shopmetrics_test
MYSQL_USER: test
MYSQL_PASSWORD: test
MYSQL_ROOT_PASSWORD: test
ports: ['3306:3306']
redis:
image: redis:7-alpine
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: xdebug
- name: Install dependencies
run: composer install --no-progress --prefer-dist
- name: Copy .env
run: cp .env.example .env && php artisan key:generate
- name: Run tests
run: php artisan test --parallel --coverage-text
- name: Check code style
run: vendor/bin/pint --test
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/shopmetrics
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec app php artisan config:cache
docker compose -f docker-compose.prod.yml exec app php artisan queue:restart
sleep 5
curl -sf http://localhost:8080/health || exit 1
echo "Deploy successful!"
(2) デプロイフローチャート
flowchart LR
A[git push main] --> B[GitHub Actions]
B --> C[テスト実行]
C -->|合格| D[Dockerイメージビルド]
C -->|失敗| E[通知 + ブロック]
D --> F[レジストリにプッシュ]
F --> G[本番にSSH]
G --> H[docker compose pull]
H --> I[docker compose up -d]
I --> J[php artisan migrate]
J --> K[キャッシュ + キュー再起動]
K --> L[ヘルスチェック]
L -->|OK| M[デプロイ完了]
L -->|失敗| N[自動ロールバック]
(1) ▶ サンプル:ShopMetricsデプロイヘルスチェックエンドポイント
// routes/web.php
Route::get('/health', function () {
$checks = [
'database' => fn () => DB::connection()->getPdo() ? 'ok' : 'fail',
'redis' => fn () => Cache::put('health_check', 'ok', 10) ? 'ok' : 'fail',
'storage' => fn () => Storage::put('health_check', 'ok') ? 'ok' : 'fail',
];
$results = collect($checks)->map(fn ($check) => $check());
if ($results->contains('fail')) {
return response()->json([
'status' => 'unhealthy',
'checks' => $results,
], 503);
}
return response()->json([
'status' => 'healthy',
'checks' => $results,
'timestamp' => now()->toIso8601String(),
'version' => config('app.version', 'unknown'),
]);
});
出力:
// 実行成功
6. デプロイチェックリスト
(1) リリース前チェックリスト
| ステップ | コマンド/アクション | 説明 |
|---|---|---|
| 1 | .env設定確認 |
APP_ENV=production, APP_DEBUG=false |
| 2 | php artisan key:generate |
APP_KEYが設定されていることを確認 |
| 3 | php artisan migrate --force |
データベースマイグレーションの実行 |
| 4 | php artisan config:cache |
設定のキャッシュ |
| 5 | php artisan route:cache |
ルートのキャッシュ |
| 6 | php artisan view:cache |
ビューのキャッシュ |
| 7 | php artisan storage:link |
ストレージリンクの作成 |
| 8 | php artisan queue:restart |
ワーカーキューの再起動 |
| 9 | パーミッション確認 | storage/ + bootstrap/cache/が書き込み可能 |
| 10 | ヘルスチェック | /healthが200を返す |
(1) ▶ サンプル:ShopMetrics自動デプロイスクリプト
#!/bin/bash
# deploy.sh — ゼロダウンタイムデプロイスクリプト
set -e
APP_DIR="/var/www/shopmetrics"
RELEASES_DIR="/var/www/releases"
CURRENT_LINK="/var/www/current"
NEW_RELEASE=$(date +%Y%m%d%H%M%S)
echo "=== Deploying ShopMetrics ==="
# 1. リリースディレクトリの作成
mkdir -p "$RELEASES_DIR/$NEW_RELEASE"
cd "$RELEASES_DIR/$NEW_RELEASE"
# 2. コードのクローン/プル
git clone --depth 1 --branch main git@github.com:bob/shopmetrics.git .
echo "→ Code pulled"
# 3. 依存関係のインストール
composer install --no-dev --optimize-autoloader --no-progress
echo "→ Dependencies installed"
# 4. 環境ファイルのコピー
cp "$APP_DIR/.env.production" .env
# 5. 最適化
php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "→ Caches built"
# 6. シムリンクの切り替え (アトミック)
ln -sfn "$RELEASES_DIR/$NEW_RELEASE" "$CURRENT_LINK"
echo "→ Symlink switched"
# 7. マイグレーションの実行
cd "$CURRENT_LINK"
php artisan migrate --force
echo "→ Migrations done"
# 8. キューワーカーの再起動
php artisan queue:restart
echo "→ Queue workers restarting"
# 9. ヘルスチェック
sleep 3
if curl -sf http://localhost:8080/health > /dev/null; then
echo "✓ Health check passed"
else
echo "✗ Health check failed! Rolling back..."
ln -sfn "$RELEASES_DIR/$(ls -t $RELEASES_DIR | sed -n '2p')" "$CURRENT_LINK"
echo "✗ Rolled back"
exit 1
fi
# 10. 古いリリースのクリーンアップ (最新5つを保持)
ls -t "$RELEASES_DIR" | tail -n +6 | xargs -r rm -rf
echo "→ Old releases cleaned"
echo "=== Deploy complete! ==="
出力:
{"status":"ok","data":{}}
7. ゼロダウンタイムデプロイ
(1) デプロイ戦略の比較
| 戦略 | ダウンタイム | リソース使用量 | 複雑さ | 用途 |
|---|---|---|---|---|
| 直接デプロイ | 5〜30秒 | 低 | 低 | 低トラフィック/短時間のダウンタイムが許容される場合 |
| Blue-Green | 0 | 2倍 | 中 | 高可用性要件 |
| ローリングアップデート | 0 | 1.5倍 | 中 | マルチインスタンスデプロイ |
| カナリア | 0 | 1.1倍 | 高 | 大規模 / グレイスケール検証が必要 |
(2) Blue-Greenデプロイ
flowchart LR
subgraph Blue["Blue (現在)"]
B1[App v1.0]
B2[DB v1.0]
end
subgraph Green["Green (新規)"]
G1[App v1.1]
G2[DB v1.1]
end
LB[ロードバランサー] -->|"100%"| B1
LB -.->|"0%"| G1
LB -->|"ヘルスチェック後に切り替え"| G1
# Dockerを使用したBlue-Greenデプロイ
# 現在:blueが稼働中, greenはアイドル
docker compose -f docker-compose.blue.yml up -d
# greenのヘルスチェック
curl -sf http://green:8080/health || exit 1
# トラフィックの切り替え (nginxアップストリームを更新)
echo "server green:8080;" > /etc/nginx/conf.d/upstream.conf
nginx -s reload
# 古いblueを停止
docker compose -f docker-compose.blue.yml down
(1) ▶ サンプル:ShopMetrics GitHub Actionsゼロダウンタイムデプロイ
# .github/workflows/zero-downtime-deploy.yml
name: Zero-Downtime Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Determine active slot
uses: appleboy/ssh-action@v1
id: slot
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: |
if docker compose -f docker-compose.blue.yml ps | grep -q "Up"; then
echo "ACTIVE=blue" >> $GITHUB_OUTPUT
echo "DEPLOY=green" >> $GITHUB_OUTPUT
else
echo "ACTIVE=green" >> $GITHUB_OUTPUT
echo "DEPLOY=blue" >> $GITHUB_OUTPUT
fi
- name: Deploy to inactive slot
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: |
DEPLOY=${{ steps.slot.outputs.DEPLOY }}
docker compose -f docker-compose.${DEPLOY}.yml pull
docker compose -f docker-compose.${DEPLOY}.yml up -d
docker compose -f docker-compose.${DEPLOY}.yml exec app php artisan migrate --force
docker compose -f docker-compose.${DEPLOY}.yml exec app php artisan config:cache
- name: Health check & switch traffic
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: |
DEPLOY=${{ steps.slot.outputs.DEPLOY }}
ACTIVE=${{ steps.slot.outputs.ACTIVE }}
# ヘルスチェックを待機
for i in $(seq 1 10); do
if curl -sf http://localhost:8080/health; then break; fi
sleep 2
done
# nginxアップストリームを切り替え
echo "server ${DEPLOY}:8080;" > /etc/nginx/conf.d/upstream.conf
nginx -s reload
# 古いスロットを停止
docker compose -f docker-compose.${ACTIVE}.yml down
echo "Zero-downtime deploy complete!"
出力:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
8. 総合サンプル:ShopMetricsの完全なデプロイと設定
# ============================================
# 総合:ShopMetrics本番Dockerfile
# 最適化を含むマルチステージビルド
# ============================================
# ステージ1:フロントエンドアセットのビルド
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY vite.config.ts tailwind.config.js postcss.config.js ./
COPY resources/ ./resources/
RUN npm run build
# ステージ2:PHP依存関係のインストール
FROM composer:2.7 AS backend
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-progress --classmap-authoritative
COPY . .
COPY --from=frontend /app/public/build ./public/build
RUN php artisan route:cache && \
php artisan config:cache && \
php artisan view:cache && \
php artisan event:cache
# ステージ3:本番イメージ
FROM php:8.3-fpm-alpine AS production
WORKDIR /var/www/html
RUN apk add --no-cache \
curl nginx supervisor mysql-client \
&& docker-php-ext-install pdo_mysql opcache pcntl
COPY docker/opcache.ini /usr/local/etc/php/conf.d/
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY --from=backend /app /var/www/html
RUN chown -R www-data:www-data storage bootstrap/cache
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/bash
# docker/entrypoint.sh
set -e
echo "→ Running migrations..."
php artisan migrate --force
echo "→ Starting supervisord..."
exec /usr/bin/supervisord -c /etc/supervisord.conf
; docker/supervisord.conf
[supervisord]
nodaemon=true
user=root
[program:php-fpm]
command=php-fpm --nodaemonize
autorestart=true
[program:nginx]
command=nginx -g 'daemon off;'
autorestart=true
[program:queue-worker]
command=php artisan queue:work --queue=high,default --sleep=3 --tries=3 --max-time=3600
autorestart=true
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
❓ よくある質問
php artisan migrate --forceを自動化し, マイグレーションが失敗した場合はデプロイをブロックします。node_modules/.gitをコピーしない), composer installに--no-devを指定する, .dockerignoreでテストファイルを除外してください。目標サイズ:< 200MB。docker compose pull <previous-version>とdocker compose up -dを組み合わせるだけです。Blue-Greenデプロイの方がロールバックがさらに速い—古いスロットに切り替えるだけです。queue:restartでタスクが失われますか?queue:restartはワーカーが現在のタスクを完了した後にグレースフル終了し, Supervisorが新しいワーカーを自動的に再起動します。まだ開始されていないタスクはキューに残り, 新しいワーカーによる処理を待ちます。📖 まとめ
- Dockerのマルチステージビルドでイメージをスリムにし, 環境の一貫性を保証します
- Docker Composeで5つのサービス (app, worker, scheduler, mysql, redis)をオーケストレーションします
- Nginxリバースプロキシ + SSLターミネーション + セキュリティヘッダー設定
- GitHub Actions CI/CD:テスト → ビルド → デプロイが完全自動化
- 10ステップのデプロイチェックリスト:1つも飛ばさない
- Blue-Greenゼロダウンタイムデプロイ:先に新バージョンを起動 → ヘルスチェック → トラフィック切り替え
📝 練習問題
-
基本問題 (⭐):ShopMetricsのDocker Compose開発環境 (app + MySQL + Redis + Mailpit)を構築し,
docker compose up -dの実行後すぐにアプリケーションにアクセスできることを確認してください。 -
応用問題 (⭐⭐):mainブランチへのプッシュ時に自動実行されるGitHub Actionsワークフローを記述してください:テスト実行 → Dockerイメージビルド → レジストリにプッシュ → SSHでサーバーにデプロイ → ヘルスチェック。テスト失敗とヘルスチェック失敗のブロックロジックを含めてください。
-
チャレンジ (⭐⭐⭐):完全なBlue-Greenゼロダウンタイムデプロイソリューションを実装してください:2セットのDocker Compose設定, アクティブスロットの自動検出, 非アクティブスロットへのデプロイ, ヘルスチェック, Nginxアップストリーム切り替え, 古いスロットのグレースフルシャットダウン。30秒以内に前バージョンに戻せるロールバックスクリプトを記述してください。



