|
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
目次 †
利用するサービス †
開発環境 †
仕様 †方針 †
基本仕様 †
動作間隔 †
その他 †
通知メールの件名と本文 †通知メールの件名、本文は以下の通りとする
ファイル構成 †
+ .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."
料金試算 †参考 †
前提 †
試算結果 †
※1 ... メモリ128MB の場合 ※2025/11時点の為替レート TODO: 1ヶ月運用した場合の請求額の確認
|