AWS Copilot で Phoenix Framework の本番環境を構築する クラスタリング編

## はじめに

AWS Copilot で Phoenix Framework の本番環境を構築するシリーズです

- [コスト・料金](https://qiita.com/RyoWakabayashi/private/a170c2ce9dd3cd1fe9ff)
- [リリース用ビルド](https://qiita.com/RyoWakabayashi/private/21baa60f9fcf64835a49)
- クラスタリング <- 今ここ
- [データベース](https://qiita.com/RyoWakabayashi/private/660b87f9deaaa179545d)
- [認証](https://qiita.com/RyoWakabayashi/private/9ea90df3970ec6e9d272)

実装したサンプルはこちらに格納しています

https://github.com/RyoWakabayashi/phoenix-channels-samples

## 実装するシステム

今回はクラスタリングされていることを分かりやすくするため、
Phoenix Channels を用いたチャットシステムを実装します

https://hexdocs.pm/phoenix/channels.html

Phoenix Channels を使うと、 Websocket によるリアルタイム通信を簡単に実装できます

こちらの記事を参考に実装しました

https://qiita.com/sand/items/8b3733366e85d0cf9082

このシステムはチャットの内容を DB には書き込んでいないため、
今まさに送信されたメッセージだけをやりとりします

## システムのデプロイ

クラスタリング前のコードはこちら

https://github.com/RyoWakabayashi/phoenix-channels-samples/tree/without-cluster

複数台構成の動作を見たいので、
`copilot/lb-svc/manifest.yml` の `count` を `spot: 2` に変更します

- 変更前

```yml
count: 1
```

- 変更後

```yml
count:
  spot: 2
```

アプリケーション名を `react-chat` 、サービス名を `lb-svc` として初期化します

```bash
copilot init \
  --app react-chat \
  --name lb-svc \
  --type "Load Balanced Web Service"
```

`test` 環境を作ります

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

環境変数 `SECRET_KEY_BASE` を設定します

```bash
export SECRET_KEY_BASE="<任意の値>"
```

デプロイします

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

しばらくすると以下のように表示されます

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

表示された　URL にアクセスすると、以下のような画面が表示されます

![スクリーンショット 2022-06-01 10.15.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c849233b-ef69-78f5-6abe-67125657b0c6.png)

ユーザ名を入力して Join をクリックします

チャットにテキストを入力できるようになりました

![スクリーンショット 2022-06-01 10.16.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b7539bbf-e93c-d9d8-668d-ea70f2a946af.png)

## クラスタリングしていない場合の動作

まず、コンテナの状態を確認してみましょう

```bash
$ copilot svc status
Found only one deployed service lb-svc in environment test
Task Summary

  Running            ██████████  2/2 desired tasks are running
  Health             ██████████  2/2 passes HTTP health checks
  Capacity Provider  ▓▓▓▓▓▓▓▓▓▓  2/2 on Fargate Spot

Tasks

  ID        Status      Revision    Started At      Capacity      HTTP Health
  --        ------      --------    ----------      --------      -----------
  049724e2  RUNNING     45          12 minutes ago  FARGATE_SPOT  HEALTHY
  72c217d7  RUNNING     45          12 minutes ago  FARGATE_SPOT  HEALTHY
```

FARGATE_SPOT で 2台 のコンテナが動いています

それぞれのコンテナのログを見てみましょう

`--tasks` に表示されたタスク一覧の ID を指定します

`--follow` で control + C するまでログを追跡し続けます

```bash
$ copilot svc logs --tasks 049724e2 --follow
Found only one deployed service lb-svc in environment test
```

別のターミナルを開いてもう片方のコンテナのログを見ます

```bash
$ copilot svc logs --tasks 72c217d7 --follow
Found only one deployed service lb-svc in environment test
```

この状態でチャットの LEAVE ボタンを押し、改めて JOIN します

すると、以下のようなログがどちらか片方にだけ表示されます

```
copilot/lb-svc/049724e2f6 01:25:46.457 [info] CONNECTED TO ReactChatWeb.UserSocket in 50µs
copilot/lb-svc/049724e2f6   Transport: :websocket
copilot/lb-svc/049724e2f6   Serializer: Phoenix.Socket.V2.JSONSerializer
copilot/lb-svc/049724e2f6   Parameters: %{"token" => "undefined", "vsn" => "2.0.0"}
copilot/lb-svc/049724e2f6 01:25:46.490 [info] JOINED room:lobby in 16µs
copilot/lb-svc/049724e2f6   Parameters: %{"user_name" => "bbb"}
```

では、ブラウザの別タブでチャットシステムを開き、別のユーザ名で JOIN してみましょう

一回では同じコンテナに接続されるかもしれませんが、何度かやっているうちに別のコンテナのログに接続結果が表示されるはずです

```
copilot/lb-svc/72c217d7eb 01:28:18.451 [info] CONNECTED TO ReactChatWeb.UserSocket in 42µs
copilot/lb-svc/72c217d7eb   Transport: :websocket
copilot/lb-svc/72c217d7eb   Serializer: Phoenix.Socket.V2.JSONSerializer
copilot/lb-svc/72c217d7eb   Parameters: %{"token" => "undefined", "vsn" => "2.0.0"}
copilot/lb-svc/72c217d7eb 01:28:18.484 [info] JOINED room:lobby in 16µs
copilot/lb-svc/72c217d7eb   Parameters: %{"user_name" => "xxx"}
```

4つか5つくらい開くと、2グループのチャットになると思います

同じコンテナに接続したものはチャットを共有しますが、違うコンテナ間ではチャットが共有されません

![スクリーンショット 2022-06-01 10.34.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a8902269-6f50-a125-f2fe-49e3017a21cb.png)

ブラウザからの接続はロードバランサーによって割り振られます

クライアントA と クライアントB が コンテナX に接続され、
クライアントC と クライアントD が コンテナY に接続されたとき、
それぞれのコンテナは別個に動いているため、情報が共有されていないのです

![without_clustering.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/228257d2-9a51-a5e1-211a-73c1d68547a0.png)

これはいけません

同じチャットに繋いでいるはずなのに、人によって違うチャットに入ってしまっています

DB　を使って情報を共有する、という手段もありますが、
リアルタイム性や整合性を求める場合に問題があります

クラスタリングしていないことを別の方法でも確認しましょう

`copilot svc exec --task-id <タスクID>` で、
コンテナ内に入ることができます（超便利！）

```bash
$ copilot svc exec --task-id 049724e2
Found only one deployed service lb-svc in environment test
Execute `/bin/sh` in container lb-svc in task 049724e2f64e4197b6b67349b169876b.

Starting session with SessionId: ecs-execute-command-06b484046dfea7346
/app # 
```

コンテナ内で Phoenix のセッションに接続してみましょう

`<mix release で生成された実行可能ファイル> remote` で Phoenix のセッションに接続できます

```bash
$ ./bin/react_chat remote
Erlang/OTP 24 [erts-12.3.2] [source] [64-bit] [smp:2:2] [ds:2:2:10] [async-threads:1] [jit:no-native-stack]

Interactive Elixir (1.13.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(react_chat@ip-10-0-1-169)1> 
```

`Node.list` で接続している（クラスタリングしている）ノードを確認します

```bash
iex(react_chat@ip-10-0-1-169)1> Node.list
[]
```

空配列が返ってきました

何も設定していないので当然です

というわけで、クラスタリングしましょう

## クラスタリング方法

ECS 上でクラスタリングするので、ちょっと特殊な設定をする必要があります

実装済のものがこちらになります

https://github.com/RyoWakabayashi/phoenix-channels-samples/tree/with-cluster

参考記事:

https://silbernagel.dev/posts/deploying-elixir-on-ecs-part-3/

### libcluster の導入

libcluster は、設定さえしておけば自動的にクラスタリングしてくれる優秀なライブラリです

https://github.com/bitwalker/libcluster

`react_chat/mix.exs` の依存パッケージに libcluster を追加してください

```exs
  defp deps do
    [
      ...
      {:libcluster, "~> 3.3"}
    ]
  end
```

### クラスタリング設定

`react_chat/config/prod.exs` に以下の設定を加えます

```exs
config :libcluster,
  topologies: [
    dns_poll_example: [
      strategy: Elixir.Cluster.Strategy.DNSPoll,
      config: [
        polling_interval: 5_000,
        query: "lb-svc.test.react-chat.local",
        node_basename: "react_chat"
      ]
    ]
  ]
```

`strategy` でどうやってクラスタリング先を特定するのか、を指定します

`Elixir.Cluster.Strategy.DNSPoll` の場合、 DNS の名前解決を利用します

`query` で指定している `"lb-svc.test.react-chat.local"` が肝です

これは `"<サービス名>.<環境名>.<アプリケーション名>.local"` になっています

このドメインは　Copilot　が自動的にプライベートDNSに登録した値です

![スクリーンショット 2022-06-01 11.54.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7c6d3059-ec0e-54b4-f662-6665daf83e0a.png)

libcluster は DNS(Route53)に `lb-svc.test.react-chat.local` の名前解決をリクエストします

結果として、各コンテナの IP アドレスが全て取得できます

libcluster は `node_basename` に指定した値と各IPアドレスを使って、ノードに接続しに行きます

この例だと、以下の２つのノードをクラスタリングすることになります

- react_chat@10.0.1.169
- react_chat@10.0.0.117

ただし、デフォルトのリリースだと、
ノード名が `react_chat@ip-10-0-1-169` という形式になってしまっているので、
次にノード名を変更します

### ノード名の変更

ノード名は `react_chat/rel/env.sh.eex` を以下のように設定することで変更可能です

```bash
export PUBLIC_HOSTNAME=`curl ${ECS_CONTAINER_METADATA_URI}/task | jq -r ".Containers[0].Networks[0].IPv4Addresses[0]"`
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${PUBLIC_HOSTNAME}
```

`rel/env.sh.eex` は Erlang VM 実行時の環境変数を指定できます

https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html

ここで行っていることの意味を解説します

ECS のコンテナ上では `ECS_CONTAINER_METADATA_URI` という環境変数が設定されています

```bash
/app # printenv ECS_CONTAINER_METADATA_URI
http://169.254.170.2/v3/049724e2f64e4197b6b67349b169876b-3196447412
```

ここの `/task` にリクエストを投げて、 jq でパースしてみます

https://stedolan.github.io/jq/

```bash
/app # curl ${ECS_CONTAINER_METADATA_URI}/task | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1518  100  1518    0     0   236k      0 --:--:-- --:--:-- --:--:--  247k
{
  "Cluster": "arn:aws:ecs:ap-northeast-1:xxx:cluster/react-chat-test-Cluster-fxvr1iv0DCPA",
  "TaskARN": "arn:aws:ecs:ap-northeast-1:xxx:task/react-chat-test-Cluster-fxvr1iv0DCPA/049724e2f64e4197b6b67349b169876b",
  "Family": "react-chat-test-lb-svc",
  "Revision": "45",
  "DesiredStatus": "RUNNING",
  "KnownStatus": "RUNNING",
  "Containers": [
    {
      "DockerId": "049724e2f64e4197b6b67349b169876b-3196447412",
      "Name": "lb-svc",
      "DockerName": "lb-svc",
      "Image": "xxx.dkr.ecr.ap-northeast-1.amazonaws.com/react-chat/lb-svc@sha256:bef4ecf131400c5a00b37aca5ca67aa29c029aa714c3bfb89aa5b76ef81aa8b4",
      "ImageID": "sha256:bef4ecf131400c5a00b37aca5ca67aa29c029aa714c3bfb89aa5b76ef81aa8b4",
      "Labels": {
        "com.amazonaws.ecs.cluster": "arn:aws:ecs:ap-northeast-1:xxx:cluster/react-chat-test-Cluster-fxvr1iv0DCPA",
        "com.amazonaws.ecs.container-name": "lb-svc",
        "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:xxx:task/react-chat-test-Cluster-fxvr1iv0DCPA/049724e2f64e4197b6b67349b169876b",
        "com.amazonaws.ecs.task-definition-family": "react-chat-test-lb-svc",
        "com.amazonaws.ecs.task-definition-version": "45"
      },
      "DesiredStatus": "RUNNING",
      "KnownStatus": "RUNNING",
      "Limits": {
        "CPU": 2
      },
      "CreatedAt": "2022-06-01T00:31:34.964143832Z",
      "StartedAt": "2022-06-01T00:31:34.964143832Z",
      "Type": "NORMAL",
      "Networks": [
        {
          "NetworkMode": "awsvpc",
          "IPv4Addresses": [
            "10.0.1.169"
          ]
        }
      ]
    }
  ],
  "Limits": {
    "CPU": 0.25,
    "Memory": 512
  },
  "PullStartedAt": "2022-06-01T00:31:31.890734978Z",
  "PullStoppedAt": "2022-06-01T00:31:33.644375682Z",
  "AvailabilityZone": "ap-northeast-1c"
}
```

このように、コンテナの情報が取得できます

jq のクエリで `.Containers[0].Networks[0].IPv4Addresses[0]` としているので、
`Containers` の先頭の `Networks` の先頭の `IPv4Addresses` の先頭、
つまり `10.0.1.169` が取得できます

つまり、このコンテナの IP アドレスです

`env.sh.eex` を実行すると、
`PUBLIC_HOSTNAME` に `10.0.1.169` が設定され、
`RELEASE_NODE` が `react_chat@10.0.1.169` になります

ただし、 `hostname -i` でも IP アドレスが取得できるので、以下のように指定した方がシンプルかもしれません

`react_chat/rel/env.sh.eex`

```bash
export PUBLIC_HOSTNAME=`hostname -i`
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${PUBLIC_HOSTNAME}
```

### クラスターの監視

`react_chat/lib/react_chat/application.ex` を以下のように変更します

```exs
  @impl true
  def start(_type, _args) do
    topologies = Application.get_env(:libcluster, :topologies) || []

    children = [
      ...
      {Cluster.Supervisor, [topologies, [name: ReactChat.ClusterSupervisor]]}
    ]
    ...
  end
```

これによって、 `Cluster.Supervisor` が DNS に定期的にリクエストを送り、
クラスタリング対象の追加、削除を自動的に行ってくれます

ECS 上でコンテナの追加や変更を行えば自動的にクラスターが再構成されるため、
デプロイやオートスケーリング、一部コンテナの停止時でもシステムは止まらずに動き続けます

## クラスタリングしている場合の動作

では、クラスタリングした状態のコードをデプロイしてみましょう

改めて、コードはこちら

https://github.com/RyoWakabayashi/phoenix-channels-samples/tree/with-cluster

コードを変更した状態でデプロイコマンドを実行します

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

しばらくしてデプロイが完了したら、
表示された URL （前に表示されたものと同じ）にアクセスしてチャットに JOIN してみましょう

今度はいくつタブを開いて JOIN しても、
全てが同じチャットに入ります

![スクリーンショット 2022-06-01 13.52.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/153a95d6-39f4-8450-a6b9-b5984181aa4e.png)

コンテナがクラスタリングされたことにより、
コンテナ間でメッセージが全て共有されるようになりました

![with_clustering.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e75aefc6-d20c-3f28-a4d3-50a1356656cd.png)

クラスタリングされたことをコンテナ内で確認してみましょう

タスクは全て変更されているので、改めてステータスを表示します

```bash
$ copilot svc status                          
Found only one deployed service lb-svc in environment test
Task Summary

  Running            ██████████  2/2 desired tasks are running
  Health             ██████████  2/2 passes HTTP health checks
  Capacity Provider  ▓▓▓▓▓▓▓▓▓▓  2/2 on Fargate Spot

Stopped Tasks

  Reason                          Task Count  Sample Task IDs
  ------                          ----------  ---------------
  Essential container in task ex  2           68bdc0b7,997c30e7
  ited                                        

Tasks

  ID        Status      Revision    Started At      Capacity      HTTP Health
  --        ------      --------    ----------      --------      -----------
  15fd84db  RUNNING     46          28 minutes ago  FARGATE_SPOT  HEALTHY
  8664e620  RUNNING     46          28 minutes ago  FARGATE_SPOT  HEALTHY
```

表示されたタスクに接続します

```bash
$ copilot svc exec --task-id 15fd84db
```

コンテナ内で Phoenix セッションに接続します

```bash
/app # ./bin/react_chat remote
```

ノードの一覧を取得すると、もう片方のコンテナのノードが表示されました

```bash
iex(react_chat@10.0.1.144)1> Node.list
[:"react_chat@10.0.0.149"]
```

もう一台のコンテナで同じ操作をすると、同じように他方のコンテナと接続できています

```bash
iex(react_chat@10.0.0.149)1> Node.list
[:"react_chat@10.0.1.144"]
```

## まとめ

libcluster の Elixir.Cluster.Strategy.DNSPoll を利用することにより、
ECR 内でのクラスタリングが実装できました

これでいつでも台数を増減したり、コンテナを入れ替えたりできます