Skip to content

f-lab-piz/deploy-test-study

Repository files navigation

무중단 배포 스터디 프로젝트

FastAPI, PostgreSQL, Nginx를 활용한 무중단 배포 학습 프로젝트입니다.

프로젝트 구성

  • FastAPI: 백엔드 API 서버 (2개 인스턴스)
  • PostgreSQL: 데이터베이스
  • Nginx: 로드 밸런서
  • Alembic: 데이터베이스 마이그레이션 도구
  • Locust: 부하 테스트 도구

주요 학습 내용

1. 무중단 배포 구현 ✅

  • FastAPI 서버 2대를 순차적으로 재시작
  • Graceful Shutdown으로 진행 중인 요청 보호
  • Nginx 로드 밸런서로 트래픽 분산
  • Health Check 기반 배포 검증

2. Alembic 데이터베이스 마이그레이션 ✅

데이터베이스 스키마 변경을 안전하게 관리하는 방법을 학습했습니다.

따라하기: 단계별 실습 가이드

이 프로젝트의 Alembic 학습 과정을 직접 따라해볼 수 있습니다.


📌 커밋 1: 초기 마이그레이션 설정 (ce69d14)

목표: Alembic 설정 및 첫 마이그레이션 생성

1단계: Dockerfile 수정

# Dockerfile에 추가
COPY alembic.ini .
COPY alembic ./alembic

💡 Docker 컨테이너에서 alembic 명령어를 실행하려면 alembic 파일들이 이미지에 포함되어야 합니다.

2단계: 초기 마이그레이션 생성

# 로컬에서 실행 (개발 환경)
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic revision --autogenerate -m "Initial migration: create items table"

명령어 분석:

  • alembic revision: 새 마이그레이션 파일 생성
  • --autogenerate: SQLAlchemy 모델과 DB를 비교해서 자동으로 코드 생성
  • -m "메시지": 마이그레이션 설명

결과:

Generating alembic/versions/0fee3ec0c79f_initial_migration_create_items_table.py ... done

INFO  [alembic.autogenerate.compare] Detected added table 'items'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_items_id'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_items_name'

생성된 파일 확인:

# alembic/versions/0fee3ec0c79f_initial_migration_create_items_table.py
def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('items',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(), nullable=True),
        sa.Column('description', sa.String(), nullable=True),
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
        sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
        sa.PrimaryKeyConstraint('id')
    )
    # ...

⚠️ 중요: --autogenerate는 자동 생성이지만 항상 검토 필요! 주석에 "please adjust!"라고 써있는 이유입니다.

3단계: 마이그레이션 적용

# DB에 마이그레이션 적용
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic upgrade head

출력:

INFO  [alembic.runtime.migration] Running upgrade  -> 0fee3ec0c79f, Initial migration: create items table

4단계: 적용 확인

# 현재 마이그레이션 상태 확인
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic current

출력:

0fee3ec0c79f (head)

📌 커밋 2: 스키마 변경 실습 (e35dc3b)

목표: 컬럼 추가 및 마이그레이션 자동 생성 학습

1단계: 모델 수정

# app/models.py
from sqlalchemy import Column, Integer, String, DateTime, Numeric  # Numeric 추가

class Item(Base):
    __tablename__ = "items"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String, index=True)
    description = Column(String)
    price = Column(Numeric(10, 2), nullable=True)  # 👈 추가
    stock = Column(Integer, default=0)              # 👈 추가
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

2단계: 스키마도 함께 수정

# app/schemas.py
from decimal import Decimal

class ItemBase(BaseModel):
    name: str
    description: Optional[str] = None
    price: Optional[Decimal] = None  # 👈 추가
    stock: int = 0                    # 👈 추가

3단계: 마이그레이션 생성 (변경 감지)

DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic revision --autogenerate -m "Add price and stock columns to items table"

출력:

INFO  [alembic.autogenerate.compare] Detected added column 'items.price'
INFO  [alembic.autogenerate.compare] Detected added column 'items.stock'
Generating alembic/versions/211d1fe6d912_add_price_and_stock_columns_to_items_.py ... done

💡 Alembic의 마법: 모델 변경을 자동으로 감지해서 마이그레이션 코드 생성!

생성된 파일:

# alembic/versions/211d1fe6d912_add_price_and_stock_columns_to_items_.py
def upgrade() -> None:
    op.add_column('items', sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True))
    op.add_column('items', sa.Column('stock', sa.Integer(), nullable=True))

def downgrade() -> None:
    op.drop_column('items', 'stock')
    op.drop_column('items', 'price')

4단계: 마이그레이션 적용

DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic upgrade head

출력:

INFO  [alembic.runtime.migration] Running upgrade 0fee3ec0c79f -> 211d1fe6d912, Add price and stock columns

5단계: 롤백 테스트

# 한 단계 되돌리기
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic downgrade -1

출력:

INFO  [alembic.runtime.migration] Running downgrade 211d1fe6d912 -> 0fee3ec0c79f

6단계: 다시 최신으로

DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic upgrade head

학습 포인트: upgrade/downgrade가 정확히 반대 동작을 수행합니다!

7단계: 히스토리 확인

DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic history

출력:

0fee3ec0c79f -> 211d1fe6d912 (head), Add price and stock columns to items table
<base> -> 0fee3ec0c79f, Initial migration: create items table

📌 커밋 3: 무중단 배포 통합 (2186626)

목표: 배포 스크립트에 마이그레이션 자동화

1단계: deploy.sh 수정

# deploy.sh

# 새 이미지 빌드
docker-compose build

# 👇 마이그레이션 추가
echo "2. 데이터베이스 마이그레이션 실행..."
docker-compose run --rm app1 alembic current

if docker-compose run --rm app1 alembic upgrade head; then
    echo "   - 마이그레이션 완료!"
else
    echo "   - 오류: 마이그레이션 실패! 배포를 중단합니다."
    exit 1
fi

docker-compose run --rm app1 alembic current

# 이후 app1, app2 순차 재시작...

왜 docker-compose run을 사용할까?

# ❌ 로컬 실행의 문제점
alembic upgrade head
# - 로컬 Python 환경 필요
# - localhost:5433으로 DB 접속 (외부 포트)
# - 배포 환경과 다를 수 있음

# ✅ docker-compose run 장점
docker-compose run --rm app1 alembic upgrade head
# - 실제 배포 환경과 동일한 컨테이너에서 실행
# - Docker 네트워크 내부에서 db:5432 접속 (내부 통신)
# - --rm: 실행 후 컨테이너 자동 삭제 (정리)

명령어 분석:

docker-compose run --rm app1 alembic upgrade head
     ↓           ↓    ↓           ↓
  도구명      옵션  서비스명   실행할 명령어

- run: 일회성 명령어 실행 (새 컨테이너 생성)
- --rm: 실행 후 컨테이너 자동 삭제
- app1: docker-compose.yml의 서비스명
- alembic upgrade head: 컨테이너 내부에서 실행할 명령

💡 중요: app1이 이미 실행 중이어도 괜찮습니다! docker-compose run새로운 독립적인 컨테이너를 생성하기 때문입니다.

2단계: 배포 실행

chmod +x deploy.sh
./deploy.sh

배포 순서:

1. 이미지 빌드
2. 마이그레이션 실행 (DB 스키마 변경) ← 새로 추가!
3. app1 재시작 (새 코드)
4. app2 재시작 (새 코드)

📌 커밋 4: 데이터 마이그레이션 (a4ff441)

목표: SQL을 직접 실행하는 데이터 마이그레이션 학습

스키마 마이그레이션 vs 데이터 마이그레이션:

구분 스키마 마이그레이션 데이터 마이그레이션
목적 테이블/컬럼 구조 변경 데이터 변환/정제
생성 --autogenerate 수동 작성
예시 ADD COLUMN UPDATE 기존 데이터

1단계: 빈 마이그레이션 생성

# --autogenerate 없이 생성 (수동 작성용)
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic revision -m "Set default values for existing items"

출력:

Generating alembic/versions/f25407aec7a2_set_default_values_for_existing_items.py ... done

생성된 파일 (초기 상태):

def upgrade() -> None:
    pass  # 👈 비어있음! 직접 작성해야 함

def downgrade() -> None:
    pass

2단계: 데이터 마이그레이션 코드 작성

# alembic/versions/f25407aec7a2_set_default_values_for_existing_items.py
def upgrade() -> None:
    # op.execute()로 직접 SQL 실행
    op.execute("""
        UPDATE items
        SET stock = 0
        WHERE stock IS NULL
    """)

    op.execute("""
        UPDATE items
        SET price = 1000.00
        WHERE price IS NULL
    """)

def downgrade() -> None:
    # 데이터 마이그레이션은 롤백이 어려움
    pass

💡 핵심: op.execute()어떤 SQL이든 실행 가능!

3단계: 적용

DATABASE_URL="postgresql://user:password@localhost:5433/testdb" \
  python3 -m alembic upgrade head

출력:

INFO  [alembic.runtime.migration] Running upgrade 211d1fe6d912 -> f25407aec7a2, Set default values for existing items

📌 커밋 5: 문서화 (bd8242a)

README에 실전 가이드 추가 (이 섹션!)


실습 요약: 전체 플로우

# 1️⃣ 모델 변경
vim app/models.py  # 컬럼 추가/수정

# 2️⃣ 마이그레이션 생성 (자동)
alembic revision --autogenerate -m "설명"

# 3️⃣ 생성된 파일 검토
vim alembic/versions/xxxxx_설명.py
# ⚠️ 항상 검토 필수!

# 4️⃣ 적용
alembic upgrade head

# 5️⃣ 확인
alembic current
alembic history

# 6️⃣ (필요시) 롤백
alembic downgrade -1

# 7️⃣ 배포
./deploy.sh  # 자동으로 마이그레이션 실행됨

핵심 학습 포인트

  • ✅ 스키마 버전 관리로 팀 협업 효율성 향상
  • ✅ 자동 생성 + 수동 검토로 안전한 마이그레이션
  • ✅ 배포 전 마이그레이션으로 다운타임 최소화
  • ✅ nullable 컬럼으로 무중단 배포 호환성 유지
  • ✅ 트랜잭션 기반 실행으로 실패 시 자동 롤백

더 자세한 학습 내용은 plan.md의 "6단계: Alembic 데이터베이스 마이그레이션" 섹션을 참고하세요.


API 엔드포인트

Health Check

  • GET /health - 서버 상태 확인

Root

  • GET / - 기본 응답 (버전 정보 포함) 응답시간을 의도적으로 늘리기 위해 sleep(10)를 넣어놓음.

2. 서비스 실행

# Docker Compose로 모든 서비스 실행
docker-compose up -d

# 로그 확인
docker-compose logs -f

실행되는 서비스:

  • db: PostgreSQL 데이터베이스 (포트 5432)
  • app1: FastAPI 서버 1 (포트 8001)
  • app2: FastAPI 서버 2 (포트 8002)
  • nginx: 로드 밸런서 (포트 80)

무중단 배포 구현 요소

무중단 배포를 위해서는 다음 요소들이 필요합니다:

1. FastAPI 애플리케이션 설정

Health Check 엔드포인트 (필수)

로드 밸런서와 배포 스크립트가 서버 상태를 확인하기 위해 필요합니다.

@app.get("/health")
async def health_check():
    return {"status": "healthy"}

Lifespan 이벤트 (권장)

애플리케이션 시작/종료 시 리소스 정리를 위해 사용합니다.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: 데이터베이스 연결, 리소스 초기화
    Base.metadata.create_all(bind=engine)
    print("Application started")
    yield
    # Shutdown: 연결 종료, 리소스 정리
    print("Shutting down gracefully...")
    await asyncio.sleep(0.1)  # 진행 중인 요청 대기

app = FastAPI(lifespan=lifespan)

주의: 커스텀 signal handler를 추가하면 Uvicorn의 graceful shutdown을 방해할 수 있으므로 제거하는 것이 좋습니다.

2. Uvicorn 설정

Graceful Shutdown Timeout (필수)

Dockerfile CMD에서 설정:

CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--timeout-graceful-shutdown", "30"]
  • SIGTERM 수신 시 새 요청 거부
  • 진행 중인 요청은 최대 30초 대기
  • 요청 완료 시 즉시 종료

STOPSIGNAL (권장)

Dockerfile에 명시:

STOPSIGNAL SIGTERM

3. Docker Compose 설정

Health Check (필수)

각 서비스의 상태를 모니터링:

app1:
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 10s

주의: 컨테이너에 curl이 설치되어 있어야 합니다 (Dockerfile에 추가).

Restart Policy (선택)

운영 환경에서는 자동 재시작 설정:

app1:
  restart: unless-stopped

Depends On (권장)

서비스 시작 순서 및 의존성 관리:

app1:
  depends_on:
    db:
      condition: service_healthy

4. Nginx 로드 밸런서 설정

Upstream Health Check

백엔드 서버 상태 자동 감지:

upstream backend {
    least_conn;  # 연결 수가 적은 서버로 요청 분산
    server app1:8000 max_fails=3 fail_timeout=30s;
    server app2:8000 max_fails=3 fail_timeout=30s;
}

Proxy Timeout 설정

긴 요청 처리를 위한 타임아웃 설정:

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

5. 배포 스크립트

핵심 로직

# 1. SIGTERM으로 graceful shutdown 시작
docker-compose stop -t 30 app1

# 2. 새 컨테이너 시작
docker-compose up -d app1

# 3. Health check 확인
until curl -f http://localhost:8001/health; do
    sleep 1
done

왜 docker-compose kill이 아닌 stop을 사용하나?

  • stop -t 30: SIGTERM → 30초 대기 → SIGKILL (graceful)
  • kill: 즉시 강제 종료 (진행 중인 요청 손실)

요약: 필수 vs 선택 요소

요소 필수 여부 이유
/health 엔드포인트 필수 서버 상태 확인용
Uvicorn graceful shutdown timeout 필수 진행 중인 요청 보호
Docker health check 필수 서비스 준비 상태 확인
Lifespan 이벤트 권장 리소스 정리
Nginx 로드 밸런서 권장 무중단 전환
커스텀 signal handler ❌ 불필요 Uvicorn이 자동 처리

무중단 배포하기

배포 스크립트 실행

# 배포 스크립트에 실행 권한 부여
chmod +x deploy.sh

# 무중단 배포 실행
./deploy.sh

배포 프로세스

  1. 새 이미지 빌드: 최신 코드로 Docker 이미지 생성
  2. app1 배포:
    • SIGTERM 신호 전송 → Graceful Shutdown
    • 기존 요청 완료 대기 (최대 30초)
    • 컨테이너 재시작
    • Health check 통과 확인
  3. 안정화 대기: 5초간 대기
  4. app2 배포: app1과 동일한 과정 반복

Graceful Shutdown 동작

FastAPI 애플리케이션은 SIGTERM 신호를 받으면:

  1. 새로운 요청 수락 중지
  2. 현재 처리 중인 요청 완료 대기 (최대 30초)
  3. 모든 연결 정리 후 종료

참고: app/main.py, Dockerfile

부하 테스트

Locust 설치

pip install locust

테스트 시나리오

무중단 배포 검증 (DeploymentTestUser)

배포 중 서비스 연속성 확인:

# 터미널 1: Locust 실행
locust -f locustfile.py --users 20 --spawn-rate 2 --host http://localhost DeploymentTestUser

localhost:8089 에 접속해서 http://localhost 에 요청 보내기 시작.

# 터미널 2: 배포 실행
./deploy.sh

데이터베이스 마이그레이션 (Alembic)

Alembic이란?

Alembic은 SQLAlchemy를 위한 데이터베이스 마이그레이션 도구입니다. 데이터베이스 스키마 변경을 버전 관리하고, 여러 환경에서 일관되게 적용할 수 있습니다.

주요 명령어

# 로컬에서 실행 (개발 시)
DATABASE_URL="postgresql://user:password@localhost:5433/testdb" python3 -m alembic <command>

# Docker 컨테이너에서 실행 (배포 시)
docker-compose run --rm app1 alembic <command>

마이그레이션 생성

# 스키마 변경 자동 감지하여 마이그레이션 생성
alembic revision --autogenerate -m "설명"

# 수동 마이그레이션 생성 (데이터 변환 등)
alembic revision -m "설명"

마이그레이션 적용

# 최신 버전으로 업그레이드
alembic upgrade head

# 특정 리비전으로 업그레이드
alembic upgrade <revision_id>

# 한 단계 롤백
alembic downgrade -1

# 특정 리비전으로 다운그레이드
alembic downgrade <revision_id>

마이그레이션 상태 확인

# 현재 적용된 마이그레이션 확인
alembic current

# 마이그레이션 히스토리 확인
alembic history

# 적용되지 않은 마이그레이션 확인
alembic heads

마이그레이션 파일 구조

alembic/
├── env.py                    # Alembic 환경 설정
├── script.py.mako           # 마이그레이션 템플릿
└── versions/                # 마이그레이션 파일들
    ├── 0fee3ec0c79f_initial_migration_create_items_table.py
    ├── 211d1fe6d912_add_price_and_stock_columns_to_items_.py
    └── f25407aec7a2_set_default_values_for_existing_items.py

마이그레이션 예시

1. 스키마 마이그레이션 (자동 생성)

# alembic/versions/211d1fe6d912_add_price_and_stock_columns_to_items_.py
def upgrade() -> None:
    op.add_column('items', sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True))
    op.add_column('items', sa.Column('stock', sa.Integer(), nullable=True))

def downgrade() -> None:
    op.drop_column('items', 'stock')
    op.drop_column('items', 'price')

2. 데이터 마이그레이션 (수동 작성)

# alembic/versions/f25407aec7a2_set_default_values_for_existing_items.py
def upgrade() -> None:
    op.execute("UPDATE items SET stock = 0 WHERE stock IS NULL")
    op.execute("UPDATE items SET price = 1000.00 WHERE price IS NULL")

def downgrade() -> None:
    pass  # 데이터 마이그레이션은 롤백이 어려움

무중단 배포와 마이그레이션

배포 스크립트(deploy.sh)는 자동으로 마이그레이션을 실행합니다:

1. 이미지 빌드
2. 데이터베이스 마이그레이션 실행 ← alembic upgrade head
3. app1 배포
4. app2 배포

하위 호환 마이그레이션 원칙:

  • ✅ 컬럼 추가는 nullable=True로 설정
  • ✅ 인덱스 추가는 안전
  • ⚠️ 컬럼 삭제, NOT NULL 제약은 2단계 배포 필요

트러블슈팅

마이그레이션 충돌

# 현재 상태 확인
alembic current

# 마이그레이션 히스토리 확인
alembic history

# 특정 리비전으로 강제 설정 (주의!)
alembic stamp <revision_id>

마이그레이션 실패 시

마이그레이션은 트랜잭션 내에서 실행되므로 실패 시 자동 롤백됩니다. 하지만 배포는 중단되므로:

  1. 오류 로그 확인
  2. 마이그레이션 파일 수정
  3. 다시 배포 실행

더 자세한 학습 내용은 plan.md의 "6단계: Alembic 데이터베이스 마이그레이션" 섹션을 참고하세요.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors