TECH PLAY

MNTSQ

MNTSQ の技術ブログ

98

はじめに 既存構成 課題感 設計 CloudWatch Logs ではなく S3 に倒す クラスタ単位で S3 prefix を切る 実装 ECS タスクロールに付与する IAM ポリシー ECS クラスタの executeCommandConfiguration おわりに はじめに Amazon Linux 2 (以下 AL2) の EOL (2026 年 6 月 30 日) が近付いてくる昨今、皆様いかがお過ごしでしょうか。 弊社では SSH 踏み台として使う EC2 インスタンスを AL2 ベースで用意し、運用作業の起点として長年取り扱ってきました。運用者はここを経由して ECS タスクや各種マネージドサービスへアクセスしてきた経緯があり、単純な SSH 踏み台ではなく、運用機能を集約した実行基盤として機能してきた格好です。 前述の通り ECS 最適化 AL2 AMI の EOL が迫る状況下、これを契機にこの踏み台の処遇を決める必要が出てきました。AWS は同案内において後継として Amazon Linux 2023 (以下 AL2023) への移行を推奨しているため、これに沿えば AL2023 で素直に再構築するのが定石となります。一方、棚卸してみると踏み台はいくつもの運用基盤を兼務する構造になっており、AL2023 で再構築すれば短期的な EOL 対応は済むものの、これらの構造をそのまま AL2023 のサポート期限 (2028 年) まで持ち越すことになります。 踏み台が抱える各役割はそれぞれ別個の代替手段が出揃ってきていたため、AL2 の EOL を契機にして「踏み台ごと畳んでしまい、機能を別の代替先へ分散させる」方針を取ることにしました。本稿はその分散先のうち、運用者が ECS Exec で直接コンテナへ入る経路のセッションログを、アプリログとは別系統で取り扱う仕組みの話です。 既存構成 踏み台を経由する従来構成では、運用者の操作ログは踏み台側でまるごと取得できていました。踏み台を廃止して ECS Exec へ切り替えると、この経路が無くなります。 ECS Exec の出力先はクラスタ単位の executeCommandConfiguration で制御できます。 logging が DEFAULT のまま (= 明示設定なし) だと、タスク定義側で指定された awslogs に運用者のターミナル出力が同居する格好となります。アプリログには Firelens 経由で CloudWatch Logs / S3 / Datadog の 3 系統へ流すルーティングがすでに敷かれているため、ここに ECS Exec のセッションログが乗っかると、出力先・保管期間・閲覧権限がアプリログ側の都合に縛られてしまいます。 課題感 運用者の操作ログとアプリログが同じ経路で混ざってしまうと、以下のような不便が出てきます。 後から運用者の操作だけを切り出して追うのに難儀する アプリログのライフサイクルに引きずられて長期保管も難しくなる そこで運用者の操作ログを専用 S3 バケットへ独立して書き出すように整備しました。本稿ではその設計判断と実装手順を取り扱います。 設計 新たに専用 S3 バケットを設け、ECS Exec のセッションログをすべてここへ集約することにしました。 CloudWatch Logs ではなく S3 に倒す ECS Exec の出力先は S3 と CloudWatch Logs のいずれか (あるいは両方) を選べますが、本件は S3 単独としました。理由は次のとおりです。 既存の SSM Session Manager の操作ログを同じく専用 S3 バケットに集約しており、運用者の操作ログは「S3 集約してから Athena で横断的に追う」運用と既に親和性がある 保管期間が長く読み出し頻度の低いログを置く先として、CloudWatch Logs より S3 のほうがコスト効率に優れる ライフサイクル制御が CloudWatch Logs より自由に効き、長期保管要件に応じて低頻度アクセス向けの STANDARD_IA やアーカイブ向けの GLACIER_IR を組み合わせて吊るしで設計できる ここでいう「S3 集約してから Athena で横断的に追う」経路は、業務時間外の不審操作を自動検知して Slack 通知する社内の仕組みである A2RM (監査回答強制マン) の動作基盤にもなっています。ECS Exec ログを同経路に乗せておくことで、将来同じ枠組みで取り扱える余地が生まれます。 https://tech.mntsq.co.jp/entry/2026/03/17/114506 クラスタ単位で S3 prefix を切る ECS Exec ログを書き出す S3 オブジェクトキーには、クラスタ側の executeCommandConfiguration で任意の接頭辞を指定できます。本件ではこれを ${env}-${service}-${cluster_id}/ というクラスタ単位の名前空間にしてあります。複数サービスの ECS Exec ログが同じバケット内に同居するため、接頭辞をクラスタ単位で分けておかないと、後から Athena でクエリするときにフィルタ条件が複雑化します。 実装 ECS Exec ログの分離整備は次の 2 段階に分けて投入しました。 専用 S3 バケットの新設と、ECS タスクロールへの S3 関連権限の追加 ECS クラスタの executeCommandConfiguration を logging = OVERRIDE に切り替え 順序依存があるため、必ず 1 を先に着地させてから 2 を投入する必要があります。クラスタ側の executeCommandConfiguration を OVERRIDE に切り替えた瞬間、運用者が ECS Exec で接続する度に、タスクロールが当該 S3 バケットに対して以下の API を呼ぶようになるためです。 s3:GetBucketLocation s3:GetEncryptionConfiguration (バケット側で s3_bucket_encryption_enabled = true を設定しているため) s3:PutObject これらに対する IAM 権限がタスクロール側に揃っていない状態でクラスタを切り替えると、運用者が ECS Exec を叩いた瞬間に AccessDenied で落ちます。踏み台廃止に向けて ECS Exec の信頼性を担保したい局面でこれが起きると本末転倒なので、IAM 整備を先に着地させてからクラスタ切替を投入する順序を踏むのが安全です。 ECS タスクロールに付与する IAM ポリシー ECS タスクが ECS Exec のセッションを開き、その出力ログを S3 バケットへ書き出すために必要な権限を、タスクロールへアタッチするポリシーとして以下のように定義します。 IAM policy document の Terraform 定義 data "aws_iam_policy_document" "ecs_exec" { # SSM Agent によるセッション確立に必要 statement { actions = [ "ssmmessages:OpenDataChannel" , "ssmmessages:OpenControlChannel" , "ssmmessages:CreateDataChannel" , "ssmmessages:CreateControlChannel" , ] resources = [ "*" ] } # ECS Exec ログを専用 S3 バケットへ書き出すために必要 statement { actions = [ "s3:GetBucketLocation" ] resources = [ "*" ] } statement { actions = [ "s3:GetEncryptionConfiguration" ] resources = [ aws_s3_bucket.ecs_exec_logs.arn ] } statement { actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.ecs_exec_logs.arn } /*" ] } } s3:GetBucketLocation はバケットのリージョン解決に、 s3:GetEncryptionConfiguration はセッション開始時のバケット暗号化設定の検証に、 s3:PutObject は実際のログ書き出しにそれぞれ必要となります。 s3:GetEncryptionConfiguration はバケット ARN に絞った権限とすることで、不要な走査を抑制できます。 ECS クラスタの executeCommandConfiguration ECS クラスタの configuration.execute_command_configuration に出力先 S3 バケットと接頭辞、暗号化検証の有効化を指定します。 aws_ecs_cluster の Terraform 定義 resource "aws_ecs_cluster" "main" { # ... クラスタ自体の既存設定 ... configuration { execute_command_configuration { logging = "OVERRIDE" log_configuration { s3_bucket_name = aws_s3_bucket.ecs_exec_logs.bucket s3_key_prefix = "$ { var.env } -$ { var.service } -$ { var.cluster_id } /" s3_bucket_encryption_enabled = true } } } } logging = "OVERRIDE" で明示設定モードへ切り替え、 log_configuration でその内容を与える格好です。 s3_bucket_encryption_enabled = true を有効にすると、セッション開始時に SSM Agent がバケット側の暗号化設定を s3:GetEncryptionConfiguration で検証する経路に倒れます。 おわりに 本稿では、踏み台廃止に向けて運用者の ECS Exec セッションログを専用 S3 バケットへ分離した取り組みについて、設計判断と実装手順の両面から取り扱いました。 ECS Exec で運用者がコンテナ内で叩いたコマンドは、平時はあまり関心をむけられることの少ない内容です。しかし、いざ追跡や監査が必要になったときに参照先がアプリログと混ざっているか独立しているかで、後の動きやすさはずいぶん変わります。整備した瞬間に何かが大きく変わるわけではないものの、踏み台廃止のように接続経路を切り替える場面で後からじわじわ効いてくる類の作りだと思います。 ECS Exec のセッションログをアプリログとは別経路へ分離する作りは、要点さえ押さえれば素直に組めるものになっています。同じような整備に取り組む方の一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.7 が作成した内容を8割程度そのまま使用しています
はじめに SREの寺島です。 MNTSQでは継続的なコスト最適化を進めており、SREチームでもこれまでいくつかの削減施策を実施してきました。本記事では、その中からNAT Gatewayのデータ処理料金の削減に向けた取り組みを紹介します。 結果として、NAT Gatewayのデータ処理料金を約70%削減することに成功しました。今回は、コスト増の原因特定から、具体的な対応、そして効果測定にいたるまでの一連の流れをお届けします。 はじめに まずは Cost Explorer でコストの把握をする NAT Gateway の通信内容を調査する VPC Flow Logs テーブル定義 集計クエリ Route 53 Resolver Query Logs テーブル定義 IP からホスト名を引くクエリ 集計結果 ECR Public が CloudFront 経由で配信されていることを curl で確認する 通信量を削減できるか検討する Interface Endpoint と Gateway Endpoint 施策別の削減効果の試算 VPC Endpoint と Pull Through Cache での通信削減 Interface VPC Endpoint の追加 ECR Pull Through Cache の導入 ECS タスク定義の書き換え 結果 まとめ 関連記事 まずは Cost Explorer でコストの把握をする AWS のコストの内訳は Cost Explorer で確認できます。最初に大まかにどのサービスがコストの多くを占めているのかを把握しました。 レポートのパラメータは以下の値を設定し、サービスごとのコストを確認します。 グループ化の条件 ディメンション: サービス 弊社ではコストの多くを占めているのは ECS、RDS、OpenSearch、EC2 インスタンスでした。これらは既に Reserved Instance / Savings Plans を購入済みでインスタンスサイズも最適化済みのため、次いで料金が高かった EC2 - Other の内訳を確認することにしました。 EC2 - Other の中身を見るために、レポートのパラメータを以下のように変更します。 グループ化の条件 ディメンション: 使用タイプ 適用フィルター サービス: EC2 - Other 使用タイプ(Usage Type) は AWS のリソース・API 単位でコストを分解できるディメンションです。 NatGateway-Bytes のようにサービス内の課金項目単位で内訳を見たいときに使います。 結果として、 EC2 - Other の中で約3~4割を NatGateway-Bytes が占めていることが分かりました。 NatGateway-Bytes は NAT Gateway を通過したデータ量に応じて課金される項目なので、通信量を減らせばそのままコスト削減に直結します。 ただ、Cost Explorer から分かるのはNAT Gateway 経由でこれだけの通信があったという総量だけで、その内訳(何の通信が大半を占めているか)までは分かりません。削減できる余地があるかを判断するために、NAT Gateway を通っている通信の中身を詳しく調査することにしました。 NAT Gateway の通信内容を調査する NAT Gateway のデータ処理料金を削減するには、どの通信が大半を占めているのかを特定する必要があります。今回は VPC Flow Logs と Route 53 Resolver Query Logs を組み合わせて調査しました。 VPC Flow Logs VPC Flow Logs は、VPC 内の ENI を通過する通信のメタデータを記録するログです。送信元 IP、宛先 IP、ポート、プロトコル、バイト数などが記録されます。弊社では事前に VPC Flow Logs を S3 に出力する設定を入れていたため、Athena からクエリを発行できる状態になっていました。 調査の流れは以下の通りです。 マネジメントコンソールまたは aws ec2 describe-nat-gateways から、NAT Gateway の ENI ID を取得する Athena で VPC Flow Logs のテーブルに対し、 interface_id を NAT Gateway の ENI ID に絞り、 dstaddr (宛先 IP)でグルーピングして送受信バイト数を集計する 上位の宛先 IP を抽出する テーブル定義 S3 に出力した VPC Flow Logs を Athena から読むためのテーブル定義は以下のような形です(AWS 公式ドキュメントの VPC Flow Logs のテーブル作成例 をベースにしています)。 CREATE EXTERNAL TABLE IF NOT EXISTS production ( version int , account_id string, interface_id string, srcaddr string, dstaddr string, srcport int , dstport int , protocol bigint, packets bigint, bytes bigint, start bigint, ` end ` bigint, action string, log_status string, vpc_id string, subnet_id string, instance_id string, tcp_flags int , type string, pkt_srcaddr string, pkt_dstaddr string, az_id string, sublocation_type string, sublocation_id string, pkt_src_aws_service string, pkt_dst_aws_service string, flow_direction string, traffic_path int ) PARTITIONED BY ( `day` string ) ROW FORMAT DELIMITED FIELDS TERMINATED BY ' ' LOCATION ' s3://<your-flow-logs-bucket>/AWSLogs/<account_id>/vpcflowlogs/ap-northeast-1/ ' TBLPROPERTIES ( ' skip.header.line.count ' = ' 1 ' , ' projection.enabled ' = ' true ' , ' projection.day.type ' = ' date ' , ' projection.day.range ' = ' 1970/01/01,NOW ' , ' projection.day.format ' = ' yyyy/MM/dd ' , ' storage.location.template ' = ' s3://<your-flow-logs-bucket>/AWSLogs/<account_id>/vpcflowlogs/ap-northeast-1/${day} ' ); 集計クエリ 実際に NAT Gateway 経由のアウトバウンド通信(VPC → 外部)を集計したクエリは以下のような形です。ENI ID は production の VPC に紐づく NAT Gateway 3 台分(3 AZ)を指定しています。 SELECT dstaddr, dstport, SUM (bytes) / POWER ( 1024.0 , 3 ) AS gb, SUM (packets) AS pkts, COUNT (*) AS flows FROM vpc_flow_log.production WHERE day BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND interface_id IN ( ' eni-xxxxxxxxxxxxxxxx1 ' , ' eni-xxxxxxxxxxxxxxxx2 ' , ' eni-xxxxxxxxxxxxxxxx3 ' ) AND srcaddr LIKE ' 10.x.x.% ' -- VPC CIDR (内側起点) AND dstaddr NOT LIKE ' 10.x.x.% ' -- 外部宛 (NAT 越え) GROUP BY dstaddr, dstport ORDER BY gb DESC LIMIT 100 ; interface_id に NAT Gateway の ENI ID を、 srcaddr / dstaddr の LIKE 条件に VPC CIDR を指定することで、「VPC 内発・外部宛」の通信に絞り込んでいます。 このクエリを実行すると、以下のような形式の結果が返ってきます(値は例示)。 dstaddr dstport gb pkts flows 3.233.158.83 443 47.86 35,123,456 525,152 142.250.21.95 443 24.91 1,234,567 66,344 3.163.251.13 443 3.96 8,765,432 183,895 ... ... ... ... ... 各カラムの意味は以下の通りです。 dstaddr / dstport : 宛先 IP とポート gb : 通信量(バイト数を GB に換算) pkts : パケット数の合計 flows : Flow Logs のレコード件数 なお、NAT Gateway のデータ処理料金はアウトバウンド・インバウンド両方向に課金されるため、調査の際は両方向を集計しておく必要があります。 インバウンド(外部 → VPC、リプライ)を集計したい場合は、上のクエリから以下の差分で書き換えます。 - SELECT dstaddr, - dstport, + SELECT srcaddr, + srcport, SUM(bytes) / POWER(1024.0, 3) AS gb, ... - AND srcaddr LIKE '10.x.x.%' -- VPC CIDR (内側起点) - AND dstaddr NOT LIKE '10.x.x.%' -- 外部宛 (NAT 越え) - GROUP BY dstaddr, dstport + AND dstaddr LIKE '10.x.x.%' -- VPC CIDR (内側着) + AND srcaddr NOT LIKE '10.x.x.%' -- 外部発 (NAT 越えのリプライ) + GROUP BY srcaddr, srcport Route 53 Resolver Query Logs VPC Flow Logs だけだと、宛先が IP アドレスでしか分からないため、どのサービス宛の通信かが直感的に判別できません。AWS の ip-ranges.json と突き合わせれば AWS サービスかどうかは分かりますが、これは AWS が提供するサービスの IP レンジしかカバーしていません。NAT Gateway を通る通信には Datadog などの外部サービス宛のものも含まれているため、それらの IP も合わせて名寄せできる仕組みが必要でした。また、AWS サービス内でも CloudFront 経由のエンドポイントなど共有 IP のケースでは、IP レンジだけでは具体的な FQDN まで特定できません。 そこで Route 53 Resolver Query Logs を使います。これは VPC 内から発行された DNS クエリのログで、「どの FQDN がどの IP に解決されたか」が記録されます。AWS サービスか外部サービスかを問わず、VPC 内から名前解決された宛先はすべてここに記録されるため、VPC Flow Logs の宛先 IP と突き合わせることで、IP の先にあったホスト名を特定できます。 テーブル定義 Resolver Query Logs を S3 に出力したものを Athena から読むためのテーブル定義は以下のような形です(こちらも AWS 公式ドキュメントの Route 53 Resolver Query Logs のテーブル作成例 をベースにしています)。 CREATE EXTERNAL TABLE IF NOT EXISTS production ( version string, account_id string, region string, vpc_id string, query_timestamp string, query_name string, query_type string, query_class string, rcode string, answers array< struct< Rdata: string, Type : string, Class: string> >, srcaddr string, srcport int , transport string, srcids struct< instance: string, resolver_endpoint: string >, firewall_rule_action string, firewall_rule_group_id string, firewall_domain_list_id string ) PARTITIONED BY ( ` date ` string ) ROW FORMAT SERDE ' org.openx.data.jsonserde.JsonSerDe ' STORED AS INPUTFORMAT ' org.apache.hadoop.mapred.TextInputFormat ' OUTPUTFORMAT ' org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat ' LOCATION ' s3://<your-resolver-logs-bucket>/AWSLogs/<account_id>/vpcdnsquerylogs/<vpc_id>/ ' TBLPROPERTIES ( ' projection.enabled ' = ' true ' , ' projection.vpc.type ' = ' enum ' , ' projection.vpc.values ' = ' <vpc_id> ' , ' projection.date.type ' = ' date ' , ' projection.date.range ' = ' 1970/06/26,NOW ' , ' projection.date.format ' = ' yyyy/MM/dd ' , ' projection.date.interval ' = ' 1 ' , ' projection.date.interval.unit ' = ' DAYS ' , ' storage.location.template ' = ' s3://<your-resolver-logs-bucket>/AWSLogs/<account_id>/vpcdnsquerylogs/<vpc_id>/${date}/ ' ); answers カラムは構造体の配列になっており、1 つの DNS クエリに対する複数の回答(A レコードが複数返るケース等)が入っています。後述するクエリでは UNNEST で展開して使います。 IP からホスト名を引くクエリ VPC Flow Logs の集計結果(宛先 IP)と Resolver Query Logs を JOIN して、IP の先にあったホスト名を特定します。実際に使ったクエリは以下のような形です。 WITH flow AS ( SELECT dstaddr, dstport, SUM (bytes) / POWER ( 1024.0 , 3 ) AS gb, SUM (packets) AS pkts, COUNT (*) AS flows FROM vpc_flow_log.production WHERE day BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND interface_id IN ( ' eni-xxxxxxxxxxxxxxxx1 ' , ' eni-xxxxxxxxxxxxxxxx2 ' , ' eni-xxxxxxxxxxxxxxxx3 ' ) AND srcaddr LIKE ' 10.x.x.% ' AND dstaddr NOT LIKE ' 10.x.x.% ' GROUP BY dstaddr, dstport ), dns AS ( SELECT t.answer.Rdata AS ip, array_agg( DISTINCT query_name) AS domains FROM route53_resolver_query_log.production CROSS JOIN UNNEST(answers) AS t(answer) WHERE date BETWEEN ' 2026/04/10 ' AND ' 2026/04/16 ' AND t.answer. Type = ' A ' GROUP BY t.answer.Rdata ) SELECT f.dstaddr, f.dstport, f.gb, f.flows, d.domains FROM flow f LEFT JOIN dns d ON f.dstaddr = d.ip ORDER BY f.gb DESC LIMIT 100 ; flow CTE で前述のアウトバウンド集計をそのまま使い、 dns CTE で answers を CROSS JOIN UNNEST で展開して A レコードに絞り、 ip → domains のマップを作っています。最後に Flow Logs の dstaddr と DNS 解決結果の ip を JOIN することで、「宛先 IP の先にあったドメイン群」と「通信量」をセットで取得できます。 なお、 array_agg(DISTINCT query_name) を使っているのは、同じ IP に対して複数のホスト名が解決されることがあるためです(CloudFront のように 1 つの IP が多数の FQDN に紐づくケースが典型)。 このクエリを実行すると、以下のような形式の結果が返ってきます(値は例示)。 dstaddr dstport gb flows domains 3.163.251.13 443 1,557.42 1,432,100 [d5l0dvt14r5h8.cloudfront.net] 3.233.158.83 443 47.86 525,152 [trace.agent.datadoghq.com] 142.250.21.95 443 24.91 66,344 [www.googleapis.com, aiplatform.googleapis.com, vision.googleapis.com] ... ... ... ... ... domains カラムには、その IP に解決された FQDN の配列が入ります。Google APIs のように複数のサービス名が並ぶケースもあれば、Datadog の APM trace のように 1 つの FQDN だけが入るケースもあります。 集計結果 上記のログを使って NAT Gateway 経由の通信を集計した結果、上位を占めていたのは以下の通信先でした(一部、通信先は除外しています)。 インバウンド(外部 → VPC、レスポンス受信) 順位 通信先 備考 1 d5l0dvt14r5h8.cloudfront.net (CloudFront 経由の ECR Public の実体) image layer の実体配信 2 Google APIs ( *.googleapis.com ) OCR / AI 処理のレスポンス 3 Datadog ( *.datadoghq.com 系の trace / intake / config エンドポイント) 4 CloudWatch Logs ( logs.ap-northeast-1.amazonaws.com ) 5 SQS ( sqs.ap-northeast-1.amazonaws.com ) アウトバウンド(VPC → 外部) 順位 通信先 備考 1 Google APIs ( *.googleapis.com ) OCR / AI 処理向けの画像アップロード 2 Datadog ( *.datadoghq.com 系の trace / logs / process / intake) 3 CloudWatch Logs ( logs.ap-northeast-1.amazonaws.com ) Firelens 経由のログ送信 4 SQS ( sqs.ap-northeast-1.amazonaws.com ) 5 Firehose ( firehose.ap-northeast-1.amazonaws.com ) 通信量で見ると、 インバウンド側の ECR Public からの image layer 配信が突出して大きい という結果になりました。 d5l0dvt14r5h8.cloudfront.net は一見すると AWS のサービスかどうか分かりにくいドメインですが、これは ECR Public のイメージレイヤー配信に使われている CloudFront ディストリビューション の実体です。ECR Public Gallery ( public.ecr.aws ) は API 部分は別ホストで動いており通信量は僅かですが、イメージレイヤーの blob ダウンロードは CloudFront 経由で配信される仕組みになっています。 弊社では元々 VPC に S3 Gateway Endpoint しか設定しておらず、ECS タスクから public.ecr.aws/datadog/agent:latest などのサイドカーイメージを pull する通信や CloudWatch Logs / SQS 宛の AWS API 通信は、すべて NAT Gateway を経由していました。 ECR Public が CloudFront 経由で配信されていることを curl で確認する d5l0dvt14r5h8.cloudfront.net が ECR Public のイメージレイヤー配信用 CloudFront ディストリビューションである、という点について補足します。 AWS の公式ドキュメントで明確に説明している資料は限定的ですが、 EKS Anywhere のドキュメント では d5l0dvt14r5h8.cloudfront.net (for EKS Anywhere package ECR container images) と記載されており、ECR コンテナイメージの配信用であることが言及されています。 これに加えて、レジストリ API の挙動を curl で実際に確認することもできます。ECR Public からイメージレイヤー(blob)を取得しようとすると、HTTP 307 Redirect で CloudFront に飛ばされる仕組みになっており、その redirect 先のホストを直接見られます。手順は以下の通りです。 # 1. ECR Public の匿名トークンを取得 TOKEN=$(curl -s "https://public.ecr.aws/token/" | jq -r .token) # 2. イメージのマニフェストからレイヤーの digest を取得 # datadog/agent はマルチアーキ対応のため、まずマニフェストリストから # アーキ別マニフェストの digest を引き、そこから layer digest を取る MANIFEST_DIGEST=$(curl -s \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ "https://public.ecr.aws/v2/datadog/agent/manifests/latest" | jq -r '.manifests[0].digest') LAYER_DIGEST=$(curl -s \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "https://public.ecr.aws/v2/datadog/agent/manifests/$MANIFEST_DIGEST" | jq -r '.layers[0].digest') # 3. blob を取りに行く(リダイレクトを追わずヘッダのみ確認) curl -sI -X GET \ -H "Authorization: Bearer $TOKEN" \ "https://public.ecr.aws/v2/datadog/agent/blobs/$LAYER_DIGEST" | grep -E "^(HTTP|location)" 出力: HTTP/2 307 location: https://d5l0dvt14r5h8.cloudfront.net/v2/.../?... public.ecr.aws/v2/<repo>/blobs/<digest> が d5l0dvt14r5h8.cloudfront.net 配下の URL に 307 redirect していることが確認できます。 aws-for-fluent-bit など他のイメージで試しても、同じ CloudFront ドメインに redirect されます。 なお、ECR Public が使う CloudFront ドメインは時期によって変わる可能性があるので、自環境で同様の調査をする場合は上記の手順で実際の redirect 先を確認するのが確実です。 通信量を削減できるか検討する 通信内容が見えてきたので、削減方針を検討します。 Interface Endpoint と Gateway Endpoint VPC 内から AWS のサービスに NAT Gateway を経由せずアクセスするには、VPC Endpoint を使います。VPC Endpoint には 2 種類あります。 Gateway Endpoint : S3 と DynamoDB のみ対応。 追加料金なし (ルートテーブル経由でルーティングされる) Interface Endpoint : ほとんどの AWS サービスに対応。 AZ ごとに ENI が立ち、時間課金 + データ処理料金がかかる S3 は既に Gateway Endpoint があるので追加コストなしで NAT Gateway を回避できています。その他のAWSサービスに関しては Interface Endpoint で対応する必要があります。 施策別の削減効果の試算 Interface Endpoint はただ作れば全部安くなるわけではなく、Endpoint 自体の固定費(AZ 数 × 時間課金)と、NAT Gateway を通っていたデータ処理料金の削減額を比較する必要があります。NAT Gateway 経由の通信量が少ないサービスに Endpoint を作ると、むしろコストが増えるケースもあります。 前提となる ap-northeast-1 の単価は以下です(記事執筆時点の AWS の公称料金)。 NAT Gateway : データ処理料金 $0.062 / GB Interface VPC Endpoint : $0.014 / 時間 × AZ 数 の固定費 + データ処理料金 $0.01 / GB この単価に集計結果の通信量を当てはめ、施策ごとに整理したのが以下の表です(実数値は伏せ、大小関係だけ示しています)。 施策 削減対象の通信量 純削減額 Pull Through Cache + ECR API / DKR Endpoint 突出して大 ◎ 大幅プラス CloudWatch Logs Interface Endpoint 中 ○ 小幅プラス SQS Interface Endpoint 小 △ ほぼ損益分岐(採用は見送り) Datadog PrivateLink 中 △ ほぼ損益分岐(採用見送り) Datadog は対象 Endpoint の数で結果が大きく変わります。APM trace 単独に絞れば損益分岐、複数 Endpoint を貼ると固定費が積み上がって赤字側に振れます。今回はコストメリットがほとんどなかったため、PrivateLinkの採用は見送り、通信量が今後増えてきた段階で、導入を再検討する想定です。 ここまでの試算から、 最優先で対応すべきは Pull Through Cache(+ ECR API / DKR Endpoint)であり、合わせて CloudWatch Logs Endpoint も入れる 、という方針が確定しました。その他の AWS API 通信(SSM、Secrets Manager など)は今回の集計では上位に来ていなかったため、対象外としています。 VPC Endpoint と Pull Through Cache での通信削減 上記の方針を踏まえて、以下 3 つを実装しました。 ECR API / ECR DKR / CloudWatch Logs の Interface VPC Endpoint 追加 ECR Pull Through Cache の導入 ECS タスク定義の image 参照を Pull Through Cache 経由に書き換え Interface VPC Endpoint の追加 3 つの Interface Endpoint を追加しました。Terraform で書くと以下のようになります。 module "vpc_endpoints" { # ... endpoints = { s3 = { # 既存の S3 Gateway Endpoint(省略) } ecr_api = { service = "ecr.api" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -ecr-api-vpc-endpoint" } } ecr_dkr = { service = "ecr.dkr" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -ecr-dkr-vpc-endpoint" } } logs = { service = "logs" service_type = "Interface" subnet_ids = module.vpc.private_subnets private_dns_enabled = true tags = { Name = "$ { module.vpc.name } -logs-vpc-endpoint" } } } } ECR Pull Through Cache の導入 Interface VPC Endpoint を追加することで <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com 宛の通信は VPC 内で完結しますが、 public.ecr.aws/... のイメージは ECR Publicから取得するため、Interface VPC Endpoint の対象外です。 ここで使えるのが ECR Pull Through Cache です。これは「 public.ecr.aws などの upstream registry のイメージを、自アカウントの private ECR にキャッシュとして取り込む」機能です。初回 pull 時にキャッシュ側にイメージが取り込まれ、以降は自アカウントの ECR から pull できます。private ECR への pull は Interface VPC Endpoint 経由で完結するため、NAT Gateway を通らなくなります。 詳細な設定手順や仕様は AWS 公式の Creating a pull through cache rule も参照してください。 Terraform で設定するのは以下のリソースです。 resource "aws_ecr_pull_through_cache_rule" "ecr_public" { ecr_repository_prefix = "ecr-public" upstream_registry_url = "public.ecr.aws" } これを設定すると、 <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/<namespace>/<image>:<tag> という URL で pull できるようになります。 ecr_repository_prefix で指定した ecr-public/ の配下に、upstream のリポジトリ名がそのまま展開される形です。 初回 pull のときに ecr-public/datadog/agent のような private リポジトリが自動作成されます。この自動作成と upstream からのイメージ取り込みに権限が必要なため、IAM Policy を別途用意します。 data "aws_iam_policy_document" "ecr_pull_through_cache" { statement { effect = "Allow" actions = [ "ecr:BatchImportUpstreamImage" , "ecr:CreateRepository" , ] resources = [ "arn:aws:ecr:$ { data.aws_region.current.id } :$ { data.aws_caller_identity.self.account_id } :repository/$ { aws_ecr_pull_through_cache_rule.ecr_public.ecr_repository_prefix } /*" , ] } } resource "aws_iam_policy" "ecr_pull_through_cache" { name = "$ { var.env } -$ { var.service } -ecr-pull-through-cache" description = "Allow importing images from upstream registry via ECR Pull Through Cache" policy = data.aws_iam_policy_document.ecr_pull_through_cache.json } この Policy を ECS の task execution role に attach することで、タスク起動時の初回 pull が成功するようになります。これを忘れると、初回 pull 時に AccessDeniedException が出てタスクが起動しません。 ECS タスク定義の書き換え Pull Through Cache 経由でイメージを pull するには、ECS のタスク定義で public.ecr.aws/... を参照している箇所を書き換える必要があります。 書き換えた対象は、各サービスで共通して使っている Datadog Agent と aws-for-fluent-bit(Firelens)のサイドカーが中心です。 - "image": "public.ecr.aws/datadog/agent:latest", + "image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/datadog/agent:latest", - "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:init-2.32.2", + "image": "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/aws-observability/aws-for-fluent-bit:init-2.32.2", 書き換えたタスク定義をデプロイしたあとは、ECS コンソールから Pull Through Cache 経由で pull されているかを確認できます。タスクの詳細画面のコンテナイメージ欄に、書き換え後の <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/ecr-public/... という URL が表示されていれば想定通りに動作しています。 あわせて ECR のコンソールを開くと、 ecr-public/datadog/agent のような Pull Through Cache 用のプライベートリポジトリが自動作成されているはずです。 結果 対応の完了後、Cost Explorer で NatGateway-Bytes の推移を確認したところ、対応前と比べて約 70% 減少しました。2026/05/17 に各環境で対応を反映しており、グラフでもその日を境にデータ処理料金が大きく下がっているのが確認できます。 また、VPC Flow Logs で通信内容を再集計したところ、ECR Public( d5l0dvt14r5h8.cloudfront.net )、CloudWatch Logsの通信が大幅に削減されていることを確認できました。Pull Through Cache と Interface VPC Endpoint が意図通りに効いていることが確認できます。 一方で、対応後に通信量の上位を占めているのは Datadog 系(APM trace、agent flares、logs intake など)と Google APIs(Vision / AI Platform 系)でした。どちらもサービスのスケールや AI 系機能の拡充に伴って今後さらに増えていくことが想定されます。Datadog は通信量が増えていけば、PrivateLink 導入が次の打ち手として浮上してきそうです。Google APIs は AWS 外のサービスで VPC Endpoint の対象外なので、コスト面の対策はアプリケーション側での見直しが必要になります。 まとめ 本記事では以下の流れでNat Gatewayのコストを削減した事例を紹介しました。 Cost Explorer を使ったコスト内訳の把握 VPC Flow Logs と Route 53 Resolver Query Logs を組み合わせた NAT Gateway 経由の通信内容の特定 VPC Endpointの単価と通信量から施策の費用対効果の試算 Interface VPC Endpoint(ECR API / ECR DKR / CloudWatch Logs)と ECR Pull Through Cache によるデータ処理料金の削減 今回の調査がスピーディーに進んだ最大の要因は、前提として VPC Flow Logs と Route 53 Resolver Query Logs が既に S3 へ出力されていたことでした。万が一のトラブルや突発的な調査に備え、日頃からログを溜めておく体制づくりを強くおすすめします。 NAT Gateway はインフラ構築当初は通信量が少なくデータ処理料金が目立ちませんが、サービスがスケールするにつれて気づかないうちに通信量が増えてコストを圧迫します。NAT Gatewayのコスト削減を検討している方がいれば、ぜひ参考にしてみてください。 関連記事 同様の NAT Gateway コスト削減に関する事例として、以下の記事も参考になります。 NATゲートウェイの通信内容を調査して対策し、コストを約60%削減した話 - ZOZO TECH BLOG Amazon ECRプルスルーキャッシュを使ってみた - DMM Developers Blog
はじめに 「監視モニタリングのIaCとか机上の空論だろ。労力とリターンが見合わんわ」 …と思っていた時期が私にもありました(慣用句) 前回の記事 でも少し触れましたが、 AIエージェントの登場によってDatadog × Terraformのような監視モニタリングのIaCの実践が劇的に楽になり 、気づけば手動でポチポチとモニタリングの設定をする運用の方が限りなく非効率になってしまいました。 AIエージェントをどう利用するかという部分は、まだまだ過渡期であり皆さま試行錯誤中ではあると思いますが、 弊社SREチームではAIエージェントを活用し 、 モニタリング対象の飛躍的な拡充 、 モニタリングコード品質の大幅な改善 、 運用負荷の劇的な軽減 を実現できました。そこで、どのような取り組みを行い、これを実現したかを紹介したいと思います。 ※ 本記事で扱うのはDatadog × Terraform での実践内容となります 前回の記事はこちら tech.mntsq.co.jp はじめに これまで: 監視モニタリングのIaCは重い課題だった AIエージェントの登場で何が変わったか 実装はブラックボックスで良い 規約によって品質を揃える 監視モニタリングIaCの実践例 コードを最小に保つ多層構成 実装サンプル 弊社で運用している監視モニタリングの規模 おわりに これまで: 監視モニタリングのIaCは重い課題だった 一般的にSaaSを運営している開発組織では、本番環境のみではなくステージング環境、開発環境など複数の環境を管理しています。モニタリングを真面目にやろうとすると、 「環境数 × 対象」でモニタやダッシュボードが乗算的に増えていく ため、手動で管理はほぼ不可能に近いです。(頑張って作ったとしても、細かな変更を全体に反映できず、結局保守はできない) 弊社の場合、ダッシュボードは本当に重要な対象に絞って本番環境だけで整備、モニタはマルチアラートを利用して数を減らすなどの工夫で凌いでいましたが、やはり保守の手間はなかなか重いものでした。 「じゃあコード管理すれば良いのでは?」と思うかもしれませんが...... Fargate用ダッシュボード CPU、メモリ、エフェメラルストレージのウィジェットを記載するコードの一部 # ── Fargate サービスのメトリクスウィジェット ── fargate_widgets = { for svc in var.services : svc.ecs_service => [ # CPU使用率 { definition = { title = "CPU使用率(%, コンテナ単位)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "query1 / query2 * 100" }] queries = [ { name = "query1" data_source = "metrics" query = "avg:container.cpu.usage{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } , { name = "query2" data_source = "metrics" query = "avg:container.cpu.limit{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 2 , width = local.widget_width, height = 3 } } , # メモリ使用率 { definition = { title = "メモリ使用率(%, コンテナ単位)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "query1 / query2 * 100" number_format = { unit = { type = "canonical_unit" unit_name = "percent" } } }] queries = [ { name = "query1" data_source = "metrics" query = "max:container.memory.usage{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } , { name = "query2" data_source = "metrics" query = "max:container.memory.limit{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } ,$ { local.container_filter [ svc.ecs_service ]} } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 5 , width = local.widget_width, height = 3 } } , # エフェメラルストレージ { definition = { title = "エフェメラルストレージ空き領域(%)" title_size = "16" title_align = "left" show_legend = true legend_layout = "auto" legend_columns = [ "avg" , "min" , "max" , "value" , "sum" ] type = "timeseries" requests = [{ formulas = [{ formula = "(query2 - query1) / query2 * 100" }] queries = [ { name = "query2" data_source = "metrics" query = "max:ecs.fargate.ephemeral_storage.reserved{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } } by {task_arn}" } , { name = "query1" data_source = "metrics" query = "max:ecs.fargate.ephemeral_storage.utilized{$ { local.service_tag [ svc.ecs_service ]} :$ { svc.ecs_service } } by {task_arn}" } ] response_format = "timeseries" style = { palette = "dog_classic" order_by = "values" line_type = "solid" line_width = "normal" } display_type = "line" }] } layout = { x = 0 , y = 8 , width = local.widget_width, height = 3 } } ] if !svc.is_ec2 } これは流石に無理では......?何かを追加したくなる度に、どのように宣言すれば良いかを調べ、↑のようなコードを書かなければいけないわけです。少なくとも私はダッシュボードを1つ作成する前にPCを叩き割る自信があります。 そもそも IaCは宣言的な記述が求められる故に常に一定の学習コストがかかり 、自分が得意とする領域か、やらないことが許されない状況(プロダクトのインフラとか)でもない限りなかなか実践できないというのが現状だったのではないでしょうか。少なくとも、監視モニタリング領域でIaCを実践するのは、確保できる工数と照らし合わせると、不可能に近いというのが、弊社の実態でした。 AIエージェントの登場で何が変わったか ここに大きな転機が来ました。 冗長なコードを人間が理解する必要がなくなった のです。 実装はブラックボックスで良い これまでなら、新しい監視をひとつ足すたびに次のような作業が必要でした。 Datadog provider の最新仕様を確認する 似たような既存モニタの実装を探してコピーし、差分を埋めていく メトリクスのタグ表記( env: / service: / container_name: など、起動形態で違う)を調べる メッセージのテンプレート構文( {{#is_alert}} 等)を思い出す・調べる これらを、エージェントが規約と既存コードを参照しながら、ものの数十秒でこなします。人間は「ECS タスクの CPU が 100% に張り付いたら検知したい」「RDS Serverless v2 の ACU 使用率が高騰したらアラートを送ってほしい」と指示を出すだけでよく、そのまま PR にできるレベルの成果物が出てきます。 ここで重要なのは、 実装をブラックボックスのまま受け入れて構わない ということです。 関心があるのは見たいデータが正しく取れているかのみ です。それを確認できれば、生成されたコードは読む必要がないし、覚えない。それぞれが独立している故に、挙動がおかしければ捨てて作り直せばよく、それでも GUI でポチポチ作るより圧倒的に速い。「書く頻度が低くて、毎回仕様を忘れる」性質を持つ監視モニタリングコードと、この使い方は非常に相性がよいです。 これまで「IaC 化したいが、書く労力に見合うリターンが見えない」という理由で諦めていた領域に、はじめて手を出せるようになりました。 規約によって品質を揃える とはいえ、ブラックボックスを丸ごと信用すると品質がブレるリスクは当然あります。命名がバラつき、通知先がバラつき、タグ付けがバラつくと、運用負荷はむしろ増えてしまう。 弊社ではClaudeCodeを使用しているので、これを避けるためにコーディングの規約を .claude/rules/ 配下に書き溜めています。Datadog 関連では、たとえば以下のようなルールを明文化してあり、エージェントは生成時にこれらを参照します。 モニタ・ダッシュボード名は 🤖 プレフィックスで Terraform 管理であることを示す Slack チャンネル・メンションは locals.tf で集中管理し、モジュール側は変数経由で参照のみ メトリクスのタグには env:<env> を必ず入れ、全環境平均を見てしまうミスを防ぐ 新しい AWS メトリクスを使うときは、CloudWatch Metric Stream の送信対象フィルタも更新する これに加えて、 .claude/rules/datadog-monitors.md には「シンプルアラートとマルチアラートの違い」「 renotify_interval の標準値」「環境フィルタ漏れの典型ミス」など、具体的なコードパターンまで載せてあります。 結果として、誰が、あるいはどのエージェントが書いても、ほぼ同じ形のコードが出てきます。レビュアは「規約からの逸脱がないか」だけを確認すればよく、レビュー労力もかなり下がりました。 コードはブラックボックスにしつつ、規約は人間が育てる 、という分担です。 規約の一部抜粋 規約の一部です。全体ではサンプルコードを含む記述を400行くらい書いてます # .claude/rules/datadog-monitors.md --- paths: - "terraform/aws/services/datadog/monitors_*.tf" - "terraform/aws/services/datadog/modules/monitors/**" - "terraform/aws/services/datadog/*.tf" --- # Datadog Monitor 作成ガイド ## アーキテクチャ概要 モニターは以下の 3 層構造で実装する: 1 . **呼び出し層**: `terraform/aws/services/datadog/monitors_<監視対象>.tf` - 監視対象リソースの一覧を `locals` で定義 - `for_each` で環境 × リソースの組み合わせごとにモジュールを呼び出す 2 . **モジュール層**: `terraform/aws/services/datadog/modules/monitors/<monitor_name>/` - `main.tf` にモニターの実体(`datadog_monitor` リソース)を定義 - `variables.tf` にモジュールの入力変数を定義 3 . **共通設定**: `terraform/aws/services/datadog/locals.tf` - `slack_channel`, `error_channel`, `mention` 等 ## ファイル配置 ``` terraform/aws/services/datadog/ ├── monitors_<監視対象>.tf # 呼び出し層 ├── modules/monitors/<monitor_name>/ │ ├── main.tf # モニター実体 │ └── variables.tf # 入力変数 ├── locals.tf # 共通設定(slack_channel, mention等) ├── variables.tf # サービスモジュールの入力変数 └── provider.tf # プロバイダ設定 ``` ## モニター名の規約 - 必ず `🤖` プレフィックスを付ける(Terraform管理であることを示す) - 環境名は **Terraform変数 `var.env`** で埋め込む: `🤖 【$ { var.env } 】...` - 旧: `【 {{ env.name }} 】`(Datadogテンプレート変数)→ 廃止 - 新: `【$ { var.env } 】`(Terraform変数、for_eachで環境ごとに生成するため) - **Datadogテンプレート変数(` {{ xxx.name }} `)をモニター名・メッセージに使わない** - 環境名、リソース名等はすべてTerraform変数(`var.env`, `var.display_name` 等)で埋め込む - Datadogテンプレート変数はモニター一覧画面では未展開のまま表示されるため視認性が悪い - 例外: ` {{ value }} `(アラート発火時の値)や ` {{ #is_alert}}` 等の条件分岐は引き続き使用する ## Slackチャンネル / メンションの使い方 `locals.tf` で定義された変数をモジュールに渡して使う: ```hcl # Slackチャンネル(重要度別) local.slack_channel.info # 情報レベル local.slack_channel.warning # 警告レベル local.slack_channel.alert # 緊急レベル # エラー通知チャンネル(環境別) local.error_channel [ env ] # 環境ごとのエラー通知先 # メンション(チーム別) local.mention.sre # SREチーム local.mention.swe # SWEチーム local.mention.eng # エンジニア全体 local.mention.cre # CREチーム local.mention.algo # アルゴチーム local.mention.ai_agent # AI Agentチーム ``` --- <以下省略> --- 監視モニタリングIaCの実践例 具体的に弊社がどのようなコード構成をとっているかも軽く紹介しておきます。 コードを最小に保つ多層構成 Datadog 関連リソースは、以下の 3 層で管理しています。 envs/<env>/datadog/datadog.tf ← ① 環境呼び出し層 │ ▼ services/datadog/ ← ② サービスラッパー層(module) ├─ monitors_<対象>.tf (locals + for_each で展開) └─ dashboard_<対象>.tf │ ▼ services/datadog/modules/ ← ③ モジュール層(sub module) ├─ monitors/<name> (datadog_monitor の実体) └─ dashboards/<name> (datadog_dashboard_json の実体) 層 役割 ① 環境呼び出し層 環境リストや、プロダクトコードのoutputなどの依存関係を渡してサービスを呼ぶだけ ② サービスラッパー層 監視対象や閾値などの設定差分を列挙しモジュール層に渡す ③ モジュール層 datadog_monitor / datadog_dashboard の実体。「SQSの滞留モニタ」「ECSサービスのダッシュボード」といった、環境やプロダクト毎によらない抽象的なコードを配置する 弊社の場合、Datadogのアカウントは本番用と開発用の2アカウントで運用しているため、①の環境呼び出し層でproduction, stagingなどの複数の環境をまとめてサービスラッパー層に渡しています。 ポイントは ② のラッパー層で、 setproduct関数 を使って「環境 × 監視対象リソース」や「監視対象 × 閾値」などの組み合わせを for_each で展開している点です。こうすることで、例えば新しい環境を足すときは環境リストに 1 行、新しい監視対象を足すときも locals に 1 行といった具合に、 追加コストが "リスト 1 行" にまで圧縮されている のがこの構成の効きどころです。AIエージェントによる生成とも非常に噛み合います。 実装サンプル ③モジュール層、②サービスラッパー層の実装サンプルはこんな感じです。 ③SQSのDLQにメッセージが落ちてきたことを知らせる抽象モニタ # ~/modules/monitors/sqs_dlq/main.tf locals { doc_link_line = var.doc_link != "" ? "\n[こちらの手順]($ { var.doc_link } )を参考に対処してください。" : "" } resource "datadog_monitor" "main" { name = "🤖 【$ { var.env } 】$ { var.service_display_name } SQS DLQ $ { var.display_name } にメッセージが見つかりました" type = "query alert" query = "min(last_5m):min:aws.sqs.approximate_number_of_messages_visible{queuename:$ { var.env } -$ { var.queue_name } ,env:$ { var.env } } > 0" message = <<EOT $ { var.slack_channel.alert } $ { var.mention.sre } $ { var.mention_team } {{ #is_alert}} $ { var.env } 環境の $ { var.service_display_name } SQS DLQ $ { var.display_name } にメッセージが入りました。 ワーカーの処理に失敗したメッセージが存在している可能性があります。$ { local.doc_link_line } メッセージ数: {{ value }} メッセージ {{ /is_alert }} {{ #is_recovery}} $ { var.env } 環境の $ { var.service_display_name } SQS DLQ $ { var.display_name } からメッセージが削除されました。 DLQへの失敗メッセージへの対応が完了しました。 {{ /is_recovery }} EOT monitor_thresholds { critical = 0 } on_missing_data = "show_no_data" require_full_window = false renotify_interval = 120 renotify_statuses = [ "alert" ] tags = [ "service:$ { var.service_tag } " , "env:$ { var.env } " , "managed_by:terraform" , ] } ②DLQモニタに「CLM」というプロダクトのSQSを設定を渡すラッパー層 # ~/services/datadog/monitor_clm_sqs_dlq.tf # CLM SQS DLQモニター # DLQにメッセージが落ちてきた時にアラートを発する # 環境 × キューごとにモニターを生成する # キュー定義は locals_clm_sqs.tf の local.clm_sqs_queues から導出 module "monitor_clm_sqs_dlq" { for_each = { for pair in setproduct (var.dashboard_envs, local.clm_sqs_queues) : "$ { pair [ 0 ]} -$ { pair [ 1 ] .display_name } " => { env = pair [ 0 ] queue_name = "$ { pair [ 1 ] .queue_name } -dlq" display_name = pair [ 1 ] .display_name } } source = "./modules/monitors/sqs_dlq" env = each.value.env queue_name = each.value.queue_name display_name = each.value.display_name service_display_name = "CLM" service_tag = "clm" mention_team = local.mention.clm doc_link = "<対応手順書のURL>" slack_channel = local.slack_channel mention = local.mention } # ~/services/datadog/locals_clm_sqs.tf locals { # CLM SQSキュー定義(環境プレフィックスなし) # listを使用して定義順序を保持 clm_sqs_queues = [ { queue_name = "clm-default-app-job-worker-sqs-critical" display_name = "critical" } , # 取り扱うキューを列挙する。ここでは省略 ] } ②のコードはあくまで「CLM」という特定のプロダクトのDLQモニタを定義するものです。別のプロダクトの監視を行いたいときは、②のコードを別途作成します。③のコードは再利用可能です。 このように抽象化を行うことによって、コードを最小にして多数のモニタを管理することが可能になります。そして実装部分はAIエージェントに丸投げしてしまえば、 少ない運用工数 で、 多数のモニタリング対象 を、 高品質なコード で管理できる わけです。 弊社で運用している監視モニタリングの規模 2026/05/20現在の規模感は以下のとおりです。 項目 数 対象環境 8 環境 モニター種別 / 生成されるモニター数 30 種類 / 約 800 個 ダッシュボード種別 / 生成されるダッシュボード数 7 種類 / 約 80 個 ラッパー+モジュール層のコード行数 約 1 万行 おおざっぱに 1 万行で 800 のモニタと 80 のダッシュボードを支えている 計算です。 先に述べたように、実装規約を整備したおかげで誰でも気軽に対象の追加ができるようになり、現在でも日々監視モニタリングは充実していっています。 おわりに 本記事執筆のきっかけは、AIエージェントの登場による監視モニタリングIaCの変化は、 単に"運用が楽になった" だけの話ではない と感じたためです。 これまでは「重要なものだけ厳選して監視するのが限界」と言わざるを得ませんでした。リソースを増やすほど管理コストが増えるため、観測対象は常に「これは本当に必要か」というフィルタを通って絞り込まれていたわけです。 それが、追加コストがほぼ無視できるほど軽くなった瞬間、 「観測したいものは全部観測する」というスタンスに振り切れる ようになりました。新しいワーカーを足したら CPU・メモリ・レイテンシのアラートを同時に足し、新しい SQS キューを切ったら滞留と DLQ のモニタも足し、新しい RDS クラスタを建てたらスロークエリやコネクション数のダッシュボードも足す ── これらが開発フローの中で容易に実現できるようになります。 開発組織全体への良い影響もあります。 新機能リリース時に「とりあえずダッシュボードはある」状態が標準 になり、初動の異常検知が早くなります。モニタを足すコストも軽いため、開発者が「この指標を見たい」と SRE に相談する敷居も下がる、あるいは開発者自身がダッシュボードやモニタを追加することも今後可能となっていくはずです。 モニタリングは「保険」のように扱われがちで、潤沢な工数を割きづらい領域です。だからこそ、 コストを劇的に下げてくれる手段が出てきたなら、観測の "深さ" そのものを変えにいくべきでしょう。 MNTSQ株式会社 SRE 西室
はじめに セキュリティ推進室の山田です。 MNTSQはエンタープライズ企業を主な顧客としています。 契約という、顧客企業の事業戦略に直結するような情報を取り扱う性質上、さまざまな観点からセキュリティをしっかりと担保する必要があり、DMARCへの対応もそうした取り組みのひとつです。 DMARCはなりすましメール対策の仕組みであり、実質的な効果を持たせるにはポリシーをp=quarantineまたはp=rejectに設定する必要があります。 しかしMNTSQでは、DMARCレコード自体は存在していたものの、ポリシーはp=noneの状態が続いていました。本記事ではDMARCポリシーをp=rejectまで厳格化した取り組みについて紹介します。 DMARCとは DMARCはSPF・DKIMの認証結果を照合し、ポリシーに従ってメールを処理する仕組みです。認証に失敗した場合、設定されたポリシーの値に応じて処理されます。 DMARCポリシーの設定値は3つあります。 Step0: 現状の整理と取り組みの方針の決定 現状を整理した結果、次のようなステップで取り組む必要が出てきました。 自社ドメインからのメール送信元の確認 → Step1: DMARCレポートの分析 SPF・DKIMなどDNSレコード設定の適切性の確認 → Step2: DNSレコードの棚卸し メール送信元の管理方式の策定 → Step3: 未対応サービスの洗い出しと対応 DMARCレコードの管理者の策定 → Step4: DMARCポリシーの厳格化 Step1: DMARCレポートの分析 DMARCレポートとは、メールを受信したサーバーが送信ドメインの管理者に送る集計レポートです。どのIPアドレスから自社ドメインを名乗ったメールが送られているかを把握できます。 MNTSQではすでにValimailがレポートの送信先として設定されていたため、まずはValimailを使って送信元の洗い出しを試みました。Google Workspaceなど日頃から利用しているSaaSがレポートとして上がっていた一方で、Valimailだけでは不十分だとわかりました。数日ほど集計レポートを眺めていると、利用しているにもかかわらずレポートに現れないサービスがあることに気づきました。レポートがサマライズされており全容が把握しきれていなかったことが原因でした。またSendGridのようにCNAMEで委譲された独自サブドメイン(例:sg-123.example.com)からの送信がValimailで拾えていなかったことも、この時点では把握しきれていませんでした。 そこでDMARCレポートを分析するツールを自作しました。GASのコードはClaudeを活用して作成しており、ツール自体は1日ほどで動くものができました。その後、表示内容や確認したい情報が適切に出力されているかを1〜2日かけて調整し、実用的な状態に仕上げました。生成AIを活用することで実装より設計に集中できるため、ツールを自作するという意思決定のハードルが下がった実例でもあります。 GASは機能ごとに分割し、機能の橋渡しとなるようデータの構造を設計している 作成したツールの仕組みは以下の通りです。 各受信サーバーからメール添付で届くDMARCレポート(XML)をGASで収集し、Google Driveに格納 XMLをクレンジングし、表示に必要な情報だけをスプレッドシートに中間データとして展開 DMARCレポートは1日あたり数個から十数個になる BIツール側でXMLを直接読み込むと処理速度に影響するので中間データを生成する 中間データをもとにGASでBIツールを構築 中間データを挟むことで低レイテンシーでの表示を実現している これによりValimailでは把握できていなかった送信元を含めたDMARCレポートの全容が把握できるようになり、次のステップであるDNSレコードの棚卸しに必要なサービス一覧が作成できました。 スクショはサマリーだけですが、トグルを開くと詳細レポートもみれます Step2: DNSレコードの棚卸し Step1のDMARCレポート分析で得たサービス一覧をもとに、SPFおよびDKIMのレコードが正しく設定されているかを確認していきました。ヒアリングから入ると曖昧な情報に引っ張られるリスクがあるため、まずDMARCレポートというファクトをベースに実態を整理してから従業員に確認する、という順序を意識しました。 MNTSQではDNSレコードはTerraformで管理されており、変更はPRを作成してSREチームにレビュー・デプロイを依頼する運用になっています。Terraformで管理されているとDNSレコードをコードとして確認しながら棚卸しを進められる点は、作業を進める上で都合が良かったです。Terraformの構成ファイルを読み進めていくとStep1の時点で把握できていなかったCNAMEサブドメインの実態がようやく明らかになったのもこのときでした。 Terraformのファイルを読み進めながら、各サービスのDKIMレコードが正しく定義されているかを一件ずつ確認していきました。以下はDKIMレコードの一例です。DKIMの公開鍵は長く、Route 53のTXTレコードは1件あたり255文字の制限があるため、format()を使って文字列を分割して定義しています。この分割が正しく行われていないとレコードが有効にならず、DKIM認証が通らない状態になります。このコードは修正後のものですが、それまでは正しく分割されておらず、レコードが無効な状態になっていました。 resource "aws_route53_record" "txt__gws_dkim" { zone_id = local.zone_id name = "google._domainkey" type = "TXT" ttl = local.ttl records = [ format ( "%s\" \"%s" , "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmBKdjgohxRFnbL8pb0BTNajVMYNAFRHwUT7WgiKmxxsvr/H5dSIcq+1xTaKTYRA5f29yUG6K2D5UJ7MHaUr2q1F3YOX6XCbX6J1MBB0JTyfDINBXxwspf8xxx6W5I/J0nAaf4SS3LEHSSTymFRiPN27sTTI13vplwgjQ07n0JRrCg62KQTxNmV" , "ekhPuLwTZM4fqKYzZVcoP1ezQOqIlRdd80pnRvSsX1bmGmofJXBxaNlRnHA9x4z8yPN09v18jT8yJRFgXw44uyMcE9+4a9l4AOHik2Z3z/yHpxjEZY47G+tXXefRlKWb0V6dDfywB/bMtkMY4O0ICH2+a0TvBbTQIDAQAB" , ) ] } こうして机上で把握できる範囲の実態を整理しきった段階で次のステップとして全社へのヒアリングに移りました。 Step3: 未対応サービスの洗い出しと対応 DMARCレポートとDNSレコードの棚卸しによってメール送信サービスの実態はある程度整理できましたが、机上の調査には限界があります。例えばDMARCレポートの収集期間外にメールを送信していたサービスは、この時点では拾えていないケースとして残り得ます。そこで全社員を対象にヒアリングを実施しました。 ヒアリングの結果、リストに載っていないサービスがいくつか出てきました。内容を確認すると、メール送信サービス経由で送信しているため実態としては問題ないケース、FromにはSaaS側のメールアドレスが使われReply-Toに会社ドメインが設定されているだけで自社ドメインからの送信ではないケースなど、対応不要と判断できるものが多くありました。一方で、社員自身では判断がつかないとして連絡をもらったものもあり、そういった情報も含めて整理を進めることができました。 全社へのヒアリングは手間がかかるように思えましたが、机上の調査だけでは拾いきれない情報を補完できた点で有効でした。 Step4: ポリシーの厳格化 DMARCレポートの分析、DNSレコードの棚卸し、全社へのヒアリングと多角的に対応を進めた結果、自社ドメインからメールを送信しているサービスの全体像が把握できました。送信元のサービスごとにSPFおよびDKIMの設定が適切に行われていることを確認し、DMARCポリシーを引き上げる準備が整いました。 ポリシーの設定にあたっては、p=quarantineを経由せずp=rejectに直接移行することにしました。ここまでの調査と対応を通じてメールの送信状態はひと通り整理できており、段階を踏むよりも一気に厳格化した方が運用上もシンプルだという判断からです。 ポリシーをp=rejectに移行した後は、継続的な運用体制の整備に着手しました。今回の取り組みを通じて対応状況や設定内容はNotionにドキュメントとして整備しました。DMARCはメールを送信するサービスが追加・変更されるたびに設定の見直しが必要になりますが、運用の標準化という観点では担当者が複数つけられる規模になっていないと難しい面もあり、現時点では筆者が責任を持って管理する体制としています。 まとめ 今回の取り組みを通じて得られた知見を整理します。 DMARCポリシーの厳格化は、設定よりも実態の把握が本質的な難しさ DMARCレコード自体は存在していても、送信元サービスの全体像が把握できていなければポリシーの引き上げはできません。レコードを書き換えること自体は簡単ですが、そこに至るまでの調査と整備に大半の時間がかかりました。 自作の分析ツールが調査の精度を上げた Valimailでは拾えていなかったCNAMEサブドメイン経由の送信元を含め、DMARCレポートの全容を把握できるようになりました。ツールを自作して可視化したことで、調査の抜け漏れを防ぐことができました。 全社ヒアリングで調査の精度を上げた 机上の調査だけでは拾いきれないケースを補完するために全社ヒアリングを実施しました。社員が自発的に判断のつかない情報を連絡してくれたことで、調査の精度が上がりました。エンジニアだけで抱え込まず、早めに全社へ展開することが有効だと感じました。 送信状態が整理できたらp=rejectまで一気に引き上げる p=quarantineは段階的な移行のための中間設定として有効ですが、今回はStep1~Step3を通じて送信元の全体像を把握した上でポリシーを引き上げたため、p=quarantineを経由せずp=rejectに直接移行しました。送信状態の整理が完了していれば、段階を踏まずに一気に厳格化することで運用上シンプルにすることができました。 おわりに DMARCポリシーの厳格化は一度対応すれば終わりではなく、新しいサービスの導入や設定変更のたびに送信元の管理が必要になります。今回の取り組みで整備した分析基盤を活用しながら、継続的に運用していく予定です。 また、TerraformのDNSレコード変更にあたり、PRのレビューとデプロイを何度もSREチームに依頼しましたが、快く対応いただきました。この場を借りて感謝を伝えたいと思います。同様の課題を抱える組織の参考になれば幸いです。
3行で要約すると CUJ(Critical User Journey)ベースのダッシュボードを作る前提として、各 CUJ に紐づく Critical API を客観的に特定する必要がありました Playwright の route API による fault injection を使い、E2E テストから Critical API を自動抽出する仕組みを作りました ある程度汎用的に使えそうなので npm にも置いています: critical-api-finder はじめに SREの寺島です。 特定の API のエラーやレイテンシーの悪化が、どのユーザ体験に影響しているのか、容易に判断できるようになりたいと思ったことはありませんか? MNTSQ は「すべての合意をフェアにする」をミッションに、契約業務を支援するプロダクトを提供しています。 SRE チームでは、顧客向けに提供しているプロダクトにおいて重要な操作のユーザ体験の劣化を早期に検知し、継続的に追うために、CUJ(Critical User Journey)ベースのダッシュボードを作りました。 その構築の過程で、各 CUJ に紐づく Critical API を Playwright のE2E テストから自動抽出するためのツールを作りました。本記事では、このツールを中心に、ダッシュボード構築の流れと合わせて紹介します。 3行で要約すると はじめに CUJ とは ダッシュボード構築の流れ 人手で仕分ける難しさ Playwright を使ったアプローチ 動作イメージ 仕組み 1. import の書き換え 2. baseline 実行 — API リストの収集 3. パスの正規化 4. ブロックループ ダッシュボードへの組み込み 最後に CUJ とは CUJ は、ユーザがプロダクトを通じて達成したい中核的な操作の流れを指します。 MNTSQでは「契約書をアップロードする」「契約レビューを依頼する」といった操作が代表的な CUJ にあたります。 各 CUJ について、関連する API のメトリクス(エラーレート、P95 レイテンシ等)を一枚の画面で見られるようにしています。 ダッシュボード構築の流れ このダッシュボードの構築は、以下のような流れで進めました。 PDM に重要な画面操作(ユーザが毎日必ず行う操作や、壊れたら業務が止まるレベルの操作)をヒアリング 各 CUJ に紐づく API の特定・整理 ダッシュボードの構築 本記事の主題は、この 2 番目の「API の特定・整理」をどう進めたかという話です。この特定・整理を進めるなかで、まず問題になるのが「どの API をメトリクスの対象にするか」という仕分けです。 というのも、MNTSQ のアプリでは、1 つの画面操作の裏側で、主力の処理から補助的なものまで数多くの API が動いています。 契約書本体の保存やメタデータの登録のように、失敗がそのままジャーニーの中断に直結する API ユーザアイコンの取得や通知バッジ件数のポーリングのように、失敗してもユーザ操作自体は継続できる API これらを区別せずにすべてダッシュボードに並べてしまうと、重要な変化がノイズに埋もれてしまいます。運用しやすく、かつ意味のあるダッシュボードにするためには、「それが止まるとジャーニーが完遂できない API(Critical API)」を正確に特定し、絞り込む必要がありました。 人手で仕分ける難しさ いざ Critical な API の仕分けをやろうとすると、意外と根拠を持たせるのが難しいことに気づきました。 ヒアリングの限界 : 開発者に確認しても、フロントエンドのエラーハンドリングの詳細(この API がコケても画面は止まらない、等)まで正確に網羅するのは負担が大きく、属人化も避けられません。 LLM に推定させる難しさ : Claude Code にコードベースを読ませて Critical な API を推定させる方法も試しました。実行時の振る舞いではなくコード上の文脈から推定する以上、画面遷移後に裏で発火するプリフェッチ系のような「実際に動かさないと見えない」依存関係は取りこぼしやすく、判定根拠の再現性も担保しにくい結果でした。 メンテナンス性 : プロダクトの改修に合わせて API の依存関係は変わるため、その都度手動で調査し直すのは現実的ではありません。 そこで、「人間が判断するのではなく、実際に API を 1 つずつ止めてみて、挙動の変化を機械的に観測すればいいのではないか」と考えました。 Playwright を使ったアプローチ もっとも単純な方法は、Chrome DevTools の "Block request URL" 機能を使って 1 つずつ API をブロックし、画面操作を手動で確かめていくやり方です。ただし、CUJ ひとつあたり数十個の API があると、これを毎回手作業で繰り返すのは現実的ではありません。手作業の負担はもちろん、人の判定が入ることで属人化や再現性の問題も再発してしまいます。 そこで着目したのが Playwright の Network API です。 page.route / context.route を使うと、ブラウザのネットワーク通信をスクリプト側から傍受したり、改変したりできます。たとえば「特定の URL パターンに合致するリクエストだけ 500 を返す」といった操作が数行で書けます。 await context.route( "**/api/contracts" , async ( route ) => { await route.fulfill( { status : 500 , body : JSON . stringify ( { error : "blocked" } ) } ); } ); これを使えば、E2E テストを 1 度書いておくだけで、 テストを 1 度走らせて、ジャーニー中に呼ばれる API をすべて記録する 記録した API を 1 つずつ 500 で短絡しながら、テストを再実行する テストが落ちた API を Critical、通った API を非 Critical と判定する という流れを完全に自動化できます。判定の根拠は「テストが通る/通らない」という二値の客観的なシグナルで、人間の解釈を挟みません。プロダクトに改修が入って依存関係が変わっても、テストを更新して回し直せば最新の Critical API リストが手に入ります。 また、Playwrightを採用した背景としては、ちょうど MNTSQ では Autify から Playwright への E2Eテストの移行プロジェクトが進んでおり、QA が書くテストをそのままインプットとして使える見込みがある、という事情もありました。 動作イメージ このアプローチをツールとしてまとめたものが critical-api-finder です。Playwright のテストファイルを用意してコマンドを叩くだけで動きます。 npm install -D @playwright/test critical-api-finder npx critical-api-find tests/contract-upload.spec.ts 実行すると、ツールが内部でテストをN+1回繰り返し実行します(最初の 1 回で API を記録 → 各 API を 1 つずつブロックしながら再実行)。終わると critical-api-results/verify-contract-upload.json に結果が出力されます: { " journeyId ": " contract-upload ", " testPath ": " tests/contract-upload.spec.ts ", " entries ": [ { " method ": " POST ", " pattern ": " /api/contracts ", " critical ": true } , { " method ": " GET ", " pattern ": " /api/contracts/:id ", " critical ": true } , { " method ": " GET ", " pattern ": " /api/v2/user/me ", " critical ": false } , { " method ": " GET ", " pattern ": " /api/v2/notifications/count ", " critical ": false } ] } 仕組み ツール内部は大きく 4 つのコンポーネントから成ります。 ※ コードは簡略化して載せています。 1. import の書き換え テストファイルを直接書き換えたくないので、CLI は sibling file( tests/contract-upload.spec.ts → tests/contract-upload.critical.spec.ts )として複製したうえで、 @playwright/test の import だけを critical-api-finder 自身に差し替えます。 // テストファイル中のこの import を… import { test , expect } from "@playwright/test" ; // 自動的にこちらに書き換える import { test , expect } from "critical-api-finder" ; 書き換えは正規表現ベースの単純置換です。 const IMPORT_RE = /^([\t ]*import\b[^;]*?\bfrom\s+['"])@playwright\/test(['"])/gm ; const REQUIRE_RE = /^([\t ]*(?:const|let|var)\b[^;]*?\brequire\s*\(\s*['"])@playwright\/test(['"]\s*\))/gm ; export function rewriteImports ( source : string ): string { return source . replace (IMPORT_RE, `$1critical-api-finder$2` ) . replace (REQUIRE_RE, `$1critical-api-finder$2` ); } critical-api-finder は @playwright/test の公開 API を全 re-export しているので、ユーザのテストはコード変更ゼロで、こちらの拡張 fixture(route handler 入り)を引き継いで動きます。 2. baseline 実行 — API リストの収集 最初の 1 回はブロックなしでテストを走らせ、 context.route で全 API を傍受してリスト化します。 await context.route( "**/api/**" , async ( route ) => { const method = route.request(). method (); const pathname = new URL (route.request(). url ()). pathname ; const normalized = normalizePathname(pathname); appendFileSync(collectFile, ` ${ method } ${ normalized } \n ` ); await route.continue(); } ); ここでは route.continue() で素通しさせるだけなので、テストの挙動には影響を与えません。観測したリクエストはあとで重複排除して、ブロックループの対象リストとして使います。 3. パスの正規化 API の path には、リソース ID のように実行のたびに値が変わる動的セグメントが含まれることがあります。たとえば、観測時に /api/contracts/12345 だった path が、次の実行では /api/contracts/67890 のように別の値になっていて、そのままブロック対象として記録しておいても当たらない、ということが起こります。 そこで、観測した path を以下のルールで正規化します。 セグメント プレースホルダ 数値 id ( /12345 ) :id UUID :uuid ISO date ( /2026-04-22 ) :date 長い hex hash (20+ 桁) :hash 実装は順序付きの置換ルールを並べただけのシンプルなものです。 const RULES = [ // UUID(数値 id より先に判定) { regex : /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi , replacement : "/:uuid" } , // ISO date { regex : /\/\d{4}-\d{2}-\d{2}(?=\/|$)/g , replacement : "/:date" } , // 長い hex hash { regex : /\/[0-9a-f]{20,}(?=\/|$)/gi , replacement : "/:hash" } , // 数値 id { regex : /\/\d+(?=\/|$)/g , replacement : "/:id" } , ] ; function normalizePathname ( pathname : string ): string { let result = pathname; for ( const { regex , replacement } of RULES) { result = result. replace (regex, replacement); } return result; } これにより、 /api/contracts/12345 も /api/contracts/67890 も同じ /api/contracts/:id という論理的なエンドポイント単位に集約されます。ブロック時は逆にこのプレースホルダを正規表現に展開し、当該パターンに合致するリクエストだけを 500 にする、という流れです。 4. ブロックループ 正規化したパターンを 1 つずつ取り出して、Playwright を再実行します。route handler は同じ場所ですが、今度は対象パターンに合致したリクエストだけを 500 で short-circuit します。 await context.route( "**/api/**" , async ( route ) => { const pathname = new URL (route.request(). url ()). pathname ; // ブロック対象パターンに合致するリクエストだけ 500 にする if (blockedRegex. test (pathname)) { await route.fulfill( { status : 500 , contentType : "application/json" , body : JSON . stringify ( { error : "Blocked by critical-api-finder" } ), } ); return ; } await route.continue(); } ); これでテストが落ちれば Critical、通れば非 Critical と判定します。なお、毎イテレーションで --retries=0 --max-failures=1 を強制することで、Playwright project 側の retry 設定によらず「ブロックした瞬間に exit」させ、無駄な再試行を防いでいます。 ダッシュボードへの組み込み 実際にE2Eテストにこのツールを当てて Critical API のリストを取り出し、そのままダッシュボードのメトリクス対象として組み込みました。ダッシュボードは Datadog 上に Terraform で管理しており、CUJ の定義は次のような形で書いています。 locals { cuj_dashboards = { sample_journey = { title = "ユーザ体験: サンプルジャーニー" service = "sample-service" trace_name = "rack" steps = [ { name = "ステップ 1" endpoints = [{ display_name = "POST /api/sample/foo" resource_name = "resources::v2::fooapi_post_/foo" }] } , { name = "ステップ 2" endpoints = [ { display_name = "GET /api/sample/foo/:id" resource_name = "resources::v2::fooapi_get_/foo/:id" } , { display_name = "GET /api/sample/bar" resource_name = "resources::v2::barapi_get_/bar" } , ] } , ] } # 他の CUJ も同じ形で並べる } } module "cuj_dashboard" { for_each = local.cuj_dashboards # CUJダッシュボード詳細を管理するためのモジュール。本筋ではないため本稿では除外 source = "./modules/cuj_dashboard" dashboard_title = each.value.title service = each.value.service trace_name = each.value.trace_name steps = each.value.steps } これによって CUJ ごとにダッシュボードが生成され、ステップ単位でエラーレート・レイテンシ・リクエスト数が並ぶ形になります。 最後に ダッシュボードに載せる Critical API のリストを、人の判断ではなく「テストの通る/通らない」という客観的なシグナルから引けるようになり、属人化とメンテナンスの問題は大きく緩和できました。 副次的な発見として、Playwrightが「回帰を防ぐためのE2Eテストツール」という用途以外にも使えそうだという気づきがありました。fault injection との組み合わせには、依存関係の抽出以外にもいろいろな応用が効きそうで、例えば個人プロダクト用の安上がりな脆弱性診断ツールやカオスエンジニアリングツールなどを似たような仕組みで自作できそうだと思いました。このあたりは今後も探っていきたいと思っています。 同じような課題に取り組んでいる方は、ぜひ覗いてみてください。(フィードバック・PRも歓迎しています) リポジトリ: github.com/kterashi02/critical-api-finder
はじめに システムが成長し、扱うデータ量やトラフィックが増大してくると、非同期処理の安定性とスケーラビリティがサービス全体の課題となります。 弊社のサービスの根幹部分はRuby on Railsを採用しているため、長らく標準の非同期処理のキューとしてResque (Redis) を使用していました。しかし、サービス規模の拡大に伴い、 Redisベースの運用では「ワーカーのオートスケール最適化」が困難である という課題が浮き彫りになってきました。 本記事では、この非同期処理のバックエンドを Amazon SQS に移行した背景と、移行に伴って行ったキュー設計・オートスケール最適化の取り組みについて紹介します。 はじめに なぜSQSなのか 非同期処理でのオートスケール実現の課題 RedisからSQSへ ─ 移行のメリットと留意点 ─ 2千万件のログからサービス特性を分析する キューの再設計 前提: アプリケーション側での事前整備 優先度ベースのキュー: 短時間ジョブを守るための4段構成 機能ベースのキュー: 大規模オペレーションの隔離 キューごとのオートスケール戦略 モニタリングと改善のループ Datadogによるモニタリング モニタリング → 仮説 → 修正のループ 結果 次の一手: SQS Fair Queue おわりに なぜSQSなのか 非同期処理でのオートスケール実現の課題 変化の激しいワークロードに対して、理想的なオートスケールを実現するためには、以下の要素が必要不可欠です。 グレースフルなシャットダウン : オートスケールインを行うということは、処理途中であっても中断が発生しうるということです。このようなイベントに対して、適切なハンドリング・リトライを行える必要があります。 細かなメトリクスを取得できる : オートスケールを運用に乗せるためには、実際のワーカー台数の変化を、メッセージの滞留数や滞留時間、同時実行数などと照らし合わせ、適切な設定になっているかを評価する必要があります。また、オートスケールの条件もこれらのメトリクスを参照することになります。 RedisからSQSへ ─ 移行のメリットと留意点 ─ Redisをバックエンドに使用していると、キューの滞留数やジョブの状態をリアルタイムで詳細に把握するために、独自のメトリクス収集の仕組みを構築・維持しなければなりません。また、エラー時のリトライ機構についても、アプリケーション側で慎重に設計・実装する必要がありました。 これらの課題を解決するため、バックエンドを Amazon SQS へ移行することを決断しました。 SQSの 「 可視性タイムアウト 」 を利用すれば、ジョブ実行中のエラーやオートスケールに伴う中断が発生しても、メッセージを安全にキューへ戻し、自動で再試行できます。これにより、複雑なエラーハンドリングを行うことなく、グレースフルシャットダウンを容易に実現できます。また、 標準で提供される滞留数や滞留時間といった強力なメトリクス をそのままオートスケールのトリガーに利用できるため、独自のモニタリング基盤を維持するコストも不要になります。柔軟にスケールし、かつ壊れにくい基盤を作るには、SQSのマネージドな特性をフル活用することが最適解だと判断しました。 注意点として、SQSには"優先度"の概念がありません。Redisベースのジョブキュー(Resque/Sidekiqなど)では1つのキュー内でジョブの優先度を表現できますが、SQSではキューそのものを分割して、優先度の高いジョブが滞留しないような設計を取る必要があります。また、at-least-once配信を前提としたジョブの冪等化や、可視性タイムアウトを超える長時間ジョブの二重実行対策といった、アプリケーション側で事前に手当てすべきポイントもあります(これらの具体的な対応については後述します)。 つまりSQS移行においては、サービスのジョブ特性を正しく理解した上で、最初に適切なキュー構成を設計できるかが成否を分けます。 2千万件のログからサービス特性を分析する 最適な設計を行うため、ジョブ全体の傾向や特徴を把握する必要があります。 「ワーカーのオートスケールの最適化」とは、即ち「顧客体験」と「運用コスト」の最適化 です。なんとなくワーカーが増えたり減ったりしているという状況はゴールではありません。弊社のサービスではアップロードした契約書の条項の分解や、検索用のインデックス作成など、顧客体験に関わる処理も非同期で行われます。このような処理が、特定のテナントや初期導入に伴う大量解析や、実行時間が比較的長時間にわたるジョブの影響(所謂 ノイジーネイバー問題 )を受けないような設計にしたいところです。また、無駄なスケールアウトはコスト観点から好ましくないです。 ということで、以下が弊社サービスでのジョブの特徴です。(見やすいように対象を絞って表示しています) 処理時間別 ジョブの実行数のグラフ(対数軸) このグラフは、データベースのジョブの実行を管理するテーブルのここ半年分のデータ約2千万件を集計したものです。(ジョブの実行ログをデータベースに蓄積していれば、リードレプリカでSQLを叩いてExcelで集計するだけなので、特別な分析基盤がなくても気軽に行えます)縦軸がジョブの実行数、横軸がジョブの実行時間を表します。(横軸も対数チックな軸になっています)また、同じジョブでも実行時間にバラツキがあるため、同一のジョブは同じ色で表現しています。 このグラフから、以下のようなワークロードの特性が見えてきました。 78%のジョブは1秒未満、 99%のジョブは10秒以内に完了する。 1秒未満のジョブは営業時間中に分間200件以上積まれる一方、10秒超えのジョブは分間1件未満しか積まれない 実行時間に 数秒から数時間のバラツキがあるジョブが存在する また、弊社のサービスでは以下のようなオペレーションが発生する点も考慮する必要があります。 初期導入: 顧客の運用開始の準備として大量のドキュメントをアップロードし、ファイル変換や解析を行う作業がある 再インデクシング: 検索機能の拡張などで、大量の検索用インデックスを更新・再作成する作業がある よって、上記を考慮しつつ、 10秒未満のジョブをいかに滞留させずに捌けるかが設計における重要な課題 でした。 キューの再設計 設計の出発点はシンプルです。 99%を占める短時間ジョブを、長時間ジョブやバーストワークロードに巻き込まれず安定して捌くこと 。これを実現するため、キューを「 優先度ベース 」と「 機能ベース 」の2軸で分割しました。 前提: アプリケーション側での事前整備 SQSのクライアントとしては shoryuken を採用しました。 キュー設計の話に入る前に、SQSをバックエンドにする上でアプリケーション側で先に手当てした2点に触れておきます。これらは設計段階で必要になることが予想できたため、本格的なチューニングに入る前に済ませました。結果として、後段のチューニングフェーズではこの2点が問題になることはありませんでした。 ひとつめは、 ジョブの冪等化 です。SQSはat-least-once配信のため、同一メッセージが複数回配信される前提で実装する必要があります。移行に伴ってジョブの抽象クラスを見直し、すべてのジョブが冪等に動作するよう統一しました。 ふたつめは、 長時間ジョブにおける可視性タイムアウトの動的延長 です。SQSの可視性タイムアウトは、処理中のメッセージが他のワーカーに再配信されないようにするための仕組みですが、ジョブの実行時間が可視性タイムアウトを超えると、処理中にもかかわらず別ワーカーで二重実行されてしまいます。これを防ぐため、長時間ジョブに対してはアプリケーション側でハートビート的に可視性タイムアウトを延長する実装を入れました。これによりインフラ側では 可視性タイムアウトのチューニングにシビアになる必要がなくなった のは設計上のポイントです。 優先度ベースのキュー: 短時間ジョブを守るための4段構成 通常業務のジョブは、 実行時間と投入パターン の2軸で4つのキューに振り分けます。 キュー名 用途 想定される投入パターン critical 顧客体験に直結し時間にシビアなJob(UIからのファイルアップロード・解析、メール発信など) 単発・低頻度 high 顧客体験に直結するが大量投入される可能性があるJob(Zip解凍やそれに伴う解析など) バースト default 顧客業務に影響するが即時性不要なJob(メール連携・定時タスク起点、外部連携起点) 中頻度 low 実行時間が10秒を超える可能性のあるJob(台帳のexport/importなど) 不定 設計の肝は2つあります。 ひとつめは、 実行時間 10秒 を境界に 短時間ジョブキュー(critical / high / default)と 長時間ジョブキュー(low)を分離する こと(以下、10秒ルール)。ジョブ全体の99%は10秒以内に完了する一方、残り1%には数十秒〜数時間に及ぶジョブが混ざっています。これを同じキューに流すと、1本の長時間ジョブがワーカーを占有してしまいます。これでは滞留時間をオートスケール条件にしたとき、無駄にスケールアウトをしてしまいます。しかし、10秒ルールを導入することにより、短時間ジョブキュー 側ではSQSの滞留時間メトリクスを直接オートスケールのトリガーに使えるようになります。 ふたつめは、短時間ジョブをさらに 「UI起点で単発投入されるもの(critical)」と「UI起点だが大量投入されうるもの(high)」で分けた こと。たとえば「Zipアップロード後の一括解析」は、1回の操作で数百件のジョブが一気に積まれる可能性があります。これを critical に流すと、1人のユーザーが大きなZipをアップロードしただけで、他のユーザーの単発操作が裏で詰まる、という典型的な ノイジーネイバー問題 が発生します。バースト性のあるワークロードを high に隔離することで、 critical は常に低い滞留数を保ち、最も厳しいSLOを当てられるようにしています。 機能ベースのキュー: 大規模オペレーションの隔離 優先度ベースの分割だけでは扱いきれないのが、冒頭でも触れた初期導入と再インデクシングです。あるテナントの大量処理が他テナントの通常業務を圧迫しないよう、このような 特例のオペレーションには、通常業務とは完全に分離した専用キュー を用意しました。 キュー名 用途 introduction 初期導入など、通常業務と分離したいJob(ファイルアップロード・解析・インデクシング) reindexing 全件indexing / 権限変更時の大量indexing用 これらの機能ベースキューは 普段はワーカー0台で待機し、必要なタイミングでのみ起動 します。そのため、キュー区分が増えることによる定常的なコスト増は発生しません。隔離したい単位でキューを切る判断を、コストを気にせず行えるのがSQS + オートスケール構成の利点です。 キューごとのオートスケール戦略 各キューには、特性に応じたオートスケール設定を割り当てています。短時間ジョブキュー(critical / high / default)は滞留時間をトリガーにスケールアウトし、こまめにスケールインする運用にしています。「滞留時間数秒以内」という明確なSLOがあるため、滞留時間ベースで反応させるのが最もシンプルです。一方、長時間ジョブキュー(low)はメッセージ数がワーカー最小数を超えたらスケールアウト、キューが空になったらスケールインとしており、SLOを設けない代わりにMAX台数を絞ることでコストを抑制しています。 モニタリングと改善のループ キューを再設計して移行が完了しても、それで終わりではありません。 設計が想定通りに機能しているかを継続的に観測し、ズレを見つけて細かく修正していくフェーズ こそが、オートスケール運用の本番です。設計時点で全てを正解にすることは不可能なので、 動かしながら最適化する前提 でモニタリング基盤を整えました。 Datadogによるモニタリング まず、Datadog上にキュー運用のためのダッシュボードを構築しました。ダッシュボードでは主に以下の観点を一覧できるようにしています。 ワーカー台数の推移 キューごとの滞留数 キューごとの滞留時間 処理中メッセージの数 これらを並べて眺めることで、「 high キューだけ滞留時間が伸びているがワーカー台数が頭打ちになっている → スケールアウト上限が低すぎる」「 default キューはスケールアウトしているのに、処理中のメッセージ数が常に少なく滞留時間が慢性的に長い → 10秒以上かかることがある長時間Jobが紛れ込んでいる」といった 具体的な問題を即座に切り分けられる ようになりました。 加えて、前述の2千万件分析にも使ったジョブ実行履歴テーブルに対し、enqueue時のキュー名の記録や検索用インデックスの追加といった改修を行い、ダッシュボードで気になった事象を SQLで即座に深掘りできる フローも整えています。 ダッシュボードが「能動的に見にいく」仕組みである一方、 異常をプッシュで検知する仕組みとして、Datadogモニタ も「キューの種類 × 指標」の組み合わせで網羅的に仕込みました。滞留時間、滞留数、ワーカーのCPU/メモリ、DLQのメッセージ数などをキューごとに監視することで、ダッシュボードを見ていない時間帯でもSLO違反や異常な振る舞いに即座に気付ける体制を作っています。キュー数 × 指標数で監視項目はそれなりの規模になりますが、AIエージェントの登場によってこれらを実現することが可能になりました。 モニタリング → 仮説 → 修正のループ 道具が揃ったあとは、ひたすら地道な改善ループを回しました。 時間のかかっているジョブを発見 : 実装を見直し、必要に応じてリファクタリング。 low への移動で済むケースもあれば、ロジック自体に改善の余地があるケースもある。Datadog APMのトレースを仕込んでひたすら問題の処理を特定するなど、時にはアプリケーションの深い部分に踏み込んで改善を行った オートスケールがうまくいっていない : メトリクスから原因を考察し、閾値や上限台数などオートスケール関連のさまざまな設定を何度も見直した キュー配置のミスマッチ : 想定と異なる挙動をするジョブを適切なキューに移動した ひとつの修正で全てが解決することは稀で、「直すと別の歪みが見える」を繰り返すのが実態でした。しかし、モニタリングの整備をしっかり行ったことによって、何が課題かが常に明確であり、継続的に改善活動を行えています。 結果 こうしたループを繰り返した結果、まだまだ課題はありますが、現在ではかなり安定してオートスケールが機能しています。 ブログ執筆時点でのオートスケールの様子 次の一手: SQS Fair Queue 本記事の設計を進めている最中、AWSから SQS Fair Queue という機能がリリースされました 。これは、MessageGroupId をテナント識別子として設定することで、SQSがノイジーテナントを自動検出し、他テナントへのメッセージ配信を優先する機能です。本記事で扱ってきた「ノイジーネイバー問題」の一部を、マネージドな仕組みで解決してくれます。 ただしFair Queuesは「ジョブ特性ごとのキュー分離」(本記事の introduction / reindexing / low など) を代替するものではなく、短時間ジョブのキュー内で発生するテナント間の不公平を緩和する位置づけです。本記事で構築したキュー設計と組み合わせることで、よりきめ細やかなノイジーネイバー対策が可能になると期待しています。 弊社ではすでに本機能を導入済みで、本番ワークロードでの効果を観察しているフェーズです。結果についてはいつか別記事でレポートできればと思います。 docs.aws.amazon.com おわりに 長くなりましたが、改めて今回の取り組みを通して得られた学びを整理します。 設計の前にデータを見る : 2千万件のログから「99%が10秒未満」というワークロード特性を掴めたことが、10秒ルールやキュー分割という具体的な設計判断に直結しました。「なんとなくスケールしている」状態から脱却するには、定量的にサービス特性を把握することが出発点になります マネージドサービスの特性に乗る : 可視性タイムアウトや標準メトリクスといったSQSの強みをそのまま設計の前提に組み込むことで、独自の監視・リトライ基盤を維持するコストから解放されました。「自前で頑張る」を減らし、マネージドな仕組みに乗っかれる箇所は徹底的に乗っかる方が、結局シンプルで壊れにくい構成になります 設計は仮説、運用しながら最適化する : 設計時点で全てを正解にすることは不可能で、動かしながら細かく修正していくフェーズの方がむしろ重要でした。そして、そのループを高速に回すには モニタリングを疎かにしないこと が大切です 最後の点について補足すると、今回これだけ細かい粒度でダッシュボードやモニタを整備できたのは、 AIエージェント(Claude Code)の存在が大きい です。キュー × 指標の組み合わせで大量のモニタを作成・保守する作業は、人の手では不可能に近いくらい大変です。(現実にはdev環境、staging環境など環境数分必要になりますし) しかし、 「Datadogリソースを定義するコード」を完全にブラックボックスにしても、AIエージェントの力を借りれば問題なく保守し続けられる 、という確信が持てたことで、「観測したいものは全部観測する」という方針を恐れずに取れるようになりました。これは単なる開発効率の話ではなく、インフラ設計の意思決定そのものに影響を与える変化だと感じています。 次回は、このDatadogダッシュボード・モニタをIaCで運用するための工夫についても記事にしてみたいと思います。ここまで読んでくださり、ありがとうございました。 MNTSQ株式会社 SRE 西室
はじめに モチベーション 実装 インフラリポジトリ(Terraform 変更) アプリケーションリポジトリ(ECS タスク定義変更) 横展開:Reusable / Caller 構成への移行 運用風景 コスト影響がない変更の場合 コスト影響がある変更の場合 おわりに はじめに 弊社では AWS 上にマルチテナント構成のインフラを複数の環境にわたって運用しており、その構成管理を Terraform でおこなっています。インフラ側のリソース構成はもちろんのこと、アプリケーション側で管理されている ECS タスク定義の CPU / メモリ割り当てに対しても、折に触れて変更が入ります。 こうしたリソース変更は無論コストに跳ねます。新規コンポーネントの追加やインスタンスサイズの変更が Pull Request(以下 PR、サービスによっては Merge Request 等の呼称もあります)として上がってくるたびに「で、これは月いくら増えるのか?」という問いが生じるわけですが、これまでは手動で料金表を引いて試算するか、試算そのものをおこなわずにマージしてしまうかの二択という格好でした。 本稿では、この問題を Claude Code による自動コスト試算で解決した取り組みと、それを GitHub Actions の Reusable Workflow 構成で全社の主要リポジトリに横展開した方法について紹介します。 モチベーション きっかけは社内 Slack で「新規コンポーネント追加やリソース割り当て変更の際には、コスト見積もりもセットでおこなう運用にしたい」という声が上がったことです。 ただし、PR のたびに人の手でコスト試算をおこなうというやり方には、以下のような難点が付きまといます。 属人的:試算する向きによって精度にバラつきが出る 忘れがち:「あとでやろう」が「やらなかった」に帰結しがち 骨が折れる:AWS の料金表を引いて、リソース変更の前後差分を計算して、PR にコメントして……という作業を手でやるのは地味に骨が折れる 一方で弊社では Claude Code による PR レビューの自動化を既に導入しており、レビューワークフローの横に「コスト試算」ワークフローを並べるのは自然な延長線上にありました。PR の変更内容からリソースの追加・変更・削除を読み取り料金に照らすような作業は、まさに LLM が得意とする領域です。 実装 インフラリポジトリ(Terraform 変更) 最初の実装はインフラリポジトリに対してのものです。既存のコードレビューワークフロー( claude-code-review.yml )とは別ワークフローとして claude-code-cost-estimate.yml を新設しています。 name : Claude Code Cost Estimate on : pull_request : types : [ opened, synchronize, ready_for_review ] branches : [ main ] paths : - 'terraform/**' concurrency : group : claude-cost-estimate-${{ github.event.pull_request.number }} cancel-in-progress : true なお paths: に terraform/** を指定しているのは、このリポジトリでは Terraform コードを terraform/ 配下にまとめる構成を採っているためです。Terraform に触れない PR ではワークフロー自体が発火しません。 レビューとコスト試算を分離したことの嬉しさは以下の通りです。 レビューコメントとコスト試算コメントが混在しない Terraform に触れない PR ではスキップされる concurrency group が独立しているため互いにブロックしない ワークフロー内では Claude Code CLI を直接インストールし、プロンプトをファイル経由で渡す方式を採っています。当初は anthropics/claude-code-action を使用していたのですが、2026 年 4 月中旬頃から本ワークフローの実行が継続的に失敗する事象に遭遇し 1 、切り分けを重ねた末に回避策として CLI 直接実行方式へ切り替えました。 プロンプトの骨子は以下です。 gh pr diff で PR の差分を取得する 追加・変更・削除される AWS リソースを特定する 必要に応じて変更ファイルを Read で読み、リソースの設定値を確認する リソースのリージョン(原則 ap-northeast-1、CloudFront・WAF 等は us-east-1)に応じた料金に基づき月額コストを試算する 試算結果を PR コメントとして投稿する 試算対象のリソースについては、プロンプト内で以下のように列挙しています。 ## 試算対象リソース 以下のリソースはコストインパクトが大きいため、必ず試算に含めること: - **コンピューティング**: ECS (Fargate vCPU/メモリ), Lambda (リクエスト数/実行時間), EC2 - **データベース**: RDS (インスタンスクラス/ストレージ/Multi-AZ), ElastiCache (ノードタイプ/ノード数), DynamoDB - **ストレージ**: S3, EBS, EFS - **ネットワーク**: NAT Gateway ($0.062/h + データ処理料), ALB/NLB ($0.0243/h + LCU), VPC Endpoint - **検索**: OpenSearch (インスタンスタイプ/ノード数/ストレージ) - **監視**: CloudWatch Logs (取り込み/保存), CloudWatch Metrics/Alarms - **CDN/グローバル**: CloudFront (リクエスト/転送量, us-east-1 料金), WAF (WebACL/ルール/リクエスト) - **その他**: KMS (キー/リクエスト), Route53 (ホストゾーン/クエリ) 試算の際の細則についてもプロンプトで指示しており、コスト影響のない変更をどう扱うかもここに含めています。 ## 試算ルール - 料金は USD で算出する(JPY 換算は不要) - リージョンは原則 ap-northeast-1(東京)だが、CloudFront・WAF・ACM(us-east-1 発行)等のグローバルサービスは us-east-1 の料金を使用すること - リソースの削除はコスト削減として負の値で表記する - コスト影響がゼロまたは無視できる変更(タグ変更、IAM ポリシー変更、セキュリティグループルール変更等)の場合は「コスト影響なし」と簡潔に報告する - 正確な料金が不明な場合は保守的(高め)に見積もり、前提条件を明記する - 環境ごとのコスト差が明確な場合(インスタンスサイズ違い等)は環境別に記載する アプリケーションリポジトリ(ECS タスク定義変更) 弊社ではアプリケーションリポジトリを複数運用しており、いずれも ECS の起動タイプとして Fargate を採用しています。これらのリポジトリでは Terraform を直接取り扱うことはなく、ECS タスク定義テンプレート(JSON)の CPU / メモリ割り当て変更が主なコスト変動要因です。こちらは Terraform 版とは異なる設計が必要でした。 とりわけ重要だったのは Fargate 料金の動的取得です。当初は料金をプロンプト内にハードコードしていましたが、x86_64 と ARM64(Graviton)では Fargate の料金が異なるにもかかわらず、プロンプトに記載していたのは x86_64 の料金のみでした。そのため ARM64 タスクに対する PR であっても x86_64 価格で試算されてしまう状態になっており、ARM64 移行が進みつつあった当時の実態と乖離した見積もりが出てしまっていたのです。 最終的には AWS 公開の Pricing Bulk API から最新の Fargate On-Demand 料金を動的に取得し、タスク定義テンプレートの runtimePlatform.cpuArchitecture からアーキテクチャを判定して適切な料金を適用しています。プロンプト内では以下のように料金取得手順を明示しています。 ## Fargate 料金の取得 料金をハードコードせず、以下の手順で動的に取得すること: 1. タスク定義テンプレートを ` Read ` で読み、各タスクの CPU アーキテクチャ(ARM64 / X86 _ 64)を特定する - ` runtimePlatform.cpuArchitecture ` の値を確認する - テンプレート変数や条件分岐でアーキテクチャが切り替わる場合は、変数定義側も参照して実際の値を確定する - ` runtimePlatform ` が存在しない場合は X86 _ 64 とみなす 2. 以下のコマンドで ap-northeast-1 の最新 Fargate On-Demand 料金を取得する: ```shell-session $ curl -s "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonECS/current/ap-northeast-1/index.json" | jq ' .terms.OnDemand as $terms | [.products | to_entries[] | select(.value.attributes.usagetype | test("Fargate")) | select(.value.attributes.usagetype | test("Windows|Ephemeral") | not) | .key as $sku | {usagetype: .value.attributes.usagetype, price_usd: ($terms[$sku] | to_entries[0].value.priceDimensions | to_entries[0].value.pricePerUnit.USD)}]' ``` 3. usagetype とアーキテクチャの対応: - ` Fargate-vCPU-Hours:perCPU ` / ` Fargate-GB-Hours ` → X86 _ 64 - ` Fargate-ARM-vCPU-Hours:perCPU ` / ` Fargate-ARM-GB-Hours ` → ARM64 4. タスクのアーキテクチャに対応する料金と 730h/月 で試算する タスク定義の ` cpu ` / ` task_cpu ` は vCPU ユニット(1024 = 1 vCPU)、 ` memory ` / ` task_memory ` は MiB 単位です。 横展開:Reusable / Caller 構成への移行 当初、複数のアプリケーションリポジトリへの横展開は、ワークフロー YAML をそのままコピペする格好でおこないました。 しかしこの方式はすぐに破綻しました。横展開を完了した直後から、料金算出ロジックを実情に適うものにするためのプロンプトなどの修正や、利用中アクションの SHA pin 更新といった作業が連続で必要になったのです。すべてのリポジトリに同じ修正 PR を展開して回るというのは、地味に手間のかかる作業です。ワークフロー定義におけるリポジトリ固有の内容は一部に限られ、ゆえに大半が共通化可能なものでした。そこに修正が入るたびに、リポジトリ間での辻褄合わせの為に全リポジトリへ同じ変更を行う必要が生じていました。 そこで GitHub Actions の Reusable Workflows を活用し、以下の二層構成に整理し直す格好としました。 Reusable Workflow(テンプレートリポジトリ) 共通プロンプト(Fargate 料金取得ロジック、試算ルール、コメントフォーマット、セキュリティ指示) workflow_call トリガにて外部から呼び出し可能 inputs.repo_context (リポジトリ固有のタスク定義構成説明)と inputs.additional_rules (追加ルール)を受け取る Caller Workflow(各アプリケーションリポジトリ) トリガ条件( paths フィルタ)の定義 repo_context にリポジトリ固有の構成説明を記述 Reusable Workflow を呼び出すのみ Caller 側のコードは以下のように簡素です。 name : Claude Code Cost Estimate on : pull_request : types : [ opened, synchronize, ready_for_review ] paths : - 'app/task-definition/**' concurrency : group : claude-cost-estimate-${{ github.event.pull_request.number }} cancel-in-progress : true jobs : cost-estimate : uses : <自組織>/<テンプレートリポジトリ>/.github/workflows/reusable-claude-code-cost-estimate.yml@main with : repo_context : | ## リポジトリ構成 このリポジトリでは ECS タスク定義を以下のように管理しています : - `app/task-definition/template-*.json`: タスク定義テンプレート - `app/task-definition/variables-*/`: 環境ごとの変数オーバーライド ... secrets : CLAUDE_CODE_OAUTH_TOKEN : ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} uses: の記法は <owner>/<repo>/<path>@<ref> の形式で、別リポジトリにある Reusable Workflow を参照します( GitHub 公式ドキュメント )。 @<ref> にはブランチ・タグ・コミット SHA のいずれも指定できます。 repo_context は Reusable Workflow 側のプロンプトにそのまま埋め込まれる文字列入力です。リポジトリ固有のタスク定義の置き場所や YAML 構造、その他の注意事項を自然言語で記述しておけば、Claude は PR 差分をその文脈で解釈してくれます。従来であればリポジトリごとに構造解析ロジックを書き分ける必要があったところを、自然言語で補足を書くだけで済ませられるのは LLM を挟む大きな利点です。 この構成の嬉しさは明白です。Fargate 料金取得ロジックの修正や利用中アクションの SHA pin 更新が必要になった際、テンプレートリポジトリの 1 箇所を修正するだけで全リポジトリに反映されます。各リポジトリの Caller は自身の構成説明( repo_context )にのみ責任を持てばよく、共通部分の保守から解放されました。 なお Reusable Workflow を別リポジトリから参照するにあたっては、以下 2 つの設定が必要です。詳細は GitHub 公式ドキュメント を参照してください。 呼び出し元リポジトリ : Settings > Actions > General にて "Allow <自組織>, and select non-<自組織>, actions and reusable workflows" を有効化する テンプレートリポジトリ : Settings > Actions > General の "Access" セクションにて、他リポジトリからの参照を許可する 運用風景 実際に投稿されているコメントの例を以下に紹介します(機密情報は適宜マスクしています)。 コスト影響がない変更の場合 Fluent Bit のログフィルタ設定追加のような、AWS リソースの追加・変更・削除を伴わない PR に対しては、以下のように簡潔に報告されます。 コスト影響なしの報告例: Fluent Bit のログフィルタ設定追加 PR に対するコメント コスト影響がある変更の場合 具体的な金額差を伴う試算結果の例として 2 つ挙げます。 ECS タスクのアーキテクチャを x86_64 から ARM64 に変更する PR では、Fargate 料金の差分がタスクごとに算出されます。 コスト影響ありの報告例: ECS タスク定義の ARM64 移行 PR に対するコメント Terraform 側の例としては、OpenSearch マスターノードのインスタンスタイプ変更 PR に対して、インスタンス単価の根拠とともに月額差分が算出されます。 コスト影響ありの報告例: OpenSearch マスターノードのインスタンスタイプ変更 PR に対するコメント 前者のようにコード変更量は小さいがコスト影響が見えづらいケースや、後者のように一見スペックアップに見えてコスト削減となる直感に反するケースにおいて、数値根拠と併せて即座に金額影響が可視化されるのはレビュアーにとっての判断材料として有用です。 おわりに PR 上の Terraform 変更や ECS タスク定義変更に対して Claude Code にコスト影響を自動試算させる仕組みと、Reusable Workflow による全リポジトリへの横展開について紹介しました。 今回の取り組みで得られた利点は以下の通りです。 試算の自動化:コスト影響の有無が PR 上で自動的に可視化され、手動での料金表引きが不要になった レビュー観点の底上げ:「このリソース変更はいくらかかるのか」が PR コメントとして残るため、レビュアーがコスト観点でも判断できるようになった 保守性:Reusable Workflow への集約により、共通ロジックの修正が 1 箇所で済むようになった 一方で、横展開の過程ではプロンプトの修正や利用中アクションの SHA pin 更新など、全リポジトリに修正 PR を展開する羽目になる場面が複数ありました。最初から Reusable Workflow 構成にしておけばよかったと思わないでもないですが、まずは動くものを 1 リポジトリで作り、その後横展開しつつ構成を洗練させてゆくアプローチは結果的には妥当だったと考えています。実際に横展開をおこなって初めて見えてくる問題(アーキテクチャごとの料金差異など)もあり、最初から完璧な設計を目指すよりも実地で鍛えてゆくほうが確実なものが出来あがる向きもあります。 また、横展開とは別の文脈ですが、実装初期に遭遇した小さな事件として、コスト試算コメントを gh pr comment --body オプションにインラインで渡していたところ、本文中の $0 が bash のシェル変数として展開され /bin/bash に化けるというものもありました。 --body-file 経由に変更して解決しましたが、LLM を GHA 上で取り扱う際にはシェルとの境界に注意が必要であるという学びを得ています。 Terraform のコスト試算ツールとしては Infracost のような専用ツールもあります。今回それらを採用しなかったのは、PR 上でザックリ差額感を掴めれば充分で、専用ツールを導入・運用するほどに精緻な分析を求めていたわけではない、という向きが大きいです。また弊社では既に Claude Code を PR レビューの自動化で使っており、その延長線上で賄えるという点も後押しとなりました。その点、Claude Code を使うアプローチは「diff の文脈を理解した上で、コスト影響のない変更を適切にスキップできる」「プロンプトの修正のみで試算ルールを柔軟に変更できる」という固有の嬉しさがあり、要求水準に充分適うものとなりました。 PR レビュープロセスにコスト観点を自然に組み込みたい向きに、本稿で紹介した事例が一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.7 が作成した内容をほぼそのまま使用しています 当時の調査では anthropics/claude-code-action の #1205 や #1126 といったものを参照していました。原因の完全な特定には至らなかったのですが、CLI 直接実行への切り替えにより事象は解消しています。 ↩
はじめに 弊社では複数の Amazon OpenSearch Service ドメインを運用しています。これらのドメインはいずれも VPC 内に閉じた構成をとっており、セキュリティ強化を目的に きめ細やかなアクセス制御 (Fine-grained Access Control; FGAC)を有効にしています。 FGAC は「誰が OpenSearch 上でどの操作を許可されるか」をロール単位で制御する仕組みです。設定には OpenSearch の Security API を VPC 内から呼び出す必要があります。以前は手順書にもとづいて手作業で設定していましたが、OpenSearch を利用するサービスやドメインが増えるにつれてスケールしなくなってきました。 本稿では、この課題への対策として以下アプローチを採ることとしました。 FGAC 設定(ロールおよびマッピングの各定義;後述)を Terraform コードとして定義 OpenSearch ドメインへの FGAC 設定投入を VPC 内で CodeBuild を実行することで実施するようリソースを定義 以下でこのアプローチの詳細について扱います。 FGAC で設定するもの FGAC の設定対象は大きく2つです。 ロール定義 :OpenSearch 上のロールが持つ権限(どのインデックスにどの操作を許可するか等)を記述する ロールマッピング :上記ロールに「誰を」紐づけるかを記述する。AWS 環境では IAM ロールや IAM ユーザを OpenSearch 上のロールに紐付けることに対応する これを踏まえ、弊社では用途に応じて以下の3種のロールを定義し運用しています。 ロール名 用途 マッピング対象 all_access / security_manager 管理者権限 OpenSearch の master user として機能する IAM ロール mntsq アプリケーション用 各サービスの ECS タスクロール等、OpenSearch にアクセスする IAM ロール mntsq_operators 運用者用 OpenSearch 運用を担当するメンバが使う IAM ロール 課題と方針 OpenSearch を利用するサービスが追加されると、そのサービスの IAM ロールを mntsq ロールの backend_roles に追加する作業が発生します。また、新しい OpenSearch ドメインが作成された場合には3つ全てのロール設定を一から行う必要があります。この作業を続けることで以下のような課題が浮かび上がってきました。 手順書はあるが手作業であり、設定内容の差分管理やレビューができない OpenSearch ドメインの追加や既存設定の修正が発生するたびに手作業が必要となり、運用負荷が高い こうした課題を解消すべく、FGAC 設定をコードとして管理し、OpenSearch への適用も自動化することを目指しました。 terraform-provider-opensearch ではダメなのか OpenSearch のリソースを Terraform で管理する手段としては terraform-provider-opensearch が存在します。「それで十分ではないか」という指摘はもっともですが、弊社の構成ではこのアプローチは成立しません。 理由は VPC にあります。弊社の OpenSearch ドメインは VPC 内に閉じており、パブリックエンドポイントを持ちません。terraform-provider-opensearch は Terraform の実行環境から直接 OpenSearch にリクエストを送る必要がありますが、弊社では Terraform の実行主体は GitHub Actions であり、VPC の外にいます。つまり Provider が OpenSearch に到達できません。 「であれば Terraform の実行環境自体を VPC 内に配置すればよいのではないか」という考えもあります。たとえば CodeBuild を GitHub Actions の self-hosted runner として VPC 内で動かす方法 があります。しかしこの構成では FGAC 設定に限らず全ての terraform plan / terraform apply が VPC 内を経由することになります。FGAC の設定のためだけに Terraform 全体の実行環境を切り替えるのは割に合いません。 採用したアーキテクチャ terrarform-provider-opensearch を使わず、かつ必要な設定内容を IaC し、その反映も自動化したい。こうした些か欲張りな要件を満たすため、「定義と適用を分離する」アプローチを採用しました。考え方はシンプルです。 Terraform が担うこと:FGAC のロール定義・ロールマッピングをコードとして宣言し、CodeBuild プロジェクトの環境変数に JSON として注入する CodeBuild が担うこと:VPC 内で起動し、環境変数から受け取った JSON を OpenSearch Security API に PUT する 概念図 Terraform はあくまで「何を設定するか」を管理し、「設定を OpenSearch に届ける」部分は VPC 内で動ける CodeBuild に委ねる形です。 実装 FGAC ロール定義 3種のロール定義とロールマッピングは Terraform の locals ブロックで宣言しています。 locals { fgac = { # 管理者用 admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } # アプリケーション用 app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten ( [ for role_name in config.user_iam_roles : data.aws_iam_roles.targets [ role_name ] .arns ] ) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = [ "*" ] index_permissions = [{ index_patterns = [ "*" ] fls = [] masked_fields = [] allowed_actions = [ "*" ] }] tenant_permissions = [{ tenant_patterns = [ "*" ] allowed_actions = [ "kibana_all_write" ] }] } } # 運用者用 ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains ( [ "group-a" , "group-b" , "group-c" ] , group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots" , "cluster_monitor" , "cluster:admin/opendistro/ism/policy/search" , "cluster:admin/opendistro/ism/policy/get" , # ... ISM / 通知関連の権限が続く ] index_permissions = [{ index_patterns = [ "*" ] allowed_actions = [ "get" , "search" , "read" , "indices_monitor" , "manage" ] }] tenant_permissions = [] } } } } アプリケーション用( app )について補足します。 var.opensearch は OpenSearch ドメインをキーとする map で、環境層の main.tf にて各ドメインごとに user_iam_roles (そのドメインにアクセスする必要のある IAM ロール名のリスト)を定義しています。 assign_json_map はこの user_iam_roles をもとに IAM ロール ARN を引き当て、ドメインごとの backend_roles を動的に構築します。OpenSearch ドメインによってアクセス元のサービスが異なるため、マッピングもドメイン単位で分かれる必要があるということです。 運用者用( ops )は少し毛色が異なります。アプリケーション用では IAM ロールを backend_roles に設定していましたが、運用者用では IAM Identity Center のグループ ID を backend_roles として使います。 data.aws_identitystore_groups で運用担当のグループを引き、そのグループに所属するメンバが OpenSearch Dashboards にアクセスした際に適切な権限が付与されるようにしています。 管理者用( admin )にはロール定義がありません。 all_access と security_manager は OpenSearch に組み込みで存在するロールであり、権限の内容を改めて定義する必要がないためです。ここで管理するのは「誰をそのロールに紐づけるか」というマッピングだけです。 CodeBuild プロジェクト FGAC の設定を OpenSearch に届けるには VPC 内から Security API を呼び出す必要がある、という点はここまでで述べたとおりです。CodeBuild には vpc_config を指定することでビルド環境を VPC 内のサブネットで起動する機能があります。これを利用すれば、Terraform 自体は VPC 外で動かしつつ、設定の適用だけを VPC 内で実行できます。 上述 locals を jsonencode() で JSON 文字列に変換し、CodeBuild プロジェクトの環境変数に渡します。HCL のオブジェクトはそのままでは環境変数(文字列)として注入できないため、この変換が必要です。 resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "$ { var.env } -$ { var.service } -$ { each.key } -fgac-configuraton-manager" build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.app.assign_json_map [ each.key ] ) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://$ { module.opensearch [ each.key ] .domain.custom_endpoint } " } } # VPC 内で実行するための設定 vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [ aws_security_group.codebuild.id ] } source { type = "NO_SOURCE" buildspec = templatefile ( "$ { path.module } /buildspecs/fgac_configuration_manager.yaml.tmpl" , {} ) } } for_each = var.opensearch としているので、OpenSearch ドメインの数だけ CodeBuild プロジェクトが作成されます。各プロジェクトの環境変数にはそのドメイン固有のマッピング情報が入ります。 vpc_config ブロックで VPC ID、プライベートサブネット、セキュリティグループを指定することで、CodeBuild のビルド環境が VPC 内で起動するようになります。これが本構成の核心です。 IAM ロール CodeBuild が OpenSearch の master user として振る舞うためのロールを定義しています。 resource "aws_iam_role" "opensearch_master_role" { name = "$ { var.env } -$ { var.service } -opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com" , "opensearch.amazonaws.com" , ] } } } このロールは2つのサービスから Assume されます。 codebuild.amazonaws.com は CodeBuild の実行ロールとして、 opensearch.amazonaws.com は OpenSearch ドメインの master user として、それぞれこのロールを使います。ひとつのロールに両方の信頼関係を持たせることで、"CodeBuild がこのロールで実行する = OpenSearch の master user として振る舞える" という構図を成立させています。 Buildspec FGAC のロール定義やロールマッピングは、OpenSearch の Security REST API に対して PUT リクエストを送ることで設定します。利用するエンドポイントは以下の2つです。 /_plugins/_security/api/roles/{ロール名} :ロール定義の作成及び更新 /_plugins/_security/api/rolesmapping/{ロール名} :ロールマッピングの作成及び更新 CodeBuild ではこの API 呼び出しを以下の buildspec で実行しています。 version : 0.2 phases : install : commands : - pip3 install awscurl build : commands : # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT' やっていることは素直です。環境変数から JSON を取り出してファイルに書き、 awscurl (AWS SigV4 署名付きの curl)で OpenSearch Security API に PUT します。各ロールについて ロール定義の PUT → ロールマッピングの PUT の順で実行します。管理者用は組み込みロールへのマッピングのみなのでロール定義の PUT はありません。 awscurl を使うことで IAM ロールベースの認証が自動的に行われるため、API キーやパスワードの管理は不要です。 以上の実装をまとめたコード全体を以下に示します。 Terraform コード全体(クリックで展開) # ================================================== # データソース # ================================================== data "aws_iam_roles" "targets" { for_each = toset ( flatten ( values (var.opensearch) [ * ] .user_iam_roles)) name_regex = ".*$ { each.value } $" } data "aws_ssoadmin_instances" "main" {} data "aws_identitystore_groups" "main" { identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] } # ================================================== # FGAC ロール定義(locals) # ================================================== locals { fgac = { admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten ( [ for role_name in config.user_iam_roles : data.aws_iam_roles.targets [ role_name ] .arns ] ) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = [ "*" ] index_permissions = [{ index_patterns = [ "*" ] fls = [] masked_fields = [] allowed_actions = [ "*" ] }] tenant_permissions = [{ tenant_patterns = [ "*" ] allowed_actions = [ "kibana_all_write" ] }] } } ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains ( [ "group-a" , "group-b" , "group-c" ] , group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots" , "cluster_monitor" , "cluster:admin/opendistro/ism/policy/search" , "cluster:admin/opendistro/ism/policy/get" , "cluster:admin/opendistro/ism/policy/write" , "cluster:admin/opendistro/ism/policy/delete" , "cluster:admin/opensearch/notifications/channels/get" , "cluster:admin/opensearch/notifications/configs/get" , "cluster:admin/opensearch/notifications/configs/create" , "cluster:admin/opensearch/notifications/configs/update" , "cluster:admin/opensearch/notifications/configs/delete" , "cluster:admin/opensearch/notifications/features" , "cluster:admin/opensearch/notifications/feature/send" , ] index_permissions = [{ index_patterns = [ "*" ] dls = "" fls = [] masked_fields = [] allowed_actions = [ "get" , "search" , "read" , "indices_monitor" , "manage" ] }] tenant_permissions = [] } } } } # ================================================== # IAM ロール・ポリシー # ================================================== data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com" , "opensearch.amazonaws.com" , ] } } } resource "aws_iam_role" "opensearch_master_role" { name = "$ { var.env } -$ { var.service } -opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "codebuild_permissions" { statement { effect = "Allow" actions = [ "logs:CreateLogGroup" , "logs:CreateLogStream" , "logs:PutLogEvents" , "ec2:CreateNetworkInterface" , "ec2:DescribeDhcpOptions" , "ec2:DescribeNetworkInterfaces" , "ec2:DeleteNetworkInterface" , "ec2:DescribeSubnets" , "ec2:DescribeSecurityGroups" , "ec2:DescribeVpcs" , "ec2:CreateNetworkInterfacePermission" , ] resources = [ "*" ] } } data "aws_iam_policy_document" "opensearch_permissions" { statement { effect = "Allow" actions = [ "es:*" ] resources = [ "*" ] } } resource "aws_iam_policy" "codebuild" { name = "$ { var.env } -$ { var.service } -codebuild-permissions" policy = data.aws_iam_policy_document.codebuild_permissions.json } resource "aws_iam_role_policy" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy = data.aws_iam_policy_document.opensearch_permissions.json } resource "aws_iam_role_policy_attachment" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy_arn = aws_iam_policy.codebuild.arn } # ================================================== # セキュリティグループ # ================================================== resource "aws_security_group" "codebuild" { name = "$ { var.env } -$ { var.service } -codebuild" vpc_id = local.remote_state_core.vpc.vpc_id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = [ "0.0.0.0/0" ] } } # ================================================== # CodeBuild プロジェクト # ================================================== resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "$ { var.env } -$ { var.service } -$ { each.key } -fgac-configuraton-manager" description = "Configure OpenSearch FGAC roles for $ { each.key } in $ { var.env } " build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn artifacts { type = "NO_ARTIFACTS" } environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" image_pull_credentials_type = "CODEBUILD" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.app.assign_json_map [ each.key ] ) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode (local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode (local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://$ { module.opensearch [ each.key ] .domain.custom_endpoint } " } environment_variable { name = "AWS_REGION" value = data.aws_region.current.region } } logs_config { cloudwatch_logs { status = "ENABLED" } } vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [ aws_security_group.codebuild.id ] } source { type = "NO_SOURCE" buildspec = templatefile ( "$ { path.module } /buildspecs/fgac_configuration_manager.yaml.tmpl" , {} ) } } Buildspec 全体(クリックで展開) version : 0.2 phases : install : commands : - pip3 install awscurl build : commands : # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT' 運用フロー 初期セットアップ 新しい OpenSearch ドメインを作成した場合の流れは以下のようになります。 terraform apply で OpenSearch ドメインと CodeBuild プロジェクトが作成される AWS コンソールから対象の CodeBuild プロジェクト( ${ENV}-search-${DOMAIN}-fgac-configuraton-manager )を手動で Start build する FGAC のロールとマッピングが OpenSearch に設定される terraform apply は CodeBuild プロジェクトの定義(環境変数やビルド環境の設定)を作成するのみで、ビルド自体の実行は行いません。そのため初回は手動で Start build する必要があります。 設定が正しく反映されたかどうかは、OpenSearch の /_plugins/_security/authinfo API で確認できます。対象の IAM ロールで OpenSearch にリクエストを送り、レスポンスの roles フィールドに期待するロール名が含まれていれば設定は成功です。 設定変更 新しいサービスが OpenSearch にアクセスする必要が生じた場合の流れは以下のようになります。 環境層の main.tf で該当ドメインの user_iam_roles にロール名を追加する PR を作成してレビューを受ける(ここで設定差分がコードとして見える) terraform apply で CodeBuild プロジェクトの環境変数が更新される CodeBuild を再実行して新しいマッピングを OpenSearch に適用する ステップ 3 の terraform plan では、CodeBuild プロジェクトの環境変数( APP_MAPPING_DEFINITION_JSON 等)の値に差分が出ます。JSON 文字列の diff として「どの IAM ロール ARN が追加されたか」が確認できるため、レビュー時の判断材料になります。 Security API の PUT は既存の設定を全量上書きする動作であるため、CodeBuild の実行は冪等です。同じ設定で何度実行しても結果は変わりません。誤って二重実行してしまった場合でも問題は生じません。 設定変更が PR として可視化されるのが、手作業時代との大きな違いです。「どの環境のどのドメインに、どの IAM ロールがいつ追加されたか」がコードの履歴として残ります。 CodeBuild の手動実行について 現時点では terraform apply の後に CodeBuild を手動で実行するステップが残っています。 terraform apply だけで設定の反映まで完結するのが理想ではありますが、現状の構成でも手作業で行っていた FGAC 設定がコード管理下に入り、適用も CodeBuild のボタンひとつで済むようになったことで、運用負荷は大きく改善されています。 おわりに VPC 内に閉じた OpenSearch の FGAC 設定を構成管理するにあたり、terraform-provider-opensearch が使えないという制約のもとで Terraform で宣言し、CodeBuild で適用する というアプローチを紹介しました。 Terraform の得意なこと(宣言的な定義、差分管理、環境間の一貫性)と CodeBuild の得意なこと(VPC 内での任意のコマンド実行)を組み合わせることで、Provider が直接使えない領域にも構成管理の恩恵を持ち込むことができます。VPC 内 OpenSearch に限らず、「Terraform の実行環境からは到達できないが設定をコード管理したい」という場面で応用できるパターンかと思います。 VPC 内に存在する諸要素の構成管理に課題を感じている方に本稿で紹介した事例が一助となれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.6 が作成した内容をほぼそのまま使用しています
SREの藤原です。 MNTSQではセールス、コンサルティング、テクニカルサポートのメンバーなどが顧客からの問い合わせに回答する際に参照するセキュリティホワイトペーパーが存在しています。 このセキュリティホワイトペーパーを、これまではGoogle Docsにて管理していました。 これをマークダウン + GitHubリポジトリでの管理に移行したので、その事例エントリです。 Google Docsで管理する際に発生していた問題 Google Docs自体は共同編集ツールとしては非常に優れています。 一方で、社内で公式に参照する文書という観点では課題を抱えていました。 主な課題としては以下の2点です。 変更管理の難しさ 複数人で異なる変更を行う場合にそれぞれを個別に変更として取り込むことがGoogle Docsでは困難でした 最新版か否かの分かりづらさ Google Docs自体にも版管理機能はありますが、リリース版のファイルを個別で管理する必要があるなど少々面倒な側面があります。 特定のファイルが公式としての変更が加えられたものなのか、それとも変更は加えられているが、それがまだレビュー中なのか?といった判断が難しい(変更の提案機能などをつかうこともできるが、必ずしもそれを全員が使うわけでもありません) 解決策としてのGitHub管理 解決策は非常にシンプルでGoogle Docsではなく、GitHubでマークダウンとして管理するという方式にしました。 元のドキュメント自体もGoogle Docsでなければ困るような高度な機能を利用しているわけでもなかったため、マークダウンで実現可能な表現力で十分であるという判断のもと、マークダウン+GitHubに移行しました。 GitHubのリポジトリにて章ごとにホワイトペーパーが管理されている様子 章ごとにマークダウンファイルを分割することで、変更のコンフリクトが起こりづらいようにしています。 以降では、GitHubに移行することによるメリットについて述べていきます。 メリット1: 変更管理がPRベースになった GitHubに移行したことで、文書の変更がプルリクエスト(PR)ベースで管理されるようになりました。結果として以下を容易に実現できるようになりました。 どの章を、誰が、なぜ変更したか、がコミットメッセージとPR説明に残ります レビュアーは差分(diff)を見て内容を確認できます マージ前に承認フローを設定することで、「誰でも勝手に書き換えられる」リスクを排除できます これはコードのレビューとまったく同じフローだ。エンジニアにとっては馴染み深いプロセスなので、文書の品質管理がやりやすくなりました。 メリット2:Claude Codeで文書修正が容易になった 章ごとにマークダウンで分かれているため、Claude Codeを使った文書修正が非常にやりやすくなりました。 たとえば、まずは、キーワードのみをClaude Codeに与えて文章を出力した後に、文書内容を確認して、問題ないかといったことができるようになりました。またその修正結果はそのままGitのdiffとして確認でき、PRを作ってレビューするだけで取り込むこともできますし、他の人からのレビューもしやすくなります。 人が文章を記述するよりもClaude Codeの方が書き上げるまでにかかる時間は短く、文章の質ともより高いものになります。キーワードとおおよその文脈情報さえ与えれば、ほとんどの場合において、内容を確認して一部だけ修正することで改善できます。 メリット3: GitHub Actionsを使ったPDF出力と、リリース管理 「公式文書としての最新版管理」を実現するため、GitHub Actionsのワークフローを組みました。 仕組みは次の通りです。 任意のタイミングでリリース版作成のGitHub Actionsワークフローをトリガーします ワークフロー内でタグをきり、プレリリースを作成します マークダウンファイルを結合してpdfファイルを作成します 3で作成したpdfファイルを2で作成したプレリリースに成果物として付与します GitHub Actionsワークフローでpdf出力しているイメージ ここで、プレリリースまでとしているのはpdfファイルの体裁などが崩れていないか?を確認したのちに、リリースに昇格することを意図しています。 GitHubリポジトリに作成されたリリースの例、アーティファクトとしてのpdfも確認できる GitHubリポジトリのリリースを見れば、最新版のドキュメントにたどり着けるようになります。 移行時の注意点 ただし、マークダウンに移行する際の注意点としては、以下の2点が挙げられます。 日本語フォント GitHub Actionsでは日本語フォントを明示的にインストールする必要があります。 fonts-noto-cjkなどをインストールして利用しましょう。 pdfへの変換ツールの選定 本事例では md-to-pdf を利用しています。今回要求されている事例ではこれで要件を満たせていたため、問題はなかったのですが、高度な組版などが要求される場合はそれにフィットしたツール( Pandoc や Vivliostyle.js 、 Re:VIEW など)が必要になるかもしれません。 github.com pandoc.org vivliostyle.org reviewml.org まとめ ここまでGoogle Docsで管理されていたドキュメントをGitHub + マークダウン管理に移行した事例について述べました。これによって、ドキュメントの変更管理と最新版配布などが容易にできるようになりました。特に定期的な更新が発生しつつ、SSoTとして正確性が重要になる文書にはフィットするとおもいます。 本事例が文書管理に悩んでいる組織の参考となれば幸いです。
はじめに SREの寺島です。 MNTSQでは、本番環境でのAWSの手動操作や顧客情報データへのアクセス等をCloudTrailログから検知し、操作者に目的や理由を確認するセキュリティ監査を運用しています(詳細は こちらの記事 を参照)。 これまではログからの異常検知は自動化されていたものの、その後の操作内容の確認や目的や理由を確認する運用が人力で行われており、運用上のToilとなっていました。 この課題を解決するため、今回、セキュリティ監査運用を自動化するSlack Botを開発しました。本記事では、そのアーキテクチャから実装の詳細までを紹介します。 はじめに 背景 監査の仕組み 課題 どのようなものを作ったか 動作フロー 通知 回答 リマインド アーキテクチャ コンポーネント一覧 Slack Appの設定 各機能の実装 通知(notify-audit-report) 回答受け付け(audit-response-handler) リマインド(send-audit-reminder) 導入効果 コスト面 機能・運用面 セキュリティ面・ガバナンス面 最後に 背景 監査の仕組み MNTSQでは、本番環境に対する以下のような操作をセキュリティ監査の対象としています。 検知対象 データソース 本番系AWSアカウントでの手動変更操作 CloudTrail 顧客情報を収容するS3バケットへのアクセス CloudTrail 業務時間外のRedash操作・ログイン Redashサーバログ / CloudTrail(IAM Identity Center) これらの検知からSREメンバーへの通知までは自動化されていました。EventBridgeをトリガーに週次でAthenaの名前付きクエリを実行し、結果をSREチャンネルに通知する仕組みです。 例えばAWSの手動変更操作の場合、以下のようなAthenaクエリが実行されます。 select ${環境名} as environment, eventtime, split_part(userIdentity.principalId, ' : ' , 2 ) as email, eventName, resources[ 1 ].arn as resource , requestParameters from ${環境名} where timestamp between date_format( current_date - interval ' 1 ' day, ' %Y/%m/%d ' ) and date_format( current_date , ' %Y/%m/%d ' ) -- 個々人の直接操作を対象とする and userIdentity.principalId like ' %@mntsq.com ' -- 新設 / 変更 / 削除に該当する操作を対象とする and ( eventName like ' Update% ' or eventName like ' Put% ' or eventName like ' Create% ' or eventName like ' Delete% ' or eventName like ' Modify% ' ) -- CloudShell そのものに関するイベントは対象から外す and eventsource != ' cloudshell.amazonaws.com ' 個人の手動操作のうち、リソースの作成・変更・削除にあたるイベントを抽出しています。 不審な操作が検知されると、以下のようにSlackへ通知されます。 課題 検知までは自動化されていた一方で、その後の操作内容の確認・操作者へのヒアリングは手作業となっていました。 具体的な作業フロー 担当者がAthenaの結果を確認 ログを読み解いて操作内容を把握、背景を確認 操作者に個別にヒアリング 回答がなければリマインド この作業を担当していたSREメンバーは、週に約3時間をこの確認フローに費やしていました。年間に換算すると約1人月の工数です。これは弊社のSREチームの規模を考えると無視できないコストでした。 どのようなものを作ったか 上述の課題を解決するため、セキュリティ監査の運用を自動化するSlack Botを開発しました。 このSlack Botに「A2RM(Automatic Audit Reaction Mechanism)」と名付けました。 別名「監査回答強制マン」です。 Slack Botは、主に以下の4つの機能を備えています。 Amazon Bedrockによるログ要約 : 操作ログを自然言語に変換して操作対象者に通知 Slack UIによる回答受付 : ボタン選択による回答の簡略化 自動リマインド : 未回答者への追跡通知 証跡管理 : 検知内容と回答結果のDynamoDB保存 動作フロー 通知 不審な操作が検知されると、Slackチャンネルへ操作者本人をメンションしたメッセージが投稿されます。 CloudTrailの生ログは可読性が低く、内容の把握に一定の知識を要します。 例えば、S3バケットポリシーの変更操作を行った場合、Athenaから出力されるCSVは以下のようになります。 "environment","eventtime","email","eventName","resource","requestParameters" "example-env","2026-03-06T07:57:17Z","user@example.com","PutBucketPolicy","arn:aws:s3:::example-tfstate","{""bucketPolicy"":{""Version"":""2012-10-17"",""Statement"":[{""Sid"":""AllowDatadogReadAccess"",""Effect"":""Allow"",""Principal"":{""AWS"":""arn:aws:iam::123456789012:root""},""Action"":[""s3:GetObject"",""s3:ListBucket""],""Resource"":[""arn:aws:s3:::example-tfstate/*"",""arn:aws:s3:::example-tfstate""]}]},""bucketName"":""example-tfstate"",""Host"":""example-tfstate.s3.ap-northeast-1.amazonaws.com"",""policy"":""""}" このログはDatadogへの参照権限付与という内容ですが、パッと見て判断するのは困難です。 そこで、Amazon Bedrockを用いて以下のように自然言語で要約しています。 💡 変更概要: S3バケットに対するポリシー更新操作が検知されました。 example-tfstate バケットのポリシーが変更され、外部AWSアカウントに対して s3:GetObject と s3:ListBucket の権限が付与されました。これは、Datadogからの読み取りアクセスを許可するための変更と推測されます。 📋 検知された操作: ・S3バケット example-tfstate のポリシー更新(Datadogからの読み取りアクセス許可) (16:57) このように要約された内容を通知することで、操作者が「自分のどの作業か」を瞬時に判断できるようになります。 回答 この監査の仕組みによって検知される操作の多くは「デプロイ」や「障害対応」などの定型作業です。そのため、ボタン選択式で回答できるようにし、操作者の記述負荷を最小限に抑えています。 定型作業の場合 : ボタンをクリックするだけで回答が完了します。 イレギュラーな操作の場合 : 「その他」を選択した場合のみモーダルが開き、自由記述と関連URL(チケット等)を入力します。 回答が完了すると、元のSlackメッセージは「回答済み」のステータスに更新されます。 回答内容はSREチームのチャンネルへ転送・DBに記録されます。 リマインド 回答がないまま翌営業日を迎えた場合、該当メッセージのスレッドに対して自動でリマインドを投稿します。3回リマインドを行っても回答が得られない場合は、SREチームへのエスカレーション通知に切り替わります。 アーキテクチャ 3つのLambda関数がそれぞれ独立した責務を持ち、DynamoDBを介して状態を共有するイベント駆動のアーキテクチャです。 コンポーネント一覧 コンポーネント 種別 説明 notify-audit-report Lambda Athena結果をBedrockで要約しSlack通知 audit-response-handler Lambda Function URL Slackインタラクティビティの処理 send-audit-reminder Lambda 未回答者への自動リマインド audit_attestations DynamoDB 回答状態の管理,証跡の蓄積 Amazon Bedrock (Nova Pro) LLM 操作ログの自然言語要約 各コンポーネントの実装の詳細は後述します。 Slack Appの設定 本システムのSlack Appに必要なOAuth Scopesは最小限です。 Scope 用途 chat:write メッセージ投稿・更新 reactions:write リアクション追加 users:read.email メールアドレスからユーザーID検索 users:read users:read.email の前提として必要 認証情報(Bot Token / Signing Secret)はSSM Parameter Store(SecureString)に保管し、Lambda実行時に取得します。 Slack AppのInteractivityのRequest URLには、Lambda Function URLを指定します。 各機能の実装 通知(notify-audit-report) 操作ログの要約生成からSlack通知までの処理について解説します。 処理フロー EventBridgeでAthenaクエリの完了を検知し、Lambdaを起動 S3からAthenaの結果CSVを取得 ユーザー(メールアドレス)ごとにグルーピング Bedrockで操作ログを自然言語に要約 操作者にメンション付きでSlack通知を投稿 DynamoDBにレコードを保存(リマインド・回答追跡用) EventBridgeのイベントパターンでAthenaのワークグループごとにクエリ完了を検知し、 input_transformer を用いて必要なメタデータ(S3パスや監査種別)をLambdaへ渡しています。 resource "aws_cloudwatch_event_rule" "a2rm_notify_audit_report" { event_pattern = jsonencode ( { "source" : [ "aws.athena" ] , "detail-type" : [ "Athena Query State Change" ] , "detail" : { "workgroupName" : [ "manual-modification-report" ] , "currentState" : [ "SUCCEEDED" ] } } ) } resource "aws_cloudwatch_event_target" "a2rm_notify_audit_report" { input_transformer { input_paths = { queryExecutionId = "$.detail.queryExecutionId" } input_template = <<-EOT { "s3_bucket": "athena-results-bucket", "s3_key": "cloudtrail/manual_modification/<queryExecutionId>.csv", "audit_type": "manual_operation" } EOT } } CloudTrailログからSlackメンションへの変換 MNTSQではAWSへのログインに IAM Identity Center を利用しています。これにより、CloudTrailの userIdentity 内にユーザーのメールアドレスが記録されるようになっています。 { " type ": " AssumedRole ", " principalid ": " AROA...:user@example.com ", " arn ": " arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_Administrator_.../user@example.com ", " sessioncontext ": { " sessionissuer ": { " type ": " Role ", " arn ": " arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/.../AWSReservedSSO_Administrator_... " } } } principalid や arn にメールアドレスが含まれるため、Athenaクエリでこれを抽出します。Slack APIの users.lookupByEmail を用いてメールアドレスをSlack User IDに変換し、メンション文字列を生成しています。 def lookup_user_by_email (slack: WebClient, email: str ) -> str | None : try : response = slack.users_lookupByEmail(email=email) return response[ "user" ][ "id" ] except SlackApiError as e: logger.warning( "Slackユーザーの検索に失敗しました: %s: %s" , email, e.response[ "error" ]) return None Bedrock周り 操作ログの要約にはAmazon Bedrockの Amazon Nova Pro を使用しています。 このモデルを選定した理由は以下のとおりです。 Bedrockで使える他のモデルと比較して安価 検証段階で出力品質に問題がなかった 完璧な要約は求めておらず、操作者が内容を思い出せる程度の内容で十分だった Slack mrkdwn記法での構造化出力が安定していた Bedrockクライアントの実装はシンプルです。 bedrock_client = boto3.client( "bedrock-runtime" , region_name= "us-east-1" ) def generate_summary (prompt: str , model_id: str ) -> str : response = bedrock_client.invoke_model( modelId=model_id, body=json.dumps({ "messages" : [{ "role" : "user" , "content" : [{ "text" : prompt}]}], "inferenceConfig" : { "maxTokens" : 2048 , "temperature" : 0.3 , # 一貫性のある出力のため低めに設定 }, }), ) response_body = json.loads(response[ "body" ].read()) return response_body[ "output" ][ "message" ][ "content" ][ 0 ][ "text" ] temperature はLLMの出力のランダム性を制御するパラメータで、値が低いほど決定的な出力になります。監査レポートという性質上、同じ入力に対してできるだけ一貫した出力を得たいため、 0.3 と低めに設定しています。 実際に使用しているプロンプトの全文を以下に掲載します。 {csv_data} にAthenaの結果CSVが埋め込まれます。 あなたはAWS操作の監査レポートを生成するアシスタントです。 以下のCSVデータはAWS環境で検知された操作ログです。このデータを分析し、指定されたフォーマットでレポートを生成してください。 ## CSVデータ {csv_data} ## 出力フォーマット 以下のフォーマットで出力してください。Slackのmrkdwn記法を使用してください。 🗓 *最終検知日時:* YYYY-MM-DD HH:MM (JST) 💡 *変更概要:* (リソースのカテゴリごとに、何が行われ、どのような状態になったかを目的の推測を含めて2〜3行ずつ簡潔に要約してください。複数が混在する場合は箇条書きを使用してください) 📋 *検知された操作:* ・操作の説明 (HH:MM) ・操作の説明 (HH:MM) ... ## ルール - 変更概要は「複数のリソースが更新されました」といった抽象的な表現を避け、サービス単位(例:ECS関連、RDS関連、ACM関連など)でそれぞれの具体的な変更内容と目的を記述してください。 - eventtimeはUTCなのでJST(+9時間)に変換してください - 最終検知日時は最も新しいeventtimeをJSTで表示してください - 検知された操作は各行のeventNameとrequestParametersから人間にわかりやすい日本語の操作説明を生成してください - 操作説明にはリソース名やサービス名を含めてください - 同一のeventNameが多数ある場合は代表的なものをまとめて表示してください(例:「ECSサービスの更新 x5件」) - 最大20件まで表示し、それ以上は「…他N件」としてください ## 出力例(入力データとは無関係のサンプル) 入力: "environment","eventtime","email","eventName","resource","requestParameters" "production","2026-01-20T08:18:00Z","user@example.com","UpdateService",,"{{""cluster"":""prod-cluster"",""service"":""web-api"",""desiredCount"":3}}" "production","2026-01-20T08:15:00Z","user@example.com","UpdateService",,"{{""cluster"":""prod-cluster"",""service"":""worker"",""desiredCount"":0}}" 出力: 🗓 *最終検知日時:* 2026-01-20 17:18 (JST) 💡 *変更概要:* production環境のECSサービスに対する更新操作が検知されました。web-apiサービスのインスタンス数を3台に増加し、workerサービスを停止(0台)にする変更が行われており、デプロイまたはメンテナンス作業の一環と推測されます。 📋 *検知された操作:* ・ECSサービス web-api の更新(台数: 3) (17:18) ・ECSサービス worker の更新(台数: 0) (17:15) --- 上記のフォーマットに従い、レポートを出力してください。出力のみを返し、余計な説明や前置きは不要です。 いくつか工夫したポイントを記載します。 出力フォーマットをSlack mrkdwn記法で指定 : LLMの出力をそのままSlack Block Kitの mrkdwn テキストとして使えるようにしています。 抽象的な表現を禁止するルール : 指示しないと「複数のリソースが更新されました」のような無意味な要約を返すことがあったため、明示的に禁止しています 入出力例の提示 : Few-shot的に具体例を与えることで出力フォーマットの安定性が向上しました レコード数の上限 : 最大50件のCSV行をプロンプトに含め(LLM出力は20件まで表示)、超過分はその旨を注記します。レコード数が多すぎるとトークン制限に達する恐れがあり、要約の精度が低下するためです LLMの呼び出しに失敗した場合や正しくSlack mrkdwn記法で出力されなかった場合でも、通知自体は止めず「詳細ログを確認してください」というFallbackテキストを送信するように実装していますが、現時点では発生していません。 回答ボタンの生成 上述した通り、定型的な理由はボタン1クリックで回答できるようにし、説明が必要な例外的な操作だけモーダルで自由記述してもらう設計にしています。 以下のように監査種別ごとに回答ボタンを出し分けています。 AWS手動操作 : デプロイ、定型作業、障害対応。 S3/Redash操作 : 上記に加え「顧客問い合わせ対応」「顧客データ分析」などを追加。 BUTTONS_MANUAL_OPERATION = [ { "action_id" : "deploy" , "label" : "デプロイ・リリース" , "emoji" : "🚀" }, { "action_id" : "routine_work" , "label" : "定型作業" , "emoji" : "💫" }, { "action_id" : "incident_response" , "label" : "障害対応" , "emoji" : "🚨" }, { "action_id" : "other" , "label" : "その他(記述する)" , "emoji" : "📝" }, ] BUTTONS_S3_EVENT = [*_BASE_BUTTONS, _BUTTON_CUSTOMER_INQUIRY, _BUTTON_OTHER] BUTTONS_REDASH = [ _BUTTON_DEPLOY, _BUTTON_CUSTOMER_INQUIRY, _BUTTON_CUSTOMER_ANALYSIS, _BUTTON_INCIDENT_RESPONSE, _BUTTON_OTHER, ] ボタンは Slack Block Kit の actions ブロックとして構築します。 button_elements = [ { "type" : "button" , "text" : { "type" : "plain_text" , "text" : f "{btn['emoji']} {btn['label']}" }, "action_id" : btn[ "action_id" ], "value" : btn[ "action_id" ], } for btn in button_defs ] action_id が後続の回答処理でどのボタンが押されたかを判定するキーになります。 DynamoDBへの保存 通知投稿後、リマインドと回答追跡のためにDynamoDBにレコードを保存します。将来的な監査証跡としての活用も見据え、検知内容の要約も含めて保存しています。 属性 書き込みタイミング 説明 message_ts (PK) 通知時 Slackメッセージのタイムスタンプ channel_id 通知時 投稿先チャンネル slack_user_id 通知時 操作者のSlack User ID email 通知時 操作者のメールアドレス status 通知時 → 回答時に更新 pending → completed created_at 通知時 レコード作成日時 audit_type 通知時 監査種別( manual_operation 等) detection_summary 通知時 LLMの要約 or テンプレートテキスト(JSON) s3_result_url 通知時 Athena結果CSVのS3パス responded_at 回答時 回答日時 response_reason 回答時 選択された理由( deploy 等) response_content 回答時 回答内容の詳細(JSON) remind_count リマインド時に更新 リマインド回数 table.put_item( Item={ "message_ts" : message_ts, # PK: Slackメッセージのタイムスタンプ "channel_id" : channel_id, "slack_user_id" : slack_user_id, "email" : email, "status" : "pending" , "created_at" : now.isoformat(), "remind_count" : 0 , "s3_result_url" : s3_log_url, "audit_type" : audit_type, "detection_summary" : detection_summary, # LLMの要約 or テンプレートテキスト } ) パーティションキーにSlackの message_ts を使っています。Slackのメッセージタイムスタンプはチャンネル内で一意であり、スレッド返信やメッセージ更新の際にもこの値で元メッセージを特定できるためです。 ※ message_ts単体のPKなのは、本Botは単一の監査チャンネルに投稿する設計のためです。 resource "aws_dynamodb_table" "a2rm_audit_attestations" { billing_mode = "PAY_PER_REQUEST" hash_key = "message_ts" global_secondary_index { name = "status-created_at-index" hash_key = "status" range_key = "created_at" projection_type = "ALL" } global_secondary_index { name = "email-created_at-index" hash_key = "email" range_key = "created_at" projection_type = "ALL" } } status と created_at にGSI(グローバルセカンダリインデックス)を張ることで、リマインド対象となる「未回答(pending)」かつ「一定期間経過」したレコードを効率的に抽出できるようにしています。 また、将来的にユーザーごとの監査履歴を参照できるように email と created_at にもGSIを張っています。 回答受け付け(audit-response-handler) 操作者がSlack上でボタンを押下した際のインタラクション処理について解説します。 Slack Boltとlazy listener Slackのインタラクティブイベント(ボタン押下やモーダル送信)を処理するには、SlackからのHTTPリクエストを 3秒以内にACK する必要があります。しかし、DynamoDB更新やSlack APIの複数呼び出しを含む一連の処理は、3秒を超過する可能性があります。 この問題を Slack Bolt for Python の lazy listener パターンで解決しています。 app.action( "deploy" )(ack=ack_action, lazy=[process_standard_button]) app.action( "other" )(ack=ack_action, lazy=[process_other_button]) app.view( "audit_reason_other" )(ack=ack_view, lazy=[process_modal_submission]) ack 関数が即座に200レスポンスを返し、 lazy に指定した関数は 自身のLambdaを非同期で再invoke して別のLambda実行コンテキストで処理されます。この仕組みを実現するため、LambdaのIAMポリシーには自身へのInvoke権限を付与しています。 # lazy listener が自身を非同期呼び出しするために必要 statement { effect = "Allow" actions = [ "lambda:InvokeFunction" , "lambda:GetFunction" ] resources = [ "arn:aws:lambda:<略>:function:audit-response-handler" ] } Lambda Function URL の採用 回答処理のエンドポイントには Lambda Function URL を採用しました。 Interactivity Webhookの認証は、Slackがリクエストヘッダーに付与する署名の検証(Signing Secretによる検証)によって行われるため、API Gateway等の前段を置かずにFunction URLのみで十分であると判断しました。 回答処理の流れ ボタンが押された後の処理は以下の順で行います。 二重処理防止 : Slackのリトライ仕様を考慮し、処理の冒頭で、DynamoDBのstatusを確認し、すでに completed なら処理をスキップします。 元メッセージを更新 : 操作者が連続してボタンを押せないよう、 chat.update で元メッセージのボタンを「回答済み」のテキスト表示に差し替えます。 SREチャンネルに回答内容を転送 : 監査結果の透明性を確保するため、回答は公開チャンネルに流します ステータスの更新: すべての処理が成功した段階で、DynamoDBの status を completed に更新します。 def complete_attestation (client, message_ts, channel_id, user_id, action_id, label, original_blocks, detail_text= None ): # 1. 二重処理防止 item = table.get_item(Key={ "message_ts" : message_ts}).get( "Item" ) if item and item.get( "status" ) == "completed" : return # 2. 元メッセージを更新(ボタン → 回答済み表示) completed_blocks = build_completed_blocks(original_blocks, user_id, label, detail_text) client.chat_update(channel=channel_id, ts=message_ts, blocks=completed_blocks) # 3. SREチャンネルに回答内容を転送 forward_blocks = build_forward_blocks(user_id, label, original_blocks, channel_id, message_ts, detail_text) client.chat_postMessage(channel=FORWARD_CHANNEL_ID, blocks=forward_blocks) # 4. DynamoDB更新(回答内容も証跡として保存) response_content = json.dumps( { "reason" : action_id, "detail_text" : detail_text}, ensure_ascii= False , ) table.update_item( Key={ "message_ts" : message_ts}, UpdateExpression= "SET #status = :s, responded_at = :r, response_reason = :reason, response_content = :rc" , ExpressionAttributeNames={ "#status" : "status" }, ExpressionAttributeValues={ ":s" : "completed" , ":r" : datetime.now(timezone.utc).isoformat(), ":reason" : action_id, ":rc" : response_content, }, ) リマインド(send-audit-reminder) 未回答の操作者に対し、自動で再通知およびエスカレーションを行う仕組みについて解説します。 処理フロー トリガー : EventBridgeにより、平日の午前10:00(JST)にLambdaが起動。 対象の抽出 : DynamoDBのGSIを用いて、前日以前に作成された未回答レコード( status=pending )をクエリ。 リマインド投稿 : 該当するSlackメッセージのスレッドに対してリマインドを投稿。 カウンタ更新 : DynamoDBの remind_count をインクリメント。 エスカレーション : リマインド回数が規定値(3回)を超えた場合、SREチームへ通知。 リマインド対象の特定 リマインド対象のレコードは status-created_at-index GSIで取得します。 paginator = dynamodb_client.get_paginator( "query" ) pages = paginator.paginate( TableName=dynamodb_table_name, IndexName= "status-created_at-index" , KeyConditionExpression= "#s = :status AND created_at < :cutoff" , ExpressionAttributeNames={ "#s" : "status" }, ExpressionAttributeValues={ ":status" : { "S" : "pending" }, ":cutoff" : { "S" : cutoff}, }, ) ここで cutoff は「当日の0:00(JST)」を基準としています。これにより、当日検知されたばかりのレコード(まだ回答の猶予があるもの)をリマインド対象から除外しています。 エスカレーション 3回リマインドしても回答がない場合は、SREチームへのエスカレーションに切り替えます。 def _process_item (item, slack, channel_id, sre_group_id, table): remind_count = int (item.get( "remind_count" , 0 )) if remind_count >= 3 : text = _build_escalation_text(slack_user_id, sre_group_id) else : text = _build_reminder_text(slack_user_id) slack.chat_postMessage(channel=channel_id, thread_ts=message_ts, text=text) 導入効果 コスト面 全体をサーバーレスアーキテクチャで構成しているため、月額の運用コストは1,000円以下に抑えられています。 Lambda、DynamoDB、Amazon Bedrockはいずれも従量課金であり、週数回という実行頻度ではほとんどコストが発生しません。LLMに安価な Amazon Nova Pro を選定したことも低コスト化に寄与しています。 月額1,000円以下の運用コストで年間約1人月分に相当する工数を削減することができました。 機能・運用面 導入前後で、監査確認フローは以下のように改善されました。 項目 導入前(Before) 導入後(After) 確認頻度 週次(手動) 日次(自動) 回答方法 Slackでの自由記述 ボタン1クリック / モーダル入力 リマインド 手動でフォローアップ 自動リマインド+エスカレーション 証跡管理 なし DynamoDBに保存 属人化 担当者の手作業に依存 Botによる標準化 セキュリティ面・ガバナンス面 検知内容、回答内容がDynamoDBに構造化された状態で蓄積されるため、そのまま監査証跡として活用可能な状態になりました。 ISO27001(ISMS)などのセキュリティ認証では、イベントログの定期的なレビューや、重要な変更の特定・記録・通知といった管理策が求められます。 今回のシステム化により、「検知 → 確認 → 記録」の一連のプロセスが自動化・標準化されました。仕組みとして漏れのない運用が行われていることを客観的に示せるようになったことは、今後のセキュリティ認証の維持・取得に向けても良い影響があるのではないかと考えています。 最後に 本記事では、Slack Botを活用したセキュリティ監査運用の自動化事例について紹介しました。 セキュリティ監査運用は非常に重要ですが、検知後の確認フローをいかに効率化し、形骸化させずに継続するかという点についてはまだ多くの組織で模索が続いている部分だと感じています。 今回、サーバーレスアーキテクチャとLLMを組み合わせることで、低コストかつ運用負荷の低い形でこの課題を解決することができました。 本記事の内容が、同様の課題を抱えている方や、セキュリティ運用の自動化を検討されている方々にとって、参考になれば幸いです。
MNTSQ プラットフォーム部の藤原です。 本記事では、PythonとLibreOfficeを組み合わせたオフィスファイルのpdf変換について解説します。 LibreOffice はオープンソースのオフィススイートです。 Microsoft Officeで作成した各種ファイル(docxや、xslx、pptx)を読み込み、編集できます。 LibreOfficeにはこれらファイルをpdfでエクスポートする機能も存在しています。 GUIからの実行はもちろんCLIでも実行可能です。 soffice --headless --convert-to pdf ファイル名.docx LibreOfficeを導入済みの場合はこのようなコマンド 1 を実行することで、docxなどをpdfに変換できます。 さて、 soffice コマンドでdocxファイルなどをpdfなどで変換可能なことはここで示せました。 ウェブアプリケーションなどでオフィスファイルをpdf変換する場合には、内部で soffice コマンドを呼び出す形で変換できそうです。 ただし、この方式では処理のたびにプロセスを立ち上げることになります。 大量のファイルを効率的に変換しようと考えると都度プロセスを立ち上げることは非効率です。 そのための対策として、LibreOfficeのプロセスを立ち上げた状態で外部プログラムからLibreOfficeの機能を利用するための仕組みが提供されています。 それが、UNO(Unified Network Objects)です。 UNO(Unified Network Objects)について UNO(Unified Network Objects)とは、LibreOfficeの内部機能に外部プログラムからアクセスするためのコンポーネントです。 UNOは言語非依存でさまざまなプログラミング言語から利用可能です。 UNOへのアクセス方法としてはインプロセス方式とソケット接続方式があります。インプロセス方式はLibreOfficeマクロからLibreOfficeの操作をするためのものです。ソケット接続方式は外部プロセス(=外部プログラム)からTCPを使って接続してLibreOfficeの各種操作をするための仕組みです。 次のようにすることでTCP接続を介してUNOを利用できます。 soffice --headless \ --accept="socket,host=localhost,port=2002;urp;"\ --norestore UNOを直接操作するためのPythonパッケージとしては、 PyUNO が存在しますが、オフィスファイルをpdfに変換する目的としては、too muchと言えそうです。 本記事ではUNOそのものの説明については、このようにすると指定したTCPポートでLISTENさせることが可能である旨に留めておきます。 以降は、UNOを使ってLibreOfficeの機能を簡単に利用するための仕組みとしてunoserverを紹介します。 unoserverについて unoconv/unoserver は、XML-RPC経由でUNOの仕組みを利用できるようにするための、Pythonパッケージです。 コンテナで動かす場合の動作イメージとしては以下のようになっています。 unoserverの動作イメージ コンテナ内ではunoserverとheadlessなLibreOfficeを常時立ち上げて処理を待ち受けています 2 。 unoserverを簡便に利用するため、また、macOS等でも利用する際の参考としてコンテナイメージおよび、docker-compose.ymlを準備しました。 コードの全体像は Fufuhu/docker-unoserver にて公開しています。 以下のようにコマンドを実行してください。 docker run -d -p 2003:2003 fufuhu/unoserver コードからビルドして利用する場合は以下の通り実行してください。 git clone git@github.com:Fufuhu/docker-unoserver.git # dockerコマンドで利用する場合 docker build -t docker-unorsever . docker run -d -p 2003:2003 docker-unoserver # docker composeコマンドで利用する場合 docker compose up --build -d 動作確認としてPythonを使ってXML-RPCのリクエストを投げてみます。 python -c " import xmlrpc.client; import json; print(json.dumps(xmlrpc.client.ServerProxy('http://localhost:2003').info()))" \ | jq { "unoserver": "3.6", "api": "3", "import_filters": { "HTML": "HTML (StarWriter)", 〜〜中略〜〜 "impress_svg_Export": "impress_svg_Export", "impress_tif_Export": "impress_tif_Export", "impress_webp_Export": "impress_webp_Export", "impress_wmf_Export": "impress_wmf_Export" } } このようにして、汎用のXML-RPCクライアントを使ってunoserverを利用することが可能です。 ただし、unoserverの個別の機能を利用するには汎用のクライアントでは少々面倒です。 そこで、unoserverの提供するUnoClientを使った実装例を提示します。 UnoClientのサンプル UnoClientはunoserverパッケージに含まれているunoserver向けのXML-RPCクライアントやバイナリデータの送受信などをラッピングした高レベルクライアントです。 Fufuhu/docker-unoserver-client-sample にサンプルのコードを作成しました。 このリポジトリのコードを git clone して docker compose up --build -d でUNOサーバーとサンプルとなるウェブアプリケーションが立ち上がります 3 。 すこし本題に入るまでが長くなりますが、クライアントウェブアプリケーションのコードを眺めてみましょう。 main.py の抜粋を見てみましょう。 from fastapi import FastAPI, Request, UploadFile from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from unoserver.client import UnoClient app = FastAPI() templates = Jinja2Templates(directory=Path(__file__).parent / "templates" ) UNOSERVER_HOST = os.getenv( "UNOSERVER_HOST" , "localhost" ) UNOSERVER_PORT = os.getenv( "UNOSERVER_PORT" , "2003" ) FastAPIを使ったアプリケーションサーバーが立ち上がります。 UNOサーバーのホスト名と待受ポートを環境変数で指定できます。 また、テンプレートエンジンであるJinja2向けのテンプレートを格納しているディレクトリとして templates ディレクトリを指定しています。 docker-compose.ymlの該当部分を抜粋すると次のようになっています。 services : unoserver : image : fufuhu/unoserver ports : - "2003:2003" app : build : . ports : - "8000:8000" depends_on : - unoserver environment : - UNOSERVER_HOST=unoserver - UNOSERVER_PORT=2003 次に main.py のindex関数を見てみましょう。 / にアクセスすると、 index.html ファイルをレンダリングして返すようになっています。 @ app.get ( "/" , response_class=HTMLResponse) async def index (request: Request): return templates.TemplateResponse(request, "index.html" ) index.htmlのbodyタグ以下の抜粋としては以下のとおりです 4 。 < body > < h1 > PDF変換ツール </ h1 > < form method = "post" action = "/convert" enctype = "multipart/form-data" > < p > 変換したいファイルを選択してください </ p > < input type = "file" name = "file" required >< br > < button type = "submit" > 変換 </ button > </ form > </ body > ファイルを選択して変換ボタンをクリックすると /convert にファイルがPOSTされる形となっています。 ブラウザ上で http://localhost:8000 にアクセスすると次のような表示になります。 インデックス画面イメージ このフォーム内でファイルを指定して変換ボタンをクリックすると、 /convert にファイルがPOSTされる形となっています。 次に main.py の /convert に対応するコードを見てみましょう。 @ app.post ( "/convert" ) async def convert ( file : UploadFile): # アップロードされたファイルの内容をバイト列として読み込む indata = await file .read() # 元のファイル名から拡張子を除いた部分を取得し、ダウンロード用のPDFファイル名を生成する stem = Path( file .filename).stem if file .filename else "output" out_filename = f "{stem}.pdf" # unoserverに接続するクライアントを作成する(XMLRPC経由で通信) client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) # ファイルのバイト列をunoserverに送信し、PDF形式に変換する # 変換結果はPDFのバイト列として返される result = client.convert(indata=indata, convert_to= "pdf" ) # 変換されたPDFをレスポンスとして返す # Content-Dispositionヘッダーにより、ブラウザがファイルを直接ダウンロードする return Response( content=result, media_type= "application/pdf" , headers={ "Content-Disposition" : f 'attachment; filename="{out_filename}"' }, ) 内容としてはコード中のコメントに記載の通りです。 リクエストからアップロードされたファイルのバイト列を取得して、UnoClientを使ってpdf変換を実現しています。 ポイントは、 UnoClient です。 UnoClient を利用することで、XML-RPCなどの処理を隠蔽して簡潔に記述できています。 実際の変換処理部分としては以下の2行のみで実現できます。 client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) result = client.convert(indata=indata, convert_to= "pdf" ) ここまでで、Python(unoserver, UnoClient)とLibreOfficeを使ったオフィスファイルのpdf変換について示しました。 今回提示のサンプルでは、リクエストを受けてその場で変換処理を実行して変換後のpdfファイルを返しています。 巨大なファイルを処理する場合、UXなどを考慮すると好ましい実装としてはこの限りではない点には注意した方が良いでしょう 5 。 まとめ LibreOfficeのGUI/CLIを使ってオフィスファイル(docx, xslx, pptxなど)をpdfに変換できる 大量のファイルを変換する場合はLibreOfficeのプロセスを常駐させ、UNO(Unified Network Objects)を使って変換する方が効率的である UNOを容易に利用するための仕組みとして unoconv/unoserver が提供されている unoserverは言語非依存なXML-RPCを使ったファイルのpdf変換を提供している。ただし、Pythonの場合はunoserverパッケージの提供するUnoClientがあり、容易にpdf変換を実現できる 参考文献 https://ja.wikipedia.org/wiki/XML-RPC https://github.com/unoconv/unoserver https://docs.libreoffice.org/pyuno.html https://hub.docker.com/r/fufuhu/unoserver https://github.com/unoconv/unoserver https://github.com/Fufuhu/docker-unoserver https://github.com/Fufuhu/docker-unoserver-client-sample コマンド名が soffice となっているのはLibreOfficeの歴史的経緯に起因するものです。基本的にはlibreofficeコマンドでもaliasが貼られていることがほとんどであり、同等の動作が可能なはずです ↩ コンテナ内部でunoserverのプロセスと、LibreOfficeのプロセス両方を管理するための仕組みとしてtiniを導入しています。 ↩ Fufuhu/docker-unoserverから立ち上げたdockerコンテナやdocker composeとポート競合が発生するので、あらかじめ停止した上で実行してください。 ↩ CSS指定部分などは今回はメインではないので例示からは除外しています。 ↩ レスポンスで変換済みファイルを即時返却するのではなく、裏側でイベント駆動で処理したのちに変換処理完了通知とダウンロードリンクを送るなどが考えられます ↩
はじめに 弊社では LLM を活用した機能開発の観測基盤として Langfuse をセルフホストで運用しています。Langfuse は LLM アプリケーションのトレーシングやプロンプト管理等に活用できるオープンソースの LLM エンジニアリングプラットフォームです。 さてこの種のサービスには認証の課題がつきまといます。Langfuse は標準でメールアドレス / パスワードによるログインが可能ですが、弊社の方針として開発組織内で利用、管理しているアプリケーションやサービス群には IAM Identity Center を IdP とする SSO を採用しています。 Redash や SendGrid といったサービスで既にこの方式を採用しており、Langfuse でも同様に IAM Identity Center 経由の SSO ログインを実現したいと考えました。 ところが Langfuse は IAM Identity Center を IdP として直接サポートしていません。Langfuse の認証は NextAuth.js ベースであり、対応する認証方式は OAuth / OIDC が中心です *1 。では IAM Identity Center の OIDC 機能をそのまま使えるかというと、そう単純ではありません。IAM Identity Center が OIDC を提供するにはアプリケーション側が trusted token issuer に対応している必要があり、Langfuse はこれに該当しないため直接利用できません。 Cognito と Langfuse の SSO 連携については 既に優れた先例 がありますが、ここに IAM Identity Center を加えた構成についての情報はあまり無いようです。ニッチな話題かもしれませんが、同様の課題を抱えている方もいるのではと考え、本稿にて事例を紹介できればと思います。 構成 前述の事情を踏まえ、間に Amazon Cognito を挟む構成としました。Cognito は SAML によって IdP からの認証情報を受け取り、これを OIDC にてアプリケーション(今回の場合は Langfuse)に提供する役目を担います。IAM Identity Center は SAML による外部アプリケーション(今回の場合は Cognito)のフェデレーションをサポートしているので、これを組み合わせると以下のような構成が実現できます。 IAM Identity Center --- (SAML) ---> Cognito --- (OIDC) ---> Langfuse 認証フローを時系列で描くと以下のようになります。 sequenceDiagram autonumber participant User as ユーザー (Browser) participant LF as Langfuse participant Cog as Cognito participant IDC as IAM Identity Center User->>LF: SSO ログインボタンをクリック LF->>User: Cognito のログイン URL へリダイレクト User->>Cog: 認証リクエスト (OIDC) Cog->>User: IAM Identity Center へリダイレクト User->>IDC: SAML 認証リクエスト IDC->>User: ログイン画面の表示 / 認証の実施 IDC->>User: SAML Assertion を発行 User->>Cog: SAML Assertion をポスト (ACS URL) Cog->>Cog: SAML Assertion の検証 / ユーザー情報の処理 Cog->>User: Langfuse へリダイレクト (認可コード) User->>LF: 認可コードを送信 LF->>Cog: トークン交換リクエスト Cog->>LF: ID トークン / アクセストークン (OIDC) LF->>User: ログイン完了 要するに Cognito が SAML -- OIDC 間の橋渡し役を担う格好です。ユーザー視点では Langfuse のログイン画面で SSO ボタンを押すと IAM Identity Center のログイン画面に遷移し、認証後 Langfuse に戻ってきます。 これを実現する構成が以下のようになります。本稿の趣旨は Langfuse 利用にかかる SSO ログイン化が主軸につき、Langfuse 本体の構成はかなり端折った図である点ご容赦ください *2 構成図。admin / langfuse / logging / security は AWS アカウント名の意 なお上図における logging / security 各アカウントは以下拙稿における member / security に対応するものです。主に SSO ログイン時の監査に使われる周辺要素であり、本稿では詳細を割愛します。 設定の手順 設定は大きく4つのステップに分かれます。 IAM Identity Center に SAML アプリケーションを登録する(admin アカウント) Cognito で SAML IdP と OIDC クライアントを構成する(langfuse アカウント) Langfuse に OIDC 関連の環境変数を設定する(langfuse アカウント) SSO ログインへの一本化(langfuse アカウント) 以下、各ステップの内容を解説します。 Step 1: IAM Identity Center への SAML アプリケーション登録 admin アカウントの IAM Identity Center に、Langfuse 用のカスタム SAML アプリケーション( customer-managed application )を Terraform で登録します *3 。 aws_ssoadmin_application リソースで application_provider_arn に custom-saml を指定し、IAM Identity Center のポータル上での可視性を有効にします。 あわせて aws_ssoadmin_application_assignment で SSO ログインを許可する対象を紐付けます。グループ単位での割り当ても可能ですが、弊社では職責や担当職務に応じて柔軟にアクセス範囲を制御したかったため、ユーザー単位での割り当てとしました *4 。 続いてマネジメントコンソールから Application metadata を設定します。これは Cognito が SAML Assertion を正しく受け取るために必要な設定です。 Application ACS URL : https://<Cognito ドメイン>.auth.<リージョン>.amazoncognito.com/saml2/idpresponse Application SAML audience : urn:amazon:cognito:sp:<Cognito User Pool ID> ACS URL は Cognito User Pool のドメイン名から、SAML audience は Cognito User Pool の ID からそれぞれ導出されます。Step 2 で Cognito User Pool を作成した後に設定する、という順序になります *5 。 同じくマネジメントコンソールから Attribute mappings を設定します。SAML Assertion に含めるユーザー属性と Cognito 側の属性との対応を定義するものです。 User attribute in the application Maps to this string value or user attribute in IAM Identity Center Format Subject(変更不可) ${user:email} persistent email ${user:email} basic email_verified true unspecified name ${user.name} unspecified email_verified を固定値 true としているのは弊社の運用上 IAM Identity Center で管理されているユーザーのメールアドレスは検証済みであると見なして差し支えないためです。これは以下の拙稿に詳細を譲りますが、弊社の IAM Identity Center は IdP として Entra ID を参照しており、Entra ID 上のユーザ情報を信頼できるケースに該当する為です。 最後に、マネジメントコンソールから SAML メタデータファイルをダウンロードします。 IAM Identity Center コンソールへ移動 Application assignments → Applications を選択 Customer managed タブへ移動 対象の SAML アプリケーションを選択 IAM Identity Center metadata 内の IAM Identity Center SAML metadata file からダウンロード このメタデータ XML ファイルは次のステップで Cognito に渡す必要があります。 Step 2: Cognito の構成 langfuse アカウント側では Cognito User Pool を作成し、IAM Identity Center を SAML IdP として登録した上で、Langfuse 向けの OIDC クライアントを構成します。 まず Step 1 でダウンロードした SAML メタデータ XML を S3 バケットに手動でアップロードし、Terraform からは data "aws_s3_object" で参照します *6 。S3 バケット自体も Terraform で作成し、サーバーサイド暗号化とパブリックアクセスブロックを設定しています。 続いて、この SAML メタデータを使って aws_iam_saml_provider と aws_cognito_identity_provider の2つを設定します。前者は IAM レベルでの SAML プロバイダ登録、後者は Cognito User Pool に対する SAML IdP の登録です。 aws_cognito_identity_provider では attribute_mapping で Step 1 の Attribute mappings との対応を指定します。 なお provider_details には ignore_changes を設定しています。Cognito は MetadataFile を解釈して内部的にいくつかの属性を展開するため、内容に変更がなくても terraform plan で差分が出続けます。これを抑制するための措置です。 次に aws_cognito_user_pool_client を設定します。これは Langfuse から見た OIDC クライアントに相当するリソースです。OAuth フローには Authorization Code Grant( code )を、スコープには openid / email / profile を指定します。 callback_urls には Langfuse の OIDC コールバック URL( https://<Langfuse のドメイン>/api/auth/callback/custom )を設定します。トークンの有効期間やクライアントシークレットの生成など、必要な設定を適宜入れます。 最後に、Cognito が払い出した Client ID / Client Secret / Issuer URL を aws_ssm_parameter (SecureString)で SSM パラメータストアに格納し、Langfuse の ECS タスク定義から参照できるようにします。 Step 3: Langfuse への環境変数設定 Langfuse は Custom OAuth Provider としての設定を環境変数で受け取ります。ECS タスク定義に以下の環境変数を追加しました。 平文で渡すものとして AUTH_CUSTOM_NAME 、 AUTH_CUSTOM_CHECKS 、 AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING の3つがあります。SSM パラメータストアから取得する秘密情報として AUTH_CUSTOM_CLIENT_ID 、 AUTH_CUSTOM_CLIENT_SECRET 、 AUTH_CUSTOM_ISSUER の3つがあり、これらは Step 2 で格納した値を参照します。 AUTH_CUSTOM_NAME は Langfuse のログイン画面に表示される SSO ボタンのラベルです。わかりやすい名称を選ぶと良いでしょう AUTH_CUSTOM_CHECKS には pkce を指定し PKCE (Proof Key for Code Exchange) を有効にしています AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING を true にすると、同一メールアドレスの既存アカウントと SSO アカウントが紐付けられます SSO 導入前のアカウントとの互換性のために有効にしています これが無いと後述のトラブルシュート時に Cognito 側で対応する手法が取れず Langfuse 側 DB を直接触ってアカウント管理をする手間が発生します Step 4: SSO ログインへの一本化 SSO ログインが安定稼動していることを確認した後、環境変数 AUTH_DISABLE_USERNAME_PASSWORD を true に設定してメールアドレス / パスワードによるログインを無効化しました。これにより Langfuse のログイン画面は SSO ボタンのみが表示される状態になります。アカウント管理を IAM Identity Center に一元化でき、Langfuse 側での個別のアカウント発行 / 削除が不要となります。 サンプルコード Step 1〜2 で使用した Terraform コードの抜粋を以下に示します。それぞれ collapsed になっていますので、クリックして中身を参照ください。 admin アカウント: IAM Identity Center への SAML アプリケーション登録 # Identity Store からユーザー情報を参照 data "aws_identitystore_user" "main" { for_each = toset (local.langfuse_users) identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] alternate_identifier { unique_attribute { attribute_path = "UserName" attribute_value = each.key } } } # カスタム SAML アプリケーションの登録 resource "aws_ssoadmin_application" "langfuse" { name = "Langfuse" application_provider_arn = "arn:aws:sso::aws:applicationProvider/custom-saml" instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] portal_options { visibility = "ENABLED" sign_in_options { origin = "IDENTITY_CENTER" } } } # SSO ログインを許可するユーザーの割り当て resource "aws_ssoadmin_application_assignment" "langfuse" { for_each = toset (local.langfuse_users) # 職責に応じてフィルタしたユーザーリスト application_arn = aws_ssoadmin_application.langfuse.arn principal_id = data.aws_identitystore_user.main [ each.key ] .id principal_type = "USER" } langfuse アカウント: Cognito の構成 # ----- SAML メタデータの管理 ----- # メタデータ XML を格納する S3 バケット(暗号化・パブリックアクセスブロックは別途設定) resource "aws_s3_bucket" "saml_metadata" { bucket = "mntsq-$ { var.env } -saml-metadata" } # IAM Identity Center からダウンロードしたメタデータ XML を参照 data "aws_s3_object" "saml_metadata" { bucket = aws_s3_bucket.saml_metadata.id key = "langfuse.xml" } # ----- SAML プロバイダ・Cognito User Pool ----- resource "aws_iam_saml_provider" "langfuse" { name = "langfuse" saml_metadata_document = data.aws_s3_object.saml_metadata.body } resource "aws_cognito_user_pool" "langfuse" { name = "langfuse" auto_verified_attributes = [ "email" ] } resource "aws_cognito_user_pool_domain" "langfuse" { user_pool_id = aws_cognito_user_pool.langfuse.id domain = "mntsq-$ { var.env } -langfuse" } # IAM Identity Center を SAML IdP として登録 resource "aws_cognito_identity_provider" "langfuse" { user_pool_id = aws_cognito_user_pool.langfuse.id provider_name = "langfuse" provider_type = "SAML" provider_details = { "MetadataFile" = data.aws_s3_object.saml_metadata.body } attribute_mapping = { email = "email" email_verified = "email_verified" name = "name" } lifecycle { # Cognito が MetadataFile を解釈して provider_details を展開するため、 # 毎回差分が出るのを抑制する ignore_changes = [ provider_details ] } } # ----- OIDC クライアント (User Pool Client) ----- resource "aws_cognito_user_pool_client" "langfuse" { name = "langfuse" user_pool_id = aws_cognito_user_pool.langfuse.id allowed_oauth_flows_user_pool_client = true allowed_oauth_scopes = [ "openid" , "email" , "profile" ] allowed_oauth_flows = [ "code" ] supported_identity_providers = [ aws_cognito_identity_provider.langfuse.provider_name ] access_token_validity = 8 id_token_validity = 8 refresh_token_validity = 1 token_validity_units { access_token = "hours" id_token = "hours" refresh_token = "days" } callback_urls = [ "https://<Langfuse のドメイン>/api/auth/callback/custom" ] default_redirect_uri = "https://<Langfuse のドメイン>/api/auth/callback/custom" generate_secret = true } # ----- 認証情報の SSM パラメータストア格納 ----- resource "aws_ssm_parameter" "auth_id" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_CLIENT_ID" type = "SecureString" value = aws_cognito_user_pool_client.langfuse.id } resource "aws_ssm_parameter" "auth_secret" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_CLIENT_SECRET" type = "SecureString" value = aws_cognito_user_pool_client.langfuse.client_secret } resource "aws_ssm_parameter" "auth_issuer" { name = "/$ { var.env } /langfuse/AUTH_CUSTOM_ISSUER" type = "SecureString" value = "https://cognito-idp.$ { data.aws_region.current.name } .amazonaws.com/$ { aws_cognito_user_pool.langfuse.id } " } トラブルシューティング 運用を開始して以降、SSO ログインが失敗するケースに遭遇しました。とくに初回ログイン時に顕著で、Cognito と Langfuse との間でユーザー情報のフェデレーションがうまくいかない場合に発生します。 リトライで解消するケースもありますが、それでも解決しない場合は Cognito 側のユーザーを一旦削除し、再度 Langfuse へ SSO ログインしてもらう ことで通るようになります。Cognito User Pool 内のユーザーは IAM Identity Center から federate されたものなので、削除しても次回の SSO ログイン時に再作成されます。 手順としては以下の通りです。 Cognito User Pool のマネジメントコンソールへ移動 User management → Users でログインに失敗しているユーザーを特定 対象ユーザーを選択し、まず Disable user access を実施 その後 Delete user を実行 ユーザー削除後に改めて SSO ログインを試みてもらえば、Cognito 側にユーザーが再作成されてログインが成功するはずです。 おわりに IAM Identity Center を IdP として Langfuse に SSO ログインを実現する構成について解説しました。ポイントは Cognito を SAML と OIDC との翻訳役として活用する 点です。 設定にあたっては Terraform とマネジメントコンソールの手作業が混在する点がやや煩雑です。とくに IAM Identity Center 側の Application metadata や Attribute mappings、SAML メタデータの取得はコンソール操作が必要であり、手順として残しておかないと再現が難しくなります。 Cognito + Langfuse の SSO 構成 については先例がありますが、IAM Identity Center を加えた構成についてはあまり情報が見当たりませんでした。このパターンは Langfuse に限らず、OIDC のみをサポートするアプリケーションに IAM Identity Center 経由の SSO を提供したい場合に広く応用できるものと考えています。同様の課題を抱えている方の参考になれば幸いです。 文責:MNTSQ 株式会社 SRE 秋本 注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.6 が作成した内容を9割程度そのまま使用しています *1 : https://langfuse.com/self-hosting/security/authentication-and-sso を参照。Langfuse は Google / GitHub / Azure AD (Entra ID) / Okta / Auth0 / Custom OAuth Provider 等をサポートしています *2 : 興味のある方向け:ECS で ClickHouse 含めて Langfuse が必要とする諸要素を動作させ、DB は Aurora PostgreSQL、キャッシュ関連は ElastiCache にて Valkey クラスタを組んで動作させています *3 : 実際には全ての設定を Terraform でカバーできるわけではないので、適宜 AWS マネジメントコンソールからの設定作業も必要になります *4 : この種の柔軟さをグループ単位で表現できなかったという内部事情もあります *5 : 厳密には Cognito User Pool の作成 → IAM Identity Center の Application metadata 設定 → Cognito 側の残りの設定、という順序を踏む必要があります。Terraform で一括管理する場合は初回適用時にこの依存関係を意識する必要があります。要するに IAM Identity Center と Cognito とを行ったり来たりする必要が2026年2月時点で有ります *6 : SAML メタデータはコード管理の対象としていません。IAM Identity Center 側で生成されるものであり、かつ XML の内容が大きいため S3 経由での参照としました。メタデータの取得手順は Step 1 に記載の通りです
はじめに Datadog には Notebook というものがあります。以前から存在する機能であり、目新しいものではありません。 詳細は公式のドキュメントが詳しいのですが、Datadog で取り扱えるログやメトリックなどを埋め込める ドキュメンテーション ツールという捉え方で然程ズレは無いはずです。単にログやメトリックを追うという意味ではダッシュボード *1 も使え、 継続的な観測をする場合は ダッシュボードのほうが断然よいです。 いっぽうでこういった課題感もあると思います。すくなくとも私は常々あります。 あのメトリックとこのメトリックとは何らか関連性のある動きをするはずなんだが、パッと追いづらいぞ 監視やダッシュボードの設計をしたいのでメトリック状況を追いたいのだが、 メトリクスエクスプローラー ではいまいち状況がわからないぞ 複数のメトリックを束ねて計算したい *2 が、メトリクス エクスプローラ ーで頑張っていたら訳分からなくなってしまった *3 メトリックを見つつ考察や見解を添えておきたい。別途存在するドキュメント側に Datadog への誘導を設けるでもよいが、ひとつの場所で扱いたい こういった場合、ダッシュボードをつくるよりも Notebook を使うほうが手軽かつ確実な効果が得られます。 本項にて弊社で活用事例をあげつつ、Datadog Notebook は便利だよ、という声を微々たるものながら上げられればと思います。 事例1:Elasticsearch → OpenSearch への移行準備にかかる既存調査 弊社では現在検索基盤の更改を検討しており、現状利用している Elasticsearch から Amazon OpenSearch への移行を手段のひとつとして考えています。 この移行にあたり、移行先 OpenSearch ドメイン *4 のリソース見積もりをする必要があり、その材料を既存 Elasticsearch の状態から得ようと考えました。 実際に使った Elaseticsearch 状況確認用の Notebook(黒塗り部分が多い点ご容赦ください) 出すべき材料はおおむね以下のようになるはずです: Elasticsearch ノード台数 各 Elasticsearch ノードのリソース消費量 CPU RAM *5 ディスク 収容テナント数 収容シャード量 発生しているコスト これらメトリックは既に Datadog 上に蓄積されています。もちろんこれらを一瞥するようなダッシュボードを作ることも可能ですが、今回は 定点監視することを目的に確認したい訳ではない 並べた数字を眺めながら議論をすすめるケースが多々有った 既にあるドキュメントは詳細な考察と検討に使い、メトリックを並べた場所は純粋にメトリックから得られる所感だけを扱うようにしたい需要があった ということもあり、とりわけ 1. を主要な目的として Notebook へ、いわば「書き散らし」することにしました。 継続的な観察を目的とせず一時的な調査として Notebook という手段をもっておくことで、こういった書き散らしに数字的な意味を持たせることができます。かつ体裁を気にせず素早くまとめが仕上げられるので、議論と結論出しを迅速に行うことができました。 事例2:Datadog Cloud Cost と Unit Economics もうひとつの事例は、 Datadog Cloud Cost を活用した、一歩踏み込んだコスト分析での利用です。 実際に使った Unit Economics 検討用 Notebook (こちらも黒塗り部分が多い点ご容赦ください) 弊社では先般 Datadog の Cloud Cost を使い始めており、 AWS / Google Cloud / Azure 各コストの俯瞰的な観察ができるようになっています。以下拙稿も参照ください。 一方で Cloud Cost を折角導入した以上、コストをただ観察するのではなく、よりよい洞察を得たい気持ちもあります。前掲拙稿の通り Datadog メトリックとして各 クラウド ベンダのコスト情報を適切な分解能でもって Datadog 内で扱える という利点が Cloud Cost にはあり、各種メトリックとコスト情報とを掛け合わせたものが整備できそうです。つまり単に「いくらかかっているか」を眺めるだけでなく、その費用対効果を 定量 的に評価できる余地がありそうです。そこで Unit Economics の概念を取り入れ、 クラウド 利用にかかるコストの経済性を考察することにしました。 SaaS における Unit Economics といえば、一般的には LTV(Life Time Value;顧客生涯価値) CAC(Customer Acquisition Cost;顧客獲得コスト) の比率で語られます。しかし、エンジニアリングの側面からこれを捉え直すと 1テナントあたりの提供コスト (COGS) Cost Of Goods Sold;売上原価 アクティブユーザー(以下 Active User を略して AU と表記します)あたりのインフラ費用 といったものをいかに最適化し、粗利を最大化するかという議論に単 純化 できます。 こうした指標を追跡するなかで、特定の利用パターンにおいて Unit Economics の悪化が見られた場合、それはそのパターンを支えるインフラ的な アーキテクチャ やリソース配分に改善の余地がある、という仮説が立ちます。単なるコストの総額ではなく、ユニットあたりの効率を見ることで、エンジニアリングとして優先的に手を入れるべき箇所を特定しようという試みです。 この分析を行うには、 クラウド コストのデータに加えて、Datadog 上の既存メトリックやログから生成したメトリックを突き合わせる必要があります。この試行錯誤において、 Notebook が非常に役立ちました。 扱うデータの検討 まずは クラウド コストをテナントで割るか AU で割るかの検討が必要です。テナント単位で見るのが前述のとおり筋と当初は考えたのですが、両者の実態や傾向に差異がある都合、実際にはユーザーあたりで見た場合によりよい洞察が得られるケースもあり、最終的には両者を状況によって比較する(つまりテナント按分コストと AU 按分コストとを両方算出する)手法をとることになりました。 この試行を素早く繰り返す際に役立ったのが Notebook で、複数の計算結果を(再び)書き散らし、実態を最も反映していそうな指標が何かをグルグル考え回すことができました。 観察 コスト観察は長期間の観察が不可欠です。日単位や週単位ではあまり意味のある情報は得られません。定点観察にはダッシュボード化が適しますが、そもそも今組み立てているデータが有意義かどうかが判っていない段階でのダッシュボード化は早計でしょう。 まずは Notebook を サンドボックス として使い、少ない整備の手間で傾向観察をはかることが可能となりました。 考察 データが揃い、観察が出来るようになったのち、必要なのはその結果の考察です。これにも Notebook への「書き散らし」が便利でした。 データの定義はどういったものでこの結果は何を示すものと考えられるか等の情報がデータと同じ場所にメモでき、何を伝えればよいのだ等と思い悩む必要性が減り、思い切った検証ができるようにもなります。 また検討と訂正を繰り返していると訪れる「果たしてこれは何を意味するのだったか」と失念して困惑するという事態も、書き散らしによって避けることができます。 要するに いきなり「正解」のダッシュボードを作るのではなく、 Notebook 上でこうした泥臭い検証を重ねられたことが、最終的に精度の高い可視化への近道になりました。 なお最終的に Notebook の内容はダッシュボードとして新たに整備することになりました。有益さが判った結果ですが、最初からダッシュボードにしておけばよかったと思わないでもないです。 これは現時点(2026年1月)では Notebook からダッシュボードに移行できるような機能が Datadog には無いためで、 Notebook 上に整備した各種メトリックをダッシュボードに気合で移す作業が必要になりました。今となっては良い思い出です。 おわりに 今回の活用を通じて、以下のような嬉しさがありました。 Elasticsearch 移行 検証数値的な裏付けを持って机上での検討ができるようになり、移行計画に際し十分な情報を提示することができた Notebook に適宜情報を追加しながら仮説検証を繰り返すサイクルが回せたことで、手戻りの少ない検討が可能となった Unit Economics 分析 仮説検証が Notebook で素早く行え、これが有益かどうかの検討が手軽に行えるようになった まずは Notebook で「これで本当に分析できるか?」を確かめ、確信を持ってからダッシュボードとして正式に整備する、というフローを検証できた Datadog Notebook は、監視や o11y *6 といったメイン機能に比べると、正直なところ影が薄い印象があります *7 。しかし、実際に使ってみると「ダッシュボードを作るほど大掛かりではないが、メトリクス エクスプローラ ーよりは踏み込んだことがしたい」という場面で大変丁度よいツールです。 メトリクス エクスプローラ ーでは複数メトリックの取扱が勿論可能ですが、どうしてもその場限りの確認になりがちです。一方、 Notebook であれば複数のメトリックを時系列や比率で比較した「論理構造」をそのまま残しておけます。これはつまり グラフの側に「なぜこの数字を見たのか」というコンテキストを記録できる 一時的な調査結果を、そのままチーム内への共有レポートとして転用できる ダッシュボード化する前の「プロトタイプ」として活用できる このように、一種の「思考の跡地」としての価値が生まれるわけです。 ダッシュボード化する前の下書きとして、あるいは一時的な調査レポートとして。便利な手段のひとつとして Datadog Notebook があるという点、認知に貢献できたら幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : https://docs.datadoghq.com/dashboards/ *2 : 例: https://docs.datadoghq.com/monitors/guide/monitor-arithmetic-and-sparse-metrics/ *3 : 私的にこれが一番あります *4 : https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html の語法に則り「 ドメイン 」と呼びますが、データノードおよび専用マネージドノードの集合体という意味から「 クラスタ 」と同一視してよいはずです *5 : Elasticsearch であることから実際には JVM ヒープメモリ量などの状況も重要になります。今回このあたりを引っ括めて "RAM" と読んでいます *6 : observability;可観測性。本稿を読んでいる方には釈迦に説法感がありますが念の為 *7 : これまで在籍してきたいくつかの会社でわたしは Datadog Notebook を使ってきましたが、いざ共有などすると「こんな機能があったのか!!!」という反応に毎度なります
前回のあらすじ スキーマ 分離設計のDB(テナント毎に独立した スキーマ を持つDB)でサービス規模が拡大すると、 スキーマ 数の増加に由来するオーバーヘッドが無視できないものになる 次はパラメータチューニングなどで何とか延命できないか試してみたい tech.mntsq.co.jp はじめに 前回の 負荷試験 によって、弊社サービスは600テナントを超えたあたりから、データベースの急激な性能劣化を起こすリスクが高いことが判明しました。長期的には根本的な構成の見直しを行うとして、パラメータチューニングなどでデッドラインを後ろにずらせるのであれば、それはそれでありがたいです。 よって、今回の 負荷試験 の目的は、 チューニングによって アーキテクチャ 改善をどの程度後ろ倒しにできるかを検討することでした(過去形) 。 結論を申し上げると、弊社のケースではパラメータチューニングによって延命を図れる見込みは薄いことが判明しました。ただし、調査の過程で、前回の結果の解釈の誤りを発見したり、新たな条件で見えてきた スキーマ 分離の定性的な特性を発見したりと、それなりに実りのある結果が得られたので、再び報告させていただきたいと思います。 前回のあらすじ はじめに 追加試験の結果報告 真のボトルネックはwait/io/table/sql/handlerだった パラメータチューニングについて table_definition_cache & table_open_cache innodb_sync_array_size インスタンスサイズを大きくしてみる 一定高負荷下でのスキーマ数によるパフォーマンスの変化 150スキーマで性能が劣化する理由 1200スキーマで性能が劣化する理由 負荷試験を行う上で注意したいこと 本番環境と試験環境の違いを考える 測定の分散について おわりに 追加試験の結果報告 真の ボトルネック は wait/io/table/sql/handler だった wait/io/table/sql/handler は、ストレージエンジン層における行レベルの操作(読み取り、挿入、更新、削除など)に対して、 SQL 層が費やした待機時間を計測するイベントです。一般には物 理I /O、行レベルのロック待ちなどが主原因になります。 前回の結果では wait/synch/mutex/innodb/dict_sys_mutex が ボトルネック になると誤った結論をつけてしまいましたが、これは暖機運転(ウォームアップ)不足による一時的な現象でした。以下は暖機運転完了前後の待機時間の内訳を、PeformanceInsiteの 平均アクティブセッション(AAS) のグラフで確認したものです。 暖機運転完了前後の待機時間の内訳 これを見るとvCPU数を大幅にオーバーして wait/synch/mutex/innodb/dict_sys_mutex の待機イベントが支配的になっている時間(左側: 暖機運転中)と、vCPU数以下の範囲内で wait/io/table/sql/handler が支配的になっている時間(右側: 暖機運転完了)がハッキリと分かれていることが確認できます。 wait/synch/mutex/innodb/dict_sys_mutex は データディクショナリ へのアクセス競合でしたが、必要な メタデータ がすべてメモリに乗り切れば、基本的に競合は発生しません。よって、今回の測定の条件下では、 wait/synch/mutex/innodb/dict_sys_mutex が発生している場合は暖機運転が十分でないと言えます。 また、このような wait/synch/mutex/innodb/dict_sys_mutex の変化は、今回の測定条件内では table_definition_cache (テーブル定義の メタデータ を載せるキャッシュ)などのキャッシュサイズが十分に足りていることを示唆しています。 以降の測定は暖機運転を十分に行い、この待機イベントの変化を確認してから本測定を行いました。 なお、今回の検証では最終的にこの wait/io/table/sql/handler の ボトルネック を解消することはできませんでしたが、高並列・高QPSの負荷環境においては、ストレージエンジンが膨大なリクエストを処理する上で避けられない現象であると解釈しています。 wait/io/table/sql/handler の待機時間の内訳を、クエリの種別ごとに分けたものが以下の画像です。 負荷を構成しているクエリの比率がそのまま待機時間の比率になっていました 。したがって、特定のスロークエリがこれ発生させているわけではないことがわかります。 `wait/io/table/ sql /handler`のクエリ種別ごとの内訳 パラメータチューニングについて チューニング( インスタンス サイズなどの変更も含む)を試みた項目について、端的に結果をまとめていきます。結論、 今回の測定条件下では 、パフォーマンスの改善はほとんどみられませんでした。 table_definition_cache & table_open_cache table_definition_cache は、テーブルの定義情報( メタデータ )を保持するグローバルなキャッシュです。 データディクショナリ にアクセスする代わりにこのキャッシュを参照することで、テーブルの定義参照処理を高速化することができます。 table_open_cache は、各スレッドがテーブルにアクセスする際に使用するオブジェクトを保持するキャッシュです。テーブルを物理的にオープンする処理は CPU コストが高いため、このキャッシュに保存されたオブジェクトを再利用することで、オーバーヘッドを劇的に低減できます。 前回の調査結果から、 メタデータ へのアクセス待機が ボトルネック であると予想していたため、キャッシュサイズを大きくすることで改善が見込めると思い、これらのパラメータについて調査を行いました。 チューニングが有効でなかった理由は、先ほどの暖機運転の議論でも述べた通り、今回の測定においてはキャッシュサイズは最初から十分足りていたためです。キャッシュヒット率も確認しましたが、99.555%とほとんどキャッシュミスは発生していませんでした。前回も述べたとおり、負荷は十数種類のパターンのクエリで作っており、参照テーブルも数種類にとどまります。そのため、 メモリの使い方は本番環境の方が圧倒的にハードであり、今回の測定ではメモリへの負荷や改善策を評価することができません。 もしも本番環境で 頻繁にキャッシュミスが発生していた場合には、これらのパラメータのチューニングはパフォーマンスを改善する上で非常に有効 になります。 innodb_sync_array_size innodb_sync_array_size は、内部同期用の配列サイズを調整するパラメータで、CPUコア数の多い環境で値を大きくすると、複数のスレッドが内部ラッチを取り合う際の競合 wait/synch/mutex/innodb/sync_array_mutex を緩和し、スケーラビリティを向上させることができます。 このパラメータを増やすと、待機中スレッドの多いワークロードの同時実行性が高まるというのが通説なので、調査を行いました。 チューニングが有効でなかった理由はシンプルで、 wait/synch/mutex/innodb/sync_array_mutex が発生していなかったので調整する必要がなかったというものです。 インスタンス サイズを大きくしてみる これまでの測定では2xlargeを使用していましたが、これを4xlargeにスケールアップした際に性能が改善するかを調査しました。クライアント側は24並列・各スレッドは500QPSの設定で負荷をかけました。以下は測定結果の一部です。 インスタンス TotalQPS QueryCount P99[μs] Avg [μs] r8g.2xlarge 8450 15,210,198 4.44E+03 2.36E+03 r8g.4xlarge 9233 16,618,969 3.15E+03 2.06E+03 4xlargeの方が全体的に性能が改善しているのがわかります。しかし、2xlarge -> 4xlargeではCPU、メモリ共に2倍になっているにも関わらず、性能の改善はQPS換算でせいぜい10%程度でした。これは費用対効果としては非常に効率が悪いです。 この理由は、CPUを使用しているセッションの内訳で説明できます。 2xlarge 4xlarge 両方とも主要な待機は wait/io/table/sql/handler です。4xlargeの方はCPUが2倍になっているため縦軸の縮尺が異なりますが、その絶対値はほとんど変わっていないことがわかります。2xlarge時点でCPUは ボトルネック ではなかった(CPU数を超えたアクティブセッションが発生していなかった)ため、4xlargeに変更してCPUが増えても、目に見えた性能改善はできなかったと解釈できます。逆に、このグラフでCPU数を超えて待機しているセッションが多くなった場合は、CPUの数を増やすことで大きな性能の改善が期待できます。 したがって、 インスタンス のスペックアップは、必ずしも限界を迎えた際の応急処置として使えるとは限らない と言えます。 一定高負荷下での スキーマ 数によるパフォーマンスの変化 Aurora MySQL のパフォーマンスと スキーマ 数の関係をより掘り下げて調べてみました。前回とは以下の点を変更し、150 ~ 1200 スキーマ にて再測定を行いました。 クライアントの負荷設定を固定する(24並列 * 500QPS = 12000TotalQPS) 十分な暖機運転を行いキャッシュに乗り切ったことを確認してから本測定を行う 測定結果を以下にまとめます。かなり 直感とは異なる結果 になりました。 高負荷で固定した際のパフォーマンスの変化 なんと、600 スキーマ が最も スループット が高く、 スキーマ 数が増えた時だけではなく、少なくなった時にも性能の劣化が見て取れます。 そして150, 600, 1200 スキーマ での測定時のAASのグラフは以下のようになっていました。色は異なりますが、支配的となっているのはすべて wait/io/table/sql/handler です。 150 スキーマ 600 スキーマ 1200 スキーマ 600 スキーマ での測定で最も平均AASが少なくなっています。また、1200 スキーマ に加えて、150 スキーマ での測定でも wait/io/table/sql/handler の待機時間が増加しています。 150 スキーマ で性能が劣化する理由 150 スキーマ 構成では、600 スキーマ 構成と比較してテーブルあたりのアクセス密度が4倍になります。たとえ スキーマ が分かれていても、 InnoDB 内部の管理用ハッシュテーブルやラッチ( 排他制御 )は インスタンス 全体で共有、あるいは少数の パーティション で管理されています。 150 スキーマ では管理対象が少ない分、 特定の管理 パーティション にアクセスが集中する「 ホットスポット 」 が発生しやすく、内部的な順番待ちが頻発します。この微細な足止めが積み重なり、結果としてストレージエンジン層の処理時間である wait/io/table/sql/handler を引き延ばしていると考えられます。満遍なく一定の遅延が発生していることも、この説を裏付けます。 150 スキーマ 1200 スキーマ で性能が劣化する理由 一方、1200 スキーマ 構成での劣化は、150 スキーマ の時とは逆に 「管理対象の膨大さ」によるオーバーヘッド が原因であると考えられます。 今回の測定ではキャッシュヒット率が99.5%を超えており、ディスクI/Oの影響は無視できます。しかし、これほど大量の スキーマ が存在すると、 InnoDB が高速化のために生成する「 アダプティブ ハッシュインデックス(AHI)」などの管理データ自体が巨大化します。この巨大化したデータの中から目的のデータを探し出す際、たとえメモリ上であっても管理 パーティション の奪い合いが発生します。この様子は wait/synch/sxlock/innodb/hash_table_locks の待機時間(茶色)として現れており、これに比例して wait/io/table/sql/handler が増加していることがわかります。 つまり、分散のメリットよりも、巨大なリソースを管理・検索するコストが上回ってしまった状態と言えます。 1200 スキーマ 逆に言えば、適切な範囲内であれば スキーマ を分離して負荷を分散すること(パーティショニング)によってパフォーマンスが向上するケースもあると言えます。(これは前回時点では認識していなかった スキーマ 分離のメリットですね) なお、今回の測定はクエリの種類とテーブルを絞り、メモリ負荷が スキーマ 数に対して非常に少なくなるような条件で行ってます。実際に大量 スキーマ のテーブルに対して満遍なくアクセスが発生する場合は、 wait/synch/mutex/innodb/dict_sys_mutex ( データディクショナリ へのアクセス競合)などが支配的になることも十分に考えられます。 負荷試験 を行う上で注意したいこと 負荷試験 において最も注意しなければならないのは、 「得られた数値を絶対視し、誤った解釈をしてしまうこと」 です。今回の測定を通して見えてきた、試験設計と結果評価における注意点をまとめます。 本番環境と試験環境の違いを考える 前回、今回の 負荷試験 は、 上位のものとはいえ、使用するクエリやアクセスするテーブルを絞った環境にて行ったものになります。また、アクセスの並列数も24並列と実際のワークロードに比べると少ないものであり、1スレッドあたりの負荷を上げて総量を補っているとはいえ、本番環境を再現しているとは言い難いです。 よって、今回は以下のような点に注意して結果を評価しています。 「キャッシュ不足」は評価できても、「キャッシュ十分」とは評価できない 負荷試験 はあくまでよく使われている一部のクエリ、テーブルのみをサンプリングしている。使用されないテーブルはキャッシュに乗らない 結果は鵜呑みにするのではなく、定性的に再解釈する 例えば負荷の総量が同じでも、1並列でかける負荷と100並列でかける場合ではDB内部の 排他制御 は全く異なるため、結果の数値をそのまま受け取ってはいけない。数値をそのまま本番のキャパシティとして解釈するのではなく、振る舞いの定性的な変化を読み取ることに主眼を置くべき 負荷試験 には、 条件設定によって評価できるものと評価できないものがある ということです。また、数値をそのままの状態で受け取ることもミ スリード を生む危険があります。このような点に注意して試験設計、結果評価を行いましょう。 測定の分散について 以下は全く同じ負荷設定で、 数時間以内に 測定した結果を比較したものです。サンプルは少ないですが、QPS換算で1%程度の分散におさまっています。 TotalQPS QueryCount P99[μs] Avg [μs] 8554 15,398,029 4.73E+03 2.34E+03 8459 15, 226 ,641 4.49E+03 2.37E+03 8460 15,228,216 4.32E+03 2.36E+03 対してこちらは全く同じ負荷設定で、 日付を跨いで 測定した結果を比較したものです。 TotalQPS QueryCount P99[μs] Avg [μs] 9118 16,412,667 4.60E+03 2.09E+03 8450 15,210,198 4.44E+03 2.36E+03 なんと、QPS換算で 7.3%程度の差 が出てしまっています。マネージドサービスゆえの不可避な外部要因( AWS の帯域や近隣 インスタンス の負荷)によるものだと思っていますが、この結果は、測定そのものの分散よりも時間帯による分散の方が遥かに大きなことを示唆しており、 最低でも7%以上の分散 があることがわかります。 つまり、 この測定では「5%程度の性能改善」を論じても意味がない わけです。その数値は誤差の範囲に埋もれてしまうからです。結果を数値的に求めたい場合は、測定自体の誤差がどの程度なのかを評価する必要があり、それができない限りは 定量 的な評価は難しいということです。間違っても、1回だけ測定して「こんな数値が取れました!」という結果の受け取り方はしないようにしましょう。 おわりに 前回記事の内容と合わせて本格的な 負荷試験 を行い、当初の目的であった現行 アーキテクチャ のデッドラインを見積もるという目的は無事達成できました。しかし、目的達成以上に、その試行錯誤の過程から多くのことを学ぶことができたと思います。 「 スキーマ 数が増えれば管理コストで遅くなる」という一般論は知っていても、実際に手を動かして測定を行い、予想と異なる振る舞いに悩み、考え抜いたことで、より MySQL に関する理解は深まりました。そして、「推論と検証を繰り返すことで ブラックボックス を一つずつ明らかにしていく」というプロセスそのものが、エンジニアにとって何よりも大切な経験であり、自信にもつながることを再確認できました。 本記事の試行錯誤の過程が、これから 負荷試験 に挑む皆様にとって、一歩を踏み出すための参考になれば幸いです。 MNTSQ株式会社 SRE 西室
はじめに 課題感 スプリットビュー DNS サンプルコード おわりに 参考 はじめに 小ネタです。記事タイトルが長いのですが、これは本稿の内容を1行で説明したものになります。 そして OpenSearch に限らない一般的な話題としては repost.aws という優れた情報が既にあります。本稿では Terraform コードの例示や背景についての解説を与えることで、付加価値を与えんとするものになります。 課題感 OpenSearch を AWS 上のマネージドサービスとして扱う場合、 OpenSearch ドメイン を外部からのアクセスが可能なものとして構築するか、 VPC 内に閉じたものとして構築するかの2択があります *1 。後者を選ぶ場合 OpenSearch ドメイン へのアクセスは対象の VPC 内からのみアクセスが可能になります また OpenSearch ドメイン には作成後標準で払い出されるエンドポイントとは別にカスタムエンドポイントを設定することができます。これは apex を含む任意 ドメイン を利用者の所望のものにすることができるものになります *2 さて OpenSearch は通常 HTTPS による通信が強制されており、これは標準のエンドポイント / カスタムエンドポイント 両方で該当します。標準のエンドポイントについては AWS がよしなに SSL 証明書を設定してくれますが、カスタムエンドポイントについてはユーザ側で SSL 証明書を用意しなくてはなりません。 手軽にこのあたりをやるには ACM で SSL 証明書を払い出そうという話になり、より手軽にやろうとすればカスタムエンドポイント設定値としての ドメイン の存在確認を DNS 検証でやる *3 という段取りになるでしょう。 ただし ACM による DNS 検証は インターネット経由で名前解決が可能な DNS レコード に対してのみ対応可能です。 VPC に閉じる構成とした OpenSearch ドメイン に対し設定するカスタムエンドポイントで使う ドメイン をインターネットから名前解決可能とするメリットは無いでしょうから、これも VPC 内からのみ名前解決が可能な Route 53 の private hosted zone で設定することになります *4 。 つまり カスタムエンドポイントとして使用する ドメイン はインターネットから名前解決不可 になり、これで何が困るかというと ACM による DNS 検証が不可 となります。 いままでの議論をまとめると以下のようになります。 課題感 スプリットビュー DNS これを解決するには本稿題名に掲げたスプリットビュー DNS *5 が有効でしょう。 AWS においては Route 53 に関するドキュメントのうち こちら にあるとおり、Route 53 において private / public の両 hosted zone を組み合わせることで実現が可能です。すなわち example.com という public な hosted zone を Route 53 に追加する internal. example.com という private な hosted zone を Route 53 に追加する これだけです。 opensearch.internal.example.com という ドメイン を VPC 内に閉じた OpenSearch ドメイン のカスタムエンドポイントとして使用することを考えると opensearch というレコードで OpenSearch エンドポイントを向く CNAME レコードを private 側の Route 53 hosted zone( internal.example.com に対応)へ設定 opensearch.internal.example.com という ドメイン に対し ACM 上で SSL 証明書発行 2. の結果得られた DNS 検証用レコードを public 側の Route 53 hosted zone( opensearch.internal.example.com に対応)へ設定 ここまでに得られた ACM 上 SSL 証明書の ARN と ドメイン 名とを OpenSearch へカスタムエンドポイントとして設定 という手筈を踏めば、 ACM で DNS 検証が可能な OpenSearch カスタム ドメイン 向けの SSL 証明書が手に入り、かつ VPC 内に限って OpenSearch ドメイン にてカスタム ドメイン を使っての通信が可能、という状態にもってゆくことができます *6 。 opensearch .internal. example.com な OpenSearch カスタムエンドポイントを使う図 サンプルコード 上記構想を AWS マネジメントコンソールでやるのは少々骨です。実際に使用している Terraform コードに適宜修正を加えたサンプルコードを例示します variable "hostzone" { type = object ( { external = string internal = string } ) default = { external = "example.com" internal = "internal.example.com" } } variable "custom_endpoint" { type = string nullable = true default = "opensearch.internal.example.com" } resource "aws_vpc" "main" { # 詳細略 } /* Route 53 関連 各 hosted zone 定義と OpenSearch カスタムエンドポイントとして使うドメインに対応する DNS レコードの設定を行う */ resource "aws_route53_zone" "external" { name = var.hostzone.external } resource "aws_route53_zone" "internal_split_dns" { name = var.hostzone.internal vpc { vpc_id = aws_vpc.main.id } } resource "aws_route53_record" "opensearch_custom_endpoint" { # 本稿の例では常時 true だが var.custom_endpoint が無い場合はレコード作成不要なので条件分岐できるようにしておく for_each = ( var.custom_endpoint != null ? { "main" = {} } : {} ) zone_id = aws_route53_zone.internal_split_dns.zone_id /* var.custom_endpoint の apex ドメインは aws_route53_zone.internal_split_dns で指される Route 53 ゾーンに一致する必要がある この理屈から当該 apex zone に設定すべき OpenSearch ドメイン用 DNS レコードの値は var.custom_endpoint から apex zone 名を差っ引けば得られる */ name = replace (var.custom_endpoint, aws_route53_zone.internal_split_dns.name, "" ) type = "CNAME" ttl = 60 records = [ aws_opensearch_domain.main.endpoint, ] } /* ここから ACM 関連 証明書払い出しと DNS 検証のための各種設定までを行う */ resource "aws_acm_certificate" "internal_split_dns" { domain_name = var.custom_endpoint validation_method = "DNS" } resource "aws_route53_record" "internal_split_dns" { for_each = { for dvo in aws_acm_certificate.internal_split_dns.domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } zone_id = aws_route53_zone.external.id allow_overwrite = true name = each.value.name records = [ each.value.record ] ttl = 600 # 適当に変更可 type = each.value.type } resource "aws_acm_certificate_validation" "internal_split_dns" { certificate_arn = aws_acm_certificate.internal_split_dns.arn validation_record_fqdns = [ for record in aws_route53_record.internal_split_dns : record.fqdn ] timeouts { create = "15m" } } resource "aws_opensearch_domain" "main" { /* カスタムエンドポイント関連以外の詳細は省略 実際に OpenSearch ドメインを構築するには他にも必須項目がある See: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain */ domain_endpoint_options { enforce_https = true custom_endpoint_enabled = var.custom_endpoint == null ? false : true custom_endpoint = var.custom_endpoint custom_endpoint_certificate_arn = aws_acm_certificate.internal_split_dns.arn } encrypt_at_rest { enabled = true } node_to_node_encryption { enabled = true } } おわりに 簡単ではありますが OpenSearch 構成の tips について解説する項目となりました。カスタムエンドポイントを使わない場合、 OpenSearch のデフォルトでは https://search-<ドメイン名>-<ランダム文字列>.<リージョン>.es.amazonaws.com のようになり *7 、かつ VPC 内に閉じる場合ではより長く https://vpc-search-<ドメイン名>-<ランダム文字列>.<リージョン>.es.amazonaws.com となります。これが本稿の例でいえば https://opensearch.internal.example.com として扱えるようになり、シンプルなエンドポイントで OpenSearch を使うことが可能になります。 用途その他の事情で OpenSearch デフォルトのものではないエンドポイントを設定したくなり、またその対象となる OpenSearch ドメイン が VPC 内からのみ利用可能なものであった場合に、本稿の内容がお役に立てば幸いです。だいぶニッチな話題ではありますが、そういった需要は決してゼロではないと考えています。 MNTSQ 株式会社 SRE 秋本 参考 文中で示した引用以外のものとして、本稿の話題に取り組む前の調査時に 【Route53】スプリットビューDNSの名前解決順序を整理してみた | DevelopersIO を拝読しました。 *1 : https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html *2 : https://docs.aws.amazon.com/opensearch-service/latest/developerguide/customendpoint.html *3 : https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html *4 : OpenSearch / ACM / VPC とお膳立てを AWS 前提として進めてきたので、いきなり DNS 関係も Route 53 を使うこととしてもさして矛盾はしないと思っています *5 : 本稿を書く上でこの定義が気になったので調べたところ https://datatracker.ietf.org/doc/html/rfc8499 や https://datatracker.ietf.org/doc/html/rfc6950/ が有用そうでした。本稿を読む上でこの内容を理解する必要は全くありません。これはほぼ私的なメモです *6 : 直下の図中 xN の表記は https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html に拠ります *7 : https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html
はじめに 弊社では AWS を主軸としたインフラ構成をもってプロダクトを展開していますが、一部では AWS 以外にも Azure および Google Cloud も活用しています。それぞれの棲み分けは以下のようなものになります。 AWS :ほとんど全て コンピュート / ネットワーク / ストレージ / DB / セキュリティ etc. Azure: OCR 関連 Google Cloud:LLM を使う処理および OCR 関連 利用規模としては AWS >> Google Cloud > Azure といったものになり、結果としてこれら クラウド ベンダーの利用にかかるコスト(利用料金の意。以下本稿では「コスト」を料金を指す意味でのみ用います)割合も AWS が支配的なものになります *1 。 これら クラウド ベンダーを扱う上でネックになることといえば勿論コストです。各ベンダーそれぞれコスト確認のための優れたツールを提供していますが、無論各ツールはそれぞれのベンダー内に閉じるものであり、例えば「今月 Google Cloud コストが普段と比べて妙な増え方をしているが AWS 側でも変動している形跡は無いだろうか?」といった調査をする場合、 AWS と Google Cloud とで行きつ戻りつしながらコスト調査を行う必要が出て来ます。 このあたりへの対処として、弊社では Datadog にコスト情報を集約する という選択肢をとることにしました。この内容について解説します。 構成 概要を以下に示します。 図の複雑さの違いが示すとおり、Datadog にコスト情報を連携させる戦略は各 クラウド ベンダーによって異なります。やりかたは一通りではないという前置きをしつつ、弊社では以下のような手法をとっています。 AWS :consolidated billing アカウントで Cost & Usage Report (CUR) を生成し、Datadog で CUR の内容を収集する AWS Organizations 管理下にある全 AWS アカウントのコスト情報を Datadog に連携する 弊社では例外なく全 AWS アカウントが AWS Organizations 管理下にあるので、個別に連携を頑張るよりも合理的 弊社では AWS Organizations 管理アカウントが consolidated billing アカウントを兼ねており、 適切な権限制御をする前提において 管理アカウントと Datadog とを連携するのみで事足りる Azure:コストを追いたい サブスクリプション に対し cost export を設定し、その結果を Datadog で収集する Google Cloud:Datadog 連携用のプロジェクトでコスト情報を BigQuery + Cloud Storage で扱えるようにした上でその内容を Datadog で収集する プロダクト向けのワークロードを稼動させているプロジェクトは同一の請求アカウントに紐付けるような構成としているので、追跡したいコスト *2 としてはこの請求アカウントに関するもの Datadog 連携用のプロジェクトもこの請求アカウントに紐付け(後述)、必要なコスト情報を単一プロジェクトに集約する格好とした さも最初から設計したかのように書いていますが、実際には殆どの要素を Datadog が提供するドキュメントに従ってのものになります。以後本稿でも基本的にはこの情報を前提にします。 https://docs.datadoghq.com/cloud_cost_management/setup/aws/ https://docs.datadoghq.com/cloud_cost_management/setup/azure/ https://docs.datadoghq.com/cloud_cost_management/setup/google_cloud/ なお Azure のみ単一の サブスクリプション を対象としていますが、これは以下背景によります。弊社事情である、というのが要旨です。 本番ワークロードを処理する サブスクリプション が Azure コストの支配的な要素を占めており、それ以外の サブスクリプション のコストを追う動機が薄い AWS / Google Cloud とは異なり Azure はプロダクト用途以外にもコーポレート用途の要素があり、SRE で管理できていない部分がある 具体的には管理グループ単位でのコスト監視設定を行うのが難しかった プロダクト向けのワークロードを考える範囲においては 1. の理由で特定の サブスクリプション を追跡できれば充分という背景があった 設定 基本的には前述の Datadog が提供する設定手順に従えばスムーズに設定できます。とくにコスト関連のお膳立てが整っていれば Datadog との連携およびコスト関連の情報取得はかなりスムーズに設定できます。 一方でコスト情報を適切に出力する為の作業で一部難儀したことがありました。これは作業者が AWS の経験に寄り過ぎていることも一因ですが、ひょっとしたら同じようなハマり方をする方がいるかもしれません。恥をしのんで解説します。 なお以下では実際に筆者が作業した過程をなぞるかたちでドキュメントベースで解説しますが、実際には Cloud Cost のアカウント設定画面 からウィザードに従って作業をすすめるほうが直感的だったりします。また文中で示すリンクテキストのリンク先は特記のない限り Datadog 公式ドキュメントです。 AWS 作業対象(前掲概要図から抜粋) Datadog の公式ドキュメントは こちら になります。やることはおおまかに Datadog と AWS アカウントとの連携設定 AWS アカウント側での Cost & Usage Report (CUR) の設定 Datadog 連携用 IAM ロールで 2. の CUR が参照できるよう権限の設定 Datadog 側での Cloud Cost 設定 の4点です。 ドキュメントでは CloudFormation を使う方法と手作業 (manual) で進める方法の2択がありますが、弊社では既存設定を Terraform で IaC 化しているためにて本作業も Terraform で進めるべく、手作業での設定としました。この場合に参考にすべき手順は こちら になります。 1. と 4. は上掲手順に従うのみで大丈夫そうでした。 2. と 3. *3 を Terraform で設定する場合のサンプルコードは以下のようになります。 variable "datadog_external_id" { type = string description = "External ID provided by Datadog on AWS integration" default = "" } data "aws_caller_identity" "current" {} data "aws_iam_policy_document" "datadog_integration" { statement { actions = [ "s3:PutObject" , "s3:GetBucketPolicy" , ] resources = [ aws_s3_bucket.datadog_integration.arn, "$ { aws_s3_bucket.datadog_integration.arn } /*" , ] principals { type = "Service" identifiers = [ "billingreports.amazonaws.com" , "bcm-data-exports.amazonaws.com" , ] } condition { test = "StringLike" variable = "aws:SourceArn" values = [ "arn:aws:cur:us-east-1:$ { data.aws_caller_identity.current.account_id } :definition/*" , "arn:aws:bcm-data-exports:us-east-1:$ { data.aws_caller_identity.current.account_id } :export/*" , ] } condition { test = "StringLike" variable = "aws:SourceAccount" values = [ data.aws_caller_identity.current.account_id, ] } } } resource "aws_s3_bucket" "datadog_integration" { bucket = "mntsq-master-datadog-integration" } resource "aws_s3_bucket_policy" "datadog_integration" { bucket = aws_s3_bucket.datadog_integration.id policy = data.aws_iam_policy_document.datadog_integration.json } /* Datadog の Cloud Cost では CUR 2.0 に対応していない legacy CUR の場合は以下リソースを使って定義する */ resource "aws_cur_report_definition" "datadog_integration" { report_name = "datadog-integration" time_unit = "HOURLY" format = "Parquet" compression = "Parquet" additional_schema_elements = [ "RESOURCES" , "SPLIT_COST_ALLOCATION_DATA" , ] s3_bucket = aws_s3_bucket.datadog_integration.bucket s3_prefix = "cost_and_usage_report" s3_region = aws_s3_bucket.datadog_integration.region } data "aws_iam_policy_document" "datadog_aws_integration_assume_role" { statement { actions = [ "sts:AssumeRole" ] principals { type = "AWS" identifiers = [ "arn:aws:iam::464622532012:root" ] # See: https://docs.datadoghq.com/integrations/guide/aws-manual-setup/?tab=roledelegation } condition { test = "StringEquals" variable = "sts:ExternalId" values = [ var.datadog_external_id, ] } } } data "aws_iam_policy_document" "datadog_aws_integration" { statement { actions = [ /* Datadog と AWS との連携 (integration) で何を連携するか(= Datadog にどのような AWS の情報を流すか)によって変わる 必要に応じて定義する。今回は詳細省略 */ ] effect = "Allow" resources = [ "*" ] } } data "aws_iam_policy_document" "cost_and_usage_report_export" { statement { effect = "Allow" actions = [ "s3:ListBucket" ] resources = [ aws_s3_bucket.datadog_integration.arn, ] } statement { effect = "Allow" actions = [ "s3:GetObject" ] resources = [ "$ { aws_s3_bucket.datadog_integration.arn } /cost_and_usage_report/*" , ] } statement { effect = "Allow" actions = [ "ce:Get*" ] resources = [ "*" ] } statement { effect = "Allow" actions = [ "cur:DescribeReportDefinitions" ] resources = [ "*" ] } statement { sid = "DDCloudCostListOrganizations" effect = "Allow" actions = [ "organizations:Describe*" , "organizations:List*" ] resources = [ "*" ] } } resource "aws_iam_policy" "datadog_aws_integration" { name = "DatadogAWSIntegrationPolicy" description = "Allow actions for Datadog Integration" policy = data.aws_iam_policy_document.datadog_aws_integration.json } resource "aws_iam_policy" "datadog_cost_and_usage_report_export" { name = "DatadogCurExportPolicy" description = "Allow actions required to export Cost and Usage Report to Datadog" policy = data.aws_iam_policy_document.cost_and_usage_report_export.json } resource "aws_iam_role" "datadog_aws_integration" { name = "DatadogAWSIntegrationRole" description = "Role to integrate AWS and Datadog" assume_role_policy = data.aws_iam_policy_document.datadog_aws_integration_assume_role.json } resource "aws_iam_role_policy_attachment" "datadog_aws_integration" { role = aws_iam_role.datadog_aws_integration.name policy_arn = aws_iam_policy.datadog_aws_integration.arn } # AWS マネージドポリシ。Datadog が Resource Collection 時に要求する data "aws_iam_policy" "security_audit" { name = "SecurityAudit" } resource "aws_iam_role_policy_attachment" "datadog_aws_integration_security_audit" { role = aws_iam_role.datadog_aws_integration.name policy_arn = data.aws_iam_policy.security_audit.arn } resource "aws_iam_role_policy_attachment" "datadog_aws_integration_cost_and_usage_report" { role = aws_iam_role.datadog_aws_integration.name policy_arn = aws_iam_policy.datadog_cost_and_usage_report_export.arn } Azure 作業対象(前掲概要図から抜粋) Datadog の公式ドキュメントは こちら になります。やることはおおまかに Datadog と Azure サブスクリプション との連携設定 Azure サブスクリプション での cost export 設定 cost export 結果を収容する storage container を Datadog 連携用 の app registration が参照できるよう設定 Datadog 側での Cloud Cost 設定 の4点です。 Azure については IaC 管理できていない範囲 *4 につき、手作業で設定をすすめてゆく必要がありました。なお本対応をおこなうまで Azure と Datadog とを組み合わせて利用するケースは弊社内において存在せず、その段階からの作業が必要となった点、補記しておきます。 1. については 手順 を通読することになります。弊社では "Quickstart (recommended)" によって作業をすすめました。 続く 2. ですが、 手順 に従い作業をする前に storage account blob container を用意しておく必要があります。Datadog と連携した Azure サブスクリプション 内に storage account を作り、その中に blob container を設ける格好です。cost export の結果は最終的に blob container 内に格納されます。上述二点が用意できたら前掲手順に従い cost export を設定します。いくつかフォーマットを設定できますが、弊社では AWS の CUR に合わせて Parquet を選定しました。 3. については 手順 に従い素直に設定すれば大丈夫です。Azure の世界観ではタブは 画面左側 にあるので、手順内で "tab" と説明されるものは画面左側にありますので注意が必要です *5 。 4. については手順通りで割合簡単に設定が可能なので詳細は省略します。 Google Cloud 作業対象(前掲概要図から抜粋) Datadog の公式ドキュメントは こちら です。以下5点が必要な作業となりました。本対応をおこなうまで Google Cloud と Datadog とを組み合わせて利用するケースは弊社内において存在せず、その段階からの作業が必要となった点、補記しておきます。 Datadog 連携用のプロジェクトを用意し、Datadog との連携設定を実施 cost export 設定を対象プロジェクト内の BigQuery から取り扱う設定を追加 用意したプロジェクトに対し請求アカウントからの cost export 設定を追加 BigQuery 出力先となる Cloud Storage バケット を対象プロジェクト内に追加 Datadog 用 service account から 4. で設定した Cloud Storage バケット が参照できるよう権限を設定 Datadog 側での Cloud Cost 設定 Google Cloud についても IaC 管理できていない範囲 *6 につき、手作業で設定をおこないます。 1. については 手順 を通読し必要な作業を行います。どのように Google Cloud プロジェクトを設計するかは個々のケースによって様々な方法論があると思いますが、弊社では素直に Datadog と連携するためのプロジェクトを新設する方向にしました。この際プロジェクトは適当な請求アカウントに紐付けないと Datadog との連携設定でエラーになるため、事前に請求アカウント向けの設定を Google Cloud 側の手順 などを参照して済ませておく必要があります。 2. については こちら が手順となりますが、ここでは既に BigQuery デー タセット が存在していることが前提になります。利用するプロジェクト内で事前に用意しておき、その上で 3. に臨みます。cost export 設定を 請求アカウントから 実施する必要があります。 4. と 5. は 手順 に従い素直に作業します。ここで手順内の "co-located" は BigQuery デー タセット と Cloud Storage バケット とでリージョン設定を同一にせよという意味になる *7 ので、そのように設定します。 6. においては手順に従えば作業は簡単にできます。以下が必要になるので適宜参照できるよう準備して設定に臨みます。 請求アカウント ID プロジェクト ID cost export 用の BigQuery デー タセット 名 BigQuery デー タセット 出力先の Cloud Storage バケット 名 なお BigQuery でコスト情報が取り扱えるようになる前に Cloud Cost 設定に臨むとエラーになります。おおむね数時間程度 *8 待つと BigQuery でコスト情報が取り扱えるようになるので、それ以降で設定を試みるとよいでしょう。 結果 全ての設定が済むと Cloud Cost 概要画面 で設定した クラウド ベンダー横断の状況が観察できるようになります。backfill 的な作業をしない限りは基本的に Cloud Cost 利用開始時点のコスト情報が連携されるので、これが真価を発揮するのは充分にコスト情報が蓄積されてからとなるでしょう。 Cloud Cost そのものの機能を使いこなすには弊社としてもまだ至っていません。有効にしてから日が浅く、まだ充分なデータが溜まっていないという側面があります。 一方で Datadog メトリックとして各 クラウド ベンダのコスト情報を適切な分解能でもって Datadog 内で扱える というメリットは Cloud Cost そのものの機能を差っ引いても享受でき、弊社ではひとまず AWS / Azure / Google Cloud 全体のコスト確認用 上記のうち主要なコストファクターとして扱われるアカウント / プロジェクトに絞ったコスト確認用 プロダクト環境単位でアカウント / プロジェクト / サブスクリプション を整理した上で「環境」を横断してのコスト確認用 といった区分で ダッシュ ボードをつくり、定期的に傾向をみる、といった運用をとっています。 3. が少々解り辛いのですが、要するに本番環境として扱われるお客様のワークロードを実際に捌いている AWS アカウント / Google Cloud プロジェクト / Azure サブスクリプション のコストを俯瞰して観察するというものです。 おわりに Datadog の Cloud Cost を使用し、社内で利用している各 クラウド ベンダーのコストを集約して観察する方法について取り扱いました。費用変動の痕跡をつぶさに追う上で参照すべき情報が散逸している状況は多くの苦労を伴いますが、ひとつの場所を確認しておけば間に合う、という状況は中々の 心理的 安全性が見込めます。Cloud Cost は SaaS コストや Datadog 自身のコストも対象に含めることが出来る *9 ので、さらなる観測範囲の拡張をおこない精度の高いコスト監視体制を組むことも可能そうです。弊社でも Cloud Cost の利用は開始したばかりといった状況なので、まずはデータの蓄積をしつつ、よりよい使い所を検討できればと考えています。 マルチ クラウド 構成におけるコスト監視に課題感をお持ちの方の対応検討の一助になれば幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : 詳細な数字は割愛しますが AWS の利用コストは Google Cloud / Azure に比べ桁が1つ異なります *2 : もちろん「プロダクト向けのワークロード」を稼動させているプロジェクトが対象です。というのも弊社ではこれ以外にもプロジェクトが様々な用途で存在し、それらプロジェクトは別の請求アカウントに紐付きます。これら全てのコスト情報を集約させると話が大きくなってしまうので、今回はワークロード稼動環境に絞るものとしました *3 : IAM ロール / ポリシ関連のコードがあることから推測できるとおり、実際には 1. に対応する範囲も含んでいます *4 : Terraform でやりたい *5 : 本稿筆者はこれでけっこうハマりました。実は CUI が主な生活空間なので、 GUI 操作には苦手意識があります *6 : Terraform でやりたい2 *7 : 恥ずかしながらドキュメントから誘導される https://docs.cloud.google.com/bigquery/docs/exporting-data#data-locations を読んでも勝手が解らず少々ハマりました。英語的なニュアンスが汲み取れればよかったのですが…… *8 : だいたい Google Cloud 側で設定完了してから 4 -- 5 時間といったところでした。状況による変動がかなりあると推測されるので、ひとつの参考事例とご理解ください *9 : https://docs.datadoghq.com/cloud_cost_management/
はじめに ソフトウェアエンジニアの森山です。 CloudFront で API と静的ファイルを別オリジンで扱い Single Page Application(以下 SPA)を ホスティング する構成について解説します。 プライベート API を遮断する設定を追加する際に CloudFront のカスタムエラーレスポンスで躓きました。しかし CloudFront Functions を活用することで上手く切り抜けることができました。 構成 リク エス トのパスを元にCloudFrontでオリジンへのアクセスを振り分けています。 S3でSPAのアセットを ホスティング し、ALB経由でECSでバックエンドの API へ疎通しています。 ハマりポイント /api/private/* へのリク エス トのみALBで遮断する設定を施しました。しかし、何度 API コールしても ステータスコード 200が返却され遮断できていないように見えました。試しにWAFで遮断する設定をしても結果は同じく ステータスコード 200が返却されました。 AWS の設定反映が遅延していることを疑いましたがWAFには /api/private/* のリク エス トはブロックしている アクセスログ が記録されていました。 原因 原因は以下のCloudFrontのカスタムエラーレスポンスでした。 上記はオリジンからの ステータスコード 403,404のレスポンスを ステータスコード 200に上書きしindex.htmlを返却します。 カスタムエラーレスポンスを設定しているとCloudFrontがレスポンスを返却する 直前に上書き するのでALBでブロックしてもその後に200に上書きしたレスポンスが返却されます。 余談ですが、動作確認の反省ポイントは HTTPメソッド: HEAD の API を使っていたことです。GETで確認していればレスポンスがindex.htmlに上書きされることの早期発見に繋がったはずです。 カスタムエラーレスポンスの意図 ではなぜこんな設定をしていたのか。 一言で言うと / 以外のリク エス トで index.html を返却するためです。 これはフロントエンドがSPAであることが関係しています。SPAは名の通り1つのhtml(今回はindex.html)を起点に動作します。画面遷移は JavaScript で実装されたrouterが担います。routerがURLを管理し、動的importが評価されるたびにブラウザがアセットのリク エス トを飛ばします。 SPAの場合はこの起点となるindex.htmlありきです。 当たり前ですがindex.htmlがユーザーに届けられなければ、アプリケーションのエントリーポイントが無いので後続のjsや css を読み込めません。 CloudFrontでSPAを ホスティング している場合、ルートページ以外への直接アクセスで index.html が存在しない状態が発生し得ます。 例えば https://mntsq.com/hoge というURLへrouterを介さずにブラウザのURLバーへ直接入力してリク エス ト、新規タブで画面遷移、またはリロード等です。 その場合は、 GET: https://mntsq.com/hoge リク エス トが CloudFront へ飛びます。 しかしS3は /hoge というオブジェクトが無いのでエラーを返します。非認証なら403、認証後なら404です。結果としてブラウザに index.html が届けられなくなってしまいます。 これを回避するためにカスタムエラーレスポンスを設定していました。 しかし、カスタムエラーレスポンスの対処ではマルチオリジン構成の場合に課題があります。 カスタムエラーレスポンスの課題 カスタムエラーレスポンスはS3以外のオリジンへのレスポンスも上書きしてしまうことです。 カスタムエラーレスポンスはCloudFrontの ディストリビューション に対して設定されます。 CloudFrontにおいて ディストリビューション とオリジンの以下の関係性です。 ディストリビューション S3 オリジン API オリジン S3だけでなく API のレスポンスが403や404の場合にも上書きされてしまいます。そうなるとクライアント側で API のエラーハンドリングもできません。 解決策 CloudFront Functionsで リク エス ト を上書きします。 CloudFront Functionsはカスタムエラーレスポンスと違い ディストリビューション 事ではなくビヘイビア毎に紐つけることができます。 つまり /* へのリク エス トだけを上書きし、 /api/* へのリク エス トに対しては何もしないという制御ができます。 処理の分割としては以下のようになります。 CloudFront FunctionsではCloudFrontのリク エス トやレスポンスに JavaScript で軽量な処理を挟むことができます。 シーケンス図内の⑨ 必要に応じてindex.htmlを要求の箇所は、拡張子の有無で識別できました。 そのため、拡張子の無いリク エス トはindex.htmlのリク エス トに上書きするというCloudFront functionを以下のように実装しました。 /** * SPAの初回ロードリクエストに対して/index.htmlを返す関数 * * SPAでは新規タブやリロードでルート以外のURLに初回アクセスするとindex.htmlが返らず画面がホワイトアウトするため、 * この関数は初回リクエストを/index.htmlに書き換える。 */ function handler ( event ) { const request = event . request const uri = request . uri const pathTail = uri . substring ( uri . lastIndexOf ( "/" ) + 1 ) // 最後の/以降のパス const hasExtension = pathTail . includes ( "." ) // パスに.が含まれているか(ファイル拡張子があるか) if ( ! hasExtension ) { // 拡張子無しは、新規タブやリロードによるルートページ以外の初回ロードリクエスト // SPAにおけるエントリーポイントである/index.html に書き換える request . uri = "/index.html" } return request } } CloudFront Functionsで注意すべき点は JavaScript のランタイム環境です。 JavaScript ランタイム1.0と2.0の2種類ありますがどちらも ECMAScript 5.1に準拠しています。 大規模で レイテンシー の影響を受けやすい CDN カスタマイズのための軽量な関数を記述できるとあります。( link ) この軽量さを実現するためにランタイム環境として使える機能も厳選されているようなので注意が必要です。( link ) 上記のCloufFront Functionsは JavaScript ランタイム2.0で動作しています。 まとめ カスタムエラーレスポンスからCloudFront Functionsに切り替えることでマルチオリジンのSPAを ホスティング し、 API のエラーも適切にハンドリングできるようになりました。 元々カスタムエラーレスポンスで403,404を200に上書きするという設定に違和感がありましたが、直感の通りでした。CloudFront Functionsは AWS コンソール上にテスト機能があり非常に助かりました。 MNTSQの仕事にご興味を持たれた方は、ぜひ 採用情報のページ をご覧ください。
こんにちは、 MNTSQ ( モンテスキュー ) で アルゴリズム エンジニア(AIエンジニア)をしている清水です。 MNTSQのプロダクトをLLMネイティブなプロダクトに進化させるべく、LLMOpsに関する実装が増えてきた今日この頃です。 これらの実装の過程で、 複数の MCP サーバーに接続してセッションを管理するにはどのような実装がベストか? という問題にぶつかりました。 自前でラッパークラスを実装するしか方法はないのか、と思っていたのですが、 MCP Python SDK に ClientSessionGroup というクラスがあることを発見したので、これを使うと良さそうだという結論になりました。 私が調べた限りでは、 ClientSessionGroup の使用方法について紹介している記事などは見つけられませんでした。そのため、本記事で MCP Python SDK の ClientSessionGroup の仕様や使い方、 ClientSession との違いなどを整理してまとめてみました。 前提 MCP の実装にはAnthropicの MCP Python SDK のバージョン1.19.0を使用しています。今後のバージョンアップによって仕様が変更する可能性がある点にご留意ください。 github.com 単一 MCP サーバーと接続するサンプルコード 単一の MCP サーバーと接続するミニマムなサンプルコードを以下に示します。このサンプルコードは公式 GitHub で紹介されている サンプルコード を少し改変したものです。このサンプルコードを複数の MCP サーバーと接続できるように拡張し、比較する形式で説明します。 import asyncio from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client async def main (): mcp_server_url = "http://127.0.0.1:8000/echo/mcp" async with streamablehttp_client(mcp_server_url) as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream) as session: await session.initialize() tools = ( await session.list_tools()).tools print (f "Available tools: {[tool.name for tool in tools]}" ) result = await session.call_tool( name= "echo" , arguments={ "message" : "Hello, world!" } ) print (f "Call tool result: {result}" ) if __name__ == "__main__" : asyncio.run(main()) streamablehttp_client を使用して MCP サーバーに接続し、 ClientSession を使用してセッションを作成しています。 MCP サーバーが一つだけならば、このサンプルコードをほぼコピペする形で実装できるのですが、 複数 MCP サーバーの場合はどのように実装すれば良いでしょうか? ClientSessionGroup を使って複数の MCP サーバーと接続する このような用途のために ClientSessionGroup が用意されています 。公式の APIリファレンス では以下のように紹介されています(筆者による日本語訳)。 複数の MCP サーバーへの接続を管理するためのクライアントクラス。 このクラスは、サーバー接続の管理機能を カプセル化 する役割を担う。接続されたすべてのサーバーから提供されるツール、リソース、およびプロンプトを集約する。 先ほどのサンプルコードを複数 MCP サーバーへと接続できるように拡張すると以下のようになります。 import asyncio from mcp import ClientSessionGroup from mcp.client.session_group import StreamableHttpParameters async def main (): mcp_server_urls = [ "http://127.0.0.1:8000/echo/mcp" , "http://127.0.0.1:8001/math/mcp" , ] # 1. `ClientSession`の代わりに`ClientSessionGroup`を使用して`session_group`を作成 async with ClientSessionGroup( component_name_hook= lambda name, server_info: f "{server_info.name}.{name}" ) as session_group: for url in mcp_server_urls: # 2. `ClientSessionGroup.connect_to_server`を使い、MCPサーバーと接続・セッションを作成 await session_group.connect_to_server(StreamableHttpParameters(url=url)) # 3. `ClientSessionGroup.tools`から全てのツールにアクセスする tools = session_group.tools.values() print (f "Available tools: {[tool.name for tool in tools]}" ) tool_names = session_group.tools.keys() print (f "Tool names: {tool_names}" ) # 4. `ClientSessionGroup.call_tool`で各種ツールを実行する result = await session_group.call_tool( name= "EchoServer.echo" , args={ "message" : "Hello, world!" } ) print (f "Call tool result: {result}" ) result = await session_group.call_tool( name= "MathServer.add_two" , args={ "n" : 10 } ) print (f "Call tool result: {result}" ) if __name__ == "__main__" : asyncio.run(main()) 単一 MCP の場合との差分を一つずつ見ていきます。 1. ClientSession の代わりに ClientSessionGroup を使用して session_group を作成 ClientSession を インスタンス 化するときとは異なり read_stream や write_stream などは不要です。空のセッショングループを作成し、後からセッションを追加していく方式であるためです。また、 MCP サーバー間でのツール名の衝突を避けるために、 component_name_hook を与えることができます。 2. ClientSessionGroup.connect_to_server を使い、 MCP サーバーと接続・セッションを作成 ClientSessionGroup.connect_to_server に MCP サーバーのURLを渡すだけで、内部で MCP サーバーに接続し、セッションが作成されます 。以下に connect_to_server メソッド内部の処理を一部抜粋します。 session_stack = contextlib.AsyncExitStack() try : # 中略 else : client = streamablehttp_client( url=server_params.url, headers=server_params.headers, timeout=server_params.timeout, sse_read_timeout=server_params.sse_read_timeout, terminate_on_close=server_params.terminate_on_close, ) read, write, _ = await session_stack.enter_async_context(client) session = await session_stack.enter_async_context(mcp.ClientSession(read, write)) result = await session.initialize() 実装を確認すると、単一 MCP の場合と同様に streamblehttp_client で MCP サーバーに接続 ClientSession でセッションを作成 ClientSession.initialize で初期化 を実行していることがわかります。このように 単一 MCP の場合と同じように作成したセッション をコンテキストスタックに追加することで、複数セッションを管理しています。 3. ClientSessionGroup.tools から全てのツールにアクセスする ClientSessionGroup.tools で全てのツールにアクセスできます。dictを返すので .values() をつけることで、 単一 MCP の場合の (await session.list_tools()).tools と同等のオブジェクト を得ることができます。ただし、以下のような相違点があります。 ClientSessionGroup は、 connect_to_server の実行時に、内部で ClientSession.tool_list を呼び出します。 ClientSessionGroup.tools はすでに取得済みのツール群を返すpropertyであり、その場で ClientSession.tool_list を呼び出しているわけではありません。 ClientSessionGroup.tool は {”ツール名”: Toolオブジェクト} のdictを返します。この”ツール名”は component_name_hook 関数によって付けられた名前です。一方で、 Tool オブジェクトの name 属性は元のツール名のまま であることに注意してください。後に call_tool を実行するときは component_name_hook 関数によって付けられた名前で呼び出す必要があります。よって LLMに Tool オブジェクトを与えるときも、 component_name_hook 関数によって付けられた名前に差し替えた Tool オブジェクトを与える 必要があります 1 。 また、promptやresourceについても上記の仕様が当てはまります。 4. ClientSessionGroup.call_tool で各種ツールを実行する ClientSessionGroup.call_tool は ClientSession.call_tool とほぼ同等に使うことができます 。以下に ClientSessionGroup.call_tool のコードを抜粋します。 async def call_tool (self, name: str , args: dict [ str , Any]) -> types.CallToolResult: """Executes a tool given its name and arguments.""" session = self._tool_to_session[name] session_tool_name = self.tools[name].name return await session.call_tool(session_tool_name, args) 見ての通り内部の実装は簡素なものです。与えられたツール名がどのセッションに属するツールかを解決する処理が挟まっています。この点を除けば、単に ClientSession.call_tool を呼び出す関数と言って良いでしょう。 まとめ 本記事では MCP Python SDK の ClientSessionGroup について紹介しました。ぜひ開発の参考にしていただけたら幸いです。 MNTSQでは、プロダクトをLLMネイティブに進化させるべく、LLMエージェントを搭載した新機能や、LLMの運用・改善のための基盤(LLMOps)を鋭意開発・構築中です。もしMNTSQの仕事にご興味を持っていただけたら、 ぜひお気軽にカジュアル面談でお話ししましょう! careers.mntsq.co.jp note.mntsq.co.jp tech.mntsq.co.jp この記事を書いた人 清水健 吾 MNTSQ アルゴリズム エンジニア LLMのご機嫌と格闘する日々です。 私はこの仕様に気付かず時間を溶かしてしまいました。 MCP のSpecificationでも複数サーバー間でのツール名の衝突に関する仕様は定まっておらず、現状では component_name_hook を与えずに実装するのが無難かもしれません。 ↩
はじめに 構成 ログ送出 ログ保管 GuardDuty 関係 分析 結果確認 実際の運用 分析系 行動系 おわりに はじめに MNTSQ はそのサービスの性質(「契約」の集約、一元管理、活用)上、セキュリティの維持と向上が至上命題です。よってセキュリティ改善において強いモチベーションが存在します。 今回の取り組み以前にも AWS ベストプ ラク ティスに沿った AWS アカウントの管理や各種ログの収集は行われていましたが、収集済みログの活用やセキュリティ系の AWS 各サービス運用には改善の余地が多々ありました。今回ここにテコ入れし、現状に寄り添った運用ができるように改善することを目論みました。 ここでいう「現状に寄り添った」運用とは以下のようなことを指します。 少ない人員でも無理のない範囲で状況把握ができること 管理の手間がなるべく発生しないようなシンプルな構成であること 機微情報に対するアクセス状況を追跡したいなど、上記を鑑みてもなお守りたいセキュリティ要件を達成できるような仕組みが整備できること 構成 おおむね以下のような構成をとっています。アイコンがいっぱい並んでいて「管理の手間がなるべく発生しない」構成なのかには議論の余地がありそうですが、 AWS マネージドサービスを多用する構成につき、方針自体は問題ないと思います。 図中の AWS アカウントの区分は以下のようになります。 member:実際にアプリケーションが動きワークロードを捌く環境(= AWS アカウント) security:セキュリティ関係の諸々を集約している AWS アカウント 各種ログを分析する Athena 関連リソースはここに置く AWS Organizations で管理可能なセキュリティ系 AWS サービスの delegated admin はこのアカウントに設定する master: AWS Organizations 管理アカウント セキュリティ文脈では然程関係ないが上述 "security" な AWS アカウントとの関連で言及 構成図内の要素を大別すると以下のような部位に分けられるでしょう。 ログ送出 ログ保管 GuardDuty 関係 *1 分析 結果確認 以後それぞれについて解説します。 ログ送出 ログ送出関連要素をハイライトした図 分析の項で別途詳述しますが、基本的には S3 バケット にログを置き、それを Athena によって検索 / 分析するような手法をとっています。つまり収集対象としたいログは S3 に置く必要があります。 標準で保存先を S3 に指定可能なサービスであれば話は簡単ですが、一部そうでないものもあります。上記構成図でいえば Route 53 公開 DNS クエリログが該当しました。こちらについての取り組みは拙稿の以下を参照ください。 tech.mntsq.co.jp ログ保管 ログ保管関連要素をハイライトした図(大変見辛いのですが S3 のあたりを強調しています) S3 バケット にログを置くようにさえ出来れば IAM ポリシ / S3 バケット ポリシで定義される権限調整を頑張る前提で Athena から扱えるようになります。Athena と S3 バケット とは同一の AWS アカウントにあってもよく、また別個であっても構いません。とはいえ管理の手間や認知負荷などを考えればどちらか一方(= 同一アカウントに置くか別個のアカウントに置くか)に運用方針を寄せるのがベターでしょう。 弊社では現状を鑑みて無理に運用方針を統一させるのは止し、以下のように2つを並立させるようにしました。 既に長年にわたりログが蓄積されており、過去ログへアクセスできることが運用上でメリットになるもの Athena / S3 とで別アカウントに置くことを許容 AWS WAF v2 ブロックログや ALB アクセスログ などが該当 新規にログ取得を開始したもの Athena / S3 それぞれ同一 AWS アカウントで取扱 前述のとおりセキュリティ関係の諸々は単一 AWS アカウント (security) で管理しており、この文脈において Athena はセキュリティ用 AWS アカウントで管理されます。 Athena と同一の AWS アカウントに置かれる S3 バケット は security に、別個の AWS アカウントに置かれる場合は member に、それぞれ存在することになります。 GuardDuty 関係 GuardDuty 関係の関連要素をハイライトした図 ここは至極単純で、GuardDuty をほぼ吊るしで使うのみです。本番環境用の AWS アカウントでは S3 向けの malware protection *2 も有効にしています。前述のとおりセキュリティ用 AWS アカウントを delegated admin *3 に設定し、他の AWS アカウントをすべて member 扱いにします。member / delegated admin 問わず、すべての GuardDuty 検出内容 (finding) はセキュリティ用 AWS アカウントの GuardDuty で管理します。 分析 分析関連の要素をハイライトした図 各ログ別にデータベースを、環境別にテーブルを、それぞれ Athena 上に選択します。データベースにあわせて workgroup も分けるようにしました。これは以下効果を狙ってのものになります。 Athena ログ検索結果を活用するにあたり、結果の格納先をログの種類別に分けたかった。これを手間なくやる *4 には workgroup 単位で保存先を指定する必要があった workgroup を定義することで Athena クエリ実行状況を EventBridge によって追跡することができ、後処理をイベント駆動的に実施できるようになる利点があった *5 Athena によるログ分析は週次での定期実行とし、ログ分析用 Athena クエリを名前付きクエリとして整備したうえで Lambda から当該クエリを呼び出すようにしています。 名前付きクエリではなく生のクエリを使うことで EventBridge スケジューラ *6 を使えるメリットがあるのですが、今回は EventBridge イベント(cron 記法によるスケジュール実行)+ Lambda 関数による実行の仕組みを整備しています。 これは定期実行したクエリ内容を適宜改変して詳細なログ調査を行いたい状況も多々あり、この改変に用いるクエリの元ネタを割合簡単に扱えるよう整備するには、名前付きクエリとして管理するのがベターと思われた為です。 結果確認 結果確認関連要素をハイライトした図 ログの内容や分析結果、およびそれらからどういった情報を得たいかによって、Athena クエリ結果の取り扱い方が変わります。弊社ではおおむね以下のような分類ができました。 ログ検索結果としてごく少量の情報が得られ、そこから直ちに ネクス トアクションが決められるもの ログ検索結果から得られる情報が量的にそれなりの規模になってしまうものの、結果を踏まえて ネクス トアクションが決められるもの ログ検索結果から得られる情報が量的にそれなりの規模になってしまい、かつ ネクス トアクションが決めづらいもの 各結果を実際にどう取り扱っているかについては後述しますが、上記3分類において、以下のような取り扱い方を整備することにしました。項番は前項3点にそれぞれ対応します。 検索結果とあわせて対応すべき内容を示した手順とを含めて Slack に流し、通知された結果をみて適宜対処 1. と同様 結果確認用のアプリケーションを整備し、傾向の変化を観察する。異常が見られた場合は都度対処 要するに何をすればよいかが明確なものは通知して対処し、何をすべきか悩むものについては傾向をみるためのお膳立てを整える、といった指針になります。 なお 3. で示した結果確認用のアプリケーションは S3 にコンテンツを置き CloudFront で配信し Cognito で認証認可を行う SPA を用意し、これに Athena クエリ結果を浚わせて可視化するといった体制を組んでいます。このあたりの裏側については拙稿以下を参照ください *7 。 tech.mntsq.co.jp 実際の運用 ひとまずここまでで各種ログを取り扱うまでの仕組みについては解説できました。ここからは得られたものをどう運用しているかについて解説します。主に2つの区分の運用があります。 分析系 Route 53 公開 DNS クエリログ調査 Route 53 リ ゾル バクエリログ調査 VPC フローログ調査 WAF ブロックログ調査 行動系 機微情報を収容する S3 バケット 操作状況調査 Redash 操作状況調査 GuardDuty 検知内容調査 分析系 Route 53 クエリログ / VPC フローログ / WAF ブロックログを所定の方針で Athena によって週次で調査し、その結果を観察したうえで適当なアクションをとります。これら分析系の運用は前掲の ログ検索結果としてごく少量の情報が得られ、そこから直ちに ネクス トアクションが決められるもの ログ検索結果から得られる情報が量的にそれなりの規模になってしまうものの、結果を踏まえて ネクス トアクションが決められるもの ログ検索結果から得られる情報が量的にそれなりの規模になってしまい、かつ ネクス トアクションが決めづらいもの という3分類においては 2. と 3. に該当します。2025年10月現在、各ログはそれぞれ以下のような指針 / 手段でもって分析結果を取り扱うようにしています。 種類 方針 取り扱い方の分類 (前項 1. , 2. , 3. ) 運用方法 Route 53 公開 DNS クエリログ調査 NXDOMAIN ログの傾向観察 *8 3. 集計結果を自前の SPA で捌き観察 Route 53 リ ゾル バクエリログ調査 継続して通信実績のある ドメイン 宛以外に VPC 内からインターネットへ出るリク エス トが無いか確認 2. 未知 ドメイン があれば詳細を確認し、既知 ドメイン はアクセス許可リスト *9 に追加 VPC フローログ調査 VPC 内からインターネットへ出る際のアクセス先に不審なものがないかを AS のレベルで確認 3. 集計結果を自前の SPA で捌き観察 WAF ブロックログ調査 ブロック状況に変動が無いかを傾向観察 *10 3. 集計結果を自前の SPA で捌き観察 Route 53 クエリログについては 拙稿 も参照ください。本稿二度目の参照につきリンクテキストにて失礼します。 また SPA をつかった調査をしている事例は実際の風景をお見せできればよかったのですが、マスクすべき箇所があまりに多く、有益なものを提示できなさそうだったので、泣く泣く省略します。 行動系 ログ調査結果から直ちにアクションがとれるもの(つまり前掲の分類でいう 1. )がここに該当します。なお GuardDuty についてはログ云々の取り扱いではなく、GuardDuty が finding を上げたタイミングがアクションをとるトリガとなります。以下のような指針 / 手段になります。 種類 方針 運用方法 機微情報を収容する S3 バケット 操作状況調査 特定の S3 バケット を対象に CloudTrail にて data event *11 を収集し、この結果として操作が認められた場合に背景などを確認 週次で前週分の操作ログを棚卸しし、操作対象者を特定の上で背景を非同期で確認 *12 Redash 操作状況調査 Redash ログイン時ログおよび Redash クエリ実行ログを収集の上、勤務時間帯ではないタイミングでの操作が無いかを確認 週次で前週分の操作ログを棚卸しし、操作対象者を特定の上で背景を非同期で確認 GuardDuty 検知内容調査 GuardDuty 検知事項を素直に調査する GuardDuty がなにがしかを検知したタイミングで調査タスクとして GitHub issue が作成され、issue ベースで内容を調査 *13 Redash ログに関しては以下をもとにした内容を使い収集 / 運用しています。 tech.mntsq.co.jp おわりに 弊社における AWS 各種ログの集約と活用、およびそれらを踏まえたセキュリティ系改善の取り組みについて解説しました。半年程度でここまでやったのだなと思う向きもありますし、まだまだやれた乃至やれることもあるよなとも感じています。 もちろんここまでに解説した内容については様々な改善余地があり、また現時点でも着手出来ていない施策が数多くあります。たとえば傾向観察が主体となっている取り組みについては掴んだ傾向を踏まえて具体的なアクションをとってゆく必要があり、GuardDuty / CloudTrail 以外にもこの種の用途では有効な AWS のセキュリティ関連サービスがあるはずです。よりよい組み合わせを模索し、今後もセキュリティ方面の最適化に取り組んでゆきたいと考えています。 記事中で扱った取り組みにおいて、特に何らかの制限を課す方向の取り組みはしていない向きに気付かれた方がいらっしゃるかもしれません。これは制約を課すセキュリティを目的とするものではなく、発生している事実を正確かつ適切なタイミングで把握するという方面に重きを置いている面があるためです。予防的統制のための取り組みであると言ってもよいかなと思います。 AWS サービスの各種ログの活用やセキュリティ施策の取っ掛りとして、本稿が何らかのお役に立てば幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : GuardDuty はじめセキュリティ系の AWS サービスは admin / member 間の関係による "finding" の蓄積という考え方をし、ログとは異なるため、別物として扱っています *2 : https://docs.aws.amazon.com/guardduty/latest/ug/gdu-malware-protection-s3.html *3 : https://docs.aws.amazon.com/guardduty/latest/ug/delegated-admin-designate.html に従い設定します *4 : クエリ実行時に結果保存先を指定する手法は https://docs.aws.amazon.com/athena/latest/ug/query-results-specify-location.html によれば AWS マジネジメントコンール上では使えますがそれ以外では使えません。後述のとおり Athena クエリの実行は定期処理とするため、この手法は使えませんでした *5 : https://docs.aws.amazon.com/athena/latest/ug/athena-events.html が詳しいです *6 : https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html *7 : 余談ですが当該記事において「ちょっとした要件」とした内容は本件を指すものでした。伏線(?)回収でした *8 : 存在しない ドメイン へのアクセス試行状況から attack surface を探る傾向に変動がないかを確認する狙いがあります *9 : いままでは Athena 上に Route 53 リ ゾル バクエリログとの突き合わせを目的とした DB を整備し、その中身である CSV ファイルをアクセス許可リストとして運用していましたが、つい最近 Route 53 リ ゾル バ DNS ファイアウォール を使う運用に改めました。 https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-dns-firewall-overview.html が詳しいです。これもいずれ記事にできればと思います *10 : ブロック量が急激に増えてきた場合は攻撃試行が増えてきたという見方ができるので、そのあたりを追跡する狙いがあります *11 : https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html *12 : Slack 等テキストベースで「これこれこういう操作をされたようで、どんな感じです?」と伺うような感じです。 性善説 をとっています *13 : 前述の構成図からこのあたりの要素が漏れていました。GuardDuty イベントを EventBridge でフックし、Lambda 関数を呼び出し、 GitHub の API を呼び出し issue を起票するようにしています。GuardDuty 検知事項対処用の GitHub Project を整備したうえでタスク管理をおこなっています
はじめに スキーマ分離と行分離 目的と結論 目的 結論のサマリ 試験内容 試験環境とツール 負荷の設計 本番環境でのクエリ傾向の分析 QPSの測定 進め方 試験結果 スキーマ分離のボトルネック スキーマ数を固定して負荷をあげてみる 結果まとめ なんとか延命したい はじめに 弊社が採用しているDB設計は、テナントごとに独立した スキーマ を持つ 「 スキーマ 分離」 のデータ構造に基づいています。この アーキテクチャ は、高いデータ分離性とセキュリティを確保できる一方で、 「 スキーマ 数の増加に伴ってパフォーマンスが劣化する」 という性質が指摘されます。 サービスのスケールにおいてこの「性能劣化」が、いつ、どのように顕在化するのかは、設計上の大きな課題でした。この漠然としたリスクを 定量 的に評価し、将来的な「行分離」 アーキテクチャ への移行の是非を判断することを目的に、 負荷試験 を実施しました。 本記事では、この試験で明らかになった、 スキーマ 分離 アーキテクチャ の抱える本質的な ボトルネック を共有させていただきます。 ※ MySQL では"SCHEMA"よりも"DATABASE"という呼び方が一般的ですが、本記事では宗教上の理由により" スキーマ "と表記させていただきます スキーマ 分離と行分離 マルチテナントのデータベース設計において、代表的なデータ分離方式は「 スキーマ 分離」と「行分離」の2つです。弊社が現在採用しているのは「 スキーマ 分離」方式です。 それぞれの分離方式の特性を比較します。 スキーマ 分離・行分離の特性比較 弊社サービスは、元々シングルテナントで運用していた過去があり、 スキーマ 分離を選択しました。しかし、マルチテナントのリアーキテクトが完了し、事業がスケールするフェーズに入ったことにより、 スキーマ 分離の抱えるリスクを無視できなくなってしまいました。 目的と結論 目的 記事冒頭に記載した通り、現在の スキーマ 分離データ構造が、サービスのスケールに伴い、どの段階で性能上の限界を迎えるのかを明確にし、 アーキテクチャ 移行の要否と期限を定めることが目的です。 結論のサマリ 性能劣化の要因は、 クエリの絶対量よりも スキーマ 数の増加にある 弊社ケースでは600テナント数を超えると、 データディクショナリ への メタデータ アクセスへの待機時間が顕著 になり、これが性能劣化の支配的な要因となる 試験内容 試験環境とツール 試験環境の構成は基本的にRDS+EC2のみです。 試験対象のRDS (Aurora MySQL ) 8.0. mysql _aurora.3.08.2 db.r6g.2xlarge 本番環境の平均的なテナントを模したデータ量(169テーブル、7.8GiBくらい)の スキーマ を複製する 負荷をかけるEC2 インスタンス OSは不問 (今回は Ubuntu を使用) スペックはかけたい負荷の関係でc6gn.2xlargeから最終的にc6gn.8xlargeまで上げた また、 負荷試験 には qube という オープンソース のツールを使用しました。このツールは一つの スキーマ にしか負荷をかけられないため、これを複数 スキーマ に対して並列実行し、出力されたレポートを集計する bash スクリプト を、以前記事にしたDevinに作成してもらいました。 集計レポートはこんな感じ { " StartedAt ": " 2025-09-25T07:30:32.531620003Z ", " FinishedAt ": " 2025-09-25T08:42:16.475152837Z ", " ElapsedTime ": 1800 , " ParallelCount ": 24 , " TargetSchemaCount ": 150 , " QueryCount ": 44652323 , " ErrorQueryCount ": 0 , " AvgQPS ": 847.79 , " TotalQPS ": 24806.84 , " Duration ": { " P50 ": " 304.22µs ", " P75 ": " 364.25µs ", " P95 ": " 639.14µs ", " P99 ": " 886.44µs ", " P999 ": " 1345.26µs ", " Avg ": " 343.97µs ", " Max ": " 37678.64000µs ", " Min ": " 141.706µs ", " TotalSamples ": 44652323 } } tech.mntsq.co.jp 負荷の設計 本番環境でのクエリ傾向の分析 MySQL では performance_schema.events_statements_summary_by_digest というテーブルで、クエリを正規化して集計した情報を見ることができます。 (例えば WHERE id = 120 を WHERE id = * として、値部分が異なるクエリも同じクエリとみなします)弊社サービスの場合は、本番環境のクエリの80%以上が、上位11種類のクエリで占められていました。この11種類のクエリ比率を崩さないように、具体的な値を入れて SQL 文を10000個程作成し、qubeで実行可能なjsonlファイルを作成しました。(この作業もDevinで行いました。生成AIの進化に感謝です) なお、 SQL をどの程度用意すべきかはケースバイケースです。今回のケースでは1 スキーマ に数百QPSの負荷を5秒程度かけて次の スキーマ に負荷をかけるということを行うので、10000種類程度用意すれば同じ SQL が何度も流れることはないだろうと判断しました。MySQL8以降はクエリキャッシュの概念がないので、ここまでシビアになる必要はなかったかもしれませんが、キャッシュが効く環境だと、この点も考慮しないと意味のない 負荷試験 になってしまうので注意しましょう。 QPSの測定 クエリ傾向とは別に、本番環境での QPS (Query Per Second) を測定しました。 SHOW GLOBAL STATUS LIKE 'Questions' という SQL で、 MySQL が起動してから受け付けたクエリの総数がわかります。サービスがよく利用される時間帯でこの値の増分を記録し、1秒あたりに直すことでQPSを見積もることができます。時期・曜日など、サービスの利用のされ方が異なる複数の日の結果を平均するのが好ましいでしょう。 弊社サービスでは約3200QPS程度でした。 進め方 スキーマ 数に比例して負荷を増やしていき、目標QPSを達成できない点を見極めます。 スキーマ 数: 150 → 300 → 600 → 1200と増やしていき、限界点が見えたらその間の設定も追加で検証する QPS: 3,200 → 6,400 → 12,800 → 25,600と増やしていく( スキーマ 数=テナント数にQPSが比例するという厳しめの条件) 試験ツールは、指定の数だけスレッドを立ち上げ、各スレッドは指定したローテーションの時間だけ スキーマ に負荷をかけ、終わったら次の スキーマ に負荷をかけに行きます。ローテーションごとにレポートを保存し、全実行が終わり次第全てのレポートを集計した統合レポートを出力します。今回の試験では、 スキーマ ごとに5秒程度の負荷をかけてローテーションし、全体で30分ほど測定を行いました。 # 4並列の場合 Thread1: schema_1 → schema_5 → …. Thread2: schema_2 → schema_6 → …. Thread3: schema_3 → schema_7 → …. Thread4: schema_4 → schema_8 → …. 試験結果 スキーマ 分離の ボトルネック 弊社のケースだと、600 スキーマ を超えたあたりで急激な性能劣化が見られました。縦軸が対数目盛りである点に注意してください(遅延は1目盛り増えると10倍になる) スキーマ 数の増加 対 クエリ遅延のグラフ 800 スキーマ 以上では目標QPSまで負荷を上げることができなかったので、600 スキーマ 時点の負荷設定(クライアント側で24並列, 合計12000QPSをかける設定)で固定してデータを取りました。クライアント側の設定を固定しても、達成できるQPSは800テナント以降では顕著に低下していきました。 また、1200 スキーマ の負荷をかけていた時間帯のパフォーマンス インサイト を見て、遅延の原因を考察してみます。簡単に見方を説明すると、灰色の破線がCPUのキャパシティで、これを超えているとよろしくない状態であると言えます。問題なく稼働しているDBでは、破線よりも下のラインに収まっているはずです。 1200 スキーマ 負荷試験 時の待機時間の内訳 このグラフを見ると、dict_sys_mutexやparser_mutexなどの待機時間が支配的になっていることがわかります。これは、MySQL8.0以降では、 データディクショナリ テーブルが mysql . ibd という単一の InnoDB テーブルスペースに保存される仕様に起因していることが考えられます。 dict_sys_mutexは、 データディクショナリ へのアクセスが競合状態となった際に発生する待機イベントで、例えばオープンテーブルキャッシュに載ってないテーブルにアクセスする場合などに データディクショナリ へのアクセスが必要になります。テーブル数の増加に伴い、この際に競合が発生しやすくなります。 parser_mutexは、クエリのパースの際にテーブル・カラムなどの情報を データディクショナリ から取得する際の競合イベントで、やはりこちらもテーブル数の増加に伴い顕著になります。 スキーマ 数を固定して負荷をあげてみる データディクショナリ への メタデータ アクセスが性能劣化の原因になるならば、 スキーマ 数(= メタデータ のサイズ)を固定すれば、より大きな負荷を捌けるはずです。 スキーマ 数を600に固定し、当初予定していた1200 スキーマ 相当の負荷(24000QPS)を捌けるかの測定も行ってみました。 結果は以下のとおりです。今度は縦軸は線形目盛りです。 負荷(QPS) 対 クエリ遅延のグラフ 24000QPSの 負荷試験 実施時の待機時間の内訳 当初予定していた1200テナント想定の負荷である24000QPSも余裕で達成できました。また データディクショナリ へのアクセス待機時間は目立たなくなり、CPU待機時間が支配的な、健全なものであることが確認できます。 スキーマ 数(= メタデータ のサイズ)を固定すれば、より大きな負荷を捌けるはずという仮説は正しそうです。 結果まとめ 以上のことから、弊社のケースだと600テナント程度の収容が限界点であることが確認でき、 負荷試験 の目的を達成できました。また、MySQL8.0以降の スキーマ 分離のデータ構造は、 スキーマ 数(テナント)の増加が性能の ボトルネック になるという定性的な事実を数値的に理解することができました。 例えば、 スキーマ 数が数百程度までしか増加しない、 スキーマ あたりのテーブル数が多くない、などの場合は、「 スキーマ 分離・行分離の特性比較」の表で示したメリットを享受するために、 スキーマ 分離のデータ構造を選択するのもありなのかもしれません。しかし、 スキーマ 数が大きくスケールすることが予想されるサービスでは、行分離のデータ構造を採用することが無難と言えるでしょう。 なんとか延命したい とはいえ、 スキーマ 分離で作ってしまったものを行分離に作り直すのはかなり骨が折れる作業になります。弊社でも長期的にはリアーキテクトが必要という認識にはなりましたが、中期的な事業計画・ 工数 の観点から、なんとか延命措置を図れないかという議論が生まれました。 そこで、 今回の試験結果を AWS のソリューションアーキテクト(SA)の方に共有をしたところ、以下のようなアド バイス を頂けました! table_open_cache, table_definition_cache の調整: オープンテーブルキャッシュのサイズを増やすことで、 データディクショナリ の参照頻度および競合発生頻度を抑えられるかもしれない innodb _sync_array_size の調整: 待機中のスレッドの数が多いワークロードの同時実行性が高まるので、競合の待機時間が短くなるかもしれない インスタンス タイプやストレージタイプの変更: r6g → r7g,r8gにすること、ストレージ設定をAurora I/O-Optimized に変更することなどで、パフォーマンスの向上が見込める 根本的な解決にはなりませんが、これらのチューニングを行うことで、現在よりも アーキテクチャ 移行のデッドラインが後ろにずれる可能性があります。これらの調整を行ってみて、どのような結果が得られたかについては、また次回報告させていただきます! MNTSQ株式会社 SRE 西室 ↓次回 tech.mntsq.co.jp