[Elixir] libclusterを使ってKubernetes上でErlangクラスタを形成する

私は普段、インフラ周りの仕事をすることがよくあるのですが、最近初めてKubernetes（k8s）上でElixir/PhoenixアプリのErlangクラスタを形成（クラスタリング）する機会がありました。**libcluster** というライブラリを使えば簡単にクラスタリングできますが、公式ドキュメントの説明だけだとk8sの設定が足りずうまくいきませんでした…

https://hexdocs.pm/libcluster/3.3.1/readme.html

https://hexdocs.pm/libcluster/3.3.1/Cluster.Strategy.Kubernetes.html

そこで、今回は公式ドキュメントを補足しつつ、libclusterを使ってk8s上でElixir/Phoenixアプリをクラスタリングする方法を解説します。

# 解説の前に

以下については本題ではないため、説明を省略します。

- k8sでWebアプリを動かすためのPodやDeployment、Serviceといった各種用語
- kubectlコマンドの使い方
- [mix release](https://hexdocs.pm/mix/Mix.Tasks.Release.html) 周りのこと

# k8s上で動かすElixir/Phoenixアプリを用意する

クラスタリングできていることを視覚的に確認できるように、Phoenix LiveViewで作った簡単なアプリをk8s上で動かすことにします。

https://github.com/mokichi/microposts-sample-phoenix-liveview

匿名で短い文章を投稿できるアプリです。あるセッションでの投稿/更新/削除が別のセッションにも反映されるようになっており、以下のデモ動画のように動きます。

<iframe width="560" height="315" src="https://www.youtube.com/embed/5yK1MrEjaME" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

仕組みの説明は省略しますが、興味がある方は以下のスライドを見てみてください。

https://speakerdeck.com/mokichi/phoenix1-dot-6debiao-zhun-da-zai-saretaliveviewniru-men-sitemiyou

# minikubeを使ってローカルにk8sクラスタを作る

k8sクラスタを作る方法は何でもいいんですが、ローカルで気軽に試せるように今回は [minikube](https://kubernetes.io/ja/docs/setup/learning-environment/minikube/) を使います。

```sh
$ minikube start
```

アプリを動かすためにPostgreSQLが必要なので、[Helm](https://helm.sh/ja/) でさくっと入れちゃいましょう。諸々の簡略化のため、postgresユーザのパスワードを固定して起動します。

```sh
$ helm repo add bitnami https://charts.bitnami.com/bitnami
$ helm install db --set auth.postgresPassword=postgres bitnami/postgresql
```

# k8sにアプリをデプロイする

本題ではないので詳しい説明は省略しますが、今回は [Skaffold](https://skaffold.dev/) を使ってデプロイします。

```sh
$ skaffold run
```

デプロイが完了したら、DBマイグレーションを忘れずに実行してください。クラスタリングできていることを確認するためにPodが2つ動くようにしていますが、どちらのPodで実行しても構いません。

```sh
$ kubectl exec {pod_name_1_or_2} -it -- bin/migrate
```

DBマイグレーションを実行したら、Terminalを2つ起動してそれぞれのPodで動くアプリにブラウザからアクセスできるようにします。

```sh:Terminal 1
$ kubectl port-forward {pod_name_1} 4000:4000
```

```sh:Terminal 2
$ kubectl port-forward {pod_name_2} 4001:4000
```

ブラウザでタブを2つ開いてそれぞれ http://localhost:4000/microposts と http://localhost:4001/microposts にアクセスして操作すると、デモ動画と同様に投稿/更新/削除がお互いに同期されるようになっているはずです :clap: 

<iframe width="560" height="315" src="https://www.youtube.com/embed/5yK1MrEjaME" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# 何をやっているのか

とりあえずk8s上でクラスタリングできましたが、ここからは具体的にどう対応するとクラスタリングできるのか解説していきます。

## libclusterを導入する

まずはlibclusterをインストールします。

```diff_elixir:mix.exs
  defp deps do
    [
+     {:libcluster, "~> 3.3"},
      {:phoenix, "~> 1.6.2"},
```

```sh
$ mix deps.get
```

次に、libclusterの設定を追加します。設定内容は [公式ドキュメントの設定例](https://hexdocs.pm/libcluster/3.3.1/Cluster.Strategy.Kubernetes.html) とほぼ同じですが、 `kubernetes_node_basename` と `kubernetes_selector` のみ今回のアプリ用に変更しています（変更内容については後述）。

```diff_elixir:config/runtime.exs
  # For this example you need include a HTTP client required by Swoosh API client.
  # Swoosh supports Hackney and Finch out of the box:
  #
  #     config :swoosh, :api_client, Swoosh.ApiClient.Hackney
  #
  # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
+
+  config :libcluster,
+    topologies: [
+      k8s_example: [
+        strategy: Cluster.Strategy.Kubernetes,
+        config: [
+          mode: :ip,
+          kubernetes_node_basename: "sample",
+          kubernetes_selector: "app=microposts-sample",
+          kubernetes_namespace: "default",
+          polling_interval: 10_000]]]
```

そして、libclusterのスーパーバイザを追加すると、設定に沿って他のErlangノードを探して接続してくれるようになります。

```diff_elixir:lib/sample/application.ex
  def start(_type, _args) do
+   topologies = Application.get_env(:libcluster, :topologies, [])
+
    children = [
+     {Cluster.Supervisor, [topologies, [name: Sample.ClusterSupervisor]]},
      # Start the Ecto repository
      Sample.Repo,
```

## Erlangノード名を設定する

libclusterの設定に `mode: :ip` という部分がありますが、このモードではPodのIPアドレスで他ノードと接続するため、ノード名が `sample@{IPアドレス}` というフォーマットになるように `rel/env.sh.eex` を追加します。ノード名に含まれる `sample` はlibclusterの設定における `kubernetes_node_basename` と同じ値である必要があります。

```sh:rel/env.sh.eex
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=sample@${POD_IP}
```

また、Pod内で `POD_IP` という環境変数でIPアドレスを参照できるように、以下の指定が必要です。

```diff_yaml:k8s/deployment.yaml
    spec:
      containers:
        - name: microposts-sample
          image: microposts-sample
          env:
            - name: DATABASE_URL
              value: ecto://postgres:postgres@db-postgresql/postgres
            - name: SECRET_KEY_BASE
              value: mysecretmysecretmysecretmysecretmysecretmysecretmysecretmysecret
+           # For libcluster
+           - name: POD_IP
+             valueFrom:
+               fieldRef:
+                 fieldPath: status.podIP
          ports:
            - containerPort: 4000
```

## k8sに必要なリソースを追加する

libclusterのデフォルト設定では、 `kubernetes_ip_lookup_mode` という項目が `:endpoints` になっています。これは、他PodのIPアドレスを探すためにk8sのEndpointリソースを参照するというモードです。

EndpointリソースはServiceリソースを作成すると自動的に作成されるので、まずはServiceリソースを追加します。

```yaml:k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: microposts-sample
  labels:
    app: microposts-sample # ①
spec:
  type: ClusterIP
  ports:
    - name: http
      protocol: TCP
      port: 4000
      targetPort: 4000
  selector:
    app: microposts-sample # ②
```

libclusterの設定に `kubernetes_selector: "app=microposts-sample"` という部分がありますが、これはlibclusterがEndpointリソースを参照する際のセレクタで、①と②の指定が必要です。

また、PodにはデフォルトではEndpointリソースを参照する権限がないため、RoleリソースとRoleBindingリソースを作成して権限を付与します。説明の簡略化のためデフォルトのServiceAccountに権限を付与しますが、別途ServiceAccountを作成してPodに指定しても構いません。

```yaml:k8s/rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: endpoints-reader
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["endpoints"]
    verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-endpoints
  namespace: default
subjects:
  - kind: ServiceAccount
    name: default
    namespace: default
roleRef:
  kind: Role
  name: endpoints-reader
  apiGroup: rbac.authorization.k8s.io
```

ここまでの設定で、k8s上でクラスタリングできるようになりました :tada: 

なお、以下のようなコマンドを実行して他ノードの一覧が得られれば、わざわざ画面操作しなくてもクラスタリングできていることを確認できます :sparkles: 

```sh
$ kubectl exec {pod_name_1_or_2} -it -- bin/sample rpc "IO.inspect(Node.list)"
```

```:実行結果（例）
[:"sample@172.17.0.7"]
```

# 最後に

他の言語で複数サーバのセッションやWebSocket接続の管理をする際はRedisを使うことが多いですが、ElixirであればクラスタリングすることでRedisを使わず簡単に複数台構成を実現できます。libclusterや関連ライブラリでk8s以外でもクラスタリングする機能が提供されているので、各自の環境に合うものを探してみてください。

また、ローカルでしか動かしていなかったElixir/Phoenixアプリをk8s上で動かしつつクラスタリングするための変更点がまとまっているプルリクエストを作っているので、mix releaseを含めすべての変更点を知りたい方はそちらも見てみてください :muscle: 

https://github.com/mokichi/microposts-sample-phoenix-liveview/pull/1