PHP-FPM+NginxをCodePipelineを利用してECSにデプロイする

## 概要
DockerでPHP+Nginxの環境を作り、ECSにデプロイしブラウザでこれを表示することがゴールです。
![スクリーンショット 2022-12-07 23.05.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/4b1fb9b5-a949-806f-17e5-1b1ec6649245.png)
備忘録としてECSにデプロイするまでの最低限の手順を辿ることが目的なので、各サービスや用語等の説明はあまりありません。
GitリポジトリにはAWS CodeCommitを、デプロイ時のビルド作業はCodeBuildを利用します。今回はCodeDeployは利用しません。

# 構成
本記事はデプロイまでの手順に主眼を置いているので、構成は以下の通り最低限のものになっています。一番上の `buildspec.yml`はECSにデプロイする頃に作成するので、一旦は無視して大丈夫です。
```
.
├── buildspec.yml
├── docker-compose.yml
├── infra
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php
│       └── Dockerfile
└── src
    └── index.php
```


## CodeCommitの設定
GitホスティングサービスはCodeCommitを使います。Githubを使ったことがあれば特に難しいことはないと思います。まずこの設定をしていきます。
AWSにログインし、CodeCommitのページにアクセス、右上の「リポジトリを作成」をクリック。
![スクリーンショット 2022-12-08 22.08.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/ef8328a1-73fc-b822-d5e1-31106125bb8a.png)

今回`miniapp`という名前のプロジェクトにするので、リポジトリ名を`miniapp`にします。リポジトリ名だけ入力して作成ボタンをクリック。
![スクリーンショット 2022-12-08 22.11.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/b527bd27-69c2-edf8-793d-4a32fe67afd5.png)

次の画面で接続の仕方が表示されるので、説明通りにやればOKです。
![スクリーンショット 2022-12-08 22.24.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/066c4193-5b01-4355-eedb-c9758365c49f.png)

HTTPSでもSSHでもお好きな方法でリポジトリをローカルにクローンします。
```
$ git clone ssh://git-codecommit.{お使いのリージョン}.amazonaws.com/v1/repos/miniapp
```

#### Gitの初期操作
ローカルにリポジトリをクローンしてプロジェクトのディレクトリができたら一度リモートにpushしておきましょう。
```
$ cd miniapp
$ echo '<?php echo "Hello PHP + Nginx";' >> index.php
$ git add .
$ git commit -m 'first commit'
$ git push
```
index.phpは後ほど正しいディレクトリに移動させます。

## ローカルでアプリを完成させる

### docker-compose.ymlの作成
最初にdocker-compose.ymlを作成して、全体像を把握します。
```docker-compose.yml
version: '3.9'
services:
  app:
    build:
      context: .
      dockerfile: ./infra/php/Dockerfile

  web:
    build:
      context: .
      dockerfile: ./infra/nginx/Dockerfile
      target: local
    ports:
      - 80:80
```
見ての通りとても簡単な構成になります。appという名でPHPが動くコンテナを、webという名でNginxが動くコンテナを作成します。ローカルで動かす場合とECSにデプロイした場合で使いたいdockerイメージに少し差があるので、webコンテナの方に`target:local`との記述をしています。詳しくはDockerfileを作成する際に見ていきます。


### infra/php以下の作成
このディレクトリで必要なのはDockerfileのみです。今回はとりあえずPHPが動けば良いので、最低限以上のことはしません。
```Dockerfile
FROM php:8.1-fpm

RUN apt-get update && \
    apt-get -y install --no-install-recommends less git unzip libzip-dev libicu-dev libonig-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    docker-php-ext-install intl pdo_mysql zip bcmath

COPY ./src /data
```

```
FROM php:8.1-fpm
```
ベースとなるイメージは `php:8.1-fpm`を利用します。

```
RUN apt-get update && \
    apt-get -y install --no-install-recommends less git unzip libzip-dev libicu-dev libonig-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    docker-php-ext-install intl pdo_mysql zip bcmath
```

不要なものもありますが、とりあえず色々インストールしています。

```
COPY ./src /data
```

コンテナの `/data`以下を公開用のディレクトリにするので、ローカルの `src`を `/data`にコピーします。
後ほど作成しますがsrcディレクトリの中にGitの初期操作時に作ったindex.phpを移動し、それをブラウザで表示させるのがゴールです。


### infra/nginx以下の作成
ここではDockerfileと、appコンテナと通信するためのNginxの設定を書いたdefault.confを作成します。

#### default.conf

nginxのコンテナイメージに元々存在するdefault.confを上書きするためのファイルを作成します。

ここで確認しておくべき箇所はコメントを書いている2ヶ所です。1つ目は公開ディレクトリを`/data`にしている`root /data`と、2つ目はPHPを扱うappコンテナとの通信に関する`fastcgi_pass localhost:9000`の部分です。
`fastcgi_pass localhost:9000`に関して簡単に説明すると、後ほどECSにデプロイする際にはPHPが動いているappコンテナと通信するために通信先を`localhost`とする必要があるのですが、ローカルで動かす際には`app`というコンテナ名で通信先を指定したいので、ローカルで動かす場合のみこの記述を修正する必要があります。これに関しては後ほどDockerfile内でそれに対応する処理を書いていきます。

```default.conf
server {
    listen 80;
    server_name example.com;
    root /data;  # 公開するディレクトリを/dataに設定

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";

    index index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_pass localhost:9000;  # phpを処理する場合はappコンテナにバトンタッチするイメージ
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
```

#### src/index.php
```index.php
<?php
echo 'Hello PHP + Nginx';
```
index.phpは最初に作成したので、`src/`に移動させておきます。

```
$ mv index.php src/
```

#### Dockerfile

```Dockerfile
FROM nginx:latest AS production
COPY ./infra/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY ./src /data

FROM production AS local
RUN sed -i -e s/localhost:9000/app:9000/ /etc/nginx/conf.d/default.conf
```

```
FROM nginx:latest AS production
```
ベースとなるイメージは`nginx:latest`です。一番下にローカルでのみ実行するコマンドを記述しているのですが、ECSデプロイの際にはそれを適用したくないので、区別するために`production`という名前をつけています。

参考: [マルチステージ ビルドを使う](https://docs.docker.jp/develop/develop-images/multistage-build.html)

```
COPY ./infra/nginx/default.conf /etc/nginx/conf.d/default.conf
```
先ほど作成した`infra/nginx/default.conf`（nginxの設定ファイル）でコンテナイメージに元々存在するdefault.confを上書きします。

```
COPY ./src /data
```
PHPの方のDockerfileと同様です。

```
FROM production AS local
RUN sed -i -e s/localhost:9000/app:9000/ /etc/nginx/conf.d/default.conf
```
こちらには`local`という名前をつけています。ローカルではdocker-compose.ymlでwebコンテナの記述に`target: local`として`production`のイメージをビルドしてさらに`local`も適用します。CodePipelineを使ってECSをデプロイする際には、後ほど出てくる`buildspec.yml`にwebコンテナのビルドを記述する際`target: production`としてビルドに`production`しか適用されないようにします。

### いったん完成！

ここまでで以下のようなディレクトリ構成になりました。

```
.
├── docker-compose.yml
├── infra
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php
│       └── Dockerfile
└── src
    └── index.php
```

ではイメージをビルドしていきます。コマンドを実行するのはプロジェクトルートのディレクトリです。間違って`infra/`や`src/`で実行しないように気をつけましょう。

```
$ docker compose build
```

ビルドが完了したらイメージを確認します。
```
$ docker images
```

「REPOSITORY」に`miniapp_app`と`miniapp_web`が出てきたらOKです。
ビルドしたイメージからコンテナを作ります。

```
$ docker compose up -d
```
`-d`をつけることでデタッチモードにしていますが、アクセスした時のログなどをみたい場合は`-d`なしで実行します。その場合、コンテナを停止したいときは`control+C`でできます。
ブラウザで`localhost`にアクセスするとこちらの画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/773d658e-3afc-ac8a-85f6-8cda92ec955e.png)

ここまで確認できたらコンテナを削除しておきます。

```
$ docker compose down
```

出来上がったコードをリモートにpushします。
```
$ git add .
$ git commit -m 'create php and nginx app'
$ git push
```

### ECRへのpush
ECRにdockerイメージをpushします。ECRのコンソールへはECSのページの左にある「リポジトリ」からとべます。「リポジトリを作成」をクリック
![スクリーンショット 2022-12-10 23.35.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/313efbf8-8034-b441-272b-c3974508cd7f.png)


appコンテナ用のリポジトリとwebコンテナ用のリポジトリの2つを作ります。appコンテナ用ののものはリポジトリ名を`miniapp_app`、他はデフォルトのままで下の「リポジトリを作成」をクリック。webコンテナ用のものはリポジトリ名を`mini_web`として作成します。
![スクリーンショット 2022-12-10 23.46.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/c2624328-5eb5-fff0-3fc3-a008c7dad044.png)


できたリポジトリを確認すると右上に「プッシュコマンドの表示」があります。これを開くとECRにプッシュするための手順が書いてあるので、この通りに進めてappコンテナのイメージとwebコンテナのイメージをそれぞれプッシュします。AWS CLIからECRへアクセスする権限が必要となります。

## CodePipelineを使ってECSにデプロイする

ここからはECSにデプロイするための作業をしていきます。

### タスク定義

最初にタスク定義を作成します。ECSコンソール画面の左のメニューからタスク定義を選択、タスク定義画面に移ったら「新しいタスク定義の作成」をクリック
![スクリーンショット 2022-12-10 20.34.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/60295c3c-24ad-d637-7dbe-41d10641065d.png)

タスク定義名は`miniapp`とします。オペレーティングシステムにLinuxを選択します。
![スクリーンショット 2022-12-10 20.36.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/b1dade22-5f1d-65bf-f36d-15aa0a38d167.png)

タスク実行ロールは、「ecsTaskExcecutionRole」が存在すればそれを選択、ない場合は「新しいロールの作成」を選択。タスクサイズはメモリもCPUも最小のもの（メモリ0.５GBとCPU0.25vCPU）を選択します。ここまでできたらコンテナの追加をクリック。
![スクリーンショット 2022-12-10 20.40.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/fa729262-81e1-0c38-7d1d-617c7b0a279f.png)

appコンテナとwebコンテナの2つを作ります。最初にappコンテナです。コンテナ名は`app`、イメージは`ECRで作ったminiapp_appリポジトリの「latest」というイメージタグがついているもののURI`を使います。ポートマッピングは`9000`と入力してあとはデフォルトのままで「追加」をクリック。続いてもう一度「コンテナの追加」を押しwebコンテナを作ります。コンテナ名は`web`、イメージは`miniapp_appリポジトリの「latest」というイメージタグがついているもののURI`、ポートマッピングは`80`で作成してください。
![スクリーンショット 2022-12-11 21.29.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/68bfa2ab-807d-4bc5-c2e6-627c8406ffdd.png)
これでタスク定義の設定は完了です。スクロールして一番下にある「作成」をクリックします。

### クラスター

次はクラスターを作成します。ECSコンソールのクラスターから、「クラスターの作成」をクリック
![スクリーンショット 2022-12-10 18.39.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/ba565c74-ae66-18f3-91df-f8ea3fb30809.png)

今回はFargateでデプロイするのでデフォルトのままで「次のステップ」をクリック
![スクリーンショット 2022-12-10 18.40.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/96699905-a50a-87dd-9364-cf0939faab21.png)

クラスター名は`miniapp`とします。ここで新しくVPCを作成したい場合はVPCの作成にチェックをつけて設定をしましょう。「作成」をクリック。
![スクリーンショット 2022-12-10 18.42.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/11ccc391-9a1a-0bf0-b809-9a6d6927435b.png)

### サービス

次にサービスを作成します。「作成」をクリック。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3730373138322f33326231343564372d623163302d356265322d653433632d3531353163376239616331622e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/70a49c65-388a-618e-fabe-596cc7673b02.png)

項目はたくさんありますが入力するところはあまり多くありません。
起動タイプに`FARGATE`、タスク定義は`miniapp`、サービス名は`miniapp_service`、タスク数は`1`とします。他はデフォルトのままで「次のステップ」へいきます。
![スクリーンショット 2022-12-11 21.51.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/d6298cf2-1d2c-e1b6-b9e7-0e305284f597.png)

次はネットワーク構成の設定です。適切なVPC、サブネット、セキュリティグループを選択すれば大丈夫です。httpでアクセス可能なら問題ないと思います。これから作成する場合はこちらに書いているとおり作成すれば良いと思います。[Amazon ECS を使用するようにセットアップする](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/get-set-up-for-amazon-ecs.html)
他はデフォルトのままで「次のステップ」。
次はAutoScalingの設定です。今回は必要ないので「次のステップ」。
最後に設定を確認し、「サービスの作成」でサービスの作成は完了です。

### CodePipeline

CodePipelineに移ります。CodePipelineのページにアクセスし、右上の「パイプラインを作成する」をクリック。
![スクリーンショット 2022-12-08 22.40.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/dd14e331-caf0-b802-9b67-f914eecf05df.png)

パイプライン名は`miniapp`にします。パイプライン名を入力すると新しいサービスロールのロール名も自動的に入力されます。このロール名のまま使うことにします。「次に」をクリック。
![スクリーンショット 2022-12-08 22.47.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/a1c57799-552d-1895-201e-856c4f169132.png)

ソースプロバイダーとして`CodeCommit`を、リポジトリは先ほど作成した`miniapp`、ブランチ名は`main`を選択します。あとは初期値のままにしておきます。今後miniappのmainブランチに変更が生じたときにパイプラインが自動的に起動しデプロイがされることになります。「次に」をクリック。
![スクリーンショット 2022-12-08 22.48.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/6aa80f5c-352a-642d-36c2-cc6135febb4e.png)


ここではCodeBuildの設定をします。「プロバイダーを構築する」でAWS CodeBuildを選択。リージョンはお使いのリージョンを選択します。
ここでCodeBuildのビルドプロジェクトを作成します。「プロジェクトの作成」をクリック。
![スクリーンショット 2022-12-08 22.53.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/b5de8b54-75b0-0d4d-80e3-586515890d3b.png)

別のウィンドウでCodeBuildの画面が立ち上がるので設定していきます。プロジェクト名は`miniapp`にします。
![スクリーンショット 2022-12-10 18.15.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/c537f313-1b55-4ee3-f86b-6ee44d3a2e6f.png)


少し下に行き、環境の設定です。画像の通り選択します。
![スクリーンショット 2022-12-10 18.21.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/2e8af229-0964-c77f-87fe-5d30a22f5dc4.png)
![スクリーンショット 2022-12-10 18.21.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/14e5a6cb-10ca-bab7-e655-0b40b437bd7b.png)

これより下はデフォルトのままで大丈夫です。「CodePipelineに進む」をクリック。

これでCodeBuildのプロジェクトができたので、「次に」をクリック。
![スクリーンショット 2022-12-10 18.23.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/0bce1a7a-a78c-63c6-8789-37d42f4a7043.png)


ここでCodePipelineの設定は中断し、ローカルで必要なファイルを作成していきます。プロジェクトルートに`buildspec.yml`を作成します。こちらに書いているものを雛形に使っています。[Tutorial: Amazon ECS Standard Deployment with CodePipeline](https://docs.aws.amazon.com/codepipeline/latest/userguide/ecs-cd-pipeline.html)
日本語で記載している部分は適宜修正してください。

```buildspec.yml
version: 0.2

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws --version
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin {12桁のアカウントID}.dkr.ecr.{ap-northeast-1などのリージョン名}.amazonaws.com
      - REPOSITORY_URI_APP={ECRのminiapp_appリポジトリのURI}
      - REPOSITORY_URI_WEB={ECRのminiapp_webリポジトリのURI}
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - IMAGE_TAG=${COMMIT_HASH:=latest}
  build:
    commands:
      - echo Build started on `date`
      - echo Building the Docker image...
      - docker build -t $REPOSITORY_URI_APP:latest -f infra/php/Dockerfile .
      - docker build -t $REPOSITORY_URI_WEB:latest -f infra/nginx/Dockerfile --target production .
      - docker tag $REPOSITORY_URI_APP:latest $REPOSITORY_URI_APP:$IMAGE_TAG
      - docker tag $REPOSITORY_URI_WEB:latest $REPOSITORY_URI_WEB:$IMAGE_TAG
  post_build:
    commands:
      - echo Build completed on `date`
      - echo Pushing the Docker images...
      - docker push $REPOSITORY_URI_APP:latest
      - docker push $REPOSITORY_URI_WEB:latest
      - docker push $REPOSITORY_URI_APP:$IMAGE_TAG
      - docker push $REPOSITORY_URI_WEB:$IMAGE_TAG
      - echo Writing image definitions file...
      - printf '[{"name":"app","imageUri":"%s"}]' $REPOSITORY_URI_APP:$IMAGE_TAG > imagedefinitions.json
      - printf '[{"name":"web","imageUri":"%s"}]' $REPOSITORY_URI_WEB:$IMAGE_TAG > imagedefinitions.json
artifacts:
    files: imagedefinitions.json
```
mainブランチの変更でCodePipelineは発火します。CodeCommitからソースコードの情報がCodeBuildに渡され、それを元にCodeBuildのビルド環境で`buildspec.yml`に書かれたコマンドを実行しコンテナイメージをビルド、ECRにプッシュされます。
ここではwebコンテナの`docker build`に`target production`を指定することで、webコンテナのDockerfileから適切なイメージがビルドできます。

ここで改めてCodeCommitにプッシュします。
```
$ git add .
$ git commit -m 'created buildspec.yml'
$ git push
```

ここでCodePipelineコンソールに戻ります。
デプロイプロバイダーに`ECS`を選択、クラスター名、サービス名は先ほど作成したものを選択し、「次に」をクリック。
![スクリーンショット 2022-12-11 11.26.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/dd079647-c00e-699f-4e75-106bf5e79902.png)

「パイプラインを作成する」をクリックするとパイプラインが出来上がり、一連の流れが実行されます。
するとCodeBuildでパイプラインが失敗します。CodeBuildのビルドプロジェクト`miniapp`から入り、ビルド履歴で最新の「ビルドの実行」をクリック、「フェーズ詳細」タブを確認すると「PRE_BUILD」でECRにログインするコマンドが失敗しています。
![スクリーンショット 2022-12-11 20.50.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/bda367cb-5aee-81fd-7a56-f0fbdcdc8aab.png)
これはCodePipelineからECRにアクセスする権限がないためです。「ビルド詳細」タブの「環境」にサービスロールを見つけクリックします。
![スクリーンショット 2022-12-11 20.52.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/96b05d1a-afaa-eb1f-aca8-e4679cfed09f.png)

許可を追加から「ポリシーをアタッチ」をクリック
![スクリーンショット 2022-12-11 20.54.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/aed6031d-205d-3138-36fa-1fdad40bf98f.png)

「AmazonEC2ContainerRegistryPowerUser」を探してポリシーをアタッチします。
![スクリーンショット 2022-12-11 20.56.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/2fd4343f-1c2a-b866-0413-34969a2594b6.png)

これでビルドがうまくいくはずなのでCodePipelineに戻り再試行します。
![スクリーンショット 2022-12-11 20.58.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/707182/0c3051e1-9e48-aba9-8af6-f54f52ac710b.png)

これで完了です。この通りにやってうまく行かない場合は、権限周りに問題がある可能性が高いと思います。こけた所に関係するIAMロールなどの権限を確認してみましょう。
全て完了したら適宜ECSのサービスやECRリポジトリなど削除しておきましょう。