MNTSQのブログ - TECH PLAY

TECH PLAY

MNTSQ

MNTSQ の技術ブログ

107

はじめに MNTSQ 株式会社 アルゴリズムエンジニアの内海です。 弊社では最近、MNTSQ契約レビューという機能をリリースしました。 私自身は今年の5月に入社したばかりなのですが、AIエージェントが人間に代わって契約書の条文を読んでリスクをチェックし修正までしてくれる、便利な時代になったものだなと感心するばかりです。 さて、そんな便利な機能ですが当然素のLLMだけで実現できるわけではなく、裏では契約書を解析してどのようなチェックを行うべきかを判断したり、過去の案件や文書で関連しているものを検索して参照したりしています。 そのため、関連情報を見つける検索の性能は契約レビューの性能にも関わってくる大事な要素になります。 まだまだ取り組み始めたばかりの段階ですが、この記事では法務担当者が担当業務を達成するためにどのような情報を必要とするのか、それを検索で実現するにはどうしたら良いかを考えたいと思います。 契約レビューで必要となる情報とは何か 契約は、自社と相手方が、取引を通じてそれぞれの目的を達成するために締結するものです。そのため契約レビューでは、条文単体の文言を見るだけでは不十分です。 同じ条文でも、交渉の経緯や社内規定、取引の背景、当事者の立場、その時点の状況などによって、判断すべきリスクは変わります。こうした事情と条文の内容が組み合わさったときにリスクが顕在化し、それを最小限に抑えるための修正が必要になります。 つまり、検索の目的は、法務担当者がリスクを判断するために必要な情報を、さまざまな状況や事情も含めて過不足なく集めることだといえます。 では、これらの情報を得るためにはどのような検索が必要でしょうか。 キーワードによる文書検索からナレッジグラフを用いたRAGへ ある契約書の条文のレビューを行う際、必要となる情報は具体的な過去の契約書全文でしょうか。勿論類似する案件の修正内容は重要な参考資料ですが、一つの条文をレビューするために契約書全体を読むのは過剰です。また、修正内容そのものも重要ですが、その判断の背景にある交渉内容や社内規定、その時の状況や事情も重要な情報です。 MNTSQ契約レビューはLLMが検索先と検索キーワードを考えて、ヒットした文書を取得します。取得した文書をすべてLLMのコンテキストに含めると入力が非常に長くなるため、参照できる文書数は少数に限られます。 また、検索結果はLLMが生成する検索キーワードにも左右されます。たとえば「秘密保持」や「損害賠償」のような一般的な語では、それらを含む大量の契約書がヒットし、本当に関連する文書を絞り込むことが難しくなります。 このように、検索キーワードを使って文書単位で関連情報を取得する方法には、次の2つの課題があります。 レビューに必要な粒度でコンテキストを得られない 多様かつ包括的な情報が得られない そこで、現在取り組んでいるのがナレッジグラフに基づく検索・回答生成です。 まず、契約書の条文、案件のコメント、プレイブックのチェック項目など、各種のデータソースをチャンク(意味的なまとまり)単位に分割します。次に、それぞれのチャンクからエンティティ(人物、組織・会社など、特定の物事や概念)と、エンティティ間の関係を抽出します。たとえば、MNTSQが当事者となる秘密保持契約であれば、「MNTSQ」と「秘密保持契約」という2つのエンティティの間に、「甲」「締結当事者」といった関係が抽出されます。こうして抽出したエンティティをノード、関係をエッジとして表したものがナレッジグラフです。 グラフのノードとエッジには、エンティティ名や関係だけでなく、LLMがチャンクから生成した説明と、根拠となるチャンクの情報も持たせます。同じエンティティが複数のチャンクに現れる場合は、それらの情報を統合してノードの説明を作ります。同じエンティティ間の関係が複数のチャンクから抽出された場合も、重複を排除しながら情報を統合します。 現在試作しているナレッジグラフの全体像は、次のとおりです。 具体的なキーワードと、状況や事情を表す複数の観点を組み合わせて検索する ナレッジグラフを構築するだけでは、検索の課題は解決しません。法務担当者からの質問は、必ずしも条文の言い回しをそのまま含んでいるとは限らないからです。具体的なキーワードだけで検索すると同じ語を含む大量の情報がヒットし、反対に条文の表現へ寄せすぎると、言い回しの異なる情報を見落としてしまいます。 そこで、チャンクから抽出した関係には、条文に現れる具体的な表現だけでなく、リスク判断に必要な状況や事情を言い表すラベルも付与します。ここでいうラベルは単語ではなく、取引の背景、当事者の立場、義務・責任の内容、リスクの型といった複数の観点を組み合わせた、一文程度の短い説明です。一つの抽象的な言い回しに集約するのではなく、観点ごとに複数のラベルを持たせます。これらを関係の説明とともに関係索引へ埋め込むことで、質問と条文の表現が一致しなくても、質問の意味に近く、その状況や事情に合う関係を検索できるようにします。 検索時には、一つの質問から2種類の検索語を生成します。図2で「低レベルキーワード」と呼んでいるのは、契約種別や条文に現れる語など、具体的なエンティティを探すための単語レベルの検索語です。一方、「高レベルキーワード」は名前に反して単語ではありません。質問に含まれる背景や当事者の立場、確認したい義務・責任などに対応する関係を、複数の観点から探すための短い説明文です。前者でエンティティ索引を、後者で関係索引を検索します。 たとえば、「MNTSQが甲(開示者)となる秘密保持契約で注意すべき論点は?」という質問では、「秘密保持」「開示範囲」といった具体語でエンティティ索引を検索します。同時に、関係索引では「秘密保持契約における当事者の義務」という高レベルキーワードを使います。この高レベルキーワードは、単に具体的な文言を抽象語へ置き換えたものではなく、「秘密保持契約」という契約の種類と、「当事者の義務」という確認の軸を組み合わせ、質問の状況に関係するエッジを探すための検索語となっています。「MNTSQ」「甲(開示者)」といった具体的な当事者や立場の情報は、エンティティ側の検索結果や、そこにつながる関係と組み合わせて補います。 両方の索引でヒットしたノードやエッジについて、つながっている情報(1-hop近傍)を集約し、元のチャンクから根拠となる原文を取得します。そのうえで、LLMが根拠付きの回答を生成します。具体的なキーワードだけに依存せず、複数の観点を持つ関係の意味からも検索することで、表現の異なる条文や、質問に明示されていない関連情報を拾いやすくなります。 整理すると、先に挙げた2つの課題に対して、次のような解決を試みています。 レビューに必要な粒度でコンテキストを得られない → 文書全体ではなく、ヒットした関係やエンティティに直接紐づくチャンクだけを回答のコンテキストに渡す 多様かつ包括的な情報が得られない → 具体的なキーワードによるエンティティ検索と、状況や事情を表す複数の観点を用いた関係検索を組み合わせる 自然言語処理に明るい方はもう既にお気付きかもしれませんが、これは LightRAG [Guo+, EMNLP2025] という手法で、それを法務業務で必要とする検索に合わせて調整しています。 PoCで実際に効果を検証してみる 仕組みの説明だけでは効果が分からないため、開発環境にある開発用データを使って、一連の処理を動かしてみました。なお、使用したのは本番データではなく、あくまで検証用のデータです。投入したデータと、構築されたグラフのサイズは次のとおりです。 データ種別 データ源 投入件数 案件管理 mntsq matters 8件 契約管理 mntsq documents / clauses (条=level1) 42件 ナレッジ mntsq-knowledge playbook_items 15件 コメント mntsq matter_timeline_comments 1件 ノード数 エッジ数 355 615 埋め込みにはGemini Embedding(768次元)を使っています。 契約書からチャンクを作る際に、条=level1 単位としていることに対して、法務担当者の方は、「定義条項で定義された語や『前条の規定を準用する』といった条をまたぐ参照が切れるけどどうするんだろう?」と感じるかもしれません。 しかし、ナレッジグラフではエンティティはグラフ上で1つのノードに統合されます。 定義条項で定義したエンティティと条項間で関係が抽出されるため、グラフとしてエンティティが統合されていく過程で定義条項のチャンクの情報はエンティティの説明に含まれることになります。 その上で他のエンティティとの関係にも説明が付与されるため、条でチャンクを作ったために条を跨いだ参照が途切れてしまうという心配はありません。 ナレッジグラフを用いた検索の効果を見るために、以下の3つの質問を使って、現行の検索と比較しました。 MNTSQ契約レビューは MNTSQ AI Agent の機能で、関連文書の検索は MNTSQ AI Agent がツールとして呼び出しています。今回は、この検索部分をナレッジグラフに置き換えた場合の効果を検証しています。 3つの質問 「委託先や外部の協力者に社内情報を渡す場面で、委託先に、その再委託先や従業者の行為まで責任を負わせ、報告・監査で情報の取扱いを確認できるようにするには、過去の契約ではどのような設計がされていたか比較して」 「Playbook株式会社が委託者として関与する契約について、相手方ごとの委託者・受託者の役割と支払関係を比較して」 「MNTSQが甲(開示者)となる秘密保持契約で注意すべき論点は?」 また、今回の実験では検索結果の上位20チャンクを取得して回答しています。 結果から、それぞれ異なる種類の差が見えてきました。 比較結果 コンテキストの量の差 MNTSQ AI Agent ナレッジグラフ検索 質問1 15,749文字 8,144文字 質問2 6,587文字 7,205文字 質問3 10,089文字 6,509文字 質問1、質問3ではLLMへ与えるコンテキストの量が減らせていることが分かります。 質問1では検索結果の網羅性はどちらも同程度でした。これは質問に適切な観点を表す「報告・監査」というキーワードが存在しているため、MNTSQ AI Agent では LLM が適切な検索キーワードを用いて検索ができているためです。 取得できた文書・案件の件数 MNTSQ AI Agent ナレッジグラフ検索 質問1 4文書 7件(契約テンプレート5種+2案件) 質問2 1案件 3案件 質問3 4文書 13件(テンプレート3種+案件10件) ※ ナレッジグラフ検索はこの表の件数をすべて使って回答するわけではなく、この中から必要なチャンクを選んで根拠付きで回答を生成します。 質問2で特に大きな差が出ました。MNTSQ AI Agentは、案件を横断して検索する仕組み自体は備えているものの、実際に生成された検索キーワードでは1つの案件しか見つけられませんでした。一方、ナレッジグラフ検索では同じ質問から3件の候補を発見し、その中から回答に必要な2つの案件(残り1件は質問とは無関係)を正しく選んで、相手方ごとの役割と支払関係を比較する回答を生成できていました。これは、LLMが生成した検索キーワードが実際の文書の表記と一致しない場合に、関連する案件そのものを丸ごと見落としてしまう、キーワード検索特有の弱点を示す結果です。 質問3の論点網羅性 質問3(「MNTSQが甲(開示者)となる秘密保持契約で注意すべき論点は?」)は、案件を横断する質問ではなく、単一のテーマについて論点を網羅できるかを見るための質問です。取得したコンテキスト量は表1に示した通り(MNTSQ AI Agent: 10,089文字/ナレッジグラフ: 6,509文字)ですが、ここでは論点の網羅性を比較します。 MNTSQ AI Agent ナレッジグラフ検索 挙げられた論点の数 5/6 6/6 MNTSQ AI Agentは文書4件を取得し、ナレッジグラフ検索が挙げた6つの論点(秘密情報の定義・除外事由、目的外利用の禁止、第三者開示の制限、善管注意義務・調査権、漏洩時対応・損害賠償、終了時の返還・破棄)のうち5点をカバーし、1点(漏洩時の通知義務)は明示されていませんでした。ナレッジグラフの回答は、実際の過去案件5件の具体的な条項(案件番号・条項番号付き)を根拠に、6つの論点すべてをカバーしています。 まとめ 3つの質問の結果を整理すると、次のようになります。 質問 差の種類 結果 委託先・再委託先・従業者への責任と報告・監査の設計比較 コンテキストの量 回答の網羅性は同程度。入力コンテキストはナレッジグラフが約52%(15,749文字 → 8,144文字) Playbook株式会社が関与する契約の相手方ごとの比較(案件横断) 検索の再現率・正確性 MNTSQ AI Agentは案件を見落とし「比較対象は存在しない」と誤答。ナレッジグラフは2案件を正しく発見 MNTSQが甲となる秘密保持契約の論点(単一案件・網羅性) コンテキストの量・網羅性 ナレッジグラフでは論点を網羅。入力コンテキストはナレッジグラフが約65%(10,089文字 → 6,509文字) 今後の展望 今回の検証は、開発環境のシードデータを使った小規模な実証実験に過ぎません。データ量が少ないため、たまたまうまく検索できた可能性も否定できません。 今後は、より多くの案件・契約書・プレイブックを投入し、質問のパターンも増やして効果を検証するとともに、プロダクトへ導入するための権限管理や、ノードやエッジに持たせるプロパティに時間軸を追加して、期限切れの情報のフィルタリングや法令改正の前に関係する契約の棚卸しに繋げられるようにするなど、より実務を意識した検証を行っていく予定です。 あわせて、MNTSQ AI Agentなどの既存のAIエージェントからナレッジグラフを実際に呼び出すための接続も進め、契約レビューの現場で使える形に近づけていきたいと考えています。 注記:この記事の図は Claude を用いて作成しています。また、文章の添削には ChatGPT も使用しました。 謝辞:この記事を書くにあたり、法務担当者が必要とする検索や論点とは何かについて、テックリードの平田氏、PdMの松葉氏に資料の共有や法務領域の知識に基づいたアドバイスを頂きました。ドメイン知識が不足していることもあり大変感謝しております。
はじめに なぜ移行したか アーキテクチャ 進め方 運用とコストの現実 ハマりどころ Terraform プロバイダのバグ カスタムパッケージの Content-Type tar コマンドの実装差 自動スナップショットと運用タスクの競合 おわりに はじめに 弊社の検索基盤は、長らく EC2 上でセルフホストした Elasticsearch 7.x で動いていました。 これを AWS マネージドの OpenSearch Service に無停止で移行するプロジェクトが、2025年10月から2026年6月にかけて動きました。 本稿はこの移行を SRE の立場から振り返る記録です。 検索品質やアプリケーションのコード対応といった SWE 1 視点の話は別稿に譲り、本稿ではインフラ構成、移行の進め方、運用とコストの現実、そしてハマりどころに絞って取り扱います。 先に断っておくと、この移行は「セキュリティ対応のために始めた小さな作業が、気づけば大きな基盤刷新になっていた」という経緯を辿っています。 何がその転換点だったのかも含めて書いていきます。 なぜ移行したか きっかけは地味なところにありました。 2025年10月、CI 上で Elasticsearch 7 系のコンテナが起動しなくなり、検索まわりのテストがことごとく落ちるようになったのです 2 。 ローカルでは問題なく動くのに CI 上でだけ動かない。 実行環境側で何が変わってそうなったのかまでは詰めきれませんでしたが、原因が Elasticsearch 7 系の古さ、より正確にはそこで使われているライブラリにあることは見えており、当座は該当するテストをスキップして凌ぎました。 ちょうど Elasticsearch 7 系は EOL を迎えつつあり、遅かれ早かれ何らかの対応が必要だとは認識していたところに、CI の破綻という分かりやすい形で背中を押された格好でした。 この時点での主題は、あくまで Elasticsearch 8 系へのアップグレードでした。 実は当時から遡ること1年ほど前(2024年11月)にも Elasticsearch 8 または OpenSearch のどちらへ行くかを検討したことがあり、そのときは「コストメリットもあまりないので微妙」という判断で OpenSearch は見送られていました。 検索まわりには古くからの独自 Lucene プラグイン(日本語の読みを使った検索候補の補完機能)が組み込まれており、これが Elasticsearch 8 系はおろか OpenSearch でも動かないという制約につき、選択肢を狭めていた事情もあります。 流れが変わったのは、この独自プラグインを廃止する意思決定が、Elasticsearch 8 移行に向けて別途進んでいたことに、CI 破綻を受けた検討の場でメンバーの一人が気づいたときでした。 「独自プラグインが理由で OpenSearch に行けなかったはずだが、その独自プラグインはどのみち今回の対応で廃止する予定になっている。 ならば制約は実質的に無くなっているのではないか」という指摘です。 あわせて、マネージドサービスに寄せることで自前運用に起因するインデックス破損等のリスクが下がるはず、という意見も複数のメンバーから出ました。 この2点が決め手となり、Elasticsearch 8 ではなく OpenSearch への移行を実施するという意思決定がなされました。 無停止での移行方針(後述「進め方」の節を参照)も同じ議論の中で固まりました。 一括で止めて移行する案も検討されましたが、テナント数が増え続けている状況では、限られたメンテナンス時間内に確実にやり切れる保証がありません。 何より、切り戻しの効かない一発勝負に踏み切るリスクの方が重いという判断から、ダブルライトによる無停止移行を選んでいます。 アーキテクチャ 移行前は Elasticsearch のクラスタをテナント群ごとに複数用意する構成を取っていました。 各クラスタは数台のノードで構成され、クラスタの数はテナントの増加にあわせて増やしていく運用です。 これを、単一の OpenSearch ドメインにデータノードを集約する構成に転換しました。 移行前の構成は次のような形です。 flowchart TB API["検索 API"] subgraph SELF["EC2 上のセルフホスト Elasticsearch 7.x"] C1["クラスタ A<br/>ノード数台<br/>(マスターは兼任)"] C2["クラスタ B<br/>ノード数台<br/>(マスターは兼任)"] C3["クラスタ C, D, ...<br/>テナント増加に<br/>あわせて増設"] end API --> C1 API --> C2 API --> C3 テナント群ごとにクラスタが割り当てられ、専用のマスターノードは置かずデータノードに兼任させています。 テナントが増えればクラスタを足す、という形で伸ばしてきた構成です。 移行後はこうなりました。 flowchart TB API["検索 API"] EP["カスタムドメイン名<br/>スプリットビュー DNS + ACM 証明書"] subgraph DOM["OpenSearch ドメイン(単一 / VPC 内 / FGAC 有効)"] MST["専用マスターノード x3<br/>3 AZ に分散"] DAT["データノード<br/>全テナントぶんを 1 ドメインに集約"] MST -. クラスタ状態の管理 .-> DAT end API --> EP EP --> DOM クラスタという単位そのものが無くなり、データノードは単一ドメインの中に集約されました。 クラスタ管理はデータノードから切り離され、専用マスターノードの仕事になっています。 集約にあたって議論になったのが、全データノードにシャードを均等配置することのデメリットです。 AWS の担当者を交えた技術ディスカッション 3 の中で、単一ドメインに寄せてもマネージドサービスとして問題なく運用できるとの見立てが得られ、この構成に決めています。 検索対象のインデックスには規模の差があり、特に契約書本体を格納する巨大インデックスと、それ以外の一般的なインデックスとでは望ましいシャード数が大きく異なります。 そこで index template を優先度違いで2段構成にし、対象インデックスの種類に応じて適用されるシャード数を出し分けるようにしました。 インデックス作成時に複数のテンプレートのパターンに一致した場合、priority がもっとも高いものだけが適用され、下位のテンプレートは無視されます。 巨大テナントのインデックスは共通テンプレートのパターンにも一致しますが、専用テンプレートのほうが priority が高いため、シャード数はそちらの値が採られます。 図にすると次のような関係です。 flowchart TB IDXA["一般テナントの<br/>インデックス"] IDXB["巨大テナントの<br/>インデックス"] TC["共通テンプレート<br/>priority 10<br/>documents_*, clauses_*, ...<br/>number_of_shards = 3"] TH["巨大テナント<br/>専用テンプレート<br/>priority 100<br/>対象インデックス名を列挙<br/>number_of_shards = 10"] IDXA ==>|適用される| TC IDXB -.->|一致するが無視される| TC IDXB ==>|適用される| TH 専用テンプレート側のシャード数は、1シャードあたりのデータ量が推奨される上限を超えないことを目安に決めています 4 。 ネットワーク構成は VPC 内に閉じた形を採ったうえで、 きめ細かなアクセスコントロール (FGAC)を有効化しています。 OpenSearch のドメインエンドポイントはそのままでは扱いにくいため、環境ごとに専用のカスタムドメイン名を用意し、スプリットビュー DNS と ACM 証明書で疎通できるようにしました。 この構成の詳細と Terraform コードの例は以下拙稿で扱っているので、そちらも参照ください。 ドメインそのものは Terraform で管理しています。 ここまでに触れた構成上の判断が実際のコードではどう現れるのか、要点だけ抜き出すと次のような形になります。 /* FGAC のマスターユーザに充てる IAM ロールを取得する IAM Identity Center が各メンバーアカウントへ配置する管理者権限セットのロールを使う */ data "aws_iam_roles" "sso_administrator" { name_regex = "AWSReservedSSO_Administrator_.*" path_prefix = "/aws-reserved/sso.amazonaws.com/" } resource "aws_opensearch_domain" "main" { domain_name = local.domain_name engine_version = "OpenSearch_2.19" cluster_config { instance_type = var.instance_type instance_count = var.instance_count dedicated_master_enabled = true dedicated_master_count = 3 dedicated_master_type = var.master_node_instance_type zone_awareness_enabled = true zone_awareness_config { availability_zone_count = length (var.private_subnet_ids) } } # インターネットには出さず VPC 内に閉じる vpc_options { subnet_ids = var.private_subnet_ids security_group_ids = [ aws_security_group.opensearch.id ] } # 自動生成のドメインエンドポイントは扱いにくいのでカスタムドメイン名を与える domain_endpoint_options { enforce_https = true # advanced_security_options の要請による custom_endpoint_enabled = true custom_endpoint = var.custom_endpoint custom_endpoint_certificate_arn = var.custom_endpoint_certificate_arn } advanced_security_options { enabled = true master_user_options { master_user_arn = one (data.aws_iam_roles.sso_administrator.arns) } } encrypt_at_rest { enabled = true } node_to_node_encryption { enabled = true } } 専用マスターノードを3台にしているのは、消去法の結果です。 開発者ガイド では1台構成が明示的に禁止されており、 API リファレンス によれば2台と4台も設定できません。 選べるのは3台か5台です。 5台にすればマスターの2台同時喪失まで耐えられますが、常時アクティブなマスターは1台だけなので遊休ノードが4台分になり、多くの場合は過剰だとドキュメント自身が述べています。 ゆえに3台としました。 ノード数やインスタンスタイプを変数に寄せているのは、同じモジュールを検証環境から本番環境まで使い回すためです。 zone_awareness_config に渡すサブネット数と instance_count の関係には注意が必要で、AZ 数を下回るノード数を指定すると apply が通りません。 カスタムドメイン名の側は、この custom_endpoint に対応する CNAME レコードをスプリットビュー DNS のゾーンへ別途登録して名前解決させています。 なお、フィルタやアナライザーといった検索の中身に関わる設定は、独自プラグイン以外は基本的にすべてそのまま持ってくる方針を最初に決めています。 移行と同時に最適化まで検討すると収拾がつかなくなる、という判断からで、整理は移行完了後の課題として切り出しました。 進め方 採用したのはダブルライト方式です。 全体の流れは次のとおりです。 新しい OpenSearch ドメインを構築する 既存データを OpenSearch 側に流し込みつつ、以降の書き込みは Elasticsearch と OpenSearch の両方に対しておこなう(ダブルライト開始) 新旧の整合性を確認しながら、検索結果を新旧で比較するシャドウテストを実施する 問題がないことを確認できたら、実際にユーザへ返すレスポンスの参照先を OpenSearch 側に切り替える Elasticsearch への書き込みを止める Elasticsearch 側のリソースを削除する 出発点は、Elasticsearch だけが読み書きの対象になっている状態です。 OpenSearch のドメインは先に作っておきますが、この時点では中身が空で、どこからも参照されていません。 flowchart LR W["アプリケーション<br/>(書き込み)"] R["検索 API<br/>(読み取り)"] ES["Elasticsearch(EC2)"] OS["OpenSearch<br/>構築しただけで中身は空"] W ==>|書き込み| ES R ==>|ユーザに返すのはこちら| ES R ~~~ OS ここからダブルライトを始めて参照先を切り替えるまでのあいだ、各コンポーネントの関係は次のようになります。 flowchart LR W["アプリケーション<br/>(書き込み)"] R["検索 API<br/>(読み取り)"] B["インデックス再構築<br/>ワーカー<br/>(既存データの移送)"] ES["Elasticsearch(EC2)"] OS["OpenSearch"] W ==>|書き込み| ES W ==>|書き込み| OS R ==>|ユーザに返すのはこちら| ES R -.->|比較のために投げるだけ| OS B ==>|既存データを流し込む| OS 書き込みを二重化しているので、OpenSearch 側も常に最新のデータを持ちます。 その状態で検索は Elasticsearch の結果だけをユーザに返し、OpenSearch には同じクエリを投げて結果を突き合わせるに留めます。 この段階では、OpenSearch 側の検索結果がどれだけ壊れていてもユーザには影響しません。 参照先を切り替えると、こうなります。 flowchart LR W["アプリケーション<br/>(書き込み)"] R["検索 API<br/>(読み取り)"] ES["Elasticsearch(EC2)<br/>切り戻し先として維持"] OS["OpenSearch"] W ==>|書き込み| ES W ==>|書き込み| OS R ==>|ユーザに返すのはこちら| OS 変わったのは、ユーザに返す結果をどちらから取るかだけです。 書き込みは両方に続けているため Elasticsearch のデータも最新のまま保たれ、問題が起きれば参照先を戻すだけで復旧できます。 切り替えたあとしばらく様子を見て、問題が出ないことを確認してから、Elasticsearch への書き込みを止めて残ったリソースを片付けます。 flowchart LR W["アプリケーション<br/>(書き込み)"] R["検索 API<br/>(読み取り)"] OS["OpenSearch"] W ==>|書き込み| OS R ==>|ユーザに返すのはこちら| OS 切り戻し先が無くなるのはこの段階です。 環境ごとの適用順は、検証環境を先行させ、本番環境は後段に回しています。 本番の切り替えに際しては、テナント単位で段階的に有効化できる仕組みを活用し、問題があった場合はすぐに元に戻せる状態を維持しながら進めました。 結果として、本番切り替えは無停止で完了し、顧客からの問い合わせもゼロでした。 ただし、移行に要した期間は当初の想定を大きく超えました。 プロジェクト全体を、1〜2ヶ月の集中作業に1ヶ月のバッファを見込む程度に見積もっていましたが、実際には本番の全件インデックス再構築に着手してからだけで2ヶ月を超えています。 テナント数が思いのほか増えていたこと、そして新旧の挙動差異への対応に都度時間を取られたことが主な要因です。 運用とコストの現実 マネージドサービスに移行したことで、真夜中にディスク逼迫で reindex をおこなう、プライマリとレプリカのスペックアップ手順を間違えないよう気を張る、といった自前運用ならではの負荷からは解放されました。 ノード追加やディスク拡張がコンソール操作で完結するようになったのは、素直な嬉しさがあります。 定期スナップショットについても、以前は仕組みとして整備されていなかったものが、移行後は自動で取得され、保持されるようになり、リストアの選択肢が確保されました。 コストは、構成が落ち着いたあとの日次で見ると、Elasticsearch 時代のもっとも安かった時期と比べても2割ほど低い水準に収まりました。 効いているのは、1ノードあたりのシャード数に上限があるために、リソースが余っていてもクラスタを増やさざるを得なかった構造が、単一ドメインへの集約によってなくなったことです。 一方で、移行の途中ではコストが大きく膨らみました。 Elasticsearch と OpenSearch を並行して動かしたうえ、既存データの全件インデックス再構築を進めるあいだは OpenSearch 側を厚めに構えていたため、ピークにあたる月の日次コストは移行前のおよそ倍に達しています。 並行して動かす期間をいかに短く畳むか、という意識が薄かったことは反省点です。 もう一つの反省点は、コストを抑えるための リザーブドインスタンス 購入のタイミングです。 移行が完了して構成が落ち着く前に契約してしまい、後からの調整余地を狭めてしまいました。 コスト最適化は移行が一段落してから着手する方が、結果的には無駄が少なかっただろうと思います。 シャード数の上限(1ノードあたり1,000という cluster.max_shards_per_node の既定値)にも移行の前後で何度か踏みました。 この上限はノード数に比例して実質的な天井が決まるため、ノード数の少ない検証環境ほど早く頭を打ちます。 移行時にシャード設計を作り込まず既存設定を横引きしたことのツケが、こういった形で後から現れています。 ハマりどころ Terraform プロバイダのバグ Terraform でのドメイン構築では、プロバイダのバグに難儀させられました。 パッケージの関連付けを管理するリソースで、既存の関連付け済みリソースをインポートできない、再適用すると意図しないエラーが返る、複数のパッケージを同時に適用しようとすると競合が起きる、といった挙動が確認できたため、 アップストリームに issue を報告 しつつ、手動での調整を織り交ぜて apply を進める必要がありました。 カスタムパッケージの Content-Type 検索の日本語解析には Sudachi を使っており、プラグイン本体は AWS がマネージドパッケージとして提供しているものをそのまま利用できます。 一方で辞書は、移行元の Elasticsearch が 2020年12月版という古いものを使っていた都合上、同じ版を踏襲する形で自前で用意しました。 配布元から zip で取得して展開し、S3 を経由してカスタムパッケージとして登録する、という仕組みです。 S3 に置いた辞書ファイルが OpenSearch 側に受け付けられず、internal error になりました。 原因は Content-Type です。 .dic という拡張子が text/x-c と判定されており、OpenSearch が要求する binary/octet-stream になっていなかったのです。 当時この制約はドキュメントに記載がなく、AWS サポートに問い合わせて初めて判明しました。 その後フィードバックが反映され、現在は カスタムパッケージのドキュメント に明記されています。 application/octet-stream では失敗する、という当時は分からなかった情報も添えられています。 tar コマンドの実装差 zip の展開処理を手元の macOS で書いたのですが、BSD tar は zip も展開できてしまうため、 tar のまま動作確認を終えてしまいました。 GitHub Actions(Linux)の GNU tar は zip を扱えず、そこで失敗します。 unzip に切り替えて解消しました。 自動スナップショットと運用タスクの競合 移行が完了した後にも、新しい種類のハマりどころが出てきました。 OpenSearch の自動スナップショット取得と、テナント作成などの運用系タスクの実行タイミングが重なり、タスク側がタイムアウトする事象です。 当初は「特定の時間帯は一律で自動スナップショットを止める」という対策を検討しましたが、保護されない時間が広くなりすぎるとして採用を見送り、最終的には「保護したい作業の実行中だけ、参照カウント方式で一時停止して再開する」仕組みに落ち着きました。 移行そのものが完了した後も、運用フェーズならではの課題が形を変えて現れ続けるのだと実感した出来事です。 おわりに 今回の移行を振り返って一番印象に残っているのは、「なぜやるか」が途中で静かに変わっていたという点です。 始まりは EOL 対応という守りの話でしたが、実際に大きな決断を後押ししたのは、過去に一度見送った選択肢の制約が別の文脈でたまたま解消されていたことに、誰かが気づけたという偶然でした。 大規模な移行の意思決定は、こうした細部の気づきに支えられていることが多いのかもしれません。 無停止でのダブルライト移行は手間のかかるやり方ですが、切り戻しの選択肢を最後まで持ち続けられたことは、本番での事故を防ぐうえで確かな安心材料になりました。 一方でシャード設計は「後から直せばいい」で先送りにした分だけ、後になって顔を出しています。 移行そのものを安全に倒すための「先送り」と、あとで確実に効いてくる「先送り」は別物で、後者をどこまで見分けておけたかが、この移行の反省点だったように思います。 この記事が、Elasticsearch から OpenSearch への移行、またはセルフホスト版 Elasticsearch から AWS マネージドサービスとしての OpenSearch への移行検討の材料として一助になれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は、Mermaid 図を含む内容の9割程度を、文責者の過去記事や社内の関連ドキュメントをもとに Claude Opus 5 が執筆しています。 追伸:本稿の執筆と推敲にあたっては、 日本語技術文書の規範をまとめた k16shikano 氏の文書 を参照しました。 記して感謝します。 ソフトウェアエンジニアの意です ↩ 古い Elasticsearch のコンテナが、新しいカーネルや systemd と組み合わさると起動しなくなる事象自体は広く知られています。JVM の cgroup 検出が失敗するもので、Elasticsearch に固有の話ではなく古い Java アプリケーション全般で起こります( mastodon/mastodon#33635 )。GitHub Actions のランナーイメージでも、2026年2月にカーネルが 6.11 系から 6.14 系へ上がった際に Elasticsearch 7.17 が起動しなくなる事象が報告されており( actions/runner-images#13684 )、我々の CI でもこの時期に再び Elasticsearch 関連のテストが落ちています。EOL の近い古いミドルウェアは、こちらの都合とは無関係に実行環境の更新で動かなくなる、という一例です。 ↩ 移行の検討にあたっては、AWS のソリューションアーキテクトおよびスペシャリストの方々に技術ディスカッションの場でご相談させていただきました。シャード設計やノード構成の妥当性について具体的な見立てをいただけたことは、構成を決めるうえで大きな助けになりました。この場を借りて御礼申し上げます。 ↩ AWS は1シャードあたりのサイズについて、検索ワークロードでは 10〜30 GiB、ログワークロードでは 30〜50 GiB を目安とし、50 GiB を上限とすることを推奨しています( Amazon OpenSearch Service の運用上のベストプラクティス )。 ↩
はじめに 1. 取り組みの全体像 2. Security Hub 刷新の道程 3. フロントエンドとバックエンドの体制調査で見えたもの 4. 具体的な改善 5. 対応後処理 6. 今後の展望 はじめに SaaS を提供する事業者にとって、セキュリティ体制の維持・改善は事業の成長に直結するテーマです。弊社でも、顧客からセキュリティ対策の状況について問い合わせや開示を求められる場面は少なくありません。セキュリティへの投資は単なる守りのコストではなく、顧客からの信頼を獲得し、プロダクトの成長を後押しする取り組みでもあると捉えています。 note.mntsq.co.jp 一方で、この取り組みに着手した当初、脆弱性管理の実態を顧客に説明する対外説明資料は十分に整っていませんでした。 本稿は、そこから対外説明資料を積み上げていった、1年あまりの取り組みの記録です。Amazon Inspector の導入に始まり、AWS Security Hub の刷新、フロントエンドとバックエンドの現状調査、GuardDuty の運用整備、そして対外説明資料の整備まで、自分が実際に手を動かし判断してきた範囲を時系列でまとめます。 1. 取り組みの全体像 1年あまりで動いた取り組みを俯瞰すると、大きく4本の柱がありました。 AWS Security Hub の刷新と運用ループの構築(本稿の中心) Amazon Inspector の全社導入と、スキャン結果の取り扱い整理 フロントエンドとバックエンドそれぞれの脆弱性管理体制の現状調査、およびそこで見つかった運用上の抜け漏れの解消 IAM アクセスキーの棚卸しや GuardDuty の保護機能拡充など、周辺の統制強化 これらの取り組みは、「脆弱性管理プロセスの検討と実装」という一つのゴールの下に位置づけて着手したものです。ゴールの達成にあたっては、いきなり全体を作り込むのではなく、対象をインフラ、フロントエンド、バックエンドといった領域にいったん切り分け、まず領域ごとの現状を整理したうえで、それぞれに体制整備や仕組みの構築を進めるという進め方を取りました。また、ゴールの射程もプロセスを定義するところで終わらせず、実際にそのプロセスに沿って運用を回し、そのうえで継続的な改善に取り組み続けるところまでを見据えています。 2. Security Hub 刷新の道程 当初の状況は以下のようなものでした。 Amazon Inspector は有効化済み Security Hub については、 Security Hub CSPM (クラウドのセキュリティ体制を継続的に評価する、従来からある機能)のみ有効化済み いずれも最低限の検知の仕組みは既にあったものの、それを活用して継続的に運用が回っている状態ではなかった ここから、優先度判断が適切にできること、対応が持続可能であること、形骸化しないこと、という3つを要件として掲げ、取り組みを始めました。 そこで、Security Hub CSPM による既存の検知内容に加えて、セキュリティ対応プロセスの整備に資する機能が新たに盛り込まれた Security Hub(いわゆる v2) を用いた統合的な運用管理の実施を目的に、利用の切り替えを検討しました。30日間の無料評価枠を使って実際にトライアル導入し、検知内容を確認するとともに、CSPM で得られる情報や GuardDuty / Inspector など他のセキュリティ系 AWS サービスの情報を一元的に集約する基盤として、現実的なコスト感で運用できるかを検証しました。 あくまで弊社のユースケースにおいてですが、実際にかかった料金は CSPM 単体運用時と比べて月あたり1.1〜1.5倍程度の増加にとどまりました。許容できる範囲だと判断しています。なお、この時点では hashicorp/aws プロバイダが Security Hub v2 に未対応で、 hashicorp/awscc プロバイダで構成管理する必要がありました。当時のコードは次のようなものです。 resource "awscc_securityhub_hub_v2" "main" {} resource "awscc_securityhub_aggregator_v2" "main" { region_linking_mode = "SPECIFIED_REGIONS" linked_regions = [ /* 対象リージョン */ ] } 後日 hashicorp/aws 側でも対応リソースが提供されたため 1 、本稿執筆時点では次のように書き換え済みで、 awscc プロバイダへの依存は解消しています。 resource "aws_securityhub_account_v2" "main" {} resource "aws_securityhub_aggregator_v2" "main" { region_linking_mode = "SPECIFIED_REGIONS" linked_regions = [ /* 対象リージョン */ ] depends_on = [ aws_securityhub_account_v2.main ] } トライアルの評価を経て本格導入を決め、次の5点を整備しました。 脆弱性への対応期限を定めたポリシー(Critical は7日以内、High は30日以内) EventBridge 経由でのリアルタイム Slack 通知の仕組み 評価結果を踏まえた継続利用の判断 意図的な構成に対する誤検知を自動で Suppress するルール 週次でのサマリ通知 このうちリアルタイム通知は、有効化した時点までの未処理分(バックログ)がまとめて起票されて大量の通知になってしまうため、意図的に無効の状態で構築だけを済ませています。バックログを週次レポートで消化しきってから有効化する計画です。 検知の集約はこのような形になっています。 flowchart LR CSPM["Security Hub CSPM<br/>コントロールチェック"] --> HUB GD["GuardDuty<br/>脅威検知"] --> HUB INS["Amazon Inspector<br/>脆弱性スキャン"] --> HUB HUB["Security Hub<br/>統合・相関分析"] --> WEEKLY["週次レポート起票"] HUB -. "バックログ解消後に有効化予定" .-> RT["リアルタイム Slack 通知"] 運用が固まってからは、週次レポートを起点に検知内容を確認し、必要な個別対応を切り出す運用を継続しています。この運用を支えるツールとして、Claude Code と連携した対応スキルも自分で実装しました。骨子は次のようなものです(社内固有の設定は割愛し、簡略化しています)。 --- description : 定期レポートから対応単位を切り出し、完了時に対外説明資料への反映要否まで判断する --- ## 前提 - 各検知は「イメージの脆弱性(A系)」「リソースの構成不備(B系)」「運用整備(C系)」の3カテゴリに分けて扱う - 切り出した対応単位は、完了するまでクローズしない ## 1. 対象レポートの特定 直近の未対応レポートを取得する。同一トピックが翌回のレポートに再出現した場合も、既存の対応単位を継続利用し、重複して起票しない。 ## 2. 内容の解析 レポート本文から検知内容を抽出し、カテゴリごとに整理する。抽出結果はいったんユーザーに提示し、認識のずれがないか確認する。 ## 3. 対応単位への分類 - A系: イメージ単位、担当チーム単位で集約する - B系: コントロール単位で集約する - C系: 運用上の課題として個別に扱う ## 4. 重複確認 既存の対応中issueや、関連する依存更新ツールのプルリクエストと重複しないかを確認する。 ## 5. 起票案の提示 起票が必要なものについて、タイトルと本文の案を提示する。ユーザーの承認を得てから起票し、承認前に無断で起票しない。 ## 6. クローズ時の判断 対応単位をクローズする前に、対応内容が対外説明資料へ反映すべき恒久的な改善かどうかを、以下の基準で判断する。 - 露出面を恒久的に減らしたか(公開設定の是正など) - 全環境に適用される予防的な統制を有効化し、恒久化したか - 運用フロー自体を文書化したか 該当する場合は、対外説明資料側への追記プルリクエストを起票してからクローズする。 週次レポートから対応単位を切り出すまでの流れは次の通りです。 flowchart TD WEEKLY["週次レポート"] --> ANALYZE["内容の解析"] ANALYZE --> A["A系: イメージの脆弱性"] ANALYZE --> B["B系: リソース構成の不備"] ANALYZE --> C["C系: 運用整備"] A --> DUP{"重複確認"} B --> DUP C --> DUP DUP -- "既存で対応中/決着済み" --> SKIP["既存issueに追記 or 対応不要と記録"] DUP -- "新規" --> PROPOSE["起票案を提示"] PROPOSE --> APPROVE{"承認"} APPROVE -- "承認" --> ISSUE["対応単位issueを起票"] APPROVE -- "見送り" --> SKIP 実装後も、次のような改善を重ねてきました。 sub-issue が全て完了してから親を閉じる 対応をクローズする前に、その内容が対外説明資料に反映すべき強化策かどうかを判断するステップを挟む(顧客に訴求できる改善が反映漏れになっていた運用課題への対応) 重複した対応の有無や、過去の議論によって対応不要の整理になったものがないかを確認する 運用開始後には、「稼働中の最新イメージのみを対象とする」という基準が曖昧になっていた点にも気づき、実際にデプロイされているイメージのリスクに絞る形に是正しています。 個別の対応としては、コントロールごとに次のような判断と対応を継続的に積み重ねています。 EBS ボリュームのスナップショットの公開共有ブロック を全リージョンで有効化した SSM ドキュメントの公開共有ブロック を全リージョンで有効化した AWS アカウントの集中ルートアクセス管理 を有効化した S3 へのアクセスでの通信の暗号化 を必須化した 以降、本稿で単に「Security Hub」と表記する場合は、統合版(いわゆる v2)を指します。 3. フロントエンドとバックエンドの体制調査で見えたもの Security Hub の整備と並行して、フロントエンドとバックエンドそれぞれの脆弱性管理の現状を調べました。脆弱性管理プロセスを全社的な取り組みとして整備するには、インフラ側から見える範囲だけでは不十分で、アプリケーション側の実態を踏まえる必要があったからです。 フロントエンド側を調べたところ、依存更新ツールによる自動更新、新規リリースの一定期間の待機、パッケージインストール時のスクリプト実行の無効化、未使用依存の定期削除、週次脆弱性スキャンでの自動起票という体制が、既に整備されていることを確認しました。これらは次の3層で整理できることも分かりました。 入れない(事前防御) 実行させない(インストール時の防御) 検知する(事後検知) この整理をそのまま対外説明資料の下敷きにしています。 バックエンド側は、複数のリポジトリに分かれて運用されている構成を取っています。この複数のリポジトリを横断して調査したところ、依存更新ツールの導入状況にはリポジトリごとに差がありました。 ここに Security Hub と Amazon Inspector による検知を軸としてテコ入れをおこない、各リポジトリが既に個別に持っていた取り組みを組み合わせる形で、更新に取り組む体制を構築しました。この体制は、アプリケーションが利用するライブラリへの対応が主軸です。 OS パッケージについては別枠で、次のような対応を積み重ねました。いずれも Security Hub の検知をきっかけに気づいた課題を、運用として解消したものです。 複数サービスの Dockerfile を見直し、不要なパッケージの削減やベースイメージの最新化でCVEを削減した ビルド時に OS パッケージの更新を明示的に指定し、ベースイメージの更新を待たずに CVE を解消できるようにした コンテナランタイムイメージやパブリックイメージのタグ追従を整備した ビルド時に無関係なディレクトリが混入して CVE を持ち込む構成を、複数リポジトリで是正した ベースイメージの陳腐化を検出できていなかった依存更新ツールの運用を見直し、滞留していた更新が継続的に反映される体制を整えた Amazon Inspector についても、導入からスキャン結果の取り扱い整理まで一貫して関わっています。コンテナイメージの継続的なスキャンを AWS Organizations 単位で有効化し、その後全アカウントでの自動有効化を構成管理コードに落とし込みました。スキャン結果の扱いについては、OS パッケージ由来の脆弱性は元となるベースイメージ側で担保し、言語処理系レベルのものは別途方針を検討する、という切り分けを整理しています。 4. 具体的な改善 1年あまりの取り組みで、Security Hub と Inspector の検知は、週次レポートを軸に確実に人の目に触れるようになりました。そのうえで、個別に効果が大きかったものを挙げます。 Inspector が挙げた「CVE ではなくベースOS自体がサポート対象外(EOL)になっている」という検知をきっかけに、複数サービスで使っていた共通コンポーネントのタグ運用のばらつきに気づき、バージョン管理を統一できた GuardDuty についても、ECS のランタイム監視、マルウェア対策、Lambda 保護といった機能を順次評価し、有効化し、検知に対する初動フローも整えた インフラ側で管理している Lambda 関数の依存ライブラリは、アプリケーションリポジトリの依存更新ツールの対象外にあり、Security Hub の検知が唯一の安全網になっている。実際に同じライブラリで CVE が繰り返し公表されるたびに検知し、都度更新している ECS タスクをコンテナ単位で 読み取り専用のルートファイルシステム として稼働させる改修にも着手した。数十のタスク定義・複数リポジトリにまたがるスコープの大きい対応だが、侵害時の書き込みを構造的に防げる効果は大きいと判断している 5. 対応後処理 対応からクローズまでの判断は、次のような流れになっています。 flowchart TD ISSUE["対応単位issue"] --> FIX["対応 or Suppress判断"] FIX --> JUDGE{"対外説明資料に<br/>反映すべき恒久的改善か"} JUDGE -- "該当" --> PR["対外説明資料への<br/>追記PRを起票"] JUDGE -- "非該当" --> REASON["理由をコメント"] PR --> CLOSE["クローズ"] REASON --> CLOSE ここまでの取り組みで積み上がった内容について、対外説明資料として有意義な情報となるよう整備をおこないました。まずフロントエンドの依存ライブラリ管理に関する説明を整え、その後、脆弱性管理に関する説明そのものを次の3区分に拡張しています。 依存ライブラリ サービス稼働環境 第三者による脆弱性試験 この整理にあたっては、国内外の SaaS 事業者複数社が自社のセキュリティ対策をどう説明しているかを比較調査し、顧客への伝わりやすさと抽象度の落としどころを検討しました。対外的には3区分で概要を説明し、各区分の実装詳細は社内向けの補足として別途整理しています。 こうした説明は作って終わりではありません。改訂履歴を記録する仕組みを新たに整え、過去の記載分も遡って記入し直したほか、現状に照らして正確な情報を簡潔に保てるよう、継続的な更新を重ねています。レビュー体制も整え、週1〜数回のペースで更新され続けています。 実際、この整理された説明は、顧客からのセキュリティ関連の問い合わせ対応や資料提供の場面で活用されており、営業・法務側の対応工数の削減や、顧客からの信頼獲得に直接つながっています。 6. 今後の展望 1年あまりの取り組みを経て、検知から対応までの経路は一通り組み上がりました。残っている課題は次の通りです。 バックエンドの依存更新ツールの恒久的な設定整備は、担当チームへの引き継ぎ段階にあり、実際に定着するかは継続的に見ていく必要がある リアルタイム通知は意図的に無効化したままになっており、週次レポートで検知のバックログを消化しきってから有効化する計画が残っている GuardDuty の追加保護機能の要否判断も並行して進めている 対外説明資料の継続的な更新体制を維持し、対応の実態と記述の乖離が生まれないようにすること 週次レポートの分類やクローズ前の判断、および実際の対処にかかる一連の運用を、特定の担当者に閉じない形にすること 現時点では、対応実績をつくる目的もあって、これらの判断はまだ筆者に属人化している この取り組みによって対応実績自体は整備されつつあるため、これを土台に運用の仕組みや基準としてどこまで明文化・自動化できるかが課題として残っている 着手した当初は、Security Hub の検知結果すら定常的に見られていませんでした。今は、検知を分類し、期限を持たせ、対応履歴を対外説明資料に反映するところまで進んでいます。ここから先は、この運用ループを特定の担当者に依存せず回せる形にどこまで均していけるかが焦点になります。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は、文章の8割程度を、文責者の過去記事や社内の関連ドキュメントをもとに Claude Sonnet 5 が執筆しています。 追伸:本稿の執筆・推敲にあたっては、日本語技術文書の規範をまとめた k16shikano 氏の文書 を参照しました。記して感謝します。 hashicorp/aws では v6.43.0 で aws_securityhub_account_v2 を追加する PR 、v6.45.0 で aws_securityhub_aggregator_v2 を追加する PR がそれぞれマージされ、公式提供された。 ↩
こんにちは、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指定部分などは今回はメインではないので例示からは除外しています。 ↩ レスポンスで変換済みファイルを即時返却するのではなく、裏側でイベント駆動で処理したのちに変換処理完了通知とダウンロードリンクを送るなどが考えられます ↩