AWS

[AWS 비용 절감 #3]slack과 aws의 lambda 연동

chanyoun 2025. 7. 9. 15:13
slack과 aws의 lambda 연동

[AWS 비용 절감 #3] Slack과 AWS Lambda를 버튼으로 연동하기

AWS 비용 절감 #1: S3 + CloudFront + HTTPS 정적 웹사이트

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

 

앞선 두 가지 방식으로 AWS 비용이 절반가량 줄었다.

image-20250709130010551

하지만 EC2 인스턴스를 예약 실행하는 방식(오전 9시부터 오후 9시까지)을 도입하고 나니 한 가지 문제가 생겼다.

예약된 시간 외에 서버를 사용해야 할 경우, 다음과 같은 두 가지 방법이 있다.

  1. AWS 콘솔에 직접 접속하여 EC2 인스턴스를 시작하고, SSH로 접속해 필요한 컨테이너를 실행한다.
  2. Lambda의 실행 시간을 임시로 변경하여 원하는 시간에 EC2 인스턴스를 실행한다.

위 두 가지 방법 모두 가능하지만, 만약 내가 자리에 없는 상황에서 다른 팀원이 테스트 서버를 사용해야 한다면 즉각적인 대응이 어렵다는 문제가 있다.

이러한 문제를 해결하기 위해 현재 사용 중인 협업 도구인 Slack을 통해 특정 채널에 특정 명령어로 Lambda 를 trigger 해보기로 했다.

 

전체 아키텍처

[Slack User] -> [Slash Command] -> [Slack API] -> [AWS API Gateway] -> [AWS Lambda] -> [Amazon EC2]
  1. 사용자가 Slack 채널에서 특정 명령어(예: /start-server)를 입력한다.
  2. Slack은 이벤트를 AWS API Gateway로 전달한다.
  3. API Gateway는 요청을 받아 연결된 AWS Lambda 함수를 트리거한다.
  4. Lambda 함수는 EC2 인스턴스를 시작하는 코드를 실행하고, 실행 결과를 Slack으로 다시 보낸다

 

Slack App 생성

가장 먼저 Slack과 연동 작업을 수행할 Slack App을 생성한다.

  1. Slack API 사이트 접속: https://api.slack.com/apps 로 이동하여 Create New App 버튼을 클릭한다.

  2. 앱 생성 방식 선택: From scratch (처음부터 만들기)를 선택한다.

  3. 앱 이름 및 워크스페이스 지정:

    • App Name: 앱의 이름을 입력한다. (예: slack-app-test)
    • Workspace: 앱을 설치할 Slack 워크스페이스를 선택한다.
    • Create App 버튼을 클릭한다.

    image-20250709132038140

  4. 기본 정보 확인: 앱이 생성되면 Basic Information 페이지로 이동한다. 여기서 나중에 사용될 Signing Secret과 같은 중요한 정보를 확인할 수 있다.

    image-20250709132348535

  5. 앱 Install

    Install App 에 들어가면 최소 하나의 권한을 줘야 install 을 할수있다는 문구가 뜰텐데,

    스크린샷 2025-07-09 오후 2.07.26

    OAuth & Permissions -> Scopes -> Add an OAuth Scope -> command, incoming-webhook 권한을 주도록 하자.

     

    이후 install 버튼이 활성화 되는걸 확인할수 있고 install 을 누르게 되면 incoming-webhook 을 어느 채널에 게시할건지 물어보는 창이 뜬다. 이때 임시로 webhook 을 받을 채널을 만든후 설정해주자.

 

EC2 제어용 Lambda 함수 생성

Slack 요청을 받아 실제로 EC2 인스턴스를 제어할 Lambda 함수를 생성한다.

  1. AWS Lambda 콘솔 접속: AWS 관리 콘솔에서 Lambda 서비스로 이동하여 함수 생성 버튼을 클릭한다.

  2. 함수 생성:

    • Function name: 함수의 이름을 입력한다. (예: slack-ec2-controller)
    • Runtime: Python 3.13 또는 원하는 언어를 선택한다.
    • Permissions: AmazonEC2FullAccess, AmazonSSMFullAccess 권한을 가진 역활을 연결해준다. (역활이 없다면 위 권한을 가진 역활을 만들어준후 연결해주면 된다.)
    • 추가 권한: 추가 권한은 아래와 같이 설정해준다. slack 에서 요청할 url 이 필요하기 떄문에 URL 을 활성화하고, 인증 유형을 None 으로 설정해준다. (slack 의 signature key를 검증할거기 때문에 괜찮다)

    image-20250709142330245

  3. IAM 역할에 EC2 권한 추가:

    • ec2 에는 SSM 관련 권한을 줘야한다. AmazonSSMManagedInstanceCore 을 가진 IAM 역활을 ec2 에게 부여해준다.
  4. Lambda 함수 코드 작성:

    • 다시 Lambda 함수 Code 탭으로 돌아와 lambda_function.py 파일에 아래 코드를 붙여넣는다. 이 코드는 Slack의 요청을 받아 EC2 인스턴스를 시작하고 결과를 Slack에 알린다.
    import boto3
    import json
    import os
    import time
    import hmac
    import hashlib
    import base64
    import urllib.request
    
    # 환경 변수로부터 필요한 값 읽기
    region = 'ap-northeast-2'
    instance_id = '인스턴스-id'
    signing_secret = os.environ['SLACK_SIGNING_SECRET']
    ssm_doc_path = '/home/ubuntu/nginx/conf.d/service-container.inc' # green, blue 중 최근에 배포된게 뭔지 확인할수 있는 파일 경로
    
    # AWS 클라이언트 초기화
    ec2 = boto3.client('ec2', region_name=region)
    ssm = boto3.client('ssm', region_name=region)
    
    def notify_slack(text):
        webhook_url = os.environ['SLACK_WEBHOOK_URL']
        payload = json.dumps({"text": text}).encode("utf-8")
    
        try:
            req = urllib.request.Request(
                webhook_url,
                data=payload,
                headers={"Content-Type": "application/json"},
                method="POST"
            )
            with urllib.request.urlopen(req) as res:
                print(f"Slack notify status: {res.status}")
        except Exception as e:
            print(f"Slack message failed: {e}")
    
    
    # Slack Signature 검증 함수
    def is_valid_slack_request(headers, body, is_base64_encoded):
        slack_signature = headers.get('x-slack-signature', '')
        slack_timestamp = headers.get('x-slack-request-timestamp', '')
        
        if is_base64_encoded:
            body = base64.b64decode(body).decode('utf-8')
    
        if not slack_signature or not slack_timestamp:
            return False
    
        if abs(time.time() - int(slack_timestamp)) > 60 * 5:
            print("❌ Timestamp too old")
            return False
    
        base_string = f"v0:{slack_timestamp}:{body}".encode("utf-8")
        my_signature = "v0=" + hmac.new(
            signing_secret.encode("utf-8"),
            base_string,
            hashlib.sha256
        ).hexdigest()
    
        return hmac.compare_digest(my_signature, slack_signature)
    
    def lambda_handler(event, context):
        headers = event.get("headers", {})
        body = event.get("body", "")
        is_base64_encoded = event.get("isBase64Encoded", False)
    
        if not is_valid_slack_request(headers, body, is_base64_encoded):
            return {
                "statusCode": 401,
                "body": "Unauthorized"
            }
    
        try:
            response = ec2.describe_instance_status(
                InstanceIds=[instance_id],
                IncludeAllInstances=True
            )
            state = response['InstanceStatuses'][0]['InstanceState']['Name']
            print(f"💡 EC2 현재 상태: {state}")
    
            if state in ['stopped', 'stopping']:
                ec2.start_instances(InstanceIds=[instance_id])
                print("☁️ EC2 시작, SSM 대기 중...")
    
                # SSM PingStatus 확인
                while True:
                    info = ssm.describe_instance_information(
                        Filters=[{'Key': 'InstanceIds', 'Values': [instance_id]}]
                    )
                    instance_info = info.get('InstanceInformationList', [])
                    if instance_info and instance_info[0]['PingStatus'] == 'Online':
                        break
                    time.sleep(5)
    
                # 서비스 up
                pre_command = """
                cd /home/ubuntu
                sudo docker compose config --services | grep -vE "dev-blue|dev-green" | xargs -r docker compose up -d
                """
                ssm.send_command(
                    InstanceIds=[instance_id],
                    DocumentName="AWS-RunShellScript",
                    Parameters={'commands': [pre_command]},
                )
    
                main_command = f"""
                cd /home/ubuntu
                if grep -q "dev-blue" {ssm_doc_path}; then
                    sudo docker compose up -d dev-blue
                elif grep -q "dev-green" {ssm_doc_path}; then
                    sudo docker compose up -d dev-green
                else
                    echo "No active container info."
                fi
                """
                ssm.send_command(
                    InstanceIds=[instance_id],
                    DocumentName="AWS-RunShellScript",
                    Parameters={'commands': [main_command]},
                )
    
                notify_slack("✅ EC2 인스턴스를 시작하고 서비스를 실행했습니다.")
            
            elif state in ['running', 'pending']:
                ec2.stop_instances(InstanceIds=[instance_id])
                notify_slack("🛑 EC2 인스턴스를 중지했습니다.")
            
            else:
                notify_slack(f"⚠️ 현재 상태({state})에서는 작업을 수행할 수 없습니다.")
    
        except Exception as e:
            print(f"❌ 작업 중 오류 발생: {e}")
            notify_slack(f"❌ 작업 중 오류 발생: {e}")
    
        return {
            "statusCode": 200,
            "body": json.dumps({"text": "⏳ 요청을 처리 중입니다..."}),
            "headers": {
                "Content-Type": "application/json"
            }
        }
    
    
  5. 환경 변수 설정:

    • Lambda 구성 탭 > 환경 변수로 이동한다.

    • Edit을 클릭하고 다음 두 변수를 추가한다.

      • SLACK_SIGNING_SECRET: 우리가 만들어줬던 App 의 Signing Secret 을 넣어주면 된다.

        스크린샷 2025-07-09 오후 2.28.30

      • SLACK_WEBHOOK_URL : slack app의 Incoming Webhooks 에 들어가 Webhook URL 로 설정해준다.

    • 일반 구성 -> 편집 으로 들어가 제한시간을 2~3분 정도로 늘려주자

 

Slack 에서 Command 를 통해 Lambda 호출

우리는 Slack App 에

image-20250709143405214

2개의 권한을 줬다.

commands : slack 채널에서 command 를 통해 Lambda 를 트리거 하는 용도이다.

incoming-webhook : 우리가 command 로 Lambda 를 트리거 하고 결과를 특정 채널로 보내주기 위함이다.

 

incoming-webhook 은 위에서 설정을 했고, 이제 남은건 commands 를 통해 람다를 트리거 하는 동작이다.

slack api 의 Slash Commands 에 들어가 Create New Command 를 눌러 새로운 command 를 만들어 준다.

  • command : 원하는 command 명

  • request: url : lambda 의 함수 URL

    image-20250709143812478

그외 설정은 입맛에 맞게 하면 된다.

image-20250709143921997

이런 식으로 설정을 해주고 save 로 마무리 해준다.

 

테스트

이제 모든 설정이 끝났다.

슬랙으로 들어가 slash command 를 통해 ec2 가 꺼지고 켜지는지 테스트 하면 된다.

image-20250709144041900

우리가 원하는 slash command 를 확인할수 있다.

기존 test 서버는 켜져있는 상태였다. 따라서 해당 command 를 입력하면 아래와 같이 중지했다는 응답이 webbhook 에 의해 보인다.

image-20250709144147796

실제 aws ec2 에 들어가서 봐도 마지막 인스턴스가 종료중인걸 확인할 수 있다.

스크린샷 2025-07-09 오후 2.42.17

다시한번 /test-trigger 를 누르면 아래와 같이 서비스를 실행했다는 메세지를 받을수있으며

image-20250709144439788

SSH 로 접속해서 확인하면 원하는 container 들이 모두 켜져있는걸 확인 할 수 있다.스크린샷 2025-07-09 오후 2.45.58


 

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

기존 Slack 커맨드 연동 방식에서 발견된 문제점들을 개선한 버전입니다.

 

기존 버전의 문제점

  1. 메시지가 나만 보임: 기존 코드는 응답이 "ephemeral" 방식으로 본인에게만 보여서 채널 히스토리에 남지 않습니다.
  2. Lambda 제한 시간: Lambda 최대 실행 시간(15분)이 지나면 타임아웃이 발생할 수 있습니다..
  3. 코드 구조: 단일 함수에 모든 로직이 들어있어 유지보수가 어렵습니다.

 

주요 개선사항

1. 채널 전체에 메시지 표시

  • response_type: "in_channel" 추가로 모든 팀원이 메시지 확인 가능
  • 30일 후 채널 자동 삭제 문제 해결

2. 비동기 처리

  • Lambda가 자기 자신을 비동기로 재호출하여 15분 제한 시간 문제 해결
  • 즉시 응답(⏳ 서버 작업을 시작했습니다...)을 먼저 보내고, 실제 작업은 백그라운드에서 진행

3. 개선된 코드 구조

  • 함수 분리로 가독성 향상
  • send_followup(), wait_for_ssm(), async_worker() 등 명확한 역할 분담

 

개선된 Lambda 코드

import boto3
import json
import os
import time
import hmac
import hashlib
import base64
import urllib.request
import urllib.parse


# ===== 환경 설정 =====
REGION = "지역"
INSTANCE_ID = "인스턴스 아이디"

SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"]
FUNCTION_NAME = os.environ["AWS_LAMBDA_FUNCTION_NAME"]

ec2 = boto3.client("ec2", region_name=REGION)
ssm = boto3.client("ssm", region_name=REGION)
lambda_client = boto3.client("lambda")


def send_followup(response_url: str, text: str):
    """Slack followup 메시지 전송 (채널 전체에 보이도록)"""
    payload = json.dumps({
        "text": text,
        "response_type": "in_channel"
    }).encode("utf-8")
    req = urllib.request.Request(
        response_url, data=payload,
        headers={"Content-Type": "application/json"},
        method="POST"
    )
    urllib.request.urlopen(req)


def is_valid_slack_request(headers: dict, raw_body: str) -> bool:
    headers_lower = {k.lower(): v for k, v in headers.items()}
    slack_signature = headers_lower.get("x-slack-signature", "")
    slack_timestamp = headers_lower.get("x-slack-request-timestamp", "")

    if not slack_signature or not slack_timestamp:
        return False

    if abs(time.time() - int(slack_timestamp)) > 60 * 5:
        return False

    base_string = f"v0:{slack_timestamp}:{raw_body}".encode("utf-8")
    my_signature = "v0=" + hmac.new(
        SLACK_SIGNING_SECRET.encode("utf-8"),
        base_string,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(my_signature, slack_signature)


# ------------------------------------------------------------
# 🔥 EC2 시작 + SSM 명령
# ------------------------------------------------------------
def wait_for_ssm(instance_id, timeout=600):
    print("[SSM] Waiting for Online...")
    deadline = time.time() + timeout

    while time.time() < deadline:
        info = ssm.describe_instance_information(
            Filters=[{"Key": "InstanceIds", "Values": [instance_id]}]
        )
        info_list = info.get("InstanceInformationList", [])

        if info_list and info_list[0]["PingStatus"] == "Online":
            print("[SSM] Online")
            return True

        time.sleep(5)

    raise TimeoutError("[SSM] Instance did not become online")


def start_instance_and_services(instance_id: str, response_url: str):
    ec2.start_instances(InstanceIds=[instance_id])
    print("[EC2] Starting instance...")

    time.sleep(5)
    wait_for_ssm(instance_id)

    # 공용 서비스 돌리기

    # TODO: 배포에 필요한 작업

    send_followup(response_url, "🚀 EC2 인스턴스가 시작되고 서비스가 정상적으로 실행되었습니다!")


def stop_instance(instance_id: str, response_url: str):
    ec2.stop_instances(InstanceIds=[instance_id])
    send_followup(response_url, "🛑 EC2 인스턴스가 중지되었습니다.")


# ------------------------------------------------------------
# 🔥 비동기로 실행되는 worker
# ------------------------------------------------------------
def async_worker(response_url: str):
    print("[Worker] Async execution started")

    state_resp = ec2.describe_instance_status(
        InstanceIds=[INSTANCE_ID],
        IncludeAllInstances=True,
    )
    statuses = state_resp.get("InstanceStatuses", [])

    if not statuses:
        # fallback
        instance_info = ec2.describe_instances(InstanceIds=[INSTANCE_ID])
        state = instance_info["Reservations"][0]["Instances"][0]["State"]["Name"]
    else:
        state = statuses[0]["InstanceState"]["Name"]

    print("[Worker] Current EC2 state:", state)

    if state in ("stopped", "stopping"):
        start_instance_and_services(INSTANCE_ID, response_url)

    elif state in ("running", "pending"):
        stop_instance(INSTANCE_ID, response_url)

    else:
        send_followup(response_url, f"⚠️ 상태({state})에서는 작업을 수행할 수 없습니다.")


# ------------------------------------------------------------
# 🔥 Lambda Handler
# ------------------------------------------------------------
def lambda_handler(event, context):
    # 비동기 worker 콜이면 여기로 들어옴
    if event.get("type") == "async_worker":
        async_worker(event["response_url"])
        return {"statusCode": 200}

    # 정상 Slack Command 요청
    headers = event.get("headers") or {}
    body = event.get("body") or ""
    is_b64 = event.get("isBase64Encoded", False)

    raw_body = base64.b64decode(body).decode("utf-8") if is_b64 else body

    if not is_valid_slack_request(headers, raw_body):
        return {"statusCode": 401, "body": "Unauthorized"}

    parsed = urllib.parse.parse_qs(raw_body)
    response_url = parsed.get("response_url", [""])[0]

    # Lambda 비동기 재호출
    lambda_client.invoke(
        FunctionName=FUNCTION_NAME,
        InvocationType="Event",
        Payload=json.dumps({
            "type": "async_worker",
            "response_url": response_url
        })
    )

    return {
        "statusCode": 200,
        "body": json.dumps({
            "text": "⏳ 서버 작업을 시작했습니다...",
            "response_type": "in_channel"
        }),
        "headers": {"Content-Type": "application/json"}
    }

 

기존 버전과의 차이점

구분기존 버전업그레이드 버전
메시지 표시나만 보임 (ephemeral)채널 전체 표시 (in_channel)
Lambda 제한 시간직접 실행 (최대 15분)비동기 재호출로 우회
응답 속도작업 완료 후 응답즉시 응답 + 백그라운드 처리
코드 구조단일 함수역할별 함수 분리
채널 자동 삭제❌ 30일 후 삭제됨✅ 영구 보존