DockerとKubernetesでのCassandraクラスタ作り

# はじめに
どんどん利用の広がるDockerコンテナの世界。Apache Cassandraもお馴染みのDocker Hubレジストリに[Cassandraリポジトリ](https://hub.docker.com/_/cassandra/)として登録されているので、あとはいつものdockerコマンド一発！
`$ docker run -d cassandra`
とするだけで、1分もかからずに1台構成のCassandraを簡単に起動することができます。便利ですね。

Cassandraは分散データベースであることが最大の特徴なので、当然次のステップとして複数台のPodによるクラスタ構成を試してみたくなりますよね？
そこで先ほどの[Cassandraリポジトリ](https://hub.docker.com/_/cassandra/)の「Make a cluster」の項を読むと、以下の様に書かれています。

> there are two cluster scenarios: instances on the same machine and instances on separate machines

なるほど、クラスタ構成に挑むには、①同じマシンに複数のCassandraを作るか、②異なるマシンにCassandraを作るかの2通りあるよ、ということですね。

個人的に楽しむだけなら①のケースで簡単に…もありですが、業務利用も見据えるならより現実的な構成で、ということで②を選択する訳ですが、おそらくこの辺りからCassandraライフが楽しくなくなる方が多いのではないでしょうか。

そこでこの記事では、DockerとKubernetesについてはそこそこ知っているけども、Cassandraについてはさほど詳しくないという方を想定しながらCassandraクラスタの作り方を説明していきます。

# まずはDockerでCassandraクラスタ
ということで読み進めていくと、

> For separate machines (ie, two VMs on a cloud provider), you need to tell Cassandra what IP address to advertise to the other nodes (since the address of the container is behind the docker bridge).
Assuming the first machine's IP address is 10.42.42.42 and the second's is 10.43.43.43, start the first with exposed gossip port:
`$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=10.42.42.42 -p 7000:7000 cassandra:tag`

とあります。
この部分はなかなかに言葉足らずだと思うのですが、行間の言葉も拾い上げて書くと、つまり2台の仮想マシン（以下、VM1とVM2とします）がある場合、

- そもそもCassandraの仕組みとして、VM1とVM2にあるCassandraがお互いに通信しながら協調動作することで1つのクラスタとして動作するようになっている。

- だからお互い相手のIPアドレスを知っている必要がある。ただしコンテナ内での内部IPアドレスではPod間の通信には使えないので、通信したい相手のコンテナが動作している仮想マシン自体のIPアドレスであることに注意する。例えば、VM1のIPアドレスを10.42.42.42、VM2は10.43.43.43とすると、VM1のCassandraは10.43.43.43に対して通信し、VM2のCassandraは10.42.42.42に対して通信する。

- また、Cassandra同士の通信の他にも、一般的にVM1、VM2以外の仮想マシンで動作するクライアントからINSERTやSELECTなどの要求通信があり、このクライアントに対しても内部IPアドレスではなく、やはり仮想マシン自体のIPアドレスを伝える必要がある。
- そうすると、例えばVM1のCassandraにしてみれば、コンテナ内で割り当てられた内部IPアドレスで実際は動作しつつも、以下のことをする状況になる。
    ① VM2など外部のCassandraに対しては10.42.42.42に向けて通信してねと伝える（broadcastする）
    ② クライアントに対しても10.42.42.42に向けて通信してねと伝える（broadcastする）

- この状況を実現するためにCassandraの設定ファイル「cassandra.yaml」の出番となり、その中で上記①に相当する設定項目が「broadcast_address」、上記②に相当する設定項目が「broadcast_rpc_address」になります。そしてDocker Hubに登録されているCassandraリポジトリでは、特別にこの2つの項目を1つの環境変数で同時に設定できるようにしてあり、これが「CASSANDRA_BROADCAST_ADDRESS」という環境変数となる。（使用できる環境変数については「Configuring Cassandra」の項に記載があり、同様のことがさらっと触れられています）
- ちなみに、1台構成の時には不要だったCassandra同士の協調動作が必要になったことで、相手の死活情報を知るための通信（これが文中に出てくる「gossip」）やデータのやり取りに使われるポート番号をDockerに追加で伝える必要もある。このポート番号はcassandra.yamlファイル内でデフォルトで7000として指定されていることで、コンテナ内では7000番ポートで待ち受けており、今回は仮想マシン間でも同じ7000番ポートを使用することにする。

これらの結果として、「-e CASSANDRA_BROADCAST_ADDRESS=10.42.42.42」という環境変数の指定と「-p 7000:7000」というポートの指定が加わり、

```shell
$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=10.42.42.42 -p 7000:7000 cassandra:tag
```

というコマンドが出来上がる訳です。

## 2台目のCassandraは"seed"が肝

ここまでくれば後は簡単です。
1台目のCassandraが起動したので、さらに読み進めると

> Then start a Cassandra container on the second machine, with the exposed gossip port and seed pointing to the first machine:
`$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=10.43.43.43 -p 7000:7000 -e CASSANDRA_SEEDS=10.42.42.42 cassandra:tag`

となっています。
ここで新しく出てきた「seed」とは、2台目（VM2）のCassandraを起動してクラスタに参加させる時に、参加先のクラスタがどこにあるのかを指し示すためのもので、今回はVM1のCassandraしかないためそのIPアドレス10.42.42.42となり、VM2のCassandraはここからクラスタに関する情報を得て、自分がクラスタ内で2台目であることを認識してから起動処理が進むようになっています。

seedの指定はcassandra.yamlファイル内では「seeds」という項目で行い、これをDocker HubのCassandraリポジトリでは「CASSANDRA_SEEDS」という環境変数を通じて指定するようになっているので、「-e CASSANDRA_SEEDS=10.42.42.42」を増やします。
また、VM2からVM1へbroadcastするアドレスは10.43.43.43になることから「-e CASSANDRA_BROADCAST_ADDRESS=10.43.43.43」に修正します。

これらの結果として

```shell
$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=10.43.43.43 -p 7000:7000 -e CASSANDRA_SEEDS=10.42.42.42 cassandra:tag
```

というコマンドになる訳です。

## 3台目以降はどうするのか…！

Cassandraは過半数（QUORUM）の考え方により強い整合性（結果整合性ではありません！）と耐障害性を両立できるため、最低3台構成のクラスタとすることで、1台が故障しても（3台の過半数である）2台が残っていることで、最新のデータを常に読み出すことができるようになります。残念ながらDocker Hubのサイトには2台目までの記述しかないのですが、Cassandraが本来の能力を発揮する3台構成以上を体験できるようにするため、その方法ついても記載しておきます。

実はその方法は簡単で「2台目の時と同じ」となります。つまり3台目をVM3、そのIPアドレスを10.44.44.44とすると以下の通りです。

```shell
$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=10.44.44.44 -p 7000:7000 -e CASSANDRA_SEEDS=10.42.42.42 cassandra:tag
```

2台目の時と同様に、他のCassandraに対してbroadcastするIPアドレスと、参加先クラスタの情報を得るためのIPアドレスを指定すればいい訳です。4台目以降も同様です。

## seedを複数指定するには？

もしVM1が停止してしまった場合、seedとしての役割は果たせなくなるため、新しいCassandraコンテナをクラスタに追加することができなくなってしまいます。これではせっかく単一障害点（SPoF）がないというCassandraの強みが台無しですね。

実はseedはVM1のIPアドレスである必要はなく、参加先クラスタに含まれるVMのIPアドレスであればどれでも構いません。また、複数指定することが可能なので、VM1の障害に備えて本番運用では複数指定するのが通常です。ただしこの場合はここまで見てきた`$ docker run`の内容が少し変わってきます。

例えばVM1～VM5の5台でクラスタを組みたいとします。また、seedにはVM1～VM3の3つを指定することにとします。そのためには単にIPアドレスをカンマ区切りで並べればいいだけなので
　-e CASSANDRA_SEEDS=[VM1のIPアドレス],[VM2のIPアドレス],[VM3のIPアドレス]
という形になります。

結果として、各仮想サーバVMx（x=1,2,3,4,5）について、以下の通り全て同じ形式でCassandraコンテナを起動することでクラスタを構築できます。

```shell
$ docker run --name some-cassandra -d -e CASSANDRA_BROADCAST_ADDRESS=[VMxのIPアドレス] -p 7000:7000 -e CASSANDRA_SEEDS=[VM1のIPアドレス],[VM2のIPアドレス],[VM3のIPアドレス] cassandra:tag
```

ただし、seedになるCassandraは初回起動時に参加先クラスタを探さないので、必ず最初にseedに指定されたVMからCassandraを起動していく必要があります。起動後はgossipによりクラスタ情報が全てのCassandraで共有されて記憶されるので、2度目以降は起動順序は気にしなくて大丈夫です。

また、seed情報を元に動作しているgossipの通信が必要以上に増えてしまうことを避けるために、以下の様な注意点があります。

- seedにクラスタの全てのIPアドレスを指定しないようにする。（3個程度を目安）
- seedの指定内容はクラスタの全てで同じにしておく。

# KubernetesでもCassandraクラスタ

次にKubernetesでの話を。
Kubernetes上で複数PodによるCassandraクラスタを構築するには、基本的にStatefulSetを使用するのが簡単ですが、[公式サイトのチュートリアル](https://kubernetes.io/docs/tutorials/stateful-application/cassandra/)に英語で書かれているだけで日本語情報はほとんど見当たりません！そこで今回はこの公式サイトの内容に沿って説明してみることにします。

いろいろ書かれていますが、重要なステップは
　① Headless serviceを作る
　② StatefulSetを作る
という2つだけ。シンプルです。

## ① Headless service を作る

チュートリアルの「[Creating Cassandra Headless Service](https://kubernetes.io/docs/tutorials/stateful-application/cassandra/#creating-a-cassandra-headless-service)」の部分です。

```shell:service.yaml
apiVersion: v1
kind: Service
metadata:
  labels:
    app: cassandra
  name: cassandra
spec:
  clusterIP: None
  ports:
  - port: 9042
  selector:
    app: cassandra
```

要は通常のService作成時に`clusterIP: None`を指定したものがHeadless serviceになり、実際にこのServiceにはクラスタIPが割り当てられません。

```shell
$ kubectl get svc cassandra
NAME        TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
cassandra   ClusterIP   None         <none>        9042/TCP   45s
```

Podのレプリカ同士に違いがないため、代表してIPアドレスを持ってレプリカへロードバランシングすればいいという通常のステートレスなServiceとは違い、StatefulSetによってPod（と対応するVolume）が個別管理されることで代表IPアドレスが不要になるためです。
9042番ポートはDockerの時と同じで、Cassandraにクライアントが問い合わせをする時に使用するポートです。

## ② StatefulSet を作る

[StatefulSetの説明ページ](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#using-statefulsets)に詳細が書かれていますが、ざっくりまとめると

- Pod単位でユニークなホスト名を用意してくれる
- Pod単位でいつも同じ永続ストレージを接続してくれるので同じデータを使用できる
- Podの起動や停止を、Podに割り振られた連番を使用していつも同じ順番で実施してくれる

という特徴を持ったDeploymentです。
チュートリアルの「[Using a StatefulSet to Create a Cassandra Ring](https://kubernetes.io/docs/tutorials/stateful-application/cassandra/#using-a-statefulset-to-create-a-cassandra-ring)」では以下の様になっています。

```shell:statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: cassandra
  labels:
    app: cassandra
spec:
  serviceName: cassandra
  replicas: 3
  selector:
    matchLabels:
      app: cassandra
  template:
    metadata:
      labels:
        app: cassandra
    spec:
      terminationGracePeriodSeconds: 1800
      containers:
      - name: cassandra
        image: gcr.io/google-samples/cassandra:v13
        imagePullPolicy: Always
        ports:
        - containerPort: 7000
          name: intra-node
        - containerPort: 7001
          name: tls-intra-node
        - containerPort: 7199
          name: jmx
        - containerPort: 9042
          name: cql
        resources:
          limits:
            cpu: "500m"
            memory: 1Gi
          requests:
            cpu: "500m"
            memory: 1Gi
        securityContext:
          capabilities:
            add:
              - IPC_LOCK
        lifecycle:
          preStop:
            exec:
              command: 
              - /bin/sh
              - -c
              - nodetool drain
        env:
          - name: MAX_HEAP_SIZE
            value: 512M
          - name: HEAP_NEWSIZE
            value: 100M
          - name: CASSANDRA_SEEDS
            value: "cassandra-0.cassandra.default.svc.cluster.local"
          - name: CASSANDRA_CLUSTER_NAME
            value: "K8Demo"
          - name: CASSANDRA_DC
            value: "DC1-K8Demo"
          - name: CASSANDRA_RACK
            value: "Rack1-K8Demo"
          - name: POD_IP
            valueFrom:
              fieldRef:
                fieldPath: status.podIP
        readinessProbe:
          exec:
            command:
            - /bin/bash
            - -c
            - /ready-probe.sh
          initialDelaySeconds: 15
          timeoutSeconds: 5
        # These volume mounts are persistent. They are like inline claims,
        # but not exactly because the names need to match exactly one of
        # the stateful pod volumes.
        volumeMounts:
        - name: cassandra-data
          mountPath: /cassandra_data
  # These are converted to volume claims by the controller
  # and mounted at the paths mentioned above.
  # do not use these in production until ssd GCEPersistentDisk or other ssd pd
  volumeClaimTemplates:
  - metadata:
      name: cassandra-data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: fast
      resources:
        requests:
          storage: 1Gi
---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: fast
provisioner: k8s.io/minikube-hostpath
parameters:
  type: pd-ssd
```

`kind: StatefulSet`でStatefulSetが指定されており、その他は通常のDeploymentと似た構造になっています。
少し長いですが、以下にCassandraに関係するポイントを見ていきます。

### replicas: 3

Cassandraクラスタの構成Pod数を指定します。
データベースですので一般的なWEBアプリケーションなどよりはリソースを消費します。数が大きくなるに連れてKubernetesのリソースがたくさん必要になるのでご注意を。

### image: gcr.io/google-samples/cassandra:v13

Dockerの時と違い、今回はGoogleのレジストリに用意されたCassandraリポジトリが使われています。
基本的には同じなのですが、後述の通り設定が少し異なっています。

### containerPort:

**・7000**
7000番ポートはgossipやデータのやり取りなど、Cassandra間での通信に使われています。

**・7001**
7001番ポートはCassandra間通信をSSL/TLS暗号化して行う時に使われます。
今回のリポジトリのデフォルト状態では暗号化設定がOFFになっているため、今回は実際には気にしなくて構いません。ONにするにはコンテナ内の /etc/cassandra/cassandra.yaml にて`internode_encryption: all`にする必要があります。（デフォルトでは`internode_encryption: none`）

**・7199**
7199番ポートはJMXモニタリングポートで、主にCassandraの状態を知るためのツールであるnodetoolで使用されます。

**・9042**
9042番ポートはクライアントからのINSERTやSELECTなどの要求の受け付けに使われます。

### IPC_LOCK

IPC_LOCKはメモリデータがスワップアウトしないようにするためのものです。
ちなみに、KubernetesやDockerを使用せずにCassandraを使用する場合には、`$ swap -a off`を実行し、fstabも編集して恒久的にスワップを無効化するのが定石です。

### nodetool drain

preStopで指定されているので、Podの停止前にnodetool drainが実行されます。
これはリクエストの受け付けを停止した上でメモリ上のデータをファイルに書き出させるようにCassandraに対して指示します。この処理が無くても（もしくは失敗しても）コミットログがあるためデータはロストしませんが、次回起動時にコミットログのリプレイ分の時間が余計に掛かります。

### name:

**・CASSANDRA_SEEDS**

CASSANDRA_SEEDSはDockerでも説明したseedのことで、cassandra.yamlファイル内のseedsを設定してくれます。
StatefulSetは作成されるPodに0始まりの連番が付与されることが特徴で、
　[Statefulset名]-[連番].[サービス名].[名前空間].svc.cluster.local
という形式のホスト名になります。
よって、seedには最初に作られるPodのホスト名となる「cassandra-0.cassandra.default.svc.cluster.local」を指定しています。

もちろん、Dockerの時に説明した通り複数指定することも可能で、その時はカンマ区切りで「cassandra-0.cassandra.default.svc.cluster.local,cassandra-1.cassandra.default.svc.cluster.local」のようにすれば大丈夫です。

**・CASSANDRA_CLUSTER_NAME**

Cassandraクラスタに付ける名前のことで、cassandra.yamlファイル内のcluster_nameを設定してくれます。
異なるcluster_name設定を持つCassandraをseedに指定しても、クラスタが違うと認識されて参加できません。

**・CASSANDRA_DC**

Cassandraクラスタ内には複数の論理的なデータセンターを持つことができるため、自身が所属するはずのデータセンターの名前を指定します。cassandra-rackdc.propertiesファイル（/etc/cassandra/cassandra-rackdc.properties）内のdcを設定してくれます。ちなみにはこんな感じになっています。

```shell
$ cat /etc/cassandra/cassandra-rackdc.properties
dc= DC1-K8Demo
rack= Rack1-K8Demo
```

論理的なものなので、物理的な配置や名前とは無関係に指定できます。

**・CASSANDRA_RACK**

論理的なラック名を指定します。cassandra-rackdc.propertiesファイル内のrackを設定してくれます。
Cassandraは耐障害性を高めるために、各論理データセンター間でデータのレプリカを持つことに加えて、各論理データセンター内でも極力異なる論理ラックにデータのレプリカを配置するように動作します。どの論理データセンターにどれくらいのレプリカを配置するかはクラスタ稼働後にキースペースを作成する際に指定できます。

実は（今回のGoogleレジストリ版Cassandraではなく）標準のCassandraでは、この論理データセンターや論理ラックという構成概念がないフラットな構成を使用するための「SimpleSnitch」がcassandra.yamlファイル内のendpoint_snitchにデフォルトで指定されています。検証目的での使用が想定されており、この時は上記のCASSANDRA_DCやCASSANDRA_RACKを指定する必要はありません。
ただし、キースペースを作成する際には「SimpleStrategy」クラスを指定する必要があり、レプリカ数（レプリケーションファクター）を3とする場合のキースペース作成CQLは以下のようになります。

```shell
CREATE KEYSPACE mykeyspace WITH REPLICATION = { 'class': 'SimpleStrategy', 'replication_factor': 3 };
```

一方で、商用の本番環境などを想定して論理データセンターや論理ラックを使用する場合は、本来はcassandra.yamlファイル内のendpoint_snitchに「GossipingPropertyFileSnitch」を設定する必要があるのですが、今回のGoogleレジストリのCassandraリポジトリの場合にはこれが最初から設定されているため、CASSANDRA_DCやCASSANDRA_RACKも指定する形になっている訳です。

キースペースを作成する際には「NetworkTopologyStrategy」クラスを指定する必要があり、仮想データセンター名と対応付けてレプリカ数を指定する形になります。例えばDC1-K8Demoのレプリカ数を3とする場合（かつ、参考までにレプリカ数5のDC2-K8Demoという仮想データセンターもある場合）のキースペース作成CQLは以下のようになります。

```shell
CREATE KEYSPACE mykeyspace WITH REPLICATION = {'class' :'NetworkTopologyStrategy', 'DC1-K8Demo' :3, 'DC2-K8Demo' : 5};
```

### ready-probe.sh

readinessProbeで指定されているready-probe.shは、今回使用しているコンテナイメージ内に（Cassandra標準ではなく）特別に用意されているものです。
Cassandraの起動が完了して準備が整ったかどうかを判別するためのスクリプトで、これが0を返すと完了状態とみなして次のPodの作成に進みます。 中身は以下のようになっています。

```shell:ready-probe.sh
if [[ $(nodetool status | grep $POD_IP) == *"UN"* ]]; then
  if [[ $DEBUG ]]; then
    echo "UN";
  fi
  exit 0;
else
  if [[ $DEBUG ]]; then
    echo "Not Up";
  fi
  exit 1;
fi
```

内部で`nodetoo status`を実行していますね。
これを実際に手動で実行してみると以下の様な出力を得ることができます。

```
nodetool status
Datacenter: DC1-K8Demo
======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address    Load       Tokens       Owns (effective)  Host ID                               Rack
UN  10.36.0.7  99.6 KiB   32           100.0%            ee5f3194-3429-4ed2-afdb-f180e3694dc5  Rack1-K8Demo
```

「UN」で始まる行はクラスタに参加している数に応じて増えていきます。
1文字目の「U」はCassandraが起動状態にあることを示していて、これが「D」だと停止状態です。2文字目の「N」は通常運転状態であることを示していて、これ以外だと何かしら状態が変化している途中であることを示します。よって、正常な起動状態であれば「UN」、正常な停止状態であれば「DN」となります。
ready-probe.shではAddress列を使って自身について記述された行を特定し、その状態が「UN」であることを確認しているということになります。

### mountPath: /cassandra_data

標準のCassandraでは/var/lib/cassandra以下に書き込んだ各種データが保存されるようにcassandra.yamlファイル内のdata_file_directories、commitlog_directory、hints_directory、saved_caches_directoryが設定されていますが、Googleレジストリ版ではこれらが全て/cassandra_dataに変更されています。よって永続ボリュームをここにマウントします。

もちろん、cassandra.yamlファイル内の上記4つのdata_file_directoriesやcommitlog_directoryなどの指定を別々にしても問題ありませんが、不意の停止時などにPodの終了に付随してコミットログが失われないように、少なくともdata_file_directoriesとcommitlog_directoryには永続ボリュームを割り当てる必要があることに注意してください。

### 【オマケ】storageClassName: fast

kubernetesの環境によってはストレージクラスの記述内容で悩むかもしれないので、特にGoogleのGKEでは`storageClassName: standard`にして、`kind: StorageClass`以下の記述を全て削除してしまった方が早いかもしれません。（動的にボリュームが作られます）

# まとめ

DockerとKubernetesでそれぞれ複数台のCassandraによるクラスタを構築する際に、参考となりそうな情報やハマりポイントを書き綴ってみました。

Apache Cassandraはもちろん、その商用版であるDataStax Enterpriseも2018年12月5日の[バージョン6.7](https://www.datastax.com/2018/12/datastax-announces-datastax-enterprise-6-7)でコンテナ環境に公式に対応しましたのでこちらも試してみることができます。検証目的であれば開発ツールを含めてすべて無償で使用できます！
この記事がCassandraをコンテナ環境で楽しむ一助となれば嬉しいです。