ElastiCache がフェイルオーバーした際に気をつけるべき redis-rb の利用方法について

## 遭遇した状況

http://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/UserGuide/AutoFailover.html#AutoFailover.Overview

今回は中でも __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` を利用していますが、今回の事象に以下のように対応しています。

https://github.com/mperham/sidekiq/blob/3_x/lib/sidekiq.rb#L79-L92

```rb:sidekiq.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 のライブラリです。

https://github.com/luin/ioredis#reconnect-on-error

先ほどの issue でも提案されているエラー発生時に再接続するかのフックを提供してくれています。

今回の対応をするためには、上述の Sidekiq のようにエラーメッセージでの対応をフックに書くことになりそうです。

### Redis::Fast

Perl のライブラリです。

https://metacpan.org/pod/Redis::Fast

[Redis::Fast の reconnect について](http://tech.kayac.com/archive/reconnect_redisfast.html)

上記のブログが詳しいです。説明もすごく分かりやすい！

## どう対応するべきか？

望ましいのは、issue での議論が進み、なんらかの対応か、フックが提供されれば良いと思います。

`redis-rb` 以外で、今回の事象に対応できそうな gem を探すのもいいと思いますが、ここでは `redis-rb` を使い続ける前提で対応策を考えます。

### Sidekiq のように例外を外で rescue して再接続に対応する

`Redis.current` で接続する箇所で毎回行うのは辛いので、なんらか工夫は必要そうです。

### 都度接続するようにする

私は、Rails の `initializer` で `Redis.current` に `Redis.new` したものを入れ、アプリ内では `Redis.current` を使うのですが、毎回 `Redis.new` してインスタンスを作るようにすれば、今回のようなことは起こりません。

### redis-rb に手を入れる

モンキーパッチになるので、`redis-rb` の更新についていく必要があるのが辛いですが。。

https://github.com/redis/redis-rb/blob/master/lib/redis/client.rb#L335-L367

```rb:redis/client.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` として、今回のエラーを扱うことが１つできます。

> reconnect_attempts のデフォルトは 1 
https://github.com/redis/redis-rb/blob/master/lib/redis/client.rb#L21

または `Redis::CommandError` をここで rescue して、Sidekiq のようにエラーメッセージを見ることもできます。

また、そもそも `Redis::CommandError` を投げている箇所で `BaseConnectionError` を投げるようにする方法も考えられます。それは以下です。

https://github.com/redis/redis-rb/blob/master/lib/redis/connection/ruby.rb#L281-L294

```rb:redis/connection/ruby.rb
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 のみを事例にあげましたが、そもそもフェイルオーバー時の再接続の対応はしっかり考えてしないといけないなというお話でした。