Kubernetes上でRundeckをクラスタ構成で運用する

以前RundeckをDockerで動かす話を書いた.

h3poteto.hatenablog.com

このときはECSだったが,こいつをKubernetesに載せ替え,

  • ジョブ実行ノードがクラスタ全体に分散する
  • Webのリクエストが分散する

ようにクラスタ設定を組んだ. なお,この記事を書いている時点でのRundeckの最新バージョンは,3.0.24 である.それを前提に書く.

Rundeckのクラスタ構成は意外に難しい

前述の記事でも触れていたが,Rundeckをクラスタ構成で起動するにはそんなに難しい設定をする必要はない.

DBを別インスタンスMySQLにしたり,ログの保存先をS3にしたりしておく必要はあるが,clusterModeオプション自体は,remcoのデフォルトでtrueになっている.

github.com

しかし,これだけで本当にRundeckクラスタが,我々の期待する通りにジョブを実行してくれるかというと,実はそんなことはなかったのである. あくまで,Rundeckの管理画面のWebリクエストは,クラスタ内のノードに均等に分散するようになるが,ジョブ実行するノードは,実は全然分散しないのである.

ジョブ実行ノードの分散

SERVER_UUIDは超重要

Rundeckを動かすときに,SERVER_UUID というオプションがある. これは,クラスタモードで動作するRundeckの,個別のノードを区別するために振られている識別子である.

なお,特に指定しない場合は自動生成されるのだが……

github.com

これが曲者である.

ジョブ実行の仕組みとSERVER_UUID

Rundeckをクラスタモードで実行する際,Rundeckクラスタは,どうやってスケジュールされたジョブの実行ノードを決定するのだろうか?

これが,ErlangVMならば,VM同士を繋いでクラスタ化することができるので,Erlangクラスタ内の任意のノードでジョブを一つ実行するような制御は簡単である. ただ,RundeckはJVMなのだ.そしてクラスタ化するときに,各ノード同士を接続するような設定を書いてはいないし,そんな項目も見当たらない.

個別に動くそれぞれのノードで,ジョブの多重実行や,逆に実行されないジョブが生まれたりしないのか?

実はこれを制御するのがSERVER_UUIDなのだ.

Rundeckでスケジュール化したジョブには,そのジョブを編集した際にリクエストしたノードが持っているSERVER_UUIDが付与されている. そして,Rundeckの1ノードは,自分自身のSERVER_UUIDと同じSERVER_UUIDをもつジョブを探し出してきて,指定時間にそれを実行する.

即ち,デプロイやAutoScale等でジョブ保存時のSERVER_UUIDを持つノードが退役してしまうと,そのジョブは一生実行されることはない. だから,デプロイ前後で,SERVER_UUIDは一致し続けていなければならない.

そして,ScaleOutで新しいノードが生まれ,そいつに新しいSERVER_UUIDが付与された場合,そのノードで新たにジョブを編集・保存しない限り,そこでジョブが実行されることはない. 逆にScaleInで,ノードが削除された場合,そのノードが持っていたSERVER_UUIDと同じものが指定されていたジョブは,その後二度と実行されることはない.

このように,ジョブとSERVER_UUIDは非常に強い結びつきを持っているので,クラスタ化にするときにSERVER_UUIDをどのように割り振るかというのは非常に重要な設定となる.

そして,現状のRundeckのDockerでは,SERVER_UUIDはあくまで人が指定するものであって,このあたりの分散を自動でやってくれるものではない.

全て同じSERVER_UUIDをセットしたら?

もちろんジョブは全て実行される.

ただし,各ノードが全て同じSERVER_UUIDを持っている場合,各ノードが指定時間にそれぞれジョブを起動することになる. 即ち,ジョブ定義を1つしか書いていなくても,ノードの数分だけ重複してジョブが実行されることになる.

べき等性が完全に担保されており,重複実行時のロック等も発生しないジョブであれば特に問題はないだろうが,これではクラスタ化している意味がほとんどない.

ではどうしたら良いのか

クラスタ化における作戦

まず,RundeckクラスタをScaleIn,ScaleOutするという前提を排除する.まずは簡単化のため,クラスタのノード数は一定であり,負荷に応じて増減はさせない. ただし,デプロイの前後で,ジョブ実行がきちんと保証され続ける状態を目指す.

デプロイ前後で,ジョブ実行が保証されるということは,つまりSERVER_UUIDをデプロイによって変更しなければ良い. そして今の前提で言えば,ノード数は変化しないはずなので,最初に用意しておいたSERVER_UUIDを,永久に使い続ければ良いだけのことである.

例えば,Redis等のKeyValueStoreに,SERVER_UUIDを,予め必要な個数だけ保存しておく. サーバ起動時には,そのRedisから,順番にSERVER_UUIDを取得して,環境変数にセットしていけば良さそうに見える.

そして,デプロイ時にも,また同様の処理を走らせることができれば,特に問題なくデプロイ後もジョブが実行される.

Kubernetes上での実現方法

これをKubernetes上で実現する. まず,こういった用途にDeploymentは向かない.DeploymentやReplicaSetは,個々のPodが何番目に起動されたPodであるかということに気を使わない. そのため,上記のような作戦で,SERVER_UUIDを順番に取得するというのを担保するのがかなり難しい.

そこで,StatefulSetを使う. StatefulSetを使えば,個々のPodは必ず順番に起動される上に,各Podに一意の識別子が割り振られる. 具体的に言えば, rundeck-set-0, rundeck-set-1 というような名前のPodが生成され,なおかつこの名前はPod内から hostname として参照可能である.

となれば,ConfigMapで SERVER_UUID_0, SERVER_UUID_1というような環境変数に,それぞれSERVER_UUIDを入れておく. そして起動時にentrypoint内のスクリプトで,hostnamerundeck-set-0ならば,SERVER_UUID_0を,hostnamerundeck-set-1ならば,SERVER_UUID_1を取得してきて,それをSERVER_UUIDとしてセットし,remcoを走らせれば良い. そうすれば,rundeck-set-0は,必ずSERVER_UUID_0が設定されるようになり,この値はデプロイしたとしても変更されることはないし,他のノードと被ることはない.

f:id:h3poteto:20190724231214p:plain

というわけでそういう設定を書いてみる.

まずはConfigMapから.

apiVersion: v1
kind: ConfigMap
metadata:
  name: rundeck-env
  namespace: rundeck
data:
  # ここで設定するUUIDをentrypoint内で選別して,各ホストに適切なUUIDを設定する
  SERVER_UUID_0: "41c6e68f-3fc4-4dd9-b78e-a78cae4a3c27"
  SERVER_UUID_1: "e4506569-1a16-42ba-be51-1272f0b7f0e7"
  # その他にも必要になる環境変数はあるが,これに関しては以前RundeckをDocker化したところで説明したとおり
  # ほとんど同じ値を設定しているため省略
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: rundeck-entrypoint
  namespace: rundeck
data:
  # ここでhostnameに合致するSERVER_UUIDを見つけ出す
  entrypoint.sh: |
    #!/bin/bash
    if [[ `hostname` =~ -([0-9]+)$ ]]; then
        echo `hostname`
        echo "matched host number: "
        echo ${BASH_REMATCH[1]}
        case "${BASH_REMATCH[1]}" in
            0 ) echo "matched 0" && export RUNDECK_SERVER_UUID="${SERVER_UUID_0}" ;;
            1 ) echo "matched 1" && export RUNDECK_SERVER_UUID="${SERVER_UUID_1}" ;;
            * ) echo "does not exist SERVER_UUID" && exit 1;;
        esac
    else
        echo "host name does not match the number"
        exit 1
    fi
    exec

そしてこれを利用したStatefulSetを作る.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: rundeck-set
  namespace: rundeck
  labels:
    app: rundeck-set
spec:
  replicas: 2
  serviceName: rundeck
  selector:
    matchLabels:
      app: rundeck
  template:
    metadata:
      labels:
        app: rundeck
    spec:
      volumes:
        - name: entrypoint
          configMap:
            name: rundeck-entrypoint
            defaultMode: 0777
      containers:
        - name: rundeck
          image: rundeck/rundeck:latest
          imagePullPolicy: Always
          command: ["/var/entrypoint/entrypoint.sh"]
          args: ["docker-lib/entry.sh"]
          volumeMounts:
            - name: entrypoint
              mountPath: /var/entrypoint
          envFrom:
            - configMapRef:
                name: rundeck-env

また,前回作ったnginxとoauth2_proxyについてはStatefulSetから除外している. これは,Rundeck自身のScaleと,oauth2_proxyのScaleは必ずしも一致する必要がないからである.むしろnginxもoauth2_proxyも,リクエストを通しているだけで,そこまでスケールする必要もない. StatefulSetと一緒にスケールさせてはリソースの無駄遣いなので分離して,これらはDeploymentを別で用意した.

f:id:h3poteto:20190724230552p:plain

つまりこうなる.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: rundeck-proxy-deployment
  namespace: rundeck
  labels:
    app: rundeck-proxy-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: rundeck-proxy
  template:
    metadata:
      labels:
        app: rundeck-proxy
    spec:
      volumes:
        - name: nginx-config
          configMap:
            name: nginx-config
      containers:
        - name: oauth2-proxy
          image: my_oauth2_proxy_image:latest
          envFrom:
            - configMapRef:
                name: oauth2-proxy-env
            - secretRef:
                name: oauth2-proxy-env
          env:
            - name: RUNDECK_HOST
              value: rundeck-service.rundeck.svc.cluster.local
            - name: RUNDECK_PORT
              value: "4440"
            - name: OAUTH2_PROXY_PORT
              value: "4180"
        - name: nginx
          image: nginx:1.15.6-alpine
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx/conf.d
          ports:
            - name: http-port
              containerPort: 80
              protocol: TCP

このDeploymentからは,rundeck-service.rundeck.svc.cluster.local で,先程のStatefulSetに疎通する必要がある. つまりその分のServiceを間に作っている.

apiVersion: v1
kind: Service
metadata:
  name: rundeck-service
  namespace: rundeck
spec:
  ports:
    - port: 4440
      targetPort: 4440
      protocol: TCP
  type: ClusterIP
  selector:
    app: rundeck

各JobのSERVER_UUIDはどうやって分散させるのか

以上の設定で,PodのSERVER_UUIDを固定することはできた.あとは,各ジョブが,適度に分散したSERVER_UUIDを持つようにすれば良い.

これは,RundeckのWebリクエストがそれなりに分散するのであれば,そんなに難しい話ではない.ジョブのSERVER_UUIDは,ジョブの作成/編集リクエストを処理したサーバのSERVER_UUIDが付与される. そのため後述するWebリクエストの負荷分散ができているという前提に立てば,日常的に我々がジョブの作成/更新を行っていくうちにだんだんクラスタ全体に分散したジョブ設定が作られていくはずである.

ScaleIn, ScaleOutについて

上記設定により,StatefulSetがデプロイされた前後でも,SERVER_UUIDは維持され,ジョブの実行スケジュールが壊れることはない.

では,負荷に合わせてScaleIn, ScaleOutが発生した場合はどうなるか.

まず,ScaleOutについて考えよう. これについては簡単で,ConfigMapに定義している環境変数を一つ増やして,SERVER_UUID_2を作れば良い.あとはentrypointに追加修正すれば,3台目のノードもRundeckノードとして動くことができる. ただし,新しく増えたrundeck-set-2は,増えた直後はなんのジョブも実行しない.なぜなら SERVER_UUID_2を持つジョブが存在しないからだ.

ただこれに関しては,既存のジョブを編集したり,ジョブを新規登録したりすることで,リクエストは3ノードに分散し,少しずつrundeck-set-2で実行されるジョブも増えてくる. そういう意味では,即効性のあるScaleOutはできない.普段の負荷をもう少し分散したいと思ったときに,もうちょっと長いスパンでクラスタスケールを上げる用途にしか使えない.

では次にScaleInについてだが,これについては半ばあきらめている. これまで説明したように,SERVER_UUID_2が付与されたジョブが一つでも登録された時点で,rundeck-set-2は消すことができない. あえて手動対応することを許容するのであれば,StatefulSetのreplicaを下げ,rundeck-set-2 を退役させた後に,SERVER_UUID_2が付与されたジョブを編集,上書き保存することで,現存するノードのSERVER_UUIDを持つジョブにすることはできる. ただしこれはジョブを一つずつ上書きしていく作業が必要になる.

こういうような状態なので,そもそもAutoScaleは難しい.まぁSERVER_UUIDを大量に用意しておけばScaleOutだけはできるが,それをやったとして,ジョブ設定が編集されない限り,実行ノードは分散しない.

Webリクエストの分散

ジョブ実行はこれで分散できるようになった. そして,このジョブの分散は,設定時のWebリクエストがクラスタ全体に分散されることにより,各ジョブに付与される SERVER_UUDI が変化することで実現されている.

しかし,上記の設定だけでは実はWebのリクエスト分散はうまく行かない.

CSRFによりリクエストがエラーになる

この状態のRundeckを使っていると,ジョブの作成/編集や,ジョブ実行時に,The request did not include a valid token, or the token has expired. Please try your request again. というメッセージが出て,400エラーを変えされることがある.

これは,RundeckのフレームワークであるGrailsが,フォームのCSRF時に返すエラーである.

なぜこうなっているかというと,Rundeckクラスタ化時のセッションの扱いに原因がある. Rundeckはsession storeとしてRDBmemcachedをサポートしていない.全てCookieにセッションを格納している.そしてこれが JSESSIONID という名前のCookieだ. Rundeckをクラスタ化する上で,このCookieをStickiness SessionとしてLoadBalancerに設定することが非常に重要になる.

通常,AWSのELB, ALBのようなLoadBalancerは,バックエンドのサーバの負荷に応じてランダムにリクエストを振り分ける. そのため,フォームを表示するGETリクエストと,そこでsubmitするPOSTリクエストを受け付けるサーバは,必ずしも一致するわけではない.

そして,Rundeckはこのような動作に対応していない.CSRFのtokenをsessionに格納し,そしてそのsessionがそもそもJVMごとに生成され,それをcookieで保持しているために,接続先のバックエンドサーバが変更されると,sessionID自体が変更されてしまい,sessionから取り出したCSRFのtokenが不一致となってしまう.

そのため,Stickiness Sessionという,LoadBalancerでリクエストを振り分ける際,sessionに応じてリクエストを振り分けるという方式を採用する必要がある.

全てのLBでSticky Sessionを使う

今回,上記のPod群は,alb-ingress-controllerで外部からのアクセスを可能にしている.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress
  namespace: rundeck
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=3600
  labels:
    app: rundeck
spec:
  rules:
    - host: rundeck.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: rundeck-web
              servicePort: 80
apiVersion: v1
kind: Service
metadata:
  name: rundeck-web
  namespace: rundeck
spec:
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
  type: NodePort
  selector:
    app: rundeck-proxy

もちろん,ここに stickinessの設定は書いてあり,ALBはsessionに応じてリクエストを振り分けてくれていた.

ただ,問題はKubernetes内のServiceだ.

ClusterIPのServiceは,selectorで指定されたPodにランダムにリクエストを振り分ける.そのため,ここではSticky Sessionが無視されてしまう.

これに対応するには,選択肢が2つ考えられた.

  1. nginx, oauth2_proxyをRundeckと同じPodにのせ,StatefulSetで運用する.前段にはNodePortのServiceがひとつだけあれば足りる.ここにABLからのリクエストを受付させる.そしてNodePortに externalTrafficPolicy: Local を指定すれば,TargetGroupからは,Podが生きているNodeのみがHealthyになり,リクエストの振り分けはALBのみが行うことになる.
  2. 今の構成を維持したまま,nginxでX-Forwarded-ForをX-Real-IPにセットし,rundeck-serviceは sessionAffinity: ClientIP を指定することで,ClusetrIPのserviceでStickySessionを使わせる.ALB -> nginxは,どういう振り分け方をされてもRundeckのPodへの振り分けに影響しないので問題ない.

f:id:h3poteto:20190724230729p:plain
externalTrafficPolicy: Localを指定してALBでStickinessSessionをするパターン

f:id:h3poteto:20190724230806p:plain
nginxでX-Forwarded-Forの書き換えを行いServiceでsessionAffinityを設定するパターン

1の場合,NodePortなServiceであっても,externalTrafficPolicy: Local を指定しなければ,受け取ったリクエストはPodが存在するノードにランダムに転送されてしまう.そのため, externalTrafficPolicy は必須となる.もちろん,Localを指定した場合,そのノードでPodが生きていなければリクエストは疎通しなくなってしまう.そのため,一部のインスタンスがUnhealthyになることを許容し続けなければならない.

2の場合,ClusterIPの sessionAffinity は,X-Forwarded-Forを見てくれない.X-Real-IPのIPでしか振り分けできないので,前段のnginxは必須となる.

今回は,oauth2_proxyを入れる都合上,必ずnginxが必要になってしまうので,そこでX-Forwarded-Forを解決することにした.というわけで,sessionAffinityをつけて,

apiVersion: v1
kind: Service
metadata:
  name: rundeck-service
  namespace: rundeck
spec:
  ports:
    - port: 4440
      targetPort: 4440
      protocol: TCP
  type: ClusterIP
  selector:
    app: rundeck
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600

nginxはX-Forwarded-ForからX-Real-IPに流す.

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
  namespace: rundeck
data:
  default.conf: |
    server {
        listen 80 default_server;
        server_name localhost;
        set_real_ip_from   10.0.0.0/16;
        real_ip_header     X-Forwarded-For;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   Host $http_host;
        proxy_set_header   X-Forwarded-Proto $http_x_forwarded_proto;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        # Set rundeck role
        proxy_set_header   X-Forwarded-Roles admin;
        location / {
            proxy_pass http://localhost:4180;
        }
    }

これで無事,sessionにより振り分けられるノードが固定されるようになった.

というわけでCSRFのtokenエラーは解消された.

まとめ

一応ジョブ実行ノードを多少は分散できるクラスタを作ることができた. ただ,WebのリクエストもStickiness Sessionにしてしまったので,分散されるタイミングがかなり少ない(Stickinessのタイムアウトとセッションタイムアウトを短くすれば良いのだが,それはそれでまたCSRFみたいなものに引っかるタイミングが発生しやすくなる). そのためジョブ登録で SERVER_UUIDが分散されると行っても,そこまでランダムに分散されるわけではない.

やはりRundeckのJVMは,ノード同士がまったく別々に動いているため,AutoScaleのような仕組みとの相性が悪い. こういうところはErlangVMすごく得意だったよなぁと思うし,ErlangVMであればStatefulSetでクラスタ化するのも簡単にできてしまう.

Rundeck使ってる人たちは,一体どういう環境で運用しているんだろうか? たしかにでかいインスタンス1台を固定で動かしている分にはさして困らないだろう.

ただ,JVMなのでそれなりにメモリを使うのと,ジョブ実行を増やしていくとどんどん負荷は上がってくる. とくに並列実行指定のジョブなどは,かなりメモリを食うようになるので,クラスタ化は楽にできたほうがいいと思うんだけどなぁ.