Cline
イベント
該当するコンテンツが見つかりませんでした
マガジン
該当するコンテンツが見つかりませんでした
技術ブログ
はじめに # これは、「面白そう」と感じたアーキテクチャや技術スタックを、まずはあれこれ試しながら作ってみたプロトタイピングの記録です。 前回は、AWS LambdaとSSMセッションマネージャーによるサーバーレスGPU起動ブローカーを構築しました。 (詳細は 前回の記事 を参照してください) これにより、ポート開放ゼロかつSSH秘密鍵不要で、世界中の最安スポットGPUをわずか数秒で探索・選定し、安全に直結できる環境が完成しました。 しかし、前回の構成を実践していく中で、もう1つ避けて通れない重要な課題が浮き彫りになります。 「マルチリージョン展開に必要なS3・ECR・セキュリティグループ・IAMロール・起動構成・Lambda関数を、手動や場当たり的なシェルスクリプトで管理し続けるのは辛い」 今回の構成では、東京(ap-northeast-1)に加えて海外4リージョン(バージニア、オハイオ、オレゴン、フランクフルト)の計5リージョンを調達先として扱います。これらのリージョンに手動で設定を入れると、設定漏れやタイプミスの温床になります。使わなくなった際の環境破棄や、別アカウントへの再現も極めて困難になります。 複数リージョンに分散配置するGPU実行基盤を、不要になったら丸ごと消せるエフェメラルなIaCとしてTerraformで組めば、管理や片付けが大幅に楽になります。 そこで今回は、 静的インフラ基盤のすべてをTerraformで一括コード化(IaC化) することにしました。 --> Information 📚 関連する過去の検証記事 第1回 : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回 : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回 : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回 : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ 第5回 : vLLM起動をAWS Lambda化!最安スポット探索と自動フォールバック 1. Terraform化における「役割分担(スコープ設計)」 # 📌 このセクションの要点 「常に存在する静的基盤」と「作業時だけ使い捨てるGPU」を明確に分離します。動的なGPU起動・停止までTerraformで管理しようとせず、ブローカー基盤までをTerraformで作り、GPUのオンデマンド調達はLambdaに任せる設計がベストプラクティスです。 本検証では、「常に存在するインフラ」と「作業時だけ使い捨てるGPU」を明確に分離しています。そのため、Terraformの管理範囲を以下のように切り分けるのがベストプラクティスです。 カテゴリ リソース 管理ツール ライフサイクル・費用感 静的基盤 S3, ECR(+レプリケーション), IAM, 各リージョンSG, Launch Template, Lambda Terraform 常時存在(使わない時の待機費用はS3・ECRの数GB保管料数十円のみ) 動的エフェメラル 最適リージョンのリアルタイム探索, EC2スポット起動/破棄, SSMトンネル Lambda + CLI オンデマンド実行(作業中のみ $0.4〜0.6/h 課金) 「EC2スポットの起動・破棄」までをTerraformで管理しようとすると、動的なリージョン選定が難しくなります。また、状態ファイル( tfstate )のロックや同期で毎回1分以上のオーバーヘッドが発生します。 そのため、 「ブローカー基盤までをTerraformで作り、GPUの生成・破棄はブローカー(Lambda)に任せる」 という設計が最も軽快で堅牢です。 2. コンテナイメージのマルチリージョン配置(ECRクロスリージョンレプリケーション) # 第1回〜第5回で採用している通り、vLLMのコンテナイメージ(約13GB)は Amazon ECR に永続化し、EC2の起動時に同一リージョン内から高速・転送料無料でPullします。 調達候補となる各リージョン(バージニア、オレゴン、オハイオ、フランクフルトなど)においても、同様に 同一リージョン内のECRからイメージを取得できる状態 を整えておく必要があります。仮に東京のECRから他リージョンのEC2へ都度Pullしてしまうと、リージョン間データ転送料金が毎回発生するうえ、コンテナ起動時間も大幅に増大してしまうためです。 とはいえ、イメージを更新するたびに各リージョンのECRエンドポイントへ手動で docker push を繰り返すのは運用負荷が高く、非効率です。 そこで、Amazon ECRがネイティブで備えている クロスリージョンレプリケーション(Cross-Region Replication) を採用します。Terraformでこの設定( aws_ecr_replication_configuration )を宣言しておきます。これにより、東京のECRに1回Pushするだけで、AWSバックボーン経由で全候補リージョンへ自動複製されます。 以下は、東京リージョンでのECRリポジトリ作成、旧世代イメージの自動削除ポリシー、および他4リージョンへの自動レプリケーションを定義したTerraformコードです。 ecr.tf(クリックで展開) # terraform/ecr.tf # 東京(プライマリ)リージョンの ECR リポジトリ resource "aws_ecr_repository" "vllm" { name = var.ecr_repository_name image_tag_mutability = "MUTABLE" tags = { Project = "vllm-ephemeral" } } # 古いタグの自動削除ポリシー(無駄なストレージ課金を防止) resource "aws_ecr_lifecycle_policy" "vllm_lifecycle" { repository = aws_ecr_repository.vllm.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "最新イメージのみ保持し、旧世代は30日で失効" selection = { tagStatus = "any", countType = "sinceImagePushed", countUnit = "days", countNumber = 30 } action = { type = "expire" } }] }) } # ★マルチリージョン・クロスリージョン自動レプリケーション resource "aws_ecr_replication_configuration" "vllm_replication" { replication_configuration { rule { dynamic "destination" { for_each = [for r in var.target_regions : r if r != var.primary_region] content { region = destination.value registry_id = data.aws_caller_identity.current.account_id } } } } } --> Information 💡 S3のモデル重み同期について(マルチリージョン配置の検討) コンテナイメージと同様に、S3に格納するモデル重み(約15GB)についても候補リージョンへの分散配置が考えられます。 S3クロスリージョンレプリケーション(CRR)を利用する場合 : 全リージョンに重みを事前同期しておくことで、起動時のダウンロード時間を最短化し、同一リージョン内通信(無料)に抑制可能。一方で、複数リージョンにモデルを保持し続けるとS3保管料がリージョン数分発生するため、旧バケットの定期削除やGlacierへの退避など、ストレージ管理の併用が有効。 東京S3から都度クロスリージョン取得する場合 : モデル保管料は東京の1バケット分で済む一方、海外リージョン起動時にクロスリージョン転送料(1回約220円)とネットワーク遅延が発生。 本システムでは、頻繁に利用する主要リージョンにはS3バケットを準備し、必要に応じて初期同期しておく構成を推奨しています。 3. ゼロインバウンドSG & 最小権限IAM # 第4回で解説した「インバウンドルール0件のセキュリティグループ」と、EC2およびLambdaに必要なIAMロールを一括で定義します。 セキュリティグループ(全リージョンへの一括プロビジョニング) # Terraformのマルチプロバイダ機能( providers = { aws = ... } )やモジュールループを活用します。これにより、全リージョンへインバウンド0件のセキュリティグループを展開できます。 security_groups.tf(クリックで展開) # 各リージョン向けのセキュリティグループ(インバウンドルールなし、アウトバウンド全開放) resource "aws_security_group" "vllm_sg" { name = "vllm-ssm-isolated-sg" description = "Zero-inbound security group for vLLM SSM port forwarding" vpc_id = data.aws_vpc.default.id # インバウンドルールは定義しない(完全閉塞) egress { description = "Allow all outbound traffic for SSM Agent and S3/ECR pull" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "vllm-ssm-isolated-sg" } } IAMロール(EC2用 & Lambda用) # EC2インスタンスにはSSM通信・S3読み取り・ECR読み取りに必要な最小権限を付与し、Lambda関数にはマルチリージョンのEC2探索・起動に必要な権限を付与します。 iam.tf(クリックで展開) # EC2インスタンス用 IAMロール resource "aws_iam_role" "ec2_role" { name = "vllm-ssm-instance-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } # SSMエージェント通信用ポリシー resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # S3 & ECR 読み取りポリシー resource "aws_iam_role_policy_attachment" "s3_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" } resource "aws_iam_role_policy_attachment" "ecr_read" { role = aws_iam_role.ec2_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" } # EC2インスタンスプロファイル resource "aws_iam_instance_profile" "ec2_profile" { name = "vllm-ssm-instance-profile" role = aws_iam_role.ec2_role.name } # Lambda GPUブローカー用 IAMロール resource "aws_iam_role" "lambda_role" { name = "vllm-spot-broker-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "lambda_basic" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } # マルチリージョンでのスポット探索・EC2起動権限 resource "aws_iam_role_policy" "lambda_ec2_broker" { name = "VLLMBrokerEC2Policy" role = aws_iam_role.lambda_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:DescribeSpotPriceHistory", "ec2:DescribeSecurityGroups", "ec2:DescribeImages", "ec2:RunInstances", "ec2:CreateTags", "ssm:GetParameter", "iam:PassRole" ] Resource = "*" } ] }) } --> Information 💡 第4回・第5回のスクリプト環境から移行する場合の注意点 第4回や第5回の環境構築スクリプトを実行済みのAWS環境でTerraformを実行する場合に注意が必要です。同名のリソースが存在すると競合エラー( EntityAlreadyExistsException 等)が発生します。その場合は terraform import で取り込むか、既存リソースを削除(または名前を変更)してから terraform apply を実行してください。 4. EC2起動テンプレート(Launch Template)とUserDataのテンプレート化 # 📌 このセクションの要点 第5回でS3へ切り離したUserData( startup.sh )をさらに一歩進めます。インスタンスタイプやEBS、IAMプロファイル、閉塞SG、UserDataブートストラップを EC2起動テンプレート(Launch Template) として一元管理します。これにより、Lambdaブローカーからハードウェア依存の記述を完全に切り離せます。 第5回では、110行を超えるUserDataスクリプトをS3バケットへ配置しました。EC2側にはS3から取得して実行する3行のブートストラップスクリプトを渡す構成としました。これにより、Lambdaコードの肥大化とUserDataの16KB上限問題を回避しました。 しかし、もう1つの課題が残っていました。 インスタンス仕様がLambdaコード内にハードコードされている : インスタンスタイプ( g6.xlarge )、EBSボリューム(40GB gp3、削除保護オフ)、IAMインスタンスプロファイル、セキュリティグループ、スポット市場オプションなどのハードウェア構成が、依然としてLambdaのPythonコード内の辞書として記述されていました。 インスタンスのハードウェア構成やブートストラップ手順は、本来「静的なインフラ仕様」としてIaCで管理すべき領域です。 そこで本構成では、 aws_launch_template (EC2起動テンプレート) を導入します。これにより、ハードウェア定義とUserDataブートストラップを一括コード化します。 1. UserDataブートストラップのテンプレート( userdata_bootstrap.sh ) # Terraformの templatefile 関数で動的パラメータ(リージョン、S3バケット名、ECRイメージ名)を注入できる極小シェルスクリプトを用意します。なお、Bash変数( $LOG_FILE 等)は波括弧を付けずに記述することで、Terraformテンプレート展開時の変数名衝突を綺麗に回避しています。 templates/userdata_bootstrap.sh(クリックで展開) #!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-bootstrap.log" exec > >(tee -a "$LOG_FILE" | logger -t user-data -s 2>/dev/console) 2>&1 AWS_REGION="${AWS_REGION}" S3_BUCKET="${S3_BUCKET_NAME}" DOCKER_IMAGE="${DOCKER_IMAGE}" echo "=== S3から起動スクリプト (startup.sh) を取得 ===" aws s3 cp "s3://$S3_BUCKET/scripts/startup.sh" /tmp/startup.sh --region "$AWS_REGION" chmod +x /tmp/startup.sh echo "=== startup.sh 実行 ===" /tmp/startup.sh "$AWS_REGION" "$S3_BUCKET" "$DOCKER_IMAGE" 2. 全リージョンへの起動テンプレート一括プロビジョニング( launch_template.tf ) # EC2 Launch Templateは リージョン固有 のリソースです。東京だけに配置すると、海外リージョンでの起動時にテンプレートが見つかりません。そのため、Terraformを使って全候補リージョンへ同一名( vllm-spot-template )で一括展開します。 これにより、Lambdaブローカーがリアルタイム探索でどのリージョンを引き当てた場合でも、フォールバックなしに起動テンプレートから直接インスタンスを生成できます。 launch_template.tf(クリックで展開) # terraform/launch_template.tf # ============================================================================== # EC2 起動テンプレート(Launch Template)全候補リージョン一括プロビジョニング # EBS(40GB gp3)、IAMプロファイル、セキュリティグループ、UserData(S3ブートストラップ) # を全リージョンで共通のテンプレート名「vllm-spot-template」としてコード化する # ============================================================================== # 1. 東京リージョン (ap-northeast-1) resource "aws_launch_template" "lt_ap_northeast_1" { name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [ap-northeast-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_ap_northeast_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "ap-northeast-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-ap-northeast-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 2. バージニア北部リージョン (us-east-1) resource "aws_launch_template" "lt_us_east_1" { provider = aws.us_east_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 3. オハイオリージョン (us-east-2) resource "aws_launch_template" "lt_us_east_2" { provider = aws.us_east_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-east-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_east_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-east-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-east-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 4. オレゴンリージョン (us-west-2) resource "aws_launch_template" "lt_us_west_2" { provider = aws.us_west_2 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [us-west-2]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_us_west_2.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "us-west-2" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-us-west-2-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-west-2.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } # 5. フランクフルトリージョン (eu-central-1) resource "aws_launch_template" "lt_eu_central_1" { provider = aws.eu_central_1 name = "vllm-spot-template" description = "vLLM Ephemeral Spot Node (g6.xlarge, NVIDIA L4 24GB) [eu-central-1]" instance_type = var.instance_type iam_instance_profile { name = aws_iam_instance_profile.ec2_profile.name } vpc_security_group_ids = [ aws_security_group.sg_eu_central_1.id ] block_device_mappings { device_name = "/dev/sda1" ebs { volume_size = 40 volume_type = "gp3" delete_on_termination = true } } instance_market_options { market_type = "spot" spot_options { spot_instance_type = "one-time" instance_interruption_behavior = "terminate" } } user_data = base64encode(templatefile("${path.module}/templates/userdata_bootstrap.sh", { AWS_REGION = "eu-central-1" S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-${data.aws_caller_identity.current.account_id}-eu-central-1-an" DOCKER_IMAGE = "${data.aws_caller_identity.current.account_id}.dkr.ecr.eu-central-1.amazonaws.com/${var.ecr_repository_name}:latest" })) tag_specifications { resource_type = "instance" tags = { Name = "vllm-ssm-spot-node" Project = "vllm-ephemeral" MarketType = "spot" } } } output "launch_template_ids" { value = { "ap-northeast-1" = aws_launch_template.lt_ap_northeast_1.id "us-east-1" = aws_launch_template.lt_us_east_1.id "us-east-2" = aws_launch_template.lt_us_east_2.id "us-west-2" = aws_launch_template.lt_us_west_2.id "eu-central-1" = aws_launch_template.lt_eu_central_1.id } description = "EC2 Launch Template IDs across all target regions" } output "launch_template_name" { value = "vllm-spot-template" description = "Common EC2 Launch Template Name across all target regions" } 起動テンプレートを全候補リージョンで同名( vllm-spot-template )としてTerraformで管理します。これにより、ハードウェア仕様の変更時もコードを修正して terraform apply するだけで、全リージョンへ一括反映されます。 5. Lambda関数の自動パッケージング & デプロイ # 第5回で紹介したGPUブローカーのPythonスクリプト( handler.py )をデプロイします。Terraform標準の archive_file データソースを使い、自動でZIP化して展開します。 起動テンプレートの導入に伴い、Lambda関数の環境変数には LAUNCH_TEMPLATE_NAME を渡します。 lambda.tf(クリックで展開) # Pythonコードの自動ZIPパッケージング data "archive_file" "lambda_zip" { type = "zip" source_dir = "${path.module}/../lambda" output_path = "${path.module}/dist/lambda_function.zip" } # GPU起動ブローカー Lambda関数 resource "aws_lambda_function" "broker" { function_name = "vllm-spot-broker" runtime = "python3.12" handler = "handler.lambda_handler" role = aws_iam_role.lambda_role.arn timeout = 120 memory_size = 256 filename = data.archive_file.lambda_zip.output_path source_code_hash = data.archive_file.lambda_zip.output_base64sha256 environment { variables = { TARGET_REGIONS = join(",", var.target_regions) S3_BUCKET_NAME = "my-vllm-models-hackathon-2026-{account_id}-{region}-an" INSTANCE_PROFILE_NAME = aws_iam_instance_profile.ec2_profile.name ECR_REPOSITORY_NAME = aws_ecr_repository.vllm.name TAG_SERVER_NAME = "vllm-ssm-spot-node" SG_NAME = "vllm-ssm-isolated-sg" LAUNCH_TEMPLATE_NAME = aws_launch_template.vllm_spot_template.name } } tags = { Project = "vllm-ephemeral" } } Lambdaハンドラー側での呼び出し(起動処理の簡潔化) # 起動テンプレートが整ったことで、Lambda関数( handler.py )のインスタンス起動処理は極めて簡潔になります。 # Launch Template を指定して1行で起動 run_params = { "MinCount": 1, "MaxCount": 1, "LaunchTemplate": { "LaunchTemplateName": os.environ.get("LAUNCH_TEMPLATE_NAME"), "Version": "$Latest", }, } if target_ami: run_params["ImageId"] = target_ami ec2.run_instances(**run_params) 長大な BlockDeviceMappings や UserData などをPython側で構築する必要はありません。Lambdaの責務は「最適リージョンのリアルタイム探索と、テンプレートによるエフェメラル起動」のみに集中できます。 6. Terraformの実行(一括プロビジョニングと環境破棄) # ディレクトリ構成を整えたら、コマンド実行で全リソースをプロビジョニングできます。 cd terraform/ # 1. プロバイダ初期化 terraform init # 2. 静的基盤の全展開(S3・ECR・IAM・SG・Lambda) terraform apply -auto-approve Apply complete! Resources: 18 added, 0 changed, 0 destroyed. Outputs: broker_lambda_arn = "arn:aws:lambda:ap-northeast-1:123456789012:function:vllm-spot-broker" ecr_repository_url = "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/vllm-openai" これにより、わずか数十秒でマルチリージョン展開に必要な基盤のすべてが整います。 日々のGPU起動・停止 # 静的基盤のデプロイ後は、日々のGPU起動・停止に第5回のスクリプト( 01_start_vllm_ssm.sh / 02_stop_vllm_ssm.sh )をそのまま使用します。 # GPUインスタンスの起動(Lambdaブローカー呼び出し & SSMトンネル確立) ./01_start_vllm_ssm.sh # 作業終了時のリソース破棄(SSM切断 & Lambda経由でのTerminate) ./02_stop_vllm_ssm.sh Terraform側でGPUインスタンス自体のライフサイクルは抱え込みません。動的な起動・停止をクライアントスクリプトに任せることで、 tfstate のロック待ちなしに迅速に推論環境を利用できます。 検証作業全体が終了し、クラウド上の静的基盤ごとすべてクリーンアップしたい場合のみ、 terraform destroy を実行します。 cd terraform/ terraform destroy -auto-approve --> 💡 発展:他ワークロードへの応用パターン 今回の検証ではvLLM(GPU推論)を題材としました。しかし、「Lambdaによるスポット探索・起動」と「SSMポートフォワーディング」の組み合わせは、他のワークロードにも幅広く応用可能です。 大規模コンパイル・ビルド環境 : 手元端末の負荷を軽減するため、多数のvCPUを持つコンピュート最適化インスタンス(例: c6i 系列)を必要な時間だけ立ち上げ、ビルド完了後に自動終了させる。 モデルのファインチューニングやバッチ学習 : 学習時のみ複数GPUインスタンスをスポット起動し、S3からデータセットを取得して学習し、生成された重みをS3へ保存した後にインスタンスを破棄する。 メモリ集約型のデータ処理 : ローカル環境ではメモリ不足になりやすい大規模データフレームの集計・分析時のみ、大容量メモリを搭載したインスタンス(例: r6i 系列)を一時的に利用する。 セキュアな一時開発環境(リモートDevbox) : インバウンドポートを開放せずにSSM経由で接続できるため、外部へのポート公開が制限されている環境でも、手元のVS Code等からセキュアに一時的な作業環境として利用できる。 「必要なタイミングだけクラウド上の適切なリソースを立ち上げ、完了後は確実に終了させる」というエフェメラルな運用は、コスト効率とセキュリティを両立させるアプローチとして幅広く活用できます。 まとめ # 本記事では、マルチリージョンにまたがるGPU推論基盤の静的リソースを Terraformで宣言的にコード化(IaC化) しました。 静的基盤と動的エフェメラルの役割分担 : 状態管理のオーバーヘッドを避け、静的基盤のみをTerraformに任せることで、軽快かつ堅牢なアーキテクチャを実現。 EC2起動テンプレート(Launch Template)の導入 : ハードウェア仕様や閉塞SG、UserDataブートストラップを一元的にコード化し、Lambda側からハードウェア依存コードを切り離し。 ECRクロスリージョン自動レプリケーション : 東京へ1回Pushするだけで全候補リージョンへAWSバックボーン経由で自動同期されるパイプラインを構築。 ゼロインバウンドSG & 最小権限IAM : 受信ルール0件のセキュリティグループやSSM/ECR/S3接続に必要なIAMポリシーを一括定義。 Lambda自動デプロイと環境破棄 : Pythonコードの自動ZIP化からプロビジョニング、そして検証終了時の terraform destroy による確実なクリーンアップ性を確立。 手動セットアップや場当たり的なスクリプトを排除し、コードで再現可能なインフラ基盤が整ったことで、日々のLLM検証をより安全・確実に運用できるようになりました。
はじめに # これは、思いついたアイデアや気になるクラウド技術を、まずは好奇心のままに手を動かして形にしてみる技術検証ログです。 前回の記事( EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ )では、AWS Systems Manager(SSM)Session Managerによるポートフォワーディングを導入し、セキュリティグループのインバウンドを完全に閉塞(ポート開放ゼロ)しながら、SSH鍵管理を完全撤廃したセキュアなGPU利用環境を構築しました。 しかし、第3回・第4回と機能を追加していく中で、 クライアント側(WSL / Mac等)の起動スクリプト(Bash)に新たな運用課題 が生じていました: 探索レイテンシーの遅さ(直列処理の限界) : BashスクリプトからAWS CLIを使って複数リージョンのスポット価格、AZ候補、Spot Advisor中断率を逐次(直列に)取得していたため、探索とスコアリングだけで15〜30秒近く待たされる。 スポット枯渇(全滅)時の対応 : 世界的なAIブームの影響で、時間帯によっては候補リージョンのスポットキャパシティが全滅( InsufficientInstanceCapacity )することがあります。その際、エラーでスクリプトが止まり、手動でオンデマンドインスタンスを立て直すなどの二度手間が発生していました。 ローカル環境の肥大化と保守性 : Bashスクリプト内に価格パース、中断率計算、JSON加工、AWKによるスコアリングロジックが詰め込まれ、シェルスクリプトのメンテナンス性が限界に達していました。 そこで今回は、「いっそクラウド側で世界中の最安スポットGPUを並列で高速に自動探索・起動してくれるサーバーレスAPIを作ってしまえば面白いのでは?」というアイデアを形にするため、これらの判定・探索ロジックをすべてクライアントから切り離し、 AWS Lambdaによる「サーバーレスGPUブローカー」 へと進化させます。 Pythonの concurrent.futures によるマルチリージョン・マルチAZの高速並列スキャン(1秒台で完了)と、 スポット枯渇時のスマートな「オンデマンド・フォールバック機能」 を実装した実践的な手法を紹介します。 --> Information 📚 関連する過去の検証記事 第1回(基本構成) : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回(Cline連携) : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回(スポット活用) : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する 第4回(SSMセキュア化) : EC2スポット×vLLMをSSMでセキュア化!ポート開放ゼロで繋ぐ サーバーレスGPUブローカーのアーキテクチャ # 📌 このセクションの要点 クライアントは探索・判定ロジックから解放され、「Lambdaを叩いて起動結果を受け取る」シンプルな構成に。Lambda側で全リージョンのBoto3並列クエリ・総合スコアリング・スポット起動試行・オンデマンドフォールバックを一手に引き受けます。 flowchart TD subgraph Client ["ローカル開発端末 (Bashスクリプト)"] CLI["./01_start_vllm_ssm.sh<br>(--on-demand / --strategy)"] Tunnel["SSM ポートフォワーディング<br>(localhost:8000 -> EC2:8000)"] end subgraph AWS_Mgmt ["AWS 管理基盤 (Lambda)"] Broker["vLLM Broker Lambda<br>(Python 3.12 / Boto3)"] Advisor["AWS Spot Advisor<br>(JSON 中断率)"] end subgraph Multi_Region ["マルチリージョン EC2 群"] direction LR R1["東京 (ap-northeast-1)"] R2["オレゴン (us-west-2)"] R3["バージニア (us-east-1)"] R4["オハイオ (us-east-2)"] R5["フランクフルト (eu-central-1)"] end CLI -->|"1. invoke (JSON)"| Broker Broker -.->|"並列価格・AZ取得 (1秒)"| Multi_Region Broker -.->|中断頻度取得| Advisor Broker -->|"2. スポット起動試行 (順次)"| Multi_Region Broker -- "スポット全滅時: エラー案内 or<br>オンデマンド(cost/latency)起動" --> Multi_Region Broker -->|"3. Instance ID & Region 返却"| CLI CLI -->|4. SSM ポートフォワード接続| Tunnel アーキテクチャの進化ポイント # 項目 第4回までの構成 (クライアントBash) 今回の構成 (Lambda ブローカー) メリット 探索速度 15〜30秒以上(CLIを直列実行) 約1〜2秒(Pythonスレッド並列) 待ち時間を大幅に短縮 クライアントの責務 価格・中断率の取得、探索計算、起動試行 Lambda呼び出しとSSMトンネル接続のみ 複雑な探索ロジックから解放 スポット枯渇時 エラーで中断、手動対応 明確なエラー案内 & オンデマンド切替 開発作業を止めずにリカバリ可能 通常起動戦略 なし 「コスト最安」または「レイテンシー最小」 業務の優先度に応じた柔軟な選択 スポット枯渇時のフォールバック設計 # スポットインスタンスは余剰キャパシティを利用するため、世界中でGPU需要がピークに達した時間帯には、全候補リージョンで InsufficientInstanceCapacity が返ってくる「スポット枯渇」が発生します。 本システムでは、以下の2段構えのフォールバックポリシーを採用しています: デフォルト(スポット起動時) : 候補リージョンすべてでスポット起動を試み、すべて在庫切れになった場合は即座にエラー ALL_SPOT_EXHAUSTED を返却。エラーメッセージには「通常起動オプション( --on-demand )を付与して再実行できる旨」を分かりやすく案内します。勝手にオンデマンド課金に切り替わって意図せぬ出費が発生する事故を未然に防ぎます。 オンデマンド指定時( --on-demand ) : どうしても今すぐ作業を進めたい場合、ユーザーは明示的に --on-demand フラグを指定して再実行します。その際、以下の2つの戦略( --strategy )を選択できます: cost (最安コスト重視・デフォルト) : 海外リージョンを含めた全リージョンのオンデマンド定価(ドル換算)を比較し、最も定価が安いリージョンで起動。 latency (低遅延重視) : 物理的に最も近い東京リージョン( ap-northeast-1 )を最優先で起動し、快適なタイピング・レスポンスを確保。 Lambda関数の実装 ( handler.py ) # Lambda関数のコアロジックです。外部ライブラリを必要とせず、標準ライブラリ( concurrent.futures , urllib.request )と boto3 だけで完結します。 --> Information 💡 工夫ポイント:UserDataのS3外出しと3行ブートストラップ 本実装では、初期化処理(NVMeマウント、Docker領域移動、並列同期、vLLM起動、アイドル自動停止)の長大なBashスクリプト本体を S3( s3://${S3_BUCKET}/scripts/startup.sh )へ外出し しています。 EC2に渡すUserDataは「S3からスクリプトを取得して実行するわずか3行」に留めることで、以下のメリットが得られます: Lambdaコードの大幅なスリム化 : 100行を超えるBashヒアドキュメントを排除でき、コード全体の可読性が向上。 高速な試行錯誤 : 起動スクリプトを微修正したいとき、Lambda関数を再ビルド・再デプロイすることなく、S3上のスクリプトを更新するだけで即時反映。 UserDataの16KB制限回避 : スクリプトがどれだけ肥大化しても、EC2のUserDataサイズ上限(16KB)に抵触しません。 handler.py(クリックで展開) """ vLLM GPU Spot Broker Lambda マルチリージョンのスポット探索、最適リージョン選定、Spot起動、Terminateを統括するサーバーレスブローカー """ import json import logging import urllib.request import concurrent.futures import boto3 from botocore.config import Config import os logger = logging.getLogger() logger.setLevel(logging.INFO) # 候補リージョンとインスタンスタイプ env_regions = os.environ.get("TARGET_REGIONS") CANDIDATE_REGIONS = [r.strip() for r in env_regions.split(",") if r.strip()] if env_regions else ["ap-northeast-1", "eu-central-1", "us-east-2", "us-east-1", "us-west-2"] INSTANCE_TYPE = os.environ.get("INSTANCE_TYPE", "g6.xlarge") IAM_ROLE_NAME = os.environ.get("INSTANCE_PROFILE_NAME", "vllm-ssm-instance-profile") TAG_SERVER_NAME = os.environ.get("TAG_SERVER_NAME", "vllm-ssm-spot-node") S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "vllm-models-storage") ECR_REPOSITORY_NAME = os.environ.get("ECR_REPOSITORY_NAME", "vllm-openai") # リージョン別 AMI ID キャッシュ (Ubuntu 22.04 Deep Learning AMI) AMI_MAPPING = { "ap-northeast-1": "ami-0152728adca68ed36", "eu-central-1": "ami-0efde0090dab8cacf", "us-east-2": "ami-032b37e4db407994d", "us-east-1": "ami-012ba162b9cd2729c", "us-west-2": "ami-0ca70308d230e8a6e", } # リージョン別 オンデマンド参考価格 ($/h: g6.xlarge) と東京からの目安RTT (ms) ON_DEMAND_METRICS = { "ap-northeast-1": {"price": 1.3412, "latency_ms": 15, "name": "東京 (最低遅延・国内)"}, "us-west-2": {"price": 0.9840, "latency_ms": 110, "name": "オレゴン (コスト最安・中遅延)"}, "us-east-2": {"price": 0.9840, "latency_ms": 150, "name": "オハイオ (コスト最安)"}, "us-east-1": {"price": 0.9840, "latency_ms": 170, "name": "バージニア (コスト最安)"}, "eu-central-1": {"price": 1.2024, "latency_ms": 230, "name": "フランクフルト"}, } # S3外出しブートストラップ UserData (本体は s3://.../scripts/startup.sh から高速取得) USERDATA_BOOTSTRAP = """#!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-bootstrap.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 AWS_REGION="__AWS_REGION__" S3_BUCKET="__S3_BUCKET_NAME__" DOCKER_IMAGE="__DOCKER_IMAGE__" echo "=== S3から起動スクリプト (startup.sh) を取得 ===" aws s3 cp "s3://${S3_BUCKET}/scripts/startup.sh" /tmp/startup.sh --region "${AWS_REGION}" chmod +x /tmp/startup.sh echo "=== startup.sh 実行 ===" /tmp/startup.sh "${AWS_REGION}" "${S3_BUCKET}" "${DOCKER_IMAGE}" """ def get_spot_advisor_data(): """Spot Advisor から中断頻度ランクを取得""" url = "https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json" tiers = {} try: req = urllib.request.Request(url, headers={"User-Agent": "Lambda-Broker"}) with urllib.request.urlopen(req, timeout=3) as res: adv = json.loads(res.read().decode("utf-8")).get("spot_advisor", {}) for r, info in adv.items(): tier = info.get("Linux", {}).get(INSTANCE_TYPE, {}).get("r", 4) tiers[r] = tier except Exception as e: logger.warning(f"Spot Advisor 取得失敗: {e}") return tiers def query_region_metrics(region, advisor_tiers): """単一リージョンのスポット価格・AZ数を取得""" boto_cfg = Config(connect_timeout=2, read_timeout=3, retries={"max_attempts": 1}) ec2 = boto3.client("ec2", region_name=region, config=boto_cfg) try: res = ec2.describe_spot_price_history( InstanceTypes=[INSTANCE_TYPE], ProductDescriptions=["Linux/UNIX"], MaxResults=10, ) prices = res.get("SpotPriceHistory", []) if not prices: return None # 最安値とAZ数 min_price = min(float(p["SpotPrice"]) for p in prices) azs = set(p["AvailabilityZone"] for p in prices) az_count = len(azs) tier = advisor_tiers.get(region, 4) return { "region": region, "min_price": min_price, "az_count": az_count, "tier": tier, } except Exception as e: logger.error(f"Error querying {region}: {e}") return None def select_best_region(): """全候補リージョンを並列評価して最適リージョンを算出""" advisor_tiers = get_spot_advisor_data() metrics = [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: futures = {executor.submit(query_region_metrics, r, advisor_tiers): r for r in CANDIDATE_REGIONS} for future in concurrent.futures.as_completed(futures): res = future.result() if res: metrics.append(res) if not metrics: raise RuntimeError("全リージョンでスポット価格を取得できませんでした") best_price = min(m["min_price"] for m in metrics) # スコア計算: 価格40 + 余剰AZ40 + 中断耐性20 best_item = None max_score = -1 for m in metrics: p_score = int(40.0 * (best_price / m["min_price"])) tier = m["tier"] i_score = {0: 20, 1: 15, 2: 10, 3: 5}.get(tier, 0) az = m["az_count"] if az >= 5: c_score = 40 elif az == 4: c_score = 32 elif az == 3: c_score = 24 elif az == 2: c_score = 16 else: c_score = 8 total_score = p_score + i_score + c_score m["score"] = total_score if total_score >= 75: m["rank"] = "Rank S" elif total_score >= 65: m["rank"] = "Rank A" elif total_score >= 50: m["rank"] = "Rank B" else: m["rank"] = "Rank C" if total_score > max_score: max_score = total_score best_item = m return best_item, metrics def get_or_create_ssm_security_group(ec2_client, region): """インバウンド完全遮断(受信0ルール)のSSM専用セキュリティグループ""" sg_name = "vllm-ssm-isolated-sg" try: res = ec2_client.describe_security_groups( Filters=[{"Name": "group-name", "Values": [sg_name]}] ) if res["SecurityGroups"]: return res["SecurityGroups"][0]["GroupId"] except Exception: pass # 作成(デフォルトVPC) vpcs = ec2_client.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"] vpc_id = vpcs[0]["VpcId"] if vpcs else ec2_client.describe_vpcs()["Vpcs"][0]["VpcId"] new_sg = ec2_client.create_security_group( GroupName=sg_name, Description="Zero inbound ports allowed - access exclusively via AWS Systems Manager (SSM)", VpcId=vpc_id, ) # インバウンドルールは一切追加しない(完全閉塞) return new_sg["GroupId"] def get_latest_dlami(ec2_client): """最新のUbuntu 22.04 Deep Learning AMI IDを動的取得""" try: res = ec2_client.describe_images( Owners=["amazon"], Filters=[{"Name": "name", "Values": ["Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.* (Ubuntu 22.04)*"]}], ) images = sorted(res.get("Images", []), key=lambda x: x["CreationDate"]) if images: return images[-1]["ImageId"] except Exception as e: logger.warning(f"Failed to query latest DLAMI: {e}") return None def select_on_demand_region(strategy="cost"): """通常起動(オンデマンド)時の最適リージョンを選定 - cost: オンデマンド単価が最も安いリージョン(同価格なら低遅延優先: us-west-2 > us-east-2 > us-east-1) - latency: 東京からの遅延が最小のリージョン(ap-northeast-1 優先) """ valid_regions = [r for r in CANDIDATE_REGIONS if r in ON_DEMAND_METRICS] if not valid_regions: valid_regions = CANDIDATE_REGIONS if strategy == "latency": return sorted(valid_regions, key=lambda r: ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999)) else: return sorted( valid_regions, key=lambda r: ( ON_DEMAND_METRICS.get(r, {}).get("price", 999), ON_DEMAND_METRICS.get(r, {}).get("latency_ms", 999), ), ) def launch_instance(region, account_id, is_spot=True): """指定リージョンでインスタンス(スポットまたは通常起動)を起動""" ec2 = boto3.client("ec2", region_name=region, config=Config(retries={"max_attempts": 1})) ami_id = AMI_MAPPING.get(region) if not ami_id: ami_id = get_latest_dlami(ec2) if not ami_id: raise ValueError(f"AMI ID not configured for region {region}") sg_id = get_or_create_ssm_security_group(ec2, region) s3_bucket = S3_BUCKET_NAME if not s3_bucket or s3_bucket == "vllm-models-storage" or "*" in s3_bucket: s3_bucket = f"my-vllm-models-hackathon-2026-{account_id}-{region}-an" elif "{account_id}" in s3_bucket or "{region}" in s3_bucket: s3_bucket = s3_bucket.replace("{account_id}", account_id).replace("{region}", region) docker_image = f"{account_id}.dkr.ecr.{region}.amazonaws.com/{ECR_REPOSITORY_NAME}:latest" # UserData 生成 (S3外出しブートストラップ) userdata_script = USERDATA_BOOTSTRAP.replace("__AWS_REGION__", region) userdata_script = userdata_script.replace("__S3_BUCKET_NAME__", s3_bucket) userdata_script = userdata_script.replace("__DOCKER_IMAGE__", docker_image) def do_run(target_ami): run_params = { "ImageId": target_ami, "InstanceType": INSTANCE_TYPE, "MinCount": 1, "MaxCount": 1, "IamInstanceProfile": {"Name": IAM_ROLE_NAME}, "SecurityGroupIds": [sg_id], "InstanceInitiatedShutdownBehavior": "terminate", "BlockDeviceMappings": [ { "DeviceName": "/dev/sda1", "Ebs": { "VolumeSize": 40, "VolumeType": "gp3", "DeleteOnTermination": True, }, } ], "UserData": userdata_script, "TagSpecifications": [ { "ResourceType": "instance", "Tags": [ {"Key": "Name", "Value": TAG_SERVER_NAME}, {"Key": "Project", "Value": "vllm-ephemeral"}, {"Key": "MarketType", "Value": "spot" if is_spot else "on-demand"}, ], } ], } if is_spot: run_params["InstanceMarketOptions"] = { "MarketType": "spot", "SpotOptions": { "SpotInstanceType": "one-time", "InstanceInterruptionBehavior": "terminate", }, } return ec2.run_instances(**run_params) try: run_res = do_run(ami_id) except Exception as e: if "InvalidAMIID" in str(e): logger.info(f"AMI {ami_id} invalid in {region}, querying latest dynamically...") dynamic_ami = get_latest_dlami(ec2) if dynamic_ami and dynamic_ami != ami_id: run_res = do_run(dynamic_ami) else: raise else: raise instance_id = run_res["Instances"][0]["InstanceId"] return instance_id def launch_spot_instance(best_region_info, account_id): """後方互換性エイリアス""" return launch_instance(best_region_info["region"], account_id, is_spot=True) def terminate_all_instances(): """全候補リージョンから稼働中の vLLM インスタンス(スポット・オンデマンド問わず)を検索して Terminate""" terminated = [] def check_and_term(region): ec2 = boto3.client("ec2", region_name=region) try: res = ec2.describe_instances( Filters=[ {"Name": "tag:Name", "Values": [TAG_SERVER_NAME]}, {"Name": "instance-state-name", "Values": ["pending", "running", "stopping"]}, ] ) ids = [i["InstanceId"] for r in res.get("Reservations", []) for i in r.get("Instances", [])] if ids: ec2.terminate_instances(InstanceIds=ids) return [{"region": region, "instance_id": i} for i in ids] except Exception as e: logger.error(f"Error terminating in {region}: {e}") return [] with concurrent.futures.ThreadPoolExecutor(max_workers=len(CANDIDATE_REGIONS)) as executor: results = executor.map(check_and_term, CANDIDATE_REGIONS) for r_list in results: terminated.extend(r_list) return terminated def terminate_all_spot_instances(): """後方互換性エイリアス""" return terminate_all_instances() def lambda_handler(event, context): """Lambda メインエントリーポイント""" action = event.get("action", "launch") mode = event.get("mode", "spot") # "spot" or "on-demand" if event.get("on_demand") is True or event.get("use_on_demand") is True: mode = "on-demand" strategy = event.get("strategy", "cost").lower() # "cost" or "latency" account_id = context.invoked_function_arn.split(":")[4] logger.info(f"Received action: {action}, mode: {mode}, strategy: {strategy} (Account: {account_id})") if action == "launch": if mode == "on-demand": # 通常起動(オンデマンド) candidates = select_on_demand_region(strategy) logger.info(f"On-Demand candidate order (strategy={strategy}): {candidates}") failed_attempts = [] last_error = None for region in candidates: info = ON_DEMAND_METRICS.get(region, {"price": 1.0, "latency_ms": 100, "name": region}) logger.info(f"Attempting On-Demand launch in {region} ({info.get('name')})...") try: instance_id = launch_instance(region, account_id, is_spot=False) logger.info(f"Successfully launched On-Demand instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "on-demand", "strategy": strategy, "region": region, "region_name": info.get("name"), "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "on_demand_price": info.get("price"), "latency_ms": info.get("latency_ms"), "fallback_attempts": failed_attempts, "note": f"通常起動(オンデマンド)で起動しました(優先戦略: {strategy})。", } except Exception as e: err_str = str(e) if "VcpuLimitExceeded" in err_str or "InstanceLimitExceeded" in err_str: reason = "オンデマンドGPUクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"On-Demand launch failed in {region} ({reason})") failed_attempts.append({"region": region, "reason": reason}) last_error = e continue return { "status": "error", "error_code": "ALL_ON_DEMAND_FAILED", "message": f"すべてのオンデマンド候補リージョンで起動に失敗しました。Last error: {last_error}", "fallback_attempts": failed_attempts, } else: # デフォルト: スポット起動 _, all_metrics = select_best_region() sorted_candidates = sorted(all_metrics, key=lambda x: x.get("score", 0), reverse=True) logger.info(f"Ranked spot candidates: {[c['region'] for c in sorted_candidates]}") failed_attempts = [] last_error = None for candidate in sorted_candidates: region = candidate["region"] logger.info(f"Attempting spot launch in {region} (Score: {candidate.get('score')}, Rank: {candidate.get('rank')})...") try: instance_id = launch_instance(region, account_id, is_spot=True) logger.info(f"Successfully launched spot instance {instance_id} in {region}") return { "status": "success", "action": "launch", "market_type": "spot", "region": region, "instance_id": instance_id, "instance_type": INSTANCE_TYPE, "spot_price": candidate.get("min_price"), "score": candidate.get("score"), "rank": candidate.get("rank"), "candidates": all_metrics, "fallback_attempts": failed_attempts, } except Exception as e: err_str = str(e) if "InsufficientInstanceCapacity" in err_str: reason = "スポット余剰キャパシティ不足 (在庫切れ)" elif "MaxSpotInstanceCountExceeded" in err_str: reason = "スポットクォータ上限到達" else: reason = f"{type(e).__name__}" logger.warning(f"Spot launch failed in {region} ({reason}), trying next candidate...") failed_attempts.append({ "region": region, "score": candidate.get("score"), "rank": candidate.get("rank"), "reason": reason, }) last_error = e continue # ★ 全リージョンでスポットが取得できなかった場合のエラーレスポンス return { "status": "error", "error_code": "ALL_SPOT_EXHAUSTED", "message": ( "すべての候補リージョンでスポットインスタンスの起動に失敗しました(キャパシティ不足等)。\n" "通常起動(オンデマンド)で起動する場合は、スクリプトに '--on-demand' オプションを指定してください。\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy cost # コスト最安(米オレゴン等: 約$0.98/h)\n" " 例: ./01_start_vllm_ssm.sh --on-demand --strategy latency # レイテンシー最小(東京: 約$1.34/h)" ), "fallback_attempts": failed_attempts, } elif action == "terminate": terminated = terminate_all_instances() return { "status": "success", "action": "terminate", "terminated_instances": terminated, "count": len(terminated), } elif action == "status": best_region_info, all_metrics = select_best_region() return { "status": "success", "action": "status", "recommended_region": best_region_info, "all_metrics": all_metrics, } else: return { "status": "error", "message": f"Unknown action: '{action}'. Must be 'launch', 'terminate', or 'status'.", } S3に配置する初期化スクリプト ( startup.sh ) # S3( s3://${S3_BUCKET}/scripts/startup.sh )に配置する初期化シェルスクリプト本体です。インスタンス起動時にブートストラップUserData経由でS3から自動ダウンロードされ、実行されます。 NVMeマウント、Docker領域の退避、S3モデル同期とDocker pullの並列処理、vLLM起動、およびアイドル自動停止デーモンの全処理がこのスクリプトにカプセル化されています。 startup.sh(クリックで展開) #!/bin/bash set -euo pipefail LOG_FILE="/var/log/userdata-vllm.log" exec > >(tee -a "${LOG_FILE}" | logger -t user-data -s 2>/dev/console) 2>&1 echo "=== [$(date '+%Y-%m-%d %H:%M:%S')] startup.sh 実行開始 (S3外出しブートストラップ版) ===" AWS_REGION="${1:-$(curl -s http://169.254.169.254/latest/meta-data/placement/region)}" S3_BUCKET_NAME="${2:-}" DOCKER_IMAGE="${3:-}" HF_MODEL_ID="Qwen/Qwen2.5-Coder-7B-Instruct" SERVED_MODEL_NAME="Qwen/Qwen2.5-Coder-7B-Instruct" VLLM_PORT="8000" GPU_MEMORY_UTILIZATION="0.90" MAX_MODEL_LEN="16384" # 1. ローカル NVMe インスタンスストア(250GB)の検出と活用 NVME_DIR="/opt/dlami/nvme" if mountpoint -q "${NVME_DIR}" || [ -d "${NVME_DIR}" ]; then echo "DLAMI既定の NVMe マウント (${NVME_DIR}) を活用します..." mkdir -p "${NVME_DIR}/models" "${NVME_DIR}/docker" mkdir -p /data ln -sfn "${NVME_DIR}/models" /data/models else NVME_DEV=$(lsblk -d -n -o NAME,SIZE | grep -E '250G|232G' | head -n1 | awk '{print $1}') if [ -n "${NVME_DEV}" ]; then mkfs.ext4 -F "/dev/${NVME_DEV}" || true mkdir -p /data mount -o noatime "/dev/${NVME_DEV}" /data || true else mkdir -p /data fi mkdir -p /data/models /data/docker NVME_DIR="/data" fi LOCAL_MODEL_ROOT="/data/models" LOCAL_MODEL_DIR="${LOCAL_MODEL_ROOT}/${HF_MODEL_ID}" mkdir -p "${LOCAL_MODEL_DIR}" chmod 777 "${LOCAL_MODEL_ROOT}" # 2. Docker データ領域を NVMe に配置 systemctl stop docker containerd || true mkdir -p "${NVME_DIR}/docker" "${NVME_DIR}/containerd" mkdir -p /var/lib/docker /var/lib/containerd cp -a /var/lib/docker/* "${NVME_DIR}/docker/" 2>/dev/null || true cp -a /var/lib/containerd/* "${NVME_DIR}/containerd/" 2>/dev/null || true mount --bind "${NVME_DIR}/docker" /var/lib/docker mount --bind "${NVME_DIR}/containerd" /var/lib/containerd systemctl daemon-reload systemctl start containerd systemctl start docker # 3. S3モデル同期 と Docker pull の並列実行 S3_SRC="s3://${S3_BUCKET_NAME}/models/${HF_MODEL_ID}" aws configure set default.s3.max_concurrent_requests 20 ( echo "--- [Task A] Dockerイメージ取得: ${DOCKER_IMAGE} ---" if [[ "${DOCKER_IMAGE}" == *".dkr.ecr."* ]]; then ECR_REGISTRY=$(echo "${DOCKER_IMAGE}" | cut -d'/' -f1) ECR_REGION=$(echo "${ECR_REGISTRY}" | awk -F. '{print $4}') aws ecr get-login-password --region "${ECR_REGION:-${AWS_REGION}}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" || true fi docker pull "${DOCKER_IMAGE}" ) > /var/log/docker-pull.log 2>&1 & PID_DOCKER=$! ( echo "--- [Task B] S3モデル高速同期 ---" aws s3 sync "${S3_SRC}" "${LOCAL_MODEL_DIR}" --region "${AWS_REGION}" --no-progress ) > /var/log/model-sync.log 2>&1 & PID_S3=$! wait "${PID_DOCKER}" wait "${PID_S3}" echo "=== 並列ダウンロード完了! ===" # 4. vLLMコンテナ起動 docker run -d \ --name "vllm-server" \ --restart unless-stopped \ --gpus all \ --ipc=host \ -p "${VLLM_PORT}:8000" \ -v "${LOCAL_MODEL_ROOT}:/models" \ "${DOCKER_IMAGE}" \ --model "/models/${HF_MODEL_ID}" \ --served-model-name "${SERVED_MODEL_NAME}" \ --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}" \ --max-model-len "${MAX_MODEL_LEN}" \ --trust-remote-code \ --enable-auto-tool-choice \ --tool-call-parser hermes # 5. アイドル1時間自動終了デーモン cat <<'EOF_IDLE' > /usr/local/bin/auto-idle-shutdown.sh #!/bin/bash IDLE_LIMIT=3600 IDLE_COUNT=0 while true; do sleep 300 REQ=$(docker logs --since 5m vllm-server 2>&1 | grep -c "POST /v1" || true) if [ "${REQ}" -eq 0 ]; then IDLE_COUNT=$((IDLE_COUNT + 300)) [ "${IDLE_COUNT}" -ge "${IDLE_LIMIT}" ] && { shutdown -h now; exit 0; } else IDLE_COUNT=0 fi done EOF_IDLE chmod +x /usr/local/bin/auto-idle-shutdown.sh nohup /usr/local/bin/auto-idle-shutdown.sh > /dev/null 2>&1 & echo "=== startup.sh 完了 (SSM待機中) ===" Lambdaのデプロイスクリプト ( 00_deploy_lambda.sh ) # 作成した handler.py をZIPアーカイブ化してAWS Lambdaにデプロイし、同時に startup.sh をS3バケットへ自動同期するシェルスクリプトです。 00_deploy_lambda.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 00_deploy_lambda.sh # GPU起動ブローカーLambda関数およびIAM実行ロールを自動作成・更新するスクリプト # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LAMBDA_DIR="$(cd "${SCRIPT_DIR}/../lambda" && pwd)" FUNCTION_NAME="vllm-spot-broker" ROLE_NAME="vllm-spot-broker-lambda-role" REGION="ap-northeast-1" echo "=== 1. Lambda 実行ロール (${ROLE_NAME}) の作成/確認 ===" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) TRUST_POLICY='{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }' if ! aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then echo "IAMロールを作成中..." aws iam create-role \ --role-name "${ROLE_NAME}" \ --assume-role-policy-document "${TRUST_POLICY}" \ --description "Execution role for vLLM Spot Broker Lambda" >/dev/null # 基本ログ出力権限 aws iam attach-role-policy \ --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" # EC2 & IAM PassRole 権限インラインポリシー BROKER_POLICY="{ \"Version\": \"2012-10-17\", \"Statement\": [ { \"Effect\": \"Allow\", \"Action\": [ \"ec2:Describe*\", \"ec2:RunInstances\", \"ec2:TerminateInstances\", \"ec2:CreateSecurityGroup\", \"ec2:CreateTags\" ], \"Resource\": \"*\" }, { \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": [ \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/vllm-ssm-instance-role\", \"arn:aws:iam::${AWS_ACCOUNT_ID}:role/VllmServerSSMRole\" ] } ] }" aws iam put-role-policy \ --role-name "${ROLE_NAME}" \ --policy-name "VllmBrokerPermissions" \ --policy-document "${BROKER_POLICY}" echo "IAMロール作成完了。権限の伝播を少し待機中..." sleep 10 else echo "既存のIAMロールを使用します: ${ROLE_NAME}" fi ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${ROLE_NAME}" echo "=== 2. Lambda コードのパッケージング ===" TMP_ZIP=$(mktemp --suffix=.zip) rm -f "${TMP_ZIP}" (cd "${LAMBDA_DIR}" && zip -q -r "${TMP_ZIP}" handler.py) echo "=== 2.5 起動スクリプト (startup.sh) を S3 へ同期 ===" S3_BUCKET="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" if aws s3 ls "s3://${S3_BUCKET}" >/dev/null 2>&1; then echo "startup.sh を s3://${S3_BUCKET}/scripts/startup.sh へアップロード中..." aws s3 cp "${SCRIPT_DIR}/startup.sh" "s3://${S3_BUCKET}/scripts/startup.sh" --region "${REGION}" fi echo "=== 3. Lambda 関数 (${FUNCTION_NAME}) の作成/更新 ===" if aws lambda get-function --region "${REGION}" --function-name "${FUNCTION_NAME}" >/dev/null 2>&1; then echo "既存のLambda関数コードを更新中..." aws lambda update-function-code \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --zip-file "fileb://${TMP_ZIP}" >/dev/null else echo "新しいLambda関数を作成中..." aws lambda create-function \ --region "${REGION}" \ --function-name "${FUNCTION_NAME}" \ --runtime "python3.12" \ --role "${ROLE_ARN}" \ --handler "handler.lambda_handler" \ --zip-file "fileb://${TMP_ZIP}" \ --timeout 60 \ --memory-size 256 \ --description "Multi-region spot instance broker for vLLM" >/dev/null fi rm -f "${TMP_ZIP}" echo "=== デプロイ完了! ===" echo "Lambda 関数名: ${FUNCTION_NAME} (${REGION})" 探索ロジックを手放したクライアント側スクリプト ( 01_start_vllm_ssm.sh ) # ローカルPC側の起動スクリプトは、複雑なマルチリージョン計算や中断率データの取得をすべてクラウド側へ手放し、 「Lambdaを呼び出して起動されたインスタンス情報を受け取り、SSMポートフォワーディングを張る」 というローカル環境の接続・トンネル管理に専念する構成へと進化します。 01_start_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 01_start_vllm_ssm.sh # Lambdaブローカーを呼び出して最安スポットを起動し、SSMポートフォワーディングで安全に直結する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # オプション解析 USE_ON_DEMAND="false" STRATEGY="cost" while [[ $# -gt 0 ]]; do case "$1" in --on-demand) USE_ON_DEMAND="true" shift ;; --strategy) STRATEGY="${2:-cost}" shift 2 ;; -h|--help) echo "使用方法: $0 [オプション]" echo "" echo "オプション:" echo " --on-demand 通常起動(オンデマンド)で起動する(デフォルトはスポット起動)" echo " --strategy <cost|latency> 通常起動時の優先戦略(デフォルト: cost)" echo " cost : オンデマンド単価が最も安いリージョンを選択(米オレゴン等: 約\$0.98/h)" echo " latency : レイテンシーが最も小さいリージョンを選択(東京: 約\$1.34/h)" echo " -h, --help ヘルプを表示" exit 0 ;; *) echo "不明なオプション: $1" echo "ヘルプ: $0 --help" exit 1 ;; esac done TMP_RES=$(mktemp) if [ "${USE_ON_DEMAND}" = "true" ]; then echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [通常起動 / 戦略: ${STRATEGY}]... ===" PAYLOAD=$(jq -n --arg mode "on-demand" --arg strategy "${STRATEGY}" '{"action":"launch","mode":$mode,"strategy":$strategy}') else echo "=== 1. サーバーレスGPUブローカー(Lambda)呼び出し [スポット探索 & 起動]... ===" PAYLOAD='{"action":"launch","mode":"spot"}' fi aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload "${PAYLOAD}" \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}") if [ "${STATUS}" != "success" ]; then ERROR_CODE=$(jq -r '.error_code // ""' "${TMP_RES}") MESSAGE=$(jq -r '.message // ""' "${TMP_RES}") echo "==========================================================" if [ "${ERROR_CODE}" = "ALL_SPOT_EXHAUSTED" ]; then echo "❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。" echo "----------------------------------------------------------" echo "【試行した候補リージョンと結果】" jq -r '.fallback_attempts[]? | " - \(.region) (\(.score // "-")点 / \(.rank // "-")): \(.reason)"' "${TMP_RES}" echo "" echo "💡 通常起動(オンデマンド)であれば確実に起動可能です。" echo " 以下のコマンドで再実行してください:" echo "" echo " # コスト最安リージョン(米オレゴン等: 約\$0.98/h)で通常起動:" echo " $0 --on-demand --strategy cost" echo "" echo " # レイテンシー最小リージョン(東京: 約\$1.34/h)で通常起動:" echo " $0 --on-demand --strategy latency" else echo "エラー: Lambdaの起動リクエストが失敗しました。" echo "${MESSAGE}" cat "${TMP_RES}" fi echo "==========================================================" rm -f "${TMP_RES}" exit 1 fi MARKET_TYPE=$(jq -r '.market_type // "spot"' "${TMP_RES}") INSTANCE_ID=$(jq -r '.instance_id' "${TMP_RES}") SELECTED_REGION=$(jq -r '.region' "${TMP_RES}") echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${SELECTED_REGION}" > "${REGION_STATE_FILE}" echo "==========================================================" if [ "${MARKET_TYPE}" = "on-demand" ]; then STRAT=$(jq -r '.strategy // "cost"' "${TMP_RES}") PRICE=$(jq -r '.on_demand_price // "-"' "${TMP_RES}") LATENCY=$(jq -r '.latency_ms // "-"' "${TMP_RES}") REG_NAME=$(jq -r '.region_name // ""' "${TMP_RES}") echo " ⚡ 通常起動(オンデマンド)完了: 戦略 = ${STRAT}" echo " 選定リージョン : ${SELECTED_REGION} (${REG_NAME})" echo " インスタンスID : ${INSTANCE_ID}" echo " オンデマンド価格: \$${PRICE}/h" echo " 目安レイテンシー: 約 ${LATENCY} ms" else PRICE=$(jq -r '.spot_price // "-"' "${TMP_RES}") SCORE=$(jq -r '.score // "-"' "${TMP_RES}") RANK=$(jq -r '.rank // "-"' "${TMP_RES}") echo " 🏆 最適スポットリージョン選定完了: ${SELECTED_REGION} (${RANK} / ${SCORE}点)" echo " インスタンスID: ${INSTANCE_ID}" echo " スポット価格 : \$${PRICE}/h" FALLBACK_COUNT=$(jq -r '.fallback_attempts | length // 0' "${TMP_RES}") if [ "${FALLBACK_COUNT}" -gt 0 ]; then echo " ⚠️ 上位候補リージョンで一時的なスポット在庫不足が発生したため、自動フォールバックしました:" jq -r '.fallback_attempts[] | " - \(.region) (\(.score)点 / \(.rank)): \(.reason)"' "${TMP_RES}" fi fi echo "==========================================================" rm -f "${TMP_RES}" # インスタンスの実行状態(running)待機 echo "インスタンスが running 状態になるのを待機中..." aws ec2 wait instance-running --region "${SELECTED_REGION}" --instance-ids "${INSTANCE_ID}" # Git Bash環境向けにSessionManagerPluginのパスを追加 if [ -d "/c/Program Files/Amazon/SessionManagerPlugin/bin" ]; then export PATH="${PATH}:/c/Program Files/Amazon/SessionManagerPlugin/bin" fi # --- 2. SSMエージェントのオンライン待機 --- echo "=== 2. SSMエージェントのオンライン待機 ===" echo "EC2インスタンス内部のSSM Agentが接続されるのを待機中..." SSM_OK=false for i in {1..30}; do STATUS=$(aws ssm describe-instance-information \ --region "${SELECTED_REGION}" \ --filters "Key=InstanceIds,Values=${INSTANCE_ID}" \ --query "InstanceInformationList[0].PingStatus" \ --output text 2>/dev/null || true) if [ "${STATUS}" = "Online" ]; then echo " SSM Agent が Online になりました! (${i}回目の試行)" SSM_OK=true break fi echo -n "." sleep 3 done echo "" if [ "${SSM_OK}" != "true" ]; then echo "エラー: SSM Agent が Online になりませんでした。IAMロールやアウトバウンド通信を確認してください。" exit 1 fi # --- 3. サーバー側での vLLM 起動待機 (S3モデルDL・コンテナ起動) --- echo "=== 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) ===" echo "UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)..." SERVER_READY=false for i in {1..90}; do CMD_ID=$(aws ssm send-command \ --region "${SELECTED_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --document-name "AWS-RunShellScript" \ --parameters 'commands=["curl -s -m 2 http://127.0.0.1:8000/health >/dev/null && echo OK || echo WAITING"]' \ --query "Command.CommandId" \ --output text 2>/dev/null || true) if [ -n "${CMD_ID}" ]; then sleep 2 RES=$(aws ssm get-command-invocation \ --region "${SELECTED_REGION}" \ --command-id "${CMD_ID}" \ --instance-id "${INSTANCE_ID}" \ --query "StandardOutputContent" \ --output text 2>/dev/null || true) if [[ "${RES}" =~ "OK" ]]; then echo " サーバー側の vLLM が正常に応答しました!" SERVER_READY=true break fi fi echo -n "." sleep 5 done echo "" if [ "${SERVER_READY}" != "true" ]; then echo "警告: サーバー側のvLLM起動待機がタイムアウトしました。UserDataログを確認してください。" fi # --- 4. 既存トンネルの整理と新規SSMポートフォワーディング確立 --- echo "=== 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) ===" if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${OLD_PID}" ] && ps -p "${OLD_PID}" > /dev/null 2>&1; then echo "古いポートフォワードプロセス (PID: ${OLD_PID}) を終了します..." kill -9 "${OLD_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # バックグラウンドでSSMポートフォワーディングを開始(ログを .current_tunnel.log に出力) nohup aws ssm start-session \ --region "${SELECTED_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' \ > "${TUNNEL_LOG}" 2>&1 & TUNNEL_PID=$! echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" echo "SSMポートフォワーディングを開始しました (PID: ${TUNNEL_PID})" sleep 2 # --- 5. ローカル疎通確認 --- echo "=== 5. ローカル疎通確認 (http://localhost:8000/health) ===" LOCAL_OK=false for i in {1..10}; do if curl -s -m 3 "http://localhost:8000/health" > /dev/null 2>&1; then echo " ローカルからの直結疎通を確認しました! (HTTP 200 OK)" LOCAL_OK=true break fi echo -n "." sleep 2 done echo "" if [ "${LOCAL_OK}" != "true" ]; then echo "警告: ローカル接続に失敗しました。トンネルログを確認してください:" cat "${TUNNEL_LOG}" 2>/dev/null || true fi echo "==========================================================" echo " サーバーレスGPUブローカーによる直結完了!" echo " EC2 Instance ID : ${INSTANCE_ID} (${MARKET_TYPE}, Region: ${SELECTED_REGION})" echo " セキュリティ : インバウンドルール 0件(完全閉塞)" echo " トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化)" echo " Web UI / API : http://localhost:8000/v1" echo "" echo " VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1)" echo " 検証終了時は以下のコマンドでリソースを破棄してください:" echo " ./02_stop_vllm_ssm.sh" echo "==========================================================" 実際の動作パターンと検証 # 1. 通常のスポット起動(1秒台で最適リージョンを選定・起動要求) # 引数なしで実行すると、Lambdaが全リージョンを並列評価し、最安・低中断率のスポットをわずか1秒台で引き当てます。 $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== 🏆 最適スポットリージョン選定完了: us-west-2 (Rank A / 68点) インスタンスID: i-087654321fedcba09 スポット価格 : $0.4264/h ========================================================== インスタンスが running 状態になるのを待機中... === 2. SSMエージェントのオンライン待機 === EC2インスタンス内部のSSM Agentが接続されるのを待機中... . SSM Agent が Online になりました! (1回目の試行) === 3. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... ................................ サーバー側の vLLM が正常に応答しました! === 4. SSMポートフォワーディングの確立 (ポート開放不要トンネル) === SSMポートフォワーディングを開始しました (PID: 53120) === 5. ローカル疎通確認 (http://localhost:8000/health) === . ローカルからの直結疎通を確認しました! (HTTP 200 OK) ========================================================== サーバーレスGPUブローカーによる直結完了! EC2 Instance ID : i-087654321fedcba09 (spot, Region: us-west-2) セキュリティ : インバウンドルール 0件(完全閉塞) トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化) Web UI / API : http://localhost:8000/v1 VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1) 検証終了時は以下のコマンドでリソースを破棄してください: ./02_stop_vllm_ssm.sh ========================================================== 2. スポット枯渇時(フォールバック案内) # 世界的にGPUキャパシティが逼迫している時間帯では、Lambdaがすべての候補リージョンを試みた上でスマートにフォールバック案内を返します: $ ./01_start_vllm_ssm.sh === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [スポット起動]... === ========================================================== ❌ スポット在庫切れ: 全候補リージョンでスポットインスタンスを調達できませんでした。 ---------------------------------------------------------- 【試行した候補リージョンと結果】 - us-west-2 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - us-east-2 (74点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - eu-central-1 (78点 / Rank S): スポット余剰キャパシティ不足 (在庫切れ) - us-east-1 (68点 / Rank A): スポット余剰キャパシティ不足 (在庫切れ) - ap-northeast-1 (43点 / Rank C): スポット余剰キャパシティ不足 (在庫切れ) 💡 通常起動(オンデマンド)であれば確実に起動可能です。 以下のコマンドで再実行してください: # コスト最安リージョン(米オレゴン等: 約$0.98/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy cost # レイテンシー最小リージョン(東京: 約$1.34/h)で通常起動: ./01_start_vllm_ssm.sh --on-demand --strategy latency ========================================================== 3. オンデマンド起動への切り替え # 今すぐ作業を進めたい場合は、案内通りに --on-demand を付与して再実行します。 最安コスト重視( --strategy cost ): $ ./01_start_vllm_ssm.sh --on-demand --strategy cost === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: cost]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = cost 選定リージョン : us-west-2 (オレゴン (コスト最安・中遅延)) インスタンスID : i-01122334455667788 オンデマンド価格: $0.9840/h 目安レイテンシー: 約 110 ms ========================================================== ... 低遅延重視( --strategy latency ): $ ./01_start_vllm_ssm.sh --on-demand --strategy latency === 1. サーバーレスGPUブローカー(Lambda)を呼び出し中 [通常起動 / 戦略: latency]... === ========================================================== ⚡ 通常起動(オンデマンド)完了: 戦略 = latency 選定リージョン : ap-northeast-1 (東京 (最低遅延・国内)) インスタンスID : i-09988776655443322 オンデマンド価格: $1.3412/h 目安レイテンシー: 約 15 ms ========================================================== ... 検証終了時のリソース破棄スクリプト ( 02_stop_vllm_ssm.sh ) # 作業が終了したら、破棄スクリプト( 02_stop_vllm_ssm.sh )を実行します。ローカルのSSMトンネルプロセスを安全に切断した上で、Lambdaブローカーに終了リクエストを発行し、EC2インスタンスおよびEBSボリュームを確実に破棄します。 02_stop_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_stop_vllm_ssm.sh # SSMポートフォワードプロセスを終了し、Lambda経由でGPUインスタンスを破棄する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" BROKER_REGION="ap-northeast-1" FUNCTION_NAME="vllm-spot-broker" # 1. SSMポートフォワードプロセスの終了 echo "=== 1. SSMポートフォワードプロセスの終了 ===" if [ -f "${TUNNEL_PID_FILE}" ]; then TUNNEL_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${TUNNEL_PID}" ] && ps -p "${TUNNEL_PID}" > /dev/null 2>&1; then echo "SSMポートフォワードプロセス (PID: ${TUNNEL_PID}) を終了中..." kill -9 "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # 2. Lambdaブローカーへのインスタンス破棄リクエスト echo "=== 2. サーバーレスGPUブローカー(Lambda)へリソース破棄リクエスト ===" TMP_RES=$(mktemp) aws lambda invoke \ --region "${BROKER_REGION}" \ --function-name "${FUNCTION_NAME}" \ --payload '{"action":"terminate"}' \ --cli-binary-format raw-in-base64-out \ "${TMP_RES}" >/dev/null STATUS=$(jq -r '.status // "error"' "${TMP_RES}" 2>/dev/null || echo "error") if [ "${STATUS}" = "success" ]; then COUNT=$(jq -r '.count // 0' "${TMP_RES}") echo "==========================================================" echo " 🛑 破棄完了! 稼働中のインスタンス ${COUNT} 台を Terminate しました。" jq -r '.terminated_instances[]? | " - [\(.region)] \(.instance_id)"' "${TMP_RES}" 2>/dev/null || true echo "==========================================================" else echo "警告: Lambda経由の破棄でエラーが返されました。ローカル情報による直接破棄を試みます..." AWS_REGION=$(cat "${REGION_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "ap-northeast-1") INSTANCE_ID=$(cat "${INSTANCE_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "") if [ -n "${INSTANCE_ID}" ]; then aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" >/dev/null 2>&1 || true echo "インスタンス ${INSTANCE_ID} (${AWS_REGION}) を直接Terminateしました。" fi fi rm -f "${TMP_RES}" "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" "${TUNNEL_LOG}" echo "スポットインスタンス、EBSボリューム、SSMトンネルの破棄が完了しました!" まとめ # 今回は、マルチリージョンのGPU調達ロジックを AWS LambdaによるサーバーレスGPUブローカー へと刷新しました。 並列探索による高速化 : Pythonのマルチスレッドにより、全リージョン・AZのスポット価格・中断率取得から判定までをわずか1〜2秒で完結。 クライアントの責務スリム化 : 複雑なマルチリージョン探索や中断率集計、フォールバックの重厚なロジックをクラウド側へ切り離し、ローカルスクリプトは接続とプロセス管理のみに専念。 耐障害性の向上(スマートなフォールバック) : スポット全滅時も勝手に高額課金せず、明確なエラー通知と再試行オプション(コスト最安 vs 低遅延)を提供。 クラウドの余剰リソースを徹底的に使い倒すための、極めて実用的で回復力の高いGPU基盤が完成しました。
はじめに # 前回の記事( EC2スポットインスタンス×vLLMでGPUコストを大幅削減する )では、時差・価格・中断耐性を加味した「マルチリージョン総合優先度自動選定」を導入し、 g6.xlarge (NVIDIA L4 24GB)を1時間あたり約80〜90円という格安価格でスポット調達する仕組みを構築しました。 これにより、高価なクラウドGPUを個人の手元から「使いたい時だけサッと起動し、終わったら即座に破棄する」エフェメラルな開発スタイルが確立できました。 しかし、第3回までの運用構成には、セキュリティおよび運用の観点で 看過できない2つの課題 が残っていました: ポート22(SSH)の開放リスク : インスタンスへのアクセスにSSHトンネル(ポート8000転送)を使用していたため、セキュリティグループでインバウンドのポート22を許可する必要がありました。IP制限をかけているとはいえ、自宅・オフィス・リモートワーク環境でグローバルIPが変わるたびにセキュリティグループの更新が必要になり、運用が煩雑でした。 SSH秘密鍵( .pem )の管理負荷 : 探索対象の複数リージョン(東京、オレゴン、バージニア等)それぞれにキーペアを事前作成し、ローカルに秘密鍵ファイルを安全に保持・管理する必要がありました。 そこで今回は、AWSのマネージドサービスである 「AWS Systems Manager (SSM) Session Manager」のポートフォワーディング機能 を導入します。 EC2セキュリティグループの インバウンドルールを完全に空(ポート開放ゼロ) にしたまま、ローカルPCのポート8000とEC2上のvLLM(ポート8000)を安全にトンネリングし、SSH秘密鍵の管理を完全に撤廃する手法を詳しく解説します。 --> Information 📚 関連する過去の検証記事 第1回(基本構成) : AWS×UserDataでvLLMを自動起動!停止時コストほぼゼロのローカルLLM環境 第2回(Cline連携) : 自前vLLMをOpen WebUI経由でVS Code(Cline)に接続!トークンフリーで開発する 第3回(スポット活用) : EC2スポットインスタンス×vLLMでGPUコストを大幅削減する なぜSSHからSSMポートフォワーディングへ移行するのか? # 📌 このセクションの要点 SSMポートフォワーディングを採用することで、「インバウンド全閉塞(ポート開放ゼロ)」と「SSH秘密鍵の完全撤廃」を同時に実現できます。接続の認可はAWS IAMで一元管理され、CloudTrailによる監査ログも自動で残ります。 1. ポート開放ゼロ(インバウンド許可一切不要) # 従来のSSH接続では、EC2のセキュリティグループでインバウンドポート22を許可する必要がありました。 しかし、SSM Session Managerは EC2インスタンス内部で稼働する「SSM Agent」が、AWSのSSMエンドポイントに対して外向き(アウトバウンド HTTPS/443)にWebSocketセッションを張りに行く アーキテクチャを採用しています。 flowchart LR subgraph Client ["ローカル開発端末 (WSL2 / Mac)"] direction TB Cline["VS Code (Cline)"] CLI["AWS CLI (Session Manager Plugin)<br>localhost:8000"] Cline -->|"http://localhost:8000"| CLI end subgraph AWS ["AWS バックボーン"] SSMEndpoint["SSM Service<br>(Session Manager)"] end subgraph EC2Host ["EC2 GPUインスタンス (g6.xlarge)"] direction TB SG["セキュリティグループ<br>【インバウンドルール: 0件 (完全閉塞)】"] Agent["SSM Agent"] VLLM["vLLM Dockerコンテナ<br>(:8000)"] Agent -->|localhost:8000| VLLM end CLI ===|"HTTPS / 443 (IAM認証)"| SSMEndpoint Agent ===|"HTTPS / 443 (アウトバウンド確立)"| SSMEndpoint これにより、EC2セキュリティグループのインバウンドルールは 1つも存在しない(インバウンド0件) 状態にできます。インターネット側からのポートスキャンやブルートフォース攻撃をネットワーク的に遮断し、インバウンドの攻撃面(アタックサーフェス)を最小化できます。 2. SSH秘密鍵( .pem )の完全撤廃 # 第3回のようにオレゴンやバージニアなど複数リージョンへインスタンスを立ち上げる場合、リージョンごとにキーペアを作成して手元のマシンに秘密鍵を保管しておく必要がありました。鍵のローテーションや紛失リスク、誤ってGitへコミットしてしまうリスクなど、鍵管理の心理的負荷は小さくありません。 SSMポートフォワーディングを利用する場合、認証・認可はすべて AWS IAM に統合されます。 開発者が手元の端末で aws configure や SSO(IAM Identity Center)で設定したAWS認証情報(アクセスキーやセッショントークン)を使ってセッションを開始するため、 SSH秘密鍵は1つも必要ありません 。 3. SSH接続とSSMポートフォワーディングの比較 # 項目 従来のSSHトンネル (第3回) SSMポートフォワーディング (今回) メリット インバウンドポート ポート 22 を開放必須 完全閉塞(ポート開放ゼロ) インターネットからのインバウンド直接侵入経路を排除 接続元IP制限 自宅/オフィスのIP変更時にSG修正が必要 不要 (IAM認証で保護) リモートワークでもIP変更作業がゼロに 鍵管理 リージョンごとの .pem 秘密鍵の保管・管理が必要 不要(鍵レス) 秘密鍵自体の保管・ローテーション管理が不要に 監査ログ SSH接続ログをサーバー内に収集する設定が必要 CloudTrail / CloudWatch に自動記録 いつ誰がセッションを開始したか証跡が残る マルチリージョン展開 リージョンごとにKeyName作成が必要 全リージョン共通で追加設定不要 スクリプトの可搬性と自動化が容易 前提条件とローカル環境の準備 # SSMポートフォワーディングを使用するには、ローカルマシンに session-manager-plugin をインストールしておく必要があります。 Session Manager Pluginのインストール # まだインストールしていない場合は、AWS公式ドキュメントに従ってインストールします。 Ubuntu / WSL2 の場合: curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "session-manager-plugin.deb" sudo dpkg -i session-manager-plugin.deb rm session-manager-plugin.deb macOS (Homebrew) の場合: brew install --cask session-manager-plugin Windowsの場合: winget install Amazon.SessionManagerPlugin インストール後、以下のコマンドでバージョン情報が表示されれば準備完了です: session-manager-plugin # 出力例: The Session Manager plugin is installed successfully. Use the AWS CLI to start a session. AWSリソースの事前セットアップ # SSM経由での接続を実現するために、各リージョンに以下の2つのリソースを準備します: IAMインスタンスプロファイル : EC2がSSMエージェントを通じてAWS SSMサービスと通信するためのIAMロール( AmazonSSMManagedInstanceCore ポリシーを含む)。 インバウンド全閉塞セキュリティグループ : インバウンドルールが0件で、アウトバウンドのみ許可されたセキュリティグループ( vllm-ssm-isolated-sg )。 これらを一括作成するセットアップスクリプトを用意しました。 00_setup_ssm_assets.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 00_setup_ssm_assets.sh # SSMポートフォワーディング用のIAMロールおよびインバウンド全閉塞SGを各リージョンに作成する # ============================================================================== ROLE_NAME="vllm-ssm-instance-role" PROFILE_NAME="vllm-ssm-instance-profile" SG_NAME="vllm-ssm-isolated-sg" REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "us-east-2" "eu-central-1") echo "=== 1. IAMロール & インスタンスプロファイルの作成 ===" # 信頼ポリシー (EC2) TRUST_POLICY='{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }' if ! aws iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then echo "IAMロール ${ROLE_NAME} を作成中..." aws iam create-role --role-name "${ROLE_NAME}" --assume-role-policy-document "${TRUST_POLICY}" # SSM管理ポリシーの付与 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" # S3モデル読み取り権限 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" # ECRコンテナ読み取り権限 aws iam attach-role-policy --role-name "${ROLE_NAME}" \ --policy-arn "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" else echo "IAMロール ${ROLE_NAME} は既に存在します。" fi if ! aws iam get-instance-profile --instance-profile-name "${PROFILE_NAME}" >/dev/null 2>&1; then echo "インスタンスプロファイル ${PROFILE_NAME} を作成中..." aws iam create-instance-profile --instance-profile-name "${PROFILE_NAME}" aws iam add-role-to-instance-profile --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" echo "IAMインスタンスプロファイルの反映待機中 (10秒)..." sleep 10 else echo "インスタンスプロファイル ${PROFILE_NAME} は既に存在します。" fi echo "=== 2. 各リージョンでの インバウンド全閉塞セキュリティグループ作成 ===" for REGION in "${REGIONS[@]}"; do echo "リージョン [${REGION}] を確認中..." VPC_ID=$(aws ec2 describe-vpcs --region "${REGION}" --filters "Name=is-default,Values=true" --query "Vpcs[0].VpcId" --output text) EXISTING_SG=$(aws ec2 describe-security-groups --region "${REGION}" --filters "Name=group-name,Values=${SG_NAME}" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true) if [ -z "${EXISTING_SG}" ] || [ "${EXISTING_SG}" = "None" ]; then echo " ${SG_NAME} を VPC (${VPC_ID}) に作成中..." SG_ID=$(aws ec2 create-security-group \ --region "${REGION}" \ --group-name "${SG_NAME}" \ --description "Zero-inbound security group for vLLM SSM port forwarding" \ --vpc-id "${VPC_ID}" \ --query "GroupId" --output text) # デフォルトで全アウトバウンドは許可されるため、インバウンドルールは何も追加しない(ポート開放ゼロ) echo " 作成完了: ${SG_ID} (インバウンドルール: 0件)" else echo " 既に存在します: ${EXISTING_SG}" fi done echo "事前準備が完了しました!" このスクリプトを実行すると、各リージョンにインバウンドルールが1つもないセキュリティグループ vllm-ssm-isolated-sg と、SSM通信が許可されたインスタンスプロファイル vllm-ssm-instance-profile が作成されます。 起動スクリプトのSSM対応 # 第3回の起動スクリプト( 01_ec2_launch_spot_and_sync_openwebui.sh )から、SSH接続部分をSSMポートフォワーディングへと置き換えたスクリプト( 01_start_vllm_ssm.sh )を作成します。 主な変更ポイント # SSHキーの指定を削除 : aws ec2 run-instances に渡していた --key-name パラメータを完全に削除します。 IAMインスタンスプロファイルとインバウンド全閉塞SGを指定 : --iam-instance-profile Name=vllm-ssm-instance-profile を追加。 --security-group-ids に vllm-ssm-isolated-sg を指定。 SSMエージェントのオンライン待機 : 以前はSSHポート22の応答を nc -z でループ待機していましたが、今回はAWS SSM API( aws ssm describe-instance-information )を呼び出し、 PingStatus == "Online" になるまで待機します。 SSMポートフォワーディングセッションの確立 : aws ssm start-session でポートフォワーディングを開始し、バックグラウンド実行します。 aws ssm start-session \ --region "${AWS_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' 完全なスクリプト: 01_start_vllm_ssm.sh # 01_start_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 01_start_vllm_ssm.sh # スポットインスタンスを探索・起動し、SSMポートフォワーディングで安全に直結する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" USER_DATA_FILE="${USER_DATA_FILE:-${SCRIPT_DIR}/02_ec2_userdata.sh}" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" CANDIDATE_REGIONS=("ap-northeast-1" "us-west-2" "us-east-1" "us-east-2" "eu-central-1") CANDIDATE_INSTANCE_TYPES=("g6.xlarge") IAM_PROFILE_NAME="vllm-ssm-instance-profile" SG_NAME="vllm-ssm-isolated-sg" EBS_SIZE_GB="40" echo "=== 1. マルチリージョン スポット総合優先度探索 ===" ITYPE="${CANDIDATE_INSTANCE_TYPES[0]}" START_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Spot Advisor 中断頻度の取得 declare -A REGION_INTR_TIERS PY_BIN="" command -v python3 >/dev/null 2>&1 && PY_BIN="python3" || (command -v python >/dev/null 2>&1 && PY_BIN="python") if [ -n "${PY_BIN}" ]; then ADVISOR_DATA=$("${PY_BIN}" -c " import urllib.request, json try: with urllib.request.urlopen('https://spot-bid-advisor.s3.amazonaws.com/spot-advisor-data.json', timeout=4) as res: adv = json.loads(res.read()).get('spot_advisor', {}) for r, info in adv.items(): print(f'{r}:{info.get(\"Linux\", {}).get(\"${ITYPE}\", {}).get(\"r\", 4)}') except Exception: pass " 2>/dev/null || true) while IFS=':' read -r r_id r_tier; do r_id=$(echo "${r_id}" | tr -d '[:space:]') r_tier=$(echo "${r_tier}" | tr -d '[:space:]') [ -n "${r_id}" ] && REGION_INTR_TIERS["${r_id}"]="${r_tier}" done <<< "${ADVISOR_DATA}" fi declare -A REG_STATUS REG_MIN_PRICES REG_AZ_COUNTS REG_SCORES REG_RANKS BEST_PRICE="999.0" for REGION in "${CANDIDATE_REGIONS[@]}"; do RAW_PRICES=$(aws ec2 describe-spot-price-history \ --region "${REGION}" \ --instance-types "${ITYPE}" \ --product-descriptions "Linux/UNIX" \ --start-time "${START_TIME}" \ --no-paginate \ --query 'SpotPriceHistory[*].[AvailabilityZone, SpotPrice]' \ --output text 2>/dev/null | tr -d '\r' || true) if [ -z "${RAW_PRICES}" ]; then REG_STATUS["${REGION}"]="unavailable" continue fi REG_MIN_PRICE="999.0" AZ_COUNT=0 while read -r AZ PRICE; do AZ=$(echo "${AZ}" | tr -d '[:space:]') PRICE=$(echo "${PRICE}" | tr -d '[:space:]') [ -z "${AZ}" ] || [ -z "${PRICE}" ] && continue AZ_COUNT=$((AZ_COUNT + 1)) if awk -v p="${PRICE}" -v m="${REG_MIN_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then REG_MIN_PRICE="${PRICE}" fi done <<< "${RAW_PRICES}" if [ "${REG_MIN_PRICE}" = "999.0" ]; then REG_STATUS["${REGION}"]="unavailable" continue fi REG_STATUS["${REGION}"]="ok" REG_MIN_PRICES["${REGION}"]="${REG_MIN_PRICE}" REG_AZ_COUNTS["${REGION}"]="${AZ_COUNT}" if awk -v p="${REG_MIN_PRICE}" -v m="${BEST_PRICE}" 'BEGIN {exit !(p < m)}' 2>/dev/null; then BEST_PRICE="${REG_MIN_PRICE}" fi done # スコア計算 (価格 40点 + 余剰AZ数 40点 + 中断頻度 20点 = 100点満点) for REGION in "${CANDIDATE_REGIONS[@]}"; do if [ "${REG_STATUS[${REGION}]:-}" != "ok" ]; then echo " - [${REGION}]: 在庫なしまたは未提供" continue fi PRICE="${REG_MIN_PRICES[${REGION}]}" TIER="${REGION_INTR_TIERS[${REGION}]:-4}" AZ_COUNT="${REG_AZ_COUNTS[${REGION}]}" # 価格スコア (最大40点) P_SCORE=$(awk -v min="${BEST_PRICE}" -v cur="${PRICE}" 'BEGIN {printf "%.0f", 40.0 * (min / cur)}') # 中断頻度スコア (最大20点) case "${TIER}" in 0) I_SCORE=20; I_TEXT="< 5% (極低)" ;; 1) I_SCORE=15; I_TEXT="5-10% (低)" ;; 2) I_SCORE=10; I_TEXT="10-15% (中)" ;; 3) I_SCORE=5; I_TEXT="15-20% (中高)" ;; *) I_SCORE=0; I_TEXT="> 20% (高)" ;; esac # キャパシティスコア (最大40点) if [ "${AZ_COUNT}" -ge 5 ]; then C_SCORE=40 elif [ "${AZ_COUNT}" -eq 4 ]; then C_SCORE=32 elif [ "${AZ_COUNT}" -eq 3 ]; then C_SCORE=24 elif [ "${AZ_COUNT}" -eq 2 ]; then C_SCORE=16 else C_SCORE=8 fi TOTAL_SCORE=$(( P_SCORE + I_SCORE + C_SCORE )) REG_SCORES["${REGION}"]="${TOTAL_SCORE}" if [ "${TOTAL_SCORE}" -ge 75 ]; then RANK="Rank S" elif [ "${TOTAL_SCORE}" -ge 65 ]; then RANK="Rank A" elif [ "${TOTAL_SCORE}" -ge 50 ]; then RANK="Rank B" else RANK="Rank C" fi REG_RANKS["${REGION}"]="${RANK}" PRICE_FMT=$(awk -v p="${PRICE}" 'BEGIN {printf "%.4f", p}') echo " - [${REGION}] 価格=\$${PRICE_FMT}/h, 中断率=${I_TEXT}, 余剰AZ=${AZ_COUNT} -> 総合スコア: ${TOTAL_SCORE}点 (${RANK})" done # スコア順にソート (降順) SORTED_REGIONS=($(for r in "${!REG_SCORES[@]}"; do echo "$r ${REG_SCORES[$r]}"; done | sort -k2 -nr | awk '{print $1}')) echo "----------------------------------------------------------" echo "総合優先度ランキング:" for i in "${!SORTED_REGIONS[@]}"; do r="${SORTED_REGIONS[$i]}" echo " 第$((i+1))位: ${r} (${REG_RANKS[$r]} / スコア: ${REG_SCORES[$r]}点, 最安: \$${REG_MIN_PRICES[$r]}/h)" done echo "----------------------------------------------------------" # --- 2. スポットインスタンス起動試行 --- echo "=== 2. スポットインスタンス起動試行 ===" SELECTED_REGION="" INSTANCE_ID="" for REGION in "${SORTED_REGIONS[@]}"; do echo "リージョン [${REGION}] でスポット起動を試行中..." # インバウンド全閉塞SGのID取得 SG_ID=$(aws ec2 describe-security-groups --region "${REGION}" --filters "Name=group-name,Values=${SG_NAME}" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true) if [ -z "${SG_ID}" ] || [ "${SG_ID}" = "None" ]; then echo " 警告: ${REGION} に ${SG_NAME} が見つかりません。スキップします。" continue fi # Ubuntu 22.04 Deep Learning AMI (NVIDIA Driver & Docker導入済み) の動的取得 AMI_ID=$(aws ec2 describe-images \ --region "${REGION}" \ --owners amazon \ --filters "Name=name,Values=Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Ubuntu 22.04)*" "Name=state,Values=available" \ --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ --output text 2>/dev/null || true) # UserDataの読み込みと環境変数の正確な注入 AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "") S3_BUCKET_NAME="my-vllm-models-hackathon-2026-${AWS_ACCOUNT_ID}-${REGION}-an" DOCKER_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/vllm-openai:latest" TMP_USERDATA=$(mktemp) sed -e "s|^AWS_REGION=.*|AWS_REGION=\"${REGION}\"|" \ -e "s|^S3_BUCKET_NAME=.*|S3_BUCKET_NAME=\"${S3_BUCKET_NAME}\"|" \ -e "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=\"${DOCKER_IMAGE}\"|" \ "${USER_DATA_FILE}" > "${TMP_USERDATA}" LAUNCH_RES=$(aws ec2 run-instances \ --region "${REGION}" \ --image-id "${AMI_ID}" \ --instance-type "${ITYPE}" \ --iam-instance-profile Name="${IAM_PROFILE_NAME}" \ --security-group-ids "${SG_ID}" \ --instance-market-options '{"MarketType":"spot","SpotOptions":{"SpotInstanceType":"one-time","InstanceInterruptionBehavior":"terminate"}}' \ --block-device-mappings "[{\"DeviceName\":\"/dev/sda1\",\"Ebs\":{\"VolumeSize\":${EBS_SIZE_GB},\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \ --user-data "file://${TMP_USERDATA}" \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vllm-ssm-spot-node}]" \ --query "Instances[0].InstanceId" \ --output text 2>&1 || true) rm -f "${TMP_USERDATA}" if [[ "${LAUNCH_RES}" =~ ^i-[0-9a-f]+$ ]]; then INSTANCE_ID="${LAUNCH_RES}" SELECTED_REGION="${REGION}" echo " 起動成功! Instance ID: ${INSTANCE_ID} (${SELECTED_REGION})" break else echo " キャパシティ不足またはエラーのためスキップ: ${LAUNCH_RES}" fi done if [ -z "${INSTANCE_ID}" ]; then echo "エラー: 全候補リージョンでスポット起動に失敗しました。" exit 1 fi echo "${INSTANCE_ID}" > "${INSTANCE_STATE_FILE}" echo "${SELECTED_REGION}" > "${REGION_STATE_FILE}" echo "インスタンスが running 状態になるのを待機中..." aws ec2 wait instance-running --region "${SELECTED_REGION}" --instance-ids "${INSTANCE_ID}" # Git Bash環境向けにSessionManagerPluginのパスを追加 if [ -d "/c/Program Files/Amazon/SessionManagerPlugin/bin" ]; then export PATH="${PATH}:/c/Program Files/Amazon/SessionManagerPlugin/bin" fi # --- 3. SSMエージェントのオンライン待機 --- echo "=== 3. SSMエージェントのオンライン待機 ===" echo "EC2インスタンス内部のSSM Agentが接続されるのを待機中..." SSM_OK=false for i in {1..30}; do STATUS=$(aws ssm describe-instance-information \ --region "${SELECTED_REGION}" \ --filters "Key=InstanceIds,Values=${INSTANCE_ID}" \ --query "InstanceInformationList[0].PingStatus" \ --output text 2>/dev/null || true) if [ "${STATUS}" = "Online" ]; then echo " SSM Agent が Online になりました! (${i}回目の試行)" SSM_OK=true break fi echo -n "." sleep 3 done echo "" if [ "${SSM_OK}" != "true" ]; then echo "エラー: SSM Agent が Online になりませんでした。IAMロールやアウトバウンド通信を確認してください。" exit 1 fi # --- 4. サーバー側での vLLM 起動待機 (S3モデルDL・コンテナ起動) --- echo "=== 4. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) ===" echo "UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)..." SERVER_READY=false for i in {1..90}; do CMD_ID=$(aws ssm send-command \ --region "${SELECTED_REGION}" \ --instance-ids "${INSTANCE_ID}" \ --document-name "AWS-RunShellScript" \ --parameters 'commands=["curl -s -m 2 http://127.0.0.1:8000/health >/dev/null && echo OK || echo WAITING"]' \ --query "Command.CommandId" \ --output text 2>/dev/null || true) if [ -n "${CMD_ID}" ]; then sleep 2 RES=$(aws ssm get-command-invocation \ --region "${SELECTED_REGION}" \ --command-id "${CMD_ID}" \ --instance-id "${INSTANCE_ID}" \ --query "StandardOutputContent" \ --output text 2>/dev/null || true) if [[ "${RES}" =~ "OK" ]]; then echo " サーバー側の vLLM が正常に応答しました!" SERVER_READY=true break fi fi echo -n "." sleep 5 done echo "" if [ "${SERVER_READY}" != "true" ]; then echo "警告: サーバー側のvLLM起動待機がタイムアウトしました。UserDataログを確認してください。" fi # --- 5. 既存トンネルの整理と新規SSMポートフォワーディング確立 --- echo "=== 5. SSMポートフォワーディングの確立 (ポート開放不要トンネル) ===" if [ -f "${TUNNEL_PID_FILE}" ]; then OLD_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${OLD_PID}" ] && ps -p "${OLD_PID}" > /dev/null 2>&1; then echo "古いポートフォワードプロセス (PID: ${OLD_PID}) を終了します..." kill -9 "${OLD_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # バックグラウンドでSSMポートフォワーディングを開始(ログを .current_tunnel.log に出力) nohup aws ssm start-session \ --region "${SELECTED_REGION}" \ --target "${INSTANCE_ID}" \ --document-name AWS-StartPortForwardingSession \ --parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}' \ > "${TUNNEL_LOG}" 2>&1 & TUNNEL_PID=$! echo "${TUNNEL_PID}" > "${TUNNEL_PID_FILE}" echo "SSMポートフォワーディングを開始しました (PID: ${TUNNEL_PID})" sleep 2 # --- 6. ローカル疎通確認 --- echo "=== 6. ローカル疎通確認 (http://localhost:8000/health) ===" LOCAL_OK=false for i in {1..10}; do if curl -s -m 3 "http://localhost:8000/health" > /dev/null 2>&1; then echo " ローカルからの直結疎通を確認しました! (HTTP 200 OK)" LOCAL_OK=true break fi echo -n "." sleep 2 done echo "" if [ "${LOCAL_OK}" != "true" ]; then echo "警告: ローカル接続に失敗しました。トンネルログを確認してください:" cat "${TUNNEL_LOG}" 2>/dev/null || true fi echo "==========================================================" echo " ポート開放ゼロ SSM環境の起動が完了しました!" echo " EC2 Instance ID : ${INSTANCE_ID} (Spot, Region: ${SELECTED_REGION})" echo " セキュリティ : インバウンドルール 0件(完全閉塞)" echo " トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化)" echo " Web UI / API : http://localhost:8000/v1" echo "" echo " VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1)" echo " 検証終了時は以下のコマンドで完全破棄してください:" echo " ./02_stop_vllm_ssm.sh" echo "==========================================================" 実際に動かしてみる # 1. スクリプトの実行 # スクリプトを実行すると、マルチリージョン探索、スポットインスタンスの起動、SSMエージェントの接続待機、サーバー側vLLM起動待機、ポートフォワーディングの確立がすべて全自動で進みます。 $ ./01_start_vllm_ssm.sh === 1. マルチリージョン スポット総合優先度探索 === - [ap-northeast-1] 価格=$0.5510/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 72点 (Rank A) - [us-west-2] 価格=$0.3850/h, 中断率=< 5% (極低), 余剰AZ=4 -> 総合スコア: 92点 (Rank S) - [us-east-1] 価格=$0.5830/h, 中断率=5-10% (低), 余剰AZ=6 -> 総合スコア: 81点 (Rank S) - [us-east-2] 価格=$0.4210/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 80点 (Rank S) - [eu-central-1] 価格=$0.4120/h, 中断率=< 5% (極低), 余剰AZ=3 -> 総合スコア: 81点 (Rank S) ---------------------------------------------------------- 総合優先度ランキング: 第1位: us-west-2 (Rank S / スコア: 92点, 最安: $0.3850/h) 第2位: us-east-1 (Rank S / スコア: 81点, 最安: $0.5830/h) 第3位: eu-central-1 (Rank S / スコア: 81点, 最安: $0.4120/h) 第4位: us-east-2 (Rank S / スコア: 80点, 最安: $0.4210/h) 第5位: ap-northeast-1 (Rank A / スコア: 72点, 最安: $0.5510/h) ---------------------------------------------------------- === 2. スポットインスタンス起動試行 === リージョン [us-west-2] でスポット起動を試行中... 起動成功! Instance ID: i-0abc12345678def01 (us-west-2) インスタンスが running 状態になるのを待機中... === 3. SSMエージェントのオンライン待機 === EC2インスタンス内部のSSM Agentが接続されるのを待機中... ..... SSM Agent が Online になりました! (5回目の試行) === 4. サーバー側での vLLM 起動待機 (http://127.0.0.1:8000/health) === UserDataによるS3モデルダウンロードとvLLM起動を待機しています(通常2〜4分程度)... ................................ サーバー側の vLLM が正常に応答しました! === 5. SSMポートフォワーディングの確立 (ポート開放不要トンネル) === SSMポートフォワーディングを開始しました (PID: 41820) === 6. ローカル疎通確認 (http://localhost:8000/health) === ローカルからの直結疎通を確認しました! (HTTP 200 OK) ========================================================== ポート開放ゼロ SSM環境の起動が完了しました! EC2 Instance ID : i-0abc12345678def01 (Spot, Region: us-west-2) セキュリティ : インバウンドルール 0件(完全閉塞) トンネル接続 : localhost:8000 -> EC2:8000 (SSM暗号化) Web UI / API : http://localhost:8000/v1 VS Code の Cline から接続してください (Base URL: http://localhost:8000/v1) 検証終了時は以下のコマンドで完全破棄してください: ./02_stop_vllm_ssm.sh ========================================================== 2. インバウンドルール0件の確認 # AWSマネジメントコンソールやAWS CLIで対象インスタンスのセキュリティグループを確認してみます。 $ aws ec2 describe-security-groups \ --region us-west-2 \ --filters "Name=group-name,Values=vllm-ssm-isolated-sg" \ --query "SecurityGroups[0].IpPermissions" [] 出力は [] 、つまり インバウンドルールは1件も登録されていません 。 外部から一切の着信ポートが存在しないにもかかわらず、手元の端末からは http://localhost:8000/v1/models で普通にモデル情報が取得できます: $ curl -s http://localhost:8000/v1/models | jq .data[0].id "Qwen/Qwen2.5-Coder-14B-Instruct-AWQ" 外部に対するインバウンドポートを一切開けずに手元から透過的に直結できる体験は、従来のSSH運用と比べても管理負荷とセキュリティの両面で大きなメリットがあります。 検証終了時の完全破棄スクリプト # 作業が終わったら、破棄スクリプト( 02_stop_vllm_ssm.sh )を実行してリソースを解放します。 02_stop_vllm_ssm.sh(クリックで展開) #!/bin/bash set -euo pipefail # ============================================================================== # 02_stop_vllm_ssm.sh # SSMポートフォワードプロセスを終了し、スポットインスタンスを完全破棄する # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTANCE_STATE_FILE="${SCRIPT_DIR}/.current_instance_id" TUNNEL_PID_FILE="${SCRIPT_DIR}/.current_tunnel_pid" REGION_STATE_FILE="${SCRIPT_DIR}/.current_region" TUNNEL_LOG="${SCRIPT_DIR}/.current_tunnel.log" AWS_REGION=$(cat "${REGION_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "ap-northeast-1") INSTANCE_ID=$(cat "${INSTANCE_STATE_FILE}" 2>/dev/null | tr -d '[:space:]' || echo "${1:-}") # 1. SSMポートフォワードプロセスの終了 echo "=== 1. SSMポートフォワードプロセスの終了 ===" if [ -f "${TUNNEL_PID_FILE}" ]; then TUNNEL_PID=$(cat "${TUNNEL_PID_FILE}" | tr -d '[:space:]') if [ -n "${TUNNEL_PID}" ] && ps -p "${TUNNEL_PID}" > /dev/null 2>&1; then echo "SSMポートフォワードプロセス (PID: ${TUNNEL_PID}) を終了中..." kill -9 "${TUNNEL_PID}" 2>/dev/null || true fi rm -f "${TUNNEL_PID_FILE}" fi pkill -f "AWS-StartPortForwardingSession.*8000" 2>/dev/null || true # 2. EC2インスタンスの完全終了 (Terminate) if [ -n "${INSTANCE_ID}" ]; then echo "=== 2. EC2インスタンスの完全終了 (Terminate) ===" echo "対象インスタンスID: ${INSTANCE_ID} (${AWS_REGION})" aws ec2 terminate-instances --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" --output table echo "インスタンスの終了完了を待機中..." aws ec2 wait instance-terminated --region "${AWS_REGION}" --instance-ids "${INSTANCE_ID}" fi rm -f "${INSTANCE_STATE_FILE}" "${REGION_STATE_FILE}" "${TUNNEL_LOG}" echo "==========================================================" echo " スポットインスタンス、EBSボリューム、SSMトンネルの完全破棄が完了しました!" echo "==========================================================" 実行すると、バックグラウンドのポートフォワーディングプロセスが終了し、EC2インスタンスとEBSがTerminateされ、これ以降の課金は完全にストップします。 まとめ # 今回は、第3回で構築した「マルチリージョン・スポットvLLM環境」の通信レイヤーを根本から見直し、 AWS Systems Manager Session Managerによるポートフォワーディング へと刷新しました。 ポート開放ゼロ(インバウンド全閉塞)の達成 : セキュリティグループのインバウンド許可を完全に撤廃し、不特定多数からのポートスキャンや直接攻撃のリスクを遮断。 SSH鍵管理の完全撤廃 : リージョンごとの鍵ペア作成や .pem ファイルの管理・配布・紛失リスクから完全に解放。 IAMによる認可と監査 : AWS認証情報に基づくアクセス制御とCloudTrailによるセッション開始ログの取得により、企業ユースでも求められるアクセス統制とセッション監査ログの取得に対応。 使い勝手は変わらず快適 : ローカルPC上ではこれまで通り localhost:8000 にアクセスするだけで、Open WebUIやClineからの透過的な推論が可能。 セキュリティと運用利便性の双方を大きく向上させた強固な基盤が整いました。 低コストなスポットGPUをさらにセキュアに活用したい方の参考になれば幸いです。
動画
該当するコンテンツが見つかりませんでした
書籍
該当するコンテンツが見つかりませんでした







