<-- mermaid -->

5000万件のDynamoDBテーブルをダウンタイム無しで移行した話

こんにちは、ソーシャル経済メディア「NewsPicks」の高山周太郎です。

この記事は NewsPicks アドベントカレンダー 2023 の13日目の記事です。

昨日はakaneyoshiさんによる『BEMの記述メソッドを参考にデザインファイルのクラス名を定義する』でした!


NewsPicksではデータストアの一部にDynamoDBを使用しています。先日、DynamoDBの約5000万件データが入ったテーブルAを、新規のテーブルBとCに分割する移行作業を実施したので、その事例紹介をします。

移行の要件

今回の移行では以下のような要件を満たす必要がありました。

  1. テーブルA(旧テーブル)に存在する既存データは、移行後テーブルBかC(新テーブル)どちらかに存在する必要がある。
  2. 移行後しばらくは新旧両テーブルに同じデータが書き込まれる。
    • 参照系のコードを一気に修正せず、徐々に本番適用していくため。
  3. サービスを停止しない
    • 移行時点では新テーブルを参照するコードは存在せず、旧テーブルへの変更も加えないので比較的容易に達成できるはず。

設計

前述の要件を満たすため、以下のような3段階での移行を計画しました。

  1. JSONでの一括import
  2. import時点での差分処理
  3. 残りの差分の定点観測

Phase1: JSONでの一括import

DynamoDBにはS3バケットへJSON形式でデータをexportする/S3バケットのJSONデータからDynamoDBテーブルをimportする、という機能があるためこれを利用しました。この機能は1年ほど前までは存在せず、batch-write-item API (1回25件のアイテムしか書き込めない制約がある) や AWS GlueなどのETLツールを利用するしかありませんでした。S3を使ったデータ移行が可能になったことでかなり簡単にできるようになっただけでなく、この方法はテーブルの書き込み容量を消費しないためコスト面でもメリットが大きいです。

ただし、S3からのimportは新テーブルでしか実行できず、すでに存在するテーブルはサポートされていません。また、あまり明確に書かれていないのですが、この方法ではローカルセカンダリインデックス(LSI)が作成できないという制約があります。今回はLSIの代わりにグローバルセカンダリインデックス(GSI)を代用することにしました。

exportしたJSONデータを適切な形に変形する必要がありますが、移行元のカラムの状態でどちらのテーブルに移行するか仕分けるだけなので、簡単なスクリプトを用意して変形しました。

スクリプトの用意

今回はDenoを使ったTypeScriptでデータ変形スクリプトを用意しました。Deno+TypeScriptを選定したのは以下の理由です。

  • 扱うデータ形式がJSONなため、JS/TSだと楽。
  • 1~2ヶ月ほどの中期に渡って実験・調整していくことが予想されるため、型チェックの恩恵が受けられると嬉しい。
  • Denoだと実行時に依存関係解決ができたり、デフォルトでTypeScript対応しているため、環境構築が楽。
  • 大量のファイルIOが発生するため、適度に非同期処理をいれて高速化したい。Denoの非同期処理はデフォルトでPromiseが返されるため、async/awaitを使った非同期処理が書きやすい。

また、Denoは去年からnpmパッケージに対応したので、AWS-SDKが問題なく使用できました。そのため、変形スクリプトだけでなくS3へのアップロードなどの周辺スクリプトもDenoで簡単に用意できました。(変形スクリプト自体はJSONの操作のみなのでAWS-SDKを使用していません。)

DynamoDBのJSONについて

DynamoDBからJSON形式でexportしたとき、各ItemがJSONとして表現されますが、その形式が通常のJSONと異なります。

{"Item":{"id":{"N":"1"},"text":{"S":"sample"},"flag":{"BOOL":"true"}}}
{"Item":{"id":{"N":"2"},"text":{"S":"sample2"},"flag":{"BOOL":"false"}}}
...

まず、ファイル全体ではJSONの正しい形式なっていません。ファイルの1行が一つのJSON形式の文字列になっており、これがDynamoDBの一つのアイテムと対応しています。これは、プログラムで扱うときにファイル全体を読み込まずとも、1行ずつ読み込んでJSON文字列として扱えるメリットがあります。

また、データ型はJSONの数値やBool型を使うのではなく文字列として表現し、DynamoDBでどのような型として扱うかは "N", "S", "BOOL" などのフィールド名をつけることで指定します。

実験と時間計測

スクリプトの動作確認と、移行の実行時間の予測のため、本番環境相当量のデータが入った開発環境で実験と時間計測を事前に行いました。

処理内容 実行時間
S3へのexport 10分37秒
スクリプトによるデータ変形 1時間3分56秒
S3からのimport 13時間41分52秒

importが圧倒的に時間がかかることがわかりました。この時点では新テーブルは参照されないため時間がかかっても構わないのですが、ここが長引けばPhase2で処理すべきデータが増えるため、なるべく短いほうが嬉しいです。

importの高速化

Amazon S3 から DynamoDB にインポートするためのベストプラクティス - Amazon DynamoDB を読んで、いくつかの高速化処理を施しました。

  • JSONファイルを100個に分割
    • 各ファイルは並列してインポートされるため、分割が推奨されています。
    • 1ファイル内のアイテム数は400万件程度が目安とされています。今回のデータは全体で5000万件程度あるので、最低でも10~20個のファイルに分割したほうがよいでしょう。実験後もデータ数は増えていくので、余裕を持って100個へ分割しました。
    • ファイル数も5000個を上限とすることが推奨されているようなので、あまり多すぎない意味で100個のファイルにしています。
  • 書き込み先のファイルをアイテム1件ごとにローテーションする
    • 1件目のアイテムはファイル1に、2件目のアイテムはファイル2に…といった要領です
    • アイテムのソート順がランダム化されていたほうがインポートの効率がよいらしいのですが、データ量が膨大で完全にランダムにするコストが大きかったので、連続するアイテムの書き込み先ファイルを分散することで擬似的にランダム化しています。これがどの程度効果があるのかは不明ですが、実装コストが軽いためこの方法をとりました。

これらの処理の結果、約14時間かかっていた処理が4時間に短縮できました。なお、ファイル数が増えたためスクリプトの処理時間が微増したのですが、非同期戦略を調整することでほとんど同じ時間で実行できました。

Phase2: import時点での差分処理

Phase1が完了したあと、新テーブルと旧テーブルを同時に書き込む処理(以下、新旧同期処理)を有効化し、新規データも新テーブルに書き込まれるようになります。しかし、Phase1の実行開始~新旧同期処理の有効化までの約6時間分のデータが新テーブルに書き込まれていません。

Phase2では、Phase1と新旧同期有効化の間に書き込まれたデータを新テーブルに書き込むことを考えます。

以下のようなスクリプトを用意しました。実装はPhase1と同じ理由でdreno+TypeScriptです。

  1. この時間に変更があったパーティションキー+ソートキーを抽出するスクリプト
  2. 抽出されたキーのアイテムが、新テーブル上で旧テーブルと同じ内容になるように更新するスクリプト

旧テーブルにはDynamoDB Streamを使い書き込みログを5分ごとに吐き出す仕組みがすでに存在していました。なのでスクリプト1はそこから変更があったキーを抽出・重複排除する形で実装しました。

スクリプト2では、1で抽出したキーで 旧テーブルを確認し、新テーブルが適切な形になるように修正します。

Phase3: 残差分の定点観測

Phase2までにほとんどのデータの移行が完了しているはずですが、DynamoDBにはトランザクションの機能的な制約が大きく、以下のような状況で新旧のデータが一致しないことが考えられます。

  • Phase2の実行中のうち微妙なタイミング(旧テーブル参照後新テーブル更新前)で書き込みがリクエストされた場合
  • 何らかの理由で新テーブルのみ書き込みに失敗し、旧テーブルのみ書き込みに成功した場合
    • トランザクションで回避可能ですが、実装コストやコードの移行が完全に完了すれば不要になることを鑑みて、今回はトランザクションを使っていません。

NewsPicksでは分析のためDynamoDBのデータを定期的にRedshiftに連携しており、今回のテーブルもその対象となっています。Redshift上で比較用のSQLを実行することで、新旧テーブル間で差分がないか定期的に確認します。

差分があったアイテムについては、Phase2のスクリプト2を実行することで差分を解消します。

まとめ

DynamoDBではimport/exportの制約があり、RDSと比較して整合性が弱いですが、不整合を丁寧に検知しながら進めることで移行することができました。

また、各種の制約が許容できるのであれば、S3へのimport/exportは扱いやすくコスト的にもメリットが大きいです。

より大量のデータや頻繁な書き込みがある場合、不整合の発生頻度が高まるため、より丁寧な移行計画が求められると思います。

告知

NewsPicks ではエンジニアを募集中です!ご興味のある方はこちらまで。

https://hrmos.co/pages/uzabase/jobs/NP_Eng004

今回の記事がおもしろいと思ったら NewsPicks アドベントカレンダーの他の記事も見てみてくださいね。 明日はmorinotaさんが書いてくれます。お楽しみに!

Page top