ここまでの管理画面は、開発用のコンテナで php artisan serve を使って動かしてきました。これは手元で確認するための簡易サーバーで、本番の利用には向きません。今回は、AWS の ECS(Elastic Container Service)で動かす前提で、Laravel 12 のアプリを本番用の Docker イメージにまとめます。
「開発環境では動いているのに、本番用のコンテナを作ると急に動かない。何を変えればいいのか分からない…」
結論から言うと、本番用イメージは「マルチステージ Dockerfile で必要なものだけを載せる」「秘密情報は焼き込まず起動時に受け取る」「root で動かさない」「ログは標準出力に出す」の4点を押さえて作ります。さらに、1つのアプリイメージを Web・キューワーカー・スケジューラの3役で使い回すと、更新のたびに3つの役割のコードが必ず揃います。
前回(第5回:請求と非同期処理)では、請求書の発行をキューで、月次締めをスケジューラで動かしました。今回はそれらを含むアプリ全体を、本番用のイメージにします。
本番用コンテナイメージの構成
ECS では、1つの「タスク」(=一緒に動くコンテナの組)の中に nginx と php-fpm の2つのコンテナを置きます。それぞれの役割は次のとおりです。
| イメージ | 中身 | ECS での使い方 |
|---|---|---|
| admin-web(nginx) | nginx と静的ファイル(public/build など) | web タスクの入口。静的ファイルは自分で返し、それ以外を php-fpm へ転送 |
| admin-app(php-fpm) | PHP 8.4・アプリのコード・本番用の依存パッケージ・ビルド済みの画面 | web タスクの php-fpm、worker(queue:work)、scheduler(schedule:work)、マイグレーション用の単発タスク |
php-fpm(=nginx から受け取ったリクエストを PHP で処理するプロセス)は、nginx と「同じタスクの localhost」で通信します。ECS の Fargate では、同じタスクのコンテナはネットワークを共有するためです(第9回で詳しく扱います)。
マルチステージ Dockerfile の5つのステージ
マルチステージビルドは、1つの Dockerfile の中で「作業用の段階」と「最終的に配る段階」を分け、最終イメージには必要なファイルだけをコピーする書き方です。Composer や Node.js などのビルド用の道具を本番イメージに残さずに済みます。
📰 出典:Docker Docs Multi-stage builds
Dockerfile(base・vendor・assets ステージ)
# ---------- 1. base ----------
FROM php:8.4-fpm-bookworm AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends libzip-dev libicu-dev \
&& docker-php-ext-install pdo_mysql zip bcmath intl pcntl \
&& apt-get purge -y --auto-remove libzip-dev libicu-dev \
&& apt-get install -y --no-install-recommends libzip4 libicu72 \
&& rm -rf /var/lib/apt/lists/*
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/prod/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/prod/php/www.conf /usr/local/etc/php-fpm.d/zz-app.conf
WORKDIR /var/www/html
# ---------- 2. vendor ----------
FROM base AS vendor
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
# 依存関係の定義だけを先にコピーする(コードを変えても composer.lock が同じならこの層はキャッシュが効く)
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress
COPY . .
# オートローダーを最適化して生成し、package:discover で本番用のパッケージ一覧(bootstrap/cache/packages.php)を作る
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative --no-interaction
# ---------- 3. assets ----------
FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
COPY --from=vendor /var/www/html/vendor/tightenco/ziggy ./vendor/tightenco/ziggy
# 画面のタイトルに使うアプリ名(秘密情報ではないのでビルド時に渡してよい)
ARG VITE_APP_NAME="Admin Console"
ENV VITE_APP_NAME=${VITE_APP_NAME}
RUN npm run build
- base:PHP 8.4 の php-fpm 公式イメージに、MySQL 接続(pdo_mysql)や、キューワーカーの安全な停止に使う pcntl などの拡張を入れます。ビルドにだけ必要な
-devパッケージは消し、実行に必要なライブラリだけを残します。 - vendor:
composer install --no-devで、テスト用の faker や phpunit などの開発用パッケージを除いた依存関係を入れます。base から作るので、PHP の拡張が揃っているかの確認(プラットフォームチェック)も本番と同じ条件で行われます。 - assets:Node.js 22 で
npm run buildを実行し、public/buildを作ります。スターターキットはルート名を扱う ziggy-js を vendor から読み込む設定なので、vendor ステージから該当ディレクトリだけをコピーしています。
composer.json と package.json を先にコピーしてから依存関係を入れているのは、Docker のレイヤーキャッシュを効かせるためです。アプリのコードだけを変えたときは、依存関係のインストールが省略され、ビルドが速くなります。
app ステージ:root で動かさない
Dockerfile(app ステージ)
# ---------- 4. app ----------
FROM base AS app
ENV APP_ENV=production \
APP_DEBUG=false \
LOG_CHANNEL=stderr \
LOG_LEVEL=info
# コードは root 所有のまま(実行ユーザーから書き換えられない)。書き込みが必要な storage と bootstrap/cache だけ www-data に渡す
COPY --from=vendor /var/www/html /var/www/html
COPY --from=assets /app/public/build /var/www/html/public/build
RUN chown -R www-data:www-data storage bootstrap/cache
COPY --chmod=755 docker/prod/entrypoint.sh /usr/local/bin/entrypoint
# root では動かさない(php-fpm のマスタープロセスも www-data で動く)
USER www-data
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
# 既定は php-fpm。worker は `php artisan queue:work`、scheduler は `php artisan schedule:work` に差し替える
CMD ["php-fpm"]
USER www-data で、コンテナ内のすべてのプロセスを一般ユーザーで動かします。さらに、アプリのコードは root の所有のままにし、実行ユーザーが書き込めるのはキャッシュやセッションを置く storage と bootstrap/cache だけにしました。万一アプリに脆弱性があっても、コードの書き換えや、コンテナ内での権限の広がりを抑えられます。
LOG_CHANNEL=stderr は、Laravel のログをファイルではなく標準エラー出力に出す設定です。Fargate のコンテナ内のファイルはタスクが止まると消えるため、ログは標準出力・標準エラー出力に出し、ECS の awslogs ドライバで CloudWatch Logs に送ります(第9回)。
📰 出典:Laravel 12.x Deployment(Directory Permissions)
web ステージ:静的ファイルだけを持つ nginx
Dockerfile(web ステージ)
# ---------- 5. web ----------
FROM nginx:1.28-bookworm AS web
COPY docker/prod/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/prod/nginx/default.conf /etc/nginx/conf.d/default.conf
# 静的ファイル(public/build・favicon など)だけを載せる。PHP のコードは nginx のイメージには入れない
COPY --from=app /var/www/html/public /var/www/html/public
RUN chown -R nginx:nginx /var/cache/nginx
USER nginx
EXPOSE 8080
nginx も一般ユーザー(nginx)で動かします。一般ユーザーは 1024 番未満のポートを開けないため、待ち受けは 8080 番にし、pid ファイルと一時ファイルの置き場所を /tmp に変えた nginx.conf を用意しました。
php-fpm と nginx の本番設定
php-fpm:環境変数を消さない設定が重要
docker/prod/php/www.conf(抜粋)
[www]
; 同じタスク(同じネットワーク)の nginx からだけ受け付ける。ECS の awsvpc モードでは同じタスクのコンテナは localhost を共有する
listen = 127.0.0.1:9000
; ECS タスク定義の environment / secrets で渡した環境変数を PHP から読めるようにする(yes だと消える)
clear_env = no
; ワーカーの標準出力・標準エラー(Laravel の LOG_CHANNEL=stderr)をコンテナのログに出す
catch_workers_output = yes
decorate_workers_output = no
; ワーカー数:1プロセス 30〜60MB 程度として、タスクのメモリに収まる数にする
pm = dynamic
pm.max_children = 10
pm.max_requests = 500
php-fpm は、既定(clear_env = yes)では起動時の環境変数を PHP のプロセスに渡しません。ECS では DB の接続先やパスワード、APP_KEY を環境変数で渡すため、clear_env = no がないと「タスク定義には書いたのに Laravel から読めない」状態になります。PHP の公式 Docker イメージは同じ設定を最初から入れていますが、本番で必須の設定なので、自分たちの設定ファイルにも明示しました。
pm.max_children は同時に動く PHP プロセスの上限です。1プロセスが使うメモリ×上限数が、タスクに割り当てたメモリを超えないように決めます。
PHP:OPcache でコードの読み込みを速くする
docker/prod/php/php.ini(抜粋)
; レスポンスヘッダーに PHP のバージョンを出さない
expose_php = Off
; OPcache:コンパイル済みのコードをメモリに保持して高速化する
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
; コードはイメージに焼き込まれて変わらないので、ファイルの更新確認をしない(再デプロイ=新しいコンテナで反映)
opcache.validate_timestamps = 0
コンテナのコードは、デプロイで新しいコンテナに入れ替わるまで変わりません。そのため opcache.validate_timestamps = 0 で「ファイルが更新されたか」の確認を省き、速度を優先します。
nginx:index.php 以外の PHP とドットファイルを返さない
docker/prod/nginx/default.conf(抜粋)
server {
# 非 root では 1024 未満のポートを開けないため 8080 で待ち受ける(ALB のターゲットも 8080)
listen 8080;
root /var/www/html/public;
# Vite のビルド結果はファイル名にハッシュが付くので、長期間キャッシュさせてよい
location /build/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
# 同じタスクの php-fpm コンテナ(localhost:9000)へ転送する
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
# index.php 以外の .php と、.env などのドットファイルには応答しない
location ~ \.php$ { return 404; }
location ~ /\.(?!well-known).* { deny all; }
}
Laravel 公式ドキュメントの nginx 設定例をもとに、転送先を同じタスクの php-fpm にし、Vite のビルド結果に長期キャッシュを付けました。nginx のイメージには PHP のファイルがないため、SCRIPT_FILENAME は php-fpm 側のパスを直接指定しています。
📰 出典:Laravel 12.x Deployment(Nginx)
entrypoint でキャッシュを作り、同じイメージを3役で使う
docker/prod/entrypoint.sh
#!/bin/sh
# 本番用 entrypoint(web / worker / scheduler / migrate 共通)
# 設定・ルート・ビュー・イベントのキャッシュを起動時に作る。
# ビルド時ではなく起動時に行うのは、APP_KEY や DB の接続先などの環境変数が
# ECS のタスク定義(secrets)から起動時に渡されるため(config:cache は環境変数の値を焼き込む)。
set -eu
php artisan optimize
exec "$@"
php artisan optimize は、設定・イベント・ルート・ビューのキャッシュをまとめて作るコマンドです。設定のキャッシュ(config:cache)は、その時点の環境変数の値をファイルに書き込みます。ビルド時に実行すると秘密情報がないまま(またはイメージに焼き込まれた状態で)キャッシュされてしまうため、起動時に実行しています。
📰 出典:Laravel 12.x Deployment(Optimization)
最後の exec "$@" で、CMD に指定したコマンドがコンテナの主プロセスになります。ECS がタスクを止めるときに送る終了の合図(SIGTERM)が、php-fpm やキューワーカーに直接届くようにするためです。キューワーカーは合図を受けると、処理中のジョブを終えてから停止します。
同じ admin-app イメージを、コマンドだけ変えて3役で使います。ローカルで本番相当の動きを確かめる compose.prod.yaml では、次のように書きました。
compose.prod.yaml(抜粋)
services:
# ECS の web タスクの php-fpm コンテナに相当
app:
image: admin-app
environment: *app-env
# nginx(web)はこのコンテナのネットワークを共有するので、公開ポートはここに書く
ports:
- "${WEB_PORT:-8080}:8080"
# ECS の web タスクの nginx コンテナに相当。ECS の awsvpc モードと同じく、php-fpm と localhost を共有する
web:
image: admin-web
network_mode: "service:app"
# 同じ admin-app イメージをコマンドだけ変えて使う
queue:
image: admin-app
environment: *app-env
command: ["php", "artisan", "queue:work", "--sleep=3", "--max-time=3600"]
stop_grace_period: 70s
scheduler:
image: admin-app
environment: *app-env
command: ["php", "artisan", "schedule:work"]
network_mode: "service:app" で nginx と php-fpm のネットワークを共有させ、ECS のタスクと同じ「localhost で通信する」構成を再現しています。環境変数(*app-env)の中身は、DB の接続先などのローカル専用の値と、.env(Git 管理外)から読む APP_KEY です。
.dockerignore には .env、node_modules、vendor、テストコード、ローカルで生成された bootstrap/cache/*.php などを書き、イメージに入らないようにしました。特に .env がイメージに入ると、イメージを取得できる人全員に秘密情報が見えてしまいます。
動作確認の方法
docker build --target app -t admin-app .
docker build --target web -t admin-web .
docker compose -f compose.prod.yaml up -d mysql
docker compose -f compose.prod.yaml run --rm app php artisan migrate --force
docker compose -f compose.prod.yaml up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/up
docker compose -f compose.prod.yaml exec app id
docker compose -f compose.prod.yaml logs web
docker image ls
マイグレーションは、Web のコンテナの起動時ではなく、run --rm で単発のコンテナとして1回だけ実行しています。ECS でも同じく、デプロイ前に単発のタスクとして実行する予定です(第10回・第11回)。
筆者の環境では、次のことを確認しました(ホストの 8080 番が別の用途で使用中だったため、WEB_PORT=8081 で公開しました)。
- 2つのイメージのビルドが成功し、
/upと/loginが 200 を返す /build/assets/の JavaScript にCache-Control: public, immutableと1年のキャッシュが付く。/.envは 403、/info.phpのような index.php 以外の PHP は 404idの結果が、php-fpm はuid=33(www-data)、nginx はuid=101(nginx)。www-data からアプリのコード(app/)には書き込めず、storageには書き込めるphp -iでopcache.enable => On、opcache.validate_timestamps => Off、expose_php => Off- 担当者でログインして請求書を発行すると、queue コンテナのログに
production.INFO: 請求書を発行しましたと Laravel のログが出る。nginx のログにはアクセスログが出る docker compose stop queueで、ワーカーが終了コード 0 で停止するdocker image lsのサイズは admin-app が 757MB、admin-web が 280MB。admin-app の大半は PHP の公式イメージ(同じ表示で 712MB)で、アプリと依存パッケージの追加分は 45MB ほど
つまずきやすい点・セキュリティ上の注意
- 本番イメージでは db:seed できない:シーダーが使う faker は開発用パッケージなので、
--no-devのイメージには入りません。実際に試すとClass "Faker\Factory" not foundになりました。本番の初期データは、マイグレーションや専用のコマンドで入れる設計にします。 - tinker は書き込み可能な設定ディレクトリが必要:非 root で動かしているため、
php artisan tinkerが設定ファイルを作れずに失敗します。調査で使う場合はXDG_CONFIG_HOME=/tmpを付けて実行します(ECS でのコマンド実行は第12回)。 APP_DEBUG=falseを必ず確認する:trueのままだとエラー画面に設定値が表示されるおそれがあります。イメージの既定を false にし、タスク定義でも上書きしないようにします。- イメージに秘密情報を入れない:
.envを.dockerignoreに入れるだけでなく、ARGやENVにパスワードを書かないことも大切です。ビルド時の値はイメージの履歴に残ります。 - ベースイメージの更新:
php:8.4-fpm-bookwormなどのタグは、セキュリティ修正のたびに中身が更新されます。定期的にビルドし直す運用を決めておきます。
発注者向けメモ:イメージを1つにまとめると更新が揃う
本番用のコンテナイメージは、見積もりでは「インフラ構築」や「デプロイ環境整備」の一部として扱われることが多く、中身が見えにくい作業です。確認しておきたいのは次の点です。
- Web・ワーカー・スケジューラが同じイメージか:別々に作ると、画面は新しいのにワーカーだけ古いコード、という食い違いが起きやすくなります
- イメージのサイズとビルド時間:デプロイの待ち時間と、コンテナイメージの保管費用(Amazon ECR は保存量に応じた課金)に影響します
- 秘密情報の渡し方:イメージや Git に入れず、起動時に AWS のサービスから受け取る設計になっているか
- root で動かしていないか、ログはどこに出るか
- ベースイメージの更新を誰がいつ行うか:保守契約の範囲に含まれているか
開発会社には、次のように確認してみてください。
- 「本番用のコンテナイメージに、パスワードや API キーが含まれていないことをどう確認していますか?」
- 「画面・キューワーカー・定期実行は同じイメージから起動しますか?更新のときに食い違うことはありませんか?」
- 「ベースイメージのセキュリティ更新は、どのくらいの頻度で、誰が反映しますか?」
- 「アプリのログは、コンテナが入れ替わった後も確認できる場所に残りますか?」
まとめと次回予告
第6回では、本番用のマルチステージ Dockerfile を作り、ローカルで本番相当の構成を動かしました。
- base → vendor → assets → app → web の5ステージで、開発用パッケージやビルド用の道具を最終イメージに残さない
- php-fpm・nginx とも非 root で動かし、コードは書き換えられないようにする
clear_env = noとLOG_CHANNEL=stderrで、環境変数を受け取り、ログを標準出力に出す- 設定のキャッシュは起動時に entrypoint で作り、同じイメージを Web・ワーカー・スケジューラで使い回す
次回は「Terraform の土台とネットワーク:state 管理と VPC」です。いよいよ AWS 側の準備に入り、Terraform の state の置き場所と、VPC・サブネット・NAT Gateway・セキュリティグループをコードで定義します。
この連載の記事一覧
この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第1回】全体像と開発環境:Laravel 12+Vue 3 スターターキットを Docker で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第3回】ロールと Policy:誰が何をできるかをコードで決める
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile(この記事)
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第8回】RDS for MySQL と秘密情報:パスワードをコードに書かない
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第9回】ECR・ECS on Fargate・ALB:管理画面をインターネットに公開する
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第12回】運用編:ログ・監視・スケーリング・切り戻し










コメント
コメント一覧 (2件)
[…] 前回(第6回:本番用コンテナイメージを作る)では、nginx と php-fpm の本番用イメージを作りました。今回はそれを動かすための VPC・サブネット・NAT Gateway・セキュリティグループを Terraform で定義します。 […]
[…] 本番用コンテナイメージを作る:マルチステージ Dockerfile […]