Compare commits
120 Commits
feat/train
...
904968a1be
| Author | SHA1 | Date | |
|---|---|---|---|
| 904968a1be | |||
| 4b44be6a29 | |||
| f9f0662f8e | |||
| 7416327cc3 | |||
| da31bd9d99 | |||
| f3e5347335 | |||
| 7d5581f60c | |||
| b4428217ea | |||
| 8a63fdacdd | |||
| cb2e42143a | |||
| 997e85c0cc | |||
| 731ca59475 | |||
| fe6d37456d | |||
| 6c98a48a5d | |||
| 81b69caa99 | |||
| 7fce070686 | |||
| 8d83505ee7 | |||
| 0ff38b24d4 | |||
| 5c082f7c9d | |||
| 43d0e55cb7 | |||
| df3bedfbda | |||
| c26a48d07d | |||
| 1c7c213977 | |||
| 6583a45abd | |||
| b15f77d894 | |||
| 3bcd99f0db | |||
| 5513cd60a0 | |||
| 7b35d26a13 | |||
| d92ff88ef7 | |||
| bfcddd0327 | |||
| e193330f99 | |||
| 6c0184597d | |||
| 1e8a8d8dad | |||
| eb7680b952 | |||
| 2c357ebf27 | |||
| 0daaa1c8cb | |||
| 96383595df | |||
| c5e03f7ca8 | |||
| df2acc4dfb | |||
| 693de354d2 | |||
| 62cdc5015e | |||
| 5c11263d55 | |||
| dd5031ae3a | |||
| c369e01ada | |||
| 54991622f1 | |||
| 17d69486ec | |||
| 948f1061da | |||
| 42438b3cd5 | |||
| 524ae200b0 | |||
| 4f763d3c2e | |||
| 9ebf525387 | |||
| 4ab672a96e | |||
| 7d2a367e3f | |||
| 67a67749c3 | |||
| 251307b5c9 | |||
| 8423a03d31 | |||
| d6cdf6b690 | |||
| 9cfa299e58 | |||
| a137e71420 | |||
| f08f80622f | |||
| e565fd7a34 | |||
| 8c45b39dcc | |||
| b119f333ac | |||
| bbe04ee458 | |||
| fab3c83a69 | |||
| fb87a0f32f | |||
| 63794ec4ec | |||
| bf6dc9740f | |||
| a23bc8dd67 | |||
| 13023a06cc | |||
| 28b50bd949 | |||
| 78ab928459 | |||
| ae3601cff5 | |||
| 5f62f4a209 | |||
| 29bf155b4f | |||
| da03f8b749 | |||
| 6a2deff93b | |||
| b0a99afcd3 | |||
| eedf72d7aa | |||
| 25e9941464 | |||
| a0da0392cf | |||
| a3ebee12b5 | |||
| c5b14ca09d | |||
| 44b3b857b1 | |||
| 63124455fd | |||
| e75ea8d8a5 | |||
| 31ac4209c3 | |||
| df09935789 | |||
| bb15b1b0f2 | |||
| f4d491ed94 | |||
| 96cb7d2f23 | |||
| cc6305b0df | |||
| 3916b13876 | |||
| ee4a06df30 | |||
| bb5ff7c3cd | |||
| 312a96dda1 | |||
| e38231e06d | |||
| bf6e45d706 | |||
| 7fa8921a25 | |||
| 7c940351d9 | |||
| 6b834da912 | |||
| 25aaa97d65 | |||
| da9d47ae4a | |||
| 7d6a77bf2a | |||
| 26828d0968 | |||
| e2dbae15c0 | |||
| b246034632 | |||
| 687ea82d78 | |||
| 4ac0f19908 | |||
| 9e5e7595eb | |||
| 9cd9274e99 | |||
| 5d82f3ecfe | |||
| 2ce249ab33 | |||
| e34bf68de0 | |||
| 862bda0cb9 | |||
| 90f7b17d07 | |||
| 2128baa46a | |||
| 875c30f467 | |||
| 2b29cd1ac6 | |||
| 9206fff5d0 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -72,3 +72,9 @@ docker-compose.override.yml
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
!/CLAUDE.md
|
!/CLAUDE.md
|
||||||
|
|
||||||
|
### SSL Certificates ###
|
||||||
|
nginx/ssl/
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|||||||
415
DEPLOY.md
Normal file
415
DEPLOY.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# KAMCO Training API 배포 가이드 (RedHat 9.6)
|
||||||
|
|
||||||
|
## 빠른 배포 (Quick Start)
|
||||||
|
|
||||||
|
이 문서는 RedHat 9.6 환경에서 HTTPS로 KAMCO Training API를 배포하는 방법을 설명합니다.
|
||||||
|
|
||||||
|
**접속 URL**:
|
||||||
|
- `https://api.train-kamco.com`
|
||||||
|
- `https://train-kamco.com`
|
||||||
|
|
||||||
|
## 사전 요구사항
|
||||||
|
|
||||||
|
- [x] Docker & Docker Compose 설치
|
||||||
|
- [x] Git 설치
|
||||||
|
- [x] sudo 권한
|
||||||
|
- [x] 포트 80, 443 사용 가능
|
||||||
|
|
||||||
|
## 1단계: /etc/hosts 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# root 권한으로 도메인 추가
|
||||||
|
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
|
||||||
|
|
||||||
|
# 확인
|
||||||
|
cat /etc/hosts | grep train-kamco
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
127.0.0.1 api.train-kamco.com train-kamco.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2단계: 방화벽 설정 (필요시)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 방화벽 상태 확인
|
||||||
|
sudo firewall-cmd --state
|
||||||
|
|
||||||
|
# HTTP/HTTPS 포트 개방
|
||||||
|
sudo firewall-cmd --permanent --add-port=80/tcp
|
||||||
|
sudo firewall-cmd --permanent --add-port=443/tcp
|
||||||
|
|
||||||
|
# 방화벽 재로드
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**: `80/tcp 443/tcp`
|
||||||
|
|
||||||
|
## 3단계: 프로젝트 디렉토리로 이동
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/kamco-train-api
|
||||||
|
|
||||||
|
# 현재 위치 확인
|
||||||
|
pwd
|
||||||
|
# 예상: /home/username/kamco-train-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4단계: 파일 구조 확인
|
||||||
|
|
||||||
|
배포 전 필수 파일이 모두 있는지 확인하세요:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSL 인증서 확인
|
||||||
|
ls -la nginx/ssl/
|
||||||
|
|
||||||
|
# 예상 결과:
|
||||||
|
# train-kamco.com.crt (인증서)
|
||||||
|
# train-kamco.com.key (개인 키)
|
||||||
|
# openssl.cnf (설정 파일)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Compose 파일 확인
|
||||||
|
ls -la docker-compose-prod.yml nginx/nginx.conf
|
||||||
|
|
||||||
|
# 예상: 두 파일 모두 존재
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5단계: Docker 네트워크 생성 (최초 1회)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kamco-cds 네트워크가 있는지 확인
|
||||||
|
docker network ls | grep kamco-cds
|
||||||
|
|
||||||
|
# 없으면 생성
|
||||||
|
docker network create kamco-cds
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6단계: Docker Compose 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기존 컨테이너 중지 (있는 경우)
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
|
||||||
|
# 새로운 이미지 빌드 및 실행
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
|
||||||
|
# 컨테이너 상태 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
NAME STATUS
|
||||||
|
kamco-cd-nginx Up (healthy)
|
||||||
|
kamco-cd-training-api Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7단계: 배포 확인
|
||||||
|
|
||||||
|
### 컨테이너 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nginx 로그
|
||||||
|
docker logs kamco-cd-nginx --tail 50
|
||||||
|
|
||||||
|
# API 로그
|
||||||
|
docker logs kamco-cd-training-api --tail 50
|
||||||
|
|
||||||
|
# 실시간 로그 (Ctrl+C로 종료)
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP → HTTPS 리다이렉트 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTP 접속 시 HTTPS로 리다이렉트되는지 확인
|
||||||
|
curl -I http://api.train-kamco.com
|
||||||
|
curl -I http://train-kamco.com
|
||||||
|
|
||||||
|
# 예상 결과: 301 Moved Permanently
|
||||||
|
# Location: https://api.train-kamco.com/ 또는 https://train-kamco.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPS 헬스체크
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# -k 플래그: 사설 인증서 경고 무시
|
||||||
|
curl -k https://api.train-kamco.com/monitor/health
|
||||||
|
curl -k https://train-kamco.com/monitor/health
|
||||||
|
|
||||||
|
# 예상 결과: {"status":"UP","components":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브라우저 테스트
|
||||||
|
|
||||||
|
브라우저에서 다음 URL에 접속:
|
||||||
|
|
||||||
|
- `https://api.train-kamco.com/monitor/health`
|
||||||
|
- `https://train-kamco.com/monitor/health`
|
||||||
|
|
||||||
|
**사설 인증서 경고**:
|
||||||
|
- "안전하지 않음" 경고가 표시되면 **"고급"** → **"계속 진행"** 클릭
|
||||||
|
|
||||||
|
## 8단계: SSL 인증서 확인 (선택사항)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증서 정보 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | head -30
|
||||||
|
|
||||||
|
# 유효 기간 확인 (100년)
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -dates
|
||||||
|
|
||||||
|
# SAN (멀티 도메인) 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A1 "Subject Alternative Name"
|
||||||
|
|
||||||
|
# 예상 결과:
|
||||||
|
# X509v3 Subject Alternative Name:
|
||||||
|
# DNS:api.train-kamco.com, DNS:train-kamco.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 문제 1: "Connection refused"
|
||||||
|
|
||||||
|
**원인**: 컨테이너가 실행되지 않음
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# 컨테이너 상태 확인
|
||||||
|
docker ps -a | grep kamco-cd
|
||||||
|
|
||||||
|
# 컨테이너 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker logs kamco-cd-nginx
|
||||||
|
docker logs kamco-cd-training-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 2: "502 Bad Gateway"
|
||||||
|
|
||||||
|
**원인**: Nginx는 실행 중이지만 API 컨테이너가 준비되지 않음
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# API 컨테이너 상태 확인
|
||||||
|
docker logs kamco-cd-training-api
|
||||||
|
|
||||||
|
# API 헬스체크 (컨테이너 내부에서)
|
||||||
|
docker exec kamco-cd-nginx wget -qO- http://kamco-changedetection-api:8080/monitor/health
|
||||||
|
|
||||||
|
# API 컨테이너 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart kamco-changedetection-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 3: "Name or service not known"
|
||||||
|
|
||||||
|
**원인**: /etc/hosts에 도메인이 설정되지 않음
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# /etc/hosts 확인
|
||||||
|
cat /etc/hosts | grep train-kamco
|
||||||
|
|
||||||
|
# 없으면 추가
|
||||||
|
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 4: 포트 80 또는 443이 이미 사용 중
|
||||||
|
|
||||||
|
**원인**: 다른 프로세스가 포트를 사용 중
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# 포트 사용 확인
|
||||||
|
sudo lsof -i :80
|
||||||
|
sudo lsof -i :443
|
||||||
|
|
||||||
|
# 사용 중인 프로세스 종료 (예: httpd, nginx)
|
||||||
|
sudo systemctl stop httpd
|
||||||
|
sudo systemctl stop nginx
|
||||||
|
|
||||||
|
# Docker Compose 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 5: SELinux 권한 오류
|
||||||
|
|
||||||
|
**원인**: SELinux가 Docker 볼륨 마운트를 차단
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```bash
|
||||||
|
# SELinux 상태 확인
|
||||||
|
getenforce
|
||||||
|
|
||||||
|
# Permissive 모드로 임시 변경 (재부팅 시 초기화됨)
|
||||||
|
sudo setenforce 0
|
||||||
|
|
||||||
|
# 영구 변경 (권장하지 않음)
|
||||||
|
sudo vi /etc/selinux/config
|
||||||
|
# SELINUX=permissive 또는 SELINUX=disabled로 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
## 컨테이너 관리 명령어
|
||||||
|
|
||||||
|
### 시작/중지/재시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 시작
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 중지
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
|
||||||
|
# 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
|
||||||
|
# 특정 서비스만 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart nginx
|
||||||
|
docker-compose -f docker-compose-prod.yml restart kamco-changedetection-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 로그
|
||||||
|
docker-compose -f docker-compose-prod.yml logs
|
||||||
|
|
||||||
|
# 특정 서비스 로그
|
||||||
|
docker-compose -f docker-compose-prod.yml logs nginx
|
||||||
|
docker-compose -f docker-compose-prod.yml logs kamco-changedetection-api
|
||||||
|
|
||||||
|
# 실시간 로그
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f
|
||||||
|
|
||||||
|
# 마지막 N줄만 보기
|
||||||
|
docker logs kamco-cd-nginx --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 상태 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 실행 중인 컨테이너
|
||||||
|
docker-compose -f docker-compose-prod.yml ps
|
||||||
|
|
||||||
|
# 상세 정보
|
||||||
|
docker inspect kamco-cd-nginx
|
||||||
|
docker inspect kamco-cd-training-api
|
||||||
|
|
||||||
|
# 리소스 사용량
|
||||||
|
docker stats kamco-cd-nginx kamco-cd-training-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 내부 접속
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nginx 컨테이너 내부 접속
|
||||||
|
docker exec -it kamco-cd-nginx sh
|
||||||
|
|
||||||
|
# API 컨테이너 내부 접속
|
||||||
|
docker exec -it kamco-cd-training-api sh
|
||||||
|
|
||||||
|
# 내부에서 빠져나오기
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 업데이트 및 재배포
|
||||||
|
|
||||||
|
### 코드 업데이트 후 재배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Git pull (코드 업데이트)
|
||||||
|
git pull origin develop
|
||||||
|
|
||||||
|
# 2. JAR 파일 빌드 (Jenkins에서 수행하는 경우 생략)
|
||||||
|
./gradlew clean build -x test
|
||||||
|
|
||||||
|
# 3. 컨테이너 재빌드 및 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
|
||||||
|
# 4. 로그 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설정 파일만 변경한 경우
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# nginx.conf 또는 docker-compose-prod.yml 변경 시
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 또는
|
||||||
|
docker-compose -f docker-compose-prod.yml restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모니터링
|
||||||
|
|
||||||
|
### 헬스체크 엔드포인트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API 헬스체크
|
||||||
|
curl -k https://api.train-kamco.com/monitor/health
|
||||||
|
|
||||||
|
# 예상 결과:
|
||||||
|
# {
|
||||||
|
# "status": "UP",
|
||||||
|
# "components": {
|
||||||
|
# "db": {"status": "UP"},
|
||||||
|
# "diskSpace": {"status": "UP"}
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 시스템 리소스
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 디스크 사용량
|
||||||
|
df -h
|
||||||
|
|
||||||
|
# 메모리 사용량
|
||||||
|
free -h
|
||||||
|
|
||||||
|
# Docker 이미지 및 컨테이너 용량
|
||||||
|
docker system df
|
||||||
|
```
|
||||||
|
|
||||||
|
## 보안 권장 사항
|
||||||
|
|
||||||
|
1. **사설 인증서**: 현재 사설 인증서를 사용 중입니다. 프로덕션 환경에서는 **Let's Encrypt** 또는 **GlobalSign** 같은 공인 인증서 사용을 권장합니다.
|
||||||
|
|
||||||
|
2. **방화벽**: 필요한 포트(80, 443)만 개방하고, 불필요한 포트는 차단하세요.
|
||||||
|
|
||||||
|
3. **정기 업데이트**: Docker 이미지와 시스템 패키지를 정기적으로 업데이트하세요.
|
||||||
|
|
||||||
|
4. **로그 모니터링**: 정기적으로 로그를 확인하여 비정상적인 활동을 감지하세요.
|
||||||
|
|
||||||
|
5. **백업**: SSL 인증서 키 파일(`train-kamco.com.key`)과 데이터베이스를 정기적으로 백업하세요.
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- **SSL 인증서 설정**: [nginx/SSL_SETUP.md](nginx/SSL_SETUP.md)
|
||||||
|
- **프로젝트 개요**: [README.md](README.md)
|
||||||
|
- **CLAUDE.md**: [CLAUDE.md](CLAUDE.md)
|
||||||
|
|
||||||
|
## 지원
|
||||||
|
|
||||||
|
문제가 발생하면 다음을 확인하세요:
|
||||||
|
1. 컨테이너 로그: `docker-compose -f docker-compose-prod.yml logs`
|
||||||
|
2. 컨테이너 상태: `docker-compose -f docker-compose-prod.yml ps`
|
||||||
|
3. /etc/hosts 설정: `cat /etc/hosts | grep train-kamco`
|
||||||
|
4. 방화벽 상태: `sudo firewall-cmd --list-ports`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**배포 완료!** 🎉
|
||||||
|
|
||||||
|
접속 URL:
|
||||||
|
- `https://api.train-kamco.com`
|
||||||
|
- `https://train-kamco.com`
|
||||||
443
DEPLOYMENT.md
Normal file
443
DEPLOYMENT.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# KAMCO Train API - Production Deployment Guide
|
||||||
|
|
||||||
|
프로덕션 환경 배포 가이드
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
- [사전 요구사항](#사전-요구사항)
|
||||||
|
- [초기 설정](#초기-설정)
|
||||||
|
- [배포 순서](#배포-순서)
|
||||||
|
- [개별 서비스 관리](#개별-서비스-관리)
|
||||||
|
- [롤백 절차](#롤백-절차)
|
||||||
|
- [모니터링 및 헬스체크](#모니터링-및-헬스체크)
|
||||||
|
- [트러블슈팅](#트러블슈팅)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사전 요구사항
|
||||||
|
|
||||||
|
### 시스템 요구사항
|
||||||
|
- Docker Engine 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- 최소 메모리: 4GB
|
||||||
|
- 디스크 공간: 20GB 이상
|
||||||
|
|
||||||
|
### 네트워크 요구사항
|
||||||
|
- 도메인 설정:
|
||||||
|
- `train-kamco.com` → 서버 IP (Web UI)
|
||||||
|
- `api.train-kamco.com` → 서버 IP (API)
|
||||||
|
- 포트:
|
||||||
|
- 80 (HTTP)
|
||||||
|
- 443 (HTTPS)
|
||||||
|
- 8080 (API - internal)
|
||||||
|
- 3002 (Web - internal)
|
||||||
|
|
||||||
|
### 필수 파일
|
||||||
|
- SSL 인증서:
|
||||||
|
- `nginx/ssl/train-kamco.com.crt`
|
||||||
|
- `nginx/ssl/train-kamco.com.key`
|
||||||
|
- 환경 설정:
|
||||||
|
- `application-prod.yml` (API 설정)
|
||||||
|
- `.env` 파일 (IMAGE_TAG 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 초기 설정
|
||||||
|
|
||||||
|
### 1. Docker 네트워크 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kamco-cds 네트워크 생성 (최초 1회만)
|
||||||
|
docker network create kamco-cds
|
||||||
|
|
||||||
|
# 네트워크 확인
|
||||||
|
docker network ls | grep kamco-cds
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SSL 인증서 배치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증서 디렉토리 생성
|
||||||
|
mkdir -p nginx/ssl
|
||||||
|
|
||||||
|
# 인증서 파일 복사
|
||||||
|
cp /path/to/train-kamco.com.crt nginx/ssl/
|
||||||
|
cp /path/to/train-kamco.com.key nginx/ssl/
|
||||||
|
|
||||||
|
# 권한 설정
|
||||||
|
chmod 600 nginx/ssl/train-kamco.com.key
|
||||||
|
chmod 644 nginx/ssl/train-kamco.com.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 환경 변수 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 파일 생성
|
||||||
|
cat > .env << EOF
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 볼륨 디렉토리 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 데이터 디렉토리 생성
|
||||||
|
mkdir -p ./app/model_output
|
||||||
|
mkdir -p ./app/train_dataset
|
||||||
|
|
||||||
|
# 권한 설정
|
||||||
|
chmod -R 755 ./app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포 순서
|
||||||
|
|
||||||
|
### 전체 스택 초기 배포
|
||||||
|
|
||||||
|
**중요**: 반드시 아래 순서대로 실행해야 합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Nginx 시작
|
||||||
|
docker-compose -f docker-compose-nginx.yml up -d
|
||||||
|
|
||||||
|
# 2. Nginx 상태 확인
|
||||||
|
docker ps | grep kamco-train-nginx
|
||||||
|
|
||||||
|
# 3. API 서비스 시작
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 4. API 헬스체크 대기 (최대 40초)
|
||||||
|
sleep 40
|
||||||
|
curl -f http://localhost:8080/monitor/health
|
||||||
|
|
||||||
|
# 5. Web 서비스 시작 (kamco-train-web 프로젝트에서)
|
||||||
|
# cd ../kamco-train-web
|
||||||
|
# docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 6. 전체 상태 확인
|
||||||
|
docker ps -a | grep kamco
|
||||||
|
```
|
||||||
|
|
||||||
|
### 배포 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서비스별 헬스체크
|
||||||
|
curl -f http://localhost:8080/monitor/health # API (internal)
|
||||||
|
curl -kf https://api.train-kamco.com/monitor/health # API (external)
|
||||||
|
curl -kf https://train-kamco.com # Web (external)
|
||||||
|
|
||||||
|
# Nginx 설정 검증
|
||||||
|
docker exec kamco-train-nginx nginx -t
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker-compose -f docker-compose-nginx.yml logs --tail=50
|
||||||
|
docker-compose -f docker-compose-prod.yml logs --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개별 서비스 관리
|
||||||
|
|
||||||
|
### Nginx 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 설정 변경 후 리로드 (다운타임 없음)
|
||||||
|
docker exec kamco-train-nginx nginx -s reload
|
||||||
|
|
||||||
|
# 재시작
|
||||||
|
docker-compose -f docker-compose-nginx.yml restart
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker-compose -f docker-compose-nginx.yml logs -f
|
||||||
|
|
||||||
|
# 컨테이너 내부 접근
|
||||||
|
docker exec -it kamco-train-nginx sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 서비스 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 재배포 (새 이미지 빌드)
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
|
||||||
|
# 재시작 (이미지 변경 없이)
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
|
||||||
|
# 중지
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f kamco-train-api
|
||||||
|
|
||||||
|
# 컨테이너 내부 접근
|
||||||
|
docker exec -it kamco-train-api bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web 서비스 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kamco-train-web 프로젝트에서 실행
|
||||||
|
cd ../kamco-train-web
|
||||||
|
|
||||||
|
# 재배포
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
|
||||||
|
# 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤백 절차
|
||||||
|
|
||||||
|
### 이미지 기반 롤백
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 사용 가능한 이미지 확인
|
||||||
|
docker images | grep kamco-train-api
|
||||||
|
|
||||||
|
# 2. 이전 이미지 태그로 롤백
|
||||||
|
export IMAGE_TAG=previous-commit-hash
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 3. 헬스체크 확인
|
||||||
|
curl -f http://localhost:8080/monitor/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git 기반 롤백
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 이전 커밋으로 체크아웃
|
||||||
|
git log --oneline -10
|
||||||
|
git checkout <previous-commit-hash>
|
||||||
|
|
||||||
|
# 2. 재빌드 및 배포
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
|
||||||
|
# 3. 검증 후 브랜치 업데이트 (필요시)
|
||||||
|
# git checkout develop
|
||||||
|
# git reset --hard <previous-commit-hash>
|
||||||
|
# git push -f origin develop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 모니터링 및 헬스체크
|
||||||
|
|
||||||
|
### 헬스체크 엔드포인트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API 헬스체크
|
||||||
|
curl http://localhost:8080/monitor/health
|
||||||
|
curl http://localhost:8080/monitor/health/readiness
|
||||||
|
curl http://localhost:8080/monitor/health/liveness
|
||||||
|
|
||||||
|
# Nginx를 통한 헬스체크
|
||||||
|
curl -k https://api.train-kamco.com/monitor/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컨테이너 상태 모니터링
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 모든 컨테이너 상태
|
||||||
|
docker ps -a | grep kamco
|
||||||
|
|
||||||
|
# 리소스 사용량 실시간 모니터링
|
||||||
|
docker stats kamco-train-nginx kamco-train-api
|
||||||
|
|
||||||
|
# 헬스체크 상태
|
||||||
|
docker inspect kamco-train-api | grep -A 10 Health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로그 모니터링
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 실시간 로그 (모든 서비스)
|
||||||
|
docker-compose -f docker-compose-nginx.yml logs -f &
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f &
|
||||||
|
|
||||||
|
# 에러 로그만 필터링
|
||||||
|
docker-compose -f docker-compose-prod.yml logs | grep -i error
|
||||||
|
|
||||||
|
# 최근 100줄
|
||||||
|
docker-compose -f docker-compose-prod.yml logs --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 1. Nginx 502 Bad Gateway
|
||||||
|
|
||||||
|
**원인**: API 서비스가 준비되지 않음
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API 컨테이너 상태 확인
|
||||||
|
docker ps | grep kamco-train-api
|
||||||
|
|
||||||
|
# API 로그 확인
|
||||||
|
docker logs kamco-train-api --tail=100
|
||||||
|
|
||||||
|
# 네트워크 연결 확인
|
||||||
|
docker network inspect kamco-cds | grep kamco-train-api
|
||||||
|
|
||||||
|
# 해결: API 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SSL 인증서 오류
|
||||||
|
|
||||||
|
**원인**: 인증서 파일 누락 또는 권한 문제
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증서 파일 확인
|
||||||
|
ls -la nginx/ssl/
|
||||||
|
|
||||||
|
# Nginx 설정 검증
|
||||||
|
docker exec kamco-train-nginx nginx -t
|
||||||
|
|
||||||
|
# 해결: 인증서 재배치 및 권한 설정
|
||||||
|
chmod 600 nginx/ssl/train-kamco.com.key
|
||||||
|
chmod 644 nginx/ssl/train-kamco.com.crt
|
||||||
|
docker-compose -f docker-compose-nginx.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 컨테이너 시작 실패
|
||||||
|
|
||||||
|
**원인**: 포트 충돌, 볼륨 권한, 메모리 부족
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 포트 사용 확인
|
||||||
|
netstat -tulpn | grep -E '80|443|8080'
|
||||||
|
|
||||||
|
# 볼륨 권한 확인
|
||||||
|
ls -la ./app/
|
||||||
|
|
||||||
|
# 메모리 사용량 확인
|
||||||
|
free -h
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# 해결: 충돌 프로세스 종료 또는 포트 변경
|
||||||
|
# 메모리 정리
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 네트워크 연결 문제
|
||||||
|
|
||||||
|
**원인**: kamco-cds 네트워크 미생성 또는 컨테이너 미연결
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 네트워크 확인
|
||||||
|
docker network ls | grep kamco-cds
|
||||||
|
|
||||||
|
# 네트워크 상세 정보
|
||||||
|
docker network inspect kamco-cds
|
||||||
|
|
||||||
|
# 해결: 네트워크 생성
|
||||||
|
docker network create kamco-cds
|
||||||
|
|
||||||
|
# 컨테이너를 네트워크에 연결
|
||||||
|
docker network connect kamco-cds kamco-train-api
|
||||||
|
docker network connect kamco-cds kamco-train-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 데이터베이스 연결 실패
|
||||||
|
|
||||||
|
**원인**: application-prod.yml의 DB 설정 오류
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API 로그에서 DB 연결 에러 확인
|
||||||
|
docker logs kamco-train-api | grep -i "connection"
|
||||||
|
|
||||||
|
# DB 호스트 연결 테스트
|
||||||
|
docker exec kamco-train-api ping <db-host>
|
||||||
|
|
||||||
|
# 해결: application-prod.yml 수정 후 재배포
|
||||||
|
vim src/main/resources/application-prod.yml
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jenkins CI/CD 연동
|
||||||
|
|
||||||
|
현재 프로젝트는 Jenkins 파이프라인으로 자동 배포됩니다.
|
||||||
|
|
||||||
|
### Jenkinsfile-dev 주요 단계
|
||||||
|
|
||||||
|
1. **Checkout**: develop 브랜치 체크아웃
|
||||||
|
2. **Build**: `./gradlew clean build -x test`
|
||||||
|
3. **Extract Commit**: IMAGE_TAG로 사용
|
||||||
|
4. **Transfer**: 배포 서버로 파일 전송
|
||||||
|
5. **Deploy**: Docker Compose 빌드 및 배포
|
||||||
|
6. **Health Check**: 30초 대기 후 헬스체크
|
||||||
|
7. **Cleanup**: 오래된 이미지 정리 (최신 5개 유지)
|
||||||
|
|
||||||
|
### 배포 서버 정보
|
||||||
|
|
||||||
|
- **서버**: 192.168.2.109
|
||||||
|
- **사용자**: space
|
||||||
|
- **배포 경로**: `/home/space/kamco-training-api`
|
||||||
|
- **헬스체크**: `http://localhost:7200/monitor/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 백업 및 복구
|
||||||
|
|
||||||
|
### 데이터 백업
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 볼륨 데이터 백업
|
||||||
|
tar -czf backup-$(date +%Y%m%d).tar.gz ./app/model_output ./app/train_dataset
|
||||||
|
|
||||||
|
# 설정 파일 백업
|
||||||
|
tar -czf config-backup-$(date +%Y%m%d).tar.gz \
|
||||||
|
nginx/nginx.conf \
|
||||||
|
nginx/ssl/ \
|
||||||
|
src/main/resources/application-prod.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이미지 백업
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 현재 이미지 저장
|
||||||
|
docker save kamco-train-api:latest | gzip > kamco-train-api-latest.tar.gz
|
||||||
|
|
||||||
|
# 이미지 복구
|
||||||
|
gunzip -c kamco-train-api-latest.tar.gz | docker load
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 보안 체크리스트
|
||||||
|
|
||||||
|
- [ ] SSL 인증서 유효기간 확인
|
||||||
|
- [ ] nginx/ssl/ 디렉토리 권한 600
|
||||||
|
- [ ] application-prod.yml에 DB 비밀번호 암호화
|
||||||
|
- [ ] JWT secret key 환경변수로 관리
|
||||||
|
- [ ] Docker 소켓 권한 최소화
|
||||||
|
- [ ] 방화벽 규칙 설정 (80, 443만 외부 노출)
|
||||||
|
- [ ] 정기 보안 업데이트 (docker images)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- [CLAUDE.md](./CLAUDE.md) - 프로젝트 개발 가이드
|
||||||
|
- [README.md](./README.md) - 프로젝트 개요
|
||||||
|
- [Jenkinsfile-dev](./Jenkinsfile-dev) - CI/CD 파이프라인
|
||||||
|
- [nginx/nginx.conf](./nginx/nginx.conf) - Nginx 설정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 연락처
|
||||||
|
|
||||||
|
문제 발생 시:
|
||||||
|
1. 로그 수집: `docker-compose logs` 출력
|
||||||
|
2. 시스템 정보: `docker ps -a`, `docker network ls`
|
||||||
|
3. 이슈 리포트: GitHub Issues 또는 내부 이슈 트래커
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Stage 1: Build stage (gradle build는 Jenkins에서 이미 수행)
|
||||||
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
|
|
||||||
|
# docker CLI 설치 (컨테이너에서 호스트 Docker 제어용) 260212 추가
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends docker.io ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 작업 디렉토리 설정
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# JAR 파일 복사 (Jenkins에서 빌드된 ROOT.jar)
|
||||||
|
COPY build/libs/ROOT.jar app.jar
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 애플리케이션 실행
|
||||||
|
# dev 프로파일로 실행
|
||||||
|
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]
|
||||||
28
docker-compose-nginx.yml
Normal file
28
docker-compose-nginx.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: kamco-train-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
- nginx-logs:/var/log/nginx
|
||||||
|
networks:
|
||||||
|
- kamco-cds
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "--no-check-certificate", "https://localhost/monitor/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kamco-cds:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nginx-logs:
|
||||||
|
driver: local
|
||||||
29
docker-compose-prod.yml
Normal file
29
docker-compose-prod.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
kamco-train-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: kamco-train-api:${IMAGE_TAG:-latest}
|
||||||
|
container_name: kamco-train-api
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
- SPRING_PROFILES_ACTIVE=prod
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
volumes:
|
||||||
|
- ./app/model_output:/app/model-outputs
|
||||||
|
- ./app/train_dataset:/app/train-dataset
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- kamco-cds
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/monitor/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kamco-cds:
|
||||||
|
external: true
|
||||||
414
nginx/SSL_SETUP.md
Normal file
414
nginx/SSL_SETUP.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# SSL 사설 인증서 설정 가이드 (RedHat 9.6)
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
이 문서는 RedHat 9.6 환경에서 `https://api.train-kamco.com` 및 `https://train-kamco.com` 도메인을 위한 100년 유효한 사설 SSL 인증서 설정 방법을 설명합니다.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
nginx/
|
||||||
|
├── nginx.conf # Nginx 설정 파일
|
||||||
|
├── ssl/
|
||||||
|
│ ├── openssl.cnf # OpenSSL 설정 파일 (SAN 포함)
|
||||||
|
│ ├── train-kamco.com.crt # 사설 SSL 인증서 (멀티 도메인)
|
||||||
|
│ └── train-kamco.com.key # 개인 키 (비공개)
|
||||||
|
└── SSL_SETUP.md # 이 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인증서 정보
|
||||||
|
|
||||||
|
- **도메인**: api.train-kamco.com, train-kamco.com (멀티 도메인)
|
||||||
|
- **유효 기간**: 100년 (36500일)
|
||||||
|
- **알고리즘**: RSA 4096-bit
|
||||||
|
- **CN (Common Name)**: api.train-kamco.com
|
||||||
|
- **SAN (Subject Alternative Names)**: api.train-kamco.com, train-kamco.com
|
||||||
|
|
||||||
|
## 사설 SSL 인증서 생성 (이미 생성됨)
|
||||||
|
|
||||||
|
인증서가 이미 생성되어 있습니다. 재생성이 필요한 경우 아래 단계를 따르세요.
|
||||||
|
|
||||||
|
### 1. OpenSSL 설정 파일 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/kamco-train-api
|
||||||
|
|
||||||
|
cat > nginx/ssl/openssl.cnf << 'EOF'
|
||||||
|
[req]
|
||||||
|
default_bits = 4096
|
||||||
|
prompt = no
|
||||||
|
default_md = sha256
|
||||||
|
distinguished_name = dn
|
||||||
|
req_extensions = v3_req
|
||||||
|
|
||||||
|
[dn]
|
||||||
|
C=KR
|
||||||
|
ST=Seoul
|
||||||
|
L=Seoul
|
||||||
|
O=KAMCO
|
||||||
|
OU=Training
|
||||||
|
CN=api.train-kamco.com
|
||||||
|
|
||||||
|
[v3_req]
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
DNS.1 = api.train-kamco.com
|
||||||
|
DNS.2 = train-kamco.com
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SSL 인증서 및 개인 키 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# nginx/ssl 디렉토리 생성 (없는 경우)
|
||||||
|
mkdir -p nginx/ssl
|
||||||
|
chmod 700 nginx/ssl
|
||||||
|
|
||||||
|
# 인증서 및 개인 키 생성 (100년 유효)
|
||||||
|
openssl req -new -x509 -newkey rsa:4096 -sha256 -nodes \
|
||||||
|
-keyout nginx/ssl/train-kamco.com.key \
|
||||||
|
-out nginx/ssl/train-kamco.com.crt \
|
||||||
|
-days 36500 \
|
||||||
|
-config nginx/ssl/openssl.cnf \
|
||||||
|
-extensions v3_req
|
||||||
|
|
||||||
|
# 파일 권한 설정
|
||||||
|
chmod 600 nginx/ssl/train-kamco.com.key
|
||||||
|
chmod 644 nginx/ssl/train-kamco.com.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 인증서 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증서 정보 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout
|
||||||
|
|
||||||
|
# 유효 기간 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A2 "Validity"
|
||||||
|
|
||||||
|
# SAN (멀티 도메인) 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -text -noout | grep -A1 "Subject Alternative Name"
|
||||||
|
|
||||||
|
# CN 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -subject
|
||||||
|
|
||||||
|
# 개인 키 확인
|
||||||
|
openssl rsa -in nginx/ssl/train-kamco.com.key -check
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
X509v3 Subject Alternative Name:
|
||||||
|
DNS:api.train-kamco.com, DNS:train-kamco.com
|
||||||
|
|
||||||
|
Validity
|
||||||
|
Not Before: Mar 2 23:39:XX 2026 GMT
|
||||||
|
Not After : Feb 6 23:39:XX 2126 GMT
|
||||||
|
```
|
||||||
|
|
||||||
|
## /etc/hosts 설정 (RedHat 9.6)
|
||||||
|
|
||||||
|
### 1. hosts 파일에 도메인 추가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# root 권한으로 실행
|
||||||
|
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
|
||||||
|
|
||||||
|
# 확인
|
||||||
|
cat /etc/hosts | grep train-kamco
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
127.0.0.1 api.train-kamco.com train-kamco.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 도메인 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ping 테스트
|
||||||
|
ping -c 2 api.train-kamco.com
|
||||||
|
ping -c 2 train-kamco.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose 배포
|
||||||
|
|
||||||
|
### 1. 기존 컨테이너 중지 (실행 중인 경우)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/kamco-train-api
|
||||||
|
docker-compose -f docker-compose-prod.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Production 환경 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IMAGE_TAG 환경 변수 설정 (선택사항)
|
||||||
|
export IMAGE_TAG=latest
|
||||||
|
|
||||||
|
# Docker Compose 실행
|
||||||
|
docker-compose -f docker-compose-prod.yml up -d
|
||||||
|
|
||||||
|
# 컨테이너 상태 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nginx 로그
|
||||||
|
docker logs kamco-cd-nginx
|
||||||
|
|
||||||
|
# API 로그
|
||||||
|
docker logs kamco-cd-training-api
|
||||||
|
|
||||||
|
# 실시간 로그 확인
|
||||||
|
docker-compose -f docker-compose-prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTPS 접속 테스트
|
||||||
|
|
||||||
|
### 1. HTTP → HTTPS 리다이렉트 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# api.train-kamco.com
|
||||||
|
curl -I http://api.train-kamco.com
|
||||||
|
|
||||||
|
# train-kamco.com
|
||||||
|
curl -I http://train-kamco.com
|
||||||
|
|
||||||
|
# 예상 결과: 301 Moved Permanently
|
||||||
|
# Location: https://api.train-kamco.com/ 또는 https://train-kamco.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. HTTPS 헬스체크 (-k: 사설 인증서 경고 무시)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# api.train-kamco.com
|
||||||
|
curl -k https://api.train-kamco.com/monitor/health
|
||||||
|
|
||||||
|
# train-kamco.com
|
||||||
|
curl -k https://train-kamco.com/monitor/health
|
||||||
|
|
||||||
|
# 예상 결과: {"status":"UP","components":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSL 인증서 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# api.train-kamco.com
|
||||||
|
openssl s_client -connect api.train-kamco.com:443 -showcerts
|
||||||
|
|
||||||
|
# train-kamco.com
|
||||||
|
openssl s_client -connect train-kamco.com:443 -showcerts
|
||||||
|
|
||||||
|
# CN 및 SAN 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 브라우저 테스트
|
||||||
|
|
||||||
|
브라우저에서 다음 URL에 접속:
|
||||||
|
|
||||||
|
- `https://api.train-kamco.com/monitor/health`
|
||||||
|
- `https://train-kamco.com/monitor/health`
|
||||||
|
|
||||||
|
**주의**: 사설 인증서이므로 "안전하지 않음" 경고가 표시됩니다.
|
||||||
|
- **Chrome/Edge**: "고급" → "계속 진행" 클릭
|
||||||
|
- **Firefox**: "위험 감수 및 계속" 클릭
|
||||||
|
|
||||||
|
## 브라우저에서 사설 인증서 신뢰 설정 (선택사항)
|
||||||
|
|
||||||
|
사설 인증서를 브라우저에 등록하면 경고 없이 접속 가능합니다.
|
||||||
|
|
||||||
|
### Chrome/Edge (RedHat Desktop)
|
||||||
|
|
||||||
|
1. `chrome://settings/certificates` 접속
|
||||||
|
2. **Authorities** 탭 선택
|
||||||
|
3. **Import** 클릭
|
||||||
|
4. `nginx/ssl/train-kamco.com.crt` 선택
|
||||||
|
5. **Trust this certificate for identifying websites** 체크
|
||||||
|
6. **OK** 클릭
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
1. `about:preferences#privacy` 접속
|
||||||
|
2. **Certificates** → **View Certificates** 클릭
|
||||||
|
3. **Authorities** 탭 선택
|
||||||
|
4. **Import** 클릭
|
||||||
|
5. `nginx/ssl/train-kamco.com.crt` 선택
|
||||||
|
6. **Trust this CA to identify websites** 체크
|
||||||
|
7. **OK** 클릭
|
||||||
|
|
||||||
|
## 방화벽 설정 (RedHat 9.6)
|
||||||
|
|
||||||
|
### 1. 방화벽 상태 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo firewall-cmd --state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. HTTP (80) 및 HTTPS (443) 포트 개방
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HTTP 포트 개방
|
||||||
|
sudo firewall-cmd --permanent --add-port=80/tcp
|
||||||
|
|
||||||
|
# HTTPS 포트 개방
|
||||||
|
sudo firewall-cmd --permanent --add-port=443/tcp
|
||||||
|
|
||||||
|
# 방화벽 재로드
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
80/tcp 443/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 보안 체크리스트
|
||||||
|
|
||||||
|
- [x] `train-kamco.com.key` 파일 권한이 600으로 설정됨
|
||||||
|
- [x] ssl 디렉토리가 버전 관리에서 제외됨 (.gitignore 확인)
|
||||||
|
- [x] 두 도메인(api.train-kamco.com, train-kamco.com) 모두 SAN에 포함됨
|
||||||
|
- [ ] 방화벽에서 80, 443 포트 개방 확인
|
||||||
|
- [x] HSTS 헤더 활성화 확인
|
||||||
|
- [x] TLS 1.2 이상만 허용 확인
|
||||||
|
- [ ] /etc/hosts에 도메인 매핑 확인
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 인증서 관련 오류
|
||||||
|
|
||||||
|
**"certificate verify failed"**
|
||||||
|
```bash
|
||||||
|
# 해결: -k 플래그 사용 (사설 인증서 경고 무시)
|
||||||
|
curl -k https://api.train-kamco.com/monitor/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**"NET::ERR_CERT_AUTHORITY_INVALID" (브라우저)**
|
||||||
|
- 정상 동작: 사설 인증서이므로 브라우저 경고는 예상된 동작입니다
|
||||||
|
- 해결: 브라우저에 인증서 등록 (위 "브라우저에서 사설 인증서 신뢰 설정" 참조)
|
||||||
|
|
||||||
|
### 연결 오류
|
||||||
|
|
||||||
|
**"Connection refused"**
|
||||||
|
```bash
|
||||||
|
# 컨테이너 상태 확인
|
||||||
|
docker ps | grep kamco-cd
|
||||||
|
|
||||||
|
# 포트 바인딩 확인
|
||||||
|
docker port kamco-cd-nginx
|
||||||
|
|
||||||
|
# 예상 결과:
|
||||||
|
# 80/tcp -> 0.0.0.0:80
|
||||||
|
# 443/tcp -> 0.0.0.0:443
|
||||||
|
```
|
||||||
|
|
||||||
|
**"502 Bad Gateway"**
|
||||||
|
```bash
|
||||||
|
# API 컨테이너 상태 확인
|
||||||
|
docker logs kamco-cd-training-api
|
||||||
|
|
||||||
|
# nginx → API 연결 확인
|
||||||
|
docker exec kamco-cd-nginx wget -qO- http://kamco-changedetection-api:8080/monitor/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Name or service not known" (도메인 해석 실패)**
|
||||||
|
```bash
|
||||||
|
# /etc/hosts 확인
|
||||||
|
cat /etc/hosts | grep train-kamco
|
||||||
|
|
||||||
|
# 없으면 추가
|
||||||
|
echo "127.0.0.1 api.train-kamco.com train-kamco.com" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방화벽 관련 오류
|
||||||
|
|
||||||
|
**외부에서 접속 안 됨**
|
||||||
|
```bash
|
||||||
|
# 방화벽 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
|
||||||
|
# 포트 개방
|
||||||
|
sudo firewall-cmd --permanent --add-port=80/tcp
|
||||||
|
sudo firewall-cmd --permanent --add-port=443/tcp
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인증서 만료 및 갱신
|
||||||
|
|
||||||
|
### 만료 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증서 만료일 확인
|
||||||
|
openssl x509 -in nginx/ssl/train-kamco.com.crt -noout -enddate
|
||||||
|
|
||||||
|
# 예상 결과: notAfter=Feb 6 23:39:XX 2126 GMT (100년 후)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 갱신 방법 (필요시)
|
||||||
|
|
||||||
|
100년 유효한 인증서이므로 갱신이 필요하지 않지만, 재생성이 필요한 경우:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기존 인증서 백업
|
||||||
|
cp nginx/ssl/train-kamco.com.crt nginx/ssl/train-kamco.com.crt.bak
|
||||||
|
cp nginx/ssl/train-kamco.com.key nginx/ssl/train-kamco.com.key.bak
|
||||||
|
|
||||||
|
# 위의 "사설 SSL 인증서 생성" 단계 재실행
|
||||||
|
|
||||||
|
# nginx 재시작
|
||||||
|
docker-compose -f docker-compose-prod.yml restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
1. **사설 인증서 경고**: 브라우저에서 "안전하지 않음" 경고가 표시됩니다. 프로덕션 환경에서는 **공인 인증서(Let's Encrypt, GlobalSign 등) 사용을 권장**합니다.
|
||||||
|
|
||||||
|
2. **포트 80/443**: Docker가 자동으로 처리하지만, 이미 사용 중인 프로세스가 있으면 충돌할 수 있습니다.
|
||||||
|
```bash
|
||||||
|
# 포트 사용 확인
|
||||||
|
sudo lsof -i :80
|
||||||
|
sudo lsof -i :443
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **대용량 파일 업로드**: `client_max_body_size`를 10GB로 설정했으므로, 서버 메모리 및 디스크 용량을 충분히 확보하세요.
|
||||||
|
|
||||||
|
4. **인증서 백업**: `train-kamco.com.key` 파일은 매우 중요합니다. 안전한 곳에 백업하세요.
|
||||||
|
|
||||||
|
5. **SELinux**: RedHat 9.6에서 SELinux가 활성화된 경우, Docker 볼륨 마운트 권한 문제가 발생할 수 있습니다.
|
||||||
|
```bash
|
||||||
|
# SELinux 상태 확인
|
||||||
|
getenforce
|
||||||
|
|
||||||
|
# 필요시 permissive 모드로 변경
|
||||||
|
sudo setenforce 0
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2단계: 시스템 신뢰 폴더로 복사
|
||||||
|
터미널을 열고 관리자 권한(sudo)을 사용해 인증서를 시스템 폴더로 복사합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo cp mycert.crt /etc/pki/ca-trust/source/anchors/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 시스템 신뢰 목록 업데이트
|
||||||
|
아래 명령어를 입력해 추가한 인증서를 시스템에 갱신시킵니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo update-ca-trust
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- [OpenSSL Documentation](https://www.openssl.org/docs/)
|
||||||
|
- [Nginx SSL Configuration](https://nginx.org/en/docs/http/configuring_https_servers.html)
|
||||||
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
|
- [Let's Encrypt (공인 인증서)](https://letsencrypt.org/)
|
||||||
179
nginx/nginx.conf
Normal file
179
nginx/nginx.conf
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# 로그 설정
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# 업로드 파일 크기 제한 (10GB)
|
||||||
|
client_max_body_size 10G;
|
||||||
|
|
||||||
|
# Upstream 설정
|
||||||
|
upstream api_backend {
|
||||||
|
server kamco-train-api:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream web_backend {
|
||||||
|
server kamco-train-web:3002;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP → HTTPS 리다이렉트 서버
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.train-kamco.com train-kamco.com;
|
||||||
|
|
||||||
|
# 모든 HTTP 요청을 HTTPS로 리다이렉트
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS 서버 설정
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name api.train-kamco.com;
|
||||||
|
|
||||||
|
# SSL 인증서 설정 (사설 인증서 - 멀티 도메인)
|
||||||
|
ssl_certificate /etc/nginx/ssl/train-kamco.com.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/train-kamco.com.key;
|
||||||
|
|
||||||
|
# SSL 프로토콜 및 암호화 설정
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# SSL 세션 캐시
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# HSTS (HTTP Strict Transport Security)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# 보안 헤더
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# 프록시 설정
|
||||||
|
location / {
|
||||||
|
proxy_pass http://api_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# 프록시 헤더 설정
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $server_name;
|
||||||
|
|
||||||
|
# 인증 헤더 및 쿠키 전달 (JWT 토큰 전달 보장)
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
|
||||||
|
# 타임아웃 설정 (대용량 파일 업로드 지원)
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
# 버퍼 설정
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 헬스체크 엔드포인트
|
||||||
|
location /monitor/health {
|
||||||
|
proxy_pass http://api_backend/monitor/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS 서버 설정 - Next.js Web Application
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name train-kamco.com;
|
||||||
|
|
||||||
|
# SSL 인증서 설정 (사설 인증서 - 멀티 도메인)
|
||||||
|
ssl_certificate /etc/nginx/ssl/train-kamco.com.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/train-kamco.com.key;
|
||||||
|
|
||||||
|
# SSL 프로토콜 및 암호화 설정
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# SSL 세션 캐시
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# HSTS (HTTP Strict Transport Security)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# 보안 헤더
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# API 프록시 설정 (Web에서 API 호출 시)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api_backend/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# 프록시 헤더 설정
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $server_name;
|
||||||
|
|
||||||
|
# 인증 헤더 및 쿠키 전달
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
|
||||||
|
# 타임아웃 설정
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 프록시 설정
|
||||||
|
location / {
|
||||||
|
proxy_pass http://web_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# 프록시 헤더 설정
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $server_name;
|
||||||
|
|
||||||
|
# Next.js WebSocket 지원을 위한 Upgrade 헤더
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# 타임아웃 설정
|
||||||
|
proxy_connect_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
|
||||||
|
# 버퍼 설정
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.kamco.cd.training.common.dto;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MonitorDto {
|
||||||
|
|
||||||
|
public int cpu; // 30초 평균 (%)
|
||||||
|
public String memory; // "3.2/16GB"
|
||||||
|
public List<Gpu> gpus = new ArrayList<>();
|
||||||
|
|
||||||
|
public static class Gpu {
|
||||||
|
public int index;
|
||||||
|
public int util;
|
||||||
|
|
||||||
|
public Gpu(int index, int util) {
|
||||||
|
this.index = index;
|
||||||
|
this.util = util;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.kamco.cd.training.common.service;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Log4j2
|
||||||
|
public class GpuDmonReader {
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// GPU 사용률 저장소
|
||||||
|
// - key: GPU index (0,1,2...)
|
||||||
|
// - value: 현재 GPU 사용률 (%)
|
||||||
|
// - ConcurrentHashMap → 멀티스레드 안전
|
||||||
|
// =========================
|
||||||
|
private final Map<Integer, Integer> gpuUtilMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// nvidia-smi dmon 프로세스
|
||||||
|
// - 스트리밍으로 GPU 상태를 계속 받아옴
|
||||||
|
// =========================
|
||||||
|
private volatile Process process;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 외부에서 GPU 사용률 조회
|
||||||
|
// =========================
|
||||||
|
public Map<Integer, Integer> getGpuUtilMap() {
|
||||||
|
return gpuUtilMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Bean 초기화 시 실행
|
||||||
|
// - 별도 스레드에서 dmon 실행
|
||||||
|
// - 메인 스레드 block 방지
|
||||||
|
// =========================
|
||||||
|
@PostConstruct
|
||||||
|
public void start() {
|
||||||
|
|
||||||
|
if (!isNvidiaAvailable()) {
|
||||||
|
log.warn("nvidia-smi not found. GPU monitoring disabled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread t = new Thread(this::runWithRestart, "gpu-dmon-thread");
|
||||||
|
t.setDaemon(true); // 서버 종료 시 같이 종료
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// dmon 실행 + 자동 재시작 루프
|
||||||
|
// - dmon이 죽어도 계속 재시작
|
||||||
|
// =========================
|
||||||
|
private void runWithRestart() {
|
||||||
|
boolean firstError = true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
log.info("Starting nvidia-smi dmon...");
|
||||||
|
runDmon();
|
||||||
|
firstError = true; // 정상 실행되면 초기화
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
|
||||||
|
if (firstError) {
|
||||||
|
log.error("nvidia-smi not available. GPU monitoring disabled.", e);
|
||||||
|
firstError = false;
|
||||||
|
} else {
|
||||||
|
log.warn("dmon retry...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(5000); // 5초 후에 시작
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// nvidia-smi dmon 실행 및 출력 파싱
|
||||||
|
// =========================
|
||||||
|
private void runDmon() throws Exception {
|
||||||
|
|
||||||
|
// GPU utilization만 출력 (-s u)
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("nvidia-smi", "dmon", "-s", "u");
|
||||||
|
|
||||||
|
process = pb.start();
|
||||||
|
|
||||||
|
// dmon은 stdout으로 계속 데이터를 뿌림 (스트리밍)
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
|
||||||
|
// 헤더 라인 제거 (# 으로 시작)
|
||||||
|
if (line.startsWith("#")) continue;
|
||||||
|
|
||||||
|
// 공백 기준 분리
|
||||||
|
String[] parts = line.trim().split("\\s+");
|
||||||
|
|
||||||
|
// 최소 index, util 있어야 함
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
// GPU index (0,1,2...)
|
||||||
|
int index = Integer.parseInt(parts[0]);
|
||||||
|
|
||||||
|
// GPU 사용률 (%)
|
||||||
|
int util = Integer.parseInt(parts[1]);
|
||||||
|
|
||||||
|
// 최신 값으로 덮어쓰기
|
||||||
|
gpuUtilMap.put(index, util);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여기 도달했다는 건 dmon 프로세스가 종료된 상태
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
log.warn("dmon exited. code={}", exitCode);
|
||||||
|
|
||||||
|
// 상위 루프에서 재시작하도록 예외 발생
|
||||||
|
throw new IllegalStateException("dmon stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// dmon 프로세스 살아있는지 확인
|
||||||
|
// =========================
|
||||||
|
public boolean isAlive() {
|
||||||
|
return process != null && process.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// dmon 강제 재시작
|
||||||
|
// - 기존 프로세스 종료 → runWithRestart에서 자동 재시작
|
||||||
|
// =========================
|
||||||
|
public void restart() {
|
||||||
|
try {
|
||||||
|
if (process != null && process.isAlive()) {
|
||||||
|
process.destroy();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNvidiaAvailable() {
|
||||||
|
try {
|
||||||
|
Process p = new ProcessBuilder("which", "nvidia-smi").start();
|
||||||
|
return p.waitFor() == 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.kamco.cd.training.common.service;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.common.dto.MonitorDto;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.FileReader;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Log4j2
|
||||||
|
public class SystemMonitorService {
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// CPU 이전값 (누적값 → delta 계산용)
|
||||||
|
// =========================
|
||||||
|
private long prevIdle = 0;
|
||||||
|
private long prevTotal = 0;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 최근 30초 히스토리
|
||||||
|
// - CPU: 30개 (1초 * 30)
|
||||||
|
// - GPU: GPU별 30개
|
||||||
|
// =========================
|
||||||
|
private final Deque<Double> cpuHistory = new ArrayDeque<>();
|
||||||
|
private final Map<Integer, Deque<Integer>> gpuHistory = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// GPU dmon reader (스트리밍)
|
||||||
|
// =========================
|
||||||
|
private final GpuDmonReader gpuReader;
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 캐시 (API 응답용)
|
||||||
|
// - 매 요청마다 계산하지 않기 위해 사용
|
||||||
|
// - volatile로 동시성 안전 보장
|
||||||
|
// =========================
|
||||||
|
private volatile MonitorDto cached = new MonitorDto();
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 1초마다 실행되는 수집 스케줄러
|
||||||
|
// =========================
|
||||||
|
@Scheduled(fixedRate = 1000)
|
||||||
|
public void collect() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 1. CPU 수집
|
||||||
|
// =====================
|
||||||
|
double cpu = readCpu();
|
||||||
|
|
||||||
|
cpuHistory.add(cpu);
|
||||||
|
|
||||||
|
// 30개 유지 (rolling window)
|
||||||
|
if (cpuHistory.size() > 30) cpuHistory.poll();
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 2. GPU (dmon 데이터 사용)
|
||||||
|
// =====================
|
||||||
|
Map<Integer, Integer> gpuMap = gpuReader.getGpuUtilMap();
|
||||||
|
|
||||||
|
for (Map.Entry<Integer, Integer> entry : gpuMap.entrySet()) {
|
||||||
|
int index = entry.getKey();
|
||||||
|
int util = entry.getValue();
|
||||||
|
|
||||||
|
gpuHistory.computeIfAbsent(index, k -> new ArrayDeque<>()).add(util);
|
||||||
|
|
||||||
|
Deque<Integer> q = gpuHistory.get(index);
|
||||||
|
if (q.size() > 30) q.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 3. 캐시 업데이트
|
||||||
|
// =====================
|
||||||
|
updateCache(gpuMap);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("collect error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// CPU 사용률 계산 (/proc/stat)
|
||||||
|
// =========================
|
||||||
|
private double readCpu() throws Exception {
|
||||||
|
|
||||||
|
// linux 환경일때만 실행
|
||||||
|
if (!isLinux()) {
|
||||||
|
return 0; // 또는 -1
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedReader br = new BufferedReader(new FileReader("/proc/stat"));
|
||||||
|
String line = br.readLine(); // "cpu ..." 라인
|
||||||
|
br.close();
|
||||||
|
|
||||||
|
String[] p = line.split("\\s+");
|
||||||
|
|
||||||
|
long user = Long.parseLong(p[1]);
|
||||||
|
long nice = Long.parseLong(p[2]);
|
||||||
|
long system = Long.parseLong(p[3]);
|
||||||
|
long idle = Long.parseLong(p[4]);
|
||||||
|
long iowait = Long.parseLong(p[5]);
|
||||||
|
long irq = Long.parseLong(p[6]);
|
||||||
|
long softirq = Long.parseLong(p[7]);
|
||||||
|
|
||||||
|
// 전체 시간 (누적)
|
||||||
|
long total = user + nice + system + idle + iowait + irq + softirq;
|
||||||
|
|
||||||
|
// idle 시간
|
||||||
|
long idleAll = idle + iowait;
|
||||||
|
|
||||||
|
// 최초 호출 (이전값 없음)
|
||||||
|
if (prevTotal == 0) {
|
||||||
|
prevTotal = total;
|
||||||
|
prevIdle = idleAll;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이전 대비 변화량
|
||||||
|
long totalDiff = total - prevTotal;
|
||||||
|
long idleDiff = idleAll - prevIdle;
|
||||||
|
|
||||||
|
// 상태 업데이트
|
||||||
|
prevTotal = total;
|
||||||
|
prevIdle = idleAll;
|
||||||
|
|
||||||
|
// CPU 사용률 계산
|
||||||
|
return (1.0 - (double) idleDiff / totalDiff) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLinux() {
|
||||||
|
return System.getProperty("os.name").toLowerCase().contains("linux");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Memory (/proc/meminfo)
|
||||||
|
// - 현재값 (평균 아님)
|
||||||
|
// =========================
|
||||||
|
private String readMemory() throws Exception {
|
||||||
|
|
||||||
|
// linux 환경일때만 실행
|
||||||
|
if (!isLinux()) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedReader br = new BufferedReader(new FileReader("/proc/meminfo"));
|
||||||
|
|
||||||
|
long total = 0;
|
||||||
|
long available = 0;
|
||||||
|
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
if (line.startsWith("MemTotal")) {
|
||||||
|
total = Long.parseLong(line.replaceAll("\\D+", ""));
|
||||||
|
} else if (line.startsWith("MemAvailable")) {
|
||||||
|
available = Long.parseLong(line.replaceAll("\\D+", ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
br.close();
|
||||||
|
|
||||||
|
long used = total - available;
|
||||||
|
|
||||||
|
// kB → GB
|
||||||
|
double usedGB = used / (1024.0 * 1024);
|
||||||
|
double totalGB = total / (1024.0 * 1024);
|
||||||
|
|
||||||
|
return String.format("%.1f/%.0fGB", usedGB, totalGB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 캐시 업데이트
|
||||||
|
// =========================
|
||||||
|
private void updateCache(Map<Integer, Integer> gpuMap) throws Exception {
|
||||||
|
|
||||||
|
MonitorDto dto = new MonitorDto();
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// CPU 평균 (30초)
|
||||||
|
// =====================
|
||||||
|
dto.cpu = (int) cpuHistory.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Memory (현재값)
|
||||||
|
// =====================
|
||||||
|
dto.memory = readMemory();
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// GPU 평균 (30초)
|
||||||
|
// =====================
|
||||||
|
for (Map.Entry<Integer, Integer> entry : gpuMap.entrySet()) {
|
||||||
|
|
||||||
|
int index = entry.getKey();
|
||||||
|
|
||||||
|
Deque<Integer> q = gpuHistory.get(index);
|
||||||
|
|
||||||
|
int avg = (int) (q == null ? 0 : q.stream().mapToInt(i -> i).average().orElse(0));
|
||||||
|
|
||||||
|
dto.gpus.add(new MonitorDto.Gpu(index, avg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 캐시 교체 (atomic)
|
||||||
|
// =====================
|
||||||
|
this.cached = dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 외부 조회 (Controller에서 호출)
|
||||||
|
// =========================
|
||||||
|
public MonitorDto get() {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,15 +104,19 @@ public class SecurityConfig {
|
|||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CORS 설정 */
|
/** CORS 설정 - application.yml에서 환경별로 관리 */
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성
|
CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성
|
||||||
|
|
||||||
|
// application.yml에서 환경별로 설정된 도메인 사용
|
||||||
config.setAllowedOriginPatterns(List.of("*")); // 도메인 허용
|
config.setAllowedOriginPatterns(List.of("*")); // 도메인 허용
|
||||||
|
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("*")); // 헤더요청 Authorization, Content-Type, X-Custom-Header
|
config.setAllowedHeaders(List.of("*")); // 헤더요청 Authorization, Content-Type, X-Custom-Header
|
||||||
config.setAllowCredentials(true); // 쿠키, Authorization 헤더, Bearer Token 등 자격증명 포함 요청을 허용할지 설정
|
config.setAllowCredentials(true); // 쿠키, Authorization 헤더, Bearer Token 등 자격증명 포함 요청을 허용할지 설정
|
||||||
config.setExposedHeaders(List.of("Content-Disposition"));
|
config.setExposedHeaders(List.of("Content-Disposition", "Authorization"));
|
||||||
|
config.setMaxAge(3600L); // Preflight 요청 캐시 (1시간)
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
/** "/**" → 모든 API 경로에 대해 이 CORS 규칙을 적용 /api/** 같이 특정 경로만 지정 가능. */
|
/** "/**" → 모든 API 경로에 대해 이 CORS 규칙을 적용 /api/** 같이 특정 경로만 지정 가능. */
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public class StartupLogger {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
╔════════════════════════════════════════════════════════════════════════════════╗
|
╔════════════════════════════════════════════════════════════════════════════════╗
|
||||||
║ 🚀 APPLICATION STARTUP INFORMATION ║
|
║ 🚀 APPLICATION STARTUP INFORMATION 2 ║
|
||||||
╠════════════════════════════════════════════════════════════════════════════════╣
|
╠════════════════════════════════════════════════════════════════════════════════╣
|
||||||
║ PROFILE CONFIGURATION ║
|
║ PROFILE CONFIGURATION ║
|
||||||
╠────────────────────────────────────────────────────────────────────────────────╣
|
╠────────────────────────────────────────────────────────────────────────────────╣
|
||||||
|
|||||||
@@ -398,24 +398,6 @@ public class DatasetService {
|
|||||||
return datasetCoreService.getFilePathByUUIDPathType(uuid, pathType);
|
return datasetCoreService.getFilePathByUUIDPathType(uuid, pathType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String readRemoteFileAsString(String remoteFilePath) {
|
|
||||||
|
|
||||||
String command = "cat " + escape(remoteFilePath);
|
|
||||||
|
|
||||||
List<String> lines = FIleChecker.execCommandAndReadLines(command);
|
|
||||||
|
|
||||||
return String.join("\n", lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonNode parseJson(String json) {
|
|
||||||
try {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
return mapper.readTree(json);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("JSON 파싱 실패", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String escape(String path) {
|
private String escape(String path) {
|
||||||
// 쉘 커맨드에서 안전하게 사용할 수 있도록 문자열을 작은따옴표로 감싸면서, 내부의 작은따옴표를 이스케이프 처리
|
// 쉘 커맨드에서 안전하게 사용할 수 있도록 문자열을 작은따옴표로 감싸면서, 내부의 작은따옴표를 이스케이프 처리
|
||||||
return "'" + path.replace("'", "'\"'\"'") + "'";
|
return "'" + path.replace("'", "'\"'\"'") + "'";
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.kamco.cd.training.model;
|
package com.kamco.cd.training.model;
|
||||||
|
|
||||||
|
import com.kamco.cd.training.common.dto.MonitorDto;
|
||||||
|
import com.kamco.cd.training.common.service.SystemMonitorService;
|
||||||
import com.kamco.cd.training.config.api.ApiResponseDto;
|
import com.kamco.cd.training.config.api.ApiResponseDto;
|
||||||
import com.kamco.cd.training.dataset.dto.DatasetDto;
|
import com.kamco.cd.training.dataset.dto.DatasetDto;
|
||||||
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
|
import com.kamco.cd.training.dataset.dto.DatasetDto.DatasetReq;
|
||||||
@@ -40,6 +42,7 @@ public class ModelTrainMngApiController {
|
|||||||
private final ModelTrainMngService modelTrainMngService;
|
private final ModelTrainMngService modelTrainMngService;
|
||||||
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
private final ModelTestMetricsJobService modelTestMetricsJobService;
|
private final ModelTestMetricsJobService modelTestMetricsJobService;
|
||||||
|
private final SystemMonitorService systemMonitorService;
|
||||||
|
|
||||||
@Operation(summary = "모델학습 목록 조회", description = "모델학습 목록 조회 API")
|
@Operation(summary = "모델학습 목록 조회", description = "모델학습 목록 조회 API")
|
||||||
@ApiResponses(
|
@ApiResponses(
|
||||||
@@ -214,4 +217,22 @@ public class ModelTrainMngApiController {
|
|||||||
modelTestMetricsJobService.findTestValidMetricCsvFiles();
|
modelTestMetricsJobService.findTestValidMetricCsvFiles();
|
||||||
return ApiResponseDto.ok(null);
|
return ApiResponseDto.ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "학습서버 시스템 사용율 조회", description = "cpu, gpu, memory 사용율 조회")
|
||||||
|
@ApiResponses(
|
||||||
|
value = {
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "검색 성공",
|
||||||
|
content =
|
||||||
|
@Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = Long.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
|
})
|
||||||
|
@GetMapping("/monitor")
|
||||||
|
public ApiResponseDto<MonitorDto> getSystem() throws IOException {
|
||||||
|
return ApiResponseDto.ok(systemMonitorService.get());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,44 @@ public class ModelTrainMngCoreService {
|
|||||||
master.setUpdatedDttm(ZonedDateTime.now());
|
master.setUpdatedDttm(ZonedDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step1 정지 처리
|
||||||
|
*
|
||||||
|
* @param modelId
|
||||||
|
* @param errorMessage
|
||||||
|
*/
|
||||||
|
public void markStep1Stop(Long modelId, String errorMessage) {
|
||||||
|
ModelMasterEntity master =
|
||||||
|
modelMngRepository
|
||||||
|
.findById(modelId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Model not found: " + modelId));
|
||||||
|
|
||||||
|
master.setStatusCd(TrainStatusType.STOPPED.getId());
|
||||||
|
master.setStep1State(TrainStatusType.STOPPED.getId());
|
||||||
|
master.setLastError(errorMessage);
|
||||||
|
master.setUpdatedUid(userUtil.getId());
|
||||||
|
master.setUpdatedDttm(ZonedDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step2 정지 처리
|
||||||
|
*
|
||||||
|
* @param modelId
|
||||||
|
* @param errorMessage
|
||||||
|
*/
|
||||||
|
public void markStep2Stop(Long modelId, String errorMessage) {
|
||||||
|
ModelMasterEntity master =
|
||||||
|
modelMngRepository
|
||||||
|
.findById(modelId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Model not found: " + modelId));
|
||||||
|
|
||||||
|
master.setStatusCd(TrainStatusType.STOPPED.getId());
|
||||||
|
master.setStep2State(TrainStatusType.STOPPED.getId());
|
||||||
|
master.setLastError(errorMessage);
|
||||||
|
master.setUpdatedUid(userUtil.getId());
|
||||||
|
master.setUpdatedDttm(ZonedDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void markSuccess(Long modelId) {
|
public void markSuccess(Long modelId) {
|
||||||
ModelMasterEntity master =
|
ModelMasterEntity master =
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
|
|
||||||
BooleanBuilder builder = new BooleanBuilder();
|
BooleanBuilder builder = new BooleanBuilder();
|
||||||
|
|
||||||
|
builder.and(dataset.deleted.isFalse());
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) {
|
if (StringUtils.isNotBlank(req.getDataType()) && !"CURRENT".equals(req.getDataType())) {
|
||||||
builder.and(dataset.dataType.eq(req.getDataType()));
|
builder.and(dataset.dataType.eq(req.getDataType()));
|
||||||
}
|
}
|
||||||
@@ -249,6 +251,7 @@ public class DatasetRepositoryImpl implements DatasetRepositoryCustom {
|
|||||||
public List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req) {
|
public List<SelectDataSet> getDatasetSelectG2G3List(DatasetReq req) {
|
||||||
|
|
||||||
BooleanBuilder builder = new BooleanBuilder();
|
BooleanBuilder builder = new BooleanBuilder();
|
||||||
|
builder.and(dataset.deleted.isFalse());
|
||||||
|
|
||||||
NumberExpression<Long> selectedCnt = null;
|
NumberExpression<Long> selectedCnt = null;
|
||||||
NumberExpression<Long> wasteCnt =
|
NumberExpression<Long> wasteCnt =
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ public class DataSetCountersService {
|
|||||||
log.info("missing = {}", missing.size());
|
log.info("missing = {}", missing.size());
|
||||||
log.info("extra = {}", extra.size());
|
log.info("extra = {}", extra.size());
|
||||||
|
|
||||||
|
log.info("[MISSING] total = {}", missing.size());
|
||||||
missing.stream().sorted().limit(50).forEach(f -> log.warn("[MISSING] {}", f));
|
missing.stream().sorted().limit(50).forEach(f -> log.warn("[MISSING] {}", f));
|
||||||
|
log.info("[EXTRA] total = {}", extra.size());
|
||||||
extra.stream().sorted().limit(50).forEach(f -> log.warn("[EXTRA] {}", f));
|
extra.stream().sorted().limit(50).forEach(f -> log.warn("[EXTRA] {}", f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,17 @@ public class DockerTrainService {
|
|||||||
@Value("${train.docker.shmSize:16g}")
|
@Value("${train.docker.shmSize:16g}")
|
||||||
private String shmSize;
|
private String shmSize;
|
||||||
|
|
||||||
|
// data 경로 request,response 상위 폴더
|
||||||
|
@Value("${train.docker.basePath}")
|
||||||
|
private String basePath;
|
||||||
|
|
||||||
// IPC host 사용 여부
|
// IPC host 사용 여부
|
||||||
@Value("${train.docker.ipcHost:true}")
|
@Value("${train.docker.ipcHost:true}")
|
||||||
private boolean ipcHost;
|
private boolean ipcHost;
|
||||||
|
|
||||||
|
@Value("${spring.profiles.active}")
|
||||||
|
private String profile;
|
||||||
|
|
||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,7 +261,9 @@ public class DockerTrainService {
|
|||||||
|
|
||||||
// 요청/결과 디렉토리 볼륨 마운트
|
// 요청/결과 디렉토리 볼륨 마운트
|
||||||
c.add("-v");
|
c.add("-v");
|
||||||
c.add("/home/kcomu/data" + "/tmp:/data");
|
c.add(basePath + ":" + basePath); // 심볼릭 링크와 연결되는 실제 파일 경로도 마운트를 해줘야 함
|
||||||
|
c.add("-v");
|
||||||
|
c.add(basePath + "/tmp:/data");
|
||||||
c.add("-v");
|
c.add("-v");
|
||||||
c.add(responseDir + ":/checkpoints");
|
c.add(responseDir + ":/checkpoints");
|
||||||
|
|
||||||
@@ -273,8 +282,15 @@ public class DockerTrainService {
|
|||||||
addArg(c, "--output-folder", req.getOutputFolder());
|
addArg(c, "--output-folder", req.getOutputFolder());
|
||||||
addArg(c, "--input-size", req.getInputSize());
|
addArg(c, "--input-size", req.getInputSize());
|
||||||
addArg(c, "--crop-size", req.getCropSize());
|
addArg(c, "--crop-size", req.getCropSize());
|
||||||
addArg(c, "--batch-size", req.getBatchSize());
|
// addArg(c, "--batch-size", req.getBatchSize());
|
||||||
addArg(c, "--gpu-ids", req.getGpuIds()); // null
|
// addArg(c, "--gpu-ids", req.getGpuIds()); // null
|
||||||
|
if ("prod".equals(profile)) {
|
||||||
|
addArg(c, "--batch-size", 2); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
||||||
|
addArg(c, "--gpus", "1"); // 학습서버 GPU 1개인 곳은 1이어야 함
|
||||||
|
addArg(c, "--gpu-ids", "0"); // 학습서버 GPU 1개인 곳은 0이어야 함
|
||||||
|
} else {
|
||||||
|
addArg(c, "--batch-size", req.getBatchSize()); // 학습서버 GPU 1개인 곳은 batch-size:2 까지만 가능
|
||||||
|
}
|
||||||
addArg(c, "--lr", req.getLearningRate());
|
addArg(c, "--lr", req.getLearningRate());
|
||||||
addArg(c, "--backbone", req.getBackbone());
|
addArg(c, "--backbone", req.getBackbone());
|
||||||
addArg(c, "--epochs", req.getEpochs());
|
addArg(c, "--epochs", req.getEpochs());
|
||||||
@@ -440,11 +456,14 @@ public class DockerTrainService {
|
|||||||
c.add("--rm");
|
c.add("--rm");
|
||||||
c.add("--gpus");
|
c.add("--gpus");
|
||||||
c.add("all");
|
c.add("all");
|
||||||
|
|
||||||
c.add("--ipc=host");
|
c.add("--ipc=host");
|
||||||
c.add("--shm-size=" + shmSize);
|
c.add("--shm-size=" + shmSize);
|
||||||
|
|
||||||
c.add("-v");
|
c.add("-v");
|
||||||
c.add("/home/kcomu/data" + "/tmp:/data");
|
c.add(basePath + ":" + basePath); // 심볼릭 링크와 연결되는 실제 파일 경로도 마운트를 해줘야 함
|
||||||
|
c.add("-v");
|
||||||
|
c.add(basePath + "/tmp:/data");
|
||||||
|
|
||||||
c.add("-v");
|
c.add("-v");
|
||||||
c.add(responseDir + ":/checkpoints");
|
c.add(responseDir + ":/checkpoints");
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import java.util.stream.Stream;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
|
|
||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
|
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
|
||||||
@@ -56,7 +59,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
* <p>@Transactional: - recover() 메서드 전체가 하나의 트랜잭션으로 감싸집니다. - Job 하나씩 처리하다가 예외가 발생하면 전체 롤백이 될 수
|
* <p>@Transactional: - recover() 메서드 전체가 하나의 트랜잭션으로 감싸집니다. - Job 하나씩 처리하다가 예외가 발생하면 전체 롤백이 될 수
|
||||||
* 있으므로 "잡 단위로 확실히 커밋"이 필요하면 (권장) 잡 단위로 분리 트랜잭션(REQUIRES_NEW) 고려하세요.
|
* 있으므로 "잡 단위로 확실히 커밋"이 필요하면 (권장) 잡 단위로 분리 트랜잭션(REQUIRES_NEW) 고려하세요.
|
||||||
*/
|
*/
|
||||||
// @EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void recover() {
|
public void recover() {
|
||||||
|
|
||||||
@@ -91,57 +94,92 @@ public class JobRecoveryOnStartupService {
|
|||||||
if (out.completed()) {
|
if (out.completed()) {
|
||||||
log.info("[RECOVERY] outputs look completed. mark SUCCESS. jobId={}", job.getId());
|
log.info("[RECOVERY] outputs look completed. mark SUCCESS. jobId={}", job.getId());
|
||||||
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
||||||
|
// model 상태 변경
|
||||||
markStepSuccessByJobType(job);
|
markStepSuccessByJobType(job);
|
||||||
|
// 결과 csv 파일 정보 등록
|
||||||
|
modelTrainMetricsJobService.findTrainValidMetricCsvFiles();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 3-3) 산출물이 부족하면 실패 처리(운영 정책에 따라 "유예"도 가능)
|
|
||||||
|
// 3-3) 산출물이 부족하면 중단처리
|
||||||
|
// 산출물이 부족하면 "중단/보류"로 처리
|
||||||
|
// 운영자가 재시작 할 수 있게 한다.
|
||||||
log.warn(
|
log.warn(
|
||||||
"[RECOVERY] outputs incomplete. mark FAILED. jobId={} reason={}",
|
"[RECOVERY] outputs incomplete. mark PAUSED/STOP for restart. jobId={} reason={}",
|
||||||
job.getId(),
|
job.getId(),
|
||||||
out.reason());
|
out.reason());
|
||||||
|
|
||||||
modelTrainJobCoreService.markFailed(
|
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
||||||
job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
|
||||||
|
|
||||||
markStepErrorByJobType(job, out.reason());
|
// PAUSED/STOP
|
||||||
|
modelTrainJobCoreService.markPaused(
|
||||||
|
job.getId(), modelId, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
||||||
|
|
||||||
|
// 모델도 에러가 아니라 STOP으로
|
||||||
|
markStepStopByJobType(
|
||||||
|
job, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: " + out.reason());
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 컨테이너는 존재하고, 아직 running=true
|
// 4) 컨테이너는 존재하고, 아직 running=true
|
||||||
// - 서버만 재기동됐고 컨테이너는 그대로 살아있는 케이스
|
// - 서버만 재기동됐고 컨테이너는 그대로 살아있는 케이스
|
||||||
// - 이 경우 DB를 건드리면 오히려 꼬일 수 있으니 RUNNING 유지
|
// - 실행중 docker 를 kill 하고 이어하기를 한다,
|
||||||
if (state.running()) {
|
if (state.running()) {
|
||||||
log.info("[RECOVERY] container still running. container={}", containerName);
|
log.warn("[RECOVERY] container still running. force kill. container={}", containerName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ProcessBuilder pb = new ProcessBuilder("docker", "stop", "-t", "20", containerName);
|
// ============================================================
|
||||||
|
// 1) docker kill (SIGKILL) 바로 전송
|
||||||
|
// - kill은 즉시 종료
|
||||||
|
// ============================================================
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("docker", "kill", containerName);
|
||||||
pb.redirectErrorStream(true);
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
Process p = pb.start();
|
Process p = pb.start();
|
||||||
|
|
||||||
boolean finished = p.waitFor(30, TimeUnit.SECONDS);
|
boolean finished = p.waitFor(20, TimeUnit.SECONDS);
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
p.destroyForcibly();
|
p.destroyForcibly();
|
||||||
throw new IOException("docker stop timeout");
|
throw new IOException("docker kill timeout");
|
||||||
}
|
}
|
||||||
|
|
||||||
int code = p.exitValue();
|
int code = p.exitValue();
|
||||||
if (code != 0) {
|
if (code != 0) {
|
||||||
throw new IOException("docker stop failed. exit=" + code);
|
throw new IOException("docker kill failed. exit=" + code);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
// ============================================================
|
||||||
"[RECOVERY] container stopped (will be auto removed by --rm). container={}",
|
// 2) kill 후 실제로 죽었는지 확인
|
||||||
containerName);
|
// ============================================================
|
||||||
|
DockerInspectState after = inspectContainer(containerName);
|
||||||
|
if (after.exists() && after.running()) {
|
||||||
|
throw new IOException("docker kill returned 0 but container still running");
|
||||||
|
}
|
||||||
|
|
||||||
// 여기서 상태를 PAUSED로 바꿔도 되고
|
log.info("[RECOVERY] container killed successfully. container={}", containerName);
|
||||||
modelTrainJobCoreService.markPaused(job.getId(), -1, "AUTO_STOP_FAILED_ON_RESTART");
|
|
||||||
|
// ============================================================
|
||||||
|
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
|
||||||
|
// ============================================================
|
||||||
|
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
||||||
|
|
||||||
|
modelTrainJobCoreService.markPaused(
|
||||||
|
job.getId(), modelId, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
|
|
||||||
|
log.info("job = {}", job);
|
||||||
|
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[RECOVERY] docker stop failed. container={}", containerName, e);
|
|
||||||
|
|
||||||
modelTrainJobCoreService.markFailed(job.getId(), -1, "AUTO_STOP_FAILED_ON_RESTART");
|
log.error("[RECOVERY] docker kill failed. container={}", containerName, e);
|
||||||
|
|
||||||
|
modelTrainJobCoreService.markFailed(
|
||||||
|
job.getId(), -1, "AUTO_KILL_FAILED_ON_SERVER_RESTART");
|
||||||
|
|
||||||
|
markStepErrorByJobType(job, "AUTO_KILL_FAILED_ON_SERVER_RESTART");
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +192,10 @@ public class JobRecoveryOnStartupService {
|
|||||||
if (exitCode != null && exitCode == 0) {
|
if (exitCode != null && exitCode == 0) {
|
||||||
log.info("[RECOVERY] container exited(0). mark SUCCESS. container={}", containerName);
|
log.info("[RECOVERY] container exited(0). mark SUCCESS. container={}", containerName);
|
||||||
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
modelTrainJobCoreService.markSuccess(job.getId(), 0);
|
||||||
|
// model 상태 변경
|
||||||
markStepSuccessByJobType(job);
|
markStepSuccessByJobType(job);
|
||||||
|
// 결과 csv 파일 정보 등록
|
||||||
|
modelTrainMetricsJobService.findTrainValidMetricCsvFiles();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 5-2) exitCode != 0 이거나 null이면 실패로 간주 → FAILED 처리
|
// 5-2) exitCode != 0 이거나 null이면 실패로 간주 → FAILED 처리
|
||||||
@@ -166,7 +207,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
|
|
||||||
modelTrainJobCoreService.markFailed(
|
modelTrainJobCoreService.markFailed(
|
||||||
job.getId(), exitCode, "SERVER_RESTART_CONTAINER_EXIT_NONZERO");
|
job.getId(), exitCode, "SERVER_RESTART_CONTAINER_EXIT_NONZERO");
|
||||||
|
// model 상태 변경
|
||||||
markStepErrorByJobType(job, "exit=" + exitCode + " status=" + status);
|
markStepErrorByJobType(job, "exit=" + exitCode + " status=" + status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +219,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
|
|
||||||
modelTrainJobCoreService.markFailed(
|
modelTrainJobCoreService.markFailed(
|
||||||
job.getId(), -1, "SERVER_RESTART_CONTAINER_INSPECT_ERROR");
|
job.getId(), -1, "SERVER_RESTART_CONTAINER_INSPECT_ERROR");
|
||||||
|
// model 상태 변경
|
||||||
markStepErrorByJobType(job, "inspect-error");
|
markStepErrorByJobType(job, "inspect-error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +245,16 @@ public class JobRecoveryOnStartupService {
|
|||||||
*
|
*
|
||||||
* <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
|
* <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
|
||||||
*/
|
*/
|
||||||
|
private void markStepStopByJobType(ModelTrainJobDto job, String msg) {
|
||||||
|
Map<String, Object> params = job.getParamsJson();
|
||||||
|
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
|
||||||
|
if (isEval) {
|
||||||
|
modelTrainMngCoreService.markStep2Stop(job.getModelId(), msg);
|
||||||
|
} else {
|
||||||
|
modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void markStepErrorByJobType(ModelTrainJobDto job, String msg) {
|
private void markStepErrorByJobType(ModelTrainJobDto job, String msg) {
|
||||||
Map<String, Object> params = job.getParamsJson();
|
Map<String, Object> params = job.getParamsJson();
|
||||||
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
|
boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
|
||||||
@@ -310,33 +361,66 @@ public class JobRecoveryOnStartupService {
|
|||||||
*/
|
*/
|
||||||
private OutputResult probeOutputs(ModelTrainJobDto job) {
|
private OutputResult probeOutputs(ModelTrainJobDto job) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[RECOVERY] probeOutputs start. jobId={}, modelId={}", job.getId(), job.getModelId());
|
||||||
|
|
||||||
|
// 1) 출력 디렉토리 확인
|
||||||
Path outDir = resolveOutputDir(job);
|
Path outDir = resolveOutputDir(job);
|
||||||
if (outDir == null || !Files.isDirectory(outDir)) {
|
if (outDir == null || !Files.isDirectory(outDir)) {
|
||||||
|
log.warn("[RECOVERY] output directory missing. jobId={}, path={}", job.getId(), outDir);
|
||||||
return new OutputResult(false, "output-dir-missing");
|
return new OutputResult(false, "output-dir-missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("[RECOVERY] output directory found. jobId={}, path={}", job.getId(), outDir);
|
||||||
|
|
||||||
|
// 2) totalEpoch 확인
|
||||||
Integer totalEpoch = extractTotalEpoch(job).orElse(null);
|
Integer totalEpoch = extractTotalEpoch(job).orElse(null);
|
||||||
if (totalEpoch == null || totalEpoch <= 0) {
|
if (totalEpoch == null || totalEpoch <= 0) {
|
||||||
|
log.warn(
|
||||||
|
"[RECOVERY] totalEpoch missing or invalid. jobId={}, totalEpoch={}",
|
||||||
|
job.getId(),
|
||||||
|
totalEpoch);
|
||||||
return new OutputResult(false, "total-epoch-missing");
|
return new OutputResult(false, "total-epoch-missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("[RECOVERY] totalEpoch={}. jobId={}", totalEpoch, job.getId());
|
||||||
|
|
||||||
|
// 3) val.csv 존재 확인
|
||||||
Path valCsv = outDir.resolve("val.csv");
|
Path valCsv = outDir.resolve("val.csv");
|
||||||
if (!Files.exists(valCsv)) {
|
if (!Files.exists(valCsv)) {
|
||||||
|
log.warn("[RECOVERY] val.csv missing. jobId={}, path={}", job.getId(), valCsv);
|
||||||
return new OutputResult(false, "val.csv-missing");
|
return new OutputResult(false, "val.csv-missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) val.csv 라인 수 확인
|
||||||
long lines = countNonHeaderLines(valCsv);
|
long lines = countNonHeaderLines(valCsv);
|
||||||
|
|
||||||
// “같아야 완료” 정책
|
log.info(
|
||||||
|
"[RECOVERY] val.csv lines counted. jobId={}, lines={}, expected={}",
|
||||||
|
job.getId(),
|
||||||
|
lines,
|
||||||
|
totalEpoch);
|
||||||
|
|
||||||
|
// 5) 완료 판정
|
||||||
if (lines == totalEpoch) {
|
if (lines == totalEpoch) {
|
||||||
|
log.info("[RECOVERY] outputs look COMPLETE. jobId={}", job.getId());
|
||||||
return new OutputResult(true, "ok");
|
return new OutputResult(true, "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
"[RECOVERY] val.csv line mismatch. jobId={}, lines={}, expected={}",
|
||||||
|
job.getId(),
|
||||||
|
lines,
|
||||||
|
totalEpoch);
|
||||||
|
|
||||||
return new OutputResult(
|
return new OutputResult(
|
||||||
false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch);
|
false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
||||||
log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e);
|
log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e);
|
||||||
|
|
||||||
return new OutputResult(false, "probe-error");
|
return new OutputResult(false, "probe-error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class TmpDatasetService {
|
|||||||
|
|
||||||
Path tmp = Path.of(trainBaseDir, "tmp", uid);
|
Path tmp = Path.of(trainBaseDir, "tmp", uid);
|
||||||
|
|
||||||
long hardlinksMade = 0;
|
long linksMade = 0;
|
||||||
|
|
||||||
for (ModelTrainLinkDto dto : links) {
|
for (ModelTrainLinkDto dto : links) {
|
||||||
|
|
||||||
@@ -54,27 +54,26 @@ public class TmpDatasetService {
|
|||||||
Files.createDirectories(tmp.resolve(type).resolve("label-json"));
|
Files.createDirectories(tmp.resolve(type).resolve("label-json"));
|
||||||
|
|
||||||
// comparePath → input1
|
// comparePath → input1
|
||||||
hardlinksMade += link(tmp, type, "input1", dto.getComparePath());
|
linksMade += link(tmp, type, "input1", dto.getComparePath());
|
||||||
|
|
||||||
// targetPath → input2
|
// targetPath → input2
|
||||||
hardlinksMade += link(tmp, type, "input2", dto.getTargetPath());
|
linksMade += link(tmp, type, "input2", dto.getTargetPath());
|
||||||
|
|
||||||
// labelPath → label
|
// labelPath → label
|
||||||
hardlinksMade += link(tmp, type, "label", dto.getLabelPath());
|
linksMade += link(tmp, type, "label", dto.getLabelPath());
|
||||||
|
|
||||||
// geoJsonPath -> label-json
|
// geoJsonPath -> label-json
|
||||||
hardlinksMade += link(tmp, type, "label-json", dto.getGeoJsonPath());
|
linksMade += link(tmp, type, "label-json", dto.getGeoJsonPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hardlinksMade == 0) {
|
if (linksMade == 0) {
|
||||||
throw new IOException("No hardlinks created.");
|
throw new IOException("No symlinks created.");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("tmp dataset created: {}, hardlinksMade={}", tmp, hardlinksMade);
|
log.info("tmp dataset created: {}, linksMade={}", tmp, linksMade);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long link(Path tmp, String type, String part, String fullPath) throws IOException {
|
private long link(Path tmp, String type, String part, String fullPath) throws IOException {
|
||||||
|
|
||||||
if (fullPath == null || fullPath.isBlank()) return 0;
|
if (fullPath == null || fullPath.isBlank()) return 0;
|
||||||
|
|
||||||
Path src = Path.of(fullPath);
|
Path src = Path.of(fullPath);
|
||||||
@@ -87,20 +86,18 @@ public class TmpDatasetService {
|
|||||||
String fileName = src.getFileName().toString();
|
String fileName = src.getFileName().toString();
|
||||||
Path dst = tmp.resolve(type).resolve(part).resolve(fileName);
|
Path dst = tmp.resolve(type).resolve(part).resolve(fileName);
|
||||||
|
|
||||||
// 충돌 시 덮어쓰기
|
Files.createDirectories(dst.getParent());
|
||||||
if (Files.exists(dst)) {
|
|
||||||
|
if (Files.exists(dst) || Files.isSymbolicLink(dst)) {
|
||||||
Files.delete(dst);
|
Files.delete(dst);
|
||||||
}
|
}
|
||||||
|
|
||||||
Files.createLink(dst, src);
|
Files.createSymbolicLink(dst, src);
|
||||||
|
log.info("symlink created: {} -> {}", dst, src);
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safe(String s) {
|
|
||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* request 전체 폴더 link
|
* request 전체 폴더 link
|
||||||
*
|
*
|
||||||
@@ -111,7 +108,7 @@ public class TmpDatasetService {
|
|||||||
*/
|
*/
|
||||||
public String buildTmpDatasetSymlink(String uid, List<String> datasetUids) throws IOException {
|
public String buildTmpDatasetSymlink(String uid, List<String> datasetUids) throws IOException {
|
||||||
|
|
||||||
log.info("========== buildTmpDatasetHardlink START ==========");
|
log.info("========== buildTmpDatasetSymlink START ==========");
|
||||||
log.info("uid={}", uid);
|
log.info("uid={}", uid);
|
||||||
log.info("datasetUids={}", datasetUids);
|
log.info("datasetUids={}", datasetUids);
|
||||||
log.info("requestDir(raw)={}", requestDir);
|
log.info("requestDir(raw)={}", requestDir);
|
||||||
@@ -123,7 +120,7 @@ public class TmpDatasetService {
|
|||||||
log.info("BASE exists? {}", Files.isDirectory(BASE));
|
log.info("BASE exists? {}", Files.isDirectory(BASE));
|
||||||
log.info("tmp={}", tmp);
|
log.info("tmp={}", tmp);
|
||||||
|
|
||||||
long noDir = 0, scannedDirs = 0, regularFiles = 0, hardlinksMade = 0;
|
long noDir = 0, scannedDirs = 0, regularFiles = 0, symlinksMade = 0;
|
||||||
|
|
||||||
// tmp 디렉토리 준비
|
// tmp 디렉토리 준비
|
||||||
for (String type : List.of("train", "val", "test")) {
|
for (String type : List.of("train", "val", "test")) {
|
||||||
@@ -134,26 +131,7 @@ public class TmpDatasetService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 하드링크는 "같은 파일시스템"에서만 가능하므로 BASE/tmp가 같은 FS인지 미리 확인(권장)
|
// 심볼릭 링크는 파일시스템이 달라도 작동하므로 FileStore 체크 불필요
|
||||||
try {
|
|
||||||
var baseStore = Files.getFileStore(BASE);
|
|
||||||
var tmpStore = Files.getFileStore(tmp.getParent()); // BASE/tmp
|
|
||||||
if (!baseStore.name().equals(tmpStore.name()) || !baseStore.type().equals(tmpStore.type())) {
|
|
||||||
throw new IOException(
|
|
||||||
"Hardlink requires same filesystem. baseStore="
|
|
||||||
+ baseStore.name()
|
|
||||||
+ "("
|
|
||||||
+ baseStore.type()
|
|
||||||
+ "), tmpStore="
|
|
||||||
+ tmpStore.name()
|
|
||||||
+ "("
|
|
||||||
+ tmpStore.type()
|
|
||||||
+ ")");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// FileStore 비교가 환경마다 애매할 수 있어서, 여기서는 경고만 주고 실제 createLink에서 최종 판단하게 둘 수도 있음.
|
|
||||||
log.warn("FileStore check skipped/failed (will rely on createLink): {}", e.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String id : datasetUids) {
|
for (String id : datasetUids) {
|
||||||
Path srcRoot = BASE.resolve(id);
|
Path srcRoot = BASE.resolve(id);
|
||||||
@@ -191,13 +169,12 @@ public class TmpDatasetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 하드링크 생성 (dst가 새 파일로 생기지만 inode는 f와 동일)
|
// 심볼릭 링크 생성 (파일시스템이 달라도 작동)
|
||||||
Files.createLink(dst, f);
|
Files.createSymbolicLink(dst, f);
|
||||||
hardlinksMade++;
|
symlinksMade++;
|
||||||
log.debug("created hardlink: {} => {}", dst, f);
|
log.debug("created symlink: {} => {}", dst, f);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// 여기서 바로 실패시키면 “tmp는 만들었는데 내용은 0개” 같은 상태를 방지할 수 있음
|
log.error("FAILED create symlink: {} => {}", dst, f, e);
|
||||||
log.error("FAILED create hardlink: {} => {}", dst, f, e);
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,9 +183,9 @@ public class TmpDatasetService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hardlinksMade == 0) {
|
if (symlinksMade == 0) {
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
"No hardlinks created. regularFiles="
|
"No symlinks created. regularFiles="
|
||||||
+ regularFiles
|
+ regularFiles
|
||||||
+ ", scannedDirs="
|
+ ", scannedDirs="
|
||||||
+ scannedDirs
|
+ scannedDirs
|
||||||
@@ -218,11 +195,11 @@ public class TmpDatasetService {
|
|||||||
|
|
||||||
log.info("tmp dataset created: {}", tmp);
|
log.info("tmp dataset created: {}", tmp);
|
||||||
log.info(
|
log.info(
|
||||||
"summary: scannedDirs={}, noDir={}, regularFiles={}, hardlinksMade={}",
|
"summary: scannedDirs={}, noDir={}, regularFiles={}, symlinksMade={}",
|
||||||
scannedDirs,
|
scannedDirs,
|
||||||
noDir,
|
noDir,
|
||||||
regularFiles,
|
regularFiles,
|
||||||
hardlinksMade);
|
symlinksMade);
|
||||||
|
|
||||||
return uid;
|
return uid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,13 +268,19 @@ public class TrainJobService {
|
|||||||
try {
|
try {
|
||||||
// 데이터셋 심볼링크 생성
|
// 데이터셋 심볼링크 생성
|
||||||
// String pathUid = tmpDatasetService.buildTmpDatasetSymlink(raw, uids);
|
// String pathUid = tmpDatasetService.buildTmpDatasetSymlink(raw, uids);
|
||||||
// train path
|
// train path 모델 클래스별 조회
|
||||||
List<ModelTrainLinkDto> trainList = modelTrainMngCoreService.findDatasetTrainPath(modelId);
|
List<ModelTrainLinkDto> trainList = modelTrainMngCoreService.findDatasetTrainPath(modelId);
|
||||||
// validation path
|
// validation path 모델 클래스별 조회
|
||||||
List<ModelTrainLinkDto> valList = modelTrainMngCoreService.findDatasetValPath(modelId);
|
List<ModelTrainLinkDto> valList = modelTrainMngCoreService.findDatasetValPath(modelId);
|
||||||
// test path
|
// test path 모델 클래스별 조회
|
||||||
List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId);
|
List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"createTmpFile class list trainList = {} valList = {} testList = {}",
|
||||||
|
trainList.size(),
|
||||||
|
valList.size(),
|
||||||
|
testList.size());
|
||||||
|
|
||||||
// train 데이터셋 심볼링크 생성
|
// train 데이터셋 심볼링크 생성
|
||||||
tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList);
|
tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList);
|
||||||
// val 데이터셋 심볼링크 생성
|
// val 데이터셋 심볼링크 생성
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ public class TrainJobWorker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0 정상 종료 SUCCESS 1~125 학습 코드 에러 FAILED 137 OOMKill FAILED 143 SIGTERM (stop) STOP -1 우리 내부
|
||||||
|
* 강제 중단 STOP
|
||||||
|
*/
|
||||||
if (result.getExitCode() == 0) {
|
if (result.getExitCode() == 0) {
|
||||||
// 성공 처리
|
// 성공 처리
|
||||||
modelTrainJobCoreService.markSuccess(jobId, result.getExitCode());
|
modelTrainJobCoreService.markSuccess(jobId, result.getExitCode());
|
||||||
@@ -124,17 +128,34 @@ public class TrainJobWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
String failMsg = result.getStatus() + "\n" + result.getLogs();
|
|
||||||
// 실패 처리
|
|
||||||
modelTrainJobCoreService.markFailed(
|
|
||||||
jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs());
|
|
||||||
|
|
||||||
if (isEval) {
|
String failMsg = result.getStatus() + "\n" + result.getLogs();
|
||||||
// 오류 정보 등록
|
log.info("training fail exitCode={} Msg ={}", result.getExitCode(), failMsg);
|
||||||
modelTrainMngCoreService.markStep2Error(modelId, "exit=" + result.getExitCode());
|
|
||||||
|
if (result.getExitCode() == -1 || result.getExitCode() == 143) {
|
||||||
|
// 실패 처리
|
||||||
|
modelTrainJobCoreService.markPaused(
|
||||||
|
jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs());
|
||||||
|
|
||||||
|
if (isEval) {
|
||||||
|
// 오류 정보 등록
|
||||||
|
modelTrainMngCoreService.markStep2Stop(modelId, "exit=" + result.getExitCode());
|
||||||
|
} else {
|
||||||
|
// 오류 정보 등록
|
||||||
|
modelTrainMngCoreService.markStep1Stop(modelId, "exit=" + result.getExitCode());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 오류 정보 등록
|
// 실패 처리
|
||||||
modelTrainMngCoreService.markError(modelId, "exit=" + result.getExitCode());
|
modelTrainJobCoreService.markFailed(
|
||||||
|
jobId, result.getExitCode(), result.getStatus() + "\n" + result.getLogs());
|
||||||
|
|
||||||
|
if (isEval) {
|
||||||
|
// 오류 정보 등록
|
||||||
|
modelTrainMngCoreService.markStep2Error(modelId, "exit=" + result.getExitCode());
|
||||||
|
} else {
|
||||||
|
// 오류 정보 등록
|
||||||
|
modelTrainMngCoreService.markError(modelId, "exit=" + result.getExitCode());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ spring:
|
|||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://192.168.2.127:15432/kamco_training_db
|
url: jdbc:postgresql://192.168.2.127:15432/kamco_training_db
|
||||||
# url: jdbc:postgresql://localhost:15432/kamco_training_db
|
|
||||||
username: kamco_training_user
|
username: kamco_training_user
|
||||||
password: kamco_training_user_2025_!@#
|
password: kamco_training_user_2025_!@#
|
||||||
hikari:
|
hikari:
|
||||||
@@ -48,7 +47,7 @@ member:
|
|||||||
init_password: kamco1234!
|
init_password: kamco1234!
|
||||||
|
|
||||||
swagger:
|
swagger:
|
||||||
local-port: 9080
|
local-port: 8080
|
||||||
|
|
||||||
file:
|
file:
|
||||||
sync-root-dir: /app/original-images/
|
sync-root-dir: /app/original-images/
|
||||||
@@ -70,3 +69,4 @@ train:
|
|||||||
containerPrefix: kamco-cd-train
|
containerPrefix: kamco-cd-train
|
||||||
shmSize: 16g
|
shmSize: 16g
|
||||||
ipcHost: true
|
ipcHost: true
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ spring:
|
|||||||
on-profile: prod
|
on-profile: prod
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: false # 운영 환경에서는 성능을 위해 비활성화
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: validate
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
default_batch_fetch_size: 100 # ✅ 성능 - N+1 쿼리 방지
|
default_batch_fetch_size: 100 # N+1 쿼리 방지
|
||||||
order_updates: true # ✅ 성능 - 업데이트 순서 정렬로 데드락 방지
|
order_updates: true # 업데이트 순서 정렬로 데드락 방지
|
||||||
use_sql_comments: true # ⚠️ 선택 - SQL에 주석 추가 (디버깅용)
|
|
||||||
format_sql: true # ⚠️ 선택 - SQL 포맷팅 (가독성)
|
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://127.0.01:15432/kamco_training_db
|
url: jdbc:postgresql://kamco-cd-train-db:5432/kamco_training_db
|
||||||
# url: jdbc:postgresql://localhost:15432/kamco_training_db
|
|
||||||
username: kamco_training_user
|
username: kamco_training_user
|
||||||
password: kamco_training_user_2025_!@#
|
password: kamco_training_user_2025_!@#
|
||||||
hikari:
|
hikari:
|
||||||
@@ -31,13 +28,14 @@ spring:
|
|||||||
default-timeout: 300 # 5분 트랜잭션 타임아웃
|
default-timeout: 300 # 5분 트랜잭션 타임아웃
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: "kamco_token_dev_dfc6446d-68fc-4eba-a2ff-c80a14a0bf3a"
|
# ⚠️ 운영 환경에서는 반드시 별도의 강력한 시크릿 키를 사용하세요
|
||||||
|
secret: "kamco_token_prod_CHANGE_THIS_TO_SECURE_SECRET_KEY"
|
||||||
access-token-validity-in-ms: 86400000 # 1일
|
access-token-validity-in-ms: 86400000 # 1일
|
||||||
refresh-token-validity-in-ms: 604800000 # 7일
|
refresh-token-validity-in-ms: 604800000 # 7일
|
||||||
|
|
||||||
token:
|
token:
|
||||||
refresh-cookie-name: kamco # 개발용 쿠키 이름
|
refresh-cookie-name: kamco
|
||||||
refresh-cookie-secure: false # 로컬 http 테스트면 false
|
refresh-cookie-secure: true # HTTPS 환경에서 필수
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
@@ -70,3 +68,5 @@ train:
|
|||||||
shmSize: 16g
|
shmSize: 16g
|
||||||
ipcHost: true
|
ipcHost: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ spring:
|
|||||||
datasource:
|
datasource:
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
jdbc:
|
|
||||||
time_zone: UTC
|
|
||||||
batch_size: 50
|
|
||||||
# 권장 설정
|
|
||||||
minimum-idle: 2
|
minimum-idle: 2
|
||||||
maximum-pool-size: 2
|
maximum-pool-size: 2
|
||||||
connection-timeout: 20000
|
connection-timeout: 20000
|
||||||
@@ -21,18 +17,11 @@ spring:
|
|||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000
|
||||||
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
password:
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update # 스키마 자동 관리 활성화
|
ddl-auto: update # 스키마 자동 관리 활성화
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
hbm2ddl:
|
|
||||||
auto: update
|
|
||||||
javax:
|
javax:
|
||||||
persistence:
|
persistence:
|
||||||
validation:
|
validation:
|
||||||
@@ -54,6 +43,9 @@ logging:
|
|||||||
web: INFO
|
web: INFO
|
||||||
security: INFO
|
security: INFO
|
||||||
root: INFO
|
root: INFO
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# actuator
|
# actuator
|
||||||
management:
|
management:
|
||||||
health:
|
health:
|
||||||
@@ -77,20 +69,3 @@ management:
|
|||||||
exposure:
|
exposure:
|
||||||
include:
|
include:
|
||||||
- "health"
|
- "health"
|
||||||
|
|
||||||
# GeoJSON 파일 모니터링 설정
|
|
||||||
geojson:
|
|
||||||
monitor:
|
|
||||||
watch-directory: ~/geojson/upload
|
|
||||||
processed-directory: ~/geojson/processed
|
|
||||||
error-directory: ~/geojson/error
|
|
||||||
temp-directory: /tmp/geojson_extract
|
|
||||||
cron-expression: "0/30 * * * * *" # 매 30초마다 실행
|
|
||||||
supported-extensions:
|
|
||||||
- zip
|
|
||||||
- tar
|
|
||||||
- tar.gz
|
|
||||||
- tgz
|
|
||||||
max-file-size: 104857600 # 100MB
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ spring:
|
|||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000
|
||||||
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
password:
|
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none # 테스트 환경에서는 DDL 자동 생성/수정 비활성화
|
ddl-auto: none # 테스트 환경에서는 DDL 자동 생성/수정 비활성화
|
||||||
@@ -69,20 +63,6 @@ management:
|
|||||||
include:
|
include:
|
||||||
- "health"
|
- "health"
|
||||||
|
|
||||||
geojson:
|
|
||||||
monitor:
|
|
||||||
watch-directory: ~/geojson/upload
|
|
||||||
processed-directory: ~/geojson/processed
|
|
||||||
error-directory: ~/geojson/error
|
|
||||||
temp-directory: /tmp/geojson_extract
|
|
||||||
cron-expression: "0/30 * * * * *"
|
|
||||||
supported-extensions:
|
|
||||||
- zip
|
|
||||||
- tar
|
|
||||||
- tar.gz
|
|
||||||
- tgz
|
|
||||||
max-file-size: 104857600
|
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: "test_secret_key_for_testing_purposes_only"
|
secret: "test_secret_key_for_testing_purposes_only"
|
||||||
access-token-validity-in-ms: 86400000
|
access-token-validity-in-ms: 86400000
|
||||||
|
|||||||
Reference in New Issue
Block a user