Timee Product Team Blog

タイミー開発者ブログ

「はたらく」の体験に向き合う。タイミーに入社して感じていること

はじめに

2026年7月にタイミーへ入社した細野です。現在はバックエンドエンジニアとして、ワーカーさんと事業者様双方の体験をより良くするためのプロダクト開発に携わっています。

この記事では、私がタイミーに興味を持った背景と、入社後に感じていることを書きます。タイミーに少しでも興味を持っている方にとって、入社後のイメージを持つ材料になれば嬉しいです。

簡単な自己紹介

私はもともと、紳士服販売からキャリアをスタートしました。その後、プログラミング講師を経て、2021年からエンジニアとして働いています。

エンジニアとしては、スタートアップや事業会社でフロントエンドからインフラまで、幅広く開発に携わってきました。プレイングマネージャーとして開発推進や技術面のリードを担いながら、新入社員メンターや中途採用にも関わらせていただきました。

直近ではニフティ株式会社で、ポイントサービスのモダナイゼーションに取り組んでいました。特に、データベースの PostgreSQL 移行など、長く運用されてきたシステムを今後も継続的に改善できる状態に近づける取り組みに関わらせていただきました。

参考: ニフティ株式会社「Oracle Database Enterprise Edition から Amazon Aurora PostgreSQL への移行によりメンテナンス時の対応コストを50%削減」(出典:同社公開記事)

タイミーに興味を持つまでの背景

私は、エンジニアリングを通じて、人の選択肢や可能性を広げることに貢献できる仕事に、強いやりがいを感じます。

人生の中で、仕事が占める時間はとても大きいものです。だからこそ、どんな仕事に向き合うかは、自分自身の幸福度にも大きく関わると考えています。

以前、「ジョブ・キャリア・コーリング」という考え方を知ったとき、自分は収入を得るためだけでも、キャリアを積み上げるためだけでもなく、自分なりに意味を感じられる仕事に向き合いたいのだと気づきました。

その後、プログラミング講師として受講生の方々に向き合う中で、人の選択肢を広げることに関わる仕事への思いはより強くなりました。さらに、教えるためにプログラミングを学び続けるうちに、その面白さに強く惹かれるようになり、エンジニアとしてプロダクトを通じて価値を届けたいと思うようになりました。

特に印象に残っているのは、自社サービスの開発で、事業部、ディレクター、CS、デザイナーなど多くのメンバーと一緒に、要求定義からリリース、リリース後の改善まで取り組んだ経験です。技術だけで完結するのではなく、ユーザー体験や事業成果に向き合いながら、チームでプロダクトを育てていくことに強いやりがいを感じました。

タイミーに興味を持ったきっかけ

タイミーに興味を持ったきっかけは、以前から業務で参考にしていたテックブログや、求人媒体で見た募集情報でした。

タイミーが掲げる Vision「一人ひとりの時間を豊かに」と Mission「『はたらく』を通じて人生の可能性を広げるインフラをつくる」は、自分が大切にしてきた価値観と強く重なるものでした。加えて、モジュラーモノリスやチームトポロジーなど、プロダクトと組織の成長に向き合うための技術的・組織的なチャレンジがあることにも惹かれました。

また、エンジニアとして技術を深めるだけでなく、PdM、アーキテクト、EM、テックリード、シニアエンジニアなど、さまざまなキャリアの可能性があることも魅力でした。自分自身、技術、プロダクト、人・チームの成長のどれにも関心があるので、入社後の選択肢を広く持ちながら挑戦できそうだと感じました。

入社して感じていること

実際に入社してみると、事業、プロダクト、開発組織、ドメイン知識など、キャッチアップすることはたくさんあります。正直、まだまだ目の前のことを理解しながら、なんとか現場についていっている段階です。

ただ、チームトポロジーを踏まえた組織設計、Notion上のドキュメント、バックエンド開発Handbook、AIエージェント向けのスキルなど、必要な情報にたどり着きやすい仕組みが整っていることは、とても心強く感じています。

tech.timee.co.jp

加えて、入社後はメンターの方と毎日1on1の時間があり、分からないことをそのままにせず相談できる環境があります。キャッチアップ量は多いですが、一人で抱え込まずに前に進める安心感があります。

正直、まだ一度聞いただけで理解しきれることばかりではありません。だからこそ、分からなかったことや後で見返したいことは Notion DB に残し、タスクやキャッチアップ用のメモとして少しずつ整理しています。レビューでいただいたフィードバックも、同じ指摘を繰り返さないように Claude や Cursor の Skill として残し、次の実装やレビュー前に見返せるようにしています。文字だけでは把握しづらいドメインやシステムの関係性は、Miro で図にしながら、自分にとって理解しやすい形に変換しているところです。

また、単に仕様を実装するだけではなく、「なぜそれをやるのか」「誰にどのような価値があるのか」を考える機会が多いことも印象的です。ドメインやプロダクトの変化に向き合う難しさはありますが、その難しさも含めて、プロダクトやユーザーに近い場所で価値を届けるエンジニアリングに取り組めていることを面白く感じています。

これからやりたいこと

まずは担当領域でしっかり価値を出しながら、ワーカーさんと事業者様双方にとってより良い体験を届けられるよう、プロダクト理解と技術力の両方を深めていきたいです。

そのうえで、継続的に学び、学びをチームや組織に還元できるエンジニアでありたいと思っています。目の前の課題に対して横着せず、背景や構造を理解しながら、論理的に考え、周囲と協力して成果につなげていきたいです。

また、バックエンドエンジニアとしての専門性を高めつつ、これまでの経験も活かして、プロダクトやチームの成長にも貢献していきたいです。

タイミーが向き合っている「はたらく」の領域には、ワーカーさんと事業者様双方の体験をより良くすること、複雑なドメインをプロダクトとして分かりやすく届けること、事業成長に耐えられるシステムを作ることなど、エンジニアリングで向き合えるテーマがたくさんあると感じています。これから少しずつ、自分なりの形で価値を届けていけるよう頑張っていきます。

おわりに

ここまで読んでいただき、ありがとうございました。

タイミーはいま、第二創業期ともいえるフェーズにあります。新規事業や既存領域の進化がいくつも並行して走っており、意思決定や仮説検証のサイクルを速めるための仕組みづくりも盛んです。

まだ入社して間もないですが、このスピード感の中でプロダクト開発に向き合えることは、率直にとても面白いと感じています。

もしタイミーの Vision、Mission、Value に共感し、同じ熱量でプロダクト開発に関わりたいと感じる方がいれば、ぜひ一度カジュアル面談でお話しできると嬉しいです。

product-recruit.timee.co.jp

テストが増えすぎてもう限界だったので、PRで全テストを回すのをやめた話

はじめに

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

この記事では、スキマバイトサービス「タイミー」のバックエンド(Rails API)に Datadog Test Impact Analysis(TIA) を導入し、PRごとのCIフィードバックを大幅に高速化した取り組みについて紹介します。

なお、バックエンドはRailsアプリケーションなので、テストフレームワークは RSpec を前提として話を進めます。

サービスが成長してプロダクトの機能が増えてくると、テストも当然増えていきますよね。開発者が増えればPRの数も増え、CIの待ち時間がボトルネックになってくるのも、割とあるあるな課題だと思います。同じような悩みを抱えているチームの参考になれば嬉しいです。

背景:膨れ上がるテストとの戦い

タイミーのバックエンドはモジュラーモノリスを採用しており、複数のインターフェースを1つのRailsアプリケーションで提供しています。

担当するバックエンドエンジニアの数もかなり多く、当然テストの数もそれに比例して増えていきます。

テスト数は約35,000。そして月に約2,000テストのペースで増加していました。

さらに最近はAIコーディングツールの活用も進み、テストが増えるペースが加速しています。人手に加えてAIコーディングツールの活用も進む中で、テストの増加速度が既存のチューニングでは吸収しきれない状況になりつつありました。

これまでの高速化の取り組み

もちろん手をこまねいていたわけではありません。たとえば、self-hosted runnerの活用、GitHub Actionsのjobの35並列分割、split-test による実行時間ベースの均等分割、ジョブ内のステップ並列実行(Redis/ESの起動とRuby/bundleセットアップをbackground + wait-allで同時進行)、MySQLマイグレーションキャッシュ、Dockerイメージキャッシュなど——泥臭いチューニングを積み重ねてきました。その結果、35,000テストを約10分で完走させるところまで持っていくことができました。

しかし、毎月2,000テストずつ増えていく状況では、並列数を増やし続けるのにも限界があります。ノード数を増やせばその分CIコストも増えていきますし、どこかで別のアプローチが必要になるのは明らかでした。

今後の成長を考えると、すべてのPRですべてのテストを実行するのは持続可能ではない と判断しました。

Datadog Test Impact Analysis(TIA)という選択肢

そこで目をつけたのが Datadog Test Impact Analysis(TIA) です。

TIAは、コード変更の差分を解析し、その変更に関係のあるテストだけを選択的に実行する仕組みです。Datadogが各テストのコードカバレッジを収集・保持しており、「このファイルを変更したなら、このテストを実行すべき」という判断を自動で行ってくれます。

これにより、PRごとのCIでは 変更に関連するテストだけ を実行してフィードバックを高速化しつつ、品質の担保は別の仕組みで行うという戦略が取れるようになります。

TIAを使うための前提条件

TIAを使うには、まず Test Optimization を導入しておく必要があります。Test Optimizationはテスト結果やパフォーマンスデータをDatadogに送信して可視化する仕組みで、TIAはその上に乗っかる機能です。

具体的には以下が必要になります。

  • Test Optimization の設定が完了していること: datadog-ci gem(>= 1.0)を導入し、CIからテスト結果をDatadogに送信できる状態にしておく
  • Datadog側でTIAを有効化すること: Test Service Settingsページから、Intelligent Test Runner Activation 権限を持つユーザーがTIAを有効にする必要がある

弊社ではもともとTest Optimizationを使ってテストの実行結果をDatadogで可視化していたので、TIAの導入自体は追加コストなしでスムーズに進められました。

設計方針:GitHub Flow + マージキューを活かす

ここからが今回のキモです。

弊社は基本的に GitHub Flow を採用しています。mainにマージされるとリリースが走るというシンプルな方針です。そして、mainへのマージには マージキュー(Merge Queue) を利用しています。マージキューの導入については、こちらの記事で詳しく紹介しています。

全体のフローはこんな感じです。

flowchart TD
    A["featureブランチで開発・push"] --> B["PR CI(ci_branch)<br/>TIA有効 → 関連テストのみ実行<br/>⚡ 高速フィードバック"]
    B -->|CI通過| C["レビュー & Approve"]
    C --> D["マージキューに投入"]
    D --> E["マージキュー CI(ci)<br/>全テスト実行<br/>+ Datadogカバレッジ収集"]
    E -->|全テスト通過| F["mainにマージ"]
    F --> G["リリース"]
    style B fill:#e8f5e9,stroke:#4caf50
    style E fill:#fff3e0,stroke:#ff9800

ポイントは、PRのCIとマージキューのCIで役割を分けている ところです。この「マージキュー」の特性をうまく活かすことで、以下のような構成を実現しました。

PRブランチ(ci_branch ワークフロー)

  • TIAを有効化して、差分に関連するテストのみを実行
  • 開発者へのフィードバックを高速化
# ci_branch.yml
jobs:
  ci:
    uses: ./.github/workflows/_ci.yml
    with:
      skip: false
      itr_enabled: true  # TIAによるテストフィルタリングを有効化
    secrets: inherit

マージキュー(ci ワークフロー merge_group イベント)

  • 全テストを実行(TIAフィルタリングは無効)
  • Datadogへのカバレッジ収集も同時に実施
  • これを通過しないとmainにマージされない
# ci.yml
on:
  merge_group:

jobs:
  ci:
    uses: ./.github/workflows/_ci.yml
    with:
      skip: ${{ github.event_name != 'merge_group' }}
      # マージキューでは全テストを実行しつつカバレッジを収集
      dd_coverage_enabled: true
      tia_test_skipping_mode: suite
      tia_force_run_all: true  # gem側のスキップも無効化して全spec実行
    secrets: inherit

(コード内の tia_test_skipping_mode: suite は、テストの選定を「テストファイル単位」で行う設定です。なぜ suite にしたのかは、後述の「suiteモードを選んだ理由」で詳しく触れます。)

つまり、PRでは「速さ」を、マージキューでは「安全」を という役割分担です。

マージキューのテストを全件パスしないとmainにマージされず、mainにマージされないとリリースされない。この構造があるからこそ、PRのテストを絞っても品質を担保できるわけです。

カバレッジ収集はマージキューで

TIAの精度を保つには、カバレッジデータを最新に保つ必要があります。

マージキューは mainにマージされる直前のコミットで全テストを実行 するので、ここでカバレッジを収集すれば常に最新の状態が保たれます。

# ci.yml(merge_group イベント時)
dd_coverage_enabled: true       # カバレッジ収集を有効化
tia_test_skipping_mode: suite   # suite単位で収集
tia_force_run_all: true         # gem側のスキップも無効化して全spec実行

当初はこのカバレッジ収集を別ワークフロー(Coverage Build)でも回していたのですが、マージキューで毎回「全テスト実行+カバレッジ収集」を行う構成にしたことで一本化でき、運用がシンプルになりました。

ddtest plan によるテスト選定と分割の工夫

ddtest plan の基本

TIAによるテスト選定には、Datadogが提供する ddtest CLIツールを使っています。

./ddtest plan \
  --platform ruby \
  --framework rspec \
  --min-parallelism 1 \
  --max-parallelism 1 \
  --test-skipping-mode suite \
  --tests-location "**/*_spec.rb" \
  --tests-exclude-pattern "vendor/**"

ddtest plan はDatadog APIと通信して、現在のコミットの差分に対してスキップ可能なテストを判定し、実行すべきテストファイルの一覧を .testoptimization/runner/test-files.txt に出力します。

gem の環境変数スキップではなく ddtest plan を採用した理由

実は ddtest コマンドを使わなくても、datadog-ci gemを導入していれば、環境変数 DD_CIVISIBILITY_ITR_ENABLED=true の設定だけでTIAによるテストスキップを有効にできます。最も手軽な方法です。

ただ、弊社ではこの方法を採用しませんでした。理由は 35並列との相性が悪い からです。

gemの組み込みスキップは、RSpecの実行時に各テストケースを個別にスキップします。そのため、35ノードにテストを分割した後に各ノード内でスキップが走るので、「このノードは割り当てられたテストの大半がスキップされて30秒で終わったけど、別のノードは全部実行対象で5分かかった」といったことが起きます。結果としてノード間のばらつきが大きくなり、並列化の効率がガクッと落ちるんですよね。

そこで、テストの選定は ddtest plan実行前に 行い、選定されたファイルだけを split-test で均等に分割する、という方式を取りました。スキップの判断をRSpec実行の外に出すことで、各ノードに割り当てるテスト量を事前にコントロールできるようにしています。

ちなみに ddtest plan 自体にも --min-parallelism / --max-parallelism オプションによるノード分割機能があります。ただ、検証してみたところ split-test と比べてノード間の実行時間のばらつきが大きかったため、分割は引き続き split-test に任せる構成にしました。ddtest plan は「どのテストを実行するか」の選定だけに使い、「どう分割するか」は split-test に委ねる、という役割分担です。

カバレッジ収集時の罠と DD_TIA_FORCE_RUN_ALL

もう一つ、DD_CIVISIBILITY_ITR_ENABLED 周りで地味にハマったポイントがあります。

TIAが正しくテストを選定するには、各テストがどのソースコードを通過するかという カバレッジデータ をDatadogに送る必要があります。カバレッジ収集は DD_CIVISIBILITY_ITR_ENABLED=true で有効化できます。ただし、この環境変数を true にすると、カバレッジ収集と同時にテストのスキップも有効になります

つまり、カバレッジを集めたいだけなのに、TIAが「このテストはスキップしてOK」と判断したテストのカバレッジが収集できないという矛盾が起きます。これだとカバレッジデータに穴が空き、次回以降のTIA判定精度が落ちていきます。

この問題を解決するために、datadog-ci gem が提供する datadog_itr_unskippable というRSpecメタデータを活用しています。

datadog-ci gem は、DD_CIVISIBILITY_ITR_ENABLED=true のときにDatadog APIからスキップ可能なテストの一覧を取得し、該当するテストをスキップします。しかし、RSpecのメタデータに datadog_itr_unskippable: true が設定されているテストは、スキップ対象から除外されて必ず実行されます。

これを利用して、spec_helper.rb で以下のように 全テストにunskippableメタデータを付与 しています。

# spec_helper.rb
if ENV['DD_TIA_FORCE_RUN_ALL'] == 'true'
  # DD_CIVISIBILITY_ITR_ENABLED はカバレッジ収集と suite スキップを同時に有効化するため、
  # 収集専用ジョブでは全 example を unskippable にして forced run させる
  RSpec.configure do |config|
    config.define_derived_metadata do |metadata|
      metadata[:datadog_itr_unskippable] = true
    end
  end
end

define_derived_metadata はRSpecの機能で、全テストのメタデータにデフォルト値を追加できます。これにより、gem側のスキップ判定が働いても「このテストはスキップ不可」と判定されるので、結果的に全テストが実行されます。

# マージキューでの環境変数設定
DD_CIVISIBILITY_ITR_ENABLED: true    # カバレッジ収集を有効化
DD_TIA_FORCE_RUN_ALL: true           # 全テストをunskippableにして必ず実行

DD_CIVISIBILITY_ITR_ENABLED=true でカバレッジ収集の仕組みを有効にしつつ、DD_TIA_FORCE_RUN_ALL=true で全テストにunskippableメタデータを付与してスキップを防ぐ。これにより、全テストを実行して完全なカバレッジデータを収集できるようになります。

マージキューではこの組み合わせを使い、PRブランチでは DD_CIVISIBILITY_ITR_ENABLED=false(カバレッジ収集もスキップも無効)にして余計なオーバーヘッドを排除しています。

suiteモードを選んだ理由

TIAには testモードsuiteモード の2つの粒度があります。

  • testモード: 個々のテストケース(it ブロック)単位でスキップを判定
  • suiteモード: テストファイル(_spec.rb)単位でスキップを判定

今回は suiteモード を採用しました。

理由はシンプルで、testモードでは35,000テストの分割判定に約2分かかるためです。差分が小さいPRなら問題ありません。しかし全テストに影響する変更(例えば spec_helper.rb の変更など)をした場合は、2分かけて「全テスト実行」という結論になり、かえってオーバーヘッドが大きくなってしまいます。その点、suiteモードなら対象がファイル単位なので、この判定が高速(5s程度)に完了します。

split-test との組み合わせ

前述の通り ddtest plan で選定したファイルを split-test で均等分割するのですが、ここが地味に苦労したポイントです。

split-test はファイルリストを直接受け取れず --tests-glob しか受け付けません。そこで、以下のようにシンボリックリンクを使ってglobのマッチ対象をTIA対象ファイルだけに制限するアプローチを取りました。

# TIA対象ファイルのシンボリックリンクを一時ディレクトリに作成
SPLIT_DIR=$(mktemp -d)
while IFS= read -r f; do
  [ -f "$f" ] && mkdir -p "$SPLIT_DIR/$(dirname "$f")" && ln -s "$WORKSPACE/$f" "$SPLIT_DIR/$f"
done < .testoptimization/runner/test-files.txt

# 一時ディレクトリ内で split-test を実行(TIA対象のみが均等に分割される)
(cd "$SPLIT_DIR" && "$WORKSPACE/split-test" \
  --junit-xml-report-dir "$WORKSPACE/tmp/rspec_junit" \
  --node-index $NODE_INDEX \
  --node-total 35 \
  --tests-glob "spec/**/*_spec.rb" \
  --tests-glob "packs/*/spec/**/*_spec.rb")

ちょっとトリッキーですが、これにより TIAで絞り込んだテストを、実行時間ベースで均等に35分割する ことが実現できました。

なお、35並列のうちTIAの絞り込みで対象テストがゼロになるノードも出てきます。その場合は空のダミーJUnitレポートを出力しておき、後続のジョブが正常に動くようにしています。

PRの全変更を正しく評価するための工夫

もう一つ、導入時にハマったポイントがあります。

ddtest はHEADコミットのdiffだけを見てテスト選定を行います。PRに複数コミットがある場合、最後のコミットの変更しか考慮されません。これだと初期コミットで変更したファイルに関連するテストがスキップされてしまう可能性があります。

そこで、GitHub APIで merge-base(分岐点) を取得し、PRの全変更を1つのdiffとして ddtest に認識させる仕組みを入れています。

# PRの分岐点を取得
MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$GITHUB_SHA" \
  --jq '.merge_base_commit.sha')

# 分岐点を親、現在のツリーを内容とするコミットを作成
git fetch --depth=1 origin "$MERGE_BASE" --no-tags
TREE=$(git rev-parse "HEAD^{tree}")
TIA_COMMIT=$(git commit-tree "$TREE" -p "$MERGE_BASE" -m "TIA: all PR changes")
git reset --soft "$TIA_COMMIT"

これにより、HEADのdiffが「PR全体の変更」を表すようになり、TIAが正しく全変更を評価できるようになります。

全体のアーキテクチャまとめ

最終的な構成を図にすると以下のようになります。

flowchart TD
    A[開発者がPRを作成] --> B

    subgraph PR["ci_branch ワークフロー(PRブランチ)"]
        B["ddtest plan (TIA)\n関連テストのみ抽出"] --> C["split-test\n35並列に均等分割"] --> D["RSpec実行\n(関連テストのみ)"]
    end

    D -->|テスト通過| E[マージキューに投入]
    E --> F

    subgraph MQ["ci ワークフロー(merge_group イベント)"]
        F["全テスト実行\n(TIAフィルタリングなし)"] --> G["Datadogカバレッジ収集\n(TIA精度を維持)"]
    end

    G -->|全テスト通過| H["mainにマージ → リリース"]

    style PR fill:#e8f5e9,stroke:#4caf50
    style MQ fill:#fff3e0,stroke:#ff9800

導入時のハマりポイントまとめ

実際に導入してみて、いくつかハマったポイントをまとめておきます。

1. testモードの分割コスト

前述の通り、testモードでは35,000テストの分割判定に約2分かかります。suiteモードなら数秒で完了します。テスト数が多い場合はsuiteモード一択だと思います。

2. split-test との組み合わせ

split-test がファイルリストを直接受け取れないため、シンボリックリンクを使った一時ディレクトリ方式を採用しました。ちょっとトリッキーですが、確実に動きます。

3. マルチコミットPRの差分評価

ddtest がHEADコミットのdiffしか見ない問題は、merge-baseからのコミットを作り直すことで解決しました。

4. カバレッジの鮮度

TIAの精度はカバレッジデータの鮮度に依存します。当初は別ワークフロー(Coverage Build)でも収集していましたが、マージキューで毎回全テスト+カバレッジ収集を行う構成にしたことで一本化でき、シンプルになりました。

5. TIAで全テストがスキップされるノードの対策

35並列のうち、TIAで対象テストがゼロになるノードが出てきます。その場合はダミーのJUnitレポートを出力して後続のジョブが正常に動くようにしています。

成果

TIA導入前は、PRのCIで全テストを実行していたため 約10分 かかっていました。TIA導入後は変更の差分によって実行されるテスト数が変わりますが、差分が小さいPRでは 1〜2分 で完了するケースもあり、大幅に高速化しています。

おわりに

Datadog TIAの導入により、PRごとのテスト実行時間を大幅に短縮しつつ、マージキューで全テストを実行するという安全な構成を実現できました。

ポイントをまとめると:

  • PRブランチ: TIAで関連テストのみ実行 → 高速フィードバック
  • マージキュー: 全テスト実行 + カバレッジ収集 → 品質担保
  • suiteモード: テスト数が多い場合はtestモードよりsuiteモードが実用的
  • ddtest plan + split-test: TIAフィルタリングと均等分割の組み合わせがキモ
  • GitHub Flow + マージキュー: この組み合わせがTIA導入の前提条件として非常にフィットした

テスト数がどんどん増えていく中で、「全部回す」から「必要なものだけ回す」へのシフトは避けて通れない道だと思います。同じような課題を抱えているチームの参考になれば幸いです。

Self-hosted Runner 基盤の o11y 向上 - Grafana・Prometheus・Loki・Alloy の導入時に直面した問題とその解決策 -

1. はじめに

こんにちは。プラットフォームエンジニアリングチームに所属する小泉(@naotoko_)です。

本記事は、同チームの徳富(@yannKazu1)が執筆した「消えるランナーの観測基盤をどう選んだか — Datadog・マネージド・OSS を料金体系で比べて Loki + Prometheus に決めた話」の本番環境への導入編です。EKS Auto Mode でホストしている Self-hosted Runner の監視基盤に Grafana・Prometheus・Loki・Alloy を導入した際にハマったポイントと解決策を、各コンポーネントごとにお伝えします。

Grafana・Prometheus・Loki・Alloy をどのような構成で実装したかは、上記ブログの「実装:どう組んだか」をご覧ください。

2. Alloy - メトリクスが重複する・取れない

2-1. DaemonSet の重複 scrape 問題

Alloy は DaemonSet でデプロイしており、全ノードに1つずつ Pod が立ち上がる構成となっています。

デフォルトのまま使用すると、Alloy はメトリクス収集時にクラスター全体から scrape 対象の一覧を取得し、そのリストに対して定期的に HTTP リクエストを送ります。DaemonSet の各 Pod がそれぞれ独立してこれを実行するため、全 Pod が同じターゲット一覧を取得してしまいます。

たとえば kube-state-metrics や ARC controller のようにクラスターに1つしかないエンドポイントは、全ての Alloy Pod が同じエンドポイントを scrape しに行くためノード数分重複します。さらに、node-exporter や kubelet のようにノードごとに存在するエンドポイントでも、各 Alloy Pod がクラスター全ノード分のエンドポイントを発見して全て scrape するため、同様にノード数分重複します。その結果、全ての scrape 対象で同一のメトリクスが重複して Prometheus に送られてしまいます。

各 Alloy Pod がそれぞれ独立にクラスター全体のターゲット一覧を取得するため、クラスターに1つしかない kube-state-metrics も、ノードごとに存在する kubelet も、全 Pod から scrape される。

なぜログ収集では同じ問題が起きないか

ログ収集は各 Alloy Pod がノードのローカルファイル(/var/log/pods/)を読む方式です。ノードAの Alloy はノードAのログだけ、ノードBの Alloy はノードBのログだけを読むため、Pod 間でデータが重複しません。読む対象がノード単位で自然に分割されているため、メトリクスのような重複の問題が起きませんでした。

解決策:Alloy のクラスタリングを有効化する

Alloy にはクラスタリング機能があり、Pod 同士がクラスターを形成して scrape 対象を自動的に分担します。(詳しい仕組みは割愛)

Alloy Pod 同士がクラスターを組み、各ターゲットの担当をいずれか1つの Pod に決める。担当はハッシュで決まるため、図のように自分と同じノード上のターゲットを担当するとは限らない。

設定は2段階必要です。まず Helm chart の values でクラスタリングを有効化し、DaemonSet の全 Pod がクラスターを形成するようにします。

alloy:
  clustering:
    enabled: true

そのうえで、分担させたい各 prometheus.scrape ブロックに clustering { enabled = true } を付けます。

prometheus.scrape "kube_state_metrics" {
  targets    = [...]
  clustering {
    enabled = true
  }
  forward_to = [prometheus.remote_write.default.receiver]
}

注意点として、この2つはセットで初めて機能します。Helm values 側を有効化せずに prometheus.scrape 側だけ書いても no-op(何もしない)になり、逆に Helm values 側だけ有効化しても clustering ブロックを付けていない scrape コンポーネントは従来どおり全 Pod が独立に scrape し続けます。

2-2. kubelet scrape の InternalIP 対応

Grafana でメトリクスを確認すると Pod の CPU・メモリが No data になっていました。調べると kubelet の scrape が全ノードで失敗していました。

原因は Alloy が __meta_kubernetes_node_name(ノード名)を scrape 先のアドレスとして使っていたためです。EKS Auto Mode ではノード名がインスタンス ID になるため名前解決できず、全ノードでタイムアウトしてしまっていました。

通常の EKS ではノード名は ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal のようなプライベート DNS 名になるため、 __meta_kubernetes_node_name でも名前解決できます。一方 EKS Auto Mode では i-xxxxxxxxxxxxxxxxx のような EC2 インスタンス ID がノード名になるため、名前解決できないという状況でした。

解決策:InternalIP を使う

__meta_kubernetes_node_address_InternalIP に切り替えることで、ノード名ではなく IP アドレスで直接アクセスするようにしました。

rule {
  source_labels = ["__meta_kubernetes_node_address_InternalIP"]
  regex         = "(.+)"
  replacement   = "${1}:10250"
  target_label  = "__address__"
}

3. Prometheus - OOM と欠損との戦い

3-1. OOM 問題

導入後、Prometheus サーバーが OOMKilled → 再起動を繰り返すようになりました。原因は1つではなく、複数が重なっていました。

原因1:Alloy と Prometheus の二重取り込み

Prometheus の Helm chart はデフォルトでいくつかの scrape job(kubernetes-nodes-cadvisorkubernetes-pods 等)が有効になっています。今回の構成では Alloy が一元的に scrape して Prometheus に remote_write する設計のため、Prometheus 自身の scrape job と Alloy の remote_write で同じメトリクスが2経路で入っていました。

これは Alloy が scrape するターゲットと重複している chart デフォルトの scrape job を無効化することで解決することができます。

scrapeConfigs:
  kubernetes-nodes: false
  kubernetes-nodes-cadvisor: false
  kubernetes-pods: false
  # ...

原因2:cAdvisor の高 cardinality ラベル

cAdvisor は kubelet に組み込まれており、コンテナのリソース使用量を収集するコンポーネントです。Alloy は kubelet の /metrics/cadvisor エンドポイントを scrape することでこのメトリクスを取得しています。cAdvisor のメトリクスには imagename などのラベルが付いており、同時稼働するランナー数が多いほど cardinality(系列数)が爆発的に増えてしまいます。

そこで、Prometheus に送る前の段階で Alloy 側でメトリクスやログ調査に不要なラベルを drop することで、Prometheus に送られる系列数を極力減らすようにしました。 ちなみに、image はコンテナイメージ名、name はコンテナランタイム上のコンテナ ID(containerd では 64 桁の 16 進文字列)が入ります。name はコンテナが起動するたびに異なる値になるためほぼユニークであり、集計や絞り込みには使わないと判断し、この2つのラベルを drop しました。一方で、メトリクスのクエリで実際に使う namespacepodcontainer といったラベルは残しています。環境に応じて、調査に使わないラベルは可能な範囲で drop するようにするのが無難です。

rule {
  regex  = "image|name"
  action = "labeldrop"
}

ただし、id ラベルだけは残す必要があります。cAdvisor は同じメトリクス名で複数のコンテナの情報を収集しており、それぞれを区別するために id ラベルが使われています。id を drop すると異なるコンテナのメトリクスがラベルセット上で同一系列として扱われていしまいます。その結果、同じタイムスタンプに複数のサンプルが届き、 Prometheus が duplicate sample for timestamp エラーを返してしまうため注意が必要です。

原因3:Head Block のメモリ常駐

Prometheus はメトリクスを受け取ると、書き込み効率のためにまずメモリ上に一時的に貯めます。これが Head Block です。 一定時間が経つとメモリから EBS 上のファイルに書き出されます。デフォルトでは Head Block が3時間分(min-block-duration の1.5倍)に達した時点で古い2時間分がブロックとして書き出されます。つまり常に1〜3時間分のデータがメモリに残り続けるため、メトリクスの量が多いほどメモリ使用量が増えます。

storage.tsdb.min-block-duration を 2h から 30m に短縮することでこの「一時的に貯める時間」を短くし、より頻繁に EBS に書き出すことでメモリ常駐量を削減しました。その代わり、以前はメモリから取得できていたデータが EBS から取得されるためクエリが遅くなるトレードオフがあります。Self-hosted Runner の監視という性質上、確認したいのは基本的に直近の状況であるため、30分より古いデータの取得が多少遅くなっても問題ないと判断しています。

なお、storage.tsdb.min-block-duration は --help にも表示されない hidden フラグで、公式には「テスト用途」とされています。挙動を理解したうえで利用してください。

extraArgs:
  storage.tsdb.min-block-duration: 30m

3-2. out-of-order サンプル問題

Alloy クラスタリングを有効化してから、Prometheus に out of order sample エラーが大量に出るようになりました。

Alloy のクラスタリングは Pod の増減をトリガーにターゲットの再配分を行います。Self-hosted Runner はジョブの増減に応じてノードが頻繁にスケールするため、Alloy の Pod も増減し、再配分が頻繁に起きます。Alloy のクラスタリングによるターゲットの分担は eventually consistent なモデルであるため、再配分の引き継ぎのタイミングによっては同じターゲットが一時的に2つの Pod から scrape されることがあります(grafana/alloy の issue #1611・#2348 でも報告されている既知の挙動です)。この状態で同じ時系列のサンプルが2つ届くと、後着のサンプルのタイムスタンプが先着より古い場合があります。すると Prometheus はこれを out-of-order として拒否し、該当のサンプルを捨ててしまいます。その結果、 Grafana で確認できるメトリクスに欠損が生じてしまいます。

解決策:out-of-order time window を設定する

out_of_order_time_window を設定することで、指定した時間内の過去のタイムスタンプを受け入れるようになります。弊社の環境では、最終的に 10m に落ち着きました。out-of-order のサンプルを保持するためのメモリが若干増えますが、storage.tsdb.min-block-duration を 30m に短縮して Head Block のメモリを節約できているため、特に問題ないと判断し、10m に設定しています。

tsdb:
  out_of_order_time_window: 10m

4. Loki - chart 移管の罠と起動しない Pod

4-1. Helm chart リポジトリ移管の罠

Loki の Helm chart はもともと grafana/helm-chartshttps://grafana.github.io/helm-charts)で配布されていました。 しかし chart v6.55.0 を最後に OSS Loki 向けの chart は grafana-community/helm-chartshttps://grafana-community.github.io/helm-charts)へフォークされ、v7.0.0 以降は community 側からリリースされています。従来のリポジトリに残った loki chart は Grafana Enterprise Logs(GEL)向けのメンテナンス専用になりました。

そのため、grafana/helm-charts を使い続けると、気づかないうちに GEL 向けの chart を引いてしまいます。

合わせて以下の変更もあるので、注意が必要です。

  • フォーク後はメジャーバージョンの上がるペースが非常に速い(v7.0.0 から数ヶ月で v17.x、執筆時点の最新は v18.x)
  • deploymentMode の値が SingleBinary → Monolithic にリネーム(chart v12.0.0)。なお values のキーは singleBinary のまま変わっていません
resource "helm_release" "loki" {
  repository = "<https://grafana-community.github.io/helm-charts>"  # ここが変わった
  chart      = "loki"
  version    = "17.1.6"
  ...
}
# chart v12.0.0 以降
deploymentMode: Monolithic  # SingleBinary から変更

4-2. StorageClass が自動作成されない

Loki の Pod がスケジュールされず、以下のエラーが出ていました。

eks-auto-mode/compute Failed to schedule pod, unbound pvc must define a storage class

通常の EKS では EBS CSI driver addon がデフォルトの StorageClass を自動作成しますが、EKS Auto Mode では作成されません。StorageClass が作成されていないと、PVC がバインドできず Pod が起動しません。

そのため、StorageClass を手動で作成し、Loki の PVC に指定することで解決しました。

# StorageClass の手動作成
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: auto-ebs-sc
provisioner: ebs.csi.eks.amazonaws.com
volumeBindingMode: WaitForFirstConsumer
parameters:
  type: gp3
  encrypted: "true"
# loki.yaml
singleBinary:
  persistence:
    storageClass: auto-ebs-sc

5. Grafana - デプロイ戦略とコード管理

5-1. EBS(RWO)と RollingUpdate の相性問題

Grafana を helm upgrade したとき、Pod が新しくなるはずが延々と Pending のまま膠着しました。原因は EBS の制約です。

EBS は RWO(ReadWriteOnce)のため、1つの Node にしか同時にアタッチできません。デフォルトの RollingUpdate は新しい Pod を起動してから古い Pod を落とす順番なので、新旧の Pod が同じ PVC を取り合って Multi-Attach error が発生します。

解決策:Recreate 戦略に変更する

Recreate にすると「旧 Pod 終了 → EBS detach → 新 Pod 起動」という順番になります。replicas=1 の構成なので更新時に短時間のダウンタイムが発生しますが、監視基盤という特性を踏まえて瞬断は許容し、Recreate に設定しています。

deploymentStrategy:
  type: Recreate

5-2. Dashboard・Alerting のコード管理

なぜ Terraform provider ではなく Helm values で管理するか

Grafana のダッシュボードとアラートルールをコード管理する方法として、grafana Terraform provider を使う方法もあります。しかし今回は Helm values(YAML/JSON)での管理を選びました。

理由はシンプルで、Grafana の UI でダッシュボードやアラートルールを作り込んだあと、そのまま JSON/YAML でエクスポートして Helm values に貼り付けるだけでコード管理できるからです。Terraform のリソース定義に落とし込む手間が不要で、UI で確認しながら作ったものをそのまま反映できます。

Grafana の Helm chart は provisioning の仕組みを持っており、values に書いたダッシュボード定義やアラートルールを起動時に自動で読み込みます。

YAML 管理による制約:削除は deleteRules に明示が必要

アラートルールを YAML から削除しても、Grafana 上のルールは消えません。アラートルールの provisioning は追加・更新してくれますが、削除は行いません。

これは Grafana の設計上の安全策です。file-based provisioning はステートレスで、Grafana は「この YAML がルールの全量である」という保証を持てません。複数ファイルから同時にプロビジョニングできる設計上、「ファイル A にないルール」がファイル B で管理されているかもしれず、Grafana にはどのファイルが何を管理しているか判断できません。また設定ファイルのバグやマウント失敗で一時的にファイルが読めなくなったとき、自動削除だとアラートルールが全消えするリスクもあります。Terraform が管理対象を state ファイルで追跡しているから自動削除できるのと対照的で、file-based provisioning にはその state 概念がないため、削除の意図を明示する仕組みとして deleteRules が用意されています。

ルールを削除するには以下のように UID を指定します。

alerting:
  rules.yaml:
    groups:
      - ...
  deleteRules:
    - orgId: 1
      uid: hoge-alert  # 削除したいルールの UID を明示

6. 専用 NodePool への分離

Prometheus・Loki・Grafana・Alloy といった観測性コンポーネントを、Runner と同じ NodePool に混在させていると2つのリスクがあります。

1つ目は、Prometheus の OOM のような観測性コンポーネントのリソースプレッシャーが同じノード上のランナーの動作に悪影響を与えるリスクです。

2つ目は、Karpenter の consolidation に巻き込まれるリスクです。runner が使う NodePool は業務時間帯の consolidation を無効にしており、早朝の限られた時間帯のみ有効になる設定にしています。この時間帯に consolidation が走ったとき、同じ NodePool にいる観測性コンポーネントの Pod が別ノードに移動させられ、メトリクスやログが欠損するリスクがあります。

専用 NodePool に分離することで、これらの問題をランナーから切り離せます。

Karpenter で dedicated=observability:NoSchedule の taint を付けた専用 NodePool を用意し、Prometheus・Loki・Grafana などの Deployment / StatefulSet 系コンポーネントに対応する toleration と nodeSelector を設定しました。なお、DaemonSet である Alloy と node-exporter は全ノードで動かす必要があるため toleration のみ付与しています。

# NodePool
taints:
  - key: dedicated
    value: observability
    effect: NoSchedule

# 各コンポーネント
tolerations:
  - key: dedicated
    value: observability
    effect: NoSchedule
nodeSelector:
  karpenter.sh/nodepool: observability

監視用コンポーネントについてもランナーと同じく、arm64 on-demand インスタンスを使用するようにしています。

ただし、インスタンスタイプの指定が甘いと Karpenter がコンピュート最適化インスタンス(c6g.large / 4GB RAM)を選んでしまい、Loki や Grafana が OOMKill されるという問題が起きてしまいます。observability スタックのメモリ使用量を実際に確認したうえで、NodePool の requirements に 8GB 以上のインスタンスを指定するなど、きちんとメモリ要件に合ったインスタンスが選ばれるように注意が必要です。

requirements:
  - key: eks.amazonaws.com/instance-memory
    operator: Gt
    values: ["7168"]  # 8GB 以上

7. まとめ

Grafana・Prometheus・Loki・Alloy を Self-hosted Runner の監視基盤として導入するにあたり、各コンポーネントで様々なハマりポイントがありました。特に Prometheus の OOM は複数の原因が重なっており、1つ解決しても次の問題が出てくる形で対応に時間がかかりました。また EKS Auto Mode には通常の EKS(マネージドノードグループ等)と設定において異なる部分があるため、EKS Auto Mode に初めて触る方や移行を考えている方は特に注意してください。

同じ構成を検討している方の参考になれば幸いです。

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 化を検討している方にとって、この記事が何らかの参考になれば幸いです。

参考リンク