2025-11-22 2025-11-22

概要

AWSのサービスを利用してWebアプリケーションの簡単な外形監視(外部監視)を行う

(イメージ)

graph LR A[Web Application] L[AWS Lambda] C{error検知} S[Amazon SES] M[Mail] L -. health_check(N分間隔) .-> A L --> C C -->|Yes| S S --> M

 https://xxx.xxx.xxx/health                 AWS Lambda
 +------------------+                       +-------------+
 | Web Application  |<---- health check ----| AWS Lambda  | (N分間隔)
 +------------------+                       +-------------+
                                                |
                                                |error検知時
                                                ↓                    Mail
                                            +-------------+         ┌───┐
                                            | Amazon SES  |  -----> │\_/│
                                            +-------------+         └───┘

目次

利用するサービス

機能AWSサービス
サーバレスアプリAWS Lambda(&EventBridge)
メール送信Amazon SES

開発環境

  • AWS CLI がインストールされている事
  • Python がインストールされている事

仕様

方針

  • 極力「シンプルに」「小さく」「速く(軽く)」なるように注力し、無料利用枠のみでの運用を目指す。(メモリ割り当ても最小の128MB)
  • 状態(稼働中、停止中、復帰など)は保持しない。なのでDBも使用しない。
  • ライブラリの利用も必要最低限とする(コールドスタート時のロードに係るオーバーヘッド削減)

基本仕様

  • 実装は python とする
  • 一定時間ごとに環境変数 SERVER_URL に設定された URL に GET リクエストを送信する
  • レスポンスが以下のいずれかの場合、通知メールを送信する
    • レスポンスステータスが200系でない場合
    • レスポンステキストに 環境変数 CHECK_RESPONSE_TEXT に設定されている文字列が含まれない場合
  • 通知先メールアドレスは、環境変数 NOTIFY_EMAIL_ADDRESS から取得する

動作間隔

  • AWS Lambda で動作する事を前提として、タイマートリガー(EventBridge)で 10分間隔で動作させる

その他

  • CloudFormation テンプレートを作成する ( template.yml として作成 )
  • AWSサービスデプロイ用のシェルを作成する ( x_deploy_aws.sh )
  • AWSサービス削除用のシェルを作成する ( x_remove_aws.sh )

通知メールの件名と本文

通知メールの件名、本文は以下の通りとする

項目説明
件名環境変数 NOTIFY_MAIL_TITLE から取得したもの
本文
アプリケーションが正常に動作していない可能性があります。
日時: {チェックした日時}
ステータス: {チェック時に取得したHTTPレスポンスステータス}

ファイル構成

+ .env                     ... 環境変数
+ src
    + lambda_function.py   ... メイン処理
+ requirements.txt         ... pythonの依存ライブラリ
+ template.yml             ... CloudFormationテンプレート
+ x_deploy_aws.sh          ... AWSリソースのデプロイ用シェル
+ x_remove_aws.sh          ... AWSリソースの削除用シェル

ソース

.env

# 監視対象
SERVER_URL=https://xxx.xxx.xxx/?healthcheck=1
CHECK_RESPONSE_TEXT=正常時にレスポンスに含まれる文字列

# 通知先(メールアドレスは AWS SESで設定済みのものを指定すること)
NOTIFY_SENDER_ADDRESS=xxx@xxx.com
NOTIFY_EMAIL_ADDRESS=xxx@xxx.com
NOTIFY_MAIL_TITLE=サーバ監視アラート(myapp)

# AWSアカウントID
ACCOUNT_ID=`aws sts get-caller-identity | grep Account | awk '{print $2}' | sed -e "s/[^0-9]//g"`
res=$?
if [ "${res}" != "0" ]; then
  echo "AWS Account IDの取得に失敗しました。「aws login」して下さい"
  exit 1
fi

# AWSリージョン
AWS_REGION=ap-northeast-1

# バケット名(世界で唯一である必要がある為、末尾にアカウントID等を付与しておく)
S3_BUCKET=myapp-monitor-${ACCOUNT_ID}

# CloudFormationスタック名
STACK_NAME=MyAppMonitorFunc

Lambdaの実装

src/lambda_function.py

import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Dict, Tuple
import urllib.error
import urllib.request

import boto3


JST = timezone(timedelta(hours=9))


@dataclass(frozen=True)
class MonitorConfig:
    server_url: str
    check_response_text: str
    notify_email: str
    notify_subject: str
    sender_email: str


class ConfigurationError(RuntimeError):
    """Raised when mandatory environment variables are missing."""


def _required_env(name: str) -> str:
    value = os.getenv(name)
    if value is None or value.strip() == "":
        raise ConfigurationError(f"Environment variable '{name}' is required.")
    return value.strip()


def load_config() -> MonitorConfig:
    notify_email = _required_env("NOTIFY_EMAIL_ADDRESS")
    sender_email = os.getenv("NOTIFY_SENDER_ADDRESS", notify_email).strip() or notify_email
    return MonitorConfig(
        server_url=_required_env("SERVER_URL"),
        check_response_text=_required_env("CHECK_RESPONSE_TEXT"),
        notify_email=notify_email,
        notify_subject=_required_env("NOTIFY_MAIL_TITLE"),
        sender_email=sender_email,
    )


def fetch_server_state(url: str, timeout: int = 10) -> Tuple[int, str]:
    request = urllib.request.Request(url=url, method="GET", headers={"User-Agent": "ServerMonitor/1.0"})
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            status_code = response.getcode()
            payload = response.read().decode("utf-8", errors="replace")
            return status_code, payload
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
        return exc.code, body
    except urllib.error.URLError as exc:
        raise RuntimeError(f"Failed to reach '{url}': {exc}") from exc


def needs_notification(status_code: int, body: str, expected_text: str) -> Tuple[bool, Dict[str, str]]:
    reasons = {}
    if status_code < 200 or status_code >= 300:
        print(f"[ERROR] status: {status_code}")
        reasons["status"] = f"Unexpected HTTP status: {status_code}"
    if expected_text not in body:
        print(f"[ERROR] text not found: {expected_text}")
        reasons["body"] = "Expected text not found in response body."
    if len(reasons) == 0:
        print("[INFO] Server is healthy.")
    return (len(reasons) > 0, reasons)


def send_notification(config: MonitorConfig, status_code: int, check_text: str, checked_at: datetime) -> Dict[str, str]:
    client = boto3.client("ses")
    checked_at_jst = checked_at.astimezone(JST)
    body = (
        "アプリケーションが正常に動作していない可能性があります。\n\n"
        f"日時: {checked_at_jst.isoformat()}\n"
        f"ステータス: {status_code}\n"
        f"チェックテキスト: {check_text}\n"
    )
    response = client.send_email(
        Source=config.sender_email,
        Destination={"ToAddresses": [config.notify_email]},
        Message={
            "Subject": {"Data": config.notify_subject, "Charset": "UTF-8"},
            "Body": {
                "Text": {"Data": body, "Charset": "UTF-8"}
            },
        },
    )
    return {"MessageId": response["MessageId"]}


def lambda_handler(event, context):
    config = load_config()
    checked_at = datetime.now(timezone.utc)
    try:
        status_code, body = fetch_server_state(config.server_url)
    except RuntimeError as exc:
        status_code = 0
        body = str(exc)
        notify = True
        details = {"connection": body}
    else:
        notify, details = needs_notification(status_code, body, config.check_response_text)

    result = {
        "checkedAt": checked_at.isoformat(),
        "statusCode": status_code,
        "notified": notify,
    }

    if notify:
        send_notification(config, status_code, config.check_response_text, checked_at)
        result["details"] = details
    return result


def _load_local_env(dotenv_path: str = ".env") -> None:
    if not os.path.exists(dotenv_path):
        return
    with open(dotenv_path, "r", encoding="utf-8") as handle:
        for line in handle:
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            key, sep, value = stripped.partition("=")
            if not sep:
                continue
            os.environ.setdefault(key, value)


if __name__ == "__main__":
    _load_local_env()
    outcome = lambda_handler({}, None)
    print(json.dumps(outcome, indent=2, ensure_ascii=False))

requirements.txt

boto3>=1.34,<2.0

CloudFormationテンプレート

template.yml

Transform: AWS::Serverless-2016-10-31
Description: Server monitoring Lambda function triggered every 10 minutes.

Parameters:
  ServerUrl:
    Type: String
    Description: HTTP endpoint that will be probed by the Lambda function.
  CheckResponseText:
    Type: String
    Description: Text that must appear in the response body.
  NotifyEmailAddress:
    Type: String
    Description: Destination email address for failure notifications.
  NotifyMailTitle:
    Type: String
    Description: Subject used for notification emails.

Resources:
  MonitorFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-monitor
      CodeUri: src/
      Handler: lambda_function.lambda_handler
      Runtime: python3.11
      MemorySize: 128
      Timeout: 30
      Description: Periodically checks a server and sends a notification when anomalies are detected.
      Environment:
        Variables:
          SERVER_URL: !Ref ServerUrl
          CHECK_RESPONSE_TEXT: !Ref CheckResponseText
          NOTIFY_EMAIL_ADDRESS: !Ref NotifyEmailAddress
          NOTIFY_MAIL_TITLE: !Ref NotifyMailTitle
      Policies:
        - Statement:
            Effect: Allow
            Action:
              - ses:SendEmail
              - ses:SendRawEmail
            Resource: "*"
      Events:
        EveryTenMinutes:
          Type: Schedule
          Properties:
            Name: !Sub ${AWS::StackName}-monitor-schedule
            Description: EventBridge rule that triggers the monitor Lambda every 10 minutes.
            Schedule: rate(10 minutes)
            Enabled: true

Outputs:
  MonitorFunctionName:
    Description: Name of the monitoring Lambda function
    Value: !Ref MonitorFunction

x_deploy_aws.sh

#!/usr/bin/env bash
set -euo pipefail

PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_ROOT"

if [ -f .env ]; then
  set -a
  # shellcheck disable=SC1091
  source .env
  set +a
fi

REQUIRED_VARS=(STACK_NAME S3_BUCKET SERVER_URL CHECK_RESPONSE_TEXT NOTIFY_EMAIL_ADDRESS NOTIFY_MAIL_TITLE)
for var in "${REQUIRED_VARS[@]}"; do
  if [ -z "${!var:-}" ]; then
    echo "Environment variable '$var' must be set before running deploy.sh" >&2
    exit 1
  fi
done

ensure_aws_login() {
  if ! aws sts get-caller-identity >/dev/null 2>&1; then
    echo "AWS CLI credentials are not configured or have expired. Aborting deploy." >&2
    exit 1
  fi
}

ensure_bucket_exists() {
  if aws s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
    return
  fi

  echo "S3 bucket '$S3_BUCKET' not found. Creating it now..."
  REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
  if [ -n "$REGION" ] && [ "$REGION" != "us-east-1" ]; then
    aws s3api create-bucket \
      --bucket "$S3_BUCKET" \
      --create-bucket-configuration LocationConstraint="$REGION"
  else
    aws s3api create-bucket --bucket "$S3_BUCKET"
  fi
}

ensure_aws_login
ensure_bucket_exists

PACKAGED_TEMPLATE="template.packaged.yml"

aws cloudformation package \
  --template-file template.yml \
  --s3-bucket "$S3_BUCKET" \
  --output-template-file "$PACKAGED_TEMPLATE"

aws cloudformation deploy \
  --template-file "$PACKAGED_TEMPLATE" \
  --stack-name "$STACK_NAME" \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    ServerUrl="$SERVER_URL" \
    CheckResponseText="$CHECK_RESPONSE_TEXT" \
    NotifyEmailAddress="$NOTIFY_EMAIL_ADDRESS" \
    NotifyMailTitle="$NOTIFY_MAIL_TITLE"

echo "Deployment complete. Stack: $STACK_NAME"

x_remove_aws.sh

#!/usr/bin/env bash
set -euo pipefail

PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_ROOT"

if [ -f .env ]; then
  set -a
  # shellcheck disable=SC1091
  source .env
  set +a
fi

REQUIRED_VARS=(STACK_NAME S3_BUCKET)
for var in "${REQUIRED_VARS[@]}"; do
  if [ -z "${!var:-}" ]; then
    echo "Environment variable '$var' must be set before running remove_aws.sh" >&2
    exit 1
  fi
fi

ensure_aws_login() {
  if ! aws sts get-caller-identity >/dev/null 2>&1; then
    echo "AWS CLI credentials are not configured or have expired. Aborting removal." >&2
    exit 1
  fi
}

stack_exists() {
  aws cloudformation describe-stacks --stack-name "$STACK_NAME" >/dev/null 2>&1
}

delete_stack() {
  if stack_exists; then
    echo "Deleting CloudFormation stack '$STACK_NAME'..."
    aws cloudformation delete-stack --stack-name "$STACK_NAME"
    echo "Waiting for stack deletion to complete..."
    aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"
    echo "Stack '$STACK_NAME' deleted."
  else
    echo "Stack '$STACK_NAME' does not exist or has already been deleted."
  fi
}

bucket_exists() {
  aws s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1
}

delete_bucket() {
  if ! bucket_exists; then
    echo "S3 bucket '$S3_BUCKET' does not exist or is inaccessible. Skipping bucket removal."
    return
  fi

  echo "Emptying S3 bucket '$S3_BUCKET'..."
  aws s3 rm "s3://$S3_BUCKET" --recursive >/dev/null 2>&1 || true
  echo "Deleting S3 bucket '$S3_BUCKET'..."
  aws s3api delete-bucket --bucket "$S3_BUCKET"
  echo "Bucket '$S3_BUCKET' deleted."
}

ensure_aws_login
delete_stack
delete_bucket

echo "Removal complete."

料金試算

参考

前提

  • 月あたりの日数は 31 で試算
  • SESについては全てのヘルスチェックでエラーがあったと仮定して、想定される最大送信回数から試算。

試算結果

サービス項目料金試算無料利用枠請求額補足
Lambda実行回数USD 0.20 / 100万件86400(1日) / 600(10分) * 31日 = 4464回 / 月100万回 / 月USD 0 / 月無料利用枠の範囲内(4464回 < 100万回)
Lambda実行時間USD 0.0000021 / 秒(※1)メモリ 0.125GB(128MB) * 2秒(※2) * 4464回 = 1116.0 GB秒40万 GB秒 / 月USD 0 / 月無料利用枠の範囲内(1116 GB秒 < 40万 GB秒)
SESメール送信回数USD 0.10 / 1,000 件86400(1日) / 600(10分) * 31日 -> 4464回 / 月なし(※3)USD 0.446 / 月USD 0.10 * (4464 / 1000)
SESメッセージサイズUSD 0.12 / 1GB3 KB(※4) * 4464回 -> 約 0.014 GB / 月なしUSD 0.00168 / 月USD 0.12 * 0.014
EventBridge Schedulerスケジューラー呼出-4464回 / 月1400 万回 / 月USD 0 / 月無料利用枠の範囲内 (4464回 < 1400万回)
合計----USD 0.44768 / 月-

※1 ... メモリ128MB の場合
※2 ... 実際には1.2秒程だったが、ここでは切り上げておく
※3 ... 最初の12か月間は 3000件 /月 が無料だが、ここでは2年目以降を考慮し、無料利用枠は無いものとして試算
※4 ... 実際にはヘッダ等も全て含めても2.7KB程だが、ここでは切り上げておく

合計: 0円 〜 約 70円 / 月(0 〜 0.44768 USD)

※2025/11時点の為替レート

TODO: 1ヶ月運用した場合の請求額の確認

トップ   差分 バックアップ リロード   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2025-12-01 (月) 08:22:04 (4d)