Amazon RDS Aurora MySQL Failover と Rails Connection Pool について

Amazon RDS Aurora MySQL Failover 時に発生するダウンタイムと、 Rails Connection Pool の組み合わせで発生するアプリケーションエラーの概要、およびその解決手法について考えてみる。

Aurora MySQL Replication Strategy

Amazon RDS Aurora MySQL (以下: Aurora MySQL) でクラスターを組む場合、2つの Replication 戦略が実装されている。

1つ目が、Single-Master Replication。よく見られる Replication 手法で、単一の Node を "Writer" (a.k.a. Primary/Leader/Master etc.) に任命し、すべての Writable な操作をその Node が扱う。他の Nodes は "Reader" (a.k.a. Secondary/Follower/Slave/Replica, etc.) と呼ばれ、Writer Node から多くの場合非同期でデータを Trigger や Redo Log などの実装を用いて取り込み、Readable な操作を扱うことができる。Writable な操作は単一の Writer Node がボトルネックになるが、Read-heavy な要件であれば、Reader Nodes を増やす (= Horizontal Scaling) ことで、Workload の増加にも対応することができる。

2つ目が、Multi-Master Replication と呼ばれ、複数またはすべての Node が Writable な操作を扱うことができる。書き込まれるデータは Hash Algorithm などで決定され、他の Nodes には非同期でデータを取り込む。Multi-Master Replication は、別に銀の弾丸というわけではなく、その特性上限られた Workloads についてのみ真価を発揮する. Multi-Master が Single-Master より優れているというわけでは必ずしも無い。

NOTE: 一般的には、他には Leaderless Replication と呼ばれる Replication 戦略も存在するが、Aurora MySQL は関係ない

ただし、Single-Master には Failover という構造上避けられない欠点が存在する。Single-Master でクラスターを組んでいた場合、例えば Writer Node にハードウェア障害が発生したり、MySQL Engine Version Update のために再起動をさせたりしたいといった場合、一時的に Writer Node が存在しない瞬間が発生する。つまり、ダウンタイムが発生するのだ。より正確に言うと、他の選抜された Reader Node を Writer に昇格させればよいのだが、その間に若干のダウンタイムが発生する。

Aurora MySQL の場合、cluster endpointreader endpoint といった、クラスターの構成と疎結合に接続させるためのエンドポイントを提供しており、アプリケーション側からはいずれかのエンドポイントに接続するだけで良い。Failover が発生した時、Aurora MySQL の内部では Writer/Reader Nodes の DNS Record を切り替える ことで、透過的に扱うことができる。

Rails Connection Pool

Rails などに代表されるほとんどの(要出典)アプリケーションでは、Connection Pool のようなデータベースへの接続を再利用するための最適化が実装されている。特に Rails (ActiveRecord) の場合、一度接続したら、新しい Thread や Process が生成されるまで同じ接続を Connection Pool の中から再利用 する。これは、殆どのケースに置いてパフォーマンスの向上を期待できる最適化だ。Database に接続するたびに DNS Lookup を行う必要がなくなるからだ。

しかし、Aurora MySQL の Single-Master Cluster で Failover が発生した時に、この最適化が仇となる。Aurora MySQL 内部では、旧 Writer Node から新 Writer Node への昇格が完了し、cluster endpoint または reader endpoint が返す Node の IP Address が変更されている。しかし、旧 Writer Node への接続が Connection Pool で再利用されているので、アプリケーション側では旧 Writer Node に接続しようとしてしまうため、アプリケーションエラーが発生してしまう。

Solution

上記の問題を解決するためには、いくつか手段がある。

  1. アプリケーションをデプロイなどで restart させることで新 Writer Node に接続させる
  2. Connection Pool を無効にする
  3. Multi-Master Replication Cluster へ移行する
  4. Writer Node への接続がエラーになった時、re-connection する実装を Application Layer で実装する

まず、「1. アプリケーションをデプロイなどで restart させることで新 Writer Node に接続させる」については、Failover が発生するたびに手動でアプリケーションをデプロイする必要があるため、非現実的だろう。Failover は、Engine version update などの意図的な場合のみならず、ハードウェア障害などの予知できないケースのよっても発生するため、すべてのケースを救うことはできない。

次に、「2. Connection Pool を無効にする」については、たしかに Failover 発生時のエラーを防ぐことはできるが、ほとんどの正常なケースにおける最適化処理を捨てることになるため、総合的に考えるとパフォーマンスの Degadation に繋がることが想像できる。木を見て森を見ず、な解決策といえるだろうか。

また、「3. Multi-Master Replication Cluster へ移行する」だが、Single-Master/Multi-Master の選択というのは、Failover 時の挙動だけが判断条件ではない。ビジネス要件とアプリケーションの Workloads が適しているなら Multi-Master に移行したほうが良いだろうが、Failover 時の挙動のみが決め手となって移行判断が下ることは稀だはないだろうか。

ということで、一般的には「4. Writer Node への接続がエラーになった時、re-connection する実装を Application Layer で実装する」が主流になるだろうか。かくいう自分も、所属するチームでは、社内の日本側の SRE メンバーが開発・保守している https://github.com/winebarrel/active_record_mysql_xverify を利用している。どうやら、他には https://github.com/alfa-jpn/mysql2-aurorahttps://github.com/sonots/activerecord-refresh_connection といった選択肢もあるようだ。

winebarrel/active_record_mysql_xverify

この Gem では、ActiveRecord::ConnectionAdapters::Mysql2Adapter#active? を Override し、該当メソッドが Connection Pool 内にある接続先へのクエリがエラーになったタイミングで、@@innodb_read_only を確認することで、今接続している Node が Writer なのか Reader なのかを判別している。これは Aurora MySQL のドキュメントでも推奨 されている手法である。

したがって、私の理解が間違っていなければ、Update などの Vertical Scaling による人為的な Failover 発生時だけではなく、Scaling-in (Horizontal Scaling) をした場合の DNS Cache による旧 Writer Node への接続時のエラーも対応できるはずだ。

2021-01-23