MNTSQのブログ - TECH PLAY

TECH PLAY

MNTSQ

MNTSQ の技術ブログ

104

こんにちは、MNTSQ株式会社セキュリティ推進室の北村です。 セイ・テクノロジーズ株式会社・株式会社K-model共催のオンラインセミナー「実際どうやる? ISMS取得企業がSCS評価制度に対応するためのセキュリティ運用設計」(2026年5月22日開催)に参加しました。 「SCS評価制度」という名前はここ最近よく耳にするようになりましたが、「ISMSと何が違うの?」「本当に対応が必要なの?」という疑問を持っている方も多いのではないかと思います。このレポートでは、参加を通じて理解したことや気づきをまとめてお伝えします。 なぜセミナーに参加したのか MNTSQは、SaaS型契約管理プラットフォーム「MNTSQ CLM」を提供しています。すでにMNTSQ CLMをご利用いただいているお客様には大手企業も多く、そうした企業のサプライチェーンの一端を担う「受注側」でもあります。 弊社ではISMS認証(ISO/IEC 27001)を取得しています。お客様からお預かりする重要な情報をしっかり守るために、ISMSの枠組みに沿って本質的にセキュリティを高めることが取得の動機であり、認証はその結果として位置づけています。 こうした取り組みの先にある目標は、大きく2つあります。ひとつは、 既にご利用いただいているお客様にこれからも安心して使い続けていただくこと 。もうひとつは、 新たに契約管理サービスをご検討いただく企業様に、安心してMNTSQ CLMを選んでいただけること です。 SCS評価制度も、発注企業が受注企業に対してセキュリティ水準を取引条件として提示する枠組みです。「私たちはどういう水準でセキュリティに取り組んでいるか」を示せることは、お客様への誠実な説明責任のひとつだと考えています。 また、SCS評価制度は経済産業省が 2026年度末頃の制度開始 を予定しており、業界全体での注目度が急速に高まっています。制度の実態を早めに把握しておきたいというのが、今回のセミナー参加の動機でした。 改めてSCS評価制度とは SCS評価制度の正式名称は「 サプライチェーン強化に向けたセキュリティ対策評価制度 」です。経済産業省が推進する、日本のサプライチェーン全体のサイバーセキュリティを向上させるための仕組みです。 本制度の目的については、セミナーのスライド「SCS評価制度 本来の目的」が非常にわかりやすく整理していました。 「SCS評価制度 本来の目的」(セイ・テクノロジーズ株式会社・株式会社K-model共催セミナーより) 目的はシンプルで、企業単体ではなく、 サプライチェーン全体での「防御力」と「回復力(レジリエンス)」を高める ことです。評価の段階としては★3と★4が設けられています。 ★3 :一般的なサイバー攻撃を想定し、全サプライチェーン企業が最低限実装すべき基礎的な対策(要求事項26件・専門家確認付き自己評価) ★4 :供給停止や情報漏えいなど影響の大きい攻撃を想定し、取引先管理・インシデント対応等を含む包括的な対策(要求事項43件・第三者評価) ★3ではすべてのサプライチェーン企業に基礎的な防御の底上げが求められ、★4ではより高度な脅威への包括的な備えが求められるという二段構えの構造です。 ISMSとの違い:抽象から具体へ セミナーで最も勉強になったのが、ISMSとSCS評価制度の違いについての解説でした。 ISMSは「フレームワーク型」、SCSは「実装指定型」 ISMSは「マネジメントシステム」です。組織が自らリスクを評価し、「リスクに応じた適切な手段」を選んで実施するという構造になっています。要求事項は意図的に抽象的で、組織の規模・業種・リスク環境に合わせた柔軟な対応が可能です。 SCS評価制度はその発想が異なります。「適切な手段を選びなさい」ではなく、「この要件を実装しなさい」という形で、数値・期限・手順まで踏み込んで具体的に規定されています。 セミナーのスライド「ISMSとSCS評価制度の代表的な要求事項比較(★3)」には、この違いが対比の形でまとめられていました。 「ISMSとSCS評価制度の代表的な要求事項比較(★3)」(セイ・テクノロジーズ株式会社・株式会社K-model共催セミナーより) ISMSは「何をするか」を組織が自ら決めるものですが、SCSは「どうするか」まで定めているイメージです。スライドを見ると、ISMSが「リスクに応じた適切な手段で対処する」と書くところを、SCSは「CVSS基本値7.0以上は14日以内にパッチを適用する」と数値で明示しており、その具体性の差は一目瞭然です。 両者は対立しない 重要なのは、ISMSとSCS評価制度は対立するものではなく 補完関係 にあるという点です。セミナーでは「ISMS取得済みの企業であれば、SCS★3の要件の6割程度はすでに満たしている状態にある」という肌感覚も紹介されていました。 ISMS取得済みの組織にとっては、これまでの積み上げをそのままSCS対応の出発点にできるという、心強い話でした。 気づき・所感:目的が大事、手段を目的化しない セミナーを通じて最も強く印象に残ったのは、講師の近藤誠司さん(株式会社K-model)が繰り返し強調していたこのメッセージです。 「目的が大事。手段を目的化しない」 SCS評価制度への対応も、ISMSの維持も、あくまでセキュリティの実力を高めるための「手段」です。評価の取得を目的にした瞬間、本来の「防御力と回復力の向上」という目的が霧散してしまいます。 「特定の製品を導入しないとSCS評価が取れない」という誘い文句も同様です。製品導入の目的を見失うと、コストをかけても実態が伴わないセキュリティ対応になりかねません。 一方で、SCS評価制度には「具体的であること」のメリットもあると感じました。「なぜこの対策が必要なのか」を数値や根拠とともに説明しやすくなるため、社内への説明・合意形成がしやすくなります。抽象的な「リスクに応じた対策」よりも、「SCS★3ではCVSS7.0以上の脆弱性に14日以内のパッチ対応を求めている」という具体的な根拠のほうが、経営層への説明においても説得力を持ちやすいのは事実です。 ISMS取得済みの組織として、私たちが意識すべきは「評価を取りに行く」ことではなく「足りていないところを実態として埋める」ことだと整理できました。 おわりに SCS評価制度はまだ制度開始前の段階ですが、要件の方向性はすでに公開されており、対応を検討し始めるのに遅すぎるタイミングはありません。ISMS取得済みの組織であれば、差分の特定から始めるのが現実的なファーストステップだと思います。 このレポートが、同じようにSCS評価制度を調べているセキュリティ担当者の方の参考になれば幸いです。 なお、SCS評価制度は 任意の制度 です。「評価を取得していないと取引ができない」「今すぐ取得しないと入札から除外される」といった勧誘を受けた場合は、制度の趣旨とは異なります。経済産業省からも 注意喚起 が出ていますので、情報収集の際はご注意ください。 本記事は、 セイ・テクノロジーズ株式会社・株式会社K-model共催セミナー (2026年5月22日開催)の内容をもとに執筆しました。スライド・資料の引用元は同セミナーです。 セミナーのアーカイブ配信はこちらからご覧いただけます。 https://www.say-tech.co.jp/seminar-archive/saytech-seminar-sys-op-20260522
はじめに セキュリティ推進室の山田です。 MNTSQは2025年10月にオフィスを移転しました。 その際にオフィスネットワークを構築しましたが、Web会議の通信遅延や定期的なネットワークの不調がたびたび発生し、全社的な課題となっていました。 その調査をするには可視化が必要と判断し、SNMPでネットワーク機器のメトリクスを収集してDatadogに集約する構成を作りました。この記事はその取り組みのひとつで、SNMPマネージャーの構築について紹介します。 課題:ネットワークの状態を一元的に把握できない MNTSQのオフィスネットワークはルーター、L3スイッチ、L2スイッチ、APで構成されていますが、複数メーカーの機器が混在しています。各機器にはそれぞれ管理画面が用意されていますが、ネットワークの状態を確認したいときに機器ごとにログインしなければならず、全体像を把握するのに手間がかかる状態でした。 また、管理画面では「今、何が起きているか」を判断するのが難しく、機器の状態を機器単位で確認するよりも、帯域やエラーパケットなどのメトリクスとして時系列で見たいという欲求がありました。 方針:SNMPマネージャーを用意して一元収集する SNMPはエージェントレスでネットワーク機器からメトリクスを収集できるプロトコルです。各ネットワーク機器でSNMPを有効にし、 SNMPマネージャー (情報を収集するホストマシン)が定期的にポーリングすることでメトリクスを取得します。なお、今回はSNMPのバージョンは認証と暗号化に対応したV3を採用しています(機器がV3に対応していることが前提で、古い機種ではV1/V2cしか使えない場合もあります)。 SNMPマネージャーは監視対象のネットワーク機器と同じネットワークに到達できる必要があります。これを満たす方法は2つあり、ひとつはオフィスに物理マシンを設置する方法、もうひとつはAWSとプライベートネットワークを構築してAWS側にSNMPマネージャーを用意する方法です。今回は構築スピードと運用コストの観点から、物理マシンでの構築を選びました。 収集したメトリクスを最終的に可視化するために、今回は社内ですでに利用されているDatadogを採用しました。Datadog AgentをSNMPマネージャーで動作させ、SNMPで収集したメトリクスをDatadogに送信することで可視化できます。 設計:構成の全体像 全体の設計イメージ 要素 内容 OS(SNMPマネージャー) Ubuntu SNMPバージョン V3 監視エージェント Datadog Agent 設計:SNMPマネージャー構成の選択 今回はすぐにスタートするためにも、SNMPマネージャーとして余剰のラップトップを転用しました。SNMPによるメトリクス収集はポーリングが中心で処理負荷が軽く、ハイスペックなマシンは必要ないため、社内で使われなくなったラップトップでも十分にまかなえます。 専用のPCを調達するよりコストをかけずに着手できるうえ、バッテリーを内蔵しているため瞬断や短時間の停電があってもポーリングが途切れにくく、24時間稼働する監視ホストとして適していると判断しました(バッテリーが簡易的なUPSのように働きます)。 また、運用を開始してからの課題感の洗い出しや継続利用するかどうかの判断をするためにも、イニシャルコストを抑えて始められることは大きな利点でした。まずは手元のリソースでスモールスタートし、有用性が確認できた段階で専用機への置き換えを検討する、という進め方としました。 設計:AWS SSMでリモートアクセスできるようにする SNMPマネージャーはオフィスに常設となるため、リモートワーク時に設定変更やトラブルシュートで直接アクセスしたくなるタイミングは少なくありません。 そこで、今回は AWS Systems Manager(SSM) のSession Manager機能を使って、ブラウザやCLIからリモートでシェルに接続できるようにしました。選んだ理由は次のとおりです。 プロダクトのインフラ環境としてAWSを利用している AWSへのアクセスはAWS IAM Identity CenterでSSOを利用してユーザーのアクセス管理を行っている この2点により、SNMPマネージャーへのアクセスも既存の権限管理の仕組みにそのまま乗せられます。さらにSSH用のポート開放やVPNを用意する必要がなく、インバウンドの口を増やさずにリモート運用できる点もメリットでした。 下図はAWS SSMを使ったアクセス経路の全体像です。 AWS Session Managerを使ったリモートアクセスのイメージ ワンポイント: 今回のSNMPマネージャーはEC2ではなくオフィスのラップトップ(オンプレミス機)です。AWS外のマシンをSSMの管理下に置くには、SSM Agentを入れてハイブリッドアクティベーション(AWS外のサーバーをマネージドインスタンスとして登録する仕組み)で登録します。なお、EC2ならSSMの機能を追加料金なしで使えますが、オンプレミス機でSession Managerを使うには有料のadvanced-instancesティアが必要で、1インスタンスあたり約$5/月かかります。 構築で苦労した点 ネットワーク機器のSNMP設定にベンダー調整が必要だった ルーターとL3スイッチは自社で管理しているため、SNMP V3の設定を自分たちで行えました。一方、L2スイッチとAPはネットワーク構築を委託したベンダーが管理しており、SNMPの有効化や設定変更は自分たちでは行えず、ベンダーへの依頼が必要でした。依頼にあたっては、SNMP V3で使う設定値(SNMPユーザー名、認証・暗号化の方式とパスフレーズ、SNMPマネージャーからのアクセスを許可するIPアドレスなど)をこちらで決めて伝える必要があり、設定内容のすり合わせにもやり取りが発生していました。実際、依頼から対応までには数日のタイムラグがあり、設定作業が断続的になりました。 ベンダーでネットワーク機器を管理するメリットもある一方で、自分たちの管理下にないとこういった取り組みのフットワークが落ちるという点はもどかしかったです。また、ベンダー管理下だとSNMPの設定は保守の対象外だったりもするので、良し悪しがあると実感できました。 Datadogでのメトリクス取得にMIBプロファイルの適用が必要だった 機器とのSNMPの疎通は取れたものの、それだけではDatadog上にメトリクスは表示されないものがありました。Datadogは機器の種類をsysObjectID(機器が返す識別子)から判定し、それに対応するMIBプロファイル(どのOIDをどのメトリクスとして扱うかの定義)を適用してはじめてメトリクスを取得できます。Datadog SNMP Profile Managerには標準で多くのプロファイルが用意されています。しかし機器によっては、対応するプロファイルがなかったり、プロファイルはあってもすべてのメトリクスが取得できなかったりと、標準のままでは十分に取得できないことがありました。こうした機器に対応するにはMIBそのものへの理解が欠かせず、知識がないとスムーズには進められない部分でした。 この点については別記事で詳しく解説したいと思います。 次の記事へ この記事では、オフィスネットワークを可視化するために、SNMPマネージャーをどう用意したか(余剰ラップトップの転用)、AWS SSMによるリモートアクセス、そして構築でつまずいた点を紹介しました。 別記事では、収集したメトリクスをDatadogでどう監視・可視化していくか、Datadogにフォーカスした取り組みを紹介する予定です。
はじめに 実態調査 課題感 改善 1. Redash 内部 DB から events を吸い出して Athena に載せる 考慮箇所 2. 通知本文に操作内容の要約を載せる 効果 おわりに はじめに 弊社では BI ツールとして Redash を運用しています。操作内容の監査を考える場合、BI ツールという性格上、誰がいつどのデータソースに対して何を実行したかを後から追える状態を保つことは、監査の観点で外せない要件になっています。 これに対し、営業時間外(平日深夜早朝・土日終日)に Redash 上で操作の形跡があれば、その操作者本人に Slack 上で利用目的の回答を促す、という監査運用を敷いてきました。検知した操作を放置せず、必ず本人に説明責任を返す、という運用思想を名前にした格好です。なお、操作者本人まで辿れるのは、Redash へのログインを IAM Identity Center を IdP とする SSO に寄せているためです(後述)。 監査対象者が Redash 上でどういった操作をしたか追跡したいケースにおいて、Redash の実装上の都合により、追跡の材料にログだけを用いると いつどういったクエリが実行されたか そのクエリはどのデータソースを対象として実行されたか といった事項が難しいという課題があります。 本稿は、ログに乗ってくる情報が弱かった Redash の操作ログ監査を、Redash 内部の DB を引っ張り出すことでなんとか実用に堪える水準まで引き上げた話になります。 実態調査 監査通知の仕組みは動いていますが、これは営業時間外の検知に特化した運用です。平日日勤帯も含めた全期間で日常的にどんな操作が行われているのか、その全体像については、一度じっくり把握しておきたいところでした。 そこで既存の Athena 基盤を用いて、半年分の操作を全数で棚卸ししてみました。Redash の操作ログを Athena で追えるようにした整備そのものについては、以下拙稿を参照ください。 ユーザ別・操作種別・時間帯・接続データソース別など、思いつく限りの切り口で集計してみたのですが、ここで一つ無視できない事実に行き当たりました。 人手による Redash のクエリ実行のうち、その大半が「保存済みクエリ」ではなく、その場限りの adhoc 実行 だったのです。Redash では保存済みクエリを開いて実行すると個別のクエリ ID が記録されますが、エディタにその場で SQL を打ち込んで流す adhoc 実行は、すべて query_id が adhoc という固定値で記録されます。 つまり、日常の操作のほとんどが、保存済みクエリではなく、その場限りの adhoc 実行で占められていたわけです。冒頭で触れた「どんなクエリを、どのデータソースに対して流したか」を追いにくいのは、まさにこの adhoc 実行でした。日常の操作の主役がここである以上、その中身まできちんと追える状態にしておく意義は大きい、ということになります。 課題感 では、なぜサーバログだけでは adhoc 実行の中身まで取れないのか、既存の監査ログの作りから振り返ります。Redash のサーバログは CloudWatch Logs 経由で S3 に外出ししてあり、Athena から横断的にクエリできるよう整備済みでした(この収集パイプラインをどう組んだかは 前掲記事 で扱っています)。ただしこのログから操作内容を抽出する view は、ログ行のうちジョブ投入を示す行を正規表現で拾い上げるだけの作りになっていました。 この方式で取れるのは、おおむね次の情報に限られます。 いつ実行されたか(実行時刻) 誰が実行したか(実行ユーザ) どのクエリ ID か( adhoc 実行の場合はすべて adhoc ) 裏を返すと、 実際に流した SQL 本文 どの データソース (=どのテナント DB)に対して実行したか が、まるごと欠落していました。営業時間外に adhoc 実行が検知されても、通知を受け取る側が分かるのは「Redash で何か操作した」という事実までで、「どのテナント DB に対してどんな SQL を流したか」という、監査でまさに知りたい肝心の中身に踏み込めなかったのです。 検知して本人に回答を強制するところまでは出来ているのに、回答を突き合わせる材料が手元に無い。この中身を補うことが、今回の出発点になりました。 改善 やるべきことがわかりました。改善をやっていきます。具体的には以下のような取り組みをおこないました。 Redash 内部 DB から events を吸い出して Athena に載せる 通知本文に操作内容の要約を載せる 1. Redash 内部 DB から events を吸い出して Athena に載せる 前述のとおり、SQL の本文とデータソースはサーバログには出ず、Redash 自身が内部状態の管理に用いる Postgres、その events テーブルにのみ記録されます。 adhoc 実行であっても、流された SQL の本文も接続データソースの ID も、この内部 DB の中にはきちんと残っています。外向きのログが弱いぶんは、この内部 DB から補えばよい、という理屈になります。 とはいえ、本番で稼働している Redash の Postgres へ監査の都合で直接コネクションを張りにゆくのは避けたいところでした。そこで、Redash 自身が events を読み出す API を備えている点を活かし、方針は次のとおりとしました。 events テーブルを 日次で Redash の API 経由 で取得する 取得結果を、既存の監査ログ集約用 S3 バケットに置く Athena 側から Glue table として読み、監査用の view に合流させる events 参照専用のユーザを Redash に用意し、その API キー経由で events を吸い出します。実際に投げているのはおおよそ次のようなクエリで、 execute_query (クエリ実行)のイベントに絞り、誰が・いつ・どのデータソース( data_source_id )に対して・何を実行したか( details に SQL 本文が入る)を取り出します。 SELECT e.id, e.created_at, u.email, e.action, e.object_type, e.object_id AS data_source_id, e.additional_properties::text AS details FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.created_at >= ( current_date - interval ' 2 days ' ) AND e.action = ' execute_query ' ORDER BY e.created_at 取りこぼしを避けるため当日分に前日分の余裕を持たせて取得し、重複は後段の view で排除しています。取得結果は日付別のプレフィックスを切って NDJSON で S3 に PUT します。SQL 本文には改行やカンマが含まれうるため、CSV ではなく 1 行 1 レコードの NDJSON を選んでいます。 S3 側は日付(年/月/日)でパスを切ってあるので、Athena 側は partition projection でパーティションを自動認識させ、クローラを別途回す必要はない格好にしてあります。Glue table の設定はこうです。 parameters = { "projection.enabled" = "true" "projection.dt.type" = "date" "projection.dt.format" = "yyyy/MM/dd" "projection.dt.range" = "2026/05/01,NOW" "projection.dt.interval" = "1" "projection.dt.interval.unit" = "DAYS" "storage.location.template" = "s3://<監査ログ用バケット>/events/$${dt}/" } dt パーティションを日付として NOW まで射影しておけば、新しい日付のオブジェクトが増えても定義変更もクローラ実行も要りません。NDJSON は JsonSerDe で読み込みます。 その上で、既存の監査用 view に対して、 adhoc 実行の行へ events 由来の SQL 本文とデータソース情報を LEFT JOIN で補完 しました。これにより、従来は「 adhoc を実行した」としか言えなかった行に、「どのデータソースに対して、どんな SQL を流したか」という中身が紐付くようになりました。 全体の構成図は以下のとおりです(通知への Bedrock 要約は「改善その2」で後述します)。 図。Redash ユーザ = IAM Identity Center ユーザという前提があります。Redash へは IAM Identity Center を IdP とする SSO ログイン体制を敷いています 考慮箇所 実装上ひとつ難儀したのが、この日次バッチの Lambda から Redash の API を叩く経路でした。前提として Redash は ALB の背後に立っており、その手前で接続元 IP を許可リストで絞ることで、限られたネットワークからのみ到達できるようにしてあります(EC2 + ALB でどう立てているかは前掲の拙稿でも触れています)。Lambda は VPC 内で動かしているため、その出口となる NAT Gateway の EIP をこの許可リストに足さないと、API までたどり着けません。 ここで安易に既存の社内 CIDR 変数へ EIP を混ぜ込むと、その変数はセキュリティグループなどで広範に再利用されているため、意図しない範囲まで許可が広がってしまいます。そこで NAT Gateway の EIP を /32 のリストとして locals で組み立て、Redash のリスナルール専用に連結する、という形に切り分けました。 locals { # events export Lambda は VPC 内から NAT GW 経由で外向きに出る # その NAT GW EIP を Redash ALB リスナルールの source_ip allowlist に含めて API 到達性を確保する # 既存の社内 CIDR 変数には混ぜない (SG 等で広範に再利用されており副作用が大きいため) redash_listener_rule_source_cidrs = concat ( var.allowed_cidrs, [ for ip in module.vpc.nat_public_ips : "$ { ip } /32" ] , ) } もうひとつ細かいハマりどころがあります。ALB のリスナルールは 1 ルールにつき条件値を 5 個までしか持てず、Host ヘッダの条件で 1 つ消費しているため、IP の条件値は 4 個が上限になります。許可したい IP がそれを超えるので、 chunklist で 4 個ずつに割り、ルールを複数に分けて回避しています。 resource "aws_lb_listener_rule" "redash" { count = ceil ( length (local.redash_listener_rule_source_cidrs) / 4 ) listener_arn = aws_lb_listener.admin.arn action { type = "forward" target_group_arn = aws_lb_target_group.redash.arn } condition { source_ip { values = chunklist (local.redash_listener_rule_source_cidrs, 4 ) [ count.index ] } } condition { host_header { values = [ aws_route53_record.redash_external.fqdn ] } } } 監査の仕組みを足したことで別の口がうっかり広がる、というのは本末転倒につき、ここは経路を限定して取り扱っています。 2. 通知本文に操作内容の要約を載せる 中身が Athena 側で取れるようになったとはいえ、それだけでは監査担当が S3 上のデータを手で開いて目を通さないと内容を把握できません。せっかく SQL 本文が手に入っても、通知を見た人が毎回データを開きに行くのでは運用として回りません。 そこで、本人へ送る Slack 通知の本文そのものに、実行内容の要約を載せることにしました。要約には Amazon Bedrock を使い、以下のような観点でまとめさせています。 どのデータソースに対して何件の操作があったか SQL の動詞分布(SELECT / UPDATE / DELETE などの内訳) 連続して実行された SQL のおおまかな流れ 従来の通知本文は、実のところ時間範囲を伝えるだけのものでした。 2026-06-20 22:00〜23:30 (JST) に Redash での操作が検知されました。 営業時間外の操作のため、利用目的の確認をお願いします。 これが、要約を載せたことで次のような格好になります(以下は架空の例です)。 2026-06-20 22:00〜23:30 (JST) に Redash での操作が検知されました。 営業時間外の操作のため、利用目的の確認をお願いします。 【操作内容の要約】 - データソース A に対して SELECT を中心に 12 件 - 特定レコードの状態を確認するための参照系クエリが主体 - 更新系(UPDATE / DELETE)の実行は検知されていない この要約は、events から組み立てたクエリ実行の一覧を Amazon Bedrock に渡し、次のようなプロンプトで生成しています(抜粋)。 details に入っている SQL 本文から動詞と対象テーブルを読み取らせ、更新系は埋もれないよう個別に列挙させる、といった監査向けの要件をルールとして与えています。 ## 出力フォーマット Slack の mrkdwn 記法を使用してください。 🗓 *最終検知日時:* YYYY-MM-DD HH:MM (JST) 💡 *操作概要:* (SQL の動詞 (SELECT / INSERT / UPDATE / DELETE 等) の分布、頻繁にアクセスされたテーブル、目的推測などを 2〜3 行で要約してください) 📋 *検知された操作:* ・クエリ説明 (HH:MM) ... ## ルール - eventtime は UTC なので JST (+9 時間) に変換してください - 各クエリは details.query から SQL の動詞と主要なテーブル名を読み取り、「テーブル名から〜を取得」のような日本語の操作説明に変換してください - adhoc 実行の場合は SQL の概要 (動詞 + 対象テーブル) を、保存クエリの場合は「保存クエリ #<query_id> の実行」を主としてください - UPDATE / DELETE 等のデータ更新系クエリは個別に列挙してください (営業時間外の更新は要注意のため) - 最大 20 件まで表示し、それ以上は「…他 N 件」としてください 実は、手動オペレーションや S3 イベント経由の監査通知では、すでに Bedrock 要約を載せた通知に切り替え済みでした。こうした操作を検知して本人に背景を問い合わせる監査運用、そしてその通知に Bedrock 要約を載せる仕組みそのものの成り立ちについては、弊社ブログに詳しい記事があります。 今回 Redash のクエリ実行経路も同じパターンに揃えた、という位置付けです。これにより、通知を受けた本人もレビューする側も、S3 のデータを開きに行くことなく「何が起きたか」の概要を Slack 上で把握できるようになりました。 なお、ログイン検知のような詳細データを持たない経路は、従来どおり時間範囲だけの通知で十分につき、Bedrock 要約の対象には含めていません。要約する中身を持つ経路にのみ要約を載せる、という切り分けです。 効果 一連の対応を経て、監査の解像度は次のように変わりました。 これまで「 adhoc を実行した」としか言えなかった操作に、SQL 本文とデータソースが紐付くようになった 監査担当が S3 のデータを開きに行かずとも、Slack の通知本文だけで「どのデータソースに、どんな種類のクエリが流れたか」の概要を把握できるようになった 検知 → 本人への回答強制、という従来の運用に、回答を突き合わせるための材料が揃った 監査ログは、取得を始めて直ちに何らか嬉しさが生じるようなものではありませんが、いざインシデント対応や定期監査で「あの操作は何だったのか」を遡る局面でこそ効いてくる、将来への投資の類だと考えています。 おわりに 今回の対応は、半年分を一度きちんと棚卸ししてみたことが起点でした。日常の操作の大半が、ログだけでは中身を追いにくい adhoc 実行で占められていると数字で分かったことで、内部 DB を覗いてでも中身を補いにゆく方針が固まった格好です。調査が改善を呼ぶ、という順序を地で行く一連となりました。 Redash の操作ログ監査に取り組む向きに、手法の事例のひとつとして参考になれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は、構成図を除く文章の8割程度を、文責者の過去記事や社内の関連ドキュメントをもとに Claude Opus 4.8 が執筆しています。 追伸:本稿の執筆・推敲にあたっては、日本語技術文書の規範をまとめた k16shikano 氏の文書 を参照しました。記して感謝します。
はじめに SREの寺島です。 Amazon SQS をタスクキューのブローカーとして使うとき、実行時間の短いジョブと長いジョブが混在していると、処理に時間のかかるタスクが途中で再配信されてしまう問題に当たります。可視性タイムアウト(Visibility Timeout)を長くすれば防げますが、長くするほど障害時の復旧が遅くなるというトレードオフがあります。 これを解決するのが、可視性タイムアウトを短く設定し、処理中にハートビートで動的に延長する方法です。本記事では Celery を使った実装例を紹介します。 はじめに SQS + Worker の非同期処理の構成 可視性タイムアウト設計の難しさ 解決策 具体的な動作 実装サンプル ReceiptHandle / Queue URL の取得 ハートビート本体 タスク側 注意点 動作確認 通常時:長時間タスクでも再配信されない クラッシュ時:Worker A が落ちると Worker B が引き継ぐ 最後に SQS + Worker の非同期処理の構成 まず前提として、Celery + SQS でよくある非同期処理の構成を示します。 クライアントからジョブのリクエストが送られる API がリクエストを受け付けてキュー(SQS)に enqueue し、クライアントに即座にAcceptedレスポンスを返す Worker が SQS に入ったメッセージを拾って処理を実行する 可視性タイムアウト設計の難しさ 可視性タイムアウトとは、Workerがメッセージを受信してから、そのメッセージが他のWorker から見えなくなる(再受信されなくなる)時間のことです。Worker はこの時間内に処理を終えてメッセージを削除します。削除されないままタイムアウトが切れると、メッセージは再び可視化され、別の Worker に再配信されます。 ここで問題になるのが、実行時間にばらつきのあるジョブです。例として、LLM を用いた処理を考えます。こうした処理は入力の大きさや複雑さ、呼び出し先 API のレイテンシによって実行時間が大きく変動し、多くは数秒〜10 秒程度で終わるものの、入力によっては数分、最悪で数十分かかることもあります。 可視性タイムアウトは最も遅い処理時間に合わせるのが定石ですが、 長すぎると障害時の復旧が遅れます 。この例で可視性タイムアウトを数十分に設定すると、Worker がクラッシュしてもメッセージは「処理中」として扱われ続けるため、その数十分間どの Worker も再処理しません。 逆に 短すぎると、重複処理の無駄が発生します 。処理が終わる前にメッセージが再可視化され、別の Worker が同じジョブを処理してしまうためです。 冪等性を担保すれば、重複処理されても最終的な状態は変わりません。ただし冪等性が保証するのは「結果が二重に反映されないこと」だけで、重い処理(外部API呼び出し、DB書き込み、計算など)が二重に走るという無駄までは消えません(実装で回避できるケースはあります)。特に LLM 呼び出しのようにコストのかかる処理では、重複処理が頻発するのは避けたいところです。 なお、実行時間の長短が別々の業務に対応しているのであれば、キューを分けてそれぞれに適切な可視性タイムアウトを設定するのが素直です。短時間ジョブ用・長時間ジョブ用にキューを分ければ、可視性タイムアウトの設計はキューごとに完結します。 しかし今回のように、同一のジョブの中で実行時間が大きく変動する場合は、同じキューに速いものと遅いものが混在するため、キュー分離では解決できません。 解決策 長すぎても短すぎても問題があるとなると、どちらかに固定するのは難しそうです。そこで 最初は短く設定しておき、処理が続いている間だけ可視性タイムアウトを動的に延長する 、というアプローチを取ります。 こうすれば、通常は短いタイムアウトのおかげで障害時に素早く再処理でき、長時間かかるジョブは延長によって再配信を防げます。固定値では避けられなかった長すぎ・短すぎのトレードオフの問題が解消されるわけです。 これは AWS のドキュメントでも紹介されている方法です。 タイムアウトの設定と調整。 まず、アプリケーションがメッセージを処理して削除するのに通常必要な最大時間に合わせて可視性タイムアウトを設定します。正確な処理時間について不明な場合は、短いタイムアウト (2 分など) で開始し、必要に応じて延長します。ハートビートメカニズムを実装して可視性タイムアウトを定期的に延長し、処理が完了するまでメッセージを非表示にします。これにより、未処理メッセージの再処理の遅れを最小限に抑えるとともに、再表示が早すぎないようにします。 https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html 具体的な動作 ベースの可視性タイムアウトを短めに設定します(例: 30 秒)。 Worker はバックグラウンドで定期的に(例: 15 秒ごと) ChangeMessageVisibility を呼び出し、可視性タイムアウトを延長し続けます。 なお、 ベースの可視性タイムアウトはハートビートの延長間隔より長く しておく必要があります。延長間隔の方が長いと、最初のハートビートが届く前にベースの可視性タイムアウトが切れて再配信されてしまうためです(同様に、1 回あたりの延長幅も延長間隔より長くしておきます)。 長時間実行されるジョブ でも、延長リクエストを出し続けている間は再配信されません。処理が完了したらハートビートを止め、メッセージを削除します。 sequenceDiagram participant W as 処理スレッド participant H as ハートビートスレッド participant Q as SQS Q->>W: メッセージ受信 (初期 30s) W->>H: ハートビート開始 activate H activate W Note over W: 重い処理を実行中 loop 15秒ごと H->>Q: ChangeMessageVisibility(30s) Note over Q: 期限を30sに上書き end W->>W: 処理完了 deactivate W W->>H: stop.set() で停止 deactivate H W->>Q: DeleteMessage Note over Q: メッセージ削除 一方、 Worker がクラッシュした場合 は延長リクエストが途絶えます。すると最後の延長から(最大でも延長幅の時間で)メッセージが再可視化され、別の Worker に再処理されます。短時間で終わるジョブがクラッシュした場合も同様に、ベースの可視性タイムアウト経過後に再処理されます。 sequenceDiagram participant W as 処理スレッド (Worker A) participant H as ハートビートスレッド participant Q as SQS participant W2 as 別ワーカー (Worker B) Q->>W: メッセージ受信 (初期 30s) W->>H: ハートビート開始 activate H activate W H->>Q: ChangeMessageVisibility(30s) Note over Q: 期限 = 今から30s W->>W: クラッシュ / 強制kill deactivate W Note over H: スレッドも道連れで停止 deactivate H Note over Q: 延長が来ない → 30s経過で期限切れ Q->>W2: 再配信(別ワーカーが受信) activate W2 Note over W2: 処理を最初からやり直す<br/>※冪等性で二重実行を吸収 deactivate W2 実装サンプル Celery/kombu は SQS の可視性タイムアウトを自動延長しないため、ハートビートは自前で実装します。 ここでは要点を絞って簡略化しています。そのまま動かせる完全版は GitHub に置いてあるので、こちらを参照してください。 github.com ReceiptHandle / Queue URL の取得 可視性タイムアウトを延長するには、対象メッセージの ReceiptHandle とキューの URL が必要です。kombu の SQS transport は、受信したメッセージの ReceiptHandle と Queue URL を task.request.delivery_info に載せてくれるので、タスク側から取得できます。 def extract_sqs_receipt (request): """Celery タスクの request から (ReceiptHandle, QueueURL) を取り出す。""" for src in ( getattr (request, "delivery_info" , None ), ( getattr (request, "properties" , None ) or {}).get( "delivery_info" ), ): if isinstance (src, dict ): msg = src.get( "sqs_message" ) if msg and msg.get( "ReceiptHandle" ): return msg[ "ReceiptHandle" ], src.get( "sqs_queue" ) return None , None 取得方法の出典: celery/celery Discussion #7388 ReceiptHandle さえ取れれば、あとは boto3 の change_message_visibility を定期的に叩くだけです。 ハートビート本体 再利用しやすいよう context manager にします。タスク実行中は daemon thread でハートビートを回し、処理本体はメインスレッドで実行、ハートビートスレッドが定期的に ChangeMessageVisibility を叩く構成です。 import contextlib import threading import boto3 @ contextlib.contextmanager def sqs_visibility_heartbeat (task, interval= 15 , extend_by= 30 ): receipt_handle, queue_url = extract_sqs_receipt(task.request) if not (receipt_handle and queue_url): yield # SQS 以外 / handle 取得不可なら何もしない return sqs = boto3.client( "sqs" ) stop = threading.Event() def _beat (): # stop されるか interval 経過のたびにループ while not stop.wait(interval): try : sqs.change_message_visibility( QueueUrl=queue_url, ReceiptHandle=receipt_handle, VisibilityTimeout=extend_by, # 呼んだ時点から extend_by 秒に設定し直す ) except Exception : break # 期限切れ等は延長を諦めて停止 t = threading.Thread(target=_beat, daemon= True ) t.start() try : yield finally : stop.set() # 必ずハートビートを止める t.join(timeout= 2 ) クラッシュ/強制終了時はこの daemon thread もプロセスごと止まるため延長が途絶え、ベースの可視性タイムアウト経過後に SQS が自動で再配信してくれます。 タスク側 タスク本体は with で囲むだけです。 @ celery_app.task (bind= True , name= "long_task" ) def long_task (self, seconds): with sqs_visibility_heartbeat(self): do_heavy_work() なお、この方式が機能する前提として task_acks_late = True が必要です。これが無いと kombu は受信直後にメッセージを削除してしまい、延長対象が消える上にクラッシュ時も再配信されなくなります。 注意点 本実装のハートビートはスレッドで動くため、Python の GIL の影響を受けます。とはいえ、I/O 待ちや通常の Python 処理では GIL は定期的に解放されるため、ほとんどのケースでは問題になりません。注意が必要なのは、GIL を解放しない C 拡張が 1 回の呼び出しで長時間 GIL を握り続けるようなケースです。この間はハートビートスレッドが動けず延長が止まり、可視性タイムアウト経過後に再配信されてしまう可能性があります。そのようなワークロードでは、別プロセスでの延長など別の方式を検討する必要があります。 動作確認 ローカルの SQS 互換サーバ( ElasticMQ )で動作を確認できます。確認しやすいように base は短め(10 秒)にし、ハートビートは 4 秒ごと・延長幅 10 秒で動かします。 以下の手順は前掲の GitHub リポジトリ をクローンすればそのまま実行できます。まず ElasticMQ を起動し、各ターミナルでは下記の環境変数を設定しておきます。 docker compose up -d # ElasticMQ(jobs キュー: base 10 秒) # 以降の各ターミナルで設定 export SQS_ENDPOINT_URL=http://localhost:9324 \ AWS_ACCESS_KEY_ID=x AWS_SECRET_ACCESS_KEY=x AWS_REGION=us-east-1 \ HEARTBEAT_INTERVAL=4 HEARTBEAT_EXTEND_BY=10 通常時:長時間タスクでも再配信されない Worker を起動し、base を超える 25 秒のタスクを投入します。 # Worker(別ターミナル) uv run celery -A celery_app worker -Q jobs --concurrency=4 --loglevel=INFO # 投入(別ターミナル) uv run python enqueue.py --seconds 25 すると Worker のログは次のようになります。 long_task start: 25s (task=01cb2cbb-...) heartbeat #1: +10s (task=01cb2cbb-...) heartbeat #2: +10s (task=01cb2cbb-...) ... heartbeat #6: +10s (task=01cb2cbb-...) long_task done (task=01cb2cbb-...) base(10 秒) を超える 25 秒タスクでも、4 秒ごとのハートビートで延長され続けるため 再配信されず、1 回だけで完走します ( long_task start が 1 回しか出ないことで確認できます)。 クラッシュ時:Worker A が落ちると Worker B が引き継ぐ 図のように「処理中の Worker が落ちて、別の Worker が引き継ぐ」様子を再現します。Worker を 2 つ(A・B)起動し、長めのタスクを投入してから、処理中の Worker を強制終了します。 # ターミナル1: Worker A uv run celery -A celery_app worker -Q jobs -n workerA@%h --concurrency=1 --loglevel=INFO # ターミナル2: Worker B uv run celery -A celery_app worker -Q jobs -n workerB@%h --concurrency=1 --loglevel=INFO # ターミナル3: 60 秒のタスクを投入 uv run python enqueue.py --seconds 60 タスクはどちらか一方(仮に Worker A)が拾い、A 側に long_task start と heartbeat #N が出ます。処理中に A を強制終了します。 pkill -9 -f workerA A が止まるとハートビートが途絶えるため、base(10 秒) 経過後にメッセージが再可視化されます。すると まだ生きている Worker B がそのメッセージを拾い、最初から処理をやり直します (B 側に long_task start が出ます)。タスクの長さ(60 秒)を待つことなく、base 程度の時間で別の Worker に引き継がれることが確認できます。 最後に 実行時間にばらつきのある非同期処理ワークロードに対して、可視性タイムアウトの動的延長は非常に有効です。ジョブの実行時間が予測できない場合や、データ量の増加によってジョブ時間が伸びる可能性がある場合にも役立つと思います。 Celery + SQS の構成では可視性タイムアウトの動的延長がサポートされておらず、自前で実装する必要がありました。対応内容はシンプルですが、Webで実装例をなかなか見つけられなかったので、本記事でサンプル実装を紹介しました。同じ悩みを抱えている方の参考になれば幸いです。
藤原です。 パブリッククラウドのコストコントロール、どこから手を付けていますか? 本エントリでは、2026年上期に取り組んだAWSコストコントロール施策を題材に、 AIを壁打ち相手として使いながら、施策の棚卸し → 想定削減効果の見積もり → 優先度づけ → 実施判断を進めた 事例を解説します。AIと人間の役割分担について、ひとつの例として捉えてもらえるとありがたいです。 コスト削減の進め方とAI コスト削減の打ち手そのものは、実はそれほど目新しいものではありません。教科書的な施策は山ほどあります。 とくに難しかったり大変なのは施策の実行ではなく、その前段階です。進め方をざっと大枠で分けると 棚卸し 自分たちの環境で効きそうな施策を漏れなく洗い出す 見積もり それぞれが「いくら効くのか」を、自分たちの請求データに基づいて試算する 優先度づけ 効果も工数もリスクもバラバラな施策を、どの順で並べるか 判断 「やる/やらない/保留」を、コミットメントのリスクや事業の文脈を踏まえて決める 実施 やることが確定した施策を実行 これら5ステップに分解できます。とくに 2 の試算と 3 の優先度づけは、Cost Explorerの数字を引っ張り、インスタンス単価を調べ、各種割引やサイズフレキシビリティなどを加味してと、多くの作業が必要になります。これらが確実に時間を溶かしてくれます。これらの作業ををAIに任せことができると検討の速度を大幅に高められます。 進め方の全体像 最初に親Issueを立て、そこにぶら下げる形で施策を棚卸し・試算・優先度づけしていきました。進め方は大きく5ステップで、AIに任せられるのは Step 1〜3、最後の Step 4 の判断は人間が握る、Step 5は人間でもAIでも必要に応じて進めるという分担です。 Step 1: 棚卸し(AIにブレストさせる) 起点にしたのは、 ここ数ヶ月のAWS Billing and Cost Managementの情報をAIに調査させること でした。Cost Explorer や Cost and Usage Report のサービス別・アカウント別・月次の推移をAIに読ませ、「どのサービスがコスト増の主因になっているか」「直近で異常な伸びをしている費目はどれか」をまず特定させます。人間が請求画面をあちこち開いて差分を追う作業を、ここでごっそり肩代わりさせられます。 その内訳を踏まえて「効きそうなコスト削減施策を、効果見込みの仮説とあわせて出して」と棚卸しさせると、RI/SP・停止・ログ・アーキ移行といった定番に加え、自分では見落としていた切り口(Config記録頻度、MetricStream転送料、ECR PublicからのNAT通信)まで出てきます(図1)。 図1. AIと壁打ちして作成した施策と記載削減額の例 結果として、このStep 1の成果物として、こうした施策候補のリストが得られました。 OpenSearch の緊急調査と最適化(RI購入 + Right Sizing) EC2/ECS の Compute Savings Plans 導入 RDS の Reserved Instances 導入 Enterprise Support プランの評価 ECS ARM64(AArch64) 移行 非本番環境の夜間・休日停止 CloudWatch コスト最適化 AWS Config ルールの棚卸し ElastiCache の Reserved Nodes 導入 NAT Gateway のデータ処理料金最適化 EBS ボリュームサイズの適正化 ここで出てくる削減効果はあくまで「仮説」です。粒度を揃えるための叩き台と割り切ります。なお、AIが施策ごとに積み上げてくる想定削減効果の合計は、こちらの感覚値よりもずっと大きな数字になっていました。後述するとおり、この数字はそのままは実現しません。 Step 2: 想定削減効果の試算(AIに請求データを食わせて計算させる) 棚卸しした施策を1つずつ、AIに 実データに基づいた試算 をさせていきます。ここがAIの一番の効かせどころです。 ここで効いたのが、 AWSのPricing APIをAIに調査させること 1 です。RIやSavings Plansの試算では、オンデマンド単価・RI単価・支払い方式ごとの割引率といった「正確な料金」が欠かせません。これを記憶やうろ覚えで埋めると試算ごと崩れますが、Pricing APIを叩いて対象インスタンスタイプ・リージョンの単価を取得させることで、実際の料金表に基づいた試算ができます(図2)。 図2. 削減効果を試算した例 たとえばOpenSearch RIでは、Pricing APIで取得した単価をもとに、対象ノードのインスタンスタイプ・台数・期間・支払い方式を踏まえ、オンデマンド時とRI適用時の月額を突き合わせて試算させています。r7g/c7g系の3年 No Upfront RIなら割引率は約48%、といった具合に、施策ごとの削減率が定量的に見えてきます。 Compute Savings PlansやRDS RIでは、Cost Explorerの推奨値(ルックバック期間・推奨カバレッジ・想定稼働率)まで読み込ませて、1年/3年 × No Upfront/All Upfront のマトリクスで比較させました。RDSのサイズフレキシビリティ( db.r8g.2xlarge を db.r8g.large × 4 換算でカバーする)のような細かい仕様も、ちゃんと計算に織り込んでくれます。 ただしここで重要なのが、 「稼働の前提」まで含めて試算させること です。RIやSavings Plansは購入後、対象が停止していても課金され続けます。非本番環境で夜間・休日は停止する運用がある場合に、フル稼働を前提にカバレッジを設計すると、停止している時間帯のぶんだけコミットがムダになります。「この環境は平日日中だけ稼働、夜間・休日は停止」という運用実態を前提に置いて見積もらせると、 どこまでをRI/SPで踏み込んでよく、どこからは停止やオンデマンドに任せるべきか の境界が定量的に見えてきます。常時稼働の本番系は厚めにコミットし、停止運用のある非本番系はコミットを薄く(あるいは停止施策に寄せる)といった、環境ごとのメリハリのある設計につなげられます。 裏を返せば、 稼働状況の前提をどこまでインプットできるかが、そのまま試算の質を決めます 。各環境の稼働時間・停止スケジュール・将来の構成変更の見込みといった前提を具体的に与えられるほど、コミットメントの過剰購入リスクを抑える「リスクコントロール」につながり、同時に実際に得られるコスト削減効果の算定精度も上がります。逆に前提が曖昧なまま試算させると、実態と乖離するリスクが大きくなります。AIに任せる部分だからこそ、 インプットする情報の質と量がアウトプットの信頼性を左右する という点への理解が重要です。 Step 3: 効果 × 工数 × リスクで優先度づけ 試算が揃ったら、各施策を 「削減効果」「工数」「リスク」 の3軸で並べます。これもAIに整理させると一覧になります。 次に挙げているのはElastiCacheでのコスト削減案の例です。 優先 施策 想定削減 工数 リスク ★★★ Reserved Nodes 購入 中 低(購入のみ) 低 ★★☆ production ノードダウングレード 小 中(要メトリクス確認) 中 ★☆☆ 非本番ダウングレード 小 低 低 基本方針はシンプルで、 「効果が大きく・工数が低く・リスクが低い」ものから着手 します。RI/SP購入や設定変更だけで効く施策(OpenSearch RI、Config棚卸し)は即実施、メトリクス確認やデータ移行が要る施策は後回しといった整理になります。 Step 4: 実施判断(ここは人間がやる) そして最後、 「やる/やらない/保留」の判断は人間が握ります。 ここはAIに丸投げできないというのが今回はっきりした境界です。具体の判断結果の事例は以降で解説します。 なお、施策によっては Step 2 の時点で判断がほぼ片付くものもあります。稼働の前提情報や現状の構成・設定をインプットして試算させる過程で、 「これはすでに実施済みだった」と判明する施策が出てくる からです。実際、非本番環境の夜間・休日停止はすでに運用に入っていましたし、AWS Config の記録頻度見直しもすでに対応済みでした。前提情報を丁寧に与えるほど、こうした「やらなくてよい施策」が早い段階でふるい落とされ、人間が本当に判断すべき施策に集中できます。 実際にどう決着したかを区分ごとに並べます。 即実施、または実施済みだったもの 効果が大きくリスクが小さく、さらに工数もかからないものについては即実施しました。 また、Step 1の時点で上がっていたもののうちすでに実施済みのものもありました。 提案施策 判断 OpenSearch RI(3年 No Upfront) 本番系の安定稼働ノードなので3年RIで即購入 RDS RI(r8g系・1年 No Upfront) r8g系は3年RIが存在しないため1年一択 AWS Config ルール棚卸し VPC関連リソースの記録頻度を CONTINUOUS→DAILY に。すでに対応済みだった 非本番環境の夜間・休日停止 調べたらすでに停止済みだった ここで挙げたものはいずれもすでに高い効果を上げていたもの、実施することで高い効果がほぼ得られるものです。 とくに本番環境のOpenSearchのRI購入は効果として大きいものでした。 試算して実施したもの 次に効果が大きく、リスクも大きいが、工数はかからないものです。 リスクとしてはミスコミットが起きる可能性が高いものです。 具体的には以下に挙げているEC2/ECS絡みの1施策でした。 提案施策 判断 Compute SP(ECS関連・カバレッジは控えめに設定) AWS推奨より低め・1年・No Upfrontを選択 この施策がリスクとして大きくなっている理由はシンプルに一定自動スケールアウト・インの仕組みを導入しているからです。 システムの利用状況に応じてスケールアウト・インをさせる構造となっているため全体の需給を確実に予測することは難しくなっています。 ここで効いた人間としての判断は以下の通りです。 AWS推奨の単価をそのまま採用しない 。前述のとおりスケールアウト・インで需給の予測が難しく、コミットしすぎるとスケールイン時に使い切れずムダになります。そこでミスコミットを避けるため、AWS推奨よりも低めのコミット単価に寄せました。コミットを薄くすればカバーしきれないオンデマンド分は残りますが、確実に使い切れる範囲に絞ることを優先した判断です。 コミットは「後から増やす方向」に倒す 。Savings Plans は後からコミットメントを積み増すことはできても、いったん買ったコミットを途中で減らすことは困難です。減らせない以上、最初から攻めると過剰コミットがそのまま固定費になってしまいます。そこでまずは低めのコミットから入り、しばらく稼働傾向(スケールの実績や需給の振れ幅)を観察したうえで、足りないぶんを段階的に積み増していく方針としました。 3年ではなく1年を選ぶ 。割引率だけ見れば3年のコミットが最大になります。今後のアーキテクチャ変更などを見据えると3年コミットはリスクとして大きいと判断して1年にしています。将来的には3年へ切り替える前提です。 追加調査の上実施したもの 案としては出てきたが、具体の詳細の対応内容として、追加の調査(具体対象とすべきものはなにか、除外が必要なものは何か、どう実装すべきか)が必要なものについては追加調査の上実施しました。 次に挙げる施策がその対象となりました。 提案施策 具体の施策内容 CloudWatch コスト最適化 非本番MetricStream停止・ログ保持期間設定・Container Insights無効化など6施策を特定。Datadog でカバーできる範囲を調査の上で判断 ECS ARM64(AArch64) 移行 Fargateが約20%安。サービス単位で順次移行 NAT Gateway 最適化 この時点ではコストの原因特定まで届かず、具体の施策は詳細を調査の上で検討 CloudWatch のコスト最適化では、単純にログ保持期間やメトリクスを削るのではなく、 「それは Datadog 側でカバーできているか」を調査してから判断 しました。MNTSQ では o11y 基盤として Datadog を主に利用しており、メトリクス・ログ・トレースは原則 Datadog に集約する方針です。だからこそ、CloudWatch 側で何を削っても監視・調査に支障が出ないか——たとえば非本番環境の MetricStream を止めても Datadog 側の監視で足りるか、Container Insights を無効化しても必要なコンテナメトリクスが Datadog で取れているか、保持期間を縮めるロググループが Datadog に転送済みか、またはS3にアーカイブ済みかなどをひとつずつ確認した上で、削ってよいものだけを対象に絞りました。「CloudWatch のコスト」と「実際に必要な可観測性」は別物で、Datadog でカバーできている部分は CloudWatch 側を遠慮なく削れる、という切り分けがここでの肝でした。 ARM64移行では、削減効果そのものより 「x86(amd64)から ARM64(aarch64) へ移行する際のブロッカーが具体的に何か」の洗い出し に重きを置きました。具体的には、 aarch64 向けのコンテナイメージが提供されているか (マルチアーキ対応イメージか、ベースイメージに arm64 タグがあるか) アーキ依存のネイティブバイナリ・商用ライブラリがないか (例: Aspose のような x86 依存の商用ライブラリ、特定アーキ向けにビルドされた拡張モジュール) CI/ビルドパイプラインが aarch64 ビルドに対応しているか (クロスビルドやマルチアーキビルドの整備が必要か) といった観点を施策化の前に調査しています。こうして「すぐ倒せるもの」「依存を解消してから倒すもの」「現状は倒せないもの」を切り分けたうえで、サービス単位で順次移行する形にしました。 一律に倒さず、ブロッカーを見極めて移行可否を判断する のはやはり人間の仕事です。ここでは、幸いにもほとんどのものをaarch64へ移行することができました。 そして NAT Gateway のデータ処理料金最適化は、今回のなかでもとくに「分析から対策まで」の流れが綺麗に決まった取り組みでした。VPC Flow Logs を Athena で集計し、通信先を Route53 Resolver のクエリログと突き合わせて「どの宛先がデータ処理料金を押し上げているのか」を地道に特定。その結果、最大の要因が ECR Public からの image pull(CloudFront 経由)であることを突き止め、ECR Pull Through Cache の導入という具体的な対策につなげています。この調査と対策の詳細は、担当メンバーが別エントリにまとめてくれているので、ぜひそちらを読んでください。 tech.mntsq.co.jp 「コストが高い」という入口から、Flow Logs の分析で真因にたどり着き、回避策まで落とし込む——という一連の流れは、コスト最適化のお手本のような進め方になっていると思います。 見送り・保留にしたもの(人の判断が必要な部分) AIが「やれば効く」と試算した施策でも、見送ったものがあります。 次のようなものです。 提案施策 見送り・保留の理由 Enterprise Support → Business TAM・Trusted AdvisorなどEnterprise固有の価値を踏まえ、初めから削減対象外とみていた。単体の損得だけでは測れない。 ElastiCache RI Redis→Valkey移行を予定。移行後にノードのサイズ見直しがあること。コスト削減効果も小さいことから保留。 EBS ボリューム縮小 EBSは縮小不可でデータ移行が必要。手間とリスクに対し効果が小さく コストパフォーマンスが悪い 。新規構築時に適正サイズを当てる方針へ Enterprise Support の解約は、AIの試算上は当初想定で最大級の削減効果が出る施策でした。ただ、これは 最初から削減の対象外とみていた ものです。TAMの伴走やインフライベント管理など、Enterprise Supportで得ている価値は請求書の削減額には現れません。サポートのランクを落とした結果として障害対応や運用の質が下がれば、コスト削減以上のものを失いかねません。だからこそ、削減額の大小にかかわらず検討対象から外していました。これは請求書の数字をいくら睨んでも出てこない、 契約と事業の文脈に基づく判断 です。 EBSも同じで、一定の削減ポテンシャルはあるものの「縮小不可・要データ移行」というEBSの仕様上、費用対効果が見合わない。AIの試算は「やれば効く額」を出してくれますが、 「やる価値があるか」は別問題 だということです。 取組結果: ピーク時比で20%以上のコスト削減を実現 最終的に、ここまでの取組を通じてAWSコストがもっとも高かった時期と比べて、 20%以上のコスト削減 を実現できました。 当初AIが施策ごとに積み上げてきた想定削減効果の合計は、これよりもかなり大きな数字でした。 「そんなわけないだろ」と思いながらも、稼働の前提や現状の構成といった追加情報をコンテキストとして渡していくことで、最終的にはかなり高い精度で削減効果を見積もれるようになりました。 そして実際に実行に移した結果が、このピーク時比 20%以上 という削減を達成できました。 これらのアクションは、見積もりから実行まで3月から4月にかけておおよそ1ヶ月程度で実施しました。 これだけの削減を短期間で実現できたのは、AIを使った見積もり支援だけでなく、その後の施策推進のために迅速に対応してくれたSREチームのメンバーのおかげです。 今後の取り組み見込み ここまでで実施済みのコスト管理施策について、弊社を担当してもらっているTAMの方からもレビューいただきました。 短期間で大きな削減を実施できており、インフラ観点では打つべき手立てについてはひととおり打てている旨をお墨付きいただきました。 今後元々予定していた大きな構成の変更が佳境を迎えており、それが完了すればさらにかなりのコスト削減が実現できそうです。 さらに、今後の施策としては、 RIの積み増し 追加でさらに購入しても問題なさそうなものについてRIを購入する Savings Planの積み増し Compute Savings Planの積み増しだけでなく、OpenSearchのデータノードも狙ったDatabase Savings Planのコミット などを検討しています。 なお、この2つ目のうち、Database Savings PlanでOpenSearch のデータノードも対象にできることは、われわれも把握できておらず、担当いただいているAWS TAM 2 の方からのアドバイスではじめて知った選択肢です。AIに棚卸しさせても出てこず、OpenSearch はRI前提で進めていたところに、TAMの方から教わって今後の打ち手に加わったものです。「AIでも拾いきれない最新動向や、自社の構成に踏み込んだ最適な打ち手」を埋めてくれるTAMのような高度専門人材の価値は、AIで足元を固めたからこそあらためて実感できました。 まとめ AIの使いどころと、人間が握るべき判断 最後に、今回の事例から得た「コストコントロールにおけるAIとの役割分担」について振り返ってみます。 AIが圧倒的に速い領域(積極的に任せる) 施策候補の棚卸し・ブレスト(見落としを拾う) 請求データ・単価・割引を踏まえた削減効果の試算 効果 × 工数 × リスクの一覧化 人間が握るべき判断(任せてはいけない) コミットメント期間のリスク(変動の激しい構成に3年RIを当てないなど) 契約・サポート品質の文脈(Enterprise Supportのように削減額だけでは測れない価値を考慮するなど) 費用対効果の閾値(EBS縮小のように「効くがやる手間が多く価値が薄い」施策への線引き) とくに効果 × 工数 × リスクの観点からのGo/Stopの最終判断は人間が担います。 そこに取り組むだけの価値があるのかについて、具体的な数字ではあらわせない部分があるからです。 AIで棚卸しと試算を高速に回しつつ、追加のコンテキストなどを人間がAIに提供して見積もりを精緻化、人間がAIの出力した情報をもとにGo/Stopを判断する。 この分担が、今回うまく噛み合いました。 本事例が、コスト削減に取り組む際の参考になれば幸いです。 AWSのPricing MCPサーバーを使ってもよいかもしれません。 参考 ↩ TAMとはTechnical Account Managerのことです。AWSのエンタープライズサポートを契約すると顧客ごとに担当としてTAMがアサインされ、さまざまな観点からの支援を提供してもらえます。2026/6時点の情報なので最新の情報や詳細については、 AWS エンタープライズサポート のページを参照してください。 ↩
藤原です。 みなさん、システムコストの管理、どうしていますか? システムコストがどの程度発生しているのか、どんな機能から発生しているのか、特定の機能が利用された際にどの程度のコストがかかっているかを気にしたことはありませんか。 本エントリでは、Datadog Cloud Cost Managementを使ったマルチクラウド環境におけるコスト管理ダッシュボードの作成事例および、コスト計算を実現する方法について事例ベースで解説します。 複数クラウドのコスト管理 コスト管理を複数のクラウドサービスにまたがって行うには、多くの課題が生じがちです。 おそらくは以下のいずれかで管理している方が多いかと思います。 毎月の初めに個別クラウドの請求書をダウンロードしてスプレッドシートに転記の上、報告資料を準備 (1.よりももう少しスマートな形で)毎月の初めに個別クラウドのAPIを叩いてスプレッドシートに転記して報告資料を準備 もちろん、これを特定のワークフローの中で定義していたり、AIの支援のもと効率的に実行できるようにしている方はいると思います。 今回はDatadogのCloud Cost Managementを中心に、Datadogの機能を活用してコストのo11yを高めることを事例として提示します。 複数クラウドを横断したコスト管理の課題 結局のところ、複数クラウドを利用する場合の課題とはなんでしょうか。 おおよそ以下のようなところに集約されると思います。 異常なコスト発生の検知やコストコントロールのための打ち手を考えたいが、情報源が散逸しているので情報集約の手間が辛すぎる 情報源が散逸しているので、コストの相関関係をみていくための分析も大変 個別のクラウドサービスにはコスト管理のためのマネージドサービスが存在 1 するので、 単一のクラウドのみにオールインする形でプロダクトを提供している場合はこのような悩みは生じません。 弊社はAWS, Google Cloud, Azureいずれも使う形をとっています。したがって、特定のクラウドプロバイダのコスト管理サービスでOKとはなりません。 Datadog Cloud Cost Management Datadog Cloud Cost Management は A unified cost observability platform for Engineering and FinOps という謳い文句のとおり、エンジニアリングとFinOpsを結びつけるためのプラットフォームサービスです。 AWSやGoogle Cloud、Microsoft Azureなどいわゆるメガクラウドと呼ばれるクラウドサービスプロバイダはもちろん、GitHubやAnthropic、OpenAIなどのサービスにも対応しています。 個別クラウドサービスプロバイダのコスト管理サービスとDatadog Cloud Cost Managementの比較 先ほども述べたとおり、クラウドサービスプロバイダ固有のコスト管理サービスは存在しています。 それらと比較した場合のDatadog Cloud Cost Managementのメリット・デメリットを見てみましょう。 その上で利用事例を見た上で判断すると良いでしょう。 クラウドサービスプロバイダ固有のコスト管理サービス メリット zero configurationで単一クラウド内の情報を取得できる デメリット 複数クラウドに横断する形のコスト管理には対応していない 個別クラウドごとのサービス利用方法を理解する必要がある Datadog Cloud Cost Management メリット 複数クラウドプロバイダー横断での各種メトリクスの集計とダッシュボード化 すべての分析、設定操作をDatadogの中で実施する形となるので設定方法の習得コストは低くなる デメリット 有償 2 個別クラウドのメトリクスを取得するための設定が必要 クラウド固有のコスト管理サービスの強みは、原則としてZero configuraionな点です。 ひとまず何かしらの分析をしようと思えば特に設定することなく、メトリクスがそこに存在しダッシュボード化できるという点が魅力です。 ただし、特定クラウドの中のサービスなので、カスタムメトリクスで数値情報を何かしかの情報で取り込むなどしない限りはクラウドサービス横断で情報を集計するといったことは困難でしょう。 翻って、Datadog Cloud Cost Managementについては、メリットとしては複数のクラウドプロバイダーのメトリクスを集約してダッシュボード化できるという点が挙げられます。 また、複数のクラウドプロバイダのコスト情報を同じ操作感で調査、ダッシュボード作成ができます。 一方で有償サービスであること、個別のクラウドプロバイダとの繋ぎ込みの設定が必要なことデメリットと言えるでしょう 3 。 活用事例 ここからは活用事例です。 具体何を見ているかと言われると、現状は以下を実現しています。 全体としてのコスト概要 クラウドプロバイダ別のコスト詳細 個別テナント・機能別のコスト統計 全体としてのコスト概要 確定分のコストとして、ひと月前とふた月前のシステムコスト統計を比較できるようにしています(図1)。 図1. 全体としてのコスト比較 基本的には、 クラウドプロバイダ単位で意図しない大きなコスト変動が発生していないか? や、 コスト削減のためのアクションを実施したけれどもどれくらいの効果が全体で見た時に出ているか? などをウォッチする目的です。 詳細は別のダッシュボード(タブ)に任せています。 クラウドプロバイダ別のコスト詳細 クラウドプロバイダ別に環境やサービス、とくにコスト総額が大きかったり変動比率が大きいサービスについてトレンドや、直近2ヶ月のコストを比較できるようにしています。 図2. クラウドプロバイダ別のコスト傾向 とくにコスト観点で注目したいサービスについてはサービスごとにふた月前まで含めたコスト比較をできるようにしています。 図3. とくにコスト観点で着目すべきサービスは別途具体的な数値ベースで比較できるようにしている 利用金額がそもそも大きいサービスや、新規に利用を開始したばかりのサービスなどについてはトレンドだけでなく、詳細をウォッチするようにしたほうが良いでしょう。 個別テナントごとのシステムコスト 個別テナント毎の平均システムコスト(≒ システム原価)を見えるようにもしています。 システムコスト総額をテナント数で除算した結果がシステムコストのテナントあたりの平均システムコストになります(図4)。 図4. テナントごとのシステム原価を見れるようにする 図4のようにテナント数の変動を見つつ、システム原価をさまざまな試算パターン別に見れるようにしています。 従量によるコスト発生度合いの大きい機能についてのモニタリング さらに追加で、変動要素の多いものとしてAI系の機能についてのコストも算出できるようにしています。 具体的には以下のようなものをみています。 個別機能毎のコスト総額 テナント別の機能利用に伴うコスト統計 弊社では、AIに関連した機能提供に際しては、Vertex AIを利用しています。 Vertex AIのクライアントでリクエスト時に追加のラベル(どの機能からの呼び出しなのか、どのテナントからの呼び出しなのか)を設定することで、どの機能、どのテナントがどれくらいのコストを発生させているのか?を確認できるようになっています 4 。 図5. どの機能別にコストが発生しているかを可視化 どのテナントが、積極的に利用しているのか?なども見ることができます。 図6. テナント別の利用料金変動の概観 とくにたくさん利用されているテナントについては、ヘビーユーザーだと思うので、ヒアリングしてみるといったアクションも取れるかもしれないですね。 機能別の処理単価 最後は機能別に処理を1回実行するとどの程度のコストが発生しますか?も概算を見れるようにしています。 この辺りが見えるようになると、さらに精緻にコスト戦略を練れるようになるでしょう。 あまりにもコスト的に上振れしていればなんらかのコストコントロール施策のための作業が必要になるでしょうし、あまりに低ければもっと豪勢なモデルを使ってもいいんでないか?といった議論ができるかもしれません。 基本的には以下の情報が必要になるでしょう。 機能別のコスト総額 機能別の呼び出し回数 1については、すでに図5で取得できることがわかっています。 2についてはどうでしょうか、特定機能の呼び出し回数を計測することは案外大変です。パッとは取れません。 そこで、ALBのアクセスログからDatadogのカスタムメトリクスを作成し、エンドポイントごとの呼び出し回数を測定できるようにしました。 エンドポイントとAI機能の対応関係が取れるため、それをベースに個別機能の呼び出し回数を取れるようにしました(図7)。 1の数値はGoogle Cloudからの取得、2の数値はAWSからの取得という形で複数クラウドを横断する形でコスト分析をできるようになっており、その好例と言えるかもしれません。 最終的に、機能別のコスト総額を機能別の呼び出し回数で除算することで機能別の呼び出し単価を計算できるようになりました。 図7. 機能別の1回呼び出しごとの発生コストの推移 これで対象機能のプロンプトを変更したり、モデルを変更した際のコスト推移を見ることができるようになりました。 まとめ ここまで、複数クラウド環境でのコストのo11yを改善するための手段としてDatadog Cloud Cost Monitoringについての事例を解説しました。 もちろんメリット・デメリットありますが、毎月のコストモニタリングの手間やクラウド横断でのコスト分析に苦労している人にはおすすめできる機能だと思うのでぜひ利用を検討してみると良いのではないでしょうか。 AWSであれば、 Billing and Cost Management 、Google Cloudであれば Cloud Billing 、Microsoft Azureであれば Cost ManagementとBilling といったクラウドプロバイダ固有のサービスが存在します。 ↩ 具体的な料金設定は 公式ページ を参照してください。なお、クラウドサービスプロバイダによってはAWSのCloudWatchのように ダッシュボード作成が有償 といった場合もあるのでご注意ください。 ↩ ただし公式のインテグレーション機能が用意しているため設定自体は難しくはありません。 ↩ なお、この仕組みは個別プロダクトの開発メンバーの協力のもと、プロダクトコードにてラベル埋め込みを進めました。 ↩
はじめに 既存構成 課題感 設計 CloudWatch Logs ではなく S3 に倒す クラスタ単位で S3 prefix を切る 実装 ECS タスクロールに付与する IAM ポリシー ECS クラスタの executeCommandConfiguration おわりに はじめに Amazon Linux 2 (以下 AL2) の EOL (2026 年 6 月 30 日) が近付いてくる昨今、皆様いかがお過ごしでしょうか。 弊社では SSH 踏み台として使う EC2 インスタンスを AL2 ベースで用意し、運用作業の起点として長年取り扱ってきました。運用者はここを経由して ECS タスクや各種マネージドサービスへアクセスしてきた経緯があり、単純な SSH 踏み台ではなく、運用機能を集約した実行基盤として機能してきた格好です。 前述の通り ECS 最適化 AL2 AMI の EOL が迫る状況下、これを契機にこの踏み台の処遇を決める必要が出てきました。AWS は同案内において後継として Amazon Linux 2023 (以下 AL2023) への移行を推奨しているため、これに沿えば AL2023 で素直に再構築するのが定石となります。一方、棚卸してみると踏み台はいくつもの運用基盤を兼務する構造になっており、AL2023 で再構築すれば短期的な EOL 対応は済むものの、これらの構造をそのまま AL2023 のサポート期限 (2028 年) まで持ち越すことになります。 踏み台が抱える各役割はそれぞれ別個の代替手段が出揃ってきていたため、AL2 の EOL を契機にして「踏み台ごと畳んでしまい、機能を別の代替先へ分散させる」方針を取ることにしました。本稿はその分散先のうち、運用者が ECS Exec で直接コンテナへ入る経路のセッションログを、アプリログとは別系統で取り扱う仕組みの話です。 既存構成 踏み台を経由する従来構成では、運用者の操作ログは踏み台側でまるごと取得できていました。踏み台を廃止して ECS Exec へ切り替えると、この経路が無くなります。 ECS Exec の出力先はクラスタ単位の executeCommandConfiguration で制御できます。 logging が DEFAULT のまま (= 明示設定なし) だと、タスク定義側で指定された awslogs に運用者のターミナル出力が同居する格好となります。アプリログには Firelens 経由で CloudWatch Logs / S3 / Datadog の 3 系統へ流すルーティングがすでに敷かれているため、ここに ECS Exec のセッションログが乗っかると、出力先・保管期間・閲覧権限がアプリログ側の都合に縛られてしまいます。 課題感 運用者の操作ログとアプリログが同じ経路で混ざってしまうと、以下のような不便が出てきます。 後から運用者の操作だけを切り出して追うのに難儀する アプリログのライフサイクルに引きずられて長期保管も難しくなる そこで運用者の操作ログを専用 S3 バケットへ独立して書き出すように整備しました。本稿ではその設計判断と実装手順を取り扱います。 設計 新たに専用 S3 バケットを設け、ECS Exec のセッションログをすべてここへ集約することにしました。 CloudWatch Logs ではなく S3 に倒す ECS Exec の出力先は S3 と CloudWatch Logs のいずれか (あるいは両方) を選べますが、本件は S3 単独としました。理由は次のとおりです。 既存の SSM Session Manager の操作ログを同じく専用 S3 バケットに集約しており、運用者の操作ログは「S3 集約してから Athena で横断的に追う」運用と既に親和性がある 保管期間が長く読み出し頻度の低いログを置く先として、CloudWatch Logs より S3 のほうがコスト効率に優れる ライフサイクル制御が CloudWatch Logs より自由に効き、長期保管要件に応じて低頻度アクセス向けの STANDARD_IA やアーカイブ向けの GLACIER_IR を組み合わせて吊るしで設計できる ここでいう「S3 集約してから Athena で横断的に追う」経路は、業務時間外の不審操作を自動検知して Slack 通知する社内の仕組みである A2RM (監査回答強制マン) の動作基盤にもなっています。ECS Exec ログを同経路に乗せておくことで、将来同じ枠組みで取り扱える余地が生まれます。 https://tech.mntsq.co.jp/entry/2026/03/17/114506 クラスタ単位で S3 prefix を切る ECS Exec ログを書き出す S3 オブジェクトキーには、クラスタ側の executeCommandConfiguration で任意の接頭辞を指定できます。本件ではこれを ${env}-${service}-${cluster_id}/ というクラスタ単位の名前空間にしてあります。複数サービスの ECS Exec ログが同じバケット内に同居するため、接頭辞をクラスタ単位で分けておかないと、後から Athena でクエリするときにフィルタ条件が複雑化します。 実装 ECS Exec ログの分離整備は次の 2 段階に分けて投入しました。 専用 S3 バケットの新設と、ECS タスクロールへの S3 関連権限の追加 ECS クラスタの executeCommandConfiguration を logging = OVERRIDE に切り替え 順序依存があるため、必ず 1 を先に着地させてから 2 を投入する必要があります。クラスタ側の executeCommandConfiguration を OVERRIDE に切り替えた瞬間、運用者が ECS Exec で接続する度に、タスクロールが当該 S3 バケットに対して以下の API を呼ぶようになるためです。 s3:GetBucketLocation s3:GetEncryptionConfiguration (バケット側で s3_bucket_encryption_enabled = true を設定しているため) s3:PutObject これらに対する IAM 権限がタスクロール側に揃っていない状態でクラスタを切り替えると、運用者が ECS Exec を叩いた瞬間に AccessDenied で落ちます。踏み台廃止に向けて ECS Exec の信頼性を担保したい局面でこれが起きると本末転倒なので、IAM 整備を先に着地させてからクラスタ切替を投入する順序を踏むのが安全です。 ECS タスクロールに付与する IAM ポリシー ECS タスクが ECS Exec のセッションを開き、その出力ログを S3 バケットへ書き出すために必要な権限を、タスクロールへアタッチするポリシーとして以下のように定義します。 IAM policy document の Terraform 定義 data "aws_iam_policy_document" "ecs_exec" { # SSM Agent によるセッション確立に必要 statement { actions = [ "ssmmessages:OpenDataChannel" , "ssmmessages:OpenControlChannel" , "ssmmessages:CreateDataChannel" , "ssmmessages:CreateControlChannel" , ] resources = [ "*" ] } # ECS Exec ログを専用 S3 バケットへ書き出すために必要 statement { actions = [ "s3:GetBucketLocation" ] resources = [ "*" ] } statement { actions = [ "s3:GetEncryptionConfiguration" ] resources = [ aws_s3_bucket.ecs_exec_logs.arn ] } statement { actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.ecs_exec_logs.arn } /*" ] } } s3:GetBucketLocation はバケットのリージョン解決に、 s3:GetEncryptionConfiguration はセッション開始時のバケット暗号化設定の検証に、 s3:PutObject は実際のログ書き出しにそれぞれ必要となります。 s3:GetEncryptionConfiguration はバケット ARN に絞った権限とすることで、不要な走査を抑制できます。 ECS クラスタの executeCommandConfiguration ECS クラスタの configuration.execute_command_configuration に出力先 S3 バケットと接頭辞、暗号化検証の有効化を指定します。 aws_ecs_cluster の Terraform 定義 resource "aws_ecs_cluster" "main" { # ... クラスタ自体の既存設定 ... configuration { execute_command_configuration { logging = "OVERRIDE" log_configuration { s3_bucket_name = aws_s3_bucket.ecs_exec_logs.bucket s3_key_prefix = "$ { var.env } -$ { var.service } -$ { var.cluster_id } /" s3_bucket_encryption_enabled = true } } } } logging = "OVERRIDE" で明示設定モードへ切り替え、 log_configuration でその内容を与える格好です。 s3_bucket_encryption_enabled = true を有効にすると、セッション開始時に SSM Agent がバケット側の暗号化設定を s3:GetEncryptionConfiguration で検証する経路に倒れます。 おわりに 本稿では、踏み台廃止に向けて運用者の ECS Exec セッションログを専用 S3 バケットへ分離した取り組みについて、設計判断と実装手順の両面から取り扱いました。 ECS Exec で運用者がコンテナ内で叩いたコマンドは、平時はあまり関心をむけられることの少ない内容です。しかし、いざ追跡や監査が必要になったときに参照先がアプリログと混ざっているか独立しているかで、後の動きやすさはずいぶん変わります。整備した瞬間に何かが大きく変わるわけではないものの、踏み台廃止のように接続経路を切り替える場面で後からじわじわ効いてくる類の作りだと思います。 ECS Exec のセッションログをアプリログとは別経路へ分離する作りは、要点さえ押さえれば素直に組めるものになっています。同じような整備に取り組む方の一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.7 が作成した内容を8割程度そのまま使用しています
はじめに SREの寺島です。 MNTSQでは継続的なコスト最適化を進めており、SREチームでもこれまでいくつかの削減施策を実施してきました。本記事では、その中からNAT Gatewayのデータ処理料金の削減に向けた取り組みを紹介します。 結果として、NAT Gatewayのデータ処理料金を約70%削減することに成功しました。今回は、コスト増の原因特定から、具体的な対応、そして効果測定にいたるまでの一連の流れをお届けします。 はじめに まずは Cost Explorer でコストの把握をする NAT Gateway の通信内容を調査する VPC Flow Logs テーブル定義 集計クエリ Route 53 Resolver Query Logs テーブル定義 IP からホスト名を引くクエリ 集計結果 ECR Public が CloudFront 経由で配信されていることを curl で確認する 通信量を削減できるか検討する Interface Endpoint と Gateway Endpoint 施策別の削減効果の試算 VPC Endpoint と Pull Through Cache での通信削減 Interface VPC Endpoint の追加 ECR Pull Through Cache の導入 ECS タスク定義の書き換え 結果 まとめ 関連記事 まずは Cost Explorer でコストの把握をする AWS のコストの内訳は Cost Explorer で確認できます。最初に大まかにどのサービスがコストの多くを占めているのかを把握しました。 レポートのパラメータは以下の値を設定し、サービスごとのコストを確認します。 グループ化の条件 ディメンション: サービス 弊社ではコストの多くを占めているのは ECS、RDS、OpenSearch、EC2 インスタンスでした。これらは既に Reserved Instance / Savings Plans を購入済みでインスタンスサイズも最適化済みのため、次いで料金が高かった EC2 - Other の内訳を確認することにしました。 EC2 - Other の中身を見るために、レポートのパラメータを以下のように変更します。 グループ化の条件 ディメンション: 使用タイプ 適用フィルター サービス: EC2 - Other 使用タイプ(Usage Type) は AWS のリソース・API 単位でコストを分解できるディメンションです。 NatGateway-Bytes のようにサービス内の課金項目単位で内訳を見たいときに使います。 結果として、 EC2 - Other の中で約3~4割を NatGateway-Bytes が占めていることが分かりました。 NatGateway-Bytes は NAT Gateway を通過したデータ量に応じて課金される項目なので、通信量を減らせばそのままコスト削減に直結します。 ただ、Cost Explorer から分かるのはNAT Gateway 経由でこれだけの通信があったという総量だけで、その内訳(何の通信が大半を占めているか)までは分かりません。削減できる余地があるかを判断するために、NAT Gateway を通っている通信の中身を詳しく調査することにしました。 NAT Gateway の通信内容を調査する NAT Gateway のデータ処理料金を削減するには、どの通信が大半を占めているのかを特定する必要があります。今回は VPC Flow Logs と Route 53 Resolver Query Logs を組み合わせて調査しました。 VPC Flow Logs VPC Flow Logs は、VPC 内の ENI を通過する通信のメタデータを記録するログです。送信元 IP、宛先 IP、ポート、プロトコル、バイト数などが記録されます。弊社では事前に VPC Flow Logs を S3 に出力する設定を入れていたため、Athena からクエリを発行できる状態になっていました。 調査の流れは以下の通りです。 マネジメントコンソールまたは aws ec2 describe-nat-gateways から、NAT Gateway の ENI ID を取得する Athena で VPC Flow Logs のテーブルに対し、 interface_id を NAT Gateway の ENI ID に絞り、 dstaddr (宛先 IP)でグルーピングして送受信バイト数を集計する 上位の宛先 IP を抽出する テーブル定義 S3 に出力した VPC Flow Logs を Athena から読むためのテーブル定義は以下のような形です(AWS 公式ドキュメントの VPC Flow Logs のテーブル作成例 をベースにしています)。 CREATE EXTERNAL TABLE IF NOT EXISTS production ( version int , account_id string, interface_id string, srcaddr string, dstaddr string, srcport int , dstport int , protocol bigint, packets bigint, bytes bigint, start bigint, ` end ` bigint, action string, log_status string, vpc_id string, subnet_id string, instance_id string, tcp_flags int , type string, pkt_srcaddr string, pkt_dstaddr string, az_id string, sublocation_type string, sublocation_id string, pkt_src_aws_service string, pkt_dst_aws_service string, flow_direction string, traffic_path int ) PARTITIONED BY ( `day` string ) ROW FORMAT DELIMITED FIELDS TERMINATED BY ' ' LOCATION ' s3://<your-flow-logs-bucket>/AWSLogs/<account_id>/vpcflowlogs/ap-northeast-1/ ' TBLPROPERTIES ( ' skip.header.line.count ' = ' 1 ' , ' projection.enabled ' = ' true ' , ' projection.day.type ' = ' date ' , ' projection.day.range ' = ' 1970/01/01,NOW ' , ' projection.day.format ' = ' yyyy/MM/dd ' , ' storage.location.template ' = ' s3://<your-flow-logs-bucket>/AWSLogs/<account_id>/vpcflowlogs/ap-northeast-1/${day} ' ); 集計クエリ 実際に NAT Gateway 経由のアウトバウンド通信(VPC → 外部)を集計したクエリは以下のような形です。ENI ID は production の VPC に紐づく NAT Gateway 3 台分(3 AZ)を指定しています。 SELECT dstaddr, dstport, SUM (bytes) / POWER ( 1024.0 , 3 ) AS gb, SUM (packets) AS pkts, COUNT (*) AS flows FROM vpc_flow_log.production WHERE day BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND interface_id IN ( ' eni-xxxxxxxxxxxxxxxx1 ' , ' eni-xxxxxxxxxxxxxxxx2 ' , ' eni-xxxxxxxxxxxxxxxx3 ' ) AND srcaddr LIKE ' 10.x.x.% ' -- VPC CIDR (内側起点) AND dstaddr NOT LIKE ' 10.x.x.% ' -- 外部宛 (NAT 越え) GROUP BY dstaddr, dstport ORDER BY gb DESC LIMIT 100 ; interface_id に NAT Gateway の ENI ID を、 srcaddr / dstaddr の LIKE 条件に VPC CIDR を指定することで、「VPC 内発・外部宛」の通信に絞り込んでいます。 このクエリを実行すると、以下のような形式の結果が返ってきます(値は例示)。 dstaddr dstport gb pkts flows 3.233.158.83 443 47.86 35,123,456 525,152 142.250.21.95 443 24.91 1,234,567 66,344 3.163.251.13 443 3.96 8,765,432 183,895 ... ... ... ... ... 各カラムの意味は以下の通りです。 dstaddr / dstport : 宛先 IP とポート gb : 通信量(バイト数を GB に換算) pkts : パケット数の合計 flows : Flow Logs のレコード件数 なお、NAT Gateway のデータ処理料金はアウトバウンド・インバウンド両方向に課金されるため、調査の際は両方向を集計しておく必要があります。 インバウンド(外部 → VPC、リプライ)を集計したい場合は、上のクエリから以下の差分で書き換えます。 - SELECT dstaddr, - dstport, + SELECT srcaddr, + srcport, SUM(bytes) / POWER(1024.0, 3) AS gb, ... - AND srcaddr LIKE '10.x.x.%' -- VPC CIDR (内側起点) - AND dstaddr NOT LIKE '10.x.x.%' -- 外部宛 (NAT 越え) - GROUP BY dstaddr, dstport + AND dstaddr LIKE '10.x.x.%' -- VPC CIDR (内側着) + AND srcaddr NOT LIKE '10.x.x.%' -- 外部発 (NAT 越えのリプライ) + GROUP BY srcaddr, srcport Route 53 Resolver Query Logs VPC Flow Logs だけだと、宛先が IP アドレスでしか分からないため、どのサービス宛の通信かが直感的に判別できません。AWS の ip-ranges.json と突き合わせれば AWS サービスかどうかは分かりますが、これは AWS が提供するサービスの IP レンジしかカバーしていません。NAT Gateway を通る通信には Datadog などの外部サービス宛のものも含まれているため、それらの IP も合わせて名寄せできる仕組みが必要でした。また、AWS サービス内でも CloudFront 経由のエンドポイントなど共有 IP のケースでは、IP レンジだけでは具体的な FQDN まで特定できません。 そこで Route 53 Resolver Query Logs を使います。これは VPC 内から発行された DNS クエリのログで、「どの FQDN がどの IP に解決されたか」が記録されます。AWS サービスか外部サービスかを問わず、VPC 内から名前解決された宛先はすべてここに記録されるため、VPC Flow Logs の宛先 IP と突き合わせることで、IP の先にあったホスト名を特定できます。 テーブル定義 Resolver Query Logs を S3 に出力したものを Athena から読むためのテーブル定義は以下のような形です(こちらも AWS 公式ドキュメントの Route 53 Resolver Query Logs のテーブル作成例 をベースにしています)。 CREATE EXTERNAL TABLE IF NOT EXISTS production ( version string, account_id string, region string, vpc_id string, query_timestamp string, query_name string, query_type string, query_class string, rcode string, answers array< struct< Rdata: string, Type : string, Class: string> >, srcaddr string, srcport int , transport string, srcids struct< instance: string, resolver_endpoint: string >, firewall_rule_action string, firewall_rule_group_id string, firewall_domain_list_id string ) PARTITIONED BY ( ` date ` string ) ROW FORMAT SERDE ' org.openx.data.jsonserde.JsonSerDe ' STORED AS INPUTFORMAT ' org.apache.hadoop.mapred.TextInputFormat ' OUTPUTFORMAT ' org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat ' LOCATION ' s3://<your-resolver-logs-bucket>/AWSLogs/<account_id>/vpcdnsquerylogs/<vpc_id>/ ' TBLPROPERTIES ( ' projection.enabled ' = ' true ' , ' projection.vpc.type ' = ' enum ' , ' projection.vpc.values ' = ' <vpc_id> ' , ' projection.date.type ' = ' date ' , ' projection.date.range ' = ' 1970/06/26,NOW ' , ' projection.date.format ' = ' yyyy/MM/dd ' , ' projection.date.interval ' = ' 1 ' , ' projection.date.interval.unit ' = ' DAYS ' , ' storage.location.template ' = ' s3://<your-resolver-logs-bucket>/AWSLogs/<account_id>/vpcdnsquerylogs/<vpc_id>/${date}/ ' ); answers カラムは構造体の配列になっており、1 つの DNS クエリに対する複数の回答(A レコードが複数返るケース等)が入っています。後述するクエリでは UNNEST で展開して使います。 IP からホスト名を引くクエリ VPC Flow Logs の集計結果(宛先 IP)と Resolver Query Logs を JOIN して、IP の先にあったホスト名を特定します。実際に使ったクエリは以下のような形です。 WITH flow AS ( SELECT dstaddr, dstport, SUM (bytes) / POWER ( 1024.0 , 3 ) AS gb, SUM (packets) AS pkts, COUNT (*) AS flows FROM vpc_flow_log.production WHERE day BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND interface_id IN ( ' eni-xxxxxxxxxxxxxxxx1 ' , ' eni-xxxxxxxxxxxxxxxx2 ' , ' eni-xxxxxxxxxxxxxxxx3 ' ) AND srcaddr LIKE ' 10.x.x.% ' AND dstaddr NOT LIKE ' 10.x.x.% ' GROUP BY dstaddr, dstport ), dns AS ( SELECT t.answer.Rdata AS ip, array_agg( DISTINCT query_name) AS domains FROM route53_resolver_query_log.production CROSS JOIN UNNEST(answers) AS t(answer) WHERE date BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND t.answer. Type = ' A ' GROUP BY t.answer.Rdata ) SELECT f.dstaddr, f.dstport, f.gb, f.flows, d.domains FROM flow f LEFT JOIN dns d ON f.dstaddr = d.ip ORDER BY f.gb DESC LIMIT 100 ; flow CTE で前述のアウトバウンド集計をそのまま使い、 dns CTE で answers を CROSS JOIN UNNEST で展開して A レコードに絞り、 ip → domains のマップを作っています。最後に Flow Logs の dstaddr と DNS 解決結果の ip を JOIN することで、「宛先 IP の先にあったドメイン群」と「通信量」をセットで取得できます。 なお、 array_agg(DISTINCT query_name) を使っているのは、同じ IP に対して複数のホスト名が解決されることがあるためです(CloudFront のように 1 つの IP が多数の FQDN に紐づくケースが典型)。 このクエリを実行すると、以下のような形式の結果が返ってきます(値は例示)。 dstaddr dstport gb flows domains 3.163.251.13 443 1,557.42 1,432,100 [d5l0dvt14r5h8.cloudfront.net] 3.233.158.83 443 47.86 525,152 [trace.agent.datadoghq.com] 142.250.21.95 443 24.91 66,344 [www.googleapis.com, aiplatform.googleapis.com, vision.googleapis.com] ... ... ... ... ... domains カラムには、その IP に解決された FQDN の配列が入ります。Google APIs のように複数のサービス名が並ぶケースもあれば、Datadog の APM trace のように 1 つの FQDN だけが入るケースもあります。 集計結果 上記のログを使って NAT Gateway 経由の通信を集計した結果、上位を占めていたのは以下の通信先でした(一部、通信先は除外しています)。 インバウンド(外部 → VPC、レスポンス受信) 順位 通信先 備考 1 d5l0dvt14r5h8.cloudfront.net (CloudFront 経由の ECR Public の実体) image layer の実体配信 2 Google APIs ( *.googleapis.com ) OCR / AI 処理のレスポンス 3 Datadog ( *.datadoghq.com 系の trace / intake / config エンドポイント) 4 CloudWatch Logs ( logs.ap-northeast-1.amazonaws.com ) 5 SQS ( sqs.ap-northeast-1.amazonaws.com ) アウトバウンド(VPC → 外部) 順位 通信先 備考 1 Google APIs ( *.googleapis.com ) OCR / AI 処理向けの画像アップロード 2 Datadog ( *.datadoghq.com 系の trace / logs / process / intake) 3 CloudWatch Logs ( logs.ap-northeast-1.amazonaws.com ) Firelens 経由のログ送信 4 SQS ( sqs.ap-northeast-1.amazonaws.com ) 5 Firehose ( firehose.ap-northeast-1.amazonaws.com ) 通信量で見ると、 インバウンド側の ECR Public からの image layer 配信が突出して大きい という結果になりました。 d5l0dvt14r5h8.cloudfront.net は一見すると AWS のサービスかどうか分かりにくいドメインですが、これは ECR Public のイメージレイヤー配信に使われている CloudFront ディストリビューション の実体です。ECR Public Gallery ( public.ecr.aws ) は API 部分は別ホストで動いており通信量は僅かですが、イメージレイヤーの blob ダウンロードは CloudFront 経由で配信される仕組みになっています。 弊社では元々 VPC に S3 Gateway Endpoint しか設定しておらず、ECS タスクから public.ecr.aws/datadog/agent:latest などのサイドカーイメージを pull する通信や CloudWatch Logs / SQS 宛の AWS API 通信は、すべて NAT Gateway を経由していました。 ECR Public が CloudFront 経由で配信されていることを curl で確認する d5l0dvt14r5h8.cloudfront.net が ECR Public のイメージレイヤー配信用 CloudFront ディストリビューションである、という点について補足します。 AWS の公式ドキュメントで明確に説明している資料は限定的ですが、 EKS Anywhere のドキュメント では d5l0dvt14r5h8.cloudfront.net (for EKS Anywhere package ECR container images) と記載されており、ECR コンテナイメージの配信用であることが言及されています。 これに加えて、レジストリ API の挙動を curl で実際に確認することもできます。ECR Public からイメージレイヤー(blob)を取得しようとすると、HTTP 307 Redirect で CloudFront に飛ばされる仕組みになっており、その redirect 先のホストを直接見られます。手順は以下の通りです。 # 1. ECR Public の匿名トークンを取得 TOKEN=$(curl -s "https://public.ecr.aws/token/" | jq -r .token) # 2. イメージのマニフェストからレイヤーの digest を取得 # datadog/agent はマルチアーキ対応のため、まずマニフェストリストから # アーキ別マニフェストの digest を引き、そこから layer digest を取る MANIFEST_DIGEST=$(curl -s \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ "https://public.ecr.aws/v2/datadog/agent/manifests/latest" | jq -r '.manifests[0].digest') LAYER_DIGEST=$(curl -s \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "https://public.ecr.aws/v2/datadog/agent/manifests/$MANIFEST_DIGEST" | jq -r '.layers[0].digest') # 3. blob を取りに行く(リダイレクトを追わずヘッダのみ確認) curl -sI -X GET \ -H "Authorization: Bearer $TOKEN" \ "https://public.ecr.aws/v2/datadog/agent/blobs/$LAYER_DIGEST" | grep -E "^(HTTP|location)" 出力: HTTP/2 307 location: https://d5l0dvt14r5h8.cloudfront.net/v2/.../?... public.ecr.aws/v2/<repo>/blobs/<digest> が d5l0dvt14r5h8.cloudfront.net 配下の URL に 307 redirect していることが確認できます。 aws-for-fluent-bit など他のイメージで試しても、同じ CloudFront ドメインに redirect されます。 なお、ECR Public が使う CloudFront ドメインは時期によって変わる可能性があるので、自環境で同様の調査をする場合は上記の手順で実際の redirect 先を確認するのが確実です。 通信量を削減できるか検討する 通信内容が見えてきたので、削減方針を検討します。 Interface Endpoint と Gateway Endpoint VPC 内から AWS のサービスに NAT Gateway を経由せずアクセスするには、VPC Endpoint を使います。VPC Endpoint には 2 種類あります。 Gateway Endpoint : S3 と DynamoDB のみ対応。 追加料金なし (ルートテーブル経由でルーティングされる) Interface Endpoint : ほとんどの AWS サービスに対応。 AZ ごとに ENI が立ち、時間課金 + データ処理料金がかかる S3 は既に Gateway Endpoint があるので追加コストなしで NAT Gateway を回避できています。その他のAWSサービスに関しては Interface Endpoint で対応する必要があります。 施策別の削減効果の試算 Interface Endpoint はただ作れば全部安くなるわけではなく、Endpoint 自体の固定費(AZ 数 × 時間課金)と、NAT Gateway を通っていたデータ処理料金の削減額を比較する必要があります。NAT Gateway 経由の通信量が少ないサービスに Endpoint を作ると、むしろコストが増えるケースもあります。 前提となる ap-northeast-1 の単価は以下です(記事執筆時点の AWS の公称料金)。 NAT Gateway : データ処理料金 $0.062 / GB Interface VPC Endpoint : $0.014 / 時間 × AZ 数 の固定費 + データ処理料金 $0.01 / GB この単価に集計結果の通信量を当てはめ、施策ごとに整理したのが以下の表です(実数値は伏せ、大小関係だけ示しています)。 施策 削減対象の通信量 純削減額 Pull Through Cache + ECR API / DKR Endpoint 突出して大 ◎ 大幅プラス CloudWatch Logs Interface Endpoint 中 ○ 小幅プラス SQS Interface Endpoint 小 △ ほぼ損益分岐(採用は見送り) Datadog PrivateLink 中 △ ほぼ損益分岐(採用見送り) Datadog は対象 Endpoint の数で結果が大きく変わります。APM trace 単独に絞れば損益分岐、複数 Endpoint を貼ると固定費が積み上がって赤字側に振れます。今回はコストメリットがほとんどなかったため、PrivateLinkの採用は見送り、通信量が今後増えてきた段階で、導入を再検討する想定です。 ここまでの試算から、 最優先で対応すべきは Pull Through Cache(+ ECR API / DKR Endpoint)であり、合わせて CloudWatch Logs Endpoint も入れる 、という方針が確定しました。その他の AWS API 通信(SSM、Secrets Manager など)は今回の集計では上位に来ていなかったため、対象外としています。 VPC Endpoint と Pull Through Cache での通信削減 上記の方針を踏まえて、以下 3 つを実装しました。 ECR API / ECR DKR / CloudWatch Logs の Interface VPC Endpoint 追加 ECR Pull Through Cache の導入 ECS タスク定義の image 参照を Pull Through Cache 経由に書き換え Interface VPC Endpoint の追加 3 つの Interface Endpoint を追加しました。Terraform で書くと以下のようになります。 module "vpc_endpoints" { # ... endpoints = { s3 = { # 既存の S3 Gateway Endpoint(省略) } ecr_api = { service = "ecr.api" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -ecr-api-vpc-endpoint" } } ecr_dkr = { service = "ecr.dkr" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -ecr-dkr-vpc-endpoint" } } logs = { service = "logs" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -logs-vpc-endpoint" } } } } ECR Pull Through Cache の導入 Interface VPC Endpoint を追加することで <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com 宛の通信は VPC 内で完結しますが、 public.ecr.aws/... のイメージは ECR Publicから取得するため、Interface VPC Endpoint の対象外です。 ここで使えるのが ECR Pull Through Cache です。これは「 public.ecr.aws などの upstream registry のイメージを、自アカウントの private ECR にキャッシュとして取り込む」機能です。初回 pull 時にキャッシュ側にイメージが取り込まれ、以降は自アカウントの ECR から pull できます。private ECR への pull は Interface VPC Endpoint 経由で完結するため、NAT Gateway を通らなくなります。 詳細な設定手順や仕様は AWS 公式の Creating a pull through cache rule も参照してください。 Terraform で設定するのは以下のリソースです。 resource "aws_ecr_pull_through_cache_rule" "ecr_public" { ecr_repository_prefix = "ecr-public" upstream_registry_url = "public.ecr.aws" } これを設定すると、 <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/<namespace>/<image>:<tag> という URL で pull できるようになります。 ecr_repository_prefix で指定した ecr-public/ の配下に、upstream のリポジトリ名がそのまま展開される形です。 初回 pull のときに ecr-public/datadog/agent のような private リポジトリが自動作成されます。この自動作成と upstream からのイメージ取り込みに権限が必要なため、IAM Policy を別途用意します。 data "aws_iam_policy_document" "ecr_pull_through_cache" { statement { effect = "Allow" actions = [ "ecr:BatchImportUpstreamImage" , "ecr:CreateRepository" , ] resources = [ "arn:aws:ecr:$ { data.aws_region.current.id } :$ { data.aws_caller_identity.self.account_id } :repository/$ { aws_ecr_pull_through_cache_rule.ecr_public.ecr_repository_prefix } /*" , ] } } resource "aws_iam_policy" "ecr_pull_through_cache" { name = "$ { var.env } -$ { var.service } -ecr-pull-through-cache" description = "Allow importing images from upstream registry via ECR Pull Through Cache" policy = data.aws_iam_policy_document.ecr_pull_through_cache.json } この Policy を ECS の task execution role に attach することで、タスク起動時の初回 pull が成功するようになります。これを忘れると、初回 pull 時に AccessDeniedException が出てタスクが起動しません。 ECS タスク定義の書き換え Pull Through Cache 経由でイメージを pull するには、ECS のタスク定義で public.ecr.aws/... を参照している箇所を書き換える必要があります。 書き換えた対象は、各サービスで共通して使っている Datadog Agent と aws-for-fluent-bit(Firelens)のサイドカーが中心です。 - "image": "public.ecr.aws/datadog/agent:latest", + "image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/datadog/agent:latest", - "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:init-2.32.2", + "image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/aws-observability/aws-for-fluent-bit:init-2.32.2", 書き換えたタスク定義をデプロイしたあとは、ECS コンソールから Pull Through Cache 経由で pull されているかを確認できます。タスクの詳細画面のコンテナイメージ欄に、書き換え後の <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/... という URL が表示されていれば想定通りに動作しています。 あわせて ECR のコンソールを開くと、 ecr-public/datadog/agent のような Pull Through Cache 用のプライベートリポジトリが自動作成されているはずです。 結果 対応の完了後、Cost Explorer で NatGateway-Bytes の推移を確認したところ、対応前と比べて約 70% 減少しました。2026/05/17 に各環境で対応を反映しており、グラフでもその日を境にデータ処理料金が大きく下がっているのが確認できます。 また、VPC Flow Logs で通信内容を再集計したところ、ECR Public( d5l0dvt14r5h8.cloudfront.net )、CloudWatch Logsの通信が大幅に削減されていることを確認できました。Pull Through Cache と Interface VPC Endpoint が意図通りに効いていることが確認できます。 一方で、対応後に通信量の上位を占めているのは Datadog 系(APM trace、agent flares、logs intake など)と Google APIs(Vision / AI Platform 系)でした。どちらもサービスのスケールや AI 系機能の拡充に伴って今後さらに増えていくことが想定されます。Datadog は通信量が増えていけば、PrivateLink 導入が次の打ち手として浮上してきそうです。Google APIs は AWS 外のサービスで VPC Endpoint の対象外なので、コスト面の対策はアプリケーション側での見直しが必要になります。 まとめ 本記事では以下の流れでNat Gatewayのコストを削減した事例を紹介しました。 Cost Explorer を使ったコスト内訳の把握 VPC Flow Logs と Route 53 Resolver Query Logs を組み合わせた NAT Gateway 経由の通信内容の特定 VPC Endpointの単価と通信量から施策の費用対効果の試算 Interface VPC Endpoint(ECR API / ECR DKR / CloudWatch Logs)と ECR Pull Through Cache によるデータ処理料金の削減 今回の調査がスピーディーに進んだ最大の要因は、前提として VPC Flow Logs と Route 53 Resolver Query Logs が既に S3 へ出力されていたことでした。万が一のトラブルや突発的な調査に備え、日頃からログを溜めておく体制づくりを強くおすすめします。 NAT Gateway はインフラ構築当初は通信量が少なくデータ処理料金が目立ちませんが、サービスがスケールするにつれて気づかないうちに通信量が増えてコストを圧迫します。NAT Gatewayのコスト削減を検討している方がいれば、ぜひ参考にしてみてください。 関連記事 同様の NAT Gateway コスト削減に関する事例として、以下の記事も参考になります。 NATゲートウェイの通信内容を調査して対策し、コストを約60%削減した話 - ZOZO TECH BLOG Amazon ECRプルスルーキャッシュを使ってみた - DMM Developers Blog
はじめに 「監視モニタリングのIaCとか机上の空論だろ。労力とリターンが見合わんわ」 …と思っていた時期が私にもありました(慣用句) 前回の記事 でも少し触れましたが、 AIエージェントの登場によってDatadog × Terraformのような監視モニタリングのIaCの実践が劇的に楽になり 、気づけば手動でポチポチとモニタリングの設定をする運用の方が限りなく非効率になってしまいました。 AIエージェントをどう利用するかという部分は、まだまだ過渡期であり皆さま試行錯誤中ではあると思いますが、 弊社SREチームではAIエージェントを活用し 、 モニタリング対象の飛躍的な拡充 、 モニタリングコード品質の大幅な改善 、 運用負荷の劇的な軽減 を実現できました。そこで、どのような取り組みを行い、これを実現したかを紹介したいと思います。 ※ 本記事で扱うのはDatadog × Terraform での実践内容となります 前回の記事はこちら tech.mntsq.co.jp はじめに これまで: 監視モニタリングのIaCは重い課題だった AIエージェントの登場で何が変わったか 実装はブラックボックスで良い 規約によって品質を揃える 監視モニタリングIaCの実践例 コードを最小に保つ多層構成 実装サンプル 弊社で運用している監視モニタリングの規模 おわりに これまで: 監視モニタリングのIaCは重い課題だった 一般的にSaaSを運営している開発組織では、本番環境のみではなくステージング環境、開発環境など複数の環境を管理しています。モニタリングを真面目にやろうとすると、 「環境数 × 対象」でモニタやダッシュボードが乗算的に増えていく ため、手動で管理はほぼ不可能に近いです。(頑張って作ったとしても、細かな変更を全体に反映できず、結局保守はできない) 弊社の場合、ダッシュボードは本当に重要な対象に絞って本番環境だけで整備、モニタはマルチアラートを利用して数を減らすなどの工夫で凌いでいましたが、やはり保守の手間はなかなか重いものでした。 「じゃあコード管理すれば良いのでは?」と思うかもしれませんが...... Fargate用ダッシュボード CPU、メモリ、エフェメラルストレージのウィジェットを記載するコードの一部 # ── Fargate サービスのメトリクスウィジェット ── fargate_widgets = { for svc in var.services : svc.ecs_service => [ # CPU使用率 { definition = { title = "CPU使用率(%, コンテナ単位)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "query1 / query2 * 100" }] queries = [ { name = "query1" data_source = "metrics" query = "avg:container.cpu.usage{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } , { name = "query2" data_source = "metrics" query = "avg:container.cpu.limit{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 2 , width = local.widget_width, height = 3 } } , # メモリ使用率 { definition = { title = "メモリ使用率(%, コンテナ単位)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "query1 / query2 * 100" number_format = { unit = { type = "canonical_unit" unit_name = "percent" } } }] queries = [ { name = "query1" data_source = "metrics" query = "max:container.memory.usage{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } , { name = "query2" data_source = "metrics" query = "max:container.memory.limit{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 5 , width = local.widget_width, height = 3 } } , # エフェメラルストレージ { definition = { title = "エフェメラルストレージ空き領域(%)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "(query2 - query1) / query2 * 100" }] queries = [ { name = "query2" data_source = "metrics" query = "max:ecs.fargate.ephemeral_storage.reserved{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } } by {task_arn}" } , { name = "query1" data_source = "metrics" query = "max:ecs.fargate.ephemeral_storage.utilized{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 8 , width = local.widget_width, height = 3 } } ] if !svc.is_ec2 } これは流石に無理では......?何かを追加したくなる度に、どのように宣言すれば良いかを調べ、↑のようなコードを書かなければいけないわけです。少なくとも私はダッシュボードを1つ作成する前にPCを叩き割る自信があります。 そもそも IaCは宣言的な記述が求められる故に常に一定の学習コストがかかり 、自分が得意とする領域か、やらないことが許されない状況(プロダクトのインフラとか)でもない限りなかなか実践できないというのが現状だったのではないでしょうか。少なくとも、監視モニタリング領域でIaCを実践するのは、確保できる工数と照らし合わせると、不可能に近いというのが、弊社の実態でした。 AIエージェントの登場で何が変わったか ここに大きな転機が来ました。 冗長なコードを人間が理解する必要がなくなった のです。 実装はブラックボックスで良い これまでなら、新しい監視をひとつ足すたびに次のような作業が必要でした。 Datadog provider の最新仕様を確認する 似たような既存モニタの実装を探してコピーし、差分を埋めていく メトリクスのタグ表記( env: / service: / container_name: など、起動形態で違う)を調べる メッセージのテンプレート構文( {{#is_alert}} 等)を思い出す・調べる これらを、エージェントが規約と既存コードを参照しながら、ものの数十秒でこなします。人間は「ECS タスクの CPU が 100% に張り付いたら検知したい」「RDS Serverless v2 の ACU 使用率が高騰したらアラートを送ってほしい」と指示を出すだけでよく、そのまま PR にできるレベルの成果物が出てきます。 ここで重要なのは、 実装をブラックボックスのまま受け入れて構わない ということです。 関心があるのは見たいデータが正しく取れているかのみ です。それを確認できれば、生成されたコードは読む必要がないし、覚えない。それぞれが独立している故に、挙動がおかしければ捨てて作り直せばよく、それでも GUI でポチポチ作るより圧倒的に速い。「書く頻度が低くて、毎回仕様を忘れる」性質を持つ監視モニタリングコードと、この使い方は非常に相性がよいです。 これまで「IaC 化したいが、書く労力に見合うリターンが見えない」という理由で諦めていた領域に、はじめて手を出せるようになりました。 規約によって品質を揃える とはいえ、ブラックボックスを丸ごと信用すると品質がブレるリスクは当然あります。命名がバラつき、通知先がバラつき、タグ付けがバラつくと、運用負荷はむしろ増えてしまう。 弊社ではClaudeCodeを使用しているので、これを避けるためにコーディングの規約を .claude/rules/ 配下に書き溜めています。Datadog 関連では、たとえば以下のようなルールを明文化してあり、エージェントは生成時にこれらを参照します。 モニタ・ダッシュボード名は 🤖 プレフィックスで Terraform 管理であることを示す Slack チャンネル・メンションは locals.tf で集中管理し、モジュール側は変数経由で参照のみ メトリクスのタグには env:<env> を必ず入れ、全環境平均を見てしまうミスを防ぐ 新しい AWS メトリクスを使うときは、CloudWatch Metric Stream の送信対象フィルタも更新する これに加えて、 .claude/rules/datadog-monitors.md には「シンプルアラートとマルチアラートの違い」「 renotify_interval の標準値」「環境フィルタ漏れの典型ミス」など、具体的なコードパターンまで載せてあります。 結果として、誰が、あるいはどのエージェントが書いても、ほぼ同じ形のコードが出てきます。レビュアは「規約からの逸脱がないか」だけを確認すればよく、レビュー労力もかなり下がりました。 コードはブラックボックスにしつつ、規約は人間が育てる 、という分担です。 規約の一部抜粋 規約の一部です。全体ではサンプルコードを含む記述を400行くらい書いてます # .claude/rules/datadog-monitors.md --- paths: - "terraform/aws/services/datadog/monitors_*.tf" - "terraform/aws/services/datadog/modules/monitors/**" - "terraform/aws/services/datadog/*.tf" --- # Datadog Monitor 作成ガイド ## アーキテクチャ概要 モニターは以下の 3 層構造で実装する: 1 . **呼び出し層**: `terraform/aws/services/datadog/monitors_<監視対象>.tf` - 監視対象リソースの一覧を `locals` で定義 - `for_each` で環境 × リソースの組み合わせごとにモジュールを呼び出す 2 . **モジュール層**: `terraform/aws/services/datadog/modules/monitors/<monitor_name>/` - `main.tf` にモニターの実体(`datadog_monitor` リソース)を定義 - `variables.tf` にモジュールの入力変数を定義 3 . **共通設定**: `terraform/aws/services/datadog/locals.tf` - `slack_channel`, `error_channel`, `mention` 等 ## ファイル配置 ``` terraform/aws/services/datadog/ ├── monitors_<監視対象>.tf # 呼び出し層 ├── modules/monitors/<monitor_name>/ │ ├── main.tf # モニター実体 │ └── variables.tf # 入力変数 ├── locals.tf # 共通設定(slack_channel, mention等) ├── variables.tf # サービスモジュールの入力変数 └── provider.tf # プロバイダ設定 ``` ## モニター名の規約 - 必ず `🤖` プレフィックスを付ける(Terraform管理であることを示す) - 環境名は **Terraform変数 `var.env`** で埋め込む: `🤖 【$ { var.env } 】...` - 旧: `【 {{ env.name }} 】`(Datadogテンプレート変数)→ 廃止 - 新: `【$ { var.env } 】`(Terraform変数、for_eachで環境ごとに生成するため) - **Datadogテンプレート変数(` {{ xxx.name }} `)をモニター名・メッセージに使わない** - 環境名、リソース名等はすべてTerraform変数(`var.env`, `var.display_name` 等)で埋め込む - Datadogテンプレート変数はモニター一覧画面では未展開のまま表示されるため視認性が悪い - 例外: ` {{ value }} `(アラート発火時の値)や ` {{ #is_alert}}` 等の条件分岐は引き続き使用する ## Slackチャンネル / メンションの使い方 `locals.tf` で定義された変数をモジュールに渡して使う: ```hcl # Slackチャンネル(重要度別) local.slack_channel.info # 情報レベル local.slack_channel.warning # 警告レベル local.slack_channel.alert # 緊急レベル # エラー通知チャンネル(環境別) local.error_channel [ env ] # 環境ごとのエラー通知先 # メンション(チーム別) local.mention.sre # SREチーム local.mention.swe # SWEチーム local.mention.eng # エンジニア全体 local.mention.cre # CREチーム local.mention.algo # アルゴチーム local.mention.ai_agent # AI Agentチーム ``` --- <以下省略> --- 監視モニタリングIaCの実践例 具体的に弊社がどのようなコード構成をとっているかも軽く紹介しておきます。 コードを最小に保つ多層構成 Datadog 関連リソースは、以下の 3 層で管理しています。 envs/<env>/datadog/datadog.tf ← ① 環境呼び出し層 │ ▼ services/datadog/ ← ② サービスラッパー層(module) ├─ monitors_<対象>.tf (locals + for_each で展開) └─ dashboard_<対象>.tf │ ▼ services/datadog/modules/ ← ③ モジュール層(sub module) ├─ monitors/<name> (datadog_monitor の実体) └─ dashboards/<name> (datadog_dashboard_json の実体) 層 役割 ① 環境呼び出し層 環境リストや、プロダクトコードのoutputなどの依存関係を渡してサービスを呼ぶだけ ② サービスラッパー層 監視対象や閾値などの設定差分を列挙しモジュール層に渡す ③ モジュール層 datadog_monitor / datadog_dashboard の実体。「SQSの滞留モニタ」「ECSサービスのダッシュボード」といった、環境やプロダクト毎によらない抽象的なコードを配置する 弊社の場合、Datadogのアカウントは本番用と開発用の2アカウントで運用しているため、①の環境呼び出し層でproduction, stagingなどの複数の環境をまとめてサービスラッパー層に渡しています。 ポイントは ② のラッパー層で、 setproduct関数 を使って「環境 × 監視対象リソース」や「監視対象 × 閾値」などの組み合わせを for_each で展開している点です。こうすることで、例えば新しい環境を足すときは環境リストに 1 行、新しい監視対象を足すときも locals に 1 行といった具合に、 追加コストが "リスト 1 行" にまで圧縮されている のがこの構成の効きどころです。AIエージェントによる生成とも非常に噛み合います。 実装サンプル ③モジュール層、②サービスラッパー層の実装サンプルはこんな感じです。 ③SQSのDLQにメッセージが落ちてきたことを知らせる抽象モニタ # ~/modules/monitors/sqs_dlq/main.tf locals { doc_link_line = var.doc_link != "" ? "\n[こちらの手順]($ { var.doc_link } )を参考に対処してください。" : "" } resource "datadog_monitor" "main" { name = "🤖 【$ { var.env } 】$ { var.service_display_name } SQS DLQ $ { var.display_name } にメッセージが見つかりました" type = "query alert" query = "min(last_5m):min:aws.sqs.approximate_number_of_messages_visible{queuename:$ { var.env } -$ { var.queue_name } ,env:$ { var.env } } > 0" message = <<EOT $ { var.slack_channel.alert } $ { var.mention.sre } $ { var.mention_team } {{ #is_alert}} $ { var.env } 環境の $ { var.service_display_name } SQS DLQ $ { var.display_name } にメッセージが入りました。 ワーカーの処理に失敗したメッセージが存在している可能性があります。$ { local.doc_link_line } メッセージ数: {{ value }} メッセージ {{ /is_alert }} {{ #is_recovery}} $ { var.env } 環境の $ { var.service_display_name } SQS DLQ $ { var.display_name } からメッセージが削除されました。 DLQへの失敗メッセージへの対応が完了しました。 {{ /is_recovery }} EOT monitor_thresholds { critical = 0 } on_missing_data = "show_no_data" require_full_window = false renotify_interval = 120 renotify_statuses = [ "alert" ] tags = [ "service:$ { var.service_tag } " , "env:$ { var.env } " , "managed_by:terraform" , ] } ②DLQモニタに「CLM」というプロダクトのSQSを設定を渡すラッパー層 # ~/services/datadog/monitor_clm_sqs_dlq.tf # CLM SQS DLQモニター # DLQにメッセージが落ちてきた時にアラートを発する # 環境 × キューごとにモニターを生成する # キュー定義は locals_clm_sqs.tf の local.clm_sqs_queues から導出 module "monitor_clm_sqs_dlq" { for_each = { for pair in setproduct (var.dashboard_envs, local.clm_sqs_queues) : "$ { pair [ 0 ]} -$ { pair [ 1 ] .display_name } " => { env = pair [ 0 ] queue_name = "$ { pair [ 1 ] .queue_name } -dlq" display_name = pair [ 1 ] .display_name } } source = "./modules/monitors/sqs_dlq" env = each.value.env queue_name = each.value.queue_name display_name = each.value.display_name service_display_name = "CLM" service_tag = "clm" mention_team = local.mention.clm doc_link = "<対応手順書のURL>" slack_channel = local.slack_channel mention = local.mention } # ~/services/datadog/locals_clm_sqs.tf locals { # CLM SQSキュー定義(環境プレフィックスなし) # listを使用して定義順序を保持 clm_sqs_queues = [ { queue_name = "clm-default-app-job-worker-sqs-critical" display_name = "critical" } , # 取り扱うキューを列挙する。ここでは省略 ] } ②のコードはあくまで「CLM」という特定のプロダクトのDLQモニタを定義するものです。別のプロダクトの監視を行いたいときは、②のコードを別途作成します。③のコードは再利用可能です。 このように抽象化を行うことによって、コードを最小にして多数のモニタを管理することが可能になります。そして実装部分はAIエージェントに丸投げしてしまえば、 少ない運用工数 で、 多数のモニタリング対象 を、 高品質なコード で管理できる わけです。 弊社で運用している監視モニタリングの規模 2026/05/20現在の規模感は以下のとおりです。 項目 数 対象環境 8 環境 モニター種別 / 生成されるモニター数 30 種類 / 約 800 個 ダッシュボード種別 / 生成されるダッシュボード数 7 種類 / 約 80 個 ラッパー+モジュール層のコード行数 約 1 万行 おおざっぱに 1 万行で 800 のモニタと 80 のダッシュボードを支えている 計算です。 先に述べたように、実装規約を整備したおかげで誰でも気軽に対象の追加ができるようになり、現在でも日々監視モニタリングは充実していっています。 おわりに 本記事執筆のきっかけは、AIエージェントの登場による監視モニタリングIaCの変化は、 単に"運用が楽になった" だけの話ではない と感じたためです。 これまでは「重要なものだけ厳選して監視するのが限界」と言わざるを得ませんでした。リソースを増やすほど管理コストが増えるため、観測対象は常に「これは本当に必要か」というフィルタを通って絞り込まれていたわけです。 それが、追加コストがほぼ無視できるほど軽くなった瞬間、 「観測したいものは全部観測する」というスタンスに振り切れる ようになりました。新しいワーカーを足したら CPU・メモリ・レイテンシのアラートを同時に足し、新しい SQS キューを切ったら滞留と DLQ のモニタも足し、新しい RDS クラスタを建てたらスロークエリやコネクション数のダッシュボードも足す ── これらが開発フローの中で容易に実現できるようになります。 開発組織全体への良い影響もあります。 新機能リリース時に「とりあえずダッシュボードはある」状態が標準 になり、初動の異常検知が早くなります。モニタを足すコストも軽いため、開発者が「この指標を見たい」と SRE に相談する敷居も下がる、あるいは開発者自身がダッシュボードやモニタを追加することも今後可能となっていくはずです。 モニタリングは「保険」のように扱われがちで、潤沢な工数を割きづらい領域です。だからこそ、 コストを劇的に下げてくれる手段が出てきたなら、観測の "深さ" そのものを変えにいくべきでしょう。 MNTSQ株式会社 SRE 西室
はじめに セキュリティ推進室の山田です。 MNTSQはエンタープライズ企業を主な顧客としています。 契約という、顧客企業の事業戦略に直結するような情報を取り扱う性質上、さまざまな観点からセキュリティをしっかりと担保する必要があり、DMARCへの対応もそうした取り組みのひとつです。 DMARCはなりすましメール対策の仕組みであり、実質的な効果を持たせるにはポリシーをp=quarantineまたはp=rejectに設定する必要があります。 しかしMNTSQでは、DMARCレコード自体は存在していたものの、ポリシーはp=noneの状態が続いていました。本記事ではDMARCポリシーをp=rejectまで厳格化した取り組みについて紹介します。 DMARCとは DMARCはSPF・DKIMの認証結果を照合し、ポリシーに従ってメールを処理する仕組みです。認証に失敗した場合、設定されたポリシーの値に応じて処理されます。 DMARCポリシーの設定値は3つあります。 Step0: 現状の整理と取り組みの方針の決定 現状を整理した結果、次のようなステップで取り組む必要が出てきました。 自社ドメインからのメール送信元の確認 → Step1: DMARCレポートの分析 SPF・DKIMなどDNSレコード設定の適切性の確認 → Step2: DNSレコードの棚卸し メール送信元の管理方式の策定 → Step3: 未対応サービスの洗い出しと対応 DMARCレコードの管理者の策定 → Step4: DMARCポリシーの厳格化 Step1: DMARCレポートの分析 DMARCレポートとは、メールを受信したサーバーが送信ドメインの管理者に送る集計レポートです。どのIPアドレスから自社ドメインを名乗ったメールが送られているかを把握できます。 MNTSQではすでにValimailがレポートの送信先として設定されていたため、まずはValimailを使って送信元の洗い出しを試みました。Google Workspaceなど日頃から利用しているSaaSがレポートとして上がっていた一方で、Valimailだけでは不十分だとわかりました。数日ほど集計レポートを眺めていると、利用しているにもかかわらずレポートに現れないサービスがあることに気づきました。レポートがサマライズされており全容が把握しきれていなかったことが原因でした。またSendGridのようにCNAMEで委譲された独自サブドメイン(例:sg-123.example.com)からの送信がValimailで拾えていなかったことも、この時点では把握しきれていませんでした。 そこでDMARCレポートを分析するツールを自作しました。GASのコードはClaudeを活用して作成しており、ツール自体は1日ほどで動くものができました。その後、表示内容や確認したい情報が適切に出力されているかを1〜2日かけて調整し、実用的な状態に仕上げました。生成AIを活用することで実装より設計に集中できるため、ツールを自作するという意思決定のハードルが下がった実例でもあります。 GASは機能ごとに分割し、機能の橋渡しとなるようデータの構造を設計している 作成したツールの仕組みは以下の通りです。 各受信サーバーからメール添付で届くDMARCレポート(XML)をGASで収集し、Google Driveに格納 XMLをクレンジングし、表示に必要な情報だけをスプレッドシートに中間データとして展開 DMARCレポートは1日あたり数個から十数個になる BIツール側でXMLを直接読み込むと処理速度に影響するので中間データを生成する 中間データをもとにGASでBIツールを構築 中間データを挟むことで低レイテンシーでの表示を実現している これによりValimailでは把握できていなかった送信元を含めたDMARCレポートの全容が把握できるようになり、次のステップであるDNSレコードの棚卸しに必要なサービス一覧が作成できました。 スクショはサマリーだけですが、トグルを開くと詳細レポートもみれます Step2: DNSレコードの棚卸し Step1のDMARCレポート分析で得たサービス一覧をもとに、SPFおよびDKIMのレコードが正しく設定されているかを確認していきました。ヒアリングから入ると曖昧な情報に引っ張られるリスクがあるため、まずDMARCレポートというファクトをベースに実態を整理してから従業員に確認する、という順序を意識しました。 MNTSQではDNSレコードはTerraformで管理されており、変更はPRを作成してSREチームにレビュー・デプロイを依頼する運用になっています。Terraformで管理されているとDNSレコードをコードとして確認しながら棚卸しを進められる点は、作業を進める上で都合が良かったです。Terraformの構成ファイルを読み進めていくとStep1の時点で把握できていなかったCNAMEサブドメインの実態がようやく明らかになったのもこのときでした。 Terraformのファイルを読み進めながら、各サービスのDKIMレコードが正しく定義されているかを一件ずつ確認していきました。以下はDKIMレコードの一例です。DKIMの公開鍵は長く、Route 53のTXTレコードは1件あたり255文字の制限があるため、format()を使って文字列を分割して定義しています。この分割が正しく行われていないとレコードが有効にならず、DKIM認証が通らない状態になります。このコードは修正後のものですが、それまでは正しく分割されておらず、レコードが無効な状態になっていました。 resource "aws_route53_record" "txt__gws_dkim" { zone_id = local.zone_id name = "google._domainkey" type = "TXT" ttl = local.ttl records = [ format ( "%s\" \"%s" , "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmBKdjgohxRFnbL8pb0BTNajVMYNAFRHwUT7WgiKmxxsvr/H5dSIcq+1xTaKTYRA5f29yUG6K2D5UJ7MHaUr2q1F3YOX6XCbX6J1MBB0JTyfDINBXxwspf8xxx6W5I/J0nAaf4SS3LEHSSTymFRiPN27sTTI13vplwgjQ07n0JRrCg62KQTxNmV" , "ekhPuLwTZM4fqKYzZVcoP1ezQOqIlRdd80pnRvSsX1bmGmofJXBxaNlRnHA9x4z8yPN09v18jT8yJRFgXw44uyMcE9+4a9l4AOHik2Z3z/yHpxjEZY47G+tXXefRlKWb0V6dDfywB/bMtkMY4O0ICH2+a0TvBbTQIDAQAB" , ) ] } こうして机上で把握できる範囲の実態を整理しきった段階で次のステップとして全社へのヒアリングに移りました。 Step3: 未対応サービスの洗い出しと対応 DMARCレポートとDNSレコードの棚卸しによってメール送信サービスの実態はある程度整理できましたが、机上の調査には限界があります。例えばDMARCレポートの収集期間外にメールを送信していたサービスは、この時点では拾えていないケースとして残り得ます。そこで全社員を対象にヒアリングを実施しました。 ヒアリングの結果、リストに載っていないサービスがいくつか出てきました。内容を確認すると、メール送信サービス経由で送信しているため実態としては問題ないケース、FromにはSaaS側のメールアドレスが使われReply-Toに会社ドメインが設定されているだけで自社ドメインからの送信ではないケースなど、対応不要と判断できるものが多くありました。一方で、社員自身では判断がつかないとして連絡をもらったものもあり、そういった情報も含めて整理を進めることができました。 全社へのヒアリングは手間がかかるように思えましたが、机上の調査だけでは拾いきれない情報を補完できた点で有効でした。 Step4: ポリシーの厳格化 DMARCレポートの分析、DNSレコードの棚卸し、全社へのヒアリングと多角的に対応を進めた結果、自社ドメインからメールを送信しているサービスの全体像が把握できました。送信元のサービスごとにSPFおよびDKIMの設定が適切に行われていることを確認し、DMARCポリシーを引き上げる準備が整いました。 ポリシーの設定にあたっては、p=quarantineを経由せずp=rejectに直接移行することにしました。ここまでの調査と対応を通じてメールの送信状態はひと通り整理できており、段階を踏むよりも一気に厳格化した方が運用上もシンプルだという判断からです。 ポリシーをp=rejectに移行した後は、継続的な運用体制の整備に着手しました。今回の取り組みを通じて対応状況や設定内容はNotionにドキュメントとして整備しました。DMARCはメールを送信するサービスが追加・変更されるたびに設定の見直しが必要になりますが、運用の標準化という観点では担当者が複数つけられる規模になっていないと難しい面もあり、現時点では筆者が責任を持って管理する体制としています。 まとめ 今回の取り組みを通じて得られた知見を整理します。 DMARCポリシーの厳格化は、設定よりも実態の把握が本質的な難しさ DMARCレコード自体は存在していても、送信元サービスの全体像が把握できていなければポリシーの引き上げはできません。レコードを書き換えること自体は簡単ですが、そこに至るまでの調査と整備に大半の時間がかかりました。 自作の分析ツールが調査の精度を上げた Valimailでは拾えていなかったCNAMEサブドメイン経由の送信元を含め、DMARCレポートの全容を把握できるようになりました。ツールを自作して可視化したことで、調査の抜け漏れを防ぐことができました。 全社ヒアリングで調査の精度を上げた 机上の調査だけでは拾いきれないケースを補完するために全社ヒアリングを実施しました。社員が自発的に判断のつかない情報を連絡してくれたことで、調査の精度が上がりました。エンジニアだけで抱え込まず、早めに全社へ展開することが有効だと感じました。 送信状態が整理できたらp=rejectまで一気に引き上げる p=quarantineは段階的な移行のための中間設定として有効ですが、今回はStep1~Step3を通じて送信元の全体像を把握した上でポリシーを引き上げたため、p=quarantineを経由せずp=rejectに直接移行しました。送信状態の整理が完了していれば、段階を踏まずに一気に厳格化することで運用上シンプルにすることができました。 おわりに DMARCポリシーの厳格化は一度対応すれば終わりではなく、新しいサービスの導入や設定変更のたびに送信元の管理が必要になります。今回の取り組みで整備した分析基盤を活用しながら、継続的に運用していく予定です。 また、TerraformのDNSレコード変更にあたり、PRのレビューとデプロイを何度もSREチームに依頼しましたが、快く対応いただきました。この場を借りて感謝を伝えたいと思います。同様の課題を抱える組織の参考になれば幸いです。
3行で要約すると CUJ(Critical User Journey)ベースのダッシュボードを作る前提として、各 CUJ に紐づく Critical API を客観的に特定する必要がありました Playwright の route API による fault injection を使い、E2E テストから Critical API を自動抽出する仕組みを作りました ある程度汎用的に使えそうなので npm にも置いています: critical-api-finder はじめに SREの寺島です。 特定の API のエラーやレイテンシーの悪化が、どのユーザ体験に影響しているのか、容易に判断できるようになりたいと思ったことはありませんか? MNTSQ は「すべての合意をフェアにする」をミッションに、契約業務を支援するプロダクトを提供しています。 SRE チームでは、顧客向けに提供しているプロダクトにおいて重要な操作のユーザ体験の劣化を早期に検知し、継続的に追うために、CUJ(Critical User Journey)ベースのダッシュボードを作りました。 その構築の過程で、各 CUJ に紐づく Critical API を Playwright のE2E テストから自動抽出するためのツールを作りました。本記事では、このツールを中心に、ダッシュボード構築の流れと合わせて紹介します。 3行で要約すると はじめに CUJ とは ダッシュボード構築の流れ 人手で仕分ける難しさ Playwright を使ったアプローチ 動作イメージ 仕組み 1. import の書き換え 2. baseline 実行 — API リストの収集 3. パスの正規化 4. ブロックループ ダッシュボードへの組み込み 最後に CUJ とは CUJ は、ユーザがプロダクトを通じて達成したい中核的な操作の流れを指します。 MNTSQでは「契約書をアップロードする」「契約レビューを依頼する」といった操作が代表的な CUJ にあたります。 各 CUJ について、関連する API のメトリクス(エラーレート、P95 レイテンシ等)を一枚の画面で見られるようにしています。 ダッシュボード構築の流れ このダッシュボードの構築は、以下のような流れで進めました。 PDM に重要な画面操作(ユーザが毎日必ず行う操作や、壊れたら業務が止まるレベルの操作)をヒアリング 各 CUJ に紐づく API の特定・整理 ダッシュボードの構築 本記事の主題は、この 2 番目の「API の特定・整理」をどう進めたかという話です。この特定・整理を進めるなかで、まず問題になるのが「どの API をメトリクスの対象にするか」という仕分けです。 というのも、MNTSQ のアプリでは、1 つの画面操作の裏側で、主力の処理から補助的なものまで数多くの API が動いています。 契約書本体の保存やメタデータの登録のように、失敗がそのままジャーニーの中断に直結する API ユーザアイコンの取得や通知バッジ件数のポーリングのように、失敗してもユーザ操作自体は継続できる API これらを区別せずにすべてダッシュボードに並べてしまうと、重要な変化がノイズに埋もれてしまいます。運用しやすく、かつ意味のあるダッシュボードにするためには、「それが止まるとジャーニーが完遂できない API(Critical API)」を正確に特定し、絞り込む必要がありました。 人手で仕分ける難しさ いざ Critical な API の仕分けをやろうとすると、意外と根拠を持たせるのが難しいことに気づきました。 ヒアリングの限界 : 開発者に確認しても、フロントエンドのエラーハンドリングの詳細(この API がコケても画面は止まらない、等)まで正確に網羅するのは負担が大きく、属人化も避けられません。 LLM に推定させる難しさ : Claude Code にコードベースを読ませて Critical な API を推定させる方法も試しました。実行時の振る舞いではなくコード上の文脈から推定する以上、画面遷移後に裏で発火するプリフェッチ系のような「実際に動かさないと見えない」依存関係は取りこぼしやすく、判定根拠の再現性も担保しにくい結果でした。 メンテナンス性 : プロダクトの改修に合わせて API の依存関係は変わるため、その都度手動で調査し直すのは現実的ではありません。 そこで、「人間が判断するのではなく、実際に API を 1 つずつ止めてみて、挙動の変化を機械的に観測すればいいのではないか」と考えました。 Playwright を使ったアプローチ もっとも単純な方法は、Chrome DevTools の "Block request URL" 機能を使って 1 つずつ API をブロックし、画面操作を手動で確かめていくやり方です。ただし、CUJ ひとつあたり数十個の API があると、これを毎回手作業で繰り返すのは現実的ではありません。手作業の負担はもちろん、人の判定が入ることで属人化や再現性の問題も再発してしまいます。 そこで着目したのが Playwright の Network API です。 page.route / context.route を使うと、ブラウザのネットワーク通信をスクリプト側から傍受したり、改変したりできます。たとえば「特定の URL パターンに合致するリクエストだけ 500 を返す」といった操作が数行で書けます。 await context.route( "**/api/contracts" , async ( route ) => { await route.fulfill( { status : 500 , body : JSON . stringify ( { error : "blocked" } ) } ); } ); これを使えば、E2E テストを 1 度書いておくだけで、 テストを 1 度走らせて、ジャーニー中に呼ばれる API をすべて記録する 記録した API を 1 つずつ 500 で短絡しながら、テストを再実行する テストが落ちた API を Critical、通った API を非 Critical と判定する という流れを完全に自動化できます。判定の根拠は「テストが通る/通らない」という二値の客観的なシグナルで、人間の解釈を挟みません。プロダクトに改修が入って依存関係が変わっても、テストを更新して回し直せば最新の Critical API リストが手に入ります。 また、Playwrightを採用した背景としては、ちょうど MNTSQ では Autify から Playwright への E2Eテストの移行プロジェクトが進んでおり、QA が書くテストをそのままインプットとして使える見込みがある、という事情もありました。 動作イメージ このアプローチをツールとしてまとめたものが critical-api-finder です。Playwright のテストファイルを用意してコマンドを叩くだけで動きます。 npm install -D @playwright/test critical-api-finder npx critical-api-find tests/contract-upload.spec.ts 実行すると、ツールが内部でテストをN+1回繰り返し実行します(最初の 1 回で API を記録 → 各 API を 1 つずつブロックしながら再実行)。終わると critical-api-results/verify-contract-upload.json に結果が出力されます: { " journeyId ": " contract-upload ", " testPath ": " tests/contract-upload.spec.ts ", " entries ": [ { " method ": " POST ", " pattern ": " /api/contracts ", " critical ": true } , { " method ": " GET ", " pattern ": " /api/contracts/:id ", " critical ": true } , { " method ": " GET ", " pattern ": " /api/v2/user/me ", " critical ": false } , { " method ": " GET ", " pattern ": " /api/v2/notifications/count ", " critical ": false } ] } 仕組み ツール内部は大きく 4 つのコンポーネントから成ります。 ※ コードは簡略化して載せています。 1. import の書き換え テストファイルを直接書き換えたくないので、CLI は sibling file( tests/contract-upload.spec.ts → tests/contract-upload.critical.spec.ts )として複製したうえで、 @playwright/test の import だけを critical-api-finder 自身に差し替えます。 // テストファイル中のこの import を… import { test , expect } from "@playwright/test" ; // 自動的にこちらに書き換える import { test , expect } from "critical-api-finder" ; 書き換えは正規表現ベースの単純置換です。 const IMPORT_RE = /^([\t ]*import\b[^;]*?\bfrom\s+['"])@playwright\/test(['"])/gm ; const REQUIRE_RE = /^([\t ]*(?:const|let|var)\b[^;]*?\brequire\s*\(\s*['"])@playwright\/test(['"]\s*\))/gm ; export function rewriteImports ( source : string ): string { return source . replace (IMPORT_RE, `$1critical-api-finder$2` ) . replace (REQUIRE_RE, `$1critical-api-finder$2` ); } critical-api-finder は @playwright/test の公開 API を全 re-export しているので、ユーザのテストはコード変更ゼロで、こちらの拡張 fixture(route handler 入り)を引き継いで動きます。 2. baseline 実行 — API リストの収集 最初の 1 回はブロックなしでテストを走らせ、 context.route で全 API を傍受してリスト化します。 await context.route( "**/api/**" , async ( route ) => { const method = route.request(). method (); const pathname = new URL (route.request(). url ()). pathname ; const normalized = normalizePathname(pathname); appendFileSync(collectFile, ` ${ method } ${ normalized } \n ` ); await route.continue(); } ); ここでは route.continue() で素通しさせるだけなので、テストの挙動には影響を与えません。観測したリクエストはあとで重複排除して、ブロックループの対象リストとして使います。 3. パスの正規化 API の path には、リソース ID のように実行のたびに値が変わる動的セグメントが含まれることがあります。たとえば、観測時に /api/contracts/12345 だった path が、次の実行では /api/contracts/67890 のように別の値になっていて、そのままブロック対象として記録しておいても当たらない、ということが起こります。 そこで、観測した path を以下のルールで正規化します。 セグメント プレースホルダ 数値 id ( /12345 ) :id UUID :uuid ISO date ( /2026-04-22 ) :date 長い hex hash (20+ 桁) :hash 実装は順序付きの置換ルールを並べただけのシンプルなものです。 const RULES = [ // UUID(数値 id より先に判定) { regex : /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi , replacement : "/:uuid" } , // ISO date { regex : /\/\d{4}-\d{2}-\d{2}(?=\/|$)/g , replacement : "/:date" } , // 長い hex hash { regex : /\/[0-9a-f]{20,}(?=\/|$)/gi , replacement : "/:hash" } , // 数値 id { regex : /\/\d+(?=\/|$)/g , replacement : "/:id" } , ] ; function normalizePathname ( pathname : string ): string { let result = pathname; for ( const { regex , replacement } of RULES) { result = result. replace (regex, replacement); } return result; } これにより、 /api/contracts/12345 も /api/contracts/67890 も同じ /api/contracts/:id という論理的なエンドポイント単位に集約されます。ブロック時は逆にこのプレースホルダを正規表現に展開し、当該パターンに合致するリクエストだけを 500 にする、という流れです。 4. ブロックループ 正規化したパターンを 1 つずつ取り出して、Playwright を再実行します。route handler は同じ場所ですが、今度は対象パターンに合致したリクエストだけを 500 で short-circuit します。 await context.route( "**/api/**" , async ( route ) => { const pathname = new URL (route.request(). url ()). pathname ; // ブロック対象パターンに合致するリクエストだけ 500 にする if (blockedRegex. test (pathname)) { await route.fulfill( { status : 500 , contentType : "application/json" , body : JSON . stringify ( { error : "Blocked by critical-api-finder" } ), } ); return ; } await route.continue(); } ); これでテストが落ちれば Critical、通れば非 Critical と判定します。なお、毎イテレーションで --retries=0 --max-failures=1 を強制することで、Playwright project 側の retry 設定によらず「ブロックした瞬間に exit」させ、無駄な再試行を防いでいます。 ダッシュボードへの組み込み 実際にE2Eテストにこのツールを当てて Critical API のリストを取り出し、そのままダッシュボードのメトリクス対象として組み込みました。ダッシュボードは Datadog 上に Terraform で管理しており、CUJ の定義は次のような形で書いています。 locals { cuj_dashboards = { sample_journey = { title = "ユーザ体験: サンプルジャーニー" service = "sample-service" trace_name = "rack" steps = [ { name = "ステップ 1" endpoints = [{ display_name = "POST /api/sample/foo" resource_name = "resources::v2::fooapi_post_/foo" }] } , { name = "ステップ 2" endpoints = [ { display_name = "GET /api/sample/foo/:id" resource_name = "resources::v2::fooapi_get_/foo/:id" } , { display_name = "GET /api/sample/bar" resource_name = "resources::v2::barapi_get_/bar" } , ] } , ] } # 他の CUJ も同じ形で並べる } } module "cuj_dashboard" { for_each = local.cuj_dashboards # CUJダッシュボード詳細を管理するためのモジュール。本筋ではないため本稿では除外 source = "./modules/cuj_dashboard" dashboard_title = each.value.title service = each.value.service trace_name = each.value.trace_name steps = each.value.steps } これによって CUJ ごとにダッシュボードが生成され、ステップ単位でエラーレート・レイテンシ・リクエスト数が並ぶ形になります。 最後に ダッシュボードに載せる Critical API のリストを、人の判断ではなく「テストの通る/通らない」という客観的なシグナルから引けるようになり、属人化とメンテナンスの問題は大きく緩和できました。 副次的な発見として、Playwrightが「回帰を防ぐためのE2Eテストツール」という用途以外にも使えそうだという気づきがありました。fault injection との組み合わせには、依存関係の抽出以外にもいろいろな応用が効きそうで、例えば個人プロダクト用の安上がりな脆弱性診断ツールやカオスエンジニアリングツールなどを似たような仕組みで自作できそうだと思いました。このあたりは今後も探っていきたいと思っています。 同じような課題に取り組んでいる方は、ぜひ覗いてみてください。(フィードバック・PRも歓迎しています) リポジトリ: github.com/kterashi02/critical-api-finder
はじめに システムが成長し、扱うデータ量やトラフィックが増大してくると、非同期処理の安定性とスケーラビリティがサービス全体の課題となります。 弊社のサービスの根幹部分はRuby on Railsを採用しているため、長らく標準の非同期処理のキューとしてResque (Redis) を使用していました。しかし、サービス規模の拡大に伴い、 Redisベースの運用では「ワーカーのオートスケール最適化」が困難である という課題が浮き彫りになってきました。 本記事では、この非同期処理のバックエンドを Amazon SQS に移行した背景と、移行に伴って行ったキュー設計・オートスケール最適化の取り組みについて紹介します。 はじめに なぜSQSなのか 非同期処理でのオートスケール実現の課題 RedisからSQSへ ─ 移行のメリットと留意点 ─ 2千万件のログからサービス特性を分析する キューの再設計 前提: アプリケーション側での事前整備 優先度ベースのキュー: 短時間ジョブを守るための4段構成 機能ベースのキュー: 大規模オペレーションの隔離 キューごとのオートスケール戦略 モニタリングと改善のループ Datadogによるモニタリング モニタリング → 仮説 → 修正のループ 結果 次の一手: SQS Fair Queue おわりに なぜSQSなのか 非同期処理でのオートスケール実現の課題 変化の激しいワークロードに対して、理想的なオートスケールを実現するためには、以下の要素が必要不可欠です。 グレースフルなシャットダウン : オートスケールインを行うということは、処理途中であっても中断が発生しうるということです。このようなイベントに対して、適切なハンドリング・リトライを行える必要があります。 細かなメトリクスを取得できる : オートスケールを運用に乗せるためには、実際のワーカー台数の変化を、メッセージの滞留数や滞留時間、同時実行数などと照らし合わせ、適切な設定になっているかを評価する必要があります。また、オートスケールの条件もこれらのメトリクスを参照することになります。 RedisからSQSへ ─ 移行のメリットと留意点 ─ Redisをバックエンドに使用していると、キューの滞留数やジョブの状態をリアルタイムで詳細に把握するために、独自のメトリクス収集の仕組みを構築・維持しなければなりません。また、エラー時のリトライ機構についても、アプリケーション側で慎重に設計・実装する必要がありました。 これらの課題を解決するため、バックエンドを Amazon SQS へ移行することを決断しました。 SQSの 「 可視性タイムアウト 」 を利用すれば、ジョブ実行中のエラーやオートスケールに伴う中断が発生しても、メッセージを安全にキューへ戻し、自動で再試行できます。これにより、複雑なエラーハンドリングを行うことなく、グレースフルシャットダウンを容易に実現できます。また、 標準で提供される滞留数や滞留時間といった強力なメトリクス をそのままオートスケールのトリガーに利用できるため、独自のモニタリング基盤を維持するコストも不要になります。柔軟にスケールし、かつ壊れにくい基盤を作るには、SQSのマネージドな特性をフル活用することが最適解だと判断しました。 注意点として、SQSには"優先度"の概念がありません。Redisベースのジョブキュー(Resque/Sidekiqなど)では1つのキュー内でジョブの優先度を表現できますが、SQSではキューそのものを分割して、優先度の高いジョブが滞留しないような設計を取る必要があります。また、at-least-once配信を前提としたジョブの冪等化や、可視性タイムアウトを超える長時間ジョブの二重実行対策といった、アプリケーション側で事前に手当てすべきポイントもあります(これらの具体的な対応については後述します)。 つまりSQS移行においては、サービスのジョブ特性を正しく理解した上で、最初に適切なキュー構成を設計できるかが成否を分けます。 2千万件のログからサービス特性を分析する 最適な設計を行うため、ジョブ全体の傾向や特徴を把握する必要があります。 「ワーカーのオートスケールの最適化」とは、即ち「顧客体験」と「運用コスト」の最適化 です。なんとなくワーカーが増えたり減ったりしているという状況はゴールではありません。弊社のサービスではアップロードした契約書の条項の分解や、検索用のインデックス作成など、顧客体験に関わる処理も非同期で行われます。このような処理が、特定のテナントや初期導入に伴う大量解析や、実行時間が比較的長時間にわたるジョブの影響(所謂 ノイジーネイバー問題 )を受けないような設計にしたいところです。また、無駄なスケールアウトはコスト観点から好ましくないです。 ということで、以下が弊社サービスでのジョブの特徴です。(見やすいように対象を絞って表示しています) 処理時間別 ジョブの実行数のグラフ(対数軸) このグラフは、データベースのジョブの実行を管理するテーブルのここ半年分のデータ約2千万件を集計したものです。(ジョブの実行ログをデータベースに蓄積していれば、リードレプリカでSQLを叩いてExcelで集計するだけなので、特別な分析基盤がなくても気軽に行えます)縦軸がジョブの実行数、横軸がジョブの実行時間を表します。(横軸も対数チックな軸になっています)また、同じジョブでも実行時間にバラツキがあるため、同一のジョブは同じ色で表現しています。 このグラフから、以下のようなワークロードの特性が見えてきました。 78%のジョブは1秒未満、 99%のジョブは10秒以内に完了する。 1秒未満のジョブは営業時間中に分間200件以上積まれる一方、10秒超えのジョブは分間1件未満しか積まれない 実行時間に 数秒から数時間のバラツキがあるジョブが存在する また、弊社のサービスでは以下のようなオペレーションが発生する点も考慮する必要があります。 初期導入: 顧客の運用開始の準備として大量のドキュメントをアップロードし、ファイル変換や解析を行う作業がある 再インデクシング: 検索機能の拡張などで、大量の検索用インデックスを更新・再作成する作業がある よって、上記を考慮しつつ、 10秒未満のジョブをいかに滞留させずに捌けるかが設計における重要な課題 でした。 キューの再設計 設計の出発点はシンプルです。 99%を占める短時間ジョブを、長時間ジョブやバーストワークロードに巻き込まれず安定して捌くこと 。これを実現するため、キューを「 優先度ベース 」と「 機能ベース 」の2軸で分割しました。 前提: アプリケーション側での事前整備 SQSのクライアントとしては shoryuken を採用しました。 キュー設計の話に入る前に、SQSをバックエンドにする上でアプリケーション側で先に手当てした2点に触れておきます。これらは設計段階で必要になることが予想できたため、本格的なチューニングに入る前に済ませました。結果として、後段のチューニングフェーズではこの2点が問題になることはありませんでした。 ひとつめは、 ジョブの冪等化 です。SQSはat-least-once配信のため、同一メッセージが複数回配信される前提で実装する必要があります。移行に伴ってジョブの抽象クラスを見直し、すべてのジョブが冪等に動作するよう統一しました。 ふたつめは、 長時間ジョブにおける可視性タイムアウトの動的延長 です。SQSの可視性タイムアウトは、処理中のメッセージが他のワーカーに再配信されないようにするための仕組みですが、ジョブの実行時間が可視性タイムアウトを超えると、処理中にもかかわらず別ワーカーで二重実行されてしまいます。これを防ぐため、長時間ジョブに対してはアプリケーション側でハートビート的に可視性タイムアウトを延長する実装を入れました。これによりインフラ側では 可視性タイムアウトのチューニングにシビアになる必要がなくなった のは設計上のポイントです。 優先度ベースのキュー: 短時間ジョブを守るための4段構成 通常業務のジョブは、 実行時間と投入パターン の2軸で4つのキューに振り分けます。 キュー名 用途 想定される投入パターン critical 顧客体験に直結し時間にシビアなJob(UIからのファイルアップロード・解析、メール発信など) 単発・低頻度 high 顧客体験に直結するが大量投入される可能性があるJob(Zip解凍やそれに伴う解析など) バースト default 顧客業務に影響するが即時性不要なJob(メール連携・定時タスク起点、外部連携起点) 中頻度 low 実行時間が10秒を超える可能性のあるJob(台帳のexport/importなど) 不定 設計の肝は2つあります。 ひとつめは、 実行時間 10秒 を境界に 短時間ジョブキュー(critical / high / default)と 長時間ジョブキュー(low)を分離する こと(以下、10秒ルール)。ジョブ全体の99%は10秒以内に完了する一方、残り1%には数十秒〜数時間に及ぶジョブが混ざっています。これを同じキューに流すと、1本の長時間ジョブがワーカーを占有してしまいます。これでは滞留時間をオートスケール条件にしたとき、無駄にスケールアウトをしてしまいます。しかし、10秒ルールを導入することにより、短時間ジョブキュー 側ではSQSの滞留時間メトリクスを直接オートスケールのトリガーに使えるようになります。 ふたつめは、短時間ジョブをさらに 「UI起点で単発投入されるもの(critical)」と「UI起点だが大量投入されうるもの(high)」で分けた こと。たとえば「Zipアップロード後の一括解析」は、1回の操作で数百件のジョブが一気に積まれる可能性があります。これを critical に流すと、1人のユーザーが大きなZipをアップロードしただけで、他のユーザーの単発操作が裏で詰まる、という典型的な ノイジーネイバー問題 が発生します。バースト性のあるワークロードを high に隔離することで、 critical は常に低い滞留数を保ち、最も厳しいSLOを当てられるようにしています。 機能ベースのキュー: 大規模オペレーションの隔離 優先度ベースの分割だけでは扱いきれないのが、冒頭でも触れた初期導入と再インデクシングです。あるテナントの大量処理が他テナントの通常業務を圧迫しないよう、このような 特例のオペレーションには、通常業務とは完全に分離した専用キュー を用意しました。 キュー名 用途 introduction 初期導入など、通常業務と分離したいJob(ファイルアップロード・解析・インデクシング) reindexing 全件indexing / 権限変更時の大量indexing用 これらの機能ベースキューは 普段はワーカー0台で待機し、必要なタイミングでのみ起動 します。そのため、キュー区分が増えることによる定常的なコスト増は発生しません。隔離したい単位でキューを切る判断を、コストを気にせず行えるのがSQS + オートスケール構成の利点です。 キューごとのオートスケール戦略 各キューには、特性に応じたオートスケール設定を割り当てています。短時間ジョブキュー(critical / high / default)は滞留時間をトリガーにスケールアウトし、こまめにスケールインする運用にしています。「滞留時間数秒以内」という明確なSLOがあるため、滞留時間ベースで反応させるのが最もシンプルです。一方、長時間ジョブキュー(low)はメッセージ数がワーカー最小数を超えたらスケールアウト、キューが空になったらスケールインとしており、SLOを設けない代わりにMAX台数を絞ることでコストを抑制しています。 モニタリングと改善のループ キューを再設計して移行が完了しても、それで終わりではありません。 設計が想定通りに機能しているかを継続的に観測し、ズレを見つけて細かく修正していくフェーズ こそが、オートスケール運用の本番です。設計時点で全てを正解にすることは不可能なので、 動かしながら最適化する前提 でモニタリング基盤を整えました。 Datadogによるモニタリング まず、Datadog上にキュー運用のためのダッシュボードを構築しました。ダッシュボードでは主に以下の観点を一覧できるようにしています。 ワーカー台数の推移 キューごとの滞留数 キューごとの滞留時間 処理中メッセージの数 これらを並べて眺めることで、「 high キューだけ滞留時間が伸びているがワーカー台数が頭打ちになっている → スケールアウト上限が低すぎる」「 default キューはスケールアウトしているのに、処理中のメッセージ数が常に少なく滞留時間が慢性的に長い → 10秒以上かかることがある長時間Jobが紛れ込んでいる」といった 具体的な問題を即座に切り分けられる ようになりました。 加えて、前述の2千万件分析にも使ったジョブ実行履歴テーブルに対し、enqueue時のキュー名の記録や検索用インデックスの追加といった改修を行い、ダッシュボードで気になった事象を SQLで即座に深掘りできる フローも整えています。 ダッシュボードが「能動的に見にいく」仕組みである一方、 異常をプッシュで検知する仕組みとして、Datadogモニタ も「キューの種類 × 指標」の組み合わせで網羅的に仕込みました。滞留時間、滞留数、ワーカーのCPU/メモリ、DLQのメッセージ数などをキューごとに監視することで、ダッシュボードを見ていない時間帯でもSLO違反や異常な振る舞いに即座に気付ける体制を作っています。キュー数 × 指標数で監視項目はそれなりの規模になりますが、AIエージェントの登場によってこれらを実現することが可能になりました。 モニタリング → 仮説 → 修正のループ 道具が揃ったあとは、ひたすら地道な改善ループを回しました。 時間のかかっているジョブを発見 : 実装を見直し、必要に応じてリファクタリング。 low への移動で済むケースもあれば、ロジック自体に改善の余地があるケースもある。Datadog APMのトレースを仕込んでひたすら問題の処理を特定するなど、時にはアプリケーションの深い部分に踏み込んで改善を行った オートスケールがうまくいっていない : メトリクスから原因を考察し、閾値や上限台数などオートスケール関連のさまざまな設定を何度も見直した キュー配置のミスマッチ : 想定と異なる挙動をするジョブを適切なキューに移動した ひとつの修正で全てが解決することは稀で、「直すと別の歪みが見える」を繰り返すのが実態でした。しかし、モニタリングの整備をしっかり行ったことによって、何が課題かが常に明確であり、継続的に改善活動を行えています。 結果 こうしたループを繰り返した結果、まだまだ課題はありますが、現在ではかなり安定してオートスケールが機能しています。 ブログ執筆時点でのオートスケールの様子 次の一手: SQS Fair Queue 本記事の設計を進めている最中、AWSから SQS Fair Queue という機能がリリースされました 。これは、MessageGroupId をテナント識別子として設定することで、SQSがノイジーテナントを自動検出し、他テナントへのメッセージ配信を優先する機能です。本記事で扱ってきた「ノイジーネイバー問題」の一部を、マネージドな仕組みで解決してくれます。 ただしFair Queuesは「ジョブ特性ごとのキュー分離」(本記事の introduction / reindexing / low など) を代替するものではなく、短時間ジョブのキュー内で発生するテナント間の不公平を緩和する位置づけです。本記事で構築したキュー設計と組み合わせることで、よりきめ細やかなノイジーネイバー対策が可能になると期待しています。 弊社ではすでに本機能を導入済みで、本番ワークロードでの効果を観察しているフェーズです。結果についてはいつか別記事でレポートできればと思います。 docs.aws.amazon.com おわりに 長くなりましたが、改めて今回の取り組みを通して得られた学びを整理します。 設計の前にデータを見る : 2千万件のログから「99%が10秒未満」というワークロード特性を掴めたことが、10秒ルールやキュー分割という具体的な設計判断に直結しました。「なんとなくスケールしている」状態から脱却するには、定量的にサービス特性を把握することが出発点になります マネージドサービスの特性に乗る : 可視性タイムアウトや標準メトリクスといったSQSの強みをそのまま設計の前提に組み込むことで、独自の監視・リトライ基盤を維持するコストから解放されました。「自前で頑張る」を減らし、マネージドな仕組みに乗っかれる箇所は徹底的に乗っかる方が、結局シンプルで壊れにくい構成になります 設計は仮説、運用しながら最適化する : 設計時点で全てを正解にすることは不可能で、動かしながら細かく修正していくフェーズの方がむしろ重要でした。そして、そのループを高速に回すには モニタリングを疎かにしないこと が大切です 最後の点について補足すると、今回これだけ細かい粒度でダッシュボードやモニタを整備できたのは、 AIエージェント(Claude Code)の存在が大きい です。キュー × 指標の組み合わせで大量のモニタを作成・保守する作業は、人の手では不可能に近いくらい大変です。(現実にはdev環境、staging環境など環境数分必要になりますし) しかし、 「Datadogリソースを定義するコード」を完全にブラックボックスにしても、AIエージェントの力を借りれば問題なく保守し続けられる 、という確信が持てたことで、「観測したいものは全部観測する」という方針を恐れずに取れるようになりました。これは単なる開発効率の話ではなく、インフラ設計の意思決定そのものに影響を与える変化だと感じています。 次回は、このDatadogダッシュボード・モニタをIaCで運用するための工夫についても記事にしてみたいと思います。ここまで読んでくださり、ありがとうございました。 MNTSQ株式会社 SRE 西室
はじめに モチベーション 実装 インフラリポジトリ(Terraform 変更) アプリケーションリポジトリ(ECS タスク定義変更) 横展開:Reusable / Caller 構成への移行 運用風景 コスト影響がない変更の場合 コスト影響がある変更の場合 おわりに はじめに 弊社では AWS 上にマルチテナント構成のインフラを複数の環境にわたって運用しており、その構成管理を Terraform でおこなっています。インフラ側のリソース構成はもちろんのこと、アプリケーション側で管理されている ECS タスク定義の CPU / メモリ割り当てに対しても、折に触れて変更が入ります。 こうしたリソース変更は無論コストに跳ねます。新規コンポーネントの追加やインスタンスサイズの変更が Pull Request(以下 PR、サービスによっては Merge Request 等の呼称もあります)として上がってくるたびに「で、これは月いくら増えるのか?」という問いが生じるわけですが、これまでは手動で料金表を引いて試算するか、試算そのものをおこなわずにマージしてしまうかの二択という格好でした。 本稿では、この問題を Claude Code による自動コスト試算で解決した取り組みと、それを GitHub Actions の Reusable Workflow 構成で全社の主要リポジトリに横展開した方法について紹介します。 モチベーション きっかけは社内 Slack で「新規コンポーネント追加やリソース割り当て変更の際には、コスト見積もりもセットでおこなう運用にしたい」という声が上がったことです。 ただし、PR のたびに人の手でコスト試算をおこなうというやり方には、以下のような難点が付きまといます。 属人的:試算する向きによって精度にバラつきが出る 忘れがち:「あとでやろう」が「やらなかった」に帰結しがち 骨が折れる:AWS の料金表を引いて、リソース変更の前後差分を計算して、PR にコメントして……という作業を手でやるのは地味に骨が折れる 一方で弊社では Claude Code による PR レビューの自動化を既に導入しており、レビューワークフローの横に「コスト試算」ワークフローを並べるのは自然な延長線上にありました。PR の変更内容からリソースの追加・変更・削除を読み取り料金に照らすような作業は、まさに LLM が得意とする領域です。 実装 インフラリポジトリ(Terraform 変更) 最初の実装はインフラリポジトリに対してのものです。既存のコードレビューワークフロー( claude-code-review.yml )とは別ワークフローとして claude-code-cost-estimate.yml を新設しています。 name : Claude Code Cost Estimate on : pull_request : types : [ opened, synchronize, ready_for_review ] branches : [ main ] paths : - 'terraform/**' concurrency : group : claude-cost-estimate-${{ github.event.pull_request.number }} cancel-in-progress : true なお paths: に terraform/** を指定しているのは、このリポジトリでは Terraform コードを terraform/ 配下にまとめる構成を採っているためです。Terraform に触れない PR ではワークフロー自体が発火しません。 レビューとコスト試算を分離したことの嬉しさは以下の通りです。 レビューコメントとコスト試算コメントが混在しない Terraform に触れない PR ではスキップされる concurrency group が独立しているため互いにブロックしない ワークフロー内では Claude Code CLI を直接インストールし、プロンプトをファイル経由で渡す方式を採っています。当初は anthropics/claude-code-action を使用していたのですが、2026 年 4 月中旬頃から本ワークフローの実行が継続的に失敗する事象に遭遇し 1 、切り分けを重ねた末に回避策として CLI 直接実行方式へ切り替えました。 プロンプトの骨子は以下です。 gh pr diff で PR の差分を取得する 追加・変更・削除される AWS リソースを特定する 必要に応じて変更ファイルを Read で読み、リソースの設定値を確認する リソースのリージョン(原則 ap-northeast-1、CloudFront・WAF 等は us-east-1)に応じた料金に基づき月額コストを試算する 試算結果を PR コメントとして投稿する 試算対象のリソースについては、プロンプト内で以下のように列挙しています。 ## 試算対象リソース 以下のリソースはコストインパクトが大きいため、必ず試算に含めること: - **コンピューティング**: ECS (Fargate vCPU/メモリ), Lambda (リクエスト数/実行時間), EC2 - **データベース**: RDS (インスタンスクラス/ストレージ/Multi-AZ), ElastiCache (ノードタイプ/ノード数), DynamoDB - **ストレージ**: S3, EBS, EFS - **ネットワーク**: NAT Gateway ($0.062/h + データ処理料), ALB/NLB ($0.0243/h + LCU), VPC Endpoint - **検索**: OpenSearch (インスタンスタイプ/ノード数/ストレージ) - **監視**: CloudWatch Logs (取り込み/保存), CloudWatch Metrics/Alarms - **CDN/グローバル**: CloudFront (リクエスト/転送量, us-east-1 料金), WAF (WebACL/ルール/リクエスト) - **その他**: KMS (キー/リクエスト), Route53 (ホストゾーン/クエリ) 試算の際の細則についてもプロンプトで指示しており、コスト影響のない変更をどう扱うかもここに含めています。 ## 試算ルール - 料金は USD で算出する(JPY 換算は不要) - リージョンは原則 ap-northeast-1(東京)だが、CloudFront・WAF・ACM(us-east-1 発行)等のグローバルサービスは us-east-1 の料金を使用すること - リソースの削除はコスト削減として負の値で表記する - コスト影響がゼロまたは無視できる変更(タグ変更、IAM ポリシー変更、セキュリティグループルール変更等)の場合は「コスト影響なし」と簡潔に報告する - 正確な料金が不明な場合は保守的(高め)に見積もり、前提条件を明記する - 環境ごとのコスト差が明確な場合(インスタンスサイズ違い等)は環境別に記載する アプリケーションリポジトリ(ECS タスク定義変更) 弊社ではアプリケーションリポジトリを複数運用しており、いずれも ECS の起動タイプとして Fargate を採用しています。これらのリポジトリでは Terraform を直接取り扱うことはなく、ECS タスク定義テンプレート(JSON)の CPU / メモリ割り当て変更が主なコスト変動要因です。こちらは Terraform 版とは異なる設計が必要でした。 とりわけ重要だったのは Fargate 料金の動的取得です。当初は料金をプロンプト内にハードコードしていましたが、x86_64 と ARM64(Graviton)では Fargate の料金が異なるにもかかわらず、プロンプトに記載していたのは x86_64 の料金のみでした。そのため ARM64 タスクに対する PR であっても x86_64 価格で試算されてしまう状態になっており、ARM64 移行が進みつつあった当時の実態と乖離した見積もりが出てしまっていたのです。 最終的には AWS 公開の Pricing Bulk API から最新の Fargate On-Demand 料金を動的に取得し、タスク定義テンプレートの runtimePlatform.cpuArchitecture からアーキテクチャを判定して適切な料金を適用しています。プロンプト内では以下のように料金取得手順を明示しています。 ## Fargate 料金の取得 料金をハードコードせず、以下の手順で動的に取得すること: 1. タスク定義テンプレートを ` Read ` で読み、各タスクの CPU アーキテクチャ(ARM64 / X86 _ 64)を特定する - ` runtimePlatform.cpuArchitecture ` の値を確認する - テンプレート変数や条件分岐でアーキテクチャが切り替わる場合は、変数定義側も参照して実際の値を確定する - ` runtimePlatform ` が存在しない場合は X86 _ 64 とみなす 2. 以下のコマンドで ap-northeast-1 の最新 Fargate On-Demand 料金を取得する: ```shell-session $ curl -s "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/ap-northeast-1/index.json" | jq ' .terms.OnDemand as $terms | [.products | to_entries[] | select(.value.attributes.usagetype | test("Fargate")) | select(.value.attributes.usagetype | test("Windows|Ephemeral") | not) | .key as $sku | {usagetype: .value.attributes.usagetype, price_usd: ($terms[$sku] | to_entries[0].value.priceDimensions | to_entries[0].value.pricePerUnit.USD)}]' ``` 3. usagetype とアーキテクチャの対応: - ` Fargate-vCPU-Hours:perCPU ` / ` Fargate-GB-Hours ` → X86 _ 64 - ` Fargate-ARM-vCPU-Hours:perCPU ` / ` Fargate-ARM-GB-Hours ` → ARM64 4. タスクのアーキテクチャに対応する料金と 730h/月 で試算する タスク定義の ` cpu ` / ` task_cpu ` は vCPU ユニット(1024 = 1 vCPU)、 ` memory ` / ` task_memory ` は MiB 単位です。 横展開:Reusable / Caller 構成への移行 当初、複数のアプリケーションリポジトリへの横展開は、ワークフロー YAML をそのままコピペする格好でおこないました。 しかしこの方式はすぐに破綻しました。横展開を完了した直後から、料金算出ロジックを実情に適うものにするためのプロンプトなどの修正や、利用中アクションの SHA pin 更新といった作業が連続で必要になったのです。すべてのリポジトリに同じ修正 PR を展開して回るというのは、地味に手間のかかる作業です。ワークフロー定義におけるリポジトリ固有の内容は一部に限られ、ゆえに大半が共通化可能なものでした。そこに修正が入るたびに、リポジトリ間での辻褄合わせの為に全リポジトリへ同じ変更を行う必要が生じていました。 そこで GitHub Actions の Reusable Workflows を活用し、以下の二層構成に整理し直す格好としました。 Reusable Workflow(テンプレートリポジトリ) 共通プロンプト(Fargate 料金取得ロジック、試算ルール、コメントフォーマット、セキュリティ指示) workflow_call トリガにて外部から呼び出し可能 inputs.repo_context (リポジトリ固有のタスク定義構成説明)と inputs.additional_rules (追加ルール)を受け取る Caller Workflow(各アプリケーションリポジトリ) トリガ条件( paths フィルタ)の定義 repo_context にリポジトリ固有の構成説明を記述 Reusable Workflow を呼び出すのみ Caller 側のコードは以下のように簡素です。 name : Claude Code Cost Estimate on : pull_request : types : [ opened, synchronize, ready_for_review ] paths : - 'app/task-definition/**' concurrency : group : claude-cost-estimate-${{ github.event.pull_request.number }} cancel-in-progress : true jobs : cost-estimate : uses : <自組織>/<テンプレートリポジトリ>/.github/workflows/reusable-claude-code-cost-estimate.yml@main with : repo_context : | ## リポジトリ構成 このリポジトリでは ECS タスク定義を以下のように管理しています : - `app/task-definition/template-*.json`: タスク定義テンプレート - `app/task-definition/variables-*/`: 環境ごとの変数オーバーライド ... secrets : CLAUDE_CODE_OAUTH_TOKEN : ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} uses: の記法は <owner>/<repo>/<path>@<ref> の形式で、別リポジトリにある Reusable Workflow を参照します( GitHub 公式ドキュメント )。 @<ref> にはブランチ・タグ・コミット SHA のいずれも指定できます。 repo_context は Reusable Workflow 側のプロンプトにそのまま埋め込まれる文字列入力です。リポジトリ固有のタスク定義の置き場所や YAML 構造、その他の注意事項を自然言語で記述しておけば、Claude は PR 差分をその文脈で解釈してくれます。従来であればリポジトリごとに構造解析ロジックを書き分ける必要があったところを、自然言語で補足を書くだけで済ませられるのは LLM を挟む大きな利点です。 この構成の嬉しさは明白です。Fargate 料金取得ロジックの修正や利用中アクションの SHA pin 更新が必要になった際、テンプレートリポジトリの 1 箇所を修正するだけで全リポジトリに反映されます。各リポジトリの Caller は自身の構成説明( repo_context )にのみ責任を持てばよく、共通部分の保守から解放されました。 なお Reusable Workflow を別リポジトリから参照するにあたっては、以下 2 つの設定が必要です。詳細は GitHub 公式ドキュメント を参照してください。 呼び出し元リポジトリ : Settings > Actions > General にて "Allow <自組織>, and select non-<自組織>, actions and reusable workflows" を有効化する テンプレートリポジトリ : Settings > Actions > General の "Access" セクションにて、他リポジトリからの参照を許可する 運用風景 実際に投稿されているコメントの例を以下に紹介します(機密情報は適宜マスクしています)。 コスト影響がない変更の場合 Fluent Bit のログフィルタ設定追加のような、AWS リソースの追加・変更・削除を伴わない PR に対しては、以下のように簡潔に報告されます。 コスト影響なしの報告例: Fluent Bit のログフィルタ設定追加 PR に対するコメント コスト影響がある変更の場合 具体的な金額差を伴う試算結果の例として 2 つ挙げます。 ECS タスクのアーキテクチャを x86_64 から ARM64 に変更する PR では、Fargate 料金の差分がタスクごとに算出されます。 コスト影響ありの報告例: ECS タスク定義の ARM64 移行 PR に対するコメント Terraform 側の例としては、OpenSearch マスターノードのインスタンスタイプ変更 PR に対して、インスタンス単価の根拠とともに月額差分が算出されます。 コスト影響ありの報告例: OpenSearch マスターノードのインスタンスタイプ変更 PR に対するコメント 前者のようにコード変更量は小さいがコスト影響が見えづらいケースや、後者のように一見スペックアップに見えてコスト削減となる直感に反するケースにおいて、数値根拠と併せて即座に金額影響が可視化されるのはレビュアーにとっての判断材料として有用です。 おわりに PR 上の Terraform 変更や ECS タスク定義変更に対して Claude Code にコスト影響を自動試算させる仕組みと、Reusable Workflow による全リポジトリへの横展開について紹介しました。 今回の取り組みで得られた利点は以下の通りです。 試算の自動化:コスト影響の有無が PR 上で自動的に可視化され、手動での料金表引きが不要になった レビュー観点の底上げ:「このリソース変更はいくらかかるのか」が PR コメントとして残るため、レビュアーがコスト観点でも判断できるようになった 保守性:Reusable Workflow への集約により、共通ロジックの修正が 1 箇所で済むようになった 一方で、横展開の過程ではプロンプトの修正や利用中アクションの SHA pin 更新など、全リポジトリに修正 PR を展開する羽目になる場面が複数ありました。最初から Reusable Workflow 構成にしておけばよかったと思わないでもないですが、まずは動くものを 1 リポジトリで作り、その後横展開しつつ構成を洗練させてゆくアプローチは結果的には妥当だったと考えています。実際に横展開をおこなって初めて見えてくる問題(アーキテクチャごとの料金差異など)もあり、最初から完璧な設計を目指すよりも実地で鍛えてゆくほうが確実なものが出来あがる向きもあります。 また、横展開とは別の文脈ですが、実装初期に遭遇した小さな事件として、コスト試算コメントを gh pr comment --body オプションにインラインで渡していたところ、本文中の $0 が bash のシェル変数として展開され /bin/bash に化けるというものもありました。 --body-file 経由に変更して解決しましたが、LLM を GHA 上で取り扱う際にはシェルとの境界に注意が必要であるという学びを得ています。 Terraform のコスト試算ツールとしては Infracost のような専用ツールもあります。今回それらを採用しなかったのは、PR 上でザックリ差額感を掴めれば充分で、専用ツールを導入・運用するほどに精緻な分析を求めていたわけではない、という向きが大きいです。また弊社では既に Claude Code を PR レビューの自動化で使っており、その延長線上で賄えるという点も後押しとなりました。その点、Claude Code を使うアプローチは「diff の文脈を理解した上で、コスト影響のない変更を適切にスキップできる」「プロンプトの修正のみで試算ルールを柔軟に変更できる」という固有の嬉しさがあり、要求水準に充分適うものとなりました。 PR レビュープロセスにコスト観点を自然に組み込みたい向きに、本稿で紹介した事例が一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.7 が作成した内容をほぼそのまま使用しています 当時の調査では anthropics/claude-code-action の #1205 や #1126 といったものを参照していました。原因の完全な特定には至らなかったのですが、CLI 直接実行への切り替えにより事象は解消しています。 ↩
はじめに 弊社では複数の Amazon OpenSearch Service ドメインを運用しています。これらのドメインはいずれも VPC 内に閉じた構成をとっており、セキュリティ強化を目的に きめ細やかなアクセス制御 (Fine-grained Access Control; FGAC)を有効にしています。 FGAC は「誰が OpenSearch 上でどの操作を許可されるか」をロール単位で制御する仕組みです。設定には OpenSearch の Security API を VPC 内から呼び出す必要があります。以前は手順書にもとづいて手作業で設定していましたが、OpenSearch を利用するサービスやドメインが増えるにつれてスケールしなくなってきました。 本稿では、この課題への対策として以下アプローチを採ることとしました。 FGAC 設定(ロールおよびマッピングの各定義;後述)を Terraform コードとして定義 OpenSearch ドメインへの FGAC 設定投入を VPC 内で CodeBuild を実行することで実施するようリソースを定義 以下でこのアプローチの詳細について扱います。 FGAC で設定するもの FGAC の設定対象は大きく2つです。 ロール定義 :OpenSearch 上のロールが持つ権限(どのインデックスにどの操作を許可するか等)を記述する ロールマッピング :上記ロールに「誰を」紐づけるかを記述する。AWS 環境では IAM ロールや IAM ユーザを OpenSearch 上のロールに紐付けることに対応する これを踏まえ、弊社では用途に応じて以下の3種のロールを定義し運用しています。 ロール名 用途 マッピング対象 all_access / security_manager 管理者権限 OpenSearch の master user として機能する IAM ロール mntsq アプリケーション用 各サービスの ECS タスクロール等、OpenSearch にアクセスする IAM ロール mntsq_operators 運用者用 OpenSearch 運用を担当するメンバが使う IAM ロール 課題と方針 OpenSearch を利用するサービスが追加されると、そのサービスの IAM ロールを mntsq ロールの backend_roles に追加する作業が発生します。また、新しい OpenSearch ドメインが作成された場合には3つ全てのロール設定を一から行う必要があります。この作業を続けることで以下のような課題が浮かび上がってきました。 手順書はあるが手作業であり、設定内容の差分管理やレビューができない OpenSearch ドメインの追加や既存設定の修正が発生するたびに手作業が必要となり、運用負荷が高い こうした課題を解消すべく、FGAC 設定をコードとして管理し、OpenSearch への適用も自動化することを目指しました。 terraform-provider-opensearch ではダメなのか OpenSearch のリソースを Terraform で管理する手段としては terraform-provider-opensearch が存在します。「それで十分ではないか」という指摘はもっともですが、弊社の構成ではこのアプローチは成立しません。 理由は VPC にあります。弊社の OpenSearch ドメインは VPC 内に閉じており、パブリックエンドポイントを持ちません。terraform-provider-opensearch は Terraform の実行環境から直接 OpenSearch にリクエストを送る必要がありますが、弊社では Terraform の実行主体は GitHub Actions であり、VPC の外にいます。つまり Provider が OpenSearch に到達できません。 「であれば Terraform の実行環境自体を VPC 内に配置すればよいのではないか」という考えもあります。たとえば CodeBuild を GitHub Actions の self-hosted runner として VPC 内で動かす方法 があります。しかしこの構成では FGAC 設定に限らず全ての terraform plan / terraform apply が VPC 内を経由することになります。FGAC の設定のためだけに Terraform 全体の実行環境を切り替えるのは割に合いません。 採用したアーキテクチャ terrarform-provider-opensearch を使わず、かつ必要な設定内容を IaC し、その反映も自動化したい。こうした些か欲張りな要件を満たすため、「定義と適用を分離する」アプローチを採用しました。考え方はシンプルです。 Terraform が担うこと:FGAC のロール定義・ロールマッピングをコードとして宣言し、CodeBuild プロジェクトの環境変数に JSON として注入する CodeBuild が担うこと:VPC 内で起動し、環境変数から受け取った JSON を OpenSearch Security API に PUT する 概念図 Terraform はあくまで「何を設定するか」を管理し、「設定を OpenSearch に届ける」部分は VPC 内で動ける CodeBuild に委ねる形です。 実装 FGAC ロール定義 3種のロール定義とロールマッピングは Terraform の locals ブロックで宣言しています。 locals { fgac = { # 管理者用 admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } # アプリケーション用 app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten ( [ for role_name in config.user_iam_roles : data.aws_iam_roles.targets [ role_name ] .arns ] ) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = [ "*" ] index_permissions = [{ index_patterns = [ "*" ] fls = [] masked_fields = [] allowed_actions = [ "*" ] }] tenant_permissions = [{ tenant_patterns = [ "*" ] allowed_actions = [ "kibana_all_write" ] }] } } # 運用者用 ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains ( [ "group-a" , "group-b" , "group-c" ] , group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots" , "cluster_monitor" , "cluster:admin/opendistro/ism/policy/search" , "cluster:admin/opendistro/ism/policy/get" , # ... ISM / 通知関連の権限が続く ] index_permissions = [{ index_patterns = [ "*" ] allowed_actions = [ "get" , "search" , "read" , "indices_monitor" , "manage" ] }] tenant_permissions = [] } } } } アプリケーション用( app )について補足します。 var.opensearch は OpenSearch ドメインをキーとする map で、環境層の main.tf にて各ドメインごとに user_iam_roles (そのドメインにアクセスする必要のある IAM ロール名のリスト)を定義しています。 assign_json_map はこの user_iam_roles をもとに IAM ロール ARN を引き当て、ドメインごとの backend_roles を動的に構築します。OpenSearch ドメインによってアクセス元のサービスが異なるため、マッピングもドメイン単位で分かれる必要があるということです。 運用者用( ops )は少し毛色が異なります。アプリケーション用では IAM ロールを backend_roles に設定していましたが、運用者用では IAM Identity Center のグループ ID を backend_roles として使います。 data.aws_identitystore_groups で運用担当のグループを引き、そのグループに所属するメンバが OpenSearch Dashboards にアクセスした際に適切な権限が付与されるようにしています。 管理者用( admin )にはロール定義がありません。 all_access と security_manager は OpenSearch に組み込みで存在するロールであり、権限の内容を改めて定義する必要がないためです。ここで管理するのは「誰をそのロールに紐づけるか」というマッピングだけです。 CodeBuild プロジェクト FGAC の設定を OpenSearch に届けるには VPC 内から Security API を呼び出す必要がある、という点はここまでで述べたとおりです。CodeBuild には vpc_config を指定することでビルド環境を VPC 内のサブネットで起動する機能があります。これを利用すれば、Terraform 自体は VPC 外で動かしつつ、設定の適用だけを VPC 内で実行できます。 上述 locals を jsonencode() で JSON 文字列に変換し、CodeBuild プロジェクトの環境変数に渡します。HCL のオブジェクトはそのままでは環境変数(文字列)として注入できないため、この変換が必要です。 resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "$ { var.env } -$ { var.service } -$ { each.key } -fgac-configuraton-manager" build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.app.assign_json_map [ each.key ] ) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://$ { module.opensearch [ each.key ] .domain.custom_endpoint } " } } # VPC 内で実行するための設定 vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [ aws_security_group.codebuild.id ] } source { type = "NO_SOURCE" buildspec = templatefile ( "$ { path.module } /buildspecs/fgac_configuration_manager.yaml.tmpl" , {} ) } } for_each = var.opensearch としているので、OpenSearch ドメインの数だけ CodeBuild プロジェクトが作成されます。各プロジェクトの環境変数にはそのドメイン固有のマッピング情報が入ります。 vpc_config ブロックで VPC ID、プライベートサブネット、セキュリティグループを指定することで、CodeBuild のビルド環境が VPC 内で起動するようになります。これが本構成の核心です。 IAM ロール CodeBuild が OpenSearch の master user として振る舞うためのロールを定義しています。 resource "aws_iam_role" "opensearch_master_role" { name = "$ { var.env } -$ { var.service } -opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com" , "opensearch.amazonaws.com" , ] } } } このロールは2つのサービスから Assume されます。 codebuild.amazonaws.com は CodeBuild の実行ロールとして、 opensearch.amazonaws.com は OpenSearch ドメインの master user として、それぞれこのロールを使います。ひとつのロールに両方の信頼関係を持たせることで、"CodeBuild がこのロールで実行する = OpenSearch の master user として振る舞える" という構図を成立させています。 Buildspec FGAC のロール定義やロールマッピングは、OpenSearch の Security REST API に対して PUT リクエストを送ることで設定します。利用するエンドポイントは以下の2つです。 /_plugins/_security/api/roles/{ロール名} :ロール定義の作成及び更新 /_plugins/_security/api/rolesmapping/{ロール名} :ロールマッピングの作成及び更新 CodeBuild ではこの API 呼び出しを以下の buildspec で実行しています。 version : 0.2 phases : install : commands : - pip3 install awscurl build : commands : # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT' やっていることは素直です。環境変数から JSON を取り出してファイルに書き、 awscurl (AWS SigV4 署名付きの curl)で OpenSearch Security API に PUT します。各ロールについて ロール定義の PUT → ロールマッピングの PUT の順で実行します。管理者用は組み込みロールへのマッピングのみなのでロール定義の PUT はありません。 awscurl を使うことで IAM ロールベースの認証が自動的に行われるため、API キーやパスワードの管理は不要です。 以上の実装をまとめたコード全体を以下に示します。 Terraform コード全体(クリックで展開) # ================================================== # データソース # ================================================== data "aws_iam_roles" "targets" { for_each = toset ( flatten ( values (var.opensearch) [ * ] .user_iam_roles)) name_regex = ".*$ { each.value } $" } data "aws_ssoadmin_instances" "main" {} data "aws_identitystore_groups" "main" { identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] } # ================================================== # FGAC ロール定義(locals) # ================================================== locals { fgac = { admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten ( [ for role_name in config.user_iam_roles : data.aws_iam_roles.targets [ role_name ] .arns ] ) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = [ "*" ] index_permissions = [{ index_patterns = [ "*" ] fls = [] masked_fields = [] allowed_actions = [ "*" ] }] tenant_permissions = [{ tenant_patterns = [ "*" ] allowed_actions = [ "kibana_all_write" ] }] } } ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains ( [ "group-a" , "group-b" , "group-c" ] , group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots" , "cluster_monitor" , "cluster:admin/opendistro/ism/policy/search" , "cluster:admin/opendistro/ism/policy/get" , "cluster:admin/opendistro/ism/policy/write" , "cluster:admin/opendistro/ism/policy/delete" , "cluster:admin/opensearch/notifications/channels/get" , "cluster:admin/opensearch/notifications/configs/get" , "cluster:admin/opensearch/notifications/configs/create" , "cluster:admin/opensearch/notifications/configs/update" , "cluster:admin/opensearch/notifications/configs/delete" , "cluster:admin/opensearch/notifications/features" , "cluster:admin/opensearch/notifications/feature/send" , ] index_permissions = [{ index_patterns = [ "*" ] dls = "" fls = [] masked_fields = [] allowed_actions = [ "get" , "search" , "read" , "indices_monitor" , "manage" ] }] tenant_permissions = [] } } } } # ================================================== # IAM ロール・ポリシー # ================================================== data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com" , "opensearch.amazonaws.com" , ] } } } resource "aws_iam_role" "opensearch_master_role" { name = "$ { var.env } -$ { var.service } -opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "codebuild_permissions" { statement { effect = "Allow" actions = [ "logs:CreateLogGroup" , "logs:CreateLogStream" , "logs:PutLogEvents" , "ec2:CreateNetworkInterface" , "ec2:DescribeDhcpOptions" , "ec2:DescribeNetworkInterfaces" , "ec2:DeleteNetworkInterface" , "ec2:DescribeSubnets" , "ec2:DescribeSecurityGroups" , "ec2:DescribeVpcs" , "ec2:CreateNetworkInterfacePermission" , ] resources = [ "*" ] } } data "aws_iam_policy_document" "opensearch_permissions" { statement { effect = "Allow" actions = [ "es:*" ] resources = [ "*" ] } } resource "aws_iam_policy" "codebuild" { name = "$ { var.env } -$ { var.service } -codebuild-permissions" policy = data.aws_iam_policy_document.codebuild_permissions.json } resource "aws_iam_role_policy" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy = data.aws_iam_policy_document.opensearch_permissions.json } resource "aws_iam_role_policy_attachment" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy_arn = aws_iam_policy.codebuild.arn } # ================================================== # セキュリティグループ # ================================================== resource "aws_security_group" "codebuild" { name = "$ { var.env } -$ { var.service } -codebuild" vpc_id = local.remote_state_core.vpc.vpc_id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = [ "0.0.0.0/0" ] } } # ================================================== # CodeBuild プロジェクト # ================================================== resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "$ { var.env } -$ { var.service } -$ { each.key } -fgac-configuraton-manager" description = "Configure OpenSearch FGAC roles for $ { each.key } in $ { var.env } " build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn artifacts { type = "NO_ARTIFACTS" } environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" image_pull_credentials_type = "CODEBUILD" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.app.assign_json_map [ each.key ] ) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://$ { module.opensearch [ each.key ] .domain.custom_endpoint } " } environment_variable { name = "AWS_REGION" value = data.aws_region.current.region } } logs_config { cloudwatch_logs { status = "ENABLED" } } vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [ aws_security_group.codebuild.id ] } source { type = "NO_SOURCE" buildspec = templatefile ( "$ { path.module } /buildspecs/fgac_configuration_manager.yaml.tmpl" , {} ) } } Buildspec 全体(クリックで展開) version : 0.2 phases : install : commands : - pip3 install awscurl build : commands : # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT' 運用フロー 初期セットアップ 新しい OpenSearch ドメインを作成した場合の流れは以下のようになります。 terraform apply で OpenSearch ドメインと CodeBuild プロジェクトが作成される AWS コンソールから対象の CodeBuild プロジェクト( ${ENV}-search-${DOMAIN}-fgac-configuraton-manager )を手動で Start build する FGAC のロールとマッピングが OpenSearch に設定される terraform apply は CodeBuild プロジェクトの定義(環境変数やビルド環境の設定)を作成するのみで、ビルド自体の実行は行いません。そのため初回は手動で Start build する必要があります。 設定が正しく反映されたかどうかは、OpenSearch の /_plugins/_security/authinfo API で確認できます。対象の IAM ロールで OpenSearch にリクエストを送り、レスポンスの roles フィールドに期待するロール名が含まれていれば設定は成功です。 設定変更 新しいサービスが OpenSearch にアクセスする必要が生じた場合の流れは以下のようになります。 環境層の main.tf で該当ドメインの user_iam_roles にロール名を追加する PR を作成してレビューを受ける(ここで設定差分がコードとして見える) terraform apply で CodeBuild プロジェクトの環境変数が更新される CodeBuild を再実行して新しいマッピングを OpenSearch に適用する ステップ 3 の terraform plan では、CodeBuild プロジェクトの環境変数( APP_MAPPING_DEFINITION_JSON 等)の値に差分が出ます。JSON 文字列の diff として「どの IAM ロール ARN が追加されたか」が確認できるため、レビュー時の判断材料になります。 Security API の PUT は既存の設定を全量上書きする動作であるため、CodeBuild の実行は冪等です。同じ設定で何度実行しても結果は変わりません。誤って二重実行してしまった場合でも問題は生じません。 設定変更が PR として可視化されるのが、手作業時代との大きな違いです。「どの環境のどのドメインに、どの IAM ロールがいつ追加されたか」がコードの履歴として残ります。 CodeBuild の手動実行について 現時点では terraform apply の後に CodeBuild を手動で実行するステップが残っています。 terraform apply だけで設定の反映まで完結するのが理想ではありますが、現状の構成でも手作業で行っていた FGAC 設定がコード管理下に入り、適用も CodeBuild のボタンひとつで済むようになったことで、運用負荷は大きく改善されています。 おわりに VPC 内に閉じた OpenSearch の FGAC 設定を構成管理するにあたり、terraform-provider-opensearch が使えないという制約のもとで Terraform で宣言し、CodeBuild で適用する というアプローチを紹介しました。 Terraform の得意なこと(宣言的な定義、差分管理、環境間の一貫性)と CodeBuild の得意なこと(VPC 内での任意のコマンド実行)を組み合わせることで、Provider が直接使えない領域にも構成管理の恩恵を持ち込むことができます。VPC 内 OpenSearch に限らず、「Terraform の実行環境からは到達できないが設定をコード管理したい」という場面で応用できるパターンかと思います。 VPC 内に存在する諸要素の構成管理に課題を感じている方に本稿で紹介した事例が一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.6 が作成した内容をほぼそのまま使用しています
SREの藤原です。 MNTSQではセールス、コンサルティング、テクニカルサポートのメンバーなどが顧客からの問い合わせに回答する際に参照するセキュリティホワイトペーパーが存在しています。 このセキュリティホワイトペーパーを、これまではGoogle Docsにて管理していました。 これをマークダウン + GitHubリポジトリでの管理に移行したので、その事例エントリです。 Google Docsで管理する際に発生していた問題 Google Docs自体は共同編集ツールとしては非常に優れています。 一方で、社内で公式に参照する文書という観点では課題を抱えていました。 主な課題としては以下の2点です。 変更管理の難しさ 複数人で異なる変更を行う場合にそれぞれを個別に変更として取り込むことがGoogle Docsでは困難でした 最新版か否かの分かりづらさ Google Docs自体にも版管理機能はありますが、リリース版のファイルを個別で管理する必要があるなど少々面倒な側面があります。 特定のファイルが公式としての変更が加えられたものなのか、それとも変更は加えられているが、それがまだレビュー中なのか?といった判断が難しい(変更の提案機能などをつかうこともできるが、必ずしもそれを全員が使うわけでもありません) 解決策としてのGitHub管理 解決策は非常にシンプルでGoogle Docsではなく、GitHubでマークダウンとして管理するという方式にしました。 元のドキュメント自体もGoogle Docsでなければ困るような高度な機能を利用しているわけでもなかったため、マークダウンで実現可能な表現力で十分であるという判断のもと、マークダウン+GitHubに移行しました。 GitHubのリポジトリにて章ごとにホワイトペーパーが管理されている様子 章ごとにマークダウンファイルを分割することで、変更のコンフリクトが起こりづらいようにしています。 以降では、GitHubに移行することによるメリットについて述べていきます。 メリット1: 変更管理がPRベースになった GitHubに移行したことで、文書の変更がプルリクエスト(PR)ベースで管理されるようになりました。結果として以下を容易に実現できるようになりました。 どの章を、誰が、なぜ変更したか、がコミットメッセージとPR説明に残ります レビュアーは差分(diff)を見て内容を確認できます マージ前に承認フローを設定することで、「誰でも勝手に書き換えられる」リスクを排除できます これはコードのレビューとまったく同じフローだ。エンジニアにとっては馴染み深いプロセスなので、文書の品質管理がやりやすくなりました。 メリット2:Claude Codeで文書修正が容易になった 章ごとにマークダウンで分かれているため、Claude Codeを使った文書修正が非常にやりやすくなりました。 たとえば、まずは、キーワードのみをClaude Codeに与えて文章を出力した後に、文書内容を確認して、問題ないかといったことができるようになりました。またその修正結果はそのままGitのdiffとして確認でき、PRを作ってレビューするだけで取り込むこともできますし、他の人からのレビューもしやすくなります。 人が文章を記述するよりもClaude Codeの方が書き上げるまでにかかる時間は短く、文章の質ともより高いものになります。キーワードとおおよその文脈情報さえ与えれば、ほとんどの場合において、内容を確認して一部だけ修正することで改善できます。 メリット3: GitHub Actionsを使ったPDF出力と、リリース管理 「公式文書としての最新版管理」を実現するため、GitHub Actionsのワークフローを組みました。 仕組みは次の通りです。 任意のタイミングでリリース版作成のGitHub Actionsワークフローをトリガーします ワークフロー内でタグをきり、プレリリースを作成します マークダウンファイルを結合してpdfファイルを作成します 3で作成したpdfファイルを2で作成したプレリリースに成果物として付与します GitHub Actionsワークフローでpdf出力しているイメージ ここで、プレリリースまでとしているのはpdfファイルの体裁などが崩れていないか?を確認したのちに、リリースに昇格することを意図しています。 GitHubリポジトリに作成されたリリースの例、アーティファクトとしてのpdfも確認できる GitHubリポジトリのリリースを見れば、最新版のドキュメントにたどり着けるようになります。 移行時の注意点 ただし、マークダウンに移行する際の注意点としては、以下の2点が挙げられます。 日本語フォント GitHub Actionsでは日本語フォントを明示的にインストールする必要があります。 fonts-noto-cjkなどをインストールして利用しましょう。 pdfへの変換ツールの選定 本事例では md-to-pdf を利用しています。今回要求されている事例ではこれで要件を満たせていたため、問題はなかったのですが、高度な組版などが要求される場合はそれにフィットしたツール( Pandoc や Vivliostyle.js 、 Re:VIEW など)が必要になるかもしれません。 github.com pandoc.org vivliostyle.org reviewml.org まとめ ここまでGoogle Docsで管理されていたドキュメントをGitHub + マークダウン管理に移行した事例について述べました。これによって、ドキュメントの変更管理と最新版配布などが容易にできるようになりました。特に定期的な更新が発生しつつ、SSoTとして正確性が重要になる文書にはフィットするとおもいます。 本事例が文書管理に悩んでいる組織の参考となれば幸いです。
はじめに SREの寺島です。 MNTSQでは、本番環境でのAWSの手動操作や顧客情報データへのアクセス等をCloudTrailログから検知し、操作者に目的や理由を確認するセキュリティ監査を運用しています(詳細は こちらの記事 を参照)。 これまではログからの異常検知は自動化されていたものの、その後の操作内容の確認や目的や理由を確認する運用が人力で行われており、運用上のToilとなっていました。 この課題を解決するため、今回、セキュリティ監査運用を自動化するSlack Botを開発しました。本記事では、そのアーキテクチャから実装の詳細までを紹介します。 はじめに 背景 監査の仕組み 課題 どのようなものを作ったか 動作フロー 通知 回答 リマインド アーキテクチャ コンポーネント一覧 Slack Appの設定 各機能の実装 通知(notify-audit-report) 回答受け付け(audit-response-handler) リマインド(send-audit-reminder) 導入効果 コスト面 機能・運用面 セキュリティ面・ガバナンス面 最後に 背景 監査の仕組み MNTSQでは、本番環境に対する以下のような操作をセキュリティ監査の対象としています。 検知対象 データソース 本番系AWSアカウントでの手動変更操作 CloudTrail 顧客情報を収容するS3バケットへのアクセス CloudTrail 業務時間外のRedash操作・ログイン Redashサーバログ / CloudTrail(IAM Identity Center) これらの検知からSREメンバーへの通知までは自動化されていました。EventBridgeをトリガーに週次でAthenaの名前付きクエリを実行し、結果をSREチャンネルに通知する仕組みです。 例えばAWSの手動変更操作の場合、以下のようなAthenaクエリが実行されます。 select ${環境名} as environment, eventtime, split_part(userIdentity.principalId, ' : ' , 2 ) as email, eventName, resources[ 1 ].arn as resource , requestParameters from ${環境名} where timestamp between date_format( current_date - interval ' 1 ' day, ' %Y/%m/%d ' ) and date_format( current_date , ' %Y/%m/%d ' ) -- 個々人の直接操作を対象とする and userIdentity.principalId like ' %@mntsq.com ' -- 新設 / 変更 / 削除に該当する操作を対象とする and ( eventName like ' Update% ' or eventName like ' Put% ' or eventName like ' Create% ' or eventName like ' Delete% ' or eventName like ' Modify% ' ) -- CloudShell そのものに関するイベントは対象から外す and eventsource != ' cloudshell.amazonaws.com ' 個人の手動操作のうち、リソースの作成・変更・削除にあたるイベントを抽出しています。 不審な操作が検知されると、以下のようにSlackへ通知されます。 課題 検知までは自動化されていた一方で、その後の操作内容の確認・操作者へのヒアリングは手作業となっていました。 具体的な作業フロー 担当者がAthenaの結果を確認 ログを読み解いて操作内容を把握、背景を確認 操作者に個別にヒアリング 回答がなければリマインド この作業を担当していたSREメンバーは、週に約3時間をこの確認フローに費やしていました。年間に換算すると約1人月の工数です。これは弊社のSREチームの規模を考えると無視できないコストでした。 どのようなものを作ったか 上述の課題を解決するため、セキュリティ監査の運用を自動化するSlack Botを開発しました。 このSlack Botに「A2RM(Automatic Audit Reaction Mechanism)」と名付けました。 別名「監査回答強制マン」です。 Slack Botは、主に以下の4つの機能を備えています。 Amazon Bedrockによるログ要約 : 操作ログを自然言語に変換して操作対象者に通知 Slack UIによる回答受付 : ボタン選択による回答の簡略化 自動リマインド : 未回答者への追跡通知 証跡管理 : 検知内容と回答結果のDynamoDB保存 動作フロー 通知 不審な操作が検知されると、Slackチャンネルへ操作者本人をメンションしたメッセージが投稿されます。 CloudTrailの生ログは可読性が低く、内容の把握に一定の知識を要します。 例えば、S3バケットポリシーの変更操作を行った場合、Athenaから出力されるCSVは以下のようになります。 "environment","eventtime","email","eventName","resource","requestParameters" "example-env","2026-03-06T07:57:17Z","user@example.com","PutBucketPolicy","arn:aws:s3:::example-tfstate","{""bucketPolicy"":{""Version"":""2012-10-17"",""Statement"":[{""Sid"":""AllowDatadogReadAccess"",""Effect"":""Allow"",""Principal"":{""AWS"":""arn:aws:iam::123456789012:root""},""Action"":[""s3:GetObject"",""s3:ListBucket""],""Resource"":[""arn:aws:s3:::example-tfstate/*"",""arn:aws:s3:::example-tfstate""]}]},""bucketName"":""example-tfstate"",""Host"":""example-tfstate.s3.ap-northeast-1.amazonaws.com"",""policy"":""""}" このログはDatadogへの参照権限付与という内容ですが、パッと見て判断するのは困難です。 そこで、Amazon Bedrockを用いて以下のように自然言語で要約しています。 💡 変更概要: S3バケットに対するポリシー更新操作が検知されました。 example-tfstate バケットのポリシーが変更され、外部AWSアカウントに対して s3:GetObject と s3:ListBucket の権限が付与されました。これは、Datadogからの読み取りアクセスを許可するための変更と推測されます。 📋 検知された操作: ・S3バケット example-tfstate のポリシー更新(Datadogからの読み取りアクセス許可) (16:57) このように要約された内容を通知することで、操作者が「自分のどの作業か」を瞬時に判断できるようになります。 回答 この監査の仕組みによって検知される操作の多くは「デプロイ」や「障害対応」などの定型作業です。そのため、ボタン選択式で回答できるようにし、操作者の記述負荷を最小限に抑えています。 定型作業の場合 : ボタンをクリックするだけで回答が完了します。 イレギュラーな操作の場合 : 「その他」を選択した場合のみモーダルが開き、自由記述と関連URL(チケット等)を入力します。 回答が完了すると、元のSlackメッセージは「回答済み」のステータスに更新されます。 回答内容はSREチームのチャンネルへ転送・DBに記録されます。 リマインド 回答がないまま翌営業日を迎えた場合、該当メッセージのスレッドに対して自動でリマインドを投稿します。3回リマインドを行っても回答が得られない場合は、SREチームへのエスカレーション通知に切り替わります。 アーキテクチャ 3つのLambda関数がそれぞれ独立した責務を持ち、DynamoDBを介して状態を共有するイベント駆動のアーキテクチャです。 コンポーネント一覧 コンポーネント 種別 説明 notify-audit-report Lambda Athena結果をBedrockで要約しSlack通知 audit-response-handler Lambda Function URL Slackインタラクティビティの処理 send-audit-reminder Lambda 未回答者への自動リマインド audit_attestations DynamoDB 回答状態の管理,証跡の蓄積 Amazon Bedrock (Nova Pro) LLM 操作ログの自然言語要約 各コンポーネントの実装の詳細は後述します。 Slack Appの設定 本システムのSlack Appに必要なOAuth Scopesは最小限です。 Scope 用途 chat:write メッセージ投稿・更新 reactions:write リアクション追加 users:read.email メールアドレスからユーザーID検索 users:read users:read.email の前提として必要 認証情報(Bot Token / Signing Secret)はSSM Parameter Store(SecureString)に保管し、Lambda実行時に取得します。 Slack AppのInteractivityのRequest URLには、Lambda Function URLを指定します。 各機能の実装 通知(notify-audit-report) 操作ログの要約生成からSlack通知までの処理について解説します。 処理フロー EventBridgeでAthenaクエリの完了を検知し、Lambdaを起動 S3からAthenaの結果CSVを取得 ユーザー(メールアドレス)ごとにグルーピング Bedrockで操作ログを自然言語に要約 操作者にメンション付きでSlack通知を投稿 DynamoDBにレコードを保存(リマインド・回答追跡用) EventBridgeのイベントパターンでAthenaのワークグループごとにクエリ完了を検知し、 input_transformer を用いて必要なメタデータ(S3パスや監査種別)をLambdaへ渡しています。 resource "aws_cloudwatch_event_rule" "a2rm_notify_audit_report" { event_pattern = jsonencode ( { "source" : [ "aws.athena" ] , "detail-type" : [ "Athena Query State Change" ] , "detail" : { "workgroupName" : [ "manual-modification-report" ] , "currentState" : [ "SUCCEEDED" ] } } ) } resource "aws_cloudwatch_event_target" "a2rm_notify_audit_report" { input_transformer { input_paths = { queryExecutionId = "$.detail.queryExecutionId" } input_template = <<-EOT { "s3_bucket": "athena-results-bucket", "s3_key": "cloudtrail/manual_modification/<queryExecutionId>.csv", "audit_type": "manual_operation" } EOT } } CloudTrailログからSlackメンションへの変換 MNTSQではAWSへのログインに IAM Identity Center を利用しています。これにより、CloudTrailの userIdentity 内にユーザーのメールアドレスが記録されるようになっています。 { " type ": " AssumedRole ", " principalid ": " AROA...:user@example.com ", " arn ": " arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_Administrator_.../user@example.com ", " sessioncontext ": { " sessionissuer ": { " type ": " Role ", " arn ": " arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/.../AWSReservedSSO_Administrator_... " } } } principalid や arn にメールアドレスが含まれるため、Athenaクエリでこれを抽出します。Slack APIの users.lookupByEmail を用いてメールアドレスをSlack User IDに変換し、メンション文字列を生成しています。 def lookup_user_by_email (slack: WebClient, email: str ) -> str | None : try : response = slack.users_lookupByEmail(email=email) return response[ "user" ][ "id" ] except SlackApiError as e: logger.warning( "Slackユーザーの検索に失敗しました: %s: %s" , email, e.response[ "error" ]) return None Bedrock周り 操作ログの要約にはAmazon Bedrockの Amazon Nova Pro を使用しています。 このモデルを選定した理由は以下のとおりです。 Bedrockで使える他のモデルと比較して安価 検証段階で出力品質に問題がなかった 完璧な要約は求めておらず、操作者が内容を思い出せる程度の内容で十分だった Slack mrkdwn記法での構造化出力が安定していた Bedrockクライアントの実装はシンプルです。 bedrock_client = boto3.client( "bedrock-runtime" , region_name= "us-east-1" ) def generate_summary (prompt: str , model_id: str ) -> str : response = bedrock_client.invoke_model( modelId=model_id, body=json.dumps({ "messages" : [{ "role" : "user" , "content" : [{ "text" : prompt}]}], "inferenceConfig" : { "maxTokens" : 2048 , "temperature" : 0.3 , # 一貫性のある出力のため低めに設定 }, }), ) response_body = json.loads(response[ "body" ].read()) return response_body[ "output" ][ "message" ][ "content" ][ 0 ][ "text" ] temperature はLLMの出力のランダム性を制御するパラメータで、値が低いほど決定的な出力になります。監査レポートという性質上、同じ入力に対してできるだけ一貫した出力を得たいため、 0.3 と低めに設定しています。 実際に使用しているプロンプトの全文を以下に掲載します。 {csv_data} にAthenaの結果CSVが埋め込まれます。 あなたはAWS操作の監査レポートを生成するアシスタントです。 以下のCSVデータはAWS環境で検知された操作ログです。このデータを分析し、指定されたフォーマットでレポートを生成してください。 ## CSVデータ {csv_data} ## 出力フォーマット 以下のフォーマットで出力してください。Slackのmrkdwn記法を使用してください。 🗓 *最終検知日時:* YYYY-MM-DD HH:MM (JST) 💡 *変更概要:* (リソースのカテゴリごとに、何が行われ、どのような状態になったかを目的の推測を含めて2〜3行ずつ簡潔に要約してください。複数が混在する場合は箇条書きを使用してください) 📋 *検知された操作:* ・操作の説明 (HH:MM) ・操作の説明 (HH:MM) ... ## ルール - 変更概要は「複数のリソースが更新されました」といった抽象的な表現を避け、サービス単位(例:ECS関連、RDS関連、ACM関連など)でそれぞれの具体的な変更内容と目的を記述してください。 - eventtimeはUTCなのでJST(+9時間)に変換してください - 最終検知日時は最も新しいeventtimeをJSTで表示してください - 検知された操作は各行のeventNameとrequestParametersから人間にわかりやすい日本語の操作説明を生成してください - 操作説明にはリソース名やサービス名を含めてください - 同一のeventNameが多数ある場合は代表的なものをまとめて表示してください(例:「ECSサービスの更新 x5件」) - 最大20件まで表示し、それ以上は「…他N件」としてください ## 出力例(入力データとは無関係のサンプル) 入力: "environment","eventtime","email","eventName","resource","requestParameters" "production","2026-01-20T08:18:00Z","user@example.com","UpdateService",,"{{""cluster"":""prod-cluster"",""service"":""web-api"",""desiredCount"":3}}" "production","2026-01-20T08:15:00Z","user@example.com","UpdateService",,"{{""cluster"":""prod-cluster"",""service"":""worker"",""desiredCount"":0}}" 出力: 🗓 *最終検知日時:* 2026-01-20 17:18 (JST) 💡 *変更概要:* production環境のECSサービスに対する更新操作が検知されました。web-apiサービスのインスタンス数を3台に増加し、workerサービスを停止(0台)にする変更が行われており、デプロイまたはメンテナンス作業の一環と推測されます。 📋 *検知された操作:* ・ECSサービス web-api の更新(台数: 3) (17:18) ・ECSサービス worker の更新(台数: 0) (17:15) --- 上記のフォーマットに従い、レポートを出力してください。出力のみを返し、余計な説明や前置きは不要です。 いくつか工夫したポイントを記載します。 出力フォーマットをSlack mrkdwn記法で指定 : LLMの出力をそのままSlack Block Kitの mrkdwn テキストとして使えるようにしています。 抽象的な表現を禁止するルール : 指示しないと「複数のリソースが更新されました」のような無意味な要約を返すことがあったため、明示的に禁止しています 入出力例の提示 : Few-shot的に具体例を与えることで出力フォーマットの安定性が向上しました レコード数の上限 : 最大50件のCSV行をプロンプトに含め(LLM出力は20件まで表示)、超過分はその旨を注記します。レコード数が多すぎるとトークン制限に達する恐れがあり、要約の精度が低下するためです LLMの呼び出しに失敗した場合や正しくSlack mrkdwn記法で出力されなかった場合でも、通知自体は止めず「詳細ログを確認してください」というFallbackテキストを送信するように実装していますが、現時点では発生していません。 回答ボタンの生成 上述した通り、定型的な理由はボタン1クリックで回答できるようにし、説明が必要な例外的な操作だけモーダルで自由記述してもらう設計にしています。 以下のように監査種別ごとに回答ボタンを出し分けています。 AWS手動操作 : デプロイ、定型作業、障害対応。 S3/Redash操作 : 上記に加え「顧客問い合わせ対応」「顧客データ分析」などを追加。 BUTTONS_MANUAL_OPERATION = [ { "action_id" : "deploy" , "label" : "デプロイ・リリース" , "emoji" : "🚀" }, { "action_id" : "routine_work" , "label" : "定型作業" , "emoji" : "💫" }, { "action_id" : "incident_response" , "label" : "障害対応" , "emoji" : "🚨" }, { "action_id" : "other" , "label" : "その他(記述する)" , "emoji" : "📝" }, ] BUTTONS_S3_EVENT = [*_BASE_BUTTONS, _BUTTON_CUSTOMER_INQUIRY, _BUTTON_OTHER] BUTTONS_REDASH = [ _BUTTON_DEPLOY, _BUTTON_CUSTOMER_INQUIRY, _BUTTON_CUSTOMER_ANALYSIS, _BUTTON_INCIDENT_RESPONSE, _BUTTON_OTHER, ] ボタンは Slack Block Kit の actions ブロックとして構築します。 button_elements = [ { "type" : "button" , "text" : { "type" : "plain_text" , "text" : f "{btn['emoji']} {btn['label']}" }, "action_id" : btn[ "action_id" ], "value" : btn[ "action_id" ], } for btn in button_defs ] action_id が後続の回答処理でどのボタンが押されたかを判定するキーになります。 DynamoDBへの保存 通知投稿後、リマインドと回答追跡のためにDynamoDBにレコードを保存します。将来的な監査証跡としての活用も見据え、検知内容の要約も含めて保存しています。 属性 書き込みタイミング 説明 message_ts (PK) 通知時 Slackメッセージのタイムスタンプ channel_id 通知時 投稿先チャンネル slack_user_id 通知時 操作者のSlack User ID email 通知時 操作者のメールアドレス status 通知時 → 回答時に更新 pending → completed created_at 通知時 レコード作成日時 audit_type 通知時 監査種別( manual_operation 等) detection_summary 通知時 LLMの要約 or テンプレートテキスト(JSON) s3_result_url 通知時 Athena結果CSVのS3パス responded_at 回答時 回答日時 response_reason 回答時 選択された理由( deploy 等) response_content 回答時 回答内容の詳細(JSON) remind_count リマインド時に更新 リマインド回数 table.put_item( Item={ "message_ts" : message_ts, # PK: Slackメッセージのタイムスタンプ "channel_id" : channel_id, "slack_user_id" : slack_user_id, "email" : email, "status" : "pending" , "created_at" : now.isoformat(), "remind_count" : 0 , "s3_result_url" : s3_log_url, "audit_type" : audit_type, "detection_summary" : detection_summary, # LLMの要約 or テンプレートテキスト } ) パーティションキーにSlackの message_ts を使っています。Slackのメッセージタイムスタンプはチャンネル内で一意であり、スレッド返信やメッセージ更新の際にもこの値で元メッセージを特定できるためです。 ※ message_ts単体のPKなのは、本Botは単一の監査チャンネルに投稿する設計のためです。 resource "aws_dynamodb_table" "a2rm_audit_attestations" { billing_mode = "PAY_PER_REQUEST" hash_key = "message_ts" global_secondary_index { name = "status-created_at-index" hash_key = "status" range_key = "created_at" projection_type = "ALL" } global_secondary_index { name = "email-created_at-index" hash_key = "email" range_key = "created_at" projection_type = "ALL" } } status と created_at にGSI(グローバルセカンダリインデックス)を張ることで、リマインド対象となる「未回答(pending)」かつ「一定期間経過」したレコードを効率的に抽出できるようにしています。 また、将来的にユーザーごとの監査履歴を参照できるように email と created_at にもGSIを張っています。 回答受け付け(audit-response-handler) 操作者がSlack上でボタンを押下した際のインタラクション処理について解説します。 Slack Boltとlazy listener Slackのインタラクティブイベント(ボタン押下やモーダル送信)を処理するには、SlackからのHTTPリクエストを 3秒以内にACK する必要があります。しかし、DynamoDB更新やSlack APIの複数呼び出しを含む一連の処理は、3秒を超過する可能性があります。 この問題を Slack Bolt for Python の lazy listener パターンで解決しています。 app.action( "deploy" )(ack=ack_action, lazy=[process_standard_button]) app.action( "other" )(ack=ack_action, lazy=[process_other_button]) app.view( "audit_reason_other" )(ack=ack_view, lazy=[process_modal_submission]) ack 関数が即座に200レスポンスを返し、 lazy に指定した関数は 自身のLambdaを非同期で再invoke して別のLambda実行コンテキストで処理されます。この仕組みを実現するため、LambdaのIAMポリシーには自身へのInvoke権限を付与しています。 # lazy listener が自身を非同期呼び出しするために必要 statement { effect = "Allow" actions = [ "lambda:InvokeFunction" , "lambda:GetFunction" ] resources = [ "arn:aws:lambda:<略>:function:audit-response-handler" ] } Lambda Function URL の採用 回答処理のエンドポイントには Lambda Function URL を採用しました。 Interactivity Webhookの認証は、Slackがリクエストヘッダーに付与する署名の検証(Signing Secretによる検証)によって行われるため、API Gateway等の前段を置かずにFunction URLのみで十分であると判断しました。 回答処理の流れ ボタンが押された後の処理は以下の順で行います。 二重処理防止 : Slackのリトライ仕様を考慮し、処理の冒頭で、DynamoDBのstatusを確認し、すでに completed なら処理をスキップします。 元メッセージを更新 : 操作者が連続してボタンを押せないよう、 chat.update で元メッセージのボタンを「回答済み」のテキスト表示に差し替えます。 SREチャンネルに回答内容を転送 : 監査結果の透明性を確保するため、回答は公開チャンネルに流します ステータスの更新: すべての処理が成功した段階で、DynamoDBの status を completed に更新します。 def complete_attestation (client, message_ts, channel_id, user_id, action_id, label, original_blocks, detail_text= None ): # 1. 二重処理防止 item = table.get_item(Key={ "message_ts" : message_ts}).get( "Item" ) if item and item.get( "status" ) == "completed" : return # 2. 元メッセージを更新(ボタン → 回答済み表示) completed_blocks = build_completed_blocks(original_blocks, user_id, label, detail_text) client.chat_update(channel=channel_id, ts=message_ts, blocks=completed_blocks) # 3. SREチャンネルに回答内容を転送 forward_blocks = build_forward_blocks(user_id, label, original_blocks, channel_id, message_ts, detail_text) client.chat_postMessage(channel=FORWARD_CHANNEL_ID, blocks=forward_blocks) # 4. DynamoDB更新(回答内容も証跡として保存) response_content = json.dumps( { "reason" : action_id, "detail_text" : detail_text}, ensure_ascii= False , ) table.update_item( Key={ "message_ts" : message_ts}, UpdateExpression= "SET #status = :s, responded_at = :r, response_reason = :reason, response_content = :rc" , ExpressionAttributeNames={ "#status" : "status" }, ExpressionAttributeValues={ ":s" : "completed" , ":r" : datetime.now(timezone.utc).isoformat(), ":reason" : action_id, ":rc" : response_content, }, ) リマインド(send-audit-reminder) 未回答の操作者に対し、自動で再通知およびエスカレーションを行う仕組みについて解説します。 処理フロー トリガー : EventBridgeにより、平日の午前10:00(JST)にLambdaが起動。 対象の抽出 : DynamoDBのGSIを用いて、前日以前に作成された未回答レコード( status=pending )をクエリ。 リマインド投稿 : 該当するSlackメッセージのスレッドに対してリマインドを投稿。 カウンタ更新 : DynamoDBの remind_count をインクリメント。 エスカレーション : リマインド回数が規定値(3回)を超えた場合、SREチームへ通知。 リマインド対象の特定 リマインド対象のレコードは status-created_at-index GSIで取得します。 paginator = dynamodb_client.get_paginator( "query" ) pages = paginator.paginate( TableName=dynamodb_table_name, IndexName= "status-created_at-index" , KeyConditionExpression= "#s = :status AND created_at < :cutoff" , ExpressionAttributeNames={ "#s" : "status" }, ExpressionAttributeValues={ ":status" : { "S" : "pending" }, ":cutoff" : { "S" : cutoff}, }, ) ここで cutoff は「当日の0:00(JST)」を基準としています。これにより、当日検知されたばかりのレコード(まだ回答の猶予があるもの)をリマインド対象から除外しています。 エスカレーション 3回リマインドしても回答がない場合は、SREチームへのエスカレーションに切り替えます。 def _process_item (item, slack, channel_id, sre_group_id, table): remind_count = int (item.get( "remind_count" , 0 )) if remind_count >= 3 : text = _build_escalation_text(slack_user_id, sre_group_id) else : text = _build_reminder_text(slack_user_id) slack.chat_postMessage(channel=channel_id, thread_ts=message_ts, text=text) 導入効果 コスト面 全体をサーバーレスアーキテクチャで構成しているため、月額の運用コストは1,000円以下に抑えられています。 Lambda、DynamoDB、Amazon Bedrockはいずれも従量課金であり、週数回という実行頻度ではほとんどコストが発生しません。LLMに安価な Amazon Nova Pro を選定したことも低コスト化に寄与しています。 月額1,000円以下の運用コストで年間約1人月分に相当する工数を削減することができました。 機能・運用面 導入前後で、監査確認フローは以下のように改善されました。 項目 導入前(Before) 導入後(After) 確認頻度 週次(手動) 日次(自動) 回答方法 Slackでの自由記述 ボタン1クリック / モーダル入力 リマインド 手動でフォローアップ 自動リマインド+エスカレーション 証跡管理 なし DynamoDBに保存 属人化 担当者の手作業に依存 Botによる標準化 セキュリティ面・ガバナンス面 検知内容、回答内容がDynamoDBに構造化された状態で蓄積されるため、そのまま監査証跡として活用可能な状態になりました。 ISO27001(ISMS)などのセキュリティ認証では、イベントログの定期的なレビューや、重要な変更の特定・記録・通知といった管理策が求められます。 今回のシステム化により、「検知 → 確認 → 記録」の一連のプロセスが自動化・標準化されました。仕組みとして漏れのない運用が行われていることを客観的に示せるようになったことは、今後のセキュリティ認証の維持・取得に向けても良い影響があるのではないかと考えています。 最後に 本記事では、Slack Botを活用したセキュリティ監査運用の自動化事例について紹介しました。 セキュリティ監査運用は非常に重要ですが、検知後の確認フローをいかに効率化し、形骸化させずに継続するかという点についてはまだ多くの組織で模索が続いている部分だと感じています。 今回、サーバーレスアーキテクチャとLLMを組み合わせることで、低コストかつ運用負荷の低い形でこの課題を解決することができました。 本記事の内容が、同様の課題を抱えている方や、セキュリティ運用の自動化を検討されている方々にとって、参考になれば幸いです。
MNTSQ プラットフォーム部の藤原です。 本記事では、PythonとLibreOfficeを組み合わせたオフィスファイルのpdf変換について解説します。 LibreOffice はオープンソースのオフィススイートです。 Microsoft Officeで作成した各種ファイル(docxや、xslx、pptx)を読み込み、編集できます。 LibreOfficeにはこれらファイルをpdfでエクスポートする機能も存在しています。 GUIからの実行はもちろんCLIでも実行可能です。 soffice --headless --convert-to pdf ファイル名.docx LibreOfficeを導入済みの場合はこのようなコマンド 1 を実行することで、docxなどをpdfに変換できます。 さて、 soffice コマンドでdocxファイルなどをpdfなどで変換可能なことはここで示せました。 ウェブアプリケーションなどでオフィスファイルをpdf変換する場合には、内部で soffice コマンドを呼び出す形で変換できそうです。 ただし、この方式では処理のたびにプロセスを立ち上げることになります。 大量のファイルを効率的に変換しようと考えると都度プロセスを立ち上げることは非効率です。 そのための対策として、LibreOfficeのプロセスを立ち上げた状態で外部プログラムからLibreOfficeの機能を利用するための仕組みが提供されています。 それが、UNO(Unified Network Objects)です。 UNO(Unified Network Objects)について UNO(Unified Network Objects)とは、LibreOfficeの内部機能に外部プログラムからアクセスするためのコンポーネントです。 UNOは言語非依存でさまざまなプログラミング言語から利用可能です。 UNOへのアクセス方法としてはインプロセス方式とソケット接続方式があります。インプロセス方式はLibreOfficeマクロからLibreOfficeの操作をするためのものです。ソケット接続方式は外部プロセス(=外部プログラム)からTCPを使って接続してLibreOfficeの各種操作をするための仕組みです。 次のようにすることでTCP接続を介してUNOを利用できます。 soffice --headless \ --accept="socket,host=localhost,port=2002;urp;"\ --norestore UNOを直接操作するためのPythonパッケージとしては、 PyUNO が存在しますが、オフィスファイルをpdfに変換する目的としては、too muchと言えそうです。 本記事ではUNOそのものの説明については、このようにすると指定したTCPポートでLISTENさせることが可能である旨に留めておきます。 以降は、UNOを使ってLibreOfficeの機能を簡単に利用するための仕組みとしてunoserverを紹介します。 unoserverについて unoconv/unoserver は、XML-RPC経由でUNOの仕組みを利用できるようにするための、Pythonパッケージです。 コンテナで動かす場合の動作イメージとしては以下のようになっています。 unoserverの動作イメージ コンテナ内ではunoserverとheadlessなLibreOfficeを常時立ち上げて処理を待ち受けています 2 。 unoserverを簡便に利用するため、また、macOS等でも利用する際の参考としてコンテナイメージおよび、docker-compose.ymlを準備しました。 コードの全体像は Fufuhu/docker-unoserver にて公開しています。 以下のようにコマンドを実行してください。 docker run -d -p 2003:2003 fufuhu/unoserver コードからビルドして利用する場合は以下の通り実行してください。 git clone git@github.com:Fufuhu/docker-unoserver.git # dockerコマンドで利用する場合 docker build -t docker-unorsever . docker run -d -p 2003:2003 docker-unoserver # docker composeコマンドで利用する場合 docker compose up --build -d 動作確認としてPythonを使ってXML-RPCのリクエストを投げてみます。 python -c " import xmlrpc.client; import json; print(json.dumps(xmlrpc.client.ServerProxy('http://localhost:2003').info()))" \ | jq { "unoserver": "3.6", "api": "3", "import_filters": { "HTML": "HTML (StarWriter)", 〜〜中略〜〜 "impress_svg_Export": "impress_svg_Export", "impress_tif_Export": "impress_tif_Export", "impress_webp_Export": "impress_webp_Export", "impress_wmf_Export": "impress_wmf_Export" } } このようにして、汎用のXML-RPCクライアントを使ってunoserverを利用することが可能です。 ただし、unoserverの個別の機能を利用するには汎用のクライアントでは少々面倒です。 そこで、unoserverの提供するUnoClientを使った実装例を提示します。 UnoClientのサンプル UnoClientはunoserverパッケージに含まれているunoserver向けのXML-RPCクライアントやバイナリデータの送受信などをラッピングした高レベルクライアントです。 Fufuhu/docker-unoserver-client-sample にサンプルのコードを作成しました。 このリポジトリのコードを git clone して docker compose up --build -d でUNOサーバーとサンプルとなるウェブアプリケーションが立ち上がります 3 。 すこし本題に入るまでが長くなりますが、クライアントウェブアプリケーションのコードを眺めてみましょう。 main.py の抜粋を見てみましょう。 from fastapi import FastAPI, Request, UploadFile from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from unoserver.client import UnoClient app = FastAPI() templates = Jinja2Templates(directory=Path(__file__).parent / "templates" ) UNOSERVER_HOST = os.getenv( "UNOSERVER_HOST" , "localhost" ) UNOSERVER_PORT = os.getenv( "UNOSERVER_PORT" , "2003" ) FastAPIを使ったアプリケーションサーバーが立ち上がります。 UNOサーバーのホスト名と待受ポートを環境変数で指定できます。 また、テンプレートエンジンであるJinja2向けのテンプレートを格納しているディレクトリとして templates ディレクトリを指定しています。 docker-compose.ymlの該当部分を抜粋すると次のようになっています。 services : unoserver : image : fufuhu/unoserver ports : - "2003:2003" app : build : . ports : - "8000:8000" depends_on : - unoserver environment : - UNOSERVER_HOST=unoserver - UNOSERVER_PORT=2003 次に main.py のindex関数を見てみましょう。 / にアクセスすると、 index.html ファイルをレンダリングして返すようになっています。 @ app.get ( "/" , response_class=HTMLResponse) async def index (request: Request): return templates.TemplateResponse(request, "index.html" ) index.htmlのbodyタグ以下の抜粋としては以下のとおりです 4 。 < body > < h1 > PDF変換ツール </ h1 > < form method = "post" action = "/convert" enctype = "multipart/form-data" > < p > 変換したいファイルを選択してください </ p > < input type = "file" name = "file" required >< br > < button type = "submit" > 変換 </ button > </ form > </ body > ファイルを選択して変換ボタンをクリックすると /convert にファイルがPOSTされる形となっています。 ブラウザ上で http://localhost:8000 にアクセスすると次のような表示になります。 インデックス画面イメージ このフォーム内でファイルを指定して変換ボタンをクリックすると、 /convert にファイルがPOSTされる形となっています。 次に main.py の /convert に対応するコードを見てみましょう。 @ app.post ( "/convert" ) async def convert ( file : UploadFile): # アップロードされたファイルの内容をバイト列として読み込む indata = await file .read() # 元のファイル名から拡張子を除いた部分を取得し、ダウンロード用のPDFファイル名を生成する stem = Path( file .filename).stem if file .filename else "output" out_filename = f "{stem}.pdf" # unoserverに接続するクライアントを作成する(XMLRPC経由で通信) client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) # ファイルのバイト列をunoserverに送信し、PDF形式に変換する # 変換結果はPDFのバイト列として返される result = client.convert(indata=indata, convert_to= "pdf" ) # 変換されたPDFをレスポンスとして返す # Content-Dispositionヘッダーにより、ブラウザがファイルを直接ダウンロードする return Response( content=result, media_type= "application/pdf" , headers={ "Content-Disposition" : f 'attachment; filename="{out_filename}"' }, ) 内容としてはコード中のコメントに記載の通りです。 リクエストからアップロードされたファイルのバイト列を取得して、UnoClientを使ってpdf変換を実現しています。 ポイントは、 UnoClient です。 UnoClient を利用することで、XML-RPCなどの処理を隠蔽して簡潔に記述できています。 実際の変換処理部分としては以下の2行のみで実現できます。 client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) result = client.convert(indata=indata, convert_to= "pdf" ) ここまでで、Python(unoserver, UnoClient)とLibreOfficeを使ったオフィスファイルのpdf変換について示しました。 今回提示のサンプルでは、リクエストを受けてその場で変換処理を実行して変換後のpdfファイルを返しています。 巨大なファイルを処理する場合、UXなどを考慮すると好ましい実装としてはこの限りではない点には注意した方が良いでしょう 5 。 まとめ LibreOfficeのGUI/CLIを使ってオフィスファイル(docx, xslx, pptxなど)をpdfに変換できる 大量のファイルを変換する場合はLibreOfficeのプロセスを常駐させ、UNO(Unified Network Objects)を使って変換する方が効率的である UNOを容易に利用するための仕組みとして unoconv/unoserver が提供されている unoserverは言語非依存なXML-RPCを使ったファイルのpdf変換を提供している。ただし、Pythonの場合はunoserverパッケージの提供するUnoClientがあり、容易にpdf変換を実現できる 参考文献 https://ja.wikipedia.org/wiki/XML-RPC https://github.com/unoconv/unoserver https://docs.libreoffice.org/pyuno.html https://hub.docker.com/r/fufuhu/unoserver https://github.com/unoconv/unoserver https://github.com/Fufuhu/docker-unoserver https://github.com/Fufuhu/docker-unoserver-client-sample コマンド名が soffice となっているのはLibreOfficeの歴史的経緯に起因するものです。基本的にはlibreofficeコマンドでもaliasが貼られていることがほとんどであり、同等の動作が可能なはずです ↩ コンテナ内部でunoserverのプロセスと、LibreOfficeのプロセス両方を管理するための仕組みとしてtiniを導入しています。 ↩ Fufuhu/docker-unoserverから立ち上げたdockerコンテナやdocker composeとポート競合が発生するので、あらかじめ停止した上で実行してください。 ↩ CSS指定部分などは今回はメインではないので例示からは除外しています。 ↩ レスポンスで変換済みファイルを即時返却するのではなく、裏側でイベント駆動で処理したのちに変換処理完了通知とダウンロードリンクを送るなどが考えられます ↩
はじめに 弊社では LLM を活用した機能開発の観測基盤として Langfuse をセルフホストで運用しています。Langfuse は LLM アプリケーションのトレーシングやプロンプト管理等に活用できるオープンソースの LLM エンジニアリングプラットフォームです。 さてこの種のサービスには認証の課題がつきまといます。Langfuse は標準でメールアドレス / パスワードによるログインが可能ですが、弊社の方針として開発組織内で利用、管理しているアプリケーションやサービス群には IAM Identity Center を IdP とする SSO を採用しています。 Redash や SendGrid といったサービスで既にこの方式を採用しており、Langfuse でも同様に IAM Identity Center 経由の SSO ログインを実現したいと考えました。 ところが Langfuse は IAM Identity Center を IdP として直接サポートしていません。Langfuse の認証は NextAuth.js ベースであり、対応する認証方式は OAuth / OIDC が中心です *1 。では IAM Identity Center の OIDC 機能をそのまま使えるかというと、そう単純ではありません。IAM Identity Center が OIDC を提供するにはアプリケーション側が trusted token issuer に対応している必要があり、Langfuse はこれに該当しないため直接利用できません。 Cognito と Langfuse の SSO 連携については 既に優れた先例 がありますが、ここに IAM Identity Center を加えた構成についての情報はあまり無いようです。ニッチな話題かもしれませんが、同様の課題を抱えている方もいるのではと考え、本稿にて事例を紹介できればと思います。 構成 前述の事情を踏まえ、間に Amazon Cognito を挟む構成としました。Cognito は SAML によって IdP からの認証情報を受け取り、これを OIDC にてアプリケーション(今回の場合は Langfuse)に提供する役目を担います。IAM Identity Center は SAML による外部アプリケーション(今回の場合は Cognito)のフェデレーションをサポートしているので、これを組み合わせると以下のような構成が実現できます。 IAM Identity Center --- (SAML) ---> Cognito --- (OIDC) ---> Langfuse 認証フローを時系列で描くと以下のようになります。 sequenceDiagram autonumber participant User as ユーザー (Browser) participant LF as Langfuse participant Cog as Cognito participant IDC as IAM Identity Center User->>LF: SSO ログインボタンをクリック LF->>User: Cognito のログイン URL へリダイレクト User->>Cog: 認証リクエスト (OIDC) Cog->>User: IAM Identity Center へリダイレクト User->>IDC: SAML 認証リクエスト IDC->>User: ログイン画面の表示 / 認証の実施 IDC->>User: SAML Assertion を発行 User->>Cog: SAML Assertion をポスト (ACS URL) Cog->>Cog: SAML Assertion の検証 / ユーザー情報の処理 Cog->>User: Langfuse へリダイレクト (認可コード) User->>LF: 認可コードを送信 LF->>Cog: トークン交換リクエスト Cog->>LF: ID トークン / アクセストークン (OIDC) LF->>User: ログイン完了 要するに Cognito が SAML -- OIDC 間の橋渡し役を担う格好です。ユーザー視点では Langfuse のログイン画面で SSO ボタンを押すと IAM Identity Center のログイン画面に遷移し、認証後 Langfuse に戻ってきます。 これを実現する構成が以下のようになります。本稿の趣旨は Langfuse 利用にかかる SSO ログイン化が主軸につき、Langfuse 本体の構成はかなり端折った図である点ご容赦ください *2 構成図。admin / langfuse / logging / security は AWS アカウント名の意 なお上図における logging / security 各アカウントは以下拙稿における member / security に対応するものです。主に SSO ログイン時の監査に使われる周辺要素であり、本稿では詳細を割愛します。 設定の手順 設定は大きく4つのステップに分かれます。 IAM Identity Center に SAML アプリケーションを登録する(admin アカウント) Cognito で SAML IdP と OIDC クライアントを構成する(langfuse アカウント) Langfuse に OIDC 関連の環境変数を設定する(langfuse アカウント) SSO ログインへの一本化(langfuse アカウント) 以下、各ステップの内容を解説します。 Step 1: IAM Identity Center への SAML アプリケーション登録 admin アカウントの IAM Identity Center に、Langfuse 用のカスタム SAML アプリケーション( customer-managed application )を Terraform で登録します *3 。 aws_ssoadmin_application リソースで application_provider_arn に custom-saml を指定し、IAM Identity Center のポータル上での可視性を有効にします。 あわせて aws_ssoadmin_application_assignment で SSO ログインを許可する対象を紐付けます。グループ単位での割り当ても可能ですが、弊社では職責や担当職務に応じて柔軟にアクセス範囲を制御したかったため、ユーザー単位での割り当てとしました *4 。 続いてマネジメントコンソールから Application metadata を設定します。これは Cognito が SAML Assertion を正しく受け取るために必要な設定です。 Application ACS URL : https://<Cognito ドメイン>.auth.<リージョン>.amazoncognito.com/saml2/idpresponse Application SAML audience : urn:amazon:cognito:sp:<Cognito User Pool ID> ACS URL は Cognito User Pool のドメイン名から、SAML audience は Cognito User Pool の ID からそれぞれ導出されます。Step 2 で Cognito User Pool を作成した後に設定する、という順序になります *5 。 同じくマネジメントコンソールから Attribute mappings を設定します。SAML Assertion に含めるユーザー属性と Cognito 側の属性との対応を定義するものです。 User attribute in the application Maps to this string value or user attribute in IAM Identity Center Format Subject(変更不可) ${user:email} persistent email ${user:email} basic email_verified true unspecified name ${user.name} unspecified email_verified を固定値 true としているのは弊社の運用上 IAM Identity Center で管理されているユーザーのメールアドレスは検証済みであると見なして差し支えないためです。これは以下の拙稿に詳細を譲りますが、弊社の IAM Identity Center は IdP として Entra ID を参照しており、Entra ID 上のユーザ情報を信頼できるケースに該当する為です。 最後に、マネジメントコンソールから SAML メタデータファイルをダウンロードします。 IAM Identity Center コンソールへ移動 Application assignments → Applications を選択 Customer managed タブへ移動 対象の SAML アプリケーションを選択 IAM Identity Center metadata 内の IAM Identity Center SAML metadata file からダウンロード このメタデータ XML ファイルは次のステップで Cognito に渡す必要があります。 Step 2: Cognito の構成 langfuse アカウント側では Cognito User Pool を作成し、IAM Identity Center を SAML IdP として登録した上で、Langfuse 向けの OIDC クライアントを構成します。 まず Step 1 でダウンロードした SAML メタデータ XML を S3 バケットに手動でアップロードし、Terraform からは data "aws_s3_object" で参照します *6 。S3 バケット自体も Terraform で作成し、サーバーサイド暗号化とパブリックアクセスブロックを設定しています。 続いて、この SAML メタデータを使って aws_iam_saml_provider と aws_cognito_identity_provider の2つを設定します。前者は IAM レベルでの SAML プロバイダ登録、後者は Cognito User Pool に対する SAML IdP の登録です。 aws_cognito_identity_provider では attribute_mapping で Step 1 の Attribute mappings との対応を指定します。 なお provider_details には ignore_changes を設定しています。Cognito は MetadataFile を解釈して内部的にいくつかの属性を展開するため、内容に変更がなくても terraform plan で差分が出続けます。これを抑制するための措置です。 次に aws_cognito_user_pool_client を設定します。これは Langfuse から見た OIDC クライアントに相当するリソースです。OAuth フローには Authorization Code Grant( code )を、スコープには openid / email / profile を指定します。 callback_urls には Langfuse の OIDC コールバック URL( https://<Langfuse のドメイン>/api/auth/callback/custom )を設定します。トークンの有効期間やクライアントシークレットの生成など、必要な設定を適宜入れます。 最後に、Cognito が払い出した Client ID / Client Secret / Issuer URL を aws_ssm_parameter (SecureString)で SSM パラメータストアに格納し、Langfuse の ECS タスク定義から参照できるようにします。 Step 3: Langfuse への環境変数設定 Langfuse は Custom OAuth Provider としての設定を環境変数で受け取ります。ECS タスク定義に以下の環境変数を追加しました。 平文で渡すものとして AUTH_CUSTOM_NAME 、 AUTH_CUSTOM_CHECKS 、 AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING の3つがあります。SSM パラメータストアから取得する秘密情報として AUTH_CUSTOM_CLIENT_ID 、 AUTH_CUSTOM_CLIENT_SECRET 、 AUTH_CUSTOM_ISSUER の3つがあり、これらは Step 2 で格納した値を参照します。 AUTH_CUSTOM_NAME は Langfuse のログイン画面に表示される SSO ボタンのラベルです。わかりやすい名称を選ぶと良いでしょう AUTH_CUSTOM_CHECKS には pkce を指定し PKCE (Proof Key for Code Exchange) を有効にしています AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING を true にすると、同一メールアドレスの既存アカウントと SSO アカウントが紐付けられます SSO 導入前のアカウントとの互換性のために有効にしています これが無いと後述のトラブルシュート時に Cognito 側で対応する手法が取れず Langfuse 側 DB を直接触ってアカウント管理をする手間が発生します Step 4: SSO ログインへの一本化 SSO ログインが安定稼動していることを確認した後、環境変数 AUTH_DISABLE_USERNAME_PASSWORD を true に設定してメールアドレス / パスワードによるログインを無効化しました。これにより Langfuse のログイン画面は SSO ボタンのみが表示される状態になります。アカウント管理を IAM Identity Center に一元化でき、Langfuse 側での個別のアカウント発行 / 削除が不要となります。 サンプルコード Step 1〜2 で使用した Terraform コードの抜粋を以下に示します。それぞれ collapsed になっていますので、クリックして中身を参照ください。 admin アカウント: IAM Identity Center への SAML アプリケーション登録 # Identity Store からユーザー情報を参照 data "aws_identitystore_user" "main" { for_each = toset (local.langfuse_users) identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] alternate_identifier { unique_attribute { attribute_path = "UserName" attribute_value = each.key } } } # カスタム SAML アプリケーションの登録 resource "aws_ssoadmin_application" "langfuse" { name = "Langfuse" application_provider_arn = "arn:aws:sso::aws:applicationProvider/custom-saml" instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] portal_options { visibility = "ENABLED" sign_in_options { origin = "IDENTITY_CENTER" } } } # SSO ログインを許可するユーザーの割り当て resource "aws_ssoadmin_application_assignment" "langfuse" { for_each = toset (local.langfuse_users) # 職責に応じてフィルタしたユーザーリスト application_arn = aws_ssoadmin_application.langfuse.arn principal_id = data.aws_identitystore_user.main [ each.key ] .id principal_type = "USER" } langfuse アカウント: Cognito の構成 # ----- SAML メタデータの管理 ----- # メタデータ XML を格納する S3 バケット(暗号化・パブリックアクセスブロックは別途設定) resource "aws_s3_bucket" "saml_metadata" { bucket = "mntsq-$ { var.env } -saml-metadata" } # IAM Identity Center からダウンロードしたメタデータ XML を参照 data "aws_s3_object" "saml_metadata" { bucket = aws_s3_bucket.saml_metadata.id key = "langfuse.xml" } # ----- SAML プロバイダ・Cognito User Pool ----- resource "aws_iam_saml_provider" "langfuse" { name = "langfuse" saml_metadata_document = data.aws_s3_object.saml_metadata.body } resource "aws_cognito_user_pool" "langfuse" { name = "langfuse" auto_verified_attributes = [ "email" ] } resource "aws_cognito_user_pool_domain" "langfuse" { user_pool_id = aws_cognito_user_pool.langfuse.id domain = "mntsq-$ { var.env } -langfuse" } # IAM Identity Center を SAML IdP として登録 resource "aws_cognito_identity_provider" "langfuse" { user_pool_id = aws_cognito_user_pool.langfuse.id provider_name = "langfuse" provider_type = "SAML" provider_details = { "MetadataFile" = data.aws_s3_object.saml_metadata.body } attribute_mapping = { email = "email" email_verified = "email_verified" name = "name" } lifecycle { # Cognito が MetadataFile を解釈して provider_details を展開するため、 # 毎回差分が出るのを抑制する ignore_changes = [ provider_details ] } } # ----- OIDC クライアント (User Pool Client) ----- resource "aws_cognito_user_pool_client" "langfuse" { name = "langfuse" user_pool_id = aws_cognito_user_pool.langfuse.id allowed_oauth_flows_user_pool_client = true allowed_oauth_scopes = [ "openid" , "email" , "profile" ] allowed_oauth_flows = [ "code" ] supported_identity_providers = [ aws_cognito_identity_provider.langfuse.provider_name ] access_token_validity = 8 id_token_validity = 8 refresh_token_validity = 1 token_validity_units { access_token = "hours" id_token = "hours" refresh_token = "days" } callback_urls = [ "https://<Langfuse のドメイン>/api/auth/callback/custom" ] default_redirect_uri = "https://<Langfuse のドメイン>/api/auth/callback/custom" generate_secret = true } # ----- 認証情報の SSM パラメータストア格納 ----- resource "aws_ssm_parameter" "auth_id" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_CLIENT_ID" type = "SecureString" value = aws_cognito_user_pool_client.langfuse.id } resource "aws_ssm_parameter" "auth_secret" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_CLIENT_SECRET" type = "SecureString" value = aws_cognito_user_pool_client.langfuse.client_secret } resource "aws_ssm_parameter" "auth_issuer" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_ISSUER" type = "SecureString" value = "https://cognito-idp.$ { data.aws_region.current.name } .amazonaws.com/$ { aws_cognito_user_pool.langfuse.id } " } トラブルシューティング 運用を開始して以降、SSO ログインが失敗するケースに遭遇しました。とくに初回ログイン時に顕著で、Cognito と Langfuse との間でユーザー情報のフェデレーションがうまくいかない場合に発生します。 リトライで解消するケースもありますが、それでも解決しない場合は Cognito 側のユーザーを一旦削除し、再度 Langfuse へ SSO ログインしてもらう ことで通るようになります。Cognito User Pool 内のユーザーは IAM Identity Center から federate されたものなので、削除しても次回の SSO ログイン時に再作成されます。 手順としては以下の通りです。 Cognito User Pool のマネジメントコンソールへ移動 User management → Users でログインに失敗しているユーザーを特定 対象ユーザーを選択し、まず Disable user access を実施 その後 Delete user を実行 ユーザー削除後に改めて SSO ログインを試みてもらえば、Cognito 側にユーザーが再作成されてログインが成功するはずです。 おわりに IAM Identity Center を IdP として Langfuse に SSO ログインを実現する構成について解説しました。ポイントは Cognito を SAML と OIDC との翻訳役として活用する 点です。 設定にあたっては Terraform とマネジメントコンソールの手作業が混在する点がやや煩雑です。とくに IAM Identity Center 側の Application metadata や Attribute mappings、SAML メタデータの取得はコンソール操作が必要であり、手順として残しておかないと再現が難しくなります。 Cognito + Langfuse の SSO 構成 については先例がありますが、IAM Identity Center を加えた構成についてはあまり情報が見当たりませんでした。このパターンは Langfuse に限らず、OIDC のみをサポートするアプリケーションに IAM Identity Center 経由の SSO を提供したい場合に広く応用できるものと考えています。同様の課題を抱えている方の参考になれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.6 が作成した内容を9割程度そのまま使用しています *1 : https://langfuse.com/self-hosting/security/authentication-and-sso を参照。Langfuse は Google / GitHub / Azure AD (Entra ID) / Okta / Auth0 / Custom OAuth Provider 等をサポートしています *2 : 興味のある方向け:ECS で ClickHouse 含めて Langfuse が必要とする諸要素を動作させ、DB は Aurora PostgreSQL、キャッシュ関連は ElastiCache にて Valkey クラスタを組んで動作させています *3 : 実際には全ての設定を Terraform でカバーできるわけではないので、適宜 AWS マネジメントコンソールからの設定作業も必要になります *4 : この種の柔軟さをグループ単位で表現できなかったという内部事情もあります *5 : 厳密には Cognito User Pool の作成 → IAM Identity Center の Application metadata 設定 → Cognito 側の残りの設定、という順序を踏む必要があります。Terraform で一括管理する場合は初回適用時にこの依存関係を意識する必要があります。要するに IAM Identity Center と Cognito とを行ったり来たりする必要が2026年2月時点で有ります *6 : SAML メタデータはコード管理の対象としていません。IAM Identity Center 側で生成されるものであり、かつ XML の内容が大きいため S3 経由での参照としました。メタデータの取得手順は Step 1 に記載の通りです
はじめに Datadog には Notebook というものがあります。以前から存在する機能であり、目新しいものではありません。 詳細は公式のドキュメントが詳しいのですが、Datadog で取り扱えるログやメトリックなどを埋め込める ドキュメンテーション ツールという捉え方で然程ズレは無いはずです。単にログやメトリックを追うという意味ではダッシュボード *1 も使え、 継続的な観測をする場合は ダッシュボードのほうが断然よいです。 いっぽうでこういった課題感もあると思います。すくなくとも私は常々あります。 あのメトリックとこのメトリックとは何らか関連性のある動きをするはずなんだが、パッと追いづらいぞ 監視やダッシュボードの設計をしたいのでメトリック状況を追いたいのだが、 メトリクスエクスプローラー ではいまいち状況がわからないぞ 複数のメトリックを束ねて計算したい *2 が、メトリクス エクスプローラ ーで頑張っていたら訳分からなくなってしまった *3 メトリックを見つつ考察や見解を添えておきたい。別途存在するドキュメント側に Datadog への誘導を設けるでもよいが、ひとつの場所で扱いたい こういった場合、ダッシュボードをつくるよりも Notebook を使うほうが手軽かつ確実な効果が得られます。 本項にて弊社で活用事例をあげつつ、Datadog Notebook は便利だよ、という声を微々たるものながら上げられればと思います。 事例1:Elasticsearch → OpenSearch への移行準備にかかる既存調査 弊社では現在検索基盤の更改を検討しており、現状利用している Elasticsearch から Amazon OpenSearch への移行を手段のひとつとして考えています。 この移行にあたり、移行先 OpenSearch ドメイン *4 のリソース見積もりをする必要があり、その材料を既存 Elasticsearch の状態から得ようと考えました。 実際に使った Elaseticsearch 状況確認用の Notebook(黒塗り部分が多い点ご容赦ください) 出すべき材料はおおむね以下のようになるはずです: Elasticsearch ノード台数 各 Elasticsearch ノードのリソース消費量 CPU RAM *5 ディスク 収容テナント数 収容シャード量 発生しているコスト これらメトリックは既に Datadog 上に蓄積されています。もちろんこれらを一瞥するようなダッシュボードを作ることも可能ですが、今回は 定点監視することを目的に確認したい訳ではない 並べた数字を眺めながら議論をすすめるケースが多々有った 既にあるドキュメントは詳細な考察と検討に使い、メトリックを並べた場所は純粋にメトリックから得られる所感だけを扱うようにしたい需要があった ということもあり、とりわけ 1. を主要な目的として Notebook へ、いわば「書き散らし」することにしました。 継続的な観察を目的とせず一時的な調査として Notebook という手段をもっておくことで、こういった書き散らしに数字的な意味を持たせることができます。かつ体裁を気にせず素早くまとめが仕上げられるので、議論と結論出しを迅速に行うことができました。 事例2:Datadog Cloud Cost と Unit Economics もうひとつの事例は、 Datadog Cloud Cost を活用した、一歩踏み込んだコスト分析での利用です。 実際に使った Unit Economics 検討用 Notebook (こちらも黒塗り部分が多い点ご容赦ください) 弊社では先般 Datadog の Cloud Cost を使い始めており、 AWS / Google Cloud / Azure 各コストの俯瞰的な観察ができるようになっています。以下拙稿も参照ください。 一方で Cloud Cost を折角導入した以上、コストをただ観察するのではなく、よりよい洞察を得たい気持ちもあります。前掲拙稿の通り Datadog メトリックとして各 クラウド ベンダのコスト情報を適切な分解能でもって Datadog 内で扱える という利点が Cloud Cost にはあり、各種メトリックとコスト情報とを掛け合わせたものが整備できそうです。つまり単に「いくらかかっているか」を眺めるだけでなく、その費用対効果を 定量 的に評価できる余地がありそうです。そこで Unit Economics の概念を取り入れ、 クラウド 利用にかかるコストの経済性を考察することにしました。 SaaS における Unit Economics といえば、一般的には LTV(Life Time Value;顧客生涯価値) CAC(Customer Acquisition Cost;顧客獲得コスト) の比率で語られます。しかし、エンジニアリングの側面からこれを捉え直すと 1テナントあたりの提供コスト (COGS) Cost Of Goods Sold;売上原価 アクティブユーザー(以下 Active User を略して AU と表記します)あたりのインフラ費用 といったものをいかに最適化し、粗利を最大化するかという議論に単 純化 できます。 こうした指標を追跡するなかで、特定の利用パターンにおいて Unit Economics の悪化が見られた場合、それはそのパターンを支えるインフラ的な アーキテクチャ やリソース配分に改善の余地がある、という仮説が立ちます。単なるコストの総額ではなく、ユニットあたりの効率を見ることで、エンジニアリングとして優先的に手を入れるべき箇所を特定しようという試みです。 この分析を行うには、 クラウド コストのデータに加えて、Datadog 上の既存メトリックやログから生成したメトリックを突き合わせる必要があります。この試行錯誤において、 Notebook が非常に役立ちました。 扱うデータの検討 まずは クラウド コストをテナントで割るか AU で割るかの検討が必要です。テナント単位で見るのが前述のとおり筋と当初は考えたのですが、両者の実態や傾向に差異がある都合、実際にはユーザーあたりで見た場合によりよい洞察が得られるケースもあり、最終的には両者を状況によって比較する(つまりテナント按分コストと AU 按分コストとを両方算出する)手法をとることになりました。 この試行を素早く繰り返す際に役立ったのが Notebook で、複数の計算結果を(再び)書き散らし、実態を最も反映していそうな指標が何かをグルグル考え回すことができました。 観察 コスト観察は長期間の観察が不可欠です。日単位や週単位ではあまり意味のある情報は得られません。定点観察にはダッシュボード化が適しますが、そもそも今組み立てているデータが有意義かどうかが判っていない段階でのダッシュボード化は早計でしょう。 まずは Notebook を サンドボックス として使い、少ない整備の手間で傾向観察をはかることが可能となりました。 考察 データが揃い、観察が出来るようになったのち、必要なのはその結果の考察です。これにも Notebook への「書き散らし」が便利でした。 データの定義はどういったものでこの結果は何を示すものと考えられるか等の情報がデータと同じ場所にメモでき、何を伝えればよいのだ等と思い悩む必要性が減り、思い切った検証ができるようにもなります。 また検討と訂正を繰り返していると訪れる「果たしてこれは何を意味するのだったか」と失念して困惑するという事態も、書き散らしによって避けることができます。 要するに いきなり「正解」のダッシュボードを作るのではなく、 Notebook 上でこうした泥臭い検証を重ねられたことが、最終的に精度の高い可視化への近道になりました。 なお最終的に Notebook の内容はダッシュボードとして新たに整備することになりました。有益さが判った結果ですが、最初からダッシュボードにしておけばよかったと思わないでもないです。 これは現時点(2026年1月)では Notebook からダッシュボードに移行できるような機能が Datadog には無いためで、 Notebook 上に整備した各種メトリックをダッシュボードに気合で移す作業が必要になりました。今となっては良い思い出です。 おわりに 今回の活用を通じて、以下のような嬉しさがありました。 Elasticsearch 移行 検証数値的な裏付けを持って机上での検討ができるようになり、移行計画に際し十分な情報を提示することができた Notebook に適宜情報を追加しながら仮説検証を繰り返すサイクルが回せたことで、手戻りの少ない検討が可能となった Unit Economics 分析 仮説検証が Notebook で素早く行え、これが有益かどうかの検討が手軽に行えるようになった まずは Notebook で「これで本当に分析できるか?」を確かめ、確信を持ってからダッシュボードとして正式に整備する、というフローを検証できた Datadog Notebook は、監視や o11y *6 といったメイン機能に比べると、正直なところ影が薄い印象があります *7 。しかし、実際に使ってみると「ダッシュボードを作るほど大掛かりではないが、メトリクス エクスプローラ ーよりは踏み込んだことがしたい」という場面で大変丁度よいツールです。 メトリクス エクスプローラ ーでは複数メトリックの取扱が勿論可能ですが、どうしてもその場限りの確認になりがちです。一方、 Notebook であれば複数のメトリックを時系列や比率で比較した「論理構造」をそのまま残しておけます。これはつまり グラフの側に「なぜこの数字を見たのか」というコンテキストを記録できる 一時的な調査結果を、そのままチーム内への共有レポートとして転用できる ダッシュボード化する前の「プロトタイプ」として活用できる このように、一種の「思考の跡地」としての価値が生まれるわけです。 ダッシュボード化する前の下書きとして、あるいは一時的な調査レポートとして。便利な手段のひとつとして Datadog Notebook があるという点、認知に貢献できたら幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : https://docs.datadoghq.com/dashboards/ *2 : 例: https://docs.datadoghq.com/monitors/guide/monitor-arithmetic-and-sparse-metrics/ *3 : 私的にこれが一番あります *4 : https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html の語法に則り「 ドメイン 」と呼びますが、データノードおよび専用マネージドノードの集合体という意味から「 クラスタ 」と同一視してよいはずです *5 : Elasticsearch であることから実際には JVM ヒープメモリ量などの状況も重要になります。今回このあたりを引っ括めて "RAM" と読んでいます *6 : observability;可観測性。本稿を読んでいる方には釈迦に説法感がありますが念の為 *7 : これまで在籍してきたいくつかの会社でわたしは Datadog Notebook を使ってきましたが、いざ共有などすると「こんな機能があったのか!!!」という反応に毎度なります
前回のあらすじ スキーマ 分離設計のDB(テナント毎に独立した スキーマ を持つDB)でサービス規模が拡大すると、 スキーマ 数の増加に由来するオーバーヘッドが無視できないものになる 次はパラメータチューニングなどで何とか延命できないか試してみたい tech.mntsq.co.jp はじめに 前回の 負荷試験 によって、弊社サービスは600テナントを超えたあたりから、データベースの急激な性能劣化を起こすリスクが高いことが判明しました。長期的には根本的な構成の見直しを行うとして、パラメータチューニングなどでデッドラインを後ろにずらせるのであれば、それはそれでありがたいです。 よって、今回の 負荷試験 の目的は、 チューニングによって アーキテクチャ 改善をどの程度後ろ倒しにできるかを検討することでした(過去形) 。 結論を申し上げると、弊社のケースではパラメータチューニングによって延命を図れる見込みは薄いことが判明しました。ただし、調査の過程で、前回の結果の解釈の誤りを発見したり、新たな条件で見えてきた スキーマ 分離の定性的な特性を発見したりと、それなりに実りのある結果が得られたので、再び報告させていただきたいと思います。 前回のあらすじ はじめに 追加試験の結果報告 真のボトルネックはwait/io/table/sql/handlerだった パラメータチューニングについて table_definition_cache & table_open_cache innodb_sync_array_size インスタンスサイズを大きくしてみる 一定高負荷下でのスキーマ数によるパフォーマンスの変化 150スキーマで性能が劣化する理由 1200スキーマで性能が劣化する理由 負荷試験を行う上で注意したいこと 本番環境と試験環境の違いを考える 測定の分散について おわりに 追加試験の結果報告 真の ボトルネック は wait/io/table/sql/handler だった wait/io/table/sql/handler は、ストレージエンジン層における行レベルの操作(読み取り、挿入、更新、削除など)に対して、 SQL 層が費やした待機時間を計測するイベントです。一般には物 理I /O、行レベルのロック待ちなどが主原因になります。 前回の結果では wait/synch/mutex/innodb/dict_sys_mutex が ボトルネック になると誤った結論をつけてしまいましたが、これは暖機運転(ウォームアップ)不足による一時的な現象でした。以下は暖機運転完了前後の待機時間の内訳を、PeformanceInsiteの 平均アクティブセッション(AAS) のグラフで確認したものです。 暖機運転完了前後の待機時間の内訳 これを見るとvCPU数を大幅にオーバーして wait/synch/mutex/innodb/dict_sys_mutex の待機イベントが支配的になっている時間(左側: 暖機運転中)と、vCPU数以下の範囲内で wait/io/table/sql/handler が支配的になっている時間(右側: 暖機運転完了)がハッキリと分かれていることが確認できます。 wait/synch/mutex/innodb/dict_sys_mutex は データディクショナリ へのアクセス競合でしたが、必要な メタデータ がすべてメモリに乗り切れば、基本的に競合は発生しません。よって、今回の測定の条件下では、 wait/synch/mutex/innodb/dict_sys_mutex が発生している場合は暖機運転が十分でないと言えます。 また、このような wait/synch/mutex/innodb/dict_sys_mutex の変化は、今回の測定条件内では table_definition_cache (テーブル定義の メタデータ を載せるキャッシュ)などのキャッシュサイズが十分に足りていることを示唆しています。 以降の測定は暖機運転を十分に行い、この待機イベントの変化を確認してから本測定を行いました。 なお、今回の検証では最終的にこの wait/io/table/sql/handler の ボトルネック を解消することはできませんでしたが、高並列・高QPSの負荷環境においては、ストレージエンジンが膨大なリクエストを処理する上で避けられない現象であると解釈しています。 wait/io/table/sql/handler の待機時間の内訳を、クエリの種別ごとに分けたものが以下の画像です。 負荷を構成しているクエリの比率がそのまま待機時間の比率になっていました 。したがって、特定のスロークエリがこれ発生させているわけではないことがわかります。 `wait/io/table/ sql /handler`のクエリ種別ごとの内訳 パラメータチューニングについて チューニング( インスタンス サイズなどの変更も含む)を試みた項目について、端的に結果をまとめていきます。結論、 今回の測定条件下では 、パフォーマンスの改善はほとんどみられませんでした。 table_definition_cache & table_open_cache table_definition_cache は、テーブルの定義情報( メタデータ )を保持するグローバルなキャッシュです。 データディクショナリ にアクセスする代わりにこのキャッシュを参照することで、テーブルの定義参照処理を高速化することができます。 table_open_cache は、各スレッドがテーブルにアクセスする際に使用するオブジェクトを保持するキャッシュです。テーブルを物理的にオープンする処理は CPU コストが高いため、このキャッシュに保存されたオブジェクトを再利用することで、オーバーヘッドを劇的に低減できます。 前回の調査結果から、 メタデータ へのアクセス待機が ボトルネック であると予想していたため、キャッシュサイズを大きくすることで改善が見込めると思い、これらのパラメータについて調査を行いました。 チューニングが有効でなかった理由は、先ほどの暖機運転の議論でも述べた通り、今回の測定においてはキャッシュサイズは最初から十分足りていたためです。キャッシュヒット率も確認しましたが、99.555%とほとんどキャッシュミスは発生していませんでした。前回も述べたとおり、負荷は十数種類のパターンのクエリで作っており、参照テーブルも数種類にとどまります。そのため、 メモリの使い方は本番環境の方が圧倒的にハードであり、今回の測定ではメモリへの負荷や改善策を評価することができません。 もしも本番環境で 頻繁にキャッシュミスが発生していた場合には、これらのパラメータのチューニングはパフォーマンスを改善する上で非常に有効 になります。 innodb_sync_array_size innodb_sync_array_size は、内部同期用の配列サイズを調整するパラメータで、CPUコア数の多い環境で値を大きくすると、複数のスレッドが内部ラッチを取り合う際の競合 wait/synch/mutex/innodb/sync_array_mutex を緩和し、スケーラビリティを向上させることができます。 このパラメータを増やすと、待機中スレッドの多いワークロードの同時実行性が高まるというのが通説なので、調査を行いました。 チューニングが有効でなかった理由はシンプルで、 wait/synch/mutex/innodb/sync_array_mutex が発生していなかったので調整する必要がなかったというものです。 インスタンス サイズを大きくしてみる これまでの測定では2xlargeを使用していましたが、これを4xlargeにスケールアップした際に性能が改善するかを調査しました。クライアント側は24並列・各スレッドは500QPSの設定で負荷をかけました。以下は測定結果の一部です。 インスタンス TotalQPS QueryCount P99[μs] Avg [μs] r8g.2xlarge 8450 15,210,198 4.44E+03 2.36E+03 r8g.4xlarge 9233 16,618,969 3.15E+03 2.06E+03 4xlargeの方が全体的に性能が改善しているのがわかります。しかし、2xlarge -> 4xlargeではCPU、メモリ共に2倍になっているにも関わらず、性能の改善はQPS換算でせいぜい10%程度でした。これは費用対効果としては非常に効率が悪いです。 この理由は、CPUを使用しているセッションの内訳で説明できます。 2xlarge 4xlarge 両方とも主要な待機は wait/io/table/sql/handler です。4xlargeの方はCPUが2倍になっているため縦軸の縮尺が異なりますが、その絶対値はほとんど変わっていないことがわかります。2xlarge時点でCPUは ボトルネック ではなかった(CPU数を超えたアクティブセッションが発生していなかった)ため、4xlargeに変更してCPUが増えても、目に見えた性能改善はできなかったと解釈できます。逆に、このグラフでCPU数を超えて待機しているセッションが多くなった場合は、CPUの数を増やすことで大きな性能の改善が期待できます。 したがって、 インスタンス のスペックアップは、必ずしも限界を迎えた際の応急処置として使えるとは限らない と言えます。 一定高負荷下での スキーマ 数によるパフォーマンスの変化 Aurora MySQL のパフォーマンスと スキーマ 数の関係をより掘り下げて調べてみました。前回とは以下の点を変更し、150 ~ 1200 スキーマ にて再測定を行いました。 クライアントの負荷設定を固定する(24並列 * 500QPS = 12000TotalQPS) 十分な暖機運転を行いキャッシュに乗り切ったことを確認してから本測定を行う 測定結果を以下にまとめます。かなり 直感とは異なる結果 になりました。 高負荷で固定した際のパフォーマンスの変化 なんと、600 スキーマ が最も スループット が高く、 スキーマ 数が増えた時だけではなく、少なくなった時にも性能の劣化が見て取れます。 そして150, 600, 1200 スキーマ での測定時のAASのグラフは以下のようになっていました。色は異なりますが、支配的となっているのはすべて wait/io/table/sql/handler です。 150 スキーマ 600 スキーマ 1200 スキーマ 600 スキーマ での測定で最も平均AASが少なくなっています。また、1200 スキーマ に加えて、150 スキーマ での測定でも wait/io/table/sql/handler の待機時間が増加しています。 150 スキーマ で性能が劣化する理由 150 スキーマ 構成では、600 スキーマ 構成と比較してテーブルあたりのアクセス密度が4倍になります。たとえ スキーマ が分かれていても、 InnoDB 内部の管理用ハッシュテーブルやラッチ( 排他制御 )は インスタンス 全体で共有、あるいは少数の パーティション で管理されています。 150 スキーマ では管理対象が少ない分、 特定の管理 パーティション にアクセスが集中する「 ホットスポット 」 が発生しやすく、内部的な順番待ちが頻発します。この微細な足止めが積み重なり、結果としてストレージエンジン層の処理時間である wait/io/table/sql/handler を引き延ばしていると考えられます。満遍なく一定の遅延が発生していることも、この説を裏付けます。 150 スキーマ 1200 スキーマ で性能が劣化する理由 一方、1200 スキーマ 構成での劣化は、150 スキーマ の時とは逆に 「管理対象の膨大さ」によるオーバーヘッド が原因であると考えられます。 今回の測定ではキャッシュヒット率が99.5%を超えており、ディスクI/Oの影響は無視できます。しかし、これほど大量の スキーマ が存在すると、 InnoDB が高速化のために生成する「 アダプティブ ハッシュインデックス(AHI)」などの管理データ自体が巨大化します。この巨大化したデータの中から目的のデータを探し出す際、たとえメモリ上であっても管理 パーティション の奪い合いが発生します。この様子は wait/synch/sxlock/innodb/hash_table_locks の待機時間(茶色)として現れており、これに比例して wait/io/table/sql/handler が増加していることがわかります。 つまり、分散のメリットよりも、巨大なリソースを管理・検索するコストが上回ってしまった状態と言えます。 1200 スキーマ 逆に言えば、適切な範囲内であれば スキーマ を分離して負荷を分散すること(パーティショニング)によってパフォーマンスが向上するケースもあると言えます。(これは前回時点では認識していなかった スキーマ 分離のメリットですね) なお、今回の測定はクエリの種類とテーブルを絞り、メモリ負荷が スキーマ 数に対して非常に少なくなるような条件で行ってます。実際に大量 スキーマ のテーブルに対して満遍なくアクセスが発生する場合は、 wait/synch/mutex/innodb/dict_sys_mutex ( データディクショナリ へのアクセス競合)などが支配的になることも十分に考えられます。 負荷試験 を行う上で注意したいこと 負荷試験 において最も注意しなければならないのは、 「得られた数値を絶対視し、誤った解釈をしてしまうこと」 です。今回の測定を通して見えてきた、試験設計と結果評価における注意点をまとめます。 本番環境と試験環境の違いを考える 前回、今回の 負荷試験 は、 上位のものとはいえ、使用するクエリやアクセスするテーブルを絞った環境にて行ったものになります。また、アクセスの並列数も24並列と実際のワークロードに比べると少ないものであり、1スレッドあたりの負荷を上げて総量を補っているとはいえ、本番環境を再現しているとは言い難いです。 よって、今回は以下のような点に注意して結果を評価しています。 「キャッシュ不足」は評価できても、「キャッシュ十分」とは評価できない 負荷試験 はあくまでよく使われている一部のクエリ、テーブルのみをサンプリングしている。使用されないテーブルはキャッシュに乗らない 結果は鵜呑みにするのではなく、定性的に再解釈する 例えば負荷の総量が同じでも、1並列でかける負荷と100並列でかける場合ではDB内部の 排他制御 は全く異なるため、結果の数値をそのまま受け取ってはいけない。数値をそのまま本番のキャパシティとして解釈するのではなく、振る舞いの定性的な変化を読み取ることに主眼を置くべき 負荷試験 には、 条件設定によって評価できるものと評価できないものがある ということです。また、数値をそのままの状態で受け取ることもミ スリード を生む危険があります。このような点に注意して試験設計、結果評価を行いましょう。 測定の分散について 以下は全く同じ負荷設定で、 数時間以内に 測定した結果を比較したものです。サンプルは少ないですが、QPS換算で1%程度の分散におさまっています。 TotalQPS QueryCount P99[μs] Avg [μs] 8554 15,398,029 4.73E+03 2.34E+03 8459 15, 226 ,641 4.49E+03 2.37E+03 8460 15,228,216 4.32E+03 2.36E+03 対してこちらは全く同じ負荷設定で、 日付を跨いで 測定した結果を比較したものです。 TotalQPS QueryCount P99[μs] Avg [μs] 9118 16,412,667 4.60E+03 2.09E+03 8450 15,210,198 4.44E+03 2.36E+03 なんと、QPS換算で 7.3%程度の差 が出てしまっています。マネージドサービスゆえの不可避な外部要因( AWS の帯域や近隣 インスタンス の負荷)によるものだと思っていますが、この結果は、測定そのものの分散よりも時間帯による分散の方が遥かに大きなことを示唆しており、 最低でも7%以上の分散 があることがわかります。 つまり、 この測定では「5%程度の性能改善」を論じても意味がない わけです。その数値は誤差の範囲に埋もれてしまうからです。結果を数値的に求めたい場合は、測定自体の誤差がどの程度なのかを評価する必要があり、それができない限りは 定量 的な評価は難しいということです。間違っても、1回だけ測定して「こんな数値が取れました!」という結果の受け取り方はしないようにしましょう。 おわりに 前回記事の内容と合わせて本格的な 負荷試験 を行い、当初の目的であった現行 アーキテクチャ のデッドラインを見積もるという目的は無事達成できました。しかし、目的達成以上に、その試行錯誤の過程から多くのことを学ぶことができたと思います。 「 スキーマ 数が増えれば管理コストで遅くなる」という一般論は知っていても、実際に手を動かして測定を行い、予想と異なる振る舞いに悩み、考え抜いたことで、より MySQL に関する理解は深まりました。そして、「推論と検証を繰り返すことで ブラックボックス を一つずつ明らかにしていく」というプロセスそのものが、エンジニアにとって何よりも大切な経験であり、自信にもつながることを再確認できました。 本記事の試行錯誤の過程が、これから 負荷試験 に挑む皆様にとって、一歩を踏み出すための参考になれば幸いです。 MNTSQ株式会社 SRE 西室