MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile

    ここまでの管理画面は、開発用のコンテナで 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 は 404
    • id の結果が、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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次