タイミーのブログ - TECH PLAY

TECH PLAY

タイミー

タイミー の技術ブログ

全311件

はじめに こんにちは。プラットフォームエンジニアリングチームに所属している徳富( @yannKazu1 )です。 MySQL が遅いとき、まず EXPLAIN で計画を見る。見積もりが怪しければ EXPLAIN ANALYZE で実測と突き合わせる。たいていのケースはこの二つで十分で、実際自分もそれで困ったことはほとんどありませんでした。 ところが先日、この二つをいくら眺めても答えが出ない場面に当たりました。最後に頼ったのが Optimizer Trace でした。普段のチューニングではまず使わないので、すっかり引き出しの奥にしまっていた手札です。そこに至るまでの経緯も含めて書いておきます。 まず、何が起きていたか Aurora MySQL を 3.04.2(8.0.28 互換)から 3.10.3(8.0.42 互換)にアップグレードしたら、通知配信バッチの特定クエリが 4,000〜5,000 秒 滞留するようになりました。アップグレード前は同じクエリが全体コスト 18.27 で、何事もなく一瞬で返ってきていたクエリです(なお、通知配信そのものへの影響は限定的でした)。 クエリは NOT EXISTS の中に IN サブクエリがネストされた構造でした(テーブル名は適当に置き換えています)。 SELECT users.id FROM users INNER JOIN notification_settings ON notification_settings.user_id = users.id WHERE ... AND NOT EXISTS ( SELECT * FROM user_events WHERE event_id IN ( SELECT id FROM events WHERE start_at BETWEEN ? AND ?) AND user_id = users.id) EXPLAIN を見て「?」となる events テーブル(5,000 万行くらい)が type: ALL でフルスキャンされていました。 possible_keys: PRIMARY, idx_events_start_at key: NULL 使えるインデックスが候補に挙がっているのに、使っていない。Datadog DBM の実行計画履歴を見ると、アップグレード前は idx_events_start_at を使った range でした。 試しに SET optimizer_switch='semijoin=off' にしてみると range に戻って見積もり 89,078 行に絞られたので、「semijoin へのフラット化まわりが怪しい」ことはわかりました(後述しますが、犯人はフラット化そのものではありませんでした)。わかったんですが、 なぜこうなるとインデックスが使えなくなるのか はまったくわからない。 EXPLAIN ANALYZE で統計情報の線を消す 次に疑ったのは統計情報です。今回は Blue/Green でアップグレードしてクラスターが丸ごと入れ替わっているので、統計情報がずれている可能性は十分にありそうだと思ったためです。 結果、テーブル行数の見積もりは 5,060 万に対して実測 5,300 万で 1.05 倍。インデックス経由の range も 89,078 に対して 52,129 で 1.7 倍。 どっちも妥当 でした。 一方で、フルスキャン経路だけが明らかにおかしかった。 -> Filter: (events.start_at between ? and ?) (cost=2.64e+9 rows=50.6e+6) (actual rows=52,129) -> Table scan on events (cost=2.64e+9 rows=50.6e+6) (actual rows=53e+6) 同じ BETWEEN 述語なのに、インデックス経由では 89,078 行と正しく見積もるのに、テーブルスキャン経路では選択率がまったく効かず 5,060 万行。実測の約 971 倍です。この誤差が上位ノードでどんどん増幅していました。 ここまでで「統計の問題じゃない、アクセスパス評価のほうがおかしい」とは言えるようになりました。 つまり、 EXPLAIN 系だけだと「変な計画が選ばれている」までしか言えない 。ここで手札が尽きました。 EXPLAIN / EXPLAIN ANALYZE / Optimizer Trace の使い分け Optimizer Trace の話に入る前に、三つの違いを整理しておきます。ここが曖昧だと Trace を出すタイミングが判断できないので。 EXPLAIN でわかるのは決まった計画だけ EXPLAIN SELECT ...; EXPLAIN FORMAT=JSON SELECT ...; -- コスト値も見たいときはこっち 結合順序、各テーブルのアクセス方法、使われたインデックス、推定行数。 FORMAT=JSON にすればコスト値も出ます。基本的にクエリは実行されないので(実体化される派生テーブルなど、一部内部的に実行が走る例外はあります)、重いクエリでも本番で気軽に打てます。日常的なチューニングはこれで十分です。 ただ、EXPLAIN が返してくれるのは、オプティマイザが検討を終えて定めた「こう実行するつもりです」という計画だけです。そこに至るまでに何を検討して、何を却下したのかは出てきません。 だから possible_keys にインデックスが並んでいるのに key: NULL という出力を見ても、「使えるはずのものを使わなかった」という事実がわかるだけで、理由はさっぱりわからない。まさに今回詰まったところです。 EXPLAIN ANALYZE は見積もりと実測を突き合わせる EXPLAIN ANALYZE SELECT ...; MySQL 8.0.18 以降。クエリを実際に走らせて、どう実行したのかと、実行計画の見積もりが実測とどれだけズレていたかを並べてくれます。 -> Filter: (events.start_at between ? and ?) (cost=2.64e+9 rows=50.6e+6) (actual time=22733..27300 rows=52129 loops=1) rows= が見積もり、 actual ... rows= が実測です。これが効くのは「 統計情報がずれているのでは? 」という仮説を切り分けたいとき。推定行数が実測とほぼ一致していれば統計は問題なし、逆に大きくズレていればそこが怪しい、という判断ができます。今回もこれで統計の線を消しました。 ただしこれも、見積もりが合っていたかしかわかりません。なぜその計画を選んだのかは、EXPLAIN と同様に出てきません。また、実行を伴うため、数千秒かかるクエリには気軽に打てません。 Optimizer Trace は「なぜその計画を選んだのか」 MySQL 5.6 から標準で入っている機能で、追加インストールも要りません。オプティマイザが何を検討して何を却下したのかを丸ごと吐き出してくれます。 「なぜその計画になったのか」を知りたいときはこれ一択でした。これも クエリの実行は不要 で、 EXPLAIN に対して取れば中身が出ます(後述)。 三つの違い 雑にまとめると、こうなります。 EXPLAIN … どう実行するつもりか(クエリ実行:不要) EXPLAIN ANALYZE … その見積もりは実測と合っていたか(クエリ実行: 必要 ) Optimizer Trace … なぜその計画を選んだのか(クエリ実行:不要。 EXPLAIN で取れる) 前の二つは「変な計画だ」で止まり、その計画を選んだ理由まで踏み込めるのが Trace です。 今回も、テーブル全体の行数見積もりは妥当なのに、 フルスキャン経路の行数見積もりだけ が実測の約 971 倍に崩れていました。そこまでは EXPLAIN 系で追えたのですが、なぜそうなるのかはどこにも出てきません。そこで Optimizer Trace を開くことにしました。 Optimizer Trace の出し方 拍子抜けするくらい簡単です。セッション変数を on にして、クエリを投げて、 information_schema から読む。それだけ。 SET SESSION optimizer_trace = ' enabled=on ' ; SET SESSION optimizer_trace_max_mem_size = 16777216 ; -- 16MB EXPLAIN SELECT ... ; SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET SESSION optimizer_trace = ' enabled=off ' ; ファイルに落とすなら、後で jq に食わせる前提で JSON として保存します。 TRACE カラムの中身は JSON なので、ヘッダや罫線が入らないよう -N --raw --batch で出します。 mysql --comments -N --raw --batch -u user -p -e " SET SESSION optimizer_trace='enabled=on'; SET SESSION optimizer_trace_max_mem_size=16777216; EXPLAIN SELECT ... ; SELECT TRACE FROM information_schema.OPTIMIZER_TRACE; " | awk '/^\{/{found=1} found' > trace.json EXPLAIN の結果行も同じ標準出力に流れてくるので、 awk で JSON が始まる行以降だけを拾っています。対話シェルで目で見たいだけなら \G でもいいですが、 \G 付きの出力は *** 1. row *** や TRACE: といった装飾が付くので、そのまま jq には渡せません。 本番で打つときに安心できる点を二つ。Trace はオプティマイザの動きの記録なので、 実行を伴わない EXPLAIN でも中身が出ます 。数千秒かかるクエリを本番で流す必要はありません。そして SET SESSION で有効化した Trace は その接続にしか効かない ので、他のセッションを巻き込まず本番でも打てます。 Optimizer Trace を読む 最初に Trace を開いたときの感想は「うわ」でした。数 MB の JSON が出てきて、どこから見ればいいのか見当もつかない。 先に言っておくと、 全部読む必要はありません。 見るべき場所は数箇所です。以下、それぞれのステップが何を記録しているのかと、今回のTraceで何が見えたのかを並べて見ていきます。 全体の構造 トップレベルは三つに分かれています。 { " steps ": [ { " join_preparation ": { ... } } , { " join_optimization ": { ... } } , // 9割ここ { " join_explain ": { ... } } // 実行した場合は join_execution ] } 三つ目は、 EXPLAIN で取ると join_explain 、実行を伴うと join_execution になります。どちらにしても、読むのはほぼ join_optimization です。まずはどんなステップがあるかを一覧します。 # 最適化フェーズのステップ一覧 jq '.steps[] | select(.join_optimization) | .join_optimization.steps[] | keys' trace.json # 前処理(サブクエリ変換など)はこちら jq '.steps[] | select(.join_preparation) | .join_preparation.steps[] | keys' trace.json select() を挟んでいるのは、 .steps[] が上記三つの要素を全部回すためです。そのまま .join_optimization.steps[] と書くと、 join_preparation の要素で null に当たって Cannot iterate over null で止まります。 jq '.steps[].join_optimization?.steps[]? | keys' と ? を付ける書き方でも同じです。 これでどんなステップが並んでいるかがわかります。その中で見るのは、実質この四つでした。 transformations_to_nested_joins … サブクエリがどう変換されたか(これだけ join_preparation 配下) rows_estimation … 各テーブルで range が使えるかの判定 considered_execution_plans … 候補とそのコスト。一番ボリュームがある attaching_conditions_to_tables … 条件の割り当て。ここで計画が書き換わることがある transformations_to_nested_joins :サブクエリがどう変換されたか join_preparation 配下にあり、 IN や EXISTS のサブクエリが semijoin / antijoin にフラット化されたかどうかが記録されています。フラット化されるとサブクエリという単位は消え、通常の結合として最適化されるので、計画が大きく変わる出発点になります。 今回の Trace はこうなっていました。 { " select# ": 5 ," from ":" IN (SELECT) "," to ":" semijoin "," chosen ": true } { " select# ": 4 ," from ":" IN (SELECT) "," to ":" antijoin "," chosen ": true } 変換自体は起きていました。ただ、旧バージョンの EXPLAIN JSON にも is_not_null_compl が出ていたので、旧版でも同じように変換されていたと見てよさそうです(後に AWS サポートも両バージョンで同一と確認)。 semijoin=off で直ったので真っ先に疑っていた「フラット化そのものが犯人」という仮説は、ここで消えました。 rows_estimation :range が使えるかどうか テーブルごとに、range スキャンが使えるかを評価した結果が入っています。 " range_analysis ": { " table_scan ": { " rows ": 50628522 , " cost ": 5887250 } , " potential_range_indexes ": [ { " index ": " PRIMARY ", " usable ": false , " cause ": " not_applicable " } , { " index ": " idx_events_start_at ", " usable ": true , " key_parts ": [ " start_at ", " id " ] } ] , " analyzing_range_alternatives ": { " range_scan_alternatives ": [ { " index ": " idx_events_start_at ", " rows ": 89078 , " cost ": 10450 , " chosen ": true } ] } } ありがたいのは、 usable: false に多くの場合 cause が付いてくるところです。「なぜ候補から外れたか」がここで一段階わかります。 今回の events を見ると、 idx_events_start_at は usable: true 。range の見積もりも 89,078 行・cost 10,450 と妙なところはなく、この時点ではインデックスがちゃんと候補に挙がっていました。 もう一つ大事なのは、ここで chosen: true (そのステップの比較でベストだと判断された、という印)になっていても最終的に使われないケースがあることです。今回はまさにそれでした。 considered_execution_plans :候補の比較 一番量が多いところです。結合順序の組み合わせごとに、各テーブルの best_access_path が延々と記録されています。 { " plan_prefix ": [ " users ", " user_events " ] , " table ": " events ", " best_access_path ": { " considered_access_paths ": [ { " access_type ": " ref ", " index ": " PRIMARY ", " usable ": false , " chosen ": false } , { " access_type ": " range ", " range_details ": { " used_index ": " idx_events_start_at " } , " rows_to_scan ": 89078 , " resulting_rows ": 89078 , " cost ": 308026000 , " chosen ": true } ] } } 候補とコストが横に並ぶので、「どれがいくらで、なぜ選ばれたのか」が数字で見えます。今回の Trace では、この段階では range が chosen: true でした。つまりコスト計算の上ではインデックスを使うつもりだった、ということになります。 よく使う jq: # 特定テーブルのアクセスパス候補だけ jq '.. | objects | select(.table? == "events") | .best_access_path' trace.json # chosen になったものだけ拾う jq '.. | objects | select(.chosen? == true)' trace.json attaching_conditions_to_tables :ここで計画が書き換わる 名前からすると地味な後処理に見えるし、自分も最初は読み飛ばしていました。ところが今回の原因はここでした。 見るべきは rechecking_index_usage です。「条件を割り当て直したので、インデックスの使い方をもう一度検討する」という処理で、結合の内側に回ったテーブルについてはここで range を組み直します。今回の Trace はこうでした。 " rechecking_index_usage ": { " recheck_reason ": " not_first_table ", " range_analysis ": { " table_scan ": { " rows ": 50628522 , " cost ": 5887250 } , " best_covering_index_scan ": { " index ": " idx_events_start_at ", " cost ": 5143080 , " chosen ": true } , " setup_range_conditions ": [] } } setup_range_conditions が空になっています。組み直すための BETWEEN が渡ってきていません。range アクセスは「どこからどこまで読むか」という範囲条件とセットでしか成立しないので、条件がなければ range は候補として再構成されません。残るのは table_scan とカバリングインデックスの全スキャンだけで、安いほう(cost 5,143,080)が選ばれる。つまり、 range が負けたのではなく、range が候補として作られなかった わけです。 これで、 possible_keys にインデックスがあるのに key: NULL になる現象も、EXPLAIN ANALYZE で見えていた「テーブルスキャン経路だけ選択率が効かず約 971 倍ズレる」現象も説明がつきました。オプティマイザはインデックスを使うつもりでコストを計算していて、最後の工程でその手段を失っていただけでした。 AWS サポートに投げたら、バグだった 「コスト計算で選ばれた range が確定処理で消えている」というのは、どう考えても正常な最適化には見えません。Trace の該当箇所を添えて AWS サポートに問い合わせたところ、実体化経路については MySQL の既知バグに該当することが確認できました。 Bug #117770 : rechecking_index_usage causes the materialized table to be generated using table scan in Materialize semijoin strategy(問い合わせ時点で Status: Verified、修正バージョンなし) フラット化(FirstMatch)経路のほうは該当する公開バグ報告が見つかっていないものの、同じ機構で range が失われることを再現確認した、とのことでした。 回避策としては、対象クエリ 1 文に限定して、オプティマイザヒントで events に使うインデックスを明示する方針にしました。ステージングで EXPLAIN を取り、 events が idx_events_start_at の range に戻ることを確認してから入れています。 SELECT /*+ INDEX(events idx_events_start_at) */ users.id FROM users ... 振り返って この件で Trace が役に立ったのは、原因を具体的に書けるようになったからだと思っています。 EXPLAIN 系だけだと「変な計画が選ばれる」までしか言えませんが、Trace を読んだことで「確定処理の再チェックで range が捨てられている」と書けるようになりました。おかげで、最適化の結果ではなくバグではないかと疑えたし、問い合わせるときも該当箇所を貼るだけで話が通じました。 ここで Trace を思い出せなかったら、「アップグレードしたら遅くなったので戻します」で終わっていたと思います。 まとめ Optimizer Trace は毎回出すものではないです。順番としては、 EXPLAIN で変な計画を見つける EXPLAIN ANALYZE で見積もりのズレ方を見て、統計情報が原因かを見極める それでも「なぜこの計画なんだ」が説明できないとき、 Trace を開く EXPLAIN でわかるのは何が起きたかで、Trace でわかるのはなぜ起きたかです。 Trace を取るのに必要なのは SET SESSION optimizer_trace='enabled=on' の一行だけです。使う頻度は低いですが、詰まったときの選択肢として覚えておくと便利だと思います。
こんにちは!このたびタイミーは、9/11(金)開催のGo Conference 2026 にGoルドスポンサーとして協賛し、ブースを出展いたしました。 gocon.jp 素敵な場をつくってくださった運営のみなさま、そしてブースにお越しいただいたみなさま、ありがとうございました! Closingでの発表によると、参加者は会場とオンライン合わせて700人を超えたそうです。その内、300人以上の方がタイミーのブースに立ち寄ってくださいました。 この記事では、タイミーのブース企画と、ブースでいただいた質問をご紹介します。執筆者はこちらの4名です。 Engineering Manager: 新谷( @euglena1215 ) Backend Engineer: 成田( @7riatsu ) Backend Engineer: 永田( nagataaaas · GitHub ) DevEnable室: shihorin( @shihorin_kjy ) 企画紹介 企画①タイミーでのGo活用紹介 株式会社タイミーは、「働きたい時間」と「働いてほしい時間」をマッチングするスキマバイトサービス「タイミー」を開発・運営しています。 バックエンドはRuby on Railsで主に開発していますが、認証基盤にはGoも活用しています。トータルプラットフォームを実現する構想の中で、Goの出番はこれから広がっていく可能性があります。 ブースでは、システム構成図やプロダクト戦略のイメージ図をご覧いただきながら、Go活用の現状と今後の可能性についてお話しました。 企画②Goの良いところを教えていただくアンケート ブースでは、アンケートへのご回答をタイミーでのお仕事に見立てた体験企画をご用意しました(実際の求人ではありません)。ご回答いただいたお礼にガチャを1回まわしていただき、ストレスボール、ぷくぷくシールなどのノベルティをお渡ししました。 アンケートの設問は「Goを選んでよかったこと、教えてください!」です。タイミーではこれからよりGoを活用していく可能性があります。そのため「Goエンジニアの先輩方からGoの良いところを教えていただきたい!」という意図でこの設問にしました。 結果はこちらです! 元々用意していた選択肢に加えて、「バージョンUPしやすい」「Gopherがかわいい」という選択肢も途中で追加していただきました。 良いところ 票数 チームで読みやすく、保守しやすい 134 ビルド・デプロイがしやすい 94 並行処理を扱いやすい 69 ツールがそろっていて開発しやすい 49 性能を出しやすい、省リソース 43 Gopherがかわいい 35 まだ使っていない 11 バージョンUPしやすい 10 並行処理の扱いやすさや省リソースであることよりも、ビルド・デプロイの方がかなり票を集めていることに驚きました。 1エンジニアとしての言語の扱いやすさにとどまらず、プロダクトの一部として見たときの取り回しの良さが評価されているのかな?と考察しています。 企画③Reading List タイミーのプロダクト開発をもっと知りたい方向けに、「Reading List(おすすめ記事リスト)」をQRコードで掲示しました。その中から、特に反響のあった記事を紹介します。 タイミーの1,200万超ユーザーを支える認証基盤を Go と Ory Hydra で作っている話 バックエンド開発Handbookを届けるために ― AI時代の知の高速道路を敷く - Timee Product Team Blog プロダクト開発に関する記事の公開情報や登壇情報は、Xでも発信しています。ぜひご覧ください! Timee Engineering / タイミーエンジニアリング 公式 (@TimeeDev) / X ブースで特にいただいた質問 + 回答 特に多くいただいた質問と、質問への回答をご紹介します。回答の中で出てくる用語は、以下の構成図でご確認ください。 再掲:ブースでお話ししながらご覧いただいた、システム構成図 Q1.「タイミーはGoを使っているのですか?Rubyの印象でした」 A.Goも使っています!基幹API(スポットワークのタイミー)はRubyで動いているのですが、ワーカー(toCユーザー)向けの認証基盤をGoで開発しています。 これまでバックエンドはRubyだけだったのですが、ここからバックエンドの言語のバリエーションを増やすことを前向きに検討していて、その中にGoが含まれています。 Q2.「タイミーの認証基盤はどういうもの?何のために作っている?」 A.複数事業展開を見据え、特定プロダクトに依存しない共通アカウント基盤が必要になったためです。 もともと認証機能は基幹 API(スポットワークのタイミー) に同居していましたが、それを最近切り出しました。スポットワークに隣接する事業を始めるために、同じアカウントでのログインや、データ連携ができるアカウント基盤が必要になったことがきっかけです。既存の認証機能は基幹APIの一部として実装されており、そのままでは共通の認証機能として利用できませんでした。そこで、独立したIdPとしてゼロから構築し、段階的に移行する判断をしました。 再掲:ブースでお話ししながらご覧いただいた、プロダクト戦略のイメージ図 Q3.「認証基盤だけGoなのはなぜ?なぜ認証基盤だけRails以外の技術選定をしたの?」 A.認証機能を既存のRailsアプリから独立したサービスとして切り出すにあたり、言語仕様がシンプルで、標準ライブラリや機能を絞った軽量なライブラリを組み合わせて必要な機能だけを実装しやすいGoを採用しました。型安全性や保守性を確保しやすく、長期運用する基盤との相性がよいことも理由の一つです。 また、OAuth2/OIDCに採用したOry HydraもGo製で、必要に応じて内部実装をコードリーディングで理解しやすいこともメリットでした。 Q4.「今後もGoを選定する予定はありますか?」 A.タイミーでは今後新規事業にも取り組んでいくことを検討しています。その際の技術選定の有力な選択肢としてGoを考えています。(検討・検証段階なので確定ではありません) 技術選定の参考にしたいという意図もあり、今回のブース企画を実施させていただきました。 Q5.「既存RailsアプリはGoにリプレイスするのですか?」 A.言葉通りのリプレイスは考えていません。 先述した認証基盤と同様、複数事業展開に伴い、いくつかの事業で同様の基盤が欲しくなることがあると思います。そういったユースケースが生まれた際に、基幹APIから共通基盤を切り出し移行する可能性は十分にあります。共通基盤の技術選定で、Goは候補のひとつになると考えています。その上で、Goが選ばれるのか、Rubyが選ばれるのか、はたまた別の言語が選ばれるのかはプロジェクト次第になりそうです。 Q6.「整合性チェッカーは何のためにあるのか?」 A.新たに構築した認証基盤(IdP)への移行途中である現在は、既存DBとIdP DBの双方へ書き込むダブルライト構成です。2つのDBでデータがずれる可能性があるため、整合性チェッカーが定期的に両DBを比較し、不整合を検知・通知しています。これは移行期間中の一時的な安全装置であり、読み書きをIdP DBへ完全に切り替え、ダブルライトを終了した後は廃止予定です。 おわりに ここまで読んでくださり、ありがとうございました!これからもGoコミュニティを盛り上げていけるよう、継続して関わっていけると嬉しいです。 最後になりますが、タイミーでは一緒にはたらく仲間を募集しています。ご興味をお持ちいただけましたら、ぜひカジュアル面談にお申し込みください! プロダクト組織の概要や募集職種 シニアバックエンドエンジニア(プラットフォーム領域)採用情報 カジュアル面談のお申し込みはこちら
はじめに タイミーでは、世界中で開催される技術系カンファレンスの参加を支援する「Kaigi Pass」という制度があります。今回は、2026年9月5日(土)に中野セントラルパークカンファレンスにて行われた、Product Engineering Conference 2026に5名が現地参加してきました。 本レポートでは、参加したエンジニアが注目したセッションごとに、参加者自身の視点で学びや気づきをまとめました。読者の皆様にとって、今後の学びの参考になれば幸いです。 営業・CSを「開発の当事者」にする — 少人数チームで複数プロダクトを動かすDev<>Ops連携の方法 speakerdeck.com こちらのセッションは、estieの山本龍平さん・北村大助さんによる、少人数で複数プロダクトを動かすなかで、営業・CSがプロダクト開発の当事者になる取り組みについての共同発表でした。 Ops(営業やCS)は、要望をDev(開発)に渡して実装を待たない。AIでモックを作り顧客に当て、課題と解決策の仮説を磨き込んでからDevに渡す。現場に近いOpsが仮説検証を一定担うことで、質と速度を上げる、というのが主題でした。 印象的だったのは、Opsの北村さんの話です。小さく早く検証することを前提に、動くものを用意して顧客とのコミュニケーションコストを下げ、検証精度を上げていることでした。 仮説検証はDevの役割だと思い込んでいた私にとって、Opsが回せるよう検証環境で支えるという体制は、前提を覆される話でした。また、仮説検証に対する「小さく、早く」といった姿勢をどのようにOpsに共有しているのかという疑問が浮かびました。 そういったプロダクト開発に対する理解向上の取り組みとして タッグ開発 Night が紹介されていました。社内のハッカソンに近いイベントで、Opsが顧客要望をもとにAIでプルリクエスト(PR)を出す。仮説を試す部分をDevと進める場でした。 このほか、 Preview環境でPRごとに専用DBを用意する取り組み など、Opsの越境を助ける具体事例も紹介されていました。自社でも、まずは「動くものを顧客に当ててからDevに渡す」ところから試せそうです。自身の役割を越境するだけでなく、担当領域への越境を助ける環境づくりまで含めて、組織としてプロダクト開発を進める視点を得た発表でした。 津守( @ytsumori59 ) エンジニアリングは、どこまで拡大解釈できるか 心技体をつないだままで事業責任者になった話 speakerdeck.com 柳川さんのセッションでは、エンジニアリングを「責任を引き受ける営み」として捉え、技術の実装にとどまらず、プロダクトのアウトカムまで責任範囲を広げる考え方が語られていました。 責任範囲を広げる鍵として示されたのが、責任を負う実感(心)、スキル(技)、現場感覚(体)です。3要素を切り離さずに育てることが重要だと、私は受け取りました。 AIはスキルを伸ばす助けになります。一方、責任を引き受ける実感は、組織環境にも左右されます。そのため、個人プロダクトのリリースなど、社外で自ら意思決定と結果を引き受ける経験も有用だと提示されていました。 技術や知識の活用をAIが支援する範囲は、今後さらに広がっていきます。だからこそ、アウトカムに責任を持つ姿勢の重要性は増すのではないかと感じました。 私は、「プロダクトエンジニア」という言葉には、アウトカムへコミットする姿勢が含まれると考えています。その延長線上に事業責任者という役割がある、という捉え方には納得感がありました。 この話を聞き、仕事で大切にしている「ジブンゴト」という言葉を思い出しました。物事を自分の責任として捉え、行動する姿勢です。今回のテーマにも通じると感じました。 発表では、「エンジニアは元来ラストマンである」という問いかけもありました。ここでいう「ラストマン」とは、成果物の最終責任を引き受ける立場を指します。また、組織の中に暗黙的な親子関係が生まれる、という経営学的な視点も印象に残りました。 AIが成果物の生成を支援する範囲が広がるほど、最終的な責任を誰が担うのかを意識する必要があります。自分はどこまで責任を引き受けるのか。改めて考える機会になりました。 また、また、柳川さんの発表そのものも素晴らしいものでした。「責任」を起点に話を展開し、帰納的にセッションのテーマへ収束させる構成や、洗練された言葉選びが強く印象に残りました。 これまで聴講したセッションの中でも、特に心を動かされました。エンジニアとして、職業人として、こんなふうに生きたいと思わせてくれるセッションでした。 志賀( @akitoshiga ) その機能が使われないのは、業務の捉え方を間違えたから?─プロダクトエンジニアのメタモデル設計論 特に印象に残ったのは、機能をリリースするというアウトプットがあっても、顧客の業務が良くなるというアウトカムにはつながらないことがある、という話です。その原因が機能の不足ではなく、そもそもの業務の捉え方にあったという点が、日々の開発を振り返るきっかけになりました。 このセッションで紹介されたのが、業務をどのような概念や関係性で捉えるかという「メタモデル」の考え方です。その土台には、一次情報から業務の流れを理解することがあります。データがどう移動するかに加えて、誰が何を判断し、どのように仕事を進めているのかを捉える。現場を観察し、可能であれば体験し、専門家との対話で判断の背景を確かめる。業務モデルは会議室の議論だけでは完成しない、という話が心に残っています。 業務の中心となる概念を見極める観点も参考になりました。例えば契約であれば、さまざまな処理の軸に、顧客と交わした約束があります。個々の処理やデータ項目を整理するだけでなく、それらが何を軸につながっているのかを考えることが、モデルの土台になるのだと受け取りました。 また、良い業務モデルは、顧客が業務の進め方を組み立て、運用する負担を吸収するという話もありました。柔軟な機能を用意しても、その組み合わせや使い分けを顧客が一から考える必要があれば、負担は残ります。業務理解をモデルに反映することは、コードの構造だけでなく、顧客がどれだけ無理なく使えるかにも関わるのだと思いました。 ここからは、タイミーの身近な機能を題材に、この考え方を自分なりに当てはめてみます。 例えば求人を公開する際には、募集人数が定員に達していない場合に、設定に応じて公開範囲を自動で広げる仕組みがあります。詳しくは、 求人の公開範囲 についてをご覧ください。 この仕組みを単なる機能拡張ではなく「業務の流れ」として捉え直してみます。すると、応募状況や残り時間を踏まえて「どのタイミングで求人を届ける相手を広げるか」という事業者の判断を支えていることが見えてきます。 言われてみれば当然のことですが、日々の開発に追われているとつい見落としがちな視点です。機能が裏で支えている判断や作業にまで解像度を戻してこそ、本質的な設計や検証ができるのだと改めてハッとさせられました。  効果を確かめる際も、自動で公開範囲が広がったかというシステム挙動だけでなく、事業者様の手間が減り必要な人数の確保につながったかを見る必要があると思います。同時に、ワーカーさんにとっても希望や条件に合う仕事との出会いにつながったかも重要です。「募集が埋まる」という数値だけでなく、その先の就業が双方の期待に沿う体験になったかまで視野を広げることが、まさにセッションで聞いた「業務を捉える」ということなのだと感じました。今後の自分の開発でも、この視点を忘れずに向き合っていきたいです。 細野 越境負債:称賛されるほど、負債は返済されない 「越境をしても、越境が必要になってしまう状況そのものは越境では改善されない」という内容。越境が越えている境界を組織のそれであるとし、プロダクトの価値最大化のために越境は必要だが、一方でそういう境界を最適化する手法として組織再編を挙げていた。つまり、プロダクト価値の創出のために越境が必要だという状況は、組織の境界が最適でないということを意味する。もちろん越境は組織再編行為ではないため、最適でない状況そのものは、越境では改善されない。 たしかにそういう状況ならそう言えるかもしれない。組織の境界を越える行為を越境と呼ぶならば、組織再編が根本解決手段の一つであり得る。またそれを役割の境界であるとするなら、職務記述やロールの定義を更新すべきなのかもしれない。 ただどうしても、境界があれば越境が可能になる。境界を意識させないために私達は、これまでにも「何でも屋」、「遊撃部隊」、「フルスタックエンジニア」、「FDE」などを生み出してきた。実際のところ何でもできる人の手が空いているのなら何でも幅広く任せるほうが、少なくとも短期的なプロダクト価値の最大化にとっては最適だと言える。境界がある限り、越えたくなる。そして価値を創出している限り、越境は称賛されなければならない。 個人的には、どこかに線を引く限り越境はなくならないと思うし、それは称賛されるべきだとも思う。 口藏( @mizunokura ) おわりに Product Engineering Conferenceは初開催ながら、多くの参加者が集まり、皆さんのプロダクトエンジニアリングに対する熱量を感じました。 各セッションを通じて、プロダクトエンジニアリングについて改めて考える機会になりました。今回得た学びはタイミーでのプロダクト開発にも活かしていきたいと思います。 次回の開催については未定とのことですが、開催される際はぜひ次回も参加したいと思います。
こんにちは。タイミーでプロダクトエンジニアをしている津守です。 普段は、エンジニアとして機能の開発をメインの仕事としていますが、最近は営業チームの商談に同行する機会が増えてきました。そこで突きつけられたのが、タイトルの問いです。 自分が作った機能を、自分で売れるか。 機能は作れる。仕様も説明できる。でも、顧客を前にして「これはあなたの課題をこう解決します」と語り、納得してもらえるか。やってみると想像以上に難しく、同時に開発者として得るものが大きい体験でした。 この記事では、商談同行を重ねる中で見えてきたことと、そこから営業メンバーとの間に育っていったコミュニケーションパスについてまとめます。9月5日開催の Product Engineering Conference 2026 (以下、PdEConf 2026)が掲げる「職能の壁を越え、プロダクト価値を最大化させる」というテーマの、ひとつの実践記録として読んでもらえると嬉しいです。 なぜ「自分で売ってみよう」と思ったのか きっかけは、チームで開発した機能がなかなか顧客に活用されない、という課題感でした。 タイミーでは、営業が顧客にしっかり伴走しながらプロダクト導入後もサポートするコンサルティングに近い構造です。機能案内なども都度丁寧に行う体制ではありますが、新しく作った機能の導入は思うように広がりませんでした。チームとしては必要とされる機能を開発している認識でしたが、それがなかなか使われない。なぜ使われないのかという疑問に加え、使われなければ当初予定していた検証も進みません。 どうすれば営業メンバーの理解を得て、機能を届けられるのか。スプリントレビューの運用を見直して情報伝達を改善し、営業行動ログから提案状況も追いました。しかし、解消には至りませんでした。 チーム内に閉じた取り組みでは答えが出ないので、まずは「自分が作った機能は本当に顧客に売れるのか」を現場で感じ取りたい。そう考えて営業メンバーに相談し、商談への同行を始めました。 売ろうとすると、機能の強みと弱みがわかる 実際に商談の場で機能を説明して真っ先に気づいたのは、価値を感じてもらえる点と解決できていない点が同時に見えることでした。概要の説明だけで納得してもらえる部分がある一方で、答えに詰まる指摘や、対応しきれていない側面が容赦なく浮き彫りになります。 素朴な質問に十分答えきれず「将来的には対応していきます」としか言えない——。 答えに詰まる箇所は、そのまま営業メンバーが提案しづらい箇所です。 逆に、その場で納得してもらえた説明の切り口は、そのまま営業が使える強みになります。 ユーザーインタビューと違い、商談では自分が価値を証明する側に立ち、顧客も「使うか、使わないか」を判断します。そのため、「使うけれど、ここは不満」という改善要望と、「ここが解決されなければ使わない」という利用の障壁を切り分けられます。既存の運用や他の選択肢との比較も含め、機能がまだ使われていない理由を直接受け取れるのが、商談ならではです。 ユーザーインタビューと商談同行の違い ただし、注意しなければいけない点もあります。商談で聞いた一社・一人の意見は実在する課題ですが、優先すべき課題かは別問題です。 セールス主導開発の滑りやすい坂道 が指摘するように、商談同行は使い方を誤れば、一社の要望を過度に優先した開発の入り口にもなります。複数のユーザーに聞いて「重なる課題感」に手をつけながら、個別事例には深く踏み込む。この具体と抽象の行き来と、 定常的に顧客接点を持ち続ける ことが必要です。 「自分が売れた」で終わらせない ― プロダクトを売れる形で提供する 自分が同行した商談での提案がきっかけで売上に貢献できると、素直に嬉しいです。「自分で作った機能を自分で売れるんだ」という手応えは、ものづくりをする人間にとって強烈な体験ではないでしょうか。ただ、この体験は感動が大きい分、意識しないと「自分で売ること」自体を目指してしまい本来の役割を見失ってしまいます。 エンジニアが商談に同行する意義は、自分が売れるようになることではないと考えています。自分の中で売り方の検証を回し、そこでの学びをプロダクト開発に反映することが本筋です。そのうえで、刺さった提案の仕方や文脈を型化し、営業資料のひな形として渡していく。つまり、 プロダクトを「売れる形」にして提供すること です。改善した機能と、それを説明するための型をセットで渡せば、組織全体で価値を届ける力になります。 営業とともに開発する体制を築く 商談同行は、一度きりで終わらせず継続することで真価を発揮します。同行を重ね、商談後にはその場で営業メンバーと振り返り、機能の強みと弱みを一緒に整理して目線を揃えていく。これを繰り返すことで、営業メンバーとの間に「機能を届ける」ためのコミュニケーションパスができていきました。 一番大きな変化は、商談に関する相談をされるようになったことです。機能説明の商談を控えたメンバーから「この機能はどう勧めるのがいいか」と事前に相談が入る。商談を終えたメンバーから「この質問に上手く答えられなかった」と持ち込まれる。以前はプロダクトへのフィードバックの機会がほぼスプリントレビューだけでしたが、Slack で非同期にこうしたやり取りが行われるようになりました。これらは営業の困りごとであると同時に、機能に対するフィードバックそのものです。相談で出た論点は開発に反映されたり、営業資料のひな形に反映し共有知として残したりしています。 変化は開発チーム側にも起きました。チーム内で商談の内容を共有していくうちに、機能をどう売るかを主題として会話する機会が増え、メンバーの関心が自然と営業の動きへ向いていきました。営業メンバーの日報に開発メンバーがリアクションする光景は、今では当たり前です。 この双方向の関心が重なった結果としてスプリントレビューにも変化がありました。開発チームからの共有会に近かった場が、お互いが「この機能をどう提供するか」の視点で意見を交換し合い、どんなプロダクトにしていくべきかを議論する場になりました。 商談同行で生まれたコミュニケーションパスの変化 この関係性が当たり前になると、 作る前から「これは本当に届けられるのか」という問いに対して、解像度の高い状態で開発を進める環境 が生まれます。一度の商談で得られるのは顧客の課題ですが、続けることで得られるのは、課題が自然に集まってくる関係そのものです。 おわりに 「そのプロダクト、自分で売れるか?」 自分で売ろうとすれば機能の強みと弱みが同時に見え、その学びをプロダクトの価値に還元する。売れるかという問いへの答えを探す過程で、営業メンバーと「作る前から『届けられるか』を検証できる土壌」が育っていきました。 この取り組みは、自分の役職に囚われない動きも必要でしたが、それ以上に営業メンバーが日頃から築いてきた顧客との関係性があったから起こせた行動でもあります。良いことも悪いこともはっきりフィードバックしてもらえる顧客が多いのは、営業メンバーの日々の取り組みのおかげなので本当に感謝しています。 PdEConf 2026 に参加される方で、同じような取り組みをされている方、これから始めようとしている方がいれば、ぜひ会場でお話しできると嬉しいです。
こんにちは、タイミーでバックエンドエンジニアをしている志賀( @akitoshiga )です。 先日、複数の顧客企業にまたがる、複雑なデータ統合プロジェクトに取り組みました。 このプロジェクトには、次のような難しさがありました。 要件が曖昧で、影響範囲も見えづらい 統合パターンごとに状況や制約が異なり、考慮すべき条件が多い 関係者が多く、合意形成に時間がかかりやすい 事業上、変更しづらい期限がある 当時の体制では、バックエンド領域を主担当として動けるメンバーが限られていた このまま進めるだけでは、期限内の完了が難しい状況でした。 結論から言うと、次の3つを実践することで、期限内に完了できました。 AIを使ってコードと業務を調査し、自分で判断できるメンタルモデルを作った AI-DLCを使い、主担当領域の異なるメンバーも実装を進められる状態を作った ドキュメントを通じて論点を絞り、関係者の認知負荷を下げた 本記事では、この取り組みを紹介します。 プロジェクトの概要 タイミーでは、契約・請求・拠点・アカウント・アクセス権限など、複数の業務データが企業情報と結びついています。 データ統合は、単に参照先を変更するだけでは終わりません。 統合後にどのような状態であるべきかを定義し、その状態に合わせて関連データを整合させる必要があります。 今回のプロジェクトでは、統合パターンごとに移行範囲や制約が異なりました。 そのため、対象データごとに「何を移すのか」「何を残すのか」「どの状態を正とするのか」を判断する必要がありました。 複雑な統合パターンの存在 実案件を抽象化すると、次のような関係でした。 ある統合元は、すべての拠点を統合先へ移す 別の統合元は、一部の拠点だけを統合先へ移す さらに別の統合元は、別の企業へすべての拠点を移す この図は、実案件を説明用に抽象化・簡略化したモデルです。 ポイントは、同じ対象が、ある統合では移行元になり、別の統合では移行先にもなることです。 そのため、すべてのデータを一律に移すことはできません。 残すデータと移すデータを、対象範囲ごとに判定することが求められました。 また、別の統合によって入ってくるデータも考慮する必要がありました。 そのため、統合の実行順序にも依存関係が生まれました。 アクセス権限の組み合わせ爆発 顧客に紐づく各種データについても、場合分けが必要でした。 その一例が、管理画面アカウントとアクセス権限です。 アクセス権限には、全体に関わるものと、一部の範囲に関わるものがあります。 移行後も、本来アクセスできない範囲へ権限が広がってはいけません。 一方で、移行前に必要だった範囲へのアクセスを失わせることもできません。 この両立が、難しいポイントでした。 権限の種類、移行範囲、統合パターンの組み合わせによって、望ましい状態が変わるためです。 関連データごと種類ごとにこれらの判断が必要になる 他のデータも同様でした。 統合パターン、移行範囲、状態、関連データの制約が組み合わさり、判断すべき条件が増えていました。 自分は、関連するすべてのドメインに詳しいわけではありませんでした。 影響範囲を把握し、統合後の状態を決めるには、まず調査が必要でした。 どう進めたか AIを使って調査し、メンタルモデルを作る まず、AIを使ってコードを解析しました。 そのうえで、実際に画面を操作し、コード・画面・データの対応関係を確認しました。 自分の中でメンタルモデルを作れるまで、時間をかけて調査・検証しました。 前例がなく、自分たちで統合後の正しい状態を定義する必要があったためです。 ここで言うメンタルモデルとは、次のようなものです。 どのデータが、どの業務概念を表しているか どの操作で、どのデータが変わるか 統合後に、何が変わってはいけないか 異常系や例外パターンで、どこに影響が出るか この理解をもとに、PdMや関係者と相談しながら統合仕様を決めていきました。 短納期とチーム構成を踏まえてAI-DLCを採用する データ統合には、バックエンドのフレームワーク上で動作するスクリプトの実装が必要でした。 一方で、変更しづらい期限がありました。 自分だけで進めるには、時間的な制約が大きい状況でした。 そこで、主担当領域の異なるメンバーと一緒に進めるため、AI-DLCを採用しました。 AI-DLC(AI-Driven Development Life Cycle) とは、AWSが提唱するAIを中心に据えた開発手法です。 要件整理、計画、設計、実装、テスト、運用まで、開発ライフサイクル全体にAIを組み込んでプロジェクトを進めます。 タイミーでは、以前AI-DLCを業務で活用するための合宿を実施しました。 3日間のUnicorn Gymが1ヶ月で組織を変えた —— データで見るAI-DLC導入の波及効果 - Timee Product Team Blog 今回は詳細設計までを、複数人でAIの出力を確認するモブワークで進めました。 実装に必要な前提と完了条件を共有した後は、主担当領域の異なるメンバーにも、非同期で実装を進めてもらいました。 AIを使うことで、バックエンドの文法や実装パターンの理解を補助できます。 ただし、仕様の正しさまでAIに任せることはできません。 そのため、前提・制約・完了条件を人間が明確にしました。 そのうえで、AIの出力をレビューできる状態を作ることを重視しました。 合意のための認知負荷を下げる このプロジェクトは影響範囲が広く、多様な関係者が関わっていました。 全員に統合仕様のすべてを理解してもらおうとすると、合意までに時間がかかります。 また、関係者がそれぞれ判断すべきポイントも異なります。 そこで、意思決定のためのADR(Architecture Decision Record)では、必要な論点を絞りました。 レビュー観点、選択肢、推奨方針、影響、リスクを整理しました。 関係者が、それぞれの判断範囲に集中できるようにするためです。 仕様を説明・相談する際にも、認知負荷を下げるためのドキュメントを用意しました。 具体的な手法はここでは割愛しますが、以下の書籍を読んだ経験が自分の中で大きな礎となっています。 『ノンデザイナーズ・デザインブック』 『プログラマー脳』 『実装パターン』 その取り組みの結果、短いリードタイムで方針への合意を得られました。 終盤の考慮漏れに向き合う 仕様が複雑だったため、終盤のテスト工程で考慮漏れが見つかりました。 また、終盤で体制変更があり、権限に関する仕様判断を自分が引き取る必要がありました。 ただし、序盤にメンタルモデルを作っていたため、どこを確認し、誰に相談し、どの制約を守るべきかを自分で判断できる状態になっていました。 追加対応は発生しました。 それでも、影響範囲を絞り、対応順を決め、関係者と優先度を確認し、期限内に実装とテストを終えることができました。 まとめ 約1か月半という短い期間かつ限られた体制で、前例のない複雑なデータ統合プロジェクトに取り組みました。 この状況に対して、次の3つを実践しました。 AIを使ってコードと業務を調査し、自分で判断できるメンタルモデルを作った AI-DLCを使い、主担当領域の異なるメンバーも実装を進められる状態を作った ドキュメントを通じて論点を絞り、関係者の認知負荷と合意形成のリードタイムを下げた 結果として、複数のメンバーで実装を分担し、期限内に実装とテストを完了できました。 一方で、AI-DLCを使っても、仕様の正しさまで保証されるわけではありません。 前提となるドメイン理解と、人間による意思決定が重要です。 AIは、単なるコード生成ツールではありません。 理解・合意・協働を加速する存在として開発プロセスに組み込むこと。 それが、AI時代のプロダクトエンジニアリングに必要な姿勢だと感じました。
iOSDC Japan 2026 にシルバースポンサーとして協賛します こんにちは!DevEnable室のshihorinです。 このたびタイミーは、 iOSDC Japan 2026 にシルバースポンサーとして協賛いたします 。 開催に向けて準備を進めてくださっている運営のみなさま、ありがとうございます! iOSDC Japan は、iOS関連技術をコアテーマにした技術者のためのカンファレンスです。 詳細は 公式サイト をご覧ください。 開催日程:2026年9月11日(金)〜9月13日(日) 会場:有明セントラルタワーホール&カンファレンス/オンライン iosdc.jp 「iOSDCチャレンジ」に参加しています 今回の開催期間中、「iOSDCチャレンジ」企画が実施されます。 会場やWeb上で「iOSDCトークン」を探していただき、見つけた数に応じて抽選券を獲得。 会場の抽選カウンターでノベルティが当たる抽選に参加できる企画です。 タイミーもこの企画に参加しています。 この記事の最後にもiOSDCチャレンジトークンを掲載していますので、ぜひご覧ください。 タイミーとiOS開発について タイミーは、「働きたい時間」と「働いてほしい時間」をマッチングするスキマバイトサービス「タイミー」を開発・運営しています。 ワーカーは働きたい仕事を選ぶだけで、履歴書・面接なしですぐに働くことができ、勤務後すぐにお金を受け取ることができます。 ワーカー向けのアプリはiOS・Androidのネイティブアプリとして開発しています。iOSエンジニアは日々の機能開発だけでなく、テスト戦略やCI/CD、開発生産性の改善にも取り組んでいます。 1,420万人のワーカー(※2026年4月末時点)に登録いただいているサービスのアプリを、限られた人数でどう安定して速く届けるか——こうした課題に向き合えるのも、iOS開発のおもしろさのひとつです。 iOSDC参加者のみなさま向け:おすすめ記事・資料 タイミーのiOSエンジニアが公開している中から、iOSDCに参加されるみなさまに楽しんでいただけそうな記事・登壇資料を紹介します。 ① 半自動E2Eで手っ取り早くリグレッションテストを効率化しよう 昨年のiOSDC Japanでの登壇資料と動画です。「完全自動化」の前に挑戦した、半自動E2Eの技術選定やプロセスを紹介しています。 ② iOSエンジニアがAIと協調してAndroidプロジェクトにコミットしてみた iOSエンジニアがAIを相棒に、あえてAndroidプロジェクトへコミットしてみた記録です。プラットフォームの壁をAIでどこまで越えられるのか、勘所やぶつかった壁が書かれています。 おわりに ここまで読んでくださり、ありがとうございました! iOSDCチャレンジトークンはこちらです👇 #iosdc2026_timee そしてタイミーでは、一緒にはたらく仲間を募集しています。 プロダクト組織の情報や募集職種は、以下のページからご覧ください。 product-recruit.timee.co.jp ご興味をお持ちいただけましたら、ぜひカジュアル面談にお申し込みください。
こんにちは。タイミーでプラットフォームエンジニアをしている菅原です。普段はAWSを中心に、インフラの設計と運用に取り組んでいます。 今回は、ランサムウェア対策として構築したバックアップと、そのバックアップが正しく機能していることを継続的に監視する仕組みを紹介します。 ランサムウェア攻撃は、データを暗号化したり盗んだりするだけでは終わりません。復旧そのものを妨害するために、既存のバックアップまで削除や改ざんの対象にするケースが増えています。この脅威に備え、私たちはAmazon Auroraクラスタのバックアップを、クロスアカウントの論理エアギャップVault(Logically Air-Gapped Vault、以下LAGV)へ継続的にコピーする仕組みを作りました。 ただ、実際に手間がかかったのはバックアップの経路を組むことよりも、それが本当に機能し続けているかを検証することでした。設定上は正しく組まれているように見えても、タグの付け忘れやジョブの失敗、Vault Policyの不備などで、気づかないうちにコピーが止まっていることがあります。 背景 クロスアカウントの論理エアギャップバックアップ 想定する脅威とアカウント分離 前提となる脅威シナリオはこうです。攻撃者はワークロードのAWSアカウントで管理者権限を奪うと、復旧を妨害するためにAWS Backupなどのバックアップ設定を無効化・改ざんし、IAMロールやユーザーの権限も書き換えて正規の管理者による復旧作業を封じます。そのうえでデータを外部に持ち出したのち、DB本体と既存バックアップ(スナップショット)を削除する、というのが想定する動きです。 この脅威に対応するため、バックアップに関わるアカウントは役割ごとに分離しています。なお、このアカウント分離は、AWS Storage Blog「Building cyber resiliency with AWS Backup logically air-gapped vault」で紹介されているData Bunker、Forensics、Recovery Accountの分離パターンを参考にしています。 アカウント種別 役割 アクセス Backup Admin AWS Backupのポリシー管理、Organization全体のバックアップ状況の監視 管理者による日常的なアクセスを許可(ワークロードアカウントと同等のセキュリティレベル) Data Bunker 論理エアギャップVaultを保有し、バックアップデータを保管 管理者であっても日常的なアクセスは禁止 Forensics Data Bunkerに保管されたバックアップを定期的にリストアし、復旧の整合性をテスト 管理者であっても日常的なアクセスは禁止 Workload Recovery インシデント時の復旧先となるクリーンな環境 インシデント発生時のみ、Data Bunkerから共有された復旧ポイントを参照 バックアップの実体を持つData Bunkerアカウントには、平常時は誰もログインさせず、ワークロードアカウントとのネットワーク疎通もありません。加えてAWS Backupの論理エアギャップVaultは仕様上コンプライアンスモードが強制され、ルートユーザーであっても保持期間中は復旧ポイントもVault自体も削除できません。この多層の防御によって、攻撃者がワークロードアカウントの管理者権限を握ってもバックアップを壊すことが困難な構成になっています。 アカウント構成 暗号化キーの種類とコピー経路 論理エアギャップVaultにコピーできるリソースには仕様上の制約があります。AWS Backupのドキュメントによれば、「フル AWS Backup 管理」をサポートしていないリソースタイプは、AWS管理のKMSキーで暗号化されている場合、論理エアギャップVaultへのコピーがサポートされません。カスタマーマネージドキーで暗号化されているか、暗号化されていないことが条件になります(参考: Copying backups to a logically air-gapped vault )。Auroraはこのフル AWS Backup 管理非サポートのリソースタイプに該当するため、サービスマネージドキーのままでは直接コピーできません。また、Auroraでは既存クラスタの暗号化キーを直接変更できず、異なるキーを使用する場合は新しいクラスタへの移行が必要です。 そこで、既存クラスタの構成変更を前提とせず、キー種別にかかわらずバックアップを論理エアギャップVaultへ保管できるよう、2つのコピー経路を用意しました。カスタマーマネージドキーであれば、定期実行されるバックアッププランでData Bunkerアカウントの論理エアギャップVaultへ直接コピーします。サービスマネージドキーの場合は、まず中間Vault(カスタマーマネージドキーを設定した通常のVault)にコピーして暗号化キーを変換し、そこから論理エアギャップVaultへコピーする形です。中間Vaultへのコピー完了イベントをトリガーに、Step Functionsが論理エアギャップVaultへのコピーを実行します。なお、この中間Vaultを介して暗号化キーを変換し、クロスアカウントコピーを行う構成は、AWS Storage Blog「Protecting encrypted Amazon RDS instances with cross-account and cross-Region backups」で紹介されている方式を参考にしています。 サービスマネージドキーの場合のバックアップ経路 タグでバックアップ対象を制御する 各ワークロードアカウントに個別のバックアップ設定を持たせると設定漏れのリスクが上がるため、AWS Backupのバックアップポリシー機能でBackup Adminアカウントから組織標準のバックアッププランを配布しています。エンジニアがやることは、Auroraクラスタにバックアップ有効化と暗号化キー種別を示すタグを付けるだけです。ここでは例示名で記載します。 タグキー 設定値 説明 BackupEnabled true false 組織標準バックアップを有効化するか KeyType aws-managed customer-managed 前述のコピー経路を切り替えるためのキー種別 このオプトイン方式には、タグの付け忘れがそのままバックアップ対象外につながるという副作用があります。これを防ぐため、AWS Organizationsのタグポリシーで許可されない値を検出できるようにし、Terraform AWS Providerとも統合して、plan実行時に必須タグの欠落をエラーとして検出できるようにしました。 背景の説明はここまでにして、ここからが本題の監視です。 バックアップが取れているはずを信じない 上のアーキテクチャを組んだだけでは、気づかないうちにバックアップが取れなくなっているリスクはまだ残ります。タグの付け忘れや意図しないタグ変更でバックアップ対象から外れることもあれば、バックアップジョブ自体が失敗することもあります。中間Vaultから論理エアギャップVaultへのコピーが、Step Functions経由で失敗することもあります。また、Data Bunker側のVault Policyに不備があり、Organizationからの CopyIntoBackupVault が許可されていないケースもあります。 どれも、バックアッププランは設定されているのに論理エアギャップVaultには実際のデータが届いていない状態を引き起こします。設定が存在することではなく、コピーが実際に完了したという事実を継続的に検証する必要があります。 なぜAWS標準のコントロールではなく自作したのか 論理エアギャップVaultにリソースが入っているかを確認する仕組みは、実はAWSの標準機能にも存在します。AWS Backup Audit Managerのフレームワークで「リソースは論理的に隔離された保管庫の中にある」というコントロールを有効にすると、内部的にAWS ConfigのマネージドルールAURORA-RESOURCES_IN_LOGICALLY_AIR_GAPPED_VAULTが作成されます。これにより自動でチェックできます。 まずはこの標準機能で要件を満たせないか検討し、クロスアカウントのコピー構成でも正しく評価できるかをAWSサポートに問い合わせましたが、クロスアカウントで復旧ポイントをコピーした場合、現時点ではこのコントロールではチェックできないことがわかりました。 つまりこのマネージドルールは、同一アカウント内でのVaultへのコピーは評価できます。一方で、私たちのようにコピー先の論理エアギャップVaultが別アカウント(Data Bunker)にあるクロスアカウント構成では、正しくコピーが完了していても常に非準拠として扱われます。責務分離のためにアカウントを分けたこと自体が、標準コントロールの前提と噛み合いませんでした。 この制約で標準のマネージドルールを使う選択肢は消え、クロスアカウントのコピージョブの実行結果を直接確認するカスタムルールを自作する方針に切り替えました。 監視の仕組み AWS Configカスタムルール AWS ConfigのカスタムルールとしてLambda関数を実装し、各アカウント内のAuroraクラスタについて、一定期間内に論理エアギャップVaultへのコピーが完了した実績があるかを評価しています。評価結果は3種類で、期間内にLAGVへのコピー完了を確認できれば COMPLIANT 、確認できなければ NON_COMPLIANT 、対象のAuroraクラスタが存在しなければ NOT_APPLICABLE になります。 Lambdaの処理は大きく4ステップです。 AWS Configから定期評価イベントを受け取り、ルールパラメータを読み取ります。 Auroraクラスタ一覧を取得し、必要に応じてタグでフィルタをかけます。 各クラスタについて、評価対象期間内にLAGVへのコピー完了実績があるかを確認します。 最後に結果をAWS Configに報告します。 必要なIAM権限は、Auroraクラスタとバックアップジョブの参照、およびAWS Configへの評価結果登録に限定しています。 この監視をどう組織全体に展開するか この監視は1つのLambda関数とConfig Ruleで構成していますが、対象となる全ワークロードアカウントに1つずつデプロイして回るのは現実的ではありません。今後アカウントが増えるたびに設定を追加する運用も避けたいところです。 そこでCloudFormation StackSetsを使い、対象OU配下の全アカウントに自動で配布する構成にしました。 工夫したところは3つあります。 1つ目はLambdaのデプロイ管理をCFn StackSets委任管理者アカウントに集約したことです。CFn StackSetsはOrganizationsの管理アカウントではなく委任管理者アカウントからスタックセットを管理できます。バックアップリソース自体はBackup AdminとData Bunkerのアカウントに閉じていますが、全アカウントにLambdaをばらまくという関心事は別の委任管理者に持たせ、責務を分けました。 2つ目はLambdaコードとインフラのリポジトリを分けつつ、S3のバージョンIDでつないだことです。Lambdaの実装は通常のアプリケーションと同じくアプリケーションリポジトリで管理し、GitHub ActionsからOIDCでAssumeRoleしてビルド成果物をS3にアップロードします。StackSetの定義はTerraformリポジトリ側で管理し、両者をつなぐためにTerraformからS3オブジェクトの version_id を参照して、それをStackSetのCloudFormationパラメータとして渡します。Lambdaコードの更新はS3へのアップロードだけで完結し、インフラ側は参照するバージョンを更新してapplyするだけで全アカウントへの再配布が終わります。アプリケーションのデプロイフローとインフラのデプロイフローを混ぜずに済む構成です。S3バケットはバージョニングを有効化したうえで、 noncurrent_version_expiration で古いバージョンを一定期間後に自動削除し、オブジェクトが無制限に溜まらないようにもしています。 3つ目はクロスアカウントの成果物配布をOrganization ID条件で絞ったことです。各メンバーアカウントのLambdaが実行時にS3から成果物を取得できるよう、バケットポリシーで組織内アカウントからの s3:GetObject を許可しています。 aws:PrincipalOrgID 条件を使い、組織に所属するプリンシパルだけにアクセスを絞ることで、意図しない外部アカウントからの参照を防いでいます。 検知後の運用フロー 監視の仕組みだけ作っても、検知後にどう動くかが決まっていなければ意味がありません。 NON_COMPLIANT が検出された場合の一次切り分け手順を運用ガイドラインとして整備しました。 日常的な状態確認はダッシュボードで行い、加えて全アカウントの評価結果はSecurity Hub CSPMを介して委任管理者アカウントに集約しています。このカスタムルールでの新規違反検出にフィルタする Security Hubのインサイト を作成することで、対応が必要な検出結果が残っているアカウントを一覧できるようにしています。これにより、個別のワークロードアカウントを一つずつ見て回らなくても、組織全体のバックアップの健全性を俯瞰できます。 制約と今後の展望 今の監視は、定期監査として組織全体の状態を継続的に確認する役割を担っています。 より即時性が必要な場面では、ジョブ状態変更イベントをEventBridgeで拾い、失敗時にアラートを上げる仕組みを別途組み合わせることを考えています。定期監査は取りこぼしなく全体を俯瞰する役割、EventBridge側は異常をすぐ知らせる役割と、分けて考えています。 おわりに クロスアカウントの論理エアギャップVaultは、アカウントを分けて壊せないバックアップを組むところまでは、比較的素直に設計できます。難しいのは、その仕組みが実際に動き続けていることをどう証明し続けるかです。今回のプロジェクトも、試行錯誤を重ねるなかであらためてそのことを実感しました。 参考文献 AWS Backupユーザーガイド, Copying backups to a logically air-gapped vault AWS Storage Blog, Building cyber resiliency with AWS Backup logically air-gapped vault AWS Storage Blog, Protecting encrypted Amazon RDS instances with cross-account and cross-Region backups
はじめに 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エンジニア 募集ポジション一覧