k8s 上での Redis の永続化戦略

Redis を起動すると次のようなログが出力されることがある。

1:M 31 Aug 2020 12:36:07.554 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.

これは残メモリが少ないときにバックグラウンドでの永続化に失敗することがあるので vm.overcommit_memory = 1 としといたほうがいいぞ、という内容である。

Redis の永続化

redis.io

Redis はオンメモリデータベースとして有名であるが、それは永続化できないことを意味していない。

Redis は以下のような永続化戦略を提供している。

  • RDB: 一定期間毎にその時点でのスナップショットをファイルに保存する
  • AOF(Append Only File): 追記専用ファイルに実行したコマンドを逐次書き込み、再起動時はそれを上から順に実行する

これらの戦略のメリットデメリットや RDBMS との比較については Redis 作者の書いた以下の記事が参考になる。

oldblog.antirez.com

重要なのは、これらの戦略が fork(2) を必要とすることである。

AOF で fork(2) が必要になるのはログの最適化をするためなので、最悪失敗しても永続化としての役目を失なう訳ではないためよいが、RDB はスナップショットを取るために fork(2) を利用するため fork(2) の失敗は永続化機能としては重大な影響がある。

なぜ fork(2) の成否を気にする必要があるかというと、fork(2) をすると親プロセスが使用している仮想メモリ空間を子プロセスの仮想メモリ空間にコピーするからである。
ただし Linux では Copy-on-Write の仕組みにより、実メモリとしては親子で共有しており、書き込む瞬間だけその部分がコピーされる。が、とにかくスナップショットプロセスの走る時点の Redis が使用している仮想メモリの倍が必要になるということが重要だ。
// スナップショットはメモリ上のデータをほぼ読むだけでよいはずで、より Copy-on-Write の恩恵を受けられる。

vm.overcommit_memory

この仮想メモリ確保時の挙動を制御するのが、vm.overcommit_memory というカーネルパラメータである。

https://www.kernel.org/doc/Documentation/vm/overcommit-accounting

細かい制御までは踏み込まないが、ざっくりと以下のような挙動をする。

  • 0: ある許容量を越えない限りはオーバーコミットする
  • 1: 常にオーバーコミットする
  • 2: 常にオーバーコミットしない(確保した瞬間に実メモリを消費する)

ここで言うオーバーコミットとは物理メモリの空き容量を越えてメモリを確保することを指す。

つまり fork(2) をした瞬間にはメモリへの書き込みが発生しないと仮定すると、以下のような挙動になると推測される。
// 実際試した訳ではないので細かいところは異なるかもしれないが、だいたい、ということで...

  • 0: Redis が多くのデータを保持している場合、メモリ確保に失敗する
  • 1: 常に成功する
  • 2: Redis が実メモリの 50% 以上使用していると必ず失敗する。

オーバーコミットをする場合気を付けなければならないのが、仮想メモリは確保したものの、実メモリが足りなくて OOM Killer が走ることである。
が、ことスナップショットという処理の性質上、追加の実メモリはほとんど必要とならないはずなので、気にしなくてよい。(ので vm.overcommit_memory = 1 にしろよ、という WARNING を出している、と思われる)

k8sカーネルパラメータ

kubernetes.io

k8s 上の Pod は securityContextsysctls フィールドを利用してカーネルパラメータを指定できる。
カーネルパラメータは namespaced なものとそうでないものに分類されており、ここで指定できるのは以下の namespaced なパラメータである。

  • kernel.shm*
  • kernel.msg*
  • kernel.sem
  • fs.mqueue.*
  • net.* のうちいくつかを除いたもの

vm.* ...は...?

というわけで、今回必要な vm.overcommit_memory は Pod のフィールドで指定できない。
// なお他にも k8s 上では safe と unsafe に分類されており、上記の方法で指定するためには別途 kubelet に --allowed-unsafe-sysctls で指定する必要がある。
// 今回は話題にしていないが、これまた必要になるであろう net.core.somaxconn も unsafe なパラメータと指定されている。

namespaced でない(Node レベルの)カーネルパラメータを指定するには、Node の OS で指定するか、privileged なコンテナを持つ DaemonSet で動作させる必要がある。

これらの権限を持つ幸運な(不幸な?)人間であれば良いが、そうでない一般の人々はどうすればよいのだろうか。
また実際 vm.overcommit_memory = 1 にできたとしても Node は様々なプロセスを動作させることを考えると常にオーバーコミットされるのも OOM Killer のリスクをふまえると考えものである。

前置きが長くなってしまったが、これが本題である。

k8s と Redis 永続化

ざっくり以下のような戦略があると思われる。

  • AOF を信用する(AOF only)
  • AOF/RDB を有効にして、k8s スケジューラーに全てをまかせる
  • AOF/RDB を有効にして、Redis の maxmemory を指定しつつ、Pod のメモリ要求/制限量をその倍(+ α)に指定する

AOF を信用する

これはある種噛ませ犬的候補として用意したのだが、以下の Helm chart のデフォルト値はこの設定である。

github.com

というのも、ここまで触れてこなかった事実として、Redis の Replication による冗長化において、Replica 側にデータを受け渡すときに使うのが RDB なのである。
// experimental な機能として直接 replica 側のソケットに RDB ファイルを書き込む機能はある。が...

redis.io

つまり RDB が信用できない == Replication が信用できない、ということになる。
また、どうせ RDB ファイルを生成するのなら、両方有効化しとけばいいでしょ、という話でもある。
// もちろん頻度の問題はある、かもしれないが

もちろん、Redis が一台で Replication もしないというのであれば、これで十分だと思われる。

AOF/RDB を有効にして、k8s スケジューラに全てをまかせる

というわけで少なくともプロダクションに乗せる構成だと AOF only な選択はほとんどこの選択肢と同一であることが分かったので、こちらを考察する。

といっても結局 RDB の取得に失敗することが許容できるのかどうかである。
では、一番恐ろしい状況を想像してみる。
以下はどうだろうか。

  • 全 Node うまくメモリ使用量がバランスされており、どの Node でも Redis は通常に動作するがスナップショットの取得の度にメモリ確保に失敗して RDB が取得できない

この状況でも AOF は動作できるため、大抵の場合は問題にならない。
さらにひょんなことから、AOF 書き込み中に Redis が死んでしまい、中途半端な AOF が生成されてしまった。

このときの挙動は aof-load-truncated yes であれば自動で修復(壊れた部分は落とす)されるし、それが嫌であれば no を指定した上で k8s に auto restart をさせなければよいだろう。

つまりこの選択は

  • 最悪でも(AOF のパースにバグがなければ)最新数回のクエリが失なわれる
  • Replication が機能しない状態も想定しておく
    • Node 全体が本当に常に逼迫しているようであれば repl-diskless-sync yes にすることも検討

ということになるかと思われる。

AOF/RDB を有効にして、Redis の maxmemory を指定しつつ、Pod のメモリ要求/制限量をその倍(+ α)に指定する

これは、RDB が動作できる環境を手で保証しようとする試みである。

もちろんバーストしたときには守られないが、それはそもそも Redis 自体の書き込みで死ぬのとそうリスクとしては変わらないことを考えて許容したい。

この選択肢を取るにはまずはリソース使用量の計測をしなければならない、が k8s を使っているところはまず exporter を導入し、メモリ使用量などを取得できることは前提としてよいであろう。

その上で考慮すべきはメモリ使用量の分散かと考える。
分散が大きいのであれば、ベストエフォートにしておけばよいし、分散が小さく安定しているのであれば、上限値を基準にリソース制限/要求を設定すればよい。

ここまでくると本質的には通常の Pod のリソース制限/要求をどのように定めるのかと同じ議論が適用できるかと思うので、今回はここまでにしておこうと思う。

今のところだと私は AOF/RDB をベストエフォートで動作させつつ、メトリクスを取得し、トレンドが見えたらリソース制限/要求を指定する、という(非常に無難かつありきたりな)方針でやるかなぁと思う。

調査しきれてない部分や不正確な部分、他の選択肢などあると思うが、そも sysctl が触れない状況下での永続化をどのように考えるかについて書いた資料が見当らなかったので、私のぱっと考えたことを垂れ流す次第である。 まぁ叩き台だと思っていろいろ意見いただけるとありがたいところである。