71 Commits

Author SHA1 Message Date
4f763d3c2e prod 2026-03-03 08:44:01 +09:00
9ebf525387 prod 2026-03-03 08:42:16 +09:00
4ab672a96e 중복 경고 2026-02-28 01:56:48 +09:00
7d2a367e3f Merge pull request '리커버리 삭제' (#142) from feat/training_260202 into develop
Reviewed-on: #142
2026-02-28 01:24:49 +09:00
67a67749c3 Merge pull request '리커버리 추가' (#141) from feat/training_260202 into develop
Reviewed-on: #141
2026-02-28 01:02:10 +09:00
251307b5c9 Merge pull request '하드링크 수정' (#140) from feat/training_260202 into develop
Reviewed-on: #140
2026-02-27 23:31:24 +09:00
8423a03d31 Merge pull request '하드링크 로그 추가' (#139) from feat/training_260202 into develop
Reviewed-on: #139
2026-02-27 23:12:17 +09:00
d6cdf6b690 Merge pull request '하드링크 로그 추가' (#138) from feat/training_260202 into develop
Reviewed-on: #138
2026-02-27 22:51:51 +09:00
9cfa299e58 Merge pull request 'feat/training_260202' (#137) from feat/training_260202 into develop
Reviewed-on: #137
2026-02-24 16:55:35 +09:00
a137e71420 Merge pull request 'feat/training_260202' (#136) from feat/training_260202 into develop
Reviewed-on: #136
2026-02-24 15:11:12 +09:00
f08f80622f Merge pull request 'feat/training_260202' (#135) from feat/training_260202 into develop
Reviewed-on: #135
2026-02-23 14:31:11 +09:00
e565fd7a34 Merge pull request '전이학습 상세 수정' (#134) from feat/training_260202 into develop
Reviewed-on: #134
2026-02-20 18:34:58 +09:00
8c45b39dcc Merge pull request 'feat/training_260202' (#133) from feat/training_260202 into develop
Reviewed-on: #133
2026-02-20 18:22:45 +09:00
b119f333ac Merge pull request 'best epoch 파일 선택 수정' (#132) from feat/training_260202 into develop
Reviewed-on: #132
2026-02-20 15:41:58 +09:00
bbe04ee458 Merge pull request 'best epoch 파일 선택 수정' (#131) from feat/training_260202 into develop
Reviewed-on: #131
2026-02-20 15:31:50 +09:00
fab3c83a69 Merge pull request 'best epoch 파일 선택 수정' (#130) from feat/training_260202 into develop
Reviewed-on: #130
2026-02-20 15:15:39 +09:00
fb87a0f32f Merge pull request '중복 수정 제거' (#129) from feat/training_260202 into develop
Reviewed-on: #129
2026-02-20 14:31:15 +09:00
63794ec4ec Merge pull request 'feat/training_260202' (#128) from feat/training_260202 into develop
Reviewed-on: #128
2026-02-20 14:25:13 +09:00
bf6dc9740f Merge pull request 'tmp 하드링크 수정' (#127) from feat/training_260202 into develop
Reviewed-on: #127
2026-02-20 13:37:04 +09:00
a23bc8dd67 Merge pull request 'tmp 하드링크 수정' (#126) from feat/training_260202 into develop
Reviewed-on: #126
2026-02-20 12:30:23 +09:00
13023a06cc Merge pull request 'feat/training_260202' (#125) from feat/training_260202 into develop
Reviewed-on: #125
2026-02-20 12:23:37 +09:00
28b50bd949 Merge pull request 'test json 수정' (#124) from feat/training_260202 into develop
Reviewed-on: #124
2026-02-20 12:20:36 +09:00
78ab928459 Merge pull request 'ing-cnt 로직에 step2도 추가, transactional' (#123) from feat/training_260202 into develop
Reviewed-on: #123
2026-02-20 12:05:40 +09:00
ae3601cff5 Merge pull request '비밀번호 변경 security 로직 수정' (#122) from feat/training_260202 into develop
Reviewed-on: #122
2026-02-20 11:37:08 +09:00
5f62f4a209 Merge pull request 'test 실행 시 회차별 데이터 적재하기' (#121) from feat/training_260202 into develop
Reviewed-on: #121
2026-02-19 18:19:40 +09:00
29bf155b4f Merge pull request 'LogErrorLevel -> CodeExpose 추가' (#120) from feat/training_260202 into develop
Reviewed-on: #120
2026-02-19 17:35:45 +09:00
da03f8b749 Merge pull request '모델학습관리 > 모델별 진행 상황 API 추가' (#119) from feat/training_260202 into develop
Reviewed-on: #119
2026-02-19 17:17:46 +09:00
6a2deff93b Merge pull request '모델학습관리 > 목록 API 메모,작성자 추가로 인한 수정' (#118) from feat/training_260202 into develop
Reviewed-on: #118
2026-02-19 15:35:03 +09:00
b0a99afcd3 Merge pull request '모델학습 2단계 패키징 시작,종료일시,상태 로직 추가' (#117) from feat/training_260202 into develop
Reviewed-on: #117
2026-02-19 14:44:12 +09:00
eedf72d7aa Merge pull request '공통코드 common-code 로 prefix 변경' (#116) from feat/training_260202 into develop
Reviewed-on: #116
2026-02-19 11:39:23 +09:00
25e9941464 Merge pull request '로그관리 로직 커밋' (#115) from feat/training_260202 into develop
Reviewed-on: #115
2026-02-19 11:16:40 +09:00
a0da0392cf Merge pull request '압축해제 시, 동일 폴더가 있으면 삭제 후 재업로드' (#114) from feat/training_260202 into develop
Reviewed-on: #114
2026-02-18 16:37:13 +09:00
a3ebee12b5 Merge pull request 'feat/training_260202' (#113) from feat/training_260202 into develop
Reviewed-on: #113
2026-02-18 16:29:55 +09:00
c5b14ca09d Merge pull request '업로드 시 uid로 중복체크 -> 삭제인 row는 제외하기' (#112) from feat/training_260202 into develop
Reviewed-on: #112
2026-02-18 15:40:38 +09:00
44b3b857b1 Merge pull request 'feat/training_260202' (#111) from feat/training_260202 into develop
Reviewed-on: #111
2026-02-18 15:29:25 +09:00
63124455fd Merge pull request '1단계 실행 시, 시작시간 update 추가' (#110) from feat/training_260202 into develop
Reviewed-on: #110
2026-02-18 13:06:33 +09:00
e75ea8d8a5 Merge pull request '하이퍼 파라미터 수정' (#109) from feat/training_260202 into develop
Reviewed-on: #109
2026-02-13 15:00:50 +09:00
31ac4209c3 Merge pull request '하이퍼 파라미터 수정' (#108) from feat/training_260202 into develop
Reviewed-on: #108
2026-02-13 14:47:19 +09:00
df09935789 Merge pull request '하이퍼 파라미터 수정' (#107) from feat/training_260202 into develop
Reviewed-on: #107
2026-02-13 14:42:54 +09:00
bb15b1b0f2 Merge pull request '하이퍼 파라미터 수정' (#106) from feat/training_260202 into develop
Reviewed-on: #106
2026-02-13 14:38:01 +09:00
f4d491ed94 Merge pull request '하이퍼 파라미터 수정' (#105) from feat/training_260202 into develop
Reviewed-on: #105
2026-02-13 14:31:13 +09:00
96cb7d2f23 Merge pull request '사용가능 용량 API 수정' (#104) from feat/training_260202 into develop
Reviewed-on: #104
2026-02-13 14:19:26 +09:00
cc6305b0df Merge pull request '이어하기 수정' (#103) from feat/training_260202 into develop
Reviewed-on: #103
2026-02-13 14:08:51 +09:00
3916b13876 Merge pull request '이어하기 수정' (#102) from feat/training_260202 into develop
Reviewed-on: #102
2026-02-13 14:04:52 +09:00
ee4a06df30 Merge pull request '이어하기 수정' (#101) from feat/training_260202 into develop
Reviewed-on: #101
2026-02-13 13:57:48 +09:00
bb5ff7c3cd Merge pull request '이어하기 로그 수정' (#100) from feat/training_260202 into develop
Reviewed-on: #100
2026-02-13 13:27:08 +09:00
312a96dda1 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#99) from feat/training_260202 into develop
Reviewed-on: #99
2026-02-13 13:24:10 +09:00
e38231e06d Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#98) from feat/training_260202 into develop
Reviewed-on: #98
2026-02-13 13:17:41 +09:00
bf6e45d706 Merge pull request 'feat/training_260202' (#97) from feat/training_260202 into develop
Reviewed-on: #97
2026-02-13 12:53:32 +09:00
7fa8921a25 Merge pull request 'flush 추가해보기' (#96) from feat/training_260202 into develop
Reviewed-on: #96
2026-02-13 12:47:11 +09:00
7c940351d9 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#95) from feat/training_260202 into develop
Reviewed-on: #95
2026-02-13 12:39:21 +09:00
6b834da912 Merge pull request 'feat/training_260202' (#94) from feat/training_260202 into develop
Reviewed-on: #94
2026-02-13 12:25:26 +09:00
25aaa97d65 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#93) from feat/training_260202 into develop
Reviewed-on: #93
2026-02-13 12:21:11 +09:00
da9d47ae4a Merge pull request '주석 처리' (#92) from feat/training_260202 into develop
Reviewed-on: #92
2026-02-13 12:10:46 +09:00
7d6a77bf2a Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#91) from feat/training_260202 into develop
Reviewed-on: #91
2026-02-13 12:01:01 +09:00
26828d0968 add log 2026-02-13 12:00:54 +09:00
e2dbae15c0 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#90) from feat/training_260202 into develop
Reviewed-on: #90
2026-02-13 11:58:55 +09:00
b246034632 Merge pull request '트랜젝션처리 임시폴더 uid업데이트' (#89) from feat/training_260202 into develop
Reviewed-on: #89
2026-02-13 11:58:25 +09:00
687ea82d78 Merge pull request 'feat/training_260202' (#88) from feat/training_260202 into develop
Reviewed-on: #88
2026-02-13 10:51:07 +09:00
4ac0f19908 Merge pull request '파일 count 기능 추가' (#87) from feat/training_260202 into develop
Reviewed-on: #87
2026-02-13 10:38:48 +09:00
9e5e7595eb Merge pull request '학습실행 step1 할 때 best epoch 업데이트' (#86) from feat/training_260202 into develop
Reviewed-on: #86
2026-02-13 10:18:26 +09:00
9cd9274e99 Merge pull request '학습데이터 목록 파일 단위 MB 나오게 하기' (#85) from feat/training_260202 into develop
Reviewed-on: #85
2026-02-13 09:48:08 +09:00
5d82f3ecfe Merge pull request 'tmp 파일 링크 수정' (#84) from feat/training_260202 into develop
Reviewed-on: #84
2026-02-13 09:11:05 +09:00
2ce249ab33 Merge pull request 'tmp 파일 링크 수정' (#83) from feat/training_260202 into develop
Reviewed-on: #83
2026-02-13 08:44:37 +09:00
e34bf68de0 Merge pull request 'tmp 파일 링크 수정' (#82) from feat/training_260202 into develop
Reviewed-on: #82
2026-02-13 08:33:58 +09:00
862bda0cb9 Merge pull request '이어하기 수정' (#81) from feat/training_260202 into develop
Reviewed-on: #81
2026-02-12 23:02:12 +09:00
90f7b17d07 Merge pull request '학습데이터 다운로드 파일 정보 API 추가' (#80) from feat/training_260202 into develop
Reviewed-on: #80
2026-02-12 22:58:47 +09:00
2128baa46a Merge pull request 'feat/training_260202' (#79) from feat/training_260202 into develop
Reviewed-on: #79
2026-02-12 22:26:26 +09:00
875c30f467 Merge pull request 'feat/training_260202' (#78) from feat/training_260202 into develop
Reviewed-on: #78
2026-02-12 21:52:16 +09:00
2b29cd1ac6 Merge pull request '파라미터 변경' (#77) from feat/training_260202 into develop
Reviewed-on: #77
2026-02-12 21:30:18 +09:00
9206fff5d0 Merge pull request 'feat/training_260202' (#76) from feat/training_260202 into develop
Reviewed-on: #76
2026-02-12 21:17:33 +09:00
10 changed files with 645 additions and 208 deletions

6
.gitignore vendored
View File

@@ -72,3 +72,9 @@ docker-compose.override.yml
*.swo *.swo
*~ *~
!/CLAUDE.md !/CLAUDE.md
### SSL Certificates ###
nginx/ssl/
*.crt
*.key
*.pem

20
Dockerfile Normal file
View 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"]

56
docker-compose-prod.yml Normal file
View File

@@ -0,0 +1,56 @@
services:
nginx:
image: nginx:alpine
container_name: kamco-cd-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
depends_on:
kamco-changedetection-api:
condition: service_healthy
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
kamco-changedetection-api:
build:
context: .
dockerfile: Dockerfile
image: kamco-cd-training-api:${IMAGE_TAG:-latest}
container_name: kamco-cd-training-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
volumes:
nginx-logs:
driver: local

397
nginx/SSL_SETUP.md Normal file
View File

@@ -0,0 +1,397 @@
# 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
```
## 참고 자료
- [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/)

93
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,93 @@
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-changedetection-api:8080;
}
# 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 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;
# 타임아웃 설정 (대용량 파일 업로드 지원)
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;
}
}
}

View File

@@ -3,6 +3,7 @@ package com.kamco.cd.training.common.utils;
import static java.lang.String.CASE_INSENSITIVE_ORDER; import static java.lang.String.CASE_INSENSITIVE_ORDER;
import com.jcraft.jsch.ChannelExec; import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session; import com.jcraft.jsch.Session;
import com.kamco.cd.training.common.exception.CustomApiException; import com.kamco.cd.training.common.exception.CustomApiException;
@@ -719,26 +720,18 @@ public class FIleChecker {
public static void unzip(String fileName, String destDirectory) throws IOException { public static void unzip(String fileName, String destDirectory) throws IOException {
String zipFilePath = destDirectory + File.separator + fileName; String zipFilePath = destDirectory + File.separator + fileName;
log.info("fileName : {}", fileName);
log.info("destDirectory : {}", destDirectory);
log.info("zipFilePath : {}", zipFilePath);
// zip 이름으로 폴더 생성 (확장자 제거) // zip 이름으로 폴더 생성 (확장자 제거)
String folderName = String folderName =
fileName.endsWith(".zip") ? fileName.substring(0, fileName.length() - 4) : fileName; fileName.endsWith(".zip") ? fileName.substring(0, fileName.length() - 4) : fileName;
log.info("folderName : {}", folderName);
File destDir = new File(destDirectory, folderName); File destDir = new File(destDirectory, folderName);
log.info("destDir : {}", destDir);
// 동일 폴더가 이미 있으면 삭제 // 동일 폴더가 이미 있으면 삭제
log.info("111 destDir.exists() : {}", destDir.exists());
if (destDir.exists()) { if (destDir.exists()) {
deleteDirectoryRecursively(destDir.toPath()); deleteDirectoryRecursively(destDir.toPath());
} }
log.info("222 destDir.exists() : {}", destDir.exists());
if (!destDir.exists()) { if (!destDir.exists()) {
log.info("mkdirs : {}", destDir.exists());
destDir.mkdirs(); destDir.mkdirs();
} }

View File

@@ -398,26 +398,9 @@ 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("'", "'\"'\"'") + "'";
} }

View File

@@ -20,187 +20,71 @@ public class TmpDatasetService {
@Value("${train.docker.basePath}") @Value("${train.docker.basePath}")
private String trainBaseDir; private String trainBaseDir;
/** private final DataSetCountersService dataSetCountersService;
* train, val, test 폴더별로 link
*
* @param uid 임시폴더 uuid
* @param type train, val, test
* @param links tif pull path
* @return
* @throws IOException
*/
public void buildTmpDatasetHardlink(String uid, String type, List<ModelTrainLinkDto> links)
throws IOException {
if (links == null || links.isEmpty()) {
throw new IOException("links is empty");
}
Path tmp = Path.of(trainBaseDir, "tmp", uid);
long hardlinksMade = 0;
for (ModelTrainLinkDto dto : links) {
if (type == null) {
log.warn("SKIP - trainType null: {}", dto);
continue;
}
// type별 디렉토리 생성
Files.createDirectories(tmp.resolve(type).resolve("input1"));
Files.createDirectories(tmp.resolve(type).resolve("input2"));
Files.createDirectories(tmp.resolve(type).resolve("label"));
Files.createDirectories(tmp.resolve(type).resolve("label-json"));
// comparePath → input1
hardlinksMade += link(tmp, type, "input1", dto.getComparePath());
// targetPath → input2
hardlinksMade += link(tmp, type, "input2", dto.getTargetPath());
// labelPath → label
hardlinksMade += link(tmp, type, "label", dto.getLabelPath());
// geoJsonPath -> label-json
hardlinksMade += link(tmp, type, "label-json", dto.getGeoJsonPath());
}
if (hardlinksMade == 0) {
throw new IOException("No hardlinks created.");
}
log.info("tmp dataset created: {}, hardlinksMade={}", tmp, hardlinksMade);
}
private long link(Path tmp, String type, String part, String fullPath) throws IOException {
if (fullPath == null || fullPath.isBlank()) return 0;
Path src = Path.of(fullPath);
if (!Files.isRegularFile(src)) {
log.warn("SKIP (not file): {}", src);
return 0;
}
String fileName = src.getFileName().toString();
Path dst = tmp.resolve(type).resolve(part).resolve(fileName);
// 충돌 시 덮어쓰기
if (Files.exists(dst)) {
Files.delete(dst);
}
Files.createLink(dst, src);
return 1;
}
private String safe(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
/** /**
* request 전체 폴더 link * 다른 데이터셋 파일과 이름이 겹치면 그 파일은 skip함
* *
* @param uid * @param uid
* @param datasetUids * @param type
* @param links
* @return * @return
* @throws IOException * @throws IOException
*/ */
public String buildTmpDatasetSymlink(String uid, List<String> datasetUids) throws IOException { public String buildTmpDatasetSymlink(String uid, String type, List<ModelTrainLinkDto> links)
throws IOException {
log.info("========== buildTmpDatasetHardlink START =========="); if (uid == null || uid.isBlank()) throw new IOException("uid is empty");
log.info("uid={}", uid); if (type == null || type.isBlank()) throw new IOException("type is empty");
log.info("datasetUids={}", datasetUids); if (links == null || links.isEmpty()) throw new IOException("links is empty");
log.info("requestDir(raw)={}", requestDir);
log.info("========== buildTmpDatasetHardlink MERGE START ==========");
log.info("uid={}, type={}, links.size={}", uid, type, links.size());
Path BASE = toPath(requestDir); Path BASE = toPath(requestDir);
Path tmp = Path.of(trainBaseDir, "tmp", uid); Path tmp = Path.of(trainBaseDir, "tmp", uid);
log.info("BASE={}", BASE); long hardlinksMade = 0;
log.info("BASE exists? {}", Files.isDirectory(BASE)); long skippedCollision = 0;
log.info("tmp={}", tmp); long noDir = 0;
long noDir = 0, scannedDirs = 0, regularFiles = 0, hardlinksMade = 0; // tmp/<type>/<part> 준비
for (String part : List.of("input1", "input2", "label", "label-json")) {
Files.createDirectories(tmp.resolve(type).resolve(part));
}
for (ModelTrainLinkDto dto : links) {
String datasetUid = safe(dto.getDatasetUid());
if (datasetUid == null) {
log.warn("SKIP dto (datasetUid null): {}", dto);
continue;
}
Path srcRoot = BASE.resolve(datasetUid);
// tmp 디렉토리 준비
for (String type : List.of("train", "val", "test")) {
for (String part : List.of("input1", "input2", "label", "label-json")) { for (String part : List.of("input1", "input2", "label", "label-json")) {
Path dir = tmp.resolve(type).resolve(part);
Files.createDirectories(dir);
log.info("createDirectories: {}", dir);
}
}
// 하드링크는 "같은 파일시스템"에서만 가능하므로 BASE/tmp가 같은 FS인지 미리 확인(권장) Path srcDir = srcRoot.resolve(type).resolve(part);
try { if (!Files.isDirectory(srcDir)) {
var baseStore = Files.getFileStore(BASE); noDir++;
var tmpStore = Files.getFileStore(tmp.getParent()); // BASE/tmp continue;
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) { // 하위폴더까지 전부
Path srcRoot = BASE.resolve(id); try (var walk = Files.walk(srcDir)) {
log.info("---- dataset id={} srcRoot={} exists? {}", id, srcRoot, Files.isDirectory(srcRoot)); for (Path f : walk.filter(Files::isRegularFile).toList()) {
for (String type : List.of("train", "val", "test")) { String fileName = f.getFileName().toString();
for (String part : List.of("input1", "input2", "label", "label-json")) { Path dst = tmp.resolve(type).resolve(part).resolve(fileName);
Path srcDir = srcRoot.resolve(type).resolve(part); // 이름 유지 + 충돌은 skip
if (!Files.isDirectory(srcDir)) { if (Files.exists(dst)) {
log.warn("SKIP (not directory): {}", srcDir); skippedCollision++;
noDir++; continue;
continue;
}
scannedDirs++;
log.info("SCAN dir={}", srcDir);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(srcDir)) {
for (Path f : stream) {
if (!Files.isRegularFile(f)) {
log.debug("skip non-regular file: {}", f);
continue;
}
regularFiles++;
String dstName = id + "__" + f.getFileName();
Path dst = tmp.resolve(type).resolve(part).resolve(dstName);
// dst가 남아있으면 삭제(심볼릭링크든 파일이든)
if (Files.exists(dst) || Files.isSymbolicLink(dst)) {
Files.delete(dst);
log.debug("deleted existing: {}", dst);
}
try {
// 하드링크 생성 (dst가 새 파일로 생기지만 inode는 f와 동일)
Files.createLink(dst, f);
hardlinksMade++;
log.debug("created hardlink: {} => {}", dst, f);
} catch (IOException e) {
// 여기서 바로 실패시키면 “tmp는 만들었는데 내용은 0개” 같은 상태를 방지할 수 있음
log.error("FAILED create hardlink: {} => {}", dst, f, e);
throw e;
}
} }
Files.createLink(dst, f);
hardlinksMade++;
} }
} }
} }
@@ -208,29 +92,35 @@ public class TmpDatasetService {
if (hardlinksMade == 0) { if (hardlinksMade == 0) {
throw new IOException( throw new IOException(
"No hardlinks created. regularFiles=" "No hardlinks created. noDir=" + noDir + ", skippedCollision=" + skippedCollision);
+ regularFiles
+ ", scannedDirs="
+ scannedDirs
+ ", noDir="
+ noDir);
} }
log.info("tmp dataset created: {}", tmp);
log.info( log.info(
"summary: scannedDirs={}, noDir={}, regularFiles={}, hardlinksMade={}", "tmp dataset merged: {} (type={}), hardlinksMade={}, skippedCollision={}, noDir={}",
scannedDirs, tmp,
noDir, type,
regularFiles, hardlinksMade,
hardlinksMade); skippedCollision,
noDir);
return uid; return uid;
} }
private static Path toPath(String p) { private static Path toPath(String p) {
if (p.startsWith("~/")) { if (p == null || p.isBlank()) {
return Paths.get(System.getProperty("user.home")).resolve(p.substring(2)).normalize(); throw new IllegalArgumentException("path is null or blank");
} }
return Paths.get(p).toAbsolutePath().normalize(); String trimmed = p.trim();
if (trimmed.startsWith("~/")) {
return Paths.get(System.getProperty("user.home"))
.resolve(trimmed.substring(2))
.toAbsolutePath()
.normalize();
}
return Paths.get(trimmed).toAbsolutePath().normalize();
}
private static String safe(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
} }
} }

View File

@@ -266,8 +266,6 @@ public class TrainJobService {
List<String> uids = modelTrainMngCoreService.findDatasetUid(datasetIds); List<String> uids = modelTrainMngCoreService.findDatasetUid(datasetIds);
try { try {
// 데이터셋 심볼링크 생성
// 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
@@ -276,12 +274,13 @@ public class TrainJobService {
List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId); List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId);
// train 데이터셋 심볼링크 생성 // train 데이터셋 심볼링크 생성
tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList); tmpDatasetService.buildTmpDatasetSymlink(raw, "train", trainList);
// val 데이터셋 심볼링크 생성 // val 데이터셋 심볼링크 생성
tmpDatasetService.buildTmpDatasetHardlink(raw, "val", valList); tmpDatasetService.buildTmpDatasetSymlink(raw, "val", valList);
// test 데이터셋 심볼링크 생성 // test 데이터셋 심볼링크 생성
tmpDatasetService.buildTmpDatasetHardlink(raw, "test", testList); tmpDatasetService.buildTmpDatasetSymlink(raw, "test", testList);
// 카운트 로그
dataSetCounters.getCount(modelId);
ModelTrainMngDto.UpdateReq updateReq = new ModelTrainMngDto.UpdateReq(); ModelTrainMngDto.UpdateReq updateReq = new ModelTrainMngDto.UpdateReq();
updateReq.setRequestPath(raw); updateReq.setRequestPath(raw);

View File

@@ -15,7 +15,7 @@ spring:
format_sql: 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 # 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_!@#