TECH PLAY

タイミー

タイミー の技術ブログ

299

こんにちは、タイミーでバックエンドエンジニアをしている 福井 (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 による切り替え後に指定すべきファイルとポジションがわからなくなってしまう」という問題でした。 解決した切り替え手順 そこで、考え方を変えて 「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の公式ブログ に書かれているため、ここでは説明を割愛します。 これらの手順により、安全にGTID方式のレプリケーション接続に切り替えを行うことができます。 まとめ 今回は、Aurora MySQL の GTID モード有効化方法と、Datastreamを安全に切り替えるための方法を紹介しました。 今回の移行が完了してもゴールではなく、この先には gtid-mode = ON への引き上げ(匿名トランザクションの完全な消化、 enforce_gtid_consistency = ON 化)が続きます。 また徳富さん( @yannKazu1 ) さんが 並行してDatastream 関連のネットワーク周りのリアーキテクチャも行なっております。 詳細は以下の資料をご覧ください。 tcpdump で追う Datastream 障害調査と Transit Gateway × VPN のリアーキテクチャ設計 もし、今回の自分と同じように Aurora MySQL の GTID 化を検討している方にとって、この記事が何らかの参考になれば幸いです。 参考リンク MySQL :: MySQL 8.0 リファレンスマニュアル :: 17.1.6.5 グローバルトランザクション ID システム変数 MySQL :: MySQL 8.0 リファレンスマニュアル :: 19.1.4 オンラインサーバーでの GTID モードの変更 MySQL データベースからデータをストリーミングする | Datastream | Google Cloud Documentation
はじめに こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。 自分が所属しているチームでは、週一で「観測会」を実施しています。 サービスの負荷状況が分かるダッシュボードを確認したところ、ある時期から Aurora MySQL の Reader CPU 使用率が大きく上昇していることに気づきました。 原因になりそうな処理は見えてきましたが、関連する機能はすでに利用されていたため、単純にリバートできる状況ではありませんでした。そのため、インスタンスを追加して一時的にしのぎつつ、根本的な改善を進める必要がありました。 この記事では Reader CPU 急増への対応を題材に、Platform Team が Datadog で事実を収集し、Stream-aligned Team(機能開発チーム)と協力して性能改善を進めた流れを紹介します。 1. Reader CPU が急増し、単純なリバートでは解決できなかった ある時期から、Aurora MySQL の Reader CPU 使用率が通常時よりも上昇しました。 まずはサービスへの影響を避けるため、 Reader インスタンスをスケールアップし、必要に応じて追加する暫定対応を行いました。 ただし、これはあくまで暫定対応です。この状態が長期化するとコスト面で健全ではないため、並行して根本原因の特定に着手しました。 最終的にポイントだったのは、原因である機能を簡単に止められる状況ではなかったことです。その機能はすでに利用されており、利用増加に伴って、もともと非効率だった処理が顕在化した形でした。 そのため、機能として必要な振る舞いを保ちながら、処理をどう改善できるかを見極める必要がありました。 2. Datadog Notebook で事実をまとめ、温度感を揃えた 最初に行ったのは、Datadog Notebook に事象をまとめることでした。 CPU が高いこと、重そうなクエリがあること、暫定対応した時期、関係していそうな処理といった情報が Slack 上に散らばったままだと、認識が揃いません。 特に、「どれくらい危ない状況なのか」「暫定対応の結果どうなったのか」「恒久対応をどう進めるべきか」「どのチームに何を相談したいのか」を一箇所にまとめないと、適切な温度感が伝わりづらくなります。 そこで Datadog Notebook に、以下のような情報を集約しました。 観点 Notebook で整理した内容 添付したウィジェット・リンク 起きていること Reader CPU が通常時より大きく上昇し、いつ障害になってもおかしくない水準まで到達していた Reader CPU の推移が分かるグラフ 負荷の変化 特定クエリの実行頻度が大きく増加し、1回あたりの実行時間や走査行数も高い状態だった クエリ実行頻度、実行時間、走査行数のグラフ 暫定対応 サービス影響を避けるため、Reader インスタンスを追加して一時しのぎしていた 対応時刻やインスタンス追加・入れ替えの時系列 原因の仮説 ある機能の作成件数増加と、クエリ自体の構造的な重さが重なって Reader CPU に影響していそうだった 作成件数増加の時系列、DBM の Query Signature へのリンク 問題の分解 負荷に寄与しているクエリは複数あり、求人作成・更新時に走るものと、毎時の定期バッチで走るものに分けて確認した Query Signature ごとの DBM リンク、APM のトレースリンク 改善方針 一方はクエリ自体の書き換えが必要で、もう一方は既存の結果テーブルを参照する形に変えられる可能性があった 発行元の処理名、トレース、DBM / APM のリンク 相談したい判断事項 既存の結果テーブルを使うと対象者の範囲が変わる可能性があるため、機能仕様として許容できるかを Stream-aligned Team に確認したかった 判断に必要な調査メモと、根拠となる DBM / APM のリンク このとき意識したのは、単にダッシュボードのようにグラフを列挙するのではなく、 関係者が判断できる形に情報を並べること です。 CPU 使用率や重いクエリのメトリクスだけでは、「いま何が起きているのか」は分かっても、「どれくらい急ぐべきか」「誰に何を相談したいのか」「暫定対応で耐えられるのか」は伝わりません。 また、原因仮説は Platform Team 側で、Datadog から見えた処理名や実行タイミングを手がかりに、関連する変更履歴や実装を確認しながら立てていきました。その際はAI も活用し、どの機能・処理と関連していそうか当たりをつけました。 3. DBM / APM で重い Query Signature と呼び出し元を特定した Reader CPU の上昇など、データベースのリソース使用状況の悪化は Metrics で把握できます。しかし、それだけでは何を直せばよいかは分かりません。 そこで次に、どのクエリが Reader に負荷をかけているのかを調べました。 mysql.queries.time と query_signature ここで見たのが、Datadog DBM の mysql.queries.time メトリクスと、そのラベルである query_signature です。 mysql.queries.time は、正規化されたクエリごとの実行時間を表すメトリクスです。ざっくり言うと、 1回あたりの実行時間 × 実行回数 に近い値として見ることができます。 query_signature は、SQL の具体的な値を取り除いて正規化したクエリの識別子です。条件に入る ID や日時だけが異なる SQL を、同じ種類のクエリとしてまとめて確認できます。 ただし、これは直接的に「クエリ単体の性能」だけを表すものではありません。1回あたりは速いクエリでも、実行回数が急増すれば mysql.queries.time は増えます。 逆に、1回あたりが遅いクエリでも、ほとんど実行されなければ全体負荷への寄与は小さく見えます。 一方で、今回のように「Reader CPU が上がっている」という事実に対して、「どの種類のクエリが寄与していそうか」を特定するには、非常に有用なメトリクスです。 調査は、次の流れで進めました。 Metrics で Reader CPU の上昇タイミングを見る mysql.queries.time から相関関係のある query_signature を見る Count / AVG Duration / Rows Scanned を見て、実行回数と1回あたりの重さを確認する DBMのUpstreamからAPMをたどり、そのクエリがどの処理から呼ばれているのかを確認する この調査により、クエリの種類と、その呼び出し元の処理を特定できました。 具体的には、ある機能に関連する非同期処理から発行されるクエリが増加していました。対象データ量の増加に伴って、複数の条件を組み合わせた抽出処理が重くなり、Reader 負荷に大きく寄与している状態でした。 4. Datadog だけでは分からないことを Stream-aligned Team と確認した どのクエリが重いか、いつ実行されているか、どの処理から呼ばれているかは分かりました。 しかし、Datadog だけでは以下は分かりません。 その処理は何の機能のために存在しているのか 抽出対象のデータは、機能上どのような意味を持つのか 他のデータやキャッシュされた結果が使えるのか 機能利用がなぜ増えているのか 最終手段として、機能制限や一時停止が取り得るのか ここで、機能開発を担当する Stream-aligned Team のドメイン知識が必要になりました。 Platform Team 側では Datadog を見ながら負荷の原因を整理し、Stream-aligned Team 側では仕様や処理の中身を確認しました。Slack やハドル、Datadog Notebook で状況を共有しながら、「どの処理が負荷に寄与しているのか」「仕様を壊さず処理を変えられるのか」「もし改善しない場合に、停止などの措置は取り得るのか」を相談しました。 今回重要だったのは、技術的な事実だけでなく、「この状況をどれくらい危険と見ているか」を共有することでした。 Platform Team では、今回の CPU 負荷上昇に対して暫定対応として Reader インスタンスのスケールアップと追加を行いました。 この対応により CPU 使用率を一定以下に抑えることができたため、サービス影響を抑えることはできました。 しかし、依然として以下の問題を抱えていました。 CPU負荷上昇の原因となっているクエリ発行元の機能の利用者は増加傾向にあり、サービス影響が生じる可能性がある。 スケールアップ対応により、インスタンス使用量のコストが対応前より増加している。もともと想定していたDBにかかる費用を超過しているため、コストを抑えたい。 利用増は外部要因の影響もあり、こちらで直接コントロールしづらい状況でした。この背景を踏まえ、機能開発を担当していたチームに、数日で直す必要があることを伝えました。 一方で、機能を担当するチームから見ると、単に「このクエリが重い」と言われても、それがどれくらい急ぎなのか、すぐ直すべきなのか、仕様変更や一時停止まで検討すべきなのかは判断しづらいはずです。 このときの会話は、観点ごとに整理すると以下のようになります。 観点 Platform Team が確認したこと Stream-aligned Team と確認したこと 認識合わせ Notebook にまとめた経緯に認識齟齬がないか 認識齟齬がなく正しそうであること 背景・要因 負荷増加がどの処理と関連していそうか 機能利用の増加が関係ありそうなこと 改善方針 重い処理を改善できないか クエリの修正が可能そうであること ワーストケース 機能制限や一時停止を最終手段として取り得るか 事業部との調整が必要そうであること この会話によって、単なる技術調査ではなく、障害リスク・コスト・仕様影響・事業影響について議論できるようになりました。 最終的には、Stream-aligned Team が仕様上の判断をしたうえで、重い抽出処理を避ける方針やクエリ自体の改善を進めてくれました。 改善前は、複数の条件に該当する対象者を SQL 側で一度に抽出していました。その結果、同じ対象集合を使うサブクエリが複数回展開されたり、複雑な条件が組み合わさったりしていました。対象データ量や実行頻度が増えるにつれて、この構造が Reader に大きな負荷をかける要因になっていました。 改善後は、仕様の互換性を保ちながら、処理をいくつかの小さな取得に分け、アプリケーション側で結果を統合する形に変更しました。これにより、SQL 側で複雑な条件を一度に処理させる必要がなくなり、Reader にかかる負荷を抑えられるようになりました。 5. 改善後も Datadog で効果を確認した クエリを修正すると、Datadog DBM 上の Query Signature が変わります。 そのため、改善前後を見るときに、単純に同じ Query Signature の before / after だけを見ても判断できません。今回も、修正後に対象クエリが分割され、新しい Query Signature が増えました。 そこで、以下の観点で改善効果を確認しました。 変更前の重い Query Signature が減っているか 変更後に増えた Query Signature の AVG Duration は許容範囲か Total Duration は減ったか 対象となる処理の実行時間は改善したか Reader CPU 使用率に変化があったか 結果として、修正前に時間がかかっていたケースが、修正後は主要なクエリで大きく改善していることを確認できました。また、翌日に改めてメトリクスを確認すると、デプロイ以降で Reader CPU にも改善傾向が見られました。 一方で、Reader 全体の実行クエリ数も増えていたため、残る負荷はクエリ単体の問題ではなく、ワークロードの増加として切り分けました。ここまで判断できると、次はアプリケーション改善ではなく、キャパシティやコストの議論として扱えます。 6. まとめ 今回の対応で重要だったのは、重いクエリを見つけることだけではありませんでした。 本番サービスでは負荷の原因になっている機能は単純に止められないことがあります。だからこそ、Datadog DBM / APM / Metrics で負荷を分解し、Datadog Notebook に時系列・メトリクス・仮説・暫定対応・相談したい判断事項をまとめることで、関係者が同じ事実を見ながら会話できる状態を作りました。 そのうえで、Platform Team が整理した事実やリスク、温度感を共有し、受け取った Stream-aligned Team は機能仕様・実装方針・事業影響を踏まえて改善を進めてくれました。 性能課題は、重いクエリを見つければ終わるものではありません。どの負荷が危険で、どの判断が必要で、誰のドメイン知識が必要なのかを整理し、事実を整理して進めることが重要です。 タイミーではこのように、Platform Team と Stream-aligned Team が協力しながら、サービスの成長に伴って生まれる性能課題に向き合っています。
はじめに こんにちは。プラットフォームエンジニアリングチームに所属している徳富( @yannKazu1 )です。 「インスタンスサイズを上げたらコストが下がりました」と言うと、だいたい「?」という顔をされます。スペック上げたらお金かかるに決まってるだろ、と。自分もそう思っていたので気持ちはわかります。 この記事では、Amazon Aurora のReaderインスタンスを db.r7g.8xlarge から db.r7g.12xlarge にスケールアップした結果、I/Oコストが大幅に減り、トータルのAuroraコストがむしろ下がった話を書きます。バッファプールの仕組みと、判断の経緯もあわせて紹介します。 前提:私たちのAurora構成 まず、当時のAurora(MySQL互換)クラスターの構成を簡単に紹介します。 インスタンス クラス vCPU メモリ プロセッサ 役割 reader-1 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader reader-2 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader reader-3 db.r7g.8xlarge 32 256 GiB Graviton3 API Reader writer-candidate-1 db.r5.24xlarge 96 768 GiB Intel Xeon Writer Candidate / Reader writer-candidate-2 db.r5.24xlarge 96 768 GiB Intel Xeon Writer Readerは3台構成で、3つのAZに分散配置しています。加えて、writer-candidate-1もWriter Candidateとしてフェイルオーバーに備えつつ、Readerとして読み取りクエリも処理しています。つまり、読み取りは実質4台で分散している構成です。 バッファプールとは何か 本題に入る前に、今回のキーワードである「バッファプール」について少し説明させてください。 MySQLやAurora(MySQL互換)には InnoDB バッファプール と呼ばれるメモリ領域があります。これはデータベースが頻繁にアクセスするデータやインデックスをメモリ上にキャッシュしておく仕組みです。 データベースがクエリを処理するとき、必要なデータがバッファプールに載っていれば、メモリから直接読み取れるので非常に高速です。一方、バッファプールに載っていないデータが必要になった場合は、ストレージ(ディスク)からデータを読み込む必要があります。これが ストレージI/O です。 ここで重要なのが バッファプールヒット率 という指標です。これは「データベースが必要としたデータのうち、バッファプールから取得できた割合」を示します。 ヒット率100% :すべてのデータがメモリ上にあり、ディスクへのアクセスが発生しない理想的な状態 ヒット率99.9% :一見ほぼ完璧に見えますが、0.1%のミスが積み重なると、秒間数千〜数万回のI/Oリクエストに化けることがあります Aurora ではバッファプールのサイズはデフォルトでインスタンスメモリの75%が割り当てられます。つまり、256 GiBのメモリを持つ db.r7g.8xlarge では、約192 GiBがバッファプールとして使われる計算です。 そしてAuroraの料金体系において見逃せないのが、 I/Oコストは従量課金 であるという点です。ストレージへの読み取りリクエスト(Read IOPS)が増えれば増えるほど、課金額も増える。つまりバッファプールヒット率の僅かな低下が、じわじわとコストに効いてくるわけです。 異変に気づく:「たった0.1%」の落とし穴 事の発端は、以前から認知しつつも対応の優先度を上げきれていなかった、バッファプールヒット率の数字でした。 Readerインスタンスのバッファプールヒット率は、もともと99.9%前後で推移していて、100%には達していませんでした。「99.9%ならほぼ問題ないのでは?」という感覚で、それまで明確な対策は打てていなかった、というのが正直なところです。 しかし、弊社のサービスは非常にアクセス数が多く、それに伴うクエリの総量も膨大です。母数が大きいと、たった0.1%のバッファプールミスでも、ストレージへの問い合わせ回数は凄まじい量になります。 実際にCloudWatchで AuroraStorageReadIOsPS (Aurora ストレージへの秒間読み取りI/O数)を確認すると、reader-1では最大約13,000 IOPS、平均でも約7,488 IOPSに達していました。 そしてこの数字は 1インスタンスあたり の値です。同じスペックのReaderが3台あるため、クラスター全体では単純計算で最大約39,000 IOPS、平均でも約22,000 IOPSものストレージ読み取りが発生していたことになります。AuroraのI/Oコストは従量課金なので、この3台分のI/Oがそのままコストに跳ね返っていました。 「パラメータをいじる」という選択肢はなかったのか 「バッファプールを大きくしたいなら、 innodb_buffer_pool_size パラメータを変更すればいいのでは?」という発想は当然あります。 たしかに、Auroraのパラメータグループからバッファプールサイズを変更すること自体は可能です。デフォルトではインスタンスメモリの75%が割り当てられていますが、これを80%や85%に引き上げれば、インスタンスサイズを変えずにバッファプールを拡大できます。 しかし、この方法にはリスクがあります。バッファプール以外にも、MySQLの内部処理やOS、各種バックグラウンドプロセスがメモリを使用しています。バッファプールの比率を引き上げすぎると、これらに必要なメモリが不足し、最悪の場合OOM(Out of Memory)でインスタンスがクラッシュする可能性があります。 本番環境のReaderで「メモリ配分を攻めた結果、突然落ちました」では笑えません。デフォルトの75%という設定は、こうしたリスクを考慮した上での安全なバランスであり、ここを変更するのは慎重にならざるを得ませんでした。 背中を押した「CPU 100%」 パラメータ変更は避けたいが、状況は悪化していく——そんな中、事態は急変しました。 もともとパフォーマンスの良くないクエリを使う機能が存在していたのですが、それまではあまり利用されていませんでした。しかし、ビジネスサイドの施策推進により、その機能の利用が急増。約2週間のうちにDBのCPU使用率が急上昇し、ついにCPU 100%に張り付く場面が出てきたのです。 ここに至って、インスタンスサイズの引き上げを決断しました。 db.r7g.8xlarge (256 GiB)から db.r7g.12xlarge (384 GiB)への変更です。 メモリが256 GiBから384 GiBに増えることで、バッファプールもデフォルトの75%換算で約192 GiBから約288 GiBへと拡大されます。vCPUも32から48に増えるため、純粋なCPU処理能力の向上も期待できます。 当然ながら、インスタンス単価は上がるので、コスト増は覚悟の上での判断でした。 予想外の結果:コストが「減った」 12xlargeへの変更後、メトリクスの変化は劇的でした。 まず、バッファプールヒット率がほぼ100%に回復しました。これは期待通りの結果です。バッファプールの容量が増えたことで、これまでキャッシュに載りきらなかったデータもメモリ上に保持できるようになったためです。 それに伴い、 AuroraStorageReadIOsPS が急激に減少しました。バッファプールから直接データを返せるようになったため、ストレージへの読み取りリクエストがほとんど発生しなくなったのです。 そしてここからが本題です。具体的なコストの数字を見てみましょう。 まず、インスタンスの日額単価の比較です。 インスタンスクラス 日額単価 db.r7g.8xlarge(3台分) $383.33 db.r7g.12xlarge(3台分) $574.92 Reader 3台合計で見ると、インスタンス料金は約$192/日増加します。普通に考えれば「やっぱり高くなるじゃないか」という話です。 しかし、スケールアップ前のI/Oコスト( APN1-Aurora:StorageIOUsage )を見ると、 毎日$350〜400ほど が発生していました。3台のReaderがそれぞれ秒間数千IOPSものストレージ読み取りを行っていた結果、I/Oの従量課金だけでこれだけの金額が積み上がっていたのです。 12xlargeへの変更後、このI/Oコストは約$80/日まで激減しました。以下のAuroraクラスター全体のコスト推移グラフを見ると、変化が一目瞭然です。 6月15日前後を境に、紫色(db.r7g.8xlarge)がオレンジ(db.r7g.12xlarge)に置き換わると同時に、それまで毎日存在していた赤色の帯——StorageIOUsageがほぼ消滅しています。インスタンス単価の上昇分よりも、I/Oコストの削減幅の方が大きかったため、結果として Auroraのトータルコストはスケールアップ前よりも下がりました 。 「スペックを上げたのにコストが下がる」という一見矛盾した結果ですが、Auroraの料金構造を考えれば理にかなっています。Auroraのコストは大きく分けて「インスタンス料金」と「I/O料金」で構成されており、I/Oが大量に発生している状態では、I/O料金がインスタンス料金を圧迫するほど膨れ上がることがあります。インスタンスサイズの引き上げでバッファプールを拡大し、I/Oを削減することで、増えたインスタンス料金以上のI/Oコスト削減が実現したというわけです。 その後の話:クエリチューニングとさらなるコスト削減の可能性 インスタンスサイズの引き上げで急場を凌いだ一方で、根本原因であるパフォーマンスの悪いクエリについても手を打ちました。DBに悪影響が出始めてから2〜3日でチューニングを実施し、現在はCPU使用率も安定した状態になっています。 こうなると面白いのが、インスタンスサイズを上げたことに加えてクエリも改善されたため、リソースに余裕が生まれているという点です。現在の負荷状況を見ると、Readerの台数を減らせる可能性すら出てきています。 整理すると、こんな流れになります。 インスタンスサイズを8xlargeから12xlargeに上げたことでI/Oコストが大幅に削減され、サイズアップ前よりもトータルコストが減少しました。さらにクエリチューニングによってCPU負荷も安定し、もしReaderの台数削減が実現すれば、インスタンス料金そのものもさらに圧縮できることになります。 台数削減についてはまだ検討段階ですが、「CPU 100%で焦っていたあの頃」から考えると、コスト削減とパフォーマンス改善の両方が実現しつつある今の状況は、結果的に良い方向に転がったと言えそうです。 なお、コスト推移のグラフを見て「なぜオンデマンドで使っているのか」と気になった方もいるかもしれません。現在、Readerインスタンスのr8g系への移行を予定しており、移行が完了したタイミングでReserved Instancesを購入する計画です。インスタンスファミリーが変わる前にRIを買ってしまうと無駄になるため、あえて今はオンデマンドのままにしています。 学んだこと 今回の経験から得られた教訓をいくつか共有します。 バッファプールヒット率の「0.1%」を甘く見ない。 99.9%と100%の差は数字の上では僅かですが、大量のリクエストを処理するシステムでは、この0.1%が秒間数千回のストレージI/Oに化けます。ヒット率が100%から僅かでも低下し始めたら、それはバッファプールの容量が限界に近づいているサインです。 コスト最適化は「安いインスタンスを選ぶ」だけではない。 クラウドの料金体系は複合的です。インスタンス料金だけでなく、I/O料金、ネットワーク転送量、ストレージ料金など、複数の要素が絡み合っています。ひとつの要素を抑えることに固執すると、別の要素が膨れ上がるケースがあります。今回のように、インスタンスコストを「上げる」ことがトータルコストの「削減」につながることもあるのです。 パラメータ変更は慎重に。 バッファプールサイズの拡大はパラメータ変更でも可能ですが、本番環境でデフォルトから逸脱した設定を入れるのは、想定外のOOMなど別のリスクを抱えることになります。インスタンスサイズの変更であればデフォルトのバランスを保ったまま全体的なリソースを増やせるため、より安全なアプローチだと考えています。 おわりに 今回は「インスタンスサイズを上げたらコストが下がった」という、やや直感に反する事例を紹介しました。 振り返ってみると、バッファプールヒット率の僅かな変化に気づき、その背景にあるI/Oコストの構造を理解していたからこそ、適切な判断ができたと思います。ただ漫然と「CPUが高いからスペックを上げよう」ではなく、メトリクスを観察し、原因を分析し、コスト構造を踏まえた上で意思決定する。地味ですが、こうした積み重ねがインフラ運用の質を上げていくのだと改めて感じました。 同じようにAuroraのI/Oコストやバッファプールヒット率で悩んでいる方の参考になれば幸いです。
はじめに こんにちは、初めまして。今年3月に入社し、タイミーで Platform Engineer をしている小河原( @kgwryk28 )です。 現在、タイミーのシステムで利用しているメインのデータベース(Aurora MySQL)のバージョンアップにおける検証を進めています。 データベースのアップグレードで気になるのは、「今まで動いていたSQLが新バージョンでもそのまま動くのか」「アップグレードによって遅くなるクエリはないのか」という点です。 アップグレード後に互換性の問題や性能劣化が本番で発覚すると、影響は計り知れません。そのため、バージョンアップ範囲内の各バージョンの変更内容(changelog)を調べるだけでは不十分だと感じました。 そこで今回は、 Insight SQL Testing を使って検証を実施した際の工夫(期間の絞り込み・重複排除・結果のトリアージ)や検証手順を中心に共有したいと思います。 Insight SQL Testing とは? 「Insight SQL Testing」は、株式会社インサイトテクノロジーが提供する、 データベース移行やバージョンアップ時の SQL テストを自動化・効率化する ソフトウェアです。 移行元のデータベースで実行されていたSQLを、移行先のデータベースでも実際に使えるか検証できます。そのため、異種間の移行やバージョンアップ時に、SQLの互換性やパフォーマンスを確認できます。 実環境の SQL クエリを使用することで、本番環境に近い網羅性で「動くか/遅くならないか」を検証できます。 仕組みとしては以下のようになっています。 Insight SQL Testing を利用した検証の流れ 移行元のデータベース(ソース DB)と移行先のデータベース(ターゲット DB)の 2 つの DB を検証用に作成しておき、それぞれに対して同じクエリを実行します。 今回はデータベースのバージョンアップを行うため、ソース DB を現状の本番環境と同一のバージョンである Aurora MySQL 3.04.2(MySQL 8.0.28 相当)、ターゲット DB を移行先の Aurora MySQL 3.10.3(MySQL 8.0.42 相当)で作成します。 クエリの実行結果(成功/失敗・返ってきた値・実行時間)の突き合わせにより、バージョン間での互換性(片方の DB でだけ失敗するクエリ、結果が食い違うクエリ)、性能劣化(移行先のデータベースだけパフォーマンスが劣化するクエリ)を洗い出すことができます。 なお、入力となる実環境の SQL クエリには、元々 S3 に保存している監査用の Audit Log を使用しました。監査ログのフォーマットはInsight SQL Testingが要求するフォーマットと異なるため、作業用EC2インスタンスであらかじめ変換してから取り込みました。 検証作業の流れ 今回の検証は、おおまかに次の流れで進めました。 検証環境を構築 検証用の SQL セットを準備 Insight SQL Testing による検査(アセスメント)を実施 検査結果を元に SQL の互換性、パフォーマンス劣化クエリの抽出 検査結果を元に評価レポートの作成 検証上の課題 検証にあたって直面した課題が、 検証対象期間での全量テストは現実的に間に合わない という問題でした。 当初、検査対象の期間は 1ヶ月を想定していました。 これは、週次・月次で実行される定期バッチがあるため、クエリの網羅性を担保するためです。 実際に確認してみたところ、以下のことがわかりました。 1 日あたり約 1 億行 のクエリが流れている 1 時間分の量を流すだけでも 24 時間かかる。このペースで 1ヶ月分(約 31 億行)を全量テストすると、 約 2 年 かかる計算になる 当然これでは時間がかかり過ぎてしまいます。そのため、アセスメントの実行時間を短縮するための対策が必須でした。 SQL 互換性検査とパフォーマンス検査を分けて考える まず、 SQL 互換性検査とパフォーマンス検査を分けて実施 する方針にしました。目的が違うので、必要なクエリの範囲や確認方法も変わるからです。 SQL 互換性検査 の目的: アップグレード後の構文エラーの検出 パフォーマンス検査 の目的: アップグレード後に性能が劣化するクエリがないか確認したい データベースに対するアクセス特性による網羅性 タイミーのシステムでは、データベースに対するアクセス特性を大きく以下の 2 つに分類することができます。 常時アクセスが発生するもの。タイミーのワーカー様(アプリ利用者)・事業者様からの API 経由のデータベースアクセスや、CDC(変更データキャプチャ)によるレプリケーション接続(データ連携用)が該当 一定期間のみアクセスが発生するもの。日次・週次・月次の定期バッチジョブなど、システム内部で決められた日時に実行されるもの 「2. 一定期間のみアクセスが発生するもの」が動いている期間には、「1. 常時アクセスが発生するもの」も同時に発生します。そのため完全性は保証できないものの、一定の網羅性は確保できます。 SQL 互換性検査における重複排除による効率化 検査対象のクエリを削減する方法として、SQL パターンごとにクエリの重複排除を行う方法があります。 SQL の互換性検査に関しては、 重複排除 が効率化の鍵になります。同じパターンのクエリを何度もテストする必要はないからです。 重複排除の 3 つの設定 SQL Testing Manager の最新のバージョンでは、入力として実行する SQL(評価 SQL セット)を作成する際に、重複排除の挙動を 3 種類から選ぶことができます。 設定 重複排除 区別の仕方 しない 排除しない すべてのクエリをそのまま対象にする PI Hash 排除する リテラルを除いた SQL で区別する SQL ID 排除する SQL 文の違いで区別する 実際に検証したところ、各設定ごとに重複排除の方法が異なることが確認できました。 まず、以下のような SQL セットを準備します。 /* comment 1 */ SELECT * FROM test_table WHERE id = 1 ; /* comment 2 */ SELECT * FROM test_table WHERE id = 1 ; /* comment 3 */ SELECT * FROM test_table WHERE id = 2 ; 各重複排除モードで評価 SQL セットを作成した結果、行数は以下のようになりました。 重複排除モード 結果行数 区別の仕方 比較イメージ しない 3 すべてのクエリをそのまま対象にする - SQL ID 3 コメントも含めた SQL 文の完全一致による重複排除が行われる /* comment 1 */ SELECT * FROM test_table WHERE id = 1; PI Hash 1 コメントの有無にかかわらず、リテラル値が異なる SQL のみ区別される SELECT * FROM test_table WHERE id = ? PI Hash モードではクエリが完全にパターン化されるため、上記の入力クエリはすべて同一のものとして扱われます。一方、SQL ID での重複排除はコメントも含めたクエリ文の完全一致による比較が行われます。 まとめると以下のようになります。 SQL ID : コメントも含めた SQL 文の完全一致 で重複排除する。 同じクエリでも コメントが異なると別物 として扱われ、重複排除されない。 PI Hash : リテラル値の違い で区別しない(コメントの有無は問わない)。 例えば WHERE id = 1 と WHERE id = 2 のように リテラルだけが違う SQL は同一 として扱われる。 検証方法まとめ これらを踏まえてまとめると、以下のように検証を行うことにしました。 項目 SQL 互換性検証 パフォーマンス検証 目的 構文エラーの検出 アップグレード後の SQL のパフォーマンス劣化可能性の検出 方針 PI Hash によるクエリ構文のパターン化が行われたクエリで構文エラーの確認を行う 利用されている SQL クエリの実行比較を行い、アップグレード前後でパフォーマンスの変化を確認する 利用する重複排除モード PI Hash SQL ID テスト件数 約 31 万件 約 6700 万件 対象期間 過去 1ヶ月の SQL クエリ 全定期ジョブをカバーする最小のテスト期間(3 区間・合計 7 日間) 互換性検査(1ヶ月)が約 31 万件、性能検査(7 日間)が約 6700 万件と、期間が短い性能検査のほうが件数が多くなっています。これは、PI Hash がリテラル値まで畳んでクエリをパターン化するため件数が激減するのに対し、SQL ID はリテラル値の違いを区別するため件数が大きく残るためです。 なお、性能検査の対象期間は、定期バッチの実行タイミングを全てカバーできる最小の組み合わせを選んだ結果、3 区間・合計 7 日間となりました。 SQL IDの重複排除モードを利用するため、あらかじめ入力に使用するクエリからはコメントを除去しておき、SQLクエリに対する重複排除が行われるようにしておきます。 また、パフォーマンス検査はバッチ期間に合わせて3期間に区切り、アセスメント実行期間を高速化するため、独立した3環境を用意して並列で実行しました。 これらの対処により、テスト件数を大幅に削減できたことに加え、パフォーマンス検査は環境ごとに並列で実行したことで、元々約 2 年かかる想定だった検証作業を 5 日で完了することができました。 トリアージ方針 アセスメント(評価 SQL の実行)を実施すると、各クエリごとに「ソース DB/ターゲット DB でそれぞれどうだったか」を示す結果パターンが割り当てられます。これをもとに、次の方針で調査の要否を切り分けました。 各結果パターンの分類と、それぞれのトリアージ方針は以下のとおりです。 結果パターン 対応する検証 トリアージ方針 成功 SQL 互換性検査 問題なし ターゲット DB のみで失敗 SQL 互換性検査 エラーコードを元に原因を調査し、SQL の互換性に起因するエラーでないことを確認 ソース DB のみで失敗 SQL 互換性検査 エラーコードを元に原因を調査し、SQL の互換性に起因するエラーでないことを確認 両 DB で失敗 SQL 互換性検査 アップグレードによる差分は発生していないが、エラーコードごとの集計結果により、SQL の互換性に起因するエラーでないか原因を調査 両 DB で成功したが結果が相違 SQL 互換性検査 結果が異なる原因が検証環境起因のエラーであるか調査 両 DB で成功したがターゲット DB で性能劣化 パフォーマンス検査 ターゲット DB での実行時間が 1 秒以上、かつソース DB での実行と比較して 2 倍以上かかったクエリを抽出して原因を調査 ポイントは以下の点です。 バージョン間で挙動が変わったクエリに調査リソースを集中させることで、膨大な結果の中から本当に見るべきものを絞り込む 「両 DB で成功したがターゲット DB で性能劣化」したものは、わずかな実行時間の差まで拾うとノイズが多くなるため、「 遅くなった倍率(200% 以上) 」かつ「 絶対値としても 1 秒以上かかっている 」という条件で、影響の大きいものだけを抽出する 調査結果 調査の結果、バージョンアップ前後で結果差分が発生していることが判明しました。 PI Hash による重複排除後、EXPLAIN を除いて結果差分が検出されたクエリパターンは16件でした。 これらを調査したところ、いずれもアップグレード前後で行の順番が異なることによる差分であり、各行の内容は同一で、保存されているデータ自体に差分は発生していませんでした。 原因は主に二つありました。 ORDER BY で指定したカラムの値が同一になっている行が複数存在しており、同一の値に対する ORDER BY ... LIMIT の結果は非決定的であること EXPLAIN 結果の比較により、内部的な結合アルゴリズムとして Nested Loop Join が選択されていたクエリが、バージョンアップ後では Hash Join が選択されたことによる結果順序の不定化 このうち、クライアント上で表示される結果順序が変わってしまうなど、実際のプロダクトに問題が発生するクエリについては、該当クエリに ORDER BY を追加する等の対応を入れました。保存されているデータ自体に差分は確認されなかったため、現時点ではこれらの対応により、アップグレードに伴う影響は許容範囲内であると判断しています。 まとめ 今回はマイナーバージョンアップ(MySQL 8.0.28 相当 → 8.0.42 相当)であり、 MySQL 8.0.34 以降は bugfix のみのリリースの方針 であるため、大量の差分が発生することはありませんでした。しかし、調査結果で検出された内部的な結合アルゴリズムの変化は、各バージョンの変更内容(changelog)の調査だけでは気づきにくい部分でもあるため、今回の検証で気づくことができたのは大きな収穫でした。 膨大なクエリを現実的な時間で検証するために効いたのは、次の 4 つの工夫です。 目的で検証を分ける : 網羅性が欲しい互換性検査と、負荷状況を見たい性能検査を切り離し、それぞれに最適な対象範囲を設定 期間を賢く絞る : 定期バッチのタイミングを考慮し、「全定期ジョブをカバーする最小の 7 日間」で性能検査の対象を設計 重複排除で件数を削る : PI Hash / SQL ID の挙動の違いを理解し、目的に合わせて使い分けることで、テスト件数を大幅に削減 効率的なトリアージ : アップグレードに起因する変化に絞り込み、重要な差分に調査リソースを割く もし、今回の自分と同じようにデータベースのアップグレードを検討している方が、この記事が事前検証を行う上で何らかの参考になれば嬉しいです。
はじめに こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。 2026年6月9日〜10日にニューヨークで開催された Datadog の年次カンファレンス DASH 2026 に参加してきました。弊社からは、MLOpsエンジニアの斎藤が「 How Timee Delivers Day 1 Production Ready LLM Features 」というタイトルで登壇していました。 本記事では、Keynote の全体像、タイミーの登壇セッション、そして Fireside Chat から得たメッセージについてお届けします。 DASH 2026 の全体像 公式のKeynoteの記事 にあるように、まさに「Datadog enables teams to build better with AI」といった内容でした。AI がコードを書くスピードは劇的に速くなったが、それを安全に運用するためのループも同じスピードで回らなければ意味がない。この課題に対し、Datadog は Bits AI というAIエージェント群を中核に据えた新製品群を提示しました。 Keynote Keynote で発表された新プロダクトは、AIエージェントによってループを閉じ、開発を高速化するためのものでした。ループは大きくOps・Dev・AI Agent の3つの軸で整理できます。 全発表の詳細は Datadog 公式の記事 に網羅されているので、ここではループごとの要点に絞って紹介します。 Ops ループ (Detect → Investigate → Remediate) 従来人間が手作業で回していた検知・調査・修復のループを Bits AI で自動化する軸です。 Bits Detection (Preview)はサービストポロジーやデプロイ履歴から何が重要かを推測し、本番が赤くなりそうなときだけ発火するモニターを自動で作成・維持します。大量のフレーキーなモニターリストを置き換えるものです。 Bits Memories (Preview)は Slack でのインシデント対応やポストモーテムから運用上の教訓を学習し、将来の調査に適用します。 Bits Remediation (Preview)は根本原因に対して kubectl コマンド実行やコード修正 PR 作成まで自律的に行います。 Bits Infrastructure Operations (Preview)は OOMKilled や証明書期限切れといった日常的なインフラ問題を自動で検知・修復します。 Dev ループ (Code → Deliver → Evaluate) AI コーディングエージェントが加速する開発スピードに対して、リリースの信頼性を追いつかせる軸です。 Bits Code (GA)は Datadog が問題を検出したあらゆる場所(Error Tracking、APM、Code Security 等)から、本番テレメトリを根拠にした修正 PR を生成します。 Bits Release (Preview)は PR の意図を理解し、「新機能が動くか」「リグレッションがないか」の両面でバリデーションプランを自動生成するリリース検証エージェントです。 Bits Testing Agent (Preview)は URL や自然言語のゴール(例:「黒いサングラスを購入して」)からアプリを自律探索し、セルフヒーリングなテストスイートを生成します。 Agent Console (GA)は Copilot / Cursor / Claude Code 等のコーディングエージェントの利用状況を組織横断で可視化し、非効率なパターンの検出やコストアラートを提供します。 AI Agent ループ (Observe → Evaluate → Experiment → Ship) AI エージェント自体を本番で運用・改善するためのループです。 Agent Observability Patterns (Preview)は、本番の LLM トレースを行動パターンに自動分類します。デモでは15,000件のトレースから想定外の「coordination」パターンが $20,000 のコストを生んでいることを発見し、根本原因分析から修正・検証まで一気通貫で行っていました。 Bits Evals (Preview)はトレース・データセット・プロンプトバージョンを横断して仮説検証やプロンプト改善を自動化します。 Data Observability (GA)はデータパイプライン全体のリネージと異常検知を担います。 Infinite Cardinality Metrics (GA)は課金モデルをカーディナリティベースからメトリクス名+データボリュームベースに変更し、高カーディナリティ環境でのコスト予測可能性を大幅に向上させます。 その他 AI Guard (Limited Availability)はカスタムエージェントとコーディングエージェント双方の入出力をインターセプトし、プロンプトインジェクションやツール悪用をリアルタイムでブロックします。 Runtime Prioritization Engine (Preview)は16,000件の CVE から本番で悪用可能な9件に自動で絞り込み、 Security Analyst (GA)がセキュリティ調査を自動化します。 Journey Monitoring (Preview):Synthetics・RUM・Product Analytics を統合し、ユーザージャーニー単位で可用性・パフォーマンス・コンバージョン率を一つのビューで可視化。「CPU が高い」ではなく「その CPU 高騰でコンバージョンが落ちているか」を見る、ビジネスインパクトへの引き上げが狙い Federated Logs (Preview):Log Explorer から離れずに Databricks 等の外部ストレージを横断クエリ Bits Database Optimization :LLM 生成のクエリ最適化案をシミュレート DB で検証してから PR 化。デモでは500クエリから検証済み30件に絞り、ノイズを9割削減 タイミーの登壇:How Timee Delivers Day 1 Production Ready LLM Features dash.datadoghq.com speakerdeck.com 弊社 MLOps エンジニアの斎藤が、タイミーにおける LLM 機能のプロダクション対応と LLM Gateway の構築について発表しました。内容について要約して紹介します。 背景 タイミーでは LLM がワーカー・クライアント双方の体験を向上させる重要な要素になっています。求人票の自動生成をはじめ、多くのストリームアラインドチームが独立して LLM 機能を活用しており、MLOpsエンジニアがその基盤を支えています。 チェックリストの誕生 最初のプロダクション導入では、LLM の不安定さ(タイムアウト、レイテンシスパイク、レートリミット)を想定した設計を行い、Vertex AI をプラットフォームとして採用しました。この経験から、LLM 機能に求められるプロダクション水準を定義した Production Readiness Checklist を策定しました。一般的なプロダクションの品質基準に加え、LLM 固有のシグナル(フォールバック、モデルレイテンシ、コスト制御など)をカバーするものです。 障害による転機 求人票生成機能の導入後、同一プロバイダー起因で2つの LLM 機能が同時にダウンする障害が発生しました。チェックリスト・モニタリング・ゲートウェイはそれぞれ存在していたものの、採用するかは各チームに依存していたことが根本原因でした。 また、高いスピードで価値を届けるという当然の行動をしていた中で、3人の ML プラットフォームチームが全チームに基準を強制するのは物理的に不可能だったのです。 解決策:LLM Gateway この障害を転機に、全 LLM 呼び出しの共通エントリーポイントとして LLM Gateway を導入しました。Cloud Run 上に構築され、複数の LLM プロバイダーを抽象化しています。 ゲートウェイが提供する価値は3つです。 自由な探索:プロダクトチームがセットアップのオーバーヘッドなしにプロンプトやユースケースを試せる 可観測性:全呼び出しにチーム・機能・環境のメタデータが付与され、トレース・レイテンシ・エラー・フォールバックが一箇所で見える ガバナンス:コスト・使用量・レートリミット・安全性が自動的に強制される これにより、チェックリスト(期待値を定義)× モニタリング(コンプライアンスのエビデンス)× ゲートウェイ(パスの強制)が統一化され、すべてのチームが恩恵を得られる状態になりました。 このセッションは「成功事例ではなく、何が壊れ、何を学び、何ができるかの話」という齋藤の言葉通り、実践的な知見が詰まった内容でした。 Fireside Chat OpenAI や Vercel の幹部を招いた Fireside Chat (対談)が非常に印象的でした。それぞれの視点からエージェント時代のソフトウェア開発について語られましたが、共通するメッセージが浮かび上がってきました。 The New Shape of Engineering(Fireside Chat with Datadog CTO Alexis Lê-Quôc and OpenAI Head of Product and Platform Thibault Sottiaux ) dash.datadoghq.com OpenAI で Codex と API Enterprise を率いる Thibaut との対談では、エージェントが組織にもたらす変化が語られました。 特に印象的だったのは、可観測性の役割が根本的に変わるという点です。エージェントの生産性が人間のレビュー能力を超えるにつれ、「すべてのコードをレビューするのか?」という問いに直面します。システム開発は、コードを一行ずつレビューするのではなく、「症状と振る舞い」で監視する、つまり医者が患者を診断するようなアプローチになるというビジョンが語られました。 また、OpenAI 社内ではフォンブースを使っている人の大半が会議ではなく AI と話していたというエピソードや、Thibaut 自身の Codex の使い方がコーディングから情報の統合と組織の把握へシフトしたという話も、エージェント時代の働き方を象徴していました。 エージェントを正しく使うヒントとして、「生産性の幻想を避ける」(並行して動かしていても、本当に意味のある問題に取り組んでいるか?)と「良い状態を説明する」(同僚に期待値を説明するように、エージェントにも伝えること)が挙げられていたのも実践的でした。 Fireside Chat with Datadog CPO Yanbing Li and Vercel CPO Tom Occhino dash.datadoghq.com Vercel CPO の Tom Occhino との対談では、「意図と実装の距離がほぼゼロに縮まった」という時代認識から出発し、プロダクト開発の変革が議論されました。 特に印象的だったのは、仕事を「2つの波長」として捉える考え方です。 Long Wavelength(基盤作業):コアプラットフォームの品質・信頼性・セキュリティを高める作業。AI を使ってプロセスを加速しても、既存の検証・可観測性・レビューはすべて残る Short Wavelength(グリーンフィールド):誰も依存していない新規領域。AI をエンドツーエンドで使ってどんどんシップする 両方の組み合わせが大事であること、そして Long Wavelength 自体を短くしていくこと、つまりリリースエンジニアリングのエージェント化が次の挑戦として語られていました。 共通メッセージ 両セッションは全く別物ですが、かなり共通する点が多かったのが印象的でした。 「作ること」は安く速くなり、人間の価値は"何を・どう良くするか"の定義に移る OpenAI(Thibaut / The New Shape of Engineering):Thibaut は「良い状態を説明せよ」と語りました。実装が安くなったぶん、エージェントに何を期待するのかを伝えないと、エージェントは勝手に仮定を置いてしまう。難しいプロジェクトの期待値を同僚に説明するのと同じように、成功基準と検証方法を明示することが結果を大きく左右する。 Vercel(Tom / Fireside Chat):Tom は「意図と実装の距離がほぼゼロに縮まった」という時代背景からセッションを始めました。だからこそ PM のコア業務である成功の定義、明確な問題設定、顧客理解はなくなるどころか重要性を増す。仕様が明確であるほどコーディングエージェントの出力は良くなるため。 本番・検証・信頼は"無料ではない" OpenAI:Thibaut は、OpenAIのメインエージェントの全アクションを"元の意図"に照らして検証する Guardian(デュアルエージェント安全システム)を紹介しました。そしてAIエージェントへの信頼は一足飛びには得られない。テストとログへ惜しみなく投資し、実績を積み重ねることで漸進的に築かれていくものだと語りました。 Vercel:Tom は「ソフトウェアの構築はほぼ無料でも、本番は絶対に無料ではない」と強調しました。基盤となる作業(Long Wavelength)では、AI でプロセスを加速しても、可観測性・既存システムへの統合・人間によるレビューはすべて残る、と。 可観測性が検証の中心になる OpenAI:Thibaut は、AIエージェントの生産性が人間のレビュー能力を超えていく中で「すべてのコードをレビューするのか?」と問いを投げかけ、システムは医者が患者を診断するように「症状と振る舞い」で監視する時代になると語りました。過去に発火したアラートを分析してノイズを減らす、アラートのトリアージ自体もエージェントが担い始めています。 Vercel:Tom がユースケースで示したのは、本番から得たインサイトでフィードバックループを閉じる「AIエージェント型インフラ」でした。コアのバイタルを常時監視し、リグレッションが起きれば原因を二分探索し、修正もしくはリバートの PR を自動で出す。コードを一行ずつ追うのではなく、本番の振る舞いから全体の健全性を捉える方向に進んでいます。 まとめ DASH 2026 を通じて感じたのは、「検証、品質、安全性の評価を、個人の規律ではなく経路(path)に作り込む」という考え方が、発表全体を貫いていたことです。 例えばBits Release はリリース検証を、AI Guard はセキュリティ評価を、呼び出し経路に強制的に組み込みます。 この時代におけるプラットフォームエンジニアリングの仕事は基準そのものを作ることではなく、基準が自動で適用される経路(基盤)を作ることだと思いました。
はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannKazu1 )です。 タイミーではメインサービスのバックエンドを Rails で開発しています(Go を採用しているプロダクトもありますが、本記事では Rails を前提とします)。 突然ですが、皆さんのチームでは CI の待ち時間、気になっていませんか? 「Push した、コーヒー淹れた、戻ってきた、まだ回ってる……」みたいな経験は、開発者なら一度はあるのではないでしょうか。 本記事では、そんな状況を改善するために GitHub Actions 上のテスト実行パイプラインで取り組んだ 3 つの高速化テク を紹介します。どれも「知っていれば明日から試せる」くらいの温度感なので、気軽に読んでいただければと思います。 1. キャッシュの保存先を GitHub Cache から S3 に移行 課題: actions/cache が安定して速くない 最初にぶつかった壁が actions/cache の速度でした。 vendor/bundle (数百 MB〜1 GB 超)の save/restore でやたら時間がかかることがあり、リストアだけで数分待たされる場面がちょくちょくありました。これはセルフホストランナーに限った話ではなく、GitHub ホステッドランナーでも起きます。 実際、公式リポジトリにも Extremely slow cache on self-hosted from time to time という Issue が立っていて、セルフホスト・GitHub ホステッド問わず同様の報告が寄せられています。 さらに私たちの場合、 AWS 上のセルフホストランナー を使っているのでなおさらです。 actions/cache のバックエンドは Azure Blob Storage のため、セルフホストランナーからだとインターネット経由のアクセスになり、スループットが 約 20 MB/s まで落ちる ケースも報告されています( Actuated Blog )。突発的に遅いうえに経路も遠い——これでは安定した速度は望めません。 容量面でも、リポジトリあたり 10GB の制限があります。また、7 日間アクセスのないキャッシュは自動削除されます。その結果、ブランチが増えるとすぐに上限に達し、必要なキャッシュが消えてしまうのも地味にストレスでした。 解決策: runs-on/cache で S3 をバックエンドに そこで [runs-on/cache](https://github.com/runs-on/cache) を導入し、キャッシュの保存先を 同一リージョン(東京)の S3 バケット に切り替えました。 前述のとおり、セルフホストランナーで  actions/cache  を使うとスループットが ~20 MB/s まで落ちるケースがあります。一方  runs-on/cache  は同一リージョンの S3 を使えるため、200 MiB/s 以上 のスループットが出ます( 公式ドキュメント )。単純計算で 10 倍近い改善 です。 actions/cache とインターフェースがそのまま同じなので、 uses: を差し替えて環境変数を 1 つ足すだけで移行できました。 # .github/actions/setup-ruby-with-s3-cache/action.yml - name : Restore cache uses : runs-on/cache@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : "**/vendor/bundle" key : bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}-${{ hashFiles('Gemfile.lock') }} restore-keys : | bundle-v1-${{ runner.os }}-${{ inputs.ruby_version }}- bundle-v1-${{ runner.os }}- なぜ runs-on/cache を選んだか S3 をキャッシュバックエンドにする方法は他にもあります( tespkg/actions-cache 、 whywaita/actions-cache-s3 、自前の aws s3 cp スクリプトなど)。その中で runs-on/cache にした決め手はこのあたりです。 環境変数 1 つで切り替え : RUNS_ON_S3_BUCKET_CACHE を設定するだけで S3 バックエンドに切り替わる 自前実装が不要 : 圧縮・展開・キャッシュキーのマッチング・フォールバックなど、地味にめんどくさい部分を全部やってくれる 容量無制限 : S3 なので 10GB の制限もキャッシュの自動削除もなし キャッシュキーの設計 キャッシュキーは 3 段階のフォールバック構造にしています。 bundle-v1-Linux-3.3.6-<Gemfile.lock のハッシュ> ← 完全一致(最速) bundle-v1-Linux-3.3.6- ← Ruby バージョン一致 bundle-v1-Linux- ← OS のみ一致 完全一致しなくても、部分一致したキャッシュをリストアして bundle install すれば差分の gem だけで済みます。ゼロからインストールするより圧倒的に速いので、新しいブランチでもほぼキャッシュが効く状態を維持できます。 OIDC 認証で安全に S3 にアクセス AWS へのアクセスには OIDC 認証 を使っています。長期的なアクセスキーをシークレットに保存しなくて済むので、セキュリティ面でも安心です。 - name : Configure AWS credentials uses : aws-actions/configure-aws-credentials@v6 with : role-to-assume : arn:aws:iam::123456789012:role/your-gha-role aws-region : ap-northeast-1 2. マイグレーション結果をまるごとキャッシュ 課題: 毎回のマイグレーションが地味に重い テストジョブは毎回データベースをセットアップします。ここで問題になったのが、マイグレーション数が数百を超えてくると rails db:create db:schema:load だけで 数分かかる ということ。 「schema:load だからすぐ終わるでしょ?」と思いきや、テーブル数が多いとそうでもないんですよね。 解決策: MySQL のデータディレクトリごと S3 にキャッシュ 発想を変えて、 マイグレーション済みの MySQL データディレクトリ ( /var/lib/mysql ) をまるごと S3 にキャッシュ することにしました。要は「マイグレーション済みの DB をそのまま持ってくれば、マイグレーション自体を省略できるよね」という作戦です。 仕組みの全体像 【キャッシュの生成】 【キャッシュの利用】 master ブランチ feature ブランチ db/migrate/** 変更 テストジョブ起動 or 毎日定時 │ │ ▼ ▼ S3 からキャッシュをリストア MySQL 起動 → ./tmp/mysql_data に展開 │ │ ▼ ▼ rails db:create db:migrate MySQL 起動(データマウント済み) │ │ ▼ ▼ ./tmp/mysql_data を S3 に保存 rails db:migrate(差分のみ) │ ▼ テスト実行 キャッシュの生成: master で定期的に焼き直す master ブランチでマイグレーションファイルが変更されたとき、または毎日定時に、専用のワークフローがキャッシュを更新します。 # .github/workflows/update-migration-cache.yml on : push : branches : [ master ] paths : - 'db/migrate/**' - 'db/schema.rb' - '.github/workflows/update-migration-cache.yml' - '.github/actions/migration-hash/**' schedule : - cron : '0 2 * * *' # 毎日 UTC 2:00(JST 11:00)に実行 workflow_dispatch : # 手動実行も可能 やっていることはシンプルです。 MySQL コンテナを起動(データディレクトリを ./tmp/mysql_data にマウント) rails db:create db:migrate でフルマイグレーション実行 ./tmp/mysql_data をまるごと S3 にアップロード - name : Run database migration run : bundle exec rails db:create db:migrate - name : Save migration cache uses : runs-on/cache/save@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : ./tmp/mysql_data key : test-${{ runner.os }}-${{ runner.arch }}-mysql${{ steps.migration-hash.outputs.mysql_version }}-${{ runner.environment }}-db-migration-${{ steps.migration-hash.outputs.hash }} キャッシュキーの設計: 何をキーに含めるかが大事 キャッシュキーには地味に気を使っています。 test-Linux-X64-mysql8.0.28-self-hosted-db-migration-<db/schema.rb のハッシュ> │ │ │ │ │ OS ARCH MySQL Ver ランナー環境 スキーマハッシュ ポイントは db/schema.rb のハッシュを含めていること。 マイグレーションの内容が変われば schema.rb も変わる ので、自動的に新しいキャッシュが生成されます。MySQL バージョンやアーキテクチャもキーに入れているのは、バイナリ非互換でハマらないための保険です(一度やらかしました……)。 キャッシュの利用: Composite Action で再利用しやすく キャッシュの利用ロジックは Composite Action に切り出して、RSpec だけでなく Steep(型チェック)など他のワークフローからも使い回しています。 # .github/actions/setup-mysql/action.yml - name : Create MySQL data directory run : mkdir -p ./tmp/mysql_data - name : Restore migration cache id : cache-hit-check uses : runs-on/cache/restore@v4.2.3-r2 env : RUNS_ON_S3_BUCKET_CACHE : your-gha-cache-bucket with : path : ./tmp/mysql_data key : test-${{ runner.os }}-${{ runner.arch }}-mysql${{ mysql_version }}-${{ runner.environment }}-db-migration-${{ hash }} - name : Start MySQL service with docker compose run : docker compose -f compose.ci.yml up -d mysql8 Docker Compose では、リストアしたデータディレクトリをそのままボリュームマウントします。 # compose.ci.yml services : mysql8 : volumes : - ./tmp/mysql_data:/var/lib/mysql MySQL が起動すると、キャッシュ内のデータファイルがそのまま認識されるので、 マイグレーション済みのデータベースが即座に使える 状態になります。 テストジョブでの分岐: キャッシュがあれば差分だけ 各テストジョブでは、キャッシュがヒットしたかどうかで処理を分岐しています。 - name : RSpec run : | if [ "${{ steps.setup-mysql.outputs.cache_hit }}" == "true" ] ; then echo "Using cached migration data, running incremental migration" bundle exec rails db:migrate # ← ブランチ固有の差分だけ else echo "No cache found, running schema load" bundle exec rails db:create db:schema:load fi キャッシュヒット時 : master のマイグレーション済みデータが復元されているので、 db:migrate で差分だけ適用。たいていは数秒で終わります キャッシュミス時 : MySQL バージョンアップ直後などキャッシュがない場合は db:schema:load にフォールバック この仕組みのおかげで、並列のテストジョブそれぞれで数分かかっていた DB セットアップが数秒になりました。体感で一番効果が大きかった施策かもしれません。 3. CI 用 MySQL のパフォーマンスチューニング 課題: デフォルト設定の MySQL が意外とボトルネック テスト環境の MySQL をデフォルト設定のまま使っていたのですが、ある日ふと気づきました。テストでは各テストケースごとに BEGIN / ROLLBACK やテーブルのクリーンアップが走るので、 書き込みが尋常じゃない量になっている んですよね。 デフォルト設定だと、コミットのたびにディスクへの fsync が走ります。本番では安全のために必要ですが、テスト環境では……正直、オーバースペックです。 解決策: テスト環境に限定して、耐久性よりパフォーマンスを優先する CI 専用の compose.ci.yml で、 データ耐久性を思い切って犠牲にして、書き込みパフォーマンスを最大化 しました。 # compose.ci.yml services : mysql8 : image : ${MYSQL_IMAGE} command : > mysqld --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-innodb-doublewrite environment : MYSQL_ALLOW_EMPTY_PASSWORD : "yes" ports : - "3306:3306" volumes : - ./tmp/mysql_data:/var/lib/mysql 各パラメータの解説 innodb-flush-log-at-trx-commit=0 InnoDB のログ書き込み動作を制御するパラメータです。 値 動作 用途 1(デフォルト) コミットのたびにログをディスクに fsync 本番環境(ACID 完全準拠) 2 コミットのたびに OS バッファに書き込み、 fsync は毎秒 レプリカなど 0 ログの書き込みも fsync も毎秒のバッチ処理 テスト環境 テストで 1 秒以内にクラッシュリカバリが必要な場面はないので、 0 にして コミットごとの fsync オーバーヘッドを完全に排除 しています。 sync-binlog=0 バイナリログ(レプリケーション用)の同期タイミングです。 値 動作 1(デフォルト) コミットごとにバイナリログを fsync 0 OS のファイルシステムキャッシュに任せる テスト環境ではレプリケーションを使わないので、バイナリログを sync_binlog=0 にし、同期を OS のキャッシュに任せることでコミットごとの fsync を省いています。 skip-innodb-doublewrite InnoDB の doublewrite バッファを無効化します。これは書き込み途中のクラッシュに備えて全ページを 2 回書く安全機構なのですが、テスト環境では不要です。無効化すれば 書き込み I/O が大幅に減ります 。 注意: 本番では絶対にやらないでください 念のため書いておきますが、上記の設定は データの耐久性・整合性を犠牲にしています 。 innodb-flush-log-at-trx-commit=0 : クラッシュで最大 1 秒分のトランザクションが消える sync-binlog=0 : クラッシュでバイナリログが不完全になる可能性 skip-innodb-doublewrite : 部分書き込みでデータ破損のリスク テスト環境は「テストが通ればデータは捨てる」使い捨ての世界なので、これらのリスクは許容しています。くれぐれも本番には適用しないように! まとめ 施策 何をキャッシュ/最適化しているか 効果 S3 キャッシュ vendor/bundle (Gem パッケージ) ダウンロード高速化・容量制限の解消 マイグレーションキャッシュ マイグレーション済み MySQL データ DB セットアップ時間を数分→数秒に MySQL チューニング fsync・doublewrite の無効化 テスト中の書き込み I/O を削減 CI の高速化に近道はなくて、結局はボトルネックを一つずつ潰していくしかありません。 DB のデータ耐久性は不要なので無効化する。マイグレーションは毎回ゼロからやる必要がないのでキャッシュする。キャッシュの保存先は、ネットワーク的に近い場所に置く。こうした「当たり前だけど意外とやっていない」割り切りが、大きな高速化につながりました。 同じような課題を抱えるチームの参考になれば嬉しいです。
こんにちは、株式会社タイミーでMLOpsエンジニアをしているKYです。普段はMLプラットフォームの構築・運用を担当しています。 実務の中でコンテナイメージのサプライチェーンセキュリティ強化を進めており、その一環として Docker 社が提供する「Docker Hardened Images(DHI)」の実装を辿る機会がありました。 その際、実際の定義ファイルを見て、少し驚きました。コンテナのビルド定義といえば「Dockerfile」が当たり前だと思っていたのですが、DHI の定義はなんと YAML で書かれていたのです。 「なぜ Dockerfile ではないのか?」と定義の読み方を追いかけていくうちに、BuildKit のアーキテクチャに行き着きました。この記事では、DHI の仕組みを通じて、私たちが普段の Dockerfile 運用で押さえるべきポイントを再確認したいと思います。 ビルド定義の主役は「Frontend」 BuildKit は、「定義を解釈する部分(Frontend)」と「実際にビルドを実行する部分(Backend)」に分離しています。Frontend は、入力(Dockerfile や YAML)を BuildKit の中間表現(LLB)に変換する役割を持ちます。 ここで鍵になるのが、ファイル先頭のコメント行 # syntax=... です。BuildKit はまずこの1行を読み、どの Frontend で後続を解釈するかを決めます。つまり、Docker 公式が推奨しているのに見落とされがちな以下の1行は、単なるコメントではなく「このファイルは公式の Dockerfile Frontend で解釈してほしい」という宣言です。 # syntax=docker/dockerfile:1 一方で DHI の定義ファイルを開くと、YAML の1行目に次の指定があります。 # syntax=dhi.io/build:2-debian13 YAML も # をコメントとして扱うため、BuildKit から見れば「 # syntax= から始まるビルド定義」という意味で入口は同じ。その後の中身を YAML として解釈するのは、差し替えられた DHI Frontend の仕事 というわけです。 DHI は何をしているのか:YAML をコンパイルする DHI の定義ファイル(YAML)は、 RUN apt-get... のようにといった手順を重ねるのではなく、「最終的に何を入れるか」という状態を宣言します。 【DHI の YAML 定義例(実際の定義ファイルからの抜粋)】 # syntax=dhi.io/build:2-debian13 name : Debian 13 Base image : dhi.io/debian-base variant : runtime platforms : - linux/amd64 - linux/arm64 dates : release : "2025-08-09" end-of-life : "2028-08-09" contents : packages : - '!libelogind0' - '!mawk' - '!original-awk' - base-files - bash - ca-certificates - coreutils # ... 以下、ベースに含めるパッケージの列挙が続く accounts : run-as : nonroot users : - name : nonroot uid : 65532 gid : 65532 cmd : - /bin/bash いくつかのフィールドに注目してみます。 contents.packages : !mawk のように ! プレフィックスを付けると「明示的に含めない」パッケージを宣言できます。削除手順を書くのではなく、最初から「入れない」と表明する点が Dockerfile との大きな違いです。 accounts.run-as: nonroot : 実行ユーザーを非 root に固定する宣言で、Dockerfile の USER 命令に相当します。Dockerfile のように RUN useradd ... といったユーザ作成手順を書く必要はなく、「誰で動かすか」という状態だけが残る点が特徴です。 dates.end-of-life : イメージのライフサイクル終了日まで定義に含まれており、運用上の管理情報もビルド定義の一部として扱われています。 このように、DHI の YAML は「どう作るか」ではなく「何が入っていて、誰が動かすか」を宣言しています。そしてここで重要なのは、 BuildKit が YAML を直接ビルドしているわけではない という点です。 DHI の Frontend がこの YAML を読み込んで中間表現(LLB)へコンパイルし、あとは通常通り BuildKit がビルドを実行します。つまり、DHI の YAML は「別言語」ではなく、 Frontend を差し替えて得た “別の入力形式” なのです。 たとえば不要パッケージの除外ひとつとっても、Dockerfile では apt-get remove → autoremove → キャッシュ削除と手順を重ねる必要があります。一方、DHI なら - '!mawk' の1行で意図が完結します。手順(How)ではなく意図(What)だけが残るため、セキュリティ監査や再現性の面で有利です。DHI が宣言的定義を採用しているのは、こうした相性の良さがあるからです。 忘れられがちな Dockerfile の公式推奨設定 今後、DHI のような宣言的フロントエンドがすぐに主流になるかは未知数であり、当面は既存の Dockerfile 運用が続くでしょう。 しかし、DHI が示す「Frontend は明示し、選ぶものである」という観点は重要です。まずは Docker 公式が推奨する以下の2行を、忘れずに Dockerfile の先頭へ記述しましょう。 # syntax=docker/dockerfile:1 # check=error=true # syntax=... 使用する Frontend を固定し、手元の環境と CI の違いによるビルド結果の揺れを防ぎます。 # check=error=true BuildKit の静的解析(lint)を強め、警告レベルの記述を CI で弾けるようにします。 これらを習慣づけるだけで、「Frontend を明示し、品質を保つ」文化に確実に近づきます。 まとめ DHI から学べる本質は、 BuildKit は Frontend を自由に差し替えられる という点にあります。この視点を持つと、DHI は単なるセキュアなベースイメージではなく、ビルド定義の抽象度を一段上げる試みとして見えてきます。 「手順を書く」から「状態を宣言する」への移行は、Infrastructure as Code で何度か見てきた流れと重なって見えます。DHI を触ってみて、その発想がコンテナビルドの入力形式にも持ち込まれていることを実感しました。 将来的にビルドのパラダイムがどう変わるにせよ、まずは見逃されがちな # syntax=... と # check=... をきちんと置くこと。タイミーでも Cloud Run / Vertex AI Pipelines の DHI 移行を進める中で、Frontend 指定の差がビルド結果の揺れに直結する場面に何度か遭遇し、この2行の重要性を改めて感じました。DHI がもたらした視点を持ちつつ、足元の運用を公式のベストプラクティスで堅牢にする。これが、現実的で安全なコンテナ運用の第一歩です。 参考文献 Docker Hardened Images - カタログリポジトリ Debian 13 Base 定義ファイル(13.yaml) — 記事中の YAML 定義例の抽出元 Custom Dockerfile syntax - Docker Docs Build hardened images - Docker Docs We're Hiring! サプライチェーンセキュリティや ML 基盤の足回りに興味を持っていただけたなら、ぜひ一緒に働きませんか。タイミーでは、ML プラットフォームの構築・運用やサプライチェーンセキュリティの強化に取り組むエンジニアを募集しています! 少しでも興味を持っていただけましたら、ぜひ以下のリンクから詳細をご覧ください。 MLOpsエンジニア シニアMLOpsエンジニア 募集ポジション一覧
こんにちは、株式会社タイミーで MLOps エンジニアをしている KY です。普段は ML プラットフォームの構築・運用を担当しています。 私たちのチームでは、機械学習エンジニアやデータサイエンティストが開発に集中できるよう、VS Code のリモート開発(Remote SSH および Dev Container)を活用した開発環境を提供しています。本記事では、その中でも 共通 Dev Container Feature によるガードレール にフォーカスし、各チームが自分たちで開発環境を立ち上げられることを前提にしながら、 セキュア・バイ・デフォルト をどう実現しているかをご紹介します。 なぜ Dev Container Feature にガードレールを寄せるのか この記事を書こうと思ったきっかけは、もともと機械学習エンジニアやデータサイエンティスト向けだった開発環境を、データアナリストをはじめとする別職種のメンバーにも広げ始めたことでした。ユーザー層が広がるにつれ、「どこまでを各自の設定に任せ、どこからを仕組みで縛るか」をあらためて考え直す必要が出てきた、というのが出発点です。あわせて、組織として求められるセキュリティレベルも年々高まってきています。 ML プラットフォーム特有の事情として、ユーザーの専門領域が幅広い、という点があります。機械学習エンジニアやデータサイエンティストはモデリングやデータ分析を主戦場としており、依存パッケージの脆弱性管理やコンテナの権限設計といった領域は、本来の業務の中心ではないことが多いです。だからこそ、これらをユーザー個々の習熟度に委ねるのではなく、プラットフォーム側で初期値を配る方針を取りました。 各チームがセルフサービスで開発環境を立ち上げられ、特別な設定をしなくても初期状態でセキュリティのベースラインが担保される 状態を目指しています。推奨パスに乗るだけで安全に進められる、いわゆる「ゴールデンパス」の発想であり、 セキュア・バイ・デフォルト を仕組みで成立させるアプローチです。 この方針を devcontainer.json レベルで素直に表現できる仕組みが Dev Container Feature でした。Feature を1行足すだけで宣言的にガードレールが適用されるため、「各チームが自律的に環境を立ち上げつつ、危険な操作だけは仕組みで塞ぐ」という設計とよく噛み合っています。 共通 Dev Container Feature によるガードレール 私たちの開発環境では、共通化した Dev Container Feature(以下、共通 Feature)を配っています。まず、ベースイメージと Feature の役割は明確に分けています。 Docker Hardened Images(以下、DHI)をベースにした開発用イメージでは、各種開発ツール(Python / uv / gcloud / Claude Code など)をインストールしておきます 。 共通 Feature では、それらツールの設定ファイル配置とガードレール適用のみを担います 。 この前提のもと、各チームの devcontainer.json は以下のようにシンプルで、ベースイメージを指定し、共通 Feature を追加するだけで、後述するガードレールがまとめて適用されます。 { " image ": " asia-northeast1-docker.pkg.dev/<PROJECT>/<CUSTOM_DHI_PATH>:<TAG> ", " features ": { " asia-northeast1-docker.pkg.dev/<PROJECT>/<CUSTOM_FEATURE_PATH>:<TAG> ": {} } } こうしてレイヤーを分けておくと、ツールの入れ物とポリシーの適用が混ざらずに整理されるため、 よりセキュアに締めやすい という体感があります。たとえばポリシー側だけを Renovate で継続的に更新していけるので、イメージの差し替えと独立してセキュリティ設定の追従・レビューを回せます。なお、ベースイメージ側で押さえるべきリスク(OS パッケージの脆弱性など)と、Feature 側で押さえるべきリスク(ツールの権限・設定)をどう切り分けるかといった論点もあります。ただし本記事のスコープ外のため、詳細は割愛します。 この Feature がプロビジョニング時に各種設定ファイルを配置し、ガードレールを自動で効かせます。実際には複数のツール設定を同じ方式で配布していますが、本記事では代表例として AI エージェントの制御を取り上げます。 Claude Code などの AI エージェントの制御 昨今、 Claude Code のような AI コーディングエージェントが普及していますが、無制限の権限を与えると破壊的変更や意図しないデータ送信のリスクがあります。共通 Feature は /etc/claude-code/managed-settings.json を自動生成し、システムレベルで制御を行います。 { " strictKnownMarketplaces ": [ { " source ": " github ", " repo ": " <ORGANIZATION>/<REPOSITORY> " } ] , " allowedMcpServers ": [ { " name ": " <APPROVED_MCP_NAME> ", " command ": " ... " } ] , " permissions ": { " deny ": [ " Bash(sudo:*) ", " Bash(gcloud:*) ", " Read(~/.config/**) " ] } } ※ 実際の設定から一部を抜粋しています。 プラグインマーケットプレイスと MCP サーバーは、社内で承認されたもののみに制限しています(ホワイトリスト形式)。また、 sudo や gcloud などの権限昇格・クラウド操作、 ~/.config/ 配下の機密情報へのアクセスといった危険な操作は、Deny リストでブロックしています。ユーザー側の settings.json では上書きできない managed settings として配置しているため、「うっかり緩めてしまう」ことを構造的に防げます。 Feature に寄せることの嬉しさ これらを共通 Feature として提供していることで、以下のようなメリットが得られています。 各チームの devcontainer.json は Feature を1行足すだけでよく、 セキュリティ設定の知識なしにベースラインを満たせる 。 Feature のバージョンを上げるだけで、 全社的にガードレールを一括更新できる (Renovate で自動 PR される)。 設定の出所が Feature に集約されているため、 監査やレビューの対象が明確 になる。 実際に運用してみると、Renovate の PR を1本マージするだけで全チームの Claude Code 設定が同時に更新されるのは、想像していた以上に運用が軽くなったと感じています。 補足:周辺で効かせている多層防御 共通 Feature だけで全てを押さえようとせず、周辺の仕組みと組み合わせて多層防御を成立させています。ベースイメージには DHI を採用してコンテナ起動時点でのベースラインを引き上げ、ホストとなる Remote SSH 用 VM 側にも同等のポリシーを展開し、依存関係は Dependabot / Renovate で継続的に追従させる、という具合です。 おわりに 今回は、MLOps チームが 共通 Dev Container Feature を使って、ML 開発環境のガードレールをどのように設計・運用しているかをご紹介しました。 振り返ってみると、 ツールは DHI イメージ、設定は共通 Feature、更新は Renovate と責務を分けておくと、それぞれに対するレビューや更新のサイクルを独立して回しやすいのが大きな利点でした。ガードレール自体を作ることよりも、 ガードレールを錆びさせない構造 に落とすことが、各チームの自律性を損なわずにベースラインを引き上げていくうえでの要だったように思います。 参考文献 Claude Code - System settings : /etc/claude-code/managed-settings.json に関する公式ドキュメント Dev Containers - Features : Dev Container Feature の仕様 こうした「セキュア・バイ・デフォルトな ML 開発環境」を、より多くのチームと一緒に磨き込んでいきたいと考えています。 We're Hiring! タイミーでは、ML プラットフォームの構築・運用やセキュアな開発環境の整備に一緒に取り組んでいただけるエンジニアを募集しています! 少しでも興味を持っていただけましたら、ぜひ以下のリンクから詳細をご覧ください。 MLOpsエンジニア シニアMLOpsエンジニア 募集ポジション一覧
1. 自己紹介・経歴 はじめまして、データアナリストのrizumuです。2025年にタイミーに入社しました。 前職ではファッション系のCtoCマーケットプレイスを運営する会社で約4年間データアナリストとして働いていました。UIの分析やクーポン施策の効果検証、顧客セグメントの分析などを担当していました。 2. なぜタイミーを選んだか 転職活動で最も重視したのは、アナリストが多い環境で働きたいという点でした。 前職のチームでは、少人数ならではのスピード感や、幅広い領域を任せてもらえる環境に感謝していました。一方で、「この分析アプローチで良いのか」「他のアナリストはどう考えるのか」と壁打ちをしたい場面では、相談できる相手が限られるもどかしさもありました。分析は一人で完結させようと思えばできてしまう仕事ですが、だからこそ他のアナリストの視点に触れる機会が、自分の成長には欠かせないと感じていました。 一方タイミーは、データを事業の意思決定に活かすことに組織として積極的に投資している姿勢が、選考を通じて伝わってきました。この人数の差は単なる規模の話ではなく、分析の型や議論の文化に触れられる機会が増えることを意味します。ここでならさらにアナリストとしてのスキルを伸ばせると感じたので、タイミーに転職することにしました。 3. 入社して驚いたこと 1つのプロジェクトに複数のアナリストが関わる体制 まず驚いたのは、プロジェクトの体制です。タイミーでは1つのプロジェクトに必ずリードメンバーが一人いて、その上で領域ごとに担当が分かれる形で複数のデータアナリストが関わります。 前職では、基本的に1プロジェクトにつきアナリストは一人、という体制でした。同じチームにアナリストはいても、プロジェクトの深い文脈を理解しているのは担当者だけ。他のメンバーに相談したい場面でも、まずは理解の前提をそろえるために背景共有から始める必要がある場面がありました。 結果として、一人で抱え込んで意思決定する場面が多かったように思います。 タイミーでは、同じプロジェクトに対して最初から共通理解を持った仲間が複数いる。だから「この切り口でいいのか」「この数字の解釈、どう思う?」といった相談を、前提の説明なしにその場で進められます。共通理解を持った相談相手がいる状態は、分析の進めやすさを変えてくれました。 リードメンバーへの相談ハードルの低さ もうひとつ驚いたのが、リードメンバーへの相談のしやすさです。ここでのリードとは、直接の上司ではなく、プロジェクトの中でリードの立ち位置を担うアナリストを指します。Slackで気軽に聞けるのはもちろん、リモート中心の環境なので、「今すぐちょっと相談したい」と思えばGoogle Meetで時間をもらうこともできます。立ち上がり期には、その点に助けられました。 ダッシュボードに求められるクオリティの高さ 想像以上だったのが、ダッシュボードに求められる見やすさ・使いやすさの水準です。データアナリストだけでなく営業など異なる職種の方も使うため、数字が正しいだけでは足りず、誰が見ても意図が伝わり、迷わず使えることが要件になります。ダッシュボード単体で完結する品質が必要、という感覚は業務に入って分かった部分でした。 これらを通して感じるのは、アナリストが多い環境の価値が、日々の相談しやすさやアウトプット基準の高さという形で具体的に現れている、ということです。 4. 半年やってみて、これから 具体的な業務エピソードでいうと、あるプロジェクトで担当したダッシュボード構築の案件が思い浮かびます。ステークホルダーが使い道の想いは持っているものの、ダッシュボードとして何を見るべきかの要件は固まっていない状態からのスタートでした。まず叩き台を作り、リードのデータアナリストからフィードバックをもらいながら要件を詰めていき、最終的には当初の目的に沿った形に仕上がったと評価をいただけました。 このプロジェクトに限らず、目的を起点に形を組み立てていく仕事を経験する中で、半年で一番変わったと感じるのは、以前より目的を強く意識するようになったことです。事業部の業務を十分に理解できていない状態から関わることが多く、目的を掴めていないとアウトプットはすぐにブレてしまう。解像度高く目的を持ち続けることの優先度が、自分の中で上がりました。加えて、自分一人では思いつかない分析の切り口や議論の進め方に日々触れられるのは、データアナリストが多い組織ならではだと実感しています。 今後チャレンジしていきたいのは、AI活用の幅を広げることです。SQL生成などは日常的に使っていますが、最近特に手応えがあるのはダッシュボードのモック作成です。自作のClaudeスキルを使うことでイメージに近いモックが最初から出てきます。一方で実装工程はまだ手作業が中心なので、ここをもっと簡単に進められる仕組みを作っていけたらと感じています。 AI活用の面白さは、個人の業務が速くなるだけにとどまりません。データアナリストが多い組織とAIへの意識の高さが重なると、一人のスキルや得意領域を他のメンバーも扱えるように広げていけます。自分が扱えるスキルやチャレンジできる領域も自然と広がっていく、この循環こそ、この半年で感じているタイミーで働くことの面白さだと思っています。 もし、かつての自分と同じように小規模なデータアナリスト組織で働いていて、次のステージを考えている方や、AIを活用しながらデータアナリストとしての幅を広げていきたいと考えている方がいれば、この記事が何かのきっかけになれば嬉しいです。 We're Hiring! タイミーでは、ともに働くメンバーを募集しています! データアナリストのポジションも募集中です。カジュアル面談も行っていますので、少しでも興味がありましたら、お気軽にご連絡ください。 https://product-recruit.timee.co.jp/
こんにちは。昨年度まで社会人大学院生(修士課程)として学び、無事卒業した Hunachi です 🙌 研究生活の中で、 SICS 2026 と DEIM 2026 に参加し、論文の執筆や発表、ポスター発表をしてきました。 私の研究内容は「Android搭載端末での pKVM 環境を使ったセキュアな声紋認証の実装と評価」です 👀 このブログでは、 私が研究で扱っている pKVM ってなに? どんな研究をしていたのか(ざっくり) 学会に参加したり、論文を書いて発表してみての感想 社会人大学院生をしてみた感想 以上の4 本立てで、私の研究や大学院生活について紹介していきます。 SCISは函館開催でした。その時に食べたごっこ汁 🐟  pKVM ってなに? モバイル端末でも「セキュアな実行環境」が欲しい 最近のスマートフォンでは、生体認証・決済・オンデバイス AI(Gemini Nano など)と、機密性の高い処理を端末上で動かす場面がどんどん増えていますよね。 Android でのセキュアな環境としては 2014 年から Trusty TEE (Trusted Execution Environment)という ARM TrustZone ベースの隔離環境が使われてきました。Android の一般的なアプリが動作する環境( REE: Rich Execution Environment )とは、ハードウェアレベルで分離されたセキュアな環境です。そのため、堅牢なセキュリティを実現できます。 ただし TEE には以下の弱点があります。 利用できるメモリが 数 MB 程度 ととても小さい 開発のハードルがそれなりに高い 端末のベンダーによってセキュリティの質がまちまち 特に利用できるメモリが少ないので、DNN モデルなどを動かすのは大変困難です 😖 pKVM の登場 そこで Android 13 から導入された Android Virtualization Framework(AVF) の中核として、 pKVM(Protected KVM) という仮想化技術が組み込まれました。 ざっくり言うと、 ベースは Linux 由来の KVM (Kernel-based Virtual Machine) そこに「ホスト OS からも触れない VM( Protected VM, pVM )」という概念を載せる 端末の物理メモリ容量いっぱいまで使える隔離環境が手に入る という、Trusty TEE のメモリ制約を解消した比較的新しい技術です 🚀 ちなみに数年前、「 Pixel で root を取らずに Linux(Arch や Ubuntu)が動かせる 」という話題、目にした方もいるんじゃないでしょうか。Danny Lin 氏の Nestbox というアプリで Android 上に Linux VM を立ち上げるものです( 参考記事 )。この基盤になっているのがまさに pKVM で、「ホスト OS から保護された VM」という枠組みを使えば、セキュリティ用途だけでなく汎用的な OS だってホストできてしまう、というのを実証した一例です。 pKVM のアーキテクチャをざっくり ARM のアーキテクチャでは、特権レベルが Exception Level(EL) という階層で分かれています。pKVM 環境に関する階層分けはこのようになっています。 EL2 : pKVM ハイパーバイザ EL1 : Android Host OS と Protected VM EL0 : ユーザアプリケーション EL2 で動く pKVM が ステージ 2 ページテーブル を使って、ホスト OS からの pVM メモリへのアクセスを物理的に遮断します。さらに IOMMU を使うことで、DMA デバイス経由の不正アクセスもブロックしてくれます。 また、pKVM上で動かすプログラムはC/C++で書く必要がありますが、TEE向けアプリの開発に比べれば容易です。 セキュアな環境を成り立たせる仕組み pKVM(AVF)の凄いところは、ただメモリを隔離するだけじゃない点です。 pvmfw (Protected VM Firmware)がペイロードの署名を検証して改ざん検知 DICE (Device Identifier Composition Engine)プロトコルで pVM ごとのシークレットを導出 DICEで導出したシークレットからsealing secretを生成し、さらにsealing keyを作成して永続データなどを暗号化 pVM 終了時にはハイパーバイザがメモリページをゼロクリアして残留防止 つまり、コードの正当性 → 起動時のシークレット → 永続データ → 終了時の残留防止 まで一貫してハイパーバイザがケアしてくれる、という設計です。 そして 2025 年 8 月、Google が pKVM で SESIP Level 5 認証を取得したと発表しました 🎉 SESIP(Security Evaluation Standard for IoT Platforms)は IoT デバイス向けセキュリティ評価基準で、Level 5 は最高レベルです。 大規模消費者向けに展開されるソフトウェアセキュリティシステムとして取得したのは世界初 で、最新かつかなりセキュアな技術であることがわかります( Google Online Security Blog )。 私の研究をざっくり やったこと ここからは自分の研究をかなりざっくり紹介します。 タイトルは「 Google Tensor 搭載端末の pKVM におけるセキュアな音声処理および声紋認証の実装手法と課題の検討 」です。 論文はこちらから読めます 👉 DEIM2026 3D-01 すごく簡単に言うと、 (pKVM環境)上で話者識別のDNNモデルを動かし、実用可能な処理速度で動作する声紋認証システムアプリを実現 pKVM のメモリアクセス特性を細かく測定 提案システムの認証精度・処理時間・pKVMのVM 起動時間などを多角的に評価 を行った論文です。 そしてありがたいことに、この発表で DEIM 2026 学生プレゼンテーション賞 をいただきました 🎉 一緒に研究を進めてくれた共著の先生方、コメントをくださった皆さん、本当にありがとうございました 🙇 まだまだ改善の余地がたくさんある研究内容ですが、興味のある方は論文を読んでもらえると嬉しいです 🙇 学会の感想 SICS に参加した感想 SICSは、以前は暗号系の発表が多かったようですが、最近は傾向が変わってきたようです。セキュリティ関連の発表では、高レイヤの話も多く見られました。特にLLMのセキュリティや研究方法に関する講演や発表が印象的でした。最先端のLLMの研究をしている日本人研究者もいることや、LLMのセキュリティの研究がどこまで進んでいるかの話を聞くことができ、面白かったです。 DEIM に参加した感想 たくさんの学生さんが参加している学会で、色々な研究の発表やポスター発表を見ることができました。特に土日にリモート開催だったので、社会人の私にとって大変嬉しかったです。LINEヤフーさんのDBの話なども興味深く聞かせていただきました。 最近の研究は、やはりLLM関連が多く、自分も研究でLLMも扱えるよう、ある程度は詳しくならないといけないと思いました。 論文執筆・発表・ポスター発表をしてみた感想 学部時代の研究をそのまま続けなかったこともあり、成果が出せる研究テーマにたどり着くまで時間がかかり、とても大変でした。一方で、先生方の助言やAIの活用により、先行研究や最新技術の調査を効率化できました。その結果、成果を出せてよかったです。 また論文を執筆するにあたり、慣れない部分については、AIに手助けしてもらいながら執筆しました。4年前の学部時代や高専時代に論文を書いた時と比べて、LaTeXのエラーに悩まされる時間や、誤字脱字の修正にかかる時間が、ほぼゼロになりました。本当に楽な時代になったなと感じます。 発表では厳しめの質問をいただくこともありましたが、それ以上に嬉しいこともありました。似た研究をしている方が少ないにもかかわらず、特にDEIMでは私の研究に興味を持って質問してくださる方が多く、とても嬉しかったです。 人に自分の研究内容を伝えることは、社会人におけるプレゼンテーションを行う際にも活かせるなと思いました。 社会人大学院生(修士課程)をしてみた感想 大学の教授やD進している同期、夫の家事サポートがあったからこそ、卒業できました。関係者の皆さんに感謝しかありません。 人におすすめできるかというと、とても忙しい生活スタイルになるため、研究が趣味な人以外にはおすすめしにくいです。ただ、AIの活用で調査や文章執筆が容易になった今の時代だからこそ、「チャレンジは可能だ」と思います。 私の感じたメリット・デメリット メリットは、金銭的な問題で困りにくいことです。いろいろな理由があり、猫と暮らしている自分には働かないという選択肢がなかったため、社会人学生を選びました。働きつつ学生でいることを許してくれた大学の教授には感謝しかありません。そのおかげで猫と暮らしつつ学費も安定して払うことができました。 デメリットは以下のとおりです。 大学以外のことをするプライベートな時間がかなり少なくなること 研究に時間を費やす必要があるのはもちろんのこと、学会や授業の参加で有給が消費されます 仕事や大学が忙しい時期には睡眠時間以外はパソコンの前にいる、というような不健康な生活が日常になること 学生らしい生活ができないこと 私の場合は、大学に行く時間が取れず在宅で研究を行なっていた関係で、友人と研究室でおしゃべりしたり、飲み会や合宿への参加などはできませんでした。 また私は、学部時代に大学院の授業単位を取得できる制度を活用していたため、大きな問題はありませんでした。ただし、大学や単位の取得状況によっては、授業のために有給を使う必要が出てくるかもしれません。さらに、大学生らしい生活が送れないのはもったいないと感じるため、個人的には可能であれば通常の大学院生として通うほうがよいと思います。 ※ 私の大学生活のほとんどはコロナでオンラインだった関係で大学生活をまともにしたことがないので意見が偏っている可能性もあります。 ただ、事情があり社会人になる必要がある人やすでに社会人の方で、研究をしたい・続けたい人は十分頑張ってみる価値があると思うので応援しています 🚩 おわりに 引き続きpKVMや研究関連の勉強は続けようと思っています 🧑‍🎓 最後まで読んでくださってありがとうございました!
こんにちは。タイミーのデータエンジニアリング部 DSグループでMLOpsを担当しているYukitomoです。 私たちのチームでは多くのPythonアプリをモノレポで管理していますが、Dependabotによる依存関係更新PRが多すぎることが運用課題でした。本記事では、Renovateへの移行によって「更新PRの粒度と数をコントロールできる運用」を実現するまでの設計判断と、Python + uv環境特有の注意点を共有します。 この記事の想定読者 Pythonのモノレポ環境で、複数のアプリケーションやライブラリを運用している方 Dependabotが生成する大量の更新PRの対応に疲弊しており、運用を効率化したい方 Renovateへの移行を検討している、または導入したが設定(packageRules)のベストプラクティスに悩んでいる方 パッケージマネージャーに uv を採用している(または検討している)方 要約(TL;DR:この記事でわかること) 本記事では、Python + uv環境でRenovateを運用する際の課題とその解決策(新しすぎるパッケージの除外設定、Google Cloud WIFにおけるブランチ名の文字数制限の回避など)を整理し、実践的なrenovate.json5の設定ノウハウを解説します。 背景 近年はサプライチェーン攻撃が現実的なリスクになっており、Trivyの侵害以降も Python モジュールや JS ライブラリを狙った攻撃が継続して観測されています。PyPI など外部エコシステムに依存する以上、これまで以上に「依存関係をどう安全に運用するか」を真面目に考える必要があります。 一方で、依存関係を「安全に」保つには、継続的にアップデートを回し続ける必要があります。ここで次の課題になるのが、運用対象が増えたときに更新対応のコストがスケールしてしまう点です。 私たちもDependabot運用の効率化を進めてきましたが *1 、アプリごとにパッケージ管理へ移行した結果、モノレポ内のpyproject.tomlが増えました。2026年5月時点では、DSグループだけでも約70のPythonアプリケーション/ライブラリを扱っています。Dependabotは脆弱性の検知とPR作成を行ってくれる一方で、依存関係ごとにPRが分割されます。そのため、対象が増えるほど対応コストが急増します。 そこでこの課題を解決するため、Renovateを導入し「更新をまとめて扱える運用」へ切り替える方針にしました。本記事では、公式ドキュメントや公開されている設定例を参考にしつつ、私たちが重視した設定ポイントを整理します。 この記事の前提 言語: Python 依存関係ファイル: pyproject.toml / uv.lock 動作環境: GitHub & Google Cloud 目的: Renovateで「脆弱性対応」と「定常アップデート」を破綻なく回す(PRの数と粒度をコントロールする) 設定ファイル(.renovaterc.json5) 設計方針 私たちが設定で重視したのは以下の3点です。 PRの粒度をコントロールする — patch / minor / vulnerability を適切にグルーピングし、PRの本数を削減する サプライチェーンリスクを軽減する — 公開直後のバージョンを即座に採用しない 小さく始める — まず許可リスト方式で必要な更新だけを有効化し、段階的に広げる 全体像 以下が設定ファイルの抜粋です(各設定の詳細は後述)。 // .renovaterc.json5 より一部抜粋 { extends : [ " config:best-practices " ] , minimumReleaseAge : " N days ", lockFileMaintenance : { enabled : true , branchTopic : " lfm ", // GCP WIF 127-byte limitに対応するためブランチ名を省略 minimumReleaseAgeBehaviour : " timestamp - optional " // 一時的な対応 } , vulnerabilityAlerts : { groupName : " maintenance ", groupSlug : " maint ", minimumReleaseAge : " 14 days " , } , packageRules : [ // packageFileDirをブランチ名に含めつつGCP WIF 127-byte limitに対応するためブランチ名を省略 { matchFileNames : [ " base_containers/base/** " ] , additionalBranchPrefix : " {{{replace 'base_containers/base/' 'b_b/' packageFileDir}}}/ " , } , // packageRuleを一旦無効化 { matchPackageNames : [ " ** " ] , enabled : false } , // グルーピング ---------------------------------------------------- // ルール 1: { matchUpdateTypes : [ " patch " ] , enabled : true , groupName : " maintenance ", groupSlug : " maint " , } , // ルール 2: { matchUpdateTypes : [ " minor " ] , matchJsonata : [ " isVulnerabilityAlert = false " ] enabled : true , groupName : " minor updates ", groupSlug : " minor ", dependencyDashboardApproval : true } , // ルール 3: { matchPackageNames : [ " ** " ] , matchJsonata : [ " isVulnerabilityAlert = true " ] enabled : true , // この2つは vulnerabilityAlerts で設定した値で上書きされます groupName : " maintenance ", groupSlug : " maint " , } , // マイナーレベルでの破壊的な更新の抑制 ただし脆弱性対応を除く { matchJsonata : [ " isBreaking = true and not(isVulnerabilityAlert) " ] , enabled : false , }, ] , // バージョンの更新 bumpVersions : [ { bumpType : " patch ", filePatterns : [ " {{packageFileDir}}/pyproject.toml " ] , matchStrings : [ " version \\ s*= \\ s* \" (?<version>[^ \" ]+) \" " ] } , { bumpType : " patch ", filePatterns : [ " {{packageFileDir}}/uv.lock " ] , matchStrings : [ " name = \" [^ \" ]+ \"\\ nversion = \" (?<version>[^ \" ]+) \"\\ nsource = \\ { (?:editable|virtual) = \"\\ . \" \\ } " ] } ] } 設定項目の説明 config:best-practices を土台にする Renovateは設定可能な項目が多く、ゼロから組むと必ず設定が肥大化します。そこでまずは extends: ["config:best-practices"] をベースにして、一般的に安全側なデフォルトを取り込みました。 ベース設定を取り込んだうえで、運用上の要所(PRの粒度、セキュリティ例外、ロックファイルの扱い)だけを packageRules で上書きしています。 minimumReleaseAge (新しすぎるリリースを避ける) サプライチェーン観点では「出たばかりのバージョンを即座に拾う」ことが常に正解とは限りません。そこで minimumReleaseAge を設定し、公開直後のバージョンを一定期間は自動採用しないようにしています。 ここで一つ注意点があります。 minimumReleaseAge が効くのは、Renovateが pyproject.toml のバージョン指定を直接書き換える通常の更新(Standard Update)だけです。 一方、 pyproject.toml には記載されず uv.lock にだけ現れる間接依存(依存パッケージがさらに依存しているパッケージ)の更新は、Renovateの lockFileMaintenance が担当します。 lockFileMaintenance は内部で uv lock を実行しますが、現時点ではRenovateから uv lock に --exclude-newer 等のオプションを渡す手段がありません *2 。つまり、間接依存に対しては minimumReleaseAge による「新しすぎるバージョンの除外」が効かないのです。 そこで、uv自身が持つ exclude-newer 機能で補完しています。各 pyproject.toml に以下のように記述することで、 uv lock 実行時にuv側で公開直後のバージョンを除外します *3 。 # pyproject.toml [tool.uv] exclude-newer = "n days ago" # uv 0.10+ で相対日付指定が可能 # uv.lock (uv lock 実行時、以下のように変換されて保存されます) [options] exclude-newer = "2026-04-07T08:39:48.633055Z" exclude-newer-span = "PnD" この二重構成により、直接依存は Renovate の minimumReleaseAge 、間接依存は uv の exclude-newer でそれぞれカバーし、すべての依存パッケージに対して「新しすぎるバージョンを即座に採用しない」制約を適用しています。 lockFileMaintenance (Google Cloud Workload Identity Federationの制約対策とminimumReleaseAgeBehaviourの調整) 明示的に有効化します。また、プライベートなモジュールを独自のArtifactoryやNexusなどのパッケージインデックスに配置・利用することはよくあると思います。我々はGoogle Cloudを利用しており、プライベートなパッケージは Google Artifact Registry に保管しています。この場合、RenovateからGoogle Cloudにアクセスが発生し、Workload Identity Federation (WIF) を使う構成だと、subject claimにブランチ名が入ります *4 。ここに127 bytes制約があり、Renovateが生成するブランチ名が長いと認証に失敗します。対策として、作成するブランチ名を短縮化するため、 branchTopic の値を短縮しています(デフォルトでは “lock-file-maintenance”)。 この値を調整して通常の更新とブランチ名を一緒にし、PRを一緒にできないかと試したのですが、 Grouping lockfile maintenance with other update types is not supported というエラーメッセージが出たため断念しました。 minimumReleaseAgeBehaviour は、本来はデフォルト値の "timestamp-required" のほうが自然です。ただし、(*2)のrenovate のPRがマージされるまでは "timestamp-optional" にしておかないと、 lockFileMaintenance のPRがRenovate botの承認待ちになってしまうため注意してください。 vulnerabilityAlerts AIエージェントの助けを借りてRenovateのコードを確認し、試行錯誤する中で気づいたのですが、脆弱性対応に関する一部の設定は、packageRules で指定してもグローバルの vulnerabilityAlerts の値で上書きされます。具体的には、後述するグルーピングルール3の内容や、前項の minimumReleaseAge の設定です。 packageRules 以下、設定の意図をダイジェスト順に説明します。 1) packageFileDirをブランチ名に追加 複数のモジュールの更新を集約するために packageFileDir を additionalBranchPrefix に利用しています。モノレポにおけるadditionalBranchPrefixの一般化( packageFileDir 由来のprefixを使う等)の詳細は別記事 *5 に委ねます。lockFileMaintenanceの項と同様、ブランチ名が長くなるため、Google Cloud Workload Identity Federation (WIF) の制約対策のためにブランチ名を短縮しています。 2) いったん全ルールを無効化して「許可リスト方式」にする { matchPackageNames: ["*"], enabled: false } 最初に全パッケージを enabled:false に落としてから、必要な更新だけを後続ルールで enabled:true に戻しています。Defaultを使いこなす方が安全とは思うのですが、まず最初は小さく、自分達でコントロールできる範囲から始めようとしてこのような設定としました。 3) PRのグルーピング(patch / minor / vulnerability) 別記事(*5)にもありますが依存関係更新の運用コストは「PRの数」と「レビューのコンテキスト切り替え」で決まるので、グルーピングが最重要です。 グルーピングルール1: patch更新: まとめて1本(メンテナンス枠) グルーピングルール2: minor更新: まとめて1本(ただし dependencyDashboardApproval:true で人間の許可待ちにする) グルーピングルール3: vulnerability: patchと同じグループに合流させ、優先して処理できるようにする Minor更新は、まずはダッシュボードで確認する運用にします。運用がうまく回りそうであれば、将来的にpatch groupingに合流させる想定です。 また、renovateのconfigは後ろの条件が前の条件を上書きするため、グルーピングルール3は最後に配置する必要があります。4)で扱う isBreaking に関する packageRule も同様に、後ろに配置してください。 4) 0.y.z系の“実質breaking”を抑止する SemVer上はminorでも、 0.y.z のようにリリースされていない場合、minor更新がbreakingになり得ます。そこで isBreaking = true を検知した更新は原則止めています。 ここは「通常アップデートは保守的に、ただし脆弱性対応は止めない」方針にしたいため、脆弱性アラート( isVulnerabilityAlert:true )にはこの抑止が効かないようにしています(=脆弱性があるものはIsBreakingでも検出させる)。 bumpVersions RenovateをGitHubで動作させるには、1) GitHub Actionsとして renovatebot/github-action を利用する方法と、2) 作成元のMend社が提供するMend Renovate Appを利用する方法があります。設定が簡単なことからタイミーでは後者を利用しているのですが、それ故の制約もあり、 postUpgradeTasks のように任意のコマンドを実行するようなことができず、コマンド実行できれば簡単なversionの更新もそのままでは実現できません *6 。解決策として、 bumpVersions を使い、正規表現で pyproject.toml と uv.lock の両方を同時に更新しています。なおbumpVersionsは オフィシャルドキュメントの最初にはpackageRulesの外の記述例があるのですが、マッチ表現と組み合わせpackageRulesの中に記述することもできます *7 。上記のサンプルではpackageRulesの外で記述していますが、我々はlockFileMaintenanceとそれ以外でbump up の方法を一部変えるため、 matchUpdateTypes を lockFileMaintenance とそれ以外で分離し、packageRulesの中に両方を記述しています。 // 応用例: lockFileMaintenanceと通常の更新を別々に記述する場合 (packageRulesの中に記述する) packageRules : [   : { matchUpdateTypes : [ " lockFileMaintenance " ] , bumpVersions : [ .. ] } , { matchUpdateTypes : [ " major ", " minor ", ... ] , bumpVersions : [ .. ] } ] まとめ Dependabotの「依存ごとにPRが分割される」性質は、対象が増えるほど脆弱性対応の運用コストを押し上げる。 Renovateは packageRules を適切に設定することで、上記の運用コストの削減を行うことができる。 Python + uv の場合、 lockFileMaintenance のオプションとして指定できない exclude-newer については pyproject.toml に直接記述することで問題を回避できる。 Mend Renovate Appを利用する場合においても、bumpVersionsを利用することで pyproject.toml / uv.lock それぞれのversionの更新を実現できる。 We’re Hiring! 現在、タイミーでは、データサイエンスやエンジニアリングの分野で、共に成長し、革新を推し進めてくれる新たなチームメンバーを積極的に探しています!  現在募集中のポジションは こちら です! また、気軽な雰囲気での カジュアル面談 も随時行っておりますので、ぜひお気軽にエントリーしてください。↓ 「話を聞きたい」と思われた方は、是非一度 カジュアル面談 でお話ししましょう! References *1 : https://tech.timee.co.jp/entry/2024/10/15/101953 *2 : uv lock コマンド自体は --exclude-newer オプションを持つのですが、Renovateからこのオプションを2026年5月13日現在渡せていません。他のパッケージマネージャーに関しても同様でIssueとして登録されており、uvに関しては既にPRも出ているようですがリリースはまだされていません。 https://github.com/renovatebot/renovate/issues/41652 *3 : 0.10より古いバージョンのuvでも exclude-newerは記述できるのですが ”N days ago” のような相対的な評価がこのバージョンから記述できるようになりメンテナンスが非常に楽になっています。 https://github.com/astral-sh/uv/releases/tag/0.10.0 *4 : https://docs.cloud.google.com/iam/docs/troubleshooting-workload-identity-federation#error-google-subject-too-long *5 : 近日公開予定 *6 : uvを利用する場合、 pyproject.toml / uv.lock に記述された自身のversionの更新はuv version --bump patchのように実現できます。 *7 : https://docs.renovatebot.com/configuration-options/#bumpversions
はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannkazu1 )です。 クラウドネイティブ会議2026 で発表された「 ペアーズ本番環境でのcgroup-aware化との死闘録 」がめちゃくちゃ面白かったので、自分の手でも体感したくなりました。 GoのGOMAXPROCSがコンテナのCPU制限を無視するって、実際に見るとどうなるのか? 過剰並列のスループット低下って、数字で見るとどのくらいインパクトがあるのか? スロットリングとスレッド数の関係を自分の目でたしかめたい! 自分で動かして数字を見ないと腑に落ちないタイプなので、 ローカルのMac環境で全部再現してみました。 発表の要約 ペアーズのバックエンド pairs-main はGo製でAmazon EKS上で稼働。48コアのNodeで limits.cpu: 5000m (5コア)のPodが動いていたが、 GoのGOMAXPROCSがデフォルトで48 (=Node全体のコア数)になっていた。これにより以下の問題が発生: 過剰並列 : 5コアしか使えないのに48スレッドが走る → Goスケジューラのオーバーヘッド増大 CPUスロットリング : cgroupのクォータ(CPU時間の上限)をスレッドが共食い → 全スレッドが同時に停止 監視の死角 : CPU使用率は正常に見えるが、実際はスロットリングで断続的に停止 同じ問題がHAProxy( nbthread=48 、CPU制限1コア)でも発生していた。 これらをcgroup-awareな設定(GOMAXPROCS=5, nbthread=1)に修正したところ、大幅に改善した、という話でした。 用語の整理 ここから先で出てくる「コア」「GOMAXPROCS」「クォータ」「スロットリング」あたりがピンと来なくても大丈夫です。記事全体で繰り返し登場するので、最初にざっくり整理しておきます(すでに馴染みがある方はスキップでOK)。 CPUコア・プロセス・スレッド 用語 ざっくりした意味 CPUコア 計算を実行する物理的な実体。1コア = 同時に1つの処理を進められる プロセス 動いているプログラム1つ分の単位 スレッド プロセス内で実際にCPUに割り当てられる作業の単位。1プロセスは複数スレッドを持てる ざっくり言うと、 コアの数 = 同時に進められるスレッドの数の上限 です。8コアのCPUなら、ある一瞬に進行できるのは最大8スレッドまで。それ以上のスレッドを立ち上げた場合は、OSが順番にコアを割り当て直しながら回します(= コンテキストスイッチ)。 コンテナと cgroup 用語 ざっくりした意味 コンテナ 同じサーバー上で複数のアプリを互いに干渉しないように動かす仕組み(Docker や Kubernetes の中身)。実体はホストのカーネルをそのまま使う 「namespaces で見える範囲を、cgroup で使える量を制限したプロセス(群)」 にすぎず、VM のように専用カーネルを持つわけではない cgroup (Control Groups) Linuxカーネルの機能で「このプロセス群はCPUをここまで・メモリはここまで」と上限を設定する仕組み CPU制限 「このコンテナはCPU 1コア分まで」のような上限設定。実体は cgroup の cpu.max ファイル コンテナの「CPU 0.5コアまで」という設定は、Linuxカーネルが cgroup を通じて「100msのうち50msまでしかCPUを使わせない」という形で強制します。この 100msの枠を「ピリオド」、その中で使ってよい時間量を「クォータ」 と呼びます( cpu.max: 50000 100000 なら「100msのうち50ms使える = 0.5コア相当」)。 CFS スケジューラ Linux のデフォルトの CPU スケジューラを CFS(Completely Fair Scheduler) と呼びます。先ほどの「ピリオド」「クォータ」は、CFS が持つ 帯域制御(Bandwidth Controller) という機能の用語で、cgroup の cpu.max の値を実際にスレッドへ適用する(=クォータを使い切ったら停止させる)のはこの CFS の仕事です。 つまり「cgroup が制限値を持ち、CFS がそれを実施する」という分担関係。後の実験で出てくる nr_periods (CFS が時間を区切る単位の総数)や nr_throttled (CFS が停止させたピリオドの数)も、この CFS 帯域制御の統計を見ています。 Goroutine と GOMAXPROCS(Go特有の話) 用語 ざっくりした意味 goroutine Goの軽量スレッド。OSスレッドより遥かに軽く、1プロセスで数万〜数百万個立ち上げられる OSスレッド OSが実際にCPUにスケジュールするスレッド。コアを取り合うのはこちら GOMAXPROCS Goランタイムが同時に走らせるOSスレッドの数の上限。デフォルトはホストのCPUコア数 goroutine を何万個立ち上げても、Goランタイムは GOMAXPROCS 個の OSスレッドの上にそれらを多重化して実行します。つまり同時に CPU を握っているのは最大でも GOMAXPROCS 個。この割り当てを管理するのが Goスケジューラ です。 ポイントは、 コンテナのCPU制限が下がってもデフォルトの GOMAXPROCS はホストのCPU数のまま ということ。これがそもそも今回のテーマで、後の実験でその挙動を実際に確かめます。 過剰並列 CPU 制限よりも多くのスレッド(や goroutine、ワーカー)を同時に走らせている状態 を指します。たとえば 5 コア相当の CPU 制限に対して GOMAXPROCS=48 なら、約 9.6 倍の過剰並列。実際に走れるのは制限分のスレッドだけなので、残りはスケジューラの上で順番待ちをしつつ、共有クォータを早食いし合うことになります。 Go の GOMAXPROCS に限った話ではなく、HAProxy の nbthread 、Nginx の worker_processes 、Puma の workers など、 「並列数のデフォルトがホスト CPU 数に依存する」設定はすべて同じ構造で過剰並列を起こします 。 CPUスロットリング cgroupでCPU 0.5コア分に制限されたコンテナが、たくさんのスレッドでCPUを一気に使おうとすると、Linuxカーネルが 「クォータを使い切ったので、次のピリオドまで全スレッド一時停止」 と強制的にブロックします。これが CPUスロットリング です。 スロットリングが頻発すると、レスポンスが断続的に止まったり、スループットが落ちたりします。その結果、「なぜか遅延がスパイクする」原因になっているケースが多いです。発生状況は /sys/fs/cgroup/cpu.stat に出力されており、本記事では以下の3指標を追います: nr_periods : スケジューラの計測単位(ピリオド = 100ms)の総数 nr_throttled : そのうちスロットリングが起きたピリオドの数(回数) throttled_usec : スロットリングで実際にCPUが止められた累積時間(マイクロ秒) 「回数」だけでなく「 累積停止時間 」も見るのが重要だ、というのが発表の山場の一つで、後の実験3でその違いがハッキリ出ます。 Thundering Herd スロットリングで停止していた全スレッドが、 次のピリオドのリセットで一斉に走り出し、また一瞬でクォータを食い潰して同時に止まる 、というサイクルが繰り返される状態を 「Thundering Herd(雷鳴の群れ)」 と呼びます。元はソケット accept など I/O 文脈の用語ですが、cgroup の帯域制御下でも同じ構造の問題が起きます。スレッド数が多いほど被害が大きくなるのは、ここに端を発しています。実験4でその挙動を観察します。 cgroup-aware プログラムやライブラリが cgroup の制限( cpu.max など)を自分で読み取り、その値に合わせて並列度を調整する 設計のことを 「cgroup-aware」 と呼びます。Go 1.25 以降のランタイムや uber-go/automaxprocs は cgroup-aware に GOMAXPROCS を設定します。逆に Go 1.24 以前のように cgroup を見ずにホストの CPU 数だけ見る挙動は「cgroup-aware ではない」状態で、今回の過剰並列はそこから生まれています。 この記事で検証すること # 検証テーマ 発表でのポイント 1 GOMAXPROCSのデフォルト値 コンテナのCPU Limitを無視してホストのCPU数になる 2 過剰並列のパフォーマンス影響 GOMAXPROCSが大きすぎるとスループットが低下する 3 CPUスロットリングの発生 スレッド数が多いほどクォータを早く消費し、停止時間が増える 4 スレッド数とスロットリングの相関 スレッド数に比例して throttled_usec が増加する 1. ローカル環境構築(Mac) 前提条件 macOS (Apple Silicon / Intel 両対応) Docker Desktop がインストール済み なぜDockerで検証できるのか cgroup(Control Groups)は Linuxカーネルの機能 で、macOS 自体には存在しません。しかし Docker Desktop は内部で Linux VM を動かしており、コンテナはその Linux 上で動作します。 ┌─────────────────────────────────────────────┐ │ macOS │ │ ┌────────────────────────────────────────┐ │ │ │ Docker Desktop (Linux VM) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ コンテナ │ │ │ │ │ │ /sys/fs/cgroup/cpu.max ← ここ! │ │ │ │ │ │ /sys/fs/cgroup/cpu.stat │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ Docker の --cpus フラグは Kubernetes の limits.cpu と同じく cgroup の cpu.max に変換されます。つまり Kubernetes と同じ仕組みをローカルで再現 できます。 Docker Kubernetes cgroup v2 --cpus=0.5 limits.cpu: 500m cpu.max: 50000 100000 --cpus=1.0 limits.cpu: 1000m cpu.max: 100000 100000 --cpus=5.0 limits.cpu: 5000m cpu.max: 500000 100000 セットアップ手順 Step 1: Docker Desktop のインストール Docker Desktop for Mac からインストール。 docker --version # Docker version 27.x.x, build xxxxxxx Step 2: 検証用 Go アプリケーション 本記事の検証コードは以下のリポジトリにまとめています: hirosi1900day/cgroup-throttling-lab git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab 3つのモードを持つGoアプリケーションを書きました。 モード 用途 info GOMAXPROCSの値とcgroupの設定を表示 benchmark CPU負荷をかけてスループットを計測 throttle-demo CPU負荷をかけてスロットリングの Before/After を表示 コード解説 各パートを順に見ていきます。 1. CPU負荷を発生させる関数 // cpuIntensiveWork はCPU負荷をかける計算処理 // 平方根と三角関数を1万回ループし、意図的にCPUを使い切る func cpuIntensiveWork() float64 { result := 0.0 for i := 0 ; i < 10000 ; i++ { result += math.Sqrt( float64 (i)) * math.Sin( float64 (i)) } return result } この関数が実験の要です。 math.Sqrt と math.Sin の計算を1万回繰り返すことで、 純粋なCPU負荷 を発生させます。I/O待ちが一切ないので、GOMAXPROCS(=ワーカースレッド数)の影響がダイレクトに現れます。 2. infoモード — GoランタイムとcgroupのCPU設定を表示 func showRuntimeInfo() { // runtime.GOMAXPROCS(0) は「現在の値を返し、変更しない」 // ← これがコンテナのCPU制限と一致しているかがポイント fmt.Printf( "GOMAXPROCS: %d \n " , runtime.GOMAXPROCS( 0 )) fmt.Printf( "NumCPU: %d \n " , runtime.NumCPU()) // --- cgroup のCPU制限を直接読む --- // /sys/fs/cgroup/cpu.max は cgroup v2 のCPU制限ファイル // 中身は "クォータ ピリオド" の形式(例: "100000 100000") // Kubernetes の limits.cpu や Docker の --cpus がここに反映される if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.max" ); err == nil { fmt.Printf( "cpu.max: %s" , string (data)) } // /sys/fs/cgroup/cpu.stat はCPUスロットリングの統計情報 // nr_periods: CFSスケジューラのピリオド(100ms)の総数 // nr_throttled: スロットリングが発生したピリオドの数 // throttled_usec: スロットリングでCPUが停止した累積時間(μs) if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.stat" ); err == nil { fmt.Printf( "cpu.stat: \n %s" , string (data)) } } このモードでは、 GoランタイムがcgroupのCPU制限を認識しているか を見ます。Go 1.24以前では、 GOMAXPROCS がホストのCPU数のままなのが確認できるはずです。 3. benchmarkモード — スループットの計測 func runBenchmark() { // 環境変数でベンチマーク時間とgoroutine数を制御可能にしている duration := 10 * time.Second // BENCH_DURATION で変更可 goroutines := 100 // BENCH_GOROUTINES で変更可 // --- ここからが計測のコア --- var totalOps atomic.Int64 // goroutine間で安全にカウントを共有 var wg sync.WaitGroup // 全goroutineの完了を待つ // タイマーで終了を通知するチャネル done := make ( chan struct {}) go func () { <-time.After(duration) close (done) // ← 全goroutineに「終了」を伝える }() // goroutines個のgoroutineを起動し、それぞれが独立にCPU負荷をかける // これらのgoroutineは GOMAXPROCS 個のワーカースレッドに // Goスケジューラによって割り当てられる for i := 0 ; i < goroutines; i++ { wg.Add( 1 ) go func () { defer wg.Done() localOps := int64 ( 0 ) // goroutineローカルでカウント(競合を避ける) for { select { case <-done: totalOps.Add(localOps) // 最後にまとめて加算 return default : cpuIntensiveWork() // CPU負荷をかけ続ける localOps++ } } }() } wg.Wait() // Ops/sec = 単位時間あたりの処理回数 // この値が高いほどスループットが良い opsPerSec := float64 (totalOps.Load()) / elapsed.Seconds() } 100個のgoroutineが cpuIntensiveWork() を呼び続け、それらがGOMAXPROCS個のOSスレッド上でスケジュールされる構造。CPU制限がある環境では、スレッドが多いほどcgroupのクォータを早く使い切る、スロットリングでスループットが落ちるわけです。 (脱線)ベンチマークコードの工夫 — キャッシュコヒーレンシの話 cgroup の検証とは直接関係ないですが、このベンチマークコードには「計測自体が結果を歪めないための工夫」が入っています。せっかくなので解説します。 select + default でノンブロッキングに終了チェックしつつCPU処理を回し続ける、というのはGoの定番パターンなので軽く触れるだけにして、本題はカウンタの設計です。 localOps := int64(0) // goroutineローカル(普通のint) for { select { case <-done: totalOps.Add(localOps) // ← 終了時に1度だけatomic操作 return default: cpuIntensiveWork() localOps++ // ← 普通のインクリメント。超高速 } } ループ内では localOps++ (普通の int インクリメント)だけを使い、終了時に1度だけ totalOps.Add(localOps) ( atomic 操作)で合算しています。 「毎回 totalOps.Add(1) でいいのでは?」と思うかもしれませんが、それだと100個の goroutine が同じメモリアドレスに毎ループ書き込み合い、 キャッシュコヒーレンシ(Cache Coherency) のオーバーヘッドで性能が大きく落ちます。 キャッシュコヒーレンシとは まず前提として、CPUがデータにアクセスする仕組みを整理しておきます。 CPU のメモリ階層 CPUが変数やデータを読み書きするとき、毎回メインメモリ(DRAM)まで取りに行くのは遅すぎます。そこで CPUは メモリ階層(Memory Hierarchy) という多段のキャッシュ構造を持っています: ┌─────────────────────────────────────────────────┐ │ CPU コア │ │ ┌───────────┐ │ │ │ レジスタ │ ← 最速(~0.3ns)、数十〜数百個 │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L1 キャッシュ│ ← 32〜64KB / コア、~1ns │ │ │ (データ+命令)│ │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L2 キャッシュ│ ← 256KB〜1MB / コア、~3-10ns │ │ └─────┬─────┘ │ │ │ ┌──────────┐ │ │ │ │ TLB │ ← 仮想→物理アドレス │ │ │ │ │ 変換のキャッシュ │ │ │ └──────────┘ │ └────────┼────────────────────────────────────────┘ ┌─────┴─────┐ │ L3 キャッシュ│ ← 数MB〜数十MB、全コア共有、~10-30ns └─────┬─────┘ ┌─────┴──────────────────┐ │ メインメモリ(DRAM) │ ← 数GB〜数百GB、~50-100ns └─────┬──────────────────┘ ┌─────┴──────────────────┐ │ ストレージ(SSD/HDD) │ ← ~10,000ns (SSD) 〜 10,000,000ns (HDD) └───────────────────────┘ 階層 容量 レイテンシ 特徴 レジスタ 数百バイト ~0.3ns CPUが直接演算する場所 L1キャッシュ 32〜64KB/コア ~1ns データ用と命令用に分離。コアごとに専有 L2キャッシュ 256KB〜1MB/コア ~3-10ns コアごとに専有(アーキテクチャによる) L3キャッシュ 数MB〜数十MB ~10-30ns 全コア共有 。ここがコア間のデータの橋渡し TLB 数百〜数千エントリ ~1ns(ヒット時) 仮想アドレス→物理アドレスの変換キャッシュ メインメモリ 数GB〜 ~50-100ns L1の50〜100倍遅い CPUが localOps++ を実行するとき、その変数がレジスタや L1 にあれば 1ns 以下で完了します。しかし L1 にない(キャッシュミス)と L2 → L3 → メインメモリと順にたどる必要があり、最悪100nsかかる。 L1ヒットとメインメモリアクセスでは約100倍の速度差 があるわけです。 TLB(Translation Lookaside Buffer) は少し役割が違って、仮想メモリのアドレス変換を高速化するキャッシュです。プロセスが使うメモリアドレス(仮想アドレス)を実際の物理アドレスに変換するにはページテーブルを引く必要がありますが、毎回引くとメモリアクセスが2倍になるので、よく使う変換結果を TLB にキャッシュしています。TLB ミスが発生すると ページテーブルウォーク が走り、数十nsの追加コストがかかります。goroutine が大量のスタックやヒープを使うワークロードでは、TLB ミスもパフォーマンスに効いてきます。 この前提を踏まえると、マルチコアでのキャッシュ一貫性がなぜ重要かがわかります。 キャッシュコヒーレンシ問題 マルチコアCPUでは、各コアが独自の L1/L2キャッシュ を持っています。あるコアが変数を更新すると、他のコアが持つ同じ変数のキャッシュラインは 古い値(stale) になります。これを放置すると各コアが異なる値を見てしまうため、ハードウェアレベルで一貫性を保つ仕組みが必要です。これが キャッシュコヒーレンシプロトコル (代表的なものに MESI プロトコル )です。 MESI プロトコルでは、キャッシュラインは以下の4状態を遷移します: 状態 意味 M odified 自コアだけが変更済みの値を持つ E xclusive 自コアだけが持っているが、メモリと同じ値 S hared 複数コアが同じ値を持っている(読み取り専用) I nvalid 他コアが更新したので、このキャッシュラインは無効 atomic 変数への書き込みが発生すると: 書き込むコアがキャッシュラインの 排他的所有権 を要求 他の全コアの同じキャッシュラインが Invalid に変わる(無効化) 次にそのコアが同じ変数にアクセスすると、 キャッシュミス → メモリ(or 他コアのキャッシュ)から再取得 これが毎ループ・100 goroutine で発生すると: [NG] 毎回 atomic(キャッシュラインのピンポン) コア1: totalOps.Add(1) → キャッシュライン取得 (Exclusive) → 値を更新 (Modified) → 他の全コアのキャッシュラインが Invalid に コア2: totalOps.Add(1) → Invalid なので再取得が必要(キャッシュミス!) → コア1から転送 → Exclusive → Modified → 他の全コアのキャッシュラインが Invalid に コア3: totalOps.Add(1) → また Invalid → また再取得...(以下ピンポン状態) → 実際のCPU計算ではなく、キャッシュの同期にCPU時間が消える このキャッシュラインの奪い合いは 「キャッシュラインバウンシング」 や 「false sharing」 (同じキャッシュラインに別の変数が乗っている場合)とも呼ばれ、マルチスレッドプログラミングの有名なパフォーマンス落とし穴です。 一方、ローカルカウンタなら: [OK] ローカルカウンタ + 最後に1回だけ atomic コア1: localOps++ → 自コアのレジスタ or L1キャッシュだけ。他コアに影響なし コア2: localOps++ → 同上。各goroutineが独立したメモリを触る ... (終了時だけ totalOps.Add → atomic 操作は10秒間で合計たった100回) 「 ベンチマークそのもののコストでベンチマーク結果が歪む 」のを防ぐテクニックです。cgroup のスロットリングを正確に測るなら、計測のオーバーヘッドは極力削っておきたい。 4. throttle-demoモード — スロットリングの観測 func runThrottleDemo() { // GOMAXPROCS個のワーカーを起動(= OSスレッド数と一致させる) numWorkers := runtime.GOMAXPROCS( 0 ) // Before: スロットリング前の統計を記録 // cpu.stat の nr_throttled, throttled_usec を確認 fmt.Println( "--- Before ---" ) readCgroupStat() // numWorkers個のgoroutineでCPU負荷をかける // GOMAXPROCS=8 なら8本、GOMAXPROCS=1 なら1本 // → スレッド数の違いがスロットリングにどう影響するかを観測 for i := 0 ; i < numWorkers; i++ { go func () { for { cpuIntensiveWork() // 全スレッドでCPU全開 } }() } // 5秒間 CPU負荷をかけた後... // After: スロットリング後の統計を記録 // Before との差分が「この5秒間で発生したスロットリング」 fmt.Println( "--- After ---" ) readCgroupStat() } GOMAXPROCS の値がそのままワーカー数になります。GOMAXPROCS=8 なら8スレッドが同時にCPUを使おうとするので、共有クォータを一瞬で食い潰します。Before/After の throttled_usec の差分で、 実際にどれだけCPUが止められたか がわかります。 Dockerfile # ビルドステージ: Go 1.24 でコンパイル FROM golang:1.24-bookworm AS builder WORKDIR /app COPY go.mod ./ COPY main.go ./ RUN go build -o /app/cgroup-bench . # 実行ステージ: 軽量なイメージで実行 FROM debian:bookworm-slim COPY --from=builder /app/cgroup-bench /usr/local/bin/cgroup-bench ENTRYPOINT ["cgroup-bench"] CMD ["info"] Go バージョンについて Go の最新安定版 : 1.26.3(2026年5月時点) container-aware GOMAXPROCS が導入されたバージョン : Go 1.25 本記事で使うバージョン : Go 1.24(1.25直前の最終版) Go 1.25以降ではランタイムがcgroupの cpu.max を自動で読み取り、GOMAXPROCSをCPU制限に合わせて設定します。今回は 問題が発生していた当時の挙動を再現 するため、あえてGo 1.24を使用しています。 main.go 全文(クリックで展開) ```go package main import ( "encoding/json" "fmt" "math" "os" "runtime" "strconv" "sync" "sync/atomic" "time" ) type Result struct { GOMAXPROCS int `json:"gomaxprocs"` NumCPU int `json:"num_cpu"` CPULimit string `json:"cpu_limit"` Duration time.Duration `json:"duration_ns"` DurationStr string `json:"duration"` TotalOps int64 `json:"total_ops"` OpsPerSec float64 `json:"ops_per_sec"` GoroutineCount int `json:"goroutine_count"` } func cpuIntensiveWork() float64 { result := 0.0 for i := 0; i < 10000; i++ { result += math.Sqrt(float64(i)) * math.Sin(float64(i)) } return result } func main() { mode := "benchmark" if len(os.Args) > 1 { mode = os.Args[1] } switch mode { case "benchmark": runBenchmark() case "info": showRuntimeInfo() case "throttle-demo": runThrottleDemo() } } func showRuntimeInfo() { fmt.Println("=== Go Runtime Information ===") fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Printf("NumCPU: %d\n", runtime.NumCPU()) fmt.Printf("GOVERSION: %s\n", runtime.Version()) envGOMAXPROCS := os.Getenv("GOMAXPROCS") if envGOMAXPROCS == "" { fmt.Println("ENV GOMAXPROCS: (not set — using default)") } else { fmt.Printf("ENV GOMAXPROCS: %s\n", envGOMAXPROCS) } fmt.Println("\n=== cgroup CPU Information ===") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil { fmt.Printf("cpu.max: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.weight"); err == nil { fmt.Printf("cpu.weight: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("cpu.stat:\n%s", string(data)) } } func runBenchmark() { duration := 10 * time.Second if d := os.Getenv("BENCH_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } goroutines := 100 if g := os.Getenv("BENCH_GOROUTINES"); g != "" { if parsed, err := strconv.Atoi(g); err == nil { goroutines = parsed } } maxprocs := runtime.GOMAXPROCS(0) var totalOps atomic.Int64 var wg sync.WaitGroup done := make(chan struct{}) go func() { <-time.After(duration) close(done) }() start := time.Now() for i := 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() localOps := int64(0) for { select { case <-done: totalOps.Add(localOps) return default: cpuIntensiveWork() localOps++ } } }() } wg.Wait() elapsed := time.Since(start) ops := totalOps.Load() opsPerSec := float64(ops) / elapsed.Seconds() fmt.Printf("GOMAXPROCS=%d Ops/sec=%.2f Total=%d\n", maxprocs, opsPerSec, ops) jsonData, _ := json.Marshal(Result{ GOMAXPROCS: maxprocs, OpsPerSec: opsPerSec, TotalOps: ops, }) fmt.Printf("JSON: %s\n", string(jsonData)) if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("\ncpu.stat:\n%s", string(data)) } } func runThrottleDemo() { fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Println("\n--- Before ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } numWorkers := runtime.GOMAXPROCS(0) duration := 5 * time.Second if d := os.Getenv("DEMO_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } var wg sync.WaitGroup stop := make(chan struct{}) go func() { <-time.After(duration) close(stop) }() for i := 0; i < numWorkers; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: cpuIntensiveWork() } } }() } wg.Wait() fmt.Println("\n--- After ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } } ``` Step 3: ビルド docker build -t cgroup-bench go-app/ これで準備完了です。 2. 実験と結果 検証環境: - macOS(Apple Silicon) - Docker Desktop - Docker VM: 11コア (ここがKubernetesの「48コアNode」に相当) 実験1: GOMAXPROCS はコンテナの CPU 制限を無視する 何を確認するか 発表では、コンテナの limits.cpu: 5000m に対して GOMAXPROCS が 48(ノードのコア数)になっていたことが、問題の発端でした。まずは、 GoランタイムがcgroupのCPU制限を見ていない という状態をローカルで確認します。 実行コマンド # A: CPU制限なし docker run --rm cgroup-bench info # B: CPU制限 1コア docker run --rm --cpus=1.0 cgroup-bench info # C: CPU制限 0.5コア docker run --rm --cpus=0.5 cgroup-bench info 実際の結果 A: CPU制限なし === Go Runtime Information === GOMAXPROCS: 11 ← Docker VMの全CPUコア数 NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: max 100000 ← "max" = 上限なし B: CPU制限 1コア( --cpus=1.0 ) === Go Runtime Information === GOMAXPROCS: 11 ← 制限をかけたのに11のまま! NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: 100000 100000 ← cgroupには1コア分の制限が正しく設定されている C: CPU制限 0.5コア( --cpus=0.5 ) === Go Runtime Information === GOMAXPROCS: 11 ← まだ11のまま! NumCPU: 11 === cgroup CPU Information === cpu.max: 50000 100000 ← cgroupには0.5コア分の制限が設定されている 結果を見てみる CPU制限 cpu.max(cgroup) GOMAXPROCS 過剰並列の倍率 なし max 100000 (無制限) 11 - 1コア 100000 100000 11 11倍 0.5コア 50000 100000 11 22倍 完全に無視してます。cgroupには cpu.max として正しくCPU制限が設定されているのに、 Go 1.24のランタイムは一切見ていない 。GOMAXPROCSは常にホスト(Docker VM)のCPU数=11がデフォルト。 発表の本番環境では48コアNodeで limits.cpu: 5000m だったので、 GOMAXPROCS=48(約10倍の過剰並列) が起きていた。ローカルでも同じ構造の問題を再現できました。 cpu.max の読み方 : クォータ ピリオド の形式。ピリオド(デフォルト100ms=100000μs)のうち、クォータ分だけCPUを使える。 50000 100000 なら「100msのうち50ms使用可能 = 0.5コア分」。 実験2: 過剰並列はスループットを低下させる 何を確認するか 発表では GOMAXPROCS を48→5に変えたらスループットが大幅改善、Goスケジューラの CPU使用率が50%以上減ったとのこと。同じ体験をローカルでも数字で見てみます。 実行コマンド CPU制限1コアの環境で、100個のgoroutineを10秒間走らせます。変えるのはGOMAXPROCSだけ。 # GOMAXPROCS=1(CPU制限に一致 = 適切) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=1 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark # GOMAXPROCS=8(CPU制限の8倍 = 過剰並列) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=8 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark 実際の結果 GOMAXPROCS=1(適切な並列数): GOMAXPROCS=1 Ops/sec=21503.63 Total=215525 cpu.stat: nr_periods 101 nr_throttled 56 throttled_usec 43791 GOMAXPROCS=8(過剰並列): GOMAXPROCS=8 Ops/sec=6832.54 Total=68646 cpu.stat: nr_periods 102 nr_throttled 101 throttled_usec 70703432 結果を見てみる 指標 GOMAXPROCS=1 GOMAXPROCS=8 差分 Ops/sec(スループット) 21,504 6,833 68.2% 低下 nr_throttled / nr_periods 56/101 (55%) 101/102 ( 99% ) ほぼ全ピリオドで停止 throttled_usec(累積停止時間) 43,791μs (0.04秒) 70,703,432μs (70.7秒) 1,614倍 正直、ここまで差が出るとは思っていませんでした。 GOMAXPROCS を1→8にするだけで、 スループットが約3分の1に落ちる 10秒間のテストで 累計70.7秒ものCPU停止 (8スレッドが各約8.8秒ずつ止まった計算) スロットリング率99% — ほぼ毎ピリオドで全スレッドが止められている 発表で説明されていた「 クォータをスレッドが共食いする 」現象そのものです。 ┌──────── 1ピリオド (100ms) ────────┐ GOMAXPROCS=1 の場合: [████████ 実行 ████████][░░ 停止 ░░] ← 1スレッドで穏やかに使う GOMAXPROCS=8 の場合: [█ 8スレッド一斉実行 █][░░░░░░░░░░░░░░░░░░░░░░ 長時間停止 ░░░░░░░░░░░░░░░░░░░░░░] ↑ クォータ枯渇 ↑ 全スレッドが同時にスロットリング 実験3: スロットリングの深刻度はスレッド数で変わる 何を確認するか 発表で「 時間も見れば、ピリオドの%が同じでも深刻度の違いが分かる 」と指摘されていました。これ、実際に nr_throttled (回数)は同じくらいなのに throttled_usec (停止時間)には大きな差が出るということなので、自分の目で見てみます。 実行コマンド CPU制限0.5コア(かなり厳しい制限)でGOMAXPROCS=8 vs 1 を比較。 # 過剰並列(GOMAXPROCS=8, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=8 -e DEMO_DURATION=5s cgroup-bench throttle-demo # 適切な並列(GOMAXPROCS=1, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=1 -e DEMO_DURATION=5s cgroup-bench throttle-demo 実際の結果 GOMAXPROCS=8(過剰並列): --- After --- nr_periods 52 nr_throttled 51 ← 98%のピリオドでスロットリング throttled_usec 39039180 ← 39秒のCPU停止 GOMAXPROCS=1(適切): --- After --- nr_periods 51 nr_throttled 50 ← 98%のピリオドでスロットリング(ほぼ同じ!) throttled_usec 2644221 ← 2.6秒のCPU停止 結果を見てみる 指標 GOMAXPROCS=8 GOMAXPROCS=1 差分 nr_throttled / nr_periods 51/52 (98%) 50/51 (98%) ほぼ同じ throttled_usec 39,039,180μs (39秒) 2,644,221μs (2.6秒) 14.8倍 数字を自分で並べてみて、初めて深刻さがわかりました。 nr_throttled の割合(スロットリング率)だけ見るとどっちも98%で全く同じに見えます。でも throttled_usec (実際の停止時間)には14.8倍もの差がある。 これが発表で言われていた「CPU使用率だけでは気づけない」「監視の死角」の正体です。 なぜCPU使用率では見えないのか ここをもう少し掘り下げます。実はこの実験、 どちらのケースもCPU使用率は約100% と表示されます。「え、GOMAXPROCS=8 のほうが遅いのにCPU使用率は同じ?」と思うかもしれませんが、これにはカラクリがあります。 CPU使用率の計算式は本質的にこうです: $$ \text{CPU使用率} = \frac{\text{消費したCPU時間}}{\text{割り当てクォータ}} $$ 今回の実験では --cpus=0.5 なので、1ピリオド(100ms)あたりのクォータは 50ms です。 GOMAXPROCS=1 GOMAXPROCS=8 クォータ 50ms / period 50ms / period 消費CPU時間 50ms(使い切る) 50ms(使い切る) CPU使用率 ≈100% ≈100% 消費ペース 1スレッド × 50ms = 50msかけて徐々に 8スレッド × 6.25ms = 約6msで一気に 残りの時間 50ms間は停止(穏やか) 94ms間 全スレッド凍結 どちらもクォータ50msを使い切るので、CPU使用率は同じ100%です。しかし 消費のペースがまるで違います 。 1ピリオド(100ms)の内訳: GOMAXPROCS=1: |███████████████████████████░░░░░░░░░░░░░░░░░░░| ← 1スレッドで50ms実行 →← 50ms 待機 → CPU使用率: 50/50 = 100% レイテンシ: 安定 GOMAXPROCS=8: |████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| ←6ms→←───────── 94ms 全スレッド凍結 ──────────→ CPU使用率: 50/50 = 100% レイテンシ: スパイク発生 GOMAXPROCS=1 は1スレッドで50msを穏やかに消費するので、処理は途切れつつも比較的均等に進みます。一方 GOMAXPROCS=8 は8スレッドが同時にCPUを要求するため、わずか約6msでクォータを食い尽くし、 残りの94msは全スレッドが完全に凍結 します。 つまりCPU使用率100%の裏で起きていることが全く異なるのに、 集約メトリクスではその違いが消えてしまう 。これが「監視の死角」の本質です。 まとめると: nr_throttled率が同じでも、 8スレッドが同時に止まる のと 1スレッドだけ止まる のでは深刻度がまるで違う CPU使用率は クォータを消費した量 しか示さず、 消費のペース(バースト性)を一切反映しない throttled_usec を合わせて監視しないと、スロットリングの実態はつかめない 実験4: スレッド数と停止時間の相関 何を確認するか スレッド数を段階的に増やしたとき、 throttled_usec が比例して増えるのか。発表スライドの「 クォータはスレッド間で共有 」「 スレッドが多いほど早く消費 」という説明を、グラフで体感してみます。 実行コマンド for MAXPROCS in 1 2 4 8 16; do echo "--- GOMAXPROCS=$MAXPROCS ---" docker run --rm --cpus=1.0 -e GOMAXPROCS=$MAXPROCS -e DEMO_DURATION=5s \ cgroup-bench throttle-demo 2>&1 \ | grep -E "nr_periods|nr_throttled|throttled_usec" | tail -3 echo "" done 実際の結果 --- GOMAXPROCS=1 --- nr_periods 51 nr_throttled 20 throttled_usec 21885 --- GOMAXPROCS=2 --- nr_periods 51 nr_throttled 50 throttled_usec 5075001 --- GOMAXPROCS=4 --- nr_periods 51 nr_throttled 50 throttled_usec 15053306 --- GOMAXPROCS=8 --- nr_periods 52 nr_throttled 51 throttled_usec 35847841 --- GOMAXPROCS=16 --- nr_periods 51 nr_throttled 51 throttled_usec 50338309 結果を見てみる GOMAXPROCS nr_throttled スロットリング率 throttled_usec 累積停止時間 1 20 / 51 39% 21,885 0.02秒 2 50 / 51 98% 5,075,001 5.1秒 4 50 / 51 98% 15,053,306 15.1秒 8 51 / 52 98% 35,847,841 35.8秒 16 51 / 51 100% 50,338,309 50.3秒 停止時間 (秒) 50 ┤ ● GOMAXPROCS=16 │ ╱ 40 ┤ ╱ │ ╱ 35 ┤ ● ╱ GOMAXPROCS=8 │ ╱ ╱ 20 ┤ ╱ │ ╱ 15 ┤ ● ╱ GOMAXPROCS=4 │ ╱ ╱ 10 ┤ ╱ │ ╱ 5 ┤ ● ╱ GOMAXPROCS=2 │ ╲╱ 0 ┤● GOMAXPROCS=1 └──┬──┬──┬──┬──┬──┬── 1 2 4 8 12 16 GOMAXPROCS GOMAXPROCS=1〜8の範囲ではほぼ線形に比例しています。 GOMAXPROCS=1 → 0.02秒(ほぼ停止なし) GOMAXPROCS=16 → 50.3秒(5秒のテストで累計50秒分の停止!) ただし GOMAXPROCS=16 では、同時にCPUを使えるスレッド数が Docker VM の物理CPU数(11コア)で頭打ちになるため、純粋な線形モデルの予測(75秒)より低い50.3秒に飽和しています。16スレッド中、同時に実行できるのは最大11スレッドなので、停止時間は $\min(n, 11) \times P - Q$ に近づきます。 GOMAXPROCS=8 以下では物理CPU数の制約を受けないため、きれいに $n \times P - Q$ の線形モデルと一致しています(8スレッド時の予測35秒 vs 実測35.8秒)。 発表でいう Thundering Herd 問題 そのもので、クォータリセットで全スレッドが一斉に再開 → 共有クォータを瞬殺 → 全スレッド同時停止、のサイクルが繰り返される。 3. 考察: なぜスレッドを増やすと「遅くなる」のか 実験4の結果を改めて見ると、 throttled_usec がスレッド数にほぼ比例して増えています。「スレッドを増やすほど損をする」って、直感に反しますが、CFS 帯域制御の仕組みから数式で説明できます。 CFS 帯域制御の数理 — クォータ消費のモデル cgroup の CPU 制限は「1ピリオド(100ms)あたり $Q$ だけ CPU を使える」というクォータ制です。 --cpus=1.0 なら $Q = 100\text{ms}$ です。 $n$ 本のスレッドが同時にフル稼働すると、CPU 時間は $n$ 倍の速度で消費されます。つまり: クォータ枯渇までの時間 : $\frac{Q}{n}$ 残りのピリオド : 全 $n$ スレッドが同時に停止 1スレッドあたりの停止時間 : $\text{ピリオド} - \frac{Q}{n}$ 累積停止時間 (= throttled_usec ): $n \times \left(\text{ピリオド} - \frac{Q}{n}\right)$ -cpus=1.0 ($Q = 100\text{ms}$、ピリオド $= 100\text{ms}$)で $n = 8$ の場合: クォータ枯渇: $\frac{100}{8} = 12.5\text{ms}$ で使い切る 各スレッドの停止: $100 - 12.5 = 87.5\text{ms}$ 1ピリオドあたりの累積停止: $8 \times 87.5 = 700\text{ms}$ 5秒間(50ピリオド)なら $50 \times 700\text{ms} = 35\text{秒}$。実験4の GOMAXPROCS=8 の結果(35.8秒)とほぼ一致します。 USL で見るとスループット悪化も説明がつく この現象をスケーリング法則の観点から見ると、Neil Gunther の USL(Universal Scalability Law) が当てはまります: $$ S(n) = \frac{n}{1 + \alpha(n-1) + \beta \cdot n(n-1)} $$ パラメータ 意味 cgroup 環境での具体例 $\alpha$ 競合 — 直列化ペナルティ Go スケジューラのロック競合、ランキュー管理 $\beta$ 一貫性 — スレッド間の協調コスト CFS クォータの共有消費 + 全スレッド一斉停止 USL で効いてくるのは $\beta$ の項です。$\beta \cdot n(n-1)$ は $n 2 $ オーダーで増大するため、ある閾値を超えるとスループットが ピークから減少に転じます 。実験2の「GOMAXPROCS=8 で 68% 低下」は、この retrograde(逆行)領域に入った結果です。 スループット ↑ ● ピーク(GOMAXPROCS=1) │ ╱╲ │ ╱ ╲ ← USL の retrograde 領域 │ ╱ ╲ │╱ ╲ ● GOMAXPROCS=8(68%低下) └──────────→ スレッド数 cgroup制限下では「スレッド1本」がピーク。 増やすほどクォータの奪い合いで損をする。 通常の並列プログラミングでは「コア数まではスケールする」のが常識ですが、cgroup でリソースが制限された環境では スレッド1本がすでに最適解 という直感に反する結果になる。CPU 時間の総量が固定されたゼロサム環境なので、スレッドを増やすほど「クォータの奪い合い → 一斉停止 → 再開 → また枯渇」のサイクルが重くなるだけ。 「I/O 待ちがある場合はどうなのか?」 ここまでの実験は cpuIntensiveWork() による 純粋な CPU バウンド処理 です。「I/O 待ちがあるならスレッドを増やしたほうがいいんじゃ?」と思いますよね。一般論としてはその通りで、スレッドが I/O で待っている間は CPU クォータを消費しないので、CPU 数より多いスレッドが有効な場面はあります。 ただし Go の場合は話が別 です。Goランタイムには以下の仕組みがあるので、GOMAXPROCS を I/O のために増やす必要は基本的にないです: I/O の種類 Goランタイムの挙動 GOMAXPROCS への影響 ネットワーク I/O netpoller が非同期処理。goroutine は待つが OS スレッドはブロックしない 影響なし ブロッキング syscall (ファイル I/O, CGO 等) ランタイムが GOMAXPROCS とは 別に追加の OS スレッドを自動生成 影響なし つまり Go では、ネットワーク I/O は goroutine レベルで多重化され、ブロッキング I/O は GOMAXPROCS の枠外で処理される。GOMAXPROCS が制御するのは CPU を実際に使うスレッドの数 だけなので、I/O の多寡に関わらず GOMAXPROCS = CPU 制限 が正解です。 DB クエリや API 呼び出しを大量に行う Web サービスでも、GOMAXPROCS=5(CPU 制限に一致)で大幅に改善した事例があるのは、この仕組みがあるからです。 一方、Go 以外のランタイムでは、それぞれ事情が違うので整理しておきます: ランタイム 並列の仕組み cgroup の影響 Java スレッドプール(ForkJoinPool 等)で並列化。 Runtime.availableProcessors() の値を基準にプールサイズが決まることが多い スレッドプールサイズを CPU limit より大きい値にするとスロットリング発生 Node.js メインスレッドはシングル。 UV_THREADPOOL_SIZE (デフォルト4)で fs/dns 等のブロッキング I/O を処理。CPU バウンド処理は worker_threads で並列化 worker_threads の数を CPU limit より大きい値にするとスロットリング発生 Ruby (CRuby) GVL(Global VM Lock)があるため、スレッドを増やしても CPU バウンド処理は並列実行されない 。Puma 等の Web サーバーは workers (fork によるマルチプロセス)で並列化 Puma の workers を CPU limit より大きい値にするとスロットリング発生 4. 発表の内容をローカルで再現できたか? 全検証結果まとめ # 発表のポイント ローカル検証の結果 再現 1 GOMAXPROCSはcgroupのCPU制限を考慮しない(Go 1.24以前) --cpus=0.5 でも GOMAXPROCS=11(ホストCPU数)のまま 再現 2 limits.cpu は cgroup の cpu.max (クォータ/ピリオド)に変換される --cpus=0.5 → cpu.max: 50000 100000 を確認 再現 3 過剰並列はスループットを低下させる GOMAXPROCS 1→8 で Ops/sec が 68.2% 低下 (21,504 → 6,833) 再現 4 クォータはスレッド間で共有され、多いほど早く消費される スレッド数と throttled_usec がほぼ線形に比例 再現 5 スロットリング時は全スレッドが同時に停止する GOMAXPROCS=16で5秒間に累計50.3秒分の停止を確認 再現 6 CPU使用率だけではスロットリングに気づけない nr_throttled 率は同じ98%でも throttled_usec に14.8倍の差 再現 7 GOMAXPROCSをCPU制限に合わせると改善する GOMAXPROCS=1 で停止時間が 1/1,614 に改善 再現 すべて手元で再現できました。 Docker と Go 1.24 だけでここまで体験できるのは、やってみてよかったと素直に思います。 個人的に印象に残った数字 比較 過剰並列の場合 適切な並列の場合 倍率 Ops/sec(スループット) 6,833 21,504 3.1倍の差 throttled_usec(停止時間) 70.7秒 0.04秒 1,614倍の差 GOMAXPROCS=16の停止時間 50.3秒 - 5秒のテストで50秒停止 本番環境との対応関係 発表 ローカル検証 48コアNode 11コア Docker VM limits.cpu: 5000m --cpus=0.5 GOMAXPROCS=48(デフォルト) GOMAXPROCS=11(デフォルト) 過剰並列倍率: 約10倍 過剰並列倍率: 最大22倍 /sys/fs/cgroup/cpu.max 同じパス(Docker内Linux) cpu.stat の nr_throttled 同じメトリクス 本番ではさらにHAProxy( nbthread=48 , CPU制限1コア = 48倍の過剰並列 )でも同じ問題が起きていたそうで、Goに限った話ではないということがわかります。 まとめ 1. 並列設定を cgroup-aware にする GOMAXPROCS に限らず、 コンテナ内で動くすべてのプロセスの並列設定 は確認したほうがいいです。 ソフトウェア 並列設定 対処 Go GOMAXPROCS Go 1.25+ で自動対応 / 1.24以前は uber-go/automaxprocs Ruby (Puma) WEB_CONCURRENCY / workers CPU制限に合わせて明示指定。cgroup非対応の auto 設定に注意 Java スレッドプールサイズ JDK 10+ は availableProcessors() が cgroup 認識。ライブラリ側も要確認 Node.js worker_threads 数 CPU バウンド処理の並列数を CPU 制限に合わせる HAProxy nbthread 手動でCPU制限に合わせて設定 Nginx worker_processes auto はcgroup非対応の場合あり、明示指定が安全 2. スロットリングを監視する CPU使用率だけじゃなくて、 スロットリングのメトリクスもセットで見る 。これを怠ると実験3で見たような死角にハマります。 メトリクス Linux Datadog 停止時間 throttled_usec kubernetes.cpu.cfs.throttled.seconds 停止ピリオド数 nr_throttled kubernetes.cpu.cfs.throttled.periods 3. throttled_usec まで見る 今回の実験を通して一番の収穫は、 nr_throttled の割合が同じ 98% でも、 throttled_usec に 14.8倍の差がある と自分の手で確認できたこと。スロットリング率だけ見ても、 実際にどれだけ止まっているかは見えない 。 CPUをもっと知りたくなった方へ — 個人的なおすすめ本 今回の検証を通して「もっとCPUの中身を理解したくなった」という方に、個人的に強くおすすめしたい一冊があります。 「プログラマーのためのCPU入門 — CPUは如何にしてソフトウェアを高速に実行するか」 パイプライン、スーパースカラ、分岐予測、キャッシュ、メモリオーダリングといった、 普段は意識しないけれど性能に直結するCPU内部のメカニズム が、プログラマーの目線で一通り整理されている本です。本記事の脱線で触れたキャッシュコヒーレンシまわりも、この本を読むとより腑に落ちると思います。 「なぜこのコードは速いのか/遅いのか」を、ハードウェア寄りの視点から考えられるようになる本なので、cgroup の挙動の先を覗いてみたい方にぴったりです。 参考 ペアーズ本番環境でのcgroup-aware化との死闘録(発表スライド) — 本記事のベースとなった発表 クラウドネイティブ会議2026 セッションページ Container-aware GOMAXPROCS | Go 1.25 Release Notes uber-go/automaxprocs — Go 1.24以前で使えるcgroup-aware GOMAXPROCS Kubernetes CPU limits and requests: A deep dive | Datadog 検証コード 本記事の検証に使ったコードは以下のリポジトリにあります: git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab docker build -t cgroup-bench go-app/ ./scripts/run_experiments.sh # 全実験を一括実行
株式会社タイミーでモバイルエンジニア (Android / iOS) をしている、みかみです。介護領域のグロース施策を中心に、AB テストや分析、マーケティングとの連携などにも取り組んでいます。 2026年4月16日に開催された RevenueCat App Growth Annual Tokyo 2026 (以下 RAGA) に参加してきました。 アプリ成長、サブスクリプション、AI をテーマにしたセッションの中から、個人的に印象に残った話をいくつか紹介します。 RAGA について RAGA (RevenueCat App Growth Annual) は、 RevenueCat が主催する「モバイルアプリ成長」をテーマにしたグローバルカンファレンスです。RevenueCat は、モバイルアプリのサブスク収益化プラットフォームを提供している会社です。 カンファレンスでは「 AI・サブスクリプション・アプリ成長 」をテーマに、プロダクト戦略やユーザー獲得、マネタイゼーション設計といったトピックが扱われていました。登壇者は Notion、ElevenLabs、Speak、YAMAP、SmartNews、Mirrativ、NOT A HOTEL といった国内外のアプリ企業の実践者で、経営層・CPO・CTO クラスが多かったのが特徴的でした。加えて RevenueCat Co-founder / CTO による基調講演もありました。 参加した目的 RAGA に参加したのは、アプリグロースに取り組む他社の現場の話を直接聞ける機会だったからです。日々の業務では、アプリの機能開発だけでなく、AB テスト・分析・マーケ連携にも取り組んでいます。エンジニアリングの先にあるグロース領域への関心も、最近広がってきていました。RAGA で扱われるトピックは、まさに自分の関心と重なる内容でした。 もうひとつ、AI に聞けばなんでも答えてくれる時代だからこそ、現場で一次情報に触れる機会を大事にしたいという考えもありました。後からまとめ記事や録画で追える情報も多いですが、一日を通して様々な実践者の話を続けて聞ける経験は、その場に行かないと得られないと思ったからです。 印象に残ったセッション RAGA は2トラック構成で、世界でアプリを成長させたリーダーが集う「 Global Track 」と、日本市場の実践者や世界進出を目指す起業家が登壇する「 Japan Track 」がありました。自分が参加した中で特に印象に残ったセッションを紹介します。 1. 原点回帰:iモードから現代のアプリ経済へ RevenueCat 共同創業者兼 CTO の Miguel Carranza さんによる基調講演では、日本のモバイル市場の歴史を起点に、現代のアプリ経済が直面する変化が語られました。基調講演の内容や RAGA Tokyo 全体の様子は ProductZine の詳細レポート に詳しいので、ここでは個人的に印象に残った点を中心に紹介します。 冒頭で印象的だったのは、現代のアプリ経済で当たり前になっている概念の多くが日本発だった、という視点です。1999 年の i-mode、絵文字、LINE など、日本のモバイル文化が今のアプリ経済につながっているという話から始まりました。 同時に、日本市場の大きさも具体的な数字で示されました。 日本は世界 3 位のアプリ市場 ユーザー一人あたりの年間支出は $166 2027 年にはスマホユーザーが人口の 94% に達する見込み 一方で、印象に残ったのは「市場が大きい」という話だけではありません。AI によってアプリを作るハードルが下がった結果、過去 4 年間で アプリの供給は7 倍に増え、市場には明確な分断が起きているそうです。上位 25% のアプリは収益を 80% 以上伸ばす一方で、下位 25% は 33% 減少しているという話もありました。 この話を聞いて、アプリ市場は「作れば伸びる」市場ではなくなってきているのだと感じました。AI によって作る力が広がるほど、作れること自体の価値は相対的に下がり、継続して使われる体験をどれだけ設計できるかがより重要になっていくのだと思います。 最後に取り上げられていた 「AI パラドックス」 も、その流れとつながる話でした。AI を組み込んだアプリは初期コンバージョンが強い一方で、解約が約 30% 早く、継続率に課題を抱えるケースが多いそうです。AI の新しさで一度使ってもらうことはできても、継続的な価値に落とし込めなければ LTV にはつながらない、という指摘として受け取りました。 アプリ市場というと、これまで漠然と「大きそうだな」くらいに捉えていましたが、今回の話を聞いて、規模の大きさ以上に競争の中身が変わってきていることを実感しました。ペイウォール設計やトライアル期間、継続率改善といった細部への投資が差を生むという話は、日々のグロース施策を考えるうえでもかなり示唆的でした。 2. 広告視点で考えるサブスクハックネタお披露目会 Repro の中野竜太郎さんと Alethne の坂本翔也さんが、 Adjust の高橋将平さんのモデレートで議論したパネルディスカッションです。広告運用・計測・クリエイティブ最適化など、アプリ成長を広告の視点から見つめてきた登壇者ならではの、現場感の強い話が詰まったセッションでした。 冒頭で出てきたフレーズが強烈です。 "CPI (Cost per install) だけ見る投資はナンセンス。" "エンゲージメントの時代じゃない、アクティベーションの時代だ。" 登壇者たちが共通して強調していたのは、 広告で獲得したユーザーの8割は Day 0 で離脱する という前提です。離脱を防ぐ鍵になるのが 「アハ体験」 、つまりユーザーがアプリの価値を実感する瞬間の設計です。リワードで引っ張ってくるのではなく、自社サービスのコア体験そのものをゲーム化していくのが大事だ、という主張でした。 もう一つ参考になったのが「マジックナンバー」と最適化トリガーの設計の話です。トライアル開始を成果イベントにするのは弱く、「7日間トライアルで辞める気のユーザーは2時間で消える」というデータがあるそうです。そこで、「2時間以上滞在したユーザー」や「特定アクションを N 回実行したユーザー」をマジックナンバーに設定したほうが、広告の最適化トリガーとしても成果が出やすいとのことでした。 AB テストや分析に取り組んでいる身として、「何をイベントとして計測するか」の解像度を一段上げるヒントになる話でした。単に画面遷移を計測するのではなく、ユーザーがそのアプリの価値を実感したシグナルとしてイベントを設計する、という視点が新鮮でした。 3. 激動の時代、日本発メガベンチャーはどのように世界で勝ちに行くのか SmartNews のホン・ランドンさんと Mirrativ の赤川隼一さんによるパネルディスカッションです。コミスマの坂本達夫さんがモデレーターを務めたこの回が、自分にとっては強く印象に残ったセッションでした。プロダクト・グロース・経営の観点から、AI の進化と激化するグローバル市場にどう立ち向かうかが議論されました。 特に印象に残ったのが、グローバル展開の戦略の話です。展開する国の数によって取るべき戦略は変わるそうです。1 カ国に絞るなら徹底した現地化が有効、多国展開なら同一プロダクトを広げて手応えのある市場に集中投資する、という対比でした。いずれにせよ、個別市場の局所最適ではなく、会社全体の収益最大化を基準に手段を選ぶべきだ、という話が腑に落ちました。 セッションの締めくくり、ランドンさんからの「AI 時代に変わるもの・変わらないものは?」という問いに対する赤川さんの答えが、一日で最も刺さった言葉でした。 "変わるもの = ほぼ全部。アンラーニングとスピードが勝負。変わらないもの = 現地現物。" 「優秀な PM は必ず現地でユーザーが迷う姿を観察する」という話でした。AI で機能開発のスピードが上がる時代だからこそ、ただ機能開発を進めるだけではなく、問いを立てる力や、現場やユーザーに直接聞きにいくことの重要性は、むしろ増しているように感じます。 おわりに 参加してみて改めて感じたのは、AI 時代だからといって仕事が 華やか になっているわけではない、ということでした。 各セッションで語られていたのは、AI を使いこなす派手な事例というよりも、その手前にある 地道な計測や粘り強い観察、ユーザーの一次の声を拾い続ける姿勢 だったと感じます。今回語られていた事例のどれもが、こうした地道さの積み重ねの上にあるということを、当たり前のようでいて改めて強く感じました。 AI でプロダクトの作り方は変わっていきますが、アプリを成長させるために向き合う対象は変わらず、むしろ AI が広がるほど、ユーザーをどれだけ深く読めるかがアプリの差として出やすくなっていくのかもしれない、と感じた一日でした。 カンファレンス後のアフターパーティーも独特でした。ライブや DJ セットを交えた構成で、日中のセッション合間にもスタンダップコメディが入るなど、国内の他のカンファレンスと比べても振り切り方が際立っていて、一日を通して印象的でした。
はじめに 株式会社タイミーでデータアナリストをしている平野です。 タイミーキャリアプラス (以下、タイキャリ)という新規事業に、リードDAとして半年あまり伴走してきました。 タイキャリは、タイミーの スキマバイトのデータを起点に、長期就業(正社員採用)を支援する サービスです。タイミーで得た就業実績・バッジを"職務経歴書"として活用できるのが特徴で、2024年2月に開始されています。 新規事業DAの面白さ 0→1のフェーズで、データと事業を一緒に育てていく感覚 を味わえる仕事です。 自分の分析が 事業の意思決定に直結 する瞬間が日常的にある 事業責任者・GM・PdMと机を並べて、 事業の方向づけにデータで参加できる データ基盤・モニタリング・分析テーマを ゼロから設計 できる この記事で書くこと 一方で、新規事業ならではの難しさもあり、半年の間に"知っていれば避けられた"失敗 を数多く経験しました。同時に 再現可能な"型"が存在するとも確信しています。 本記事では、私が踏んだ落とし穴とそこから抽出したアクションを 時系列で整理 します。これから新規事業に入るDAの参考になれば嬉しいです。 Part 1: 新規事業DA特有の3つの難しさ 💡 難しさはPart 2の予防アクション、Part 3の原則とセットで読むと"克服可能な型"として見えてきます。 私が実際に踏んだ落とし穴を3つ共有します。どれも 新規事業DAが共通して直面しうる構造的な問題 で、「誰かが悪い」ではなく、立ち上げ期に起きやすい"あるある"です。DA側がどう動くと摩擦や手戻りを減らせるか、という観点で整理します。 ① 統制構造の有無で難易度が桁違いに変わる DAへの依頼を集約する"要望窓口"(bizops的人材)がいるかどうかで、伴走の難易度が大きく変わります。立ち上げ初期は役割や導線が固まりきっていないため、集約は"自然発生"しないことも多いです。 集約役がいる事業 : 要望が一本化、定義の議論は1対1で済む 窓口体制が立ち上がる前の事業 : DA側が複数の現場と直接やり取りし、 定義確認を個別に進める必要が出てくる 人材紹介領域はbizops担当の方がいてスムーズだった一方、DR領域では 複数の現場と直接やり取りしながら同じ定義の議論を繰り返す ことになりました。 ② DA介入前にプロダクト開発が進んでしまう プロダクト駆動型の新規事業では、スピード重視で DAが合流する前に実装が固まる ことが起きやすく、後から計測設計を整える負担が大きくなります。 DRサービスでは、私が伴走を始めた時点で、 計測設計にDA観点を反映する場がない 状態でした。一気通貫のファネル分析のため、 計測設計の整理を提案する ところから始める必要があったのです。 新規事業の速度感を考えれば自然なこと。だからこそ DA側から早期に合流する関係性を作りに行く ことが重要だと痛感しました。 ③ DA側が組織横断の優先順位付けの場を提案できていない 立ち上げ期は、各担当者が自分の領域の進捗を重視するのは自然(むしろ健全)です。一方で、全体の優先順位を議論する場は用意されていないことも多く、 DA側から立ち上げないと、横断の判断軸を持って動くのが難しくなる と感じました。 私も最初の数ヶ月は会議体を提案できず、 腰を据えるべきテーマに集中しきれない期間 を過ごしました。 Part 2: 時系列アクションリスト 新規事業にDAが入るとき、いつ何をやるべきか を時系列で整理します。各アクションに「なぜそう思ったか」のエピソードを添えました。 🔵 Day 0: アサイン前の意思決定 アクション 内容と効くポイント 🧗 DAをディープダイブ(兼務させない) 新規事業は定義変更・指標追加が高頻度で、半コミットでは事業理解が追いつかない。 特にリードアナリストは他案件と兼務させない ことが、後の伴走の質を大きく左右する 💡 最初からディープダイブ前提でアサインされたことが、信頼関係構築 → 中長期伴走パートナー化につながりました。 🟢 Week 1-4: 関係性構築・PJ立ち上げ 🎯 特に効いた3つ : 関係者ヒアリング / 要望窓口の確認 / ログ設計レビュー合流(プロダクト駆動型) アクション 内容と効くポイント 📋 関係者ヒアリング 事業責任者・GM・現場メンバーに事業の目的・課題・関心事をひとりひとり丁寧に聞く。 この時間投資が後の伴走の土台 になるため、急ぎたい気持ちを抑えても話を聴く価値がある 💬 依頼チャンネル合意 Slack・依頼フォーム等を事業側と合意して「どこに・どの形式で依頼を投げるか」を明確化。受け手のオペが回り、依頼の取りこぼしが減る 🔁 週次定例の継続参加 事業の解像度を大きく上げる手段。 ただし参加だけでは何も変わらない 。「この定例から何のアクションを生むか」を常に自問する姿勢が大事 🚪 要望窓口の確認/提案 集約役不在のままだとPart 1 ①の状況に陥りやすい。いない場合は、GMや事業部のミドルマネージャーに 集約役を担ってもらう依頼導線の設計 をDA側から提案 🔍 ログ設計レビュー合流 プロダクト仕様検討・ログ設計レビューの場にDAが入れるよう、 開発開始前から合流 しておく。これを逃すとPart 1 ②の罠にはまる 📚 ナレッジのGit管理 AIコーディングツール(Claude Code・Cursor・Devin等)が参照できる形でナレッジを蓄積。 分析の壁打ち・クエリ作成の工数が劇的に削減 されるため、アサイン初期の最優先投資 ⚠️ 前任期に「定例参加だけでアクションに繋がらない」時期がありました。 参加は手段、目的ではない 。 🟡 Month 1-3: 目先の困りごと解決期(信頼残高を貯める) 🎯 特に効いた3つ : 爆速対応+問い返しの両立 / 要件定義を向き合いと一緒に言語化 / 基盤整備のタイミング評価 アクション 内容と効くポイント ⚡ 爆速対応 + 「なぜ/必要性/定義」の問い返し 初動の爆速対応は信頼関係構築の重要戦略。「このDAは価値がある」と認識されると、後のポジション変更が楽になる。 ただし依頼対応に留まり続けると"依頼を捌く人"として固定 されるため、「なぜやるか/本当に必要か/定義は適切か」を毎回問い返す姿勢を早期から取り入れる。 信頼関係構築と「言うべきことを言う」は両立可能 ✏️ 要件定義は向き合いと一緒にテキスト化 ダッシュボードやレポート作成の依頼時、「どの目的で、どの観点で、なぜ見るのか」を 向き合いと一緒に書き残せるレベル で整理。これを省くと要件が固まる前に着手することになり、双方にとって手戻りが発生しやすい 🏗️ データ基盤整備のタイミングを常に評価 早すぎても空振り、遅すぎても移行コストが跳ね上がるのが基盤整備の難しさ。 スプシ主体のモニタリングが積み上がってから着手すると、現行オペとの二重管理で身動きが取りにくくなる 🎁 外注保守カット案件は中長期運用主体を確認 「外注の保守費用削減のためにDA側で巻き取る」相談は、一度立ち止まって 中長期の運用主体を一緒に確認したい 類の案件。自分が異動・退職したときの移管コストが高く、長期の保守タスクとして残り続けやすい。 特にdbt等が絡むものは要注意 💡 私はこの切り替えが遅れ、後から上流に意見を出し始めた際に向き合いとの期待値調整が必要になりました。 ⚠️ 整備のタイミングをDA側から提案しきれず、暫定的な参照・運用が一部残っています。 軽いうちに議論を持ち出す ことが重要。 🟠 Month 3-6: 中長期伴走パートナーへのギアチェンジ 🎯 特に効いた3つ : 事業責任者+GM+bizops週次MTG立ち上げ / OKR策定シンクロ / 能動的価値発揮へシフト アクション 内容と効くポイント 🗓️ 事業責任者+GM+bizops 週次MTG立ち上げ 組織全体で優先順位を棚卸しする会議体をDA側から提案して立ち上げると、 横断の判断軸を持って動けるようになる 。この会議体は、2事業目が入ったときの司令塔にもなる 🎯 OKR策定タイミングにシンクロ 期初のOKR策定に合流して、事業側と「何を課題と捉えるか」をすり合わせ。これをやると、その後の依頼について 目的を事前にすり合わせた状態 で動けるようになる 🔀 GM経由のコミュニケーションフロー 現場の複数メンバーからは似た観点の依頼がそれぞれ届くため、個別にすり合わせていると工数が膨らむ。 GM(またはbizops・集約役)経由のフローに整える と、定義の議論が一つの場に集約され、双方のコミュニケーションコストが抑えられる 🎤 能動的価値発揮へシフト 受け身で依頼を捌くフェーズから、 DA起点で価値を提案するフェーズ へ。例: 現場担当者(CA等)へのインタビュー設計で定性課題を抽出 / 事業責任者・GMが意思決定に使う経営ダッシュボードをDA起点で設計・提案 💡 ここからが 新規事業DAの本領発揮ゾーン 。事業の意思決定に直接関われるようになり、 自分の分析・提案が事業の方向づけにつながる手触り感 を強く感じられます。 🔴 2事業目が追加されるタイミング 🎯 特に効いた3つ : 熱量シグナル検知→能動介入 / ログ設計レビュー最優先 / 要望窓口の最初からの設計 アクション 内容と効くポイント 🌡️ 熱量シグナル検知 → 能動介入 新しい領域が熱を帯びる瞬間(目標変更・予算変更等)を DAが能動的にキャッチして動く ことが重要。待っていると後手に回り、Part 1 ②の罠(介入前に実装が固まる)にはまる 🔍 ログ設計レビューを最優先 プロダクト駆動型事業なら必須。 Part 1 ②を2度繰り返さない ためにも、最初期から計測設計のレビューに入る関係性を作りに行く 🚪 要望窓口の体制を最初から設計 1事業目の学び(GM経由フロー)を即座に転用。人材紹介領域で「GM経由フロー」が効くと学んだので、DR領域では 初期から意識的にGM経由フローを設計 した 🗓️ 横断の優先順位会議体を再設計 2つ目以降の事業が入るタイミングを 会議体再設計のトリガー にする。1事業目向けの会議体を拡張して、横断優先順位を扱える形に変えていく 💡 DR領域で目標が跳ね上がる動きを検知し、自分から事業責任者+PdMに働きかけて週次定例への参加を勝ち取りました。 これがなければ今の伴走体制は立ち上がっていません 。 Part 3: 全体を貫く2つの原則 これまでの話は 2つの原則 に集約できます。 原則① 未来を見据えた動きをせよ 「今が楽に回るから」で意思決定しない。 半年後のオペレーション・組織・関係性を想像して、初動で先手を打ちます。 データ基盤整備のタイミングを早めに見定める 初期から事業責任者との接点を作る KPIや優先順位の構造を、組織が大きくなる前に整理する 「目先の困りごと解決 → 中長期伴走パートナー」の移行を意図的にデザインする 新規事業は成長スピードが速く、 "今困っていないこと"が半年後に大きなコスト として返ってくることが頻繁に起きます。 原則② 統制構造(要望窓口)を早期に組め 現場にbizops相当の集約役がいるかで、新規事業DAの難易度は桁違いに変わります。 いなければDA側から「誰が集約役を担うか」を提案するのが、DAの重要な仕事です。 統制構造が組めると、依頼フロー・優先順位付け・コミュニケーション工数の すべてが好転 。逆にここが組めないと、どんなに優秀なDAでも個別対応だけで手が回りきらなくなります。 おわりに 落とし穴を中心に書きましたが、新規事業のDA伴走は半年経った今、 自分のキャリアで最も学びと手触り感のあるフェーズ だと感じています。「新規事業DA、ちょっと面白そう」と感じてもらえたら本望です。 最後に改めて、本記事の失敗エピソードはすべて、 私自身(DA)の動きで改善できる余地について書いたもの であり、事業側の皆さんを批判する意図はありません。 急成長する新規事業の中で常に最善を尽くしてくださっているCareer Plus部の皆さんと、前任のDA・bizopsの方々に深く感謝 しています。 この役回りに関する再現可能なノウハウはまだ少ない領域です。 多くのDAが同じ試行錯誤を繰り返している 現状が変わるきっかけになれば嬉しいです。 最後に、タイミーでは一緒に働くメンバーを募集してます!ご興味があればぜひお話しましょう! プロダクト採用サイトTOP カジュアル面談申込は こちら
こんにちは。株式会社タイミーのバックエンドエンジニアの神山( @ dak2 )です。 2026/4/22 から 24 まで函館で開催された RubyKaigi 2026 に参加し、Day 2 に登壇しました。 タイミーでは、世界中で開催される技術系カンファレンスに無制限で参加できる「 Kaigi Pass 」という制度を活用し、8名が現地でカンファレンスに参加してきました。 登壇内容や参加セッションで得た学びは各レポートにまとめています。気になった方はご覧いただけると幸いです。読者の皆様の今後の学びの参考になれば嬉しいです。 tech.timee.co.jp tech.timee.co.jp tech.timee.co.jp Road To RubyKaigi 2026 地震や飛行機の遅延などもありましたね。皆様の中にも、移動に戸惑った方いらっしゃったのではないでしょうか。 色々大変でしたが、参加者の方々が無事到着されたのを X(旧Twitter)で見て、一安心したのを覚えています。 印象に残ったセッション A Faster FFI rubykaigi.org 自分が作っている gem で Ruby と Rust を使っており、FFI 周りが気になっていたので聞きました。 FFIは、引数の数の確認や型変換(アンボックス)を実行時に行う必要があるため、C拡張に比べてオーバーヘッドが大きいようです。また、ピュアなRubyで書かれたJITコンパイラのFJITはC拡張より速いものの、CRubyの内部データ構造に強く依存していました。そのため仕様変更に弱く、実用的ではなかったとのことです。 FJIT これですね。 fjit.rb · GitHub これに加え、アーロンは hacks gem というものを作っていて、そこから CRuby の 構造体を引っ張ってきて FJIT で使っているみたい。AST をダンプして構造体の名前を元にデータ構造を抽出、メモリのレイアウト情報を計算して Ruby のハッシュに格納して返してるみたいですね。 面白いなー。すごい力技だw FJIT はこのハッシュに依存しているから実用的ではなかったとのこと。 新たな解決策として ffx という gem を作ったみたいです。 Ruby の値を C に変換してネイティブ関数を呼ぶ impl 関数と、実際のコードへジャンプするトランポリン関数を生成。トランポリン関数を生成する際に、アセンブリに impl 関数への jump 命令を書いておき、その次のメモリ領域にマジックマーカーや型情報、パラメータなどのメタデータを書き込んでおく。 通常の実行時には impl 関数へ jump するので、型情報などは読み込まれないけど、ZJIT でのコンパイル時にはマジックマーカーを検知してメタデータを読み取って最適化に使うとのこと。 このハックは単純にすごいなと思いました。感心しながら聞いていたのを覚えています。勉強になります。個人的に印象に残るセッションでした。 Lightning-Fast Method Calls with Ruby 4.1 ZJIT rubykaigi.org speakerdeck.com 行く末が気になる ZJIT ですね。速さは正義ということで、とても期待しています。そこで、このセッションを聞きに行きました。 今の ZJIT はメソッド呼び出し時に全てのパラメータを一度メモリにロードしてしまう課題があるみたいですね。 バックトレースや例外処理、ローカル変数読み込みなどでスタック上に正確なメタデータを残しておく必要があるためとのこと。 これに対し、 Lightweight Frames が提案されていました。 メタデータの書き込みを遅延させ、書き込むフィールドを「JIT Return」の1つなど最小限に限定しつつ、メソッドコール時のメモリライトを削減すると。ただ、ローカル変数の処理や例外時の longjmp などをどうにかしていかないといけないみたいですね。帰ってきてから ZJIT の内部を読んでみています。 Matz Keynote なんか Matz が一番楽しそうだったなあと思う Keynote でした。Ruby という言語を作ってなお、まだ作りたいものがある。AI と一緒に作った Spinel を発表している Matz が楽しそうだった。AI Slop とか色々ありますが、作りたいものを作るって最高ですよね。最高にクールでした。非常にインスピレーションを受けた Keynote でした。 matz リツイート 登壇 登壇時の写真 special thanks to @ginkouno rubykaigi.org speakerdeck.com AI 時代の Ruby では、NoMethodError を静的に解析できたら良いのではないか、という内容で登壇しました。登壇は想像していた何倍も得るものがありました。 きっかけは「日常の疑問」だった このトークの種は、普段の業務で感じた引っかかりでした。型アノテーション付きの Ruby とそうでない Ruby を行き来していると、どうしても型チェックの遅さが気になります。一方で、AI Agent のコーディング能力が一気に上がったことで、「そもそも型をちゃんと書いていく ROI ってどうなんだっけ?」という疑問が、頭の片隅から離れませんでした。 この「気になり」を寝かせていたところ、CFP が1週間延長になった当日、サウナで急に像を結びました。よく「サウナで整うと閃く」と言いますが、実際は逆で、 普段から問題意識を温めていたからこそ、たまたま緩んだ瞬間に繋がったのかな と思っています。整うどころではなくなり、速攻で帰りました。その後、寝る間も惜しんでアイディアを形にし、CFP を提出したのが懐かしいです。 「型ありの Ruby と型なしの Ruby、両方を日常的に書いている自分だからこそ立てられる問い」だと思えたことが、提出のモチベーションになりました。自分の業務の中の違和感は、思っている以上にトークの種になるんだなと。 gem を作って「持論」を形にした セッション内で発表した Method-Ray という gem は、コアロジックに Rust を用いています。命名は X-Ray から着想を得ていて、個人的にも気に入っています。「こうすればできるのでは」という仮説をもとに設計し、最小限で動くものを形にし、gem として公開しました。僕の持論やスタンスを gem に込めた上で CFP を提出しました。 株式会社mov の @pjocprac さんが型関連のセッション内容をまとめてくださっており、僕の発表内容にも触れてくださっているので、詳細に興味があればぜひご覧ください。 RubyKaigiで型まわりの内容をまとめたブログ書きました! RubyKaigi 2026 型まわり4セッション聴講メモ — AI コーディング時代の Ruby の型 #RubyKaigi https://t.co/HZB5PJW7z0 — Takeshi Watanabe (@pjocprac) 2026年4月27日 登壇して初めて得られた3つのもの 発表後、いろんな方に声をかけていただきました。「ここの型解析どうしてる?」「なんでそう思ったんですか?」「英語話せるんですか?」などたくさんお声がけいただき嬉しかったです。 自分の盲点を埋めるフィードバック 質問内容を受けて「自分の gem もこう直さないといけないな」と気づきが連鎖的に出てきました。一人だと気づきにくい視点に気づかせてくれるというのは良い機会だなと。 自分のスタンスを認識する 今回はスタンスを取った発表をしたのですが、それに対していろんな意見をもらえました。賛否両論あると思いますし、いろんな意見があると思いますが、同時に自分の立ち位置もはっきりしました。スタンスを取った発表をして良かったなと思っています。 アイデアの昇華 @okuramasafumi さんとは、いわゆる廊下の話で、「テストが十分速ければ AI が PDCA を回しやすくなって、それは型解析の近似になっているのでは」という方向性を議論しました。登壇内容を、登壇の外で次のテーマへと押し進めてもらえる感覚があり、これは登壇したからこそ発生した会話なんだと思うと、良い機会に恵まれたなと嬉しく思いました。 次にやりたいこと 今後は、 Method-Ray の解析範囲を広げたいのと、RBS の Rust crate の利便性を高めたいですね。自分の gem で RBS のロードを Rust crate から行いたいなと思っています。また、廊下での会話の流れもあって、高速化にも気持ちが出てきています。登壇を経て、自分の中の「次にやること」のリストが、行く前より大きくなっています。 終わりに 今年の Kaigi も最高でした。運営の方々、本当に素敵な機会をありがとうございました。いろんな方と知り合えましたし、思考を深められました。ぼんやりと次にやりたいことが見えてきました。 次の RubyKaigi は宮崎 ですね。次も楽しんで行きましょう!(帰りに青森、秋田に旅行したんですが、それ含め諸々個人ブログで感情を書こうと思います)
はじめに はじめまして、プラットフォームエンジニアリング本部に所属している徳富( @yannKazu1 )です。 みなさん、サプライチェーン攻撃って気にしてますか? npm パッケージの乗っ取り( ua-parser-js 事件 )、GitHub Actions の改ざん( tj-actions/changed-files 事件 )、依存パッケージへのバックドア混入( xz-utils 事件 )……。ここ数年、OSS を取り巻くセキュリティの前提がガラッと変わってきています。正直、「いつ・どこから仕掛けられるかわからない」状況です。 しかもサプライチェーン攻撃って、攻撃側のコストが低いわりに被害範囲が広いのが厄介なんですよね。 そんなわけで、ECS Fargate 環境におけるサプライチェーン攻撃対策を整理してみようと思ったのですが、いきなり全部を洗い出そうとしてもカオスになるだけ。何かいいフレームワークはないかな……と探していたところ、Kubernetes の 4C セキュリティモデル(Cloud → Cluster → Container → Code) の考え方がそのまま使えそうだったので、これをベースにチェックシート的に整理してみました。 「うちの環境だとどこが手薄いんだろう?」を考えるときの参考にしてもらえればと思います。 おことわり: これをやれば完璧!というものではないです。あくまで「見通しよく整理するための道具」として 4C モデルを借りているだけなので、実際にどこまでやるかは環境やリスク許容度に応じて取捨選択してください。 整理に使う 2 つの軸 軸 1:4C セキュリティモデル —「どこを守るか」 Kubernetes の公式ドキュメントで紹介されている、クラウドネイティブセキュリティを 4 つの同心円レイヤー で捉えるモデルです。 参考: クラウドネイティブセキュリティの概要 | Kubernetes ┌─────────────────────────────────────────┐ │ Cloud(クラウド基盤) │ │ ┌─────────────────────────────────────┐ │ │ │ Cluster(オーケストレーター) │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ Container(コンテナランタイム) │ │ │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ │ │ Code(アプリケーション) │ │ │ │ │ │ │ └─────────────────────────────┘ │ │ │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘ ポイントは 各レイヤーが外側のレイヤーの上に構築されている ということ。どれだけアプリのコードを堅牢にしても、基盤レイヤーのセキュリティが低い水準では守りきれません。だからこそ、特定のレイヤーだけに頼るのではなく、すべてのレイヤーを固める 多層防御(Defense in Depth) が基本方針になります。 ECS Fargate への読み替えはこんな感じです。 4C レイヤー K8s での意味 ECS Fargate での対応 サプライチェーン攻撃での主な攻撃面 Cloud クラウド基盤 AWS アカウント・IAM・ネットワーク IAM キー漏洩、ECR への不正 push Cluster オーケストレーター ECS クラスター・CI/CD パイプライン CI/CD アクションの改ざん、パイプライン侵入 Container コンテナランタイム Docker イメージ・Fargate タスク ベースイメージの汚染、OS パッケージへのバックドア混入、実行時の不正プロセス Code アプリケーションコード ソースコード・依存パッケージ パッケージ乗っ取り、typosquatting、悪意ある PR 軸 2:対策の目的 —「何のために守るか」 4C モデルは「どこを守るか」を整理するフレームワークですが、それだけだと対策が偏りがちです。そこでもうひとつ、 「何のために守るか」 という軸を加えてみます。今回は、セキュリティ対策を以下の 4 つの目的に分類して整理してみました。 目的 説明 考え方 🛡 予防(Prevention) 攻撃を未然に防ぐ そもそも悪いものを入れさせない 🔍 検知(Detection) 攻撃や脆弱性を発見する 入り込んだ・紛れ込んだことに気づく 🧱 封じ込め(Containment) 侵入後の被害を最小化する やられても被害を広げさせない 🔎 調査(Investigation) 何が起きたかを追跡する 事後に原因と影響範囲を特定する よくある落とし穴は 「予防」ばかりに意識が向いて、他が手薄になる こと。完璧な予防は不可能なので、入り込まれた後にどう気づいて・どう被害を抑えて・どう調べるか、まで含めて考えるのが多層防御の本質です。 この記事の構成 本記事では 目的(予防・検知・封じ込め・調査)を大項目 にして、それぞれの中で 4C のどのレイヤーに対する対策か を整理していきます。 🛡 予防(Prevention)— そもそも入れさせない 攻撃を未然に防ぐための対策です。「入口を塞ぐ」イメージですね。 Cloud:VPC Endpoint 概要: AWS サービスへの通信をインターネットを経由せずに VPC 内で完結させる。 防げる攻撃: 侵害されたタスクからの外部 AWS アカウントへのデータ持ち出し (Endpoint Policy で自社アカウントに限定) マルウェア感染後の C2 通信・情報送信 (Egress 全遮断下でも AWS サービスは利用可能) 漏洩した IAM 認証情報による外部からの不正アクセス (バケットポリシーで aws:SourceVpce を指定) 設定のポイント: S3 Gateway Endpoint(無料)は必須 ECR、SSM、Secrets Manager、CloudWatch Logs 用の Interface Endpoint を検討 Endpoint Policy で aws:PrincipalAccount を制限 リソース側ポリシーで aws:SourceVpce を指定 Cluster:CI/CD パイプラインのハードニング 概要: GitHub Actions など CI/CD で使うサードパーティアクションを、改ざんされない形で固定する。 防げる攻撃: GitHub Actions の改ざん (tj-actions/changed-files 事件のように、人気アクションのリポジトリが侵害されてタグが書き換えられるケース) バージョンタグの上書きによる 意図しないコードの実行 設定のポイント: GitHub Actions は通常 uses: actions/checkout@v4 のようにタグやブランチで指定しますが、これらは 後から書き換え可能 です。tj-actions/changed-files 事件(2025 年 3 月)では、攻撃者がメンテナーの認証情報を侵害し、既存タグを悪意あるコミットに向け直すことで、汚染されたアクションを使う CI でシークレットがビルドログに書き出されるという被害が広範囲に発生しました。一方、 commit SHA でピンニングしていたユーザーは影響を受けませんでした (侵害期間中に対象 SHA へ更新していなければ)。 対策として、 commit SHA でピンニングする のが推奨されます。 # Before(タグ指定 - 書き換えられる可能性あり) - uses : actions/checkout@v4 # After(commit SHA でピンニング - 改ざんされない) - uses : actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 Dependabot や Renovate Bot で SHA の自動更新を設定 permissions: で各ジョブの GITHUB_TOKEN 権限を最小化 OIDC を使った AWS 認証に切り替え、長期クレデンシャルを廃止 パブリックリポジトリでは特に注意:ビルドログが公開されるため、ログ経由のシークレット漏洩のインパクトが大きい サプライチェーン攻撃との関連: これは Container レイヤーの digest ピンニングと同じ思想です。「同じ名前で違うものを掴まされる」攻撃を防ぐには、暗号学的なハッシュで内容を固定するのが基本になります。 Cluster:Secrets の安全な注入 概要: DB パスワードや API キー等の機密情報を、コードへの直書きではなく SSM Parameter Store や Secrets Manager から注入する。 防げる攻撃: ソースコードやコンテナイメージへのシークレット埋め込み を防止 Git リポジトリの漏洩時に クレデンシャルが直接露出するリスク を排除 KMS 暗号化により、AWS アカウントが侵害されても 暗号化キーなしでは復号不可能 設定のポイント: " secrets ": [ { " name ": " DATABASE_PASSWORD ", " valueFrom ": " /fargate/myapp/database-password " } ] Cluster:ECS Exec の制御 概要: ECS Exec(コンテナへの対話的アクセス)を必要時以外は無効化する。 防げる攻撃: IAM クレデンシャルが漏洩した場合の コンテナへの直接侵入 本番コンテナへの 不正なコマンド実行 設定のポイント: ECS Exec の制御は、サービス (または RunTask 呼び出し)レベル と IAM レベル の二重で行うのが確実です。 enableExecuteCommand はタスク定義のフィールドではなく、サービス(CreateService / UpdateService)または RunTask のパラメータです。 サービス定義(または RunTask 呼び出し)で無効化 : enableExecuteCommand = false を明示。そもそもサービス側で受け付けない状態にしておく。AWS CLI でも aws ecs update-service --no-enable-execute-command のように切り替え可能です。 IAM で ecs:ExecuteCommand を Deny :ECS Exec 専用の API なので、これを Deny するのが最も直接的。なお ECS Exec は内部的に SSM Session Manager の通信レイヤー(ssmmessages API)を利用するため、より厳密に制御したい場合は、タスクロールに ssmmessages:* 系の権限が紛れ込んでいないかも確認する 必要時のみ一時的に有効化する運用フロー :踏み台用の専用サービスを別途用意し、調査時のみそちらを起動する運用が安全。 補足: 後述の封じ込めセクションで紹介する readonlyRootFilesystem: true と ECS Exec は 両立しない 点に注意してください。SSM agent がコンテナファイルシステムへの書き込みを必要とするため、ルートファイルシステムを読み取り専用にすると ECS Exec が動きません(AWS 公式ドキュメントでも明記されています)。本番では readonlyRootFilesystem: true + ECS Exec 無効化、調査用の踏み台サービスでは ECS Exec 有効化、と用途で使い分けるのが現実的です。 Container:ベースイメージの digest ピンニング 概要: Dockerfile のベースイメージ指定を、タグだけでなく digest(SHA256 ハッシュ)で固定する。 防げる攻撃: ベースイメージのタグ上書き攻撃 (同一タグに悪意あるイメージを push) レジストリが侵害された場合の イメージ改ざんの検知 設定例: # Before(タグのみ) FROM ruby:3.3.0-bookworm # After(digest ピンニング) FROM ruby:3.3.0-bookworm@sha256:2e1e76e5b2... 運用のポイント: Dependabot や Renovate Bot で digest の自動更新を設定する digest がまだ付いていないイメージに digest を自動付与する pinning 自体は、現時点では Renovate のほうが運用面で先行 ( pinDigests プリセット)。Dependabot 側でも 2026 年 2 月に PR #14071 が dependabot-core 本体にマージされ、 docker_pin_digests という experiment flag として実装されました。ただし experiment flag は Dependabot サービス側で段階的に展開されるため、GitHub.com の Dependabot で「タグだけのイメージに digest を新規付与する挙動」がデフォルトで使えるかはタイミング次第です(フラグの有効化状況によっては自分の環境で効かないこともある点に注意)。 現時点で「すでに digest 付き」のイメージに対する digest 更新は Dependabot でも問題なくできます。タグだけで運用しているイメージを一括で digest ピン化したい、もしくは version と digest の同時更新・グルーピングや自動マージ条件など細かい制御をしたい場合は、Renovate を選ぶのが堅実です。 サプライチェーン攻撃との関連: Docker Hub のアカウントが侵害されて、同一タグに悪意あるイメージが push されることがあります。digest ピンニングをしておけば、たとえタグが上書きされても意図しないイメージを引っ張ってくることがなくなります。 Container:ECR イミュータブルタグ 概要: ECR リポジトリのタグを上書き不可(IMMUTABLE)に設定する。サプライチェーン攻撃対策の観点では 基本的に有効化しておくべき 設定です。 防げる攻撃: 既存タグへの 悪意あるイメージの上書き CI/CD パイプラインが侵害された際の イメージ差し替え 設定のポイント: ECR のタグ mutability 設定は、 2025 年 7 月 23 日のアップデート で以下の 4 モードに拡張されました。 モード 挙動 MUTABLE すべてのタグを上書き可(従来) IMMUTABLE すべてのタグを上書き不可(従来) MUTABLE_WITH_EXCLUSION デフォルト mutable、特定タグのみ immutable IMMUTABLE_WITH_EXCLUSION デフォルト immutable、特定タグ(例: latest )のみ mutable これにより、たとえば「 本番用の v1.2.3 のようなセマンティックタグや git SHA タグは IMMUTABLE で固めつつ、 latest だけは上書き可能にしておきたい 」という現実的な要件にも対応できるようになりました。 IMMUTABLE 化(または IMMUTABLE_WITH_EXCLUSION )すると同じタグでの再 push ができなくなるため、デプロイフローを以下のいずれかに合わせる必要があります。 タグを毎回ユニークにする : repo:v1.0.1 、 repo:gitsha-abc1234 のように、デプロイのたびに別タグを振る。既存のタグベースのデプロイフローを大きく変えずに済むので導入しやすい。 digest ベースのデプロイに移行する :ecspresso 等で image: repo:tag → image: repo@sha256:xxxxx に変更。改ざん検知の観点でもより堅牢で、究極的にはこちらが望ましい。 IMMUTABLE_WITH_EXCLUSION を活用する : latest のような可変運用が必要なタグだけを除外し、それ以外のタグは IMMUTABLE で固める。 まずは (1) のタグユニーク化で IMMUTABLE 化(必要に応じて (3) で latest を除外) から始めて、余裕があれば (2) の digest ベースに進化させていく、というステップ方式が現実的です。 注意: 現在 MUTABLE で運用している場合、デプロイ失敗時のイメージ再 push に依存していないか事前に確認してください。CI のリトライ等でタグの上書きが発生する構成だと、IMMUTABLE 化で詰まります。 Container:イメージ署名(AWS Signer / ECR Managed Signing) 概要: CI でビルドしたイメージに暗号署名を付与し、デプロイ時に署名を検証する。 防げる攻撃: ECR リポジトリが侵害された場合の 未署名イメージのデプロイ CI パイプラインをバイパスした 不正なイメージの注入 「CI でビルドされたイメージと本番で動くイメージが同一である」ことの 暗号学的な保証 実現方法: ECS には EKS の admission controller のような署名検証機構が長らくネイティブには用意されていませんでしたが、近年は AWS 側でも仕組みが整ってきています。代表的なパターンは以下の通り。 (1) 署名フェーズ : (a) Amazon ECR Managed Signing を使う(2025 年 11 月 21 日 GA、AWS の推奨アプローチ) :ECR にシグニングルールを登録しておくと、push 時に ECR が AWS Signer を呼んで自動で署名してくれる。クライアント側に Notation を入れる必要がなく、署名キーやその証明書ライフサイクルも AWS が完全マネージドで管理する。新規導入ならまずこちらを検討するのが筋。 (b) 自前で notation sign を実行(manual signing) :CI のビルドステップでイメージ push 後に Notation CLI + AWS Signer プラグインで署名する。署名タイミングや対象を細かく制御したい場合に選択。プラットフォーム ID は Notation-OCI-SHA384-ECDSA 。 いずれの場合も 署名キー自体は AWS Signer が完全マネージドで管理するため、利用者が KMS キー等を別途用意する必要はありません 。 (2) 検証フェーズ : (a) ECS Service Lifecycle Hook(PRE_SCALE_UP)で Lambda を呼び、 notation verify を実行 :ECS のサービスデプロイ時にイメージ署名を検証し、失敗時はデプロイをブロックできる。 現在 AWS が公開している ECS 向け公式パターンであり、 BLOCK_ON_FAILURE (厳格モード)と LOG_ON_FAILURE (監査のみモード)の両方をサポート 。 2025 年 7 月 18 日の ECS ネイティブ Blue/Green デプロイメント GA 以降、PRE_SCALE_UP hook は rolling update 戦略でも利用可能 (rolling update で使えるのは PRE_SCALE_UP のみで、それ以外の hook は Blue/Green 専用)。 (b) EventBridge → Lambda で push 直後に検証 :監査・アラート用途。Lifecycle Hook と違ってデプロイ自体はブロックできない非同期パターン。 (c) CodePipeline / CI のデプロイ前ステップで notation verify を実行 :パイプラインの中でブロックする方式。 注意: ECR イミュータブルタグと組み合わせると効果が高まります(タグの差し替え + 署名なしイメージのデプロイの両方を防げる)。Lifecycle Hook ベースの検証はデプロイをブロックできるため、本気の防御として AWS が現在推奨している実装パターンです。実装工数はそれなりにかかるので、リスク許容度と相談して導入判断するのがよいでしょう。まずは EventBridge 経由のアラート運用(監査)から始めて、本格運用で Lifecycle Hook に移行する、というステップでも問題ありません。 EKS との対比(参考): EKS の場合は Kubernetes admission controller(Kyverno、OPA Gatekeeper、Connaisseur 等)を使って、Pod 起動前に署名を検証するのが定石です。ECS Service Lifecycle Hook はこれに相当する仕組みを ECS 側で提供する位置づけと考えると理解しやすいです。 🔍 検知(Detection)— 入り込んだことに気づく 予防を突破された場合に、異常や脆弱性を発見するための対策です。 Cloud:GuardDuty + ECS Runtime Monitoring 概要: AWS 環境全体の脅威検知サービス。ECS Runtime Monitoring を有効にすると、Fargate タスク内の不審な振る舞いをリアルタイムで検知できる(ECS Fargate 向けは re:Invent 2023 で一般提供開始)。 防げる攻撃: コンテナ内での クリプトマイニングプロセスの実行 C2(Command & Control)サーバーへの通信 コンテナ内での リバースシェルの起動 既知のマルウェアバイナリの実行 設定のポイント: Fargate の場合、セキュリティエージェントは GuardDuty が自動でサイドカーとしてデプロイするため、タスク定義の変更は不要 Organizations の委任管理者から一括有効化が可能 サプライチェーン攻撃との関連: 汚染された npm パッケージや gem が実行時にこっそりマイニングスクリプトを起動したり、バックドアが C2 サーバーに接続したり……といったケースをリアルタイムで検知してくれます。「入り込まれた後」の最後の砦ですね。 Cloud:Security Hub(セキュリティポスチャ管理) 概要: AWS Foundational Security Best Practices (FSBP) などのセキュリティ標準に基づき、設定の逸脱を検出する。 防げる攻撃: セキュリティグループの過剰な公開、暗号化の欠如など、 設定ミスに起因する攻撃面の拡大 を防止 Inspector や GuardDuty の検出結果を一元的に集約し、 見逃しを減らす Container:イメージスキャン(CI + ECR Enhanced Scanning) 概要: コンテナイメージに含まれる脆弱性を、CI のビルド時とレジストリの両方でスキャンする。サプライチェーン攻撃対策の観点では OS パッケージレイヤーへの攻撃(xz-utils 事件のような)を捕まえるための要 となる対策です。 防げる攻撃: 既知の脆弱性を持つ OS パッケージ・言語ライブラリ がプロダクションに到達するのを検知 xz-utils 事件のように OS パッケージレベルでバックドアが混入したケース (CVE 公開後)を検知 アプリ側の依存パッケージマネージャー(Bundler / npm 等)では拾えない、 OS レイヤーの脆弱性 をカバー サプライチェーン防御の基本は 複数のポイントでチェックする ことです。CI でのビルド時スキャンとレジストリでの継続スキャンを組み合わせて、異なるタイミングで網をかけるのが効果的です。 CI ビルド時スキャン → レジストリスキャン(継続) → ランタイム監視 (Trivy / Inspector 等) (ECR Enhanced Scanning) (GuardDuty) デプロイ前にブロック 新規 CVE も自動で再スキャン 実行時の異常検知 CI でのスキャン 代表的なアプローチは大きく 2 系統あります。 (a) OSS スキャナ(Trivy / Grype 等) docker build 後にスキャンを実行し、脆弱性が閾値を超えたらデプロイをブロック セットアップが軽く、外部 API への依存もないので導入ハードルが低い Dockerfile の Linting(hadolint 等)も合わせて入れると効果的 (b) Amazon Inspector SBOM Generator (sbomgen) + Inspector Scan API AWS 公式アプローチ。 sbomgen でイメージから CycloneDX 形式の SBOM を生成し、Inspector Scan API に投げて脆弱性スキャンを実行 GitHub Actions なら AWS 公式の aws-actions/vulnerability-scan-github-action-for-amazon-inspector が使える。SBOM 生成からスキャン、結果のサマリー表示までワンステップで完結 Jenkins 用のプラグインも公式提供あり Inspector を既に有効化している環境なら、ECR Enhanced Scanning と 同じ脆弱性データベース・同じ判定基準 で CI 段階から検査できるのがメリット 生成された SBOM をアーティファクトとして保存しておけば、調査セクションで触れる SBOM 管理にもそのまま流用可能 CI で push 前に止められる のがこのレイヤーの最大の価値(シフトレフト)です。AWS 中心の構成なら (b) が一気通貫で扱いやすく、ツール選択の自由度を取りたいなら (a) という選び方になります。 ECR Enhanced Scanning(Inspector 統合) ECR push 時 + 新規 CVE 公開時に自動で再スキャン( CONTINUOUS_SCAN ) OS パッケージに加えて言語ライブラリも対象 Security Hub にネイティブ統合されるため、検出結果を一元管理できる Basic Scan Enhanced Scan (Inspector) 対象 OS パッケージのみ OS パッケージ + 言語ライブラリ(gem, npm, pip 等) スキャン頻度 push 時のみ push 時 + 新規 CVE 公開時(CONTINUOUS_SCAN) Security Hub 統合 なし あり 組織一括管理 なし あり(Organizations 委任管理者から) xz-utils 事件との関連: xz-utilsのような OS パッケージレベルのバックドアは、アプリケーションの依存パッケージマネージャー(Bundler / npm)レベルの SCA では検知できません。 コンテナイメージスキャンの OS パッケージレイヤー で拾うのが正しい守備範囲です。なお、xz-utils のバックドアはビルドプロセス中に難読化されたペイロードを段階的に展開する極めて巧妙な手口で、CVE 公開「前」の検知は実質的に困難でした。だからこそ、CVE 公開後にどれだけ早く対応できるかが勝負になります( CONTINUOUS_SCAN のような新規 CVE への自動再スキャンが効いてくるのはこのため)。 Code:SCA(ソフトウェア構成分析) 概要: アプリケーションが依存するパッケージ(Bundler の gem、npm のパッケージ等)の既知脆弱性および悪意あるバージョンへの依存を検出し、更新を促す。 アプリケーション依存パッケージレイヤーのサプライチェーン攻撃対策の中核 となる対策です。 防げる攻撃: 既知の脆弱性を持つアプリ依存パッケージ への依存を早期発見 パッケージの乗っ取り (ua-parser-js 事件、event-stream 事件のような)で注入された悪意あるバージョンへの自動更新を抑止 typosquatting (正規パッケージに似た名前の悪意あるパッケージ)への気づき ツール例: Dependabot(GitHub ネイティブ、Bundler / npm / Docker 対応) Renovate Bot(digest ピン対応、細かい設定が可能) GitHub Advanced Security の Dependency Review 設定のポイント: パッケージマネージャー(Bundler、npm)と GitHub Actions の両方を対象に daily または weekly でスキャン versioning-strategy の設定で意図しないメジャーバージョンアップを防止(npm なら lockfile-only 、Bundler なら lockfile-only 相当の制約をかける) 更新を即座に取り込まない :新バージョンに悪意が混入していた場合に備え、リリースから一定期間(例:3〜7 日)寝かせてからマージするポリシーも検討に値する 守備範囲の整理: SCA は アプリの依存パッケージマネージャー(Bundler / npm 等)の領域 が対象です。OS パッケージ(apt / yum / apk)レベルの脆弱性は SCA ではなく、前述のコンテナイメージスキャンが守備範囲になります。 ua-parser-js のような npm パッケージ乗っ取りは SCA、xz-utils のような OS パッケージへのバックドア混入はイメージスキャン、と分けて考えるとスッキリします。 Code:SAST(静的アプリケーションセキュリティテスト) 概要: ソースコードを静的に解析し、セキュリティ上の問題を検出する。 位置づけの注意: SAST はサプライチェーン攻撃そのものへの直接対策ではなく、 自前コードの脆弱性検出ツール です。ただし、攻撃者が悪意ある PR を送り込んできた場合(社外コントリビューターからの PR や、内部アカウントが乗っ取られた場合)に、危険なコードパターンを検出できる可能性があります。多層防御の一環として位置づけてください。 防げる攻撃: SQL インジェクション、XSS、CSRF 等の コーディングレベルの脆弱性 フレームワーク固有の危険なパターン(Rails なら Brakeman、Node.js なら ESLint Security Plugin 等) 悪意ある PR に含まれる 明らかに怪しいコードパターン 設定のポイント: CI で全 PR に対して実行し、マージ前に検出 言語・フレームワーク固有のツールを選定 🧱 封じ込め(Containment)— やられても被害を広げさせない 予防も検知もすり抜けて侵入された場合に、被害の範囲を最小限にするための対策です。「入られた前提」で考えるのがポイント。 Cloud:ネットワーク制御(Egress 制限) 概要: コンテナからのアウトバウンド通信を必要最小限に制限する。侵害が起きた後にデータ持ち出しや C2 通信を成立させにくくする、封じ込めとしての効果が大きい。 防げる攻撃: ランタイム侵害後の C2 サーバーへの通信をネットワークレベルで遮断 悪意あるパッケージや侵害されたコンテナによる外部へのデータ送信をブロック xz-utils 事件のようなバックドアが仮にコンテナ内に入り込んでも、外部との通信路を断つことで攻撃者の活動を阻害 設定のポイント: AWS Network Firewall:FQDN ベースのアウトバウンド許可リスト方式で必要なドメインのみ通す Route 53 DNS Firewall:悪意あるドメインへの DNS クエリをブロック Security Group:NAT Gateway 経由のアウトバウンドを最小化し、不要なポート・宛先を塞ぐ Cluster:IAM ロール分離(タスクロール / 実行ロール) 概要: ECS の実行ロール(ECR pull、ログ出力等)とタスクロール(アプリケーションが使う権限)を分離する。最小権限の原則そのものなので予防の性質も持ちますが、真価を発揮するのは侵害が起きた後—— 横移動の範囲を制限する封じ込めとしての効果が大きい ため、ここで扱います。 防げる攻撃: アプリケーションが侵害された場合でも、 ECR へのイメージ push や他タスクへの影響を防止 権限の最小化により、 ラテラルムーブメント(横移動)の範囲を制限 設定のポイント: 実行ロール :ECR pull、CloudWatch Logs、SSM パラメータ取得のみ タスクロール :アプリケーションが実際に必要とする権限のみ ssm:GetParameters の Resource は ではなくパラメータパス(例: /fargate/myapp/* )で制限 タスクロールに ecr:PutImage 等の書き込み権限が紛れ込んでいないか定期確認 Container:コンテナハードニング コンテナが侵害された後の被害を最小限にするための設定群です。まさに「封じ込め」の代表格。 readonlyRootFilesystem 概要: コンテナのルートファイルシステムを読み取り専用にする。 防げる攻撃: コンテナ侵害時の マルウェアのファイルシステムへの書き込み・永続化 攻撃ツールの 追加ダウンロードと配置 Web シェルの設置 設定例: { " containerDefinitions ": [ { " name ": " app ", " readonlyRootFilesystem ": true , " mountPoints ": [ { " sourceVolume ": " tmp ", " containerPath ": " /tmp " } , { " sourceVolume ": " app-tmp ", " containerPath ": " /app/tmp " } ] } ] , " volumes ": [ { " name ": " tmp " } , { " name ": " app-tmp " } ] } 注意 1: アプリケーションによっては /tmp や /app/tmp 、 /app/log 等への書き込みが必要です。tmpfs ボリュームをマウントして書き込み先を確保してください。 注意 2: readonlyRootFilesystem: true は ECS Exec と両立しません 。SSM agent がコンテナファイルシステムへの書き込みを必要とするためです( AWS 公式ドキュメント で明記)。本番タスクは readonlyRootFilesystem: true + ECS Exec 無効化、調査用の踏み台サービスは readonlyRootFilesystem: false + ECS Exec 有効化、と用途で分けるのが現実的です。 Linux capabilities の DROP ALL 概要: コンテナに付与される Linux capabilities をすべて削除する。 防げる攻撃: コンテナ内からの 不要な特権操作 を制限 権限昇格(setuid バイナリ経由、カーネル脆弱性等)を試みた際の 被害を抑制 USER ディレクティブが何らかの理由で無視・上書きされた場合の 保険 設定例: { " linuxParameters ": { " initProcessEnabled ": true , " capabilities ": { " drop ": [ " ALL " ] } } } 補足: Fargate では元々 capability の add は不可でしたが、プラットフォームバージョン 1.4.0 以降、 SYS_PTRACE の 1 つだけは追加可能 になりました(Sysdig Falco のような観測ツール用途のために 2020 年に解禁された経緯がある)。一方、 drop は問題なく可能 です。EC2 launch type ではすべての capability が利用できますが、Fargate で add できるのは依然として SYS_PTRACE のみという制約があります。非 root で実行していれば実質的な効果は限定的ですが、多層防御(Defense in Depth)の観点から「保険として入れておく」温度感で設定しておくと安心です。 非 root 実行 概要: コンテナプロセスを root 以外のユーザーで実行する。 防げる攻撃: コンテナ侵害時の ホストへのエスケープリスクを低減 ファイルシステムやプロセスへの不正操作の範囲を制限 設定方法: Dockerfile で USER app:1000 のように非 root ユーザーを指定 サイドカー(fluent-bit 等)も可能な限り非 root 化 🔎 調査(Investigation)— 何が起きたかを追える状態にする インシデントが起きた後に「何が起きたか」「影響範囲はどこまでか」を追跡するための対策です。地味ですが、ここが抜けていると事後対応でめちゃくちゃ苦労します。 Cloud:CloudTrail(監査ログ) 概要: AWS API コールの監査ログを記録・保全する。 防げる攻撃・実現できること: IAM クレデンシャルが漏洩した場合の 事後調査(フォレンジック) が可能になる 「誰が・いつ・どの API を叩いたか」を追跡でき、不正な ECR push や IAM 変更を特定できる ECS のコントロールプレーン操作(クラスター / タスク定義 / サービスの作成・更新・削除など)も AWS API コール経由で行われるため CloudTrail でカバーされる 。Cluster 層で「誰が悪意あるタスク定義を登録したか」「サービスが書き換えられたのはいつか」を追えるのはここ S3 Object Lock でログの改ざん・削除を防止 CloudTrail ログファイルの整合性の検証(CloudTrail log file integrity validation)でログ自体が改ざん・削除されていないことを事後検証できる 。CloudTrail が 1 時間ごとに、その間に配信されたログファイルのハッシュを含むダイジェストファイルを RSA で署名して S3 に配置するので、 aws cloudtrail validate-logs で「保管されているログが配信時のものと一致するか」を検証できる 設定のポイント: 組織トレイルでマルチリージョン・全管理イベントを記録 S3 Object Lock(GOVERNANCE モード以上)でログの不変性を担保 ログファイルの検証(Log file validation)を有効化 (コンソール作成時のオプション、CLI なら --enable-log-file-validation )。S3 Object Lock が「改ざんさせない」予防策、ログファイルの整合性の検証が「改ざんされていないことを示す」検出策で、両方揃えると証跡としての信頼性が一段上がる Advanced Event Selectors で S3 データイベントも記録 サプライチェーン攻撃との関連: たとえば攻撃者が CI/CD の認証情報を奪って ECR にイメージを push した場合、CloudTrail がなければ「いつ・どのアカウントから push されたか」すら追えません。同様に、奪った認証情報で RegisterTaskDefinition や UpdateService を叩かれた場合も、CloudTrail があれば Cluster 層での改ざんを追跡できます。防御だけでなく「何が起きたか調べられる状態にしておく」のも大事です。 Cloud:VPC フローログ / DNS クエリログ(ネットワークレベルの追跡) 概要: VPC 内の通信メタデータと DNS 名前解決を記録し、「どこから・どこへ・何が通信したか」を追跡可能にする。 実現できること: 侵害されたタスクが どこに通信していたか (C2 サーバー、不審な外部エンドポイント、想定外の内部リソース)を特定できる VPC フローログは ECS タスクの ENI 単位で取得可能で、 タスクごとの通信を追跡 できる( ECS 向けの VPC フローログ機能 ) VPC フローログと Route 53 Resolver クエリログを組み合わせて分析することで、「いつ・どのドメインに対して通信したか」まで踏み込んだ調査ができる 。フローログ単体だと宛先 IP しか分からず、CDN やクラウドサービス相手だと「結局どこと話していたのか」が判然としない。クエリログで名前解決の履歴を突き合わせることで、IP → ドメインのマッピングが復元でき、不審な通信先の正体を特定しやすくなる マルウェアが DGA(ドメイン生成アルゴリズム)で生成したドメインへ問い合わせた痕跡など、フローログだけでは見えない挙動も DNS クエリログ側で捕捉できる 設定のポイント: VPC フローログは S3 / CloudWatch Logs に出力。長期保管なら S3 + Object Lock カスタムフォーマットで pkt-srcaddr / pkt-dstaddr を含めると、NAT 越しでも実際の送信元・宛先が分かる Route 53 Resolver クエリログを VPC に紐づけておけば、タスクからの DNS 問い合わせも記録される サプライチェーン攻撃との関連: 悪意ある依存パッケージが侵入した場合、最終的に外部 C2 への通信を試みるケースが多いです。CloudTrail は API コールの記録なので、こうした データプレーン上の通信 までは追えません。VPC フローログ + DNS クエリログを揃えて組み合わせ分析できる状態にしておけば、「侵害されたタスクがどのドメインを名前解決し、その IP に対して実際にどれだけのトラフィックを流したか」まで一連の流れで再構成でき、情報流出先や C2 ドメインの特定が一気に現実的になります。 Code:SBOM(ソフトウェア部品表) 概要: アプリケーションが依存するすべてのパッケージとそのバージョンを一覧化する。 実現できること: インシデント発生時に 「何のバージョンの何が動いていたか」 を即座に特定 新たな CVE が公開された際に 影響範囲を迅速に判断 xz-utils 事件のように 依存関係に紛れ込んだバックドア が後から発覚した場合の影響調査を効率化 実現方法: CI 段階で sbomgen を使う:検知セクションで触れた aws-actions/vulnerability-scan-github-action-for-amazon-inspector 等は、副産物として CycloneDX 形式の SBOM を出力する。脆弱性スキャンと SBOM 保存が同時にできるので一石二鳥 Amazon Inspector の SBOM エクスポート機能 を使うと、Inspector でスキャン済みのリソース(ECR イメージ等)の SBOM を CycloneDX または SPDX 形式で S3 にエクスポート できる。リアルタイム生成ではなく一括エクスポート方式なので、定期実行ジョブとして仕込んでおくのが現実的 いずれの場合も、イメージのバージョン(できれば digest)と SBOM を紐づけて保管 しておくのがポイント。インシデント時に「あの時動いていたバージョン」の SBOM をすぐ参照できるようにする サプライチェーン攻撃との関連: 「うちで動いてるイメージに xz-utils の脆弱バージョンって入ってたっけ?」を即答できる状態を作っておく、というのが SBOM の本質です。インシデント時に手作業で全イメージを掘り返すのは現実的ではないので、平時から仕組み化しておきましょう。SBOM は OS パッケージとアプリ依存パッケージの両方をカバーするため、「SCA で見える範囲」と「イメージスキャンで見える範囲」を横断して影響調査できる のが強みです まとめ 正直、セキュリティ対策って「どこまでやればいいの?」が永遠の問いだと思うんですが、まずはこのマトリクスで現状を棚卸しして「ここが手薄いな」と見えるようにするだけでも一歩前進です。 この記事が、ECS Fargate 環境のサプライチェーン攻撃対策を考える際のチェックシートとして使ってもらえれば嬉しいです。 参考資料 クラウドネイティブセキュリティの概要 | Kubernetes Amazon GuardDuty ECS Runtime Monitoring Amazon Inspector ECR scanning Amazon Inspector SBOM Generator (sbomgen) Integrating Amazon Inspector scans into your CI/CD pipeline aws-actions/vulnerability-scan-github-action-for-amazon-inspector Amazon Inspector SBOM export AWS Signer for container images Streamline container image signatures with Amazon ECR managed signing Amazon ECR now supports managed container image signing (2025-11-21) Extending deployment pipelines with Amazon ECS blue green deployments and lifecycle hooks Container image signing and verification using AWS Signer for Amazon ECS and AWS Fargate Amazon ECR now supports exceptions to tag immutability (2025-07-23) Preventing image tags from being overwritten in Amazon ECR ECS タスク定義 - linuxParameters KernelCapabilities - Amazon ECS Fargate security best practices in Amazon ECS Monitor Amazon ECS containers with ECS Exec (readonlyRootFilesystem との非互換について) Security hardening for GitHub Actions GHSA-mrrh-fwg8-r2c3: tj-actions/changed-files supply chain compromise CISA: Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup (CVE-2025-30154) CVE-2024-3094: XZ Utils Backdoor Renovate Docker Presets Dependabot: Add digest pinning when updating Docker image tags (#14065 / PR #14071, 2026-02-04 マージ済み)
はじめに タイミーでは、世界中で開催される技術系カンファレンスに無制限で参加できる「Kaigi Pass」という制度を活用し、8名がRubyKaigi 2026 in 函館に現地参加しました。 また今年はDay2に、タイミーから @ dak2 さんが "No Types Needed, Just Callable Method Check" というタイトルで登壇しました。 本レポートでは、参加したエンジニアが注目したセッションごとに、ポイントや得た知見をまとめてご紹介します。 各セッションごとに内容を整理し、参加者自身の視点から学びや気づきをまとめています。読者の皆様にとって、今後の学びの参考になれば幸いです。 Surviving Black Friday: 329 billion requests with Falcon! rubykaigi.org Shopify社のSamuel Williamsさん、Marc-André Cournoyerさん、Josh TeeterさんがFalconを導入することでブラックフライデー・サイバーマンデー(BFCM)期間の合計3,290億リクエストを乗り切ったお話でした。 自身は普段Webのバックエンドエンジニアとしてユーザートラフィックのパフォーマンス課題について関心を持っており、その流れでFalconや基礎技術のAsyncにも興味を持っていました。 Falconの開発者であるSamuelさんから、本番稼働中の高トラフィックなプロダクトにFalconを導入する際のコストや注意点、そして得られる効果を聞けると期待して、このセッションを聴講しました。 前提として、FalconとはAsyncベースのRack互換Webサーバーです。 AsyncとはRubyの軽量スレッドであるFiberを利用した非同期I/Oフレームワーク(ノンブロッキングI/O)です。 github.com Falconは単一プロセスの中で複数のHTTPリクエストに対応するFiberを割り当てます。 FiberでI/Oが発生すると、処理を別のFiberに移すことで、大量のリクエストの処理を実現します。 OSネイティブのプロセスやスレッドではなく、Fiberで並行処理を実現するためメモリ効率が良く、PumaやUnicornと比較してランニングコストが低いという特徴があります。 セッションではFalconのアーキテクチャの解説と導入に伴う段階的なスケールテストを通して多くの課題を解決していった様子を発表されていました。 取り組みの結果、従前のUnicornよりもスループット・コアあたりのリクエスト数・レイテンシが改善し、冒頭にもあったBFCM期間の3,290億リクエストを捌き切りました。 まず最初に自分が抱いた感想として、プロダクトの課題を解決しつつOSSコミュニティへの還元を忘れないという点に感銘を受けました。 導入の過程でFalconに対して行われた改善は誰もが利用できるようになっています。 Falconを実際のプロダクトに導入し、入念なテストを重ねてブラッシュアップしたうえで、個社最適化にとどめずOSSとして誰もが使える形で公開し、Rubyコミュニティ全体へ還元する姿勢に、エンジニアとしてとても憧れました。 また、発表の中でスケールテストの重要性について強くお話をされていたのが印象的でした。 最近の自身の関心ごとの一つとして、以下にテストをしやすい環境を作るか・本番に近い環境でテストができるかというものがありました。 Shopify社は「Genghis」というツールを用いてこれを行っているそうなのですが、詳しい情報が見つけられなかったのでご存知の方がいれば教えていただけると嬉しいです。 また、同社のテックブログを読むと「Game Day」と称してスケールテスト以外にもカオスエンジニアリングや障害のシミュレーションなどかなり大規模で入念なテストを行っていたことがわかります。 shopify.engineering ユーザー増加やキャンペーン施策によるトラフィックの増加は、パフォーマンスだけではなくランニングコストという面においても重大な関心事です。そうした状況で、Falconは有効な選択肢の一つだと感じました。 実際に触ってみて他のRack互換アプリケーションサーバーとどのような違いがあるか試してみたくなったので、まずは自身のローカルのRailsでFalconを動かしてみようと思います。 FalconのRuby on Railsの導入に関しては2025年のKaigi on RailsのキーノートでSamuelさんが発表しておりこちらもおすすめです。 kaigionrails.org 志賀( @akitoshiga ) Practical TypeProf: Lessons from Analyzing Optcarrot speakerdeck.com @mametter さんによる、Rubyの型解析ツールTypeProfを、Ruby製8ビットマシンシミュレータであるOptcarrotに適用し、偽陽性の型エラーをゼロにするまでに何をしたのかという話でした。 TypeProfは、最小限の型注釈でエラー表示、定義ジャンプ、補完を提供するエディタ支援ツールです。型検査機にはSteepやSorbetがありますが、TypeProfはそれらとは異なり、型注釈をほとんど書かずにコードから型を推論します。 偽陽性の型エラーの原因は、実行時の不変条件が伝わらず、コード上nilになり得る型に対するメソッド呼び出しによるもの、動的メソッドで解析自体が難しいものなどがありましたが、本発表ではそれらをどう見つけどう直したかというものをAIの活用も含めてお話しされていました。 原因調査の中で、AIがキーポイントの調査において有効であることが語られました。実装全体の把握やその中でも特に多く呼び出される処理の特定は人間が調査するにはコストが高く不向きであると思うので、その部分をAIが十分に代替できるというのは素晴らしいなと思いました。 一方で、RBSでの型付けはAI任せだと雑になりがちなので一部は自分の手で直したと語られていました。まだまだAIは万能ではなく、人間が有効に使えているかどうかを判断する必要があり、場合によっては人の手で修正することが必要であるというメッセージとして受け取りました。 話の最後には 同氏が以前公開された記事 を踏まえ、型を書かないRubyがAIコーディングとの相性が良い可能性について触れており、その中で型の役割についてもガードレールとして機能するとされていたが、定量効果については慎重に評価すべきではないだろうか、としていました。 今後の展望としては、まずは人間向けに改善を進めていき、AIの使い勝手が安定してきた頃にAIエージェントを助けるためのツールとしての改良も検討しているとしていました。 RubyがAIコーディングと相性が良いかもしれないという示唆は、Rubyをメインで使っている者として、今後も使っていく価値のある言語である可能性を示されたような安心がありました。一方で動的言語であるが故に実行時エラーの懸念が常に付き纏いますが、そこを少ないコストで軽減できるツールとしてTypeProfの進化には期待したいです。 Rubyにおける型は、人によって意見が分かれると思います。個人的には、「型はあるに越したことはない。しかし、自分でわざわざ型注釈は書きたくない」という意見です。TypeProfは僕のようなRubyistにとって理想のツールとなる可能性があります。 RubyKaigi後、早速TypeProfを試してみようと思いましたが、RBSでモジュールエイリアスを定義しているとエラーによりTypeProfが起動しない問題に遭遇したので、PRを作成しました。 github.com 今後も自分にできる範囲でTypeProfの進化に貢献していきたいです。 rhiroe( @buta_botti ) Ruby Releases Ruby rubykaigi.org @hsbt さんによるRubyのリリースを支えるRubyによる仕組みづくりについての発表でした。 今回の発表で印象的だったのは、数値で示されたリリースプロセスの進化と「徹底してRubyを使い込むこと」でした。 かつてRubyのセキュリティリリース現場では、最大4つの安定版ブランチにパッチを当てるために複数名のコミッターが5〜6時間に及ぶ深夜作業をされていたそうです。インフラ構成管理やリリーススクリプト、各種自動化ツールをRubyで揃えて改善を進めたことで、それが今や1名が1時間程度の作業で完了できる体制になったといいます。自動化による効率性向上に加えて、緊急性が高いセキュリティパッチ対応におけるRubyコミュニティのアジリティが底上げされたと感じました。 更に、リリースの頻度についても、2022年の年間9回から2024年の15回へと約1.5倍に加速しています。hsbtさん自身が、Ruby 4.0以降は「2週間に1回の頻度でリリースする」という目標を立てられているといい、既にRuby 4.0リリース以降から10回のリリースが実現できているようです。テスト基盤はGitHub ActionsとRuby CIの2系統で運用され、GitHub Actionsには約120のworkflowが存在するなど、幅広いプラットフォーム・ビルドパターン・コンフィギュレーションを継続的に検証できる体制が紹介されていました。 特に、開発版Rubyにおけるパフォーマンス向上やメモリ削減などの成果を現行の安定バージョンに還元するバックポートの仕組みや、OIDC連携によるTrusted Publisher、Sigstoreを用いた署名など、Bundlerを含むエコシステム全体に最新のセキュリティ標準が組み込まれていくことが紹介され、Rubyエコシステムの揺るぎない技術的信頼を感じました。 RubyによるRubyのためのリリースプロセスの改善が、あらゆるRubyistへの良質なユーザー体験を形作っているのだと感じられる発表でした。 江田( @edy629s ) Lightning-Fast Method Calls with Ruby 4.1 ZJIT speakerdeck.com こちらのセッションでは今後のRubyの進化には欠かすことのできないZJITに関する内容でした。 特にメソッドコールに関する詳細なアプローチについて話されていました。 このセッションは今回の会場で一番広いホールで行われたのですが、そのほとんどの座席が埋まっているという状態で始まりました。 これはRubyにおけるZJITの注目度の高さが伺えますし、ShopifyのJIT開発チームでもYJITの頃から活躍されているk0kubun氏のトークであることからも多くの人を集めたセッションとなったのだと思います。 セッションの内容はメソッドコールにおけるRuby内部のアプローチについてRuby VMとYJIT、そしてZJITについて比較を行いながら話されていました。 まず最初にZJITのメソッドコール最適化の方法はいかにmemory writeを減らすかということに主眼をおいていることがわかりました。 メソッドコールはコードの実行においては多くの割合を占めるものであり、ここの部分での最適化の積み重ねが最終的に大きな改善となりうる箇所です。 本セッションではmemoryに加えてregisterの活用とそれぞれの活用順序を組み合わせることで、YJITと比べてもより高効率な最適化を目指したことを個々のステップを丁寧に説明されていました。 私はこのセッションにおいて、情報工学において誰もが習うであろうmemory, registerの組み合わせがいかに重要かを改めて認識することになりました。 CRubyという巨大な処理系においてもコンピューターの基礎となるアーキテクチャを駆使することで改善を積み重ねられるという事実に、ZJITという大きな取り組みの中にも基礎ありき、という重要さを再認識させられました。 関口亮一 ( @ryopeko ) Blazing-fast Code Indexing for Smarter Ruby Tools rubykaigi.org この発表では Rubydex という Rust 製の Ruby Code Indexer が紹介されていました。RubyLSP や Tapioca に統合することで最大10倍の高速化と2倍のメモリ削減を実現したという内容でした。 また、Ruby ツールのための統一的なコードインデックス基盤としてのビジョンも示されていました。Shopify の Ruby DX チームが9名関わっていることから、Rubydexに対するShopifyの本気度がうかがえます。 個人的に RubyKaigi で最も注目していたのはこの Rubydex でした。というのも、これからの時代の言語選定において、大きな要素となるのがトークン効率の高さだと考えているからです。 mame さんによる Ruby はトークン効率が高い言語なのではないか という記事が話題を呼びましたが、私は言語自体のトークン効率と同程度もしくはそれ以上にトークン効率を高める補助ツールの存在が重要なのではないかと考えています。 AI エージェントにコードベースを調査させると、Grep → Read → また Grep…というループが延々と続き、トークンを湯水のごとく消費します。人間はこんなことはせず、エディタのコードジャンプや「このコードはこのあたりのファイルにあるはずだ」と当たりをつけて調べます。これと同じことを AI エージェントが行えるようになればトークン効率は劇的に改善するはずです。 実際にタイミーのモノリス Rails で Rubydex MCP を試したところ、簡単なベンチマークではトークン消費量を3〜4割削減できました。 tech.timee.co.jp トークン効率の改善としては、Claude Code の LSP サポートはまさにそのための機能だと思いますが、Rubydex は LSP 以外でも活用できます。Rubydex を基盤として、コードを静的解析してデッドコードを検出し、自動で削除・改善するワークフローを組むことができるかもしれませんし、AI によるコードレビュー時に「この Pull Request で変更したメソッドの全呼び出し元」をインプットとして与えることでシステム全体への影響をより詳しく検証できるようになるかもしれません。Rubydex は Ruby が “AI 時代にとっても最高の言語” であるための重要な技術基盤になると確信しています。 Rust 実装なのでコントリビュートの敷居が高いというのは正直なところなのですが、自分でできることを探して貢献したいと思っています。 新谷( @euglena1215 ) Matz Keynote rubykaigi.org 「Claude Codeでこんなの作ってみました」という話は最近よく目にしますが、今年のキーノートではRubyの生みの親であるMatzがそんな話をしだして自分は終始テンションが上がりっぱなしでした。ここ数年のMatzキーノートの中でも一番印象に残る内容だったかもしれません。 最近のMatzはあえて自分ではコードを書かない制約を課してClaude Codeで様々なプロジェクトに取り組んでいるといいます。自身の日常的な課題を解決するためにRSSリーダーをRuby on Railsで作ったり、組み込み向けRubyであるmrubyの実装の改善をしたり、そして極めつけは今回の目玉、RubyのAOT(Ahead of Time)コンパイラであるSpinelを開発したりと驚くべきスピード感で具体的なアウトプットを次々と生み出しています。 同日の『Ruby Committers and the World』では後継者問題も話題にのぼりましたが、そんな懸念をよそに新しい技術に目を輝かせ、誰よりも楽しそうにプロダクトを作り続けるMatzの姿には非常に感銘を受けました。 また、リーナス・トーバルズがLinuxとGitという世界的に利用されているプロダクトを複数世に送り出していることに触れ、自分もRuby以外でもう一発当てたいと話していたのも良かったです。Spinelがそのひとつになるかはわかりませんが、彼の手からまた新しい何かが生まれてくるんじゃないかという期待を抱かずにはいられない、最高のキーノートでした。 須貝( @sugaishun ) おわりに RubyKaigi 2026は、Rubyコミュニティの熱量と、言語としての更なる可能性を肌で感じられる3日間でした。 スピーカーの発表内容や企業ブースでの会話、DrinkUpイベントなどを通じて得た繋がりは、単なる情報交換に留まらず、新たな技術的な挑戦への大きな原動力となっています。 タイミーはRubyコミュニティの一員としてこれからも技術への探究心を燃やし続けていきます。 また来年、宮崎で開催されるRubyKaigi 2027でお会いしましょう!
1. はじめに DRE(Data Reliability Engineering)グループ のつざきです。タイミーのデータエンジニアリング部で、BigQuery / dbt / Cloud Composer / Looker といったデータ基盤の開発・運用をしています。 DREチームでは 2026 年 2 月から、AWS が提唱する AI-DLC(AI-Driven Development Life Cycle)というワークフローを運用しています。きっかけは、 1 月末に AWS 主催の研修「Unicorn Gym」で3 日間 AI-DLC を体験したことでした。 AI-DLC 自体とタイミー全体への波及は同部の橋本さんが、Operations フェーズ(リリース後の検証)の独自構築については同じ DRE G の chanyou さんが、それぞれまとめています。 3日間のUnicorn Gymが1ヶ月で組織を変えた —— データで見るAI-DLC導入の波及効果 (橋本さん) 「リリース後」に向き合うAI駆動開発の実践 (chanyou さん) 本記事はこれらの続編的な位置づけで、「DREチーム が Inception と Construction フェーズで何を実装・運用しているか」に絞って書きます。 対象読者 : AI-DLC を個人ではなく、チーム(モブ)で運用したい開発/データ基盤チーム この記事の目的 : 公式の想定(単一プロジェクト/個人運用)を、複数リポジトリ・リモートモブ前提に翻訳した実装パターンを共有する 扱わないこと : Operations フェーズの詳細、全社展開の話、AI-DLC の一般解説 TL;DR DREチームは 2026 年 2 月から AI-DLC を運用中 実装 : Workspace + CLAUDE.md 読み替え、Intent 単位の運用 モブ : 1 日 3 ~ 4 時間のフルリモートモブ。狙いは「フロー効率(承認ゲートで止まらない)」「キーパーソンに頼らない(新基盤導入や新メンバー受け入れに効く)」「AI 出力の欠陥を集合知で減らす」の 3 つ 3 ヶ月の結果 : Intent 完了が月 14〜17 件で推移、PR 数は維持、サイクルタイムに劇的な変化は見えず 記事の立ち位置 : 公式に書かれていない実装の隙間(Mob、複数リポジトリ、パス読み替え等)を自分たちで翻訳した事例として記録 2. AI-DLC をざっくり AI-DLC の全体像は既出記事に譲り、本記事で後から使う概念だけ押さえておきます。 本記事での用語の使い方 Intent : 1 つのゴール(例: あるデータソースを BigQuery で使えるようにする) Unit : Intent を疎結合に分解した作業単位(DDD の Subdomain 相当。例: Terraform 追加、DAG 実装など) Ritual : モブでの儀式的な作業(後述の Mob Elaboration / Mob Programming / Mob Testing) Workspace : ドキュメントとルールを置く専用リポジトリ フェーズと成果物の階層 AI-DLC には 3 つのフェーズがあります。 Inception : 要件分析・設計 Construction : 実装・テスト Operations : デプロイ・監視 3 つの Mob Ritual 各フェーズには対応する儀式(Ritual)が定義されています。 Mob Elaboration (Inception): 要件分析・分解を全員で Mob Programming (Construction): 実装を全員で Mob Testing (Construction): テストを全員で いずれも、公式推奨は「物理集合 + 共有スクリーン + ファシリテーター」です。 Human Oversight = Loss Function AI-DLC は AI が実行主体、人間は各ステップで検証・承認する構造です。公式ペーパーの表現が印象的で: "Each step serves as a strategic decision point where human oversight functions like a 'loss function' - catching and correcting errors early before they snowball downstream." 機械学習の損失関数のように、人間のレビューが早期にエラーを補正する、というメタファーです。後の章でモブワークの話をするときに効いてきます。 3. 公式ドキュメントに書かれていない実装ギャップ chanyou さんの記事では、awslabs/aidlc-workflows リポジトリで Operations フェーズがプレースホルダになっている話が出てきます。実は Inception と Construction の側にも、公式の文書と実装の間にいくつかのギャップがあります。 awslabs/aidlc-workflows の構成 原典の awslabs/aidlc-workflows は MIT-0 ライセンスで公開されている、マークダウンのルールファイル群です。 aidlc-rules/ ├── aws-aidlc-rules/ │ └── core-workflow.md # ワークフロー本体 └── aws-aidlc-rule-details/ ├── common/ # 共通ルール ├── inception/ # Inception 詳細 ├── construction/ # Construction 詳細 └── operations/ # プレースホルダ ギャップ 1: ルール実装に Mob の記述がない AI-DLC 公式ペーパーでは Mob Elaboration / Mob Programming / Mob Testing が中核の儀式として定義されています。しかし原典のルールファイル群を mob で grep してもヒットしません。実装部分は「個人と AI エージェントが 1 対 1 で対話しながら承認ゲートを通す」構造になっており、Mob は想定されていない書き方です。 ギャップ 2: 公式チュートリアルは個人開発の例 AWS 公式ブログの実践記事 Building with AI-DLC using Amazon Q Developer のサンプルは、単一 HTML ファイルの川渡りパズルを個人で作る例だけで、モブで回す実演は出てきません。 ギャップ 3: 複数リポジトリの扱いが明確でない 公式は単一プロジェクト前提です。データチームのように「1 つの機能を作るのに複数リポジトリにまたがる」ケースへの具体的な示唆はほぼありません。 理念と実装の翻訳が必要 つまり、公式ペーパーに書かれた「Mob ワーク」や「複数チームでの協調」を実際に動かすには、自分たちで翻訳する必要があります。DRE では、各ギャップに対応する形で次のように対処しています。 ギャップ 1(Mob がルールにない) → モブでの意思決定を組み込み(章 6) ギャップ 2(単一 Intent 想定) → Workspace + CLAUDE.md 読み替え(章 4) ギャップ 3(複数リポジトリが薄い) → 複数リポジトリを 1 Intent でまとめる(章 5) 次章から具体に入ります。 4. DRE の実装: Workspace + CLAUDE.md 読み替え AI-DLC を Claude Code で回すために、DRE では次の構成にしています。 全体像(先に結論) ルール階層 : aidlc-rules/ (上流)→ .claude/rules/ (上書き)→ CLAUDE.md (入口) パス読み替え : aidlc-docs/requirements.md を aidlc-docs/intents/<YYYY-MM>/<intent_name>/inception/requirements.md に読み替え Intent 箱 : Intent ごとに独立したディレクトリ( intents/<YYYY-MM>/<intent_name>/ ) 状態管理 : aidlc-state.md に Status と Code Repositories を記録 スキル化 : Intent ライフサイクルを Claude Code のスキルで操作 以下、理由と詳細を順に見ていきます。 なぜこの構成なのか awslabs のリポジトリは単一プロジェクト・単一 Intent 前提で書かれていて、1 つの aidlc-docs/ ディレクトリに成果物を蓄積する想定になっています。 一方で DRE は、Intent という単位で開発を進めていて、完了した Intent もそのまま保存しています(後述しますが 2026 年 3 月は 14 件の Intent が完了しました)。Intent ごとに独立したディレクトリが必要になるので、パス読み替えが不可欠です。 ルール階層(継承構造) aidlc-rules/ : awslabs/aidlc-workflows の中身をそのまま取り込む。手動変更禁止、 /aidlc-rules-update スキルで上流追従 .claude/rules/ : プロジェクト固有のルール。aidlc-rules のオーバーライドや追加ルールを置く CLAUDE.md : エントリポイント。プロジェクト概要とディレクトリ規則を最小限に記述 上流は変えない。プロジェクト固有の振る舞いは派生側で足す。オブジェクト指向の継承に近い発想です。 [入口] CLAUDE.md ├─ 参照: aidlc-rules/ # 上流(awslabs 同期、変更禁止) └─ 参照: .claude/rules/ # 派生(DRE 固有、オーバーライド+追加) パス読み替えの例 awslabs のルールは、成果物の置き場として aidlc-docs/ というパスを前提に書かれています。DRE ではこれを Intent ごとのディレクトリに読み替えます。 公式: aidlc-docs/requirements.md DRE: aidlc-docs/intents/<YYYY-MM>/<intent_name>/inception/requirements.md この読み替えは .claude/rules/aidlc-workflow.md に定義してあり、Claude Code が実行時に解釈します。ルール本体(aidlc-rules/)は触らずに、パスだけ派生側で書き換える構成です。 Intent ディレクトリの構造 1 つの Intent のディレクトリはこういう構造です。 aidlc-docs/intents/<YYYY-MM>/<intent_name>/ ├── intent.md # Intent の目的・受け入れ基準 ├── aidlc-state.md # Intent の状態管理 ├── audit.md # 監査ログ ├── inception/ │ ├── requirements.md │ ├── stories.md │ └── ... └── construction/ └── <unit_name>/ ├── functional-design.md ├── code-generation.md └── ... aidlc-state.md のカスタマイズ Intent の進捗追跡に使う aidlc-state.md は、公式テンプレートをベースに少し拡張しています。 Status : OPEN / SUSPEND / CLOSED の 3 値を追加 Assignee : 担当者 Code Repositories : 複数のコードリポジトリのブランチ状態を記録 この Code Repositories セクションが次の章(複数リポジトリ運用)の鍵になります。 スキル化 Intent のライフサイクル管理は Claude Code のスキルとして定義しています。 /aidlc-intent-start : 新規 Intent 開始 /aidlc-intent-continue : 既存 Intent の再開 /aidlc-intent-save : 作業内容を PR 化してマージ /aidlc-rules-update : 上流(awslabs)への追従 chanyou さんの記事では /inception のように AI-DLC のワークフローそのものを呼び出すスキルが紹介されています。一方、DRE では「Intent というライフサイクルの入れ物」をスキル側で担う構成にしています。どちらも awslabs のルールに乗りつつ、スキルで扱う粒度が違う、という関係です。 5. 複数リポジトリを 1 Intent でまとめる DRE のようなデータ基盤チームでは、1 つの機能を作るのに複数のリポジトリが絡みます。 典型的なワーク 例えば「ある外部 SaaS のデータを BigQuery に自動転送するパイプラインを構築する」といった Intent だと、以下のようなリポジトリにまたがる変更が必要になります。 GCP Terraform リポジトリ : IAM やデータセットの定義 Composer インフラリポジトリ : Cloud Composer や Secret Manager の Terraform Composer DAG リポジトリ : Cloud Run Job と Airflow DAG のコード dbt リポジトリ : staging モデル これを 1 つの Intent としてまとめます。まず Inception フェーズで全体の要件・設計を固め、その後 Construction フェーズで各リポジトリに Unit を切って進めます。例えば DRE の 2026 年 2 月に動かしたあるパイプライン構築 Intent では、4 ユニット・60 ドキュメント・6 PR で完了しました(規模感の一例として)。 ブランチ戦略の 2 階建て ドキュメントとコードで別々のブランチ戦略を使い分けています。 Workspace リポ : session/<intent_name>/<hex> という短命ブランチ。スキル呼び出し単位で切って都度 main にマージ コードリポ : feature/<intent_name> という長命ブランチ。Intent が完了するまで維持 Workspace 側はドキュメントの進捗を小さくマージして積み上げ、コードリポ側は実装が揃ったタイミングで main に入れる、という二層構造です。 aidlc-state.md に Code Repositories を記録 1 つの Intent が複数リポジトリに触るので、どのリポのどのブランチで作業しているかを aidlc-state.md に記録しておきます。 ## Code Repositories - < dbt-repo > (feature/ < intent _name> ) - < composer-dag-repo > (feature/ < intent _name> ) - < composer-infra-repo > (feature/ < intent _name> ) - < gcp-terraform-repo > (feature/ < intent _name> ) Intent を再開するときも、Claude Code がどのブランチをチェックアウトすればよいか即判断できます。 横断ドキュメントとして蓄積される Inception で作る requirements.md や application-design.md は、複数リポジトリにまたがる機能の「横断的な設計書」になります。公式ペーパーではこうした成果物を "semantically rich context memory" と呼んでいて、AI がライフサイクル全体で参照する知識として機能します。 「1 機能 = 複数リポジトリ」というデータチーム特有の性質と、AI-DLC のコンテキストメモリの思想が、意外とうまくかみ合った部分です。 並列 Unit と audit.md 分散 モブとは別に、1 つの Intent の中で独立した複数 Unit を並列処理で進めるケースもあります。これは、Worktree で複数の Claude Code Agent を同時起動する方式です。このとき地味に困ったのが audit.md の Git コンフリクトです。並列の Agent が 1 つの audit.md に書き込もうとして衝突します。 対策として、 audit.md は Intent レベルのマイルストーンのみ記録する役割に限定し、Unit 内の詳細ログは construction/<unit>/audit.md に分散する運用にしました。このルールは .claude/rules/parallel-unit-audit.md に定義しています。 6. モブでの意思決定: なぜモブにしたか DREチームではメンバー 5〜6 名でモブワークを組み、1 日 3 ~ 4 時間をこれに充てています。全員フルリモートのため、公式ペーパー推奨の「物理集合 + 共有スクリーン」ではなく、Google Meet を接続して画面共有しながら進めています。本章では「なぜモブにしたか」と「どう使い分けているか」を DRE 視点で書きます。 3 つの狙い モブを採用する狙いは、マーク・パール『モブプログラミング・ベストプラクティス』で挙げられている利点と重なります。特に以下の 3 つが今の DRE に効いています。 フロー効率(Loss function を強化する) 章 2 で触れた通り、AI-DLC の各ステップには人間の検証・承認ゲートがあります("human oversight functions like a 'loss function'")。この承認が詰まるとフロー全体が止まる構造です。 AI の確認質問(clarifying questions)に即答できる人がその場にいないと、承認ゲートで数時間〜半日止まることもあります。Intent 単位で開発を進める AI-DLC では、並行して複数の Intent を抱えるより、少数に集中する方がリードタイムが短くなります。 少人数のDREチームでモブをやると、WIP は 1〜2 Intent に絞られ、意思決定の待ち時間がほぼゼロになります。モブは AI-DLC の Loss function を強化する実装とも言えます。ただし、外部チームへの依存がある場合はこの限りではなく、PR レビュー待ちや情報提供待ちになることはあります。 キーパーソンに頼らない Cloud Composer の統合や TROCCO から dlt への移行など、DRE は新しい基盤への切り替えを多く抱えています。こうした新基盤は「誰か一人だけが仕組みを分かっている」状態になりがちで、障害対応や次の意思決定がその 1 人に依存するリスクがあります。 モブで設計判断を進めると、全員が同時に基盤の意図を理解していきます。ドキュメントで読むのと、設計を議論しながら作るのとでは、後の理解度の深さが違います。 加えて、Intent の議論は Claude Code の audit.md に自然と記録されていくので、後から加入したメンバーが「なぜこの設計にしたか」を追えるようになります。口頭の議論を議事録に起こす手間が、AI-DLC の運用中に自動で払われる形です。 AI の出力の欠陥を減らす モブで AI の出力をその場でレビューすると、一人では見逃しがちな欠陥が減ります。厳密に比較計測したわけではないのですが、集合知がうまく効いている実感はあります。 AI の提案に対して「そこは業務仕様的にこう違う」「その構成だと監視が弱くなる」「別の選択肢の方が運用が楽」といった指摘が即座に入るので、Intent 完了後に気づく修正が減っていると感じます。 使い分け: 全員モブ / 2 分割 / 個人 タスクの性質に応じて、モブの粒度を変えています。 粒度 想定シーン 全員モブ(チーム全員) Intent の Inception、新基盤の設計判断、障害対応 2 分割モブ(2 ~ 3 人 × 2) 複数 Unit を並列で進められる Construction 個人ワーク 既知パターンの実装、軽微なドキュメント整備 判断基準は「不確実性」と「依存関係」です。不確実性が高いものは全員モブ。独立した Unit に切れるなら 2 分割。どちらも低い軽微な作業は個人。 公式ペーパーでも Mob Elaboration(Inception 相当)は必須、Mob Programming(Construction 相当)は分岐可能、と書き分けられていて、DRE の使い分けもほぼこの原則通りです。 リモートモブの実装と未解決の課題 公式ペーパーが想定するモブは「物理集合 + 共有スクリーン + ファシリテーター」ですが、DRE は全員フルリモートなので、ここを読み替える必要がありました。現在の構成は Google Meet + 画面共有 + 各メンバーのローカルエディタ(VS Code / Zed / Vim など人により様々)+ Claude Code です。 タイピスト交代 タイピスト交代は時間交代式(30 分サイクル)で、現タイピストが /aidlc-intent-save スキルで作業を保存・マージし、次のタイピストが自分のマシンで /aidlc-intent-continue で引き継ぎます。 試して撤退したもの VS Code Live Share : タイピスト交代のスイッチングコストを下げる狙いで試しましたが、ターミナル共有はできても接続環境によって表示が崩れ、肝心の Claude Code 拡張機能自体は Live Share で共有できなかったため断念しました タイマーの Claude Code スキル化 : タイピスト交代を時間で促すスキルを試作したものの、時間ベースで AI セッションに割り込む仕組みが安定せず、一旦撤退。今は Google Meet のタイマー機能で運用しています バットマン 『モブプログラミング・ベストプラクティス』に登場する「バットマン」(モブの外で外部からの問い合わせや割り込みに対応するメンバー)に倣い、その日の障害対応当番はタイピストに入れない運用にしています。データ基盤の障害対応はアラート監視と並行処理が必要で、モブに入ると集中を妨げるためです。 未解決の課題: スイッチングコスト 『モブプログラミング・ベストプラクティス』では 10 分ごとの交代が目安として紹介されていますが、DRE では現状 30 分が限界です。AI-DLC を始めた当初は 1 時間以上かかっていたのを、保存・再開処理の高速化や Google Meet のタイマーで短縮してきました。それでも、各メンバーが自分のマシンで作業するスタイルでは、保存・再開を速くしても、セッションを次の人のマシンに同期し直すスイッチングコストが残ります。10 分にはまだ遠いのが現状で、ここはリモートモブの構成上の制約として残っています。 7. 3 ヶ月の数字 AI-DLC を運用してみて、何が数字で変わったかを見ていきます。 計測の方針 2025 年 10 月〜 2026 年 4 月の PR を集計しました(4 月は 4/24 時点)。対象は、DREチームのメンバー(5〜6 名程度)が author の PR に限定しています。なお、ドキュメント中心の Workspace リポは /aidlc-intent-save で作られる即マージ PR が多く数字を歪めるため、集計は コードリポジトリのみ にしています。また、他チーム調整が必要で構造的に長い PR は、上位 5% を外れ値として除外しています。 Intent 完了数 一番わかりやすい変化は、Intent 単位で開発を進められるようになったことです。 月 完了 Intent ドキュメント成果物 2026-02 0(初月、進行中 1) 60 2026-03 14 263 2026-04(4/24 時点) 17 215 2 月は AI-DLC 導入初月で Intent の完了は 3 月にずれ込みました。3 月は 14 件完了、ドキュメントは 263 ファイルが積み上がりました。4 月は月途中(4/24 時点)で既に 17 件の Intent が完了しています。 PR 数 コードリポジトリのマージ済み PR 数(DRE メンバー author 分)です。 月 コードリポ PR 2025-11 36 2025-12 24 2026-01 43 2026-02 41 2026-03 74 2026-04(4/24 時点) 58 1 日 3 ~ 4 時間をモブに充てているので、「モブで時間を使っている分、実装量は減るのでは?」という懸念もありましたが、少なくとも PR 数の面では減っていません。2026-03 で 74 件、4 月は月途中で既に 58 件のペースです。 PR サイクルタイム DRE メンバーの PR サイクルタイム(作成〜マージ)です。外れ値除外後の値です。 月 PR 数 中央値 P90 2025-10 19 1.5h 64.9h 2025-11 36 10.6h 104.7h 2025-12 24 21.2h 94.5h 2026-01 43 2.1h 89.1h 2026-02 41 6.5h 137.5h 2026-03 74 2.6h 96.5h 2026-04(4/24 時点) 58 2.2h 89.6h 月ごとのばらつきが大きく、AI-DLC 導入前後で劇的な改善があるとは言いにくい数字です。ただ、モブで 1 日 3 ~ 4 時間を使いつつ中央値 2〜3 時間台で安定しているので、モブによる時間投入に見合う速度は維持できているとは言えそうです。 注記 この期間の数字を AI-DLC の効果だけで説明するのは慎重にしたいところです。大型案件の時期的な偏りや、メンバーの稼働割合など、他の要因も混ざっています。 それでも、Intent 単位で開発を進める仕組みが 2 月から稼働し、3 月に 14 件の Intent 完了まで回せるようになったのは定量的な変化です。PR サイクルタイムの劇的改善は見えていませんが、モブで使う時間分の生産性ロスが起きていない点は、少なくともマイナスの兆候は出ていないと言えます。 何が効いていそうか(仮説) PR サイクルタイムが変わっていない一方で Intent 完了数は増えている、という組み合わせから、いくつか仮説を立てられます(断定はできません)。 WIP の削減 : 少数の Intent にチーム全員が集中することで、リードタイムのばらつきが抑えられている可能性 レビュー待ちの削減 : モブで合意してから PR を作るので、PR 段階での非同期レビュー待ちが実質なくなっている(中央値を押し下げる方向) 外部依存が P90 を支配 : P90 は 60〜140h 台で大きく変わっていません。他チームとの調整や権限待ちで止まる PR が混ざっているためと思われ、ここは AI-DLC 単独では改善しにくい部分 記事執筆時点での仮説として記録しておきます。 8. やれていないこと 3 ヶ月で骨格はできた感覚がありますが、まだうまく回せていない領域もあります。 Operations フェーズ : DRE では /aidlc-ops-incident という障害対応スキルに留まっています。chanyou さんの記事で紹介されている Operations ワークフローを取り込むのが次のステップです ハーネスエンジニアリング寄りの自動化 : コードレビューはモブで全員でやっていますが、AI にコードをレビューさせる仕組み、Claude Code の Hooks / Agents の活用、AI 出力の評価ループなど、人間介入を減らす方向(いわゆるハーネスエンジニアリング)の仕組みはまだ薄いです。モブの検証密度は保ちつつ、自動化できる部分はそちらに寄せていきたいと考えています 本記事で触れなかった工夫 本記事は Inception と Construction フェーズの実装に絞ったため、以下のような工夫は紙面の都合で触れていません。続編で書く予定です。 多角的レビュー : コンテキスト分離型のサブエージェントで Inception / Construction 成果物をレビューする仕組み Knowledge Base 体系 : Intent 横断の仕様・運用ノウハウを knowledge-base/ に蓄積し、Intent 完了時に差分提案するルール 他チームとの擦り合わせルールの統合 : 連携する別チームとの合意事項を AI-DLC ワークフローに組み込み、PR 差分の自動準拠レビューも用意 効果計測・導入促進 : 月次効果計測レポートの自動生成と GitHub Discussions 投稿、社内全体の AI-DLC 導入状況レポート 運用ガードレール : 本番環境への破壊的操作を一律禁止するルール、bash コマンド実行規約 9. おわりに 3 ヶ月運用して現時点で落ち着いている構成を書き残しました。完成形ではないですが、この方向で続けるメリットはあると感じています。 公式ドキュメントには書かれていない実装の隙間を埋める例として、同じようにデータチームで AI-DLC を運用している方の参考になれば嬉しいです。 参考 AI-Driven Development Life Cycle: Reimagining Software Engineering — AI-DLC の理念を紹介した AWS DevOps Blog AI-DLC Method Definition(Raja SP, AWS)— https://prod.d13rzhkk8cj2z0.amplifyapp.com/ — 本記事で引用した "Human oversight as loss function" 等の出典 awslabs/aidlc-workflows — AI-DLC ワークフローの原典リポジトリ(MIT-0) Building with AI-DLC using Amazon Q Developer — Amazon Q Developer を使った実践ガイド マーク・パール『モブプログラミング・ベストプラクティス — ソフトウェアの品質と生産性をチームで高める』オライリー・ジャパン タイミーのデータエンジニアリング部 DRE G では、こうしたデータ基盤の構築と AI-DLC の運用に取り組んでいます。興味がある方は、以下よりプロダクト採用サイトをご覧いただけますので、ぜひカジュアル面談などお申込みください! 株式会社タイミー | プロダクト採用サイト
こんにちは、タイミーでSRE業務を担当している徳富(@yannKazu1)です。 先日、函館で開催されたRubyKaigi 2026に参加してきました。Ruby本体やパーサ、GC、JITといった「言語の中身」を深掘りするカンファレンスなので、普段アプリケーションコードよりインフラ寄りの仕事をしている自分が行って楽しめるのか、という気持ちも少しありました。ですが、結果としてとても楽しめたので、感想を書いておきます。 SREが行っても普通に楽しめた 普段の仕事はRailsアプリケーションを安定して動かしたり、スケールさせたり、観測したりすることが中心です。Ruby本体にコミットするわけでも、パーサを書くわけでもないので、専門外の話も多いだろうと思っていました。 実際、聞いていて全部の細部までは追えないセッションもありました。それでも、 普段ブラックボックスとして扱っているGCやランタイムの中身が、作っている人の口から語られる 他社のRails運用をやっている人たちと直接話せる このあたりだけでも参加する価値を感じました。 Day 1のブースが意外と良かった 参加されたことがある方ならわかると思いますが、Day 2以降はブース巡りが意外と慌ただしくなります。スタンプラリーで人が流れたり、セッションの合間で時間が限られていたり。 その点、 Day 1はスタンプラリーがまだ始まっていないので、ブースが比較的空いていてゆっくり話せる時間帯 でした。立ち寄っても順番待ちがほとんどなく、エンジニアの方とじっくり話せます。 RubyKaigiにはほぼ例外なくRailsを本番運用している会社さんがスポンサーとして出展しているので、SREとしては「他社さんのアーキテクチャや困りごとを直接聞ける場」として、これがかなり面白かったです。 Railsで完結する vs クラウドネイティブに振り切る 複数の会社さんと話して興味深かったのが、「Railsの中で完結させるか、AWSのマネージドサービスに切り出すか」の判断基準が会社によって全然違うことです。 Railsはよくできていて、ActiveJob + Sidekiq、ActiveStorage、ActionCableなどを組み合わせれば、大抵のユースケースはRailsの世界の中で完結します。わざわざクラウドネイティブなマネージドサービスに切り出さなくても、運用負荷を抑えながら回せるケースは多い。 一方で、「ジョブの遅延が事業KPIに直結するので、マネージドサービスに切り出して水平スケールを確実にしている」と話す会社さんもあれば、逆に「今のスタックで十分捌けているし、Rubyエンジニアが運用できる構成に揃えたほうが組織的に強い」と話す会社さんもありました。 技術選定の基準として挙がっていたのは、ざっくりこんな観点です。 スパイク耐性が事業上クリティカルかどうか 運用するチームのスキルセットと採用市場 コールドスタートを許容できるワークロードか RubyKaigiは登壇者も来場者もアプリケーションエンジニアが中心です。そのため、「Sidekiqのままいくか、それとも切り出すか」といったテーマひとつとっても、「アプリケーション側からどう見えているか、何が嬉しいかつらいか」という視点で語られていたのが印象的でした。 普段、SRE系のイベントで「インフラ側の都合」としてこの手の話を聞くことが多い私にとっては、この視点の対比が非常に新鮮に感じられました。 「正解は一つじゃない」と頭ではわかっていても、SRE目線とアプリ目線では同じ意思決定でも見えている景色が違います。両方の視点を持っておくことが大事だなと、改めて感じました。 AI活用の温度感 もう一つ、多くのブースでも話題になったのが AI活用 です。プロダクトへの組み込み、社内開発フローへの導入、営業・カスタマーサポートへの活用と、レイヤーごとに状況が違っていて面白かったです。 特に印象に残ったのが、SmartBankさんのブースで展示されていた「スマートバンクで働くAI Agentたち」のポスターでした。アプリユーザー向けの「 ワンバンフレンズ 」(家計を読み解いて気づきを届けるAIアシスタント)、社内メンバー向けの「 Ask! ワンバン 」(自然言語で社内データを検索・分析する分析AI)、そして開発者向けの「 Guardie 」(エラーや異常を検知してログ・コード・変更履歴を横断して原因特定を支援する番犬AI)という三本立てで、 ユーザー向け / 社内向け / 開発者向けの3レイヤーに対してそれぞれAIエージェントを配置している のがすごく整理されていて印象的でした。 特にGuardieはSRE視点でめちゃくちゃ刺さりました。「2時間覚悟していた調査が10分で終わった」という社内の声が紹介されていて、これは障害対応における MTTR(平均復旧時間)を本質的に短縮しに行っている 事例だなと。エラー検知 → ログ・コード・変更履歴を横断した原因特定までをAIに任せる、というのはこれから我々も作っていこうと思っていた仕組みが普通に動いていて、刺激を受けました。 ブース担当の方とは「どこまでをAIに任せて、どこから人間がやるべきか」「誤検知や暴走への安全装置をどう設計しているか」みたいな話までできて、こういう一次情報が聞けるのもRubyKaigiならではでした。 気になった話は、その後のアフターパーティでさらに深掘りできました。資料には載らない現場のリアルな知見が交換される場として、ブース + アフターパーティの組み合わせは、セッションと同じくらい価値があったと感じます。 参加したセッションを日ごとに振り返る ここからは、自分が参加して特に印象に残ったセッションを日ごとに紹介していきます。 Day 1: Exploring RuboCop with MCP (Koichi ITO さん) 1日目に聴いたのが、RuboCopコアチーム・MCP Ruby SDKチームメンバーのKoichi ITOさんによる、 RuboCopとMCP(Model Context Protocol) を組み合わせる試みについてのセッションです。 これまでRuboCopは「人間」または「他のプログラム(CIなど)」をきっかけに実行されてきました。そこにAI時代になって、 AIエージェントという新しい実行のきっかけ が登場した、というのが導入の話です。生成AIとリンター/フォーマッターをどう組み合わせるか、Rubyで実装されたMCPサーバーをエージェントの隣で走らせるとどうなるか、という内容でした。 技術的には、 MCP SDKの構造(サーバーとクライアントそれぞれのSDKがあること) トランスポート層として stdio と Streamable HTTP の2種類があり、用途で使い分けること HTTPトランスポートではセッション管理が肝になり、Pumaのようなスレッドモデル + シングルプロセス構成だと素直に動くが、複数プロセス・複数ホストに横断するとセッションの保持が難しくなること ただし Stateless Mode ( stateless: true )を使えば、Pumaの複数ワーカーやUnicornのような複数プロセス構成にも対応できること。ただしこれは リクエストごとに新しいtransportインスタンスが生成されるためMCP-Session-Idを共有できない という制約とのトレードオフであり、「セッション保持を諦める代わりに複数ワーカー/プロセスでもスケールできる」という割り切り あたりが特に勉強になりました。特にセッション管理の話は、MCPサーバーをWebアプリケーションとして本番に乗せようとすると、ロードバランサーやスケールアウトとの兼ね合いが出てくるという点で実践的でした。 ステートフル/ステートレスをどう使い分けるか は、これから考えないといけないテーマになりそうです。 セッションの締めくくりで「LLMの 確率的な性質 を決定的なツールに組み込むことで、これまでの決定的なツールとは違う未来が描ける」という話があって、そこも印象的でした。MCPの サンプリング (サーバーがクライアント経由でLLM補完を要求する仕組み)や、 Elicitation (実行中にユーザーへ追加情報を問い合わせる仕組み)といった機能は、ツールの形そのものを変えそうな予感があります。 スライド: https://speakerdeck.com/koic/exploring-rubocop-with-mcp Day 2: Chasing Real-Time Observability for CRuby (Shintaro Otsuka さん) 2日目で一番テンションが上がったのがこのセッション。CRubyの実行状態を リアルタイムに3D可視化する というツール「rrtrace」の話でした。 普通のプロファイラはサンプリングベースで、後から集計して結果を見る形が多いですが、このツールは「いまこの瞬間にCRubyの中で何が起きているか」を、複数スレッドのスタック状態として3次元空間にレンダリングしながら見せる、というアプローチです。デモを見せてもらった時、IRBに入力するたびにスタックが積み上がっていく様子がリアルタイムで見えて、純粋に「すごいものを見ている」という感覚になりました。 技術的なポイントとしては、 計測側のC拡張は軽量に保つ設計 で、イベントの収集と転送に特化している イベントは TracePoint API ( CALL / RETURN / INTERNAL_GC_ENTER / INTERNAL_GC_EXIT )や内部のスレッドイベント( INTERNAL_THREAD_EVENT 、こちらはWindowsでは利用不可)から収集し、timestamp(60bit) + event type(4bit) + method id/thread id(64bit)の合計16バイトの構造体に統一 C拡張(計測側)とビジュアライザプロセスの間は OS管理の共有メモリ上のリングバッファ で受け渡し ビジュアライザ側はCRubyのコアを使っていない別プロセスなので、可視化処理が重くてもCRubyの実行を直接ブロックしない スタックシミュレーションが重いため、 Parallel Scanアルゴリズム で並列化している という設計でした。ただし、スライドのベンチマーク結果を見ると、rrtrace有効時は 関数呼び出しスループットがplain CRubyの17%程度 (73,417,127 → 12,760,131 calls/s、約5.9倍遅くなる)、 Railsサーバーのrpsもplain CRubyの55%程度 (203.19 → 110.84 rps)になるとのことで、 計測のオーバーヘッド自体は「小さくない(not small)」 とスライドでも明記されていました。TracePointフックのコストが支配的で、ここは今後の課題とのことです。 それでも、「 重い処理を別プロセスに全部寄せる 」というアーキテクチャの考え方は面白かったです。計測側はできるだけ軽く保って、分析・可視化は別のリソースでやる。この割り切りが設計をシンプルにしていて、きれいだなと思いました。 「現代のマシンは10コア以上あるのが普通で、CRubyが1コアで動くなら残りのコアを観測やビジュアライズに自由に使える」という、リソースの捉え方の話も新鮮でした。GVLがある世界での観測ツールの設計思想として、納得感が強かったです。 スライド: https://speakerdeck.com/whitegreen/chasing-real-time-observability-for-cruby Day 3: The Less-Told Story of Socket Timeouts (Misaki Shioi さん) 3日目に聴いたのがこれ。ソケットライブラリのタイムアウトの 歴史と内部実装 を、Issue/Commitを参照しながらRuby 4.0までの流れに沿って解説していくセッションでした。タイトルからして気になっていたけど、期待以上の内容でした。 Socket.tcp / TCPSocket.new には、 resolv_timeout (名前解決のタイムアウト) connect_timeout (接続のタイムアウト) そしてRuby 4.0で追加された open_timeout (全体のタイムアウト) の3種類があります。「この3つがなぜ必要で、どういう順番で導入され、どんな歴史的な紆余曲折があったのか」を、Issue/Commitを参照しながら丁寧に追っていく構成でした。 特に印象的だったのが、 まず Socket.tcp に connect_timeout が導入され、続いて Addrinfo.getaddrinfo への timeout および Socket.tcp への resolv_timeout が追加されたこと resolv_timeout と connect_timeout を両方指定しても、全体のタイムアウト時間は制御できない (複数アドレスに対して逐次接続を試行するため、合計時間が想定より長くなりうる) これらの問題を解決するために、Ruby 4.0で 全体時間を管理する open_timeout が追加された という話の流れです。普段、HTTPクライアントの open_timeout / read_timeout / write_timeout を「だいたいこのくらい」で設定しがちですが、その下のレイヤーでは名前解決と接続が並行で走っていて、 タイムアウトの組み合わせによっては想定と全然違う挙動になる ということを、改めて意識させられました。 また、歴史的な経緯として特に面白かったのが、 名前解決の中断可能化(interruptible)をめぐる話 です。 getaddrinfo(3) はブロッキング呼び出しで、 Ctrl+C でも中断できないという問題が長年ありました。これを解決するアプローチとして、まず 2020年1月に「 Addrinfo.getaddrinfo が timeout をサポートする」提案が行われ、そのパッチで Addrinfo.getaddrinfo に timeout 、 Socket.tcp に resolv_timeout が追加されると同時に、内部的に「GNU拡張の getaddrinfo_a(3) が利用可能ならそれを使う」実装が入りました ( getaddrinfo_a(3) はワーカースレッドで非同期に名前解決を行う仕組み)。その後 2020年8月には「Make Socket.getaddrinfo interruptible」がマージされ、 Socket.getaddrinfo の内部もこの getaddrinfo_a(3) を使うように利用範囲が拡張 、さらに 2020年9月には TCPSocket.new にも resolv_timeout / connect_timeout が追加 され、名前解決を中断可能にする方向で進められました。 ところがその後、 Rails ActiveJobの統合テストが失敗するようになった という報告が入り、調査の結果、 fork後の子プロセスで getaddrinfo_a(3) を呼び出すとハングする ことが判明します。 getaddrinfo_a(3) は内部で再利用可能なワーカースレッドを保持しているのですが、forkでコピーされる子プロセスにはワーカースレッドが存在しないにもかかわらず内部状態は「ワーカースレッドが待機中」のままになっており、これによってデッドロックが発生する、という仕組みでした。 2ヶ月以上の調査と回避策の検討を経て、最終的には getaddrinfo_a(3) の導入自体が撤回 され、関連変更もrevertされました。その代わり、後に別アプローチとして 「名前解決ごとに専用のpthreadを立てて getaddrinfo(3) を実行する」方式 (mameさん提案、 ruby/ruby#8695 )が採用され、こちらは rsock_getaddrinfo 内に実装されることで、内部的に rsock_getaddrinfo を呼んでいる Addrinfo.getaddrinfo や Socket.getaddrinfo を含む幅広いメソッド で名前解決のブロッキング問題が解消された、という流れです。 外部API連携で「タイムアウトを設定したはずなのにハングする」「 connect_timeout を短くしたのに、複数アドレスがあるホストで合計時間が想定の何倍もかかる」みたいな経験がある人は少なくないと思いますが、まさにあれの背景にある話でした。タイムアウト設計の見直しや、Ruby 4.0以降は open_timeout を積極的に使っていくこと、テストでタイムアウト周りの挙動を確認しておくことなど、すぐに持ち帰れる学びがいくつもありました。 スライド: https://speakerdeck.com/coe401_/the-less-told-story-of-socket-timeouts リモートワーク時代の副次効果 もう一つ書いておきたいのが 社内メンバーとの関係性 の話です。 弊社エンジニアはリモートワーク中心で、普段の業務だと開発チームの全員と毎日話すわけではありません。SlackやZoomでは話すけれど、雑談ベースで「最近どう?」みたいな会話になりにくい人もいます。 それが、RubyKaigiで3日間一緒に過ごすと一気に距離が縮まります。一緒にセッションを聞いて、休憩中に「今のどう思った?」と話して、夜は飲みに行って、移動中に雑談する。この3日間の密度は、リモートでの数ヶ月分のコミュニケーションに相当するんじゃないかと思います。 おわりに RubyKaigiは「Ruby本体に関わっている人たちのお祭り」という側面が強いカンファレンスですが、Rubyを本番で動かしている側の人間にとっても十分に楽しめる場でした。SREとしても、ランタイムの理解が深まったり、他社の運用知見をもらえたり、社内の関係性が深まったりと、副次効果を含めて満足度の高い3日間でした。 普段Railsを動かしているSREの方や、これからRubyKaigiどうしようかなと迷っている方の参考になれば嬉しいです。