LaravelアプリケーションをローカルでもAWSでもDockerで動かす

## はじめに
2016年前半くらいまではローカル環境を`Vagrant`で仮想化していました。
それ以降は徐々に`Docker`を使って構築するようになりました。
しかし、本番環境は、インフラチームが構築してくれたサーバーに[deployer](https://deployer.org/)などのデプロイツールを使ってデプロイしていました。

ただ、せっかくローカルとはいえ`Docker`化できているのに、それをそのまま本番環境にもっていけないのは、もったいないし非効率なのは否めません。

「のぼりーさんのクラウドインフラPodcast」でも、その話題に触れていますので、興味ある方は聴いてみてください。

- [Track-24 SUPINF中丸さんとFargate, DockerとCI/CD、Jupyter Notebookを使ったハンズオンの話](https://cloudinfra.audio/track24-b6ea346867f1)
- [のぼりーさんのクラウドインフラPodcast](https://itunes.apple.com/jp/podcast/id1123053653)

今回は、`Laravel`アプリケーションをローカルでも本番でも全く同じ`Docker`コンテナでホストすることを目指して、[AWS ECS](https://aws.amazon.com/jp/ecs/)に`Docker`環境をデプロイしてみます。

## プロジェクト概要
実際にデプロイしたアプリのソースコード、`Dockerfile`などは`github`にて公開しています。

- [laravel-ecs-demo](https://github.com/imunew/laravel-ecs-demo)

### ディレクトリ構成
標準的な`Laravel`アプリケーションに、`docker`ディレクトリを作成して、`Dockerfile`などを格納しています。

```
├── app
├── bootstrap
├── config
├── database
├── docker
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── site.conf
│   ├── nodejs
│   │   └── Dockerfile
│   └── php-fpm
│       └── Dockerfile
├── node_modules
├── public
├── resources
├── routes
├── storage
├── tests
├── artisan
├── buildspec.yml
├── composer.json
├── composer.lock
├── docker-compose.yml
├── package-lock.json
├── package.json
├── phpunit.xml
├── README.md
├── server.php
└── webpack.mix.js
```

### docker-compose.yml

```yml
version: '3.2'
services:
  nginx:
    build:
      context: .
      dockerfile: ./docker/nginx/Dockerfile
    ports:
      - "80:80"
    depends_on:
      - php-fpm
  php-fpm:
    build:
      context: .
      dockerfile: ./docker/php-fpm/Dockerfile
  nodejs:
    build:
      context: .
      dockerfile: ./docker/nodejs/Dockerfile
    volumes:
      - .:/build
    working_dir: /build
```

### `nginx`コンテナ
`public`ディレクトリ配下のファイルは`nginx`コンテナに格納します。

```
# docker/nginx/Dockerfile
FROM nginx

ADD ./docker/nginx/site.conf /etc/nginx/conf.d/default.conf

RUN mkdir -p /app/public
ADD ./public/ /app/public/
```

静的ファイルは、`nginx`コンテナにて配信するように設定しています。
それ以外のリクエストは、`php-fpm`コンテナに送信します。

```
# docker/nginx/site.conf
server {
    index index.php index.html;
    server_name localhost;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /app/public;

    location ~ ^.+/\.(png|jpg|jpeg|gif|ico|css|js|html?)$ {
        try_files $uri $uri/ =404;
    }

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}
```

### `php-fpm`コンテナ
アプリのソースコードは、`volumes`で共有するのではなく、コンテナ内にコピーして、コンテナ内でセットアップします。
ビルド時間を短縮するために、[hirak/prestissimo](https://github.com/hirak/prestissimo)をインストールしています。

```
FROM php:7-fpm

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update && apt-get install -y git zlib1g-dev zip unzip
RUN docker-php-ext-install zip

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
RUN composer global require hirak/prestissimo

RUN mkdir -p /app
ADD . /app/

WORKDIR /app

RUN composer install
RUN cp .env.example .env
RUN php artisan key:generate
RUN chmod -R a+w storage/ bootstrap/cache
```

## ECS環境の構築
ECS環境を構築する手順をざっくりと説明します。

### ECR(Elastic Container Registry)の構築
`Docker`イメージ用のリポジトリを作成します。

`nginx`と`php-fpm`用のリポジトリを作成します。
下記のように`namespace`をつけることも可能です。

- `laravel-ecs-demo/nginx`
- `laravel-ecs-demo/php-fpm`

### ECRへのログイン
ECRへ`awscli`を使ってログインします。

[Amazon ECR での AWS CLI の使用](https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/ECR_AWSCLI.html)

```
get-login コマンドは AWS CLI のバージョン 1.9.15 以降で利用できます。
ただし、Docker の最新バージョン (17.06 以降) を使用している場合は、バージョン 1.11.91 以降をお勧めします。
AWS CLI のバージョンは、aws --version コマンドを使用して確認できます。
```

私の環境でもバージョンが`1.11.91`以前だったので、アップグレードしました。

```bash
$ pip install awscli --upgrade
```

```bash
$ $(aws ecr get-login --no-include-email --region ${AWS_DEFAULT_REGION})
```

### `nodejs`コンテナのビルド、アセット(css/js)のコンパイル
`nodejs`コンテナは、ECRにはプッシュしませんが、アセット(css/js)のコンパイルのためにビルドします。

```bash
$ docker build -t nodejs -f docker/nodejs/Dockerfile .
$ docker run --rm -v $(pwd):/build -w /build nodejs npm install
$ docker run --rm -v $(pwd):/build -w /build nodejs npm run dev
```

ビルドしたアセットは、`nginx`コンテナ内に配置されます。

- `public/css/app.css`
- `public/js/app.js`

### `nginx`/`php-fpm`コンテナのビルド

```bash
$ docker build -t ${REPOSITORY_URI_NGINX}:latest -f docker/nginx/Dockerfile .
$ docker build -t ${REPOSITORY_URI_PHP_FPM}:latest -f docker/php-fpm/Dockerfile .
```

### Dockerイメージへのタグ付け
Dockerイメージに対して、タグ付けします。

```bash
$ docker tag ${REPOSITORY_URI_PHP_FPM}:latest 
$ docker tag ${REPOSITORY_URI_NGINX}:latest
```

### Dockerイメージのプッシュ
タグ付けされたイメージをリポジトリにプッシュします。

```bash
$ docker push ${REPOSITORY_URI_PHP_FPM}:latest
$ docker push ${REPOSITORY_URI_NGINX}:latest
```

### タスク定義

| 項目 | 値 |
| --- | --- |
| 起動タイプの互換性 | EC2 |
| タスク定義名 | laravel-ecs-demo |

#### `php-fpm`コンテナ

| 項目 | 値 |
| --- | --- |
| コンテナ名 | php-fpm |
| イメージ | `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/php-fpm:latest` | 
| 作業ディレクトリ | /app | 
| メモリ制限（MB） | ハード制限 300(MB) | 

#### `nginx`コンテナ

| 項目 | 値 |
| --- | --- |
| コンテナ名 | nginx |
| イメージ | `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/nginx:latest` | 
| メモリ制限（MB） | ハード制限 300(MB) | 
| リンク | php-fpm | 
| ポートマッピング | tcp 80:80 | 

### ECSクラスタの作成

| 項目 | 値 |
| --- | --- |
| クラスターテンプレートの選択 | EC2 Linux + Networking | 
| クラスター名 | laravel-ecs-demo-cluster |
| EC2 インスタンスタイプ | t2.small |
| プロビジョニングモデル | Spot |
| スポットインスタンスの配分戦略 | 最低価格 |
| インスタンス数 | 2 |
| Maximum bid price (per instance/hour) | 10($) |

### ALB(Application load balance)の作成
ECSクラスタの作成が完了したら、ALB(Application load balance)を作成します。
作成したECSクラスタに紐づくVPCを選択しないと、ブラウザからアクセスしても、502/503が返ってくるだけなので、注意してください。

| 項目 | 値 |
| --- | --- |
| アベイラビリティゾーン | (ECSクラスタ内のVPCから選択) |
| リスナー | HTTP 80 |
| ターゲット | (アベイラビリティゾーンから選択) |

### サービスの作成

| 項目 | 値 |
| --- | --- |
| 起動タイプ | EC2 |
| タスク定義 | laravel-ecs-demo |
| クラスタ | laravel-ecs-demo-cluster |
| サービス名 | laravel-ecs-demo |
| サービスタイプ | REPLICA |
| ELB タイプ | `Application Load Balancer` |
| 負荷分散用のコンテナ | nginx:80:80 |
| リスナーポート | 80:HTTP |
| Target Group | (作成したALBに紐づくターゲットグループを選択) |

### 動作確認
ここまで問題がなければ、ECSにコンテナが配置され、ブラウザからアクセスできるようになるはずです。

`http://xxxxx.${AWS_REGION}.elb.amazonaws.com`

`Laravel on ECS Demo`の文字が表示されれば成功です。

## 苦戦したところ

### サービスが起動しない
サービスがいつまでたっても起動しないので、イベントを確認すると、下記のメッセージが出力されていました。

```
service laravel-ecs-demo was unable to place a task because no container instance met all of its requirements. The closest matching container-instance xxxxxxxxxxxxx has insufficient memory available. For more information, see the Troubleshooting section.
```

下記の記事にもありますが、原因はメモリ不足のようです。

- [AWS ECSでサービス運用するために必要（そう）なこと](https://qiita.com/uchihara/items/8647c94f7af607a9a236)

ECSクラスタを作り直してもいいのですが、なぜかクラスタの削除に失敗してしまうので、`Cloud Formation`にて変更セットを作成・実行するのがオススメです。

#### インスタンスタイプが非力すぎる
今回の例だと`t2.micro`では起動できませんでした。
`t2.small`に変更したら、メモリ不足にはならなくなりました。

#### スポットインスタンスの最高入札額が低すぎる？
インスタンスタイプを`t2.small`にしているのに起動しないことがあったので、試しに`Maximum price (per instance/hour)`を`５`から`10`に`Cloud Formation`から設定変更したところ起動するようになりました。
入札額が低すぎると、需要と供給の関係で落札できない？ってことがあるんでしょうか。
継続的に使うインスタンスであれば、オンデマンドインスタンスにしたほうが良さそうです。

### ECSクラスタの削除に高確率で失敗する
逆に成功したことがないくらい必ず失敗します。
いずれAWSが解決してくれるとは思いますが、メモリ不足などの理由で構成を変更したい場合は、`Cloud Formation`から構成を変更しましょう。

### ブラウザからアクセスしても`502`/`503`になる
原因はおそらく２つあって、いずれも設定ミスです。

#### ターゲットグループにターゲットインスタンスを登録していない
ALBを(再)作成したタイミングで、ターゲットグループにターゲットインスタンスを登録し忘れてしまうと、単純にリクエストを送信する先がいないのでエラーになります。

#### ターゲットインスタンスが何も表示されない
ECSクラスタで作成したVPCとは異なるVPCを選択してしまっている可能性があります。
ECSクラスタが削除できないので、何個も作ってしまうと間違えやすくなるので注意しましょう。

## おわりに
ローカルで動かしている環境をそのままデプロイできるのは安心感あるので、色々なプロジェクトでECS使っていきたいと思いました。

`CodePipeline`/`CodeBuild`を使ったデプロイ自動化の話は別記事にしていますので、興味ある方はこちらもご覧になってください。

- [LaravelアプリケーションをCodePipeline/CodeBuildでECSに自動デプロイする](https://qiita.com/imunew/items/687221e02d977564d610)

ではでは。