Files
sam-docs/deploys/ops-manual/05-deployment.md
권혁성 7922745bea docs:ops-manual 누락 항목 보강 — DB 동기화, PM2, MySQL 업그레이드
- 10-backup-recovery: 개발→운영 DB 동기화 절차 추가
- 05-deployment: Jenkins env-files에 APP_ENV 컬럼 및 접두사 설명 추가
- 11-server-setup: 개발서버 PM2 설정, MySQL 8.0→8.4 업그레이드 절차 추가
- 11-server-setup: 개발서버 MySQL 버전 8.0.45→8.4.8 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:33:12 +09:00

920 lines
36 KiB
Markdown

# 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 | 운영 (직접) |
### 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. Jenkins UI에서 **승인 클릭** → Production 배포 (24시간 타임아웃)
**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 빌드) │
│ │ │
│ ├─ ⏸️ 승인 대기 (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 |
|------|---------|-------------|---------|
| .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 |
> `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
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
checkout scm
}
}
stage('Prepare Env') {
steps {
script {
if (env.BRANCH_NAME == 'main') {
// main: Stage 빌드 먼저 (승인 후 Production 재빌드)
sh "cp /var/lib/jenkins/env-files/react/.env.stage .env.local"
} else {
def envFile = "/var/lib/jenkins/env-files/react/.env.${env.BRANCH_NAME}"
sh "cp ${envFile} .env.local"
}
}
}
}
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.local ${DEPLOY_USER}@114.203.209.83:/home/webservice/react/.env.local
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.local ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.local
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 {
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.local"
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.local ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.local
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.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
```
> **참고:** Next.js는 `NEXT_PUBLIC_*` 환경변수가 빌드 시 바인딩되므로,
> Stage(.env.stage)와 Production(.env.main)에서 별도 빌드가 필요하다.
> main 빌드 시 Stage용으로 먼저 빌드 → 승인 후 Production용으로 재빌드.
### 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
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
checkout scm
}
}
// ── 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 {
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.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\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 재시작 불필요.
### Jenkinsfile (mng/Jenkinsfile)
```groovy
pipeline {
agent any
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
}
stages {
stage('Checkout') {
steps {
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *mng* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
checkout scm
}
}
// ── 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} storage/logs &&
ln -sfn /home/webservice/mng/shared/.env .env &&
ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
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.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *mng* 배포 실패 (`${env.BRANCH_NAME}`)\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/storage /home/webservice/mng/releases/$RELEASE_ID/storage
ln -sfn /home/webservice/mng/shared/.env /home/webservice/mng/releases/$RELEASE_ID/.env
cd /home/webservice/mng/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs
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.local
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.local hskwon@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.local
# 심링크 전환 및 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
# 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 후 생성 필요.