1. 들어가며: 이제 코드를 만져볼 시간
안녕하세요! 지난 1편에서는 GitHub Webhook 자동 배포 시스템을 직접 구축하는 이유와 필요한 준비물을, 2편에서는 전체 아키텍처와 FastAPI 웹훅 서비스의 핵심 역할에 대해 상세히 살펴보았습니다. 이제 머릿속으로 그린 그림을 실제 코드로 옮겨볼 차례입니다.
이전 편을 아직 못 보신 분들은 아래의 링크로 확인할 수 있습니다.
GitHub Webhook을 활용한 나만의 자동 배포 시스템 구축
1 - 왜 직접 구현하는가? 2 -전체 아키텍처 및 프로세스 설계
이번 3편에서는 스테이징 서버의 기본적인 환경 설정을 마무리하고, GitHub Webhook 요청을 안전하게 수신하고 검증할 수 있는 FastAPI 웹훅 서버의 기초 골격을 함께 만들어 볼 것입니다. 막연하게만 느껴졌던 자동 배포 시스템이 한 단계 더 현실로 다가오는 시간입니다!
2. 스테이징 서버 초기 환경 설정
가장 먼저 할 일은 배포가 일어날 스테이징 서버의 환경을 우리가 코드를 실행할 수 있도록 준비하는 것입니다. SSH로 서버에 접속하여 다음 단계를 따라주세요.
2.1. 파이썬 개발 환경 준비
서버에 파이썬 가상 환경을 설정하는 것은 프로젝트 간의 의존성 충돌을 막고 깔끔한 개발 환경을 유지하는 좋은 습관입니다. venv환경에 pip 로 필요한 패키지를 설치합니다.
# FastAPI, Uvicorn, 그리고 .env 파일을 읽어오기 위한 python-dotenv 설치
pip install fastapi uvicorn python-dotenv
2.2. Git, Docker, Docker Compose 설치 확인
지난 편에서 언급했듯이, Git과 Docker, Docker Compose는 자동 배포의 핵심 도구입니다. 이 글을 읽고 계신 독자분들이라면 이미 이 도구들이 서버에 설치되어 있을 것이라고 가정합니다.
혹시라도 설치가 안 되어 있다면, 먼저 다음 명령어로 설치 여부를 확인해주세요.
git --version
docker --version
docker compose version # 또는 docker-compose --version (설치 방식에 따라 다름)
만약 설치되어 있지 않다면, 각 도구의 공식 문서를 참고하여 설치를 진행해주세요. 특히 Docker 설치에 대한 자세한 가이드가 필요하시다면 다음 글을 참고하실 수 있습니다: Docker 설치 가이드
참고: Docker를 설치한 후에는 현재 사용자에게 Docker 그룹 권한을 부여하고 로그아웃 후 다시 로그인해야 sudo
없이 docker
명령어를 사용할 수 있습니다.
sudo usermod -aG docker $USER
Docker 권한을 부여한 후에는 반드시 서버에서 로그아웃했다가 다시 로그인해야 변경 사항이 적용됩니다. docker run hello-world
명령을 sudo
없이 실행하여 정상 작동하는지 확인해주세요.
3. FastAPI 웹훅 서버 기본 구조 만들기
이제 웹훅 요청을 받아들일 FastAPI 애플리케이션의 기본 뼈대를 만들어 봅시다. ~/projects/webhook_server
디렉토리 안에 main.py
파일을 생성하고 다음 코드를 작성합니다.
3.1. main.py
파일 생성 및 FastAPI 앱 초기화
저는 설명을 위해 main.py
에 모든 로직을 한꺼번에 작성했지만, 여러분은 실제 프로젝트에서 관리를 위해 각 유틸리티 함수별로 개별 파일로 모듈화하여 import 하는 등 자신의 스타일에 맞게 보기 좋게 작성하시기 바랍니다. 아래는 작성의 예시입니다.
# ~/projects/webhook_server/main.py
import hmac
import hashlib
import os
import logging
import subprocess
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from dotenv import load_dotenv, dotenv_values
# .env 파일 로드 (환경 변수 사용을 위함)
load_dotenv()
# 로깅 설정
logging.basicConfig(
level=logging.INFO, # INFO 레벨 이상의 로그를 기록
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # 파일로 로그 저장
logging.StreamHandler() # 콘솔에도 로그 출력
]
)
logger = logging.getLogger(__name__)
app = FastAPI()
# GitHub Webhook Secret 환경 변수에서 로드
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET")
# 테스트용 루트 엔드포인트 : 실제 운영시에는 보안을 위해 없애는 것이 좋습니다.
# 인터넷에 노출되는 순간 온갖 크롤러들이 돌아다니므로 굳이 이런 엔드포인트가 존재한다고 알릴 필요가 없습니다.
@app.get("/")
async def read_root():
return {"message": "Webhook server is running!"}
# GitHub Webhook을 수신할 엔드포인트
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
logger.info("Webhook request received.")
# 1. GitHub Webhook Secret 검증 (가장 먼저 수행해야 할 보안 단계)
if GITHUB_WEBHOOK_SECRET:
signature = request.headers.get("X-Hub-Signature-256")
if not signature:
logger.warning("Signature header missing. Aborting.")
raise HTTPException(status_code=401, detail="X-Hub-Signature-256 header missing")
body = await request.body()
expected_signature = "sha256=" + hmac.new(
GITHUB_WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
logger.warning("Invalid signature. Aborting.")
raise HTTPException(status_code=401, detail="Invalid signature")
else:
logger.info("Signature verified successfully.")
else:
logger.warning("GITHUB_WEBHOOK_SECRET is not set. Skipping signature verification (NOT RECOMMENDED FOR PRODUCTION).")
# 2. HTTP Body 파싱 (Payload)
try:
payload = await request.json()
event_type = request.headers.get("X-GitHub-Event")
# GitHub Webhook 페이로드에서 레포지토리 이름을 추출합니다.
# 'full_name' 대신 'name'을 사용합니다.
repo_name = payload.get("repository", {}).get("name", "unknown_repository")
ref = payload.get("ref", "unknown_ref")
pusher_name = payload.get("pusher", {}).get("name", "unknown_pusher")
logger.info(f"Event: {event_type}, Repo: {repo_name}, Ref: {ref}, Pusher: {pusher_name}")
# 실제 배포 로직을 백그라운드에서 실행
background_tasks.add_task(handle_deploy, repo_name)
except Exception as e:
logger.error(f"Error parsing webhook payload or processing request: {e}")
raise HTTPException(status_code=400, detail=f"Error processing request: {e}")
# GitHub에 즉시 응답 반환
return {"message": "Webhook received and processing started in background!"}
# 실제 배포 로직을 처리하는 함수
def handle_deploy(repo_name: str):
# 'deployer'라는 이름의 레포지토리는 웹훅 서버 자체를 업데이트하는 경우이므로 스킵
if repo_name == "deployer":
logger.info("⚙️ Deployer self-update skipped.")
return
# 환경 변수에서 각 레포지토리의 로컬 경로를 읽어옵니다.
# 이 딕셔너리는 여러분의 실제 프로젝트 이름과 서버 내 경로를 매핑해야 합니다.
# 예: SAMPLE_PROJECT_1_PATH 환경 변수에 해당 프로젝트의 서버 경로가 저장되어 있어야 합니다.
repo_paths = {
"sample_project1": os.getenv("SAMPLE_PROJECT_1_PATH"),
"sample_project2": os.getenv("SAMPLE_PROJECT_2_PATH"),
"sample_project3": os.getenv("SAMPLE_PROJECT_3_PATH"),
}
# 레포지토리와 Docker Compose 프로젝트의 이름이 다르면 아래와 같이 프로젝트의 이름을 새롭게 매핑하는 것도 권장합니다.
# 이렇게 해두면 Docker Compose의 -p (project name) 옵션으로 사용할 수 있습니다.
# Docker의 특성상 -p 옵션에는 대문자나 하이픈을 사용할 수 없음을 유의하세요.
p_name = {
"sample_project1": "p_name_1",
"sample_project2": "p_name_2",
"sample_project3": "p_name_3",
}
# 현재 웹훅 요청이 들어온 레포지토리의 로컬 경로를 찾습니다.
repo_path: Optional[str] = repo_paths.get(repo_name)
# 정의되지 않은 레포지토리인 경우 경고 로그를 남기고 종료합니다.
if not repo_path:
logger.warning(f"🚫 Unknown repository: {repo_name}")
return
# Path 객체로 변환하여 파일 시스템 경로를 쉽게 다룹니다.
repo_path = Path(repo_path)
# 해당 레포지토리의 .env 파일에서 DEBUG 값과 COLOR 값을 로드합니다.
# 이는 프로젝트별로 개발/운영 환경을 구분하거나, Docker Compose 프로젝트 이름을 동적으로 설정하는 데 활용될 수 있습니다.
env_path = repo_path / ".env"
env_vars = dotenv_values(env_path) if env_path.exists() else {}
is_debug = env_vars.get("DEBUG", "false").lower() == "true" # "true" 문자열을 불리언으로 변환
deploy_color = env_vars.get("COLOR", "red") # 기본값은 "red"
project_name = str(deploy_color) + p_name.get(repo_name, repo_name) # Docker Compose 프로젝트 이름
# DEBUG 값에 따라 사용할 Docker Compose 파일을 결정할 수도 있습니다.
docker_compose_file = "docker-compose.dev.yml" if is_debug else "docker-compose.prod.yml"
# Docker Compose 파일 경로를 정의합니다.
compose_file = repo_path / docker_compose_file
logger.info(f"Pulling latest code for {repo_name} in {repo_path}...")
# Git Pull 실행: 해당 레포지토리 경로에서 최신 코드를 가져옵니다.
# check=True 옵션은 명령 실행 실패 시 subprocess.CalledProcessError를 발생시켜 예외 처리를 돕습니다.
try:
subprocess.run(["git", "-C", repo_path, "pull"], check=True, capture_output=True, text=True)
logger.info(f"Git pull successful for {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Git pull failed for {repo_name}: {e.stderr}")
return # 배포 실패 시 여기서 종료
logger.info(f"Checking for changes in {repo_name} to decide rebuild...")
# 변경사항 비교: Docker 이미지 재빌드가 필요한지 판단합니다.
changed = should_rebuild(repo_path)
try:
if changed:
logger.info(f"🚀 Changes detected. Building and deploying {repo_name} with {docker_compose_file}...")
# 변경사항이 있다면 Docker Compose를 --build 옵션과 함께 실행하여 이미지를 재빌드합니다.
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d", "--build"], check=True, capture_output=True, text=True)
logger.info(f"Docker compose build and up successful for {repo_name}.")
else:
logger.info(f"✨ No significant changes. Deploying {repo_name} without rebuilding images using {docker_compose_file}...")
# 변경사항이 없다면 Docker Compose를 --build 옵션 없이 실행합니다.
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d"], check=True, capture_output=True, text=True)
logger.info(f"Docker compose up successful for {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Docker compose failed for {repo_name}: {e.stderr}")
return # 배포 실패 시 여기서 종료
logger.info(f"✅ Deployment task for {repo_name} finished.")
# Docker 이미지 재빌드가 필요한지 판단하는 함수
def should_rebuild(repo_path: Path) -> bool:
# 이 파일들이 변경되면 Docker 이미지를 재빌드해야 합니다.
trigger_files = [
"requirements.txt", "Dockerfile", "Dockerfile.dev", "Dockerfile.prod",
".dockerignore", ".env" # .env 파일 변경도 재빌드 트리거로 포함
]
logger.info(f"Checking git diff for {repo_path}...")
# Git diff를 사용하여 HEAD~1 (이전 커밋)과 현재 HEAD 사이의 변경된 파일 목록을 가져옵니다.
# --name-only 옵션으로 변경된 파일의 이름만 가져옵니다.
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1"],
cwd=repo_path,
capture_output=True, text=True, check=True
)
changed_files_in_git = result.stdout.strip().splitlines()
logger.info(f"Changed files in git: {changed_files_in_git}")
except subprocess.CalledProcessError as e:
logger.error(f"Git diff failed for {repo_path}: {e.stderr}")
# git diff 실패 시 안전하게 재빌드를 시도하는 것이 좋습니다.
return True
# 변경된 파일 목록 중에 트리거 파일이 포함되어 있는지 확인합니다.
if any(f in changed_files_in_git for f in trigger_files):
logger.info("Detected trigger file changes via git diff. Rebuild required.")
return True
# 수동 재빌드 트리거 파일(REBUILD_TRIGGER)이 존재하는지 확인합니다.
# 이 파일이 있다면, 수동으로 재빌드를 강제하는 것으로 간주하고 파일을 삭제합니다.
rebuild_trigger_file = repo_path / "REBUILD_TRIGGER"
if rebuild_trigger_file.exists():
try:
rebuild_trigger_file.unlink() # 파일 삭제
logger.info("Detected REBUILD_TRIGGER file. Rebuild required and trigger file removed.")
return True
except Exception as e:
logger.error(f"Error removing REBUILD_TRIGGER file: {e}")
# 파일 삭제 실패 시에도 재빌드를 시도하는 것이 안전합니다.
return True
logger.info("No rebuild trigger files found. No rebuild required.")
return False
3.2. /webhook
엔드포인트 정의
위 코드에서 app.post("/webhook")
데코레이터 아래에 정의된 비동기 함수 github_webhook
이 바로 GitHub Webhook 요청을 수신할 엔드포인트입니다. 이 함수는 들어오는 POST
요청을 처리하며, Request
객체를 통해 헤더와 본문(Payload)에 접근할 수 있습니다. BackgroundTasks
는 GitHub에 즉시 응답을 보내면서도 배포와 같은 시간이 오래 걸리는 작업을 백그라운드에서 실행할 수 있도록 돕는 FastAPI의 강력한 기능입니다.
4. GitHub Webhook Secret 검증 로직 구현
위 코드에서 가장 중요한 보안 요소는 GITHUB_WEBHOOK_SECRET
을 이용한 검증 로직입니다.
4.1. Secret
값의 중요성
GitHub 웹훅을 설정할 때, Secret
값을 설정할 수 있습니다. 이 값은 GitHub가 웹훅 요청을 보낼 때 요청 본문과 Secret 값을 조합하여 해시(Hash) 값을 생성하고, 이를 X-Hub-Signature-256
헤더에 담아 보냅니다. 우리의 웹훅 서버는 동일한 방법으로 해시 값을 생성한 후, GitHub가 보낸 해시 값과 일치하는지 비교하여 요청이 GitHub에서 온 것이 맞는지, 그리고 데이터가 전송 중에 변조되지 않았는지 확인할 수 있습니다.
4.2. X-Hub-Signature-256
헤더를 이용한 검증
코드에서 보셨듯이, hmac
및 hashlib
모듈을 사용하여 Secret 검증을 수행합니다.
- Secret 설정: 가장 먼저,
~/projects/webhook_server
디렉토리에.env
파일을 만들고 여기에 GitHub Webhook 설정 시 사용할 Secret 값을 기록합니다. 이 파일은.gitignore
에 추가하여 Git 저장소에 올라가지 않도록 주의해야 합니다. 이 Secret 값은 나중에 GitHub Repository의 Webhook설정에서 사용될 것입니다.
# ~/projects/webhook_server/.env
GITHUB_WEBHOOK_SECRET="여기에_GitHub_웹훅_설정시_사용할_시크릿_값을_입력하세요"
- FastAPI 코드:
main.py
에서는python-dotenv
라이브러리를 사용하여 이.env
파일을 로드하고os.getenv()
로GITHUB_WEBHOOK_SECRET
값을 가져옵니다. 이후 웹훅 요청이 들어올 때마다Request.headers.get("X-Hub-Signature-256")
로 서명 값을 가져오고,hmac.new()
함수를 이용해 자체적으로 예상 서명 값을 계산하여 비교합니다.
이 검증 로직은 웹훅 엔드포인트에 대한 외부 공격을 막는 필수적인 방패이므로, 절대로 건너뛰지 마세요!
5. 로깅 시스템 설정
코드를 보면 logging
모듈을 사용하여 기본적인 로깅 설정을 해두었습니다.
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # webhook_server.log 파일에 저장
logging.StreamHandler() # 콘솔에도 출력
]
)
logger = logging.getLogger(__name__)
이렇게 설정하면 웹훅 요청이 들어올 때마다 webhook_server.log
파일에 정보가 기록되고, 동시에 터미널에도 출력됩니다. 나중에 배포 로직이 더 복잡해지면 이 로깅 기능을 통해 어떤 단계에서 문제가 발생했는지 쉽게 파악할 수 있습니다.
6. Uvicorn으로 웹훅 서버 실행 및 로컬 테스트
이제 작성한 FastAPI 웹훅 서버가 제대로 동작하는지 로컬에서 간단히 테스트해봅시다. 스테이징 서버의 ~/projects/webhook_server
디렉토리에서 가상 환경이 활성화된 상태에서 다음 명령어를 실행합니다.
# 가상 환경이 활성화되어 있는지 확인 (앞에 (venv)가 있는지 확인)
# source venv/bin/activate # 활성화되지 않았다면 이 명령어를 다시 실행
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
--reload` 옵션은 개발 중 코드 변경 시 자동으로 서버를 재시작해 주어 편리합니다.
브라우저에서 127.0.0.1:8000/docs 에 접근하여 제대로 문서가 API 문서가 생성되었는지 확인해봅시다. 문서가 확인된다면 애플리케이션이 정상가동 중입니다. 서버의 IP 주소(예: 192.168.1.100
)를 알고 있다면, 로컬 머신에서 다음 curl
명령어를 사용하여 테스트 요청을 보낼 수 있습니다.
# 서버의 IP 주소를 'YOUR_SERVER_IP'로 바꾸세요.
# 'YOUR_WEBHOOK_SECRET'은 .env 파일의 실제 값으로 바꾸세요.
# 간단한 더미 JSON 데이터를 포함합니다.
curl -X POST http://YOUR_SERVER_IP:8000/webhook \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=$(echo -n '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}' | openssl dgst -sha256 -hmac 'YOUR_WEBHOOK_SECRET' | awk '{print $2}')" \
-d '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}'
터미널에 Webhook received and processing started in background!
메시지와 함께 로깅이 출력되고, 서버 로그 파일 (webhook_server.log
)에도 기록되는지 확인하세요. 만약 오류가 발생한다면, 터미널에 출력되는 에러 메시지를 통해 디버깅할 수 있습니다.
7. 마무리하며: 다음 편 예고
이번 편에서는 스테이징 서버의 환경을 구축하고, GitHub Webhook 요청을 받아 Secret을 검증하는 FastAPI 웹훅 서버의 기본적인 골격을 완성했습니다. 이제 웹훅 서버가 외부 요청을 안전하게 받을 준비가 되었습니다.
다음 4편에서는 오늘 만든 기초 위에 실제 배포 로직(Git Pull, Docker 이미지 재빌드 결정, Docker Compose 실행)을 subprocess
모듈을 활용하여 구현하고, 이 FastAPI 웹훅 서버가 서버 재부팅 시에도 자동으로 실행되도록 Systemd 서비스로 등록하는 방법을 상세히 다룰 것입니다. 기대해주세요!
댓글이 없습니다.