【Amazon ElastiCache】オンデマンドRedisからServerless Valkeyに移行した時の自分用メモ

# 概要

現在ECサイトのインフラ運用を担当しているエンジニアです。
最近オンデマンドノードのRedisからServerless Valkeyに移行する作業を行ったので、その際に学習・気づいた点をメモします。

先に言っておくと、Redis→Valkeyへの移行の注意点というよりは、クラスターモード無効→有効にした場合の移行の注意点に近いです。

# 環境

2つのSpring Bootフレームワークを使用したシステムのRedisデータをValkeyに移行しました。

- システム1(ちょっと古め): Spring2系, Java8
- システム2(ちょっと新しめ): Spring3系, Java17

移行前に使用していたRedisについては、以下の設定にしていました
- Redis：7.0.2
- クラスターモード：無効

# Valkey Serverless移行の背景

## なぜRedis→Valkeyなのか

理由は単純にコストの安さです。公式でもRedisよりも30%ほど価格が安くなることをアピールしています。

> 2024 年 10 月 8 日、Amazon ElastiCache は、Serverless の価格が他のサポートされているエンジンより 33% 低く、セルフデザインの (ノードベースの) クラスターが 20% 低い Valkey バージョン 7.2 のサポートを発表しました。ElastiCache Serverless for Valkey を使えば、お客様は 1 分以内にキャッシュを作成でき、月額 $6 から利用を開始できます。

引用：[Amazon ElastiCache for Valkeyの開始方法](https://aws.amazon.com/jp/blogs/news/get-started-with-amazon-elasticache-for-valkey/)

ちなみに、同じプロジェクトの他システムでRedisからValkeyに移行した際には、90%以上コスト削減を達成したそうです。

~~まあ、TTL設定なしキャッシュを大量に保持してたのが原因というオチですが...~~

## なぜオンデマンドノード→Serverlessなのか

手動によるスケールアップ作業をなくすことによる運用負荷の軽減が目的です。

- オンデマンドノードとは：
  - キャッシュの単位のことで、必要なスペック（vCPU・メモリ・ネットワークパフォーマンス）を選択できます
  - スペックが高いほどコストがより多くかかります
- サーバーレスとは：
  - スペックは特に指定せず、vCPU・メモリ・ネットワークリソースなどを監視して垂直・水平方向に自動でスケーリングしてくれます
  - データを読み書きした分だけコストがかかります

私が担当しているECサイトでは定期的に割引セールが開催され、その際に通常の何倍ものユーザーがサイトにアクセスします。

それに備えてセール時のみRedisのキャッシュノードのスペックを上げているのですが、サーバーレスの場合は自動スケーリングが行われるため、その作業が不要になります。

# Spring BootでServerless Valkeyに移行した際にやったこと

今回は元々作成されているValkeyに保存先を変えるだけなので、AWS上での作業はありませんでした。

開発作業については以下を実施しました。

1. キャッシュ保存先のリンクを変更する
2. SSL/TLS設定を追加する
3. MultiGetされるキャッシュのキーにハッシュタグを付与する

## 1. キャッシュ保存先のリンクを変更する

元々あるRedisの保存先（ホスト）をValkeyのAPIに変更するだけです。楽〜
環境ごとにyamlファイルで管理しているのでそちらを修正しました。

ただし、今回は元々のRedisのクラスターモードが無効だったため、さらなる変更が必要です。
（サーバーレスなキャッシュはデフォルトでクラスターモードが有効なので、実質クラスターモード無効→有効になった場合の変更といえます）

これに関して以下2点を修正しました。

- クラスターモード無効の場合：Primary(プライマリノード、書き込みできる)/Read(レプリカノード、読み取り専用)でエンドポイントを分ける必要がある
    - →有効の場合はプライマリ・リードレプリカでエンドポイントを分ける必要がないため、エンドポイントを統一しました
    - ※参考：[ElastiCache での接続エンドポイントの検索](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/Endpoints.html?utm_source=chatgpt.com)
- クラスターモード無効の場合：0~15のデータベース番号を選択できる
    - →有効の場合は、データベース番号を指定できないので0に統一しました
    （デフォルトで0に登録されるのでそもそも設定しなくてもいいと思います）
    - ※参考：[クラスターモードの変更](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/modify-cluster-mode.html)

変更前（Redis/クラスターモード無効）：
````yaml:src/main/resources/application-dev.yml
cache:
  primary:
    host: XXX-redis-XXX.XXX.cache.amazonaws.com
    port: 6379
    database: 1
  read:
    host: XXX-redis-XXX-ro.XXX.cache.amazonaws.com
    port: 6379
    database: 1
````

変更後（Serverless Valkey/クラスターモード有効）：
````yaml:src/main/resources/application-dev.yml
cache:
  host: XXX-valkey-XXX.XXX.cache.amazonaws.com
  port: 6379
  database: 0
````

## 2. SSL/TLS設定を追加する

ElastiCacheのServerlessでは、データ転送時に暗号化が有効になります。

> データを安全に保つために、Amazon ElastiCache および Amazon EC2 は、サーバーのデータへの不正アクセスに対する防御メカニズムを提供します。ElastiCache では転送時の暗号化機能を提供されるため、ある場所から別の場所に移動しているデータの保護ツールとして使用できます。
>
> すべての Valkey または Redis OSS サーバーレスキャッシュで、転送時の暗号化が有効になっています。

引用：[ElastiCache の転送時の暗号化 (TLS)](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/in-transit-encryption.html)

### アプリケーション側でSSL/TLS設定をしていないとどうなる？

指定されたエンドポイントに繋がらないよ！というエラーが発生します。

なぜこのようなエラーが発生するのでしょうか？それはプロトコルが異なってくるからです。

SSL/TLS設定をしていないと、プロトコルは`redis://`のように設定されます。
````
# SSL/TLS設定なし
redis://XXX-valkey-XXX.XXX.cache.amazonaws.com
````

が、実際に存在している（AWS側が投げてほしい）プロトコルは`rediss://`です。
````
# SSL/TLS設定あり
rediss://XXX-valkey-XXX.XXX.cache.amazonaws.com
````

よって、プロトコルが`rediss://`となるようにSSL/TLS設定をしなければなりません。

### Spring BootでのRedisのSSL/TLS設定のやり方

ライブラリによりますが、だいたい同じやり方です。

#### ライブラリの選定

- RedisConnectionFactory:
  - `JedisConnectionFactory`でも`LettuceConnectionFactory`でも良いです
- ClientConfiguration:
  - `JedisClientConfigration`でも`LettuceClientConfiguration`でも良いです
- RedisConfiguration:
  - 色々あるけどクラスターモード対応のものを使用した方がいいかもしれません
  - ⭕️ `RedisClusterConfiguration`
  - ✖️ `RedisStandaloneConfiguration`
  - ✖️ `RedisStaticMasterReplicaConfiguration`
  - （読み書きは一見どれもできているようなので、後述のコードではとりあえず変更していないです。良い子は真似しないでね！※後から問題が発生したら追記します）

#### コードを書いてみる

環境ごとのyamlファイルに`useSsl: true`などの項目を追加しておき、`useSsl = true`の場合はSSL/TLS設定をする、と言う分岐を入れたほうが良いと思います。

（なぜ分岐が必要かというと、ローカル環境や自動テストでServerless Valkeyを使用せずに普通のRedisを使用すると逆にSSL/TLS設定がいらなくなるためです）

#### JedisClientConfigurationの場合

対象システム：`システム1(ちょっと古め): Spring2系, Java8`

````java:RedisConfig.java
private RedisConnectionFactory getCacheRedisConnectionFactory(String hostName, Integer port, Integer database, bool useSsl) {
    GenelicObjectPoolConfig poolConfig = ...(省略)

    // ※良い子はクラスター対応のConfigurationにしてください（RedisClusterConfigurationとか）
    RedisStandaloneConfiguration hostConfig = new RedisStandaloneConfiguration(hostname, port);

    JedisClientConfigration.JedisClientConfigurationBuilder builder = JedisClientConfiguration.builder()
       .usePooling()
       .poolConfig(poolConfig)
       .and()
       .connectTimeout(Duration.ofSeconds(connectionTimeOut))
       .readTimeout(Duration.ofSeconds(readTimeOut));

    // SSL/TLS設定
    if (useSsl) {
      builder.useSsl();
    }
    
    JedisClientConfiguration clientConfig = builder.build();

    return new JedisConnectionFactory(hostConfig, clientConfig);
}
````

#### LettuceClientConfigurationの場合

対象システム：`システム2(ちょっと新しめ): Spring3系, Java17`

````java:RedisConfig.java
private LettuceConnectionFactory getCacheRedisConnectionFactory(String hostName, Integer port, Integer database, bool useSsl) {
    GenelicObjectPoolConfig poolConfig = ...(省略)
    ClientOptions clientOptions = ...(省略)
    int readTimeOut = ...(省略)

    // ※良い子はクラスター対応のConfigurationにしてください（RedisClusterConfigurationとか）
    RedisStaticMasterReplicaConfiguration hostConfig = new RedisStaticMasterReplicaConfiguration(hostName, port);
    hostConfig.setDatabase(database);

    LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettucePoolingClientConfiguration.builder()
       .poolConfig(poolConfig)
       .commandTimeout(Duration.ofSeconds(readTimeOut))
       .clientOptions(clientOptions);

    // SSL/TLS設定
    if (useSsl) {
      builder.useSsl();
    }
    
    LettuceClientConfiguration clientConfig = builder.build();

    return new LettuceConnectionFactory(hostConfig, clientConfig);
}
````

実際はもっとごちゃごちゃ設定項目がありますが、基本は上記で問題ないかと思われます。

### その他考慮すべき点

#### 1. pool（接続プール）を追加すべきか？

````java
JedisClientConfigration.JedisClientConfigurationBuilder builder = JedisClientConfiguration.builder()
       .usePooling()
       .poolConfig(poolConfig)
````

````java
LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettucePoolingClientConfiguration.builder()
       .poolConfig(poolConfig)
````

→追加すべき

[多数の接続 (Valkey および Redis OSS)](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/BestPractices.Clients.Redis.Connections.html)

上記について要約すると、プール接続なしの場合は

> - Redis/Valkey は シングルスレッドのイベントループでクライアントリクエストを順番に処理する
> - 多数の同時接続があると、1クライアントあたりの応答時間が遅くなる傾向がある
> - また、新しいTCP接続の確立は高コスト（CPU・ネットワーク的に重い）がかかる
> - 毎回接続→切断を繰り返すとレイテンシーが大きく増加する

という問題があり、プール接続によって

1. 既存の接続を再利用することで、毎回の接続・切断のオーバーヘッドを削減
2. クライアントアプリケーションから Redis への同時接続数を制限し、Redisサーバーへの負荷を軽減
3. 実際のベンチマークでは：
    - 新規接続ごとに処理 → 2.82ms
    - 接続プール使用時 → 0.21ms
    → 10倍以上のパフォーマンス改善が見られる。

のようなメリットを得られる


#### 2. ClusterNodeを追加すべきか？

````java
hostConfig.addClusterNode(node); // これやるべきか？
````

→Serverlessなので、追加は必要なさそうです。

ノードの増減は自動スケーリングで対処してくれますが、いくつリードレプリカノードを作成してくれるかはAWS上で設定しなければならないので、留意が必要です。

参考：[Adding a scaling policy](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/AutoScaling-Adding-Policy-Replicas.html)


## 3. MultiGetされるキャッシュのキーにハッシュタグを付与する

### CROSSSLOTエラーの概要

接続先をValkeyに変えて、TLS設定も完了したぞ！さあコード修正作業は終わりだ！と思った矢先、別の問題が発生します。

ログに以下のエラーが出現するようになりました。

````
CROSSSLOT Keys in request don't hash to the same slot
````

これもクラスターモードを無効→有効にした影響で出るエラーです。

具体的には、以下でエラーが発生していました。

````java
// 複数のキャッシュデータを一括で取得する
List<String> values = redisTemplate.opsForValue().multiGet(keys);
````

AWS re:Postで上記のエラーについて回答されているのを見つけました。

[Redis (クラスターモードが有効) の ElastiCache クラスターでマルチキーオペレーションを行うときに、すべてのキーが同じノードに保存されているのに、「CROSSSLOT Keys in request don't hash to the same slot (リクエストされた CROSSSLOT キーが同じスロットにハッシュされません)」というエラーが発生するのは、なぜですか?](https://repost.aws/ja/knowledge-center/elasticache-crossslot-keys-error-redis)

上記によると、エラーが発生するのは複数のキーが同じノード内だけでなく、同じハッシュスロット内に存在する必要があるためとのことです。

### ノード？スロット？って何？

という人は以下を参照してください。

ものすごく簡単にいうと、Redisデータを登録するときに、
1. マンション（クラスター）があって、
2. マンション（クラスター）の中に複数の階（シャード）があって、
3. マンションの各階（シャード）に複数の部屋（スロット）があって、
4. 各部屋（スロット）になるべく分散するようにデータが登録される

ということです。分割しすぎじゃぁ

もう少し追記するなら、

- マンション（クラスター）全体に部屋（スロット）が16384戸ある
- データを取り出しやすいように、マンションの各階（シャード、ノードグループ）にスロットが存在する部屋エリア（プライマリノード）とは別に倉庫エリア（リードレプリカノード）が最大5つ存在する

みたいな感じです。

````
【クラスターモード有効な Redis / Valkey クラスター】
　└─ 複数のシャード（＝ノードグループ）
　　　　└─ 各シャードに1つのプライマリノード ＋ 複数のレプリカノード（最大5つ）
　　　　　　└─ プライマリノードは特定のスロット範囲を担当
````

| 用語                               | 意味                                                         |
| -------------------------------- | ---------------------------------------------------------- |
| **クラスター (Cluster)**              | Redis/Valkey全体の分散システム単位。シャードを束ねる単位。                        |
| **シャード (Shard)**<br>（別名：ノードグループ） | キー空間の一部（スロット）を担当する論理的な分割。1つのプライマリと0～5のレプリカノードを持つ。          |
| **ノード (Node)**                   | 実際にRedisプロセスが動作する単位。EC2インスタンスやElastiCacheノード。プライマリまたはレプリカ。 |
| **スロット (Slot)**                  | クラスター全体でキー空間（0～16383）を分割した単位。各スロットは必ず1つのプライマリノードに割り当てられる。  |


参考：
[Valkey または Redis OSS のノードとシャード](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/CacheNodes.NodeGroups.html)
[Amazon ElastiCache for Redis でクラスターモードを使用する](https://aws.amazon.com/jp/blogs/news/work-with-cluster-mode-on-amazon-elasticache-for-redis/)

先ほどのCROSSSLOTエラーは何を言っているかというと、
キーを複数渡されても、データが全員同じ部屋（スロット）にいてくれないと一括で取り出せないよ！というエラーです。

````
CROSSSLOT Keys in request don't hash to the same slot
````

### CROSSSLOTエラーの対処法

上記[Q&A](https://repost.aws/ja/knowledge-center/elasticache-crossslot-keys-error-redis)では、次の2つの解決策が提示されています。

1. Redisクラスターをサポートする Redis クライアントライブラリを使用（redis-py-clusterなど）
2. ハッシュタグを使用してキーを同じハッシュスロットに強制する

#### 解決策1：Redisクラスターをサポートする Redis クライアントライブラリを使用

`JedisClientConfiguration`でも`LettuceClientConfiguration`でもエラーが発生しており、特にマルチスロットから一括取得を可能にしてくれるJavaライブラリが見つかりませんでした。

こんなライブラリ使ったら解決できたよ！という方がいらっしゃったらコメントで教えてください。

#### 解決策2：ハッシュタグを使用してキーを同じハッシュスロットに強制する

今回はこちらの解決策を採用しました。

例えば、以下のようなキーを一括で取得したいとします
````
XXX_CacheGroupA_10000000
XXX_CacheGroupA_11000000
XXX_CacheGroupA_12000000
````

````
XXX_CacheGroupB_90000000
XXX_CacheGroupB_91000000
XXX_CacheGroupB_92000000
````

通常であれば、これらのデータはランダムにスロットに分散され、CROSSSLOTエラーが発生します。
ところが、ハッシュタグ`{}`を付与することにより、同じハッシュタグのキーは同じスロットに登録されるようになります。

例えば、以下のように設定することができます。
````
// 以下はハッシュタグ{CacheGroupA}があるため、同じスロットに登録される
XXX_{CacheGroupA}_10000000
XXX_{CacheGroupA}_11000000
XXX_{CacheGroupA}_12000000
````

````
// 以下はハッシュタグ{CacheGroupB}があるため、同じスロットに登録される
XXX_{CacheGroupB}_90000000
XXX_{CacheGroupB}_91000000
XXX_{CacheGroupB}_92000000
````

これにより、同じスロットからデータを取り出すことになるので、以下でCROSSSLOTエラーは起きなくなります。

````java
// 複数のキャッシュデータを一括で取得する
List<String> values = redisTemplate.opsForValue().multiGet(keys);
````

が、後述の「ハッシュタグを使用する場合の懸念点」にあるように問題点も存在します。

#### 解決策3(番外編)：forループを回して個別に取得する

パフォーマンス面とコスト面が気にならないなら...という感じです。
**10個のキャッシュを一括取得していたのを、ループを回して1個1個取得する場合は、当然ながらレスポンス時間と読み込み回数が10倍になります。**

10倍というとレスポンス時間は5ms→50msぐらいになる程度なのでまだ良いですが、読み込み回数はコストにも直結する部分なので不安ではあります。

平均2〜3個ぐらいだったら個別取得に変更しようかと思いましたが、調査したところ最大50個のキャッシュを一括取得していたので断念しました。

とはいえ、後述の「ハッシュタグを使用する場合の懸念点」を考えると、できればこちらで解決できたほうがいいかなという所感です。

### ハッシュタグを使用する場合の懸念点

ハッシュタグの使用によって同一のタグを持つキーのデータは同一のハッシュスロットに登録されることを学びました。

しかし、同一のハッシュスロットに登録されるということは該当のデータが分散されないことを意味します。

スロットはマンションの1つの部屋という例え話をしましたが、当然部屋にはキャパシティが存在します。具体的には以下のキャパが存在します。

| 名前 | 詳細 | 説明 |
|---|----|--------|
| スロットあたりのサイズ | 32 GiB | 単一の Valkey または Redis OSS ハッシュスロットのサイズ上限。クライアントが 1 つの Valkey または Redis OSS スロットにこれよりも多くのデータを設定しようとすると、そのスロットのエビクションポリシーがトリガーされ、削除可能なキーがない場合はメモリ不足 (OOM) エラーが表示されます。 |
| スロットあたりの ECPU | 30K～90K ECPU/秒 | READONLY 接続を使用してレプリカから読み取るを使用する場合、スロットあたり最大 30K000 ECPUs/秒、または 90K000 ECPUs/秒。 |

参考：[Valkey と Redis OSS の設定と制限](https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/dg/RedisConfiguration.html)

これ大丈夫？超えちゃわない？

仮にValkeyが1つのハッシュタグあたり1つのスロット、他のデータは登録しない、という割り振りをしてくれていても、そのハッシュタグを持つデータだけで超えてしまわない？

みたいな不安が出てきます。

とはいえ、先ほどの「解決策3(番外編)：forループを回して個別に取得する」で述べたように、MultiGetしない方法ではパフォーマンス面・コスト面が良くないので、こちらを採用せざるを得ないです。

とりあえずMultiGetを対象にしているキャッシュだけハッシュタグの対応を行い、それ以外はタグをつけないようにしました。めでたしめでたし。