Loading...
  • 한성산한성산
  • Date:  
  • Backend

sshsys.kr 배포기 — Next.js · FastAPI · docconv를 단일 VPS에 띄우기

목록으로
sshsys.kr 배포기 — Next.js · FastAPI · docconv를 단일 VPS에 띄우기
Backend한성산한성산10분 읽기

도메인, OpenAI API, 이미 운영 중이던 한 대의 VPS. 이 셋만으로 내가 쓴 글들이 검색되고, 시멘틱 검색이 작동하는 사이트를 만들었다. 도구는 Next.js · FastAPI · docconv 셋. 공식 문서대로 따라가다 막힌 지점들과 그걸 어떻게 풀었는지 기록한다.

왜 자체 VPS인가

처음엔 Vercel을 고민했다. Next.js의 본가이고, 무료 티어로 충분히 시작 가능. 그러나 두 가지 이유로 자체 호스팅을 택했다.

  • 비용 통제 — 트래픽 늘어나도 같은 VPS 안에서 끝남. Vercel은 이미지 최적화·serverless 호출 단위로 과금

  • 사이드카 자유도 — Python ML 스택(docconv)을 같은 호스트에 띄워야 하는데, Vercel에선 별도 서비스로 분리해야 한다. 작은 사이트엔 과함

이미 다른 서비스(ai.sshsys.kr)가 운영되고 있던 VPS라서, 그 위에 nginx 가상호스트만 추가하는 형태가 가장 단순했다.

최종 토폴로지

VPS (Ubuntu 24.04)
├── nginx :80/:443
├── postgresql :5432 (localhost only)
│
├── ai.sshsys.kr   → :8001 hssagent (기존)
├── sshsys.kr      → :3000 portfolio web (Next.js)
├── api.sshsys.kr  → :8002 portfolio api (FastAPI)
└── docs.sshsys.kr → :8003 docconv (지식 플랫폼)

4개의 도메인, 3개의 새 서비스. 각각 격리된 venv·systemd 유닛·DB를 가진다.

첫 함정: 공유 venv 충돌

처음에 시간을 아끼려고 portfolio 백엔드와 docconv를 같은 venv에 깔았다. 곧장 깨졌다.

fastapi.exceptions.FastAPIError: Invalid args for response field!
Hint: check that ForwardRef('UploadFile') is a valid Pydantic field type.

portfolio가 FastAPI 0.115.0에 핀되어 있는데, docconv는 0.136.1을 기대해서 한 venv에 두 버전이 충돌. 결국 venv를 분리했다.

/var/www/sshsys/
├── venv/                  ← portfolio 전용 (FastAPI 0.115.0)
└── docconv/venv/          ← docconv 전용 (FastAPI 0.136.1)

당연한 듯하지만 한 번 합쳐봐야 절실해진다. 운영 환경에서 venv 공유는 절대 비추.

Pydantic Settings의 까다로움

portfolio 백엔드의 config.py는 pydantic-settings로 환경변수를 관리한다. 그런데 docconv 연동을 위해 .envDOCCONV_URL·DOCCONV_INTERNAL_TOKEN·PUBLIC_SITE_URL을 추가하니 시작도 못 했다.

pydantic_core._pydantic_core.ValidationError: 4 validation errors for Settings
public_site_url
  Extra inputs are not permitted

해결: extra="ignore" 옵션 추가. 이러면 라우터에서 os.environ.get(...)로 직접 읽는 변수들이 Settings에 등록되어 있지 않아도 거부되지 않는다.

model_config = SettingsConfigDict(
    env_file=".env",
    case_sensitive=False,
    extra="ignore",
)

빌드 시점에 SSL 미발급 도메인 호출하는 함정

가장 까다로웠던 부분. 프론트엔드를 빌드할 때 Next.js가 getStaticProps를 실행하면서 https://api.sshsys.kr에 axios로 fetch한다. 그런데 그 시점엔 nginx에 api.sshsys.kr 가상호스트도, SSL 인증서도 없었다. 그래서 같은 IP의 다른 호스트의 인증서가 응답하면서 호스트 불일치 에러.

Error: Hostname/IP does not match certificate's altnames:
  Host: api.sshsys.kr is not in the cert's altnames:
  DNS:ai.sshsys.kr, DNS:<other-service>

해결책은 서버사이드용 API URL과 브라우저용 API URL을 분리하는 것이다.

// src/lib/api.js
export const API_URL =
  (typeof window === "undefined"
    ? process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL
    : process.env.NEXT_PUBLIC_API_URL) || "http://localhost:8000";

.env.local:

NEXT_PUBLIC_API_URL=https://api.sshsys.kr      # 브라우저
INTERNAL_API_URL=http://127.0.0.1:8002          # 빌드/SSR

빌드 시점은 nginx 거치지 않고 백엔드 직접 호출. 브라우저는 공개 도메인. nginx 설정 순서에 빌드가 종속되지 않는다. 같은 정보가 두 경로로 흘러야 할 때 분리하는 패턴은 다른 곳에서도 쓸 수 있다.

docconv 사이드카 — pgvector로 단순화

이 사이트에는 시멘틱 검색·Q&A·엔티티 자동 추출을 담당하는 별도 서비스가 떠 있다. docconv라는 지식 플랫폼인데, 처음엔 ChromaDB를 쓸 줄 알았다.

그런데 docconv의 store는 PostgreSQL + pgvector 백엔드를 지원한다. 이미 portfolio 백엔드용 Postgres가 있으므로, 같은 인스턴스에 docconv DB만 추가하면 끝. ChromaDB가 자체 SQLite + DuckDB 메모리를 따로 잡는 것 대비 메모리 풋프린트가 작다.

{"status":"ok","backend":"postgresql+pgvector",
 "collections":{"chunks":0,"entities":0},
 "embedding_model":"text-embedding-3-small"}

대기 메모리 140MB. ChromaDB 구성이었으면 250MB 이상 잡았을 것.

portfolio ↔ docconv 연결

두 서비스 사이엔 내부 토큰 한 줄로 끝낸다. portfolio가 글을 저장하면 background_task로 docconv의 /wiki/ingest를 호출. docconv가 본문을 청킹·임베딩·엔티티 추출 후 entity 슬러그를 반환하면, portfolio가 그걸 DB의 entities JSONB 컬럼에 저장한다.

# portfolio backend/app/routers/blogs.py
def _index_blog(blog_id: str, payload: dict) -> None:
    entities = knowledge_client.ingest(
        kind="blog",
        doc_id=blog_id,
        title=payload.get("title") or "",
        content_html=payload.get("content") or "",
        url=f"{SITE_URL}/blog/{blog_id}",
        tags=payload.get("tags") or [],
        category=payload.get("category"),
    )
    if entities is None:
        return
    db = SessionLocal()
    try:
        b = db.query(Blog).filter(Blog.id == blog_id).first()
        if b:
            b.entities = entities or None
            db.commit()
    finally:
        db.close()

nginx에선 /wiki/ingest·/wiki/remove·/extract 같은 인덱싱 엔드포인트는 127.0.0.1만 허용하고, 검색·Q&A는 공개. 이중 격리.

메모리 측정 결과

4개 서비스가 동시에 떠 있는 정상 운영 시:

sshsys-docs (docconv)        139.6 MB
sshsys-backend (hssagent)    202.7 MB
sshsys-api (portfolio)       215.5 MB
sshsys-web (Next.js)          70~150 MB
─────────────────────────────────────
                             약 700 MB

2GB VPS에 충분히 들어간다. 빌드 시점만 잠깐 1GB 가까이 점유하는데, 그때만 docconv를 일시 중단하면 안전하다.

이 글이 사이트에 첫 콘텐츠로 올라가면, docconv가 자동으로 entity를 뽑아낸다. /topics/fastapi·/topics/nginx·/topics/pgvector 같은 페이지가 자동 생성될 것이다.

#nextjs#fastapi#nginx#vps#deploy#selfhost#postgresql#pgvector
한성산
Written by
한성산