💽

ElastiCache Redis のデータベースを移行した話

2024/12/19に公開

前提条件

本記事では以下のバージョンを前提としています。

  • ElastiCache Redis エンジンバージョン 7.1

概要

弊社では、元々は各サービスごとに専用の Redis インスタンスを用意していましたが、各々メンテナンスが必要となり、ダウンタイムが発生する機会が増え、インフラエンジニアの運用コストも上がるという課題がありました。
そこで、Redis の DB 番号を 0 ~ 15 まで割り当てられるという特性を活かし、1台の Redis インスタンスに各サービスごとに異なる DB 番号を割り当てる運用に切り替えました。
この方法により、複数のサービスが同じ Redis を共有しつつも、1台の Redis のみをメンテナンスすれば済むようになり運用効率が向上しました。
しかし、サービスの成長に伴い1台の Redis で運用することに対して、安定性・保守性・拡張性の面で課題が出てきました。
これらの課題を解消するために行ったことについてこの記事で解説します。

課題解決のためのアプローチ選定

課題解決のアプローチとして以下の2つが候補に上がりました。

  • クラスターモードを有効化し、今ある Redis をスケーラブルにする
  • ElastiCache Redis をもう一台構築し、負荷を分散する

クラスターモードを有効化すると、負荷に応じてシャードを増やすことで書き込みを分散させたり、フェイルオーバー時のダウンタイムを最小限にすることができるメリットがあるので、クラスターモードを有効化する方針で進めることにしました。

クラスターモード有効化の条件

クラスターモード有効化の条件について調査したところ、公式ドキュメントに以下の記述がありました。

  • クラスターのキーは DB 番号 0 にのみ存在できます。
  • アプリケーションは、クラスタープロトコルを使用できる Valkey または Redis OSSクライアントを使用し、設定エンドポイントを使用する必要があります。
  • 少なくとも 1 つのレプリカがあるクラスターでは、自動フェイルオーバーを有効にする必要があります。
  • 移行に必要な最小エンジンバージョンは Valkey 7.2 以降、または Redis 7.0 OSS 以降です。

概要にも記載した通り、弊社では Redis を複数のサービスで共有している都合上、DB 番号 0 以外にもキーを格納しているためクラスターモードの移行要件をそのままでは満たせないことが発覚しました。

以上のことから、「ElastiCache Redis をもう一台構築し、負荷を分散する」アプローチで進めることにしました。

移行対象 DB の選定

データ移行の効果を最大化するため、圧倒的にデータ量の多い DB 番号 1(以後、DB:1と表記します)を新しく構築した ElastiCache Redis の DB:0 に移行することにしました。
移行系計画時点での Redis 全体のキーの数は 31,000,000件 程度なのに対して、DB:1 のキー数は 30,000,000件以上存在しており、DB:1 を独立させれば CPU やメモリの使用率をかなり改善できる見込みでした。
更に、DB:1 は1時間程度ならキーが欠損してもサービスの稼働に問題ない性質を持っていたため、他の DB に比べて移行の条件が整っていました。
もし DB:1 のデータロストがサービスの稼働に影響した場合、移行は困難だったと思います。
そのため、開発チームがサービス設計時に、Redis に保存するデータは一時的にロストしてもサービスに影響が出ないよう配慮してきた開発努力が功を奏したと言えるでしょう🙏

移行方法の選定

移行方法として以下の2つが候補に上がりました。

  1. Redis を2台立てて DB:1 の書き込みを両方行い、データ量が同一になった時点で接続先を移行先に変更する
  2. Redis CLI の MOVE などを使い、データを直接移行する

最もダウンタイムが少ないのは 1 の方法ですが、以下のデメリットがあり 2 の方法を選択しました。

  • 両方に書き込むことによる書き込み側の負荷を考慮しなければならない
  • データ量が同一になるまで Redis の料金が倍増する
  • キーの有効期限が長いものだと90日以上あったりするので、データ量が同一になるのに時間がかかる

また、ElastiCache Redis が機能として DB 移行をサポートしているかについても調査しましたが、EC2 上の Redis から ElastiCache Redis への移行のみサポートされており、ElastiCache Redis 同士でのデータ移行はサポートされていませんでした。
Online migration for Valkey or Redis OSS

移行手順の構築

キーの欠損が1時間程度許されているとはいえ、30,000,000件ものキーを移行する必要があるため、しっかりとした手順を構築する必要がありました。
初期段階として、大まかな移行手順を以下のように作成しました。

  1. ElastiCache Redis のスナップショットを取得
  2. スナップショットから新しい Redis を構築
  3. 新しい Redis に接続し、DB:1 以外のデータを削除する
  4. DB:1 のデータを DB:0 に移行する
  5. バックエンド側で DB:1 の接続先を変更する

スナップショットを取得した段階からデータの欠損が始まるので、スナップショットを取得〜それぞれにかかる所要時間を見積もりました。

  1. ElastiCache Redis のスナップショットを取得(15分程度)
  2. スナップショットから新しい Redis を構築(15分程度)
  3. 新しい Redis に接続し、DB:1 以外のデータを削除する(1分以内)
  4. DB:1 のデータを DB:0 に移行する(未確定)
  5. バックエンド側で DB:1 の接続先を変更する(10分程度)

確定している所要時間は合計40分程度だったので、未確定な「DB:1 のデータを DB:0 に移行する」にかかる時間を20分程度に抑えることができれば要件を満たすことができそうです。

データ移行スクリプトのチューニング

30,000,000件のデータを移行するために、Redis CLI の MOVECOPY が使えそうで、MOVE の方が速度的にも良さそうだったので MOVE を活用した以下スクリプトを実装しました。

import redis

# Redisに接続
client = redis.StrictRedis(host='localhost', port=63795, db=1)

# 移動するキーのリストを取得
keys = client.keys('*')

# while でkeysを取得して、keysがなくなったら抜けるようにする
for key in keys:
    # キーを移動
    client.move(key, 0)

print("キーの移動が完了しました。")

実際、このスクリプトは機能しましたが 30,000,000件のデータを20分で移行するには速度が全く足りませんでした。
速度を上げるためにスクリプトの並列化とインスタンスタイプのスケールアップを試しましたが、限界があり移行に3時間以上は確実にかかってしまいそうでした。

そこで、Redis のデータ移行に特化した OSS はないか調査したところ、redis-dump-go というツールを発見しました。
https://github.com/yannh/redis-dump-go
README.md にも記載されていますが、RESP(Redis serialization protocol specification) 形式で dump ファイルを生成・取り込みすることで、コマンドと比較して高速な処理が可能とのことです。

使い方は README.md にも記載されていますが、よく使いそうなオプションについて解説しておきます。

  • -port
    • redis の接続ポート番号を指定
  • -n
    • 並列数を指定
  • -batchSize
    • 1プロセスあたりの処理データ量を指定
  • db
    • どの DB に対して処理するかを指定

実際に redis-dump-go を使い、データ量の少ない DB:2 から DB:0 への移植が可能かどうか試してみます。

以下にて、 resp を取得し DB:0 に流し込みました。

// DB2 の resp を取得
$ ./redis-dump-go -port 63XX -n 10 -batchSize 1000 -db 2 > dump.resp
Database 2: 2736 element dumped

// DB0 に流し込む
$ redis-cli -n 0 --pipe < dump.resp
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 5467

ところが、DB:0 に DB:2 のデータが反映されていませんでした。

$ redis-cli
// DB0に入ってない?
127.0.0.1:6379> dbsize
(integer) 0

取得した dump.resp の中身を確認してみると、以下のようになっていました。

*2
$6
SELECT
$1
2

調べると以下のような意味になっているようです。

*2 (サイズが2のArrayを示す)
$6 (次の文字数が6であることを示す)
SELECT (Redis CLI における SELECT を示す)
$1 (次の文字数が1であることを示す)
2 (SELECT の引数としての 2 を示す)

つまり以下コマンドが表現されていることになり、Redis CLI で DB:2 を選んでいるわけです。
SELECT 2

DB:2 のデータを DB:0 に移行したいので、dump.resp を以下のように編集し、

*2
$6
SELECT
$1
0

以下コマンドを実行することで、DB:2 のデータを DB:0 に移行することに成功しました。

$ redis-cli -n 0 --pipe < dump.resp
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 5467

$ redis-cli -n 0  dbsize
(integer) 2736

DB:1 のデータ移行も別途検証した結果、redis-dump-go を使うことで 30,000,000件のデータ移行を 1時間程度で終わらせることができることがわかりました。
しかし、スナップショットの取得などに40分はかかるので更に高速化する必要がありました。
また、redis-dump-go 自体にはまだまだ速度向上の余地があるように感じられたので、ElastiCache Redis 側の性能がボトルネックになっているように感じたので、EC2 上に Redis をインストールし、そこで redis-dump-go を実行してみることにしました。

用意した EC2 は以下のスペックになります(詳細実装は割愛します)。

  • インスタンス
    • r7i.2xlarge
  • ストレージ
    • gp3
    • iops
      • 16000
    • スループット
      • 125

上記スペックの EC2 上の Redis で検証を重ねた結果、256並列・バッチサイズ1000 の redis-dump-go コマンドを実行することで、30,000,000件ものデータを7分程度で移行することに成功しました。

# ダンプ (5分程度)
$ time redis-dump-go -port 6379 -n 256 -batchSize 1000 -db 1 > dump.resp

# 4行目を $1 , 5行目を 0 に編集する(DB0向けにするために必要)
$ vi dump.resp

# DB:0 に向けてリストア (2分程度)
$ time redis-cli -n 0 --pipe < dump.resp

最終的な移行手順

当初は ElastiCache Redis 上でデータ移行をするつもりだったため、最終的な移行手順が大きく変わりました。
かなり特殊事例だと思うので、参考になるか分かりませんが以下手順にて移行しました。

  1. あらかじめ移行のための Redis がインストールされた EC2 を立ち上げて session-manager などでログインしておく
  2. 移行対象の ElastiCache Redis のスナップショットを取得(15分程度)
  3. スナップショットを S3 にエクスポート(1分程度)
  4. S3 のスナップショットを 移行のために用意した EC2 からダウンロード(30秒程度)
$ aws s3 cp s3://elasticache-snapshot/hoge-production-snapshot-0001.rdb  /var/lib/redis/dump.rdb
  1. 上記で取得したスナップショットを復元(1分程度)
$ systemctl start redis
  1. DB:0 のデータを削除する(10秒以内)
$ redis-cli -n 0 flushdb
  1. DB:1 のデータのみ redis-dump-go でダンプ取得(5分程度)
$ time redis-dump-go -port 6379 -n 256 -batchSize 1000 -db 1 > dump.resp
  1. dump.resp の 5行目を 0 に編集する(DB:0向けにするために必要)
$ vi dump.resp
  1. DB:0 に向けてリストア(2分程度)
$ time redis-cli -n 0 --pipe < dump.resp
  1. DB:0以外を削除(1分程度)
redis-cli -n 1 flushdb
  1. DB:1 のデータが DB:0 に入っている状態で、スナップショットを取得する(1分程度)
redis-cli bgsave
  1. スナップショット取得が完了したら S3 にアップロードする(1分程度)
aws s3 cp /var/lib/redis/dump.rdb s3://elasticache-snapshot/hoge-production-db1.rdb
  1. 上記で構築したスナップショットを元に新しい ElastiCache Redis を構築する(15分程度)
    Terraform の場合、以下のようにすることで S3 のスナップショットを元に構築することができる
snapshot_arns = ["arn:aws:s3:::elasticache-snapshot/hoge-production-db1.rdb"]
  1. 構築後、DB:1 のデータが DB:0 に格納されていることを確認する
  2. バックエンド側で DB:1 の接続先を変更する(10分程度)

これらの手順を実際に実施した際にかかった時間は58分でした。

結果

巨大な DB:1 を独立させることで、以下のメリットがありました。

  • 頻繁に発生していたCPU使用率のアラートが発生しづらくなった
  • メモリ使用率が 70% から 40% に下がった
  • 独立させたことによって、どの経路から負荷がかかっているのか明確になった
  • クラスターモード化の要件を満たしたため、今後の拡張性が上がった

まとめ

  • RESP(Redis serialization protocol specification) を活用することで Redis のデータ移行を高速に実施できる
  • redis-dump-go を使うことで、簡単に RESP 形式のファイルをダンプしたり取り込むことができる
  • 将来的にクラスタモードへの移行を検討しているなら DB:0 以外利用しない方が良い
    • クラスターモード化の要件に DB:0 以外にキーが存在できないというものがあるため
SocialPLUS Tech Blog

Discussion