Docker・インフラ構築

Laravelの本番環境向けDockerfile:マルチステージビルドによるイメージ軽量化の是非

本番環境にデプロイしたLaravelアプリのDockerイメージが1.12GBを超えると、CIのキューやレジストリからのイメージ取得に無視できない負担がかかる。イメージの中身を確認してみると、Node.js、npm、Composer、開発用パッケージ、ビルド用ツールまで、本番では使わないものが同じコンテナに入っていることがある。…

Laravelの本番環境向けDockerfile:マルチステージビルドによるイメージ軽量化の是非

マルチステージビルドに切り替え、ベースイメージもphp:8.3-fpm-alpineへ移行すれば、最終イメージを大幅に縮小できる。デプロイ時の転送量だけでなく、コンテナレジストリに保存する容量や、ビルドキャッシュの効き方も変わってくる。

ただし、ここで「Laravelの本番環境ではAlpineを使えばよい」と結論づけるのは早い。軽量化には、拡張機能の互換性、デバッグのしやすさ、脆弱性対応、CIの再現性といった別のコストがついてくる。この記事では、ComposerとNode.jsを分離する構成を中心に、どこまでイメージを削る価値があるのかを、個人開発者の本番運用という視点から考えていく。

なぜLaravelの本番環境でマルチステージビルドが必要なのか

LaravelのDockerfileを書くと、自然と「全部入りのコンテナ」が出来上がる。理由のひとつは、LaravelがComposerとNode.jsという異なるビルドチェーンを必要とするからだ。

PHP側では、composer installによってアプリケーションの依存パッケージをvendor/に展開する。フロントエンド側では、Viteなどを使ってJavaScriptやCSSをビルドし、public/build/に本番用アセットを生成する。開発中はComposer、Node.js、npm、コンパイラ、テストツールがすべて必要になる。

しかし、本番のPHPコンテナが必要とするものは、それらの実行環境全体ではない。通常は次のような成果物だけで動かせる。

  • 本番用依存関係を含むvendor/
  • ビルド済みのpublic/build/
  • Laravelのアプリケーションコード
  • PHP本体と必要な拡張機能
  • PHP-FPMを起動するための設定
  • 書き込みが必要なstorage/などのディレクトリ

Node.jsそのものやnode_modules/、Composerのキャッシュ、テスト用パッケージまで本番イメージに残す理由はない。それでも同じステージで全部を実行すると、途中で使ったものがそのまま最終イメージのレイヤーに残りやすい。

構成によっては、composer installnpm ciをすべて同一ステージで実行した結果、LaravelのDockerイメージが1GBを超えることもある。PHP本体、拡張機能、Composer本体、Node.js、npm、全node_modules、Composerのvendorが同じイメージに積み上がるためだ。

もちろん、表示されるイメージサイズは圧縮前後の扱い、ベースイメージのタグ、導入する拡張機能、レジストリ側の表示方法によって変わる。1.12GBという値をどのプロジェクトにも当てはまる基準として扱うべきではない。ただ、開発用ツールと本番用ランタイムを同居させた結果、必要以上に大きくなるという構造自体は変わらない。

この「太ったコンテナ」が引き起こす問題は容量だけではない。

  • デプロイ時間の長期化:レジストリからのプルに時間がかかり、リリースの待ち時間が伸びる
  • ストレージと転送コストの増大:イメージを保存する容量と、ノードへ配布する転送量が増える
  • 攻撃対象領域の拡大:本番コンテナ内にビルドツールや不要なCLIが残り、脆弱性の影響範囲が広がる
  • キャッシュの効率低下:変更の少ない依存関係まで毎回再構築され、CIの実行時間が安定しない
  • 運用時の判断の複雑化:本番で使わないツールが入っていると、障害時に何を使ってよいのか分かりにくくなる

イメージが大きいからといって、必ずデプロイが遅くなるわけではない。ネットワーク帯域、ノード上のキャッシュ、レジストリの配置、CIの構成などにも左右される。ただし、キャッシュされていないノードへ大きなイメージを配布する場合、プルやデプロイに時間がかかりやすくなるのは確かだ。

マルチステージビルドは、これらの問題に対する構造的な解決策になる。重要なのは、単に「途中のファイルを削除する」ことではない。削除したレイヤーが最終イメージに存在しないように、最初からビルド環境と実行環境を分けて設計することだ。

マルチステージビルドの本質は、捨てられるビルド環境を作ることではない。最終イメージには成果物だけを載せ、本番運用に必要なものだけを残すという、設計思想の転換にある。

ComposerとNode.jsを分離するビルド戦略の設計

Laravelで扱いやすい基本構成は、Composer用、Node.js用、PHPランタイム用の三つのステージに分ける方法だ。

1. Composerステージでvendor/を生成する

2. Node.jsステージでフロントエンドのアセットをビルドする

3. PHPランタイムステージへ、必要な成果物だけをコピーする

Dockerfileでは、ステージに名前を付けておくと意図が読みやすくなる。たとえばComposer用のステージをcomposer_deps、Node.js用をfrontend、最終段をruntimeといった名前にしておけば、後から見ても依存関係が追いやすい。

最終ステージで重要なのは、アプリケーション全体を一度コピーしてから不要なものを削除するのではなく、必要なものを明示的に組み立てることだ。COPY --from=composer_deps /app/vendor ./vendorのように、Composerステージからvendor/だけを受け取る。同じように、Node.jsステージからはpublic/build/だけを受け取る。

この順序には、サイズ以外のメリットもある。どのステージがどの成果物を作るのかが明確になり、ビルドの失敗箇所を切り分けやすい。Composerの依存関係で失敗したのか、フロントエンドのビルドで失敗したのか、PHP拡張機能の導入で失敗したのかを、ログから追いやすくなる。

Composerステージで本番用依存関係だけを作る

Composerステージでは、まずcomposer.jsoncomposer.lockを先にコピーする。アプリケーション全体を先にコピーすると、PHPコードを一行変更しただけで依存関係のインストール層まで無効になるからだ。

依存関係のインストールには、通常次のようなオプションを使う。

composer install --no-dev --prefer-dist --optimize-autoloader --no-scripts

それぞれの役割は異なる。

オプション役割
--no-devrequire-devにあるテストや静的解析用のパッケージを除外する
--prefer-dist可能な場合に配布アーカイブを利用し、不要なGit履歴の取得を避ける
--optimize-autoloader本番向けにオートローダーを最適化する
--no-scriptsインストール中のComposerスクリプト実行を抑え、処理を分離する

--no-scriptsについては、常に付ければ正解というわけではない。Laravelプロジェクトでは、Composerスクリプトによってパッケージ検出やキャッシュ生成を行っている場合がある。これを無効にしたまま、後続の処理を用意しなければアプリケーションが期待どおりに動かないことがある。

ここで大切なのは、セキュリティのために無効化した処理を放置しないことだ。依存パッケージのインストールとLaravel固有の初期化処理を分け、必要な処理だけを、確認できる段階で実行する。自動処理を一括で走らせるより、何が起きているかを把握しやすい。

また、composer installcomposer updateを混ぜないことも基本になる。本番イメージのビルドで依存関係を更新すると、同じコミットから異なる依存関係が生成される可能性がある。ロックファイルを基準にインストールし、依存関係の変更は別のコミットとして管理する方が、ロールバックもしやすい。

Composerの依存パッケージにネイティブ拡張が必要な場合は、Composerステージに必要なビルド環境を用意する必要がある。ここで生成したvendor/だけを最終ステージへ移すなら、Composerステージにコンパイラや開発用ヘッダーが残っていても、ランタイムイメージへは持ち込まれない。

Node.jsステージではビルド成果物だけを受け取る

Node.jsステージでは、最初にpackage.jsonpackage-lock.jsonだけをコピーしてnpm ciを実行する。Laravel本体のPHPファイルや、頻繁に変更されるテンプレートを先にコピーしないことで、依存関係のインストール層をキャッシュしやすくなる。

npm ciは、ロックファイルに従って依存関係をクリーンにインストールするためのコマンドだ。開発者のローカル環境に残った古いnode_modulesを前提にせず、CIと本番ビルドで同じ依存関係を再現しやすい。

ビルドが終わったら、最終ステージへ移すのはpublic/build/のような成果物だけにする。node_modules/を本番のPHPコンテナに持ち込む必要はない。JavaScriptの実行が必要なサーバーサイド構成でない限り、Node.jsも不要だ。

ただし、Laravelのアプリケーションコードをコピーするタイミングには注意がいる。public/全体をコピーした後でビルド成果物を上書きする構成にするのか、アプリケーションコードとビルド成果物を別々に扱うのかを決めておく必要がある。後から何となくCOPY . .を追加すると、.dockerignoreの設定次第でローカルの古いアセットが混入する。

Viteで環境変数をアセットに埋め込む構成では、ビルド時に参照する値の扱いも確認しておきたい。ブラウザーへ配信する値は最終的に利用者から見えるため、秘密情報をフロントエンドのビルド変数へ入れてはいけない。実行時にサーバーだけが必要とする値と、ビルド成果物へ埋め込んでよい値は分ける必要がある。

ランタイムステージは「動くための最小構成」にする

最終ステージのベースには、公式PHPイメージの実在するタグを使う。Debian系を選ぶなら、たとえばphp:8.3-fpm-bookwormが候補になる。php:8.3-fpm-slimというタグを前提にDockerfileを書くのは避けたい。PHP公式イメージには用途別に複数のタグがあるが、すべての組み合わせに「slim」が用意されているわけではないためだ。

タグは、名前の雰囲気ではなく、公式イメージの公開タグとして確認してから採用する。似た名前のイメージが存在していても、PHP公式イメージのタグとして提供されているとは限らない。ベースイメージの存在確認を怠ると、設計以前にCIのビルドが開始できない。

php:8.3-fpm-bookwormは、Alpine系より大きくなりやすい一方、Debian系のパッケージ管理やライブラリ互換性を利用しやすい。Laravelで使うPHP拡張機能が多い場合や、画像処理、国際化、データベース接続などのネイティブ依存を扱う場合には、こちらの方がトラブルを抑えられることがある。

イメージサイズは、タグ名だけで一律に語れない。PHPの公式イメージ本体に加えて、導入した拡張機能、パッケージの残骸、アプリケーションコード、vendor/、ビルド済みアセットが加わるためだ。Docker Hub上の表示サイズ、ローカルでの仮想サイズ、レジストリへの圧縮転送量も一致しない。

そのため、「bookwormなら何MB」「Alpineなら何MB」と断定するより、同じアプリケーション、同じ拡張機能、同じビルド条件で比較する方が実務的だ。ベースイメージだけを差し替えたサイズ比較は、構成の一部しか見ていない。

Alpine Linuxと最適化フラグによるイメージの軽量化

ステージ分離だけでも、Node.jsや開発用Composerパッケージを最終イメージから外せる。さらに軽量化を進めるなら、Alpine Linuxをベースにしたphp:8.3-fpm-alpineが候補になる。

一方、Debian系ではphp:8.3-fpm-bookwormのような公式タグを利用できる。両者の違いを、単純なサイズ競争ではなく、運用上の性質で見ると次のようになる。

比較項目php:8.3-fpm-alpinephp:8.3-fpm-bookworm
ベースの特徴最小構成を作りやすいAlpine LinuxDebian系の標準的なユーザーランド
Cライブラリmusl libcglibc
パッケージ管理apkapt
サイズ傾向小さくなりやすいAlpineより大きくなりやすい
拡張機能の導入パッケージ名やビルド手順の確認が必要Debian系の手順や資料を利用しやすい
トラブル時の調査環境差を意識する必要がある一般的なLinux環境に近く調査しやすい場合がある
向いている構成依存関係を管理できる小さなランタイム拡張機能やネイティブライブラリが多い構成

Alpineの魅力は、ベースが小さいことだけではない。不要なパッケージを入れず、ビルド用の依存関係をランタイムへ残さない構成にすると、イメージの中身を把握しやすくなる。

ただし、Alpineではmusl libcを採用しているため、glibcを前提にしたバイナリやライブラリとの間で差が出ることがある。PHP自体が動くかどうかだけなら問題にならなくても、PECL拡張や画像処理ライブラリ、デバッグツール、外部の実行ファイルを追加したところで詰まるケースがある。

Laravelでよく使われる拡張機能でも、導入方法はベースイメージによって変わる。pdo_mysqlmbstringbcmathopcacheなどの基本的な拡張だけなら比較的組みやすいが、imagickredisなどを追加する場合は、Alpine用のパッケージとビルド手順を先に確認したい。

ビルド用依存関係をランタイムに残さない

Alpineで拡張機能をビルドする場合は、コンパイラやヘッダーファイルなどのビルド用依存関係が必要になる。ここでそれらを通常のパッケージとして追加すると、拡張機能が完成した後も不要なツールが残ってしまう。

apk add --no-cacheを利用してパッケージキャッシュを残さないようにするほか、ビルド専用パッケージを仮想パッケージとしてまとめて導入する方法がある。ビルドが終わった段階で仮想パッケージを削除すれば、コンパイラや開発用ヘッダーをランタイムから取り除ける。

Debian系でも同じ考え方は使える。apt-get updateとパッケージ導入を同じレイヤーで実行し、不要なリストを削除する。ビルド用パッケージと実行時に必要なライブラリを分け、不要なものを最終レイヤーに残さない。

ここで注意したいのは、単純な削除では過去のレイヤーに残ったデータまで消えるわけではないことだ。ひとつのRUNでインストールと削除を行う、あるいはビルドステージ自体を分ける。マルチステージビルドは、このレイヤーの性質を意識しなくても不要なビルド環境を最終イメージから切り離せる点に強みがある。

PHPの最適化はイメージサイズだけではない

--optimize-autoloaderはComposerのオートローダーを本番向けに最適化する。これによって依存パッケージの読み込みを効率化できるが、Dockerイメージが目に見えて小さくなる機能ではない。軽量化と実行性能の改善は、分けて考えた方がよい。

PHP側ではOPcacheも検討対象になる。OPcacheの設定は、PHP-FPMのワーカー構成やデプロイ方式と関係するため、Dockerfileだけで完結させようとしない方がよい。コードをイメージに焼き込んで変更しない運用なら、ファイル変更を前提にした設定と相性が悪いこともある。

また、Laravelの設定・ルート・イベントキャッシュをビルド時に生成するか、コンテナ起動時に生成するかも決めておく必要がある。環境変数や外部サービスの接続情報をビルド時に取り込む設計にすると、環境ごとの再利用が難しくなる。開発、ステージング、本番で同じイメージを使い、設定だけを実行時に注入できる構成の方が、リリースの見通しはよい。

「軽ければ正義」ではない。Alpineの採用は、互換性リスクを許容できるかどうかの判断でもある。Laravel本体と基本的な拡張だけなら軽量化しやすいが、ネイティブ依存が多いならbookwormの方が無難なことも多い。

.dockerignoreの活用とセキュリティリスクの低減

マルチステージビルドを導入しても、ビルドコンテキストに不要なファイルを含めていれば、最初から効率が悪い。.dockerignoreは単なる容量削減の設定ではなく、意図しない情報をDockerビルドへ渡さないための境界線だ。

Laravelプロジェクトでは、少なくとも次のようなファイルやディレクトリを検討する。

  • .env
  • .git
  • node_modules
  • vendor
  • tests
  • storage/logs
  • storage/framework/cache/data
  • .idea
  • .vscode
  • ローカル専用のDocker Compose設定
  • 開発環境用の一時ファイルやログ

.env.exampleまで除外する必要はない。設定項目の見本として必要になることがあるからだ。一方、実際の.envにはアプリケーションキー、データベース接続情報、外部APIの認証情報などが含まれる可能性がある。これをCOPY . .で取り込むのは、ローカル開発の癖が本番イメージへそのまま持ち込まれる典型的な事故だ。

ただし、.dockerignoreに書いたから完全に安全というわけでもない。ビルドコンテキストに含めないことと、別の手段でシークレットを渡すことは分けて考える必要がある。DockerfileのARGENVに秘密情報を設定すると、履歴やメタデータから参照できる可能性がある。

ビルド時にプライベートなComposerリポジトリへ接続する場合も、アクセストークンを単純なビルド引数として渡す構成は避けたい。BuildKitのシークレットマウントなど、ビルド履歴や最終レイヤーに値を残しにくい仕組みを使う方が安全だ。

COPY . .を使うなら、何をコピーするかを把握する

COPY . .は短く書けるが、コピー対象が広すぎる。.dockerignoreが不十分だと、次のようなファイルまでイメージに入る。

  • Gitの履歴や設定
  • ローカルの環境変数
  • テストのフィクスチャ
  • IDEの設定
  • 大きなログファイル
  • ホスト環境で生成されたvendor/
  • ホスト環境と異なる依存関係を持つnode_modules/

依存関係をComposerステージとNode.jsステージで生成するなら、ローカルのvendor/node_modules/は基本的にコンテキストから除外したい。ホスト側で生成したファイルをそのままコピーすると、OSやCPUアーキテクチャの違いが混ざり、再現性も崩れる。

アプリケーションコードをコピーした後で、ビルドステージから成果物を上書きする構成を採る場合も、コピーの順序を意識する。どのファイルが最終的に採用されるのか曖昧なDockerfileは、開発者が変わったときに壊れやすい。

可能なら、最終ステージではコピーする対象をある程度明示したい。Laravelのルートディレクトリには、開発用の設定、テスト、ドキュメント、ローカル専用のスクリプトなどが増えやすい。すべてを実行時に必要なファイルとして扱うのではなく、アプリケーションの起動に必要なものと、開発を支えるものを切り分けるだけでも、構成の意図が見えやすくなる。

書き込みディレクトリと権限を設計する

.dockerignorestorage/を除外しすぎると、Laravelが必要とするディレクトリまで消えてしまうことがある。Laravelはログ、キャッシュ、コンパイル済みテンプレートなどをstorage/配下へ書き込むため、ディレクトリが存在しない、またはPHP-FPMの実行ユーザーに権限がない場合は起動後に失敗する。

本番では、イメージ内へログを書き込むのか、標準出力へ流すのかも決めておきたい。コンテナのログ収集基盤を使うなら、ファイルログを永続化するより標準出力へ集約する方が扱いやすい場合がある。逆に、アプリケーションや周辺ツールがファイル書き込みを前提としているなら、ボリュームや起動時のディレクトリ作成が必要になる。

イメージを小さくすることに集中して、権限設定を最後に付け足すと、起動時にchownを実行して毎回時間がかかる構成になりやすい。可能ならビルド中に実行ユーザーと所有権を整え、ランタイムでは必要な範囲だけを書き込み可能にする。rootでPHP-FPMを動かさないことも、本番環境ではサイズとは別の重要な改善になる。

CI/CDパイプラインにおけるビルドキャッシュと運用効率

イメージサイズを縮めても、CI/CDの設計が甘ければ効果は半減する。毎回すべての依存関係をダウンロードし、同じ拡張機能をビルドし直していれば、最終イメージが小さくなっても開発者の待ち時間は減らない。

マルチステージビルドでは、ステージごとの入力を安定させることがキャッシュの基本になる。Composerステージではcomposer.jsoncomposer.lockを先にコピーし、Node.jsステージではpackage.jsonpackage-lock.jsonを先にコピーする。アプリケーションコードを依存関係のインストールより前にコピーしないだけで、コード変更時のキャッシュ無効化を抑えられる。

BuildKitを使える環境では、レジストリやCIサービス側へキャッシュを保存できる。GitHub Actionsのビルドであれば、キャッシュの入出力を指定して、依存関係の取得や中間ステージの処理を再利用する構成が考えられる。

cache-fromcache-toの代表的な指定は次のようなものだ。

cache-from: type=gha

cache-to: type=gha,mode=max

mode=maxは最終ステージだけでなく、中間ステージのキャッシュも対象にする指定だ。ComposerステージやNode.jsステージの再利用を期待するなら、中間ステージのキャッシュを保存する意味がある。

ただし、キャッシュは常に残るとは限らない。CIサービスの保存期間や容量制限、ブランチごとのキー、Dockerfileの変更によって無効になる。キャッシュヒットを前提にしてビルド時間を見積もるのではなく、キャッシュが空でも許容できる時間でビルドが完了する設計にしておきたい。

キャッシュを効かせるDockerfileの順序

依存関係のキャッシュを効かせるには、ファイルの変更頻度に応じてCOPYの順番を考える。

1. Composer関連のマニフェストをコピーする

2. Composerの依存関係をインストールする

3. Node.js関連のマニフェストをコピーする

4. npmの依存関係をインストールする

5. 残りのアプリケーションコードをコピーする

6. フロントエンドをビルドする

7. 最終ステージで成果物を組み立てる

実際には、Laravelのビルドスクリプトや環境依存によって順番を調整する必要がある。たとえば、フロントエンドのビルドでアプリケーション側の設定ファイルを参照するなら、必要なファイルをNode.jsステージへ渡さなければならない。ただし、.envをそのまま渡すのではなく、ビルド時に必要な値と実行時に必要な秘密情報を分けるべきだ。

Dockerfileの一行を変更すると、その行以降のキャッシュが無効になる。RUN命令を何でも一つにまとめればよいわけでも、細かく分ければよいわけでもない。キャッシュさせたい処理と、同時に実行して結果を残したくない処理を見極める必要がある。

Composerとnpmのキャッシュマウント

依存関係のレイヤーキャッシュとは別に、BuildKitのキャッシュマウントを使う方法もある。Composerのダウンロードキャッシュやnpmのキャッシュを、ビルド用の一時領域として再利用する考え方だ。

これにより、Dockerfileのレイヤーが無効になってcomposer installnpm ciを再実行する場合でも、パッケージの取得元から毎回すべてを取り直す必要がなくなることがある。ネットワークが不安定なCIでは、処理時間のばらつきを抑える効果も期待できる。

一方、キャッシュマウントはビルド環境に依存する。ローカルでは効いても、CIの実行基盤が変われば消える可能性がある。キャッシュにアプリケーションの正しさを依存させず、失われても再生成できる補助的な仕組みとして扱うのが安全だ。

Composerのキャッシュを使う場合も、キャッシュに含まれるパッケージそのものを信頼し続けるのではなく、ロックファイルとチェックサムを基準にインストールする必要がある。キャッシュは取得処理を効率化するものであり、依存関係のバージョンを決める仕組みではない。

本当に短くなったのかを測る

軽量化の効果は、イメージの見た目だけで判断しない方がよい。少なくとも次の項目を同じ条件で比較したい。

  • ローカルでのイメージサイズ
  • レジストリへのプッシュ時間
  • 実行環境でのプル時間
  • キャッシュなしのクリーンビルド時間
  • キャッシュありの通常ビルド時間
  • コンテナ起動からヘルスチェック成功までの時間
  • 脆弱性スキャンで検出されるパッケージ数
  • PHP拡張機能や外部ライブラリの動作可否

Alpineへ移行した結果、イメージは小さくなったが、拡張機能のビルドに時間がかかってCI全体では速くならない、ということもある。逆にbookwormのイメージは大きくても、既存のパッケージを利用できるためビルドが安定するかもしれない。

サイズは分かりやすい指標だが、運用コストの一部にすぎない。再現性、復旧の速さ、アップデートのしやすさまで含めて判断しないと、数字のために複雑なDockerfileを抱えることになる。

運用してみて分かった「ハマりポイント」と次に試したいこと

実際に本番運用へ投入すると、Dockerfileを書いている段階では見えなかった落とし穴が出てくる。

まず、.dockerignorestorage/を除外しすぎると、ランタイムでディレクトリが自動生成されず、権限エラーになるケースがある。書き込みが必要なディレクトリをイメージに含めるのか、起動スクリプトで作るのか、ボリュームとして外へ出すのかを決めておきたい。

次に、AlpineイメージでPHP拡張機能を追加する際は、ビルドが通ったことだけで安心しない方がよい。docker-php-ext-installやPECLのログを確認し、実際にLaravelの起動、キュー処理、スケジューラー、画像生成、データベース接続まで動かす必要がある。

特にPHP-FPMでは、ワーカーの実行ユーザー、シグナルの扱い、プロセスの停止方法がコンテナ運用と関係する。Webリクエストだけでなく、キューワーカーやスケジューラーを同じイメージから起動する場合、それぞれに必要な拡張機能と起動コマンドを確認したい。

また、最終イメージにNode.jsを残さない構成では、本番コンテナ上でnpm run buildを実行できない。当然のことだが、従来の運用手順が「サーバーへ入ってビルドする」前提になっていると、移行後に手作業が詰まる。ビルドはCIで完了させ、成果物を含むイメージをデプロイするという流れへ切り替える必要がある。

Laravelのキャッシュ生成も同じだ。環境変数や外部サービスの設定が必要な処理をビルド時に行うのか、起動時に行うのかを曖昧にすると、ステージング用の値が本番イメージへ混入する可能性がある。できるだけ環境に依存しない処理をビルドへ寄せ、環境固有の設定は実行時に注入する方が扱いやすい。

Alpineを採用してPHPコンテナを小さくしても、NginxやPostgreSQLなど周辺コンテナを含めたシステム全体の運用コストが大きいこともある。PHPコンテナだけを小さくしても、データベースのバックアップ、ログ保存、監視、ネットワーク転送が自動的に軽くなるわけではない。

コンテナ軽量化はゴールではなく、運用を見直す入口だ。デプロイが速くなって初めて、CI/CD全体やマルチサービス構成の改善に進める。

次に試したいのは、BuildKitの--mount=type=cacheを使ったComposerとnpmのキャッシュ永続化だ。現在の構成では、Dockerのレイヤーキャッシュが無効になると依存関係の取得からやり直す。パッケージマネージャー単位のキャッシュを組み合わせれば、クリーンビルドに近い状況でもダウンロード量を抑えられる可能性がある。

さらに、Kubernetesへの移行を見据えたヘルスチェックも整えたい。Laravelにはアプリケーションの状態確認に使える/upルートがあるため、これをコンテナのヘルスチェックやオーケストレーター側のLiveness Probe、Readiness Probeとどう接続するかを決めておく。

ただし、ヘルスチェックでデータベースや外部APIまで毎回確認するかどうかは慎重に考える必要がある。依存先の一時的な障害を、アプリケーションコンテナ自体の再起動条件にしてしまうと、障害が連鎖することがある。軽量な生存確認と、サービス全体の準備状態を確認するチェックは、用途を分けた方がよい。

Laravel本番環境での落としどころ

マルチステージビルドは、Laravelの本番環境向けDockerfileにおいて、比較的効果を説明しやすい改善だ。ComposerとNode.jsをビルドステージへ閉じ込め、最終ステージへvendor/とビルド済みアセットだけを渡す。これだけでも、本番で使わないツールや依存関係を取り除ける。

そのうえでAlpineを使えば、さらに小さなイメージを目指せる。ただし、Alpineは「PHPイメージを小さくするための交換条件」でもある。musl libcや拡張機能のビルド、ネイティブライブラリの互換性を確認できる体制がなければ、サイズの差よりもトラブル対応の負担が大きくなる。

Debian系を使う場合は、存在する公式タグを選ぶことが前提になる。php:8.3-fpm-bookwormのようなタグを基準に構成し、ベースイメージのサイズは固定値ではなく、同じ条件で比較する。特定のタグ名から想定サイズを断定するのではなく、実際にビルドしたイメージと転送時間を測るべきだ。

個人開発では、最初から極限まで削る必要はない。まずは次の順番で整理すると、無理のない改善になりやすい。

1. 本番イメージからNode.jsとnode_modules/を外す

2. Composerのrequire-dev依存を外す

3. .dockerignoreで秘密情報とローカル生成物を除外する

4. ビルド用依存関係とランタイム用依存関係を分離する

5. 公式タグとPHP拡張機能の互換性を確認する

6. BuildKitのキャッシュでCIの再ビルドを効率化する

7. サイズ、ビルド時間、プル時間、脆弱性を同じ条件で測る

この順番なら、Alpineへ移行しなくても大きな改善が得られることがある。逆に、最初からベースイメージだけを小さくしても、COPY . .で不要なファイルを取り込み、ビルド用ツールを残していれば効果は限定的だ。

マルチステージビルドは、銀の弾丸ではない。だが、Laravelを本番運用する個人開発者が一度経験しておく価値は高い。大きくなったイメージを整理する過程で、Dockerのレイヤー、依存関係の再現性、ビルドキャッシュ、ランタイム分離、シークレット管理をまとめて見直せるからだ。

最終的に選ぶべきDockerfileは、最も小さいものではない。変更を安全にリリースでき、障害時に原因を追いやすく、依存関係を再現できるものだ。その条件を満たしたうえで不要なものを削るなら、軽量化には十分な意味がある。

Related reading: Docker導入の分岐点:個人開発でコンテナ化が必要になる基準 and Laravel 11のミドルウェア登録で判明した新設計と移行の注意点.

よくある質問

LaravelのDockerイメージが1GBを超えてしまう主な原因は何ですか?
Node.js、npm、Composer、開発用パッケージ、ビルド用ツールなど、本番環境では不要なものがすべて同じコンテナに含まれていることが主な原因です。
マルチステージビルドでComposerとNode.jsを分離するメリットは何ですか?
最終イメージに本番用依存関係とビルド済みアセットのみを含めることで、イメージサイズを削減し、攻撃対象領域を減らし、ビルドの失敗箇所を切り分けやすくなります。
Alpine LinuxとDebian系(bookwormなど)のどちらを選ぶべきですか?
軽量化を優先するならAlpineですが、拡張機能の互換性やトラブル時の調査のしやすさを重視するなら、Debian系の方が運用コストを抑えられる場合があります。
Dockerビルドのキャッシュを効率化するにはどうすればよいですか?
依存関係のインストールに必要なマニフェストファイルを先にコピーし、アプリケーションコードのコピーを後回しにすることで、コード変更時のキャッシュ無効化を最小限に抑えられます。
.dockerignoreには何を含めるべきですか?
.env、.git、node_modules、vendor、tests、ローカル専用のDocker Compose設定やIDE設定など、本番環境で不要なファイルや機密情報を含めるべきです。

参考情報