こしあん
2024-12-17

ECS FargateとX-Rayの連携を試す


9{icon} {views}

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の中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です