본문 바로가기
AWS Ambassador

[AWS 비용 최적화]AWS Lambda 기반 EC2 자동 중지/시작 솔루션

by 백룡화검 2025. 4. 23.

1. 들어가며

많은 기업에서 AWS EC2 인스턴스를 사용하면서도, 업무 외 시간 동안에도 인스턴스를 계속 실행시켜 놓는 경우가 많습니다. 이로 인해 불필요한 비용이 발생하곤 하죠. 특히 개발/테스트 용도의 인스턴스라면 이러한 낭비는 더욱 심각합니다.

이번 글에서는 실무에서 직접 활용한 Lambda와 EventBridge를 이용한 EC2 자동 중지/시작 솔루션을 공유하고자 합니다. 이를 통해 비용 절감뿐만 아니라 운영 효율성도 함께 도모할 수 있습니다.

2. 아키텍처 개요

솔루션의 구성은 다음과 같습니다:

  • Lambda: EC2 인스턴스를 시작하거나 중지하는 역할. Python 기반의 경량 함수로 작성되며, 태그 조건에 맞는 인스턴스를 선택적으로 제어합니다.
  • EventBridge: Lambda를 주기적으로 실행시키기 위한 스케줄러 역할. 크론 표현식을 사용하여 평일/주말 근무 시작/종료 시간 동안 Lambda를 트리거합니다.
  • IAM Role: Lambda 함수가 EC2 인스턴스를 제어할 수 있도록 최소 권한의 역할을 제공합니다.
  • EC2 Tags: 제어 대상 인스턴스를 명확히 구분하고, 근무 시작/종료 시간을 차등적용 하기 위해 사용자 정의 태그 ( scheduler )를 사용합니다.

이 구조는 별도의 관리 서버 없이 서버리스 방식으로 구현되며, 유지보수가 적고 확장성이 뛰어난 것이 장점입니다.

3. 사전 준비 사항

IAM Role 구성

다음 정책을 포함한 IAM 역할을 생성하고 Lambda에 연결합니다:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:StartInstances",
        "ec2:StopInstances",
        "ec2:CreateTags",
        "rds:StartDBCluster",
        "rds:StopDBCluster",
        "rds:DescribeDBEngineVersions",
        "rds:StopDBInstance",
        "rds:StartDBInstance",
        "rds:ListTagsForResource",
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters",
        "rds:AddTagsToResource",
        "rds:RemoveTagsFromResource",
        "tag:GetResources"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "*"
    }
  ]
}

EC2 태그 설정

관리하고자 하는 인스턴스에 다음과 같은 태그를 추가합니다:

  • Key: scheduler
  • Value: TRUE_{StartAt}{StopAt}_{ExpireDate}_{day/week} / FALSE_{ExpireDate}
    1. TRUE / FALSE
      : TRUE - 시간 외 중지 적용 / FALSE - 시간 외 중지 예외
    2. StartAt / StopAt : 시작시각 / 중지시각
       : 시작 시간 / 중지 시간 : 24시간 단위로 입력
    3. ExpireDate
      : 임시적으로 업무 시간 외에 추가 작업이 필요한 경우 예외 적용을 언제까지 유지할지 만료일자 작성
         - YYMMDD 형식으로 입력
      : 만료 일자 다음날 기본형식인 TRUE_0820_999999_day 로 태그 변경
         - 기본 값 - 999999
    4. day / week
      : 임시적으로 주말에 추가 작업이 필요하여 서버를 시작해야하는 경우 week로 설정하여 주말에도
         지정한 시간에 서버 시작 / 중지하도록 적용
          - 기본 값 - day (주중)

태그 값에 추가 제어를 원하는 정보들을 입력해서 다중 서비스 아키텍쳐 환경에서도 더욱 세밀한 제어가 가능합니다.

EventBridge의 TimeZone 고려

EventBridge의 rule은 UTC 기준으로 작동하기 때문에, rule로 Lambda를 트리거하는 경우 UTC와 KST의 9시간의 차이를 고려하여 설정하여야 합니다.

다만, 특정 시간을 기준으로 Cron 스케줄링만 설정하는 경우 EventBridge의 Scheduler를 사용하는 것이 좋습니다.

Scheduler는 TimeZone을 설정할 수 있어서 KST를 기준으로 Lambda 트리거가 가능합니다.

이번 솔루션도 Scheduler를 기반으로 Lambda를 트리거하도록 설정되었습니다.

4. Lambda 함수 구성

Python으로 구현된 Lambda 함수 예시는 다음과 같습니다. EventBridge의 Scheduler가 Lambda를 호출하고, Lambda는 호출한 Scheduler의 ARN을 기반으로 시작/중지를 구분합니다.

 

1. main.py : Lambda의 메인 함수 lambda_handler

  - EventBridge Scheduler가 Lambda를 트리거하는 이벤트 메세지 정보를 기반으로 호출되는 시간을 체크하고, start_resource / stop_resource 함수를 호출합니다.

import logging as logger
from datetime import date, datetime, timezone, timedelta
import stop_resource
import start_resource

logging = logger.getLogger()
logging.setLevel("INFO")
 
logger.basicConfig(
    format='%(asctime)s %(levelname)s: %(message)s',
    level=logger.INFO,
    datefmt='%m/%d/%Y %I:%M:%S %p',
)
 
def lambda_handler(event, context):
    event_arn = event['resources'][0]
 
    ###################### Lambda Scheduler 시작 ##########################
    logging.info(f'==============================================================================================')
    logging.info(f'Runnging Scheduler')
 
    event_time = event['time']
    utc_time = datetime.fromisoformat(event_time.replace('Z', '+00:00'))
    KST = timezone(timedelta(hours=9))
    kst_time_h = utc_time.astimezone(KST).strftime("%H")
    kst_time_m = utc_time.astimezone(KST).strftime("%M")
    kst_time = utc_time.astimezone(KST).strftime("%H:%M")
    week_day_ck = utc_time.astimezone(KST).weekday()
         
    if kst_time_h == '00':
        kst_time_h = '24'
 
    if 'StartScheduler-Rule' in event_arn:
        init = start_resource.start_resource(kst_time_h=kst_time_h, kst_time_m=kst_time_m, week=week_day_ck)
        start = init.start_instance()
        logging.info(f"start schedule complete")
    elif 'StopScheduler-Rule' in event_arn:
        init = stop_resource.stop_resource(kst_time_h=kst_time_h, week=week_day_ck)
        stop = init.stop_instance()
        logging.info(f"stop schedule complete")
    logging.info(f'Complete Scheduler')
    logging.info(f'==============================================================================================')
    ###################### Lambda Scheduler 종료 ##########################

 

2. start_resource.py : EC2 시작 함수

    - 프로세스 순서
       1. __init__에서 변수 선언 및 session() 실행해서 pagenator로 rds 및 ec2 전체 리소스 및 태그 값 호출
       2. 외부에서 start_instance() 호출 - process start_instance() -> create_list() -> start_instance()
       3. 필터링 과정은 create_list() 호출해서 rds 및 ec2 리소스를 구분하고 day / week 를 구분해서 각 변수에 저장
       4. start_instances()에서 self.kst_h와 scheduler 태그 값을 비교해서 서버 시작
       5. start_instances()에서 scheduler 태그에 week가 있는 경우 주말에도 서버 시작

import boto3
from botocore.exceptions import ClientError
import logging
import time
 
logging.basicConfig(
    format='%(asctime)s %(levelname)s: %(message)s',
    level=logging.INFO,
    datefmt='%m/%d/%Y %I:%M:%S %p',
)
 
 
class start_resource:
    def __init__(self, kst_time_h, kst_time_m, week):
        self.ec2_session = None
        self.rds_session = None
        self.rds_tag = []
        self.ec2_tag = []
        self.db_tag = []
        self.kst_h = kst_time_h
        self.kst_m = kst_time_m
        self.week = week
        # 00~10분에 트리거 되면 type은 ec2 / 30~40분에 트리거 되면 type은 db(rds + 설치형 db ec2 포함)
        if (int(self.kst_m) in list(range(00, 10))) or int(self.kst_m) in list(range(45, 59)):
            self.tag_type = 'ec2'
        else:
            self.tag_type = 'db'
        self.session()
 
    def session(self):
        self.ec2_session = boto3.client('ec2')
        self.rds_session = boto3.client('rds')
        # pagenator로 tag 리소스 정보를 가져와서 rds 및 ec2의 리소스 및 태그 정보 추출
        tag_session = boto3.client('resourcegroupstaggingapi')
        paginator = tag_session.get_paginator('get_resources')
        rds_all_tag = paginator.paginate(ResourceTypeFilters=['rds:db'])
        cluster_all_tag = paginator.paginate(ResourceTypeFilters=['rds:cluster'])
        for cluster_resource in cluster_all_tag:
            for rsc in cluster_resource['ResourceTagMappingList']:
                self.rds_tag.append(rsc)
        for rds_resource in rds_all_tag:
            for rsc in rds_resource['ResourceTagMappingList']:
                self.rds_tag.append(rsc)
        ec2_all_tag = paginator.paginate(ResourceTypeFilters=['ec2:instance'])
        for tag_resource in ec2_all_tag:
            for rsc in tag_resource['ResourceTagMappingList']:
                for tag in rsc['Tags']:
                    # ec2의 리소스는 설치형 DB 인스턴스와 일반 인스턴스로 나뉨
                    # 구분 방법은 Name Tag에 마지막이 "-DB"로 끝나는 경우 설치형 DB로 구분
                    if tag['Key'] == 'Name' and tag['Value'].endswith('-DB') == 1:
                        self.db_tag.append(rsc)
                    elif tag['Key'] == 'Name' and tag['Value'].endswith('-DB') != 1:
                        self.ec2_tag.append(rsc)
        return self.ec2_tag, self.db_tag, self.rds_tag
 
    def create_list(self, case):
        # 위에서 paginator로 가져온 전체 리소스 정보를 db와 ec2에 맞춰서 호출
        tag_resource = self.ec2_tag
        if self.tag_type == 'db':
            tag_resource = self.rds_tag + self.db_tag
        elif self.tag_type != 'ec2' and self.tag_type != 'db':
            logging.info(f'Please select type ec2 / rds')
            return None
        # info_list는 주중에 시작하는 리소스 tag : {'scheduler': 'TRUE_hhhh_yymmdd_day'} 인 리소스
        info_list = []
        # week_list는 주말에 시작하는 리소스 tag : {'scheduler': 'TRUE_hhhh_yymmdd_week'} 인 리소스
        week_list = []
        for i in tag_resource:
            resource_id = ''
            if ':ec2:' in i['ResourceARN']:
                resource_id = i['ResourceARN'].split('/')[1]
            elif ':rds:' in i['ResourceARN']:
                resource_id = i['ResourceARN'].split(':')[6]
            scheduler_tag = None
            name_tag = ''
            for tag in i['Tags']:
                if tag['Key'].lower() == 'scheduler':
                    scheduler_tag = tag['Value']
                if tag['Key'] == 'Name':
                    name_tag = tag['Value']
            # RDS 중에서 identifier가 cluster-로 시작하는 리소스는 클러스터 리소스라서 제외
            if scheduler_tag is not None and resource_id.startswith('cluster-') is False:
                # DB는 트리거되는 시간 기준인 self.kst_h에서 1시간을 더해줘서 tag값과 비교한다.
                # 이유는 DB의 경우 TRUE_0722_yymmdd_day로 설정된 리소스는 6시 30분에 시작해야해서 트리거되는 시간 기준이 6시이다.
                schedule_h = format(int(self.kst_h) + 1, '02')
                # ec2 타입은 설치형 DB 인스턴스와 일반 인스턴스가 같이 추출되기 때문에, 전체 리스트에서 구분해줘야 한다.
                # 이유는 설치형 DB 인스턴스와 RDS는 일반 인스턴스 보다 30분 먼저 실행되어야 하기 때문
                try:
                    # ec2는 self.kst_h와 태그 값의 시작 시간을 비교
                    tag_check = scheduler_tag.split('_')
                    if self.tag_type == 'ec2' and tag_check[0].lower() == 'true' and self.kst_h == tag_check[1][0:2]:
                        info_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag})
                        if tag_check[3] == 'week':
                            week_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag})
                    # db는 self.kst_h에 1시간을 더한 scheduler_h와 태그 값의 시작 시간을 비교
                    elif self.tag_type == 'db' and tag_check[0].lower() == 'true' and schedule_h == tag_check[1][0:2]:
                        info_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag})
                        if tag_check[3] == 'week':
                            week_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag})
                except Exception as e:
                    pass
        if case == 'resource':
            return info_list
        elif case == 'week':
            return week_list
 
    def start_instance(self):
        # list_ins는 시작되는 모든 리소스 정보를 로그에 남기기 위해서 사용
        # list_ec2는 start_instances로 한 번에 ec2를 시작하기 위해서 사용
        list_ins = []
        list_ec2 = []
        # create_list를 호출 할때 case 파라미터로 주중/주말을 구분해서 리소스를 호출함
        tag_resource = self.create_list(case='resource')
        if self.week in [5, 6]:
            tag_resource = self.create_list(case='week')
        for resource in tag_resource:
            try:
                ins_name = resource['name']
                # 1. ec2 리소스의 경우
                if self.tag_type == 'ec2':
                    if ins_name.endswith('-DB') != 1:
                        # 트리거는 00분에 되지만 지연을 고려해서 00~10분 까지로 설정
                        # 리소스 부족으로 t3a type이 시작되지 않는 경우 발생 - 45분에 한 번 더 트리거 시켜서 시작되지 않은 리소스 시작 시도함
                        if int(self.kst_m) in list(range(00, 10)) or int(self.kst_m) in list(range(45, 59)):
                            ins_id = resource['resource_id']
                            disable_stop = self.ec2_session.describe_instance_attribute(Attribute='disableApiStop', InstanceId=ins_id)['DisableApiStop']
                            status_stop = self.ec2_session.describe_instances(InstanceIds=[ins_id])['Reservations'][0]['Instances'][0]['State']['Code']
                            if disable_stop == {'Value': False} and status_stop == 80:
                                list_ec2.append(ins_id)
                                list_ins.append({ins_id: ins_name})
                # 2. DB 리소스의 경우
                elif self.tag_type == 'db':
                    ins_id = resource['resource_id']
                    # 2-1. DB 중에서 EC2에 설치형 DB 시작 진행
                    if ins_name.endswith('-DB') == 1 and ins_id.startswith('i-'):
                        if int(self.kst_m) in list(range(30, 40)):
                            disable_stop = self.ec2_session.describe_instance_attribute(Attribute='disableApiStop', InstanceId=ins_id)['DisableApiStop']
                            status_stop = self.ec2_session.describe_instances(InstanceIds=[ins_id])['Reservations'][0]['Instances'][0]['State']['Code']
                            if disable_stop == {'Value': False} and status_stop == 80:
                                list_ec2.append(ins_id)
                                list_ins.append({ins_id: ins_name})
                    # 2-2. DB 중에서 RDS 시작 진행
                    elif ins_id.startswith('i-') != 1:
                        if int(self.kst_m) in list(range(30, 40)):
                            # 2-2-1. DBInstance 리소스 시작
                            if 'aur' not in ins_id:
                                rds_response = self.rds_session.describe_db_instances(DBInstanceIdentifier=ins_id)
                                v_readReplica = []
                                for db_info in rds_response['DBInstances']:
                                    readReplica = db_info['ReadReplicaDBInstanceIdentifiers']
                                    v_readReplica.extend(readReplica)
                                for db_info in rds_response['DBInstances']:
                                    if db_info['Engine'] not in ['aurora-mysql', 'aurora-postgresql', 'docdb']:
                                        if db_info['DBInstanceIdentifier'] not in v_readReplica and db_info['DBInstanceStatus'] == 'stopped':
                                            tag_info_list = db_info['TagList']
                                            if 0 == len(tag_info_list):
                                                logging.info(f'DB Instance {0} is not part of autoshutdown'.format(db_info['DBInstanceIdentifier']))
                                            else:
                                                try:
                                                    self.rds_session.start_db_instance(DBInstanceIdentifier=db_info['DBInstanceIdentifier'])
                                                    list_ins.append(ins_id)
                                                except Exception as e:
                                                    logging.info(f'Exception : {e}\nResource : {ins_id}')
                                                    pass
                                                break
                            # 2-2-2. DBCluster 리소스 시작
                            else:
                                rds_response_clu = self.rds_session.describe_db_clusters(DBClusterIdentifier=ins_id)
                                for db_info in rds_response_clu['DBClusters']:
                                    if db_info['Engine'] not in ['docdb'] and db_info['Status'] == 'stopped':
                                        tag_info_list = db_info['TagList']
                                        if 0 == len(tag_info_list):
                                            logging.info(f'DB Cluster {0} is not part of autoshutdown'.format(db_info['DBClusterIdentifier']))
                                        else:
                                            try:
                                                self.rds_session.start_db_cluster(DBClusterIdentifier=db_info['DBClusterIdentifier'])
                                                list_ins.append(ins_id)
                                            except Exception as e:
                                                logging.info(f'Exception : {e}\nResource : {ins_id}')
                                                pass                               
                                            break
            except Exception as e:
                logging.info(f'Exception : {e}\nResource : {resource}')
                pass
        if str(list_ins) == '[]':
            logging.info(f'=====================================[No started resources]')
        elif str(list_ec2) == '[]':
            logging.info(f'=====================================Start resources count : {str(len(list_ins))}')
            logging.info(f'=====================================Start resources : {str(list_ins)}')
        else:
            # EC2를 시작시킬 때 AWS에서 할당된 리소스가 부족해서 서버가 시작되지 않는 경우 발생
            # 이를 방지하기 위해 'InsufficientInstanceCapacity' 에러가 발생하는 경우 3분 뒤에 다시 서버 시작하도록 설정
            for attempt in range(1, 4):
                try:
                    self.ec2_session.start_instances(InstanceIds=list_ec2)
                    logging.info(f'=====================================Start resources count : {str(len(list_ins))}')
                    logging.info(f'=====================================Start resources : {str(list_ins)}')
                    return list_ins
                except ClientError as error:
                    error_code = error.response['Error']['Code']
                    if error_code == 'InsufficientInstanceCapacity':
                        logging.info(f'API call limit exceeded; backing off and retrying...')
                        logging.info(f'Attempt {attempt} failed: {error_code}')
                        if attempt > 2:
                            logging.info(f'All attempts failed')
                            return None
                        else:
                            logging.info(f'Retrying... ({attempt}/3)')
                            time.sleep(180)
                    else:
                        raise error

 

3. stop_resource.py : EC2 중지 함수

  - 프로세스 순서
     1. __init__에서 변수 선언 및 session() 실행해서 pagenator로 rds 및 ec2 전체 리소스 및 태그 값 호출
     2. 외부에서 stop_instance() 호출 - process stop_instance() -> create_list() -> stop_instance() -> expire_check()
     3. 필터링 과정은 create_list() 호출해서 rds 및 ec2 리소스를 구분하고, 종료할 리소스와 만료확인용 리소스를 구분해서 각 변수에 저장
     4. stop_instances()에서 self.kst_h와 태그 값을 비교해서 서버 종료
     5. expire_check에서 어제 날짜와 scheduler 태그에 작성된 만료날짜를 확인해서 만료된 경우 TRUE_0820_999999_day (기본값)으로 변경

import boto3
import logging
import datetime as dt
 
logging.basicConfig(
    format='%(asctime)s %(levelname)s: %(message)s',
    level=logging.INFO,
    datefmt='%m/%d/%Y %I:%M:%S %p',
)
 
 
class stop_resource:
    def __init__(self, kst_time_h, week):
        self.ec2_session = None
        self.rds_session = None
        self.all_tag = []
        self.kst_h = kst_time_h
        self.week = week
        self.ec2_desc = None
        self.session()
 
    def session(self):
        self.ec2_session = boto3.client('ec2')
        self.rds_session = boto3.client('rds')
        # pagenator로 tag 리소스 정보를 가져와서 rds 및 ec2의 리소스 및 태그 정보 추출
        tag_session = boto3.client('resourcegroupstaggingapi')
        paginator = tag_session.get_paginator('get_resources')
        rds_all_tag = paginator.paginate(ResourceTypeFilters=['rds:db'])
        cluster_all_tag = paginator.paginate(ResourceTypeFilters=['rds:cluster'])
        for cluster_resource in cluster_all_tag:
            for rsc in cluster_resource['ResourceTagMappingList']:
                self.all_tag.append(rsc)
        for rds_resource in rds_all_tag:
            for rsc in rds_resource['ResourceTagMappingList']:
                self.all_tag.append(rsc)
        ec2_all_tag = paginator.paginate(ResourceTypeFilters=['ec2:instance'])
        for tag_resource in ec2_all_tag:
            for rsc in tag_resource['ResourceTagMappingList']:
                self.all_tag.append(rsc)
        # 종료는 시작과 다르게 ec2와 db를 구분하지 않고 동일한 시간에 같이 종료시킴
        return self.all_tag
 
    def create_list(self, case):
        tag_resource = self.all_tag
        # info_list는 종료할 리소스
        info_list = []
        # expire_list는 태그에 작성된 만료날짜를 확인하기 위한 리소스
        expire_list = []
        for i in tag_resource:
            resource_id = ''
            if ':ec2:' in i['ResourceARN']:
                resource_id = i['ResourceARN'].split('/')[1]
            elif ':rds:' in i['ResourceARN']:
                resource_id = i['ResourceARN'].split(':')[6]
            scheduler_tag = None
            name_tag = ''
            for tag in i['Tags']:
                if tag['Key'].lower() == 'scheduler'.lower():
                    scheduler_tag = tag['Value']
                if tag['Key'] == 'Name':
                    name_tag = tag['Value']
            try:
                # RDS 중에서 identifier가 cluster-로 시작하는 리소스는 클러스터 리소스라서 제외
                if scheduler_tag is not None and resource_id.startswith('cluster-') is False:
                    check_tag = scheduler_tag.split('_')
                    if check_tag[0].lower() == 'true' and self.kst_h == check_tag[1][2:4]:
                        info_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag})
                    if check_tag[0].lower() == 'false' and check_tag[1].startswith('9') is False:
                        expire_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag, 'resource_arn': i['ResourceARN']})
                    elif check_tag[2].startswith('9') is False:
                        expire_list.append({'resource_id': resource_id, 'scheduler': scheduler_tag, 'name': name_tag, 'resource_arn': i['ResourceARN']})
            except Exception as e:
                pass
        if case == 'resource':
            return info_list
        elif case == 'expire':
            return expire_list
 
    def stop_instance(self):
        list_ins = []
        list_ec2 = []
        tag_resource = self.create_list(case='resource')
        for resource in tag_resource:
            try:
                ins_name = resource['name']
                ins_id = resource['resource_id']
                # 1. 20:00가 기본 TRUE
                if ins_id.startswith('i-') == 1:
                    # ec2 인스턴스 먼저 종료 시작
                    ins_id = resource['resource_id']
                    disable_stop = self.ec2_session.describe_instance_attribute(Attribute='disableApiStop', InstanceId=ins_id)['DisableApiStop']
                    status_stop = self.ec2_session.describe_instances(InstanceIds=[ins_id])['Reservations'][0]['Instances'][0]['State']['Code']
                    if disable_stop == {'Value': False} and status_stop == 16:
                        list_ec2.append(ins_id)
                        list_ins.append({ins_id: ins_name})
                elif 'aur' not in ins_id:
                    # rds 종료 시작
                    rds_response = self.rds_session.describe_db_instances(DBInstanceIdentifier=ins_id)
                    v_readReplica = []
                    for db_info in rds_response['DBInstances']:
                        readReplica = db_info['ReadReplicaDBInstanceIdentifiers']
                        v_readReplica.extend(readReplica)
                    for db_info in rds_response['DBInstances']:
                        if db_info['Engine'] not in ['aurora-mysql', 'aurora-postgresql', 'docdb']:
                            if db_info['DBInstanceIdentifier'] not in v_readReplica and db_info['DBInstanceStatus'] == 'available':
                                tag_info_list = db_info['TagList']
                                if 0 == len(tag_info_list):
                                    logging.info(f'DB Instance {0} is not part of autoshutdown'.format(db_info['DBInstanceIdentifier']))
                                else:
                                    try:
                                        self.rds_session.stop_db_instance(DBInstanceIdentifier=db_info['DBInstanceIdentifier'])
                                        list_ins.append(ins_id)
                                    except Exception as e:
                                        logging.info(f'Exception : {e}')
                                        pass
                                    break
                else:
                    rds_response_clu = self.rds_session.describe_db_clusters(DBClusterIdentifier=ins_id)
                    for db_info in rds_response_clu['DBClusters']:
                        if db_info['Engine'] not in ['docdb'] and db_info['Status'] == 'available':
                            tag_info_list = db_info['TagList']
                            if 0 == len(tag_info_list):
                                logging.info(f'DB Cluster {0} is not part of autoshutdown'.format(db_info['DBClusterIdentifier']))
                            else:
                                try:
                                    self.rds_session.stop_db_cluster(DBClusterIdentifier=db_info['DBClusterIdentifier'])
                                    list_ins.append(ins_id)
                                except Exception as e:
                                    logging.info(f'Exception : {e}')
                                    pass
                                break
            except Exception as e:
                logging.info(f"Error : ", e)
                pass
        if str(list_ins) == '[]':
            logging.info(f'=====================================[No stopped resources]')
        elif str(list_ec2) == '[]':
            logging.info(f'=====================================Stop resources count : {str(len(list_ins))}')
            logging.info(f'=====================================Stop resources : {str(list_ins)}')
        else:
            self.ec2_session.stop_instances(InstanceIds=list_ec2)
            logging.info(f'=====================================Stop resources count : {str(len(list_ins))}')
            logging.info(f'=====================================Stop resources : {str(list_ins)}')
        # 모든 리소스 종료 후 태그 변경에 대해 만료된 태그 값이 있는지 확인
        self.expire_check()
        return list_ins
 
    def expire_check(self):
        # 기간이 만료된 태그는 TRUE로 변경하여 기본값(20시 종료)으로 변경함
        expire_result = self.create_list(case='expire')
        for expire_list in expire_result:
            resource_id = expire_list['resource_id']
            resource_arn = expire_list['resource_arn']
            check_tag = expire_list['scheduler'].split('_')
            expire_day = check_tag[1]
            if check_tag[0] == 'TRUE':
                expire_day = check_tag[2]
            if resource_id.startswith('i-') == 1:
                yesterday = (dt.datetime.now() - dt.timedelta(days=1)).strftime("%y%m%d")
                logging.info(f'{resource_id} - Current day : {yesterday}')
                logging.info(f'{resource_id} - Expire day : {expire_day}')
                if yesterday >= expire_day:
                    try:
                        logging.info(f'Scheduler exception is expired : {expire_day}')
                        self.ec2_session.create_tags(
                            Resources=[
                                resource_id
                            ],
                            Tags=[
                                {
                                    'Key': 'scheduler',
                                    'Value': 'TRUE_0820_999999_day'
                                }
                            ]
                        )
                    except Exception as e:
                        logging.info(f'Error occurred {e}')
                        pass
                else:
                    logging.info(f'No expire scheduler exception')
            else:
                yesterday = (dt.datetime.now() - dt.timedelta(days=1)).strftime("%y%m%d")
                logging.info(f'{resource_id} - Current day : {yesterday}')
                logging.info(f'{resource_id} - Expire day : {expire_day}')
                if yesterday >= expire_day:
                    try:
                        logging.info(f'Scheduler exception is expired : {expire_day}')
                        logging.info(f'exception rds is : {resource_id}')
                        self.rds_session.add_tags_to_resource(
                            ResourceName=resource_arn,
                            Tags=[
                                {
                                    'Key': 'scheduler',
                                    'Value': 'TRUE_0820_999999_day'
                                }
                            ]
                        )
                    except Exception as e:
                        logging.info(f'Error occurred {e}')
                        pass
                else:
                    logging.info(f'No expire scheduler exception')

 

5. EventBridge로 스케줄링하기

AWS EventBridge를 통해 Lambda를 주기적으로 실행합니다. scheduler를 사용하기 때문에, KST 기준으로 스케줄을 설정합니다.

이번 솔루션은 모든 서버가 동일한 시간에 시작하지 않고, 필요한 경우 1시간 일찍 시작하거나 1시간 늦게 중지하는 경우가 발생합니다.

그래서 리소스 시작 스케줄은 오전 05~08시 동안 매 정각, 30분, 45분에 Lambda를 호출하며, 리소스 중지 스케줄은 20시~01시 동안 lambda를 트리거하게 설정합니다. 

  • 리소스 시작 스케줄 (KST) → cron(00,30,45 05-08 * * ? *)
  • 리소스 중지 스케줄 (KST) → cron(0 20-01 * * ? *)

시작 스케줄이 00,30,45분에 설정되는 이유는 WAS <-> DB 사이의 정상적인 연동을 고려한 설정입니다.

일반적으로 WAS 보다 DB 엔진 기동 시에 시간이 더 소요되기 때문에, WAS보다 DB가 30분 먼저 start되게 설정되어 있습니다.

반대로 리소스 중지 시에는 WAS와 DB 엔진이 동시에 중지되어도 문제가 없기 때문에, 매 정각에만 lambda를 호출합니다.

EventBridge schedule와 Cron을 모두 적용하였다면, 타겟에 위에서 생성된 Lambda를 지정해줍니다.

6. 테스트 및 검증

시스템 도입 전, 반드시 테스트 환경에서 검증을 진행해야 합니다:

  • 테스트 인스턴스를 준비하고 태그를 적용
  • 수동으로 Lambda를 실행하여 로그 확인
  • CloudWatch를 통해 성공/실패 여부 모니터링
  • IAM 권한 부족, 태그 누락, 잘못된 인스턴스 ID 등 예외 상황 대비

또한 실무에서 발생했던 이슈 중 하나는 Lambda를 통해 EC2 시작 명령이 정상적으로 전송되었음에도 불구하고, EC2 인스턴스 내의 Application이 정상적으로 기동되지 않았던 사례였습니다. 이는 AWS 자체 특정 EC2 Type 리소스 부족, 의존 서비스의 지연, 혹은 애플리케이션 자체의 자동 실행 설정 누락으로 인해 발생할 수 있습니다.

이런 문제를 해결하기 위해 start_resource 코드에서 botocore.exceptions 패키지를 사용하여 InsufficientInstanceCapacity 에러가 발생하는 경우 3분 대기 후 재시도를 하도록 설정하였습니다. 또한, WAS <--> DB 연동을 위해 DB 엔진을 WAS 보다 30분 먼저 기동하도록 설정하였고, 애플리케이션을 서비스로 등록하여 OS 부팅 시 자동으로 실행되도록 설정하여 문제를 해결하였습니다.

또 다른 사례로는 EC2 중지 명령이 전달되었지만, 인스턴스가 'running' 상태에서 'stopped'로 전환되지 않으며 Lambda 에러가 발생하여 정상 작동하지 않는 경우가 있었으며, 이는 인스턴스에 "중지 보호"가 설정되어 있어 stop 관련 API 호출에서 에러가 발생하는 것으로 분석되었습니다. 이런 경우 코드에 리소스 속성 중 "중지 보호"가 설정된 경우 pass하는 설정을 추가하여 해결이 가능합니다.

이러한 점들을 고려하여 EC2 인스턴스의 상태와 내부 애플리케이션의 기동 여부까지 함께 모니터링할 수 있는 후속 확인 로직이나 헬스 체크를 Lambda 내 혹은 외부에서 수행하는 것을 권장합니다.

7. 비용 절감 효과 분석

한 달간 운영한 결과 다음과 같은 절감 효과를 확인할 수 있었습니다:

  • EC2 및 RDS 평균 실행 시간 약 40~50% 감소
  • 주말 24시간/day * 8일 + 주중 12시간/day * 20일 기준 약 432시간/월 절감
  • EC2 t3a.medium 기준 약 20 USD/월 절약 (1대당)
  • RDS db.t3.medium 기준 약 135 USD/월 절약 (1대당)
  • EC2 10대 운영 시 월 200 USD 절감
  • RDS 1대 운영 시 월 135 USD 절감

8. 운영 중 팁 & 확장 아이디어

태그 기반 확장

태그에 시간을 부여하여 더욱 세밀한 스케줄링이 가능하며, 추후 Python 코드 내에서 해당 태그를 파싱하는 방식으로 동작을 커스터마이징할 수 있습니다.

SSM과 연계

AWS Systems Manager와 연동하여 인스턴스 내부 작업(예: 캐시 초기화, 로그 정리) 등을 Lambda에서 직접 실행 가능하도록 확장할 수 있습니다.

대시보드 및 리포트 자동화

CloudWatch Metric과 로그 데이터를 활용하여 Athena + QuickSight로 대시보드를 구성하거나, 비용 리포트를 자동 발행하도록 연계할 수 있습니다.

9. 맺으며

이번 자동화 솔루션은 Lambda와 EventBridge만으로도 인프라 비용을 줄이는 데 매우 효과적임을 입증했습니다. 초기 설정만 잘 되어 있다면 별도의 유지보수 없이 장기적으로도 안정적인 효과를 낼 수 있습니다.

AWS Ambassador Chater로서 이러한 실무 기반의 사례를 통해 보다 많은 사용자들이 서버리스 기반의 자동화 혜택을 체감할 수 있도록 돕고자 합니다.

 

 

 

 

본 글은 MegazoneCloud의 AWS Ambassador 활동으로 작성된 글입니다.