AWS Advanced JDBC Wrapperの導入時につまずいたこと
はじめに
初めまして、ソフトウェアエンジニアリングチームの大谷です。
今回は弊社でAurora MySQL バージョン2からバージョン3へのアップデートに伴い導入したAWS Advanced JDBC Wrapperに関して、導入時に直面した問題を中心にお話しします。
AWS Advanced JDBC Wrapperは、Amazon Web Services(AWS)が提供するJava Database Connectivity(JDBC)ドライバーの拡張(ラッパー)です。このライブラリは、AWSのデータベースサービスとの接続を簡素化し、セキュリティとパフォーマンスを向上させるための様々な機能を備えています。弊社では可用性の観点からAWS謹製でAuroraと親和性のある、AWS Advanced JDBC Wrapperを導入しました。
本記事ではAWS Advanced JDBC Wrapperの特徴や導入方法を簡単に紹介した後、導入調査時や導入実装中に直面した問題とその解決策を紹介します。
この記事を読んで欲しい人
- AWS Advanced JDBC Wrapperの導入を検討している人
- AWS Advanced JDBC Wrapperの導入時に困ったことがある人
AWS Advanced JDBC Wrapperの特徴
- AWSが開発しているJDBCドライバー
- 既存の JDBC ドライバーを補完し、ドライバーの機能を拡張して、アプリケーションが Amazon Aurora などのクラスター化されたデータベースの機能を最大限に活用できる
- IAMやSecrets ManagerなどAWS認証サービスとの統合も導入されている
- MySQL JDBC ドライバーだけではなくPostgreSQLとMariaDBの JDBC ドライバーもサポートしている
- DNSキャッシュに依存する従来のJDBCドライバーよりも高速にフェイルオーバーを行うことができる
導入方法
弊社ではSpring Boot, MySQLを利用しているため、Spring BootとMySQLを利用した場合の導入方法を紹介します。
用意するもの
必須
任意
Secrets Manager Pluginを利用する場合は以下のライブラリが必要です。
Maven
<dependencies>
<dependency>
<groupId>software.amazon.jdbc</groupId>
<artifactId>aws-advanced-jdbc-wrapper</artifactId>
<version>2.3.9</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<!-- Secrets Manager Pluginを利用する場合 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>secretsmanager</artifactId>
<version>2.26.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
</dependencies>
Gradle
dependencies {
implementation 'software.amazon.jdbc:aws-advanced-jdbc-wrapper:2.3.9'
implementation 'com.mysql:mysql-connector-j:8.0.33'
// Secrets Manager Pluginを利用する場合
implementation 'software.amazon.awssdk:secretsmanager:2.26.7'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
}
AWS Advanced JDBC Wrapperのデフォルトプラグインは、failover connection plugin、aurora connection plugin、host monitoring plugin v2の3つですが、弊社ではfailover connection pluginとaurora connection pluginを利用しているため以下のような設定を行なっています。
application.properties
# ${DB_HOST}などの${}で囲まれた部分は環境変数を利用しています
spring.datasource.url=jdbc:aws-wrapper:mysql://${DB_HOST}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=software.amazon.jdbc.Driver
spring.datasource.type=org.apache.tomcat.jdbc.pool.DataSource
spring.datasource.tomcat.connectionProperties=wrapperPlugins=auroraConnectionTracker,failover;wrapperDialect=aurora-mysql;
application.yml
# ${DB_HOST}などの${}で囲まれた部分は環境変数を利用しています
spring:
datasource:
url: jdbc:aws-wrapper:mysql://${DB_HOST}
username: ${DB_USER}
password: ${DB_PASSWORD}
driverClassName: software.amazon.jdbc.Driver
type: org.apache.tomcat.jdbc.pool.DataSource
tomcat:
connectionProperties: wrapperPlugins=auroraConnectionTracker,failover;wrapperDialect=aurora-mysql;
導入時に困ったこと
datasource.urlにポート番号が含まれているとfailoverModeで設定漏れが発生する
公式ドキュメントには以下のような記述があります。(日本語訳)
デフォルト値は接続 url に依存します。Aurora読み取り専用のクラスタエンドポイントの場合、reader-or-writerに設定されます。それ以外は strict-writer です。
引用元: Failover Connection Plugin - Failover Parameters 翻訳はDeepLを使用しています
設定を行なっているサービスメソッドに対して以下のテストを行いました。
@Test
void test_init_withClusterEndpoint() throws SQLException {
initializePlugin();
plugin.initHostProvider(
"jdbc:aws-wrapper:mysql//xxx.cluster-xxx.region.rds.amazonaws.com",
mockHostListProviderService,
mockInitHostProviderFunc,
() -> mockReaderFailoverHandler,
() -> mockWriterFailoverHandler);
assertEquals("STRICT_WRITER", plugin.failoverMode.name());
}
// -> PASS
@Test
void test_init_withReadOnlyClusterEndpoint() throws SQLException {
initializePlugin();
plugin.initHostProvider(
"jdbc:aws-wrapper:mysql//xxx.cluster-ro-xxx.region.rds.amazonaws.com",
mockHostListProviderService,
mockInitHostProviderFunc,
() -> mockReaderFailoverHandler,
() -> mockWriterFailoverHandler);
assertEquals("READER_OR_WRITER", plugin.failoverMode.name());
}
// -> PASS
@Test
void test_init_withReadOnlyClusterEndpointWithPort() throws SQLException {
initializePlugin();
plugin.initHostProvider(
"jdbc:aws-wrapper:mysql//xxx.cluster-ro-xxx.region.rds.amazonaws.com:3306",
mockHostListProviderService,
mockInitHostProviderFunc,
() -> mockReaderFailoverHandler,
() -> mockWriterFailoverHandler);
assertEquals("READER_OR_WRITER", plugin.failoverMode.name());
}
// -> FAILED
// Expected :READER_OR_WRITER
// Actual :STRICT_WRITER
原因
datasource.urlの文字列比較を行なっているメソッドが以下のパターンで判定を行なっていたため、ポート番号が含まれている場合に正しく判定できないようです。ポート番号以外でもデータベース名やユーザ名、クエリパラメータなどが含まれている場合にも同様の問題が発生します。
private static final Pattern AURORA_DNS_PATTERN =
Pattern.compile(
"^(?<instance>.+)\\."
+ "(?<dns>proxy-|cluster-|cluster-ro-|cluster-custom-)?"
+ "(?<domain>[a-zA-Z0-9]+\\.(?<region>[a-zA-Z0-9\\-]+)"
+ "\\.rds\\.amazonaws\\.com)$",
Pattern.CASE_INSENSITIVE);
private static final Pattern AURORA_CLUSTER_PATTERN =
Pattern.compile(
"^(?<instance>.+)\\."
+ "(?<dns>cluster-|cluster-ro-)+"
+ "(?<domain>[a-zA-Z0-9]+\\.(?<region>[a-zA-Z0-9\\-]+)"
+ "\\.rds\\.amazonaws\\.com)$",
Pattern.CASE_INSENSITIVE);
考えれる対応策
対応策は2つあります。
- datasource.urlのポート番号を削除する
ポート番号を指定せず、デフォルトのポート番号を使用しデータベース名はWrapperのパラメータで設定することで、正しく判定できるようになります。
データベース名の指定方法は以下の通りです。
spring.datasource.hikari.database=DB_NAME
spring:
datasource:
hikari:
database: DB_NAME
- failoverModeを指定する
failover connection pluginにはfailoverModeを指定することができます。以下のように設定することで、ポート番号が含まれている場合でも正しく判定できるようになります。
spring.datasource.hikari.wrapperPlugin: failover
spring.datasource.hikari.failoverMode: READER_OR_WRITER
spring:
datasource:
hikari:
wrapperPlugin: failover
failoverMode: READER_OR_WRITER
HikariCPSQLExceptionを利用してもフェイルオーバー時にコネクションが破棄されず、hibernate.TransactionExceptionが発生する
弊社では使用していませんが、HikariCPで例外処理をオーバーライドし特定の例外時の挙動を制御することができます。
HikariCPの公式ドキュメントには以下のような記述があります。(日本語訳)
フェイルオーバープラグインはフェイルオーバー関連の例外を投げるため、HikariCPで明示的に処理する必要があります。フェイルオーバープラグインでHikariCPを使用する場合、カスタム例外オーバーライドクラスを提供する必要があります。AWS JDBC Driverは、HikariCP用のカスタム例外オーバーライドクラスを提供します。
引用元: Failover Connection Plugin - HikariCP 翻訳はDeepLを使用しています
主に、フェイルオーバー発生時に出力されるFailoverSuccessSQLExceptionやTransactionStateUnknownSQLExceptionでコネクションが破棄されることを防ぐために使用されます。
FailOverSuccessSQLExceptionとTransactionStateUnknownSQLExceptionについては公式ドキュメントに以下のように記載されています。(日本語訳)
08S02 - 通信リンク
AWS JDBC Driver が FailoverSuccessSQLException をスローする場合、元の接続がトランザクションの外で失敗し、AWS JDBC Driver がクラスタ内の別の利用可能なインスタンスにフェイルオーバーに成功します。しかし、最初の接続のセッション状態の構成はすべて失われています。
このシナリオでは、次のようにします:
- 元の接続を再利用して再設定する(例えば、セッション状態を元の接続と同じに再設定する)。
- 接続が失敗したときに実行されたクエリを繰り返し、必要な作業を続行します。
08007 - トランザクションの解決方法が不明
AWS JDBC Driver が TransactionStateUnknownSQLException をスローする場合、元の接続がトランザクション内で失敗しています。このシナリオでは、JDBCラッパーは最初にトランザクションのロールバックを試み、クラスタ内の別の利用可能なインスタンスにフェイルオーバーします。JDBCラッパーが問題を認識した時点で最初の接続が切断されている可能性があるため、ロールバックが失敗する可能性があることに注意してください。また、初期接続のセッション状態の構成も失われることに注意してください。
このシナリオでは、次のようにします:
- 元の接続を再利用し再設定する(例:セッション状態を元の接続と同じに再設定する)。
- トランザクションを再起動し、接続が失敗する前にトランザクション中に実行されたすべてのクエリを繰り返します。
- 接続が失敗したときに実行されたクエリを繰り返し、必要な作業を継続する。
引用元: Failover Connection Plugin - Failover Exception Codes 翻訳はDeepLを使用しています
導入方法
まずは、ExceptionOverrideClassNameの導入方法を紹介します。
ここではAWS Advanced JDBC Wrapperに標準で用意されているHikariCPSQLExceptionを利用します。
spring.datasource.hikari.exceptionOverrideClassName=software.amazon.jdbc.util.HikariCPSQLException
spring:
datasource:
hikari:
exceptionOverrideClassName: software.amazon.jdbc.util.HikariCPSQLException
実際に使った際の挙動
正常にフェイルオーバーが終了した際に、以下のようなログが出力されます。期待する挙動は、HikariCPSQLExceptionでこの例外をキャッチし処理をオーバーライドすることでコネクションが閉じられるのを防ぐことです。
ERROR 84364 --- [nio-8082-exec-3] s.a.j.p.f.FailoverConnectionPlugin : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
実際にやってみたところ、上記のエラーでコネクションが閉じられることはありませんでしたが、以下のようにhibernate.TransactionExceptionでServlet.service()が例外をスローしました。
ERROR 84364 --- [nio-8082-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.TransactionException: JDBC begin transaction failed: ] with root cause
いくつか条件を変更してリクエストを送信してみましたが、0.5秒より短い間隔でリクエストを送信した際に例外が発生しました。
for i in {1..100}; do curl -X GET http://localhost:8080/get/time/now ; done
# -> 500 internal server error: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [
# Request processing failed; nested exception is org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.TransactionException: JDBC begin transaction failed:
# ] with root cause
for i in {1..100}; do curl -X GET http://localhost:8080/get/time/now ; sleep 0.1; done
# -> 500 internal server error: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [
# Request processing failed; nested exception is org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.TransactionException: JDBC begin transaction failed:
# ] with root cause
for i in {1..100}; do curl -X GET http://localhost:8080/get/time/now ; sleep 0.5; done
# -> 200 ok
原因
HikariCPの公式ドキュメントのイシューに以下のような記述があります。(日本語訳)
HikariCPのデフォルトでは、過去500ms以内に使用された接続は有効(使用されたばかり)とみなされ、検証がスキップされるようにパフォーマンスが最適化されています。これをバイパスウィンドウと呼びます。
ユーザーは借入れのたびにバリデーションを行いたいと考えるが、これには2つの落とし穴がある。第一に、そうすることでパフォーマンスが半減し、サーバーの負荷も大幅に増加します。
第二に、これは実際には何の追加保証も提供しない。典型的なレースコンディションである。500ms前には有効だった接続が、今は無効になっているのと同じように、500msの間にデータベースが失敗したためです。理論的な検証が完了するとすぐに、ユーザーのクエリが実行される前にデータベースが失敗し、同様のエラーが発生する可能性があります。
基本的に、すべての接続を検証しても、検証後にクエリ実行前にデータベースが失敗する可能性があるため、保証はできません。
それでもすべての接続を検証したい場合は、参照されるシステム・プロパティをゼロ(0)に設定することができます。引用元: Database failover - closed connections returned from pool #1296 翻訳はDeepLを使用しています
リクエストの送信間隔が0.5秒(500ミリ秒)より短い場合、HikariCPのバイパスウィンドウにより、コネクションの検証がスキップされるため、コネクションが破棄されずに再利用されてしまい、hibernate.TransactionExceptionが発生していると考えられます。
検証
上記の仮説のもと以下のような設定を行い、リクエストの送信間隔を0.5秒未満に設定した場合、HikariCPのバイパスウィンドウにより、コネクションの検証がスキップされることを確認しました。
一方で、バイパスウィンドウを無効(0ms)にすることで、リクエストの送信間隔に関係なくコネクションの検証が行われることを確認しました。
# システムプロパティでバイパスウィンドウを500msに設定し、リクエストの送信間隔を0.3秒に設定
for i in {1..100}; do curl -X GET http://localhost:8080/get/time/now ; sleep 0.3; done
# -> 500 internal server error: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [
# Request processing failed; nested exception is org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.TransactionException: JDBC begin transaction failed:
# ] with root cause
# システムプロパティでバイパスウィンドウを0msに設定し、リクエストの送信間隔を0.3秒に設定
for i in {1..100}; do curl -X GET http://localhost:8080/get/time/now ; sleep 0.3; done
# -> 200 ok
バイパスウィンドウの設定は以下のように行います。
-Dcom.zaxxer.hikari.aliveBypassWindowMs=500
わかったこと
フェイルオーバー発生時、リクエストの送信間隔がHikariCPのバイパスウィンドウの設定時間より短い場合、コネクションへの検証がスキップされ障害の発生しているコネクションに対してクエリが実行されるため、hibernate.TransactionExceptionが発生することがわかりました。
まとめ
今回は、AWS Advanced JDBC Wrapperの導入時に躓いた仕様についてまとめました。特にデータソースの設定に関しては公式ドキュメントのサンプル通りの場合に陥りやすい問題なので導入前に確認する必要があると感じます。まだまだ、挙動を完璧に把握できていない部分も多いので、引き続き調査を進めていきたいと思います。
この記事が皆様のお役に立てれば幸いです。
Discussion