AWS Lambda CloudWatch Jobs TypeScript
AWS LambdaとCloudWatch/EventBridgeを使用した定期ジョブの包括的なガイド - TypeScriptで実装するベストプラクティス
バックアップ、メール送信、データ処理などの定期的なタスクを自動化する必要がありますか?AWS LambdaとCloudWatch Events/EventBridgeの組み合わせは、従来のcronジョブを置き換えるのに最適なサーバーレスソリューションです。このガイドでは、本番環境向けの定期ジョブをTypeScript/Node.jsで実装する方法に焦点を当てます。
Lambda定期ジョブの概要#
AWS Lambdaは、サーバーを管理せずにコードを実行できるサーバーレスコンピューティングサービスです。スケジューリングサービスと組み合わせることで、以下のことが可能になります:
- タスクの自動化: バックアップ、レポート生成、データクリーンアップ
- コスト削減: コード実行時のみ支払う
- 自動スケーリング: 設定なしで負荷増加に対応
- 高可用性: AWSが可用性を保証
[!NOTE] AWSは現在、新しい定期ジョブには従来のCloudWatch EventsではなくEventBridge Schedulerの使用を推奨しています。
flowchart LR
subgraph Scheduler["トリガーサービス"]
direction TB
EBS["EventBridge Scheduler<br/>(タイムゾーン / 再試行 / 柔軟なウィンドウ)"]
CWE["CloudWatch Events<br/>(従来のCron)"]
end
subgraph Serverless["サーバーレス実行基盤"]
Lambda["AWS Lambda Function<br/>(Node.js / TypeScript)"]
DLQ["Dead Letter Queue (SQS)<br/>(失敗時のイベント退避)"]
end
subgraph Targets["対象リソースと監視"]
DB[(データベース / S3)]
Log["CloudWatch Logs / アラーム"]
end
EBS -->|定期実行呼び出し| Lambda
CWE -.->|標準呼び出し| Lambda
Lambda -.->|実行失敗 / 再試行上限| DLQ
Lambda -->|データ読み書き| DB
Lambda -->|ログ・メトリクス送信| Log
EventBridge Schedulerは、CloudWatch Eventsと比較して機能が強化された最新のスケジューリングサービスです。
EventBridge Schedulerの利点#
- タイムゾーンサポート: UTCのみに制限されない
- 柔軟な時間ウィンドウ: 柔軟な時間範囲内で実行可能
- 組み込み再試行: 失敗時の自動再試行
- ワンタイムスケジュール: 単一実行のサポート
- デッドレターキュー: より良いエラーハンドリング
ステップ1: TypeScript Lambda関数の作成#
import { Context } from 'aws-lambda';
import { DynamoDB } from 'aws-sdk';
import { logger } from './utils/logger';
interface ScheduledEvent {
time: string;
detail?: any;
}
interface LambdaResponse {
statusCode: number;
body: string;
}
export const lambdaHandler = async (
event: ScheduledEvent,
context: Context
): Promise<LambdaResponse> => {
logger.info(`ジョブ実行時刻: ${new Date().toISOString()}`);
logger.info(`イベント: ${JSON.stringify(event)}`);
try {
// ジョブ処理ロジック
const result = await processScheduledTask(event);
return {
statusCode: 200,
body: JSON.stringify({
message: 'ジョブが正常に完了しました',
result
})
};
} catch (error) {
logger.error(`ジョブ失敗: ${error}`);
throw error;
}
};
async function processScheduledTask(event: ScheduledEvent): Promise<any> {
// 例: データベースクリーンアップ、レポート送信など
logger.info('定期タスクを処理中...');
// ビジネスロジックをここに記述
return { status: 'success', processedItems: 10 };
}typescriptステップ2: IAMロールの作成#
Lambdaはジョブ実行とログ書き込みの権限が必要です:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}jsonポリシー権限:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:*",
"s3:*"
],
"Resource": "*"
}
]
}jsonステップ3: EventBridge Schedulerでスケジュール作成#
AWSコンソールを使用#
- EventBridge Schedulerコンソールに移動
- スケジュールの作成をクリック
- 定期スケジュールを選択
- スケジュール式を設定:
# 毎日9:00 AM UTCで実行
cron(0 9 * * ? *)
# 15分ごとに実行
rate(15 minutes)
# 毎週月曜日9:00 AMで実行
cron(0 9 ? * MON *)
# 毎月1日に実行
cron(0 0 1 * ? *)bash- Lambda関数をターゲットとして選択
- タイムゾーンを設定(必要な場合):
# 9:00 AM Eastern Time
cron(0 9 ? * MON *)
timezone: America/New_Yorkbash- 柔軟な時間ウィンドウを設定(オプション):
# スケジュール時刻から15分以内に実行
mode: FLEXIBLE
maximum_window_in_minutes: 15bash- 再試行ポリシーを設定:
maximum_retry_attempts: 3
maximum_event_age_in_seconds: 3600bashAWS CLIを使用#
aws scheduler create-schedule \
--name daily-job \
--schedule-expression 'cron(0 9 * * ? *)' \
--schedule-expression-timezone 'UTC' \
--target '{
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:my-function",
"RoleArn": "arn:aws:iam::123456789012:role/scheduler-role"
}' \
--flexible-time-window '{
"Mode": "FLEXIBLE",
"MaximumWindowInMinutes": 15
}'bashTerraformを使用#
resource "aws_scheduler_schedule" "daily_job" {
name = "daily-job"
group_name = "default"
flexible_time_window {
mode = "FLEXIBLE"
maximum_window_in_minutes = 15
}
schedule_expression = "cron(0 9 * * ? *)"
schedule_expression_timezone = "UTC"
target {
arn = aws_lambda_function.my_function.arn
role_arn = aws_iam_role.scheduler_role.arn
retry_policy {
maximum_retry_attempts = 3
maximum_event_age_in_seconds = 3600
}
dead_letter_config {
arn = aws_sqs_queue.dlq.arn
}
}
}hcl方法2: CloudWatch Events(従来)#
CloudWatch Eventsは従来の方法で、まだサポートされていますが機能は少ないです。
ステップ1: Lambda関数の作成#
Schedulerメソッドと同じ。
ステップ2: CloudWatchルールの作成#
AWSコンソールを使用#
- CloudWatchコンソールに移動
- Events → Rulesを選択
- ルールの作成をクリック
- スケジュール式を選択
- cron式を入力:
# 毎日9:00 AM UTC
0 9 * * ? *
# 5分ごと
rate(5 minutes)bash- Lambda関数をターゲットとして選択
- 権限を設定(AWSが自動作成)
AWS CLIを使用#
aws events put-rule \
--name daily-lambda-rule \
--schedule-expression 'cron(0 9 * * ? *)'
aws events put-targets \
--rule daily-lambda-rule \
--targets '{
"Id": "1",
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:my-function"
}'
aws lambda add-permission \
--function-name my-function \
--statement-id daily-lambda-rule \
--action 'lambda:InvokeFunction' \
--principal events.amazonaws.com \
--source-arn arn:aws:events:us-east-1:123456789012:rule/daily-lambda-rulebashCron式ガイド#
EventBridgeは6フィールドのcron式を使用します:
cron(分 時 日-月 月 曜-日 年)textCron式の例#
# 毎日9:00 AM UTC
cron(0 9 * * ? *)
# 毎週月曜日9:00 AM
cron(0 9 ? * MON *)
# 毎月1日00:00
cron(0 0 1 * ? *)
# 15分ごと
rate(15 minutes)
# 1時間ごと
rate(1 hour)
# 月曜日から金曜日の9:00 AM
cron(0 9 ? * MON-FRI *)
# 月の最終日
cron(0 0 L * ? *)
# 毎月の第1月曜日
cron(0 0 ? * MON#1 *)bashフィールド値#
| フィールド | 値 | 特殊文字 |
|---|---|---|
| 分 | 0-59 | , - * / |
| 時 | 0-23 | , - * / |
| 日-月 | 1-31, L, W | , - * / ? L W |
| 月 | 1-12, JAN-DEC | , - * / |
| 曜-日 | 1-7, SUN-SAT, ?, L, # | , - * / ? L # |
| 年 | 1970-2199 | , - * / |
[!WARNING] 日-月または曜-日が制限されている場合は
?を使用し、両方のフィールドを同時に使用しないでください。
ベストプラクティス#
1. 冪等性#
安全な再試行のためにハンドラーが冪等であることを確認:
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const TABLE_NAME = 'job-locks';
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
const jobId = `${event.time}-${event.detail?.id || 'default'}`;
// ジョブが既に実行されたか確認
if (await isJobAlreadyProcessed(jobId)) {
logger.info('ジョブは既に処理済み、スキップ');
return { status: 'skipped' };
}
// ジョブを処理
const result = await processJob(event);
// ジョブを処理済みとしてマーク
await markJobAsProcessed(jobId);
return result;
};
async function isJobAlreadyProcessed(jobId: string): Promise<boolean> {
try {
const result = await dynamodb.get({
TableName: TABLE_NAME,
Key: { jobId }
}).promise();
return !!result.Item;
} catch (error) {
logger.error(`ジョブステータス確認エラー: ${error}`);
return false;
}
}
async function markJobAsProcessed(jobId: string): Promise<void> {
await dynamodb.put({
TableName: TABLE_NAME,
Item: {
jobId,
processedAt: new Date().toISOString(),
ttl: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60) // 7日間TTL
}
}).promise();
}typescript2. デッドレターキュー(DLQ)#
失敗した呼び出しを処理するためにDLQを設定:
import { SQS } from 'aws-sdk';
const sqs = new SQS();
const DLQ_URL = process.env.DLQ_URL || '';
async function sendToDLQ(errorMessage: string, event: ScheduledEvent): Promise<void> {
await sqs.sendMessage({
QueueUrl: DLQ_URL,
MessageBody: JSON.stringify({
error: errorMessage,
event,
timestamp: new Date().toISOString()
})
}).promise();
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
try {
const result = await processJob(event);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`ジョブ失敗: ${errorMessage}`);
// DLQに送信
await sendToDLQ(errorMessage, event);
throw error;
}
};typescript3. 重複実行の防止#
DynamoDBロックで重複実行を防止:
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const LOCK_TABLE = 'job-locks';
interface LockItem {
jobId: string;
lockedAt: string;
expiresAt: string;
}
async function acquireLock(jobId: string): Promise<boolean> {
const now = new Date();
const expiresAt = new Date(now.getTime() + 10 * 60 * 1000); // 10分
try {
await dynamodb.put({
TableName: LOCK_TABLE,
Item: {
jobId,
lockedAt: now.toISOString(),
expiresAt: expiresAt.toISOString()
},
ConditionExpression: 'attribute_not_exists(jobId)'
}).promise();
return true;
} catch (error) {
if ((error as any).code === 'ConditionalCheckFailedException') {
return false; // ロックが既に存在
}
throw error;
}
}
async function releaseLock(jobId: string): Promise<void> {
await dynamodb.delete({
TableName: LOCK_TABLE,
Key: { jobId }
}).promise();
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
const jobId = `scheduled-job-${event.time}`;
// ロック取得
const lockAcquired = await acquireLock(jobId);
if (!lockAcquired) {
logger.info('ジョブは既に実行中、スキップ');
return { status: 'skipped', reason: 'lock_not_acquired' };
}
try {
const result = await processJob(event);
return result;
} finally {
// ロック解放
await releaseLock(jobId);
}
};typescript4. モニタリングとログ#
CloudWatch LogsとMetricsを使用:
import { CloudWatch } from 'aws-sdk';
const cloudwatch = new CloudWatch({ region: process.env.AWS_REGION });
export const lambdaHandler = async (event: ScheduledEvent, context: Context): Promise<any> => {
const startTime = Date.now();
try {
// カスタムメトリクス
logger.info('JOB_STARTED');
const result = await processJob(event);
// 実行時間をログ
const duration = Date.now() - startTime;
logger.info(`JOB_COMPLETED duration=${duration}ms`);
// カスタムメトリクスを送信
await cloudwatch.putMetricData({
Namespace: 'ScheduledJobs',
MetricData: [
{
MetricName: 'JobDuration',
Value: duration,
Unit: 'Milliseconds',
Dimensions: [
{
Name: 'JobName',
Value: 'DailyCleanup'
}
]
}
]
}).promise();
return result;
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`JOB_FAILED error=${error} duration=${duration}ms`);
// 失敗メトリクスを送信
await cloudwatch.putMetricData({
Namespace: 'ScheduledJobs',
MetricData: [
{
MetricName: 'JobFailures',
Value: 1,
Unit: 'Count',
Dimensions: [
{
Name: 'JobName',
Value: 'DailyCleanup'
}
]
}
]
}).promise();
throw error;
}
};typescript5. エラーハンドリング#
エラーを適切に処理:
interface APIResponse {
statusCode: number;
body: string;
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<APIResponse> => {
try {
const result = await processJob(event);
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
if (error instanceof ValidationError) {
logger.error(`検証エラー: ${error.message}`);
return {
statusCode: 400,
body: JSON.stringify({ error: error.message })
};
} else if (error instanceof BusinessError) {
logger.error(`ビジネスエラー: ${error.message}`);
return {
statusCode: 422,
body: JSON.stringify({ error: error.message })
};
} else {
logger.error(`予期しないエラー: ${error}`);
// DLQまたはアラートに送信
await sendAlert(error instanceof Error ? error.message : String(error));
throw error;
}
}
};
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
class BusinessError extends Error {
constructor(message: string) {
super(message);
this.name = 'BusinessError';
}
}typescript比較: EventBridge Scheduler vs CloudWatch Events#
| 機能 | EventBridge Scheduler | CloudWatch Events |
|---|---|---|
| スケジュールタイプ | cron + rate + one-time | cron + rate |
| タイムゾーンサポート | ✅ 任意のタイムゾーン | ❌ UTCのみ |
| 柔軟なウィンドウ | ✅ あり | ❌ なし |
| 組み込み再試行 | ✅ あり | ❌ なし(DLQが必要) |
| コスト | $1.00/M呼び出し | 無料(最初の5M/月) |
| ユースケース | 本番ジョブ | 単純な定期ジョブ |
実践例: 毎日のデータベースクリーンアップ#
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const TABLE_NAME = 'user-activity';
interface CleanupResult {
deletedCount: number;
cutoffDate: string;
}
export const lambdaHandler = async (): Promise<APIResponse> => {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 30);
try {
// 古いレコードをスキャンして削除
const response = await dynamodb.scan({
TableName: TABLE_NAME,
FilterExpression: 'created_at < :cutoff',
ExpressionAttributeValues: {
':cutoff': cutoffDate.toISOString()
}
}).promise();
let deletedCount = 0;
if (response.Items) {
for (const item of response.Items) {
await dynamodb.delete({
TableName: TABLE_NAME,
Key: { id: item.id }
}).promise();
deletedCount++;
}
}
logger.info(`${deletedCount}件の古いレコードを削除`);
return {
statusCode: 200,
body: JSON.stringify({
deletedCount,
cutoffDate: cutoffDate.toISOString()
} as CleanupResult)
};
} catch (error) {
logger.error(`クリーンアップ失敗: ${error}`);
throw error;
}
};typescriptEventBridge Schedulerでスケジュール:
# 毎日2:00 AM UTCで実行
cron(0 2 * * ? *)bashトラブルシューティング#
ジョブが実行されない#
- スケジュール式を確認: cron構文が正しいか確認
- IAM権限を確認: Lambdaに必要な権限があるか確認
- CloudWatch Logsを確認: エラーメッセージを探す
- ターゲットARNを確認: Lambda関数ARNが正しいか確認
ジョブは実行されるが失敗する#
- CloudWatch Logsを確認: エラーメッセージを探す
- タイムアウトを確認: Lambdaがタイムアウトしている可能性
- リソース権限を確認: データベース、S3等のアクセス権
- ローカルでテスト: Lambdaをローカルで実行してデバッグ
重複実行#
- ロックメカニズムを実装: DynamoDBまたはRedisを使用
- 予約同時実行数を使用: 同時実行数を制限
- 冪等性を追加: ハンドラーが冪等であることを確認
リソースのクリーンアップ#
不要になったらリソースをクリーンアップ:
# スケジュール削除
aws scheduler delete-schedule --name daily-job
# CloudWatchルール削除
aws events delete-rule --name daily-lambda-rule
# Lambda関数削除
aws lambda delete-function --function-name my-function
# IAMロール削除
aws iam delete-role --role-name scheduler-rolebash結論#
AWS LambdaとEventBridge Scheduler/CloudWatch Eventsの組み合わせは、定期ジョブの強力なソリューションを提供します:
- EventBridge Scheduler: タイムゾーンサポート、再試行、柔軟なウィンドウを持つ本番ジョブ向け
- CloudWatch Events: コスト効率の良い単純な定期ジョブ向け
主要なポイント:
- 本番にはEventBridge Schedulerを使用
- 冪等性とエラーハンドリングを実装
- CloudWatchでモニタリングを設定
- 失敗した呼び出しにはDLQを使用
- 重複実行を防止