Timee Product Team Blog

タイミー開発者ブログ

Datadogでも長期ログ保存はできる ─ Flex Logs導入でエンジニアのログ調査工数を削減しようとしている話

はじめに

株式会社タイミーのプラットフォームエンジニアリングチームに所属している徳富(@yannKazu1)です。

突然ですが、皆さんの組織では「3ヶ月前のログを見たいんですが……」という依頼が来たとき、どう対応していますか?

タイミーではDatadogをログ基盤として利用しています。Datadogは日常的なログ検索やアラート、ダッシュボードなどに使える非常に強力なツールです。一方で、ログの保存にはそれなりのコストがかかります。そのため弊社でも、コストとのバランスを考えてログの種類ごとに14日〜長くても45日程度の保持期間を設定していました。

普段の運用ではこれで十分です。ただ問題となるのは、「保持期間を超えた過去のログを検索したい」という場面が来たとき。今回はこの課題に対してDatadogのFlex Logsを導入し、コストをほぼ変えずに長期ログ検索を実現しようとしている取り組みについてお話しします。

なお、本記事で「長期ログ」と呼んでいるのは、おおむね1年程度遡って検索したいログを指します。また、今回の取り組みは、Datadog上で一定期間ログを「検索できる状態に保つ」ためのものです。ログを永続的に保存できるようになるわけではありません。Flex Logsの最大保持期間は15ヶ月(450日)で、それを超えたログはやはり削除されていきます。あくまで「現実的なコストで、実用的な長さの過去ログを検索できるようにする」取り組みだとご理解ください。

保持期間を超えたログ検索、実はけっこう大変だった

エンジニアへの依頼やインシデント対応の中で、保持期間を超えたログの検索が求められるケースは意外と多くありました。セキュリティに関する調査、外部からの問い合わせ対応、過去の操作ログの確認など、数ヶ月前のログが必要になる場面は定期的に発生します。

そうなると、いつものLog Explorerでは当然ヒットしません。代わりに、AthenaでS3上のアーカイブを直接クエリしたり、DatadogのRehydrate機能でアーカイブからログを復元したりする必要があります。最近はArchive Searchを使うケースもあります。いずれも通常とは異なるオペレーションです。

ここで問題になるのは、長期ログ検索の依頼を受けるのが、多くの場合SREではなく担当チームのバックエンドエンジニアである点です。RehydrateやAthenaでのクエリは普段の業務ではなかなか触れる機会がないため、調査のたびに手順を調べ直すことになり、想定以上に工数がかかっていました。操作方法がわからない場合はSREに相談が来ることもあって、いろんな意味で効率が悪い状態だったんですよね。

「この時間を開発に充てられたら、チーム全体の生産性が上がるのでは?」そう考えて、まずは過去に長期ログ検索が必要だったケースを洗い出してみることにしました。

過去1年のログ調査を分析してみた

過去1年間のログ調査タスクを抽出したところ、通常の依頼27件+インシデント4件=計31件が見つかりました。思っていたより多いな、というのが正直な感想です。

保持期間を何日にすればカバーできるか

まず「保持期間を何日にすれば、Datadog UIだけで調査が完結できるか」をシミュレーションしてみました。

保持期間 UI完結率 カバーしきれないケース
現行(15〜45日) 約19% 大部分がRehydrate / Athenaにエスカレート
90日(3ヶ月) 約63% セキュリティ調査 / 外部照会、一部クライアント抽出が残る
180日(6ヶ月) 約89% 残り約11%(≒3件/年)のみ
365日(12ヶ月) 約97% 残りはごく少数(年1〜2件程度)

現状だとたった19%しかDatadog UIで完結できておらず、大半がRehydrateやAthenaにエスカレートしていたことが数字で見えてきました。

ちなみにこの31件はあくまでチケットとして起票された依頼の数です。実際には、チケット化されずに個人で対応していたケースもあったはずです。また、「過去ログを確認したいが手間がかかるため諦めた」というケースもあるでしょう。そのため、潜在的なニーズはもっと多いのではないかと感じています。

180日あれば約89%をカバーできますが、後述するコストシミュレーションの結果、12ヶ月でも現状とほぼ同額に収まることがわかったため、余裕を持って12ヶ月(1年)の保持期間を採用することにしました。

調査はどのサービスに集中しているか

次に、調査がどのサービスに集中しているのかも見てみました。

少し前提を補足すると、タイミーでは主に以下のようなサービス群でシステムが構成されています。

  • クライアント画面: 企業(クライアント)が利用する画面
  • 社内管理画面: 社内のオペレーションチームが利用する管理画面
  • ワーカーAPI: ワーカー(働き手)向けアプリのバックエンドAPI

これを踏まえて集計した結果がこちらです。

サービス 主な用途 件数 シェア
クライアント画面・社内管理画面 アクセス履歴・操作ログの調査など 21件 68%
ワーカーAPI 各種オペレーションの調査など 5件 16%
その他 5件 16%

クライアント画面・社内管理画面が調査全体の68%を占めていました。 ここに長期保存を集中させれば、効率よく大半のケースをカバーできそうです。

Flex Logsとは

ここで、今回導入したFlex Logsについて説明します。

Flex LogsはDatadogが提供するログストレージの一種です。Standard Tier(従来のインデックス)とArchive(S3等への長期保存)の中間に位置し、いわゆる「Warm Storage」にあたります。

従来のDatadogのログ管理では、Standard Tierの保持期間が過ぎるとそのログはDatadog上から検索できなくなります。Archive(S3等)を設定していれば、ログは取り込み時点でアーカイブにも保存されます。ただし、アーカイブを再度検索するにはRehydrateという復元操作が必要で、手間もコストもかかっていました。また、Rehydrateを使わずにアーカイブを直接検索できるArchive Searchという機能もありますが、こちらは検索はできるものの集計やグラフ化といった分析機能が使えず、結果も専用ページでしか閲覧できないという制約があります。また、コールドストレージをスキャンする仕組みのため、普段のLog Explorerのインデックス済みログ検索と比べると速度面で劣り、利用できるUI機能も大幅に制限されるため、調査に着手するときの心理的なハードルも大きく、調査用途では不便な場面もありました。

Flex Logsはこの問題を解消してくれます。ストレージコストとクエリ(コンピュート)コストを分離することで、大量のログを低コストで長期間保持しつつ、必要なときにはLog Explorerからそのまま検索できるようにした仕組みです。最大15ヶ月(450日)の保持が可能で、Rehydrateのような復元操作は一切不要です。

個人的に一番嬉しいのは、普段使っているDatadogの操作感がそのまま使えるところです。Log Explorerの画面上部にある「Include Flex Logs」トグルを有効にするだけで、Flex Tierのログも含めて検索できます。クエリの書き方もフィルタの使い方もいつもと同じなので、新しいツールや操作を覚える必要がありません。「保持期間を超えているからAthenaで……」と切り替える必要がなくなるのは、地味ですがかなり大きな変化だと思います。

Flex Logsの課金体系

Flex Logsの課金は大きく分けて2つのプランがあります。

Flex Logs Starter はストレージとコンピュートがセットになったプランで、保存イベント100万件あたり月額$0.60の料金体系です。手軽に始められるのが特徴で、ログ量がそこまで多くない組織に向いています。

一方、Flex Logs(Scalable) はストレージとコンピュートが分離されたプランです。ストレージは保存イベント100万件あたり月額$0.05(年額請求の場合。オンデマンドだと$0.075)と非常に安価で、コンピュートはXS/S/M/Lのサイズから選ぶ形になります。大量のログを保存しつつ、クエリ頻度に応じてコンピュートサイズを調整できるため、大規模な組織ではこちらの方がコスト効率が良くなります。

※ 上記はいずれもDatadog公開価格ページの参考値です。実際の単価はリージョンや契約条件により異なるため、詳細はDatadogの料金ページまたは担当営業にご確認ください。

インデックスごとにTierを設定できる

Flex Logsの便利なところは、インデックスごとにStandard Tierの保持期間とFlex Tierの保持期間を個別に設定できる点です。

たとえば「このインデックスはStandard Tierを15日、Flex Tierを含めて12ヶ月」といった設定が可能です。Standard Tierの保持期間を短くした分のコストをFlex Logsの長期保存に回すことで、トータルのコストを抑えながら長期間のログ保持を実現できます。

設定方法

Flex Logs Starterの場合は、DatadogのLogs > Configuration > Flex Logs Controlの設定画面からセルフサーブで有効化・変更が可能です。

ただし、Scalable Compute(XS/S/M/L)を利用する場合はDatadogへの問い合わせが必要になりますので、その点はご注意ください。

Flex Logsの制限事項

Flex Logsにはいくつかの制限もあります。導入前に知っておきたいポイントです。

  • Monitorの対象にできない: Flex Tierのログに対してアラートを設定することはできません。アラートが必要なログはStandard Tierに保持する必要があります
  • Watchdog Insightsが利用不可: Datadogの異常検知機能であるWatchdogはFlex Tierのログには対応していません
  • 検索速度はStandard Tierより遅い: クエリの実行速度はStandard Tierと比較すると遅くなります。ただ、体感としてはものすごく遅すぎるというわけではなく、長期ログの調査用途であれば十分実用的なレベルだと感じています
  • コンピュートの同時実行数に上限がある: 大量のクエリが同時に走るとスローダウンやリトライが発生する場合があります

これらを踏まえると、リアルタイムの監視やアラートが必要なログはStandard Tierで保持し、「普段は見ないけど、いざというときにすぐ検索したい」ログをFlex Tierに回す、という使い分けが基本になります。

導入した構成と期待される効果

弊社ではログの種類ごとにStandard Tierの保持期間のバランスを見直しました。そのうえで、先ほどの分析で調査の68%が集中していたクライアント画面・社内管理画面のログに対して、Flex Logsを導入しました。Flex Tierの保持期間は12ヶ月です。全サービスに一律で入れるのではなく、実際に長期検索のニーズが高いサービスに絞って適用しています。

気になるコストですが、各インデックスのStandard Tier保持期間を調整して捻出したコスト削減分をFlex Logsの費用に充てた結果、トータルのログコストは当初比で約-3%。コストを増やすどころか、わずかに削減できています。

過去のログ調査実績と照らし合わせると、調査の大半を占めるクライアント画面・社内管理画面のログが12ヶ月分検索可能になるため、これまでRehydrateやAthenaに頼っていたケースの大半が、いつものLog Explorerで完結できるようになる見込みです。エンジニアの調査工数の削減に、かなり貢献できるのではないかと期待しています。

まだこれから、でも楽しみ

正直なところ、Flex Logsを導入してからまだ日が浅いので、ログの蓄積期間としてはまだまだこれからです。

ただ、1年分のログが貯まったときのことを想像すると、エンジニアからのログ調査依頼への対応は格段に楽になるはずです。「あのログ、もう消えちゃってて見られません……」という返答がなくなる未来が近づいていると思うと、素直に楽しみです。

おわりに

Datadogのログはコストが高いイメージがあるかもしれません。でも、保持期間やTierの設定を見直すだけで、同じ金額でもカバーできるユースケースが大きく広がる可能性があります。

「長期ログの検索依頼のたびにRehydrateやAthenaで対応している」「そのたびに手順を調べ直している」…そんな心当たりがある方は、Flex Logsの導入を検討してみる価値があると思います。

皆さんもぜひ一度、自社のDatadogログ設定を見直してみてはいかがでしょうか。

消えるランナーの観測基盤をどう選んだか — Datadog・マネージド・OSS を料金体系で比べて Loki + Prometheus に決めた話

はじめに

こんにちは。プラットフォームエンジニアリングチームに所属している徳富(@yannKazu1)です。

GitHub Actions のセルフホストランナーを運用していると、「あのジョブのログ、後から見たいんだけど……」という場面、けっこうありますよね。普段は気にしないんですが、いざ調査となると地味に困る。しかもランナーは ephemeral(ジョブが終わると Pod が即削除される)なので、見たい頃にはログが残っていない、という状態でした。

今回は、この「消えるランナー」のログとメトリクスを観測できるようにした話です。ただ、構築手順そのものよりも、 「Datadog・マネージド・OSS のどれを、何を基準に選んだのか」 を中心に書いていきます。

先に結論だけ書いておくと、こんな判断をしました。

  • ログ基盤:社内標準の Datadog ではなく、Loki + S3(料金体系がランナーログと相性が良く、チーム裁量で導入・撤去できるため)
  • メトリクス基盤:マネージドではなく、自前の OSS Prometheus(短期保持・内部用途なので最もシンプルで安い)
  • 収集エージェントGrafana Alloy を DaemonSet で1つ置き、ログもメトリクスも兼ねさせる

なぜそう判断したのかを、料金体系やトレードオフの考え方とあわせて書いていきます。


解決したかったこと

うちのチームでは、GitHub Actions のセルフホストランナーを EKS 上で動かしています。(詳細はこちら)困っていたのは、大きく2つありました。

① ログが残らない。 ARC(Actions Runner Controller) のランナーは ephemeral で、ジョブが終わるとその Pod は即座に削除されます。調査しようとした頃には kubectl logs を打っても pod not found が返ってくるだけです。

じゃあどうしていたかというと、問題が起きそうな状況を手元で再現しながら、kubectl logs -f でログをファイルに書き出して張り込む、という運用をしていました。

# こういうのを毎回手でやっていました
kubectl logs -f -n arc-runners <さっき立ち上がったばかりの pod> | tee debug.log

ランナーが立ち上がる瞬間を待ち構えて、消える前にログを掴む。完全に職人芸です。しんどいし、属人化の温床でした。

② ランナー群の状態が見えない。 ログは個別のジョブを追うのは得意ですが、pending のまま積み上がっている runner 数、ジョブの待ち時間、idle のランナー台数といった全体像は読み取れません。既存の Datadog でも CPU・メモリは取れていましたが、ARC 固有のメトリクス(gha_*)は取れていませんでした


技術選定:何を基準に、何を選んだか

観測したいものは決まったので、次は「何で実現するか」です。まず判断の軸を先に置きました。

  • コスト — 取り込み量に比例する SaaS の従量課金は、量が読めないと青天井になりがちです。一方、AWS 側に自分たちで持てば、保持期間やストレージクラスを調整してコストをコントロールできます
  • 導入・撤去のしやすさ(調達・承認のフリクション) — 新しい SaaS を1つ増やすのは、ベンダー審査・セキュリティレビュー・予算確保・データ取り扱い確認……と技術以前の社内手続きが乗ります。一方、OSS を自分たちの EKS 内に Helm で立てるのは、チーム裁量で完結します。「自分たちだけで始められて、ダメなら畳める」
  • 枯れたエコシステムであること — 近年はクエリやダッシュボード定義を AI に書かせる場面が日常的にあります。普及している技術ならだいたい書いてくれますが、ニッチなツールだと AI 支援を受けにくくなります。2026 年に技術選定するなら、無視できない観点だと個人的に思っています

ランナー周りは我々が単独で管理している領域で、社内標準から外れたスタックを使っても全体への影響は小さく、切り戻しも容易です。

ログ基盤:そもそも他の選択肢はなかったのか

結論としては Loki + S3 を選んだのですが、もちろん最初から絞っていたわけではありません。選択肢を整理すると、こんな感じです。

選択肢 性格 今回の評価
Datadog(社内標準 SaaS) 既に導入済み。追加導入ゼロで楽 コスト構造が量と相性が悪い
他の SaaS(Splunk / New Relic 等) 機能は十分 新規ベンダーの調達・承認コストが乗る
CloudWatch Logs(AWS ネイティブ) 既存ベンダー内で完結。承認は軽い 取り込み・スキャンの従量がログ量と相性が悪い
Loki + S3(採用) クラスタ内 OSS。S3 ストレージ中心 アクセスパターンに素直にハマる

順に、なぜそれぞれを見送ったかを書いていきます。

Datadog:素直だが、コスト構造が量と合わない

弊社では Observability は基本的に Datadog に寄せる方針で、EKS にも既に Datadog Agent が動いています。logs.enabled: true を入れれば、全コンテナログの収集がすぐ始められる。素直に考えれば「ランナーのログも Datadog でいいじゃん」です。

でも見送りました。理由は主にコストです。この基盤には社内中のワークフローのランナーが相乗りしていて、日中は大量のランナーが同時に起動します。試しに日中の10分だけログを Datadog に流したら、その10分で普段の組織全体のログ量のおよそ2倍 になりました。しかもこのログ、見るのはうちのチームだけです。

Datadog のログ課金は 取り込み(GB 単位)+ インデックス(イベント数単位)+ リテンション(保持を延ばすとインデックス単価が上がる) の合算です。取り込み単価は安く見えますが、ログを「使える」状態にする indexing が、イベント数とリテンションの両方に比例して効いてきます。自分たちしか見ないログに同じコスト構造を当てる必要はないよな、と。

他の SaaS:機能ではなく「導入のフリクション」で落ちた

Splunk や New Relic、あるいはマネージド Loki である Grafana Cloud——機能面ではどれも十分すぎるほどで、ランナーログの観測くらい余裕でこなせます。ただ、今回これらを早い段階で外したのは、機能の優劣ではなく「新しい SaaS を1つ増やすこと自体のコスト」 でした。

新規 SaaS の導入はベンダー審査・セキュリティレビュー・契約・予算確保といった社内手続きとセットです。今回観測したいのは「うちのチームしか見ない、内部用途のランナーログ」。自分たちしか見ないニッチなログのために、組織を巻き込む調達プロセスを回すのは割に合わない と判断しました。Datadog がコスト面で見送りになった時点で、「わざわざ別の新規 SaaS を……」という選択肢は自然と消えていった、というのが正直なところです。

CloudWatch Logs:「新規 SaaS」ではないが……

ここで少し悩ましいのが CloudWatch Logs です。AWS ネイティブなので「新規ベンダーの調達」問題が起きません。Fluent Bit や Container Insights を入れればすぐ始められます。導入のしやすさという軸では、Datadog の次くらいに楽な選択肢でした。

それでも本命にしなかったのは、コストの効き方です。CloudWatch Logs は 取り込み(GB 単位)と、Logs Insights でクエリするたびのスキャン量(GB 単位) に応じて従量課金されます。そのため、ランナーのように多弁なログを大量に流すと、取り込みだけでもそれなりに積み上がります。「書き込みは多いが読むのはたまに」という今回のパターンだと、Loki + S3 のストレージ中心モデルのほうが読みが立てやすい。導入のしやすさでは勝っていましたが、コスト構造で Loki に譲った形です。

残った Loki + S3 が、いちばん素直にハマった

対する Loki + S3 は、課金の中心が S3 のストレージ代+コンピュート です。Loki はログ本体を圧縮した chunk として S3 に置き、ラベルの index だけを別に持ちます。そのため、indexing のようなイベント単位の課金軸がなく、量が増えてもコストが急激に膨らみにくい構造です。

ただし Loki + S3 もタダ同然ではありません。S3 には PUT/GET/LIST のリクエスト課金がありますし、Loki はクエリのたびにキャッシュになければ S3 から chunk を読むので、調査が増えれば読み取り側のコストが乗ります。「量に比例する軸がゼロ」ではなく、効く軸が indexing からリクエスト・取り出しに移る、が正確なところです。それでも「書き込みは多いが読むのはたまに」というパターンでは、読み取り側のコストは限定的です。

主な課金軸 効き方・調整余地
Datadog Logs 取り込み(GB) + インデックス(イベント数×リテンション) index 量・保持に比例。filter 等で抑えられるが、その設計・運用がコストになる
CloudWatch Logs 取り込み(GB) + Logs Insights スキャン(GB) 書き込みが多いと取り込みが積み上がる。クエリ頻度でもスキャン課金が乗る
Loki + S3 S3 ストレージ + リクエスト・取り出し + コンピュート ストレージ中心。保持・ストレージクラスは自分で握れる

料金体系は執筆時点の公開情報をもとにした概略です。割引やコミット契約でも変わるので、最新は各サービスの料金ページでご確認を。

加えて、Loki には 導入のしやすさと k8s 相性 という後押しもありました。EKS 内に Helm で立てて完結するので、社内承認を巻き込まずチーム裁量で始められます。そして Loki は Grafana エコシステムの一部で、ノードの /var/log/pods を読む DaemonSet から取り込む構成が公式の本線として整っています。ラベルベースの検索モデルは namespace / pod / container といった k8s メタデータとそのまま対応するので、{namespace="arc-runners", container="manager"} のような絞り込みが自然に書けます。

※「導入が楽」は運用フリーという意味ではありません。「新規 SaaS の調達フリクションを回避できる」という意味での導入のしやすさで、立てたあとは自分たちで面倒を見る前提です。そのトレードオフを承知のうえで、今回の規模・用途なら割に合う、という判断でした。

メトリクス基盤:マネージド Prometheus か、自前か

ログ基盤に Loki を選んでいるので、可視化には同じ Grafana エコシステムの Grafana が相性がいい。メトリクス基盤も Prometheus で揃えれば、ダッシュボード上でログとメトリクスをシームレスに行き来できます。加えて、ARC は gha_* メトリクスを Prometheus 形式(/metrics エンドポイント)で公開しているので、これを scrape するなら Prometheus が自然な選択です。

悩んだのはマネージド(AMP 等)か自前かですが、今回は自前 OSS Prometheus を選びました。マネージドは運用を丸ごと預けられるぶんラクですが、請求の大半を占めるのが取り込み(サンプル量)で、ストレージ代はごく一部 という構造です。取り込み課金は保持期間とは独立して発生するので、保持を短くしてもコストはたいして下がりません。今回の前提は「1週間保持で十分」「見るのはうちのチームだけ」なので、自前なら EBS を1本ぶら下げるだけで済み、取り込みの従量課金も乗らない。短期保持・内部用途という条件では、素朴な自前 Prometheus が最もシンプルで安かった、という判断です。

料金体系の概略です。最新は各サービスの料金ページでご確認ください。

収集エージェント:Alloy か、それ以外か

最後に、ログとメトリクスを集めて Loki / Prometheus に送る収集エージェントです。

エージェント 特徴 状態
Grafana Alloy ログ・メトリクス・トレースを1つで扱える。Loki 公式が前提に置いている 現行推奨
Promtail Loki 公式の軽量ログ専用エージェント 非推奨(2026年3月に EOL)
Grafana Agent Alloy の前身 Alloy に統合され EOL 済み
Fluent Bit 軽量で実績豊富なログフォワーダー 現行(ただし Grafana 公式の本線ではない)

今回選んだのは Grafana Alloy です。決め手は、Loki 公式が標準エージェントとして Alloy を位置づけており、ドキュメントも Alloy 前提で整備されていて互換性の問題が起きにくいことです。加えて、ログとメトリクスの scrape を1つの DaemonSet で兼ねられる こと、将来トレースやプロファイルに拡張する余地があることも理由です。デメリットとしては、設定が独自構文(パイプライン形式)で学習コストがあること、比較的新しくコンポーネントによっては experimental なこと、が挙げられます。また、Fluent Bit は C 言語で書かれたログ専用エージェントでメモリ消費が非常に小さいのに対し、Alloy は複数シグナルを扱うぶんメモリ消費が大きくなります。


実装:どう組んだか

runner Pod (ephemeral)
controller / listener
kubelet, kube-state-metrics, node-exporter
        │
        ▼
  Alloy (DaemonSet, 各ノード)
  ├─ /var/log/pods を読む (ログ)
  └─ 各 /metrics を scrape (メトリクス)
        │
        ├──[ログ]──> Loki (Monolithic, 1 replica) ──> S3
        └──[メトリクス]──> Prometheus (1 replica, EBS 永続化)
                              │
                              ▼
                           Grafana
                           ├─ Loki データソース (ログ)
                           └─ Prometheus データソース (メトリクス)

各ノードに DaemonSet で置いた Alloy が、ログ収集とメトリクス scrape の両方を担います。ログは k8s がノードの /var/log/pods/ に書き出しているものを Alloy が読み続けて Loki へ送ります。Pod が消えてもログファイルはノードに残っているし、そもそも消える前にもう送信済み なので、ephemeral runner でも取りこぼしません。メトリクスは ARC controller-manager / listener の gha_*、kubelet(cAdvisor)、kube-state-metrics、node-exporter を scrape して prometheus.remote_write で Prometheus に送っています。

なお、今回自分が担当したのは選定・設計・検証までで、本番環境への構築は、6月に入社した小泉(@naotoko_)が担当してくれました。導入にあたってはいろいろとハマりどころがあったそうなので、その点は続編として書く予定です。お楽しみに。


まとめ

Grafana を開けば、ログもメトリクスも同じ画面から引けるようになりました。ログは {namespace="arc-runners", container="manager"} |= "error" で絞り込めますし、メトリクスは gha_controller_pending_ephemeral_runners でランナー群の状態を常時眺められます。何より、もう Pod が消える前にログを掴みにいかなくていい。あの kubectl logs -f の張り込みから解放されたのが、体感としていちばん大きいです。

今回いちばん伝えたかったのは、構築手順よりも 「何を基準に選んだか」 のほうです。Datadog か OSS か、マネージドか自前か——一般論としての正解はなくて、コスト構造・保持期間・誰が見るのか・撤去しやすさ・導入の手続きの重さ といった軸に、自分たちの状況を当てはめて初めて答えが決まります。今回は「内部用途・短期保持・自チーム管轄」という前提だったからこそ自前 OSS スタックにハマりました。全社で見るログや、自前運用のリスクを持ちたくない場面であれば、マネージドや Datadog を選ぶという選択肢も十分あると思います。

似たような観測基盤の選定で迷っている方の、判断の足しになれば嬉しいです。

日中でも安心して ALTER TABLE を流したい ─ Datadog + Devin によるロングトランザクション削減

こんにちは、タイミーでバックエンドエンジニアをしている 福井 (bary822) です。

タイミーのバックエンドは巨大な Rails のモノリスアプリケーションです。以前から「アクセスが集中する特定のテーブル(以下、人気テーブル)への DB マイグレーションが日中に通らない」という問題を抱えており、看過できないレベルになってきたため、本格的に対処に乗り出しました。

この記事では、原因となっていたロングトランザクションに対し、Datadog と Devin を組み合わせた自動修正フローで対処した話と、その設計の裏側を紹介します。

DBマイグレーション失敗のメカニズム

日常的に発生していたのは、人気テーブルへの ALTER TABLE が日中はほぼ通らない、という状況でした。原因は メタデータロック (MDL) です。

  • Aurora MySQL(8.0) では、SELECT / INSERT / UPDATE / DELETE などの DML が対象テーブルの MDL(共有ロック)を取得する
  • MDL はテーブルなどのメタデータに対して取得されるロックであり、共有 MDL が保持されている間は ALTER TABLE に必要な排他 MDL を取得できずロック待ちになる
  • MDL が解放されるまで ALTER TABLE はブロックされるため、1 本でも長い時間走るトランザクション(以下、ロングトランザクション)があると、その裏で ALTER TABLE がタイムアウトしてしまう

つまり、クエリ実行頻度の高い人気テーブルほど日中は触れなくなり、「カラムを別テーブルに切り出す」「カラム、インデックスの削除を諦める」といった、技術的制約が設計を歪める方向に力学が働き始めていました。

このマイグレーション失敗そのものに対しては、これまで strong_migrations gem のロック取得リトライ機能(lock_timeout_retries など)で何とか対策してきました。しかし、これらはあくまで成功確率を上げる ための投機的なアプローチにとどまり、根本原因であるロングトランザクションそのものには手を入れられていませんでした。

ロングトランザクション修正の方針

これまで見てきた通り、根本原因はロングトランザクションそのものです。そこで、リトライで凌ぐ運用から一歩踏み込んで、いよいよロングトランザクション自体を減らしていく方向に舵を切ることにしました。

とはいえ、現時点において目立ったロングトランザクションを頑張って解消したとしても、今後開発者が意図せず新たなロングトランザクションを生み出してしまう可能性は大いにあります。

かといってマージ前にロングトランザクションを検出するのも現実的ではありませんでした。トランザクションの長さは、多くの場合そのレコード(スキャン)量に依存しており、本番で実行してみるまで検知しにくいからです。

そこで本番リリース前の検知は諦めて、リリース後にできるだけ早く検知する方針にしました。また、検知から修正、レビューまでをできるだけ自動化し、人間は最終判断要員として介入するだけで済む状態にすることで持続可能な運用を目指すことにしました。

仕組みの全体像

上記方針をもとにいくつかのプランを検討した結果、タイミーで既に導入されていた Datadog、Devin などを組み合わせ、以下の 5 フェーズからなる自動化フローを構築しました。

  1. 準備: ActiveRecord Query Logs を有効化し、クエリの発行元がSQLコメントとして埋め込まれるようにしておく
  2. 観測: Datadog Agent から本番 DB に対して定期クエリを実行し、performance_schemainformation_schema の情報をもとに、テーブルごとにMDLを取得するロングトランザクション時間をカスタムメトリクスとして Datadog に送信する
    1. テーブルごとにMDLを保持しているトランザクションのうち、計測時点で最も時間が長い秒数を記録する
  3. 検知: Datadog Monitor にてテーブルごとに一定のしきい値を超えるロングトランザクションを検知する
  4. 修正: Datadog Monitor で発火されたアラートをトリガーとして、Datadog Workflow Automation を起動。コンテキストを整理して GitHub Actions 経由で Devin Session を起動し、修正 PR を作成
  5. レビュー: 「修正対象のコードに詳しい人」を自動的に判定してアサイン + AI による事前レビュー

ロングトランザクション修正フローの構成図

以下、それぞれのフェーズで工夫したポイントを紹介します。

準備: クエリの発行元を明らかにする

Rails 7 から標準提供されている ActiveRecord Query Logs には豊富なオプションが用意されており、クエリの発行元をコメントとして付与する対象を限定することができます。

https://railsguides.jp/v8.1/configuring.html#config-active-record-query-log-tags

タイミーでは次の設定を入れています。

config.active_record.query_log_tags_enabled = true
config.active_record.query_log_tags = %i[namespaced_controller action sidekiq_worker rake_task]

観測: ロングトランザクション発生状況を可視化する

MySQL では performance_schemainformation_schema の情報を組み合わせることで「テーブルごとのその時点で実行されている最も長いMDLを取得するトランザクション」を特定することができます。

さらにクエリコメントとして付与された発行元の情報を組み合わせることで「どこから実行されたトランザクションが何秒実行されているか」が特定可能になります。

次の例では、テーブル名を table_name 、クエリの発行元を query_source として取得しています(query_source は、実際の出力を見ながら扱いやすいように加工している)。

計測クエリ例

    SELECT
      table_name,
      CASE
        WHEN raw_sql LIKE '%namespaced_controller:%' THEN
          CONCAT(
            TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'namespaced_controller:', -1), '*/', 1), ',', 1)),
            '#',
            TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'action:', -1), '*/', 1), ',', 1))
          )
        WHEN raw_sql LIKE '%sidekiq_worker:%' THEN
          TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'sidekiq_worker:', -1), '*/', 1), ',', 1))
        WHEN raw_sql LIKE '%rake_task:%' THEN
          CONCAT('rake:', TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'rake_task:', -1), '*/', 1), ',', 1)))
        ELSE 'unknown'
      END AS query_source,
      tx_duration_seconds AS max_tx_duration_seconds
    FROM (
      SELECT
        CASE
          WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS'
          ELSE ml.OBJECT_NAME
        END AS table_name,
        COALESCE(esc.SQL_TEXT, it.trx_query, '') AS raw_sql,
        TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) AS tx_duration_seconds,
        ROW_NUMBER() OVER (
          PARTITION BY CASE WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS' ELSE ml.OBJECT_NAME END
          ORDER BY TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) DESC
        ) AS rn
      FROM performance_schema.metadata_locks ml
      JOIN performance_schema.threads th
        ON ml.OWNER_THREAD_ID = th.THREAD_ID
      JOIN information_schema.innodb_trx it
        ON th.PROCESSLIST_ID = it.trx_mysql_thread_id
      LEFT JOIN performance_schema.events_statements_current esc
        ON th.THREAD_ID = esc.THREAD_ID
      WHERE ml.OBJECT_TYPE = 'TABLE'
        AND ml.OBJECT_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
        AND TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) >= 5
    ) ranked
    WHERE rn = 1

このクエリを何かしらの方法で本番DBに対して定期的に実行し、その結果をどこかに貯めておけばロングトランザクション発生状況を可視化できます。

Datadog ではこれを簡単に行うことができました。アプリケーションが実行されているものとは別のサービスとして ECS 上で常時稼働している Datadog Agent にて定期的にクエリを実行し、その結果をカスタムメトリクスとして Datadog に送信しています。

Aurora MySQL での設定方法: https://docs.datadoghq.com/ja/database_monitoring/setup_mysql/aurora

検知: 修正対象のロングトランザクションを絞り込む

カスタムメトリクスとして1度 Datadog に取り込んでしまえば、それを使ってアラート(Datadog Monitor)を仕込むことは簡単です。

メトリクスはクエリ発行元( query_source )でグルーピングして監視するようにしました。こうすることで後続のフローに「どのクラス(ファイル)でロングトランザクションが発生したか」を渡せるようになります。

また、発行元が特定できなかったものや定期実行しないバッチなどは対象外としました。

以下が Datadog Monitor のクエリです。(!query_source:rake:tmp:* は定期実行しないバッチを取り除くためのものです)

default_zero(avg:custom.mysql.mdl_holder.max_tx_duration_by_table{account:timee-jp-prod,replication_role:writer, !query_source:unknown, !query_source:rake:tmp:*} by {query_source})

しきい値はまずはアラートがノイズにならない程度(後続の修正フローによって作成されるPRのレビューが負担にならない程度)から始めることをおすすめします。

タイミーの場合は当初数百 sec を超えるロングトランザクションが発生していたため、まずは 100 秒をしきい値として設定しました。

この時点でロングトランザクションの発生元が限られている場合は、後続の自動修正フローを構築する前に、まずはそれらだけを対象にいったん修正してみるのも効果的かもしれません。

修正: パターン集で修正アプローチを制御する

Datadog Monitor のしきい値超過をトリガーに、Datadog Workflow Automation を起動します。ここでは、Monitor から渡されたロングトランザクションに関する情報(クエリ実行元、発生時間など)を取りまとめ、GitHub Action 経由で Devin Session を起動して、詳細な原因調査と修正PRの作成を行います。

また、数百秒にわたるロングトランザクションでは、Monitor が重複してトリガーされる可能性があります。そのため、同一クエリ発行元に対して Devin Session が重複実行されないようにする必要がありました。具体的には、Session 起動時のタグに query_source を設定し、新しい Session を起動する前に既存の起動有無をチェックして、利用料金の無駄を防いでいます(初期段階ではこのチェックがなく、一夜にして数百ドルかかったことがありました)。

Devin による修正では Datadog MCP 経由で APM などの情報を分析させることで詳細な原因調査を行っていますが、しばらく運用しているうちにロングトランザクションの発生とその修正方法には一定のパターンがあることを発見しました。そこであらかじめ修正パターンをドキュメント化してレポジトリに置いておき、それを Devin に参照させるようにしました。こうすることで調査のアタリをつけやすくなりコンテキストの節約に寄与したり、実行時間を短縮することができました。

修正パターンドキュメント例

    # トランザクション内の外部APIコールを排除する
    
    ## 概要
    
    トランザクション(`with_lock` / `transaction do`)の内側で外部APIコール(HTTP リクエスト、LLM API、外部 SDK 呼び出しなど)を実行している場合、通信時間の間ずっとMDL(Metadata Lock)が保持され続けます。外部呼び出しの所要時間は秒〜分単位に及ぶことがあり、これがロングトランザクションの**最も典型的な原因**です。
    
    改善の基本方針は、外部呼び出しをトランザクション外に出して **MDL保持時間を最小化** することです。完全な除去ではなく **トランザクションスコープの最小化** を第一選択とし、ロックが守ろうとしていたデータ整合性は別の手段(ステータス管理・楽観的整合性チェックなど)で維持します。
    
    ## 問題のシグネチャ
    
    - **コード上の特徴**:
      - `with_lock do ... end` または `transaction do ... end` の内部に、HTTP クライアント呼び出し(Net::HTTP, Faraday, RestClient など)、AWS SDK 呼び出し、LLM API 呼び出し、メール送信、Slack 通知などが含まれている
      - 外部呼び出しが完了してから `save!` / `update!` が呼ばれる流れになっている
    - **APMトレース上の特徴**:
      - トランザクション開始から終了までのスパン内に、`http.client` / `aws.s3` / `openai.api` 等の子スパンがある
      - DB クエリの所要時間より外部呼び出しスパンの所要時間のほうが長い
      - 「DB時間 << 全体時間」のトレースが頻発している
    
    ## Before / After
    
    ```ruby
    # Before(外部APIコールがトランザクション内 → MDLを長時間保持)
    def process
      with_lock do
        reload
        return false unless entered?
        result = call_external_api!  # 外部APIコール → 最大120秒のMDL保持
        save_result!(result)
      end
    end
    
    # After(トランザクションを分離してMDL保持時間を最小化)
    def process
      # 短いトランザクション: ステータス確認のみ
      with_lock do
        reload
        return false unless entered?
      end
    
      # 外部APIコールはトランザクション外で実行(MDLを保持しない)
      result = call_external_api!
      save_result!(result)
    end
    ```
    
    ### 楽観的整合性チェックの追加(再enqueueパターンがある場合)
    
    対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます:
    
    - Worker A がデータ読み込み後にトランザクションを終了
    - レコードが更新され Worker B が enqueue
    - Worker A が古いデータで重い処理を続行
    - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる)
    
    このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。
    
    ```ruby
    # トランザクション内でスナップショット取得
    before_checker = SomeChecker.new(record)
    data = load_data_in_transaction
    
    # トランザクション外で鮮度確認(重い処理の前)
    current_record = Record.includes(...).find(id)
    return if before_checker.changed?(current_record)  # Worker Bに任せる
    
    # 重い処理を実行
    process(data)
    ```
    
    ## 効果
    
    - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん)
    - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される
    - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される
    
    ## 注意点・トレードオフ
    
    - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。`retry: false` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます
    - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを `processing` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応)
    - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です
    - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯(`git log` / `git blame`)を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります
   ```
    
    # Before(外部APIコールがトランザクション内 → MDLを長時間保持)
    
    def process
    
    with_lock do
    
    reload
    
    return false unless entered?
    
    result = call_external_api!  # 外部APIコール → 最大120秒のMDL保持
    
    save_result!(result)
    
    end
    
    end
    
    # After(トランザクションを分離してMDL保持時間を最小化)
    
    def process
    
    # 短いトランザクション: ステータス確認のみ
    
    with_lock do
    
    reload
    
    return false unless entered?
    
    end
    
    # 外部APIコールはトランザクション外で実行(MDLを保持しない)
    
    result = call_external_api!
    
    save_result!(result)
    
    end
    
    ```
    
    ### 楽観的整合性チェックの追加(再enqueueパターンがある場合)
    
    対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます:
    
    - Worker A がデータ読み込み後にトランザクションを終了
    - レコードが更新され Worker B が enqueue
    - Worker A が古いデータで重い処理を続行
    - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる)
    
    このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。
    
    ```
    
    # トランザクション内でスナップショット取得
    
    before_checker = SomeChecker.new(record)
    
    data = load_data_in_transaction
    
    # トランザクション外で鮮度確認(重い処理の前)
    
    current_record = Record.includes(...).find(id)
    
    return if before_checker.changed?(current_record)  # Worker Bに任せる
    
    # 重い処理を実行
    
    process(data)
    
    ```
    
    ## 効果
    
    - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん)
    - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される
    - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される
    
    ## 注意点・トレードオフ
    
    - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。`retry: false` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます
    - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを `processing` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応)
    - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です
    - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯(`git log` / `git blame`)を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります

Devin は与えられたコンテキストとパターン集を照らし合わせ、当てはまるパターンがあればこれを参考に修正。なければ新規パターンとしてドキュメントを追加します。

つまり、Devin が直せば直すほど、次の Devin が使えるドキュメントが増えていくループを、リポジトリ内で完結する形で作っています。プロンプトの調整も普通の PR ベースで行えるので、レビュアーからのフィードバックが自然と AI 側の挙動改善に還元されていきます。

レビュー: 「そのコードに詳しい人」を特定する

ロングトランザクション修正は、コードの表面的な変更だけでは判断できないケースが多く、実装の意図やドメイン背景を知っている人のレビューが不可欠です。

そこで、次の手順でレビュアーを決めています。

  1. コードオーナーが設定されていれば、その人(チーム)をレビュアーとする
  2. なければ、直近 1 年間で最も多くそのファイルに commit したユーザーとその時点での所属チーム
  3. 1 年以内に commit がなければ、特定チーム(私が所属するチーム)

これはプロンプトベースだと間違ったアサインを行うことがあったため、スクリプト化しました。

さらに、作成された PR に対して AI レビューを実行しています。Devin はレビューに対して自動で対応を行うため、人間レビュアーの目に届く時点で、AI 同士の一次すり合わせは終わっている状態になっています。

運用上のポイント

昨今、コーディングエージェントの性能向上やその周辺ツールの充実により、このような自動修正フローを簡単に構築することができるようになりました。

一方で「作った仕組みを普段の開発フローの中で無理なく運用する方法」をセットで実装することは以前に増して重要になってきたように思います。

今回のケースでは下記3点を特に意識して実装に落とし込みました。

  • 人間の目に触れる前までに無駄を削ること
  • 人間が対応する場合の工数を可能な限り小さくすること
  • 無理なく運用できるペースで継続できること

AI による相互レビューで無駄を削る

前述の AI 相互レビューでは次の観点でPRの妥当性を判断しています。

  • この変更は本当に長時間MDLを生み出すボトルネックにアプローチしているか?
  • この変更が長時間MDLを解消するための必要最小限の変更か?
  • 長時間MDLを解消しつつ、元の振る舞いを極力維持できているか?

たとえ修正によってあるトランザクションがMDLを取得する時間が短くなったとしても、それが検出されたロングトランザクションを十分に解消する(アラートが鳴らなくなるレベル)でなければ修正する価値はありません。

また、修正できたとしてもその変更範囲が膨大になってしまえばレビュアーの負荷が高くなり、いつまでもマージできないことで運用が回らなくなってしまいます。

AI レビューでこれらの観点を満たさない場合は PR を クローズする運用を行っています。

「対応しない」ことも選択肢におく

継続的な運用で意外と重要なのが、「対応しない」判断を尊重することです。

Devin が作った PR が、レビュアーの目から見て対応しないと判断されることは普通にあります。多くの場合トランザクションの範囲を小さくしたりトランザクション自体を無くすことはデータの整合性とトレードオフの関係にあるからです。

このとき単にクローズして終わりだと、次に同じクエリ発行元( query_source )でトリガーされたときにまた同じ PR が生成されてしまいます。

これを避けるために、「対応しない」ことがあるという前提で運用を考えました。また、対応しない場合の工数もできる限り小さくなるようにしています。

  • 対応しないものは query_source 単位で Ignore List として管理し、リポジトリに含めておく
    • Ignore List の実体はただの query_source のリスト(フォーマットは JSON、YAML など何でもいい)
  • レビュアーが PR に long-transaction-wontfix ラベルを付けるとGitHub Actions が起動し、それまでの commit を破棄して Ignore List に追加する

⚠️ Ignore List は query_source 単位なので、同じ query_source の別箇所で新たにロングトランザクションが発生しても検知されなくなります。厳密な検知性より運用のシンプルさを優先した割り切りで、必要があれば粒度を後から変えられるようにしています。

しきい値を下げて対象を広げていく

ここまでの仕組みは、Datadog Monitor のしきい値(初期構築時は 100 s)を超えたロングトランザクションを対象にしています。運用初期はやや保守的な値に置き、専用のダッシュボードにまとめたロングトランザクション発生状況や作成された修正 PR 数やマージ数、レビュアーの偏りを見ながら、段階的に下げていく運用を行っています。

現在では無理なく運用しながらしきい値を 50s まで引き下げられており、人気テーブルによっては MDL 保持時間が以前の半分以下になりました。

定期観測しているダッシュボード。画面上部のメトリクス(MDL保持時間)が時間が進むにつれて改善されている(短くなっている)ことがわかる

おわりに

以前投稿した Flaky Test 自動修正の取り組みとテーマは違いますが、同じようなパターンでロングトランザクションを改善する仕組みの実装と運用ノウハウを紹介しました。

tech.timee.co.jp

今回のケースでは変更によるトレードオフが発生する特性があるため、「対応しない」という選択も同じように尊重する必要がありました。そこでロングトランザクションを駆逐するのではなく、あくまでも現状を緩和することをターゲットに置いたことで現実的に持続可能な運用に落とし込むことができました。

問題の発生を検知し、自動で原因分析から修正 PR の作成まで行うパターンは、他の問題にも適用できる汎用性があります。そのため、ついつい多用したくなってしまいます。しかし、開発サイクルのどこかに人間が介在する限り持続可能な運用に落とし込むことが重要になっていることをあらためて実感しています。

最後までお読みいただき、ありがとうございました!

Aurora MySQL の GTID モード有効化と Datastream の安全な切り替え

はじめに

こんにちは。タイミーで Platform Engineer をしている小河原(@kgwryk28)です。

現在、タイミーのシステムで利用しているメインのデータベース(Aurora MySQL)のバージョンアップを進めています。前回の記事では、アップグレードに伴う SQL の互換性や性能の検証について共有しました。

この記事では、そのアップグレードと並行して取り組んでいる Aurora MySQL の GTID モード有効化 を行うにあたって直面した課題と、それぞれをどう解決したかを紹介します。 GTID やレプリケーションに詳しくない方にも読んでいただけるよう、必要な前提はその都度補足しながら説明します。

背景

きっかけは、現状利用しているAurora MySQL 3.x 系(MySQL 8.0 相当の互換性)から Aurora MySQL 8.4 系(MySQL 8.4 相当の互換性)へのアップグレードが視野に入ってきたことです。

タイミーでは Aurora MySQL のデータを BigQuery に連携するため、Google Cloud の Datastream を利用しています。 一方、Datastream の MySQL ソース対応バージョンによると、MySQL 8.4 は「GTID ベースのレプリケーションでのみサポート」 とされています。

現状 Datastream の接続方式として バイナリログの位置ベース です。そのため、8.4 以降を Datastream のソースにするには GTID ベースのレプリケーションが必須 になります。

つまり、将来のバージョン追従を見据えると、どこかで GTID ベースの接続方式へ移行することは避けられません。

現状 Aurora MySQL では GTID モードが有効化されていないため、その前段として Aurora 側で GTID モードを有効化 しておく必要があります。 これが今回 GTID モードの有効化を行う動機です。

前提

本題に入る前に、この記事を読むのに必要な前提を 3 つ押さえます。

① GTIDについて

GTID(Global Transaction Identifier)は、データベース上でコミットされた各トランザクションにクラスター全体で一意な ID を振る仕組みです。 GTIDモードが有効になるとバイナリログ(binlog)に GTID が記録されます。無効の場合はバイナリログに GTID は記録されません。

GTID は、レプリカとしてバイナリログを受け取った際に『どのトランザクションまで実行したか』を管理するために使われます。

GTIDモード が無効なマスターに対してレプリケーション接続する場合、GTIDは利用できません。そのため、バイナリログのファイルとポジションでどこまで実行されたかを管理します。

本記事では用語を統一するため、以下のように呼びます。

  • GTIDトランザクション:GTIDが含まれているトランザクション
  • 匿名トランザクション:GTIDが含まれていないトランザクション
  • GTID方式:レプリカが「どこまで実行したか」を、GTID で管理するか
  • バイナリログの位置ベース方式: レプリカが「どこまで実行したか」を、バイナリログのファイル+ポジションで管理するか

② 4種類のGTIDモード

GTIDモードには4種類の設定値があり、まとめると以下のようになります。

gtid-mode マスターとしての書き出し(出力) レプリカとしての受け入れ(入力)
OFF GTID なし バイナリログの位置ベース方式
OFF_PERMISSIVE GTID なし 両方OK(バイナリログの位置ベース方式 / GTID方式)
ON_PERMISSIVE GTID 付きで書き出す 両方OK(バイナリログの位置ベース方式 / GTID方式)
ON GTID 付きで書き出す GTID方式

注目すべき点は、OFF_PERMISSIVEON_PERMISSIVE が移行用の中間状態として設定できることです。この場合、レプリカ側は GTID方式バイナリログの位置ベース方式 のどちらでも接続できます。

Aurora MySQLでは、DBクラスターパラメータグループ の gtid-mode で 設定できます。 ただし、これは Static パラメータ であり、既存のクラスターに適用する場合、クラスター全体(すべてのDBインスタンス)の 再起動が必須 です。

③ GTIDベースの整合性に関する設定

もう一つGTID に関連する設定値として enforce_gtid_consistency という設定があります。 GTIDモードで安全にレプリケーションできないようなSQLの実行を、エラーにするか許容するかを設定できるパラメータになります。 設定値は以下の3種類から選ぶことができます。

enforce_gtid_consistency GTID 非対応クエリ実行時の挙動
OFF 制限なし
WARN 実行は許可、警告ログを出力
ON エラーにして拒否

ON で設定すると以下のようなクエリが実行時にエラーになります。(詳細: MySQL :: MySQL 8.0 リファレンスマニュアル :: 17.1.3.7 GTID ベースレプリケーションの制約)

  • CREATE TABLE ... SELECT 構文が含まれるクエリ
  • トランザクション内で CREATE TEMPORARY TABLE または DROP TEMPORARY TABLE 構文が含まれるクエリ
  • トランザクション内で普通のテーブル(InnoDBなど)と一時テーブル(Temporary Table)の同時更新が行われるクエリ

Aurora MySQLでは、DBクラスターパラメータグループ の enforce_gtid_consistency で設定できます。 ただしこれも同様に Static パラメータ であり、既存のクラスターに適用する場合、クラスター全体(すべてのDBインスタンス)の 再起動が必須 です。

解くべき 2 つの課題

GTID モードを有効化するにあたり、次の 2 つの課題に直面しました。 一つずつ深掘りしていきます。

  1. 課題A:どのようにGTID モードを有効化するか
  2. 課題B:どのように Datastream を安全に切り替えるか

課題A: どのようにGTID モードを有効化するか

再起動を回避

前述のとおり既存クラスターに対する gtid-mode の変更にはクラスター全体の再起動が必要です。 今回、GTID モードの有効化は、Blue/Green Deployments を利用しました。 元々、データベースのアップグレードはBlue/Green Deployments で行う想定でした。そこで、作成された移行先環境(Green環境)に別途パラメータグループを用意し、Green環境だけでGTIDモードを有効化します。 これにより現行環境(Blue環境)のデータベース再起動を行わずにスイッチオーバーで切り替えることができます。

Blue/Green で Green 側のパラメータを変更する

今回 Green環境で変更したのは以下の 2 つのパラメータです。

項目 Blue(現行) Green(移行先)
gtid-mode OFF_PERMISSIVE ON_PERMISSIVE
enforce_gtid_consistency OFF WARN

それぞれなぜこの値にしたのかを見ていきます。

gtid-mode の設定のうち、GTID を有効化する値は ONON_PERMISSIVE の 2 つのどちらかになります。 今回 Green環境の設定値として ON_PERMISSIVE を選んだのは、GTIDモードが 無効になっている Blue環境からの匿名トランザクションを Green環境で実行できるように許容するためです。 Green環境を ON にしてしまうと、匿名トランザクションを実行できません。そのため、Blue/Green Deployments による Blue環境 から Green環境へのレプリケーションを設定してもエラーになります。

また、GTIDベースの整合性に関する設定である enforce_gtid_consistency は、実行を許可しつつ警告ログに記録する WARN を選択しました。 ON にすると非対応クエリがエラーになり、既存クエリにも影響するリスクがあります。一方 WARN はクエリの成否を変えません。そのため、切り替え時にクエリ互換性の再検証は不要で、適用後は警告ログを基に確認できます。

課題B: どのように Datastream を安全に切り替えるか

切り替え時の課題

当初は、シンプルに次の手順を想定していました。

Datastream を一度停止し、アップグレード(スイッチオーバー)時に RDS のイベントへ出力されるGreen環境のファイルポジションを指定して再開する。

ところが、ステージング環境で検証したところ、この手順では Datastream を再開できずエラーが発生して停止してしまう ことがわかりました。

根本原因は、スイッチオーバーでクラスターエンドポイントの参照先がBlueからGreenに切り替わることです。その結果、Blue環境とGreen環境ではバイナリログのファイルとポジションに互換性がないため、Datastreamを再開できません。 Managing AWS DMS Tasks with RDS or Aurora Blue/Green Deployments の「How Blue Green switchover affects AWS DMS tasks」セクションに、バイナリログのファイル名とポジションは Blue・Green 間で異なると記載されています。これは DMS のドキュメントですが、ファイル名とポジションが変わるのは DB 側の挙動であるため、Datastream でも同様に問題になります。

【原文】

Because the binary log file names and sequence positions differ between the two instances, DMS can no longer resume from the log position it previously recorded. This causes Full Load + CDC tasks and CDC only tasks to fail or enter an error state.

【日本語訳】

2つのインスタンス間(文脈から、BlueとGreenを指している)でバイナリログのファイル名とシーケンスポジションが異なるため、DMSは以前に記録したログポジションから(キャプチャを)再開できなくなります。これにより、CDCタスクが失敗するかエラー状態になります。

この辺りは少しややこしいので補足します。

前提として、バイナリログのファイル名(例:mysql-bin.000123)とポジション(バイトオフセット)は、各クラスタのライターインスタンスがそれぞれ独立して採番します。 そのため Blue環境 と Green環境 の間では、たとえ同じ「ファイル名+ポジション」であっても、それが指している変更内容(=論理的にどこまで進んだか)は全く別物です。

実際にBlue環境とGreen環境のバイナリログのファイルをそれぞれ確認すると、同一ファイル名のバイナリログは存在するが、ファイルサイズは一致していないことが確認できます。

# Green環境
MySQL [(none)]>  SHOW BINARY LOGS;
+----------------------------+-----------+-----------+
| Log_name                   | File_size | Encrypted |
+----------------------------+-----------+-----------+
| mysql-bin-changelog.000085 |  42576033 | No        |
| mysql-bin-changelog.000086 |       157 | No        |
| mysql-bin-changelog.000087 |       157 | No        |
| mysql-bin-changelog.000088 |    238579 | No        |
| mysql-bin-changelog.000089 |    840588 | No        |
| mysql-bin-changelog.000090 |    168156 | No        |
| mysql-bin-changelog.000091 |  134237510 | No        |
| mysql-bin-changelog.000092 |  134217852 | No        |
| mysql-bin-changelog.000093 |  134221756 | No        |
| mysql-bin-changelog.000094 |  112130632 | No        |
+----------------------------+-----------+-----------+

# Blue環境
MySQL [(none)]> SHOW BINARY LOGS;
+----------------------------+-----------+-----------+
| Log_name                   | File_size | Encrypted |
+----------------------------+-----------+-----------+
| mysql-bin-changelog.000085 | 134602837 | No        |
| mysql-bin-changelog.000086 | 134429601 | No        |
| mysql-bin-changelog.000087 | 134218551 | No        |
| mysql-bin-changelog.000088 | 134221393 | No        |
| mysql-bin-changelog.000089 |  13935369 | No        |
+----------------------------+-----------+-----------+

一方で Datastream は、停止した時点で「Blue環境 のファイル名とポジションで どこまで読んだか」を記憶しています。切り替え後はエンドポイントの参照先が Green環境 に変わるため、Datastream が握っている Blue環境 のファイル名とポジションを Green環境 のバイナリログに対して解釈してしまうことになります。両者に対応関係がない以上、これは正しく再開できません。

しかも厄介なのは、Green環境のファイルとポジションを指定した場合、ズレた地点から再開してしまいます。ズレ方によって、データの不整合が発生するパターンが2パターンに分かれます。

  • ① 重複適用:Green 環境の同じファイルポジションが、実際に同期済みの地点より「手前」を指していた場合。すでに適用済みのデータをもう一度流してしまう。
  • ② 欠落(スキップ):Green 環境の同じファイルポジションが、まだ同期していない地点より「先」を指していた場合。未同期のデータが飛ばされてしまう。

問題点をまとめると以下のようになります。

  • Blue/Green Deployments で作られた Blue環境と Green環境では、バイナリログのファイルとポジションは一致しない
  • RDS のイベントには、切り替え時点の Green環境 のバイナリログのファイルとポジションが出力される。切り替え時点の Green環境 のポジションから、対応する Blue環境 のポジションを探すのは困難
  • Datastream を再開する際に、Green環境のポジションを指定すると重複適用または未適用のデータがスキップされてデータの不整合が発生してしまう。

つまり、「Blue/Green Deployments による切り替え後に指定すべきファイルとポジションがわからなくなってしまう」という問題でした。

解決した切り替え手順

2026/07/15 一部訂正とお詫び

本文中に「Green環境では GTID方式 で接続しておく」とありましたが、誤りがありました。
正しくは「Green環境では バイナリログの位置ベース方式 で接続しておく」となりますので、該当箇所を修正いたしました。

Datastream を GTIDモードで接続する場合は、接続先のAurora MySQL で gtid-modeON に設定しておく必要があります。
詳細は CDC 用に Amazon Aurora MySQL データベースを構成する | Datastream | Google Cloud Documentation をご確認ください。

ご迷惑をおかけした読者の皆様に深くお詫び申し上げます。

そこで、考え方を変えて 「Datastream が参照するクラスターを固定化する」方針にしました。

Blue/Green Deployments を使用した Datastream の切り替え手順
Blue/Green Deployments を使用した Datastream の切り替え手順

各 Datastream が 同じクラスターのバイナリログを参照し続けられる よう、接続先を「クラスターエンドポイント」から「ライターエンドポイント(特定インスタンス固定)」に切り替える方針にしました。手順は「切り替え前」「切り替え時」「切り替え後」の3段階です。

Blue/Green Deployments による Green環境への切り替え前

  • Green環境経由の Datastream の別系統をあらかじめ作成しておく。Green環境では GTID方式 バイナリログの位置ベース方式 で接続しておく
  • 既存の Blue / Green それぞれに接続されている Datastream を 一時停止 しておく。

Blue/Green Deployments による Green環境への切り替え後

  • Datastream のストリームの接続プロファイルの接続先ホストを変更して再開する。
    • Blue 系統:クラスターエンドポイント → Blue(切り替え前クラスター)のライターエンドポイント に変更
    • Green 系統:Green のライターエンドポイント → クラスターエンドポイント に変更

事後作業

  • Datastream の出力先テーブルを参照しているアプリケーションの参照先を Blue環境から Green環境へ切り替える

この手順により、スイッチオーバー後も 各Datastream は同一クラスターを参照し続けられます。その結果、ファイルとポジションの不一致を回避でき、安全に切り替えられます。

Blue/Green Deployments で切り替えた後、Blue環境はクラスターから切り離されるため、変更内容はBlue環境には反映されません。ただし、切り替え後のGreen環境を参照元としてBlue 側からレプリケーション接続を張れば、Green環境の変更内容をBlue環境へ同期できます。 ロールバック用クラスターのレプリケーション方法は、以前の記事である Aurora MySQLのアップグレード後ロールバック方法を検討してみたAWSの公式ブログ に書かれているため、ここでは説明を割愛します。

これらの手順により、Datastream によるレプリケーション接続を安全に切り替えることができます。

まとめ

今回は、Aurora MySQL の GTID モード有効化方法と、Datastreamを安全に切り替えるための方法を紹介しました。

今回の移行が完了してもゴールではなく、この先には gtid-mode = ON への引き上げ(匿名トランザクションの完全な消化、enforce_gtid_consistency = ON 化)が続きます。

また徳富さん(@yannKazu1) さんが 並行してDatastream 関連のネットワーク周りのリアーキテクチャも行なっております。 詳細は以下の資料をご覧ください。

tcpdump で追う Datastream 障害調査と Transit Gateway × VPN のリアーキテクチャ設計

もし、今回の自分と同じように Aurora MySQL の GTID 化を検討している方にとって、この記事が何らかの参考になれば幸いです。

参考リンク

成長サービスのDB負荷に向き合う、Platform Team と Stream-aligned Teamで取り組む性能改善

はじめに

こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。

自分が所属しているチームでは、週一で「観測会」を実施しています。 サービスの負荷状況が分かるダッシュボードを確認したところ、ある時期から Aurora MySQL の Reader CPU 使用率が大きく上昇していることに気づきました。

原因になりそうな処理は見えてきましたが、関連する機能はすでに利用されていたため、単純にリバートできる状況ではありませんでした。そのため、インスタンスを追加して一時的にしのぎつつ、根本的な改善を進める必要がありました。

この記事では Reader CPU 急増への対応を題材に、Platform Team が Datadog で事実を収集し、Stream-aligned Team(機能開発チーム)と協力して性能改善を進めた流れを紹介します。

1. Reader CPU が急増し、単純なリバートでは解決できなかった

ある時期から、Aurora MySQL の Reader CPU 使用率が通常時よりも上昇しました。

まずはサービスへの影響を避けるため、Reader インスタンスをスケールアップし、必要に応じて追加する暫定対応を行いました。ただし、これはあくまで暫定対応です。この状態が長期化するとコスト面で健全ではないため、並行して根本原因の特定に着手しました。

最終的にポイントだったのは、原因である機能を簡単に止められる状況ではなかったことです。その機能はすでに利用されており、利用増加に伴って、もともと非効率だった処理が顕在化した形でした。

そのため、機能として必要な振る舞いを保ちながら、処理をどう改善できるかを見極める必要がありました。

2. Datadog Notebook で事実をまとめ、温度感を揃えた

最初に行ったのは、Datadog Notebook に事象をまとめることでした。

CPU が高いこと、重そうなクエリがあること、暫定対応した時期、関係していそうな処理といった情報が Slack 上に散らばったままだと、認識が揃いません。

特に、「どれくらい危ない状況なのか」「暫定対応の結果どうなったのか」「恒久対応をどう進めるべきか」「どのチームに何を相談したいのか」を一箇所にまとめないと、適切な温度感が伝わりづらくなります。

そこで Datadog Notebook に、以下のような情報を集約しました。

観点 Notebook で整理した内容 添付したウィジェット・リンク
起きていること Reader CPU が通常時より大きく上昇し、いつ障害になってもおかしくない水準まで到達していた Reader CPU の推移が分かるグラフ
負荷の変化 特定クエリの実行頻度が大きく増加し、1回あたりの実行時間や走査行数も高い状態だった クエリ実行頻度、実行時間、走査行数のグラフ
暫定対応 サービス影響を避けるため、Reader インスタンスを追加して一時しのぎしていた 対応時刻やインスタンス追加・入れ替えの時系列
原因の仮説 ある機能の作成件数増加と、クエリ自体の構造的な重さが重なって Reader CPU に影響していそうだった 作成件数増加の時系列、DBM の Query Signature へのリンク
問題の分解 負荷に寄与しているクエリは複数あり、求人作成・更新時に走るものと、毎時の定期バッチで走るものに分けて確認した Query Signature ごとの DBM リンク、APM のトレースリンク
改善方針 一方はクエリ自体の書き換えが必要で、もう一方は既存の結果テーブルを参照する形に変えられる可能性があった 発行元の処理名、トレース、DBM / APM のリンク
相談したい判断事項 既存の結果テーブルを使うと対象者の範囲が変わる可能性があるため、機能仕様として許容できるかを Stream-aligned Team に確認したかった 判断に必要な調査メモと、根拠となる DBM / APM のリンク

このとき意識したのは、単にダッシュボードのようにグラフを列挙するのではなく、関係者が判断できる形に情報を並べることです。

CPU 使用率や重いクエリのメトリクスだけでは、「いま何が起きているのか」は分かっても、「どれくらい急ぐべきか」「誰に何を相談したいのか」「暫定対応で耐えられるのか」は伝わりません。

また、原因仮説は Platform Team 側で、Datadog から見えた処理名や実行タイミングを手がかりに、関連する変更履歴や実装を確認しながら立てていきました。その際はAI も活用し、どの機能・処理と関連していそうか当たりをつけました。

3. DBM / APM で重い Query Signature と呼び出し元を特定した

Reader CPU の上昇など、データベースのリソース使用状況の悪化は Metrics で把握できます。しかし、それだけでは何を直せばよいかは分かりません。

そこで次に、どのクエリが Reader に負荷をかけているのかを調べました。

mysql.queries.time と query_signature

ここで見たのが、Datadog DBM の mysql.queries.time メトリクスと、そのラベルである query_signature です。

mysql.queries.time は、正規化されたクエリごとの実行時間を表すメトリクスです。ざっくり言うと、1回あたりの実行時間 × 実行回数 に近い値として見ることができます。

query_signature は、SQL の具体的な値を取り除いて正規化したクエリの識別子です。条件に入る ID や日時だけが異なる SQL を、同じ種類のクエリとしてまとめて確認できます。

ただし、これは直接的に「クエリ単体の性能」だけを表すものではありません。1回あたりは速いクエリでも、実行回数が急増すれば mysql.queries.time は増えます。

逆に、1回あたりが遅いクエリでも、ほとんど実行されなければ全体負荷への寄与は小さく見えます。

一方で、今回のように「Reader CPU が上がっている」という事実に対して、「どの種類のクエリが寄与していそうか」を特定するには、非常に有用なメトリクスです。

調査は、次の流れで進めました。

  1. Metrics で Reader CPU の上昇タイミングを見る
  2. mysql.queries.time から相関関係のあるquery_signatureを見る
  3. Count / AVG Duration / Rows Scanned を見て、実行回数と1回あたりの重さを確認する
  4. DBMのUpstreamからAPMをたどり、そのクエリがどの処理から呼ばれているのかを確認する

この調査により、クエリの種類と、その呼び出し元の処理を特定できました。

具体的には、ある機能に関連する非同期処理から発行されるクエリが増加していました。対象データ量の増加に伴って、複数の条件を組み合わせた抽出処理が重くなり、Reader 負荷に大きく寄与している状態でした。

4. Datadog だけでは分からないことを Stream-aligned Team と確認した

どのクエリが重いか、いつ実行されているか、どの処理から呼ばれているかは分かりました。

しかし、Datadog だけでは以下は分かりません。

  • その処理は何の機能のために存在しているのか
  • 抽出対象のデータは、機能上どのような意味を持つのか
  • 他のデータやキャッシュされた結果が使えるのか
  • 機能利用がなぜ増えているのか
  • 最終手段として、機能制限や一時停止が取り得るのか

ここで、機能開発を担当する Stream-aligned Team のドメイン知識が必要になりました。

Platform Team 側では Datadog を見ながら負荷の原因を整理し、Stream-aligned Team 側では仕様や処理の中身を確認しました。Slack やハドル、Datadog Notebook で状況を共有しながら、「どの処理が負荷に寄与しているのか」「仕様を壊さず処理を変えられるのか」「もし改善しない場合に、停止などの措置は取り得るのか」を相談しました。

今回重要だったのは、技術的な事実だけでなく、「この状況をどれくらい危険と見ているか」を共有することでした。

Platform Team では、今回の CPU 負荷上昇に対して暫定対応として Reader インスタンスのスケールアップと追加を行いました。 この対応により CPU 使用率を一定以下に抑えることができたため、サービス影響を抑えることはできました。 しかし、依然として以下の問題を抱えていました。

  • CPU負荷上昇の原因となっているクエリ発行元の機能の利用者は増加傾向にあり、サービス影響が生じる可能性がある。
  • スケールアップ対応により、インスタンス使用量のコストが対応前より増加している。もともと想定していたDBにかかる費用を超過しているため、コストを抑えたい。

利用増は外部要因の影響もあり、こちらで直接コントロールしづらい状況でした。この背景を踏まえ、機能開発を担当していたチームに、数日で直す必要があることを伝えました。

一方で、機能を担当するチームから見ると、単に「このクエリが重い」と言われても、それがどれくらい急ぎなのか、すぐ直すべきなのか、仕様変更や一時停止まで検討すべきなのかは判断しづらいはずです。

このときの会話は、観点ごとに整理すると以下のようになります。

観点 Platform Team が確認したこと Stream-aligned Team と確認したこと
認識合わせ Notebook にまとめた経緯に認識齟齬がないか 認識齟齬がなく正しそうであること
背景・要因 負荷増加がどの処理と関連していそうか 機能利用の増加が関係ありそうなこと
改善方針 重い処理を改善できないか クエリの修正が可能そうであること
ワーストケース 機能制限や一時停止を最終手段として取り得るか 事業部との調整が必要そうであること

この会話によって、単なる技術調査ではなく、障害リスク・コスト・仕様影響・事業影響について議論できるようになりました。

最終的には、Stream-aligned Team が仕様上の判断をしたうえで、重い抽出処理を避ける方針やクエリ自体の改善を進めてくれました。

改善前は、複数の条件に該当する対象者を SQL 側で一度に抽出していました。その結果、同じ対象集合を使うサブクエリが複数回展開されたり、複雑な条件が組み合わさったりしていました。対象データ量や実行頻度が増えるにつれて、この構造が Reader に大きな負荷をかける要因になっていました。

改善後は、仕様の互換性を保ちながら、処理をいくつかの小さな取得に分け、アプリケーション側で結果を統合する形に変更しました。これにより、SQL 側で複雑な条件を一度に処理させる必要がなくなり、Reader にかかる負荷を抑えられるようになりました。

5. 改善後も Datadog で効果を確認した

クエリを修正すると、Datadog DBM 上の Query Signature が変わります。

そのため、改善前後を見るときに、単純に同じ Query Signature の before / after だけを見ても判断できません。今回も、修正後に対象クエリが分割され、新しい Query Signature が増えました。

そこで、以下の観点で改善効果を確認しました。

  • 変更前の重い Query Signature が減っているか
  • 変更後に増えた Query Signature の AVG Duration は許容範囲か
  • Total Duration は減ったか
  • 対象となる処理の実行時間は改善したか
  • Reader CPU 使用率に変化があったか

結果として、修正前に時間がかかっていたケースが、修正後は主要なクエリで大きく改善していることを確認できました。また、翌日に改めてメトリクスを確認すると、デプロイ以降で Reader CPU にも改善傾向が見られました。

一方で、Reader 全体の実行クエリ数も増えていたため、残る負荷はクエリ単体の問題ではなく、ワークロードの増加として切り分けました。ここまで判断できると、次はアプリケーション改善ではなく、キャパシティやコストの議論として扱えます。

6. まとめ

今回の対応で重要だったのは、重いクエリを見つけることだけではありませんでした。

本番サービスでは負荷の原因になっている機能は単純に止められないことがあります。だからこそ、Datadog DBM / APM / Metrics で負荷を分解し、Datadog Notebook に時系列・メトリクス・仮説・暫定対応・相談したい判断事項をまとめることで、関係者が同じ事実を見ながら会話できる状態を作りました。

そのうえで、Platform Team が整理した事実やリスク、温度感を共有し、受け取った Stream-aligned Team は機能仕様・実装方針・事業影響を踏まえて改善を進めてくれました。

性能課題は、重いクエリを見つければ終わるものではありません。どの負荷が危険で、どの判断が必要で、誰のドメイン知識が必要なのかを整理し、事実を整理して進めることが重要です。

タイミーではこのように、Platform Team と Stream-aligned Team が協力しながら、サービスの成長に伴って生まれる性能課題に向き合っています。

インスタンスサイズを「上げた」のに、Auroraのコストが「下がった」話

はじめに

こんにちは。プラットフォームエンジニアリングチームに所属している徳富(@yannKazu1)です。

「インスタンスサイズを上げたらコストが下がりました」と言うと、だいたい「?」という顔をされます。スペック上げたらお金かかるに決まってるだろ、と。自分もそう思っていたので気持ちはわかります。

この記事では、Amazon Aurora のReaderインスタンスを db.r7g.8xlarge から db.r7g.12xlarge にスケールアップした結果、I/Oコストが大幅に減り、トータルのAuroraコストがむしろ下がった話を書きます。バッファプールの仕組みと、判断の経緯もあわせて紹介します。

前提:私たちのAurora構成

まず、当時のAurora(MySQL互換)クラスターの構成を簡単に紹介します。

インスタンス クラス vCPU メモリ プロセッサ 役割
reader-1 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader
reader-2 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader
reader-3 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader
writer-candidate-1 db.r5.24xlarge 96 768 GiB Intel Xeon Writer Candidate / Reader
writer-candidate-2 db.r5.24xlarge 96 768 GiB Intel Xeon Writer

Readerは3台構成で、3つのAZに分散配置しています。加えて、writer-candidate-1もWriter Candidateとしてフェイルオーバーに備えつつ、Readerとして読み取りクエリも処理しています。つまり、読み取りは実質4台で分散している構成です。

バッファプールとは何か

本題に入る前に、今回のキーワードである「バッファプール」について少し説明させてください。

MySQLやAurora(MySQL互換)にはInnoDB バッファプールと呼ばれるメモリ領域があります。これはデータベースが頻繁にアクセスするデータやインデックスをメモリ上にキャッシュしておく仕組みです。

データベースがクエリを処理するとき、必要なデータがバッファプールに載っていれば、メモリから直接読み取れるので非常に高速です。一方、バッファプールに載っていないデータが必要になった場合は、ストレージ(ディスク)からデータを読み込む必要があります。これがストレージI/Oです。

ここで重要なのがバッファプールヒット率という指標です。これは「データベースが必要としたデータのうち、バッファプールから取得できた割合」を示します。

  • ヒット率100%:すべてのデータがメモリ上にあり、ディスクへのアクセスが発生しない理想的な状態
  • ヒット率99.9%:一見ほぼ完璧に見えますが、0.1%のミスが積み重なると、秒間数千〜数万回のI/Oリクエストに化けることがあります

Aurora ではバッファプールのサイズはデフォルトでインスタンスメモリの75%が割り当てられます。つまり、256 GiBのメモリを持つ db.r7g.8xlarge では、約192 GiBがバッファプールとして使われる計算です。

そしてAuroraの料金体系において見逃せないのが、I/Oコストは従量課金であるという点です。ストレージへの読み取りリクエスト(Read IOPS)が増えれば増えるほど、課金額も増える。つまりバッファプールヒット率の僅かな低下が、じわじわとコストに効いてくるわけです。

異変に気づく:「たった0.1%」の落とし穴

事の発端は、以前から認知しつつも対応の優先度を上げきれていなかった、バッファプールヒット率の数字でした。

Readerインスタンスのバッファプールヒット率は、もともと99.9%前後で推移していて、100%には達していませんでした。「99.9%ならほぼ問題ないのでは?」という感覚で、それまで明確な対策は打てていなかった、というのが正直なところです。

しかし、弊社のサービスは非常にアクセス数が多く、それに伴うクエリの総量も膨大です。母数が大きいと、たった0.1%のバッファプールミスでも、ストレージへの問い合わせ回数は凄まじい量になります。

実際にCloudWatchで AuroraStorageReadIOsPS(Aurora ストレージへの秒間読み取りI/O数)を確認すると、reader-1では最大約13,000 IOPS、平均でも約7,488 IOPSに達していました。

そしてこの数字は1インスタンスあたりの値です。同じスペックのReaderが3台あるため、クラスター全体では単純計算で最大約39,000 IOPS、平均でも約22,000 IOPSものストレージ読み取りが発生していたことになります。AuroraのI/Oコストは従量課金なので、この3台分のI/Oがそのままコストに跳ね返っていました。

「パラメータをいじる」という選択肢はなかったのか

「バッファプールを大きくしたいなら、innodb_buffer_pool_size パラメータを変更すればいいのでは?」という発想は当然あります。

たしかに、Auroraのパラメータグループからバッファプールサイズを変更すること自体は可能です。デフォルトではインスタンスメモリの75%が割り当てられていますが、これを80%や85%に引き上げれば、インスタンスサイズを変えずにバッファプールを拡大できます。

しかし、この方法にはリスクがあります。バッファプール以外にも、MySQLの内部処理やOS、各種バックグラウンドプロセスがメモリを使用しています。バッファプールの比率を引き上げすぎると、これらに必要なメモリが不足し、最悪の場合OOM(Out of Memory)でインスタンスがクラッシュする可能性があります。

本番環境のReaderで「メモリ配分を攻めた結果、突然落ちました」では笑えません。デフォルトの75%という設定は、こうしたリスクを考慮した上での安全なバランスであり、ここを変更するのは慎重にならざるを得ませんでした。

背中を押した「CPU 100%」

パラメータ変更は避けたいが、状況は悪化していく——そんな中、事態は急変しました。

もともとパフォーマンスの良くないクエリを使う機能が存在していたのですが、それまではあまり利用されていませんでした。しかし、ビジネスサイドの施策推進により、その機能の利用が急増。約2週間のうちにDBのCPU使用率が急上昇し、ついにCPU 100%に張り付く場面が出てきたのです。

ここに至って、インスタンスサイズの引き上げを決断しました。db.r7g.8xlarge(256 GiB)から db.r7g.12xlarge(384 GiB)への変更です。

メモリが256 GiBから384 GiBに増えることで、バッファプールもデフォルトの75%換算で約192 GiBから約288 GiBへと拡大されます。vCPUも32から48に増えるため、純粋なCPU処理能力の向上も期待できます。

当然ながら、インスタンス単価は上がるので、コスト増は覚悟の上での判断でした。

予想外の結果:コストが「減った」

12xlargeへの変更後、メトリクスの変化は劇的でした。

まず、バッファプールヒット率がほぼ100%に回復しました。これは期待通りの結果です。バッファプールの容量が増えたことで、これまでキャッシュに載りきらなかったデータもメモリ上に保持できるようになったためです。

それに伴い、AuroraStorageReadIOsPS が急激に減少しました。バッファプールから直接データを返せるようになったため、ストレージへの読み取りリクエストがほとんど発生しなくなったのです。

そしてここからが本題です。具体的なコストの数字を見てみましょう。

まず、インスタンスの日額単価の比較です。

インスタンスクラス 日額単価
db.r7g.8xlarge(3台分) $383.33
db.r7g.12xlarge(3台分) $574.92

Reader 3台合計で見ると、インスタンス料金は約$192/日増加します。普通に考えれば「やっぱり高くなるじゃないか」という話です。

しかし、スケールアップ前のI/Oコスト(APN1-Aurora:StorageIOUsage)を見ると、毎日$350〜400ほどが発生していました。3台のReaderがそれぞれ秒間数千IOPSものストレージ読み取りを行っていた結果、I/Oの従量課金だけでこれだけの金額が積み上がっていたのです。

12xlargeへの変更後、このI/Oコストは約$80/日まで激減しました。以下のAuroraクラスター全体のコスト推移グラフを見ると、変化が一目瞭然です。

6月15日前後を境に、紫色(db.r7g.8xlarge)がオレンジ(db.r7g.12xlarge)に置き換わると同時に、それまで毎日存在していた赤色の帯——StorageIOUsageがほぼ消滅しています。インスタンス単価の上昇分よりも、I/Oコストの削減幅の方が大きかったため、結果としてAuroraのトータルコストはスケールアップ前よりも下がりました

「スペックを上げたのにコストが下がる」という一見矛盾した結果ですが、Auroraの料金構造を考えれば理にかなっています。Auroraのコストは大きく分けて「インスタンス料金」と「I/O料金」で構成されており、I/Oが大量に発生している状態では、I/O料金がインスタンス料金を圧迫するほど膨れ上がることがあります。インスタンスサイズの引き上げでバッファプールを拡大し、I/Oを削減することで、増えたインスタンス料金以上のI/Oコスト削減が実現したというわけです。

その後の話:クエリチューニングとさらなるコスト削減の可能性

インスタンスサイズの引き上げで急場を凌いだ一方で、根本原因であるパフォーマンスの悪いクエリについても手を打ちました。DBに悪影響が出始めてから2〜3日でチューニングを実施し、現在はCPU使用率も安定した状態になっています。

こうなると面白いのが、インスタンスサイズを上げたことに加えてクエリも改善されたため、リソースに余裕が生まれているという点です。現在の負荷状況を見ると、Readerの台数を減らせる可能性すら出てきています。

整理すると、こんな流れになります。

インスタンスサイズを8xlargeから12xlargeに上げたことでI/Oコストが大幅に削減され、サイズアップ前よりもトータルコストが減少しました。さらにクエリチューニングによってCPU負荷も安定し、もしReaderの台数削減が実現すれば、インスタンス料金そのものもさらに圧縮できることになります。

台数削減についてはまだ検討段階ですが、「CPU 100%で焦っていたあの頃」から考えると、コスト削減とパフォーマンス改善の両方が実現しつつある今の状況は、結果的に良い方向に転がったと言えそうです。

なお、コスト推移のグラフを見て「なぜオンデマンドで使っているのか」と気になった方もいるかもしれません。現在、Readerインスタンスのr8g系への移行を予定しており、移行が完了したタイミングでReserved Instancesを購入する計画です。インスタンスファミリーが変わる前にRIを買ってしまうと無駄になるため、あえて今はオンデマンドのままにしています。

学んだこと

今回の経験から得られた教訓をいくつか共有します。

バッファプールヒット率の「0.1%」を甘く見ない。 99.9%と100%の差は数字の上では僅かですが、大量のリクエストを処理するシステムでは、この0.1%が秒間数千回のストレージI/Oに化けます。ヒット率が100%から僅かでも低下し始めたら、それはバッファプールの容量が限界に近づいているサインです。

コスト最適化は「安いインスタンスを選ぶ」だけではない。 クラウドの料金体系は複合的です。インスタンス料金だけでなく、I/O料金、ネットワーク転送量、ストレージ料金など、複数の要素が絡み合っています。ひとつの要素を抑えることに固執すると、別の要素が膨れ上がるケースがあります。今回のように、インスタンスコストを「上げる」ことがトータルコストの「削減」につながることもあるのです。

パラメータ変更は慎重に。 バッファプールサイズの拡大はパラメータ変更でも可能ですが、本番環境でデフォルトから逸脱した設定を入れるのは、想定外のOOMなど別のリスクを抱えることになります。インスタンスサイズの変更であればデフォルトのバランスを保ったまま全体的なリソースを増やせるため、より安全なアプローチだと考えています。

おわりに

今回は「インスタンスサイズを上げたらコストが下がった」という、やや直感に反する事例を紹介しました。

振り返ってみると、バッファプールヒット率の僅かな変化に気づき、その背景にあるI/Oコストの構造を理解していたからこそ、適切な判断ができたと思います。ただ漫然と「CPUが高いからスペックを上げよう」ではなく、メトリクスを観察し、原因を分析し、コスト構造を踏まえた上で意思決定する。地味ですが、こうした積み重ねがインフラ運用の質を上げていくのだと改めて感じました。

同じようにAuroraのI/Oコストやバッファプールヒット率で悩んでいる方の参考になれば幸いです。

データベースのバージョンアップの不安を解消。Insight SQL Testingを活用した検証手順

はじめに

こんにちは、初めまして。今年3月に入社し、タイミーで Platform Engineer をしている小河原(@kgwryk28)です。

現在、タイミーのシステムで利用しているメインのデータベース(Aurora MySQL)のバージョンアップにおける検証を進めています。

データベースのアップグレードで気になるのは、「今まで動いていたSQLが新バージョンでもそのまま動くのか」「アップグレードによって遅くなるクエリはないのか」という点です。

アップグレード後に互換性の問題や性能劣化が本番で発覚すると、影響は計り知れません。そのため、バージョンアップ範囲内の各バージョンの変更内容(changelog)を調べるだけでは不十分だと感じました。

そこで今回は、Insight SQL Testing を使って検証を実施した際の工夫(期間の絞り込み・重複排除・結果のトリアージ)や検証手順を中心に共有したいと思います。

Insight SQL Testing とは?

「Insight SQL Testing」は、株式会社インサイトテクノロジーが提供する、データベース移行やバージョンアップ時の SQL テストを自動化・効率化するソフトウェアです。

移行元のデータベースで実行されていたSQLを、移行先のデータベースでも実際に使えるか検証できます。そのため、異種間の移行やバージョンアップ時に、SQLの互換性やパフォーマンスを確認できます。

実環境の SQL クエリを使用することで、本番環境に近い網羅性で「動くか/遅くならないか」を検証できます。

仕組みとしては以下のようになっています。

Insight SQL Testing を利用した検証の流れ

移行元のデータベース(ソース DB)と移行先のデータベース(ターゲット DB)の 2 つの DB を検証用に作成しておき、それぞれに対して同じクエリを実行します。

今回はデータベースのバージョンアップを行うため、ソース DB を現状の本番環境と同一のバージョンである Aurora MySQL 3.04.2(MySQL 8.0.28 相当)、ターゲット DB を移行先の Aurora MySQL 3.10.3(MySQL 8.0.42 相当)で作成します。

クエリの実行結果(成功/失敗・返ってきた値・実行時間)の突き合わせにより、バージョン間での互換性(片方の DB でだけ失敗するクエリ、結果が食い違うクエリ)、性能劣化(移行先のデータベースだけパフォーマンスが劣化するクエリ)を洗い出すことができます。

なお、入力となる実環境の SQL クエリには、元々 S3 に保存している監査用の Audit Log を使用しました。監査ログのフォーマットはInsight SQL Testingが要求するフォーマットと異なるため、作業用EC2インスタンスであらかじめ変換してから取り込みました。

検証作業の流れ

今回の検証は、おおまかに次の流れで進めました。

  1. 検証環境を構築
  2. 検証用の SQL セットを準備
  3. Insight SQL Testing による検査(アセスメント)を実施
  4. 検査結果を元に SQL の互換性、パフォーマンス劣化クエリの抽出
  5. 検査結果を元に評価レポートの作成

検証上の課題

検証にあたって直面した課題が、検証対象期間での全量テストは現実的に間に合わないという問題でした。

当初、検査対象の期間は 1ヶ月を想定していました。 これは、週次・月次で実行される定期バッチがあるため、クエリの網羅性を担保するためです。

実際に確認してみたところ、以下のことがわかりました。

  • 1 日あたり約 1 億行のクエリが流れている
  • 1 時間分の量を流すだけでも 24 時間かかる。このペースで 1ヶ月分(約 31 億行)を全量テストすると、約 2 年かかる計算になる

当然これでは時間がかかり過ぎてしまいます。そのため、アセスメントの実行時間を短縮するための対策が必須でした。

SQL 互換性検査とパフォーマンス検査を分けて考える

まず、SQL 互換性検査とパフォーマンス検査を分けて実施する方針にしました。目的が違うので、必要なクエリの範囲や確認方法も変わるからです。

  • SQL 互換性検査 の目的: アップグレード後の構文エラーの検出
  • パフォーマンス検査 の目的: アップグレード後に性能が劣化するクエリがないか確認したい

データベースに対するアクセス特性による網羅性

タイミーのシステムでは、データベースに対するアクセス特性を大きく以下の 2 つに分類することができます。

  1. 常時アクセスが発生するもの。タイミーのワーカー様(アプリ利用者)・事業者様からの API 経由のデータベースアクセスや、CDC(変更データキャプチャ)によるレプリケーション接続(データ連携用)が該当
  2. 一定期間のみアクセスが発生するもの。日次・週次・月次の定期バッチジョブなど、システム内部で決められた日時に実行されるもの

「2. 一定期間のみアクセスが発生するもの」が動いている期間には、「1. 常時アクセスが発生するもの」も同時に発生します。そのため完全性は保証できないものの、一定の網羅性は確保できます。

SQL 互換性検査における重複排除による効率化

検査対象のクエリを削減する方法として、SQL パターンごとにクエリの重複排除を行う方法があります。 SQL の互換性検査に関しては、重複排除が効率化の鍵になります。同じパターンのクエリを何度もテストする必要はないからです。

重複排除の 3 つの設定

SQL Testing Manager の最新のバージョンでは、入力として実行する SQL(評価 SQL セット)を作成する際に、重複排除の挙動を 3 種類から選ぶことができます。

設定 重複排除 区別の仕方
しない 排除しない すべてのクエリをそのまま対象にする
PI Hash 排除する リテラルを除いた SQL で区別する
SQL ID 排除する SQL 文の違いで区別する

実際に検証したところ、各設定ごとに重複排除の方法が異なることが確認できました。 まず、以下のような SQL セットを準備します。

/* comment 1 */ SELECT * FROM test_table WHERE id = 1;
/* comment 2 */ SELECT * FROM test_table WHERE id = 1;
/* comment 3 */ SELECT * FROM test_table WHERE id = 2;

各重複排除モードで評価 SQL セットを作成した結果、行数は以下のようになりました。

重複排除モード 結果行数 区別の仕方 比較イメージ
しない 3 すべてのクエリをそのまま対象にする -
SQL ID 3 コメントも含めた SQL 文の完全一致による重複排除が行われる /* comment 1 */ SELECT * FROM test_table WHERE id = 1;
PI Hash 1 コメントの有無にかかわらず、リテラル値が異なる SQL のみ区別される SELECT * FROM test_table WHERE id = ?

PI Hash モードではクエリが完全にパターン化されるため、上記の入力クエリはすべて同一のものとして扱われます。一方、SQL ID での重複排除はコメントも含めたクエリ文の完全一致による比較が行われます。

まとめると以下のようになります。

  • SQL ID: コメントも含めた SQL 文の完全一致で重複排除する。
    • 同じクエリでもコメントが異なると別物として扱われ、重複排除されない。
  • PI Hash: リテラル値の違いで区別しない(コメントの有無は問わない)。
    • 例えば WHERE id = 1WHERE id = 2 のように リテラルだけが違う SQL は同一として扱われる。

検証方法まとめ

これらを踏まえてまとめると、以下のように検証を行うことにしました。

項目 SQL 互換性検証 パフォーマンス検証
目的 構文エラーの検出 アップグレード後の SQL のパフォーマンス劣化可能性の検出
方針 PI Hash によるクエリ構文のパターン化が行われたクエリで構文エラーの確認を行う 利用されている SQL クエリの実行比較を行い、アップグレード前後でパフォーマンスの変化を確認する
利用する重複排除モード PI Hash SQL ID
テスト件数 約 31 万件 約 6700 万件
対象期間 過去 1ヶ月の SQL クエリ 全定期ジョブをカバーする最小のテスト期間(3 区間・合計 7 日間)

互換性検査(1ヶ月)が約 31 万件、性能検査(7 日間)が約 6700 万件と、期間が短い性能検査のほうが件数が多くなっています。これは、PI Hash がリテラル値まで畳んでクエリをパターン化するため件数が激減するのに対し、SQL ID はリテラル値の違いを区別するため件数が大きく残るためです。

なお、性能検査の対象期間は、定期バッチの実行タイミングを全てカバーできる最小の組み合わせを選んだ結果、3 区間・合計 7 日間となりました。

SQL IDの重複排除モードを利用するため、あらかじめ入力に使用するクエリからはコメントを除去しておき、SQLクエリに対する重複排除が行われるようにしておきます。 また、パフォーマンス検査はバッチ期間に合わせて3期間に区切り、アセスメント実行期間を高速化するため、独立した3環境を用意して並列で実行しました。 これらの対処により、テスト件数を大幅に削減できたことに加え、パフォーマンス検査は環境ごとに並列で実行したことで、元々約 2 年かかる想定だった検証作業を 5 日で完了することができました。

トリアージ方針

アセスメント(評価 SQL の実行)を実施すると、各クエリごとに「ソース DB/ターゲット DB でそれぞれどうだったか」を示す結果パターンが割り当てられます。これをもとに、次の方針で調査の要否を切り分けました。

各結果パターンの分類と、それぞれのトリアージ方針は以下のとおりです。

結果パターン 対応する検証 トリアージ方針
成功 SQL 互換性検査 問題なし
ターゲット DB のみで失敗 SQL 互換性検査 エラーコードを元に原因を調査し、SQL の互換性に起因するエラーでないことを確認
ソース DB のみで失敗 SQL 互換性検査 エラーコードを元に原因を調査し、SQL の互換性に起因するエラーでないことを確認
両 DB で失敗 SQL 互換性検査 アップグレードによる差分は発生していないが、エラーコードごとの集計結果により、SQL の互換性に起因するエラーでないか原因を調査
両 DB で成功したが結果が相違 SQL 互換性検査 結果が異なる原因が検証環境起因のエラーであるか調査
両 DB で成功したがターゲット DB で性能劣化 パフォーマンス検査 ターゲット DB での実行時間が 1 秒以上、かつソース DB での実行と比較して 2 倍以上かかったクエリを抽出して原因を調査

ポイントは以下の点です。

  • バージョン間で挙動が変わったクエリに調査リソースを集中させることで、膨大な結果の中から本当に見るべきものを絞り込む
  • 「両 DB で成功したがターゲット DB で性能劣化」したものは、わずかな実行時間の差まで拾うとノイズが多くなるため、「遅くなった倍率(200% 以上)」かつ「絶対値としても 1 秒以上かかっている」という条件で、影響の大きいものだけを抽出する

調査結果

調査の結果、バージョンアップ前後で結果差分が発生していることが判明しました。 PI Hash による重複排除後、EXPLAIN を除いて結果差分が検出されたクエリパターンは16件でした。 これらを調査したところ、いずれもアップグレード前後で行の順番が異なることによる差分であり、各行の内容は同一で、保存されているデータ自体に差分は発生していませんでした。

原因は主に二つありました。

  1. ORDER BY で指定したカラムの値が同一になっている行が複数存在しており、同一の値に対する ORDER BY ... LIMIT の結果は非決定的であること
  2. EXPLAIN 結果の比較により、内部的な結合アルゴリズムとして Nested Loop Join が選択されていたクエリが、バージョンアップ後では Hash Join が選択されたことによる結果順序の不定化

このうち、クライアント上で表示される結果順序が変わってしまうなど、実際のプロダクトに問題が発生するクエリについては、該当クエリに ORDER BY を追加する等の対応を入れました。保存されているデータ自体に差分は確認されなかったため、現時点ではこれらの対応により、アップグレードに伴う影響は許容範囲内であると判断しています。

まとめ

今回はマイナーバージョンアップ(MySQL 8.0.28 相当 → 8.0.42 相当)であり、MySQL 8.0.34 以降は bugfix のみのリリースの方針であるため、大量の差分が発生することはありませんでした。しかし、調査結果で検出された内部的な結合アルゴリズムの変化は、各バージョンの変更内容(changelog)の調査だけでは気づきにくい部分でもあるため、今回の検証で気づくことができたのは大きな収穫でした。

膨大なクエリを現実的な時間で検証するために効いたのは、次の 4 つの工夫です。

  • 目的で検証を分ける: 網羅性が欲しい互換性検査と、負荷状況を見たい性能検査を切り離し、それぞれに最適な対象範囲を設定
  • 期間を賢く絞る: 定期バッチのタイミングを考慮し、「全定期ジョブをカバーする最小の 7 日間」で性能検査の対象を設計
  • 重複排除で件数を削る: PI Hash / SQL ID の挙動の違いを理解し、目的に合わせて使い分けることで、テスト件数を大幅に削減
  • 効率的なトリアージ: アップグレードに起因する変化に絞り込み、重要な差分に調査リソースを割く

もし、今回の自分と同じようにデータベースのアップグレードを検討している方が、この記事が事前検証を行う上で何らかの参考になれば嬉しいです。