この記事は GMOペパボエンジニア Advent Calendar 2021 - Adventar の20日目の記事です。
概要
上記の記事でRedisを使う時に見積の二倍の容量が必要ということが述べられています。これについて細かく「なぜ?」を追求して深掘りしてみようと思って書いた記事です。結論としては記事でも述べられている下記になります。
redisのバックアップが走る際、おそらく現状使用している量と同じだけのallocateを要求しているために、redis自体はメモリ使用が50%強だとしても、バックアッププロセスが落ちてしまう模様。
Redisにはデータ永続化の機能が二つあって特定の時点のスナップショットを取るRDBとデータベースのWAL/REDOログのような機能のAOFというものがあります。今回はRDBの方を追っていきますがAOFのタイプでも起こりうる話となっています。(大枠的には同じような内容になる予定なのでAOF版を書くかも未定です)
目次
仮想メモリ
仮想メモリは後々必要になる知識なのでここで軽く抑えておきます。仮想メモリとは、システムに実際以上のメモリがあるかのように見せる仕組みです。データ保護だったりアドレス空間を独立化することでのメリットは多々ありますが何故必要になったのかの説明はここでは省きます。(私自身もわかってないポイントなので勉強は必要...OSの本とか読むと良さそう)
仮想メモリに比べて物理メモリは非常に量が少ないので、アプリケーションでやるようなメモリの最適化(共有ライブラリやチューニング)以外にもOSでも物理メモリを効率的に使うような仕組みが必要です。その仕組みの一つがデマンドページングです。
デマンドページングをざっくり書くと、例えばmalloc(3)しただけでは、仮想アドレス空間だけをわりあてて、実メモリは実際に使う時まで割り当てられません。メモリ割り当てた時には、実メモリはまだ割り当てられないので、実際の物理メモリの容量以上のメモリ割当てが行われることを許されることになります。ページへ実際にアクセスされない限り、ページがメモリに読み込まれないので使わないけど確保するみたいなケースでは物理メモリは消費されません。
実験
簡単に実験してみます。
以下のようなプログラムで物理マシン以上のメモリを割り当ててもプログラムは成功する。psコマンドとかで見るとVSZの値のみが増えてRSSは全然変わっていかないことを読み取ることができる。
#include <stdio.h> #include <stdlib.h> #define SIZE (100 * 1000 * 1000 * 10) /* 100MB */ int main(int argc, char* argv[]) { int *i; i = malloc(SIZE); sleep(60); return 0; }
(コラム) callocだとRSSも増えるって話
callocだとmallocと違って確保された領域の全ビットが自動的に0で埋められます。それだと実メモリが確保要求した時点で割り当てられます。(malloc*memsetとかのセットでの使用との違いはよくわかっていないです。どっちが早いの議論はたまに見ますが結局環境依存でケースバイケースなんですかね〜)
プロセスの最大メモリ使用量とか
/proc/<pid>/status
で見れる値のうち以下を今回は使うのでメモです。
VmPeak プロセスが実行中に最大で使用した仮想メモリ使用量 VmSize 今現在プロセスが使用している仮想メモリ使用量 VmHWM プロセスが実行中に最大で使用した物理メモリ使用量 VmRSS 今現在プロセスが使用している物理メモリ使用量
overcommit
今回の肝の一つのovercommitです。カーネルパラメータとしてvm.overcommit_memory
とかで設定できる内容でRedisを構築したことある人なら見たことがあるものだと思います。
Linuxにはmemory overcommitと呼ばれる仕組みがあります。これは先ほどまで書いていた仮想メモリの仕組みを活用した仕組みで実際の物理メモリの容量以上の仮装メモリ割当てを行えるというものです。
たとえば以下のような2GBしかメモリがない環境でもmallocで4GBの要求は成功するというものです。これがオーバーコミットです。
root@3d193efd178c:/data# free -m total used free shared buff/cache available Mem: 1985 479 145 313 1360 1015 Swap: 1023 135 888 root@3d193efd178c:/data# ps aux | grep a.out USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1253 0.0 0.0 3908556 580 pts/1 S+ 01:46 0:00 ./a.out
この状態でプロセスが確保したメモリ領域を1バイトずつ書き込みをすると実メモリが割り当てられどこかのタイミングでメモリ不足が発生しおなじみのOOM Killerが発生します。kill対象のプロセスの選定は(設定は可能だが)ロシアンルーレットなのでこのプロセスがkillされるとは限らないので困ります。そこでカーネルパラメータvm.overcommit_memory
の出番でこの挙動を変えることができます。とりうる値は0~2でそれぞれmanを引くと以下のように記載されています。
/proc/sys/vm/overcommit_memory このファイルにはカーネル仮想メモリーのアカウントモードが書かれている。 値は以下の通り: 0: 発見的なオーバーコミット (heuristic overcommit) (これがデフォルトである) 1: 常にオーバーコミットし、チェックしない。 2: 常にチェックし、オーバーコミットしない。 モード 0 では、 MAP_NORESERVE を設定して呼び出された mmap(2) はチェックされない。 またデフォルトのチェックはとても脆弱で、 プロセスを "OOM-kill" してしまうリスクを引き起こす。 Linux 2.4 では 0 以外の値はモード 1 を意味する。 モード 2 (Linux 2.6 以降で利用可能) では、 割り当て可能な仮想アドレス空間 (/proc/meminfo の CommitLimit) は以下で計算される。 CommitLimit = (total_RAM - total_huge_TLB) * overcommit_ratio / 100 + total_swap
デフォルトは0になっています。この状態でRedisを起動させようとすると以下のようなメッセージが出力されます。よくみる0になってるぞ1にしてくれ的なメッセージですね。内容自体は後の章で追っていきます。
# 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.
/proc/sys/vm/overcommit_memoryが0だと何が起こるのか
さっきのmanを該当部分のみを抜粋すると以下のようになります。発見的なオーバーコミットとはなんでしょう?それを確認してみます。
0: 発見的なオーバーコミット (heuristic overcommit) (これがデフォルトである) モード 0 では、 MAP_NORESERVE を設定して呼び出された mmap(2) はチェックされない。 またデフォルトのチェックはとても脆弱で、 プロセスを "OOM-kill" してしまうリスクを引き起こす。 Linux 2.4 では 0 以外の値はモード 1 を意味する。
https://www.kernel.org/doc/Documentation/vm/overcommit-accounting
ヒューリスティックの実装まで追うかぁ〜と思っていたらカンペ的なものがStackOverflowにあったのでこちらを参考に読んでいきます。このペースだとタイトル回収のRedisのところまで辿り着く気がしないので簡略的にやっていつか詳細は読んでみたいと思います。(多分)
overcommit_memoryが0に設定されるとOVERCOMMIT_GUESSというフラグが設定され__vm_enough_memory()
で成功の可否をチェックする処理が実行されるとのこと。ちなみにv.5.16とか見るとがっつり実装変わってるので4.10とかでみていく(そんな最新を使うケースもあんまないので...)
// プロセスに新しい仮想を割り当てるのに十分なメモリがあることを確認する処理を行う関数 int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin) if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) { // これがovercommit0を指定されたケース free = global_page_state(NR_FREE_PAGES); free += global_node_page_state(NR_FILE_PAGES); // shmemページはカウントしない free -= global_node_page_state(NR_SHMEM); // swapページをカウント free += get_nr_swap_pages(); // 再利用可能なslabキャッシュをカウント free += global_page_state(NR_SLAB_RECLAIMABLE); /* * Leave reserved pages. The pages are not for anonymous pages. */ if (free <= totalreserve_pages) goto error; else free -= totalreserve_pages; /* * Reserve some for root。root(cap_sys_admin)だと一般ユーザと少し挙動が変わるようなので * 動作確認するときは注意かな? */ if (!cap_sys_admin) free -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10); // freeの方が要求ページ数より多ければ成功を返す if (free > pages) return 0; goto error; }
ざっくりだがこんかな感じ。freeのavailとほぼ同じような値になる模様。overcommit_memoryが0だとfork()して何かをする処理でCoWが前提となっているプログラムだと結構困るケースがあったりする。以下のようなサンプルで親プロセスでcmallocを実行、fork()後に何もしないプログラムでovercommit_memoryの値によって挙動は異なる。0なら失敗するし1なら成功する、2ならvm.overcommit_ratioと空きの仮想メモリのサイズによって成功可否が変わる。(/proc/meminfo
のCommited_*の値を確認する必要がある)
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> int main(int argc, char* argv[]) { int *i; unsigned long long SIZE = 1024 * 1024 * 1024; i = (int *)calloc(1, SIZE); if (i == NULL) { fprintf(stderr, "malloc failed: %s\n", strerror(errno)); exit(1); } int pid = fork (); if (-1 == pid) { fprintf(stderr, "can not fork%s\n", strerror(errno)); } else if (0 == pid) { puts ("child"); sleep(60); exit(EXIT_SUCCESS); } else { puts ("parents"); } sleep(60); return 0; }
cowの話はここで省略しますがRedisの先の話では大事なので不明な方はこちらをご確認ください。
vm.overcommit_memory=2の値の時の処理はこの辺り、(OVERCOMMIT_NEVERが設定されているケース)。設定あたりを読んでいて気づいたがsysctl_overcommit_ratioは100以上も設定可能になっていて2だろうがオーバーコミットは可能にすることもでできるみたい。(用途はわからんが...)
/* * Committed memory limit enforced when OVERCOMMIT_NEVER policy is used */ unsigned long vm_commit_limit(void) { unsigned long allowed; if (sysctl_overcommit_kbytes) allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10); else allowed = ((totalram_pages - hugetlb_total_pages()) // hugetlb_total_pagesはなんだろ? * sysctl_overcommit_ratio / 100); allowed += total_swap_pages; return allowed; }
Redisを使う時に見積の二倍の容量が必要なのは何故か
これまでは前提知識の補完とかでここからが本題。Redisを使う時に見積の二倍の容量が必要なのは何故かという話
Redisの永続化の種類
Redisの永続は二つあることは記事の冒頭で説明していますがここで公式ドキュメントの内容を参照しておきます。今回のタイトルはRedisを使う時に見積の二倍の容量が必要なのは何故か
でRDBをメインに追っていきます。
Redis は、守備範囲の異なる永続化オプションを提供します: RDB 永続化は、ある時点のデータセットのスナップショットを、特定の間隔ごとに作成します。 AOF 永続化では、サーバーが受け付けたすべての書き込みコマンドを記録します。サーバーは起動時にログをリプレイし、元のデータを再構成します。コマンドは Redis プロトコルと同じフォーマットを使い、追記方式で記録されます。ログが大きくなりすぎたら、Redis はバックグラウンドでそれをリライトします。
Redisのバックアッププロセスの動きをvm.overcommit_memoryの値ごとに理解してみる
Redisではvm.overcommit_memory
を1に設定するよう推奨している。vm.overcommit_memory
の値ごとに仮想メモリなどが溢れた場合にどのような動きになるかを考察してみる。
RedisのRDBでのバックアップ処理は子プロセスとしてバックアップ用のプロセスを起動し親プロセスと同等のメモリを必要とする。この時親プロセスと同等のメモリを必要とするがLinuxではCoWという仕組みがありある程度はメモリが共有される。overcommit_memoryの値が0の場合はヒューリスティクスなオーバコミットなので利用可能なメモリサイズのがRedisの親プロセスの使用メモリ分だけ必要となる。overcommit_memoryが1の場合は何も気にせず成功しする。overcommit_memoryが2の場合はovercommit_ratioの値次第となるovercommit_ratioが100以上ならovercommit_memoryが0の場合よりも失敗する確率は減っていく。
Redisはデフォルトでmax memoryの制限をしない。なのでCoWが聞きやすいバックアッププロセスでvm.overcommit_memory
を0とか2とかovercommitを厳密にみる設定だとRedis的には都合が良くなさそうに見えたのでこのような推奨値になっているのかな?と思いました。
バックアッププロセスを追う
bgsaveを実行された直後から追っていく。rdbSaveBackgroundが内部的に呼ばれoomの設定やCPUアフィニティの設定を行なっている。
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) { if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) { int retval; /* Child */ redisSetProcTitle("redis-rdb-bgsave"); // プロセス名を変更 redisSetCpuAffinity(server.bgsave_cpulist); // CPUアフィニティを設定する retval = rdbSave(filename,rsi); // メイン処理。ここから追う if (retval == C_OK) { sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB"); } exitFromChild((retval == C_OK) ? 0 : 1); }
rdbSave()ではfsyncなどを用いた永続化などを実施している。
int rdbSave(char *filename, rdbSaveInfo *rsi) { char tmpfile[256]; char cwd[MAXPATHLEN]; /* Current working dir path for error messages. */ FILE *fp = NULL; rio rdb; int error = 0; snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid()); fp = fopen(tmpfile,"w"); // 一時ファイルをopen // 具体的なdump処理は以下。 if (rdbSaveRio(&rdb,&error,RDBFLAGS_NONE,rsi) == C_ERR) { // key-valを捜査してデータを取得 errno = error; goto werr; } // 一次ファイルを永続化している if (fflush(fp)) goto werr; if (fsync(fileno(fp))) goto werr; if (fclose(fp)) { fp = NULL; goto werr; } fp = NULL; // tmpファイルをRDBで指定されているファイルへrenameする if (rename(tmpfile,filename) == -1) { char *cwdp = getcwd(cwd,MAXPATHLEN); serverLog(LL_WARNING, "Error moving temp DB file %s on the final " "destination %s (in server root dir %s): %s", tmpfile, filename, cwdp ? cwdp : "unknown", strerror(errno)); unlink(tmpfile); stopSaving(0); return C_ERR; } }
redisのデータをtmpファイルへ書き出している処理は以下で実施。
int rdbSaveRio(rio *rdb, int *error, int rdbflags, rdbSaveInfo *rsi) { for (j = 0; j < server.dbnum; j++) { redisDb *db = server.db+j; dict *d = db->dict; if (dictSize(d) == 0) continue; di = dictGetSafeIterator(d); // key-valのペアの分だけループしていく while((de = dictNext(di)) != NULL) { sds keystr = dictGetKey(de); robj key, *o = dictGetVal(de); long long expire; initStaticStringObject(key,keystr); expire = getExpire(db,&key); // キーと値のペアを、有効期限、タイプ、キー、値とともに保存 if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr; // tmpへの書き出しはrdbSaveKeyValuePairで実施 } dictReleaseIterator(di); di = NULL; /* So that we don't release it again on error. */ } }
ざっくり振り返るとこんな感じになります。
① bgsaveなどコマンドや設定をトリガーとしてredis-rdb-bgsaveをforkして生成 ② 子プロセスが一時ファイルを生成 ③ 子プロセスが自身のメモリにあるkey:valueのペアを一時ファイルに書き出し ④ 子プロセスが一時ファイルをfllush(3)してfsync(2) ⑤ 子プロセスがファイル名をconfigの指定されたファイルへリネーム
親子でのメモリ共有率をざっくり出してみます。先程読んだコードのうち子プロセスの仕事がほぼ終わっているrdbSaveBackground()の最後の方でRedisを太らせた後にgdbでアタッチしておきprocfsを覗いてみます。共有率はお馴染みのこちらのスクリプトを使ってみます。
rss shared (%) 10932 10204 93%
ある程度予想通りの結果でしたがほぼほぼCoWによる効果が効いていて共有したメモリだけで済んでいます。じゃあタイトルの2倍はなんで必要なのって話になります。仮にサーバに4GB積んだ状態でRedisのmaxを3.5GBに設定しmaxまで使った状態になったとします。その状態でバックアップが走った場合はcowの恩恵で多くのメモリは共有されバックアップは成功すると思われます。この時書き込みが少ない用途で使用されていれば問題はありませんが問題になるのはRedisへの書き込みが多い用途でこの状態が発生すると困ることが起こります。
書き込みが多いとなぜ困るかは先程から述べているcowの恩恵が減ってしまう現象が起こるからです。バックアッププロセスはfsync(2)を呼び出す以上ある程度の遅さは懸念されます、処理中にwriteを大量にされると親子でメモリ共有率が下がるため3.5GBのメモリを使っている親プロセスと3.5GBのメモリを子プロセスが同一のタイミングで実行されるとメモリ不足になってしまう可能性がある。これが"Redisを使う時に見積の二倍の容量が必要"と言われる所以なのかなと個人的には納得しました。
Can't save in background: fork: Cannot allocate memoryの話
Redisの運用をしていると"fork: Cannot allocate memory"をみる機会って結構あったりします。これはなぜ出るのかを仮想メモリの話と絡めて見ていきます。出力してるのはどこかというとsrc/server.c:redisFork
の以下の箇所です。なんら変わったことのないforkのラッパー的な関数です。 Cannot allocate memoryはerronoのstrerror出力です。
int redisFork(int purpose) { int childpid; long long start = ustime(); if ((childpid = fork()) == 0) { // 子プロセスの処理 server.in_fork_child = purpose; setOOMScoreAdj(CONFIG_OOM_BGCHILD); setupChildSignalHandlers(); closeChildUnusedResourceAfterFork(); } else { // 親プロセスの処理 server.stat_total_forks++; server.stat_fork_time = ustime()-start; server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024*1024*1024); /* GB per second. */ latencyAddSampleIfNeeded("fork",server.stat_fork_time/1000); if (childpid == -1) { if (isMutuallyExclusiveChildType(purpose)) closeChildInfoPipe(); return -1; } return childpid; }
出力自体はこの辺り
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) { if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) { /* Child */ } else { /* Parent */ if (childpid == -1) { server.lastbgsave_status = C_ERR; serverLog(LL_WARNING,"Can't save in background: fork: %s", // ここ strerror(errno)); return C_ERR; } } return C_OK; /* unreached */ }
ここでovercommit_memoryの値が0の場合はヒューリスティクスなオーバコミットなので利用可能なメモリサイズのがRedisの親プロセスの使用メモリ分だけ必要となる。なので2倍のメモリが必要になる可能性があることがわかります。ただこれはovercommit_memoryの値を1へ変更するようredisは推奨しているのでこの値を設定することで防ぐことができます。
バックアッププロセスが失敗するとどうなるのか
なんらかの原因でバックアッププロセスが落ちてredisが持っているステータスでバックグラウンドのバックアップが失敗したステータスになっている状態でsetなどの書き込みを実施すると以下のようなエラーとなる。データセットを変更する可能性のあるコマンドは無効になり次回のsave成功まで何も書き込むことができないような状態となる。こういう観点からもvm.overcommit_memoryを1に設定する方が良さそうな根拠の一つになりそうと思えました。
(error) MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Redis logs for details about the RDB error.
Redisは独自にCoWを計算する機能もある模様
bgsaveを打ってredis自体のログを見ていると以下のような出力が行われていることがわかりました。cowを計算する何かがRedisにはあるみたいですということで出力から追ってみました。
RDB: XXX MB of memory used by copy-on-write
rdbの処理結果を親プロセスに
void sendChildInfoGeneric(childInfoType info_type, size_t keys, double progress, char *pname) { if (info_type != CHILD_INFO_TYPE_CURRENT_INFO || !cow_updated || now - cow_updated > cow_update_cost * CHILD_COW_DUTY_CYCLE) { // これが怪しい cow = zmalloc_get_private_dirty(-1); cow_updated = getMonotonicUs(); cow_update_cost = cow_updated - now; // ここが出力している処理。cowというsize_t型の変数へzmalloc_get_private_dirty()の結果を格納している if (cow) { serverLog((info_type == CHILD_INFO_TYPE_CURRENT_INFO) ? LL_VERBOSE : LL_NOTICE, "%s: %zu MB of memory used by copy-on-write", pname, cow / (1024 * 1024)); } } // 通知自体は親子間でpipeを使って行われている模様。今回は細かくはみない if (write(server.child_info_pipe[1], &data, wlen) != wlen) { /* Nothing to do on error, this will be detected by the other side. */ }
zmalloc()はredisが独自に実装している(?)メモリアロケータで中身を見るとmallocの薄めのラッパーで確保したサイズをredisのプロセスから管理するために使われている模様(ここも詳しくないので間違ってたらすいません...)
// zmalloc_get_private_dirtyはzmalloc_get_smap_bytes_by_field()を呼び出しているだけ size_t zmalloc_get_private_dirty(long pid) { return zmalloc_get_smap_bytes_by_field("Private_Dirty:",pid); } // 具体的に共有しているメモリを調べているのはこっち size_t zmalloc_get_smap_bytes_by_field(char *field, long pid) { if (pid == -1) { fp = fopen("/proc/self/smaps","r"); } else { char filename[128]; snprintf(filename,sizeof(filename),"/proc/%ld/smaps",pid); fp = fopen(filename,"r"); } if (!fp) return 0; // 全行を読み取る while(fgets(line,sizeof(line),fp) != NULL) { // Private_Dirtyという文字列で比較する if (strncmp(line,field,flen) == 0) { char *p = strchr(line,'k'); if (p) { *p = '\0'; bytes += strtol(line+flen,NULL,10) * 1024; // Private_Dirtyの数値*1024の合計を呼び出し元へ返している } } } return bytes; }
AOFの場合も2倍のメモリが必要になるケースがある
BGREWRITEAOFなどを実行するとAOFの再構築が走りますがこのさいに子プロセスを生成してログを書き換えるので同様のことが起こります。
まとめ
RDBを使った永続化をする場合は安全に使用するには搭載メモリの2倍を用意しておくことでバックアッププロセスが必要とするメモリを確保でき意図しない書き込みエラーを防ぐことができるということがわかりました。本番運用では大事な観点の一つであると言えそうです。ただ本番で使う場合は2倍で良いかは要検討でOSが使うメモリやその他の監視ツールやセキュリティ関連のソフトウェアを常駐させるケースではその辺も要件に入れて検討する必要がありそうです。
感想
細かいことを気にしなくて良いのでElasitCacheを使いたい気持ちになりました。