카테고리 없음

Slack 기반 GCE Managed Instance Group 오토스케일링 자동화 구축 가이드 (Pub/Sub 연동, 상태 알림 포함)

달빛궁전- 2025. 8. 8. 10:00
목표 및 배경

목표 : Slack에서 버튼 한 번 클릭만으로 GCP Regional Managed Instance Group(MIG)의 인스턴스 개수를 신속하게 조정

배경 : 운영자들이 수동으로 GCP 콘솔/명령어 대신, 더 친숙한 Slack 환경에서 증설·감소를 할 수 있도록 함
Pub/Sub 기반 비동기 아키텍처로 Cloud Run이 혹여 동작을 못하였다고 해도 확인 후 재시도가 가능합니다.
증설/감소 시 현 상태(기존 인스턴스 수 → 변경 후 인스턴스 수)를 명확하게 Slack으로 안내받아 실시간 모니터링 효과까지 볼 수 있습니다.

 

1. 아키텍처

 

1-1. 아키텍처 설명

Slack (/auto 명령 or 버튼)
  ↓
Cloud Run 서비스(슬랙 이벤트 트리거)
  ↓ (Pub/Sub 메시지 발행)
Pub/Sub (증설/감소 요청 큐)
  ↓ (푸시 구독)
Cloud Run Processor (MIG 정보 조회 + 증설/감소 실행)
  ↓
Slack Webhook(채널/DM)로 결과 메시지: "기존 x대에서 y대로 증설/감소됨"

서비스 계정

서비스 계정 용도Roles 권한부여주요 권한(Permission, 실제 API/용도)
서비스 계정    
트리거용 Cloud Run
(Slack → Pub/Sub)
Pub/Sub Publisher pubsub.topics.publish: Pub/Sub 토픽에 메시지 게시
pubsub.topics.get: 토픽 정보 읽기
Cloud Run Invoker run.routes.invoke: Cloud Run 서비스 HTTP/HTTPS 엔드포인트 인증 호출
프로세서용 Cloud Run
(Pub/Sub → MIG 조정)
Compute Instance Admin (v1) compute.instanceGroupManagers.get: MIG 상태 조회
compute.instanceGroupManagers.resize: MIG 증설/감소
compute.instances.get/insert/delete: VM 등 Compute 인스턴스 관리
Pub/Sub Subscriber pubsub.subscriptions.consume: Pub/Sub 구독 메시지 수신/처리(pull, ack 포함)
Cloud Run Invoker run.routes.invoke: Cloud Run Processor 엔드포인트 인증 POST 허용
Pub/Sub 푸시 인증용 시스템 계정 Service Account Token Creator iam.serviceAccounts.getAccessToken: 서비스 계정 액세스 토큰 생성
iam.serviceAccounts.getOpenIdToken: OIDC 토큰 생성
iam.serviceAccounts.signJwt: JWT 서명 (Pub/Sub Push 인증 등)

(A) 트리거용 Cloud Run 서비스 계정에 부여할 최소 권한

  • roles/pubsub.publisher
    : Slack 이벤트를 받아 Pub/Sub 메시지를 발행하기 위한 권한입니다.
  • roles/run.invoker
    : 필요한 경우 Cloud Run 서비스 호출 권한입니다. (Slack, Pub/Sub 호출 허용)
 

(B) 프로세서용 Cloud Run 서비스 계정에 부여할 최소 권한

  • roles/compute.instanceGroupManager
    : GCE Managed Instance Group(Regional MIG 등) 크기 조회 및 변경 권한
  • roles/pubsub.subscriber
    : Pub/Sub 메시지 구독 및 수신 권한
  • roles/run.invoker
    : Pub/Sub 푸시 호출 권한 (Cloud Run 서비스 접근 허용)
 

(C) Pub/Sub 푸시 인증용 서비스 계정 (자동 시스템 계정)

  • Google Cloud에서 자동 생성하는 계정 예:
    service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com
  • 역할:
    roles/iam.serviceAccountTokenCreator
    : Pub/Sub가 인증된 토큰을 생성하여 Cloud Run 서비스에 안전하게 HTTP 푸시 호출하기 위해 필요합니다.

2. Cloud Run 기본 환경 변수 (env.list)

아래의 내용을 환경변수로 지정합니다.
env.list로 따로 저장을 합니다.
키:값 쌍으로 표현되어야 하기 때문에 YAML 포맷으로 작성합니다.
GCP_PROJECT_ID: ctu-gcp-dsa2-unit // 프로젝트 ID
GCE_REGION: asia-northeast3 // AutoScling 대상 REGION
MIG_NAME: my-mig-web-prod // GCE Instance Group Name
CATEGORY_TARGET_SIZE_JSON: '{"Low":2,"Normal":4,"High":8,"Very High":12}' // 증설 카테고리
SLACK_SIGNING_SECRET: xoxb-your-signing-secret // slack Secret Key
PUBSUB_TOPIC: mig-autoscale-requests // Pubsub 주제
SLACK_RESULT_WEBHOOK_URL: https://hooks.slack.com/services/XXXX/XXXX/XXXX // slack Webhook 메시지 발송 URL

3. Slack 트리거용 Cloud Run 서비스 (main.py)

import os
import json
import time
import hmac
import hashlib
from flask import Flask, request, make_response
from google.cloud import pubsub_v1

# 환경 변수 로드
SLACK_SIGNING_SECRET = os.environ.get('SLACK_SIGNING_SECRET', '')
PUBSUB_TOPIC = os.environ.get('PUBSUB_TOPIC', '')
PROJECT_ID = os.environ.get('GCP_PROJECT_ID', '')
CATEGORY_TARGET_SIZE_JSON = os.environ.get('CATEGORY_TARGET_SIZE_JSON', '{}')
try:
    CATEGORY_TARGET_SIZE = json.loads(CATEGORY_TARGET_SIZE_JSON)
except json.JSONDecodeError:
    CATEGORY_TARGET_SIZE = {}

# Flask 앱 및 Pub/Sub 퍼블리셔 초기화
app = Flask(__name__)
publisher = pubsub_v1.PublisherClient()
topic_path = ''
if PROJECT_ID and PUBSUB_TOPIC:
    topic_path = publisher.topic_path(PROJECT_ID, PUBSUB_TOPIC)

def verify_slack_signature(req):
    """슬랙 요청 서명 검증(보안 필수!)"""
    timestamp = req.headers.get('X-Slack-Request-Timestamp')
    slack_signature = req.headers.get('X-Slack-Signature')
    if not timestamp or not slack_signature:
        return False
    # 5분 이상 차이나는 timestamp는 무효(Replay 방지)
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False
    body = req.get_data(as_text=True)
    basestring = f'v0:{timestamp}:{body}'
    computed = 'v0=' + hmac.new(
        SLACK_SIGNING_SECRET.encode(),
        basestring.encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(computed, slack_signature)

@app.route('/slack/events', methods=['POST'])
def slack_events():
    """
    - 슬래시 커맨드(/auto 등) 입력시: 증설 카테고리 버튼 UI 리턴
    - 버튼 클릭시: Pub/Sub 메시지 발행, 슬랙에 접수 알림
    """
    try:
        if not verify_slack_signature(request):
            return make_response("Unauthorized", 401)

        # 슬래시커맨드 입력시: 버튼 UI 리턴
        if request.form.get('command') in ['/auto', '/autoscale']:
            blocks = [
                {"type": "section",
                 "text": {"type": "mrkdwn",
                          "text": "*오토스케일링*\n증설 카테고리를 선택하세요:"}},
                {"type": "actions",
                 "elements": [
                    {"type": "button",
                     "text": {"type": "plain_text", "text": category},
                     "value": category,
                     "action_id": f"select_{category.lower().replace(' ', '_')}"}
                    for category in CATEGORY_TARGET_SIZE.keys()
                ]}
            ]
            response_body = {"blocks": blocks, "response_type": "ephemeral"}
            return make_response(json.dumps(response_body), 200, {"Content-Type": "application/json"})

        # 버튼 클릭시(인터랙션): Pub/Sub로 메시지 적재, 즉시 접수 알림
        if 'payload' in request.form:
            payload = json.loads(request.form['payload'])
            user_id = payload.get('user', {}).get('id', 'unknown')
            actions = payload.get('actions', [])
            if actions and len(actions) > 0:
                action = actions[0]
                selected_category = action.get('value')
                if not topic_path:
                    return make_response("Pub/Sub topic not configured", 500)
                pubsub_data = {
                    "category": selected_category,
                    "user_id": user_id,
                    "timestamp": payload.get('action_ts', '')
                }
                publisher.publish(topic_path, json.dumps(pubsub_data).encode())
                response_text = f"⏳ <@{user_id}>님, '{selected_category}' 증설 요청이 접수되었습니다. 상태는 별도 알림으로 안내됩니다."
                response = {
                    "replace_original": True,
                    "text": response_text
                }
                return make_response(json.dumps(response), 200, {"Content-Type": "application/json"})
        # 그 외 200
        return make_response("", 200)
    except Exception as e:
        # 예외 발생시 500 에러 및 로그
        print(f"Exception in slack_events handler: {e}")
        return make_response("Internal Server Error", 500)

 

Cloud Run 배포 명령어

gcloud run deploy 서비스명-slack-gce-mig-trigger \
  --source . \ (소스코드 위치)
  --region asia-northeast3 \ (Cloud Run 배포 리전)
  --env-vars-file env.list \ (환경설정 변수 파일위치)
  --allow-unauthenticated \ (별도 인증없이 허용 / slack에서 인증을 하기에 cloud run은 해당옵션으로 진행)
  --service-account=slack@***.iam.gserviceaccount.com (cloud run 실행할 서비스 계정 / 계정항목에서 설명)

 

4. Pub/Sub Processor용 Cloud Run  (main.py)

import os
import json
import base64
import requests
from flask import Flask, request, make_response
from google.cloud import compute_v1

app = Flask(__name__)

# 환경 변수 로드
PROJECT_ID = os.environ.get('GCP_PROJECT_ID', '')
REGION = os.environ.get('GCE_REGION', '')
MIG_NAME = os.environ.get('MIG_NAME', '')
SLACK_RESULT_WEBHOOK_URL = os.environ.get('SLACK_RESULT_WEBHOOK_URL', '')
CATEGORY_TARGET_SIZE_JSON = os.environ.get('CATEGORY_TARGET_SIZE_JSON', '{}')
try:
    CATEGORY_TARGET_SIZE = json.loads(CATEGORY_TARGET_SIZE_JSON)
except json.JSONDecodeError:
    CATEGORY_TARGET_SIZE = {}

compute_client = compute_v1.RegionInstanceGroupManagersClient()

def send_slack_result_message(message: str):
    """Slack Webhook으로 작업 결과 알림 전송"""
    if not SLACK_RESULT_WEBHOOK_URL:
        print("Slack Webhook URL이 설정되지 않음.")
        return
    try:
        response = requests.post(SLACK_RESULT_WEBHOOK_URL, json={"text": message})
        if response.status_code != 200:
            print(f"Slack 메시지 전송 실패: {response.status_code}, {response.text}")
    except Exception as e:
        print(f"Slack Webhook 예외: {e}")

def do_resize_with_current(target_size):
    """기존 인스턴스 수 조회 후 스케일링 및 알림 메시지 전송"""
    try:
        mig = compute_client.get(
            project=PROJECT_ID,
            region=REGION,
            instance_group_manager=MIG_NAME
        )
        current_size = mig.target_size

        compute_client.resize(
            project=PROJECT_ID,
            region=REGION,
            instance_group_manager=MIG_NAME,
            size=target_size
        )

        if target_size > current_size:
            msg = f":white_check_mark: 기존 인스턴스는 {current_size}대였고, 증설 명령을 통해 {target_size}대로 증설되었습니다."
        elif target_size < current_size:
            msg = f":white_check_mark: 기존 인스턴스는 {current_size}대였고, 감소 명령을 통해 {target_size}대로 감소하였습니다."
        else:
            msg = f":white_check_mark: 인스턴스 수는 {target_size}대로 변경되지 않았습니다."

        send_slack_result_message(msg)

    except Exception as e:
        send_slack_result_message(f":x: 스케일링 작업 실패: {e}")

@app.route('/', methods=['POST'])
def pubsub_handler():
    """Pub/Sub 푸시 메시지 엔드포인트(증설/감소 작업 실행)"""
    envelope = request.get_json(silent=True)
    if not envelope or 'message' not in envelope:
        return make_response("Bad Request", 400)

    pubsub_message = envelope['message']
    try:
        data = json.loads(base64.b64decode(pubsub_message.get('data', '')).decode())
    except Exception as e:
        print(f"Pub/Sub 메시지 디코딩 오류: {e}")
        return make_response("", 400)

    category = data.get('category')
    user_id = data.get('user_id', 'unknown')

    if category not in CATEGORY_TARGET_SIZE:
        send_slack_result_message(f":x: 잘못된 카테고리 '{category}' 요청이 들어왔습니다.")
        return make_response("", 200)

    target_size = CATEGORY_TARGET_SIZE[category]
    do_resize_with_current(target_size)
    return make_response("", 200)


배포 및 Pub/Sub 푸시 구독 연결

gcloud run deploy mig-autoscale-processor \
  --source . \
  --region asia-northeast3 \
  --env-vars-file env.list \
  --service-account=slack@***.iam.gserviceaccount.com@****.iam.gserviceaccount.com \
  --no-allow-unauthenticated \
# Cloud Run 배포 후 변수설정하여, 구독연결
SERVICE_URL=$(gcloud run services describe mig-autoscale-processor --region asia-northeast3 --format='value(status.url)')
gcloud pubsub subscriptions create mig-autoscale-sub \
  --topic=mig-autoscale-requests \
  --push-endpoint=$SERVICE_URL \
  --push-auth-service-account=slack@.iam.gserviceaccount.com (프로세서용 Cloud Run 서비스 계정 연결)

 

5. Slack 앱 설정

 

  • Slash Command
    슬랙 명령어 설정

 

 

  • 원하는 커맨드(/auto 등) 추가 후 Request URL 설정
  • Request URL: Cloud Run Trigger의 Service URL+ /slack/events 입력 [...]/slack/events
     
  • Interactivity (슬랙 메시지 내에서 사용자가 버튼 클릭, 메뉴 선택 등 상호작용을 할 때, 그 이벤트를 슬랙이 지정한 서버(웹훅 URL)로 전달해 주도록 하기 위한 기능)
    ON, 위 Slash Command과 동일한 Request URL 입력 

 

  • Bot Token Scopes: commands, chat:write, incoming-webhook

    Scope 이름 용도 요약
    commands 슬래시 커맨드(/auto 등) 등록 및 사용
    incoming-webhook Webhook 방식으로 슬랙 채널에 메시지 전송
    chat:write 봇이 슬랙 채널/DM에 메시지 보내기
  • Incoming Webhooks : auto Scaling가 완료된 후 Slack Webhook(채널/DM)로 결과 메시지를 받기 위한 설정

  • 앱 워크스페이스에 설치 : Installed App 에서  설치진행


6. 운영

  1. Slack에서 /auto 실행 → 증설 카테고리 버튼 클릭
  2. Cloud Run 서비스 로그와 Pub/Sub 구독 확인
  3. Processor에서 증설/감소 및 슬랙 알림 메시지까지 정상 표시 점검
  4. 알림 메시지 예시
  • :white_check_mark: 기존 인스턴스는 1대였고, 증설 명령을 통해 2대로 증설되었습니다.
  • :white_check_mark: 기존 인스턴스는 6대였고, 감소 명령을 통해 2대로 감소하였습니다. 

 

Slack 동작화면

 

GCE Instance Group AutoScaling 동작화면