refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)

- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 16:46:03 +09:00
parent 7e1daca81b
commit db63fcff85
440 changed files with 407 additions and 460 deletions

View File

@@ -0,0 +1,343 @@
# 1. 서버 인프라 개요
[목차로 돌아가기](./README.md)
---
## 운영서버 (sam-prod)
### 서버 사양
| 항목 | 값 |
|------|-----|
| IP | 211.117.60.189 |
| 호스트명 | sam-prod |
| OS | Ubuntu 24.04.4 LTS |
| 커널 | 6.8.0-100-generic |
| CPU | 2 vCPU |
| RAM | 8GB |
| Swap | 4GB |
| 디스크 | 98GB (여유 79GB) |
| 사용자 | hskwon (SSH 키 인증, sudo NOPASSWD) |
### 도메인 목록
| 도메인 | 서비스 | 백엔드 | 포트 |
|--------|--------|--------|------|
| sam.it.kr | Next.js 15 프론트엔드 | PM2 cluster x2 | 3000 |
| api.sam.it.kr | Laravel 12 API | PHP-FPM api pool | unix socket |
| mng.codebridge-x.com | Laravel 12 Admin | PHP-FPM admin pool | unix socket |
| sales.codebridge-x.com | Plain PHP 레거시 | PHP-FPM sales pool | unix socket |
| codebridge-x.com (+ www) | 정적 랜딩페이지 | Nginx direct | 80/443 |
| stage.sam.it.kr | Next.js Stage | PM2 fork x1 | 3100 |
| stage-api.sam.it.kr | Laravel API Stage | PHP-FPM api-stage pool | unix socket |
모든 도메인은 Let's Encrypt SSL 적용 (알림: develop@codebridge-x.com).
### 서비스 현황
| 서비스 | 버전 | 포트 | 상태 |
|--------|------|------|------|
| Nginx | 1.24.0 | 80/443 | active |
| PHP-FPM | 8.4.18 | unix socket (4개 pool) | active |
| MySQL | 8.4.8 | 3306 | active |
| Redis | 7.0.15 | 6379 (localhost) | active |
| PM2 | 6.0.14 | 3000 (cluster x2), 3100 (fork x1) | active |
| Supervisor | - | - | active (queue worker x2) |
| node_exporter | 1.8.2 | 9100 | active |
| Certbot | 2.9.0 | - | timer active |
| fail2ban | - | - | active |
### 주요 디렉토리
```
/home/webservice/
api/ Laravel API (운영) - releases/shared 구조
current -> releases/...
releases/
shared/ (.env, storage/)
api-stage/ Laravel API (Stage) - 동일 구조
mng/ Laravel Admin - 동일 구조
sales/ Plain PHP 레거시 (.env, uploads/)
react/ Next.js 운영 - releases/shared 구조
react-stage/ Next.js Stage - 동일 구조
landing/ 정적 랜딩페이지
ecosystem.config.js PM2 설정
```
### 주요 설정 파일
| 구분 | 경로 |
|------|------|
| Nginx 메인 설정 | /etc/nginx/nginx.conf |
| Nginx 사이트 설정 | /etc/nginx/sites-available/*.conf |
| Nginx 보안 스니펫 | /etc/nginx/snippets/security.conf |
| PHP-FPM Pool (API) | /etc/php/8.4/fpm/pool.d/api.conf |
| PHP-FPM Pool (Admin) | /etc/php/8.4/fpm/pool.d/admin.conf |
| PHP-FPM Pool (Sales) | /etc/php/8.4/fpm/pool.d/sales.conf |
| PHP-FPM Pool (API Stage) | /etc/php/8.4/fpm/pool.d/api-stage.conf |
| MySQL 튜닝 | /etc/mysql/mysql.conf.d/sam-tuning.cnf |
| Redis | /etc/redis/redis.conf |
| Supervisor | /etc/supervisor/conf.d/sam-queue.conf |
| PM2 | /home/webservice/ecosystem.config.js |
| API .env | /home/webservice/api/shared/.env |
| MNG .env | /home/webservice/mng/shared/.env |
| Sales .env | /home/webservice/sales/.env |
### 메모리 배분
| 서비스 | 할당 | 설정 |
|--------|------|------|
| MySQL 8.4 | ~2GB | innodb_buffer_pool_size=2G |
| Redis | ~0.5GB | maxmemory 512mb |
| PHP-FPM (API) | ~0.8GB | max_children=10 |
| PHP-FPM (Admin) | ~0.3GB | max_children=5 |
| PHP-FPM (Sales) | ~0.2GB | max_children=3 |
| PHP-FPM (API-Stage) | ~0.2GB | max_children=3 |
| Next.js 운영 (PM2 cluster×2) | ~0.6GB | max-old-space-size=256 |
| Next.js Stage (PM2 fork×1) | ~0.15GB | max-old-space-size=128 |
| Supervisor (Queue Worker) | ~0.1GB | numprocs=2 |
| Nginx | ~0.1GB | worker_connections 1024 |
| node_exporter | ~10MB | - |
| OS + 여유 | ~2.9GB | 스왑 4GB |
### 방화벽 (UFW) 규칙
| 포트 | 프로토콜 | 허용 범위 | 용도 |
|------|----------|-----------|------|
| 22 | TCP | Anywhere | SSH |
| 80 | TCP | Anywhere | HTTP |
| 443 | TCP | Anywhere | HTTPS |
| 9100 | TCP | 110.10.147.46 only | node_exporter (Prometheus) |
| 3306 | TCP | 110.10.147.46 only | MySQL 백업 (CI/CD 서버) |
### 데이터베이스 사용자
| 사용자 | 인증 방식 | 권한 | 용도 |
|--------|-----------|------|------|
| codebridge@localhost | 비밀번호 | sam, sam_stage, sam_stat, codebridge | 애플리케이션 |
| hskwon@localhost | auth_socket | ALL (WITH GRANT OPTION) | 관리자 |
| root@localhost | auth_socket | ALL | 시스템 (sudo mysql) |
| sam_backup@110.10.147.46 | 비밀번호 | SELECT, LOCK TABLES (sam, sam_stat) | CI/CD 백업 |
---
## CI/CD 서버 (sam-cicd)
### 서버 사양
| 항목 | 값 |
|------|-----|
| IP | 110.10.147.46 |
| SSH 별칭 | sam-cicd |
| OS | Ubuntu 24.04.4 LTS |
| Kernel | 6.8.0-41-generic |
| CPU | 4 vCPU |
| RAM | 8GB (Swap 4GB) |
| Disk | 98GB (사용 15GB / 여유 79GB) |
| 사용자 | hskwon (SSH 키 인증, sudo NOPASSWD) |
### 도메인 매핑
| 도메인 | 서비스 | 백엔드 포트 | SSL |
|--------|--------|------------|-----|
| git.sam.it.kr | Gitea | :3000 | Let's Encrypt |
| ci.sam.it.kr | Jenkins | :8080 | Let's Encrypt |
| monitor.sam.it.kr | Grafana | :3100 | Let's Encrypt |
### 서비스 현황
| 서비스 | 버전 | 포트 | 도메인 |
|--------|------|------|--------|
| Nginx | 1.24.0 | 80/443 | 리버스 프록시 |
| Jenkins | LTS (2.541.2) | 8080 | ci.sam.it.kr |
| Gitea | 1.22.6 | 3000 | git.sam.it.kr |
| MySQL | 8.4.8 | 3306 | - |
| Prometheus | 2.51.0 | 9090 | - (localhost only) |
| Grafana | - | 3100 | monitor.sam.it.kr |
| node_exporter | 1.8.2 | 9100 | - |
| Java | OpenJDK 21.0.10 | - | Jenkins 런타임 |
| Certbot | - | - | SSL 자동 갱신 |
| fail2ban | - | - | SSH 보호 |
### 메모리 배분
| 서비스 | 할당 | 설정 |
|--------|------|------|
| Jenkins | ~2.0GB | -Xmx2048m |
| MySQL | ~1.5GB | innodb_buffer_pool_size=1536M |
| Gitea | ~0.5GB | Go 기반 |
| Prometheus | ~0.5GB | retention 30d |
| Grafana | ~0.3GB | - |
| Nginx | ~0.1GB | - |
| node_exporter | ~10MB | - |
| OS + 여유 | ~3.1GB | Swap 4GB |
### 주요 설정 파일
| 설정 | 경로 |
|------|------|
| Nginx 사이트 | /etc/nginx/sites-available/{ci,git,monitor}.sam.it.kr |
| Jenkins 홈 | /var/lib/jenkins/ |
| Jenkins JVM 설정 | /etc/systemd/system/jenkins.service.d/override.conf |
| Jenkins Agent | /var/lib/jenkins-agent/ (workspace, agent.jar) |
| Jenkins Agent 서비스 | /etc/systemd/system/jenkins-agent.service |
| Jenkins 환경파일 | /var/lib/jenkins/env-files/react/.env.{develop,stage,main} |
| Gitea 설정 | /etc/gitea/app.ini |
| Gitea 저장소 | /var/lib/gitea/data/repositories/ |
| Gitea 로그 | /var/lib/gitea/log/ |
| Prometheus 설정 | /etc/prometheus/prometheus.yml |
| Prometheus 데이터 | /var/lib/prometheus/ |
| Grafana 설정 | /etc/grafana/grafana.ini |
| MySQL 튜닝 | /etc/mysql/mysql.conf.d/sam-tuning.cnf |
| fail2ban 설정 | /etc/fail2ban/ |
| SSL 인증서 | /etc/letsencrypt/live/ |
| 백업 스크립트 | /home/hskwon/scripts/backup-db.sh |
| 백업 저장소 | /home/hskwon/backups/mysql/ |
### 방화벽 (UFW) 규칙
| 포트 | 프로토콜 | 용도 |
|------|---------|------|
| 22/tcp | ALLOW | SSH |
| 80/tcp | ALLOW | HTTP |
| 443/tcp | ALLOW | HTTPS |
---
## 개발서버 (sam-dev)
### 서버 사양
| 항목 | 값 |
|------|-----|
| IP | 114.203.209.83 |
| 호스트명 | sam-dev |
| OS | Ubuntu 24.04.2 LTS |
| 사용자 | hskwon (SSH 키 인증, sudo NOPASSWD) |
### 서비스 현황
| 서비스 | 포트 | 상태 |
|--------|------|------|
| Nginx | 80/443 | active |
| Apache | 8080 | active (레거시) |
| MySQL 8.4 | 3306 (localhost) | active |
| Gitea | 3000 | active |
| Next.js (PM2) | 3001 | active |
| fail2ban | - | active |
### 방화벽 (UFW) 규칙
| 포트 | 프로토콜 | 용도 |
|------|---------|------|
| 22/tcp | ALLOW | SSH |
| 80/tcp | ALLOW | HTTP |
| 443/tcp | ALLOW | HTTPS |
| 3000/tcp | ALLOW | Gitea |
> MySQL(3306), Apache(8080), Next.js(3001), CUPS(631) 등은 외부 차단
### 주요 디렉토리
```
/home/webservice/
react/ Next.js 프론트엔드
api/ Laravel API
mng/ Laravel Admin
sales/ Plain PHP 레거시
/data/GIT/samproject/ Gitea bare repositories
```
---
## 아키텍처 다이어그램
### 운영서버
```
┌──────────────────────────────────────────────────────────┐
│ 운영서버 (2 vCPU / 8GB) │
│ Ubuntu 24.04 / IP: 211.117.60.189 │
│ │
│ ┌──────────┐ ┌───────────┐ ┌───────────────────────┐ │
│ │ Nginx │ │ Certbot │ │ UFW (22,80,443,9100) │ │
│ └────┬─────┘ └───────────┘ └───────────────────────┘ │
│ │ │
│ ┌────┴───────────────────────────────────────────────┐ │
│ │ sam.it.kr ──────────→ Next.js (PM2 cluster, :3000)│ │
│ │ api.sam.it.kr ──────→ PHP-FPM (api pool) │ │
│ │ mng.codebridge-x.com ──→ PHP-FPM (admin pool) │ │
│ │ sales.codebridge-x.com → PHP-FPM (sales pool) │ │
│ │ stage.sam.it.kr ────→ Next.js (PM2 fork, :3100) │ │
│ │ stage-api.sam.it.kr → PHP-FPM (api-stage pool) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌─────────────────┐ │
│ │ MySQL 8.4 │ │ Redis │ │ Supervisor │ │
│ │ (Master) │ │ (캐시/큐) │ │ (Queue Worker) │ │
│ └────────────┘ └────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ node_exporter (:9100) → CI/CD Prometheus │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
### CI/CD 서버
```
┌──────────────────────────────────────────────────────────┐
│ CI/CD서버 (2 vCPU / 8GB) │
│ Ubuntu 24.04 / IP: 110.10.147.46 │
│ │
│ ┌──────────┐ ┌───────────┐ ┌───────────────────────┐ │
│ │ Nginx │ │ Certbot │ │ UFW (22,80,443) │ │
│ └────┬─────┘ └───────────┘ └───────────────────────┘ │
│ │ │
│ ┌────┴───────────────────────────────────────────────┐ │
│ │ git.sam.it.kr ──────────→ Gitea (:3000) │ │
│ │ ci.sam.it.kr ───────────→ Jenkins (:8080) │ │
│ │ monitor.sam.it.kr ──────→ Grafana (:3100) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ Gitea │ │ Jenkins │ │ MySQL 8.4 │ │
│ │ (운영 Git) │ │ (CI/CD) │ │ (Gitea DB + 백업) │ │
│ └────────────┘ └────────────┘ └────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Prometheus │ │ Grafana │ │
│ │ (:9090) │ │ (:3100) │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 도메인 환경 분리
| 서비스 | 운영 | Stage | 개발 |
|--------|------|-------|------|
| Front | sam.it.kr | stage.sam.it.kr | dev.codebridge-x.com |
| API | api.sam.it.kr | stage-api.sam.it.kr | api.codebridge-x.com |
| Admin | mng.codebridge-x.com | - | admin.codebridge-x.com |
| Sales | sales.codebridge-x.com | - | salesdev.codebridge-x.com |
| Landing | codebridge-x.com | - | - |
### 타이틀 접두사 (환경 구분)
브라우저 탭에서 환경을 즉시 구분할 수 있도록 타이틀에 접두사를 표시한다.
| 환경 | 접두사 | 예시 |
|------|--------|------|
| 로컬 | `[L]` | `[L]SAM_MNG` |
| 개발 | `[D]` | `[D]SAM_SYSTEM` |
| 운영 | 없음 | `SAM_SYSTEM` |
**설정 위치:**
| 프로젝트 | 방식 | 설정 파일 |
|---------|------|----------|
| mng | `.env``APP_NAME`에 접두사 포함 | 로컬: `mng/.env`, 개발: `/home/webservice/mng/.env` |
| api | `.env``APP_NAME`에 접두사 포함 | 로컬: `api/.env`, 개발: `/home/webservice/api/.env` |
| react | 코드에서 `NEXT_PUBLIC_APP_ENV` 값으로 자동 판별 | CI/CD: `/var/lib/jenkins/env-files/react/.env.develop` |

View File

@@ -0,0 +1,253 @@
# 2. 일상 운영
[목차로 돌아가기](./README.md)
---
## [운영] 전체 서비스 상태 확인
```bash
# 핵심 서비스 상태 한번에 확인
sudo systemctl status nginx php8.4-fpm mysql redis-server supervisor node_exporter
# PM2 프로세스 상태
pm2 status
# 열린 포트 확인
sudo ss -tlnp
```
## [CI/CD] 전체 서비스 상태 확인
```bash
# 모든 핵심 서비스 상태 한 번에 확인
sudo systemctl status nginx jenkins gitea mysql prometheus grafana-server node_exporter
# 개별 서비스 상태
sudo systemctl status jenkins
sudo systemctl status gitea
```
---
## [운영] .env 파일 편집 시 주의사항
> **경고:** `vi`로 `.env`를 편집하면 권한이 `600`으로 변경되어 서비스 장애가 발생할 수 있습니다.
```bash
# 편집 전 권한 확인
ls -la /home/webservice/api/shared/.env # 640(-rw-r-----)이어야 함
# 편집 후 반드시 권한 확인 및 복원
chmod 640 /home/webservice/api/shared/.env
chmod 640 /home/webservice/mng/shared/.env
```
이를 방지하려면 `~/.vimrc``set backupcopy=yes`가 설정되어 있어야 합니다.
자세한 내용: [09-security.md - .env 파일 보안](./09-security.md)
---
## 시스템 리소스 모니터링
양쪽 서버 공통 명령어:
```bash
# 메모리 사용량
free -h
# 디스크 사용량
df -h
# CPU 및 프로세스 (실시간)
htop
# 로드 평균 (즉시 확인)
uptime
# 스왑 사용량
swapon --show
# 열린 포트 확인
sudo ss -tlnp
# 프로세스별 메모리 사용량 (상위 10개)
ps aux --sort=-%mem | head -11
```
**[CI/CD] 디스크 사용량 상세:**
```bash
sudo du -sh /var/lib/jenkins /var/lib/gitea /var/lib/prometheus /var/lib/mysql /var/log 2>/dev/null
```
---
## 로그 확인
### [운영] Nginx
```bash
# 접근 로그 (실시간)
sudo tail -f /var/log/nginx/api.sam.it.kr.access.log
sudo tail -f /var/log/nginx/sam.it.kr.access.log
sudo tail -f /var/log/nginx/mng.codebridge-x.com.access.log
# 에러 로그 (실시간)
sudo tail -f /var/log/nginx/api.sam.it.kr.error.log
sudo tail -f /var/log/nginx/sam.it.kr.error.log
# 최근 에러 50줄
sudo tail -50 /var/log/nginx/api.sam.it.kr.error.log
```
### [운영] PHP-FPM
```bash
sudo tail -f /var/log/php8.4-fpm.log
```
### [운영] Laravel
```bash
# API 로그
sudo tail -f /home/webservice/api/shared/storage/logs/laravel.log
# Admin(MNG) 로그 — storage/logs가 shared 심링크가 아니므로 current 경로 사용
sudo tail -f /home/webservice/mng/current/storage/logs/laravel.log
# API Stage 로그
sudo tail -f /home/webservice/api-stage/shared/storage/logs/laravel.log
# Queue Worker 로그
sudo tail -f /home/webservice/api/shared/storage/logs/queue-worker.log
```
### [운영] PM2 (Next.js)
```bash
# 운영 로그
pm2 logs sam-front --lines 50
# Stage 로그
pm2 logs sam-front-stage --lines 50
# 에러 로그만
pm2 logs sam-front --err --lines 50
```
### [운영] Supervisor
```bash
sudo supervisorctl status
sudo tail -f /home/webservice/api/shared/storage/logs/queue-worker.log
```
### [운영] MySQL
```bash
sudo tail -f /var/log/mysql/slow.log
sudo tail -f /var/log/mysql/error.log
```
### [CI/CD] Jenkins
```bash
sudo journalctl -u jenkins -f
sudo journalctl -u jenkins --since "1 hour ago"
```
### [CI/CD] Gitea
```bash
sudo journalctl -u gitea -f
sudo tail -f /var/lib/gitea/log/gitea.log
```
### [CI/CD] Prometheus / Grafana
```bash
sudo journalctl -u prometheus -f
sudo journalctl -u grafana-server -f
```
### [CI/CD] Nginx / MySQL
```bash
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/mysql/error.log
```
### 시스템 로그 (공통)
```bash
# 시스템 전체 로그 (최근)
sudo journalctl -xe --no-pager | tail -50
# 특정 서비스 로그
sudo journalctl -u 서비스명 --since "1 hour ago"
```
---
## SSL 인증서 확인 (공통)
```bash
# 전체 인증서 목록 및 만료일
sudo certbot certificates
# 자동 갱신 타이머 상태
sudo systemctl status certbot.timer
# 갱신 테스트 (실제 갱신하지 않음)
sudo certbot renew --dry-run
```
---
## [CI/CD] 네트워크 연결 확인
```bash
# 운영서버 연결
ping -c 3 211.117.60.189
ssh sam-prod "echo 'prod OK'"
# 개발서버 연결
ping -c 3 114.203.209.83
ssh sam-dev "echo 'dev OK'"
# 웹 서비스 응답 확인
curl -sI https://ci.sam.it.kr | head -5
curl -sI https://git.sam.it.kr | head -5
curl -sI https://monitor.sam.it.kr | head -5
```
---
## 일일 점검 스크립트
### [운영]
```bash
echo "=== 서비스 ===" && \
for s in nginx php8.4-fpm mysql redis-server supervisor node_exporter; do
printf "%-20s %s\n" "$s" "$(systemctl is-active $s)"
done && \
echo "=== PM2 ===" && pm2 status && \
echo "=== 메모리 ===" && free -h | grep Mem && \
echo "=== 디스크 ===" && df -h / | tail -1 && \
echo "=== SSL ===" && sudo certbot certificates 2>/dev/null | grep "Expiry Date"
```
### [CI/CD]
```bash
echo "=== 서비스 ===" && \
for s in nginx jenkins gitea mysql prometheus grafana-server node_exporter; do
printf "%-20s %s\n" "$s" "$(systemctl is-active $s)"
done && \
echo "=== 메모리 ===" && free -h | grep Mem && \
echo "=== 디스크 ===" && df -h / | tail -1 && \
echo "=== SSL ===" && sudo certbot certificates 2>/dev/null | grep "Expiry Date"
```

View File

@@ -0,0 +1,381 @@
# 3. 운영서버 서비스 관리
[목차로 돌아가기](./README.md) | 서버: sam-prod (211.117.60.189)
---
## Nginx
**명령어:**
```bash
sudo systemctl status nginx
sudo nginx -t # 설정 테스트 (반드시 reload/restart 전에 실행)
sudo systemctl reload nginx # 설정 리로드 (무중단)
sudo systemctl restart nginx # 재시작 (연결 끊김 발생)
sudo systemctl stop nginx
sudo systemctl start nginx
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /etc/nginx/nginx.conf | 메인 설정 (worker_connections 1024, client_max_body_size 50M) |
| /etc/nginx/sites-available/ | 사이트별 설정 |
| /etc/nginx/sites-enabled/ | 활성화된 사이트 (심링크) |
| /etc/nginx/snippets/security.conf | 보안 규칙 (.env, .git 차단) |
**로그 파일:**
| 파일 | 내용 |
|------|------|
| /var/log/nginx/api.sam.it.kr.access.log | API 접근 로그 |
| /var/log/nginx/api.sam.it.kr.error.log | API 에러 로그 |
| /var/log/nginx/sam.it.kr.access.log | 프론트엔드 접근 로그 |
| /var/log/nginx/sam.it.kr.error.log | 프론트엔드 에러 로그 |
| /var/log/nginx/mng.codebridge-x.com.access.log | Admin 접근 로그 |
| /var/log/nginx/mng.codebridge-x.com.error.log | Admin 에러 로그 |
| /var/log/nginx/sales.codebridge-x.com.access.log | Sales 접근 로그 |
| /var/log/nginx/sales.codebridge-x.com.error.log | Sales 에러 로그 |
**주요 설정 값:**
- worker_processes: auto
- worker_connections: 1024
- client_max_body_size: 50M
- keepalive_timeout: 65
- gzip: on (text/plain, application/json, application/javascript, text/css)
---
## PHP-FPM
**명령어:**
```bash
sudo systemctl status php8.4-fpm
sudo systemctl reload php8.4-fpm # 무중단, 설정 변경 시
sudo systemctl restart php8.4-fpm
sudo systemctl stop php8.4-fpm
sudo systemctl start php8.4-fpm
```
**Pool 설정:**
| Pool | 설정 파일 | 소켓 | max_children | memory_limit |
|------|----------|------|-------------|-------------|
| api | /etc/php/8.4/fpm/pool.d/api.conf | /run/php/php8.4-fpm-api.sock | 10 | 128M |
| admin | /etc/php/8.4/fpm/pool.d/admin.conf | /run/php/php8.4-fpm-admin.sock | 5 | 128M |
| sales | /etc/php/8.4/fpm/pool.d/sales.conf | /run/php/php8.4-fpm-sales.sock | 3 | 128M |
| api-stage | /etc/php/8.4/fpm/pool.d/api-stage.conf | /run/php/php8.4-fpm-api-stage.sock | 3 | 128M |
모든 Pool 공통 설정: upload_max_filesize=50M, post_max_size=50M, display_errors=Off
**로그:** /var/log/php8.4-fpm.log
---
## MySQL
**명령어:**
```bash
sudo systemctl status mysql
sudo systemctl restart mysql # 주의: 연결 끊김
sudo systemctl stop mysql
sudo systemctl start mysql
# 접속
sudo mysql # root (auth_socket)
mysql -u hskwon # hskwon (auth_socket, sudo 불필요)
mysql -u codebridge -p sam # 앱 사용자
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /etc/mysql/mysql.conf.d/sam-tuning.cnf | 성능 튜닝 |
| /etc/mysql/mysql.conf.d/mysqld.cnf | 기본 설정 |
**주요 튜닝 값:**
- innodb_buffer_pool_size: 2048M
- innodb_log_file_size: 512M
- innodb_flush_log_at_trx_commit: 2
- max_connections: 100
- slow_query_log: ON (long_query_time: 2s)
**로그:**
| 파일 | 내용 |
|------|------|
| /var/log/mysql/slow.log | 느린 쿼리 (2초 이상) |
| /var/log/mysql/error.log | 에러 로그 |
**데이터베이스:**
| DB 이름 | 용도 |
|---------|------|
| sam | 메인 운영 DB |
| sam_stage | Stage 환경 DB |
| sam_stat | 통계 DB |
| codebridge | Sales 레거시 DB |
---
## Redis
**명령어:**
```bash
sudo systemctl status redis-server
sudo systemctl restart redis-server
sudo systemctl stop redis-server
sudo systemctl start redis-server
redis-cli # CLI 접속
redis-cli ping # 연결 테스트 → PONG
```
**설정 파일:** /etc/redis/redis.conf
**주요 설정:**
- bind: 127.0.0.1 ::1 (로컬 전용)
- maxmemory: 512mb
- maxmemory-policy: allkeys-lru
- supervised: systemd
**Redis CLI 유용한 명령어:**
```bash
redis-cli info memory # 메모리 사용량
redis-cli dbsize # 키 개수
redis-cli keys '*' | head -20 # 키 확인 (운영 주의)
redis-cli ttl "키이름" # TTL 확인
redis-cli flushall # 전체 삭제 (주의: 세션도 삭제됨)
```
**용도:**
| 기능 | 드라이버 | .env 설정 |
|------|---------|----------|
| 캐시 | Redis | CACHE_STORE=redis |
| 세션 | Database | SESSION_DRIVER=database |
| 큐 | Redis | Supervisor에서 `queue:work redis` 명시 |
---
## PM2 (Next.js)
**명령어:**
```bash
pm2 status # 전체 상태
pm2 reload sam-front # 운영 무중단 재시작 (cluster 모드)
pm2 restart sam-front-stage # Stage 재시작
pm2 logs sam-front --lines 100 # 로그 확인
pm2 logs sam-front-stage --lines 100
pm2 monit # 실시간 CPU/메모리
pm2 describe sam-front # 상세 정보
pm2 stop all # 전체 정지
pm2 start all # 전체 시작
cd /home/webservice && pm2 start ecosystem.config.js # 설정 파일로 시작
pm2 save # 현재 상태 저장 (부팅 시 자동 복구용)
```
**설정 파일:** /home/webservice/ecosystem.config.js
**프로세스 목록:**
| 프로세스명 | 모드 | 인스턴스 | 포트 | 메모리 제한 | 용도 |
|-----------|------|---------|------|-----------|------|
| sam-front | cluster | 2 | 3000 | 300M (max-old-space-size=256) | 운영 프론트엔드 |
| sam-front-stage | fork | 1 | 3100 | 200M (max-old-space-size=128) | Stage 프론트엔드 |
**로그 파일:** ~/.pm2/logs/ (sam-front-out.log, sam-front-error.log 등)
---
## Supervisor (Queue Worker)
**명령어:**
```bash
sudo supervisorctl status # 전체 상태
sudo supervisorctl restart sam-queue-worker:* # 재시작
sudo supervisorctl stop sam-queue-worker:* # 정지
sudo supervisorctl start sam-queue-worker:* # 시작
sudo supervisorctl reread # 설정 리로드
sudo supervisorctl update
```
**설정 파일:** /etc/supervisor/conf.d/sam-queue.conf
**프로세스 구성:**
- 프로그램명: sam-queue-worker
- 프로세스 수: 2 (numprocs=2)
- 실행 명령: `php /home/webservice/api/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600`
- 실행 사용자: www-data
- 자동 재시작: true
**로그:** /home/webservice/api/shared/storage/logs/queue-worker.log
---
## node_exporter
```bash
sudo systemctl status node_exporter
sudo systemctl restart node_exporter
curl -s localhost:9100/metrics | head -20 # 메트릭 확인
```
**포트:** 9100 (UFW에서 CI/CD 서버 IP만 허용)
**역할:** CPU, RAM, 디스크, 네트워크 메트릭을 CI/CD 서버의 Prometheus에 제공.
---
## Certbot (SSL)
```bash
sudo certbot certificates # 인증서 목록 및 만료일
sudo systemctl status certbot.timer # 자동 갱신 타이머
sudo certbot renew --dry-run # 갱신 시뮬레이션
sudo certbot renew # 수동 갱신
sudo certbot --nginx -d 도메인명 --email develop@codebridge-x.com # 새 도메인 발급
```
자동 갱신은 systemd 타이머(certbot.timer)가 처리한다. 별도 crontab 불필요.
---
## fail2ban
```bash
sudo systemctl status fail2ban
sudo fail2ban-client status # jail 목록
sudo fail2ban-client status sshd # SSH jail 상태 (차단 IP 목록)
sudo fail2ban-client set sshd unbanip 차단된_IP주소 # IP 차단 해제
sudo systemctl restart fail2ban
```
**설정 파일:** /etc/fail2ban/jail.local (또는 jail.d/)
---
## UFW (방화벽)
```bash
sudo ufw status verbose # 상태 확인 (규칙 목록)
sudo ufw status numbered # 번호로 규칙 목록
sudo ufw allow from IP주소 to any port 포트번호 # 규칙 추가
sudo ufw delete 번호 # 규칙 삭제 (번호 기반)
sudo ufw disable # 비활성화 (비상시만)
sudo ufw enable # 활성화
```
---
## LibreOffice (문서 변환)
API 서버에서 문서 변환(Excel→PDF 등)에 사용. 헤드리스 모드로 동작.
**버전:** 24.2.7.2 (개발/운영 동일)
**명령어:**
```bash
libreoffice --version # 버전 확인
libreoffice --headless --convert-to pdf input.xlsx # CLI 변환 테스트
```
**설치 패키지:**
```bash
sudo apt-get install -y libreoffice-core libreoffice-writer libreoffice-calc libreoffice-impress
```
---
## 폰트
LibreOffice 문서 변환 시 폰트가 없으면 글자가 깨지므로 개발/운영 서버 동일하게 설치 필수.
**설치된 한글 폰트:**
| 폰트 | 설치 방식 | 경로 |
|------|----------|------|
| **Pretendard** (9 웨이트) | 수동 설치 (OTF) | `/usr/local/share/fonts/Pretendard-*.otf` |
| **Nanum** (고딕/명조/스퀘어/손글씨 등) | apt (`fonts-nanum`, `fonts-nanum-extra`) | `/usr/share/fonts/truetype/nanum/` |
| **Noto CJK** (Sans/Serif) | apt (`fonts-noto-cjk`) | `/usr/share/fonts/opentype/noto/` |
**폰트 관리 명령어:**
```bash
fc-list :lang=ko family | sort -u # 설치된 한글 폰트 목록
fc-list | grep -i pretendard # Pretendard 설치 확인
sudo fc-cache -fv # 폰트 캐시 갱신 (새 폰트 추가 후 필수)
```
**새 폰트 추가 시:**
```bash
# 1. OTF/TTF 파일을 /usr/local/share/fonts/ 에 복사
sudo cp *.otf /usr/local/share/fonts/
# 2. 폰트 캐시 갱신
sudo fc-cache -fv
# 3. 확인
fc-list | grep -i "폰트이름"
```
> **주의:** 개발서버에 폰트를 추가하면 운영서버에도 동일하게 설치해야 변환 결과가 일치한다.
---
## SMTP (메일 발송)
Gmail SMTP를 통해 메일 발송. Google 앱 비밀번호 사용 (2단계 인증 필요).
**프로젝트별 SMTP 설정:**
| 항목 | api | mng |
|------|-----|-----|
| MAIL_HOST | smtp.gmail.com | smtp.gmail.com |
| MAIL_PORT | 587 | 587 |
| MAIL_USERNAME | shine1324@gmail.com | admin@codebridge-x.com |
| MAIL_FROM_ADDRESS | shine1324@gmail.com | develop@codebridge-x.com |
| MAIL_FROM_NAME | ${APP_NAME} | (주)코드브릿지엑스 |
| MAIL_ENCRYPTION | tls | tls |
> **주의:** 개발/운영 서버의 MAIL_PASSWORD(앱 비밀번호)는 반드시 동일하게 유지.
> Google 앱 비밀번호를 재발급하면 모든 서버에 동일하게 반영해야 한다.
**설정 파일 위치:**
| 프로젝트 | 운영 | 개발 |
|---------|------|------|
| api | `/home/webservice/api/shared/.env` | `/home/webservice/api/.env` |
| mng | `/home/webservice/mng/shared/.env` | `/home/webservice/mng/.env` |
**변경 후 반영:**
```bash
# api
cd /home/webservice/api/current && php artisan config:cache
# mng
cd /home/webservice/mng/current && php artisan config:cache
```
**트러블슈팅:**
- `535 Username and Password not accepted` → 앱 비밀번호 만료 또는 불일치. 개발서버 값과 비교 후 동기화
- `Connection refused` → 방화벽에서 587 포트 아웃바운드 차단 여부 확인
- Google 앱 비밀번호 발급: Google 계정 → 보안 → 2단계 인증 → 앱 비밀번호

View File

@@ -0,0 +1,363 @@
# 4. CI/CD 서비스 관리
[목차로 돌아가기](./README.md) | 서버: sam-cicd (110.10.147.46)
---
## Jenkins
**서비스 제어:**
```bash
sudo systemctl start jenkins
sudo systemctl stop jenkins
sudo systemctl restart jenkins
sudo systemctl status jenkins
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /var/lib/jenkins/ | Jenkins 홈 (jobs, plugins, credentials) |
| /etc/systemd/system/jenkins.service.d/override.conf | JVM 메모리 설정 |
| /var/lib/jenkins/env-files/ | 배포 환경변수 (.env 파일) |
| /var/lib/jenkins-agent/ | Agent 워크스페이스 (빌드 실행 격리) |
| /etc/systemd/system/jenkins-agent.service | Agent systemd 서비스 |
**JVM 메모리 설정:**
```bash
# /etc/systemd/system/jenkins.service.d/override.conf
# Environment="JAVA_OPTS=-Xmx2048m -Xms512m -Djava.awt.headless=true"
# 변경 후 적용
sudo systemctl daemon-reload
sudo systemctl restart jenkins
```
**로그:**
```bash
sudo journalctl -u jenkins -f
sudo journalctl -u jenkins --since "2 hours ago" --no-pager
```
**웹 UI:** https://ci.sam.it.kr (관리자: hskwon)
### Credential 관리
| Credential ID | 유형 | 용도 |
|--------------|------|------|
| deploy-ssh-key | SSH Username with private key | 운영/개발서버 SSH 배포 |
| gitea-api-token | Username with password | Gitea API 연동 (token을 username, 비밀번호 빈값) |
**Credential 위치:** Jenkins 관리 > Credentials > System > Global credentials
**SSH 키 경로:** /var/lib/jenkins/.ssh/id_ed25519
**환경변수 파일:**
```
/var/lib/jenkins/env-files/
react/
.env.develop # 개발서버용
.env.stage # Stage용
.env.main # 운영용
```
### 설치된 주요 플러그인
- Gitea Plugin -- Gitea Webhook 연동
- SSH Agent Plugin -- SSH 키 기반 배포
- Pipeline / Workflow Aggregator -- Jenkinsfile 지원
- Pipeline Stage View -- 파이프라인 시각화
- Blue Ocean -- 모던 UI
- NodeJS Plugin -- Node.js 도구 관리 (22.22.0)
플러그인 업데이트 후 Jenkins 재시작이 필요한 경우: `sudo systemctl restart jenkins`
### Build Agent (분산 빌드)
Built-in Node의 executor는 0으로 설정되어 있으며, 빌드는 로컬 Agent(`local-agent`)에서 실행된다.
| 항목 | 값 |
|------|-----|
| Agent 이름 | local-agent |
| Workspace | /var/lib/jenkins-agent/ |
| Executor 수 | 2 |
| 라벨 | build |
| 연결 방식 | WebSocket (Inbound) |
**서비스 제어:**
```bash
sudo systemctl start jenkins-agent
sudo systemctl stop jenkins-agent
sudo systemctl restart jenkins-agent
sudo systemctl status jenkins-agent
# Agent 로그
sudo journalctl -u jenkins-agent -f
```
> **참고**: Jenkins 마스터 재시작 시 Agent가 자동 재연결된다. Agent가 연결 실패하면 `sudo systemctl restart jenkins-agent`로 수동 재시작.
### Workspace 정리
```bash
# Agent workspace 용량 확인
sudo du -sh /var/lib/jenkins-agent/workspace/*
# 특정 workspace 삭제
sudo rm -rf /var/lib/jenkins-agent/workspace/<JOB_NAME>
# 전체 workspace 정리 (빌드 중이 아닌지 확인 후)
sudo rm -rf /var/lib/jenkins-agent/workspace/*
# 레거시 Built-in workspace (이전 빌드 잔존 시)
sudo du -sh /var/lib/jenkins/workspace/*
sudo rm -rf /var/lib/jenkins/workspace/*
# 임시 파일 정리
sudo find /tmp -name "jenkins*" -mtime +7 -delete
```
---
## Gitea
**서비스 제어:**
```bash
sudo systemctl start gitea
sudo systemctl stop gitea
sudo systemctl restart gitea
sudo systemctl status gitea
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /etc/gitea/app.ini | 메인 설정 |
| /var/lib/gitea/data/repositories/ | Git 저장소 데이터 |
| /var/lib/gitea/log/ | Gitea 로그 |
| /var/lib/gitea/custom/ | 커스텀 설정 |
**주요 설정 (app.ini):**
```ini
[server]
DOMAIN = git.sam.it.kr
HTTP_PORT = 3000
ROOT_URL = https://git.sam.it.kr/
[service]
DISABLE_REGISTRATION = true # 회원가입 비활성화
REQUIRE_SIGNIN_VIEW = true # 로그인 필수
```
**로그:**
```bash
sudo journalctl -u gitea -f
sudo tail -f /var/lib/gitea/log/gitea.log
```
**웹 UI:** https://git.sam.it.kr (관리자: hskwon)
### 저장소 현황
| Organization | 저장소 | 설명 |
|-------------|--------|------|
| SamProject | sam-api | Laravel REST API |
| SamProject | sam-manage | Laravel Admin (mng) |
| SamProject | sam-react-prod | Next.js 프론트엔드 |
| SamProject | sam-sales | 영업자 사이트 (레거시) |
### 사용자/조직 관리
- 사이트 관리: https://git.sam.it.kr/-/admin
- 사용자 관리: https://git.sam.it.kr/-/admin/users
- 조직 관리: https://git.sam.it.kr/-/admin/orgs
**CLI로 사용자 추가:**
```bash
sudo -u git /usr/local/bin/gitea admin user create \
--config /etc/gitea/app.ini \
--username 사용자명 \
--password 비밀번호 \
--email 이메일 \
--admin # 관리자 권한 (선택)
```
### Webhook 설정
각 저장소에 Jenkins Webhook이 설정되어 있다.
| 항목 | 값 |
|------|-----|
| URL | https://ci.sam.it.kr/gitea-webhook/post |
| Content Type | application/json |
| Events | Push Events |
**Webhook 확인/테스트:** 저장소 > Settings > Webhooks
### 개발서버 동기화 (post-receive hook)
개발서버 Gitea에서 CI/CD Gitea로 자동 동기화:
**Hook 위치 (개발서버):** `/data/GIT/samproject/<repo>.git/hooks/post-receive.d/push-to-cicd`
**토큰 파일 (개발서버):** `/data/GIT/.cicd-env` (chmod 600, owner: git)
| 저장소 | 동기화 브랜치 | 비고 |
|--------|-------------|------|
| sam-react-prod | main, develop | post-update hook 비활성화 (CI/CD가 개발서버 배포 담당) |
| sam-api | main | develop은 기존 post-update hook 유지 |
| sam-sales | main | |
| sam-manage | main | 2026-02-24 hook 추가 |
> **참고:** react의 개발서버 배포는 Jenkins CI/CD 파이프라인이 처리한다.
> 기존 post-update hook의 git pull 방식(`pull_react.sh`)은 비활성화됨 (2026-02-24).
> 스크립트 위치: `/home/webservice/script/pull_react.sh`
**동기화 로그 확인:**
```bash
ssh sam-dev "tail -20 /home/webservice/logs/cicd_push_react-prod.log"
ssh sam-dev "tail -20 /home/webservice/logs/cicd_push_api.log"
ssh sam-dev "tail -20 /home/webservice/logs/cicd_push_sales.log"
ssh sam-dev "tail -20 /home/webservice/logs/cicd_push_manage.log"
```
---
## Prometheus
**서비스 제어:**
```bash
sudo systemctl start prometheus
sudo systemctl stop prometheus
sudo systemctl restart prometheus
sudo systemctl status prometheus
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /etc/prometheus/prometheus.yml | 스크래핑 설정 |
| /var/lib/prometheus/ | 시계열 데이터 |
**바인딩:** 127.0.0.1:9090 (외부 접근 차단)
**데이터 보존:** 30일 (--storage.tsdb.retention.time=30d)
**설정 변경 후 적용:**
```bash
promtool check config /etc/prometheus/prometheus.yml # 문법 검사
sudo systemctl restart prometheus
# 또는 설정 리로드 (재시작 없이)
curl -X POST http://localhost:9090/-/reload
```
---
## Grafana
**서비스 제어:**
```bash
sudo systemctl start grafana-server
sudo systemctl stop grafana-server
sudo systemctl restart grafana-server
sudo systemctl status grafana-server
```
**설정 파일:**
| 파일 | 용도 |
|------|------|
| /etc/grafana/grafana.ini | 메인 설정 |
| /var/lib/grafana/ | 대시보드 데이터, 플러그인 |
**주요 설정:**
```ini
[server]
http_port = 3100
domain = monitor.sam.it.kr
[users]
allow_sign_up = false
```
**웹 UI:** https://monitor.sam.it.kr
---
## MySQL (CI/CD)
```bash
sudo systemctl status mysql
sudo systemctl restart mysql
# 접속
mysql # hskwon (auth_socket)
sudo mysql # root (auth_socket)
```
**주요 튜닝 설정:**
```ini
innodb_buffer_pool_size = 1536M
max_connections = 50
slow_query_log = 1
long_query_time = 2
```
**데이터베이스:** gitea (Gitea 데이터)
---
## Nginx (CI/CD)
```bash
sudo nginx -t && sudo systemctl reload nginx # 무중단 리로드
sudo systemctl restart nginx
sudo systemctl status nginx
```
**사이트 설정:**
| 파일 | 서비스 |
|------|--------|
| /etc/nginx/sites-available/git.sam.it.kr | Gitea 리버스 프록시 |
| /etc/nginx/sites-available/ci.sam.it.kr | Jenkins 리버스 프록시 |
| /etc/nginx/sites-available/monitor.sam.it.kr | Grafana 리버스 프록시 |
**git.sam.it.kr 주요 설정:**
```nginx
client_max_body_size 500M; # 대용량 Git push 허용
proxy_request_buffering off; # 스트리밍 전송 (413 방지)
```
---
## node_exporter / Certbot / fail2ban / UFW
운영서버와 동일한 명령어 체계. [운영서버 서비스 관리](./03-service-prod.md) 참조.
**UFW 규칙 (CI/CD):**
| 포트 | 프로토콜 | 용도 |
|------|---------|------|
| 22/tcp | ALLOW | SSH |
| 80/tcp | ALLOW | HTTP |
| 443/tcp | ALLOW | HTTPS |

View File

@@ -0,0 +1,971 @@
# 5. 배포 가이드
[목차로 돌아가기](./README.md)
---
## 파이프라인 개요
### 전체 흐름
```
개발자 push -> 개발서버 Gitea -> post-receive hook -> CI/CD Gitea push
-> Webhook -> Jenkins -> 빌드/배포
```
### 파이프라인 구성
| 저장소 | 파이프라인 | 트리거 브랜치 | 배포 대상 |
|--------|-----------|-------------|----------|
| sam-react-prod | React 빌드+배포 | develop, main | 개발 / Stage→승인→운영 |
| sam-api | Laravel API 배포 | main | Stage→승인→운영 |
| sam-manage | Laravel Admin 배포 | main | 운영 (직접) |
| sam-sales | 레거시 PHP 배포 | main | 운영 (직접) |
### Slack 알림 채널
| 채널 | 용도 | 알림 내용 |
|------|------|----------|
| `#product_infra` | 빌드/배포 상태 | 빌드 시작, 배포 성공/실패 |
| `#product_deploy` | 운영 배포 승인 | Stage 배포 완료 후 승인 대기 알림 (Jenkins 승인 링크 포함) |
### 2-Branch 전략 (develop + main)
> **stage 브랜치 없음.** main 브랜치 push 시 Stage 자동 배포 → Jenkins 승인 → Production 배포.
| 브랜치 | react | api | mng | sales |
|--------|-------|-----|-----|-------|
| develop | Jenkins 빌드 → 개발서버 | 기존 post-update hook | 기존 post-update hook | 기존 post-update hook |
| main | Stage 배포 → **승인** → Production 배포 | Stage 배포 → **승인** → Production 배포 | Production 직접 배포 | Production 직접 배포 |
**main 브랜치 배포 흐름 (react/api):**
1. 개발자가 develop → main 머지 후 push
2. post-receive hook → CI/CD Gitea 자동 push
3. Jenkins 빌드 → Stage 자동 배포
4. `#product_deploy` Slack 채널에 승인 대기 알림 전송
5. Jenkins UI에서 **승인 클릭** → Production 배포 (24시간 타임아웃)
> **동시 빌드 방지:** 모든 파이프라인에 `disableConcurrentBuilds()` 적용.
> 같은 프로젝트에서 빌드가 동시에 2개 이상 돌지 않음.
> 승인 대기 중 새 push 시 → 기존 빌드 Abort 후 새 빌드 자동 시작.
**main 브랜치 배포 흐름 (mng/sales):**
1. 개발자가 main push → hook → CI/CD Gitea → Jenkins → Production 직접 배포
---
## Git 동기화 전략
**방침**: 개발서버 Gitea(origin) 유지 + CI/CD Gitea에 **선택적 브랜치 push** (post-receive hook)
> Gitea Push Mirror는 전체 브랜치를 미러링하므로 사용하지 않음.
> 대신 개발서버 Gitea의 **post-receive hook**으로 필요한 브랜치만 CI/CD Gitea에 push.
```
개발자 로컬
│ git push origin (develop / main)
개발서버 Gitea (114.203.209.83:3000) ← 모든 개발자의 origin
├─ develop push 시
│ ├─ api/mng/sales: 기존 post-update hook (개발서버 pull) ← 현행 유지
│ └─ react: hook → CI/CD Gitea push → Jenkins 빌드 → 개발서버 배포
└─ main push 시
├─ react: hook → CI/CD Gitea → Jenkins 빌드 → Stage 배포 → 승인 → Production 배포
├─ api: hook → CI/CD Gitea → Jenkins → Stage 배포 → 승인 → Production 배포
├─ mng: hook → CI/CD Gitea → Jenkins → Production 직접 배포
└─ sales: hook → CI/CD Gitea → Jenkins → Production 직접 배포
```
### 브랜치별 배포 정책 상세
| 브랜치 | 저장소 | CI/CD Gitea 동기화 | Jenkins 배포 | 배포 대상 |
|--------|--------|-------------------|-------------|----------|
| **main** | react | 자동 (hook) | 빌드 → Stage → **승인** → 재빌드 → Production | Stage + Production |
| **main** | api | 자동 (hook) | rsync → Stage → **승인** → rsync → Production | Stage + Production |
| **main** | mng | 자동 (hook) | rsync + npm build → Production | Production |
| **main** | sales | 자동 (hook) | rsync → Production | Production |
| **develop** | react | 자동 (hook) | 빌드 → 개발서버 배포 | 개발서버 |
| **develop** | api/mng/sales | ❌ (현행 유지) | ❌ | 개발서버 (post-update hook) |
### post-receive hook 동기화 요약
| 저장소 | hook 대상 브랜치 | 동작 |
|--------|-----------------|------|
| sam-react-prod | main, develop | CI/CD Gitea에 push |
| sam-api | main | CI/CD Gitea에 push |
| sam-manage | main | CI/CD Gitea에 push |
| sam-sales | main | CI/CD Gitea에 push |
| sam-landing | main | CI/CD Gitea에 push |
hook 스크립트 경로: `/data/GIT/samproject/<repo>.git/hooks/post-receive.d/push-to-cicd`
토큰 환경변수: `/data/GIT/.cicd-env` (chmod 600, owner: git)
### Webhook 설정 (CI/CD Gitea → Jenkins)
각 저장소에 Webhook 추가 (CI/CD Gitea 웹 UI):
```
Repository Settings → Webhooks → Add Webhook (Gitea)
- URL: https://ci.sam.it.kr/gitea-webhook/post
- Content Type: application/json
- Secret: <webhook_secret>
- Events: Push events
```
---
## 배포 흐름도
```
개발자 로컬
│ git push origin (develop / main)
┌──────────────────────────────────────────────────────────────┐
│ 개발서버 Gitea (114.203.209.83:3000) ← 모든 개발자 origin │
│ │
│ post-receive hooks: │
│ │
│ ┌─ develop push ────────────────────────────────────────┐ │
│ │ react → hook: CI/CD Gitea push ──→ Jenkins 빌드 │ │
│ │ → 빌드 결과 rsync → 개발서버 배포 │ │
│ │ api → 기존 post-update hook (pull + migrate) │ │
│ │ mng → 기존 post-update hook (pull + build) │ │
│ │ sales → 기존 post-update hook (pull) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─ main push (모든 저장소 자동) ────────────────────────┐ │
│ │ react → hook: CI/CD Gitea push ──→ Jenkins │ │
│ │ → Stage 빌드+배포 → 승인 → Production 재빌드 │ │
│ │ api → hook: CI/CD Gitea push ──→ Jenkins │ │
│ │ → Stage rsync+배포 → 승인 → Production 배포 │ │
│ │ mng → hook: CI/CD Gitea push ──→ Jenkins │ │
│ │ → Production rsync + build │ │
│ │ sales → hook: CI/CD Gitea push ──→ Jenkins │ │
│ │ → Production rsync │ │
│ └───────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
┌─ Jenkins 승인 흐름 (react/api main) ─────────────────────────┐
│ │
│ Jenkins 빌드 시작 │
│ │ │
│ ├─ Stage 자동 배포 (react: .env.stage 빌드) │
│ │ │
│ ├─ 📢 #product_deploy Slack 알림 (승인 링크 포함) │
│ │ │
│ ├─ ⏸️ 승인 대기 (24시간 타임아웃) │
│ │ https://ci.sam.it.kr 에서 "운영 배포 진행" 클릭 │
│ │ │
│ ├─ Production 배포 (react: .env.main 재빌드) │
│ │ │
│ └─ 완료 │
│ │
└───────────────────────────────────────────────────────────────┘
```
### 환경별 배포 비교
| 항목 | Production (main→승인) | Stage (main→자동) | 개발 (develop) |
|------|----------------------|------------------|----------------|
| **트리거** | main push → Jenkins 승인 | main push → 자동 | react만 자동 (hook), 나머지 기존 hook |
| **react 전략** | CI/CD 빌드(.env.main) → rsync | CI/CD 빌드(.env.stage) → rsync | CI/CD 빌드(.env.develop) → rsync |
| **api 전략** | rsync + Release 심링크 | rsync + Release 심링크 | 기존 post-update (pull) |
| **mng 전략** | rsync + npm build + Release 심링크 | - | 기존 post-update (pull + build) |
| **롤백** | 이전 릴리즈 심링크 | 이전 릴리즈 심링크 | git revert |
| **릴리즈 보관** | 최근 5개 | 최근 3개 | - |
---
## React (Next.js) 배포
### 자동 배포 흐름
```
CI/CD Gitea push -> Webhook -> Jenkins
-> npm install -> npm run build -> rsync -> PM2 reload
```
**브랜치별 배포 대상:**
| 브랜치 | 배포 단계 | 대상 서버 | 대상 경로 | PM2 이름 |
|--------|----------|----------|----------|----------|
| develop | 개발서버 | 114.203.209.83 | /home/webservice/react/ | sam-react |
| main | Stage (자동) | 211.117.60.189 | /home/webservice/react-stage/releases/ | sam-front-stage |
| main | Production (승인 후) | 211.117.60.189 | /home/webservice/react/releases/ | sam-front |
**환경변수 파일 (CI/CD 서버):** /var/lib/jenkins/env-files/react/
| 파일 | API URL | Frontend URL | APP_ENV | DEV_TOOLBAR |
|------|---------|-------------|---------|-------------|
| .env.develop | https://api.codebridge-x.com | https://dev.codebridge-x.com | development | - |
| .env.stage | https://stage-api.sam.it.kr | https://stage.sam.it.kr | staging | - |
| .env.main | https://api.sam.it.kr | https://sam.it.kr | production | false |
> `NEXT_PUBLIC_APP_ENV` 값으로 타이틀 접두사 결정: `development` → `[D]`, `local` → `[L]`, 그 외 → 없음
**rsync 주의:** trailing slash 사용 금지: `.next` (O), `.next/` (X)
**릴리즈 보관:** 운영 5개, Stage 3개
### Jenkinsfile (react/Jenkinsfile)
```groovy
pipeline {
agent any
options {
disableConcurrentBuilds()
}
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
stage('Prepare Env') {
steps {
script {
if (env.BRANCH_NAME == 'main') {
// main: Stage 빌드 먼저 (승인 후 Production 재빌드)
sh "cp /var/lib/jenkins/env-files/react/.env.stage .env.production"
} else {
def envFile = "/var/lib/jenkins/env-files/react/.env.${env.BRANCH_NAME}"
sh "cp ${envFile} .env.production"
}
}
}
}
stage('Install') {
steps { sh 'npm install --prefer-offline' }
}
stage('Build') {
steps { sh 'npm run build' }
}
// ── develop → 개발서버 배포 ──
stage('Deploy Development') {
when { branch 'develop' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
rsync -az --delete \
--exclude='.git' --exclude='.env*' --exclude='ecosystem.config.*' \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@114.203.209.83:/home/webservice/react/
scp .env.production ${DEPLOY_USER}@114.203.209.83:/home/webservice/react/.env.production
ssh ${DEPLOY_USER}@114.203.209.83 'cd /home/webservice/react && pm2 restart sam-react'
"""
}
}
}
// ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@211.117.60.189 '
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// ── 운영 배포 승인 ──
stage('Production Approval') {
when { branch 'main' }
steps {
slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔔 *react* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage: https://stage.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
timeout(time: 24, unit: 'HOURS') {
input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage: https://stage.sam.it.kr',
ok: '운영 배포 진행'
}
}
}
// ── main → Production 재빌드 (운영 환경변수) ──
stage('Rebuild for Production') {
when { branch 'main' }
steps {
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
sh 'npm run build'
}
}
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@211.117.60.189 '
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front &&
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
}
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
```
> **참고:** Next.js는 `NEXT_PUBLIC_*` 환경변수가 빌드 시 바인딩되므로,
> Stage(.env.stage)와 Production(.env.main)에서 별도 빌드가 필요하다.
> main 빌드 시 Stage용으로 먼저 빌드 → 승인 후 Production용으로 재빌드.
> **환경파일:** Jenkins는 CI/CD 서버의 env-files를 `.env.production`으로 복사하여 빌드한다.
> Next.js 우선순위: `.env.local` > `.env.production` > `.env`
> 따라서 서버에 `.env.local`이 있으면 `.env.production`을 덮어쓰므로 `.env.local`은 사용하지 않는다.
### PM2 수동 재시작
```bash
ssh sam-prod
# 무중단 재시작 (cluster 모드)
pm2 reload sam-front
pm2 status
# 전체 재기동 필요한 경우
pm2 stop sam-front
cd /home/webservice && pm2 start ecosystem.config.js --only sam-front
pm2 save
```
---
## API (Laravel) 배포
### 자동 배포 흐름
```
CI/CD Gitea push -> Webhook -> Jenkins
-> checkout -> rsync → Stage 배포 → 승인 → rsync → Production 배포
```
**브랜치별 배포 대상:**
| 브랜치 | 배포 단계 | 대상 서버 | 대상 경로 |
|--------|----------|----------|----------|
| main | Stage (자동) | 운영서버 | /home/webservice/api-stage/releases/ |
| main | Production (승인 후) | 운영서버 | /home/webservice/api/releases/ |
| develop | 개발서버 | 개발서버 | 기존 post-update hook |
### Jenkinsfile (api/Jenkinsfile)
```groovy
pipeline {
agent any
options {
disableConcurrentBuilds()
}
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
// ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
ln -sfn /home/webservice/api-stage/shared/.env .env &&
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current &&
sudo systemctl reload php8.4-fpm &&
cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// ── 운영 배포 승인 ──
stage('Production Approval') {
when { branch 'main' }
steps {
slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
timeout(time: 24, unit: 'HOURS') {
input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
ok: '운영 배포 진행'
}
}
}
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
ln -sfn /home/webservice/api/shared/.env .env &&
ln -sfn /home/webservice/api/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm &&
sudo supervisorctl restart sam-queue-worker:* &&
cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// develop → Jenkins 관여 안함 (기존 post-update hook 유지)
}
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (env.BRANCH_NAME == 'main') {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 '
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm
' || true
"""
}
}
}
}
}
}
```
> **참고:** Laravel은 런타임 .env를 사용하므로 Stage/Production 별도 빌드가 필요 없다.
> 각 환경의 shared/.env가 심링크로 연결된다.
### 수동 배포 절차 (API Production)
> **참고:** CI/CD Gitea는 `REQUIRE_SIGNIN_VIEW = true` 설정이므로,
> 수동 git clone 시 `https://사용자:비밀번호@git.sam.it.kr/...` 형식 또는
> CI/CD 서버에서 rsync로 전송하는 방식을 사용한다.
```bash
ssh sam-prod
# 1. 새 릴리즈 디렉토리 생성
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/api/releases
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-api.git $RELEASE_ID
# 2. shared 심링크 연결
ln -sfn /home/webservice/api/shared/storage /home/webservice/api/releases/$RELEASE_ID/storage
ln -sfn /home/webservice/api/shared/.env /home/webservice/api/releases/$RELEASE_ID/.env
# 3. 필수 디렉토리 생성 (.gitignore에 의해 누락)
cd /home/webservice/api/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs
# 4. 의존성 설치
composer install --no-dev --optimize-autoloader --no-interaction
# 5. 캐시 생성
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 6. 마이그레이션 (필요시)
php artisan migrate --force
# 7. 심링크 전환 (이 시점에 배포 적용)
ln -sfn /home/webservice/api/releases/$RELEASE_ID /home/webservice/api/current
# 8. 서비스 리로드
sudo systemctl reload php8.4-fpm
sudo supervisorctl restart sam-queue-worker:*
# 9. 오래된 릴리즈 정리 (최근 5개만 유지)
cd /home/webservice/api/releases
ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
```
### 수동 배포 절차 (API Stage)
```bash
ssh sam-prod
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/api-stage/releases
git clone --depth 1 --branch stage https://git.sam.it.kr/SamProject/sam-api.git $RELEASE_ID
ln -sfn /home/webservice/api-stage/shared/storage /home/webservice/api-stage/releases/$RELEASE_ID/storage
ln -sfn /home/webservice/api-stage/shared/.env /home/webservice/api-stage/releases/$RELEASE_ID/.env
cd /home/webservice/api-stage/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs
composer install --no-dev --optimize-autoloader --no-interaction
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
ln -sfn /home/webservice/api-stage/releases/$RELEASE_ID /home/webservice/api-stage/current
sudo systemctl reload php8.4-fpm
# 최근 3개만 유지
cd /home/webservice/api-stage/releases
ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
```
---
## MNG (Laravel Admin) 배포
API와 동일한 releases/shared 구조. 차이점: npm build 추가, Queue Worker 재시작 불필요.
> **참고: storage/logs 심링크 (2026-02-26 변경)**
> MNG는 storage/logs를 shared로 심링크하여 배포 간 로그를 영속 보존한다.
> 이전에는 `mkdir`로 릴리즈 디렉토리에 생성하여 배포마다 로그가 유실되었음.
> 변경: `ln -sfn /home/webservice/mng/shared/storage/logs storage/logs`
### Jenkinsfile (mng/Jenkinsfile)
```groovy
pipeline {
agent any
options {
disableConcurrentBuilds()
}
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *mng* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
// ── main → 운영서버 Production ──
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/mng/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
--exclude='node_modules' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/mng/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/mng/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} &&
ln -sfn /home/webservice/mng/shared/.env .env &&
ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
ln -sfn /home/webservice/mng/shared/storage/logs storage/logs &&
composer install --no-dev --optimize-autoloader --no-interaction &&
npm install --prefer-offline &&
npm run build &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/mng/releases/${RELEASE_ID} /home/webservice/mng/current &&
sudo systemctl reload php8.4-fpm &&
cd /home/webservice/mng/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
'
"""
}
}
}
// develop → Jenkins 관여 안함 (기존 post-update hook 유지)
}
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *mng* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *mng* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (env.BRANCH_NAME == 'main') {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 '
PREV=\$(ls -1dt /home/webservice/mng/releases/*/ | sed -n "2p" | xargs basename) &&
[ -n "\$PREV" ] && ln -sfn /home/webservice/mng/releases/\$PREV /home/webservice/mng/current &&
sudo systemctl reload php8.4-fpm
'
"""
}
}
}
}
}
}
```
### 수동 배포
```bash
ssh sam-prod
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/mng/releases
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-manage.git $RELEASE_ID
ln -sfn /home/webservice/mng/shared/.env /home/webservice/mng/releases/$RELEASE_ID/.env
ln -sfn /home/webservice/mng/shared/storage/app /home/webservice/mng/releases/$RELEASE_ID/storage/app
ln -sfn /home/webservice/mng/shared/storage/logs /home/webservice/mng/releases/$RELEASE_ID/storage/logs
cd /home/webservice/mng/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions}
composer install --no-dev --optimize-autoloader --no-interaction
# Vite 빌드 (Blade + Tailwind)
npm install --prefer-offline
npm run build
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
ln -sfn /home/webservice/mng/releases/$RELEASE_ID /home/webservice/mng/current
sudo systemctl reload php8.4-fpm
# 오래된 릴리즈 정리
cd /home/webservice/mng/releases
ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
```
---
## Sales (Plain PHP) 배포
레거시 PHP 애플리케이션. rsync 기반 배포.
### Jenkinsfile (sales/Jenkinsfile)
```groovy
pipeline {
agent any
environment { DEPLOY_USER = 'hskwon' }
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
rsync -az --delete \
--exclude='.git' --exclude='.env' --exclude='storage' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/sales/
ssh ${DEPLOY_USER}@211.117.60.189 'cd /home/webservice/sales && echo "sales deployed"'
"""
}
}
}
// develop → 개발서버는 기존 post-update hook 유지
}
post {
success { echo '✅ sales 배포 완료 (' + env.BRANCH_NAME + ')' }
failure { echo '❌ sales 배포 실패 (' + env.BRANCH_NAME + ')' }
}
}
```
### 수동 배포
```bash
ssh sam-prod
cd /home/webservice/sales
git pull origin main
```
별도 캐시나 빌드 절차 없음. .env 변경 시에만 주의.
---
## Landing (정적 페이지) 배포
### Jenkinsfile (landing/Jenkinsfile)
```groovy
pipeline {
agent any
environment { DEPLOY_USER = 'hskwon' }
stages {
stage('Deploy Production') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh "ssh ${DEPLOY_USER}@211.117.60.189 'cd /home/webservice/landing && git pull origin main'"
}
}
}
}
}
```
---
## 롤백
### React 롤백
```bash
# 이전 릴리즈 확인
ssh sam-prod "ls -lt /home/webservice/react/releases/"
ssh sam-prod "readlink /home/webservice/react/current"
# 롤백 실행
ssh sam-prod "
PREV=\$(ls -1dt /home/webservice/react/releases/*/ | sed -n '2p' | xargs basename) &&
echo \"롤백 대상: \$PREV\" &&
ln -sfn /home/webservice/react/releases/\$PREV /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front
"
```
### API 롤백
```bash
ssh sam-prod "ls -1dt /home/webservice/api/releases/*/"
ssh sam-prod "
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n '2p' | xargs basename) &&
echo \"롤백 대상: \$PREV\" &&
ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm &&
sudo supervisorctl restart sam-queue-worker:*
"
```
---
## Jenkins 장애 시 수동 배포
### React 수동 배포
```bash
# CI/CD 서버에서 빌드
cd /tmp
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-react-prod.git react-build
cd react-build
cp /var/lib/jenkins/env-files/react/.env.main .env.production
npm install --prefer-offline
npm run build
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
# 운영서버로 전송
ssh sam-prod "mkdir -p /home/webservice/react/releases/${RELEASE_ID}"
rsync -az --delete \
.next package.json next.config.ts public node_modules \
hskwon@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production hskwon@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
# 심링크 전환 및 PM2 재시작
ssh sam-prod "
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front
"
# 빌드 디렉토리 정리
rm -rf /tmp/react-build
```
### API 수동 배포
```bash
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
ssh sam-prod "
cd /home/webservice/api/releases &&
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-api.git ${RELEASE_ID} &&
ln -sfn /home/webservice/api/shared/storage /home/webservice/api/releases/${RELEASE_ID}/storage &&
ln -sfn /home/webservice/api/shared/.env /home/webservice/api/releases/${RELEASE_ID}/.env &&
cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
php artisan migrate --force &&
ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm &&
sudo supervisorctl restart sam-queue-worker:*
"
```
---
## 배포 후 확인 사항
```bash
# 서비스 상태
sudo systemctl status nginx php8.4-fpm
pm2 status
sudo supervisorctl status
# 에러 로그
sudo tail -20 /var/log/nginx/api.sam.it.kr.error.log
sudo tail -20 /home/webservice/api/shared/storage/logs/laravel.log
sudo tail -20 /home/webservice/mng/shared/storage/logs/laravel.log
# HTTP 응답 확인
curl -sI https://api.sam.it.kr
curl -sI https://sam.it.kr
curl -sI https://mng.codebridge-x.com
```
---
## 빌드 아티팩트 관리
```bash
# Jenkins workspace 용량 확인
sudo du -sh /var/lib/jenkins/workspace/*
# 운영서버 릴리즈 정리
ssh sam-prod "cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf"
ssh sam-prod "cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf"
# Jenkins 빌드 보관 정책: Jenkins > Job > Configure > Discard old builds
```
---
## 빌드 실패 조사
```bash
# Jenkins 로그에서 최근 오류
sudo journalctl -u jenkins --since "30 minutes ago" | grep -i error
# Jenkins workspace 확인
ls -la /var/lib/jenkins/workspace/
# 웹 콘솔 로그 (권장)
# https://ci.sam.it.kr/job/<JOB_NAME>/<BUILD_NUMBER>/console
```
**빌드 실패 주요 원인:**
1. npm install 실패 -- node_modules 캐시, 네트워크
2. npm run build 실패 -- TypeScript 오류, 환경변수 누락
3. rsync 실패 -- SSH 키 문제, 디스크 공간 부족
4. composer install 실패 -- 네트워크, PHP 확장 누락
5. SSH 연결 실패 -- known_hosts 변경, 키 만료
6. Laravel `package:discover` 실패 -- `bootstrap/cache/` 디렉토리 누락 (`.gitignore`에 포함)
7. Blade view 캐시 실패 -- `storage/framework/views/` 디렉토리 누락
8. `Target class [request] does not exist` -- CLI 컨텍스트에서 `request()` 호출 (AppServiceProvider 확인)
> **Laravel 배포 필수:** `mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs`를
> `composer install` 전에 실행해야 함. `.gitignore`가 이 디렉토리들을 제외하므로 rsync/git clone 후 생성 필요.

View File

@@ -0,0 +1,203 @@
# 6. 데이터베이스 관리
[목차로 돌아가기](./README.md)
---
## [운영] MySQL 접속
```bash
sudo mysql # root (auth_socket)
mysql -u hskwon # 관리자 (auth_socket, sudo 불필요)
mysql -u codebridge -p sam # 앱 사용자
```
## [CI/CD] MySQL 접속
```bash
mysql # hskwon (auth_socket)
sudo mysql # root (auth_socket)
```
---
## DB 백업
### [운영] 수동 백업
```bash
# sam DB
mysqldump -u hskwon --single-transaction --routines --triggers sam | gzip > /tmp/sam_$(date +%Y%m%d_%H%M%S).sql.gz
# sam_stat DB
mysqldump -u hskwon --single-transaction --routines --triggers sam_stat | gzip > /tmp/sam_stat_$(date +%Y%m%d_%H%M%S).sql.gz
# codebridge DB (Sales)
mysqldump -u hskwon --single-transaction --routines --triggers codebridge | gzip > /tmp/codebridge_$(date +%Y%m%d_%H%M%S).sql.gz
# 전체 DB
mysqldump -u hskwon --single-transaction --routines --triggers --all-databases | gzip > /tmp/all_db_$(date +%Y%m%d_%H%M%S).sql.gz
# 특정 테이블만
mysqldump -u hskwon --single-transaction sam 테이블명 > /tmp/sam_테이블명_$(date +%Y%m%d_%H%M%S).sql
```
### [CI/CD] 자동 백업 (운영 DB)
CI/CD 서버 crontab에서 매일 03:00에 원격 백업 수행. sam_backup 사용자로 운영 DB에 접속.
**스크립트:** /home/hskwon/scripts/backup-db.sh
**저장소:** /home/hskwon/backups/mysql/
**보존:** 14일
```bash
# 수동 원격 백업
ssh sam-prod "mysqldump --single-transaction --routines sam" | gzip \
> /home/hskwon/backups/mysql/sam_production_$(date +%Y%m%d).sql.gz
```
### [CI/CD] Gitea DB 백업
```bash
mysqldump --single-transaction --routines --triggers gitea \
| gzip > /home/hskwon/backups/mysql/gitea_$(date +%Y%m%d_%H%M%S).sql.gz
```
### 백업 파일 외부 전송
```bash
# 운영서버 -> CI/CD 서버
scp /tmp/sam_*.sql.gz sam-cicd:/home/hskwon/backups/mysql/
```
---
## DB 복구
### [운영]
```bash
# 전체 DB 복구
gunzip -c /path/to/sam_백업파일.sql.gz | sudo mysql sam
# 특정 테이블 복구
sudo mysql sam < /path/to/sam_테이블명_백업파일.sql
```
### [CI/CD] Gitea DB 복구
```bash
gunzip -c /home/hskwon/backups/mysql/gitea_YYYYMMDD_HHMMSS.sql.gz | mysql gitea
```
---
## Slow Query 분석 (운영)
```bash
# 로그 직접 확인
sudo tail -100 /var/log/mysql/slow.log
# 요약 분석 (상위 10개, 횟수 기준)
sudo mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 요약 분석 (소요 시간 기준)
sudo mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
```
---
## 자주 사용하는 MySQL 명령어
```sql
-- 현재 프로세스 목록
SHOW PROCESSLIST;
-- 현재 연결 수
SHOW STATUS LIKE 'Threads_connected';
-- 최대 연결 수
SHOW VARIABLES LIKE 'max_connections';
-- InnoDB 상태
SHOW ENGINE INNODB STATUS\G
-- 테이블 크기 확인 (sam DB)
SELECT table_name, ROUND(data_length/1024/1024, 2) AS data_mb,
ROUND(index_length/1024/1024, 2) AS index_mb
FROM information_schema.tables
WHERE table_schema = 'sam'
ORDER BY data_length DESC
LIMIT 20;
-- 실행 중인 쿼리 확인
SELECT id, user, host, db, command, time, state, info
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC;
-- 느린 쿼리 kill
KILL 프로세스_ID;
```
---
## DB 사용자 관리
```sql
-- 사용자 목록
SELECT user, host, plugin FROM mysql.user;
-- 사용자 권한 확인
SHOW GRANTS FOR 'codebridge'@'localhost';
-- 비밀번호 변경
ALTER USER 'codebridge'@'localhost' IDENTIFIED BY '새_비밀번호';
FLUSH PRIVILEGES;
```
---
## Redis 관리 (운영서버)
### 기본 명령
```bash
redis-cli info memory # 메모리 사용량
redis-cli dbsize # 키 개수
redis-cli --bigkeys # 가장 큰 키 확인
redis-cli info keyspace # 키 통계
redis-cli info commandstats | head -20 # 명령어 실행 통계
```
### 캐시 정리
```bash
# Laravel 캐시 삭제 (artisan)
cd /home/webservice/api/current
php artisan cache:clear
# 특정 접두어 키 삭제
redis-cli keys "laravel_cache:*" | xargs redis-cli del
# 전체 초기화 (세션도 삭제됨 - 주의)
redis-cli flushall
```
### 설정 임시 변경
```bash
# maxmemory 임시 증가 (재시작 불필요)
redis-cli config set maxmemory 768mb
# maxmemory 확인
redis-cli config get maxmemory
```
### 실시간 모니터링
```bash
# 실시간 명령어 모니터링 (부하 주의)
redis-cli monitor
# Ctrl+C로 중단
```

View File

@@ -0,0 +1,271 @@
# 7. 모니터링
[목차로 돌아가기](./README.md)
---
## 아키텍처
```
운영서버 (node_exporter:9100) --스크래핑--> CI/CD (Prometheus:9090) --> Grafana:3100
개발서버 (node_exporter:9100) --스크래핑--> CI/CD (Prometheus:9090) --> Grafana:3100
CI/CD (node_exporter:9100) --스크래핑--> CI/CD (Prometheus:9090) --> Grafana:3100
```
- **Grafana 대시보드:** https://monitor.sam.it.kr
- **Prometheus 쿼리:** CI/CD 서버에서 http://localhost:9090
- **운영서버 메트릭:** 운영서버에서 http://localhost:9100/metrics
- **개발서버 메트릭:** 개발서버에서 http://localhost:9100/metrics
---
## Prometheus 스크래핑 설정
**현재 설정 (/etc/prometheus/prometheus.yml):**
```yaml
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'sam-prod'
static_configs:
- targets: ['211.117.60.189:9100']
labels:
server: 'production'
- job_name: 'sam-cicd'
static_configs:
- targets: ['localhost:9100']
labels:
server: 'cicd'
- job_name: 'sam-dev'
static_configs:
- targets: ['114.203.209.83:9100']
labels:
server: 'development'
```
### 스크래핑 대상 추가
```bash
# 1. 대상 서버에 node_exporter 설치 (미설치 시)
# 바이너리: https://github.com/prometheus/node_exporter/releases
# 서비스: /etc/systemd/system/node_exporter.service
# 포트: 9100 (기본)
# 2. 대상 서버 방화벽에서 CI/CD IP 허용
sudo ufw allow from 110.10.147.46 to any port 9100 comment 'Prometheus scraping from CI/CD'
# 3. CI/CD 서버에서 설정 파일 편집
sudo vim /etc/prometheus/prometheus.yml
# 4. 새 대상 추가 예시
# - job_name: 'sam-new'
# static_configs:
# - targets: ['<서버IP>:9100']
# labels:
# server: '<환경명>'
# 5. 문법 검사
promtool check config /etc/prometheus/prometheus.yml
# 6. 서비스 리로드
sudo systemctl restart prometheus
```
### 대상 상태 확인
```bash
curl -s http://localhost:9090/api/v1/targets | python3 -c "
import json, sys
data = json.load(sys.stdin)
for t in data['data']['activeTargets']:
print(f\"{t['labels'].get('job','?'):15} {t['health']:6} {t['scrapeUrl']}\")
"
```
---
## PromQL 쿼리
Prometheus UI (http://localhost:9090) 또는 Grafana에서 사용.
### CPU
```promql
# CPU 사용률 (%) - 서버별
100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# 유휴 CPU 비율 (5분 평균)
rate(node_cpu_seconds_total{mode="idle"}[5m])
```
### 메모리
```promql
# 사용 가능 메모리 비율
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100
# 사용 중인 메모리 (GB)
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 1024 / 1024 / 1024
# 전체 메모리 (GB)
node_memory_MemTotal_bytes / 1024 / 1024 / 1024
```
### 디스크
```promql
# 디스크 사용률 (%)
100 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100)
# 사용 가능 디스크 (GB)
node_filesystem_avail_bytes{mountpoint="/"} / 1024 / 1024 / 1024
# 디스크 I/O (읽기/쓰기 바이트, 5분 평균)
rate(node_disk_read_bytes_total[5m])
rate(node_disk_written_bytes_total[5m])
```
### 네트워크
```promql
# 수신 (bytes/sec, 5분 평균)
rate(node_network_receive_bytes_total{device="eth0"}[5m])
# 전송 (bytes/sec, 5분 평균)
rate(node_network_transmit_bytes_total{device="eth0"}[5m])
```
### 시스템
```promql
# 서버 업타임 (초)
time() - node_boot_time_seconds
# Load Average (1분)
node_load1
# 열린 파일 디스크립터
node_filefd_allocated
```
---
## Grafana 대시보드
**기본 대시보드:** Node Exporter Full (ID: 1860)
**Data Source:** Prometheus (http://localhost:9090)
### 대시보드 추가 (Import)
1. Grafana 웹 > Dashboards > Import
2. Dashboard ID 입력 (예: 1860)
3. Data Source로 Prometheus 선택
4. Import 클릭
### 알림 규칙 설정
**설정 경로:** Grafana > Alerting > Alert rules
**현재 설정된 알림 규칙 (SAM Alerts 폴더):**
| 규칙명 | 조건 | 대기 시간 | 설명 |
|--------|------|-----------|------|
| CPU 사용률 > 90% | avg(rate(node_cpu_idle[5m])) | 5분 | CPU 과부하 |
| 메모리 사용률 > 85% | MemAvailable/MemTotal | 5분 | 메모리 부족 |
| 디스크 사용률 > 80% | filesystem_avail/size (/) | 5분 | 디스크 공간 부족 |
| 서비스 다운 (스크래핑 실패) | up < 1 | 1분 | Prometheus 타겟 다운 |
**알림 채널:** Grafana > Alerting > Contact points 에서 이메일, Slack 등 설정
**현재 설정:** SAM Slack Contact Point (Incoming Webhook) 연결 완료. Notification Policy에서 SAM Alerts 폴더의 알림이 Slack `#product_infra` 채널로 전송됨.
---
## [운영] 성능 모니터링
### 메모리 사용량 분석
```bash
free -h
ps aux --sort=-%mem | head -16
# MySQL 메모리
sudo mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
sudo mysql -e "SHOW STATUS LIKE 'Innodb_buffer_pool_bytes_data';"
# Redis 메모리
redis-cli info memory | grep -E "used_memory_human|maxmemory_human"
# PHP-FPM 프로세스별 메모리
ps -C php-fpm8.4 -o pid,user,%mem,rss,args --sort=-rss
```
### CPU 모니터링
```bash
htop
uptime # 로드 평균 (1분/5분/15분)
ps aux --sort=-%cpu | head -11 # CPU 상위 프로세스
nproc # CPU 코어 수
```
### 디스크 I/O
```bash
df -h
sudo du -sh /home/webservice/*
sudo du -sh /var/log/*
sudo du -sh /var/lib/mysql/*
sudo iostat -x 1 5 # 실시간 I/O
```
### 네트워크
```bash
sudo ss -tlnp # 열린 포트
ss -s # 연결 상태 요약
sudo ss -tn | awk '{print $4}' | grep -oP ':\d+$' | sort | uniq -c | sort -rn | head -10
```
### PHP-FPM Pool 상태
```bash
ps aux | grep "php-fpm" | grep -v grep | wc -l # 프로세스 수
ps aux | grep "php-fpm" | grep -v grep | awk '{print $NF}' | sort | uniq -c # Pool별
sudo grep "max_children" /var/log/php8.4-fpm.log | tail -10 # max_children 도달 여부
```
### MySQL 성능
```bash
# 연결 상태
sudo mysql -e "SHOW STATUS LIKE 'Threads%';"
# Slow Query 요약
sudo mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# InnoDB Buffer Pool 히트율
sudo mysql -e "
SELECT
ROUND((1 - (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_reads') /
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_read_requests')) * 100, 2) AS buffer_pool_hit_rate_pct;
"
# 테이블 락 대기
sudo mysql -e "SHOW STATUS LIKE 'Table_locks%';"
```
### PM2 모니터링
```bash
pm2 status
pm2 monit # 실시간 CPU/메모리
pm2 describe sam-front # 상세 정보
pm2 describe sam-front | grep -A5 "restart" # 재시작 이력
```

View File

@@ -0,0 +1,787 @@
# 8. 장애 대응 가이드
[목차로 돌아가기](./README.md)
---
## 운영서버 장애
### Nginx 502 Bad Gateway
**증상:** 브라우저에서 502 에러. 정적 파일은 정상, 동적 요청만 실패.
**진단:**
```bash
sudo tail -50 /var/log/nginx/api.sam.it.kr.error.log
# "connect() failed" 또는 "no live upstreams" 메시지 확인
# Laravel 사이트인 경우
sudo systemctl status php8.4-fpm
ls -la /run/php/php8.4-fpm-*.sock
# Next.js 사이트인 경우
pm2 status
```
**조치:**
```bash
# PHP-FPM이 죽은 경우
sudo systemctl restart php8.4-fpm
# PM2가 죽은 경우
cd /home/webservice && pm2 start ecosystem.config.js
pm2 save
# Nginx 자체 문제
sudo nginx -t && sudo systemctl restart nginx
```
**예방:** PHP-FPM과 PM2는 자동 재시작 설정됨. 반복 발생 시 메모리 부족을 의심.
---
### Nginx 504 Gateway Timeout
**증상:** 요청이 오래 걸린 후 504 에러. 무거운 API 호출에서 발생.
**진단:**
```bash
sudo tail -50 /var/log/nginx/api.sam.it.kr.error.log
# "upstream timed out" 메시지 확인
sudo tail -50 /var/log/mysql/slow.log
```
**조치:**
```bash
# 장시간 실행 중인 MySQL 쿼리 kill
sudo mysql -e "SHOW PROCESSLIST;" | grep -v Sleep
sudo mysql -e "KILL 프로세스_ID;"
# Nginx timeout 일시적 증가 (필요시)
# /etc/nginx/sites-available/api.sam.it.kr 에서 fastcgi_read_timeout 값 조정
sudo nginx -t && sudo systemctl reload nginx
```
**예방:** 무거운 작업은 Queue로 처리. 현재 fastcgi_read_timeout은 60초.
---
### MySQL 연결 거부 / Too Many Connections
**증상:** "Connection refused" 또는 "Too many connections" 에러.
**진단:**
```bash
sudo systemctl status mysql
sudo mysql -e "SHOW STATUS LIKE 'Threads_connected';"
sudo mysql -e "SHOW VARIABLES LIKE 'max_connections';"
sudo mysql -e "SHOW PROCESSLIST;"
```
**조치:**
```bash
# MySQL이 정지된 경우
sudo systemctl start mysql
# Sleep 연결 정리 (300초 이상 유휴)
sudo mysql -e "SELECT id FROM information_schema.processlist WHERE command='Sleep' AND time > 300;" | while read id; do
[ "$id" != "id" ] && sudo mysql -e "KILL $id;"
done
# 임시로 max_connections 증가 (재시작 없이)
sudo mysql -e "SET GLOBAL max_connections = 150;"
```
**예방:** max_connections(100)은 현재 규모에 적합. 부족 시 sam-tuning.cnf 조정.
---
### [개발] MySQL 8.4 인증 플러그인 오류
**증상:** `SQLSTATE[HY000] [2054] The server requested authentication method unknown to the client`
**원인:** MySQL 8.4에서 `mysql_native_password` 플러그인이 기본 비활성화됨. 레거시 PHP(5130 등)의 mysqlnd가 `caching_sha2_password`를 지원하지 못함.
**조치:**
```bash
# 1. mysqld.cnf에 플러그인 활성화 추가
sudo vi /etc/mysql/mysql.conf.d/mysqld.cnf
# [mysqld] 섹션에 추가:
# mysql_native_password=ON
# 2. MySQL 재시작
sudo systemctl restart mysql
# 3. 레거시 PHP용 계정 인증 방식 변경
mysql -u debian-sys-maint -p'비밀번호' -e "
ALTER USER '계정'@'localhost' IDENTIFIED WITH mysql_native_password BY '비밀번호';
FLUSH PRIVILEGES;"
```
**실제 사례 (2026-02-25):**
1. 5130 레거시 사이트 로그인 시 2054 에러 발생
2. `/etc/mysql/mysql.conf.d/mysqld.cnf``mysql_native_password=ON` 추가 후 MySQL 재시작
3. `codebridge`, `pro`, `chandj` 계정을 `mysql_native_password`로 변경하여 해결
**참고:** debian-sys-maint 비밀번호는 `/etc/mysql/debian.cnf`에서 확인 가능.
---
### .env 권한 오류로 전체 500 에러 (API + MNG)
**증상:** api.sam.it.kr, mng.sam.it.kr 모든 요청에서 500 에러. PHP-FPM, Redis, MySQL 모두 정상.
**원인:** `.env` 파일 권한이 `600`(`-rw-------`)으로 변경되어 PHP-FPM(`www-data`)이 읽지 못함. 모든 환경변수가 null이 되면서 `DB_CONNECTION`, `CACHE_STORE` 등이 기본값(SQLite)로 fallback.
**주요 발생 원인:**
- `vi``.env` 편집 시 파일이 재생성되면서 umask에 따라 `600`으로 변경됨
- 배포 스크립트에서 권한 미설정
**진단:**
```bash
# 1. .env 권한 확인 (640이어야 정상)
ls -la /home/webservice/api/shared/.env
ls -la /home/webservice/mng/shared/.env
# 2. www-data로 읽기 테스트
sudo -u www-data cat /home/webservice/api/shared/.env | head -1
# "Permission denied" → 권한 문제 확인
# 3. artisan으로 config 확인 (CLI는 sudo로 정상 작동)
sudo php /home/webservice/api/current/artisan config:show cache
# default가 database/file이면 .env 미반영 상태
```
**조치:**
```bash
sudo chmod 640 /home/webservice/api/shared/.env
sudo chmod 640 /home/webservice/mng/shared/.env
```
**예방:**
- 서버 계정 `~/.vimrc``set backupcopy=yes` 추가 (vi 편집 시 권한 유지)
- 배포 스크립트에 `chmod 640` 포함
- 자세한 내용: 09-security.md "[운영] .env 파일 보안" 참조
**실제 사례 (2026-03-03):**
1. `.env`에서 `GEMINI_MODEL` 값을 `vi`로 변경
2. `vi`가 파일을 재생성하면서 권한 `600`으로 변경
3. PHP-FPM(`www-data`)이 `.env` 읽기 실패 → 모든 env 값 null
4. `CACHE_STORE=null` → 기본값 `database` → SQLite 연결 시도 → 500 에러
5. `chmod 640`으로 권한 복원하여 즉시 해결
**진단 포인트:**
- CLI(`artisan tinker`)에서는 정상인데 웹만 500 → **파일 권한 문제 의심**
- Laravel 로그에 기록이 없으면 **로그 파일 쓰기 권한도 확인**
---
### Redis 메모리 부족
**증상:** "OOM command not allowed" 메시지.
**진단:**
```bash
redis-cli info memory | grep used_memory_human
redis-cli config get maxmemory
redis-cli dbsize
redis-cli --bigkeys
```
**조치:**
```bash
cd /home/webservice/api/current && php artisan cache:clear
redis-cli keys "laravel_cache:*" | xargs redis-cli del
redis-cli flushall # 전체 초기화 (세션도 삭제 - 주의)
redis-cli config set maxmemory 768mb # 임시 증가
```
**예방:** allkeys-lru 정책 설정됨. 512MB 부족 시 redis.conf에서 maxmemory 조정.
---
### PM2 프로세스 크래시 / 재시작 반복
**증상:** sam.it.kr 접속 불가 또는 간헐적 502. PM2 status에서 restart 횟수 급증.
**진단:**
```bash
pm2 status
pm2 logs sam-front --err --lines 100
pm2 describe sam-front | grep memory
```
**조치:**
```bash
pm2 reload sam-front
# 문제 지속 시 완전 재시작
pm2 stop sam-front
cd /home/webservice && pm2 start ecosystem.config.js --only sam-front
pm2 save
# 로그 파일이 너무 큰 경우
pm2 flush
```
**예방:** max_memory_restart=300M 설정됨. 반복 크래시 시 코드 문제 조사.
---
### Queue Worker 정지 / 미처리
**증상:** 이메일, 알림 등 비동기 작업 미처리.
**진단:**
```bash
sudo supervisorctl status
sudo tail -50 /home/webservice/api/shared/storage/logs/queue-worker.log
cd /home/webservice/api/current && php artisan queue:monitor redis:default
```
**조치:**
```bash
sudo supervisorctl restart sam-queue-worker:*
cd /home/webservice/api/current
php artisan queue:failed # 실패한 작업 확인
php artisan queue:retry all # 실패한 작업 재시도
php artisan queue:flush # 실패한 작업 전체 삭제
```
**예방:** max-time=3600 설정 (1시간마다 자동 재시작). Supervisor가 프로세스 자동 복구.
---
### SSL 인증서 만료
**증상:** 브라우저에서 "연결이 비공개가 아닙니다" 경고.
**진단:**
```bash
sudo certbot certificates
sudo systemctl status certbot.timer
echo | openssl s_client -servername api.sam.it.kr -connect 211.117.60.189:443 2>/dev/null | openssl x509 -noout -dates
```
**조치:**
```bash
sudo certbot renew
sudo certbot certonly --nginx -d api.sam.it.kr # 특정 도메인만
sudo systemctl reload nginx
```
**예방:** certbot.timer 정상 작동 시 만료 30일 전 자동 갱신.
---
### PHP-FPM Pool 소진 (max_children)
**증상:** 응답 지연 후 502. PHP-FPM 로그에 "server reached max_children" 경고.
**진단:**
```bash
sudo grep "max_children" /var/log/php8.4-fpm.log
ps aux | grep "php-fpm" | grep -v grep | wc -l
```
**조치:**
```bash
sudo systemctl restart php8.4-fpm
# max_children 조정 (예: api pool 10 -> 15)
sudo vi /etc/php/8.4/fpm/pool.d/api.conf
sudo systemctl reload php8.4-fpm
```
**예방:** 프로세스당 약 80MB. API pool: 10 x 80MB = 800MB. 메모리 여유 시만 증가.
---
### Laravel Storage 권한 문제
**증상:** "Permission denied". 로그 파일 작성 불가. 파일 업로드 실패.
**진단:**
```bash
ls -la /home/webservice/api/shared/storage/
ls -la /home/webservice/api/shared/storage/logs/
```
**조치:**
```bash
sudo chown -R www-data:webservice /home/webservice/api/shared/storage
sudo chmod -R 775 /home/webservice/api/shared/storage
sudo chown -R www-data:webservice /home/webservice/api/current/bootstrap/cache
sudo chmod -R 775 /home/webservice/api/current/bootstrap/cache
```
**예방:** 배포 스크립트에 권한 설정 포함. shared/storage 심링크 확인.
---
### MNG 500 에러 (storage/logs 권한 + SOAP)
**증상:** mng.codebridge-x.com 특정 페이지에서 500 에러. Laravel 로그에 기록 없음.
**배경:** 2026-02-26 이후 MNG `storage/logs`는 shared로 심링크됨. 이전에는 릴리즈 디렉토리에 직접 생성되어 배포마다 로그가 유실되었음.
**진단 순서:**
```bash
# 1. 로그 심링크 확인
ls -la /home/webservice/mng/current/storage/logs
# → shared/storage/logs 심링크인지 확인
# 2. 로그 파일 소유자 확인
ls -la /home/webservice/mng/shared/storage/logs/laravel.log
# 3. nginx 접근 로그에서 500 확인
sudo tail -20 /var/log/nginx/mng.codebridge-x.com.access.log | grep " 500 "
```
**조치:**
```bash
# 로그 심링크가 아닌 경우 (이전 배포 방식)
rm -rf /home/webservice/mng/current/storage/logs
ln -sfn /home/webservice/mng/shared/storage/logs /home/webservice/mng/current/storage/logs
# shared 로그 권한 수정
sudo chown www-data:webservice /home/webservice/mng/shared/storage/logs/
sudo chown www-data:webservice /home/webservice/mng/shared/storage/logs/laravel.log
# 로그 확인
cat /home/webservice/mng/shared/storage/logs/laravel.log
```
**실제 사례 (2026-02-25):**
1. 최초 증상: `Table 'sam.cache' doesn't exist``CACHE_STORE=database`였으나 cache 테이블 미존재
2. 해결: `.env`에서 `CACHE_STORE=redis`로 변경 + `php artisan config:cache`
3. 여전히 500 → 로그 파일 권한 문제로 에러 미기록 → 권한 수정 후 실제 에러 확인
4. 실제 원인: `Class "SoapClient" not found``php8.4-soap` 미설치
5. 최종 해결: `sudo apt install php8.4-soap && sudo systemctl restart php8.4-fpm`
**교훈:**
- MNG 로그는 `shared/storage/logs/`에 있음 (2026-02-26~)
- 500 에러인데 로그가 비어있으면 **심링크 여부 → 파일 권한** 순서로 확인
- PHP 확장 누락은 artisan tinker로 확인 가능: `php artisan tinker --execute="new SoapClient('test');"`
---
### MNG 전자계약(E-Sign) PDF 서명 합성 오류
**증상:** 전자계약 완료 후 다운로드한 PDF에 서명/도장/텍스트가 적용되지 않음. DB에서 `signed_file_path`가 null.
**진단:**
```bash
# 1. 완료됐지만 signed_file_path 없는 계약 확인
cd /home/webservice/mng/current && php artisan tinker --execute="
\$contracts = App\Models\ESign\EsignContract::withoutGlobalScopes()
->where('status', 'completed')->whereNull('signed_file_path')
->get(['id','tenant_id','status','completed_at']);
echo \$contracts->toJson(JSON_PRETTY_PRINT);
"
# 2. 서명 이미지 파일 존재 확인
sudo ls -la /home/webservice/mng/shared/storage/app/private/esign/*/signatures/
# 3. signed 디렉토리 존재 및 권한 확인
ls -la /home/webservice/mng/shared/storage/app/private/esign/*/signed/
# 4. 로그 확인
grep -i "서명\|esign\|pdf" /home/webservice/mng/shared/storage/logs/laravel.log | tail -20
# 5. 한글 폰트 확인
ls -la /usr/share/fonts/truetype/nanum/NanumGothic.ttf
```
**조치 (수동 PDF 재합성):**
```bash
cd /home/webservice/mng/current && sudo -u www-data php artisan tinker --execute="
try {
\$contract = App\Models\ESign\EsignContract::withoutGlobalScopes()->find(<계약ID>);
\$pdfService = new App\Services\ESign\PdfSignatureService;
\$result = \$pdfService->mergeSignatures(\$contract);
echo 'SUCCESS: ' . \$result;
} catch (\Throwable \$e) {
echo 'ERROR: ' . \$e->getMessage();
}
"
```
**주의:** 반드시 `sudo -u www-data`로 실행해야 서명 이미지 파일 접근 가능.
**주요 원인 및 해결:**
| 원인 | 진단 방법 | 해결 |
|------|----------|------|
| `signed/` 디렉토리 미존재 | `ls esign/*/signed/` | `sudo -u www-data mkdir -p esign/{tenant_id}/signed` |
| `signatures/` 권한 부족 | `stat esign/*/signatures/` | `sudo chmod 2775 esign/*/signatures/` |
| 로그 유실로 에러 추적 불가 | `ls -la current/storage/logs` | `storage/logs` → shared 심링크 확인 |
| 한글 폰트 미설치 | `ls /usr/share/fonts/truetype/nanum/` | `sudo apt install fonts-nanum` |
| FPDI/TCPDF 미설치 | `composer show setasign/fpdi` | `composer install` |
| TCPDF 폰트 정의 파일 오류 | 아래 "TCPDF 폰트 정의 파일 오류" 참고 | `registerKoreanFont()` 코드 수정 |
**esign 디렉토리 권한 기준:**
```bash
# 모든 esign 하위 디렉토리: www-data:webservice 2775
sudo chown -R www-data:webservice /home/webservice/mng/shared/storage/app/private/esign/
sudo chmod -R 2775 /home/webservice/mng/shared/storage/app/private/esign/
```
**실제 사례 (2026-02-26):**
1. 계약 #17이 `completed`인데 `signed_file_path`가 null
2. 원인: `signatures/` 디렉토리 권한 `2700` (www-data만 접근 가능), `signed/` 디렉토리 미존재
3. 추가 원인: `storage/logs`가 릴리즈 디렉토리에 있어 이전 배포 로그 유실
4. 조치: 권한 `2775`로 수정 + `sudo -u www-data`로 수동 재합성 + storage/logs 심링크 적용
5. 결과: 409KB signed PDF 생성 (원본 265KB + 서명 이미지 144KB)
---
### TCPDF 폰트 정의 파일 오류 (font definition file)
**증상:** 전자계약 서명 페이지에서 `TCPDF ERROR: Could not include font definition file: pretendard` (또는 `nanumgothic`) 오류.
**근본 원인:**
운영 환경에서 `vendor/tecnickcom/tcpdf/fonts/` 디렉토리가 배포 사용자(`hskwon`) 소유이므로 PHP-FPM(`www-data`)이 쓰기 불가.
`TCPDF_FONTS::addTTFfont()`는 폰트 캐시 파일(.php, .z, .ctg.z)을 **생성만** 하고,
`$pdf->SetFont('폰트명')``K_PATH_FONTS`(vendor 경로)에서 **찾기만** 해서 경로 불일치 발생.
개발서버는 `vendor/` 권한이 `2775 pro:develop`이라 PHP가 직접 쓸 수 있어 문제없음.
**진단:**
```bash
# 폰트 캐시 존재 확인 (storage에 있으나 vendor에 없는 상태)
ls -la /home/webservice/mng/shared/storage/app/private/fonts/
ls /home/webservice/mng/current/vendor/tecnickcom/tcpdf/fonts/pretendard* 2>/dev/null
# vendor fonts 소유자 확인
stat -c "%U:%G %a" /home/webservice/mng/current/vendor/tecnickcom/tcpdf/fonts/
# 에러 로그 확인
grep -i "font definition\|Could not include" /home/webservice/mng/shared/storage/logs/laravel.log
```
**영구 해결 (코드 수정 - 2026-02-26 적용):**
`PdfSignatureService.php`에서 `registerKoreanFont(Fpdi $pdf)` 메서드로 분리하여:
1. 폰트 캐시를 `storage/app/private/fonts/`에 생성 (vendor 의존 제거)
2. `$pdf->AddFont('pretendard', '', $fontDefFile)` — PDF 인스턴스에 **전체 경로로 등록**
3. 이후 `SetFont('pretendard')`가 이미 등록된 폰트를 사용하므로 K_PATH_FONTS 미참조
**긴급 임시 조치 (코드 수정 전):**
```bash
# vendor 폰트 디렉토리 권한 변경 (배포 시마다 초기화됨)
sudo chown -R www-data:webservice /home/webservice/mng/current/vendor/tecnickcom/tcpdf/fonts/
sudo chmod -R 775 /home/webservice/mng/current/vendor/tecnickcom/tcpdf/fonts/
# 기존 캐시 삭제 (코드 수정 후 새 경로로 재생성)
sudo rm -f /home/webservice/mng/shared/storage/app/private/fonts/pretendard.*
sudo rm -f /home/webservice/mng/shared/storage/app/private/fonts/nanumgothic.*
```
**개발 vs 운영 환경 차이:**
| 항목 | 개발 서버 | 운영 서버 |
|------|----------|----------|
| vendor/ 소유자 | `pro:develop` (2775) | `hskwon:hskwon` (배포 사용자) |
| www-data vendor 쓰기 | ✅ 가능 | ❌ 불가 |
| 폰트 캐시 위치 | vendor 내부 (기본) | storage/app/private/fonts/ |
| `addTTFfont()` 결과 | vendor에 캐시 생성 → SetFont 성공 | storage에 캐시 생성 → SetFont 실패 (경로 불일치) |
---
## 공통 장애
### 디스크 공간 부족
**증상:** 서비스 오류. 로그 기록 실패. MySQL 쓰기 실패.
**진단:**
```bash
df -h
sudo du -sh /var/log/*
```
**[운영] 정리:**
```bash
cd /home/webservice/api/releases && ls -1dt */ | tail -n +4 | xargs rm -rf
cd /home/webservice/react/releases && ls -1dt */ | tail -n +4 | xargs rm -rf
sudo find /var/log -name "*.gz" -mtime +30 -delete
sudo truncate -s 0 /home/webservice/api/shared/storage/logs/laravel.log
pm2 flush
sudo mysql -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY);"
sudo apt clean
```
**[CI/CD] 정리:**
```bash
sudo rm -rf /var/lib/jenkins/workspace/*
sudo find /var/lib/jenkins/jobs/*/builds -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
sudo journalctl --vacuum-size=500M
find /home/hskwon/backups -name "*.sql.gz" -mtime +14 -delete
sudo apt clean && sudo apt autoremove -y
```
---
### 메모리 부족 (OOM)
**증상:** 프로세스 갑자기 종료. dmesg에 "Out of memory" 메시지.
**진단:**
```bash
free -h
sudo dmesg | grep -i "out of memory"
sudo dmesg | grep -i "killed process"
ps aux --sort=-%mem | head -15
```
**[운영] 조치:**
```bash
cd /home/webservice/api/current && php artisan cache:clear
redis-cli flushall
```
**[운영] 메모리 배분:** MySQL 2GB, Redis 512MB, PHP-FPM ~1.5GB, PM2 ~0.75GB, OS ~3GB
**[CI/CD] 조치:**
```bash
# Jenkins JVM 메모리 축소 (긴급)
# override.conf: -Xmx2048m -> -Xmx1536m
sudo systemctl daemon-reload
sudo systemctl restart jenkins
```
---
### 서버 접속 불가 (SSH 타임아웃)
**진단 (로컬에서):**
```bash
ping 서버_IP
nc -zv 서버_IP 22
nc -zv 서버_IP 80
```
**조치:**
- ping 응답 없음: IDC 업체에 서버 상태 확인 요청
- ping 응답, SSH 불가: fail2ban IP 차단 의심. IDC 콘솔 또는 다른 IP에서 접속하여 `sudo fail2ban-client set sshd unbanip 본인_IP`
- 웹은 되나 SSH만 불가: `sudo systemctl restart sshd` (IDC 콘솔)
**예방:** 관리자 IP를 fail2ban whitelist에 추가.
---
### fail2ban 정상 IP 차단
**진단:**
```bash
sudo fail2ban-client status sshd
sudo fail2ban-client get sshd banned | grep 차단의심_IP
```
**조치:**
```bash
sudo fail2ban-client set sshd unbanip 차단된_IP주소
sudo systemctl restart fail2ban # 전체 차단 초기화
```
**예방:**
```bash
# /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 관리자_IP_1 관리자_IP_2
```
---
## CI/CD 서버 장애
### Jenkins 시작 실패
**진단:**
```bash
sudo journalctl -u jenkins --since "10 minutes ago" --no-pager
ps aux | grep java
df -h
free -h
```
**(a) Java Heap 메모리 부족** (로그: `java.lang.OutOfMemoryError: Java heap space`)
```bash
cat /etc/systemd/system/jenkins.service.d/override.conf
# -Xmx 값 조정
sudo systemctl daemon-reload
sudo systemctl restart jenkins
```
**(b) 디스크 공간 부족** (로그: `No space left on device`)
```bash
sudo rm -rf /var/lib/jenkins/workspace/*
sudo find /var/lib/jenkins/jobs/*/builds -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
sudo journalctl --vacuum-size=500M
sudo systemctl restart jenkins
```
**(c) 플러그인 충돌** (업데이트 후 시작 실패, ClassNotFoundException)
```bash
ls -lt /var/lib/jenkins/plugins/*.jpi | head -10
sudo rm /var/lib/jenkins/plugins/문제플러그인.jpi
sudo systemctl restart jenkins
```
---
### Jenkins 빌드 실패
**(a) npm/composer 오류:**
```bash
sudo -u jenkins npm cache clean --force
sudo rm -rf /var/lib/jenkins/workspace/<JOB>/node_modules
```
**(b) SSH 키 문제:** (`Permission denied`, `Host key verification failed`)
```bash
sudo -u jenkins ssh -i /var/lib/jenkins/.ssh/id_ed25519 hskwon@211.117.60.189 "echo OK"
sudo -u jenkins ssh-keyscan -H 211.117.60.189 >> /var/lib/jenkins/.ssh/known_hosts
sudo -u jenkins ssh-keyscan -H 114.203.209.83 >> /var/lib/jenkins/.ssh/known_hosts
```
**(c) rsync 실패:** (`connection unexpectedly closed`)
```bash
ssh sam-prod "df -h"
ssh sam-prod "ls -la /home/webservice/react/"
```
---
### Gitea 접속 불가
**진단:**
```bash
sudo systemctl status gitea
curl -I http://localhost:3000
sudo ss -tlnp | grep 3000
```
**(a) 포트 충돌:**
```bash
sudo fuser 3000/tcp
sudo systemctl restart gitea
```
**(b) DB 연결 실패:**
```bash
sudo systemctl status mysql
mysql -u gitea -p gitea -e "SELECT 1;"
sudo systemctl restart mysql && sudo systemctl restart gitea
```
**(c) 설정 파일 오류:**
```bash
sudo chown git:git /etc/gitea/app.ini
sudo systemctl restart gitea
```
---
### Gitea push/pull 느림
```bash
sudo tail -50 /var/lib/gitea/log/gitea.log
sudo du -sh /var/lib/gitea/data/repositories/SamProject/*
# Git GC (저장소 최적화)
sudo -u git git -C /var/lib/gitea/data/repositories/SamProject/sam-react-prod.git gc --aggressive
sudo systemctl restart gitea
```
---
### Prometheus 스크래핑 실패
**증상:** Grafana에서 데이터 없음.
```bash
sudo systemctl status prometheus
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A5 "health"
promtool check config /etc/prometheus/prometheus.yml
# 대상 서버 연결 확인
curl -s --connect-timeout 5 http://211.117.60.189:9100/metrics | head -5
ssh sam-prod "sudo ufw status | grep 9100"
```
---
### Grafana 대시보드 로딩 실패
```bash
sudo systemctl status grafana-server
curl -I http://localhost:3100
sudo systemctl restart grafana-server
```
---
## 긴급 연락처
| 역할 | 연락처 | 비고 |
|------|--------|------|
| 서버 관리 | hskwon | SSH 접속 가능 |
| IDC 업체 | (확인 후 기입 필요) | 서버 물리적 장애, 네트워크 |

View File

@@ -0,0 +1,244 @@
# 9. 보안 관리
[목차로 돌아가기](./README.md)
---
## SSH 키 관리
양쪽 서버 모두 비밀번호 로그인 비활성화, root SSH 비활성화, 키 인증만 허용.
```bash
# SSH 설정 확인
sudo grep -E "^(PasswordAuthentication|PermitRootLogin|PubkeyAuthentication)" /etc/ssh/sshd_config
# 올바른 설정:
# PasswordAuthentication no
# PermitRootLogin no
# PubkeyAuthentication yes
```
### [운영] 공개키 관리
```bash
cat /home/hskwon/.ssh/authorized_keys
# 새 공개키 추가
echo "새_공개키_내용" >> /home/hskwon/.ssh/authorized_keys
# SSH 설정 변경 후 반드시 재시작
sudo systemctl restart sshd
```
### [CI/CD] 공개키 관리
```bash
cat /home/hskwon/.ssh/authorized_keys
echo "ssh-ed25519 AAAA... user@host" >> /home/hskwon/.ssh/authorized_keys
chmod 600 /home/hskwon/.ssh/authorized_keys
```
### [CI/CD] Jenkins SSH 키
```bash
# 경로: /var/lib/jenkins/.ssh/id_ed25519
# 공개키는 운영서버/개발서버 hskwon authorized_keys에 등록됨
sudo cat /var/lib/jenkins/.ssh/id_ed25519.pub
# 연결 테스트
sudo -u jenkins ssh -i /var/lib/jenkins/.ssh/id_ed25519 hskwon@211.117.60.189 "hostname && date"
sudo -u jenkins ssh -i /var/lib/jenkins/.ssh/id_ed25519 hskwon@114.203.209.83 "hostname && date"
# known_hosts 갱신 (호스트 키 변경 시)
sudo -u jenkins ssh-keygen -R 211.117.60.189
sudo -u jenkins ssh-keyscan -H 211.117.60.189 >> /var/lib/jenkins/.ssh/known_hosts
```
---
## UFW (방화벽) 관리
### [운영] 규칙
| 포트 | 허용 범위 | 용도 |
|------|-----------|------|
| 22 | Anywhere | SSH |
| 80 | Anywhere | HTTP |
| 443 | Anywhere | HTTPS |
| 9100 | 110.10.147.46 only | node_exporter |
| 3306 | 110.10.147.46 only | MySQL 백업 |
### [CI/CD] 규칙
| 포트 | 허용 범위 | 용도 |
|------|-----------|------|
| 22 | Anywhere | SSH |
| 80 | Anywhere | HTTP |
| 443 | Anywhere | HTTPS |
### [개발] 규칙
| 포트 | 허용 범위 | 용도 |
|------|-----------|------|
| 22 | Anywhere | SSH |
| 80 | Anywhere | HTTP |
| 443 | Anywhere | HTTPS |
| 3000 | Anywhere | Gitea |
> MySQL(3306), Apache(8080), Next.js(3001) 등은 외부 차단됨
### 공통 명령어
```bash
# 규칙 확인
sudo ufw status numbered
# 규칙 추가
sudo ufw allow from IP주소 to any port 포트번호
# 규칙 삭제
sudo ufw delete 규칙_번호
# 변경사항은 즉시 적용 (재시작 불필요)
```
**주의:** SSH (22/tcp) 규칙 삭제 금지
```bash
# 변경 전 백업 (CI/CD)
sudo ufw status numbered > /tmp/ufw-backup-$(date +%Y%m%d).txt
```
---
## SSL 인증서 관리
```bash
# 인증서 만료일 전체 확인
sudo certbot certificates
# 자동 갱신 타이머 확인
sudo systemctl status certbot.timer
# 새 도메인 인증서 발급
sudo certbot --nginx -d 새도메인 --email develop@codebridge-x.com
# 수동 갱신
sudo certbot renew
# 인증서 삭제
sudo certbot delete --cert-name 도메인명
```
---
## fail2ban 관리
```bash
# jail 상태 확인
sudo fail2ban-client status
sudo fail2ban-client status sshd
# IP 차단 해제
sudo fail2ban-client set sshd unbanip IP주소
# jail 재시작
sudo fail2ban-client restart sshd
```
### 화이트리스트 설정
**현재 설정:**
| 서버 | ignoreip |
|------|----------|
| 운영 | 127.0.0.1/8, 110.10.147.46 (CI/CD) |
| CI/CD | 127.0.0.1/8, 211.117.60.189 (운영) |
| 개발 | 127.0.0.1/8, 110.10.147.46 (CI/CD), 211.117.60.189 (운영) |
```bash
# /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 110.10.147.46 211.117.60.189
# 변경 후
sudo systemctl restart fail2ban
```
---
## [운영] .env 파일 보안
> **주의:** `.env` 권한은 반드시 `640` (`-rw-r-----`)이어야 합니다.
> PHP-FPM은 `www-data` 사용자(webservice 그룹)로 실행되므로 그룹 읽기 권한이 필요합니다.
> `600`으로 설정하면 PHP-FPM이 .env를 읽지 못해 **전체 서비스 500 에러**가 발생합니다.
> (실제 장애 사례: 2026-03-03, 08-troubleshooting.md 참조)
```bash
# 권한 확인 (640이어야 함 — 소유자 rw + 그룹 r)
ls -la /home/webservice/api/shared/.env
ls -la /home/webservice/mng/shared/.env
ls -la /home/webservice/sales/.env
# 권한 수정
chmod 640 /home/webservice/api/shared/.env
chmod 640 /home/webservice/mng/shared/.env
chmod 640 /home/webservice/sales/.env
```
### vi 편집 시 권한 변경 방지
`vi`로 파일을 편집하면 새 파일로 교체되면서 권한이 `600`으로 초기화될 수 있습니다.
이를 방지하기 위해 서버 계정의 `~/.vimrc`에 아래 설정을 추가합니다:
```bash
# 원본 파일에 직접 덮어쓰기 (권한 유지)
echo "set backupcopy=yes" >> ~/.vimrc
```
**적용 현황 (2026-03-03):**
- sam-prod: hskwon, pro 계정 적용 완료
- sam-cicd: hskwon, pro 계정 적용 완료
---
## [운영] Redis 보안
Redis는 127.0.0.1에만 바인딩되어 외부 접근 불가.
```bash
redis-cli config get bind # "127.0.0.1 ::1"
grep "^bind" /etc/redis/redis.conf
```
---
## [운영] MySQL 사용자 관리
```bash
# 사용자 목록
sudo mysql -e "SELECT user, host, plugin FROM mysql.user;"
# 비밀번호 변경
sudo mysql -e "ALTER USER 'codebridge'@'localhost' IDENTIFIED BY '새_비밀번호'; FLUSH PRIVILEGES;"
# 외부 접근 사용자 확인
sudo mysql -e "SELECT user, host FROM mysql.user WHERE host != 'localhost';"
```
---
## [CI/CD] Jenkins 보안
- Jenkins Credentials에서만 민감 정보 관리
- Jenkinsfile에 직접 비밀번호 기재 금지
- 관리자: hskwon
- 사용자 추가: Jenkins 관리 > Users > Create User
---
## [CI/CD] Gitea 접근 제어
- 회원가입 비활성화 (DISABLE_REGISTRATION = true)
- 로그인 필수 (REQUIRE_SIGNIN_VIEW = true)
- API 토큰 기반 인증
- 사용자 추가: CLI 또는 관리자 웹 UI

View File

@@ -0,0 +1,632 @@
# 10. 백업, 복구, 재부팅
[목차로 돌아가기](./README.md)
---
## [운영] DB 백업
### 수동 백업
```bash
# sam DB
mysqldump -u hskwon --single-transaction --routines --triggers sam | gzip > /tmp/sam_$(date +%Y%m%d_%H%M%S).sql.gz
# sam_stat DB
mysqldump -u hskwon --single-transaction --routines --triggers sam_stat | gzip > /tmp/sam_stat_$(date +%Y%m%d_%H%M%S).sql.gz
# codebridge DB (Sales)
mysqldump -u hskwon --single-transaction --routines --triggers codebridge | gzip > /tmp/codebridge_$(date +%Y%m%d_%H%M%S).sql.gz
# 전체 DB
mysqldump -u hskwon --single-transaction --routines --triggers --all-databases | gzip > /tmp/all_db_$(date +%Y%m%d_%H%M%S).sql.gz
```
### 파일 백업 (업로드, Storage)
```bash
# API storage
tar czf /tmp/api_storage_$(date +%Y%m%d).tar.gz -C /home/webservice/api/shared storage
# MNG storage
tar czf /tmp/mng_storage_$(date +%Y%m%d).tar.gz -C /home/webservice/mng/shared storage
# Sales uploads
tar czf /tmp/sales_uploads_$(date +%Y%m%d).tar.gz -C /home/webservice/sales uploads
# 외부 전송
scp /tmp/*_$(date +%Y%m%d).tar.gz sam-cicd:/home/hskwon/backups/files/
```
### .env 백업
```bash
mkdir -p /tmp/env_backup
cp /home/webservice/api/shared/.env /tmp/env_backup/api.env
cp /home/webservice/mng/shared/.env /tmp/env_backup/mng.env
cp /home/webservice/sales/.env /tmp/env_backup/sales.env
tar czf /tmp/env_backup_$(date +%Y%m%d).tar.gz -C /tmp env_backup
scp /tmp/env_backup_$(date +%Y%m%d).tar.gz sam-cicd:/home/hskwon/backups/env/
rm -rf /tmp/env_backup /tmp/env_backup_*.tar.gz
```
### DB 복구
```bash
# 전체 DB 복구
gunzip -c /path/to/sam_백업파일.sql.gz | sudo mysql sam
# 특정 테이블
sudo mysql sam < /path/to/sam_테이블명_백업파일.sql
```
---
## [CI/CD] Gitea 백업/복구
### 백업
```bash
# 전체 백업 (저장소 + DB + 설정)
sudo mkdir -p /home/hskwon/backups/gitea
sudo -u git /usr/local/bin/gitea dump \
--config /etc/gitea/app.ini \
--tempdir /tmp \
--file /home/hskwon/backups/gitea/gitea-dump-$(date +%Y%m%d).zip
# 저장소만
sudo tar czf /home/hskwon/backups/gitea/repos-$(date +%Y%m%d).tar.gz \
/var/lib/gitea/data/repositories/
# DB만
mysqldump --single-transaction gitea | gzip > /home/hskwon/backups/gitea/gitea-db-$(date +%Y%m%d).sql.gz
```
### 복구
```bash
sudo systemctl stop gitea
cd /tmp
unzip /home/hskwon/backups/gitea/gitea-dump-YYYYMMDD.zip
mysql -u root gitea < gitea-db.sql
sudo rsync -av gitea-repo/ /var/lib/gitea/data/repositories/
sudo chown -R git:git /var/lib/gitea/data/repositories/
sudo cp app.ini /etc/gitea/app.ini
sudo chown git:git /etc/gitea/app.ini
sudo systemctl start gitea
```
---
## [CI/CD] Jenkins 백업/복구
### 백업
```bash
sudo mkdir -p /home/hskwon/backups/jenkins
# Jobs 설정
sudo tar czf /home/hskwon/backups/jenkins/jobs-$(date +%Y%m%d).tar.gz \
-C /var/lib/jenkins jobs/
# Credentials
sudo tar czf /home/hskwon/backups/jenkins/secrets-$(date +%Y%m%d).tar.gz \
-C /var/lib/jenkins secrets/ credentials.xml
# 플러그인 목록
sudo ls /var/lib/jenkins/plugins/*.jpi 2>/dev/null | xargs -I{} basename {} .jpi \
> /home/hskwon/backups/jenkins/plugins-$(date +%Y%m%d).txt
# 환경변수 파일
sudo tar czf /home/hskwon/backups/jenkins/env-files-$(date +%Y%m%d).tar.gz \
-C /var/lib/jenkins env-files/
# SSH 키
sudo tar czf /home/hskwon/backups/jenkins/ssh-$(date +%Y%m%d).tar.gz \
-C /var/lib/jenkins .ssh/
```
### 복구
```bash
sudo systemctl stop jenkins
sudo tar xzf /home/hskwon/backups/jenkins/jobs-YYYYMMDD.tar.gz -C /var/lib/jenkins/
sudo tar xzf /home/hskwon/backups/jenkins/secrets-YYYYMMDD.tar.gz -C /var/lib/jenkins/
sudo tar xzf /home/hskwon/backups/jenkins/env-files-YYYYMMDD.tar.gz -C /var/lib/jenkins/
sudo tar xzf /home/hskwon/backups/jenkins/ssh-YYYYMMDD.tar.gz -C /var/lib/jenkins/
sudo chown -R jenkins:jenkins /var/lib/jenkins/
sudo systemctl start jenkins
```
---
## [CI/CD] Prometheus / Grafana 백업
```bash
# Prometheus 설정 (필수)
sudo cp /etc/prometheus/prometheus.yml /home/hskwon/backups/prometheus-config-$(date +%Y%m%d).yml
# Prometheus 데이터 (선택, 보존 기간 30일)
sudo systemctl stop prometheus
sudo tar czf /home/hskwon/backups/prometheus-data-$(date +%Y%m%d).tar.gz /var/lib/prometheus/
sudo systemctl start prometheus
# Grafana 설정 + 대시보드
sudo mkdir -p /home/hskwon/backups/grafana
sudo cp /etc/grafana/grafana.ini /home/hskwon/backups/grafana/grafana.ini-$(date +%Y%m%d)
sudo tar czf /home/hskwon/backups/grafana/grafana-data-$(date +%Y%m%d).tar.gz /var/lib/grafana/
```
---
## [CI/CD] MySQL 자동 백업 (운영 DB)
### 개요
CI/CD 서버(sam-cicd)에서 운영 서버(sam-prod)의 MySQL DB를 원격으로 백업합니다.
| 항목 | 값 |
|------|-----|
| 스케줄 | **매일 03:00** (crontab) |
| 스크립트 | `/home/hskwon/scripts/backup-db.sh` |
| 인증 정보 | `/home/hskwon/.sam_backup.cnf` (chmod 600) |
| 저장소 | `/home/hskwon/backups/mysql/` |
| 보존 기간 | **14일** (자동 삭제) |
| 로그 | `/home/hskwon/backups/mysql/backup.log` |
### 백업 대상
| DB | 서버 | 사용자 | 크기 (gzip) | 비고 |
|----|------|--------|------------|------|
| gitea | localhost | root (auth_socket) | ~50KB | Gitea DB |
| sam | 211.117.60.189 (운영) | sam_backup | ~9.3MB | 운영 메인 DB (295 테이블) |
| sam_stat | 211.117.60.189 (운영) | sam_backup | ~184KB | 통계 DB (20 테이블) |
### 백업 스크립트
```bash
# /home/hskwon/scripts/backup-db.sh
#!/bin/bash
set -e
BACKUP_DIR="/home/hskwon/backups/mysql"
BACKUP_CNF="/home/hskwon/.sam_backup.cnf"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=14
mkdir -p $BACKUP_DIR
# Gitea DB 백업 (로컬, auth_socket)
mysqldump --single-transaction --routines --triggers gitea | gzip > $BACKUP_DIR/gitea_$DATE.sql.gz
# 운영 DB 원격 백업 (sam_backup 사용자)
if [ -f "$BACKUP_CNF" ]; then
mysqldump --defaults-extra-file=$BACKUP_CNF -h 211.117.60.189 --single-transaction --routines --triggers --no-tablespaces sam | gzip > $BACKUP_DIR/sam_production_$DATE.sql.gz
mysqldump --defaults-extra-file=$BACKUP_CNF -h 211.117.60.189 --single-transaction --routines --triggers --no-tablespaces sam_stat | gzip > $BACKUP_DIR/sam_stat_production_$DATE.sql.gz
echo "$(date '+%Y-%m-%d %H:%M:%S'): Backup completed (gitea + sam + sam_stat)" >> $BACKUP_DIR/backup.log
else
echo "$(date '+%Y-%m-%d %H:%M:%S'): Backup completed (gitea only - $BACKUP_CNF not found)" >> $BACKUP_DIR/backup.log
fi
# 오래된 백업 삭제
find $BACKUP_DIR -name '*.sql.gz' -mtime +$RETENTION_DAYS -delete
```
### 인증 설정
```ini
# /home/hskwon/.sam_backup.cnf (chmod 600)
[client]
user=sam_backup
password=<백업용_비밀번호>
```
### 크론탭 (sam-cicd 서버, hskwon 유저)
```crontab
# SAM DB 백업 (매일 새벽 3시)
0 3 * * * /home/hskwon/scripts/backup-db.sh >> /home/hskwon/backups/mysql/backup.log 2>&1
```
### 수동 실행 및 확인
```bash
# 수동 백업 실행
/home/hskwon/scripts/backup-db.sh
# 백업 파일 확인
ls -lht /home/hskwon/backups/mysql/
# 백업 로그 확인
tail -10 /home/hskwon/backups/mysql/backup.log
# 크론 스케줄 확인
crontab -l
```
### 백업 복원 (CI/CD → 운영)
```bash
# sam DB 복원 (운영 서버에서 실행)
gunzip -c /path/to/sam_production_YYYYMMDD_HHMMSS.sql.gz | mysql -ucodebridge -p sam
# sam_stat DB 복원
gunzip -c /path/to/sam_stat_production_YYYYMMDD_HHMMSS.sql.gz | mysql -ucodebridge -p sam_stat
```
### 운영 MySQL 백업 사용자 (운영 서버 설정)
```sql
-- 운영 서버(sam-prod)에서 실행
CREATE USER 'sam_backup'@'110.10.147.46' IDENTIFIED BY '<백업용_비밀번호>';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON sam.* TO 'sam_backup'@'110.10.147.46';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON sam_stat.* TO 'sam_backup'@'110.10.147.46';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON codebridge.* TO 'sam_backup'@'110.10.147.46';
GRANT REPLICATION SLAVE ON *.* TO 'sam_backup'@'110.10.147.46';
FLUSH PRIVILEGES;
```
UFW에서 CI/CD IP의 MySQL 접근이 허용되어 있어야 합니다:
```bash
# 운영 서버 UFW 규칙 확인
sudo ufw status | grep 3306
# → 110.10.147.46 ALLOW (CI/CD 백업/리플리케이션용)
```
---
## [CI/CD] MySQL 리플리케이션 (운영 → CI/CD)
### 개요
운영 DB의 변경사항을 실시간으로 CI/CD 서버에 동기화합니다. binlog 기반 리플리케이션으로 변경분만 전송되어 네트워크/디스크 부하가 최소화됩니다.
| 항목 | 값 |
|------|-----|
| 방식 | **MySQL Replication** (Source → Replica) |
| Source (운영) | 211.117.60.189, server-id=1 |
| Replica (CI/CD) | 110.10.147.46, server-id=2 |
| 인증 | `sam_backup@110.10.147.46` (REPLICATION SLAVE) |
| 대상 DB | **sam**, **sam_stat**, **codebridge** |
| 제외 DB | gitea (CI/CD 자체 DB, 리플리케이션 영향 없음) |
| 동기화 | 실시간 (Seconds_Behind_Source ≈ 0) |
| CI/CD MySQL | **read_only=OFF** (Gitea DB 쓰기 필요, replicate-do-db로 대상 DB 제한) |
### 아키텍처
```
[운영 서버 211.117.60.189] [CI/CD 서버 110.10.147.46]
MySQL (Source, server-id=1) MySQL (Replica, server-id=2)
┌─────────┐ ┌─────────┐
│ sam │ ── binlog ──────────▶ │ sam │ (read-only)
│ sam_stat│ ── binlog ──────────▶ │ sam_stat│ (read-only)
│codebridge│── binlog ──────────▶ │codebridge│(read-only)
└─────────┘ ├─────────┤
│ gitea │ (독립, read-write)
└─────────┘
```
### CI/CD MySQL 설정
```ini
# /etc/mysql/mysql.conf.d/sam-tuning.cnf (리플리케이션 관련 부분)
[mysqld]
server-id = 2
relay-log = /var/log/mysql/mysql-relay-bin
# read-only = 1 # Gitea DB 쓰기 필요하여 비활성화 (replicate-do-db로 대상 제한)
replicate-do-db = sam
replicate-do-db = sam_stat
replicate-do-db = codebridge
```
### 리플리케이션 상태 확인
```bash
# CI/CD 서버(sam-cicd)에서 실행
mysql -u hskwon -p -e "SHOW REPLICA STATUS\G" | grep -E 'IO_Running|SQL_Running|Behind|Error'
# 정상 상태:
# Replica_IO_Running: Yes
# Replica_SQL_Running: Yes
# Seconds_Behind_Source: 0
# Last_IO_Error: (빈 값)
# Last_SQL_Error: (빈 값)
```
### 리플리케이션 장애 복구
#### IO 스레드 중단 시
```bash
# 에러 확인
mysql -u hskwon -p -e "SHOW REPLICA STATUS\G" | grep -E 'IO_Running|IO_Error'
# 네트워크 문제: 자동 재연결 (Connect_Retry=60, 10회 시도)
# 인증 문제: 운영 서버 sam_backup 유저 확인
# 수동 재시작
mysql -u hskwon -p -e "STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;"
```
#### SQL 스레드 에러 시
```bash
# 에러 확인
mysql -u hskwon -p -e "SHOW REPLICA STATUS\G" | grep -E 'SQL_Running|SQL_Error'
# 특정 에러 건너뛰기 (주의: 데이터 불일치 가능)
mysql -u hskwon -p -e "SET GLOBAL SQL_REPLICA_SKIP_COUNTER = 1; START REPLICA;"
```
#### 전체 재구축 (데이터 불일치 심각 시)
```bash
# 1. CI/CD 리플리케이션 중지
mysql -u hskwon -p -e "STOP REPLICA;"
# 2. 운영에서 새 덤프 생성
ssh sam-prod "mysqldump -u hskwon -p --databases sam sam_stat codebridge \
--source-data=1 --single-transaction --routines --triggers --events \
--set-gtid-purged=OFF | gzip > /tmp/repl_rebuild.sql.gz"
# 3. CI/CD로 전송
scp sam-prod:/tmp/repl_rebuild.sql.gz /tmp/
# 4. CI/CD에서 임포트
zcat /tmp/repl_rebuild.sql.gz | mysql -u hskwon -p
# 5. 덤프 헤더에서 binlog position 확인
zcat /tmp/repl_rebuild.sql.gz | head -30 | grep "CHANGE"
# 6. 리플리케이션 재설정 (position 값은 위 결과로 교체)
mysql -u hskwon -p << 'SQL'
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='211.117.60.189',
SOURCE_USER='sam_backup',
SOURCE_PASSWORD='<백업용_비밀번호>',
SOURCE_LOG_FILE='binlog.XXXXXX',
SOURCE_LOG_POS=XXXXXXXXX,
GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;
SQL
# 7. 임시 파일 정리
ssh sam-prod "rm -f /tmp/repl_rebuild.sql.gz"
rm -f /tmp/repl_rebuild.sql.gz
```
### 주의사항
- CI/CD MySQL은 `read_only=OFF` (Gitea가 같은 MySQL 사용하여 쓰기 필요) → **CI/CD에서 sam/sam_stat/codebridge DB에 직접 쓰기 금지** (replicate-do-db 필터로 리플리케이션 대상만 제한)
- `replicate-do-db` 필터로 gitea DB는 리플리케이션 영향 없음
- 운영 서버 MySQL 8.4는 `caching_sha2_password` 사용 → 리플리케이션 설정 시 `GET_SOURCE_PUBLIC_KEY=1` 필수
- binlog 보존 기간(`binlog_expire_logs_seconds`) 내에 리플리케이션 장애를 복구해야 함, 초과 시 전체 재구축 필요
---
## [운영] sam → sam_stage 동기화
Stage 환경(stage-api.sam.it.kr)은 `sam_stage` DB를 사용합니다. 운영 `sam` DB와 **자동 동기화는 없으며**, 필요 시 수동으로 동기화합니다.
### 스키마만 동기화 (테이블 구조)
운영 DB에 테이블/컬럼 변경이 있을 때 실행합니다.
```bash
# 운영 서버(sam-prod)에서 실행
# 1. sam_stage 초기화
mysql -ucodebridge -p -e "DROP DATABASE IF EXISTS sam_stage; CREATE DATABASE sam_stage CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 2. 스키마 복사 (구조만, 데이터 없음)
mysqldump -ucodebridge -p --single-transaction --no-data --no-tablespaces --skip-triggers --skip-routines sam | mysql -ucodebridge -p sam_stage
# 3. 데이터 복사 (필요시)
mysqldump -ucodebridge -p --single-transaction --no-create-info --no-tablespaces --skip-triggers --skip-routines sam | mysql -ucodebridge -p sam_stage
# 4. Laravel 캐시 갱신
cd /home/webservice/api-stage/current
php artisan config:cache
php artisan cache:clear
```
### 전체 동기화 (스키마 + 데이터)
Stage 환경을 운영과 동일한 상태로 리셋할 때 실행합니다.
```bash
# 운영 서버(sam-prod)에서 실행
mysql -ucodebridge -p -e "DROP DATABASE IF EXISTS sam_stage; CREATE DATABASE sam_stage CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysqldump -ucodebridge -p --single-transaction --no-tablespaces --skip-triggers --skip-routines sam | mysql -ucodebridge -p sam_stage
cd /home/webservice/api-stage/current && php artisan config:cache && php artisan cache:clear
```
### 주의사항
- `codebridge` 사용자에 `SUPER`, `PROCESS` 권한이 없으므로 `--no-tablespaces --skip-triggers --skip-routines` 옵션 필수
- sam_stage의 `.env`는 별도 관리 (`APP_URL=https://stage-api.sam.it.kr`, `APP_ENV=staging`)
- Jenkins 파이프라인(api)의 Stage 배포 시 `php artisan migrate --force`로 스키마 자동 반영
---
## 전체 서버 복구 절차
### [운영] 복구 순서
1. OS 설치: Ubuntu 24.04 + 기본 패키지
2. 보안 설정: SSH 키, UFW, fail2ban
3. MySQL 복구: MySQL 8.4 설치 -> 백업 파일 복원 -> 사용자 재생성
4. Redis 설치: Redis 7.x + 설정
5. PHP-FPM 설치: PHP 8.4 + 확장 + Pool 설정 복원
6. Nginx 설치: Nginx + 사이트 설정 복원 + SSL 재발급
7. Node.js + PM2 설치: Node.js 22 + PM2
8. 애플리케이션 배포: 각 서비스 코드 + .env 복원 + storage 복원
9. Supervisor 설치: Queue Worker 설정
10. 모니터링: node_exporter 설치
상세: [서버 설치 가이드](./11-server-setup.md)
### [CI/CD] 복구 순서
1. OS 기본 셋팅 (UFW, 스왑, 타임존)
2. MySQL 설치 + Gitea DB 복원
3. **MySQL 리플리케이션 설정** (sam-tuning.cnf 복원, 운영 DB 덤프 임포트, CHANGE REPLICATION SOURCE)
4. Java 설치
5. Gitea 설치 + 설정/저장소 복원
6. Jenkins 설치 + jobs/credentials/env-files/SSH 키 복원
7. Nginx 설치 + 사이트 설정 + SSL 인증서 발급
8. Prometheus + node_exporter 설치 + 설정 복원
9. Grafana 설치 + 대시보드 임포트
10. fail2ban 설치
11. Webhook 연결 확인
12. 전체 서비스 동작 검증 (리플리케이션 상태 포함)
상세: [서버 설치 가이드](./11-server-setup.md)
---
## 서버 재부팅 절차
### [운영] 재부팅
**재부팅 전 점검:**
```bash
# 서비스 상태 기록
sudo systemctl status nginx php8.4-fpm mysql redis-server supervisor node_exporter
pm2 status
# 대기 중인 Queue 작업 확인
cd /home/webservice/api/current && php artisan queue:monitor redis:default
# 진행 중인 MySQL 쿼리 확인
sudo mysql -e "SHOW PROCESSLIST;" | grep -v Sleep
# PM2 상태 저장
pm2 save
# 리소스 상태 기록
free -h
df -h
```
**재부팅 실행:** `sudo reboot`
**재부팅 후 확인 (1~2분 후):**
```bash
# 시스템 상태
uptime && free -h && df -h
# 서비스 확인
sudo systemctl status nginx php8.4-fpm mysql redis-server supervisor node_exporter certbot.timer fail2ban
pm2 status
# 포트 확인
sudo ss -tlnp
# 웹 서비스 응답
curl -sI https://sam.it.kr
curl -sI https://api.sam.it.kr
curl -sI https://mng.codebridge-x.com
curl -sI https://sales.codebridge-x.com
curl -sI https://stage.sam.it.kr
curl -sI https://stage-api.sam.it.kr
# Redis / MySQL 연결
redis-cli ping
sudo mysql -e "SELECT 1;"
# Queue Worker
sudo supervisorctl status
# 방화벽
sudo ufw status
```
**서비스 자동 시작 실패 시:**
```bash
sudo systemctl start nginx
sudo systemctl start php8.4-fpm
sudo systemctl start mysql
sudo systemctl start redis-server
sudo systemctl start supervisor
sudo systemctl start node_exporter
sudo systemctl start fail2ban
pm2 resurrect # 저장된 프로세스 복구
# PM2 복구 실패 시
cd /home/webservice && pm2 start ecosystem.config.js && pm2 save
```
**자동 시작 등록 확인:**
```bash
sudo systemctl is-enabled nginx php8.4-fpm mysql redis-server supervisor node_exporter fail2ban
# 등록 안 된 서비스: sudo systemctl enable 서비스명
# PM2는 pm2 startup + pm2 save로 관리
```
---
### [CI/CD] 재부팅
**재부팅 전 점검:**
```bash
# Jenkins 실행 중인 빌드 확인 (웹 UI: https://ci.sam.it.kr)
# Gitea 진행 중인 push 확인
sudo tail -5 /var/lib/gitea/log/gitea.log
# 서비스 상태 기록
sudo systemctl status nginx jenkins gitea mysql prometheus grafana-server node_exporter > /tmp/pre-reboot-status.txt
```
**재부팅 실행:** `sudo reboot`
**재부팅 후 검증:**
```bash
# 서비스 상태
sudo systemctl status nginx jenkins gitea mysql prometheus grafana-server node_exporter
# 포트 확인
sudo ss -tlnp | grep -E '(80|443|3000|3100|8080|9090|9100|3306)'
# 웹 서비스 응답
curl -sI https://ci.sam.it.kr | head -3
curl -sI https://git.sam.it.kr | head -3
curl -sI https://monitor.sam.it.kr | head -3
# 리소스 확인
free -h && df -h
# 모니터링 연결
curl -s http://localhost:9090/api/v1/targets | python3 -c "
import json, sys
data = json.load(sys.stdin)
for t in data['data']['activeTargets']:
print(f\"{t['labels'].get('job','?'):15} {t['health']:6} {t['scrapeUrl']}\")
"
# MySQL 상태
mysql -e "SHOW GLOBAL STATUS LIKE 'Uptime';"
# MySQL 리플리케이션 상태
mysql -u hskwon -p -e "SHOW REPLICA STATUS\G" | grep -E 'IO_Running|SQL_Running|Behind|Error'
# → IO_Running: Yes, SQL_Running: Yes, Seconds_Behind: 0
```
**자동 시작 확인:**
```bash
for svc in nginx jenkins gitea mysql prometheus grafana-server node_exporter fail2ban; do
echo -n "$svc: "
systemctl is-enabled $svc 2>/dev/null || echo "NOT FOUND"
done
# 비활성 서비스: sudo systemctl enable 서비스명
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
# SAM 인프라 운영 매뉴얼
> 작성일: 2026-02-24
> 대상: SAM 프로젝트 운영팀
---
## 서버 현황
| 서버 | IP | SSH 별칭 | 용도 |
|------|-----|---------|------|
| **운영** | 211.117.60.189 | `sam-prod` | 웹 서비스 (7개 도메인) |
| **CI/CD** | 110.10.147.46 | `sam-cicd` | Jenkins, Gitea, 모니터링 |
| **개발** | 114.203.209.83 | `sam-dev` | 개발 환경 |
## 문서 목차
| # | 문서 | 내용 |
|---|------|------|
| 1 | [서버 인프라 개요](./01-server-overview.md) | 서버 사양, 도메인, 서비스 현황, 디렉토리 구조, 설정 경로 |
| 2 | [일상 운영](./02-daily-operations.md) | 상태 확인, 리소스 모니터링, 로그 확인, SSL 인증서 |
| 3 | [운영서버 서비스 관리](./03-service-prod.md) | Nginx, PHP-FPM, MySQL, Redis, PM2, Supervisor 등 |
| 4 | [CI/CD 서비스 관리](./04-service-cicd.md) | Jenkins, Gitea, Prometheus, Grafana 등 |
| 5 | [배포 가이드](./05-deployment.md) | Jenkins 파이프라인, 수동 배포, 롤백, Gitea 연동 |
| 6 | [데이터베이스 관리](./06-database.md) | MySQL, Redis 접속/백업/복구/성능 |
| 7 | [모니터링](./07-monitoring.md) | Prometheus, Grafana, PromQL, 성능 분석 |
| 8 | [장애 대응](./08-troubleshooting.md) | 운영/CI/CD 장애 시나리오별 진단 및 조치 |
| 9 | [보안 관리](./09-security.md) | SSH, UFW, SSL, fail2ban, 접근 제어 |
| 10 | [백업/복구/재부팅](./10-backup-recovery.md) | DB/파일 백업, 서버 복구, 재부팅 절차 |
| 11 | [서버 설치 가이드](./11-server-setup.md) | 운영/CI/CD 서버 설치 절차, 설정 템플릿, 보안 체크리스트 |
## 빠른 접속
```bash
ssh sam-prod # 운영서버
ssh sam-cicd # CI/CD 서버
ssh sam-dev # 개발서버
```
## 관련 문서
- 서버 설치 절차는 [11. 서버 설치 가이드](./11-server-setup.md) 참조