Laravel + Terraform + ECS + CircleCIでCI/CD環境を作る

先日、タイトルの構成で環境構築する機会があったので、復習としてまとめます。

## 各技術について

### Terraform

Terraformは、AWSやGCPの設定をコードで定義できるツールです。「Infrastructure as Code」とも呼ばれたりします。
「コード = ドキュメント」とも言われるように、属人化しやすいインフラの設定をコードに落とし込むことで、ドキュメント作成の手間を減らしたり、不意な操作ミスによる環境の破壊リスクを減らしたりなどのメリットが期待できます。

### ECS

AWSのコンテナ管理サービスで、各Dockerコンテナをクラスターという単位でEC2やFargate上で動作させることができます。コンテナに関わる複雑な操作をほぼ丸投げできるので、コンテナ技術を使って環境構築する場合にお世話になるサービスです。（最近流行りのk8s（EKS）とは違います。）

### CircleCI

「継続的インテグレーション(デリバリー)サービス」と呼ばれるものです。字面だけ見るとなんのこっちゃって感じですが、要は自動テストや自動デプロイをの仕組みを簡単に構築できるサービスみたいなものです。GitHub上でmasterに変更がmergeされたタイミングで自動でデプロイを行う、みたいなことができます。

## 各技術の選定理由について

チームにインフラを得意とするメンバーがおらず、コンソール上で各々設定していると何が何やらわからなくなる可能性があり、それを回避したかったというのが一つの理由です。全員が副業という形での参画なので、今の時点ではインフラにあまり時間を割けない（ドキュメント作成も含めて）ということで、一度構築すればそれをベースにコミュニケーションできるTerraformを選定しました。

また、開発環境はDockerを用いて構築していたので、それを活かすためにECSでのコンテナ運用を決定しました。CircleCIについても、自動デプロイ、自動テストによるリソース削減が目的です。

また、Terraformによる環境構築については、「[コンテナ時代のWebサービスの作り方](https://booth.pm/ja/items/1316133)」という本にとてもお世話になりました。Terraformを用いてRailsアプリをECSにデプロイする方法が書かれています。今回はLaravelですが、参考にした箇所がたくさん含まれています。

## サービス構成について

アプリはLaravelで実装します。また、WebサーバーとしてNginxを置き、Nginxでクライアントからのリクエストを受け取ってLaravel側へ流すようにします。コンテナの構成としてはLaravel用のコンテナ、Nginxのコンテナの2つを用意することになります。

AWS上ではさらに前段にロードバランサー（ALB）を設置するので、ALB → Nginx → Laravelという流れになりますね。

## 実装の流れ

以下の順番で実装、設定を進めていきます。

1. PHPコンテナとNgixnコンテナのDockerfileを作成
2. ECRにコンテナレポジトリを作成
3. デプロイに必要なIAM、VPC、EC2などの作成
4. ECSのクラスター、サービス、タスク定義
5. CircieCIの設定（.circleci/config.yml）

細かい部分はいろいろ端折っていますが、大まかな流れはこんな感じです。

## ディレクトリ構成

アプリ（Laravel）のリポジトリと、Terraformのリポジトリの2つを作成します。
今回の例として、Laravel側のディレクトリ構成は以下のようにしました。リポジトリのルートディレクトリ直下にdockerディレクトリを作成し、必要なファイルを配置しています。

```
app/
resources/
.
. // Laravel作成時に作成されるディレクトリ
.
docker/
  php/
    Dockerfile
    php.ini
  nginx/
    Dockerfile
    default.conf
.circleci/
  config.yml
```

Terraformのリポジトリ構成は以下です。今回、dev環境やstg環境は考慮していないので、それらを考慮するとディレクトリ構成はまた変わってくると思います。

```
ecr/      // ECR関係のtfファイル
iam/      // IAM関係
services/ // ECSやEC2関係
vpc/      // VPC関係
```

## 1. Dockerfileの作成

ECSに配置するコンテナのDockerfileを作成します。まずはAppコンテナ。各Dockerfileの内容は「[Laravelの開発環境をDockerを使って構築する](https://qiita.com/ucan-lab/items/17c806973e69792ada99)」の記事を参考にさせていただきました。また、同じディレクトリにphp.iniファイルも置いておきます。

```Dockerfile:Dockerfile
FROM php:7.3-fpm-alpine

ARG PSYSH_DIR=/usr/local/share/psysh
ARG PHP_MANUAL_URL=http://psysh.org/manual/ja/php_manual.sqlite

ARG TZ

ENV COMPOSER_ALLOW_SUPERUSER 1
ENV COMPOSER_HOME /composer

RUN set -eux && \
  apk update && \
  apk add --update --no-cache --virtual=.build-dependencies \
    autoconf \
    gcc \
    g++ \
    make \
    tzdata && \
  apk add --update --no-cache \
    icu-dev \
    libzip-dev && \
  cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
  echo ${TZ} > /etc/timezone && \
  pecl install xdebug && \
  apk del .build-dependencies && \
  docker-php-ext-install intl pdo_mysql mbstring zip bcmath && \
  docker-php-ext-enable xdebug && \
  mkdir $PSYSH_DIR && wget $PHP_MANUAL_URL -P $PSYSH_DIR && \
  curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer && \
  composer config -g repos.packagist composer https://packagist.jp && \
  composer global require hirak/prestissimo

RUN apk add --no-cache freetype libpng libjpeg-turbo freetype-dev libpng-dev libjpeg-turbo-dev && \
  docker-php-ext-configure gd \
    --with-gd \
    --with-freetype-dir=/usr/include/ \
    --with-png-dir=/usr/include/ \
    --with-jpeg-dir=/usr/include/ && \
  NPROC=$(grep -c ^processor /proc/cpuinfo 2>/dev/null || 1) && \
  docker-php-ext-install -j${NPROC} gd && \
  apk del --no-cache freetype-dev libpng-dev libjpeg-turbo-dev

ADD . /work/app

WORKDIR /work/app
```
`ADD ./ /work/appと`とすることで、Laravelの各ディレクトリをコンテナ内に含めています。コンテナの/work/app/配下にapp/やresources/が格納されるということです。

ビルドする際に、ビルドコンテキストをルートディレクトリにしてビルドしないとエラーが起きます（仕様としてビルドコンテキスト外を参照できないため）。以下のように、-fオプションでDockerfile Pathを指定することで回避します。

```zsh

docker build --build-arg TZ=Asia/Tokyo -f ./docker/php/Dockerfile .
```

次にNginx用のDockerfile。Dockerfile自体はシンプルです。

```Dockerfile:Dockerfile
FROM nginx:1.17-alpine
  
ADD ./docker/nginx/default.conf /etc/nginx/conf.d/default.conf

RUN mkdir -p /work/app/public

ADD ./public /work/app/public
```

Laravelアプリの公開ディレクトリ（publicディレクトリ）をNginxコンテナに加えておき、リクエストがあった場合はそちらに向けるようにdefault.confで設定します。

```default.conf
server {
    listen 80;
    root /work/app/public;
    index index.php;
    charset utf-8;

    location / {
        root /work/app/public;
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /work/app/public/index.php;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}
```
80番ポートにリクエストが来ると、/work/app/public/index.phpを参照する設定です。また、Nginxではリクエストを向けるだけではPHPが実行されないので、先ほど設定したAppコンテナでリクエストを処理させるように記述を追加します。（location ~ \.php$以下）

app:9000というところで、AppコンテナとNginxコンテナが通信しているのがわかると思います。

最後にどちらもビルドして、無事イメージが作成できればOKです。

ECS上で動かすならこの2つのDockerfile+αで大丈夫ですが、環境統一のためにローカルでの開発時にもコンテナを使いたいですね。ということで、リポジトリのルートディレクトリ直下にdocker-compose.ymlを作成し、設定を記述します。（内容は割愛します）


これでコンテナ周りのファイル作成はOKです。

## 2.ECRにコンテナリポジトリを作成

ECR（Amazon Elastic Container Registry）とは、AWS上にコンテナイメージを保管しておけるサービス。こちらにビルドしたイメージを置いておき、必要になったら取り出して使う、みたいなイメージです。Docker Hubのprivate版みたいな感じですね。

ECRはAWS上のサービスなので、Terraformを使って設定を行います。Terraform用のリポジトリを作成し、ecrというディレクトリを作成します。そしてその配下にtfファイルを作成します。（AWSの認証を通すために事前に~/.aws/credentialsを作っておきましょう）

```:aws_ecr_repository
resource "aws_ecr_repository" "app" {
  name = "app"
}

resource "aws_ecr_repository" "nginx" {
  name = "nginx"
}
```

上記の記述で、ECRにAppコンテナレポジトリとNginxコンテナレポジトリが作成されるようになります。
また、AWSにリソースが作成された際に、リソースの詳細が記述された*.tfstateというファイルが作成されるので、それをs3に保存するようにしておきます。ecrディレクトリにconfig.tfというファイルを作って、以下のように記述します。

```config.tf
terraform {
  backend "s3" {
    bucket = "terraform"
    key    = "ecr/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

provider "aws" {
  region  = "ap-northeast-1"
}
```
これで、terraformというバケットのecrディレクトリ配下にterraform.tfstateが作成されます。Terraformでは、ディレクトリを横断してリソースのパラメータを参照することはできません。たとえば、ecrディレクトリからvpcディレクトリ配下にあるvpc_idといったパラメータを見れないのです。そこで登場するのがこの.tfstateファイル。こちらを参照することで、ディレクトリをまたがってリソースを参照できるようになります。

最後に、terraformコマンドを実行すれば設定通りにECRレポジトリが作成されます。

```zsh
terraform init  // 初期化
terraform plan  // 変更内容の確認
terraform apply // 変更を適用
```

### 3. デプロイに必要なIAM、VPC、EC2などの作成

上のような感じでどんどんAWS上にリソースを作っていきます。さすがに量が多いので全部は書けませんし、「[コンテナ時代のWebサービスの作り方](https://booth.pm/ja/items/1316133)」のコードをかなり参考にしたので公開するわけにもいきません。気になる人は「[コンテナ時代のWebサービスの作り方](https://booth.pm/ja/items/1316133)」を買いましょう。超おすすめです。

### 4. ECSのクラスター、サービス、タスク定義

このステップが大変でした。クラスター、サービス、タスクの概念がかなりぼんやりしていて、理解に時間がかかりました（今でも理解できているかは怪しいですが。。。）

ざっくり説明すると、クラスターは各コンテナ群が動作するインスタンス（EC2、Fargate）、サービスは文字通りそのサービスを動作させるのに必要なコンテナ群です。今回の例でいえば、Laravel APIを動作させるのにAppコンテナとNginxコンテナが必要であり、この2つのコンテナをまとめてサービスと呼びます。

そして、タスクは各コンテナの挙動を決定するものです。期待する挙動を記述したJSONファイルをもとにタスクが実行されることでコンテナがその挙動を示します。自分で書いててよくわかんなくなってきましたが、具体例を見るとわかりやすいかと思います。

```json:
[
  {
    "name": "app",
    "image": "755760073555.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest",
    "cpu": 300,
    "memoryReservation": 600,
    "essential": true,
    "portMappings": [
      {
        "hostPort": 8080,
        "containerPort": 8080
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "service",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "app"
      }
    }
  },
  {
    "name": "nginx",
    "image": "755760073555.dkr.ecr.ap-northeast-1.amazonaws.com/nginx:latest",
    "essential": true,
    "cpu": 200,
    "memoryReservation": 128,
    "portMappings": [
      {
        "hostPort": 80,
        "containerPort": 80
      }
    ],
    "links": [
      "app",
      "admin"
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "service",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "nginx"
      }
    }
  }
]
```

このタスクが実行されることで、AppコンテナとNginxコンテナが起動します。
ちょっと長いですが簡単に解説すると、「name」はコンテナの指定、「image」はどのコンテナイメージを利用するか（ステップ2で作ったECRからイメージを引っ張ってきます）などです。他の各プロパティについてはググってください。

cpuとかどれだけ確保すればいいのかよくわからなかったので、最初適当に設定していたらリソースが足りないと言われて2時間くらいハマりました。要注意です。

また、AppコンテナとNginxコンテナは通信できるようにしておく必要があります。NginxコンテナだけではPHPを実行できないので。Nginx側に以下のような記述がありますが、これでコンテナ同士の通信設定が可能になります。

```
"links": [
  "app",
],
```

これらもTerraformでリソースを作ります。必要なtfファイルを作成後、applyすればOKです。

これでAWS側の必要な条件は整いました。試しにローカルでコンテナをビルドしてECRにpushし、手動でタスクを実行すればAPIが動作します。

上記はコンテナ起動のタスクでしたが、Laravelのmigrationのタスクであればこんな感じに書きます。

```json:app-migrate.json
[
  {
    "name": "app",
    "image": "755760073555.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest",
    "cpu": 200,
    "memory": null,
    "memoryReservation": 600,
    "essential": true,
    "command": ["php", "artisan", "migrate"],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "service",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "app-migration"
      }
    }
  }
]
```

## 5. CircieCIの設定（.circleci/config.yml）

最後に、CircleCIの設定をしていきます。この設定が完了すると、LaravelアプリのCI/CD環境が完成します。結構長いので、workflow区切りで解説します。全部で3ステップです。こちらも「[コンテナ時代のWebサービスの作り方](https://booth.pm/ja/items/1316133)」を参考にしています。

```yml:config.yml
version: 2
jobs:
  build: # テストを行うworkflow
    docker:
      - image: circleci/php:7.3.9-fpm
      - image: circleci/mysql:8.0
        command: mysqld --default-authentication-plugin=mysql_native_password
        envirionment: # MySQLの初期設定
          - APP_DEBUG: true
          - APP_ENV: circle
          - DB_CONNECTION: circle_test
          - MYSQL_ALLOW_EMPTY_PASSWORD: true

    working_directory: ~/repo

    steps:
      - checkout

      - run:
          name: test_setup # テストを行うために必要なライブラリなどのインストール
          command: |
            sudo apt-get update && sudo apt-get install -y libpng-dev libjpeg-dev |
            sudo docker-php-ext-configure gd --with-png-dir=/usr/include --with-jpeg-dir=/usr/include |
            sudo docker-php-ext-install pdo_mysql gd
        
      - restore_cache: # composer.jsonのキャッシュがあれば利用
          keys:
          - v1-dependencies-{{ checksum "composer.json" }}
          - v1-dependencies-
    
      - run:
          name: composer install
          command: composer install -n --prefer-dist

      - save_cache: # composer.jsonをキャッシュ
          paths:
            - ./vendor
          key: v1-dependencies-{{ checksum "composer.json" }}

      - run:
          name: migration
          command: php artisan migrate --env=circle

      - run: # envファイルをcircleci用のものに変更
          name: env_copy
          command: rm .env.testing && ln -s .env.circle .env.testing

      - run:
          name: test_run
          command: ./vendor/bin/phpunit --testdox
```

この例ではMySQLでLaravelのテストが行うようにしています。phpunit.xml、database.php .envファイルなどに変更を加えておきましょう。

このworkflowでやっているのはテスト実行のみです。なので、全ブランチが対象となります。

次は、コンテナイメージをビルドしてECRにpushするworkflowです。ECR周りの権限が必要なので、IAMロールを作成して、CircleCIのProject settingsから環境変数として登録しておく必要があります。

```yml
build_image:
    docker:
      - image: circleci/php:7.3.9-fpm

    working_directory: ~/repo

    steps:
      - checkout:

      - run:
          name: setup Laravel # envをproduction用のものに変更&権限設定
          command: |
            ln -s .env.production .env
            sudo chmod -R 777 storage bootstrap/cache

      - run:
          name: composer install
          command: composer install -n --prefer-dist

      - run: # configファイルをキャッシュ
          name: create config cache
          command: php artisan config:cache

      - run:
          name: build container # PHPコンテナをビルド
          command: docker build -t ${ECR_DOMAIN_APP}:$CIRCLE_SHA1 -t ${ECR_DOMAIN_APP}:latest --build-arg TZ=${TZ} -f ./docker/app/Dockerfile .

      - run:
          name: install aws cli # コマンドラインからAWSを操作するためにaws-cliをインストール
          command: |
            curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"
            sudo python get-pip.py
            sudo pip install awscli

      - run:
          name: push docker image # ECRにコンテナイメージをpush
          command: |
            $(aws ecr get-login --no-include-email --region ap-northeast-1)
            docker push ${ECR_DOMAIN_APP}:$CIRCLE_SHA1
            docker push ${ECR_DOMAIN_APP}:latest
```

それぞれのrunごとに見ていけば複雑なことはやっていないことがわかるかと思います。ここまでの操作で、最新のmasterブランチの内容が格納されたAppコンテナがECRにpushされます。${}となっている箇所は環境変数なので、忘れずに登録しておきましょう。

次にデプロイを行うworkflowです。コンテナをpushするだけではまだ変更は反映されません。新しいタスクを定義し、それを実行することでECRの各レポジトリの最新イメージが使われるようになります。

```yml
deploy:
    docker:
      - image: circleci/python:3.7
    steps:
      - run:
          name: install aws cli
          command: sudo pip install awscli

      - run:
         name: download jq # JSONファイルを簡単に操作するためにjqをインストール
         command: |
           wget https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
           mv jq-linux64 jq

      - run:
          name: login ecr
          command: $(aws ecr get-login --no-include-email --region ap-northeast-1)

      - run:
          name: deploy # 新しいタスク定義ファイルを作成し、ECSに反映させる
          command: |
            aws ecs describe-task-definition --task-definition sample-service | jq '.taskDefinition | { containerDefinitions: .containerDefinitions }' > task_definition.json
            aws ecs register-task-definition --family sample-service --cli-input-json file://task_definition.json
            aws ecs update-service --cluster sample-ecs-cluster --service sample-service --task-definition sample-service

      - run:
          name: migration # マイグレーションタスクを実行
          command: aws ecs run-task --cluster sample-ecs-cluster --task-definition sample-app-migrate
```

deployの箇所が長くて複雑な感じですが、やっていることはAWSから最新のタスクを取得し、それを一度コンテナ内に保存して、そのファイルで既存タスクを更新しているだけです。同じファイルで更新しているので、タスク定義の内容は変わりません。ただ、タスク定義にはリビジョンという概念があり、タスクが更新されるとリビジョンも同じく更新されます。あとはサービス側で利用するタスクのリビジョンNoを変更してあげれば利用するタスクが更新され、結果使われるコンテナイメージが最新のものに変更されるというカラクリです。

この部分は、「[コンテナ時代のWebサービスの作り方](https://booth.pm/ja/items/1316133)」の本の中でも外部ライブラリを使ったよりスマートな方法があると解説されていたので、改善の余地はありそうです。

やっとconfig.ymlの最後の部分です。

```yaml
workflows:
  version: 2
  test:
    jobs:
      - build
      - build_image:
          requires:
            - build
          filters:
            branches:
              only: master
      - deploy:
          requires:
            - build_image
          filters:
            branches:
              only: master
```
この箇所については特に解説の必要もないかと思います。どのブランチのときにどのworkflowが走るかという設定ですね。deploy周りのworkflowはmasterブランチに変更が入ったときのみ動いてほしいので、そのように設定しています。

これでCircleClの設定も完了し、リポジトリに変更をpushすると自動テストが走り、masterにmergeされると自動デプロイが走るようになりました。

## まとめ

Dockerを含めたインフラには結構な苦手意識があったのですが、触る機会が増えたことによって徐々に理解できる領域が増えてきたように感じます。

ただ、インフラ構築や監視についての知識はまだまだまだまだといった感じなので、どんどんキャッチアップしていきたいなと思っています。