個人受託のLP案件でAWSコスト管理を仕組み化した話

|
AWS Budget Lambda Cost 監視 CloudFront S3 個人受託

個人受託でS3+CloudFront構成のLPを運用するときのAWSコスト管理。BudgetアラートとLambda自動停止で「気づいて止める」仕組みを構築した記録です。

AWS課金事故の発生パターン

「月末に請求書を開いたら想定の10倍だった」——LP規模の案件でも、特定のパターンで課金は膨らみます。

LP案件で起きやすいパターン:

  パターンA: DDoS・クローラーによるトラフィック急増
    CloudFront 経由でも大量リクエストは転送量課金に直撃する
    API Gateway + Lambda 構成なら実行回数課金も加算される
    → 悪意あるクローラーで数日で数万円の課金になることがある

  パターンB: S3バケットの誤設定
    CloudFront を経由しない S3 直接アクセスが発生すると
    転送量課金が想定外に積み上がる
    → OAC(Origin Access Control)で塞いでいないケースで起きる

  パターンC: Lambda の暴走
    問い合わせフォームの Lambda が無限ループ・無限再帰すると
    数時間でそれなりの課金になる
    → タイムアウトと同時実行数の上限を設定しておかないと止まらない

LP案件は構成がシンプルな分、個々のサービスの単価が低いですが「量でやられる」パターンがあります。


構成の前提

個人受託でよく組む LP 構成はだいたいこうなっています。

[ユーザー]
    ↓
CloudFront → S3(HTML/CSS/JS/画像)
    │
    └→ API Gateway → Lambda → SES(問い合わせフォーム)
  • S3 + CloudFront: LP 本体の静的ファイル配信
  • API Gateway + Lambda: 問い合わせフォームのバックエンド
  • SES: フォームからのメール送信
  • Route53: 独自ドメイン

通常運用なら月 $1〜$15 程度に収まる構成ですが、異常時に何も仕込んでいないと気づいたときには手遅れです。


防御の全体構成

単一の対策ではなく、検知・通知・自動停止の3層で構成します。

防御の3層構成:

  Layer 1: 予防(Budget アラート)
  ─────────────────────────────────
  月次の支出を監視
  50% / 80% / 100% の3段階でアラート
  → SNS トピックに通知を送信

  Layer 2: 通知(SNS → メール)
  ─────────────────────────────────
  SNS サブスクリプションでメール通知

  Layer 3: 自動停止(SNS → Lambda)
  ─────────────────────────────────
  Budget の 100% 超過通知を Lambda がトリガー
  CloudFront のディストリビューションを Disabled に
  API Gateway のスロットリングを 0 に設定
  Lambda の同時実行数を 0 に設定

Budget アラートの設定

Terraform で管理する場合のコードは下記の通り。( AWS コンソールからも設定できます。)

# SNS トピック(通知ハブ)
resource "aws_sns_topic" "cost_alert" {
  name = "${var.project_name}-cost-alert"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.cost_alert.arn
  protocol  = "email"
  endpoint  = var.alert_email
}

resource "aws_sns_topic_subscription" "auto_stop_lambda" {
  topic_arn = aws_sns_topic.cost_alert.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.auto_stop.arn
}

resource "aws_lambda_permission" "allow_sns" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.auto_stop.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.cost_alert.arn
}

# Budget の設定
resource "aws_budgets_budget" "monthly" {
  name         = "${var.project_name}-monthly"
  budget_type  = "COST"
  limit_amount = tostring(var.monthly_budget_usd)
  limit_unit   = "USD"
  time_unit    = "MONTHLY"

  # 50% 到達: 早期の異常検知
  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 50
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
  }

  # 80% 到達: 警告メール
  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 80
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
  }

  # 100% 到達: 自動停止トリガー
  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 100
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alert.arn]
  }
}

LP 案件では月次予算を $10〜$20 に設定することが多いです。通常運用が月数ドルなので、50% の時点($5〜$10)で既に異常です。100% を待つと手遅れになることがあります。

Budget はアカウントあたり2件まで無料です(3件目以降は $0.02/budget/日)。1つの Budget に通知をいくつ載せても料金は変わらないので、上の 50/80/100% は1 Budget 内に集約しています。


Lambda の自動停止ロジック

100% 超過の SNS 通知を受けた Lambda が、CloudFront・API Gateway・Lambda の3サービスを停止します。LP 案件は EC2 や RDS を使わないので、止める対象はこの3つです。

# auto_stop.py

import boto3
import json
import logging
import os

logger = logging.getLogger()
logger.setLevel(logging.INFO)

AWS_REGION   = os.environ.get("AWS_REGION", "ap-northeast-1")
PROJECT_NAME = os.environ.get("PROJECT_NAME", "")
DRY_RUN      = os.environ.get("DRY_RUN", "true").lower() == "true"

cf        = boto3.client("cloudfront")
apigateway = boto3.client("apigateway", region_name=AWS_REGION)
lambda_client = boto3.client("lambda", region_name=AWS_REGION)


def stop_cloudfront():
    """タグ auto-stop=enabled の CloudFront ディストリビューションを Disabled にする"""
    paginator = cf.get_paginator("list_distributions")
    for page in paginator.paginate():
        items = page.get("DistributionList", {}).get("Items", [])
        for dist in items:
            dist_id = dist["Id"]
            tags = cf.list_tags_for_resource(
                Resource=dist["ARN"]
            ).get("Tags", {}).get("Items", [])

            tag_map = {t["Key"]: t["Value"] for t in tags}
            if tag_map.get("protect") == "true":
                logger.info(f"PROTECTED (skip): CloudFront {dist_id}")
                continue
            if tag_map.get("auto-stop") != "enabled":
                logger.info(f"NO auto-stop tag (skip): CloudFront {dist_id}")
                continue

            logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Disabling CloudFront: {dist_id}")
            if not DRY_RUN:
                config = cf.get_distribution_config(Id=dist_id)
                etag = config["ETag"]
                dist_config = config["DistributionConfig"]
                dist_config["Enabled"] = False
                cf.update_distribution(
                    DistributionConfig=dist_config,
                    Id=dist_id,
                    IfMatch=etag
                )


def stop_api_gateway():
    """タグ auto-stop=enabled の API Gateway ステージのスロットリングを 0 にする"""
    apis = apigateway.get_rest_apis().get("items", [])
    for api in apis:
        api_id = api["id"]
        stages = apigateway.get_stages(restApiId=api_id).get("item", [])
        for stage in stages:
            stage_name = stage["stageName"]
            tags = stage.get("tags", {})

            if tags.get("protect") == "true":
                logger.info(f"PROTECTED (skip): API Gateway {api_id}/{stage_name}")
                continue
            if tags.get("auto-stop") != "enabled":
                logger.info(f"NO auto-stop tag (skip): API Gateway {api_id}/{stage_name}")
                continue

            logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Throttling API Gateway: {api_id}/{stage_name}")
            if not DRY_RUN:
                apigateway.update_stage(
                    restApiId=api_id,
                    stageName=stage_name,
                    patchOperations=[
                        {"op": "replace", "path": "/*/*/throttling/rateLimit",  "value": "0"},
                        {"op": "replace", "path": "/*/*/throttling/burstLimit", "value": "0"},
                    ]
                )


def stop_lambda_functions():
    """タグ auto-stop=enabled の Lambda 関数の同時実行数を 0 にする"""
    paginator = lambda_client.get_paginator("list_functions")
    for page in paginator.paginate():
        for fn in page["Functions"]:
            fn_name = fn["FunctionName"]
            tags = lambda_client.list_tags(Resource=fn["FunctionArn"]).get("Tags", {})

            if tags.get("protect") == "true":
                logger.info(f"PROTECTED (skip): Lambda {fn_name}")
                continue
            if tags.get("auto-stop") != "enabled":
                logger.info(f"NO auto-stop tag (skip): Lambda {fn_name}")
                continue

            logger.info(f"{'[DRY-RUN] ' if DRY_RUN else ''}Throttling Lambda: {fn_name}")
            if not DRY_RUN:
                lambda_client.put_function_concurrency(
                    FunctionName=fn_name,
                    ReservedConcurrentExecutions=0
                )


def lambda_handler(event, context):
    logger.info(f"Triggered. DRY_RUN={DRY_RUN}")
    logger.info(f"Event: {json.dumps(event)}")

    stop_cloudfront()
    stop_api_gateway()
    stop_lambda_functions()

    return {"statusCode": 200, "body": "Auto-stop completed"}

タグ設計のポイント:

# 停止対象の Lambda(問い合わせフォーム処理)
resource "aws_lambda_function" "contact_form" {
  function_name = "${var.project_name}-contact"
  # ...

  tags = {
    auto-stop   = "enabled"   # 予算超過時に同時実行数を 0 にする
    protect     = "false"
    ManagedBy   = "terraform"
  }
}
判定フロー:

  リソースを発見
      ↓
  protect=true ?
  ├── YES → スキップ
  └── NO  ↓
         auto-stop=enabled ?
         ├── NO  → スキップ
         └── YES → 停止(DRY_RUN=true なら実際には止めない)

Lambda の IAM ロール

Lambda に付与する権限は必要最小限にします。削除系の権限は含めません。

data "aws_iam_policy_document" "auto_stop_policy" {
  statement {
    effect  = "Allow"
    actions = ["cloudfront:GetDistribution", "cloudfront:GetDistributionConfig",
               "cloudfront:UpdateDistribution", "cloudfront:ListDistributions",
               "cloudfront:ListTagsForResource"]
    resources = ["*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["apigateway:GET", "apigateway:PATCH"]
    resources = ["arn:aws:apigateway:*::*"]
  }

  statement {
    effect  = "Allow"
    actions = ["lambda:ListFunctions", "lambda:PutFunctionConcurrency",
               "lambda:ListTags"]
    resources = ["*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
    resources = ["arn:aws:logs:*:*:*"]
  }
}

Dry-run で動作を確認してから本番有効化

初回デプロイ時は DRY_RUN=true で動作確認します。

# Lambda を手動でテスト実行
aws lambda invoke \
  --function-name my-project-auto-stop \
  --payload '{"Records":[{"Sns":{"Message":"test"}}]}' \
  --cli-binary-format raw-in-base64-out \
  response.json

# CloudWatch Logs で対象リソースを確認
aws logs tail /aws/lambda/my-project-auto-stop --follow
# [DRY-RUN] Disabling CloudFront: E1ABCD2EFGHIJ
# NO auto-stop tag (skip): Lambda my-project-another-fn
# [DRY-RUN] Throttling Lambda: my-project-contact

「止まるべきリソースだけが対象になっている」ことを確認してから DRY_RUN=false に変更してデプロイします。


S3 直接アクセスのブロック

CloudFront を経由しない S3 への直接アクセスを OAC(Origin Access Control)で塞ぎます。コスト管理というより構成の基本ですが、転送量課金の無駄を防ぐ効果もあります。

resource "aws_cloudfront_origin_access_control" "s3" {
  name                              = "${var.project_name}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_s3_bucket_policy" "lp" {
  bucket = aws_s3_bucket.lp.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = {
        Service = "cloudfront.amazonaws.com"
      }
      Action   = "s3:GetObject"
      Resource = "${aws_s3_bucket.lp.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.lp.arn
        }
      }
    }]
  })
}

まとめ

対策 カバーするリスク コスト
Budget アラート(50/80/100%) 月次異常支出の段階的検知 無料
Budget → Lambda 自動停止 上限超過後のリソース暴走 Lambda 実行コスト(月 $0.001 以下)
OAC による S3 直接アクセス制限 S3 転送量の不正利用 無料
Lambda タイムアウト・同時実行数制限 無限ループ事故の上限設定 無料
Cost Explorer 月1チェック 想定外課金の早期発見 無料

LP 規模の案件であっても、コスト管理を人的プロセスに依存するのは設計として不十分です。最初に仕込んでおけばランニングコストはほぼゼロで、クライアント案件を安定して運用できます。

この記事をシェア

Twitter / X
記事一覧に戻る
🤖
Cloud Assistant
Llama 3.3 × AI Gateway

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋