kubernetes上にelasticsearchクラスタをデプロイ(statefulset)する

# はじめに
ラズパイkubernetesクラスタ用のelasticsearchクラスタ(3ノード)をstatefulsetでデプロイしたくなって、kubernetesのstatefulsetなどの勉強も兼ねて模索しました。なかなか汎用的なyamlを作るのは難しいですね...
まだkubernetes初心者マークが外れない感じです。

ゴールは、cephストレージを使用したelasticsearchを3ノード構成で冗長化し、更にanalysis-kuromojiのインストールとBASIC認証も設定します。BASIC認証については、無償(BASIC)ライセンスではtransportの暗号化もしなくてはなりませんので、一旦、BASIC認証なしの構成を構築して証明書の作成をします。
また、私の自宅のネットワーク環境はノードからはインターネットへの接続ができますが、コンテナからインターネットへの接続はできませんので、analysis-kuromojiのインストールは、masterノードで動いているNode-RED(http)でプラグインファイルを共有しています。

色々探したんですけど、elasticsearchのプラグインとBASIC認証を有効にしたオンプレkubernetesのyamlの事例が無いんですよね...

# 前提環境
手抜きですみません。オンプレkubernetesです。（いわゆる「一家に一台、おうちkubernetes」です）

```
# kubectl get nodes -o wide
NAME    STATUS   ROLES    AGE   VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                       KERNEL-VERSION   CONTAINER-RUNTIME
chino   Ready    master   20d   v1.19.2   10.0.0.1      <none>        Debian GNU/Linux 10 (buster)   5.4.51-v8+       docker://19.3.13
chiya   Ready    worker   20d   v1.19.2   10.0.0.5      <none>        Debian GNU/Linux 10 (buster)   5.4.65-v8+       docker://19.3.13
cocoa   Ready    worker   13d   v1.19.2   10.0.0.2      <none>        Debian GNU/Linux 10 (buster)   5.4.65-v8+       docker://19.3.13
rize    Ready    worker   13d   v1.19.2   10.0.0.3      <none>        Debian GNU/Linux 10 (buster)   5.4.65-v8+       docker://19.3.13
syaro   Ready    worker   13d   v1.19.2   10.0.0.4      <none>        Debian GNU/Linux 10 (buster)   5.4.65-v8+       docker://19.3.13
```

# 構築

## ネームスペースの作成とcephストレージアクセス用secretの登録
elasticsearchクラスタをデプロイするネームスペース「elastic」を作成します。
前提部分になるので、ご自身で使われている環境に合わせて設定してください。

```shell-session:
# kubectl create namespace elastic
namespace/elastic created
```

で、次は任意なのですが、私の構成では永続ストレージにcephを利用するので、このネームスペースでもsecretを登録します。

```yaml:csi-rbd-secret.yaml
apiVersion: v1

kind: Secret
metadata:
  name: csi-rbd-secret
  namespace: elastic
stringData:
  userID: kubernetes
  userKey: AQBrNmZfVCowLBAAeN3EYjhOPBG9442g4NF/bQ==
```

```shell-session:
# kubectl apply -f csi-rbd-secret.yaml
secret/csi-rbd-secret created
```

## サービスリソースの定義
elasticsearch用のサービス(http,transport)を定義します。
httpだけNodePortしたかったのですが、transportもNodePrtになってしまいました。
細かく制御するのはまた後の宿題にします。

```yaml:elasticsearch-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: elasticsearch
  namespace: elastic
  labels:
    app: elasticsearch
spec:
  type: NodePort
  ports:
  - name: http
    port: 9200
    targetPort: 9200
    nodePort: 31920
    protocol: TCP
  - name: transport
    port: 9300
    targetPort: 9300
    nodePort: 31930
    protocol: TCP
  selector:
    app: elasticsearch
```

```
# kubectl apply -f elasticsearch-svc.yaml
service/elasticsearch created
```

## statefulsetでの3ノードクラスタのデプロイ
次に、elasticsearchクラスタをstatefulsetでデプロイする設定です。
この時点で、elasticsearchは以下の設定にしています。これで良いならこのstatefulsetをデプロイしておしまいです。

- xpack.security.enabled:false (認証なし)
- kuromoji-analysis (日本語解析器プラグインインストール)
- 冗長性担保のために、3ノードはkubernetesの物理ノードに1ノードずつデプロイする

elasticsearchのpluginについては、永続ストレージを用意し、initContainersでpluginのインストールコマンドを実行します。
気を付けたいのは、pluginsディレクトリにはプラグイン以外のファイルがあるとelasticsearchが起動時にスキャンしてエラーになるので、事前に「rm -rf」であらかじめディレクトリ内を掃除をしておきます。

他には、ノード名はkubernetesの内部DNSで解決できるFQDN形式で記載します。
そもそもFQDN形式がわからない場合(私がそうでした)は、elasticsearch-0のRunning後、Errorになるまでに時間があるので「ping elasticsearch-0」をしてみてください。
おそらく、「<ポッド名>.<サービス名>.<ネームスペース名>.svc.cluster.local」となっています。

```shell-session:
# kubectl exec -it elasticsearch-0 -n elastic -- ping elasticsearch-0
PING elasticsearch-0.elasticsearch.elastic.svc.cluster.local (172.16.2.106) 56(84) bytes of data.
64 bytes from elasticsearch-0.elasticsearch.elastic.svc.cluster.local (172.16.2.106): icmp_seq=1 ttl=64 time=0.135 
```

あとは、メモリやCPUはリソースが許す限りのお好みで設定してください。
requestsにはデプロイする際のリソースのしきい値を定義し、limitsには最大で利用できる値を設定しますが、同じ値で良いと思います。
elasticsearchのJavaオプション-Xms/-Xmxについても同じ値で良いと思います。目安としてはlimitsの25〜50%減らした値が良いです。limitsと同じにしたり、多く指定するとOOMでコンテナが起動失敗します。

```yaml:elasticsearch-sts.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: elasticsearch
  namespace: elastic
spec:
  selector:
    matchLabels:
      app: elasticsearch
  serviceName: "elasticsearch"
  replicas: 3
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      initContainers:
      - name: plugins-install
        image: elasticsearch:7.9.2
        command: ['sh', '-c']
        args:
        - |
          rm -rf /usr/share/elasticsearch/plugins/*; /usr/share/elasticsearch/bin/elasticsearch-plugin install http://10.0.0.1:1880/analysis-kuromoji-7.9.2.zip
        volumeMounts:
        - name: es-plugins
          mountPath: /usr/share/elasticsearch/plugins
      containers:
      - name: elasticsearch
        image: elasticsearch:7.9.2
        resources:
          requests:
            cpu: 1
            memory: 1Gi
          limits:
            cpu: 1
            memory: 1Gi
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: network.host
          value: "0.0.0.0"
        - name: node.name
          value: $(NODE_NAME).elasticsearch.elastic.svc.cluster.local
        - name: transport.host
          value: $(NODE_NAME).elasticsearch.elastic.svc.cluster.local
        - name: cluster.name
          value: elasticsearch_cluster
        - name: discovery.seed_hosts
          value: elasticsearch-0.elasticsearch.elastic.svc.cluster.local,elasticsearch-1.elasticsearch.elastic.svc.cluster.local,elasticsearch-2.elasticsearch.elastic.svc.cluster.local
        - name: cluster.initial_master_nodes
          value: elasticsearch-0.elasticsearch.elastic.svc.cluster.local,elasticsearch-1.elasticsearch.elastic.svc.cluster.local,elasticsearch-2.elasticsearch.elastic.svc.cluster.local
        - name: ES_JAVA_OPTS
          value: "-Xms512m -Xmx512m"
        - name: xpack.ml.enabled
          value: "false"
        ports:
        - name: http
          containerPort: 9200
        - name: transport
          containerPort: 9300
        volumeMounts:
        - name: es-data
          mountPath: /usr/share/elasticsearch/data
        - name: es-plugins
          mountPath: /usr/share/elasticsearch/plugins
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - elasticsearch
            topologyKey: "kubernetes.io/hostname"
  volumeClaimTemplates:
  - metadata:
      name: es-data
    spec:
      accessModes: 
        - ReadWriteOnce
      resources:
        requests:
          storage: 10Gi
      storageClassName: csi-rbd-sc
  - metadata:
      name: es-plugins
    spec:
      accessModes: 
        - ReadWriteOnce
      resources:
        requests:
          storage: 50Mi
      storageClassName: csi-rbd-sc
```

statefulsetのデプロイは1Podずつシーケンシャルに行われるので時間がかかりますが、私の運用ではそんなに頻繁に再起動するものでもないので問題ありません。
もし、迅速なデプロイが必要なシステムなのであれば、毎回同じノードに同じストレージが使われるようdeploymentで並行してデプロイするようなOperatorを開発すると良いかと思ってます。

```shell-session:
# kubectl apply -f elasticsearch-sts.yaml 
statefulset.apps/elasticsearch created 
```

状態確認です。

```shell-session:
# kubectl get sts,pod,pvc -n elastic -o wide
NAME                             READY   AGE   CONTAINERS      IMAGES
statefulset.apps/elasticsearch   3/3     24m   elasticsearch   elasticsearch:7.9.2

NAME                                             READY   STATUS    RESTARTS   AGE    IP            NODE    NOMINATED NODE   READINESS GATES
pod/elasticsearch-0                              1/1     Running   0          24m    172.16.4.74   syaro   <none>           <none>
pod/elasticsearch-1                              1/1     Running   0          23m    172.16.3.83   rize    <none>           <none>
pod/elasticsearch-2                              1/1     Running   0          23m    172.16.2.65   cocoa   <none>           <none>

NAME                                               STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE    VOLUMEMODE
persistentvolumeclaim/es-data-elasticsearch-0      Bound    pvc-19d4e20b-490f-4ba8-9ceb-c26056d07c87   10Gi       RWO            csi-rbd-sc     139m   Filesystem
persistentvolumeclaim/es-data-elasticsearch-1      Bound    pvc-2d491f80-7a87-492a-98da-602955a86b03   10Gi       RWO            csi-rbd-sc     138m   Filesystem
persistentvolumeclaim/es-data-elasticsearch-2      Bound    pvc-195a45a4-c59c-49fd-b423-5cbaa70fcca6   10Gi       RWO            csi-rbd-sc     138m   Filesystem
persistentvolumeclaim/es-plugins-elasticsearch-0   Bound    pvc-f468536b-b589-49d4-8391-b7f43db515de   50Mi       RWO            csi-rbd-sc     11h    Filesystem
persistentvolumeclaim/es-plugins-elasticsearch-1   Bound    pvc-f74d780d-56d2-4845-baab-87690dc96e23   50Mi       RWO            csi-rbd-sc     11h    Filesystem
persistentvolumeclaim/es-plugins-elasticsearch-2   Bound    pvc-fa5bc7db-4f4d-485c-94b1-5b0a6f3a12cf   50Mi       RWO            csi-rbd-sc     11h    Filesystem
```

BASIC認証をしないのであれば、これで作業は終了です。お疲れ様でした。

## クライアント証明書の作成
elasticsearchクラスタを起動した状態で、transportを暗号化する際に使用する証明書の作成をします。
まずCA証明書の作成をして、お目当てのクライアント証明書の作成をして、コンテナ外にコピーしてconfigmapにします。
とりあえず、コンテナ内の悪影響の無い場所にCA証明書を作成します。

```shell-session:
# kubectl exec -it elasticsearch-0 -n elastic -- /usr/share/elasticsearch/bin/elasticsearch-certutil ca --out /elastic_cluster.p12 --pass ''
```

CA証明書を指定して、クライアント証明書を作成します。

```shell-session:
# kubectl exec -it elasticsearch-0 -n elastic -- /usr/share/elasticsearch/bin/elasticsearch-certutil cert --ca /elastic_cluster.p12 --ca-pass '' --out /elastic-cert.p12 --pass ''
```

クライアント証明書を操作している環境へコピーします。

```shell-session:
# kubectl cp -n elastic elasticsearch-0:/elastic-cert.p12 elastic-cert.p12
```

クライアント証明書をconfigmapにします。※secretでもかまいません。

```shell-session:
# kubectl create configmap elastic-cert -n elastic --from-file=elastic-cert.p12
configmap/elastic-cert created
```

## statefulsetでの3ノードクラスタのデプロイ（BASIC認証あり版)
kubernetesから、稼働しているelasticsearchクラスタを破棄します。

```shell-session:
# kubectl delete -f elasticsearch-sts.yaml
statefulset.apps "elasticsearch" deleted
```

といっても、先ほど作成したマニフェストファイルは、elasticsearchのBASIC認証あり(かつ、transportの暗号化)のベースにします。

```shell-session:
# cp elasticsearch-sts.yaml elasticsearch-sts-sec.yaml
```

次にelasticsearchのBASIC認証あり、かつ、transportの暗号化のstatefulsetのymalを作成します。
追加部分は、elasticsearchのセキュリティ設定（環境変数）と、クライアント証明書のconfigmapをファイル単体でvolumemountしています。

elasticsearchの特権ユーザ「elastic」のパスワードは、「ELASTIC_PASSWORD」に設定している「elastic」です。
他のユーザ含めてパスワード設定する場合は「ELASTIC_PASSWORD」の定義を削除してください。

```yaml:elasticsearch-sts-sec.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: elasticsearch
  namespace: elastic
spec:
  selector:
    matchLabels:
      app: elasticsearch
  serviceName: "elasticsearch"
  replicas: 3
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      initContainers:
      - name: plugins-install
        image: elasticsearch:7.9.2
        command: ['sh', '-c']
        args:
        - |
          rm -rf /usr/share/elasticsearch/plugins/*; /usr/share/elasticsearch/bin/elasticsearch-plugin install http://10.0.0.1:1880/analysis-kuromoji-7.9.2.zip
        volumeMounts:
        - name: es-plugins
          mountPath: /usr/share/elasticsearch/plugins
      containers:
      - name: elasticsearch
        image: elasticsearch:7.9.2
        resources:
          requests:
            cpu: 1
            memory: 1Gi
          limits:
            cpu: 1
            memory: 1Gi
        env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: network.host
          value: "0.0.0.0"
        - name: node.name
          value: $(NODE_NAME).elasticsearch.elastic.svc.cluster.local
        - name: transport.host
          value: $(NODE_NAME).elasticsearch.elastic.svc.cluster.local
        - name: cluster.name
          value: elasticsearch_cluster
        - name: discovery.seed_hosts
          value: elasticsearch-0.elasticsearch.elastic.svc.cluster.local,elasticsearch-1.elasticsearch.elastic.svc.cluster.local,elasticsearch-2.elasticsearch.elastic.svc.cluster.local
        - name: cluster.initial_master_nodes
          value: elasticsearch-0.elasticsearch.elastic.svc.cluster.local,elasticsearch-1.elasticsearch.elastic.svc.cluster.local,elasticsearch-2.elasticsearch.elastic.svc.cluster.local
        - name: ES_JAVA_OPTS
          value: "-Xms512m -Xmx512m"
        - name: ELASTIC_PASSWORD
          value: "elastic"
        - name: xpack.ml.enabled
          value: "false"
        - name: xpack.security.enabled
          value: "true"
        - name: xpack.security.transport.ssl.enabled
          value: "true"
        - name: xpack.security.transport.ssl.verification_mode
          value: "certificate"
        - name: xpack.security.transport.ssl.keystore.path
          value: "elastic-cert.p12"
        - name: xpack.security.transport.ssl.truststore.path
          value: "elastic-cert.p12"
        ports:
        - name: http
          containerPort: 9200
        - name: transport
          containerPort: 9300
        volumeMounts:
        - name: es-data
          mountPath: /usr/share/elasticsearch/data
        - name: es-plugins
          mountPath: /usr/share/elasticsearch/plugins
        - name: elastic-cert
          mountPath: /usr/share/elasticsearch/config/elastic-cert.p12
          subPath: elastic-cert.p12
      volumes:
        - name: elastic-cert
          configMap:
            name: elastic-cert
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - elasticsearch
            topologyKey: "kubernetes.io/hostname"
  volumeClaimTemplates:
  - metadata:
      name: es-data
    spec:
      accessModes: 
        - ReadWriteOnce
      resources:
        requests:
          storage: 10Gi
      storageClassName: csi-rbd-sc
  - metadata:
      name: es-plugins
    spec:
      accessModes: 
        - ReadWriteOnce
      resources:
        requests:
          storage: 50Mi
      storageClassName: csi-rbd-sc

```

```
# kubectl apply -f elasticsearch-sts-sec.yaml
statefulset.apps/elasticsearch created
```

「ELASTIC_PASSWORD」を定義しなかった場合や、kibana等で使用するパスワードを初期化したい場合は、elasticsearchのコンテナに入り、「elasticsearch-setup-password」コマンドでパスワード設定をしてください。
コマンドで設定したパスワードは、elasticsearch内のインデックス（永続ストレージ）に書き込まれるので、再起動後も保持されます。※elasticユーザのパスワードは、環境変数の「ELASTIC_PASSWORD」より優先されます。

```shell-session:
# kubectl exec -it elasticsearch-0 -n elastic -- /usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive
Initiating the setup of passwords for reserved users elastic,apm_system,kibana,kibana_system,logstash_system,beats_system,remote_monitoring_user.
You will be prompted to enter passwords as the process progresses.
Please confirm that you would like to continue [y/N]y


Enter password for [elastic]: 
Reenter password for [elastic]: 
Enter password for [apm_system]: 
Reenter password for [apm_system]: 
Enter password for [kibana_system]: 
Reenter password for [kibana_system]: 
Enter password for [logstash_system]: 
Reenter password for [logstash_system]: 
Enter password for [beats_system]: 
Reenter password for [beats_system]: 
Enter password for [remote_monitoring_user]: 
Reenter password for [remote_monitoring_user]: 
Changed password for user [apm_system]
Changed password for user [kibana_system]
Changed password for user [kibana]
Changed password for user [logstash_system]
Changed password for user [beats_system]
Changed password for user [remote_monitoring_user]
Changed password for user [elastic]

```

動作確認です。

```shell-session:
# curl -u elastic http://localhost:31920
Enter host password for user 'elastic':
{
  "name" : "elasticsearch-1.elasticsearch.elastic.svc.cluster.local",
  "cluster_name" : "elasticsearch_cluster",
  "cluster_uuid" : "u_mabT4gRDS-evcYFOsgMQ",
  "version" : {
    "number" : "7.9.2",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "d34da0ea4a966c4e49417f2da2f244e3e97b4e6e",
    "build_date" : "2020-09-23T04:28:49.179747Z",
    "build_snapshot" : false,
    "lucene_version" : "8.6.2",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}
# curl -u elastic http://localhost:31920/_cat/nodes?v
Enter host password for user 'elastic':
ip           heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
172.16.3.118           53          47  88    2.42    2.04     1.54 dimrt     *      elasticsearch-1.elasticsearch.elastic.svc.cluster.local
172.16.2.103           48          48  81    1.99    1.78     1.50 dimrt     -      elasticsearch-0.elasticsearch.elastic.svc.cluster.local
172.16.4.99            34          45  98    1.61    1.58     1.34 dimrt     -      elasticsearch-2.elasticsearch.elastic.svc.cluster.local
```

# おわりに
pluginのインストールはそこそこ面倒でしたが、勉強になりました。「あらかじめプラグインをインストールしたストレージを準備して...」も考えたのですが、レプリカ数を変更できるようにもしたかったので、デプロイに時間はかかるけど汎用性を持たせました。
証明書の作成は手順が煩雑なわりに手数少なくできたと思います。http側を暗号化していないのですが、自宅外から利用する場合は、ngrokでSSL化しているので問題ないと思ってます。

kubernatesといっても「Raspberry Pi」を使っているので、いつもリソースに余裕は無く、「最低限のリソースで冗長化したい！」という事が多いのですが、cephに続きelasticsearchもなんとか構築できました。他にも、クラスタモニタリング(prometheus,grafanaなど)も稼働してますし、dashboardやkibanaも動かしています。