blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2025.08.01 技術記事

AWS ElastiCache for Redis から Valkey への移行 [DeNA インフラ SRE]

by yayohei

#infrastructure #sre #aws #ElastiCache #Redis #Valkey #infra-quality

移行トラブルに関する追加情報 (2025/09/22 更新)

移行トラブルに関する追加情報を追記しました。

再現手順と対応方法まとめ

弊社にてテストを行い移行時のトラブルを再現することができました。 現象から推測するとプライマリへの書き込みが頻繁に発生している状態でアップデートを行うと、プライマリの接続が Valkey に切り替わった後にすでに接続済みのプライマリ接続の読み込みにて 30 秒程度の反映遅延が確認できました。 遅延時間については、本番で数分程度の反映遅延があったことも確認しており、環境によって異なるようです。

対応として考えられるのは、アップデートの際は常にエンドポイントへ名前解決し直した再接続を行うように実装を変更したり、一時的な遅延が許容できない環境ではメンテナンスを計画するといった対応が考えられます。

検証時点においては現象を確認していますので、移行の際の参考になれば幸いです。

環境情報

  • node type: cache.t3.medium
  • redis v7.1.0 -> valkey v8.1.0 へのアップデート

検証方法

  1. Redis に対して、更新遅延を検知するコードと書き込み負荷を行うコードを実施
  2. Redis を Valkey にアップデート
  3. 書き込み遅延を検知するコードにて Valkey への書き込みに切り替わった際に、更新遅延が発生することを確認

検証結果

  • 書き込み不可: 2025-09-19 13:46:22 - 13:46:30 (約 8 秒)
  • 読み込み遅延: 2025-09-19 13:46:30 - 13:46:55 (約 25 秒)
更新遅延を検知するコード
package main

import (
 "context"
 "fmt"
 "log"
 "os"
 "os/signal"
 "sync/atomic"
 "syscall"
 "time"

 "github.com/redis/go-redis/v9"
)

const (
 writerAddr   = "localhost:6379"
 readerAddr   = "localhost:6379"
 readTimeout  = 5 * time.Second
 writeTimeout = 3 * time.Second
 testInterval = 1 * time.Second
)

type MigrationTester struct {
 writerClient *redis.Client
 readerClient *redis.Client
 counter      int64
 running      int32
}

func NewMigrationTester() *MigrationTester {
 writerOpts := &redis.Options{
  Addr:         writerAddr,
  ReadTimeout:  readTimeout,
  WriteTimeout: writeTimeout,
  PoolSize:     10,
  MaxRetries:   3,
 }

 readerOpts := &redis.Options{
  Addr:         readerAddr,
  ReadTimeout:  readTimeout,
  WriteTimeout: writeTimeout,
  PoolSize:     10,
  MaxRetries:   3,
 }

 return &MigrationTester{
  writerClient: redis.NewClient(writerOpts),
  readerClient: redis.NewClient(readerOpts),
  running:      1,
 }
}

func (mt *MigrationTester) testConnections(ctx context.Context) error {
 if err := mt.writerClient.Ping(ctx).Err(); err != nil {
  return fmt.Errorf("failed to connect to writer (%s): %v", writerAddr, err)
 }

 if err := mt.readerClient.Ping(ctx).Err(); err != nil {
  return fmt.Errorf("failed to connect to reader (%s): %v", readerAddr, err)
 }

 log.Printf("Successfully connected to Writer (%s) and Reader (%s)", writerAddr, readerAddr)
 return nil
}

func (mt *MigrationTester) runTest(ctx context.Context) {
 log.Println("Starting migration test. Press Ctrl+C to stop.")

 for atomic.LoadInt32(&mt.running) == 1 {
  select {
  case <-ctx.Done():
   return
  default:
   if err := mt.executeTestCycle(ctx); err != nil {
    log.Printf("Test cycle failed: %v", err)
    time.Sleep(testInterval)
    continue
   }
   time.Sleep(testInterval)
  }
 }
}

func (mt *MigrationTester) executeTestCycle(ctx context.Context) error {
 counter := atomic.AddInt64(&mt.counter, 1)
 key := fmt.Sprintf("migration_test_%d", counter)
 value := time.Now().Format(time.RFC3339Nano)

 // Print key information
 fmt.Printf("key: %s\n", key)

 // Write to writer
 writeCtx, writeCancel := context.WithTimeout(ctx, writeTimeout)
 defer writeCancel()

 writeStartTime := time.Now()
 if err := mt.writerClient.Set(writeCtx, key, value, 0).Err(); err != nil {
  fmt.Printf("%s 書き込み失敗❌ | WriteHandle [Error]\n", writeStartTime.Format("2006-01-02 15:04:05"))
  return fmt.Errorf("write failed for key %s: %v", key, err)
 }
 writeCompleteTime := time.Now()
 
 // Get actual server type for WriteHandle
 writerType := mt.getServerType(ctx, mt.writerClient)
 fmt.Printf("  %s 書き込み成功✅ | WriteHandle [%s]\n", writeCompleteTime.Format("2006-01-02 15:04:05"), writerType)

 // Read from reader with retry - pass write completion time
 readSuccessTime, err := mt.getWithRetryAndTiming(ctx, key, value, readTimeout, writeCompleteTime)

 if err != nil {
  fmt.Printf("  読み込み失敗❌ | ReadHandle [Error]: %v\n", err)
  return err
 }

 // Calculate time difference
 timeDiff := readSuccessTime.Sub(writeCompleteTime)
 timeDiffMs := timeDiff.Seconds() * 1000
 
 // Get actual server type for ReadHandle
 readerType := mt.getServerType(ctx, mt.readerClient)
 fmt.Printf("  %s 読み込み成功✅ | ReadHandle [%s] | 読み込み遅延 %.0f msec\n", 
  readSuccessTime.Format("2006-01-02 15:04:05"), readerType, timeDiffMs)
 
 fmt.Println("----------------------------------------")
 return nil
}

func (mt *MigrationTester) getWithRetryAndTiming(ctx context.Context, key, expectedValue string, timeout time.Duration, writeCompleteTime time.Time) (time.Time, error) {
 start := time.Now()
 attempt := 0

 for time.Since(start) < timeout {
  select {
  case <-ctx.Done():
   return time.Time{}, ctx.Err()
  default:
  }

  attempt++
  readCtx, readCancel := context.WithTimeout(ctx, 200*time.Millisecond)
  val, err := mt.readerClient.Get(readCtx, key).Result()
  readCancel()

  if err == nil && val == expectedValue {
   successTime := time.Now()
   if attempt > 1 {
    delay := successTime.Sub(writeCompleteTime)
    log.Printf("Read succeeded after %v (attempts: %d)", delay, attempt)
   }
   return successTime, nil
  }

  if err != nil && err != redis.Nil {
   elapsed := time.Since(writeCompleteTime)
   if elapsed > 5*time.Second && attempt%50 == 0 { // Report every 5 seconds
    fmt.Printf("  🔄 読み込み試行中... (%.1f秒経過, 試行回数: %d)\n", elapsed.Seconds(), attempt)
   }
  }

  time.Sleep(100 * time.Millisecond)
 }

 // Final attempt
 finalCtx, finalCancel := context.WithTimeout(ctx, 200*time.Millisecond)
 defer finalCancel()
 val, err := mt.readerClient.Get(finalCtx, key).Result()
 
 if err != nil {
  return time.Time{}, fmt.Errorf("final read failed after %v: %v", timeout, err)
 }
 
 if val != expectedValue {
  return time.Time{}, fmt.Errorf("value mismatch after %v: expected %s, got %s", timeout, expectedValue, val)
 }
 
 return time.Now(), nil
}

func (mt *MigrationTester) getServerType(ctx context.Context, client *redis.Client) string {
 info, err := client.Info(ctx, "server").Result()
 if err != nil {
  return "Unreachable"
 }
 if strings.Contains(info, "valkey_version") {
  for _, line := range strings.Split(info, "\r\n") {
   if strings.HasPrefix(line, "valkey_version:") {
    return fmt.Sprintf("Valkey(v%s)", strings.TrimSpace(strings.Split(line, ":")[1]))
   }
  }
 } else if strings.Contains(info, "redis_version") {
  for _, line := range strings.Split(info, "\r\n") {
   if strings.HasPrefix(line, "redis_version:") {
    return fmt.Sprintf("Redis(v%s)", strings.TrimSpace(strings.Split(line, ":")[1]))
   }
  }
 }
 return "Unknown"
}

func (mt *MigrationTester) close() {
 atomic.StoreInt32(&mt.running, 0)
 if err := mt.writerClient.Close(); err != nil {
  log.Printf("Error closing writer client: %v", err)
 }
 if err := mt.readerClient.Close(); err != nil {
  log.Printf("Error closing reader client: %v", err)
 }
 log.Println("Migration tester stopped")
}

func main() {
 ctx, cancel := context.WithCancel(context.Background())
 defer cancel()

 // Handle shutdown signals
 sigChan := make(chan os.Signal, 1)
 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

 tester := NewMigrationTester()
 defer tester.close()

 if err := tester.testConnections(ctx); err != nil {
  log.Fatalf("Connection test failed: %v", err)
 }

 go func() {
  <-sigChan
  log.Println("Shutdown signal received, stopping test...")
  cancel()
 }()

 tester.runTest(ctx)
}
書き込み負荷を行うコード
package main

import (
 "context"
 "fmt"
 "log"
 "os"
 "os/signal"
 "sync"
 "sync/atomic"
 "syscall"
 "time"

 "github.com/redis/go-redis/v9"

)

const (
 defaultRedisAddr = "localhost:6379"
 numWorkers       = 20
 writeInterval    = 10 * time.Millisecond
 writeTimeout     = 3 * time.Second
 maxRetries       = 3
)

type LoadGenerator struct {
 client       *redis.Client
 running      int32
 totalWrites  int64
 failedWrites int64
 startTime    time.Time
}

func NewLoadGenerator() *LoadGenerator {
 redisAddr := defaultRedisAddr
 if host := os.Getenv("REDIS_HOST"); host != "" {
  redisAddr = host
 }

 opts := &redis.Options{
  Addr:         redisAddr,
  ReadTimeout:  5 * time.Second,
  WriteTimeout: writeTimeout,
  PoolSize:     numWorkers * 2,
  MaxRetries:   maxRetries,
 }

 return &LoadGenerator{
  client:    redis.NewClient(opts),
  running:   1,
  startTime: time.Now(),
 }
}

func (lg *LoadGenerator) testConnection(ctx context.Context) error {
 redisAddr := defaultRedisAddr
 if host := os.Getenv("REDIS_HOST"); host != "" {
  redisAddr = host
 }

 if err := lg.client.Ping(ctx).Err(); err != nil {
  return fmt.Errorf("failed to connect to Redis (%s): %v", redisAddr, err)
 }

 fmt.Printf("Successfully connected to Redis (%s)\n", redisAddr)
 return nil
}

func (lg *LoadGenerator) start(ctx context.Context) {
 fmt.Printf("Starting load generator with %d workers\n", numWorkers)
 fmt.Println("Press Ctrl+C to stop and show statistics")

 var wg sync.WaitGroup

 // Start workers
 for i := 0; i < numWorkers; i++ {
  wg.Add(1)
  go lg.worker(ctx, &wg, i)
 }

 // Start statistics reporter
 wg.Add(1)
 go lg.statsReporter(ctx, &wg)

 wg.Wait()
}

func (lg *LoadGenerator) worker(ctx context.Context, wg *sync.WaitGroup, workerID int) {
 defer wg.Done()

 key := fmt.Sprintf("worker-key-%d", workerID)
 var counter uint64

 for atomic.LoadInt32(&lg.running) == 1 {
  select {
  case <-ctx.Done():
   return
  default:
  }

  counter++
  value := fmt.Sprintf("value-%d-%d", workerID, time.Now().UnixNano())

  if err := lg.writeWithRetry(ctx, key, value); err != nil {
   atomic.AddInt64(&lg.failedWrites, 1)
   fmt.Printf("Worker %d: failed to set key '%s': %v\n", workerID, key, err)
  } else {
   atomic.AddInt64(&lg.totalWrites, 1)
  }

  // Control write rate
  time.Sleep(writeInterval)
 }

 fmt.Printf("Worker %d stopped after %d operations\n", workerID, counter)
}

func (lg *LoadGenerator) writeWithRetry(ctx context.Context, key, value string) error {
 for attempt := 0; attempt < maxRetries; attempt++ {
  writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
  err := lg.client.Set(writeCtx, key, value, 0).Err()
  cancel()

  if err == nil {
   return nil
  }

  if attempt < maxRetries-1 {
   // Exponential backoff
   backoff := time.Duration(attempt+1) * 100 * time.Millisecond
   time.Sleep(backoff)
  }
 }

 return fmt.Errorf("failed after %d attempts", maxRetries)
}

func (lg *LoadGenerator) statsReporter(ctx context.Context, wg *sync.WaitGroup) {
 defer wg.Done()

 ticker := time.NewTicker(10 * time.Second)
 defer ticker.Stop()

 for atomic.LoadInt32(&lg.running) == 1 {
  select {
  case <-ctx.Done():
   return
  case <-ticker.C:
   lg.printStats()
  }
 }
}

func (lg *LoadGenerator) printStats() {
 elapsed := time.Since(lg.startTime)
 totalWrites := atomic.LoadInt64(&lg.totalWrites)
 failedWrites := atomic.LoadInt64(&lg.failedWrites)
 
 rate := float64(totalWrites) / elapsed.Seconds()
 successRate := float64(totalWrites) / float64(totalWrites+failedWrites) * 100

 fmt.Printf("Stats: %d writes, %d failures, %.2f writes/sec, %.2f%% success, elapsed: %v\n",
  totalWrites, failedWrites, rate, successRate, elapsed.Round(time.Second))
}

func (lg *LoadGenerator) stop() {
 atomic.StoreInt32(&lg.running, 0)
 lg.printStats()
 
 if err := lg.client.Close(); err != nil {
  fmt.Printf("Error closing Redis client: %v\n", err)
 }
 
 fmt.Println("Load generator stopped")
}

func main() {
 ctx, cancel := context.WithCancel(context.Background())
 defer cancel()

 // Handle shutdown signals
 sigChan := make(chan os.Signal, 1)
 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

 generator := NewLoadGenerator()
 defer generator.stop()

 if err := generator.testConnection(ctx); err != nil {
  fmt.Printf("Connection test failed: %v\n", err)
  os.Exit(1)
 }

 go func() {
  <-sigChan
  fmt.Println("Shutdown signal received, stopping load generator...")
  cancel()
 }()

 generator.start(ctx)
}
実行結果(抜粋)
key: migration_test_510
  2025-09-19 13:46:20 書き込み成功✅ | WriteHandle [Redis(v7.1.0)]
  2025-09-19 13:46:20 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 1 msec
----------------------------------------
key: migration_test_511
  2025-09-19 13:46:21 書き込み成功✅ | WriteHandle [Redis(v7.1.0)]
  2025-09-19 13:46:21 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 1 msec
----------------------------------------
key: migration_test_512
2025-09-19 13:46:22 書き込み失敗❌ | WriteHandle [Error]
2025/09/21 03:46:25 Test cycle failed: write failed for key migration_test_512: dial tcp 10.225.52.19:6379: i/o timeout
key: migration_test_513
2025-09-19 13:46:26 書き込み失敗❌ | WriteHandle [Error]
2025/09/21 03:46:29 Test cycle failed: write failed for key migration_test_513: dial tcp 10.225.52.19:6379: i/o timeout
key: migration_test_514
  2025-09-19 13:46:30 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  読み込み失敗❌ | ReadHandle [Error]: final read failed after 5s: redis: nil
2025/09/21 03:46:35 Test cycle failed: final read failed after 5s: redis: nil
key: migration_test_515
  2025-09-19 13:46:36 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  読み込み失敗❌ | ReadHandle [Error]: final read failed after 5s: redis: nil
2025/09/21 03:46:41 Test cycle failed: final read failed after 5s: redis: nil
key: migration_test_516
  2025-09-19 13:46:42 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  読み込み失敗❌ | ReadHandle [Error]: final read failed after 5s: redis: nil
2025/09/21 03:46:47 Test cycle failed: final read failed after 5s: redis: nil
key: migration_test_517
  2025-09-19 13:46:48 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  読み込み失敗❌ | ReadHandle [Error]: final read failed after 5s: redis: nil
2025/09/21 03:46:53 Test cycle failed: final read failed after 5s: redis: nil
key: migration_test_518
  2025-09-19 13:46:54 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
2025/09/21 03:46:55 Read succeeded after 403.835981ms (attempts: 5)
  2025-09-19 13:46:55 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 404 msec
----------------------------------------
key: migration_test_519
  2025-09-19 13:46:56 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  2025-09-19 13:46:56 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 1 msec
----------------------------------------
key: migration_test_520
  2025-09-19 13:46:57 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  2025-09-19 13:46:57 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 1 msec
----------------------------------------
key: migration_test_521
  2025-09-19 13:46:58 書き込み成功✅ | WriteHandle [Valkey(v8.1.0)]
  2025-09-19 13:46:58 読み込み成功✅ | ReadHandle [Redis(v7.1.0)] | 読み込み遅延 1 msec
----------------------------------------

はじめに

こんにちは。 IT 本部 IT 基盤部 第一グループのインフラエンジニア山本です。

2025年3~4月に、当社の複数のサービスで AWS ElastiCache for Redis から AWS ElastiCache for Valkey への移行を実施しました。本稿では、Valkey 移行の経緯、パフォーマンス検証、移行検証、実際の移行結果まで解説します。 移行を検討されている方々の参考になれば幸いです。

Valkey 移行に至る経緯

機能と歴史を振り返り、移行に至る理由を説明します。

Redis の利用用途

Valkey の前身は Redis です。 Redis は、オープンソースのインメモリデータストアであり、高速なデータアクセスが求められるキャッシュ、ランキングサーバー、メッセージブローカーなどとして DeNA でも広く利用されています。高速性と多様なデータ構造のサポートから、多くの企業で採用されています。

インフラエンジニアが知っておきたい Redis の主な特徴

アプリケーションのキャッシュデータとして組み込みやすい以下のような特徴をもっています。

  • シングルスレッドアーキテクチャ: シングルスレッドで動作し、スレッド間の競合がなくデータの整合性が保たれる
  • インメモリ処理: メモリ上でデータを操作するため、ミリ秒以下のレイテンシを実現
  • 豊富なデータ構造: 文字列、リスト、セット、ハッシュ、ソート済みセットなどのデータ構造をサポート
  • パブリッシュ/サブスクライブ: リアルタイムのデータストリーミングをサポート
  • 永続化機能: RDB(スナップショット)とAOF(Append Only File)による耐障害性
  • スケーラビリティ: レプリケーションとクラスタによる水平スケーリング
    • Redis はシングルスレッド実装のため、インスタンスのスケールアップによるマルチコアCPUの恩恵が得られにくいです。データを論理分割できない場合には水平スケーリングにて対応することができます。

Redis/Valkey のバージョンと技術的進歩

以下は、Redis の知っておくべき機能強化の歴史です。初期バージョンでほとんどのユースケースで利用される基本的な機能が実装され、その後のバージョンで安定性、パフォーマンス、セキュリティが強化されてきたことがわかります。

Redis 1.0 (2009年3月)

  • 基本データ構造: Strings、Lists、Sets、Hash、Sorted Sets
  • 基本コマンド: SET、GET、DEL
  • トランザクション: MULTI/EXEC

Redis 2.0 (2010年9月)

  • Pub/Sub: PUBLISH、SUBSCRIBE、PSUBSCRIBE コマンドの導入
  • 永続化の改善: バックグラウンド保存の最適化

Redis 3.0 (2015年4月)

  • クラスタリング: マルチマスター構成によるシャーディングの実装

Redis 5.0 (2018年10月)

  • Streams データ型: ログデータやリアルタイムデータ処理に特化

Redis 6.0 (2020年4月)

  • SSL/TLS サポート: 暗号化通信

Valkey の誕生とライセンス変更の背景

2023年にライセンスを変更し、RedisソフトウェアにRedis Source Available License(RSAL)を適用しました。 この変更に対応するため、AWSはRedis 7.2.4をフォークし、Apache 2.0ライセンスの下で「Valkey」として新たなプロジェクトを立ち上げました。Valkeyは技術的にはRedis 7.2.4と同等であり、以下の特徴があります。

Valkey 7.2.5 (2024年4月)

  • Redis 7.2.4 をベースにしたフォーク
  • ブランディングの変更のみで機能的には同等

Valkey 8.0 (2024年9月)

  • 非同期I/Oスレッディング: マルチコア活用によるパフォーマンス向上
  • メモリオーバーヘッドの改善: より効率的なメモリ使用

更に 2025年5月に Redis 8.0以降のバージョンから、SSPLv1、RSALv2に加えて、OSI承認のAGPLv3ライセンスも選択肢として追加することが発表されました。

移行の理由

以下の理由から Valkey への移行を検討することをおすすめします。

  • Valkey 8.0 時点では Redis 7.2 と機能的に同等
  • AWS ElastiCache for Valkey を中心にプロダクトが展開されると予想される将来性
  • AWS ElastiCache for Valkey は 20% 減のコスト面でのメリット

移行検証

移行前の準備

  1. Valkey 移行に伴う ElastiCache パラメータグループの 変更 の確認
    • 変更削除されたパラメーターグループを確認
  2. アプリケーションの互換性確認
    • 開発環境で Valkey に接続し、アプリケーションが問題なく動作するか検証
    • Redis クライアントライブラリが Valkey に対応していることを確認
  3. バックアップの取得
    • 念のため移行前に手動スナップショットを取得
    • 万が一の際のロールバックプランを用意

移行コマンド

以下のようなコマンドを使用して移行を実施できます。

aws elasticache modify-replication-group \
  --replication-group-id ${replication_group_id} \
  --apply-immediately \
  --engine valkey \
  --engine-version 8.0 \
  --parameter-group-name ${parameter_group_name}

ただし、実際の移行時はコマンドではエラーとなり成功しなかったため AWS マネジメントコンソールからの操作しました。当時の情報となりますが、参考までに記載いたします。検証環境などでの動作確認を行うことをおすすめします。

パフォーマンス検証

Valkey への移行は、性能を損なうことなくコスト面でのメリットを享受できることが、実際の検証で確認できました。

検証環境

ネットワークの同一 AZ 内に配置された AWS ElastiCache for Redis と AWS ElastiCache for Valkey の性能を比較しました。同一環境での検証により、公平な比較を実施しています。

  • AWS ElastiCache for Redis

    • インスタンスタイプ: t4g.small
    • リージョン/AZ: ap-northeast-1a
    • バージョン: Redis 6.2.6 および 7.1.0
  • AWS ElastiCache for Valkey

    • インスタンスタイプ: t4g.small
    • リージョン/AZ: ap-northeast-1a
    • バージョン: Valkey 7.2.5 および 8.0
  • ベンチマーククライアント

    • AWS EC2 (t4g.small)
    • リージョン/AZ: ap-northeast-1a(ElastiCacheと同一AZ)
    • ネットワークレイテンシの影響を最小化

検証ツール

複数の標準ベンチマークツールを使用して性能評価を行いました。結果はそれぞれ3回程度の実行の平均値を取得しています。

redis-benchmark

Redis 公式の標準ベンチマークツールを使用して基本的なパフォーマンス特性を評価しました。

redis-benchmark -h ${endpoint} -p 6379 -q -n 100000

コマンドラインからシンプルに実行可能で、GET/SET などの基本操作のスループットを測定します。

memtier_benchmark

より複雑なワークロードシミュレーションのため、Redis Labs が開発したベンチマークツールを使用しました。

memtier_benchmark -s ${endpoint} -p 6379 --protocol redis --clients 50 \
--threads 4 --requests 100000 --data-size 1024 --pipeline 100 --key-pattern R:R \
--ratio 5:5 --random-data --key-maximum=5000 --hide-histogram

検証結果

以下の表は、Redis 6.2.6 のパフォーマンスを 100% としたときの相対的なパフォーマンスを示しています: 性能差はとても少なく ほぼ同等 の結果が得られました。

バージョン redis-benchmark memtier_benchmark
Redis 6.2.6 100% 100%
Redis 7.1.0 105% 100%
Valkey 7.2.5 106% 109%
Valkey 8.0 112% 98%

ダウンタイム測定

Amazon ElastiCache / Google Cloud Memorystore スペック変更時のダウンタイム比較 と同様の手法で、writer ノードのダウンタイムを測定しました。

方法:

  1. redis-cli を使用して1秒間隔で書き込みを実行
  2. タイムスタンプと結果をログに記録
  3. 応答がない期間を測定

測定結果

結果:

READONLY You can't write against a read only replica. のエラーで書き込めなかった時間は、3~4秒程度でした。

回数 時間経過
1回目 3sec
2回目 4sec
3回目 3sec

コスト試算

Valkey ノードは同スペックの Redis ノードよりも 20% のコスト削減が可能です。

インスタンスタイプ Redis料金 (USD/hour) Valkey料金 (USD/hour) 削減率
cache.m7g.large $0.202 $0.1616 20%
cache.m7g.xlarge $0.405 $0.324 20%

※ ap-northeast-1リージョン、オンデマンド料金、2025年6月時点

ただし、リザーブドノードの契約期間によっては移行のタイミングが難しい場合があると思います。 その場合、 Amazon ElastiCache リザーブドノードにサイズ柔軟性が適用 を利用することで Redis ノードで購入したリザーブドノードが Valkey ノードにも適用されますので移行に先んじて 20% 減の台数分で購入することで期間次第ではコスト削減が可能です。

正規化係数 は以下のように違いがあります。

Node size Normalized units with Redis Normalized units with Valkey
micro 0.5 0.4
small 1 0.8
medium 2 1.6
large 4 3.2

逆に Valkey インスタンス用に購入したものは Redis インスタンスには適用できませんので期間中に移行を検討している場合にはご注意ください。

本番移行

移行手順

  1. 移行コマンドを実行
  2. AWS マネジメントコンソールでイベントやステータスをモニタリング
  3. エンドポイントは変更されないため、アプリケーション側の設定変更は不要
  4. 30~40 分後に移行が完了し、数秒の書き込み不可の後に新しいエンジンでサービスが再開
  5. 移行後の確認
    • CPU使用率、メモリ使用率、接続数、キャッシュヒット率が維持されていること
    • アプリケーションのレイテンシに問題がないこと、エラーが発生していないこと

移行トラブル

パフォーマンスやコストでは期待した通りの結果が得られました。 一方で移行中に予期しない挙動が発生しました。

Redis から Valkey へのアップグレードの際に一部のクラスターにおいて Valkey cluster の作成が終わりエンドポイントの移行が完了するまでの数分間に Golang と Rails のアプリケーションから Writer のエンドポイントへのアクセスにおいて存在するはずのキーが存在しない現象が発生しました。 こちらは現在も問い合わせ中で継続して調査を行っています。 確認した範囲では、単純な Writer エンドポイントのフェイルオーバーではなく、Valkey への移行の際にのみ発生しました。

時間経過 状態
00:00 移行開始
00:30 Valkey クラスター作成完了・現象&アプリケーションエラー発生開始
00:40 移行完了・現象&アプリケーションエラー発生終了

現象のみの共有で厳密な再現条件までは特定できておらず恐縮ですが参考になれば幸いです。

まとめ

  1. パフォーマンス: ほぼ同等の性能でした。
  2. コスト効率: 約20%のコスト削減を実現しました。
  3. 移行の容易さ: 2-4秒程度の最小限のダウンタイムで移行が可能でしたが、一部のユースケースではトラブルが発生するケースがあります。
  4. 互換性: 既存の Redis クライアントやアプリケーションコードは変更なしで動作しました。

移行の際に予期しない現象が発生しましたので、移行を検討されている方は事前に本番同等のアプリケーション接続で動作検証を行うことをおすすめします。

参考文献

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。