フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

InfinispanをRedisの代わりに使ってWebサーバーをHA構成する

皆さんこんにちは。エンジニアの佐藤です。今回はメモリキャッシュのお話です。

Redisはポピュラーなメモリストレージではあるが。。

Webアプリケーション開発者の方々なら、Redisを知らない人はいないでしょう。メモリストレージの定番として、筆者が認識し始めたのは10年ぐらい前でした。無料で利用可能で、Dockerコンテナで手軽にデプロイできます。今ではクラウドベンダーはほとんどRedisをSaas提供しており、ローカル開発環境からクラウドの本番環境にそのままま乗せ替えできる点も便利です。

しかし、問題はお値段です。Redisは当然、CPUを占有します。CPUは高価なリソースです。さらに頭が痛いのが、高可用性(HA)構成です。RedisをHA構成しようと思ったら、待機系を用意しなければならず、費用は2倍です。つまり、WebサーバーのHA構成と合わせてCPUを4つ運用する必要があります。昨今は様々な調達方法がありますので実際は単純ではありませんが(本稿執筆中にAWSからElastiCache Serverlessなるアナウンスもありました)、Redisが本質的に高価なストレージであることに変わりはありません。

Infinispanという選択肢が

そんな折、Infinispanなるものの存在を知りました。RedhatJBossの一部としてオープンソースで開発しているもので、商用ライセンスはDataGridとして販売されています。業務要件でこのInfinispanを展開する機会があり、調べて試しているうちに、これはRedisの代わりに使えるんじゃないかと思い始めました。(ただし、仕様は異なります。InfinispanはRedis RESP3エンドポイントもありますが、サポートされるコマンドは限定的です。)

Infinispanの特徴は、HA構成をサポートしていながら、クラスタノード同士は対等な、マルチマスター構成である点です。(もちろん、主系待機系構成のRedisとどちらが良いという問題ではありません。)普段は調達リソースの100%のパフォーマンスを期待し、障害時は半分で我慢する、という構成が取れます。

そこで今回は、このInfinispanをWebサーバーのメモリストレージとしてHA構成する方法をご紹介したいと思います。何かの参考にしていただければ幸いです。

まず最初に、作戦を立てる

Infinispanは高機能なので、様々な構成が可能です(興味のある方は文末の余談「高機能だが知名度が低く、難解なInfinispan」をお読みください。)その分迷いやすく、結局どうすれば良いのかわからなくなりがちです。そこで今回は最初に作戦を立てて、参考知識を収集していきます。

今回立てた作戦は以下のようなものです。

  • 基本的にHA構成のWebサーバーです。
  • Webサーバーは、自身のノード(クラウドインスタンスなどに相当)のInfinispanノードにアクセスします。
  • これが2ノード
  • 2つのノードに配置されたInifispanノードは、ひとつのクラスタとして動作します。片方のInfinispanノードに対する変更は、他方のInfinispanノードにも反映されます。
  • クライアントはロードバランサーを経由してこのWebサーバーを見ており、あたかも1台のサーバーがあるかのように見せられています。

Infinispanはポート11222をREST APIエンドポイントとして使います。このエンドポイントはノード内部専用で、認証は設定しません。ポイントは赤点線の中で、Infinispanノード同士は「ノード間通信」と「障害検知」の2つのコネクションでつながっています。前者はポート7800、後者はポート57800です。(文末の余談「Infinispanのポートはどうやって決まっているのか」参照)

この作戦自体は、特に不自然なものではなく、平易に理解していただけると思います。

この作戦を展開するもっとも簡便な方法は、KubernetesのSidecarの利用です。こうするとノード間通信はKubernetesクラスタの内部ネットワークに隔離され、WebサーバーコンテナとInfinispanノードがひとつのPodに配置されます。今回はKubernetes環境にはCanonical MicroK8sをUbuntu 22.04LTSにインストールして使いました。

Infinispanを設定して、動かしてみる

次に、この作戦に従ってInfinispanを設定します。設定はXMLファイルを記述し、これをKubernetes ConfigMapでInfinispanコンテナに指定します。

まず設定のXMLファイルです。

<infinispan
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="urn:infinispan:config:14.0 https://infinispan.org/schemas/infinispan-config-14.0.xsd
        urn:infinispan:server:14.0 https://infinispan.org/schemas/infinispan-server-14.0.xsd
        urn:org:jgroups http://www.jgroups.org/schema/jgroups-5.2.xsd"
    xmlns="urn:infinispan:config:14.0"
    xmlns:ispn="urn:infinispan:config:14.0"
    xmlns:server="urn:infinispan:server:14.0">
    
    <server xmlns="urn:infinispan:server:14.0">
        <interfaces>
            <interface name="public">
                <inet-address value="${infinispan.bind.address:127.0.0.1}"/>
            </interface>
        </interfaces>

        <socket-bindings default-interface="public" port-offset="${infinispan.socket.binding.port-offset:0}">
            <socket-binding name="default" port="${infinispan.bind.port:11222}"/>
        </socket-bindings>

        <endpoints>
            <endpoint socket-binding="default">
                <rest-connector />
            </endpoint>
        </endpoints>
    </server>

    <cache-container name="default">
        <transport stack="kubernetes"/>
        <distributed-cache name="cache01" owners="2" />
        <counters xmlns="urn:infinispan:config:counters:14.0" num-owners="2" reliability="CONSISTENT">
            <strong-counter name="counter01" initial-value="0" storage="VOLATILE"/>
        </counters>
    </cache-container>
</infinispan>

この内容は、infinispanの公開コンテナの内容から筆者が抜粋して作ったもので、以下のような内容です。

  • server/interface要素以下で、localhostネットワークの利用を設定します。
  • server/socket-binding要素以下で、ポート11222をエンドポイントに定めます。
  • server/endpoint要素以下で、REST APIをサービスプロトコルに定めます。

そしてcache-container要素の中でキャッシュストレージを定義していくわけですが、ここが難解です。

<transport stack="kubernetes"/>

これは、Infinispanノード間通信と障害検知に"kubernetes"という名称で既定で定義されている設定を使うという意味です。その定義はここにありますが、本ブログの範囲を超える内容になりますので、割愛します。

次のdistributed-cache要素は、クラスタ化された(つまり片方のInfinispanノードが障害となっても消えない)キーバリューストアです。本ブログでは、定義のみ紹介します。

次のcounters/strong-counterは、クラスタ化されたカウンターで、本ブログでこの後紹介していくものです。strong-counter(解説ブログはこちら)とは、クラスタ化されていて2ノード以上で維持されているストレージでありながら、その更新をトランザクションによって原子的に実行できるカウンターのことです。このカウンターの利用が、先の作戦図のような冗長構成でできることは、Infinispanクラスタが正常動作していることを確認する最も簡便な手段です。

このXML設定ファイルを利用して、作戦図にあるシステム構成を実現する定義ファイルは以下のようになりました。

apiVersion: v1
kind: ConfigMap
metadata:
  name: ispn-config
data:
  infinispan.conf: |
    (ここにXML設定ファイルを貼り付け)
---
apiVersion: v1
kind: Service
metadata:
  name: test01-service
spec:
  type: NodePort
  selector:
    app: test01
  ports:
  - name: http
    port: 5000
    nodePort: 30001
    targetPort: http  
---
apiVersion: v1
kind: Service
metadata:
  name: infinispan-service
spec:
  ports:
  - name: jgroups
    port: 7800
    targetPort: jgroups
  - name: jgroups-fd
    port: 57800
    targetPort: jrogups-fd
  selector:
    app: test01
  type: ClusterIP
  clusterIP: None
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test01
  labels:
    app: test01
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test01
  template:
    metadata:
      labels:
        app: test01
    spec:
      containers:
      - name: test01
        image: localhost:32000/test01:20231128_03
        env:
        - name: INFINISPAN_REST_ENDPOINT
          value: http://localhost:11222
        ports:
        - name: http
          containerPort: 5000
      - name: infinispan
        image: infinispan/server:latest
        args: [
          "-c", "/usr-config/infinispan.conf",
          "-Djgroups.dns.query=infinispan-service.default.svc.cluster.local",
        ]
        ports:
        - name: infinispan
          containerPort: 11222
        - name: jgroups
          containerPort: 7800
        - name: jgroups-fd
          containerPort: 57800
        volumeMounts:
        - name: user-config
          mountPath: /usr-config
      volumes:
      - name: user-config
        configMap:
          name: ispn-config
      dnsConfig:
        options:
        - name: ndots
          value: "1"

一番重要なポイントは、Service「infninspan-service」を定めていることです。この定義により、Kubernetesの内部DNS名「infinispan-cluster-service.default.svc.cluster.local」により、Infinispanを含むPodのポート7800と57800の列挙ができるようになります。(KubernetesクラスタのCoreDNSプラグインを有効にしておきましょう。)

Webサーバー(test01コンテナ)はポート5000で着信待機し、localhost:11222で 手元の Infinispanノードと通信します。

InfinispanはConfigMapで作成されたvolumeをマウントし、これをinfinispan/serverコンテナの -c 起動オプションで指定することにより設定します。

Webサーバーの内容は、以下のような、Python Flaskを利用したごく簡単なものです。その機能は、ルートパスでアクセスされると、strong counter counter01を「原子的に参照し、加算する」処理を手元のInfinispanノードに対して行います。どのPodで実行されたかを示すために、Podのアドレスも付記することにします。このWebサーバーのエンドポイントはNodePort 30001で外部に公開します。

from flask import Flask
import requests
import os
import socket

INFINISPAN = os.getenv('INFINISPAN_REST_ENDPOINT', 'http://localhost:11222')

app = Flask(__name__)

@app.route("/")
def index():
    counter = requests.post(f'{INFINISPAN}/rest/v2/counters/counter01?action=increment').text
    addr = socket.gethostbyname(socket.gethostname())
    return f'{addr},{counter}'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

MicroK8sにインポートして、以下のようにWebサーバーの( HA対応の! )Webサーバーのエンドポイントを連続アクセスしてみましょう。

cmd="curl http://localhost:30001"; for i in $(seq 50); do $cmd; echo ""; sleep 0; done

結果は以下の通りで、ごく短時間で別のPodにアクセスした場合でも、正しくカウントアップされています。期待通りに動作していると考えて良さそうです!

10.1.89.54,162
10.1.89.55,163
10.1.89.55,164
10.1.89.55,165
10.1.89.55,166
10.1.89.54,167
10.1.89.54,168
10.1.89.54,169
10.1.89.55,170
10.1.89.54,171
10.1.89.55,172
10.1.89.54,173
10.1.89.54,174
10.1.89.55,175
10.1.89.54,176
10.1.89.55,177
10.1.89.55,178
10.1.89.54,179
10.1.89.55,180
10.1.89.55,181
10.1.89.54,182
10.1.89.55,183
10.1.89.54,184
10.1.89.55,185
10.1.89.54,186
10.1.89.54,187
10.1.89.55,188
10.1.89.55,189
10.1.89.55,190
10.1.89.54,191
10.1.89.55,192
10.1.89.54,193
10.1.89.55,194
10.1.89.55,195
10.1.89.54,196
10.1.89.55,197
10.1.89.55,198
10.1.89.55,199
10.1.89.54,200
10.1.89.54,201
10.1.89.55,202
10.1.89.54,203
10.1.89.55,204
10.1.89.54,205
10.1.89.55,206
10.1.89.54,207
10.1.89.54,208
10.1.89.54,209
10.1.89.54,210
10.1.89.54,211

今度はPodを落として実験してみましょう。

curl http://localhost:30001/

10.1.89.64,5

Podを確認します。

kubectl get pod -o wide

NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE             NOMINATED NODE   READINESS GATES
test01-f9c589f59-6vkbt   2/2     Running   0          12m   10.1.89.64   ip-172-31-0-83   <none>           <none>
test01-f9c589f59-krh6p   2/2     Running   0          12m   10.1.89.63   ip-172-31-0-83   <none>           <none>

先ほど返信した、IP 10.1.89.64のPodを落とします。

kubectl delete pod test01-f9c589f59-6vkbt

pod "test01-f9c589f59-6vkbt" deleted

再びアクセスすると、カウンタは正しくカウントアップされています!

curl http://localhost:30001/

10.1.89.65,6

振り返って、どうか

ここまでお読みくださりありがとうございました。

うまく当初の目標を達成しました。Webサーバーにメモリキャッシュは追加したいが、HAでRedisを構成するのは予算的に難しそうなケースにうまくフィットするかもしれません。

ただしInfinispanの学習・設定の負担はそれなりにあります。本家マニュアルポータルはこちらで、Dockerイメージの説明はこちらで、本ブログもこれらを参照していますが、不足している情報は周辺調査やソースコードを見て補う必要がありました。

「で、結局ミニマムHA構成はどうすれば良いのか?」と思われた時に、本ブログを参考にしていただければ幸いです。

(余談) Infinispanのポートはどうやって決まっているのか

Infinispanがどうやってポートを決めているのかは、簡単に見つかりません。筆者の邪推ですが、その昔、クラスタ内の一群の機材がファイアウォールを定めず自由に通信していた時代の文化のようにも感じられます。

その定義は、以下の部分に書かれています。

障害検知通信ポートは、ノード間通信ポートからのオフセット(ポート番号の足し算)で定義されているので、既定では7800 + 50000 = 57800になります。

JGroupsのマニュアルには、この障害検知ポートは設定されたポート範囲を定めることもできるとあります。これもまた、通信ポートがファイアウォールで制限される前の時代の文化のように、筆者には感じられます。

(余談) 高機能だが知名度が低く、難解なInfinispan

Infinispanは非常に高機能で、継続開発されており、ドキュメントも詳しく書かれています。今回はWebサーバーに併設する方法を採りましたが、単体のストレージクラスタとして構成することも可能で、遠隔バックアップ構成も可能なようです。

今回はKubernetesの例を紹介しましたが、クラスタノードディスカバリーにはAmazon S3などのオブジェクトストレージやJDBCを経由する方法もあり、AWS ECSではこちらの方法でクラスタを構成できます。

今回紹介していない有用な機能に「Java Transaction APIサポート」があり、XA(2フェーズ)トランザクションにも対応します。これによりメモリストレージとJDBCの更新の両方にまたがるトランザクション処理が構成できます。