
Terraform
イベント
該当するコンテンツが見つかりませんでした
マガジン
技術ブログ
G-gen の佐々木です。当記事では、Cloud Run サービスに https://<サブドメイン>.cloud.run 形式の短い URL を割り当てる機能、 カスタム URL (Custom URLs)について解説します。 概要 Cloud Run とは カスタム URL とは ドメインマッピングとの違い カスタム URL の基本 仕組み 命名規則 文字と長さの規則 サブドメインの一意性 使用できない単語 認証 ID トークンの検査 カスタムオーディエンス 設定の有無による応答の違い 制限事項 注意点 上り(内向き)を制限したサービス 削除したサブドメインは第三者が取得できる サービスを削除してもマッピングは残る 反映には時間がかかる 料金 設定手順 事前準備 新規サービスへの割り当て 既存サービスへのマッピング カスタムオーディエンスの設定 カスタム URL の削除 別のサービスへの移動 動作確認 カスタム URL へのアクセス デフォルト URL の無効化 audience ごとの認証結果 サービスを削除した場合 概要 Cloud Run とは Cloud Run は、Google Cloud のサーバーレスなコンテナ実行サービスです。HTTP リクエストを処理する サービス (service)をデプロイすると、サービスごとに HTTPS のエンドポイントが自動で割り当てられます。 当記事では、この自動で割り当てられる URL を デフォルト URL と呼びます。デフォルト URL は、サービス名、プロジェクト番号、リージョンから決まる run.app ドメインの URL です。 参考 : HTTPS リクエストで呼び出す - 決定論的 URL Cloud Run の基本については、以下の記事を参照してください。 blog.g-gen.co.jp カスタム URL とは カスタム URL (Custom URLs)は、Cloud Run サービスに https://<サブドメイン>.cloud.run 形式の URL を割り当てる機能です。サブドメインの部分はユーザーが指定します。 URL の種類 形式 デフォルト URL https://<サービス名>-<プロジェクト番号>.<リージョン>.run.app カスタム URL https://<サブドメイン>.cloud.run 参考 : カスタム URL を作成する カスタム URL の使用に当たり、独自ドメインの取得、DNS レコードの設定、ロードバランサの構築はいずれも不要で、コマンド1つで URL を割り当てられます。サービスを短く覚えやすい URL で共有したい場面で使用できます。 Cloud Run のカスタム URL には、以下の特徴があります。 https://<サブドメイン>.cloud.run という短い形式の URL をサービスに割り当てられる デフォルト URL と同様に、どこからでもアクセスできる 新しいリビジョンをデプロイせずに、URL の割り当てや解除、別のサービスへの割り当て直しができる 1つのサービスに複数のカスタム URL を割り当てられる 2026年10月現在、カスタム URL は Preview 公開の機能です。Preview 段階の機能は、サポートが限定される場合や、一般公開(GA)までに仕様が変わる可能性があるため、本番環境での使用は推奨されません。 参考 : Preview版のサービスを使うとはどういうことなのか - G-gen Tech Blog ドメインマッピングとの違い Cloud Run ではカスタム URL とは別に、独自ドメインをサービスに割り当てる ドメインマッピング という機能があります。カスタム URL は、gcloud CLI ではドメインマッピングと同じコマンドで操作しますが、性質は異なります。 カスタム URL は、Google が管理する cloud.run ドメインのサブドメインを使用します。ドメインマッピングの設定に必要なドメインの所有権確認や DNS レコードの設定は、カスタム URL では不要です。 比較項目 カスタム URL ドメインマッピング URL のドメイン cloud.run (Google が管理) ユーザー独自のドメイン ドメインの所有権確認 不要 必要 DNS レコードの設定 不要 必要 TLS 証明書 発行を待つ必要がない マッピング後に自動発行(通常15分程度、最大24時間) 提供ステータス Preview 公開 Preview 公開 なお、ドメインマッピングには、東京リージョン(asia-northeast1)などで呼び出しのレイテンシが非常に大きくなる既知の問題があり、2026年10月現在も解消されていません。 本番環境で独自ドメインを使用したい場合は、グローバル外部アプリケーションロードバランサを使用する方法が推奨されています。 参考 : カスタム ドメインのマッピング 参考 : Cloud Run の既知の問題 - 一部のリージョンからの呼び出し時にカスタム ドメインでリクエスト レイテンシが増加する カスタム URL の基本 仕組み カスタム URL は、サブドメインとサービスを対応付ける マッピング として管理されます。マッピングはサービスとは独立したリソースです。 そのため、マッピングの作成や削除では、サービスの新しいリビジョンは作成されません。1つのサービスに複数のマッピングを作成することもできます。 サブドメインは Google Cloud 全体で一意ですが、マッピング自体はサービスと同じリージョンに作成されるリージョンリソースです。 1つのサービスに複数のマッピングを作成できる cloud.run ドメインは未使用のサブドメインを含めてワイルドカードで名前解決され、TLS 証明書も *.cloud.run のワイルドカード証明書が使用されます。 そのため、マッピングごとに DNS の反映や証明書の発行を待つ必要がありません。ただし、マッピングの作成後、リクエストが安定してサービスに届くまでには時間がかかります。この点は注意点の節で説明します。 命名規則 文字と長さの規則 サブドメインは、以下の規則に従って指定します。 長さは6〜63文字 使用できる文字は小文字の英数字とハイフン 先頭と末尾は小文字の英数字 規則に違反するサブドメインを指定すると、以下のエラーになります。 # 5文字のサブドメインを指定した場合 $ gcloud beta run domain-mappings create \ --service = my-service-b \ --region = asia-northeast1 \ --project =< プロジェクトID > \ --domain = sshun.cloud.run ----- 出力例 ----- ERROR: ( gcloud.beta.run.domain-mappings.create ) INVALID_ARGUMENT: metadata.name: The subdomain ' sshun ' cannot be used as a valid custom URL. It must be 6-63 characters long, start and end with a lowercase alphanumeric character, and can only contain lowercase alphanumeric characters and hyphens. - ' @type ' : type .googleapis.com/google.rpc.BadRequest fieldViolations: - description: The subdomain ' sshun ' cannot be used as a valid custom URL. It must be 6-63 characters long, start and end with a lowercase alphanumeric character, and can only contain lowercase alphanumeric characters and hyphens. field: metadata.name 検証では、大文字を含むサブドメインも作成できました。ただし、小文字のサブドメインとは別のマッピングとして登録され、2つのマッピングが併存する状態になります。URL のホスト名は大文字と小文字を区別しないため、サブドメインは小文字で指定してください。 参考 : カスタム URL を作成する - 制限事項 サブドメインの一意性 サブドメインは Google Cloud 全体で一意です。すでに使用されているサブドメインを指定すると、以下のエラーになります。 # 別のプロジェクトで使用中のサブドメインを指定した場合 $ gcloud beta run domain-mappings create \ --service = hello \ --region = asia-northeast1 \ --project =< 別のプロジェクトID > \ --domain =< サブドメイン > .cloud.run ----- 出力例 ----- ERROR: ( gcloud.beta.run.domain-mappings.create ) Domain mapping to [< サブドメイン > .cloud.run ] already exists in this region. メッセージには「in this region」とありますが、他のリージョンや他のプロジェクトで使用中の場合も同じメッセージが表示されます。リージョンを変えても解決しないため、別のサブドメインを指定します。 使用できない単語 一部のサブドメインは使用が制限されています。2026年10月現在、制限対象の一覧は公式ドキュメントに記載されていません。 検証では、ハイフンで区切った単語のいずれかが特定の単語と一致する場合に、以下のエラーになりました。 # 末尾の単語が lb のサブドメインを指定した場合 $ gcloud beta run domain-mappings create \ --region = asia-northeast1 \ --project =< プロジェクトID > \ --service = my-service-c \ --domain = sasashun-ingress-lb.cloud.run ----- 出力例 ----- ERROR: ( gcloud.beta.run.domain-mappings.create ) INVALID_ARGUMENT: metadata.name: The subdomain ' sasashun-ingress-lb ' contains the prohibited term ' lb ' and cannot be used in Cloud Run custom URL. - ' @type ' : type .googleapis.com/google.rpc.BadRequest fieldViolations: - description: The subdomain ' sasashun-ingress-lb ' contains the prohibited term ' lb ' and cannot be used in Cloud Run custom URL. field: metadata.name 検証で拒否された単語は google 、 youtube 、 gemini 、 internal 、 lb 、 admin 、 www 、 auth の8つです。Google の製品名だけでなく、環境や用途を表す一般的な単語も含まれます。 一方、 internal2 のように単語が完全には一致しない場合や、ハイフンで区切らずに他の文字列と連結した場合は作成できました。ここに挙げた単語は検証で確認できた例であり、制限対象のすべてではありません。 参考 : カスタム URL を作成する - 制限事項 認証 ID トークンの検査 カスタム URL 経由のリクエストにも、デフォルト URL と同じくサービスの IAM 認証が適用されます。認証が必要なサービスでは、呼び出し元は Google が署名した ID トークンをリクエストに付与します。認証が必要なサービスは、受け取った ID トークンを2段階で検査します。 まず、トークンの宛先を示す audience ( aud クレーム)がサービス宛てかを確認します。次に、トークンの主体がサービスを呼び出す権限(Cloud Run 起動元、 roles/run.invoker )を持つかを IAM で確認します。 audience が一致しない場合の応答は 401 です。トークンがない場合や、トークンの主体に権限がない場合の応答は 403 です。カスタム URL が関係するのは、前者の audience の検査だけです。 参考 : サービス間認証 カスタムオーディエンス Cloud Run がサービス宛てと判定する audience は、サービスのデフォルト URL です。サービス側で受け入れる audience の値を追加する設定を、Cloud Run では カスタムオーディエンス と呼びます。 カスタム URL をマッピングしても、カスタム URL が自動で audience として受け入れられることはありません。以下は、Cloud Run や Compute Engine 上で動作する呼び出し元が、メタデータサーバーから audience を指定して ID トークンを取得する例です。 # audience にデフォルト URL を指定して ID トークンを取得 $ curl -s -H " Metadata-Flavor: Google " \ " http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://<サービス名>-<プロジェクト番号>.<リージョン>.run.app " # audience にカスタム URL を指定して ID トークンを取得 $ curl -s -H " Metadata-Flavor: Google " \ " http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://<サブドメイン>.cloud.run " 1つ目のコマンドで取得したトークンは、カスタムオーディエンスを設定しなくても、カスタム URL 経由の呼び出しに使用できます。 2つ目のコマンドで取得したトークンは、カスタムオーディエンスを設定していないサービスでは使用できません。audience がサービス宛てと判定されず、応答は 401 になります。 このトークンを使用するには、サービスに https://<サブドメイン>.cloud.run をカスタムオーディエンスとして設定します。 カスタムオーディエンスが必要になるのは、呼び出し元が audience にカスタム URL を指定する場合です。 Cloud Scheduler や Pub/Sub の push サブスクリプションのように、呼び出し先の URL をそのまま audience にするサービスから、カスタム URL 宛てに呼び出す構成が典型です。 参考 : サービスにカスタム オーディエンスを設定する 設定の有無による応答の違い サービスの認証設定、ID トークンの audience、カスタムオーディエンスの組み合わせごとに、カスタム URL を呼び出した結果を整理すると以下のとおりです。 サービスの認証設定 ID トークンの audience カスタムオーディエンス 応答 未認証アクセスを許可 トークンなし 設定の有無に無関係 200 認証が必要 トークンなし 設定の有無に無関係 403 認証が必要 デフォルト URL 設定の有無に無関係 200 認証が必要 カスタム URL 設定なし 401 認証が必要 カスタム URL カスタム URL を設定 200 カスタムオーディエンスの設定で結果が変わるのは、表の下2行だけです。サービス側の設定手順は、「カスタムオーディエンスの設定」の節で説明します。 制限事項 2026年10月現在、カスタム URL には以下の制限事項があります。 デフォルトでは、1つのプロジェクトにマッピングできるドメインは最大50件 カスタム URL だけを一時的に無効化することはできず、トラフィックの提供を止めるにはマッピングを削除する 上り(内向き)の設定を「内部」または「内部と Cloud Load Balancing」に制限しているサービスではサポートされない Terraform はサポートされない 不正利用の防止や商標の保護などを理由に、Google がサブドメインを取り消す、または再割り当てする場合がある なお、カスタム URL はデフォルト URL から独立しています。サービスのデフォルト URL を無効化しても、カスタム URL は引き続き使用できます。 参考 : カスタム URL を作成する - 制限事項 参考 : Cloud Run のネットワーク上り(内向き)を制限する - デフォルトの URL を無効にする 注意点 上り(内向き)を制限したサービス 上り(内向き)を「内部」または「内部と Cloud Load Balancing」に制限しているサービスは、カスタム URL をサポートしていません。 検証では、上り(内向き)を「内部」に制限したサービスに対しても、マッピングの作成はエラーにならず、マッピングの状態も正常と表示されました。しかし、インターネットからカスタム URL にアクセスすると 404 が返ります。 設定が成功したように見えても、インターネットからはアクセスできない状態になります。このようなサービスでは、カスタム URL を使用せず、デフォルト URL を使用してください。 参考 : Cloud Run のネットワーク上り(内向き)を制限する 削除したサブドメインは第三者が取得できる マッピングを削除すると、そのサブドメインは即座に他のユーザーが取得できる状態になります。 第三者に同じサブドメインを取得されると、以前の URL を知っている利用者が第三者のサービスにアクセスするおそれがあります。一度設定したマッピングの削除には、このリスクが伴います。削除は必要な場合に限り、削除と再作成を繰り返す運用は避けてください。 参考 : カスタム URL を作成する - カスタム URL を削除する サービスを削除してもマッピングは残る マッピング済みのサービスを削除しても、マッピングは自動では削除されません。サブドメインは占有されたままになり、プロジェクトあたりの上限も消費し続けます。 検証では、残ったマッピングは、同じ名前のサービスを再度デプロイすると新しいサービスへルーティングされました。意図しないサービスがカスタム URL で公開されないよう、不要になったマッピングは明示的に削除することを推奨します。 反映には時間がかかる 公式ドキュメントでは、既知の問題として、マッピングの作成後に完全に動作するまで数秒かかる場合があるとされています。 検証では、作成から約2分間は、正常な応答と 404 が混在しました。別のサービスへ移動した場合も、移動前のサービスがしばらく応答を続けます。詳細は動作確認の章で示します。 参考 : カスタム URL を作成する - 既知の問題 料金 2026年10月現在、カスタム URL の使用に追加の料金はかかりません。サービスの実行にかかる料金は、カスタム URL の有無で変わりません。 設定手順 事前準備 以降では、gcloud CLI でカスタム URL の割り当て、確認、削除、移動を行います。コンソールでも同等の操作が可能で、サービスの作成フォームやドメインマッピングのページからカスタム URL を追加できます。 検証は東京リージョン(asia-northeast1)で行いました。gcloud CLI のバージョンは以下のとおりです。 # gcloud CLI のバージョンを確認 $ gcloud version ----- 出力例 ----- Google Cloud SDK 587 . 0 . 0 beta 2026 . 09 . 25 // 省略 core 2026 . 09 . 25 // 省略 2026年10月現在、カスタム URL の操作には gcloud beta run コマンドを使用します。 gcloud CLI のバージョンが古い場合、 --domain に .cloud.run のサブドメインを指定すると以下のエラーになります。 # 古いバージョンの gcloud CLI でカスタム URL を指定した場合 $ gcloud beta run domain-mappings create \ --service = my-service-a \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- ERROR: ( gcloud.beta.run.domain-mappings.create ) The provided domain does not appear to be verified for the current account. To verify it, run: $ gcloud domains verify < サブドメイン > .cloud.run Once verified, try this command again. Currently verified domains: * < 所有権を確認済みのドメイン > メッセージはドメインの所有権確認を案内していますが、 cloud.run は Google が管理するドメインであり、ユーザーが所有権を確認することはできません。このエラーが表示された場合は、 gcloud components update コマンドで gcloud CLI を更新します。 マッピングはリージョンリソースのため、操作時にはリージョンの指定が必要です。gcloud CLI の run/region プロパティを設定していない環境で --region を省略すると、 You must specify a region. というエラーになります。 当記事のコマンドは、すべて --region を指定しています。 マッピングを作成、更新、削除するユーザーには、プロジェクトレベルで Cloud Run 管理者( roles/run.admin )が必要です。 参考 : カスタム URL を作成する - 必要なロール 新規サービスへの割り当て gcloud beta run deploy コマンドの --domain に <サブドメイン>.cloud.run を指定すると、サービスのデプロイと同時にカスタム URL を割り当てられます。 コンテナイメージには、Google が提供するサンプルイメージ us-docker.pkg.dev/cloudrun/container/hello:latest を使用しました。 # カスタム URL を指定してサービスをデプロイ $ gcloud beta run deploy my-service-a \ --image = us-docker.pkg.dev/cloudrun/container/hello:latest \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- Allow unauthenticated invocations to [ my-service -a] ( y/N ) ? Deploying container to Cloud Run service [ my-service -a] in project [< プロジェクトID >] region [ asia-northeast1 ] Deploying new service... Creating Revision................................................................done Creating domain mapping................................................done Routing traffic...done Creating domain mapping...done Done. Service [ my-service -a] revision [ my-service-a-00001-fmh ] has been deployed and is serving 100 percent of traffic. Service URL: https://my-service-a- < プロジェクト番号 > .asia-northeast1.run.app Proxy locally with: gcloud run services proxy my-service-a --region asia-northeast1 --project < プロジェクトID > 出力の Creating domain mapping の行が、カスタム URL のマッピングの作成を示しています。 Service URL にはデフォルト URL のみが表示され、カスタム URL は表示されません。 作成されたマッピングは、 gcloud beta run domain-mappings list コマンドで一覧表示できます。複数のリージョンにサービスがある場合は、リージョンごとに実行します。 # リージョン内のマッピングを一覧表示 $ gcloud beta run domain-mappings list --region = asia-northeast1 --project =< プロジェクトID > ----- 出力例 ----- DOMAIN SERVICE REGION ✔ < サブドメイン > .cloud.run my-service-a asia-northeast1 マッピングの詳細は gcloud beta run domain-mappings describe コマンドで表示します。 # マッピングの詳細を表示 $ gcloud beta run domain-mappings describe \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- apiVersion: domains.cloudrun.com/v1 kind: DomainMapping // 省略 spec: routeName: my-service-a status: conditions: - lastTransitionTime: ' 2026-09-30T06:27:48.748572Z ' status: ' True ' type: Ready - lastTransitionTime: ' 2026-09-30T06:27:48.748572Z ' status: ' True ' type: DomainRoutable mappedRouteName: my-service-a observedGeneration: 1 参考 : gcloud beta run deploy 既存サービスへのマッピング 既存のサービスにカスタム URL をマッピングするには、 gcloud beta run domain-mappings create コマンドを使用します。 --service にサービス名、 --domain に <サブドメイン>.cloud.run を指定します。 # 既存のサービスにカスタム URL をマッピング $ gcloud beta run domain-mappings create \ --service = my-service-a \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- Creating...... .............done. サブドメインを変えて同じコマンドを繰り返すと、1つのサービスに複数のカスタム URL をマッピングできます。 参考 : gcloud beta run domain-mappings create カスタムオーディエンスの設定 audience にカスタム URL を指定した ID トークンを受け入れるには、サービスにカスタムオーディエンスを設定します。 カスタムオーディエンスの値は、トークンの audience と文字列として完全一致で照合されます。 https:// の有無や末尾のスラッシュが異なるだけでも一致しません。 たとえば <サブドメイン>.cloud.run のようにスキーム( https:// )なしの値を設定した場合、受け入れられるのは audience が <サブドメイン>.cloud.run のトークンだけです。 audience に https://<サブドメイン>.cloud.run を指定したトークンは 401 になります。 呼び出し元が audience に指定する値は、呼び出し先の URL 全体( https:// 付き)であるのが通例です。そのため当記事では、 https://<サブドメイン>.cloud.run をカスタムオーディエンスとして設定します。 以下は、カスタム URL をマッピング済みで認証が必要なサービス my-service-b に設定する例です。 # サービスにカスタムオーディエンスを追加 $ gcloud beta run services update my-service-b \ --add-custom-audiences = https:// < サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- Deploying... Creating Revision.............................................................done Routing traffic.....done Done. Service [ my-service -b] revision [ my-service-b-00003-fbp ] has been deployed and is serving 100 percent of traffic. Service URL: https://my-service-b- < プロジェクト番号 > .asia-northeast1.run.app Proxy locally with: gcloud run services proxy my-service-b --region asia-northeast1 --project < プロジェクトID > マッピングの作成や削除とは異なり、カスタムオーディエンスの設定では新しいリビジョンが作成されます。 呼び出し元では、ここで設定した値と同じ文字列を audience に指定して ID トークンを取得します。Cloud Scheduler や Pub/Sub の push サブスクリプションのように、audience を設定項目として持つサービスでは、同じ値を設定します。 参考 : カスタム URL を作成する - カスタム オーディエンスで保護する カスタム URL の削除 カスタム URL を削除するには、 gcloud beta run domain-mappings delete コマンドを使用します。 # カスタム URL のマッピングを削除 $ gcloud beta run domain-mappings delete \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > \ --quiet ----- 出力例 ----- Deleting [< サブドメイン > .cloud.run ] ... ..............done. Mappings to [< サブドメイン > .cloud.run ] now have been deleted. 前述のとおり、削除したサブドメインは即座に他のユーザーが取得できる状態になります。今後も使用する可能性があるサブドメインは、削除せずに保持してください。 参考 : gcloud beta run domain-mappings delete 別のサービスへの移動 カスタム URL のマッピング先を直接変更するコマンドはありません。カスタム URL を別のサービスへ移動するには、マッピングを削除してから、移動先のサービスを指定して同じサブドメインのマッピングを作成し直します。 # 削除したサブドメインを、移動先のサービスにマッピング $ gcloud beta run domain-mappings create \ --service = my-service-b \ --domain =< サブドメイン > .cloud.run \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- Creating...... ...........done. 削除から再作成までの間は、サブドメインが解放された状態になります。第三者に取得されないよう、削除と再作成は続けて実行します。 動作確認 カスタム URL へのアクセス カスタム URL をマッピングしたサービスを、カスタム URL 経由で呼び出します。サービスは認証が必要な設定のため、実行ユーザーの ID トークンをリクエストに付与します。 # ID トークンを付けてカスタム URL を呼び出し、ステータスコードを表示 $ curl -s -o /dev/null -w ' %{http_code}\n ' \ -H " Authorization: Bearer $( gcloud auth print-identity-token ) " \ https:// < サブドメイン > .cloud.run/ ----- 出力例 ----- 200 ただし、マッピングの作成直後は 404 が返る場合があります。以下は、マッピングの作成直後から2秒間隔でカスタム URL を呼び出した結果です(時刻は UTC)。マッピングの作成が完了した時刻は 06:29:47 です。 # ID トークンを取得 $ TOKEN = $( gcloud auth print-identity-token ) # 2秒間隔でカスタム URL を呼び出し、時刻とステータスコードを表示 $ for n in $( seq 1 75 ) ; do STATUS = $( curl -s -o /dev/null -w ' %{http_code} ' \ -H " Authorization: Bearer $TOKEN " \ https:// < サブドメイン > .cloud.run/ ) echo " $( date -u +%H:%M:%S ) $STATUS " sleep 2 done ----- 出力例 ----- 06:29:49 404 06:29:51 404 06:29:53 404 06:29:55 404 06:29:57 404 06:29:59 404 06:30:02 200 06:30:04 200 06:30:06 200 06:30:08 200 06:30:10 200 06:30:12 404 06:30:14 200 06:30:16 200 06:30:19 200 06:30:21 404 06:30:23 200 06:30:25 200 06:30:27 404 06:30:29 404 06:30:31 200 // 省略 最初の 200 は作成完了の15秒後に返りましたが、その後も 200 と 404 が混在しました。最後の 404 は作成完了の111秒後で、それ以降はすべて 200 でした。 この 404 は、未使用のサブドメインにアクセスした場合と同じ応答です。マッピングの作成後は、2分程度待ってから動作を確認してください。 別のサービスへ移動した場合は、切り替えがさらに不連続になります。検証では、マッピングの削除後も約10秒間は移動前のサービスが応答しました。 再作成後の約2分間は、移動前のサービスの応答、移動先のサービスの応答、404 が混在しました。移動前のサービスの応答は、削除の約6分後にも1回観測しています。 カスタム URL の移動は、切り替えの瞬間を制御できません。移動前と移動先のどちらのサービスが応答しても問題のない状態で実施してください。 デフォルト URL の無効化 カスタム URL をマッピングしたサービスのデフォルト URL を無効化し、カスタム URL だけで呼び出せることを確認します。デフォルト URL は、 gcloud run services update コマンドの --no-default-url で無効化します。 # サービスのデフォルト URL を無効化 $ gcloud run services update my-service-a \ --no-default-url \ --region = asia-northeast1 \ --project =< プロジェクトID > ----- 出力例 ----- Updating... Done. Service [ my-service -a] has been updated. 無効化の前後で、ID トークンを付けて各 URL を呼び出した結果は以下のとおりです。 呼び出し先 無効化前 無効化後 デフォルト URL 200 404 カスタム URL 200 200 デフォルト URL を無効化した後も、カスタム URL は引き続き使用できます。デフォルト URL を元に戻すには --default-url を指定します。 参考 : Cloud Run のネットワーク上り(内向き)を制限する - デフォルトの URL を無効にする audience ごとの認証結果 認証が必要なサービス my-service-b に対して、audience の異なる ID トークンでカスタム URL を呼び出し、カスタムオーディエンスの設定による違いを確認します。 audience を指定した ID トークンは、サービスアカウントの権限借用で発行します。ユーザーアカウントの ID トークンは audience を指定できないためです。サービスアカウントには、サービスレベルで Cloud Run 起動元( roles/run.invoker )を付与しています。 以下は、カスタムオーディエンスを設定していない状態で、audience にカスタム URL を指定したトークンを使用した例です。 # audience を指定して ID トークンを発行 $ TOKEN = $( gcloud auth print-identity-token \ --impersonate-service-account =< サービスアカウントのメールアドレス > \ --audiences = https:// < サブドメイン > .cloud.run ) # ID トークンを付けてカスタム URL を呼び出し、ステータスコードを表示 $ curl -s -o /dev/null -w ' %{http_code}\n ' \ -H " Authorization: Bearer $TOKEN " \ https:// < サブドメイン > .cloud.run/ ----- 出力例 ----- 401 カスタムオーディエンスの設定前後で、3種類の audience で呼び出した結果は以下のとおりです。 トークンの audience カスタムオーディエンス設定なし https://<サブドメイン>.cloud.run を設定 デフォルト URL 200 200 https://<サブドメイン>.cloud.run 401 200 https://<サブドメイン>.cloud.run/ 401 401 末尾にスラッシュを付けた audience は、設定後も 401 です。カスタムオーディエンスの値と、呼び出し元が指定する audience は、末尾のスラッシュの有無まで一致させる必要があります。 audience が一致しない場合の応答は以下のとおりです。 # audience が一致しない ID トークンで呼び出した場合 $ curl -si -H " Authorization: Bearer $TOKEN " https:// < サブドメイン > .cloud.run/ ----- 出力例 ----- HTTP/ 2 401 www-authenticate: Bearer error = " invalid_token " error_description = " The access token could not be verified " // 省略 サービスを削除した場合 カスタム URL をマッピング済みのサービス my-service-d を削除し、マッピングの状態を確認します。サービスの削除後にマッピングを一覧表示すると、マッピングは残っており、先頭の記号が X に変わっています。 # サービスの削除後にマッピングを一覧表示 $ gcloud beta run domain-mappings list --region = asia-northeast1 --project =< プロジェクトID > ----- 出力例 ----- DOMAIN SERVICE REGION // 省略 X < サブドメイン > .cloud.run my-service-d asia-northeast1 この状態でカスタム URL にアクセスすると 404 が返ります。 残ったマッピングのサブドメインを、他のサービスや他のプロジェクトから指定しても、使用中のエラーになります。サブドメインを解放するには、マッピングを明示的に削除する必要があります。 続けて、同じ名前のサービスを再度デプロイすると、残っていたマッピングは新しいサービスへルーティングされ、カスタム URL は再デプロイの直後から 200 を返しました。 サービスを削除するときは、マッピングも合わせて削除するか、保持する場合は同じ名前のサービスが意図せず公開されないよう管理してください。 佐々木 駿太 (記事一覧) クラウドソリューション部 クラウドエンジニアリング1課 北海道在住 大学院まで社会心理学を専攻し、AI に興味を持ち IT 業界へ。2022年6月に G-gen にジョイン。Google Cloud Partner Top Engineer に選出(2024 / 2025 Fellow / 2026)。好きな Google Cloud プロダクトは Cloud Run。 趣味はコーヒー、小説(SF、ミステリ)、カラオケなど。最近は時計と007(映画)がマイブーム Follow @sasashun0805
駅メモ!開発基盤チームの id:xztaityozx です。 今回は複数のAWSアカウントにある CloudWatch ダッシュボードのスナップショットを定期的に作成する仕組みを EventBridge Scheduler / Lambda / S3 を使って再構築した話を書きます。 前提と課題 駅メモ!では複数のAWSアカウントを利用しており、それぞれの用途に合わせた CloudWatch ダッシュボードを作成しています。 これらのダッシュボードは、日常の負荷確認でも使いますが、長期的なトレンド把握や過去の施策の効果検証などにも利用しています。このとき困るのが CloudWatch Metrics の保存期間です。 通常、CloudWatch Metricsは1分単位のデータは15日分、5分単位のデータは63日分、1時間単位のデータは15か月分と集計期間ごとに保存期間が設定されています *1 。そのため長期的なトレンド把握や過去の施策の効果検証を行う際に詳細なデータが確認できなかったり、1分、5分粒度では取得できなくなっていることがあります。 これを回避するために、以前からCloudWatchの GetMetricWidgetImage API を使ってダッシュボードを構成するメトリクスウィジェットの画像を保存する仕組みを構築していました。 大まかな仕組みとしては以下の通りです。 before シンプルな構成です。EC2のcronで定期的にスナップショット保存の処理を実行するようにしていました。 初期はこの構成でも問題がなかったのですが、AWSアカウントが増えるにつれて以下のような課題が出てきました。 課題1: アカウントごとにEC2インスタンスを用意する必要がある 初期に仕組みが構成されていたアカウントにはcronを動かしているEC2インスタンスが存在していましたが、EC2を利用していないアカウントやVPCすら作っていないアカウントも出てきたため、単純な横展開は難しくなっていました。 また、多数のcronが同居しており、それぞれのジョブが異なる権限を必要とするため、適切なインスタンスプロファイルの設計が複雑になっていることも課題でした。 課題2: スナップショット作成スクリプトが、アプリケーションと同居している スナップショット作成スクリプトが、駅メモ!本体のリポジトリに存在しており、その変更を反映するためには駅メモ!本体のデプロイが必要になっていました。 ちょっとパラメータを変更したいだけなのに、本番に影響が起こりうるのはあまり好ましくありません。 課題3: スナップショットの保存先がS3バケットで、アカウントごとにバケットを用意している アカウントが増えるにつれて管理対象のバケットも増え、適切なバケットポリシーやネットワーク制限の設定など、運用コストが増えていました。 これらの課題を解決するために、この仕組みを見直すことにしました。 設計の話 前項で挙げた課題解決と、運用負荷低減のためのポイントは以下の通りです。 VPCの有無などに左右されない構成にする スナップショット作成スクリプトをアプリケーションから切り離す スナップショットの保存先をアカウントごとに分けない Lambda失敗時に自動でリトライする。所定の回数失敗したら処理を諦めてSlackに通知する これらのポイントを踏まえて、今回は以下のような構成にすることにしました。 after EventBridge Schedulerで定期的にLambdaを起動する EC2で動いていたcronの代わりにEventBridge Schedulerを利用 配信失敗時の再試行などもEventBridge Schedulerの機能で実装可能 LambdaでCloudWatchダッシュボードのスナップショットを作成する スナップショット作成スクリプトをLambdaに移行 アプリケーションから切り離す Lambda失敗時はEventBus->SNS->Amazon Q Developer in chat applications(旧Chatbot)経由で通知が送られる 関数エラーの場合は、初回に加えて最大2回リトライし、すべて失敗した場合、Lambda destinationsで失敗イベントをEventBusに送る EventBusを挟むのはLambdaの失敗イベントペイロードを変換するため Push型のアーキテクチャを採用する 後述 EventBridge Scheduler Lambdaの定期実行のトリガーにはEventBridge Schedulerを利用することにしました。管理すべきサーバーが不要なのでcronを動かすEC2の役割を減らすことができます。 配信失敗時のリトライはEventBridge Schedulerで、関数実行失敗時のリトライはLambdaの非同期呼び出し設定で実装できるので、運用負荷の低減にもつながります。 data "aws_iam_policy_document" "assume_role_policy" { statement { # 省略 } } data "aws_iam_policy_document" "allow_lambda_invoke" { statement { effect = "Allow" actions = [ "lambda:InvokeFunction" ] resources = [ aws_lambda_function.this.arn, "$ { aws_lambda_function.this.arn } :*" ] } } resource "aws_iam_role" "eventbridge_scheduler_role" { name = "scheduler-role" assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json path = "/service-role/" } resource "aws_iam_policy" "invoke_lambda_policy" { name = "scheduler-invoke-lambda-policy" policy = data.aws_iam_policy_document.allow_lambda_invoke.json path = "/service-role/" } resource "aws_iam_role_policy_attachment" "attach_invoke_lambda_policy" { policy_arn = aws_iam_policy.invoke_lambda_policy.arn role = aws_iam_role.eventbridge_scheduler_role.name } # EventBridge SchedulerはLambdaを非同期で呼び出すため、 # 後述のLambda destinationsによる失敗通知が利用できる resource "aws_scheduler_schedule" "this" { for_each = { for config in var.schedule_config : config.name => config } name = each.value.name schedule_expression = each.value.cron_expression schedule_expression_timezone = "Asia/Tokyo" group_name = aws_scheduler_schedule_group.this.name target { arn = aws_lambda_function.this.arn role_arn = aws_iam_role.eventbridge_scheduler_role.arn # Lambdaに渡すイベントペイロード # dashboard_names: ダッシュボード名のリスト # period: データの期間 (例: "1h", "1w" など) # s3_bucket: 書き込み先S3 # resolution: データの解像度 (例: "1m", "5m" など) input = jsonencode ( { "dashboard_names" : each.value.target_dashboards, "period" : each.value.period, "s3_bucket" : var.s3_bucket_name, "resolution" : each.value.resolution } ) } flexible_time_window { mode = "OFF" } } Lambda こちらは移行前のスクリプトを元にPythonに書き直したものです。 単純にAPIを呼び出して画像を保存、レポート用のHTMLファイルを生成して保存するだけの処理なので詳細は割愛させていただきます。 Terraformでの定義は以下のような感じです。 resource "aws_lambda_function" "this" { function_name = var.function_name role = aws_iam_role.lambda_execution.arn handler = "lambda_handler.handler" runtime = local.python_runtime timeout = 300 memory_size = 256 filename = data.archive_file.lambda.output_path source_code_hash = data.archive_file.lambda.output_base64sha256 layers = [ aws_lambda_layer_version.deps.arn ] } resource "aws_lambda_function_event_invoke_config" "send_to_eventbridge_on_failure" { function_name = aws_lambda_function.this.function_name destination_config { on_failure { destination = aws_cloudwatch_event_bus.failures_notice.arn } } # 関数エラーの場合は、初回に加えて最大2回リトライする maximum_retry_attempts = 2 } resource "aws_iam_role" "lambda_execution" { name = "$ { var.function_name } -execution-role" assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json path = "/service-role/" } data "aws_iam_policy_document" "lambda_execution_policy" { statement { effect = "Allow" actions = [ "cloudwatch:GetMetricWidgetImage" , "cloudwatch:GetDashboard" , ] resources = [ "*" ] } statement { effect = "Allow" actions = [ "s3:PutObject" , ] resources = [ "arn:aws:s3:::$ { var.s3_bucket_name } /*" ] } statement { effect = "Allow" actions = [ # 失敗時にEventBusに送信するための権限 "events:PutEvents" , ] resources = [ aws_cloudwatch_event_bus.failures_notice.arn, ] } } data "aws_iam_policy_document" "lambda_assume_role_policy" { # 省略... } resource "aws_iam_policy" "lambda_execution_policy" { # 省略... } resource "aws_iam_role_policy_attachment" "attach_lambda_execution_policy" { # 省略... } EventBus->SNS->Amazon Q Developer in chat applications Lambda destinationsで失敗イベントをEventBusに送信、入力トランスフォーマーを使ってAmazon Q Developer in chat applicationsが受け取れる形式に変換してからSNSに送るという構成です。 Amazon Q Developer in chat applicationsが受け取れる形式はドキュメントによると以下のようになっています スキーマ: https://docs.aws.amazon.com/chatbot/latest/adminguide/custom-notifs.html サンプル: https://docs.aws.amazon.com/chatbot/latest/adminguide/sample-custom-action.html#custom-notifs-metadata { " version ": " 1.0 ", " source ": " custom ", " content ": { " title ": " IP Address Allowlist Request ", " description ": " We have received a request to allows list an IP address from 'sample@sample.com'. " } , " metadata ": { " additionalContext ": { " IPAddress ": " 192.168.0.1 " } } } これを参考に入力トランスフォーマーでLambdaの失敗イベントを変換します。例えば以下のような感じですね resource "aws_cloudwatch_event_target" "sns" { rule = "<snip>" target_id = "<snip>" arn = "<snip>" event_bus_name = "<snip>" input_transformer { input_paths = { id = "$.detail.requestContext.requestId" reason = "$.detail.responsePayload.errorMessage" } input_template = <<-EOT { "version": "1.0", "source": "custom", "id": "$ { var.function_name } -<id>", "content": { "textType": "client-markdown", "title": "Lambda Alert", "description": "$ { var.account_alias } アカウントで `<reason>` により失敗しました。ここに続けて何かお好きなメッセージ" } } EOT } } input_template 内の <id> や <reason> は input_paths で指定したJSONパスの値に置き換えられます。今回は投稿ごとにユニークなIDを付与したかったので、LambdaのリクエストIDを、失敗理由はLambdaのエラーメッセージをそれぞれ指定するようにしました。 このあたりは細かい調整が結構発生したので、試行錯誤しやすいWebコンソールからポチポチやってTerraformに取り込むほうが楽かもと思いました。 Push型のアーキテクチャを採用した理由 今回はPush型のアーキテクチャを採用することにしました。最小権限の原則を考えるとPull型の権限の広さは避けたかったのが大きな理由です。 このようなデータ収集を行う場合には、集約するアカウントでデータ収集コードを実行するPull型のアーキテクチャと、各アカウントがデータ収集を行い、一箇所に集約するPush型のアーキテクチャのどちらかを選択することになると考えています。例えば以下のようなイメージですね。 pull push このどちらかをベースに実装していく必要がありますが、正直実装の重さはほぼ変わらないので、検討すべきは運用の楽さ、可用性、権限管理のしやすさなどになりそうです。それぞれ考えてみると Pull 型 運用の楽さ 集約するアカウントにだけLambdaやスケジューラーがあればよい 権限管理 集約するアカウントに、データ収集対象アカウントのCloudWatchを読み取る権限を与える必要がある 集約するアカウントが侵害されたときに、全アカウントの監視情報が漏えいするおそれがある 可用性 集約するアカウントのLambdaの障害が全てのデータ収集に影響する Push 型 運用の楽さ 各アカウントが独立してデータ収集を行うため、Lambdaやスケジューラーの設定は各アカウントで行う必要がある 権限管理 権限の範囲をアカウント単位に限定しやすい 可用性 あるアカウントでLambdaに障害が起きても、他のアカウントのデータ収集への影響が出にくい やはりいいところ悪いところがありますが、権限管理に注目するとPush型のほうが良さそうに見えました。書き込み先S3バケットの権限(クロスアカウントな権限)はPush型でも必要になりますが、アカウントごとにS3 prefixを分け、ポリシーで制御するなどしておけば影響範囲は狭めることができます。 Pull型ではダッシュボードの取得権限は個別に制限できますが、GetMetricWidgetImageの権限はメトリクス単位に制限できません。そのため、各アカウントの読み取り権限を集約先に持たせないPush型を選んだ感じです。 まとめとネクストアクション 今回はCloudWatch ダッシュボードのスナップショットを保存する仕組みを EventBridge Scheduler / Lambda / S3 を使って再構築した話を書きました。 これにより、以前からの課題を解決し、運用負荷を下げることができました。 ネクストアクションとしては次のものがあります CloudWatch OAM経由を試してみる CloudWatch OAMを使ったクロスアカウントなダッシュボードを構築している部分がある これ経由ならPull型でも権限を狭められるかもしれない? メトリクスデータ自体をアーカイブしてみる 現状は GetMetricWidgetImage で取得した画像をアーカイブするだけ これまでのアーカイブとの互換性維持のため 生のメトリクスデータを埋め込んだHTMLでも良い気はしている 試してみて新しい知見があればまた記事にしたいと思います。以上です。 *1 : https://aws.amazon.com/jp/cloudwatch/faqs/
はじめに # これは、「面白そう」と感じたアーキテクチャや技術スタックを、まずはあれこれ試しながら作ってみたプロトタイピングの記録です。 前回は、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検証をより安全・確実に運用できるようになりました。















