LaravelをElastic BeanstalkからFargateに移行しました

Elastic Beanstalk（以下、EB）からFargateに移行したので、色々と書き残します。

DockerfileやTerraformなどは全てGitHubで公開しています。
https://github.com/hareku/laravel-fargate-terraform

## AWS Fargateに移行したモチベーション
### 1. CLBからALBに移行したかった
Classic Load Balancer(CLB)からApplication Load Balancer(ALB)に移行したかったのですが、EBのコンソール画面上からはまだ変更できず、手動でやるにしても面倒な手順が必要そうでした。

### 2. Infrastructure as Code
昔はコンソール画面上でポチポチ作っていましたが、今回はTerraformで全て構築しました。

一番大きなメリットとしては、他のサービスにもTerraformのコードを流用できることです。
例えば今回だとAPIサーバーのTerraformを作成しましたが、フロント側のインフラも構築したいとなった時、Terraformの変数を変え、RDSなどの不要ファイルを削除するだけで簡単に構築できます。コマンド一発で、面倒な作業が数分で完了です。

### 3. お勉強
EBのMulti-container Dockerを使っていたのですが、学習がてら移行しました。
またAWSのDocker周りを自分で構築したことのない僕には、ECSとEKSで出来ることの違いなどが、さっぱり違いが分かりませんでした。
なのでこれから東京リージョンにも来そうなEKSに備え、一から自分で構築してみました。

## AWS Fargateのメリット
今までのECSは、コンテナ（タスク）を乗っけるEC2インスタンスを意識しなければいけませんでした。
しかしFargateという起動タイプを使えば、インスタンスを管理する必要はなく、タスクを起動する土台は全てAWS側が管理してくれます。

メリットとしては、

- EC2インスタンスのAutoScalingや、リソースの効率利用を考えなくても良い
- スケールアウト・インが早い（EC2インスタンスのスケーリングがなく、タスクのスケーリングのみであるため）
- EC2インスタンスを考える必要がないのでインフラがシンプルになる

ただEFSが使えなかったり、料金が若干高め（cpu256, memory512という最小タスク構成でも月$16ほど掛かる）という所がデメリットですが、料金はこの考察記事を見る限り、妥当な値段であると思っています。
参考：[Qiita: 新しいマネージドコンテナサービスAWS Fargateの価格は高いか安いか？](https://qiita.com/sudoyu/items/cde059bf98f3d32cfa61)

しかしEC2インスタンスの管理コストや、t2タイプであればCPUクレジットなども意識しなければならないため、Fargateで面倒なレイヤーが一つ減るというのは大きなメリットです。

## Fargateでの3つの運用ポイント
AWSでのコンテナ運用における、よくある疑問点などをまとめてみました。

### 1. DB Migrationはどこでするか
自分はCodePipelineを使い、CodeCommit（AWSが提供するソースリポジトリ）にプッシュすれば自動的にECSにデプロイするようになっています。流れとしては、

1. CodeCommitのmasterブランチにプッシュ
2. CodePipelineが起動
3. CodeBuildでnginx,php-fpmのイメージをビルドしてECRにプッシュ
4. そのままCodeBuild内で、db-migrationタスクを起動させる
5. CodeDeployでECSにデプロイ

こういった感じでCodeBuild内でDBのMigrationを行っています。
具体的には、buildspec.ymlにこう記述しています。（コメントアウト部分）

```yaml
version: 0.2

phases:
  pre_build:
    commands:
      - pip install awscli --upgrade --user
      # - apt-get update -y
      # - apt-get install -y jq
      - $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION)
      - IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - NGINX_IMAGE_REPO_URI=123456.dkr.ecr.ap-northeast-1.amazonaws.com/devicebook-api-nginx
      - PHP_FPM_IMAGE_REPO_URI=123456.dkr.ecr.ap-northeast-1.amazonaws.com/devicebook-api-php-fpm
  build:
    commands:
      - docker build -t $NGINX_IMAGE_REPO_URI:latest -f Dockerfile_nginx .
      - docker tag $NGINX_IMAGE_REPO_URI:latest $NGINX_IMAGE_REPO_URI:$IMAGE_TAG
      - docker build -t $PHP_FPM_IMAGE_REPO_URI:latest -f Dockerfile_php_fpm .
      - docker tag $PHP_FPM_IMAGE_REPO_URI:latest $PHP_FPM_IMAGE_REPO_URI:$IMAGE_TAG
  post_build:
    commands:
      - docker push $NGINX_IMAGE_REPO_URI:latest
      - docker push $NGINX_IMAGE_REPO_URI:$IMAGE_TAG
      - docker push $PHP_FPM_IMAGE_REPO_URI:latest
      - docker push $PHP_FPM_IMAGE_REPO_URI:$IMAGE_TAG
      - printf '[{"name":"rp","imageUri":"%s"},{"name":"php-fpm","imageUri":"%s"}]' $NGINX_IMAGE_REPO_URI:$IMAGE_TAG $PHP_FPM_IMAGE_REPO_URI:$IMAGE_TAG > imagedefinitions.json
      # - aws ecs run-task --launch-type FARGATE --cluster devicebook-api-cluster --task-definition devicebook-api-db-migration --network-configuration "awsvpcConfiguration={subnets=[subnet-123456, subnet-123456],securityGroups=[sg-123456],assignPublicIp=ENABLED}" > run-task.log
      # - TASK_ARN=$(jq -r '.tasks[0].taskArn' run-task.log)
      # - aws ecs wait tasks-stopped --cluster arn:aws:ecs:ap-northeast-1:123456:cluster/devicebook-api-cluster --tasks $TASK_ARN
artifacts:
  files: imagedefinitions.json
```

DB Migrationさせたい時だけ、コメントアウトを外します。
`aws ecs run-task`でmigrationタスクを起動させ、`aws ecs wait tasks-stopped`でdb-migrationタスクが終了するのを待っています。migrationが終了する前に、新しいバージョンのサービスがデプロイしてしまっては基本的にダメだと思いますので。

db-migrationタスクはこんな感じで、php-fpmイメージのentypointを上書きしています。

```json
[
  {
    "name": "php-fpm",
    "image": "${php_fpm_image_url}",
    "essential": true,
    "network_mode": "awsvpc",
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "application/${app_name}",
        "awslogs-region": "${aws_region}",
        "awslogs-stream-prefix": "db-migration-task"
      }
    },
    "entrypoint": ["php", "/var/www/artisan", "migrate", "--force"]
  }
]
```

#### Terraformとの整合性
CodeDeployでタスク定義を更新しているため、`terraform apply`との整合性が取れないのでは？という疑問があると思います。
しかし以下のように、aws_ecs_serviceのtask_definitionにタスク定義のrevisionを指定しなければ、planで差分が出るものの、applyした時にタスクの再配置などが行われないようになります。

```
resource "aws_ecs_service" "this" {
  task_definition = "arn:aws:ecs:ap-northeast-1:${var.aws_id}:task-definition/${aws_ecs_task_definition.main.family}"
}
```

### 2. Laravelの`schedule:run`と`queue:work`の実行
この両コマンドは、Supervisorを使ってphp-fpmイメージ内で全て実行させています。
具体的には、supervisorで以下の3コマンドを監視しています。

- php-fpm
- crond（cronで`artisan schedule:run`を定期実行）
- `artisan queue:work`（SQSなどへのポーリングコマンド）

この手法はDockerの1コンテナ1プロセスという思想からは少しかけ離れていますが、こういった個人規模での運用であれば全然問題ないと思っています。（そもそも1コンテナ1プロセスに関しては色々論争がありますが）。
自分はそれほど規模が大きいサービスではないこともあり、このように1つのコンテナにまとめています。

ちなみにLaravel5.6で実装されたonOneServerを使えば、単一コンテナのみで実行させることが可能です。
https://readouble.com/laravel/5.6/ja/scheduling.html#running-tasks-on-one-server
5.6未満の場合は、何かしらの対応が必要そうです。

#### ※Elastic Beanstalk時代のやり方
ちなみにElastic Beanstalkを使っていた時は、Worker環境タイプというものを使っていました。

Worker環境タイプの内部ではEC2インスタンス上でSQSに対してポーリングを行っており、メッセージがあればアプリケーション上の指定URIへメッセージ内容を送信するというシステムになっています。
またスケジュール実行もcron.ymlをアプリケーション直下に置けば指定URIへ定期的にリクエストを送信するような形になっています。

Laravelでは[laravel-aws-worker](https://github.com/dusterio/laravel-aws-worker)というパッケージを使うことで、EB用のAPIを簡単に実装できます。

### 3. Dockerfileはどこに置くか
application直下に、Dockerfile_nginxなどの命名で置いています。

Dockerfile内で`ADD ../../ /var/www/`のように、Dockerfileより上のディレクトリに対して`COPY`などが実行できないため、直下に置きました。buildの-fオプションを使えば実現できるのですが、Dockerfile内のパス指定が-f依存になって気持ち悪いのでやめました。

## RDSからAuroraへの移行
こちらは既存のRDSのスナップショットを取り、移行時にサービスを20分ほど一時停止しました。
またあまりDBがダウンしてはダメなサービスであるため、ついでにAuroraへの移行も行いました。

AuroraはSingle-AZの場合でも10分程度でフェイルオーバー（自動復旧）してくれ、Multi-AZの場合は1分～2分程度のダウンタイムで済みます。Auroraは最低のdb.t2.smallでも月$40ほど掛かりますが、RDSは自分で復旧しなければならないことを考えれば妥当かなと思います。それに後々を考えると拡張しやすい。

またダウンタイム無しでRDSの移行をしたい場合は、[AWS Database Migration Service](https://aws.amazon.com/jp/dms/)というものを使えば実現できそうです。

## レスポンス（スループット）が300%ほど改善しました
移行のついでに、PHP-FPMイメージにOPcacheを導入しました。
その他にもphp-fpmやngixのworker数を調整したところ、一秒あたりに捌けるリクエスト数（Requests per second）が12.41から41.05に改善しました。Fargateを導入したおかげでは無いですが…。

Reverse ProxyにはNginxを用いており、`ab -n 100 -c 10`の結果は以下です。

### Elastic Beanstalk
```
Concurrency Level:      10
Time taken for tests:   8.060 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      297362 bytes
HTML transferred:       201600 bytes
Requests per second:    12.41 [#/sec] (mean)
Time per request:       805.967 [ms] (mean)
Time per request:       80.597 [ms] (mean, across all concurrent requests)
Transfer rate:          36.03 [Kbytes/sec] received
```

### Fargate
```
Concurrency Level:      10
Time taken for tests:   2.436 seconds
Complete requests:      100
Failed requests:        0
Total transferred:      288888 bytes
HTML transferred:       201600 bytes
Requests per second:    41.05 [#/sec] (mean)
Time per request:       243.614 [ms] (mean)
Time per request:       24.361 [ms] (mean, across all concurrent requests)
Transfer rate:          115.81 [Kbytes/sec] received
```

ちなみにNginxからH2Oに移行しようとテストしてみましたが、Nginxの方が若干パフォーマンスが良かったので今回は見送りました。

### FargateにおけるvCPUの注意点
少しハマっていたので書き残します。
Fargateでは、**場合によってはレスポンス速度などが1/4にまで落ちるケースがあります。**

今回EB Multi-Container Dockerから移行をしましたが、その時のEC2インスタンスはt2.micro（1vCPU,1GBMemory）を使用していました。

そして1vCPUのEC2インスタンスに、0.25vCPU（256cpu）の1つのタスクを起動していたのですが、**実際はそのタスクに1vCPUが割り当てられていました**。そもそもLinuxコンテナの仕組みとして、割り当てられていないCPUユニットがあると、それをコンテナタスクが共有して使用できるという仕様があります。

>Linux コンテナは、割り当てられた CPU ユニットと同じ比率を使用して、割り当てられていない CPU ユニットをコンテナインスタンス上の他コンテナと共有します。たとえば、単一コンテナタスクを単一コアインスタンスタイプで実行する場合、そのコンテナ用に 512 個の CPU ユニットを指定しており、そのタスクがコンテナインスタンスで実行される唯一のタスクであると、そのコンテナは 1,024 個の CPU ユニット配分すべてをいつでも使用できます。

CPUユニットの意味は明確に定義されていないですが、私はAWSにおけるCPUの時間配分の単位だと捉えています。
1vCPU、つまり1024CPUユニットが割り当てられるというのは、1つの仮想CPUコアの全てを利用できるという意味を表します。

しかしFargate上でタスクに0.25vCPUを指定した場合、EC2インスタンスという概念は無いので（実際には裏で動いているのでしょうが）、実際に使えるのは1024CPUユニットの内256CPUユニット、つまり1つの仮想CPUコアのおよそ25%あたりしか使用できないのです。

今回のケースではOPcacheを導入し、0.25vCPUのFargateでも十分なパフォーマンスが出たので良かったですが、**アクセス頻度は高くないがそれなりのパフォーマンスは必要**といったケースでは、ECSでt2インスタンスを利用した構成の方がコストパフォーマンス的にも優れているケースがあります。

フロントのNuxt.jsでもFargateを導入しようかな？と一度試してみましたが、0.25vCPUでは十分なパフォーマンスが出なかったため、引き続きEB（Node.jsプラットフォーム）で運用しています。そもそもDockerを利用するメリットとしては開発環境との統一などですが、Node.jsの場合はミドルウェアがシンプルであり、Dockerの恩恵をそれほど受けられません。

※2018/01/08 追記
https://dev.classmethod.jp/cloud/aws/fargate-lower-price/
Fargateの大幅な値下げが行われたようです。コストパフォーマンス的にFargateの導入を悩んでいた方はハードルが少し下がったと思われます。

## まとめ

- FargateはECSよりインフラ構成がシンプルになる
- Laravelを運用する場合でも、特に問題なく運用できる
- vCPU、CPUユニットの概念を理解していないとパフォーマンスチューニングで詰まる

今回作成したDockerfileやTerraformなどは全てGitHubで公開しています。
https://github.com/hareku/laravel-fargate-terraform

DockerfileはLaradockなどを~~パクって~~参考にしながら作ったものですので、お好みに合わせて編集してください。