Interview Q&A All Levels Python

Script 1: Write a Python script to start/stop EC2 instances based on a schedule tag

January 1, 0001 12 min read 50 Questions DB
Level:

πŸ• EC2 Auto Scheduler β€” Stop & Start Instances on a Schedule Using Python & AWS Lambda

Automatically stop your EC2 instances at 8 PM and start them at 8 AM using a tag-based Lambda function and EventBridge schedules β€” saving costs without lifting a finger.


πŸ“‹ Table of Contents

  1. Overview
  2. Architecture Diagram
  3. Prerequisites
  4. How It Works
  5. Step 1 β€” Tag Your EC2 Instances
  6. Step 2 β€” The Lambda Function (Deep Dive)
  7. Step 3 β€” Create the EventBridge Schedule Rules
  8. Step 4 β€” IAM Permissions Required
  9. Step 5 β€” Deploy the Lambda Function
  10. Step 6 β€” Verify It Works
  11. Complete Code
  12. Common Errors & Fixes
  13. Cost Savings Estimate
  14. Extending the Solution

Overview

Running EC2 instances 24/7 in non-production environments (dev, staging, QA) is one of the most common sources of unnecessary AWS spend. If developers only work 8 AM–8 PM, your instances are idle and billing you for 12 hours every night and all weekend.

This guide shows you how to build a fully automated, tag-driven scheduler:

  • βœ… Tag-based β€” Only instances tagged AutoStop=true are affected
  • βœ… Serverless β€” Runs as an AWS Lambda function (no EC2 needed to manage EC2)
  • βœ… EventBridge-triggered β€” Cron-driven, zero manual intervention
  • βœ… Safe β€” Won’t touch instances in wrong states (e.g., already stopped)
  • βœ… Logged β€” Every action is recorded in CloudWatch Logs

Architecture Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        AWS Cloud                            β”‚
β”‚                                                             β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚   β”‚   EventBridge   β”‚          β”‚    Lambda Function    β”‚    β”‚
β”‚   β”‚                 β”‚          β”‚                       β”‚    β”‚
β”‚   β”‚  cron(0 20 * *) │─────────►│  lambda_handler()     β”‚    β”‚
β”‚   β”‚  (8 PM Stop)    β”‚          β”‚                       β”‚    β”‚
β”‚   β”‚                 β”‚          β”‚  1. Get current hour  β”‚    β”‚
β”‚   β”‚  cron(0 8  * *) │─────────►│  2. Decide: stop/startβ”‚    β”‚
β”‚   β”‚  (8 AM Start)   β”‚          β”‚  3. Filter by tag     β”‚    β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚  4. Call EC2 API      β”‚    β”‚
β”‚                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                           β”‚                 β”‚
β”‚                                           β–Ό                 β”‚
β”‚                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”‚
β”‚                                β”‚   EC2 Instances  β”‚         β”‚
β”‚                                β”‚  [AutoStop=true] β”‚         β”‚
β”‚                                β”‚                  β”‚         β”‚
β”‚                                β”‚  dev-server-01   β”‚         β”‚
β”‚                                β”‚  staging-api-02  β”‚         β”‚
β”‚                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚
β”‚                                                             β”‚
β”‚          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚          β”‚  CloudWatch Logs             β”‚                   β”‚
β”‚          β”‚  β†’ "Stopped instances: [...]"β”‚                   β”‚
β”‚          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Prerequisites

Before you begin, make sure you have the following in place:

RequirementDetails
AWS AccountWith permissions to manage EC2, Lambda, EventBridge, and IAM
Python 3.9+For local development and testing
boto3AWS SDK for Python (pip install boto3)
AWS CLIConfigured with aws configure
IAM RoleA Lambda execution role with EC2 and logging permissions

How It Works

The solution has three components that work together:

Tag on Instance  β†’  EventBridge Cron  β†’  Lambda Function  β†’  EC2 Action
(AutoStop=true)     (fires at 8AM/PM)     (decides stop/start)  (API call)
  1. You tag your EC2 instances with AutoStop=true
  2. EventBridge fires the Lambda at 8 AM and 8 PM (UTC) every day
  3. Lambda checks the current UTC hour to decide whether to stop or start
  4. Lambda calls the EC2 API with only the instances matching the tag + correct state
  5. CloudWatch Logs records the result of every run

Step 1 β€” Tag Your EC2 Instances

Before writing any code, tag the EC2 instances you want to auto-schedule.

Via AWS Console

  1. Go to EC2 β†’ Instances
  2. Select the instance you want to schedule
  3. Click the Tags tab β†’ Manage tags
  4. Add a new tag:
    • Key: AutoStop
    • Value: true
  5. Click Save

Via AWS CLI

aws ec2 create-tags \
  --resources i-0123456789abcdef0 \
  --tags Key=AutoStop,Value=true

Via boto3 (Python)

import boto3

ec2 = boto3.client('ec2')

ec2.create_tags(
    Resources=['i-0123456789abcdef0', 'i-0abcdef1234567890'],
    Tags=[{'Key': 'AutoStop', 'Value': 'true'}]
)

⚠️ Important: The tag value is case-sensitive. Use lowercase true, not True or TRUE, to match the Lambda filter.


Step 2 β€” The Lambda Function (Deep Dive)

Let’s walk through every section of the Lambda function in detail.

Imports & Logger Setup

import boto3
import logging
from datetime import datetime

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

What each import does:

ImportPurpose
boto3The AWS SDK for Python β€” lets us call EC2 and other AWS APIs
loggingPython’s built-in logging module; outputs go to CloudWatch Logs automatically in Lambda
datetimeUsed to get the current UTC hour to decide which action to take

Why logging instead of print()?

In Lambda, print() works, but logging is preferred because:

  • It includes timestamps and log levels (INFO, WARNING, ERROR)
  • It integrates cleanly with CloudWatch Logs filtering
  • You can control verbosity with setLevel()

get_tagged_instances() β€” Fetching the Right Instances

def get_tagged_instances(ec2_client, tag_key, tag_value, state):
    """Fetch EC2 instances with specific tag and state."""
    response = ec2_client.describe_instances(
        Filters=[
            {'Name': f'tag:{tag_key}', 'Values': [tag_value]},
            {'Name': 'instance-state-name', 'Values': [state]}
        ]
    )
    instance_ids = [
        instance['InstanceId']
        for reservation in response['Reservations']
        for instance in reservation['Instances']
    ]
    return instance_ids

Parameter breakdown:

ParameterTypeDescription
ec2_clientboto3 clientThe EC2 client object used to make API calls
tag_keystrThe tag key to filter by β€” e.g., "AutoStop"
tag_valuestrThe tag value to match β€” e.g., "true"
statestrOnly return instances in this state β€” "running" or "stopped"

Why filter by both tag AND state?

This is a key safety feature. Without the state filter:

  • At 8 PM, you might try to stop already-stopped instances β†’ AWS API error
  • At 8 AM, you might try to start already-running instances β†’ wasted API calls

By passing state='running' when stopping and state='stopped' when starting, you only get instances that actually need the action.

Understanding the nested list comprehension:

AWS describe_instances returns a deeply nested response:

{
  "Reservations": [
    {
      "Instances": [
        { "InstanceId": "i-001", ... },
        { "InstanceId": "i-002", ... }
      ]
    },
    {
      "Instances": [
        { "InstanceId": "i-003", ... }
      ]
    }
  ]
}

The double loop flattens this structure:

instance_ids = [
    instance['InstanceId']           # ← grab the ID
    for reservation in response['Reservations']   # ← outer loop: each reservation
    for instance in reservation['Instances']       # ← inner loop: each instance
]
# Result: ['i-001', 'i-002', 'i-003']

lambda_handler() β€” The Entry Point

def lambda_handler(event, context):
    ec2 = boto3.client('ec2')
    current_hour = datetime.utcnow().hour
    action = 'stop' if current_hour == 20 else 'start'

event and context:

  • event β€” The data EventBridge sends when it triggers this function (we don’t use it here, but it’s required by Lambda’s signature)
  • context β€” Runtime info like function name, remaining time, etc. (also unused here)

Why datetime.utcnow().hour?

Lambda always runs in UTC regardless of your AWS region. EventBridge cron expressions also use UTC. Using .utcnow() ensures the hour check is consistent and reliable everywhere.

current_hour = 20  β†’ action = 'stop'   (8 PM UTC)
current_hour = 8   β†’ action = 'start'  (8 AM UTC)
current_hour = anything else β†’ action = 'start' (safe default)

πŸ’‘ Tip: If your team works in IST (UTC+5:30), 8 PM IST = 14:30 UTC and 8 AM IST = 02:30 UTC. Adjust your EventBridge cron accordingly, not the Python code.

The stop block:

if action == 'stop':
    instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'running')
    if instance_ids:
        ec2.stop_instances(InstanceIds=instance_ids)
        logger.info(f"Stopped instances: {instance_ids}")
    else:
        logger.info("No running instances with AutoStop=true found.")
  • Fetches only running instances with the tag
  • Calls stop_instances() β€” this initiates a graceful OS shutdown (like pressing the power button)
  • Logs the result either way β€” important for debugging “did it run?”

The start block:

elif action == 'start':
    instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'stopped')
    if instance_ids:
        ec2.start_instances(InstanceIds=instance_ids)
        logger.info(f"Started instances: {instance_ids}")
    else:
        logger.info("No stopped instances with AutoStop=true found.")
  • Fetches only stopped instances with the tag
  • Calls start_instances() β€” initiates boot sequence
  • Note: Instances take 1–3 minutes to fully start after this call returns

The return value:

return {
    'statusCode': 200,
    'action': action,
    'instances_affected': instance_ids if instance_ids else []
}

Lambda doesn’t need a return value when triggered by EventBridge, but returning structured data is helpful for:

  • Manual test invocations in the console
  • Step Functions or other orchestrators wrapping this Lambda
  • Debugging with CloudWatch Logs Insights

Step 3 β€” Create the EventBridge Schedule Rules

Understanding Cron Syntax on AWS

AWS EventBridge uses a 6-field cron format (unlike the standard 5-field Unix cron):

cron(Minutes  Hours  Day-of-month  Month  Day-of-week  Year)
ExpressionMeaning
cron(0 20 * * ? *)Every day at 8:00 PM UTC
cron(0 8 * * ? *)Every day at 8:00 AM UTC
cron(0 20 ? * MON-FRI *)Weekdays only at 8 PM UTC

⚠️ The ? in day-of-month or day-of-week means “no specific value” β€” required when the other field is set. AWS requires this for valid cron expressions.

Creating Rules via boto3

import boto3

events = boto3.client('events')

# ── Stop rule: fires every day at 8 PM UTC ──────────────────
events.put_rule(
    Name='StopDevInstances',
    ScheduleExpression='cron(0 20 * * ? *)',
    State='ENABLED',
    Description='Stop EC2 instances tagged AutoStop=true at 8 PM UTC'
)

# ── Start rule: fires every day at 8 AM UTC ─────────────────
events.put_rule(
    Name='StartDevInstances',
    ScheduleExpression='cron(0 8 * * ? *)',
    State='ENABLED',
    Description='Start EC2 instances tagged AutoStop=true at 8 AM UTC'
)

Connecting Rules to the Lambda Function

After creating the rules, you need to add the Lambda as a target for each rule:

lambda_arn = 'arn:aws:lambda:us-east-1:123456789012:function:EC2AutoScheduler'

# Add target to Stop rule
events.put_targets(
    Rule='StopDevInstances',
    Targets=[
        {
            'Id': 'EC2StopTarget',
            'Arn': lambda_arn
        }
    ]
)

# Add target to Start rule
events.put_targets(
    Rule='StartDevInstances',
    Targets=[
        {
            'Id': 'EC2StartTarget',
            'Arn': lambda_arn
        }
    ]
)

Grant EventBridge Permission to Invoke Lambda

lambda_client = boto3.client('lambda')

lambda_client.add_permission(
    FunctionName='EC2AutoScheduler',
    StatementId='AllowEventBridgeStop',
    Action='lambda:InvokeFunction',
    Principal='events.amazonaws.com',
    SourceArn='arn:aws:events:us-east-1:123456789012:rule/StopDevInstances'
)

lambda_client.add_permission(
    FunctionName='EC2AutoScheduler',
    StatementId='AllowEventBridgeStart',
    Action='lambda:InvokeFunction',
    Principal='events.amazonaws.com',
    SourceArn='arn:aws:events:us-east-1:123456789012:rule/StartDevInstances'
)

Step 4 β€” IAM Permissions Required

Your Lambda function needs an execution role with the following permissions:

Minimum IAM Policy (JSON)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2SchedulerPermissions",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*"
    },
    {
      "Sid": "CloudWatchLogsPermissions",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    }
  ]
}

πŸ”’ Security tip: For production, restrict the ec2:StartInstances and ec2:StopInstances actions to specific instance ARNs or by tag condition using aws:ResourceTag condition keys.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "ec2:ResourceTag/AutoStop": "true"
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": "ec2:DescribeInstances",
      "Resource": "*"
    }
  ]
}

Step 5 β€” Deploy the Lambda Function

Option A: AWS Console (Quickest)

  1. Go to Lambda β†’ Create function
  2. Choose Author from scratch
  3. Function name: EC2AutoScheduler
  4. Runtime: Python 3.12
  5. Execution role: Use the IAM role you created in Step 4
  6. Paste the full Lambda code into the inline editor
  7. Click Deploy
  8. Set Timeout to at least 30 seconds (Configuration β†’ General configuration)

Option B: AWS CLI

# Package the Lambda code
zip function.zip lambda_function.py

# Create the function
aws lambda create-function \
  --function-name EC2AutoScheduler \
  --runtime python3.12 \
  --role arn:aws:iam::123456789012:role/EC2SchedulerRole \
  --handler lambda_function.lambda_handler \
  --zip-file fileb://function.zip \
  --timeout 30

Option C: boto3 Script

import boto3
import zipfile
import io

# Read and zip the Lambda source
with open('lambda_function.py', 'rb') as f:
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w') as zf:
        zf.writestr('lambda_function.py', f.read())
    zip_bytes = zip_buffer.getvalue()

lambda_client = boto3.client('lambda')
lambda_client.create_function(
    FunctionName='EC2AutoScheduler',
    Runtime='python3.12',
    Role='arn:aws:iam::123456789012:role/EC2SchedulerRole',
    Handler='lambda_function.lambda_handler',
    Code={'ZipFile': zip_bytes},
    Timeout=30
)

Step 6 β€” Verify It Works

Manual Test via Console

  1. Open your Lambda function in the AWS Console
  2. Click Test β†’ Create new test event
  3. Use any JSON payload (e.g., {}) β€” our handler ignores the event body
  4. Click Test and check the output

Expected output at 8 PM UTC:

{
  "statusCode": 200,
  "action": "stop",
  "instances_affected": ["i-0123456789abcdef0"]
}

Manual Test via CLI

aws lambda invoke \
  --function-name EC2AutoScheduler \
  --payload '{}' \
  --cli-binary-format raw-in-base64-out \
  response.json

cat response.json

Check CloudWatch Logs

  1. Go to CloudWatch β†’ Log groups
  2. Find /aws/lambda/EC2AutoScheduler
  3. Open the latest log stream

You should see entries like:

[INFO] 2024-01-15T20:00:03Z Stopped instances: ['i-0abc123', 'i-0def456']

Complete Code

lambda_function.py

import boto3
import logging
from datetime import datetime

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


def get_tagged_instances(ec2_client, tag_key, tag_value, state):
    """Fetch EC2 instances with specific tag and state."""
    response = ec2_client.describe_instances(
        Filters=[
            {'Name': f'tag:{tag_key}', 'Values': [tag_value]},
            {'Name': 'instance-state-name', 'Values': [state]}
        ]
    )
    instance_ids = [
        instance['InstanceId']
        for reservation in response['Reservations']
        for instance in reservation['Instances']
    ]
    return instance_ids


def lambda_handler(event, context):
    """
    Lambda handler triggered by EventBridge schedule:
    - 8 PM UTC: stop instances tagged AutoStop=true
    - 8 AM UTC: start instances tagged AutoStop=true
    """
    ec2 = boto3.client('ec2')
    current_hour = datetime.utcnow().hour
    action = 'stop' if current_hour == 20 else 'start'

    if action == 'stop':
        instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'running')
        if instance_ids:
            ec2.stop_instances(InstanceIds=instance_ids)
            logger.info(f"Stopped instances: {instance_ids}")
        else:
            logger.info("No running instances with AutoStop=true found.")

    elif action == 'start':
        instance_ids = get_tagged_instances(ec2, 'AutoStop', 'true', 'stopped')
        if instance_ids:
            ec2.start_instances(InstanceIds=instance_ids)
            logger.info(f"Started instances: {instance_ids}")
        else:
            logger.info("No stopped instances with AutoStop=true found.")

    return {
        'statusCode': 200,
        'action': action,
        'instances_affected': instance_ids if instance_ids else []
    }

setup_eventbridge.py

import boto3

events = boto3.client('events')

# Stop rule at 8 PM UTC
events.put_rule(
    Name='StopDevInstances',
    ScheduleExpression='cron(0 20 * * ? *)',
    State='ENABLED',
    Description='Stop EC2 instances tagged AutoStop=true at 8 PM UTC'
)

# Start rule at 8 AM UTC
events.put_rule(
    Name='StartDevInstances',
    ScheduleExpression='cron(0 8 * * ? *)',
    State='ENABLED',
    Description='Start EC2 instances tagged AutoStop=true at 8 AM UTC'
)

print("EventBridge rules created successfully.")

Common Errors & Fixes

ErrorCauseFix
UnauthorizedOperation: describe_instancesIAM role missing ec2:DescribeInstancesAdd the permission to the Lambda execution role
InvalidInstanceID.NotFoundInstance ID doesn’t exist in the regionEnsure the boto3 client region matches where instances are
No instances affectedTag value mismatchCheck tag is exactly AutoStop = true (lowercase)
Task timed out after 3.00 secondsDefault 3s timeout too shortIncrease Lambda timeout to 30s in Configuration
Lambda not triggeredEventBridge target not linked to LambdaAdd Lambda as a target in the EventBridge rule
AccessDeniedException on EventBridgeLambda resource policy missingRun add_permission() to grant EventBridge invoke access

Cost Savings Estimate

Stopping instances 12 hours per night + full weekends:

Hours saved per monthInstance typeOn-Demand priceMonthly savings
~372 hrs (12 hrs/night + weekends)t3.medium~$0.0416/hr~$15.50/instance
~372 hrsm5.large~$0.096/hr~$35.70/instance
~372 hrsc5.xlarge~$0.17/hr~$63.24/instance

For a team of 5 developers each with a dev EC2 instance, that’s $75–$315/month saved on just this one automation.


Extending the Solution

Here are some ideas to build on this foundation:

πŸ—“ Weekday-only schedules Change the EventBridge cron to cron(0 20 ? * MON-FRI *) so instances stay running over the weekend (or vice versa).

🌍 Multiple timezones Use separate Lambda functions or check tags like AutoStopTimezone=IST to handle teams in different regions.

πŸ“¬ SNS Notifications Add an SNS publish call after stopping/starting to email your team a daily summary.

πŸ” DynamoDB state tracking Store instance start/stop history in DynamoDB for cost reporting and audit trails.

πŸ›‘ Override tag Add an AutoStopOverride=true tag that skips the schedule on a specific day (e.g., when working late).

πŸ— Infrastructure as Code Migrate the entire setup to Terraform or AWS CDK for repeatable, version-controlled deployments.


Summary

ComponentWhat It Does
EC2 Tag (AutoStop=true)Marks which instances to manage
get_tagged_instances()Safely fetches instances by tag + state
lambda_handler()Decides stop/start based on current UTC hour
EventBridge Cron RulesFires the Lambda at 8 AM and 8 PM daily
IAM RoleGrants Lambda permission to describe/stop/start EC2
CloudWatch LogsRecords every action for auditing and debugging

With this solution running, your non-production EC2 instances automatically hibernate each night and wake up each morning β€” saving costs, reducing your AWS bill, and requiring zero manual intervention.


Built with ❀️ using AWS Lambda, EventBridge, and boto3

Add More Questions to This Guide

Know questions that should be here? Share them and help the community!

Open Google Form