AWS Copilot で Elixir + Phoenix Framework の Web サイトを公開する

## はじめに

[Phoenix Framework][pf] で作った Web サイトをとりあえず [AWS] 上で公開したい！

でも、 EC2 でサーバーを立てたり SSH 接続して環境構築とかしたくない！

ちょっと動かしたいだけなので [Ansible] まで使いたくない！

AWS コンソールから VPC の設定とかもしたくない！

かといって [CloudFormation][cf] も [Terraform][tf] も面倒臭い！

という人向けの記事です

AWS Copilot を使えば何も考えずに Phoenix が AWS 上で動きます

※本番環境用となるとクラスタリングだったり、 mix release したりとかあるので、
あくまでも簡単に立ち上げるだけです

※ある程度 AWS や Docker の知識があることを前提としています

AWS Copilot 公式はこちら

https://aws.github.io/copilot-cli/ja/

本記事で実装したコードはこちら

https://github.com/RyoWakabayashi/phoenix-with-aws-copilot

## AWS Copilot とは

Copilot = 副操縦士の名前の通り、 AWS 上のシステム構築を補助してくれるツールです

[GitHub Copilot][gc] も優秀ですが、この副操縦士も超優秀です

「こんな感じでやっといて」で1から10まで全部やってしまいます

本記事で言うと、 Docker コンテナさえ用意しておけば、
[ECS] や [VPC] や [ALB] などをいい感じに設定してくれます

AWS Copulot CLI のインストール方法はこちら

https://aws.github.io/copilot-cli/ja/docs/getting-started/install/

## Web アプリケーションの作成

こちらのリポジトリーに沿って手順を説明します

https://github.com/RyoWakabayashi/phoenix-with-aws-copilot

まず、公開したい Web サイトを作ります

### ローカル環境構築

私は普段から asdf を愛用しているので、 [asdf] で Elixir をインストールします

asdf を使わない場合は Elixir 1.13.4 OTP 24 を何らかの手段でインストールしてください

```bash
asdf plugin add elixir
asdf install
```

これで `.too-versions` に記載されている Elixir 1.13.4 OTP 24 がインストールできます

次に Phoenix を使うための準備をします

```bash
mix local.hex --force \
  && mix archive.install hex phx_new --force \
  && mix local.rebar --force
```

### Phoenix プロジェクトの作成

上記リポジトリーではすでに作成済なので、同じプロジェクトを使う場合はこの手順を飛ばしてください

DB は使わないので、

```bash
mix phx.new sample_app --no-ecto
```

で Phoenix のプロジェクトを作成します

また、外部からアクセスしたいので `sample_app/dev.exs` の 12行目を変更します

変更前

```exs
  http: [ip: {127, 0, 0, 1}, port: 4000],
```

変更後

```exs
  http: [ip: {0, 0, 0, 0}, port: 4000],
```

### ローカルでの Phoenix 起動

sample_app ディレクトリーに移動して、 Phoenix を起動します

```
cd sample_app \
  && mix setup \
  && mix phx.server
```

以下のような表示が出たら http://localhost:4000 にアクセスしてください

```text
[info] Running SampleAppWeb.Endpoint with cowboy 2.9.0 at 0.0.0.0:4000 (http)
[info] Access SampleAppWeb.Endpoint at http://localhost:4000
[debug] Downloading esbuild from https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.29.tgz
[watch] build finished, watching for changes...
```

このような画面が表示されれば OK です

![スクリーンショット 2022-05-12 13.26.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/62dad7c0-8206-0492-f6d9-28f80f229131.png)

control + c で中断し、 a を入力して Phoenix を終了させます

## Docker での起動

次に Docker 上で動かしてみます

ローカルでインストールした依存パッケージやビルドしたファイルを削除してから、ルートディレクトリーに戻ります

```bash
rm -rf deps _build \
  && cd ..
```

コンテナは Dockerfile に以下のように定義しています

```dockerfile
FROM elixir:1.13.4

RUN mix local.hex --force \
  && mix archive.install hex phx_new --force \
  && mix local.rebar --force

COPY ./sample_app /app

WORKDIR /app

RUN mix deps.get

RUN mix compile.phoenix

EXPOSE 4000

CMD ["mix", "phx.server"]
```

デプロイ後、ヘルスチェックにすぐ応えられるよう
コンテナビルド時に `mix compile.phoenix` まで実行しています

また、 `EXPOSE 4000` としておけば、 Copilot が 4000 番ポートを使うことを察してくれます

docker-compose.yml は以下のように定義しています

```yml
---

version: '3.2'
services:
  web:
    container_name: phoenix
    build: .
    ports:
      - '4000:4000'
    command: mix phx.server
    environment:
      - MIX_ENV=dev
```

では、 docker compose で動かしてみましょう

```bash
docker compose up
```

以下のような表示が出たら再び http://localhost:4000 にアクセスしてください

```text
phoenix  | [info] Running SampleAppWeb.Endpoint with cowboy 2.9.0 at 0.0.0.0:4000 (http)
phoenix  | [info] Access SampleAppWeb.Endpoint at http://localhost:4000
phoenix  | [watch] build finished, watching for changes...
```

このような画面が表示されれば OK です（ローカル実行と同じ）

![スクリーンショット 2022-05-12 13.26.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/62dad7c0-8206-0492-f6d9-28f80f229131.png)

control + c でコンテナを停止させます

## AWS Copilot による AWS へのデプロイ

さあ、いよいよ AWS Copilot の出番です

少なくとも AWS アカウントと、操作を実行するための IAM ユーザーが必要になります

### IAM ユーザーのパーミッション

管理者権限を持っていて何でもできる IAM ユーザーであれば気にする必要はありませんが、
そうでない場合、以下のようなパーミッションが必要になります

※App Runner でのデプロイ時だけ使うパーミッションも含んでいます
※過剰になっている箇所があるかもしれません

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Fulls",
            "Effect": "Allow",
            "Action": [
                "cloudformation:*"
                "cloudtrail:LookupEvents",
                "codebuild:ListProjects",
                "codestar-connections:CreateConnection",
                "codestar-connections:ListConnections",
                "codestar-connections:PassConnection",
                "codestar-connections:TagResource",
                "codestar-connections:UntagResource"
                "iam:*",
                "ec2:*",
                "ecr:*",
                "ecs:CreateCluster",
                "s3:*",
                "secretsmanager:DescribeSecret",
                "servicediscovery:*"
            ],
            "Resource": "*"
        },
        {
            "Sid": "ECRCreateRole",
            "Effect": "Allow",
            "Action": [
                "iam:CreateServiceLinkedRole"
            ],
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    "iam:AWSServiceName": [
                        "replication.ecr.amazonaws.com"
                    ]
                }
            }
        },
        {
            "Sid": "CodeStarConnections",
            "Effect": "Allow",
            "Action": [
                "codestar-connections:DeleteConnection",
                "codestar-connections:GetConnection"
            ],
            "Resource": [
                "arn:aws:codestar-connections:*:<アカウントID>:connection/*"
            ]
        },
        {
            "Sid": "CodeBuild",
            "Effect": "Allow",
            "Action": [
                "codebuild:CreateProject",
                "codebuild:DeleteProject",
                "codebuild:UpdateProject"
            ],
            "Resource": [
                "arn:aws:codebuild:*:<アカウントID>:project/*"
            ]
        },
        {
            "Sid": "ECS",
            "Effect": "Allow",
            "Action": [
                "ecs:DeleteCluster",
                "ecs:DescribeClusters"
            ],
            "Resource": "arn:aws:ecs:*:<アカウントID>:cluster/*"
        },
        {
            "Sid": "SSM",
            "Effect": "Allow",
            "Action": [
                "ssm:DeleteParameter",
                "ssm:DeleteParameters",
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath",
                "ssm:PutParameter"
            ],
            "Resource": "arn:aws:ssm:*:<アカウントID>:parameter/*"
        },
        {
            "Sid": "STS",
            "Effect": "Allow",
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::<アカウントID>:role/*"
        }
    ]
}
```


### アプリケーションの初期化

アプリケーションとサービスを作成します

※作成しても、まだデプロイはしていない状態です

```bash
$ copilot init \
    --app sample-app \
    --name lb-svc \
    --type "Load Balanced Web Service"
Welcome to the Copilot CLI! We're going to walk you through some questions
to help you get set up with a containerized application on AWS. An application is a collection of
containerized services that operate together.

Manifest file for service lb-svc already exists. Skipping configuration.
Ok great, we'll set up a Load Balanced Web Service named lb-svc in application sample-app.

⠴ Creating the infrastructure to manage services and jobs under application sample-app.
```

`sample-app` がアプリケーション名、 `lb-svc` がサービス名です

`--type` には以下のものを指定可能です

- "Request-Driven Web Service"
- "Load Balanced Web Service"
- "Backend Service"
- "Worker Service"
- "Scheduled Job"

"Request-Driven Web Service" は [AWS App Runner][aar] ですが、
Web Socket に未対応なので使えません

https://github.com/aws/apprunner-roadmap/issues/13

"Load Balanced Web Service"　が ALB の後ろにコンテナを動かす Web サービスなので、
これを選択しています
（今回は1台なのでロードバランサーの意味はないですが、、、）

しばらく待つと以下のように質問されます

```text
All right, you're all set for local development.

  Would you like to deploy a test environment? [? for help] (y/N)
```

[CloudWatch][cw] の [Container Insights][ci] を使いたいので、ここでは `N` を入力してください

次に `test` 環境を作成します

`<プロファイル名>` の部分だけ自分のプロファイルに変更して実行してください

```bash
copilot env init \
  --name test \
  --profile <プロファイル名> \
  --default-config \
  --container-insights
```

作成に数分かかるため、しばらく待ちます

以下のような表示が出れば `test` 環境が作成できています

```bash
✔ Proposing infrastructure changes for the sample-app-test environment.
- Creating the infrastructure for the sample-app-test environment.            [create complete]  [93.7s]
  - An IAM Role for AWS CloudFormation to manage resources                    [create complete]  [30.3s]
  - An ECS cluster to group your services                                     [create complete]  [9.4s]
  - An IAM Role to describe resources in your environment                     [create complete]  [28.5s]
  - A security group to allow your containers to talk to each other           [create complete]  [6.1s]
  - An Internet Gateway to connect to the public internet                     [create complete]  [18.2s]
  - Private subnet 1 for resources with no internet access                    [create complete]  [4.3s]
  - Private subnet 2 for resources with no internet access                    [create complete]  [4.3s]
  - A custom route table that directs network traffic for the public subnets  [create complete]  [15.3s]
  - Public subnet 1 for resources that can access the internet                [create complete]  [5.7s]
  - Public subnet 2 for resources that can access the internet                [create complete]  [5.7s]
  - A private DNS namespace for discovering services within the environment   [create complete]  [50.2s]
  - A Virtual Private Cloud to control networking of your AWS resources       [create complete]  [12.8s]
✔ Created environment test in region ap-northeast-1 under application sample-app.
```

例えば、 ECS が以下のようにクラスターが作成されています

まだタスクは1つも実行されていない状態です

![スクリーンショット 2022-05-12 14.14.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1fb3183f-33f6-0202-244a-9a1563523c25.png)


### アプリケーションのデプロイ

以下のコマンドでデプロイします

```bash
copilot svc deploy \
  --name lb-svc \
  --env test
```

途中で以下のような表示が出ます

```
  - An ECS service to run and maintain your tasks in the environment cluster      [create in progress]  [17.3s]
    Deployments                                                                                         
               Revision  Rollout        Desired  Running  Failed  Pending                                       
      PRIMARY  5         [in progress]  1        0        0       1                                             
```

これは保留中のタスク（コンテナ）が1台、と言う意味で、コンテナが起動してヘルスチェックに成功すれは以下のような表示に切り替わります

```
  - An ECS service to run and maintain your tasks in the environment cluster      [create complete]  [100.5s]
    Deployments                                                                                       
               Revision  Rollout        Desired  Running  Failed  Pending                                     
      PRIMARY  5         [in progress]  1        1        0       0                                           
```

その後、以下のように表示されるので、 `http://sampl-` から始まる URL にアクセスしてください

```text
✔ Deployed service lb-svc.
Recommended follow-up action:
  - You can access your service at http://sampl-Publi-xxx-1316580721.ap-northeast-1.elb.amazonaws.com over the internet.
```

このような画面が表示されれば OK です（ローカル実行と同じ）

![スクリーンショット 2022-05-12 13.26.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/62dad7c0-8206-0492-f6d9-28f80f229131.png)

ついでに LiveDashboard も見てみましょう

![スクリーンショット 2022-05-12 14.29.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/18dea337-ae44-fc4a-7b22-76419928f213.png)

開発者ツールを使ってみれば、 WebSocket 通信できていることが確認できます

![スクリーンショット 2022-05-12 14.30.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b40c8924-d009-19e6-398a-a14400bf518f.png)

Container Insights を有効にしたため、 Cloud Watch からもコンテナの状態を確認できます

![スクリーンショット 2022-05-12 15.04.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/938b6381-abc7-c71a-4e93-f18eb693fab0.png)

## アプリケーションの更新

では、少し変更してデプロイしてみましょう

`sample_app/lib/sample_app_web/templates/page/index.html.heex` の先頭を以下のように変えてみます

`name: "Phoenix"` の部分を `name: "AWS"` に変更しています

```exs
<section class="phx-hero">
  <h1><%= gettext "Welcome to %{name}!", name: "AWS" %></h1>
  <p>Peace of mind from prototype to production</p>
</section>
```

この状態で、もう一度デプロイコマンドを実行します

```bash
copilot svc deploy \
  --name lb-svc \
  --env test
```

コンテナのビルドが走り、再びデプロイが開始されます

今度は以下のような表示になります

```
  - An ECS service to run and maintain your tasks in the environment cluster  [update in progress]  [7.8s]
    Deployments                                                                                     
               Revision  Rollout        Desired  Running  Failed  Pending                                   
      PRIMARY  6         [in progress]  1        0        0       1                                         
      ACTIVE   5         [completed]    1        1        0       0                                         
```

現在1台コンテナが動いていて、裏で新しい1台が起動しようとしています

しばらくすると以下のようになり、

```
  - An ECS service to run and maintain your tasks in the environment cluster  [update in progress]  [115.7s]
    Deployments                                                                                     
               Revision  Rollout        Desired  Running  Failed  Pending                                   
      PRIMARY  6         [in progress]  1        1        0       0                                         
      ACTIVE   5         [completed]    1        1        0       0                                         
```

こうなって、

```
  - An ECS service to run and maintain your tasks in the environment cluster  [update in progress]  [210.3s]
    Deployments                                                                                     
               Revision  Rollout        Desired  Running  Failed  Pending                                   
      PRIMARY  6         [in progress]  1        1        0       0                                         
      ACTIVE   5         [completed]    1        0        0       0                                         
```

こうなります

```
  - An ECS service to run and maintain your tasks in the environment cluster  [update in progress]  [258.0s]
    Deployments                                                                                     
               Revision  Rollout        Desired  Running  Failed  Pending                                   
      PRIMARY  6         [in progress]  1        1        0       0                                         
```

つまり、旧バージョンが動いている裏で新バージョンを起動させ、起動し終わってから新バージョンに切り替えているわけです

これによって無停止でのデプロイができるようになっています

最終的に以下のように表示されれば更新完了です

```text
  - An ECS service to run and maintain your tasks in the environment cluster  [update complete]  [258.9s]
    Deployments                                                                                   
               Revision  Rollout      Desired  Running  Failed  Pending                                   
      PRIMARY  6         [completed]  1        1        0       0                                         
  - An ECS task definition to group your containers and run them on ECS       [delete complete]  [0.0s]
✔ Deployed service lb-svc.
Recommended follow-up action:
  - You can access your service at http://sampl-Publi-xxx-1316580721.ap-northeast-1.elb.amazonaws.com over the internet.
```

URL にアクセスすると表示が更新されています

![スクリーンショット 2022-05-12 14.45.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f0b4dd67-2152-6727-efb6-8ca67bdc98d3.png)

以降は `copilot svc deploy ...` を実行するだけでデプロイできるので、非常に楽ですね


## リソースの変更

copilot/lb-svc/manifest.yml の `cpu` と `memory` を変更することで、
コンテナに割り当てるリソースを調整することができます

[ただし、設定できる値の組み合わせには制限があります][resource]

```yml
cpu: 256       # Number of CPU units for the task.
memory: 512    # Amount of memory in MiB used by the task.
count: 1       # Number of tasks that should be running in your service.
```

## アプリケーションの削除

以下のコマンドでアプリケーション、環境、サービスを全て削除できます

アプリケーションを削除しないと課金され続けるので注意してください

```bash
> copilot app delete
...
✔ Deleted service lb-svc from environment test.
✔ Deleted resources of service lb-svc from application sample-app.
✔ Deleted service lb-svc from application sample-app.
✔ Deleted environment test from application sample-app.
✔ Cleaned up deployment resources.
✔ Deleted application resources.
✔ Deleted application configuration.
✔ Deleted local .workspace file.
```

## おわりに

Elixir と言えば、昔は [edeliver] を使ってデプロイしていて、
これがややこしかったり、途中でエラーになったりでリリース作業が毎回嫌でした

その後 `mix release` ができて改善されたものの、
[この記事][deps_error]や[この記事][release_error]で書いたようなエラーが度々発生しているので、
未だに、特に久しぶりのリリース作業だと嫌になります

AWS Copilot を使って ECS で動かすようにしてしまえば、
リリース関連の不安からはキレイサッパリ解放されるので、
この方法を本番運用に使えるように試行錯誤していきたいです

[pf]: https://phoenixframework.org/
[aws]: https://aws.amazon.com/jp/
[ansible]: https://www.ansible.com/
[cf]: https://aws.amazon.com/jp/cloudformation/
[tf]: https://www.terraform.io/
[gc]: https://copilot.github.com/
[ecs]: https://aws.amazon.com/jp/ecs/
[vpc]: https://aws.amazon.com/jp/vpc/
[alb]: https://aws.amazon.com/jp/elasticloadbalancing/
[asdf]: https://github.com/asdf-vm/asdf
[aar]: https://aws.amazon.com/jp/apprunner/
[cw]: https://aws.amazon.com/jp/cloudwatch/
[ci]: https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/ContainerInsights.html
[resource]: https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-cpu-memory-error.html
[edeliver]: https://github.com/edeliver/edeliver
[deps_error]: https://qiita.com/RyoWakabayashi/items/2ac9c633452a7bc80bee
[release_error]: https://qiita.com/RyoWakabayashi/items/ea8f567bf12863d9145e