Vercel から起動した SageMaker Real-time Endpoint の停止忘れを自動削除で防ぐ

Vercel から起動した SageMaker Real-time Endpoint の停止忘れを自動削除で防ぐ

Vercel 上の Next.js アプリから起動した SageMaker Real-time Endpoint を、ブラウザ終了後も自動削除できました。追加検証では停止期限を 1,800 秒とし、モデルが利用可能になってから約 30 分を確保できました。
2026.07.31

はじめに

Vercel にデプロイした Next.js アプリから SageMaker Real-time Endpoint を必要なときだけ起動する構成では、常時起動の GPU 費用を避けられます。一方、利用者が停止操作を忘れると、ブラウザを閉じた後も Endpoint は動き続けます。

Vercel 上の Next.js 検証用アプリで self-hosted モデルを操作する画面

そこで、ブラウザを閉じ、Vercel Functions の実行が終わった後も、AWS 側で Endpoint を自動削除する仕組みを追加しました。本記事では、その手順を紹介します。

対象読者

  • Vercel にデプロイした Next.js アプリから SageMaker Real-time Endpoint を必要なときだけ起動したい方
  • 常時起動の GPU 費用を避けたい方
  • ブラウザやサーバーレス関数へ停止処理を依存させたくない方

検証環境

  • モデル: lmstudio-community/Qwen3.6-35B-A3B-GGUF
  • ファイル: Qwen3.6-35B-A3B-Q4_K_M.gguf
  • インスタンス: ml.g5.2xlarge
  • リージョン: us-west-2
  • Terraform: 1.15.8
  • AWS Provider: 6.49.0
  • 監視間隔: 1 分

参考資料

検証構成

Next.js 側では、Endpoint の起動、状態確認、ストリーミング生成、停止を別のリクエストに分けています。モデルの起動中に、生成用の HTTP 接続を維持する必要はありません。

Next.js 検証用アプリで SageMaker Endpoint の状態を確認する画面

自動停止はアプリケーション側のタイマーではなく、EventBridge Scheduler から 1 分ごとに Lambda を呼ぶ方式にしました。

ブラウザのタイマーでは、タブを閉じた後の停止を保証できません。 Vercel Functions で待機を続ける方法も、関数の終了や再デプロイの影響を受けます。AWS 側で定期的に状態を照合すれば、利用者の端末とアプリケーションサーバーが終了しても処理を続けられます。

停止期限は、SageMaker が返す CreationTime に最大セッション時間を加えて求めます。同じ名前で Endpoint を作り直しても CreationTime が更新されるため、以前の作成時刻を引き継ぎません。

自動停止の実装

Lambda は DescribeEndpoint を呼び、現在時刻と CreationTime の差から経過時間を求めます。最大セッション時間へ到達していなければ、Endpoint を維持します。

lambda_function.py
description = client.describe_endpoint(EndpointName=endpoint_name)
status = description["EndpointStatus"]
creation_time = _as_utc(description["CreationTime"])
age_seconds = max(0, int((now - creation_time).total_seconds()))

if status == "Deleting":
    return {"action": "deletion_pending"}
if age_seconds < max_session_seconds:
    return {"action": "retained"}

期限へ到達した場合は DeleteEndpoint を呼びます。手動停止と重なって Endpoint がすでに存在しなければ、削除済みとして扱います。

lambda_function.py
try:
    client.delete_endpoint(EndpointName=endpoint_name)
except Exception as error:
    if is_missing_error(error):
        return {"action": "absent_after_race"}
    raise
return {"action": "delete_requested"}

定期照合の開始時点で Endpoint が存在しない場合も正常終了します。Deleting の場合は削除を重ねません。DeleteEndpoint がそれ以外の理由で失敗した場合は例外を返すため、Lambda の非同期実行による再試行または次の定期実行で再び照合できます。

EventBridge Scheduler は 1 分間隔にしました。期限到達から削除要求まで、最大で約 1 分遅れることを許容します。

main.tf
resource "aws_scheduler_schedule" "reconciler" {
  schedule_expression = "rate(1 minute)"
  state               = "ENABLED"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = aws_lambda_function.reconciler.arn
    role_arn = aws_iam_role.scheduler.arn

    retry_policy {
      maximum_event_age_in_seconds = 60
      maximum_retry_attempts       = 2
    }
  }
}

Lambda の権限は、対象 Endpoint の DescribeEndpointDeleteEndpoint、専用 CloudWatch Logs だけに限定しました。

実行結果

最初に、Qwen3.6 の Endpoint を作成してから 900 秒後を停止期限としました。正常生成と client cancel を確認した後、ブラウザを閉じました。以降、ローカルの検証処理から DeleteEndpoint は呼んでいません。

項目 結果
Endpoint 作成から ready 312.363 秒
warm TTFT 1.838 秒
正常生成の完了 2.625 秒
client cancel 0.612 秒
自動停止期限 Endpoint 作成から 900 秒
Lambda の削除要求 Endpoint 作成から 914 秒
Endpoint 不在 停止期限から 20.488 秒後
検証処理からの DeleteEndpoint 0 回
保守的な GPU 費用 約 0.387 USD

ブラウザを閉じた後も Scheduler と Lambda は動作を続け、Endpoint は期限後に削除されました。

分かったこと

その後も何度か試したところ、Endpoint の作成時刻を期限の起点にすると、モデルを利用できる時間が起動にかかる時間に左右されることが分かりました。

別の検証では 1,800 秒後を停止期限としたのですが、 Endpoint が 1,800 秒を超えて Creating のままとなり、DeleteEndpoint は次のエラーで拒否されました。

Cannot update in-progress endpoint

Endpoint が InService へ移行した後、作成から 2,114 秒で Lambda が削除を要求しました。作成から Endpoint の不在を確認するまで 2,175 秒でした。

起動完了後を基準にした追加検証

そこで、モデルを利用できる時間を一定にするため、Lambda が最初に InService を観測した時刻を期限の起点とする検証を追加で行いました。

起点となる時刻、停止期限、Endpoint の世代は DynamoDB へ保存しました。SageMaker の CreationTime を世代として記録し、同じ名前で作り直した Endpoint が以前の停止期限を引き継がないようにしています。

追加検証では、停止期限を最初の InService 観測から 1,800 秒後に設定しました。Lambda は観測から 1,859 秒後に削除を要求し、約 6 秒後に Endpoint が不在になりました。利用可能になってから約 30 分を確保できました。

まとめ

EventBridge Scheduler と Lambda を使い、ブラウザを閉じた後も SageMaker Real-time Endpoint を自動削除できました。SageMaker の CreationTime から期限を計算する構成では、停止期限を保存するデータストアも不要です。

モデルを利用できる時間を一定にするため、最初の InService 観測を期限の起点にする方式も検証しました。停止期限を 1,800 秒に設定した追加検証では、利用可能になってから約 30 分を確保した後に自動削除できました。

付録

付録には、作成時刻基準と InService 基準の最小実装を掲載します。

再現条件

項目
Repository lmstudio-community/Qwen3.6-35B-A3B-GGUF
Revision c7ed48a94a6a082167b768bab350745695824e0a
File Qwen3.6-35B-A3B-Q4_K_M.gguf
Size 21,166,757,728 bytes
SHA-256 4ac6a06bce551257267f49ad2226f8671a22519ccc1a4dde9d5b433d1f2a410d
llama.cpp 86a9c79f866799eb0e7e89c03578ccfbcc5d808e
Region us-west-2
Instance ml.g5.2xlarge
Context 16,384
Parallel slot 1

作成時刻基準の Lambda

lambda_function.py
lambda_function.py
import json
import os
from datetime import datetime, timezone

def _as_utc(value):
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)

def _is_missing_endpoint(error):
    details = getattr(error, "response", {}).get("Error", {})
    return (
        details.get("Code") == "ResourceNotFound"
        or (
            details.get("Code") == "ValidationException"
            and "Could not find endpoint" in details.get("Message", "")
        )
    )

def reconcile_endpoint(
    *,
    client,
    endpoint_name,
    max_session_seconds,
    now,
    is_missing_error=_is_missing_endpoint,
):
    try:
        description = client.describe_endpoint(EndpointName=endpoint_name)
    except Exception as error:
        if is_missing_error(error):
            return {"action": "absent"}
        raise

    status = description["EndpointStatus"]
    creation_time = _as_utc(description["CreationTime"])
    age_seconds = max(0, int((now - creation_time).total_seconds()))
    result = {
        "endpoint_status": status,
        "age_seconds": age_seconds,
        "max_session_seconds": max_session_seconds,
    }

    if status == "Deleting":
        return {**result, "action": "deletion_pending"}
    if age_seconds < max_session_seconds:
        return {**result, "action": "retained"}

    try:
        client.delete_endpoint(EndpointName=endpoint_name)
    except Exception as error:
        if is_missing_error(error):
            return {**result, "action": "absent_after_race"}
        raise
    return {**result, "action": "delete_requested"}

def handler(event, context):
    del event, context
    import boto3

    result = reconcile_endpoint(
        client=boto3.client("sagemaker"),
        endpoint_name=os.environ["ENDPOINT_NAME"],
        max_session_seconds=int(os.environ["MAX_SESSION_SECONDS"]),
        now=datetime.now(timezone.utc),
    )
    print(json.dumps(result, sort_keys=True, separators=(",", ":")))
    return result

作成時刻基準の Terraform

versions.tf
versions.tf
terraform {
  required_version = ">= 1.15.0, < 2.0.0"

  required_providers {
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.7"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.49"
    }
  }
}
variables.tf
variables.tf
variable "endpoint_name" {
  type = string
}

variable "name_prefix" {
  type    = string
  default = "sagemaker-autostop-poc"
}

variable "max_session_seconds" {
  type    = number
  default = 900
}
main.tf
main.tf
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}

locals {
  function_name    = "${var.name_prefix}-session-autostop"
  endpoint_arn     = "arn:${data.aws_partition.current.partition}:sagemaker:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:endpoint/${var.endpoint_name}"
  lambda_log_group = "/aws/lambda/${local.function_name}"
}

data "archive_file" "lambda" {
  type        = "zip"
  source_file = "${path.module}/lambda_function.py"
  output_path = "${path.module}/.terraform/${local.function_name}.zip"
}

resource "aws_cloudwatch_log_group" "lambda" {
  name              = local.lambda_log_group
  retention_in_days = 14
}

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda" {
  name               = "${local.function_name}-execution"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

data "aws_iam_policy_document" "lambda" {
  statement {
    actions = [
      "sagemaker:DeleteEndpoint",
      "sagemaker:DescribeEndpoint",
    ]
    resources = [local.endpoint_arn]
  }

  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
  }
}

resource "aws_iam_role_policy" "lambda" {
  name   = "${local.function_name}-policy"
  role   = aws_iam_role.lambda.id
  policy = data.aws_iam_policy_document.lambda.json
}

resource "aws_lambda_function" "reconciler" {
  function_name                  = local.function_name
  role                           = aws_iam_role.lambda.arn
  handler                        = "lambda_function.handler"
  runtime                        = "python3.13"
  architectures                  = ["arm64"]
  filename                       = data.archive_file.lambda.output_path
  source_code_hash               = data.archive_file.lambda.output_base64sha256
  memory_size                    = 128
  timeout                        = 30
  reserved_concurrent_executions = 1

  environment {
    variables = {
      ENDPOINT_NAME       = var.endpoint_name
      MAX_SESSION_SECONDS = tostring(var.max_session_seconds)
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda,
    aws_iam_role_policy.lambda,
  ]
}

resource "aws_lambda_function_event_invoke_config" "reconciler" {
  function_name                = aws_lambda_function.reconciler.function_name
  maximum_event_age_in_seconds = 60
  maximum_retry_attempts       = 2
}

data "aws_iam_policy_document" "scheduler_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["scheduler.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "scheduler" {
  name               = "${var.name_prefix}-session-scheduler"
  assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
}

data "aws_iam_policy_document" "scheduler" {
  statement {
    actions   = ["lambda:InvokeFunction"]
    resources = [aws_lambda_function.reconciler.arn]
  }
}

resource "aws_iam_role_policy" "scheduler" {
  name   = "${var.name_prefix}-session-scheduler-policy"
  role   = aws_iam_role.scheduler.id
  policy = data.aws_iam_policy_document.scheduler.json
}

resource "aws_scheduler_schedule_group" "reconciler" {
  name = "${var.name_prefix}-session"
}

resource "aws_scheduler_schedule" "reconciler" {
  name       = "${var.name_prefix}-session-reconcile"
  group_name = aws_scheduler_schedule_group.reconciler.name
  state      = "ENABLED"

  flexible_time_window {
    mode = "OFF"
  }

  schedule_expression = "rate(1 minute)"

  target {
    arn      = aws_lambda_function.reconciler.arn
    role_arn = aws_iam_role.scheduler.arn

    retry_policy {
      maximum_event_age_in_seconds = 60
      maximum_retry_attempts       = 2
    }
  }
}

resource "aws_lambda_permission" "scheduler" {
  statement_id  = "AllowEventBridgeScheduler"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.reconciler.function_name
  principal     = "scheduler.amazonaws.com"
  source_arn    = aws_scheduler_schedule.reconciler.arn
}

InService 基準の Lambda

InService 基準の lambda_function.py
lambda_function.py
import json
import os
from datetime import datetime, timedelta, timezone

def _as_utc(value):
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)

def _generation(creation_time):
    return _as_utc(creation_time).isoformat()

def _is_missing_endpoint(error):
    details = getattr(error, "response", {}).get("Error", {})
    return (
        details.get("Code") == "ResourceNotFound"
        or (
            details.get("Code") == "ValidationException"
            and "Could not find endpoint" in details.get("Message", "")
        )
    )

class DynamoDbStateStore:
    def __init__(self, client, table_name):
        self.client = client
        self.table_name = table_name

    def initialize_ready(
        self,
        *,
        endpoint_name,
        generation,
        ready_at,
        deadline_at,
    ):
        try:
            self.client.put_item(
                TableName=self.table_name,
                Item={
                    "EndpointName": {"S": endpoint_name},
                    "Generation": {"S": generation},
                    "ReadyAt": {"S": ready_at.isoformat()},
                    "DeadlineAt": {"S": deadline_at.isoformat()},
                },
                ConditionExpression=(
                    "attribute_not_exists(#name) "
                    "OR #generation <> :generation"
                ),
                ExpressionAttributeNames={
                    "#name": "EndpointName",
                    "#generation": "Generation",
                },
                ExpressionAttributeValues={
                    ":generation": {"S": generation},
                },
            )
        except self.client.exceptions.ConditionalCheckFailedException:
            pass

    def get(self, endpoint_name):
        response = self.client.get_item(
            TableName=self.table_name,
            Key={"EndpointName": {"S": endpoint_name}},
            ConsistentRead=True,
        )
        item = response.get("Item")
        if not item:
            return None
        return {
            "generation": item["Generation"]["S"],
            "ready_at": datetime.fromisoformat(item["ReadyAt"]["S"]),
            "deadline_at": datetime.fromisoformat(item["DeadlineAt"]["S"]),
        }

def reconcile_endpoint(
    *,
    client,
    state_store,
    endpoint_name,
    max_session_seconds,
    now,
    is_missing_error=_is_missing_endpoint,
):
    try:
        description = client.describe_endpoint(EndpointName=endpoint_name)
    except Exception as error:
        if is_missing_error(error):
            return {"action": "absent"}
        raise

    status = description["EndpointStatus"]
    generation = _generation(description["CreationTime"])

    if status == "Deleting":
        return {"action": "deletion_pending"}
    if status != "InService":
        return {"action": "provisioning", "endpoint_status": status}

    state_store.initialize_ready(
        endpoint_name=endpoint_name,
        generation=generation,
        ready_at=now,
        deadline_at=now + timedelta(seconds=max_session_seconds),
    )
    state = state_store.get(endpoint_name)
    if state is None or state["generation"] != generation:
        return {"action": "stale_generation"}
    if now < state["deadline_at"]:
        return {"action": "retained"}

    try:
        current = client.describe_endpoint(EndpointName=endpoint_name)
    except Exception as error:
        if is_missing_error(error):
            return {"action": "absent_after_race"}
        raise

    if _generation(current["CreationTime"]) != generation:
        return {"action": "stale_generation"}
    if current["EndpointStatus"] != "InService":
        return {"action": "status_changed_before_delete"}

    try:
        client.delete_endpoint(EndpointName=endpoint_name)
    except Exception as error:
        if is_missing_error(error):
            return {"action": "absent_after_race"}
        raise
    return {"action": "delete_requested"}

def handler(event, context):
    del event, context
    import boto3

    result = reconcile_endpoint(
        client=boto3.client("sagemaker"),
        state_store=DynamoDbStateStore(
            boto3.client("dynamodb"),
            os.environ["STATE_TABLE_NAME"],
        ),
        endpoint_name=os.environ["ENDPOINT_NAME"],
        max_session_seconds=int(os.environ["MAX_SESSION_SECONDS"]),
        now=datetime.now(timezone.utc),
    )
    print(json.dumps(result, sort_keys=True, separators=(",", ":")))
    return result

InService 基準の Terraform

InService 基準の variables.tf
variables.tf
variable "endpoint_name" {
  type = string
}

variable "name_prefix" {
  type    = string
  default = "sagemaker-ready-autostop-poc"
}

variable "max_session_seconds" {
  type    = number
  default = 1800
}
InService 基準の main.tf
main.tf
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}

locals {
  function_name    = "${var.name_prefix}-session-autostop"
  endpoint_arn     = "arn:${data.aws_partition.current.partition}:sagemaker:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:endpoint/${var.endpoint_name}"
  lambda_log_group = "/aws/lambda/${local.function_name}"
}

data "archive_file" "lambda" {
  type        = "zip"
  source_file = "${path.module}/lambda_function.py"
  output_path = "${path.module}/.terraform/${local.function_name}.zip"
}

resource "aws_dynamodb_table" "session" {
  name         = "${var.name_prefix}-session"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "EndpointName"

  attribute {
    name = "EndpointName"
    type = "S"
  }

  server_side_encryption {
    enabled = true
  }
}

resource "aws_cloudwatch_log_group" "lambda" {
  name              = local.lambda_log_group
  retention_in_days = 14
}

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda" {
  name               = "${local.function_name}-execution"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

data "aws_iam_policy_document" "lambda" {
  statement {
    actions = [
      "sagemaker:DeleteEndpoint",
      "sagemaker:DescribeEndpoint",
    ]
    resources = [local.endpoint_arn]
  }

  statement {
    actions = [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
    ]
    resources = [aws_dynamodb_table.session.arn]
  }

  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
  }
}

resource "aws_iam_role_policy" "lambda" {
  name   = "${local.function_name}-policy"
  role   = aws_iam_role.lambda.id
  policy = data.aws_iam_policy_document.lambda.json
}

resource "aws_lambda_function" "reconciler" {
  function_name                  = local.function_name
  role                           = aws_iam_role.lambda.arn
  handler                        = "lambda_function.handler"
  runtime                        = "python3.13"
  architectures                  = ["arm64"]
  filename                       = data.archive_file.lambda.output_path
  source_code_hash               = data.archive_file.lambda.output_base64sha256
  memory_size                    = 128
  timeout                        = 30
  reserved_concurrent_executions = 1

  environment {
    variables = {
      ENDPOINT_NAME       = var.endpoint_name
      MAX_SESSION_SECONDS = tostring(var.max_session_seconds)
      STATE_TABLE_NAME    = aws_dynamodb_table.session.name
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda,
    aws_iam_role_policy.lambda,
  ]
}

resource "aws_lambda_function_event_invoke_config" "reconciler" {
  function_name                = aws_lambda_function.reconciler.function_name
  maximum_event_age_in_seconds = 60
  maximum_retry_attempts       = 2
}

data "aws_iam_policy_document" "scheduler_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["scheduler.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "scheduler" {
  name               = "${var.name_prefix}-session-scheduler"
  assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
}

data "aws_iam_policy_document" "scheduler" {
  statement {
    actions   = ["lambda:InvokeFunction"]
    resources = [aws_lambda_function.reconciler.arn]
  }
}

resource "aws_iam_role_policy" "scheduler" {
  name   = "${var.name_prefix}-session-scheduler-policy"
  role   = aws_iam_role.scheduler.id
  policy = data.aws_iam_policy_document.scheduler.json
}

resource "aws_scheduler_schedule_group" "reconciler" {
  name = "${var.name_prefix}-session"
}

resource "aws_scheduler_schedule" "reconciler" {
  name       = "${var.name_prefix}-session-reconcile"
  group_name = aws_scheduler_schedule_group.reconciler.name
  state      = "ENABLED"

  flexible_time_window {
    mode = "OFF"
  }

  schedule_expression = "rate(1 minute)"

  target {
    arn      = aws_lambda_function.reconciler.arn
    role_arn = aws_iam_role.scheduler.arn

    retry_policy {
      maximum_event_age_in_seconds = 60
      maximum_retry_attempts       = 2
    }
  }
}

resource "aws_lambda_permission" "scheduler" {
  statement_id  = "AllowEventBridgeScheduler"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.reconciler.function_name
  principal     = "scheduler.amazonaws.com"
  source_arn    = aws_scheduler_schedule.reconciler.arn
}

この記事をシェアする

関連記事