地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Redis】Redisを使う時に見積の二倍の容量が必要なのは何故か

この記事は GMOペパボエンジニア Advent Calendar 2021 - Adventar20日目の記事です。

概要

qiita.com

上記の記事でRedisを使う時に見積の二倍の容量が必要ということが述べられています。これについて細かく「なぜ?」を追求して深掘りしてみようと思って書いた記事です。結論としては記事でも述べられている下記になります。

redisのバックアップが走る際、おそらく現状使用している量と同じだけのallocateを要求しているために、redis自体はメモリ使用が50%強だとしても、バックアッププロセスが落ちてしまう模様。

Redisにはデータ永続化の機能が二つあって特定の時点のスナップショットを取るRDBとデータベースのWAL/REDOログのような機能のAOFというものがあります。今回はRDBの方を追っていきますがAOFのタイプでも起こりうる話となっています。(大枠的には同じような内容になる予定なのでAOF版を書くかも未定です)

目次

仮想メモリ

仮想メモリは後々必要になる知識なのでここで軽く抑えておきます。仮想メモリとは、システムに実際以上のメモリがあるかのように見せる仕組みです。データ保護だったりアドレス空間を独立化することでのメリットは多々ありますが何故必要になったのかの説明はここでは省きます。(私自身もわかってないポイントなので勉強は必要...OSの本とか読むと良さそう)

archive.linux.or.jp

仮想メモリに比べて物理メモリは非常に量が少ないので、アプリケーションでやるようなメモリの最適化(共有ライブラリやチューニング)以外にも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とかのセットでの使用との違いはよくわかっていないです。どっちが早いの議論はたまに見ますが結局環境依存でケースバイケースなんですかね〜)

qiita.com

プロセスの最大メモリ使用量とか

/proc/<pid>/statusで見れる値のうち以下を今回は使うのでメモです。

VmPeak プロセスが実行中に最大で使用した仮想メモリ使用量
VmSize 今現在プロセスが使用している仮想メモリ使用量
VmHWM プロセスが実行中に最大で使用した物理メモリ使用量
VmRSS 今現在プロセスが使用している物理メモリ使用量

linuxjm.osdn.jp

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のところまで辿り着く気がしないので簡略的にやっていつか詳細は読んでみたいと思います。(多分)

stackoverflow.com

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;
    }

github.com

ざっくりだがこんかな感じ。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の先の話では大事なので不明な方はこちらをご確認ください。

cstmize.hatenablog.jp

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を覗いてみます。共有率はお馴染みのこちらのスクリプトを使ってみます。

naoya-2.hatenadiary.org

rss       shared (%)
10932 10204 93%

ある程度予想通りの結果でしたがほぼほぼCoWによる効果が効いていて共有したメモリだけで済んでいます。じゃあタイトルの2倍はなんで必要なのって話になります。仮にサーバに4GB積んだ状態でRedisのmaxを3.5GBに設定しmaxまで使った状態になったとします。その状態でバックアップが走った場合はcowの恩恵で多くのメモリは共有されバックアップは成功すると思われます。この時書き込みが少ない用途で使用されていれば問題はありませんが問題になるのはRedisへの書き込みが多い用途でこの状態が発生すると困ることが起こります。

書き込みが多いとなぜ困るかは先程から述べているcowの恩恵が減ってしまう現象が起こるからです。バックアッププロセスはfsync(2)を呼び出す以上ある程度の遅さは懸念されます、処理中にwriteを大量にされると親子でメモリ共有率が下がるため3.5GBのメモリを使っている親プロセスと3.5GBのメモリを子プロセスが同一のタイミングで実行されるとメモリ不足になってしまう可能性がある。これが"Redisを使う時に見積の二倍の容量が必要"と言われる所以なのかなと個人的には納得しました。

yasukata.hatenablog.com

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の再構築が走りますがこのさいに子プロセスを生成してログを書き換えるので同様のことが起こります。

mogile.web.fc2.com

まとめ

RDBを使った永続化をする場合は安全に使用するには搭載メモリの2倍を用意しておくことでバックアッププロセスが必要とするメモリを確保でき意図しない書き込みエラーを防ぐことができるということがわかりました。本番運用では大事な観点の一つであると言えそうです。ただ本番で使う場合は2倍で良いかは要検討でOSが使うメモリやその他の監視ツールやセキュリティ関連のソフトウェアを常駐させるケースではその辺も要件に入れて検討する必要がありそうです。

感想

細かいことを気にしなくて良いのでElasitCacheを使いたい気持ちになりました。

参考資料

github.com

mogile.web.fc2.com