[AWS 비용 절감 #3] Slack과 AWS Lambda를 버튼으로 연동하기
AWS 비용 절감 #1: S3 + CloudFront + HTTPS 정적 웹사이트
AWS 비용 절감 #2: EC2 인스턴스 예약 실행으로 비용 최적화
앞선 두 가지 방식으로 AWS 비용이 절반가량 줄었다.

하지만 EC2 인스턴스를 예약 실행하는 방식(오전 9시부터 오후 9시까지)을 도입하고 나니 한 가지 문제가 생겼다.
예약된 시간 외에 서버를 사용해야 할 경우, 다음과 같은 두 가지 방법이 있다.
- AWS 콘솔에 직접 접속하여 EC2 인스턴스를 시작하고, SSH로 접속해 필요한 컨테이너를 실행한다.
- Lambda의 실행 시간을 임시로 변경하여 원하는 시간에 EC2 인스턴스를 실행한다.
위 두 가지 방법 모두 가능하지만, 만약 내가 자리에 없는 상황에서 다른 팀원이 테스트 서버를 사용해야 한다면 즉각적인 대응이 어렵다는 문제가 있다.
이러한 문제를 해결하기 위해 현재 사용 중인 협업 도구인 Slack을 통해 특정 채널에 특정 명령어로 Lambda 를 trigger 해보기로 했다.
전체 아키텍처
[Slack User] -> [Slash Command] -> [Slack API] -> [AWS API Gateway] -> [AWS Lambda] -> [Amazon EC2]
- 사용자가 Slack 채널에서 특정 명령어(예:
/start-server)를 입력한다. - Slack은 이벤트를 AWS API Gateway로 전달한다.
- API Gateway는 요청을 받아 연결된 AWS Lambda 함수를 트리거한다.
- Lambda 함수는 EC2 인스턴스를 시작하는 코드를 실행하고, 실행 결과를 Slack으로 다시 보낸다
Slack App 생성
가장 먼저 Slack과 연동 작업을 수행할 Slack App을 생성한다.
Slack API 사이트 접속: https://api.slack.com/apps 로 이동하여
Create New App버튼을 클릭한다.앱 생성 방식 선택:
From scratch(처음부터 만들기)를 선택한다.앱 이름 및 워크스페이스 지정:
- App Name: 앱의 이름을 입력한다. (예:
slack-app-test) - Workspace: 앱을 설치할 Slack 워크스페이스를 선택한다.
Create App버튼을 클릭한다.

- App Name: 앱의 이름을 입력한다. (예:
기본 정보 확인: 앱이 생성되면
Basic Information페이지로 이동한다. 여기서 나중에 사용될 Signing Secret과 같은 중요한 정보를 확인할 수 있다.
앱 Install
Install App 에 들어가면 최소 하나의 권한을 줘야 install 을 할수있다는 문구가 뜰텐데,

OAuth & Permissions -> Scopes -> Add an OAuth Scope -> command, incoming-webhook 권한을 주도록 하자.
이후 install 버튼이 활성화 되는걸 확인할수 있고 install 을 누르게 되면 incoming-webhook 을 어느 채널에 게시할건지 물어보는 창이 뜬다. 이때 임시로 webhook 을 받을 채널을 만든후 설정해주자.
EC2 제어용 Lambda 함수 생성
Slack 요청을 받아 실제로 EC2 인스턴스를 제어할 Lambda 함수를 생성한다.
AWS Lambda 콘솔 접속: AWS 관리 콘솔에서 Lambda 서비스로 이동하여
함수 생성버튼을 클릭한다.함수 생성:
- Function name: 함수의 이름을 입력한다. (예:
slack-ec2-controller) - Runtime:
Python 3.13또는 원하는 언어를 선택한다. - Permissions:
AmazonEC2FullAccess,AmazonSSMFullAccess권한을 가진 역활을 연결해준다. (역활이 없다면 위 권한을 가진 역활을 만들어준후 연결해주면 된다.) - 추가 권한: 추가 권한은 아래와 같이 설정해준다. slack 에서 요청할 url 이 필요하기 떄문에 URL 을 활성화하고, 인증 유형을 None 으로 설정해준다. (slack 의 signature key를 검증할거기 때문에 괜찮다)

- Function name: 함수의 이름을 입력한다. (예:
IAM 역할에 EC2 권한 추가:
- ec2 에는 SSM 관련 권한을 줘야한다.
AmazonSSMManagedInstanceCore을 가진 IAM 역활을 ec2 에게 부여해준다.
- ec2 에는 SSM 관련 권한을 줘야한다.
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" } }- 다시 Lambda 함수
환경 변수 설정:
Lambda 구성 탭 > 환경 변수로 이동한다.
Edit을 클릭하고 다음 두 변수를 추가한다.SLACK_SIGNING_SECRET: 우리가 만들어줬던 App 의 Signing Secret 을 넣어주면 된다.
SLACK_WEBHOOK_URL: slack app의 Incoming Webhooks 에 들어가 Webhook URL 로 설정해준다.
일반 구성 -> 편집 으로 들어가 제한시간을 2~3분 정도로 늘려주자
Slack 에서 Command 를 통해 Lambda 호출
우리는 Slack App 에

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

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

이런 식으로 설정을 해주고 save 로 마무리 해준다.
테스트
이제 모든 설정이 끝났다.
슬랙으로 들어가 slash command 를 통해 ec2 가 꺼지고 켜지는지 테스트 하면 된다.

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

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

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

SSH 로 접속해서 확인하면 원하는 container 들이 모두 켜져있는걸 확인 할 수 있다.
📌 업그레이드 버전 (2026년 1월)
기존 Slack 커맨드 연동 방식에서 발견된 문제점들을 개선한 버전입니다.
기존 버전의 문제점
- 메시지가 나만 보임: 기존 코드는 응답이 "ephemeral" 방식으로 본인에게만 보여서 채널 히스토리에 남지 않습니다.
- Lambda 제한 시간: Lambda 최대 실행 시간(15분)이 지나면 타임아웃이 발생할 수 있습니다..
- 코드 구조: 단일 함수에 모든 로직이 들어있어 유지보수가 어렵습니다.
주요 개선사항
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일 후 삭제됨 | ✅ 영구 보존 |
'AWS' 카테고리의 다른 글
| CloudWatch Logs 를 통해 로그 수집하기 (0) | 2025.12.26 |
|---|---|
| CloudFormation을 활용한 이미지 변환 및 리사이징 (0) | 2025.11.18 |
| [AWS 비용 절감 #2] EC2 인스턴스 예약 실행으로 비용 최적화 (2) | 2025.07.04 |
| [AWS 비용 절감 #1] S3 + CloudFront + HTTPS 정적 웹사이트 (0) | 2025.05.19 |