feat(devops): Phase E CI/CD 파이프라인 — 개발서버 배포 구성

- deploy/systemd: kintex.service(백엔드 8021) + kintex-nanobanana.service(워커, GEMINI env 워커 한정)
- deploy/nginx: kintex.zioinfo.kr vhost(/ static, /api·/ws → 8021)
- deploy/deploy_kintex.sh: 검증→원자교체→헬스체크→롤백(WISE 패턴)
- deploy/setup_kintex_service.py: 멱등 프로비저닝(유닛·venv·nginx)
- deploy/deploy_server_kintex_block.py: 서버 webhook 블록 정본 사본
- Jenkinsfile: push→build(vite+bootJar)→deploy→health
- .gitattributes: 배포 산출물 LF 강제

포트 8021 배정(8003~8020 기존 GUARDiA 예약). 시크릿은 서버 env only(미커밋).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-11 18:30:03 +09:00
parent eccbeb1337
commit 21fecc37da
8 changed files with 380 additions and 0 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
# 배포 산출물은 Linux 서버에서 실행/파싱되므로 LF 강제(CRLF 시 systemd 유닛·bash 파싱 오류).
deploy/**/*.sh text eol=lf
deploy/**/*.service text eol=lf
deploy/**/*.conf text eol=lf
deploy/**/*.py text eol=lf
Jenkinsfile text eol=lf

72
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,72 @@
// KINTEX 자동전시시스템 — CI/CD 파이프라인 (git push → Gitea webhook → 빌드 → 배포 → 헬스체크)
// 실행 노드: 서버(101.79.17.164) Jenkins. 프론트(vite)→/var/www/kintex, 백엔드(bootJar)→/opt/kintex/app/app.jar,
// kintex.service + kintex-nanobanana.service 재기동. 배포 단계는 sudo(deploy_kintex.sh) 위임.
// ※ 상시 자동배포는 deploy_server.py(webhook, 포트 9999)의 kintex 블록이 1차 경로. 본 파이프라인은 Jenkins 병행 트랙.
pipeline {
agent any
options {
timeout(time: 40, unit: 'MINUTES')
timestamps()
buildDiscarder(logRotator(numToKeepStr: '10'))
}
environment {
WEB_ROOT = '/var/www/kintex'
APP_JAR = '/opt/kintex/app/app.jar'
BACKEND_PORT = '8021'
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Frontend Build') {
steps {
dir('src/frontend') {
sh 'npm ci --silent || npm install --silent'
sh 'NODE_OPTIONS=--max-old-space-size=4096 npm run build'
}
}
}
stage('Backend Build') {
steps {
dir('src/backend') {
sh 'chmod +x gradlew'
sh './gradlew clean bootJar -x test -q --no-daemon'
}
}
}
stage('Verify Jar') {
steps {
sh '''
JAR=$(ls -1 src/backend/build/libs/kintex-backend-*.jar | grep -v plain | head -1)
[ -n "$JAR" ] || { echo "jar not found"; exit 1; }
unzip -l "$JAR" | grep -q 'BOOT-INF/classes/com/zioinfo/kintex/KintexApplication.class' \
|| { echo "invalid jar (no main class)"; exit 1; }
echo "jar OK: $JAR"
'''
}
}
stage('Deploy') {
steps {
// 권한 필요한 배포는 deploy_kintex.sh 로 위임(sudo NOPASSWD 권장).
sh 'sudo /opt/kintex/deploy_kintex.sh "$WORKSPACE"'
}
}
stage('Health Check') {
steps {
sh '''
for i in $(seq 1 15); do
code=$(curl -s -m 6 -o /dev/null -w '%{http_code}' http://127.0.0.1:'"$BACKEND_PORT"'/health || true)
echo "health try $i: $code"
if [ "$code" = "200" ]; then exit 0; fi
sleep 5
done
echo "health check failed"; systemctl is-active kintex.service || true; exit 1
'''
}
}
}
post {
success { echo 'KINTEX 배포 성공 (kintex.zioinfo.kr, 백엔드 8021)' }
failure { echo 'KINTEX 빌드/배포 실패 — 롤백은 deploy_kintex.sh 가 수행' }
}
}

77
deploy/deploy_kintex.sh Normal file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# KINTEX 배포 스크립트 — deploy_server.py(webhook) 또는 Jenkins가 실행.
# 인자: $1 = 소스 체크아웃 루트(빌드 산출물 위치, 기본 /opt/kintex/src)
# 동작(fail-safe): 프론트 dist 검증→원자적 교체(/var/www/kintex)→백엔드 jar 교체→서비스 재기동→헬스체크.
# 깨진 빌드가 라이브를 무너뜨리지 않도록 "검증 후 교체" + 실패 시 롤백(WISE deploy_uiws.sh 패턴).
set -euo pipefail
SRC="${1:-/opt/kintex/src}"
WEB_ROOT=/var/www/kintex
APP_JAR=/opt/kintex/app/app.jar
BACKEND_PORT=8021
log(){ echo "[deploy-kintex] $*"; }
die(){ echo "[deploy-kintex] ERROR: $*" >&2; exit 1; }
# ── 1. 프론트: 빌드 완전성 검증(라이브 건드리기 전) → 스테이징 → 원자적 rename ──
DIST="$SRC/src/frontend/dist"
if [ -d "$DIST" ]; then
[ -f "$DIST/index.html" ] || die "dist/index.html 없음 — 불완전 빌드, 배포 중단(라이브 무변경)"
[ -d "$DIST/assets" ] || die "dist/assets 없음 — 불완전 빌드, 배포 중단"
mapfile -t REFS < <(grep -oE '/assets/[A-Za-z0-9._-]+\.(js|css)' "$DIST/index.html" | sort -u)
[ "${#REFS[@]}" -gt 0 ] || die "index.html 참조 assets 없음 — 비정상 빌드, 중단"
for ref in "${REFS[@]}"; do
[ -f "${DIST}${ref}" ] || die "참조 자산 누락: ${ref} — 불완전 빌드, 중단"
done
log "프론트 빌드 검증 통과(${#REFS[@]} assets)"
STAGE="${WEB_ROOT}.new.$$"
rm -rf "$STAGE"; mkdir -p "$STAGE"
cp -r "$DIST/." "$STAGE/"
if [ -d "$WEB_ROOT" ] && [ -n "$(ls -A "$WEB_ROOT" 2>/dev/null)" ]; then
tar czf "/var/www/kintex.bak_$(date +%Y%m%d_%H%M%S).tgz" -C "$WEB_ROOT" . || log "WARN: 프론트 백업 실패(계속)"
fi
OLD="${WEB_ROOT}.old.$$"
[ -d "$WEB_ROOT" ] && mv "$WEB_ROOT" "$OLD" || true
mv "$STAGE" "$WEB_ROOT"
rm -rf "$OLD"
log "프론트 배포 완료 → $WEB_ROOT"
else
log "WARN: $DIST 없음 — 프론트 배포 건너뜀(백엔드만)"
fi
# ── 2. 백엔드 jar: 검증(BOOT-INF 메인 클래스) 후 교체 ──
JAR=$(ls -1 "$SRC"/src/backend/build/libs/kintex-backend-*.jar 2>/dev/null | grep -v plain | head -1 || true)
if [ -n "$JAR" ]; then
unzip -l "$JAR" | grep -q 'BOOT-INF/classes/com/zioinfo/kintex/KintexApplication.class' \
|| die "유효하지 않은 jar(메인 클래스 없음) — 배포 중단"
[ -f "$APP_JAR" ] && cp "$APP_JAR" "${APP_JAR}.bak" || true
cp "$JAR" "$APP_JAR"
log "백엔드 jar 교체 → $APP_JAR ($(basename "$JAR"))"
else
log "WARN: bootJar 없음 — 백엔드 교체 건너뜀"
fi
# ── 3. 워커 의존성 갱신(venv) — requirements 변경 대비(멱등) ──
if [ -x /opt/kintex/worker/venv/bin/pip ]; then
/opt/kintex/worker/venv/bin/pip install -q redis Pillow google-genai 2>/dev/null || log "WARN: 워커 의존성 갱신 스킵"
fi
# ── 4. 서비스 재기동 ──
systemctl restart kintex.service
systemctl restart kintex-nanobanana.service 2>/dev/null || log "WARN: 워커 재기동 스킵"
# ── 5. 헬스체크(백엔드 /health 200 = success:true) — 실패 시 jar 롤백 ──
ok=0
for i in $(seq 1 20); do
sleep 3
code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${BACKEND_PORT}/health" || true)
if [ "$code" = "200" ]; then log "헬스체크 OK ($code)"; ok=1; break; fi
done
if [ "$ok" != "1" ]; then
log "헬스체크 실패 — jar 롤백 시도"
if [ -f "${APP_JAR}.bak" ]; then cp "${APP_JAR}.bak" "$APP_JAR"; systemctl restart kintex.service; fi
systemctl is-active kintex.service || true
die "배포 헬스체크 실패(롤백 수행)"
fi
log "배포 완료 — 백엔드 8021 UP, 워커 재기동"

View File

@ -0,0 +1,46 @@
# KINTEX 블록 — 서버 /opt/zioinfo/deploy_server.py 의 _deploy() 체인에 삽입되는 정본 사본.
# (deploy_server.py 는 루트 인프라 파일이므로 kintex 리포엔 이 참조 사본만 둔다. 서버 사본이 실행 권위.)
# 삽입 위치: 마지막 `elif repo == "uiws":` 블록 다음, `logging.info(f"=== {repo} 배포 완료 ===")` 앞.
# Gitea owner=zio, 기본 브랜치=main, 백엔드 포트 8021.
'''
elif repo == "kintex":
# 분리형 배포: 프론트 dist(Vite) → /var/www/kintex, 백엔드 bootJar(Gradle) → /opt/kintex/app/app.jar,
# kintex.service + kintex-nanobanana.service(나노바나나 워커, GEMINI 키는 워커 env 전용) 재기동.
# Flyway baseline-on-migrate=true → 기동 시 스키마 자동 적용. deploy_kintex.sh 가 검증→원자교체→헬스체크→롤백.
SRC = "/opt/kintex/src"
ok = run_steps(repo, [
("git pull", ["bash", "-c",
f"if [ -d {SRC}/.git ]; then "
f"git -C {SRC} fetch origin main && git -C {SRC} reset --hard origin/main; "
f"else "
f"[ -e {SRC} ] && mv {SRC} {SRC}.bak_$(date +%Y%m%d%H%M%S); "
f"git clone 'http://zio:1q2w3e%21Q@127.0.0.1:9003/zio/kintex.git' {SRC}; "
f"fi"]),
("npm build", ["bash", "-c",
f"cd {SRC}/src/frontend && (npm ci 2>/dev/null || npm install) && "
f"NODE_OPTIONS=--max-old-space-size=4096 npm run build"]),
("gradle build", ["bash", "-c",
f"cd {SRC}/src/backend && chmod +x gradlew 2>/dev/null; sed -i 's/\\r$//' gradlew 2>/dev/null; "
f"./gradlew clean bootJar -x test -q --no-daemon > /tmp/kintex_gradle.log 2>&1; "
f"rc=$?; tail -8 /tmp/kintex_gradle.log; exit $rc"]),
("verify jar", ["bash", "-c",
f"JAR=$(ls -1 {SRC}/src/backend/build/libs/kintex-backend-*.jar 2>/dev/null | grep -v plain | head -1); "
f"[ -n \"$JAR\" ] || {{ echo 'jar not found'; exit 1; }}; "
f"unzip -l \"$JAR\" | grep -q 'BOOT-INF/classes/com/zioinfo/kintex/KintexApplication.class' "
f"|| {{ echo 'invalid jar'; exit 1; }}; echo \"jar OK: $JAR\""]),
("deploy (deploy_kintex.sh)", ["bash", "-c",
f"sed -i 's/\\r$//' {SRC}/deploy/*.sh 2>/dev/null; "
f"bash {SRC}/deploy/deploy_kintex.sh {SRC}"]),
("health check", ["bash", "-c",
"for i in $(seq 1 20); do sleep 3; "
"code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:8021/health); "
"if [ \"$code\" = '200' ]; then echo \"health OK ($code)\"; exit 0; fi; "
"done; echo 'health FAIL'; systemctl is-active kintex.service 2>/dev/null; exit 1"]),
])
if ok:
notify_itsm(True, "✅ kintex 배포 완료 (kintex.zioinfo.kr, 포트 8021)")
trigger_jenkins("kintex")
else:
notify_itsm(False, "❌ kintex 빌드/배포 실패")
'''

View File

@ -0,0 +1,50 @@
# KINTEX 자동전시시스템 — nginx vhost (개발: kintex.zioinfo.kr → 101.79.17.164)
# / → /var/www/kintex (React/Vite SPA, try_files 폴백)
# /api/, /health → 127.0.0.1:8021 (Spring Boot 백엔드)
# /ws → 127.0.0.1:8021 (STOMP/WebSocket 업그레이드)
# TLS(443)는 certbot --nginx -d kintex.zioinfo.kr 로 후속 주입(80→443 리다이렉트 자동).
# 운영 도메인 kintex.wise.ai.kr 은 server_name 추가 또는 별도 vhost 로 후속 구성.
server {
listen 80;
listen [::]:80;
server_name kintex.zioinfo.kr;
root /var/www/kintex;
index index.html;
client_max_body_size 50m;
access_log /var/log/nginx/kintex.access.log;
error_log /var/log/nginx/kintex.error.log;
# 백엔드 API
location /api/ {
proxy_pass http://127.0.0.1:8021;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# 헬스체크(공개)
location = /health {
proxy_pass http://127.0.0.1:8021/health;
proxy_set_header Host $host;
}
# WebSocket(STOMP) — RenderJob 완료·승인 이벤트 실시간
location /ws {
proxy_pass http://127.0.0.1:8021;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# 프론트 SPA — 정적 서빙 + 클라이언트 라우팅 폴백
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""KINTEX 서버 프로비저닝(멱등) — systemd 유닛·디렉터리·워커 venv·nginx vhost 설치.
DB(kintex_db+PostGIS)·시크릿 env provision.sh(별도, 시크릿 미출력) 담당한다.
스크립트는 "코드로 유지되는" 서비스 구성만 다룬다(시크릿 없음 리포 커밋 가능).
전제: /opt/kintex/kintex.env·/opt/kintex/nanobanana.env 이미 존재(provision.sh 산출, root 600).
사용:
sudo python3 deploy/setup_kintex_service.py # 유닛/디렉터리/venv/nginx 설치
sudo python3 deploy/setup_kintex_service.py --no-nginx # nginx 제외
배정 사실(2026-07-11 서버 survey):
- 백엔드 포트 8021 (8003~8020 = 기존 GUARDiA 솔루션 예약, 8020=guardia-rag)
- 개발 도메인 kintex.zioinfo.kr 101.79.17.164
"""
import argparse
import os
import shutil
import subprocess
import sys
KINTEX_ROOT = "/opt/kintex"
SRC = f"{KINTEX_ROOT}/src"
WEB_ROOT = "/var/www/kintex"
VENV = f"{KINTEX_ROOT}/worker/venv"
HERE = os.path.dirname(os.path.abspath(__file__))
def sh(cmd, check=True):
print(f"[setup] $ {cmd}")
r = subprocess.run(cmd, shell=True, text=True)
if check and r.returncode != 0:
sys.exit(f"[setup] 실패(rc={r.returncode}): {cmd}")
return r.returncode
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--no-nginx", action="store_true")
args = ap.parse_args()
if os.geteuid() != 0:
sys.exit("[setup] root 로 실행하세요(sudo).")
# 1. 디렉터리
for d in (f"{KINTEX_ROOT}/app", SRC, f"{KINTEX_ROOT}/worker",
f"{KINTEX_ROOT}/output/visualizations", WEB_ROOT, "/var/log/kintex"):
os.makedirs(d, exist_ok=True)
# 2. deploy_kintex.sh 설치(서버 표준 위치)
sh(f"install -m 755 {HERE}/deploy_kintex.sh {KINTEX_ROOT}/deploy_kintex.sh")
# 3. 워커 venv + 의존성(google-genai·Pillow·redis)
if not os.path.exists(f"{VENV}/bin/python"):
sh(f"python3 -m venv {VENV}")
sh(f"{VENV}/bin/pip install -q --upgrade pip")
sh(f"{VENV}/bin/pip install -q redis Pillow google-genai")
# 4. systemd 유닛 설치
for unit in ("kintex.service", "kintex-nanobanana.service"):
src = f"{HERE}/systemd/{unit}"
shutil.copyfile(src, f"/etc/systemd/system/{unit}")
os.chmod(f"/etc/systemd/system/{unit}", 0o644)
print(f"[setup] 유닛 설치: {unit}")
sh("systemctl daemon-reload")
sh("systemctl enable kintex.service kintex-nanobanana.service", check=False)
# 5. nginx vhost
if not args.no_nginx:
conf = "kintex.zioinfo.kr.conf"
shutil.copyfile(f"{HERE}/nginx/{conf}", f"/etc/nginx/sites-available/{conf}")
link = f"/etc/nginx/sites-enabled/{conf}"
if not os.path.islink(link) and not os.path.exists(link):
os.symlink(f"/etc/nginx/sites-available/{conf}", link)
if sh("nginx -t", check=False) == 0:
sh("systemctl reload nginx")
else:
print("[setup] WARN: nginx -t 실패 — vhost 반영 보류(타 사이트 무영향)")
print("[setup] 완료. env(시크릿)는 provision.sh 산출물 사용. "
"서비스 기동은 배포(deploy_kintex.sh)에서 jar 배치 후 수행.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,22 @@
[Unit]
# KINTEX 나노바나나 렌더 워커 — Redis 큐(kintex:renderjob:queue) 소비 → Gemini 이미지 생성.
# GEMINI_API_KEY 는 이 유닛의 EnvironmentFile(/opt/kintex/nanobanana.env, root 600)에서만 로드.
# 백엔드·타 서비스에 미노출. G1 승인(2026-07-11)으로 NANOBANANA_LIVE=1(라이브 생성).
Description=KINTEX Nanobanana Render Worker (Gemini)
After=network.target redis-server.service kintex.service
Wants=redis-server.service
[Service]
Type=simple
User=root
# `python -m tools.nanobanana.worker` 는 소스 루트(/opt/kintex/src)를 CWD·sys.path 로 요구.
WorkingDirectory=/opt/kintex/src
EnvironmentFile=/opt/kintex/nanobanana.env
ExecStart=/opt/kintex/worker/venv/bin/python -m tools.nanobanana.worker
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/kintex/worker.log
StandardError=append:/var/log/kintex/worker.log
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,22 @@
[Unit]
# KINTEX 자동전시시스템 — 백엔드(Spring Boot 3.2.5, 포트 8021)
# 시크릿은 EnvironmentFile(/opt/kintex/kintex.env, root 600)로만 주입. GEMINI 키는 여기 없음(워커 전용).
Description=KINTEX AI Exhibition Backend (Spring Boot)
After=network.target postgresql.service redis-server.service
Wants=redis-server.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/kintex/app
EnvironmentFile=/opt/kintex/kintex.env
# JDK21로 Java17 타깃 bootJar 실행(Spring Boot 3.2.5 호환). 힙은 공유 서버 보호로 상한.
ExecStart=/usr/bin/java -Xms128m -Xmx512m -jar /opt/kintex/app/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/kintex/backend.log
StandardError=append:/var/log/kintex/backend.log
[Install]
WantedBy=multi-user.target