AI 블로그 완성기(3) - 전체적인 구조를 보여드립니다
AI 요약
이 블로그 포스트는 AI 블로그의 전체적인 구조를 보여주며, 컨테이너 구성과 네트워크 연결을 설명합니다. 두 개의 Docker 컨테이너로 구성되어 있으며, blog 컨테이너는 Next.js 앱 (포트 3001)이고, LLM 서버(GPU 가속)은 Ollama로, 리버스 프록시와 SSL 인증서 관리를 담당하는 NPM도 함께 작동합니다. 또한, GPU 가속을 위한 deploy 블록에서 핵심적인 부분을 설명하고 있습니다.
AI 블로그 완성기(3) - 전체적인 구조를 보여드립니다
완성기 1편에서는 RAG 챗봇의 기획과 구현을, 2편에서는 로컬 LLM의 삽질과 회고를 다뤘습니다. 이번 편에서는 조금 다른 관점에서 이야기합니다.
"이 블로그는 지금 어떻게 돌아가고 있는가?"
Docker 컨테이너 구성, GPU 가속 설정, 리버스 프록시까지 — 블로그를 실제로 서빙하고 있는 인프라를 정리했습니다.
전체 아키텍처
이 블로그는 2개의 Docker 컨테이너로 구성되어 있습니다.
┌────────────────────────────────────────
│ Host Server | │ |
│ RTX 4070 SUPER (12GB) |
│ │
│ ┌──────────┐ blog-internal ┌───────────┐ │
│ │ blog │───────────►│ ollama │ │
│ │ (Next.js) │ HTTP :11434 │ (LLM/GPU) │ │
│ │ :3001 │ │ │ │
│ └────┬─────┘ └───────────┘ │
│ proxy-net │
│ ┌────┴──────┐ │
│ │ NPM │ ← HTTPS + SSL 자동 갱신 │
│ │ (Reverse │ │
│ │ Proxy) │ │
│ └───────────┘ │
└────────────────────────────────────────
-
blog: Next.js 16 앱 (포트 3001)
-
ollama: LLM 서버 (GPU 가속, 내부 포트 11434)
-
NPM (Nginx Proxy Manager): 리버스 프록시 + SSL 인증서 관리
두 컨테이너는 blog-internal이라는 내부 네트워크로 연결됩니다. Ollama는 외부에 포트를 노출하지 않고, 오직 blog 컨테이너에서만 접근할 수 있습니다.
Docker Compose 구성 뜯어보기
blog 컨테이너
blog:
build: .
container_name: blog
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- blog-data:/app/prisma # SQLite DB 영속화
- blog-uploads:/app/public/uploads # 미디어 파일
- /var/run/docker.sock:/var/run/docker.sock:ro # 컨테이너 상태 모니터링
- /sys/class/thermal:/sys/class/thermal:ro # 서버 온도 읽기
environment:
- DATABASE_URL=file:/app/prisma/blog.db
- OLLAMA_URL=http://ollama:11434
depends_on:
- ollama
Docker 소켓 마운트: /var/run/docker.sock을 읽기 전용으로 마운트합니다. 블로그 대시보드에서 현재 돌아가는 컨테이너 상태를 실시간으로 보여주기 위해서입니다. 보안상 읽기 전용(:ro)으로 제한했습니다.
Thermal 정보 마운트: /sys/class/thermal을 마운트해서 서버의 CPU 온도를 블로그 UI에 표시합니다. 포트폴리오 블로그다 보니 "이 서버가 실제로 돌아가고 있다"는 느낌을 주고 싶었습니다.
Named Volume: SQLite DB와 업로드 파일은 Named Volume으로 영속화합니다. 컨테이너를 재빌드해도 데이터가 유지됩니다.
Ollama 컨테이너 (GPU 가속)
ollama:
image: ollama/ollama:latest
container_name: blog-ollama
restart: unless-stopped
volumes:
- /usr/share/ollama/.ollama:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
networks:
- blog-internal
여기서 핵심은 deploy.resources.reservations 블록입니다.
GPU 가속: 왜, 그리고 어떻게
CPU vs GPU 체감 차이
2편에서도 언급했지만, CPU와 GPU의 LLM 추론 속도 차이는 극적입니다.
| 환경 | 모델 | 첫 토큰 | 전체 응답 (400토큰) |
|---|---|---|---|
| CPU (서버) | qwen3:8b | ~5초 | ~20초 |
| GPU (RTX 4070 SUPER) | qwen3:8b | ~0.5초 | ~3초 |
약 6~7배 빠릅니다. 사용자 경험 관점에서 20초와 3초는 "참을 수 있다"와 "꽤 빠르다"의 차이입니다.
NVIDIA Container Toolkit 설정
Docker 컨테이너에서 GPU를 쓰려면 호스트에 NVIDIA Container Toolkit이 설치되어 있어야 합니다.
# NVIDIA Container Toolkit 설치 (Ubuntu)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
설치 후 Docker Compose에서 deploy.resources.reservations.devices로 GPU를 예약하면 됩니다. 이전에는 runtime: nvidia를 썼지만, Docker Compose v2에서는 deploy 블록을 사용하는 게 권장됩니다.
모델 볼륨 공유
volumes:
- /usr/share/ollama/.ollama:/root/.ollama
Ollama 모델 파일을 호스트의 /usr/share/ollama/.ollama에 저장합니다. 이렇게 하면 컨테이너를 재생성해도 모델을 다시 다운로드할 필요가 없습니다. qwen2.5:7b 하나가 4.7GB이고, 현재 12개 모델이 설치되어 있어서 총 30GB 이상의 모델 데이터가 있습니다.
qwen3:8b 6 GB ← 챗봇 메인 모델
nomic-embed-text 274 MB ← 임베딩 모델
gemma2:9b 5.4 GB ← 대안 모델
phi3:14b 7.9 GB ← 실험용
... (8개 더)
RTX 4070 SUPER의 VRAM이 12GB이므로, 8B 모델은 전체가 VRAM에 올라갑니다. 14B 모델부터는 일부가 시스템 RAM으로 넘어가면서 속도가 크게 떨어집니다.
멀티 스테이지 Dockerfile
블로그의 Dockerfile은 3단계 멀티 스테이지 빌드를 사용합니다.
# 1단계: 의존성 설치
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 2단계: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# 3단계: 실행
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache docker-cli sqlite
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# ... (필요한 파일만 복사)
왜 멀티 스테이지인가
| 방식 | 이미지 크기 | 포함 내용 |
|---|---|---|
| 단일 스테이지 | ~1.5GB | node_modules 전체 + 소스 + 빌드 결과물 |
| 멀티 스테이지 | ~300MB | standalone 결과물 + 런타임 의존성만 |
Next.js의 output: 'standalone' 설정과 결합하면 프로덕션에 필요한 최소한의 파일만 포함됩니다. 빌드 도구, 개발 의존성, 소스 코드는 최종 이미지에 포함되지 않습니다.
네트워크 구성
이중 네트워크
networks:
proxy-net:
external: true # NPM(리버스 프록시)과 통신
blog-internal:
driver: bridge # blog ↔ ollama 내부 통신
proxy-net: Nginx Proxy Manager와 연결된 외부 네트워크입니다. 다른 서비스들(Grafana, Portainer 등)도 이 네트워크에 연결되어 하나의 리버스 프록시를 공유합니다.
blog-internal: blog과 ollama만 연결된 격리된 네트워크입니다. Ollama가 외부에서 직접 접근되는 것을 방지합니다.
이 구조 덕분에 Ollama는 외부 포트 노출 없이 blog 컨테이너에서만 http://ollama:11434로 접근할 수 있습니다.
리버스 프록시와 SSL
블로그는 Nginx Proxy Manager를 통해 HTTPS로 서빙됩니다. NPM이 Let's Encrypt 인증서를 자동으로 발급하고 갱신해주기 때문에 SSL 설정에 신경 쓸 필요가 없습니다.
인터넷 → NPM (443/HTTPS) → blog (3001/HTTP)
자동 마이그레이션과 초기화
컨테이너 시작 시 entrypoint.sh가 실행되면서 두 가지 작업을 자동으로 처리합니다.
1. SQLite 마이그레이션
for dir in /app/prisma/migrations/*/; do
# 이미 적용된 마이그레이션은 건너뜀
ALREADY_APPLIED=$(sqlite3 "$DB_PATH" \
"SELECT COUNT(*) FROM _prisma_migrations WHERE migration_name='$migration_name';")
if [ "$ALREADY_APPLIED" = "0" ]; then
sqlite3 "$DB_PATH" < "$sql_file"
fi
done
prisma migrate deploy 대신 직접 마이그레이션 스크립트를 작성했습니다. Prisma CLI가 프로덕션 이미지에 포함되면 이미지 크기가 불필요하게 커지기 때문입니다. SQLite3 CLI만으로 마이그레이션을 적용하는 게 더 가볍습니다.
2. 관리자 계정 생성
ADMIN_EXISTS=$(sqlite3 "$DB_PATH" \
"SELECT COUNT(*) FROM AdminUser WHERE username='${ADMIN_USERNAME}';")
if [ "$ADMIN_EXISTS" = "0" ]; then
node /app/init-admin.js
fi
환경 변수로 전달받은 관리자 계정이 DB에 없으면 자동으로 생성합니다. 컨테이너를 처음 띄울 때 수동 작업 없이 바로 관리자로 로그인할 수 있습니다.
운영하면서 느낀 점
Docker + GPU는 생각보다 안정적이다
NVIDIA Container Toolkit만 잘 설정하면 GPU 패스스루가 매우 안정적으로 동작합니다. 몇 주째 컨테이너를 재시작하지 않았는데, Ollama가 GPU를 점유하고 있는 상태에서도 별다른 메모리 누수나 성능 저하 없이 돌아가고 있습니다.
Named Volume은 필수다
처음에 바인드 마운트를 썼다가 퍼미션 문제로 고생했습니다. Alpine Linux 기반 컨테이너는 호스트와 UID/GID가 다르기 때문에, Named Volume을 쓰는 게 훨씬 편합니다. Docker가 볼륨 권한을 알아서 관리해줍니다.
보안은 네트워크 격리가 핵심이다
Ollama에 인증이 없으므로, 포트를 외부에 노출하면 누구나 모델을 사용할 수 있습니다. blog-internal 네트워크로 격리하고 포트를 노출하지 않는 것만으로 간단하고 효과적인 보안을 확보했습니다.
댓글 (0)
아직 댓글이 없습니다.