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

そこで、ブラウザを閉じ、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 分
参考資料
- AWS: DeleteEndpoint
- AWS: Invoke a Lambda function on a schedule
- AWS: Invoking a Lambda function asynchronously
- AWS: RetryPolicy
- Hugging Face: lmstudio-community/Qwen3.6-35B-A3B-GGUF
検証構成
Next.js 側では、Endpoint の起動、状態確認、ストリーミング生成、停止を別のリクエストに分けています。モデルの起動中に、生成用の HTTP 接続を維持する必要はありません。

自動停止はアプリケーション側のタイマーではなく、EventBridge Scheduler から 1 分ごとに Lambda を呼ぶ方式にしました。
ブラウザのタイマーでは、タブを閉じた後の停止を保証できません。 Vercel Functions で待機を続ける方法も、関数の終了や再デプロイの影響を受けます。AWS 側で定期的に状態を照合すれば、利用者の端末とアプリケーションサーバーが終了しても処理を続けられます。
停止期限は、SageMaker が返す CreationTime に最大セッション時間を加えて求めます。同じ名前で Endpoint を作り直しても CreationTime が更新されるため、以前の作成時刻を引き継ぎません。
自動停止の実装
Lambda は DescribeEndpoint を呼び、現在時刻と CreationTime の差から経過時間を求めます。最大セッション時間へ到達していなければ、Endpoint を維持します。
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 がすでに存在しなければ、削除済みとして扱います。
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 分遅れることを許容します。
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 の DescribeEndpoint と DeleteEndpoint、専用 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
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
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
variable "endpoint_name" {
type = string
}
variable "name_prefix" {
type = string
default = "sagemaker-autostop-poc"
}
variable "max_session_seconds" {
type = number
default = 900
}
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
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
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
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
}



