はじめに
AWS Copilot で Phoenix Framework の本番環境を構築するシリーズです
実装したサンプルはこちらに格納しています
実装するシステム
今回はクラスタリングされていることを分かりやすくするため、
Phoenix Channels を用いたチャットシステムを実装します
Phoenix Channels を使うと、 Websocket によるリアルタイム通信を簡単に実装できます
こちらの記事を参考に実装しました
このシステムはチャットの内容を DB には書き込んでいないため、
今まさに送信されたメッセージだけをやりとりします
システムのデプロイ
クラスタリング前のコードはこちら
複数台構成の動作を見たいので、
copilot/lb-svc/manifest.yml
の count
を spot: 2
に変更します
- 変更前
count: 1
- 変更後
count:
spot: 2
アプリケーション名を react-chat
、サービス名を lb-svc
として初期化します
copilot init \
--app react-chat \
--name lb-svc \
--type "Load Balanced Web Service"
test
環境を作ります
copilot env init \
--name test \
--profile <プロファイル名> \
--default-config \
--container-insights
環境変数 SECRET_KEY_BASE
を設定します
export SECRET_KEY_BASE="<任意の値>"
デプロイします
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 にアクセスすると、以下のような画面が表示されます
ユーザ名を入力して Join をクリックします
チャットにテキストを入力できるようになりました
クラスタリングしていない場合の動作
まず、コンテナの状態を確認してみましょう
$ 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 するまでログを追跡し続けます
$ copilot svc logs --tasks 049724e2 --follow
Found only one deployed service lb-svc in environment test
別のターミナルを開いてもう片方のコンテナのログを見ます
$ 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グループのチャットになると思います
同じコンテナに接続したものはチャットを共有しますが、違うコンテナ間ではチャットが共有されません
ブラウザからの接続はロードバランサーによって割り振られます
クライアントA と クライアントB が コンテナX に接続され、
クライアントC と クライアントD が コンテナY に接続されたとき、
それぞれのコンテナは別個に動いているため、情報が共有されていないのです
これはいけません
同じチャットに繋いでいるはずなのに、人によって違うチャットに入ってしまっています
DB を使って情報を共有する、という手段もありますが、
リアルタイム性や整合性を求める場合に問題があります
クラスタリングしていないことを別の方法でも確認しましょう
copilot svc exec --task-id <タスクID>
で、
コンテナ内に入ることができます(超便利!)
$ 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 のセッションに接続できます
$ ./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
で接続している(クラスタリングしている)ノードを確認します
iex(react_chat@ip-10-0-1-169)1> Node.list
[]
空配列が返ってきました
何も設定していないので当然です
というわけで、クラスタリングしましょう
クラスタリング方法
ECS 上でクラスタリングするので、ちょっと特殊な設定をする必要があります
実装済のものがこちらになります
参考記事:
libcluster の導入
libcluster は、設定さえしておけば自動的にクラスタリングしてくれる優秀なライブラリです
react_chat/mix.exs
の依存パッケージに libcluster を追加してください
defp deps do
[
...
{:libcluster, "~> 3.3"}
]
end
クラスタリング設定
react_chat/config/prod.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に登録した値です
libcluster は DNS(Route53)に lb-svc.test.react-chat.local
の名前解決をリクエストします
結果として、各コンテナの IP アドレスが全て取得できます
libcluster は node_basename
に指定した値と各IPアドレスを使って、ノードに接続しに行きます
この例だと、以下の2つのノードをクラスタリングすることになります
- 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
を以下のように設定することで変更可能です
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 実行時の環境変数を指定できます
ここで行っていることの意味を解説します
ECS のコンテナ上では ECS_CONTAINER_METADATA_URI
という環境変数が設定されています
/app # printenv ECS_CONTAINER_METADATA_URI
http://169.254.170.2/v3/049724e2f64e4197b6b67349b169876b-3196447412
ここの /task
にリクエストを投げて、 jq でパースしてみます
/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
export PUBLIC_HOSTNAME=`hostname -i`
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${PUBLIC_HOSTNAME}
クラスターの監視
react_chat/lib/react_chat/application.ex
を以下のように変更します
@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 上でコンテナの追加や変更を行えば自動的にクラスターが再構成されるため、
デプロイやオートスケーリング、一部コンテナの停止時でもシステムは止まらずに動き続けます
クラスタリングしている場合の動作
では、クラスタリングした状態のコードをデプロイしてみましょう
改めて、コードはこちら
コードを変更した状態でデプロイコマンドを実行します
copilot svc deploy \
--name lb-svc \
--env test
しばらくしてデプロイが完了したら、
表示された URL (前に表示されたものと同じ)にアクセスしてチャットに JOIN してみましょう
今度はいくつタブを開いて JOIN しても、
全てが同じチャットに入ります
コンテナがクラスタリングされたことにより、
コンテナ間でメッセージが全て共有されるようになりました
クラスタリングされたことをコンテナ内で確認してみましょう
タスクは全て変更されているので、改めてステータスを表示します
$ 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
表示されたタスクに接続します
$ copilot svc exec --task-id 15fd84db
コンテナ内で Phoenix セッションに接続します
/app # ./bin/react_chat remote
ノードの一覧を取得すると、もう片方のコンテナのノードが表示されました
iex(react_chat@10.0.1.144)1> Node.list
[:"react_chat@10.0.0.149"]
もう一台のコンテナで同じ操作をすると、同じように他方のコンテナと接続できています
iex(react_chat@10.0.0.149)1> Node.list
[:"react_chat@10.0.1.144"]
まとめ
libcluster の Elixir.Cluster.Strategy.DNSPoll を利用することにより、
ECR 内でのクラスタリングが実装できました
これでいつでも台数を増減したり、コンテナを入れ替えたりできます