TECH PLAY

MNTSQ

MNTSQ の技術ブログ

98

こんにちは。 「すべての合意をフェアにする」MNTSQの森山です。 この度、MNTSQでリードエンジニアを務めることになりました。 リードエンジニアは、チームの出力を最大化するためにあらゆる角度からデリバリーを支えます。責任を持つのは「コードの品質」だけではなく、「チームとして成果を出すこと」です。そのために必要な技術的・組織的な取り組みをリードしていきます。 エンジニアの役割は、会社やチームによって定義が少しずつ異なります。そこで今回は、 MNTSQにおけるリードエンジニアとは何か を、自分自身の整理も兼ねて 言語化 してみました。 なぜリードエンジニアという役割が生まれたのか? これまで弊社には「リードエンジニア」という役割は存在しませんでした。 近年、AIの進化によって開発の生産性を高める新しい手段が次々と生まれています。やりたいこと、試したいことが増える一方で、単にエンジニアの人数を増やすだけでは期待通りに成果が伸びないという課題も見えてきました。エンジニアの人数が正しくデリバリーに寄与するためにはデリバリーにフォーカスする役割が必要だという考えからリードエンジニアという役割が新たに設けられました。 いままでは、テッ クリード が技術的な意思決定とともにデリバリーの責任も担い、負担が大きい状態にありました。今後は、テッ クリード は アーキテクチャ レベルで技術的な責務を担い、リードエンジニアがデリバリーの責務を担います。そうすることでテッ クリード はより技術的な意思決定にフォーカスできるようになります。 リードエンジニアは担当領域のデリバリー及びテッ クリード と相談の上でそれに伴う技術的な意思決定の責務を負います。相談は担当領域の難易度やリードエンジニア自身のスキルによって範囲が変わります。テッ クリード はリードエンジニアの責任範囲を適宜広げていくことも役割の一つであり、リードエンジニアはその範囲を広げることを目標とします。こうしてリードエンジニアを軸とした組織拡大が、より大きな成果につながることを目指しています。 またリードエンジニアは若手ソフトウェアエンジニアが次のステップへ進むためのキャリアの登竜門としても位置づけられています。内部登用も積極的に推進し、エンジニアが成長し続けられる環境を整えるという狙いもあります。 役割の具体像 例えば以下のような役割を担います。 アーキテクチャ ・技術の選定や エス カレーション 内部品質の担保 開発フローの整備 リソースの割り当て 技術タスクのコスト・納期の 言語化 ・共有 …etc 技術的な意思決定については、チーム内で完結できるものはメンバーを巻き込みながら主体的に結論を導きます。一方で、システム全体への影響が大きい判断についてはテッ クリード へ エス カレーションし、意思決定に必要な情報を提供します。 また、継続的な開発速度を維持するためには、内部品質への意識も欠かせません。将来的な ボトルネック を防ぐことも必要です。例えば「この負債を解消することでA機能の開発コストを○人日削減できる」といった形で、技術的負債の解消に対する 費用対効果を明確にしてPdMやデザイナーと共有する ことも積極的に提案します。 さらに、チーム全体の生産性を長期的な目線で高めるために、誰がどのタスクを担当するとスムーズに開発が進むか等も見極め、チームのリソースを配置します。 姿勢と マインドセット リードエンジニアとして大切にしたい姿勢や マインドセット についても整理しました。考えていたことを的確に 言語化 されていた こちらの記事 を一部、参考にしています。 ❌️ 最も優秀なプレイヤーであるべき ⭕️ 全体を俯瞰し、サポートに徹する リードエンジニアは、誰よりも優秀で、誰よりも多くのチケットを消化できることが必須ではありません。チーム作業の停滞を招く ボトルネック を解消し、将来的なリスクを先回りしてケアすることで、チーム全体の出力を最大化することに責任を持ちます。 ❌️ 難しいタスクを自分が担当するべき ⭕️ 難しいタスクもメンバーに任せる 技術調査や重めの機能実装、不確定要素の多いタスクこそメンバーを信頼して任せます。 リードエンジニア自身は、軽微な修正や方針が見えているバグチケットを消化しながら、リソースに余白を残しておきます。その余白を活かして突発的な ボトルネック の解消やリスクケアに対応し、チーム全体の出力向上に貢献します。 (もちろん全任せではなく、必要に応じて自らも難しいタスクを担います。) ❌️ コーディングは最小限にして管理に専念する ⭕️ コーディング・レビューも欠かさずやる 技術的な ボトルネック やプロセス上の問題を把握するためには、技術スキルとコード理解が不可欠です。またリードエンジニアは組織的にもフラットな立場であるため、 権威ではなく影響力をもってリーダーシップを発揮 することが求められます。 ❌️ チーム内の技術的な決定を一手に引き受ける ⭕️ チームで最善の決定ができるように情報整理、提案する 自らが結論を下して共有するのではなく、メンバーを巻き込みながら意思決定のプロセスを支えます。その過程と結論の双方にチームがオーナーシップを持てるよう導くことが、リードエンジニアの重要な役割です。 まとめ リードエンジニアは、時に自ら手を動かし、時に協力を仰ぎながら、 デリバリーを維持・向上させるために課題を見つけ、解決する存在 です。 今後、この役割を担う人数こそが、会社全体のデリバリー速度を左右する重要な役割になると考えています。 ただし、エンジニアだけでの開発を高速化することは容易ではありません。MNTSQでは、デザイナーやPdMなど様々な役割のメンバーと協力しながら、長期的なデリバリーの維持・向上を実現しています。 MNTSQの仕事にご興味を持たれた方は、ぜひ 採用情報のページ をご覧ください。
バックエンドエンジニアの河久保です 2日間にわたる Kaigi on Rails 2025 お疲れ様でした 今回の会場が東京駅 丸の内南口 から徒歩1分で着く会場だったので、中央線(一番丸の内寄りにホームがある)ユーザーの私としてはものすごくアプローチが良くて最高でした 次回は渋谷(神泉寄り)とのことで、 井の頭線 使うかなぁーとか考えながら帰途に就いてました 今回の聴講スタイル 今回の Kaigi on Rails では聴講したすべてのセッションを スマートフォン で録音し、終わり次第 Notebook LM に音声データを渡すということを実践してみました 日英のセッション問わず文字起こし精度も良く、音声まとめ、レポート作成といったもの良い体験が得られました 以下の スクリーンショット は『 Sidekiq その前に:Webアプリケーションにおける非同期ジョブ設計原則 / morihirok | Kaigi on Rails 2025 』の音声データをソースとしてテキストレポートを作成したものになります 「Sidekiq その前に:Webアプリケーションにおける非同期ジョブ設計原則」の音声データを流し込んだもの セッション中も PC のタイピングなどに気が削がれることがなかったので、今後のカンファレンスもこのスタイルで臨もうと思えるものでした 聴講したセッションの紹介 さて、ここからは本編の感想になります 聴講したセッションからいくつか所感交えて取り上げます kaigionrails.org 他のセッションでも言及されていたのですが、今年の Kaigi on Rails は「非同期ジョブ」に関するセッションが多かったです こちらのセッションは非同期ジョブのバックエンドを Delayed から Rails 8 から導入された Solid Queue に移行するというものでした ここで紹介されていた障害事例( トランザクション 内で perform_later)は弊社でも踏んでいたので、「わかる〜〜」と頷きながら聞いていました 弊プロダクトのコードを見てみると以下のような module でケアされていました module TransactionAwareClient extend ActiveSupport :: Concern include AfterCommitEverywhere included do around_enqueue do |_, block| after_commit do block.call end end end end 今は Rails 7.2 移行に update されているので enqueue_after_transaction_commit オプションに切り替え済みです フレームワーク の進化に感謝です kaigionrails.org Rails 8.0 で本体から提供されるようになった認証プロセスの generator で生成されるコードを使って、 Rails がどんな処理を行っているかをステップを追って解説する内容でした ブルートフォース アタックへのケアとして、 Rails 7.2 でサポートされた controller の rate limit を利用したり、わざとハッシュ生成を遅らせる機構が入れていたりするということでした またタイミング攻撃への考慮もされているよという紹介もありました セッションは User と 1 : 多関係の Session というモデルで DB 管理で 理由は セッションハイジャック 時にログインセッションを削除する際に redis データを全件調べる必要があり遅くなるからとのこと セッション情報は ActiveSupport ::CurrentAttributes を継承した Current というモデルでスレッドローカルな グローバル変数 として格納するという実装になっている パスワードリセットの挙動についても丁寧に説明いただきました これだけみても devise にお任せしたくなる気持ちはとてもわかりますが、 Web アプリケーションを開発する身としては知っておくべき内容なので、普段触っている フレームワーク のコードでレクチャーいただけるとても良い内容でした kaigionrails.org セッション終了して即座に社の Slack に投稿してしまうくらい印象に残ったセッションでした セッション終了後にさっそく社内投稿したところ module による table prefix を連動させた分離は packwerk を用いない形でコードとDB スキーマ への責務を見た目で分かるようになるので、導入してみたい気持ちが沸々と湧いてきました 運用面の事例紹介も大変学びが多かったです バッチ処理 のリスト化 目的や起動タイミング・頻度にとどまらず、何か異常ステータスになった際の リカバリ 緊急度、リトライ可否といった内容が記載することを MUST として、属人化を防ぐ取り組みをされていました エラート ラッキング 弊社も Datadog によるエラート ラッキング をやっていますが、エラーの軽重含めて通知量が多かったり、あまり理解できていない ドメイン の通知が飛んで来たりして取りこぼしがあったりします これに対してはオーナーシップを持たせた旗振り役のがんばりで通知チャンネルの正常化させたというエピソードが紹介されていました Runbook 障害対応やデータメンテといった本番環境の作業手順書の作成だけにとどまらず、 Runbook の利用回数、作業時間なども記録していました これにより複数回実施されていたり、対応時間のかかるものに対して運用の見直しや恒久対応に向けた 定量 指標に基づいた トリアージ が行われるということでした さいごに 9/30 に社内でのプチ参加報告会の機会をいただき、発表スライドを使って私の方から口頭にていくつかのセッションを取り上げて紹介させていただきました 早くみんなで同時視聴して 感想戦 したいので、 アーカイブ が公開されることを待ち望んでいます
はじめに 弊社では Entra ID ユーザ / グループを使い AWS 利用時の認証や権限制御を IAM Identity Center を使い実現しています。Entra ID と IAM Identity Center を SCIM で連携させることで Entra ID 側の情報を用いて達成しており、このあたりは 拙稿 に詳細があります。 IAM Identity Center は自身が ID プロバイダ(以下 IdP と書きます)になることもでき、この場合 SAML / OAuth2 で外部アプリケーションとやりとりすることが可能です。このあたりも 拙稿 として存在します。 今回、社内のちょっとした要件で SPA(single page application の SPA です。本稿題名含め以下でも同様)を作成する事情があり、これに対しアクセスが可能なメンバを社内でも絞っておきたい需要があったので、このあたりの制御を IAM Identity Center で行わせるようにしてみました。ただし SPA に直接 IAM Identity Center とやりとりさせるのは骨が折れるので、何らかの仲立ちが欲しいところです。そこで Cognito を使い、以下を実現しました。 SPA へのアクセス時に Cognito user pool + IAM Identity Center による認証を行い、アクセスしたユーザが SPA を使う上で妥当な権限を持っているか認証 この時の「認証」の材料には Entra ID ユーザ / グループを使う SPA 用にユーザが払い出されるわけではなく、既に Entra ID 上に有るユーザを使用した SSO という認証体験になる Cognito identity pool + IAM ロールによって SPA が AWS 上で実行可能なアクションを制御 これにより、SPA 動作にあたり 認証の為のバックエンドを特に設ける事なく 、また IAM Identity Center を IdP とすることで Entra ID 側には触れずに Entra ID ユーザ / グループを用いた認証が可能 という構成をとることができました。このあたりを tips として紹介できればと思います。 構成 はじめに今回整備した諸々の全容を示します。 SPA の中身には立ち入りません。 AWS の適当なサービスを触る必要がある(= IAM クレデンシャルが必要)ものと理解ください。 SPA 自体はシンプルで、S3 バケット に配置したものを CloudFront で配信しているのみになります。CloudFront を経由しないアクセスを防ぐよう origin access control による制限 *1 を加えています。 認証に関しては Cognito の user pool *2 に依拠しています。以下要領です。 user pool の IdP として IAM Identity Center を指定 SPA からは Cognito を隠蔽せず、アクセスされた際に Cognito が用意するログインページへリダイレクトし、Cognito での認証が完了したのちに SPA へ戻ってくるように構成 Cognito による認証(= IdP での認証)が成功しアクセス トーク ンが返ってきた場合は SPA は正規のコンテンツを、失敗した場合はアクセス拒否時のコンテンツを返却 認証が完了したのち、得られた トーク ンと identity pool *3 とを使い、クレデンシャルを得、SPA は得られたクレデンシャルをもとに AWS API を叩きます。このとき identity pool には SPA に執り行わせたい IAM アクションを許可した IAM ロールを authenticated role として設定しておき、払い出されたクレデンシャルの効力範囲を適当に制限しておく構想としました。 ポイント 上述した内容、特に Cognito の user pool / identity pool はドキュメントに従った素直な使い方につき、特段の補足は必要無いと思われます。一方で Cognito と IAM Identity Center とを連携させる方面については少々難儀しました。この周辺について説明します。 IAM Identity Center を Cognito の IdP としてどのように設定するか Cognito の user pool では特定のベンダに依存しない IdP 連携方法として SAML か OpenID Connect が選択できます *4 。IAM Identity Center ではこの種の連携を行う際にアプリケーションを用意し設定するのですが *5 、ここでの選択肢には SAML か OAuth 2.0 かのみです *6 。 共通要素としては SAML になり、もちろんこれでうまくいくので、 Cognito で使う IdP として IAM Identity Center を使う場合は SAML で連携させればよい と承知しておいて頂ければ、本稿の内容は8割カバーできます。 アプリケーションの作成に関しては AWS 公式ドキュメントを読むのが手っ取り早いです。以下が該当します。 docs.aws.amazon.com アプリケーション作成後に設定する各種設定に関しては以下が詳しいです。 repost.aws そもそも「認証」をどのようにするか ちょっと大袈裟な節名で自分でも困惑していますが、要は「Cognito が IAM Identity Center の結果を認証結果とするなら IAM Identity Center は何をもって認証するの?」ということです。 これは単純に今回用意した IAM Identity Center アプリケーションに紐付けされているユーザ / グループであれば OK という整理にしてあります。つまりは Redash の SSO ログインに関する拙稿 の整理と同様に これには IAM Identity Center 内でアプリケーションというものを用意し、アクセスさせたいユーザ / グループをアプリケーションに紐付けることで達成 という方針としました。Cognito から IAM Identity Center に処理が遷移した際、 SAML 連携に使用している IAM Identity Center アプリケーションに紐付けされているユーザであればアプリケーションはそのまま処理を通してくれるので、Cognito 側では特に何も考えず、連携だけを気にしておけばよくなり、話が単純になります。 繰り返しになりますが、弊社は IAM Identity Center を Entra ID と連携させ、IAM Identity Center で使えるユーザ / グループは Entra ID のそれを引き継いでいます。 よって Entra ID 側の情報を前提に「認証」に必要な条件を構成することで、IAM Identity Center でもそれを引き継いで構成することが可能なようになっています。この際 Entra ID には一切触れないで済ませることが可能です。 コード例 おまたせしました。コード例を示します。 今回は Terraform コードに加え、 あくまで参考として SPA コードのうち Cognito を取り扱う箇所を抜粋で掲載します。SPA は Vue.js を使い作っており *7 。記法のお作法(主に 環境変数 周辺)は Vite のそれに従ったものになります。 Terraform 前述の アクセスさせたいユーザ / グループをアプリケーションに紐付ける の方針をグループ単位で実施させる前提のコードです IAM Identity Center 側 こちらをクリックして参照のこと locals { entra_id_groups = { spa = [ "test" # SPA 利用を許可したい Entra ID グループ名を指定 ] } } /* IAM Identity Center インスタンスに対し必要な設定が行われる これは Terraform リソースでの管理が難しいので、設定は AWS マネジメントコンソール上から行い、Terraform からは参照に留める */ data "aws_ssoadmin_instances" "main" {} resource "aws_ssoadmin_application" "spa" { name = "SPA" description = "SAML application for SPA" 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" } } } data "aws_identitystore_group" "spa" { for_each = toset (local.entra_id_groups.spa) identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] alternate_identifier { unique_attribute { attribute_path = "DisplayName" attribute_value = each.key } } } resource "aws_ssoadmin_application_assignment" "spa" { for_each = toset (local.entra_id_groups.spa) application_arn = aws_ssoadmin_application.spa.arn principal_id = data.aws_identitystore_group.spa [ each.key ] .group_id principal_type = "GROUP" } Cognito ほか SPA 側で必要とする側 こちらをクリックして参照のこと data "aws_s3_object" "saml_metadata" { /* コード管理していない。手でアップロードすること IAM Identity Center アプリケーションの "IAM Identity Center SAML metadata file" から取得する */ bucket = aws_s3_bucket.static_files.id key = "saml-metadata/spa.xml" } resource "aws_iam_saml_provider" "spa" { name = "spa" saml_metadata_document = data.aws_s3_object.saml_metadata.body } resource "aws_cognito_identity_pool" "spa" { identity_pool_name = "spa" allow_unauthenticated_identities = false # 認証されていないIDを無効化 saml_provider_arns = [ aws_iam_saml_provider.spa.arn ] cognito_identity_providers { client_id = aws_cognito_user_pool_client.spa.id provider_name = "cognito-idp.$ { data.aws_region.current.region } .amazonaws.com/$ { aws_cognito_user_pool.spa.id } " server_side_token_check = false } } data "aws_iam_policy_document" "assume_from_cognito" { statement { effect = "Allow" principals { type = "Federated" identifiers = [ "cognito-identity.amazonaws.com" ] } actions = [ "sts:AssumeRoleWithWebIdentity" ] condition { test = "StringEquals" variable = "cognito-identity.amazonaws.com:aud" values = [ aws_cognito_identity_pool.spa.id, ] } condition { test = "ForAnyValue:StringLike" variable = "cognito-identity.amazonaws.com:amr" values = [ "authenticated" ] } } } /* Cognito Identity Pool と連携して一時認証情報を取得するための IAM ロール このロールを Cognito Identity Pool の Authenticated Role として設定 */ resource "aws_iam_role" "allow_actions_for_spa" { name = "allow-actions-for-spa" assume_role_policy = data.aws_iam_policy_document.assume_from_cognito.json } data "aws_iam_policy_document" "allow_actions_for_spa" { statement { # 略 } } # データバケットへの読み取り専用ポリシーをアタッチ resource "aws_iam_role_policy" "allow_actions_for_spa" { name = "allow-actions-for-spa" role = aws_iam_role.allow_actions_for_spa.id policy = data.aws_iam_policy_document.allow_actions_for_spa.json } # 認証されたユーザーにデフォルトロールを割り当てる resource "aws_cognito_identity_pool_roles_attachment" "spa" { identity_pool_id = aws_cognito_identity_pool.spa.id roles = { "authenticated" = aws_iam_role.allowed_actions_for_spa.arn } } resource "aws_cognito_user_pool" "spa" { name = "spa" } resource "aws_cognito_user_pool_domain" "spa" { user_pool_id = aws_cognito_user_pool.spa.id domain = "test" # 適宜変更する } # これは SPA かどうかに関係なく使えるはずなので "main" に改めておく resource "aws_cognito_identity_provider" "main" { user_pool_id = aws_cognito_user_pool.spa.id provider_name = "iam-identity-center" provider_type = "SAML" provider_details = { "MetadataFile" = data.aws_s3_object.saml_metadata.body } attribute_mapping = { "email" = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" } lifecycle { ignore_changes = [ provider_details, # MetadataFile を解釈して設定される値が延々差分を生じさせるので ignore する ] } } # Cognito User Pool のクライアント(SPAからログインするための設定) resource "aws_cognito_user_pool_client" "spa" { name = "SPA" user_pool_id = aws_cognito_user_pool.spa.id explicit_auth_flows = [ "ALLOW_USER_AUTH" , "ALLOW_CUSTOM_AUTH" , "ALLOW_USER_SRP_AUTH" , "ALLOW_REFRESH_TOKEN_AUTH" , ] allowed_oauth_flows_user_pool_client = true allowed_oauth_scopes = [ "openid" , "email" , ] allowed_oauth_flows = [ "code" ] supported_identity_providers = [ aws_cognito_identity_provider.main.provider_name, ] token_validity_units { access_token = "minutes" id_token = "minutes" refresh_token = "days" } callback_urls = [ "https://$ { var.spa_domain } /path/to/app" ] # 適宜変更する default_redirect_uri = "https://$ { var.spa_domain } /path/to/app" # 適宜変更する } resource "local_file" "spa" { content = <<EOT VITE_AWS_REGION=$ { data.aws_region.current.region } VITE_USER_POOL_ID=$ { aws_cognito_user_pool.spa.id } VITE_USER_POOL_CLIENT_ID=$ { aws_cognito_user_pool_client.spa.id } VITE_IDENTITY_POOL_ID=$ { aws_cognito_identity_pool.spa.id } VITE_COGNITO_DOMAIN=$ { aws_cognito_user_pool_domain.spa.id } .auth.$ { data.aws_region.current.region } .amazoncognito.com VITE_REDIRECT_URI=https://$ { var.spa_domain } /path/to/spa EOT filename = "./path/to/spa/.env" # 適宜変更する file_permission = "0644" } おまけとして S3 および CloudFront に関するコードも掲載しておきます こちらをクリックして参照のこと resource "aws_s3_bucket" "spa" { bucket = "test" # 適宜変更する } # CloudFront からのアクセスを許可する S3 バケットポリシーを定義 data "aws_iam_policy_document" "spa" { statement { effect = "Allow" principals { type = "Service" identifiers = [ "cloudfront.amazonaws.com" , ] } actions = [ "s3:GetObject" , ] resources = [ "$ { aws_s3_bucket.spa.arn } /*" , ] condition { test = "StringEquals" variable = "AWS:SourceArn" values = [ aws_cloudfront_distribution.spa.arn, ] } } } /* OAC (Origin Access Control) を使った CloudFront 経由のアクセスを許可するバケットポリシー このポリシーにより、S3バケットへの直接アクセスが拒否される */ resource "aws_s3_bucket_policy" "spa" { bucket = aws_s3_bucket.spa.id policy = data.aws_iam_policy_document.spa.json } resource "aws_cloudfront_origin_access_control" "spa" { name = "oac-for-spa" description = "OAC for SPA S3 bucket" origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } data "aws_cloudfront_cache_policy" "caching_optimized" { name = "Managed-CachingOptimized" } data "aws_cloudfront_cache_policy" "caching_disabled" { name = "Managed-CachingDisabled" } resource "aws_acm_certificate" "spa" { provider = aws.us-east-1 domain_name = "*.test.example" # 適宜変更する validation_method = "DNS" lifecycle { create_before_destroy = true } } resource "aws_acm_certificate_validation" "spa" { provider = aws.us-east-1 certificate_arn = aws_acm_certificate.spa.arn validation_record_fqdns = [ for record in aws_route53_record.spa_cert_validation : record.fqdn ] } resource "aws_cloudfront_distribution" "spa" { enabled = true tags = { Name = "spa" } aliases = [ var.spa_domain, ] origin { domain_name = aws_s3_bucket.spa.bucket_regional_domain_name origin_id = aws_s3_bucket.spa.id origin_access_control_id = aws_cloudfront_origin_access_control.spa.id } default_cache_behavior { allowed_methods = [ "GET" , "HEAD" , "OPTIONS" ] cached_methods = [ "GET" , "HEAD" ] target_origin_id = aws_s3_bucket.spa.id viewer_protocol_policy = "redirect-to-https" # 今回の SPA では Cookie やヘッダーを考慮しないので転送もしない cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id function_association { event_type = "viewer-request" function_arn = aws_cloudfront_function.spa.arn } } # JavaScriptファイルを処理 ordered_cache_behavior { path_pattern = "path/to/app/assets/*.js" allowed_methods = [ "GET" , "HEAD" , "OPTIONS" ] cached_methods = [ "GET" , "HEAD" ] target_origin_id = aws_s3_bucket.spa.id viewer_protocol_policy = "redirect-to-https" cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id } # SPA の HTML ファイルや画像、その他アセットを処理 ordered_cache_behavior { path_pattern = "path/to/app/*" allowed_methods = [ "GET" , "HEAD" , "OPTIONS" ] cached_methods = [ "GET" , "HEAD" ] target_origin_id = aws_s3_bucket.spa.id viewer_protocol_policy = "redirect-to-https" cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { acm_certificate_arn = aws_acm_certificate.spa.arn ssl_support_method = "sni-only" } } # SPAのパスを書き換える CloudFront Function resource "aws_cloudfront_function" "spa" { name = "spa-router" runtime = "cloudfront-js-1.0" comment = "Rewrites SPA paths to index.html" publish = true code = <<EOT function handler(event) { var request = event.request; var uri = request.uri; // ファイル拡張子を持つパスはスキップする(例: .js, .css, .png) if (uri. split ('.'). pop () !== uri) { return request; } // リクエストパスが特定のSPAプレフィックスで始まるかチェック if (uri.startsW ith ('/path/to/app')) { request . uri = '/path/to/app/index.html'; } return request; } EOT } SPA コード 認証部分 const CLIENT_ID = import . meta . env . VITE_USER_POOL_CLIENT_ID ; const COGNITO_DOMAIN = import . meta . env . VITE_COGNITO_DOMAIN ; # Cognito の user pool に対し設定できるドメイン(hosted UI アクセス時のドメインとして見える)の ID。環境変数経由で渡す const REDIRECT_URI = import . meta . env . VITE_REDIRECT_URI ; # SPA の URL。Cognito で認証が成功した場合に戻ってくるため必要。環境変数経由で渡す export function redirectToHostedUI () { const url = new URL ( `https:// ${ COGNITO_DOMAIN } /login` ) ; url . searchParams . set ( "client_id" , CLIENT_ID ) ; url . searchParams . set ( "response_type" , "code" ) ; url . searchParams . set ( "scope" , "openid email" ) ; url . searchParams . set ( "redirect_uri" , REDIRECT_URI ) ; window . location . href = url . toString () ; } export async function handleCognitoRedirect () { const params = new URLSearchParams ( window . location . search ) ; const code = params . get ( "code" ) ; if ( ! code ) return sessionStorage . getItem ( "id_token" ) ; // すでに保持している場合 const body = new URLSearchParams ({ grant_type : "authorization_code" , client_id : CLIENT_ID , redirect_uri : REDIRECT_URI , code , }) ; const tokenResp = await fetch ( `https:// ${ COGNITO_DOMAIN } /oauth2/token` , { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : body . toString () , }) ; if ( ! tokenResp . ok ) throw new Error ( "Failed to fetch tokens" ) ; const tokens = await tokenResp . json () ; sessionStorage . setItem ( "id_token" , tokens . id_token ) ; return tokens . id_token ; } クレデンシャル取得部分 import { S3Client } from "@aws-sdk/client-s3" ; # S3 を触る場合の例として記載 import { CognitoIdentityClient , GetIdCommand , GetCredentialsForIdentityCommand } from "@aws-sdk/client-cognito-identity" ; const REGION = import . meta . env . VITE_AWS_REGION ; # AWS リージョン。Cognito リソースが存在する場所。環境変数経由で渡す const USER_POOL_ID = import . meta . env . VITE_USER_POOL_ID ; const IDENTITY_POOL_ID = import . meta . env . VITE_IDENTITY_POOL_ID ; export async function getS3Client () { const idToken = localStorage . getItem ( "id_token" ) ; if ( ! idToken ) throw new Error ( "User not authenticated" ) ; const client = new CognitoIdentityClient ({ region : REGION }) ; const identityResp = await client . send ( new GetIdCommand ({ IdentityPoolId : IDENTITY_POOL_ID , Logins : { [ `cognito-idp. ${ REGION } .amazonaws.com/ ${ USER_POOL_ID } ` ] : idToken , } , }) ) ; const credResp = await client . send ( new GetCredentialsForIdentityCommand ({ IdentityId : identityResp . IdentityId , Logins : { [ `cognito-idp. ${ REGION } .amazonaws.com/ ${ USER_POOL_ID } ` ] : idToken , } , }) ) ; return new S3Client ({ region : REGION , credentials : { accessKeyId : credResp . Credentials . AccessKeyId , secretAccessKey : credResp . Credentials . SecretKey , sessionToken : credResp . Credentials . SessionToken , expiration : credResp . Credentials . Expiration , } , }) ; } おわりに Cognito から IAM Identity Center を IdP として参照し、これを使用して SPA へのアクセス制御を実施する例について解説しました。 S3 + CloudFront で配信する諸々のアクセス制御として真っ先に浮かんでしまうのが私的にはアクセス元 IP アドレスによる境界制限( AWS WAF v2 などを使う想定)なのですが、IAM Identity Center に依拠したユーザ認証という選択肢が今回割合気軽に使え、なかなか現代的なアクセス制御ができるようになったと思っています。 セキュリティ的な方面から考えても 境界防御ではなく認証による制限ができる 既存の IdP の構成を使用して認証の設計ができ、ID 運用の分散が防げる 既に IdP 上に存在するユーザを使っての SSO という挙動になり利用者の体験をあまり損わない といったメリットがあり、見通しのよい構成となりました。 IAM Identity Center / Cognito l活用の一助として本稿が参考になれば幸いです。 MNTSQ 株式会社 SRE 秋本 参考 IAM Identity Center と Cognito を統合してみた 先行事例です *1 : https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html *2 : https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools.html *3 : https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-identity.html *4 : https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-identity-federation.html?icmpid=docs_cognito_console_help_panel *5 : https://docs.aws.amazon.com/singlesignon/latest/userguide/manage-your-applications.html *6 : https://docs.aws.amazon.com/singlesignon/latest/userguide/customermanagedapps-saml2-oauth2.html *7 : 特に選定に理由はなく、とりあえず動くものとサクッと作りたかった程度です
はじめに 弊社では BI ツールとして Redash を EC2 上でセルフホストするかたちで利用しています。CS や Sales 等の部門で日々の指標を追うのに使われるだけでなく、開発や運用のためのアラーティングにも活用されています。 今回この Redash へ SSO ログインの仕組みを導入し、Redash 利用者の操作履歴を 部分的に 監査できるようにしました。SSO の IdP としては掲題通り IAM Identity Center を選定しています。これら対応について記録します。 なぜ IAM Identity Center を IdP に? 去る2025年5月下旬に以下記事を公開しました。 この中で ユーザから見た場合に各 AWS アカウントへのアクセスが透過的になるのも IAM Identity Center 導入での嬉しさのひとつですが、IAM Identity Center 自体がアイデンティプロバイダ (IdP) としての利用も可能であるという点から、SRE 主体での他のサービスへの SSO 化の試みについても着手し易くなってきました。今後は社内で独自のユーザ管理体系が存在する諸々のサービスを着実に SSO 化し、利用者の利便性向上やセキュアな体制構築に繋げてゆこうと計画しています。 という一節があります。弊社では Redash を SRE と CRE とが連携して運用しており、SSO 化の取り組みは SRE の責任範囲にて実施することとしました。弊社 CRE については以下もぜひご参照ください。 note.mntsq.co.jp さて前掲の IAM Identity Center に関する記事は Entra ID を IdP として IAM Identity Center から AWS への SSO ログインを可能にするという趣旨になります。つまり IAM Identity Center が抱え込んでいるユーザ / グループの元ネタは Entra ID となります。 ここで IAM Identity Center を IdP として Redash へ SSO するよう構成することで、 Entra ID 側での対応をすることなく Entra ID ユーザ / グループを活用して Redash へのログインが可能になります。 これは Redash を利用する立場にしてみると Entra ID ユーザを使って Redash が使えるという体験につながり、弊社が普段の業務で使う他の SaaS と同様の利用体験が得られる格好になります。サービス利用の煩雑さが低減できるという嬉しさがあるでしょう。 構成 SSO ログイン化前後での構成変遷を示します。なお Redash のためのインフラは EC2 + ALB で構成され、Redash そのものは EC2 インスタンス 内で docker-compose によって動かしています。公式ドキュメントに沿った方法 *1 といえるでしょう。 SSO ログイン前 至極シンプルです。パスワードマネージャ(弊社では全社的に Keeper *2 を使用しています)上の Redash ユーザ名およびパスワードを必要に応じて利用しログインする方式です。 SSO ログイン後 相応に複雑になりました。Redash 以外にも登場人物が増えています。 IAM Identity Center に Entra ID から連携されたユーザを使い、Redash へログインします Redash が出力するログを EC2 から CloudWatch Logs に出力し、そこから Data Firehose を経由し、最終的には S3 に配置します IAM Identity Center 経由での Redash へのログイン履歴を CloudTrail ログ(これも最終的には S3 に配置されます)から追跡します S3 上に配置された各種ログは必要に応じて Athena から検索が可能です SSO 設定 おおむね以下のような順番での作業が必要になります: IAM Identity Center アプリケーションの用意 IAM Identity Center アプリケーションからの必要な情報の取得と設定 Redash 上での SAML 設定 IAM Identity Center アプリケーションの用意 前述のとおり、IAM Identity Center は外部 IdP を引き受けて AWS アカウントへのアクセスを可能とする利用形態以外に、自身が IdP となり AWS サービスや外部サービスへのアクセスを担うこともできます。 これには IAM Identity Center 内でアプリケーションというものを用意し、アクセスさせたいユーザ / グループをアプリケーションに紐付けることで達成可能です *3 。 AWS サービスと連携させる場合とそれ以外とでそれぞれ対応が異なり、今回のように Redash が対象の場合は利用者自身が管理する (customer managed) アプリケーションで構成するとよいでしょう *4 。 Redash 公式ドキュメントのうち SSO に関するもの *5 を読むと、今回使うべきは SAML であることがわかります。したがって IAM Identity Center も SAML を前提としたアプリケーションを用意します。 サクッと Terraform で作ってしまいましょう。コード例は以下の通りです。 こちらを参照 locals { entra_id_groups = { /* Redash へのアクセスを許可したいユーザが属する Entra ID グループ名を指定 Entra ID グループ / ユーザが IAM Identity Center へ SCIM で連携などされていることを前提にする */ redash = [ "Engineer" , "CS" , "Sales" , ... ] } } /* IAM Identity Center インスタンスに対し必要な設定が行われる これは Terraform リソースでの管理が難しいので、設定は AWS マネジメントコンソール上から行い、Terraform からは参照に留める */ data "aws_ssoadmin_instances" "main" {} # IAM Identity Center アプリケーション定義。SAML を前提とするもの resource "aws_ssoadmin_application" "redash" { name = "Redash" description = "IAM Identity Center appliction to access Redash" 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" } } } # IAM Identity Center アプリケーションの利用を許可する Entra ID グループ(が IAM Identity Center に同期されたもの)を参照できるようにする data "aws_identitystore_group" "redash" { for_each = toset (local.entra_id_groups.redash) identity_store_id = tolist (data.aws_ssoadmin_instances.main.identity_store_ids) [ 0 ] alternate_identifier { unique_attribute { attribute_path = "DisplayName" attribute_value = each.key } } } # アプリケーション / Entra ID グループ間の紐付けをする resource "aws_ssoadmin_application_assignment" "redash" { for_each = toset (local.entra_id_groups.redash) application_arn = aws_ssoadmin_application.redash.arn principal_id = data.aws_identitystore_group.redash [ each.key ] .group_id principal_type = "GROUP" } IAM Identity Center アプリケーションからの必要な情報の取得と設定 ここまでで IAM Identity Center アプリケーションの用意はできました。しかし残念ながら SAML 設定そのものは Terraform では設定できません 。公式ドキュメントを参照しつつ、適宜設定する必要があります。以下を見るとよいでしょう。 docs.aws.amazon.com 明示的な設定が必要になるのは Application metadata における以下内容です。必要な値は Redash 公式ドキュメント *6 をもとに設定します。 なお Application metadata と同じ画面にある IAM Identity Center metadata はその内容を控えておきます。Redash 側の設定で使います。IAM Identity Center としてはこのあたりの設定をまとめた XML ファイルの取得が可能なのですが、生憎 Redash にはこの XML ファイルを読み取れる仕組みがありません。よって逐次設定を行う必要があります。 重要:attribute mapping の設定 ここまで済めば一段落……と思いきや、前述の AWS 公式ドキュメントには以下の一節があります。 However, you must also provide additional SAML attribute mappings for your own SAML 2.0 applications. These mappings enable IAM Identity Center to populate the SAML 2.0 assertion correctly for your application. You can provide this additional SAML attribute mapping when you set up the application for the first time. You can also provide SAML 2.0 attribute mappings on the application details page in the IAM Identity Center console. このあたりも Terraform では直接の設定が不可能です。 AWS マネジメントコンソールから設定してしまうのが手っ取り早いでしょう。 設定した IAM Identity Center アプリケーションの詳細画面から Actions -> Edit attribute mappings と辿り、以下の値を設定します。 User attribute in the application Maps to this string value or user attribute in IAM Identity Center Format Subject(変更不可) ${user:email} emailAddress FirstName ${user:givenName} basic LastName ${user:familyName} basic Redash 上での SAML 設定 基本的には Redash の公式ドキュメントに従えば設定が済みます *7 。Admin グループに属する Redash ユーザでないと設定画面が出ないので注意が必要です。実際に設定が必要な内容は以下の通りです。 SAML Enabled :チェックをいれる SAML Metadata URL :IAM Identity Center 側 IAM Identity Center metadata 内のうち “IAM Identity Center SAML metadata file” で示される URL を記入 SAML Entity ID :こだわりがなければ Redash IAM Identity Center 側 Application metadata 内 “Application SAML audience” と一致していればよい SAML NameID Format : urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress 固定値 ログ監査 ここまでで Redash へ IAM Identity Center を IdP として SSO アクセスする手筈が整いました。折角 IAM Identity Center を経由しての操作となるので、CloudTrail あたりから操作ログを棚卸したくなるのが人情というものでしょう。このあたりの整備をします。 ただし本稿冒頭で Redash 利用者の操作履歴を 部分的に 監査できるようにしました と記載した通り、Redash へのログインからログアウトまでの全操作をユーザにひもづくかたちで追跡できるような状況には出来ていません。ユーザを絞れる状態で追えるログは以下までとなります。 IAM Identity Center (CloudTrail) ログで追跡可 Redash にログインした Redash ログを駆使して追跡可 クエリを実行した(実行したクエリまで追跡化) 以下は上記ログでは追跡不可な様子でした。無論ログ自体はあるのですが、ユーザにひもづく形でのログは存在せず、他の情報と突き合わせて追跡する必要があります。 クエリ実行結果を見た ダッシュ ボードを見た 追跡可能な範囲でログをどのように追えるかを示せればと思います。 IAM Identity Center (CloudTrail) ログから Redash ログイン形跡を追う いきなり結論です。以下のような Athena クエリが使えます。直近7日間のログを追跡する例です select userIdentity.onBehalfOf.userId as userId, json_extract_scalar(serviceEventDetails, ' $["app-id"] ' ) as appId, eventtime from <CloudTrail ログ用 Athena DB> where timestamp between date_format( current_date - interval ' 7 ' day, ' %Y/%m/%d ' ) and date_format( current_date , ' %Y/%m/%d ' ) and eventSource = ' sso.amazonaws.com ' and eventName = ' Federate ' and json_extract_scalar(serviceEventDetails, ' $["app-id"] ' ) = ' <Redash への SAML ログインで使う IAM Identity Center アプリケーション ID> ' 日本語で記載してある箇所について解説します。 CloudTrail ログ用 Athena DB :S3 上にある CloudTrail ログを取り扱う為の Athena DB。 パーティション 射影を使う AWS 公式ドキュメントに沿った内容を前提とする *8 Redash への SAML ログインで使う IAM Identity Center アプリケーション ID :IAM Identity Center アプリケーション に対応する インスタンス の ID 2. については解説が必要でしょう。ここで指定すべき ID は ARN に含まれる ID (ex. api-xxx ) ではなく、 URL 中に示される ID (ex. ins-xxx ) のほうです。伏字が多い例で恐縮ですが、以下 スクリーンショット 中の赤枠の箇所を使って始めて上記クエリで所望のログが得られます。 このあたりについては AWS 公式のドキュメントを含めても公開されている情報がなく、弊社を担当頂いております AWS 社の SA の方々に多大なるご協力を頂き実現したものとなります。この場を借りて感謝申し上げます。なおこの内容は今後変化する可能性が多分にある模様で、その際にログの取り方を変えられるようにしておく必要があります。 弊社では上記 Athena クエリを名前付きクエリとして整備したうえで専用の workgroup を用意し、適当な期間でのログ検索とその結果のレポーティングの仕組みを運用するようにしています。これは今回の Redash ログ以外でも同様の仕組みでレポーティングを行っているものがあるので、それらをまとめて別の機会にブログ化できればと思います。少しだけネタばらしをすると以下のような仕組みです。 Lambda で Athena クエリを実行 Athena がクエリの実行を終える Athena workgroup にひもづくかたちでクエリ実行完了イベントが EventBridge から発火する 3. のイベントを拾う Lambda 関数が Athena クエリ実行結果を取得し、必要な処理を実行 最終的な結果を Lambda -> SNS -> AWS Chatbot -> Slack という流れで通知 Redash ログからクエリ実行形跡を追う 弊社のように EC2 インスタンス 上で Redash をセルフホストする場合、当然ながらそのログは EC2 インスタンス 上に存在することになります。 これではログを継続的に追う場合に都合が悪いので、EC2 上からいったん CloudWatch Logs ロググループにログを出し、そのうえで Data Firehose を経由し S3 にログを出力し、Athena で追跡が可能なようにしています。 EC2 から CloudWatch Logs へログを出すにはいくつか方法がありますが、幸いなことに弊社での Redash の動かし方は docker-compose によるのものなため、以下を利用できます。 docs.docker.com これを使い logging : driver : awslogs options : awslogs-region : ap-northeast-1 awslogs-group : <CloudWatch ロググループ名> awslogs-create-group : "false" といった記述を Redash コンテナ管理用の docker-compose.yml へ追記し、EC2 インスタンス プロファイルへ CloudWatch Logs 関係の必要なアクションを許可してやれば、EC2 から CloudWatch Logs へのログ出力については解決します。具体的には以下アクションが認可できていれば OK です。 logs:CreateLogStream logs:PutLogEvents Redash の典型的なクエリ実行ログは Redash サーバにおける [2025-08-08 02:11:13,060][PID:31][INFO][root] Inserting job for 915b2acd6d435dfd3ea3578a457f4ded with metadata={'Username': u'foo.bar@example.com', 'Query ID': u'1234'} なので、これだけが Athena で取り扱えればひとまずは充分という指針のもと、以下のような仕組みを入れています。 Redash サーバが CloudWatch Logs へログを全て出力する CloudWatch Logs は log subscription filter を介して Data Firehose へログを送出する この際に必要なログだけを Data Firehose へ送出するようフィルタ設定を行う Data Firehose は最終目的地の S3 バケット へログを送出する Terraform コードとしては以下のようになります。EC2 から送られてくるログを一旦集める CloudWatch Logs ロググループの用意から S3 バケット までを担当しています。 こちらを参照 data "aws_caller_identity" "current" {} /* Redash ログ保管用 EC2 内で docker-compose にて動かしているコンテナ群からログを飛ばす想定 */ resource "aws_cloudwatch_log_group" "redash_log" { name = "/$ { var.env } -redash" retention_in_days = 7 # ほとんど実用に堪えない内容なので長く保持しておく必要なし } resource "aws_cloudwatch_log_subscription_filter" "redash_log" { name = "$ { var.env } -redash-log-to-data-firehose" log_group_name = aws_cloudwatch_log_group.redash_log.name /* クエリ実行ログでしか Redash 操作者を絞れない このログは "Inserting job" を含むものになるので、こいつだけを S3 へ送出するログとする */ filter_pattern = "Inserting job" destination_arn = aws_kinesis_firehose_delivery_stream.redash_log.arn role_arn = aws_iam_role.redash_log_to_data_firehose.arn } # Redash ログを Athena で取り扱えるように S3 へ保存したい。そのためのバケット resource "aws_s3_bucket" "redash_log" { bucket = "$ { var.env } -redash-log" } # 半年程度はログを確保できておくようにする。監査目的であればもっと長く取っておくべきかも resource "aws_s3_bucket_lifecycle_configuration" "redash_log" { bucket = aws_s3_bucket.redash_log.id rule { status = "Enabled" id = "delete after 180 days" expiration { days = 180 } filter { prefix = "" } } } resource "aws_s3_bucket_versioning" "redash_log" { bucket = aws_s3_bucket.redash_log.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "redash_log" { bucket = aws_s3_bucket.redash_log.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_server_side_encryption_configuration" "redash_log" { bucket = aws_s3_bucket.redash_log.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } data "aws_iam_policy_document" "redash_log_bucket_policy" { statement { effect = "Allow" actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.redash_log.arn } /*" ] principals { type = "Service" identifiers = [ "logging.s3.amazonaws.com" ] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [ data.aws_caller_identity.current.account_id ] } } } resource "aws_s3_bucket_policy" "redash_log" { bucket = aws_s3_bucket.redash_log.bucket policy = data.aws_iam_policy_document.redash_log_bucket_policy.json } /* CloudWatch Logs から S3 へログを保存するための Data Firehose リソース CW Logs 側 subscription filter を経由して流れてくるデータを扱う */ resource "aws_kinesis_firehose_delivery_stream" "redash_log" { name = "$ { var.env } -redash-log" destination = "extended_s3" extended_s3_configuration { role_arn = aws_iam_role.redash_log_to_s3.arn bucket_arn = aws_s3_bucket.redash_log.arn buffering_size = 64 # MB 単位。dynamic partitioning が有効の場合必須 dynamic_partitioning_configuration { enabled = "true" } processing_configuration { enabled = "true" processors { type = "MetadataExtraction" parameters { parameter_name = "JsonParsingEngine" parameter_value = "JQ-1.6" } parameters { parameter_name = "MetadataExtractionQuery" parameter_value = "{prefix: {dummy: (\"logs\")} | .dummy}" # 実質的に "logs" という固定文字列を返すだけ } } processors { type = "Decompression" parameters { parameter_name = "CompressionFormat" parameter_value = "GZIP" } } processors { type = "AppendDelimiterToRecord" } } prefix = "!{partitionKeyFromQuery:prefix}/$ { data.aws_caller_identity.current.account_id } /!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/" error_output_prefix = "error/!{firehose:error-output-type}/$ { data.aws_caller_identity.current.account_id } /!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/" cloudwatch_logging_options { enabled = true log_group_name = aws_cloudwatch_log_group.redash_log.name log_stream_name = "S3Delivery" } } } Athena テーブル / ビューの例は以下のようになります。Redash ログは少々やんちゃな内容となる場合があり、冗長な表記になっています。 CREATE TABLE 文 CREATE EXTERNAL TABLE " redash_server_log_source " ( messageType string, owner string, logGroup string, logStream string, subscriptionFilters array<string>, logEvents array<struct<id:string, timestamp :bigint, message:string>> ) PARTITIONED BY ( ` timestamp ` string ) ROW FORMAT SERDE ' org.openx.data.jsonserde.JsonSerDe ' LOCATION ' s3://mntsq-ops-redash-log/logs/465191204183/ ' TBLPROPERTIES ( ' projection.enabled ' = ' true ' , ' projection.timestamp.type ' = ' date ' , ' projection.timestamp.format ' = ' yyyy/MM/dd ' , ' projection.timestamp.range ' = ' 2025/01/01,NOW ' , ' projection.timestamp.interval ' = ' 1 ' , ' projection.timestamp.interval.unit ' = ' DAYS ' , ' storage.location.template ' = ' s3://mntsq-ops-redash-log/logs/465191204183/${timestamp}/ ' ); CREATE VIEW 文 CREATE OR REPLACE VIEW redash_server_log AS WITH unnested_logs AS ( SELECT " day " , l.id AS event_id, from_unixtime(l. " timestamp " / 1000 ) AS timestamp , l.message FROM " redash_server_log_source " CROSS JOIN UNNEST(logEvents) AS t (l) ), parsed_logs AS ( SELECT *, -- TRY関数を追加 TRY(regexp_extract(message, ' ([ -:,0-9]+) ' , 1 )) AS log_timestamp, TRY(regexp_extract(message, ' \[PID:([0-9]+)\] ' , 1 )) AS pid, TRY(regexp_extract(message, ' \[([A-Z]+)\] ' , 1 )) AS log_level, TRY(regexp_extract(message, ' Inserting job for ([a-f0-9]+) ' , 1 )) AS job_id, TRY(regexp_extract(message, ''' Username '' : u '' ([^ '' ]+) ''' , 1 )) AS username, TRY(regexp_extract(message, ''' Query ID '' : u '' ([^ '' ]+) ''' , 1 )) AS query_id FROM unnested_logs ) SELECT " day " , event_id, timestamp , log_timestamp, pid, log_level, job_id, username, query_id, message AS original_message FROM parsed_logs; なおテーブル以外にビューを作成しているのは Data Firehose 内でログの整形などをせず S3 に送出しているために本来欲しいログが message エントリ内に押し込められている点の対策によります。これは拙稿の以下と同様です。 tech.mntsq.co.jp おわりに IAM Identity Center を IdP として SSO アクセスするケースについて、Redash を題材に解説しました。Redash への SSO アクセスに必要な情報よりもログ関連のオペレーションに関する内容のほうが多いように思い、本稿題名をミスったのではないかと今にして思ってしまいました。 SSO アクセスすることでの ID 管理手法の整頓やサービス利便性向上もさることながら、利用対象とは別の箇所でログが得られることでの状況把握が出来るようになるというメリットも、外部 IdP を使うことでのメリットといえるでしょう。本稿で述べたように CloudTrail ログが使える場合であってもその探索には難儀するケースがありますが、手を尽くせば追跡は可能という事実に勇気付けられる面もあり、また実務上も有益な点はあると感じました。 Redash をお使いの方で IdP の選定に悩まれている方の一助になれば幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : https://redash.io/help/open-source/setup/#-Docker *2 : https://www.keepersecurity.com/ja_JP/solutions/enterprise-password-management.html *3 : https://docs.aws.amazon.com/singlesignon/latest/userguide/manage-your-applications.html *4 : https://docs.aws.amazon.com/singlesignon/latest/userguide/customermanagedapps.html *5 : https://redash.io/help/user-guide/users/authentication-options/ *6 : https://redash.io/help/user-guide/users/authentication-options/#Self-Hosted-SAML *7 : https://redash.io/help/user-guide/users/authentication-options/#SAML-2-0 *8 : https://docs.aws.amazon.com/athena/latest/ug/create-cloudtrail-table-partition-projection.html
SREチームの藤原です。 サービスを運用しているとデータ分析や他システムとのデータ連携などで、DBへの読み取り専用アクセスが必要になる場合は多々あると思います。 本エントリでは、 AWS PrivateLink を活用することで、セキュアなクロスアカウントかつ、クロス VPC なアクセスを実現できるのでその方法を解説します。 aws.amazon.com 構成のイメージ 本エントリでは、2つのアカウントの間でのクロスアカウント & クロス VPC アクセスを前提とします。 アカウントAがアクセスする側、アカウントBがアクセスされる側とします。 PrivateLink設定前のアカウントBは図1のような構成です。 図1. アカウントB(アクセスされる側)の構成 この環境に対して、他の AWS アカウント(この図ではアカウントA)の VPC からアクセスしたいケースを想定しています。 具体的には図2のような構成を想定しています。 図2. アカウントAからアカウントBのRDSへのアクセスイメージ ここではアカウントAからアカウントBへの具体的なアクセス方法については示していません。 PrivateLinkを使った接続をする前に VPC ピアリングや、Transit Gateway などのサービスを使った VPC 間接続と今回の要件における課題について述べたのち、PrivateLinkを使った接続方法について解説します。 VPC ピアリングやTransit Gateway を使った VPC 間接続とその課題 クロスアカウント、クロス VPC でのリソースアクセスを考えると、昔から AWS を使っている人であれば、 VPC ピアリング( VPC ピア機能)と AWS Transit Gateway の活用が浮かぶと思います。 docs.aws.amazon.com aws.amazon.com 今回これでも要件は達成できるのですが、注意点が2つほど存在します。 ひとつ目が VPC ネットワークのCIDRが重複している場合は VPC ピアリングもTransit Gateway も使えません。 もうひとつがセキュリティ的な課題です。 VPC ピアリングやTransit Gateway での接続はIP層でネットワークを直接繋げるような形になります。 このような場合、意図しないリソースへのアクセスを防ぐためには、セキュリティグループなどの設定を細かく設定、管理しなければいけません。 今回の事例では、CIDRの重複は発生していませんが、意図しないリソースへのアクセスの可能性を排除する目的で、PrivateLinkを活用することとしました。 PrivateLinkを使ったセキュアな VPC 間接続 PrivateLinkを活用した場合の VPC 間の接続は次の図3のようになります。 VPC エンドポイントを介してアカウントA、Bそれぞれの VPC 間を接続し、 Aurora MySQL のリーダーエンドポイントのみへの到達性を確保 します。 図3. PrivateLinkを使った接続 以降では、まずアカウントB側の準備を行い、その後でアカウントA側の準備を進めていきます。 なお、 Terraformでの定義例を示していますが、アカウントAとBのリソースはそれぞれ異なるstateファイルで管理していることを前提としています 。 アカウントB(アクセスされる側)でのリソースの準備 まず、アカウントB側でResource Gateway とResource Configurationsを作成します(図4)。 図4. Resource ConfigurationとResource Gateway AWS VPC Latticeにおいて、Resource Gateway に対応するTerraformのリソースは aws_vpclattice_resource_gateway です。 このリソースはTerraformを利用する場合、次のように定義します。 resource "aws_vpclattice_resource_gateway" "gateway" { name = Resource Gatewaysの名前 vpc_id = Resource Gatewayを設置するVPCのID subnet_ids = [ Resource Gatewayを設置するサブネットIDのリスト ] security_group_ids = [ Resource Gatewayに設定するセキュリティグループIDのリスト ] } Resource Configurationに対応するTerraformのリソースは aws_vpclattice_resource_configuration です。 resource "aws_vpclattice_resource_configuration" "configuration" { name = Resource Configurationの名前 resource_gateway_identifier = aws_vpclattice_resource_gateway.gateway.id port_ranges = [ "3306" ] protocol = "TCP" resource_configuration_definition { dns_resource { domain_name = Aurora MySQLのリーダーエンドポイントのFQDN ip_address_type = "IPV4" } } } resource_gateway_identifier パラメータで接続先となるResource Gateway のIDを指定します。 resource_configuration_definition の dns_resource ブロックで接続先となるリソースの ドメイン を指定します。 Aurora MySQL のリーダーエンドポイントの FQDN をここで指定することにより、 PrivateLinkの接続先をリーダーエンドポイントのみに限定できます 。 次に、アカウントAから VPC エンドポイントを通じて接続できるようにする必要があります。 このために AWS Resource Access Manager(以降、 AWS RAM)を利用して、 Resource ConfigurationをアカウントAに共有します 。 aws.amazon.com RAMで共有するリソースをTerraformで定義する場合は、 aws_ram_resource_share 、 aws_ram_principal_association 、 aws_ram_resource_association の3つのリソースを利用します。 resource "aws_ram_resource_share" "resource_configuration_share" { name = リソース共有の名前 } resource "aws_ram_principal_association" "resource_configuration_share" { resource_share_arn = aws_ram_resource_share.resource_configuration_share.arn principal = リソースの共有先となるAWSアカウントID(アカウントAのアカウントID) } resource "aws_ram_resource_association" "resource_configuration_share" { resource_arn = aws_vpclattice_resource_configuration.configuration.arn resource_share_arn = aws_ram_resource_share.resource_configuration_share.arn } aws_ram_resource_share はリソース共有そのものを定義します。 aws_ram_principal_association では、リソースを共有する先のアカウントなどを指定します。 今回はアカウントAとリソースを共有したいので、 aws_ram_principal_association にはアカウントAの AWS アカウントIDを指定します。 最後に aws_ram_resource_association では、具体的に aws_ram_resource_share で共有したい AWS リソースを指定します。 上記の例では、Resource Configurationを共有対象としています。 これにより、アカウントB側のResource ConfigurationがアカウントA側で利用する準備ができました。 アカウントA(アクセスする側)でのリソースの準備 アカウントAでは、アカウントBから AWS RAMを通じて共有されたResource Configurationを受け入れる必要があります。 AWS 管理コンソールの AWS RAM画面からリソース共有を受け入れます 1 。 図3. PrivateLinkを使った接続(再掲) アカウントA側では、Resource Configurationに紐づいた VPC エンドポイントを定義する必要があります。 まず、 VPC エンドポイントに紐づける共有リソースの情報を取得するため、 aws_ram_resource_share のDataソースを使用します。 data "aws_ram_resource_share" "rds_resource_config" { name = リソース共有の名前  # アカウントBで共有されたリソースの名前 resource_owner = "OTHER-ACCOUNTS"   # 他アカウントから共有されたIPアドレス } name でリソース共有の名前を指定、 resource_owner にOTHER-ACCOUNTSを指定することで、アカウントBから共有された AWS リソース(今回の場合はResource Configuration)を取得できます。 次に、取得した aws _ram_resource_shareのリソースARNを VPC エンドポイントに関連付けます。 VPC エンドポイントに対応するリソースは aws_vpc_endpoint です。 resource "aws_vpc_endpoint" "rds_endpoint" { vpc_endpoint_type = "Resource" vpc_id = VPCエンドポイントを作成するVPCのID subnet_ids = [ VPCエンドポイントを作成するVPCのプライベートサブネットIDのリスト ] resource_configuration_arn = data.aws_ram_resource_share.rds_resource_config.resource_arns [ 0 ] } これにより、アカウントAのプライベートサブネットに VPC エンドポイントが作成され、サブネットに応じたプライベート IPアドレス が割り当てられます。 この状態でも機能しますし、アカウントBのAurora MySQL にアクセスできますが、 IPアドレス ベースよりもホスト名ベースでアクセスする方が管理しやすいため、 IPアドレス と FQDN を関連付けましょう。 Route53のプライベートホストゾーンを準備し、そのプライベートホストゾーン内でAレコードを定義します。 aws_vpc_endpoint.rds_endpoint の subnet_configuration から VPC エンドポイントの IPアドレス を取得できるので、Terraformの 局所変数 ( rds_endpoint_ip_addresses )を使って IPアドレス のリストを取得します。 locals { rds_endpoint_ip_addresses = sort ( [ for address in aws_vpc_endpoint.rds_endpoint.subnet_configuration : address.ipv4 ] ) } resource "aws_route53_record" "production_rds_endpoint_dns" { zone_id = プライベートホストゾーンのID name = VPCエンドポイントに紐づけたいFQDN type = "A" ttl = 60 records = local.rds_endpoint_ip_addresses } これにより、アカウントAのプライベートサブネットからアカウントBのAurora MySQL のリーダーエンドポイントへセキュアにアクセスできるようになりました。実際の接続コマンドは以下のような形になります。 mysql -u ユーザー名 -p -h VPCエンドポイントのFQDN これにより、アカウント横断かつ VPC 横断でAurora MySQL のリーダーエンドポイントへのセキュアなアクセスを実現できました。最終的な構成のイメージとしては図3のとおりになります。 まとめ クロスアカウント、クロス VPC でのリソースアクセスに際してPrivateLinkの活用する場合の例について解説しました。 VPC ピアリングや、Transit Gateway を使う方法もありますが、ネットワーク観点やセキュリティ観点での考慮事項(ネットワークのCIDRや、セキュリティグループなど)を減らせるため、今後はPrivateLinkを第一候補として考えても良いでしょう。 aws_ram_resource_share_accepter リソースを定義して受け入れることも可能ですが、一時的に必要なリソースであるため、 AWS 管理コンソールから手動で受け入れています) ↩
SREチームマネージャーの藤原です。 本エントリでは、現在構築中の新サービスにて利用する予定の CloudFrontおよび VPC Originの活用と、CloudFrontを経由した静的リソース配信について解説します。 シンプルな構成へのCloudFrontの導入 まずは非常にシンプルなアプリケーションを考えてみます(図1)。 単一のバックエンドのコンピューティングリソースに外部向けの API と内部向けの API がある場合、インターネットからアクセス可能な外部向けの ロードバランサー と、内部からのみアクセス可能な内部向けの ロードバランサー を分けて設定するパターンがあります。 図1. シンプルな構成の ウェブアプリケーション このような構成の場合、静的リソース(画像やjs, css など)はバックエンドのコンピューティングリソースから配信することになります。 静的リソースはコンピューティングリソースを消費しない形で配信したい ところです。 そこで、 CDN とオブジェクトストレージを活用します。 AWS の場合、CloudFrontとS3を利用することになります。この場合、インターネットと外部向けアプリケーション ロードバランサー の間にCloudFrontの ディストリビューション を配置、静的リソース配信用のS3 バケット を作成することになります(図2)。 図2. 単純にCloudFront ディストリビューション と静的リソース配信用 バケット を導入 CloudFrontと静的リソース配信用 バケット の間については、OAC(Origin Access Control)を利用することでセキュアに接続できます 1 。 図2の構成では静的リソースを CDN を用いてコンピューティングリソースを使用することなく効率的に配布することができます。 さらに、外部向けアプリケーション ロードバランサー からの動的リソース配信と同じ ドメイン 名を使って静的リソースを配信できるため、CORS 2 などを意識する必要もありません。 ただし、この構成では、CloudFront ディストリビューション と外部向けアプリケーション ロードバランサー の間に課題が埋まっています。 外部向けアプリケーション ロードバランサー の抱える課題 外部向けアプリケーション ロードバランサー はパブリック IPアドレス を持っています。 また、 AWS マネージドな FQDN も持つため、CloudFrontを経由せずに直接アクセスが可能です(図3)。 図3. インターネットから外部向けアプリケーション ロードバランサー への直アクセス(破線部) 一定以上の利用があるサービスを運用したことのある方にはわかると思いますが、 ロードバランサー の IPアドレス や AWS マネージドな FQDN を直接指定したHTTPリク エス トは、想像以上に多く発生します 3 。 ロードバランサー への直接アクセスをブロックしつつ、CloudFrontを経由したアクセスを通したい場合、次のような対策が基本として考えられます。 CloudFrontでHTTPヘッダを追加し、ロードバランサのリスナールールで特定のヘッダがある場合のみリク エス トを フォワ ードする 4 CloudFrontの AWS マネージド プレフィックス リストを ロードバランサー のセキュリティグループに適用する 5 それでも、外部向けアプリケーション ロードバランサー にはパブリックな IPアドレス (や AWS マネージドな FQDN )が割り当てられたままです。そこで「パブリック IPアドレス も FQDN も持たない ロードバランサー をCloudFrontのオリジンとして使えないだろうか」と考えたくなります。この考えを実現してくれるのがCloudFrontの VPC Originです。 CloudFrontの VPC Origin CloudFrontの VPC Origin機能を利用すると、プライベートサブネット内にENI(Elastic Network Interface)が作成されます。このENIを経由して、CloudFront ディストリビューション とアプリケーション ロードバランサー 間の通信が行われます(図4)。 図4. VPC Originの利用によるENIの設置 VPC Originを利用することで、CloudFront ディストリビューション のオリジンを、パブリックサブネットに配置された外部向け ロードバランサー ではなく、プライベートサブネットに配置された ロードバランサー (以前の図における内部向け ロードバランサー )とすることができます。 プライベートサブネットに配置されたアプリケーション ロードバランサー は内部向けのため、パブリック IPアドレス を持ちません。 これにより、CloudFront ディストリビューション を経由しないインターネットからの直接アクセスを完全に排除できます 。 VPC Originを導入した後の構成 VPC Originを導入した後の構成としては図5のようになりました。 図5. VPC Origin導入後の構成 図1と図5を比較すると、以下の機能的な違いがあります。 静的リソースを配信用 バケット で提供することで、コンピューティングリソースを消費せずに配布できるようになった 図2と図5を比較すると、主な差分は次の2点です。 アプリケーション ロードバランサー へのインターネットからの直接アクセス経路が完全に排除された 外部 ロードバランサー と内部 ロードバランサー が一本化され、内部 ロードバランサー 相当のもののみとなった 単純な構成の差分としてはここまでです。運用時も見据えると以下の観点でメリデメが生じます。 メリット アプリケーション ロードバランサー へのインターネットからの直アクセスを考える必要がない(セキュリティ的なメリット) インターネット - ロードバランサー 間の経路そのものがなくなった 設定管理対象の削減(構成管理上のメリット) アプリケーション ロードバランサー が2台から1台になった デメリット アプリケーション ロードバランサー の設定が複雑化する可能性(構成管理上顕在化する可能性のあるもの) 外部向けと内部向けのリスナールール両方を一つのリスナー設定に含める必要がある アプリケーション ロードバランサー のリスナールールは外部向けと内部向けの両方を単一の場所にまとめて記述する必要があるため、複雑なルールを設定する場合は注意が必要です。 一方で、設定が一箇所にまとまることで「あれ、これはどのロードバランサに含まれていたっけ?」といった混乱が起きにくくなるため、リスナー設定がシンプルな場合はデメリットは最小化できます。 今回設計したサービスではリスナールールに複雑な要素がほとんどなかったため、外部向けと内部向けの ロードバランサー を一つに統合した際のデメリットの顕在化はほぼなく、直接アクセス経路の排除というメリットのみを実質的に得ることができました。 まとめ 本エントリでは、CloudFrontの VPC Originの利用による構成への影響、CloudFrontを使った静的リソースの配信について触れました。 特にCloudFrontの VPC Originを利用することでパブリックサブネットの利用を最小化することはセキュリティ観点でポジティブに捉えて良いでしょう。 今後はCloudFront + VPC Origin + ロードバランサー の組み合わせが AWS の構成の デファクトスタンダード となる可能性もあるかもしれないとも感じました。 また、静的リソース配信についてはCloudFront + S3の組み合わせが大量アクセスを捌くといった観点でも非常に重要なので設定しない手はないと言えるでしょう。 Amazon CloudFront オリジンアクセスコントロール(OAC)のご紹介 ↩ オリジン間リソース共有 (CORS) - HTTP | MDN ↩ これは一種の攻撃に類するものではあるので、何かしらの対策はした方が良いでしょう。前職にて対策において参考となりそうな情報を提示しています。 WAFを活用する上で入れておきたいファイアウォールのルール定義 - STORES Product Blog ↩ Application Load Balancer へのアクセスを制限する - Amazon CloudFront ↩ Amazon CloudFront用のAWS マネージドプレフィックスリストを使用したオリジンへのアクセス制限 | Amazon Web Services ブログ ↩
はじめに こんにちは、MNTSQ株式会社でSREをやっている西室と申します。私生活ではゲーム以外でPCを使わないので、最新技術へのアンテナ感度はエンジニアとしては最低クラスです。未だに タッチタイピング ができません。 さて、最近巷では「生成AIがすごい」だの「使えないと時代に取り残される」だの、何かと話題が尽きないですが、まだ業務にうまく活用できていないという方も多いのではないでしょうか? かくいう私も「なんか調べるのが億劫だな〜」と、ChatGPT以外には手を出していなかったのですが、半年ほど前に開発チームに Devin が導入されたので試しに使ってみたところ、これがなんともう 世界が一変するくらい便利 でした......。この感動を共有するために、本記事にて活用事例を紹介してみようと思います。 繰り返しますが、私はAIに関する専門知識もありませんし、技術が好きというわけでもありません。 生成AIを便利に活用するだけなら高度な知識は必要ありません 。 はじめに その前にDevinとは 活用事例紹介 仕組みを作らせる系 GuardDutyのアラートをGitHubにIssueとして起票するLambdaを実装してもらった ECSタスク定義の差分をPRにコメントするGitHub Actionsの実装してもらった コード整頓系 デプロイWFの実装シンメトリを揃えてもらった ハードコードされていたオートスケールのパラメータを変数化してもらった 壁打ち系 使い慣れていないリポジトリに大きな変更を行う際の調査・設計を手伝ってもらった まとめ Devinを活用するには おわりに その前にDevinとは いったいなんなんでしょう。私にはよくわかりません。半年前に会社の人が導入してくれました。詳しいことはChatGPTとかに聞いてください。他にもClaude Codeとかも流行ってるという噂を聞きました。 私にわかることは以下のみです。 なんかお願いすると リポジトリ のコードとかをみていい感じにPRを作ってくれる PRコメントやプロンプトを使って対話形式で追加修正もしてくれる 「ナレッジ」と呼ばれる約束事みたいなのを人間の言葉で設定できて、基本的にそれを守って作業してくれる devin.ai 活用事例紹介 記事の趣旨から外れるため、生成した ソースコード などは載せていません。「こんな風に使えるんだ〜」というのを感じ取っていただければ幸いです。 仕組みを作らせる系 GuardDutyのアラートを GitHub にIssueとして起票するLambdaを実装してもらった 弊社SREチームではセキュリティ関連のさらなる充実をミッションにしており、 GuardDuty を使用した脅威検出などの整備を進めています。今までの対応フローは 脅威検出 -> Slack通知 -> Issueの手動起票 -> 対応 -> Issueクローズ という流れでしたが、Issueを手動で起票するのがいい加減大変になってきたため、この部分をDevinに自動化してもらいました。 ※ 書いたプロンプト <terraformファイルへのリンク>で定義されている aws _ sns _topic.chatbot_guarddutyは、GuardDutyで脅威が検出された際、EventBridge -> SNS -> Chatbot -> Slackと通知を行うための SNS である。この SNS からLambdaにもファンアウトして、以下のような処理を行いたい 1. 脅威のタイトルと同名のIssueを GitHub のmntsq-infra リポジトリ に作成する 2. 本文には適当な概要と、検出結果へのリンクを貼る 3. guardduty/<レベル>のタグをつける<レベル>は脅威の重要度(低~クリティカル) 4. MNTSQ/sreのチームを アサイ ンする かなり大雑把な指示ですが、Devinは リポジトリ の ソースコード を読んで、どうのように実装すれば良いか、周りのコードとシンメトリーを合わせながら実装を行ってくれました。いきなり完璧なものが出来上がるわけではないので、PRにコメントする形で何度かやり取りをしました。同僚とチャットするような感覚で、ラフに不足箇所を指摘します。 ※ やり取りの雰囲気(実際にはもうちょいコンテキストの深いやり取りもしています) Devin 「できたよ。 GitHub tokenが必要だからこうやって設定してね」 わたし「了解。Lambdaのソースファイルはそっちじゃなくてこっちに置いて」 Devin 「OK」 わたし「token平文でLambdaの 環境変数 に突っ込んでるのまずいから、処理の中でSSMから取得するようにして」 Devin 「ごめん、直したよ」 わたし「Lambdaに〜の権限つけ忘れてるよ」 Devin 「ごめん、直したよ」 わたし「動いたわ、サンキュー」 結果、GuardDutyで脅威検出した際には以下のようなIssueが起票されるようになりました!下手に人間が作業するよりも、綺麗なフォーマットで起票してくれる実装になっていて嬉しいですね。細かいトラブルシュートまで含め、PR完成までの作業時間としては4時間くらいでしたが、 Devinがコードを書いている間の待ち時間をチャットの返信や細かいタスクに当てられるので、非常に効率的 に作業ができました。 起票されるようになったIssue ECSタスク定義の差分をPRにコメントする GitHub Actionsの実装してもらった 弊社ではタスク定義のテンプレートをアプリケーションのコードと同じ リポジトリ で管理しています。アプリケーションに変更が入ると、CDによって自動で AWS 環境のECSサービスの更新までが行われます。先日、タスク定義テンプレートの修正に不備があり、今まで値が入っていた 環境変数 が空になってしまったことによる障害が発生しました。障害を受けての恒久対策として、PR作成時に、CDによってタスク定義が変わる場合はその差分をPRにコメントするGitHubActionを作成してもらいます。 以下のようにプロンプトを書いてみました。 < リポジトリ 名>に以下のようなGithubActionのワークフローを作成したい。 * <ブランチ名>へのPR作成をトリガに動く * マージ後、デプロイのWF(deploy_clm, deploy_connectmail, deploy_ibreoffice, deploy_search_ api )が動いた際に新規作成されるタスク定義の差分表示とバリデーションを行う * deploy_clm, deploy_connectmail, deploy_ibreoffice, deploy_search_ api にて作成されるタスク定義の JSON と、ECSの最新のタスク定義 JSON のenvironment以下の要素を比較し、差分をどのタスク定義の差分なのかがわかる形でPRにコメントする * 新たに作成されるタスク定義のenvironmentに Value が空のものがあれば失敗させる deploy_clmとかいっているのは、サブサービス毎のデプロイのWFのことです。弊社ではサブサービス毎にデプロイの単位を分割しているので、WFファイルも複数に分かれていますが、それぞれのWFの実装方法に結構な差分がありました。 コードベースがごちゃついていることに加え、この丸投げするような指示の仕方がDevinを混乱させてしまい 、見当違いのPRばかりが作成されてしまいました。 そこで、以下のように作業を分割しました。 似たような処理を共 通化 して実装差分をなくす デプロイWF内のタスク定義 JSON を生成するステップ群を composite 化して、各デプロイのWFからはこのcompositeを使用してタスク定義 JSON を生成するようにしてもらう タスク定義の差分比較ロジックを共 通化 して再利用可能にする inputに与えた、タスク定義 JSON およびタスクファミリーのlatestの定義を比較して、 環境変数 の差分を指定したPRにコメントするcompositeを作成する バリデーションWFの外枠を整える PR作成をトリガとし、コード差分があったサブサービスについてタスク定義を作成し、2で作成したcompositeを使用してタスク定義の比較を行うWFを作成する 1の作業でコードのシンメトリーが揃い、Devinがコード構造を理解しやすくなります。加えて2,3の作業のように変更が大きくなりすぎないように指示を出すことによって、Devinもやるべきことを間違えずらいし、レビューもしやすくなりました。Devinに指示を出すときは、 新卒の社員にタスクを渡す感覚で分割する こと、そもそも 理解しやすいようにコードベースを整理しておく ことが大切みたいです。 PR作成時に、タスク定義の差分が表示されるようになりました! タスク定義に差分できる場合のコメント コード整頓系 デプロイWFの実装シンメトリを揃えてもらった コードが整頓されていることが、Devinの活用のしやすさにつながることは前述のとおりです。であれば、コードの整頓もDevinにやってもらいましょう。 リポジトリ に似たような処理が複数あるが、実装方法が微妙に異なる場合を考えてみます。まずは1つ自前で(Devinにやらせてもいいですが)お手本の修正PRを作ります。そしてDevinに以下のように指示します。 ※先ほども例に挙げた、サブサービス毎のデプロイWFのシンメトリーを揃えてもらうための指示 <マージ済みのお手本PRのリンク>と同じように、. github /workflows/deploy_connectmail.ymも修正してください。以下の点に気をつけてください。 * 変数設定のロジックをset-variablesというjobに集約すること * 変数ファイルの読み込みには. github /actions/load-env-variables/action.ymlを使用すること 注意して欲しい箇所だけプロンプトに書いておけば、あとは お手本を参照して綺麗に一発で作業してくれました 。これをサブサービス毎に行い、デプロイWFの実装のシンメトリーを完全に揃えることができました。 お手本があり、それを模倣して実装するという作業ではかなりの威力を発揮してくれるようです 。 ハードコードされていたオートスケールのパラメータを変数化してもらった terraformのvariableにすべき箇所をハードコードしてしまっているので、直してほしいというケースです。特に今回治したかった箇所は、カスタムメトリクス化している自前のキューのメッセージ滞留数を 閾値 としてオートスケールさせている箇所で、設定が複数のリソースにわたっています。 人力で影響範囲を網羅するのが少し大変 です。そんな作業も、 リポジトリ 構造を把握しながら作業してくれるDevinにお願いすれば一発です。 <関連 ディレクト リ>以下にハードコードされているオートスケールの設定を、<変数ファイル>に変数として抜き出して、気軽に書き換えられるようにしたい。設定したい項目はここら辺。 * オートスケールの上限 * スケールアウトを行うキュー滞留の 閾値 * 1回のスケールアウトでタスクを何台追加するか * 1回スケールアウトしてから再度スケールアウトするまでのクールダウン時間 とりあえず実装方針を確認したいから、<サブサービス>についてのみ上記の対応を行なって 変更が大きくなる場合は局所に絞って変更するように指示を出すと、レビューがしやすい です。方針をレビューしたら、他の部分も同じように変更するように指示を出しましょう。この変更も、特に直しの必要はなく一発でやってくれました。こういう単純な作業は人間よりも明らかにAIの方が得意ですね。 壁打ち系 使い慣れていない リポジトリ に大きな変更を行う際の調査・設計を手伝ってもらった 弊社ではインフラとアプリケーションの リポジトリ は分かれており、私はアプリケーションの リポジトリ には明るくありません。ところが少々アプリケーション リポジトリ を読み込まないければならない事情があり、Devinに相談に乗ってもらいました。 ※ ElasticSearchのインデクシングをBlue/Greenで行いたいんだけど......という相談 < リポジトリ 名>について。<ファイル名>の"initialize"のタスクについて、その処理時間がプロダクト運用上大きな問題になっている。弊社では頻繁にインデックス構造の見直しがあり、その度にこのタスクを実行するが、その間インデックスが使用できなくなるため、サービスを停止してインデクシングを行なっている。 サービス停止を行わずにインデックスを更新できるように、blue/greenでinitializeのタスクを実行できるようにしたい。以下は現時点で考えているア イデア の箇条書き * ノードではなくインデックスレベルでblue/greenの面を作る * initializeタスク実行時、現在使用していない面について更新を実行し、使用している面はその間も停止時間なく使用可能とする * タスク実行中にインデックスの追加・更新などのメッセージがキューに詰まれる時、それはblueとgreen両方の面の分積む(blue/green差分が生じないように) * 更新が終わったら面の切り替えを行う * 平時は使用していない面は落としておく とてもざっくりしていて申し訳ないけど、 * もっと良いア イデア はないか * このア イデア に追加したい項目はないか * クラスレベルの抽象度で、実装するとしたらどのような変更を加えるか など、考えを聞かせてほしい。 膨大な ソースコード を読み込まなければ検討すらできないような大きな課題ですが、なんとものの数分で複数のわかりやすい図とともに実装計画をドラフトしてくれました! もちろん考慮漏れは多々あるので、少しでも疑問を感じたら実際のコードを読みにいって、方針の妥当性を確認し、必要なら直してもらいます。以下のようなやり取りを延々と続けて、気になる点をひたすら潰していきました。 こんなやり取りを数十往復した 2日ほどかけ、最終的に、 とりあえず検証のために仮実装できるイメージを持つところまで到達できました 。クラス図や処理フロー毎のシーケンス図も、プロンプトでのやり取りを反映しながらブラッシュアップしてくれました。作成してもらった図は、現在の構造を表すものまで含めると合計で10個になりますが、これを手で作ったとするとかなりの 工数 がかかったはずです。もちろん実際に実装を始めてみると問題も出てくるかもしれませんが、 たった2日程度でほとんど手を加えたことのない リポジトリ の(必要な箇所の)構造把握と、大規模な変更プランをイメージできるようになれるのであれば、破格の作業効率ではないでしょうか。 仮完成したクラス図 仮作成したシーケンス図 まとめ Devinを活用するには ダラダラと活用事例を書いてきましたが、結局のところ以下のようにまとめられると思います。 Devinを活用するための前提 適切なタスク分解・わかりやすい指示ができる コードベースがある程度整っている レビュー・動作確認を怠けないマインドを持っている(とても重要) Devinは決して万能の存在ではなく、素晴らしく作業が早い新入社員と思って接するのがちょうど良い なと思いました。うまく指示を出してあげないと見当違いのことをしますし、ぐちゃぐちゃのコードを理解するのには人並みに手こずるようです。また、ちゃんと指示を出したつもりでも細かいミスはします。 Devinを使うべきでないシチュエーション タスクを 言語化 ができない場合 実装後の差分が大きくなり過ぎてしまう場合 これはシンプルに 「自分が理解していないタスクは任せるな」 ということです。 言語化 できないのは論外ですし、実装後の差分が大きくなりすぎるということは、そのタスクをきちんとサブタスクに分けられていないということです。(≒作業見通しが甘い)逆に自分が理解していて、適切な粒度に分けられるタスクは、積極的に任せて問題ないなと思います。しかし、 理解できていないタスクを理解するための壁打ち相手としては優秀 です。 おわりに ここ最近クローズしたIssueを振り返ると、8割近くをDevinに実装をしてもらっていました。もう私いらないんじゃないかな? というのは冗談で、生成AIはあくまでツールです。使う側が リテラシー を持って扱う必要があります。Devinを業務に導入するようになってから、決して大袈裟ではなく 業務効率は倍以上になった と感じます。今後は、自分でコードを書けるだけでなく、生成AIに適切な指示を出し、その結果を正しく評価できることが、エンジニアの市場価値につながっていくのだと思います。 先ほど「Devinは新入社員と思って接するのがちょうど良いと」いう例えをしました。いかに彼らに的確な指示を出し、円滑に協力できるか。生成AIを扱う上で求められる能力は、いわゆる「コミュニケーション能力」に近いのかもしれません。(詳しい人はもっとプロンプトに関わるハックなど持っているのかもしれません。あくまで私くらいの活用レベルでの話です)この感覚が正しいのであれば、"そこそこ" 程度の技術力は、もはや大した強みにはならなくなってしまうのでしょう。磨こう、 コミュ力 。 MNTSQ株式会社 SRE 西室
はじめに MNTSQ はそのサービスの性質(「契約」の集約、一元管理、活用)上、セキュリティの維持と向上が至上命題です。 セキュリティへの取り組みには幾つかのアプローチがありますが、何が不足しているのか、どういった対処が必要かという点を突き止めるには情報が必要です。これはどういったアプローチを取るにしても共通して重要な観点と思います。 本稿はこの情報の獲得のためのログ収集範囲の拡充を行った記録となります。対象は Route 53 の DNS クエリログです。 なぜ DNS クエリログを取るか DNS クエリログはその名前の通り DNS へのクエリのログです。つまり いつ 誰が 何を どこから(「誰が」と同一の情報になる場合あり) が DNS クエリ単位で得られます。Route 53 で得られる DNS クエリログには以下2種類があります。 公開 DNS クエリログ: Public DNS query logging - Amazon Route 53 インターネットからの Route 53 公開 (public) hosted zone に対して発行された DNS クエリに関するログ リ ゾル バクエリログ: Resolver query logging - Amazon Route 53 VPC 内からインターネットに向けて発行された DNS クエリに関するログ VPC に紐付く Route 53 非公開 (private) hosted zone の名前解決は Route 53 リゾルバ が担い、ログ出力もこれが担う つまり Route 53 においては上述 DNS クエリログを インターネット → Route 53 公開 hosted zone(公開 DNS クエリログ) VPC → インターネット(リ ゾル バクエリログ) の2方向に関して収集することができます。これによって得られる情報はいくつか考えられますが、 インターネット → Route 53 公開 hosted zone 所謂 attack surface を狙われている形跡の確認 意図しないホストに対してのリク エス トが継続していないか等、接続エラーの確認 VPC → インターネット VPC 内から意図しない通信が発生していないかの確認 といったものがパッと思い付くだけでも挙げられます。実際にログを取ってみて初めて気付ける活用法もあるはずなので、まずはログを取ることを目的としてもよいでしょう。 DNS クエリログ収集構成 構成図を以下に示します。 AWSACC1 = Route 53 リソース稼動アカウント(図では1つだが実際には複数存在) Analysis = ログ分析用アカウント(1つのみ存在) Route 53 が生成する各 DNS クエリログを最終的には専用の AWS アカウント内に用意した S3 バケット に集約し、当該アカウントの Athena からログを解析する構成となります。 ログを必ずしも専用の AWS アカウントに集約する必要は無いのですが、今回は Athena でのログ検索時の利便性の面から、ログ集約先および活用場所をひとつの場所に絞るようにしました。S3 上にログを集約する取り組みが DNS クエリログについては初であった点も保存先選定の柔軟さに一役買っています。 図から判るとおり、 DNS クエリログによって S3 への保存方法が異なります。 リ ゾル バクエリログはログ出力先を複数選べ、選択肢の中には S3 がデフォルトで存在します *1 。 一方で公開 DNS クエリログについては CloudWatch Logs 以外にログを出力する選択肢はありません *2 。また CloudWatch Logs ロググループは us-east-1 にあるものだけが利用可 という制約もあります。 弊社でログ検索用に整備している Athena とその関連リソースは ap-northeast-1 にあることを前提にしているので、ここは出来れば ap-northeast-1 に寄せたいところです。このあたりを踏まえて公開 DNS クエリログについては Data Firehose を使い us-east-1 内で CloudWatch Logs から S3 へログを移設 S3 レプリケーション で us-east-1 から ap-northeast-1 へリージョンを跨いで最終目的地となる S3 バケット へログを保存 という構成をとるようにしました。 Terraform コード Route 53 ログを生成する側を submitter、ログを最終的に保管し Athena で検索する側を receiver とし、2つのコードを例示します。 前述の構成図でいえば AWSACC1 に相当するものが submitter、 Analysis に相当するものが receiver になります。 いずれも実際に使っているコードを改変しての例示となります。 submitter 以下を実施するコードです。Route 53 各ゾーン (private / public) および VPC は既に存在するものとします。 リ ゾル バクエリログを receiver 側 S3 バケット として保存 公開 DNS クエリログを us-east-1 の CloudWatch Logs ロググループに保存 us-east-1 にある CloudWatch Logs ロググループの内容を us-east-1 の S3 バケット へ Data Firehose を使い送出 後述の Athena でのログ解析の都合で dynamic partitioning ( https://docs.aws.amazon.com/firehose/latest/dev/dynamic-partitioning.html ) を有効にしています us-east-1 にある S3 バケット の内容を receiver 側の ap-northeast-1 下 S3 バケット へレプリケート main.tf data "aws_caller_identity" "current" {} # リゾルバクエリログ収集用コード resource "aws_route53_resolver_query_log_config" "main" { name = var.route53 [ "resolver_query_log" ][ "config_name" ] destination_arn = var.route53 [ "resolver_query_log" ][ "bucket_arn" ] } resource "aws_route53_resolver_query_log_config_association" "main" { resolver_query_log_config_id = aws_route53_resolver_query_log_config.main.id resource_id = var.vpc [ "id" ] } # 公開 DNS クエリログ収集用コード resource "aws_cloudwatch_log_group" "aws_route53_public" { provider = aws.us-east-1 name = var.route53 [ "public_dns_query_log" ][ "log_group_name" ] retention_in_days = 14 # S3 上のログを実運用上は使うので CloudWatch Logs には長期保管する必要がない } data "aws_iam_policy_document" "route53_query_logging" { statement { actions = [ "logs:CreateLogStream" , "logs:PutLogEvents" , ] resources = [ aws_cloudwatch_log_group.aws_route53_public.arn, ] principals { identifiers = [ "route53.amazonaws.com" ] type = "Service" } } } resource "aws_cloudwatch_log_resource_policy" "route53_public_query_logging_policy" { provider = aws.us-east-1 policy_document = data.aws_iam_policy_document.route53_query_logging.json policy_name = "$ { var.route53 [ "resolver_query_log" ][ "keyword" ]} -policy" } resource "aws_route53_query_log" "public" { provider = aws.us-east-1 depends_on = [ aws_cloudwatch_log_resource_policy.route53_public_query_logging_policy ] cloudwatch_log_group_arn = aws_cloudwatch_log_group.aws_route53_public.arn zone_id = aws_route53_zone.public.zone_id } # us-east-1 内で CloudWatch Logs から S3 へログを Data Firehose 経由で移す resource "aws_cloudwatch_log_subscription_filter" "s3_stream_filter" { provider = aws.us-east-1 name = "$ { var.route53 [ "public_dns_query_log" ][ "keyword" ]} -to-firehose" log_group_name = aws_cloudwatch_log_group.aws_route53_public.name # 全ログを転送対象にしたいので filter_pattern は空にする filter_pattern = "" destination_arn = aws_kinesis_firehose_delivery_stream.aws_route53_public.arn role_arn = aws_iam_role.route53_public_query_logs_to_firehose_role.arn } resource "aws_cloudwatch_log_group" "route53_public_firehose_log" { provider = aws.us-east-1 name = "/aws/kinesisfirehose/$ { aws_kinesis_firehose_delivery_stream.main.name } " retention_in_days = 14 # 最終保存先 S3 バケットにレプリケートされたログを実運用上では使うので長期間の保持は不要 } resource "aws_kinesis_firehose_delivery_stream" "main" { provider = aws.us-east-1 name = var.route53 [ "public_dns_query_log" ][ "keyword" ] destination = "extended_s3" extended_s3_configuration { role_arn = aws_iam_role.route53_public_query_logging_role.arn bucket_arn = aws_s3_bucket.aws_route53_public.arn buffering_size = 64 # MB 単位。dynamic partitioning が有効の場合必須 /* Data Firehose 内で Route 53 公開 DNS ログを Athena が解釈できる形式に変換する。詳細は後述 実施している内容は以下のとおり - CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開 - 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開 - S3 レプリケーションの事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定 */ dynamic_partitioning_configuration { enabled = "true" } processing_configuration { enabled = "true" processors { type = "MetadataExtraction" parameters { parameter_name = "JsonParsingEngine" parameter_value = "JQ-1.6" } parameters { parameter_name = "MetadataExtractionQuery" parameter_value = "{prefix: {dummy: (\"logs\")} | .dummy}" # 実質的に "logs" という固定文字列を返すだけ } } processors { type = "Decompression" parameters { parameter_name = "CompressionFormat" parameter_value = "GZIP" } } processors { type = "AppendDelimiterToRecord" } } prefix = "!{partitionKeyFromQuery:prefix}/$ { data.aws_caller_identity.current.account_id } /!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/!{timestamp:HH}/" error_output_prefix = "error/$ { data.aws_caller_identity.current.account_id } /!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/!{timestamp:HH}/!{firehose:error-output-type}/" compression_format = "GZIP" cloudwatch_logging_options { enabled = true log_group_name = aws_cloudwatch_log_group.route53_public_firehose_log.name log_stream_name = "S3Delivery" } } } resource "aws_s3_bucket" "aws_route53_public" { provider = aws.us-east-1 bucket = var.route53 [ "public_dns_query_log" ][ "source_bucket_name" ] } resource "aws_s3_bucket_lifecycle_configuration" "aws_route53_public" { provider = aws.us-east-1 bucket = aws_s3_bucket.aws_route53_public.id rule { status = "Enabled" id = "delete after 180 days" expiration { days = 180 # 集約先 S3 バケット側のログを使うので然程長期間保持しておく必要はない } filter { prefix = "" } } } resource "aws_s3_bucket_versioning" "aws_route53_public" { provider = aws.us-east-1 bucket = aws_s3_bucket.aws_route53_public.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "aws_route53_public" { provider = aws.us-east-1 bucket = aws_s3_bucket.aws_route53_public.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_server_side_encryption_configuration" "aws_route53_public" { provider = aws.us-east-1 bucket = aws_s3_bucket.aws_route53_public.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } data "aws_iam_policy_document" "aws_route53_public_bucket_policy" { provider = aws.us-east-1 statement { effect = "Allow" actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.aws_route53_public.arn } /*" ] principals { type = "Service" identifiers = [ "logging.s3.amazonaws.com" ] } condition { test = "ArnLike" variable = "aws:SourceArn" values = [ "arn:aws:s3:::mntsq-$ { var.env } -*" ] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [ data.aws_caller_identity.current.account_id ] } } } resource "aws_s3_bucket_policy" "aws_route53_public" { provider = aws.us-east-1 bucket = aws_s3_bucket.aws_route53_public.bucket policy = data.aws_iam_policy_document.aws_route53_public_bucket_policy.json } resource "aws_s3_bucket_replication_configuration" "route53_public_query_logging" { provider = aws.us-east-1 depends_on = [ aws_s3_bucket_versioning.aws_route53_public ] bucket = aws_s3_bucket.aws_route53_public.id role = aws_iam_role.route53_public_query_logging_replication.arn rule { id = "route53-public-dns-query-log-replication" status = "Enabled" filter { prefix = "logs" } delete_marker_replication { status = "Disabled" } destination { account = var.route53 [ "public_dns_query_log" ][ "destination_account_id" ] bucket = var.route53 [ "resolver_query_log" ][ "destination_bucket_arn" ] storage_class = "STANDARD_IA" access_control_translation { owner = "Destination" } } } } provider.tf provider "aws" { region = "ap-northeast-1" } provider "aws" { alias = "us-east-1" region = "us-east-1" } terraform { required_version = "~> 1.11.4" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0.0" } } } receiver submitter が生成した Route 53 ログを最終的に保管する S3 バケット を管理します。こちらはリージョンを跨がず ap-northeast-1 のみで完結するので、provider.tf の例示は省略します main.tf /* DNS クエリログを収集する対象となる AWS アカウントは AWS Organizations で管理している これらアカウントに対してのアクセス許可(S3 バケットポリシ)を個々設定するのは手間なので、organization 単位で許可できるようにする これには organization ID が要り、その値を得るための data */ data "aws_organizations_organization" "main" {} # リゾルバクエリログの最終保管場所となる S3 バケットとその周辺のリソースを定義 resource "aws_s3_bucket" "route53_resolver_query_logs" { bucket = var.s3 [ "resolver_query_logs" ][ "name" ] } resource "aws_s3_bucket_server_side_encryption_configuration" "route53_resolver_query_logs" { bucket = aws_s3_bucket.route53_resolver_query_logs.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } resource "aws_s3_bucket_lifecycle_configuration" "route53_resolver_query_logs" { bucket = aws_s3_bucket.route53_resolver_query_logs.id rule { id = "transition to archives" transition { days = 30 storage_class = "STANDARD_IA" } transition { days = 60 storage_class = "GLACIER" } filter { prefix = "" } status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "route53_resolver_query_logs" { bucket = aws_s3_bucket.route53_resolver_query_logs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } data "aws_iam_policy_document" "route53_resolver_query_logs" { statement { effect = "Allow" actions = [ "s3:GetBucketAcl" ] resources = [ aws_s3_bucket.route53_resolver_query_logs.arn, ] principals { type = "Service" identifiers = [ "delivery.logs.amazonaws.com" ] } } statement { effect = "Allow" actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.route53_resolver_query_logs.arn } /*" , ] principals { type = "Service" identifiers = [ "delivery.logs.amazonaws.com" ] } condition { test = "StringEquals" variable = "s3:x-amz-acl" values = [ "bucket-owner-full-control" , ] } condition { test = "StringEquals" variable = "aws:PrincipalOrgID" values = [ data.aws_organizations_organization.main.id ] } } } resource "aws_s3_bucket_policy" "route53_resolver_query_logs" { bucket = aws_s3_bucket.route53_resolver_query_logs.id policy = data.aws_iam_policy_document.route53_resolver_query_logs.json } # 公開 DNS クエリログの最終保管場所となる S3 バケットとその周辺のリソースを定義 resource "aws_s3_bucket" "route53_public_dns_query_logging" { bucket = var.s3 [ "public_dns_query_logs" ][ "name" ] } resource "aws_s3_bucket_versioning" "route53_public_dns_query_logging" { bucket = aws_s3_bucket.route53_public_dns_query_logging.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "route53_public_dns_query_logging" { bucket = aws_s3_bucket.route53_public_dns_query_logging.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_ownership_controls" "route53_public_dns_query_logging" { bucket = aws_s3_bucket.route53_public_dns_query_logging.id rule { object_ownership = "BucketOwnerPreferred" } } resource "aws_s3_bucket_server_side_encryption_configuration" "route53_public_dns_query_logging" { bucket = aws_s3_bucket.route53_public_dns_query_logging.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } data "aws_iam_policy_document" "route53_public_dns_query_logging" { statement { effect = "Allow" actions = [ "s3:ReplicateObject" , "s3:ReplicateDelete" , "s3:ReplicateTags" , "s3:GetObjectVersionTagging" , "s3:ObjectOwnerOverrideToBucketOwner" , ] resources = [ "$ { aws_s3_bucket.route53_public_dns_query_logging.arn } /*" ] principals { type = "AWS" identifiers = [ "*" ] } condition { test = "StringEquals" variable = "aws:PrincipalOrgID" values = [ data.aws_organizations_organization.main.id ] } } statement { effect = "Allow" actions = [ "s3:GetBucketVersioning" , "s3:PutBucketVersioning" , "s3:ListBucket" , "s3:GetReplicationConfiguration" , ] resources = [ aws_s3_bucket.route53_public_dns_query_logging.arn ] principals { type = "AWS" identifiers = [ "*" ] } condition { test = "StringEquals" variable = "aws:PrincipalOrgID" values = [ data.aws_organizations_organization.main.id ] } } statement { effect = "Allow" actions = [ "s3:PutObject" ] resources = [ "$ { aws_s3_bucket.route53_public_dns_query_logging.arn } /*" , ] principals { type = "Service" identifiers = [ "logging.s3.amazonaws.com" ] } condition { test = "StringEquals" variable = "s3:x-amz-acl" values = [ "bucket-owner-full-control" ] } condition { test = "StringEquals" variable = "aws:PrincipalOrgID" values = [ data.aws_organizations_organization.main.id ] } } } resource "aws_s3_bucket_policy" "route53_public_dns_query_logging" { bucket = aws_s3_bucket.route53_public_dns_query_logging.id policy = data.aws_iam_policy_document.route53_public_dns_query_logging.json } 公開 DNS クエリログの取り扱いについての注意 上記サンプルコード内で Data Firehose を使い CloudWatch Logs から S3 へ公開 DNS クエリログを送出する過程で、何やら小難しいことをしている箇所に目が付くと思います。 extended_s3_configuration { role_arn = aws_iam_role.route53_public_query_logging_role.arn bucket_arn = aws_s3_bucket.aws_route53_public.arn buffering_size = 64 # MB 単位。dynamic partitioning が有効の場合必須 /* Data Firehose 内で Route 53 公開 DNS ログを Athena が解釈できる形式に変換する。詳細は後述 実施している内容は以下のとおり - CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開 - 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開 - S3 レプリケーションの事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定 */ dynamic_partitioning_configuration { enabled = "true" } processing_configuration { enabled = "true" processors { type = "MetadataExtraction" parameters { parameter_name = "JsonParsingEngine" parameter_value = "JQ-1.6" } parameters { parameter_name = "MetadataExtractionQuery" parameter_value = "{prefix: {dummy: (\"logs\")} | .dummy}" # 実質的に "logs" という固定文字列を返すだけ } } processors { type = "Decompression" parameters { parameter_name = "CompressionFormat" parameter_value = "GZIP" } } processors { type = "AppendDelimiterToRecord" } } これは Athena でログを処理することを前提とした前処理を Data Firehose のみで(= ログ処理用の Lambda 関数等を噛ませないで)実施する為の措置です。 通常 CloudWatch Logs にある Route 53 公開 DNS クエリログを Data Firehose でシンプルに S3 へ送出すると以下のような改行なしで複数の JSON オブジェクトが1行に集約されたものが得られます(実際のログを適当にマスクし例示します)。 { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/YVR52-R2 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522674731119363142938582278340608 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP YVR52-R2 192.0.2.143 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522685262133562904066894168588288 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO9-SN1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522690947843897953596035415605248 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com AAAA NOERROR UDP SFO9-SN1 192.0.2.144 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SEA900-R3 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522692678857209236868848315727872 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP SEA900-R3 2001:DB8::143 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522703737195603523266279501332480 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/ATL58-R1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047077085475805385546231824257833115761174552619646976 "," timestamp ": 1750931493000 ," message ":" 1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP ATL58-R1 192.0.2.148 192.0.2.0/24 " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/NRT8-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047077085475805385546231840537028970579482687526338560 "," timestamp ": 1750931493000 ," message ":" 1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP NRT8-SO1 192.0.2.10 - " }]}{ " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047082905970302202038872078382990508713243113432088576 "," timestamp ": 17509 31754000,"message":"1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24"}]} ところがこの形式の JSON ログは Athena では受け付けられません。Athena は1行1エントリの JSON ログを要求するためです *3 。つまり上記例でいえば { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/YVR52-R2 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522674731119363142938582278340608 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP YVR52-R2 192.0.2.143 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522685262133562904066894168588288 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO9-SN1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522690947843897953596035415605248 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com AAAA NOERROR UDP SFO9-SN1 192.0.2.144 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SEA900-R3 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522692678857209236868848315727872 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP SEA900-R3 2001:DB8::143 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/SFO6-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047076594859411017872522703737195603523266279501332480 "," timestamp ": 1750931471000 ," message ":" 1.0 2025-06-26T09:51:11Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP SFO6-SO1 192.0.2.144 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/ATL58-R1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047077085475805385546231824257833115761174552619646976 "," timestamp ": 1750931493000 ," message ":" 1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP ATL58-R1 192.0.2.148 192.0.2.0/24 " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/NRT8-SO1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047077085475805385546231840537028970579482687526338560 "," timestamp ": 1750931493000 ," message ":" 1.0 2025-06-26T09:51:33Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NXDOMAIN UDP NRT8-SO1 192.0.2.10 - " }] } { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047082905970302202038872078382990508713243113432088576 "," timestamp ": 1750931754000 ," message ":" 1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24 " }]} のような JSON ログとなっている必要があります。よって Data Firehose から S3 へログを流す際にその中身を書き換える必要が出てきます。Data Firehose ではデータ処理の過程で Lambda 関数にその役目を担わせることができるので *4 それを使うのも手ですが、お世話が必要な主体をあまり増やしたくありません。 同じような事例が無いか調査していたところ medium.com が基本的な構想として多いに参考になり、また S3 による レプリケーション を考える場合の prefix 付与においては dev.classmethod.jp が大変参考になりました。つまりはコード中のコメントにもあるとおり CloudWatch Logs から Data Firehose に流れてくるログは gzip 圧縮されているのでこれを展開 展開した内容は1行に複数の JSON オブジェクトが含まれる形式になっているので jq を使い1行1オブジェクトになるよう展開 S3 レプリケーション の事情で Data Firehose から S3 へログデータを置く場合は適当な prefix が欲しいので、これを "logs/" とできるよう設定 を Data Firehose のみで実施することが出来、これは dynamic partitioning *5 によって達成が可能ということになります。 本来 dynamic partitioning はログに含まれるキー値から S3 へオブジェクトを保存する際の prefix を決定し Athena をはじめとする S3 をデータソースとする解析系サービス向けに パーティション を整備するための機能ですが、弊社のケースではそこまで凝ったことは不要で、 JSON ログをその構造を維持しつつログエントリ単位で適当に改行したいという共有が満たせれば OK です。先に示したコードも processing_configuration ブロックが割合シンプルなものになっています。 このコードは前述2記事に拠るところが多大に有ります。この場を借りて感謝申し挙げます。 ログを Athena で検索する さて前項までに Route 53 由来の DNS クエリログを S3 に集約して保存できるようになりました。これを Athena で検索してゆくようにする手筈を整えます。 リ ゾル バクエリログについては Use partition projection - Amazon Athena で示される内容が充分実用に耐えるものになりますが、公開 DNS クエリログについては AWS としての公式サポートが CloudWatch Logs であるということを踏まえても想像に難くなく、このようなクエリ例が存在しません。従って自前で頑張る必要があります。 早い話以下のような内容が使えます。前述の Data Firehose コードによって S3 へログが送出される前提の内容です。例示値や置換すべき値は前述のリ ゾル バクエリ用の例に倣っています。 CREATE EXTERNAL TABLE r53_public_dns_logs ( messageType string, owner string, logGroup string, logStream string, subscriptionFilters array< string >, logEvents array< struct< id: string, timestamp : bigint, message: string > > ) PARTITIONED BY ( `datehour` 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://amzn-s3-demo-bucket/route53-public-dns-query-logging/logs/aws_account_id/ ' TBLPROPERTIES( ' projection.enabled ' = ' true ' , ' projection.datehour.type ' = ' date ' , ' projection.datehour.range ' = ' 1970/01/01/00,NOW ' , ' projection.datehour.format ' = ' yyyy/MM/dd/HH ' , ' projection.datehour.interval ' = ' 1 ' , ' projection.datehour.interval.unit ' = ' HOURS ' , ' storage.location.template ' = ' s3://amzn-s3-demo-bucket/route53-public-dns-query-logging/logs/aws_account_id/$${datehour}/ ' ) 確かにこれで公開 DNS クエリログを検索できるのですが、クエリの内容をみてもわかるとおり、ログの中身で最も知りたい筈の DNS クエリ周辺の状況 ( logEvents[].message ) が string として扱われるに留まっており、少々厄介です。例えば { " messageType ":" DATA_MESSAGE "," owner ":" 123456789012 "," logGroup ":" LOG_GROUP "," logStream ":" ZxxxxxxxxxxxxxxxxxxxQ/KUL51-R1 "," subscriptionFilters ": [ " SUBSCRIPTION_FILTER " ] ," logEvents ": [{ " id ":" 39047082905970302202038872078382990508713243113432088576 "," timestamp ": 1750931754000 ," message ":" 1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24 " }]} というログが有ったとして、この中で真に知りたいのは 1.0 2025-06-26T09:55:54Z ZxxxxxxxxxxxxxxxxxxxQ sample.example.com A NOERROR UDP KUL51-R1 192.0.2.152 192.0.2.0/24 であって、これを適当な列に分割したうえで列に対して具体的な値やパターン等を当て嵌めて検索するということが本来やりたいことです。とれる手段は Data Firehose でのデータ処理時に Lambda 関数を噛ませて JSON ログ中 logEvents[].message だけを S3 へ送出する対象とする ログは加工せず、Athena で頑張る というものが考えられそうですが、弊社のケースでは先述の通り「お世話が必要な主体をあまり増やしたくない」ので、Athena で頑張る方法を選びました。具体的には ログデータを直接扱い Athena 上で取り回しのしやすい構造にする為のテーブル 上述の Athena テーブル定義による ログデータから DNS クエリログに関する内容(= logEvents[].message )だけを検索対象とするビュー 後述 といったようにテーブル以外に Athena ビュー *6 を用意することで対処しています。具体的には以下のようなビュー定義を使用しています。 -- Route 53 公開 DNS クエリログを Athena で扱うためのビュー -- r53_public_dns_logs というテーブルを元ネタとして DNS クエリログを直接 Athena で検索できるようにするためのもの CREATE OR REPLACE VIEW r53_public_dns_log_view AS SELECT -- 正規表現を使い、message フィールドを仮想的な列に分割 -- 正規表現の各( )がキャプチャグループ(1から始まるインデックス)に対応 regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 1 ) AS version, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 2 ) AS timestamp , regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 3 ) AS hosted_zone_id, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 4 ) AS name, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 5 ) AS type , regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 6 ) AS response_code, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 7 ) AS protocol, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 8 ) AS edge_location, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 9 ) AS r_ip, regexp_extract(e.message, ' ^([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) ([^ ]+) (.*)$ ' , 10 ) AS c_ip, l.datehour FROM -- 対応する Athena テーブル名を指定する -- ログ内容は datehour によってパーティションが切られているのでビューでもこれを使えるようにする r53_public_dns_logs l CROSS JOIN UNNEST(l.logEvents) AS t(e) -- 元のログにおける logEvents 配列を展開 ここで作成したビューを対象として検索を実施することで、公開 DNS クエリログもリ ゾル バクエリログと同等の使い勝手で Athena にて取り回すことが可能になります。 おわりに Route 53 由来の DNS クエリログを Athena で取り扱う方法について解説しました。 S3 へのログ保存が公式にサポートされているリ ゾル バクエリログでは Athena による検索およびその運用に関する tips が数多く見付かる一方、公開 DNS クエリログについては CloudWatch Logs 以外の場所での保管を自前で頑張らないといけない事情で、ログ検索それ自体の tips は然程多くない現状があります。 両方の DNS クエリログを同等の手段(本記事では Athena + S3 ベースで)で横断して追跡できるようにすることで、ログ利用の手間感の低減や新たな洞察を得ることの切っ掛けになるはずです。実際に弊社ではこの手法で DNS クエリログを割合気軽に追えるようになったことで、これまであまりケアできていなかった DNS 関連の運用改善や外部からのリク エス ト調査に新たな観点を導入するといった効果が得られ、予想よりも多くの嬉しさがありました。 DNS クエリログ収集やその運用改善に本稿が一助となれば幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : AWS resources that you can send Resolver query logs to - Amazon Route 53 *2 : Public DNS query logging - Amazon Route 53 *3 : https://repost.aws/ja/knowledge-center/error-json-athena *4 : https://docs.aws.amazon.com/firehose/latest/dev/data-transformation.html *5 : https://docs.aws.amazon.com/firehose/latest/dev/dynamic-partitioning.html *6 : https://docs.aws.amazon.com/athena/latest/ug/views.html
こんにちは、MNTSQ( モンテスキュー )で アルゴリズム エンジニアをしている清水です。 MNTSQは契約書を解析・管理・検索するプロダクトを提供しています。これらのプロダクトには大規模 言語モデル (以下LLM)が搭載された機能が実装されています。また、LLMを活用した新プロダクトも鋭意開発中です。 LLMをアプリケーションに組み込む際の大きな課題の一つとして、 「LLMの出力形式(型)を如何に矯正するか?」 が挙げられます。単純なチャットアプリケーションであればそこまで問題にはなりませんが、LLMによる生成結果を後続のプログラムで処理する必要がある場合、事前に定義された型に従って出力を生成する必要があります。 現在、複数のLLMサービスで出力形式を制御する機能が搭載されていますが、本記事では Google が提供している Gemini の Structured output を取り上げます。本記事では、 開発の過程で得られた、GeminiのStructured outputにおける7つのTips を紹介したいと思います。 サンプルコード 例として以下のように Python の Google Gen AI SDK ( google -genai)を使用することを想定しています。 google -genaiでは types.GenerateContentConfig の response_schema に Pydantic のモデルを渡すことで、Structured outputを使用することができます。 本記事ではStructured outputの機能にフォーカスするのでプロンプトは最低限の内容にしています。また、 スキーマ として指定するPydanticモデルも、タイトルの抽出と契約書かどうかを判定するだけのシンプルなものにしています。また、Gemini API ではなく Vertex AI の API を介してGeminiを使用します。(ほとんどのケースでGemini API に対しても同じTipsが適用できると思いますが、一部仕様が異なる可能性があります。) from google.genai import Client, types from pydantic import BaseModel PROMPT_TEMPLATE = """ \ JSONスキーマに従って、ドキュメントの内容を分析してください。 <json_schema> {json_schema} </json_schema> <document> {document_text} </document> """ class ContractAnalysisResult (BaseModel): document_name: str is_contract: bool def analyze_contract (document_text: str ) -> ContractAnalysisResult: client = Client(vertexai= True , project= "development" , location= "global" ) prompt = PROMPT_TEMPLATE.format( json_schema=ContractAnalysisResult.model_json_schema(), document_text=document_text, ) contract_analysis_result = client.models.generate_content( model= "gemini-2.5-flash" , contents=prompt, # response_schemaにPydanticモデルを渡す config=types.GenerateContentConfig( response_schema=ContractAnalysisResult, ), ) return contract_analysis_result Tips 1: プロンプトに JSON スキーマ を含めない 一番簡単に試すことができるテクニックは、「プロンプトに JSON スキーマ を含めない」です。実は、 response_schema を設定した場合は、 JSON スキーマ をプロンプトに含めない ことが 公式のドキュメント で推奨されています。 警告:   responseSchema  を構成する場合は、テキスト プロンプトで スキーマ を指定しないでください。これにより、予期しない結果や品質の低い結果が生じる可能性があります。 以下のサンプルコードでは、上記のサンプルコードから JSON スキーマ を埋め込んでいた箇所を削除しています。 # response_schemaを指定する際には、JSONスキーマをプロンプトに含めない PROMPT_TEMPLATE = """ \ JSONスキーマに従って、ドキュメントの内容を分析してください。 <document> {document_text} </document> """ Structured outputについてすべての仕組みが詳細に明かされているわけではないので、なぜ JSON スキーマ をプロンプトに含めないことが推奨されるのか技術的な理由は分かりません。公式のドキュメントで”don't duplicate the schema in the text prompt.”と書いてあることから、重複した情報をLLMに与えることが悪影響を及ぼすのかもしれません。 また、OpenAIやAnthropicのドキュメントには同様の記述は見当たらず(見逃していたらすみません)、Gemini特有の性質である可能性もあります。 Tips 2: title と description を設定する JSON スキーマ の各フィールドにおいて、 自然言語 による説明を付けたい時は以下のように title や description フィールドを使いましょう 。Structured outputでは JSON スキーマ による構造化されたデータしか渡せないと勘違いされがちですが、LLMらしく 自然言語 による情報も与えることができます。 from pydantic import BaseModel, Field class ContractAnalysisResult (BaseModel): document_name: str = Field( title= "タイトル (Title)" , description= "ドキュメント冒頭に記載されているタイトル" , ) is_contract: bool = Field( title= "契約書かどうか (Is Contract)" , description= "ドキュメントが契約書かどうかを示す。就業規則や賃金規定などは契約書ではない。" , ) ここで定義された title と description は Vertex AIのAPIリファレンス で記述されている responseSchema フィールドの title フィールドと description フィールドに渡されます。(詳しくは次の項目で言及します) Tips 3: API に渡されるパラメータを確認する Pydanticモデルを response_schema に渡すだけでStructured outputを使用できますが、 最終的にどのような形式で API に渡されるのかを確認する ことは有効です。以下のようにして、 response_schema に渡したPydanticモデルが、どのように API に渡すためのパラメータに変換されるかを確認することができます。 from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class ContractAnalysisResult (BaseModel): document_name: str = Field( title= "タイトル (Title)" , description= "ドキュメント冒頭に記載されているタイトル" , ) is_contract: bool = Field( title= "契約書かどうか (Is Contract)" , description= "ドキュメントが契約書かどうか。就業規則や賃金規定などは契約書ではない。" , ) if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) # _transformers.t_schemaにClientオブジェクトとPydanticモデルを渡す request_params = t.t_schema(client, ContractAnalysisResult) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) 出力結果 { " properties ": { " document_name ": { " description ": " ドキュメント冒頭に記載されているタイトル ", " title ": " タイトル (Title) ", " type ": " STRING " } , " is_contract ": { " description ": " ドキュメントが契約書かどうか。就業規則や賃金規定などは契約書ではない。 ", " title ": " 契約書かどうか (Is Contract) ", " type ": " BOOLEAN " } } , " property_ordering ": [ " document_name ", " is_contract " ] , " required ": [ " document_name ", " is_contract " ] , " title ": " ContractAnalysisResult ", " type ": " OBJECT " } 例えば、私は開発の過程で以下のような google -genaiの仕様(というよりはバグ?)を見つけました 1 。 下記のように、Pydanticモデルが 入れ子 構造になっている スキーマ において、以下のように親モデルの Field において title と description を設定します。 from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class ContractTerm (BaseModel): effective_date: str expiration_date: str class ContractAnalysisResult (BaseModel): contract_term: ContractTerm = Field( title= "契約期間 (Contract Term)" , description= "契約有効日と失効日から構成される契約の期間。" , ) if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) request_params = t.t_schema(client, ContractAnalysisResult) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) この スキーマ の t_schema の出力を確認してみると、以下のように title と description が消えてしまっていることが確認できます。( title はデフォルト値のクラス名( ContractTerm )が代わりに格納されています。) { " properties ": { " contract_term ": { " properties ": { " effective_date ": { " title ": " Effective Date ", " type ": " STRING " } , " expiration_date ": { " title ": " Expiration Date ", " type ": " STRING " } } , " property_ordering ": [ " effective_date ", " expiration_date " ] , " required ": [ " effective_date ", " expiration_date " ] , " title ": " ContractTerm ", " type ": " OBJECT " } } , " required ": [ " contract_term " ] , " title ": " ContractAnalysisResult ", " type ": " OBJECT " } 以下のように親モデルの Field ではなく子モデルの ConfigDict で title を、docstringで description を設定すると、問題なく変換されます。 from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, ConfigDict class ContractTerm (BaseModel): """契約有効日と失効日から構成される契約の期間。""" # docstringを設定するとdescriptionとして認識される model_config = ConfigDict(title= "契約期間 (Contract Term)" ) effective_date: str expiration_date: str class ContractAnalysisResult (BaseModel): contract_term: ContractTerm if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) request_params = t.t_schema(client, ContractAnalysisResult) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) 出力結果 { " properties ": { " contract_term ": { " description ": " 契約有効日と失効日から構成される契約の期間。 ", " properties ": { " effective_date ": { " title ": " Effective Date ", " type ": " STRING " } , " expiration_date ": { " title ": " Expiration Date ", " type ": " STRING " } } , " property_ordering ": [ " effective_date ", " expiration_date " ] , " required ": [ " effective_date ", " expiration_date " ] , " title ": " 契約期間 (Contract Term) ", " type ": " OBJECT " } } , " required ": [ " contract_term " ] , " title ": " ContractAnalysisResult ", " type ": " OBJECT " } 思ったように型の矯正が効かないときはこのようなエッジケースを踏んでいるのかもしれません。そのような時は、この方法を使って API に渡されるパラメータを確認すると良いでしょう。 Tips 4: date 型や datetime 型を使用する スキーマ を定義するPydanticモデルの各フィールドおいて、 Python の date 型や datetime 型を使用する ことができます。以下のPydanticモデルを t_schema に渡すと以下のようなパラメータに変換されていることが確認できます。 from datetime import date from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel class ContractTerm (BaseModel): effective_date: date # str型ではなくdate型を指定 expiration_date: date if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) request_params = t.t_schema(client, ContractTerm) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) 出力結果( "format": "date", となっている箇所に注目してください) { " properties ": { " effective_date ": { " format ": " date ", " title ": " Effective Date ", " type ": " STRING " } , " expiration_date ": { " format ": " date ", " title ": " Expiration Date ", " type ": " STRING " } } , " property_ordering ": [ " effective_date ", " expiration_date " ] , " required ": [ " effective_date ", " expiration_date " ] , " title ": " ContractTerm ", " type ": " OBJECT " } この format フィールドは title や description と同様に API の responseSchema でサポートされているフィールドです。ただし、どのようなformatでも良いわけではなく現状は date 、 date-time 、 time 、 duration のみが サポートされているようです 。それぞれ Python のdatetimeライブラリの date クラス、 datetime クラス、 time クラス、 timedelta クラスが対応しています 2 。 from datetime import date, datetime, time, timedelta from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel class DateTimeClasses (BaseModel): date_field: date datetime_field: datetime time_field: time duration_field: timedelta if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) request_params = t.t_schema(client, DateTimeClasses) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) 出力結果 { " properties ": { " date_field ": { " format ": " date ", " title ": " Date Field ", " type ": " STRING " } , " datetime_field ": { " format ": " date-time ", " title ": " Datetime Field ", " type ": " STRING " } , " time_field ": { " format ": " time ", " title ": " Time Field ", " type ": " STRING " } , " duration_field ": { " format ": " duration ", " title ": " Duration Field ", " type ": " STRING " } } , ... } Tips 5: その他サポートされている API のフィールドを使用する responseSchema がサポートしているフィールドは、上記で紹介した title 、 description 、 format フィールド以外にもあります。詳しくは 公式ドキュメント をご参照ください。これらのフィールドはPydanticで以下のように表現できます。 from datetime import date, datetime, time, timedelta from enum import Enum from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class EnumClass (Enum): A = "a" B = "b" C = "c" class Schema (BaseModel): number_field: int = Field(ge= 1 , le= 10 ) string_field: str = Field(min_length= 1 , max_length= 10 ) list_field: list [ int ] = Field(min_items= 1 , max_items= 10 ) with_pattern_field: str = Field(pattern= r"^[a-z]+$" ) # examplesを渡すとエラーになるので注意 with_example_field: str = Field(json_schema_extra={ "example" : "example string" }) nullable_field: str | None = Field(default= None ) any_of_field: str | int enum_field: EnumClass if __name__ == "__main__" : client = Client(vertexai= True , project= "development" , location= "global" ) request_params = t.t_schema(client, Schema) print (request_params.model_dump_json(indent= 2 , exclude_none= True )) 出力結果 { " properties ": { " number_field ": { " maximum ": 10.0 , " minimum ": 1.0 , " title ": " Number Field ", " type ": " INTEGER " } , " string_field ": { " max_length ": 10 , " min_length ": 1 , " title ": " String Field ", " type ": " STRING " } , " list_field ": { " items ": { " type ": " INTEGER " } , " max_items ": 10 , " min_items ": 1 , " title ": " List Field ", " type ": " ARRAY " } , " with_pattern_field ": { " pattern ": " ^[a-z]+$ ", " title ": " With Pattern Field ", " type ": " STRING " } , " with_example_field ": { " example ": " example string ", " title ": " With Example Field ", " type ": " STRING " } , " nullable_field ": { " nullable ": true , " title ": " Nullable Field ", " type ": " STRING " } , " any_of_field ": { " any_of ": [ { " type ": " STRING " } , { " type ": " INTEGER " } ] , " title ": " Any Of Field " } , " enum_field ": { " enum ": [ " a ", " b ", " c " ] , " title ": " EnumClass ", " type ": " STRING " } } , " property_ordering ": [ " number_field ", " string_field ", " list_field ", " with_pattern_field ", " with_example_field ", " nullable_field ", " any_of_field ", " enum_field " ] , " required ": [ " number_field ", " string_field ", " list_field ", " with_pattern_field ", " with_example_field ", " any_of_field ", " enum_field " ] , " title ": " Schema ", " type ": " OBJECT " } Tips 6: エラー回避のためのvalidatorを実装する 上記で紹介した date 型のフィールドや max_ength などはPydanticモデルの制約として働きます。例えば、 date 型のフィールドに無効な日付の文字列が代入されるとエラーになります。また、 max_length=10 と指定されているフィールドに11文字以上の文字列が渡されると同じくエラーになります。 この時、 Geminiがこれらの制約に違反した JSON を生成する可能性がある ことに注意が必要です。一定の矯正力はありますが、100%制約を守ってくれるわけではありません 3 。制約に違反したテキストが生成されたときにエラーにならないように、 生成されたテキストを加工するvalidatorを実装しておく と安全でしょう。 例えば私は、 date 型のフィールドに対して 0000-01-01 のような無効な日付をGeminiが生成するケースを観測したことがあります。この場合、以下のようなvalidatorを実装してエラーを回避すると良いでしょう。 import logging from datetime import date from typing import Any from pydantic import BaseModel, Field, ModelWrapValidatorHandler, ValidationError, field_validator class EffectiveDate (BaseModel): effective_date: date | None = Field(default= None ) @ field_validator ( "effective_date" , mode= "wrap" ) def date_parsing_validator (value: Any, handler: ModelWrapValidatorHandler[Any]) -> Any: """0000-01-01のような無効な日付をNoneに変換する""" try : return handler(value) except ValidationError as e: if "date_parsing" in (error[ "type" ] for error in e.errors()): logging.warning(f "Invalid date: {value}" ) return None else : raise e if __name__ == "__main__" : # Geminiが0000-01-01のような無効な日付を生成したと想定 effective_date = EffectiveDate.model_validate_json( '{"effective_date": "0000-01-01"}' ) print (effective_date) # WARNING:root:Invalid date: 0000-01-01 # effective_date=None Tips 7: Chain of Thoughtを意識する Chain of Thought (CoT)とは、結論だけでなく推論の過程も生成させることでLLMの性能を向上させる手法のことです。通常はプロンプトを工夫したり、専用にチューニングされたモデルを使用することでCoTを実現するのですが、 response_schema を工夫することで擬似的なCoTを実現することができます 。 例として、以下のようなPydanticモデルを定義します。 from datetime import date from pydantic import BaseModel, Field class ContractTerm (BaseModel): effective_date: date | None = Field(description= "契約期間の有効日" ) period: int | None = Field(description= "契約が持続する期間" ) expiration_date: date | None = Field(description= "契約期間の失効日" ) 欲しいのは effective_date と expiration_date だけですが、同時に period も抽出するようにしています。このようにすることで、例えば「本契約は2025年1月1日から3年間有効とする」のように契約失効日が直接的に書かれていない場合でも、事前に抽出した effective_date と period から expiration_date を計算してくれる効果が期待できます。 このように、フィールドを定義する順番を工夫したり、関連する情報を抽出するように促すことで、擬似的なCoTが期待できるでしょう。 注意事項 本記事で紹介した内容は、2025年6月時点のGemini/Vertex AIの仕様と、 google -genaiのバージョン1.19.0の仕様に基づいています。今後のアップデートによってGeminiや SDK の仕様が変更される可能性があります。実際に利用される際は、必ず公式のドキュメントをご確認いただくようお願いします。 まとめ 本記事では、GeminiのStructured outputでレスポンスの型を矯正するためのTipsをいくつか紹介しました。開発で得られた知見を全て盛り込んだら想定よりも多い文字数になってしまいました。是非開発のヒントにしていただけたら幸いです。 冒頭でも触れましたが、MNTSQではLLMを活用したプロダクトを鋭意開発中です。もしMNTSQの仕事にご興味を持っていただけたら、 ぜひお気軽にカジュアル面談でお話ししましょう! careers.mntsq.co.jp note.mntsq.co.jp tech.mntsq.co.jp この記事を書いた人 清水健 吾 MNTSQ アルゴリズム エンジニア LLMのご機嫌と格闘する日々です。 google -genaiバージョン1.19.0時点での動作です。バグであれば今後解消されるかもしれません。 ↩ 他にも Pendulum も対応しています。 ↩ どの程度の矯正力を持つかはフィールドによって異なるようです。例えば私の場合 enum フィールドに違反したケースに遭遇したことはありません。反対に min_length , max_length は矯正力が弱く、validatorの実装は必須だと思われます。 ↩
openapi-ts 導入 こんにちは、MNTSQ のソフトウェアエンジニアの森山です。今回は、 REST API の OpenAPI 3.0 から API クライアントを自動生成するまでの過程を紹介します。 実はメインのプロダクトへ TypeScript を導入できたのはつい最近のことです。 API クライアントを自動生成するまでの苦労や新たな発見が 1 つでも参考になれば嬉しく思います。 課題 API クライアントの自動生成に取り組む上で、現在の BE と FE には以下の課題がありました。 BE API フレームワーク 移行期のため、OpenAPI 2.0 と 3.0 の 2 つの API 定義ファイルが存在し、自動生成前に merge が必要。 FE TypeScript へ移行できていない JavaScript が大半。 API コールを堅牢にするための独自の機構が複雑で認知負荷が高い。 API レスポンスが class 化されているが TypeScript の型として利用できない。 自動生成の目的 型や API クライアントの自動生成の目的は以下です。 よりシンプルな API コール API の破壊的変更を検知 TypeScript の導入を加速 詳細な背景は以下の通りです。 よりシンプルな API コール 独自の機構を撤廃し、 API コール処理の認知負荷を下げたい。TypeScript の型でよりシンプルに解決できる。 API の破壊的変更を検知 既存の API コールを堅牢化する機構はランタイム上で動作します。そのため API の破壊的な変更を開発中に見逃すことがありました。開発中に 機械的 に検知できる必要がある。 TypeScript の導入を加速 型が自動生成できると以下の要因で加速できる。 TypeScript を導入したばかりで使える型が少ないが、一気に使える型が増える。 型のメンテナンス 工数 が削減できる。 過剰なプロパティを持った型が生まれない。(流用性を高める意図で生まれやすい) ライブラリの比較 以下の 3 つのライブラリが検討の対象です。 openapi-ts(採用) openapi-typescript swagger-typescript- api 結果としては 1. openapi-ts を採用しました。 次にその選定における観点と過程を説明します。 比較観点 型の流用性 API コール時の認知負荷 型の流用性 特に API のパラメータやレスポンスの型が流用しやすい形式であるか。それらの型は API コールの前後の加工処理等で参照したいことがあります。出力される型が API コールの関数のみだと、その関数の型から引数や返り値の型を抜き出す必要があるため複雑になります。 API コール時の認知負荷 API コール時のインターフェースがシンプルかどうか。 API コールのために関数や型をいくつも import したくないです。関数名を書いただけで補完が始まり実装が自然と進んでいく体験が理想です。 以下は上記の観点を具体化した比較表です。 ライブラリ api クライアントの生成 snake_case ↔ camelCase の変換 自動生成時の安定性 型の流用性 API コールに必要な import 数 endpoint の型制御 path parameter の型制御 query parameter の型制御 request body の型制御 response body の型注釈 openapi-ts ◯ ◯ ◯ ◯ △ ◯ ◯ ◯ ◯ ◯ openapi-typescript x ◯ ◯ ◯ x △ △ ◯ ◯ ◯ swagger-typescript- api ◯ ◯ x △ ◯ ◯ ◯ ◯ ◯ ◯ 各ライブラリごとにプロトタイプを実装しました。手を動かして得た発見と評価も合わせて以下に記載します。 openapi-ts メリット API のパラメータ、リク エス トの型が独立して定義されている API コールの関数を自動生成できる API クライアント(fetch, axios, …etc)を選択できる あらゆる型補完が効く デメリット API クライアントの インスタンス を API コールの関数に都度渡す API コールの際に API クライアントの インスタンス として毎回同じものを渡すのが冗長です。しかし、それ以外は観点を満たしていました。 実装例 import { typedAxios } from "./client" import { postV2Authentication } from "./generated/sdk.gen" import { getV2DocumentDocumentId } from "./generated/sdk.gen" import { getV2DocumentDiff } from "./generated/sdk.gen" // 認証 postV2Authentication( { client : typedAxios, // fetchやaxios等のAPIクライアントを毎回渡す必要がある body : { email , password } , } ) // document取得 getV2DocumentDocumentId( { client : typedAxios, path : { documentId : 1 } , } ) // user取得 getV2User( { client : typedAxios, query : { userId : 1 } , } ) requestBody の型参照も簡単です。 import { typedAxios } from "./client" import { postV2Authentication } from "~/api/openapi-ts/generated/sdk.gen" import type { PostV2AuthenticationData } from "./generated" // requestBodyの型をimport(pathパラメータ、queryパラメータも可) export const authentication = async ( { email , password } : PostV2AuthenticationData[ "body" ]) => { // ...何か前処理をしたり const response = await postV2Authentication( { client : typedAxios, body : { email , password } , } ) return response.data } openapi-typescript メリット 型のみの生成でカスタマイズ性が高い デメリット API コールの関数生成には派生ライブラリの openapi-fetch が必要( API クライアントは fetch 限定) axios を利用すると必要な import が多い API クライアントが fetch であれば有力だった可能性がありますが、現状は axios を活用しています。また型のみを生成するのは流用性が高く良いと思っていました。しかし axios に型を渡して矯正すると API コールのために必要な import が増えます。そして型の構造的に必要な型を探り当てるのが面倒でした。 実装例 import { type paths, type operations } from "./generated/schema.d" import { typedAxios } from "./client" // 認証 type Request = operations [ "postV2Authentication" ][ "requestBody" ][ "content" ][ "application/x-www-form-urlencoded" ] type Response = operations [ "postV2Authentication" ][ "responses" ][ "201" ] typedAxios.post< Response >( "/v2/authentication" , { email , password , } ) // ドキュメント取得 type Request = operations [ "getV2DocumentDocumentId" ][ "parameters" ][ "path" ] type Response = operations [ "getV2DocumentDocumentId" ][ "responses" ][ "200" ][ "content" ][ "application/json" ] typedAxios. get < Response >( `/v2/document/ ${ documentId } ` ) 上記はプレーンな axios のため import が多く、型の深堀りが必要です。 それを解消したものも実装しました。OpenAPI 3.0 の構造では HTTP メソッドと endpoint の組み合わせで欲しい API が特定できます。そのため HTTP メソッドと endpoint を渡せばパラメータやリク エス トの型を推論できる axios を実装しました。コードすべてではないですが実装の概要は把握できると思います。 渡す型を最小限にした axios // カスタマイズしたaxios const customAxios = async <M extends Methods, E extends Endpoint< M >>( { methods , endpoint , pathParams , queryParams , body , } : { methods : M endpoint : E pathParams ?: Snake2Camel < PathParams < M >> queryParams ?: Snake2Camel < QueryParams < M >> body ?: Snake2Camel < RequestBody < M , E >> } ): Promise < AxiosResponse < Snake2Camel < SuccessResponse < M , E >>>> => { const dynamicEndpoint = pathParams ? getDynamicEndpoint(endpoint, camel2SnakeDeep(pathParams)) : endpoint const snakeCaseBody = body ? camel2SnakeDeep(body) : body const response = await axios[methods]< SuccessResponse < M , E >>( ` ${ dynamicEndpoint }${ getQueryParams(queryParams) } ` , snakeCaseBody ) return { ...response, data : snake2CamelDeep(response.data), } } // 呼び出しイメージ await customAxios( { methods : "get" , endpoint : "/api/v2/document/{document_id}" , pathParams : { documentId } , } ) 呼び出し時には補完が HTTP メソッド → endpoint → パラメータと順番に絞り込まれるように推論されます。しかし見ての通り実装が複雑です。他のライブラリのように endpoint 毎に関数が生えた方が圧倒的にリーダブルです。また上記を用いて AI にコード生成を指示するとコード生成 → 型エラー → コード生成 を繰り返して徐々に正しいコードに近づけていく様子で、AI の精度が落ちるのも難点でした。 swagger-typescript- api メリット 呼び出しが最もシンプル API クライアント(fetch, axios, …etc)を選択できる デメリット JSON ファイルに特定の文字が含まれると自動生成に失敗する パラメータやレスポンスの型が流用しづらい API コールのインターフェースは最もシンプルでした。しかし 参照元 の JSON ファイルに「*( アスタリスク )」が含まれていると自動生成に失敗します。OpenAPI のコメント等には様々な文字列を使う可能性があるため運用が辛くなる印象です。またパラメータやレスポンスの型が独立して参照できません。型の取り出しが面倒でした。 型の取り出し import { typedAxios } from "./typedAxios" export const getDocument = async ( { documentId , } : // 特定のqueryパラメータが欲しい時にapiの関数から引数の型を抜き出す必要がある。 Parameters< typeof typedAxios.v2.getV2Document >[ "0" ]) => { return await typedAxios.v2.getV2Document( { documentId , } ) } 実装例 import { Api } from "./api" const typedAxios = new Api() // 認証 typedAxios.v2.postV2Authentication( { email , password , } ) // ドキュメント取得 typedAxios.v2.getV2DocumentDocumentId(documentId) ライブラリ比較まとめ 消去法的に openapi-ts を採用しました。 以下の懸念が他ライブラリのノックアウトファクターでした。 openapi-typescript 呼び出し時の認知負荷の高さ swagger-typescript- api JSON ファイルに使われている文字を気にする必要がある 型の流用性が低い 導入の前処理 冒頭にあった課題を払拭するために以下の前処理が必要です。 OpenAPI 2.0(Swagger) → OpenAPI 3.0 の変換 openapi. json の merge snake_case ↔ camelCase の変換 OpenAPI 2.0(Swagger) → OpenAPI 3.0 の変換 API クライアントの自動生成ライブラリは OpenAPI 3.0 形式であることを想定しているため、 前処理として swagger2openapi というライブラリで OpenAPI 2.0(Swagger) → OpenAPI 3.0 へ変換しました。 npx swagger2openapi swagger.json -o openapi.json 1 コマンドでキレイに 2 系 →3 系になってくれて嬉しかったです。 openapi. json の merge openapi-ts が読み込める JSON ファイルは 1 つなので、2 つの API 定義 JSON を merge します。openapi. json の中には様々なプロパティがありますが、merge したいのは以下の 2 つです。 path: 各 endpoint と HTTP method 等の情報が定義 components: 具体的な スキーマ を内包 上記を単純に merge することで欲しい json が手に入りました。 import * as openapiJson1 from "openapi-1.json" import * as openapiJson2 from "openapi-2.json" import fs from "fs" /** * openapi.jsonをマージして新規ファイルとして出力 */ const mergedJson = { ...openapiJson1, paths : { ...openapiJson1.paths, ...openapiJson2.paths, } , components : { ...openapiJson1.components, ...openapiJson2.components, } , } fs.writeFileSync( "merged.json" , JSON . stringify (mergedJson, null , 2 )) snake_case ↔ camelCase の変換 FE のコーディングスタイルが camelCase なのに対して BE は snake_case です。この乖離については API コールのパラメータ作成時やレスポンス受け取り時に変換をする必要があります。 API コール時に都度変換するのは認知負荷が高いため共通処理に含めることにしました。 共通処理に含めるデメリットとして以下があります。 変換ユーティリティの開発・メンテナンスの手間 ランタイム上の変換処理によるオーバーヘッド しかし上記よりも開発者体験の方が価値があると判断しました。 また重要なポイントとして API クライアントの自動生成前の API 定義 JSON にもケース変換を施しました。つまり API 定義 JSON の時点でパラメータやレスポンスを camelCase にしておきました。これをしないと生成後の API クライアントが型補完で snake_case を要求してしまうので type error になります。openapi. json の時点でケース変換ができると関数の引数と返り値の型としては camelCase で出力してくれます。あとは axios の interceptors に変換処理を入れるだけです。 axios の interceptors import { client } from "client.gen" // 自動生成されたaxiosのクライアント client.setConfig( { baseURL : "/" } ) /** リクエストパラメータをsnake_caseに変換 */ client.instance.interceptors.request.use(( request : InternalAxiosRequestConfig < any >) => { // snake_caseへの変換処理 return request } ) /** レスポンスデータをcamelCaseに変換 */ client.instance.interceptors. response .use(( response : AxiosResponse < any , any >) => { // camelCaseへの変換処理 return response } ) export { client } 本筋から脱線: 型の上書きでケース変換 BE が生成した 参照元 の JSON を FE の都合に合わせて変更してしまうと思わぬ不都合が起きるのではと懸念がありました。そのため型の上書き等も試してみました。 いざ試すと生成後の関数や型に対しての TypeScript 上でのケース変換はしんどいです。例えばパスパラメータの型を snake_case から camelCase に変換するだけでも後述の複雑な型が必要になります。またランタイムで実行されるコードと比較して型に対しての検証は難しいです。そのためこの複雑な実装よりかは JSON を書き換える方が妥当と考えました。 型変換の一部 type Snake2CamelString < T extends string > = T extends ` ${ infer R } _ ${ infer U } ` ? ` ${ R }${ Capitalize< Snake2CamelString < U >> } ` : T // keyをsnake_case → camelCase type Snake2Camel < T > = T extends any [] ? Snake2Camel < T [number]>[] : T extends object ? { [ K in keyof T as Snake2CamelString < string & K >]: Snake2Camel < T [K]> } : T // httpメソッド type Methods = "get" | "post" | "put" | "patch" | "delete" // endpointのURL type Endpoint < M extends Methods > = { [ Key in keyof paths ]: M extends keyof paths [Key] ? Key : never } [keyof paths] // path parameter type PathParams < M extends Methods > = { [ Key in Endpoint < M >]: M extends keyof paths [Key] ? paths [Key][M] extends { parameters : { path : infer T } } ? T extends { [ key : string ]: string | number } ? T : never : never : never } [Endpoint<M>] // 最終的に欲しいpathParamsの型。 // これ以外にもqueryParamsやrequestBody,responseBodyにも似たようでちょっと違う変換をするgenericが必要 Snake2Camel< PathParams < M >> (脱線終わり。) before / after 今までは API コールの前後にクラスを通していました。 API クライアント自動生成後は関数を呼ぶだけでシンプルです。BE で破壊的変更も type error として検知できます。 今までの呼び出しイメージ import { repositoryFactory } from "./repositoryFactory" // parameterのバリデーションやケース変換 import { DocumentEntityClass } from "./documentEntityClass" // responseのケース変換やオブジェクト化 const documentGetRequest = repositoryFactory . documentDiff . getParam () documentGetRequest . documentId = documentId const documentEntity = new DocumentEntityClass () const response = await repositoryFactory . documentDiff . get ({ documentGetRequest }) documentEntity = response . data 新しい API コール import { typedAxios } from "./client" import { getV2DocumentDocumentId } from "./generated/sdk.gen" getV2DocumentDocumentId( { client : typedAxios, path : { documentId } , } ) まとめ API 定義 JSON から型だけを出力しても、認知負荷の低い API コールの実現は難しいことが分かりました。型のみでは結局、認知負荷を下げるために共通処理に複雑さが必要になってしまいます。 共 通化 するのではなく、シンプルな成果物に変換できる機構が必要でした。頑張って共 通化 し、インターフェースがシンプルになれば実装が捗ると思っていましたが、複雑さのシワ寄せとして AI のコード生成精度に影響するという気づきも得ることができました。 また今回の選定においては早めにプロトタイプを実装したことが良かった点だと振り返って思います。やりたいことや実現したいことの中核はぼんやりありましたが、実際に手を動かしてみることで比較するべき観点や実装イメージが具体化されました。ドキュメントに記載のない思わぬ欠点を早めに検知したことも収穫でした。 ご精読ありがとうございました。こうした技術的な意思決定のプロセスや、MNTSQ の日々の開発の進め方にご興味を持っていただけた方は、ぜひお気軽にカジュアル面談でお話ししましょう。 careers.mntsq.co.jp
小ネタです。そして掲題が全てを語っています。 以下、ECS on EC2 構成の ECS サービスにおいて ECS タスクを動作させるプラットフォームとなる EC2 インスタンス を ECS コンテナ インスタンス と呼称します。これは Launching an Amazon ECS Linux container instance へ微妙に倣っての呼び方になります。 3行で ECS on EC2 構成の ECS サービスで GuardDuty ECS Runtime Monitoring を有効化する場合、ECS サービスの更新は必要ない ECS コンテナ インスタンス で GuardDuty エージェントが動作し EC2 Runtime Monitoring の要件を満たせれば、その時点で ECS Runtime Monitoring も有効になる ECS コンテナ インスタンス での EC2 Runtime Monitoring 導入には GuardDuty エージェントが要求する制約がいくつかある なお本稿が伝えたいことは末尾の 追伸 で全て事足ります。 背景 現在 MNTSQ では SRE を中心にプロダクトセキュリティの向上施策を進めており、その中で GuardDuty の利用範囲拡充も目論んでいます。 ここで白羽の矢が立ったのが GuardDuty の機能のひとつである Runtime Monitoring です。詳細は AWS 公式ドキュメント( GuardDuty Runtime Monitoring ) に譲りますが、EC2 / EKS / ECS において インスタンス やタスクの振舞いを内部から観測し、脅威となりうる挙動の検出が可能なサービスです。 サポートされるサービスは前掲ドキュメントにもある通り EKS / ECS / EC2 で、EKS 以外は MNTSQ のワークロードにも合致します。 さて Runtime Monitoring の導入ですが、この方法にはいくつかの経路があります。 Enabling GuardDuty Runtime Monitoring にその全容があり、実に多様な手法が用意されていることが伺えます。MNTSQ では 全ての AWS アカウントは AWS Organizations で管理している GuardDuty 管理用に delegated admin として設定された AWS アカウント( AWS Organizations 配下だが organizations 管理アカウントではない)が存在する 導入対象は ECS とし、EC2 は追って導入を検討する ECS Runtime Monitoring 有効化にかかる手間は最小限のものとしたい。自動導入の仕組みがあれば積極的にこれを使いたい ただし有効化にかかる影響範囲のコン トロール を行いたいので Runtime Monitoring を有効にする AWS アカウントは明示的に選択したい Runtime Monitoring を有効にする決定をした AWS アカウント内では全ての ECS クラスタ を一律対象とする という背景があり、以下の要領で有効化を進めることにしました。 Runtime Monitoring 有効化対象のアカウントは明示的に指定する Enabling Runtime Monitoring for multiple-account environments の "For selective active member accounts only" で有効化設定をする GuardDuty エージェントの導入は自動でやってもらう Managing automated security agent for Fargate (Amazon ECS only) に解説があるとおり、Runtime Monitoring 有効化設定を投入した後に ECS サービスを更新すれば自動で GuardDuty エージェントが サイドカー コンテナとして起動してくる なお、ECS on Fargate 構成において GuardDuty エージェントが サイドカー コンテナとして起動する際、 ECS タスク定義への変更は特段発生しません 。 ECS サービス / タスク定義の範囲外の箇所で aws-guardduty-agent- という接頭辞の名称をもつコンテナが自動で起動してくるようになります。詳細は How Runtime Monitoring works with Fargate (Amazon ECS only) の "GuardDuty adds a sidecar container" の節に説明があります。 さて2025年6月現在、MNTSQ で扱う ECS サービスでは ECS on Fargate ECS on EC2 の2種類の構成をとるものがあります。ECS サービスの数で言えば ECS on Fargate が圧倒的に多く、ECS on EC2 は一部用途(主に GPU を利用したい向き)で使われるのみです。 前述ドキュメントに従い ECS Runtime Monitoring の導入をすすめると、ECS on Fargate に関しては ECS サービス更新後に Reviewing runtime coverage statistics and troubleshooting issues に示される手法にて healthy(= GuardDuty エージェントが稼動し Runtime Monitoring の動作も開始した)なことが確認できるようになりました。 実際の runtime coverage 画面。伏字が多い点はご容赦ください いっぽうでこの手法では ECS on EC2 構成の ECS サービスでは coverage が unhealthy のままになってしまう という気付きも得られました。さてどうしたことでしょう。 ECS Runtime Monitoring を ECS on EC2 構成で healthy にする ECS と EC2 のそれぞれで Runtime Monitoring がどのように動作するかは以下ドキュメントに示されています。 How Runtime Monitoring works with Amazon EC2 instances How Runtime Monitoring works with Fargate (Amazon ECS only) いずれも GuardDuty エージェントが動作していることが前提で、EC2 の場合は Systemd ユニットとして、ECS の場合は サイドカー コンテナとして動作します 今回目指したい ECS on EC2 構成の場合でも Fargate ではないとはいえ ECS ではあるので、 サイドカー コンテナとして GuardDuty エージェントは動作するのではないかと考えるのは自然なはずです。少なくとも本稿筆者はそう考えました。しかしながら実際に作業をしてみると 作業 ECS on Fargate で Runtime Monitoring が healthy になった ECS on EC2 で Runtime Monitoring が healthy になった GuardDuty 側で ECS Runtime Monitoring を有効にした × × エージェントの自動導入を有効にした × × ECS タスクの入れ替えをした × × ECS サービスを更新した ○ × ECS コンテナ インスタンス で GuardDuty エージェントを動作させた ×(関係なし) ○ という格好になりました。つまり ECS on EC2 構成の場合は ECS ではなく EC2 側での Runtime Monitoring 対応が必要 という洞察が得られました。 ちなみにこのとき ECS サービス上では サイドカー コンテナとしての GuardDuty エージェントは稼動せず、EC2 インスタンス 上で動作する GuardDuty エージェントがその役目を担っている模様です。 ECS コンテナ インスタンス で GuardDuty エージェントを動作させる どこで何が必要になるかが判れば話は早いです。事前準備としては以下を参照すればよいでしょう。 How Runtime Monitoring works with Amazon EC2 instances Prerequisites for Amazon EC2 instance support 早い話が以下です。 EC2 インスタンス プロファイルで SSM の Run Command によるコマンド実行が許可されるよう権限設定を行う 新しめの Linux カーネル が利用可能な状態で EC2 インスタンス を稼動させる Linux カーネル のバージョンが見落されがちなので注意が必要です。筆者は見落しました。 ECS コンテナ インスタンス を動作させる場合、おおよそのケースでは ECS-optimized AMI が利用されると思います。MNTSQ でも例に漏れず ECS-optimized AMI を使用し ECS コンテナ インスタンス を稼動させています。この ECS-optimized AMI で上記ドキュメントに示される Linux カーネル 5.4 以上のもの *1 を使う方法を考える必要があります。 最も簡単なのは Amazon ECS-optimized Linux AMIs や Retrieving Amazon ECS-optimized Linux AMI metadata で案内されている、 Linux カーネル 5.10 を標準で使用する ECS-optimized AMI に差し替えてしまうというものでしょう。弊社でもこの差し替えを行うことで対応としました。 まとめ ECS on EC2 構成の場合は ECS ではなく EC2 側での Runtime Monitoring 対応が必要 という点、早々に気付ければ話は早かったのですが、「ECS が対象なのだから ECS 向けの有効化作業に何か抜け漏れがあるはずだ」と執着してしまい、試行錯誤をする羽目になりました。Runtime Monitoring に関する AWS 公式ドキュメントのうち ECS に言及されるものはほぼ全て ECS on Fargate が対象の模様で、ECS on EC2 構成に関しての言及が見られない点も少々難儀する箇所だったように思います。プラットフォームを自前で管理する場合の観点を今一度鍛えようと思える機会になりました。 ECS on EC2 構成で ECS Runtime Monitoring が一向に有効化できないという状況にお困りの方の一助となれば幸いです。 MNTSQ 株式会社 SRE 秋本 追伸 ECS on EC2 構成の場合は ECS ではなく EC2 側での Runtime Monitoring 対応が必要 という点、本稿筆者が本作業を行った際には先行情報となるものを見付けられず、本記事が有意義なものになると妄想していました。しかし今本記事を書きつつ探してみたところ、 Turning on Runtime Monitoring for Amazon ECS という ECS のドキュメント(GuardDuty のドキュメントではない) で ECS on EC2 向けの解説がありました。もっと早く知りたかった……。 *1 : ECS-optimized AMI は Amazon Linux 2 か Amazon Linux 2023 がベースになっているので、 Ubuntu や Debian 向けの情報として扱われている内容には触れていません
こんにちは、MNTSQで アルゴリズム エンジニアをやっている平田です。 MNTSQではAIで企業の契約業務を変革するプロダクトを開発しています。 mntsq.co.jp ところでみなさん、 MCP (Model Context Protocol)使っていますか? 2024年11月にAnthropicがMCPを提唱 してから半年しか経っていないのに、 MCP を取り巻くAIエージェント開発のエコシステムは爆発的なスピードで成長を遂げています。 (実際、この記事を書いている最中にアップデートがあって、何度か書き直しています🫠) 先日 MCP がStreamable HTTPをサポートしたため、MNTSQでも自社プロダクトへの MCP 導入を検討し始めました。 Streamable HTTPではサーバーをステートレスにできるので、 アーキテクチャ がシンプルになり、水平スケーリングが容易になります。これはMNTSQのような SaaS での MCP 活用において非常に重要です。 この記事では、具体的なアプリケーションの実装を通じて、 SaaS での利用を想定した MCP の使い方を学びます。 アプリケーションの主な要件は次の通りです: MCP サーバーをステートレスにする : Streamable HTTPでステートレスな MCP サーバーを構築します。これにより、SSE(Server-Sent Events)よりも アーキテクチャ がシンプルになり、保守性やスケーラビリティが向上します。 生成されたツールの情報を検証する : Function Callingで誤ったリソースにアクセスすることを防ぐため、ツールの自動実行を無効化して、生成されたツールの情報を検証・修正してから実行します。 Gemini API を使う : MNTSQの契約データを扱うには、非常に長いコンテキストウィンドウを持つGeminiが適しています。 LangChainを使わない : LangChainは便利ですが、実際には使用しない機能が依存関係に含まれます。依存関係の 脆弱性 や競合によるメンテナンスコストを下げるため、シンプルかつ軽量な構成を保ちます。 アプリケーションの概要 MCP サーバーで提供するツールは何でも良いので、今回はシンプルにElasticsearchをバックエンドとするRAGアプリケーションを実装します。 リポジトリ この記事で紹介する ソースコード や実行方法はすべて次の リポジトリ にあります。 github.com アーキテクチャ graph TD A[Application] -->|ツール実行| M[MCPサーバー<br/>(Streamable HTTP)] A -->|Function Calling| G[Gemini API] M -->|データ取得/検索| E[Elasticsearch] class A appClass class M mcpClass class G apiClass class E dbClass 処理の流れ sequenceDiagram participant App as Application participant Gemini as Gemini API participant MCP as MCPサーバー Note over App: 初期化 App->>MCP: セッション開始 Activate MCP App->>MCP: ツール一覧取得 (list_tools) MCP-->>App: 利用可能なツール一覧<br/>(get_indices, get_mapping, search) Note over App: 初回リクエスト App->>Gemini: コンテンツ生成リクエスト<br/>(プロンプト + ツール定義) Gemini-->>App: レスポンス loop レスポンスにfunction_callが含まれる限り Note over App: Function Calling App->>MCP: ツール実行 MCP-->>App: ツール実行結果 Note over App: 2回目以降のリクエスト App->>Gemini: 次のコンテンツ生成リクエスト<br/>(履歴 + ツール実行結果) Gemini-->>App: レスポンス end Note over App: 最終回答 App->>App: 最終回答を出力 App->>MCP: セッション終了 Deactivate MCP 例えば、「昨日の売上をカテゴリ別に集計してください。」というプロンプトに対して、次のように動作します。 セッション開始 MCP サーバーからツール一覧を取得 ツール get_indices によりインデックス一覧を取得 ツール get_mapping により kibana_sample_data_ecommerce の マッピング を取得 ツール search によりプロンプトをElasticsearchの DSL に変換して検索を実行 検索結果から回答を生成 回答例: 昨日のカテゴリ別の売上は以下の通りです。 * **Men's Clothing**: 3999.13 * **Women's Clothing**: 3924.91 * **Women's Shoes**: 3360.66 * **Men's Shoes**: 2197.89 * **Men's Accessories**: 1669.72 * **Women's Accessories**: 1292.59 解説 MCP サーバー MCP サーバーの実装は、主に MCP Python SDK の公式ドキュメントを参考にしています。 github.com MCP サーバーは、次の3つのツールを提供します。 get_indices : インデックス一覧取得 get_mapping : マッピング 取得 search : 検索 @ mcp.tool (description= "Elasticsearchで検索を実行するツール" ) def search (index: str , query_body: dict [ str , Any], ctx: Context) -> Any: logger.info( "search tool called" ) logger.debug(f "index: {index}, query: {query_body}" ) es_client: Elasticsearch = ctx.request_context.lifespan_context.es_client response = es_client.search(index=index, body=query_body) return response @ mcp.tool (description= "Elasticsearchのインデックスを取得するツール" ) def get_indices (ctx: Context) -> Any: logger.info( "get_indices tool called" ) es_client: Elasticsearch = ctx.request_context.lifespan_context.es_client response = es_client.indices.get_alias( "*" ) return response @ mcp.tool (description= "Elasticsearchで指定したインデックスのマッピングを取得するツール" ) def get_mapping (index: str , ctx: Context) -> Any: logger.info( "get_mapping tool called" ) logger.debug(f "index: {index}" ) es_client: Elasticsearch = ctx.request_context.lifespan_context.es_client response = es_client.indices.get_mapping(index=index) return response ステートレスを有効化するには FastMCP で stateless_http=True を指定します。 mcp = FastMCP(name= "SearchServer" , stateless_http= True , lifespan=lifespan) MCP サーバーは FastAPI にマウントできます。 app = FastAPI(lifespan=lifespan) app.mount( "/search" , search.mcp.streamable_http_app()) MCP クライアント MCP クライアントの実装は、主に Google Gen AI SDK ( google-genai==1.19.0 )の公式ドキュメントを参考にしています。 ai.google.dev Google Gen AI SDK の公式ドキュメントとの差分は次の3点です。 stdio_client ではなく streamablehttp_client を使う ツールの自動実行を無効化する 最終回答に至るまでFunction Callingを繰り返す Streamable HTTPを使用するため、 streamablehttp_client を使ってセッションを開始します。 async with streamablehttp_client( f "http://{mcp_server_host}:{mcp_server_port}/search/mcp/" ) as ( read_stream, write_stream, _, ): async with ClientSession(read_stream, write_stream) as session: await session.initialize() Function Callingのために、 MCP サーバーで提供されるツールの情報と、ツールの自動実行を無効化する設定をGemini API に渡します。 MCP サーバーで提供されるツールの情報は、 tools=[session] で渡します。( google-genai==1.15.0 以前は types.FunctionDeclaration オブジェクトに変換する必要がありました。) ツールの自動実行を無効化する設定は、 automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True) で渡します。無効化する理由は、主に認可の目的で、生成されたツールの情報を検証・修正してから実行するためです。 config = types.GenerateContentConfig( system_instruction=SYSTEM_INSTRUCTION, temperature= 0 , tools=[session], # type: ignore[arg-type] automatic_function_calling=types.AutomaticFunctionCallingConfig(disable= True ), ) ツールの自動実行を無効化しているので、ツールは自分で実行する必要があります。ツールの実行に関する情報(ツール名と引数)は response.candidates[0].content.parts[-1].function_call に出力されます。 Gemini API の公式ドキュメントでは parts[0] を使用していますが、次のように text と function_call が混在する場合があるため parts[-1] を使用するほうが確実です。この順番は Google が保証するものではないですが、ツールの自動実行を無効化するとユーザーの types.FunctionResponse を待ち受ける状態になるので、末尾になると考えるのが自然です。 { " parts ": [ { " text ": " 昨日の売上をカテゴリ別に集計するために、まず売上情報が含まれていそうなインデックスを特定する必要があります。 \n 利用可能なインデックスをリストアップするために、`get_indices()` を実行します。 \n " } , { " function_call ": { " args ": {} , " name ": " get_indices " } } ] } ツールは MCP サーバーが提供しているので、 MCP サーバーにリク エス トしてツールを実行します。 # NOTE : ツールの引数を修正する場合は、 `function_call.args` を直接書き換える # function_call.args["index"] = "kibana_sample_data_ecommerce" result = await session.call_tool(function_call.name, arguments=function_call.args) まとめ この記事では、RAGの実装を通じて、Gemini Function Callingと MCP Streamable HTTPの使い方を学びました。 MCP がStreamable HTTPをサポートしたおかげで、 MCP サーバーの アーキテクチャ がシンプルになりました。 MCP が JSON -RPCを採用しているおかげで、生成されたツールの情報を柔軟に検証・修正することができます。 余談ですが、実装したアプリケーションを実際に動かしてみると、「売上」に対してちゃんと kibana_sample_data_ecommerce インデックスを参照したり、 DSL を教えていないのにElasticsearchという情報だけで正しくクエリを生成したりと、Geminiの生成能力に驚かされます。 MCP を取り巻くエコシステムはまだ発展途上なので、この記事の情報もすぐ古くなってしまうかもしれませんが、みなさんのAIエージェント開発の一助となれば幸いです。 もしMNTSQの仕事にご興味を持っていただけたら、ぜひ以下のページもご覧ください。 careers.mntsq.co.jp note.mntsq.co.jp
はじめに 構成 実装してみる EventBridge EventBridge API destinationsの設定 オートスケールイベントを拾うためのEventルールを作成する Datadog Slack連携の設定 Event Monitorの設定 おわりに はじめに ECSのオートスケールは、一度設定してしまえば非常に便利ですが、人の手を離れて安定運用に乗せるまでには様々な技術的なハードルがあります。安定運用に入るまでは、現在の設定は妥当なのかを判断するため、オートスケールが発生したことを何らかの方法で人間が把握し、日々改善を重ねていくことが必要不可欠です。そこで今回は、ECSのオートスケールイベントをEventBridgeで拾い、Datadogに連携してSlack通知する仕組みを実装してみます。 構成 全体の構成は以下の通りです。ECS クラスタ やサービスを限定せずに、オートスケールイベントが発生した時に通知を行うような汎用的な仕組みを作っていきます。 オートスケールのイベントをEventBridgeで拾う Datadog Event Management API からDatadogに連携する Datadog Event Monitorにてアラートの設定を行う Slackにメッセージを送信する 実装してみる EventBridge EventBridge API destinationsの設定 EventBridgeでは、 API destinations(API送信先) を設定することによって、 AWS 内のイベントを任意の API に連携することができます。 以下のサンプルコードにて、 API destinationsおよび必要なIAMリソースを作成します。DATADOG_ API _KEYはあらかじめSecrets Managerに登録されているものとしています。 サンプルコード # IAM data "aws_iam_policy_document" "eventbridge_sts" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "events.amazonaws.com" ] } } } data "aws_iam_policy_document" "eventbridge_datadog" { statement { effect = "Allow" actions = [ "events:InvokeApiDestination" ] resources = [ aws_cloudwatch_event_api_destination.datadog.arn ] } statement { effect = "Allow" actions = [ "secretsmanager:DescribeSecret" , "secretsmanager:GetSecretValue" , ] resources = [ "arn:aws:secretsmanager:*:*:secret:events!connection/<DATADOG_API_KEYのシークレット名>/*" ] } } resource "aws_iam_role" "eventbridge_datadog" { name = "eventbridge_datadog_role" assume_role_policy = data.aws_iam_policy_document.eventbridge_sts.json } resource "aws_iam_role_policy" "eventbridge_datadog" { name = aws_iam_role.eventbridge_datadog.name role = aws_iam_role.eventbridge_datadog.id policy = data.aws_iam_policy_document.eventbridge_datadog.json } # あらかじめSecretsManagerにDATADOG_API_KEYを登録しておくこと data "aws_secretsmanager_secret_version" "datadog_api_key" { secret_id = "<DATADOG_API_KEYのシークレット名>" } # Connection resource "aws_cloudwatch_event_connection" "datadog" { name = "datadog-event-api" authorization_type = "API_KEY" auth_parameters { api_key { key = "DD-API-KEY" value = data.aws_secretsmanager_secret_version.datadog_api_key.secret_string } } } resource "aws_cloudwatch_event_api_destination" "datadog" { name = "datadog-event-api" connection_arn = aws_cloudwatch_event_connection.datadog.arn invocation_endpoint = "https://api.datadoghq.com/api/v1/events" http_method = "POST" invocation_rate_limit_per_second = 10 } EventBridgeのマネジメントコンソール左側のツリーから「 API の 送信先 」および「接続」を確認し、"datadog-event- api "のリソースが作成されていたらOKです。 オートスケールイベントを拾うためのEventルールを作成する ECSのオートスケールイベントは、一例としては以下のように、" aws .ecs"の"UpdateService"イベントを呼び元の"userIdentity"で絞ることによって判別することができます。" aws .application-autoscaling"のイベントもありますが、こちらで設定してもスケールの最大値に達した時しかトリガされないようです。ただでさえ動作確認が大変なところなので、気をつけましょう。 { " detail-type ": [ " AWS API Call via CloudTrail " ] , " source ": [ " aws.ecs " ] , " detail ": { " userIdentity ": { " invokedBy ": [ " ecs.application-autoscaling.amazonaws.com " ] } , " eventSource ": [ " ecs.amazonaws.com " ] , " eventName ": [ " UpdateService " ] } } ※今回やってはいけないイベントパターンの設定(スケール最大値に達した時しか発生しない) { " source ": [ " aws.application-autoscaling " ] " detail-type ": [ " Application Auto Scaling Scaling Activity State Change " ] } ターゲットには、先ほど作成した API destinationsを指定します。 また、拾ったイベントの json を API のリク エス トボディによしなに整形するため、 入力トランスフォーマ を以下のように設定します。 入力パスで使用したい変数をバインドし、入力テンプレートでリク エス トボディを作成しています。 ※ 入力パス { " newDesiredCount ": " $.detail.requestParameters.desiredCount ", " service ": " $.detail.requestParameters.service " } ※ 入力テンプレート { " title ": " ECS Cluster Auto Scaling ", " source_type_name ": " amazon ecs ", " alert_type ": " info ", " text ": " %%%`<service>` is auto scaled. \n New desiredCount: <newDesiredCount> %%% ", " tags ": [ " environment:development ", " source:amazon_ecs ", " aws_account:************ ", " new_desired_count:<newDesiredCount> ", " ecs_service_name:<service> " ] } これらの設定のサンプルコードです。 サンプルコード # ECSサービスのオートスケールを検知して通知するイベント resource "aws_cloudwatch_event_rule" "ecs_service_autoscale" { name = "ecs-service-autoscale" event_pattern = jsonencode ( { detail-type = [ "AWS API Call via CloudTrail" ] source = [ "aws.ecs" ] detail = { userIdentity = { invokedBy = [ "ecs.application-autoscaling.amazonaws.com" ] } eventSource = [ "ecs.amazonaws.com" ] , eventName = [ "UpdateService" ] } } ) } # API destinationsをターゲットに設定 resource "aws_cloudwatch_event_target" "ecs_service_autoscale" { rule = aws_cloudwatch_event_rule.ecs_service_autoscale.name arn = aws_cloudwatch_event_api_destination.datadog.arn role_arn = aws_iam_role.eventbridge_datadog.arn input_transformer { input_paths = { newDesiredCount = "$.detail.requestParameters.desiredCount" , service = "$.detail.requestParameters.service" } # jsonencodeが特殊文字をエスケープしてしまうので、ヒアドキュメントを使用する input_template = <<EOF { "title": "ECS Service Auto Scaling", "source_type_name": "amazon ecs", "alert_type": "info", "text": "%%%`<service>` is auto scaled.\n New desiredCount: <newDesiredCount> %%%", "tags": [ "environment:development", "source:amazon_ecs", "aws_account:**********", "event_name:ecs_service_autoscaled", "new_desired_count:<newDesiredCount>", "ecs_service_name:<service>" ] } EOF } } Eventルールが作成されたら、何らかの方法でECSサービスをオートスケールさせてみましょう。DatadogのEvent Explorer ( https :// .datadoghq.com/event/ explorer )にて以下のようなイベントが飛んできていることを確認します。"event_name:ecs_service_autoscaled "でクエリをすれば出てくるはずです。 ※ イベントがDatadogに飛んでこない時 イベントが飛ばない時は以下を行いましょう EventルールのターゲットにCloudWatchを設定 コンソールからEventルールを選択し、「ターゲット」のタブから「編集」でCloudWatchロググループを追加します。追加の権限などは不要なはずです。イベントが正しく拾えていれば、設定したロググループにログストリームが生成されているはずです。また、ロググループからイベントの詳細を確認できるので、入力テンプレートの情報を充実させたい場合などにもこちらを参照します。 Eventルールの API destinationsのターゲットにDLQを設定する コンソールからEventルールを選択し、「ターゲット」のタブから「編集」-> 「ターゲットを選択」のページに移動します。ターゲットは複数設定できますが、 API destinationsのものを選び、「追加設定」から以下のようにDLQを設定します。Datadogの API を読んだ時にエラーが返ってきていた場合は、設定したキューを確認することでレスポンスを確認できます。 Datadog Slack連携の設定 Slack通知を行うためには、あらかじめDatadogとSlack側で簡単な設定が必要です。本記事では設定方法の説明は割愛するので、公式ドキュメントを参考に設定を行ってください。 docs.datadoghq.com Event Monitorの設定 Datadogコンソールの左側のツリーから「Monitors」を選択し、遷移後画面の左上にある「+New Monitor」をクリックします。(もしくはブラウザに https :// .datadoghq.com/monitors/createを入力)作成画面では「Event」を選択します。 1. Define the search query を以下のように設定します クエリの欄に"event_name:ecs_service_autoscaled "を入力 "ecs_service_autoscaled"は入力テンプレートで独自定義したものなので、これで目的のイベントのみを拾います by句に"new_desired_count"と"ecs_service_name"を入力 サービス, スケールイベントごとに通知が飛ぶようにマルチモニター化します。また、ここでby句に指定したTagsのみが通知メッセージに埋め込み可能になります。 2. Set alert conditions を以下のように設定します Trigger when the evaluated value is " above or equal to "に設定 イベント発生を検知したいので データ点1 以上 でWarnアラートを飛ばせるように設定します Alert thresholdを適当な大きな値に設定 オートスケールイベントの通知はWarnレベルで行いたいので、Alertレベルの通知は行わないように、適当に大きな値にします。(本当はInfoレベルにしたいですが、2025/06現在ではDatadogの仕様上不可能と回答をサポートの方にいただきました) Warning thresholdを1に設定 イベントが起きる毎に通知を行いたいので、1に設定します 3. Configure notifications & automations をお好みで設定します 以下は設定例です。{{ }}で囲んである部分には、「EventのTagsに設定されている」かつ「by句で指定している」値のみ埋め込めます。通知をリッチにしたい場合は、 AWS のEventルール側で入力テンプレートの"tags"を充実させ、DatadogのEvent Monitor側のby句でも使用したいTagsを指定しましょう。また、メッセージ全体を{{#is_warning}}{{/is_warning}}で囲えば、Warn状態からの リカバリ 時の通知が飛ばなくなります。 以上で、オートスケールイベントが起きた時に、以下のようにSlackに通知が飛ぶようになりました。 おわりに とりあえずEventBridge -> Datadog -> Slackの通知ができそうだということで仕組みを作成してみましたが、思ったよりも通知がゴチャついてしまったなという印象があります。(通知タイトルに "on ecs_service_name:~"ってついてしまうなど)もしかしたら、以前紹介した Amazon Q Developer in chat applications(旧: AWS Chatbot)を使用した仕組み の方が、スマートに通知できたような気もしています。とはいえオートスケールイベントを通知するという目的は達成しており、監視・モニタリング系の管理をDatadogに集約することには運用上のメリットもあるので、そことの兼ね合いでもあると思います。ここら辺は今後のDatadogのアップデートにも期待ですね。 MNTSQ株式会社 SRE 西室
はじめに AWS Organizations にて複数の AWS アカウントを管理する場合において、各アカウントへどのようにアクセスするかは色々と検討の余地があると思います。 弊社では長らくこれを以下のような手法で運用していました。 踏み台的用途の AWS アカウントを1つ用意し、そこに作業者が使う IAM ユーザ及びスイッチ用 IAM ロールを用意 各 AWS アカウントにもスイッチ先 IAM ロールを用意 踏み台 AWS アカウント上の IAM ユーザから、作業対象 AWS アカウント上の IAM ロールへスイッチすることで、対象 AWS アカウントへアクセス 今回このあたりを全て IAM Identity Center を用いて以下のような運用に改めました。 IAM ユーザを考慮しなくてよくした IAM ロールも考慮しなくてよくした 全社的な ID 基盤(弊社では Entra ID )を IdP としての AWS への SSO ログイン及び各 AWS アカウントへのアクセスに寄せた これら作業にかかる考慮事項や実際の作業内容、設定完了後の利用感等、コード例を交えつつ解説します。 これまでの状況 構成 弊社における AWS アカウントの状況は以下の通りです。おおむね AWS のホワイトペーパー *1 に則った構成になっているはずです。 AWS Organizations で全ての AWS アカウントを一元管理 AWS アカウントの内訳は以下のとおり AWS Organizations 管理用 AWS アカウント 各 AWS アカウントへスイッチする為の IAM ユーザ / ロール等を管理する為の AWS アカウント プロダクトのワークロードが存在する AWS アカウント 開発環境 QA 環境 ステージング環境 本番環境 etc. 各種内部用途の為の AWS アカウント 全ての AWS アカウントへは 上述 2. に存在する IAM ユーザを使用し、 AWS アカウント間で IAM ロールをスイッチする構成をとっています。これを図にしたものが以下です。 スイッチロール構成。前述 2. を admin に、それ以外の AWS アカウントを member-X (X は適当な数)に、それぞれ置き換えている点に注意 課題 いっぽうで実際の運用上、弊社では以下のような課題感がありました。 IAM ユーザ管理コストが一定発生する 現存 IAM ユーザが在籍者に紐付くものか把握しておく必要がある 入退職や異動の際の IAM ユーザ管理作業が都度発生する IAM ロール(スイッチ元 / 先両方)の管理コストも一定生じる 現在は principal tag を使った制御 *2 にてスイッチ先 AWS アカウントおよび IAM ロールの制限を実施している 状況に応じ特殊な権限設定 / スイッチ先アカウント設定が必要になるケースがあり、タグ設計を検討する場合がある スイッチロール時の煩雑な操作を緩和するために外部ツールに頼る必要がある 実際に使っているもの Web ブラウザ: AWS Extended Switch Roles CLI : aws-vault 外部ツールを使うこと自体が問題なのではなく、ツールに依存性が生じる事(= コントールが難しい領域が発生する事)に課題感がある 3. については AWS が公式に提供 / メンテナンスをしているツール群のみの利用とできるのが理想です。 また 1. については、現在弊社では全社的な ID 基盤として Entra ID を使用しており、ここで管理されるユーザ情報が AWS で利用出来れば IAM ユーザを管理する必要がなくなるため、かなり嬉しくなれます。 ただし IAM ユーザのみを考慮した場合 2. がクリアできず、このあたりも吸収できるようなうまい策を考えなければなりません。 IAM Identity Center を導入する 前項で述べた課題感にほぼ対処できる策として IAM Identity Center があります。詳細はリンク先に譲るとして、これは早い話が AWS 上で利用できる ID 基盤です。 AWS Organizations とも特段複雑なことをせずとも連携できます。これを従来の IAM ユーザ / ロールの代わりに用いることで以下が達成できます。 外部 IdP をユーザ / グループの情報ソースとして利用が可能。当該ユーザを使用しての SAML 認証による SSO も実現可 IAM ユーザの管理が不要 IdP から連携されたユーザを用いることでユーザ管理の責務は IdP に移る IAM ロールの考慮も不要 IAM Identity Center 内には権限セット *3 という概念があり、どのような権限を認可するかを IAM ポリシドキュメントの形で設定可能 権限セットの効力範囲は AWS アカウント別に設定可能 実際に採った構成を以下に示します。 SSO 構成 作業 作業は以下の段取りで進める格好としました。 Entra ID と IAM Identity Center との連携設定投入 権限セットの整備 Entra ID から IAM Identity Center に連携されてきたユーザに対しての権限セット / AWS アカウント紐付け ユーザとグループが連携されているがグループは今回考慮していない。後述 1. のみ手作業での対応とし、他は Terraform にて IaC した状態で作業しています。 1. Entra ID と IAM Identity Center との連携設定投入 基本的には Configure SAML and SCIM with Microsoft Entra ID and IAM Identity Center に従うことで作業は完了となります。弊社では今回以下を前提としました。 Entra ID および AWS アカウントについては既存構成をそのまま使う Entra ID 側でのユーザ / グループへの変更が自動で IAM Identity Center へ連携されてくる構成 *4 とする 実際には前掲ドキュメント中の以下を適宜読み替えての実施としました。 Step 3: Configure and test your SAML connection Step 4: Configure and test your SCIM synchronization 本来このあたりも Terraform にて管理できるのが理想ですが、2025年5月現在 Terraform では IAM Identity Center 組織 インスタンス *5 の構成管理が出来ないため、本作業は手作業での実施としています。 2. 権限セットの整備 IAM Identity Center を利用してアクセスした各 AWS アカウント内でどういった操作を認可するかを定義するものが権限セット *6 です。利用者が IAM Identity Center 経由で各 AWS アカウントへアクセスする際に使う IAM ロール / ポリシの定義、という感覚で大方問題ないはずです(当方もその理解でいます)。 実際の定義方法も IAM Identity Center 側で事前に定義された権限セットから選択 IAM ポリシにおける AWS マネージドなものに大方対応する 既存 / 新設した IAM ポリシを割り当てる形式で設定 といった形で、概念としては IAM ロールの定義に似ています。弊社の事例では事前定義の権限セットで充分であった為、こちらを使っての設定としています。 ただしここには微妙に落とし穴があり、現在 IAM Identity Center が設定を推奨している identity-enhanced console session *7 を有効にする場合、事前定義の権限セットを素直に使うのみでは AWS アカウントへのアクセス時に HTTP 400 が発生して難儀することになります。 対処としては Granting permissions to use identity-aware console sessions で挙げられている IAM ポリシ ステートメント を権限セット側に設定すれば解決しますが、当該ドキュメントのコード例は resource に AWS アカウント ID を指定する厳格なもので、不特定多数の AWS アカウントへのアクセスが想定される IAM Identity Center とは食い合わせが悪いです。弊社では { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Action ": " sts:SetContext ", " Resource ": " * " } ] } のように、少々ゆとりを持たせた ステートメント を使うようにしました。 3. Entra ID から IAM Identity Center に連携されてきたユーザに対しての権限セット / AWS アカウント紐付け Entra ID と IAM Identity Center との連携が無事に済むと、連携対象とした Entra ID 上のユーザとグループが IAM Identity Center 上で見えるようになります。これらに権限セットと操作対象 AWS アカウントとを設定してやれば晴れて Entra ID 側情報を利用しての AWS への SSO アクセスが可能となります。 ユーザとグループのどちらに権限セット / AWS アカウント割り当て設定を行うかは運用形態によって検討の余地があると思います。弊社では ユーザ単位での設定 としました。というのも以下事情が有ったためです。 Entra ID は社内の別部門が管理しており、IAM Identity Center と Entra ID とが密結合になるような状況は避けたかった Entra ID グループ単位での権限設計ではカバーが難しい例外的な設定が求められるケースが一部にあり Entra ID ユーザ単位での設定に利があった Entra ID ユーザ個別に権限セットや AWS アカウントの設定をおこなう煩雑さはコードレベルで可能な限り吸収するようにしました。 コード例 おまたせしました。IAM Identity Center で権限セットと AWS アカウントとを IAM Identity Center ユーザに紐付けする Terraform コード例を示します。実際に運用しているコードを元にしていますが適宜フィクションを交えての内容となる点はご容赦ください。 main.tf /* AWS Organizations で管理される AWS アカウント一覧を得るためのもの */ data "aws_organizations_organization" "main" {} /* IAM Identity Center インスタンスに対し必要な設定が行われる これは Terraform リソースでの管理が難しいので、設定は AWS マネジメントコンソール上から行い、Terraform からは参照に留める */ data "aws_ssoadmin_instances" "main" {} /* Identity-aware console sessions を有効にしている場合、以下を権限セットに設定する必要がある これが無いと IAM Identity Center 経由で AWS アカウントへログインができない */ data "aws_iam_policy_document" "identity_aware_sessions" { statement { actions = [ "sts:SetContext" ] resources = [ "*" # 不特定多数の AWS アカウントにログインするのでリソースを絞るのが難しい ] } } # 権限セットに割り当てる IAM ポリシ定義を取得 data "aws_iam_policy" "main" { for_each = local.permission_sets name = each.value.managed_policy_name } # 権限セットを定義する。権限セットへの IAM ポリシの割り当てもここでやる resource "aws_ssoadmin_permission_set" "main" { for_each = local.permission_sets name = each.key description = "Allow permissions defined as AdministratorAccess" instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] # セッションを8時間維持する。ISO 8601 の記法で記載 session_duration = "PT8H" } resource "aws_ssoadmin_managed_policy_attachment" "main" { for_each = local.permission_sets instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] managed_policy_arn = data.aws_iam_policy.main [ each.key ] .arn permission_set_arn = aws_ssoadmin_permission_set.main [ each.key ] .arn } resource "aws_ssoadmin_permission_set_inline_policy" "main" { for_each = local.permission_sets inline_policy = data.aws_iam_policy_document.identity_aware_sessions.json instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] permission_set_arn = aws_ssoadmin_permission_set.main [ each.key ] .arn } /* SCIM 経由で連携された Entra ID ユーザへ権限セットと AWS アカウントとを紐付ける 紐付け関係は locals.tf を参照 */ data "aws_identitystore_user" "main" { for_each = local.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 } @$ { local.domain_name [ each.value.type ]} " } } } resource "aws_ssoadmin_account_assignment" "main" { # locals.tf 側 user_account_mapping も参照のこと for_each = tomap ( { for element in local.user_account_mapping : "$ { element.user } @$ { element.aws_account_id } " => element } ) instance_arn = tolist (data.aws_ssoadmin_instances.main.arns) [ 0 ] /* locals.tf 内で定義される local.users で admin = true が設定されている場合は Administrator 一択 そうでない場合は同 local.permission で定義されている権限セットを指定 */ permission_set_arn = ( try (each.value.admin, false ) ? aws_ssoadmin_permission_set.main [ "Administrator" ] .arn : aws_ssoadmin_permission_set.main [ local.permissions [ each.value.type ][ each.value.role ]] .arn ) principal_id = data.aws_identitystore_user.main [ each.value.user ] .id principal_type = "USER" target_id = each.value.aws_account_id target_type = "AWS_ACCOUNT" } locals.tf locals { permission_sets = { Administrator = { description = "Allow permissions defined as AdministratorAccess" managed_policy_name = "AdministratorAccess" } Developer = { description = "Allow permissions defined as PowerUserAccess" managed_policy_name = "PowerUserAccess" } ReadOnly = { description = "Allow permissions defined as ReadOnlyAccess" managed_policy_name = "ReadOnlyAccess" } } } locals { # 職責の一覧 roles = { sre = "sre" # SRE swe = "swe" # 開発(フロントエンド / バックエンド) algo = "algo" # Algo qa = "qa" # QA cre = "cre" # CRE pdm = "pdm" # PdM sales = "sales" # セールス cs = "cs" # CS } # 雇用区分の一覧 types = { employee = "employee" # 正社員 partner = "partner" # 業務委託 } /* Entra ID 上で設定されるユーザ名で使用されるドメイン部 もちろん実際に MNTSQ 内で使われるものとは一致しない */ domain_name = { employee = "example.com" partner = "partner.example.com" } } locals { environment = { production = [ /* プロダクトのワークロードが載っている AWS アカウントで本番環境として扱うべきものを列記 AWS アカウント名が期待される */ ] non_production = [ /* プロダクトのワークロードが載っている AWS アカウントで本番環境として扱う必要は無いものを列記 AWS アカウント名が期待される */ ] } aws_accounts = { # AWS Organizations 管理下にある全ての有効な AWS アカウント for account in data.aws_organizations_organization.main.accounts : account.name => account.id if account.status == "ACTIVE" } } /* 雇用区分と職責とによってどの権限セットをアサインするかを決定する 内訳 - permissions:職責と権限セットとの対応 - mappings:職責と AWS アカウントとの対応 - users:ユーザ(Entra ID 管理) / 雇用区分 / 職責の対応 */ locals { permissions = { /* 雇用区分と職責とで異なる権限セットを設定できるようにする: - 雇用区分:local.types を参照 - 職責:local.roles を参照 */ employee = { sre = "Administrator" swe = "Developer" algo = "Developer" qa = "Developer" cre = "Developer" pdm = "Developer" sales = "ReadOnly" cs = "ReadOnly" } partner = { sre = "Developer" swe = "Developer" algo = "Developer" qa = "Developer" } } mappings = { /* 職責によって IAM Identity Center 経由でアクセス可能な AWS アカウントを制御する 職責の定義は local.roles を参照 */ employee = { sre = values (local.aws_accounts) swe = concat ( local.environment.production, local.environment.non_production ) algo = concat ( local.environment.production, local.environment.non_production ) qa = concat ( local.environment.production, local.environment.non_production ) cre = concat ( local.environment.production, local.environment.non_production, ) pdm = concat ( local.environment.production, local.environment.non_production, ) sales = local.environment.production, cs = local.environment.production, } partner = { sre = local.environment.non_production, swe = local.environment.non_production algo = local.environment.non_production qa = local.environment.non_production cre = local.environment.non_production, } } users = { /* **Entra ID で** 管理されるユーザであって AWS を利用する者を宣言する ユーザ名はドメイン名を省く形で指定する ユーザ名をキーとし、値には以下をもつ map を宣言する: - 雇用区分 (type):local.types を参照 - 職責 (role):local.roles を参照 - 操作許可 AWS アカウント内で Administrator 権限が必要な場合は admin を true に設定 */ # # <名>.<姓> でユーザ名が定義されるとする "taro.sre" = { type = local.types.employee, role = local.roles.sre } , "hanako.swe" = { type = local.types.employee, role = local.roles.swe, admin = true } , "jiro.swe" = { type = local.types.partner, role = local.roles.swe } , "saburo.qa" = { type = local.types.employee, role = local.roles.qa, } , "shiro.algo" = { type = local.types.employee, role = local.roles.algo } , "goro.sales" = { type = local.types.employee, role = local.roles.sales } , } } /* IAM Identity Center 上でのユーザ / 権限セット / AWS アカウントとの紐付けには aws_ssoadmin_account_assignment というリソースを使う https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssoadmin_account_assignment このリソースはユーザと AWS アカウントを一度にそれぞれ1つしか設定できない 窮余の策として以下値をもつ list を定義し、当該リソースの for_each では "USERNAME@AWS_ACCOUNT_ID" をキーとする map として処理する: - ユーザ名 (user)(例:"hoge.fuga") - 職責 (role)(例:"sre") - 雇用区分 (type):"employee"(正社員)または "partner"(業務委託) - AWS アカウント ID (aws_account_id) 参考:https://developer.hashicorp.com/terraform/language/functions/flatten */ locals { user_account_mapping = flatten ( [ for user_key, user in local.users : [ for aws_account_id in local.mappings [ user.type ][ user.role ] : { user = user_key role = user.role type = user.type admin = try (user.admin, false ) aws_account_id = aws_account_id } ] ] ) } 使用感 Web ブラウザ IAM Identity Center のセットアップが無事に完了すると IAM Identity Center 設定時に指定した AWS アクセスポータル *8 の URL から SSO ログインが行えます。 権限セットや AWS アカウントが割り当ていない状態でもアクセスは可能ですが、 AWS アカウントへはアクセスできません。一方で両者が設定されている場合は SSO ログイン後に AWS アカウントの選択肢が表示され、設定された権限セットでもってのアクセスが可能となります。 実際に AWS アクセスポータルへアクセスした際の状況は以下のようになります。伏字が多い点はご寛容いただければと思います。 権限セット / AWS アカウント割当前 権限セット / AWS アカウント割当後 AWS アクセスポータルから各 AWS アカウントへアクセスした後はいつものマネジメントコンソール上での操作になります。アクセス先を切り替える為には AWS アクセスポータルへの都度アクセスが必要ですが、都度アクセスの面倒を避けたい向きに サードパーティ の拡張がいくつか提供されています。本稿筆者は AWS SSO Extender を使用しています *9 。 CLI 新しめの AWS CLI を使っている限りにおいて Configuring IAM Identity Center authentication with the AWS CLI に従い設定を済ませれば、以後は aws sso login --profile <プロファイル名> を都度実行することで認証が済み、 aws コマンドでの操作が可能です。これはクレデンシャル管理が必要であった IAM ユーザ利用に比べ大きなメリットと言えるでしょう。 AWS アカウントの切り替えも --profile オプションの指定ないしは 環境変数 AWS_PROFILE の宣言で済みます。 AWS CLI の設定内容については aws configure sso を使い都度設定してゆく方法もありますが、弊社では各職責毎にテンプレート化した設定ファイルを用意し、各作業者が利用している PC に設定を保存してもらう方針をとりました。 ほか Terraform に関しても AWS CLI 向けの設定が済んでいれば aws sso login が完了している前提にて特段支障なく操作が可能です。ただし Terraform 1.6.0 以前では AWS CLI 用の設定に改変を加える必要があり *10 、弊社でも当初この事象を踏みました。最終的には Terraform バージョンを最新版(作業時点で 1.11.4)にしてしまうことで対処としました。 おわりに 複数の AWS アカウントを管理する構成において IAM ロールをスイッチする運用を改め、IAM Identity Center による一元的かつ柔軟な AWS アカウント横断のアクセスを可能とするための作業とその後の所感について扱いました。 現在弊社では IAM Identity Center による AWS への SSO ログインの利用浸透を全社で行っており、これが完了した暁には個々人向けに払い出していた IAM ユーザおよびスイッチロール用の IAM ロールの削除をし、IAM Identity Center 経由での AWS アクセスに完全に移行するよう計画しています。 ユーザから見た場合に各 AWS アカウントへのアクセスが透過的になるのも IAM Identity Center 導入での嬉しさのひとつですが、IAM Identity Center 自体が IdP としての利用も可能であるという点から、SRE 主体での他のサービスへの SSO 化の試みについても着手し易くなってきました。今後は社内で独自のユーザ管理体系が存在する諸々のサービスを着実に SSO 化し、利用者の利便性向上やセキュアな体制構築に繋げてゆこうと計画しています。 IAM Identity Center と Entra ID との組み合わせ自体は然程珍しいものではなく、Web 上にも同様の事例が多数みられます。本稿がそれらのひとつとして数えられ、ひとつの事例としてお役に立てれば幸いです。 MNTSQ 株式会社 SRE 秋本 *1 : Organizing Your AWS Environment Using Multiple Accounts *2 : Controlling access to and for IAM users and roles using tags に詳細があります。弊社では IAM ユーザに所定のタグを設定し、スイッチ先 IAM ロールでは trust policy 内でタグを評価し、タグのキーと値とが上限に合致する場合にスイッチを認可する、といった設定で運用している *3 : Manage AWS accounts with permission sets *4 : 自動プロビジョニング。 Provisioning an external identity provider into IAM Identity Center using SCIM が詳しい *5 : ここ が詳しい。IAM Identity Center にはその設定や効力範囲を制御するための インスタンス という概念があり、 AWS Organizations 管理者アカウントにひもづく インスタンス を組織 インスタンス 、同メンバーアカウントにひもづく インスタンス をアカウント インスタンス という。組織 インスタンス で IdP 連携や SSO 時の設定等を管理する *6 : Manage AWS accounts with permission sets *7 : 本文中で後述している "identity-aware" の語が本記事にかかる設定作業を実際に行っていた2025年4月時点では使われていたが、2025年5月時点では "identity-enhanced" に変わっていた *8 : Using the AWS access portal *9 : 外部ツールに頼らなくしたいという動機はどこにいったのだというツッコミはその通りです…… *10 : https://sadayoshi-tada.hatenablog.com/entry/2023/10/06/001405 が詳しいです。事例公開多謝です
はじめに Redis ログについて slow ログ engine ログ 設定方針 構成 Datadog Logs 向けに slow ログを修正する インフラ構成 Datadog Logs の風景 おわりに 参考 はじめに 弊社では Web アプリケーションや非同期処理用ジョブの一時データなどを取り扱う際に ElastiCache Redis をインメモリデータベースとして利用しています。 これまで ElastiCache Redis 自体の稼動状況の把握には CloudWatch メトリクスを中心とした数値ベースの情報のみを利用していましたが、今回ここにログ情報を加えることにしました。ログ取得に際し必要となった作業について扱います。 簡単のため、以降は特に断わりなく "ElastiCache Redis" を単に "Redis" と呼びます。 Redis ログについて Redis ログには slow / engine の2種類があります。ElastiCache Redis の場合、ログのフォーマットは JSON およびプレーンテキストの2種類を選べますが、以下では JSON ログを前提とします。 なお slow ログは Datadog Logs へのログ送信に際し注意が必要 です(後述)。 slow ログ 「遅い処理」を記録するものです。詳細は Redis 公式のドキュメント を参照ください。 何をもって遅い処理とするかは ElastiCache Redis の場合、パラメタグループ内 slowlog-log-slower-than にてマイクロ秒単位で決定されます。また slow ログの収集は ElastiCache Redis 6.0 以上のバージョンから可能です。 ログ例は ElastiCache 公式ドキュメント で示される通り、以下のようになります。S3 へ保存されるログもこの形式になります。記録対象のログ1件に対し JSON オブジェクトが1件対応します。 { " CacheClusterId ": " logslowxxxxmsxj ", " CacheNodeId ": " 0001 ", " Id ": 296 , " Timestamp ": 1605631822 , " Duration (us) ": 0 , " Command ": " GET ... (1 more arguments) ", " ClientAddress ": " 192.168.12.104:55452 ", " ClientName ": " logslowxxxxmsxj## " } 各要素は以下のような意味合いになります。前掲ドキュメントの内容を意訳し、適宜補足を加えています。 CacheClusterId:Redis クラスタ 名 CacheNodeId:Redis ノード名(上記 クラスタ 内にあるはず) Id:各ログ固有の識別子 Timestamp: 当該ログが Redis 内で記録された際の 日時 Datadog でログが処理された時刻とは異なるので注意 Duration (us):ログで記録された処理に要した処理時間(マイクロ秒) Command:Redis で処理されたコマンドの内容 ClientAddress:Redis にコマンドを発行したクライアントの IP 通常これは ECS タスクなどが持つ VPC 内 IP になるはず ClientName:Redis にコマンドを発行したクライアントの名前 engine ログ Redis そのものの稼動状況を示すログになります。engine ログの収集は ElastiCache Redis 6.2 以上のバージョンから可能です。これも ElastiCache 公式ドキュメント から例を引用します。S3 側に保存される状況は slow ログと同様です。 例: { " CacheClusterId ": " xxxxxxxxxzy-engine-log-test ", " CacheNodeId ": " 0001 ", " LogLevel ": " VERBOSE ", " Role ": " M ", " Time ": " 12 Nov 2020 01:28:57.994 UTC ", " Message ": " Replica is waiting for next BGSAVE before synchronizing with the primary. Check back later " } 内容については以下となります。 CacheClusterId:Redis クラスタ 名 CacheNodeId:Redis ノード名(上記 クラスタ 内にあるはず) LogLevel:ログの重要性の度合い VERBOSE:雑多なやつ NOTICE:見ておくほうがよいやつ WARNING:真剣に見ておくほうがよいやつ Time: 当該ログが Redis 内で記録された際の 日時 Datadog でログが処理された時刻とは異なるので注意 Role:ログを出力した主体 M:プライマリノード S:レプリカ( セカンダリ )ノード C:ディスクにデータが書き出されたとき より詳細には RDB / AOF 処理が発生したとき( Redis 公式ドキュメントの "Redis persistence" が詳しい) X:フェイルオーバが発生したとき より詳細には Redis Sentinel が動いたとき( Redis 公式ドキュメントの "High availability with Redis Sentinel " が詳しい) 設定方針 既に稼動中の Redis クラスタ ( レプリケーション グループ)はそのまま利用し、ログ取得のための設定を追加で入れます。 ログの出力先については S3 バケット 及び Datadog Logs を選定しました。これは以下のような事情によります。 Datadog Logs:弊社ではログ管理(集約 / 検索 / 監視 etc.)を Datadog で一元化しており、Redis ログも Datadog 上で取り扱えると都合がよい S3:基本的には Datadog 上でログを取り扱うが、Datadog に万一の事態が生じた場合に Athena 等でログを扱えるようにしておきたい いっぽうで ElastiCache ログ取得に関する AWS のドキュメント によればログ出力先には Data Firehose と CloudWatch Logs の2つの選択肢があります。どちらが適切かという話になりますが、以下のとおり Data Firehose を使っておくのがベターなようです。 Redis ログ出力先 メリット デメリット Data Firehose ● S3 をバックアップ先に指定することで S3 へのログ転送も達成可( ドキュメント ) ● Datadog が公式にData Firehose によるログ転送を解説している( ドキュメント ) ● CloudWatch Logs へはログが飛ばない CloudWatch Logs ● S3 と Datadog Logs に加え CloudWatch Logs でのログ取扱が可能になる(CloudWatch Logs Insights が使える) ● S3 / Datadog Logs 向けに サブスクリプション フィルタと別途ログ送信用リソースの用意が必要になる よって本稿では Redis ログを Data Firehose へ出力し、Data Firehose から S3 および Datadog Logs へログを送信するような構成をとることとしました。 構成 構成図は以下の通りです。slow / engine 各ログは最終目的地が同一の S3 バケット / Datadog Logs であることから、Data Firehose ストリームは特に分けない構成としました。S3 バケット は Redis ログ用に新設したものを使います。 構成図 いきなり Data Firehose に Lambda 関数がくっつく構成となっていますが、これには事情があります。 Datadog Logs 向けに slow ログを修正する 当初、上記構成のうち Lambda 関数を設けず(つまり各ログを無加工の状態で) Redis ログを送信した際、slow ログのみ Datadog Logs でログとして取り扱えない状況となりました。Data Firehose から Datadog へのログ送信は問題無い様子だったので、Datadog がログを正しく処理できていない模様です。 トラブルシュートに難儀したのですが、ヒントは Datadog ドキュメントのうち Log Management -> Log Configuration -> Pipelines にありました。関連する箇所を同ドキュメントから要約します: JSON ログ中に Timestamp という attribute がある場合、Datadog Logs はその情報をログの時刻情報として扱う 取り扱える日付のフォーマットは ISO8601 / ミリ秒精度の UNIX 時間 / RFC3164 JSON ログ中に存在する Message の内容をログメッセージ本体として扱う ここで各ログの内容を見てみましょう。前掲のログ例を引用します。 slow ログ { "CacheClusterId": "logslowxxxxmsxj", "CacheNodeId": "0001", "Id": 296, "Timestamp": 1605631822, "Duration (us)": 0, "Command": "GET ... (1 more arguments)", "ClientAddress": "192.168.12.104:55452", "ClientName": "logslowxxxxmsxj##" } engine ログ { "CacheClusterId": "xxxxxxxxxzy-engine-log-test", "CacheNodeId": "0001", "LogLevel": "VERBOSE", "Role": "M", "Time": "12 Nov 2020 01:28:57.994 UTC", "Message": "Replica is waiting for next BGSAVE before synchronizing with the primary. Check back later" } 2ログを見比べると以下がわかります。 slow ログ Timestamp に UNIX 時間( ミリ秒精度ではない )と解釈できる値が入っている Message が無い Command に Redis へ発行されたコマンドが記録されている engine ログ Timestamp は無い Message がある engine ログが Datadog Logs で処理できているので、slow ログを engine ログと同様の内容にするのが手っ取り早いでしょう。つまり以下のようにすると光明が見えそうです。 { " CacheClusterId ": " logslowxxxxmsxj ", " CacheNodeId ": " 0001 ", " Id ": 296 , " Time ": 1605631822 , # 元 ` Timestamp ` " Duration (us) ": 0 , " Message ": " GET ... (1 more arguments) ", # 元 ` Command ` " ClientAddress ": " 192.168.12.104:55452 ", " ClientName ": " logslowxxxxmsxj## " } Data Firehose はストリーム内を流れるデータを Lambda 関数を使い処理できる機能があります。弊社では以下のような TypeScript コードを Node.js ランタイムを使う Lambda 関数として整備し、上記変換処理を行わせることにしました。 コードはこちらを参照 import { Buffer } from 'buffer' ; import { DateTime } from 'luxon' ; console . log ( 'Loading function' ); interface InputRecord { recordId : string ; data : string ; } interface Event { records : InputRecord [] ; } interface OutputRecord { recordId : string ; result : string ; data : string ; } interface Output { records : OutputRecord [] ; } export const handler = async ( event : Event ): Promise < Output > => { const output = event.records. map (( record , index ): OutputRecord => { const decoded = Buffer .from(record.data, 'base64' ). toString ( 'utf-8' ); console .log( `Decoded data: ${ decoded } ` ); /* * event として Data Firehose から受け取ったペイロードが JSON ログでない場合は後続処理をさせない * ElastiCache ログは Data Firehose へ JSON の形式で送るよう設定しているので通常はここに落ちないはず */ let parsed: any ; try { parsed = JSON . parse (decoded); } catch (error) { console .error( `Error parsing JSON for record ${ record.recordId } :` , error); return { recordId : record.recordId, result : 'Ok' , data : record.data, } ; } /* * Timestamp というエントリは Datadog Logs が処理する上で不適。以下のように直す * (参考:https://docs.datadoghq.com/logs/log_configuration/pipelines/?tab=date) * * - Time というエントリに改名 * - 元々 Timestamp として記録されていた epoch 秒を engine ログと同じような日付フォーマットの文字列に変換 * - engine ログと互換性のある内容にしたい */ if ( 'Timestamp' in parsed) { const dt = DateTime.fromSeconds(parsed.Timestamp, { zone : 'utc' } ); parsed.Time = dt.toFormat( "dd MMM yyyy HH:mm:ss.SSS 'UTC'" ); delete parsed.Timestamp; } /* * Message というエントリが無いと Datadog Logs 上でログとして受け付けてくれない * 拾いたい情報である "Command" の内容を Message に改名する * これも engine ログと同様の措置 * (参考:https://docs.datadoghq.com/logs/log_configuration/pipelines/?tab=message) */ if ( 'Command' in parsed) { parsed.Message = parsed.Command; delete parsed.Command; } console .log( `Transformed data: ${ JSON . stringify (parsed) } ` ); const encoded = Buffer .from( JSON . stringify (parsed)). toString ( 'base64' ); return { recordId : record.recordId, result : 'Ok' , data : encoded, } ; } ); console .log( `Processing completed. Successful records: ${ output. length} ` ); return { records : output } ; } ; インフラ構成 Terraform コード例を示します。実際に利用しているコードから要所を抜粋 / 省略したものとなります。 コードはこちらを参照 # Data Firehose 内でのログ処理に使用する Lambda 関数のソースコード # Terraform とは別の経路で事前に S3 バケットへアップロードしておく data "aws_s3_object" "lambda_source_archive" { bucket = var.bucket_name key = "transform-elasticache-logs/artifact.zip" } # Lambda 関数にアタッチされる IAM ロールで使用する IAM ポリシ # 当該関数内ではシンプルに JSON データをいじるのみで AWS サービスを触らない # 最低限の権限(CloudWatch Logs へのログ書き出し)をもつ IAM ポリシだけ参照できるようにする data "aws_iam_policy" "lambda_basic_role" { name = "AWSLambdaBasicExecutionRole" } # Lambda 関数本体 module "lambda_transform_elasticache_logs" { source = "terraform-aws-modules/lambda/aws" version = "7.20.2" function_name = "$ { var.env } -transform-elasticache-logs" description = "Lambda function for transforming slow log from Elasticache to appropriate format suitable for Datadog Logs" handler = "main.handler" runtime = "nodejs22.x" timeout = 60 publish = true attach_policy = true create_package = false policy = data.aws_iam_policy.lambda_basic_role.arn s3_existing_package = { bucket = var.bucket_name key = "transform-elasticache-logs/artifact.zip" version_id = data.aws_s3_object.lambda_source_archive.version_id } assume_role_policy_statements = { account_root = { effect = "Allow" actions = [ "sts:AssumeRole" ] principals = { account_principal = { type = "AWS" identifiers = [ "arn:aws:iam::$ { data.aws_caller_identity.self.account_id } :root" ] } } } } tags = { Name = "$ { var.env } -transform-elasticache-logs" } } # Data Firehose 向け principal を assume する data "aws_iam_policy_document" "elasticache_log_assuming" { statement { actions = [ "sts:AssumeRole" ] effect = "Allow" principals { type = "Service" identifiers = [ "firehose.amazonaws.com" ] } } } # Data Firehose が所定の S3 バケットにログを流し込めるようにする data "aws_iam_policy_document" "elasticache_log_permission" { statement { effect = "Allow" actions = [ "s3:AbortMultipartUpload" , "s3:GetBucketLocation" , "s3:GetObject" , "s3:ListBucket" , "s3:ListBucketMultipartUploads" , "s3:PutObject" , ] resources = [ aws_s3_bucket.main.arn, "$ { aws_s3_bucket.main.arn } /*" , ] } # ログ送信のためには CloudWatch Logs 関連の権限も必要そう statement { effect = "Allow" actions = [ "logs:CreateLogStream" , "logs:PutLogEvents" , "logs:CreateLogGroup" , ] resources = [ "*" ] } } # ElastiCache ログを Data Firehose 経由で配信する為の IAM ロール resource "aws_iam_role" "main" { name = "$ { var.env } -elasticache-log-role" assume_role_policy = data.aws_iam_policy_document.elasticache_log_assuming.json } # ElastiCache ログを Data Firehose 経由で配信する為の IAM ロールにポリシーをアタッチ resource "aws_iam_role_policy" "main" { name = "$ { var.env } -elasticache-log-policy" role = aws_iam_role.main.id policy = data.aws_iam_policy_document.elasticache_log_permission.json } # ログ処理用 Data Firehose ストリーム resource "aws_kinesis_firehose_delivery_stream" "main" { depends_on = [ aws_s3_bucket.main, ] name = "$ { var.env } -redis-log" # S3 と Datadog Logs とにログを送信する # S3 にログを送るのみであれば extended_s3_configuration が使えるが、その場合 Datadog 向けの設定と共存不可 destination = "http_endpoint" http_endpoint_configuration { url = "https://aws-kinesis-http-intake.logs.datadoghq.com/v1/input" name = "Datadog Log" # 以下 `DATADOG_API_KEY` は適切な値に置き換える access_key = DATADOG_API_KEY role_arn = aws_iam_role.main.arn # 今回は Datadog Logs と同内容のログを S3 にも置きたいという目的につき、全ログを S3 に置く設定とした s3_backup_mode = "AllData" request_configuration { content_encoding = "GZIP" # これら値を設定しておくことで Datadog Logs 上でタグが付与される common_attributes { name = "env" value = var.env } } s3_configuration { role_arn = aws_iam_role.main.arn bucket_arn = aws_s3_bucket.main.arn prefix = "redis/" } # ログ整形用 Lambda 関数定義 processing_configuration { enabled = "true" processors { type = "Lambda" parameters { parameter_name = "LambdaArn" parameter_value = module.lambda_transform_elasticache_logs.lambda_function_arn } } } } tags = { "LogDeliveryEnabled" = "true" } } # Redis クラスタ resource "aws_elasticache_replication_group" "main" { depends_on = [ aws_kinesis_firehose_delivery_stream.elasticache_log, ] replication_group_id = "$ { var.env } -redis" description = "Redis replication group" engine = var.elasticache.engine engine_version = var.elasticache.engine_version node_type = var.elasticache.instance_type port = var.elasticache.port subnet_group_name = var.elasticache.subnet_group_name parameter_group_name = aws_elasticache_parameter_group.main.name num_cache_clusters = var.elasticache.instance_num at_rest_encryption_enabled = true apply_immediately = true security_group_ids = var.elasticache.security_group_ids # slow ログ(後述) log_delivery_configuration { destination = aws_kinesis_firehose_delivery_stream.main.name destination_type = "kinesis-firehose" log_format = "json" log_type = "slow-log" } # engine ログ(後述) log_delivery_configuration { destination = aws_kinesis_firehose_delivery_stream.main.name destination_type = "kinesis-firehose" log_format = "json" log_type = "engine-log" } } # Redis パラメタグループ # ログ取得は Redis 6.x 系で利用出来る機能につき、パラメタグループも Redis 6.x 系を対象にする resource "aws_elasticache_parameter_group" "main" { name = "$ { var.env } -redis6" family = "redis6.x" /* どのくらい処理に時間が掛かれば slow な処理として slow log へ出すかを制御する マイクロ秒単位で設定する。デフォルトは 10000(= 10ミリ秒) */ parameter { name = "slowlog-log-slower-than" value = 10000 } } # ログ保存先 S3 バケット resource "aws_s3_bucket" "main" { bucket = "$ { var.env } -elasticache-log" } 必要になるリソースは以下の通りです。 当然用意が必要なもの S3 バケット Redis クラスタ ( レプリケーション グループ) Data Firehose ストリーム 注意が必要なもの Data Firehose が使用する IAM ロールと権限認可のための IAM ポリシ firehose.amazonaws.com な principal を assume できるよう設定 S3 バケット / オブジェクトを変更含め操作する為の権限を設定 Data Firehose は転送エラーを CloudWatch Logs へ記録するので CloudWatch Logs 向けの権限も設定 Redis パラメタグループ slowlog-log-slower-than というパラメタで slow ログに記録する対象となる処理時間の 閾値 を設定(マイクロ秒単位) ここで指定した時間以内で終わる処理はログに記録されないので注意 Lambda 関数 「Datadog Logs 向けに slow ログを修正する」の節で述べた通り slowlog-log-slower-than パラメタによって slow ログとしての記録がなされるか否かについては見落しがちなので注意が必要です。遅い処理が存在しない場合ログにも記録されません。Redis / Data Firehose / S3 各所の設定は問題ないはずなのに何故ログが出ないのだと悩む羽目にならないよう、適当な 閾値 を設定しておきましょう。 Datadog Logs の風景 実際に Datadog Logs 上で収集されたログをお見せできればと思います(所々伏せ字なのはご容赦ください) slow ログ engine ログ 既に扱った通り、slow / engine ログはログ中に自身が slow / engine ログである旨を示す情報を持っていません。各ログに固有の情報でログの種類を判定するのが手軽です。弊社では以下のように @Role の有無でログ判別を行うような view を Datadog Logs へそれぞれ追加し、運用しています。 slow ログ: service:aws -@Role:* engine ログ: service:aws @Role:* Data Firehose + Lambda によりログ内容の調整が可能な構成になっていることから、必要に応じてログ中へ @type: slow といったような値の盛り込みも検討できる状態ではあります。これは運用を続けてみてからの判断となるでしょう。 おわりに ここまで本稿を読んで頂き有り難うございました。Data Firehose を使用して ElastiCache Redis ログを S3 及び Datadog Logs で取り扱う為の設定について扱いました。 engine ログと slow ログとで Datadog Logs へのログ送信に際し検討すべきことに差が出るのは想定外でしたが、最終的には狙った結果を得ることが出来ました。 ログは取得を始めて直ちに何らか嬉しさが生じるようなものではありませんが、月日が経過し記録と実績が蓄積されゆく中で自ずとその価値や用途が見えてくるものとは成り得るはずです。不確定要素の多い将来への投資としての一助となれば幸いです。 MNTSQ 株式会社 SRE 秋本 参考 Log delivery - Amazon ElastiCache Send AWS Services Logs with the Datadog Amazon Data Firehose Destination Understand data delivery in Amazon Data Firehose - Amazon Data Firehose [アップデート] ElastiCache for RedisのスローログをCloudWatch LogsやKinesis Data Firehoseにパブリッシュできるようになりました! | DevelopersIO
SREチームマネージャーの藤原です。 LM Studio + Gemma 3 + Cline + VSCode の環境を自由研究的に試用したので、その報告エントリです。 モチベーション プライベートでコードを書く際も最近はClineなどを使ってLLMを使ってコーディングをすることが徐々に増えてきました。 VSCode とClineを組み合わせて外部サービスをつかってコードの変更作業を実施する場合、 何かコードの変更を依頼するたびに、財布の中身から少しずつお金が溢(こぼ)れていく感覚があるでしょう。 1回1回の額は少額とはいえ、多数回繰り返すとなかなかの金額になってきます。 会社では予算の範囲内であれば、利用できますが、個人開発の場合はなかなか躊躇してしまうこともあるでしょう。 また、先日 Google が公開したオープンなローカルLLMのGemma 3も話題になったりしています。 そこで API 課金に怯えることなくLLMを活用したコーディングができないか?ということでGemma3を使ったコーディングにチャレンジしてみました。 やったこと Clineからローカルマシンで動かしているGemma 3のモデルを利用してコードを作成させてみる。 試行に用いた環境 昨年くらいにインフルエンザにかかって熱に浮かされた際に購入したマシンで試してみます。 HP ZBook Fury 16 G9 CPU: Core i9 12950HX メモリ: 64GB GPU : NVIDIA A4500 Laptop GPU 16GB OS: Ubuntu 22.04 あらかじめ、GEMMA 3を動かす上で必要となる NVIDIA の プロプライエタリ ドライバや、CUDAは導入済みです。 また ファイアウォール 等も設定済みです。 環境構築 LM Studioの導入 ローカルLLMを動かすためのツールとしてLM Studioを導入します。 LM Studioの公式ページ にアクセスして、LM Studioをダウンロードします。 lmstudio.ai Linux 向けには、 AppImage 版が用意されており、libfuse2さえインストールされていればシングルバイナリで動作するようになっています。 Linux 向けLM Studioのダウンロード画面 ダウンロードしたAppImageファイルを実行できるようにファイルのプロパティを変更します。 ファイルに実行権限を付与しましょう。 ダウンロードしたファイルのアイコンを右クリックして、ファイルのプロパティを開きます。 アクセス権のタブを開いて、プログラムとして実行可能の チェックボックス にチェックを入れます。 ダウンロードした LM Studioのバイナリファイルのプロパティ これでLM Studioの導入は完了です。 それでは、アイコンをダブルクリックしてLM Studioを立ち上げてみましょう。 LM Studio Gemma 3の取得と設定 LM Studioの画面下部でDeveloperに設定します。 画面下部の設定項目 ウィンドウ左側の虫眼鏡アイコンを選択します。 モデルの検索画面が表示されるので、Gemma 3を検索します。 LM Studioのモデル検索画面 検索結果から Gemma3 4Bを選択してダウンロードします。 元の画面に戻りターミナル風のアイコンをクリックします。 Status: Stopped となっているトグルスイッチを切り替えるとCline等からアクセスするためのサーバーが起動します。 Settings ドロップダウンメニューから追加の設定ができます。 他のマシンからアクセスする際は ローカルネットワークでサービング を有効にします。 LM Studioのサーバー設定 次にサーバー経由で提供するモデルを読み込んでおきます。画面上部の 読み込むモデルを選択 ドロップダウンメニューを選択します。 ダウンロード済みのモデル一覧が表示されるので、Gemma 3 4B Instructを選択します。 読み込むモデルの一覧 モデルの読み込みが完了しましたが、このままではコンテキスト長が不足しているため、Cline経由で利用できません。 右側のモデルの設定画面から Load タブを開いて、コンテキスト長を設定します。 ここでは、50000を設定しました。 モデル利用の設定 設定を変更したので 変更の適用 ボタンをクリックして設定変更を反映します。 Info タブを開くと接続に必要な情報が表示されます。 LM Studioへの接続情報 VSCode (Cline)の設定 VSCode を起動し、 拡張機能 でClineを検索してインストールします。 拡張機能 の マーケットプレイス でClineを検索 VSCode の左側のClineのアイコンをクリックします。 VSCode 上のClineアイコン Clineの画面が開くので画面右上の歯車アイコンをクリックして設定画面を開きます。 Clineの画面抜粋 Clineの設定画面が表示されるので、LM Studioに接続するように設定します。 API Providerに LM Studio を選択します。 Base URLにはLM StudioでGamma 3を設定した際に表示された接続情報を設定します。 ここまで設定すると、利用可能なモデルが表示されるので gemma-3-4b-it を選択します。 Clineの設定画面 ここまで設定したら Done ボタンをクリックして設定完了です。 では試みに動かしてみましょう。 動かしてみる Clineを使って Fizz Buzz の bash スクリプト を書かせてみました。 Fizz Buzzを書かせてみる いくつかのプロンプトや何度かの指示出しを試してみましたが、残念ながら gemma-3-4b-it では正しい Fizz Buzzプログラムを出力するまでは、結構な手数が必要でした。 プロンプトの書き方についてはまだまだ改良の余地はありそうなのでこの辺りを磨いていくことで改善はできるかもしれません。 速度的には、外部のサービスを利用するよりもかなり高速に動作しました。試行錯誤する観点では、かなりストレス少なく利用できると思います。 また、(ハードがすでにあれば)電気代のみなので、その点ではコストを気にすることなく安心して利用できました。 モデルを変えた場合どうなるかを検証する目的で gemma-3-12b-it を使っての出力も試してみました。 デフォルトの設定のままでは十分な トーク ン数(Clineの要求するだけのもの)を設定することができませんでした。 一方で、 gemma-3-12b-it が動作するよう設定を変更した場合は正しく動作し、 Fizz Buzzプログラムを正しく生成することはできましたが、実用的とはいいがたい出力速度でした。 まとめ 今回は試みにGemma 3 + LM Studio + Cline + VS Code で生成AIをつかったコーディングができないかを検証してみました。 最低限は動作するところまでは確認できました。 今後もモデルの改良は続くでしょうし、将来的にはより使いやすいものになることは間違いないので、時間を見つけて新しいモデル活用なども含めて試行錯誤を重ねる価値はありそうです。