
しかも、イメージが大きいだけならまだ我慢できる。実際には、デプロイ時の転送量、脆弱性スキャンの対象、更新時のビルド時間、障害調査時に触るコンポーネントの数まで増える。便利な開発環境を、そのまま本番へ運ぶという罠がある。
そこで使うのが、LaravelのDockerイメージを複数の段階に分けて構築するマルチステージビルドだ。ビルドに必要なNode.jsやComposer、コンパイル用パッケージは専用ステージへ隔離し、最終的なPHP-FPMイメージには、実行に必要なファイルだけを転送する。
結局のところ、軽量化の本質は「何を削るか」ではない。「どこで使うものを、どの段階に置くか」を決める設計にある。
Laravelでマルチステージビルドを使う理由
マルチステージビルドは、1つのDockerfile内で複数のFROMを使い、ビルド環境と実行環境を分離する仕組みだ。Docker 17.05以降で利用できるようになった機能で、現在のLaravel開発では特別な裏技というほどのものではない。
ただし、仕組みが簡単だからといって、適当にFROMを増やせばよいわけではない。ステージごとの役割を曖昧にすると、結局すべての依存関係を最終イメージへコピーしてしまう。マルチステージビルドを使っているのに、サイズも脆弱性も大して変わらないという、少し悲しい状態になる。
典型的なLaravelアプリケーションなら、次のように役割を分ける。
| ステージ | 主な役割 | 最終イメージへ持ち込むもの |
|---|---|---|
| Composer | PHP依存関係の取得 | vendorディレクトリ |
| フロントエンド | ViteやMixによるアセット生成 | public/buildなどの成果物 |
| PHP拡張ビルド | PHP拡張のコンパイル | 必要な拡張と実行ライブラリ |
| 本番実行 | PHP-FPMによるアプリケーション実行 | アプリケーション本体、依存関係、成果物 |
開発用ステージを別に用意する場合は、さらにNode.jsやデバッグ拡張、Composerの開発依存関係を含めたステージを作る。開発と本番で同じDockerfileを使いながら、Composeのbuild.targetで到達するステージを切り替える構成も取れる。
本番イメージに必要なのは、アプリケーションを「作る道具」ではなく、アプリケーションを「動かすための部品」だけだ。
この線引きができると、Dockerfileの整理が急に楽になる。
最初に決めるべきは最終イメージの責務
軽量化を始める前に、最終ステージで何を実行するのかを決める。Laravelのウェブアプリケーションなら、基本的にはPHP-FPMがリクエスト処理を担当し、Nginxなどのウェブサーバーが静的ファイル配信やFastCGI接続を担当する構成が多い。
ここで、1つのコンテナにNginx、PHP-FPM、Node.js、スケジューラー、キューワーカーをすべて詰め込むと、責務が崩れる。もちろん、小規模な個人開発で力技の構成を採用すること自体は悪ではない。管理対象が少なく、再起動やログ収集も一元化できるなら、現実的な妥協になる場合もある。
しかし、その場合でも「本番のPHP実行コンテナにNode.jsまで必要なのか」は分けて考えたい。
最終イメージに残す候補は、概ね次の範囲に収まる。
- PHP本体と、Laravelが実際に利用するPHP拡張
- 本番用にインストールした
vendorディレクトリ - Laravelのソースコード
- ViteやMixが生成した公開用アセット
- 実行時に必要な設定ファイルやエントリーポイント
- PHP-FPMの設定、必要に応じたプロセス管理用の設定
反対に、次のものは原則としてビルド用ステージへ隔離する。
- Node.jsとnpm
- Composer本体
- PHPUnit、PHPStanなどの開発用パッケージ
- SassやTypeScriptなどのアセット生成用ツール
- PHP拡張のコンパイルにしか使わないビルドツール
- パッケージマネージャーのキャッシュ
- Gitや一時的なアーカイブ
Gitを本番コンテナに残している構成も見かけるが、アプリケーションの実行に必要なければ不要だ。デプロイのために必要なのは、Gitの履歴ではなく、ビルド済みの成果物である。
Composerステージは依存関係の境界として使う
Laravelの本番イメージが膨らむ大きな理由の一つは、Composerの扱いだ。開発中はrequire-devのパッケージも必要になる。テストや静的解析を実行するためには、PHPUnitやPHPStanを削れない。
ただし、本番実行にそれらが必要とは限らない。そこでComposerのステージでは、まず依存関係を取得し、その後に本番用依存関係だけを最終ステージへ転送する。
Composer本体は、公式のComposerイメージからバイナリをコピーする方法が使える。Dockerfileでは、COPY --from=composer:2 /usr/bin/composer /usr/bin/composerのように指定できる。Composerのインストーラーをダウンロードして実行する手順よりも短く、インストール方法の差異も減らせる。
Composerステージでは、いきなりアプリケーション全体をコピーしない。依存関係の定義ファイルを先にコピーするのが基本だ。
まずcomposer.jsonとcomposer.lockをコピーし、composer installを実行する。その後でアプリケーション本体をコピーする。この順番にすると、ソースコードだけを変更した場合に、依存関係のインストール層をDockerのキャッシュで再利用しやすい。
本番用のインストールでは、次のようなオプションを組み合わせる。
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
それぞれの意味は明確だ。
--no-devで開発用パッケージを除外する--prefer-distで配布アーカイブを優先する--no-interactionで対話入力を発生させない--optimize-autoloaderで本番向けにオートローダーを最適化する
ここで注意したいのは、composer.lockを使わずに本番ビルドを行うことだ。ロックファイルがなければ、ビルドのタイミングによって取得される依存関係のバージョンが変わる可能性がある。昨日まで動いていたアプリケーションが、Dockerイメージを再ビルドしただけで動かなくなる。こういう謎のバグは、たいてい謎ではなく、固定していない依存関係が原因だ。
vendorだけを転送する
Composerステージから最終ステージへ移すのは、Composer本体ではない。基本的には生成されたvendorディレクトリだけでよい。
たとえば、Composerステージで/app/vendorを作り、最終ステージへCOPY --from=composer /app/vendor /var/www/html/vendorのようにコピーする。アプリケーション本体は最終ステージで別途コピーし、依存関係とソースコードを混ぜない。
この分離には、サイズ以外の利点もある。Composerのキャッシュやインストール用の一時ファイルを誤って本番へ残しにくい。何を転送したのかがDockerfile上で明確になるため、後から構成を見直すときにも追いやすい。
ただし、Composerパッケージが実行時に必要とするPHP拡張は、最終ステージ側に存在しなければならない。Composerで依存関係を取得できたからといって、実行環境の拡張要件まで解決したわけではない。
たとえば、データベース接続、画像処理、文字列処理などで利用する拡張が不足していれば、コンテナ起動後にLaravelがエラーを出す。依存関係の取得と、アプリケーションの実行条件は別々に確認する必要がある。
Node.jsはアセットを作ったら退場させる
ViteやMixを利用するLaravelアプリケーションでは、フロントエンド用の依存関係も必要になる。npm installやnpm ciを実行し、JavaScriptやCSSをビルドし、public/buildなどの成果物を生成する流れだ。
ここでありがちな構成が、PHP-FPMのイメージにNode.jsをインストールし、その中でアセットを生成する方法である。開発環境では分かりやすい。しかし本番実行だけを考えると、アセット生成後のNode.jsは仕事を終えている。
Node.js用のステージを別に作り、生成物だけを最終PHPイメージへコピーすればよい。Node.js 20 Alpineのようなイメージをステージとして使い、npm ciとアセットビルドを行う。最終ステージには、Node.jsの実行バイナリも、node_modulesも持ち込まない。
設計の流れは次のようになる。
1. package.jsonとロックファイルをフロントエンドステージへコピーする。
2. npm ciで依存関係を固定してインストールする。
3. Laravelのソースコードや設定をコピーする。
4. ViteまたはMixの本番ビルドを実行する。
5. 生成されたpublic/buildなどだけを最終PHPステージへコピーする。
このとき、node_modulesを最終イメージへコピーしないことが重要だ。アセットの生成に使ったパッケージと、ブラウザへ配信する成果物は別物である。ここを一緒に扱うと、Node.jsを削除しても大量の開発依存関係が残る。
Node.jsを本番から削除するのではなく、Node.jsの仕事を本番イメージの外で終わらせる。これが一番揉めない。
開発時だけNode.jsが必要な場合
開発環境では、Viteの開発サーバーやホットリロードを使うことがある。この場合、Node.jsを開発用コンテナとして残すのは自然だ。
開発用Composeでは、PHPコンテナ、データベース、Node.jsコンテナを分けてもよい。あるいは、開発用のPHPステージにNode.jsを含め、ひとまとめにする方法もある。
後者は構成が簡単になる反面、コンテナの責務が広がる。前者はサービスが増えるが、Node.jsのログや依存関係を独立して管理しやすい。個人開発では、どちらが正解という話ではない。ホットリロードを毎日使うのか、アセットをたまにビルドするだけなのかで決めればよい。
ただし、本番用ターゲットがNode.jsを含まないことだけは明確にしておく。開発の便利さと、本番イメージの構成は同じでなくてよい。
Dockerfileのステージをどう組み立てるか
LaravelのDockerfileでは、ステージの分け方にいくつかの選択肢がある。次のような構成は、個人開発でも扱いやすい。
base:PHP-FPMと共通設定の土台composer:Composer依存関係を生成frontend:Node.jsでアセットをビルドdevelopment:開発用ツールやデバッグ機能を追加production:本番用依存関係と成果物だけを集約
baseステージには、PHPのランタイムと実行時に必要な共通設定を置く。PHP拡張のインストール方法によっては、ビルド用パッケージが必要になるため、その扱いは別途整理する。
PHP拡張をコンパイルする場合、コンパイラーやヘッダーファイルなどのビルド依存関係が必要になる。これらをインストールしたまま最終イメージに残すと、マルチステージ化の効果が薄れる。ビルド専用ステージでコンパイルし、実行に必要なライブラリだけを本番ステージへ残す構成が望ましい。
一方で、PHP拡張の扱いはComposerやNode.jsほど単純にコピーできない場合がある。拡張モジュールとリンクするライブラリの組み合わせ、PHPのバージョン、ベースイメージのディストリビューションによって差が出るからだ。
ここを無理に一つのテクニックで統一しようとすると、Dockerfileが読みにくくなる。標準的な拡張インストール手順を使い、不要なビルド依存関係を削除する方法が安定する場合もある。力技だが、運用で困らない構成を優先したい。
レイヤーキャッシュを意識する
軽量化というと、ついイメージの最終サイズだけに目が向く。しかし、Laravelの開発ではビルド時間も無視できない。
Dockerfileの命令は、変更されにくいものから先に置く。依存関係の定義ファイルを先にコピーし、インストールを実行し、その後で変更頻度の高いソースコードをコピーする。これだけでも、毎回Composerやnpmの処理を最初からやり直す状況を避けやすくなる。
Composerの場合は、次の順番が基本になる。
1. Composerの実行環境を用意する。
2. composer.jsonとcomposer.lockをコピーする。
3. 本番用または開発用の依存関係をインストールする。
4. Laravelのソースコードをコピーする。
5. 必要な最適化処理を実行する。
Node.js側も同様で、package.jsonとロックファイルを先にコピーする。ソースコードを先に丸ごとコピーしてからnpm ciを実行すると、PHPファイルを1つ変更しただけでフロントエンド依存関係のインストールが再実行されるという罠がある。
.dockerignoreも忘れない。.git、node_modules、vendor、ログ、ローカルの環境設定ファイルなどをビルドコンテキストから除外する。ホスト側のvendorをコピーしてしまうと、OSやPHPのバージョン差による不具合を持ち込む可能性がある。
Alpine Linuxは軽いが、万能ではない
LaravelのDockerイメージを軽量化する方法として、Alpine Linuxベースのイメージがよく使われる。たとえば、PHP 8.3のAlpine系イメージやNode.jsのAlpine系イメージをベースにすると、一般的なディストリビューションより小さな構成を作りやすい。
ただし、「Alpineだから必ず最適」という話ではない。Alpine Linuxは標準のCライブラリとしてmuslを採用しているため、glibcを前提としたバイナリやライブラリとの互換性で引っかかることがある。
Node.jsのパッケージ、PHP拡張、画像処理ライブラリ、外部バイナリなどを組み合わせる場合は、Alpine特有の差異が表面化しやすい。ビルドが通らない、実行時にライブラリが見つからない、ネイティブモジュールのコンパイルで止まる、といった問題が起きる。
ここで別のベースイメージへ戻すのは敗北ではない。軽量化のために採用したAlpineが、調査時間と運用コストを増やしているなら、目的と手段が入れ替わっている。
ベースイメージは、次のような観点で選ぶとよい。
- 利用するPHP拡張が問題なくインストールできるか
- Composerパッケージにネイティブ拡張の依存がないか
- Node.jsの依存パッケージがAlpine環境に対応しているか
- 本番環境で必要な診断ツールをどう用意するか
- チームや自分がトラブル時に調査できる構成か
最初から最小サイズを狙うより、まず動作する構成を作り、その後に不要な依存関係を取り除くほうが安全だ。Alpineの採用は、その後でも遅くない。
開発環境と本番環境をComposeで切り替える
Docker Composeを使う場合、開発用と本番用でDockerfileを完全に分ける方法もある。しかし、共通部分が多いLaravelプロジェクトでは、1つのDockerfileに複数ステージを定義し、Compose側からビルド対象を切り替えるほうが管理しやすいことがある。
Composeのbuild.targetを指定すれば、Dockerfile内の特定ステージを選んでビルドできる。
開発環境ではdevelopmentターゲットを選び、デバッグ拡張、Composerの開発依存関係、必要な補助ツールを含める。本番環境ではproductionターゲットを選び、--no-devでComposer依存関係をインストールし、Node.jsを含まない最終イメージを生成する。
| 項目 | 開発環境 | 本番環境 |
|---|---|---|
| Composer依存関係 | 開発用を含める | --no-devで除外 |
| Node.js | 開発サーバーやビルドに使用 | アセット生成後は含めない |
| PHPデバッグ拡張 | 必要に応じて有効化 | 原則として実行要件に合わせる |
| ソースコード | ボリュームマウントを利用することがある | イメージへコピーして固定 |
| アセット | ホットリロードや開発用ビルド | 本番用ビルド成果物のみ |
| キャッシュ | 開発速度を優先 | 再現性とデプロイ速度を優先 |
| 設定 | ローカル向け | 環境変数やシークレット管理に合わせる |
ここで注意したいのは、開発用ボリュームマウントの扱いだ。開発環境ではホストのソースコードをコンテナへマウントすることが多いが、その状態でイメージ内に生成したvendorやpublic/buildが隠れることがある。
たとえば、イメージビルド時に/var/www/html/vendorを生成していても、ホスト側のプロジェクトディレクトリを/var/www/htmlへマウントすれば、コンテナ内のファイルが見えなくなる。結果として、依存関係がない、アセットがないというエラーになる。
この問題はDockerの不具合というより、マウントの仕様だ。開発用Composeでは、ソースコードと依存関係のボリュームを分ける、起動時にComposerを実行する、依存関係用の名前付きボリュームを使うなど、プロジェクトの運用に合わせて決める必要がある。
本番環境では、ホストのソースコードをマウントしないほうが扱いやすい。ビルドしたイメージそのものをデプロイ単位にすれば、どのファイルが動いているのかが明確になる。サーバー上でソースコードを直接書き換える運用は、復旧時に差分が分からなくなるため、炎上案件の入口になりやすい。
本番イメージにコピーする順番
最終ステージへファイルを集めるときは、単にアプリケーション全体をコピーしてから不要なものを削除するより、最初から必要な成果物だけを転送するほうがよい。
削除方式には、削除し忘れという罠がある。ビルド途中で生成されたキャッシュ、テスト用設定、開発依存関係、ソースマップなどが残りやすい。しかも、Dockerのレイヤーには削除前のファイルが残ることがあるため、後からrmしても期待したほどイメージサイズが減らない場合がある。
最終ステージでは、次のようにコピー対象を整理する。
1. PHP-FPMの実行環境を用意する。
2. 実行時に必要なPHP拡張とライブラリを導入する。
3. Composerステージから本番用のvendorをコピーする。
4. フロントエンドステージから生成済みアセットをコピーする。
5. Laravelのアプリケーションコードをコピーする。
6. ストレージやキャッシュディレクトリの所有者を調整する。
7. PHP-FPMの起動設定とヘルスチェックを設定する。
Laravelでは、storageやキャッシュ関連のディレクトリへ書き込みが必要になる。イメージの軽量化ばかりに集中して、実行ユーザーの権限を無視すると、デプロイ後にログが書けない、キャッシュを生成できない、アップロードに失敗するといった問題が起きる。
コンテナをrootユーザーのまま動かして、とりあえず書き込みを通す方法はある。だが、これは運用上の妥協として明示しておきたい。可能であれば、実行用ユーザーとディレクトリ所有権を整理し、アプリケーションが必要とする範囲だけ書き込み可能にする。
.envとシークレットをイメージへ入れない
本番イメージを固定するために、.envをビルド時にコピーしたくなることがある。しかし、環境ごとの設定やシークレットをイメージへ埋め込むと、イメージの再利用性が落ちる。レジストリやキャッシュに残る可能性もある。
アプリケーションコードと環境設定は分ける。データベース接続情報、アプリケーションキー、外部サービスの認証情報は、実行環境の環境変数やシークレット管理の仕組みから注入する。
Laravelの設定キャッシュを利用する場合も、どの時点で環境変数を読み込むのかを考える必要がある。ビルド時に設定をキャッシュすると、ビルド環境の値がイメージへ固定されることがある。本番環境の値を実行時に渡す設計なら、キャッシュの生成タイミングも運用に合わせるべきだ。
ビルドキャッシュとイメージサイズを混同しない
マルチステージビルドでは、中間ステージのファイルを最終ステージへコピーしなければ、本番イメージには含まれない。つまり、ComposerやNode.jsのステージで一時的に大きな依存関係を持っていても、それだけで最終イメージが同じ大きさになるわけではない。
一方で、ビルドキャッシュは別に存在する。CI/CD環境やローカルのDockerキャッシュに中間レイヤーが残れば、ディスク使用量は増える。最終イメージのサイズを削減できても、ビルドホストの容量問題が解決するとは限らない。
ここでは、次の3つを分けて考える。
- 最終的にレジストリへ送る本番イメージのサイズ
- Dockerビルド中に使われる一時ファイルの量
- ローカルやCI環境へ保存されるビルドキャッシュの量
デプロイ速度を上げたいなら、最終イメージの軽量化に加えて、レイヤーキャッシュの再利用も設計する必要がある。依存関係ファイルのコピー順を整え、頻繁に変わるソースコードを後ろへ置く。CI/CDではキャッシュの保存方法を用意し、毎回すべてを再構築しないようにする。
ただし、キャッシュを永遠に残すのも別の問題だ。古いレイヤーが蓄積し、ディスクを圧迫する。キャッシュの削除や保持期間は、CI環境と本番サーバーで別々に考える。軽量なイメージを作ったのに、ホストのディスクが先に埋まるという、少し皮肉な結末は避けたい。
よくある失敗と、その直し方
マルチステージビルドへ移行するとき、つまずきやすいポイントはいくつかある。
依存関係を最終ステージで再インストールする
Composerステージでvendorを作ったのに、最終ステージでもComposerを実行している構成がある。これではComposerステージが単なる遠回りになってしまう。
最終ステージへは、必要なvendorをコピーする。最終環境で実行する必要がある処理だけを残し、依存関係の取得はComposerステージへ寄せる。
Node.jsのビルド成果物をコピーし忘れる
Node.jsを削除した後、public/buildが生成されていない、またはコピー先が違うという問題が起きる。ViteやMixの設定によって出力先が異なるため、Dockerfile上のコピー先を決め打ちにしすぎないことが大切だ。
Laravelが参照するマニフェストファイルも成果物に含まれる場合がある。単にJavaScriptやCSSのファイルだけをコピーすればよいとは限らない。アプリケーション側が本番で読むファイルを、ビルド結果として確認する。
npm installで毎回依存関係が変わる
ロックファイルを利用できるなら、npm installよりも固定インストールを使う。依存関係が変わっていないのに、ビルドするたびに解決結果が変わるのは困る。
Composerとnpmの両方でロックファイルを管理し、Dockerのキャッシュが効く構造にする。この地味な作業が、後でデプロイの再現性を支える。
Alpineでビルドが通らない
Alpineベースへ変更した直後に、ネイティブモジュールやPHP拡張のコンパイルで失敗することがある。このとき、依存パッケージを手当たり次第に追加すると、Dockerfileが徐々に読めなくなる。
まず、どの処理がglibcや特定のビルド環境を要求しているのかを切り分ける。Alpineに合わせて依存関係を調整する方法もあるが、対応コストが高ければDebian系のPHPイメージへ戻す選択も検討する。
軽量化は目的ではなく手段だ。数百メガバイトを削るために、毎回のビルドで長時間悩み、障害時の調査手順まで複雑になるなら、得られたものはそれほど多くない。
実際の設計で分けるべき部分、妥協してよい部分
すべてを厳密に分離する必要はない。個人開発では、開発者が一人しかいないことも多い。構成を細かく分割しすぎると、DockerfileとComposeの理解コストが増え、更新されない設定だけが残る。
コードで解決したほうがよい部分は、依存関係と成果物の分離だ。
- 本番からNode.jsを外す
- 本番からComposerの開発依存関係を外す
- Composerやnpmのキャッシュを最終イメージへ持ち込まない
- ビルド成果物を明示的に転送する
- 開発と本番でDockerのターゲットを分ける
一方で、運用でカバーしてもよい部分もある。
- 開発環境でのホットリロード用Node.js
- 小規模サービスにおける補助ツールの同居
- 多少大きくても、Alpine特有の問題を避けるベースイメージ選択
- 障害調査のための最小限の診断ツール
本番イメージを軽くするために、ログ出力や監視を削ってはいけない。コンテナが起動しても、何が起きているのか分からなければ運用できない。サーバー監視やログ収集は、イメージサイズとは別の責務として残すべきだ。
まとめ:軽量化は「削除」より「境界線」の設計
LaravelのDockerイメージを軽量化するマルチステージビルドでは、ComposerとNode.jsを専用ステージへ分離し、本番実行に必要な成果物だけをPHP-FPMの最終イメージへコピーする。
Composerでは--no-devとオートローダー最適化を使い、vendorだけを転送する。Node.jsではViteやMixのビルドを終えたら、public/buildなどの成果物だけを持ち込む。Docker Composeではbuild.targetを利用し、開発環境と本番環境で必要なステージを切り替える。
Alpine Linuxは軽量化に有効だが、musl由来の互換性問題を抱えることがある。サイズだけを見て採用せず、PHP拡張やネイティブモジュールの実行条件まで確認する。動かない軽量イメージは、動く少し大きなイメージより役に立たない。
結局のところ、本番イメージに必要なのは、ビルドの苦労ではなく、ビルドの結果だ。どの依存関係をどこで使い、何を最終成果物として残すのか。その境界線をDockerfileに書ければ、LaravelのDocker環境はかなり素直になる。
残りの細かな妥協は、運用でカバーすればよい。すべてを理想通りに分離しようとして構成を壊すより、コードで守る部分と、人間が手順で守る部分を見極めるほうが、長く使えるインフラになる。
Related reading: Laravelの本番環境向けDockerfile:マルチステージビルドによるイメージ軽量化の是非 and Docker・インフラ構築をわかりやすく解説.