/var/log/jsoizo

メモ帳 技術とか趣味とか

AWS JDBC Driver for MySQLのfailover機能に関する動作検証

これは スタンバイアドベントカレンダーの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 MySQLAWSが提供しているAuroraのfailover検知機能を持ったMySQLJDBCドライバ
  • Auroraがfailoverしてwirterインスタンスが切り替わったときにJDBCで勝手に接続先インスタンスを切り替えてくれるのでアプリケーションは書き込み先を意識する必要がなくなる
  • Connection Poolを利用する場合はConnection Pool側でfailoverエラー時の再接続を回避する必要あり
  • 意図せずwriterインスタンスに昇格してしまう可能性もあるのでリードレプリカのみ利用したい場合は利用しないが吉

AWS JDBC Driver for MySQLとは

AWSが提供している、Aurora for MySQLのfailoverを検知し再接続をする機能を持ったMySQLJDBCドライバ。
本件ではfailoverの機能だけを検証するがその他にもAurora for MySQLを利用するにあたって便利なIAM接続とかSSMで管理しているクレデンシャルの読み込みなどの機能を有している。

https://github.com/awslabs/aws-mysql-jdbc#amazon-web-services-aws-jdbc-driver-for-mysql

awslabs.github.io

どのようにfailoverが動いているのか?

MySQL Connector/Jのプラグインとして実装されており、以下の図の通りに動作する。 JDBCドライバ内にLogical(論理) ConnectionとPhysical(物理) Connectionを保持し、ユーザ側にはLogical Connectionを見せている。Failover Processがクラスタのfailoverを検知し、検知したら新しい接続先へのConnectionを張りPhysical Connectionを置き換える。Logical Connectionから参照しているPhysical Connectionだけが変わっているのでユーザ側からはConnectionを使い続けることができるということである。

img 画像引用元: github.com/awslabs/aws-mysql-jdbc#enhanced-failure-monitoring

細かいことは FailoverConnectionPlugin.javaAuroraTopologyService.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を検知し、接続を再確立しようとするもの。

dev.classmethod.jp

なお、MariaDB Connector/Jの最新のメジャーバージョンである3系からはAuroraのfailover検知モードは削除されている。

mariadb.com

理由は以下に引用する通りで、当面は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時の書き込み先インスタンスの切り替えなどの機能がある。

aws.amazon.com

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クラスタ構成

1つのクラスタに以下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で行う。

  1. clusterエンドポイントに接続した状態でfailoverをしたとき
  2. カスタムエンドポイントに接続した状態でfailoverをしたとき
  3. connection poolを利用している状態でfailoverしたとき
  4. 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を見る限りドキュメントも丁寧にまとまっており、プロダクション環境で使うにも困ることは無いと思われる。積極的に使っていくことをおすすめしたい。

リンク

Amazon Web Services (AWS) JDBC Driver for MySQL

GitHub - awslabs/aws-mysql-jdbc: The Amazon Web Services (AWS) JDBC Driver for MySQL is a driver that enables applications to take full advantage of the features of clustered MySQL databases.

GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.