Redisのappend-only fileの解析

こんにちは、Red Hat の Middleware Technical Account Manager のイアンです。

最近、Redisデータベースの append-only file を解析する機会がありまして、独自の解析ツールを作るまでの道のりを説明いたします。

TLDR: Redisのソースを元にして、当初分からなかったファイル形式の解析ツールを作りました。ツールは https://github.com/yasny/redis-aof-parser で公開しました。

Append-only fileとは

Redisデータベースは、設定によってすべての書き込みコマンドを append-only file (以降AOF) に保存しています。 障害やデータベースの破損が発生した場合は、当ファイルのコマンドを再読込すると、障害直前までのデータベース状態に戻すことができます。

簡単な説明でしたので、Redisのバックアップや永続性の詳細は、マニュアルのRedis persistenceをご参照ください。

解析に向けて

まずは、私はAOFファイルの構成が全く分かりませんでしたので、誰かが解析方法を既に出しているかをGoogle先生に聞きました。 いくつかの記事やツールがありましたが、どれも自分が持っていたAOFファイルを解析できませんでした。 自分のファイルが何か変わったようです。

そのため、Pythonで独自のツールを作ることにしました。

Redisのソースへ

解析するために他のツールに頼れないことが分かりましたので、Redis のソースを見に行きます。 これはオープンソースの強みですね。

AOF構成の理解へのはじめの一歩

Redis 5を使っていましたので、GitHub上のソースを見つけ、AOF関連の関数などを探し始めました。 そこで、aof.cloadAppendOnlyFile()を発見しました。

関数を読みながら、以下の処理が見つかりました。

/* Check if this AOF file has an RDB preamble. In that case we need to
 * load the RDB file and later continue loading the AOF tail. */
char sig[5]; /* "REDIS" */
if (fread(sig,1,5,fp) != 5 || memcmp(sig,"REDIS",5) != 0) {
    /* No RDB preamble, seek back at 0 offset. */
    if (fseek(fp,0,SEEK_SET) == -1) goto readerr;
} else {
    /* RDB preamble. Pass loading the RDB functions. */
    rio rdb;

    serverLog(LL_NOTICE,"Reading RDB preamble from AOF file...");
    if (fseek(fp,0,SEEK_SET) == -1) goto readerr;
    rioInitWithFile(&rdb,fp);
    if (rdbLoadRio(&rdb,NULL,1) != C_OK) {
        serverLog(LL_WARNING,"Error reading the RDB preamble of the AOF file, AOF loading aborted");
        goto readerr;
    } else {
        serverLog(LL_NOTICE,"Reading the remaining AOF tail...");
    }
}

この処理は、AOFファイルの頭に REDIS という文字列があれば、"RDB preamble"を読み込みます。

なるほどです! 持っていたファイルを確認したら、確かに REDIS が書いてありました! "RDB preamble" というバイナリデータがあったから、他のツールが読み込めなかった原因が分かりました。

"RDB preamble" を飛ばせば、通常のAOFファイルを解析できると考えましたが、AOFファイル自体と同様に"RDB preamble" の構造が全く分かりませんでした。 次は、rdbLoadRio()を確認しました。

絶望

rdbLoadRio() はデータを読みながら、opcodeによって、読み込み処理が変わります。 RDB preambleを最後まで正しく読み込むためには、すべてのopcodeの読み込み処理を実装する必要があったみたいです。

AOFファイルだけを解析したいのに、Redisデータベース読み込む処理をPythonで実装しようとすると、かなり大事になり、本当に意味あるかと思い始めました。 絶望に落ちました。。。

ひらめき

まだ諦めない! loadAppendOnlyFile() に戻って、RDB preambleがなかった場合の処理を確認しました。

/* Read the actual AOF file, in REPL format, command by command. */
while(1) {
    int argc, j;
    unsigned long len;
    robj **argv;
    char buf[128];
    ...
    if (buf[0] != '*') goto fmterr;
    if (buf[1] == '\0') goto readerr;
    argc = atoi(buf+1);
    if (argc < 1) goto fmterr;
    ...

おや?RDB preambleの後、*の文字がないと fmterr に飛び、異常終了します。 *があれば、その後に数字があるはずです。 argc という変数名から、数字はRedisコマンドの引数として使用されていることが分かりました。

処理は続きました。

    for (j = 0; j < argc; j++) {
        /* Parse the argument len. */
        char *readres = fgets(buf,sizeof(buf),fp);
        if (readres == NULL || buf[0] != '$') {
            fakeClient->argc = j; /* Free up to j-1. */
            freeFakeClientArgv(fakeClient);
            if (readres == NULL)
                goto readerr;
            else
                goto fmterr;
        }
        len = strtol(buf+1,NULL,10);
        ...

argc分のループを回し、データを読み込んでいます。

また、最初に$の文字がないと、エラーになります。

確かに、自分のファイルには以下のようなデータがありました。

<RDB preamble>*2\r\n$6\r\nSELECT\r\n...

最初の*を検索すれば、RDB preambleを飛ばせるかと思いましたが、RDB preambleには*が普通に出ていました。 残念でした。

次は、\r\nは改行ですので、*または$から始まる最初の を検索すれば、preambleを飛ばせるのではないかと推測しました。 $から始まる行を最初に見つけたら、その位置から*を見つけるまで 逆読み する必要がありますが、これはRDB preamble自体を解析することよりよっぽど楽です。

解析ツールの実装

AOFの構成が分かりましたので、Pythonで実装してみました。 処理の内容を以下のコードの中にコメントいたします。

まずは、loadAppendOnlyFile()を真似して実装しました。

with open("appendonly.aof", 'rb') as f:
    signature = f.read(5)
    if signature == bytes('REDIS', 'utf-8'):
        # RDB preamble を見つけたので、飛ばす
        skip_rdb_preamble(f)
    else:
        # RDB preamble がなかったので、ファイル位置を0にリセットする
        f.seek(0)

    argc = 0
    while True:
        line = f.readline().decode('utf-8')
        if not line:
            break

        if not line.startswith('*'):
            raise IOError(f'AOF file format error at byte {f.tell()}')

        argc = int(line[1:])

        if argc < 1:
            raise IOError('AOF file format error')

        # 1つコマンドは argc 分の行に跨がるが、各行の間に$<number>の行があるから、argc * 2 分回のループを回す
        command = list()
        for _ in range(argc * 2):
            cmd = f.readline().decode('utf-8')
            if cmd.startswith('$'):
                continue
            command.append(cmd.strip('\r\n'))

        if command:
            # commandがあったら、スペース区切りで画面に出力する
            print(" ".join(command))

その後、skip_rdb_preamble()の関数を書きました。

def skip_rdb_preamble(rdb):
    rdb.seek(0)
    buffer = rdb.read(9)
    if buffer[0:5] != bytes('REDIS', 'utf-8'):
        raise IOError('Wrong signature')

    for line in rdb:
        if line[0] == ord('$') or line[0] == ord('*'):
            break

    if line[0] == ord('$'):
        # $ を見つけましたので、lineの先頭位置から1バイト単位で逆読みし、*を探す
        rdb.seek(rdb.tell() - len(line) - 1)
        check = rdb.read(1)
        while check[0] != ord('*'):
            rdb.seek(rdb.tell() - 2)
            check = rdb.read(1)

    # ファイル読み込み位置を*の前に設定する
    rdb.seek(rdb.tell() - 1)

Pythonツールができ、AOFファイルで実行したら以下のように各コマンドが1行ずつに出ました!

$ ./parse-redis-aof.py
SELECT  1
MULTI
incrby  stat:processed  0
...

まとめ

知らないファイル形式の解析は中々いい勉強になります。 他の人が書いたコードを読みながら、自分のコードに落とすスキルも向上できます。

作った解析ツールは https://github.com/yasny/redis-aof-parser に公開しました。 AOFファイルの解析の機会があれば、使ってみてください。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。