Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/ISSUE_TEMPLATE/deploy-배포-템플릿.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: Deploy 이슈 템플릿
about: 배포를 위한 이슈를 생성합니다.
title: "[DEPLOY] production-release(v.0.0.0)"
labels: ''
assignees: ''

---

## 🚀 배포 설명
<!-- 진행할 배포에 대해 간단하게 설명해 주세요 -->

## 🛠 하위 태스크
<!-- 해당 작업을 수행하기 위해 해야 할 하위 태스크를 작성해 주세요 -->
- [ ]

## ✅ 추가 사항
<!-- 기타 내용을 작성해 주세요 -->
186 changes: 186 additions & 0 deletions .github/workflows/_reusable-eb-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
name: Reusable EB Deploy

on:
workflow_call:
inputs:
application_name:
required: true
type: string
environment_name:
required: true
type: string
health_url:
required: true
type: string
checkout_ref:
required: true
type: string
secrets:
DEPLOY_ENV:
required: true

permissions:
contents: read
id-token: write

jobs:
deploy:
runs-on: ubuntu-latest

env:
AWS_DEFAULT_REGION: ap-northeast-2
HEALTH_URL: ${{ inputs.health_url }}

steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.checkout_ref }}

- name: Create .env file from secret
run: |
if [ -z "${{ secrets.DEPLOY_ENV }}" ]; then
echo "DEPLOY_ENV secret is empty"
exit 1
fi
printf '%s' "${{ secrets.DEPLOY_ENV }}" > .env
chmod 600 .env

- name: Get current time
uses: josStorer/get-current-time@v2
id: current-time
with:
format: 'YYYYMMDD-HHmmss'
utcOffset: '+09:00'

- name: Generate deployment package
run: |
mkdir -p deploy
rsync -a \
--exclude='.git' \
--exclude='deploy' \
--exclude='__pycache__' \
--exclude='.venv' \
--exclude='venv' \
--exclude='.idea' \
--exclude='.cursor' \
--exclude='.github' \
--exclude='tests' \
--exclude='rag/raw' \
--exclude='docs' \
--exclude='*.pyc' \
./ deploy/
(cd deploy && zip -r deploy.zip .)

- name: Verify deploy zip content
run: |
unzip -l deploy/deploy.zip | head -n 50
unzip -l deploy/deploy.zip | egrep -i "\.git/|\.venv/|__pycache__|tests/" && exit 1 || echo "OK"

- name: Configure AWS credentials
id: aws-creds
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ap-northeast-2
output-credentials: true

- name: Save current version
run: |
CURRENT=$(aws elasticbeanstalk describe-environments \
--environment-names "${{ inputs.environment_name }}" \
--query 'Environments[0].VersionLabel' \
--output text)

echo "CURRENT_VERSION=$CURRENT"

if [ "$CURRENT" = "None" ] || [ "$CURRENT" = "null" ]; then
CURRENT=""
fi

echo "PREVIOUS_VERSION=$CURRENT" >> $GITHUB_ENV

- name: Deploy to Elastic Beanstalk
id: deploy
uses: einaregilsson/beanstalk-deploy@v22
with:
aws_access_key: ${{ steps.aws-creds.outputs.aws-access-key-id }}
aws_secret_key: ${{ steps.aws-creds.outputs.aws-secret-access-key }}
aws_session_token: ${{ steps.aws-creds.outputs.aws-session-token }}
application_name: ${{ inputs.application_name }}
environment_name: ${{ inputs.environment_name }}
region: ap-northeast-2
version_label: github-action-${{ steps.current-time.outputs.formattedTime }}
version_description: ${{ github.ref_name }}@${{ github.sha }}
deployment_package: deploy/deploy.zip
wait_for_deployment: true
wait_for_environment_recovery: 180

- name: Smoke test with retry
id: smoke_test
run: |
MAX_RETRY=3

if [ -z "$HEALTH_URL" ]; then
echo "health_url input is not set."
exit 1
fi

echo "HEALTH_URL=$HEALTH_URL"

for attempt in $(seq 1 $MAX_RETRY); do
echo "=== 스모크 테스트 시도 $attempt / $MAX_RETRY ==="

for i in {1..30}; do
code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)"
echo "health check $i: $code"

if [ "$code" = "200" ]; then
echo "health OK (시도 $attempt)"
exit 0
fi

sleep 10
done

echo "시도 $attempt 실패"

if [ "$attempt" -lt "$MAX_RETRY" ]; then
echo "30초 후 재시도..."
sleep 30
fi
done

echo "최대 재시도 횟수($MAX_RETRY) 초과 - 배포 실패"
exit 1

- name: Rollback on failure
if: failure() && env.PREVIOUS_VERSION != '' && env.PREVIOUS_VERSION != 'None' && (steps.deploy.outcome == 'failure' || steps.smoke_test.outcome == 'failure')
run: |
echo "배포 실패 - $PREVIOUS_VERSION 으로 롤백"

aws elasticbeanstalk update-environment \
--environment-name "${{ inputs.environment_name }}" \
--version-label "$PREVIOUS_VERSION"

echo "롤백 배포 완료 대기"
aws elasticbeanstalk wait environment-updated \
--environment-names "${{ inputs.environment_name }}"

echo "롤백 후 smoke test 시작"
echo "HEALTH_URL=$HEALTH_URL"

for i in {1..30}; do
code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)"
echo "rollback health check $i: $code"

if [ "$code" = "200" ]; then
echo "rollback health OK"
exit 0
fi

sleep 10
done

echo "롤백 후에도 health check 실패"
exit 1
148 changes: 62 additions & 86 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
name: CI
# PR / push 시 FastAPI 통합 CI
# 서비스 컨테이너(Postgres/pgvector / Redis) → infra smoke → unittest → uvicorn /health 200 확인

name: IssueIssyu AI CI

on:
push:
branches:
- develop
pull_request:
branches:
- develop
- main
workflow_dispatch:

jobs:
build:
Expand All @@ -22,6 +31,7 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 10

redis:
image: redis:7-alpine
ports:
Expand All @@ -32,36 +42,36 @@ jobs:
--health-timeout 5s
--health-retries 10

env:
APP_ENV: local
LOCAL_DB_HOST: localhost
LOCAL_DB_PORT: 5432
LOCAL_DB_NAME: app
LOCAL_DB_USER: postgres
LOCAL_DB_PASSWORD: postgres

steps:
- uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4

- name: Create .env file from secret
- name: Create .env from secret (CI_ENV)
run: |
if [ -z "${{ secrets.CI_ENV }}" ]; then
echo "ENV_FILE secret is empty"
echo "Repository secret CI_ENV 가 비어 있습니다."
exit 1
fi
printf '%s' "${{ secrets.CI_ENV }}" > .env
if ! grep -q '^REDIS_LOCAL_HOST=' .env && grep -q '^LOCAL_REDIS_HOST=' .env; then
echo "REDIS_LOCAL_HOST=$(awk -F= '/^LOCAL_REDIS_HOST=/{print $2; exit}' .env)" >> .env
fi
if ! grep -q '^REDIS_LOCAL_PORT=' .env && grep -q '^LOCAL_REDIS_PORT=' .env; then
echo "REDIS_LOCAL_PORT=$(awk -F= '/^LOCAL_REDIS_PORT=/{print $2; exit}' .env)" >> .env
fi
chmod 600 .env

- name: Override datasource and Redis for CI services
run: |
cat >> .env << 'EOF'
APP_ENV=local
LOCAL_DB_HOST=127.0.0.1
LOCAL_DB_PORT=5432
LOCAL_DB_NAME=app
LOCAL_DB_USER=postgres
LOCAL_DB_PASSWORD=postgres
REDIS_LOCAL_HOST=127.0.0.1
REDIS_LOCAL_PORT=6379
EOF

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.14"
cache: pip
cache-dependency-path: requirements.txt

Expand All @@ -83,76 +93,42 @@ jobs:
- name: Syntax check (compileall)
run: python -m compileall -q app

- name: Infra smoke test (PostgreSQL/Redis/S3)
run: |
python - <<'PY'
import asyncio
import os
import uuid

import asyncpg
import boto3
from dotenv import dotenv_values
from redis import Redis

env = dotenv_values(".env")

async def check_postgres() -> None:
conn = await asyncpg.connect(
user=os.getenv("LOCAL_DB_USER", "postgres"),
password=os.getenv("LOCAL_DB_PASSWORD", "postgres"),
database="app",
host=os.getenv("LOCAL_DB_HOST", "127.0.0.1"),
port=int(os.getenv("LOCAL_DB_PORT", "5432")),
)
try:
result = await conn.fetchval("SELECT 1;")
assert result == 1
finally:
await conn.close()

def get_env_value(*keys: str, required: bool = False) -> str | None:
for key in keys:
value = env.get(key) or os.getenv(key)
if value:
return value
if required:
joined_keys = ", ".join(keys)
raise RuntimeError(f"Missing required env key: {joined_keys}")
return None
- name: Infra smoke (PostgreSQL/Redis/S3)
run: python scripts/ci_infra_smoke.py

asyncio.run(check_postgres())
- name: Run unit tests
run: python -m unittest discover -s tests -p "test_*.py" -v

redis_host = get_env_value("REDIS_LOCAL_HOST", "LOCAL_REDIS_HOST") or "localhost"
redis_port = int(get_env_value("REDIS_LOCAL_PORT", "LOCAL_REDIS_PORT") or "6379")
redis_client = Redis(host=redis_host, port=redis_port, db=0, decode_responses=True)
assert redis_client.ping() is True

aws_access_key = get_env_value("AWS_ACCESS_KEY", required=True)
aws_secret_key = get_env_value("AWS_SECRET_KEY", required=True)
aws_region = get_env_value("AWS_REGION", required=True)
aws_bucket = get_env_value("AWS_BUCKET", required=True)

s3 = boto3.client(
"s3",
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
region_name=aws_region,
)
s3.head_bucket(Bucket=aws_bucket)
key = f"ci-smoke/{uuid.uuid4().hex}.txt"
body = b"ci-smoke-test"
s3.put_object(Bucket=aws_bucket, Key=key, Body=body, ContentType="text/plain")
obj = s3.get_object(Bucket=aws_bucket, Key=key)
assert obj["Body"].read() == body
s3.delete_object(Bucket=aws_bucket, Key=key)
PY

- name: Uvicorn 실행 후 /health 200 확인 (port 8000)
- name: Start app and verify /health
env:
APP_ENV: local
LOCAL_DB_HOST: 127.0.0.1
LOCAL_DB_PORT: 5432
LOCAL_DB_NAME: app
LOCAL_DB_USER: postgres
LOCAL_DB_PASSWORD: postgres
REDIS_LOCAL_HOST: 127.0.0.1
REDIS_LOCAL_PORT: "6379"
run: |
set -euo pipefail
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
LOG=/tmp/ai-boot.log
: >"$LOG"
uvicorn app.main:app --host 127.0.0.1 --port 8000 >>"$LOG" 2>&1 &
PID=$!
trap 'kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true' EXIT
code="$(curl --retry 30 --retry-delay 1 --retry-connrefused -sS -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/health)"
test "$code" = "200"
for i in $(seq 1 60); do
if ! kill -0 "$PID" 2>/dev/null; then
echo "Uvicorn process exited early (iteration $i)"
break
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/health || true)"
if [ "$code" = "200" ]; then
echo "health OK"
exit 0
fi
sleep 2
done
echo "health check failed (last HTTP code=${code:-n/a})"
echo "---- last 250 lines of boot log ----"
tail -n 250 "$LOG" || true
exit 1
Loading
Loading