AWS Lambda로 EC2 인스턴스 스케쥴링#1

AWS Lambda로 EC2 인스턴스 스케쥴링#1

Why?

EC2의 프리티어 인스턴스는 t2.micro 사양일때 월 750간을 무료로 쓸 수 있다. 즉, 매월 1개의 인스턴스는 무료로 사용할 수 있다는 뜻이다. 하지만 지금 진행중인 프로젝트는 2개의 인스턴스를 필요로 한다. API 서버용으로 사용되는 인스턴스 1개와, 크롤링 작업을 위한 인스턴스 1개이다. 이처럼 2개의 인스턴스를 필요로하는 프로젝트이기 때문에, 불가피하게 비용이 발생할 수 밖에 없다.

하지만 나는 가난한 자취생이기 때문에 비용을 아껴보고자 한다. 그래서 생각해낸 아이디어는 "크롤러는 어차피 하루에 10분정도만 돌면된다.", "필요할 때만 인스턴스가 켜지고 작동되면 최대한 비용을 아낄 수 있겠다!" 였다.

참고로 중지상태의 EC2 인스턴스는 완전 무료상태에 있지는 않다, 어찌되었건 그 인스턴스의 내용물들이 AWS 데이터 센터에 저장되어있는 비용은 발생하기 때문이다. 하지만 인스턴스 사용료나 데이터 송수신 시 발생하는 비용은 절감할 수 있다. 상세 내용은 여기

How to ?

기본 구성

  • EC2 인스턴스에 있는 크롤러는 shell script로 작동한다.

    • poetry 가상환경을 활성화 하고 >> 환경변수를 설정하고 >> 크롤러 파일을 실행시키는 스크립트이다.

  • AWS Lambda에서 아래의 동작을 할 수 있도록 한다.

    1. 종료된 EC2 인스턴스를 키고

    2. 위의 shell script를 실행하고

    3. 스크립트가 완료되었을 시 EC2 인스턴스를 종료시킨다.

  • AWS Event Bridge에서 위의 Lambda 서비스가 일정에 따라 작동되도록 설정한다.

Lambda로 EC2 컨트롤하기

  • AWS Lambda는 별도의 서버없이도 함수를 올려두면 그 함수를 실행할 수 있게 해주는 AWS의 서비스이다. AWS Lambda에서 Python을 이용해 이 로직을 구현해보고자 한다.

    • AWS의 서비스를 GUI 환경에서 컨트롤하려면 웹브라우저로 AWS 사이트에 로그인해야한다.

    • AWS의 서비스를 CLI 환경에서 컨트롤 하려면 AWS CLI를 설치해서 사용하면 된다.

    • AWS의 서비스를 프로그래밍 언어로 컨트롤 하려면 AWS에서 제공하는 각 언어별 SDK를 사용하면 된다. !!

    • Python으로 AWS의 서비스를 컨트롤하려면 boto3라는 SDK 를 사용하면 된다.

    • 참고로 이 boto3는 AWS Lambda에서 Python 언어로 코드를 작성하기로 했다면, 기본으로 내장되어있는 것을 사용할 수 있다. 따로 라이브러리를 install 을 하지 않아도 import 해서 쓸 수 있다는 뜻이다.

1. EC2 인스턴스를 실행해보자

  • boto3로 생성한 ec2 클라이언트의 start_instance 메서드를 이용하여 인스턴스를 실행한다.

  • describe_instance_status 메서드를 이용하여 인스턴스가 정상적으로 켜질때 까지 대기한다.

  • 예시 코드는 아래와 같다.

    • DryRun 은 http의 options 메서드 같은 것이다. 내가 이 명령을 수행할 수 있는지만 확인하고자 할때 사용한다. 나는 실제로 명령을 실행해야하기 때문에 DryRun=Falsef로 두고 사용했다.
    import time

    import boto3

    def start_instance():
        access_key = "AWS_ACCESS_KEY"
        secret_key = "AWS_SECRET_KEY"
        region = "ap-northeast-2"
        instance_id = "AWS_INSTANCE_ID"

        # boto3 EC2 클라이언트 인스턴스 생성
        ec2_client = boto3.client(
            "ec2",
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            region_name=region
        )

        start_instance(instance_id, ec2_client)

        instance_running = False
        while instance_running is False:
            instance_running = check_instance_status(instance_id, ec2_client)
            time.sleep(20)


    def start_instance(instance_id: str, client):
        return client.start_instances(
            InstanceIds=[instance_id], DryRun=False
        )

    def check_instance_status(instance_id: str, client):
        response = client.describe_instance_status(
            Filters=[
                {
                    "Name": "instance-state-name",
                    "Values": ["running"]
                }
            ],
            InstanceIds=[instance_id],
            DryRun=False,
            IncludeAllInstances=True
        )
        return is_instance_running(response)

2. EC2 인스턴스로 명령어를 보내 shell script를 실행시키자

  • ec2 인스턴스로 명령어를 보내기 위해서는 위에서 선언한 ec2 클라이언트 인스턴스가 아닌, ssm 클라이언트 인스턴스를 생성해야한다.

    • 여기서 ssm은 AWS의 Systems Manager의 기능을 이용할 수 있는 boto3의 클라이언트 속성이다.
  • boto3로 생성한 ssm 클라이언트의 send_command 메서드를 이용하여 EC2인스턴스로 명령어를 보낸다.

    • list 타입의 commands를 인자로 넣어주면 순서대로 그 command들을 주어진 EC2 인스턴스에서 실행시키게 된다.
  • 해당 명령어가 AWS의 Systems Manager에 등록되며 실행된다.

  • get_command_invocation 메서드를 이용하여 Systems Manager에 등록된 명령어의 실행이 완료될때까지 대기한다.

  • SSM을 이용해 EC2인스턴스에 원격 명령을 보내려면, IAM에서 AmazonSSMManagedInstanceCore 권한을 열어줘야한다. 그렇지 않으면 send_command에 입력된 instance_id에 접근권한이 없다는 에러메시지가 뜰 것이다.

  • 예시 코드는 아래와 같다.

import time

import boto3

def run_command():
    access_key = "AWS_ACCESS_KEY"
    secret_key = "AWS_SECRET_KEY"
    region = "ap-northeast-2"
    instance_id = "AWS_INSTANCE_ID"

    # boto3 SSM 클라이언트 인스턴스 생성
    ssm_client = boto3.client(
        "ssm",
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key,
        region_name=region
    )

    run_crawl_job = run_crawler(instance_id, ssm_client)
    command_id = run_crawl_job.get("Command").get("CommandId")
    command_status = "Pending"
    while command_status == "Pending" or command_status == "InProgress" or command_status == "Delayed":
        command_status = get_command_status(command_id, instance_id, ssm_client)
        time.sleep(20)

    return


def run_crawler(instance_id: str, client):
    # 입력받은 EC2 인스턴스에서 실행하고자하는 명령어들
    commands = ['cd /home/ec2-user/zfind-crawler/', 'sh run.sh']
    response = client.send_command(
        DocumentName="AWS-RunShellScript",
        Parameters={"commands": commands},
        InstanceIds=[instance_id],
        CloudWatchOutputConfig={
            'CloudWatchLogGroupName': 'zfind-crawler-log',
            'CloudWatchOutputEnabled': True
        }
    )
    return response


def get_command_status(command_id: str, instance_id: str, client):
    response = client.get_command_invocation(
        CommandId=command_id,
        InstanceId=instance_id,
    )
    return response.get("Status")

3. 명령이 종료되면 인스턴스를 중지하도록 하자.

  • boto3로 생성한 ec2 클라이언트의 stop_instance 메서드를 이용하여 EC2 인스턴스를 중지한다.

  • 예시 코드는 아래와 같다.

import boto3

def stop_instance():
    access_key = "AWS_ACCESS_KEY"
    secret_key = "AWS_SECRET_KEY"
    region = "ap-northeast-2"
    instance_id = "AWS_INSTANCE_ID"

    # boto3 EC2 클라이언트 인스턴스 생성
    ec2_client = boto3.client(
        "ec2",
        aws_access_key_id=access_key,
        aws_secret_access_key=secret_key,
        region_name=region
    )

    ec2_client.stop_instances(
        InstanceIds=[instance_id], DryRun=False
    )

최종적으로 나는 아래와 같은 스크립트 코드를 짜서 AWS Lambda에 업로드했다.

import time

import boto3

def lambda_handler(event, context):
   access_key = "AWS_ACCESS_KEY"
   secret_key = "AWS_SECRET_KEY"
   region = "ap-northeast-2"
   instance_id = "AWS_INSTANCE_ID"

   ec2_client = boto3.client(
       "ec2",
       aws_access_key_id=access_key,
       aws_secret_access_key=secret_key,
       region_name=region
   )

   ssm_client = boto3.client(
       "ssm",
       aws_access_key_id=access_key,
       aws_secret_access_key=secret_key,
       region_name=region
   )

   start_instance(instance_id, ec2_client)

   instance_running = False
   while instance_running is False:
       instance_running = check_instance_status(instance_id, ec2_client)

   run_crawl_job = run_crawler(instance_id, ssm_client)
   command_id = run_crawl_job.get("Command").get("CommandId")

   command_status = "Pending"
   while command_status == "Pending" or command_status == "InProgress" or command_status == "Delayed":
       command_status = get_command_status(command_id, instance_id, ssm_client)
       time.sleep(20)

   stop_instance(instance_id, ec2_client)
   return


def start_instance(instance_id: str, client):
   return client.start_instances(
       InstanceIds=[instance_id], DryRun=False
   )


def stop_instance(instance_id: str, client):
   return client.stop_instances(
       InstanceIds=[instance_id], DryRun=False
   )


def check_instance_status(instance_id: str, client):
   response = client.describe_instance_status(
       Filters=[
           {
               "Name": "instance-state-name",
               "Values": ["running"]
           }
       ],
       InstanceIds=[instance_id],
       DryRun=False,
       IncludeAllInstances=True
   )
   return is_instance_running(response)


def run_crawler(instance_id: str, client):
   commands = ['cd /home/ec2-user/zfind-crawler/', 'sh run.sh']
   response = client.send_command(
       DocumentName="AWS-RunShellScript",
       Parameters={"commands": commands},
       InstanceIds=[instance_id],
       CloudWatchOutputConfig={
           'CloudWatchLogGroupName': 'zfind-crawler-log',
           'CloudWatchOutputEnabled': True
       }
   )
   return response


def is_instance_running(response: dict) -> bool:
   return len(response.get("InstanceStatuses", [])) > 0


def get_command_status(command_id: str, instance_id: str, client):
   response = client.get_command_invocation(
       CommandId=command_id,
       InstanceId=instance_id,
   )
   return response.get("Status")

AWS Systems Manager에서 명령을 제데로 처리하는지 확인

Lambda에 업로드한 코드가 잘 작동하는지 테스트해보자. 아마 lambda에서 송신하여 작동시키는 ec2 인스턴스 프로그램이 3초안에 끝날만큼 빠르게 완료되지 않는다면, lambda는 타임아웃을 뱉을 것이다. 내가 만든 크롤러는 시작부터 종료까지 약 4분가량의 시간이 소요된다.

하지만 lambda의 기본 설정상 함수의 시작부터 종료까지 3초의 제한시간 내에 동작하도록 설정되어있을 것이다. Lambda에서 함수를 선택하고 구성 > 일반구성으로 들어가보면 함수의 제한시간을 설정할 수 있다. 넉넉하게 15분으로 설정해두자. (최대가 15분이다.) 메모리나 임시스토리지도 설정할 수 있지만, 내가 짠 코드는 함수 자체에서 뭔가를 작업하는 것이 아니고, 단순히 boto3 SDK를 이용해 EC2 인스턴스로 명령에 관한 정보만 송수신하기 때문에 굳이 건들 필요는 없다.

이렇게 하고 다시 테스트를 해보면, 1. 종료된 EC2 인스턴스가 켜지고, 2. SSM으로 지정한 EC2 인스턴스에 명령을 송신하고, 3. 명령이 완료되면 인스턴스를 종료하는 코드가 정상 작동함을 확인 할 수 있다.

매우 간편하게 1번과 3번 과정은 EC2 대시보드에서 인스턴스의 상태가 running > stop 으로 바뀌는 것을 통해 확인해볼 수 있고, 2번 과정은 명령 송신후 해당 명령의 정상 동작 여부를 AWS SSM 콘솔에서 확인 할 수 있다.

먼저 AWS SSM 콘솔로 들어간 뒤 좌측 메뉴중 명령 실행 부분을 클릭하면 위의 lambda 코드에서 boto3의 ssm client로 작동을 지시한 명령을 확인 할 수 있다.

스케쥴링 테스트를 위해 Mon, 18, Jul 2022 06시부터 10시까지 주기적인 일정으로 명령이 실행된 것을 확인해볼 수 있다.

정리

  • EC2로 배치작업을 수행할때 수행되는 시간만큼만 인스턴스를 활성화시켜 비용을 절감할 수 있다.

  • Lambda 이용해, EC2를 키고 -> 배치 프로그램을 실행시키고 -> 완료되면 EC2를 중단하는 트리거 함수를 만들어 등록해두고.

  • EventBridge로 그 함수의 실행을 스케쥴링 하는 것이다. (이건 다음 포스팅에서 설명)

  • Lambda로 EC2를 컨트롤 하기 위해서는 AWS에서 제공하는 SDK를 이용해야하는데, python에서는 boto3를 사용할 수 있다.

  • 이 과정에서 주의해야할 점은 boto3 클라이언트를 실행하는 IAM 계정에 SSM관련 권한을 부여해야하는 것과, lambda의 함수 실행 제한시간을 적당히 조절해주는 것이다.


다음 포스팅에서는 EventBridge로 Lambda 함수의 실행을 스케쥴링 하는 방법을 확인해보자.