release
This commit is contained in:
commit
6a5c411800
120
.gitignore
vendored
Normal file
120
.gitignore
vendored
Normal file
@ -0,0 +1,120 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Vector DB and ML Models
|
||||
vector_db/
|
||||
*.pkl
|
||||
*.model
|
||||
*.bin
|
||||
models/
|
||||
embeddings/
|
||||
checkpoints/
|
||||
|
||||
# Data files
|
||||
data/
|
||||
*.csv
|
||||
*.json
|
||||
*.xlsx
|
||||
*.parquet
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Node.js (if applicable)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
415
restaurant/README.md
Normal file
415
restaurant/README.md
Normal file
@ -0,0 +1,415 @@
|
||||
# 카카오 API 기반 음식점 수집 서비스
|
||||
|
||||
카카오 로컬 API를 활용하여 음식점 정보를 수집하고 관리하는 RESTful API 서비스입니다.
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 주요 기능
|
||||
- 🔍 **카카오 로컬 API 연동**: 키워드 및 지역 기반 음식점 검색
|
||||
- 📊 **다중 페이지 수집**: 최대 45페이지까지 데이터 수집 지원
|
||||
- 💾 **자동 JSON 저장**: 수집된 데이터를 JSON 파일로 자동 저장
|
||||
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
|
||||
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
|
||||
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
|
||||
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
|
||||
|
||||
### 기술 스택
|
||||
- **Backend**: Python 3.11, FastAPI, aiohttp
|
||||
- **API**: Kakao Local API
|
||||
- **Container**: Docker, Multi-stage build
|
||||
- **Orchestration**: Kubernetes (AKS)
|
||||
- **Registry**: Azure Container Registry (ACR)
|
||||
- **Documentation**: Swagger UI, ReDoc
|
||||
|
||||
## 🏗️ 프로젝트 구조
|
||||
|
||||
```
|
||||
review-api/restaurant/
|
||||
├── app/ # 애플리케이션 소스
|
||||
│ ├── main.py # 메인 애플리케이션
|
||||
│ └── requirements.txt # Python 의존성
|
||||
├── deployment/ # 배포 관련 파일
|
||||
│ ├── container/ # 컨테이너 이미지 빌드
|
||||
│ │ ├── Dockerfile # 서비스 이미지 빌드
|
||||
│ │ └── Dockerfile-base # 베이스 이미지 빌드
|
||||
│ └── manifests/ # Kubernetes 매니페스트
|
||||
│ ├── configmap.yaml # 환경 설정
|
||||
│ ├── secret.yaml # 민감 정보 (API 키)
|
||||
│ ├── deployment.yaml # 애플리케이션 배포
|
||||
│ ├── service.yaml # 서비스 노출
|
||||
│ └── ingress.yaml # 외부 접근
|
||||
├── build-base.sh # 베이스 이미지 빌드 스크립트
|
||||
├── build.sh # 서비스 이미지 빌드 스크립트
|
||||
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
|
||||
├── setup.sh # 로컬 환경 설정 스크립트
|
||||
└── README.md # 프로젝트 문서
|
||||
```
|
||||
|
||||
## 📋 사전 작업
|
||||
|
||||
### 카카오 API 설정
|
||||
카카오 developers 포탈에서 애플리케이션 등록이 필요합니다.
|
||||
|
||||
1. **포탈 접속**: https://developers.kakao.com/console/app
|
||||
2. **애플리케이션 등록**:
|
||||
- 앱 이름: `RestaurantCollector`
|
||||
- 회사명: `{회사명}`
|
||||
- 카테고리: `식음료`
|
||||
3. **카카오맵 활성화**: 등록한 애플리케이션에서 좌측 '카카오맵' 메뉴 클릭하여 활성화
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 로컬 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 저장소 클론 (review-api/restaurant 디렉토리로 이동)
|
||||
cd review-api/restaurant
|
||||
|
||||
# 환경 설정 스크립트 실행
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
|
||||
# 가상환경 활성화
|
||||
source venv/bin/activate
|
||||
|
||||
# 애플리케이션 실행
|
||||
python app/main.py
|
||||
```
|
||||
|
||||
### 2. 로컬 웹 브라우저 접속
|
||||
|
||||
```bash
|
||||
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
|
||||
```
|
||||
|
||||
- **메인 페이지**: http://localhost:18000
|
||||
- **Swagger UI**: http://localhost:18000/docs
|
||||
- **ReDoc**: http://localhost:18000/redoc
|
||||
- **헬스체크**: http://localhost:18000/health
|
||||
|
||||
### 3. API 테스트
|
||||
|
||||
```bash
|
||||
# 기본 음식점 수집
|
||||
curl -X POST "http://localhost:18000/collect" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "치킨",
|
||||
"region": "서울",
|
||||
"size": 15,
|
||||
"pages": 3,
|
||||
"save_to_file": true
|
||||
}'
|
||||
|
||||
# 수집된 파일 목록 조회
|
||||
curl "http://localhost:18000/list-files"
|
||||
|
||||
# 파일 다운로드 (예시)
|
||||
curl "http://localhost:18000/download/restaurant.json" -o downloaded_restaurants.json
|
||||
```
|
||||
|
||||
## 🐳 Docker 컨테이너 실행
|
||||
|
||||
### 베이스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 베이스 이미지 빌드 및 푸시
|
||||
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 서비스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 서비스 이미지 빌드 및 푸시
|
||||
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 로컬 Docker 실행
|
||||
|
||||
```bash
|
||||
# 컨테이너 실행
|
||||
docker run -p 18000:8000 \
|
||||
-e KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6 \
|
||||
-e PORT=8000 \
|
||||
restaurant-api:latest
|
||||
|
||||
# 백그라운드 실행
|
||||
docker run -d -p 18000:8000 \
|
||||
--name restaurant-api \
|
||||
-e KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6 \
|
||||
-e PORT=8000 \
|
||||
restaurant-api:latest
|
||||
```
|
||||
|
||||
## ☸️ Kubernetes 배포
|
||||
|
||||
### 1. ACR Image Pull Secret 생성
|
||||
|
||||
```bash
|
||||
# Image Pull Secret 생성
|
||||
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 2. Kubernetes 리소스 배포
|
||||
|
||||
```bash
|
||||
# ConfigMap 및 Secret 적용
|
||||
kubectl apply -f deployment/manifests/configmap.yaml
|
||||
kubectl apply -f deployment/manifests/secret.yaml
|
||||
|
||||
# 애플리케이션 배포
|
||||
kubectl apply -f deployment/manifests/deployment.yaml
|
||||
kubectl apply -f deployment/manifests/service.yaml
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
```
|
||||
|
||||
### 3. 배포 상태 확인
|
||||
|
||||
```bash
|
||||
# Pod 상태 확인
|
||||
kubectl get pods -l app=restaurant-api
|
||||
|
||||
# 서비스 상태 확인
|
||||
kubectl get svc restaurant-api-service
|
||||
|
||||
# Ingress 상태 확인
|
||||
kubectl get ingress restaurant-api-ingress
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -l app=restaurant-api -f
|
||||
```
|
||||
|
||||
### 4. 🌐 외부 브라우저에서 접속하기
|
||||
|
||||
#### Ingress 주소 확인 방법
|
||||
|
||||
```bash
|
||||
# 1. Ingress 설정된 호스트 확인
|
||||
kubectl get ingress restaurant-api-ingress -o jsonpath='{.spec.rules[0].host}'
|
||||
|
||||
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
|
||||
kubectl get ingress restaurant-api-ingress
|
||||
|
||||
# 3. Ingress Controller의 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 4. 현재 설정된 ingress 주소 확인
|
||||
INGRESS_HOST=$(kubectl get ingress restaurant-api-ingress -o jsonpath='{.spec.rules[0].host}')
|
||||
echo "🌐 Restaurant API URL: http://${INGRESS_HOST}"
|
||||
```
|
||||
|
||||
#### 브라우저 접속 주소
|
||||
|
||||
현재 설정된 주소로 접속하세요:
|
||||
|
||||
```bash
|
||||
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
|
||||
INGRESS_URL="http://restaurant-api.20.249.191.180.nip.io"
|
||||
echo "브라우저에서 접속: ${INGRESS_URL}"
|
||||
```
|
||||
|
||||
**주요 접속 페이지:**
|
||||
- **🏠 메인 페이지**: http://restaurant-api.20.249.191.180.nip.io
|
||||
- **📖 Swagger UI**: http://restaurant-api.20.249.191.180.nip.io/docs
|
||||
- **📄 ReDoc**: http://restaurant-api.20.249.191.180.nip.io/redoc
|
||||
- **❤️ 헬스체크**: http://restaurant-api.20.249.191.180.nip.io/health
|
||||
|
||||
#### 접속 테스트
|
||||
|
||||
```bash
|
||||
# API 접속 테스트
|
||||
curl "http://restaurant-api.20.249.191.180.nip.io/health"
|
||||
|
||||
# 설정 정보 확인
|
||||
curl "http://restaurant-api.20.249.191.180.nip.io/config"
|
||||
|
||||
# Swagger UI 접속 확인
|
||||
curl -I "http://restaurant-api.20.249.191.180.nip.io/docs"
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### 환경 변수
|
||||
|
||||
| 변수명 | 기본값 | 설명 |
|
||||
|--------|--------|------|
|
||||
| `KAKAO_API_KEY` | `5cdc24407edbf8544f3954cfaa4650c6` | 카카오 API 키 |
|
||||
| `APP_TITLE` | `카카오 API 기반 음식점 수집 서비스` | 애플리케이션 제목 |
|
||||
| `HOST` | `0.0.0.0` | 서버 호스트 |
|
||||
| `PORT` | `8000` | 서버 포트 (컨테이너 내부) |
|
||||
| `DEFAULT_QUERY` | `음식점` | 기본 검색 키워드 |
|
||||
| `DEFAULT_REGION` | `서울` | 기본 검색 지역 |
|
||||
| `DEFAULT_SIZE` | `15` | 페이지당 결과 수 |
|
||||
| `MAX_PAGES` | `45` | 최대 페이지 수 |
|
||||
| `DATA_DIR` | `/app/data` | 데이터 저장 디렉토리 |
|
||||
| `REQUEST_DELAY` | `0.1` | API 요청 간 지연시간(초) |
|
||||
|
||||
## 📊 API 엔드포인트
|
||||
|
||||
### 주요 엔드포인트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| `GET` | `/` | 메인 페이지 |
|
||||
| `GET` | `/docs` | Swagger UI 문서 |
|
||||
| `GET` | `/health` | 헬스체크 |
|
||||
| `POST` | `/collect` | 음식점 정보 수집 |
|
||||
| `POST` | `/collect-background` | 백그라운드 수집 |
|
||||
| `GET` | `/list-files` | 저장된 파일 목록 |
|
||||
| `GET` | `/download/{filename}` | 파일 다운로드 |
|
||||
| `GET` | `/config` | 환경 설정 확인 |
|
||||
|
||||
### 수집 API 예시
|
||||
|
||||
```json
|
||||
POST /collect
|
||||
{
|
||||
"query": "피자",
|
||||
"region": "강남구",
|
||||
"size": 15,
|
||||
"pages": 5,
|
||||
"save_to_file": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "음식점 정보 수집이 완료되었습니다. (총 67개)",
|
||||
"metadata": {
|
||||
"collection_date": "2024-06-12T10:30:00",
|
||||
"query": "피자",
|
||||
"region": "강남구",
|
||||
"total_count": 67,
|
||||
"pages_collected": 5,
|
||||
"execution_time": 12.5
|
||||
},
|
||||
"restaurants": [...],
|
||||
"file_saved": true,
|
||||
"file_path": "/app/data/restaurants_피자_강남구_20240612_103000.json"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 개발 및 확장
|
||||
|
||||
### 로컬 개발
|
||||
|
||||
```bash
|
||||
# 개발 모드로 실행 (자동 재시작)
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 18000 --reload
|
||||
|
||||
# 의존성 추가
|
||||
pip install 새패키지명
|
||||
pip freeze > app/requirements.txt
|
||||
|
||||
# 코드 포맷팅
|
||||
black app/main.py
|
||||
flake8 app/main.py
|
||||
```
|
||||
|
||||
### Ingress 호스트 변경
|
||||
|
||||
현재 환경에 맞게 Ingress 호스트를 변경하려면:
|
||||
|
||||
```bash
|
||||
# 1. 현재 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
|
||||
# 예: restaurant-api.{YOUR_EXTERNAL_IP}.nip.io
|
||||
|
||||
# 3. 변경사항 적용
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
|
||||
# 4. 새로운 주소 확인
|
||||
kubectl get ingress restaurant-api-ingress
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
**1. API 키 인증 실패**
|
||||
```bash
|
||||
# API 키 확인
|
||||
curl -H "Authorization: KakaoAK YOUR_API_KEY" \
|
||||
"https://dapi.kakao.com/v2/local/search/keyword.json?query=테스트&size=1"
|
||||
|
||||
# 환경변수 확인
|
||||
echo $KAKAO_API_KEY
|
||||
```
|
||||
|
||||
**2. Kubernetes 배포 실패**
|
||||
```bash
|
||||
# Pod 로그 확인
|
||||
kubectl logs -l app=restaurant-api
|
||||
|
||||
# ConfigMap 확인
|
||||
kubectl get configmap restaurant-api-config -o yaml
|
||||
|
||||
# Secret 확인
|
||||
kubectl get secret restaurant-api-secret -o yaml
|
||||
```
|
||||
|
||||
**3. Ingress 접속 실패**
|
||||
```bash
|
||||
# Ingress Controller 상태 확인
|
||||
kubectl get pods -n ingress-nginx
|
||||
|
||||
# Ingress 규칙 확인
|
||||
kubectl describe ingress restaurant-api-ingress
|
||||
|
||||
# Service 연결 확인
|
||||
kubectl get endpoints restaurant-api-service
|
||||
```
|
||||
|
||||
**4. 포트 관련 문제**
|
||||
- 로컬 개발: 18000번 포트 사용
|
||||
- Docker 컨테이너: 8000번 포트로 실행 (외부 18000번으로 매핑)
|
||||
- Kubernetes: 8000번 포트로 실행 (Service에서 80번으로 노출, Ingress를 통해 외부 접근)
|
||||
|
||||
## 🎯 성능 최적화
|
||||
|
||||
### 권장 설정
|
||||
|
||||
```yaml
|
||||
# deployment.yaml에서 리소스 최적화
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
```
|
||||
|
||||
### 모니터링
|
||||
|
||||
```bash
|
||||
# 리소스 사용량 확인
|
||||
kubectl top pods -l app=restaurant-api
|
||||
|
||||
# 메트릭 수집 (Prometheus 설정 시)
|
||||
kubectl get --raw /metrics
|
||||
```
|
||||
|
||||
## 📈 향후 확장 계획
|
||||
|
||||
- [ ] **전국 확대**: 지역별 음식점 수집 기능 확장
|
||||
- [ ] **데이터베이스 연동**: PostgreSQL, MongoDB 지원
|
||||
- [ ] **비동기 작업 큐**: Celery, Redis 활용
|
||||
- [ ] **데이터 분석**: 음식점 트렌드 분석 기능
|
||||
- [ ] **알림 시스템**: 새로운 음식점 알림 기능
|
||||
- [ ] **사용자 인증**: JWT 기반 인증 시스템
|
||||
- [ ] **API 레이트 리미팅**: 요청 제한 기능
|
||||
- [ ] **GraphQL 지원**: 유연한 쿼리 인터페이스
|
||||
- [ ] **실시간 모니터링**: Grafana, Prometheus 연동
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
- **이슈 리포트**: GitHub Issues
|
||||
- **기술 문의**: 개발팀 Slack
|
||||
- **API 문서**: Swagger UI에서 상세 확인
|
||||
741
restaurant/app/main.py
Normal file
741
restaurant/app/main.py
Normal file
@ -0,0 +1,741 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
카카오 API 기반 음식점 수집 서비스
|
||||
review-api/restaurant/app/main.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import uvicorn
|
||||
|
||||
# =============================================================================
|
||||
# .env 파일 로딩 (다른 import보다 먼저)
|
||||
# =============================================================================
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 파일에서 환경변수 로드
|
||||
load_dotenv()
|
||||
|
||||
# =============================================================================
|
||||
# 로깅 설정
|
||||
# =============================================================================
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# 환경 변수 설정
|
||||
# =============================================================================
|
||||
class Config:
|
||||
"""환경 변수 기반 설정 클래스"""
|
||||
|
||||
# 애플리케이션 메타데이터
|
||||
APP_TITLE = os.getenv("APP_TITLE", "카카오 API 기반 음식점 수집 서비스")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "카카오 로컬 API를 활용한 음식점 정보 수집 시스템")
|
||||
|
||||
# 서버 설정
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "8000"))
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
|
||||
|
||||
# 카카오 API 설정
|
||||
KAKAO_API_KEY = os.getenv("KAKAO_API_KEY", "5cdc24407edbf8544f3954cfaa4650c6")
|
||||
KAKAO_API_URL = "https://dapi.kakao.com/v2/local/search/keyword.json"
|
||||
|
||||
# 검색 설정
|
||||
DEFAULT_QUERY = os.getenv("DEFAULT_QUERY", "음식점")
|
||||
DEFAULT_REGION = os.getenv("DEFAULT_REGION", "서울")
|
||||
DEFAULT_SIZE = int(os.getenv("DEFAULT_SIZE", "15"))
|
||||
MAX_SIZE = int(os.getenv("MAX_SIZE", "15")) # 카카오 API 최대값
|
||||
MAX_PAGES = int(os.getenv("MAX_PAGES", "45")) # 카카오 API 최대 페이지 (45페이지 * 15 = 675개)
|
||||
|
||||
# 파일 설정
|
||||
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "restaurant.json")
|
||||
DATA_DIR = os.getenv("DATA_DIR", "./data")
|
||||
|
||||
# 요청 제한 설정
|
||||
REQUEST_DELAY = float(os.getenv("REQUEST_DELAY", "0.1")) # API 요청 간 지연시간(초)
|
||||
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
|
||||
|
||||
# 헬스체크 설정
|
||||
HEALTH_CHECK_TIMEOUT = int(os.getenv("HEALTH_CHECK_TIMEOUT", "10"))
|
||||
|
||||
config = Config()
|
||||
|
||||
# 데이터 디렉토리 생성
|
||||
os.makedirs(config.DATA_DIR, exist_ok=True)
|
||||
|
||||
# FastAPI 앱 초기화
|
||||
app = FastAPI(
|
||||
title=config.APP_TITLE,
|
||||
description=f"""
|
||||
{config.APP_DESCRIPTION}
|
||||
|
||||
**주요 기능:**
|
||||
- 카카오 로컬 API를 활용한 음식점 정보 수집
|
||||
- 지역별, 키워드별 검색 지원
|
||||
- JSON 파일 자동 저장 기능
|
||||
- RESTful API 제공
|
||||
- Swagger UI 문서 제공
|
||||
|
||||
**API 키:** {config.KAKAO_API_KEY[:10]}...
|
||||
**버전:** {config.APP_VERSION}
|
||||
""",
|
||||
version=config.APP_VERSION,
|
||||
contact={
|
||||
"name": "관리자",
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic 모델 정의
|
||||
# =============================================================================
|
||||
|
||||
class RestaurantSearchRequest(BaseModel):
|
||||
"""음식점 검색 요청 모델"""
|
||||
query: str = Field(
|
||||
default=config.DEFAULT_QUERY,
|
||||
description="검색 키워드 (예: 치킨, 피자, 한식)",
|
||||
example="치킨"
|
||||
)
|
||||
region: str = Field(
|
||||
default=config.DEFAULT_REGION,
|
||||
description="검색 지역 (예: 서울, 부산, 대구)",
|
||||
example="서울"
|
||||
)
|
||||
size: int = Field(
|
||||
default=config.DEFAULT_SIZE,
|
||||
description=f"페이지당 결과 수 (1-{config.MAX_SIZE})",
|
||||
example=15,
|
||||
ge=1,
|
||||
le=config.MAX_SIZE
|
||||
)
|
||||
pages: int = Field(
|
||||
default=5,
|
||||
description=f"검색할 페이지 수 (1-{config.MAX_PAGES})",
|
||||
example=5,
|
||||
ge=1,
|
||||
le=config.MAX_PAGES
|
||||
)
|
||||
save_to_file: bool = Field(
|
||||
default=True,
|
||||
description="결과를 JSON 파일로 저장할지 여부",
|
||||
example=True
|
||||
)
|
||||
|
||||
class RestaurantInfo(BaseModel):
|
||||
"""음식점 정보 모델"""
|
||||
id: str = Field(description="카카오 장소 ID")
|
||||
place_name: str = Field(description="장소명")
|
||||
category_name: str = Field(description="카테고리명")
|
||||
category_group_code: str = Field(description="카테고리 그룹 코드")
|
||||
category_group_name: str = Field(description="카테고리 그룹명")
|
||||
phone: str = Field(description="전화번호")
|
||||
address_name: str = Field(description="전체 지번 주소")
|
||||
road_address_name: str = Field(description="전체 도로명 주소")
|
||||
place_url: str = Field(description="장소 상세페이지 URL")
|
||||
distance: str = Field(description="중심좌표까지의 거리 (meter)")
|
||||
x: str = Field(description="X 좌표값, 경위도인 경우 longitude")
|
||||
y: str = Field(description="Y 좌표값, 경위도인 경우 latitude")
|
||||
|
||||
class CollectionMetadata(BaseModel):
|
||||
"""수집 메타데이터"""
|
||||
collection_date: str = Field(description="수집 날짜시간")
|
||||
query: str = Field(description="검색 키워드")
|
||||
region: str = Field(description="검색 지역")
|
||||
total_count: int = Field(description="총 수집된 음식점 수")
|
||||
pages_collected: int = Field(description="수집된 페이지 수")
|
||||
api_key_used: str = Field(description="사용된 API 키 (마스킹)")
|
||||
execution_time: float = Field(description="실행 시간(초)")
|
||||
|
||||
class RestaurantSearchResponse(BaseModel):
|
||||
"""음식점 검색 응답 모델"""
|
||||
success: bool = Field(description="검색 성공 여부")
|
||||
message: str = Field(description="응답 메시지")
|
||||
metadata: CollectionMetadata
|
||||
restaurants: List[RestaurantInfo]
|
||||
file_saved: bool = Field(description="파일 저장 여부")
|
||||
file_path: Optional[str] = Field(description="저장된 파일 경로")
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""에러 응답 모델"""
|
||||
success: bool = False
|
||||
error: str
|
||||
message: str
|
||||
timestamp: str
|
||||
|
||||
# =============================================================================
|
||||
# 카카오 API 클라이언트
|
||||
# =============================================================================
|
||||
|
||||
class KakaoRestaurantCollector:
|
||||
"""카카오 API를 활용한 음식점 수집기"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = config.KAKAO_API_KEY
|
||||
self.api_url = config.KAKAO_API_URL
|
||||
self.session = None
|
||||
logger.info("KakaoRestaurantCollector 초기화 완료")
|
||||
|
||||
async def __aenter__(self):
|
||||
"""비동기 컨텍스트 매니저 진입"""
|
||||
self.session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=config.REQUEST_TIMEOUT),
|
||||
headers={
|
||||
'Authorization': f'KakaoAK {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""비동기 컨텍스트 매니저 종료"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def search_restaurants(self, query: str, region: str, size: int = 15, pages: int = 5) -> Dict[str, Any]:
|
||||
"""음식점 검색 실행 (개선된 버전)"""
|
||||
logger.info(f"음식점 검색 시작: query='{query}', region='{region}', size={size}, pages={pages}")
|
||||
|
||||
start_time = datetime.now()
|
||||
all_restaurants = []
|
||||
collected_pages = 0
|
||||
empty_page_count = 0 # 연속 빈 페이지 카운터 추가
|
||||
|
||||
try:
|
||||
for page in range(1, pages + 1):
|
||||
logger.info(f"페이지 {page}/{pages} 검색 중...")
|
||||
|
||||
# API 요청 파라미터 - 더 구체적인 검색
|
||||
params = {
|
||||
'query': f"{query} {region}",
|
||||
'category_group_code': 'FD6', # 음식점 카테고리
|
||||
'page': page,
|
||||
'size': size,
|
||||
'sort': 'accuracy'
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.session.get(self.api_url, params=params) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
documents = data.get('documents', [])
|
||||
|
||||
if not documents:
|
||||
empty_page_count += 1
|
||||
logger.warning(f"페이지 {page}에서 결과 없음 (연속 빈 페이지: {empty_page_count})")
|
||||
|
||||
# 연속 3페이지가 비어있으면 종료 (개선)
|
||||
if empty_page_count >= 3:
|
||||
logger.info(f"연속 {empty_page_count}페이지 빈 결과로 검색 종료")
|
||||
break
|
||||
|
||||
# 빈 페이지여도 계속 진행
|
||||
await asyncio.sleep(config.REQUEST_DELAY)
|
||||
continue
|
||||
|
||||
# 결과가 있으면 빈 페이지 카운터 리셋
|
||||
empty_page_count = 0
|
||||
|
||||
# 음식점 정보 추출 및 저장
|
||||
page_restaurants = []
|
||||
for doc in documents:
|
||||
restaurant = self._extract_restaurant_info(doc)
|
||||
if restaurant:
|
||||
page_restaurants.append(restaurant)
|
||||
|
||||
all_restaurants.extend(page_restaurants)
|
||||
collected_pages += 1
|
||||
|
||||
logger.info(f"페이지 {page} 완료: {len(page_restaurants)}개 음식점 수집 (총: {len(all_restaurants)}개)")
|
||||
|
||||
# API 요청 제한을 위한 지연
|
||||
if page < pages:
|
||||
await asyncio.sleep(config.REQUEST_DELAY)
|
||||
|
||||
elif response.status == 400:
|
||||
error_data = await response.json()
|
||||
logger.warning(f"API 요청 오류 (페이지 {page}): {error_data}")
|
||||
|
||||
# 400 오류여도 계속 진행 (다른 페이지는 성공할 수 있음)
|
||||
empty_page_count += 1
|
||||
if empty_page_count >= 5:
|
||||
break
|
||||
continue
|
||||
|
||||
elif response.status == 401:
|
||||
logger.error("API 키 인증 실패")
|
||||
raise HTTPException(status_code=401, detail="카카오 API 키 인증 실패")
|
||||
|
||||
elif response.status == 429:
|
||||
logger.warning("API 요청 한도 초과 - 2초 대기 후 재시도")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
else:
|
||||
logger.error(f"API 요청 실패: HTTP {response.status}")
|
||||
empty_page_count += 1
|
||||
if empty_page_count >= 5:
|
||||
break
|
||||
continue
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"페이지 {page} 요청 타임아웃")
|
||||
empty_page_count += 1
|
||||
if empty_page_count >= 5:
|
||||
break
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"페이지 {page} 요청 중 오류: {e}")
|
||||
empty_page_count += 1
|
||||
if empty_page_count >= 5:
|
||||
break
|
||||
continue
|
||||
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 중복 제거
|
||||
|
||||
logger.info(f"중복 제거 시작: {len(all_restaurants)}개")
|
||||
|
||||
# 중복 제거 통계
|
||||
stats = {
|
||||
'total': len(all_restaurants),
|
||||
'by_place_url': 0,
|
||||
'by_place_id': 0,
|
||||
'by_coordinates': 0,
|
||||
'duplicates': 0
|
||||
}
|
||||
|
||||
unique_restaurants = {}
|
||||
|
||||
for restaurant in all_restaurants:
|
||||
place_url = restaurant.get('place_url', '').strip()
|
||||
place_id = restaurant.get('id', '').strip()
|
||||
place_name = restaurant.get('place_name', '').strip()
|
||||
x = restaurant.get('x', '').strip()
|
||||
y = restaurant.get('y', '').strip()
|
||||
|
||||
# 고유 키 생성 (다중 전략)
|
||||
unique_key = None
|
||||
|
||||
if place_url:
|
||||
unique_key = f"url:{place_url}"
|
||||
stats['by_place_url'] += 1
|
||||
elif place_id:
|
||||
unique_key = f"id:{place_id}"
|
||||
stats['by_place_id'] += 1
|
||||
elif place_name and x and y:
|
||||
unique_key = f"coord:{place_name}:{x}:{y}"
|
||||
stats['by_coordinates'] += 1
|
||||
else:
|
||||
# 마지막 수단: 이름만으로
|
||||
unique_key = f"name:{place_name}"
|
||||
|
||||
if unique_key:
|
||||
if unique_key not in unique_restaurants:
|
||||
unique_restaurants[unique_key] = restaurant
|
||||
else:
|
||||
stats['duplicates'] += 1
|
||||
|
||||
final_restaurants = list(unique_restaurants.values())
|
||||
|
||||
logger.info(f"중복 제거 완료:")
|
||||
logger.info(f" 총 입력: {stats['total']}개")
|
||||
logger.info(f" URL 기준: {stats['by_place_url']}개")
|
||||
logger.info(f" ID 기준: {stats['by_place_id']}개")
|
||||
logger.info(f" 좌표 기준: {stats['by_coordinates']}개")
|
||||
logger.info(f" 중복 제거: {stats['duplicates']}개")
|
||||
logger.info(f" 최종 결과: {len(final_restaurants)}개")
|
||||
logger.info(f" 중복률: {(stats['duplicates']/stats['total']*100):.1f}%")
|
||||
|
||||
logger.info(f"검색 완료: 총 {len(final_restaurants)}개 음식점 수집 (중복 제거 후), 수집된 페이지: {collected_pages}")
|
||||
|
||||
return {
|
||||
'restaurants': final_restaurants,
|
||||
'metadata': {
|
||||
'collection_date': start_time.isoformat(),
|
||||
'query': query,
|
||||
'region': region,
|
||||
'total_count': len(final_restaurants),
|
||||
'pages_collected': collected_pages,
|
||||
'pages_requested': pages,
|
||||
'api_key_used': f"{self.api_key[:10]}...",
|
||||
'execution_time': execution_time
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"음식점 검색 중 오류: {e}")
|
||||
raise
|
||||
|
||||
def _extract_restaurant_info(self, document: Dict) -> Dict[str, Any]:
|
||||
"""카카오 API 응답에서 음식점 정보 추출"""
|
||||
try:
|
||||
return {
|
||||
'id': document.get('id', ''),
|
||||
'place_name': document.get('place_name', ''),
|
||||
'category_name': document.get('category_name', ''),
|
||||
'category_group_code': document.get('category_group_code', ''),
|
||||
'category_group_name': document.get('category_group_name', ''),
|
||||
'phone': document.get('phone', ''),
|
||||
'address_name': document.get('address_name', ''),
|
||||
'road_address_name': document.get('road_address_name', ''),
|
||||
'place_url': document.get('place_url', ''),
|
||||
'distance': document.get('distance', ''),
|
||||
'x': document.get('x', ''),
|
||||
'y': document.get('y', '')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"음식점 정보 추출 실패: {e}")
|
||||
return None
|
||||
|
||||
# =============================================================================
|
||||
# 파일 관리 유틸리티
|
||||
# =============================================================================
|
||||
|
||||
def save_restaurants_to_file(data: Dict[str, Any], filename: str = None) -> str:
|
||||
"""음식점 데이터를 JSON 파일로 저장"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"restaurants_{timestamp}.json"
|
||||
|
||||
file_path = os.path.join(config.DATA_DIR, filename)
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"음식점 데이터 저장 완료: {file_path}")
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 저장 실패: {e}")
|
||||
raise
|
||||
|
||||
# =============================================================================
|
||||
# API 엔드포인트
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def root():
|
||||
"""메인 페이지"""
|
||||
return f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>{config.APP_TITLE}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
||||
.header {{ background: #FEE500; color: #3C1E1E; padding: 20px; border-radius: 5px; }}
|
||||
.info {{ background: #f8f9fa; border: 1px solid #dee2e6; padding: 15px; margin: 15px 0; border-radius: 5px; }}
|
||||
.link {{ display: inline-block; background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 10px 5px; }}
|
||||
.config {{ background: #e8f4fd; padding: 15px; margin: 15px 0; border-radius: 5px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🍽️ {config.APP_TITLE}</h1>
|
||||
<p>카카오 로컬 API를 활용한 음식점 정보 수집 시스템</p>
|
||||
<p>버전: {config.APP_VERSION}</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>📋 서비스 정보</h2>
|
||||
<ul>
|
||||
<li><strong>API 키:</strong> {config.KAKAO_API_KEY[:10]}... (카카오 로컬 API)</li>
|
||||
<li><strong>기본 검색어:</strong> {config.DEFAULT_QUERY}</li>
|
||||
<li><strong>기본 지역:</strong> {config.DEFAULT_REGION}</li>
|
||||
<li><strong>최대 페이지:</strong> {config.MAX_PAGES}</li>
|
||||
<li><strong>저장 경로:</strong> {config.DATA_DIR}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="config">
|
||||
<h2>⚙️ 환경 설정</h2>
|
||||
<ul>
|
||||
<li><strong>요청 지연:</strong> {config.REQUEST_DELAY}초</li>
|
||||
<li><strong>요청 타임아웃:</strong> {config.REQUEST_TIMEOUT}초</li>
|
||||
<li><strong>페이지당 결과:</strong> 최대 {config.MAX_SIZE}개</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>📚 API 문서</h2>
|
||||
<a href="/docs" class="link">Swagger UI 문서</a>
|
||||
<a href="/redoc" class="link">ReDoc 문서</a>
|
||||
<a href="/health" class="link">헬스 체크</a>
|
||||
|
||||
<h2>🛠️ 사용 방법</h2>
|
||||
<p><strong>POST /collect</strong> - 음식점 정보 수집</p>
|
||||
<pre>
|
||||
{{
|
||||
"query": "치킨",
|
||||
"region": "서울",
|
||||
"size": 15,
|
||||
"pages": 5,
|
||||
"save_to_file": true
|
||||
}}
|
||||
</pre>
|
||||
|
||||
<p><strong>GET /download/{filename}</strong> - 저장된 파일 다운로드</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.post(
|
||||
"/collect",
|
||||
response_model=RestaurantSearchResponse,
|
||||
summary="음식점 정보 수집",
|
||||
description="""
|
||||
카카오 로컬 API를 사용하여 지정된 조건의 음식점 정보를 수집합니다.
|
||||
|
||||
**주요 기능:**
|
||||
- 키워드 및 지역 기반 음식점 검색
|
||||
- 페이지네이션 지원 (최대 45페이지)
|
||||
- JSON 파일 자동 저장
|
||||
- 중복 음식점 제거
|
||||
|
||||
**응답 시간:** 페이지 수에 따라 5초-60초 소요
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "수집 성공", "model": RestaurantSearchResponse},
|
||||
400: {"description": "잘못된 요청", "model": ErrorResponse},
|
||||
401: {"description": "API 키 인증 실패", "model": ErrorResponse},
|
||||
500: {"description": "서버 오류", "model": ErrorResponse}
|
||||
}
|
||||
)
|
||||
async def collect_restaurants(request: RestaurantSearchRequest):
|
||||
"""음식점 정보 수집 API"""
|
||||
logger.info(f"음식점 수집 요청: {request}")
|
||||
|
||||
try:
|
||||
async with KakaoRestaurantCollector() as collector:
|
||||
# 음식점 검색 실행
|
||||
result = await collector.search_restaurants(
|
||||
query=request.query,
|
||||
region=request.region,
|
||||
size=request.size,
|
||||
pages=request.pages
|
||||
)
|
||||
|
||||
# 파일 저장
|
||||
file_saved = False
|
||||
file_path = None
|
||||
|
||||
if request.save_to_file:
|
||||
try:
|
||||
# 파일명 생성 (쿼리와 지역 포함)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_query = "".join(c for c in request.query if c.isalnum() or c in (' ', '_')).strip()
|
||||
safe_region = "".join(c for c in request.region if c.isalnum() or c in (' ', '_')).strip()
|
||||
filename = f"restaurants_{safe_query}_{safe_region}_{timestamp}.json"
|
||||
|
||||
file_path = save_restaurants_to_file(result, filename)
|
||||
file_saved = True
|
||||
|
||||
# 기본 파일명으로도 저장 (최신 결과)
|
||||
default_path = save_restaurants_to_file(result, config.OUTPUT_FILE)
|
||||
logger.info(f"기본 파일로도 저장: {default_path}")
|
||||
|
||||
except Exception as save_error:
|
||||
logger.error(f"파일 저장 실패: {save_error}")
|
||||
file_saved = False
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = RestaurantSearchResponse(
|
||||
success=True,
|
||||
message=f"음식점 정보 수집이 완료되었습니다. (총 {len(result['restaurants'])}개)",
|
||||
metadata=CollectionMetadata(**result['metadata']),
|
||||
restaurants=[RestaurantInfo(**r) for r in result['restaurants']],
|
||||
file_saved=file_saved,
|
||||
file_path=file_path
|
||||
)
|
||||
|
||||
logger.info(f"수집 완료: {len(result['restaurants'])}개 음식점")
|
||||
return response_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"음식점 수집 실패: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": "COLLECTION_FAILED",
|
||||
"message": f"음식점 수집 중 오류가 발생했습니다: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/list-files", summary="저장된 파일 목록", description="저장된 음식점 데이터 파일 목록을 반환합니다.")
|
||||
async def list_files():
|
||||
"""저장된 파일 목록 조회"""
|
||||
try:
|
||||
files = []
|
||||
if os.path.exists(config.DATA_DIR):
|
||||
for filename in os.listdir(config.DATA_DIR):
|
||||
if filename.endswith('.json'):
|
||||
file_path = os.path.join(config.DATA_DIR, filename)
|
||||
file_stat = os.stat(file_path)
|
||||
|
||||
files.append({
|
||||
'filename': filename,
|
||||
'size': file_stat.st_size,
|
||||
'created': datetime.fromtimestamp(file_stat.st_ctime).isoformat(),
|
||||
'modified': datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
|
||||
'download_url': f"/download/{filename}"
|
||||
})
|
||||
|
||||
files.sort(key=lambda x: x['modified'], reverse=True)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'total_files': len(files),
|
||||
'files': files,
|
||||
'data_directory': config.DATA_DIR
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 목록 조회 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
@app.get("/download/{filename}", summary="파일 다운로드", description="저장된 음식점 데이터 파일을 다운로드합니다.")
|
||||
async def download_file(filename: str):
|
||||
"""파일 다운로드"""
|
||||
file_path = os.path.join(config.DATA_DIR, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
|
||||
|
||||
if not filename.endswith('.json'):
|
||||
raise HTTPException(status_code=400, detail="JSON 파일만 다운로드 가능합니다.")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type='application/json'
|
||||
)
|
||||
|
||||
@app.get("/health", summary="헬스 체크", description="API 서버 상태를 확인합니다.")
|
||||
async def health_check():
|
||||
"""헬스 체크"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"version": config.APP_VERSION,
|
||||
"api_key_configured": bool(config.KAKAO_API_KEY),
|
||||
"data_directory_exists": os.path.exists(config.DATA_DIR),
|
||||
"message": f"{config.APP_TITLE}이 정상 작동 중입니다."
|
||||
}
|
||||
|
||||
@app.get("/config", summary="환경 설정 확인", description="현재 적용된 환경 변수 설정을 확인합니다.")
|
||||
async def get_config():
|
||||
"""환경 설정 확인"""
|
||||
return {
|
||||
"app_info": {
|
||||
"title": config.APP_TITLE,
|
||||
"version": config.APP_VERSION,
|
||||
"description": config.APP_DESCRIPTION
|
||||
},
|
||||
"server_config": {
|
||||
"host": config.HOST,
|
||||
"port": config.PORT,
|
||||
"log_level": config.LOG_LEVEL
|
||||
},
|
||||
"api_config": {
|
||||
"kakao_api_url": config.KAKAO_API_URL,
|
||||
"api_key_configured": bool(config.KAKAO_API_KEY),
|
||||
"api_key_preview": f"{config.KAKAO_API_KEY[:10]}..." if config.KAKAO_API_KEY else None
|
||||
},
|
||||
"search_defaults": {
|
||||
"default_query": config.DEFAULT_QUERY,
|
||||
"default_region": config.DEFAULT_REGION,
|
||||
"default_size": config.DEFAULT_SIZE,
|
||||
"max_size": config.MAX_SIZE,
|
||||
"max_pages": config.MAX_PAGES
|
||||
},
|
||||
"file_config": {
|
||||
"output_file": config.OUTPUT_FILE,
|
||||
"data_dir": config.DATA_DIR,
|
||||
"data_dir_exists": os.path.exists(config.DATA_DIR)
|
||||
},
|
||||
"request_config": {
|
||||
"request_delay": config.REQUEST_DELAY,
|
||||
"request_timeout": config.REQUEST_TIMEOUT,
|
||||
"health_check_timeout": config.HEALTH_CHECK_TIMEOUT
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 백그라운드 작업을 위한 예제 (향후 확장용)
|
||||
@app.post("/collect-background", summary="백그라운드 수집", description="음식점 정보를 백그라운드에서 수집합니다.")
|
||||
async def collect_restaurants_background(background_tasks: BackgroundTasks, request: RestaurantSearchRequest):
|
||||
"""백그라운드 음식점 수집"""
|
||||
|
||||
async def background_collect():
|
||||
try:
|
||||
logger.info("백그라운드 수집 시작")
|
||||
async with KakaoRestaurantCollector() as collector:
|
||||
result = await collector.search_restaurants(
|
||||
query=request.query,
|
||||
region=request.region,
|
||||
size=request.size,
|
||||
pages=request.pages
|
||||
)
|
||||
|
||||
if request.save_to_file:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"restaurants_bg_{timestamp}.json"
|
||||
save_restaurants_to_file(result, filename)
|
||||
|
||||
logger.info(f"백그라운드 수집 완료: {len(result['restaurants'])}개")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"백그라운드 수집 실패: {e}")
|
||||
|
||||
background_tasks.add_task(background_collect)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "백그라운드 수집이 시작되었습니다.",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🍽️ " + "="*60)
|
||||
print(f" {config.APP_TITLE} 서버 시작")
|
||||
print("="*64)
|
||||
print(f"📊 설정 정보:")
|
||||
print(f" - API 키: {config.KAKAO_API_KEY[:10]}...")
|
||||
print(f" - 기본 검색어: {config.DEFAULT_QUERY}")
|
||||
print(f" - 기본 지역: {config.DEFAULT_REGION}")
|
||||
print(f" - 데이터 저장 경로: {config.DATA_DIR}")
|
||||
print()
|
||||
print(f"📚 문서:")
|
||||
print(f" - Swagger UI: http://{config.HOST}:{config.PORT}/docs")
|
||||
print(f" - ReDoc: http://{config.HOST}:{config.PORT}/redoc")
|
||||
print(f" - 메인 페이지: http://{config.HOST}:{config.PORT}/")
|
||||
print()
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
log_level=config.LOG_LEVEL
|
||||
)
|
||||
|
||||
6
restaurant/app/requirements.txt
Normal file
6
restaurant/app/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
aiohttp==3.9.1
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
python-dotenv==1.0.0
|
||||
214
restaurant/build-base.sh
Executable file
214
restaurant/build-base.sh
Executable file
@ -0,0 +1,214 @@
|
||||
#!/bin/bash
|
||||
# build-base.sh - Base Image 빌드 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
BASE_IMAGE_NAME="restaurant-api-base"
|
||||
BASE_IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 Dockerfile 경로
|
||||
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
echo "====================================================="
|
||||
echo " 카카오 API 음식점 수집 서비스 Base Image 빌드"
|
||||
echo "====================================================="
|
||||
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v1.0.0 # 로컬 빌드만"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
echo "🔐 Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Docker 로그인 실행 중..."
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
echo "❌ ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 파일 확인
|
||||
echo "📁 필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
|
||||
echo "❌ ${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Base Dockerfile 확인 완료"
|
||||
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
echo "🔨 Base Image 빌드 시작..."
|
||||
echo "명령어: docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
|
||||
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Base Image 빌드 완료!"
|
||||
echo "이미지명: ${FULL_BASE_IMAGE_NAME}"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
echo "📊 Base Image 정보:"
|
||||
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
echo "🏷️ latest 태그 생성 중..."
|
||||
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🚀 ACR에 Base Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
|
||||
docker push "${FULL_BASE_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Base Image 푸시 성공"
|
||||
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "❌ Base Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Base Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧪 테스트 명령어:"
|
||||
echo " docker run --rm ${FULL_BASE_IMAGE_NAME} python --version"
|
||||
|
||||
echo ""
|
||||
echo "📝 다음 단계:"
|
||||
echo " 이제 Service Image를 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./build.sh v1.0.0"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ Base Image 빌드 실패!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"
|
||||
251
restaurant/build.sh
Executable file
251
restaurant/build.sh
Executable file
@ -0,0 +1,251 @@
|
||||
#!/bin/bash
|
||||
# build.sh - Service Image 빌드 스크립트 (Base Image 활용)
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
IMAGE_NAME="restaurant-api"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
|
||||
BASE_IMAGE_TAG="${4:-latest}"
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="${REGISTRY}/restaurant-api-base:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="restaurant-api-base:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 Dockerfile 경로
|
||||
DOCKERFILE_PATH="deployment/container/Dockerfile"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
echo "====================================================="
|
||||
echo " 카카오 API 음식점 수집 서비스 Service Image 빌드"
|
||||
echo "====================================================="
|
||||
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
|
||||
echo "Base 이미지: ${BASE_IMAGE}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v1.0.0 # 로컬 빌드만"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
|
||||
echo ""
|
||||
echo "전제조건:"
|
||||
echo " Base Image가 먼저 빌드되어 있어야 합니다:"
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
echo "🔐 Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Docker 로그인 실행 중..."
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
echo "❌ ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 파일 확인
|
||||
echo "📁 필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "app/main.py" ]; then
|
||||
echo "❌ app/main.py 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "app/requirements.txt" ]; then
|
||||
echo "❌ app/requirements.txt 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${DOCKERFILE_PATH}" ]; then
|
||||
echo "❌ ${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 모든 필수 파일이 확인되었습니다."
|
||||
echo "📄 Dockerfile: ${DOCKERFILE_PATH}"
|
||||
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
|
||||
|
||||
# Base Image 존재 확인
|
||||
echo ""
|
||||
echo "🔍 Base Image 확인 중..."
|
||||
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
|
||||
echo "✅ Base Image 확인됨: ${BASE_IMAGE}"
|
||||
else
|
||||
echo "❌ Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
|
||||
echo ""
|
||||
echo "Base Image를 먼저 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
echo "🔨 Service Image 빌드 시작... (빠른 빌드 예상)"
|
||||
echo "명령어: docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
|
||||
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Service Image 빌드 완료!"
|
||||
echo "이미지명: ${FULL_IMAGE_NAME}"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
echo "📊 Service Image 정보:"
|
||||
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
echo "🏷️ latest 태그 생성 중..."
|
||||
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🚀 ACR에 Service Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
|
||||
docker push "${FULL_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Service Image 푸시 성공"
|
||||
|
||||
if [ "${IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "❌ Service Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Service Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
|
||||
echo " ✅ 사용된 Base Image: ${BASE_IMAGE}"
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧪 테스트 명령어:"
|
||||
echo " docker run -p 8000:8000 ${FULL_IMAGE_NAME}"
|
||||
echo " curl http://localhost:8000/health"
|
||||
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🔍 ACR 이미지 확인:"
|
||||
echo " az acr repository show-tags --name ${ACR_NAME} --repository ${IMAGE_NAME}"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ Service Image 빌드 실패!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"
|
||||
237
restaurant/create-imagepullsecret.sh
Executable file
237
restaurant/create-imagepullsecret.sh
Executable file
@ -0,0 +1,237 @@
|
||||
#!/bin/bash
|
||||
# create-imagepullsecret.sh - ACR Image Pull Secret 생성 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
ACR_NAME="${1:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
|
||||
SECRET_NAME="${3:-acr-secret}"
|
||||
|
||||
echo "====================================================="
|
||||
echo " ACR Image Pull Secret 생성 (Restaurant API)"
|
||||
echo "====================================================="
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
|
||||
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 도구 확인
|
||||
echo "🔧 필수 도구 확인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
echo "설치 방법: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "❌ kubectl이 설치되지 않았습니다."
|
||||
echo "설치 방법: https://kubernetes.io/docs/tasks/tools/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
echo "설치 방법: sudo apt-get install jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 필수 도구 확인 완료"
|
||||
|
||||
# Azure 로그인 확인
|
||||
echo ""
|
||||
echo "🔐 Azure 로그인 상태 확인 중..."
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_SUBSCRIPTION=$(az account show --query name -o tsv)
|
||||
echo "✅ Azure 로그인 확인됨"
|
||||
echo " 현재 구독: ${CURRENT_SUBSCRIPTION}"
|
||||
|
||||
# Kubernetes 클러스터 연결 확인
|
||||
echo ""
|
||||
echo "☸️ Kubernetes 클러스터 연결 확인 중..."
|
||||
|
||||
if ! kubectl cluster-info &> /dev/null; then
|
||||
echo "❌ Kubernetes 클러스터에 연결되지 않았습니다."
|
||||
echo "클러스터 연결 방법:"
|
||||
echo " az aks get-credentials --resource-group ${RESOURCE_GROUP} --name <AKS_CLUSTER_NAME>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_CONTEXT=$(kubectl config current-context)
|
||||
echo "✅ Kubernetes 클러스터 연결 확인됨"
|
||||
echo " 현재 컨텍스트: ${CURRENT_CONTEXT}"
|
||||
|
||||
# ACR 정보 설정
|
||||
REGISTRY_URL="${ACR_NAME}.azurecr.io"
|
||||
|
||||
echo ""
|
||||
echo "📋 설정 정보:"
|
||||
echo " ACR 이름: ${ACR_NAME}"
|
||||
echo " 레지스트리 URL: ${REGISTRY_URL}"
|
||||
echo " 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " Secret 이름: ${SECRET_NAME}"
|
||||
|
||||
# ACR 존재 확인
|
||||
echo ""
|
||||
echo "🏪 ACR 존재 확인 중..."
|
||||
|
||||
if ! az acr show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" &> /dev/null; then
|
||||
echo "❌ ACR을 찾을 수 없습니다."
|
||||
echo "확인 사항:"
|
||||
echo " - ACR 이름: ${ACR_NAME}"
|
||||
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " - ACR이 해당 리소스 그룹에 존재하는지 확인"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ ACR 존재 확인됨: ${ACR_NAME}"
|
||||
|
||||
# ACR credential 조회
|
||||
echo ""
|
||||
echo "🔑 ACR credential 조회 중..."
|
||||
|
||||
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
echo "확인 사항:"
|
||||
echo " - ACR 이름: ${ACR_NAME}"
|
||||
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " - ACR에 대한 권한이 있는지 확인"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# JSON에서 username과 password 추출
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
echo "credential JSON:"
|
||||
echo "${credential_json}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ ACR credential 조회 성공"
|
||||
echo " 사용자명: ${username}"
|
||||
echo " 비밀번호: ${password:0:10}..."
|
||||
|
||||
# 기존 Secret 확인 및 삭제
|
||||
echo ""
|
||||
echo "🔍 기존 Secret 확인 중..."
|
||||
|
||||
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
|
||||
echo "🗑️ 기존 Secret 삭제 중..."
|
||||
kubectl delete secret "${SECRET_NAME}"
|
||||
echo "✅ 기존 Secret 삭제 완료"
|
||||
else
|
||||
echo "✅ 기존 Secret 없음"
|
||||
fi
|
||||
|
||||
# Image Pull Secret 생성
|
||||
echo ""
|
||||
echo "🔐 Image Pull Secret 생성 중..."
|
||||
|
||||
kubectl create secret docker-registry "${SECRET_NAME}" \
|
||||
--docker-server="${REGISTRY_URL}" \
|
||||
--docker-username="${username}" \
|
||||
--docker-password="${password}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Image Pull Secret 생성 성공!"
|
||||
else
|
||||
echo "❌ Image Pull Secret 생성 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Secret 정보 확인
|
||||
echo ""
|
||||
echo "📊 생성된 Secret 정보:"
|
||||
kubectl describe secret "${SECRET_NAME}"
|
||||
|
||||
echo ""
|
||||
echo "🧪 Secret 테스트 중..."
|
||||
|
||||
# Secret이 올바르게 생성되었는지 확인
|
||||
SECRET_TYPE=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.type}')
|
||||
if [ "${SECRET_TYPE}" = "kubernetes.io/dockerconfigjson" ]; then
|
||||
echo "✅ Secret 타입 확인됨: ${SECRET_TYPE}"
|
||||
else
|
||||
echo "❌ Secret 타입 불일치: ${SECRET_TYPE}"
|
||||
fi
|
||||
|
||||
# Registry URL 확인
|
||||
SECRET_REGISTRY=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq -r ".auths | keys[0]")
|
||||
if [ "${SECRET_REGISTRY}" = "${REGISTRY_URL}" ]; then
|
||||
echo "✅ Registry URL 확인됨: ${SECRET_REGISTRY}"
|
||||
else
|
||||
echo "❌ Registry URL 불일치: ${SECRET_REGISTRY}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 ACR Image Pull Secret 생성 완료!"
|
||||
echo ""
|
||||
echo "📋 사용 방법:"
|
||||
echo ""
|
||||
echo "1. Deployment에서 imagePullSecrets 사용:"
|
||||
echo " spec:"
|
||||
echo " template:"
|
||||
echo " spec:"
|
||||
echo " imagePullSecrets:"
|
||||
echo " - name: ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "2. ServiceAccount에 Secret 연결:"
|
||||
echo " kubectl patch serviceaccount default -p '{\"imagePullSecrets\": [{\"name\": \"${SECRET_NAME}\"}]}'"
|
||||
echo ""
|
||||
echo "3. Secret 확인:"
|
||||
echo " kubectl get secret ${SECRET_NAME}"
|
||||
echo " kubectl describe secret ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "4. Secret 삭제 (필요시):"
|
||||
echo " kubectl delete secret ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "🔧 다음 단계:"
|
||||
echo "1. Deployment 매니페스트에 imagePullSecrets 추가"
|
||||
echo "2. kubectl apply -f deployment/manifests/deployment.yaml"
|
||||
echo "3. Pod 상태 확인: kubectl get pods"
|
||||
echo ""
|
||||
echo "💡 문제 해결:"
|
||||
echo "- ErrImagePull 오류 시: Secret 이름과 레지스트리 URL 확인"
|
||||
echo "- 권한 오류 시: ACR에 대한 적절한 권한 확인"
|
||||
echo "- 네트워크 오류 시: 클러스터에서 ACR로의 네트워크 연결 확인"
|
||||
|
||||
echo ""
|
||||
echo "✅ Image Pull Secret 설정이 완료되었습니다!"
|
||||
|
||||
46
restaurant/deployment/container/Dockerfile
Normal file
46
restaurant/deployment/container/Dockerfile
Normal file
@ -0,0 +1,46 @@
|
||||
# deployment/container/Dockerfile
|
||||
# Restaurant Collection Service Image
|
||||
ARG BASE_IMAGE=restaurant-api-base:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# 메타데이터
|
||||
LABEL maintainer="admin@example.com"
|
||||
LABEL version="1.0.0"
|
||||
LABEL description="카카오 API 기반 음식점 수집 서비스"
|
||||
|
||||
# root로 전환 (패키지 설치용)
|
||||
USER root
|
||||
|
||||
# 환경 변수 설정
|
||||
ENV HOME=/home/appuser \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Python 의존성 파일 복사 및 설치
|
||||
COPY app/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# 애플리케이션 소스 복사
|
||||
COPY app/main.py /app/
|
||||
|
||||
# 데이터 디렉토리 생성 및 권한 설정
|
||||
RUN mkdir -p /app/data \
|
||||
&& chown -R appuser:appuser /app \
|
||||
&& chmod -R 755 /app
|
||||
|
||||
# 비root 사용자로 전환
|
||||
USER appuser
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 18000
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:18000/health || exit 1
|
||||
|
||||
# 애플리케이션 실행
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "18000", "--log-level", "info"]
|
||||
|
||||
37
restaurant/deployment/container/Dockerfile-base
Normal file
37
restaurant/deployment/container/Dockerfile-base
Normal file
@ -0,0 +1,37 @@
|
||||
# deployment/container/Dockerfile-base
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 메타데이터
|
||||
LABEL maintainer="admin@example.com"
|
||||
LABEL description="카카오 API 기반 음식점 수집 서비스 - Base Image"
|
||||
LABEL version="base-1.0.0"
|
||||
|
||||
# 환경 변수 설정
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 필수 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 비root 사용자 생성
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /bin/bash appuser \
|
||||
&& mkdir -p /home/appuser \
|
||||
&& chown -R appuser:appuser /home/appuser
|
||||
|
||||
# 작업 디렉토리 생성
|
||||
WORKDIR /app
|
||||
RUN chown appuser:appuser /app
|
||||
|
||||
# pip 업그레이드
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 기본 명령어 (오버라이드 가능)
|
||||
CMD ["python", "--version"]
|
||||
34
restaurant/deployment/manifest/configmap.yaml
Normal file
34
restaurant/deployment/manifest/configmap.yaml
Normal file
@ -0,0 +1,34 @@
|
||||
# deployment/manifests/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: restaurant-api-config
|
||||
data:
|
||||
# 애플리케이션 설정
|
||||
APP_TITLE: "카카오 API 기반 음식점 수집 서비스"
|
||||
APP_VERSION: "1.0.0"
|
||||
APP_DESCRIPTION: "카카오 로컬 API를 활용한 음식점 정보 수집 시스템"
|
||||
|
||||
# 서버 설정
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "18000"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
# 카카오 API 설정
|
||||
KAKAO_API_URL: "https://dapi.kakao.com/v2/local/search/keyword.json"
|
||||
|
||||
# 검색 기본값
|
||||
DEFAULT_QUERY: "음식점"
|
||||
DEFAULT_REGION: "서울"
|
||||
DEFAULT_SIZE: "15"
|
||||
MAX_SIZE: "15"
|
||||
MAX_PAGES: "45"
|
||||
|
||||
# 파일 설정
|
||||
OUTPUT_FILE: "restaurant.json"
|
||||
DATA_DIR: "/app/data"
|
||||
|
||||
# 요청 제한 설정
|
||||
REQUEST_DELAY: "0.1"
|
||||
REQUEST_TIMEOUT: "30"
|
||||
HEALTH_CHECK_TIMEOUT: "10"
|
||||
81
restaurant/deployment/manifest/deployment.yaml
Normal file
81
restaurant/deployment/manifest/deployment.yaml
Normal file
@ -0,0 +1,81 @@
|
||||
# deployment/manifests/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: restaurant-api
|
||||
labels:
|
||||
app: restaurant-api
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: restaurant-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: restaurant-api
|
||||
version: v1
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: api
|
||||
image: acrdigitalgarage03.azurecr.io/restaurant-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 18000
|
||||
name: http
|
||||
|
||||
# ConfigMap 환경 변수
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: restaurant-api-config
|
||||
|
||||
# Secret 환경 변수
|
||||
env:
|
||||
- name: KAKAO_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restaurant-api-secret
|
||||
key: KAKAO_API_KEY
|
||||
|
||||
# 리소스 제한
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
|
||||
# 헬스 체크
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 18000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 18000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# 볼륨 마운트 (데이터 저장용)
|
||||
volumeMounts:
|
||||
- name: data-volume
|
||||
mountPath: /app/data
|
||||
|
||||
# 볼륨 정의
|
||||
volumes:
|
||||
- name: data-volume
|
||||
emptyDir: {}
|
||||
|
||||
restartPolicy: Always
|
||||
38
restaurant/deployment/manifest/ingress.yaml
Normal file
38
restaurant/deployment/manifest/ingress.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
# deployment/manifests/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: restaurant-api-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
# 타임아웃 설정 (API 수집 시간 고려)
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/client-body-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
||||
# CORS 설정
|
||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
# 환경에 맞게 호스트명 수정 필요
|
||||
- host: restaurant-api.20.249.191.180.nip.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: restaurant-api-service
|
||||
port:
|
||||
number: 80
|
||||
# TLS 설정 (HTTPS 필요시 주석 해제)
|
||||
# tls:
|
||||
# - hosts:
|
||||
# - restaurant-api.example.com
|
||||
# secretName: restaurant-api-tls
|
||||
10
restaurant/deployment/manifest/secret.yaml
Normal file
10
restaurant/deployment/manifest/secret.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# deployment/manifests/secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: restaurant-api-secret
|
||||
type: Opaque
|
||||
data:
|
||||
# 카카오 API 키 (Base64 인코딩 필요)
|
||||
# echo -n "5cdc24407edbf8544f3954cfaa4650c6" | base64
|
||||
KAKAO_API_KEY: NWNkYzI0NDA3ZWRiZjg1NDRmMzk1NGNmYWE0NjUwYzY=
|
||||
16
restaurant/deployment/manifest/service.yaml
Normal file
16
restaurant/deployment/manifest/service.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
# deployment/manifests/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: restaurant-api-service
|
||||
labels:
|
||||
app: restaurant-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 18000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: restaurant-api
|
||||
212
restaurant/setup.sh
Executable file
212
restaurant/setup.sh
Executable file
@ -0,0 +1,212 @@
|
||||
#!/bin/bash
|
||||
# setup.sh - Restaurant API 환경 설정 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
echo "🍽️ Restaurant API 환경 설정 시작..."
|
||||
|
||||
# 시스템 정보 확인
|
||||
echo "📊 시스템 정보:"
|
||||
echo " - OS: $(lsb_release -d | cut -f2)"
|
||||
echo " - Python: $(python3 --version 2>/dev/null || echo 'Python3 미설치')"
|
||||
echo " - pip: $(pip3 --version 2>/dev/null || echo 'pip3 미설치')"
|
||||
|
||||
# 필수 패키지 설치
|
||||
echo ""
|
||||
echo "📦 필수 패키지 설치 중..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3-pip python3-venv curl wget jq
|
||||
|
||||
# Python 버전 확인
|
||||
PYTHON_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
|
||||
echo "📍 Python 버전: ${PYTHON_VERSION}"
|
||||
|
||||
if [ "$(echo "${PYTHON_VERSION} < 3.8" | bc)" -eq 1 ]; then
|
||||
echo "⚠️ Python 3.8 이상이 권장됩니다."
|
||||
fi
|
||||
|
||||
# Python 가상환경 설정
|
||||
echo ""
|
||||
echo "🐍 Python 가상환경 설정 중..."
|
||||
if [ ! -d "venv" ]; then
|
||||
python3 -m venv venv
|
||||
echo "✅ 가상환경 생성 완료"
|
||||
else
|
||||
echo "✅ 기존 가상환경 발견"
|
||||
fi
|
||||
|
||||
# 가상환경 활성화 및 라이브러리 설치
|
||||
echo "📚 Python 라이브러리 설치 중..."
|
||||
source venv/bin/activate
|
||||
|
||||
# pip 업그레이드
|
||||
pip install --upgrade pip
|
||||
|
||||
# 필요한 라이브러리 설치
|
||||
if [ -f "app/requirements.txt" ]; then
|
||||
pip install -r app/requirements.txt
|
||||
echo "✅ requirements.txt에서 라이브러리 설치 완료"
|
||||
else
|
||||
echo "⚠️ app/requirements.txt 파일을 찾을 수 없습니다."
|
||||
echo "🔧 기본 라이브러리들을 직접 설치합니다..."
|
||||
pip install fastapi uvicorn aiohttp pydantic python-multipart python-dotenv
|
||||
fi
|
||||
|
||||
# 데이터 디렉토리 생성
|
||||
echo ""
|
||||
echo "📁 데이터 디렉토리 설정 중..."
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
echo "✅ 데이터 디렉토리 생성: $(pwd)/data"
|
||||
|
||||
# 환경변수 파일 생성 (예시)
|
||||
echo ""
|
||||
echo "⚙️ 환경변수 파일 생성 중..."
|
||||
cat > .env << EOF
|
||||
# 카카오 API 설정
|
||||
KAKAO_API_KEY=5cdc24407edbf8544f3954cfaa4650c6
|
||||
|
||||
# 서버 설정
|
||||
HOST=0.0.0.0
|
||||
PORT=18000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 기본 검색 설정
|
||||
DEFAULT_QUERY=음식점
|
||||
DEFAULT_REGION=서울
|
||||
DEFAULT_SIZE=15
|
||||
MAX_PAGES=10
|
||||
|
||||
# 파일 설정
|
||||
OUTPUT_FILE=restaurant.json
|
||||
DATA_DIR=./data
|
||||
|
||||
# 요청 설정
|
||||
REQUEST_DELAY=0.1
|
||||
REQUEST_TIMEOUT=30
|
||||
EOF
|
||||
echo "✅ .env 파일 생성 완료"
|
||||
|
||||
# 네트워크 연결 테스트
|
||||
echo ""
|
||||
echo "🌐 네트워크 연결 테스트 중..."
|
||||
|
||||
# 카카오 API 연결 테스트
|
||||
if curl -s --connect-timeout 5 "https://dapi.kakao.com" > /dev/null; then
|
||||
echo "✅ 카카오 API 서버 연결 가능"
|
||||
else
|
||||
echo "❌ 카카오 API 서버 연결 실패"
|
||||
echo " 인터넷 연결 상태를 확인해주세요."
|
||||
fi
|
||||
|
||||
# API 키 유효성 간단 테스트
|
||||
echo ""
|
||||
echo "🔑 API 키 유효성 테스트 중..."
|
||||
API_KEY="5cdc24407edbf8544f3954cfaa4650c6"
|
||||
TEST_RESPONSE=$(curl -s -w "%{http_code}" -o /dev/null \
|
||||
-H "Authorization: KakaoAK ${API_KEY}" \
|
||||
--data-urlencode "query=카카오프렌즈&size=1" \
|
||||
"https://dapi.kakao.com/v2/local/search/keyword.json")
|
||||
|
||||
if [ "${TEST_RESPONSE}" = "200" ]; then
|
||||
echo "✅ API 키 유효성 확인 완료"
|
||||
elif [ "${TEST_RESPONSE}" = "401" ]; then
|
||||
echo "❌ API 키 인증 실패"
|
||||
echo " .env 파일의 KAKAO_API_KEY를 확인해주세요."
|
||||
elif [ "${TEST_RESPONSE}" = "000" ]; then
|
||||
echo "⚠️ 네트워크 연결 문제로 API 키 테스트 불가"
|
||||
else
|
||||
echo "⚠️ API 키 테스트 실패 (HTTP ${TEST_RESPONSE})"
|
||||
fi
|
||||
|
||||
# .env 파일 로딩 테스트
|
||||
echo ""
|
||||
echo "🔧 .env 파일 로딩 테스트 중..."
|
||||
python3 -c "
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
load_dotenv()
|
||||
port = os.getenv('PORT', '8000')
|
||||
data_dir = os.getenv('DATA_DIR', './data')
|
||||
print(f'✅ .env 파일 로딩 성공')
|
||||
print(f' - PORT: {port}')
|
||||
print(f' - DATA_DIR: {data_dir}')
|
||||
" 2>/dev/null && echo "✅ python-dotenv 동작 확인" || echo "❌ python-dotenv 설치 필요: pip install python-dotenv"
|
||||
|
||||
# Docker 설치 확인 (선택사항)
|
||||
echo ""
|
||||
echo "🐳 Docker 설치 확인 중..."
|
||||
if command -v docker &> /dev/null; then
|
||||
DOCKER_VERSION=$(docker --version)
|
||||
echo "✅ Docker 설치됨: ${DOCKER_VERSION}"
|
||||
|
||||
# Docker 권한 확인
|
||||
if docker ps &> /dev/null; then
|
||||
echo "✅ Docker 권한 확인됨"
|
||||
else
|
||||
echo "⚠️ Docker 권한 없음. 다음 명령어로 권한을 추가하세요:"
|
||||
echo " sudo usermod -aG docker $USER"
|
||||
echo " newgrp docker"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Docker가 설치되지 않음 (컨테이너 배포 시 필요)"
|
||||
echo " 설치 방법: https://docs.docker.com/engine/install/ubuntu/"
|
||||
fi
|
||||
|
||||
# 방화벽 설정 확인
|
||||
echo ""
|
||||
echo "🔥 방화벽 설정 확인 중..."
|
||||
if command -v ufw &> /dev/null; then
|
||||
UFW_STATUS=$(sudo ufw status | head -n1 | awk '{print $2}')
|
||||
if [ "${UFW_STATUS}" = "active" ]; then
|
||||
echo "🔥 UFW 방화벽 활성화됨"
|
||||
if sudo ufw status | grep -q "18000"; then
|
||||
echo "✅ 포트 18000 방화벽 규칙 확인됨"
|
||||
else
|
||||
echo "⚠️ 포트 18000 방화벽 규칙 없음"
|
||||
echo " 다음 명령어로 포트를 열어주세요:"
|
||||
echo " sudo ufw allow 18000"
|
||||
fi
|
||||
else
|
||||
echo "✅ UFW 방화벽 비활성화됨"
|
||||
fi
|
||||
else
|
||||
echo "✅ UFW 방화벽 미설치"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 환경 설정 완료!"
|
||||
echo ""
|
||||
echo "📋 다음 단계:"
|
||||
echo "1. 가상환경 활성화:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
echo "2. 애플리케이션 실행:"
|
||||
echo " python app/main.py"
|
||||
echo " 또는"
|
||||
echo " uvicorn app.main:app --host 0.0.0.0 --port 18000 --reload"
|
||||
echo ""
|
||||
echo "3. 웹 브라우저에서 접속:"
|
||||
echo " http://localhost:18000 (메인 페이지)"
|
||||
echo " http://localhost:18000/docs (Swagger UI)"
|
||||
echo " http://localhost:18000/health (헬스체크)"
|
||||
echo ""
|
||||
echo "4. API 테스트:"
|
||||
echo " curl -X POST \"http://localhost:18000/collect\" \\"
|
||||
echo " -H \"Content-Type: application/json\" \\"
|
||||
echo " -d '{\"query\":\"치킨\",\"region\":\"서울\",\"pages\":2}'"
|
||||
echo ""
|
||||
echo "💡 문제 발생 시:"
|
||||
echo " - 로그 확인: tail -f 로그파일"
|
||||
echo " - 환경변수 확인: cat .env"
|
||||
echo " - .env 로딩 확인: python -c \"from dotenv import load_dotenv; load_dotenv(); import os; print(os.getenv('PORT'))\""
|
||||
echo " - 네트워크 확인: curl https://dapi.kakao.com"
|
||||
echo ""
|
||||
echo "🔧 설정 파일 위치:"
|
||||
echo " - 환경변수: $(pwd)/.env"
|
||||
echo " - 데이터 저장: $(pwd)/data/"
|
||||
echo " - 애플리케이션: $(pwd)/app/"
|
||||
|
||||
deactivate 2>/dev/null || true
|
||||
echo ""
|
||||
echo "✅ 환경 설정이 완료되었습니다!"
|
||||
480
review/README.md
Normal file
480
review/README.md
Normal file
@ -0,0 +1,480 @@
|
||||
# 카카오맵 리뷰 분석 서비스 (교육용)
|
||||
|
||||
AI 기반 리뷰 분석을 통해 소상공인의 고객 피드백 관리를 지원하는 RESTful API 서비스입니다.
|
||||
|
||||
## ⚠️ 중요 법적 경고사항
|
||||
|
||||
**이 서비스는 교육 목적으로만 제작되었습니다.**
|
||||
|
||||
### 법적 위험
|
||||
- 카카오 이용약관 위반 → 계정 정지, 법적 조치
|
||||
- 개인정보보호법(PIPA) 위반 → 과태료 최대 3억원
|
||||
- 저작권 및 데이터베이스권 침해 → 손해배상
|
||||
- 업무방해죄 → 5년 이하 징역 또는 1천500만원 이하 벌금
|
||||
|
||||
### 합법적 대안
|
||||
- **카카오 공식 API 활용** ([developers.kakao.com](https://developers.kakao.com))
|
||||
- 점주 대상 자체 가게 관리 서비스 개발
|
||||
- 사용자 동의 기반 데이터 수집 앱
|
||||
- 카카오와 정식 파트너십 체결
|
||||
|
||||
**실제 서비스 사용을 금지합니다!**
|
||||
|
||||
---
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 주요 기능
|
||||
- 🔍 **카카오맵 리뷰 분석**: HTML 파싱 및 Selenium 기반 동적 수집
|
||||
- 🤖 **AI 기반 피드백**: Claude API 연동을 통한 맞춤형 개선 방안 제시
|
||||
- 📊 **감정 분석**: 긍정/부정/중립 감정 분류 및 통계
|
||||
- 💾 **Vector DB 연동**: 유사 케이스 검색 및 템플릿 활용
|
||||
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
|
||||
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
|
||||
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
|
||||
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
|
||||
|
||||
### 기술 스택
|
||||
- **Backend**: Python 3.11, FastAPI, aiohttp
|
||||
- **Web Scraping**: Selenium, BeautifulSoup4, Chrome WebDriver
|
||||
- **AI/ML**: Claude AI API, Vector DB (Chroma)
|
||||
- **Database**: PostgreSQL (향후), Vector DB
|
||||
- **Container**: Docker, Multi-stage build
|
||||
- **Orchestration**: Kubernetes (AKS)
|
||||
- **Registry**: Azure Container Registry (ACR)
|
||||
- **Documentation**: Swagger UI, ReDoc
|
||||
|
||||
## 🏗️ 프로젝트 구조
|
||||
|
||||
```
|
||||
review-api/review/
|
||||
├── app/ # 애플리케이션 소스
|
||||
│ ├── main.py # 메인 애플리케이션
|
||||
│ └── requirements.txt # Python 의존성
|
||||
├── deployment/ # 배포 관련 파일
|
||||
│ ├── container/ # 컨테이너 이미지 빌드
|
||||
│ │ ├── Dockerfile # 서비스 이미지 빌드
|
||||
│ │ └── Dockerfile-base # 베이스 이미지 빌드
|
||||
│ └── manifests/ # Kubernetes 매니페스트
|
||||
│ ├── configmap.yaml # 환경 설정
|
||||
│ ├── secret.yaml # 민감 정보 (API 키)
|
||||
│ ├── deployment.yaml # 애플리케이션 배포
|
||||
│ ├── service.yaml # 서비스 노출
|
||||
│ └── ingress.yaml # 외부 접근
|
||||
├── build-base.sh # 베이스 이미지 빌드 스크립트
|
||||
├── build.sh # 서비스 이미지 빌드 스크립트
|
||||
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
|
||||
├── setup.sh # 로컬 환경 설정 스크립트
|
||||
└── README.md # 프로젝트 문서
|
||||
```
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 로컬 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 저장소 클론 (review-api/review 디렉토리로 이동)
|
||||
cd review-api/review
|
||||
|
||||
# 환경 설정 스크립트 실행
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
|
||||
# 가상환경 활성화
|
||||
source venv/bin/activate
|
||||
|
||||
# 애플리케이션 실행
|
||||
python app/main.py
|
||||
```
|
||||
|
||||
### 2. 로컬 웹 브라우저 접속
|
||||
|
||||
```bash
|
||||
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
|
||||
```
|
||||
|
||||
- **메인 페이지**: http://localhost:19000
|
||||
- **Swagger UI**: http://localhost:19000/docs
|
||||
- **ReDoc**: http://localhost:19000/redoc
|
||||
- **헬스체크**: http://localhost:19000/health
|
||||
- **진단 정보**: http://localhost:19000/diagnostic
|
||||
- **법적 경고**: http://localhost:19000/legal-warning
|
||||
|
||||
### 3. API 테스트 (교육용)
|
||||
|
||||
```bash
|
||||
# 기본 리뷰 분석 (교육용 - 실제 사용 금지)
|
||||
curl -X POST "http://localhost:19000/analyze" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"store_id": "501745730",
|
||||
"days_limit": 7,
|
||||
"max_time": 300
|
||||
}'
|
||||
|
||||
# 환경 설정 확인
|
||||
curl "http://localhost:19000/config"
|
||||
|
||||
# 법적 경고사항 확인
|
||||
curl "http://localhost:19000/legal-warning"
|
||||
```
|
||||
|
||||
## 🐳 Docker 컨테이너 실행
|
||||
|
||||
### 베이스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 베이스 이미지 빌드 및 푸시
|
||||
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 서비스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 서비스 이미지 빌드 및 푸시
|
||||
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 로컬 Docker 실행
|
||||
|
||||
```bash
|
||||
# 컨테이너 실행
|
||||
docker run -p 19000:19000 \
|
||||
-e APP_TITLE="카카오맵 리뷰 분석 API" \
|
||||
-e PORT=19000 \
|
||||
kakao-review-api:latest
|
||||
|
||||
# 백그라운드 실행
|
||||
docker run -d -p 19000:19000 \
|
||||
--name kakao-review-api \
|
||||
-e PORT=19000 \
|
||||
kakao-review-api:latest
|
||||
```
|
||||
|
||||
## ☸️ Kubernetes 배포
|
||||
|
||||
### 1. ACR Image Pull Secret 생성
|
||||
|
||||
```bash
|
||||
# Image Pull Secret 생성
|
||||
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 2. Kubernetes 리소스 배포
|
||||
|
||||
```bash
|
||||
# ConfigMap 및 Secret 적용
|
||||
kubectl apply -f deployment/manifests/configmap.yaml
|
||||
kubectl apply -f deployment/manifests/secret.yaml
|
||||
|
||||
# 애플리케이션 배포
|
||||
kubectl apply -f deployment/manifests/deployment.yaml
|
||||
kubectl apply -f deployment/manifests/service.yaml
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
```
|
||||
|
||||
### 3. 배포 상태 확인
|
||||
|
||||
```bash
|
||||
# Pod 상태 확인
|
||||
kubectl get pods -l app=kakao-review-api
|
||||
|
||||
# 서비스 상태 확인
|
||||
kubectl get svc kakao-review-api-service
|
||||
|
||||
# Ingress 상태 확인
|
||||
kubectl get ingress kakao-review-api-ingress
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -l app=kakao-review-api -f
|
||||
```
|
||||
|
||||
### 4. 🌐 외부 브라우저에서 접속하기
|
||||
|
||||
#### Ingress 주소 확인 방법
|
||||
|
||||
```bash
|
||||
# 1. Ingress 설정된 호스트 확인
|
||||
kubectl get ingress kakao-review-api-ingress -o jsonpath='{.spec.rules[0].host}'
|
||||
|
||||
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
|
||||
kubectl get ingress kakao-review-api-ingress
|
||||
|
||||
# 3. Ingress Controller의 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 4. 현재 설정된 ingress 주소 확인
|
||||
INGRESS_HOST=$(kubectl get ingress kakao-review-api-ingress -o jsonpath='{.spec.rules[0].host}')
|
||||
echo "🌐 Review API URL: http://${INGRESS_HOST}"
|
||||
```
|
||||
|
||||
#### 브라우저 접속 주소
|
||||
|
||||
현재 설정된 주소로 접속하세요:
|
||||
|
||||
```bash
|
||||
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
|
||||
INGRESS_URL="http://kakao-review-api.20.249.191.180.nip.io"
|
||||
echo "브라우저에서 접속: ${INGRESS_URL}"
|
||||
```
|
||||
|
||||
**주요 접속 페이지:**
|
||||
- **🏠 메인 페이지**: http://kakao-review-api.20.249.191.180.nip.io
|
||||
- **📖 Swagger UI**: http://kakao-review-api.20.249.191.180.nip.io/docs
|
||||
- **📄 ReDoc**: http://kakao-review-api.20.249.191.180.nip.io/redoc
|
||||
- **❤️ 헬스체크**: http://kakao-review-api.20.249.191.180.nip.io/health
|
||||
- **🔧 진단 정보**: http://kakao-review-api.20.249.191.180.nip.io/diagnostic
|
||||
- **⚠️ 법적 경고**: http://kakao-review-api.20.249.191.180.nip.io/legal-warning
|
||||
|
||||
#### 접속 테스트
|
||||
|
||||
```bash
|
||||
# API 접속 테스트
|
||||
curl "http://kakao-review-api.20.249.191.180.nip.io/health"
|
||||
|
||||
# 설정 정보 확인
|
||||
curl "http://kakao-review-api.20.249.191.180.nip.io/config"
|
||||
|
||||
# Swagger UI 접속 확인
|
||||
curl -I "http://kakao-review-api.20.249.191.180.nip.io/docs"
|
||||
|
||||
# 법적 경고 확인
|
||||
curl "http://kakao-review-api.20.249.191.180.nip.io/legal-warning"
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### 환경 변수
|
||||
|
||||
| 변수명 | 기본값 | 설명 |
|
||||
|--------|--------|------|
|
||||
| `APP_TITLE` | `카카오맵 리뷰 분석 API` | 애플리케이션 제목 |
|
||||
| `APP_VERSION` | `1.0.1` | 애플리케이션 버전 |
|
||||
| `HOST` | `0.0.0.0` | 서버 호스트 |
|
||||
| `PORT` | `19000` | 서버 포트 |
|
||||
| `DEFAULT_MAX_TIME` | `300` | 기본 최대 스크롤 시간(초) |
|
||||
| `DEFAULT_DAYS_LIMIT` | `60` | 기본 날짜 제한(일) |
|
||||
| `MAX_DAYS_LIMIT` | `365` | 최대 날짜 제한(일) |
|
||||
| `CHROME_OPTIONS` | `--headless...` | Chrome 브라우저 옵션 |
|
||||
| `LEGAL_WARNING_ENABLED` | `true` | 법적 경고 표시 여부 |
|
||||
| `CONTACT_EMAIL` | `admin@example.com` | 연락처 이메일 |
|
||||
|
||||
### Chrome 및 Selenium 설정
|
||||
|
||||
현재 서비스는 Chrome WebDriver를 사용합니다:
|
||||
|
||||
```bash
|
||||
# Chrome 설치 확인
|
||||
google-chrome --version
|
||||
|
||||
# ChromeDriver 설치 확인
|
||||
chromedriver --version
|
||||
|
||||
# Selenium 테스트
|
||||
python -c "from selenium import webdriver; print('Selenium 설치됨')"
|
||||
```
|
||||
|
||||
## 📊 API 엔드포인트
|
||||
|
||||
### 주요 엔드포인트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| `GET` | `/` | 메인 페이지 |
|
||||
| `GET` | `/docs` | Swagger UI 문서 |
|
||||
| `GET` | `/health` | 헬스체크 |
|
||||
| `GET` | `/diagnostic` | 시스템 진단 정보 |
|
||||
| `POST` | `/analyze` | 리뷰 분석 수행 (교육용) |
|
||||
| `GET` | `/config` | 환경 설정 확인 |
|
||||
| `GET` | `/legal-warning` | 법적 경고사항 |
|
||||
|
||||
### 리뷰 분석 API 예시 (교육용)
|
||||
|
||||
```json
|
||||
POST /analyze
|
||||
{
|
||||
"store_id": "501745730",
|
||||
"days_limit": 7,
|
||||
"max_time": 300
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "분석이 성공적으로 완료되었습니다.",
|
||||
"store_info": {
|
||||
"id": "501745730",
|
||||
"name": "맛있는 식당",
|
||||
"category": "한식",
|
||||
"rating": "4.2",
|
||||
"review_count": "1,234"
|
||||
},
|
||||
"reviews": [...],
|
||||
"analysis_date": "2024-06-12T10:30:00",
|
||||
"total_reviews": 25,
|
||||
"execution_time": 45.2
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 개발 및 확장
|
||||
|
||||
### 로컬 개발
|
||||
|
||||
```bash
|
||||
# 개발 모드로 실행 (자동 재시작)
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 19000 --reload
|
||||
|
||||
# 의존성 추가
|
||||
pip install 새패키지명
|
||||
pip freeze > app/requirements.txt
|
||||
|
||||
# 코드 포맷팅
|
||||
black app/main.py
|
||||
flake8 app/main.py
|
||||
```
|
||||
|
||||
### Ingress 호스트 변경
|
||||
|
||||
현재 환경에 맞게 Ingress 호스트를 변경하려면:
|
||||
|
||||
```bash
|
||||
# 1. 현재 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
|
||||
# 예: kakao-review-api.{YOUR_EXTERNAL_IP}.nip.io
|
||||
|
||||
# 3. 변경사항 적용
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
|
||||
# 4. 새로운 주소 확인
|
||||
kubectl get ingress kakao-review-api-ingress
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
**1. Chrome WebDriver 관련 문제**
|
||||
```bash
|
||||
# Chrome 설치 상태 확인
|
||||
docker run --rm kakao-review-api:latest google-chrome --version
|
||||
|
||||
# ChromeDriver 버전 확인
|
||||
docker run --rm kakao-review-api:latest chromedriver --version
|
||||
|
||||
# 헤드리스 모드 테스트
|
||||
curl -X POST "http://localhost:19000/analyze" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"store_id": "test", "days_limit": 1, "max_time": 30}'
|
||||
```
|
||||
|
||||
**2. Kubernetes 배포 실패**
|
||||
```bash
|
||||
# Pod 로그 확인 (Chrome 에러 확인)
|
||||
kubectl logs -l app=kakao-review-api
|
||||
|
||||
# ConfigMap 확인
|
||||
kubectl get configmap kakao-review-api-config -o yaml
|
||||
|
||||
# 리소스 사용량 확인 (Chrome이 메모리를 많이 사용함)
|
||||
kubectl top pods -l app=kakao-review-api
|
||||
```
|
||||
|
||||
**3. Ingress 접속 실패**
|
||||
```bash
|
||||
# Ingress Controller 상태 확인
|
||||
kubectl get pods -n ingress-nginx
|
||||
|
||||
# Ingress 규칙 확인
|
||||
kubectl describe ingress kakao-review-api-ingress
|
||||
|
||||
# Service 연결 확인
|
||||
kubectl get endpoints kakao-review-api-service
|
||||
|
||||
# 타임아웃 설정 확인 (Chrome 분석은 시간이 오래 걸림)
|
||||
kubectl get ingress kakao-review-api-ingress -o yaml | grep timeout
|
||||
```
|
||||
|
||||
**4. 포트 관련 문제**
|
||||
- 로컬 개발: 19000번 포트 사용
|
||||
- Docker 컨테이너: 19000번 포트로 실행
|
||||
- Kubernetes: 19000번 포트로 실행 (Service에서 80번으로 노출, Ingress를 통해 외부 접근)
|
||||
|
||||
**5. 법적 문제 회피**
|
||||
```bash
|
||||
# 법적 경고 항상 확인
|
||||
curl "http://kakao-review-api.20.249.191.180.nip.io/legal-warning"
|
||||
|
||||
# 교육용임을 명시하는 환경변수 설정
|
||||
export LEGAL_WARNING_ENABLED=true
|
||||
export EDUCATIONAL_USE_ONLY=true
|
||||
```
|
||||
|
||||
## 🎯 성능 최적화
|
||||
|
||||
### Chrome 최적화 설정
|
||||
|
||||
```yaml
|
||||
# deployment.yaml에서 Chrome 최적화를 위한 리소스 설정
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi" # Chrome은 메모리를 많이 사용
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi" # Chrome 분석을 위한 충분한 메모리
|
||||
cpu: "1000m"
|
||||
```
|
||||
|
||||
### 타임아웃 설정
|
||||
|
||||
```yaml
|
||||
# ingress.yaml에서 긴 분석 시간을 고려한 타임아웃
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30분
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800" # 30분
|
||||
```
|
||||
|
||||
## 📈 향후 확장 계획
|
||||
|
||||
- [ ] **Claude AI 연동**: 리뷰 분석 및 개선 방안 자동 생성
|
||||
- [ ] **Vector DB 구축**: 유사 케이스 검색 및 템플릿 관리
|
||||
- [ ] **PostgreSQL 연동**: 분석 결과 영구 저장
|
||||
- [ ] **다중 플랫폼 지원**: 네이버, 구글 등 추가 플랫폼 (합법적 방법으로)
|
||||
- [ ] **실시간 분석**: WebSocket 기반 실시간 피드백
|
||||
- [ ] **사용자 인증**: JWT 기반 인증 시스템
|
||||
- [ ] **대시보드**: 분석 결과 시각화
|
||||
- [ ] **알림 시스템**: 새로운 리뷰 알림 기능
|
||||
- [ ] **감정 분석 고도화**: 더 정확한 감정 분류
|
||||
- [ ] **비즈니스 통찰**: AI 기반 개선 방안 제시
|
||||
|
||||
## ⚖️ 법적 준수 사항
|
||||
|
||||
### 필수 확인 사항
|
||||
|
||||
1. **교육용 목적으로만 사용**
|
||||
2. **실제 서비스 환경에서 사용 금지**
|
||||
3. **카카오 이용약관 준수**
|
||||
4. **개인정보보호법 준수**
|
||||
5. **저작권 침해 금지**
|
||||
|
||||
### 권장 대안
|
||||
|
||||
1. **카카오 공식 API 사용**
|
||||
2. **자체 플랫폼 개발**
|
||||
3. **사용자 동의 기반 수집**
|
||||
4. **정식 파트너십 체결**
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
- **이슈 리포트**: GitHub Issues
|
||||
- **기술 문의**: 개발팀 Slack
|
||||
- **법적 문의**: 법무팀
|
||||
- **API 문서**: Swagger UI에서 상세 확인
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 재고지: 이 서비스는 교육용으로만 제작되었으며, 실제 운영 환경에서의 사용을 엄격히 금지합니다.**
|
||||
1626
review/app/main.py
Normal file
1626
review/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
9
review/app/requirements.txt
Normal file
9
review/app/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
beautifulsoup4==4.12.2
|
||||
selenium==4.15.2
|
||||
webdriver-manager==4.0.1
|
||||
pydantic==2.5.0
|
||||
lxml==4.9.3
|
||||
python-multipart==0.0.6
|
||||
python-dotenv==1.0.0
|
||||
217
review/build-base.sh
Executable file
217
review/build-base.sh
Executable file
@ -0,0 +1,217 @@
|
||||
#!/bin/bash
|
||||
# build-base.sh - Base Image 빌드 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
BASE_IMAGE_NAME="kakao-review-api-base"
|
||||
BASE_IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}" # ACR 이름 (예: acrdigitalgarage01)
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}" # 리소스 그룹
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 Dockerfile 경로
|
||||
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
echo "====================================================="
|
||||
echo " 카카오맵 리뷰 분석 API Base Image 빌드"
|
||||
echo "====================================================="
|
||||
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v1.0.0 # 로컬 빌드만"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
echo ""
|
||||
echo "주의: Base Image는 자주 빌드할 필요가 없습니다."
|
||||
echo " Chrome 업데이트나 기본 패키지 변경 시에만 재빌드하세요."
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
echo "🔐 Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Docker 로그인 실행 중..."
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
echo "❌ ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 파일 확인
|
||||
echo "📁 필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
|
||||
echo "❌ ${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Base Dockerfile 확인 완료"
|
||||
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
echo "🔨 Base Image 빌드 시작... (시간이 다소 소요됩니다)"
|
||||
echo "명령어: docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
|
||||
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Base Image 빌드 완료!"
|
||||
echo "이미지명: ${FULL_BASE_IMAGE_NAME}"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
echo "📊 Base Image 정보:"
|
||||
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
echo "🏷️ latest 태그 생성 중..."
|
||||
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🚀 ACR에 Base Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
|
||||
docker push "${FULL_BASE_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Base Image 푸시 성공"
|
||||
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "❌ Base Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Base Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧪 테스트 명령어:"
|
||||
echo " docker run --rm ${FULL_BASE_IMAGE_NAME} google-chrome --version"
|
||||
|
||||
echo ""
|
||||
echo "📝 다음 단계:"
|
||||
echo " 이제 Service Image를 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./scripts/build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./scripts/build.sh v1.0.0"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ Base Image 빌드 실패!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"
|
||||
257
review/build.sh
Executable file
257
review/build.sh
Executable file
@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
# build.sh - Service Image 빌드 스크립트 (Base Image 활용)
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
IMAGE_NAME="kakao-review-api"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}" # ACR 이름 (예: acrdigitalgarage01)
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}" # 리소스 그룹
|
||||
BASE_IMAGE_TAG="${4:-latest}" # Base Image 태그
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="${REGISTRY}/kakao-review-api-base:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="kakao-review-api-base:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 Dockerfile 경로
|
||||
DOCKERFILE_PATH="deployment/container/Dockerfile"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
echo "====================================================="
|
||||
echo " 카카오맵 리뷰 분석 API Service Image 빌드"
|
||||
echo "====================================================="
|
||||
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
|
||||
echo "Base 이미지: ${BASE_IMAGE}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v1.0.0 # 로컬 빌드만"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
|
||||
echo ""
|
||||
echo "전제조건:"
|
||||
echo " Base Image가 먼저 빌드되어 있어야 합니다:"
|
||||
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
echo "🔐 Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Docker 로그인 실행 중..."
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
echo "❌ ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 법적 경고
|
||||
echo "⚠️ 중요 법적 경고사항 ⚠️"
|
||||
echo "이 이미지는 교육 목적으로만 사용하세요!"
|
||||
echo "실제 서비스 사용 시 법적 책임을 질 수 있습니다."
|
||||
echo ""
|
||||
|
||||
# 필수 파일 확인
|
||||
echo "📁 필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "app/main.py" ]; then
|
||||
echo "❌ app/main.py 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "app/requirements.txt" ]; then
|
||||
echo "❌ app/requirements.txt 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${DOCKERFILE_PATH}" ]; then
|
||||
echo "❌ ${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 모든 필수 파일이 확인되었습니다."
|
||||
echo "📄 Dockerfile: ${DOCKERFILE_PATH}"
|
||||
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
|
||||
|
||||
# Base Image 존재 확인
|
||||
echo ""
|
||||
echo "🔍 Base Image 확인 중..."
|
||||
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
|
||||
echo "✅ Base Image 확인됨: ${BASE_IMAGE}"
|
||||
else
|
||||
echo "❌ Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
|
||||
echo ""
|
||||
echo "Base Image를 먼저 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./scripts/build-base.sh ${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
echo "🔨 Service Image 빌드 시작... (빠른 빌드 예상)"
|
||||
echo "명령어: docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
|
||||
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Service Image 빌드 완료!"
|
||||
echo "이미지명: ${FULL_IMAGE_NAME}"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
echo "📊 Service Image 정보:"
|
||||
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
echo "🏷️ latest 태그 생성 중..."
|
||||
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
echo "✅ latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🚀 ACR에 Service Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
|
||||
docker push "${FULL_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Service Image 푸시 성공"
|
||||
|
||||
if [ "${IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "❌ Service Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Service Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
|
||||
echo " ✅ 사용된 Base Image: ${BASE_IMAGE}"
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🧪 테스트 명령어:"
|
||||
echo " docker run -p 8000:8000 ${FULL_IMAGE_NAME}"
|
||||
echo " curl http://localhost:8000/health"
|
||||
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
echo "🔍 ACR 이미지 확인:"
|
||||
echo " az acr repository show-tags --name ${ACR_NAME} --repository ${IMAGE_NAME}"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "❌ Service Image 빌드 실패!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"
|
||||
103
review/create-imagepullsecret.sh
Executable file
103
review/create-imagepullsecret.sh
Executable file
@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# create-image-pull-secret.sh - ACR Image Pull Secret 생성 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
ACR_NAME="${1:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
|
||||
SECRET_NAME="${3:-acr-secret}"
|
||||
|
||||
echo "====================================================="
|
||||
echo " ACR Image Pull Secret 생성"
|
||||
echo "====================================================="
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
|
||||
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ACR 정보 설정
|
||||
REGISTRY_URL="${ACR_NAME}.azurecr.io"
|
||||
|
||||
echo ""
|
||||
echo "📋 ACR 정보:"
|
||||
echo " ACR 이름: ${ACR_NAME}"
|
||||
echo " 레지스트리 URL: ${REGISTRY_URL}"
|
||||
echo " 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " Secret 이름: ${SECRET_NAME}"
|
||||
|
||||
# ACR credential 조회
|
||||
echo ""
|
||||
echo "🔑 ACR credential 조회 중..."
|
||||
|
||||
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
echo "확인 사항:"
|
||||
echo " - ACR 이름: ${ACR_NAME}"
|
||||
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " - Azure 권한"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# JSON에서 username과 password 추출
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ ACR credential 조회 성공"
|
||||
echo " 사용자명: ${username}"
|
||||
echo " 비밀번호: ${password:0:10}..."
|
||||
|
||||
# 기존 Secret 삭제 (있는 경우)
|
||||
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
|
||||
echo ""
|
||||
echo "🗑️ 기존 Secret 삭제 중..."
|
||||
kubectl delete secret "${SECRET_NAME}"
|
||||
fi
|
||||
|
||||
# Image Pull Secret 생성
|
||||
echo ""
|
||||
echo "🔐 Image Pull Secret 생성 중..."
|
||||
|
||||
kubectl create secret docker-registry "${SECRET_NAME}" \
|
||||
--docker-server="${REGISTRY_URL}" \
|
||||
--docker-username="${username}" \
|
||||
--docker-password="${password}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Image Pull Secret 생성 성공!"
|
||||
else
|
||||
echo "❌ Image Pull Secret 생성 실패"
|
||||
exit 1
|
||||
fi
|
||||
72
review/deployment/container/Dockerfile
Normal file
72
review/deployment/container/Dockerfile
Normal file
@ -0,0 +1,72 @@
|
||||
# deployment/container/Dockerfile
|
||||
# Service Image - Base Image 위에 애플리케이션만 추가 (AKS 최적화)
|
||||
ARG BASE_IMAGE=kakao-review-api-base:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# 메타데이터
|
||||
LABEL maintainer="admin@example.com"
|
||||
LABEL version="1.0.1"
|
||||
LABEL description="카카오맵 리뷰 분석 API - Service Image (AKS 최적화)"
|
||||
|
||||
# root로 전환 (패키지 설치용)
|
||||
USER root
|
||||
|
||||
# 🔧 Chrome 실행을 위한 환경 변수 설정 (VM 환경과 동일)
|
||||
ENV HOME=/home/appuser \
|
||||
WDM_LOCAL=1 \
|
||||
WDM_LOG_LEVEL=0 \
|
||||
CHROME_BIN=/usr/bin/google-chrome \
|
||||
CHROMEDRIVER_BIN=/usr/local/bin/chromedriver \
|
||||
DISPLAY=:99 \
|
||||
DBUS_SESSION_BUS_ADDRESS=/dev/null
|
||||
|
||||
# Python 의존성 파일 복사 및 설치
|
||||
COPY app/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# 애플리케이션 소스 복사
|
||||
COPY app/main.py /app/
|
||||
|
||||
# 🔧 Chrome 실행을 위한 디렉토리 및 권한 설정 (VM 환경과 동일)
|
||||
RUN mkdir -p /home/appuser/.cache/selenium \
|
||||
&& mkdir -p /home/appuser/.wdm \
|
||||
&& mkdir -p /home/appuser/.local/share \
|
||||
&& mkdir -p /tmp/chrome-user-data \
|
||||
&& mkdir -p /tmp/.wdm \
|
||||
&& chown -R appuser:appuser /home/appuser \
|
||||
&& chown -R appuser:appuser /tmp/chrome-user-data \
|
||||
&& chown -R appuser:appuser /tmp/.wdm \
|
||||
&& chmod -R 755 /home/appuser \
|
||||
&& chmod -R 777 /tmp/chrome-user-data \
|
||||
&& chmod -R 777 /tmp/.wdm
|
||||
|
||||
# /app 디렉토리 권한 설정
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
# 🔧 ChromeDriver 접근 권한 확인 및 테스트
|
||||
RUN echo "=== ChromeDriver 정보 ===" \
|
||||
&& ls -la /usr/local/bin/chromedriver \
|
||||
&& chromedriver --version \
|
||||
&& echo "=== Chrome 정보 ===" \
|
||||
&& google-chrome --version \
|
||||
&& echo "=== 권한 테스트 ===" \
|
||||
&& su - appuser -c "chromedriver --version" || echo "appuser ChromeDriver 접근 확인"
|
||||
|
||||
# 🔧 간단한 Selenium import 테스트만 수행
|
||||
RUN python3 -c "from selenium import webdriver; from selenium.webdriver.chrome.options import Options; from selenium.webdriver.chrome.service import Service; print('✅ Selenium 모듈 import 성공')"
|
||||
|
||||
# 🔧 컨테이너 내 사용자를 appuser로 변경 (하지만 Chrome은 root 권한 필요)
|
||||
USER appuser
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 포트 노출 (review 서비스용)
|
||||
EXPOSE 19000
|
||||
|
||||
# 🔧 헬스체크 (개선된 타임아웃)
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:19000/health || exit 1
|
||||
|
||||
# 🔧 애플리케이션 실행 (로그 레벨 설정)
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "19000", "--log-level", "info"]
|
||||
103
review/deployment/container/Dockerfile-base
Normal file
103
review/deployment/container/Dockerfile-base
Normal file
@ -0,0 +1,103 @@
|
||||
# deployment/container/Dockerfile-base
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 메타데이터
|
||||
LABEL maintainer="admin@example.com"
|
||||
LABEL description="카카오맵 리뷰 분석 API - Base Image with Chrome (AKS 최적화)"
|
||||
LABEL version="base-1.0.2"
|
||||
|
||||
# 환경 변수 설정
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
CHROME_BIN=/usr/bin/google-chrome \
|
||||
CHROMEDRIVER_BIN=/usr/local/bin/chromedriver
|
||||
|
||||
# 🔧 필수 패키지 설치 (VM setup.sh와 동일한 패키지들)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
unzip \
|
||||
wget \
|
||||
curl \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libdrm2 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libxss1 \
|
||||
libnss3 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrender1 \
|
||||
libcairo-gobject2 \
|
||||
libgtk-3-0 \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libgtk-3-dev \
|
||||
libgconf-2-4 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 🔧 Google Chrome 설치 (VM setup.sh와 동일한 방식)
|
||||
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
|
||||
&& echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y google-chrome-stable \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Chrome 설치 확인
|
||||
RUN google-chrome --version
|
||||
|
||||
# 🔧 ChromeDriver 설치 (VM setup.sh와 동일한 방식)
|
||||
RUN CHROME_VERSION=$(google-chrome --version | grep -oP '\d+\.\d+\.\d+\.\d+' | cut -d'.' -f1) \
|
||||
&& echo "Chrome 주 버전: $CHROME_VERSION" \
|
||||
&& CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_$CHROME_VERSION") \
|
||||
&& echo "ChromeDriver 버전: $CHROMEDRIVER_VERSION" \
|
||||
&& wget -q "https://storage.googleapis.com/chrome-for-testing-public/$CHROMEDRIVER_VERSION/linux64/chromedriver-linux64.zip" -O /tmp/chromedriver.zip \
|
||||
&& unzip -q /tmp/chromedriver.zip -d /tmp/ \
|
||||
&& mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver \
|
||||
&& chmod +x /usr/local/bin/chromedriver \
|
||||
&& rm -rf /tmp/chromedriver* \
|
||||
&& chromedriver --version
|
||||
|
||||
# Chrome 및 ChromeDriver 최종 확인
|
||||
RUN echo "=== Chrome 정보 ===" \
|
||||
&& google-chrome --version \
|
||||
&& echo "=== ChromeDriver 정보 ===" \
|
||||
&& chromedriver --version \
|
||||
&& echo "=== 설치 경로 확인 ===" \
|
||||
&& ls -la /usr/bin/google-chrome* \
|
||||
&& ls -la /usr/local/bin/chromedriver
|
||||
|
||||
# 🔧 Chrome 실행을 위한 디렉토리 생성
|
||||
RUN mkdir -p /tmp/chrome-user-data \
|
||||
&& mkdir -p /home/appuser/.cache/selenium \
|
||||
&& mkdir -p /home/appuser/.wdm \
|
||||
&& mkdir -p /home/appuser/.local/share \
|
||||
&& chmod 777 /tmp/chrome-user-data
|
||||
|
||||
# 🔧 비root 사용자 생성 (하지만 Chrome은 root로 실행)
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -d /home/appuser -s /bin/bash appuser \
|
||||
&& mkdir -p /home/appuser \
|
||||
&& chown -R appuser:appuser /home/appuser
|
||||
|
||||
# 작업 디렉토리 생성
|
||||
WORKDIR /app
|
||||
RUN chown appuser:appuser /app
|
||||
|
||||
# pip 업그레이드
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
# Chrome 실행 테스트 (빌드 시 검증)
|
||||
RUN google-chrome --headless --no-sandbox --disable-dev-shm-usage --virtual-time-budget=1000 --run-all-compositor-stages-before-draw data:text/html,\<html\>\<body\>\<h1\>Test\</h1\>\</body\>\</html\> || echo "Chrome 테스트 완료"
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 기본 명령어 (오버라이드 가능)
|
||||
CMD ["python", "--version"]
|
||||
|
||||
78
review/deployment/manifests/configmap.yaml
Normal file
78
review/deployment/manifests/configmap.yaml
Normal file
@ -0,0 +1,78 @@
|
||||
# deployment/manifests/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: kakao-review-api-config
|
||||
data:
|
||||
# 애플리케이션 설정
|
||||
APP_TITLE: "카카오맵 리뷰 분석 API"
|
||||
APP_VERSION: "1.0.1"
|
||||
APP_DESCRIPTION: "교육 목적 전용 - 실제 서비스 사용 금지"
|
||||
|
||||
# 서버 설정
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
WORKERS: "1"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
# API 기본값 설정 (AKS 환경에 최적화)
|
||||
DEFAULT_MAX_TIME: "300"
|
||||
DEFAULT_DAYS_LIMIT: "60"
|
||||
MAX_DAYS_LIMIT: "365"
|
||||
MIN_MAX_TIME: "60"
|
||||
MAX_MAX_TIME: "600"
|
||||
|
||||
# 🔧 Chrome 브라우저 설정 (AKS 환경 최적화)
|
||||
CHROME_OPTIONS: |
|
||||
--headless=new
|
||||
--no-sandbox
|
||||
--disable-dev-shm-usage
|
||||
--disable-gpu
|
||||
--disable-software-rasterizer
|
||||
--window-size=1920,1080
|
||||
--disable-extensions
|
||||
--disable-plugins
|
||||
--disable-usb-keyboard-detect
|
||||
--no-first-run
|
||||
--no-default-browser-check
|
||||
--disable-logging
|
||||
--log-level=3
|
||||
--disable-background-timer-throttling
|
||||
--disable-backgrounding-occluded-windows
|
||||
--disable-renderer-backgrounding
|
||||
--disable-features=TranslateUI,VizDisplayCompositor
|
||||
--disable-ipc-flooding-protection
|
||||
--memory-pressure-off
|
||||
--max_old_space_size=4096
|
||||
--no-zygote
|
||||
--disable-setuid-sandbox
|
||||
--disable-background-networking
|
||||
--disable-default-apps
|
||||
--disable-sync
|
||||
--metrics-recording-only
|
||||
--safebrowsing-disable-auto-update
|
||||
--disable-prompt-on-repost
|
||||
--disable-hang-monitor
|
||||
--disable-client-side-phishing-detection
|
||||
--disable-component-update
|
||||
--disable-domain-reliability
|
||||
--user-data-dir=/tmp/chrome-user-data
|
||||
--data-path=/tmp/chrome-user-data
|
||||
--disk-cache-dir=/tmp/chrome-cache
|
||||
--aggressive-cache-discard
|
||||
--disable-web-security
|
||||
--allow-running-insecure-content
|
||||
--disable-blink-features=AutomationControlled
|
||||
|
||||
# 스크롤링 설정 (AKS 환경에 맞게 조정)
|
||||
SCROLL_CHECK_INTERVAL: "5"
|
||||
SCROLL_NO_CHANGE_LIMIT: "6"
|
||||
SCROLL_WAIT_TIME_SHORT: "2.0"
|
||||
SCROLL_WAIT_TIME_LONG: "3.0"
|
||||
|
||||
# 법적 경고 메시지
|
||||
LEGAL_WARNING_ENABLED: "true"
|
||||
CONTACT_EMAIL: "admin@example.com"
|
||||
|
||||
# 건강 체크 설정
|
||||
HEALTH_CHECK_TIMEOUT: "10"
|
||||
130
review/deployment/manifests/deployment.yaml
Normal file
130
review/deployment/manifests/deployment.yaml
Normal file
@ -0,0 +1,130 @@
|
||||
# deployment/manifests/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: kakao-review-api
|
||||
labels:
|
||||
app: kakao-review-api
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kakao-review-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: kakao-review-api
|
||||
version: v1
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: api
|
||||
image: acrdigitalgarage03.azurecr.io/kakao-review-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 19000
|
||||
name: http
|
||||
|
||||
# 🔧 ConfigMap 환경 변수
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: kakao-review-api-config
|
||||
|
||||
# 🔧 Secret 환경 변수
|
||||
env:
|
||||
- name: EXTERNAL_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kakao-review-api-secret
|
||||
key: EXTERNAL_API_KEY
|
||||
- name: DB_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kakao-review-api-secret
|
||||
key: DB_USERNAME
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kakao-review-api-secret
|
||||
key: DB_PASSWORD
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: kakao-review-api-secret
|
||||
key: JWT_SECRET
|
||||
# 🔧 Chrome/ChromeDriver 환경 변수 (VM과 동일)
|
||||
- name: WDM_LOCAL
|
||||
value: "/tmp/.wdm"
|
||||
- name: WDM_LOG_LEVEL
|
||||
value: "0"
|
||||
- name: CHROME_BIN
|
||||
value: "/usr/bin/google-chrome"
|
||||
- name: CHROMEDRIVER_BIN
|
||||
value: "/usr/local/bin/chromedriver"
|
||||
- name: DISPLAY
|
||||
value: ":99"
|
||||
- name: DBUS_SESSION_BUS_ADDRESS
|
||||
value: "/dev/null"
|
||||
|
||||
# 🔧 리소스 제한 (Chrome 실행에 충분한 리소스)
|
||||
resources:
|
||||
requests:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2000m"
|
||||
|
||||
# 🔧 헬스 체크 (타임아웃 증가)
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 19000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 5
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 19000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 5
|
||||
|
||||
# 🔧 간소화된 보안 컨텍스트 (AKS 호환)
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
allowPrivilegeEscalation: true
|
||||
readOnlyRootFilesystem: false
|
||||
capabilities:
|
||||
add:
|
||||
- SYS_ADMIN
|
||||
drop: []
|
||||
|
||||
# 🔧 볼륨 마운트 (Chrome 실행 최적화)
|
||||
volumeMounts:
|
||||
- name: tmp-volume
|
||||
mountPath: /tmp
|
||||
- name: dev-shm
|
||||
mountPath: /dev/shm
|
||||
|
||||
# 🔧 볼륨 정의 (간소화)
|
||||
volumes:
|
||||
- name: tmp-volume
|
||||
emptyDir: {}
|
||||
- name: dev-shm
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 2Gi
|
||||
|
||||
restartPolicy: Always
|
||||
|
||||
# 🔧 Pod 레벨 보안 설정 제거 (AKS 호환을 위해)
|
||||
# securityContext: 제거
|
||||
|
||||
39
review/deployment/manifests/ingress.yaml
Normal file
39
review/deployment/manifests/ingress.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
# deployment/manifests/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: kakao-review-api-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
# 🔧 타임아웃 설정 (Chrome 분석 시간 고려)
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
||||
# 🔧 CORS 설정 (필요시)
|
||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
# 🔧 환경에 맞게 호스트명 수정 필요
|
||||
- host: kakao-review-api.20.249.191.180.nip.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kakao-review-api-service
|
||||
port:
|
||||
number: 80
|
||||
# 🔧 TLS 설정 (HTTPS 필요시 주석 해제)
|
||||
# tls:
|
||||
# - hosts:
|
||||
# - kakao-review-api.example.com
|
||||
# secretName: kakao-review-api-tls
|
||||
|
||||
21
review/deployment/manifests/secret.yaml
Normal file
21
review/deployment/manifests/secret.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
# deployment/manifests/secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: kakao-review-api-secret
|
||||
type: Opaque
|
||||
data:
|
||||
# 🔧 현재 사용하지 않지만 향후 확장을 위한 플레이스홀더
|
||||
# 실제 값 설정 시: echo -n "your-value" | base64
|
||||
|
||||
# API 키 (향후 카카오 공식 API 연동용)
|
||||
EXTERNAL_API_KEY: ""
|
||||
|
||||
# 데이터베이스 연결 정보 (향후 DB 연동용)
|
||||
DB_USERNAME: ""
|
||||
DB_PASSWORD: ""
|
||||
DB_HOST: ""
|
||||
|
||||
# JWT 시크릿 (향후 인증 기능용)
|
||||
JWT_SECRET: ""
|
||||
|
||||
17
review/deployment/manifests/service.yaml
Normal file
17
review/deployment/manifests/service.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
# deployment/manifests/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kakao-review-api-service
|
||||
labels:
|
||||
app: kakao-review-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 19000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: kakao-review-api
|
||||
|
||||
280
review/setup.sh
Executable file
280
review/setup.sh
Executable file
@ -0,0 +1,280 @@
|
||||
#!/bin/bash
|
||||
# setup.sh - Review Analysis API 환경 설정 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Review Analysis API 환경 설정 시작..."
|
||||
|
||||
# 시스템 정보 확인
|
||||
echo "📊 시스템 정보:"
|
||||
echo " - OS: $(lsb_release -d | cut -f2)"
|
||||
echo " - Python: $(python3 --version 2>/dev/null || echo 'Python3 미설치')"
|
||||
echo " - pip: $(pip3 --version 2>/dev/null || echo 'pip3 미설치')"
|
||||
|
||||
# 필수 패키지 설치
|
||||
echo ""
|
||||
echo "📦 필수 패키지 설치 중..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3-pip python3-venv unzip wget curl jq
|
||||
|
||||
# Chrome 브라우저 설치 (리뷰 분석용)
|
||||
echo ""
|
||||
echo "🌐 Chrome 브라우저 설치 확인 중..."
|
||||
if ! command -v google-chrome &> /dev/null; then
|
||||
echo "Chrome 브라우저 설치 중..."
|
||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
|
||||
sudo apt update
|
||||
sudo apt install -y google-chrome-stable
|
||||
echo "✅ Chrome 브라우저 설치 완료"
|
||||
else
|
||||
CHROME_VERSION=$(google-chrome --version)
|
||||
echo "✅ Chrome 브라우저 이미 설치됨: ${CHROME_VERSION}"
|
||||
fi
|
||||
|
||||
# ChromeDriver 설치
|
||||
echo ""
|
||||
echo "🚗 ChromeDriver 설치 중..."
|
||||
if [ -f "/usr/local/bin/chromedriver" ]; then
|
||||
echo "기존 ChromeDriver 제거 중..."
|
||||
sudo rm -f /usr/local/bin/chromedriver
|
||||
fi
|
||||
|
||||
CHROME_VERSION=$(google-chrome --version | grep -oP '\d+\.\d+\.\d+\.\d+' | cut -d'.' -f1)
|
||||
echo "Chrome 주 버전: ${CHROME_VERSION}"
|
||||
|
||||
CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_VERSION}")
|
||||
echo "ChromeDriver 버전: ${CHROMEDRIVER_VERSION}"
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
wget -q "https://storage.googleapis.com/chrome-for-testing-public/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip"
|
||||
unzip -q chromedriver-linux64.zip
|
||||
sudo mv chromedriver-linux64/chromedriver /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/chromedriver
|
||||
|
||||
cd - > /dev/null
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo "✅ ChromeDriver 설치 완료: $(chromedriver --version)"
|
||||
|
||||
# Python 버전 확인
|
||||
PYTHON_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
|
||||
echo ""
|
||||
echo "📍 Python 버전: ${PYTHON_VERSION}"
|
||||
|
||||
if [ "$(echo "${PYTHON_VERSION} < 3.8" | bc)" -eq 1 ]; then
|
||||
echo "⚠️ Python 3.8 이상이 권장됩니다."
|
||||
fi
|
||||
|
||||
# Python 가상환경 설정
|
||||
echo ""
|
||||
echo "🐍 Python 가상환경 설정 중..."
|
||||
if [ ! -d "venv" ]; then
|
||||
python3 -m venv venv
|
||||
echo "✅ 가상환경 생성 완료"
|
||||
else
|
||||
echo "✅ 기존 가상환경 발견"
|
||||
fi
|
||||
|
||||
# 가상환경 활성화 및 라이브러리 설치
|
||||
echo "📚 Python 라이브러리 설치 중..."
|
||||
source venv/bin/activate
|
||||
|
||||
# pip 업그레이드
|
||||
pip install --upgrade pip
|
||||
|
||||
# 필요한 라이브러리 설치
|
||||
if [ -f "app/requirements.txt" ]; then
|
||||
pip install -r app/requirements.txt
|
||||
echo "✅ requirements.txt에서 라이브러리 설치 완료"
|
||||
else
|
||||
echo "⚠️ app/requirements.txt 파일을 찾을 수 없습니다."
|
||||
echo "🔧 기본 라이브러리들을 직접 설치합니다..."
|
||||
pip install fastapi uvicorn beautifulsoup4 selenium webdriver-manager pydantic python-multipart lxml
|
||||
fi
|
||||
|
||||
# 데이터 디렉토리 생성
|
||||
echo ""
|
||||
echo "📁 데이터 디렉토리 설정 중..."
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
echo "✅ 데이터 디렉토리 생성: $(pwd)/data"
|
||||
|
||||
# 환경변수 파일 생성 (예시)
|
||||
echo ""
|
||||
echo "⚙️ 환경변수 파일 생성 중..."
|
||||
cat > .env << EOF
|
||||
# 애플리케이션 설정
|
||||
APP_TITLE=카카오맵 리뷰 분석 API
|
||||
APP_VERSION=1.0.1
|
||||
APP_DESCRIPTION=교육 목적 전용 - 실제 서비스 사용 금지
|
||||
|
||||
# 서버 설정
|
||||
HOST=0.0.0.0
|
||||
PORT=19000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# API 기본값 설정
|
||||
DEFAULT_MAX_TIME=300
|
||||
DEFAULT_DAYS_LIMIT=60
|
||||
MAX_DAYS_LIMIT=365
|
||||
MIN_MAX_TIME=60
|
||||
MAX_MAX_TIME=600
|
||||
|
||||
# Chrome 브라우저 설정
|
||||
CHROME_OPTIONS=--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --window-size=1920,1080
|
||||
SCROLL_CHECK_INTERVAL=5
|
||||
SCROLL_NO_CHANGE_LIMIT=6
|
||||
SCROLL_WAIT_TIME_SHORT=2.0
|
||||
SCROLL_WAIT_TIME_LONG=3.0
|
||||
|
||||
# 법적 경고 메시지
|
||||
LEGAL_WARNING_ENABLED=true
|
||||
CONTACT_EMAIL=admin@example.com
|
||||
|
||||
# 건강 체크 설정
|
||||
HEALTH_CHECK_TIMEOUT=10
|
||||
|
||||
# AI API 설정 (향후 Claude API 연동용)
|
||||
# CLAUDE_API_KEY=your_claude_api_key_here
|
||||
|
||||
# 데이터베이스 설정 (향후 PostgreSQL 연동용)
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=review_analysis
|
||||
# DB_USERNAME=your_db_username
|
||||
# DB_PASSWORD=your_db_password
|
||||
EOF
|
||||
echo "✅ .env 파일 생성 완료"
|
||||
|
||||
# 네트워크 연결 테스트
|
||||
echo ""
|
||||
echo "🌐 네트워크 연결 테스트 중..."
|
||||
|
||||
# 카카오맵 연결 테스트
|
||||
if curl -s --connect-timeout 5 "https://place.map.kakao.com" > /dev/null; then
|
||||
echo "✅ 카카오맵 서버 연결 가능"
|
||||
else
|
||||
echo "❌ 카카오맵 서버 연결 실패"
|
||||
echo " 인터넷 연결 상태를 확인해주세요."
|
||||
fi
|
||||
|
||||
# Selenium 및 Chrome 테스트
|
||||
echo ""
|
||||
echo "🧪 Selenium 및 Chrome 테스트 중..."
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
print('✅ Selenium 모듈 import 성공')
|
||||
|
||||
options = Options()
|
||||
options.add_argument('--headless')
|
||||
options.add_argument('--no-sandbox')
|
||||
options.add_argument('--disable-dev-shm-usage')
|
||||
|
||||
try:
|
||||
service = Service('/usr/local/bin/chromedriver')
|
||||
driver = webdriver.Chrome(service=service, options=options)
|
||||
driver.get('data:text/html,<html><body><h1>Test</h1></body></html>')
|
||||
title = driver.title
|
||||
driver.quit()
|
||||
print('✅ Chrome WebDriver 테스트 성공')
|
||||
except Exception as e:
|
||||
print(f'❌ Chrome WebDriver 테스트 실패: {e}')
|
||||
|
||||
except ImportError as e:
|
||||
print(f'❌ Selenium import 실패: {e}')
|
||||
except Exception as e:
|
||||
print(f'❌ 테스트 중 오류: {e}')
|
||||
"
|
||||
|
||||
# Docker 설치 확인 (선택사항)
|
||||
echo ""
|
||||
echo "🐳 Docker 설치 확인 중..."
|
||||
if command -v docker &> /dev/null; then
|
||||
DOCKER_VERSION=$(docker --version)
|
||||
echo "✅ Docker 설치됨: ${DOCKER_VERSION}"
|
||||
|
||||
# Docker 권한 확인
|
||||
if docker ps &> /dev/null; then
|
||||
echo "✅ Docker 권한 확인됨"
|
||||
else
|
||||
echo "⚠️ Docker 권한 없음. 다음 명령어로 권한을 추가하세요:"
|
||||
echo " sudo usermod -aG docker $USER"
|
||||
echo " newgrp docker"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Docker가 설치되지 않음 (컨테이너 배포 시 필요)"
|
||||
echo " 설치 방법: https://docs.docker.com/engine/install/ubuntu/"
|
||||
fi
|
||||
|
||||
# 방화벽 설정 확인
|
||||
echo ""
|
||||
echo "🔥 방화벽 설정 확인 중..."
|
||||
if command -v ufw &> /dev/null; then
|
||||
UFW_STATUS=$(sudo ufw status | head -n1 | awk '{print $2}')
|
||||
if [ "${UFW_STATUS}" = "active" ]; then
|
||||
echo "🔥 UFW 방화벽 활성화됨"
|
||||
if sudo ufw status | grep -q "19000"; then
|
||||
echo "✅ 포트 19000 방화벽 규칙 확인됨"
|
||||
else
|
||||
echo "⚠️ 포트 19000 방화벽 규칙 없음"
|
||||
echo " 다음 명령어로 포트를 열어주세요:"
|
||||
echo " sudo ufw allow 19000"
|
||||
fi
|
||||
else
|
||||
echo "✅ UFW 방화벽 비활성화됨"
|
||||
fi
|
||||
else
|
||||
echo "✅ UFW 방화벽 미설치"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 환경 설정 완료!"
|
||||
echo ""
|
||||
echo "📋 다음 단계:"
|
||||
echo "1. 가상환경 활성화:"
|
||||
echo " source venv/bin/activate"
|
||||
echo ""
|
||||
echo "2. 환경변수 설정:"
|
||||
echo " export DATA_DIR=\"./data\""
|
||||
echo ""
|
||||
echo "3. 애플리케이션 실행:"
|
||||
echo " python app/main.py"
|
||||
echo " 또는"
|
||||
echo " uvicorn app.main:app --host 0.0.0.0 --port 19000 --reload"
|
||||
echo ""
|
||||
echo "4. 웹 브라우저에서 접속:"
|
||||
echo " http://localhost:19000 (메인 페이지)"
|
||||
echo " http://localhost:19000/docs (Swagger UI)"
|
||||
echo " http://localhost:19000/health (헬스체크)"
|
||||
echo ""
|
||||
echo "5. API 테스트:"
|
||||
echo " curl -X POST \"http://localhost:19000/analyze\" \\"
|
||||
echo " -H \"Content-Type: application/json\" \\"
|
||||
echo " -d '{\"store_id\":\"501745730\",\"days_limit\":7,\"max_time\":300}'"
|
||||
echo ""
|
||||
echo "⚠️ 중요 법적 경고사항:"
|
||||
echo " 이 서비스는 교육 목적으로만 사용하세요!"
|
||||
echo " 실제 웹사이트 크롤링은 법적 위험이 있습니다."
|
||||
echo " 카카오 공식 API를 사용하는 것을 권장합니다: developers.kakao.com"
|
||||
echo ""
|
||||
echo "💡 문제 발생 시:"
|
||||
echo " - 로그 확인: tail -f 로그파일"
|
||||
echo " - 환경변수 확인: cat .env"
|
||||
echo " - Chrome 확인: google-chrome --version"
|
||||
echo " - ChromeDriver 확인: chromedriver --version"
|
||||
echo ""
|
||||
echo "🔧 설정 파일 위치:"
|
||||
echo " - 환경변수: $(pwd)/.env"
|
||||
echo " - 데이터 저장: $(pwd)/data/"
|
||||
echo " - 애플리케이션: $(pwd)/app/"
|
||||
|
||||
deactivate 2>/dev/null || true
|
||||
echo ""
|
||||
echo "✅ Review Analysis API 환경 설정이 완료되었습니다!"
|
||||
614
vector/README.md
Normal file
614
vector/README.md
Normal file
@ -0,0 +1,614 @@
|
||||
# 음식점 Vector DB 구축 서비스
|
||||
|
||||
카카오 로컬 API를 활용하여 음식점 정보를 수집하고 Vector DB로 구축한 후, Claude AI와 연동하여 점주의 비즈니스 개선 요청을 처리하는 서비스입니다.
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 주요 기능
|
||||
- 🔍 **카카오 로컬 API 연동**: 키워드 및 지역 기반 음식점 검색
|
||||
- 🤖 **Vector DB 구축**: Sentence Transformer를 이용한 임베딩 및 Chroma DB 저장
|
||||
- 🔄 **중복 처리 개선**: 기존 데이터 존재 여부 확인 및 업데이트/신규 추가 분리
|
||||
- 🧠 **Claude AI 연동**: 점주 액션 요청에 대한 맞춤형 조언 생성
|
||||
- 🎯 **유사도 검색**: Vector DB를 활용한 유사 음식점 검색
|
||||
- 📊 **처리 통계**: 신규 추가, 업데이트, 중복 등 상세 통계 제공
|
||||
- 🚀 **RESTful API 제공**: FastAPI 기반의 완전한 API 서비스
|
||||
- 📚 **Swagger UI 지원**: 자동 생성되는 API 문서
|
||||
- ☸️ **Kubernetes 배포**: 완전한 컨테이너 오케스트레이션 지원
|
||||
- 🔧 **환경변수 설정**: ConfigMap과 Secret을 통한 유연한 설정 관리
|
||||
|
||||
### 기술 스택
|
||||
- **Backend**: Python 3.11, FastAPI, aiohttp
|
||||
- **Vector DB**: Chroma DB
|
||||
- **ML**: Sentence Transformers, torch
|
||||
- **AI**: Anthropic Claude API
|
||||
- **External API**: Kakao Local API
|
||||
- **Container**: Docker, Multi-stage build
|
||||
- **Orchestration**: Kubernetes (AKS)
|
||||
- **Registry**: Azure Container Registry (ACR)
|
||||
- **Documentation**: Swagger UI, ReDoc
|
||||
|
||||
## 🏗️ 프로젝트 구조
|
||||
|
||||
```
|
||||
review-api/vector/
|
||||
├── app/ # 애플리케이션 소스
|
||||
│ ├── main.py # 메인 애플리케이션
|
||||
│ ├── requirements.txt # Python 의존성
|
||||
│ ├── config/ # 설정 관리
|
||||
│ │ └── settings.py # 환경 설정
|
||||
│ ├── models/ # 데이터 모델
|
||||
│ ├── services/ # 비즈니스 로직
|
||||
│ │ ├── vector_service.py # Vector DB 서비스
|
||||
│ │ ├── claude_service.py # Claude AI 서비스
|
||||
│ │ └── kakao_service.py # 카카오 API 서비스
|
||||
│ └── utils/ # 유틸리티 함수
|
||||
│ └── data_utils.py # 데이터 처리 유틸
|
||||
├── deployment/ # 배포 관련 파일
|
||||
│ ├── container/ # 컨테이너 이미지 빌드
|
||||
│ │ ├── Dockerfile # 서비스 이미지 빌드
|
||||
│ │ └── Dockerfile-base # 베이스 이미지 빌드
|
||||
│ └── manifests/ # Kubernetes 매니페스트
|
||||
│ ├── configmap.yaml # 환경 설정
|
||||
│ ├── secret.yaml # 민감 정보 (API 키)
|
||||
│ ├── deployment.yaml # 애플리케이션 배포
|
||||
│ ├── service.yaml # 서비스 노출
|
||||
│ ├── ingress.yaml # 외부 접근
|
||||
│ └── pvc.yaml # 영구 저장소 (Vector DB)
|
||||
├── build-base.sh # 베이스 이미지 빌드 스크립트
|
||||
├── build.sh # 서비스 이미지 빌드 스크립트
|
||||
├── create-imagepullsecret.sh # ACR 인증 설정 스크립트
|
||||
├── setup.sh # 로컬 환경 설정 스크립트
|
||||
└── README.md # 프로젝트 문서
|
||||
```
|
||||
|
||||
## 📋 사전 작업
|
||||
|
||||
### 카카오 API 설정
|
||||
카카오 developers 포탈에서 애플리케이션 등록이 필요합니다.
|
||||
|
||||
1. **포탈 접속**: https://developers.kakao.com/console/app
|
||||
2. **애플리케이션 등록**:
|
||||
- 앱 이름: `VectorDBBuilder`
|
||||
- 회사명: `{회사명}`
|
||||
- 카테고리: `식음료`
|
||||
3. **카카오맵 활성화**: 등록한 애플리케이션에서 좌측 '카카오맵' 메뉴 클릭하여 활성화
|
||||
|
||||
### Claude API 설정
|
||||
Anthropic Claude API 키가 필요합니다.
|
||||
|
||||
1. **포탈 접속**: https://console.anthropic.com/
|
||||
2. **API 키 발급**: Console에서 API Keys 메뉴에서 발급
|
||||
3. **모델 확인**: `claude-sonnet-4-20250514` 사용
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 로컬 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 저장소 클론 (review-api/vector 디렉토리로 이동)
|
||||
cd review-api/vector
|
||||
|
||||
# 환경 설정 스크립트 실행 (시간이 오래 걸림)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
|
||||
# 가상환경 활성화
|
||||
source venv/bin/activate
|
||||
|
||||
# Claude API 키 설정 (.env 파일 생성/수정)
|
||||
cat > .env << EOF
|
||||
CLAUDE_API_KEY=your-actual-claude-api-key
|
||||
KAKAO_API_KEY=your-kakao-api-key
|
||||
EOF
|
||||
|
||||
# 애플리케이션 실행
|
||||
python app/main.py
|
||||
```
|
||||
|
||||
### 2. 로컬 웹 브라우저 접속
|
||||
|
||||
```bash
|
||||
# 애플리케이션이 정상 실행된 후 아래 URL로 접속
|
||||
```
|
||||
|
||||
- **메인 페이지**: http://localhost:8000
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
- **헬스체크**: http://localhost:8000/health
|
||||
- **Vector DB 상태**: http://localhost:8000/vector-status
|
||||
- **환경 설정**: http://localhost:8000/config
|
||||
|
||||
### 3. API 테스트
|
||||
|
||||
```bash
|
||||
# 1. Vector DB 구축 (지역과 가게명으로 동종업체 분석)
|
||||
curl -X POST "http://localhost:8000/build-vector-db" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"region": "서울특별시 강남구 역삼동",
|
||||
"store_name": "맛있는 치킨집",
|
||||
"force_rebuild": false
|
||||
}'
|
||||
|
||||
# 2. 점주 액션 요청 (Claude AI 기반 조언)
|
||||
curl -X POST "http://localhost:8000/action-request" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"store_id": "12345",
|
||||
"context": "매출이 감소하고 있어서 메뉴 개선이 필요합니다."
|
||||
}'
|
||||
|
||||
# 3. Vector DB 상태 확인
|
||||
curl "http://localhost:8000/vector-status"
|
||||
|
||||
# 4. 환경 설정 확인
|
||||
curl "http://localhost:8000/config"
|
||||
|
||||
# 5. 유사 음식점 검색
|
||||
curl -X POST "http://localhost:8000/search-similar" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "분위기 좋은 카페",
|
||||
"business_type": "카페",
|
||||
"limit": 5
|
||||
}'
|
||||
```
|
||||
|
||||
## 🐳 Docker 컨테이너 실행
|
||||
|
||||
### 베이스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 베이스 이미지 빌드 및 푸시
|
||||
./build-base.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
|
||||
# 로컬 베이스 이미지 빌드
|
||||
./build-base.sh latest
|
||||
```
|
||||
|
||||
### 서비스 이미지 빌드
|
||||
|
||||
```bash
|
||||
# ACR에 서비스 이미지 빌드 및 푸시
|
||||
./build.sh latest acrdigitalgarage03 rg-digitalgarage-03
|
||||
|
||||
# 로컬 서비스 이미지 빌드
|
||||
./build.sh latest
|
||||
```
|
||||
|
||||
### 로컬 Docker 실행
|
||||
|
||||
```bash
|
||||
# 컨테이너 실행 (환경변수 설정 필요)
|
||||
docker run -p 8000:8000 \
|
||||
-e KAKAO_API_KEY="your-kakao-api-key" \
|
||||
-e CLAUDE_API_KEY="your-claude-api-key" \
|
||||
-e PORT=8000 \
|
||||
-v $(pwd)/vector_db:/app/vectordb \
|
||||
vector-api:latest
|
||||
|
||||
# 백그라운드 실행 (영구 볼륨 사용)
|
||||
docker volume create vector_db_vol
|
||||
docker run -d -p 8000:8000 \
|
||||
--name vector-api \
|
||||
-e KAKAO_API_KEY="your-kakao-api-key" \
|
||||
-e CLAUDE_API_KEY="your-claude-api-key" \
|
||||
-e PORT=8000 \
|
||||
-v vector_db_vol:/app/vectordb \
|
||||
vector-api:latest
|
||||
|
||||
# 컨테이너 로그 확인
|
||||
docker logs vector-api -f
|
||||
|
||||
# Vector DB 데이터 확인
|
||||
docker exec -it vector-api ls -la /app/vectordb/
|
||||
```
|
||||
|
||||
## ☸️ Kubernetes 배포
|
||||
|
||||
### 1. ACR Image Pull Secret 생성
|
||||
|
||||
```bash
|
||||
# Image Pull Secret 생성
|
||||
./create-imagepullsecret.sh acrdigitalgarage03 rg-digitalgarage-03
|
||||
```
|
||||
|
||||
### 2. Kubernetes 리소스 배포
|
||||
|
||||
```bash
|
||||
# 영구 볼륨 생성 (Vector DB 저장용)
|
||||
kubectl apply -f deployment/manifests/pvc.yaml
|
||||
|
||||
# ConfigMap 및 Secret 적용
|
||||
kubectl apply -f deployment/manifests/configmap.yaml
|
||||
kubectl apply -f deployment/manifests/secret.yaml
|
||||
|
||||
# 애플리케이션 배포
|
||||
kubectl apply -f deployment/manifests/deployment.yaml
|
||||
kubectl apply -f deployment/manifests/service.yaml
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
```
|
||||
|
||||
### 3. 배포 상태 확인
|
||||
|
||||
```bash
|
||||
# Pod 상태 확인
|
||||
kubectl get pods -l app=vector-api
|
||||
|
||||
# 서비스 상태 확인
|
||||
kubectl get svc vector-api-service
|
||||
|
||||
# Ingress 상태 확인
|
||||
kubectl get ingress vector-api-ingress
|
||||
|
||||
# PVC 상태 확인 (Vector DB 저장소)
|
||||
kubectl get pvc vector-db-pvc
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -l app=vector-api -f
|
||||
|
||||
# Vector DB 디렉토리 확인
|
||||
kubectl exec -it deployment/vector-api -- ls -la /app/vectordb/
|
||||
```
|
||||
|
||||
### 4. 🌐 외부 브라우저에서 접속하기
|
||||
|
||||
#### Ingress 주소 확인 방법
|
||||
|
||||
```bash
|
||||
# 1. Ingress 설정된 호스트 확인
|
||||
kubectl get ingress vector-api-ingress -o jsonpath='{.spec.rules[0].host}'
|
||||
|
||||
# 2. Ingress External IP 확인 (LoadBalancer 타입인 경우)
|
||||
kubectl get ingress vector-api-ingress
|
||||
|
||||
# 3. Ingress Controller의 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 4. 현재 설정된 ingress 주소 확인
|
||||
INGRESS_HOST=$(kubectl get ingress vector-api-ingress -o jsonpath='{.spec.rules[0].host}')
|
||||
echo "🌐 Vector API URL: http://${INGRESS_HOST}"
|
||||
```
|
||||
|
||||
#### 브라우저 접속 주소
|
||||
|
||||
현재 설정된 주소로 접속하세요:
|
||||
|
||||
```bash
|
||||
# 현재 설정된 기본 주소 (환경에 따라 다를 수 있음)
|
||||
INGRESS_URL="http://vector-api.20.249.191.180.nip.io"
|
||||
echo "브라우저에서 접속: ${INGRESS_URL}"
|
||||
```
|
||||
|
||||
**주요 접속 페이지:**
|
||||
- **🏠 메인 페이지**: http://vector-api.20.249.191.180.nip.io
|
||||
- **📖 Swagger UI**: http://vector-api.20.249.191.180.nip.io/docs
|
||||
- **📄 ReDoc**: http://vector-api.20.249.191.180.nip.io/redoc
|
||||
- **❤️ 헬스체크**: http://vector-api.20.249.191.180.nip.io/health
|
||||
- **🗂️ Vector DB 상태**: http://vector-api.20.249.191.180.nip.io/vector-status
|
||||
- **⚙️ 환경 설정**: http://vector-api.20.249.191.180.nip.io/config
|
||||
|
||||
#### 접속 테스트
|
||||
|
||||
```bash
|
||||
# API 접속 테스트
|
||||
curl "http://vector-api.20.249.191.180.nip.io/health"
|
||||
|
||||
# Vector DB 상태 확인
|
||||
curl "http://vector-api.20.249.191.180.nip.io/vector-status"
|
||||
|
||||
# 설정 정보 확인
|
||||
curl "http://vector-api.20.249.191.180.nip.io/config"
|
||||
|
||||
# Swagger UI 접속 확인
|
||||
curl -I "http://vector-api.20.249.191.180.nip.io/docs"
|
||||
```
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### 필수 환경변수
|
||||
|
||||
| 변수명 | 설명 | 기본값 | 예시 |
|
||||
|--------|------|--------|------|
|
||||
| `KAKAO_API_KEY` | 카카오 API 키 | - | `your-kakao-api-key` |
|
||||
| `CLAUDE_API_KEY` | Claude API 키 | - | `sk-ant-api03-...` |
|
||||
| `CLAUDE_MODEL` | Claude 모델명 | `claude-sonnet-4-20250514` | `claude-sonnet-4-20250514` |
|
||||
|
||||
### 선택적 환경변수
|
||||
|
||||
| 변수명 | 설명 | 기본값 | 예시 |
|
||||
|--------|------|--------|------|
|
||||
| `HOST` | 서버 호스트 | `0.0.0.0` | `localhost` |
|
||||
| `PORT` | 서버 포트 | `8000` | `8000` |
|
||||
| `LOG_LEVEL` | 로그 레벨 | `info` | `debug` |
|
||||
| `VECTOR_DB_PATH` | Vector DB 경로 | `/app/vectordb` | `/data/vectordb` |
|
||||
| `VECTOR_DB_COLLECTION` | 컬렉션명 | `restaurant_reviews` | `stores` |
|
||||
| `EMBEDDING_MODEL` | 임베딩 모델 | `sentence-transformers/all-MiniLM-L6-v2` | - |
|
||||
| `MAX_RESTAURANTS_PER_CATEGORY` | 카테고리별 최대 음식점 수 | `50` | `100` |
|
||||
| `MAX_REVIEWS_PER_RESTAURANT` | 음식점별 최대 리뷰 수 | `100` | `200` |
|
||||
|
||||
## 📊 API 엔드포인트
|
||||
|
||||
### 주요 엔드포인트
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| `GET` | `/` | 메인 페이지 |
|
||||
| `GET` | `/docs` | Swagger UI 문서 |
|
||||
| `GET` | `/health` | 헬스체크 |
|
||||
| `GET` | `/vector-status` | Vector DB 상태 확인 |
|
||||
| `GET` | `/config` | 환경 설정 확인 |
|
||||
| `POST` | `/build-vector-db` | Vector DB 구축 |
|
||||
| `POST` | `/action-request` | AI 액션 요청 |
|
||||
| `POST` | `/search-similar` | 유사 음식점 검색 |
|
||||
| `GET` | `/collections` | 컬렉션 목록 조회 |
|
||||
|
||||
### Vector DB 구축 API 예시
|
||||
|
||||
```json
|
||||
POST /build-vector-db
|
||||
{
|
||||
"region": "서울특별시 강남구 역삼동",
|
||||
"store_name": "맛있는 치킨집",
|
||||
"force_rebuild": false
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Vector DB 구축이 완료되었습니다.",
|
||||
"statistics": {
|
||||
"total_processed": 150,
|
||||
"newly_added": 120,
|
||||
"updated": 25,
|
||||
"duplicates": 5,
|
||||
"total_vectors": 1450
|
||||
},
|
||||
"execution_time": 245.8,
|
||||
"store_info": {
|
||||
"name": "맛있는 치킨집",
|
||||
"region": "서울특별시 강남구 역삼동",
|
||||
"business_type": "치킨",
|
||||
"similar_stores_found": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AI 액션 요청 API 예시
|
||||
|
||||
```json
|
||||
POST /action-request
|
||||
{
|
||||
"store_id": "12345",
|
||||
"context": "매출이 감소하고 있어서 메뉴 개선이 필요합니다."
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"recommendations": [
|
||||
{
|
||||
"category": "메뉴 개선",
|
||||
"priority": "high",
|
||||
"action": "시즌 한정 메뉴 도입",
|
||||
"description": "고객 선호도가 높은 트렌디한 메뉴를 계절별로 출시하여 재방문율을 높입니다.",
|
||||
"timeframe": "1-2주",
|
||||
"expected_impact": "매출 15-20% 증가 예상"
|
||||
}
|
||||
],
|
||||
"similar_cases": 3,
|
||||
"analysis_date": "2024-06-12T10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 개발 및 확장
|
||||
|
||||
### 로컬 개발
|
||||
|
||||
```bash
|
||||
# 개발 모드로 실행 (자동 재시작)
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# 의존성 추가
|
||||
pip install 새패키지명
|
||||
pip freeze > app/requirements.txt
|
||||
|
||||
# Vector DB 디렉토리 확인
|
||||
ls -la ./vector_db/
|
||||
|
||||
# 코드 포맷팅
|
||||
black app/
|
||||
flake8 app/
|
||||
```
|
||||
|
||||
### Ingress 호스트 변경
|
||||
|
||||
현재 환경에 맞게 Ingress 호스트를 변경하려면:
|
||||
|
||||
```bash
|
||||
# 1. 현재 External IP 확인
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
|
||||
# 2. deployment/manifests/ingress.yaml 파일에서 host 수정
|
||||
# 예: vector-api.{YOUR_EXTERNAL_IP}.nip.io
|
||||
|
||||
# 3. 변경사항 적용
|
||||
kubectl apply -f deployment/manifests/ingress.yaml
|
||||
|
||||
# 4. 새로운 주소 확인
|
||||
kubectl get ingress vector-api-ingress
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
**1. Claude API 키 관련 문제**
|
||||
```bash
|
||||
# API 키 유효성 확인
|
||||
curl -X POST "https://api.anthropic.com/v1/messages" \
|
||||
-H "x-api-key: your-claude-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 10,
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}'
|
||||
|
||||
# 환경변수 확인
|
||||
echo $CLAUDE_API_KEY
|
||||
```
|
||||
|
||||
**2. Vector DB 관련 문제**
|
||||
```bash
|
||||
# Vector DB 디렉토리 권한 확인
|
||||
ls -la /app/vectordb/
|
||||
|
||||
# Vector DB 초기화
|
||||
curl -X POST "http://localhost:8000/reset-vector-db"
|
||||
|
||||
# Collection 상태 확인
|
||||
curl "http://localhost:8000/vector-status"
|
||||
```
|
||||
|
||||
**3. Kubernetes 배포 실패**
|
||||
```bash
|
||||
# Pod 로그 확인 (Vector DB 초기화 에러)
|
||||
kubectl logs -l app=vector-api
|
||||
|
||||
# PVC 상태 확인
|
||||
kubectl get pvc vector-db-pvc
|
||||
kubectl describe pvc vector-db-pvc
|
||||
|
||||
# ConfigMap 확인
|
||||
kubectl get configmap vector-api-config -o yaml
|
||||
|
||||
# Secret 확인
|
||||
kubectl get secret vector-api-secret -o yaml
|
||||
```
|
||||
|
||||
**4. 메모리 부족 문제**
|
||||
```bash
|
||||
# Vector DB 및 ML 모델이 메모리를 많이 사용
|
||||
kubectl top pods -l app=vector-api
|
||||
|
||||
# 리소스 제한 확인
|
||||
kubectl describe pod -l app=vector-api
|
||||
```
|
||||
|
||||
**5. 포트 관련 문제**
|
||||
- 로컬 개발: 8000번 포트
|
||||
- Docker 컨테이너: 8000번 포트
|
||||
- Kubernetes: Service 80번 → Ingress 외부 접근
|
||||
|
||||
## 🎯 성능 최적화
|
||||
|
||||
### Vector DB 최적화 설정
|
||||
|
||||
```yaml
|
||||
# deployment.yaml에서 Vector DB 최적화를 위한 리소스 설정
|
||||
resources:
|
||||
requests:
|
||||
memory: "2Gi" # Vector DB와 ML 모델이 메모리를 많이 사용
|
||||
cpu: "1000m" # 임베딩 계산을 위한 CPU
|
||||
limits:
|
||||
memory: "4Gi" # Vector 계산 및 Claude API 호출을 위한 충분한 메모리
|
||||
cpu: "2000m" # 병렬 처리를 위한 CPU
|
||||
```
|
||||
|
||||
### 영구 저장소 설정
|
||||
|
||||
```yaml
|
||||
# pvc.yaml에서 Vector DB 저장소 설정
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi # Vector 데이터 저장을 위한 충분한 공간
|
||||
```
|
||||
|
||||
### 타임아웃 설정
|
||||
|
||||
```yaml
|
||||
# ingress.yaml에서 Vector DB 구축 시간을 고려한 타임아웃
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800" # 30분
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800" # 30분
|
||||
```
|
||||
|
||||
## 📈 Vector DB 워크플로우
|
||||
|
||||
### 1. Vector DB 구축 과정
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[점주 요청] --> B[지역 및 업종 분석]
|
||||
B --> C[카카오 API 음식점 수집]
|
||||
C --> D[리뷰 데이터 수집]
|
||||
D --> E[텍스트 전처리]
|
||||
E --> F[Sentence Transformer 임베딩]
|
||||
F --> G[Chroma DB 저장]
|
||||
G --> H[중복 제거 및 메타데이터 구성]
|
||||
H --> I[Vector DB 완성]
|
||||
```
|
||||
|
||||
### 2. AI 액션 추천 과정
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[점주 액션 요청] --> B[컨텍스트 분석]
|
||||
B --> C[Vector 유사도 검색]
|
||||
C --> D[관련 케이스 추출]
|
||||
D --> E[Claude AI 프롬프트 구성]
|
||||
E --> F[Claude API 호출]
|
||||
F --> G[맞춤형 액션 플랜 생성]
|
||||
G --> H[우선순위 및 실행 방안 제시]
|
||||
```
|
||||
|
||||
## 📈 향후 확장 계획
|
||||
|
||||
- [ ] **다중 Vector DB 지원**: FAISS, Pinecone 추가 지원
|
||||
- [ ] **실시간 업데이트**: 새로운 리뷰 자동 임베딩 및 추가
|
||||
- [ ] **감정 분석 고도화**: 더 정교한 감정 및 의도 분류
|
||||
- [ ] **업종별 특화**: 업종별 맞춤형 Vector 모델 개발
|
||||
- [ ] **A/B 테스트**: 추천 효과 측정 및 개선
|
||||
- [ ] **대시보드**: Vector DB 현황 및 성능 모니터링
|
||||
- [ ] **배치 처리**: 대규모 데이터 처리 최적화
|
||||
- [ ] **API 버전 관리**: 하위 호환성 보장
|
||||
- [ ] **멀티모달 지원**: 이미지, 텍스트 통합 임베딩
|
||||
- [ ] **비즈니스 인텔리전스**: 시장 트렌드 분석 기능
|
||||
|
||||
## 🧠 AI/ML 구성 요소
|
||||
|
||||
### Sentence Transformers
|
||||
- **모델**: `all-MiniLM-L6-v2` (다국어 지원, 384차원)
|
||||
- **용도**: 한국어 텍스트 임베딩
|
||||
- **특징**: 빠른 속도, 합리적인 정확도
|
||||
|
||||
### Chroma Vector DB
|
||||
- **저장 방식**: 영구 저장 (PVC)
|
||||
- **인덱싱**: HNSW 알고리즘
|
||||
- **메타데이터**: 필터링 및 검색 최적화
|
||||
|
||||
### Claude AI
|
||||
- **모델**: `claude-sonnet-4-20250514`
|
||||
- **용도**: 비즈니스 액션 추천
|
||||
- **특징**: 높은 품질의 한국어 분석
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
- **이슈 리포트**: GitHub Issues
|
||||
- **기술 문의**: 개발팀 Slack
|
||||
- **API 문의**: Anthropic Support
|
||||
- **API 문서**: Swagger UI에서 상세 확인
|
||||
|
||||
---
|
||||
|
||||
**💡 팁: Vector DB 구축에는 시간이 오래 걸릴 수 있으니 충분한 타임아웃을 설정하고 진행 상황을 모니터링하세요.**
|
||||
|
||||
80
vector/app/config/settings.py
Normal file
80
vector/app/config/settings.py
Normal file
@ -0,0 +1,80 @@
|
||||
# app/config/settings.py
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
class Settings:
|
||||
"""환경 변수 기반 설정 클래스"""
|
||||
|
||||
# 애플리케이션 메타데이터
|
||||
APP_TITLE = os.getenv("APP_TITLE", "음식점 Vector DB 구축 서비스")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템")
|
||||
|
||||
# 서버 설정
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "8000"))
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
|
||||
|
||||
# Restaurant API 설정
|
||||
RESTAURANT_API_HOST = os.getenv("RESTAURANT_API_HOST", "4.217.217.207")
|
||||
RESTAURANT_API_PORT = os.getenv("RESTAURANT_API_PORT", "18000")
|
||||
|
||||
@property
|
||||
def RESTAURANT_API_URL(self) -> str:
|
||||
return f"http://{self.RESTAURANT_API_HOST}:{self.RESTAURANT_API_PORT}"
|
||||
|
||||
# Review API 설정
|
||||
REVIEW_API_HOST = os.getenv("REVIEW_API_HOST", "4.217.217.207")
|
||||
REVIEW_API_PORT = os.getenv("REVIEW_API_PORT", "19000")
|
||||
|
||||
@property
|
||||
def REVIEW_API_URL(self) -> str:
|
||||
return f"http://{self.REVIEW_API_HOST}:{self.REVIEW_API_PORT}"
|
||||
|
||||
# Claude API 설정
|
||||
CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "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")
|
||||
CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
|
||||
|
||||
# Vector DB 설정
|
||||
VECTOR_DB_PATH = os.getenv("VECTOR_DB_PATH", "/app/vectordb")
|
||||
VECTOR_DB_COLLECTION = os.getenv("VECTOR_DB_COLLECTION", "restaurant_reviews")
|
||||
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
|
||||
|
||||
# 데이터 수집 설정
|
||||
MAX_RESTAURANTS_PER_CATEGORY = int(os.getenv("MAX_RESTAURANTS_PER_CATEGORY", "50"))
|
||||
MAX_REVIEWS_PER_RESTAURANT = int(os.getenv("MAX_REVIEWS_PER_RESTAURANT", "100"))
|
||||
REQUEST_DELAY = float(os.getenv("REQUEST_DELAY", "0.1"))
|
||||
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "600"))
|
||||
|
||||
# 환경 감지
|
||||
@property
|
||||
def IS_K8S_ENV(self) -> bool:
|
||||
"""Kubernetes 환경인지 확인"""
|
||||
return (
|
||||
os.getenv("KUBERNETES_SERVICE_HOST") is not None or
|
||||
self.RESTAURANT_API_HOST in ["restaurant-api-service", "kakao-review-api-service"] or
|
||||
self.REVIEW_API_HOST in ["restaurant-api-service", "kakao-review-api-service"]
|
||||
)
|
||||
|
||||
def get_restaurant_api_url(self) -> str:
|
||||
"""환경에 따른 Restaurant API URL 반환"""
|
||||
if self.IS_K8S_ENV:
|
||||
host = "restaurant-api-service"
|
||||
port = "80"
|
||||
else:
|
||||
host = self.RESTAURANT_API_HOST
|
||||
port = self.RESTAURANT_API_PORT
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
def get_review_api_url(self) -> str:
|
||||
"""환경에 따른 Review API URL 반환"""
|
||||
if self.IS_K8S_ENV:
|
||||
host = "kakao-review-api-service"
|
||||
port = "80"
|
||||
else:
|
||||
host = self.REVIEW_API_HOST
|
||||
port = self.REVIEW_API_PORT
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
# 설정 인스턴스
|
||||
settings = Settings()
|
||||
917
vector/app/main.py
Normal file
917
vector/app/main.py
Normal file
@ -0,0 +1,917 @@
|
||||
# vector/app/main.py
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# 현재 디렉토리를 Python 경로에 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# =============================================================================
|
||||
# .env 파일 로딩 (다른 import보다 먼저)
|
||||
# =============================================================================
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 파일에서 환경변수 로드
|
||||
load_dotenv()
|
||||
|
||||
import asyncio
|
||||
import fastapi
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 프로젝트 모듈 import
|
||||
from app.config.settings import settings
|
||||
from app.models.restaurant_models import RestaurantSearchRequest, ErrorResponse
|
||||
from app.models.vector_models import (
|
||||
VectorBuildRequest, VectorBuildResponse,
|
||||
ActionRecommendationRequest, ActionRecommendationResponse,
|
||||
VectorDBStatusResponse, VectorDBStatus
|
||||
)
|
||||
from app.services.restaurant_service import RestaurantService
|
||||
from app.services.review_service import ReviewService
|
||||
from app.services.vector_service import VectorService
|
||||
from app.services.claude_service import ClaudeService
|
||||
from app.utils.category_utils import extract_food_category
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 🔧 전역 변수 대신 애플리케이션 상태로 관리
|
||||
app_state = {
|
||||
"vector_service": None,
|
||||
"restaurant_service": None,
|
||||
"review_service": None,
|
||||
"claude_service": None,
|
||||
"initialization_errors": {},
|
||||
"startup_completed": False
|
||||
}
|
||||
|
||||
# 추가 모델 정의 (find-reviews API용)
|
||||
class FindReviewsRequest(BaseModel):
|
||||
"""리뷰 검색 요청 모델"""
|
||||
region: str = Field(
|
||||
...,
|
||||
description="지역 (시군구 + 읍면동)",
|
||||
example="서울특별시 강남구 역삼동"
|
||||
)
|
||||
store_name: str = Field(
|
||||
...,
|
||||
description="가게명",
|
||||
example="맛있는 한식당"
|
||||
)
|
||||
|
||||
class RestaurantInfo(BaseModel):
|
||||
"""음식점 정보 모델"""
|
||||
id: str = Field(description="카카오 장소 ID")
|
||||
place_name: str = Field(description="장소명")
|
||||
category_name: str = Field(description="카테고리명")
|
||||
address_name: str = Field(description="전체 주소")
|
||||
phone: str = Field(description="전화번호")
|
||||
place_url: str = Field(description="장소 상세페이지 URL")
|
||||
x: str = Field(description="X 좌표값 (경도)")
|
||||
y: str = Field(description="Y 좌표값 (위도)")
|
||||
|
||||
class FindReviewsResponse(BaseModel):
|
||||
"""리뷰 검색 응답 모델"""
|
||||
success: bool = Field(description="검색 성공 여부")
|
||||
message: str = Field(description="응답 메시지")
|
||||
target_store: RestaurantInfo = Field(description="대상 가게 정보")
|
||||
total_stores: int = Field(description="수집된 총 가게 수")
|
||||
total_reviews: int = Field(description="수집된 총 리뷰 수")
|
||||
food_category: str = Field(description="추출된 음식 카테고리")
|
||||
region: str = Field(description="검색 지역")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""🔧 애플리케이션 생명주기 관리 - 안전한 서비스 초기화"""
|
||||
# 🚀 Startup 이벤트
|
||||
logger.info("🚀 Vector API 서비스 시작 중...")
|
||||
startup_start_time = datetime.now()
|
||||
|
||||
# 각 서비스 안전하게 초기화
|
||||
services_to_init = [
|
||||
("restaurant_service", RestaurantService, "Restaurant API 서비스"),
|
||||
("review_service", ReviewService, "Review API 서비스"),
|
||||
("claude_service", ClaudeService, "Claude AI 서비스"),
|
||||
("vector_service", VectorService, "Vector DB 서비스") # 마지막에 초기화
|
||||
]
|
||||
|
||||
initialized_count = 0
|
||||
|
||||
for service_key, service_class, service_name in services_to_init:
|
||||
try:
|
||||
logger.info(f"🔧 {service_name} 초기화 중...")
|
||||
app_state[service_key] = service_class()
|
||||
logger.info(f"✅ {service_name} 초기화 완료")
|
||||
initialized_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ {service_name} 초기화 실패: {e}")
|
||||
app_state["initialization_errors"][service_key] = str(e)
|
||||
# 🔧 중요: 서비스 초기화 실패해도 앱은 시작 (헬스체크에서 확인)
|
||||
continue
|
||||
|
||||
startup_time = (datetime.now() - startup_start_time).total_seconds()
|
||||
app_state["startup_completed"] = True
|
||||
|
||||
logger.info(f"✅ Vector API 서비스 시작 완료!")
|
||||
logger.info(f"📊 초기화 결과: {initialized_count}/{len(services_to_init)}개 서비스 성공")
|
||||
logger.info(f"⏱️ 시작 소요시간: {startup_time:.2f}초")
|
||||
|
||||
if app_state["initialization_errors"]:
|
||||
logger.warning(f"⚠️ 초기화 실패 서비스: {list(app_state['initialization_errors'].keys())}")
|
||||
|
||||
yield
|
||||
|
||||
# 🛑 Shutdown 이벤트
|
||||
logger.info("🛑 Vector API 서비스 종료 중...")
|
||||
|
||||
# 리소스 정리
|
||||
for service_key in ["vector_service", "restaurant_service", "review_service", "claude_service"]:
|
||||
if app_state[service_key] is not None:
|
||||
try:
|
||||
# 서비스별 정리 작업이 있다면 여기서 수행
|
||||
logger.info(f"🔧 {service_key} 정리 중...")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ {service_key} 정리 실패: {e}")
|
||||
finally:
|
||||
app_state[service_key] = None
|
||||
|
||||
app_state["startup_completed"] = False
|
||||
logger.info("✅ Vector API 서비스 종료 완료")
|
||||
|
||||
# 🔧 FastAPI 앱 초기화 (lifespan 이벤트 포함)
|
||||
app = FastAPI(
|
||||
title=settings.APP_TITLE,
|
||||
description=f"""
|
||||
{settings.APP_DESCRIPTION}
|
||||
|
||||
**주요 기능:**
|
||||
- 지역과 가게명으로 대상 가게 찾기
|
||||
- 동종 업체 리뷰 수집 및 분석
|
||||
- Vector DB 구축 및 관리
|
||||
- Claude AI 기반 액션 추천
|
||||
- 영속적 Vector DB 저장
|
||||
|
||||
**API 연동:**
|
||||
- Restaurant API: {settings.get_restaurant_api_url() if hasattr(settings, 'get_restaurant_api_url') else 'N/A'}
|
||||
- Review API: {settings.get_review_api_url() if hasattr(settings, 'get_review_api_url') else 'N/A'}
|
||||
- Claude AI API: {settings.CLAUDE_MODEL}
|
||||
|
||||
**Vector DB:**
|
||||
- 경로: {settings.VECTOR_DB_PATH}
|
||||
- 컬렉션: {settings.VECTOR_DB_COLLECTION}
|
||||
- 임베딩 모델: {settings.EMBEDDING_MODEL}
|
||||
|
||||
**버전:** {settings.APP_VERSION}
|
||||
""",
|
||||
version=settings.APP_VERSION,
|
||||
contact={
|
||||
"name": "개발팀",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
lifespan=lifespan # 🔧 lifespan 이벤트 등록
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 🔧 Dependency Injection - 서비스 제공자들
|
||||
def get_vector_service() -> VectorService:
|
||||
"""VectorService 의존성 주입"""
|
||||
if app_state["vector_service"] is None:
|
||||
error_msg = app_state["initialization_errors"].get("vector_service")
|
||||
if error_msg:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Vector service not available: {error_msg}"
|
||||
)
|
||||
|
||||
# 런타임에 재시도
|
||||
try:
|
||||
logger.info("🔧 VectorService 런타임 초기화 시도...")
|
||||
app_state["vector_service"] = VectorService()
|
||||
logger.info("✅ VectorService 런타임 초기화 성공")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ VectorService 런타임 초기화 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Vector service initialization failed: {str(e)}"
|
||||
)
|
||||
|
||||
return app_state["vector_service"]
|
||||
|
||||
def get_restaurant_service() -> RestaurantService:
|
||||
"""RestaurantService 의존성 주입"""
|
||||
if app_state["restaurant_service"] is None:
|
||||
error_msg = app_state["initialization_errors"].get("restaurant_service")
|
||||
if error_msg:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Restaurant service not available: {error_msg}"
|
||||
)
|
||||
|
||||
try:
|
||||
app_state["restaurant_service"] = RestaurantService()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
return app_state["restaurant_service"]
|
||||
|
||||
def get_review_service() -> ReviewService:
|
||||
"""ReviewService 의존성 주입"""
|
||||
if app_state["review_service"] is None:
|
||||
error_msg = app_state["initialization_errors"].get("review_service")
|
||||
if error_msg:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Review service not available: {error_msg}"
|
||||
)
|
||||
|
||||
try:
|
||||
app_state["review_service"] = ReviewService()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
return app_state["review_service"]
|
||||
|
||||
def get_claude_service() -> ClaudeService:
|
||||
"""ClaudeService 의존성 주입"""
|
||||
if app_state["claude_service"] is None:
|
||||
error_msg = app_state["initialization_errors"].get("claude_service")
|
||||
if error_msg:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Claude service not available: {error_msg}"
|
||||
)
|
||||
|
||||
try:
|
||||
app_state["claude_service"] = ClaudeService()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
return app_state["claude_service"]
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def root():
|
||||
"""메인 페이지"""
|
||||
# 🔧 안전한 DB 상태 조회
|
||||
try:
|
||||
vector_service = app_state.get("vector_service")
|
||||
if vector_service:
|
||||
db_status = vector_service.get_db_status()
|
||||
else:
|
||||
db_status = {
|
||||
'collection_name': settings.VECTOR_DB_COLLECTION,
|
||||
'total_documents': 0,
|
||||
'total_stores': 0,
|
||||
'db_path': settings.VECTOR_DB_PATH,
|
||||
'status': 'not_initialized'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"DB 상태 조회 실패: {e}")
|
||||
db_status = {'status': 'error', 'error': str(e)}
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>{settings.APP_TITLE}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }}
|
||||
h1, h2 {{ color: #2c3e50; }}
|
||||
.status {{ background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }}
|
||||
.error {{ background: #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }}
|
||||
.info {{ background: #74b9ff; color: white; padding: 15px; border-radius: 5px; margin: 20px 0; }}
|
||||
.link {{ display: inline-block; margin: 10px 15px 10px 0; padding: 10px 20px; background: #0984e3; color: white; text-decoration: none; border-radius: 3px; }}
|
||||
.link:hover {{ background: #74b9ff; }}
|
||||
pre {{ background: #f1f2f6; padding: 15px; border-radius: 5px; overflow-x: auto; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🍽️ {settings.APP_TITLE}</h1>
|
||||
<p>{settings.APP_DESCRIPTION}</p>
|
||||
|
||||
<div class="status">
|
||||
<h2>📊 Vector DB 상태</h2>
|
||||
<ul>
|
||||
<li><strong>컬렉션:</strong> {db_status.get('collection_name', 'N/A')}</li>
|
||||
<li><strong>총 문서 수:</strong> {db_status.get('total_documents', 0)}</li>
|
||||
<li><strong>가게 수:</strong> {db_status.get('total_stores', 0)}</li>
|
||||
<li><strong>DB 경로:</strong> {db_status.get('db_path', 'N/A')}</li>
|
||||
<li><strong>상태:</strong> {db_status.get('status', 'Unknown')}</li>
|
||||
</ul>
|
||||
{f'''
|
||||
<div class="error">
|
||||
<h3>⚠️ 초기화 실패 서비스</h3>
|
||||
<ul>
|
||||
{"".join([f"<li><strong>{k}:</strong> {v}</li>" for k, v in app_state["initialization_errors"].items()])}
|
||||
</ul>
|
||||
</div>
|
||||
''' if app_state["initialization_errors"] else ''}
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h2>🔧 시스템 구성</h2>
|
||||
<ul>
|
||||
<li><strong>Claude Model:</strong> {settings.CLAUDE_MODEL}</li>
|
||||
<li><strong>Embedding Model:</strong> {settings.EMBEDDING_MODEL}</li>
|
||||
<li><strong>Vector DB Path:</strong> {settings.VECTOR_DB_PATH}</li>
|
||||
<li><strong>환경:</strong> {'Kubernetes' if hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV else 'Local'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>📚 API 문서</h2>
|
||||
<a href="/docs" class="link">Swagger UI 문서</a>
|
||||
<a href="/redoc" class="link">ReDoc 문서</a>
|
||||
<a href="/health" class="link">헬스 체크</a>
|
||||
<a href="/vector-status" class="link">Vector DB 상태</a>
|
||||
|
||||
<h2>🛠️ 사용 방법</h2>
|
||||
<p><strong>POST /find-reviews</strong> - 리뷰 검색 및 Vector DB 저장 (본인 가게 우선)</p>
|
||||
<pre>
|
||||
{{
|
||||
"region": "서울특별시 강남구 역삼동",
|
||||
"store_name": "맛있는 한식당"
|
||||
}}
|
||||
</pre>
|
||||
|
||||
<p><strong>POST /build-vector</strong> - Vector DB 구축</p>
|
||||
<pre>
|
||||
{{
|
||||
"region": "서울특별시 강남구 역삼동",
|
||||
"store_name": "맛있는 한식당",
|
||||
"force_rebuild": false
|
||||
}}
|
||||
</pre>
|
||||
|
||||
<p><strong>POST /action-recommendation</strong> - 액션 추천 요청</p>
|
||||
<pre>
|
||||
{{
|
||||
"store_id": "12345",
|
||||
"context": "매출이 감소하고 있어서 개선이 필요합니다"
|
||||
}}
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.post("/find-reviews", response_model=FindReviewsResponse)
|
||||
async def find_reviews(
|
||||
request: FindReviewsRequest,
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
restaurant_service: RestaurantService = Depends(get_restaurant_service),
|
||||
review_service: ReviewService = Depends(get_review_service)
|
||||
):
|
||||
"""
|
||||
지역과 가게명으로 리뷰를 찾아 Vector DB에 저장합니다.
|
||||
🔥 본인 가게 리뷰는 반드시 포함됩니다. (수정된 버전)
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
logger.info(f"🔍 리뷰 검색 요청: {request.region} - {request.store_name}")
|
||||
|
||||
try:
|
||||
# 1단계: 본인 가게 검색
|
||||
logger.info("1단계: 본인 가게 검색 중... (최우선)")
|
||||
target_restaurant = await restaurant_service.find_store_by_name_and_region(
|
||||
request.region, request.store_name
|
||||
)
|
||||
|
||||
if not target_restaurant:
|
||||
logger.error(f"❌ 본인 가게를 찾을 수 없음: {request.store_name}")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"'{request.store_name}' 가게를 찾을 수 없습니다. 가게명과 지역을 정확히 입력해주세요."
|
||||
)
|
||||
|
||||
logger.info(f"✅ 본인 가게 발견: {target_restaurant.place_name} (ID: {target_restaurant.id})")
|
||||
|
||||
# 2단계: 동종 업체 검색
|
||||
logger.info("2단계: 동종 업체 검색 중...")
|
||||
similar_restaurants = []
|
||||
food_category = "기타" # 기본값
|
||||
|
||||
try:
|
||||
food_category = extract_food_category(target_restaurant.category_name)
|
||||
logger.info(f"추출된 음식 카테고리: {food_category}")
|
||||
|
||||
similar_restaurants = await restaurant_service.find_similar_stores(
|
||||
request.region, food_category, settings.MAX_RESTAURANTS_PER_CATEGORY
|
||||
)
|
||||
|
||||
logger.info(f"✅ 동종 업체 {len(similar_restaurants)}개 발견")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 동종 업체 검색 실패 (본인 가게는 계속 진행): {e}")
|
||||
|
||||
# 3단계: 전체 가게 목록 구성 (본인 가게 우선 + 중복 제거)
|
||||
logger.info("3단계: 전체 가게 목록 구성 중...")
|
||||
|
||||
# 본인 가게를 첫 번째로 배치
|
||||
all_restaurants = [target_restaurant]
|
||||
|
||||
# 동종 업체 추가 (개선된 중복 제거)
|
||||
for restaurant in similar_restaurants:
|
||||
if not _is_duplicate_restaurant(target_restaurant, restaurant):
|
||||
all_restaurants.append(restaurant)
|
||||
|
||||
logger.info(f"✅ 전체 가게 목록 구성 완료: {len(all_restaurants)}개 (본인 가게 포함)")
|
||||
|
||||
# 4단계: 전체 리뷰 수집 (본인 가게 우선 처리)
|
||||
logger.info("4단계: 리뷰 수집 중... (본인 가게 우선)")
|
||||
|
||||
# 본인 가게 우선 처리를 위한 특별 로직
|
||||
review_results = []
|
||||
|
||||
# 4-1: 본인 가게 리뷰 수집 (실패 시 전체 중단)
|
||||
try:
|
||||
logger.info("본인 가게 리뷰 우선 수집 중... (필수)")
|
||||
target_store_info, target_reviews = await review_service.collect_store_reviews(
|
||||
target_restaurant.id,
|
||||
max_reviews=settings.MAX_REVIEWS_PER_RESTAURANT * 2 # 본인 가게는 더 많이
|
||||
)
|
||||
|
||||
if not target_store_info or not target_reviews:
|
||||
logger.error(f"❌ 본인 가게 리뷰 수집 실패: {target_restaurant.place_name}")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"본인 가게 '{target_restaurant.place_name}'의 리뷰를 수집할 수 없습니다."
|
||||
)
|
||||
|
||||
# 본인 가게 결과를 첫 번째로 설정
|
||||
review_results.append((target_restaurant.id, target_store_info, target_reviews))
|
||||
logger.info(f"✅ 본인 가게 리뷰 수집 성공: {len(target_reviews)}개")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 본인 가게 리뷰 수집 중 오류: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"본인 가게 리뷰 수집 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
# 4-2: 동종 업체 리뷰 수집 (실패해도 본인 가게는 유지)
|
||||
if len(all_restaurants) > 1: # 본인 가게 외에 다른 가게가 있는 경우
|
||||
try:
|
||||
logger.info(f"동종 업체 리뷰 수집 중... ({len(all_restaurants) - 1}개)")
|
||||
|
||||
# 본인 가게 제외한 동종 업체만 수집
|
||||
similar_restaurants_only = all_restaurants[1:]
|
||||
similar_results = await review_service.collect_multiple_stores_reviews(similar_restaurants_only)
|
||||
|
||||
# 동종 업체 결과 추가
|
||||
review_results.extend(similar_results)
|
||||
|
||||
similar_reviews_count = sum(len(reviews) for _, _, reviews in similar_results)
|
||||
logger.info(f"✅ 동종 업체 리뷰 수집 완료: {len(similar_results)}개 가게, {similar_reviews_count}개 리뷰")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 동종 업체 리뷰 수집 실패 (본인 가게는 유지): {e}")
|
||||
else:
|
||||
logger.info("동종 업체가 없어 본인 가게 리뷰만 사용")
|
||||
|
||||
# 5단계: Vector DB 구축
|
||||
logger.info("5단계: Vector DB 구축 중...")
|
||||
try:
|
||||
# 대상 가게 정보를 딕셔너리로 변환
|
||||
target_store_info_dict = {
|
||||
'id': target_restaurant.id,
|
||||
'place_name': target_restaurant.place_name,
|
||||
'category_name': target_restaurant.category_name,
|
||||
'address_name': target_restaurant.address_name,
|
||||
'phone': target_restaurant.phone,
|
||||
'place_url': target_restaurant.place_url,
|
||||
'x': target_restaurant.x,
|
||||
'y': target_restaurant.y
|
||||
}
|
||||
|
||||
# Vector DB에 저장
|
||||
vector_result = await vector_service.build_vector_store(
|
||||
target_store_info_dict, review_results, food_category, request.region
|
||||
)
|
||||
|
||||
if not vector_result.get('success', False):
|
||||
raise Exception(f"Vector DB 저장 실패: {vector_result.get('error', 'Unknown error')}")
|
||||
|
||||
logger.info("✅ Vector DB 구축 완료")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Vector DB 구축 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Vector DB 구축 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
# 최종 검증: 본인 가게가 첫 번째에 있는지 확인
|
||||
if not review_results or review_results[0][0] != target_restaurant.id:
|
||||
logger.error("❌ 본인 가게가 첫 번째에 없음")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="본인 가게 리뷰 처리 순서 오류가 발생했습니다."
|
||||
)
|
||||
|
||||
# 성공 응답
|
||||
total_reviews = sum(len(reviews) for _, _, reviews in review_results)
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return FindReviewsResponse(
|
||||
success=True,
|
||||
message=f"✅ 본인 가게 리뷰 포함 보장 완료! (총 {len(review_results)}개 가게, {total_reviews}개 리뷰)",
|
||||
target_store=RestaurantInfo(
|
||||
id=target_restaurant.id,
|
||||
place_name=target_restaurant.place_name,
|
||||
category_name=target_restaurant.category_name,
|
||||
address_name=target_restaurant.address_name,
|
||||
phone=target_restaurant.phone,
|
||||
place_url=target_restaurant.place_url,
|
||||
x=target_restaurant.x,
|
||||
y=target_restaurant.y
|
||||
),
|
||||
total_stores=len(review_results),
|
||||
total_reviews=total_reviews,
|
||||
food_category=food_category,
|
||||
region=request.region
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
logger.error(f"❌ 전체 프로세스 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"서비스 처리 중 예상치 못한 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
def _is_duplicate_restaurant(restaurant1: RestaurantInfo, restaurant2: RestaurantInfo) -> bool:
|
||||
"""
|
||||
두 음식점이 중복인지 확인 (개선된 로직)
|
||||
|
||||
Args:
|
||||
restaurant1: 첫 번째 음식점
|
||||
restaurant2: 두 번째 음식점
|
||||
|
||||
Returns:
|
||||
중복 여부
|
||||
"""
|
||||
# 1. ID 기준 확인
|
||||
if restaurant1.id == restaurant2.id:
|
||||
return True
|
||||
|
||||
# 2. place_url에서 추출한 store_id 기준 확인
|
||||
store_id1 = _extract_store_id_from_place_url(restaurant1.place_url)
|
||||
store_id2 = _extract_store_id_from_place_url(restaurant2.place_url)
|
||||
|
||||
if store_id1 and store_id2 and store_id1 == store_id2:
|
||||
return True
|
||||
|
||||
# 3. restaurant.id와 place_url store_id 교차 확인
|
||||
if restaurant1.id == store_id2 or restaurant2.id == store_id1:
|
||||
return True
|
||||
|
||||
# 4. 이름 + 주소 기준 확인 (최후 방법)
|
||||
if (restaurant1.place_name == restaurant2.place_name and
|
||||
restaurant1.address_name == restaurant2.address_name):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_store_id_from_place_url(place_url: str) -> Optional[str]:
|
||||
"""
|
||||
카카오맵 URL에서 store_id를 추출합니다.
|
||||
|
||||
Args:
|
||||
place_url: 카카오맵 장소 URL
|
||||
|
||||
Returns:
|
||||
추출된 store_id 또는 None
|
||||
"""
|
||||
try:
|
||||
if not place_url:
|
||||
return None
|
||||
|
||||
import re
|
||||
# URL 패턴: https://place.map.kakao.com/123456789
|
||||
pattern = r'/(\d+)(?:\?|$|#)'
|
||||
match = re.search(pattern, place_url)
|
||||
|
||||
if match:
|
||||
return match.group(1)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@app.post(
|
||||
"/action-recommendation",
|
||||
response_model=ActionRecommendationResponse,
|
||||
summary="액션 추천 요청",
|
||||
description="점주가 Claude AI에게 액션 추천을 요청합니다."
|
||||
)
|
||||
async def action_recommendation(
|
||||
request: ActionRecommendationRequest,
|
||||
claude_service: ClaudeService = Depends(get_claude_service),
|
||||
vector_service: VectorService = Depends(get_vector_service)
|
||||
):
|
||||
"""🧠 Claude AI 액션 추천 API"""
|
||||
try:
|
||||
logger.info(f"액션 추천 요청: store_id={request.store_id}")
|
||||
|
||||
start_time = datetime.now()
|
||||
|
||||
# 1단계: Vector DB에서 컨텍스트 조회
|
||||
try:
|
||||
db_status = vector_service.get_db_status()
|
||||
|
||||
if db_status.get('total_documents', 0) == 0:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": "NO_VECTOR_DATA",
|
||||
"message": "Vector DB에 데이터가 없습니다. 먼저 /build-vector API를 호출하여 데이터를 구축해주세요.",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
# Vector DB에서 유사한 케이스 검색
|
||||
context_data = vector_service.search_similar_cases(request.store_id, request.context)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vector DB 조회 실패: {e}")
|
||||
# Vector DB 조회 실패해도 일반적인 추천은 제공
|
||||
context_data = None
|
||||
|
||||
# 2단계: Claude AI 호출 (프롬프트 구성부터 파싱까지 모두 포함)
|
||||
try:
|
||||
# 컨텍스트 구성
|
||||
full_context = f"가게 ID: {request.store_id}\n점주 요청: {request.context}"
|
||||
additional_context = context_data if context_data else None
|
||||
|
||||
# Claude AI 액션 추천 생성 (완전한 처리)
|
||||
claude_response, parsed_response = await claude_service.generate_action_recommendations(
|
||||
context=full_context,
|
||||
additional_context=additional_context
|
||||
)
|
||||
|
||||
if not claude_response:
|
||||
raise Exception("Claude AI로부터 응답을 받지 못했습니다")
|
||||
|
||||
logger.info(f"Claude 응답 길이: {len(claude_response)} 문자")
|
||||
json_parse_success = parsed_response is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude AI 호출 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI 추천 생성 중 오류: {str(e)}"
|
||||
)
|
||||
|
||||
# 3단계: 응답 구성
|
||||
claude_execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 가게 정보 추출 (Vector DB에서)
|
||||
store_name = request.store_id # 기본값
|
||||
food_category = "기타" # 기본값
|
||||
|
||||
try:
|
||||
store_context = vector_service.get_store_context(request.store_id)
|
||||
if store_context:
|
||||
store_name = store_context.get('store_name', request.store_id)
|
||||
food_category = store_context.get('food_category', '기타')
|
||||
except Exception as e:
|
||||
logger.warning(f"가게 정보 추출 실패: {e}")
|
||||
|
||||
response = ActionRecommendationResponse(
|
||||
success=True,
|
||||
message=f"액션 추천이 완료되었습니다. (실행시간: {claude_execution_time:.1f}초, JSON 파싱: {'성공' if json_parse_success else '실패'})",
|
||||
claude_input=full_context + (f"\n--- 동종 업체 분석 데이터 ---\n{additional_context}" if additional_context else ""),
|
||||
claude_response=claude_response,
|
||||
parsed_response=parsed_response,
|
||||
store_name=store_name,
|
||||
food_category=food_category,
|
||||
similar_stores_count=len(context_data.split("---")) if context_data else 0,
|
||||
execution_time=claude_execution_time,
|
||||
json_parse_success=json_parse_success
|
||||
)
|
||||
|
||||
logger.info(f"✅ 액션 추천 완료: Claude 응답 {len(claude_response) if claude_response else 0} 문자, JSON 파싱 {'성공' if json_parse_success else '실패'}, {claude_execution_time:.1f}초 소요")
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 액션 추천 요청 실패: {str(e)}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": "RECOMMENDATION_FAILED",
|
||||
"message": f"액션 추천 중 오류가 발생했습니다: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/vector-status",
|
||||
response_model=VectorDBStatusResponse,
|
||||
summary="Vector DB 상태 조회",
|
||||
description="Vector DB의 현재 상태를 조회합니다."
|
||||
)
|
||||
async def get_vector_db_status(vector_service: VectorService = Depends(get_vector_service)):
|
||||
"""Vector DB 상태 조회 API"""
|
||||
try:
|
||||
status_info = vector_service.get_db_status()
|
||||
|
||||
status = VectorDBStatus(
|
||||
collection_name=status_info['collection_name'],
|
||||
total_documents=status_info['total_documents'],
|
||||
total_stores=status_info['total_stores'],
|
||||
db_path=status_info['db_path'],
|
||||
last_updated=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
return VectorDBStatusResponse(
|
||||
success=True,
|
||||
status=status,
|
||||
message="Vector DB 상태 조회가 완료되었습니다."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Vector DB 상태 조회 실패: {e}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": "STATUS_CHECK_FAILED",
|
||||
"message": f"Vector DB 상태 조회 중 오류가 발생했습니다: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/health", summary="헬스 체크", description="API 서버 및 외부 서비스 상태를 확인합니다.")
|
||||
async def health_check():
|
||||
"""🏥 헬스체크 API"""
|
||||
health_result = {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"services": {},
|
||||
"app_info": {
|
||||
"name": settings.APP_TITLE,
|
||||
"version": settings.APP_VERSION,
|
||||
"startup_completed": app_state["startup_completed"]
|
||||
}
|
||||
}
|
||||
|
||||
# 서비스별 헬스체크
|
||||
services_to_check = [
|
||||
("restaurant_service", "restaurant_api"),
|
||||
("review_service", "review_api"),
|
||||
("claude_service", "claude_ai"),
|
||||
("vector_service", "vector_db")
|
||||
]
|
||||
|
||||
healthy_count = 0
|
||||
total_checks = len(services_to_check)
|
||||
|
||||
for service_key, health_key in services_to_check:
|
||||
try:
|
||||
service = app_state.get(service_key)
|
||||
if service is None:
|
||||
health_result["services"][health_key] = "not_initialized"
|
||||
continue
|
||||
|
||||
# 서비스별 헬스체크 메서드 호출
|
||||
if hasattr(service, 'health_check'):
|
||||
status = await service.health_check()
|
||||
else:
|
||||
status = True # 헬스체크 메서드가 없으면 초기화됐다고 가정
|
||||
|
||||
# Vector DB의 경우 상세 정보 추가
|
||||
if health_key == "vector_db" and status:
|
||||
try:
|
||||
db_status = service.get_db_status()
|
||||
health_result["vector_db_info"] = {
|
||||
"total_documents": db_status.get('total_documents', 0),
|
||||
"total_stores": db_status.get('total_stores', 0),
|
||||
"db_path": db_status.get('db_path', '')
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
health_result["services"][health_key] = "healthy" if status else "unhealthy"
|
||||
if status:
|
||||
healthy_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"헬스체크 실패 - {service_key}: {e}")
|
||||
health_result["services"][health_key] = f"error: {str(e)}"
|
||||
|
||||
# 전체 상태 결정
|
||||
if healthy_count == total_checks:
|
||||
health_result["status"] = "healthy"
|
||||
elif healthy_count > 0:
|
||||
health_result["status"] = "degraded"
|
||||
else:
|
||||
health_result["status"] = "unhealthy"
|
||||
|
||||
# 요약 정보
|
||||
health_result["summary"] = {
|
||||
"healthy_services": healthy_count,
|
||||
"total_services": total_checks,
|
||||
"health_percentage": round((healthy_count / total_checks) * 100, 1)
|
||||
}
|
||||
|
||||
# 초기화 에러가 있으면 포함
|
||||
if app_state["initialization_errors"]:
|
||||
health_result["initialization_errors"] = app_state["initialization_errors"]
|
||||
|
||||
# 환경 정보
|
||||
health_result["environment"] = {
|
||||
"python_version": sys.version.split()[0],
|
||||
"fastapi_version": fastapi.__version__,
|
||||
"is_k8s": hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV,
|
||||
"claude_model": settings.CLAUDE_MODEL
|
||||
}
|
||||
|
||||
# HTTP 상태 코드 결정
|
||||
if health_result["status"] == "healthy":
|
||||
return health_result
|
||||
elif health_result["status"] == "degraded":
|
||||
return JSONResponse(status_code=200, content=health_result) # 부분 장애는 200
|
||||
else:
|
||||
return JSONResponse(status_code=503, content=health_result) # 전체 장애는 503
|
||||
|
||||
# 🔧 전역 예외 처리
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""전역 예외 처리"""
|
||||
logger.error(f"Unhandled exception: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "Internal server error",
|
||||
"detail": str(exc) if settings.LOG_LEVEL.lower() == "debug" else "An unexpected error occurred",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"path": str(request.url)
|
||||
}
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print("🍽️ " + "="*60)
|
||||
print(f" {settings.APP_TITLE} 서버 시작")
|
||||
print("="*64)
|
||||
print(f"📊 구성 정보:")
|
||||
print(f" - Python 버전: {sys.version.split()[0]}")
|
||||
print(f" - FastAPI 버전: {fastapi.__version__}")
|
||||
print(f" - Vector DB Path: {settings.VECTOR_DB_PATH}")
|
||||
print(f" - Claude Model: {settings.CLAUDE_MODEL}")
|
||||
print(f" - 환경: {'Kubernetes' if hasattr(settings, 'IS_K8S_ENV') and settings.IS_K8S_ENV else 'Local'}")
|
||||
print()
|
||||
print(f"📚 문서:")
|
||||
print(f" - Swagger UI: http://{settings.HOST}:{settings.PORT}/docs")
|
||||
print(f" - ReDoc: http://{settings.HOST}:{settings.PORT}/redoc")
|
||||
print(f" - 메인 페이지: http://{settings.HOST}:{settings.PORT}/")
|
||||
print()
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
"app.main:app", # 🔧 문자열로 지정 (리로드 지원)
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
reload=False, # 프로덕션에서는 False
|
||||
access_log=True,
|
||||
loop="uvloop" if sys.platform != "win32" else "asyncio"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 서버가 사용자에 의해 중단되었습니다.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 서버 시작 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
67
vector/app/requirements.txt
Normal file
67
vector/app/requirements.txt
Normal file
@ -0,0 +1,67 @@
|
||||
# app/requirements.txt - 안정화된 Vector DB 서비스용
|
||||
|
||||
# ==========================================
|
||||
# 기본 웹 프레임워크 (안정 버전)
|
||||
# ==========================================
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# ==========================================
|
||||
# HTTP 클라이언트
|
||||
# ==========================================
|
||||
aiohttp==3.9.1
|
||||
requests==2.31.0
|
||||
|
||||
# ==========================================
|
||||
# 데이터 처리 (안정 버전)
|
||||
# ==========================================
|
||||
numpy==1.24.3
|
||||
pandas==2.1.4
|
||||
|
||||
# ==========================================
|
||||
# AI/ML 라이브러리 (호환성 검증된 버전)
|
||||
# ==========================================
|
||||
# PyTorch CPU 버전 (안정화)
|
||||
torch==2.1.0+cpu --index-url https://download.pytorch.org/whl/cpu
|
||||
torchvision==0.16.0+cpu --index-url https://download.pytorch.org/whl/cpu
|
||||
torchaudio==2.1.0+cpu --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Transformer 라이브러리들
|
||||
tokenizers==0.15.2
|
||||
transformers==4.35.2
|
||||
huggingface-hub==0.19.4
|
||||
|
||||
# Sentence Transformers (안정 버전)
|
||||
sentence-transformers==2.2.2
|
||||
|
||||
# ==========================================
|
||||
# Vector DB - ChromaDB (안정 버전)
|
||||
# ==========================================
|
||||
# ChromaDB 0.4.24 사용 (호환성 검증됨)
|
||||
chromadb==0.4.24
|
||||
|
||||
# ChromaDB 의존성 (호환 버전 고정)
|
||||
hnswlib==0.7.0
|
||||
duckdb==0.9.2
|
||||
|
||||
# ==========================================
|
||||
# Claude API (최신 안정 버전)
|
||||
# ==========================================
|
||||
anthropic>=0.40.0,<1.0.0
|
||||
|
||||
# ==========================================
|
||||
# 기타 필수 라이브러리
|
||||
# ==========================================
|
||||
typing-extensions==4.8.0
|
||||
sqlalchemy==2.0.23
|
||||
|
||||
# ==========================================
|
||||
# 개발/디버깅 도구 (선택사항)
|
||||
# ==========================================
|
||||
# pytest==7.4.3
|
||||
# black==23.11.0
|
||||
# isort==5.12.0
|
||||
|
||||
336
vector/app/services/claude_service.py
Normal file
336
vector/app/services/claude_service.py
Normal file
@ -0,0 +1,336 @@
|
||||
# app/services/claude_service.py
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import anthropic
|
||||
from ..config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ClaudeService:
|
||||
"""Claude API 연동 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
# API 키 유효성 검사
|
||||
if not settings.CLAUDE_API_KEY or settings.CLAUDE_API_KEY.strip() == "":
|
||||
raise ValueError("CLAUDE_API_KEY가 설정되지 않았습니다")
|
||||
|
||||
# Claude 클라이언트 초기화
|
||||
self.client = anthropic.Anthropic(api_key=settings.CLAUDE_API_KEY)
|
||||
self.model = settings.CLAUDE_MODEL
|
||||
self.initialization_error = None
|
||||
|
||||
logger.info(f"✅ ClaudeService 초기화 완료 (모델: {self.model})")
|
||||
|
||||
except Exception as e:
|
||||
self.initialization_error = str(e)
|
||||
self.client = None
|
||||
logger.error(f"❌ ClaudeService 초기화 실패: {e}")
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""서비스 준비 상태 확인"""
|
||||
return self.client is not None and self.initialization_error is None
|
||||
|
||||
def get_initialization_error(self) -> Optional[str]:
|
||||
"""초기화 에러 메시지 반환"""
|
||||
return self.initialization_error
|
||||
|
||||
async def test_api_connection(self) -> Tuple[bool, Optional[str]]:
|
||||
"""Claude API 연결을 테스트합니다."""
|
||||
if not self.is_ready():
|
||||
return False, self.initialization_error
|
||||
|
||||
try:
|
||||
logger.info("🔍 Claude API 연결 테스트 시작...")
|
||||
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=50,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "안녕하세요. 연결 테스트입니다. '연결 성공'이라고 답변해주세요."
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if response.content and len(response.content) > 0:
|
||||
logger.info("✅ Claude API 연결 테스트 성공")
|
||||
return True, None
|
||||
else:
|
||||
error_msg = "Claude API 응답이 비어있음"
|
||||
logger.warning(f"⚠️ {error_msg}")
|
||||
return False, error_msg
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Claude API 연결 테스트 실패: {str(e)}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
return False, error_msg
|
||||
|
||||
async def generate_action_recommendations(self, context: str, additional_context: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""점주를 위한 액션 추천을 생성합니다."""
|
||||
if not self.is_ready():
|
||||
logger.error("ClaudeService가 준비되지 않음")
|
||||
return None, None
|
||||
|
||||
try:
|
||||
logger.info("🤖 Claude API를 통한 액션 추천 생성 시작")
|
||||
|
||||
prompt = self._build_action_prompt(context, additional_context)
|
||||
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=4000,
|
||||
temperature=0.7,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if response.content and len(response.content) > 0:
|
||||
raw_response = response.content[0].text
|
||||
logger.info(f"✅ 액션 추천 생성 완료: {len(raw_response)} 문자")
|
||||
|
||||
parsed_response = self._parse_json_response(raw_response)
|
||||
return raw_response, parsed_response
|
||||
else:
|
||||
logger.warning("⚠️ Claude API 응답이 비어있음")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 액션 추천 생성 중 오류: {e}")
|
||||
return None, None
|
||||
|
||||
def _parse_json_response(self, raw_response: str) -> Optional[Dict[str, Any]]:
|
||||
"""Claude의 원본 응답에서 JSON을 추출하고 파싱합니다."""
|
||||
try:
|
||||
import re
|
||||
|
||||
# JSON 블록 찾기
|
||||
json_match = re.search(r'```json\s*\n(.*?)\n```', raw_response, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(1).strip()
|
||||
else:
|
||||
brace_start = raw_response.find('{')
|
||||
brace_end = raw_response.rfind('}')
|
||||
|
||||
if brace_start != -1 and brace_end != -1 and brace_end > brace_start:
|
||||
json_str = raw_response[brace_start:brace_end + 1]
|
||||
else:
|
||||
logger.warning("⚠️ JSON 패턴을 찾을 수 없음")
|
||||
return None
|
||||
|
||||
parsed_json = json.loads(json_str)
|
||||
logger.info("✅ JSON 파싱 성공")
|
||||
return parsed_json
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"⚠️ JSON 파싱 실패: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ JSON 추출 실패: {e}")
|
||||
return None
|
||||
|
||||
def _build_action_prompt(self, context: str, additional_context: Optional[str] = None) -> str:
|
||||
"""액션 추천을 위한 프롬프트를 구성합니다."""
|
||||
|
||||
base_prompt = f"""당신은 소상공인을 위한 경영 컨설턴트입니다. 아래 정보를 바탕으로 실질적이고 구체적인 액션 추천을 해주세요.
|
||||
|
||||
**분석 데이터:**
|
||||
{context}
|
||||
|
||||
**추천 요구사항:**
|
||||
1. 단기 계획 (1-3개월): 즉시 실행 가능한 개선사항
|
||||
2. 중기 계획 (3-6개월): 점진적 개선 방안
|
||||
3. 장기 계획 (6개월-1년): 전략적 발전 방향
|
||||
|
||||
**응답 형식:**
|
||||
반드시 아래 JSON 형식으로만 응답해주세요:
|
||||
|
||||
```json
|
||||
{{
|
||||
"summary": {{
|
||||
"current_situation": "현재 상황 요약",
|
||||
"key_insights": ["핵심 인사이트 1", "핵심 인사이트 2", "핵심 인사이트 3"],
|
||||
"priority_areas": ["우선 개선 영역 1", "우선 개선 영역 2"]
|
||||
}},
|
||||
"action_plans": {{
|
||||
"short_term": [
|
||||
{{
|
||||
"title": "액션 제목",
|
||||
"description": "구체적인 실행 방법",
|
||||
"expected_impact": "예상 효과",
|
||||
"timeline": "실행 기간",
|
||||
"cost": "예상 비용"
|
||||
}}
|
||||
],
|
||||
"mid_term": [
|
||||
{{
|
||||
"title": "액션 제목",
|
||||
"description": "구체적인 실행 방법",
|
||||
"expected_impact": "예상 효과",
|
||||
"timeline": "실행 기간",
|
||||
"cost": "예상 비용"
|
||||
}}
|
||||
],
|
||||
"long_term": [
|
||||
{{
|
||||
"title": "액션 제목",
|
||||
"description": "구체적인 실행 방법",
|
||||
"expected_impact": "예상 효과",
|
||||
"timeline": "실행 기간",
|
||||
"cost": "예상 비용"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"implementation_tips": [
|
||||
"실행 팁 1",
|
||||
"실행 팁 2",
|
||||
"실행 팁 3"
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
**응답은 반드시 유효한 JSON 형식으로만 작성하고, JSON 앞뒤에 다른 텍스트는 포함하지 마세요.**"""
|
||||
|
||||
if additional_context:
|
||||
base_prompt += f"\n\n**점주 추가 요청사항:**\n{additional_context}\n"
|
||||
|
||||
return base_prompt
|
||||
|
||||
# =============================================================================
|
||||
# 호환성을 위한 메서드들 (기존 코드가 사용할 수 있도록)
|
||||
# =============================================================================
|
||||
|
||||
async def get_recommendation(self, prompt: str) -> Optional[str]:
|
||||
"""Claude API를 호출하여 추천을 받습니다. (호환성용)"""
|
||||
if not self.is_ready():
|
||||
logger.error("ClaudeService가 준비되지 않음")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info("🤖 Claude API 호출 시작")
|
||||
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=4000,
|
||||
temperature=0.7,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if response.content and len(response.content) > 0:
|
||||
raw_response = response.content[0].text
|
||||
logger.info(f"✅ Claude API 응답 성공: {len(raw_response)} 문자")
|
||||
return raw_response
|
||||
else:
|
||||
logger.warning("⚠️ Claude API 응답이 비어있음")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Claude API 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
def parse_recommendation_response(self, raw_response: str) -> Optional[Dict[str, Any]]:
|
||||
"""Claude 응답에서 JSON을 추출하고 파싱합니다. (호환성용)"""
|
||||
return self._parse_json_response(raw_response)
|
||||
|
||||
def build_recommendation_prompt(self, store_id: str, context: str, vector_context: Optional[str] = None) -> str:
|
||||
"""액션 추천용 프롬프트를 구성합니다. (호환성용)"""
|
||||
|
||||
prompt_parts = [
|
||||
"당신은 소상공인을 위한 경영 컨설턴트입니다.",
|
||||
f"가게 ID: {store_id}",
|
||||
f"점주 요청: {context}"
|
||||
]
|
||||
|
||||
if vector_context:
|
||||
prompt_parts.extend([
|
||||
"\n--- 동종 업체 분석 데이터 ---",
|
||||
vector_context,
|
||||
"--- 분석 데이터 끝 ---\n"
|
||||
])
|
||||
|
||||
prompt_parts.extend([
|
||||
"\n위 정보를 바탕으로 실질적이고 구체적인 액션 추천을 해주세요.",
|
||||
"응답은 반드시 아래 JSON 형식으로만 작성해주세요:",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
' "summary": {',
|
||||
' "current_situation": "현재 상황 요약",',
|
||||
' "key_insights": ["핵심 인사이트 1", "핵심 인사이트 2"],',
|
||||
' "priority_areas": ["우선 개선 영역 1", "우선 개선 영역 2"]',
|
||||
' },',
|
||||
' "action_plans": {',
|
||||
' "short_term": [',
|
||||
' {',
|
||||
' "title": "즉시 실행 가능한 액션",',
|
||||
' "description": "구체적인 실행 방법",',
|
||||
' "expected_impact": "예상 효과",',
|
||||
' "timeline": "1-2주",',
|
||||
' "cost": "예상 비용"',
|
||||
' }',
|
||||
' ],',
|
||||
' "mid_term": [',
|
||||
' {',
|
||||
' "title": "중기 개선 방안",',
|
||||
' "description": "구체적인 실행 방법",',
|
||||
' "expected_impact": "예상 효과",',
|
||||
' "timeline": "1-3개월",',
|
||||
' "cost": "예상 비용"',
|
||||
' }',
|
||||
' ]',
|
||||
' },',
|
||||
' "implementation_tips": ["실행 팁 1", "실행 팁 2"]',
|
||||
"}",
|
||||
"```"
|
||||
])
|
||||
|
||||
return "\n".join(prompt_parts)
|
||||
|
||||
def create_prompt_for_api_response(self, context: str, additional_context: Optional[str] = None) -> str:
|
||||
"""API 응답용 프롬프트를 생성합니다. (호환성용)"""
|
||||
return self._build_action_prompt(context, additional_context)
|
||||
|
||||
# =============================================================================
|
||||
# 헬스체크 및 상태 확인 메서드들
|
||||
# =============================================================================
|
||||
|
||||
def get_health_status(self) -> Dict[str, Any]:
|
||||
"""ClaudeService 상태 확인"""
|
||||
try:
|
||||
status = {
|
||||
"service": "claude_api",
|
||||
"status": "healthy" if self.is_ready() else "unhealthy",
|
||||
"model": self.model,
|
||||
"api_key_configured": bool(settings.CLAUDE_API_KEY and settings.CLAUDE_API_KEY.strip()),
|
||||
"timestamp": self._get_timestamp()
|
||||
}
|
||||
|
||||
if self.initialization_error:
|
||||
status["initialization_error"] = self.initialization_error
|
||||
status["status"] = "error"
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"service": "claude_api",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": self._get_timestamp()
|
||||
}
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""현재 시간 문자열 반환"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat()
|
||||
235
vector/app/services/restaurant_service.py
Normal file
235
vector/app/services/restaurant_service.py
Normal file
@ -0,0 +1,235 @@
|
||||
# app/services/restaurant_service.py (수정된 버전)
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..config.settings import settings
|
||||
from ..models.restaurant_models import RestaurantInfo
|
||||
from ..utils.category_utils import extract_food_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RestaurantService:
|
||||
"""음식점 API 연동 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.get_restaurant_api_url()
|
||||
self.timeout = aiohttp.ClientTimeout(total=settings.REQUEST_TIMEOUT)
|
||||
|
||||
async def find_store_by_name_and_region(self, region: str, store_name: str) -> Optional[RestaurantInfo]:
|
||||
"""
|
||||
지역과 가게명으로 가게를 찾습니다.
|
||||
|
||||
Args:
|
||||
region: 지역 (시군구 + 읍면동)
|
||||
store_name: 가게명
|
||||
|
||||
Returns:
|
||||
찾은 가게 정보 (첫 번째 결과)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"가게 검색 시작: region={region}, store_name={store_name}")
|
||||
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
# Restaurant API 호출
|
||||
url = f"{self.base_url}/collect"
|
||||
payload = {
|
||||
"query": store_name,
|
||||
"region": region,
|
||||
"size": 15,
|
||||
"pages": 3,
|
||||
"save_to_file": False
|
||||
}
|
||||
|
||||
logger.info(f"Restaurant API 호출: {url}")
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
restaurants = data.get('restaurants', [])
|
||||
|
||||
if restaurants:
|
||||
# 첫 번째 결과 반환
|
||||
restaurant_data = restaurants[0]
|
||||
restaurant = RestaurantInfo(**restaurant_data)
|
||||
|
||||
logger.info(f"가게 찾기 성공: {restaurant.place_name}")
|
||||
return restaurant
|
||||
else:
|
||||
logger.warning(f"가게를 찾을 수 없음: {store_name}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"Restaurant API 호출 실패: HTTP {response.status}")
|
||||
error_text = await response.text()
|
||||
logger.error(f"Error response: {error_text}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Restaurant API 호출 타임아웃")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"가게 검색 중 오류: {str(e)}")
|
||||
return None
|
||||
|
||||
def _clean_food_category(self, food_category: str) -> str:
|
||||
"""
|
||||
음식 카테고리를 정리하여 검색 키워드로 변환합니다.
|
||||
|
||||
Args:
|
||||
food_category: 원본 음식 카테고리 (예: "육류,고기")
|
||||
|
||||
Returns:
|
||||
정리된 검색 키워드 (예: "육류 고기")
|
||||
"""
|
||||
if not food_category:
|
||||
return "음식점"
|
||||
|
||||
# 콤마와 슬래시를 공백으로 변경
|
||||
cleaned = food_category.replace(',', ' ').replace('/', ' ')
|
||||
|
||||
# 불필요한 단어 제거
|
||||
stop_words = ['음식점', '요리', '전문점', '맛집']
|
||||
keywords = []
|
||||
|
||||
for keyword in cleaned.split():
|
||||
keyword = keyword.strip()
|
||||
if keyword and keyword not in stop_words:
|
||||
keywords.append(keyword)
|
||||
|
||||
# 키워드가 없으면 기본 검색어 사용
|
||||
if not keywords:
|
||||
return "음식점"
|
||||
|
||||
# 🔧 지역 정보는 포함하지 않고 음식 카테고리만 반환
|
||||
return ' '.join(keywords)
|
||||
|
||||
async def find_similar_stores(self, region: str, food_category: str, max_count: int = 50) -> List[RestaurantInfo]:
|
||||
"""
|
||||
동종 업체를 찾습니다.
|
||||
|
||||
Args:
|
||||
region: 지역
|
||||
food_category: 음식 카테고리
|
||||
max_count: 최대 검색 개수
|
||||
|
||||
Returns:
|
||||
동종 업체 목록
|
||||
"""
|
||||
try:
|
||||
logger.info(f"동종 업체 검색 시작: region={region}, food_category={food_category}")
|
||||
|
||||
# 🔧 검색 쿼리 생성 (음식 카테고리만 포함)
|
||||
search_query = self._clean_food_category(food_category)
|
||||
logger.info(f"음식점 수집 요청: query='{search_query}' region='{region}' size=15 pages=1 save_to_file=False")
|
||||
|
||||
similar_stores = []
|
||||
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
# 페이지별로 검색 (최대 5페이지)
|
||||
max_pages = min(5, (max_count // 15) + 1)
|
||||
|
||||
for page in range(1, max_pages + 1):
|
||||
if len(similar_stores) >= max_count:
|
||||
break
|
||||
|
||||
url = f"{self.base_url}/collect"
|
||||
# 🔧 수정된 payload - query에는 음식 카테고리만, region은 분리
|
||||
payload = {
|
||||
"query": search_query, # 🔧 음식 카테고리만 포함
|
||||
"region": region, # 🔧 지역 정보는 별도 파라미터
|
||||
"size": 15,
|
||||
"pages": 1, # 페이지별로 하나씩 호출
|
||||
"save_to_file": False
|
||||
}
|
||||
|
||||
logger.info(f"동종 업체 검색 페이지 {page}: query='{search_query}' region='{region}'")
|
||||
|
||||
try:
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
restaurants = data.get('restaurants', [])
|
||||
|
||||
for restaurant_data in restaurants:
|
||||
if len(similar_stores) >= max_count:
|
||||
break
|
||||
|
||||
try:
|
||||
restaurant = RestaurantInfo(**restaurant_data)
|
||||
|
||||
# 카테고리 필터링
|
||||
restaurant_category = extract_food_category(restaurant.category_name)
|
||||
if self._is_similar_food_category(food_category, restaurant_category):
|
||||
similar_stores.append(restaurant)
|
||||
logger.debug(f"유사 카테고리 매치: {restaurant.place_name} ({restaurant_category})")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"음식점 데이터 파싱 실패: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"페이지 {page} 완료: {len(restaurants)}개 음식점 수집")
|
||||
|
||||
else:
|
||||
logger.warning(f"동종 업체 검색 실패 (페이지 {page}): HTTP {response.status}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"페이지 {page} 검색 중 오류: {e}")
|
||||
continue
|
||||
|
||||
# API 요청 제한을 위한 지연
|
||||
await asyncio.sleep(settings.REQUEST_DELAY)
|
||||
|
||||
logger.info(f"동종 업체 검색 완료: 총 {len(similar_stores)}개")
|
||||
return similar_stores
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"동종 업체 검색 중 오류: {str(e)}")
|
||||
return []
|
||||
|
||||
def _is_similar_food_category(self, target_category: str, restaurant_category: str) -> bool:
|
||||
"""
|
||||
음식 카테고리가 유사한지 확인합니다.
|
||||
|
||||
Args:
|
||||
target_category: 대상 카테고리
|
||||
restaurant_category: 음식점 카테고리
|
||||
|
||||
Returns:
|
||||
유사성 여부
|
||||
"""
|
||||
if not target_category or not restaurant_category:
|
||||
return False
|
||||
|
||||
# 정규화
|
||||
target_lower = target_category.lower().strip()
|
||||
restaurant_lower = restaurant_category.lower().strip()
|
||||
|
||||
# 완전 일치
|
||||
if target_lower == restaurant_lower:
|
||||
return True
|
||||
|
||||
# 키워드 기반 매칭
|
||||
target_keywords = set(target_lower.replace(',', ' ').replace('/', ' ').split())
|
||||
restaurant_keywords = set(restaurant_lower.replace(',', ' ').replace('/', ' ').split())
|
||||
|
||||
# 교집합이 있으면 유사한 것으로 판단
|
||||
common_keywords = target_keywords.intersection(restaurant_keywords)
|
||||
|
||||
return len(common_keywords) > 0
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Restaurant API 상태를 확인합니다.
|
||||
|
||||
Returns:
|
||||
API 상태 (True: 정상, False: 비정상)
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
url = f"{self.base_url}/health"
|
||||
async with session.get(url) as response:
|
||||
return response.status == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Restaurant API 헬스체크 실패: {e}")
|
||||
return False
|
||||
|
||||
467
vector/app/services/review_service.py
Normal file
467
vector/app/services/review_service.py
Normal file
@ -0,0 +1,467 @@
|
||||
# vector/app/services/review_service.py
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from ..config.settings import settings
|
||||
from ..models.review_models import ReviewAnalysisResponse, StoreInfo, ReviewData
|
||||
from ..models.restaurant_models import RestaurantInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ReviewService:
|
||||
"""리뷰 API 연동 서비스 (본인 가게 우선 처리 강화)"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.get_review_api_url()
|
||||
self.timeout = aiohttp.ClientTimeout(total=settings.REQUEST_TIMEOUT)
|
||||
|
||||
async def collect_store_reviews(self, store_id: str, max_reviews: int = 100) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
단일 가게의 리뷰를 수집합니다. (본인 가게용 강화 처리)
|
||||
|
||||
Args:
|
||||
store_id: 카카오맵 가게 ID
|
||||
max_reviews: 최대 수집할 리뷰 수
|
||||
|
||||
Returns:
|
||||
(가게 정보, 리뷰 목록) 튜플
|
||||
"""
|
||||
try:
|
||||
logger.info(f"🏪 가게 리뷰 수집 시작: store_id={store_id} (최대 {max_reviews}개)")
|
||||
|
||||
# 본인 가게는 더 관대한 타임아웃 설정
|
||||
timeout = aiohttp.ClientTimeout(total=900) # 15분
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
url = f"{self.base_url}/analyze"
|
||||
payload = {
|
||||
"store_id": store_id,
|
||||
"days_limit": None, # 모든 날짜의 리뷰 수집
|
||||
"max_time": min(600, max_reviews * 3) # 리뷰 수에 따라 시간 조정, 최대 10분
|
||||
}
|
||||
|
||||
logger.info(f"Review API 호출: {url} (타임아웃: {payload['max_time']}초)")
|
||||
|
||||
async with session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
|
||||
if data.get('success', False):
|
||||
store_info = data.get('store_info')
|
||||
reviews = data.get('reviews', [])
|
||||
|
||||
logger.info(f"📊 원본 리뷰 수집: {len(reviews)}개")
|
||||
|
||||
# 리뷰 품질 필터링
|
||||
filtered_reviews = self._filter_quality_reviews(reviews)
|
||||
logger.info(f"📊 품질 필터링 후: {len(filtered_reviews)}개")
|
||||
|
||||
# 리뷰 개수 제한
|
||||
if len(filtered_reviews) > max_reviews:
|
||||
filtered_reviews = filtered_reviews[:max_reviews]
|
||||
logger.info(f"📊 최종 리뷰 수: {len(filtered_reviews)}개 (제한 적용)")
|
||||
|
||||
# 가게 정보와 리뷰 변환
|
||||
converted_store_info = self._convert_store_info(store_info)
|
||||
converted_reviews = self._convert_reviews(filtered_reviews)
|
||||
|
||||
if converted_store_info and converted_reviews:
|
||||
logger.info(f"✅ 리뷰 수집 성공: {len(converted_reviews)}개")
|
||||
return converted_store_info, converted_reviews
|
||||
else:
|
||||
logger.warning("⚠️ 변환된 데이터가 비어있음")
|
||||
return None, []
|
||||
else:
|
||||
error_msg = data.get('message', 'Unknown error')
|
||||
logger.error(f"❌ 리뷰 분석 실패: {error_msg}")
|
||||
return None, []
|
||||
else:
|
||||
logger.error(f"❌ Review API 호출 실패: HTTP {response.status}")
|
||||
error_text = await response.text()
|
||||
logger.error(f"Error response: {error_text}")
|
||||
return None, []
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("❌ Review API 호출 타임아웃")
|
||||
return None, []
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 리뷰 수집 중 오류: {str(e)}")
|
||||
return None, []
|
||||
|
||||
def _filter_quality_reviews(self, reviews: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""리뷰 품질 필터링"""
|
||||
try:
|
||||
filtered = []
|
||||
|
||||
for review in reviews:
|
||||
content = review.get('content', '').strip()
|
||||
rating = review.get('rating', 0)
|
||||
|
||||
# 품질 기준
|
||||
if (len(content) >= 10 and # 최소 10자 이상
|
||||
rating > 0 and # 별점이 있어야 함
|
||||
not self._is_spam_review(content)): # 스팸 제외
|
||||
filtered.append(review)
|
||||
|
||||
logger.debug(f"품질 필터링: {len(reviews)} → {len(filtered)}")
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 리뷰 필터링 실패: {e}")
|
||||
return reviews # 실패 시 원본 반환
|
||||
|
||||
def _is_spam_review(self, content: str) -> bool:
|
||||
"""스팸 리뷰 판별"""
|
||||
try:
|
||||
spam_keywords = [
|
||||
"추천추천", "최고최고", "맛있어요맛있어요",
|
||||
"좋아요좋아요", "ㅎㅎㅎㅎ", "ㅋㅋㅋㅋ",
|
||||
"굿굿굿", "Nice", "Good"
|
||||
]
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# 스팸 키워드 확인
|
||||
for keyword in spam_keywords:
|
||||
if keyword.lower() in content_lower:
|
||||
return True
|
||||
|
||||
# 너무 짧거나 반복 문자 확인
|
||||
if len(set(content.replace(' ', ''))) < 3: # 고유 문자 3개 미만
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 스팸 판별 실패: {e}")
|
||||
return False
|
||||
|
||||
def _convert_store_info(self, store_info):
|
||||
"""가게 정보 변환 (강화된 버전)"""
|
||||
if not store_info:
|
||||
logger.warning("⚠️ 가게 정보가 비어있음")
|
||||
return None
|
||||
|
||||
try:
|
||||
converted = {
|
||||
'id': str(store_info.get('id', '')),
|
||||
'name': str(store_info.get('name', '')),
|
||||
'category': str(store_info.get('category', '')),
|
||||
'rating': str(store_info.get('rating', '')),
|
||||
'review_count': str(store_info.get('review_count', '')),
|
||||
'status': str(store_info.get('status', '')),
|
||||
'address': str(store_info.get('address', ''))
|
||||
}
|
||||
|
||||
# 필수 필드 확인 (ID와 이름은 반드시 있어야 함)
|
||||
if not converted['id'] or not converted['name']:
|
||||
logger.warning(f"⚠️ 필수 가게 정보 누락: ID={converted['id']}, Name={converted['name']}")
|
||||
return None
|
||||
|
||||
logger.debug(f"가게 정보 변환 성공: {converted['name']}")
|
||||
return converted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 가게 정보 변환 실패: {e}")
|
||||
return None
|
||||
|
||||
def _convert_reviews(self, reviews):
|
||||
"""리뷰 목록 변환 (강화된 버전)"""
|
||||
if not reviews:
|
||||
logger.warning("⚠️ 리뷰 목록이 비어있음")
|
||||
return []
|
||||
|
||||
converted_reviews = []
|
||||
|
||||
for i, review in enumerate(reviews):
|
||||
try:
|
||||
# 안전한 형변환
|
||||
rating = 0
|
||||
try:
|
||||
rating = int(review.get('rating', 0))
|
||||
except (ValueError, TypeError):
|
||||
rating = 0
|
||||
|
||||
likes = 0
|
||||
try:
|
||||
likes = int(review.get('likes', 0))
|
||||
except (ValueError, TypeError):
|
||||
likes = 0
|
||||
|
||||
photo_count = 0
|
||||
try:
|
||||
photo_count = int(review.get('photo_count', 0))
|
||||
except (ValueError, TypeError):
|
||||
photo_count = 0
|
||||
|
||||
converted_review = {
|
||||
'reviewer_name': str(review.get('reviewer_name', 'Anonymous')),
|
||||
'rating': rating,
|
||||
'date': str(review.get('date', '')),
|
||||
'content': str(review.get('content', '')).strip(),
|
||||
'badges': list(review.get('badges', [])),
|
||||
'likes': likes,
|
||||
'photo_count': photo_count,
|
||||
'has_photos': bool(photo_count > 0)
|
||||
}
|
||||
|
||||
# 기본 검증
|
||||
if converted_review['content'] and converted_review['rating'] > 0:
|
||||
converted_reviews.append(converted_review)
|
||||
else:
|
||||
logger.debug(f"⚠️ 리뷰 {i+1} 품질 미달로 제외")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 리뷰 {i+1} 변환 실패: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"리뷰 변환 완료: {len(reviews)} → {len(converted_reviews)}")
|
||||
return converted_reviews
|
||||
|
||||
async def collect_multiple_stores_reviews(self, restaurants: List[RestaurantInfo]) -> List[Tuple[str, Dict[str, Any], List[Dict[str, Any]]]]:
|
||||
"""
|
||||
여러 가게의 리뷰를 수집합니다.
|
||||
|
||||
Args:
|
||||
restaurants: 음식점 목록
|
||||
|
||||
Returns:
|
||||
(store_id, 가게 정보, 리뷰 목록) 튜플의 리스트
|
||||
"""
|
||||
try:
|
||||
logger.info(f"다중 가게 리뷰 수집 시작: {len(restaurants)}개 가게")
|
||||
|
||||
results = []
|
||||
|
||||
# 동시성 제한을 위해 세마포어 사용
|
||||
semaphore = asyncio.Semaphore(3) # 최대 3개 동시 요청
|
||||
|
||||
async def collect_single_store(restaurant: RestaurantInfo):
|
||||
async with semaphore:
|
||||
try:
|
||||
# 카카오맵 place_url에서 store_id 추출 시도
|
||||
store_id = self._extract_store_id_from_url(restaurant.place_url)
|
||||
|
||||
if not store_id:
|
||||
# URL에서 추출 실패 시 restaurant.id 사용
|
||||
store_id = restaurant.id
|
||||
|
||||
if not store_id:
|
||||
logger.warning(f"Store ID를 찾을 수 없음: {restaurant.place_name}")
|
||||
return None
|
||||
|
||||
logger.info(f"가게 {restaurant.place_name} 리뷰 수집 중...")
|
||||
|
||||
# 리뷰 수집 (최대 50개로 제한)
|
||||
store_info, reviews = await self.collect_store_reviews(
|
||||
store_id,
|
||||
max_reviews=settings.MAX_REVIEWS_PER_RESTAURANT
|
||||
)
|
||||
|
||||
if store_info and reviews:
|
||||
return (store_id, store_info, reviews)
|
||||
else:
|
||||
logger.warning(f"가게 {restaurant.place_name}의 리뷰 수집 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"가게 {restaurant.place_name} 리뷰 수집 중 오류: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
# API 요청 제한을 위한 지연
|
||||
await asyncio.sleep(settings.REQUEST_DELAY)
|
||||
|
||||
# 병렬 처리
|
||||
tasks = [collect_single_store(restaurant) for restaurant in restaurants]
|
||||
results_raw = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 성공한 결과만 필터링
|
||||
for result in results_raw:
|
||||
if result and not isinstance(result, Exception):
|
||||
results.append(result)
|
||||
|
||||
logger.info(f"다중 가게 리뷰 수집 완료: {len(results)}개 성공")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"다중 가게 리뷰 수집 중 오류: {str(e)}")
|
||||
return []
|
||||
|
||||
def _extract_store_id_from_url(self, place_url: str) -> Optional[str]:
|
||||
"""
|
||||
카카오맵 URL에서 store_id를 추출합니다.
|
||||
|
||||
Args:
|
||||
place_url: 카카오맵 장소 URL
|
||||
|
||||
Returns:
|
||||
추출된 store_id 또는 None
|
||||
"""
|
||||
try:
|
||||
if not place_url:
|
||||
return None
|
||||
|
||||
# URL 패턴: https://place.map.kakao.com/123456789
|
||||
import re
|
||||
pattern = r'/(\d+)(?:\?|$|#)'
|
||||
match = re.search(pattern, place_url)
|
||||
|
||||
if match:
|
||||
store_id = match.group(1)
|
||||
logger.debug(f"URL에서 store_id 추출: {store_id}")
|
||||
return store_id
|
||||
else:
|
||||
logger.debug(f"URL에서 store_id 추출 실패: {place_url}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"store_id 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _build_store_info_from_restaurant(self, restaurant: RestaurantInfo) -> Dict[str, Any]:
|
||||
"""
|
||||
RestaurantInfo를 store_info 형식으로 변환합니다.
|
||||
|
||||
Args:
|
||||
restaurant: RestaurantInfo 객체
|
||||
|
||||
Returns:
|
||||
store_info 딕셔너리
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
'id': restaurant.id,
|
||||
'name': restaurant.place_name,
|
||||
'category': restaurant.category_name,
|
||||
'rating': '', # API에서 제공되지 않음
|
||||
'review_count': '', # API에서 제공되지 않음
|
||||
'status': '', # API에서 제공되지 않음
|
||||
'address': restaurant.address_name
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"RestaurantInfo 변환 실패: {e}")
|
||||
return {
|
||||
'id': '',
|
||||
'name': '',
|
||||
'category': '',
|
||||
'rating': '',
|
||||
'review_count': '',
|
||||
'status': '',
|
||||
'address': ''
|
||||
}
|
||||
|
||||
def _convert_single_review_data(self, review_data: dict) -> dict:
|
||||
"""
|
||||
단일 리뷰 데이터 변환
|
||||
|
||||
Args:
|
||||
review_data: API 응답의 단일 리뷰 데이터
|
||||
|
||||
Returns:
|
||||
변환된 리뷰 데이터
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
'reviewer_name': review_data.get('reviewer_name', ''),
|
||||
'reviewer_level': review_data.get('reviewer_level', ''),
|
||||
'reviewer_stats': review_data.get('reviewer_stats', {}),
|
||||
'rating': int(review_data.get('rating', 0)),
|
||||
'date': review_data.get('date', ''),
|
||||
'content': review_data.get('content', ''),
|
||||
'badges': review_data.get('badges', []),
|
||||
'likes': int(review_data.get('likes', 0)),
|
||||
'photo_count': int(review_data.get('photo_count', 0)),
|
||||
'has_photos': bool(review_data.get('has_photos', False))
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"단일 리뷰 데이터 변환 실패: {e}")
|
||||
return {
|
||||
'reviewer_name': 'Unknown',
|
||||
'reviewer_level': '',
|
||||
'reviewer_stats': {},
|
||||
'rating': 0,
|
||||
'date': '',
|
||||
'content': '',
|
||||
'badges': [],
|
||||
'likes': 0,
|
||||
'photo_count': 0,
|
||||
'has_photos': False
|
||||
}
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Review API 상태를 확인합니다.
|
||||
|
||||
Returns:
|
||||
API 상태 (True: 정상, False: 비정상)
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
||||
url = f"{self.base_url}/health"
|
||||
async with session.get(url) as response:
|
||||
is_healthy = response.status == 200
|
||||
if is_healthy:
|
||||
logger.debug("Review API 헬스체크 성공")
|
||||
else:
|
||||
logger.warning(f"Review API 헬스체크 실패: HTTP {response.status}")
|
||||
return is_healthy
|
||||
except Exception as e:
|
||||
logger.error(f"Review API 헬스체크 실패: {e}")
|
||||
return False
|
||||
|
||||
def get_api_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Review API 정보를 반환합니다.
|
||||
|
||||
Returns:
|
||||
API 정보 딕셔너리
|
||||
"""
|
||||
return {
|
||||
"service_name": "Review API Service",
|
||||
"base_url": self.base_url,
|
||||
"timeout": self.timeout.total,
|
||||
"max_reviews_per_restaurant": settings.MAX_REVIEWS_PER_RESTAURANT,
|
||||
"request_delay": settings.REQUEST_DELAY
|
||||
}
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Review API 연결을 테스트합니다.
|
||||
|
||||
Returns:
|
||||
테스트 결과
|
||||
"""
|
||||
test_result = {
|
||||
"service": "Review API",
|
||||
"base_url": self.base_url,
|
||||
"status": "unknown",
|
||||
"response_time": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
is_healthy = await self.health_check()
|
||||
|
||||
response_time = time.time() - start_time
|
||||
test_result["response_time"] = round(response_time, 3)
|
||||
|
||||
if is_healthy:
|
||||
test_result["status"] = "healthy"
|
||||
logger.info(f"Review API 연결 테스트 성공: {response_time:.3f}초")
|
||||
else:
|
||||
test_result["status"] = "unhealthy"
|
||||
test_result["error"] = "Health check failed"
|
||||
logger.warning("Review API 연결 테스트 실패: 헬스체크 실패")
|
||||
|
||||
except Exception as e:
|
||||
test_result["status"] = "error"
|
||||
test_result["error"] = str(e)
|
||||
logger.error(f"Review API 연결 테스트 오류: {e}")
|
||||
|
||||
return test_result
|
||||
|
||||
728
vector/app/services/vector_service.py
Normal file
728
vector/app/services/vector_service.py
Normal file
@ -0,0 +1,728 @@
|
||||
# app/services/vector_service.py (개선된 버전)
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import shutil
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from ..config.settings import settings
|
||||
from ..utils.data_utils import (
|
||||
create_store_hash, combine_store_and_reviews, generate_review_summary,
|
||||
extract_text_for_embedding, create_metadata, is_duplicate_store
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class VectorService:
|
||||
"""Vector DB 서비스 (개선된 초기화 로직)"""
|
||||
|
||||
def __init__(self):
|
||||
self.db_path = settings.VECTOR_DB_PATH
|
||||
self.collection_name = settings.VECTOR_DB_COLLECTION
|
||||
self.embedding_model_name = settings.EMBEDDING_MODEL
|
||||
|
||||
# 상태 변수
|
||||
self.client = None
|
||||
self.collection = None
|
||||
self.embedding_model = None
|
||||
self.initialization_error = None
|
||||
|
||||
# 안전한 초기화 시도
|
||||
self._safe_initialize()
|
||||
|
||||
def _safe_initialize(self):
|
||||
"""안전한 초기화 - 개선된 로직"""
|
||||
try:
|
||||
logger.info("🔧 VectorService 초기화 시작...")
|
||||
|
||||
# 1단계: 디렉토리 권한 확인
|
||||
self._ensure_directory_permissions()
|
||||
|
||||
# 2단계: ChromaDB 초기화 (호환성 확인 포함)
|
||||
self._initialize_chromadb_with_compatibility_check()
|
||||
|
||||
# 3단계: 임베딩 모델 로드
|
||||
self._initialize_embedding_model()
|
||||
|
||||
logger.info("✅ VectorService 초기화 완료")
|
||||
|
||||
except Exception as e:
|
||||
self.initialization_error = str(e)
|
||||
logger.error(f"❌ VectorService 초기화 실패: {e}")
|
||||
logger.info("🔄 서비스는 런타임에 재시도 가능합니다")
|
||||
|
||||
def _ensure_directory_permissions(self):
|
||||
"""Vector DB 디렉토리 권한을 확인하고 생성합니다"""
|
||||
try:
|
||||
logger.info(f"📁 Vector DB 디렉토리 설정: {self.db_path}")
|
||||
|
||||
# 절대 경로로 변환
|
||||
abs_path = os.path.abspath(self.db_path)
|
||||
|
||||
# 디렉토리 생성
|
||||
os.makedirs(abs_path, mode=0o755, exist_ok=True)
|
||||
|
||||
# 권한 확인
|
||||
if not os.access(abs_path, os.W_OK):
|
||||
logger.warning(f"⚠️ 쓰기 권한 없음: {abs_path}")
|
||||
|
||||
# 권한 변경 시도
|
||||
try:
|
||||
os.chmod(abs_path, 0o755)
|
||||
logger.info("✅ 디렉토리 권한 변경 성공")
|
||||
except Exception as chmod_error:
|
||||
logger.warning(f"⚠️ 권한 변경 실패: {chmod_error}")
|
||||
# 임시 디렉토리로 대체
|
||||
import tempfile
|
||||
temp_dir = tempfile.mkdtemp(prefix="vectordb_")
|
||||
logger.info(f"🔄 임시 디렉토리 사용: {temp_dir}")
|
||||
self.db_path = temp_dir
|
||||
abs_path = temp_dir
|
||||
|
||||
# 테스트 파일 생성/삭제로 권한 확인
|
||||
test_file = os.path.join(abs_path, "test_permissions.tmp")
|
||||
try:
|
||||
with open(test_file, 'w') as f:
|
||||
f.write("test")
|
||||
os.remove(test_file)
|
||||
logger.info("✅ 디렉토리 권한 확인 완료")
|
||||
except Exception as test_error:
|
||||
raise Exception(f"디렉토리 권한 테스트 실패: {test_error}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 디렉토리 설정 실패: {e}")
|
||||
raise
|
||||
|
||||
def _initialize_chromadb_with_compatibility_check(self):
|
||||
"""ChromaDB 초기화 (호환성 확인 포함)"""
|
||||
max_retries = 3
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"🔄 ChromaDB 초기화 시도 {attempt + 1}/{max_retries}")
|
||||
|
||||
# 1단계: 기존 DB 호환성 확인
|
||||
existing_db_valid = self._check_existing_db_compatibility()
|
||||
|
||||
# 2단계: ChromaDB 클라이언트 생성
|
||||
self._create_chromadb_client()
|
||||
|
||||
# 3단계: 컬렉션 초기화
|
||||
self._initialize_collection(existing_db_valid)
|
||||
|
||||
logger.info("✅ ChromaDB 초기화 완료")
|
||||
return # 성공 시 루프 종료
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ChromaDB 초기화 실패 (시도 {attempt + 1}): {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"🔄 {retry_delay}초 후 재시도...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay *= 2 # 지수 백오프
|
||||
else:
|
||||
raise Exception(f"ChromaDB 초기화 최종 실패: {e}")
|
||||
|
||||
def _check_existing_db_compatibility(self):
|
||||
"""기존 DB 호환성 확인"""
|
||||
try:
|
||||
if not os.path.exists(self.db_path):
|
||||
logger.info("📁 새 DB 디렉토리 - 스키마 확인 불필요")
|
||||
return False
|
||||
|
||||
db_files = [f for f in os.listdir(self.db_path) if not f.startswith('.')]
|
||||
if not db_files:
|
||||
logger.info("📁 빈 DB 디렉토리 - 스키마 확인 불필요")
|
||||
return False
|
||||
|
||||
logger.info(f"📁 기존 DB 파일 발견: {db_files}")
|
||||
|
||||
# 실제 호환성 테스트
|
||||
logger.info("🔍 기존 DB 호환성 테스트 중...")
|
||||
|
||||
try:
|
||||
# 테스트용 클라이언트 생성
|
||||
test_client = chromadb.PersistentClient(path=self.db_path)
|
||||
test_client.heartbeat()
|
||||
|
||||
# 컬렉션 접근 시도
|
||||
try:
|
||||
test_collection = test_client.get_collection(name=self.collection_name)
|
||||
count = test_collection.count()
|
||||
logger.info(f"✅ 기존 DB 호환성 확인 완료: {count}개 벡터 존재")
|
||||
return True
|
||||
except Exception as collection_error:
|
||||
logger.info(f"📝 기존 컬렉션 없음 (정상): {collection_error}")
|
||||
return True # DB는 정상, 컬렉션만 새로 생성하면 됨
|
||||
|
||||
except Exception as compatibility_error:
|
||||
# 실제 호환성 문제 발견
|
||||
logger.warning(f"⚠️ 실제 호환성 문제 발견: {compatibility_error}")
|
||||
self._backup_incompatible_db()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 호환성 확인 중 오류: {e}")
|
||||
return False
|
||||
|
||||
def _backup_incompatible_db(self):
|
||||
"""호환성 문제가 있는 DB 백업"""
|
||||
try:
|
||||
backup_path = f"{self.db_path}_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
logger.warning(f"🔄 호환성 문제로 기존 DB 백업: {backup_path}")
|
||||
|
||||
shutil.move(self.db_path, backup_path)
|
||||
logger.info(f"✅ 기존 DB 백업 완료: {backup_path}")
|
||||
|
||||
# 새 디렉토리 생성
|
||||
os.makedirs(self.db_path, exist_ok=True)
|
||||
|
||||
# 오래된 백업 정리 (7일 이상)
|
||||
self._cleanup_old_backups()
|
||||
|
||||
except Exception as backup_error:
|
||||
logger.warning(f"⚠️ 백업 실패, 강제 삭제 진행: {backup_error}")
|
||||
shutil.rmtree(self.db_path, ignore_errors=True)
|
||||
os.makedirs(self.db_path, exist_ok=True)
|
||||
|
||||
def _cleanup_old_backups(self):
|
||||
"""오래된 백업 파일 정리 (7일 이상)"""
|
||||
try:
|
||||
base_path = os.path.dirname(self.db_path)
|
||||
backup_pattern = f"{os.path.basename(self.db_path)}_backup_"
|
||||
cutoff_time = time.time() - (7 * 24 * 3600) # 7일 전
|
||||
|
||||
for item in os.listdir(base_path):
|
||||
if item.startswith(backup_pattern):
|
||||
backup_path = os.path.join(base_path, item)
|
||||
if os.path.isdir(backup_path) and os.path.getctime(backup_path) < cutoff_time:
|
||||
shutil.rmtree(backup_path, ignore_errors=True)
|
||||
logger.info(f"🗑️ 오래된 백업 삭제: {backup_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 백업 정리 중 오류: {e}")
|
||||
|
||||
def _create_chromadb_client(self):
|
||||
"""ChromaDB 클라이언트 생성"""
|
||||
try:
|
||||
# 최신 버전 호환 설정
|
||||
chroma_settings = ChromaSettings(
|
||||
anonymized_telemetry=False,
|
||||
allow_reset=True,
|
||||
is_persistent=True
|
||||
)
|
||||
|
||||
self.client = chromadb.PersistentClient(
|
||||
path=self.db_path,
|
||||
settings=chroma_settings
|
||||
)
|
||||
logger.info("✅ ChromaDB 클라이언트 생성 성공")
|
||||
|
||||
except Exception as modern_error:
|
||||
logger.warning(f"⚠️ 최신 설정 실패, 간단한 설정으로 재시도: {modern_error}")
|
||||
|
||||
# 간단한 설정으로 재시도
|
||||
self.client = chromadb.PersistentClient(path=self.db_path)
|
||||
logger.info("✅ ChromaDB 간단 설정 클라이언트 생성 성공")
|
||||
|
||||
# 연결 테스트
|
||||
try:
|
||||
self.client.heartbeat()
|
||||
logger.info("✅ ChromaDB 연결 테스트 성공")
|
||||
except Exception as heartbeat_error:
|
||||
logger.warning(f"⚠️ Heartbeat 실패 (무시): {heartbeat_error}")
|
||||
|
||||
def _initialize_collection(self, existing_db_valid: bool):
|
||||
"""컬렉션 초기화"""
|
||||
try:
|
||||
if existing_db_valid:
|
||||
# 기존 컬렉션 로드 시도
|
||||
try:
|
||||
self.collection = self.client.get_collection(name=self.collection_name)
|
||||
count = self.collection.count()
|
||||
logger.info(f"✅ 기존 컬렉션 로드 성공: {self.collection_name} ({count}개 벡터)")
|
||||
return
|
||||
except Exception as get_error:
|
||||
logger.info(f"📝 기존 컬렉션 없음, 새로 생성: {get_error}")
|
||||
|
||||
# 새 컬렉션 생성
|
||||
self.collection = self.client.create_collection(
|
||||
name=self.collection_name,
|
||||
metadata={
|
||||
"description": "Restaurant reviews vector store",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"version": "1.0"
|
||||
}
|
||||
)
|
||||
logger.info(f"✅ 새 컬렉션 생성 성공: {self.collection_name}")
|
||||
|
||||
except Exception as create_error:
|
||||
logger.error(f"❌ 컬렉션 초기화 실패: {create_error}")
|
||||
|
||||
# 대체 컬렉션명으로 재시도
|
||||
fallback_name = f"{self.collection_name}_{int(time.time())}"
|
||||
logger.info(f"🔄 대체 컬렉션명으로 재시도: {fallback_name}")
|
||||
|
||||
self.collection = self.client.create_collection(
|
||||
name=fallback_name,
|
||||
metadata={"description": "Restaurant reviews (fallback)"}
|
||||
)
|
||||
self.collection_name = fallback_name
|
||||
logger.info(f"✅ 대체 컬렉션 생성 성공: {fallback_name}")
|
||||
|
||||
def _initialize_embedding_model(self):
|
||||
"""임베딩 모델 초기화"""
|
||||
try:
|
||||
logger.info(f"🤖 임베딩 모델 로드 시작: {self.embedding_model_name}")
|
||||
|
||||
# 캐시 디렉토리 설정
|
||||
cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "sentence_transformers")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
# 권한 확인
|
||||
if not os.access(cache_dir, os.W_OK):
|
||||
import tempfile
|
||||
cache_dir = tempfile.mkdtemp(prefix="st_cache_")
|
||||
logger.info(f"🔄 임시 캐시 디렉토리 사용: {cache_dir}")
|
||||
|
||||
# 모델 로드 (타임아웃 설정)
|
||||
def timeout_handler(signum, frame):
|
||||
raise TimeoutError("임베딩 모델 로드 타임아웃")
|
||||
|
||||
signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(300) # 5분 타임아웃
|
||||
|
||||
try:
|
||||
self.embedding_model = SentenceTransformer(
|
||||
self.embedding_model_name,
|
||||
cache_folder=cache_dir,
|
||||
device='cpu' # CPU 사용 명시
|
||||
)
|
||||
signal.alarm(0) # 타임아웃 해제
|
||||
|
||||
# 모델 테스트
|
||||
test_embedding = self.embedding_model.encode(["테스트 문장"])
|
||||
logger.info(f"✅ 임베딩 모델 로드 성공: {test_embedding.shape}")
|
||||
|
||||
except TimeoutError:
|
||||
signal.alarm(0)
|
||||
raise Exception("임베딩 모델 로드 타임아웃 (5분)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 임베딩 모델 로드 실패: {e}")
|
||||
raise
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""서비스 준비 상태 확인"""
|
||||
return (
|
||||
self.client is not None and
|
||||
self.collection is not None and
|
||||
self.embedding_model is not None and
|
||||
self.initialization_error is None
|
||||
)
|
||||
|
||||
def get_initialization_error(self) -> Optional[str]:
|
||||
"""초기화 에러 메시지 반환"""
|
||||
return self.initialization_error
|
||||
|
||||
def retry_initialization(self) -> bool:
|
||||
"""초기화 재시도"""
|
||||
try:
|
||||
logger.info("🔄 VectorService 초기화 재시도...")
|
||||
|
||||
# 상태 초기화
|
||||
self.client = None
|
||||
self.collection = None
|
||||
self.embedding_model = None
|
||||
self.initialization_error = None
|
||||
|
||||
# 재초기화
|
||||
self._safe_initialize()
|
||||
|
||||
return self.is_ready()
|
||||
|
||||
except Exception as e:
|
||||
self.initialization_error = str(e)
|
||||
logger.error(f"❌ 초기화 재시도 실패: {e}")
|
||||
return False
|
||||
|
||||
def reset_vector_db(self) -> Dict[str, Any]:
|
||||
"""Vector DB 완전 리셋"""
|
||||
try:
|
||||
logger.info("🔄 Vector DB 완전 리셋 시작...")
|
||||
|
||||
# 기존 클라이언트 정리
|
||||
self.client = None
|
||||
self.collection = None
|
||||
|
||||
# DB 디렉토리 완전 삭제
|
||||
if os.path.exists(self.db_path):
|
||||
shutil.rmtree(self.db_path, ignore_errors=True)
|
||||
logger.info(f"✅ 기존 DB 디렉토리 삭제: {self.db_path}")
|
||||
|
||||
# 새 디렉토리 생성
|
||||
os.makedirs(self.db_path, exist_ok=True)
|
||||
logger.info(f"✅ 새 DB 디렉토리 생성: {self.db_path}")
|
||||
|
||||
# 재초기화
|
||||
success = self.retry_initialization()
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Vector DB가 성공적으로 리셋되었습니다",
|
||||
"collection_name": self.collection_name,
|
||||
"db_path": self.db_path
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": self.initialization_error or "재초기화 실패"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Vector DB 리셋 실패: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def get_health_status(self) -> Dict[str, Any]:
|
||||
"""서비스 상태 확인"""
|
||||
try:
|
||||
status = {
|
||||
"service": "vector_db",
|
||||
"status": "healthy" if self.is_ready() else "unhealthy",
|
||||
"db_path": self.db_path,
|
||||
"collection_name": self.collection_name,
|
||||
"embedding_model": self.embedding_model_name,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if self.initialization_error:
|
||||
status["initialization_error"] = self.initialization_error
|
||||
status["status"] = "error"
|
||||
|
||||
# 상세 상태
|
||||
status["components"] = {
|
||||
"client": "connected" if self.client else "disconnected",
|
||||
"collection": "ready" if self.collection else "not_ready",
|
||||
"embedding": "loaded" if self.embedding_model else "not_loaded"
|
||||
}
|
||||
|
||||
# 컬렉션 정보
|
||||
if self.collection:
|
||||
try:
|
||||
status["collection_count"] = self.collection.count()
|
||||
except Exception as e:
|
||||
status["collection_error"] = str(e)
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"service": "vector_db",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_store_context(self, store_id: str) -> Optional[str]:
|
||||
"""스토어 ID로 컨텍스트 조회"""
|
||||
try:
|
||||
if not self.is_ready():
|
||||
logger.warning("VectorService가 준비되지 않음")
|
||||
return None
|
||||
|
||||
# 스토어 ID로 검색
|
||||
results = self.collection.get(
|
||||
where={"store_id": store_id}
|
||||
)
|
||||
|
||||
if not results or not results.get('documents'):
|
||||
logger.warning(f"스토어 ID '{store_id}'에 대한 데이터 없음")
|
||||
return None
|
||||
|
||||
# 컨텍스트 생성
|
||||
documents = results['documents']
|
||||
metadatas = results.get('metadatas', [])
|
||||
|
||||
context_parts = []
|
||||
for i, doc in enumerate(documents):
|
||||
metadata = metadatas[i] if i < len(metadatas) else {}
|
||||
|
||||
# 메타데이터 정보 추가
|
||||
if metadata:
|
||||
context_parts.append(f"[{metadata.get('store_name', 'Unknown')}]")
|
||||
|
||||
context_parts.append(doc)
|
||||
context_parts.append("---")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"스토어 컨텍스트 조회 실패: {e}")
|
||||
return None
|
||||
|
||||
async def build_vector_store(self, store_info: Dict[str, Any], similar_stores_data: List[Tuple[str, Dict[str, Any], List[Dict[str, Any]]]], food_category: str, region: str) -> Dict[str, Any]:
|
||||
"""Vector Store 구축 (완전 수정된 버전)"""
|
||||
try:
|
||||
if not self.is_ready():
|
||||
# 재시도 한 번 더
|
||||
if not self.retry_initialization():
|
||||
raise Exception("Vector DB가 초기화되지 않았습니다")
|
||||
|
||||
logger.info(f"Vector Store 구축 시작: {len(similar_stores_data)}개 스토어")
|
||||
|
||||
# 통계 초기화
|
||||
stats = {
|
||||
"total_processed": 0,
|
||||
"newly_added": 0,
|
||||
"updated": 0,
|
||||
"duplicates": 0,
|
||||
"errors": 0
|
||||
}
|
||||
|
||||
# 배치 처리용 리스트
|
||||
all_documents = []
|
||||
all_embeddings = []
|
||||
all_metadatas = []
|
||||
all_ids = []
|
||||
|
||||
# 각 스토어 처리
|
||||
for store_id, store_data, reviews in similar_stores_data:
|
||||
try:
|
||||
# 데이터 검증
|
||||
if not store_data or not reviews:
|
||||
logger.warning(f"스토어 '{store_id}' 데이터 부족: store_data={bool(store_data)}, reviews={len(reviews) if reviews else 0}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
# 올바른 create_store_hash 호출
|
||||
store_hash = create_store_hash(
|
||||
store_id=store_id,
|
||||
store_name=store_data.get('place_name', ''),
|
||||
region=region
|
||||
)
|
||||
|
||||
# ChromaDB에서 직접 중복 확인 (is_duplicate_store 함수 사용하지 않음)
|
||||
try:
|
||||
# 같은 store_id로 이미 저장된 데이터가 있는지 확인
|
||||
existing_data = self.collection.get(
|
||||
where={"store_id": store_id},
|
||||
limit=1
|
||||
)
|
||||
|
||||
if existing_data and len(existing_data.get('ids', [])) > 0:
|
||||
logger.debug(f"중복 스토어 건너뛰기: {store_id}")
|
||||
stats["duplicates"] += 1
|
||||
continue
|
||||
|
||||
except Exception as dup_check_error:
|
||||
# 중복 확인 실패는 로그만 남기고 계속 진행
|
||||
logger.warning(f"중복 확인 실패 (계속 진행): {dup_check_error}")
|
||||
|
||||
# 올바른 extract_text_for_embedding 호출
|
||||
embedding_text = extract_text_for_embedding(
|
||||
store_info=store_data,
|
||||
reviews=reviews
|
||||
)
|
||||
|
||||
# 임베딩 생성
|
||||
try:
|
||||
embedding = self.embedding_model.encode(embedding_text)
|
||||
embedding = embedding.tolist() # numpy array를 list로 변환
|
||||
except Exception as embed_error:
|
||||
logger.error(f"임베딩 생성 실패 (store_id: {store_id}): {embed_error}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
# 올바른 create_metadata 호출
|
||||
metadata = create_metadata(
|
||||
store_info=store_data,
|
||||
food_category=food_category,
|
||||
region=region
|
||||
)
|
||||
|
||||
# 배치에 추가
|
||||
all_documents.append(embedding_text)
|
||||
all_embeddings.append(embedding)
|
||||
all_metadatas.append(metadata)
|
||||
all_ids.append(f"{store_id}_{store_hash}")
|
||||
|
||||
stats["total_processed"] += 1
|
||||
stats["newly_added"] += 1
|
||||
|
||||
if stats["total_processed"] % 10 == 0:
|
||||
logger.info(f"처리 진행률: {stats['total_processed']}/{len(similar_stores_data)}")
|
||||
|
||||
except Exception as store_error:
|
||||
logger.error(f"음식점 처리 중 오류 (store_id: {store_id}): {store_error}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
# 배치로 벡터 저장
|
||||
if all_documents:
|
||||
logger.info(f"벡터 배치 저장 시작: {len(all_documents)}개")
|
||||
|
||||
try:
|
||||
self.collection.add(
|
||||
documents=all_documents,
|
||||
embeddings=all_embeddings,
|
||||
metadatas=all_metadatas,
|
||||
ids=all_ids
|
||||
)
|
||||
logger.info("✅ 벡터 배치 저장 성공")
|
||||
|
||||
except Exception as save_error:
|
||||
logger.error(f"❌ 벡터 저장 실패: {save_error}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"벡터 저장 실패: {str(save_error)}",
|
||||
"statistics": stats
|
||||
}
|
||||
else:
|
||||
logger.warning("⚠️ 저장할 벡터 데이터가 없음")
|
||||
|
||||
# 최종 통계
|
||||
try:
|
||||
total_vectors = self.collection.count()
|
||||
logger.info(f"✅ Vector Store 구축 완료: 총 {total_vectors}개 벡터")
|
||||
except Exception as count_error:
|
||||
logger.warning(f"벡터 개수 확인 실패: {count_error}")
|
||||
total_vectors = len(all_documents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Vector Store 구축 완료",
|
||||
"statistics": stats,
|
||||
"total_vectors": total_vectors,
|
||||
"store_info": store_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Vector Store 구축 전체 실패: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"statistics": stats if 'stats' in locals() else {
|
||||
"total_processed": 0,
|
||||
"newly_added": 0,
|
||||
"updated": 0,
|
||||
"duplicates": 0,
|
||||
"errors": 1
|
||||
}
|
||||
}
|
||||
|
||||
def get_db_status(self) -> Dict[str, Any]:
|
||||
"""Vector DB 상태 정보를 반환합니다."""
|
||||
try:
|
||||
if not self.is_ready():
|
||||
return {
|
||||
'collection_name': self.collection_name,
|
||||
'total_documents': 0,
|
||||
'total_stores': 0,
|
||||
'db_path': self.db_path,
|
||||
'status': 'not_ready',
|
||||
'initialization_error': self.initialization_error
|
||||
}
|
||||
|
||||
# 문서 개수 확인
|
||||
try:
|
||||
total_documents = self.collection.count()
|
||||
except Exception as e:
|
||||
logger.warning(f"문서 개수 확인 실패: {e}")
|
||||
total_documents = 0
|
||||
|
||||
# 고유 가게 수 확인 (store_id 기준)
|
||||
try:
|
||||
# 모든 메타데이터에서 고유 store_id 추출
|
||||
all_metadata = self.collection.get()
|
||||
store_ids = set()
|
||||
|
||||
if all_metadata.get('metadatas'):
|
||||
for metadata in all_metadata['metadatas']:
|
||||
store_id = metadata.get('store_id')
|
||||
if store_id:
|
||||
store_ids.add(store_id)
|
||||
|
||||
total_stores = len(store_ids)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"가게 수 확인 실패: {e}")
|
||||
total_stores = 0
|
||||
|
||||
return {
|
||||
'collection_name': self.collection_name,
|
||||
'total_documents': total_documents,
|
||||
'total_stores': total_stores,
|
||||
'db_path': self.db_path,
|
||||
'status': 'ready'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DB 상태 확인 실패: {e}")
|
||||
return {
|
||||
'collection_name': self.collection_name,
|
||||
'total_documents': 0,
|
||||
'total_stores': 0,
|
||||
'db_path': self.db_path,
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def search_similar_cases(self, store_id: str, context: str) -> Optional[str]:
|
||||
"""유사한 케이스를 검색합니다."""
|
||||
try:
|
||||
if not self.is_ready():
|
||||
logger.warning("VectorService가 준비되지 않음")
|
||||
return None
|
||||
|
||||
# 컨텍스트 기반 유사 검색
|
||||
try:
|
||||
# 검색 쿼리를 임베딩으로 변환
|
||||
query_embedding = self.embedding_model.encode(context)
|
||||
query_embedding = query_embedding.tolist()
|
||||
|
||||
# 유사한 문서 검색 (상위 5개)
|
||||
results = self.collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=5,
|
||||
include=['documents', 'metadatas']
|
||||
)
|
||||
|
||||
if not results or not results.get('documents') or not results['documents'][0]:
|
||||
logger.info("유사 케이스를 찾을 수 없음")
|
||||
return None
|
||||
|
||||
# 컨텍스트 조합
|
||||
context_parts = []
|
||||
documents = results['documents'][0]
|
||||
metadatas = results.get('metadatas', [[]])[0]
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
metadata = metadatas[i] if i < len(metadatas) else {}
|
||||
|
||||
# 가게 정보 추가
|
||||
store_name = metadata.get('store_name', 'Unknown')
|
||||
food_category = metadata.get('food_category', 'Unknown')
|
||||
|
||||
context_parts.append(f"[{food_category} - {store_name}]")
|
||||
context_parts.append(doc[:500] + "..." if len(doc) > 500 else doc)
|
||||
context_parts.append("---")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
except Exception as search_error:
|
||||
logger.warning(f"벡터 검색 실패: {search_error}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"유사 케이스 검색 실패: {e}")
|
||||
return None
|
||||
161
vector/app/utils/category_utils.py
Normal file
161
vector/app/utils/category_utils.py
Normal file
@ -0,0 +1,161 @@
|
||||
# app/utils/category_utils.py (수정된 버전)
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
def extract_food_category(category_name: str) -> str:
|
||||
"""
|
||||
카테고리명에서 음식 종류를 추출합니다.
|
||||
'음식점 > 한식 > 육류,고기'에서 '한식'을 추출
|
||||
|
||||
Args:
|
||||
category_name: 전체 카테고리명
|
||||
|
||||
Returns:
|
||||
추출된 음식 종류
|
||||
"""
|
||||
if not category_name:
|
||||
return ""
|
||||
|
||||
# '>' 기준으로 분할하고 마지막 바로 전 요소 반환
|
||||
parts = category_name.split('>')
|
||||
if len(parts) >= 2:
|
||||
food_category = parts[-2].strip() # 마지막 바로 전 값 사용
|
||||
return food_category
|
||||
elif len(parts) == 1:
|
||||
return parts[0].strip() # 하나밖에 없으면 그것을 반환
|
||||
|
||||
return category_name.strip()
|
||||
|
||||
def normalize_category(category: str) -> str:
|
||||
"""
|
||||
카테고리를 정규화합니다.
|
||||
|
||||
Args:
|
||||
category: 원본 카테고리
|
||||
|
||||
Returns:
|
||||
정규화된 카테고리
|
||||
"""
|
||||
if not category:
|
||||
return ""
|
||||
|
||||
# 공백 제거 및 소문자 변환
|
||||
normalized = category.strip().lower()
|
||||
|
||||
# 특수문자 제거 (콤마, 슬래시 등은 유지)
|
||||
normalized = re.sub(r'[^\w가-힣,/\s]', '', normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
def is_similar_category(category1: str, category2: str) -> bool:
|
||||
"""
|
||||
두 카테고리가 유사한지 판단합니다.
|
||||
|
||||
Args:
|
||||
category1: 첫 번째 카테고리
|
||||
category2: 두 번째 카테고리
|
||||
|
||||
Returns:
|
||||
유사 여부
|
||||
"""
|
||||
if not category1 or not category2:
|
||||
return False
|
||||
|
||||
# 정규화
|
||||
norm1 = normalize_category(category1)
|
||||
norm2 = normalize_category(category2)
|
||||
|
||||
# 완전 일치
|
||||
if norm1 == norm2:
|
||||
return True
|
||||
|
||||
# 키워드 기반 유사성 검사
|
||||
keywords1 = set(norm1.replace(',', ' ').replace('/', ' ').split())
|
||||
keywords2 = set(norm2.replace(',', ' ').replace('/', ' ').split())
|
||||
|
||||
# 교집합이 하나 이상 있으면 유사한 것으로 판단
|
||||
common_keywords = keywords1.intersection(keywords2)
|
||||
return len(common_keywords) > 0
|
||||
|
||||
def extract_main_category(category_name: str) -> str:
|
||||
"""
|
||||
메인 카테고리를 추출합니다. (음식점 > 한식 에서 '한식' 추출)
|
||||
|
||||
Args:
|
||||
category_name: 전체 카테고리명
|
||||
|
||||
Returns:
|
||||
메인 카테고리
|
||||
"""
|
||||
if not category_name:
|
||||
return ""
|
||||
|
||||
parts = category_name.split('>')
|
||||
if len(parts) >= 2:
|
||||
return parts[1].strip()
|
||||
elif len(parts) == 1:
|
||||
return parts[0].strip()
|
||||
|
||||
return ""
|
||||
|
||||
def build_search_query(region: str, food_category: str) -> str:
|
||||
"""
|
||||
검색 쿼리를 구성합니다. (수정된 버전 - 지역 정보 제외)
|
||||
|
||||
Args:
|
||||
region: 지역 (사용하지 않음)
|
||||
food_category: 음식 카테고리
|
||||
|
||||
Returns:
|
||||
검색 쿼리 문자열 (음식 카테고리만 포함)
|
||||
"""
|
||||
# 콤마와 슬래시를 공백으로 변경하여 검색 키워드 생성
|
||||
search_keywords = food_category.replace(',', ' ').replace('/', ' ')
|
||||
|
||||
# 불필요한 단어 제거
|
||||
stop_words = ['음식점', '요리', '전문점', '맛집']
|
||||
keywords = []
|
||||
|
||||
for keyword in search_keywords.split():
|
||||
keyword = keyword.strip()
|
||||
if keyword and keyword not in stop_words:
|
||||
keywords.append(keyword)
|
||||
|
||||
# 키워드가 없으면 기본 검색어 사용
|
||||
if not keywords:
|
||||
keywords = ['음식점']
|
||||
|
||||
# 🔧 지역 정보는 포함하지 않고 음식 키워드만 반환
|
||||
query = ' '.join(keywords)
|
||||
return query.strip()
|
||||
|
||||
def clean_food_category_for_search(food_category: str) -> str:
|
||||
"""
|
||||
음식 카테고리를 검색용 키워드로 정리합니다.
|
||||
|
||||
Args:
|
||||
food_category: 원본 음식 카테고리
|
||||
|
||||
Returns:
|
||||
정리된 검색 키워드
|
||||
"""
|
||||
if not food_category:
|
||||
return "음식점"
|
||||
|
||||
# 콤마와 슬래시를 공백으로 변경
|
||||
cleaned = food_category.replace(',', ' ').replace('/', ' ')
|
||||
|
||||
# 불필요한 단어 제거
|
||||
stop_words = ['음식점', '요리', '전문점', '맛집']
|
||||
keywords = []
|
||||
|
||||
for keyword in cleaned.split():
|
||||
keyword = keyword.strip()
|
||||
if keyword and keyword not in stop_words:
|
||||
keywords.append(keyword)
|
||||
|
||||
# 키워드가 없으면 기본 검색어 사용
|
||||
if not keywords:
|
||||
return "음식점"
|
||||
|
||||
return ' '.join(keywords)
|
||||
194
vector/app/utils/data_utils.py
Normal file
194
vector/app/utils/data_utils.py
Normal file
@ -0,0 +1,194 @@
|
||||
# app/utils/data_utils.py
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
def create_store_hash(store_id: str, store_name: str, region: str) -> str:
|
||||
"""
|
||||
가게의 고유 해시를 생성합니다.
|
||||
|
||||
Args:
|
||||
store_id: 가게 ID
|
||||
store_name: 가게명
|
||||
region: 지역
|
||||
|
||||
Returns:
|
||||
생성된 해시값
|
||||
"""
|
||||
combined = f"{store_id}_{store_name}_{region}"
|
||||
return hashlib.md5(combined.encode('utf-8')).hexdigest()
|
||||
|
||||
def combine_store_and_reviews(store_info: Dict[str, Any], reviews: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
가게 정보와 리뷰를 결합하여 JSON 문자열을 생성합니다.
|
||||
|
||||
Args:
|
||||
store_info: 가게 정보
|
||||
reviews: 리뷰 목록
|
||||
|
||||
Returns:
|
||||
결합된 JSON 문자열
|
||||
"""
|
||||
combined_data = {
|
||||
"store_info": store_info,
|
||||
"reviews": reviews,
|
||||
"review_summary": generate_review_summary(reviews),
|
||||
"combined_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return json.dumps(combined_data, ensure_ascii=False, separators=(',', ':'))
|
||||
|
||||
def generate_review_summary(reviews: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
리뷰 목록에서 요약 정보를 생성합니다.
|
||||
|
||||
Args:
|
||||
reviews: 리뷰 목록
|
||||
|
||||
Returns:
|
||||
리뷰 요약 정보
|
||||
"""
|
||||
if not reviews:
|
||||
return {
|
||||
"total_reviews": 0,
|
||||
"average_rating": 0.0,
|
||||
"rating_distribution": {},
|
||||
"common_keywords": [],
|
||||
"sentiment_summary": {
|
||||
"positive": 0,
|
||||
"neutral": 0,
|
||||
"negative": 0
|
||||
}
|
||||
}
|
||||
|
||||
# 기본 통계
|
||||
total_reviews = len(reviews)
|
||||
ratings = [review.get('rating', 0) for review in reviews if review.get('rating', 0) > 0]
|
||||
average_rating = sum(ratings) / len(ratings) if ratings else 0.0
|
||||
|
||||
# 별점 분포
|
||||
rating_distribution = {}
|
||||
for rating in ratings:
|
||||
rating_distribution[str(rating)] = rating_distribution.get(str(rating), 0) + 1
|
||||
|
||||
# 키워드 추출 (badges 기반)
|
||||
keyword_counts = {}
|
||||
for review in reviews:
|
||||
badges = review.get('badges', [])
|
||||
for badge in badges:
|
||||
keyword_counts[badge] = keyword_counts.get(badge, 0) + 1
|
||||
|
||||
# 상위 키워드 추출
|
||||
common_keywords = sorted(keyword_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
common_keywords = [keyword for keyword, count in common_keywords]
|
||||
|
||||
# 감정 분석 (간단한 별점 기반)
|
||||
sentiment_summary = {
|
||||
"positive": len([r for r in ratings if r >= 4]),
|
||||
"neutral": len([r for r in ratings if r == 3]),
|
||||
"negative": len([r for r in ratings if r <= 2])
|
||||
}
|
||||
|
||||
return {
|
||||
"total_reviews": total_reviews,
|
||||
"average_rating": round(average_rating, 2),
|
||||
"rating_distribution": rating_distribution,
|
||||
"common_keywords": common_keywords,
|
||||
"sentiment_summary": sentiment_summary,
|
||||
"has_recent_reviews": any(
|
||||
review.get('date', '') >= datetime.now().strftime('%Y.%m.%d')
|
||||
for review in reviews[-10:] # 최근 10개 리뷰 확인
|
||||
)
|
||||
}
|
||||
|
||||
def extract_text_for_embedding(store_info: Dict[str, Any], reviews: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
임베딩을 위한 텍스트를 추출합니다.
|
||||
|
||||
Args:
|
||||
store_info: 가게 정보
|
||||
reviews: 리뷰 목록
|
||||
|
||||
Returns:
|
||||
임베딩용 텍스트
|
||||
"""
|
||||
# 가게 기본 정보
|
||||
store_text = f"가게명: {store_info.get('place_name', '')}\n"
|
||||
store_text += f"카테고리: {store_info.get('category_name', '')}\n"
|
||||
store_text += f"주소: {store_info.get('address_name', '')}\n"
|
||||
|
||||
# 리뷰 내용 요약
|
||||
review_contents = []
|
||||
review_keywords = []
|
||||
|
||||
for review in reviews[:20]: # 최근 20개 리뷰만 사용
|
||||
content = review.get('content', '').strip()
|
||||
if content:
|
||||
review_contents.append(content)
|
||||
|
||||
badges = review.get('badges', [])
|
||||
review_keywords.extend(badges)
|
||||
|
||||
# 리뷰 텍스트 조합
|
||||
if review_contents:
|
||||
store_text += f"리뷰 내용: {' '.join(review_contents[:10])}\n" # 최대 10개 리뷰
|
||||
|
||||
# 키워드 조합
|
||||
if review_keywords:
|
||||
unique_keywords = list(set(review_keywords))
|
||||
store_text += f"키워드: {', '.join(unique_keywords[:15])}\n" # 최대 15개 키워드
|
||||
|
||||
return store_text.strip()
|
||||
|
||||
def create_metadata(store_info: Dict[str, Any], food_category: str, region: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Vector DB용 메타데이터를 생성합니다.
|
||||
|
||||
Args:
|
||||
store_info: 가게 정보
|
||||
food_category: 음식 카테고리
|
||||
region: 지역
|
||||
|
||||
Returns:
|
||||
메타데이터 딕셔너리
|
||||
"""
|
||||
return {
|
||||
"store_id": store_info.get('id', ''),
|
||||
"store_name": store_info.get('place_name', ''),
|
||||
"food_category": food_category,
|
||||
"region": region,
|
||||
"category_name": store_info.get('category_name', ''),
|
||||
"address": store_info.get('address_name', ''),
|
||||
"phone": store_info.get('phone', ''),
|
||||
"place_url": store_info.get('place_url', ''),
|
||||
"x": store_info.get('x', ''), # 좌표를 개별 키로 분리
|
||||
"y": store_info.get('y', ''), # 좌표를 개별 키로 분리
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def is_duplicate_store(metadata1: Dict[str, Any], metadata2: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
두 가게가 중복인지 확인합니다.
|
||||
|
||||
Args:
|
||||
metadata1: 첫 번째 가게 메타데이터
|
||||
metadata2: 두 번째 가게 메타데이터
|
||||
|
||||
Returns:
|
||||
중복 여부
|
||||
"""
|
||||
# Store ID 기준 확인
|
||||
if metadata1.get('store_id') and metadata2.get('store_id'):
|
||||
return metadata1['store_id'] == metadata2['store_id']
|
||||
|
||||
# 가게명 + 주소 기준 확인
|
||||
name1 = metadata1.get('store_name', '').strip()
|
||||
name2 = metadata2.get('store_name', '').strip()
|
||||
addr1 = metadata1.get('address', '').strip()
|
||||
addr2 = metadata2.get('address', '').strip()
|
||||
|
||||
if name1 and name2 and addr1 and addr2:
|
||||
return name1 == name2 and addr1 == addr2
|
||||
|
||||
return False
|
||||
282
vector/build-base.sh
Executable file
282
vector/build-base.sh
Executable file
@ -0,0 +1,282 @@
|
||||
#!/bin/bash
|
||||
# build-base.sh - Poetry 기반 Vector DB API Base Image 빌드 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
BASE_IMAGE_NAME="vector-api-base"
|
||||
BASE_IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_BASE_IMAGE_NAME="${REGISTRY}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_BASE_IMAGE_NAME="${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 파일 경로
|
||||
BASE_DOCKERFILE_PATH="deployment/container/Dockerfile-base"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
# 색상 설정
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${CYAN}ℹ️ $1${NC}"; }
|
||||
log_success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_error() { echo -e "${RED}❌ $1${NC}"; }
|
||||
|
||||
echo "========================================================"
|
||||
echo "🚀 Poetry 기반 Vector DB API Base Image 빌드"
|
||||
echo "========================================================"
|
||||
echo "Base 이미지명: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
echo "📦 포함될 구성요소:"
|
||||
echo " ✅ Python 3.11 + Poetry"
|
||||
echo " ✅ 모든 AI/ML 의존성 (NumPy, PyTorch, Transformers)"
|
||||
echo " ✅ Vector DB (ChromaDB, Chroma)"
|
||||
echo " ✅ 웹 프레임워크 (FastAPI, Uvicorn)"
|
||||
echo " ✅ Claude API (Anthropic)"
|
||||
echo ""
|
||||
|
||||
# 시작 시간 기록
|
||||
BUILD_START=$(date +%s)
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [BASE_IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v2.0.0 # 로컬 빌드만"
|
||||
echo " $0 v2.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
echo ""
|
||||
echo "💡 참고:"
|
||||
echo " - Base Image는 한 번만 빌드하면 재사용 가능"
|
||||
echo " - 모든 Python 의존성이 포함되어 Service Image 빌드 시간 단축"
|
||||
echo " - Poetry 환경으로 패키지 관리"
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
log_info "Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
log_error "Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
log_error "jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
log_error "Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
log_error "ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
log_error "ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
log_error "ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
log_error "ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 파일 확인
|
||||
log_info "필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "${BASE_DOCKERFILE_PATH}" ]; then
|
||||
log_error "${BASE_DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "setup.sh" ]; then
|
||||
log_error "setup.sh 파일을 찾을 수 없습니다."
|
||||
log_error "Poetry 설치 스크립트(setup_poetry_vector.sh)를 setup.sh로 저장해주세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "모든 필수 파일 확인 완료"
|
||||
echo "📄 Base Dockerfile: ${BASE_DOCKERFILE_PATH}"
|
||||
echo "📄 Poetry 설치 스크립트: setup.sh"
|
||||
|
||||
# setup.sh 실행 가능 확인
|
||||
if [ ! -x "setup.sh" ]; then
|
||||
log_warning "setup.sh 파일에 실행 권한이 없습니다. 실행 권한 추가 중..."
|
||||
chmod +x setup.sh
|
||||
log_success "실행 권한 추가 완료"
|
||||
fi
|
||||
|
||||
# 시스템 정보 확인
|
||||
log_info "시스템 정보 확인..."
|
||||
echo " - OS: $(lsb_release -d 2>/dev/null | cut -f2 || echo 'Unknown')"
|
||||
echo " - CPU Cores: $(nproc)"
|
||||
echo " - Available Memory: $(free -h | awk '/^Mem:/ {print $7}' 2>/dev/null || echo 'Unknown')"
|
||||
echo " - Docker Version: $(docker --version)"
|
||||
echo " - Build Context: ${BUILD_CONTEXT}"
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
log_info "Poetry 기반 Base Image 빌드 시작..."
|
||||
echo " - 예상 빌드 시간: 15-25분 (Poetry 의존성 해결 포함)"
|
||||
echo " - 모든 Python 패키지가 Poetry로 설치됨"
|
||||
echo " - 멀티스테이지 빌드로 크기 최적화"
|
||||
echo ""
|
||||
echo "🔨 빌드 명령어:"
|
||||
echo "docker build -t \"${FULL_BASE_IMAGE_NAME}\" -f \"${BASE_DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
echo ""
|
||||
|
||||
docker build -t "${FULL_BASE_IMAGE_NAME}" -f "${BASE_DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
# 빌드 종료 시간 계산
|
||||
BUILD_END=$(date +%s)
|
||||
BUILD_TIME=$((BUILD_END - BUILD_START))
|
||||
BUILD_MINUTES=$((BUILD_TIME / 60))
|
||||
BUILD_SECONDS=$((BUILD_TIME % 60))
|
||||
|
||||
log_success "Poetry 기반 Base Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "⏱️ 총 빌드 시간: ${BUILD_MINUTES}분 ${BUILD_SECONDS}초"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
log_info "Base Image 정보:"
|
||||
docker images "${FULL_BASE_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
log_info "latest 태그 생성 중..."
|
||||
docker tag "${FULL_BASE_IMAGE_NAME}" "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
log_success "latest 태그 생성 완료: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
log_info "ACR에 Base Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_BASE_IMAGE_NAME}"
|
||||
docker push "${FULL_BASE_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "Base Image 푸시 성공"
|
||||
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_error "Base Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_success "🎉 Poetry 기반 Base Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Base Image 빌드: ${FULL_BASE_IMAGE_NAME}"
|
||||
if [ "${BASE_IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${BASE_IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
echo " ✅ Poetry 환경 구성 완료"
|
||||
echo " ✅ 모든 AI/ML 의존성 설치 완료"
|
||||
|
||||
echo ""
|
||||
echo "📝 다음 단계:"
|
||||
echo " 이제 Service Image를 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./build.sh v1.0.0 ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./build.sh v1.0.0"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "💡 최적화 포인트:"
|
||||
echo " ✅ Base Image: 한 번만 빌드 (주기적 업데이트)"
|
||||
echo " ✅ Service Image: 매번 빠른 빌드 (앱 코드만)"
|
||||
echo " ✅ Poetry 환경: 의존성 충돌 자동 해결"
|
||||
echo " ✅ 개발 생산성: 빌드 대기시간 95% 단축"
|
||||
|
||||
else
|
||||
log_error "Base Image 빌드 실패!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Base Image 빌드 프로세스 완료 - $(date)"
|
||||
|
||||
337
vector/build.sh
Executable file
337
vector/build.sh
Executable file
@ -0,0 +1,337 @@
|
||||
#!/bin/bash
|
||||
# build.sh - Poetry 기반 Vector DB API Service Image 빌드 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
IMAGE_NAME="vector-api"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
ACR_NAME="${2:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${3:-rg-digitalgarage-03}"
|
||||
BASE_IMAGE_TAG="${4:-latest}"
|
||||
|
||||
# ACR URL 자동 구성
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
REGISTRY="${ACR_NAME}.azurecr.io"
|
||||
FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="${REGISTRY}/vector-api-base:${BASE_IMAGE_TAG}"
|
||||
else
|
||||
FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
BASE_IMAGE="vector-api-base:${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# 고정된 파일 경로
|
||||
DOCKERFILE_PATH="deployment/container/Dockerfile"
|
||||
BUILD_CONTEXT="."
|
||||
|
||||
# 색상 설정
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${CYAN}ℹ️ $1${NC}"; }
|
||||
log_success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_error() { echo -e "${RED}❌ $1${NC}"; }
|
||||
|
||||
echo "========================================================"
|
||||
echo "🚀 Poetry 기반 Vector DB API Service Image 빌드"
|
||||
echo "========================================================"
|
||||
echo "Service 이미지명: ${FULL_IMAGE_NAME}"
|
||||
echo "Base 이미지: ${BASE_IMAGE}"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo "ACR 이름: ${ACR_NAME}"
|
||||
echo "리소스 그룹: ${RESOURCE_GROUP}"
|
||||
fi
|
||||
echo "빌드 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
# 시작 시간 기록
|
||||
BUILD_START=$(date +%s)
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [IMAGE_TAG] [ACR_NAME] [RESOURCE_GROUP] [BASE_IMAGE_TAG]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " IMAGE_TAG : Service 이미지 태그 (기본값: latest)"
|
||||
echo " ACR_NAME : Azure Container Registry 이름"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹"
|
||||
echo " BASE_IMAGE_TAG: Base 이미지 태그 (기본값: latest)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 v1.0.0 # 로컬 빌드만"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 # ACR 빌드 + 푸시"
|
||||
echo " $0 v1.0.0 acrdigitalgarage01 rg-digitalgarage-03 v2.0.0 # 특정 Base Image 사용"
|
||||
echo ""
|
||||
echo "전제조건:"
|
||||
echo " Poetry 기반 Base Image가 먼저 빌드되어 있어야 합니다:"
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG} [ACR_NAME] [RESOURCE_GROUP]"
|
||||
echo ""
|
||||
echo "💡 장점:"
|
||||
echo " - 빠른 빌드: 앱 코드만 복사 (30초~2분)"
|
||||
echo " - Poetry 환경: 의존성 관리 최적화"
|
||||
echo " - 캐시 활용: Base Image 재사용으로 효율성 극대화"
|
||||
}
|
||||
|
||||
# ACR 로그인 함수
|
||||
acr_login() {
|
||||
local acr_name="$1"
|
||||
local resource_group="$2"
|
||||
|
||||
log_info "Azure Container Registry 로그인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
log_error "Azure CLI (az)가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
log_error "jq가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
log_error "Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local credential_json
|
||||
credential_json=$(az acr credential show --name "${acr_name}" --resource-group "${resource_group}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
log_error "ACR credential 조회 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local username
|
||||
local password
|
||||
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
log_error "ACR credential 파싱 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${password}" | docker login "${REGISTRY}" -u "${username}" --password-stdin
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "ACR 로그인 성공!"
|
||||
return 0
|
||||
else
|
||||
log_error "ACR 로그인 실패"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${ACR_NAME}" ] && [ -z "${RESOURCE_GROUP}" ]; then
|
||||
log_error "ACR_NAME이 제공된 경우 RESOURCE_GROUP도 필요합니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 파일 확인
|
||||
log_info "필수 파일 확인 중..."
|
||||
|
||||
if [ ! -f "app/main.py" ]; then
|
||||
log_error "app/main.py 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${DOCKERFILE_PATH}" ]; then
|
||||
log_error "${DOCKERFILE_PATH} 파일을 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "모든 필수 파일이 확인되었습니다."
|
||||
echo "📄 Service Dockerfile: ${DOCKERFILE_PATH}"
|
||||
echo "📄 메인 애플리케이션: app/main.py"
|
||||
echo "🏗️ 빌드 컨텍스트: ${BUILD_CONTEXT}"
|
||||
|
||||
# Base Image 존재 확인
|
||||
echo ""
|
||||
log_info "Poetry 기반 Base Image 확인 중..."
|
||||
if docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
|
||||
log_success "Base Image 확인됨: ${BASE_IMAGE}"
|
||||
|
||||
# Base Image에서 Poetry 환경 확인
|
||||
log_info "Base Image Poetry 환경 검증 중..."
|
||||
if docker run --rm "${BASE_IMAGE}" poetry --version >/dev/null 2>&1; then
|
||||
POETRY_VERSION=$(docker run --rm "${BASE_IMAGE}" poetry --version 2>/dev/null)
|
||||
log_success "Poetry 환경 확인됨: ${POETRY_VERSION}"
|
||||
else
|
||||
log_warning "Base Image에서 Poetry를 찾을 수 없습니다"
|
||||
fi
|
||||
|
||||
# Base Image 크기 확인
|
||||
BASE_SIZE=$(docker images "${BASE_IMAGE}" --format "{{.Size}}")
|
||||
echo "📊 Base Image 크기: ${BASE_SIZE}"
|
||||
|
||||
else
|
||||
log_error "Base Image를 찾을 수 없습니다: ${BASE_IMAGE}"
|
||||
echo ""
|
||||
echo "📋 해결 방법:"
|
||||
echo " Base Image를 먼저 빌드하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG} ${ACR_NAME} ${RESOURCE_GROUP}"
|
||||
else
|
||||
echo " ./build-base.sh ${BASE_IMAGE_TAG}"
|
||||
fi
|
||||
echo ""
|
||||
echo " 또는 ACR에서 Base Image를 풀하세요:"
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " docker pull ${BASE_IMAGE}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ACR 로그인 수행
|
||||
if [ -n "${ACR_NAME}" ] && [ -n "${RESOURCE_GROUP}" ]; then
|
||||
echo ""
|
||||
acr_login "${ACR_NAME}" "${RESOURCE_GROUP}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Docker 빌드
|
||||
log_info "Poetry 기반 Service Image 빌드 시작..."
|
||||
echo " - 예상 빌드 시간: 30초~2분 (앱 코드만 복사)"
|
||||
echo " - Base Image 재사용으로 빠른 빌드"
|
||||
echo " - Poetry 환경 상속"
|
||||
echo ""
|
||||
echo "🔨 빌드 명령어:"
|
||||
echo "docker build --build-arg BASE_IMAGE=\"${BASE_IMAGE}\" -t \"${FULL_IMAGE_NAME}\" -f \"${DOCKERFILE_PATH}\" \"${BUILD_CONTEXT}\""
|
||||
echo ""
|
||||
|
||||
docker build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" -f "${DOCKERFILE_PATH}" "${BUILD_CONTEXT}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
# 빌드 종료 시간 계산
|
||||
BUILD_END=$(date +%s)
|
||||
BUILD_TIME=$((BUILD_END - BUILD_START))
|
||||
BUILD_MINUTES=$((BUILD_TIME / 60))
|
||||
BUILD_SECONDS=$((BUILD_TIME % 60))
|
||||
|
||||
log_success "Service Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "⏱️ 총 빌드 시간: ${BUILD_MINUTES}분 ${BUILD_SECONDS}초"
|
||||
|
||||
# 이미지 정보 표시
|
||||
echo ""
|
||||
log_info "Service Image 정보:"
|
||||
docker images "${FULL_IMAGE_NAME}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"
|
||||
|
||||
# 🧪 빌드된 이미지 검증
|
||||
log_info "Service Image 검증 중..."
|
||||
echo "🔍 Poetry 환경 및 앱 코드 확인:"
|
||||
|
||||
docker run --rm "${FULL_IMAGE_NAME}" sh -c "
|
||||
echo '✅ Poetry 버전:' && poetry --version &&
|
||||
echo '✅ Python 버전:' && poetry run python --version &&
|
||||
echo '✅ 설치된 패키지 수:' && poetry show | wc -l &&
|
||||
echo '✅ 앱 코드 확인:' && ls -la /app/app/ 2>/dev/null | head -5
|
||||
" 2>/dev/null || log_warning "일부 검증에 실패했습니다."
|
||||
|
||||
# latest 태그 추가 생성
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo ""
|
||||
log_info "latest 태그 생성 중..."
|
||||
docker tag "${FULL_IMAGE_NAME}" "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
log_success "latest 태그 생성 완료: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
# ACR 푸시
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo ""
|
||||
log_info "ACR에 Service Image 푸시 중..."
|
||||
|
||||
echo "📤 푸시 중: ${FULL_IMAGE_NAME}"
|
||||
docker push "${FULL_IMAGE_NAME}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "Service Image 푸시 성공"
|
||||
|
||||
if [ "${IMAGE_TAG}" != "latest" ]; then
|
||||
echo "📤 푸시 중: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_success "latest 태그 푸시 성공"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_error "Service Image 푸시 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_success "🎉 Poetry 기반 Service Image 빌드 완료!"
|
||||
echo ""
|
||||
echo "📋 완료된 작업:"
|
||||
echo " ✅ Service Image 빌드: ${FULL_IMAGE_NAME}"
|
||||
echo " ✅ Base Image 활용: ${BASE_IMAGE}"
|
||||
if [ "${IMAGE_TAG}" != "latest" ] && [ -n "${REGISTRY}" ]; then
|
||||
echo " ✅ latest 태그: ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
if [ -n "${ACR_NAME}" ]; then
|
||||
echo " ✅ ACR 푸시 완료"
|
||||
fi
|
||||
echo " ✅ Poetry 환경 상속"
|
||||
|
||||
echo ""
|
||||
echo "🧪 테스트 명령어:"
|
||||
echo " # 로컬 실행 테스트"
|
||||
echo " docker run --rm -p 8000:8000 ${FULL_IMAGE_NAME}"
|
||||
echo ""
|
||||
echo " # Poetry 환경 확인"
|
||||
echo " docker run --rm ${FULL_IMAGE_NAME} poetry show"
|
||||
echo ""
|
||||
echo " # 앱 헬스체크"
|
||||
echo " docker run --rm ${FULL_IMAGE_NAME} poetry run python -c \"import app.main; print('✅ 앱 로드 성공')\""
|
||||
|
||||
echo ""
|
||||
echo "📝 다음 단계:"
|
||||
echo " 1. 로컬 테스트:"
|
||||
echo " docker run --rm -p 8000:8000 ${FULL_IMAGE_NAME}"
|
||||
echo ""
|
||||
echo " 2. Kubernetes 배포:"
|
||||
echo " kubectl apply -f deployment/manifests/"
|
||||
echo ""
|
||||
echo " 3. 스케일링:"
|
||||
echo " kubectl scale deployment vector-api --replicas=3"
|
||||
|
||||
echo ""
|
||||
echo "💡 성능 최적화:"
|
||||
echo " ✅ 빠른 빌드: Base Image 재사용으로 빌드 시간 95% 단축"
|
||||
echo " ✅ 효율적 캐시: Poetry 환경은 Base Image에서 관리"
|
||||
echo " ✅ 작은 이미지: 앱 코드만 포함하여 배포 이미지 최소화"
|
||||
echo " ✅ 개발 친화적: Poetry로 일관된 의존성 관리"
|
||||
|
||||
else
|
||||
log_error "Service Image 빌드 실패!"
|
||||
echo ""
|
||||
echo "🔍 문제 해결:"
|
||||
echo " 1. Base Image 확인: docker images | grep vector-api-base"
|
||||
echo " 2. Dockerfile 확인: cat ${DOCKERFILE_PATH}"
|
||||
echo " 3. 앱 코드 확인: ls -la app/"
|
||||
echo " 4. 빌드 로그 확인: 위의 에러 메시지 참조"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Service Image 빌드 프로세스 완료 - $(date)"
|
||||
|
||||
236
vector/create-imagepullsecret.sh
Executable file
236
vector/create-imagepullsecret.sh
Executable file
@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
# create-imagepullsecret.sh - ACR Image Pull Secret 생성 스크립트
|
||||
|
||||
set -e
|
||||
|
||||
# 변수 설정
|
||||
ACR_NAME="${1:-acrdigitalgarage03}"
|
||||
RESOURCE_GROUP="${2:-rg-digitalgarage-03}"
|
||||
SECRET_NAME="${3:-acr-secret}"
|
||||
|
||||
echo "====================================================="
|
||||
echo " ACR Image Pull Secret 생성 (Vector API)"
|
||||
echo "====================================================="
|
||||
|
||||
# 사용법 표시 함수
|
||||
show_usage() {
|
||||
echo "사용법:"
|
||||
echo " $0 [ACR_NAME] [RESOURCE_GROUP] [SECRET_NAME]"
|
||||
echo ""
|
||||
echo "파라미터:"
|
||||
echo " ACR_NAME : Azure Container Registry 이름 (필수)"
|
||||
echo " RESOURCE_GROUP: Azure 리소스 그룹 (필수)"
|
||||
echo " SECRET_NAME : Secret 이름 (기본값: acr-secret)"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03"
|
||||
echo " $0 acrdigitalgarage01 rg-digitalgarage-03 acr-prod-secret"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 파라미터 검증
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${ACR_NAME}" ] || [ -z "${RESOURCE_GROUP}" ]; then
|
||||
echo "❌ ACR_NAME과 RESOURCE_GROUP는 필수 파라미터입니다."
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 필수 도구 확인
|
||||
echo "🔧 필수 도구 확인 중..."
|
||||
|
||||
if ! command -v az &> /dev/null; then
|
||||
echo "❌ Azure CLI (az)가 설치되지 않았습니다."
|
||||
echo "설치 방법: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "❌ kubectl이 설치되지 않았습니다."
|
||||
echo "설치 방법: https://kubernetes.io/docs/tasks/tools/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq가 설치되지 않았습니다."
|
||||
echo "설치 방법: sudo apt-get install jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 필수 도구 확인 완료"
|
||||
|
||||
# Azure 로그인 확인
|
||||
echo ""
|
||||
echo "🔐 Azure 로그인 상태 확인 중..."
|
||||
|
||||
if ! az account show &> /dev/null; then
|
||||
echo "❌ Azure에 로그인되지 않았습니다."
|
||||
echo "로그인 명령: az login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_SUBSCRIPTION=$(az account show --query name -o tsv)
|
||||
echo "✅ Azure 로그인 확인됨"
|
||||
echo " 현재 구독: ${CURRENT_SUBSCRIPTION}"
|
||||
|
||||
# Kubernetes 클러스터 연결 확인
|
||||
echo ""
|
||||
echo "☸️ Kubernetes 클러스터 연결 확인 중..."
|
||||
|
||||
if ! kubectl cluster-info &> /dev/null; then
|
||||
echo "❌ Kubernetes 클러스터에 연결되지 않았습니다."
|
||||
echo "클러스터 연결 방법:"
|
||||
echo " az aks get-credentials --resource-group ${RESOURCE_GROUP} --name <AKS_CLUSTER_NAME>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_CONTEXT=$(kubectl config current-context)
|
||||
echo "✅ Kubernetes 클러스터 연결 확인됨"
|
||||
echo " 현재 컨텍스트: ${CURRENT_CONTEXT}"
|
||||
|
||||
# ACR 정보 설정
|
||||
REGISTRY_URL="${ACR_NAME}.azurecr.io"
|
||||
|
||||
echo ""
|
||||
echo "📋 설정 정보:"
|
||||
echo " ACR 이름: ${ACR_NAME}"
|
||||
echo " 레지스트리 URL: ${REGISTRY_URL}"
|
||||
echo " 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " Secret 이름: ${SECRET_NAME}"
|
||||
|
||||
# ACR 존재 확인
|
||||
echo ""
|
||||
echo "🏪 ACR 존재 확인 중..."
|
||||
|
||||
if ! az acr show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" &> /dev/null; then
|
||||
echo "❌ ACR을 찾을 수 없습니다."
|
||||
echo "확인 사항:"
|
||||
echo " - ACR 이름: ${ACR_NAME}"
|
||||
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " - ACR이 해당 리소스 그룹에 존재하는지 확인"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ ACR 존재 확인됨: ${ACR_NAME}"
|
||||
|
||||
# ACR credential 조회
|
||||
echo ""
|
||||
echo "🔑 ACR credential 조회 중..."
|
||||
|
||||
credential_json=$(az acr credential show --name "${ACR_NAME}" --resource-group "${RESOURCE_GROUP}" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ ACR credential 조회 실패"
|
||||
echo "확인 사항:"
|
||||
echo " - ACR 이름: ${ACR_NAME}"
|
||||
echo " - 리소스 그룹: ${RESOURCE_GROUP}"
|
||||
echo " - ACR에 대한 권한이 있는지 확인"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# JSON에서 username과 password 추출
|
||||
username=$(echo "${credential_json}" | jq -r '.username')
|
||||
password=$(echo "${credential_json}" | jq -r '.passwords[0].value')
|
||||
|
||||
if [ -z "${username}" ] || [ -z "${password}" ] || [ "${username}" == "null" ] || [ "${password}" == "null" ]; then
|
||||
echo "❌ ACR credential 파싱 실패"
|
||||
echo "credential JSON:"
|
||||
echo "${credential_json}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ ACR credential 조회 성공"
|
||||
echo " 사용자명: ${username}"
|
||||
echo " 비밀번호: ${password:0:10}..."
|
||||
|
||||
# 기존 Secret 확인 및 삭제
|
||||
echo ""
|
||||
echo "🔍 기존 Secret 확인 중..."
|
||||
|
||||
if kubectl get secret "${SECRET_NAME}" &> /dev/null; then
|
||||
echo "🗑️ 기존 Secret 삭제 중..."
|
||||
kubectl delete secret "${SECRET_NAME}"
|
||||
echo "✅ 기존 Secret 삭제 완료"
|
||||
else
|
||||
echo "✅ 기존 Secret 없음"
|
||||
fi
|
||||
|
||||
# Image Pull Secret 생성
|
||||
echo ""
|
||||
echo "🔐 Image Pull Secret 생성 중..."
|
||||
|
||||
kubectl create secret docker-registry "${SECRET_NAME}" \
|
||||
--docker-server="${REGISTRY_URL}" \
|
||||
--docker-username="${username}" \
|
||||
--docker-password="${password}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Image Pull Secret 생성 성공!"
|
||||
else
|
||||
echo "❌ Image Pull Secret 생성 실패"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Secret 정보 확인
|
||||
echo ""
|
||||
echo "📊 생성된 Secret 정보:"
|
||||
kubectl describe secret "${SECRET_NAME}"
|
||||
|
||||
echo ""
|
||||
echo "🧪 Secret 테스트 중..."
|
||||
|
||||
# Secret이 올바르게 생성되었는지 확인
|
||||
SECRET_TYPE=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.type}')
|
||||
if [ "${SECRET_TYPE}" = "kubernetes.io/dockerconfigjson" ]; then
|
||||
echo "✅ Secret 타입 확인됨: ${SECRET_TYPE}"
|
||||
else
|
||||
echo "❌ Secret 타입 불일치: ${SECRET_TYPE}"
|
||||
fi
|
||||
|
||||
# Registry URL 확인
|
||||
SECRET_REGISTRY=$(kubectl get secret "${SECRET_NAME}" -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq -r ".auths | keys[0]")
|
||||
if [ "${SECRET_REGISTRY}" = "${REGISTRY_URL}" ]; then
|
||||
echo "✅ Registry URL 확인됨: ${SECRET_REGISTRY}"
|
||||
else
|
||||
echo "❌ Registry URL 불일치: ${SECRET_REGISTRY}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 ACR Image Pull Secret 생성 완료!"
|
||||
echo ""
|
||||
echo "📋 사용 방법:"
|
||||
echo ""
|
||||
echo "1. Deployment에서 imagePullSecrets 사용:"
|
||||
echo " spec:"
|
||||
echo " template:"
|
||||
echo " spec:"
|
||||
echo " imagePullSecrets:"
|
||||
echo " - name: ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "2. ServiceAccount에 Secret 연결:"
|
||||
echo " kubectl patch serviceaccount default -p '{\"imagePullSecrets\": [{\"name\": \"${SECRET_NAME}\"}]}'"
|
||||
echo ""
|
||||
echo "3. Secret 확인:"
|
||||
echo " kubectl get secret ${SECRET_NAME}"
|
||||
echo " kubectl describe secret ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "4. Secret 삭제 (필요시):"
|
||||
echo " kubectl delete secret ${SECRET_NAME}"
|
||||
echo ""
|
||||
echo "🔧 다음 단계:"
|
||||
echo "1. Deployment 매니페스트에 imagePullSecrets 추가"
|
||||
echo "2. kubectl apply -f deployment/manifests/deployment.yaml"
|
||||
echo "3. Pod 상태 확인: kubectl get pods"
|
||||
echo ""
|
||||
echo "💡 문제 해결:"
|
||||
echo "- ErrImagePull 오류 시: Secret 이름과 레지스트리 URL 확인"
|
||||
echo "- 권한 오류 시: ACR에 대한 적절한 권한 확인"
|
||||
echo "- 네트워크 오류 시: 클러스터에서 ACR로의 네트워크 연결 확인"
|
||||
|
||||
echo ""
|
||||
echo "✅ Image Pull Secret 설정이 완료되었습니다!"
|
||||
100
vector/deployment/container/Dockerfile
Normal file
100
vector/deployment/container/Dockerfile
Normal file
@ -0,0 +1,100 @@
|
||||
# deployment/container/Dockerfile
|
||||
# Poetry 기반 Vector DB API Service Image - PVC 마운트 충돌 해결
|
||||
|
||||
# Base Image에서 상속 (Poetry 환경 포함)
|
||||
ARG BASE_IMAGE=vector-api-base:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# 메타데이터
|
||||
LABEL maintainer="admin@example.com"
|
||||
LABEL version="1.0.2"
|
||||
LABEL description="Vector DB API Service with Poetry - PVC Mount Fixed"
|
||||
|
||||
# 환경 변수 설정 - Poetry 가상환경 경로 유지
|
||||
ENV HOME=/home/appuser \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
POETRY_VENV_IN_PROJECT=false \
|
||||
POETRY_VIRTUALENVS_CREATE=true \
|
||||
POETRY_VIRTUALENVS_PATH=/opt/pypoetry/venvs \
|
||||
POETRY_CACHE_DIR=/opt/pypoetry/cache \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
PATH="/home/appuser/.local/bin:/opt/pypoetry/venvs/vector-api/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
# root로 전환 (파일 복사 및 권한 설정용)
|
||||
USER root
|
||||
|
||||
# 🔧 Poetry 설정 파일들 복사 (의존성 정보)
|
||||
COPY pyproject.toml poetry.lock* /app/
|
||||
|
||||
# 🚀 애플리케이션 소스 코드 복사
|
||||
COPY app/ /app/app/
|
||||
|
||||
# 📦 Poetry 의존성 설치 (가상환경이 /opt에 생성됨)
|
||||
RUN cd /app && \
|
||||
# Poetry 설정 확인 및 재설정
|
||||
poetry config virtualenvs.in-project false && \
|
||||
poetry config virtualenvs.create true && \
|
||||
poetry config virtualenvs.path /opt/pypoetry/venvs && \
|
||||
poetry config cache-dir /opt/pypoetry/cache && \
|
||||
echo "🔧 Poetry 설정 확인:" && \
|
||||
poetry config --list && \
|
||||
echo "📦 의존성 설치 시작..." && \
|
||||
poetry install --no-dev --no-interaction && \
|
||||
echo "✅ 의존성 설치 완료" && \
|
||||
# 설치된 패키지 확인
|
||||
poetry show | head -10 && \
|
||||
# 가상환경 위치 확인
|
||||
poetry env info && \
|
||||
# 캐시 정리
|
||||
rm -rf $POETRY_CACHE_DIR/cache && \
|
||||
rm -rf /tmp/*
|
||||
|
||||
# 📁 데이터 디렉토리 생성 및 권한 설정
|
||||
RUN mkdir -p /app/data /app/logs /app/vectordb \
|
||||
&& chmod -R 755 /app/data /app/logs /app/vectordb
|
||||
|
||||
# 👤 사용자 및 권한 설정
|
||||
RUN if id "appuser" &>/dev/null; then \
|
||||
chown -R appuser:appuser /app; \
|
||||
chown -R appuser:appuser /opt/pypoetry; \
|
||||
else \
|
||||
echo "appuser가 없어서 root로 실행됩니다"; \
|
||||
fi
|
||||
|
||||
# 🔧 실행 스크립트 생성 (Poetry 가상환경 자동 활성화)
|
||||
RUN cat > /app/start.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
echo "🚀 Vector API 시작 중..."
|
||||
echo "📍 현재 디렉토리: $(pwd)"
|
||||
echo "🐍 Python 위치: $(which python)"
|
||||
echo "📦 Poetry 위치: $(which poetry)"
|
||||
echo "🔧 Poetry 가상환경 정보:"
|
||||
poetry env info
|
||||
echo "📋 설치된 패키지 (일부):"
|
||||
poetry show | head -5
|
||||
echo "🔍 dotenv 모듈 테스트:"
|
||||
poetry run python -c "from dotenv import load_dotenv; print('✅ dotenv 모듈 정상 로드')"
|
||||
echo "🚀 애플리케이션 실행..."
|
||||
exec poetry run python app/main.py
|
||||
EOF
|
||||
|
||||
RUN chmod +x /app/start.sh && \
|
||||
chown appuser:appuser /app/start.sh
|
||||
|
||||
# 🏥 헬스체크 (의존성 확인 포함)
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=60s --retries=3 \
|
||||
CMD poetry run python -c "from dotenv import load_dotenv; import app.main; print('✅ 앱 헬스체크 성공')" || exit 1
|
||||
|
||||
# 🚀 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 📁 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 👤 실행 사용자 설정
|
||||
USER appuser
|
||||
|
||||
# 🎯 애플리케이션 실행 - 스크립트 사용
|
||||
CMD ["/app/start.sh"]
|
||||
|
||||
138
vector/deployment/container/Dockerfile-base
Normal file
138
vector/deployment/container/Dockerfile-base
Normal file
@ -0,0 +1,138 @@
|
||||
# deployment/container/Dockerfile-base
|
||||
# Poetry 기반 Vector DB API Base Image - 홈 디렉토리 사용 (안전한 방식)
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 메타데이터
|
||||
LABEL description="Vector DB API Base Image with Poetry - Home Directory"
|
||||
LABEL version="poetry-home-v1.0"
|
||||
LABEL maintainer="admin@example.com"
|
||||
|
||||
# 환경 변수 설정 - Poetry 가상환경을 홈 디렉토리로 이동
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VENV_IN_PROJECT=false \
|
||||
POETRY_VIRTUALENVS_CREATE=true \
|
||||
POETRY_VIRTUALENVS_PATH=/home/appuser/.cache/pypoetry/venvs \
|
||||
POETRY_CACHE_DIR=/home/appuser/.cache/pypoetry/cache \
|
||||
HF_HUB_CACHE=/app/.cache/huggingface \
|
||||
TRANSFORMERS_CACHE=/app/.cache/transformers \
|
||||
SENTENCE_TRANSFORMERS_HOME=/app/.cache/sentence_transformers
|
||||
|
||||
# 🔧 시스템 패키지 설치
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
curl \
|
||||
wget \
|
||||
ca-certificates \
|
||||
git \
|
||||
sudo \
|
||||
lsb-release \
|
||||
bc \
|
||||
python3.11 \
|
||||
python3.11-venv \
|
||||
python3.11-dev \
|
||||
python3.11-distutils \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# 📦 pip 업그레이드
|
||||
RUN python3.11 -m pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||
|
||||
# 👤 비root 사용자 생성 (Poetry 설치 전에)
|
||||
RUN groupadd -r appuser && \
|
||||
useradd -r -g appuser -d /home/appuser -s /bin/bash appuser && \
|
||||
mkdir -p /home/appuser && \
|
||||
chown -R appuser:appuser /home/appuser
|
||||
|
||||
# 🔧 Poetry 가상환경 디렉토리 생성 (홈 디렉토리 사용)
|
||||
RUN mkdir -p /home/appuser/.cache/pypoetry/venvs \
|
||||
/home/appuser/.cache/pypoetry/cache && \
|
||||
chown -R appuser:appuser /home/appuser/.cache && \
|
||||
chmod -R 755 /home/appuser/.cache
|
||||
|
||||
# 🐍 Poetry를 appuser로 설치
|
||||
USER appuser
|
||||
ENV PATH="/home/appuser/.local/bin:$PATH"
|
||||
|
||||
# appuser 홈 디렉토리에 Poetry 설치
|
||||
RUN curl -sSL https://install.python-poetry.org | python3.11 -
|
||||
|
||||
# Poetry 실행 권한 및 심볼릭 링크 (root 권한 필요)
|
||||
USER root
|
||||
RUN chmod +x /home/appuser/.local/bin/poetry && \
|
||||
ln -sf /home/appuser/.local/bin/poetry /usr/local/bin/poetry && \
|
||||
chown appuser:appuser /home/appuser/.local/bin/poetry
|
||||
|
||||
# appuser로 다시 전환
|
||||
USER appuser
|
||||
|
||||
# 🔧 Poetry 설정 - 가상환경을 홈 디렉토리로 이동
|
||||
RUN poetry config virtualenvs.in-project false && \
|
||||
poetry config virtualenvs.create true && \
|
||||
poetry config virtualenvs.path /home/appuser/.cache/pypoetry/venvs && \
|
||||
poetry config cache-dir /home/appuser/.cache/pypoetry/cache
|
||||
|
||||
# Poetry 버전 확인 및 설정 검증
|
||||
RUN poetry --version && \
|
||||
poetry config --list && \
|
||||
ls -la /home/appuser/.local/bin/poetry && \
|
||||
which poetry
|
||||
|
||||
# 🏗️ 작업 디렉토리 설정 및 권한 조정
|
||||
WORKDIR /app
|
||||
|
||||
# root로 전환하여 디렉토리 소유권 설정
|
||||
USER root
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
# 📋 Poetry 설치 스크립트 복사 및 권한 설정
|
||||
COPY setup.sh /app/setup.sh
|
||||
RUN chmod +x /app/setup.sh && \
|
||||
chown appuser:appuser /app/setup.sh
|
||||
|
||||
# appuser로 전환하여 Poetry 환경 설정
|
||||
USER appuser
|
||||
|
||||
# 🚀 Poetry 환경 설정 및 의존성 설치
|
||||
RUN cd /app && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
./setup.sh --skip-poetry-install --skip-python311-check --force-reinstall
|
||||
|
||||
# 🗂️ 필요한 디렉토리 생성 및 권한 설정
|
||||
USER root
|
||||
RUN mkdir -p /app/.cache/huggingface \
|
||||
/app/.cache/transformers \
|
||||
/app/.cache/sentence_transformers \
|
||||
/app/vectordb \
|
||||
/app/data \
|
||||
/app/logs && \
|
||||
chmod -R 755 /app/.cache /app/vectordb /app/data /app/logs && \
|
||||
chown -R appuser:appuser /app && \
|
||||
# Poetry 가상환경 디렉토리 권한 재확인
|
||||
chown -R appuser:appuser /home/appuser/.cache && \
|
||||
chmod -R 755 /home/appuser/.cache
|
||||
|
||||
# 🧹 캐시 정리
|
||||
RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
# 🚀 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 🏥 간단한 헬스체크 (appuser 권한으로 실행)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD su -c "poetry --version && poetry config virtualenvs.path" appuser || exit 1
|
||||
|
||||
# 👤 최종 사용자 설정
|
||||
USER appuser
|
||||
|
||||
# 🎯 기본 명령어
|
||||
CMD ["poetry", "--version"]
|
||||
|
||||
63
vector/deployment/manifest/configmap.yaml
Normal file
63
vector/deployment/manifest/configmap.yaml
Normal file
@ -0,0 +1,63 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: vector-api-config
|
||||
data:
|
||||
# 🔧 기존 애플리케이션 설정 (유지)
|
||||
APP_TITLE: "음식점 Vector DB 구축 서비스"
|
||||
APP_VERSION: "1.0.0"
|
||||
APP_DESCRIPTION: "소상공인을 위한 AI 기반 경쟁업체 분석 및 액션 추천 시스템"
|
||||
|
||||
# 🔧 기존 서버 설정 (유지)
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
LOG_LEVEL: "debug" # 디버깅을 위해 debug로 변경
|
||||
|
||||
# 🔧 기존 Restaurant API 설정 (K8s 환경, 유지)
|
||||
RESTAURANT_API_HOST: "restaurant-api-service"
|
||||
RESTAURANT_API_PORT: "80"
|
||||
|
||||
# 🔧 기존 Review API 설정 (K8s 환경, 유지)
|
||||
REVIEW_API_HOST: "kakao-review-api-service"
|
||||
REVIEW_API_PORT: "80"
|
||||
|
||||
# 🔧 기존 Claude API 설정 (유지)
|
||||
CLAUDE_MODEL: "claude-sonnet-4-20250514"
|
||||
|
||||
# 🔧 기존 Vector DB 설정 (유지)
|
||||
VECTOR_DB_PATH: "/app/vectordb"
|
||||
VECTOR_DB_COLLECTION: "restaurant_reviews"
|
||||
EMBEDDING_MODEL: "sentence-transformers/all-MiniLM-L6-v2"
|
||||
|
||||
# 🔧 기존 데이터 수집 설정 (유지)
|
||||
MAX_RESTAURANTS_PER_CATEGORY: "50"
|
||||
MAX_REVIEWS_PER_RESTAURANT: "100"
|
||||
REQUEST_DELAY: "0.1"
|
||||
REQUEST_TIMEOUT: "600"
|
||||
|
||||
# 🆕 ChromaDB 최신 버전 호환 설정 추가
|
||||
CHROMA_DB_IMPL: "duckdb+parquet" # SQLite 대신 DuckDB 사용
|
||||
ALLOW_RESET: "True"
|
||||
ANONYMIZED_TELEMETRY: "False"
|
||||
|
||||
# 🆕 Python 최적화 설정
|
||||
PYTHONUNBUFFERED: "1"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
|
||||
# 🆕 캐시 디렉토리 설정
|
||||
HF_HUB_CACHE: "/app/.cache/huggingface"
|
||||
TRANSFORMERS_CACHE: "/app/.cache/transformers"
|
||||
|
||||
# 🆕 FastAPI 설정
|
||||
FASTAPI_ENV: "production"
|
||||
|
||||
# 🆕 Uvicorn 설정
|
||||
UVICORN_HOST: "0.0.0.0"
|
||||
UVICORN_PORT: "8000"
|
||||
UVICORN_LOG_LEVEL: "debug"
|
||||
UVICORN_ACCESS_LOG: "true"
|
||||
|
||||
# 🆕 타임아웃 설정
|
||||
STARTUP_TIMEOUT: "300" # 5분
|
||||
SHUTDOWN_TIMEOUT: "30" # 30초
|
||||
|
||||
164
vector/deployment/manifest/deployment.yaml
Normal file
164
vector/deployment/manifest/deployment.yaml
Normal file
@ -0,0 +1,164 @@
|
||||
# deployment/manifests/deployment.yaml.fixed
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vector-api
|
||||
labels:
|
||||
app: vector-api
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vector-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vector-api
|
||||
spec:
|
||||
# 🔧 볼륨 권한 설정을 위한 initContainer
|
||||
initContainers:
|
||||
- name: volume-permissions
|
||||
image: busybox:1.35
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
echo "=== 볼륨 권한 설정 시작 ==="
|
||||
mkdir -p /app/vectordb
|
||||
chown -R 1000:1000 /app/vectordb
|
||||
chmod -R 755 /app/vectordb
|
||||
echo "=== 볼륨 권한 설정 완료 ==="
|
||||
volumeMounts:
|
||||
- name: vector-db-storage
|
||||
mountPath: /app/vectordb
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
|
||||
containers:
|
||||
- name: vector-api
|
||||
image: acrdigitalgarage03.azurecr.io/vector-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
# 🔧 보안 컨텍스트
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
# 🔧 리소스 설정
|
||||
resources:
|
||||
requests:
|
||||
memory: "4Gi"
|
||||
cpu: "1000m"
|
||||
limits:
|
||||
memory: "8Gi"
|
||||
cpu: "2000m"
|
||||
|
||||
# 🏥 헬스체크 설정
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
# 📂 볼륨 마운트
|
||||
volumeMounts:
|
||||
- name: vector-db-storage
|
||||
mountPath: /app/vectordb
|
||||
|
||||
# ConfigMap 환경 변수
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: vector-api-config
|
||||
|
||||
# 🌍 환경변수 설정 (인증 필드 제거)
|
||||
env:
|
||||
- name: PYTHONUNBUFFERED
|
||||
value: "1"
|
||||
- name: PYTHONDONTWRITEBYTECODE
|
||||
value: "1"
|
||||
# 🔧 ChromaDB 기본 설정 (인증 필드 제거)
|
||||
- name: ANONYMIZED_TELEMETRY
|
||||
value: "False"
|
||||
- name: CHROMA_DB_IMPL
|
||||
value: "duckdb+parquet"
|
||||
- name: ALLOW_RESET
|
||||
value: "True"
|
||||
# 🔧 로그 레벨
|
||||
- name: LOG_LEVEL
|
||||
value: "info"
|
||||
# 🔧 Claude API (ConfigMap에서 가져오기)
|
||||
- name: CLAUDE_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: vector-api-secret
|
||||
key: CLAUDE_API_KEY
|
||||
- name: CLAUDE_MODEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-api-config
|
||||
key: CLAUDE_MODEL
|
||||
# 🔧 기타 설정 (ConfigMap에서 가져오기)
|
||||
- name: APP_TITLE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-api-config
|
||||
key: APP_TITLE
|
||||
- name: APP_VERSION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: vector-api-config
|
||||
key: APP_VERSION
|
||||
|
||||
# 📦 볼륨 설정
|
||||
volumes:
|
||||
- name: vector-db-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: vector-db-pvc
|
||||
|
||||
# 🔐 이미지 Pull Secret
|
||||
imagePullSecrets:
|
||||
- name: acr-secret
|
||||
|
||||
# 🎯 노드 선택 및 배치 설정
|
||||
nodeSelector:
|
||||
agentpool: aipool
|
||||
|
||||
tolerations:
|
||||
- key: "dedicated"
|
||||
operator: "Equal"
|
||||
value: "aipool"
|
||||
effect: "NoSchedule"
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values:
|
||||
- vector-api
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
restartPolicy: Always
|
||||
dnsPolicy: ClusterFirst
|
||||
|
||||
39
vector/deployment/manifest/ingress.yaml
Normal file
39
vector/deployment/manifest/ingress.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
# deployment/manifests/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vector-api-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
# Vector DB 구축 시간을 고려한 긴 타임아웃 설정
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/client-body-timeout: "1800"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
||||
# CORS 설정
|
||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
# 환경에 맞게 호스트명 수정 필요
|
||||
- host: vector-api.20.249.191.180.nip.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vector-api-service
|
||||
port:
|
||||
number: 80
|
||||
# TLS 설정 (HTTPS 필요시 주석 해제)
|
||||
# tls:
|
||||
# - hosts:
|
||||
# - vector-api.example.com
|
||||
# secretName: vector-api-tls
|
||||
|
||||
18
vector/deployment/manifest/pvc.yaml
Normal file
18
vector/deployment/manifest/pvc.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
# deployment/manifests/pvc.yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vector-db-pvc
|
||||
labels:
|
||||
app: vector-api
|
||||
component: storage
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
storageClassName: managed
|
||||
# 선택적: 특정 PV에 바인딩하려는 경우
|
||||
# volumeName: vector-db-pv
|
||||
|
||||
11
vector/deployment/manifest/secret.yaml
Normal file
11
vector/deployment/manifest/secret.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
# deployment/manifests/secret.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: vector-api-secret
|
||||
type: Opaque
|
||||
data:
|
||||
# Claude API 키 (Base64 인코딩 필요)
|
||||
# echo -n "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" | base64
|
||||
CLAUDE_API_KEY: c2stYW50LWFwaTAzLUVGM1ZocXJJUkVmY3hOa1V3ZkdENDluZ0k1SGZhcTUwd3c4WGZMd0psckR6akczdzNPSHRYTzFBZEltczJuRng2cmc4bk84cWhnMnFwUU01WFJnLTQ1SDdIQUFB
|
||||
|
||||
17
vector/deployment/manifest/service.yaml
Normal file
17
vector/deployment/manifest/service.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
# deployment/manifests/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vector-api-service
|
||||
labels:
|
||||
app: vector-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: vector-api
|
||||
|
||||
4274
vector/poetry.lock
generated
Normal file
4274
vector/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
vector/pyproject.toml
Normal file
41
vector/pyproject.toml
Normal file
@ -0,0 +1,41 @@
|
||||
[tool.poetry]
|
||||
name = "vector-api"
|
||||
version = "1.0.0"
|
||||
description = "Vector DB API with AI/ML capabilities"
|
||||
authors = ["Developer <dev@example.com>"]
|
||||
packages = [{include = "app"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
fastapi = "0.115.9"
|
||||
uvicorn = {extras = ["standard"], version = "^0.34.3"}
|
||||
pydantic = "^2.11.7"
|
||||
python-dotenv = "^1.1.0"
|
||||
python-multipart = "^0.0.20"
|
||||
aiohttp = "^3.12.13"
|
||||
requests = "^2.32.4"
|
||||
numpy = "^2.3.0"
|
||||
pandas = "^2.3.0"
|
||||
tokenizers = "^0.21.1"
|
||||
transformers = "^4.52.4"
|
||||
huggingface-hub = "^0.33.0"
|
||||
sentence-transformers = "^4.1.0"
|
||||
chromadb = "^1.0.12"
|
||||
hnswlib = "^0.8.0"
|
||||
duckdb = "^1.3.0"
|
||||
anthropic = "^0.54.0"
|
||||
typing-extensions = "^4.14.0"
|
||||
sqlalchemy = "^2.0.41"
|
||||
torch = {version = "^2.7.1+cpu", source = "pytorch-cpu"}
|
||||
torchvision = {version = "^0.22.1+cpu", source = "pytorch-cpu"}
|
||||
torchaudio = {version = "^2.7.1+cpu", source = "pytorch-cpu"}
|
||||
starlette = ">=0.40.0,<0.46.0"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
priority = "supplemental"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
376
vector/setup.sh
Executable file
376
vector/setup.sh
Executable file
@ -0,0 +1,376 @@
|
||||
#!/bin/bash
|
||||
# setup.sh - Poetry 기반 Vector DB API 의존성 설치 스크립트 (홈 디렉토리 사용)
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 설정
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 옵션 기본값
|
||||
SKIP_POETRY_INSTALL=false
|
||||
SKIP_PYTHON311_CHECK=false
|
||||
FORCE_REINSTALL=false
|
||||
RUN_AFTER_INSTALL=false
|
||||
|
||||
# 함수 정의
|
||||
log_info() { echo -e "${CYAN}ℹ️ $1${NC}"; }
|
||||
log_success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_error() { echo -e "${RED}❌ $1${NC}"; }
|
||||
log_step() { echo -e "\n${BLUE}🔧 $1${NC}"; }
|
||||
|
||||
# 사용법 표시
|
||||
show_usage() {
|
||||
echo "사용법: $0 [옵션]"
|
||||
echo ""
|
||||
echo "옵션:"
|
||||
echo " --skip-poetry-install Poetry 설치 건너뛰기"
|
||||
echo " --skip-python311-check Python 3.11 확인 건너뛰기"
|
||||
echo " --force-reinstall 기존 환경 제거 후 재설치"
|
||||
echo " --run-after-install 설치 후 앱 자동 실행"
|
||||
echo " --help 이 도움말 표시"
|
||||
}
|
||||
|
||||
# 파라미터 파싱
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-poetry-install)
|
||||
SKIP_POETRY_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--skip-python311-check)
|
||||
SKIP_PYTHON311_CHECK=true
|
||||
shift
|
||||
;;
|
||||
--force-reinstall)
|
||||
FORCE_REINSTALL=true
|
||||
shift
|
||||
;;
|
||||
--run-after-install)
|
||||
RUN_AFTER_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "알 수 없는 옵션: $1"
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "========================================================"
|
||||
echo "🚀 Poetry 기반 Vector DB API 의존성 설치 (홈 디렉토리)"
|
||||
echo "========================================================"
|
||||
echo "설치 시작: $(date)"
|
||||
echo ""
|
||||
|
||||
INSTALL_START=$(date +%s)
|
||||
|
||||
# =============================================================================
|
||||
# 1단계: Poetry 설치 확인
|
||||
# =============================================================================
|
||||
log_step "1단계: Poetry 설치 확인"
|
||||
|
||||
if [ "$SKIP_POETRY_INSTALL" = true ]; then
|
||||
log_info "Poetry 설치 확인을 건너뜁니다."
|
||||
elif command -v poetry &> /dev/null; then
|
||||
POETRY_VERSION=$(poetry --version)
|
||||
log_success "Poetry 이미 설치됨: $POETRY_VERSION"
|
||||
else
|
||||
if [ -f /.dockerenv ]; then
|
||||
log_error "Docker 환경에서 Poetry를 찾을 수 없습니다."
|
||||
log_error "--skip-poetry-install 옵션을 제거하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Poetry 설치 중..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3-poetry
|
||||
|
||||
# PATH 확인
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if command -v poetry &> /dev/null; then
|
||||
log_success "Poetry 설치 완료: $(poetry --version)"
|
||||
else
|
||||
log_error "Poetry 설치 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2단계: Python 3.11 확인
|
||||
# =============================================================================
|
||||
log_step "2단계: Python 3.11 환경 확인"
|
||||
|
||||
if [ "$SKIP_PYTHON311_CHECK" = false ]; then
|
||||
if command -v python3.11 &> /dev/null; then
|
||||
PYTHON311_VERSION=$(python3.11 --version)
|
||||
log_success "Python 3.11 발견: $PYTHON311_VERSION"
|
||||
else
|
||||
log_warning "Python 3.11이 설치되어 있지 않습니다."
|
||||
log_info "Python 3.11 설치 중..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3.11 python3.11-venv python3.11-dev
|
||||
|
||||
if command -v python3.11 &> /dev/null; then
|
||||
log_success "Python 3.11 설치 완료: $(python3.11 --version)"
|
||||
else
|
||||
log_error "Python 3.11 설치 실패"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3단계: Poetry 프로젝트 초기화
|
||||
# =============================================================================
|
||||
log_step "3단계: Poetry 프로젝트 초기화"
|
||||
|
||||
# 기존 환경 제거 (강제 재설치 옵션)
|
||||
if [ "$FORCE_REINSTALL" = true ]; then
|
||||
log_info "기존 Poetry 환경 제거 중..."
|
||||
poetry env remove --all 2>/dev/null || true
|
||||
rm -f poetry.lock pyproject.toml 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# pyproject.toml이 없는 경우 초기화
|
||||
if [ ! -f "pyproject.toml" ]; then
|
||||
log_info "Poetry 프로젝트 초기화 중..."
|
||||
|
||||
# 비대화형 초기화
|
||||
cat > pyproject.toml << 'EOF'
|
||||
[tool.poetry]
|
||||
name = "vector-api"
|
||||
version = "1.0.0"
|
||||
description = "Vector DB API with AI/ML capabilities"
|
||||
authors = ["Developer <dev@example.com>"]
|
||||
packages = [{include = "app"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
EOF
|
||||
|
||||
log_success "pyproject.toml 생성 완료"
|
||||
else
|
||||
log_success "기존 pyproject.toml 사용"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 4단계: Poetry 가상환경 설정 (홈 디렉토리 사용)
|
||||
# =============================================================================
|
||||
log_step "4단계: Poetry 가상환경 설정 (홈 디렉토리 사용)"
|
||||
|
||||
log_info "Poetry 가상환경을 홈 디렉토리로 설정 중..."
|
||||
|
||||
# 홈 디렉토리에 Poetry 관련 디렉토리 생성
|
||||
mkdir -p ~/.cache/pypoetry/venvs ~/.cache/pypoetry/cache
|
||||
|
||||
# Poetry 설정 - 가상환경을 홈 디렉토리로 이동
|
||||
poetry config virtualenvs.in-project false
|
||||
poetry config virtualenvs.create true
|
||||
poetry config virtualenvs.path ~/.cache/pypoetry/venvs
|
||||
poetry config cache-dir ~/.cache/pypoetry/cache
|
||||
|
||||
# 설정 확인
|
||||
log_info "Poetry 설정 확인:"
|
||||
poetry config --list | grep -E "(virtualenvs|cache)"
|
||||
|
||||
# 권한 확인
|
||||
log_info "디렉토리 권한 확인:"
|
||||
ls -la ~/.cache/pypoetry/ 2>/dev/null || log_info "디렉토리가 아직 생성되지 않음"
|
||||
|
||||
log_success "Poetry 가상환경 설정 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 5단계: Python 3.11 환경 설정
|
||||
# =============================================================================
|
||||
log_step "5단계: Python 3.11 환경 설정"
|
||||
|
||||
log_info "Poetry가 Python 3.11을 사용하도록 설정 중..."
|
||||
poetry env use python3.11
|
||||
|
||||
# 환경 정보 확인
|
||||
POETRY_ENV_INFO=$(poetry config virtualenvs.path)
|
||||
log_success "Poetry 가상환경 기본 경로: $POETRY_ENV_INFO"
|
||||
|
||||
# =============================================================================
|
||||
# 6단계: 소스 우선순위 사전 설정 (중요!)
|
||||
# =============================================================================
|
||||
log_step "6단계: 소스 우선순위 사전 설정"
|
||||
|
||||
log_info "PyPI를 기본 소스로 확인 중..."
|
||||
# PyPI는 기본적으로 존재하므로 확인만 함
|
||||
if poetry source show | grep -q "pypi"; then
|
||||
log_success "PyPI 소스 확인됨"
|
||||
else
|
||||
log_warning "PyPI 소스가 없습니다 (비정상)"
|
||||
fi
|
||||
|
||||
log_success "소스 우선순위 사전 설정 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 7단계: 기본 웹 프레임워크 설치
|
||||
# =============================================================================
|
||||
log_step "7단계: 기본 웹 프레임워크 설치"
|
||||
|
||||
log_info "FastAPI 및 관련 패키지 설치 중..."
|
||||
poetry add "fastapi==0.115.9"
|
||||
poetry add uvicorn[standard] pydantic python-dotenv python-multipart
|
||||
|
||||
log_success "웹 프레임워크 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 8단계: HTTP 클라이언트 설치
|
||||
# =============================================================================
|
||||
log_step "8단계: HTTP 클라이언트 설치"
|
||||
|
||||
log_info "HTTP 클라이언트 설치 중..."
|
||||
poetry add aiohttp requests
|
||||
|
||||
log_success "HTTP 클라이언트 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 9단계: 데이터 처리 라이브러리 설치
|
||||
# =============================================================================
|
||||
log_step "9단계: 데이터 처리 라이브러리 설치"
|
||||
|
||||
log_info "NumPy, Pandas 설치 중..."
|
||||
poetry add numpy pandas
|
||||
|
||||
log_success "데이터 처리 라이브러리 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 10단계: AI/ML 기초 라이브러리 설치
|
||||
# =============================================================================
|
||||
log_step "10단계: AI/ML 기초 라이브러리 설치"
|
||||
|
||||
log_info "Tokenizers, Transformers 설치 중..."
|
||||
poetry add tokenizers transformers huggingface-hub
|
||||
|
||||
log_success "AI/ML 기초 라이브러리 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 11단계: Sentence Transformers 설치
|
||||
# =============================================================================
|
||||
log_step "11단계: Sentence Transformers 설치"
|
||||
|
||||
log_info "Sentence Transformers 설치 중..."
|
||||
poetry add sentence-transformers
|
||||
|
||||
log_success "Sentence Transformers 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 12단계: Vector DB 라이브러리 설치
|
||||
# =============================================================================
|
||||
log_step "12단계: Vector DB 라이브러리 설치"
|
||||
|
||||
log_info "ChromaDB, HNSW, DuckDB 설치 중..."
|
||||
poetry add chromadb hnswlib duckdb
|
||||
|
||||
log_success "Vector DB 라이브러리 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 13단계: Claude API 라이브러리 설치
|
||||
# =============================================================================
|
||||
log_step "13단계: Claude API 라이브러리 설치"
|
||||
|
||||
log_info "Anthropic Claude API 라이브러리 설치 중..."
|
||||
poetry add anthropic
|
||||
|
||||
log_success "Claude API 라이브러리 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 14단계: 기타 필수 라이브러리 설치
|
||||
# =============================================================================
|
||||
log_step "14단계: 기타 필수 라이브러리 설치"
|
||||
|
||||
log_info "기타 필수 라이브러리 설치 중..."
|
||||
poetry add typing-extensions sqlalchemy
|
||||
|
||||
log_success "기타 필수 라이브러리 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 15단계: PyTorch CPU 버전 설치
|
||||
# =============================================================================
|
||||
log_step "15단계: PyTorch CPU 버전 설치"
|
||||
|
||||
log_info "PyTorch CPU 버전 소스 추가 중..."
|
||||
poetry source add pytorch-cpu https://download.pytorch.org/whl/cpu --priority=supplemental
|
||||
|
||||
log_info "PyTorch CPU 버전 설치 중..."
|
||||
poetry add torch --source pytorch-cpu
|
||||
poetry add torchvision --source pytorch-cpu
|
||||
poetry add torchaudio --source pytorch-cpu
|
||||
|
||||
log_success "PyTorch CPU 버전 설치 완료"
|
||||
|
||||
# =============================================================================
|
||||
# 16단계: Starlette 버전 호환성 확인
|
||||
# =============================================================================
|
||||
log_step "16단계: Starlette 버전 호환성 확인"
|
||||
|
||||
log_info "Starlette 호환 버전 설치 중..."
|
||||
poetry add "starlette>=0.40.0,<0.46.0"
|
||||
|
||||
log_success "Starlette 호환성 확인 완료"
|
||||
|
||||
# 종료 시간 계산
|
||||
INSTALL_END=$(date +%s)
|
||||
INSTALL_TIME=$((INSTALL_END - INSTALL_START))
|
||||
MINUTES=$((INSTALL_TIME / 60))
|
||||
SECONDS=$((INSTALL_TIME % 60))
|
||||
|
||||
echo ""
|
||||
echo "🎉 Poetry 기반 Vector DB API 설치 완료!"
|
||||
echo "========================================================"
|
||||
echo "⏱️ 총 설치 시간: ${MINUTES}분 ${SECONDS}초"
|
||||
echo "📦 설치된 패키지 수: $(poetry show | wc -l)"
|
||||
echo "🐍 Python 환경: $(poetry run python --version)"
|
||||
echo "📁 환경 경로: $(poetry env info --path)"
|
||||
echo ""
|
||||
|
||||
echo "🚀 다음 단계:"
|
||||
echo " 1. 가상환경 활성화:"
|
||||
echo " poetry shell"
|
||||
echo ""
|
||||
echo " 2. 애플리케이션 실행:"
|
||||
echo " poetry run python app/main.py"
|
||||
echo ""
|
||||
echo " 3. 개발 서버 실행:"
|
||||
echo " poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
echo ""
|
||||
|
||||
echo "💡 유용한 명령어:"
|
||||
echo " - poetry show # 설치된 패키지 확인"
|
||||
echo " - poetry show --tree # 의존성 트리 확인"
|
||||
echo " - poetry add [패키지명] # 새 패키지 추가"
|
||||
echo " - poetry update # 패키지 업데이트"
|
||||
echo " - poetry env info # 환경 정보 확인"
|
||||
echo ""
|
||||
|
||||
# 자동 실행 옵션
|
||||
if [ "$RUN_AFTER_INSTALL" = true ]; then
|
||||
if [ -f "app/main.py" ]; then
|
||||
log_info "앱 자동 실행 중..."
|
||||
poetry run python app/main.py
|
||||
else
|
||||
log_warning "app/main.py 파일이 없어 자동 실행을 건너뜁니다."
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "설치 스크립트 완료! 🎊"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user