TECH PLAY

SCSKクラウドソリューション

SCSKクラウドソリューション の技術ブログ

1226

こんにちは、SCSK林です! 私が担当した某プロジェクトで、社内向けWebアプリケーションとしてS3静的ホスティングによるSPA(Single Page Application)、およびALBとLambdaによるサーバーレスバックエンドを採用しました。技術的にはモダンで、運用コストを極小化できる構成です。 しかし、ここで大きな技術的課題としてあったのが「認証機能(Authentication)」です。 そのプロジェクトでは全社的なID管理基盤(IdP)として、Microsoft Entra ID(旧Azure AD)が導入されていました。セキュリティガバナンスの観点から、AWS上に独自のIDストア(データベース)を構築することはよろしくなく、Entra IDのアカウントでログインすることが要件としてありました。 本記事では、この要件に対し Entra ID と Cognito をどう連携させセキュアなWebアプリケーションを構築したかをご紹介します。 アーキテクチャの全体像と設計思想 まず、今回構築した認証・認可フローの全体像を示します。 フロントエンド (SPA) : Amazon S3 (静的ホスティング) Auth Broker (認証ブローカー) : Amazon Cognito User Pool Identity Provider (IdP) : Microsoft Entra ID (SAML 2.0連携) バックエンド : Elastic Load Balancing + AWS Lambda   この構成における最大のポイントは、「認証の責務をCognitoに集約し、アプリケーション(フロント・バックエンド)からEntra ID固有の実装を排除したこと」です。 また、通常、SPAからバックエンドAPIを呼び出す際、トークンの有効性検証(JWTの署名チェックや期限確認)をLambda内のコードで行う必要があります。しかし、ALBの「認証(Authenticate)」アクションを利用すれば、ALB自体がCognitoと直接対話し、検証済みのユーザー情報のみをLambdaにフォワードしてくれます。 これにより、Lambdaから認証の複雑性を完全に排除し、「インフラレイヤーでセキュリティを担保する」という、より堅牢な設計を実現しました。 技術的ポイント①:SAML 2.0 と OIDC このアーキテクチャのポイントとなるのが、Cognitoによるプロトコル変換と、ALBによる認証プロセスの自動化です。 プロトコルの抽象化(SAML to OIDC) Entra ID側では、Cognito を SAML 2.0のサービスプロバイダー(SP)として登録します。ユーザーがログインを試みると、Entra IDのサインイン画面へリダイレクトされ、認証成功後にSAMLアサーション(XML)を持ってCognitoに戻ります。 CognitoはこのSAMLアサーションを解析し、AWS内で扱いやすいJWT(ID Token / Access Token)を発行します。これにより、フロントエンドエンジニアはEntra ID固有の複雑なSAML仕様を意識せず、モダンなOIDCプロトコルに基づいて開発を進めることができます。これはフロントエンド側の開発としては認証機能を抽象化し実装を容易としました。 ALBリスナールールによるゼロトラストな実装 ALBの設定では、特定のパス( /api/* など)へのリクエストに対し、Cognito User Poolによる認証を強制するルールを定義しました。 ユーザーが未認証でAPIにアクセスしようとすると、ALBが自動的にCognitoのログインエンドポイントへリダイレクトさせます。認証が完了すると、ALBはCognitoから取得したトークンを検証し、署名付きヘッダーにユーザー情報を格納してLambdaへ渡します。 この仕組みのよい点は、Lambda側でトークンの検証ロジックを1行も書かなくて済む点です。Lambdaは、このヘッダーが存在すること自体が「認証済み」の証拠として扱えるため、コードの簡素化と脆弱性排除を同時に達成できました。 技術的ポイント②:AWS Amplifyによるフロントエンド統合 フロントエンド(SPA)には AWS Amplify (JavaScript Library) を採用しました。 今回の構成では、認証の主体がCognito(かつその背後にEntra ID)であるため、AmplifyのAuthライブラリを利用することで、複雑なリダイレクト処理やセッション管理を極めて簡潔に記述できました。 技術的ポイント③:セキュリティと認可 – Entra IDグループとの連動 本システムは社内ツールであるため、利用可能なユーザーを特定のメンバーに限定する必要がありました。 グループベースの認可制御 全社員が持つEntra IDのアカウントを使いつつ、アクセス制限を行うために、Entra ID側の「セキュリティグループ」を活用しました。 Entra ID側 : 特定のグループに属するユーザーのみに、本アプリケーションへのSAMLアサーションを発行するよう設定。 Cognito側 : 受信したSAMLクレームをユーザープールの属性にマッピング。 ALB / Lambda側 : ALBから渡される署名付きヘッダー内のグループ情報を、Lambda側でチェック。 これにより、IDのライフサイクル管理(入社・退社・異動による権限変更)はすべてEntra ID側に集約され、AWS側での二重管理という運用リスクを完全に排除しました。 まとめ:ID管理の脱サイロ化がもたらす価値 今回のプロジェクトを通じて、Microsoft Entra IDと、AWSのサーバーレス技術を、CognitoとALBという「ハブ」を通じてシームレスに結合させることができました。 この構成は、以下の魅力があると思っています。 ガバナンスの強化: 認証の源泉を1つに絞り、ID管理のサイロ化を解消。 開発工数の削減: マネージドサービスの活用により、認証周りの開発・テスト工数を省力化。 最高水準のセキュリティ: インフラレイヤーでのトークン検証により、実装ミスによる漏洩リスクを構造的に排除。(バグによるセキュリティホールの排除) 技術選定において、エンタープライズが抱える組織的制約を理解した上で、極力マネージドサービスを活用することそれ自体が様々なリスクを下げ、結果として優れたアーキテクチャになることを再認識しました。 今回の構成、事例がどなたかのお役に立つと幸いです。
アバター
こんにちは、SCSK林です! 昨今のエンタープライズシステムにおいて、単一のクラウドプロバイダーで全てのワークロードが完結するケースはかなり稀だと思います。 とある案件では、「AWS上の業務データを閉域網経由でGoogle Cloudへ転送し、BigQueryで分析する」という要件に加え、オンプレミスの基幹システムとも連携が必要な「3地点接続」のネットワーク構築が必要でした。 本記事では、AWSの実装そのものではなく、全体アーキテクトの視点から、「AWS Direct Connect を他クラウドやオンプレミスと接続する際に、ハマりやすいポイントと設計の勘所」について共有します。 細かい実装の話ではないので、マルチクラウド接続を実際に設計/構築する時にはここら辺考えないといけないよな~的な目線で見ていただけると幸いです。 アーキテクチャ概要:SCNXをハブとしたハブ&スポーク構成 今回の要件において、最大の課題は「AWS、Google Cloud、オンプレミスの3地点を、いかにシンプルかつセキュアに接続するか」でした。 各拠点をフルメッシュで接続(AWS⇔Google Cloud、AWS⇔On-Prem、Google Cloud⇔On-Prem)すると、管理コストとルーティングの複雑さが指数関数的に増大します。 そこで今回は、SCSKのクラウド接続サービス「SCNX」をハブとして採用し、物理的な複雑さを抽象化しました。 AWS: AWS Direct Connect (DX) GCP: Cloud Interconnect On-Premises: 閉域網接続 Hub: SCNX (Virtual Router) ※SCNXの紹介はコチラ: https://www.scsk.jp/sp/netxdc/lp1/ 設計ポイント BGPルーティング設計 例えばActive/Standby構成を実現するためには、物理的に線を繋ぐだけでなく、BGP(Border Gateway Protocol)を用いて「どちらの道を優先するか」を論理的に制御する必要があります。 AWS Direct Connectにおいて、経路制御を行いActive/Standbyを正しく機能させるには、以下の設計が必要となります。 AWSへの流入制御 Google CloudやオンプレミスからAWSへデータを送る際、AWS側で受け取る経路をPrimaryに固定する必要があります。 ここで重要になるのが AS_PATH Prepend です。AWS側(Direct Connect Gateway)の設定において、Standby回線側のAS Path(自律システム経路)を意図的に長く見せる(Prependする)ことで、対向ルーター(SCNX/Google Cloud)に対して「こちらの道は遠回りだ」と判断させ、自然とPrimary回線が選択されるよう設計しました。 AWSからの流出制御 逆に、AWSからGoogle Cloudへデータを送る際は、AWS側で Local Preference 値を調整し、Primary回線の優先度を高く設定する必要があります。 ※参考URL: https://aws.amazon.com/jp/blogs/news/dx-trafficcontrol-osaka/ 他クラウドと接続する場合、AWSのBGP仕様(Prependの反映挙動など)を理解し、対向システム側とパラメータの整合性を取らなければ、頻繁に経路が切り替わる「フラッピング」の原因となります。 データ転送の最適化:MTUとMSSの調整 複数拠点を接続する際に考慮すべきなのがパケットサイズ(MTU)です。 AWS Direct Connectはジャンボフレーム(MTU 9001)をサポートしていますが、経路上にあるSCNXやGoogle Cloud Interconnect、あるいは途中の仮想アプライアンスでMTUが1500に制限されている場合があります。 この不一致を放置すると、ハンドシェイク(小さなパケット)は成功するのに、いざ大量のデータを転送し始めるとパケットがドロップされるという厄介な現象が発生します。 それの予防策、安全策として、TCP MSS Clamping(最大セグメントサイズの調整)を導入し、経路上の最小MTUに合わせてパケットサイズを最適化することで、安定した通信を確立することができます。 IPアドレス設計:AWS Security Groupはじめファイアウォール設定 構築・テストフェーズでありがちなのが、通信がタイムアウトする系のエラーです。 マルチクラウド環境では、IPアドレス設計が非常に重要です。AWS、Google Cloud、オンプレミスでCIDRが重複しないことはもちろん、「どの範囲のIPが、どのポートで通信してくるか」を厳密に管理し、SGのルールへ反映させるプロセスを徹底する必要があります。 また、アプリの追加要件で当初想定より広いIPレンジが後から必要になることもありがちです。 インフラ担当の皆さんは、特にクラウドサービスだと余裕を持ったIPレンジの確保をしておくことが心の余裕につながります。笑   さいごに 単一のクラウドに閉じていれば難しくないことも他クラウド、他拠点が出てくると技術的難易度が上がってしまいます。 また、往々にして担当者・担当チームがクラウドごとにわかれていて全体設計が蔑ろにされ、問題が後から噴出することもままあると思います。 そのためにも、AWSだけでなく、Google Cloudだけでなく、複数のクラウドに関する知識、知見を持っておくことが重要だと感じました。 この記事がどなたかのお役に立つと幸いです。
アバター
こんにちは、広野です。 以下の記事の続編記事です。RAG で CSV データからの検索精度向上を目指してみました。本記事は UI 編で、主にフロントエンド (React) のコードや UI の動作について記載しています。全体的なアーキテクチャやバックエンドについては前回記事をご覧ください。 Amazon Bedrock Knowledge Bases で構造化データ(CSV)を使用した RAG をつくる -アーキテクチャ編- Amazon Bedrock Knowledge Bases と Amazon S3 Vectors で構築した RAG 環境で、構造化データをデータソースにしたときの検索精度向上を目指しました。本記事はアーキテクチャ編です。 blog.usize-tech.com 2026.03.09 Amazon Bedrock Knowledge Bases で構造化データ(CSV)を使用した RAG をつくる -実装編- Amazon Bedrock Knowledge Bases と Amazon S3 Vectors で構築した RAG 環境で、構造化データをデータソースにしたときの検索精度向上を目指しました。本記事は実装編です。 blog.usize-tech.com 2026.03.23 UI は以前書いた以下の記事の UI をカスタマイズしています。 React で Amazon Bedrock Knowledge Bases ベースの簡易 RAG チャットボットをつくる [2026年1月版] UI編 AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。それを受けて、以前作成した RAG チャットボット環境をアレンジしてみました。本記事は UI 編です。 blog.usize-tech.com 2026.01.06 この記事では雑多な非構造化データ (PDF等) の中から参考となる情報やファイル名を見つけ出すことを目的としていましたが、本記事では構造化データ (CSV) から参考となる情報やその情報のメタデータを見つけ出すことを目的としています。コードはほぼ同じです。メタデータフィルタリングの機能が追加されているぐらいだと思って下さい。   やりたいこと (再掲) 以下のような架空のヘルプデスク問い合わせ履歴データ (CSV) を用意しました。 ヘルプデスク担当者が新たな問い合わせを受けたときに、似たような過去の対応履歴を引き当てられるようにしたい、というのが目的です。 LLM に、今届いた新しい問い合わせに対する回答案を提案させたい。 回答案を生成するために、自然言語で書かれた問い合わせ内容と回答内容から、意味的に近いデータを引き当てたい。 カテゴリで検索対象をフィルタしたい。その方が精度が上がるケースがあると考えられる。 LLM が回答案を提案するときには、参考にした過去対応履歴がどの問合せ番号のものか、提示させたい。その問合せ番号をキーに、生の対応履歴データを参照できるようにしたい。 以下の前提があります。 データソースとなる CSV ファイルは 1つのみ。過去の対応履歴は 1 つの CSV ファイルに収まっているということ。 つまり、データの1行が1件の問い合わせであり、その項目間には意味的なつながりがある。 まあ、ごくごく一般的なニーズではないかと思います。   前回記事のおさらい メタデータフィルタリングについて 今回のブログ記事では、簡略化のため上記のように「販売形態」と「カテゴリ」の 2 つのメタデータのみフィルタリング可能なように設計します。特定の項目でフィルタリングすることで、検索精度を向上させます。 フィルタ対象項目 販売形態(2種類: 直販, 代理店) カテゴリ(10種類: 家庭用コタツ, 家庭用テーブル, 家庭用収納棚, 家庭用チェア, 家庭用デスク, 業務用ラック, 業務用キャビネット, 業務用会議テーブル, 業務用チェア, 業務用デスク) フィルタ条件選択時の動作 両方未選択 → フィルタリングなし(全件検索) 片方のみ選択 → equals (完全一致) で単一条件検索 両方選択 → andAll で AND 条件検索、それぞれは equals (完全一致) とする つくったもの UI 一般的なチャットボット UI ですが、ユーザーのメッセージ入力欄の下に「絞り込み」という欄を追加しています。ここにあるプルダウンメニューの項目であれば、フィルタリングできるようになっています。 全件検索した例 メタデータフィルタリングを使用せず(プルダウンを選択せず)、「海外発送ができるか」を問い合わせてみました。 過去の問い合わせ履歴データから、4件が見つかりました。 参考問合せ番号のリンクを押すと、それぞれの実データを参照できます。ここでは省略しますが、以下の販売形態、カテゴリで海外発送に言及している履歴があることがわかりました。 問合せ番号 販売形態 カテゴリ AB01234650 直販 家庭用デスク AB01234636 代理店 業務用キャビネット AB01234577 代理店 業務用チェア AB01234653 直販 家庭用テーブル 以降、メタデータフィルタリングでこの検索結果を絞り込みたいと思います。 メタデータ1件でフィルタリングした例 販売形態が「代理店」でフィルタリングして、同じく「海外発送ができるか」を問い合わせた例です。 想定通り、「販売形態が代理店」の問い合わせ履歴データが 2 件、検索されました。 メタデータ2件でフィルタリングした例 販売形態が「直販」、かつカテゴリが「家庭用テーブル」でフィルタリングして、同じく「海外発送できるか」を問い合わせた例です。 想定通り、該当する問い合わせ履歴データ 1 件だけが検索されました。 参考問合せ番号のリンクを押すと、該当問い合わせ履歴の実データが見られます。確かに海外発送についての問い合わせです。   React コード この画面を提供している React のコードですが、詳細は実コードを見てください。 絞り込みのオプションはベタ書きの固定値にしており、選択したデータを Amazon API Gateway に渡しているだけです。当たり前ですが、フォーマットはバックエンドの AWS Lambda 関数で定義したものと合わせています。 AWS AppSync Events から送られてくるレスポンスの中に、citation と呼ばれる、回答の参考になったチャンクとそのメタデータが含まれます。それを元に参考問合せ番号のリンクを作成し、モーダルウィンドウで問い合わせ番号詳細を表示しています。 import { useState, useEffect, useRef } from "react"; import { Container, Grid, Box, Paper, Typography, TextField, Button, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Link, FormControl, InputLabel, Select, MenuItem } from "@mui/material"; import SendIcon from '@mui/icons-material/Send'; import { blue, grey } from '@mui/material/colors'; import { v4 as uuidv4 } from "uuid"; import { events } from "aws-amplify/data"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { inquireRagSr } from "./Functions.jsx"; import Header from "../Header.jsx"; import Menu from "./Menu.jsx"; const RagSr = (props) => { //定数定義 const groups = props.groups; const sub = props.sub; const idtoken = props.idtoken; const imgUrl = import.meta.env.VITE_IMG_URL; const channelOptions = ["直販", "代理店"]; const categoryOptions = ["家庭用コタツ", "家庭用テーブル", "家庭用収納棚", "家庭用チェア", "家庭用デスク", "業務用ラック", "業務用キャビネット", "業務用会議テーブル", "業務用チェア", "業務用デスク"]; //変数定義 const appsyncSessionIdRef = useRef(); const bedrockSessionIdRef = useRef(null); const channelRef = useRef(); const streamingRefMap = useRef(new Map()); const citationDataMap = useRef(new Map()); //state定義 const [prompt, setPrompt] = useState(""); const [conversation, setConversation] = useState([]); const [streaming, setStreaming] = useState({ text: "", refs: [] }); const [dialogOpen, setDialogOpen] = useState(false); const [selectedCitation, setSelectedCitation] = useState(null); const [filterChannel, setFilterChannel] = useState(""); const [filterCategory, setFilterCategory] = useState(""); //RAGへの問い合わせ送信関数 const putRagSr = () => { if (streaming.text) { setConversation(prev => [ ...prev, { role:"ai", text: streaming.text, ref: streaming.refs }, { role:"user", text: prompt, ref: [] } ]); streamingRefMap.current.clear(); setStreaming({ text:"", refs:[] }); } else { setConversation(prev => [...prev, { role:"user", text: prompt, ref: [] }]); } inquireRagSr(prompt, appsyncSessionIdRef.current, bedrockSessionIdRef.current, idtoken, (() => { const f = []; if (filterChannel) f.push({"販売形態": filterChannel}); if (filterCategory) f.push({"カテゴリ": filterCategory}); return f; })()); //プロンプト欄をクリア setPrompt(""); }; //content.textから問合せ内容と回答内容を抽出 const parseContent = (text) => { const inquiryMatch = text.match(/問合せ内容:\s*([\s\S]*?)(?=\n\n回答内容:|$)/); const answerMatch = text.match(/回答内容:\s*([\s\S]*?)$/); return { inquiry: inquiryMatch ? inquiryMatch[1].trim() : "", answer: answerMatch ? answerMatch[1].trim() : "" }; }; //問合せ番号クリック時の処理 const handleCitationClick = (inquiryNumber) => { const data = citationDataMap.current.get(inquiryNumber); if (data) { setSelectedCitation(data); setDialogOpen(true); } }; //Dialog閉じる処理 const handleDialogClose = () => { setDialogOpen(false); setSelectedCitation(null); }; //サブスクリプション開始関数 const startSubscription = async () => { const appsyncSessionId = appsyncSessionIdRef.current; if (channelRef.current) await channelRef.current.close(); const channel = await events.connect(`rag-stream-response/${sub}/${appsyncSessionId}`); channel.subscribe({ next: (data) => { //Bedrock Knowledge base の session id 取得 if (data.event.type === "bedrock_session") { console.log("=== Session received ==="); console.log(data.event.bedrock_session_id); bedrockSessionIdRef.current = data.event.bedrock_session_id; return; } //問い合わせに対する回答メッセージ (chunkされている) if (data.event.type === "text") { console.log("=== Message received ==="); setStreaming(s => ({ ...s, text: s.text + data.event.message })); return; } //回答に関する関連ドキュメント (citation) if (data.event.type === "citation") { console.log("=== Citation received ==="); console.log(data.event.citation); data.event.citation.forEach(ref => { const inquiryNumber = ref.metadata?.["問合せ番号"]; if (!inquiryNumber) return; if (!streamingRefMap.current.has(inquiryNumber)) { streamingRefMap.current.set(inquiryNumber, { id: inquiryNumber, label: inquiryNumber }); citationDataMap.current.set(inquiryNumber, { metadata: ref.metadata, content: ref.content }); setStreaming(s => ({ ...s, refs: Array.from(streamingRefMap.current.values()) })); } }); } }, error: (err) => console.error("Subscription error:", err), complete: () => console.log("Subscription closed") }); channelRef.current = channel; }; //セッションIDのリセット、サブスクリプション再接続関数 const resetSession = async () => { appsyncSessionIdRef.current = uuidv4(); bedrockSessionIdRef.current = null; setPrompt(""); streamingRefMap.current.clear(); citationDataMap.current.clear(); setStreaming({ text:"", refs:[] }); setConversation([]); setFilterChannel(""); setFilterCategory(""); await startSubscription(); }; //画面表示時 useEffect(() => { //画面表示時に最上部にスクロール window.scrollTo(0, 0); //Bedrockからのレスポンスサブスクライブ関数実行 appsyncSessionIdRef.current = uuidv4(); bedrockSessionIdRef.current = null; startSubscription(); //アンマウント時にチャンネルを閉じる return () => { if (channelRef.current) channelRef.current.close(); }; }, []); //Chatbot UI 会話部分 const renderMessage = (msg, idx) => ( <Box key={idx} sx={{ display: "flex", justifyContent: msg.role === "user" ? "flex-end" : "flex-start", alignItems: "flex-start", mb: 1, width: "100%", minWidth: 0 }} > {msg.role === "ai" && ( <Avatar src={`${imgUrl}/images/ai_chat_icon.svg`} alt="AI" sx={{ mr: 2, mt: 2 }} /> )} <Paper elevation={2} sx={{ p: 2, my: 1, maxWidth: "90%", minWidth: 0, wordBreak: "break-word", overflowWrap: "break-word", bgcolor: msg.role === "user" ? blue[100] : grey[100] }} > <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ p: ({node, ...props}) => <p style={{margin: 0}} {...props} />, code: ({node, inline, ...props}) => ( <code style={{whiteSpace: inline ? 'normal' : 'pre-wrap', wordBreak: 'break-word', overflowWrap: 'break-word'}} {...props} /> ) }} > {msg.text} </ReactMarkdown> {msg.ref.length > 0 && ( <> <h4>参考問合せ番号</h4> <ul style={{ paddingLeft: 20, margin: 0 }}> {msg.ref.map(s => ( <li key={s.id}> <Link component="button" variant="body2" onClick={() => handleCitationClick(s.id)} sx={{ cursor: "pointer" }} > {s.label} </Link> </li> ))} </ul> </> )} </Paper> {msg.role === "user" && ( <Avatar src={`${imgUrl}/images/human_chat_icon.svg`} alt="User" sx={{ ml: 2, mt: 2 }} /> )} </Box> ); return ( <> {/* Header */} <Header groups={groups} signOut={props.signOut} /> <Container maxWidth="lg" sx={{mt:2}}> <Grid container spacing={4}> {/* Menu Pane */} <Grid size={{xs:12,md:4}} order={{xs:2,md:1}}> {/* Sidebar */} <Menu /> </Grid> {/* Contents Pane IMPORTANT */} <Grid size={{xs:12,md:8}} order={{xs:1,md:2}} my={2}> <main> <Grid container spacing={2}> {/* Heading */} <Grid size={{xs:12}}> <Typography id="bedrocksrtop" variant="h5" component="h1" mb={3} gutterBottom>Amazon Bedrock RAG Stream Response テスト</Typography> </Grid> <Grid size={{xs:12}}> {/* Chatbot */} <Paper sx={{p:2,mb:2,width:"100%"}}> {/* あいさつ文(固定) */} {renderMessage({ role: "ai", text: "こんにちは。何かお困りですか?", ref: []}, -1)} {/* 会話履歴 */} {conversation.map((msg, idx) => renderMessage(msg, idx))} {/* 直近のレスポンス */} {streaming.text && renderMessage({ role:"ai", text: streaming.text, ref: streaming.refs }, "stream")} </Paper> {/* 入力エリア */} <Box sx={{display:"flex",gap:1}}> <TextField fullWidth multiline value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Type message here..." sx={{ flexGrow: 1 }} /> <Button variant="contained" size="small" onClick={putRagSr} disabled={!prompt} startIcon={<SendIcon />} sx={{ whiteSpace: "nowrap", flexShrink: 0 }}>送信</Button> </Box> {/* オプション */} <Box sx={{mt:1,p:2,border:"1px solid",borderColor:"divider",borderRadius:1}}> <Typography variant="subtitle2" mb={1}>絞り込み</Typography> <Box sx={{display:"flex",flexWrap:"wrap",gap:2}}> <FormControl size="small" sx={{minWidth:150}}> <InputLabel>販売形態</InputLabel> <Select value={filterChannel} label="販売形態" onChange={(e) => setFilterChannel(e.target.value)}> <MenuItem value="">すべて</MenuItem> {channelOptions.map(v => <MenuItem key={v} value={v}>{v}</MenuItem>)} </Select> </FormControl> <FormControl size="small" sx={{minWidth:150}}> <InputLabel>カテゴリ</InputLabel> <Select value={filterCategory} label="カテゴリ" onChange={(e) => setFilterCategory(e.target.value)}> <MenuItem value="">すべて</MenuItem> {categoryOptions.map(v => <MenuItem key={v} value={v}>{v}</MenuItem>)} </Select> </FormControl> </Box> </Box> {/* クリアボタン */} {(streaming.text || conversation.length > 0) && ( <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}> <Button variant="contained" size="small" onClick={resetSession}>問い合わせをクリアする</Button> </Box> )} </Grid> </Grid> </main> </Grid> </Grid> </Container> {/* Citation詳細Dialog */} <Dialog open={dialogOpen} onClose={handleDialogClose} maxWidth="md" fullWidth> <DialogTitle>問合せ詳細</DialogTitle> <DialogContent dividers> {selectedCitation && (() => { const { inquiry, answer } = parseContent(selectedCitation.content.text); const m = selectedCitation.metadata; return ( <Box> <Typography variant="subtitle2" color="text.secondary">問合せ番号</Typography> <Typography variant="body1" mb={2}>{m["問合せ番号"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">受付日時</Typography> <Typography variant="body1" mb={2}>{m["受付日時"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">完了日時</Typography> <Typography variant="body1" mb={2}>{m["完了日時"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">販売形態</Typography> <Typography variant="body1" mb={2}>{m["販売形態"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">商品番号</Typography> <Typography variant="body1" mb={2}>{m["商品番号"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">カテゴリ</Typography> <Typography variant="body1" mb={2}>{m["カテゴリ"] || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">問合せ内容</Typography> <Typography variant="body1" mb={2} sx={{ whiteSpace: "pre-wrap" }}>{inquiry || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">回答内容</Typography> <Typography variant="body1" mb={2} sx={{ whiteSpace: "pre-wrap" }}>{answer || "-"}</Typography> <Typography variant="subtitle2" color="text.secondary">ステータス</Typography> <Typography variant="body1" mb={2}>{m["ステータス"] || "-"}</Typography> </Box> ); })()} </DialogContent> <DialogActions> <Button onClick={handleDialogClose}>閉じる</Button> </DialogActions> </Dialog> </> ); }; export default RagSr; 途中、inquireRagSr という関数がありますが、axios で Amazon API Gateway をコールするだけの関数です。   まとめ いかがでしたでしょうか。 今回のシリーズ記事の肝は Amazon Bedrock Knowledge Bases データソースのカスタムチャンキングでしたが、結果をこうしてアプリの UI で確認できるようになると、本当にできていることを実感できますよね。引き続き検証して精度向上に努めます。 本記事が皆様のお役に立てれば幸いです。
アバター
こんにちは、SCSK林です! 昨今のデータ活用において、マルチクラウド環境でのデータパイプライン構築は珍しい要件ではなくなっていると思います。 今回紹介する事例でも、AWS上のシステムから発生する大量のストリームデータを、分析基盤であるGoogle Cloud(GCP)のBigQueryへリアルタイム連携するという要件がありました。 ソースとなるのは Amazon Managed Streaming for Apache Kafka (Amazon MSK)で、 当初、MSK Connectの採用を検討しましたが、最終的にはGoogle CloudのCloud Runを用いた独自Consumerの実装というアーキテクチャに落ち着きました。 本記事では、MSK Connectではなく独自実装を選択せざるを得なかった問題と、AWS-Google Cloud間を専用線でセキュアにつなぐためのアーキテクチャ設計の変遷について共有します。 初期構想:MSK Connect 当初構想していた構成は以下のとおりです。構成的にもマネージドサービスを使用しており申し分はなかったと思っています。 Source: Amazon MSK Connector: MSK Connect (Sink Connector to Google Cloud Pub/Sub) Sink : Pub/Sub(Google Cloud) Network: AWS Direct Connect と Google Cloud Partner Interconnect を使用した専用線接続 ※参考URL: https://docs.cloud.google.com/pubsub/docs/connect_kafka#convert-to-pubsub 技術検証の結果、専用線経由でのGoogle Cloudエンドポイントへの到達性や、基本的なデータ転送自体には問題がないことが確認できました。機能要件としては、MSK Connectで満たしている構成でした。 課金問題とアーキテクチャの転換 ただ、この構成ではサービス制約から以下の問題が生じました。 「1コネクタ = 1 Pub/Subトピック」の制約 今回採用しようとしたコネクタの構成上、「1つのMSK Connectリソースにつき、1つのPub/Subトピックへの連携しか定義できない」という制約がありました。 通常、Kafka Consumerであれば1つのプロセスで複数のトピックをSubscribeし、ロジックで振り分けることが容易です。しかし、MSK Connect(および該当のプラグイン)の仕様に準拠すると、連携したいトピックの数だけMSK Connect(Connector)を作成する必要がありました。初期フェーズではトピック数は10個ほどでしたが、次フェーズでは計100トピックまで拡張予定だったので、運用面からも受け入れられない状態となりました。 コストの大幅な増加 MSK Connectの課金体系は MCU (MSK Connect Unit) × 利用時間 です。 データ流量が少ないトピックであっても、コネクタを分割すれば最低1MCU分のコストが発生します。今回のシステムには多数のトピックが存在したため、それら全てに対して個別にConnectorを立ち上げると、MCUの総数がトピック数に比例して増加し、月額コストが想定以上に超過することが判明しました。 ※参考URL: https://aws.amazon.com/jp/msk/pricing/   上記問題の解決:集約による効率化 上記制約から、解決策は「1つのコンピュートリソースで多対多(N対N)の処理をさばくこと」を実現する必要がありました。 MSK Connectの採用断念: コネクタ管理の複雑さとコスト増が見合わないため。 独自実装(Cloud Run): コンテナベースのアプリケーションであれば、1つのConsumerグループで複数トピックをSubscribeし、メモリ上でPub/Subトピックへ振り分けるロジックを実装可能です。これにより、リソースを極限まで集約し、コストを圧縮できると判断しました。   最終アーキテクチャ:Cloud Run 最終的に採用したアーキテクチャは以下のとおりです。 Consumer (Google Cloud): Cloud Run上にKafka Consumerアプリをデプロイ。 Buffer (Google Cloud): 取得したデータを一度 Pub/Sub へPublish。 ETL (Google Cloud): Cloud Data Fusion が Pub/Sub からデータを読み出し、変換処理を行って BigQuery へロード。 構成のポイントは以下の3点です。 Consumer Groupの集約によるリソース効率の最大化 MSK Connect(採用検討時のコネクタ)では「1コネクタ = 1トピック」という制約があり、トピック数に比例してコネクタ(MCU)が線形に増加する構造でした。これに対し、Cloud Runを用いた独自実装では、1つのコンテナアプリケーション(Consumer Group)で複数のトピックをまとめてSubscribeする方式を採用しました。 Before (MSK Connect案): トピックごとにコネクタプロセスが起動。データ流量が少ないトピックでも最低限のMCUリソースを占有し、コスト効率が極めて悪い。 After (Cloud Run案): 1つのConsumerアプリで複数のトピックをSubscribe。メモリ空間を共有しながら効率的にメッセージを処理し、Cloud RunのCPU使用率ベースでオートスケールさせることで、リソースの余剰が少なくなるようにしました。 ※今回はGoogle CloudのCloud Runで実装しましたが、AWS上での実装でもよいと思います。 Pub/Subをバッファとした「疎結合」なパイプライン もう一つの重要な設計判断は、Consumerアプリ(Cloud Run)から直接BigQueryへ書き込まず、必ず Google Cloud Pub/Sub を挟む構成にしたことです。これにより、システムを「データ取得層」と「データ加工・ロード層」に明確に分離(疎結合化)しました。 責務の分離: Cloud Run (Consumer): 「MSKからデータを取り出し、Pub/Subへ投げる」ことだけに集中。データの変換ロジックやBigQueryのスキーマ定義を持たないため、軽量かつステートレスに保たれます。 Data Fusion (ETL): Pub/SubからデータをPullし、複雑な変換を行ってBigQueryへロード。 耐障害性の向上: 仮にBigQueryやData Fusion側で障害や遅延が発生しても、データはPub/Subに滞留(バッファリング)するだけです。Cloud Run(Consumer)は影響を受けず、AWS MSKからのデータ取得を継続できます。これにより、「AWS側のログ保持期間切れ(データロスト)」のリスクを最小限に抑える設計としました。 AWS-Google Cloud間のセキュアな接続 このアーキテクチャを支えるネットワークは、AWS Direct ConnectとGoogle Cloud Partner Interconnectを結ぶ専用線です。 AWS側のSecurity Groupでは、Google Cloud Cloud RunがデプロイされているサブネットからのInboundのみを許可し、かつConsumer Groupの集約によって接続元IPの管理もシンプルになりました。   まとめ 今回の最終的な構成は、おそらく初期検討段階では確実に外される構成だと思います。 機能的には要件を満たしていても、高トラフィック環境下、エンタープライズ環境ではコストがボトルネックになる場合があります。AWSの課金体系を深く理解し、全体的に適切な構成を選択していくことの重要性を改めて認識しました。 今回の構成、事例がどなたかのお役に立つと幸いです。
アバター
こんにちは、広野です。 以下の記事の続編記事です。RAG で CSV データからの検索精度向上を目指してみました。本記事は実装編で、主にバックエンドの設定について記載しています。UI や実際の動作については続編記事の UI 編で紹介します。 Amazon Bedrock Knowledge Bases で構造化データ(CSV)を使用した RAG をつくる -アーキテクチャ編- Amazon Bedrock Knowledge Bases と Amazon S3 Vectors で構築した RAG 環境で、構造化データをデータソースにしたときの検索精度向上を目指しました。本記事はアーキテクチャ編です。 blog.usize-tech.com 2026.03.09   やりたいこと (再掲) 以下のような架空のヘルプデスク問い合わせ履歴データ (CSV) を用意しました。 ヘルプデスク担当者が新たな問い合わせを受けたときに、似たような過去の対応履歴を引き当てられるようにしたい、というのが目的です。 LLM に、今届いた新しい問い合わせに対する回答案を提案させたい。 回答案を生成するために、自然言語で書かれた問い合わせ内容と回答内容から、意味的に近いデータを引き当てたい。 カテゴリで検索対象をフィルタしたい。その方が精度が上がるケースがあると考えられる。 LLM が回答案を提案するときには、参考にした過去対応履歴がどの問合せ番号のものか、提示させたい。その問合せ番号をキーに、生の対応履歴データを参照できるようにしたい。 以下の前提があります。 データソースとなる CSV ファイルは 1つのみ。過去の対応履歴は 1 つの CSV ファイルに収まっているということ。 つまり、データの1行が1件の問い合わせであり、その項目間には意味的なつながりがある。 まあ、ごくごく一般的なニーズではないかと思います。   関連記事 以前、私が公開した Amazon Bedrock Knowledge Bases や Amazon S3 Vectors を使用した RAG 基盤の記事です。今回はこの基盤のチャンキング戦略をカスタマイズして臨みました。 React で Amazon Bedrock Knowledge Bases ベースの簡易 RAG チャットボットをつくる [2026年1月版] アーキテクチャ概要編 AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。それを受けて、以前作成した RAG チャットボットをアレンジしてみました。 blog.usize-tech.com 2026.01.06   本記事の言及範囲 RAG そのものや、RAG 基盤については本記事では語りません。 以下のアーキテクチャ図の中の、赤枠の部分に着目します。ベクトルデータを格納するまでのデータソースのカスタムチャンキングと、それを実装した Amazon Bedrock Knowledge Bases にどう問い合わせするか、です。   アーキテクチャ (再掲) 前回記事で紹介した、カスタムチャンキングを実装するアーキテクチャです。 実装 Amazon Bedrock Knowledge Bases カスタムチャンキングの一連の処理は、Amazon Bedrock Knowledge Bases で行われます。各種設定の全体像は AWS マネジメントコンソールの画面で一望できます。 カスタムチャンキングを AWS Lambda 関数に処理させるので、当然 Lambda 関数が必要です。(内容は後述) Lambda 関数が出力するチャンク分割後のデータ (中間成果物の JSON) を保存する S3 バケットが必要です。これはオリジナルのドキュメント配置用 S3 バケットとは別にする必要があります。 チャンキング戦略は NO にします。NO にすると、オリジナルのドキュメントの内容をそのまま Lambda 関数に渡してくれます。別の戦略を選択すると、その戦略によってチャンク分割されたデータごとに Lambda 関数を実行してしまうので、期待するカスタムチャンク分割ができなくなります。 解析戦略は Default にします。 チャンキング戦略と解析戦略は、データソース作成後には変更できません。変更したいときは作り直しになります。 データ削除ポリシーは DELETE にすることをお勧めします。同期をかけたときに過去のデータを残すかどうかの設定で、残してしまうと古い情報が検索に引っ掛かってしまいます。 AWS Lambda 関数 (カスタムチャンキング) カスタムチャンキングする Lambda 関数コード (Python) です。 冒頭に紹介した CSV を、Amazon Bedrock Knowledge Bases が理解できるフォーマットの JSON データに変換します。内部的には 1 チャンクごとに自然言語で検索させたいデータとメタデータに分けて出力します。 Lambda レイヤーは不要です。モジュールは Lambda 標準でサポートしているものだけで実装可能でした。  インプットとなる S3 バケット内の CSV データのメタデータは、Amazon Bedrock Knowledge Bases がこの Lambda 関数を呼び出すときに渡してくれるので、こちらが特に気にすることはありません。受け取ったバケット名、キーから CSV データを取得しに行きます。出力先となる S3 バケットやキー名も Amazon Bedrock Knowledge Bases から渡されますのでこの関数内でベタ書きすることはありません。 データフォーマットの変換処理の内容的には、そんなに難しいことはしていません。大事なのは出力フォーマットです。 import json import csv import boto3 from io import StringIO s3 = boto3.client('s3') def lambda_handler(event, context): try: bucket_name = event.get('bucketName') input_files = event.get('inputFiles', []) output_files = [] for file_info in input_files: original_file_location = file_info.get('originalFileLocation', {}) s3_location = original_file_location.get('s3Location', {}) original_uri = s3_location.get('uri', '') content_batches = file_info.get('contentBatches', []) output_batches = [] for batch in content_batches: input_key = batch.get('key') # Read input file from S3 response = s3.get_object(Bucket=bucket_name, Key=input_key) input_content = json.loads(response['Body'].read().decode('utf-8')) # Extract CSV content csv_content = input_content['fileContents'][0]['contentBody'] # Remove BOM if present (input may have BOM) if csv_content.startswith('\ufeff'): csv_content = csv_content[1:] csv_reader = csv.DictReader(StringIO(csv_content)) # Process each row as a chunk file_contents = [] for row in csv_reader: content_body = f"問合せ番号: {row.get('問合せ番号', '')}\n商品番号: {row.get('商品番号', '')}\n\n問合せ内容:\n{row.get('問合せ内容', '')}\n\n回答内容:\n{row.get('回答内容', '')}" content_metadata = { "問合せ番号": row.get('問合せ番号', ''), "販売形態": row.get('販売形態', ''), "受付日時": row.get('受付日時', ''), "完了日時": row.get('完了日時', ''), "商品番号": row.get('商品番号', ''), "カテゴリ": row.get('カテゴリ', ''), "ステータス": row.get('ステータス', '') } file_contents.append({ "contentBody": content_body, "contentType": "TEXT", "contentMetadata": content_metadata }) # Write output file to S3 output_key = input_key.replace('.json', '_transformed.json') output_data = {"fileContents": file_contents} s3.put_object( Bucket=bucket_name, Key=output_key, Body=json.dumps(output_data, ensure_ascii=False), ContentType='application/json' ) output_batches.append({"key": output_key}) output_files.append({ "originalFileLocation": original_file_location, "fileMetadata": file_info.get('fileMetadata', {}), "contentBatches": output_batches }) return {"outputFiles": output_files} except Exception as e: print(f"Error: {str(e)}") import traceback traceback.print_exc() raise チャンク分割された後のデータ構造 (再掲) Lambda 関数がチャンク分割した後のデータ構造 (上のアーキテクチャ図では 5番の処理によって作成されるもの) は、以下のようになります。 { "fileContents": [ { "contentBody": "問合せ番号: AB01234569\n商品番号: SH001-01BL\n\n問合せ内容:\n[問合せ内容の文章]\n\n回答内容:\n[回答内容の文章]", "contentType": "TEXT", "contentMetadata": { "問合せ番号": "AB01234569", "販売形態": "代理店", "受付日時": "2026/2/23 12:59", "完了日時": "2026/2/23 13:39", "商品番号": "SH001-01BL", "カテゴリ": "家庭用収納棚", "ステータス": "完了" } }, { "contentBody": "問合せ番号: AB01234573\n商品番号: TB19541\n\n問合せ内容:\n[問合せ内容の文章]\n\n回答内容:\n[回答内容の文章]", "contentType": "TEXT", "contentMetadata": { "問合せ番号": "AB01234573", "販売形態": "直販", "受付日時": "2026/2/24 9:15", "完了日時": "2026/2/24 14:30", "商品番号": "TB19541", "カテゴリ": "家庭用テーブル", "ステータス": "完了" } } ] } fileContents 配列の各要素が 1 チャンク(CSV の 1 行に相当) contentBody がベクトル化・検索対象にできるテキスト contentMetadata が引用表示やフィルタリングに使用されるメタデータ ※contentBody ももちろん引用可能 ここまで実装できると、Amazon Bedrock Knowledge Bases に対して contentBody に書かれた内容に対して自然言語で検索できたり、検索時にメタデータの項目単位でフィルタリングできるようになります。 メタデータフィルタリングについて Amazon Bedrock Knowledge Bases ができてしまえば、自然言語による問い合わせは RetrieveAndGenerate API を使用して極論プロンプトさえ送ればいいので、難しいことはありません。しかし、メタデータフィルタリング機能を追加すると、設計次第ではコードが複雑になります。 ここで、メタデータフィルタリングについて仕様を説明します。 メタデータ条件にマッチするチャンクのみにベクトル検索を行うため、不要な結果を排除することができ、検索精度の向上が期待できる。 メタデータ項目に対してかけられる文字列検索条件は、完全一致や、指定した文字列を含む、などいろいろできる。Amazon S3 Vectors でサポートしている条件は以下公式ドキュメントを参照。 メタデータ項目は、複数項目を And や Or で組み合わせることが可能。 メタデータフィルタリング - Amazon Simple Storage Service メタデータフィルタリングを使用して、ベクトルにアタッチされた特定の属性に基づいてクエリ結果を絞り込む方法について説明します。 docs.aws.amazon.com つまり、かなり細かいフィルタリングができるということです。 以下にメタデータフィルタリングを設定するときの Lambda 関数コードの一部を紹介します。 単一のメタデータ条件 「販売形態が代理店で完全一致」でフィルタリングしたいとき retrievalConfiguration={ "vectorSearchConfiguration": { "filter": { "equals": { "key": "販売形態", "value": "代理店" } } } } 複数のメタデータ条件 「販売形態が代理店で完全一致」かつ「カテゴリが家庭用コタツで完全一致」でフィルタリングしたいとき retrievalConfiguration={ "vectorSearchConfiguration": { "filter": { "andAll": [ { "equals": { "key": "販売形態", "value": "代理店" } }, { "equals": { "key": "カテゴリ", "value": "家庭用コタツ" } } ] } } } 見てもらえるとわかると思いますが、複数のメタデータ条件では 2 つの equals 条件を andAll で囲んでいると思います。上記はまだシンプルですが、複数の条件が重なれば重なるほど、このような階層構造をさらにコーディングしなければなりません。Or 条件も可能とすると、さらに複雑になりそうです。 今回のブログ記事では、簡略化のため上記のように「販売形態」と「カテゴリ」の 2 つのメタデータのみフィルタリング可能なように設計します。 フィルタ対象項目 販売形態(2種類: 直販, 代理店) カテゴリ(10種類: 家庭用コタツ, 家庭用テーブル, 家庭用収納棚, 家庭用チェア, 家庭用デスク, 業務用ラック, 業務用キャビネット, 業務用会議テーブル, 業務用チェア, 業務用デスク) フィルタ条件選択時の動作 両方未選択 → フィルタリングなし(全件検索) 片方のみ選択 → 選択したキーワードに完全一致で単一条件検索 両方選択 → AND 条件検索、それぞれ選択したキーワードに完全一致とする AWS Lambda 関数 (ナレッジベースへの問い合わせ) 前述のメタデータフィルタリング機能を実装した、Amazon Bedrock Knowledge Bases の RetrieveAndGenerate API をコールする AWS Lambda 関数コードは以下のようになります。 Amazon API Gateway REST API から呼び出され、AWS AppSync Events にストリームレスポンスを返す構成です。コメントで メタデータフィルタリングの組み立て と書いてある部分が先ほど説明した部分の実装です。 インプットとして "filters": [ {"販売形態": "代理店"}, {"カテゴリ": "家庭用コタツ"} ] のようなメタデータフィルタリングパラメータを受け取る想定です。条件が2つあれば andAll で囲う処理を実装しています。 import os import json import boto3 import urllib.request from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest # common objects and valiables session = boto3.session.Session() bedrock_agent = boto3.client('bedrock-agent-runtime') endpoint = os.environ['APPSYNC_API_ENDPOINT'] model_arn = os.environ['MODEL_ARN'] knowledge_base_id = os.environ['KNOWLEDGE_BASE_ID'] region = os.environ['REGION'] service = 'appsync' headers = {'Content-Type': 'application/json'} # AppSync publish message function def publish_appsync_message(sub, appsync_session_id, payload, credentials): body = json.dumps({ "channel": f"rag-stream-response/{sub}/{appsync_session_id}", "events": [ json.dumps(payload) ] }).encode("utf-8") aws_request = AWSRequest( method='POST', url=endpoint, data=body, headers=headers ) SigV4Auth(credentials, service, region).add_auth(aws_request) req = urllib.request.Request( url=endpoint, data=aws_request.body, method='POST' ) for k, v in aws_request.headers.items(): req.add_header(k, v) with urllib.request.urlopen(req) as res: return res.read().decode('utf-8') # handler def lambda_handler(event, context): try: credentials = session.get_credentials().get_frozen_credentials() # API Gateway からのインプットを取得 prompt = event['body']['prompt'] appsync_session_id = event['body']['appsyncSessionId'] bedrock_session_id = event['body'].get('bedrockSessionId') sub = event['sub'] # Amazon Bedrock Knowledge Bases への問い合わせパラメータ作成 request = { "input": { "text": prompt }, "retrieveAndGenerateConfiguration": { "type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": { "knowledgeBaseId": knowledge_base_id, "modelArn": model_arn, "generationConfiguration": { "inferenceConfig": { "textInferenceConfig": { "maxTokens": 10000, "temperature": 0.5, "topP": 0.9 } }, "performanceConfig": { "latency": "standard" }, "promptTemplate": { "textPromptTemplate": ( "あなたは優秀なヘルプデスクアシスタントです。ヘルプデスク担当者からの質問に対して、必ず日本語で回答してください。" "適切な回答が見つからない場合は、正直に「分かりません」と回答してください。\n\n" "検索結果:\n$search_results$\n\n" "回答指示: $output_format_instructions$" ) } } } } } # メタデータフィルタ条件の組み立て filters = event['body'].get('filters', []) if filters: conditions = [{"equals": {"key": k, "value": v}} for f in filters for k, v in f.items()] if len(conditions) == 1: retrieval_filter = conditions[0] else: retrieval_filter = {"andAll": conditions} request["retrieveAndGenerateConfiguration"]["knowledgeBaseConfiguration"]["retrievalConfiguration"] = { "vectorSearchConfiguration": { "filter": retrieval_filter } } # Bedrock sessionId は存在するときのみ渡す (継続会話時のみ) if bedrock_session_id: request["sessionId"] = bedrock_session_id # Bedrock Knowledge Bases への問い合わせ response = bedrock_agent.retrieve_and_generate_stream(**request) # Bedrock sessionId if "sessionId" in response: publish_appsync_message( sub, appsync_session_id, { "type": "bedrock_session", "bedrock_session_id": response["sessionId"] }, credentials ) for chunk in response["stream"]: payload = None # Generated text if "output" in chunk and "text" in chunk["output"]: payload = { "type": "text", "message": chunk["output"]["text"] } print({"t": chunk["output"]["text"]}) # Citation elif "citation" in chunk: payload = { "type": "citation", "citation": chunk['citation']['retrievedReferences'] } print({"c": chunk['citation']['retrievedReferences']}) # Continue if not payload: continue # Publish AppSync publish_appsync_message(sub, appsync_session_id, payload, credentials) except Exception as e: print(str(e)) raise AWS CloudFormation テンプレート Amazon API Gateway REST API や AWS AppSync Events API など、関連するリソースを一式デプロイするテンプレートを掲載します。これ単体では動かないと思いますので、参考までに。ここまで実装できると、アプリ UI から API をコールすることでチャットボット UI を作れます。 AWSTemplateFormatVersion: 2010-09-09 Description: The CloudFormation template that creates a S3 vector bucket and index as a RAG Knowledge base. # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: SystemName: Type: String Description: System name. use lower case only. (e.g. example) Default: example MaxLength: 10 MinLength: 1 SubName: Type: String Description: System sub name. use lower case only. (e.g. prod or dev) Default: dev MaxLength: 10 MinLength: 1 DomainName: Type: String Description: Domain name for URL. xxxxx.xxx (e.g. example.com) Default: example.com AllowedPattern: "[^\\s@]+\\.[^\\s@]+" SubDomainName: Type: String Description: Sub domain name for URL. (e.g. example-prod or example-dev) Default: example-dev MaxLength: 20 MinLength: 1 Dimension: Type: Number Description: The dimensions of the vectors to be inserted into the vector index. The value depends on the embedding model. Default: 1024 MaxValue: 4096 MinValue: 1 EmbeddingModelId: Type: String Description: The embedding model ID. Default: amazon.titan-embed-text-v2:0 MaxLength: 100 MinLength: 1 LlmModelId: Type: String Description: The LLM model ID for the Knowledge base. Default: global.amazon.nova-2-lite-v1:0 MaxLength: 100 MinLength: 1 Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: "General Configuration" Parameters: - SystemName - SubName - Label: default: "Domain Configuration" Parameters: - DomainName - SubDomainName - Label: default: "Embedding Configuration" Parameters: - Dimension - EmbeddingModelId - Label: default: "Knowledge Base Configuration" Parameters: - LlmModelId Resources: # ------------------------------------------------------------# # S3 # ------------------------------------------------------------# S3BucketKbDatasource: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${SystemName}-${SubName}-kbdatasource PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true CorsConfiguration: CorsRules: - AllowedHeaders: - "*" AllowedMethods: - "GET" - "HEAD" - "PUT" - "POST" - "DELETE" AllowedOrigins: - !Sub https://${SubDomainName}.${DomainName} ExposedHeaders: - last-modified - content-type - content-length - etag - x-amz-version-id - x-amz-request-id - x-amz-id-2 - x-amz-cf-id - x-amz-storage-class - date - access-control-expose-headers MaxAge: 3000 Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} S3BucketPolicyKbDatasource: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref S3BucketKbDatasource PolicyDocument: Version: "2012-10-17" Statement: - Effect: Deny Principal: "*" Action: "s3:*" Resource: - !Sub "arn:aws:s3:::${S3BucketKbDatasource}" - !Sub "arn:aws:s3:::${S3BucketKbDatasource}/*" Condition: Bool: "aws:SecureTransport": "false" DependsOn: - S3BucketKbDatasource S3VectorBucket: Type: AWS::S3Vectors::VectorBucket Properties: VectorBucketName: !Sub ${SystemName}-${SubName}-vectordb S3BucketKbIntermediate: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${SystemName}-${SubName}-kbintermediate PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} S3BucketPolicyKbIntermediate: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref S3BucketKbIntermediate PolicyDocument: Version: "2012-10-17" Statement: - Effect: Deny Principal: "*" Action: "s3:*" Resource: - !Sub "arn:aws:s3:::${S3BucketKbIntermediate}" - !Sub "arn:aws:s3:::${S3BucketKbIntermediate}/*" Condition: Bool: "aws:SecureTransport": "false" DependsOn: - S3BucketKbIntermediate S3VectorBucketIndex: Type: AWS::S3Vectors::Index Properties: IndexName: !Sub ${SystemName}-${SubName}-vectordb-index DataType: float32 Dimension: !Ref Dimension DistanceMetric: cosine VectorBucketArn: !GetAtt S3VectorBucket.VectorBucketArn MetadataConfiguration: NonFilterableMetadataKeys: - AMAZON_BEDROCK_TEXT - AMAZON_BEDROCK_METADATA DependsOn: - S3VectorBucket # ------------------------------------------------------------# # Bedrock Knowledge Base # ------------------------------------------------------------# BedrockKnowledgeBase: Type: AWS::Bedrock::KnowledgeBase Properties: Name: !Sub ${SystemName}-${SubName}-kb Description: !Sub RAG Knowledge Base for ${SystemName}-${SubName} KnowledgeBaseConfiguration: Type: VECTOR VectorKnowledgeBaseConfiguration: EmbeddingModelArn: !Sub arn:aws:bedrock:${AWS::Region}::foundation-model/${EmbeddingModelId} RoleArn: !GetAtt IAMRoleBedrockKb.Arn StorageConfiguration: Type: S3_VECTORS S3VectorsConfiguration: IndexArn: !GetAtt S3VectorBucketIndex.IndexArn VectorBucketArn: !GetAtt S3VectorBucket.VectorBucketArn Tags: Cost: !Sub ${SystemName}-${SubName} DependsOn: - IAMRoleBedrockKb BedrockKnowledgeBaseDataSource: Type: AWS::Bedrock::DataSource Properties: Name: !Sub ${SystemName}-${SubName}-kb-datasource Description: !Sub RAG Knowledge Base Data Source for ${SystemName}-${SubName} KnowledgeBaseId: !Ref BedrockKnowledgeBase DataDeletionPolicy: DELETE DataSourceConfiguration: Type: S3 S3Configuration: BucketArn: !GetAtt S3BucketKbDatasource.Arn VectorIngestionConfiguration: ChunkingConfiguration: ChunkingStrategy: NONE CustomTransformationConfiguration: Transformations: - TransformationFunction: TransformationLambdaConfiguration: LambdaArn: !GetAtt LambdaCsvChunker.Arn StepToApply: POST_CHUNKING IntermediateStorage: S3Location: URI: !Sub s3://${S3BucketKbIntermediate}/ DependsOn: - S3BucketKbDatasource - BedrockKnowledgeBase - S3BucketKbIntermediate # ------------------------------------------------------------# # AppSync Events # ------------------------------------------------------------# AppSyncChannelNamespaceRagSR: Type: AWS::AppSync::ChannelNamespace Properties: Name: rag-stream-response ApiId: Fn::ImportValue: !Sub AppSyncApiId-${SystemName}-${SubName} CodeHandlers: | import { util } from '@aws-appsync/utils'; export function onSubscribe(ctx) { const requested = ctx.info.channel.path; if (!requested.startsWith(`/rag-stream-response/${ctx.identity.sub}`)) { util.unauthorized(); } } Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} # ------------------------------------------------------------# # API Gateway REST API # ------------------------------------------------------------# RestApiRagSR: Type: AWS::ApiGateway::RestApi Properties: Name: !Sub rag-sr-${SystemName}-${SubName} Description: !Sub REST API to call Lambda rag-stream-response-${SystemName}-${SubName} EndpointConfiguration: Types: - REGIONAL IpAddressType: dualstack Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} RestApiDeploymentRagSR: Type: AWS::ApiGateway::Deployment Properties: RestApiId: !Ref RestApiRagSR DependsOn: - RestApiMethodRagSRPost - RestApiMethodRagSROptions RestApiStageRagSR: Type: AWS::ApiGateway::Stage Properties: StageName: prod Description: production stage RestApiId: !Ref RestApiRagSR DeploymentId: !Ref RestApiDeploymentRagSR MethodSettings: - ResourcePath: "/*" HttpMethod: "*" LoggingLevel: INFO DataTraceEnabled : true TracingEnabled: false AccessLogSetting: DestinationArn: !GetAtt LogGroupRestApiRagSR.Arn Format: '{"requestId":"$context.requestId","status":"$context.status","sub":"$context.authorizer.claims.sub","email":"$context.authorizer.claims.email","resourcePath":"$context.resourcePath","requestTime":"$context.requestTime","sourceIp":"$context.identity.sourceIp","userAgent":"$context.identity.userAgent","apigatewayError":"$context.error.message","authorizerError":"$context.authorizer.error","integrationError":"$context.integration.error"}' Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} RestApiAuthorizerRagSR: Type: AWS::ApiGateway::Authorizer Properties: Name: !Sub restapi-authorizer-ragsr-${SystemName}-${SubName} RestApiId: !Ref RestApiRagSR Type: COGNITO_USER_POOLS ProviderARNs: - Fn::ImportValue: !Sub CognitoArn-${SystemName}-${SubName} AuthorizerResultTtlInSeconds: 300 IdentitySource: method.request.header.Authorization RestApiResourceRagSR: Type: AWS::ApiGateway::Resource Properties: RestApiId: !Ref RestApiRagSR ParentId: !GetAtt RestApiRagSR.RootResourceId PathPart: ragsr RestApiMethodRagSRPost: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref RestApiRagSR ResourceId: !Ref RestApiResourceRagSR HttpMethod: POST AuthorizationType: COGNITO_USER_POOLS AuthorizerId: !Ref RestApiAuthorizerRagSR Integration: Type: AWS IntegrationHttpMethod: POST Credentials: Fn::ImportValue: !Sub ApigLambdaInvocationRoleArn-${SystemName}-${SubName} Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaRagSR.Arn}/invocations" PassthroughBehavior: NEVER RequestTemplates: application/json: | { "body": $input.json('$'), "sub": "$context.authorizer.claims.sub" } RequestParameters: integration.request.header.X-Amz-Invocation-Type: "'Event'" IntegrationResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'" method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'" method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'" ResponseTemplates: application/json: '' StatusCode: '202' MethodResponses: - StatusCode: '202' ResponseModels: application/json: Empty ResponseParameters: method.response.header.Access-Control-Allow-Origin: true method.response.header.Access-Control-Allow-Headers: true method.response.header.Access-Control-Allow-Methods: true RestApiMethodRagSROptions: Type: AWS::ApiGateway::Method Properties: RestApiId: !Ref RestApiRagSR ResourceId: !Ref RestApiResourceRagSR HttpMethod: OPTIONS AuthorizationType: NONE Integration: Type: MOCK Credentials: Fn::ImportValue: !Sub ApigLambdaInvocationRoleArn-${SystemName}-${SubName} IntegrationResponses: - ResponseParameters: method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,Cache-Control'" method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'" method.response.header.Access-Control-Allow-Origin: !Sub "'https://${SubDomainName}.${DomainName}'" ResponseTemplates: application/json: '' StatusCode: '200' PassthroughBehavior: WHEN_NO_MATCH RequestTemplates: application/json: '{"statusCode": 200}' MethodResponses: - ResponseModels: application/json: Empty ResponseParameters: method.response.header.Access-Control-Allow-Headers: true method.response.header.Access-Control-Allow-Methods: true method.response.header.Access-Control-Allow-Origin: true StatusCode: '200' # ------------------------------------------------------------# # API Gateway LogGroup (CloudWatch Logs) # ------------------------------------------------------------# LogGroupRestApiRagSR: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub /aws/apigateway/${RestApiRagSR} RetentionInDays: 365 Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} # ------------------------------------------------------------# # Lambda # ------------------------------------------------------------# LambdaRagSR: Type: AWS::Lambda::Function Properties: FunctionName: !Sub rag-sr-${SystemName}-${SubName} Description: !Sub Lambda Function to invoke Bedrock Knowledge Bases for ${SystemName}-${SubName} Architectures: - x86_64 Runtime: python3.14 Timeout: 300 MemorySize: 128 Environment: Variables: APPSYNC_API_ENDPOINT: Fn::ImportValue: !Sub AppSyncEventsEndpointHttp-${SystemName}-${SubName} MODEL_ARN: !Sub "arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/${LlmModelId}" KNOWLEDGE_BASE_ID: !Ref BedrockKnowledgeBase REGION: !Ref AWS::Region Role: !GetAtt LambdaBedrockKbRole.Arn Handler: index.lambda_handler Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} Code: ZipFile: | import os import json import boto3 import urllib.request from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest # common objects and valiables session = boto3.session.Session() bedrock_agent = boto3.client('bedrock-agent-runtime') endpoint = os.environ['APPSYNC_API_ENDPOINT'] model_arn = os.environ['MODEL_ARN'] knowledge_base_id = os.environ['KNOWLEDGE_BASE_ID'] region = os.environ['REGION'] service = 'appsync' headers = {'Content-Type': 'application/json'} # AppSync publish message function def publish_appsync_message(sub, appsync_session_id, payload, credentials): body = json.dumps({ "channel": f"rag-stream-response/{sub}/{appsync_session_id}", "events": [ json.dumps(payload) ] }).encode("utf-8") aws_request = AWSRequest( method='POST', url=endpoint, data=body, headers=headers ) SigV4Auth(credentials, service, region).add_auth(aws_request) req = urllib.request.Request( url=endpoint, data=aws_request.body, method='POST' ) for k, v in aws_request.headers.items(): req.add_header(k, v) with urllib.request.urlopen(req) as res: return res.read().decode('utf-8') # handler def lambda_handler(event, context): try: credentials = session.get_credentials().get_frozen_credentials() # API Gateway からのインプットを取得 prompt = event['body']['prompt'] appsync_session_id = event['body']['appsyncSessionId'] bedrock_session_id = event['body'].get('bedrockSessionId') sub = event['sub'] # Amazon Bedrock Knowledge Bases への問い合わせパラメータ作成 request = { "input": { "text": prompt }, "retrieveAndGenerateConfiguration": { "type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": { "knowledgeBaseId": knowledge_base_id, "modelArn": model_arn, "generationConfiguration": { "inferenceConfig": { "textInferenceConfig": { "maxTokens": 10000, "temperature": 0.5, "topP": 0.9 } }, "performanceConfig": { "latency": "standard" }, "promptTemplate": { "textPromptTemplate": ( "あなたは優秀なヘルプデスクアシスタントです。ヘルプデスク担当者からの質問に対して、必ず日本語で回答してください。" "適切な回答が見つからない場合は、正直に「分かりません」と回答してください。\n\n" "検索結果:\n$search_results$\n\n" "回答指示: $output_format_instructions$" ) } } } } } # メタデータフィルタ条件の組み立て filters = event['body'].get('filters', []) if filters: conditions = [{"equals": {"key": k, "value": v}} for f in filters for k, v in f.items()] if len(conditions) == 1: retrieval_filter = conditions[0] else: retrieval_filter = {"andAll": conditions} request["retrieveAndGenerateConfiguration"]["knowledgeBaseConfiguration"]["retrievalConfiguration"] = { "vectorSearchConfiguration": { "filter": retrieval_filter } } # Bedrock sessionId は存在するときのみ渡す (継続会話時のみ) if bedrock_session_id: request["sessionId"] = bedrock_session_id # Bedrock Knowledge Bases への問い合わせ response = bedrock_agent.retrieve_and_generate_stream(**request) # Bedrock sessionId if "sessionId" in response: publish_appsync_message( sub, appsync_session_id, { "type": "bedrock_session", "bedrock_session_id": response["sessionId"] }, credentials ) for chunk in response["stream"]: payload = None # Generated text if "output" in chunk and "text" in chunk["output"]: payload = { "type": "text", "message": chunk["output"]["text"] } print({"t": chunk["output"]["text"]}) # Citation elif "citation" in chunk: payload = { "type": "citation", "citation": chunk['citation']['retrievedReferences'] } print({"c": chunk['citation']['retrievedReferences']}) # Continue if not payload: continue # Publish AppSync publish_appsync_message(sub, appsync_session_id, payload, credentials) except Exception as e: print(str(e)) raise DependsOn: - LambdaBedrockKbRole - BedrockKnowledgeBase LambdaRagSREventInvokeConfig: Type: AWS::Lambda::EventInvokeConfig Properties: FunctionName: !GetAtt LambdaRagSR.Arn Qualifier: $LATEST MaximumRetryAttempts: 0 MaximumEventAgeInSeconds: 300 LambdaCsvChunker: Type: AWS::Lambda::Function Properties: FunctionName: !Sub csv-chunker-${SystemName}-${SubName} Description: !Sub Lambda Function to embed with custom chunk for ${SystemName}-${SubName} Architectures: - x86_64 Runtime: python3.14 Handler: index.lambda_handler Timeout: 900 MemorySize: 512 Role: !GetAtt LambdaCsvChunkerRole.Arn Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} Code: ZipFile: | import json import csv import boto3 from io import StringIO s3 = boto3.client('s3') def lambda_handler(event, context): try: bucket_name = event.get('bucketName') input_files = event.get('inputFiles', []) output_files = [] for file_info in input_files: original_file_location = file_info.get('originalFileLocation', {}) s3_location = original_file_location.get('s3Location', {}) original_uri = s3_location.get('uri', '') content_batches = file_info.get('contentBatches', []) output_batches = [] for batch in content_batches: input_key = batch.get('key') # Read input file from S3 response = s3.get_object(Bucket=bucket_name, Key=input_key) input_content = json.loads(response['Body'].read().decode('utf-8')) # Extract CSV content csv_content = input_content['fileContents'][0]['contentBody'] # Remove BOM if present (input may have BOM) if csv_content.startswith('\ufeff'): csv_content = csv_content[1:] csv_reader = csv.DictReader(StringIO(csv_content)) # Process each row as a chunk file_contents = [] for row in csv_reader: content_body = f"問合せ番号: {row.get('問合せ番号', '')}\n商品番号: {row.get('商品番号', '')}\n\n問合せ内容:\n{row.get('問合せ内容', '')}\n\n回答内容:\n{row.get('回答内容', '')}" content_metadata = { "問合せ番号": row.get('問合せ番号', ''), "販売形態": row.get('販売形態', ''), "受付日時": row.get('受付日時', ''), "完了日時": row.get('完了日時', ''), "商品番号": row.get('商品番号', ''), "カテゴリ": row.get('カテゴリ', ''), "ステータス": row.get('ステータス', '') } file_contents.append({ "contentBody": content_body, "contentType": "TEXT", "contentMetadata": content_metadata }) # Write output file to S3 output_key = input_key.replace('.json', '_transformed.json') output_data = {"fileContents": file_contents} s3.put_object( Bucket=bucket_name, Key=output_key, Body=json.dumps(output_data, ensure_ascii=False), ContentType='application/json' ) output_batches.append({"key": output_key}) output_files.append({ "originalFileLocation": original_file_location, "fileMetadata": file_info.get('fileMetadata', {}), "contentBatches": output_batches }) return {"outputFiles": output_files} except Exception as e: print(f"Error: {str(e)}") import traceback traceback.print_exc() raise LambdaInvokePermissionCsvChunker: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaCsvChunker Action: lambda:InvokeFunction Principal: bedrock.amazonaws.com SourceAccount: !Ref AWS::AccountId # SourceArn: !Sub "arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:knowledge-base/${BedrockKnowledgeBase}" DependsOn: - LambdaCsvChunker # - BedrockKnowledgeBase # ------------------------------------------------------------# # Lambda Role (IAM) # ------------------------------------------------------------# LambdaBedrockKbRole: Type: AWS::IAM::Role Properties: RoleName: !Sub LambdaBedrockKbRole-${SystemName}-${SubName} Description: This role allows Lambda functions to invoke Bedrock Knowledge Bases and AppSync Events API. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: / ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess Policies: - PolicyName: !Sub LambdaBedrockKbPolicy-${SystemName}-${SubName} PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "bedrock:InvokeModel" - "bedrock:InvokeModelWithResponseStream" - "bedrock:GetInferenceProfile" - "bedrock:ListInferenceProfiles" Resource: - !Sub "arn:aws:bedrock:*::foundation-model/*" - !Sub "arn:aws:bedrock:*:${AWS::AccountId}:inference-profile/*" - Effect: Allow Action: - "bedrock:RetrieveAndGenerate" - "bedrock:Retrieve" Resource: - !GetAtt BedrockKnowledgeBase.KnowledgeBaseArn - Effect: Allow Action: - "appsync:connect" Resource: - Fn::ImportValue: !Sub AppSyncApiArn-${SystemName}-${SubName} - Effect: Allow Action: - "appsync:publish" - "appsync:EventPublish" Resource: - Fn::Join: - "" - - Fn::ImportValue: !Sub AppSyncApiArn-${SystemName}-${SubName} - /channelNamespace/rag-stream-response LambdaCsvChunkerRole: Type: AWS::IAM::Role Properties: RoleName: !Sub LambdaCsvChunkerRole-${SystemName}-${SubName} AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: !Sub LambdaCsvChunkerPolicy-${SystemName}-${SubName} PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "s3:GetObject" - "s3:PutObject" - "s3:ListObject" Resource: - !GetAtt S3BucketKbDatasource.Arn - !Sub ${S3BucketKbDatasource.Arn}/* - !GetAtt S3BucketKbIntermediate.Arn - !Sub ${S3BucketKbIntermediate.Arn}/* # ------------------------------------------------------------# # IAM Role for Bedrock Knowledge Base # ------------------------------------------------------------# IAMRoleBedrockKb: Type: AWS::IAM::Role Properties: RoleName: !Sub BedrockKbRole-${SystemName}-${SubName} AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - sts:AssumeRole Principal: Service: - bedrock.amazonaws.com Condition: StringEquals: "aws:SourceAccount": !Ref AWS::AccountId # ArnLike: # "aws:SourceArn": !Sub "arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:knowledge-base/*" Policies: - PolicyName: !Sub BedrockKbPolicy-${SystemName}-${SubName} PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "s3:GetObject" - "s3:ListBucket" - "s3:PutObject" Resource: - !GetAtt S3BucketKbDatasource.Arn - !Sub ${S3BucketKbDatasource.Arn}/* - !GetAtt S3BucketKbIntermediate.Arn - !Sub ${S3BucketKbIntermediate.Arn}/* - Effect: Allow Action: - "s3vectors:GetIndex" - "s3vectors:QueryVectors" - "s3vectors:PutVectors" - "s3vectors:GetVectors" - "s3vectors:DeleteVectors" Resource: - !GetAtt S3VectorBucketIndex.IndexArn - Effect: Allow Action: - "bedrock:InvokeModel" Resource: - !Sub arn:aws:bedrock:${AWS::Region}::foundation-model/${EmbeddingModelId} - Effect: Allow Action: - "lambda:InvokeFunction" Resource: - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:csv-chunker-${SystemName}-${SubName}*" DependsOn: - S3BucketKbDatasource - S3VectorBucketIndex - LambdaCsvChunker - S3BucketKbIntermediate # ------------------------------------------------------------# # Output Parameters # ------------------------------------------------------------# Outputs: # S3 S3BucketKbDatasourceName: Value: !Ref S3BucketKbDatasource # API Gateway APIGatewayEndpointRagSR: Value: !Sub https://${RestApiRagSR}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${RestApiStageRagSR}/ragsr Export: Name: !Sub RestApiEndpointRagSR-${SystemName}-${SubName} 続編記事 Amazon Bedrock Knowledge Bases で構造化データ(CSV)を使用した RAG をつくる -UI編- Amazon Bedrock Knowledge Bases と Amazon S3 Vectors で構築した RAG 環境で、構造化データをデータソースにしたときの検索精度向上を目指しました。本記事は UI 編です。 blog.usize-tech.com 2026.03.23 まとめ いかがでしたでしょうか。 メタデータフィルタリングは設計次第でかなり細かい検索ができそうですが、その分コーディングが大変です。むやみに汎用的なフィルタ設定を実装しようとすると開発負担増やバグの温床になりそうなので、フィルタ対象項目はなるべく厳選した方がよいと思います。 本記事が皆様のお役に立てれば幸いです。
アバター
こんにちは、SCSK林です! 今回は、AWS、Snowflakeで実現したニアリアルタイムデータ連携について解説します。 本記事では、実際に構築した例をベースにアーキテクチャ選定の背景と、構成や技術的に気をつけるポイントについて共有していきたいと思います。   構成の背景(いわゆる要件) 今回の主要件は、オンプレミスのシステムから出力される業務データを、AWSを経由してSnowflakeへ連携し、数分以内(ニアリアルタイム)に分析可能にすることでした。 主な要件と制約は以下の通りです。 セキュリティ: 秘匿性の高いデータを扱うため、インターネット経由の転送は不可。閉域網のみを通すこと。 データ特性: 1リクエストあたり約10MB(圧縮前)。頻度は1日100件程度だが、データの欠損は許されない。 クライアント制約: 送信元システムはHTTPリクエストの送出のみ対応。 既存資産: 組織内で実績のあるSnowflake連携用スクリプト(Shell)を流用したい。   アーキテクチャ概要 データ取り込み処理 : Client (On-prem) → Direct Connect → VPC Endpoint (Interface) → Amazon API Gateway (HTTP API) → AWS Lambda 一時保存 : Amazon S3 (Staging Bucket) ロード処理 : S3 Event Notification → AWS Lambda → Snowflake (COPY INTO) このアーキテクチャのポイントは、「データ受信」と「データ処理」をS3を介して完全に切り離した点にあります。 クライアントからのデータ受信を行うLambda(データ取り込み処理)は、データの検証とS3への保存のみを行い、即座にレスポンスを返します。一方、Snowflakeへのロードを行うLambda(ロード処理)は、S3イベントをトリガーに非同期で実行されます。 これにより、仮にSnowflake側の処理に時間がかかったとしても、クライアント側のHTTP通信がタイムアウトすることはありません。   アーキテクチャのポイント セキュリティ要件(閉域網)の実現 オンプレミスからのHTTPリクエストを安全に受け取るため、API Gatewayの前段にInterface VPC Endpoint (PrivateLink) を配置しました。 Private API Gatewayを使用する選択肢もありましたが、今回はネットワーク経路を厳密に制御するため、VPC Endpointのリソースポリシーを活用しました。これにより、特定のDirect Connect経由のトラフィックのみを許可し、それ以外のアクセスをネットワークレベルで遮断することを実現しています。 Lambdaの6MB制約とその回避 今回、技術的なハードルとなったのが、AWS Lambdaのペイロード制限です。 API Gatewayの制限: 最大10MB Lambda(同期呼び出し)の制限: 最大6MB クライアントから上記制限を越えるデータが送られてくると、そのままではLambdaに引き渡す段階で 413 Request Entity Too Large エラーが発生してしまいます。 これを回避するために、S3署名付きURLを発行してクライアントから直接S3へアップロードさせる方式も検討しましたが、クライアント側の実装負荷が複雑になるため、アーキテクチャは変更せず「データ圧縮」で解決する方針を決定しました。 今回は、クライアント側でデータをGZIP圧縮することで、ペイロードサイズを数MBまで削減し、これによりLambdaの6MB制限をクリアしました。ただ、そういうわけにも毎回いかないと思いますので同様の構成を検討する際はぜひご注意ください。 「取り込み」と「ロード処理」の責務の分離による耐障害性の確保 今回は、API Gatewayから直接Snowflakeへデータを流し込むのではなく、S3を境界として「Ingest(取り込み)」と「Process(ロード処理)」の責務を明確に分離しました。 データ取り込み処理層 (同期): 役割: クライアントからのリクエストを高速に受け付け、S3へ永続化することだけに集中する。 効果: Snowflake側の状態(一時的なパフォーマンス低下など)の影響をクライアントに与えない。クライアントへは即座に 200 OK を返却し、接続タイムアウトのリスクを排除。 データロード層 (非同期): 役割: S3へのオブジェクト作成イベントをトリガーに、非同期でSnowflakeへの COPY INTO を実行する。 効果: 重い処理(DB接続・ロード)をここへ集約。もしロード処理が失敗しても、データはS3上に「ファイル」として安全に残っているため、データロード層(クライアント)に影響を与えることなくリトライやリカバリが可能。 この「S3をバッファとした疎結合アーキテクチャ」を採用したことで、クライアントに対するレスポンス性能(レイテンシ)を一定に保ちつつ、バックエンド処理の安定性を高めることを実現しました。 運用を見据えた設計 データ連携基盤においてもっとも考慮が必要なことは「データのロスト」です。 今回は、万が一Snowflakeへのロードが失敗した場合(データフォーマット不正やウェアハウスの一時的な問題など)に備え、以下の仕組みを導入しました。 エラーハンドリング : Lambda内で例外をキャッチした場合、対象のオブジェクトをS3上の「Error」フォルダへ移動(Move)。 監視 : エラーフォルダへの配置をトリガーに、管理者へ即時通知。 これにより、失敗したデータが「どこにあるか分からない」状態を防ぎ、リカバリが必要なデータを明確に分離する運用を設計しました。   まとめ 今回の構成では、マネージドサービスベースのデータ連携基盤(ニアリアルタイム)を実現しました。 データ連携は頻度をあげることでより難易度が増していきます。 今回の構成、事例がどなたかのお役に立つと幸いです。
アバター
こんにちは、SCSK林です! 今回は、AWSで完全にプライベート環境で実現するサーバレスAPI、S3静的ホスティングについて解説します。 エンタープライズ領域だと、クラウド導入の大きな壁となるのが『セキュリティ要件』だと感じています。 データは絶対にインターネットに出してはならない・・・ アクセスは専用線やVPN経由の閉域網に限る・・・ などなど、会社ごとに厳格なポリシーをお持ちだと思います。 本来、パブリックなアクセスを前提とするサーバーレスサービスを、いかにして閉域網の中に封じ込め、かつ安全に運用するか。 本記事では、実際に構築した例をベースにアーキテクチャ選定の背景と、構成や技術的に気をつけるポイントについて共有していきたいと思います。   構成の背景(いわゆる要件) 今回想定する割とありがちな(と個人的には思っている)要件は以下のとおりです。 アクセス経路: ユーザーはオンプレミス環境からのみアクセス可能。インターネットからのアクセスは一切遮断する。 運用負荷の軽減: 極力EC2などのサーバ管理を廃止し、マネージドサービスを活用したい。 モダンなUX: SPA(Single Page Application)によるリッチなUIを提供する。 通常、SPAの配信にはAmazon S3の静的ウェブサイトホスティングやAmazon CloudFrontが定石ですが、これらはパブリックアクセスが前提となります。閉域網要件を満たすために、「サーバーレスの利便性」と「ネットワークの閉塞性」をどう両立させるかが、アーキテクチャ設計の肝となります。 アーキテクチャ概要 最終的に採用したアーキテクチャは、ALB (Application Load Balancer) をシステムの唯一の入り口とし、バックエンドのリソースを全てプライベートネットワーク内に配置する構成です。 【構成のポイント】 アクセス元: オンプレミス環境(Direct Connect / VPN経由) 入口: VPC内に配置した Internal ALB フロントエンド: ALB → VPC Endpoint (Interface型) → S3 バケット バックエンド: ALB → Lambda この構成により、トラフィックが一切インターネットに出ることなく、AWSのネットワーク内だけで完結するセキュアな通信経路を確立しました。 アーキテクチャのポイント ALBによる入口の集約 当初、S3やAPI GatewayのVPCエンドポイントを直接クライアントに公開する案もありました。しかし、前段にALBを配置する構成を採用しました。S3やAPI Gatewayのエンドポイントが個別に分散すると、クライアント(オンプレ側)でのDNS設定やファイアウォール設定が複雑化します。ALBを挟むことで以下のメリットを享受できました。 インターフェースの集約: フロントエンドもAPIも、単一のドメインでアクセス可能にする(パスベースルーティング)。 セキュリティの一元化: SSL/TLS終端をALBに集約し、証明書管理を一本化。将来的なWAF導入などの拡張性も確保。 Route 53 Resolver によるハイブリッドDNS設計 オンプレミス環境からAWS内のプライベートリソースへアクセスさせる際、大きな技術的課題は「名前解決」です。オンプレミスのDNSサーバーは、AWS内のプライベートIPアドレスをもちろん知りません。  hostsファイル等での個別対応は運用破綻のリスクがあるため、Amazon Route 53 Resolver (Inbound Endpoint) の導入をしています。 オンプレミスのDNSサーバーから、特定ドメインへのクエリをAWS側のResolverにフォワードするハイブリッド構成とすることで、ユーザーはネットワークの境界を意識することなく、シームレスにシステムを利用できるようになりました。   まとめ 今回のプロジェクトを通じて、「閉域網」と「サーバーレス」は決して相容れないものではないことを実証できました。 セキュリティ: 完全プライベートな環境で、企業の厳しいコンプライアンス要件を遵守。 運用効率: サーバー(EC2)レスにより、OSパッチ適用などの運用コストを大幅に削減。 単に流行りの技術を使うだけでなく、ビジネス要件という制約の中で、技術をどう組み合わせ、最適な解を導き出すかというアーキテクト視点の重要性を再認識しました。 今後も、技術の理想とビジネスの現実のバランスを取りながら、顧客にとって価値あるクラウド活用を推進していきたいと思います。
アバター
こんにちは、広野です。 AWS マネジメントコンソールではなく、Amazon EC2 インスタンスのターミナル操作で簡単に EBS ボリュームの拡張をしたいと思い、AWS が提供しているスクリプトを使用したら拡張できました。 方法 今はもう新規アカウントへの提供が終了になっている AWS Cloud9。そのドキュメントにある、AWS Cloud9 用の EBS ボリューム拡張用スクリプトを使用します。 Resize an Amazon EBS volume that an environment uses - AWS Cloud9 Learn how you can resize an Amazon EBS volume that you want to resize. docs.aws.amazon.com   このスクリプトを Amazon Linux 2023 インスタンスに配置して、実行する際に引数として変更後のボリュームサイズ (GB) を数値で書くだけで即時反映されます。 前提 対象の Amazon Linux 2023 インスタンスの IAM ロールに以下の権限を追加していること。 - Effect: Allow Action: - ec2:DescribeInstances - ec2:ModifyVolume - ec2:DescribeVolumesModifications Resource: "*" スクリプトにより、自分自身のメタデータからボリュームの情報を取得し、AWS CLI でボリュームサイズの変更コマンドや OS 上の変更反映をしてくれます。 この権限を与えること自体、微妙なところはありますが、、、。そのアカウントの管理者クラスの人であれば使える機会はあるかもです。   まとめ いかがでしたでしょうか? 小ネタでしたが、本記事が皆様の参考になれば幸いです。
アバター
こんにちは、広野です。 以前、以下の記事で紹介していた AWS Step Functions のジョブを最新化したので紹介します。今回のジョブは社内教育用の動画作成で活用しています。 動画ファイルをストリーミング用データに自動変換するジョブをつくる [AWS Elemental MediaConvert 使用] アプリから動画を再生できるよう動画ファイルをストリーミング用データ (HLS) に自動変換するジョブを作成したので紹介します。AWS CloudFormation で環境を構築できるようにしています。 blog.usize-tech.com 2023.03.09 ※上記記事は古いので、お勧めしません。 動画は Amazon CloudFront 経由、Amazon Cognito 認証付きで配信しています。参考記事はこちら。 React アプリで Amazon Cognito 認証済みユーザーにのみ Amazon S3 静的コンテンツへのアクセスを許可したい -環境編- Amazon Cognito でユーザー認証する React アプリで、Amazon CloudFront 経由の Amazon S3 へのアクセス制御を実装する方法を紹介します。本記事は環境編です。 blog.usize-tech.com 2026.01.13   アーキテクチャ 基本的には以下の記事と同じですので、割愛します。 MP4 をアニメーション GIF に自動変換する AWS Step Functions ジョブをつくる AWS Elemental MediaConvert の Create Job API を AWS Step Functions で実行させようと思ったらハマりました。 blog.usize-tech.com 2026.01.07   仕様 MP4 ファイルを Amazon S3 バケットの input フォルダに配置したら、自動で AWS Step Functions ステートマシンが呼び出される。 ステートマシンは、AWS Elemental MediaConvert を呼び出し、S3 上の MP4 ファイルを GIF に変換し S3 バケットの output フォルダに保存する。 最後に完了したことを Amazon SNS で通知する。 コーデックは H.264 を採用。互換性重視のため。H.265 ではブラウザによっては再生できない。 画質は 360p と 720p の 2段階。アダプティブビットレート (ABR) 対応で視聴者のネットワーク状況に応じてプレーヤーが自動で画質を切り替えられるようにした。 インプット 以下のように、MP4 ファイルを S3 バケットに配置します。 アウトプット 以下のように、m3u8 ファイル群が生成されます。プレーヤーに、この例では sample-cat.m3u8 を指定することになります。   AWS CloudFormation テンプレート 実際にデプロイしたときに使用した AWS CloudFormation テンプレートを貼り付けます。詳細な設定はこちらをご覧ください。 AWSTemplateFormatVersion: 2010-09-09 Description: The CloudFormation template that creates a Step Functions state machine. It provides converting MP4 to HLS (H.264, AAC, ABR 360p/720p). The IAM role MediaConvert_Default_Role must be created in your AWS account before creating a job. Please refer https://docs.aws.amazon.com/mediaconvert/latest/ug/creating-the-iam-role-in-mediaconvert-configured.html for details. # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: SystemName: Type: String Description: System name. use lower case only. (e.g. example) Default: example MaxLength: 10 MinLength: 1 SubName: Type: String Description: System sub name. use lower case only. (e.g. prod or dev) Default: dev MaxLength: 10 MinLength: 1 Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: "General Configuration" Parameters: - SystemName - SubName Resources: # ------------------------------------------------------------# # S3 # ------------------------------------------------------------# S3Bucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${SystemName}-${SubName}-mp4-hls-conv LifecycleConfiguration: Rules: - Id: AutoDelete Status: Enabled ExpirationInDays: 14 PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true NotificationConfiguration: EventBridgeConfiguration: EventBridgeEnabled: true Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} # ------------------------------------------------------------# # Elemental MediaConvert # ------------------------------------------------------------# MediaConvertQueue: Type: AWS::MediaConvert::Queue Properties: Description: !Sub For ${SystemName}-${SubName} Name: !Sub ${SystemName}-${SubName}-mp4-hls-conv PricingPlan: ON_DEMAND Status: ACTIVE Tags: Cost: !Sub ${SystemName}-${SubName} MediaConvertJobTemplate: Type: AWS::MediaConvert::JobTemplate Properties: Name: !Sub ${SystemName}-${SubName}-mp4-hls-abr-360-720 Description: !Sub The transcoding configuration for ${SystemName}-${SubName} (MP4 to HLS ABR 360p/720p) Category: !Ref SystemName AccelerationSettings: Mode: DISABLED Priority: 0 Queue: !GetAtt MediaConvertQueue.Arn SettingsJson: Inputs: - VideoSelector: ColorSpace: FOLLOW SampleRange: FOLLOW Rotate: DEGREE_0 AudioSelectors: Audio Selector 1: DefaultSelection: DEFAULT FilterEnable: AUTO PsiControl: IGNORE_PSI FilterStrength: 0 DeblockFilter: DISABLED DenoiseFilter: DISABLED InputScanType: AUTO TimecodeSource: ZEROBASED OutputGroups: - CustomName: HLS_output Name: Apple HLS Outputs: # 360p video - ContainerSettings: Container: M3U8 NameModifier: _360p VideoDescription: Width: 640 Height: 360 ScalingBehavior: DEFAULT Sharpness: 50 CodecSettings: Codec: H_264 H264Settings: InterlaceMode: PROGRESSIVE RateControlMode: QVBR MaxBitrate: 800000 CodecProfile: MAIN CodecLevel: AUTO FramerateControl: INITIALIZE_FROM_SOURCE GopSize: 2 GopSizeUnits: SECONDS GopClosedCadence: 1 GopBReference: DISABLED EntropyEncoding: CABAC AdaptiveQuantization: HIGH SpatialAdaptiveQuantization: ENABLED TemporalAdaptiveQuantization: ENABLED FlickerAdaptiveQuantization: DISABLED SceneChangeDetect: TRANSITION_DETECTION QualityTuningLevel: SINGLE_PASS_HQ ParControl: INITIALIZE_FROM_SOURCE # 720p video - ContainerSettings: Container: M3U8 NameModifier: _720p VideoDescription: Width: 1280 Height: 720 ScalingBehavior: DEFAULT Sharpness: 50 CodecSettings: Codec: H_264 H264Settings: InterlaceMode: PROGRESSIVE RateControlMode: QVBR MaxBitrate: 2500000 CodecProfile: MAIN CodecLevel: AUTO FramerateControl: INITIALIZE_FROM_SOURCE GopSize: 2 GopSizeUnits: SECONDS GopClosedCadence: 1 GopBReference: DISABLED EntropyEncoding: CABAC AdaptiveQuantization: HIGH SpatialAdaptiveQuantization: ENABLED TemporalAdaptiveQuantization: ENABLED FlickerAdaptiveQuantization: DISABLED SceneChangeDetect: TRANSITION_DETECTION QualityTuningLevel: SINGLE_PASS_HQ ParControl: INITIALIZE_FROM_SOURCE # Audio - ContainerSettings: Container: M3U8 NameModifier: _audio AudioDescriptions: - AudioTypeControl: FOLLOW_INPUT AudioSourceName: Audio Selector 1 CodecSettings: Codec: AAC AacSettings: Bitrate: 96000 RateControlMode: CBR CodecProfile: LC CodingMode: CODING_MODE_2_0 SampleRate: 48000 Specification: MPEG4 LanguageCodeControl: FOLLOW_INPUT OutputGroupSettings: Type: HLS_GROUP_SETTINGS HlsGroupSettings: SegmentLength: 10 MinSegmentLength: 0 Destination: !Sub s3://${S3Bucket}/output/ DestinationSettings: S3Settings: StorageClass: STANDARD SegmentControl: SEGMENTED_FILES ManifestCompression: NONE DirectoryStructure: SINGLE_DIRECTORY TimecodeConfig: Source: ZEROBASED StatusUpdateInterval: SECONDS_60 Tags: Cost: !Sub ${SystemName}-${SubName} DependsOn: - MediaConvertQueue # ------------------------------------------------------------# # Step Functions State Machine # ------------------------------------------------------------# StateMachineMp4HlsConv: Type: AWS::StepFunctions::StateMachine Properties: StateMachineName: !Sub ${SystemName}-${SubName}-mp4-hls-conv StateMachineType: STANDARD DefinitionSubstitutions: DsSystemName: !Ref SystemName DsSubName: !Ref SubName DSRegion: !Ref AWS::Region DsAwsAccountId: !Ref AWS::AccountId DsMediaConvertJobTemplateArn: !GetAtt MediaConvertJobTemplate.Arn DsMediaConvertQueueArn: !GetAtt MediaConvertQueue.Arn DsSnsTopicArn: !GetAtt SNSTopic.TopicArn DefinitionString: |- { "StartAt": "CreateJob", "States": { "CreateJob": { "Type": "Task", "Resource": "arn:aws:states:::mediaconvert:createJob.sync", "Arguments": { "JobTemplate": "${DsMediaConvertJobTemplateArn}", "Queue": "${DsMediaConvertQueueArn}", "Role": "arn:aws:iam::${DsAwsAccountId}:role/service-role/MediaConvert_Default_Role", "Settings": { "Inputs": [ { "FileInput": "{% 's3://' & $states.input.detail.bucket.name & '/' & $states.input.detail.object.key %}" } ] }, "Tags": { "Cost": "${DsSystemName}-${DsSubName}" } }, "Next": "SnsPublish", "QueryLanguage": "JSONata", "TimeoutSeconds": 600 }, "SnsPublish": { "Type": "Task", "Resource": "arn:aws:states:::sns:publish", "QueryLanguage": "JSONata", "Arguments": { "TopicArn": "${DsSnsTopicArn}", "Message": { "Input": "{% $states.input.Job.Settings.Inputs[0].FileInput %}", "Status": "{% $states.input.Job.Status %}", "Messages": "{% $states.input.Job.Messages %}" } }, "TimeoutSeconds": 30, "End": true } }, "TimeoutSeconds": 660, "Comment": "For converting a MP4 media to HLS (ABR 360p/720p)" } LoggingConfiguration: Destinations: - CloudWatchLogsLogGroup: LogGroupArn: !GetAtt LogGroupStateMachineMp4HlsConv.Arn IncludeExecutionData: true Level: ERROR RoleArn: !GetAtt StateMachineMp4HlsConvRole.Arn TracingConfiguration: Enabled: false Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} DependsOn: - LogGroupStateMachineMp4HlsConv - StateMachineMp4HlsConvRole - MediaConvertJobTemplate # ------------------------------------------------------------# # Step Functions State Machine LogGroup (CloudWatch Logs) # ------------------------------------------------------------# LogGroupStateMachineMp4HlsConv: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub /aws/vendedlogs/states/${SystemName}-${SubName}-mp4-hls-conv RetentionInDays: 365 Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} # ------------------------------------------------------------# # Step Functions State Machine Execution Role (IAM) # ------------------------------------------------------------# StateMachineMp4HlsConvRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${SystemName}-${SubName}-StateMachineMp4HlsConvRole Description: This role allows State Machines to invoke specified AWS resources. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: states.amazonaws.com Action: - sts:AssumeRole Path: /service-role/ ManagedPolicyArns: - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess - arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess - arn:aws:iam::aws:policy/AWSElementalMediaConvertFullAccess Policies: - PolicyName: !Sub ${SystemName}-${SubName}-StateMachineMp4HlsConvPolicy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "s3:GetObject" Resource: - !Sub "${S3Bucket.Arn}/input/*" - Effect: Allow Action: - "s3:PutObject" Resource: - !Sub "${S3Bucket.Arn}/output/*" - Effect: Allow Action: - "events:PutTargets" - "events:PutRule" - "events:DescribeRule" Resource: - !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/StepFunctionsGetEventsForMediaConvertJobRule" - Effect: Allow Action: - "sns:Publish" Resource: - !GetAtt SNSTopic.TopicArn DependsOn: - S3Bucket - SNSTopic # ------------------------------------------------------------# # EventBridge Rule for starting State Machine # ------------------------------------------------------------# EventBridgeRuleStartSfn: Type: AWS::Events::Rule Properties: Name: !Sub ${SystemName}-${SubName}-mp4-hls-conv-start-sfn Description: !Sub This rule starts mp4 hls converter Sfn for ${SystemName}-${SubName}. The trigger is the S3 event notifications. EventBusName: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/default" EventPattern: source: - "aws.s3" detail-type: - "Object Created" detail: bucket: name: - !Ref S3Bucket object: key: - wildcard: "input/*.mp4" State: ENABLED Targets: - Arn: !GetAtt StateMachineMp4HlsConv.Arn Id: !Sub ${SystemName}-${SubName}-mp4-hls-conv-start-sfn RoleArn: !GetAtt EventBridgeRuleSfnRole.Arn DependsOn: - EventBridgeRuleSfnRole # ------------------------------------------------------------# # EventBridge Rule Invoke Step Functions State Machine Role (IAM) # ------------------------------------------------------------# EventBridgeRuleSfnRole: Type: AWS::IAM::Role Properties: RoleName: !Sub ${SystemName}-${SubName}-EventBridgeHlsSfnRole Description: !Sub This role allows EventBridge to invoke mp4 hls converter Sfn for ${SystemName}-${SubName}. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - events.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: !Sub ${SystemName}-${SubName}-EventBridgeHlsSfnPolicy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "states:StartExecution" Resource: - !GetAtt StateMachineMp4HlsConv.Arn DependsOn: - StateMachineMp4HlsConv # ------------------------------------------------------------# # SNS Topic # ------------------------------------------------------------# SNSTopic: Type: AWS::SNS::Topic Properties: TracingConfig: PassThrough DisplayName: !Sub ${SystemName}-${SubName}-mp4-hls-conv FifoTopic: false Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName}   AWS Elemental MediaConvert は、前提として MediaConvert に割り当てる IAM ロールが必要になります。以下のドキュメント参考に作成が必要ですが、今回は雑に広い権限を作成をしています。以前は権限決め打ちで、かつ AWS アカウント共通で持たないといけなかった記憶がありますが、今は細かく定義できるようになっています。 Setting up IAM permissions - MediaConvert Set up an AWS Identity and Access Management (IAM) role to use with AWS Elemental MediaConvert. docs.aws.amazon.com AWSTemplateFormatVersion: 2010-09-09 Description: The CloudFormation template that creates a MediaConvert_Default_Role in your AWS account. It is needed when you create MediaConvert jobs. Resources: # ------------------------------------------------------------# # Elemental MediaConvert Default Role (IAM) # ------------------------------------------------------------# MediaConvertDefaultRole: Type: AWS::IAM::Role Properties: RoleName: MediaConvert_Default_Role Description: This role allows MediaConvert to execute jobs. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - mediaconvert.amazonaws.com Action: - sts:AssumeRole Path: /service-role/ ManagedPolicyArns: - arn:aws:iam::aws:policy/AmazonS3FullAccess - arn:aws:iam::aws:policy/AmazonAPIGatewayInvokeFullAccess   まとめ いかがでしたでしょうか? 以前作成した記事が古かったので、最新情報でアップデートしました。 本記事が皆様のお役に立てれば幸いです。
アバター
LifeKeeperの『困った』を『できた!』に変える!サポート事例から学ぶトラブルシューティング&再発防止策 こんにちは、SCSKの前田です。 いつも TechHarmony をご覧いただきありがとうございます。 システム基盤の主戦場がオンプレミスからパブリッククラウドへと移り変わり、AWSやAzure上でHAクラスタを構築する機会がぐっと増えましたよね。クラウドには 「自動復旧」 や 「フルマネージドサービス」 といった便利な機能が豊富に揃っており、一見するとシステムの可用性を担保するのはとても簡単になったように感じられます。 しかし、HAクラスターソフトウェアであるLifeKeeperをクラウド環境で運用する場合、この 「便利な標準機能」や「クラウド特有の仕様」が思わぬ牙を剥くことがあります。 良かれと思って有効にしたクラウド側の自動復旧機能が、LifeKeeperのフェイルオーバー動作と競合してしまったり、オンプレミスでは意識しなかった「ネットワークエンドポイント」や「アクセス権限」の壁に阻まれ、リソース設定がエラーで弾かれたりといった、予期せぬトラブルに直面することがあります。 本連載企画「LifeKeeper の『困った』を『できた!』に変える!サポート事例から学ぶトラブルシューティング&再発防止策」では、サポートセンターに蓄積された「生のトラブル事例」を元に、安定運用のための実践的な知恵を共有していきます。 はじめに 今回からスタートする新シリーズのテーマは、「カテゴリ3:クラウド環境特有の落とし穴:AWS/Azure連携でハマるポイント」です。 第一弾となる本記事では、AWS環境(EC2, Route53, S3)におけるLifeKeeperの構築・運用にフォーカスします。 良かれと思って設定したAWSの「Auto Recovery(簡易自動復旧)」が引き起こすクラスタ停止劇や、Route53連携・S3 Quorum設定時に見落としがちなネットワーク設計と権限の盲点など、実際に現場で発生したお問い合わせ事例を深掘りします。 「なぜAWS連携がうまくいかないのか?」「どう設計すればクラスタの単一障害点を防げるのか?」——AWS特有の仕様を正しく理解し、手戻りなく安定稼働させるためのノウハウを一挙に公開します! その他の連載企画は以下のリンクからどうぞ! 【リソース起動・フェイルオーバー失敗の深層 #1】EC2リソースが起動しない!クラウド連携の盲点とデバッグ術 – TechHarmony 【リソース起動・フェイルオーバー失敗の深層 #2】ファイルシステムの思わぬ落とし穴:エラーコードから原因を読み解く – TechHarmony 【リソース起動・フェイルオーバー失敗の深層 #3】設定ミス・通信障害・バージョン違いの深層と再発防止策 – TechHarmony 【OS・LKバージョンアップで泣かないために #1】OSバージョンは変えていないのに!?カーネル更新の「落とし穴」と互換性の真実 – TechHarmony 【OS・LKバージョンアップで泣かないために #2】「設定が消えた!?」「亡霊IPが警告!?」を防ぐロードマップ:単純な上書き更新に潜む落とし穴と回避策 – TechHarmony 今回の「困った!」事例3選と解決策 事例1:良かれと思った「AWS Auto Recovery」が引き起こすクラスタ停止劇 【事象の概要】 「AWSの標準機能だから有効にしておけばより安心だろう」と、EC2インスタンスの「簡易自動復旧(Auto Recovery)」を有効にして運用していました。ある日、稼働系のEC2に障害が発生。待機系へのフェイルオーバー自体は行われたものの、 障害のあった稼働系EC2も自動復旧で起動 してきてしまい、想定外の挙動をしてしまいました。 【判明した根本原因】 LifeKeeperの「EC2リソース(Recovery Kit for EC2)」は、異常を検出するとまずローカルリカバリー(自身の再起動など)を試み、ダメならリソースを停止して対向ノードへフェイルオーバーします。 しかし、ここにAWSの Auto Recovery が介入し、LifeKeeperが異常を検知・処理する前にOSごと再起動させてしまうとどうなるでしょう? 対向ノードがノード異常を検出してフェイルオーバーを開始する一方で、再起動した旧稼働系のノード上でもLifeKeeperがサービスを再開しようと動き出します。結果として動作が競合し、最悪の場合は 両ノードでLifeKeeperのリソースが停止し、サービス全体がダウンするリスク が生じます。 【具体的な解決策と学び】 ベストプラクティス:  LifeKeeperのEC2リソースを利用する場合は、AWSの「Auto Recovery(簡易自動復旧やCloudWatchアクションベースの復旧)」との 併用は非推奨 です。 復旧の主導権はLifeKeeperに一本化し、システム全体の確実なフェイルオーバーを優先する設計にしましょう。 事例2:Route53リソースが作れない!見落としがちな3つの原因 【事象の概要】 Route53リソースを作成しようとしたところ、「Hosted Zoneが見つからない」という旨のエラー画面が表示され、リソース作成に進めません。AWS側の権限(IAMポリシー)は間違いなく付与しているはずなのに、なぜ情報を取得できないのでしょうか。 【判明した根本原因】 サポートとの切り分けの結果、主に以下の3つの「隠れた原因」でAWS CLIによるRoute53へのアクセスが失敗することが判明しました。 プロキシ設定の未反映 :  OSにプロキシ設定をしていても、LifeKeeperのプロセスがそれを認識していない。 カスタムSSL証明書の読み込み漏れ :   AWS_CA_BUNDLE  などの環境変数を使用しているが、正しくエクスポートされていない。 同名のHosted Zoneの存在(LifeKeeperの仕様) : パブリックとプライベートで「同じ名前のHosted Zone」が存在すると、Multiple zone matchedとなりリソース作成に失敗する仕様となっている。 【具体的な解決策と学び】 環境変数は  export  まで確実に:  プロキシやカスタムSSLの環境変数を利用する場合、 /etc/default/LifeKeeper ファイルの末尾に以下のように export コマンドを含めて追記し、LifeKeeperを再起動する必要があります。 <記述例> AWS_CA_BUNDLE=/etc/pki/tls/certs/ca-bundle.crt; export AWS_CA_BUNDLE HTTP_PROXY=http://192.168.x.x:8080; export HTTP_PROXY   Hosted Zone名の一意化: パブリックとプライベートで同名のHosted Zoneは使用せず、重複しない名前にするか、リネームして対応します。 ※おまけの注意点 バージョンアップ(例: v9.3.x → v9.8.x)を行うと、Route53の監視処理(quickCheck)スクリプトなどが最新の 標準ファイルで 上書き されます。手動でスクリプトをリネームして無効化するなどの運用を行っている場合は、 アップデート後の再設定を忘れないようにしましょう。 事例3:S3 Quorumを導入したのにF/Oしない?盲点は「NAT Gatewayの配置」 【事象の概要】 スプリットブレイン(ネットワーク分断時に両ノードがアクティブになってしまう現象)対策として、S3を利用したStorage Quorum(aws_s3)を設計しています。 しかし、「AZ(アベイラビリティゾーン)障害が起きた際、生き残った側のノードが正しくS3にアクセスしてアクティブになれるのか?」という懸念が浮上しました。 【判明した根本原因】 AWS環境でS3へアクセスする際、インターネットを経由する「NAT Gateway」を利用する構成がよく採られます。しかし、 NAT Gatewayを1つのAZにしか配置しない( シングル構成 ) と、そのAZがダウンした瞬間に、生き残ったもう一方のAZにあるLifeKeeperからもS3へアクセスできなくなります。 S3にアクセスできない(Quorumとしての多数決が取れない)状態になると、LifeKeeperは安全を優先して自身をアクティブにする判断を行わず、結果としてフェイルオーバーが実行されません。 NG構成                   OK構成 S3 Quorum構成時のNAT Gateway配置(NG構成 vs OK構成) 【具体的な解決策と学び】 S3アクセス経路の冗長化 : マルチAZ構成のLifeKeeperクラスタでは、NAT GatewayもそれぞれのAZに配置(冗長化)し、各ノードが自身のいるAZのNAT Gatewayを経由してS3へアクセスできるようにルートテーブルを設計することが必須です。 必要なIAMポリシーの過不足ない付与 :  S3 Quorumを正常に動作させるためには、バケットに対する  s3:PutObject ,  s3:GetObject ,  s3:ListBucket ,  s3:GetBucketLocation  の4つの権限をIAMロールに必ず付与してください。 「再発させない!」ための対応策と学び(チェックリスト) 今回の事例から導き出された、設計・構築時に必ず確認したい「AWS環境構築チェックリスト」です。現場のノウハウが詰まっていますので、ぜひご活用ください! ☑ AWSの自動復旧機能との切り分け  LifeKeeperの「EC2リソース」を使用する場合、対象インスタンスの 「Auto Recovery(簡易自動復旧)」は無効化 されているか? ☑ Route53連携・ネットワーク設定の確認  パブリックとプライベートで「同じ名前のHosted Zone」を作成していないか?(同名が存在するとLifeKeeperからリソース作成に失敗します)  プロキシ環境やカスタムSSL証明書を使用する場合、 /etc/default/LifeKeeper  に  export  コマンドを含めて正しく環境変数を定義し、再起動しているか? ☑ S3 Quorum(スプリットブレイン対策)の確実な構成  IAMロールにS3 Quorum動作の必須ポリシー( s3:PutObject ,  s3:GetObject ,  s3:ListBucket ,  s3:GetBucketLocation )が付与されているか?  異なるAZからS3へアクセスする際、NAT Gateway等の通信経路は「各AZに冗長配置」されているか?(AZ障害時にS3へアクセスできないとQuorumが機能しません) ☑ 運用・保守時の注意(バージョンアップ時など)  LifeKeeperのバージョンアップを実施する際、過去に手動でリネーム・カスタマイズしたスクリプト(quickCheckなど)が上書きされることを手順書に盛り込み、再設定を計画しているか? まとめ AWSの各種サービス(EC2, Route53, S3など)とLifeKeeperは非常に相性が良く、強力な可用性を実現できます。しかし、それぞれの「仕様の壁」や「機能の重複」を理解していないと、いざという時に想定外の動きをしてしまいます。 「復旧の主導権をどちらに持たせるのか(LifeKeeper優先)」「LifeKeeperのプロセスがAWS側の権限やネットワーク経路を正しく認識できているか」の2点を意識するだけで、トラブルの多くは未然に防ぐことができます。 日々の設計や運用の中で、ぜひ本記事のチェックリストをご活用いただき、安定したクラウドHAクラスタ基盤を実現してください! 次回予告 次回は、同じくクラウド基盤として利用が拡大している Azure をテーマにお届けします。 「連載企画カテゴリ3 第二弾:Azure環境でのLifeKeeper構築・運用:ネットワークとQuorum設計の要点」をお送りする予定です。Azureならではの落とし穴とその回避策について解説しますので、どうぞお楽しみに! 詳しい内容をお知りになりたいかたは、以下のバナーからSCSK LifeKeeper公式サイトまで
アバター
こんにちは!SCSKの新沼です。 VMware環境の監視において、こんな悩みを抱えていませんか? 「膨大な数の仮想マシンを、1つずつZabbixに手動登録するのは大変…」 「すべての仮想マシンにZabbixエージェントを入れる必要があるの?」 今回は、Zabbixの標準機能(VMwareテンプレート)を利用して、VMware ESXiホストおよび、その上で稼働する仮想マシン(以下VM)を全自動で登録・監視する手順をご紹介します。 1. VMware監視の仕組みと構成 設定に入る前に、ZabbixにおけるVMware監視の仕組みを整理します。 従来の監視手法では、仮想マシン(ゲストOS)1台ずつにZabbixエージェントをインストールするのが一般的でした。しかし、この方法には以下のような課題があります。 VMの数が多すぎて、手動でのホスト登録やエージェント導入に膨大な手間がかかる。 アプライアンス製品など、そもそもOSにエージェントをインストールできない仮想マシンが存在する。 VMが作成・削除されるたびに、Zabbix側の監視設定も手動で追加・削除しなければならない。 これらを一気に解決するのが、Zabbix標準の VMwareテンプレート と ローレベルディスカバリ(LLD) の組み合わせです。   <メリット> 完全エージェントレス : Zabbixサーバーが直接ESXi(またはvCenter)のAPIと通信し、ハイパーバイザー側から見たCPUやメモリ、ストレージの利用状況を取得します。VM側には一切手を加えません。 LLDによる自動追従 : ESXiをZabbixに1つ登録するだけで、その配下にあるVMやデータストアをZabbixが自動的に見つけ出します。今後VMが増減しても、Zabbixが自動で監視対象に追加・削除してくれます。 2. 検証環境 本記事では、以下の環境を前提として設定手順を解説します。 Zabbix Server : 構築済みのZabbixサーバ (Version 7.0) 監視対象 : 1台のESXiホスト ESXi上のVM : 以下の2台を事前に作成済み Web-Server-Test DB-Server-Test この1台のESXiをZabbixに登録し、最終的に2台の仮想マシンがZabbix上に自動登録されることを目指します。 3. 事前準備①:ESXi側でのユーザー作成 ZabbixがAPI経由でESXiにアクセスするための認証情報を用意します。 セキュリティの観点から、 root ユーザーではなく Zabbix監視用の読み取り専用(Read-only)ユーザー を新規作成します。 ESXiのWeb管理画面にログインします。 左メニューの  「ホスト」  >  「管理」  >  「セキュリティとユーザー」  >  「ユーザー」  タブを開きます。 「ユーザーの追加」をクリックし、ユーザー(例: zabbix-monitor )とパスワードを設定して作成します。 次に、左メニューの  「ホスト」  を右クリックし、 「権限」  をクリックします。 「ユーザーの追加」をクリックし、先ほど作成した  zabbix-monitor  に対して  「読み取り専用 (Read-only)」 ロールを割り当てて保存します。 画像の赤枠のように、読み取り専用ロールがついたアカウントがあることを確認。 4. 事前準備②:Zabbix Server側のプロセスチューニング Zabbixのデフォルト設定では、VMware環境からデータを収集するための専用プロセスが停止しています。設定ファイルを編集してプロセスを起動させます。 Zabbix ServerにSSH等でログインし、設定ファイルを開きます。 $ sudo vi /etc/zabbix/zabbix_server.conf 以下の2つのパラメータを探し、コメントアウトを外して値を設定します。  # VMwareのデータを収集するプロセス数(デフォルト0、小~中規模なら3~5程度) StartVMwareCollectors=5 # APIから取得したデータを保存するキャッシュサイズ(VM数が多い場合は拡張推奨) VMwareCasheSize=64M 設定を保存後、Zabbixサーバープロセスを再起動して設定を反映させます。 $ sudo systemctl restart zabbix-server 5. Zabbixフロントエンドでのホスト登録 準備が整ったら、ZabbixのWeb管理画面からESXiを登録します。 ステップ1:ホストの作成 Zabbixの管理画面にログインし、 「データ収集」  >  「ホスト」  を開きます。 右上の  「ホストの作成」  をクリックします。 「ホスト」タブで以下を入力します。 ホスト名 :  ESXi-Test  (任意の名前) テンプレート :  VMware  を選択 ホストグループ :  Virtual machines  等の任意のグループ 💡 ポイント:インターフェース ZabbixのVM監視は、APIによる監視を行うのでインターフェースの設定は不要です。ここでは、エージェントを選択してデフォルトのまま設定しています。 ステップ2:マクロ(認証情報)の設定 次に、上部の「マクロ」タブを開き、「継承とホストマクロ」を選択します。VMwareテンプレートで定義されている以下の3つのマクロの「値」を入力します。 {$VMWARE.URL}  :  https://<ESXiのIPアドレス>/sdk {$VMWARE.USERNAME}  :  zabbix-monitor (先ほどESXiで作ったユーザー) {$VMWARE.PASSWORD}  : (設定したパスワード) 💡 ポイント:URLの末尾に注意 URLを設定する際、単なるIPアドレスではなく、vSphere APIのエンドポイントである /sdk を必ず末尾に付与してください。これを忘れるとAPI通信に失敗します。 入力が完了したら、画面下部の「追加」ボタンをクリックしてホストを保存します。 ※画像は、作成後のものなので、「ホストマクロ」タブになっていますが、「継承したマクロとホストマクロ」のタブで入力して「追加」をクリックしてください。 6. ディスカバリ(自動登録)の確認 ホストを追加すると、Zabbixが設定した間隔(デフォルトは1時間)でESXiと通信し、仮想マシンやデータストアの情報を自動的に取得(ディスカバリ)し始めます。 →すぐに確認したい場合は、ホスト>ディスカバリから、すべてのディスカバリルールを選択して、「監視データ取得」をクリックしてください。 💡 ポイント:初回登録時の「取得不可」エラーについて 登録直後にホストの「ディスカバリルール」画面を見ると、ルールが赤色の「取得不可」状態になることがあります。これは設定ミスではなく、 Zabbixのバックグラウンドプロセスがデータを収集し、キャッシュに保存し終わるまでの一時的な状態 です。 焦らずに5〜10分程度待つと、自動的に緑色の「有効」状態に切り替わります。 自動登録されたホストの確認 キャッシュが溜まり、ディスカバリが正常に実行された後、 「データ収集」 > 「ホスト」 の一覧画面を再度確認します。 手動で登録した ESXi-Test ホストの他に、事前準備で作成しておいた Web-Server-Test や DB-Server-Test といった仮想マシンが、別々のホストとして自動的に追加されている ことが確認できます。自動登録されたホストは、名前の横にディスカバリのリンクアイコンが表示されます。   7. 【補足】本番環境(vCenter)での運用について 今回は検証のため、「1台のESXiホスト」を直接Zabbixに登録し、その上の仮想マシンを自動検出する方針で解説しました。 しかし、実際の運用現場では複数のESXiホストを「vCenter Server」で一元管理しているケースが多いと思います。その場合、 各ESXiをZabbixに1台ずつ登録する必要はありません。 Zabbix側に「vCenter」を1つだけホストとして登録し、マクロにvCenterの認証情報を設定するだけでOKです。 ZabbixがvCenterのAPIを叩き、その配下にある 「複数のESXiホスト」も「すべての仮想マシン」も芋づる式に全自動で検出・登録してくれます。 本番環境に展開する際は、ぜひこの「vCenter起点」の構成で、劇的な監視運用の自動化を体感してください。 おわりに 以上がZabbixを利用したVMware ESXiおよび仮想マシンの自動監視設定の手順です。 VMwareテンプレートを使用することで、ハイパーバイザー自体のリソース状況をエージェントレスで取得できるだけでなく、仮想マシンが新規作成・削除された際の監視設定の追加・削除作業も全自動化されます。 仮想環境の監視運用を劇的に効率化できる強力な機能ですので、ぜひご自身の環境でも活用してみてください。                                                                 弊社ではZabbix関連サービスを展開しています。以下ページもご参照ください。 ★SCSK Plus サポート for Zabbix★ SCSK Plus サポート for Zabbix 世界で最も人気のあるオープンソース統合監視ツール「Zabbix」の導入構築から運用保守までSCSKが強力にサポートします。 www.scsk.jp ★YouTubeに、SCSK Zabbixチャンネルを開設しました!★ SCSK Zabbixチャンネル SCSK Zabbixチャンネルでは、Zabbixのトレンドや実際の導入事例を動画で解説。明日から使える実践的な操作ナレッジも提供しており、監視業務の効率化に役立つヒントが満載です。 最新のトピックについては、リンクの弊社HPもしくはXアカ... www.youtube.com ★X(旧Twitter)に、SCSK Zabbixアカウントを開設しました!★ https://x.com/SCSK_Zabbix x.com
アバター
SCSKの畑です。 今年度の Web アプリケーション開発関連のテーマは大体書きたいもの書けたからもう良いかなと思ってたんですが、本件がそれなりに大変だったことを今更思い出したので備忘として残しておこうと思います。   背景 本 Web アプリケーションの開発を始めたのが 2024 年の 5 月頃だったと思うのですが、その時点での最新版は Nuxt.js が 3.x 系、Nuxt UI が 2.x 系でした。事前調査で Nuxt.js は 2.x 系と 3.x 系で仕様がかなり異なることが分かっていたので最初から 3.x 系を入れたのですが、Nuxt UI の 3.x 系のリリースは今調べたら2025 年の 3 月ということでそもそも選択肢に上がらず。 今年度も当初は(他に優先すべきタスクがあったこともあり)特に移行することは考えていなかったのですが、お客さんから要望頂いた機能を実装するのに以下 URL のコンポーネントをどうしても使いたくなってしまい。更にその頃には既に Nuxt.js / Nuxt UI 共に 4.x 系がリリースされ始めており、Nuxt.js はまだしも Nuxt UI は 2.x からそろそろ上げておかないと EOL になってしまうかも?と思ったこともあって、少し手が空いたタイミングでやってしまうことにしました。 Vue Table Component - Nuxt UI A responsive table element to display data in rows and columns. ui3.nuxt.com ちなみに Nuxt UI 4.x では 2.x や 3.x では有料だった Pro コンポーネントが使用できるようになったため一気に 4.x に移行してしまうことも考えたのですが、その場合 Nuxt.js も 4.x 系への移行が必要になりそうだったので今回のタイミングでは断念しました。   移行ガイド 公式から移行ガイドが出ているので、まずはそれを見ながら進めていくことになります。特に 2.x / 3.x の非互換については「Changes from v2」セクション以降にまとまっているため、このセクションの内容については必ず確認しておきましょう。 Migration - Nuxt UI A comprehensive guide to migrate your application from Nuxt UI v2 to Nuxt UI v3. ui3.nuxt.com ただし、残念ながら非互換となる項目が網羅されている訳ではないようで、他にも動かないコンポーネントが大量に出てくる有様だったため、最終的にはほぼ全てのコンポーネントについて Nuxt UI のドキュメントとにらめっこしながら修正していくことになりました。 ということで、あくまで今回のケースに関する内容にはなりますが、上記 URL 以外の観点で修正が必要だったコンポーネントとその内容をざっくりまとめてみました。   個別に修正したコンポーネント 以下、順番に記載していきます。   FileUpload (Input から変更) 一部画面でブラウザからファイルをアップロードするために UInput コンポーネントを使用していたのですが、3.x に移行後は正常に動作しなくなってしまいました。3.x のマニュアルを見る限り使用方法は変わらないように見受けられたので原因が良く分からなかったのと、複数ファイルを同時にアップロードする要件も出てきたことから、コンポーネント自体を 3.x で追加された UFileUpload に変更することで解決しました。今思うと、UForm の validate のロジックに原因があった可能性が高そうですが・・ Vue FileUpload Component - Nuxt UI An input element to upload files. ui3.nuxt.com   Modal 2.x のサンプル実装だと以下 URL のように UCard と合わせて使用されているのですが、これをそのまま 3.x で動かしたところ UCard 部分が悪さをしているのか画面レイアウトがおかしなことになってしまいました。画面レイアウト上 UCard の使用がマストではなかったため、使用しない実装に変更しました。 Modal - Nuxt UI Display a modal within your application. ui2.nuxt.com   Progress インジケータの進捗状況を示す value プロパティが v-model ディレクティブに変更されています。使い方自体はこれまでと大きく変わりません。   SelectMenu プルダウンメニューのコンテンツを指すプロパティが options から items に変更されている他、プルダウンメニューにおけるラベルと値をコンテンツのプロパティにバインドする方法も変わっています。 2.x だと option-attribute でラベル、value-attribute で値のプロパティを指定していましたが、3.x ではラベルのプロパティは label 固定で、値のプロパティを指定する場合は value-key を使用します。なお、2.x/3.x どちらも値のプロパティを指定しない場合は選択したメニュー項目に対応する全ての値がバインドされるようです。   Table テーブルのコンテンツ(行データ)を指すプロパティが rows から data に変更されている他、列情報の定義方法(指定すべきプロパティ)も変更されています。また、テーブルデータを変換・加工してテーブル内に表示する場合や、何らかのアクションボタンなどをテーブルデータとは別の列として表示したい場合の実現方法が変わっています。 2.x の場合は列定義に対象列の情報のみを含めた上で、template 構文の中で列定義(key プロパティ)に対応した名前付きスロットを定義して行う形式でした。以下、該当部分を抜粋した実装例です。 <UTable :columns="TableCols" :rows="TableRows" :loading="!TableLoadStatus">     <template #status-data="{ row }">         <div class="flex items-center place-content-center">             <MTStatusBudge :table_name=row.name :display_normal=true class="ml-2"/>         </div>     </template>     <template #update-data="{ row }">         <UButton icon="i-material-symbols-edit" size="2xs" variant="outline" @click="getSettingModal('update_table', row.name)"/>     </template>     <template #delete-data="{ row }">         <UButton icon="i-material-symbols-delete" color="pink" size="2xs" variant="outline" @click="deleteTable(row.name, row.logi_name"/>     </template> </UTable> <script setup lang="ts"> TableCols.value = [   {label: "論理名", key: "logi_name", sortable: true }, {label: "物理名", key: "name", sortable: true }, {label: "編集可能組織", key: "groups" }, {label: "ステータス", key: "status", sortable: true }, {label: "ロック元テーブル", key: "locked_by", sortable: true }, {label: "編集者", key: "editor", sortable: true }, {label: "承認者", key: "author", sortable: true }, {label: "更新内容", key: "temp_changes" }, {key: "update" }, {key: "delete" }, ] </script>   3.x の場合は、以下のように template 構文で定義していた情報も列定義に含めるような形式になっているようです。編集・削除機能をボタンからプルダウンメニューに変更しているため、2.x の実装例とは等価になっていない部分がありますが。 Vue Table Component - Nuxt UI A responsive table element to display data in rows and columns. ui3.nuxt.com <UTable :columns="TableCols" :data="TableRows" :loading="!TableLoadStatus"> <script setup lang="ts"> TableCols.value = [ {header: "論理名", accessorKey: "logi_name" }, {header: "物理名", accessorKey: "name" }, {header: "編集可能組織", accessorKey: "groups" }, { header: "ステータス", accessorKey: "status", meta: { class: { th: 'text-center', td: 'text-center' } }, cell: ({ row }) => { return h( MTStatusBudge, { table_name: row.getValue('name'), display_normal: true } ) } }, {header: "ロック元テーブル", accessorKey: "locked_by" }, {header: "編集者", accessorKey: "editor" }, {header: "承認者", accessorKey: "author" }, {header: "更新内容", accessorKey: "temp_changes" }, { id: 'actions', cell: ({ row }) => { return h( 'div', { class: 'text-right' }, h( UDropdownMenu, { content: { align: 'end' }, items: getActions(row), 'aria-label': 'Actions dropdown' }, () => h(UButton, { icon: 'i-lucide-ellipsis-vertical', color: 'neutral', variant: 'ghost', class: 'ml-auto', 'aria-label': 'Actions dropdown' }) ) ) } }] </script>   Tabs 2.x ではタブの切替イベントを以下のように @change イベントで検知できたのですが、3.x ではこの仕組みが使えなくなっているようでした。実際の画面では選択されているタブに応じて表示するデータを変更する実装としていたため、影響が大きかったです。また、初期選択されているタブを指定する方法も変更されており、以下のように Tab_Items 内のインデックス値を指定する方法は使えず、合わせて実装の変更が必要となりました。 <UTabs :items="Tab_Items" :default-index="1" @change="onChangeTabs"> <script setup lang="ts"> const Tab_Items = ref([{ label: 'オリジナルデータ表示', icon: 'material-symbols:table-chart-outline', }, { label: '更新差分表示', icon: DiffTabIcon.value, }, { label: 'リレーションシップ(ERD図)表示', icon: 'material-symbols:dashboard-2-outline', }]) const onChangeTabs = (index: number) => { const tab_item = Tab_Items.value[index] // 以下、具体的なタブ切替時の処理内容を記述 // } </script>   一方 3.x における代替手段はというと、移行ガイドには @change の代わりに @update:modelValue を使用する旨記載があったものの 、Tabs の場合はタブの選択状態も合わせて変更する必要があるためその処理と合わせてどう実装するのかが良く分からず。v-model ディレクティブを使用する必要がありそうなことは分かったものの、2.x のように Tab_Items 内のインデックス値を指定しても正常に動作せず、どのような値を指定すべきか分からなかったのであれこれ試行錯誤する羽目になりました。 Vue Tabs Component - Nuxt UI A set of tab panels that are displayed one at a time. ui3.nuxt.com 結論としては、Tab_Item に value プロパティを追加した上でそのプロパティの値を指定することで対応するタブを選択することができました。タブ切替時の処理を含めて考えると上記 URL のサンプル通り v-model に computed() を指定するのが筋が良さそうだったのでそれも踏まえて以下のような実装としています。最も、このサンプルが正直分かり難かったのが実装に手間取った理由というか、@change からの移行パスとして分かるような形で書いておいて欲しかったところではありますが・・ <UTabs :items="Tab_Items" v-model="Tab_Activate"/> <script setup lang="ts"> const Tab_Items = ref([{ label: 'オリジナルデータ表示', icon: 'material-symbols:table-chart-outline', value: 'original_data', }, { label: '更新差分表示', icon: DiffTabIcon.value, value: 'diff_data', }, { label: 'リレーションシップ(ERD図)表示', icon: 'material-symbols:dashboard-2-outline', value: 'erd_view', }]) // 初期選択されるタブを変更 const currentTabValue = ref<string>('diff_data') const Tab_Activate = computed({ get() { return currentTabValue.value }, set(value: string) { currentTabValue.value = value // valueに基づいて対応するタブ項目を検索 const tabItem = Tab_Items.value.find(item => item.value === value) // 以下、具体的なタブ切替時の処理内容を記述 // } }) </script>   Toast(旧 Notification) 移行ガイドの内容以外で 1 点使い勝手が大きく変わっているところがありました。画面上に表示した特定のポップアップを削除する場合の方法が変更されています。 2.x の場合は以下のように、ポップアップ表示(toast.add)時に任意の id を定義した上で、その id をポップアップ削除(toast.remove)の引数に指定することで対象のポップアップを削除します。 toast.add({ id: 'toast_sample', title: 'toastのサンプル表示です。'}) toast.remove('toast_sample') 3.x の場合はこの方法が使用できなくなっており、 その代わりに toast.add の返り値として返却された id を toast.remove 時に指定する方法に変更されているようです。ただ、これが 3.x のマニュアルのどこにも書いておらず、調べるのに結構苦労しました。。 const toast_info = toast.add({title: 'toastのサンプル表示です。'}) toast.remove(toast_info.id)   NavigationMenu(旧 HorizontalNavigation, VerticalNavigation) 移行ガイドだとサラッと名前が変わっている程度に受け取れなくもないのですが、実態としては上記 2 つのコンポーネントが統合されているので、両方のコンポーネントを使用している状態で単純に名前を置換しただけだと画面がえらいことになります。メニューの並べ方 (horizontal or vertical) は orientation オプションで指定します。 他、2.x ではメニュー構造のカスタマイズをするためには #default スロットを使用する必要がありましたが、3.x の場合は children オプションでメニューをネストできるため、その目的で #default スロットを使用していた箇所を変更しました。VerticalNavigation の場合は 以下 URL の通り Accordion と組み合わせることでメニューのネスト構造を実現していた箇所もあったのでそちらも合わせて変更しています。相対的にシンプルな実装にはなったのでこの変更自体は良かったですが、変更箇所は多岐に渡りました。 Accordion - Nuxt UI Display togglable accordion panels. ui2.nuxt.com   まとめ 来年は Nuxt.js / Nuxt UI 共に 4.x 系に上げないといけないかなーと思っています。どちらも 2.x 系から 3.x 系に上げるのよりは大変じゃないよ!みたいなことが書いてあったので、いまのところは楽観視していますが。それより先にバックエンド処理に使用している Lambda の Python バージョンアップをまずやらないといけなさそうなのがちょっと厄介そうです。 ざっと書いたこともあり全量を網羅できているかちょっと怪しいので、もし他に思い出したら追記しようと思います。 本記事がどなたかの役に立てば幸いです。
アバター
TechHarmonyエンジニアブログでは、 AWS・Oracle Cloud・Azure・Google Cloud 各分野の受賞者 にフォーカスし、インタビューを通してこれまでの経歴や他の受賞者に聞いてみたいことをつないでいく「 リレーインタビュー 」をお届けしています。 第4弾は、「2025 Japan AWS Top Engineers」 を受賞された 畑 健治(はた けんじ)さん。 Japan AWS Top Engineers は、特定の AWS 認定資格を持ち、AWS ビジネス拡大につながる技術力を発揮した活動を行っている方、または技術力を発揮した重要な活動や成果がある方が選出されるプログラムです。 日々どのようにAWSと向き合い、どんな経験を積み重ねてきたのか。 そして、受賞に至るまでの背景には、どのようなキャリアストーリーがあったのでしょうか。 本インタビューでは、畑さんのこれまでの経歴やAWSへの向き合い方、さらに「次の受賞者へ聞いてみたいこと」まで、じっくりとお話を伺いました。 プロフィール 2025 Japan AWS Top Engineers 所属: クラウドサービス事業本部A&C部第三課 氏名: 畑 健治   【自己紹介】 元々はインフラ寄りのデータベースエンジニアでしたが、所属組織におけるテックリード的役割から「周りにできる人がいなさそうな技術/製品/サービス」を様々な案件などで触っていた結果、ここ数年で フルスタックエンジニア のような データベースエンジニア のような何かになりました。今年度も色々な案件に顔を出して、手を動かしながら口を出しています。   本編 AWSエンジニアになった背景を教えてください。 AWSに関わり始めたきっかけは、2019年頃に参画した金融系の大型案件です。目的はいわゆるAWSへの 「リフト」 でした。当時はデータベースエンジニアが主な役割でしたが、システムが大規模かつ複雑だったため、AWS環境全体の構成や設計まで理解する必要があり、これがAWSと深く向き合う契機となりました。 そして翌年頃からは、 Redshift などを活用してAWS上に DWHや情報基盤を構築 するサービス開発が本格化し、複数の案件に参画する中で、本格的にAWSを扱うようになりました。 さらにここ数年は、AWS サーバレスアーキテクチャ を用いた Webアプリケーションの実装 まで経験し、(一応)フルスタックエンジニアを名乗れるところまで成長できました。こうした経験の積み重ねもあり、現在は テックリード として周囲から技術面で頼りにされる存在になることができ、工数をやりくりしながら 色々な 案件 や 技術的な 相談 などに携わっています 。 エンジニアとして大切にしている価値観や信条はありますか? 特に自分にとって未知の技術/製品/サービスなどを触る時は、まず何にせよ 手を動かして 、対象の輪郭というか 掴み感 のようなもの を 最初に捉える ことを大事にしています。最初から詳細にこだわりすぎていると工数や時間がいくらあっても足りないのでまずは スピードを最優先 にしつつ掘り下げるべきところに目星を付けておいて、 後々のフェーズでじっくり調査・検討 するみたいなスタイルが自分の性分にも合っているようです。マニュアルやドキュメントは後から読むタイプで、昔はそれで幾度も痛い目に遭っていたのですが、ここ数年でちょうど良いバランスというか勘所を見つけられた気がします。特に、AWSのような 責任共有モデル や クラウドネイティブ という世界観において、ユーザの責任範囲が限定的である以上 相応の割り切りは必要 と考えていて、その前提でいかに上手く落としどころを見つけることが大事なのだなと改めて感じています。 この度は受賞おめでとうございます! 受賞に至るまで特に重点を置いて取り組んできたこと・乗り越えたチャレンジを教えてください。 受賞のための活動という意味合いですと、本賞へのチャレンジを決断したタイミングが遅く、社外発信の クライテリアを満たす ために ブログ を頑張って書いたことくらいでしょうか。。 特に意識 したのは 以下の2点です。 ・主なトピック(技術的な内容)だけではなく、 なぜそのトピックに 言及する に至ったかの 理由 や プロセス も 同じくらい重要と考えるため、なるべく記載しています。 ・内容が似ている他の記事が存在しない or 少ないトピックを取り上げるよう意識しています。 同じような内容の記事が少ないほど 単純に自分の書いた記事が 他の人の役に立つ 可能性が高くなると思っていて、それが自身の情報発信のモチベーションにも繋がるためです。 受賞がご自身のキャリアやチームに与えた影響はありますか? まず何よりも、自分自身がこれまでやってきたことがこのような形で認められたことが自信となりました。もう少し俗っぽい話だと、提案資料などに載せる 要員説明で書けることが1個増えた ということもあるのですが、反面この 肩書に見合うだけの価値 を お客様に 提供 しなければならないという自覚もとい プレッシャーも ひしひしと感じているところです。 また、私自身は必ずしもAWSのみを主な業務としている訳ではないのですが、そのような役割の人間においても巡り合わせ次第で十分にチャンスがあることをチームに伝えるようにしています。もちろん受賞できればそれに越したことはないのですが、もし仮に受賞に至らずとも、そのための活動の過程において、温めていたいわゆる良いネタを 発信 する機会 になったり、自分自身の 実績・キャリアの棚卸し や振り返りをする機会になったりなど、当初 考えていた以上に得るものがあった と実感しているためです。 今後、個人として、挑戦してみたい新しい技術・分野や、目指している目標について教えてください。 いずれにせよ 生成AI関連 については今後も否応なしに様々な形で関わっていくことになると思います。 KiroのSPECやAI-DLC を初めとする一連の開発・構築プロセス自体の変化も控えている中で 、いかに その 大きな流れに 適応 していけるのかが喫緊の課題だと感じています。 個人としては正直特にないのですが、今後組織の中でそのような重点的な取り組みが必要な技術的課題やトレンドなどがまた新しく出てきた時に声をかけてもらえるような存在になれるよう/そうであり続けられるよう、技術面については 良い意味で何でも屋として 最前線に立ち続け られるよう、引き続き精進していきたいです。 その上での 現実的な 目標 として は、そろそろ Amplify gen2 をちゃんと触って、 gen1 を使用しているアプリケーションの移行を検討 しないといけないかなーと思っているところです。 前回のリレーインタビュー での寺内 康之さんから 畑さんへのご質問です。ご回答をお願いいたします。 畑さんはDBからアプリケーションまで 幅広いIT技術 を 習得 されていますが、コンピュータに触れていて 一番楽しいと思う点 はどんなところにありますか 「一番楽しい」の定義がちょっと難しいのですが、瞬間最大風速という観点だと トラブルシュート ですかね。何かよく分からないけど正常に動かない、とされているものの 原因をあれこれ探って、 最終的に解決できた時の快感は代えがたい です。 反面、その原因が自分自身にある場合は諸刃の剣にもなるのですが、現実世界の複雑さを鑑みると、 コンピュータからお前が悪いと冷徹に突きつけられるのも悪くない のかもしれません。 次のインタビューは AWS Top Engineers の「福地 孝哉」さんです!福地さんにお聞きしたいことはありますか? 福地さんはいわゆる現場部隊からコーポレート部隊へ異動されたとお伺いしていますが、 異動に伴い 自身の意識や取り組みとして 特に変化したこと があれば教えてください。 畑 健治さん、ありがとうございました! 最後に、読者の方へメッセージをお願いいたします! ある意味良くも悪くも様々な技術に触れてきた経験から、普段の仕事や業務とはまた別の観点として、自分の やる気やモチベーションが 本質的に どこにあって、何が充足されれば とりあえず 走り続けられるか を知ることが大事だと思っています。   次回インタビューは、2025 Japan AWS Top Engineers を受賞された 福地 孝哉(ふくち たかや)さんです。 次回の記事もお楽しみにお待ちください!
アバター
Microsoft ESI を通じて Microsoft Foundry の講義を受講しました。 ラボ環境でFoundryを触る中で、「面白い。自分でもAIエージェントを作ってみたい」と思いが湧いてきました。 Microsoft ESI とは Microsoft ESI(Enterprise Skills Initiative) のまとめと体験記の記事です。 blog.usize-tech.com 2026.02.26 今回は応用情報技術者試験の予想問題を生成する AI エージェントの作成を試みます。 Microsoft Foundryとは Microsoft Foundry とは - Microsoft Foundry Microsoft Foundry は、開発者が安全で安全で責任ある方法で AI を使用してイノベーションを推進し、未来を形成できるようにする信頼できるプラットフォームです。 learn.microsoft.com AIエージェントの構築、運用を行うための開発基盤です。旧 Azure AI Foundry。 Foundryポータル Microsoft Foundry Azure AI Foundry ai.azure.com 上記urlからFoundryのポータルに入ります。 プロジェクト作成→エージェント作成という流れで実施していきます。 プロジェクト作成 ビルトを開始すると「新しいプロジェクトの作成」がでてきます。 リソースグループやリージョンを指定します。               Japan Eastだとデプロイに失敗するかもしれないです。 選択したリージョンに割り当て可能な空きがないと出てきました。               East US 2 だと成功しました。 エージェントの作成 ビルドの開始>エージェントの作成           エージェント名を入力             これでポータルに到達しました。               AIエージェントのカスタマイズ 手順とツールを活用します。 (1)手順にはプロンプトを入力 chat gptにAIが理解しやすいプロンプトを考えてもらいました。 あなたはIPA試験(応用情報技術者試験)の優秀な問題作成者です。 以下の指示に従って次回のテストにそのまま出題されるレベルの予想問題ファイルを全自動で作成・出力してください。 【ステップ1:過去問の分析と予測】 アップロードされた過去問ファイルを読み込み、過去の出題傾向、頻出キーワード、出題のロジックを深く分析してください。 その上で、まだ出題されていないテーマor次回狙われそうなテーマor重要な頻出テーマをベースに、「本番テスト(午後問題)と全く同じ形式・難易度」のオリジナルの予想問題を大問1問作成してください。 【ステップ2:構成要素と予測問題の生成】 「1. 問題文の構成ルール」 問題文は 背景・現状説明のセクション、加えて角括弧 [ ] の見出しを付けた4〜5 個のセクション(節)で構成すること。 以下はセクション構成の例。 (背景・現状説明) 背景、企業・組織の概要、システムの目的、登場人物などを説明する。 [システム構成] ネットワーク構成、サーバ構成、利用サービス、運用状況などを説明する。 図表・ログ・設定ファイルの抜粋などが含まれる。またそれらには注記があり、情報が補足される。 [問題の発生] インシデント、脆弱性の指摘、設定ミス、攻撃の兆候などを提示する。 [追加情報] 会話文、メール文、アクセスログ、設定ファイル、図表などを提示する。 [検討内容] 担当者の議論、改善案、対策案などを示す。 「2. 設問の構成ルール」 設問は 4〜5 問作成すること。 回答形式は以下のいずれかとする。 ・語句の選択問題 ・語句の記述問題 ・計算問題 ・20〜40字程度の記述問題 「3. 最終出力形式」 以下の形式で出力すること。 問1 情報セキュリティ ○○に関する次の記述を読んで、設問に答えよ。 「問題文」 (背景・現状説明) (本文) [現状のシステム構成] (本文+図表・ログなど) [問題の発生] (本文) [追加情報] (本文+ログ・会話など) [検討内容] (本文) 「設問」 設問1: 設問2: 設問3: 設問4: 設問5: 【ステップ3:画像の生成とファイルの自動出力】 問題が完成したら、直ちにコードインタープリター(Python)を起動し、以下の処理を行ってください。 Pythonの描画ライブラリ(matplotlib, Graphviz, または PILなど)を使用して、ステップ2で指定した「図表(画像)」を生成し、PNG画像として保存してください。 生成した画像(<img>タグ等で埋め込み)を含む、見出しや箇条書きで綺麗にフォーマットされた**「HTMLファイル(次回テスト用_予想問題集.html)」**を作成してください。画像をHTML内にBase64エンコードで直接埋め込んでも構いません。 ユーザーがワンクリックでダウンロードできる「HTMLファイルのリンク」のみを提示して処理を終了してください。 (2)ツールの設定 以下2点実施 ・応用情報過去問のファイルの添付 R7春、R6秋、R6春のtxtファイルを読み込ませる。→本番のR7秋と比較して、予測できているか確かめるため。               ・コードインタープリターの有効化                           以上でカスタマイズ完了です。 予想問題 いざ、エージェントにメッセージ送って予想問題作ってもらいました。 結果がこれ。                               問題文が短すぎる点、図が崩れている点が気になるものが出力されました。 プロンプトに工夫が足りないとは思いますが、図に関しては学習データに入れられていないの原因かも。 AIの予測ではゼロトラスト、SIEM、MFA認証など盛りだくさんでした。 答え合わせとしてR7秋の本試験をみると、選択肢の中にゼロトラストとSIEMがあり全くダメとわけではなさそう。 特にSIEMに関しては正解の選択肢でした。2点分くらいの予測ですね、、 情報技術者試験の再編 情報処理技術者試験の大幅刷新案、応用・高度試験を再編 2027年度から 情報処理技術者試験が大きく変わる。応用情報技術者試験と、9つに分かれていた高度試験を「プロフェッショナルデジタルスキル試験」として3領域・3試験に再編する他、非エンジニアがITパスポート試験の次に受けるべき試験として「データマネジメント試験... xtech.nikkei.com 2027年度から応用と高度が再編されるようでかなり驚きました。 これから高度試験に挑戦していこうと思っていた矢先だったので、少しショックです。 2026年度が現行制度での一発勝負になってしまうのですよね。 あわよくば予想問題つくって突破しようと考えましたが、難しそうという印象。
アバター
こんにちは、SCSKでAWSの内製化支援『 テクニカルエスコートサービス 』を担当している貝塚です。 以前、「プレビュー中のNetwork Firewall Proxyの導入を検討する」というタイトルで以下の記事を書きました。 プレビュー中のNetwork Firewall Proxyの導入を検討する プレビュー中のNetwork Firewall Proxyを実際に導入する場合を想定してアーキテクチャ検討をしてみました。 blog.usize-tech.com 2025.12.24 私の担当している顧客には、Transit GatewayとNetwork Firewallを組み合わせたInspection VPCを採用している会社が何社もあります。同時に、複数のVPCから使用される共通機能を集約したShared VPCも有していることが多いです。 現在の顧客環境ネットワーク(イメージ)   このような顧客環境に導入することを念頭に置いて前述記事を書いたのですが、その時は「HTTP/HTTPSトラフィックがNetwork FirewallとNetwork Firewall Proxy両方通ることになり料金が跳ね上がる」という課題に答えられていませんでした。(下図) 前回試作したネットワーク 今回、この状態から歩を進めて妥当性のありそうなアーキテクチャを作ることができたので、本記事ではそれを解説いたします。 Network Firewall/Network Firewall Proxy両対応構成 アーキテクチャは下図の通りです。(記載の仕方が今までのアーキテクチャ図と若干異なりますがご容赦ください) Network Firewall Proxyのエンドポイントは複数のVPCに作成するのではなく、集約してShared VPCに一組配置します。ただし、インターネットゲートウェイのあるVPCや、他の集約されたエンドポイントのあるVPCではなく、Network Firewall Proxyエンドポイント専用のVPCを作成して配置します。本アーキテクチャ図ではVPCエンドポイントが集約されたShared VPCは記載せず、Network Firewall Proxyエンドポイントのみが存在するVPCをShared VPCという名前で記載しています。 この構成にすることで、Network Firewall Proxyエンドポイントへ向かうトラフィックはNetwork Firewallを経由しないようにすることができます。 Transit Gatewayのルートテーブル設計 Network Firewall ProxyがないときのTransit Gatewayルートテーブル 通常、Inspection VPCを持つTransit Gatewayでは大きく2つのルートテーブルを運用します。 (1) Inspection VPCにひもづけるルートテーブル Network Firewallで検査が終わった後に参照されるルートテーブルなので、各VPC CIDRへのトラフィックを各VPCのアタッチメントに向けます。要するに”普通の”ルートテーブルです。 (2) Inspection VPC以外にひもづけるルートテーブル すべてのトラフィックがNetwork Firewallで検査されるようにする必要がありますから、すべて(0.0.0.0/0)をInspection VPCに向けます。 Network Firewall ProxyがあるときのTransit Gatewayルートテーブル 本アーキテクチャのルートテーブル構成は以下の通りです。 (1) Inspection VPCにひもづけるルートテーブル 通常アーキテクチャと同じです。Network Firewallで検査が終わった後に参照されるルートテーブルなので、各VPC CIDRへのトラフィックを各VPCアタッチメントに向けます。Shared VPC宛はそもそもInspection VPCを通らないようにするのであまり意味はありませんが、万一来た時には一応ルーティングしてあげるようにしています。 (2) ワークロードのあるVPC(VPC1, VPC5)にひもづけるルートテーブル HTTP/HTTPSトラフィックは明示的にブラウザ等で指定するNetwork Firewall Proxyエンドポイントに、Inspection VPCを経由せずに直接向かわせたいので、Shared VPC CIDRへのトラフィックはShared VPCアタッチメントに向けます。それ以外のトラフィックはNetwork Firewallで検査されるようにする必要がありますから、すべて(0.0.0.0/0)をInspection VPCに向けます。ルートテーブルにはロンゲストマッチのルール(宛先IPアドレスとマッチする経路が複数ある場合、最も長くビットが一致する(=サブネットマスクが長い)経路を優先するルール)があるので、結果的にShared VPC以外が宛先となるすべてのトラフィックがInspection VPCに向くことになります。 (3) Shared VPCにひもづけるルートテーブル ワークロードのあるVPCからNetwork Firewall Proxyエンドポイントに向かう通信の戻りのときに参照されるルートテーブルです。Network Firewallで検査したくないので各VPCに直接向けてやります。 (4) Internet VPCにひもづけるルートテーブル インターネットからの戻りに適用されるルートテーブルです。Network Firewallの検査対象にしたいので、すべて(0.0.0.0/0)をInspection VPCに向けます。 このようなルートテーブルを運用することで、Shared VPCへ向かう/Shared VPCから来るトラフィックはNetwork Firewallを通らないようにできます。 TLSインターセプト Network Firewall ProxyにはTLS(HTTPS)通信の中身を検査するTLSインターセプト機能が搭載されています。使用するにはAWS Private Certificate Authorityの汎用モード認証機関が必要です。(有効期間の短い証明書モードの認証機関では機能しませんでした) Network Firewall Proxyは、Network Firewallでは実現できなかったECH(TLSハンドシェイク時に送信されるSNI(ホスト名)を暗号化する機能。TLS1.3で採用されている)が有効化された通信の検査を可能にするなど、一部機能においてNetwork Firewallに対する優位性があります。 現時点(2026年3月16日)ではNetwork Firewall Proxyの料金が発表されていません。Network FirewallのTLSインスペクション機能は非常に高価だったので、Network Firewall Proxyでどうなるか注目です。 [補足]インターネットゲートウェイの構成について 本アーキテクチャでは、Network Firewall ProxyエンドポイントとひもづくNetwork FirewallをProxy VPCという名前のTransit Gatewayに接続していない独立したVPCに配置しています。このVPCにNAT GatewayとInternet Gatewayを配置しインターネットに接続できるようになっています。 これは主に、Network Firewall Proxyがまだプレビュー中だからなのか、1つしかNetwork Firewall Proxyを作成できず、Regional NAT Gatewayにアタッチすることもできなかったためです。そのため通常の(非HTTP/HTTPS用の)リソースと統合できるか判断できず、一旦VPCごと独立させました。Network Firewall ProxyがGAした際にはRegional NAT Gatewayへのアタッチか、少なくとも複数のNetwork Firewall Proxyデプロイに対応すると思われますので、その時この部分の構成は見直す必要があります。 トラフィック分析 こうしてHTTP/HTTPSと非HTTP/HTTPSの経路を分けたわけですが、Network Firewall Proxy経由になったHTTP/HTTPSトラフィックについて考えてみます。 ログの粒度 まず、Network Firewall Proxyに出力されるログを確認してみます。下図は出力可能なフィールドをすべて出力するようにしたときの、成功したHTTPSリクエストのログです。 HTTP/HTTPSに特化しているだけあって、Network FirewallログのようにTCPフラグやパケット数など下位層の詳細な情報は含まれていません。 各リクエストフェイズ(PRE_DNS, PRE_REQUEST, POST_RESPONSE)のどこで通信が拒否されたか分かるようになっているのが特徴的です。PRE_DNS, PRE_REQUEST, POST_RESPONSEなどのリクエストフェイズについては、冒頭に リンク を載せた過去記事を参照してください。 送信元VPCエンドポイント(src_vpce)があるので、どこのProxy VPCエンドポイントが使われたか分かるようになっています。 ネットワークのトラブルシュートという観点ではTCP/IP層の情報がない分やや心許ないですが、VPCフローログである程度補えますし、監査という観点では必要な情報が含まれていると言えそうです。 エンドポイント設置ポイントによる比較 次に、今はエンドポイントをShared VPC、すなわちTransit Gatewayの先に設置していますが、EC2インスタンスのあるVPCに作成することにメリットはあるでしょうか。料金面を措いて考えると…トラフィックがユーザの作成したネットワーク(Transit Gateway等)を通るのではなくAWS管理のネットワークを通るのでもしかするとレスポインスタイムなどに違いがあるでしょうか? 直観的に違いはなさそうですが、念のため確認しました。以下は、 https://www.scsk.jp へのリクエスト応答時間を、(a) ProxyエンドポイントがTransit Gatewayの先(Shared VPC)にあるとき、(b) Proxyエンドポイントがクライアントと同じVPCにあるとき、(c) Proxy経由しない(Network Firewall経由)とき、で比較したものです。 Proxyエンドポイントが… 平均(秒) Transit Gatewayの先にある 0.97 クライアントと同じVPCにある 0.97 Proxyを使用しない 0.95 Proxyエンドポイントの場所によるレスポンス差はないと言ってよさそうです。もっともほとんど負荷がかかっていない状態のテストなので、大量のトラフィックが発生しているときならまた違ってくるのかもしれません。 また、Network Firewall ProxyとNetwork Firewallを比較すると若干Network Firewallの方が応答が速いという結果でしたが、この結果だけを見れば問題になるような差ではなさそうです。 まとめ Transit Gateway + Network FirewallによるInspection機能を有したネットワークにNetwork Firewall Proxyを導入するケースを想定し、検討してみましたが、おおむね問題なく導入できそうだということが分かりましたので、Network Firewall ProxyのGA後に本格的に導入検討を進めたいところです。 なお、本記事で説明した構成についてのデプロイ手順を解説した記事を別記事として公開予定です。
アバター
SCSKの畑です。 引き続きデータベース関連のトピックです。今回こそ小ネタです。ちなみに、本エントリの内容で言及している RDS は、 先般の エントリ で言及していたものと同一です。   小ネタ本題 本エントリのタイトルに書いてある通りです!で終わらせられる程度の内容ではあるのですが、幾つか補足しながら説明していきます。 まず、当初は RDS の配下に Aurora Global DB をレプリカとして構成することを検討していました。Aurora Global DB の大阪リージョンのレプリカをバックアップとして使用することで、以下2点の要件を満たすことができると考えました。 アプリケーションからの read-only トラフィックを同リージョン(東京)の Aurora リードレプリカにオフロード 必要に応じてリーダーを増やしてスケールアウト 別リージョン(大阪)にバックアップを取得 可能な限り障害直前のデータを復旧したいため、RPO 要件は数秒程度(ただしレプリケーション遅延による影響は許容) が、色々調べたり試したりした限りだと、AWS コンソールや AWS CLI などからの構成はできなさそうということが分かりました。コンソールの操作メニューに該当するものがなく、AWS のドキュメントなどを見ても Aurora Global DB をレプリカとして構成する(できる)ことへの言及が一切なかったためです。そもそものサービスの位置づけからしても、RDS のレプリカとして構成することは想定されていないのかなとも思いました。 そこで、Aurora Global DB の代わりに 同一リージョン内に Aurora レプリカを作った上で、クロスリージョンでのバックアップ用途には RDS のクロスリージョンレプリカを使用する方針としました。 こちらは AWS コンソールからもサクッと試せそうだったので、RDS の配下に Aurora レプリカを構成した状態で、RDS のクロスリージョンレプリカを作ろうとしたところ・・ このようなエラーが出てしまい作成に失敗してしまいました。本エントリのタイトルに記載した通りの文面で、これはひょっとして仕様的にダメなやつなのでは?ということで AWS サポートに裏取りしてみたところやはり NG。やむを得ず別の方式を検討することに。 別リージョンにバックアップを取得するだけであれば、クロスリージョン自動バックアップの仕組みが使えるのでまずその案を持っていこうとしたものの、RPO が最大30分程度になってしまう制約があり、上記バックアップの要件を満たさなかったため NG。 Amazon RDS PITR スナップショットレプリケーション - AWS 規範ガイダンス Amazon RDS DB インスタンスを設定して、スナップショットとトランザクションログが任意の AWS リージョン にレプリケートされるようにします。 docs.aws.amazon.com よって、他の方法でクロスリージョン(大阪)に RDS のレプリカを構成する他ないということで、構成案をいくつか検討したところ最終的には以下 2 案が残りました。   案1:RDS クロスリージョンレプリカの前に中継用の RDS リードレプリカを挟む構成 先述したエラーの内容からして「同一」RDS の配下に Aurora レプリカと RDS クロスリージョンレプリカが同居できないだけなのでは?と推測し、以下のように RDS クロスリージョンレプリカとの間に同リージョン内の RDS リードレプリカを挟むような構成にした結果、問題なく構成できました。 全てのレプリケーションをAWS マネージド構成にできるのがメリットな反面、元々の構成案と比較して中継用の RDS が1個増えてしまうというのがデメリットとなります。   案2:大阪リージョンに Aurora MySQL を作成し、手動で MySQL レプリケーションを組む構成 逆に、AWS マネージドのレプリケーション構成にこだわらなければシンプルにこういう構成にできるよね?というのがこちらの案となります。RDS ⇒ Aurora MySQL(大阪リージョン)間は手動で MySQL レプリケーションを構成しています。 メリット・デメリットはちょうど案 1 の裏返しのような内容となりますので割愛します。 お客さんも交えてどちらの案を採用するか検討した結果、構成のシンプルさや 中継用の RDS のコストを重く見て、最終的に案 2 を採用することとなりました。 ちなみに、手動でレプリケーションを構成することを許容するのであれば、一番最初に検討していた構成についても同様に実現できてしまいます。(RDS ⇒ Aurora Global DB 間を手動でレプリケーション構成) 一応この案も上記案と合わせて俎上に上げてみたのですが、案2と比較して構成が複雑になること、同リージョン内の Aurora リードレプリカに対するレプリケーションも手動構成となってしまう点が少し気になる(アプリケーションが使用することもあり、マネージドレプリケーションで構成したい)ことの2点より、案2を採用することとなりました。   余談:AWS マネージドレプリケーションと手動レプリケーションの差異について(MySQLの場合) 最後にこの点について軽く言及して終わりたいと思います。私自身も具体的な差異として思いつくのはフェイルオーバ操作の可否くらいだったので、AWS サポートにも確認しながらざっくりまとめてみました。調べながら、データ不整合によるレプリケーション停止からの自動復旧とかを AWS マネージドの範疇でやってくれたら凄いんだけどなーと思いながら見ていたんですけど、さすがにそういう機能はないようです。(レプリケーションのエラーを契機にレプリカを再作成するくらいの力技であれば一応できてしまいそうですけど) なお、Aurora に閉じた項目については記載していません。 AWS マネージドレプリケーション 手動レプリケーション AWS コンソールや AWS CLI などを使用して構成可能 RDS/Aurora 内のストアドプロシージャを使用して構成 ※MySQL 自体のレプリケーション構成用 SQL 文は使用できない AWS コンソールや AWS CLI などからフェイルオーバ(昇格)のオペレーションが可能 レプリカの昇格やアプリケーションの接続先変更など、フェイルオーバに相当する一連の作業を手動で実施 CloudWatch メトリクスによるレプリケーションの統合監視が可能(レプリケーション遅延の監視やアラート通知なども可能) MySQL ログやレプリケーション監視用の SQL を使用した監視・通知の作りこみが必要 上記内容で最もインパクトがあるのはフェイルオーバ周りの挙動だと思いますが、上記案 2 における影響は実質的に監視部分のみであるため、特に大きな問題はないよねという結論になりました。本構成における別リージョンの RDS/Aurora はあくまでデータバックアップ用であり、DR 発動時の切替先ではないためです。   まとめ 本エントリのタイトルのような構成を取りたいケースは珍しいとは思いますが、現時点においては仕様としてできないのは確かなので、レプリケーション種別における差異も含め備忘も兼ねてまとめておきたかった次第です。 本記事がどなたかの役に立てば幸いです。
アバター
海外顧客のデータセンター廃止に伴い、オンプレミス環境からAWSへのクラウドリフトプロジェクトにアーキテクトとして参画しました。本記事では、AWS Organizationsが利用できないという制約下で、どのようにマルチアカウント戦略を構築し、複雑なオンプレミスネットワークとの整合性を保ちながらWell-Architectedなアーキテクチャを実現したかについて、設計上の考慮ポイントや苦労した点を共有します。 プロジェクトの背景 海外顧客では以下の課題を抱えていました。 データセンターの老朽化 :既存のオンプレミス環境を維持するコストが増大し、クラウド移行が急務 現地のAWS有識者不足 :クラウドに精通したエンジニアが現地にほぼおらず、日本側からの技術支援が必要 タイトなスケジュール :データセンター契約の関係上、限られた期間内での設計完了が求められた Organizationsが使えない :AWSアカウントの調達形態(リセラー経由)の都合上、AWS Organizationsを利用できないという大きな制約 特に最後の制約は、AWSのベストプラクティスであるマルチアカウント管理の根幹に関わる問題であり、設計の随所で代替策を検討する必要がありました。 加えて、本プロジェクトは海外顧客との直接のやり取りが前提であり、すべての設計書作成・技術議論・合意形成を英語で行う必要がありました。国内案件とは異なり、言語の壁に加えて、文化的な背景の違いや時差を考慮したコミュニケーション設計も求められる、グローバル案件ならではの難しさがありました。 アーキテクチャ全体像 マルチアカウント戦略(Organizationsなし) AWS Well-Architected Frameworkでは、ワークロードの分離・セキュリティ境界の明確化のためにマルチアカウント構成が推奨されています。通常はOrganizationsのOU(Organizational Unit)構造でアカウントを階層管理しますが、今回はそれが使えません。 そこで、以下のアカウント分類を設計しました ┌─────────────────────────────────────────────────┐ │               Hub Account              │ │ (認証・ロール切替の起点) │ ├─────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Workload │ │ Workload │ │ Test │ │ │ │ (Main) │ │ (Regional) │ │ Account │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Audit │ │ Log │ │ │ │ Account │ │ Archive │ │ │ └──────────┘ └──────────┘ │ └─────────────────────────────────────────────────┘ アカウント種別 目的 Workload (メイン) 本番ワークロードをホスト Workload (リージョナル) 地域・事業部ごとのリソース分離と課金管理 Test 開発・検証環境の本番からの完全分離 Hub Switch Roleによる一元的なアクセス管理 Audit セキュリティ監視・コンプライアンスの集約 Log Archive 全アカウントのログの不変ストレージ 技術的なポイント:Organizationsなしでのガバナンス確保 Organizationsが使えないことで、以下の機能が利用できません。 SCP(Service Control Policies) :アカウント横断のポリシー強制ができない Organizations連携のサービス :CloudTrailの組織トレイル、GuardDutyの組織管理、Config Aggregatorの自動設定等 一括請求(Consolidated Billing) :コスト管理の一元化ができない これらに対して、以下の代替設計を行いました。 1. SCPの代替:IAMポリシーとSwitch Roleによる権限制御 SCPが使えないため、Hub Accountを起点としたSwitch Role構成で権限を制御しました。各アカウントにSwitch先のIAM Roleを作成し、Hub AccountのIAMユーザーにのみAssumeRoleを許可する設計です。 { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::<HubAccountID>:root" }, "Action": "sts:AssumeRole", "Condition": { "Bool": { "aws:MultiFactorAuthPresent": "true" } } } ] } 特にAdministratorAccess権限については、MFA必須に加え、運用上マネージャーの事前承認を必須とするプロセスを設計しました。技術的な制御だけでなく、運用プロセスでカバーする部分を明確にすることが、Organizationsなし環境では重要です。 2. セキュリティサービスの集約管理 GuardDuty、Security Hub、CloudTrail、AWS Configについては、各アカウントで個別に有効化した上で、Auditアカウントに集約する設計としました。Organizationsの委任管理者機能が使えないため、各アカウントでの設定を標準化し、ログの転送先をAudit/Log Archiveアカウントに統一しています。 各Workloadアカウント Audit Account ┌─────────────────┐ ┌─────────────────┐ │ CloudTrail │ ── S3転送 ──> │ 集約S3バケット │ │ GuardDuty │ ─ EventBridge ─>│ 統合ダッシュボード │ │ AWS Config │ ── S3転送 ──> │ Conformance Pack │ │ Security Hub │ │ 統合管理 │ └─────────────────┘ └─────────────────┘ 3. コスト管理の代替 Consolidated Billingが使えないため、リセラー提供の専用コストダッシュボードを活用しつつ、各アカウントでCloudWatchによるコストアラートを設定し、閾値超過時にSNS経由で通知する仕組みを構築しました。 工夫した点:Conformance Packによるコンプライアンス自動評価 Organizationsなしでもセキュリティベースラインを担保するため、AWS ConfigのConformance Packを活用しました。CIS AWS Foundations Benchmarkを各アカウントに適用し、セキュリティポリシー違反を自動検出・通知する仕組みを構築しています。 これにより、SCPで強制できない部分を「検出的統制(Detective Control)」で補完する設計としました。予防的統制(Preventive Control)が弱い分、検出的統制を厚くするというトレードオフの判断です。 ネットワーク設計 VPCセグメンテーションとTransit Gateway 複数のVPCを用途別に分離し、Transit Gatewayで相互接続する構成を採用しました。 ┌─────────────────┐ │ Transit Gateway │ └────────┬────────┘ ┌─────────┬──────┼──────┬─────────┐ │ │ │ │ │ ┌──────┴──┐ ┌────┴───┐ ┌┴────┐┌┴──────┐ ┌┴──────┐ │ Service │ │ Prod │ │ Test ││ Dev │ │ On-prem │ │ Shared │ │ VPC │ │ VPC ││ VPC │ │ (VPN) │ │ VPC │ │ │ │ ││ │ │ │ └─────────┘ └────────┘ └─────┘└───────┘ └───────┘ VPC 目的 Service Shared VPC NAT Gateway、Route 53 Resolver、VPC Endpointなど共有リソースを集約 Production VPC 本番ワークロード Test VPC 開発・検証環境 Dev VPC 特定チーム管理のリソース 技術的なポイント:環境間アクセス制御 本番環境とテスト環境の分離は、Transit Gatewayのルートテーブルで制御しました。特に注意が必要だったのは、オンプレミス側のテスト環境が本番サブネット上に同居しているケースです。 Transit Gateway Route Table設計: [Production RT] → On-prem Production: Allow → On-prem Test (本番サブネット上): Allow(例外許可) → AWS Test VPC: Deny [Test RT] → On-prem Test: Allow → AWS Production VPC: Deny オンプレミスの既存ネットワーク構成をそのまま受け入れつつ、AWS側では論理的に分離するという、現実的な妥協点を見出す必要がありました。Well-Architectedの理想と、既存環境の制約の間でバランスを取る判断が求められた部分です。 DNS設計:Route 53によるハイブリッドDNS オンプレミスとAWS間のDNS名前解決は、Route 53 Resolver Endpointを活用したハイブリッド構成としました。 [オンプレ → AWS の名前解決] On-prem DNS Server │ Forward ▼ Route 53 Resolver Inbound Endpoint │ ▼ Route 53 Private Hosted Zone [AWS → オンプレ の名前解決] EC2 Instance (VPC DNS) │ ▼ Route 53 Resolver Outbound Endpoint │ Forward ▼ On-prem DNS Server (AD統合DNS) 複数のドメイン(本番用、開発用、リソース用等)が存在し、それぞれに対してPrivate Hosted Zoneを作成し、Transit Gateway経由で各VPCにアソシエーションする設計としました。 工夫した点:Service Shared VPCパターン NAT GatewayやVPC Endpoint、Route 53 Resolver Endpointといった共有リソースをService Shared VPCに集約することで、以下のメリットを実現しました。 コスト最適化 :各VPCにNAT GatewayやVPC Endpointを個別に作成する必要がなく、共有利用でコスト削減 運用の簡素化 :共有リソースの管理ポイントを一元化 セキュリティの一貫性 :インターネットへの出口を集約し、監視・制御を容易に ただし、Service Shared VPCがSPOF(Single Point of Failure)にならないよう、NAT Gatewayは複数AZに配置し、可用性を確保しています。 可用性設計 Multi-AZ構成の判断基準 全サーバーをMulti-AZ構成にするとコストが倍増するため、システムの重要度に応じた段階的な可用性設計を行いました。 構成 SLA 月間想定ダウンタイム 適用基準 Single AZ × 1 Instance 99.5% 約3.65時間 非クリティカルなバッチ処理等 Single AZ × 2 Instances 約99.9% 約43.8分 AZ障害を許容できるシステム 2 AZs × 2 Instances 99.99% 約4.38分 ミッションクリティカルなシステム 技術的なポイント:Cross-AZ通信コストの考慮 Multi-AZ構成では、AZ間通信にデータ転送コストが発生します。また、AZ間のレイテンシ(シングルミリ秒オーダー)がアプリケーションに影響しないかの確認も必要です。これらのトレードオフを現地エンジニアに説明し、システムごとに適切な構成を選択しました。 監視設計 CloudWatch Alarmの設計思想 監視設計では、既存のオンプレミス監視からの移行を考慮しつつ、AWSのベストプラクティスに沿った閾値設計を行いました。 既存環境の監視設定(移行前): CPUUtilization: Average, Period: 長め, Datapoints: 1 MemoryUtilization: Average, Period: 長め, Datapoints: 1 DiskSpaceUtilization: Average, Period: 長め, Datapoints: 1 AWS移行後の監視設計: CPUUtilization: Average, Period: 短縮, Datapoints: 複数回連続 → Severity: Warning MemoryUtilization: Average, Period: 短縮, Datapoints: 複数回連続 → Severity: Warning StatusCheckFailed: Maximum >= 1, Period: 短め, Datapoints: 複数回連続 → Severity: Critical EBS StalledIOCheck: Maximum >= 1, Period: 短め, Datapoints: 複数回連続 → Severity: Critical VPN TunnelState: Minimum < 1, Period: 短め, Datapoints: 複数回連続 → Severity: Critical 移行前の監視設定は、評価期間が長くDatapointsも1回のみと、検知が遅れやすい構成でした。AWS移行後は評価期間を短縮し、複数回連続で閾値を超えた場合にアラートを発報する設計に変更することで、一時的なスパイクによる誤検知を防ぎつつ、真の異常を迅速に検知できるようにしました。 工夫した点:Severity別通知の分離 CloudWatch AlarmのSeverityをWarningとCriticalに分類し、それぞれ別のSNS Topicに紐づけることで、通知先・対応フローを分離しました。Criticalは即時対応、Warningは翌営業日対応とすることで、アラート疲れを防止しつつ重要なインシデントを見逃さない設計としています。 CloudWatch Alarm (Critical) │ ▼ SNS Topic (Critical) → 即時通知(メール + 将来的にチャット連携) CloudWatch Alarm (Warning) │ ▼ SNS Topic (Warning) → 通常通知(メール) セキュリティ設計 多層防御の実現 クラウド移行に伴い、既存のオンプレミスセキュリティ対策に加えて、クラウドレイヤーのセキュリティを追加する多層防御を設計しました。 ┌─────────────────────────────────────────────────┐ │ Network Layer: VPN (既存) │ ├─────────────────────────────────────────────────┤ │ Cloud Layer: GuardDuty (新規追加) │ │ Security Hub (新規追加) │ │ AWS WAF (新規追加) │ │ Inspector (新規追加) │ ├─────────────────────────────────────────────────┤ │ Server Layer: EDR (既存) │ │ Anti-Virus (既存) │ └─────────────────────────────────────────────────┘ 技術的なポイント:GuardDutyのSeverity設計 GuardDutyの脅威検出スコア(0.1〜10.0)に基づき、一定以上のSeverityをEventBridge経由でSNS通知する設計としました。低スコアの検出は情報レベルとしてログに記録のみ、中程度以上で運用チームに通知することで、対応の優先度を明確化しています。 バックアップ・リストア設計 AWS Backupを活用し、EBSのバックアップを自動化しました。ライフサイクル管理として、一定の短い期間はWarm Storageに保管し、その後Cold Storageへ移行する構成としています。S3についてはバージョニングを有効化した上で、Standard → Standard-IA → Glacierへのライフサイクル遷移を設計し、コスト最適化を図りました。 苦労したポイント 1. Organizationsなしでのマルチアカウント設計 最も苦労したのは、Organizationsが使えない中でのマルチアカウント戦略の設計です。AWSのベストプラクティスやWell-Architected Frameworkの推奨事項の多くはOrganizations前提で書かれており、それらを「Organizationsなし」の文脈に読み替えて適用する必要がありました。 特にSCPが使えないことで、予防的統制が弱くなる点は大きな課題でした。IAMポリシーの厳格な設計、Conformance Packによる検出的統制の強化、運用プロセスでの補完という三層のアプローチで対処しましたが、この設計判断に至るまでに多くの検討と議論を重ねました。 2. 既存オンプレミス環境の複雑さの理解 顧客のネットワーク構成は、複数の地域にまたがり、ドメイン構成も複雑でした。現地に十分なドキュメントが存在しない部分もあり、限られた出張期間中に現地エンジニアとの議論を通じて既存環境を理解し、それに適合するAWS設計を行う必要がありました。 事前準備として、出張前から現地の社員とコミュニケーションを取り、ネットワーク構成図や既存システムの情報を可能な限り収集しました。この準備があったからこそ、限られた現地滞在期間で基本設計を完遂できたと考えています。 3. 海外顧客とのテクニカルコミュニケーション 設計書の作成、現地エンジニアとの技術議論、すべて英語で行う必要がありました。AWSの技術用語は英語ベースなので比較的スムーズでしたが、設計判断の背景や理由を正確に伝え、合意形成を図る部分では、技術力だけでなくコミュニケーション力が求められました。 国内案件であれば暗黙的に共有されている前提知識や業務慣習が通用しないため、設計の意図や背景を一つひとつ丁寧に言語化し、ドキュメントに落とし込む必要がありました。これは手間がかかる反面、設計の品質を高める効果もあったと感じています。 また、時差がある中でのスケジュール調整や、対面でのワークショップと日本からのリモート支援を組み合わせたハイブリッドな進め方も、グローバル案件ならではの工夫でした。 4. リセラーアカウント特有の制約への対応 リセラー経由のAWSアカウントでは、Cost ExplorerやBilling and Cost Managementに直接アクセスできないなど、通常のAWSアカウントとは異なる制約があります。これらの制約を事前に洗い出し、代替手段を設計に組み込む作業は、一般的なAWS設計ガイドには載っていない部分であり、実践的な知見が求められました。 まとめ Organizationsなしでも、適切な設計によりWell-Architectedなマルチアカウント環境は構築可能 。ただし、予防的統制の弱さを検出的統制と運用プロセスで補完する設計判断が重要 Service Shared VPCパターン は、コスト最適化と運用簡素化の両面で有効。ただしSPOFにならないよう可用性設計が必要 既存オンプレミス環境との整合性 を取るためには、理想的なクラウドアーキテクチャと現実の制約の間で適切なトレードオフを判断する力が求められる 海外顧客支援 では、技術力に加えて事前準備・英語でのコミュニケーション力・文化的背景への理解が成果を大きく左右する クラウド移行は技術的な課題だけでなく、組織・人材・コミュニケーションの課題が複合的に絡み合うプロジェクトです。アーキテクトとして、技術的な最適解を追求しつつも、現実の制約の中で最善の設計を導き出すことの重要性を改めて実感しました。 プロジェクト概要 主要サービス:EC2、VPC、Transit Gateway、Route 53、CloudWatch、GuardDuty、Security Hub、AWS Config、AWS Backup、Systems Manager、AWS WAF、Inspector 設計期間:約2ヶ月(2025年11月〜12月) 設計範囲:基本設計(ネットワーク、セキュリティ、監視、バックアップ、IAM、マルチアカウント戦略)
アバター
CatoクラウドのIPv6対応は多くのお客様の関心も高く、問い合わせも多いテーマです。 この度SCSKではCato Networks社とIPv6に関する共同検証を行いました。 本記事では、一部ですが今回の検証概要をご紹介しますので、2026年3月現在のCatoのIPv6対応の進み具合を共有できればと思います。 <注意> 本記事で記載するIPv6対応は、ラストマイル接続(インターネット回線接続)の部分です。 オーバーレイで構成する組織内の プライベートネットワークはIPv4のみの対応となります。 今回の共同検証について Catoクラウドへの主な接続方式には、外出先や在宅勤務からCato Clientを用いてリモート接続する「モバイル接続」と、オフィスにCato Socketを設置して拠点と接続する「サイト接続」があります。 Cato Clientは、一定の条件や制限はあるものの、すでにIPv6をサポートしています。 一方、Cato Socketについては、2026年2月時点ではプロバイダが提供するIPoE回線への直接接続はサポートされていません。しかし、Cato社では以前から開発が進んでおり、今回その一部の 動作検証を 日本の実際の商用回線サービスを利用して行う こととなりました。 では、その概要をご紹介します。 今回の検証は、IPv4overIPv6(DS-Lite)の動作検証ということだったので、SCSKが選定した国内プロバイダのサービスとフレッツ回線を組み合わせて提供し、そこに、Cato社が開発中の特別バージョンのSocketを接続、Cato社がリモートから開発とテストを行うという形で進められました。 検証概要は以下の通りです。 リモートから開発中特別バージョンのOSをSocketへインストール IPv6のIPアドレス自動取得と、AFTRを介したIPv4 over IPv6(DS-Lite)との通信テスト AFTRのFQDN自動取得部分の実装 SocketがIPv6網からAFTRを介してIPv4網へ接続し、Cato PoPと正常なDTLSトンネルを確立。正常な通信を行えることを確認。 DS-Liteの動作検証は、Cato社のラボ環境では既にテスト済みでしたが、実際の日本の回線サービス環境下での接続および正常動作が確認できました。 <検証構成イメージ> なお、IPv6接続には複数の方式があり、プロバイダによっても提供サービスが異なります。 今回実施したのはDS-Lite方式の検証でしたが、MAP-E方式やIPoEネイティブ方式もスコープとしているようで、既に一部は検証も済んでいると聞いています。今後、これら方式への対応状況についても公式アナウンスが予定されているようです。 現在のIPoE回線との接続 話が前後しますが、現在「サイト接続」でIPoE回線を利用する場合は、Cato Socketの上位にルータなどを設置し、そのルータでIPv4 over IPv6(DS-LiteやMAP-Eなど)やIPv6の設定を行う必要があります。 <IPoE回線接続(例)> 今後、Cato Socketがこれらに対応することで、上位ルータが不要になり、通信キャリアのIPoE回線を直接Cato Socketに接続できるようになります。 上位ルータが不要になれば、コスト削減はもちろん、機器の保守管理・コンフィグ管理・EOS(End of Support)管理などの運用負荷からも解放され、よりシンプルな構成になります。 new! IPv6ソケットのアンダーレイサポートが発表 今回の共同検証を通じて、日本固有のネットワーク事情に合わせたCatoクラウドのIPv6対応が着実に進んでいることを感じています。 また近々 SLAAC(RA), DHCPv6, DHCP-PD, IP-in-IP, DS-Liteに対応する形でリリースが予定されていると聞いていた矢先に、 2026年3月2日の Product Update で「IPv6ソケットのアンダーレイサポート」が発表になりました。 公式のKnowledge Baseには、今回共同検証したDS-Lite環境でのSocketの設定画面イメージも掲載されています。 IPv6専用インターネット接続のためのソケットサイトの設定 – Cato Learning Center 現時点では、Socketのどのバージョンに適用されるかは不明ですが、数年前から要望していた機能が一歩前進したことは、非常に喜ばしい限りです。 また、共同検証の終了から約2ヶ月後の発表ということで、Cato社で着実に開発が進み、進捗があることを実感しました。 一方、国内におけるIPoE回線の普及は進んでいるため、間もなくこの機能が提供されて実績が積み重ねられれば、この構成がいずれスタンダードとなり、よりシンプルで効率的、かつ安定したCatoクラウドの利用が期待できるでしょう。 新たな進展やリリース情報が入り次第、当ブログでも随時ご紹介していきます。 ご興味やご質問があれば、ぜひお気軽にお問い合わせください。
アバター
どうも。サービス監視といえばURL監視です。 Amazon CloudWatch Synthetics は高機能であるがゆえ、わりと構築に手間がかかりますね。ちょっとURLを突っつけばいいだけなんだけど、ということはよくあります。 AWS CloudFormationテンプレートにしておけば、必要なときに短時間でできるのでそれを共有しておこうと思います。 概略 CloudWatch Syntheticsは、Lambdaを作りそこでヘッドレスブラウザを使いさまざまなHTTPリクエストを組み合わせてWeb操作を行うことが出来ます。その定型的なリクエストパターンに応じてCloudWatchメトリクスに情報を出力し、CloudWach Alarmで監視ができます。 さて、監視される側のWebサービスが対外的に公開されているサービスであれば、監視元を考慮する必要はありません。IPアドレス制御により一部のネットワークにしか提供していない場合に、Syntheticsで監視するとセキュリティグループをどうするかが問題になります。 今回は、以下のアーキテクチャにより、Syntheticsが作り出すリクエスト発行LambdaをVPC内に閉じ込め、EIPによる接続元IPアドレスの固定化を行います。 これにより、監視される側のセキュリティグループに監視元IPアドレス(SyntheticsのLambda)を許可する設定が可能となります。 監視の結果、URLからのレスポンスが返らなくなった場合、Systems Manager でEC2の再起動を行います。 この一連の仕組みを、CloudFormationテンプレートで一気に作成します。 アーキテクチャ図   CFnテンプレートの使用方法 事前に作成する必要のあるリソース CloudFormationテンプレートが生成するのは、水色の点線の範囲です。以下のリソースについては事前に作成しておき、必要な情報をパラメータで与えてください。(もしくはテンプレート内のdefault値を書き換えてください) 監視が失敗した場合の通知先SNSトピック Synthetics が監視結果を保存するS3バケット 監視条件 現在のテンプレートでは、以下の監視条件が設定してあります。要件に応じて変更してください。 監視対象URL: http://www.example.com/index.html 監視間隔: 5分ごと 監視リクエストのタイムアウト: 60秒 監視対象を再起動する失敗回数: 3回 監視結果のデータ保持期間: 90日間 注意点 CFnスタックを作成する際は、IAMロールの作成を行います。 --capabilities CAPABILITY_NAMED_IAM オプションを付けてください。 CFnスタックを削除する歳は、VPC Lambdaを使っている関係で、Lambdaが使用するENIがVPC内に保持されています。そのため、いきなりスタック削除を実行すると、Canaryを削除しても数分間はLambdaの仕様によりENIが残ります。その結果、セキュリティグループやサブネットの削除が失敗し、スタック削除自体が失敗します。 以下のような手順でスタック削除を実施してください。 CloudWatch Synthetics の Canary を無効にする。 10分ほど待機する。 スタックの削除を実行する。 閉域ネットワークへの応用 今回、Global Network側から監視を行っていますが、VPC間のIP到達性があれば、このCloudFormationテンプレートで社内のプライベートネットワークに閉じても利用可能です。監視する側のVPCにSyntheticsのVPCエンドポイントを追加してください。 CFnテンプレート AWSTemplateFormatVersion: '2010-09-09' Description: 'CloudWatch Synthetics Canary for URL monitoring with VPC configuration' Parameters: MonitoringUrl: Type: String Default: 'http://www.exmple.com/index.html' Description: 'URL to monitor' ArtifactS3Location: Type: String Default: 's3://synurl-work/synthetics/' Description: 'S3 bucket URI for storing artifacts (format: s3://bucket-name/path/)' AllowedPattern: '^s3://[a-z0-9][a-z0-9-]*[a-z0-9]/.*$' ConstraintDescription: 'Must be a valid S3 URI format (s3://bucket-name/path/) and bucket name cannot contain periods' MonitoringFrequency: Type: String Default: 'rate(5 minutes)' Description: 'Monitoring frequency' AllowedValues: - 'rate(1 minute)' - 'rate(5 minutes)' - 'rate(10 minutes)' - 'rate(15 minutes)' - 'rate(30 minutes)' - 'rate(1 hour)' TimeoutSeconds: Type: Number Default: 60 Description: 'Timeout in seconds' MinValue: 3 MaxValue: 840 DataRetentionDays: Type: Number Default: 90 Description: 'Data retention period in days' MinValue: 1 MaxValue: 455 TargetEC2InstanceId: Type: String Default: 'i-04d493bd1eb75dd95' Description: 'EC2 Instance ID to restart on monitoring failure' AllowedPattern: '^i-[a-z0-9]{8,17}$' ConstraintDescription: 'Must be a valid EC2 instance ID (e.g., i-1234567890abcdef0)' NotificationTopicArn: Type: String Default: 'arn:aws:sns:ap-northeast-1:173173380307:synurl-TPC' Description: 'SNS Topic ARN for SMS notifications' AllowedPattern: '^arn:aws:sns:[a-z0-9-]+:[0-9]{12}:.+$' ConstraintDescription: 'Must be a valid SNS Topic ARN' Resources: # VPC SYNURLVPC: Type: AWS::EC2::VPC Properties: CidrBlock: '10.0.0.0/16' EnableDnsHostnames: true EnableDnsSupport: true Tags: - Key: Name Value: 'synurl-vpc' - Key: Cost Value: 'synurl' # Internet Gateway SYNURLIGW: Type: AWS::EC2::InternetGateway Properties: Tags: - Key: Name Value: 'synurl-igw' - Key: Cost Value: 'synurl' # Attach Internet Gateway to VPC AttachGateway: Type: AWS::EC2::VPCGatewayAttachment Properties: VpcId: !Ref SYNURLVPC InternetGatewayId: !Ref SYNURLIGW # Public Subnet (for NAT Gateway) SYNURLPublicSubnet: Type: AWS::EC2::Subnet Properties: VpcId: !Ref SYNURLVPC CidrBlock: '10.0.1.0/24' AvailabilityZone: !Select [0, !GetAZs ''] MapPublicIpOnLaunch: true Tags: - Key: Name Value: 'synurl-pub-subnet' - Key: Cost Value: 'synurl' # Private Subnet (for Lambda) SYNURLSubnet: Type: AWS::EC2::Subnet Properties: VpcId: !Ref SYNURLVPC CidrBlock: '10.0.3.0/24' AvailabilityZone: !Select [0, !GetAZs ''] MapPublicIpOnLaunch: false Tags: - Key: Name Value: 'synurl-pri-subnet' - Key: Cost Value: 'synurl' # Elastic IP for NAT Gateway YteraNATGatewayEIP: Type: AWS::EC2::EIP DependsOn: AttachGateway Properties: Domain: vpc Tags: - Key: Name Value: 'synurl-synthrics-natgw-eip' - Key: Cost Value: 'synurl' # NAT Gateway YteraNATGateway: Type: AWS::EC2::NatGateway Properties: AllocationId: !GetAtt YteraNATGatewayEIP.AllocationId SubnetId: !Ref SYNURLPublicSubnet Tags: - Key: Name Value: 'synurl-synthrics-natgw' - Key: Cost Value: 'synurl' # Public Route Table SYNURLPublicRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref SYNURLVPC Tags: - Key: Name Value: 'synurl-public-route-table' - Key: Cost Value: 'synurl' # Private Route Table SYNURLPrivateRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref SYNURLVPC Tags: - Key: Name Value: 'synurl-private-route-table' - Key: Cost Value: 'synurl' # Route to Internet Gateway (Public) PublicRoute: Type: AWS::EC2::Route DependsOn: AttachGateway Properties: RouteTableId: !Ref SYNURLPublicRouteTable DestinationCidrBlock: '0.0.0.0/0' GatewayId: !Ref SYNURLIGW # Route to NAT Gateway (Private) PrivateRoute: Type: AWS::EC2::Route Properties: RouteTableId: !Ref SYNURLPrivateRouteTable DestinationCidrBlock: '0.0.0.0/0' NatGatewayId: !Ref YteraNATGateway # Associate Public Route Table with Public Subnet PublicSubnetRouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref SYNURLPublicSubnet RouteTableId: !Ref SYNURLPublicRouteTable # Associate Private Route Table with Private Subnet PrivateSubnetRouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref SYNURLSubnet RouteTableId: !Ref SYNURLPrivateRouteTable # Security Group for Lambda SYNURLLambdaSG: Type: AWS::EC2::SecurityGroup Properties: GroupName: 'synurl-lambda-none-sg' GroupDescription: 'Security group for Synthetics Lambda - no inbound, all outbound' VpcId: !Ref SYNURLVPC SecurityGroupEgress: - IpProtocol: -1 CidrIp: '0.0.0.0/0' Description: 'Allow all outbound traffic' Tags: - Key: Name Value: 'synurl-lambda-none-sg' - Key: Cost Value: 'synurl' # IAM Role for CloudWatch Synthetics YteraCloudWatchSyntheticsRole: Type: AWS::IAM::Role Properties: RoleName: 'synurl-CloudWatchSyntheticsRole' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: SyntheticsCanaryExecutionPolicy PolicyDocument: Version: '2012-10-17' Statement: # CloudWatch Synthetics基本権限 - Effect: Allow Action: - synthetics:* Resource: '*' # CloudWatch Logs権限 - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cwsyn-*' # CloudWatch Metrics権限 - Effect: Allow Action: - cloudwatch:PutMetricData Resource: '*' Condition: StringEquals: 'cloudwatch:namespace': 'CloudWatchSynthetics' # S3権限(アーティファクト保存用) - Effect: Allow Action: - s3:PutObject - s3:GetObject - s3:GetObjectVersion - s3:PutObjectAcl - s3:GetBucketLocation - s3:ListBucket Resource: - !Sub - '${BucketArn}/*' - BucketArn: !Sub - 'arn:aws:s3:::${BucketName}' - BucketName: !Select [2, !Split ['/', !Ref ArtifactS3Location]] - !Sub - '${BucketArn}' - BucketArn: !Sub - 'arn:aws:s3:::${BucketName}' - BucketName: !Select [2, !Split ['/', !Ref ArtifactS3Location]] # VPC権限(VPC内実行用) - Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DeleteNetworkInterface - ec2:AttachNetworkInterface - ec2:DetachNetworkInterface Resource: '*' # X-Ray権限(トレーシング用) - Effect: Allow Action: - xray:PutTraceSegments Resource: '*' # Lambda基本実行権限 - Effect: Allow Action: - lambda:InvokeFunction Resource: '*' # Lambda関数とレイヤーのタグ管理権限 - Effect: Allow Action: - lambda:ListTags - lambda:TagResource - lambda:UntagResource Resource: - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:cwsyn-synurl-canary01-*' - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:cwsyn-synurl-canary01-*' Tags: - Key: Name Value: 'synurl-CloudWatchSyntheticsRole' - Key: Cost Value: 'synurl' # CloudWatch Synthetics Canary YteraCanary: Type: AWS::Synthetics::Canary DeletionPolicy: Delete Properties: Name: 'synurl-canary01' ExecutionRoleArn: !GetAtt YteraCloudWatchSyntheticsRole.Arn Code: Handler: 'heartbeat.handler' Script: !Sub | from aws_synthetics.selenium import synthetics_webdriver as webdriver from aws_synthetics.common import synthetics_logger as logger from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By import time def heartbeat_monitoring(): # ブラウザインスタンスを作成 browser = webdriver.Chrome() try: # 監視対象URLにアクセス logger.info(f'Navigating to ${MonitoringUrl}') browser.get('${MonitoringUrl}') # ページの読み込み完了を待機 WebDriverWait(browser, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # スクリーンショットを保存 browser.save_screenshot('heartbeat_screenshot.png') # ページタイトルをログに記録 page_title = browser.title logger.info(f'Page title: {page_title}') # HTTPステータスコードの確認(JavaScript経由) status_code = browser.execute_script( "return window.performance.getEntriesByType('navigation')[0].responseStatus || 200" ) if status_code >= 400: raise Exception(f'HTTP error: {status_code}') logger.info(f'Successfully accessed ${MonitoringUrl} with status: {status_code}') except Exception as e: logger.error(f'Heartbeat monitoring failed: {str(e)}') raise e finally: # ブラウザを閉じる(自動的に閉じられるが明示的に記述) browser.quit() # Canaryのエントリーポイント def handler(event, context): return heartbeat_monitoring() ArtifactS3Location: !Ref ArtifactS3Location RuntimeVersion: 'syn-python-selenium-9.0' Schedule: Expression: !Ref MonitoringFrequency DurationInSeconds: 0 RunConfig: TimeoutInSeconds: !Ref TimeoutSeconds MemoryInMB: 960 ActiveTracing: false FailureRetentionPeriod: !Ref DataRetentionDays SuccessRetentionPeriod: !Ref DataRetentionDays StartCanaryAfterCreation: true VpcConfig: VpcId: !Ref SYNURLVPC SubnetIds: - !Ref SYNURLSubnet SecurityGroupIds: - !Ref SYNURLLambdaSG # Canary自体のタグ Tags: - Key: Name Value: 'synurl-canary01' - Key: Cost Value: 'synurl' # Canaryが作成するLambda関数とレイヤーにタグを複製 ResourcesToReplicateTags: - lambda-function # CloudWatch Alarm for Canary failure detection YteraCanaryFailureAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: 'synurl-canary01-failure-alarm' AlarmDescription: 'Trigger EC2 restart and SNS notification when Canary fails for 15 minutes' MetricName: SuccessPercent Namespace: CloudWatchSynthetics Statistic: Minimum Period: 300 EvaluationPeriods: 3 Threshold: 100 ComparisonOperator: LessThanThreshold Dimensions: - Name: CanaryName Value: !Ref YteraCanary TreatMissingData: breaching ActionsEnabled: true AlarmActions: - !Ref NotificationTopicArn OKActions: - !Ref NotificationTopicArn # EventBridge Rule to trigger SSM Automation on Alarm YteraAlarmToSSMRule: Type: AWS::Events::Rule Properties: Name: 'synurl-canary-alarm-to-ssm' Description: 'Trigger SSM Automation to restart EC2 when Canary alarm fires' State: ENABLED EventPattern: source: - aws.cloudwatch detail-type: - CloudWatch Alarm State Change detail: alarmName: - !Ref YteraCanaryFailureAlarm state: value: - ALARM Targets: - Arn: !Sub 'arn:aws:ssm:${AWS::Region}::automation-definition/AWS-RestartEC2Instance:$DEFAULT' RoleArn: !GetAtt YteraEventBridgeRole.Arn Id: 'RestartEC2Target' Input: !Sub | { "InstanceId": ["${TargetEC2InstanceId}"], "AutomationAssumeRole": ["${YteraSSMAutomationRole.Arn}"] } # IAM Role for EventBridge to invoke SSM Automation YteraEventBridgeRole: Type: AWS::IAM::Role Properties: RoleName: 'synurl-EventBridgeSSMAutomationRole' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: events.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: StartSSMAutomationPolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - ssm:StartAutomationExecution Resource: - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:automation-definition/*' - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:automation-execution/*' - !Sub 'arn:aws:ssm:${AWS::Region}::document/AWS-*' - Effect: Allow Action: - iam:PassRole Resource: !GetAtt YteraSSMAutomationRole.Arn Tags: - Key: Name Value: 'synurl-EventBridgeSSMAutomationRole' - Key: Cost Value: 'synurl' # IAM Role for SSM Automation to restart EC2 YteraSSMAutomationRole: Type: AWS::IAM::Role Properties: RoleName: 'synurl-SSMAutomationExecutionRole' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - ssm.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AmazonSSMAutomationRole Policies: - PolicyName: EC2RestartPolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - ec2:RebootInstances - ec2:DescribeInstances - ec2:DescribeInstanceStatus Resource: '*' Tags: - Key: Name Value: 'synurl-SSMAutomationExecutionRole' - Key: Cost Value: 'synurl' Outputs: CanaryName: Description: 'Name of the created Canary' Value: !Ref YteraCanary Export: Name: !Sub '${AWS::StackName}-CanaryName' VPCId: Description: 'VPC ID' Value: !Ref SYNURLVPC Export: Name: !Sub '${AWS::StackName}-VPCId' SubnetId: Description: 'Subnet ID' Value: !Ref SYNURLSubnet Export: Name: !Sub '${AWS::StackName}-SubnetId' SecurityGroupId: Description: 'Security Group ID' Value: !Ref SYNURLLambdaSG Export: Name: !Sub '${AWS::StackName}-SecurityGroupId' IAMRoleArn: Description: 'IAM Role ARN' Value: !GetAtt YteraCloudWatchSyntheticsRole.Arn Export: Name: !Sub '${AWS::StackName}-IAMRoleArn' CanaryId: Description: 'Canary ID' Value: !GetAtt YteraCanary.Id Export: Name: !Sub '${AWS::StackName}-CanaryId' MonitoringUrl: Description: 'Monitoring target URL' Value: !Ref MonitoringUrl Export: Name: !Sub '${AWS::StackName}-MonitoringUrl' NATGatewayEIP: Description: 'NAT Gateway Elastic IP' Value: !Ref YteraNATGatewayEIP Export: Name: !Sub '${AWS::StackName}-NATGatewayEIP' AlarmName: Description: 'CloudWatch Alarm Name' Value: !Ref YteraCanaryFailureAlarm Export: Name: !Sub '${AWS::StackName}-AlarmName' EventBridgeRuleName: Description: 'EventBridge Rule Name' Value: !Ref YteraAlarmToSSMRule Export: Name: !Sub '${AWS::StackName}-EventBridgeRuleName' EventBridgeRoleArn: Description: 'EventBridge IAM Role ARN' Value: !GetAtt YteraEventBridgeRole.Arn Export: Name: !Sub '${AWS::StackName}-EventBridgeRoleArn' SSMAutomationRoleArn: Description: 'SSM Automation IAM Role ARN' Value: !GetAtt YteraSSMAutomationRole.Arn Export: Name: !Sub '${AWS::StackName}-SSMAutomationRoleArn'
アバター
こんにちは、SCSKでAWSの内製化支援『 テクニカルエスコートサービス 』を担当している貝塚です。 先日、テクニカルエスコートの顧客から、Windows EC2インスタンスへのRDP接続を画面録画したいというご要望をいただきました。監査要件対応などから、委託先ベンダーが実施するすべての操作を証跡として残す必要があるとのことでした。 AWS Systems Manager Session ManagerはRDP接続に対応しており、録画機能も持っているのでこの機能の導入をベースに検討を進めることになるわけですが、録画機能の設定・検証だけではなくその周辺にも考慮することがありました。それらをまとめたものが本記事となります。 課題と顧客要望 今回の顧客要件は、Windows EC2インスタンスへのすべてのアクセスを記録したいというものでした。監査要件対応のため、誰が、いつ、どのような操作を行ったかを証跡として残す必要があります。 Session Managerは、EC2インスタンスへのアクセス方法として以下の2つをサポートしています。 1. 対話型シェルセッション(CLI): コマンドラインでの操作 2. Fleet Manager Remote Desktop(RDP): GUIでの画面操作 委託先ベンダーが使用するのはRDP接続だけとのお話でしたが、Session ManagerのCLI経由でもWindows PowerShellやコマンドプロンプトを使ってOS操作が可能です。つまり、RDP録画だけでは不十分で、CLIセッションのログ記録も必要になります。 さらに重要なのは、Session Manager以外の手段でログインできてしまうと、すべての操作記録を取るという目的が達成できないという点です。ネットワーク経由で直接RDP(ポート3389)接続できてしまうと記録漏れが発生する可能性があります。 そのため、今回の実装では以下の2つのアプローチを採用しました。 1. Session Managerのセッションログ記録とRDP録画を設定 2. ネットワークレベルでSession Manager以外の接続経路をブロック 本記事では、1つ目のアプローチであるSession Managerの設定のうち、セッションログ記録について説明します。RDP録画の設定、ネットワークまわりの対応については次回以降の記事で説明します。 Session Managerの基本概念 Session Managerのセッションログ記録とRDP録画の設定方法を解説する前に、Session Managerの基本的な仕組みを理解しておきましょう。 Session Managerとは AWS Systems Manager Session Managerは、EC2インスタンスへの安全なアクセスを提供するマネージドサービスです。従来のSSH(Linuxの場合)やRDP接続と異なり、以下の特徴があります。 インバウンドポート(SSH 22番、RDP 3389番)の開放が不要 SSHキーの管理が不要(Linuxの場合) IAMベースのアクセス制御 すべての接続がCloudTrailに記録される プライベートVPC環境での動作 今回の顧客はインターネットゲートウェイを持たないプライベートなVPC環境を構築しているため、AWSサービスにはVPCエンドポイント経由でアクセスします。 Session Managerが動作するために必要なVPCエンドポイントは以下の通りです。 ssm.region.amazonaws.com : Systems Manager APIエンドポイント ssmmessages.region.amazonaws.com : Session Managerメッセージングエンドポイント ec2messages.region.amazonaws.com : EC2メッセージングエンドポイント ※SSM Agent バージョン 3.3.40.0以降では不要になっています セッションログ記録とRDP録画を有効化する場合は、さらに以下のエンドポイントが必要です。 logs.region.amazonaws.com : CloudWatch Logsエンドポイント(セッションログをCloudWatchに記録するとき用) kms.region.amazonaws.com : KMSエンドポイント(暗号化用) s3.region.amazonaws.com : S3エンドポイント(RDP録画ファイル保存用) これらのVPCエンドポイントにより、EC2インスタンスはインターネットに接続することなく、AWSサービスと通信できます。 セッションログ記録の設定 セッションログ記録の概要 Session Managerは、セッション中に実行されたコマンドや操作の詳細をログとして記録できます。ログの保存先として、以下の2つのオプションがあります。 CloudWatch Logs: リアルタイムでログを確認でき、検索やフィルタリングが容易 Amazon S3: 長期保存に適しており、コスト効率が高い ログ記録を有効化することで、以下のような監査要件に対応できます。 誰が、いつ、どのインスタンスに接続したか セッション中にどのようなコマンドを実行したか 操作の証跡を長期保存し、監査時に提示できる 本実装での設計方針 今回の実装では、監査要件対応のため、すべてのSession Manager接続でログを記録する設計を採用しました。具体的には、以下の方針で実装します。 1. SSM-SessionManagerRunShellドキュメント(後述)でログ記録を有効化 : マネジメントコンソールおよびAWS CLIからの接続で自動的にログが記録される 2. CloudWatch Logsに記録 : リアルタイムでログを確認でき、検索やフィルタリングが容易 3. KMS暗号化を有効化 : ログデータを暗号化して保護 4. IAMポリシーでドキュメントを制限 : ユーザーが別のドキュメントを指定してログ記録を回避できないようにする この設計により、記録漏れを防ぎ、すべての操作を証跡として残すことができます。詳細な設定方法については、後続のセクションで説明します。 AWS CloudFormationでの設定 検証用のCloudFormationテンプレート(cfn-session-manager-rdp-logging.yaml)を用意しました。※本記事末尾に掲載 事前準備: EC2キーペアの作成 CloudFormationテンプレートをデプロイする前に、EC2キーペアを作成しておいてください。 パラメータファイルの作成 CloudFormationテンプレートのパラメータを指定するため、以下の内容でパラメータファイルを作成します。 [ { "ParameterKey": "KeyPairName", "ParameterValue": "my-keypair" }, { "ParameterKey": "ProjectName", "ParameterValue": "session-manager-logging" } ] パラメータの説明: KeyPairName: 既存のEC2キーペア名(必須) ProjectName: プロジェクト名(リソース名のプレフィックスに使用) AWS CloudFormationスタックのデプロイ 以下のコマンドでCloudFormationスタックをデプロイします。 aws cloudformation create-stack \ --stack-name my-session-manager-stack \ --template-body file://cfn-session-manager-rdp-logging.yaml \ --parameters file://parameters-my-stack.json \ --capabilities CAPABILITY_NAMED_IAM \ --region ap-northeast-1 このテンプレートで以下のリソースが作成されます。 作成されるリソース概要 検証用ネットワーク: VPC 1つ、プライベートサブネット 1つ、ルートテーブル、セキュリティグループ 2つ VPCエンドポイント: SSM、SSM Messages、EC2 Messages、CloudWatch Logs、KMS(各Interface型)、S3(Gateway型) EC2インスタンス: Windows Server 2022インスタンス 1台 IAMロールとポリシー: EC2インスタンスプロファイル、Session Manager/CloudWatch Logs/S3/RDP録画用の権限ポリシー S3バケット: RDP録画ファイル保存用バケット(バケットポリシー含む) CloudWatch Logsロググループ: セッションログ記録用ロググループ KMSキー: RDP録画とセッションログ暗号化用のカスタマーマネージドキー(エイリアス含む) Session Managerドキュメント: オプトインSession Manager設定ドキュメント(テスト用) セッションログ用だけではなく、RDP録画用のリソースも作成しています。 以下では、セッションログ取得にあたって重要なリソースについて詳細を説明します。 1. CloudWatch Logsロググループ セッションログを記録するCloudWatch Logsロググループを作成します。保持期間は本記事用に30日に設定していますが、実際の要件に合わせて変更してください。 SessionLogsLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/ssm/${ProjectName}-session-logs' RetentionInDays: 30 KmsKeyId: !GetAtt RDPRecordingKMSKey.Arn 2. IAMロールへの権限追加 EC2インスタンスロールに、CloudWatch Logsへの書き込み権限を追加します。 EC2RoleCloudWatchLogsPolicy: Type: AWS::IAM::Policy Properties: PolicyName: !Sub '${ProjectName}-cloudwatch-logs-policy' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:CreateLogStream' - 'logs:PutLogEvents' - 'logs:DescribeLogStreams' Resource: !GetAtt SessionLogsLogGroup.Arn - Effect: Allow Action: - 'logs:DescribeLogGroups' Resource: '*' Roles: - !Ref EC2Role 3. KMSキーの作成 CloudWatch Logsに記録されるセッションログは、KMSカスタマーマネージドキーで暗号化されます。 CloudFormationテンプレートで、以下のようにKMSキーを作成します。 RDPRecordingKMSKey: Type: AWS::KMS::Key Properties: Description: 'KMS key for RDP recording and session logs encryption' KeyPolicy: Version: '2012-10-17' Statement: (中略) - Sid: 'Allow EC2 role to use the key' Effect: Allow Principal: AWS: !GetAtt EC2Role.Arn Action: - 'kms:CreateGrant' - 'kms:Decrypt' - 'kms:DescribeKey' Resource: '*' - Sid: 'Allow CloudWatch Logs to use the key' Effect: Allow Principal: Service: !Sub 'logs.${AWS::Region}.amazonaws.com' Action: - 'kms:Encrypt' - 'kms:Decrypt' - 'kms:ReEncrypt*' - 'kms:GenerateDataKey*' - 'kms:CreateGrant' - 'kms:DescribeKey' Resource: '*' Condition: ArnLike: 'kms:EncryptionContext:aws:logs:arn': !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/ssm/${ProjectName}-session-logs' Tags: - Key: SystemsManagerJustInTimeNodeAccessManaged Value: 'true' 設計上の留意点: KMSキーには必須タグ SystemsManagerJustInTimeNodeAccessManaged: true を設定 CloudWatch Logsサービスプリンシパルに暗号化・復号化権限を付与 kms:EncryptionContext:aws:logs:arn 条件でアクセスを制限 SSM-SessionManagerRunShellドキュメントとは Session Managerの設定を行うと、AWSは自動的に SSM-SessionManagerRunShell という名前のセッションドキュメントを作成します。このドキュメントには、以下のようなセッション設定が保存されます。 ログ保存先(CloudWatch LogsまたはS3バケット) KMS暗号化の設定 Run As設定(Linuxノードでの実行ユーザー指定) ただし、デフォルトではログ記録は無効になっています。ログ記録を有効化するには、SSM-SessionManagerRunShell ドキュメントを更新する必要があります。その前に、このドキュメントがSession Managerでどのような役割を果たしているのか説明します。 マネジメントコンソールからの接続の場合 AWS Management Console(Systems ManagerのFleet ManagerまたはSession Manager、EC2 ConnectのSession Manager)からSession Manager接続を開始すると、必ずSSM-SessionManagerRunShellドキュメントが使用されます。 したがって、マネジメントコンソールから開始したセッションのログを記録したい場合は、SSM-SessionManagerRunShellドキュメントでログ記録を有効化しておく必要があります。 AWS CLIからの接続の場合 AWS CLIの aws ssm start-session コマンドでSession Manager接続を開始する場合、以下の動作になります。 ドキュメントを指定しない場合: SSM-SessionManagerRunShellドキュメントが使用される –document-nameパラメータでドキュメントを指定した場合: 指定したドキュメントの設定が使用される 例えば、以下のコマンドではポートフォワーディング用のドキュメントを指定しています。 aws ssm start-session \ --target i-1234567890abcdef0 \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["80"],"localPortNumber":["8080"]}' この場合、AWS-StartPortForwardingSessionのログ記録設定が使用され、SSM-SessionManagerRunShellのログ記録設定は適用されません。 ログ記録を回避できる可能性と対策 上記の通り、AWS CLIで –document-name パラメータを使用すると、ログ記録を回避できてしまう可能性があります。すべてのセッションのログを記録するには、以下のいずれかの対策が必要です。 対策1: ログ記録しないドキュメントを作成させない この対策を有効にするには、以下の2つの条件を満たす必要があります。 1. ログ記録設定のないカスタムドキュメントをアカウント内に置かない 2. ユーザーに ssm:CreateDocument および ssm:UpdateDocument 権限を付与しない(ドキュメントの新規作成・変更を禁止) 対策2: IAMポリシーでSSM-SessionManagerRunShellのみを許可する IAMポリシーでSSM-SessionManagerRunShell以外のドキュメントを明示的に拒否(Deny)することで、ユーザーがログ記録を回避できないようにします。本案件ではこちらを採用しました。 以下のポリシー例では、NotResourceを使ったDenyステートメントで、SSM-SessionManagerRunShell以外のドキュメントを使用した接続(ssm:StartSession)を禁止しています。(SSM-SessionManagerRunShellはポリシー内の別の箇所で許可してください) { "Version": "2012-10-17", "Statement": [ { "Sid": "DenySessionsWithoutLoggingDocument", "Effect": "Deny", "Action": "ssm:StartSession", "NotResource": "arn:aws:ssm:*:*:document/SSM-SessionManagerRunShell" } ] } EC2インスタンスプロファイルの権限要件 SSM-SessionManagerRunShellドキュメントでログ記録を有効化した場合、EC2インスタンスプロファイルにログ記録先への書き込み権限が必要です。 権限が不足している場合、ログが記録されないだけでなく、セッション自体がエラーになります。したがって、マネジメントコンソールからSession Manager接続するすべてのEC2インスタンスには、以下の権限が必要です。 CloudWatch Logsへの書き込み権限(logs:CreateLogStream、logs:PutLogEvents) KMS暗号化を使用する場合は、KMSキーへのアクセス権限(kms:Decrypt、kms:GenerateDataKey) デフォルトログ記録の有効化手順 CloudFormationスタックのデプロイ後、enable-default-session-logging.shスクリプトを実行してSSM-SessionManagerRunShellドキュメントのログ記録を有効化します。※本記事末尾に掲載 ./enable-default-session-logging.sh ap-northeast-1 default session-manager-stack defaultのところはAWS CLIで使用するプロファイル名、session-manager-stackのところは、デプロイ済みのCloudFormationスタック名を指定してください。 このスクリプトは、CloudFormationスタックからCloudWatch LogsグループとKMSキーARNを取得し、SSM-SessionManagerRunShellドキュメントを更新します。スクリプト実行後、マネジメントコンソールおよびAWS CLI(ドキュメント指定なし)からのすべてのSession Manager接続で、自動的にCloudWatch Logsにログが記録されるようになります。 動作確認手順 ここまでで、設定は完了です。 マネージドコンソールから、作成されたWindowsインスタンスにSSM Session Managerで接続してみてください。 CLIが表示され、ログ保存先S3バケットにログファイルが保存されていれば正常に動作しています。 また、インスタンスへのSession Manager接続を許可するときに使われるAmazonSSMManagedInstanceCoreのみをポリシーとして持つインスタンスプロファイルのEC2に接続すると図のようなエラーになることも確認してみてください。(下図) デフォルトログ記録の無効化手順 この設定では本検証外でSession Managerを使用するEC2インスタンスへの接続がエラーになってしまうので、検証が終わったら設定を元に戻す必要があります。disable-default-session-logging.shスクリプトを実行してSSM-SessionManagerRunShellドキュメントのログ記録を無効化することができます。※本記事末尾に掲載 ./disable-default-session-logging.sh ap-northeast-1 default defaultのところはAWS CLIで使用するプロファイル名を指定してください。   まとめ 監査用ログの取得という観点から、確実にSession Managerでセッションログを取得する方法について検討してきました。CLIのセッションログで、Linuxインスタンスのログも取得できるためこれはこれで有用な設定なのですが、まだRDP録画にすらたどり着いていません。次の記事ではRDP録画について検討します。 ソースコード 本記事で使用したソースコードはこちらです。 cfn-session-manager-rdp-logging.yaml AWSTemplateFormatVersion: '2010-09-09' Description: 'Session Managerセッションログ取得、RDP録画の検証用環境を構築する' Parameters: KeyPairName: Type: AWS::EC2::KeyPair::KeyName Description: 'EC2インスタンスに関連付けるキーペア名 (既存のキーペアを指定)' ProjectName: Type: String Default: 'session-manager-recording' Description: 'プロジェクト名 (リソース名とタグに使用)' AllowedPattern: '^[a-z0-9-]+$' ConstraintDescription: '小文字英数字とハイフンのみ使用可能' Resources: # VPC VPC: Type: AWS::EC2::VPC Properties: CidrBlock: 10.0.0.0/16 EnableDnsHostnames: true EnableDnsSupport: true Tags: - Key: Name Value: !Sub '${ProjectName}-vpc' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # プライベートサブネット PrivateSubnet: Type: AWS::EC2::Subnet Properties: VpcId: !Ref VPC CidrBlock: 10.0.1.0/24 AvailabilityZone: ap-northeast-1a Tags: - Key: Name Value: !Sub '${ProjectName}-private-subnet-1a' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # プライベートサブネット用ルートテーブル PrivateRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref VPC Tags: - Key: Name Value: !Sub '${ProjectName}-private-rt' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # プライベートサブネットとルートテーブルの関連付け PrivateSubnetRouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref PrivateSubnet RouteTableId: !Ref PrivateRouteTable # VPCエンドポイント用セキュリティグループ VPCEndpointSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupName: !Sub '${ProjectName}-vpce-sg' GroupDescription: 'Security group for VPC Endpoints' VpcId: !Ref VPC SecurityGroupIngress: - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIp: 10.0.0.0/16 Description: 'Allow HTTPS from VPC CIDR' Tags: - Key: Name Value: !Sub '${ProjectName}-vpce-sg' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # EC2インスタンス用セキュリティグループ EC2SecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupName: !Sub '${ProjectName}-ec2-sg' GroupDescription: 'Security group for EC2 instance' VpcId: !Ref VPC SecurityGroupEgress: - IpProtocol: tcp FromPort: 443 ToPort: 443 DestinationSecurityGroupId: !Ref VPCEndpointSecurityGroup Description: 'Allow HTTPS to VPC Endpoints' Tags: - Key: Name Value: !Sub '${ProjectName}-ec2-sg' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # VPCエンドポイント - SSM SSMVPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Interface ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ssm' VpcId: !Ref VPC SubnetIds: - !Ref PrivateSubnet SecurityGroupIds: - !Ref VPCEndpointSecurityGroup PrivateDnsEnabled: true # VPCエンドポイント - SSM Messages SSMMessagesVPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Interface ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ssmmessages' VpcId: !Ref VPC SubnetIds: - !Ref PrivateSubnet SecurityGroupIds: - !Ref VPCEndpointSecurityGroup PrivateDnsEnabled: true # VPCエンドポイント - EC2 Messages EC2MessagesVPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Interface ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ec2messages' VpcId: !Ref VPC SubnetIds: - !Ref PrivateSubnet SecurityGroupIds: - !Ref VPCEndpointSecurityGroup PrivateDnsEnabled: true # VPCエンドポイント - CloudWatch Logs LogsVPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Interface ServiceName: !Sub 'com.amazonaws.${AWS::Region}.logs' VpcId: !Ref VPC SubnetIds: - !Ref PrivateSubnet SecurityGroupIds: - !Ref VPCEndpointSecurityGroup PrivateDnsEnabled: true # VPCエンドポイント - KMS KMSVPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Interface ServiceName: !Sub 'com.amazonaws.${AWS::Region}.kms' VpcId: !Ref VPC SubnetIds: - !Ref PrivateSubnet SecurityGroupIds: - !Ref VPCEndpointSecurityGroup PrivateDnsEnabled: true # VPCエンドポイント - S3 (Gateway型) S3VPCEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcEndpointType: Gateway ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3' VpcId: !Ref VPC RouteTableIds: - !Ref PrivateRouteTable # EC2用IAMロール EC2Role: Type: AWS::IAM::Role Properties: RoleName: !Sub '${ProjectName}-ec2-role' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: ec2.amazonaws.com Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore' Tags: - Key: Name Value: !Sub '${ProjectName}-ec2-role' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # EC2インスタンスプロファイル EC2InstanceProfile: Type: AWS::IAM::InstanceProfile Properties: InstanceProfileName: !Sub '${ProjectName}-ec2-instance-profile' Roles: - !Ref EC2Role # RDP記録用S3バケット SessionLogsBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub '${ProjectName}-session-logs-${AWS::AccountId}' BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 VersioningConfiguration: Status: Enabled PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: MoveToGlacierAfter90Days Status: Enabled Transitions: - TransitionInDays: 90 StorageClass: GLACIER Tags: - Key: Name Value: !Sub '${ProjectName}-session-logs' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # IAMロールにS3書き込み権限を追加 EC2RoleS3Policy: Type: AWS::IAM::Policy Properties: PolicyName: !Sub '${ProjectName}-s3-write-policy' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - 's3:PutObject' - 's3:PutObjectAcl' Resource: !Sub '${SessionLogsBucket.Arn}/*' - Effect: Allow Action: - 's3:GetEncryptionConfiguration' Resource: !GetAtt SessionLogsBucket.Arn Roles: - !Ref EC2Role # Session Logs用CloudWatch Logsロググループ SessionLogsLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/ssm/${ProjectName}-session-logs' KmsKeyId: !GetAtt RDPRecordingKMSKey.Arn RetentionInDays: 30 Tags: - Key: Name Value: !Sub '${ProjectName}-session-logs' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # IAMロールにCloudWatch Logs書き込み権限を追加 EC2RoleCloudWatchLogsPolicy: Type: AWS::IAM::Policy Properties: PolicyName: !Sub '${ProjectName}-cloudwatch-logs-policy' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - 'logs:CreateLogStream' - 'logs:PutLogEvents' - 'logs:DescribeLogStreams' Resource: !GetAtt SessionLogsLogGroup.Arn - Effect: Allow Action: - 'logs:DescribeLogGroups' Resource: '*' Roles: - !Ref EC2Role # オプトインSession Manager設定ドキュメント(テスト用) SessionManagerOptInDocument: Type: AWS::SSM::Document Properties: DocumentType: Session Content: schemaVersion: '1.0' description: 'Opt-in Session Manager preferences for logging to CloudWatch' sessionType: Standard_Stream inputs: s3BucketName: '' s3KeyPrefix: '' s3EncryptionEnabled: false cloudWatchLogGroupName: !Ref SessionLogsLogGroup cloudWatchEncryptionEnabled: false cloudWatchStreamingEnabled: true runAsEnabled: false runAsDefaultUser: '' Tags: - Key: Name Value: !Sub '${ProjectName}-session-manager-opt-in' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # Windows EC2インスタンス WindowsEC2Instance: Type: AWS::EC2::Instance DependsOn: - SSMVPCEndpoint - SSMMessagesVPCEndpoint - EC2MessagesVPCEndpoint - S3VPCEndpoint - LogsVPCEndpoint - KMSVPCEndpoint Properties: ImageId: !Sub '{{resolve:ssm:/aws/service/ami-windows-latest/Windows_Server-2022-Japanese-Full-Base}}' InstanceType: t3.medium KeyName: !Ref KeyPairName IamInstanceProfile: !Ref EC2InstanceProfile SubnetId: !Ref PrivateSubnet SecurityGroupIds: - !Ref EC2SecurityGroup PropagateTagsToVolumeOnCreation: true Tags: - Key: Name Value: !Sub '${ProjectName}-windows-ec2' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation - Key: Cost Value: h.kaizuka # RDP接続とセッションログ記録用KMSカスタマーマネージドキー RDPRecordingKMSKey: Type: AWS::KMS::Key Properties: Description: 'KMS key for RDP recording and session logs encryption' KeyPolicy: Version: '2012-10-17' Statement: - Sid: 'Enable IAM User Permissions' Effect: Allow Principal: AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root' Action: 'kms:*' Resource: '*' - Sid: 'Allow GUI Connect service to use the key' Effect: Allow Principal: Service: 'ssm-guiconnect.amazonaws.com' Action: - 'kms:GenerateDataKey' - 'kms:Decrypt' Resource: '*' Condition: StringEquals: 'aws:SourceAccount': !Ref AWS::AccountId - Sid: 'Allow EC2 role to use the key' Effect: Allow Principal: AWS: !GetAtt EC2Role.Arn Action: - 'kms:CreateGrant' - 'kms:Decrypt' - 'kms:DescribeKey' Resource: '*' - Sid: 'Allow CloudWatch Logs to use the key' Effect: Allow Principal: Service: !Sub 'logs.${AWS::Region}.amazonaws.com' Action: - 'kms:Encrypt' - 'kms:Decrypt' - 'kms:ReEncrypt*' - 'kms:GenerateDataKey*' - 'kms:CreateGrant' - 'kms:DescribeKey' Resource: '*' Condition: ArnLike: 'kms:EncryptionContext:aws:logs:arn': !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/ssm/${ProjectName}-session-logs' Tags: - Key: SystemsManagerJustInTimeNodeAccessManaged Value: 'true' - Key: Name Value: !Sub '${ProjectName}-rdp-recording-key' - Key: Project Value: !Ref ProjectName - Key: Environment Value: Development - Key: ManagedBy Value: CloudFormation # RDP接続記録用KMSキーエイリアス RDPRecordingKMSKeyAlias: Type: AWS::KMS::Alias Properties: AliasName: !Sub 'alias/${ProjectName}-rdp-recording-key' TargetKeyId: !Ref RDPRecordingKMSKey # S3バケットポリシー (RDP接続記録用) SessionLogsBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref SessionLogsBucket PolicyDocument: Version: '2012-10-17' Statement: - Sid: 'ConnectionRecording' Effect: Allow Principal: Service: 'ssm-guiconnect.amazonaws.com' Action: 's3:PutObject' Resource: - !GetAtt SessionLogsBucket.Arn - !Sub '${SessionLogsBucket.Arn}/*' Condition: StringEquals: 'aws:SourceAccount': !Ref AWS::AccountId # IAMロールにRDP接続記録の権限を追加 EC2RoleRDPRecordingPolicy: Type: AWS::IAM::Policy Properties: PolicyName: !Sub '${ProjectName}-rdp-recording-policy' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - 'ssm-guiconnect:UpdateConnectionRecordingPreferences' - 'ssm-guiconnect:GetConnectionRecordingPreferences' - 'ssm-guiconnect:DeleteConnectionRecordingPreferences' - 'ssm-guiconnect:CancelConnection' - 'ssm-guiconnect:GetConnection' - 'ssm-guiconnect:StartConnection' Resource: '*' - Effect: Allow Action: - 'kms:CreateGrant' - 'kms:Decrypt' - 'kms:DescribeKey' Resource: !GetAtt RDPRecordingKMSKey.Arn Roles: - !Ref EC2Role Outputs: VPCId: Description: 'VPC ID' Value: !Ref VPC Export: Name: !Sub '${AWS::StackName}-VPCId' EC2InstanceId: Description: 'EC2 Instance ID' Value: !Ref WindowsEC2Instance Export: Name: !Sub '${AWS::StackName}-EC2InstanceId' SessionLogsBucketName: Description: 'Session Logs S3 Bucket Name' Value: !Ref SessionLogsBucket Export: Name: !Sub '${AWS::StackName}-SessionLogsBucketName' SessionLogsLogGroupName: Description: 'Session Logs CloudWatch Log Group Name' Value: !Ref SessionLogsLogGroup Export: Name: !Sub '${AWS::StackName}-SessionLogsLogGroupName' SessionManagerOptInDocumentName: Description: 'Opt-in Session Manager Document Name' Value: !Ref SessionManagerOptInDocument Export: Name: !Sub '${AWS::StackName}-SessionManagerOptInDocumentName' EC2PrivateIP: Description: 'EC2 Instance Private IP Address' Value: !GetAtt WindowsEC2Instance.PrivateIp Export: Name: !Sub '${AWS::StackName}-EC2PrivateIP' RDPRecordingKMSKeyId: Description: 'RDP Recording KMS Key ID' Value: !Ref RDPRecordingKMSKey Export: Name: !Sub '${AWS::StackName}-RDPRecordingKMSKeyId' RDPRecordingKMSKeyArn: Description: 'RDP Recording KMS Key ARN' Value: !GetAtt RDPRecordingKMSKey.Arn Export: Name: !Sub '${AWS::StackName}-RDPRecordingKMSKeyArn' enable-default-session-logging.sh #!/bin/bash # アカウントレベルのSession Manager設定のログ記録を有効化 # 使用方法: ./enable-default-session-logging-updated.sh [region] [profile] [stack-name] # 例: ./enable-default-session-logging-updated.sh ap-northeast-1 mng session-manager-stack set -e REGION=${1:-ap-northeast-1} PROFILE=${2:-} STACK_NAME=${3:-} TEMP_DIR="/tmp/session-manager-config-$$" # プロファイルオプションの設定 PROFILE_OPT="" if [ -n "${PROFILE}" ]; then PROFILE_OPT="--profile ${PROFILE}" fi echo "Session Manager設定のログ記録を有効化します..." echo "リージョン: $REGION" # スタック名が指定されていない場合はエラー if [ -z "${STACK_NAME}" ]; then echo "エラー: スタック名が指定されていません" echo "使用方法: $0 [region] [profile] [stack-name]" exit 1 fi # 一時ディレクトリを作成 mkdir -p "$TEMP_DIR" # クリーンアップ関数 cleanup() { rm -rf "$TEMP_DIR" } trap cleanup EXIT # CloudFormationスタックからCloudWatch Logsグループ名を取得 echo "CloudFormationスタックからCloudWatch Logsグループ名を取得中..." LOG_GROUP=$(aws cloudformation describe-stacks \ ${PROFILE_OPT} \ --stack-name "${STACK_NAME}" \ --region "$REGION" \ --query 'Stacks[0].Outputs[?OutputKey==`SessionLogsLogGroupName`].OutputValue' \ --output text \ --no-cli-pager) if [ -z "${LOG_GROUP}" ]; then echo "エラー: CloudFormationスタックからCloudWatch Logsグループ名を取得できませんでした" echo "スタック名が正しいか確認してください: ${STACK_NAME}" exit 1 fi echo "CloudWatch Logsグループ名: $LOG_GROUP" # CloudFormationスタックからKMSキーARNを取得 echo "CloudFormationスタックからKMSキーARNを取得中..." KMS_KEY_ARN=$(aws cloudformation describe-stacks \ ${PROFILE_OPT} \ --stack-name "${STACK_NAME}" \ --region "$REGION" \ --query 'Stacks[0].Outputs[?OutputKey==`RDPRecordingKMSKeyArn`].OutputValue' \ --output text \ --no-cli-pager) echo "KMSキーARN: $KMS_KEY_ARN" # 現在の設定を取得 echo "現在の設定を取得中..." if ! aws ssm get-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --region "$REGION" \ --query 'Content' \ --output text > "$TEMP_DIR/current-session-config.json" 2>/dev/null; then echo "SSM-SessionManagerRunShellドキュメントが存在しません。新規作成します..." # デフォルト設定(ログ記録有効)を作成 cat > "$TEMP_DIR/new-session-config.json" < /dev/null echo "[+] SSM-SessionManagerRunShellドキュメントを作成しました" echo "[+] Session Manager logging enabled successfully" echo "[+] CloudWatchロググループ名: $LOG_GROUP" echo "[+] KMSキーARN: $KMS_KEY_ARN" exit 0 fi # 現在の設定を表示 echo "現在の設定:" jq '.inputs | {s3BucketName, cloudWatchLogGroupName, kmsKeyId}' "$TEMP_DIR/current-session-config.json" # ログ記録を有効化した設定を作成 echo "ログ記録を有効化した設定を作成中..." jq ".inputs.cloudWatchLogGroupName = \"${LOG_GROUP}\" | .inputs.kmsKeyId = \"${KMS_KEY_ARN}\"" \ "$TEMP_DIR/current-session-config.json" > "$TEMP_DIR/updated-session-config.json" # 更新後の設定を表示 echo "更新後の設定:" jq '.inputs | {s3BucketName, cloudWatchLogGroupName, kmsKeyId}' "$TEMP_DIR/updated-session-config.json" # 設定を更新 echo "設定を更新中..." UPDATE_RESULT=$(aws ssm update-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --content "file://$TEMP_DIR/updated-session-config.json" \ --document-version "\$LATEST" \ --region "$REGION" \ --no-cli-pager 2>&1) || true # DuplicateDocumentContentエラーの場合は、既に同じ内容のバージョンが存在する if echo "$UPDATE_RESULT" | grep -q "DuplicateDocumentContent"; then echo "[i] 同じ内容のドキュメントバージョンが既に存在します。" echo "最新バージョンをデフォルトバージョンに設定します..." # 最新バージョン番号を取得 LATEST_VERSION=$(aws ssm describe-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --region "$REGION" \ --query 'Document.LatestVersion' \ --output text) # デフォルトバージョンを最新バージョンに更新 if aws ssm update-document-default-version \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --document-version "$LATEST_VERSION" \ --region "$REGION" \ --no-cli-pager > /dev/null; then echo "[+] デフォルトバージョンをバージョン$LATEST_VERSIONに更新しました" else echo "エラー: デフォルトバージョンの更新に失敗しました。" exit 1 fi elif echo "$UPDATE_RESULT" | grep -q "error"; then echo "エラー: 設定の更新に失敗しました。" echo "$UPDATE_RESULT" exit 1 else echo "[+] 新しいドキュメントバージョンを作成しました" # 新しいバージョンをデフォルトに設定 NEW_VERSION=$(echo "$UPDATE_RESULT" | jq -r '.DocumentDescription.LatestVersion') if aws ssm update-document-default-version \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --document-version "$NEW_VERSION" \ --region "$REGION" \ --no-cli-pager > /dev/null; then echo "[+] デフォルトバージョンをバージョン$NEW_VERSIONに更新しました" else echo "エラー: デフォルトバージョンの更新に失敗しました。" exit 1 fi fi echo "[+] Session Manager logging enabled successfully" echo "[+] CloudWatchロググループ名: $LOG_GROUP" echo "[+] KMSキーARN: $KMS_KEY_ARN" disable-default-session-logging.sh #!/bin/bash # アカウントレベルのSession Manager設定のログ記録を無効化 # 使用方法: ./disable-default-session-logging.sh [region] [profile] # 例: ./disable-default-session-logging.sh ap-northeast-1 default set -e REGION=${1:-ap-northeast-1} PROFILE=${2:-} TEMP_DIR="/tmp/session-manager-config-$$" # プロファイルオプションの設定 PROFILE_OPT="" if [ -n "${PROFILE}" ]; then PROFILE_OPT="--profile ${PROFILE}" fi echo "Session Manager設定のログ記録を無効化します..." echo "リージョン: $REGION" # 一時ディレクトリを作成 mkdir -p "$TEMP_DIR" # クリーンアップ関数 cleanup() { rm -rf "$TEMP_DIR" } trap cleanup EXIT # 現在の設定を取得 echo "現在の設定を取得中..." if ! aws ssm get-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --region "$REGION" \ --query 'Content' \ --output text > "$TEMP_DIR/current-session-config.json" 2>/dev/null; then echo "SSM-SessionManagerRunShellドキュメントが存在しません。新規作成します..." # デフォルト設定(ログ記録無効)を作成 cat > "$TEMP_DIR/new-session-config.json" <<'EOF' { "schemaVersion": "1.0", "description": "Document to hold regional settings for Session Manager", "sessionType": "Standard_Stream", "inputs": { "s3BucketName": "", "s3KeyPrefix": "", "s3EncryptionEnabled": true, "cloudWatchLogGroupName": "", "cloudWatchEncryptionEnabled": true, "cloudWatchStreamingEnabled": true, "kmsKeyId": "", "runAsEnabled": false, "runAsDefaultUser": "" } } EOF # ドキュメントを作成 echo "ドキュメントを作成中..." aws ssm create-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --document-type "Session" \ --content "file://$TEMP_DIR/new-session-config.json" \ --region "$REGION" \ --no-cli-pager > /dev/null echo "[+] SSM-SessionManagerRunShellドキュメントを作成しました" echo "[+] Session Manager default logging disabled successfully" echo "[+] S3バケット名: (空)" echo "[+] CloudWatchロググループ名: (空)" exit 0 fi # 現在の設定を表示 echo "現在の設定:" jq '.inputs | {s3BucketName, cloudWatchLogGroupName}' "$TEMP_DIR/current-session-config.json" # ログ記録を無効化した設定を作成 echo "ログ記録を無効化した設定を作成中..." jq '.inputs.s3BucketName = "" | .inputs.cloudWatchLogGroupName = ""' \ "$TEMP_DIR/current-session-config.json" > "$TEMP_DIR/updated-session-config.json" # 更新後の設定を表示 echo "更新後の設定:" jq '.inputs | {s3BucketName, cloudWatchLogGroupName}' "$TEMP_DIR/updated-session-config.json" # 設定を更新 echo "設定を更新中..." UPDATE_RESULT=$(aws ssm update-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --content "file://$TEMP_DIR/updated-session-config.json" \ --document-version "\$LATEST" \ --region "$REGION" \ --no-cli-pager 2>&1) || true # DuplicateDocumentContentエラーの場合は、既に同じ内容のバージョンが存在する if echo "$UPDATE_RESULT" | grep -q "DuplicateDocumentContent"; then echo "[i] 同じ内容のドキュメントバージョンが既に存在します。" echo "最新バージョンをデフォルトバージョンに設定します..." # 最新バージョン番号を取得 LATEST_VERSION=$(aws ssm describe-document \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --region "$REGION" \ --query 'Document.LatestVersion' \ --output text) # デフォルトバージョンを最新バージョンに更新 if aws ssm update-document-default-version \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --document-version "$LATEST_VERSION" \ --region "$REGION" \ --no-cli-pager > /dev/null; then echo "[+] デフォルトバージョンをバージョン$LATEST_VERSIONに更新しました" else echo "エラー: デフォルトバージョンの更新に失敗しました。" exit 1 fi elif echo "$UPDATE_RESULT" | grep -q "error"; then echo "エラー: 設定の更新に失敗しました。" echo "$UPDATE_RESULT" exit 1 else echo "[+] 新しいドキュメントバージョンを作成しました" # 新しいバージョンをデフォルトに設定 NEW_VERSION=$(echo "$UPDATE_RESULT" | jq -r '.DocumentDescription.LatestVersion') if aws ssm update-document-default-version \ ${PROFILE_OPT} \ --name "SSM-SessionManagerRunShell" \ --document-version "$NEW_VERSION" \ --region "$REGION" \ --no-cli-pager > /dev/null; then echo "[+] デフォルトバージョンをバージョン$NEW_VERSIONに更新しました" else echo "エラー: デフォルトバージョンの更新に失敗しました。" exit 1 fi fi echo "[+] Session Manager default logging disabled successfully" echo "[+] S3バケット名: (空)" echo "[+] CloudWatchロググループ名: (空)"  
アバター