Agent 2.0 exdev 서버 배포 스택

- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
run/
logs/
*.pid
+106
View File
@@ -0,0 +1,106 @@
# 로컬 개발 — exdev AI 연동
로컬에서 **chatbotApi · chatbotApp · exAiChatBot · kakaoChatbotSkill · Qdrant · MongoDB** 를 실행하고,
**LLM / Embedding / Reranker** 만 `chatbot.exdev.co.kr` Mac Studio 호스트 AI를 SSH 터널로 사용합니다.
## exdev AI 구조
| 포트 | 서비스 | API |
|------|--------|-----|
| 16000 | MLX LLM | `/v1/chat/completions` |
| 16001 | Embedding (MPS) | `/embed` (`EMBED_API_STYLE=tei`) |
| 16002 | Reranker | `/rerank` (`RERANK_API_STYLE=legacy`) |
`https://chatbot.exdev.co.kr` 은 RAG API URL이며 LLM 직접 URL이 아닙니다.
## 한 번에 기동 / 중지
```bash
chmod +x local-dev/*.sh
./local-dev/stop-all.sh # 먼저 중지
./local-dev/start-all.sh # 전체 기동 + 헬스체크
# 옵션
./local-dev/start-all.sh --no-tunnel # AI 터널이 이미 떠 있을 때
./local-dev/start-all.sh --skip-java # Docker(RAG + chatbotApp)만
./local-dev/start-all.sh --no-build # RAG 이미지 빌드 생략
./local-dev/stop-all.sh --mongo --tunnel # Mongo·SSH 터널까지 종료
```
- RAG는 **`docker-compose.local.yml`** 사용 (Mac Docker `:28012` 포트 바인딩)
- Qdrant 호스트 포트 **`6335`** (다른 프로젝트 `:6333` 과 충돌 방지)
기동 후 UI: `http://localhost:8089/chatbot/app/`
---
## 1. exdev AI 기동 (서버에서 한 번)
exdev SSH 접속 후:
```bash
bash /Users/aidev/dev/exAIChatbot2.0/scripts/exdev/start-host-ai.sh
curl -s http://127.0.0.1:28012/health # llm/tei_embed/tei_rerank: true 확인
```
## 2. 로컬 SSH 터널
```bash
chmod +x local-dev/start-exdev-ai-tunnel.sh
./local-dev/start-exdev-ai-tunnel.sh
```
터널 확인:
```bash
curl -s http://127.0.0.1:16000/v1/models | head
curl -s http://127.0.0.1:16001/health
curl -s -X POST http://127.0.0.1:16002/rerank \
-H 'Content-Type: application/json' \
-d '{"query":"test","documents":["doc"]}'
```
## 3. exAiChatBot .env
```bash
cp local-dev/env.exdev-ai.template exAiChatBot-chatbot2.0-agent/.env
cd exAiChatBot-chatbot2.0-agent
docker compose -f docker-compose-slim.yml up -d
curl -s http://localhost:28012/health
```
## 4. Java 서비스 + chatbotApp
```bash
# WAS
cd chatbotApi-chatbot2.0-agent
export INTERNAL_TOOL_API_KEY=local-dev-key
export SKILL_INTERNAL_BASE_URL=http://127.0.0.1:8083
./gradlew bootRun
# Skill
cd kakaoChatbotSkill-chatbot2.0-agent
export AGENT_MODE=agent
export RAG_AGENT_CHAT_URL=http://127.0.0.1:28012/agent/chat
export CHATBOT_API_BASE_URL=http://127.0.0.1:8086/api/
export INTERNAL_TOOL_API_KEY=local-dev-key
./gradlew bootRun
# UI
cd chatbotApp-chatbot2.0-agent
CHATBOT_API_UPSTREAM=http://host.docker.internal:8083/web/ \
CHATBOT_API_HOST=host.docker.internal ./start.sh
```
## api_clients.py 스타일 변수
| 변수 | exdev 값 | 설명 |
|------|----------|------|
| `EMBED_API_STYLE` | `tei` | `/embed` |
| `RERANK_API_STYLE` | `legacy` | `/rerank` |
| `EMBED_API_STYLE` | `openai` | 게이트웨이 `/v1/embeddings` |
| `RERANK_API_STYLE` | `gateway` | 게이트웨이 `/score` |
로컬 exdev 연동 시 **`tei` + `legacy`** 조합을 사용합니다.
+51
View File
@@ -0,0 +1,51 @@
# =============================================================================
# 로컬 Mac/Docker + exdev(chatbot.exdev.co.kr) 호스트 AI (SSH 터널)
# 사용법:
# 1) ./local-dev/start-exdev-ai-tunnel.sh (별도 터미널 유지)
# 2) cp local-dev/env.exdev-ai.template exAiChatBot-chatbot2.0-agent/.env
# 3) exAiChatBot / chatbotApi / kakaoChatbotSkill / chatbotApp 기동
# =============================================================================
# ── exdev AI (Mac localhost:16000~16002 → SSH 터널) ──
# Docker Desktop(Mac): host.docker.internal
# Linux host network / 로컬 python: 127.0.0.1
LLM_BASE_URL=http://host.docker.internal:16000
TEI_EMBED_URL=http://host.docker.internal:16001
TEI_RERANK_URL=http://host.docker.internal:16002
# exdev Mac Studio 실제 모델명 (curl http://127.0.0.1:16000/v1/models 참고)
LLM_MODEL_NAME=mlx-community/Qwen2.5-7B-Instruct-4bit
EMBED_MODEL_NAME=Qwen/Qwen3-Embedding-8B
TEI_RERANK_MODEL=qwen3-reranker-4b
# exdev 호스트 AI API 스타일 (게이트웨이와 다름)
EMBED_API_STYLE=tei
RERANK_API_STYLE=legacy
MODEL_VERIFY_SSL=false
API_TIMEOUT=120
# 게이트웨이 키 불필요 (exdev 호스트 AI는 Bearer 없음)
MODEL_API_KEY=
# ── 로컬 MongoDB / Qdrant ──
# start-all.sh(docker-compose.local.yml) 사용 시 컨테이너→호스트는 host.docker.internal
MONGO_HOST=host.docker.internal
MONGO_PORT=27017
MONGO_USER=
MONGO_PASSWORD=
MONGO_DATABASE=chat_history
MONGO_COLLECTION=rag_conversations
VECTOR_STORE=qdrant
QDRANT_HOST=127.0.0.1
QDRANT_PORT=6333
QDRANT_COLLECTION=qa_vectors
# ── 로컬 WAS / Skill 연동 ──
CHATBOT_API_BASE_URL=http://127.0.0.1:8086/api
INTERNAL_TOOL_API_KEY=local-dev-key
AGENT_MAX_ROUNDS=6
CHATBOT_TOOL_TIMEOUT=60
ADMIN_PORT=28013
UVICORN_WORKERS=2
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
# 로컬 전체 스택 기동 (exdev AI SSH 터널 + Mongo + RAG + WAS + Skill + chatbotApp)
#
# ./local-dev/start-all.sh
# ./local-dev/start-all.sh --no-tunnel
# ./local-dev/start-all.sh --skip-java
# ./local-dev/start-all.sh --no-build
#
# 종료: ./local-dev/stop-all.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DEV_DIR="${ROOT_DIR}/local-dev"
RUN_DIR="${LOCAL_DEV_DIR}/run"
LOG_DIR="${LOCAL_DEV_DIR}/logs"
RAG_DIR="${ROOT_DIR}/exAiChatBot-chatbot2.0-agent"
API_DIR="${ROOT_DIR}/chatbotApi-chatbot2.0-agent"
SKILL_DIR="${ROOT_DIR}/kakaoChatbotSkill-chatbot2.0-agent"
APP_DIR="${ROOT_DIR}/chatbotApp-chatbot2.0-agent"
ENV_TEMPLATE="${LOCAL_DEV_DIR}/env.exdev-ai.template"
RAG_ENV="${RAG_DIR}/.env"
COMPOSE_LOCAL="${RAG_DIR}/docker-compose.local.yml"
MONGO_CONTAINER="${MONGO_CONTAINER:-ex-chatbot-mongo-local}"
INTERNAL_TOOL_API_KEY="${INTERNAL_TOOL_API_KEY:-local-dev-key}"
LLM_PORT="${LLM_PORT:-16000}"
EMBED_PORT="${EMBED_PORT:-16001}"
RERANK_PORT="${RERANK_PORT:-16002}"
RAG_PORT="${RAG_API_PORT:-28012}"
ADMIN_PORT="${ADMIN_PORT:-28013}"
WAS_PORT="${CHATBOT_API_PORT:-8086}"
SKILL_PORT="${KAKAO_SKILL_PORT:-8083}"
APP_PORT="${CHATBOT_APP_PORT:-8089}"
SKIP_TUNNEL=false
SKIP_JAVA=false
NO_BUILD=false
SYNC_QDRANT=false
for arg in "$@"; do
case "$arg" in
--no-tunnel) SKIP_TUNNEL=true ;;
--skip-java) SKIP_JAVA=true ;;
--no-build) NO_BUILD=true ;;
--sync-qdrant) SYNC_QDRANT=true ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "알 수 없는 옵션: $arg (--help)"
exit 1
;;
esac
done
export PATH="/opt/homebrew/bin:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin:${PATH}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
fail() { log "ERROR: $*"; exit 1; }
wait_http() {
local url="$1" label="$2" max="${3:-90}"
local i=0
while (( i < max )); do
if curl -sf --max-time 3 "$url" >/dev/null 2>&1; then
log "✅ ${label}"
return 0
fi
sleep 2
i=$((i + 2))
done
log "⚠️ ${label} 타임아웃 (${url})"
return 1
}
wait_port() {
local port="$1" label="$2" max="${3:-90}"
local i=0
while (( i < max )); do
if lsof -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
log "✅ ${label} (:${port})"
return 0
fi
sleep 2
i=$((i + 2))
done
log "⚠️ ${label} 타임아웃 (:${port})"
return 1
}
ai_tunnel_ready() {
curl -sf --max-time 3 "http://127.0.0.1:${LLM_PORT}/v1/models" >/dev/null 2>&1 \
&& curl -sf --max-time 3 "http://127.0.0.1:${EMBED_PORT}/health" >/dev/null 2>&1 \
&& curl -sf --max-time 3 -X POST "http://127.0.0.1:${RERANK_PORT}/rerank" \
-H "Content-Type: application/json" \
-d '{"query":"ping","documents":["doc"]}' >/dev/null 2>&1
}
start_tunnel() {
if ai_tunnel_ready; then
log "exdev AI 터널 이미 연결됨 (:${LLM_PORT}~:${RERANK_PORT})"
return 0
fi
log "exdev AI SSH 터널 백그라운드 기동..."
ssh -f -N \
-o ServerAliveInterval=30 \
-o ExitOnForwardFailure=yes \
-L "${LLM_PORT}:127.0.0.1:${LLM_PORT}" \
-L "${EMBED_PORT}:127.0.0.1:${EMBED_PORT}" \
-L "${RERANK_PORT}:127.0.0.1:${RERANK_PORT}" \
"${EXDEV_USER:-aidev}@${EXDEV_HOST:-chatbot.exdev.co.kr}"
sleep 1
pgrep -f "ssh -f -N.*${LLM_PORT}:127.0.0.1:${LLM_PORT}" | head -1 > "${RUN_DIR}/tunnel.pid" || true
local i=0
while (( i < 60 )); do
if ai_tunnel_ready; then
log "✅ exdev AI 터널 (LLM/Embed/Rerank)"
return 0
fi
sleep 2
i=$((i + 2))
done
fail "exdev AI 터널 실패 — exdev에서 start-host-ai.sh 실행 여부 확인"
}
patch_env() {
local key="$1" value="$2"
local tmp="${RAG_ENV}.tmp.$$"
if [[ -f "${RAG_ENV}" ]]; then
grep -v "^${key}=" "${RAG_ENV}" > "${tmp}" || true
else
: > "${tmp}"
fi
printf '%s\n%s=%s\n' "$(cat "${tmp}")" "${key}" "${value}" > "${RAG_ENV}"
rm -f "${tmp}"
}
ensure_rag_env() {
if [[ ! -f "${RAG_ENV}" ]]; then
log ".env 없음 → env.exdev-ai.template 복사"
cp "${ENV_TEMPLATE}" "${RAG_ENV}"
fi
# Docker 컨테이너 → Mac 호스트 접근
patch_env "LLM_BASE_URL" "http://host.docker.internal:${LLM_PORT}"
patch_env "TEI_EMBED_URL" "http://host.docker.internal:${EMBED_PORT}"
patch_env "TEI_RERANK_URL" "http://host.docker.internal:${RERANK_PORT}"
patch_env "EMBED_API_STYLE" "tei"
patch_env "RERANK_API_STYLE" "legacy"
patch_env "MONGO_HOST" "host.docker.internal"
patch_env "CHATBOT_API_BASE_URL" "http://host.docker.internal:${WAS_PORT}/api"
patch_env "INTERNAL_TOOL_API_KEY" "${INTERNAL_TOOL_API_KEY}"
# 로컬 Mongo 무인증
patch_env "MONGO_USER" ""
patch_env "MONGO_PASSWORD" ""
}
ensure_mongo() {
if docker ps --format '{{.Names}}' | grep -qx "${MONGO_CONTAINER}"; then
docker start "${MONGO_CONTAINER}" >/dev/null 2>&1 || true
log "MongoDB 재사용: ${MONGO_CONTAINER}"
wait_port 27017 "MongoDB" 20 || true
return 0
fi
if lsof -iTCP:27017 -sTCP:LISTEN >/dev/null 2>&1; then
log "MongoDB :27017 이미 사용 중 (외부 인스턴스)"
return 0
fi
log "MongoDB 컨테이너 생성: ${MONGO_CONTAINER}"
docker run -d --name "${MONGO_CONTAINER}" \
-p 27017:27017 \
mongo:7 >/dev/null
wait_port 27017 "MongoDB" 30 || true
}
start_rag() {
ensure_rag_env
[[ -f "${COMPOSE_LOCAL}" ]] || fail "docker-compose.local.yml 없음"
cd "${RAG_DIR}"
local -a up_flags=(-d)
if $NO_BUILD; then
up_flags+=(--no-build)
else
up_flags+=(--build)
fi
# 이전 slim/legacy 잔여 정리
docker compose -f docker-compose-slim.yml down --remove-orphans 2>/dev/null || true
log "RAG 스택 기동 (docker-compose.local.yml)..."
docker compose -f docker-compose.local.yml up "${up_flags[@]}"
wait_http "http://127.0.0.1:6335/readyz" "Qdrant" 30 || \
wait_http "http://127.0.0.1:6335/collections" "Qdrant" 30 || true
wait_http "http://127.0.0.1:${RAG_PORT}/health" "RAG API" 120
wait_http "http://127.0.0.1:${ADMIN_PORT}/api/stats" "Vector Admin" 60 || \
wait_port "${ADMIN_PORT}" "Vector Admin" 30 || true
local vc
vc="$(curl -sf "http://127.0.0.1:${RAG_PORT}/health" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('vector_count',0))" 2>/dev/null || echo 0)"
if $SYNC_QDRANT || [[ "${vc}" == "0" ]]; then
log "Qdrant 데이터 exdev 동기화 (vector_count=${vc})..."
chmod +x "${LOCAL_DEV_DIR}/sync-qdrant-from-exdev.sh"
"${LOCAL_DEV_DIR}/sync-qdrant-from-exdev.sh" || log "⚠️ Qdrant 동기화 실패 — 수동: ./local-dev/sync-qdrant-from-exdev.sh"
fi
}
ensure_java() {
local home=""
local candidates=(
"/Users/macbook/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
"/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
)
for candidate in "${candidates[@]}"; do
if [[ -x "${candidate}/bin/java" ]]; then
home="${candidate}"
break
fi
done
if [[ -z "${home}" ]] && /usr/libexec/java_home -v 17 >/dev/null 2>&1; then
home="$(/usr/libexec/java_home -v 17)"
fi
if [[ -z "${home}" ]]; then
fail "Java 17 필요 — brew install openjdk@17"
fi
export JAVA_HOME="${home}"
export PATH="${JAVA_HOME}/bin:${PATH}"
log "Java 17: $("${JAVA_HOME}/bin/java" -version 2>&1 | head -1)"
}
start_java_service() {
local name="$1" dir="$2" pid_file="$3" log_file="$4"
shift 4
if [[ -f "${pid_file}" ]]; then
local old_pid
old_pid="$(cat "${pid_file}")"
if kill -0 "$old_pid" 2>/dev/null; then
log "${name} 이미 실행 중 (PID ${old_pid})"
return 0
fi
rm -f "${pid_file}"
fi
chmod +x "${dir}/gradlew"
log "${name} 기동 중..."
: > "${log_file}"
(
cd "${dir}"
export JAVA_HOME
for ev in "$@"; do
export "${ev?}"
done
nohup ./gradlew -Dorg.gradle.java.home="${JAVA_HOME}" bootRun \
>> "${log_file}" 2>&1 &
echo $! > "${pid_file}"
)
log "${name} PID $(cat "${pid_file}") → ${log_file}"
}
verify_stack() {
local ok=true
ai_tunnel_ready || { log "❌ exdev AI 터널"; ok=false; }
curl -sf "http://127.0.0.1:${RAG_PORT}/health" | grep -q '"status"' || { log "❌ RAG health"; ok=false; }
wait_port "${WAS_PORT}" "chatbotApi" 5 || ok=false
wait_port "${SKILL_PORT}" "kakaoChatbotSkill" 5 || ok=false
curl -sf "http://127.0.0.1:${APP_PORT}/chatbot/app/" | grep -q 'data-chatbot-app' || { log "❌ chatbotApp UI"; ok=false; }
if $ok; then
log "✅ 전체 스택 정상"
return 0
fi
log "⚠️ 일부 서비스 미응답 — ${LOG_DIR}/ 로그 확인"
return 1
}
# ── main ──
mkdir -p "${RUN_DIR}" "${LOG_DIR}"
docker info >/dev/null 2>&1 || fail "Docker Engine 미기동"
log "=========================================="
log "exChatbot 로컬 전체 스택 기동"
log "=========================================="
if ! $SKIP_TUNNEL; then
start_tunnel
else
ai_tunnel_ready || fail "--no-tunnel 이지만 :${LLM_PORT}~:${RERANK_PORT} AI 미응답"
fi
ensure_mongo
start_rag
if ! $SKIP_JAVA; then
ensure_java
start_java_service "chatbotApi" "${API_DIR}" \
"${RUN_DIR}/chatbot-api.pid" "${LOG_DIR}/chatbot-api.log" \
"INTERNAL_TOOL_API_KEY=${INTERNAL_TOOL_API_KEY}" \
"SKILL_INTERNAL_BASE_URL=http://127.0.0.1:${SKILL_PORT}" \
"RAG_SERVER_HOST=127.0.0.1" \
"RAG_SERVER_PORT=${RAG_PORT}"
start_java_service "kakaoChatbotSkill" "${SKILL_DIR}" \
"${RUN_DIR}/kakao-skill.pid" "${LOG_DIR}/kakao-skill.log" \
"AGENT_MODE=agent" \
"RAG_AGENT_CHAT_URL=http://127.0.0.1:${RAG_PORT}/agent/chat" \
"CHATBOT_API_BASE_URL=http://127.0.0.1:${WAS_PORT}/api/" \
"INTERNAL_TOOL_API_KEY=${INTERNAL_TOOL_API_KEY}" \
"CHATBOT_APP_URL=http://localhost:${APP_PORT}/chatbot/app/"
wait_port "${WAS_PORT}" "chatbotApi" 240 || true
wait_port "${SKILL_PORT}" "kakaoChatbotSkill" 240 || true
fi
log "chatbotApp 기동..."
docker rm -f ex-chatbot-app-web 2>/dev/null || true
(
cd "${APP_DIR}"
export CHATBOT_API_UPSTREAM="http://host.docker.internal:${SKILL_PORT}/web/"
export CHATBOT_API_HOST="host.docker.internal"
docker compose up -d --force-recreate
)
wait_http "http://127.0.0.1:${APP_PORT}/chatbot/app/" "chatbotApp UI" 30 || true
log "=========================================="
log "기동 완료"
log " UI http://127.0.0.1:${APP_PORT}/chatbot/app/"
log " RAG http://127.0.0.1:${RAG_PORT}/health"
log " Admin http://127.0.0.1:${ADMIN_PORT}/"
log " Qdrant http://127.0.0.1:6335/ (호스트 포트)"
log " WAS http://127.0.0.1:${WAS_PORT}/"
log " Skill http://127.0.0.1:${SKILL_PORT}/web/ask"
log " exdev AI tunnel :${LLM_PORT} ~ :${RERANK_PORT}"
log " 로그 ${LOG_DIR}/"
log " 종료 ./local-dev/stop-all.sh"
log "=========================================="
HEALTH="$(curl -sf "http://127.0.0.1:${RAG_PORT}/health" 2>/dev/null || echo 'unavailable')"
log "RAG health: ${HEALTH}"
verify_stack || exit 1
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# exdev(chatbot.exdev.co.kr) Mac Studio 호스트 AI → 로컬 Mac 포트 포워딩
# LLM :16000 | Embedding :16001 | Reranker :16002
set -euo pipefail
REMOTE_HOST="${EXDEV_HOST:-chatbot.exdev.co.kr}"
REMOTE_USER="${EXDEV_USER:-aidev}"
LLM_PORT="${LLM_PORT:-16000}"
EMBED_PORT="${EMBED_PORT:-16001}"
RERANK_PORT="${RERANK_PORT:-16002}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
log "SSH 터널 시작 → ${REMOTE_USER}@${REMOTE_HOST}"
log " localhost:${LLM_PORT} → exdev LLM"
log " localhost:${EMBED_PORT} → exdev Embedding"
log " localhost:${RERANK_PORT} → exdev Reranker (/rerank legacy)"
log "종료: Ctrl+C"
log ""
exec ssh -N \
-o ServerAliveInterval=30 \
-o ExitOnForwardFailure=yes \
-L "${LLM_PORT}:127.0.0.1:${LLM_PORT}" \
-L "${EMBED_PORT}:127.0.0.1:${EMBED_PORT}" \
-L "${RERANK_PORT}:127.0.0.1:${RERANK_PORT}" \
"${REMOTE_USER}@${REMOTE_HOST}"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# local-dev/start-all.sh 로 기동한 프로세스·컨테이너 중지
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_DEV_DIR="${ROOT_DIR}/local-dev"
RUN_DIR="${LOCAL_DEV_DIR}/run"
RAG_DIR="${ROOT_DIR}/exAiChatBot-chatbot2.0-agent"
APP_DIR="${ROOT_DIR}/chatbotApp-chatbot2.0-agent"
MONGO_CONTAINER="${MONGO_CONTAINER:-ex-chatbot-mongo-local}"
COMPOSE_LOCAL="${RAG_DIR}/docker-compose.local.yml"
STOP_MONGO=false
STOP_TUNNEL=false
for arg in "$@"; do
case "$arg" in
--mongo) STOP_MONGO=true ;;
--tunnel) STOP_TUNNEL=true ;;
-h|--help)
echo "Usage: $0 [--mongo] [--tunnel]"
exit 0
;;
esac
done
export PATH="/opt/homebrew/bin:/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin:${PATH}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
stop_pid_file() {
local name="$1" pid_file="$2"
[[ -f "${pid_file}" ]] || return 0
local pid
pid="$(cat "${pid_file}")"
if kill -0 "$pid" 2>/dev/null; then
log "${name} 종료 (PID ${pid})"
kill "$pid" 2>/dev/null || true
sleep 2
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "${pid_file}"
}
stop_port() {
local port="$1"
local pids
pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "${pids}" ]]; then
log ":${port} 점유 프로세스 종료"
kill ${pids} 2>/dev/null || true
sleep 1
kill -9 ${pids} 2>/dev/null || true
fi
}
log "로컬 exChatbot 스택 중지..."
stop_pid_file "chatbotApi" "${RUN_DIR}/chatbot-api.pid"
stop_pid_file "kakaoChatbotSkill" "${RUN_DIR}/kakao-skill.pid"
for port in 8086 8083; do
stop_port "${port}"
done
if [[ -d "${APP_DIR}" ]]; then
docker rm -f ex-chatbot-app-web 2>/dev/null || true
(cd "${APP_DIR}" && docker compose down --remove-orphans 2>/dev/null) || true
log "chatbotApp 중지"
fi
if [[ -f "${COMPOSE_LOCAL}" ]]; then
(cd "${RAG_DIR}" && docker compose -f docker-compose.local.yml down --remove-orphans 2>/dev/null) || true
log "RAG/Qdrant/Admin 중지 (exchatbot-local)"
fi
# legacy slim compose 잔여 컨테이너 정리
if [[ -d "${RAG_DIR}" ]]; then
(cd "${RAG_DIR}" && docker compose -f docker-compose-slim.yml down --remove-orphans 2>/dev/null) || true
fi
for name in \
exaichatbot-chatbot20-agent-api-1 \
exaichatbot-chatbot20-agent-admin-1 \
exaichatbot-chatbot20-agent-qdrant-1 \
exaichatbot-chatbot20-agent-embed-1 \
exaichatbot-chatbot20-agent-index-1; do
docker rm -f "${name}" 2>/dev/null || true
done
if $STOP_MONGO; then
docker stop "${MONGO_CONTAINER}" 2>/dev/null || true
log "MongoDB 중지 (${MONGO_CONTAINER})"
fi
if $STOP_TUNNEL; then
stop_pid_file "SSH 터널" "${RUN_DIR}/tunnel.pid"
pkill -f "ssh -[fN].*-L ${LLM_PORT:-16000}:127.0.0.1:${LLM_PORT:-16000}" 2>/dev/null || true
pkill -f "ssh -f -N.*16000:127.0.0.1:16000" 2>/dev/null || true
log "SSH 터널 종료"
fi
log "완료"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# exdev Qdrant qa_vectors 스냅샷 → 로컬 Qdrant(:6335) 복원
#
# ./local-dev/sync-qdrant-from-exdev.sh
# ./local-dev/sync-qdrant-from-exdev.sh --force
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN_DIR="${ROOT_DIR}/local-dev/run"
SNAP_DIR="${RUN_DIR}/qdrant-snapshots"
EXDEV_USER="${EXDEV_USER:-aidev}"
EXDEV_HOST="${EXDEV_HOST:-chatbot.exdev.co.kr}"
REMOTE_QDRANT="${REMOTE_QDRANT:-http://127.0.0.1:6333}"
LOCAL_QDRANT="${LOCAL_QDRANT:-http://127.0.0.1:6335}"
COLLECTION="${QDRANT_COLLECTION:-qa_vectors}"
FORCE=false
for arg in "$@"; do
case "$arg" in
--force) FORCE=true ;;
-h|--help)
sed -n '2,6p' "$0"
exit 0
;;
esac
done
log() { echo "[$(date '+%H:%M:%S')] $*"; }
fail() { log "ERROR: $*"; exit 1; }
mkdir -p "${SNAP_DIR}"
local_count() {
curl -sf "${LOCAL_QDRANT}/collections/${COLLECTION}" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('points_count',0))" 2>/dev/null \
|| echo 0
}
remote_count() {
ssh "${EXDEV_USER}@${EXDEV_HOST}" \
"curl -sf '${REMOTE_QDRANT}/collections/${COLLECTION}'" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('points_count',0))" 2>/dev/null \
|| echo 0
}
curl -sf "${LOCAL_QDRANT}/readyz" >/dev/null 2>&1 \
|| curl -sf "${LOCAL_QDRANT}/collections" >/dev/null 2>&1 \
|| fail "로컬 Qdrant 미기동 — start-all.sh 먼저 실행"
lc="$(local_count)"
rc="$(remote_count)"
log "로컬 points: ${lc}, exdev points: ${rc}"
if [[ "${lc}" -gt 0 ]] && ! $FORCE; then
log "로컬에 이미 ${lc}건 — 건너뜀 (--force 로 덮어쓰기)"
exit 0
fi
log "exdev 스냅샷 생성..."
snap_json="$(ssh "${EXDEV_USER}@${EXDEV_HOST}" \
"curl -sf -X POST '${REMOTE_QDRANT}/collections/${COLLECTION}/snapshots'")"
snap_name="$(printf '%s' "${snap_json}" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['name'])")"
snap_size="$(printf '%s' "${snap_json}" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['size'])")"
log "스냅샷: ${snap_name} ($(numfmt --to=iec "${snap_size}" 2>/dev/null || echo "${snap_size} bytes"))"
local_file="${SNAP_DIR}/${snap_name}"
log "다운로드 → ${local_file}"
ssh "${EXDEV_USER}@${EXDEV_HOST}" \
"curl -sf '${REMOTE_QDRANT}/collections/${COLLECTION}/snapshots/${snap_name}'" \
> "${local_file}"
[[ -s "${local_file}" ]] || fail "스냅샷 파일 비어 있음"
log "로컬 컬렉션 삭제 후 복원..."
curl -sf -X DELETE "${LOCAL_QDRANT}/collections/${COLLECTION}" >/dev/null 2>&1 || true
restore_json="$(curl -sf -X POST \
"${LOCAL_QDRANT}/collections/${COLLECTION}/snapshots/upload?priority=snapshot" \
-H "Content-Type: multipart/form-data" \
-F "snapshot=@${local_file}")" || fail "스냅샷 업로드 실패"
log "복원 응답: ${restore_json}"
for i in $(seq 1 30); do
lc="$(local_count)"
if [[ "${lc}" -ge "$(( rc * 9 / 10 ))" ]] && [[ "${lc}" -gt 0 ]]; then
log "✅ Qdrant 동기화 완료 — ${lc} points"
curl -sf "${ROOT_DIR}/exAiChatBot-chatbot2.0-agent/.env" >/dev/null 2>&1 || true
exit 0
fi
sleep 2
done
lc="$(local_count)"
if [[ "${lc}" -gt 0 ]]; then
log "✅ Qdrant 동기화 완료 — ${lc} points (일부만 반영됐을 수 있음)"
else
fail "복원 후 points_count=0"
fi