商用環境でも使っている Laravel 用 php-fpm イメージの Dockerfile レシピ

# これは何

Laravel 用 php-fpm イメージの Dockerfile。
（多少はフォーマット変わろうとも）色んなところでずっと使いまわししそうなのでメモ。

完全に個人の秘伝のタレ化するよりは情報公開したほうが自社にとっても利益があるだろうと判断（笑）
異論は無限に受け付けるので改善点などあればコメントください。

# レシピ

## `Dockerfile`

```Dockerfile:&lt;project&gt;/docker/php-fpm/Dockerfile
FROM golang:1.15 as http2fcgi_build

# http2fcgi のビルド
RUN GO111MODULE=on go get -v -ldflags '-w -s' github.com/alash3al/http2fcgi@v1.0.0

FROM php:7.4-fpm-alpine as php_runtime

# Goバイナリが実行できるようにする
# https://stackoverflow.com/questions/34729748/installed-go-binary-not-found-in-path-on-alpine-linux-docker
RUN mkdir /lib64 \
 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2

# http2fcgi のインストール
COPY --from=http2fcgi_build /go/bin/http2fcgi /bin/http2fcgi

# Composer のインストール
COPY --from=composer:1 /usr/bin/composer /usr/bin/composer
RUN composer global require hirak/prestissimo

# Git と PHP 拡張のインストール
RUN set -eux \
 && apk add --update --no-cache git autoconf g++ libtool make libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev \
 && pecl install redis \
 && docker-php-ext-configure gd --with-jpeg=/usr \
 && docker-php-ext-configure opcache --enable-opcache \
 && docker-php-ext-install opcache bcmath pdo_mysql gd exif zip \
 && docker-php-ext-enable redis \
 && apk del autoconf g++ libtool make \
 && rm -rf /tmp/*

# 【オプション】 xdebug のインストール
# 必要に応じて PHP_DEBUGGER="xdebug" を与える
# 但し xdebug は非常に重いのでデフォルトで有効化しない
ARG PHP_DEBUGGER=""
RUN set -eux \
 && apk add --update --no-cache autoconf g++ libtool make \
 && if [ "$PHP_DEBUGGER" = "xdebug" ]; then \
      pecl install xdebug; \
    fi \
 && apk del autoconf g++ libtool make \
 && rm -rf /tmp/*

# 【オプション】 pcov のインストール
# 必要に応じて PHP_COVERAGE_DRIVER="pcov" を与える
ARG PHP_COVERAGE_DRIVER=""
RUN set -eux \
 && apk add --update --no-cache autoconf g++ libtool make \
 && if [ "$PHP_COVERAGE_DRIVER" = "pcov" ]; then \
      pecl install pcov; \
      docker-php-ext-enable pcov; \
      echo "pcov.directory = /code/app" >> $PHP_INI_DIR/conf.d/docker-php-ext-pcov.ini; \
    fi \
 && apk del autoconf g++ libtool make \
 && rm -rf /tmp/*

# Composer で利用する GitHub トークンの設定
# 必要に応じて GITHUB_TOKEN を与える
ARG GITHUB_TOKEN="****************************************"
RUN set -eux \
 && mkdir -p ~/.composer \
 && printf '{"github-oauth":{"github.com":"%s"}}' $GITHUB_TOKEN > ~/.composer/auth.json

WORKDIR /code
ENTRYPOINT ["php"]
CMD []

FROM PHP_RUNTIME as laravel_application_mounted

# アプリケーションコンテナのみで使用する起動スクリプトの設置
# 必要に応じて PHP_INIT_SCRIPT を与える
ARG PHP_INIT_SCRIPT="deploy"
COPY docker/php-fpm/scripts/$PHP_INIT_SCRIPT/*.sh /bin/
ENTRYPOINT []
CMD ["/bin/init.sh"]

FROM laravel_application_mounted as laravel_application_bundled

# Composer 依存パッケージ定義のコピー
COPY composer.* /code/

# Composer 依存パッケージをアプリケーションから分離して先にインストール（ビルド時間短縮のため）
RUN composer install --working-dir=/code --no-scripts --no-autoloader

# アプリケーションのコードをコピー（.dockerignore で vendor や .git は除外されている）
COPY . /code

# オートロードファイルの生成とストレージディレクトリのパーミッションの変更
RUN set -eux \
 && composer dump-autoload --working-dir=/code --no-scripts \
 && chmod -R a=rwX /code/storage
```

### ポイント

- `Dockerfile` は1つだが，マルチステージビルドを使って以下のパターンを網羅している。何も `ARG` を与えずにビルドした場合はデプロイ向けになるようにしている。
  - `php_runtime` … PHP ランタイムのみ （ツール向け）
  - **`laravel_application_mounted` … アプリケーションのソースコードをマウントして使用する （ローカル向け）**
  - **`laravel_application_bundled` … アプリケーションのソースコードをバンドルして使用する （デプロイ向け）**
- **ビルド高速化のため，パッケージインストールは `composer.json` `composer.lock` に変更があった場合にしか走らないように工夫している。**
  1. `composer.json` と `composer.lock` をコンテナ内にコピー
  2. `composer install --no-scripts --no-autoloader` で，スクリプト実行無しおよびオートローダ作成無しにして， `vendor` ディレクトリへのファイル投入だけを目的として実行。
  3. アプリケーションのコードをマウント。この際 `vendor` ディレクトリは除外されている。
  4. `composer dump-autoload` で後からオートローダを作成。
- **パッケージインストール高速化のため， Composer の並列インストールプラグイン [hirak/prestissimo](https://github.com/hirak/prestissimo) を使用している。**
- **GitHub のプライベートリポジトリからパッケージをインストールできるように， 会社の共用アカウントで発行したこの目的専用の `GITHUB_TOKEN` を `ARG` のデフォルト値としてハードコーディング。**
- エクステンションとして，さまざまな PHP アプリケーションで頻繁に必要になりそうな `opcache` `bcmath` `pdo_mysql` `gd` `exif` `zip` あたりをカバー。必要に応じて追加と削除の余地あり。
- デバッガとして [xdebug/xdebug](https://github.com/xdebug/xdebug) を採用。
- カバレッジドライバとして [krakjoe/pcov](https://github.com/krakjoe/pcov) を採用。
- **OPCache のコントロールユーティリティ [appstract/laravel-opcache](https://github.com/appstract/laravel-opcache) および Golang 製の軽量 FastCGI リバースプロキシ [alash3al/http2fcgi](https://github.com/alash3al/http2fcgi) を採用。**
  - PHP 7.4 のプリロード対応が入った後は不要になるが，現状 Laravel ではまともに動かない。
  - Golang バイナリのビルドのためにもマルチステージビルドを使用している。
- `composer install` の `--no-dev` フラグは，  `require-dev` で入れたパッケージを継承して `src` 配下に配置していると `appstract/laravel-opcache` によるキャッシュ生成のタイミングで事故ってハイリスクなので，敢えて使っていない。開発用パッケージをバンドルしても並列インストールプラグインを入れていれば十分速いため，除外するメリットがあまりない。

## 起動スクリプト

```bash:&lt;project&gt;/docker/php-fpm/scripts/local/init.sh
#!/bin/sh -eux

composer run-script env:init

{
  while { http2fcgi --fcgi tcp://localhost:9000 \
                    --http localhost:8000 \
                    --root /code/public & }; \
    sleep 1; \
    ! pgrep http2fcgi; do
    echo >&2 'Waiting http2fcgi ready...'
  done
} &

# -d でオプションを受け取れるようにする
exec php-fpm "$@"
```

```bash:&lt;project&gt;/docker/php-fpm/scripts/ci/init.sh
#!/bin/sh -eux

composer run-script env:init
composer run-script post-autoload-dump

exec php-fpm
```

```bash:&lt;project&gt;/docker/php-fpm/scripts/deploy/init.sh
#!/bin/sh -eux

composer run-script post-autoload-dump

php artisan config:cache
php artisan route:cache

{
  while { http2fcgi --fcgi tcp://localhost:9000 \
                    --http localhost:8000 \
                    --root /code/public & }; \
    sleep 1; \
    ! pgrep http2fcgi; do
    echo >&2 'Waiting http2fcgi ready...'
  done

  php artisan opcache:compile
} &

exec php-fpm
```

```json:&lt;project&gt;/composer.json
{
    "scripts": {
        "post-install-cmd": [
            "@composer env:init",
        ],
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover"
        ],
        "env:init": "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
    },
    "config": {
        "preferred-install": "dist",
        "sort-packages": true,
        "optimize-autoloader": true
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}
```

### ポイント

- **イメージの再利用性を高めるため， デプロイ環境でも「開発」「ステージング」「プロダクション」で分岐が発生しそうな処理に関しては， `CMD` 実行時まで遅延させている。**
- 3つの環境向けのスクリプトを網羅している。（下部のテーブルに詳細）
  - ローカル環境
  - CI 環境
  - デプロイ環境
- `php-fpm` は接続準備完了状態になるまで少し遅延があるため，リバースプロキシ `http2fcgi` の起動を繰り返し試行させている。
- **`CMD` 実行時までファイルをコピーする操作が遅延されていてパーミッション周りが厄介であるため， 実行ユーザを変更せず敢えて `root` のままにしている。** PHP ファイルが動的に作成されうる WordPress などの CMS などを使用しない限り，コンテナ環境ではこれでも十分安全であると判断した。

| 機能 | ローカル | CI | デプロイ | （補足） |
|:---|:---:|:---:|:---:|:---|
| `php-fpm` 起動オプションの受け取り | ✔ | | | ローカル環境のみ， `-d` オプションで Xdebug を有効化できるようにする |
| `.env` の生成 | ✔ | ✔ | | ローカル環境では `.env` を自動生成する。<br><br>CI 環境ではローカル用 `.env` と `phpunit.xml` の設定をマージして使用する想定のため，同じく自動生成する。<br><br>デプロイ環境では Kubernetes ConfigMap から環境変数を実行時に注入するため，生成しない。|
| FastCGI リバースプロキシの起動 | ✔ | | ✔ | CI 環境では CLI 実行のみで FastCGI のリクエストが飛んでこないため，実行しない。<br><br>ローカル環境では不具合調査のために Nginx を使用せずリクエストを送ることがあるため，実行する。<br><br>デプロイ環境では OPCache の能動的なキャッシュ生成のために必要なので実行する。|
| パッケージディスカバリなどの実行 | | ✔ | ✔ | ローカル環境では `composer install` はビルド時には呼ばれないため，実行しない。<br><br>CI 環境やデプロイ環境では実行する。|
| 設定ファイルのキャッシュ生成 | | | ✔ | ローカル環境や CI 環境では開発中に設定項目を変更したり `phpunit.xml` からの環境変数取り込みがあるため，キャッシュしない<br><br>デプロイ環境では都度新規でソースコードがチェックアウトされ，テストも不要なのでキャッシュする。|
| OPCache の能動的なキャッシュ生成 | | | ✔ | ローカル環境では開発中にソースを変更するため，実行しない。<br><br>CI 環境では FastCGI リバースプロキシが起動していないため，実行しない。<br><br>デプロイ環境では実行する。|

## `docker-compose.yml`

```yaml:&lt;project&gt;/docker-compose.yml
# ローカル用の基本形

version: '3.4'

services:
  nginx:
    image: nginx:latest
    volumes:
      - ./docker/nginx/conf.d/local.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8000:80"

  php-fpm:
    build:
      context: .
      dockerfile: docker/php-fpm/Dockerfile
      target: laravel_application_mounted
      args:
        PHP_DEBUGGER: xdebug
        PHP_INIT_SCRIPT: local
    volumes:
      - ./docker/php-fpm/conf.d/local.ini:/usr/local/etc/php/conf.d/custom.ini:ro
      - .:/code:cached
    environment:
      - PHP_IDE_CONFIG=serverName=<project>
```

```yaml:&lt;project&gt;/docker-compose.nfs.yml
# NFS を使用したい場合は docker-compose.override.yml にコピーして利用
# 他の拡張設定と共存する場合は以下のいずれかを採る
#   - 拡張設定をマージして docker-compose.override.yml に書く
#   - docker-compose -f docker-compose.yml -f docker-compose.nfs.yml のように複数 -f で並べる

version: '3.4'

services:
  php-fpm:
    volumes:
      - code:/code

volumes:
  code:
    driver: local
    driver_opts:
      type: nfs
      o: addr=host.docker.internal,actimeo=1,nolock
      device: ":${PWD}"
```

```yaml:&lt;project&gt;/docker-compose.xdebug.yml
# xdebug を使用したい場合は docker-compose.override.yml にコピーして利用
# 他の拡張設定と共存する場合は以下のいずれかを採る
#   - 拡張設定をマージして docker-compose.override.yml に書く
#   - docker-compose -f docker-compose.yml -f docker-compose.xdebug.yml のように複数 -f で並べる

version: '3.4'

services:
  php-fpm:
    command:
      - '/bin/init.sh'
      - '-d'
      - 'zend_extension=xdebug.so'
```

```&lt;project&gt;/docker-compose.ci.yml
# CI のテスト用に独立して利用
#
# - ボリュームマウントを無効化する方法が無いためオーバーライドは諦める
# - また Circle CI が target を使用できるバージョン 3.4 をサポートしていない

version: '3.2'

services:

  php-fpm:
    build:
      context: .
      dockerfile: docker/php-fpm/Dockerfile
      args:
        PHP_COVERAGE_DRIVER: pcov
        PHP_INIT_SCRIPT: ci
```

```&lt;project&gt;/docker-compose.composer.yml
# ローカル環境における composer インストーラとして独立して利用

version: '3.4'

services:

  composer:
    build:
      context: .
      dockerfile: docker/php-fpm/Dockerfile
      target: php_runtime
    volumes:
      - ./docker/php-fpm/conf.d/local.ini:/usr/local/etc/php/conf.d/custom.ini:ro
      - .:/code:delegated
    entrypoint: ['composer']
    command: []
```

```bash:&lt;project&gt;/d
#!/bin/sh

docker-compose exec php-fpm "$@"
```

```bash:&lt;project&gt;/xdebug
#!/bin/sh

docker-compose exec php-fpm php -dzend_extension=xdebug.so "$@"
```

```bash:&lt;project&gt;/composer
#!/bin/sh

docker-compose -f docker-compose.composer.yml run --rm composer "$@"
```

### ポイント

- **デプロイ環境では Kubernetes を使用しているため， Docker Compose の使用はローカル環境と CI 環境のみを対象としている。**
- Docker for Mac でのパフォーマンスチューニングのために， NFS で設定をオーバーライドする選択肢を与えている。
- `xdebug` を有効化する選択肢を与えている。
  - SAPI が `php-fpm` である場合は， NFS 同様にオーバーライドして起動する。
  - SAPI が `php-cli` である場合は， `./d` の代わりに `./xdebug` を使うことで有効化してコマンドを実行する。
- `pcov` は CI 環境でのみ有効化する。
- ローカルでは `LARAVEL_APPLICATION_MOUNTED` をターゲットにしているため， `composer install` をアプリケーションイメージビルド中に実行しない。その代わり，以下の選択肢のいずれかをホスト側で採る必要がある。
  - ホスト側に PHP 7.4 環境を構築し， `composer install --ignore-platform-reqs` を実行する
  - ホスト側で `./composer install` を実行する


## 設定ファイルなど

デプロイ環境には Kubernetes ConfigMap を使用しているため，以下にはローカル環境用のものしか用意していません。 Nginx の設定ファイルは API サーバ向けに `index.php` 決め打ちになっております。

```ini:&lt;project&gt;/docker/php-fpm/php-fpm.d/zzz-custom.conf
[www]
access.log = /dev/null
```

```ini:&lt;project&gt;/docker/php-fpm/php-ini.d/custom.ini
; general
display_errors = 0
log_errors = 1

; xdebug
xdebug.idekey = PHPSTORM
xdebug.remote_autostart = 1
xdebug.remote_enable = 1
xdebug.remote_host = host.docker.internal

; opcache
opcache.enable = 0
```

```nginx:&lt;project&gt;/docker/nginx/conf.d/local.conf
server {
    listen       80 default_server;
    server_name  _;

    add_header  X-Frame-Options "SAMEORIGIN";
    add_header  X-XSS-Protection "1; mode=block";
    add_header  X-Content-Type-Options "nosniff";
    add_header  Cache-Control "no-store" always;

    charset utf-8;

    location /nginx_status {
        stub_status;
        access_log  off;
    }

    error_page 400 404 405 =500 @40*_json;
    location @40*_json {
        default_type  application/json;
        return        500 '{"code":"GATEWAY_ERROR","message":"Gateway Error: Server unexpectedly tried to return 4xx error"}';
    }

    error_page 500 @500_json;
    location @500_json {
        default_type  application/json;
        return        500 '{"code":"GATEWAY_ERROR","message":"Gateway Error: 500 Internal Server Error"}';
    }

    error_page 502 @502_json;
    location @502_json {
        default_type  application/json;
        return        502 '{"code":"GATEWAY_ERROR","message":"Gateway Error: 502 Bad Gateway"}';
    }

    error_page 503 @503_json;
    location @503_json {
        default_type  application/json;
        return        503 '{"code":"GATEWAY_ERROR","message":"Gateway Error: 503 Service Temporarily Unavailable"}';
    }

    error_page 504 @504_json;
    location @504_json {
        default_type  application/json;
        return        504 '{"code":"GATEWAY_ERROR","message":"Gateway Error: 504 Gateway Timeout"}';
    }

    location / {
        include            fastcgi_params;
        fastcgi_pass       php-fpm:9000;
        fastcgi_param      SCRIPT_NAME     index.php;
        fastcgi_param      SCRIPT_FILENAME /code/public/index.php;
        fastcgi_buffering  off;
    }
}
```