遭遇した状況
今回は中でも ElastiCache プライマリクラスターのみで障害が発生した場合 の話しです。
ドキュメントにもあるように、マルチAZ 環境での自動フェイルオーバーでの今回の状況においては
書き込みは、昇格プロセスが完了するとすぐに (通常は数分) 再開できます。ElastiCache が昇格したレプリカの DNS を反映させるため、書き込みのためのエンドポイントを変更する必要はありません。
となっています。
ポイントは、「DNS を反映させるため、書き込みのためのエンドポイントを変更する必要がありません」というところです。
発生した課題
Redis::CommandError READONLY You can't write against a read only slave.
自動フェイルオーバーが発生した際に、運用している Rails アプリから上記のエラーが止まらないと言う状況になりました。
最終的には、Web アプリ、ワーカーアプリともに、再起動することで状況は改善しました。
このエラーメッセージから分かるように、フェイルオーバー後も同じクラスターに接続し続けた結果、リードレプリカに対して書き込みを行ってしまっていました。
昇格後のプライマリクラスターに再接続されなかった
自動フェイルオーバーによって、DNS の向き先は昇格後のプライマリクラスターに変わりましたが、Rails アプリから張られた TCP コネクションは引き続き以前のプライマリクラスターを向いています。よって、フェイルオーバー時には接続先を変えなければいけません。
しかし、 redis-rb
は上記の Redis::CommandError
では再接続を行いません。よって、このエラーは redis-rb
の外側まで飛ばされてきました。
これに関しては以下の issue でも議論されているようです。
https://github.com/redis/redis-rb/issues/543
状況は異なりますが redis-rb
は Sentinel のサポートはしっかりされているようです。
https://github.com/redis/redis-rb#sentinel-support
他のライブラリではどうしているか?
Sidekiq
Sidekiq は Redis への接続を redis-rb
を利用していますが、今回の事象に以下のように対応しています。
def self.redis
raise ArgumentError, "requires a block" unless block_given?
redis_pool.with do |conn|
retryable = true
begin
yield conn
rescue Redis::CommandError => ex
#2550 Failover can cause the server to become a slave, need
# to disconnect and reopen the socket to get back to the master.
(conn.disconnect!; retryable = false; retry) if retryable && ex.message =~ /READONLY/
raise
end
end
end
直接エラーメッセージを参照し、READONLY への接続だった場合には、接続をやり直しています。
ioredis
JavaScript のライブラリです。
先ほどの issue でも提案されているエラー発生時に再接続するかのフックを提供してくれています。
今回の対応をするためには、上述の Sidekiq のようにエラーメッセージでの対応をフックに書くことになりそうです。
Redis::Fast
Perl のライブラリです。
上記のブログが詳しいです。説明もすごく分かりやすい!
どう対応するべきか?
望ましいのは、issue での議論が進み、なんらかの対応か、フックが提供されれば良いと思います。
redis-rb
以外で、今回の事象に対応できそうな gem を探すのもいいと思いますが、ここでは redis-rb
を使い続ける前提で対応策を考えます。
Sidekiq のように例外を外で rescue して再接続に対応する
Redis.current
で接続する箇所で毎回行うのは辛いので、なんらか工夫は必要そうです。
都度接続するようにする
私は、Rails の initializer
で Redis.current
に Redis.new
したものを入れ、アプリ内では Redis.current
を使うのですが、毎回 Redis.new
してインスタンスを作るようにすれば、今回のようなことは起こりません。
redis-rb に手を入れる
モンキーパッチになるので、redis-rb
の更新についていく必要があるのが辛いですが。。
def ensure_connected
disconnect if @pending_reads > 0
attempts = 0
begin
attempts += 1
if connected?
unless inherit_socket? || Process.pid == @pid
raise InheritedError,
"Tried to use a connection from a child process without reconnecting. " +
"You need to reconnect to Redis after forking " +
"or set :inherit_socket to true."
end
else
connect
end
yield
rescue BaseConnectionError
# ★★ ここに READONLY のエラーを入れることができれば、reconnect_attempts オプションでリトライに持っていける。
disconnect
if attempts <= @options[:reconnect_attempts] && @reconnect
retry
else
raise
end
rescue Exception
disconnect
raise
end
end
上記の ensure_connected
メソッドが各 redis コマンド呼び出しの内部になっています。
★ マークでコメントしたように、BaseConnectionError
として、今回のエラーを扱うことが1つできます。
reconnect_attempts のデフォルトは 1
https://github.com/redis/redis-rb/blob/master/lib/redis/client.rb#L21
または Redis::CommandError
をここで rescue して、Sidekiq のようにエラーメッセージを見ることもできます。
また、そもそも Redis::CommandError
を投げている箇所で BaseConnectionError
を投げるようにする方法も考えられます。それは以下です。
def format_reply(reply_type, line)
case reply_type
when MINUS then format_error_reply(line)
when PLUS then format_status_reply(line)
when COLON then format_integer_reply(line)
when DOLLAR then format_bulk_reply(line)
when ASTERISK then format_multi_bulk_reply(line)
else raise ProtocolError.new(reply_type)
end
end
def format_error_reply(line)
# ここでメッセージを見て、BaseConnection を投げることもできる。
CommandError.new(line.strip)
end
という実装をすでにやっている人がいます。
https://github.com/craigmcnamara/redis-elasticache
redis-rb
に取り込んで欲しいという issue も立てているようですが、んー、どうなんでしょう。
https://github.com/redis/redis-rb/issues/550
おわりに
どう対応するかは運用しているアプリの状況によると思います。
今回は ElastiCache のみを事例にあげましたが、そもそもフェイルオーバー時の再接続の対応はしっかり考えてしないといけないなというお話でした。