AWS 비용 최적화(2) : 요청 없으면 EC2 종료

김두현·2024년 12월 27일
1

AWS

목록 보기
3/3
post-thumbnail

📍개요


이전 포스팅 AWS 비용 최적화(1) : 요청 시 EC2 실행에서 원할 때만 EC2가 실행되게끔 하여 비용을 절감하는 프로세스를 구현했다.

그러나 이것만으론 개발을 마치고 EC2를 직접 끄지 않으면, 다시 하루종일 돈이 줄줄 새는 상황이 발생한다.
따라서 일정 시간 이상 API 요청이 없으면 EC2를 종료하는 프로세스를 구현하고자 했다.

✔️ 요구사항

30분 내에 API 요청이 없는 경우, EC2가 종료되게끔 구현하고자 한다.

즉, 이 작업은 AWS 서비스 중 가장 많은 금액이 청구되는 EC2의 활성화 시간을 줄이는 과정이다.
EC2가 중지되면 자동 할당되는 퍼블릭 IPv4또한 사라지기 때문에, 시간 당 금액이 청구되는 VPC와 EC2의 요금이 모두 감소한다.

📍AWS 아키텍처


🔑 활용 서비스

  • AWS IAM
  • AWS Lambda
  • AWS EventBridge

  1. EventBridge를 통해 15초마다 Lambda 함수를 실행한다.
  2. Lambda 함수는 DB에서 마지막 API 호출 시각을 조회한다.
  3. 마지막 호출 시각으로부터 30분 이상 경과되었는지 확인한다.
  4. 30분 이상 경과되었다면 EC2를 종료한다.

1️⃣ DB에 마지막 API 요청 시각 저장


우선 마지막 요청 시각을 저장하는 로직을 추가해야 했다.

1. DB 설계

ADMIN 모듈과 USER 모듈 각각의 요청 시각이 필요했기 때문에, 두 개의 필드와 두 개의 tuple만으로 프로세스 구현이 가능했다.

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class ApiRequest {

  @Id
  @Enumerated(value = EnumType.STRING)
  @Comment("요청받은 모듈")
  private ApiModule module;

  @LastModifiedDate
  private LocalDateTime lastRequestTime;

  @Builder
  public ApiRequest(ApiModule module) {
    this.module = module;
  }
}

2. Logging AOP 수정

모든 API에 대해 처리해야 하므로, 기존에 Logging을 위해 사용 중이던 AOP를 활용했다.
controller로 요청이 들어오면 DB에 저장하도록 구현했고, 주기적으로 수행되는 healthCheck는 제외했다.

@Aspect
@Slf4j
@Component
@RequiredArgsConstructor
public class LoggingAspect {

  private final ApiRequestRepository apiRequestRepository;

  @Pointcut("execution(* org.example..*Controller.*(..))")
  public void controller() {
  }

  @Before("controller()")
  public void createApiRequest(JoinPoint joinPoint) {
  	// Health Check 제외
    if (joinPoint.getSignature().getName().equals("healthCheck")) return;
    
    apiRequestRepository.save(
      ApiRequest.builder()
        .module(ApiModule.ADMIN_MODULE)
        .build()
    );
  }
  
  // 기타 로직

}

위 로직을 ADMIN 모듈과 USER 모듈에 모두 추가해주었다.

2️⃣ AWS IAM 역할 생성


Lambda 함수가 EC2를 종료할 수 있도록 권한을 부여해야 하는데, 이를 위한 AWS IAM 역할을 생성했다.

1. 권한 정책 생성

우선 IAM 역할에 부여하기 위한 권한 정책을 생성했다.

IAM > 정책 > 정책 생성 > 정책 편집기 JSON으로 이동해 아래 코드를 삽입했으며,
Cloudwatch 쓰기 권한과 EC2 읽기, 쓰기 권한을 부여하는 내용이다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ec2:Start*",
        "ec2:Stop*"
      ],
      "Resource": "*"
    }
  ]
}

2. 역할 생성

IAM > 역할 > 역할 생성으로 이동해 아래와 같이 설정한 후, 앞서 생성한 권한 정책을 부여해 역할 생성을 마무리했다.

3️⃣ AWS Lambda 생성


이제 Lambda를 생성해야 한다.
Lambda > 함수 > 함수 생성으로 이동한 후, 아래와 같이 설정했다.

Python으로 작성할 것이므로 런타임을 Python3으로 설정하고, 기본 실행 역할을 앞서 생성한 IAM 역할로 설정했다.

4️⃣ Lambda Layer 생성 및 할당


Python 코드를 작성하기에 앞서, DB 접속과 Timezone 설정을 위한 라이브러리를 추가해야 한다.
pymysqlpytz 라이브러리를 사용할 예정이나, AWS에서 기본으로 제공되지 않아 로컬에서 설치한 후 업로드 해야한다.

필자는 Lambda Layer를 활용해 필요한 라이브러리를 간편하게 업로드할 수 있었다.

1. 로컬에서 라이브러리 zip 파일 생성

# pytz 파일 내에 pytz 라이브러리 설치
pip install pytz -t pytz

# zip 파일로 압축
zip -r pytz.zip ./pytz

# pymysql 파일 내에 pymysql 라이브러리 설치
pip install pymysql -t pymysql

# zip 파일로 압축
zip -r pymysql.zip ./pymysql

위 명령어를 실행하면 각 라이브러리의 zip 파일이 생성된다.

2. Lambda Layer 생성

Lambda > 계층 > 계층 생성으로 이동해 아래와 같이 설정했다. 두 zip 파일에 대해 동일하게 진행했다.

3. Lambda 함수에 Layer 할당

Lambda 함수로 돌아와 가장 아래로 내리면 계층 콘솔을 확인할 수 있다.
[Add a layer]를 통해 위에서 생성한 두 개의 Lambda Layer를 할당했다.

이로써 Lambda 함수에서 pymysqlpytz 라이브러리를 사용할 수 있게 되었다.

만일 Python 코드 작성 도중 Error importing {xxx} in AWS Lambda: 'No module named {xxx}와 같은 에러가 발생한다면, 동일한 방식으로 Layer를 생성하여 추가해주면 해결된다.

5️⃣ DB 조회 및 EC2 종료 코드 구현


Lambda를 생성했으므로, 이어서 함수를 구현했다.
Python의 boto3 라이브러리를 활용하면 간단하게 EC2를 종료할 수 있다.

1. 환경 변수 설정

코드 작성에 앞서, 보안을 위해 코드에서 사용할 값을 따로 관리하도록 환경 변수를 설정했다.

Lambda 함수 > 구성 > 환경 변수에서 설정했다.

여기서 설정한 값은 os.getenv() 명령을 통해 가져올 수 있다.

2. 코드 작성

사진의 코드 소스 영역에 코드를 아래와 같이 작성했다.

import pymysql
import os
from datetime import datetime, timedelta
import json
import boto3
import pytz
region = 'ap-northeast-2'
ec2 = boto3.client('ec2', region_name=region)

def lambda_handler(event, context):
	# 환경변수 조회
    db_host = os.getenv("DB_HOST")
    db_user = os.getenv("DB_USERNAME")
    db_password = os.getenv("DB_PASSWORD")
    db_name = os.getenv("DB_NAME")
    db_port = int(os.getenv("DB_PORT"))
    
    connection = None
    try:
        connection = pymysql.connect(
            host=db_host,
            user=db_user,
            password=db_password,
            database=db_name,
            port=db_port,
            connect_timeout=15 # 15초
        )
        
        # 쿼리 실행
        with connection.cursor() as cursor:
            query = "SELECT module, last_request_time FROM api_request;"
            cursor.execute(query)
            results = cursor.fetchall()  # 쿼리 결과
            
            # 결과 출력
            print("조회 결과:")
            for row in results:
                module, last_request_time = row
                
                # datetime 객체를 문자열로 변환
                if isinstance(last_request_time, datetime):
                    last_request_time = last_request_time.strftime('%Y-%m-%d %H:%M:%S')
                # 30분 이전인지 체크
                if isinstance(last_request_time, str):
                    last_request_time = datetime.strptime(last_request_time, '%Y-%m-%d %H:%M:%S')
                # Asia/Seoul 시간대 지정
                seoul_tz = pytz.timezone('Asia/Seoul')
                if last_request_time.tzinfo is None:  # offset-naive datetime인지 확인
                    last_request_time = seoul_tz.localize(last_request_time)

                current_time = datetime.now(seoul_tz)
                time_diff = current_time - last_request_time
                
                # 30분 이상 요청 없으면 EC2 종료
                if time_diff > timedelta(minutes=30):
                    if (module == 'USER_MODULE'):
                        print(f"EC2 종료 : {module}")
                        ec2.stop_instances(InstanceIds=['i-xxxxxxx'])
                    elif (module == 'ADMIN_MODULE'):
                        print(f"EC2 종료 : {module}")
                        ec2.stop_instances(InstanceIds=['i-xxxxxxx'])

    except Exception as e:
        print(f"오류 발생: {str(e)}")
    finally:
        if connection:
            connection.close()

환경변수의 경우 Lambda 함수 > 구성 > 환경 변수로 이동해 설정할 수 있다.
이후 코드 소스 왼쪽의 Deploy를 클릭하면 Lambda에 소스코드가 적용된다.

추가로 사진의 EventBridge는 캡쳐 시점이 다른 것이기 때문에, 현재 단계에서는 없는게 정상이다.

6️⃣ AWS EventBridge 트리거 설정


이제 주기적으로 Lambda 함수가 실행되도록 EventBridge를 연결만 주면 끝이다.

Lambda 함수에 들어가 함수 개요 > 트리거 설정으로 들어간 뒤, 아래와 같이 설정했다.

트리거가 잘 연결되었다면, Lambda 함수 > 구성 > 트리거에서 아래와 같이 연결된 EventBridge를 확인할 수 있다.

이로써 15분마다 Lambda 함수가 실행되어 30분간 API 요청이 없었다면 EC2가 종료된다!

👏 마무리


평소 문외한이었던 DevOps를 최적화하는 것에 대한 부담감이 컸고, EC2 종료 프로세스의 경우 레퍼런스를 찾지 못해 오롯이 뇌피셜로만 진행한만큼 뿌듯함이 컸다.

모니터링 시스템으로 자주 활용되는 CloudWatch를 활용할까 했으나, 새로운 부품을 추가하는 것보다 기존의 부품을 적소에 활용하는 것이 훨씬 깔끔하다고 생각하여 이러한 프로세스로 설계하게 되었다.

이로써 EC2를 활성화하더라도 'EC2 잊지말고 꺼야해..'와 같은 불안함 없이 개발을 할 수 있게 되었고,
EC2 시작 프로세스와의 시너지로 EC2의 활성 시간이 대폭 줄어 AWS 비용을 50% 가까이 절감될 것으로 예상된다.
정확한 절감 수치는 청구서가 나온 후 분석할 예정이다.

아직 설계를 해보진 않았지만, 현재 오직 HTTPS만을 위해 사용되고 있는 Elastic Load Balancer를 대신할 수 있는 것을 도입해 비용을 더욱 최적화할 수 있을 것으로 예상된다.
성공한다면 해당 과정도 포스팅하도록 하겠다.


💕오류 지적 및 피드백은 언제든 환영입니다. 복제시 출처 남겨주세요!💕
💕좋아요와 댓글은 큰 힘이 됩니다.💕
profile
I AM WHO I AM

0개의 댓글

관련 채용 정보