これは スタンバイアドベントカレンダーの9日目です。
はじめに
Amazon Aurora for MySQLでクラスタを組んでいて、可用性を高めるために書き込み可能インスタンスを複数並べてActive/Standby構成にすることがある。
このような運用をしているとき、Activeなインスタンスに問題があるとフェイルオーバーしてStandbyしているサーバが昇格するが、DBを利用しているアプリケーション側も接続先を自動的に昇格したサーバに対して切り替えたい。
ここではJVM言語 + JDBCなアプリケーションにおけるそのような要件に対して応えるために、2022年3月よりAWS公式でAurora for MySQL用のJDBCドライバを提供してくれるようになったので扱ってみることにする。
TL;DR
- AWS JDBC Driver for MySQLはAWSが提供しているAuroraのfailover検知機能を持ったMySQL用JDBCドライバ
- Auroraがfailoverしてwirterインスタンスが切り替わったときにJDBCで勝手に接続先インスタンスを切り替えてくれるのでアプリケーションは書き込み先を意識する必要がなくなる
- Connection Poolを利用する場合はConnection Pool側でfailoverエラー時の再接続を回避する必要あり
- 意図せずwriterインスタンスに昇格してしまう可能性もあるのでリードレプリカのみ利用したい場合は利用しないが吉
AWS JDBC Driver for MySQLとは
AWSが提供している、Aurora for MySQLのfailoverを検知し再接続をする機能を持ったMySQL用JDBCドライバ。
本件ではfailoverの機能だけを検証するがその他にもAurora for MySQLを利用するにあたって便利なIAM接続とかSSMで管理しているクレデンシャルの読み込みなどの機能を有している。
https://github.com/awslabs/aws-mysql-jdbc#amazon-web-services-aws-jdbc-driver-for-mysql
どのようにfailoverが動いているのか?
MySQL Connector/Jのプラグインとして実装されており、以下の図の通りに動作する。 JDBCドライバ内にLogical(論理) ConnectionとPhysical(物理) Connectionを保持し、ユーザ側にはLogical Connectionを見せている。Failover Processがクラスタのfailoverを検知し、検知したら新しい接続先へのConnectionを張りPhysical Connectionを置き換える。Logical Connectionから参照しているPhysical Connectionだけが変わっているのでユーザ側からはConnectionを使い続けることができるということである。
画像引用元: github.com/awslabs/aws-mysql-jdbc#enhanced-failure-monitoring
細かいことは FailoverConnectionPlugin.javaや AuroraTopologyService.java の実装を追いかけていくとわかる。
その他の選択肢
AWS JDBC Driver for MySQL以外にもAuroraのfailoverを検知した切り替えをしたり、プロキシサーバーを動かすなどでfailover対応をするソリューション自体は存在しているので明記しておく。すでに他の方にて検証など充分に行われているのでことさらにここで深堀りはしない。
MariaDB Connector/J
MariaDB向けのJDBCドライバであるMariaDB Connector/Jのバージョン2系においてはAuroraのfailoverを検知する機能が実装されている。
これはAuroraのインスタンスに接続しグローバル変数 innodb_read_only
の変化によりAuroraのfailoverを検知し、接続を再確立しようとするもの。
なお、MariaDB Connector/Jの最新のメジャーバージョンである3系からはAuroraのfailover検知モードは削除されている。
理由は以下に引用する通りで、当面は2系をサポートし続けるようなので使うことはできるが、AWS公式に手段を提供されている状況において新規にfailover対応をしたいときの選択肢としては微妙ではないかと思われる。
driver 3.0 is a complete rewrite of the connector. Specific support for aurora has not been implemented in 3.0, since it relies on pipelining. Aurora is not compatible with pipelining. Issues for Aurora were piling up without the community proposing any PR for them and without access for us to test those modifications. (2.x version has a 5 years support).
引用元: About MariaDB Connector/J - MariaDB Knowledge Base
Amazon RDS Proxy
RDS ProxyはAuroraを含むRDSに対するプロキシを提供するサービスで、主にserverlessなアプリケーションむけのコネクションのプールやfailover時の書き込み先インスタンスの切り替えなどの機能がある。
DBクラスタでfailoverが発生したとしても、Proxyがそれを検知しDBクラスタ - Proxy間の接続は切り替えつつも、Proxy - アプリケーション間の接続はそのままになるために高速に接続を切り替えられるというもの。中身は不明だがMariaDB Connector/JのAurora failover検知よりも早いこと、JVM系の言語に限らず利用することができるのが良い点。
AWS JDBC Driver for MySQLがfailoverを検知して接続先を切り替える動作検証
ここからは、実際にAWS JDBC Driver for MySQLを利用してfailover時に接続先が切り替わることを検証してみる。
検証を行うDBクラスタ構成
役割 | Tier | host | |
---|---|---|---|
1号機 | writer | 0 | ip-10-7-2-150 |
2号機 | reader | 0 | ip-10-7-2-175 |
3号機 | reader | 5 | ip-10-7-2-43 |
エンドポイントはデフォルトで用意されているclusterエンドポイントとreaderエンドポイント以外にも作成する。
接続先 | 備考 | |
---|---|---|
clusterエンドポイント | 1号機 | 自動作成 |
read-only clusterエンドポイント | 2,3号機 | 自動作成 |
書き込み用エンドポイント | 1,2号機 | カスタムエンドポイント |
読み込み用エンドポイント | 3号機 | カスタムエンドポイント |
書き込み用はTierの高いインスタンス2台によりActire/Standby的な構成にしておき、読み取り専用エンドポイントはTierが低いものだけとしてwriteが発生しうるインスタンスへ接続が発生しないようにする。と想定した設計である。
実施する検証内容
以下のような条件下でfailover時にどのように挙動するかを、ドキュメントに記載されたエンドポイント種別ごとの初回接続先とfailover時の挙動を照らし合わせながら確認する。
実装はJVM系の言語なら何でも良いがScalaで行う。
- clusterエンドポイントに接続した状態でfailoverをしたとき
- カスタムエンドポイントに接続した状態でfailoverをしたとき
- connection poolを利用している状態でfailoverしたとき
- read-only クラスタエンドポイントに接続した状態でfailoverをしたとき
発行するクエリは固定でhostnameをサーバから取得するだけのものとする。
SELECT @@hostname;
検証-1. クラスタエンドポイントに接続した状態でfailoverをしたとき
クラスタエンドポイントに接続している場合のfailover時の挙動は
Initial connection: primary DB instance Failover behavior: connect to the new primary DB instance
なので、1号機(writer)に繋いでいる状態でfailoverすると自動的に2号機(reader)に再接続するはずである
検証用コードは以下通り。
与えられたホストに対してAWS JDBC Driver for MySQLで接続を確立し、その接続を利用して無限に接続先を確認するクエリを投げ続ける。リトライ周りは softwaremill/retry を利用している。
val host = "" val user = "" val password = "" val database = "test" val jdbcUrl = s"jdbc:mysql:aws://${host}:3306/${database}" def main(args: Array[String]): Unit = { val connection: Connection = DriverManager.getConnection(jdbcUrl, user, password) val sql = "SELECT @@hostname" while (true) { val resultF = withRetry(() => execQuery(connection, sql))(20) val result = Await.result(resultF, 1.minute) if (result.next()) { println(s"connected_host_name: ${result.getString("@@hostname")}") } } connection.close() } private def execQuery(connection: Connection, sql: String): Future[ResultSet] = { val f = Future { val statement = connection.createStatement() statement.executeQuery(sql) } f.failed.foreach { case e: SQLException => log(s"[${e.getSQLState}] ${e.getMessage}") case e => log(e.getMessage) } f } private def withRetry(f: () => Future[ResultSet])(retryCount: Int): Future[ResultSet] = { implicit val success: Success[ResultSet] = Success.always retry.Pause(retryCount, 1.second).apply(f) }
結果 : 期待通り 1号機に接続中failoverし2号機に接続先のホストが切り替わった
本JDBC固有のSQLExceptionを発生させたのちに接続先が切り替わっている。
2022-11-27T23:50:54.067688 - connected_host_name: ip-10-7-2-150 2022-11-27T23:50:54.077197 - connected_host_name: ip-10-7-2-150 2022-11-27T23:51:00.119274 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required. 2022-11-27T23:51:01.153104 - connected_host_name: ip-10-7-2-175 2022-11-27T23:51:01.160771 - connected_host_name: ip-10-7-2-175
検証-2. カスタムエンドポイントに接続した状態でfailoverをしたとき
ここでは 検証-1と同じ実装で接続先だけを変えている。以下のカスタムエンドポイントでそれぞれ実験する
- A: 書き込み用(1,2号機)
- B: 読み込み用(3号機)
カスタムエンドポイントに接続している場合は、failover時にPrimaryつまり高Tierインスタンスへ接続を切り替えるが、カスタムエンドポイントのメンバーに含まれていないのだとしてもPrimaryへ接続する可能性があると記載されているので、上記Bのパターンでも1,2号機に切り替わるかもと解釈している。
Initial connection: any DB instance in the custom DB cluster Failover behavior: connect to the primary DB instance (note that this might be outside of the custom DB cluster)
結果A: 期待通り 1号機に接続中failoverし2号機に切り替わった。検証1と同様の結果
2022-11-28T00:09:37.526918 - connected_host_name: ip-10-7-2-150 2022-11-28T00:09:37.533356 - connected_host_name: ip-10-7-2-150 2022-11-28T00:09:42.929866 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required. 2022-11-28T00:09:43.960524 - connected_host_name: ip-10-7-2-175 2022-11-28T00:09:43.967610 - connected_host_name: ip-10-7-2-175
結果B: 3号機に接続中failoverし、そのまま3号機に接続し続けた
ドキュメントに記載された挙動(Primaryへ接続を切り替える)とは異なる結果となった。理由は不明であるが、必ず同じ結果が得られるのであればむしろ良好である。ただし勝手ににPrimaryへ接続が切り替わられると厄介であるので、このようなレプリカだけを対象としたエンドポイントを作る場合は本JDBCを利用せずMySQL Connector/Jを素直に使うのが良いと思われる。
検証-3. connection poolを利用している状態でfailoverしたとき
Connection Poolを利用しているときは注意が必要である。
公式のドキュメントにも記載の通り、Connection Poolライブラリの実装次第ではJDBCドライバが投げたSQL Exceptionをキャッチし、特定のエラーの場合はコネクションを再確立しようとする。だが、このJDBCドライバを使ってAuroraに対して接続するときにはfailover時のwriterインスタンスへの接続の切り替えをJDBC側で行うために、failoverの例外時はConnection Poolライブラリ側ではコネクションをそのまま維持し続けてほしい。よってJDBCドライバがConnection Poolライブラリにfailoverのエラーを返した場合の処理を追加で実装する必要がある。
Scalaで実装する場合SlickにせよQuillにせよデフォルトのConnection PoolライブラリはHikari CPであるため、公式のドキュメントに記載されたSQLExceptionOverrideの実装を行いHikari CPの ExceptionOverrideClassName
設定に実装したクラス名を与えてあげれば良い。
https://github.com/awslabs/aws-mysql-jdbc#connection-pooling
import com.zaxxer.hikari.SQLExceptionOverride import java.sql.SQLException class HikariCPSQLException extends SQLExceptionOverride { import SQLExceptionOverride.Override private val ignoreSQLStates = Seq("08S02", "08007") override def adjudicate(sqlException: SQLException): SQLExceptionOverride.Override = { val sqlState = sqlException.getSQLState if (ignoreSQLStates.exists(sqlState.equalsIgnoreCase)) Override.DO_NOT_EVICT else Override.CONTINUE_EVICT } }
上記に加え、Connection Poolの作成とconnectionをPoolから取得するように修正する。
def main(args: Array[String]): Unit = { val connectionPool: HikariDataSource = { val _config = new HikariConfig() _config.setJdbcUrl(jdbcUrl) _config.setUsername(user) _config.setPassword(password) _config.setExceptionOverrideClassName("com.jsoizo.HikariCPSQLException") new HikariDataSource(_config) } val sql = "SELECT @@hostname" while (true) { val connection: Connection = connectionPool.getConnection val resultF = withRetry(() => execQuery(connection, sql))(20) val result = Await.result(resultF, 1.minute) if (result.next()) { log(s"connected_host_name: ${result.getString("@@hostname")}") } connection.close() } }
結果: 期待通り 1号機に接続中failoverし2号機に接続先のホストが切り替わった
検証1と同じ結果を得ることができた
2022-11-28T10:05:26.017121 - connected_host_name: ip-10-7-2-150 2022-11-28T10:05:26.024403 - connected_host_name: ip-10-7-2-150 2022-11-28T10:05:30.306909 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required. 2022-11-28T10:05:31.336693 - connected_host_name: ip-10-7-2-175 2022-11-28T10:05:31.344043 - connected_host_name: ip-10-7-2-175
検証-4. read-only クラスタエンドポイントに接続した状態でfailoverをしたとき
read-only clusterエンドポイントに繋いだ場合、failover時にはTier加味しつつレプリカに接続しようとするので、readerが2,3号機であるときにfailoverして2号機がwriterに昇格すると1号機か3号機のいずれかに接続すると思われる。ただし昇格後のPrimaryつまり2号機に接続する可能性もある模様。
Initial connection: any Aurora Replica Failover behavior: prioritize connecting to any active Aurora Replica but might connect to the primary DB instance if it provides a faster connection
結果: 期待通り 2号機に接続中failoverし3号機に切り替わった。
2022-11-28T10:24:34.566652 - connected_host_name: ip-10-7-2-175 2022-11-28T10:24:34.572563 - connected_host_name: ip-10-7-2-175 2022-11-28T10:24:34.746535 - [08S02] The active SQL connection has changed due to a connection failure. Please re-configure session state if required. 2022-11-28T10:24:35.780123 - connected_host_name: ip-10-7-1-43 2022-11-28T10:24:35.789052 - connected_host_name: ip-10-7-1-43
まとめ
AWS JDBC Driver for MySQLのfailover時の挙動について、概ね期待通りの結果を得ることができた。
現状のfailover検知によるwriterインスタンスの切り替えについてはRDS Proxyを使うことが最初の選択肢だと思われるが、平常時のプロキシのオーバーヘッドが気になる場合や、JVM系の言語を使っているならばこのJDBCを使うことで安易にfailover検知を行うことができるので良さそうだ。
githubを見る限りドキュメントも丁寧にまとまっており、プロダクション環境で使うにも困ることは無いと思われる。積極的に使っていくことをおすすめしたい。