PhoenixをDockerに乗せてAWS ECS上で運用している. PhoenixはPubSubの機構を持っているが,このバックエンドは,デフォルトではPG2というErlangクラスタ内のプロセス間通信を前提にしたものになっている.
ECSのようなDockerクラスタ上にPhoenixを載せる際は,たいていこの部分をPG2ではなくRedis等にして,Erlangクラスタは構成できないがRedisを介してPubSubのメッセージがやり取りできる状態を作ったりする.
ただ,Redisバックエンドはいまいち安定しないという噂と,そもそもErlangVM自身がそういう機能を持っており,バックグラウンドプロセスを分散できるのであればそれを活用したくなるものである.
ElixirでErlangクラスタを構成する方法
ローカルホスト
別プロセスとの接続
とりあえず適当なPhoenixのプロジェクトを用意しておく.
それぞれシェルを2つ立ち上げ,
$ iex --sname first iex(first@hakone)1> Node.list []
$ iex --sname second iex(second@hakone)1> Node.connect(:first@hakone) true iex(second@hakone)2> Node.list [:first@hakone]
となる.
ただ,これをプロセス起動時に毎回やるのはめんどくさいので,自動的に接続できるようにしたい.
起動時の自動接続
sys.config
というファイルを用意し,以下のように記述する.
[{kernel, [ {sync_nodes_optional, ['n1@127.0.0.1', 'n2@127.0.0.1']}, {sync_nodes_timeout, 10000} ]} ].
そして先程と同じようにシェルを2つ立ち上げておき,
$ iex --name n1@127.0.0.1 --erl "-config sys.config" iex(n1@127.0.0.1)1>
$ iex --name n2@127.0.0.1 --erl "-config sys.config" iex(n2@127.0.0.1)1> Node.list [:"n1@127.0.0.1"] iex(n2@127.0.0.1)2>
というようになる.
これでElixirのプロセス起動時にErlangクラスタが構築されるようになる.
別のホスト間
別ホストのErlangVMに通信するには --cookie
を指定するだけでいい.
$ iex --name n1@10.0.0.1 --cookie hogehoge --erl "-config sys.config"
$ iex --name n2@10.0.0.2 --cookie hogehoge --erl "-config sys.config"
ここで,ポートの指定をしていない. これはErlangがepmd(Erlang Process Manager Daemon)というものにより実現されている.
実際にプロセス間通信に用いられるポートはランダムに決められているが,これをepmdが管理している.そのため,ホストさえ指定できれば,そのホストのepmdに通信できれば実際にプロセス間通信が可能となる. epmdは常に4369ポートで動いている.そのため,ここは必ず開放しておく必要がある.
AWS ECS上でどうすればいいか
必要な情報は何か
必要となるのは自身のノードの名前(なんでもいいけど一意にはなってほしい)とIPアドレス. そして,兄弟ノードの名前とIPアドレスだ.
そこで,名前はすべてECS TaskのIDとすることにする.IPはEC2上で実行していればEC2のIPだけで良いはず(今回,awsvpcでの話は一旦除外する).
あとはタスク起動時に,entrypoint内でこれらの情報を取得し,sys.configを生成できれば問題ない.
ecs_erlang_cluster
というわけでそのような仕事をしてくれるコマンドを作った.
goで作ろうかとも思ったのだが,どうせこれを実行する環境であるならErlangの実行環境は絶対に揃っているだろうし,escriptで作った.
後々考えるとgoの方が良かったかもしれない.
$ mix escript.install github h3poteto/ecs_erlang_cluster
でインストールできる.
自身のTaskIDとIPの取得
中身の話を少しする.
ECSが起動時に自分自身のタスクIDを調べるには,metadataURIを使えば良い.
ここから自身のタスクに関する情報が取れる. ただし,NetworkConfigurationがbridgeである場合には,ホストとなるEC2のネットワークを利用しているため,ここからIPを取得することができない(逆にawsvpcの場合はこのレスポンスからIPが取得できる).
そこで,今度はEC2のmetadataを調べてIPを調べる.
今回,ノード間の通信はVPC内通信に閉じるつもりだったため,PrivateIPを抽出している.
兄弟タスクの情報取得
次に,接続先のノードの情報を取得する必要がある.これはAWSのAPIを用いれば問題なく取得できる.
ただし,その際にクラスタとサービスの情報が必要となるため,これは引数で取ることにする.
- まず,list-tasksを叩いて該当サービスのタスク一覧を取得する
- 次に各々のタスクについてdescribe-tasksを叩いて詳細情報を取得する
- この情報によりまずはタスクIDが取得できる
- 更に,この中にcontainer_instanceの情報が入っているので,それを元にdescribe-container-instancesを叩く
- このレスポンスにec2InstanceIdが含まれるので,これを元にdescribe-instancesすると,レスポンスからPrivateIPが取得できる
- 最後にこれらの情報をまとめてsys.configを生成する
という手順でやっている.
使い勝手
まず自身のタスクIDとIPのセットを取得する.
$ export ONESELF=`ecs_erlang_cluster oneself`
次に,sys.configを生成する.
$ ecs_erlang_cluster generate \ --cluster your_cluster_name \ --service your_service_name \ --region ap-northeast-1 \ --minport 4370 \ --maxport 4370
(minport, maxportについては後述).
あとは,これをelixirの起動コマンドに食わせる.
$ iex --name $ONESELF --cookie hogehoge --erl "-config sys.config" -S mix
というようなのをentrypointに書いておくと,ECSのタスク起動時に自動的に既存のクラスタに参加することができる.
ポートについて
さて,簡単な使い方は以上なのだが,ポートという非常に大事な項目があるので,自分でECSを構築する際には絶対にこれを考慮する必要がある.
先程述べたように,ノード間の通信はepmdによるポート管理で成り立っている.
なので,まずはepmdの起動ポートである4369は必ず開放しておく必要がある.さらにこのポート,上記で生成したsys.configにかかれているIPにアクセスしたときに,4369ポートへの問い合わせが発生することになる. そのため,ECSの動的ポートマッピングを使うことはできない.これをやってしまうと,Dockerコンテナ側は4369を開いていても,インスタンス上ではまったく別のポートにバインディングされてしまい,外部のノードからアクセスが合った際に,適切にepmdに到達することができない.
次に,実際にノード間の通信に使うポートだが,これについてももちろんポートマッピングしておく必要がある.ということは,ErlangVMにまかせてランダムなポートを利用していてはダメで,こちらでポートを指定する必要がある.
その指定が,generateコマンドの引数として与えた,minportとmaxportである. ErlangVMに食わせる設定なので,sys.configで設定できる.そのため ecs_erlang_cluster
では,generateするときにポート番号指定も一緒に含めてしまっている.
即ち,epmdで使う4369ポートと,minport - maxportの範囲のポートは,ポートマッピングする必要があり,それらは動的ポートマッピングを使うことができないということになる.
先程の
$ ecs_erlang_cluster generate \ --cluster your_cluster_name \ --service your_service_name \ --region ap-northeast-1 \ --minport 4370 \ --maxport 4370
というコマンドでsys.configを生成した場合,
[ { "name": "container-name", "image": "elixir", "portMappings": [ { "containerPort": 4369, "hostPort": 4369, "protocol": "tcp" }, { "containerPort": 4369, "hostPort": 4369, "protocol": "udp" }, { "containerPort": 4370, "hostPort": 4370, "protocol": "tcp" }, { "containerPort": 4370, "hostPort": 4370, "protocol": "udp" } ], ... } ]
こんな感じのTaskDefinitionを書く必要がある.TCPだけじゃなくUDPも使うので注意.
タスクの配置戦略
というわけで,必然的に1インスタンスに複数タスクが配置されることは不可能である(ポートマッピングがバッティングしてしまうため).
そのためサービスのPlacement Constraintsで distinctInstance を指定する必要がある. これを指定すると,1インスタンス1タスクという制約がかかるため,ポートマッピングに失敗することはなくなる(他のサービスでポートが奪われない限り).
SecurityGroup
そして,上記のようなポートで,ECSクラスタ内通信ができるようにSecurityGroupを工夫しておく必要がある.
どうせECSクラスタインスタンスにはすべて同じSecurityGroupを当てていることだろうし,自身のSGからのアクセスを全許可するようなルールを書いておくと良いのではないだろうか.
IAM Policy
最後にIAM Policyを用意する必要がある.
これはECSタスク起動時に, ecs_erlang_cluster
のgenerateコマンド内で利用するAWS APIを叩くため,その権限を与える必要がある.
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ecs:ListTasks", "ecs:DescribeTasks", "ecs:DescribeContainerInstances", "ec2:DescribeInstances" ], "Resource": "*" } ] }
こんな感じの権限があれば十分.これをIAM Task Roleにつけておけば良いんじゃないだろうか.
awsvpcについて
実は以上のようなポートマッピングによる制約は,ホストのネットワークを利用しているために発生する問題である.初めからNetwork Configurationで,awsvpcを選択したサービスを作成していれば問題なくクリアするであろう制約だ.
ただし,現状 ecs_erlang_cluster
はawsvpcに対応していない.
これは単純に実装しているヒマがなかったのと,これ試すのが割とめんどくさいという理由も多分に含まれる(試しに叩くにしてもECS上のタスクないからじゃないと叩けない).
これについては近い将来実装する可能性は高い.
まとめ
試すのが異様にめんどくさかったので,実はもうあんまりデバッグをしたくないというのはある. あと,このくらいの量だと実はみんな自作していて,OSS公開していないのかなぁ.
elixirからAWSのAPIを叩くのに,ex_awsというライブラリを使っているのだが,もともとこいつはECSサポートが遅くて,現状でもECSを公式サポートしていない.
そのため野良ライブラリを使っているのだが,
こいつがhex.pmに登録された正式なライブラリではない.
そして,hex.pmに自身のライブラリを登録する際には,その依存に含まれるライブラリがすべてhex.pmに存在していなければならないという制約がある.
そのため,現状 ecs_erlang_cluster
はhex.pmへの登録ができず,githubからのダウンロードのみとなっている.