TypeORMがAurora(MySQL)のリーダーエンドポイントに再接続してくれなかったので対策してみた

TypeORMのドライバがmysqlかつオプションにreplicationを指定している場合は要注意
2023.05.17

CX事業本部@大阪の岩田です。

現在自分が関わっているシステムではフレームワークにNestJSを、ORMにはTypeORMを採用しています。DBはAuroraのMySQL互換を利用しており、Auroraのリードレプリカを有効活用できるようTypeORMのデータソースでReplicationを指定しています。データソースの指定は以下のようなイメージです。

{
  type: 'mysql',
  replication: {
    master: {
      host: 'Auroraのクラスターエンドポイント',
      ...略
    },
    slaves: [
      {
       host: 'Auroraのリーダーエンドポイント',
       ...略
      },
    ],
  },
}

先日気づいたのですが、上記のように

  • TypeORMのドライバがmysql
  • replicationを指定している

という構成だと、replicationで指定したDBとの接続が失われた場合にTypeORMが自動的にDBに再接続してくれないようです。この挙動はAuroraのフェイルオーバーが発生した場合などに問題になりそうなので、調査・対策してみました。

ライブラリのバージョン

今回利用したライブラリのバージョンは以下の通りです

  • TypeORM: 0.3.9
  • Node MySQL 2: 2.3.3
  • NestJS: 9.4.0

問題に気付くまで

元々問題に気づいたのは以下のような経緯です

  • 開発環境のコストを削減するためにopswitchでAuroraの自動停止を設定
    • 平日の10:00 ~ 22:00だけ起動するように設定
  • Auroraに接続するNestJSのアプリは夜間、早朝も起動しっぱなし
    • ※本当はAuroraと合わせて自動停止した方が良いが未対応
  • 朝の9:30頃にNestJSのアプリにアクセスしたところPool does Not exists.のエラーメッセージと共に500エラーが返却される
    • Auroraの自動停止が設定されたことを思い出す
  • 10:30頃に再度NestJSのアプリにアクセス。Auroraは10:00に自動起動済みなので、正常にアクセスできるのが期待値でしたが、相変わらずPool does Not exists.のエラーが発生。念の為Auroraの状況を確認するもAuroraは正常に稼働している

もしかしてTypeORMはDBとの接続が失われた際に再接続してくれないのか?!と疑ってGitHubのissueを漁ったところ、以下のissueが見つかりました。

このissueの最新のコメント(2023/5/16時点)に以下のコメントがあり、今回遭遇した事象と全く同じ事象に悩まされている方がいるようです

Faced kind of the same problem with replication configured

  • Stop mysqld after ts server started
  • Restart mysqld again
  • It'll never reconnect. With error Pool does Not exists always ?

むむむ...

原因調査

opswitchの自動停止に起因する接続エラーについてはNestJSの実行環境(今回はECS on Fargate)に自動停止/起動を仕込めば回避できまずが、それでは問題の本質的な対応になりません。仮に本番環境でAuroraのフェイルオーバー等に起因する一時的な接続エラーが発生した場合に、Aurora復旧後もNestJSのアプリが復旧できないという事態が懸念されます。

とりあえず対応方法について社内で相談してみたところ、以下のZennの記事が見つかりました。

上記の記事はTypeORMのソースを追いながら丁寧に原因を解説してくれています。上記の記事を読んで発生している事象について理解が深まりました。恐らくですが、Node MySQL 2としては

{
  replication: {
    slaves: [
      { host: 'host1' },
      { host: 'host2' },
      { host: 'host3' },
      { host: 'host4' },
      { host: 'host5' },      
  },
}

といった具合に複数のエンドポイントが指定されている場合に、接続エラーが発生したエンドポイントをプールから削除することで、正常稼働しているエンドポイントだけで縮退運転するような設計思想になっているのだと理解しました。今回利用しているのはAuroraのリーダーエンドポイントなので、TypeORM(というよりNode MySQL 2)にプールして欲しい読み取り用のDBはあくまでリーダーエンドポイントという仮想的なDB1つだけです。実際の各リーダーインスタンスへの振り分けはリーダーエンドポイント側の仕事であり、仮に一部のリーダーインスタンスで障害が発生している場合もプールからリーダーエンドポイントは削除したくありません。

対策

ここまでで発生している事象については理解できたので、対策を組み込んでいきます。まず上記Zennの記事でも紹介されているremoveNodeErrorCountInfinityを指定する方法を試してみましたが、この方法の採用は見送りました。

エラーログが膨大になりすぎることに加え、文字通りリトライを無限に繰り返すため、Auroraが復旧するまでNestJSからクライアントにレスポンスを返却できなくなるためです。

他のアプローチを色々検討しましたが、最終的にNestJSのExceptionFilterの機構を使って全ての例外をキャッチし、Pool does Not exists.の例外をキャッチした場合はプールにリーダーエンドポイントを再接続する処理を組み込みました。コードは以下のようなイメージです。

import { ArgumentsHost, Catch } from '@nestjs/common'
import { BaseExceptionFilter } from '@nestjs/core'
import { DataSource } from 'typeorm'
import { MysqlDriver } from 'typeorm/driver/mysql/MysqlDriver'
import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions'
import { MysqlConnectionCredentialsOptions } from 'typeorm/driver/mysql/MysqlConnectionCredentialsOptions'

interface ICreateConnectionOptions {
  createConnectionOptions: (
    options: MysqlConnectionOptions,
    credentials: MysqlConnectionCredentialsOptions,
  ) => Promise<any>
}

@Catch(Error)
export class AllExceptionsFilter extends BaseExceptionFilter {
  constructor(
    private readonly dataSource: DataSource,
    protected readonly applicationRef,
  ) {
    super(applicationRef)
  }

  private _addSlaves() {
    const driver = this.dataSource.driver as MysqlDriver
    const options = driver.options

    options.replication.slaves.forEach((slave, index) => {
      driver.poolCluster.add(
        `SLAVE${index}`,
        (driver as unknown as ICreateConnectionOptions).createConnectionOptions(
          options,
          slave,
        ),
      )
    })
    driver.poolCluster.add(
      'MASTER',
      (driver as unknown as ICreateConnectionOptions).createConnectionOptions(
        options,
        options.replication.master,
      ),
    )
  }

  async catch(exception: Error, host: ArgumentsHost) {
    if (exception.toString() === 'Error: Pool does Not exists.') {
      this._addSlaves()
    }
    super.catch(exception, host)
  }
}

ポイントは_addSlavesの部分です。このメソッド内でプールにリーダーエンドポイントを再追加しています。ここのロジック自体はTypeORMのMysqlDriverのconnectメソッド内の処理と同等の処理を移植しています。

https://github.com/typeorm/typeorm/blob/d4607a86723eef07e62e6d7321a07f3ae5ed1f90/src/driver/mysql/MysqlDriver.ts#L386

MysqlDriverのcreateConnectionOptionsはprotectedメソッドなので、本来ExceptionFilterからは呼び出せないのですが(driver as unknown as ICreateConnectionOptions)でキャストすることで無理やりcreateConnectionOptionsを呼び出しています。

if (exception.toString() === 'Error: Pool does Not exists.') {のIF文が文字列の一致のみで比較する形になっており、イマイチ感がありますが、Node MySQL 2が投げる例外がただのErrorオブジェクトなので、この方法でしか判定できなさそうです。

動作確認

対策が組み込めたので動作確認してみます。

検証はローカル環境でMySQLのdockerコンテナをstart/stopさせて実施しました。

まずはdockerコンテナを起動した状態でcurlでNestJSにアクセスして確認

curl http://localjost:3000/<適当なエンドポイント> -o /dev/null -s -w "%{http_code}"
200

続いてMySQLのコンテナを停止して再度curlで動作確認します

docker stop <コンテナ名>
curl http://localjost:3000/<適当なエンドポイント> -o /dev/null -s -w "%{http_code}"
500

500エラーが返却されました。

MySQLのコンテナを再起動して再確認してみます

docker start <コンテナ名>
curl http://localjost:3000/<適当なエンドポイント> -o /dev/null -s -w "%{http_code}"
200

ステータスコード200が返却されました!

無事DBに再接続できているようです。この後修正後のアプリをAWS環境にデプロイし、Auroraをフェイルオーバーさせながらテストしましたが、フェイルオーバー完了後に問題なくNestJSのアプリが復旧できることが確認できました。

まとめ

TypeORMのreplicationは便利な機能ではありますが、slavesで指定したDBとの接続が失われた場合に再接続してくれないという仕様から、Auroraのリーダーエンドポイントを指定するような構成とは相性が悪いと言えます。同じような課題にお悩みの方は今回紹介したコードを参考に自前でリーダーエンドポイントの再追加を実装することも検討してみて下さい。NestJS以外にも流用は可能だと思います。

参考