feat(deploy): 도커 배포(deploy/docker: compose+Dockerfile+deploy.sh) — 우리 환경용. 렌더러가 FabriX/OpenRouter 템플릿 자동 선택, 호스트 변수화. bare deploy.sh 는 git pull 포함. Phoenix 도커 compose

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-22 18:09:48 +09:00
co-authored by Claude Fable 5.1
parent f55e673cb5
commit ea1fd350da
13 changed files with 232 additions and 11 deletions
+2
View File
@@ -13,3 +13,5 @@ deploy/langfuse/docker-compose.override.yml
# render_opencode.py 가 환경마다 만드는 파일 — 서버마다 다름
opencode/opencode.json
deploy/docker/.env
deploy/phoenix/.env
+2
View File
@@ -4,6 +4,8 @@
set -euo pipefail
cd "$(dirname "$0")"
export PYTHONUTF8=1
# git 안이면 최신으로(고객사: /www/abap-ito). 로컬 수정이 있어 못 당기면 멈춤 — 렌더 산출물은 git 밖이라 안 걸림
if git rev-parse --show-toplevel >/dev/null 2>&1; then git -C "$(git rev-parse --show-toplevel)" pull --ff-only; fi
# 필수 키 비어 있으면 여기서 멈춤 — 비어 있는 채로 띄우면 채팅 때 "누락" 에러가 나서 원인 찾기 어려움
for k in AAF_FABRIX_BASE_URL AAF_FABRIX_CLIENT_KEY AAF_FABRIX_OPENAPI_TOKEN DB_HOST; do
grep -qE "^$k=.+" .env || { echo "!! .env 에 $k 가 비어 있음 — 채우고 다시"; exit 1; }
@@ -0,0 +1,37 @@
# CodeAssist 도커 배포 .env — 이 폴더에 .env 로 복사해서 채움. be 컨테이너 환경변수로 그대로 들어감.
# 바깥 포트 (앱이 http://<서버>:<BE_PORT>/api/v1 로 붙음)
BE_PORT=8080
# LLM — 우리 환경은 OpenRouter 직접(고객사 FabriX 키 AAF_* 는 비움 → 렌더러가 OpenRouter 템플릿을 고름)
OPENROUTER_API_KEY=
OPENCODE_VISION_MODEL=openrouter/google/gemma-4-31b-it
# 토큰 서명 키 (비우면 data/.auth_secret 자동 생성)
AUTH_SECRET=
# DB — 비우면 SQLite(be_data 볼륨). 팀 PostgreSQL 쓰려면 채움
DB_HOST=
DB_PORT=5432
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_SCHEMA=codeassist
DB_SSLMODE=prefer
# 앱(Tauri)에서 오는 요청 허용 origin
CORS_ORIGINS=http://tauri.localhost,tauri://localhost,http://localhost:15173
# 채팅 타임아웃(초)
STREAM_FIRST_EVENT_TIMEOUT_S=300
STREAM_TURN_TIMEOUT_S=600
# SAP 조회 MCP — 같은 서버의 abap-opencode compose 에 mcp 컨테이너가 있으면 그 주소. 비우면 끔
MCP_HOST=mcp
MCP_PORT=3200
MCP_API_KEY=
# 관측(Phoenix) — deploy/phoenix 로 띄운 주소. 비우면 안 보냄
PHOENIX_HOST=http://20.200.170.37:6006
TRACE_PROJECT=codeassist
PHOENIX_UI_PASSWORD=
@@ -0,0 +1,12 @@
# CodeAssist 백엔드 — Django(ASGI/uvicorn). 기동 때 migrate → OpenCode 설정 렌더 → 서버.
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUTF8=1 PYTHONUNBUFFERED=1 DJANGO_SETTINGS_MODULE=config.settings
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY manage.py ./
COPY config ./config
COPY apps ./apps
COPY common ./common
# opencode/ 는 compose 가 bind 로 끼움(/app/opencode) — 렌더 결과 opencode.json 을 opencode 컨테이너도 같은 폴더로 봄
CMD ["sh", "-c", "python manage.py migrate --noinput && ([ -f seed/snippets.db ] && python manage.py import_snippets seed/snippets.db || true) && python opencode/render_opencode.py && uvicorn config.asgi:application --host 0.0.0.0 --port 8080"]
@@ -0,0 +1,9 @@
# OpenCode 헤드리스 서버 — 고객사 opencode-bin 과 같은 1.18.6 으로 고정
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --no-audit --no-fund opencode-ai@1.18.6
WORKDIR /workspace
EXPOSE 4096
# .opencode/tools 의 커스텀 도구가 @opencode-ai/plugin 을 씀 — bind 된 workspace 라 기동 때 설치(있으면 건너뜀)
CMD ["sh", "-c", "cd /workspace/.opencode && npm install --no-audit --no-fund --silent >/dev/null 2>&1; cd /workspace && opencode serve --hostname 0.0.0.0 --port 4096"]
+35
View File
@@ -0,0 +1,35 @@
# CodeAssist 백엔드 — 도커 배포 (우리 환경용)
고객사(도커 없음)는 `5_django_backend/deploy.sh`(bare) 를 씀. 여긴 도커 되는 서버(20.200.170.37 등)용.
둘 다 같은 저장소 구조를 쓰고, 배포 스크립트만 다름:
| 환경 | 스크립트 | 하는 일 |
|---|---|---|
| 우리(도커) | `deploy/docker/deploy.sh [up\|update\|stop\|logs\|status]` | (update 면 git pull) → 이미지 빌드 → be·opencode 기동 → 헬스 확인 |
| 고객사(bare) | `deploy.sh` | git pull → .env 키 보정 → migrate → 렌더 → opencode-bin·uvicorn 재기동 → 헬스 |
## 처음 한 번
```bash
cd <저장소>/code-assistant/5_django_backend/deploy/docker # 고객사 repo 구조 기준. 우리 git 이면 5_django_backend/deploy/docker
cp .env.example .env && vi .env # OPENROUTER_API_KEY, PHOENIX_HOST 정도만 채우면 됨
./deploy.sh
```
컨테이너 2개: `be`(Django, 바깥 `BE_PORT`=8080) + `opencode`(OpenCode 1.18.6). 저장소의 `opencode/` 폴더를 둘 다 bind 로 봐서
AGENTS.md·위키·커스텀 도구가 그대로 먹고, be 가 렌더한 `opencode.json` 을 opencode 가 바로 읽음.
## 이후
```bash
./deploy.sh update # 최신 코드로 갱신
./deploy.sh status # 상태 + 헬스
./deploy.sh logs
```
## 알아둘 것
- LLM: `.env``AAF_FABRIX_*` 가 없으면 렌더러가 OpenRouter 템플릿(`opencode/opencode.openrouter.json.tmpl`)을 고름. 고객사는 FabriX 템플릿.
- DB: `DB_HOST` 비우면 SQLite(`be_data` 볼륨). 팀 PostgreSQL 쓰려면 DB_* 채움(스키마 `codeassist`).
- SAP 조회(MCP): 같은 서버에 abap-opencode compose 가 떠 있으면 그 `mcp` 컨테이너를 쓸 수 있음 — 두 compose 를 같은 네트워크로 묶어야 함(아직 안 함). 비우면 꺼짐.
- 관측: `PHOENIX_HOST=http://<서버>:6006` (`deploy/phoenix/docker-compose.yml` 로 띄운 것). 프로젝트 `codeassist`.
- 앱(Tauri) 쪽은 `2_frontend/.env.production``VITE_API_BASE_URL` 을 이 서버로 바꿔 빌드해야 붙음.
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# CodeAssist 도커 배포 — 우리 환경용. 이거 하나면 됨. (ABAP_OPENCODE web/deploy/deploy.sh 와 같은 명령 모양)
#
# ./deploy.sh 빌드 + 기동 (떠 있으면 갱신)
# ./deploy.sh update git pull 까지 하고 갱신
# ./deploy.sh stop 종료 (데이터 볼륨은 남음)
# ./deploy.sh logs 로그
# ./deploy.sh status 상태
#
# 전제: 이 폴더에 .env (cp .env.example .env 후 채움)
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"
CMD="${1:-up}"
command -v docker >/dev/null || { echo "docker 없음"; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "docker compose v2 없음"; exit 1; }
[ -f .env ] || { echo ".env 없음 — cp .env.example .env 후 채워"; exit 1; }
set -a; . ./.env; set +a
PORT="${BE_PORT:-8080}"; BASE="http://localhost:${PORT}/api/v1"
case "$CMD" in
stop) docker compose down; echo "종료(볼륨 유지)"; exit 0 ;;
logs) docker compose logs -f --tail=100; exit 0 ;;
status) docker compose ps; curl -fsS -m 5 "$BASE/health" && echo || echo "헬스 응답 없음"; exit 0 ;;
update) git -C "$HERE/../../.." pull --ff-only || { echo "git pull 실패 — 로컬 변경 확인"; exit 1; } ;;
up) ;;
*) echo "모르는 명령: $CMD (up|update|stop|logs|status)"; exit 1 ;;
esac
[ -n "${OPENROUTER_API_KEY:-}${AAF_FABRIX_BASE_URL:-}" ] || { echo ".env 에 OPENROUTER_API_KEY(또는 AAF_FABRIX_*) 가 비어 있음"; exit 1; }
echo "-- 이미지 빌드 + 기동"
docker compose up -d --build
echo "-- 기동 확인(최대 2분)"
for _ in $(seq 1 60); do curl -fsS -m 3 "$BASE/health" >/dev/null 2>&1 && break; sleep 2; done
curl -fsS -m 5 "$BASE/health" || { echo "안 떴음 → ./deploy.sh logs"; docker compose ps; exit 1; }
echo
echo "-- OpenCode 설정(렌더 결과) 모델:"; grep -oE '"model": *"[^"]+"' ../../opencode/opencode.json || true
echo "-- 앱 주소: http://$(hostname -I 2>/dev/null | awk '{print $1}'):${PORT}/api/v1"
@@ -0,0 +1,35 @@
# CodeAssist 백엔드 — 우리 환경(도커)용. 고객사(도커 없음)는 5_django_backend/deploy.sh.
# 사용: 이 폴더에서 cp .env.example .env 채우고 ./deploy.sh
# 밖으로 여는 건 be(:8080 → BE_PORT) 하나. 앱(Tauri)이 http://<서버>:<BE_PORT>/api/v1 로 붙음.
name: codeassist
services:
be:
build: { context: ../.., dockerfile: deploy/docker/Dockerfile.be }
ports:
- "${BE_PORT:-8080}:8080"
env_file: .env
environment:
OPENCODE_BASE_URL: http://opencode:4096
OPENCODE_DIRECTORY: /workspace # OpenCode 컨테이너가 보는 workspace 경로 — 세션이 여기 생김
BACKEND_HOST: be # 렌더된 opencode.json 의 게이트웨이 주소(고객사 모드일 때)
BACKEND_PORT: "8080"
MCP_HOST: ${MCP_HOST:-mcp}
volumes:
- ../../opencode:/app/opencode # AGENTS.md·.opencode·wiki·렌더 결과(opencode.json)
- be_data:/app/data # SQLite(DB_HOST 비울 때)·auth secret
depends_on: [opencode]
restart: unless-stopped
opencode:
build: { context: ../.., dockerfile: deploy/docker/Dockerfile.opencode }
environment:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
volumes:
- ../../opencode:/workspace
- oc_data:/root/.local/share/opencode # 세션·메시지 저장
restart: unless-stopped
volumes:
be_data:
oc_data:
@@ -0,0 +1,22 @@
# Phoenix — 우리 환경(도커)용. 한 대에 하나 띄우고 CodeAssist·ABAP_OPENCODE 둘 다 여기로 보냄(프로젝트로 나뉨).
# 고객사(도커 없음)는 같은 폴더의 run.sh(pip 방식).
# docker compose up -d → http://<서버>:6006
name: phoenix
services:
phoenix:
image: arizephoenix/phoenix:latest
ports:
- "${PHOENIX_PORT:-6006}:6006"
environment:
PHOENIX_WORKING_DIR: /data
PHOENIX_TELEMETRY_ENABLED: "false"
# 로그인 켜려면 둘 다 채움(첫 관리자 admin@localhost / PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD). 켜면 수신에도 API 키 필요 — 모듈 헤더 한 줄 추가해야 함
# PHOENIX_ENABLE_AUTH: "true"
# PHOENIX_SECRET: ${PHOENIX_SECRET}
volumes:
- phoenix_data:/data
restart: unless-stopped
volumes:
phoenix_data:
@@ -6,7 +6,7 @@
"npm": "@ai-sdk/openai-compatible",
"name": "FabriX",
"options": {
"baseURL": "http://127.0.0.1:${BACKEND_PORT}/api/ito",
"baseURL": "http://${BACKEND_HOST}:${BACKEND_PORT}/api/ito",
"apiKey": "${AAF_GATEWAY_KEY}"
},
"models": ${AAF_FABRIX_MODELS_JSON}
@@ -18,7 +18,7 @@
"mcp": {
"sap-icf": {
"type": "remote",
"url": "http://127.0.0.1:${MCP_PORT}/mcp",
"url": "http://${MCP_HOST}:${MCP_PORT}/mcp",
"headers": { "Authorization": "Bearer ${MCP_API_KEY}" },
"enabled": ${SAP_MCP_ENABLED}
}
@@ -0,0 +1,23 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"options": { "apiKey": "{env:OPENROUTER_API_KEY}" },
"models": {
"z-ai/glm-5.2": { "name": "GLM 5.2" },
"google/gemma-4-31b-it": { "name": "Gemma4", "attachment": true, "modalities": { "input": ["text", "image"], "output": ["text"] } }
}
}
},
"model": "openrouter/z-ai/glm-5.2",
"tools": { "question": false },
"permission": { "external_directory": "allow" },
"mcp": {
"sap-icf": {
"type": "remote",
"url": "http://${MCP_HOST}:${MCP_PORT}/mcp",
"headers": { "Authorization": "Bearer ${MCP_API_KEY}" },
"enabled": ${SAP_MCP_ENABLED}
}
}
}
+8 -4
View File
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
"""고객사(FabriX)용 opencode.json 만들기 — 템플릿의 ${이름} 을 .env 값으로 채움.
"""opencode.json 만들기 — 템플릿의 ${이름} 을 .env 값으로 채움.
python opencode/render_opencode.py # ../.env 읽어서 opencode/opencode.json 덮어씀
python opencode/render_opencode.py --check # 쓰진 않고 결과만 출력
개발(OpenRouter)로 돌아가려면 `git checkout opencode/opencode.json`.
템플릿 고르기: AAF_FABRIX_BASE_URL 이 있으면 고객사(FabriX 게이트웨이), 없으면 OpenRouter(우리 환경·로컬).
호스트: BACKEND_HOST/MCP_HOST 기본 127.0.0.1, 도커 compose 에선 be / mcp 컨테이너 이름.
ABAP_OPENCODE web/deploy/bare/render-opencode.py 와 같은 규칙:
AAF_FABRIX_MODELS="605:Gemma4,339:GaussO Flash,581:GaussO Think" → 모델 목록. 키가 곧 x-llm-model-id.
비어 있으면 AAF_FABRIX_MODEL_ID 하나(이름 FabriX). 기본 모델은 목록의 첫 번째.
@@ -34,7 +35,7 @@ def load_env(path: Path) -> dict[str, str]:
k, v = line.split("=", 1)
v = v.split(" #", 1)[0].strip().strip("\"'") # 줄 끝 주석은 두 칸 띄고 #
out[k.strip()] = v
out.update({k: v for k, v in os.environ.items() if k.startswith(("AAF_", "BACKEND_PORT", "MCP_", "OPENCODE_"))})
out.update({k: v for k, v in os.environ.items() if k.startswith(("AAF_", "BACKEND_", "MCP_", "OPENCODE_", "OPENROUTER_"))})
return out
@@ -53,7 +54,9 @@ def render(env: dict[str, str]) -> str:
return m
values["AAF_FABRIX_MODELS_JSON"] = json.dumps({mid: _model(mid, name) for mid, name in models.items()}, ensure_ascii=False)
values["AAF_FABRIX_DEFAULT_MODEL"] = next(iter(models))
values.setdefault("BACKEND_HOST", "127.0.0.1")
values.setdefault("BACKEND_PORT", "8001")
values.setdefault("MCP_HOST", "127.0.0.1")
values.setdefault("MCP_PORT", "8888")
# SAP MCP: MCP_API_KEY 가 있으면 켬. 없으면 끔(로컬 개발)
values["SAP_MCP_ENABLED"] = "true" if values.get("MCP_API_KEY") else "false"
@@ -67,7 +70,8 @@ def render(env: dict[str, str]) -> str:
missing.append(m.group(1))
return v
text = re.sub(r"\$\{(\w+)\}", fill, (HERE / "opencode.fabrix.json.tmpl").read_text(encoding="utf-8"))
tmpl = "opencode.fabrix.json.tmpl" if env.get("AAF_FABRIX_BASE_URL") else "opencode.openrouter.json.tmpl"
text = re.sub(r"\$\{(\w+)\}", fill, (HERE / tmpl).read_text(encoding="utf-8"))
if missing:
print("경고: 비어 있는 값 —", ", ".join(sorted(set(missing))), file=sys.stderr)
return text