ECS FargateとX-Rayの連携を試す
AWS X-RayとECS Fargateを連携し、Streamlitアプリのプロファイリングを試してみました。サイドカーコンテナとしてX-Rayを実装することで、コンテナ内の詳細なプロファイリングが可能となり、特に入れ子になったような関数のプロファイリングで便利です。
目次
はじめに
DVAの勉強をしていて、AWS X-RayとECSを連携できるというのを知ったので、Fargateで試してみました。結論としてはできて、コンテナ内のプロファイリングが便利にできます。print文を出力したCloudwatch Logsとにらめっこしなくてもよくなります。
関連情報
やること
- FargateにOpenAIにリクエストを送信するStreamlitのアプリをデプロイします
- その上で、送信部分のプロファイリングをX-Rayに行わせます
- 上の記事にもありますが、X-RayはFargateのサイドカーコンテナとして実装します。AWSの公式記事ではECSのタスク定義を以下のようにします。
- しかし、これはECSのBridgeモードで実装されたモードで、FargateはAWSVPCモードになるため、ホストのポートとコンテナ側のポートを一致させる必要があります。その対応が別途必要です。
{
"name": "xray-daemon",
"image": "123456789012.dkr.ecr.us-east-2.amazonaws.com/xray-daemon",
"cpu": 32,
"memoryReservation": 256,
"portMappings" : [
{
"hostPort": 0,
"containerPort": 2000,
"protocol": "udp"
}
]
},
{
"name": "scorekeep-api",
"image": "123456789012.dkr.ecr.us-east-2.amazonaws.com/scorekeep-api",
"cpu": 192,
"memoryReservation": 512,
"environment": [
{ "name" : "AWS_REGION", "value" : "us-east-2" },
{ "name" : "NOTIFICATION_TOPIC", "value" : "arn:aws:sns:us-east-2:123456789012:scorekeep-notifications" },
{ "name" : "AWS_XRAY_DAEMON_ADDRESS", "value" : "xray-daemon:2000" }
],
"portMappings" : [
{
"hostPort": 5000,
"containerPort": 5000
}
],
"links": [
"xray-daemon"
]
}
ディレクトリ構造
ChatGPTに聞きながらTerraformで実装してみました。以下のディレクトリ構造になります。
.
├── fargate.tf
├── main.tf
├── network.tf
├── iam.tf
└── app/
├── app.py
├── Dockerfile
└── requirements.txt
fargate.tf
: AWS Fargate関連の設定が含まれるTerraformファイル。main.tf
: メインのTerraform設定ファイル。network.tf
: ネットワーク構成の設定ファイル。ここではALBをデプロイしました。VPCは作成済みとします。iam.tf
: IAMロールやポリシーに関する設定ファイル。app/
: アプリケーション関連のファイルを格納するディレクトリ。app.py
: アプリケーション本体のPythonスクリプト。Dockerfile
: アプリケーションのDockerイメージを作成するための定義ファイル。requirements.txt
: Pythonライブラリの依存関係を記述したファイル。
結果
メインのStreamlitのアプリ
実行するとこのようにX-Rayからプロファイリングができる
注意点
うまくいったら「CloudWatch」の「X-Ray トレース→トレース」というところにいます。
コンテナからX-Rayにログ送信が失敗していることがあるので、ログがきていないなと思ったら、X-RayのCloudWatch Logも調べてみてください。CloudWatch Logに成功すると以下のようにログが書き込まれています。
2024-12-17T06:27:31Z [Info] Successfully sent batch of 1 segments (0.023 seconds)
書き込みに失敗すると以下のようになっているはずです。
2024-12-17T06:08:43Z [Error] Sending segment batch failed with: AccessDeniedException:…
各ファイル
個々のソースを張っていきます。長いのでスキップしてください
fargate.tf(Fargate)
- Streamlitのタスク定義と、X-Rayのタスク定義を用意します。イメージ的にはコンテナを2個立てる感じです。
- X-RayのコンテナはAWSが用意したものを利用します
- ログの出力先を指定します。そのためのCloudWatch Logの作成も行っています
- ECRのリポジトリは作成済みとします
- OpenAIのAPIキーはSystems Manager Parameter StoreにSecureStringとして保存し、その対応ARNをシークレットとして保存しています。これはECS側でデプロイ時にコンテナ内の環境変数として自動展開されます。
- X-Rayのデーモンアドレスは、AWS VPCモードに対応させるために
127.0.0.1:2000
のように対応させます。ここはブリッジモードと扱いが異なります。
# ECSクラスターの作成
resource "aws_ecs_cluster" "main" {
name = "fargate-cluster"
}
# セキュリティグループの作成
resource "aws_security_group" "ecs_sg" {
name = "ecs-sg"
description = "Allow traffic from ALB"
vpc_id = var.vpc_id
ingress {
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
security_groups = [aws_security_group.alb_sg.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
data "aws_ecr_image" "fargate_image" {
repository_name = var.ecr_repository
image_tag = var.image_tag
}
data "aws_ssm_parameter" "openai_api_key" {
name = "/openai-api-key"
with_decryption = false
}
# CloudWatch Logs グループの作成
resource "aws_cloudwatch_log_group" "ecs_log_group_streamlit" {
name = "/ecs/fargate-service"
retention_in_days = 14 # ログの保持期間を設定(必要に応じて変更)
}
# ECSタスク定義の作成
resource "aws_ecs_task_definition" "app_task" {
family = "fargate-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "512"
memory = "1024"
container_definitions = jsonencode([
{
name = "xray-daemon" # X-Rayをサイドカーコンテナとして定義
image = "amazon/aws-xray-daemon"
essential = false
cpu = 32
memoryReservation = 256
portMappings = [
{
containerPort = 2000
protocol = "udp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs_log_group_streamlit.name
"awslogs-region" = "ap-northeast-1"
"awslogs-stream-prefix" = "xray"
}
}
},
{
name = "streamlit"
image = data.aws_ecr_image.fargate_image.image_uri
essential = true
portMappings = [
{
containerPort = var.app_port
hostPort = var.app_port
protocol = "tcp"
}
]
environment = [
{
name = "AWS_XRAY_DAEMON_ADDRESS"
value = "127.0.0.1:2000" # localhostを使用
},
{
name = "AWS_XRAY_TRACING_NAME"
value = "my-web-app"
}
]
secrets = [
{
name = "OPENAI_API_KEY" # コンテナ内での環境変数名
valueFrom = data.aws_ssm_parameter.openai_api_key.arn
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs_log_group_streamlit.name
"awslogs-region" = "ap-northeast-1"
"awslogs-stream-prefix" = "streamlit"
}
}
}
])
execution_role_arn = aws_iam_role.ecs_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_role.arn
}
# ECSサービスの作成
resource "aws_ecs_service" "app_service" {
name = "fargate-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app_task.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = var.public_subnet_id
security_groups = [aws_security_group.ecs_sg.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.app_tg.arn
container_name = "streamlit"
container_port = var.app_port
}
depends_on = [
aws_lb_listener.http
]
}
iam.tf(IAMロール)
- ECSには実行ロールとタスクロールがあります
- 実行ロールは、ECSのタスクを開始させるもので、具体的にはStreamlitやX-Rayのサービス(タスク)を起動します。
- タスクロールは、コンテナ内のアプリの権限を扱うもので、例えばS3への書き込みが必要なアプリならここに権限付与します
- 実行ロールでは、基本的なECSのポリシー
AmazonECSTaskExecutionRolePolicy
を付与しました。また、パラメーターストアからの値の取得のポリシーと、SecureStringの値の展開としてKMSのアクセスポリシーを付与しています。 - タスクロールでは、X-Rayの書き込みポリシーを付与しました
AWSXRayDaemonWriteAccess
を付与しました
# IAMロール(ECS実行用)
resource "aws_iam_role" "ecs_execution_role" {
name = "ecsExecutionRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution_policy" {
role = aws_iam_role.ecs_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# ECS実行ロールにSSMアクセス権限を付与
resource "aws_iam_role_policy" "ecs_execution_ssm_policy" {
name = "ecsExecutionSSMPolicy"
role = aws_iam_role.ecs_execution_role.id
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"ssm:GetParameters",
"ssm:GetParameter",
"ssm:GetParametersByPath"
],
Resource = [
data.aws_ssm_parameter.openai_api_key.arn # 必要なパラメータのARNを指定
]
},
{
Effect = "Allow",
Action = [
"kms:Decrypt"
],
Resource = "*"
}
]
})
}
# IAMロール(タスク用、必要に応じて)
resource "aws_iam_role" "ecs_task_role" {
name = "ecsTaskRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
# ECSタスクロールにX-Rayアクセス権限を付与
resource "aws_iam_role_policy_attachment" "ecs_task_xray_policy" {
role = aws_iam_role.ecs_task_role.name
policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
}
network.tf(ALB)
普通にALBを定義しているだけです。
resource "aws_security_group" "alb_sg" {
name = "alb-sg"
description = "Allow HTTP inbound traffic"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [var.my_ip]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Application Load Balancerの作成
resource "aws_lb" "app_lb" {
name = "app-load-balancer"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb_sg.id]
subnets = var.public_subnet_id
}
# ターゲットグループの作成
resource "aws_lb_target_group" "app_tg" {
name = "app-target-group"
port = var.app_port
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
health_check {
path = "/"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
matcher = "200-299"
}
}
# リスナーの作成
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app_lb.arn
port = var.app_port
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app_tg.arn
}
}
# ALBのURLの出力
output "app_url" {
value = aws_lb.app_lb.dns_name
}
main.tf
各variableの値は以下の通りです。(値は一部マスクしています)
# 変数定義
variable "public_subnet_id" {
description = "パブリックサブネットのID"
type = list(string)
default = ["subnet-0123456789abcdef0", "subnet-1234567890abcdef1", "subnet-234567890abcdef12"]
}
variable "vpc_id" {
description = "VPCのID"
type = string
default = "vpc-01234567abcdef89"
}
variable "ecr_repository" {
description = "ECRリポジトリ名"
type = string
default = "sandbox-rero"
}
variable "image_tag" {
description = "ECRイメージのタグ"
type = string
default = "streamlit_openai"
}
variable "app_port" {
description = "アプリケーションホストのポート"
type = number
default = 80
}
variable "ssm_paramater_name" {
description = "SSMパラメーターストアの名前"
type = string
default = "openai-api-key"
}
variable "my_ip" {
description = "アクセスを許可するIPアドレス"
type = string
default = "203.0.113.0/32"
}
app/Dockerfile
普通にStreamlitのDockerfileを作るだけ
FROM ubuntu:22.04
RUN apt-get update
ENV TZ=Asia/Tokyo
ENV LANG=en_US.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN apt-get install -yq --no-install-recommends python3-pip \
python3-dev \
curl \
unzip \
tzdata && apt-get upgrade -y && apt-get clean
RUN ln -s /usr/bin/python3 /usr/bin/python
WORKDIR /app
COPY requirements.txt .
RUN pip install -U pip &&\
pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 80
CMD ["streamlit", "run", "app.py", "--server.port=80", "--server.address=0.0.0.0"]
app/app.py
X-Rayの取り回しは以下のようにする
import streamlit as st
import openai
from aws_xray_sdk.core import xray_recorder
xray_recorder.configure(daemon_address='127.0.0.1:2000')
@xray_recorder.capture('Request openai')
def run_openai_message(user_input):
# ChatGPT APIへのリクエスト
response = openai.chat.completions.create(
model="gpt-4o", # または最新のモデルを指定
messages=[
{"role": "system", "content": "あなたはかわいい猫耳おじさんアシスタントです。おじさん特有の気持ち悪さを残しつつ、可愛く返信してください"},
{"role": "user", "content": user_input},
],
temperature=0.5,
)
# 応答の取得
chat_response = response.choices[0].message.content.strip()
return chat_response
# アプリのタイトル
st.title("ChatGPTと対話するアプリ")
# ユーザーからの入力
user_input = st.text_input("あなたの質問を入力してください:")
# 「チャット」ボタン
if st.button("チャット"):
if user_input:
try:
xray_recorder.begin_segment('Chat processing')
chat_response = run_openai_message(user_input)
# 応答の表示
st.write("**ChatGPTの応答:**")
st.write(chat_response)
xray_recorder.end_segment()
except Exception as e:
st.error(f"エラーが発生しました: {e}")
else:
st.warning("質問を入力してください。")
app/requirements.txt
AWS X-Ray SDKの追加が必要
openai==1.57.4
streamlit==1.41.1
aws-xray-sdk==2.14.0
おわりに
- X-Rayは最初とっつきづらいが、慣れてしまえば簡単そう
- クラスメソッドの記事にもあるけど、入れ子になった関数のプロファイルなど絶妙に面倒くさいところをカバーできるのがよさげ
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー