Merge pull request 'chpark-sync' (#425) from chpark-sync into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/425
This commit is contained in:
commit
ca2af56aad
|
|
@ -0,0 +1,212 @@
|
|||
# Gitea Actions Workflow - vexplor 이미지 빌드 & Harbor Push
|
||||
#
|
||||
# 동작 방식:
|
||||
# 1. main 브랜치에 push 시 자동 실행
|
||||
# 2. Docker 이미지 빌드 (Backend, Frontend)
|
||||
# 3. Harbor 레지스트리에 Push
|
||||
# 4. 공장 서버의 Watchtower가 새 이미지 감지 후 자동 업데이트
|
||||
#
|
||||
# 필수 Secrets (Repository Settings > Secrets):
|
||||
# - HARBOR_USERNAME: Harbor 사용자명
|
||||
# - HARBOR_PASSWORD: Harbor 비밀번호
|
||||
|
||||
name: Build and Push Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "backend-node/**"
|
||||
- "frontend/**"
|
||||
- "docker/**"
|
||||
- ".gitea/workflows/deploy.yml"
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "deploy/**"
|
||||
- "k8s/**"
|
||||
workflow_dispatch: # 수동 실행도 가능
|
||||
|
||||
env:
|
||||
GITEA_DOMAIN: g.wace.me
|
||||
HARBOR_REGISTRY: localhost:5001
|
||||
HARBOR_REGISTRY_EXTERNAL: harbor.wace.me
|
||||
HARBOR_PROJECT: speefox_vexplor
|
||||
|
||||
# Frontend 빌드 환경 변수
|
||||
NEXT_PUBLIC_API_URL: "https://api.vexplor.com/api"
|
||||
NEXT_PUBLIC_ENV: "production"
|
||||
|
||||
# Frontend 설정
|
||||
FRONTEND_IMAGE_NAME: vexplor-frontend
|
||||
FRONTEND_BUILD_CONTEXT: frontend
|
||||
FRONTEND_DOCKERFILE_PATH: docker/deploy/frontend.Dockerfile
|
||||
|
||||
# Backend 설정
|
||||
BACKEND_IMAGE_NAME: vexplor-backend
|
||||
BACKEND_BUILD_CONTEXT: backend-node
|
||||
BACKEND_DOCKERFILE_PATH: docker/deploy/backend.Dockerfile
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
# 작업 디렉토리 정리
|
||||
- name: Clean workspace
|
||||
run: |
|
||||
echo "작업 디렉토리 정리..."
|
||||
cd /workspace
|
||||
rm -rf source
|
||||
mkdir -p source
|
||||
echo "정리 완료"
|
||||
|
||||
# 필수 도구 설치
|
||||
- name: Install required tools
|
||||
run: |
|
||||
echo "필수 도구 설치 중..."
|
||||
apt-get update -qq
|
||||
apt-get install -y git curl ca-certificates gnupg
|
||||
|
||||
# Docker 클라이언트 설치
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y docker-ce-cli
|
||||
|
||||
echo "설치 완료:"
|
||||
git --version
|
||||
docker --version
|
||||
|
||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
||||
docker version || echo "소켓 연결 대기 중..."
|
||||
|
||||
# 저장소 체크아웃
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "저장소 체크아웃..."
|
||||
cd /workspace/source
|
||||
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} \
|
||||
https://oauth2:${{ github.token }}@${GITEA_DOMAIN}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
echo "체크아웃 완료"
|
||||
git log -1 --oneline
|
||||
|
||||
# 빌드 환경 설정
|
||||
- name: Set up build environment
|
||||
run: |
|
||||
IMAGE_TAG="v$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV
|
||||
|
||||
# Frontend 이미지
|
||||
echo "FRONTEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}" >> $GITHUB_ENV
|
||||
|
||||
# Backend 이미지
|
||||
echo "BACKEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}" >> $GITHUB_ENV
|
||||
|
||||
echo "=========================================="
|
||||
echo "빌드 태그: ${IMAGE_TAG}"
|
||||
echo "=========================================="
|
||||
|
||||
# Harbor 로그인
|
||||
- name: Login to Harbor
|
||||
env:
|
||||
HARBOR_USER: ${{ secrets.HARBOR_USERNAME }}
|
||||
HARBOR_PASS: ${{ secrets.HARBOR_PASSWORD }}
|
||||
run: |
|
||||
echo "Harbor 로그인..."
|
||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
||||
echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} \
|
||||
--username ${HARBOR_USER} \
|
||||
--password-stdin
|
||||
echo "Harbor 로그인 완료"
|
||||
|
||||
# Backend 빌드 및 푸시
|
||||
- name: Build and Push Backend image
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo "Backend 이미지 빌드 시작..."
|
||||
echo "=========================================="
|
||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
||||
cd /workspace/source
|
||||
|
||||
docker build \
|
||||
-t ${BACKEND_FULL_IMAGE}:${IMAGE_TAG} \
|
||||
-t ${BACKEND_FULL_IMAGE}:latest \
|
||||
-f ${BACKEND_DOCKERFILE_PATH} \
|
||||
${BACKEND_BUILD_CONTEXT}
|
||||
|
||||
echo "Backend 이미지 푸시..."
|
||||
docker push ${BACKEND_FULL_IMAGE}:${IMAGE_TAG}
|
||||
docker push ${BACKEND_FULL_IMAGE}:latest
|
||||
|
||||
echo "=========================================="
|
||||
echo "Backend 푸시 완료!"
|
||||
echo " - ${BACKEND_FULL_IMAGE}:${IMAGE_TAG}"
|
||||
echo " - ${BACKEND_FULL_IMAGE}:latest"
|
||||
echo "=========================================="
|
||||
|
||||
# Frontend 빌드 및 푸시
|
||||
- name: Build and Push Frontend image
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo "Frontend 이미지 빌드 시작..."
|
||||
echo "=========================================="
|
||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
||||
cd /workspace/source
|
||||
|
||||
echo "빌드 환경 변수:"
|
||||
echo " NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
|
||||
echo " NEXT_PUBLIC_ENV=${NEXT_PUBLIC_ENV}"
|
||||
|
||||
docker build \
|
||||
-t ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG} \
|
||||
-t ${FRONTEND_FULL_IMAGE}:latest \
|
||||
-f ${FRONTEND_DOCKERFILE_PATH} \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}" \
|
||||
${FRONTEND_BUILD_CONTEXT}
|
||||
|
||||
echo "Frontend 이미지 푸시..."
|
||||
docker push ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG}
|
||||
docker push ${FRONTEND_FULL_IMAGE}:latest
|
||||
|
||||
echo "=========================================="
|
||||
echo "Frontend 푸시 완료!"
|
||||
echo " - ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG}"
|
||||
echo " - ${FRONTEND_FULL_IMAGE}:latest"
|
||||
echo "=========================================="
|
||||
|
||||
# 빌드 완료 요약
|
||||
- name: Build summary
|
||||
if: success()
|
||||
run: |
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 이미지 빌드 & Push 완료!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "빌드 버전: ${IMAGE_TAG}"
|
||||
echo ""
|
||||
echo "푸시된 이미지:"
|
||||
echo " - Backend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}:latest"
|
||||
echo " - Frontend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}:latest"
|
||||
echo ""
|
||||
echo "다음 단계:"
|
||||
echo " - 공장 서버의 Watchtower가 자동으로 새 이미지를 감지합니다"
|
||||
echo " - 또는 수동 업데이트: docker compose pull && docker compose up -d"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
|
||||
# Harbor 로그아웃
|
||||
- name: Logout from Harbor
|
||||
if: always()
|
||||
run: |
|
||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
||||
docker logout ${HARBOR_REGISTRY} || true
|
||||
|
|
@ -113,6 +113,16 @@ secrets.yml
|
|||
api-keys.json
|
||||
tokens.json
|
||||
|
||||
# Kubernetes Secrets (절대 커밋하지 않음!)
|
||||
k8s/vexplor-secret.yaml
|
||||
k8s/*-secret.yaml
|
||||
!k8s/*-secret.yaml.template
|
||||
|
||||
# Kubernetes Secrets (절대 커밋하지 않음!)
|
||||
k8s/vexplor-secret.yaml
|
||||
k8s/*-secret.yaml
|
||||
!k8s/*-secret.yaml.template
|
||||
|
||||
# 데이터베이스 덤프
|
||||
*.sql
|
||||
*.dump
|
||||
|
|
@ -168,6 +178,8 @@ uploads/
|
|||
# ===== 기타 =====
|
||||
claude.md
|
||||
|
||||
.cursor/mcp.json
|
||||
|
||||
# Agent Pipeline 로컬 파일
|
||||
_local/
|
||||
.agent-pipeline/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1772609393905,
|
||||
"projectRoot": "/Users/johngreen/Dev/vexplor",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
{
|
||||
"name": "JavaScript/TypeScript",
|
||||
"version": null,
|
||||
"confidence": "high",
|
||||
"markers": [
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"frameworks": [],
|
||||
"packageManager": "npm",
|
||||
"runtime": null
|
||||
},
|
||||
"build": {
|
||||
"buildCommand": null,
|
||||
"testCommand": null,
|
||||
"lintCommand": null,
|
||||
"devCommand": null,
|
||||
"scripts": {}
|
||||
},
|
||||
"conventions": {
|
||||
"namingStyle": null,
|
||||
"importStyle": null,
|
||||
"testPattern": null,
|
||||
"fileOrganization": "type-based"
|
||||
},
|
||||
"structure": {
|
||||
"isMonorepo": false,
|
||||
"workspaces": [],
|
||||
"mainDirectories": [
|
||||
"docs",
|
||||
"lib",
|
||||
"scripts",
|
||||
"src"
|
||||
],
|
||||
"gitBranches": {
|
||||
"defaultBranch": "main",
|
||||
"branchingStrategy": null
|
||||
}
|
||||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"WebContent": {
|
||||
"path": "WebContent",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1772609393856,
|
||||
"keyFiles": [
|
||||
"init.jsp",
|
||||
"init_jqGrid.jsp",
|
||||
"init_no_login.jsp",
|
||||
"init_toastGrid.jsp",
|
||||
"viewImage.jsp"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"path": "backend",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1772609393857,
|
||||
"keyFiles": [
|
||||
"Dockerfile",
|
||||
"Dockerfile.mac",
|
||||
"build.gradle",
|
||||
"gradlew",
|
||||
"gradlew.bat"
|
||||
]
|
||||
},
|
||||
"backend-node": {
|
||||
"path": "backend-node",
|
||||
"purpose": null,
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1772609393872,
|
||||
"keyFiles": [
|
||||
"API_연동_가이드.md",
|
||||
"API_키_정리.md",
|
||||
"Dockerfile.win",
|
||||
"PHASE1_USAGE_GUIDE.md",
|
||||
"README.md"
|
||||
]
|
||||
},
|
||||
"db": {
|
||||
"path": "db",
|
||||
"purpose": null,
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393873,
|
||||
"keyFiles": [
|
||||
"00-create-roles.sh",
|
||||
"migrate_company13_export.sh"
|
||||
]
|
||||
},
|
||||
"deploy": {
|
||||
"path": "deploy",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393873,
|
||||
"keyFiles": []
|
||||
},
|
||||
"docker": {
|
||||
"path": "docker",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393873,
|
||||
"keyFiles": []
|
||||
},
|
||||
"docs": {
|
||||
"path": "docs",
|
||||
"purpose": "Documentation",
|
||||
"fileCount": 23,
|
||||
"lastAccessed": 1772609393873,
|
||||
"keyFiles": [
|
||||
"AI_화면생성_시스템_설계서.md",
|
||||
"DB_ARCHITECTURE_ANALYSIS.md",
|
||||
"DB_STRUCTURE_DIAGRAM.html",
|
||||
"DB_WORKFLOW_ANALYSIS.md",
|
||||
"KUBERNETES_DEPLOYMENT_GUIDE.md"
|
||||
]
|
||||
},
|
||||
"frontend": {
|
||||
"path": "frontend",
|
||||
"purpose": null,
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1772609393873,
|
||||
"keyFiles": [
|
||||
"MODAL_REPEATER_TABLE_DEBUG.md",
|
||||
"README.md",
|
||||
"components.json",
|
||||
"eslint.config.mjs",
|
||||
"middleware.ts"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"path": "hooks",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393879,
|
||||
"keyFiles": [
|
||||
"useScreenStandards.ts"
|
||||
]
|
||||
},
|
||||
"k8s": {
|
||||
"path": "k8s",
|
||||
"purpose": null,
|
||||
"fileCount": 7,
|
||||
"lastAccessed": 1772609393882,
|
||||
"keyFiles": [
|
||||
"local-path-provisioner.yaml",
|
||||
"namespace.yaml",
|
||||
"vexplor-backend-deployment.yaml",
|
||||
"vexplor-config.yaml",
|
||||
"vexplor-frontend-deployment.yaml"
|
||||
]
|
||||
},
|
||||
"lib": {
|
||||
"path": "lib",
|
||||
"purpose": "Library code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393883,
|
||||
"keyFiles": []
|
||||
},
|
||||
"mcp-agent-orchestrator": {
|
||||
"path": "mcp-agent-orchestrator",
|
||||
"purpose": null,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1772609393883,
|
||||
"keyFiles": [
|
||||
"README.md",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"tsconfig.json"
|
||||
]
|
||||
},
|
||||
"popdocs": {
|
||||
"path": "popdocs",
|
||||
"purpose": null,
|
||||
"fileCount": 12,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"ARCHITECTURE.md",
|
||||
"CHANGELOG.md",
|
||||
"FILES.md",
|
||||
"INDEX.md",
|
||||
"PLAN.md"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"add-modal-ids.py",
|
||||
"remove-logs.js"
|
||||
]
|
||||
},
|
||||
"src": {
|
||||
"path": "src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"tomcat-conf": {
|
||||
"path": "tomcat-conf",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"context.xml"
|
||||
]
|
||||
},
|
||||
"backend/build": {
|
||||
"path": "backend/build",
|
||||
"purpose": "Build output",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend/src": {
|
||||
"path": "backend/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend-node/data": {
|
||||
"path": "backend-node/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"db/migrations": {
|
||||
"path": "db/migrations",
|
||||
"purpose": "Database migrations",
|
||||
"fileCount": 16,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"046_MIGRATION_FIX.md",
|
||||
"046_QUICK_FIX.md",
|
||||
"README_1003.md"
|
||||
]
|
||||
},
|
||||
"db/scripts": {
|
||||
"path": "db/scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"README_cleanup.md"
|
||||
]
|
||||
},
|
||||
"frontend/app": {
|
||||
"path": "frontend/app",
|
||||
"purpose": "Application code",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"favicon.ico",
|
||||
"globals.css",
|
||||
"layout.tsx"
|
||||
]
|
||||
},
|
||||
"frontend/components": {
|
||||
"path": "frontend/components",
|
||||
"purpose": "UI components",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"GlobalFileViewer.tsx"
|
||||
]
|
||||
},
|
||||
"mcp-agent-orchestrator/src": {
|
||||
"path": "mcp-agent-orchestrator/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"index.ts"
|
||||
]
|
||||
},
|
||||
"src/controllers": {
|
||||
"path": "src/controllers",
|
||||
"purpose": "Controllers",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramController.ts"
|
||||
]
|
||||
},
|
||||
"src/routes": {
|
||||
"path": "src/routes",
|
||||
"purpose": "Route handlers",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramRoutes.ts"
|
||||
]
|
||||
},
|
||||
"src/services": {
|
||||
"path": "src/services",
|
||||
"purpose": "Business logic services",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramService.ts"
|
||||
]
|
||||
},
|
||||
"src/utils": {
|
||||
"path": "src/utils",
|
||||
"purpose": "Utility functions",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"databaseValidator.ts",
|
||||
"queryBuilder.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hotPaths": [],
|
||||
"userDirectives": []
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23",
|
||||
"ended_at": "2026-03-04T08:10:16.810Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"timestamp": "2026-03-04T07:29:57.315Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-03-04T07:29:53.176Z",
|
||||
"sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-04T07:30:30.883Z"
|
||||
}
|
||||
|
|
@ -378,12 +378,20 @@ app.use(errorHandler);
|
|||
const PORT = config.port;
|
||||
const HOST = config.host;
|
||||
|
||||
app.listen(PORT, HOST, async () => {
|
||||
const server = app.listen(PORT, HOST, async () => {
|
||||
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
|
||||
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
||||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||
|
||||
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
|
||||
initializeServices().catch(err => {
|
||||
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// 서비스 초기화 함수 분리
|
||||
async function initializeServices() {
|
||||
// 데이터베이스 마이그레이션 실행
|
||||
try {
|
||||
const {
|
||||
|
|
@ -451,6 +459,6 @@ app.listen(PORT, HOST, async () => {
|
|||
} catch (error) {
|
||||
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
|||
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { TableManagementService } from "../services/tableManagementService";
|
||||
import { formatPgError } from "../utils/pgErrorUtil";
|
||||
|
|
@ -35,7 +36,7 @@ router.get(
|
|||
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
parseInt(screenId),
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
|
|
@ -64,7 +65,7 @@ router.get(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -90,7 +91,7 @@ router.post(
|
|||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
parseInt(screenId),
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
|
|
@ -104,7 +105,7 @@ router.post(
|
|||
const data = await masterDetailExcelService.getJoinedData(
|
||||
relation,
|
||||
companyCode,
|
||||
filters
|
||||
filters,
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||
|
|
@ -121,7 +122,7 @@ router.post(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -144,11 +145,13 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||
console.log(
|
||||
`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`,
|
||||
);
|
||||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
parseInt(screenId),
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
|
|
@ -163,7 +166,7 @@ router.post(
|
|||
relation,
|
||||
data,
|
||||
companyCode,
|
||||
userId
|
||||
userId,
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||
|
|
@ -194,7 +197,7 @@ router.post(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -202,7 +205,7 @@ router.post(
|
|||
* - 마스터 정보는 UI에서 선택
|
||||
* - 디테일 정보만 엑셀에서 업로드
|
||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||
*
|
||||
*
|
||||
* POST /api/data/master-detail/upload-simple
|
||||
*/
|
||||
router.post(
|
||||
|
|
@ -210,7 +213,14 @@ router.post(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||
const {
|
||||
screenId,
|
||||
detailData,
|
||||
masterFieldValues,
|
||||
numberingRuleId,
|
||||
afterUploadFlowId,
|
||||
afterUploadFlows,
|
||||
} = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
|
|
@ -221,10 +231,17 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||
console.log(
|
||||
`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`,
|
||||
);
|
||||
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||
console.log(
|
||||
` 업로드 후 제어:`,
|
||||
afterUploadFlows?.length > 0
|
||||
? `${afterUploadFlows.length}개`
|
||||
: afterUploadFlowId || "없음",
|
||||
);
|
||||
|
||||
// 업로드 실행
|
||||
const result = await masterDetailExcelService.uploadSimple(
|
||||
|
|
@ -235,7 +252,7 @@ router.post(
|
|||
companyCode,
|
||||
userId,
|
||||
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||
afterUploadFlows, // 업로드 후 제어 실행 (다중)
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||
|
|
@ -260,7 +277,7 @@ router.post(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ================================
|
||||
|
|
@ -504,7 +521,7 @@ router.get(
|
|||
parsedDataFilter,
|
||||
enableEntityJoinFlag,
|
||||
parsedDisplayColumns, // 🆕 표시 컬럼 전달
|
||||
parsedDeduplication // 🆕 중복 제거 설정 전달
|
||||
parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -512,7 +529,7 @@ router.get(
|
|||
}
|
||||
|
||||
console.log(
|
||||
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`
|
||||
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`,
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -527,7 +544,7 @@ router.get(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -616,7 +633,7 @@ router.get(
|
|||
}
|
||||
|
||||
console.log(
|
||||
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
|
||||
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`,
|
||||
);
|
||||
|
||||
// 페이징 정보 포함하여 반환
|
||||
|
|
@ -642,7 +659,7 @@ router.get(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -684,7 +701,7 @@ router.get(
|
|||
}
|
||||
|
||||
console.log(
|
||||
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`
|
||||
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`,
|
||||
);
|
||||
|
||||
return res.json(result);
|
||||
|
|
@ -696,7 +713,7 @@ router.get(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -748,7 +765,8 @@ router.get(
|
|||
}
|
||||
|
||||
// 🆕 primaryKeyColumn 파싱
|
||||
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
|
||||
const primaryKeyColumnStr =
|
||||
typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
|
|
@ -762,7 +780,7 @@ router.get(
|
|||
id,
|
||||
enableEntityJoinFlag,
|
||||
groupByColumnsArray,
|
||||
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
|
||||
primaryKeyColumnStr, // 🆕 Primary Key 컬럼명 전달
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -790,7 +808,7 @@ router.get(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -844,7 +862,7 @@ router.post(
|
|||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId,
|
||||
deleteOrphans
|
||||
deleteOrphans,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -856,7 +874,9 @@ router.post(
|
|||
const deleted = result.data?.deleted || 0;
|
||||
|
||||
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||
inserted, updated, deleted,
|
||||
inserted,
|
||||
updated,
|
||||
deleted,
|
||||
});
|
||||
|
||||
const parts: string[] = [];
|
||||
|
|
@ -869,7 +889,10 @@ router.post(
|
|||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE",
|
||||
action:
|
||||
inserted > 0 && updated === 0 && deleted === 0
|
||||
? "BATCH_CREATE"
|
||||
: "UPDATE",
|
||||
resourceType: "DATA",
|
||||
tableName,
|
||||
summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`,
|
||||
|
|
@ -895,7 +918,81 @@ router.post(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 설비 ID 일괄 검증 API
|
||||
* POST /api/data/equipment_mng/validate
|
||||
*
|
||||
* 요청: { "equipmentIds": ["EQ_001", "EQ_002", "EQ_003"] }
|
||||
* 응답: { "success": true, "data": [{ "equipment_id": "EQ_001", "equipment_name": "프레스 1호기" }, ...] }
|
||||
*
|
||||
* - 존재하는 설비만 반환 (존재하지 않는 ID는 응답에서 제외)
|
||||
* - 멀티테넌시: company_code 필터링 적용
|
||||
*/
|
||||
router.post(
|
||||
"/equipment_mng/validate",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { equipmentIds } = req.body;
|
||||
|
||||
if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "equipmentIds 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 최대 500개 제한
|
||||
if (equipmentIds.length > 500) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "한 번에 최대 500개까지 검증할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const companyCode = req.user?.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
// Parameterized query로 SQL Injection 방지
|
||||
const placeholders = equipmentIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const params: any[] = [...equipmentIds];
|
||||
|
||||
let whereClause = `WHERE equipment_id IN (${placeholders})`;
|
||||
|
||||
// 멀티테넌시 필터링 (company_code가 '*'이 아닌 경우)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
params.push(companyCode);
|
||||
whereClause += ` AND company_code = $${params.length}`;
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT equipment_id, equipment_name
|
||||
FROM equipment_mng
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
console.log(
|
||||
`✅ 설비 일괄 검증: ${result.rowCount}/${equipmentIds.length}개 확인`,
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("설비 일괄 검증 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "설비 검증 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -935,7 +1032,7 @@ router.post(
|
|||
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyCode = await dataService.checkColumnExists(
|
||||
tableName,
|
||||
"company_code"
|
||||
"company_code",
|
||||
);
|
||||
if (hasCompanyCode && req.user?.companyCode) {
|
||||
enrichedData.company_code = req.user.companyCode;
|
||||
|
|
@ -945,7 +1042,7 @@ router.post(
|
|||
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyName = await dataService.checkColumnExists(
|
||||
tableName,
|
||||
"company_name"
|
||||
"company_name",
|
||||
);
|
||||
if (hasCompanyName && req.user?.companyName) {
|
||||
enrichedData.company_name = req.user.companyName;
|
||||
|
|
@ -1001,7 +1098,7 @@ router.post(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -1086,7 +1183,7 @@ router.put(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -1156,7 +1253,7 @@ router.post(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -1179,12 +1276,16 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
||||
console.log(`🗑️ 그룹 삭제:`, {
|
||||
tableName,
|
||||
filterConditions,
|
||||
userCompany,
|
||||
});
|
||||
|
||||
const result = await dataService.deleteGroupRecords(
|
||||
tableName,
|
||||
filterConditions,
|
||||
userCompany // 회사 코드 전달
|
||||
userCompany, // 회사 코드 전달
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -1201,7 +1302,7 @@ router.post(
|
|||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
|
|
@ -1264,7 +1365,7 @@ router.delete(
|
|||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
# 고객사별 환경 변수 관리
|
||||
|
||||
## 개요
|
||||
|
||||
이 폴더는 각 고객사(업체)별 환경 변수 설정을 **참고용**으로 관리합니다.
|
||||
|
||||
**중요:** 실제 비밀번호는 이 파일에 저장하지 마세요. 템플릿으로만 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 고객사 목록
|
||||
|
||||
| 고객사 | 파일 | 배포 형태 | 상태 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 스피폭스 | `spifox.env` | 온프레미스 (공장 서버) | 진행 중 |
|
||||
| 엔키드 | `enkid.env` | 온프레미스 (공장 서버) | 예정 |
|
||||
|
||||
---
|
||||
|
||||
## 신규 고객사 추가 절차
|
||||
|
||||
### 1단계: 환경 변수 파일 생성
|
||||
|
||||
```bash
|
||||
# 기존 파일 복사
|
||||
cp spifox.env newcustomer.env
|
||||
|
||||
# 수정
|
||||
nano newcustomer.env
|
||||
```
|
||||
|
||||
필수 수정 항목:
|
||||
- `COMPANY_CODE`: 고유한 회사 코드 (예: NEWCO)
|
||||
- `SERVER_IP`: 고객사 서버 IP
|
||||
- `DB_PASSWORD`: 고유한 비밀번호
|
||||
- `JWT_SECRET`: 고유한 시크릿 키
|
||||
|
||||
### 2단계: 데이터베이스에 회사 등록
|
||||
|
||||
```sql
|
||||
-- company_info 테이블에 추가
|
||||
INSERT INTO company_info (company_code, company_name, status)
|
||||
VALUES ('NEWCO', '신규고객사', 'ACTIVE');
|
||||
```
|
||||
|
||||
### 3단계: 관리자 계정 생성
|
||||
|
||||
```sql
|
||||
-- user_info 테이블에 관리자 추가
|
||||
INSERT INTO user_info (user_id, user_name, company_code, role)
|
||||
VALUES ('newco_admin', '신규고객사 관리자', 'NEWCO', 'COMPANY_ADMIN');
|
||||
```
|
||||
|
||||
### 4단계: 고객사 서버에 배포
|
||||
|
||||
```bash
|
||||
# 고객사 서버에 SSH 접속
|
||||
ssh user@customer-server
|
||||
|
||||
# 설치 폴더 생성
|
||||
sudo mkdir -p /opt/vexplor
|
||||
cd /opt/vexplor
|
||||
|
||||
# docker-compose.yml 복사 (deploy/onpremise/에서)
|
||||
# .env 파일 복사 및 수정
|
||||
|
||||
# 서비스 시작
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 설명
|
||||
|
||||
| 변수 | 설명 | 예시 |
|
||||
| :--- | :--- | :--- |
|
||||
| `COMPANY_CODE` | 회사 고유 코드 (멀티테넌시) | `SPIFOX`, `ENKID` |
|
||||
| `SERVER_IP` | 서버의 실제 IP | `192.168.0.100` |
|
||||
| `DB_PASSWORD` | DB 비밀번호 | (고객사별 고유) |
|
||||
| `JWT_SECRET` | JWT 토큰 시크릿 | (고객사별 고유) |
|
||||
| `IMAGE_TAG` | Docker 이미지 버전 | `latest`, `v1.0.0` |
|
||||
|
||||
---
|
||||
|
||||
## 보안 주의사항
|
||||
|
||||
1. **비밀번호**: 이 폴더의 파일에는 실제 비밀번호를 저장하지 마세요
|
||||
2. **Git**: `.env` 파일이 커밋되지 않도록 `.gitignore` 확인
|
||||
3. **고객사별 격리**: 각 고객사는 별도 서버, 별도 DB로 완전 격리
|
||||
4. **키 관리**: JWT_SECRET은 고객사별로 반드시 다르게 설정
|
||||
|
||||
---
|
||||
|
||||
## 구조 다이어그램
|
||||
|
||||
```
|
||||
[Harbor (이미지 저장소)]
|
||||
│
|
||||
│ docker pull
|
||||
↓
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 스피폭스 공장 │ │ 엔키드 공장 │ │ 신규 고객사 │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │ Vexplor │ │ │ │ Vexplor │ │ │ │ Vexplor │ │
|
||||
│ │ SPIFOX │ │ │ │ ENKID │ │ │ │ NEWCO │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ [독립 DB] │ │ [독립 DB] │ │ [독립 DB] │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
|
||||
* 각 공장은 완전히 독립적으로 운영
|
||||
* 같은 Docker 이미지 사용, .env만 다름
|
||||
* 데이터는 절대 섞이지 않음 (물리적 격리)
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# ============================================
|
||||
# 엔키드(ENKID) 공장 서버 환경 변수
|
||||
# ============================================
|
||||
# 이 파일을 엔키드 공장 서버의 /opt/vexplor/.env로 복사
|
||||
|
||||
# 회사 정보
|
||||
COMPANY_CODE=ENKID
|
||||
|
||||
# 서버 정보 (실제 서버 IP로 변경 필요)
|
||||
SERVER_IP=10.0.0.50
|
||||
|
||||
# 데이터베이스
|
||||
DB_USER=vexplor
|
||||
DB_PASSWORD=enkid_secure_password_here
|
||||
DB_NAME=vexplor
|
||||
DB_PORT=5432
|
||||
|
||||
# 백엔드
|
||||
BACKEND_PORT=3001
|
||||
JWT_SECRET=enkid_jwt_secret_minimum_32_characters
|
||||
JWT_EXPIRES_IN=24h
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 프론트엔드
|
||||
FRONTEND_PORT=80
|
||||
|
||||
# Harbor 레지스트리
|
||||
HARBOR_USER=enkid_harbor_user
|
||||
HARBOR_PASSWORD=enkid_harbor_password
|
||||
|
||||
# 이미지 태그
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Watchtower (1시간마다 업데이트 확인)
|
||||
UPDATE_INTERVAL=3600
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# ============================================
|
||||
# 스피폭스(SPIFOX) 공장 서버 환경 변수
|
||||
# ============================================
|
||||
# 이 파일을 스피폭스 공장 서버의 /opt/vexplor/.env로 복사
|
||||
|
||||
# 회사 정보
|
||||
COMPANY_CODE=SPIFOX
|
||||
|
||||
# 서버 정보 (실제 서버 IP로 변경 필요)
|
||||
SERVER_IP=192.168.0.100
|
||||
|
||||
# 데이터베이스
|
||||
DB_USER=vexplor
|
||||
DB_PASSWORD=spifox_secure_password_here
|
||||
DB_NAME=vexplor
|
||||
DB_PORT=5432
|
||||
|
||||
# 백엔드
|
||||
BACKEND_PORT=3001
|
||||
JWT_SECRET=spifox_jwt_secret_minimum_32_characters
|
||||
JWT_EXPIRES_IN=24h
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 프론트엔드
|
||||
FRONTEND_PORT=80
|
||||
|
||||
# Harbor 레지스트리
|
||||
HARBOR_USER=spifox_harbor_user
|
||||
HARBOR_PASSWORD=spifox_harbor_password
|
||||
|
||||
# 이미지 태그
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Watchtower (1시간마다 업데이트 확인)
|
||||
UPDATE_INTERVAL=3600
|
||||
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
# Vexplor 온프레미스(공장) 배포 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 가이드는 Vexplor를 **공장 내부 서버(온프레미스)**에 배포하는 방법을 설명합니다.
|
||||
|
||||
**Watchtower**를 사용하여 Harbor에 새 이미지가 푸시되면 **자동으로 업데이트**됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
### 서버 요구사항
|
||||
|
||||
| 항목 | 최소 사양 | 권장 사양 |
|
||||
| :--- | :--- | :--- |
|
||||
| OS | Ubuntu 20.04+ | Ubuntu 22.04 LTS |
|
||||
| CPU | 4 Core | 8 Core |
|
||||
| RAM | 8 GB | 16 GB |
|
||||
| Disk | 50 GB | 100 GB SSD |
|
||||
| Network | Harbor 접근 가능 | - |
|
||||
|
||||
### 필수 소프트웨어
|
||||
|
||||
```bash
|
||||
# Docker 설치 확인
|
||||
docker --version # 20.10 이상
|
||||
|
||||
# Docker Compose 설치 확인
|
||||
docker compose version # v2.0 이상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1단계: 초기 설정
|
||||
|
||||
### 1.1 배포 폴더 생성
|
||||
|
||||
```bash
|
||||
# 배포 폴더 생성
|
||||
sudo mkdir -p /opt/vexplor
|
||||
cd /opt/vexplor
|
||||
|
||||
# 파일 복사 (또는 git clone)
|
||||
# deploy/onpremise/ 폴더의 내용을 복사
|
||||
```
|
||||
|
||||
### 1.2 환경 변수 설정
|
||||
|
||||
```bash
|
||||
# 예제 파일 복사
|
||||
cp env.example .env
|
||||
|
||||
# 편집
|
||||
nano .env
|
||||
```
|
||||
|
||||
**필수 수정 항목:**
|
||||
|
||||
```bash
|
||||
# 서버 IP (이 서버의 실제 IP)
|
||||
SERVER_IP=192.168.0.100
|
||||
|
||||
# 회사 코드
|
||||
COMPANY_CODE=SPIFOX
|
||||
|
||||
# DB 비밀번호 (강력한 비밀번호 설정)
|
||||
DB_PASSWORD=MySecurePassword123!
|
||||
|
||||
# JWT 시크릿 (32자 이상)
|
||||
JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars
|
||||
|
||||
# Harbor 인증 정보
|
||||
HARBOR_USER=your_username
|
||||
HARBOR_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### 1.3 Harbor 레지스트리 로그인
|
||||
|
||||
Watchtower가 이미지를 당겨올 수 있도록 Docker 로그인이 필요합니다.
|
||||
|
||||
```bash
|
||||
# Harbor 로그인
|
||||
docker login harbor.wace.me
|
||||
|
||||
# Username: (입력)
|
||||
# Password: (입력)
|
||||
|
||||
# 로그인 성공 확인
|
||||
cat ~/.docker/config.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2단계: 서비스 실행
|
||||
|
||||
### 2.1 서비스 시작
|
||||
|
||||
```bash
|
||||
cd /opt/vexplor
|
||||
|
||||
# 이미지 다운로드 & 실행
|
||||
docker compose up -d
|
||||
|
||||
# 상태 확인
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 2.2 정상 동작 확인
|
||||
|
||||
```bash
|
||||
# 모든 컨테이너 Running 상태 확인
|
||||
docker compose ps
|
||||
|
||||
# 로그 확인
|
||||
docker compose logs -f
|
||||
|
||||
# 개별 서비스 로그
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f watchtower
|
||||
```
|
||||
|
||||
### 2.3 웹 접속 테스트
|
||||
|
||||
```
|
||||
프론트엔드: http://SERVER_IP:80
|
||||
백엔드 API: http://SERVER_IP:3001/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3단계: 자동 업데이트 확인
|
||||
|
||||
### Watchtower 동작 확인
|
||||
|
||||
```bash
|
||||
# Watchtower 로그 확인
|
||||
docker compose logs -f watchtower
|
||||
```
|
||||
|
||||
**정상 로그 예시:**
|
||||
|
||||
```
|
||||
watchtower | time="2024-12-28T10:00:00+09:00" level=info msg="Checking for updates..."
|
||||
watchtower | time="2024-12-28T10:00:05+09:00" level=info msg="Found new image harbor.wace.me/vexplor/vexplor-backend:latest"
|
||||
watchtower | time="2024-12-28T10:00:10+09:00" level=info msg="Stopping container vexplor-backend"
|
||||
watchtower | time="2024-12-28T10:00:15+09:00" level=info msg="Starting container vexplor-backend"
|
||||
```
|
||||
|
||||
### 업데이트 주기 변경
|
||||
|
||||
```bash
|
||||
# .env 파일에서 변경
|
||||
UPDATE_INTERVAL=3600 # 1시간마다 확인
|
||||
|
||||
# 변경 후 watchtower 재시작
|
||||
docker compose restart watchtower
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 운영 가이드
|
||||
|
||||
### 서비스 관리 명령어
|
||||
|
||||
```bash
|
||||
# 모든 서비스 상태 확인
|
||||
docker compose ps
|
||||
|
||||
# 모든 서비스 중지
|
||||
docker compose stop
|
||||
|
||||
# 모든 서비스 시작
|
||||
docker compose start
|
||||
|
||||
# 모든 서비스 재시작
|
||||
docker compose restart
|
||||
|
||||
# 모든 서비스 삭제 (데이터 유지)
|
||||
docker compose down
|
||||
|
||||
# 모든 서비스 삭제 + 볼륨 삭제 (주의: 데이터 삭제됨!)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
```bash
|
||||
# 전체 로그 (실시간)
|
||||
docker compose logs -f
|
||||
|
||||
# 특정 서비스 로그
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f database
|
||||
|
||||
# 최근 100줄만
|
||||
docker compose logs --tail=100 backend
|
||||
```
|
||||
|
||||
### 수동 업데이트 (긴급 시)
|
||||
|
||||
자동 업데이트를 기다리지 않고 즉시 업데이트하려면:
|
||||
|
||||
```bash
|
||||
# 최신 이미지 다운로드
|
||||
docker compose pull
|
||||
|
||||
# 재시작
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 특정 버전으로 롤백
|
||||
|
||||
```bash
|
||||
# .env 파일에서 버전 지정
|
||||
IMAGE_TAG=v1.0.0
|
||||
|
||||
# 재시작
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백업 가이드
|
||||
|
||||
### DB 백업
|
||||
|
||||
```bash
|
||||
# 백업 디렉토리 생성
|
||||
mkdir -p /opt/vexplor/backups
|
||||
|
||||
# PostgreSQL 백업
|
||||
docker compose exec database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
### 업로드 파일 백업
|
||||
|
||||
```bash
|
||||
# 볼륨 위치 확인
|
||||
docker volume inspect vexplor_backend_uploads
|
||||
|
||||
# 또는 직접 복사
|
||||
docker cp vexplor-backend:/app/uploads /opt/vexplor/backups/uploads_$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### 자동 백업 스크립트 (Cron)
|
||||
|
||||
```bash
|
||||
# crontab 편집
|
||||
crontab -e
|
||||
|
||||
# 매일 새벽 3시 DB 백업
|
||||
0 3 * * * docker compose -f /opt/vexplor/docker-compose.yml exec -T database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +\%Y\%m\%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 컨테이너가 시작되지 않음
|
||||
|
||||
```bash
|
||||
# 로그 확인
|
||||
docker compose logs backend
|
||||
|
||||
# 일반적인 원인:
|
||||
# 1. 환경 변수 누락 → .env 파일 확인
|
||||
# 2. 포트 충돌 → netstat -tlnp | grep 3001
|
||||
# 3. 메모리 부족 → free -h
|
||||
```
|
||||
|
||||
### DB 연결 실패
|
||||
|
||||
```bash
|
||||
# DB 컨테이너 상태 확인
|
||||
docker compose logs database
|
||||
|
||||
# DB 직접 접속 테스트
|
||||
docker compose exec database psql -U vexplor -d vexplor -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Watchtower가 업데이트하지 않음
|
||||
|
||||
```bash
|
||||
# Watchtower 로그 확인
|
||||
docker compose logs watchtower
|
||||
|
||||
# Harbor 인증 확인
|
||||
docker pull harbor.wace.me/vexplor/vexplor-backend:latest
|
||||
|
||||
# 라벨 확인 (라벨이 있는 컨테이너만 업데이트)
|
||||
docker inspect vexplor-backend | grep watchtower
|
||||
```
|
||||
|
||||
### 디스크 공간 부족
|
||||
|
||||
```bash
|
||||
# 사용하지 않는 이미지/컨테이너 정리
|
||||
docker system prune -a
|
||||
|
||||
# 오래된 로그 정리
|
||||
docker compose logs --tail=0 backend # 로그 초기화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 보안 권장사항
|
||||
|
||||
1. **방화벽 설정**: 필요한 포트(80, 3001)만 개방
|
||||
2. **SSL/TLS**: Nginx 리버스 프록시 + Let's Encrypt 적용 권장
|
||||
3. **정기 백업**: 최소 주 1회 DB 백업
|
||||
4. **로그 모니터링**: 비정상 접근 감시
|
||||
|
||||
---
|
||||
|
||||
## 연락처
|
||||
|
||||
배포 관련 문의: [담당자 이메일]
|
||||
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
# Vexplor 온프레미스(공장) 배포용 Docker Compose
|
||||
# 사용법: docker compose up -d
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# 1. 데이터베이스 (PostgreSQL)
|
||||
# ============================================
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
container_name: vexplor-db
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-vexplor}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
POSTGRES_DB: ${DB_NAME:-vexplor}
|
||||
TZ: Asia/Seoul
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db:/docker-entrypoint-initdb.d # 초기화 스크립트 (선택)
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-vexplor}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- vexplor-network
|
||||
|
||||
# ============================================
|
||||
# 2. 백엔드 API (Node.js)
|
||||
# ============================================
|
||||
backend:
|
||||
image: harbor.wace.me/speefox_vexplor/vexplor-backend:${IMAGE_TAG:-latest}
|
||||
container_name: vexplor-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
HOST: 0.0.0.0
|
||||
TZ: Asia/Seoul
|
||||
# DB 연결
|
||||
DB_HOST: database
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-vexplor}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME:-vexplor}
|
||||
# JWT
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
||||
# 암호화 키 (메일 등 민감정보 암호화용)
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-vexplor-encryption-key-32characters-secure}
|
||||
# 회사 코드 (온프레미스는 단일 회사)
|
||||
DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX}
|
||||
# 로깅
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
- backend_logs:/app/logs
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3001"
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- vexplor-network
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
# ============================================
|
||||
# 3. 프론트엔드 (Next.js)
|
||||
# ============================================
|
||||
frontend:
|
||||
image: harbor.wace.me/speefox_vexplor/vexplor-frontend:${IMAGE_TAG:-latest}
|
||||
container_name: vexplor-frontend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
TZ: Asia/Seoul
|
||||
# 백엔드 API URL (내부 통신)
|
||||
NEXT_PUBLIC_API_URL: http://${SERVER_IP:-localhost}:${BACKEND_PORT:-3001}/api
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-80}:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- vexplor-network
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
# ============================================
|
||||
# 4. Watchtower (자동 업데이트)
|
||||
# ============================================
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: vexplor-watchtower
|
||||
environment:
|
||||
TZ: Asia/Seoul
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
# Harbor 레지스트리 인증
|
||||
REPO_USER: ${HARBOR_USER}
|
||||
REPO_PASS: ${HARBOR_PASSWORD}
|
||||
# 업데이트 설정
|
||||
# WATCHTOWER_POLL_INTERVAL: ${UPDATE_INTERVAL:-300} # 간격 기반 (비활성화)
|
||||
WATCHTOWER_SCHEDULE: "0 0 * * * *" # 매시 정각에 실행 (cron 형식)
|
||||
WATCHTOWER_CLEANUP: "true" # 이전 이미지 자동 삭제
|
||||
WATCHTOWER_INCLUDE_STOPPED: "true" # 중지된 컨테이너도 업데이트
|
||||
WATCHTOWER_ROLLING_RESTART: "true" # 순차 재시작 (다운타임 최소화)
|
||||
WATCHTOWER_LABEL_ENABLE: "true" # 라벨이 있는 컨테이너만 업데이트
|
||||
# 알림 설정 (선택)
|
||||
# WATCHTOWER_NOTIFICATIONS: slack
|
||||
# WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: ${SLACK_WEBHOOK_URL}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Harbor 인증 정보 (docker login 후 생성됨)
|
||||
- ~/.docker/config.json:/config.json:ro
|
||||
restart: always
|
||||
networks:
|
||||
- vexplor-network
|
||||
|
||||
# ============================================
|
||||
# 볼륨 정의
|
||||
# ============================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
backend_data:
|
||||
driver: local
|
||||
backend_logs:
|
||||
driver: local
|
||||
|
||||
# ============================================
|
||||
# 네트워크 정의
|
||||
# ============================================
|
||||
networks:
|
||||
vexplor-network:
|
||||
driver: bridge
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# ============================================
|
||||
# Vexplor 온프레미스(공장) 환경 변수
|
||||
# ============================================
|
||||
# 사용법: 이 파일을 .env로 복사 후 값 수정
|
||||
# cp env.example .env
|
||||
|
||||
# ============================================
|
||||
# 서버 정보
|
||||
# ============================================
|
||||
# 이 서버의 IP 주소 (프론트엔드가 백엔드 API 호출할 때 사용)
|
||||
SERVER_IP=192.168.0.100
|
||||
|
||||
# ============================================
|
||||
# 회사 정보
|
||||
# ============================================
|
||||
# 이 공장의 회사 코드 (멀티테넌시용)
|
||||
COMPANY_CODE=SPIFOX
|
||||
|
||||
# ============================================
|
||||
# 데이터베이스 설정
|
||||
# ============================================
|
||||
DB_USER=vexplor
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_NAME=vexplor
|
||||
DB_PORT=5432
|
||||
|
||||
# ============================================
|
||||
# 백엔드 설정
|
||||
# ============================================
|
||||
BACKEND_PORT=3001
|
||||
JWT_SECRET=your_jwt_secret_key_minimum_32_characters
|
||||
JWT_EXPIRES_IN=24h
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ============================================
|
||||
# 프론트엔드 설정
|
||||
# ============================================
|
||||
FRONTEND_PORT=80
|
||||
|
||||
# ============================================
|
||||
# Harbor 레지스트리 인증
|
||||
# ============================================
|
||||
# Watchtower가 이미지를 당겨올 때 사용
|
||||
HARBOR_USER=your_harbor_username
|
||||
HARBOR_PASSWORD=your_harbor_password
|
||||
|
||||
# ============================================
|
||||
# 이미지 태그
|
||||
# ============================================
|
||||
# latest 또는 특정 버전 (v1.0.0 등)
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# ============================================
|
||||
# Watchtower 설정
|
||||
# ============================================
|
||||
# 업데이트 확인 주기 (초 단위)
|
||||
# 300 = 5분, 3600 = 1시간, 86400 = 24시간
|
||||
UPDATE_INTERVAL=3600
|
||||
|
||||
# ============================================
|
||||
# 알림 설정 (선택)
|
||||
# ============================================
|
||||
# Slack 웹훅 URL (업데이트 알림 받기)
|
||||
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/xxx/xxx
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# Vexplor 백업 스크립트
|
||||
# Cron에 등록하여 정기 백업 가능
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/vexplor"
|
||||
BACKUP_DIR="/opt/vexplor/backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 백업 디렉토리 생성
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
echo "=========================================="
|
||||
echo " Vexplor 백업 시작 - $DATE"
|
||||
echo "=========================================="
|
||||
|
||||
cd $INSTALL_DIR
|
||||
|
||||
# 1. PostgreSQL 데이터베이스 백업
|
||||
echo "[1/3] 데이터베이스 백업..."
|
||||
docker compose exec -T database pg_dump -U vexplor vexplor > "$BACKUP_DIR/db_$DATE.sql"
|
||||
gzip "$BACKUP_DIR/db_$DATE.sql"
|
||||
echo " → $BACKUP_DIR/db_$DATE.sql.gz"
|
||||
|
||||
# 2. 업로드 파일 백업
|
||||
echo "[2/3] 업로드 파일 백업..."
|
||||
docker cp vexplor-backend:/app/uploads "$BACKUP_DIR/uploads_$DATE" 2>/dev/null || echo " → 업로드 폴더 없음 (스킵)"
|
||||
if [ -d "$BACKUP_DIR/uploads_$DATE" ]; then
|
||||
tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" -C "$BACKUP_DIR" "uploads_$DATE"
|
||||
rm -rf "$BACKUP_DIR/uploads_$DATE"
|
||||
echo " → $BACKUP_DIR/uploads_$DATE.tar.gz"
|
||||
fi
|
||||
|
||||
# 3. 환경 설정 백업
|
||||
echo "[3/3] 환경 설정 백업..."
|
||||
cp "$INSTALL_DIR/.env" "$BACKUP_DIR/env_$DATE"
|
||||
cp "$INSTALL_DIR/docker-compose.yml" "$BACKUP_DIR/docker-compose_$DATE.yml"
|
||||
echo " → $BACKUP_DIR/env_$DATE"
|
||||
echo " → $BACKUP_DIR/docker-compose_$DATE.yml"
|
||||
|
||||
# 4. 오래된 백업 정리 (30일 이상)
|
||||
echo ""
|
||||
echo "[정리] 30일 이상 된 백업 삭제..."
|
||||
find $BACKUP_DIR -type f -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
# 완료
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 백업 완료!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "백업 위치: $BACKUP_DIR"
|
||||
ls -lh $BACKUP_DIR | tail -10
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# Vexplor 온프레미스 초기 설치 스크립트
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " Vexplor 온프레미스 설치 스크립트"
|
||||
echo "=========================================="
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 설치 경로
|
||||
INSTALL_DIR="/opt/vexplor"
|
||||
|
||||
# 1. Docker 설치 확인
|
||||
echo -e "\n${YELLOW}[1/5] Docker 설치 확인...${NC}"
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Docker가 설치되어 있지 않습니다.${NC}"
|
||||
echo "다음 명령어로 설치하세요:"
|
||||
echo " curl -fsSL https://get.docker.com | sh"
|
||||
echo " sudo usermod -aG docker \$USER"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Docker $(docker --version | cut -d' ' -f3)${NC}"
|
||||
|
||||
# 2. Docker Compose 확인
|
||||
echo -e "\n${YELLOW}[2/5] Docker Compose 확인...${NC}"
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}Docker Compose v2가 설치되어 있지 않습니다.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}$(docker compose version)${NC}"
|
||||
|
||||
# 3. 설치 디렉토리 생성
|
||||
echo -e "\n${YELLOW}[3/5] 설치 디렉토리 생성...${NC}"
|
||||
sudo mkdir -p $INSTALL_DIR
|
||||
sudo chown $USER:$USER $INSTALL_DIR
|
||||
echo -e "${GREEN}$INSTALL_DIR 생성 완료${NC}"
|
||||
|
||||
# 4. 파일 복사
|
||||
echo -e "\n${YELLOW}[4/5] 설정 파일 복사...${NC}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/"
|
||||
cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/"
|
||||
|
||||
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||
cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/.env"
|
||||
echo -e "${YELLOW}[주의] .env 파일을 생성했습니다. 반드시 수정하세요!${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}파일 복사 완료${NC}"
|
||||
|
||||
# 5. Harbor 로그인 안내
|
||||
echo -e "\n${YELLOW}[5/5] Harbor 레지스트리 로그인...${NC}"
|
||||
if [ ! -f ~/.docker/config.json ] || ! grep -q "harbor.wace.me" ~/.docker/config.json 2>/dev/null; then
|
||||
echo -e "${YELLOW}Harbor 로그인이 필요합니다:${NC}"
|
||||
echo " docker login harbor.wace.me"
|
||||
else
|
||||
echo -e "${GREEN}Harbor 로그인 확인됨${NC}"
|
||||
fi
|
||||
|
||||
# 완료 메시지
|
||||
echo -e "\n=========================================="
|
||||
echo -e "${GREEN} 설치 준비 완료!${NC}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "다음 단계:"
|
||||
echo " 1. 환경 변수 설정: nano $INSTALL_DIR/.env"
|
||||
echo " 2. Harbor 로그인: docker login harbor.wace.me"
|
||||
echo " 3. 서비스 시작: cd $INSTALL_DIR && docker compose up -d"
|
||||
echo ""
|
||||
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# Vexplor 온프레미스 서버 초기 설정 스크립트
|
||||
# 스피폭스 공장 서버용
|
||||
# ============================================
|
||||
# 사용법: sudo bash server-setup.sh
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Vexplor 서버 초기 설정"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# root 권한 확인
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}이 스크립트는 root 권한이 필요합니다.${NC}"
|
||||
echo "다음 명령어로 실행하세요: sudo bash server-setup.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 1. Docker 설치
|
||||
# ============================================
|
||||
echo -e "${YELLOW}[1/5] Docker 설치 중...${NC}"
|
||||
|
||||
# 기존 Docker 제거
|
||||
apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
|
||||
|
||||
# 필수 패키지 설치
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl gnupg
|
||||
|
||||
# Docker GPG 키 추가
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Docker 저장소 추가
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Docker 설치
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
echo -e "${GREEN}Docker 설치 완료!${NC}"
|
||||
docker --version
|
||||
docker compose version
|
||||
|
||||
# ============================================
|
||||
# 2. 사용자를 docker 그룹에 추가
|
||||
# ============================================
|
||||
echo ""
|
||||
echo -e "${YELLOW}[2/5] 사용자 권한 설정...${NC}"
|
||||
|
||||
# wace 사용자를 docker 그룹에 추가
|
||||
usermod -aG docker wace
|
||||
|
||||
echo -e "${GREEN}wace 사용자를 docker 그룹에 추가했습니다.${NC}"
|
||||
|
||||
# ============================================
|
||||
# 3. Vexplor 디렉토리 생성
|
||||
# ============================================
|
||||
echo ""
|
||||
echo -e "${YELLOW}[3/5] Vexplor 디렉토리 생성...${NC}"
|
||||
|
||||
mkdir -p /opt/vexplor
|
||||
chown wace:wace /opt/vexplor
|
||||
|
||||
echo -e "${GREEN}/opt/vexplor 디렉토리 생성 완료!${NC}"
|
||||
|
||||
# ============================================
|
||||
# 4. Docker 서비스 시작 및 자동 시작 설정
|
||||
# ============================================
|
||||
echo ""
|
||||
echo -e "${YELLOW}[4/5] Docker 서비스 설정...${NC}"
|
||||
|
||||
systemctl start docker
|
||||
systemctl enable docker
|
||||
|
||||
echo -e "${GREEN}Docker 서비스 활성화 완료!${NC}"
|
||||
|
||||
# ============================================
|
||||
# 5. 방화벽 설정 (필요시)
|
||||
# ============================================
|
||||
echo ""
|
||||
echo -e "${YELLOW}[5/5] 방화벽 설정 확인...${NC}"
|
||||
|
||||
if command -v ufw &> /dev/null; then
|
||||
ufw status
|
||||
echo ""
|
||||
echo "필요시 다음 포트를 개방하세요:"
|
||||
echo " sudo ufw allow 80/tcp # 웹 서비스"
|
||||
echo " sudo ufw allow 3001/tcp # 백엔드 API"
|
||||
else
|
||||
echo "ufw가 설치되어 있지 않습니다. (방화벽 설정 스킵)"
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 완료
|
||||
# ============================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN} 서버 초기 설정 완료!${NC}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "다음 단계:"
|
||||
echo " 1. 로그아웃 후 다시 로그인 (docker 그룹 적용)"
|
||||
echo " exit"
|
||||
echo " ssh -p 22 wace@112.168.212.142"
|
||||
echo ""
|
||||
echo " 2. Docker 동작 확인"
|
||||
echo " docker ps"
|
||||
echo ""
|
||||
echo " 3. Vexplor 배포 진행"
|
||||
echo " cd /opt/vexplor"
|
||||
echo " # docker-compose.yml 및 .env 파일 복사 후"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# Vexplor 수동 업데이트 스크립트
|
||||
# Watchtower를 기다리지 않고 즉시 업데이트할 때 사용
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/vexplor"
|
||||
cd $INSTALL_DIR
|
||||
|
||||
echo "=========================================="
|
||||
echo " Vexplor 수동 업데이트"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. 현재 상태 백업
|
||||
echo "[1/4] 현재 설정 백업..."
|
||||
docker compose config > "backup-config-$(date +%Y%m%d-%H%M%S).yml"
|
||||
|
||||
# 2. 최신 이미지 다운로드
|
||||
echo "[2/4] 최신 이미지 다운로드..."
|
||||
docker compose pull backend frontend
|
||||
|
||||
# 3. 서비스 재시작 (롤링 업데이트)
|
||||
echo "[3/4] 서비스 재시작..."
|
||||
docker compose up -d --no-deps backend
|
||||
sleep 10 # 백엔드가 완전히 뜰 때까지 대기
|
||||
docker compose up -d --no-deps frontend
|
||||
|
||||
# 4. 상태 확인
|
||||
echo "[4/4] 상태 확인..."
|
||||
sleep 5
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 업데이트 완료!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "로그 확인: docker compose logs -f"
|
||||
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
# 디지털트윈 아키텍처 v4
|
||||
|
||||
## 변경사항 (v3 → v4)
|
||||
|
||||
| 구분 | v3 | v4 |
|
||||
| :--- | :--- | :--- |
|
||||
| OTA 업데이트 | 개념만 존재 | Fleet Manager + MQTT 구현 |
|
||||
| 디바이스 관리 | 없음 | Device Registry 추가 |
|
||||
| 상태 모니터링 | 없음 | Heartbeat + Metrics 추가 |
|
||||
| 원격 제어 | 없음 | MQTT 기반 명령 추가 |
|
||||
| Agent | 없음 | Fleet Agent 추가 |
|
||||
|
||||
---
|
||||
|
||||
## Mermaid 다이어그램
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
layout: dagre
|
||||
---
|
||||
flowchart BT
|
||||
subgraph Global_Platform["☁️ Vexplor 글로벌 플랫폼"]
|
||||
direction TB
|
||||
AAS_Dashboard["<b>💻 AAS 통합 대시보드</b><br>(React/Next.js)<br>• 중앙 모니터링<br>• Fleet 관리 UI"]
|
||||
Global_API["<b>🌐 글로벌 API 게이트웨이</b><br>• 사용자 인증 (Auth)<br>• 고객사 라우팅<br>• Fleet API"]
|
||||
|
||||
subgraph Fleet_System["🎛️ Fleet Management"]
|
||||
Fleet_Manager["<b>📊 Fleet Manager</b><br>• Device Registry<br>• 배포 오케스트레이션<br>• 상태 모니터링"]
|
||||
MQTT_Broker["<b>📡 MQTT Broker</b><br>(Mosquitto/EMQX)<br>• 실시간 통신<br>• 10,000+ 연결"]
|
||||
Monitoring["<b>📈 Monitoring</b><br>(Prometheus/Grafana)<br>• 메트릭 수집<br>• 알림"]
|
||||
end
|
||||
|
||||
Update_Server["<b>🚀 배포/업데이트 매니저</b><br>• Docker 이미지 레지스트리 (Harbor)<br>• 버전 관리<br>• Canary 배포"]
|
||||
end
|
||||
|
||||
subgraph Local_Server["스피폭스 사내 서버 (Local Server)"]
|
||||
Fleet_Agent_A["<b>🤖 Fleet Agent</b><br>• MQTT 연결<br>• Heartbeat (30초)<br>• 원격 명령 실행<br>• Docker 관리"]
|
||||
VEX_Engine["<b>VEX Flow 엔진</b><br>데이터 수집/처리"]
|
||||
Customer_DB[("<b>사내 통합 DB</b><br>(모든 데이터 보유)")]
|
||||
Watchtower_A["<b>🐋 Watchtower</b><br>이미지 자동 업데이트"]
|
||||
end
|
||||
|
||||
subgraph Edge_Internals["🖥️ 엣지 디바이스 (Store & Forward)"]
|
||||
Edge_Collector["<b>수집/가공</b><br>(Python)"]
|
||||
Edge_Buffer[("<b>💾 로컬 버퍼</b><br>(TimescaleDB)<br>단절 시 임시 저장")]
|
||||
Edge_Sender["<b>📤 전송 매니저</b><br>(Priority Queue)"]
|
||||
Edge_Retry_Queue[("<b>🕒 재전송 큐</b><br>(SQLite/File)")]
|
||||
end
|
||||
|
||||
subgraph Factory_A["🏭 스피폭스 공장 현장 (Factory Floor)"]
|
||||
Edge_Internals
|
||||
PLC_A["PLC / 센서"]
|
||||
end
|
||||
|
||||
subgraph Customer_A["🏢 고객사 A: 스피폭스 (사내망)"]
|
||||
Local_Server
|
||||
Factory_A
|
||||
end
|
||||
|
||||
subgraph Local_Server_B["고객사 B 사내 서버"]
|
||||
Fleet_Agent_B["<b>🤖 Fleet Agent</b>"]
|
||||
Watchtower_B["<b>🐋 Watchtower</b>"]
|
||||
end
|
||||
|
||||
subgraph Customer_B["🏭 고객사 B (확장 예정)"]
|
||||
Local_Server_B
|
||||
end
|
||||
|
||||
subgraph Local_Server_N["고객사 N 사내 서버"]
|
||||
Fleet_Agent_N["<b>🤖 Fleet Agent</b>"]
|
||||
end
|
||||
|
||||
subgraph Customer_N["🏭 고객사 N (10,000개)"]
|
||||
Local_Server_N
|
||||
end
|
||||
|
||||
%% 대시보드 연결
|
||||
AAS_Dashboard <--> Global_API
|
||||
AAS_Dashboard <--> Fleet_Manager
|
||||
|
||||
%% Fleet System 내부 연결
|
||||
Fleet_Manager <--> MQTT_Broker
|
||||
Fleet_Manager <--> Monitoring
|
||||
Fleet_Manager <--> Update_Server
|
||||
|
||||
%% 공장 내부 연결
|
||||
PLC_A <--> Edge_Collector
|
||||
Edge_Collector --> Edge_Buffer
|
||||
Edge_Buffer --> Edge_Sender
|
||||
Edge_Sender -- ① 정상 전송 --> VEX_Engine
|
||||
Edge_Sender -- ② 전송 실패 시 --> Edge_Retry_Queue
|
||||
Edge_Retry_Queue -. ③ 네트워크 복구 시 재전송 .-> Edge_Sender
|
||||
VEX_Engine <--> Customer_DB
|
||||
|
||||
%% Fleet Agent 연결 (MQTT - Outbound Only)
|
||||
Fleet_Agent_A == 📡 MQTT (Heartbeat/명령) ==> MQTT_Broker
|
||||
Fleet_Agent_B == 📡 MQTT ==> MQTT_Broker
|
||||
Fleet_Agent_N == 📡 MQTT ==> MQTT_Broker
|
||||
|
||||
%% Agent ↔ 로컬 컴포넌트
|
||||
Fleet_Agent_A <--> VEX_Engine
|
||||
Fleet_Agent_A <--> Watchtower_A
|
||||
Fleet_Agent_A <--> Customer_DB
|
||||
|
||||
%% OTA 업데이트 (Pull 방식)
|
||||
Update_Server -. 이미지 배포 .-> Watchtower_A
|
||||
Update_Server -. 이미지 배포 .-> Watchtower_B
|
||||
Watchtower_A -. 컨테이너 업데이트 .-> VEX_Engine
|
||||
|
||||
%% 엣지 업데이트
|
||||
VEX_Engine -. 엣지 업데이트 .-> Edge_Collector
|
||||
|
||||
%% 스타일
|
||||
AAS_Dashboard:::user
|
||||
Global_API:::global
|
||||
Update_Server:::global
|
||||
Fleet_Manager:::fleet
|
||||
MQTT_Broker:::fleet
|
||||
Monitoring:::fleet
|
||||
VEX_Engine:::localServer
|
||||
Customer_DB:::localServer
|
||||
Fleet_Agent_A:::agent
|
||||
Fleet_Agent_B:::agent
|
||||
Fleet_Agent_N:::agent
|
||||
Watchtower_A:::agent
|
||||
Watchtower_B:::agent
|
||||
Edge_Collector:::edge
|
||||
Edge_Buffer:::edgedb
|
||||
Edge_Sender:::edge
|
||||
Edge_Retry_Queue:::fail
|
||||
PLC_A:::factory
|
||||
|
||||
classDef factory fill:#e1f5fe,stroke:#01579b,stroke-width:2px
|
||||
classDef edge fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
|
||||
classDef edgedb fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5
|
||||
classDef localServer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
|
||||
classDef global fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
|
||||
classDef user fill:#ffebee,stroke:#c62828,stroke-width:2px
|
||||
classDef fleet fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||
classDef agent fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
|
||||
classDef fail fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
|
||||
|
||||
linkStyle 8 stroke:#2e7d32,stroke-width:2px,fill:none
|
||||
linkStyle 9 stroke:#c62828,stroke-width:2px,fill:none
|
||||
linkStyle 10 stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5,fill:none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 추가된 컴포넌트 설명
|
||||
|
||||
### 1. Fleet Management (신규)
|
||||
|
||||
| 컴포넌트 | 역할 |
|
||||
| :--- | :--- |
|
||||
| **Fleet Manager** | 10,000개 디바이스 등록/관리, 배포 오케스트레이션 |
|
||||
| **MQTT Broker** | 실시간 양방향 통신 (Outbound Only 유지) |
|
||||
| **Monitoring** | Prometheus + Grafana, 메트릭 수집 & 알림 |
|
||||
|
||||
### 2. Fleet Agent (각 공장 서버에 설치)
|
||||
|
||||
| 기능 | 설명 |
|
||||
| :--- | :--- |
|
||||
| **MQTT 연결** | 글로벌 플랫폼과 상시 연결 (Outbound) |
|
||||
| **Heartbeat** | 30초마다 상태 보고 |
|
||||
| **원격 명령** | 업데이트, 재시작, 설정 변경 수신 |
|
||||
| **Docker 관리** | 컨테이너 상태 모니터링 & 제어 |
|
||||
|
||||
### 3. Watchtower (기존 유지)
|
||||
|
||||
- Harbor에서 새 이미지 자동 Pull
|
||||
- Fleet Agent의 명령으로 즉시 업데이트 가능
|
||||
|
||||
---
|
||||
|
||||
## 통신 흐름 비교
|
||||
|
||||
### v3 (기존)
|
||||
```
|
||||
보안 커넥터 ←→ 글로벌 API (양방향 터널)
|
||||
```
|
||||
|
||||
### v4 (신규)
|
||||
```
|
||||
Fleet Agent ──→ MQTT Broker (Outbound Only)
|
||||
←── 명령 수신 (Subscribe)
|
||||
──→ 상태 보고 (Publish)
|
||||
|
||||
Watchtower ──→ Harbor (Pull Only)
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 방화벽 Inbound 규칙 불필요
|
||||
- 10,000개 동시 연결 가능
|
||||
- 실시간 명령 전달
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
[공장 → 글로벌]
|
||||
PLC → 엣지 → VEX Flow → Fleet Agent → MQTT → Fleet Manager → Dashboard
|
||||
|
||||
[글로벌 → 공장]
|
||||
Dashboard → Fleet Manager → MQTT → Fleet Agent → Docker/VEX Flow
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
# Fleet Management 시스템 구축 계획서
|
||||
|
||||
## 개요
|
||||
|
||||
**목표:** 10,000개 이상의 온프레미스 공장 서버를 중앙에서 효율적으로 관리
|
||||
|
||||
**현재 상태:** 1개 업체 (스피폭스), Watchtower 기반 자동 업데이트
|
||||
|
||||
**목표 상태:** 10,000개 업체, 실시간 모니터링 & 원격 제어 가능
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [아키텍처 설계](#1-아키텍처-설계)
|
||||
2. [Phase별 구현 계획](#2-phase별-구현-계획)
|
||||
3. [핵심 컴포넌트 상세](#3-핵심-컴포넌트-상세)
|
||||
4. [데이터베이스 스키마](#4-데이터베이스-스키마)
|
||||
5. [API 설계](#5-api-설계)
|
||||
6. [기술 스택](#6-기술-스택)
|
||||
7. [일정 및 마일스톤](#7-일정-및-마일스톤)
|
||||
8. [리스크 및 대응](#8-리스크-및-대응)
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 설계
|
||||
|
||||
### 1.1 전체 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Vexplor 글로벌 플랫폼 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Web UI │ │ Fleet API │ │ Config │ │ Monitoring │ │
|
||||
│ │ (Dashboard) │ │ Gateway │ │ Server │ │ & Alerts │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └────────────────┼────────────────┼────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Message │ │ Device │ │
|
||||
│ │ Broker │ │ Registry │ │
|
||||
│ │ (MQTT) │ │ (Redis) │ │
|
||||
│ └──────┬──────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
└──────────────────────────┼────────────────────────────────────────────┘
|
||||
│
|
||||
│ MQTT (TLS)
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ 스피폭스 │ │ 엔키드 │ │ 고객 N │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
│ │ │
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Vexplor │ │ Vexplor │ │ Vexplor │
|
||||
│ Backend │ │ Backend │ │ Backend │
|
||||
│Frontend │ │Frontend │ │Frontend │
|
||||
│ DB │ │ DB │ │ DB │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### 1.2 통신 흐름
|
||||
|
||||
```
|
||||
[공장 서버 → 글로벌]
|
||||
1. Agent 시작 시 MQTT 연결 (Outbound Only)
|
||||
2. 주기적 Heartbeat 전송 (30초)
|
||||
3. 상태/메트릭 보고 (5분)
|
||||
4. 로그 전송 (선택적)
|
||||
|
||||
[글로벌 → 공장 서버]
|
||||
1. 업데이트 명령
|
||||
2. 설정 변경
|
||||
3. 재시작 명령
|
||||
4. 데이터 요청
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase별 구현 계획
|
||||
|
||||
### Phase 1: 기반 구축 (1~10개 업체)
|
||||
**기간:** 2주
|
||||
|
||||
| 구현 항목 | 설명 | 우선순위 |
|
||||
| :--- | :--- | :--- |
|
||||
| Device Registry API | 디바이스 등록/조회 | P0 |
|
||||
| Heartbeat API | 상태 보고 수신 | P0 |
|
||||
| 기본 대시보드 | 디바이스 목록/상태 표시 | P1 |
|
||||
| Agent 기본 버전 | Heartbeat 전송 기능 | P0 |
|
||||
|
||||
**산출물:**
|
||||
- `POST /api/fleet/devices/register`
|
||||
- `POST /api/fleet/devices/heartbeat`
|
||||
- `GET /api/fleet/devices`
|
||||
- Agent Docker 이미지
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 실시간 통신 (10~100개 업체)
|
||||
**기간:** 4주
|
||||
|
||||
| 구현 항목 | 설명 | 우선순위 |
|
||||
| :--- | :--- | :--- |
|
||||
| MQTT 브로커 설치 | Eclipse Mosquitto | P0 |
|
||||
| Agent MQTT 연결 | 상시 연결 유지 | P0 |
|
||||
| 원격 명령 기능 | 업데이트/재시작 명령 | P1 |
|
||||
| 실시간 상태 업데이트 | WebSocket → 대시보드 | P1 |
|
||||
|
||||
**산출물:**
|
||||
- MQTT 브로커 (Docker)
|
||||
- Agent v2 (MQTT 지원)
|
||||
- 원격 명령 UI
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 배포 관리 (100~500개 업체)
|
||||
**기간:** 6주
|
||||
|
||||
| 구현 항목 | 설명 | 우선순위 |
|
||||
| :--- | :--- | :--- |
|
||||
| 버전 관리 시스템 | 릴리즈 버전 관리 | P0 |
|
||||
| 단계적 롤아웃 | Canary 배포 | P0 |
|
||||
| 롤백 기능 | 이전 버전 복구 | P0 |
|
||||
| 그룹 관리 | 지역/업종별 그룹핑 | P1 |
|
||||
| 배포 스케줄링 | 시간대별 배포 | P2 |
|
||||
|
||||
**산출물:**
|
||||
- Release Management UI
|
||||
- Deployment Pipeline
|
||||
- Rollback 자동화
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 모니터링 강화 (500~2,000개 업체)
|
||||
**기간:** 6주
|
||||
|
||||
| 구현 항목 | 설명 | 우선순위 |
|
||||
| :--- | :--- | :--- |
|
||||
| 메트릭 수집 | CPU/Memory/Disk | P0 |
|
||||
| 알림 시스템 | Slack/Email/SMS | P0 |
|
||||
| 로그 중앙화 | 원격 로그 수집 | P1 |
|
||||
| 이상 탐지 | 자동 장애 감지 | P1 |
|
||||
| SLA 대시보드 | 가용성 리포트 | P2 |
|
||||
|
||||
**산출물:**
|
||||
- Prometheus + Grafana
|
||||
- Alert Manager
|
||||
- Log Aggregator (Loki)
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 대규모 확장 (2,000~10,000개 업체)
|
||||
**기간:** 8주
|
||||
|
||||
| 구현 항목 | 설명 | 우선순위 |
|
||||
| :--- | :--- | :--- |
|
||||
| MQTT 클러스터링 | 고가용성 브로커 | P0 |
|
||||
| 샤딩 | 지역별 분산 | P0 |
|
||||
| 자동 프로비저닝 | 신규 업체 자동 설정 | P1 |
|
||||
| API Rate Limiting | 과부하 방지 | P1 |
|
||||
| 멀티 리전 | 글로벌 분산 | P2 |
|
||||
|
||||
**산출물:**
|
||||
- MQTT Cluster (EMQX)
|
||||
- Regional Gateway
|
||||
- Auto-provisioning System
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 컴포넌트 상세
|
||||
|
||||
### 3.1 Fleet Agent (공장 서버에 설치)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Fleet Agent │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ MQTT │ │ Command │ │
|
||||
│ │ Client │ │ Executor │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Core Controller │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Metrics │ │ Docker │ │
|
||||
│ │ Collector │ │ Manager │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
- MQTT 연결 유지 (자동 재연결)
|
||||
- Heartbeat 전송 (30초)
|
||||
- 시스템 메트릭 수집
|
||||
- Docker 컨테이너 관리
|
||||
- 원격 명령 실행
|
||||
|
||||
### 3.2 Fleet Manager (글로벌 서버)
|
||||
|
||||
**주요 기능:**
|
||||
- 디바이스 등록/인증
|
||||
- 상태 모니터링
|
||||
- 배포 오케스트레이션
|
||||
- 설정 관리
|
||||
- 알림 발송
|
||||
|
||||
### 3.3 Message Broker (MQTT)
|
||||
|
||||
**선택지:**
|
||||
| 옵션 | 장점 | 단점 | 추천 규모 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Mosquitto | 가볍고 간단 | 클러스터링 어려움 | ~1,000 |
|
||||
| EMQX | 클러스터링, 고성능 | 복잡함 | 1,000~100,000 |
|
||||
| HiveMQ | 엔터프라이즈급 | 비용 | 100,000+ |
|
||||
|
||||
**권장:** Phase 1~3은 Mosquitto, Phase 4~5는 EMQX
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터베이스 스키마
|
||||
|
||||
### 4.1 디바이스 테이블
|
||||
|
||||
```sql
|
||||
-- 디바이스 (공장 서버) 정보
|
||||
CREATE TABLE fleet_devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id VARCHAR(50) UNIQUE NOT NULL, -- 고유 식별자
|
||||
company_code VARCHAR(20) NOT NULL, -- 회사 코드
|
||||
device_name VARCHAR(100), -- 표시 이름
|
||||
|
||||
-- 연결 정보
|
||||
ip_address VARCHAR(45),
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
is_online BOOLEAN DEFAULT false,
|
||||
|
||||
-- 버전 정보
|
||||
agent_version VARCHAR(20),
|
||||
app_version VARCHAR(20),
|
||||
|
||||
-- 시스템 정보
|
||||
os_info JSONB,
|
||||
hardware_info JSONB,
|
||||
|
||||
-- 그룹/태그
|
||||
device_group VARCHAR(50),
|
||||
tags JSONB DEFAULT '[]',
|
||||
|
||||
-- 메타
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
FOREIGN KEY (company_code) REFERENCES company_info(company_code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fleet_devices_company ON fleet_devices(company_code);
|
||||
CREATE INDEX idx_fleet_devices_online ON fleet_devices(is_online);
|
||||
CREATE INDEX idx_fleet_devices_group ON fleet_devices(device_group);
|
||||
```
|
||||
|
||||
### 4.2 Heartbeat 로그 테이블
|
||||
|
||||
```sql
|
||||
-- Heartbeat 기록 (TimescaleDB 권장)
|
||||
CREATE TABLE fleet_heartbeats (
|
||||
id BIGSERIAL,
|
||||
device_id VARCHAR(50) NOT NULL,
|
||||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- 상태
|
||||
status VARCHAR(20), -- OK, WARNING, ERROR
|
||||
uptime_seconds BIGINT,
|
||||
|
||||
-- 메트릭
|
||||
cpu_percent DECIMAL(5,2),
|
||||
memory_percent DECIMAL(5,2),
|
||||
disk_percent DECIMAL(5,2),
|
||||
|
||||
-- 컨테이너 상태
|
||||
containers JSONB,
|
||||
|
||||
PRIMARY KEY (device_id, received_at)
|
||||
);
|
||||
|
||||
-- TimescaleDB 하이퍼테이블 변환 (선택)
|
||||
-- SELECT create_hypertable('fleet_heartbeats', 'received_at');
|
||||
```
|
||||
|
||||
### 4.3 배포 테이블
|
||||
|
||||
```sql
|
||||
-- 릴리즈 버전 관리
|
||||
CREATE TABLE fleet_releases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
release_type VARCHAR(20), -- stable, beta, hotfix
|
||||
|
||||
-- 이미지 정보
|
||||
backend_image VARCHAR(200),
|
||||
frontend_image VARCHAR(200),
|
||||
agent_image VARCHAR(200),
|
||||
|
||||
-- 변경사항
|
||||
changelog TEXT,
|
||||
|
||||
-- 상태
|
||||
status VARCHAR(20) DEFAULT 'draft', -- draft, testing, released, deprecated
|
||||
released_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 배포 작업
|
||||
CREATE TABLE fleet_deployments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER REFERENCES fleet_releases(id),
|
||||
|
||||
-- 배포 대상
|
||||
target_type VARCHAR(20), -- all, group, specific
|
||||
target_value VARCHAR(100), -- 그룹명 또는 device_id
|
||||
|
||||
-- 롤아웃 설정
|
||||
rollout_strategy VARCHAR(20), -- immediate, canary, scheduled
|
||||
rollout_percentage INTEGER,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
|
||||
-- 상태
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
|
||||
-- 결과
|
||||
total_devices INTEGER,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
failed_count INTEGER DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 개별 디바이스 배포 상태
|
||||
CREATE TABLE fleet_deployment_status (
|
||||
id SERIAL PRIMARY KEY,
|
||||
deployment_id INTEGER REFERENCES fleet_deployments(id),
|
||||
device_id VARCHAR(50),
|
||||
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, downloading, installing, completed, failed
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
|
||||
UNIQUE(deployment_id, device_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.4 알림 규칙 테이블
|
||||
|
||||
```sql
|
||||
-- 알림 규칙
|
||||
CREATE TABLE fleet_alert_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
|
||||
-- 조건
|
||||
condition_type VARCHAR(50), -- offline, version_mismatch, high_cpu, etc.
|
||||
condition_value JSONB,
|
||||
threshold_minutes INTEGER, -- 조건 지속 시간
|
||||
|
||||
-- 알림 채널
|
||||
notify_channels JSONB, -- ["slack", "email"]
|
||||
notify_targets JSONB, -- 수신자 목록
|
||||
|
||||
-- 상태
|
||||
is_enabled BOOLEAN DEFAULT true,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 알림 기록
|
||||
CREATE TABLE fleet_alerts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rule_id INTEGER REFERENCES fleet_alert_rules(id),
|
||||
device_id VARCHAR(50),
|
||||
|
||||
alert_type VARCHAR(50),
|
||||
message TEXT,
|
||||
severity VARCHAR(20), -- info, warning, critical
|
||||
|
||||
-- 해결 상태
|
||||
status VARCHAR(20) DEFAULT 'open', -- open, acknowledged, resolved
|
||||
resolved_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 설계
|
||||
|
||||
### 5.1 Device Management API
|
||||
|
||||
```yaml
|
||||
# 디바이스 등록
|
||||
POST /api/fleet/devices/register
|
||||
Request:
|
||||
device_id: string (required)
|
||||
company_code: string (required)
|
||||
device_name: string
|
||||
agent_version: string
|
||||
os_info: object
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
device_id: string
|
||||
mqtt_credentials:
|
||||
broker_url: string
|
||||
username: string
|
||||
password: string
|
||||
|
||||
# 디바이스 목록 조회
|
||||
GET /api/fleet/devices
|
||||
Query:
|
||||
company_code: string
|
||||
is_online: boolean
|
||||
device_group: string
|
||||
page: number
|
||||
limit: number
|
||||
Response:
|
||||
success: boolean
|
||||
data: Device[]
|
||||
pagination: { total, page, limit }
|
||||
|
||||
# 디바이스 상세 조회
|
||||
GET /api/fleet/devices/:deviceId
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
device: Device
|
||||
recent_heartbeats: Heartbeat[]
|
||||
recent_alerts: Alert[]
|
||||
```
|
||||
|
||||
### 5.2 Heartbeat API
|
||||
|
||||
```yaml
|
||||
# Heartbeat 전송
|
||||
POST /api/fleet/devices/:deviceId/heartbeat
|
||||
Request:
|
||||
status: string
|
||||
uptime_seconds: number
|
||||
metrics:
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
disk_percent: number
|
||||
containers:
|
||||
- name: string
|
||||
status: string
|
||||
version: string
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
commands: Command[] # 대기 중인 명령 반환
|
||||
```
|
||||
|
||||
### 5.3 Deployment API
|
||||
|
||||
```yaml
|
||||
# 배포 생성
|
||||
POST /api/fleet/deployments
|
||||
Request:
|
||||
release_id: number
|
||||
target_type: "all" | "group" | "specific"
|
||||
target_value: string
|
||||
rollout_strategy: "immediate" | "canary" | "scheduled"
|
||||
rollout_percentage: number
|
||||
scheduled_at: datetime
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
deployment_id: number
|
||||
estimated_devices: number
|
||||
|
||||
# 배포 상태 조회
|
||||
GET /api/fleet/deployments/:deploymentId
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
deployment: Deployment
|
||||
status_summary:
|
||||
pending: number
|
||||
in_progress: number
|
||||
completed: number
|
||||
failed: number
|
||||
device_statuses: DeploymentStatus[]
|
||||
|
||||
# 배포 롤백
|
||||
POST /api/fleet/deployments/:deploymentId/rollback
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
rollback_deployment_id: number
|
||||
```
|
||||
|
||||
### 5.4 Command API
|
||||
|
||||
```yaml
|
||||
# 원격 명령 전송
|
||||
POST /api/fleet/devices/:deviceId/commands
|
||||
Request:
|
||||
command_type: "update" | "restart" | "config" | "logs"
|
||||
payload: object
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
command_id: string
|
||||
status: "queued"
|
||||
|
||||
# 명령 결과 조회
|
||||
GET /api/fleet/commands/:commandId
|
||||
Response:
|
||||
success: boolean
|
||||
data:
|
||||
command_id: string
|
||||
status: "queued" | "sent" | "executing" | "completed" | "failed"
|
||||
result: object
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 기술 스택
|
||||
|
||||
### 6.1 글로벌 플랫폼
|
||||
|
||||
| 컴포넌트 | 기술 | 비고 |
|
||||
| :--- | :--- | :--- |
|
||||
| Fleet API | Node.js (기존 backend-node 확장) | 기존 코드 재사용 |
|
||||
| Message Broker | Mosquitto → EMQX | 단계적 전환 |
|
||||
| Device Registry | Redis | 빠른 조회 |
|
||||
| Database | PostgreSQL | 기존 DB 확장 |
|
||||
| Time-series DB | TimescaleDB | Heartbeat 저장 |
|
||||
| Monitoring | Prometheus + Grafana | 메트릭 시각화 |
|
||||
| Log | Loki | 로그 중앙화 |
|
||||
| Alert | AlertManager | 알림 관리 |
|
||||
|
||||
### 6.2 Fleet Agent
|
||||
|
||||
| 컴포넌트 | 기술 | 비고 |
|
||||
| :--- | :--- | :--- |
|
||||
| Runtime | Go 또는 Node.js | 가볍고 안정적 |
|
||||
| MQTT Client | Paho MQTT | 표준 라이브러리 |
|
||||
| Docker SDK | Docker API | 컨테이너 관리 |
|
||||
| Metrics | gopsutil | 시스템 메트릭 |
|
||||
|
||||
### 6.3 대시보드
|
||||
|
||||
| 컴포넌트 | 기술 | 비고 |
|
||||
| :--- | :--- | :--- |
|
||||
| UI Framework | Next.js (기존) | 기존 코드 확장 |
|
||||
| Real-time | Socket.io | 실시간 상태 |
|
||||
| Charts | Recharts | 메트릭 시각화 |
|
||||
| Map | Leaflet | 지역별 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 일정 및 마일스톤
|
||||
|
||||
### 7.1 전체 일정
|
||||
|
||||
```
|
||||
2025 Q1 2025 Q2 2025 Q3
|
||||
│ │ │
|
||||
├── Phase 1 (2주) ─────────┤ │
|
||||
│ Device Registry │ │
|
||||
│ Heartbeat API │ │
|
||||
│ 기본 대시보드 │ │
|
||||
│ │ │
|
||||
│ ├── Phase 2 (4주) ──────────┤ │
|
||||
│ │ MQTT 브로커 │ │
|
||||
│ │ Agent v2 │ │
|
||||
│ │ 원격 명령 │ │
|
||||
│ │ │ │
|
||||
│ │ ├── Phase 3 (6주) ──────┤
|
||||
│ │ │ 버전 관리 │
|
||||
│ │ │ Canary 배포 │
|
||||
│ │ │ 롤백 │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
### 7.2 상세 마일스톤
|
||||
|
||||
| 마일스톤 | 목표 | 완료 기준 | 예상 일정 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| M1 | Device Registry | 디바이스 등록/조회 API 완료 | 1주차 |
|
||||
| M2 | Heartbeat | 상태 보고 & 저장 완료 | 2주차 |
|
||||
| M3 | Basic Dashboard | 디바이스 목록 UI 완료 | 2주차 |
|
||||
| M4 | MQTT Setup | 브로커 설치 & 연결 테스트 | 4주차 |
|
||||
| M5 | Agent v2 | MQTT 기반 Agent 완료 | 6주차 |
|
||||
| M6 | Remote Command | 업데이트/재시작 명령 완료 | 8주차 |
|
||||
| M7 | Release Mgmt | 버전 관리 UI 완료 | 10주차 |
|
||||
| M8 | Canary Deploy | 단계적 배포 완료 | 14주차 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 리스크 및 대응
|
||||
|
||||
### 8.1 기술적 리스크
|
||||
|
||||
| 리스크 | 영향 | 확률 | 대응 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| MQTT 연결 불안정 | 높음 | 중간 | 자동 재연결, 오프라인 큐 |
|
||||
| 대량 동시 접속 | 높음 | 높음 | 클러스터링, 로드밸런싱 |
|
||||
| 보안 취약점 | 높음 | 낮음 | TLS 필수, 인증 강화 |
|
||||
| 네트워크 단절 | 중간 | 높음 | 로컬 캐시, 재전송 로직 |
|
||||
|
||||
### 8.2 운영 리스크
|
||||
|
||||
| 리스크 | 영향 | 확률 | 대응 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 잘못된 배포 | 높음 | 중간 | Canary 배포, 자동 롤백 |
|
||||
| 모니터링 누락 | 중간 | 중간 | 다중 알림 채널 |
|
||||
| 버전 파편화 | 중간 | 높음 | 강제 업데이트 정책 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 다음 단계
|
||||
|
||||
### 즉시 시작할 작업 (Phase 1)
|
||||
|
||||
1. **Device Registry 테이블 생성**
|
||||
- `fleet_devices` 테이블 마이그레이션
|
||||
|
||||
2. **Fleet API 엔드포인트 개발**
|
||||
- `POST /api/fleet/devices/register`
|
||||
- `POST /api/fleet/devices/:deviceId/heartbeat`
|
||||
- `GET /api/fleet/devices`
|
||||
|
||||
3. **Agent 기본 버전 개발**
|
||||
- Docker 이미지로 배포
|
||||
- 주기적 Heartbeat 전송
|
||||
|
||||
4. **대시보드 기본 화면**
|
||||
- 디바이스 목록
|
||||
- 온라인/오프라인 상태 표시
|
||||
|
||||
---
|
||||
|
||||
## 부록
|
||||
|
||||
### A. MQTT 토픽 설계
|
||||
|
||||
```
|
||||
vexplor/
|
||||
├── devices/
|
||||
│ ├── {device_id}/
|
||||
│ │ ├── status # 상태 보고 (Agent → Server)
|
||||
│ │ ├── metrics # 메트릭 보고 (Agent → Server)
|
||||
│ │ ├── commands # 명령 수신 (Server → Agent)
|
||||
│ │ └── responses # 명령 응답 (Agent → Server)
|
||||
│ │
|
||||
├── broadcasts/
|
||||
│ ├── all # 전체 공지
|
||||
│ └── groups/{group} # 그룹별 공지
|
||||
│
|
||||
└── system/
|
||||
├── announcements # 시스템 공지
|
||||
└── maintenance # 점검 알림
|
||||
```
|
||||
|
||||
### B. Agent 설정 파일
|
||||
|
||||
```yaml
|
||||
# /opt/vexplor/agent/config.yaml
|
||||
device:
|
||||
id: "SPIFOX-001"
|
||||
company_code: "SPIFOX"
|
||||
name: "스피폭스 메인 서버"
|
||||
|
||||
mqtt:
|
||||
broker: "mqtts://mqtt.vexplor.com:8883"
|
||||
username: "${MQTT_USERNAME}"
|
||||
password: "${MQTT_PASSWORD}"
|
||||
keepalive: 60
|
||||
reconnect_interval: 5
|
||||
|
||||
heartbeat:
|
||||
interval: 30 # seconds
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
interval: 300 # 5 minutes
|
||||
collect:
|
||||
- cpu
|
||||
- memory
|
||||
- disk
|
||||
- network
|
||||
|
||||
docker:
|
||||
socket: "/var/run/docker.sock"
|
||||
managed_containers:
|
||||
- vexplor-backend
|
||||
- vexplor-frontend
|
||||
- vexplor-db
|
||||
```
|
||||
|
||||
### C. 참고 자료
|
||||
|
||||
- [EMQX Documentation](https://docs.emqx.com/)
|
||||
- [Eclipse Mosquitto](https://mosquitto.org/)
|
||||
- [AWS IoT Device Management](https://aws.amazon.com/iot-device-management/)
|
||||
- [Google Cloud IoT Core](https://cloud.google.com/iot-core)
|
||||
- [HashiCorp Nomad](https://www.nomadproject.io/)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
|
|
@ -39,6 +39,6 @@ RUN mkdir -p logs uploads data && \
|
|||
chown -R appuser:appgroup /app && \
|
||||
chmod -R 755 /app
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE 3001
|
||||
USER appuser
|
||||
CMD ["node", "dist/app.js"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
# Node.js 백엔드 (운영용)
|
||||
backend:
|
||||
# Node.js 백엔드
|
||||
plm-backend:
|
||||
build:
|
||||
context: ../../backend-node
|
||||
dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile
|
||||
|
|
@ -25,7 +25,7 @@ services:
|
|||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
services:
|
||||
# Next.js 프론트엔드만
|
||||
frontend:
|
||||
# Next.js 프론트엔드
|
||||
plm-frontend:
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: ../docker/prod/frontend.Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
|
||||
container_name: pms-frontend-linux
|
||||
ports:
|
||||
- "5555:5555"
|
||||
- NEXT_PUBLIC_API_URL=https://api.vexplor.com
|
||||
container_name: plm-frontend
|
||||
restart: always
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
|
||||
networks:
|
||||
- pms-network
|
||||
restart: unless-stopped
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_API_URL: https://api.vexplor.com
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
volumes:
|
||||
- /home/vexplor/frontend_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
|
||||
- traefik.http.routers.frontend.entrypoints=websecure,web
|
||||
- traefik.http.routers.frontend.tls=true
|
||||
- traefik.http.routers.frontend.tls.certresolver=le
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
pms-network:
|
||||
driver: bridge
|
||||
default:
|
||||
name: toktork_server_default
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ COPY . .
|
|||
# Disable telemetry during the build
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
|
||||
ARG NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api
|
||||
# 빌드 시 환경변수 설정
|
||||
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Build the application
|
||||
|
|
@ -55,9 +55,9 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 5555
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 5555
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Next.js start 명령어 사용
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
# AI 기반 화면 자동 생성 시스템 설계서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목표
|
||||
|
||||
사용자가 자연어로 "자재입고페이지 만들어줘"라고 요청하면, AI가 자동으로 완성된 화면과 워크플로우를 생성
|
||||
|
||||
### 1.2 핵심 가치
|
||||
|
||||
- **제로 코딩**: 대화만으로 화면 생성
|
||||
- **워크플로우 포함**: 저장 시 재고 업데이트 등 자동 설정
|
||||
- **기존 시스템 활용**: 새 인프라 최소화, 기존 API 재사용
|
||||
|
||||
### 1.3 예상 시나리오
|
||||
|
||||
```
|
||||
사용자: "자재입고페이지 만들어줘"
|
||||
|
||||
AI: "입고관리 화면 41개를 분석했습니다.
|
||||
|
||||
✅ 자동 적용 (90%+ 사용)
|
||||
- 레이아웃: 분할화면 (좌: 목록, 우: 상세)
|
||||
- 필수 필드: 입고번호, 입고일자, 품목코드, 수량
|
||||
|
||||
💡 추천 기능 (60% 사용)
|
||||
- 저장 시 재고 자동 증가
|
||||
[추가하기]
|
||||
|
||||
➕ 선택 필드
|
||||
[ ] 창고 (68%) [ ] 공급업체 (61%)
|
||||
|
||||
[화면 생성하기]"
|
||||
|
||||
사용자: "재고 연동 추가하고, 창고 필드도 넣어줘"
|
||||
|
||||
AI: "화면을 생성했습니다.
|
||||
- 화면명: 자재 입고관리
|
||||
- 레이아웃: 분할화면
|
||||
- 필드: 입고번호, 입고일자, 품목, 수량, 창고
|
||||
- 워크플로우: 저장 시 inventory_stock +qty
|
||||
|
||||
[미리보기] [메뉴에 추가]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┬───────────────────────────────┐
|
||||
│ 신규 개발 (AI 담당) │ 기존 드래그앤드랍 시스템 │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ Chat UI │────▶│ AI 서비스 │ │ │ D&D UI Builder │ │
|
||||
│ └─────────────┘ │ • LLM 호출 (Claude/GPT) │ │ └─────────┬──────────┘ │
|
||||
│ │ • 패턴 분석 + RAG 검색 │ │ │ │
|
||||
│ │ • JSON 생성 │ │ ▼ │
|
||||
│ └──────────────┬──────────────┘ │ ┌─────────────────────┐ │
|
||||
└─────────────────────────────────────┼─────────────────────---──┴─┼─────────────────────┬─────┘
|
||||
│ 기존 API 호출 │ 기존 API 호출 │
|
||||
▼ ┼─────────────────────┘
|
||||
▼
|
||||
┌-------------------------------------------------------------------------------┐
|
||||
│ 기존 시스템 (vexplor) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Screen API │ │ Flow API │ │ Dataflow API│ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┴──────┐ │
|
||||
│ │ PostgreSQL │ │
|
||||
│ └─────────────┘ │
|
||||
└-------------------------------------------------------------------------------┘
|
||||
```
|
||||
|
||||
### 설계 원칙
|
||||
|
||||
| 원칙 | 설명 |
|
||||
| --------------------- | --------------------------------- |
|
||||
| 기존 코드 변경 최소화 | 기존 백엔드/프론트 수정 없음 |
|
||||
| 기존 API 재사용 | AI가 기존 API를 "사용자처럼" 호출 |
|
||||
| RAG 기반 지식 주입 | 필요한 패턴만 동적으로 LLM에 주입 |
|
||||
| **Assistive AI** | AI는 결정하지 않고, 사용자의 결정을 돕는다 |
|
||||
|
||||
---
|
||||
|
||||
## 3. AI가 화면을 "알아서" 만드는 방법
|
||||
|
||||
### 3.1 지식 소스
|
||||
|
||||
| 데이터 | 테이블 | 활용 |
|
||||
| --------------- | ---------------------------------- | ------------------- |
|
||||
| 기존 화면 | screen_definitions, screen_layouts | 패턴 학습 |
|
||||
| 테이블 라벨 | table_labels | 테이블 검색 |
|
||||
| 컬럼 라벨 | column_labels | 필드→컴포넌트 매핑 |
|
||||
| 워크플로우 패턴 | workflow_patterns (신규) | 비즈니스 로직 |
|
||||
|
||||
### 3.2 통계 기반 패턴 분석
|
||||
|
||||
> **핵심 아이디어**: 사용자들이 이미 만든 화면들을 분석해서 "입고 화면은 보통 이렇게 생겼더라"를 알아내는 것
|
||||
|
||||
#### 예시: "입고페이지 만들어줘" 요청 시
|
||||
|
||||
**Step 1. 테이블 찾기**
|
||||
|
||||
```sql
|
||||
-- "입고"라는 단어로 table_labels 검색
|
||||
SELECT table_name, table_label FROM table_labels
|
||||
WHERE table_label LIKE '%입고%';
|
||||
|
||||
-- 결과: inbound_mng (입고관리)
|
||||
```
|
||||
|
||||
**Step 2. 이 테이블을 쓰는 기존 화면 찾기**
|
||||
|
||||
```sql
|
||||
-- inbound_mng 테이블을 사용하는 화면들 조회
|
||||
SELECT * FROM screen_definitions
|
||||
WHERE table_name = 'inbound_mng';
|
||||
|
||||
-- 결과: 41개 화면 발견!
|
||||
```
|
||||
|
||||
**Step 3. 41개 화면의 "공통점" 분석**
|
||||
|
||||
레이아웃 통계:
|
||||
|
||||
| 레이아웃 | 개수 | 비율 |
|
||||
| -------------------------------- | ----- | -------- |
|
||||
| split-panel (좌: 목록, 우: 상세) | 32개 | **78%** |
|
||||
| table-list (목록만) | 6개 | 15% |
|
||||
| form (폼만) | 3개 | 7% |
|
||||
|
||||
필드 사용 통계:
|
||||
|
||||
| 필드 | 사용 화면 수 | 비율 |
|
||||
| ---------------------- | ------------ | -------- |
|
||||
| inbound_no (입고번호) | 41개 | **100%** |
|
||||
| inbound_date (입고일자)| 41개 | **100%** |
|
||||
| item_code (품목코드) | 40개 | **98%** |
|
||||
| qty (수량) | 39개 | **95%** |
|
||||
| warehouse_code (창고) | 28개 | 68% |
|
||||
| supplier_code (공급업체)| 25개 | 61% |
|
||||
|
||||
**Step 4. 확신도(Confidence)에 따라 다르게 처리**
|
||||
|
||||
AI는 통계 결과의 확신도에 따라 행동을 다르게 합니다:
|
||||
|
||||
| 확신도 | 기준 | AI 행동 | 예시 |
|
||||
| ------ | ---- | ------- | ---- |
|
||||
| **높음** | 90%+ | 자동 적용 | 필수 필드(입고번호, 일자) 자동 추가 |
|
||||
| **중간** | 60~90% | 추천하며 확인 | "분할화면으로 만들까요? (78% 사용)" |
|
||||
| **낮음** | 60% 미만 | 옵션 나열 | "창고 필드 추가할까요? (68%)" |
|
||||
|
||||
```
|
||||
AI 판단 예시:
|
||||
- 레이아웃: split-panel (78%) → "분할화면으로 생성합니다"
|
||||
- 필수 필드: 입고번호, 입고일자 (100%) → 자동 추가
|
||||
- 워크플로우: 입고→재고 (60%) → "💡 재고 자동 연동 추가할까요?"
|
||||
- 선택 필드: 창고 (68%) → "추가 필드: [ ] 창고 [ ] 공급업체"
|
||||
```
|
||||
|
||||
**핵심**: 확실한 것은 빠르게 처리하고, 애매한 것은 사용자에게 물어본다.
|
||||
|
||||
#### 비유: "맛집 추천 AI"와 같은 원리
|
||||
|
||||
| 맛집 추천 AI | 화면 생성 AI |
|
||||
| ------------ | ------------ |
|
||||
| "강남에서 점심 뭐 먹지?" | "입고페이지 만들어줘" |
|
||||
| 강남 식당 1000개 리뷰 분석 | 기존 입고 화면 41개 분석 |
|
||||
| "70%가 파스타집, 평균 1.5만원" | "78%가 분할화면, 100%가 입고번호 사용" |
|
||||
| "파스타집 추천드릴까요?" | "분할화면으로 만들까요?" |
|
||||
|
||||
### 3.3 RAG 기반 동적 지식 주입 (핵심)
|
||||
|
||||
> **문제**: 모든 도메인 지식을 프롬프트에 넣으면 Context Window 초과
|
||||
|
||||
> **해결**: 필요한 지식만 검색해서 동적 주입
|
||||
|
||||
```
|
||||
사용자: "입고페이지 만들어줘"
|
||||
↓
|
||||
1. 키워드 추출: "입고"
|
||||
↓
|
||||
2. workflow_patterns 검색 → "입고→재고 증가" 패턴 발견
|
||||
↓
|
||||
3. LLM 프롬프트에 해당 패턴만 주입
|
||||
↓
|
||||
4. 재고 증가 로직 포함된 화면 생성
|
||||
```
|
||||
|
||||
#### 장점
|
||||
|
||||
| 장점 | 설명 |
|
||||
| ------------- | ---------------------------------- |
|
||||
| 토큰 절약 | 관련 패턴 1-2개만 주입 |
|
||||
| 확장성 | 패턴 1000개여도 프롬프트 길이 동일 |
|
||||
| 회사별 커스텀 | company_code로 회사별 패턴 적용 |
|
||||
|
||||
### 3.4 멀티테넌트 & Fallback 전략
|
||||
|
||||
회사마다 테이블명이 다르거나, 신규 회사라 기존 화면이 없을 수 있습니다.
|
||||
|
||||
**데이터 검색 우선순위:**
|
||||
|
||||
```
|
||||
1순위: 해당 회사의 기존 화면 (가장 정확)
|
||||
↓ 없거나 부족하면
|
||||
2순위: 전체 회사의 익명화된 통계 (company_code 제외, 패턴만)
|
||||
↓ 그래도 부족하면
|
||||
3순위: vexplor 표준 템플릿 (기본 레이아웃 + 필수 필드)
|
||||
```
|
||||
|
||||
**여러 테이블이 검색될 때:**
|
||||
|
||||
```
|
||||
사용자: "입고페이지 만들어줘"
|
||||
|
||||
AI: "입고 관련 테이블이 3개 있습니다:
|
||||
1. 자재입고관리 (material_inbound)
|
||||
2. 제품입고관리 (product_inbound)
|
||||
3. 반품입고관리 (return_inbound)
|
||||
|
||||
어떤 테이블로 만들까요?"
|
||||
```
|
||||
|
||||
### 3.5 기존 시스템 분석 결과
|
||||
|
||||
**발견된 학습 가능 데이터:**
|
||||
|
||||
- `transferData` 액션: 14개 (발주→입고, 수주→출고 등)
|
||||
- 제어관리 프레임워크: `dataflowControlService.ts` 존재
|
||||
- 입고→재고 규칙: 아직 정의되지 않음 → AI가 생성하면 됨
|
||||
|
||||
---
|
||||
|
||||
## 4. 개발 범위
|
||||
|
||||
### 4.1 AI 담당 (신규)
|
||||
|
||||
| 작업 | 파일 | 우선순위 |
|
||||
| ------------------------------------ | ----------------------------- | ------------ |
|
||||
| AI 채팅 API | `aiRoutes.ts` | P0 |
|
||||
| 화면 패턴 분석 | `screenAnalyzer.ts` | P0 |
|
||||
| LLM 호출 | `llmService.ts` | P0 |
|
||||
| **워크플로우 패턴 검색 (RAG)** | `workflowPatternService.ts` | **P0** |
|
||||
| 채팅 UI | `AIChatPanel.tsx` | P0 |
|
||||
| **workflow_patterns 테이블** | DB | **P0** |
|
||||
|
||||
```typescript
|
||||
// 패턴 검색 핵심 로직
|
||||
export async function searchWorkflowPatterns(userIntent: string, companyCode: string) {
|
||||
const keywords = extractKeywords(userIntent);
|
||||
|
||||
return await query(`
|
||||
SELECT * FROM workflow_patterns
|
||||
WHERE intent_keywords && $1::text[]
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY priority DESC
|
||||
LIMIT 3
|
||||
`, [keywords, companyCode]);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 vexplor 담당 (기존 보완)
|
||||
|
||||
| 작업 | 현재 상태 | 필요 작업 |
|
||||
| -------------- | --------- | ------------------- |
|
||||
| 화면 생성 API | ✅ 존재 | 문서화 |
|
||||
| 워크플로우 API | ✅ 존재 | 문서화 |
|
||||
| 제어관리 API | ✅ 존재 | AI 활용 가능 |
|
||||
| table_labels | 부분 존재 | 주요 테이블 한글 라벨 |
|
||||
| column_labels | 부분 존재 | web_type 보완 |
|
||||
|
||||
#### 4.2.1 table_labels가 필요한 이유
|
||||
|
||||
AI가 "입고"라는 단어로 테이블을 찾으려면 한글 라벨이 있어야 합니다.
|
||||
|
||||
**현재 상태:**
|
||||
|
||||
| table_name | table_label | AI 검색 |
|
||||
| ---------- | ----------- | ------- |
|
||||
| inbound_mng | **입고관리** | ✅ "입고" 검색 가능 |
|
||||
| outbound_mng | **출고관리** | ✅ "출고" 검색 가능 |
|
||||
| production_record | production_record | ❌ "생산" 검색 불가 |
|
||||
| purchase_order_master | purchase_order_master | ❌ "발주" 검색 불가 |
|
||||
|
||||
**필요 작업**: 주요 업무 테이블에 한글 라벨 추가
|
||||
|
||||
```sql
|
||||
UPDATE table_labels SET table_label = '생산실적' WHERE table_name = 'production_record';
|
||||
UPDATE table_labels SET table_label = '발주관리' WHERE table_name = 'purchase_order_master';
|
||||
```
|
||||
|
||||
#### 4.2.2 column_labels의 web_type이 필요한 이유
|
||||
|
||||
AI가 컬럼을 보고 **어떤 컴포넌트를 생성할지** 결정해야 합니다.
|
||||
|
||||
**현재 상태** (inbound_mng):
|
||||
|
||||
| column_name | column_label | web_type |
|
||||
| ----------- | ------------ | -------- |
|
||||
| inbound_date | 입고일 | **null** |
|
||||
| inbound_qty | 입고수량 | **null** |
|
||||
| item_code | 품목코드 | **null** |
|
||||
|
||||
**web_type이 null이면?** → AI가 모든 필드를 text-input으로 만들어버림
|
||||
|
||||
**web_type이 있으면:**
|
||||
|
||||
| column_name | web_type | AI가 생성할 컴포넌트 |
|
||||
| ----------- | -------- | ------------------- |
|
||||
| inbound_date | **date** | 📅 날짜 선택기 |
|
||||
| inbound_qty | **number** | 🔢 숫자 입력 |
|
||||
| item_code | **entity** | 🔍 품목 검색 팝업 |
|
||||
| memo | **textarea** | 📝 여러 줄 텍스트 |
|
||||
|
||||
**필요 작업**: 주요 테이블 컬럼에 web_type 추가
|
||||
|
||||
```sql
|
||||
UPDATE column_labels SET web_type = 'date' WHERE column_name LIKE '%_date';
|
||||
UPDATE column_labels SET web_type = 'number' WHERE column_name LIKE '%_qty';
|
||||
UPDATE column_labels SET web_type = 'entity' WHERE column_name LIKE '%_code' AND column_name != 'company_code';
|
||||
UPDATE column_labels SET web_type = 'textarea' WHERE column_name = 'memo';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터베이스 스키마 (AI 전용)
|
||||
|
||||
```sql
|
||||
-- 핵심: 워크플로우 패턴 (RAG 지식 베이스)
|
||||
CREATE TABLE workflow_patterns (
|
||||
pattern_id SERIAL PRIMARY KEY,
|
||||
category VARCHAR(50) NOT NULL, -- 'inventory', 'sales'
|
||||
pattern_name VARCHAR(200) NOT NULL, -- '입고→재고 증가'
|
||||
intent_keywords TEXT[] NOT NULL, -- ['입고', '자재입고', 'inbound']
|
||||
description TEXT,
|
||||
source_table_hint VARCHAR(100), -- 'inbound_mng'
|
||||
target_table_hint VARCHAR(100), -- 'inventory_stock'
|
||||
logic_template JSONB NOT NULL, -- 제어관리 생성 템플릿
|
||||
company_code VARCHAR(20) DEFAULT '*',
|
||||
priority INTEGER DEFAULT 100,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_patterns_keywords ON workflow_patterns USING GIN(intent_keywords);
|
||||
|
||||
-- 초기 데이터
|
||||
INSERT INTO workflow_patterns (category, pattern_name, intent_keywords, description, source_table_hint, target_table_hint, logic_template) VALUES
|
||||
('inventory', '입고→재고 증가',
|
||||
ARRAY['입고', '구매입고', '자재입고', 'inbound'],
|
||||
'입고 저장 시 재고 수량 증가',
|
||||
'inbound_mng', 'inventory_stock',
|
||||
'{"actionType": "upsert", "operation": "increment", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "inbound_qty", "target": "qty", "type": "increment"}]}'::jsonb
|
||||
),
|
||||
('inventory', '출고→재고 감소',
|
||||
ARRAY['출고', '판매출고', 'outbound'],
|
||||
'출고 저장 시 재고 수량 감소',
|
||||
'outbound_mng', 'inventory_stock',
|
||||
'{"actionType": "update", "operation": "decrement", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "outbound_qty", "target": "qty", "type": "decrement"}]}'::jsonb
|
||||
);
|
||||
|
||||
-- 동의어 매핑 (P1)
|
||||
CREATE TABLE keyword_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
keyword VARCHAR(100) NOT NULL,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
company_code VARCHAR(20) DEFAULT '*'
|
||||
);
|
||||
|
||||
-- AI 대화 이력 (P2)
|
||||
CREATE TABLE ai_conversations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id VARCHAR(100) NOT NULL,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE ai_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER REFERENCES ai_conversations(id),
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 개발 로드맵
|
||||
|
||||
### Phase 1: MVP
|
||||
|
||||
**AI:**
|
||||
|
||||
- 채팅 UI + LLM 연동
|
||||
- 화면 패턴 분석
|
||||
- workflow_patterns 테이블 + RAG 검색
|
||||
|
||||
**vexplor:**
|
||||
|
||||
- API 스펙 문서화 (screen, flow)
|
||||
- 주요 테이블 한글 라벨 20개
|
||||
|
||||
### Phase 2: 워크플로우
|
||||
|
||||
**AI:**
|
||||
|
||||
- 자연어 → 워크플로우 변환
|
||||
- dataflow_diagrams 자동 생성
|
||||
|
||||
**vexplor:**
|
||||
|
||||
- 워크플로우 API 문서화
|
||||
|
||||
### Phase 3: 고도화
|
||||
|
||||
- 대화형 수정 ("왼쪽 패널 넓혀줘")
|
||||
- 멀티턴 컨텍스트
|
||||
- 사용자 피드백 학습
|
||||
|
||||
---
|
||||
|
||||
## 7. vexplor 체크리스트
|
||||
|
||||
### 즉시 (P0)
|
||||
|
||||
- [ ] POST /api/screen/create 스펙
|
||||
- [ ] POST /api/flow/definitions 스펙
|
||||
- [ ] 화면 레이아웃 JSON 예시
|
||||
|
||||
### 1주 내 (P1)
|
||||
|
||||
- [ ] 주요 테이블 20개 한글 라벨
|
||||
- inbound_mng, outbound_mng, inventory_stock
|
||||
- item_info, customer_mng, supplier_mng
|
||||
- sales_order_mng, purchase_order_mng
|
||||
- [ ] column_labels web_type 보완
|
||||
|
||||
---
|
||||
|
||||
## 8. 성공 지표
|
||||
|
||||
| 지표 | 목표 |
|
||||
| -------------------- | --------- |
|
||||
| 화면 생성 성공률 | 90%+ |
|
||||
| 평균 생성 시간 | 10초 이내 |
|
||||
| 수정 없이 사용 | 70%+ |
|
||||
| 워크플로우 자동 연결 | 80%+ |
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
# vexplor 쿠버네티스 자동 배포 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 vexplor 프로젝트를 Gitea Actions를 통해 쿠버네티스 클러스터에 자동 배포하는 방법을 설명합니다.
|
||||
|
||||
**작성일**: 2024년 12월 22일
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Repository │
|
||||
│ g.wace.me/chpark/vexplor │
|
||||
└─────────────────────┬───────────────────────────────────────────┘
|
||||
│ push to main
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Actions Runner │
|
||||
│ 1. Checkout code │
|
||||
│ 2. Build Docker images (frontend, backend) │
|
||||
│ 3. Push to Harbor Registry │
|
||||
│ 4. Deploy to Kubernetes │
|
||||
└─────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ Harbor Registry │ │ Kubernetes (K8s) │
|
||||
│ harbor.wace.me │ │ 112.168.212.142 │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Frontend │ │ Backend │ │ Ingress │
|
||||
│ :3000 │ │ :3001 │ │ Nginx │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
│ │ │
|
||||
└────────────────┴────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ External Access │
|
||||
│ v1.vexplor.com │
|
||||
│ api.vexplor.com │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
### 1. 쿠버네티스 클러스터
|
||||
|
||||
```bash
|
||||
# 서버 정보
|
||||
IP: 112.168.212.142
|
||||
SSH: ssh -p 22 wace@112.168.212.142
|
||||
K8s 버전: v1.28.15
|
||||
```
|
||||
|
||||
### 2. Harbor 레지스트리 접근 권한
|
||||
|
||||
Harbor에 `vexplor` 프로젝트가 생성되어 있어야 합니다.
|
||||
|
||||
### 3. Gitea Repository Secrets
|
||||
|
||||
Gitea 저장소에 다음 Secrets를 설정해야 합니다:
|
||||
|
||||
| Secret 이름 | 설명 |
|
||||
|------------|------|
|
||||
| `HARBOR_USERNAME` | Harbor 사용자명 |
|
||||
| `HARBOR_PASSWORD` | Harbor 비밀번호 |
|
||||
| `KUBECONFIG` | base64 인코딩된 Kubernetes config |
|
||||
|
||||
---
|
||||
|
||||
## 초기 설정
|
||||
|
||||
### 1단계: 쿠버네티스 클러스터 접속
|
||||
|
||||
```bash
|
||||
ssh -p 22 wace@112.168.212.142
|
||||
```
|
||||
|
||||
### 2단계: Nginx Ingress Controller 설치
|
||||
|
||||
```bash
|
||||
# Nginx Ingress Controller 설치 (baremetal용)
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.5/deploy/static/provider/baremetal/deploy.yaml
|
||||
|
||||
# 설치 확인
|
||||
kubectl get pods -n ingress-nginx
|
||||
kubectl get svc -n ingress-nginx
|
||||
```
|
||||
|
||||
### 3단계: Local Path Provisioner 설치 (PVC용)
|
||||
|
||||
```bash
|
||||
# Local Path Provisioner 설치
|
||||
kubectl apply -f k8s/local-path-provisioner.yaml
|
||||
|
||||
# 설치 확인
|
||||
kubectl get pods -n local-path-storage
|
||||
kubectl get storageclass
|
||||
```
|
||||
|
||||
### 4단계: Cert-Manager 설치 (SSL 인증서용)
|
||||
|
||||
```bash
|
||||
# Cert-Manager 설치
|
||||
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml
|
||||
|
||||
# 설치 확인
|
||||
kubectl get pods -n cert-manager
|
||||
|
||||
# ClusterIssuer 생성 (Let's Encrypt)
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@vexplor.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
EOF
|
||||
```
|
||||
|
||||
### 5단계: vexplor Secret 생성
|
||||
|
||||
```bash
|
||||
# Secret 템플릿을 복사하여 실제 값으로 수정
|
||||
cp k8s/vexplor-secret.yaml.template k8s/vexplor-secret.yaml
|
||||
|
||||
# 값 수정 후 적용
|
||||
kubectl apply -f k8s/vexplor-secret.yaml
|
||||
```
|
||||
|
||||
### 6단계: Gitea Secrets 설정
|
||||
|
||||
1. Gitea 저장소로 이동: https://g.wace.me/chpark/vexplor
|
||||
2. Settings > Secrets > Actions 메뉴로 이동
|
||||
3. 다음 Secrets 추가:
|
||||
|
||||
#### HARBOR_USERNAME
|
||||
Harbor 로그인 사용자명
|
||||
|
||||
#### HARBOR_PASSWORD
|
||||
Harbor 로그인 비밀번호
|
||||
|
||||
#### KUBECONFIG
|
||||
```bash
|
||||
# 쿠버네티스 서버에서 실행
|
||||
cat ~/.kube/config | base64 -w 0
|
||||
```
|
||||
출력된 값을 KUBECONFIG secret으로 등록
|
||||
|
||||
---
|
||||
|
||||
## 배포 트리거
|
||||
|
||||
### 자동 배포 (Push)
|
||||
|
||||
다음 경로의 파일이 변경되어 `main` 브랜치에 push되면 자동으로 배포됩니다:
|
||||
|
||||
- `backend-node/**`
|
||||
- `frontend/**`
|
||||
- `docker/**`
|
||||
- `k8s/**`
|
||||
- `.gitea/workflows/deploy.yml`
|
||||
|
||||
### 수동 배포
|
||||
|
||||
1. Gitea 저장소 > Actions 탭으로 이동
|
||||
2. "Deploy vexplor" 워크플로우 선택
|
||||
3. "Run workflow" 버튼 클릭
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
vexplor/
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── deploy.yml # Gitea Actions 워크플로우
|
||||
├── docker/
|
||||
│ └── deploy/
|
||||
│ ├── backend.Dockerfile # 백엔드 배포용 Dockerfile
|
||||
│ └── frontend.Dockerfile # 프론트엔드 배포용 Dockerfile
|
||||
├── k8s/
|
||||
│ ├── namespace.yaml # 네임스페이스 정의
|
||||
│ ├── vexplor-config.yaml # ConfigMap
|
||||
│ ├── vexplor-secret.yaml.template # Secret 템플릿
|
||||
│ ├── vexplor-backend-deployment.yaml # 백엔드 Deployment/Service/PVC
|
||||
│ ├── vexplor-frontend-deployment.yaml # 프론트엔드 Deployment/Service
|
||||
│ ├── vexplor-ingress.yaml # Ingress 설정
|
||||
│ ├── local-path-provisioner.yaml # 스토리지 프로비저너
|
||||
│ └── ingress-nginx.yaml # Ingress 컨트롤러 패치
|
||||
└── docs/
|
||||
└── KUBERNETES_DEPLOYMENT_GUIDE.md # 이 문서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 운영 명령어
|
||||
|
||||
### 상태 확인
|
||||
|
||||
```bash
|
||||
# 전체 리소스 확인
|
||||
kubectl get all -n vexplor
|
||||
|
||||
# Pod 상태 확인
|
||||
kubectl get pods -n vexplor -o wide
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -f deployment/vexplor-backend -n vexplor
|
||||
kubectl logs -f deployment/vexplor-frontend -n vexplor
|
||||
|
||||
# Pod 상세 정보
|
||||
kubectl describe pod <pod-name> -n vexplor
|
||||
```
|
||||
|
||||
### 수동 배포/롤백
|
||||
|
||||
```bash
|
||||
# 이미지 업데이트
|
||||
kubectl set image deployment/vexplor-backend \
|
||||
vexplor-backend=harbor.wace.me/vexplor/vexplor-backend:v20241222-120000-abc1234 \
|
||||
-n vexplor
|
||||
|
||||
# 롤아웃 상태 확인
|
||||
kubectl rollout status deployment/vexplor-backend -n vexplor
|
||||
|
||||
# 롤백
|
||||
kubectl rollout undo deployment/vexplor-backend -n vexplor
|
||||
kubectl rollout undo deployment/vexplor-frontend -n vexplor
|
||||
|
||||
# 히스토리 확인
|
||||
kubectl rollout history deployment/vexplor-backend -n vexplor
|
||||
```
|
||||
|
||||
### 스케일링
|
||||
|
||||
```bash
|
||||
# 레플리카 수 조정
|
||||
kubectl scale deployment/vexplor-backend --replicas=3 -n vexplor
|
||||
kubectl scale deployment/vexplor-frontend --replicas=3 -n vexplor
|
||||
```
|
||||
|
||||
### Pod 재시작
|
||||
|
||||
```bash
|
||||
# Deployment 재시작 (롤링 업데이트)
|
||||
kubectl rollout restart deployment/vexplor-backend -n vexplor
|
||||
kubectl rollout restart deployment/vexplor-frontend -n vexplor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### Pod이 Pending 상태일 때
|
||||
|
||||
```bash
|
||||
# Pod 이벤트 확인
|
||||
kubectl describe pod <pod-name> -n vexplor
|
||||
|
||||
# 노드 리소스 확인
|
||||
kubectl describe node
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
### ImagePullBackOff 오류
|
||||
|
||||
```bash
|
||||
# Harbor Secret 확인
|
||||
kubectl get secret harbor-registry -n vexplor -o yaml
|
||||
|
||||
# Secret 재생성
|
||||
kubectl delete secret harbor-registry -n vexplor
|
||||
kubectl create secret docker-registry harbor-registry \
|
||||
--docker-server=192.168.1.100:5001 \
|
||||
--docker-username=<username> \
|
||||
--docker-password=<password> \
|
||||
-n vexplor
|
||||
```
|
||||
|
||||
### Ingress가 작동하지 않을 때
|
||||
|
||||
```bash
|
||||
# Ingress 상태 확인
|
||||
kubectl get ingress -n vexplor
|
||||
kubectl describe ingress vexplor-ingress -n vexplor
|
||||
|
||||
# Ingress Controller 로그
|
||||
kubectl logs -f deployment/ingress-nginx-controller -n ingress-nginx
|
||||
```
|
||||
|
||||
### SSL 인증서 문제
|
||||
|
||||
```bash
|
||||
# Certificate 상태 확인
|
||||
kubectl get certificate -n vexplor
|
||||
kubectl describe certificate vexplor-tls -n vexplor
|
||||
|
||||
# Cert-Manager 로그
|
||||
kubectl logs -f deployment/cert-manager -n cert-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 네트워크 설정
|
||||
|
||||
### 방화벽 포트 개방
|
||||
|
||||
쿠버네티스 서버에서 다음 포트가 개방되어야 합니다:
|
||||
|
||||
| 포트 | 용도 |
|
||||
|-----|------|
|
||||
| 30080 | HTTP (Ingress NodePort) |
|
||||
| 30443 | HTTPS (Ingress NodePort) |
|
||||
| 6443 | Kubernetes API |
|
||||
|
||||
### DNS 설정
|
||||
|
||||
다음 도메인이 쿠버네티스 서버 IP를 가리키도록 설정:
|
||||
|
||||
- `v1.vexplor.com` → 112.168.212.142
|
||||
- `api.vexplor.com` → 112.168.212.142
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수
|
||||
|
||||
### Backend 환경 변수
|
||||
|
||||
| 변수 | 설명 | 소스 |
|
||||
|-----|------|-----|
|
||||
| `NODE_ENV` | 환경 (production) | ConfigMap |
|
||||
| `PORT` | 서버 포트 (3001) | ConfigMap |
|
||||
| `DATABASE_URL` | PostgreSQL 연결 문자열 | Secret |
|
||||
| `JWT_SECRET` | JWT 서명 키 | Secret |
|
||||
| `JWT_EXPIRES_IN` | JWT 만료 시간 | ConfigMap |
|
||||
| `CORS_ORIGIN` | CORS 허용 도메인 | ConfigMap |
|
||||
|
||||
### Frontend 환경 변수
|
||||
|
||||
| 변수 | 설명 | 소스 |
|
||||
|-----|------|-----|
|
||||
| `NODE_ENV` | 환경 (production) | ConfigMap |
|
||||
| `NEXT_PUBLIC_API_URL` | 클라이언트 API URL | ConfigMap |
|
||||
| `SERVER_API_URL` | SSR용 내부 API URL | Deployment |
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Kubernetes 공식 문서](https://kubernetes.io/docs/)
|
||||
- [Gitea Actions 문서](https://docs.gitea.com/usage/actions/overview)
|
||||
- [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
|
||||
- [Cert-Manager](https://cert-manager.io/docs/)
|
||||
- [Harbor Registry](https://goharbor.io/docs/)
|
||||
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const FLEET_API_URL = process.env.FLEET_API_URL || "https://fleet-api.vexplor.com";
|
||||
const SSO_SHARED_SECRET = process.env.SSO_SHARED_SECRET || "change_this_sso_secret";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// V1 로그인 세션에서 사용자 정보 추출
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get("session_token")?.value
|
||||
|| cookieStore.get("token")?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
// 세션에서 사용자 정보 파싱 (JWT 디코딩)
|
||||
let userId = "unknown";
|
||||
let userName = "unknown";
|
||||
let companyId = "";
|
||||
try {
|
||||
const payload = JSON.parse(atob(sessionToken.split(".")[1]));
|
||||
userId = payload.userId || payload.user_id || payload.sub || payload.id || "unknown";
|
||||
userName = payload.userName || payload.user_name || payload.name || "unknown";
|
||||
companyId = payload.companyId || payload.company_id || payload.companyCode || "";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "세션이 유효하지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fleet API로 SSO 토큰 요청 (HMAC 서명)
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signPayload = `${userId}|${userName}|${companyId}|${timestamp}`;
|
||||
const signature = crypto
|
||||
.createHmac("sha256", SSO_SHARED_SECRET)
|
||||
.update(signPayload)
|
||||
.digest("hex");
|
||||
|
||||
const response = await fetch(`${FLEET_API_URL}/api/auth/sso`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
user_name: userName,
|
||||
company_id: companyId,
|
||||
timestamp,
|
||||
signature,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
return NextResponse.json(
|
||||
{ error: data.message || "SSO 토큰 발급 실패" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ token: data.data.token });
|
||||
} catch (error) {
|
||||
console.error("[fleet-sso] 토큰 발급 에러:", error);
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token =
|
||||
cookieStore.get("authToken")?.value || cookieStore.get("session_token")?.value || cookieStore.get("token")?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ token });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Globe, Shield, Settings, ChevronDown, Info, Copy, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { V2WebViewConfig } from "@/lib/registry/components/v2-web-view/types";
|
||||
|
||||
interface V2WebViewConfigPanelProps {
|
||||
config: V2WebViewConfig;
|
||||
onChange: (config: Partial<V2WebViewConfig>) => void;
|
||||
}
|
||||
|
||||
const SSO_GUIDE_SNIPPET = `// URL에서 sso_token 파라미터를 읽어 JWT를 디코딩하세요
|
||||
const token = url.searchParams.get("sso_token");
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
// payload.userId, payload.userName, payload.companyCode`;
|
||||
|
||||
export const V2WebViewConfigPanel: React.FC<V2WebViewConfigPanelProps> = ({ config, onChange }) => {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopySnippet = () => {
|
||||
navigator.clipboard.writeText(SSO_GUIDE_SNIPPET).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const updateConfig = (field: keyof V2WebViewConfig, value: any) => {
|
||||
const newConfig = { ...config, [field]: value };
|
||||
onChange({ [field]: value });
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: newConfig },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ─── 1단계: URL 입력 ─── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-primary h-4 w-4" />
|
||||
<p className="text-sm font-medium">웹페이지 URL</p>
|
||||
</div>
|
||||
<Input
|
||||
value={config.url || ""}
|
||||
onChange={(e) => updateConfig("url", e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">임베드할 외부 웹페이지 주소를 입력하세요</p>
|
||||
</div>
|
||||
|
||||
{/* ─── 2단계: SSO 연동 ─── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-sm">SSO 연동</p>
|
||||
<p className="text-muted-foreground text-[11px]">현재 로그인 토큰을 URL에 자동 전달해요</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={config.useSSO || false} onCheckedChange={(checked) => updateConfig("useSSO", checked)} />
|
||||
</div>
|
||||
|
||||
{config.useSSO && (
|
||||
<Collapsible open={guideOpen} onOpenChange={setGuideOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-left transition-colors hover:bg-blue-100 dark:border-blue-900 dark:bg-blue-950 dark:hover:bg-blue-900"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="text-[11px] font-medium text-blue-700 dark:text-blue-300">연동 개발자 가이드</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 text-blue-400 transition-transform duration-200",
|
||||
guideOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-2.5 rounded-b-lg border border-t-0 border-blue-200 bg-blue-50/50 px-3 py-3 dark:border-blue-900 dark:bg-blue-950/50">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200">전달 방식</p>
|
||||
<p className="text-muted-foreground text-[10px]">URL 쿼리 파라미터로 JWT가 전달됩니다.</p>
|
||||
<code className="bg-muted/80 block rounded px-2 py-1 text-[10px]">?sso_token=eyJhbGciOi...</code>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200">JWT Payload 구조</p>
|
||||
<div className="bg-muted/80 rounded px-2 py-1.5 text-[10px] leading-relaxed">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">userId</span>
|
||||
<span>사용자 ID</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">userName</span>
|
||||
<span>사용자 이름</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">companyCode</span>
|
||||
<span>회사 코드</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">role</span>
|
||||
<span>권한</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200">수신측 예시 코드</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySnippet}
|
||||
className="flex items-center gap-1 text-[10px] text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "복사됨" : "복사"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-muted/80 overflow-x-auto rounded px-2 py-1.5 text-[10px] leading-relaxed whitespace-pre-wrap">
|
||||
{`const token = url.searchParams
|
||||
.get("sso_token");
|
||||
const payload = JSON.parse(
|
||||
atob(token.split(".")[1])
|
||||
);
|
||||
// payload.userId
|
||||
// payload.companyCode`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── 3단계: 표시 옵션 ─── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">테두리 표시</p>
|
||||
<p className="text-muted-foreground text-[11px]">웹 뷰 주변에 테두리를 표시해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showBorder !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showBorder", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">전체 화면 허용</p>
|
||||
<p className="text-muted-foreground text-[11px]">임베드된 페이지에서 전체 화면 전환이 가능해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.allowFullscreen || false}
|
||||
onCheckedChange={(checked) => updateConfig("allowFullscreen", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 transition-transform duration-200",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">샌드박스 모드</p>
|
||||
<p className="text-muted-foreground text-[11px]">보안을 위해 iframe 실행 환경을 제한해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.sandbox !== false}
|
||||
onCheckedChange={(checked) => updateConfig("sandbox", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">모서리 둥글기</span>
|
||||
<Input
|
||||
value={config.borderRadius || "8px"}
|
||||
onChange={(e) => updateConfig("borderRadius", e.target.value)}
|
||||
placeholder="8px"
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-muted-foreground text-xs">로딩 텍스트</span>
|
||||
<Input
|
||||
value={config.loadingText || ""}
|
||||
onChange={(e) => updateConfig("loadingText", e.target.value)}
|
||||
placeholder="로딩 중..."
|
||||
className="h-7 w-[140px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2WebViewConfigPanel.displayName = "V2WebViewConfigPanel";
|
||||
|
||||
export default V2WebViewConfigPanel;
|
||||
|
|
@ -120,6 +120,7 @@ import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
|
|||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||
import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록
|
||||
import "./v2-web-view/V2WebViewRenderer"; // 외부 웹페이지 임베딩 (SSO 지원)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { V2WebViewConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
export interface V2WebViewComponentProps extends ComponentRendererProps {}
|
||||
|
||||
export const V2WebViewComponent: React.FC<V2WebViewComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const config = (component.componentConfig || {}) as V2WebViewConfig;
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const baseUrl = config.url ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl) {
|
||||
setIframeSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.useSSO) {
|
||||
setIframeSrc(baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const paramName = "sso_token";
|
||||
|
||||
fetch("/api/system/raw-token")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data.token) {
|
||||
const separator = baseUrl.includes("?") ? "&" : "?";
|
||||
setIframeSrc(`${baseUrl}${separator}${encodeURIComponent(paramName)}=${encodeURIComponent(data.token)}`);
|
||||
} else {
|
||||
setError(data.error ?? "토큰을 가져올 수 없습니다");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("토큰 조회 실패");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseUrl, config.useSSO]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 400}px`,
|
||||
height: `${component.style?.height || 300}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : config.showBorder ? "1px solid #e0e0e0" : "none",
|
||||
borderRadius: config.borderRadius || "8px",
|
||||
overflow: "hidden",
|
||||
background: "#fafafa",
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
const domProps = filterDOMProps(props);
|
||||
|
||||
// 디자인 모드: URL 미리보기 표시
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div style={containerStyle} className="v2-web-view-component" onClick={handleClick} {...domProps}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
color: "#666",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span style={{ fontWeight: 500 }}>웹 뷰</span>
|
||||
{baseUrl ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#999",
|
||||
maxWidth: "90%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{baseUrl}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: "11px", color: "#bbb" }}>URL을 설정하세요</span>
|
||||
)}
|
||||
{config.useSSO && <span style={{ fontSize: "10px", color: "#4caf50" }}>SSO: ?sso_token=JWT</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 런타임 모드
|
||||
return (
|
||||
<div style={containerStyle} className="v2-web-view-component" {...domProps}>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
{config.loadingText || "로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#f44336",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && iframeSrc && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
style={{ width: "100%", height: "100%", border: "none" }}
|
||||
sandbox={config.sandbox ? "allow-scripts allow-same-origin allow-forms allow-popups" : undefined}
|
||||
allowFullScreen={config.allowFullscreen}
|
||||
title="Web View"
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && !iframeSrc && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#bbb",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
URL이 설정되지 않았습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const V2WebViewWrapper: React.FC<V2WebViewComponentProps> = (props) => {
|
||||
return <V2WebViewComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2WebViewDefinition } from "./index";
|
||||
import { V2WebViewComponent } from "./V2WebViewComponent";
|
||||
|
||||
export class V2WebViewRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2WebViewDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <V2WebViewComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
}
|
||||
|
||||
V2WebViewRenderer.registerSelf();
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { V2WebViewWrapper } from "./V2WebViewComponent";
|
||||
import { V2WebViewConfigPanel } from "@/components/v2/config-panels/V2WebViewConfigPanel";
|
||||
import type { V2WebViewConfig } from "./types";
|
||||
|
||||
export const V2WebViewDefinition = createComponentDefinition({
|
||||
id: "v2-web-view",
|
||||
name: "V2 웹 뷰",
|
||||
nameEng: "V2 WebView Component",
|
||||
description: "외부 웹페이지를 iframe으로 임베드하여 표시하는 컴포넌트 (SSO 지원)",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "custom",
|
||||
component: V2WebViewWrapper,
|
||||
defaultConfig: {
|
||||
url: "",
|
||||
useSSO: false,
|
||||
sandbox: true,
|
||||
allowFullscreen: false,
|
||||
showBorder: true,
|
||||
loadingText: "로딩 중...",
|
||||
} as V2WebViewConfig,
|
||||
defaultSize: { width: 600, height: 400 },
|
||||
configPanel: V2WebViewConfigPanel,
|
||||
icon: "Globe",
|
||||
tags: ["v2", "웹", "뷰", "iframe", "임베드", "외부", "SSO", "fleet"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { V2WebViewConfig } from "./types";
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
export interface V2WebViewConfig extends ComponentConfig {
|
||||
url?: string;
|
||||
useSSO?: boolean;
|
||||
sandbox?: boolean;
|
||||
allowFullscreen?: boolean;
|
||||
borderRadius?: string;
|
||||
showBorder?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
# Local Path Provisioner - 단일 노드 클러스터용 스토리지
|
||||
# Rancher의 Local Path Provisioner 사용
|
||||
# 참고: https://github.com/rancher/local-path-provisioner
|
||||
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: local-path-storage
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: local-path-provisioner-service-account
|
||||
namespace: local-path-storage
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: local-path-provisioner-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["nodes", "persistentvolumeclaims", "configmaps"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["endpoints", "persistentvolumes", "pods"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: local-path-provisioner-bind
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: local-path-provisioner-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: local-path-provisioner-service-account
|
||||
namespace: local-path-storage
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: local-path-provisioner
|
||||
namespace: local-path-storage
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: local-path-provisioner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: local-path-provisioner
|
||||
spec:
|
||||
serviceAccountName: local-path-provisioner-service-account
|
||||
containers:
|
||||
- name: local-path-provisioner
|
||||
image: rancher/local-path-provisioner:v0.0.26
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- local-path-provisioner
|
||||
- --debug
|
||||
- start
|
||||
- --config
|
||||
- /etc/config/config.json
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /etc/config/
|
||||
env:
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: local-path-config
|
||||
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: local-path
|
||||
annotations:
|
||||
storageclass.kubernetes.io/is-default-class: "true"
|
||||
provisioner: rancher.io/local-path
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
reclaimPolicy: Delete
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-path-config
|
||||
namespace: local-path-storage
|
||||
data:
|
||||
config.json: |-
|
||||
{
|
||||
"nodePathMap": [
|
||||
{
|
||||
"node": "DEFAULT_PATH_FOR_NON_LISTED_NODES",
|
||||
"paths": ["/opt/local-path-provisioner"]
|
||||
}
|
||||
]
|
||||
}
|
||||
setup: |-
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -m 0777 -p "$VOL_DIR"
|
||||
teardown: |-
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
rm -rf "$VOL_DIR"
|
||||
helperPod.yaml: |-
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: helper-pod
|
||||
spec:
|
||||
containers:
|
||||
- name: helper-pod
|
||||
image: busybox:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# vexplor 네임스페이스
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vexplor
|
||||
labels:
|
||||
name: vexplor
|
||||
project: vexplor
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# vexplor Backend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vexplor-backend
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor-backend
|
||||
component: backend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vexplor-backend
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vexplor-backend
|
||||
component: backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: harbor-registry
|
||||
containers:
|
||||
- name: vexplor-backend
|
||||
image: harbor.wace.me/vexplor/vexplor-backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: vexplor-config
|
||||
- secretRef:
|
||||
name: vexplor-secret
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3001"
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3001
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: vexplor-backend-uploads-pvc
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: vexplor-backend-data-pvc
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
|
||||
---
|
||||
# Backend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vexplor-backend-service
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor-backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: vexplor-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3001
|
||||
targetPort: 3001
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
# Backend PVC - Uploads
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vexplor-backend-uploads-pvc
|
||||
namespace: vexplor
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
storageClassName: local-path
|
||||
|
||||
---
|
||||
# Backend PVC - Data
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vexplor-backend-data-pvc
|
||||
namespace: vexplor
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
storageClassName: local-path
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# vexplor ConfigMap - 환경 설정
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: vexplor-config
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor
|
||||
data:
|
||||
# 공통 설정
|
||||
NODE_ENV: "production"
|
||||
TZ: "Asia/Seoul"
|
||||
|
||||
# Backend 설정
|
||||
BACKEND_PORT: "3001"
|
||||
BACKEND_HOST: "0.0.0.0"
|
||||
JWT_EXPIRES_IN: "24h"
|
||||
LOG_LEVEL: "info"
|
||||
CORS_CREDENTIALS: "true"
|
||||
|
||||
# Frontend 설정
|
||||
FRONTEND_PORT: "3000"
|
||||
FRONTEND_HOSTNAME: "0.0.0.0"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
# 내부 서비스 URL (클러스터 내부 통신)
|
||||
INTERNAL_BACKEND_URL: "http://vexplor-backend-service:3001"
|
||||
|
||||
# 외부 URL (클라이언트 접근용)
|
||||
NEXT_PUBLIC_API_URL: "https://api.vexplor.com/api"
|
||||
CORS_ORIGIN: "https://v1.vexplor.com,https://api.vexplor.com"
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# vexplor Frontend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vexplor-frontend
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor-frontend
|
||||
component: frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vexplor-frontend
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vexplor-frontend
|
||||
component: frontend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: harbor-registry
|
||||
containers:
|
||||
- name: vexplor-frontend
|
||||
image: harbor.wace.me/vexplor/vexplor-frontend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: vexplor-config
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "https://api.vexplor.com/api"
|
||||
# 서버사이드 렌더링시 내부 백엔드 호출용
|
||||
- name: SERVER_API_URL
|
||||
value: "http://vexplor-backend-service:3001"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
---
|
||||
# Frontend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vexplor-frontend-service
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor-frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: vexplor-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# vexplor Ingress - Nginx Ingress Controller 기반
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vexplor-ingress
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor
|
||||
annotations:
|
||||
# Nginx Ingress Controller 설정
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
|
||||
|
||||
# WebSocket 지원
|
||||
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
||||
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
|
||||
|
||||
# SSL Redirect
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
|
||||
# Cert-Manager (Let's Encrypt)
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- v1.vexplor.com
|
||||
- api.vexplor.com
|
||||
secretName: vexplor-tls
|
||||
rules:
|
||||
# Frontend 도메인
|
||||
- host: v1.vexplor.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vexplor-frontend-service
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
# Backend API 도메인
|
||||
- host: api.vexplor.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vexplor-backend-service
|
||||
port:
|
||||
number: 3001
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# vexplor Secret 템플릿
|
||||
# 이 파일은 템플릿입니다. 실제 값으로 채운 후 vexplor-secret.yaml로 저장하세요.
|
||||
# 주의: vexplor-secret.yaml은 .gitignore에 추가되어야 합니다!
|
||||
#
|
||||
# Secret 값은 base64로 인코딩해야 합니다:
|
||||
# echo -n "your-value" | base64
|
||||
#
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: vexplor-secret
|
||||
namespace: vexplor
|
||||
labels:
|
||||
app: vexplor
|
||||
type: Opaque
|
||||
data:
|
||||
# 데이터베이스 연결 정보 (base64 인코딩 필요)
|
||||
# echo -n "postgresql://postgres:password@211.115.91.141:11134/plm" | base64
|
||||
DATABASE_URL: "cG9zdGdyZXNxbDovL3Bvc3RncmVzOnZleHBsb3IwOTA5ISFAMjExLjExNS45MS4xNDE6MTExMzQvcGxt"
|
||||
|
||||
# JWT 시크릿
|
||||
# echo -n "your-jwt-secret" | base64
|
||||
JWT_SECRET: "aWxzaGluLXBsbS1zdXBlci1zZWNyZXQtand0LWtleS0yMDI0"
|
||||
|
||||
# 메일 암호화 키
|
||||
# echo -n "your-encryption-key" | base64
|
||||
ENCRYPTION_KEY: "aWxzaGluLXBsbS1tYWlsLWVuY3J5cHRpb24ta2V5LTMyY2hhcmFjdGVycy0yMDI0LXNlY3VyZQ=="
|
||||
|
||||
# API 키들
|
||||
# echo -n "your-kma-api-key" | base64
|
||||
KMA_API_KEY: "b2dkWHIyZTlUNGlIVjY5bnZWLUl3QQ=="
|
||||
|
||||
# echo -n "your-its-api-key" | base64
|
||||
ITS_API_KEY: "ZDZiOWJlZmVjMzExNGQ2NDgyODQ2NzRiOGZkZGNjMzI="
|
||||
|
||||
# echo -n "your-expressway-api-key" | base64
|
||||
EXPRESSWAY_API_KEY: ""
|
||||
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# 쿠버네티스 클러스터 구축 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
이 문서는 Digital Twin 프로젝트의 쿠버네티스 클러스터 구축 과정을 정리한 가이드입니다.
|
||||
|
||||
**작성일**: 2024년 12월 22일
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 서버 정보
|
||||
|
||||
### 기존 서버 (참조용)
|
||||
|
||||
| 항목 | 값 |
|
||||
| --------------- | ------------------ |
|
||||
| IP | 211.115.91.170 |
|
||||
| SSH 포트 | 12991 |
|
||||
| 사용자 | geonhee |
|
||||
| OS | Ubuntu 24.04.3 LTS |
|
||||
| K8s 버전 | v1.28.0 |
|
||||
| 컨테이너 런타임 | containerd 1.7.28 |
|
||||
|
||||
### 새 서버 (구축 완료)
|
||||
|
||||
| 항목 | 값 |
|
||||
| --------------- | ------------------ |
|
||||
| IP | 112.168.212.142 |
|
||||
| SSH 포트 | 22 |
|
||||
| 사용자 | wace |
|
||||
| 호스트명 | waceserver |
|
||||
| OS | Ubuntu 24.04.3 LTS |
|
||||
| K8s 버전 | v1.28.15 |
|
||||
| 컨테이너 런타임 | containerd 1.7.28 |
|
||||
| 내부 IP | 10.10.0.74 |
|
||||
| CPU | 20코어 |
|
||||
| 메모리 | 31GB |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SSH 접속 설정
|
||||
|
||||
### SSH 키 기반 인증 설정
|
||||
|
||||
```bash
|
||||
# 1. 로컬에서 SSH 키 확인
|
||||
ls -la ~/.ssh/
|
||||
|
||||
# 2. 공개키를 서버에 복사
|
||||
ssh-copy-id -p 12991 geonhee@211.115.91.170 # 기존 서버
|
||||
ssh-copy-id -p 22 wace@112.168.212.142 # 새 서버
|
||||
|
||||
# 3. 비밀번호 없이 접속 테스트
|
||||
ssh -p 12991 geonhee@211.115.91.170
|
||||
ssh -p 22 wace@112.168.212.142
|
||||
```
|
||||
|
||||
### SSH Config 설정 (선택사항)
|
||||
|
||||
```bash
|
||||
# ~/.ssh/config 파일에 추가
|
||||
Host wace-old
|
||||
HostName 211.115.91.170
|
||||
Port 12991
|
||||
User geonhee
|
||||
|
||||
Host wace-new
|
||||
HostName 112.168.212.142
|
||||
Port 22
|
||||
User wace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 쿠버네티스 클러스터 구축 과정
|
||||
|
||||
### 1단계: Swap 비활성화
|
||||
|
||||
쿠버네티스는 swap이 활성화되어 있으면 제대로 동작하지 않습니다.
|
||||
|
||||
```bash
|
||||
# swap 비활성화
|
||||
sudo swapoff -a
|
||||
|
||||
# 영구적으로 비활성화 (재부팅 후에도 유지)
|
||||
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
|
||||
|
||||
# 확인 (아무것도 출력되지 않으면 성공)
|
||||
swapon --show
|
||||
```
|
||||
|
||||
### 2단계: containerd 설정
|
||||
|
||||
```bash
|
||||
# containerd 기본 설정 생성
|
||||
sudo containerd config default | sudo tee /etc/containerd/config.toml
|
||||
|
||||
# SystemdCgroup 활성화 (중요!)
|
||||
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
|
||||
|
||||
# containerd 재시작
|
||||
sudo systemctl restart containerd
|
||||
|
||||
# 상태 확인
|
||||
sudo systemctl is-active containerd
|
||||
```
|
||||
|
||||
### 3단계: kubeadm init (클러스터 초기화)
|
||||
|
||||
```bash
|
||||
sudo kubeadm init --pod-network-cidr=10.244.0.0/16
|
||||
```
|
||||
|
||||
**출력 결과 (중요 정보)**:
|
||||
|
||||
- 클러스터 초기화 성공
|
||||
- API 서버: https://10.10.0.74:6443
|
||||
- 워커 노드 조인 토큰 생성됨
|
||||
|
||||
### 4단계: kubectl 설정
|
||||
|
||||
일반 사용자가 kubectl을 사용할 수 있도록 설정합니다.
|
||||
|
||||
```bash
|
||||
mkdir -p $HOME/.kube
|
||||
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
|
||||
sudo chown $(id -u):$(id -g) $HOME/.kube/config
|
||||
|
||||
# 확인
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
### 5단계: 네트워크 플러그인 설치 (Flannel)
|
||||
|
||||
Pod 간 통신을 위한 네트워크 플러그인을 설치합니다.
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
|
||||
```
|
||||
|
||||
### 6단계: 단일 노드 설정
|
||||
|
||||
마스터 노드에서도 워크로드를 실행할 수 있도록 taint를 제거합니다.
|
||||
|
||||
```bash
|
||||
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 구축 결과
|
||||
|
||||
### 클러스터 상태
|
||||
|
||||
```bash
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
| NAME | STATUS | ROLES | VERSION | INTERNAL-IP | OS-IMAGE | CONTAINER-RUNTIME |
|
||||
| ---------- | ------ | ------------- | -------- | ----------- | ------------------ | ------------------- |
|
||||
| waceserver | Ready | control-plane | v1.28.15 | 10.10.0.74 | Ubuntu 24.04.3 LTS | containerd://1.7.28 |
|
||||
|
||||
### 시스템 Pod 상태
|
||||
|
||||
```bash
|
||||
kubectl get pods -n kube-system
|
||||
kubectl get pods -n kube-flannel
|
||||
```
|
||||
|
||||
| 컴포넌트 | 상태 |
|
||||
| ----------------------- | ---------- |
|
||||
| etcd | ✅ Running |
|
||||
| kube-apiserver | ✅ Running |
|
||||
| kube-controller-manager | ✅ Running |
|
||||
| kube-scheduler | ✅ Running |
|
||||
| kube-proxy | ✅ Running |
|
||||
| coredns (x2) | ✅ Running |
|
||||
| kube-flannel | ✅ Running |
|
||||
|
||||
---
|
||||
|
||||
## 📌 워커 노드 추가 (필요 시)
|
||||
|
||||
다른 서버를 워커 노드로 추가하려면:
|
||||
|
||||
```bash
|
||||
kubeadm join 10.10.0.74:6443 --token 4lfga6.luad9f367uxh0rlq \
|
||||
--discovery-token-ca-cert-hash sha256:9bea59b6fd34115c3f893a4b10bacc0a5409192b288564dc055251210081c86e
|
||||
```
|
||||
|
||||
**토큰 만료 시 새 토큰 생성**:
|
||||
|
||||
```bash
|
||||
kubeadm token create --print-join-command
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 유용한 명령어
|
||||
|
||||
### 클러스터 정보 확인
|
||||
|
||||
```bash
|
||||
# 노드 상태
|
||||
kubectl get nodes -o wide
|
||||
|
||||
# 모든 Pod 상태
|
||||
kubectl get pods -A
|
||||
|
||||
# 클러스터 정보
|
||||
kubectl cluster-info
|
||||
|
||||
# 컴포넌트 상태
|
||||
kubectl get componentstatuses
|
||||
```
|
||||
|
||||
### 문제 해결
|
||||
|
||||
```bash
|
||||
# kubelet 로그 확인
|
||||
sudo journalctl -u kubelet -f
|
||||
|
||||
# containerd 로그 확인
|
||||
sudo journalctl -u containerd -f
|
||||
|
||||
# Pod 상세 정보
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
|
||||
# Pod 로그 확인
|
||||
kubectl logs <pod-name> -n <namespace>
|
||||
```
|
||||
|
||||
### 클러스터 리셋 (초기화 실패 시)
|
||||
|
||||
```bash
|
||||
sudo kubeadm reset
|
||||
sudo rm -rf /etc/cni/net.d
|
||||
sudo rm -rf $HOME/.kube
|
||||
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 다음 단계: 자동 배포 설정
|
||||
|
||||
쿠버네티스 클러스터 구축이 완료되었습니다. 다음 단계로 진행할 사항:
|
||||
|
||||
1. **Ingress Controller 설치** (외부 트래픽 라우팅) ✅ 완료
|
||||
2. **Cert-Manager 설치** (SSL 인증서 자동 관리)
|
||||
3. **Harbor/Registry 연동** (컨테이너 이미지 저장소)
|
||||
4. **CI/CD 파이프라인 구성** (Gitea Actions) ✅ 완료
|
||||
5. **Helm 설치** (패키지 관리)
|
||||
6. **애플리케이션 배포** (Deployment, Service, Ingress) ✅ 완료
|
||||
|
||||
### Gitea Actions 자동 배포 설정 완료
|
||||
|
||||
자세한 설정 방법은 [KUBERNETES_DEPLOYMENT_GUIDE.md](docs/KUBERNETES_DEPLOYMENT_GUIDE.md) 참조
|
||||
|
||||
#### 생성된 파일 목록
|
||||
|
||||
```
|
||||
.gitea/workflows/deploy.yml # Gitea Actions 워크플로우
|
||||
k8s/
|
||||
├── namespace.yaml # 네임스페이스 정의
|
||||
├── vexplor-config.yaml # ConfigMap
|
||||
├── vexplor-secret.yaml.template # Secret 템플릿
|
||||
├── vexplor-backend-deployment.yaml # 백엔드 Deployment/Service/PVC
|
||||
├── vexplor-frontend-deployment.yaml# 프론트엔드 Deployment/Service
|
||||
├── vexplor-ingress.yaml # Ingress 설정
|
||||
├── local-path-provisioner.yaml # 스토리지 프로비저너
|
||||
└── ingress-nginx.yaml # Ingress 컨트롤러 패치
|
||||
```
|
||||
|
||||
#### Gitea Repository Secrets 설정 필요
|
||||
|
||||
| Secret 이름 | 설명 |
|
||||
| ------------------- | --------------------------------- |
|
||||
| `HARBOR_USERNAME` | Harbor 사용자명 |
|
||||
| `HARBOR_PASSWORD` | Harbor 비밀번호 |
|
||||
| `KUBECONFIG` | base64 인코딩된 Kubernetes config |
|
||||
|
||||
```bash
|
||||
# KUBECONFIG 생성 방법 (K8s 서버에서 실행)
|
||||
cat ~/.kube/config | base64 -w 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 참고 정보
|
||||
|
||||
### 서버 접속
|
||||
|
||||
```bash
|
||||
# 새 서버 (쿠버네티스 클러스터)
|
||||
ssh -p 22 wace@112.168.212.142
|
||||
|
||||
# 기존 서버 (참조용)
|
||||
ssh -p 12991 geonhee@211.115.91.170
|
||||
```
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- [Kubernetes 공식 문서](https://kubernetes.io/docs/)
|
||||
- [kubeadm 설치 가이드](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/)
|
||||
- [Flannel 네트워크 플러그인](https://github.com/flannel-io/flannel)
|
||||
|
|
@ -92,8 +92,10 @@ echo "============================================"
|
|||
echo ""
|
||||
echo "📊 서비스 접속 정보:"
|
||||
echo " [DATABASE] PostgreSQL: http://39.117.244.52:11132"
|
||||
echo " [BACKEND] Spring Boot: http://localhost:8080/api"
|
||||
echo " [FRONTEND] Next.js: http://localhost:5555"
|
||||
echo " [BACKEND] API: https://api.vexplor.com"
|
||||
echo " [FRONTEND] Web: https://v1.vexplor.com"
|
||||
echo " [BACKEND LOCAL] http://localhost:3001/api"
|
||||
echo " [FRONTEND LOCAL] http://localhost:3000"
|
||||
echo ""
|
||||
echo "🔧 관리 명령어:"
|
||||
echo " 서비스 상태 확인:"
|
||||
|
|
@ -126,7 +128,7 @@ echo ""
|
|||
echo "백엔드 헬스체크..."
|
||||
backend_healthy=false
|
||||
for i in {1..12}; do
|
||||
if curl -s http://localhost:8080/health >/dev/null 2>&1; then
|
||||
if curl -s http://localhost:3001/health >/dev/null 2>&1; then
|
||||
echo " ✅ 백엔드 서비스 정상"
|
||||
backend_healthy=true
|
||||
break
|
||||
|
|
@ -149,14 +151,14 @@ if [ "$backend_healthy" = false ]; then
|
|||
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
|
||||
echo " 최근 로그:"
|
||||
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20
|
||||
echo " 포트 8080 사용 현황:"
|
||||
netstat -tln 2>/dev/null | grep ':8080' || echo " 포트 8080이 사용되지 않음"
|
||||
echo " 포트 3001 사용 현황:"
|
||||
netstat -tln 2>/dev/null | grep ':3001' || echo " 포트 3001이 사용되지 않음"
|
||||
fi
|
||||
|
||||
# 프론트엔드 헬스체크 (최대 30초 대기)
|
||||
echo "프론트엔드 헬스체크..."
|
||||
for i in {1..6}; do
|
||||
if curl -s http://localhost:5555 >/dev/null 2>&1; then
|
||||
if curl -s http://localhost:3000 >/dev/null 2>&1; then
|
||||
echo " ✅ 프론트엔드 서비스 정상"
|
||||
break
|
||||
else
|
||||
|
|
@ -166,7 +168,9 @@ for i in {1..6}; do
|
|||
done
|
||||
|
||||
echo ""
|
||||
echo "🎯 시작 완료! 브라우저에서 http://localhost:5555 을 확인하세요."
|
||||
echo "🎯 시작 완료!"
|
||||
echo " 브라우저에서 https://v1.vexplor.com 을 확인하세요."
|
||||
echo " (로컬: http://localhost:3000)"
|
||||
echo ""
|
||||
|
||||
read -p "계속하려면 Enter 키를 누르세요..."
|
||||
Loading…
Reference in New Issue