PHP-FPM+nginx構成でLaravelをDockerで動かすまでに理解したことをまとめる

2025/03/09 公開
PHP
Laravel
Docker

今年でWebエンジニア歴が1年になりました。そろそろDockerについて理解しないとなと思い、基礎を一通り勉強しました。成果物としてLaravelの環境構築をしようとしましたが、全く手が動きませんでした。ネットで検索したり、実務のDockerfileを参考にしたりしてみましたが、拡張機能やカスタム設定ファイルが多く、最低限の環境が分かりませんでした。結果、Docker以外にも様々な知識を網羅的に知る必要があったため、そこら辺をメモとして残していきます。もっとこうした方がいいよ等アドバイスあれば教えてください。

この記事の目標

サーバーアーキテクチャやPHPの実行方法の基礎から理解を深めて、最終的にDockerでシンプルな開発環境(PHP-FPM+nginx)を構築します。

完成系

先に完成系を記載しておきます。

構成はPHP-FPM+nginxです。理由はよく見る構成なのでやってみたかったからです。

なぜphp artisan serveではダメか

ネットで「Laravel Docker 環境構築」と検索した際にPHP-FPMとnginxもしくはmod_phpとApacheの構成が多く見られました。そもそも、Dockerfile内でphp artisan serveで開発サーバを起動すればWebサーバは不要ではと思ったので、その辺から調べ始めました。

PHPのドキュメントには下記のような記載があります。

この機能は 実験的なもの であり、 本番環境で使うことを意図した機能では ありません。 ビルトインウェブサーバーは本番環境で使うものでは_ありません_。

でもなんで?

このウェブサーバーは単一のシングルスレッドプロセスしか実行しないので、 リクエストがブロックされると、PHP アプリケーションはストールします。

https://www.php.net/manual/ja/features.commandline.webserver.php

この辺が怪しそう。まずは分からない単語を調べる。

プロセスとスレッド

以下簡単な説明です。

プロセス:

  • プログラムの実行単位
  • プロセス同士はメモリ空間を共有できない

スレッド:

  • プロセスを構成する処理の単位
  • スレッド同士はメモリ空間を共有できる

1プロセスに対して1スレッドのみの場合はスレッド=プロセスと言えるので、マニュアルにある「単一のシングルスレッドプロセス」は、1プロセスと考えれば良さそうです。

反復サーバと並行サーバ

サーバは反復サーバと並行サーバの2つに分けられます。

反復サーバ:

クライアントからの要求に対して、自らがその処理を行なって結果をクライアントに返します。つまりシングルプロセス・シングルスレッドなので、php artisan serveで起動できるサーバはこちらに当たります。1つ目に要求された処理が終わるまで、2つ目の要求は順番待ちになってしまいます。

並行サーバ:

クライアントからの要求に対してfork()システムコールで子プロセスを作って処理を委譲します。親プロセスのサーバは別クライアントからの要求を待つことができるので、順番待ちになりません。システムコールとは簡単にいうとカーネルに仕事を頼むAPIです。man 2 forkで下記を確認できます。

FORK(2)                       System Calls Manual                      FORK(2)                                                                         

NAME
     fork – create a new process

SYNOPSIS
     #include <unistd.h>

     pid_t
     fork(void);
...
(略)

つまり、php artisan serveで起動できるサーバは反復サーバにあたるので、本番環境のように何個もリクエストを捌く必要がある場合には適していません。そこで、前段にApacheやnginxなどのWebサーバ(並行サーバ)を置いてリクエストを並行処理できるように解決するようです。

では、nginxはどのように並行処理を実現しているのでしょうか。今回は割愛しますが下記が参考になりました。

このように、コンテナをそのままデプロイすることを考えると、並行処理のためにWebサーバが必要ということでした。逆にローカルでサクッと動かしたい場合は開発サーバをDockerfileで起動する方法もダメではないかもしれません。

PHPの実行方法

サーバがリクエストを受けたあとプロセス(PHPプログラム)は誰が起動しているのでしょうか。

PHPの実行方法に関してマニュアルには以下のように書かれています。

Web サーバーと PHP を自分でセットアップする場合、 サーバーに PHP を組み込む方法が 2 種類あります。 多くのサーバーに対して、各サーバー独自のモジュールインターフェイス (SAPI とも呼ばれます) を通じて、ダイレクトに PHP を動作させることができます。 Apache、Microsoft Internet Information Server、 Netscape、iPlanet サーバーなどがサポートされています。 PHP がモジュールのサポートをしていない Web サーバーに対しては、 CGI もしくは FastCGI プロセサとして PHP を使用することができます。 つまり、PHP ファイルへのリクエストの処理を、 PHP のコマンドライン版の実行ファイルを使って行うよう Web サーバーを設定することができます。

https://www.php.net/manual/ja/install.general.php

つまり、下記に2つになりそうです。

  • サーバー独自のモジュールインターフェイスを使用する
  • CGI もしくは FastCGIプロセサとして使用する

サーバー独自のモジュールインターフェイスを使用する

Webサーバ(のモジュールインターフェース)が直接PHPを起動します。

Apacheを使用する場合はmod_phpと呼ばれるモジュールインターフェースが用意されており、WebサーバのプロセスでPHPを実行します。こちらの方がコンテナ1つで済むので手軽そうです。

CGI もしくは FastCGIプロセサとして使用する

CGIもしくはFastCGIサーバがPHPを起動します。

CGIとはWebサーバと他プログラムとのデータの送受信の規格です。CGIサーバはWebサーバからリクエストを受け取り、サーバとは別プロセスでPHPを実行します。CGIはリクエストごとにプロセスを生成するので、上記のモジュールインターフェースを利用する方法と比較して処理が遅くなります。それを解決するのが、FastCGIです。事前にfork()しておきプロセスを使い回すことで、プロセス生成のオーバーヘッドを削減しています。

nginxを使用する場合はモジュールがないので2つ目の方法となりそうです。ただし、Apache でもこの方式を取ることができるようです。

PHP-FPM

FPM (FastCGI Process Manager) は、PHP における FastCGI 実装です。

https://www.php.net/manual/ja/install.fpm.php

FastCGIリクエストはこのPHP-FPMで受けてPHPを実行します。実際のリクエストサイクルについては下記がとても参考になりました。

このように、PHPの実行方法はサーバー独自のモジュールインターフェイスを使用する。CGI もしくは FastCGIプロセサとして使用する。の2つがありました。Webサーバにnginxを採用する場合はnginxからPHP-FPMにFastCGI通信でリクエストをして別プロセスでPHPを実行します。なるほど、だからPHP-FPM + nginxの構成になるのか!

Dockerfileの作成

色々わかってきたのでDockerfileを作成してみます。

ディレクトリ構成は下記としています。

❯ tree -L 3 .
.
├── compose.yaml
├── containers
│   ├── nginx
│   │   └── nginx.conf
│   └── php
│       └── Dockerfile
└── src
    └── // Laravelアプリケーション

PHPのDockerfileは下記のようにしています。

FROM php:8.4-fpm

RUN apt-get update && apt-get install -y \
libzip-dev \
&& docker-php-ext-install zip pdo_mysql

WORKDIR /var/www

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
FROM php:8.4-fpm

PHP-FPMの公式imageを使用しています。

RUN apt-get update && apt-get install -y \
libzip-dev \
&& docker-php-ext-install zip pdo_mysql

APTの更新とパッケージのインストールを行い、PHPの拡張機能をインストールしています。APTはDebian系Linuxディストリビューションのパッケージ管理システムです。(macのbrew的な?)docker-php-ext-install はPHP公式イメージが提供するPHP拡張のインストールツールです。Composerで入れるライブラリを解凍するためのzipとMySQLを操作するためのpdo_mysqlをインストールしています。apt-get install しているlibzip-devパッケージはそのzip拡張を使用するために必要です。

Laravelのサーバ要件を見るといくつかの拡張機能が必要そうでしたが、PHPのimageにデフォルトで入っているようなので別途インストールは不要そうでした。php -mで確認できます。

❯ docker compose exec php php -m
[PHP Modules]
Core
ctype
curl
date
dom
fileinfo
filter
hash
iconv
json
libxml
mbstring
mysqlnd
openssl
pcre
PDO
pdo_mysql
pdo_sqlite
Phar
posix
random
readline
Reflection
session
SimpleXML
sodium
SPL
sqlite3
standard
tokenizer
xml
xmlreader
xmlwriter
zip
zlib

[Zend Modules]
WORKDIR /var/www

ワーキングディレクトリの設定をしています。

Dockerfile 内で以降に続く RUN 、 CMD 、 ENTRYPOINT 、 COPY 、 ADD 命令の処理時に(コマンドを実行する場所として)使う 作業ディレクトリworking directory を指定します。

https://docs.docker.jp/engine/reference/builder.html#workdir

Laravelなどのアプリケーションのコードは/var/www以下に置かれます。なぜ/var/wwwなのかと疑問に思いましたが、同じような質問がありました。

本来は/srvが望ましいが、OS側が勝手に使えないため、「仕方なくデフォルト設定を置ける場所」 として/var/wwwが使われている。だそうです。ディレクトリツリーの標準規格であるFHSでは/varには本来、一時的なログファイルなど可変データファイルが置かれるとされています。

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

Composerのインストールはマルチステージビルドを使用しています。

マルチステージビルドを利用することでベースとなるイメージを作成し、それを再利用することによりイメージ毎に不要なファイルが含まれることを防ぐことができイメージサイズの削減が可能になります。

https://rarejob-tech-dept.hatenablog.com/entry/2020/07/31/190000

Composerはインストールする際にインストーラのハッシュ値とオリジナルのファイルを比較してファイルの破損がないかをチェックします。このハッシュ値はバージョン毎にかわるのでバージョンが上がるたびにDockerfileを書き換えなければなりません。

マルチステージビルドにより、Composerのイメージから/usr/bin/composerだけをPHPコンテナの中にコピーできるので、いきなりcomposerコマンドを使用できます。ハッシュ値を気にする必要もありません。

compose.yamlの作成

一気に複数コンテナを起動したいので、compose.yamlを作成します。

services:
  php:
    build:
      context: .
      dockerfile: ./containers/php/Dockerfile
    volumes:
      - ./src:/var/www

  web:
    image: nginx:1.27.4
    ports:
      - "8080:80"
    volumes:
      - ./src:/var/www
      - ./containers/nginx/nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - php

php側はportの指定をしなくてもポート9000番で起動します。

nginxは設定ファイルをコンテナに置きたいだけなので、Dockerfileとして切り出していません。

    volumes:
      - ./src:/var/www
      - ./containers/nginx/nginx.conf:/etc/nginx/nginx.conf

こちらでLaravelのファイルと設定ファイルをバインドマウントしています。はじめsrc以下をマウントしておらず、1敗しました。

その設定ファイルは下記です。

events {
}
http {
    server {
        listen 80;
        listen [::]:80;
        server_name example.com;
        root /var/www/public;

        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";

        index index.php;

        charset utf-8;

        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; }

        error_page 404 /index.php;

        location ~ ^/index\.php(/|$) {
            fastcgi_pass php:9000;
            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            include fastcgi_params;
            fastcgi_hide_header X-Powered-By;
        }

        location ~ /\.(?!well-known).* {
            deny all;
        }
    }
}

実は上記はLaravelの公式ドキュメントに記載の設定ファイルです。nginxがリクエストをLaravelのエントリポイントであるpublic/index.phpに送信するように設定しています。

詳しい内容については、超絶丁寧に読み解いてくれている記事があるのでそちらをご覧ください。

下記が今回の実装に合わせた変更点です。

root /var/www/public;

Laravelのドキュメントルートを指定しています。Laravel特有の事情。

fastcgi_pass php:9000;

PHP-FPMのコンテナと通信するためにcompose.yamlのservice名とポート番号を指定しています。

MySQLとの接続

DBとの接続も行いたいので、compose.yamlに下記を追記します。

services:
(略)
...

  db:
    image: mysql:8.0.41
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: laravel_database
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

db-dataという名前でMySQLのデータをボリュームマウントします。ボリュームマウントとはDocker Engineにボリュームを作成してコンテナのデータを外部に逃して永続化する方法です。こうすることで、コンテナを落としてもDBの内容を保持することができます。

Laravel側の環境変数は下記のようにしています。

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel_database
DB_USERNAME=docker
DB_PASSWORD=docker

DB_HOSTはcompose.yamlのDBのservice名になるので注意が必要です。

コンテナを起動してみる

コンテナを起動します。

❯ docker compose up -d

migrationを実行します。

❯ docker compose exec php php artisan migrate

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ................................................................................................ 75.85ms DONE
  0001_01_01_000001_create_cache_table ................................................................................................ 19.91ms DONE
  0001_01_01_000002_create_jobs_table ................................................................................................. 46.23ms DONE

localhost:8080にアクセスします。

勝ち。Laravel12のこの画面初めて見ました。

まとめ

サーバーアーキテクチャやPHPの実行方法から理解していき、LaravelをDockerで動かすことができました。分からないことが分かるようになった代わりにさらに知りたいことも増えてしまいましたが、Dockerfileをコピペしていた時に比べたらマシだなと思っています。次は今回できなかったデプロイまでやりたいです。また、FrankenPHPなどの並行プログラミングを実装したモダンなWebサーバを使用した環境構築もしてみたいです。

参考