以前RundeckをDockerで動かす話を書いた.
このときはECSだったが,こいつをKubernetesに載せ替え,
ようにクラスタ設定を組んだ.
なお,この記事を書いている時点でのRundeckの最新バージョンは,3.0.24
である.それを前提に書く.
Rundeckのクラスタ構成は意外に難しい
前述の記事でも触れていたが,Rundeckをクラスタ構成で起動するにはそんなに難しい設定をする必要はない.
DBを別インスタンスのMySQLにしたり,ログの保存先をS3にしたりしておく必要はあるが,clusterModeオプション自体は,remcoのデフォルトでtrueになっている.
しかし,これだけで本当にRundeckクラスタが,我々の期待する通りにジョブを実行してくれるかというと,実はそんなことはなかったのである. あくまで,Rundeckの管理画面のWebリクエストは,クラスタ内のノードに均等に分散するようになるが,ジョブ実行するノードは,実は全然分散しないのである.
ジョブ実行ノードの分散
SERVER_UUIDは超重要
Rundeckを動かすときに,SERVER_UUID
というオプションがある.
これは,クラスタモードで動作するRundeckの,個別のノードを区別するために振られている識別子である.
なお,特に指定しない場合は自動生成されるのだが……
これが曲者である.
ジョブ実行の仕組みと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内のスクリプトで,hostname
が rundeck-set-0
ならば,SERVER_UUID_0
を,hostname
がrundeck-set-1
ならば,SERVER_UUID_1
を取得してきて,それをSERVER_UUID
としてセットし,remcoを走らせれば良い.
そうすれば,rundeck-set-0
は,必ずSERVER_UUID_0
が設定されるようになり,この値はデプロイしたとしても変更されることはないし,他のノードと被ることはない.
というわけでそういう設定を書いてみる.
まずは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を別で用意した.
つまりこうなる.
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としてRDBやmemcachedをサポートしていない.全て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つ考えられた.
- nginx, oauth2_proxyをRundeckと同じPodにのせ,StatefulSetで運用する.前段にはNodePortのServiceがひとつだけあれば足りる.ここにABLからのリクエストを受付させる.そしてNodePortに
externalTrafficPolicy: Local
を指定すれば,TargetGroupからは,Podが生きているNodeのみがHealthyになり,リクエストの振り分けはALBのみが行うことになる. - 今の構成を維持したまま,nginxでX-Forwarded-ForをX-Real-IPにセットし,rundeck-serviceは
sessionAffinity: ClientIP
を指定することで,ClusetrIPのserviceでStickySessionを使わせる.ALB -> nginxは,どういう振り分け方をされてもRundeckのPodへの振り分けに影響しないので問題ない.
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なのでそれなりにメモリを使うのと,ジョブ実行を増やしていくとどんどん負荷は上がってくる. とくに並列実行指定のジョブなどは,かなりメモリを食うようになるので,クラスタ化は楽にできたほうがいいと思うんだけどなぁ.