AWS

[AWS 비용 절감 #2] EC2 인스턴스 예약 실행으로 비용 최적화

chanyoun 2025. 7. 4. 15:58
EC2 인스턴스 예약 실행으로 비용 최적화

[AWS 비용 절감 #2] EC2 인스턴스 예약 실행으로 비용 최적화

목표: 개발 서버 EC2 인스턴스를 평일 오전 10:00부터 오후 10:00까지만 활성화하여 불필요한 비용 발생을 방지한다.

구현 방식:

  • EventBridge (이벤트 브리지): 특정 시간에 Lambda 함수를 트리거하는 스케줄러 역할을 한다.
  • Lambda (람다): EC2 인스턴스를 시작하고 중지하는 코드를 실행한다.
  • IAM (Identity and Access Management): Lambda와 EC2가 서로 통신하고 필요한 작업을 수행할 수 있도록 권한을 부여한다.
  • SSM (Systems Manager): Lambda가 EC2 인스턴스 내부에서 명령어를 실행할 수 있도록 돕는다.

블루/그린 배포 환경 고려:

개발 서버는 블루/그린 배포 환경으로 구성되어 있다. Lambda 코드는 EC2 인스턴스 내의 파일을 확인하여 현재 활성화된 서버(블루 또는 그린)를 파악하고, 해당 서버만 실행하도록 구현한다.

 

전체 흐름 요약

  1. IAM 역할 생성: Lambda와 EC2에 필요한 권한을 부여하는 역할을 각각 생성한다.
  2. Lambda 함수 생성: EC2 인스턴스를 시작하고 중지하는 Python 코드로 Lambda 함수를 생성한다.
  3. EC2에 IAM 역할 부여: Lambda가 SSM을 통해 EC2 내부에서 명령어를 실행할 수 있도록 EC2에 생성한 IAM 역할을 연결한다.
  4. EventBridge 규칙 생성: 정해진 시간에 Lambda 함수를 트리거하도록 EventBridge 규칙을 생성하고 Lambda 함수와 연결한다.

 

1. IAM 역할 생성

 

1.1. Lambda를 위한 IAM 역할 생성

경로: IAM > 역할 > 역할 만들기

Lambda가 EC2를 제어하고, EC2 인스턴스가 켜졌을 때 스크립트를 실행하여 Docker 컨테이너를 실행할 수 있도록 미리 IAM 역할을 생성한다.

Lambda 역할 생성

필요 권한:

  • AmazonEC2FullAccess: EC2 인스턴스를 제어하기 위한 권한
  • AmazonSSMFullAccess: SSM을 통해 EC2 내부에서 명령어를 실행하기 위한 권한

위 권한을 추가하고 원하는 역할 이름을 설정하여 IAM 역할 생성을 완료한다.

 

1.2. EC2를 위한 IAM 역할 생성

경로: IAM > 역할 > 역할 만들기

EC2 인스턴스에서 SSM Agent가 정상적으로 동작하기 위해서는 AmazonSSMManagedInstanceCore 권한이 필요하다. 위와 동일한 방법으로 해당 권한을 가진 IAM 역할을 생성한다.

 

2. Lambda 함수 생성

경로: Lambda > 함수 > 함수 생성

Lambda 함수 생성

  • 함수 이름: 원하는 함수 이름을 입력한다.
  • 런타임: Python 3.9 (또는 원하는 언어)를 선택한다.
  • 아키텍처: x86_64를 선택한다.
  • 권한: "기존 역할 사용"을 선택하고, 1.1. Lambda를 위한 IAM 역할 생성에서 생성한 IAM 역할을 연결한다.

Lambda 역할 선택

Lambda 코드 작성

import boto3
import datetime
import time

region = 'ap-northeast-2 (REGION)'
instance_id = 'ec2-인스턴스-id'
ssm_doc_path = '/home/ubuntu/nginx/conf.d/service-container.inc' (ec2 종료되기전 실행했던 blue, green 이 저장되있는파일)

ec2 = boto3.client('ec2', region_name=region)
ssm = boto3.client('ssm', region_name=region)

def lambda_handler(event, context):
    now = datetime.datetime.utcnow()
    hour = now.hour
    minute = now.minute
    
    # 서버를 킨다.
    if hour == 1 and 0 <= minute <= 5: # 10:00 KST = 01:00 UTC
        # EC2 시작
        ec2.start_instances(InstanceIds=[instance_id])
        print("EC2 started.")

        # SSM Agent Online 될 때까지 기다리기
        while True:
            response = ssm.describe_instance_information(
                Filters=[
                    {
                        'Key': 'InstanceIds',
                        'Values': [instance_id]
                    }
                ]
            )
            instance_info = response['InstanceInformationList']
            if instance_info and instance_info[0]['PingStatus'] == 'Online':
                print("SSM Agent is Online!")
                break
            else:
                print("SSM Agent not ready... waiting 5s")
                time.sleep(5)

        # 2️⃣ dev-blue / dev-green 제외 나머지 서비스 up
        pre_command = f"""
        cd /home/ubuntu
        docker compose config --services | grep -vE "dev-blue|dev-green" | xargs -r docker compose up -d
        """
        response_pre = ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName="AWS-RunShellScript",
            Parameters={'commands': [pre_command]},
        )
        print("SSM Run Command for other services sent:", response_pre)

        # 3️⃣ dev-blue / dev-green 중 하나만 up
        command = f"""
        cd /home/ubuntu
        if grep -q "dev-blue" {ssm_doc_path}; then
            docker compose up -d dev-blue
        elif grep -q "dev-green" {ssm_doc_path}; then
            docker compose up -d dev-green
        else
            echo "No active container info."
        fi
        """
        response = ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName="AWS-RunShellScript",
            Parameters={'commands': [command]},
        )
        print("SSM Run Command for dev-blue/green sent:", response)

    # ✅ 2️⃣ 서버 종료
    elif hour == 13 and 0 <= minute <= 1:  # 22:00 KST = 13:00 UTC
        ec2.stop_instances(InstanceIds=[instance_id])
        print("EC2 stopped.")
    else:
        print("No action at this time.")
        print(f"Current time: {hour}:{minute}")

작성한 코드를 Lambda 함수에 적용하고 Deploy 버튼을 클릭한다.

 

Lambda 제한 시간 설정:

경로: 구성 > 일반 구성 > 편집 > 제한 시간

Lambda 함수의 기본 제한 시간은 3초이다. EC2 인스턴스 시작 및 스크립트 실행에 시간이 더 걸릴 수 있으므로 제한 시간을 충분히 늘려준다. (예: 1분)

 

3. EC2에 IAM 역할 부여

경로: EC2 > 인스턴스 > (해당 인스턴스 선택) > 작업 > 보안 > IAM 역할 수정

1.2. EC2를 위한 IAM 역할 생성에서 생성한 AmazonSSMManagedInstanceCore 권한을 가진 IAM 역할을 EC2 인스턴스에 연결한다.

 

4. EventBridge 규칙 생성

경로: Amazon EventBridge > 규칙 > 규칙 생성

EC2 인스턴스를 시작하고 중지하는 두 가지 규칙을 각각 생성한다.

EventBridge 규칙 생성

 

4.1. EC2 시작 규칙

  • 규칙 유형: 일정을 선택한다.

  • Cron 표현식: 0 1 ? * MON-FRI * (매주 월요일부터 금요일까지 01:00 UTC에 실행)

    • 참고: KST 기준 오전 10:00는 UTC 기준 01:00이다.

EventBridge Cron 표현식

  • 대상 선택:

    • 대상: Lambda 함수
    • 함수: 위에서 생성한 Lambda 함수를 선택한다.

 

4.2. EC2 중지 규칙

  • 규칙 유형: 일정을 선택한다.

  • Cron 표현식: 0 13 ? * MON-FRI * (매주 월요일부터 금요일까지 13:00 UTC에 실행)

    • 참고: KST 기준 오후 10:00는 UTC 기준 13:00이다.
  • 대상 선택: 시작 규칙과 동일하게 설정한다.

 

세 줄 요약

  1. IAM 역할을 생성하여 Lambda와 EC2에 필요한 권한을 부여한다.
  2. EC2를 켜고 끄는 Lambda 함수를 작성하고, EventBridge를 사용하여 특정 시간에 함수를 트리거하도록 설정한다.
  3. EC2에 IAM 역할을 연결하여 Lambda가 SSM을 통해 원격으로 명령을 실행할 수 있도록 한다.

 


 

📌 업그레이드 버전 (2026년 1월)

기존 버전에서는 사용자가 지정한 시간에 EventBridge에 의해 서버가 실제로 운영 중인지 확인하기 어려웠습니다.

해당 어려움을 자동 스케줄러에 Slack 알림 기능을 추가하여 서버 상태를 실시간으로 확인할 수 있도록 개선했습니다.

 

주요 개선사항

1. Slack Webhook 알림 기능 추가

  • 서버 시작 시: "🚀 개발 서버를 시작합니다... (10:00 KST)"
  • 서버 종료 시: "🛑 개발 서버가 종료되었습니다. (22:00 KST)"
  • 이미 실행 중일 때: "ℹ️ 개발 서버가 이미 실행 중입니다."
  • 모든 서비스 시작 완료 시: "✅ 개발 서버와 모든 서비스가 정상적으로 시작되었습니다!"

 

2. 개선된 Lambda 코드

import boto3
import datetime
import time
import json
import os
import urllib.request

region = 'ap-northeast-2'  # 예: ap-northeast-2
instance_id = 'instance-id'

ec2 = boto3.client('ec2', region_name=region)
ssm = boto3.client('ssm', region_name=region)

# Slack Webhook URL (환경 변수에서 가져오기)
SLACK_WEBHOOK_URL = os.environ.get('SLACK_WEBHOOK_URL', '')


def send_slack_message(text):
    """Slack Webhook으로 메시지 전송"""
    if not SLACK_WEBHOOK_URL:
        print("SLACK_WEBHOOK_URL not set, skipping notification")
        return

    payload = json.dumps({"text": text}).encode('utf-8')
    req = urllib.request.Request(
        SLACK_WEBHOOK_URL,
        data=payload,
        headers={'Content-Type': 'application/json'},
        method='POST'
    )
    try:
        urllib.request.urlopen(req)
        print(f"Slack message sent: {text}")
    except Exception as e:
        print(f"Failed to send Slack message: {e}")


def lambda_handler(event, context):
    now = datetime.datetime.utcnow()
    hour = now.hour
    minute = now.minute

    # ------------------------------------------------------------
    # EC2 상태 확인
    # ------------------------------------------------------------
    def is_instance_running():
        res = ec2.describe_instances(InstanceIds=[instance_id])
        state = res['Reservations'][0]['Instances'][0]['State']['Name']
        return state == "running"

    # ------------------------------------------------------------
    # STARTUP (KST 10:00 → UTC 01:00)
    # ------------------------------------------------------------
    if hour == 1 and 0 <= minute <= 20:

        # 이미 켜져 있으면 전부 skip
        if is_instance_running():
            print("EC2 already running. Skip ALL startup steps.")
            send_slack_message("ℹ️ 개발 서버가 이미 실행 중입니다. (스케줄: 10:00 KST)")
            return {"status": "skipped_running"}

        # 1) EC2 켜기
        ec2.start_instances(InstanceIds=[instance_id])
        print("EC2 started.")
        send_slack_message("🚀 개발 서버를 시작합니다... (10:00 KST)")

        # 2) SSM Agent Online 대기
        while True:
            info = ssm.describe_instance_information(
                Filters=[{'Key': 'InstanceIds', 'Values': [instance_id]}]
            )['InstanceInformationList']

            if info and info[0]['PingStatus'] == 'Online':
                print("SSM Agent Online.")
                break

            print("Waiting for SSM Agent… (5s)")
            time.sleep(5)

    		# TODO: 배포에 필요한 작업

        send_slack_message("✅ 개발 서버와 모든 서비스가 정상적으로 시작되었습니다!")

        return {"status": "started"}

    # ------------------------------------------------------------
    # SHUTDOWN (KST 22:00 → UTC 13:00)
    # ------------------------------------------------------------
    elif hour == 13 and 0 <= minute <= 20:
        ec2.stop_instances(InstanceIds=[instance_id])
        print("EC2 stopped.")
        send_slack_message("🛑 개발 서버가 종료되었습니다. (22:00 KST)")
        return {"status": "stopped"}

    # ------------------------------------------------------------
    # NOTHING
    # ------------------------------------------------------------
    else:
        print(f"No action at this time. ({hour}:{minute})")
        return {"status": "noop", "time": f"{hour}:{minute}"}

3. 환경 변수 설정

Lambda 함수에 다음 환경 변수를 추가해야 합니다:

  • SLACK_WEBHOOK_URL: Slack Incoming Webhook URL

경로: Lambda > 함수 > 구성 > 환경 변수 > 편집

 

4. 기존 코드와의 차이점

구분기존 버전업그레이드 버전
알림 기능❌ 없음✅ Slack 알림 지원
서버 중복 실행 감지단순 skipSlack 알림으로 명확히 표시
서비스 시작 완료 알림❌ 없음✅ 완료 메시지 전송
코드 구조인라인 코드함수 분리로 가독성 향상