半年前の自分に捧げる、DockerでのLaravel環境構築例

## はじめに
これは、[ALH Advent Calendar 2022](https://qiita.com/advent-calendar/2022/alhinc) 17日目の記事です。

## きっかけ
半年前、Docker環境の構築を行いました。理屈は理解していたつもりでしたが、初めてだったのでそれなりに苦戦しました。そんな過去の自分に贈る記事を書き上げてみました。

## 要求
- デプロイ環境：Amazon ECS
- DB：Amazon RDS
- デプロイ方法：AWS CodePipelineを使用(AWS CodeCommit->AWS CodeBuild->Amazon ECS)

(ローカルなどで)`docker compose`で動かせると同時に、ECSで動作させることも視野に入れた構築手順としています。

## 想定読者
- なんとなくDockerのことは理解できたけど環境構築をしたことがない人
- とりあえずLaravelをDockerで動かしたい人

## 構成
コンテナ構成は以下のようにします。
- アプリケーションコンテナ：PHP
- Webサーバコンテナ：nginx
- DBコンテナ：PostgreSQL

これから記す設定例で指定しているバージョンは、2022年12月時点での最新orLTSですので参考程度に見てください。
なおデプロイ時にはAmazon RDSを使用するため、PostgreSQLはローカルでの開発専用です。

```txt:ディレクトリ構成
.
├─ php
│   ├─ Dockerfile
│   ├─ zz-docker.conf
│   ├─ php.ini
│   └─ example-app <-Laravelプロジェクト
│       ├─ app
│       ├─ bootstrap
│            :
│            :
│            :
│       └─ vite.config.js
│
├─ nginx
│   ├─ Dockerfile
│   ├─ default.conf
│   └─ example-app
│       └─ .gitkeep
│
└─ docker-compose.yml
```
こういった記事では、phpディレクトリやnginxディレクトリと同じようにLaravel単体のディレクトリを作成するという構成をよく見ますが、今回はphpディレクトリ配下にLaravelのソースを配置しています。
と言いますのも、後でDockerfileでLaravelのソースを`COPY`する処理をしているのですが、Laravel単体でディレクトリを作った場合、`COPY ../example-app /var/www/html/example-app`となり、この場合ビルド時にエラーになります(Dockerfileから親ディレクトリを参照できないため)。なのでphpディレクトリ配下にソースを配置しています。

## Dockerfile
### アプリケーションコンテナ
```Dockerfile:php/Dockerfile
FROM php:8.1.10-fpm

RUN apt-get update && apt-get install -y \
    unzip \
    emacs \
    libpq-dev
RUN docker-php-ext-install pdo pdo_pgsql pgsql

# Composerのインストール
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN php -r "unlink('composer-setup.php');"

COPY zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf

COPY php.ini /usr/local/etc/php/php.ini

RUN mkdir -p /var/www/html/example-app
COPY ./example-app /var/www/html/example-app

WORKDIR /var/www/html/example-app
```
```Dockerfile
FROM php:8.1.10-fpm
```
まず、今回はWebサーバで動かすので、ベースイメージにはFPM版を使用します。

```Dockerfile
RUN apt-get update && apt-get install -y \
    unzip \
    emacs \
    libpq-dev
RUN docker-php-ext-install pdo pdo_pgsql pgsql
```
Composerを利用するためにunzipをインストールしています(Gitでも可)。
コンテナ内のファイルを直接編集したいことがあるので、Emacsを入れています(Emacsを選んだのは個人の趣味ですので、Vimなりnanoなりご自由にどうぞ)。
PostgreSQLを利用するため、`docker-php-ext-install`コマンドで拡張モジュールの`pdo` `pdo_pgsql` `pgsql`をインストールしています。またそれらモジュールのインストールのために、先に`libpq-dev`パッケージを入れています。


```Dockerfile
COPY zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf
```
FPMの設定ファイルをコンテナ側にコピーしています。[こちら](https://yoshinorin.net/articles/2017/03/06/php-official-docker-image-trap/)にあるように、`zz-docker.conf`が最終的にFPMの設定を上書きするため、`zz-docker.conf`以外のファイルに設定をしても反映されません。
ちなみに`zz-docker.conf`の設定は以下のようにしました。
```conf:zz-docker.conf
[global]
daemonize = no

[www]
listen = /var/run/php-fpm/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0666
access.log = /proc/self/fd/1
```

```Dockerfile
RUN mkdir -p /var/www/html/example-app
COPY ./example-app /var/www/html/example-app
```
Laravelアプリを置くディレクトリを作成し、ホスト側のファイルをコンテナ側にコピーしています。一般的にはホスト側でソースを編集して、かつその編集を即時に反映させるためバインドマウントしますが、AWS CodeBuildでのビルドのためこの段階でコンテナ側にソースを反映しています。
※この設定のままだと、ソースを変更したらその都度コンテナイメージを再ビルドすることになるので後でバインドマウントします。

```Dockerfile
WORKDIR /var/www/html/example-app
```
docker execした際に多少便利なので(すぐアプリケーションのソースを見られる)、Laravelアプリのディレクトリをワーキングディレクトリとしています。

### Webサーバコンテナ
```Dockerfile:nginx/Dockerfile
FROM nginx:1.20.1

# 設定ファイルをコンテナ側にコピー
COPY default.conf /etc/nginx/conf.d/default.conf

RUN mkdir -p /var/www/html/example-app
COPY ./example-app /var/www/html/example-app
```

:::note info
default.confはLaravel公式リファレンスに[設定例](https://readouble.com/laravel/8.x/ja/deployment.html)がありますので、それを参考にしてください。
:::

```Dockerfile
RUN mkdir -p /var/www/html/example-app
COPY ./example-app /var/www/html/example-app
```

[こちら](https://qiita.com/tkek321/items/9c0c5cec1dc349c67f5f#-laravel-%E3%81%AE%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E3%83%9E%E3%82%A6%E3%83%B3%E3%83%88-1)にあるように、nginxのコンテナにもLaravelのソースをコピーする必要があります。ビルド時に直接`php/example-app`は参照できないので(親ディレクトリを経由するため)、以下の対応としました。

- デプロイ環境に反映：AWS CodeBuildの`buildspec`で`cp -r php/example-app/. nginx/example-app`をnginxコンテナをビルドする直前に実行
- ローカル：`docker-compose.yml`で`php/example-app`をバインドマウント

## docker-compose.yml
```yml:docker-compose.yml
version: '3.3'

services:
  app:
    container_name: example-app
    build:
      context: ./php
      dockerfile: ./Dockerfile
    volumes:
      - type: volume
        source: php-fpm-socket
        target: /var/run/php-fpm
        volume:
          nocopy: true
      - type: bind
        source: ./php/example-app
        target: /var/www/html/example-app
      - type: bind
        source: ./php/zz-docker.conf
        target: /usr/local/etc/php-fpm.d/zz-docker.conf
      - type: bind
        source: ./php/php.ini
        target: /usr/local/etc/php/php.ini
    environment:
      - DB_HOST=db
      - DB_DATABASE= # 例：example-db
      - DB_USERNAME= # 例：postgres
      - DB_PASSWORD= # 例：postgres
    depends_on: ["db"]

  web:
    container_name: example-web
    build:
      context: ./nginx
      dockerfile: ./Dockerfile
    ports:
      - target: 8080
        published: 8000
        protocol: tcp
        mode: host
    volumes:
      - type: volume
        source: php-fpm-socket
        target: /var/run/php-fpm
        volume:
          nocopy: true
      - type: bind
        source: ./php/example-app
        target: /var/www/html/example-app
      - type: bind
        source: ./nginx/default.conf
        target: /etc/nginx/conf.d/default.conf
    depends_on: ["app"]

  db:
    container_name: example-db
    image: postgres:14.5
    ports:
      - target: 5432
        published: 5432
        protocol: tcp
        mode: host
    volumes:
      - type: volume
        source: db-store
        target: /var/lib/postgresql/data
        volume:
          nocopy: true
    environment:
      - POSTGRES_DB= # 例：example-db
      - POSTGRES_USER= # 例：postgres
      - POSTGRES_PASSWORD= # 例：postgres

volumes:
  php-fpm-socket:
  db-store:
```
### アプリケーションコンテナ
```yml
    volumes:
      - type: volume
        source: php-fpm-socket
        target: /var/run/php-fpm
        volume:
          nocopy: true
```
今回はUNIXドメインソケットでnginxのコンテナと通信をさせます。
ですのでボリューム`php-fpm-socket`を`/var/run/php-fpm`にマウントしています。

```yml
    volumes:
      # (中略)
      - type: bind
        source: ./php/example-app
        target: /var/www/html/example-app
      - type: bind
        source: ./php/zz-docker.conf
        target: /usr/local/etc/php-fpm.d/zz-docker.conf
      - type: bind
        source: ./php.ini
        target: /usr/local/etc/php/php.ini
```
ソースの変更を即時反映されるよう、Laravelのソースをバインドマウントしています。
また、各種設定のチューニングをする必要があったため、`zz-docker.conf`および`php.ini`もバインドマウントしています。頻繁に変更をしないということであればDockerfileでの`COPY`の方がいいでしょう。

```yml
    environment:
      - DB_HOST=db
      - DB_DATABASE= # 例：example-db
      - DB_USERNAME= # 例：postgres
      - DB_PASSWORD= # 例：postgres
```
環境変数の設定です。
項目は後述するDBに合わせていますが、具体的なパラメータは値をどこまで`.env`から浮かせたいかで変わります。

```
    depends_on: ["db"]
```
`db`コンテナが起動してからコンテナを作成するようにしています。

### Webサーバコンテナ
```yml
    ports:
      - target: 8080
        published: 8000
        protocol: tcp
        mode: host
```
nginxコンテナのポート設定です。
- target ：コンテナ内のポート
- published ：公開用のポート

となりますので今回の場合、`localhost:8000`に来たアクセスを(nginxの)`8080`ポートに流すという処理になります。
`protocol`は`tcp`か`udp`が、`mode`は`host`か`ingress`がそれぞれ選択肢になります。(Webアプリケーションなので)HTTPで通信しますので`protocol: tcp`、今回はSwarm モードを使用しないため`mode: host`としています。

```yml
    volumes:
      - type: volume
        source: php-fpm-socket
        target: /var/run/php-fpm
        volume:
          nocopy: true
```
ボリュームのマウントは、アプリケーションコンテナの箇所で説明したPHPコンテナ-nginxコンテナ間の通信を行うためのものです。ですのでPHPコンテナと同様の記述となっています。

```yml
    volumes:
      # (中略)
      - type: bind
        source: ./php/example-app
        target: /var/www/html/example-app
      - type: bind
        source: ./nginx/default.conf
        target: /etc/nginx/conf.d/default.conf
```
一つ目のマウントは`nginx/Dockerfile`の`COPY ./example-app /var/www/html/example-app`をバインドマウントとしています。
二つ目の処理はnginxの設定ファイルをバインドマウントしています。こちらもアプリケーションコンテナと同様に頻繁に変更をしないということであれば`COPY`でいいでしょう。

:::note warn
以下のようなエラーが発生した場合、nginxコンテナ内で`/var/run/php-fpm`のパーミッションを確認してみてください。所有者が`root`であれば`www-data`に変更してください。
```
FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream (中略), upstream: "fastcgi://unix:/var/run/php-fpm/php-fpm.sock:"
```
:::

### DBコンテナ
```yml
    image: postgres:14.5
    ports:
      - target: 5432
        published: 5432
        protocol: tcp
        mode: host
    volumes:
      - type: volume
        source: db-store
        target: /var/lib/postgresql/data
        volume:
          nocopy: true
    environment:
      - POSTGRES_DB= # 例：example-db
      - POSTGRES_USER= # 例：postgres
      - POSTGRES_PASSWORD= # 例：postgres
```
こちらのコンテナは複雑な設定をしなかったので、Dockerfileを用意しておりません。

```yml
    volumes:
      - type: volume
        source: db-store
        target: /var/lib/postgresql/data
        volume:
          nocopy: true
```
DBのデータを保存するために(コンテナを立ち上げ直してもデータを使用できるように)、ボリューム`db-store`にデータベースクラスタをマウントしています。

```yml
    environment:
      - POSTGRES_DB= # 例：example-db
      - POSTGRES_USER= # 例：postgres
      - POSTGRES_PASSWORD= # 例：postgres
```
こちらもPHPのコンテナと同様です。しかし同じDBの接続情報ですが、キーが異なるので注意が必要です。

## 動作確認
`docker-compose.yml`がある階層で
```bash
$ docker compose build
$ docker compose up
```
を実行して`http://localhost:8000/`に接続してLaravelから何かしらのレスポンスが返ってきたら無事構築完了です。

## 最後に
苦労したことが多かったですが、この構築をきっかけにDockerの理解度がワンランク上がりました。慣れるとコンテナでの開発は利点が多いのでこれからも積極的に使っていきたいところです。