MySQL - TECH PLAY - TECH PLAY

TECH PLAY

MySQL

イベント

マガジン

技術ブログ

はじめに こんにちは、トモニテ開発部の吉田です。 現在、トモニテではデータ基盤(Databricks)の整備に取り組んでいます。これまでの分析は個人が作成したスプレッドシートに頼る部分が多く、人やシートによって指標の定義が違ったり、誰が何を見られるのかを把握しにくかったりしたため、データを一元化することにしました。 その一環として、アプリの本番 DB のデータを Databricks に連携します。本番 DB には扱いに気をつけるべき情報が多く入っているので、開発部では「どのテーブル・カラムを Databricks に渡すか」をアプリのリポジトリで定義し、デプロイのたびにその定義を DB の権限と view に反映する仕組みをつくりました。 この記事は、本番 DB をデータ基盤につなごうとしていて、個人情報の扱いに悩んでいるエンジニアに向けて、その設計で考えたことを紹介します。伝えたいのは次の2点です。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す 全体の流れは次のとおりです。 図は本番環境の流れです。開発環境でも同じ流れで同期します。 連携に必要なこと Databricks が本番 DB(Aurora MySQL 3.10.4、MySQL 8.0 互換)のデータを取り込むには、次の2つが必要です。 Databricks の環境から本番 DB のリードレプリカに届く接続経路 Databricks が参照に使う、読み取り専用の DB ユーザー 経路はインフラ側で用意すれば済みますが、もう1つの「読み取り用のユーザーに何を見せるか」は、そう簡単には決められません。この記事の本題はここです。 何をどこで絞るか トモニテは妊娠・育児期の家族向けのアプリで、DB には扱いに気をつけるべき情報が多く入っています。一方で、分析に必要な情報もあり、テーブル・カラムの単位で「渡す/粗くして渡す/渡さない」を決める必要があります。 DB 側で絞る 絞り方には、次の2つがあります。 データ基盤にはすべて取り込み、分析向けにはデータ基盤側でマスクする 読み取り用ユーザーの権限で、問題のないものだけを見せる 一度 Databricks に取り込まれたデータは、コピー先まで含めて消すのが難しくなります。データの発生元である DB 側で絞るのが一番漏れのない形なので、2を選びました。 カラム単位の GRANT ではなく view で渡す 最初は、 GRANT SELECT (id, created_at, ...) ON users のように機密カラム以外のカラムだけに GRANT する方法(カラム単位の GRANT)を試しましたが、そのテーブルを Databricks から読めなくなりました。Databricks からは、Lakehouse Federation(外部の DB を Unity Catalog のカタログとして見せる機能)の MySQL 接続で読んでいます。 手元の MySQL 8.0 で、カラム単位の GRANT だけを持つユーザーをつくって確かめると、次のようになりました。 クエリ 結果 SHOW TABLES 、 information_schema.COLUMNS テーブルは見え、カラムは許可したものだけが見える SELECT id, created_at FROM users (許可したカラムだけを指定) 読める SELECT * FROM users WHERE 1=0 ERROR 1142 (42000): SELECT command denied to user ... for table 'users' MySQL では、許可していないカラムを含む SELECT * は、行を1件も返さないクエリでも拒否されます。しかもエラーはカラムではなくテーブル単位の拒否(1142)として返るので、テーブルの権限そのものが無いように見えます。 一方、Spark の JDBC は、テーブルのカラムと型を調べるときに既定で SELECT * FROM <テーブル> WHERE 1=0 を送ります。この既定は JdbcDialect.getSchemaQuery で、MySQL 用の MySQLDialect はこれを上書きしていません。 github.com github.com Databricks も同じクエリを送っているのかを、Aurora の performance_schema で確かめました。 events_statements_summary_by_digest は、実行されたクエリの値を ? に置き換えた形ごとに集計した表で、実行回数・返した行数・エラーの回数に加えて、置き換える前のクエリの例( QUERY_SAMPLE_TEXT )も持っています。users について、元のテーブルと、後述する機密カラムを除いた view( <masked> のデータベースに置いたもの)を、次のクエリで比べました。 SELECT QUERY_SAMPLE_TEXT, COUNT_STAR, SUM_ROWS_SENT, SUM_ERRORS FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT IN ( ' SELECT * FROM `<元のデータベース>` . `users` WHERE ? = ? ' , ' SELECT * FROM `<masked>` . `users` WHERE ? = ? ' ); QUERY_SAMPLE_TEXT COUNT_STAR SUM_ROWS_SENT SUM_ERRORS SELECT * FROM `<元のデータベース>`.`users` WHERE 1=0 41 0 7 SELECT * FROM `<masked>`.`users` WHERE 1=0 40 0 0 WHERE 1=0 を付けた SELECT * が届いていて、1行も返していませんでした。Lakehouse Federation も、Spark と同じ方法でカラムを調べていると考えられます。カラム単位の GRANT を試した元のテーブルにはこのクエリのエラーが記録されていて、view では 0 回でした。カラムを調べる段階で拒否され、テーブルごと読めなくなったと考えています。 許可したカラムを明示したクエリ( SELECT * FROM (SELECT id, created_at FROM users) t )なら通るので、読む側でテーブルごとにカラムを指定すれば回避はできます。ただ、それでは「何を渡すか」の定義が Databricks 側にも分散します。そこで、機密カラムを除いた view を別のデータベース(以下、masked)につくって渡すことにしました。view なら SELECT * で読んでも、出てくるのは view が出力するカラムだけです。連携が不要なテーブルには、権限を付けません。 加工も Databricks の ETL ではなく view で行います。ETL で加工すると、加工前の値が、取り込んだデータをそのまま置く層(Bronze)に残ってしまうためです。 view と Unity Catalog の二段構え view で落とす・粗くするのが一段目、Databricks 側の Unity Catalog で見られる人を絞るのが二段目です。view で粗くしきれないものは、二段目で閲覧者を限定します。 分類の方針 どのカラムを渡すかは、厳しめにルールを整備しました。 認証情報や、それ単独で個人情報になるものは渡さない 単独では個人を特定できなくても、ほかの情報と容易に照合することで個人を識別できるもの(容易照合性)も考慮し、渡さないか、分析に必要な粒度まで粗くして渡す 判断に迷ったら「渡さない」側に倒す 迷ったら渡さない側に倒したのは、あとから必要になれば view を直して追加できますが、一度渡したものを消すのは難しいからです。厳しめにした分、運用を始めてメンバーから「分析しづらい」という声が出れば、見直すつもりです。 連携するテーブル・カラムを tables.yml で宣言する この方針では、テーブルの追加・変更のたびに権限を整理する必要があります。手作業で整理する運用は避けたいので、定義をリポジトリで管理し、CI で検査、デプロイで反映する仕組みにしました。 定義は、アプリ(API サーバー)のリポジトリに置いています。中心になるのは tables.yml で、全テーブルを次の3つに分類します。 grant :そのまま渡す view :機密カラムを除いた view で渡す exclude :渡さない view のテーブルは、さらに全カラムを include (そのまま渡す)/ transform (加工して渡す)/ exclude (渡さない)に分け、 transform には加工内容と理由を、 exclude には理由を書きます。 現在は全 201 テーブルを分類していて、内訳は grant が 165、 view が 30、 exclude が 6 です。 # テーブル名・カラム名は説明用の例です tables : table_a : { policy : grant } table_b : policy : view columns : include : [ id, status, created_at ] # そのまま渡すカラム transform : # 加工して渡すカラム: 加工内容と理由 column_x : 粒度を粗くする。元の値はほかの情報と照合すると個人を特定できる exclude : # 渡さないカラム: 理由 column_y : 個人を特定できる情報 table_c : policy : exclude reason : 連携が不要 この定義と、view の SQL( views/*.sql )、Databricks の読み取り用ユーザーへの GRANT( grants.sql )を1つのディレクトリで管理しています。 なぜインフラのリポジトリではなく、アプリのリポジトリに置いたか トモニテのインフラは Terraform で別のリポジトリが管理していますが、定義はアプリのリポジトリに置きました。 見直しのきっかけになるテーブル・カラムの追加はアプリのマイグレーションで行われるので、同じ PR で分類と view も更新・レビューできる 分類漏れの検出にはマイグレーション済みの DB が必要で、アプリの CI ではすでに MySQL を立ててマイグレーションを流している Databricks からの経路や DB ユーザー・IAM などは、引き続きインフラのリポジトリで管理しています。 分類を書くまで CI が通らないようにする CI では、マイグレーション後のスキーマと定義を突き合わせ、次のものがあれば落とします。 分類されていないテーブル、 view のテーブルの分類されていないカラム 元のテーブルの変更で壊れた view exclude ・ transform にしたカラムを、そのまま出力している view grants.sql と tables.yml の食い違い これで、マイグレーションでテーブルやカラムを足すと、分類を書くまで CI が通らなくなります。 「追加に人が気づく」ことに頼らず、分類を書くことがマイグレーションの手順に組み込まれる 形です。 開発メンバーにとっては、マイグレーションのたびに分類を書く手間が増えます。この手間をどう感じるかは、これから運用しながら確かめていきます。 開発環境の DB でも実際に止まった 同じ検査は、後述の同期コマンドもデプロイのたびに実際の DB に対して行います。開発環境で動作確認したところ、定義に分類の無いカラムがある状態で、同期が想定どおり止まりました。別の機能の開発で開発環境の DB にはすでにカラムが追加されていましたが、連携定義にはそのカラムの分類がなかったためです。止まったのは定義の検証の段階(Apply の前)なので、DB には何も変えずに失敗が通知されました。 マイグレーションと定義のずれを、CI だけでなく実際の DB に対しても検知できることを確かめられました。 同期コマンド:差分(Plan)を計算して適用する 定義を DB に反映するのは、Go で書いた同期コマンドです。デプロイのたびに ECS のタスクとして起動し、定義を検証して排他をとったうえで、今の権限と view を読んで定義との差分(Plan)を計算し、適用します。適用後は状態を読み直して確かめ、変更か失敗があれば Slack に通知します。 宣言的で冪等に動くので、差分がなければ何もせず、通知もしません。定義に無い権限や view は外れます。手作業で付けた GRANT や、手でつくった view も、同期用ユーザーが外せる範囲で次の同期で外れます。 権限の管理には Terraform の MySQL provider などの既存ツールもありますが、定義をアプリのリポジトリに置いたので、同期コマンドもアプリのリポジトリに置き、アプリと同じ Go で書きました。 適用の順番を固定する Plan は、必ず次の順番で適用します。 ① view の作成・更新 ② GRANT ③ REVOKE と、定義に無い view の DROP この順番には、次のルールを決めています。 ①が1本でも失敗したら、②は流さない。 新しい view ができていないのに、GRANT だけが増える状態を避けるためです ①の中、②の中では、1本が失敗しても残りは止めない。 関係のない文まで巻き込んで止めないためです ③は①②の結果にかかわらず必ず全部流し、1本が失敗しても残りを続ける。 見える範囲を減らす操作は、止める理由がないためです 適用後に確かめ、だめなら段階的に安全側へ倒す 適用が終わったら、状態(権限と view の一覧)と、masked の各 view の出力カラムを読み直します。そのうえで、機密カラム( exclude ・ transform にした元のカラム)を出している view があれば、次のように段階的に対応します。 その view を DROP する。取り込みは止まるが、漏れは止まる DROP できなければ(ほかのユーザーが作った view など)、masked 全体の SELECT を REVOKE する。全 view の取り込みが止まるが、機密カラムが見え続けるよりは安全 それもできなければ、機密カラムが見え続けていることを至急の通知で知らせる 同期コマンド自体を信用しすぎない 同期は本番 DB の権限と view を書き換えるので、誤って別のユーザーや別のデータベースを触らないよう、コマンド自体にも制限をかけています。 発行できる SQL を4種類に固定する masked の view をつくる・更新する CREATE OR REPLACE VIEW 読み取り用ユーザーに権限を付ける GRANT SELECT 読み取り用ユーザーの権限を外す REVOKE masked の view を消す DROP VIEW 制限は二段構えです。 生成時 :SQL は専用の型( Statement )のコンストラクタからしか作れず、この4種類以外を組み立てる手段がない 実行直前 :実行する側が、許可リスト(正規表現)で SQL の文字列を再検査し、一致しないものは実行しない 同期用ユーザーの権限を絞る 同期用の DB ユーザーには、必要な権限だけを付けています。同期で外せる権限も、同期用ユーザーが GRANT OPTION 付きで持っている範囲に限り、それ以外の権限が付いていた場合は、同期では直せないものとして毎回失敗で通知します。 この最小権限で同期が通ること、2回目の同期では SQL を1本も送らないこと(冪等)、手作業で足した権限が次の同期で外れることは、CI の MySQL で本物の定義を使った結合テストで確かめています。 デプロイとの連動 同期は、開発環境・本番へのデプロイの後に、別のジョブとして起動します。定義やマイグレーションなど同期に関係するものが変わったときだけ動き、変わったかを判定できないときは、見落とさないよう同期する側に倒しています。失敗した実行は成功に数えないので、その変更は次のデプロイで同期し直されます。 割り切ったこと 最後に、この仕組みでは検出できないことも書いておきます。 view の検査は、 exclude ・ transform にした元のカラム名がそのまま出力に無いかだけを見ている。別名を付ける、式で包むといった書き方で機密カラムを出しても検出しないので、view の SQL の変更は PR のレビューで確かめる grant のテーブルに機密カラムが足された場合は、CI では検出できない。マイグレーションのレビューで view に切り替える 読み取り用ユーザーのグローバル権限やロール経由の権限は、同期用ユーザーが読めないので検出できない すべてを機械で防げるわけではないので、機械で防げる範囲と、レビューで確かめる範囲を README に明記しています。 まとめ 本番 DB のデータを Databricks に安全に渡すために、「どのテーブル・カラムを渡すか」をアプリのリポジトリで宣言し、デプロイのたびに DB に反映する仕組みをつくりました。冒頭の2点をあらためて挙げます。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない。 分類を書くまで CI が通らず、分類はマイグレーションと同じ PR でレビューする 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す。 適用の順番を固定し、機密カラムが見えていれば view の DROP、masked 全体の REVOKE と段階的に安全側へ倒す データ基盤に何を渡すかを、シートの共有範囲ではなく、リポジトリの定義で管理できるようになりました。これからは、一元化したデータの上で分析を進めていきます。
はじめに SRE の寺島です。 MNTSQ の契約管理プロダクト(以下、CLM)では、テナントごとに MySQL のスキーマを分ける構成で Aurora MySQL に接続しています。このアーキテクチャは DB 接続数が増えやすく、運用上の大きな課題となっていました。 この記事では、接続数を抑えるために取り組んだ試行錯誤や検証結果をまとめています。同じようにスキーマ分離の構成で接続数の課題に直面している方の判断材料になれば幸いです。 TL;DR Rails の idle_timeout 短縮(300 秒 → 60 秒)により、日中の DB 接続数は約 50% 減少しました。 RDS Proxy は接続数を減らすどころか、約 2 倍に増やしました。 弊社のシステムのスキーマ分離型マルチテナント構成では、RDS Proxy は接続数削減につながりませんでした。 背景: 接続数がインスタンスサイズの制約になっていた CLM の本番 DB では、接続数の増加に伴いアラートが鳴るたびに max_connections を引き上げて運用していました。その結果、設定値はインスタンスクラスのエンジン既定値を大きく超過する状態となっていました。 Aurora MySQL における max_connections の既定値はインスタンスのメモリ容量に基づいて算出されます( Managing performance and scaling for Aurora MySQL )。既定値を超える設定は「メモリの裏付けがない接続」を受け入れる状態を意味し、突発的な接続バースト時のメモリ枯渇や、Performance Schema の肥大化による OOM のリスクを抱えることになります。つまり、安全に受け入れ可能な接続数を増やすには、メモリ容量(=インスタンスサイズ)の引き上げが不可欠でした。 一方で、リソースの使用状況には大きな偏りがありました。 CPU : 日中平均で 10% 強、最大でも 25% 程度と十分な余裕がある メモリ・接続数 : 接続枠を安全に確保するために、インスタンスサイズを引き上げざるを得ない 日中の CPU Utilization しかし、インスタンスを 1 サイズ引き上げても、メモリ由来で増える max_connections の既定値は 1,000 本程度に過ぎません。CPU が余っているにもかかわらず、接続数枠とメモリの安全マージンを確保するためだけにインスタンスを上げ続ければ、短期間で費用が倍々に膨らんでいきます。 そのため、インスタンスサイズの引き上げで対処するのではなく、接続数の増加そのものを抑制する根本的な対策が必要でした。 前提の環境 2026年10月時点 アプリケーション: Rails 8.1(Active Record、mysql2 アダプタ)。 idle_timeout と reaping_frequency の既定値は 300 秒と 60 秒 DB: Aurora MySQL アプリケーション実行基盤: ECS Fargate テナント分離: テナントごとに MySQL のスキーマを分け、接続時にスキーマを指定する なぜスキーマ分離だと増えやすいか 接続数の構造 一般的な Rails アプリケーション(単一スキーマ構成)では、基本的に 1 プロセスに対して 1 つのコネクションプールが割り当てられます。しかし CLM ではテナントごとにスキーマを分けているため、Active Record のコネクションプールもテナントごとに独立して生成されます。 そのため、1 つのプロセス内には「そのプロセスが処理を担当したテナントの数」だけプールが保持されていくことになります。 この前提を踏まえると、1 プロセスが抱える DB 接続数は、おおよそ次のような関係式で捉えることができます。 1 プロセスの接続数 = プールを持つテナント数 × テナントあたりの実接続数 この構造において留意すべきなのは、 database.yml に設定する pool 値はあくまで「テナントあたりの実接続数の上限」であり、プールを持つテナント数を抑えるものではないという点です。 一般的な Rails アプリケーションであれば pool 値の引き下げが接続数削減の直接的な手段になりますが、本構成ではテナントあたりの接続数が pool の上限に達していない限り、いくら pool 値を小さくしても全体の接続数は限定的になります。 そのため、接続数の増加を抑えるには、プールを抱えるテナント数そのものの挙動や、接続のライフサイクル(アイドル時間の制御など)に着目する必要があると考えました。 実測 本番の writer に対して、 performance_schema.threads と information_schema.innodb_trx のテーブルを 60 秒ごとに取得しました。これらを ECS Fargate タスクの IP と突き合わせ、コンポーネント別に「プールを持つテナント数」と「テナントあたりの接続数」を実測・分析しました(※数千本の接続がある writer への負荷を考慮し、 SHOW PROCESSLIST は避けています)。 計測の結果、主に次の2点がわかりました。 1. 接続数増加の主因はプールを持つテナント数の増加 接続数を押し上げていた主な要因は、プロセス数そのものやテナントあたりの接続数ではなく、各プロセスがプールを保持しているテナント数の増加でした。 Web(Puma) : 1 プロセスあたりの接続数は、深夜と日中で 約 4.6 倍 の開きがありました。この主因は、プールを保持するテナント数が深夜の「約 2 割」から日中には「約 7 割」へと拡大していたためです。テナントあたりの接続数自体も深夜の 1.1 本から日中には 1.4 本へと微増しますが、接続数全体の変動の大部分はプールを持つテナント数の増減で説明がつきます。 ワーカー(syoryuken) : 日中のプロセス数は深夜の約 2 倍にとどまる一方、接続数は 約 20 倍 に跳ね上がっており、日中の全体接続数増加分の約 4 割を占めていました。こちらもプロセス数のスケールアウト以上に、各プロセスが処理の中で多様なテナントに触れ、プールを抱え込んだことが主因でした。 以下のグラフは、Web の「1 プロセスあたりの DB 接続数」と「1 プロセスあたりのプールを持つテナント数」の推移を、深夜の最低値を 1.0 として相対化して重ねたものです。 2 本の線はほぼ重なって推移しており、日中にわずかに開く差分だけが、テナントあたりの接続数の増加分(1.1 本 → 1.4 本)です。 2. 接続のほぼすべてがアイドル状態 接続がどのように使われ、破棄されているかを調査したところ、接続が使われ続けているのではなく放置されて残っているだけだと分かりました。 使われている接続はごくわずか : トランザクションを開いている接続は平均数本(瞬間最大でも数十本)で、全体の 1% 未満でした。この数字は単発の autocommit クエリを含みませんが、後に RDS Proxy 経由で測った「クエリ実行中を含めて借用されている接続」も日中平均で全体の 5% 以下でした。60 秒以上続く長時間トランザクションもほぼ存在しません。 pool 設定は上限としてほぼ機能していない : テナントあたりの接続数が pool の上限に達していたサンプルは全体の 0.07% に過ぎませんでした。 接続の生存時間の中央値はちょうど 300 秒 : 接続確立から破棄までの生存時間の中央値は、ちょうど 300 秒でした。これは Rails の idle_timeout の既定値と完全に一致しており、一度使われた接続が 5 分間放置されて Reaper に切られるまで残り続けている実態が浮かび上がりました。 以上の実測から、CLM において接続数を左右している要素は pool ではなく、「プロセス数」「プールを持つテナント数」「接続の生存時間(アイドル時間)」の 3つであることが明確になりました。 打ち手の洗い出し 実測データを数え直して、打ち手ごとの削減量を見積もりました。 打ち手 削減の見込み 根拠 pool を下げる 全体で 1〜3% 程度 テナントあたりの接続数が上限に達していないため 長時間トランザクションの解消 ほぼゼロ(最大 1 本程度) 同時に開いている長時間トランザクションは常に 1 本以下のため idle_timeout の短縮 日中ピークで 30〜45% 減 接続の生存時間の中央値(300 秒)が idle_timeout と一致しているため RDS Proxy の導入 現状の 1/4 〜 1/3 まで削減 トランザクション単位での多重化による抜本的な削減(詳細は後述) アプリケーション側の設定変更として実質的に効果が見込めるのは、 idle_timeout の短縮だけ でした。しかし、 idle_timeout を短縮しても「プールを持つテナント数が全体のテナント数に比例して増えていく」という根本的な構造は変わりません。 そこで、接続数の決定要因そのものを変える本命のアプローチとして、 RDS Proxy の導入 を検討しました。 RDS Proxy はトランザクションの終了時に DB 接続をプールへ返却するため、DB 側の接続数を「クライアントが保持している接続数」から「同時に実行中のトランザクションと文の数」ベースへと多重化できるのではないか、と考えたためです。実測上、トランザクションを開いている接続は全体の 1% 未満(数本〜数十本)にとどまっていたことから、この仕組みがうまく機能すれば接続数を大幅に抑えられるのではないかと期待していました。 idle_timeout の短縮: 接続数を半減 設定値の決め方 Rails の Active Record には、一定時間使われていない接続を自動で破棄する Reaper という仕組みがあります( ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper )。 reaping_frequency : Reaper が巡回してアイドル接続をチェックする間隔(既定値: 60 秒) idle_timeout : 最後に使われてから接続を維持する制限時間(既定値: 300 秒) Reaper は reaping_frequency の周期ごとに実行され、前回の使用から idle_timeout を超えている接続を切断します。つまり、一度使われた接続は最短で idle_timeout 、最長でも idle_timeout + reaping_frequency の時間が経過すると破棄されます。 今回の見直しでは、実測した「最終使用からの経過時間」の分布をもとに、接続が維持される上限時間を 360 秒から 90 秒 へと短縮することにしました。 idle_timeout : 300 秒 → 60 秒 reaping_frequency : 60 秒 → 30 秒 結果 本番環境に反映した直後から接続数は急減し、狙い通りの水準で安定しました。変更前後それぞれ平日 3 日の同じ時間帯で比較しています。 項目 変更前後の変化 日中の平均接続数 約 50% 減 日中のピーク 約 43% 減 新規接続の頻度 約 1.8 倍 writer の CPU(日中平均) ほぼ変化なし FreeableMemory ほぼ変化なし 次のグラフは、反映日を挟んだ writer の接続数の推移です。反映した時点で段差ができ、以後は低い水準で推移しています。 懸念された副作用についても、以下の通り問題ないことを確認しました。 CPU への影響(なし) : 接続の生存時間を縮めたことで接続の張り直し頻度は約 1.8 倍に増えましたが、接続確立時のオーバーヘッドは小さく、CPU 使用率に有意な変化はありませんでした。 メモリの挙動(想定通り) : 空きメモリの増加は 1% 未満にとどまりました。接続数削減の狙いは定常時のメモリを空けることではなく、接続が急増したときの変動幅を抑えることと max_connections を既定値に収めることにあるため、この結果は想定どおりです。 RDS Proxy の導入 仮説と試算 RDS Proxy を導入した場合、DB 側の接続数は次の 4 つの内訳の合計になると試算しました。 DB 側に残る接続 見込み 算出の根拠 同時に実行中のトランザクションと文 数本から数十本 実測値(全体の 1% 未満) pin されて返せない接続 全体の 1 から 3 割 後述の事前検証結果より Proxy がプールに保持する待機接続 ほぼスキーマ数と同じ本数 1 スキーマにつき 1 本の接続を維持すると仮定 Proxy を通さない接続 数本 運用系・メンテ用の直接接続 これらを積み上げると、全体の接続数は 当時の 1/4 〜 1/3 程度まで大幅に削減できる 見込みでした。 導入前に描いていた多重化のモデルが次の図です。上が直接接続、下が RDS Proxy 経由で、Proxy のプールを 1 つとして考え、DB 側の待機接続はテナントごとに 1 本に収まる想定でした。 事前検証: pin の発生率を測る RDS Proxy には、クライアント接続が「他のセッションに引き継げない状態」を作ると、そのクライアントを特定の DB 接続に固定するpin(ピン留め)という挙動があります( Avoiding pinning an RDS Proxy )。pin された接続はクライアントが切断するまでプールに返却されず、多重化に寄与しません。そのため、本番ワークロードで pin がどの程度発生するかが削減効果を左右します。 CLM のアプリケーションを調査したところ、該当する条件は次の 2 点でした。 接続時の SET クエリ(パラメータグループで回避) Rails が接続初期化時に発行する SET lock_wait_timeout は、Proxy が状態を追跡しない変数であるため、放置すると全接続が pin されてしまいます。これはパラメータグループ側に設定を移し、 database.yml から記述を外すことで回避しました。 16KB を超える大きな SQL(実測により影響度を推定) CLM には、1MB 級の INSERT や、巨大な IN 句を持つ preload が存在します。16KB 超のクエリは Proxy の制約で接続が pin されます(契約書を扱う以上、ある程度は避けられないと考えています)。 2 つ目の大きな SQL については発生箇所をすべて改修しきれないため、本番環境で発生頻度を計測しました。本番の general log を 5 分間ずつ複数回サンプリングし、16KB 超の SQL を発行した接続の発生率から pin される割合を推定したところ、中心的な見積もりで約 1 割、上限の見積もりでも 3 割弱でした。 この結果から「pin される接続は最大でも全体の 3 割程度に収まり、大半の接続は多重化される」と判断し、前述の試算(pin による固定: 1〜3 割)を置いて導入へと進めました。 段階導入と切り戻しの準備 RDS Proxy はホップが 1 つ増えるため、レイテンシ悪化や ERROR 9501 などの新しい障害モードが加わります。そこで、まずは遅延に寛容な非クリティカルのワーカー 1 種から本番投入し、1 営業日観測しました。 認証失敗や借用待ちは問題なく、処理量も維持されていたため、Web および全ワーカーへと展開しました。なお、万が一に備えて接続先はコンポーネント単位で即座に直接接続へ切り戻せる構成を担保しています。 結果: 接続数は減らず、逆に倍増した 3つの構成における接続数の比較 writer の DatabaseConnections について、以下の 3 つの構成で実測値を比較しました(※「 idle_timeout 短縮後の直接接続」を 1.0 とした相対比)。 構成 日中平均 深夜平均 日中ピーク 直接接続( idle_timeout 300 秒) 2.0 2.6 2.3 直接接続( idle_timeout 60 秒) 1.0(基準) 1.0(基準) 1.0(基準) Proxy 経由( idle_timeout 60 秒) 2.0 4.6 1.7 接続数を劇的に減らす本命として導入した RDS Proxy でしたが、結果は期待と大きくかけ離れるものでした。 日中平均で 2 倍、深夜平均で 4.6 倍に増加 : idle_timeout 短縮後の直接接続と比べて接続数が跳ね上がりました。短縮前(300 秒)の直接接続と比較しても、日中は同水準、深夜に至っては約 1.7 倍に悪化しています。 日中ピークのみ約 25% 抑制 : 短縮前(300 秒)の直接接続ピーク(2.3)と比較すると、Proxy 経由(1.7)は約 25% 低く抑えられていました。 パフォーマンスとエラーの検証 一方で、接続数以外の稼働状況は良好でした。 接続の借用待ち : 平均数十マイクロ秒、1 分間の最大値でも約 50 ミリ秒程度 エラー発生 : 認証失敗や ERROR 9501 (DB 接続取得のタイムアウト)はゼロ 遅延や接続エラーといった性能面の悪化はなく、純粋に DB 側の接続数だけが大幅に増えたという状況でした。 見積もりと実測の乖離 事前見積もりと本番実測値を突き合わせた結果は、次の通りでした。 DB 側に残る接続の内訳 当初の見積もり 本番での実測値 判定 同時に実行中のトランザクション 数本 数本 見積もり通り pin されて返せない接続 全体の 1〜3 割 プール全体の 5% 未満 想定より良好 Proxy を経由しない接続 数本 数本 見積もり通り Proxy がプールに保持する待機接続 1 スキーマあたり 1 本 1 スキーマあたり約 8 本 最大の誤算 懸念していた pin の発生率は 5% 未満と低く抑えられており、多重化の条件そのものは満たせていました。 接続数が減らなかった原因はシンプルで、 「Proxy が保持する待機接続」をスキーマあたり 1 本と見積もっていたのに対し、実際にはその約 8 倍に膨らんでいたため でした。 なぜ 1 つのスキーマに対して 8 本もの待機接続が残ってしまったのか 原因は、RDS Proxy のアーキテクチャと接続のライフサイクルにあります。次の 3 点です。 1. Proxy の接続プールは「ノードごと」に独立している RDS Proxy の内部は複数のノードで構成されており、接続プールはノード間で共有されません。本番環境の Proxy はノードが 8 台で、同一スキーマの DB 接続が 8 つのノード IP から別々に張られていました。1 つのスキーマに対して最大 8 本の待機接続が残る構造です。 2. 一度使われた接続は 5 分間 idle とみなされない 公式ドキュメントでは、 5 分間使われなかった DB 接続を idle とみなし、 MaxIdleConnectionsPercent を超えた分だけを閉じる とされています。言い換えると、最後の借用から 5 分以内の接続は閉じる対象になりません( AWS 公式ドキュメント )。 3. 複数プロセスからのアクセスにより、全ノードへ接続が拡散する Web やワーカーの複数プロセスからリクエストが送られる際、Proxy の負荷分散によって接続は異なるノードへ振り分けられます。また、Rails が使われていない接続を切断・再接続する際にも、別のノードに着くことがあります。 この結果、あるスキーマへのアクセスが短時間のうちに全 8 ノードへ分散し、それぞれのノードで DB 接続が確立されて 5 分間居座り続けます。 flowchart LR C["スキーマ A への<br/>クライアント接続<br/>(複数プロセス、60 秒ごとに作り直し)"] subgraph P["RDS Proxy(プールはノードごと)"] direction TB N1["ノード 1"] N2["ノード 2"] Nd["…"] N8["ノード 8"] end DB[("DB<br/>スキーマ A の接続<br/>最大 8 本")] C --> N1 C --> N2 C --> Nd C --> N8 N1 --> DB N2 --> DB Nd --> DB N8 --> DB クライアントから見ればある時点で張っている接続はわずかであっても、DB 側から見るとスキーマ A だけで 8 本の待機接続が保持されている状態になります。これが稼働している全テナントのスキーマで同時に発生した結果、待機接続が「スキーマ数 × ノード数」まで跳ね上がることになりました。 メカニズムを裏付ける実測データ この仮説は、本番のメトリクスとログからも裏付けられました。 ノード数の確認 : Proxy の CloudWatch メトリクス MaxDatabaseConnectionsAllowed は、 max_connections × MaxConnectionsPercent をノード数で等分した値の合計として出てくるので、1 ノード分の値で割るとノード数が分かります。同じメトリクスの SampleCount (1 分あたりノード数と同じ)と、Proxy が VPC に持つ ENI の本数も 8 で一致しました。ノード数の決定基準は AWS が公開しておらず、顧客側で指定も制限もできません。 全ノードへの接続の拡散 : DB 側の接続スナップショットを「ユーザー × スキーマ × ノード IP」で集計したところ、日中は平均 6.6 ノードに接続が分散しており、過半数のスキーマが 8 ノードすべてに接続を保持していました。 5 分間の生存ルール : Proxy のデバッグログから接続切断ログを分析したところ、最後の借用から切断されるまでの時間は最短でも 303 秒、中央値は 380〜430 秒であり、300 秒未満で切断された例は皆無でした(切断理由はすべて「There were too many idle connections in the connection pool」)。 (補足)深夜の接続数が 4.6 倍に跳ね上がった理由 前述の比較表で深夜の接続数が直接接続の 4.6 倍に達していたのは、この 5 分ルールと社内ツールの挙動が重なったためです。 CLM では、運用ツールが 数分間隔で全テナントのAPI を叩く仕様になっていました。直接接続のときは Rails が 60 秒で切断していたため表面化していませんでしたが、Proxy 経由では全スキーマの接続が 5 分以内に必ず再利用され、プールからほとんど解放されない状態が作られていました。 設定で下げられないかの模索 効かなかったアプローチ Proxy 側の設定変更や運用回避によって接続数を抑えられないか模索しましたが、有効な手段はありませんでした。 MaxIdleConnectionsPercent の引き下げ(効果なし) : すでに 10% と低めに設定していましたが、さらに下げても変化はありませんでした。前述の通り、接続が 5 分以内に再利用されて「アイドル判定」の対象にならないためです。 完全修飾テーブル名への移行(非現実的) : AWS が案内している根本対策は「SQL を schema.table 形式で記述し、接続時のスキーマ指定をなくす(1 つの共通プールにする)」ことでした。しかし、CLM のアプリケーション全体のコードベースを改修する必要があり、現実的ではありませんでした。 AWS サポートにも問い合わせたものの、「現在のスキーマ分離の接続方式を維持したまま、Proxy の設定だけで待機接続を大幅に削減するのは困難」という回答でした。 上限設定( MaxConnectionsPercent )によるキャップ案の検証 そこで考えたのが、 MaxConnectionsPercent を使って Proxy が保持するプール接続数そのものに上限(キャップ)を設ける案でした。 実測上、同時にアクティブとなっている接続(借用中)はプール全体の 5% 未満です。そのため、上限をかなり低く絞っても、トランザクションの同時実行に必要な接続枠(需要)自体は十分に足ります。 ここで焦点となったのは、「上限に達した際、Proxy がどのように振る舞うか」でした。 仮説 A(追い出し) : 未使用の別スキーマの待機接続を破棄し、要求されたスキーマで新しく DB 接続を開き直す 仮説 B(借用待ち) : 空きが出るまでクライアントを待たせ、最悪 ConnectionBorrowTimeout でエラーになる 仮説 A であれば接続数を物理的に頭打ちにしつつ正常稼働できますが、仮説 B であればレイテンシ悪化や障害につながります。AWS の公開ドキュメントからはどちらの挙動になるか判断がつかなかったため、開発環境で検証を実施しました。 試験 1: 上限到達時の挙動確認 あえて上限をごく小さな値に絞り、その上限数を大きく超える数のスキーマへ同時にクエリを発行し続けました。 結果 : すべてのクエリがエラーなく成功し、レイテンシも上限なしの環境と有意な差はありませんでした。 ログの裏付け : Proxy のログには The pool reached the maximum connection setting, and a connection with different properties was required と記録されており、 「上限到達時は別スキーマの待機接続を破棄して開き直す(仮説 A)」 動作であることが確認できました。 試験 2: 借用待ちが発生する境界値の測定 上限に対してどの程度まで同時借用需要が近づくと待ち時間が発生するかを測定しました。 結果 : 借用待ちが発生し始めるのは需要が上限の 9 割を超えてからであり、一時的に上限の 1.5 倍近い負荷をかけてもタイムアウト( ConnectionBorrowTimeout )には至りませんでした。 本番環境の定常需要は想定していた上限値の 2 割程度に収まる見込みだったため、この挙動が AWS の仕様として将来にわたって保証されるのであれば、接続数を抑える有力な解決策になるという段階まで検証を進めることができました。 AWS の回答と判断 AWS サポートおよび担当 TAM を通じて、サービスチームに仕様と設計の整合性を確認しました。いただいた見解は次の通りです。 接続追い出しの挙動は未保証 : 上限到達時に他スキーマの待機接続を切断して新規接続を優先する内部処理の順序や条件は公開されておらず、仕様として保証された動作ではない。 設定値の推奨プラクティス : MaxConnectionsPercent は「直近の最大接続数( DatabaseConnections )の 30% 上」に設定することが推奨されており、既存の接続数を意図的に下回るキャップを設ける運用は想定されたベストプラクティスに沿わない。 内部実装への依存リスク : 検証環境で再現できた挙動であっても、ドキュメント化されていない内部実装である以上、マネージドサービスの将来的なアップデートによって予告なく挙動が変更される可能性がある。 検証環境では期待通りに動作したものの、公式に仕様として保証されていない挙動(2026 年 10 月時点の確認です。AWS のドキュメントと挙動は更新されることがあります)に本番環境の可用性を委ねることはできないと判断しました。もし仕様変更や予期せぬ負荷パターンによって借用待ちが発生した場合、即座にサービス影響(レイテンシ悪化や接続タイムアウト)につながるリスクを抱えることになります。 一方で、上限を設けない運用のままでは、接続数は直接接続の 2 倍の水準を維持したまま、テナント数の増加に伴って増え続けてしまいます。 上限を設ければ接続数を頭打ちにできる見積もりでしたが、保証されない挙動に本番を預けることは許容できないと判断し、RDS Proxy の採用を見送って直接接続へ切り戻す判断をしました。 RDS Proxy を再検討する条件 今回の検証結果を踏まえると、単に「マルチテナント環境では RDS Proxy は使えない」という話にとどまらず、どういった条件下であれば RDS Proxy が有効な選択肢になり得るか、という損益分岐点も見えてきます。 Proxy が接続数の「上限」として機能する仕組み RDS Proxy を経由した場合、DB 接続数はクライアントのプロセス数には依存せず、以下の構造でおおむね頭打ちになると考えられます。 Proxy の上限 ≈ 待機接続(おおむね スキーマ数 × ノード数) + 同時借用のバースト分 待機接続(第 1 項) : 「ユーザー × スキーマ × ノード」の組み合わせごとに最低 1 本の接続が保持され、これが待機接続のベースラインになります。 同時借用のバースト分(第 2 項) : 同じ組み合わせに対して同時に複数のクエリ借用が発生した場合にのみ、接続が追加で開かれます。この追加接続も 5 分間はプールに残るため一時的な上乗せとなります。弊社は 1 テナントあたりの同時アクセスが少なく、組あたりの接続は深夜で 1 本、日中でも平均 2 本弱だったため、全体はおおむね第 1 項に近い値になりました。テナントあたりのトラフィックが多い環境では、第 2 項の分だけこの値を上回ります。 実測でも、日中のピーク接続数は「(スキーマ × ノード)の組み合わせ数」に「瞬間的な最大借用数」を足した値とほぼ一致していました。 前述の検証結果において、 idle_timeout 短縮前と比べて「日中のピークだけは約 25% 低く抑えられていた」のは、クライアント側の瞬間的なプロセス増加やスパイクがこの Proxy の接続上限に吸収されていたためです。 弊社の環境における損益分岐点と再検討の判断基準 今回の環境では、Proxy の上限値は「 idle_timeout 短縮後の直接接続ピーク」の 2 倍弱に位置していたため、導入によって接続数が増加する結果となりました。 しかし、直接接続が「プロセス数」に比例するのに対し、Proxy 経由は「スキーマ数 × ノード数」で頭打ちになるという構造の差を踏まえると、再検討の判断基準は次の 2 点に整理できます。 接続数の支配的要因が「テナント数」ではなく「プロセス数」であること テナント数の増加が主因であるフェーズでは、テナント増に比例して Proxy の上限そのものが押し上がるため効果は見込めません。一方で、Web の台数増やワーカーの大規模なスケールアウトによって「プロセス数の伸び」が先行し、直接接続のピークが「スキーマ数 × ノード数」を恒常的に上回る規模になれば評価は逆転します。この領域に達して初めて、Proxy は「接続数を一定の枠内に抑え込むバッファ」として機能します。 最新のノード数に基づく上限の再試算 Proxy の内部ノード数はインスタンスサイズや AWS 側のリソース配置によって変動します。再検討にあたっては、その時点の「スキーマ数」とメトリクスから把握できる「ノード数」を掛け合わせ、最新の上限ラインを引き直して判断する必要があります。 まとめ 本記事では、スキーマ分離型マルチテナント環境における DB 接続数の削減と、RDS Proxy 導入検証の顛末について紹介しました。 今回の取り組みでは、手元の idle_timeout 短縮により、副作用なく接続数を半減させる成果が得られました。一方で本命だった RDS Proxy では、事前にログから pin 率を計測して入念に試算したにもかかわらず、本番環境の複数ノードや待機仕様が重なり、想定外に接続数が倍増するという結果になりました。 同じような構成でコネクション数に悩んでいる方や、RDS Proxy の導入可否を検討されている方の参考になれば幸いです。 謝辞 一連の問い合わせに丁寧に対応いただいた AWS の担当 TAM、SA、サポートの皆さまに感謝します。 関連記事と参考資料 MNTSQ のワーカーのオートスケール(以前の記事) RDS Proxy のマルチテナント環境での多重化の制約(AWS ドキュメント) MaxIdleConnectionsPercent と idle 判定(AWS ドキュメント) MaxConnectionsPercent と ConnectionBorrowTimeout(AWS ドキュメント) RDS Proxy の pinning の条件(AWS ドキュメント)
はじめに セーフィーの中野です。 セーフィーのプラットフォームでは、主要なデータベースとしてAurora MySQLを利用しています。 弊社で起こったDBに関する改善に付随した障害とそれにどう立ち向かったか紹介したいと思います。 背景 セーフィーのプラットフォームはマイクロサービスで構成されていて、複数のサービスから同じAurora MySQLへ読み書きが集中する構成です。また、書き込みトランザクションの割合が大きいという特性もあります。 Auroraの同時接続数の上限はmax_connectionsパラメータで決まり、その既定値はDBインスタンスクラスのメモリ量によって変わりま

動画

書籍