こんにちは、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.cのloadAppendOnlyFile()
を発見しました。
関数を読みながら、以下の処理が見つかりました。
/* 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ファイルの解析の機会があれば、使ってみてください。