Auroraには可用性を高めるためにフェールオーバーという機能があります。
しかし、この機能はDBアクセスでよく使われるコネクションプールという仕組みと相性が悪いです。
今回は、Auroraのフェールオーバーとコネクションプールをうまく併用させるための方法について説明していきます。
Auroraのフェールオーバーについて
AWSのRDBサービスであるAuroraには、フェールオーバーの機能が存在します。
フェールオーバーとは、プライマリとリードレプリカを入れ替える処理になります。
この機構があることで、プライマリが何らかの理由で使用不可となった場合も、サービスを継続することができます。
もちろん、フェールオーバー切り替え中はダウンタイムは発生してしまいます。
プライマリとリードレプリカは同じ内容のDBだと思ってもらえればいいです。
Auroraは、同じDBを複製して持っているのです。
これは、片方が壊れたときにすぐに切り替えできるようにするためです。
ただ、プライマリは書き込み、読み込みが可能ですが、リードレプリカは読み込みしかできないという制限があります。
いろんなDBに書き込みがされてしまっては、同期をとるのが難しいので、あくまでマスタとなるのはプライマリ1一つとし、リードレプリカはプライマリをもとにデータを同期するわけです。
ちなみに、リードレプリカは複数個作成可能で、最大15個まで作れるそうです。
■参考:Amazon Aurora でのレプリケーション
また、書き込み用はプライマリ1台のみといいましたが、プライマリを複数台作成できるMulti-Masterというものもあるようです。
■参考:【新サービス】Amazon Aurora Multi-Masterが一般公開になりました
当然ですが、切り替え時にはダウンタイムが発生します。
ただ、ダウンタイムも非常に短くなるように作られているようです。
実際に検証してみた感覚でも数秒から長くても数十秒程度だと思います。
ただし、ダウンタイムが短くなるのはリードレプリカが存在する場合のみです。
リードレプリカが存在する場合は、プライマリとリードレプリカを入れ替えるだけなので、短いダウンタイムで済みます。
ただし、リードレプリカが存在しない場合は、新たにプライマリインスタンスを作成するという挙動になるため、新しいプライマリが作成され使用可能になるまではダウンタイムとなります。
結果として、リードレプリカが存在する時よりもダウンタイムが長くなるのです。
■参考:Amazon Aurora PostgreSQL によるフェイルオーバー
フェールオーバーの問題点
フェールオーバーは非常に便利な機能ではあるのですが、コネクションプールとの相性が悪いという問題点があります。
コネクションプールとはDBとの接続をプールして、接続処理を行う際はそのプールから接続を取り出して使用するというものになります。
この仕組みを利用することによって、DB接続のたびにコネクションを張るということをしなくてもよくなります。
DBへの接続処理は高負荷ですので、コネクションプールを使用することで、処理を軽くすることができるのです。
しかし、コネクションをプールしているときに、Auroraでフェールオーバーが発生するとどうでしょうか。
Auroraのプライマリとリードレプリカが入れ替わります。
フェールオーバー前にプールしておいたプライマリへのコネクションは、フェールオーバー後はリードレプリカへのコネクションになってしまうのです。
つまり、プライマリへDB処理を行おうと思ってプールされたコネクションを利用すると、リードレプリカにつながってしまうのです。
プライマリとリードレプリカは同期がされているので、どっちに処理をしても別に問題ないのではないかと思われるかもしれません。
しかし、Auroraは書き込み用のインスタンスはプライマリのみという制限があります。
リードレプリカに対してupdateやinsertなどの更新処理は実施できないのです。
そのため、プールされているコネクションを使って更新処理を行うと、リーダーに向けて処理を行ってしまうため失敗します。
コネクションプールは、接続に失敗したコネクションは破棄します。
しかし、プールされているコネクションをすべて破棄するわけではありません。
なので、次の処理を行う際にもリーダーにつながるコネクションをプールから取り出して使用してしまいます。
なので、接続失敗によるコネクションの破棄によってプールが空になるまで、処理が失敗し続けることになります。
(※この挙動は使用しているDBAのコネクションプールの仕様によります。)
フェールオーバー時の詳細なAuroraの挙動は以下のようになっているようです。(公式の情報ではないので正確でない可能性があります。)
Auroraはエンドポイントの切り替えによってフェールオーバーを実現しています。
Auroraにはライターとリーダーのエンドポイントが存在します。
ライターのエンドポイントにはプライマリのインスタンスのIPが、リーダーのエンドポイントにはリードレプリカのIPが対応しています。
フェールオーバー時は、エンドポイントに紐づくIPをAuroraが内部的に切り替えることで実現しているようです。
コネクションプールを使用していると、IPベースで接続を持ってしまうため、フェールオーバーによる切り替わりを検知できません。
フェールオーバーが発生したら、そのことを検知してコネクションプールを全破棄、IPをDNS(エンドポイント)から引き直すという処理をしないといけません。
おそらく、ほとんどのアプリケーションではコネクションプールの機能を使用していると思います。
したがって、多くのアプリケーションでこの問題が発生するということになります。
せっかく可用性向上のために複数台構成にしても、切り替え後処理が失敗し続けるようでは意味がありません。
なので、アプリケーションのDataAccess部をそれ用に作り変える必要があるのです。
アプリケーションのフェールオーバー対応の基本的な考え方
Auroraでのフェールオーバー機能を使用するためには、上述の理由からそれ用にDataAccess部を作り変える必要があります。
具体的には、
・Auroraのフェールオーバーを検知
・コネクションプールを全て破棄
・DB接続処理のリトライ
となります。
この仕組みがあれば、Auroraフェールオーバー時もDB接続処理が失敗しない、もしくは失敗したとしても切り替え直後のわずかな時間にとどめることができます。
C#+Postgresで実装する場合の例
では、実際にどのようにすればフェールオーバー用のDBAccessを実現できるのかを見てみましょう。
フェールオーバーの検知
まずは、Auroraのフェールオーバーを検知する必要があります。
色々なやり方があるかと思いますが、今回は実際にDB接続を行って、その時にフェールオーバーによるエラーが発生するかどうかで判断します。
では、どのようなエラーで判断すればいいのかというと、一概に明言することはできないです。
実際に開発環境などフェールオーバーを実施して発生するエラーを見るのが一番良いでしょう。
参考までに私の環境では以下のエラーが発生しました。
Npgsql.NpgsqlException (0x80004005): Exception while reading from stream ---> System.IO.EndOfStreamException: ストリームの末尾を越えて読み取ろうとしました。
エラーコードは-2147467259でした。
コネクションプールの全破棄
フェールオーバーを検知したら、コネクションプールを破棄する必要があります。
以下はNpgsqlを使用している場合ですが、以下の処理でコネクションプールをクリアすることができます。
Npgsql.NpgsqlConnection.ClearAllPools();
ただし、DB接続のフレームワークなどでコネクションプールの管理をしている場合は、これとは別にフレームワーク側でのコネクションプールのリセットが必要になる可能性があります。
前述の例外をcatchした後、これらのコネクションプールのクリア処理を入れればOKです。
DB接続処理のリトライ
上記の2つを対応すれば、フェールオーバー後にエラーが継続するということはなくなるかと思います。
ただ、場合によってはフェールオーバーが起きてもその処理だけは確実に実施したいという時があるかと思います。
そうした場合は、上記の対応に加え、DB接続のリトライの仕組みを入れてやる必要があります。
private static void execDbAccess() { try { NpgsqlCommand cmd = new NpgsqlCommand(sql, connection); //SQL実行 NpgsqlDataReader reader = cmd.ExecuteReader(); } catch (Npgsql.NpgsqlException e) when (e.ErrorCode == -2147467259) // フェールオーバー発生時のエラーをキャッチ { cmd = retryAtfailover; NpgsqlDataReader reader = cmd.ExecuteReader(); // リトライ } }
コネクションプールのクリア処理の詳細は以下です。
// フェールオーバー発生時のリトライ処理 private static IDbCommand retryAtfailover(IDbCommand cmd) { _logger.WarnFormat("[Failover]フェールオーバーによるSQLエラーが発生したため、コネクションを張り直します。"); // SQL、Conection情報を退避 string sql = cmd.CommandText; NpgsqlParameterCollection param = (NpgsqlParameterCollection)cmd.Parameters; // コネクションを閉じる cmd.Connection.Close(); // コネクションプールをクリア Npgsql.NpgsqlConnection.ClearAllPools(); _logger.WarnFormat("[Failover]コネクションプールのクリア完了。"); // SQLの再構築 NpgsqlConnection conn = new NpgsqlConnection([ConnectionString]); NpgsqlCommand newCommand = new NpgsqlCommand(sql, connection); foreach (NpgsqlParameter p in param) { newCommand.Parameters.Add(new NpgsqlParameter(p.ParameterName, p.NpgsqlDbType)); newCommand.Parameters[p.ParameterName].Value = p.Value; } // コネクションオープン newCommand.Connection.Open(); _logger.WarnFormat("[Failover]コネクションの張り直し完了。"); return newCommand; }