🐳

aws-advanced-go-wrapperでAurora高速フェイルオーバーを検証した結果、採用を見送った話

に公開

概要

自分が開発しているサービスでは、aws Auroraの可用性向上(フェイルオーバー時のダウンタイム最小化)を主目的として、 Amazon RDS Proxy を採用しています。RDS Proxyの「高速フェイルオーバー」機能により、フェイルオーバー時のDBインスタンスの切り替え時にもアプリケーション側への影響を最小限に抑えています。

そんな中、awsからGo言語向けの新しいドライバーラッパーである aws-advanced-go-wrapper がリリースされました(Java版のaws-advanced-jdbc-wrapperは以前からありましたが、Go版は2025年登場)。

aws-advanced-go-wrapperには高速フェイルオーバー機能が実装されているので、これを利用すれば「RDS Proxyを廃止し、ドライバーレベルで高速フェイルオーバーが実現できるのでは?」という期待が生まれました。RDS Proxyを廃止できれば、その分のコストを削減できることに加え、RDS Proxy自体の障害を回避できるため障害点の減少を実現できることが魅力的でした。

そこで、本番投入を見据えて技術検証を行いました。結論から言うと、「aws-advanced-go-wrapperは開発途上であるため現時点(2025年11月)での採用は見送り」 という判断に至りました。

本記事では、なぜ採用を見送ったのか、Go特有のアーキテクチャ上の課題、そして検証で見えたパフォーマンス上のトレードオフについて共有します。

aws-advanced-go-wrapper とは?

aws-advanced-go-wrapperは、Goの標準的な database/sql ドライバーをラップし、AWS Aurora等のデータベースに対する機能を拡張するライブラリです。

主な特徴は「プラグイン」による機能拡張です。代表的なプラグインを以下に紹介します。

  • Failover Plugin: 高速フェイルオーバーを実現。物理的なDBコネクションを抽象化し、DNSキャッシュの問題を回避して論理的な接続を維持する。
  • Host Monitoring Plugin (EFM): DBインスタンスの状態を監視し、異常時に即座に切り離す(Fail Fast)。
  • Read Write Splitting Plugin: 更新系・参照系クエリをWriter/Readerインスタンスに自動で振り分ける。

参考:プラグイン一覧

デフォルトでは「Failover Plugin」と「Host Monitoring Plugin (EFM)」が有効化されています。

1つ目の高速フェイルオーバープラグインは文字通り高速フェイルオーバーを実現します。DBドライバー側がDB側の物理コネクションを直に扱うのではなく、aws-advanced-go-wrapper側が用意している抽象化された論理DBコネクションを介してDBコネクションをハンドリングすることで、フェイルオーバー時に発生する古いDNSキャッシュを持ち続けるという問題を回避します。

2つ目のEFMプラグインについては、DBインスタンスの状態をモニタリングすることで、インスタンスに異常が発生した際にそれをアプリケーション側が持っているコネクションから素早く切り離すという機能です。これはいわゆる"fail fast"を実現するためのもので、アプリケーションの処理中にDBインスタンス側の異常が起因して処理が無駄に長引くことを防止してくれる(ex: timeout errorなどタイムアウトまで待つ必要がなくなる)ものだが、自サービスでは本機能は特に必要としておらず、EFMプラグインを入れることによってアプリケーション側にオーバーヘッドが発生するため現時点では本プラグインは利用しない形としました。実際、このEFMプラグインを入れると性能劣化やフェイルオーバー時のダウンタイム増加が発生することが検証で判明しています。

アプリケーションの前提

今回検証対象となるアプリケーションは以下の前提を持っています。

  • 技術スタック:Go(echo)、Aurora MySQL
  • トラフック:2,000 rps、11,000 QPS
  • トラフィック全体の99.9%以上が参照系の処理
  • 求められる可用性:平常時のダウンタイムは基本ゼロ、フェイルオーバー時のダウンタイムは10s程度まで許容
  • 利用しているDB(Aurora)の構成
    • ライターインスタンス1台
    • リーダーインスタンス2台

aws-advanced-go-wrapperの利用方法

aws-advanced-go-wrapperを利用だけなら単にDBドライバーをaws-advanced-go-wrapper専用のものに置き換えるだけでよいですが、うまく扱うためのアプリケーション側のアーキテクチャの組み方としてはいくつかあると考えています。クラスターエンドポイントの管理方法とラッパー特有のエラーハンドリング方法に焦点を当てて以下で説明します。

auroraクラスターエンドポイントの管理方法について

アプリケーション側でDBクラスターエンドポイントを管理する方法としては以下の2通りの方法があります。

①writerのクラスターエンドポイントとreaderのクラスターエンドポイントの2つを別個のコネクションプールに紐づけて2つのエンドポイントをアプリケーション側で管理する(自サービスではこのパターンを採用)

1つ目は、writerのクラスターエンドポイントとreaderのクラスターエンドポイントの2つを別個のコネクションプールに紐づけて2つのエンドポイントを管理する(2つのsql.DBをそれぞれwriterとreaderで管理する)という方法です。

type DB struct {
	Read  *sqlx.DB // readerクラスターエンドポイントを管理
	Write *sqlx.DB // writerクラスターエンドポイントを管理
}

このパターンのメリット
*(後述しますが)更新・参照クエリをそれぞれwriterとreaderに振り分ける際にRead Write Splittingプラグインを使った複雑な実装管理をしなくて済む

  • Read Write Splittingプラグインを使った際の処理上のオーバーヘッドがない
  • 実装が簡単である

このパターンのデメリット

  • writerのクラスターエンドポイントとreaderのクラスターエンドポイントの2つ分のコネクション管理をそれぞれ独立したWrapperで2重管理することになるため、アプリケーション側でのリソース消費が大きくなる
  • 2つ分のWrapperがそれぞれ協調してオーケストレーションする必要があるため、フェイルオーバーが発生した際にダウンタイムが少し長くなる

②writerのクラスターエンドポイントの1つだけをコネクションプールに紐づけて1つのエンドポイントだけを管理する(aws-advanced-go-wrapperだとこれも可能)

2つ目は、writerのクラスターエンドポイントの1つだけをコネクションプールに紐づけて1つのエンドポイントだけを管理する(1つのsql.DBをhostで管理する)という方法です。

type DB struct {
	Host  *sqlx.DB // writerクラスターエンドポイントを管理
}

aws-advanced-go-wrapperでは、ラッパー側の機能でクラスターのトポロジ情報(どのインスタンスがwriter/readerかを判別する情報)をaurora側に都度問い合わせて内部的に管理しているため、writerのクラスターエンドポイントを与えるだけでもWrapper側がトポロジ情報からreaderクラスターエンドポイントを把握して疎通することが可能な設計になっています。

ただし、このケースで更新処理と参照処理をwriterとreaderに振り分けるためには、Read Write Splittingプラグインを導入する必要があります。

ただ、このプラグインを入れれば自動で処理を振り分けてくれるということではなく、Go実装内のContextに対してその処理がRead処理かWrite処理かを示す値を埋め込む必要があります(デフォルトではWriterに処理が向いている)。

Readerに振り分けたい場合はクエリを発行する際にContextにawsctx.SetReadOnly: trueという情報を都度埋め込んで管理する必要があります(正確にいうと、sql.DB, sql.Conn, sql.Tx等のどのオブジェクトからクエリを発行するかによって実現方法は多少異なる)。

【sql.DBからクエリを発行するケース】

readOnlyCtx := context.WithValue(context.Background(), awsctx.SetReadOnly, true)
writeCtx := context.WithValue(context.Background(), awsctx.SetReadOnly, false)

dsn := "host=myhost user=myuser password=mypassword port=5432 plugins=readWriteSplitting"
db, _ := sql.Open("awssql-mysql", dsn)

db.ExecContext(readOnlyCtx, "SELECT 1") 
db.QueryContext(writeCtx, "INSERT INTO ...")

【sql.Txからクエリを発行するケース】

readOnlyCtx := context.WithValue(context.Background(), awsctx.SetReadOnly, true)

db, _ := sql.Open("awssql-mysql", "dsn")
tx, _ := db.BeginTx(readOnlyCtx, nil) // Will connect to reader instance
result, err := conn.ExecContext(readOnlyCtx, "SELECT 1")
tx.Commit()

Goでは参照系のクエリを発行する場合はsql.DBからクエリを発行する形が主流となっており、これがRead Write Splittingプラグインとものすごく相性が悪いため、GoでRead Write Splittingプラグインを利用するのは難しいと考えています。実際、公式docでも**「It is not recommended to use features of the read/write plugin with sql.DB」**と明記されています。ここらへんの挙動を理解するためにはsql.DB, sql.Conn, sql.Txの違いを理解する必要があるので以下で簡単に説明します。

・sql.DB
例えるならこれはコネクションプールである。sql.DBからクエリを発行する場合、コネクションプール内にあるコネクションを1つ取り出して(新規作成のケースもある)、それを利用する

・sql.Conn
コネクションプール内の特定の1つのコネクションをである。sql.Connからクエリを発行する場合はシンプルにこのコネクションを利用する

・sql.Tx
コネクションプール内の特定の1つのコネクションから作成されたトランザクションオブジェクトである。実質的に1つのsql.Connに紐づいているため、sql.Txからクエリを発行する場合はシンプルにこのコネクションを利用する

上記を踏まえると、sql.DBからクエリを発行する場合はコネクションプール内にある無数のコネクションからランダムでコネクションを取得して利用するため、クエリを発行するごとにContextにawsctx.SetReadOnly: trueという情報を都度埋め込んで管理する必要があります。

GoではContextは不変であるため、これは都度Contextを作り直すということであり、GoではContextの作成コストがかなり大きいため許容できないオーバーヘッドとなります。実際、自サービスで検証したところ、元の設計では4,000 rpsが出ていた出力で2,000 rps程度しか出ておらず性能劣化が確認できました。Javaだと@Transactionアノテーションで容易にwriterとreaderの振り分けが管理できたりするみたいですが、Goでは設計上Read Write Splittingプラグインとは相性が悪いというのが結論になりました。

sql.DBで問題になるのであれば、参照系クエリに対してもsql.Conn, sql.Txを使えば問題は解決しそうですが、参照系がリクエストの多数を占める自サービスではこれだと数多くのコネクションを占有してしまい性能劣化につながるためこのアプローチは採用できませんでした。

まとめると、「writerのクラスターエンドポイントの1つだけをコネクションプールに紐づけて1つのエンドポイントだけを管理するパターン」のメリット/デメリットは以下の通りです。

メリット

  • writerのクラスターエンドポイントとreaderのクラスターエンドポイントの2分のコネクション管理をそれぞれ独立したWrapperで2重管理せずに済むため、アプリケーション側でのリソース消費が小さい
  • 2つ分のラッパーのオーケストレーション管理をする必要がなくなるため、フェイルオーバーが発生した際にダウンタイムが短くなる

デメリット

  • 更新・参照クエリをそれぞれwriterとreaderに振り分ける際にRead Write Splittingプラグインを使った複雑な実装管理をする必要がある
  • 実装が複雑でミスしやすい
  • 性能劣化が発生する

参照処理が大半を占める自サービスではRead Write Splittingプラグイン利用時の性能劣化を許容できなかったため、「writerのクラスターエンドポイントとreaderのクラスターエンドポイントの2つを別個のコネクションプールに紐づけて2つのエンドポイントをアプリケーション側で管理する」パターンを採用しました。

フェイルオーバー発生時のRetry処理について

前述したように、aws-advanced-go-wrapperではDBドライバー側がDB側の物理コネクションを直に扱うのではなく、aws-advanced-go-wrapper側が用意している抽象化された論理DBコネクションを介してDBコネクションをハンドリングすることで高速フェイルオーバーを実現しています。ただし、これをうまく扱うためにはアプリケーション実装側でこれをうまくハンドリングする必要があります(全て自動でやってくれるわけではない)。

フェイルーバー発生時には、ラッパー側から以下のようなエラーが返ってきます。

・FailoverFailedError

When the AWS Advanced Go Wrapper returns a FailoverFailedError, the original connection has failed, and the AWS Advanced Go Wrapper tried to failover to a new instance, but was unable to. There are various reasons this may happen: no hosts were available, a network failure occurred, and so on. In this scenario, please wait until the server is up or other problems are solved. (Error will be returned.)

これはラッパー側でフェイルーバー発生時のコネクションの切り替えに失敗したというもので、retryしても成功の見込みがないためアプリケーション処理ではエラーとして返す必要があります。

・FailoverSuccessError

When the AWS Advanced Go Wrapper returns a FailoverSuccessError, the original connection has failed while outside a transaction, and the AWS Advanced Go Wrapper successfully failed over to another available instance in the cluster. However, any session state configuration of the initial connection is now lost. In this scenario, you should:
Reuse and reconfigure the original connection (e.g., reconfigure session state to be the same as the original connection).
Repeat that query that was executed when the connection failed, and continue work as desired.

これはラッパー側でフェイルーバー発生時のコネクションの切り替えに成功したというもので、retryすれば正常に処理できるため、アプリケーション処理上でこのエラーの時はretryする必要があります。

・TransactionResolutionUnknownError

When the AWS Advanced Go Wrapper returns a TransactionResolutionUnknownError, the original connection has failed within a transaction. In this scenario, the Go Wrapper first attempts to rollback the transaction and then fails over to another available instance in the cluster. Note that the rollback might be unsuccessful as the initial connection may be broken at the time that the Go Wrapper recognizes the problem. Note also that any session state configuration of the initial connection is now lost. In this scenario, you should:
Reuse and reconfigure the original connection (e.g: reconfigure session state to be the same as the original connection).
Restart the transaction and repeat all queries which were executed during the transaction before the connection failed.
Repeat that query which was executed when the connection failed and continue work as desired.

これはラッパー側でフェイルーバー発生時のコネクションの切り替えに成功したが、その時のDBコネクション上で発行されていたトランザクション情報が失われているというもので、再度トランザクションを取り直した上でretryすれば正常に処理できるため、アプリケーション処理上でこのエラーの時はretryする必要があります。

つまり、アプリケーション処理側でラッパーが返すFailoverSuccessError or TransactionResolutionUnknownErrorをうまくハンドリングしてretryするような機構を入れ込む必要があります。

ただし、ここには大きな制約があります。それは、高速フェイルオーバー機能が実現するのはフェイルオーバー時に処理の対象となったコネクションに対してであって、直後に新たに作成するコネクションやidle状態になっていた既存のコネクションについてはその張り替えが行われないというものです。

したがって、前述したようにsql.DBからクエリを発行する参照クエリのようなものの場合は特定のコネクションをつかみ続けることができないため、仮にうまくハンドリングしてretryしたとしてもエラーになる可能性が高いです(次のクエリでは別のコネクションを利用するため)。

sql.DBからクエリを発行する場合はこの高速フェイルオーバーの機構にうまく乗れないという点を認識する必要があります。

高速フェイルオーバー機能の検証

ここまで長々とaws-advanced-go-wrapperを利用したアーキテクチャについて記載してきましたが、自サービスのアプリケーション上で実際に高速フェイルーバーが実現できるかどうか検証してみました。

検証環境と目標

  • 比較対象
    • 標準mysqlドライバー(Rds Proxyなし): ダウンタイム 60s程度
    • RDS Proxy利用(現状の構成): ダウンタイム 約10s程度
  • 目標
    • RDS Proxyなしで、aws-advanced-go-wrapperの機能だけでダウンタイムを 10s程度 に抑えること。
  • 負荷条件
    • 約5,000 〜 6,000 rps のリクエスト負荷をかけた状態でフェイルオーバーを発生させる。

検証にあたってはベンチマークが必要になるため、①Rds Proxyとaws-advanced-go-wrapperがない標準mysqlドライバーだけのケースと、②RDS Proxyを利用した現在の設計のケースでも同様の条件でフェイルオーバー試験を実施してデータを取りました。

上記記載の結果を見ればわかる通り、標準mysqlドライバーでは60s以上のダウンタイムが発生してしまっていますが、 RDS Proxy利用すれば10s程度までダウンタイムが減少していることを確認できました。今回はRds Proxyをaws-advanced-go-wrapperで代替することが目的であるため、aws-advanced-go-wrapperを用いた場合に10s程度までダウンタイムを抑えることができればよいことになります。

検証結果

検証1回目:デフォルト設定

  • フェイルオーバープラグインのみを導入した状態
  • 結果: ダウンタイム: 26s - 50s。APIエラー数:数千件。また、更新系APIの99%ileレイテンシが10倍程度と大幅に悪化していた。
  • 所感: 標準ドライバーよりは短いが、目標には届かず。更新系クエリがReaderに飛んでしまうエラーが多発していた。

1回目の検証ではフェイルオーバープラグインのみを導入した状態(ラッパー特有のハンドリングや設定値チューニングなし)で試験を実施しました。複数回同じ条件で繰り返し試験をしたところ、フィルオーバー時のダウンタイムは26s - 50sと試験回によって異なりました。アプリケーション側のエラーログでは更新系クエリがReaderに飛んでしまうといったものが多発していました。フェイルオーバーでは元々writerだったインスタンスがreaderに入れ替わるため、フェイルオーバー時の接続先切り替えがうまくいっていない様子が伺えました。

検証2回目:リトライ機構の導入

  • アプリケーション側で FailoverSuccessError をハンドリングしてリトライを実装。
  • 結果: ダウンタイム: 22s - 50s。APIエラー数:数千件。また、更新系APIの99%ileレイテンシが10倍程度と大幅に悪化していた。
  • 所感: リトライを入れただけではダウンタイム自体は縮まらない。

2回目の検証ではラッパー特有のハンドリング実装を行なった状態で試験を実施しました。前述した通り、アプリケーション処理側でラッパーが返すFailoverSuccessErrorとTransactionResolutionUnknownErrorをうまくハンドリングしてretryするような機構を入れました。複数回同じ条件で繰り返し試験をしたところ、フィルオーバー時のダウンタイムは22s - 50sと試験回によって異なりました。アプリケーション側のエラーログでは更新系クエリがReaderに飛んでしまうといったものが1回目の試験と同様多発していました。また、リトライ機構を実行する前とフェイルーバー時のダウンタイムは変わっていませんでした。ただし、retryされた処理では成功していることを確認できました。

検証3回目:トポロジ更新頻度のチューニング

  • aws-advanced-go-wrapperにあるclusterTopologyRefreshRateMsの設定値を変更
  • 結果: ダウンタイム: 5s - 12s(設定値の水準次第)。APIエラー数:数十件。また、更新系APIの99%ileレイテンシが10倍程度と大幅に悪化していた。
  • 所感: clusterTopologyRefreshRateMsを短くすればするほどダウンタイムが減少する。

3回目の試験では、aws-advanced-go-wrapperにあるclusterTopologyRefreshRateMs という設定値に着目しました。これはラッパーが「どのホストがWriterでどれがReaderか」というクラスターのトポロジ情報を確認するポーリング間隔です。デフォルトは30秒です。

設定値をデフォルト値である①30s、②5s、③2sの3パターンで実施しました。

結果は以下の通りです

設定値 ダウンタイム 評価
30s (Default) 26s - 50s 遅い
5s 約12s RDS Proxyと同等
2s 約5s RDS Proxyより速い

clusterTopologyRefreshRateMsを短くチューニングすることで、フェイルオーバー時のダウンタイムは劇的に短縮され、APIエラー数も激減(参照系エラーでは0件)しました。

検証4回目:APIトラフィック量を300 RPSまで減少させたバージョン

  • これまでは5,000 〜 6,000 rpsの負荷でやっていましたが、300 RPS程度まで減少させた。clusterTopologyRefreshRateMsのチューニングはなし。
  • 結果: ダウンタイム: 2s。APIエラー数:1件。また、更新系APIの99%ileレイテンシが10倍程度と大幅に悪化していた。
  • 所感: トラフィックの量が減るとclusterTopologyRefreshRateMsをチューニングしなくてもダウンタイムはかなり短くなる。

4回目の試験では、これまでは5,000 〜 6,000 rpsの負荷でやっていたものを300 RPS程度まで減少させました。結果、clusterTopologyRefreshRateMsをチューニングしなくてもダウンタイムはかなり短くなりました。

検証結果まとめ

特に何のチューニングもしない状態では、5,000 〜 6,000 rpsといった高トラフィック環境下においてはフェイルオーバー時のダウンタイムは30s以上となっておりとても高速フェイルオーバーを実現している状態とは言えませんでした。300 rpsにまで減少させるともダウンタイムはかなり短くなったことから、トラフィック量によってダウンタイムの時間は変動しそうです。

また、clusterTopologyRefreshRateMsを短くすることで、ラッパー側がDBクラスターのトポロジ情報をポーリングする間隔が短くなり、結果としてフェイルオーバー時にもwriter/readerインスタンスの切り替えに素早く追随でき、ダウンタイムが減少していると推察されます。ただし、このチューニングは諸刃の剣となります。ラッパー側がDBクラスターのトポロジ情報をポーリングする間隔が短くなるということは、それだけアプリケーション側にかかるオーバーヘッドが大きくなるからです。フェイルオーバー自体が年に1回発生するかしないか程度の頻度ではあるので、こんなレアケースのためだけに備えて常時アプリケーション側に負担を強いるのには合理性はありません。自サービスは業界でもトップレベルの可用性を要求されていますが、それでもダウンタイムの減少のためだけにこのオーバーヘッドを許容することは難しいです。

加えて、aws-advanced-go-wrapperを導入した場合、チューニングの要否に関わらず更新系APIの99%ileレイテンシが10倍程度と大幅に悪化するという事象も観測されました。いくら自サービスが参照系メインのものだとはいえ、これほどのまでのレイテンシ悪化は許容できません。

結論: 採用は見送り

設定値チューニングを前提とすればダウンタイムの短縮には成功しましたが、aws-advanced-go-wrapperを導入した場合、チューニングの要否に関わらず更新系APIの99%ileレイテンシが10倍程度と大幅に悪化する事象が再現したため、パフォーマンス要件的に導入は難しいという判断になりました。clusterTopologyRefreshRateMsのチューニングもある種の裏技的なもので、フェイルオーバーの頻度を考えるとこのチューニングに妥当性はないでしょう。

検証の結果判明した問題点を改めて以下で整理します。

1. レイテンシの大幅な悪化

  • 更新系APIの99%ileレイテンシ
    • RDS Proxy環境: 100ms
    • Go Wrapper環境: 1,000ms (約10倍の悪化)

更新系APIのレイテンシがちょうど60sの間隔で悪化する事象が繰り返されていました。秒単位一致しての間隔だったので、aws-advanced-go-wrapper側で何らかの処理が定期的に走っていたのではないかと推察されますが、原因の特定までには至っていません。

2. アプリケーションの実装がaws-advanced-go-wrapperに依存してしまう

aws-advanced-go-wrapperを利用する場合、アプリケーション処理側でラッパーが返すFailoverSuccessErrorやTransactionResolutionUnknownErrorをうまくハンドリングしてretryするような機構を入れ込む必要があります。また、Read Write Splittingプラグインを使った場合にはContextに都度参照/更新処理を示すデータを入れ込む必要もあります。このように、アプリケーション実装側でaws-advanced-go-wrapper特有の処理を記述しなければならない箇所があるため、DDDやクリーンアーキテクチャが提唱するフレームワーク非依存の原則からは遠ざかるものになってしまいます。

3. 機能がまだ発展途上である(枯れていない)

検証中に遭遇した「フェイルオーバー後にトポロジ更新がうまくいかず、更新クエリがReaderに飛ぶ」という問題ですが、検証時点でまさに開発中の Aurora connection tracker pluginPR #272)などが解決策になりそうでした。

これは、トポロジー情報のポーリングに頼らず、フェイルオーバー発生時に能動的に全コネクションのトポロジ情報を更新しに行くようなアプローチのようです。

これを利用できればポーリング頻度を常時上げなくてもフェイルーバー発生時にのみ全コネクションのトポロジ情報を更新できるので、チューニングによるオーバーヘッドなしでダウンタイムの減少が期待できそうです。

しかし、これは裏を返せば 「まだ機能が出揃っておらず、安定していない」 ということです。
高い信頼性が求められるサービスにおいて、頻繁にアップデートや仕様変更が入るライブラリを今すぐ導入するのはリスクが高いと判断しました。

今後の展望

Java版のaws-advanced-jdbc-wrapperの実績を考えれば、Go版のaws-advanced-go-wrapperも今後成熟する可能性はありそうです。
特に、現在開発中のプラグインによって「ポーリングによるオーバーヘッドなしに、確実なトポロジ更新」が実現されれば、RDS Proxyを置き換える有力な選択肢になると考えています。

今回は導入を見送りましたが、引き続きアップデートをウォッチしていきたいと思います。

Discussion