タイミーのブログ - TECH PLAY

TECH PLAY

タイミー

タイミー の技術ブログ

304

はじめに 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
はじめに こんにちは、タイミーのプラットフォームエンジニアリングチームに所属している徳富( @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導入の前提条件として非常にフィットした テスト数がどんどん増えていく中で、「全部回す」から「必要なものだけ回す」へのシフトは避けて通れない道だと思います。同じような課題を抱えているチームの参考になれば幸いです。
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-cadvisor ・ kubernetes-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 のメトリクスには image ・ name などのラベルが付いており、同時稼働するランナー数が多いほど cardinality(系列数)が爆発的に増えてしまいます。 そこで、Prometheus に送る前の段階で Alloy 側でメトリクスやログ調査に不要なラベルを drop することで、Prometheus に送られる系列数を極力減らすようにしました。 ちなみに、 image はコンテナイメージ名、 name はコンテナランタイム上のコンテナ ID(containerd では 64 桁の 16 進文字列)が入ります。 name はコンテナが起動するたびに異なる値になるためほぼユニークであり、集計や絞り込みには使わないと判断し、この2つのラベルを drop しました。一方で、メトリクスのクエリで実際に使う namespace ・ pod ・ container といったラベルは残しています。環境に応じて、調査に使わないラベルは可能な範囲で 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-charts ( https://grafana.github.io/helm-charts )で配布されていました。 しかし chart v6.55.0 を最後に OSS Loki 向けの chart は grafana-community/helm-charts ( https://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 に初めて触る方や移行を考えている方は特に注意してください。 同じ構成を検討している方の参考になれば幸いです。
はじめに 株式会社タイミーのプラットフォームエンジニアリングチームに所属している徳富( @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ログ設定を見直してみてはいかがでしょうか。
はじめに こんにちは。プラットフォームエンジニアリングチームに所属している徳富( @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 を選ぶという選択肢も十分あると思います。 似たような観測基盤の選定で迷っている方の、判断の足しになれば嬉しいです。
こんにちは、タイミーでバックエンドエンジニアをしている 福井 (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 フェーズからなる自動化フローを構築しました。 準備: ActiveRecord Query Logs を有効化し、クエリの発行元がSQLコメントとして埋め込まれるようにしておく 観測: Datadog Agent から本番 DB に対して定期クエリを実行し、 performance_schema と information_schema の情報をもとに、テーブルごとにMDLを取得するロングトランザクション時間をカスタムメトリクスとして Datadog に送信する テーブルごとにMDLを保持しているトランザクションのうち、計測時点で最も時間が長い秒数を記録する 検知: Datadog Monitor にてテーブルごとに一定のしきい値を超えるロングトランザクションを検知する 修正 : Datadog Monitor で発火されたアラートをトリガーとして、Datadog Workflow Automation を起動。コンテキストを整理して GitHub Actions 経由で Devin Session を起動し、修正 PR を作成 レビュー : 「修正対象のコードに詳しい人」を自動的に判定してアサイン + 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_schema と information_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 年間で最も多くそのファイルに commit したユーザーとその時点での所属チーム 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 の作成まで行うパターンは、他の問題にも適用できる汎用性があります。そのため、ついつい多用したくなってしまいます。しかし、開発サイクルのどこかに人間が介在する限り持続可能な運用に落とし込むことが重要になっていることをあらためて実感しています。 最後までお読みいただき、ありがとうございました!
はじめに こんにちは。タイミーで 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_PERMISSIVE と ON_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 つの課題に直面しました。 一つずつ深掘りしていきます。 課題A :どのようにGTID モードを有効化するか 課題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 を有効化する値は ON と ON_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-mode を ON に設定しておく必要があります。 詳細は CDC 用に Amazon Aurora MySQL データベースを構成する | Datastream | Google Cloud Documentation をご確認ください。 ご迷惑をおかけした読者の皆様に深くお詫び申し上げます。 そこで、考え方を変えて 「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 化を検討している方にとって、この記事が何らかの参考になれば幸いです。 参考リンク MySQL :: MySQL 8.0 リファレンスマニュアル :: 17.1.6.5 グローバルトランザクション ID システム変数 MySQL :: MySQL 8.0 リファレンスマニュアル :: 19.1.4 オンラインサーバーでの GTID モードの変更 MySQL データベースからデータをストリーミングする | Datastream | Google Cloud Documentation
はじめに こんにちは! タイミーで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 が上がっている」という事実に対して、「どの種類のクエリが寄与していそうか」を特定するには、非常に有用なメトリクスです。 調査は、次の流れで進めました。 Metrics で Reader CPU の上昇タイミングを見る mysql.queries.time から相関関係のある query_signature を見る Count / AVG Duration / Rows Scanned を見て、実行回数と1回あたりの重さを確認する 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 が協力しながら、サービスの成長に伴って生まれる性能課題に向き合っています。
はじめに こんにちは。プラットフォームエンジニアリングチームに所属している徳富( @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コストやバッファプールヒット率で悩んでいる方の参考になれば幸いです。
はじめに こんにちは、初めまして。今年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インスタンスであらかじめ変換してから取り込みました。 検証作業の流れ 今回の検証は、おおまかに次の流れで進めました。 検証環境を構築 検証用の SQL セットを準備 Insight SQL Testing による検査(アセスメント)を実施 検査結果を元に SQL の互換性、パフォーマンス劣化クエリの抽出 検査結果を元に評価レポートの作成 検証上の課題 検証にあたって直面した課題が、 検証対象期間での全量テストは現実的に間に合わない という問題でした。 当初、検査対象の期間は 1ヶ月を想定していました。 これは、週次・月次で実行される定期バッチがあるため、クエリの網羅性を担保するためです。 実際に確認してみたところ、以下のことがわかりました。 1 日あたり約 1 億行 のクエリが流れている 1 時間分の量を流すだけでも 24 時間かかる。このペースで 1ヶ月分(約 31 億行)を全量テストすると、 約 2 年 かかる計算になる 当然これでは時間がかかり過ぎてしまいます。そのため、アセスメントの実行時間を短縮するための対策が必須でした。 SQL 互換性検査とパフォーマンス検査を分けて考える まず、 SQL 互換性検査とパフォーマンス検査を分けて実施 する方針にしました。目的が違うので、必要なクエリの範囲や確認方法も変わるからです。 SQL 互換性検査 の目的: アップグレード後の構文エラーの検出 パフォーマンス検査 の目的: アップグレード後に性能が劣化するクエリがないか確認したい データベースに対するアクセス特性による網羅性 タイミーのシステムでは、データベースに対するアクセス特性を大きく以下の 2 つに分類することができます。 常時アクセスが発生するもの。タイミーのワーカー様(アプリ利用者)・事業者様からの API 経由のデータベースアクセスや、CDC(変更データキャプチャ)によるレプリケーション接続(データ連携用)が該当 一定期間のみアクセスが発生するもの。日次・週次・月次の定期バッチジョブなど、システム内部で決められた日時に実行されるもの 「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 = 1 と WHERE 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件でした。 これらを調査したところ、いずれもアップグレード前後で行の順番が異なることによる差分であり、各行の内容は同一で、保存されているデータ自体に差分は発生していませんでした。 原因は主に二つありました。 ORDER BY で指定したカラムの値が同一になっている行が複数存在しており、同一の値に対する ORDER BY ... LIMIT の結果は非決定的であること 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 の挙動の違いを理解し、目的に合わせて使い分けることで、テスト件数を大幅に削減 効率的なトリアージ : アップグレードに起因する変化に絞り込み、重要な差分に調査リソースを割く もし、今回の自分と同じようにデータベースのアップグレードを検討している方が、この記事が事前検証を行う上で何らかの参考になれば嬉しいです。
はじめに こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。 2026年6月9日〜10日にニューヨークで開催された Datadog の年次カンファレンス DASH 2026 に参加してきました。弊社からは、MLOpsエンジニアの斎藤が「 How Timee Delivers Day 1 Production Ready LLM Features 」というタイトルで登壇していました。 本記事では、Keynote の全体像、タイミーの登壇セッション、そして Fireside Chat から得たメッセージについてお届けします。 DASH 2026 の全体像 公式のKeynoteの記事 にあるように、まさに「Datadog enables teams to build better with AI」といった内容でした。AI がコードを書くスピードは劇的に速くなったが、それを安全に運用するためのループも同じスピードで回らなければ意味がない。この課題に対し、Datadog は Bits AI というAIエージェント群を中核に据えた新製品群を提示しました。 Keynote Keynote で発表された新プロダクトは、AIエージェントによってループを閉じ、開発を高速化するためのものでした。ループは大きくOps・Dev・AI Agent の3つの軸で整理できます。 全発表の詳細は Datadog 公式の記事 に網羅されているので、ここではループごとの要点に絞って紹介します。 Ops ループ (Detect → Investigate → Remediate) 従来人間が手作業で回していた検知・調査・修復のループを Bits AI で自動化する軸です。 Bits Detection (Preview)はサービストポロジーやデプロイ履歴から何が重要かを推測し、本番が赤くなりそうなときだけ発火するモニターを自動で作成・維持します。大量のフレーキーなモニターリストを置き換えるものです。 Bits Memories (Preview)は Slack でのインシデント対応やポストモーテムから運用上の教訓を学習し、将来の調査に適用します。 Bits Remediation (Preview)は根本原因に対して kubectl コマンド実行やコード修正 PR 作成まで自律的に行います。 Bits Infrastructure Operations (Preview)は OOMKilled や証明書期限切れといった日常的なインフラ問題を自動で検知・修復します。 Dev ループ (Code → Deliver → Evaluate) AI コーディングエージェントが加速する開発スピードに対して、リリースの信頼性を追いつかせる軸です。 Bits Code (GA)は Datadog が問題を検出したあらゆる場所(Error Tracking、APM、Code Security 等)から、本番テレメトリを根拠にした修正 PR を生成します。 Bits Release (Preview)は PR の意図を理解し、「新機能が動くか」「リグレッションがないか」の両面でバリデーションプランを自動生成するリリース検証エージェントです。 Bits Testing Agent (Preview)は URL や自然言語のゴール(例:「黒いサングラスを購入して」)からアプリを自律探索し、セルフヒーリングなテストスイートを生成します。 Agent Console (GA)は Copilot / Cursor / Claude Code 等のコーディングエージェントの利用状況を組織横断で可視化し、非効率なパターンの検出やコストアラートを提供します。 AI Agent ループ (Observe → Evaluate → Experiment → Ship) AI エージェント自体を本番で運用・改善するためのループです。 Agent Observability Patterns (Preview)は、本番の LLM トレースを行動パターンに自動分類します。デモでは15,000件のトレースから想定外の「coordination」パターンが $20,000 のコストを生んでいることを発見し、根本原因分析から修正・検証まで一気通貫で行っていました。 Bits Evals (Preview)はトレース・データセット・プロンプトバージョンを横断して仮説検証やプロンプト改善を自動化します。 Data Observability (GA)はデータパイプライン全体のリネージと異常検知を担います。 Infinite Cardinality Metrics (GA)は課金モデルをカーディナリティベースからメトリクス名+データボリュームベースに変更し、高カーディナリティ環境でのコスト予測可能性を大幅に向上させます。 その他 AI Guard (Limited Availability)はカスタムエージェントとコーディングエージェント双方の入出力をインターセプトし、プロンプトインジェクションやツール悪用をリアルタイムでブロックします。 Runtime Prioritization Engine (Preview)は16,000件の CVE から本番で悪用可能な9件に自動で絞り込み、 Security Analyst (GA)がセキュリティ調査を自動化します。 Journey Monitoring (Preview):Synthetics・RUM・Product Analytics を統合し、ユーザージャーニー単位で可用性・パフォーマンス・コンバージョン率を一つのビューで可視化。「CPU が高い」ではなく「その CPU 高騰でコンバージョンが落ちているか」を見る、ビジネスインパクトへの引き上げが狙い Federated Logs (Preview):Log Explorer から離れずに Databricks 等の外部ストレージを横断クエリ Bits Database Optimization :LLM 生成のクエリ最適化案をシミュレート DB で検証してから PR 化。デモでは500クエリから検証済み30件に絞り、ノイズを9割削減 タイミーの登壇:How Timee Delivers Day 1 Production Ready LLM Features dash.datadoghq.com speakerdeck.com 弊社 MLOps エンジニアの斎藤が、タイミーにおける LLM 機能のプロダクション対応と LLM Gateway の構築について発表しました。内容について要約して紹介します。 背景 タイミーでは LLM がワーカー・クライアント双方の体験を向上させる重要な要素になっています。求人票の自動生成をはじめ、多くのストリームアラインドチームが独立して LLM 機能を活用しており、MLOpsエンジニアがその基盤を支えています。 チェックリストの誕生 最初のプロダクション導入では、LLM の不安定さ(タイムアウト、レイテンシスパイク、レートリミット)を想定した設計を行い、Vertex AI をプラットフォームとして採用しました。この経験から、LLM 機能に求められるプロダクション水準を定義した Production Readiness Checklist を策定しました。一般的なプロダクションの品質基準に加え、LLM 固有のシグナル(フォールバック、モデルレイテンシ、コスト制御など)をカバーするものです。 障害による転機 求人票生成機能の導入後、同一プロバイダー起因で2つの LLM 機能が同時にダウンする障害が発生しました。チェックリスト・モニタリング・ゲートウェイはそれぞれ存在していたものの、採用するかは各チームに依存していたことが根本原因でした。 また、高いスピードで価値を届けるという当然の行動をしていた中で、3人の ML プラットフォームチームが全チームに基準を強制するのは物理的に不可能だったのです。 解決策:LLM Gateway この障害を転機に、全 LLM 呼び出しの共通エントリーポイントとして LLM Gateway を導入しました。Cloud Run 上に構築され、複数の LLM プロバイダーを抽象化しています。 ゲートウェイが提供する価値は3つです。 自由な探索:プロダクトチームがセットアップのオーバーヘッドなしにプロンプトやユースケースを試せる 可観測性:全呼び出しにチーム・機能・環境のメタデータが付与され、トレース・レイテンシ・エラー・フォールバックが一箇所で見える ガバナンス:コスト・使用量・レートリミット・安全性が自動的に強制される これにより、チェックリスト(期待値を定義)× モニタリング(コンプライアンスのエビデンス)× ゲートウェイ(パスの強制)が統一化され、すべてのチームが恩恵を得られる状態になりました。 このセッションは「成功事例ではなく、何が壊れ、何を学び、何ができるかの話」という齋藤の言葉通り、実践的な知見が詰まった内容でした。 Fireside Chat OpenAI や Vercel の幹部を招いた Fireside Chat (対談)が非常に印象的でした。それぞれの視点からエージェント時代のソフトウェア開発について語られましたが、共通するメッセージが浮かび上がってきました。 The New Shape of Engineering(Fireside Chat with Datadog CTO Alexis Lê-Quôc and OpenAI Head of Product and Platform Thibault Sottiaux ) dash.datadoghq.com OpenAI で Codex と API Enterprise を率いる Thibaut との対談では、エージェントが組織にもたらす変化が語られました。 特に印象的だったのは、可観測性の役割が根本的に変わるという点です。エージェントの生産性が人間のレビュー能力を超えるにつれ、「すべてのコードをレビューするのか?」という問いに直面します。システム開発は、コードを一行ずつレビューするのではなく、「症状と振る舞い」で監視する、つまり医者が患者を診断するようなアプローチになるというビジョンが語られました。 また、OpenAI 社内ではフォンブースを使っている人の大半が会議ではなく AI と話していたというエピソードや、Thibaut 自身の Codex の使い方がコーディングから情報の統合と組織の把握へシフトしたという話も、エージェント時代の働き方を象徴していました。 エージェントを正しく使うヒントとして、「生産性の幻想を避ける」(並行して動かしていても、本当に意味のある問題に取り組んでいるか?)と「良い状態を説明する」(同僚に期待値を説明するように、エージェントにも伝えること)が挙げられていたのも実践的でした。 Fireside Chat with Datadog CPO Yanbing Li and Vercel CPO Tom Occhino dash.datadoghq.com Vercel CPO の Tom Occhino との対談では、「意図と実装の距離がほぼゼロに縮まった」という時代認識から出発し、プロダクト開発の変革が議論されました。 特に印象的だったのは、仕事を「2つの波長」として捉える考え方です。 Long Wavelength(基盤作業):コアプラットフォームの品質・信頼性・セキュリティを高める作業。AI を使ってプロセスを加速しても、既存の検証・可観測性・レビューはすべて残る Short Wavelength(グリーンフィールド):誰も依存していない新規領域。AI をエンドツーエンドで使ってどんどんシップする 両方の組み合わせが大事であること、そして Long Wavelength 自体を短くしていくこと、つまりリリースエンジニアリングのエージェント化が次の挑戦として語られていました。 共通メッセージ 両セッションは全く別物ですが、かなり共通する点が多かったのが印象的でした。 「作ること」は安く速くなり、人間の価値は"何を・どう良くするか"の定義に移る OpenAI(Thibaut / The New Shape of Engineering):Thibaut は「良い状態を説明せよ」と語りました。実装が安くなったぶん、エージェントに何を期待するのかを伝えないと、エージェントは勝手に仮定を置いてしまう。難しいプロジェクトの期待値を同僚に説明するのと同じように、成功基準と検証方法を明示することが結果を大きく左右する。 Vercel(Tom / Fireside Chat):Tom は「意図と実装の距離がほぼゼロに縮まった」という時代背景からセッションを始めました。だからこそ PM のコア業務である成功の定義、明確な問題設定、顧客理解はなくなるどころか重要性を増す。仕様が明確であるほどコーディングエージェントの出力は良くなるため。 本番・検証・信頼は"無料ではない" OpenAI:Thibaut は、OpenAIのメインエージェントの全アクションを"元の意図"に照らして検証する Guardian(デュアルエージェント安全システム)を紹介しました。そしてAIエージェントへの信頼は一足飛びには得られない。テストとログへ惜しみなく投資し、実績を積み重ねることで漸進的に築かれていくものだと語りました。 Vercel:Tom は「ソフトウェアの構築はほぼ無料でも、本番は絶対に無料ではない」と強調しました。基盤となる作業(Long Wavelength)では、AI でプロセスを加速しても、可観測性・既存システムへの統合・人間によるレビューはすべて残る、と。 可観測性が検証の中心になる OpenAI:Thibaut は、AIエージェントの生産性が人間のレビュー能力を超えていく中で「すべてのコードをレビューするのか?」と問いを投げかけ、システムは医者が患者を診断するように「症状と振る舞い」で監視する時代になると語りました。過去に発火したアラートを分析してノイズを減らす、アラートのトリアージ自体もエージェントが担い始めています。 Vercel:Tom がユースケースで示したのは、本番から得たインサイトでフィードバックループを閉じる「AIエージェント型インフラ」でした。コアのバイタルを常時監視し、リグレッションが起きれば原因を二分探索し、修正もしくはリバートの PR を自動で出す。コードを一行ずつ追うのではなく、本番の振る舞いから全体の健全性を捉える方向に進んでいます。 まとめ DASH 2026 を通じて感じたのは、「検証、品質、安全性の評価を、個人の規律ではなく経路(path)に作り込む」という考え方が、発表全体を貫いていたことです。 例えばBits Release はリリース検証を、AI Guard はセキュリティ評価を、呼び出し経路に強制的に組み込みます。 この時代におけるプラットフォームエンジニアリングの仕事は基準そのものを作ることではなく、基準が自動で適用される経路(基盤)を作ることだと思いました。
はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannKazu1 )です。 タイミーではメインサービスのバックエンドを Rails で開発しています(Go を採用しているプロダクトもありますが、本記事では Rails を前提とします)。 突然ですが、皆さんのチームでは CI の待ち時間、気になっていませんか? 「Push した、コーヒー淹れた、戻ってきた、まだ回ってる……」みたいな経験は、開発者なら一度はあるのではないでしょうか。 本記事では、そんな状況を改善するために GitHub Actions 上のテスト実行パイプラインで取り組んだ 3 つの高速化テク を紹介します。どれも「知っていれば明日から試せる」くらいの温度感なので、気軽に読んでいただければと思います。 1. キャッシュの保存先を GitHub Cache から S3 に移行 課題: actions/cache が安定して速くない 最初にぶつかった壁が actions/cache の速度でした。 vendor/bundle (数百 MB〜1 GB 超)の save/restore でやたら時間がかかることがあり、リストアだけで数分待たされる場面がちょくちょくありました。これはセルフホストランナーに限った話ではなく、GitHub ホステッドランナーでも起きます。 実際、公式リポジトリにも Extremely slow cache on self-hosted from time to time という Issue が立っていて、セルフホスト・GitHub ホステッド問わず同様の報告が寄せられています。 さらに私たちの場合、 AWS 上のセルフホストランナー を使っているのでなおさらです。 actions/cache のバックエンドは Azure Blob Storage のため、セルフホストランナーからだとインターネット経由のアクセスになり、スループットが 約 20 MB/s まで落ちる ケースも報告されています( Actuated Blog )。突発的に遅いうえに経路も遠い——これでは安定した速度は望めません。 容量面でも、リポジトリあたり 10GB の制限があります。また、7 日間アクセスのないキャッシュは自動削除されます。その結果、ブランチが増えるとすぐに上限に達し、必要なキャッシュが消えてしまうのも地味にストレスでした。 解決策: runs-on/cache で S3 をバックエンドに そこで [runs-on/cache](https://github.com/runs-on/cache) を導入し、キャッシュの保存先を 同一リージョン(東京)の S3 バケット に切り替えました。 前述のとおり、セルフホストランナーで  actions/cache  を使うとスループットが ~20 MB/s まで落ちるケースがあります。一方  runs-on/cache  は同一リージョンの S3 を使えるため、200 MiB/s 以上 のスループットが出ます( 公式ドキュメント )。単純計算で 10 倍近い改善 です。 actions/cache とインターフェースがそのまま同じなので、 uses: を差し替えて環境変数を 1 つ足すだけで移行できました。 # .github/actions/setup-ruby-with-s3-cache/action.yml - name : Restore cache uses : runs-on/cache@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : "**/vendor/bundle" key : bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}-${{ hashFiles('Gemfile.lock') }} restore-keys : | bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}- bundle-v1-${{ runner.os }}- なぜ runs-on/cache を選んだか S3 をキャッシュバックエンドにする方法は他にもあります( tespkg/actions-cache 、 whywaita/actions-cache-s3 、自前の aws s3 cp スクリプトなど)。その中で runs-on/cache にした決め手はこのあたりです。 環境変数 1 つで切り替え : RUNS_ON_S3_BUCKET_CACHE を設定するだけで S3 バックエンドに切り替わる 自前実装が不要 : 圧縮・展開・キャッシュキーのマッチング・フォールバックなど、地味にめんどくさい部分を全部やってくれる 容量無制限 : S3 なので 10GB の制限もキャッシュの自動削除もなし キャッシュキーの設計 キャッシュキーは 3 段階のフォールバック構造にしています。 bundle-v1-Linux-3.3.6-<Gemfile.lock のハッシュ> ← 完全一致(最速) bundle-v1-Linux-3.3.6- ← Ruby バージョン一致 bundle-v1-Linux- ← OS のみ一致 完全一致しなくても、部分一致したキャッシュをリストアして bundle install すれば差分の gem だけで済みます。ゼロからインストールするより圧倒的に速いので、新しいブランチでもほぼキャッシュが効く状態を維持できます。 OIDC 認証で安全に S3 にアクセス AWS へのアクセスには OIDC 認証 を使っています。長期的なアクセスキーをシークレットに保存しなくて済むので、セキュリティ面でも安心です。 - name : Configure AWS credentials uses : aws-actions/configure-aws-credentials@v6 with : role-to-assume : arn:aws:iam::123456789012:role/your-gha-role aws-region : ap-northeast-1 2. マイグレーション結果をまるごとキャッシュ 課題: 毎回のマイグレーションが地味に重い テストジョブは毎回データベースをセットアップします。ここで問題になったのが、マイグレーション数が数百を超えてくると rails db:create db:schema:load だけで 数分かかる ということ。 「schema:load だからすぐ終わるでしょ?」と思いきや、テーブル数が多いとそうでもないんですよね。 解決策: MySQL のデータディレクトリごと S3 にキャッシュ 発想を変えて、 マイグレーション済みの MySQL データディレクトリ ( /var/lib/mysql ) をまるごと S3 にキャッシュ することにしました。要は「マイグレーション済みの DB をそのまま持ってくれば、マイグレーション自体を省略できるよね」という作戦です。 仕組みの全体像 【キャッシュの生成】 【キャッシュの利用】 master ブランチ feature ブランチ db/migrate/** 変更 テストジョブ起動 or 毎日定時 │ │ ▼ ▼ S3 からキャッシュをリストア MySQL 起動 → ./tmp/mysql_data に展開 │ │ ▼ ▼ rails db:create db:migrate MySQL 起動(データマウント済み) │ │ ▼ ▼ ./tmp/mysql_data を S3 に保存 rails db:migrate(差分のみ) │ ▼ テスト実行 キャッシュの生成: master で定期的に焼き直す master ブランチでマイグレーションファイルが変更されたとき、または毎日定時に、専用のワークフローがキャッシュを更新します。 # .github/workflows/update-migration-cache.yml on : push : branches : [ master ] paths : - 'db/migrate/**' - 'db/schema.rb' - '.github/workflows/update-migration-cache.yml' - '.github/actions/migration-hash/**' schedule : - cron : '0 2 * * *' # 毎日 UTC 2:00(JST 11:00)に実行 workflow_dispatch : # 手動実行も可能 やっていることはシンプルです。 MySQL コンテナを起動(データディレクトリを ./tmp/mysql_data にマウント) rails db:create db:migrate でフルマイグレーション実行 ./tmp/mysql_data をまるごと S3 にアップロード - name : Run database migration run : bundle exec rails db:create db:migrate - name : Save migration cache uses : runs-on/cache/save@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : ./tmp/mysql_data key : test-${{ runner.os }}-${{ runner.arch }}-mysql${{ steps.migration-hash.outputs.mysql_version }}-${{ runner.environment }}-db-migration-${{ steps.migration-hash.outputs.hash }} キャッシュキーの設計: 何をキーに含めるかが大事 キャッシュキーには地味に気を使っています。 test-Linux-X64-mysql8.0.28-self-hosted-db-migration-<db/schema.rb のハッシュ> │ │ │ │ │ OS ARCH MySQL Ver ランナー環境 スキーマハッシュ ポイントは db/schema.rb のハッシュを含めていること。 マイグレーションの内容が変われば schema.rb も変わる ので、自動的に新しいキャッシュが生成されます。MySQL バージョンやアーキテクチャもキーに入れているのは、バイナリ非互換でハマらないための保険です(一度やらかしました……)。 キャッシュの利用: Composite Action で再利用しやすく キャッシュの利用ロジックは Composite Action に切り出して、RSpec だけでなく Steep(型チェック)など他のワークフローからも使い回しています。 # .github/actions/setup-mysql/action.yml - name : Create MySQL data directory run : mkdir -p ./tmp/mysql_data - name : Restore migration cache id : cache-hit-check uses : runs-on/cache/restore@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : ./tmp/mysql_data key : test-${{ runner.os }}-${{ runner.arch }}-mysql${{ mysql_version }}-${{ runner.environment }}-db-migration-${{ hash }} - name : Start MySQL service with docker compose run : docker compose -f compose.ci.yml up -d mysql8 Docker Compose では、リストアしたデータディレクトリをそのままボリュームマウントします。 # compose.ci.yml services : mysql8 : volumes : - ./tmp/mysql_data:/var/lib/mysql MySQL が起動すると、キャッシュ内のデータファイルがそのまま認識されるので、 マイグレーション済みのデータベースが即座に使える 状態になります。 テストジョブでの分岐: キャッシュがあれば差分だけ 各テストジョブでは、キャッシュがヒットしたかどうかで処理を分岐しています。 - name : RSpec run : | if [ "${{ steps.setup-mysql.outputs.cache_hit }}" == "true" ] ; then echo "Using cached migration data, running incremental migration" bundle exec rails db:migrate # ← ブランチ固有の差分だけ else echo "No cache found, running schema load" bundle exec rails db:create db:schema:load fi キャッシュヒット時 : master のマイグレーション済みデータが復元されているので、 db:migrate で差分だけ適用。たいていは数秒で終わります キャッシュミス時 : MySQL バージョンアップ直後などキャッシュがない場合は db:schema:load にフォールバック この仕組みのおかげで、並列のテストジョブそれぞれで数分かかっていた DB セットアップが数秒になりました。体感で一番効果が大きかった施策かもしれません。 3. CI 用 MySQL のパフォーマンスチューニング 課題: デフォルト設定の MySQL が意外とボトルネック テスト環境の MySQL をデフォルト設定のまま使っていたのですが、ある日ふと気づきました。テストでは各テストケースごとに BEGIN / ROLLBACK やテーブルのクリーンアップが走るので、 書き込みが尋常じゃない量になっている んですよね。 デフォルト設定だと、コミットのたびにディスクへの fsync が走ります。本番では安全のために必要ですが、テスト環境では……正直、オーバースペックです。 解決策: テスト環境に限定して、耐久性よりパフォーマンスを優先する CI 専用の compose.ci.yml で、 データ耐久性を思い切って犠牲にして、書き込みパフォーマンスを最大化 しました。 # compose.ci.yml services : mysql8 : image : ${MYSQL_IMAGE} command : > mysqld --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-innodb-doublewrite environment : MYSQL_ALLOW_EMPTY_PASSWORD : "yes" ports : - "3306:3306" volumes : - ./tmp/mysql_data:/var/lib/mysql 各パラメータの解説 innodb-flush-log-at-trx-commit=0 InnoDB のログ書き込み動作を制御するパラメータです。 値 動作 用途 1(デフォルト) コミットのたびにログをディスクに fsync 本番環境(ACID 完全準拠) 2 コミットのたびに OS バッファに書き込み、 fsync は毎秒 レプリカなど 0 ログの書き込みも fsync も毎秒のバッチ処理 テスト環境 テストで 1 秒以内にクラッシュリカバリが必要な場面はないので、 0 にして コミットごとの fsync オーバーヘッドを完全に排除 しています。 sync-binlog=0 バイナリログ(レプリケーション用)の同期タイミングです。 値 動作 1(デフォルト) コミットごとにバイナリログを fsync 0 OS のファイルシステムキャッシュに任せる テスト環境ではレプリケーションを使わないので、バイナリログを sync_binlog=0 にし、同期を OS のキャッシュに任せることでコミットごとの fsync を省いています。 skip-innodb-doublewrite InnoDB の doublewrite バッファを無効化します。これは書き込み途中のクラッシュに備えて全ページを 2 回書く安全機構なのですが、テスト環境では不要です。無効化すれば 書き込み I/O が大幅に減ります 。 注意: 本番では絶対にやらないでください 念のため書いておきますが、上記の設定は データの耐久性・整合性を犠牲にしています 。 innodb-flush-log-at-trx-commit=0 : クラッシュで最大 1 秒分のトランザクションが消える sync-binlog=0 : クラッシュでバイナリログが不完全になる可能性 skip-innodb-doublewrite : 部分書き込みでデータ破損のリスク テスト環境は「テストが通ればデータは捨てる」使い捨ての世界なので、これらのリスクは許容しています。くれぐれも本番には適用しないように! まとめ 施策 何をキャッシュ/最適化しているか 効果 S3 キャッシュ vendor/bundle (Gem パッケージ) ダウンロード高速化・容量制限の解消 マイグレーションキャッシュ マイグレーション済み MySQL データ DB セットアップ時間を数分→数秒に MySQL チューニング fsync・doublewrite の無効化 テスト中の書き込み I/O を削減 CI の高速化に近道はなくて、結局はボトルネックを一つずつ潰していくしかありません。 DB のデータ耐久性は不要なので無効化する。マイグレーションは毎回ゼロからやる必要がないのでキャッシュする。キャッシュの保存先は、ネットワーク的に近い場所に置く。こうした「当たり前だけど意外とやっていない」割り切りが、大きな高速化につながりました。 同じような課題を抱えるチームの参考になれば嬉しいです。
こんにちは、株式会社タイミーでMLOpsエンジニアをしているKYです。普段はMLプラットフォームの構築・運用を担当しています。 実務の中でコンテナイメージのサプライチェーンセキュリティ強化を進めており、その一環として Docker 社が提供する「Docker Hardened Images(DHI)」の実装を辿る機会がありました。 その際、実際の定義ファイルを見て、少し驚きました。コンテナのビルド定義といえば「Dockerfile」が当たり前だと思っていたのですが、DHI の定義はなんと YAML で書かれていたのです。 「なぜ Dockerfile ではないのか?」と定義の読み方を追いかけていくうちに、BuildKit のアーキテクチャに行き着きました。この記事では、DHI の仕組みを通じて、私たちが普段の Dockerfile 運用で押さえるべきポイントを再確認したいと思います。 ビルド定義の主役は「Frontend」 BuildKit は、「定義を解釈する部分(Frontend)」と「実際にビルドを実行する部分(Backend)」に分離しています。Frontend は、入力(Dockerfile や YAML)を BuildKit の中間表現(LLB)に変換する役割を持ちます。 ここで鍵になるのが、ファイル先頭のコメント行 # syntax=... です。BuildKit はまずこの1行を読み、どの Frontend で後続を解釈するかを決めます。つまり、Docker 公式が推奨しているのに見落とされがちな以下の1行は、単なるコメントではなく「このファイルは公式の Dockerfile Frontend で解釈してほしい」という宣言です。 # syntax=docker/dockerfile:1 一方で DHI の定義ファイルを開くと、YAML の1行目に次の指定があります。 # syntax=dhi.io/build:2-debian13 YAML も # をコメントとして扱うため、BuildKit から見れば「 # syntax= から始まるビルド定義」という意味で入口は同じ。その後の中身を YAML として解釈するのは、差し替えられた DHI Frontend の仕事 というわけです。 DHI は何をしているのか:YAML をコンパイルする DHI の定義ファイル(YAML)は、 RUN apt-get... のようにといった手順を重ねるのではなく、「最終的に何を入れるか」という状態を宣言します。 【DHI の YAML 定義例(実際の定義ファイルからの抜粋)】 # syntax=dhi.io/build:2-debian13 name : Debian 13 Base image : dhi.io/debian-base variant : runtime platforms : - linux/amd64 - linux/arm64 dates : release : "2025-08-09" end-of-life : "2028-08-09" contents : packages : - '!libelogind0' - '!mawk' - '!original-awk' - base-files - bash - ca-certificates - coreutils # ... 以下、ベースに含めるパッケージの列挙が続く accounts : run-as : nonroot users : - name : nonroot uid : 65532 gid : 65532 cmd : - /bin/bash いくつかのフィールドに注目してみます。 contents.packages : !mawk のように ! プレフィックスを付けると「明示的に含めない」パッケージを宣言できます。削除手順を書くのではなく、最初から「入れない」と表明する点が Dockerfile との大きな違いです。 accounts.run-as: nonroot : 実行ユーザーを非 root に固定する宣言で、Dockerfile の USER 命令に相当します。Dockerfile のように RUN useradd ... といったユーザ作成手順を書く必要はなく、「誰で動かすか」という状態だけが残る点が特徴です。 dates.end-of-life : イメージのライフサイクル終了日まで定義に含まれており、運用上の管理情報もビルド定義の一部として扱われています。 このように、DHI の YAML は「どう作るか」ではなく「何が入っていて、誰が動かすか」を宣言しています。そしてここで重要なのは、 BuildKit が YAML を直接ビルドしているわけではない という点です。 DHI の Frontend がこの YAML を読み込んで中間表現(LLB)へコンパイルし、あとは通常通り BuildKit がビルドを実行します。つまり、DHI の YAML は「別言語」ではなく、 Frontend を差し替えて得た “別の入力形式” なのです。 たとえば不要パッケージの除外ひとつとっても、Dockerfile では apt-get remove → autoremove → キャッシュ削除と手順を重ねる必要があります。一方、DHI なら - '!mawk' の1行で意図が完結します。手順(How)ではなく意図(What)だけが残るため、セキュリティ監査や再現性の面で有利です。DHI が宣言的定義を採用しているのは、こうした相性の良さがあるからです。 忘れられがちな Dockerfile の公式推奨設定 今後、DHI のような宣言的フロントエンドがすぐに主流になるかは未知数であり、当面は既存の Dockerfile 運用が続くでしょう。 しかし、DHI が示す「Frontend は明示し、選ぶものである」という観点は重要です。まずは Docker 公式が推奨する以下の2行を、忘れずに Dockerfile の先頭へ記述しましょう。 # syntax=docker/dockerfile:1 # check=error=true # syntax=... 使用する Frontend を固定し、手元の環境と CI の違いによるビルド結果の揺れを防ぎます。 # check=error=true BuildKit の静的解析(lint)を強め、警告レベルの記述を CI で弾けるようにします。 これらを習慣づけるだけで、「Frontend を明示し、品質を保つ」文化に確実に近づきます。 まとめ DHI から学べる本質は、 BuildKit は Frontend を自由に差し替えられる という点にあります。この視点を持つと、DHI は単なるセキュアなベースイメージではなく、ビルド定義の抽象度を一段上げる試みとして見えてきます。 「手順を書く」から「状態を宣言する」への移行は、Infrastructure as Code で何度か見てきた流れと重なって見えます。DHI を触ってみて、その発想がコンテナビルドの入力形式にも持ち込まれていることを実感しました。 将来的にビルドのパラダイムがどう変わるにせよ、まずは見逃されがちな # syntax=... と # check=... をきちんと置くこと。タイミーでも Cloud Run / Vertex AI Pipelines の DHI 移行を進める中で、Frontend 指定の差がビルド結果の揺れに直結する場面に何度か遭遇し、この2行の重要性を改めて感じました。DHI がもたらした視点を持ちつつ、足元の運用を公式のベストプラクティスで堅牢にする。これが、現実的で安全なコンテナ運用の第一歩です。 参考文献 Docker Hardened Images - カタログリポジトリ Debian 13 Base 定義ファイル(13.yaml) — 記事中の YAML 定義例の抽出元 Custom Dockerfile syntax - Docker Docs Build hardened images - Docker Docs We're Hiring! サプライチェーンセキュリティや ML 基盤の足回りに興味を持っていただけたなら、ぜひ一緒に働きませんか。タイミーでは、ML プラットフォームの構築・運用やサプライチェーンセキュリティの強化に取り組むエンジニアを募集しています! 少しでも興味を持っていただけましたら、ぜひ以下のリンクから詳細をご覧ください。 MLOpsエンジニア シニアMLOpsエンジニア 募集ポジション一覧
こんにちは、株式会社タイミーで MLOps エンジニアをしている KY です。普段は ML プラットフォームの構築・運用を担当しています。 私たちのチームでは、機械学習エンジニアやデータサイエンティストが開発に集中できるよう、VS Code のリモート開発(Remote SSH および Dev Container)を活用した開発環境を提供しています。本記事では、その中でも 共通 Dev Container Feature によるガードレール にフォーカスし、各チームが自分たちで開発環境を立ち上げられることを前提にしながら、 セキュア・バイ・デフォルト をどう実現しているかをご紹介します。 なぜ Dev Container Feature にガードレールを寄せるのか この記事を書こうと思ったきっかけは、もともと機械学習エンジニアやデータサイエンティスト向けだった開発環境を、データアナリストをはじめとする別職種のメンバーにも広げ始めたことでした。ユーザー層が広がるにつれ、「どこまでを各自の設定に任せ、どこからを仕組みで縛るか」をあらためて考え直す必要が出てきた、というのが出発点です。あわせて、組織として求められるセキュリティレベルも年々高まってきています。 ML プラットフォーム特有の事情として、ユーザーの専門領域が幅広い、という点があります。機械学習エンジニアやデータサイエンティストはモデリングやデータ分析を主戦場としており、依存パッケージの脆弱性管理やコンテナの権限設計といった領域は、本来の業務の中心ではないことが多いです。だからこそ、これらをユーザー個々の習熟度に委ねるのではなく、プラットフォーム側で初期値を配る方針を取りました。 各チームがセルフサービスで開発環境を立ち上げられ、特別な設定をしなくても初期状態でセキュリティのベースラインが担保される 状態を目指しています。推奨パスに乗るだけで安全に進められる、いわゆる「ゴールデンパス」の発想であり、 セキュア・バイ・デフォルト を仕組みで成立させるアプローチです。 この方針を devcontainer.json レベルで素直に表現できる仕組みが Dev Container Feature でした。Feature を1行足すだけで宣言的にガードレールが適用されるため、「各チームが自律的に環境を立ち上げつつ、危険な操作だけは仕組みで塞ぐ」という設計とよく噛み合っています。 共通 Dev Container Feature によるガードレール 私たちの開発環境では、共通化した Dev Container Feature(以下、共通 Feature)を配っています。まず、ベースイメージと Feature の役割は明確に分けています。 Docker Hardened Images(以下、DHI)をベースにした開発用イメージでは、各種開発ツール(Python / uv / gcloud / Claude Code など)をインストールしておきます 。 共通 Feature では、それらツールの設定ファイル配置とガードレール適用のみを担います 。 この前提のもと、各チームの devcontainer.json は以下のようにシンプルで、ベースイメージを指定し、共通 Feature を追加するだけで、後述するガードレールがまとめて適用されます。 { " image ": " asia-northeast1-docker.pkg.dev/<PROJECT>/<CUSTOM_DHI_PATH>:<TAG> ", " features ": { " asia-northeast1-docker.pkg.dev/<PROJECT>/<CUSTOM_FEATURE_PATH>:<TAG> ": {} } } こうしてレイヤーを分けておくと、ツールの入れ物とポリシーの適用が混ざらずに整理されるため、 よりセキュアに締めやすい という体感があります。たとえばポリシー側だけを Renovate で継続的に更新していけるので、イメージの差し替えと独立してセキュリティ設定の追従・レビューを回せます。なお、ベースイメージ側で押さえるべきリスク(OS パッケージの脆弱性など)と、Feature 側で押さえるべきリスク(ツールの権限・設定)をどう切り分けるかといった論点もあります。ただし本記事のスコープ外のため、詳細は割愛します。 この Feature がプロビジョニング時に各種設定ファイルを配置し、ガードレールを自動で効かせます。実際には複数のツール設定を同じ方式で配布していますが、本記事では代表例として AI エージェントの制御を取り上げます。 Claude Code などの AI エージェントの制御 昨今、 Claude Code のような AI コーディングエージェントが普及していますが、無制限の権限を与えると破壊的変更や意図しないデータ送信のリスクがあります。共通 Feature は /etc/claude-code/managed-settings.json を自動生成し、システムレベルで制御を行います。 { " strictKnownMarketplaces ": [ { " source ": " github ", " repo ": " <ORGANIZATION>/<REPOSITORY> " } ] , " allowedMcpServers ": [ { " name ": " <APPROVED_MCP_NAME> ", " command ": " ... " } ] , " permissions ": { " deny ": [ " Bash(sudo:*) ", " Bash(gcloud:*) ", " Read(~/.config/**) " ] } } ※ 実際の設定から一部を抜粋しています。 プラグインマーケットプレイスと MCP サーバーは、社内で承認されたもののみに制限しています(ホワイトリスト形式)。また、 sudo や gcloud などの権限昇格・クラウド操作、 ~/.config/ 配下の機密情報へのアクセスといった危険な操作は、Deny リストでブロックしています。ユーザー側の settings.json では上書きできない managed settings として配置しているため、「うっかり緩めてしまう」ことを構造的に防げます。 Feature に寄せることの嬉しさ これらを共通 Feature として提供していることで、以下のようなメリットが得られています。 各チームの devcontainer.json は Feature を1行足すだけでよく、 セキュリティ設定の知識なしにベースラインを満たせる 。 Feature のバージョンを上げるだけで、 全社的にガードレールを一括更新できる (Renovate で自動 PR される)。 設定の出所が Feature に集約されているため、 監査やレビューの対象が明確 になる。 実際に運用してみると、Renovate の PR を1本マージするだけで全チームの Claude Code 設定が同時に更新されるのは、想像していた以上に運用が軽くなったと感じています。 補足:周辺で効かせている多層防御 共通 Feature だけで全てを押さえようとせず、周辺の仕組みと組み合わせて多層防御を成立させています。ベースイメージには DHI を採用してコンテナ起動時点でのベースラインを引き上げ、ホストとなる Remote SSH 用 VM 側にも同等のポリシーを展開し、依存関係は Dependabot / Renovate で継続的に追従させる、という具合です。 おわりに 今回は、MLOps チームが 共通 Dev Container Feature を使って、ML 開発環境のガードレールをどのように設計・運用しているかをご紹介しました。 振り返ってみると、 ツールは DHI イメージ、設定は共通 Feature、更新は Renovate と責務を分けておくと、それぞれに対するレビューや更新のサイクルを独立して回しやすいのが大きな利点でした。ガードレール自体を作ることよりも、 ガードレールを錆びさせない構造 に落とすことが、各チームの自律性を損なわずにベースラインを引き上げていくうえでの要だったように思います。 参考文献 Claude Code - System settings : /etc/claude-code/managed-settings.json に関する公式ドキュメント Dev Containers - Features : Dev Container Feature の仕様 こうした「セキュア・バイ・デフォルトな ML 開発環境」を、より多くのチームと一緒に磨き込んでいきたいと考えています。 We're Hiring! タイミーでは、ML プラットフォームの構築・運用やセキュアな開発環境の整備に一緒に取り組んでいただけるエンジニアを募集しています! 少しでも興味を持っていただけましたら、ぜひ以下のリンクから詳細をご覧ください。 MLOpsエンジニア シニアMLOpsエンジニア 募集ポジション一覧
1. 自己紹介・経歴 はじめまして、データアナリストのrizumuです。2025年にタイミーに入社しました。 前職ではファッション系のCtoCマーケットプレイスを運営する会社で約4年間データアナリストとして働いていました。UIの分析やクーポン施策の効果検証、顧客セグメントの分析などを担当していました。 2. なぜタイミーを選んだか 転職活動で最も重視したのは、アナリストが多い環境で働きたいという点でした。 前職のチームでは、少人数ならではのスピード感や、幅広い領域を任せてもらえる環境に感謝していました。一方で、「この分析アプローチで良いのか」「他のアナリストはどう考えるのか」と壁打ちをしたい場面では、相談できる相手が限られるもどかしさもありました。分析は一人で完結させようと思えばできてしまう仕事ですが、だからこそ他のアナリストの視点に触れる機会が、自分の成長には欠かせないと感じていました。 一方タイミーは、データを事業の意思決定に活かすことに組織として積極的に投資している姿勢が、選考を通じて伝わってきました。この人数の差は単なる規模の話ではなく、分析の型や議論の文化に触れられる機会が増えることを意味します。ここでならさらにアナリストとしてのスキルを伸ばせると感じたので、タイミーに転職することにしました。 3. 入社して驚いたこと 1つのプロジェクトに複数のアナリストが関わる体制 まず驚いたのは、プロジェクトの体制です。タイミーでは1つのプロジェクトに必ずリードメンバーが一人いて、その上で領域ごとに担当が分かれる形で複数のデータアナリストが関わります。 前職では、基本的に1プロジェクトにつきアナリストは一人、という体制でした。同じチームにアナリストはいても、プロジェクトの深い文脈を理解しているのは担当者だけ。他のメンバーに相談したい場面でも、まずは理解の前提をそろえるために背景共有から始める必要がある場面がありました。 結果として、一人で抱え込んで意思決定する場面が多かったように思います。 タイミーでは、同じプロジェクトに対して最初から共通理解を持った仲間が複数いる。だから「この切り口でいいのか」「この数字の解釈、どう思う?」といった相談を、前提の説明なしにその場で進められます。共通理解を持った相談相手がいる状態は、分析の進めやすさを変えてくれました。 リードメンバーへの相談ハードルの低さ もうひとつ驚いたのが、リードメンバーへの相談のしやすさです。ここでのリードとは、直接の上司ではなく、プロジェクトの中でリードの立ち位置を担うアナリストを指します。Slackで気軽に聞けるのはもちろん、リモート中心の環境なので、「今すぐちょっと相談したい」と思えばGoogle Meetで時間をもらうこともできます。立ち上がり期には、その点に助けられました。 ダッシュボードに求められるクオリティの高さ 想像以上だったのが、ダッシュボードに求められる見やすさ・使いやすさの水準です。データアナリストだけでなく営業など異なる職種の方も使うため、数字が正しいだけでは足りず、誰が見ても意図が伝わり、迷わず使えることが要件になります。ダッシュボード単体で完結する品質が必要、という感覚は業務に入って分かった部分でした。 これらを通して感じるのは、アナリストが多い環境の価値が、日々の相談しやすさやアウトプット基準の高さという形で具体的に現れている、ということです。 4. 半年やってみて、これから 具体的な業務エピソードでいうと、あるプロジェクトで担当したダッシュボード構築の案件が思い浮かびます。ステークホルダーが使い道の想いは持っているものの、ダッシュボードとして何を見るべきかの要件は固まっていない状態からのスタートでした。まず叩き台を作り、リードのデータアナリストからフィードバックをもらいながら要件を詰めていき、最終的には当初の目的に沿った形に仕上がったと評価をいただけました。 このプロジェクトに限らず、目的を起点に形を組み立てていく仕事を経験する中で、半年で一番変わったと感じるのは、以前より目的を強く意識するようになったことです。事業部の業務を十分に理解できていない状態から関わることが多く、目的を掴めていないとアウトプットはすぐにブレてしまう。解像度高く目的を持ち続けることの優先度が、自分の中で上がりました。加えて、自分一人では思いつかない分析の切り口や議論の進め方に日々触れられるのは、データアナリストが多い組織ならではだと実感しています。 今後チャレンジしていきたいのは、AI活用の幅を広げることです。SQL生成などは日常的に使っていますが、最近特に手応えがあるのはダッシュボードのモック作成です。自作のClaudeスキルを使うことでイメージに近いモックが最初から出てきます。一方で実装工程はまだ手作業が中心なので、ここをもっと簡単に進められる仕組みを作っていけたらと感じています。 AI活用の面白さは、個人の業務が速くなるだけにとどまりません。データアナリストが多い組織とAIへの意識の高さが重なると、一人のスキルや得意領域を他のメンバーも扱えるように広げていけます。自分が扱えるスキルやチャレンジできる領域も自然と広がっていく、この循環こそ、この半年で感じているタイミーで働くことの面白さだと思っています。 もし、かつての自分と同じように小規模なデータアナリスト組織で働いていて、次のステージを考えている方や、AIを活用しながらデータアナリストとしての幅を広げていきたいと考えている方がいれば、この記事が何かのきっかけになれば嬉しいです。 We're Hiring! タイミーでは、ともに働くメンバーを募集しています! データアナリストのポジションも募集中です。カジュアル面談も行っていますので、少しでも興味がありましたら、お気軽にご連絡ください。 https://product-recruit.timee.co.jp/
こんにちは。昨年度まで社会人大学院生(修士課程)として学び、無事卒業した Hunachi です 🙌 研究生活の中で、 SICS 2026 と DEIM 2026 に参加し、論文の執筆や発表、ポスター発表をしてきました。 私の研究内容は「Android搭載端末での pKVM 環境を使ったセキュアな声紋認証の実装と評価」です 👀 このブログでは、 私が研究で扱っている pKVM ってなに? どんな研究をしていたのか(ざっくり) 学会に参加したり、論文を書いて発表してみての感想 社会人大学院生をしてみた感想 以上の4 本立てで、私の研究や大学院生活について紹介していきます。 SCISは函館開催でした。その時に食べたごっこ汁 🐟  pKVM ってなに? モバイル端末でも「セキュアな実行環境」が欲しい 最近のスマートフォンでは、生体認証・決済・オンデバイス AI(Gemini Nano など)と、機密性の高い処理を端末上で動かす場面がどんどん増えていますよね。 Android でのセキュアな環境としては 2014 年から Trusty TEE (Trusted Execution Environment)という ARM TrustZone ベースの隔離環境が使われてきました。Android の一般的なアプリが動作する環境( REE: Rich Execution Environment )とは、ハードウェアレベルで分離されたセキュアな環境です。そのため、堅牢なセキュリティを実現できます。 ただし TEE には以下の弱点があります。 利用できるメモリが 数 MB 程度 ととても小さい 開発のハードルがそれなりに高い 端末のベンダーによってセキュリティの質がまちまち 特に利用できるメモリが少ないので、DNN モデルなどを動かすのは大変困難です 😖 pKVM の登場 そこで Android 13 から導入された Android Virtualization Framework(AVF) の中核として、 pKVM(Protected KVM) という仮想化技術が組み込まれました。 ざっくり言うと、 ベースは Linux 由来の KVM (Kernel-based Virtual Machine) そこに「ホスト OS からも触れない VM( Protected VM, pVM )」という概念を載せる 端末の物理メモリ容量いっぱいまで使える隔離環境が手に入る という、Trusty TEE のメモリ制約を解消した比較的新しい技術です 🚀 ちなみに数年前、「 Pixel で root を取らずに Linux(Arch や Ubuntu)が動かせる 」という話題、目にした方もいるんじゃないでしょうか。Danny Lin 氏の Nestbox というアプリで Android 上に Linux VM を立ち上げるものです( 参考記事 )。この基盤になっているのがまさに pKVM で、「ホスト OS から保護された VM」という枠組みを使えば、セキュリティ用途だけでなく汎用的な OS だってホストできてしまう、というのを実証した一例です。 pKVM のアーキテクチャをざっくり ARM のアーキテクチャでは、特権レベルが Exception Level(EL) という階層で分かれています。pKVM 環境に関する階層分けはこのようになっています。 EL2 : pKVM ハイパーバイザ EL1 : Android Host OS と Protected VM EL0 : ユーザアプリケーション EL2 で動く pKVM が ステージ 2 ページテーブル を使って、ホスト OS からの pVM メモリへのアクセスを物理的に遮断します。さらに IOMMU を使うことで、DMA デバイス経由の不正アクセスもブロックしてくれます。 また、pKVM上で動かすプログラムはC/C++で書く必要がありますが、TEE向けアプリの開発に比べれば容易です。 セキュアな環境を成り立たせる仕組み pKVM(AVF)の凄いところは、ただメモリを隔離するだけじゃない点です。 pvmfw (Protected VM Firmware)がペイロードの署名を検証して改ざん検知 DICE (Device Identifier Composition Engine)プロトコルで pVM ごとのシークレットを導出 DICEで導出したシークレットからsealing secretを生成し、さらにsealing keyを作成して永続データなどを暗号化 pVM 終了時にはハイパーバイザがメモリページをゼロクリアして残留防止 つまり、コードの正当性 → 起動時のシークレット → 永続データ → 終了時の残留防止 まで一貫してハイパーバイザがケアしてくれる、という設計です。 そして 2025 年 8 月、Google が pKVM で SESIP Level 5 認証を取得したと発表しました 🎉 SESIP(Security Evaluation Standard for IoT Platforms)は IoT デバイス向けセキュリティ評価基準で、Level 5 は最高レベルです。 大規模消費者向けに展開されるソフトウェアセキュリティシステムとして取得したのは世界初 で、最新かつかなりセキュアな技術であることがわかります( Google Online Security Blog )。 私の研究をざっくり やったこと ここからは自分の研究をかなりざっくり紹介します。 タイトルは「 Google Tensor 搭載端末の pKVM におけるセキュアな音声処理および声紋認証の実装手法と課題の検討 」です。 論文はこちらから読めます 👉 DEIM2026 3D-01 すごく簡単に言うと、 (pKVM環境)上で話者識別のDNNモデルを動かし、実用可能な処理速度で動作する声紋認証システムアプリを実現 pKVM のメモリアクセス特性を細かく測定 提案システムの認証精度・処理時間・pKVMのVM 起動時間などを多角的に評価 を行った論文です。 そしてありがたいことに、この発表で DEIM 2026 学生プレゼンテーション賞 をいただきました 🎉 一緒に研究を進めてくれた共著の先生方、コメントをくださった皆さん、本当にありがとうございました 🙇 まだまだ改善の余地がたくさんある研究内容ですが、興味のある方は論文を読んでもらえると嬉しいです 🙇 学会の感想 SICS に参加した感想 SICSは、以前は暗号系の発表が多かったようですが、最近は傾向が変わってきたようです。セキュリティ関連の発表では、高レイヤの話も多く見られました。特にLLMのセキュリティや研究方法に関する講演や発表が印象的でした。最先端のLLMの研究をしている日本人研究者もいることや、LLMのセキュリティの研究がどこまで進んでいるかの話を聞くことができ、面白かったです。 DEIM に参加した感想 たくさんの学生さんが参加している学会で、色々な研究の発表やポスター発表を見ることができました。特に土日にリモート開催だったので、社会人の私にとって大変嬉しかったです。LINEヤフーさんのDBの話なども興味深く聞かせていただきました。 最近の研究は、やはりLLM関連が多く、自分も研究でLLMも扱えるよう、ある程度は詳しくならないといけないと思いました。 論文執筆・発表・ポスター発表をしてみた感想 学部時代の研究をそのまま続けなかったこともあり、成果が出せる研究テーマにたどり着くまで時間がかかり、とても大変でした。一方で、先生方の助言やAIの活用により、先行研究や最新技術の調査を効率化できました。その結果、成果を出せてよかったです。 また論文を執筆するにあたり、慣れない部分については、AIに手助けしてもらいながら執筆しました。4年前の学部時代や高専時代に論文を書いた時と比べて、LaTeXのエラーに悩まされる時間や、誤字脱字の修正にかかる時間が、ほぼゼロになりました。本当に楽な時代になったなと感じます。 発表では厳しめの質問をいただくこともありましたが、それ以上に嬉しいこともありました。似た研究をしている方が少ないにもかかわらず、特にDEIMでは私の研究に興味を持って質問してくださる方が多く、とても嬉しかったです。 人に自分の研究内容を伝えることは、社会人におけるプレゼンテーションを行う際にも活かせるなと思いました。 社会人大学院生(修士課程)をしてみた感想 大学の教授やD進している同期、夫の家事サポートがあったからこそ、卒業できました。関係者の皆さんに感謝しかありません。 人におすすめできるかというと、とても忙しい生活スタイルになるため、研究が趣味な人以外にはおすすめしにくいです。ただ、AIの活用で調査や文章執筆が容易になった今の時代だからこそ、「チャレンジは可能だ」と思います。 私の感じたメリット・デメリット メリットは、金銭的な問題で困りにくいことです。いろいろな理由があり、猫と暮らしている自分には働かないという選択肢がなかったため、社会人学生を選びました。働きつつ学生でいることを許してくれた大学の教授には感謝しかありません。そのおかげで猫と暮らしつつ学費も安定して払うことができました。 デメリットは以下のとおりです。 大学以外のことをするプライベートな時間がかなり少なくなること 研究に時間を費やす必要があるのはもちろんのこと、学会や授業の参加で有給が消費されます 仕事や大学が忙しい時期には睡眠時間以外はパソコンの前にいる、というような不健康な生活が日常になること 学生らしい生活ができないこと 私の場合は、大学に行く時間が取れず在宅で研究を行なっていた関係で、友人と研究室でおしゃべりしたり、飲み会や合宿への参加などはできませんでした。 また私は、学部時代に大学院の授業単位を取得できる制度を活用していたため、大きな問題はありませんでした。ただし、大学や単位の取得状況によっては、授業のために有給を使う必要が出てくるかもしれません。さらに、大学生らしい生活が送れないのはもったいないと感じるため、個人的には可能であれば通常の大学院生として通うほうがよいと思います。 ※ 私の大学生活のほとんどはコロナでオンラインだった関係で大学生活をまともにしたことがないので意見が偏っている可能性もあります。 ただ、事情があり社会人になる必要がある人やすでに社会人の方で、研究をしたい・続けたい人は十分頑張ってみる価値があると思うので応援しています 🚩 おわりに 引き続きpKVMや研究関連の勉強は続けようと思っています 🧑‍🎓 最後まで読んでくださってありがとうございました!
こんにちは。タイミーのデータエンジニアリング部 DSグループでMLOpsを担当しているYukitomoです。 私たちのチームでは多くのPythonアプリをモノレポで管理していますが、Dependabotによる依存関係更新PRが多すぎることが運用課題でした。本記事では、Renovateへの移行によって「更新PRの粒度と数をコントロールできる運用」を実現するまでの設計判断と、Python + uv環境特有の注意点を共有します。 この記事の想定読者 Pythonのモノレポ環境で、複数のアプリケーションやライブラリを運用している方 Dependabotが生成する大量の更新PRの対応に疲弊しており、運用を効率化したい方 Renovateへの移行を検討している、または導入したが設定(packageRules)のベストプラクティスに悩んでいる方 パッケージマネージャーに uv を採用している(または検討している)方 要約(TL;DR:この記事でわかること) 本記事では、Python + uv環境でRenovateを運用する際の課題とその解決策(新しすぎるパッケージの除外設定、Google Cloud WIFにおけるブランチ名の文字数制限の回避など)を整理し、実践的なrenovate.json5の設定ノウハウを解説します。 背景 近年はサプライチェーン攻撃が現実的なリスクになっており、Trivyの侵害以降も Python モジュールや JS ライブラリを狙った攻撃が継続して観測されています。PyPI など外部エコシステムに依存する以上、これまで以上に「依存関係をどう安全に運用するか」を真面目に考える必要があります。 一方で、依存関係を「安全に」保つには、継続的にアップデートを回し続ける必要があります。ここで次の課題になるのが、運用対象が増えたときに更新対応のコストがスケールしてしまう点です。 私たちもDependabot運用の効率化を進めてきましたが *1 、アプリごとにパッケージ管理へ移行した結果、モノレポ内のpyproject.tomlが増えました。2026年5月時点では、DSグループだけでも約70のPythonアプリケーション/ライブラリを扱っています。Dependabotは脆弱性の検知とPR作成を行ってくれる一方で、依存関係ごとにPRが分割されます。そのため、対象が増えるほど対応コストが急増します。 そこでこの課題を解決するため、Renovateを導入し「更新をまとめて扱える運用」へ切り替える方針にしました。本記事では、公式ドキュメントや公開されている設定例を参考にしつつ、私たちが重視した設定ポイントを整理します。 この記事の前提 言語: Python 依存関係ファイル: pyproject.toml / uv.lock 動作環境: GitHub & Google Cloud 目的: Renovateで「脆弱性対応」と「定常アップデート」を破綻なく回す(PRの数と粒度をコントロールする) 設定ファイル(.renovaterc.json5) 設計方針 私たちが設定で重視したのは以下の3点です。 PRの粒度をコントロールする — patch / minor / vulnerability を適切にグルーピングし、PRの本数を削減する サプライチェーンリスクを軽減する — 公開直後のバージョンを即座に採用しない 小さく始める — まず許可リスト方式で必要な更新だけを有効化し、段階的に広げる 全体像 以下が設定ファイルの抜粋です(各設定の詳細は後述)。 // .renovaterc.json5 より一部抜粋 { extends : [ " config:best-practices " ] , minimumReleaseAge : " N days ", lockFileMaintenance : { enabled : true , branchTopic : " lfm ", // GCP WIF 127-byte limitに対応するためブランチ名を省略 minimumReleaseAgeBehaviour : " timestamp - optional " // 一時的な対応 } , vulnerabilityAlerts : { groupName : " maintenance ", groupSlug : " maint ", minimumReleaseAge : " 14 days " , } , packageRules : [ // packageFileDirをブランチ名に含めつつGCP WIF 127-byte limitに対応するためブランチ名を省略 { matchFileNames : [ " base_containers/base/** " ] , additionalBranchPrefix : " {{{replace 'base_containers/base/' 'b_b/' packageFileDir}}}/ " , } , // packageRuleを一旦無効化 { matchPackageNames : [ " ** " ] , enabled : false } , // グルーピング ---------------------------------------------------- // ルール 1: { matchUpdateTypes : [ " patch " ] , enabled : true , groupName : " maintenance ", groupSlug : " maint " , } , // ルール 2: { matchUpdateTypes : [ " minor " ] , matchJsonata : [ " isVulnerabilityAlert = false " ] enabled : true , groupName : " minor updates ", groupSlug : " minor ", dependencyDashboardApproval : true } , // ルール 3: { matchPackageNames : [ " ** " ] , matchJsonata : [ " isVulnerabilityAlert = true " ] enabled : true , // この2つは vulnerabilityAlerts で設定した値で上書きされます groupName : " maintenance ", groupSlug : " maint " , } , // マイナーレベルでの破壊的な更新の抑制 ただし脆弱性対応を除く { matchJsonata : [ " isBreaking = true and not(isVulnerabilityAlert) " ] , enabled : false , }, ] , // バージョンの更新 bumpVersions : [ { bumpType : " patch ", filePatterns : [ " {{packageFileDir}}/pyproject.toml " ] , matchStrings : [ " version \\ s*= \\ s* \" (?<version>[^ \" ]+) \" " ] } , { bumpType : " patch ", filePatterns : [ " {{packageFileDir}}/uv.lock " ] , matchStrings : [ " name = \" [^ \" ]+ \"\\ nversion = \" (?<version>[^ \" ]+) \"\\ nsource = \\ { (?:editable|virtual) = \"\\ . \" \\ } " ] } ] } 設定項目の説明 config:best-practices を土台にする Renovateは設定可能な項目が多く、ゼロから組むと必ず設定が肥大化します。そこでまずは extends: ["config:best-practices"] をベースにして、一般的に安全側なデフォルトを取り込みました。 ベース設定を取り込んだうえで、運用上の要所(PRの粒度、セキュリティ例外、ロックファイルの扱い)だけを packageRules で上書きしています。 minimumReleaseAge (新しすぎるリリースを避ける) サプライチェーン観点では「出たばかりのバージョンを即座に拾う」ことが常に正解とは限りません。そこで minimumReleaseAge を設定し、公開直後のバージョンを一定期間は自動採用しないようにしています。 ここで一つ注意点があります。 minimumReleaseAge が効くのは、Renovateが pyproject.toml のバージョン指定を直接書き換える通常の更新(Standard Update)だけです。 一方、 pyproject.toml には記載されず uv.lock にだけ現れる間接依存(依存パッケージがさらに依存しているパッケージ)の更新は、Renovateの lockFileMaintenance が担当します。 lockFileMaintenance は内部で uv lock を実行しますが、現時点ではRenovateから uv lock に --exclude-newer 等のオプションを渡す手段がありません *2 。つまり、間接依存に対しては minimumReleaseAge による「新しすぎるバージョンの除外」が効かないのです。 そこで、uv自身が持つ exclude-newer 機能で補完しています。各 pyproject.toml に以下のように記述することで、 uv lock 実行時にuv側で公開直後のバージョンを除外します *3 。 # pyproject.toml [tool.uv] exclude-newer = "n days ago" # uv 0.10+ で相対日付指定が可能 # uv.lock (uv lock 実行時、以下のように変換されて保存されます) [options] exclude-newer = "2026-04-07T08:39:48.633055Z" exclude-newer-span = "PnD" この二重構成により、直接依存は Renovate の minimumReleaseAge 、間接依存は uv の exclude-newer でそれぞれカバーし、すべての依存パッケージに対して「新しすぎるバージョンを即座に採用しない」制約を適用しています。 lockFileMaintenance (Google Cloud Workload Identity Federationの制約対策とminimumReleaseAgeBehaviourの調整) 明示的に有効化します。また、プライベートなモジュールを独自のArtifactoryやNexusなどのパッケージインデックスに配置・利用することはよくあると思います。我々はGoogle Cloudを利用しており、プライベートなパッケージは Google Artifact Registry に保管しています。この場合、RenovateからGoogle Cloudにアクセスが発生し、Workload Identity Federation (WIF) を使う構成だと、subject claimにブランチ名が入ります *4 。ここに127 bytes制約があり、Renovateが生成するブランチ名が長いと認証に失敗します。対策として、作成するブランチ名を短縮化するため、 branchTopic の値を短縮しています(デフォルトでは “lock-file-maintenance”)。 この値を調整して通常の更新とブランチ名を一緒にし、PRを一緒にできないかと試したのですが、 Grouping lockfile maintenance with other update types is not supported というエラーメッセージが出たため断念しました。 minimumReleaseAgeBehaviour は、本来はデフォルト値の "timestamp-required" のほうが自然です。ただし、(*2)のrenovate のPRがマージされるまでは "timestamp-optional" にしておかないと、 lockFileMaintenance のPRがRenovate botの承認待ちになってしまうため注意してください。 vulnerabilityAlerts AIエージェントの助けを借りてRenovateのコードを確認し、試行錯誤する中で気づいたのですが、脆弱性対応に関する一部の設定は、packageRules で指定してもグローバルの vulnerabilityAlerts の値で上書きされます。具体的には、後述するグルーピングルール3の内容や、前項の minimumReleaseAge の設定です。 packageRules 以下、設定の意図をダイジェスト順に説明します。 1) packageFileDirをブランチ名に追加 複数のモジュールの更新を集約するために packageFileDir を additionalBranchPrefix に利用しています。モノレポにおけるadditionalBranchPrefixの一般化( packageFileDir 由来のprefixを使う等)の詳細は別記事 *5 に委ねます。lockFileMaintenanceの項と同様、ブランチ名が長くなるため、Google Cloud Workload Identity Federation (WIF) の制約対策のためにブランチ名を短縮しています。 2) いったん全ルールを無効化して「許可リスト方式」にする { matchPackageNames: ["*"], enabled: false } 最初に全パッケージを enabled:false に落としてから、必要な更新だけを後続ルールで enabled:true に戻しています。Defaultを使いこなす方が安全とは思うのですが、まず最初は小さく、自分達でコントロールできる範囲から始めようとしてこのような設定としました。 3) PRのグルーピング(patch / minor / vulnerability) 別記事(*5)にもありますが依存関係更新の運用コストは「PRの数」と「レビューのコンテキスト切り替え」で決まるので、グルーピングが最重要です。 グルーピングルール1: patch更新: まとめて1本(メンテナンス枠) グルーピングルール2: minor更新: まとめて1本(ただし dependencyDashboardApproval:true で人間の許可待ちにする) グルーピングルール3: vulnerability: patchと同じグループに合流させ、優先して処理できるようにする Minor更新は、まずはダッシュボードで確認する運用にします。運用がうまく回りそうであれば、将来的にpatch groupingに合流させる想定です。 また、renovateのconfigは後ろの条件が前の条件を上書きするため、グルーピングルール3は最後に配置する必要があります。4)で扱う isBreaking に関する packageRule も同様に、後ろに配置してください。 4) 0.y.z系の“実質breaking”を抑止する SemVer上はminorでも、 0.y.z のようにリリースされていない場合、minor更新がbreakingになり得ます。そこで isBreaking = true を検知した更新は原則止めています。 ここは「通常アップデートは保守的に、ただし脆弱性対応は止めない」方針にしたいため、脆弱性アラート( isVulnerabilityAlert:true )にはこの抑止が効かないようにしています(=脆弱性があるものはIsBreakingでも検出させる)。 bumpVersions RenovateをGitHubで動作させるには、1) GitHub Actionsとして renovatebot/github-action を利用する方法と、2) 作成元のMend社が提供するMend Renovate Appを利用する方法があります。設定が簡単なことからタイミーでは後者を利用しているのですが、それ故の制約もあり、 postUpgradeTasks のように任意のコマンドを実行するようなことができず、コマンド実行できれば簡単なversionの更新もそのままでは実現できません *6 。解決策として、 bumpVersions を使い、正規表現で pyproject.toml と uv.lock の両方を同時に更新しています。なおbumpVersionsは オフィシャルドキュメントの最初にはpackageRulesの外の記述例があるのですが、マッチ表現と組み合わせpackageRulesの中に記述することもできます *7 。上記のサンプルではpackageRulesの外で記述していますが、我々はlockFileMaintenanceとそれ以外でbump up の方法を一部変えるため、 matchUpdateTypes を lockFileMaintenance とそれ以外で分離し、packageRulesの中に両方を記述しています。 // 応用例: lockFileMaintenanceと通常の更新を別々に記述する場合 (packageRulesの中に記述する) packageRules : [   : { matchUpdateTypes : [ " lockFileMaintenance " ] , bumpVersions : [ .. ] } , { matchUpdateTypes : [ " major ", " minor ", ... ] , bumpVersions : [ .. ] } ] まとめ Dependabotの「依存ごとにPRが分割される」性質は、対象が増えるほど脆弱性対応の運用コストを押し上げる。 Renovateは packageRules を適切に設定することで、上記の運用コストの削減を行うことができる。 Python + uv の場合、 lockFileMaintenance のオプションとして指定できない exclude-newer については pyproject.toml に直接記述することで問題を回避できる。 Mend Renovate Appを利用する場合においても、bumpVersionsを利用することで pyproject.toml / uv.lock それぞれのversionの更新を実現できる。 We’re Hiring! 現在、タイミーでは、データサイエンスやエンジニアリングの分野で、共に成長し、革新を推し進めてくれる新たなチームメンバーを積極的に探しています!  現在募集中のポジションは こちら です! また、気軽な雰囲気での カジュアル面談 も随時行っておりますので、ぜひお気軽にエントリーしてください。↓ 「話を聞きたい」と思われた方は、是非一度 カジュアル面談 でお話ししましょう! References *1 : https://tech.timee.co.jp/entry/2024/10/15/101953 *2 : uv lock コマンド自体は --exclude-newer オプションを持つのですが、Renovateからこのオプションを2026年5月13日現在渡せていません。他のパッケージマネージャーに関しても同様でIssueとして登録されており、uvに関しては既にPRも出ているようですがリリースはまだされていません。 https://github.com/renovatebot/renovate/issues/41652 *3 : 0.10より古いバージョンのuvでも exclude-newerは記述できるのですが ”N days ago” のような相対的な評価がこのバージョンから記述できるようになりメンテナンスが非常に楽になっています。 https://github.com/astral-sh/uv/releases/tag/0.10.0 *4 : https://docs.cloud.google.com/iam/docs/troubleshooting-workload-identity-federation#error-google-subject-too-long *5 : 近日公開予定 *6 : uvを利用する場合、 pyproject.toml / uv.lock に記述された自身のversionの更新はuv version --bump patchのように実現できます。 *7 : https://docs.renovatebot.com/configuration-options/#bumpversions
はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannkazu1 )です。 クラウドネイティブ会議2026 で発表された「 ペアーズ本番環境でのcgroup-aware化との死闘録 」がめちゃくちゃ面白かったので、自分の手でも体感したくなりました。 GoのGOMAXPROCSがコンテナのCPU制限を無視するって、実際に見るとどうなるのか? 過剰並列のスループット低下って、数字で見るとどのくらいインパクトがあるのか? スロットリングとスレッド数の関係を自分の目でたしかめたい! 自分で動かして数字を見ないと腑に落ちないタイプなので、 ローカルのMac環境で全部再現してみました。 発表の要約 ペアーズのバックエンド pairs-main はGo製でAmazon EKS上で稼働。48コアのNodeで limits.cpu: 5000m (5コア)のPodが動いていたが、 GoのGOMAXPROCSがデフォルトで48 (=Node全体のコア数)になっていた。これにより以下の問題が発生: 過剰並列 : 5コアしか使えないのに48スレッドが走る → Goスケジューラのオーバーヘッド増大 CPUスロットリング : cgroupのクォータ(CPU時間の上限)をスレッドが共食い → 全スレッドが同時に停止 監視の死角 : CPU使用率は正常に見えるが、実際はスロットリングで断続的に停止 同じ問題がHAProxy( nbthread=48 、CPU制限1コア)でも発生していた。 これらをcgroup-awareな設定(GOMAXPROCS=5, nbthread=1)に修正したところ、大幅に改善した、という話でした。 用語の整理 ここから先で出てくる「コア」「GOMAXPROCS」「クォータ」「スロットリング」あたりがピンと来なくても大丈夫です。記事全体で繰り返し登場するので、最初にざっくり整理しておきます(すでに馴染みがある方はスキップでOK)。 CPUコア・プロセス・スレッド 用語 ざっくりした意味 CPUコア 計算を実行する物理的な実体。1コア = 同時に1つの処理を進められる プロセス 動いているプログラム1つ分の単位 スレッド プロセス内で実際にCPUに割り当てられる作業の単位。1プロセスは複数スレッドを持てる ざっくり言うと、 コアの数 = 同時に進められるスレッドの数の上限 です。8コアのCPUなら、ある一瞬に進行できるのは最大8スレッドまで。それ以上のスレッドを立ち上げた場合は、OSが順番にコアを割り当て直しながら回します(= コンテキストスイッチ)。 コンテナと cgroup 用語 ざっくりした意味 コンテナ 同じサーバー上で複数のアプリを互いに干渉しないように動かす仕組み(Docker や Kubernetes の中身)。実体はホストのカーネルをそのまま使う 「namespaces で見える範囲を、cgroup で使える量を制限したプロセス(群)」 にすぎず、VM のように専用カーネルを持つわけではない cgroup (Control Groups) Linuxカーネルの機能で「このプロセス群はCPUをここまで・メモリはここまで」と上限を設定する仕組み CPU制限 「このコンテナはCPU 1コア分まで」のような上限設定。実体は cgroup の cpu.max ファイル コンテナの「CPU 0.5コアまで」という設定は、Linuxカーネルが cgroup を通じて「100msのうち50msまでしかCPUを使わせない」という形で強制します。この 100msの枠を「ピリオド」、その中で使ってよい時間量を「クォータ」 と呼びます( cpu.max: 50000 100000 なら「100msのうち50ms使える = 0.5コア相当」)。 CFS スケジューラ Linux のデフォルトの CPU スケジューラを CFS(Completely Fair Scheduler) と呼びます。先ほどの「ピリオド」「クォータ」は、CFS が持つ 帯域制御(Bandwidth Controller) という機能の用語で、cgroup の cpu.max の値を実際にスレッドへ適用する(=クォータを使い切ったら停止させる)のはこの CFS の仕事です。 つまり「cgroup が制限値を持ち、CFS がそれを実施する」という分担関係。後の実験で出てくる nr_periods (CFS が時間を区切る単位の総数)や nr_throttled (CFS が停止させたピリオドの数)も、この CFS 帯域制御の統計を見ています。 Goroutine と GOMAXPROCS(Go特有の話) 用語 ざっくりした意味 goroutine Goの軽量スレッド。OSスレッドより遥かに軽く、1プロセスで数万〜数百万個立ち上げられる OSスレッド OSが実際にCPUにスケジュールするスレッド。コアを取り合うのはこちら GOMAXPROCS Goランタイムが同時に走らせるOSスレッドの数の上限。デフォルトはホストのCPUコア数 goroutine を何万個立ち上げても、Goランタイムは GOMAXPROCS 個の OSスレッドの上にそれらを多重化して実行します。つまり同時に CPU を握っているのは最大でも GOMAXPROCS 個。この割り当てを管理するのが Goスケジューラ です。 ポイントは、 コンテナのCPU制限が下がってもデフォルトの GOMAXPROCS はホストのCPU数のまま ということ。これがそもそも今回のテーマで、後の実験でその挙動を実際に確かめます。 過剰並列 CPU 制限よりも多くのスレッド(や goroutine、ワーカー)を同時に走らせている状態 を指します。たとえば 5 コア相当の CPU 制限に対して GOMAXPROCS=48 なら、約 9.6 倍の過剰並列。実際に走れるのは制限分のスレッドだけなので、残りはスケジューラの上で順番待ちをしつつ、共有クォータを早食いし合うことになります。 Go の GOMAXPROCS に限った話ではなく、HAProxy の nbthread 、Nginx の worker_processes 、Puma の workers など、 「並列数のデフォルトがホスト CPU 数に依存する」設定はすべて同じ構造で過剰並列を起こします 。 CPUスロットリング cgroupでCPU 0.5コア分に制限されたコンテナが、たくさんのスレッドでCPUを一気に使おうとすると、Linuxカーネルが 「クォータを使い切ったので、次のピリオドまで全スレッド一時停止」 と強制的にブロックします。これが CPUスロットリング です。 スロットリングが頻発すると、レスポンスが断続的に止まったり、スループットが落ちたりします。その結果、「なぜか遅延がスパイクする」原因になっているケースが多いです。発生状況は /sys/fs/cgroup/cpu.stat に出力されており、本記事では以下の3指標を追います: nr_periods : スケジューラの計測単位(ピリオド = 100ms)の総数 nr_throttled : そのうちスロットリングが起きたピリオドの数(回数) throttled_usec : スロットリングで実際にCPUが止められた累積時間(マイクロ秒) 「回数」だけでなく「 累積停止時間 」も見るのが重要だ、というのが発表の山場の一つで、後の実験3でその違いがハッキリ出ます。 Thundering Herd スロットリングで停止していた全スレッドが、 次のピリオドのリセットで一斉に走り出し、また一瞬でクォータを食い潰して同時に止まる 、というサイクルが繰り返される状態を 「Thundering Herd(雷鳴の群れ)」 と呼びます。元はソケット accept など I/O 文脈の用語ですが、cgroup の帯域制御下でも同じ構造の問題が起きます。スレッド数が多いほど被害が大きくなるのは、ここに端を発しています。実験4でその挙動を観察します。 cgroup-aware プログラムやライブラリが cgroup の制限( cpu.max など)を自分で読み取り、その値に合わせて並列度を調整する 設計のことを 「cgroup-aware」 と呼びます。Go 1.25 以降のランタイムや uber-go/automaxprocs は cgroup-aware に GOMAXPROCS を設定します。逆に Go 1.24 以前のように cgroup を見ずにホストの CPU 数だけ見る挙動は「cgroup-aware ではない」状態で、今回の過剰並列はそこから生まれています。 この記事で検証すること # 検証テーマ 発表でのポイント 1 GOMAXPROCSのデフォルト値 コンテナのCPU Limitを無視してホストのCPU数になる 2 過剰並列のパフォーマンス影響 GOMAXPROCSが大きすぎるとスループットが低下する 3 CPUスロットリングの発生 スレッド数が多いほどクォータを早く消費し、停止時間が増える 4 スレッド数とスロットリングの相関 スレッド数に比例して throttled_usec が増加する 1. ローカル環境構築(Mac) 前提条件 macOS (Apple Silicon / Intel 両対応) Docker Desktop がインストール済み なぜDockerで検証できるのか cgroup(Control Groups)は Linuxカーネルの機能 で、macOS 自体には存在しません。しかし Docker Desktop は内部で Linux VM を動かしており、コンテナはその Linux 上で動作します。 ┌─────────────────────────────────────────────┐ │ macOS │ │ ┌────────────────────────────────────────┐ │ │ │ Docker Desktop (Linux VM) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ コンテナ │ │ │ │ │ │ /sys/fs/cgroup/cpu.max ← ここ! │ │ │ │ │ │ /sys/fs/cgroup/cpu.stat │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ Docker の --cpus フラグは Kubernetes の limits.cpu と同じく cgroup の cpu.max に変換されます。つまり Kubernetes と同じ仕組みをローカルで再現 できます。 Docker Kubernetes cgroup v2 --cpus=0.5 limits.cpu: 500m cpu.max: 50000 100000 --cpus=1.0 limits.cpu: 1000m cpu.max: 100000 100000 --cpus=5.0 limits.cpu: 5000m cpu.max: 500000 100000 セットアップ手順 Step 1: Docker Desktop のインストール Docker Desktop for Mac からインストール。 docker --version # Docker version 27.x.x, build xxxxxxx Step 2: 検証用 Go アプリケーション 本記事の検証コードは以下のリポジトリにまとめています: hirosi1900day/cgroup-throttling-lab git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab 3つのモードを持つGoアプリケーションを書きました。 モード 用途 info GOMAXPROCSの値とcgroupの設定を表示 benchmark CPU負荷をかけてスループットを計測 throttle-demo CPU負荷をかけてスロットリングの Before/After を表示 コード解説 各パートを順に見ていきます。 1. CPU負荷を発生させる関数 // cpuIntensiveWork はCPU負荷をかける計算処理 // 平方根と三角関数を1万回ループし、意図的にCPUを使い切る func cpuIntensiveWork() float64 { result := 0.0 for i := 0 ; i < 10000 ; i++ { result += math.Sqrt( float64 (i)) * math.Sin( float64 (i)) } return result } この関数が実験の要です。 math.Sqrt と math.Sin の計算を1万回繰り返すことで、 純粋なCPU負荷 を発生させます。I/O待ちが一切ないので、GOMAXPROCS(=ワーカースレッド数)の影響がダイレクトに現れます。 2. infoモード — GoランタイムとcgroupのCPU設定を表示 func showRuntimeInfo() { // runtime.GOMAXPROCS(0) は「現在の値を返し、変更しない」 // ← これがコンテナのCPU制限と一致しているかがポイント fmt.Printf( "GOMAXPROCS: %d \n " , runtime.GOMAXPROCS( 0 )) fmt.Printf( "NumCPU: %d \n " , runtime.NumCPU()) // --- cgroup のCPU制限を直接読む --- // /sys/fs/cgroup/cpu.max は cgroup v2 のCPU制限ファイル // 中身は "クォータ ピリオド" の形式(例: "100000 100000") // Kubernetes の limits.cpu や Docker の --cpus がここに反映される if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.max" ); err == nil { fmt.Printf( "cpu.max: %s" , string (data)) } // /sys/fs/cgroup/cpu.stat はCPUスロットリングの統計情報 // nr_periods: CFSスケジューラのピリオド(100ms)の総数 // nr_throttled: スロットリングが発生したピリオドの数 // throttled_usec: スロットリングでCPUが停止した累積時間(μs) if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.stat" ); err == nil { fmt.Printf( "cpu.stat: \n %s" , string (data)) } } このモードでは、 GoランタイムがcgroupのCPU制限を認識しているか を見ます。Go 1.24以前では、 GOMAXPROCS がホストのCPU数のままなのが確認できるはずです。 3. benchmarkモード — スループットの計測 func runBenchmark() { // 環境変数でベンチマーク時間とgoroutine数を制御可能にしている duration := 10 * time.Second // BENCH_DURATION で変更可 goroutines := 100 // BENCH_GOROUTINES で変更可 // --- ここからが計測のコア --- var totalOps atomic.Int64 // goroutine間で安全にカウントを共有 var wg sync.WaitGroup // 全goroutineの完了を待つ // タイマーで終了を通知するチャネル done := make ( chan struct {}) go func () { <-time.After(duration) close (done) // ← 全goroutineに「終了」を伝える }() // goroutines個のgoroutineを起動し、それぞれが独立にCPU負荷をかける // これらのgoroutineは GOMAXPROCS 個のワーカースレッドに // Goスケジューラによって割り当てられる for i := 0 ; i < goroutines; i++ { wg.Add( 1 ) go func () { defer wg.Done() localOps := int64 ( 0 ) // goroutineローカルでカウント(競合を避ける) for { select { case <-done: totalOps.Add(localOps) // 最後にまとめて加算 return default : cpuIntensiveWork() // CPU負荷をかけ続ける localOps++ } } }() } wg.Wait() // Ops/sec = 単位時間あたりの処理回数 // この値が高いほどスループットが良い opsPerSec := float64 (totalOps.Load()) / elapsed.Seconds() } 100個のgoroutineが cpuIntensiveWork() を呼び続け、それらがGOMAXPROCS個のOSスレッド上でスケジュールされる構造。CPU制限がある環境では、スレッドが多いほどcgroupのクォータを早く使い切る、スロットリングでスループットが落ちるわけです。 (脱線)ベンチマークコードの工夫 — キャッシュコヒーレンシの話 cgroup の検証とは直接関係ないですが、このベンチマークコードには「計測自体が結果を歪めないための工夫」が入っています。せっかくなので解説します。 select + default でノンブロッキングに終了チェックしつつCPU処理を回し続ける、というのはGoの定番パターンなので軽く触れるだけにして、本題はカウンタの設計です。 localOps := int64(0) // goroutineローカル(普通のint) for { select { case <-done: totalOps.Add(localOps) // ← 終了時に1度だけatomic操作 return default: cpuIntensiveWork() localOps++ // ← 普通のインクリメント。超高速 } } ループ内では localOps++ (普通の int インクリメント)だけを使い、終了時に1度だけ totalOps.Add(localOps) ( atomic 操作)で合算しています。 「毎回 totalOps.Add(1) でいいのでは?」と思うかもしれませんが、それだと100個の goroutine が同じメモリアドレスに毎ループ書き込み合い、 キャッシュコヒーレンシ(Cache Coherency) のオーバーヘッドで性能が大きく落ちます。 キャッシュコヒーレンシとは まず前提として、CPUがデータにアクセスする仕組みを整理しておきます。 CPU のメモリ階層 CPUが変数やデータを読み書きするとき、毎回メインメモリ(DRAM)まで取りに行くのは遅すぎます。そこで CPUは メモリ階層(Memory Hierarchy) という多段のキャッシュ構造を持っています: ┌─────────────────────────────────────────────────┐ │ CPU コア │ │ ┌───────────┐ │ │ │ レジスタ │ ← 最速(~0.3ns)、数十〜数百個 │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L1 キャッシュ│ ← 32〜64KB / コア、~1ns │ │ │ (データ+命令)│ │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L2 キャッシュ│ ← 256KB〜1MB / コア、~3-10ns │ │ └─────┬─────┘ │ │ │ ┌──────────┐ │ │ │ │ TLB │ ← 仮想→物理アドレス │ │ │ │ │ 変換のキャッシュ │ │ │ └──────────┘ │ └────────┼────────────────────────────────────────┘ ┌─────┴─────┐ │ L3 キャッシュ│ ← 数MB〜数十MB、全コア共有、~10-30ns └─────┬─────┘ ┌─────┴──────────────────┐ │ メインメモリ(DRAM) │ ← 数GB〜数百GB、~50-100ns └─────┬──────────────────┘ ┌─────┴──────────────────┐ │ ストレージ(SSD/HDD) │ ← ~10,000ns (SSD) 〜 10,000,000ns (HDD) └───────────────────────┘ 階層 容量 レイテンシ 特徴 レジスタ 数百バイト ~0.3ns CPUが直接演算する場所 L1キャッシュ 32〜64KB/コア ~1ns データ用と命令用に分離。コアごとに専有 L2キャッシュ 256KB〜1MB/コア ~3-10ns コアごとに専有(アーキテクチャによる) L3キャッシュ 数MB〜数十MB ~10-30ns 全コア共有 。ここがコア間のデータの橋渡し TLB 数百〜数千エントリ ~1ns(ヒット時) 仮想アドレス→物理アドレスの変換キャッシュ メインメモリ 数GB〜 ~50-100ns L1の50〜100倍遅い CPUが localOps++ を実行するとき、その変数がレジスタや L1 にあれば 1ns 以下で完了します。しかし L1 にない(キャッシュミス)と L2 → L3 → メインメモリと順にたどる必要があり、最悪100nsかかる。 L1ヒットとメインメモリアクセスでは約100倍の速度差 があるわけです。 TLB(Translation Lookaside Buffer) は少し役割が違って、仮想メモリのアドレス変換を高速化するキャッシュです。プロセスが使うメモリアドレス(仮想アドレス)を実際の物理アドレスに変換するにはページテーブルを引く必要がありますが、毎回引くとメモリアクセスが2倍になるので、よく使う変換結果を TLB にキャッシュしています。TLB ミスが発生すると ページテーブルウォーク が走り、数十nsの追加コストがかかります。goroutine が大量のスタックやヒープを使うワークロードでは、TLB ミスもパフォーマンスに効いてきます。 この前提を踏まえると、マルチコアでのキャッシュ一貫性がなぜ重要かがわかります。 キャッシュコヒーレンシ問題 マルチコアCPUでは、各コアが独自の L1/L2キャッシュ を持っています。あるコアが変数を更新すると、他のコアが持つ同じ変数のキャッシュラインは 古い値(stale) になります。これを放置すると各コアが異なる値を見てしまうため、ハードウェアレベルで一貫性を保つ仕組みが必要です。これが キャッシュコヒーレンシプロトコル (代表的なものに MESI プロトコル )です。 MESI プロトコルでは、キャッシュラインは以下の4状態を遷移します: 状態 意味 M odified 自コアだけが変更済みの値を持つ E xclusive 自コアだけが持っているが、メモリと同じ値 S hared 複数コアが同じ値を持っている(読み取り専用) I nvalid 他コアが更新したので、このキャッシュラインは無効 atomic 変数への書き込みが発生すると: 書き込むコアがキャッシュラインの 排他的所有権 を要求 他の全コアの同じキャッシュラインが Invalid に変わる(無効化) 次にそのコアが同じ変数にアクセスすると、 キャッシュミス → メモリ(or 他コアのキャッシュ)から再取得 これが毎ループ・100 goroutine で発生すると: [NG] 毎回 atomic(キャッシュラインのピンポン) コア1: totalOps.Add(1) → キャッシュライン取得 (Exclusive) → 値を更新 (Modified) → 他の全コアのキャッシュラインが Invalid に コア2: totalOps.Add(1) → Invalid なので再取得が必要(キャッシュミス!) → コア1から転送 → Exclusive → Modified → 他の全コアのキャッシュラインが Invalid に コア3: totalOps.Add(1) → また Invalid → また再取得...(以下ピンポン状態) → 実際のCPU計算ではなく、キャッシュの同期にCPU時間が消える このキャッシュラインの奪い合いは 「キャッシュラインバウンシング」 や 「false sharing」 (同じキャッシュラインに別の変数が乗っている場合)とも呼ばれ、マルチスレッドプログラミングの有名なパフォーマンス落とし穴です。 一方、ローカルカウンタなら: [OK] ローカルカウンタ + 最後に1回だけ atomic コア1: localOps++ → 自コアのレジスタ or L1キャッシュだけ。他コアに影響なし コア2: localOps++ → 同上。各goroutineが独立したメモリを触る ... (終了時だけ totalOps.Add → atomic 操作は10秒間で合計たった100回) 「 ベンチマークそのもののコストでベンチマーク結果が歪む 」のを防ぐテクニックです。cgroup のスロットリングを正確に測るなら、計測のオーバーヘッドは極力削っておきたい。 4. throttle-demoモード — スロットリングの観測 func runThrottleDemo() { // GOMAXPROCS個のワーカーを起動(= OSスレッド数と一致させる) numWorkers := runtime.GOMAXPROCS( 0 ) // Before: スロットリング前の統計を記録 // cpu.stat の nr_throttled, throttled_usec を確認 fmt.Println( "--- Before ---" ) readCgroupStat() // numWorkers個のgoroutineでCPU負荷をかける // GOMAXPROCS=8 なら8本、GOMAXPROCS=1 なら1本 // → スレッド数の違いがスロットリングにどう影響するかを観測 for i := 0 ; i < numWorkers; i++ { go func () { for { cpuIntensiveWork() // 全スレッドでCPU全開 } }() } // 5秒間 CPU負荷をかけた後... // After: スロットリング後の統計を記録 // Before との差分が「この5秒間で発生したスロットリング」 fmt.Println( "--- After ---" ) readCgroupStat() } GOMAXPROCS の値がそのままワーカー数になります。GOMAXPROCS=8 なら8スレッドが同時にCPUを使おうとするので、共有クォータを一瞬で食い潰します。Before/After の throttled_usec の差分で、 実際にどれだけCPUが止められたか がわかります。 Dockerfile # ビルドステージ: Go 1.24 でコンパイル FROM golang:1.24-bookworm AS builder WORKDIR /app COPY go.mod ./ COPY main.go ./ RUN go build -o /app/cgroup-bench . # 実行ステージ: 軽量なイメージで実行 FROM debian:bookworm-slim COPY --from=builder /app/cgroup-bench /usr/local/bin/cgroup-bench ENTRYPOINT ["cgroup-bench"] CMD ["info"] Go バージョンについて Go の最新安定版 : 1.26.3(2026年5月時点) container-aware GOMAXPROCS が導入されたバージョン : Go 1.25 本記事で使うバージョン : Go 1.24(1.25直前の最終版) Go 1.25以降ではランタイムがcgroupの cpu.max を自動で読み取り、GOMAXPROCSをCPU制限に合わせて設定します。今回は 問題が発生していた当時の挙動を再現 するため、あえてGo 1.24を使用しています。 main.go 全文(クリックで展開) ```go package main import ( "encoding/json" "fmt" "math" "os" "runtime" "strconv" "sync" "sync/atomic" "time" ) type Result struct { GOMAXPROCS int `json:"gomaxprocs"` NumCPU int `json:"num_cpu"` CPULimit string `json:"cpu_limit"` Duration time.Duration `json:"duration_ns"` DurationStr string `json:"duration"` TotalOps int64 `json:"total_ops"` OpsPerSec float64 `json:"ops_per_sec"` GoroutineCount int `json:"goroutine_count"` } func cpuIntensiveWork() float64 { result := 0.0 for i := 0; i < 10000; i++ { result += math.Sqrt(float64(i)) * math.Sin(float64(i)) } return result } func main() { mode := "benchmark" if len(os.Args) > 1 { mode = os.Args[1] } switch mode { case "benchmark": runBenchmark() case "info": showRuntimeInfo() case "throttle-demo": runThrottleDemo() } } func showRuntimeInfo() { fmt.Println("=== Go Runtime Information ===") fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Printf("NumCPU: %d\n", runtime.NumCPU()) fmt.Printf("GOVERSION: %s\n", runtime.Version()) envGOMAXPROCS := os.Getenv("GOMAXPROCS") if envGOMAXPROCS == "" { fmt.Println("ENV GOMAXPROCS: (not set — using default)") } else { fmt.Printf("ENV GOMAXPROCS: %s\n", envGOMAXPROCS) } fmt.Println("\n=== cgroup CPU Information ===") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil { fmt.Printf("cpu.max: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.weight"); err == nil { fmt.Printf("cpu.weight: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("cpu.stat:\n%s", string(data)) } } func runBenchmark() { duration := 10 * time.Second if d := os.Getenv("BENCH_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } goroutines := 100 if g := os.Getenv("BENCH_GOROUTINES"); g != "" { if parsed, err := strconv.Atoi(g); err == nil { goroutines = parsed } } maxprocs := runtime.GOMAXPROCS(0) var totalOps atomic.Int64 var wg sync.WaitGroup done := make(chan struct{}) go func() { <-time.After(duration) close(done) }() start := time.Now() for i := 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() localOps := int64(0) for { select { case <-done: totalOps.Add(localOps) return default: cpuIntensiveWork() localOps++ } } }() } wg.Wait() elapsed := time.Since(start) ops := totalOps.Load() opsPerSec := float64(ops) / elapsed.Seconds() fmt.Printf("GOMAXPROCS=%d Ops/sec=%.2f Total=%d\n", maxprocs, opsPerSec, ops) jsonData, _ := json.Marshal(Result{ GOMAXPROCS: maxprocs, OpsPerSec: opsPerSec, TotalOps: ops, }) fmt.Printf("JSON: %s\n", string(jsonData)) if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("\ncpu.stat:\n%s", string(data)) } } func runThrottleDemo() { fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Println("\n--- Before ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } numWorkers := runtime.GOMAXPROCS(0) duration := 5 * time.Second if d := os.Getenv("DEMO_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } var wg sync.WaitGroup stop := make(chan struct{}) go func() { <-time.After(duration) close(stop) }() for i := 0; i < numWorkers; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: cpuIntensiveWork() } } }() } wg.Wait() fmt.Println("\n--- After ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } } ``` Step 3: ビルド docker build -t cgroup-bench go-app/ これで準備完了です。 2. 実験と結果 検証環境: - macOS(Apple Silicon) - Docker Desktop - Docker VM: 11コア (ここがKubernetesの「48コアNode」に相当) 実験1: GOMAXPROCS はコンテナの CPU 制限を無視する 何を確認するか 発表では、コンテナの limits.cpu: 5000m に対して GOMAXPROCS が 48(ノードのコア数)になっていたことが、問題の発端でした。まずは、 GoランタイムがcgroupのCPU制限を見ていない という状態をローカルで確認します。 実行コマンド # A: CPU制限なし docker run --rm cgroup-bench info # B: CPU制限 1コア docker run --rm --cpus=1.0 cgroup-bench info # C: CPU制限 0.5コア docker run --rm --cpus=0.5 cgroup-bench info 実際の結果 A: CPU制限なし === Go Runtime Information === GOMAXPROCS: 11 ← Docker VMの全CPUコア数 NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: max 100000 ← "max" = 上限なし B: CPU制限 1コア( --cpus=1.0 ) === Go Runtime Information === GOMAXPROCS: 11 ← 制限をかけたのに11のまま! NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: 100000 100000 ← cgroupには1コア分の制限が正しく設定されている C: CPU制限 0.5コア( --cpus=0.5 ) === Go Runtime Information === GOMAXPROCS: 11 ← まだ11のまま! NumCPU: 11 === cgroup CPU Information === cpu.max: 50000 100000 ← cgroupには0.5コア分の制限が設定されている 結果を見てみる CPU制限 cpu.max(cgroup) GOMAXPROCS 過剰並列の倍率 なし max 100000 (無制限) 11 - 1コア 100000 100000 11 11倍 0.5コア 50000 100000 11 22倍 完全に無視してます。cgroupには cpu.max として正しくCPU制限が設定されているのに、 Go 1.24のランタイムは一切見ていない 。GOMAXPROCSは常にホスト(Docker VM)のCPU数=11がデフォルト。 発表の本番環境では48コアNodeで limits.cpu: 5000m だったので、 GOMAXPROCS=48(約10倍の過剰並列) が起きていた。ローカルでも同じ構造の問題を再現できました。 cpu.max の読み方 : クォータ ピリオド の形式。ピリオド(デフォルト100ms=100000μs)のうち、クォータ分だけCPUを使える。 50000 100000 なら「100msのうち50ms使用可能 = 0.5コア分」。 実験2: 過剰並列はスループットを低下させる 何を確認するか 発表では GOMAXPROCS を48→5に変えたらスループットが大幅改善、Goスケジューラの CPU使用率が50%以上減ったとのこと。同じ体験をローカルでも数字で見てみます。 実行コマンド CPU制限1コアの環境で、100個のgoroutineを10秒間走らせます。変えるのはGOMAXPROCSだけ。 # GOMAXPROCS=1(CPU制限に一致 = 適切) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=1 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark # GOMAXPROCS=8(CPU制限の8倍 = 過剰並列) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=8 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark 実際の結果 GOMAXPROCS=1(適切な並列数): GOMAXPROCS=1 Ops/sec=21503.63 Total=215525 cpu.stat: nr_periods 101 nr_throttled 56 throttled_usec 43791 GOMAXPROCS=8(過剰並列): GOMAXPROCS=8 Ops/sec=6832.54 Total=68646 cpu.stat: nr_periods 102 nr_throttled 101 throttled_usec 70703432 結果を見てみる 指標 GOMAXPROCS=1 GOMAXPROCS=8 差分 Ops/sec(スループット) 21,504 6,833 68.2% 低下 nr_throttled / nr_periods 56/101 (55%) 101/102 ( 99% ) ほぼ全ピリオドで停止 throttled_usec(累積停止時間) 43,791μs (0.04秒) 70,703,432μs (70.7秒) 1,614倍 正直、ここまで差が出るとは思っていませんでした。 GOMAXPROCS を1→8にするだけで、 スループットが約3分の1に落ちる 10秒間のテストで 累計70.7秒ものCPU停止 (8スレッドが各約8.8秒ずつ止まった計算) スロットリング率99% — ほぼ毎ピリオドで全スレッドが止められている 発表で説明されていた「 クォータをスレッドが共食いする 」現象そのものです。 ┌──────── 1ピリオド (100ms) ────────┐ GOMAXPROCS=1 の場合: [████████ 実行 ████████][░░ 停止 ░░] ← 1スレッドで穏やかに使う GOMAXPROCS=8 の場合: [█ 8スレッド一斉実行 █][░░░░░░░░░░░░░░░░░░░░░░ 長時間停止 ░░░░░░░░░░░░░░░░░░░░░░] ↑ クォータ枯渇 ↑ 全スレッドが同時にスロットリング 実験3: スロットリングの深刻度はスレッド数で変わる 何を確認するか 発表で「 時間も見れば、ピリオドの%が同じでも深刻度の違いが分かる 」と指摘されていました。これ、実際に nr_throttled (回数)は同じくらいなのに throttled_usec (停止時間)には大きな差が出るということなので、自分の目で見てみます。 実行コマンド CPU制限0.5コア(かなり厳しい制限)でGOMAXPROCS=8 vs 1 を比較。 # 過剰並列(GOMAXPROCS=8, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=8 -e DEMO_DURATION=5s cgroup-bench throttle-demo # 適切な並列(GOMAXPROCS=1, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=1 -e DEMO_DURATION=5s cgroup-bench throttle-demo 実際の結果 GOMAXPROCS=8(過剰並列): --- After --- nr_periods 52 nr_throttled 51 ← 98%のピリオドでスロットリング throttled_usec 39039180 ← 39秒のCPU停止 GOMAXPROCS=1(適切): --- After --- nr_periods 51 nr_throttled 50 ← 98%のピリオドでスロットリング(ほぼ同じ!) throttled_usec 2644221 ← 2.6秒のCPU停止 結果を見てみる 指標 GOMAXPROCS=8 GOMAXPROCS=1 差分 nr_throttled / nr_periods 51/52 (98%) 50/51 (98%) ほぼ同じ throttled_usec 39,039,180μs (39秒) 2,644,221μs (2.6秒) 14.8倍 数字を自分で並べてみて、初めて深刻さがわかりました。 nr_throttled の割合(スロットリング率)だけ見るとどっちも98%で全く同じに見えます。でも throttled_usec (実際の停止時間)には14.8倍もの差がある。 これが発表で言われていた「CPU使用率だけでは気づけない」「監視の死角」の正体です。 なぜCPU使用率では見えないのか ここをもう少し掘り下げます。実はこの実験、 どちらのケースもCPU使用率は約100% と表示されます。「え、GOMAXPROCS=8 のほうが遅いのにCPU使用率は同じ?」と思うかもしれませんが、これにはカラクリがあります。 CPU使用率の計算式は本質的にこうです: $$ \text{CPU使用率} = \frac{\text{消費したCPU時間}}{\text{割り当てクォータ}} $$ 今回の実験では --cpus=0.5 なので、1ピリオド(100ms)あたりのクォータは 50ms です。 GOMAXPROCS=1 GOMAXPROCS=8 クォータ 50ms / period 50ms / period 消費CPU時間 50ms(使い切る) 50ms(使い切る) CPU使用率 ≈100% ≈100% 消費ペース 1スレッド × 50ms = 50msかけて徐々に 8スレッド × 6.25ms = 約6msで一気に 残りの時間 50ms間は停止(穏やか) 94ms間 全スレッド凍結 どちらもクォータ50msを使い切るので、CPU使用率は同じ100%です。しかし 消費のペースがまるで違います 。 1ピリオド(100ms)の内訳: GOMAXPROCS=1: |███████████████████████████░░░░░░░░░░░░░░░░░░░| ← 1スレッドで50ms実行 →← 50ms 待機 → CPU使用率: 50/50 = 100% レイテンシ: 安定 GOMAXPROCS=8: |████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| ←6ms→←───────── 94ms 全スレッド凍結 ──────────→ CPU使用率: 50/50 = 100% レイテンシ: スパイク発生 GOMAXPROCS=1 は1スレッドで50msを穏やかに消費するので、処理は途切れつつも比較的均等に進みます。一方 GOMAXPROCS=8 は8スレッドが同時にCPUを要求するため、わずか約6msでクォータを食い尽くし、 残りの94msは全スレッドが完全に凍結 します。 つまりCPU使用率100%の裏で起きていることが全く異なるのに、 集約メトリクスではその違いが消えてしまう 。これが「監視の死角」の本質です。 まとめると: nr_throttled率が同じでも、 8スレッドが同時に止まる のと 1スレッドだけ止まる のでは深刻度がまるで違う CPU使用率は クォータを消費した量 しか示さず、 消費のペース(バースト性)を一切反映しない throttled_usec を合わせて監視しないと、スロットリングの実態はつかめない 実験4: スレッド数と停止時間の相関 何を確認するか スレッド数を段階的に増やしたとき、 throttled_usec が比例して増えるのか。発表スライドの「 クォータはスレッド間で共有 」「 スレッドが多いほど早く消費 」という説明を、グラフで体感してみます。 実行コマンド for MAXPROCS in 1 2 4 8 16; do echo "--- GOMAXPROCS=$MAXPROCS ---" docker run --rm --cpus=1.0 -e GOMAXPROCS=$MAXPROCS -e DEMO_DURATION=5s \ cgroup-bench throttle-demo 2>&1 \ | grep -E "nr_periods|nr_throttled|throttled_usec" | tail -3 echo "" done 実際の結果 --- GOMAXPROCS=1 --- nr_periods 51 nr_throttled 20 throttled_usec 21885 --- GOMAXPROCS=2 --- nr_periods 51 nr_throttled 50 throttled_usec 5075001 --- GOMAXPROCS=4 --- nr_periods 51 nr_throttled 50 throttled_usec 15053306 --- GOMAXPROCS=8 --- nr_periods 52 nr_throttled 51 throttled_usec 35847841 --- GOMAXPROCS=16 --- nr_periods 51 nr_throttled 51 throttled_usec 50338309 結果を見てみる GOMAXPROCS nr_throttled スロットリング率 throttled_usec 累積停止時間 1 20 / 51 39% 21,885 0.02秒 2 50 / 51 98% 5,075,001 5.1秒 4 50 / 51 98% 15,053,306 15.1秒 8 51 / 52 98% 35,847,841 35.8秒 16 51 / 51 100% 50,338,309 50.3秒 停止時間 (秒) 50 ┤ ● GOMAXPROCS=16 │ ╱ 40 ┤ ╱ │ ╱ 35 ┤ ● ╱ GOMAXPROCS=8 │ ╱ ╱ 20 ┤ ╱ │ ╱ 15 ┤ ● ╱ GOMAXPROCS=4 │ ╱ ╱ 10 ┤ ╱ │ ╱ 5 ┤ ● ╱ GOMAXPROCS=2 │ ╲╱ 0 ┤● GOMAXPROCS=1 └──┬──┬──┬──┬──┬──┬── 1 2 4 8 12 16 GOMAXPROCS GOMAXPROCS=1〜8の範囲ではほぼ線形に比例しています。 GOMAXPROCS=1 → 0.02秒(ほぼ停止なし) GOMAXPROCS=16 → 50.3秒(5秒のテストで累計50秒分の停止!) ただし GOMAXPROCS=16 では、同時にCPUを使えるスレッド数が Docker VM の物理CPU数(11コア)で頭打ちになるため、純粋な線形モデルの予測(75秒)より低い50.3秒に飽和しています。16スレッド中、同時に実行できるのは最大11スレッドなので、停止時間は $\min(n, 11) \times P - Q$ に近づきます。 GOMAXPROCS=8 以下では物理CPU数の制約を受けないため、きれいに $n \times P - Q$ の線形モデルと一致しています(8スレッド時の予測35秒 vs 実測35.8秒)。 発表でいう Thundering Herd 問題 そのもので、クォータリセットで全スレッドが一斉に再開 → 共有クォータを瞬殺 → 全スレッド同時停止、のサイクルが繰り返される。 3. 考察: なぜスレッドを増やすと「遅くなる」のか 実験4の結果を改めて見ると、 throttled_usec がスレッド数にほぼ比例して増えています。「スレッドを増やすほど損をする」って、直感に反しますが、CFS 帯域制御の仕組みから数式で説明できます。 CFS 帯域制御の数理 — クォータ消費のモデル cgroup の CPU 制限は「1ピリオド(100ms)あたり $Q$ だけ CPU を使える」というクォータ制です。 --cpus=1.0 なら $Q = 100\text{ms}$ です。 $n$ 本のスレッドが同時にフル稼働すると、CPU 時間は $n$ 倍の速度で消費されます。つまり: クォータ枯渇までの時間 : $\frac{Q}{n}$ 残りのピリオド : 全 $n$ スレッドが同時に停止 1スレッドあたりの停止時間 : $\text{ピリオド} - \frac{Q}{n}$ 累積停止時間 (= throttled_usec ): $n \times \left(\text{ピリオド} - \frac{Q}{n}\right)$ -cpus=1.0 ($Q = 100\text{ms}$、ピリオド $= 100\text{ms}$)で $n = 8$ の場合: クォータ枯渇: $\frac{100}{8} = 12.5\text{ms}$ で使い切る 各スレッドの停止: $100 - 12.5 = 87.5\text{ms}$ 1ピリオドあたりの累積停止: $8 \times 87.5 = 700\text{ms}$ 5秒間(50ピリオド)なら $50 \times 700\text{ms} = 35\text{秒}$。実験4の GOMAXPROCS=8 の結果(35.8秒)とほぼ一致します。 USL で見るとスループット悪化も説明がつく この現象をスケーリング法則の観点から見ると、Neil Gunther の USL(Universal Scalability Law) が当てはまります: $$ S(n) = \frac{n}{1 + \alpha(n-1) + \beta \cdot n(n-1)} $$ パラメータ 意味 cgroup 環境での具体例 $\alpha$ 競合 — 直列化ペナルティ Go スケジューラのロック競合、ランキュー管理 $\beta$ 一貫性 — スレッド間の協調コスト CFS クォータの共有消費 + 全スレッド一斉停止 USL で効いてくるのは $\beta$ の項です。$\beta \cdot n(n-1)$ は $n 2 $ オーダーで増大するため、ある閾値を超えるとスループットが ピークから減少に転じます 。実験2の「GOMAXPROCS=8 で 68% 低下」は、この retrograde(逆行)領域に入った結果です。 スループット ↑ ● ピーク(GOMAXPROCS=1) │ ╱╲ │ ╱ ╲ ← USL の retrograde 領域 │ ╱ ╲ │╱ ╲ ● GOMAXPROCS=8(68%低下) └──────────→ スレッド数 cgroup制限下では「スレッド1本」がピーク。 増やすほどクォータの奪い合いで損をする。 通常の並列プログラミングでは「コア数まではスケールする」のが常識ですが、cgroup でリソースが制限された環境では スレッド1本がすでに最適解 という直感に反する結果になる。CPU 時間の総量が固定されたゼロサム環境なので、スレッドを増やすほど「クォータの奪い合い → 一斉停止 → 再開 → また枯渇」のサイクルが重くなるだけ。 「I/O 待ちがある場合はどうなのか?」 ここまでの実験は cpuIntensiveWork() による 純粋な CPU バウンド処理 です。「I/O 待ちがあるならスレッドを増やしたほうがいいんじゃ?」と思いますよね。一般論としてはその通りで、スレッドが I/O で待っている間は CPU クォータを消費しないので、CPU 数より多いスレッドが有効な場面はあります。 ただし Go の場合は話が別 です。Goランタイムには以下の仕組みがあるので、GOMAXPROCS を I/O のために増やす必要は基本的にないです: I/O の種類 Goランタイムの挙動 GOMAXPROCS への影響 ネットワーク I/O netpoller が非同期処理。goroutine は待つが OS スレッドはブロックしない 影響なし ブロッキング syscall (ファイル I/O, CGO 等) ランタイムが GOMAXPROCS とは 別に追加の OS スレッドを自動生成 影響なし つまり Go では、ネットワーク I/O は goroutine レベルで多重化され、ブロッキング I/O は GOMAXPROCS の枠外で処理される。GOMAXPROCS が制御するのは CPU を実際に使うスレッドの数 だけなので、I/O の多寡に関わらず GOMAXPROCS = CPU 制限 が正解です。 DB クエリや API 呼び出しを大量に行う Web サービスでも、GOMAXPROCS=5(CPU 制限に一致)で大幅に改善した事例があるのは、この仕組みがあるからです。 一方、Go 以外のランタイムでは、それぞれ事情が違うので整理しておきます: ランタイム 並列の仕組み cgroup の影響 Java スレッドプール(ForkJoinPool 等)で並列化。 Runtime.availableProcessors() の値を基準にプールサイズが決まることが多い スレッドプールサイズを CPU limit より大きい値にするとスロットリング発生 Node.js メインスレッドはシングル。 UV_THREADPOOL_SIZE (デフォルト4)で fs/dns 等のブロッキング I/O を処理。CPU バウンド処理は worker_threads で並列化 worker_threads の数を CPU limit より大きい値にするとスロットリング発生 Ruby (CRuby) GVL(Global VM Lock)があるため、スレッドを増やしても CPU バウンド処理は並列実行されない 。Puma 等の Web サーバーは workers (fork によるマルチプロセス)で並列化 Puma の workers を CPU limit より大きい値にするとスロットリング発生 4. 発表の内容をローカルで再現できたか? 全検証結果まとめ # 発表のポイント ローカル検証の結果 再現 1 GOMAXPROCSはcgroupのCPU制限を考慮しない(Go 1.24以前) --cpus=0.5 でも GOMAXPROCS=11(ホストCPU数)のまま 再現 2 limits.cpu は cgroup の cpu.max (クォータ/ピリオド)に変換される --cpus=0.5 → cpu.max: 50000 100000 を確認 再現 3 過剰並列はスループットを低下させる GOMAXPROCS 1→8 で Ops/sec が 68.2% 低下 (21,504 → 6,833) 再現 4 クォータはスレッド間で共有され、多いほど早く消費される スレッド数と throttled_usec がほぼ線形に比例 再現 5 スロットリング時は全スレッドが同時に停止する GOMAXPROCS=16で5秒間に累計50.3秒分の停止を確認 再現 6 CPU使用率だけではスロットリングに気づけない nr_throttled 率は同じ98%でも throttled_usec に14.8倍の差 再現 7 GOMAXPROCSをCPU制限に合わせると改善する GOMAXPROCS=1 で停止時間が 1/1,614 に改善 再現 すべて手元で再現できました。 Docker と Go 1.24 だけでここまで体験できるのは、やってみてよかったと素直に思います。 個人的に印象に残った数字 比較 過剰並列の場合 適切な並列の場合 倍率 Ops/sec(スループット) 6,833 21,504 3.1倍の差 throttled_usec(停止時間) 70.7秒 0.04秒 1,614倍の差 GOMAXPROCS=16の停止時間 50.3秒 - 5秒のテストで50秒停止 本番環境との対応関係 発表 ローカル検証 48コアNode 11コア Docker VM limits.cpu: 5000m --cpus=0.5 GOMAXPROCS=48(デフォルト) GOMAXPROCS=11(デフォルト) 過剰並列倍率: 約10倍 過剰並列倍率: 最大22倍 /sys/fs/cgroup/cpu.max 同じパス(Docker内Linux) cpu.stat の nr_throttled 同じメトリクス 本番ではさらにHAProxy( nbthread=48 , CPU制限1コア = 48倍の過剰並列 )でも同じ問題が起きていたそうで、Goに限った話ではないということがわかります。 まとめ 1. 並列設定を cgroup-aware にする GOMAXPROCS に限らず、 コンテナ内で動くすべてのプロセスの並列設定 は確認したほうがいいです。 ソフトウェア 並列設定 対処 Go GOMAXPROCS Go 1.25+ で自動対応 / 1.24以前は uber-go/automaxprocs Ruby (Puma) WEB_CONCURRENCY / workers CPU制限に合わせて明示指定。cgroup非対応の auto 設定に注意 Java スレッドプールサイズ JDK 10+ は availableProcessors() が cgroup 認識。ライブラリ側も要確認 Node.js worker_threads 数 CPU バウンド処理の並列数を CPU 制限に合わせる HAProxy nbthread 手動でCPU制限に合わせて設定 Nginx worker_processes auto はcgroup非対応の場合あり、明示指定が安全 2. スロットリングを監視する CPU使用率だけじゃなくて、 スロットリングのメトリクスもセットで見る 。これを怠ると実験3で見たような死角にハマります。 メトリクス Linux Datadog 停止時間 throttled_usec kubernetes.cpu.cfs.throttled.seconds 停止ピリオド数 nr_throttled kubernetes.cpu.cfs.throttled.periods 3. throttled_usec まで見る 今回の実験を通して一番の収穫は、 nr_throttled の割合が同じ 98% でも、 throttled_usec に 14.8倍の差がある と自分の手で確認できたこと。スロットリング率だけ見ても、 実際にどれだけ止まっているかは見えない 。 CPUをもっと知りたくなった方へ — 個人的なおすすめ本 今回の検証を通して「もっとCPUの中身を理解したくなった」という方に、個人的に強くおすすめしたい一冊があります。 「プログラマーのためのCPU入門 — CPUは如何にしてソフトウェアを高速に実行するか」 パイプライン、スーパースカラ、分岐予測、キャッシュ、メモリオーダリングといった、 普段は意識しないけれど性能に直結するCPU内部のメカニズム が、プログラマーの目線で一通り整理されている本です。本記事の脱線で触れたキャッシュコヒーレンシまわりも、この本を読むとより腑に落ちると思います。 「なぜこのコードは速いのか/遅いのか」を、ハードウェア寄りの視点から考えられるようになる本なので、cgroup の挙動の先を覗いてみたい方にぴったりです。 参考 ペアーズ本番環境でのcgroup-aware化との死闘録(発表スライド) — 本記事のベースとなった発表 クラウドネイティブ会議2026 セッションページ Container-aware GOMAXPROCS | Go 1.25 Release Notes uber-go/automaxprocs — Go 1.24以前で使えるcgroup-aware GOMAXPROCS Kubernetes CPU limits and requests: A deep dive | Datadog 検証コード 本記事の検証に使ったコードは以下のリポジトリにあります: git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab docker build -t cgroup-bench go-app/ ./scripts/run_experiments.sh # 全実験を一括実行
株式会社タイミーでモバイルエンジニア (Android / iOS) をしている、みかみです。介護領域のグロース施策を中心に、AB テストや分析、マーケティングとの連携などにも取り組んでいます。 2026年4月16日に開催された RevenueCat App Growth Annual Tokyo 2026 (以下 RAGA) に参加してきました。 アプリ成長、サブスクリプション、AI をテーマにしたセッションの中から、個人的に印象に残った話をいくつか紹介します。 RAGA について RAGA (RevenueCat App Growth Annual) は、 RevenueCat が主催する「モバイルアプリ成長」をテーマにしたグローバルカンファレンスです。RevenueCat は、モバイルアプリのサブスク収益化プラットフォームを提供している会社です。 カンファレンスでは「 AI・サブスクリプション・アプリ成長 」をテーマに、プロダクト戦略やユーザー獲得、マネタイゼーション設計といったトピックが扱われていました。登壇者は Notion、ElevenLabs、Speak、YAMAP、SmartNews、Mirrativ、NOT A HOTEL といった国内外のアプリ企業の実践者で、経営層・CPO・CTO クラスが多かったのが特徴的でした。加えて RevenueCat Co-founder / CTO による基調講演もありました。 参加した目的 RAGA に参加したのは、アプリグロースに取り組む他社の現場の話を直接聞ける機会だったからです。日々の業務では、アプリの機能開発だけでなく、AB テスト・分析・マーケ連携にも取り組んでいます。エンジニアリングの先にあるグロース領域への関心も、最近広がってきていました。RAGA で扱われるトピックは、まさに自分の関心と重なる内容でした。 もうひとつ、AI に聞けばなんでも答えてくれる時代だからこそ、現場で一次情報に触れる機会を大事にしたいという考えもありました。後からまとめ記事や録画で追える情報も多いですが、一日を通して様々な実践者の話を続けて聞ける経験は、その場に行かないと得られないと思ったからです。 印象に残ったセッション RAGA は2トラック構成で、世界でアプリを成長させたリーダーが集う「 Global Track 」と、日本市場の実践者や世界進出を目指す起業家が登壇する「 Japan Track 」がありました。自分が参加した中で特に印象に残ったセッションを紹介します。 1. 原点回帰:iモードから現代のアプリ経済へ RevenueCat 共同創業者兼 CTO の Miguel Carranza さんによる基調講演では、日本のモバイル市場の歴史を起点に、現代のアプリ経済が直面する変化が語られました。基調講演の内容や RAGA Tokyo 全体の様子は ProductZine の詳細レポート に詳しいので、ここでは個人的に印象に残った点を中心に紹介します。 冒頭で印象的だったのは、現代のアプリ経済で当たり前になっている概念の多くが日本発だった、という視点です。1999 年の i-mode、絵文字、LINE など、日本のモバイル文化が今のアプリ経済につながっているという話から始まりました。 同時に、日本市場の大きさも具体的な数字で示されました。 日本は世界 3 位のアプリ市場 ユーザー一人あたりの年間支出は $166 2027 年にはスマホユーザーが人口の 94% に達する見込み 一方で、印象に残ったのは「市場が大きい」という話だけではありません。AI によってアプリを作るハードルが下がった結果、過去 4 年間で アプリの供給は7 倍に増え、市場には明確な分断が起きているそうです。上位 25% のアプリは収益を 80% 以上伸ばす一方で、下位 25% は 33% 減少しているという話もありました。 この話を聞いて、アプリ市場は「作れば伸びる」市場ではなくなってきているのだと感じました。AI によって作る力が広がるほど、作れること自体の価値は相対的に下がり、継続して使われる体験をどれだけ設計できるかがより重要になっていくのだと思います。 最後に取り上げられていた 「AI パラドックス」 も、その流れとつながる話でした。AI を組み込んだアプリは初期コンバージョンが強い一方で、解約が約 30% 早く、継続率に課題を抱えるケースが多いそうです。AI の新しさで一度使ってもらうことはできても、継続的な価値に落とし込めなければ LTV にはつながらない、という指摘として受け取りました。 アプリ市場というと、これまで漠然と「大きそうだな」くらいに捉えていましたが、今回の話を聞いて、規模の大きさ以上に競争の中身が変わってきていることを実感しました。ペイウォール設計やトライアル期間、継続率改善といった細部への投資が差を生むという話は、日々のグロース施策を考えるうえでもかなり示唆的でした。 2. 広告視点で考えるサブスクハックネタお披露目会 Repro の中野竜太郎さんと Alethne の坂本翔也さんが、 Adjust の高橋将平さんのモデレートで議論したパネルディスカッションです。広告運用・計測・クリエイティブ最適化など、アプリ成長を広告の視点から見つめてきた登壇者ならではの、現場感の強い話が詰まったセッションでした。 冒頭で出てきたフレーズが強烈です。 "CPI (Cost per install) だけ見る投資はナンセンス。" "エンゲージメントの時代じゃない、アクティベーションの時代だ。" 登壇者たちが共通して強調していたのは、 広告で獲得したユーザーの8割は Day 0 で離脱する という前提です。離脱を防ぐ鍵になるのが 「アハ体験」 、つまりユーザーがアプリの価値を実感する瞬間の設計です。リワードで引っ張ってくるのではなく、自社サービスのコア体験そのものをゲーム化していくのが大事だ、という主張でした。 もう一つ参考になったのが「マジックナンバー」と最適化トリガーの設計の話です。トライアル開始を成果イベントにするのは弱く、「7日間トライアルで辞める気のユーザーは2時間で消える」というデータがあるそうです。そこで、「2時間以上滞在したユーザー」や「特定アクションを N 回実行したユーザー」をマジックナンバーに設定したほうが、広告の最適化トリガーとしても成果が出やすいとのことでした。 AB テストや分析に取り組んでいる身として、「何をイベントとして計測するか」の解像度を一段上げるヒントになる話でした。単に画面遷移を計測するのではなく、ユーザーがそのアプリの価値を実感したシグナルとしてイベントを設計する、という視点が新鮮でした。 3. 激動の時代、日本発メガベンチャーはどのように世界で勝ちに行くのか SmartNews のホン・ランドンさんと Mirrativ の赤川隼一さんによるパネルディスカッションです。コミスマの坂本達夫さんがモデレーターを務めたこの回が、自分にとっては強く印象に残ったセッションでした。プロダクト・グロース・経営の観点から、AI の進化と激化するグローバル市場にどう立ち向かうかが議論されました。 特に印象に残ったのが、グローバル展開の戦略の話です。展開する国の数によって取るべき戦略は変わるそうです。1 カ国に絞るなら徹底した現地化が有効、多国展開なら同一プロダクトを広げて手応えのある市場に集中投資する、という対比でした。いずれにせよ、個別市場の局所最適ではなく、会社全体の収益最大化を基準に手段を選ぶべきだ、という話が腑に落ちました。 セッションの締めくくり、ランドンさんからの「AI 時代に変わるもの・変わらないものは?」という問いに対する赤川さんの答えが、一日で最も刺さった言葉でした。 "変わるもの = ほぼ全部。アンラーニングとスピードが勝負。変わらないもの = 現地現物。" 「優秀な PM は必ず現地でユーザーが迷う姿を観察する」という話でした。AI で機能開発のスピードが上がる時代だからこそ、ただ機能開発を進めるだけではなく、問いを立てる力や、現場やユーザーに直接聞きにいくことの重要性は、むしろ増しているように感じます。 おわりに 参加してみて改めて感じたのは、AI 時代だからといって仕事が 華やか になっているわけではない、ということでした。 各セッションで語られていたのは、AI を使いこなす派手な事例というよりも、その手前にある 地道な計測や粘り強い観察、ユーザーの一次の声を拾い続ける姿勢 だったと感じます。今回語られていた事例のどれもが、こうした地道さの積み重ねの上にあるということを、当たり前のようでいて改めて強く感じました。 AI でプロダクトの作り方は変わっていきますが、アプリを成長させるために向き合う対象は変わらず、むしろ AI が広がるほど、ユーザーをどれだけ深く読めるかがアプリの差として出やすくなっていくのかもしれない、と感じた一日でした。 カンファレンス後のアフターパーティーも独特でした。ライブや DJ セットを交えた構成で、日中のセッション合間にもスタンダップコメディが入るなど、国内の他のカンファレンスと比べても振り切り方が際立っていて、一日を通して印象的でした。
はじめに 株式会社タイミーでデータアナリストをしている平野です。 タイミーキャリアプラス (以下、タイキャリ)という新規事業に、リードDAとして半年あまり伴走してきました。 タイキャリは、タイミーの スキマバイトのデータを起点に、長期就業(正社員採用)を支援する サービスです。タイミーで得た就業実績・バッジを"職務経歴書"として活用できるのが特徴で、2024年2月に開始されています。 新規事業DAの面白さ 0→1のフェーズで、データと事業を一緒に育てていく感覚 を味わえる仕事です。 自分の分析が 事業の意思決定に直結 する瞬間が日常的にある 事業責任者・GM・PdMと机を並べて、 事業の方向づけにデータで参加できる データ基盤・モニタリング・分析テーマを ゼロから設計 できる この記事で書くこと 一方で、新規事業ならではの難しさもあり、半年の間に"知っていれば避けられた"失敗 を数多く経験しました。同時に 再現可能な"型"が存在するとも確信しています。 本記事では、私が踏んだ落とし穴とそこから抽出したアクションを 時系列で整理 します。これから新規事業に入るDAの参考になれば嬉しいです。 Part 1: 新規事業DA特有の3つの難しさ 💡 難しさはPart 2の予防アクション、Part 3の原則とセットで読むと"克服可能な型"として見えてきます。 私が実際に踏んだ落とし穴を3つ共有します。どれも 新規事業DAが共通して直面しうる構造的な問題 で、「誰かが悪い」ではなく、立ち上げ期に起きやすい"あるある"です。DA側がどう動くと摩擦や手戻りを減らせるか、という観点で整理します。 ① 統制構造の有無で難易度が桁違いに変わる DAへの依頼を集約する"要望窓口"(bizops的人材)がいるかどうかで、伴走の難易度が大きく変わります。立ち上げ初期は役割や導線が固まりきっていないため、集約は"自然発生"しないことも多いです。 集約役がいる事業 : 要望が一本化、定義の議論は1対1で済む 窓口体制が立ち上がる前の事業 : DA側が複数の現場と直接やり取りし、 定義確認を個別に進める必要が出てくる 人材紹介領域はbizops担当の方がいてスムーズだった一方、DR領域では 複数の現場と直接やり取りしながら同じ定義の議論を繰り返す ことになりました。 ② DA介入前にプロダクト開発が進んでしまう プロダクト駆動型の新規事業では、スピード重視で DAが合流する前に実装が固まる ことが起きやすく、後から計測設計を整える負担が大きくなります。 DRサービスでは、私が伴走を始めた時点で、 計測設計にDA観点を反映する場がない 状態でした。一気通貫のファネル分析のため、 計測設計の整理を提案する ところから始める必要があったのです。 新規事業の速度感を考えれば自然なこと。だからこそ DA側から早期に合流する関係性を作りに行く ことが重要だと痛感しました。 ③ DA側が組織横断の優先順位付けの場を提案できていない 立ち上げ期は、各担当者が自分の領域の進捗を重視するのは自然(むしろ健全)です。一方で、全体の優先順位を議論する場は用意されていないことも多く、 DA側から立ち上げないと、横断の判断軸を持って動くのが難しくなる と感じました。 私も最初の数ヶ月は会議体を提案できず、 腰を据えるべきテーマに集中しきれない期間 を過ごしました。 Part 2: 時系列アクションリスト 新規事業にDAが入るとき、いつ何をやるべきか を時系列で整理します。各アクションに「なぜそう思ったか」のエピソードを添えました。 🔵 Day 0: アサイン前の意思決定 アクション 内容と効くポイント 🧗 DAをディープダイブ(兼務させない) 新規事業は定義変更・指標追加が高頻度で、半コミットでは事業理解が追いつかない。 特にリードアナリストは他案件と兼務させない ことが、後の伴走の質を大きく左右する 💡 最初からディープダイブ前提でアサインされたことが、信頼関係構築 → 中長期伴走パートナー化につながりました。 🟢 Week 1-4: 関係性構築・PJ立ち上げ 🎯 特に効いた3つ : 関係者ヒアリング / 要望窓口の確認 / ログ設計レビュー合流(プロダクト駆動型) アクション 内容と効くポイント 📋 関係者ヒアリング 事業責任者・GM・現場メンバーに事業の目的・課題・関心事をひとりひとり丁寧に聞く。 この時間投資が後の伴走の土台 になるため、急ぎたい気持ちを抑えても話を聴く価値がある 💬 依頼チャンネル合意 Slack・依頼フォーム等を事業側と合意して「どこに・どの形式で依頼を投げるか」を明確化。受け手のオペが回り、依頼の取りこぼしが減る 🔁 週次定例の継続参加 事業の解像度を大きく上げる手段。 ただし参加だけでは何も変わらない 。「この定例から何のアクションを生むか」を常に自問する姿勢が大事 🚪 要望窓口の確認/提案 集約役不在のままだとPart 1 ①の状況に陥りやすい。いない場合は、GMや事業部のミドルマネージャーに 集約役を担ってもらう依頼導線の設計 をDA側から提案 🔍 ログ設計レビュー合流 プロダクト仕様検討・ログ設計レビューの場にDAが入れるよう、 開発開始前から合流 しておく。これを逃すとPart 1 ②の罠にはまる 📚 ナレッジのGit管理 AIコーディングツール(Claude Code・Cursor・Devin等)が参照できる形でナレッジを蓄積。 分析の壁打ち・クエリ作成の工数が劇的に削減 されるため、アサイン初期の最優先投資 ⚠️ 前任期に「定例参加だけでアクションに繋がらない」時期がありました。 参加は手段、目的ではない 。 🟡 Month 1-3: 目先の困りごと解決期(信頼残高を貯める) 🎯 特に効いた3つ : 爆速対応+問い返しの両立 / 要件定義を向き合いと一緒に言語化 / 基盤整備のタイミング評価 アクション 内容と効くポイント ⚡ 爆速対応 + 「なぜ/必要性/定義」の問い返し 初動の爆速対応は信頼関係構築の重要戦略。「このDAは価値がある」と認識されると、後のポジション変更が楽になる。 ただし依頼対応に留まり続けると"依頼を捌く人"として固定 されるため、「なぜやるか/本当に必要か/定義は適切か」を毎回問い返す姿勢を早期から取り入れる。 信頼関係構築と「言うべきことを言う」は両立可能 ✏️ 要件定義は向き合いと一緒にテキスト化 ダッシュボードやレポート作成の依頼時、「どの目的で、どの観点で、なぜ見るのか」を 向き合いと一緒に書き残せるレベル で整理。これを省くと要件が固まる前に着手することになり、双方にとって手戻りが発生しやすい 🏗️ データ基盤整備のタイミングを常に評価 早すぎても空振り、遅すぎても移行コストが跳ね上がるのが基盤整備の難しさ。 スプシ主体のモニタリングが積み上がってから着手すると、現行オペとの二重管理で身動きが取りにくくなる 🎁 外注保守カット案件は中長期運用主体を確認 「外注の保守費用削減のためにDA側で巻き取る」相談は、一度立ち止まって 中長期の運用主体を一緒に確認したい 類の案件。自分が異動・退職したときの移管コストが高く、長期の保守タスクとして残り続けやすい。 特にdbt等が絡むものは要注意 💡 私はこの切り替えが遅れ、後から上流に意見を出し始めた際に向き合いとの期待値調整が必要になりました。 ⚠️ 整備のタイミングをDA側から提案しきれず、暫定的な参照・運用が一部残っています。 軽いうちに議論を持ち出す ことが重要。 🟠 Month 3-6: 中長期伴走パートナーへのギアチェンジ 🎯 特に効いた3つ : 事業責任者+GM+bizops週次MTG立ち上げ / OKR策定シンクロ / 能動的価値発揮へシフト アクション 内容と効くポイント 🗓️ 事業責任者+GM+bizops 週次MTG立ち上げ 組織全体で優先順位を棚卸しする会議体をDA側から提案して立ち上げると、 横断の判断軸を持って動けるようになる 。この会議体は、2事業目が入ったときの司令塔にもなる 🎯 OKR策定タイミングにシンクロ 期初のOKR策定に合流して、事業側と「何を課題と捉えるか」をすり合わせ。これをやると、その後の依頼について 目的を事前にすり合わせた状態 で動けるようになる 🔀 GM経由のコミュニケーションフロー 現場の複数メンバーからは似た観点の依頼がそれぞれ届くため、個別にすり合わせていると工数が膨らむ。 GM(またはbizops・集約役)経由のフローに整える と、定義の議論が一つの場に集約され、双方のコミュニケーションコストが抑えられる 🎤 能動的価値発揮へシフト 受け身で依頼を捌くフェーズから、 DA起点で価値を提案するフェーズ へ。例: 現場担当者(CA等)へのインタビュー設計で定性課題を抽出 / 事業責任者・GMが意思決定に使う経営ダッシュボードをDA起点で設計・提案 💡 ここからが 新規事業DAの本領発揮ゾーン 。事業の意思決定に直接関われるようになり、 自分の分析・提案が事業の方向づけにつながる手触り感 を強く感じられます。 🔴 2事業目が追加されるタイミング 🎯 特に効いた3つ : 熱量シグナル検知→能動介入 / ログ設計レビュー最優先 / 要望窓口の最初からの設計 アクション 内容と効くポイント 🌡️ 熱量シグナル検知 → 能動介入 新しい領域が熱を帯びる瞬間(目標変更・予算変更等)を DAが能動的にキャッチして動く ことが重要。待っていると後手に回り、Part 1 ②の罠(介入前に実装が固まる)にはまる 🔍 ログ設計レビューを最優先 プロダクト駆動型事業なら必須。 Part 1 ②を2度繰り返さない ためにも、最初期から計測設計のレビューに入る関係性を作りに行く 🚪 要望窓口の体制を最初から設計 1事業目の学び(GM経由フロー)を即座に転用。人材紹介領域で「GM経由フロー」が効くと学んだので、DR領域では 初期から意識的にGM経由フローを設計 した 🗓️ 横断の優先順位会議体を再設計 2つ目以降の事業が入るタイミングを 会議体再設計のトリガー にする。1事業目向けの会議体を拡張して、横断優先順位を扱える形に変えていく 💡 DR領域で目標が跳ね上がる動きを検知し、自分から事業責任者+PdMに働きかけて週次定例への参加を勝ち取りました。 これがなければ今の伴走体制は立ち上がっていません 。 Part 3: 全体を貫く2つの原則 これまでの話は 2つの原則 に集約できます。 原則① 未来を見据えた動きをせよ 「今が楽に回るから」で意思決定しない。 半年後のオペレーション・組織・関係性を想像して、初動で先手を打ちます。 データ基盤整備のタイミングを早めに見定める 初期から事業責任者との接点を作る KPIや優先順位の構造を、組織が大きくなる前に整理する 「目先の困りごと解決 → 中長期伴走パートナー」の移行を意図的にデザインする 新規事業は成長スピードが速く、 "今困っていないこと"が半年後に大きなコスト として返ってくることが頻繁に起きます。 原則② 統制構造(要望窓口)を早期に組め 現場にbizops相当の集約役がいるかで、新規事業DAの難易度は桁違いに変わります。 いなければDA側から「誰が集約役を担うか」を提案するのが、DAの重要な仕事です。 統制構造が組めると、依頼フロー・優先順位付け・コミュニケーション工数の すべてが好転 。逆にここが組めないと、どんなに優秀なDAでも個別対応だけで手が回りきらなくなります。 おわりに 落とし穴を中心に書きましたが、新規事業のDA伴走は半年経った今、 自分のキャリアで最も学びと手触り感のあるフェーズ だと感じています。「新規事業DA、ちょっと面白そう」と感じてもらえたら本望です。 最後に改めて、本記事の失敗エピソードはすべて、 私自身(DA)の動きで改善できる余地について書いたもの であり、事業側の皆さんを批判する意図はありません。 急成長する新規事業の中で常に最善を尽くしてくださっているCareer Plus部の皆さんと、前任のDA・bizopsの方々に深く感謝 しています。 この役回りに関する再現可能なノウハウはまだ少ない領域です。 多くのDAが同じ試行錯誤を繰り返している 現状が変わるきっかけになれば嬉しいです。 最後に、タイミーでは一緒に働くメンバーを募集してます!ご興味があればぜひお話しましょう! プロダクト採用サイトTOP カジュアル面談申込は こちら