Scheduler Lab
Create one-time, rate, and cron schedules with EventBridge Scheduler, timezones, flexible windows, and DLQs.
Mental model
EventBridge Scheduler is a serverless time trigger. At time T, it calls a target. Think of it as a cron daemon in the cloud, but with IAM, retry logic, DLQs, and the ability to invoke any AWS service or external API.
Prerequisites
IAM Roles for EventBridge Scheduler
Goal
Understand the two-role model that Scheduler requires. Build both roles correctly. Observe what breaks when they're swapped or misconfigured.
Estimated time: 45 min
The two-role model
What's happening here
EventBridge Scheduler requires two completely separate IAM roles, each with a different trust principal and a different job. Confusing them is the #1 source of silent failures.
- Scheduler Execution Role: assumed by the *Scheduler service itself* to call your target (Lambda, SQS, ECS, API Gateway, etc.). Trust principal:
scheduler.amazonaws.com. This role needs permission to invoke the target. - Lambda Execution Role: assumed by *Lambda* when it runs your function. Trust principal:
lambda.amazonaws.com. This role needs permission to write CloudWatch logs, access DynamoDB, call S3, and so on.
The Scheduler does NOT assume the Lambda role. Lambda does NOT assume the Scheduler role. They are independent. Getting this wrong causes AccessDenied errors that are hard to trace because the error surfaces in Scheduler's internal logs, not Lambda's.
EventBridge Scheduler
│
│── assumes ──▶ Scheduler Execution Role (trust: scheduler.amazonaws.com)
│ └── Permission: lambda:InvokeFunction on target Lambda
│
└── invokes ──▶ Lambda Function
│
└── assumes ──▶ Lambda Execution Role (trust: lambda.amazonaws.com)
└── Permission: logs:*, dynamodb:*, s3:*, etc.Create the Scheduler Execution Role
What's happening here
The Scheduler Execution Role's trust policy allows scheduler.amazonaws.com to call sts:AssumeRole on it. Without this, the Scheduler service can't assume the role and all schedule invocations fail silently. The inline policy on this role grants lambda:InvokeFunction. This is the permission Scheduler uses *after* it assumes the role, it calls the Lambda invoke API on your behalf.
# 1. Trust policy: allows Scheduler service to assume this role
cat > scheduler-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "scheduler.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
# 2. Create the Scheduler Execution Role
aws iam create-role \
--role-name eventbridge-scheduler-exec-role \
--assume-role-policy-document file://scheduler-trust-policy.json \
--description "Assumed by EventBridge Scheduler to invoke targets"
export SCHEDULER_ROLE_ARN=$(aws iam get-role \
--role-name eventbridge-scheduler-exec-role \
--query 'Role.Arn' --output text)
echo "Scheduler Role ARN: $SCHEDULER_ROLE_ARN"
# 3. Inline policy: allow Scheduler to invoke any Lambda in this account
aws iam put-role-policy \
--role-name eventbridge-scheduler-exec-role \
--policy-name InvokeLambda \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:*:*:function:*"
}]
}'
aws iam get-role --role-name eventbridge-scheduler-exec-role --query 'Role.AssumeRolePolicyDocument'
# trust principal must be 'scheduler.amazonaws.com'Create the Lambda Execution Role
What's happening here
Lambda needs its own role to write logs to CloudWatch. The trust principal here is lambda.amazonaws.com: completely different from the Scheduler role. AWSLambdaBasicExecutionRole is a managed policy that grants logs:CreateLogGroup, logs:CreateLogStream, and logs:PutLogEvents: the minimum for Lambda to function. If you attach AWSLambdaBasicExecutionRole to the Scheduler role instead of the Lambda role, Lambda will run but can't write logs. Debugging becomes blind.
# 1. Trust policy: allows Lambda service to assume this role
cat > lambda-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
# 2. Create Lambda Execution Role
aws iam create-role \
--role-name lambda-scheduler-lab-role \
--assume-role-policy-document file://lambda-trust-policy.json \
--description "Assumed by Lambda functions in the Scheduler lab"
export LAMBDA_ROLE_ARN=$(aws iam get-role \
--role-name lambda-scheduler-lab-role \
--query 'Role.Arn' --output text)
echo "Lambda Role ARN: $LAMBDA_ROLE_ARN"
# 3. Attach managed policy for CloudWatch Logs
aws iam attach-role-policy \
--role-name lambda-scheduler-lab-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
sleep 10 # IAM propagationCheckpoint: both roles exist, with different trust principals. Run both and compare:
aws iam get-role --role-name eventbridge-scheduler-exec-role --query 'Role.AssumeRolePolicyDocument.Statement[0].Principal'
aws iam get-role --role-name lambda-scheduler-lab-role --query 'Role.AssumeRolePolicyDocument.Statement[0].Principal'Break It: Session 0
Break 1: Swap the trust principals
# Create a role with lambda.amazonaws.com trust, then try using it as Scheduler's role
# The Scheduler can't assume it → invocation silently fails
# Observe: no Lambda execution in CloudWatch Logs, no error thrown to you
aws iam get-role \
--role-name lambda-scheduler-lab-role \
--query 'Role.AssumeRolePolicyDocument.Statement[0].Principal'
# Output: {"Service": "lambda.amazonaws.com"}
# If you pass this ARN to a schedule's RoleArn, Scheduler can't assume it → silent failureScheduler won't throw an error at schedule creation time. The failure happens silently at invocation time. You'll see InvocationAttemptCount increment in CloudWatch metrics but Lambda never runs. This is the hardest class of Scheduler bugs to debug.
Break 2: Missing lambda:InvokeFunction permission
# Remove the InvokeLambda policy and observe what happens
aws iam delete-role-policy \
--role-name eventbridge-scheduler-exec-role \
--policy-name InvokeLambda
# Create a test schedule (we'll build a real Lambda in Session 1)
# After the schedule fires, check CloudWatch Scheduler metrics:
aws cloudwatch get-metric-statistics \
--namespace AWS/Scheduler \
--metric-name InvocationDroppedCount \
--start-time $(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-10M +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 300 --statistics Sum \
--region $AWS_REGION
# Re-add the policy before continuing
aws iam put-role-policy \
--role-name eventbridge-scheduler-exec-role \
--policy-name InvokeLambda \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:*:*:function:*"
}]
}'InvocationDroppedCount goes up, not FailedInvocations. Dropped = Scheduler couldn't even call the target API. Failed = target was called but errored. Different metrics, different root causes.
Break 3: No CloudWatch policy on the Lambda role
# Detach AWSLambdaBasicExecutionRole from the Lambda role
aws iam detach-role-policy \
--role-name lambda-scheduler-lab-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Now create a Lambda function and invoke it manually
# Lambda runs but produces no CloudWatch logs at all
# Re-attach before continuing:
aws iam attach-role-policy \
--role-name lambda-scheduler-lab-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRoleLambda returns a 200 response (it ran), but /aws/lambda/<function-name> log group has no streams. Debugging scheduled functions without logs is nearly impossible in production.
Fundamentals & First Schedule
Goal
Understand Scheduler core concepts. Create cron and rate-based schedules via CLI. Verify execution through CloudWatch Logs.
Estimated time: 60 min
Scheduler vs. EventBridge Event Bus vs. CloudWatch Events
What's happening here
Three different things are often conflated under "EventBridge":
- EventBridge Event Bus: reactive pub/sub. An event happens → rule matches → target invoked. You don't control *when*, only *what*.
- EventBridge Scheduler: proactive time-driven. At time T → invoke target. No event, no bus, no rule needed. This is what this lab covers.
- CloudWatch Events: the legacy predecessor. Still works. Weaker timezone support, fewer targets, no flexible time windows. Scheduler is the replacement.
Schedule expression types:
rate(N unit): fires every N units from creation time.rate(5 minutes),rate(1 hour),rate(7 days).cron(m h dom mon dow yr), 6-field cron with year.cron(0 9 ? * MON-FRI *)= 9 AM every weekday.at(yyyy-mm-ddThh:mm:ss): one-time schedule at a specific UTC datetime.
Scheduler invokes targets asynchronously by default for Lambda. It doesn't wait for a response. Errors only surface in Lambda's async metrics or your DLQ.
Create your first Lambda function
mkdir -p /tmp/scheduler-lab
cat > /tmp/scheduler-lab/index.mjs << 'EOF'
export const handler = async (event) => {
const ts = new Date().toISOString();
console.log(JSON.stringify({
invoked_at: ts,
schedule_payload: event
}));
return { status: "ok", invoked_at: ts };
};
EOF
cd /tmp/scheduler-lab && zip function.zip index.mjs
aws lambda create-function \
--function-name scheduler-lab-fn \
--runtime nodejs20.x \
--handler index.handler \
--role $LAMBDA_ROLE_ARN \
--zip-file fileb://function.zip \
--region $AWS_REGION
export LAMBDA_ARN=$(aws lambda get-function \
--function-name scheduler-lab-fn \
--region $AWS_REGION \
--query 'Configuration.FunctionArn' --output text)
echo "Lambda ARN: $LAMBDA_ARN"Checkpoint: aws lambda get-function --function-name scheduler-lab-fn --region $AWS_REGION --query 'Configuration.State' → Active.
Create a rate-based schedule
What's happening here
A rate schedule fires on a fixed interval. rate(5 minutes) means "every 5 minutes from now". The interval is measured from the schedule's creation time, not from a wall-clock boundary. --flexible-time-window '{"Mode": "OFF"}' means fire at exactly the scheduled time. We'll explore flexible windows in Session 4. The --target JSON tells Scheduler what to call and with what payload. Input is a JSON string injected as the Lambda event, use it to pass schedule name, tenant ID, or any context your function needs.