ElastiCache ServerlessでGETしたらMOVEDエラーが出る原因と対策
結論
- ポート6380に接続して読み取り専用として使うなら
GETの前にREADONLYコマンドを実行すべし -
READONLYコマンドを実行したうえで読み取ると古いデータが返ることがあるため、それが許容できないなら通常のポート6379に接続すべし
はじめに
今回の対象のシステムではもともと、クラスターモード無効のノードベースElastiCacheクラスター(Redis OSS)を利用していました。
このシステムは負荷の変動が激しく、スケーラブルな性能が求められます。インフラを構成する要素のうちほとんどはスケーラブルになっていましたが、このElastiCacheクラスターだけは自動でスケールせず、予期せぬ負荷に弱い状況でした。
高負荷の見込まれるタイミングに合わせて、手作業でノード数を上げ下げする運用でカバーしていたものの、上げ忘れによる性能不足や、戻し忘れによる無駄な費用の発生といったリスクがありました。
そこで、運用負荷を下げつつ可用性を高められるようElastiCache Serverlessへの移行を進めることになりました。
やったこと
ElastiCache Serverlessを作成 + パラメータ格納
CloudFormationで作成しました。
ElastiCacheServerless:
Type: 'AWS::ElastiCache::ServerlessCache'
Properties:
Engine: 'redis'
MajorEngineVersion: '7'
SecurityGroupIds:
- ...
ServerlessCacheName: 'serverless-prod'
SubnetIds:
- ...
合わせて、 ServerlessCacheのReturn Values のうち以下の属性を取得してパラメータストアに登録しました。
-
ElastiCacheServerless.Endpoint.Address- 読み書き可能なエンドポイント
-
xxx.serverless.{region}.cache.amazonaws.comの形式
-
ElastiCacheServerless.Endpoint.Port- 読み書き可能なポート
- Redisのデフォルトのポート
6379が得られる
-
ElastiCacheServerless.ReaderEndpoint.Address- 読み取り専用エンドポイントという扱いだが、値は
Endpoint.Addressと同じ
- 読み取り専用エンドポイントという扱いだが、値は
-
ElastiCacheServerless.ReaderEndpoint.Port- ElastiCache Serverless独自の読み取り専用ポート
6380が得られる
アプリケーションの接続先を変更
Laravelの config/database.php を以下のように修正しました。
- ホスト変更
-
REDIS_SERVERLESS_HOSTとREDIS_SERVERLESS_HOST_ROは同じ値ですが、 Return Values としてはEndpoint.AddressとReaderEndpoint.Addressがそれぞれ存在することから、今後AWSの仕様変更で異なる値になる可能性を想定し、別々の入れ物として環境変数を2つ用意しています
-
- パスワードがなくなった
- データベース番号は0固定
- 今回は0に集約しても支障なかったものの、別々のデータベースで同じキーを使っている場合は要注意
- スキーマはtls必須
- 読み取り専用の接続
redis-roには、さきほど得たポート6380を利用
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'cluster' => false,
'default' => [
- 'host' => env('REDIS_HOST', 'localhost'),
- 'password' => env('REDIS_PASSWORD', null),
+ 'host' => env('REDIS_SERVERLESS_HOST', 'localhost'),
+ 'password' => null,
'port' => env('REDIS_PORT', 6379),
- 'database' => 3,
- 'scheme' => env('REDIS_SCHEME', 'tls')
+ 'database' => 0,
+ 'scheme' => env('REDIS_SERVERLESS_SCHEME', 'tls')
],
'redis-ro' => [
- 'host' => env('REDIS_HOST_RO', '127.0.0.1'),
- 'password' => env('REDIS_PASSWORD', null),
- 'port' => env('REDIS_PORT', 6379),
- 'database' => 3,
- 'scheme' => env('REDIS_SCHEME', 'tls')
+ 'host' => env('REDIS_SERVERLESS_HOST_RO', '127.0.0.1'),
+ 'password' => null,
+ 'port' => env('REDIS_SERVERLESS_PORT_RO', 6380),
+ 'database' => 0,
+ 'scheme' => env('REDIS_SERVERLESS_SCHEME', 'tls'),
],
],
動作確認でエラー発生
Redisからデータを取得するAPIで動作確認を行ったところ、MOVEDエラーが発生しました。
MOVED 11526 xxx.serverless.apne1.cache.amazonaws.com:6379
MOVEDエラーとは
Redis Cluster特有のエラーです。
複数のノードにデータが分散されるため、 GET コマンドを受け付けたノードには目的のデータが存在しない場合があります。その際、どのノードが目的のデータを担当しているかというリダイレクト情報を返すのが MOVED レスポンスです。
If the hash slot is served by the node, the query is simply processed, otherwise the node will check its internal hash slot to node map, and will reply to the client with a MOVED error, like in the following example:
GET x -MOVED 3999 127.0.0.1:6381The error includes the hash slot of the key (3999) and the endpoint:port of the instance that can serve the query. The client needs to reissue the query to the specified node's endpoint address and port.
トラブルシューティング
クライアント側でクラスターモードを有効化すべき? →No
検索すると、「Laravelの config/database.php でクラスターモードを有効にすべき」という情報が多く出てきました。しかし、試してみてもうまくいきません。
そもそも、ElastiCache Serverlessではクラスターの存在は隠蔽されているはずでは……?と疑問に思い、直接 redis-cli コマンドで確認したところ同様にMOVEDエラーが発生したため、Laravelの問題ではないと気付けました。
初心に戻り公式チュートリアル
行き詰まったため、公式チュートリアルに沿って1から確認していくことにしました。
ここで、ポート6379に接続すると問題なく疎通することが分かりました。つまり、読み取り専用ポートの使い方が間違っているということです。
読み取りポートのドキュメントを確認
「ElastiCache 6380」で検索して出てきた公式ドキュメントを読んでみます。
port
プライマリポート: 6379
読み取りポート: 6380
サーバーレスキャッシュは、同じホスト名の 2 つのポートをアドバタイズします。プライマリポートは書き込みと読み取りを許可し、読み取りポートは READONLY コマンドを使用して低レイテンシーで最終的に一貫性のある読み取りを可能にします。
「読み取りポートは READONLY コマンドを使用して」
↑これだ!!
読み取り専用ポートに接続しているのに READONLY コマンドを実行していなかったのが原因でした。
試しに redis-cli コマンドで READONLY を実行してから GET するとうまくいきました。
解決策
方法1: ポート6380に接続したら初手 READONLY を実行
読み取り専用アクセスをしたい場合、ポート6380に接続したあと、 GET の前に READONLY コマンドを実行する必要があります。
$connection = Redis::connection('redis-ro');
$connection->command('READONLY'); // これが必須
$cache = $connection->get('key');
ただし、 READONLY コマンドを使用すると古いデータを読み込む可能性があることがRedis公式ドキュメントに記載されています。
READONLY tells a Redis Cluster replica node that the client is willing to read possibly stale data and is not interested in running write queries.
今回のケースでは古いデータが読み込まれると困るため、不採用としました。
方法2: すべての接続でポート6379を使用
古いデータが読み込まれるリスクを避けるため、読み取り処理であっても読み書き可能なエンドポイントとポートに接続するようにしました。
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'cluster' => false,
'default' => [
'host' => env('REDIS_SERVERLESS_HOST', 'localhost'),
'password' => null,
'port' => env('REDIS_PORT', 6379),
'database' => 0,
'scheme' => env('REDIS_SERVERLESS_SCHEME', 'tls')
],
'redis-ro' => [
'host' => env('REDIS_SERVERLESS_HOST', '127.0.0.1'), // 書き込み用と同じ
'password' => null,
'port' => env('REDIS_PORT', 6379), // 書き込み用と同じ
'database' => 0,
'scheme' => env('REDIS_SERVERLESS_SCHEME', 'tls'),
],
],
おわりに
この問題にはしばらくハマり続けてしまいました。もっと早くポート6379で試していればポート6380の使い方に問題があると気付けたはずですが、悩んでいるうちは視野が狭くなっており、それを試そうという発想に至りませんでした。
行き詰まったときこそ焦らず、公式のチュートリアルに沿って1からやり直すのが近道だと思います。
また、ElastiCache Serverlessのアーキテクチャを把握していたからこそ、Laravelでクラスターモード有効化を試した際に「Serverlessはノードを意識しなくてよかったはずでは」という違和感に気付くことができました。
マネージドサービスは仕組みを理解せずとも使えるのが利点ですが、知っておくと設計やトラブルシューティングに役立つため、自分はインフラの専門家としてなるべく理解しておきたいと感じました。
【おまけ】ElastiCache Serverlessのアーキテクチャ紹介
ElastiCache Serverlessは、内部的にはRedis Clusterで動作していますが、ノード群の手前にある「プロキシレイヤー」が各ノードへのルーティングを担ってくれています。

ElastiCache Serverlessの構成(公式ドキュメントより引用)
プロキシレイヤーのおかげで、個々のノードの存在は隠蔽され、クライアントは個々のノードを意識することなく利用できます。
裏側でノードのスケールイン/アウトや入れ替えがあっても、クライアントとの接続がリセットされたりエラーが起きたりしない仕組みになっています。
そのため、今回はLaravelのクラスターモードを無効のままとするのが適切でした。
Discussion