Files
sam-docs/deploys/ops-manual/11-server-setup.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

1275 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 11. 서버 설치 가이드
[목차로 돌아가기](./README.md)
---
## [운영] 설치 순서
| 순서 | 항목 | 의존성 |
|------|------|--------|
| ① | OS 기본 셋팅 (UFW, 스왑, 타임존) | - |
| ② | MySQL 8.4 | ① |
| ③ | Redis 7.x | ① |
| ④ | Nginx + Certbot | ① |
| ⑤ | PHP 8.4 + Composer | ① |
| ⑥ | Supervisor (Queue Worker) | ⑤ |
| ⑦ | Laravel API 배포 (api, api-stage, mng) | ②③⑤ |
| ⑧ | Sales 배포 | ⑤ |
| ⑨ | Node.js 22 + PM2 (react, react-stage) | ① |
| ⑩ | SSL 인증서 (Let's Encrypt) | ④ |
| ⑪ | node_exporter | ① |
| ⑫ | fail2ban | ① |
| ⑬ | 최종 점검 | 전체 |
---
### ① OS 기본 셋팅
```bash
# 시스템 업데이트
sudo apt update && sudo apt upgrade -y
# 기본 패키지
sudo apt install -y curl wget git unzip vim htop net-tools
# 타임존
sudo timedatectl set-timezone Asia/Seoul
# 스왑 4GB 설정
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 스왑 최적화
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
# UFW 방화벽
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow from 110.10.147.46 to any port 9100 # node_exporter (CI/CD만)
sudo ufw allow from 110.10.147.46 to any port 3306 # MySQL (CI/CD 백업용)
sudo ufw enable
# webservice 사용자 그룹 생성
sudo groupadd -f webservice
sudo usermod -aG webservice hskwon
sudo usermod -aG webservice www-data
sudo mkdir -p /home/webservice
sudo chown hskwon:webservice /home/webservice
sudo chmod 2775 /home/webservice # setgid
```
### ② MySQL 8.4
```bash
# mysql-apt-config deb로 repo 등록
sudo wget https://dev.mysql.com/get/mysql-apt-config_0.8.33-1_all.deb
sudo DEBIAN_FRONTEND=noninteractive dpkg -i mysql-apt-config_0.8.33-1_all.deb
# GPG 키 만료 시 — Ubuntu keyserver에서 갱신
sudo gpg --keyserver keyserver.ubuntu.com --recv-keys B7B3B788A8D3785C
sudo gpg --export B7B3B788A8D3785C | sudo tee /usr/share/keyrings/mysql-apt-config.gpg > /dev/null
# 설치
sudo apt update
sudo apt install -y mysql-server
```
**성능 튜닝** (`/etc/mysql/mysql.conf.d/sam-tuning.cnf`):
```ini
[mysqld]
innodb_buffer_pool_size = 2048M
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 2
max_connections = 100
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
validate_password.policy = LOW
```
**DB 및 사용자:**
```sql
-- 데이터베이스 (4개)
CREATE DATABASE sam CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE sam_stage CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE sam_stat CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE codebridge CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 앱 사용자
CREATE USER 'codebridge'@'localhost' IDENTIFIED BY '<비밀번호>';
GRANT ALL PRIVILEGES ON sam.* TO 'codebridge'@'localhost';
GRANT ALL PRIVILEGES ON sam_stage.* TO 'codebridge'@'localhost';
GRANT ALL PRIVILEGES ON sam_stat.* TO 'codebridge'@'localhost';
GRANT ALL PRIVILEGES ON codebridge.* TO 'codebridge'@'localhost';
-- 관리자 (auth_socket)
CREATE USER 'hskwon'@'localhost' IDENTIFIED WITH auth_socket;
GRANT ALL PRIVILEGES ON *.* TO 'hskwon'@'localhost' WITH GRANT OPTION;
-- CI/CD 서버 백업용
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';
FLUSH PRIVILEGES;
```
### ③ Redis 7.x
```bash
sudo apt install -y redis-server
# /etc/redis/redis.conf 설정:
# bind 127.0.0.1 ::1
# maxmemory 512mb
# maxmemory-policy allkeys-lru
# supervised systemd
sudo systemctl enable redis-server
sudo systemctl restart redis-server
redis-cli ping # → PONG
```
### ④ Nginx + Certbot
```bash
sudo apt install -y nginx certbot python3-certbot-nginx
```
**보안 스니펫** (`/etc/nginx/snippets/security.conf`):
```nginx
# 숨김 파일 차단 (.env, .git 등)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# 환경설정/백업/로그 파일 차단
location ~* \.(env|ini|log|conf|bak|sql)$ {
deny all;
access_log off;
log_not_found off;
}
# Composer, Node 패키지 등 민감 파일 차단
location ~* /(composer\.(json|lock)|package\.json|yarn\.lock|phpunit\.xml|artisan|server\.php)$ {
deny all;
access_log off;
log_not_found off;
}
```
**기본 설정** (`/etc/nginx/nginx.conf`):
```nginx
worker_processes auto;
events {
worker_connections 1024;
}
http {
keepalive_timeout 65;
client_max_body_size 50M;
gzip on;
gzip_types text/plain application/json application/javascript text/css;
}
```
### ⑤ PHP 8.4 + Composer
```bash
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install -y \
php8.4-fpm php8.4-mysql php8.4-mbstring php8.4-xml \
php8.4-curl php8.4-zip php8.4-gd php8.4-bcmath \
php8.4-intl php8.4-redis php8.4-opcache
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
```
**PHP-FPM Pool 설정 (4개):**
| Pool | 설정 파일 | 소켓 | max_children |
|------|----------|------|-------------|
| api | /etc/php/8.4/fpm/pool.d/api.conf | php8.4-fpm-api.sock | 10 |
| admin | /etc/php/8.4/fpm/pool.d/admin.conf | php8.4-fpm-admin.sock | 5 |
| sales | /etc/php/8.4/fpm/pool.d/sales.conf | php8.4-fpm-sales.sock | 3 |
| api-stage | /etc/php/8.4/fpm/pool.d/api-stage.conf | php8.4-fpm-api-stage.sock | 3 |
**Pool 설정 템플릿 (api.conf 예시):**
```ini
[api]
user = www-data
group = webservice
listen = /run/php/php8.4-fpm-api.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
php_admin_value[memory_limit] = 128M
php_admin_value[upload_max_filesize] = 50M
php_admin_value[post_max_size] = 50M
php_admin_value[display_errors] = Off
```
```bash
# 기본 pool 제거, 분리된 pool 사용
sudo rm /etc/php/8.4/fpm/pool.d/www.conf
sudo systemctl restart php8.4-fpm
```
### ⑥ Supervisor (Queue Worker)
```bash
sudo apt install -y supervisor
sudo tee /etc/supervisor/conf.d/sam-queue.conf > /dev/null << 'EOF'
[program:sam-queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/webservice/api/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/home/webservice/api/shared/storage/logs/queue-worker.log
stopwaitsecs=3600
EOF
sudo supervisorctl reread
sudo supervisorctl update
```
### ⑦ Laravel 배포 (API / API-Stage / MNG)
**디렉토리 구조 생성:**
```bash
# API 운영
sudo mkdir -p /home/webservice/api/{releases,shared/storage/{app/public,framework/{cache,sessions,views},logs}}
sudo chown -R hskwon:webservice /home/webservice/api
sudo chmod -R 2775 /home/webservice/api
# API Stage
sudo mkdir -p /home/webservice/api-stage/{releases,shared/storage/{app/public,framework/{cache,sessions,views},logs}}
sudo chown -R hskwon:webservice /home/webservice/api-stage
sudo chmod -R 2775 /home/webservice/api-stage
# MNG (Admin)
sudo mkdir -p /home/webservice/mng/{releases,shared/storage/{app/public,framework/{cache,sessions,views},logs}}
sudo chown -R hskwon:webservice /home/webservice/mng
sudo chmod -R 2775 /home/webservice/mng
```
**초기 배포 절차:**
```bash
# shared 심링크 연결
ln -sfn /home/webservice/api/shared/storage /home/webservice/api/current/storage
ln -sfn /home/webservice/api/shared/.env /home/webservice/api/current/.env
# 의존성 설치 + 최적화
cd /home/webservice/api/current
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
```
### ⑧ Sales 배포
```bash
sudo mkdir -p /home/webservice/sales
sudo chown -R hskwon:webservice /home/webservice/sales
cd /home/webservice
git clone <repo_url> sales
cp /home/webservice/sales/.env.example /home/webservice/sales/.env
chmod 600 /home/webservice/sales/.env
chmod 775 /home/webservice/sales/uploads
```
### ⑨ Node.js 22 + PM2
```bash
# Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# PM2 설치
sudo npm install -g pm2
# 운영 + Stage 디렉토리
sudo mkdir -p /home/webservice/react/{releases,shared}
sudo mkdir -p /home/webservice/react-stage/{releases,shared}
sudo chown -R hskwon:webservice /home/webservice/react /home/webservice/react-stage
```
**PM2 설정** (`/home/webservice/ecosystem.config.js`):
```javascript
module.exports = {
apps: [
{
name: 'sam-front',
cwd: '/home/webservice/react/current',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000',
instances: 2,
exec_mode: 'cluster',
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
NODE_OPTIONS: '--max-old-space-size=256'
}
},
{
name: 'sam-front-stage',
cwd: '/home/webservice/react-stage/current',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3100',
instances: 1,
exec_mode: 'fork',
max_memory_restart: '512M',
env: {
NODE_ENV: 'production',
NODE_OPTIONS: '--max-old-space-size=384'
}
}
]
};
```
```bash
cd /home/webservice
pm2 start ecosystem.config.js
pm2 save
pm2 startup
```
### ⑩ SSL 인증서
```bash
# Nginx 사이트 활성화
sudo ln -s /etc/nginx/sites-available/sam.it.kr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api.sam.it.kr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/mng.codebridge-x.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sales.codebridge-x.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/codebridge-x.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/stage.sam.it.kr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/stage-api.sam.it.kr /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# SSL 인증서 발급
sudo certbot --nginx -d sam.it.kr --email develop@codebridge-x.com
sudo certbot --nginx -d api.sam.it.kr --email develop@codebridge-x.com
sudo certbot --nginx -d stage.sam.it.kr --email develop@codebridge-x.com
sudo certbot --nginx -d stage-api.sam.it.kr --email develop@codebridge-x.com
sudo certbot --nginx -d mng.codebridge-x.com --email develop@codebridge-x.com
sudo certbot --nginx -d sales.codebridge-x.com --email develop@codebridge-x.com
sudo certbot --nginx -d codebridge-x.com -d www.codebridge-x.com --email develop@codebridge-x.com
# 자동 갱신 확인
sudo certbot renew --dry-run
```
### ⑪ node_exporter
```bash
cd /tmp
wget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz
tar xzf node_exporter-1.8.2.linux-amd64.tar.gz
sudo mv node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/
rm -rf node_exporter-1.8.2*
sudo tee /etc/systemd/system/node_exporter.service > /dev/null << 'EOF'
[Unit]
Description=Node Exporter
After=network.target
[Service]
User=nobody
ExecStart=/usr/local/bin/node_exporter
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable node_exporter
sudo systemctl start node_exporter
```
### ⑫ fail2ban
```bash
sudo apt install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
```
---
## Nginx 사이트 설정 템플릿
### sam.it.kr (Next.js 운영)
```nginx
upstream nextjs_prod {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name sam.it.kr;
access_log /var/log/nginx/sam.it.kr.access.log;
error_log /var/log/nginx/sam.it.kr.error.log;
location /_next/static/ {
alias /home/webservice/react/current/.next/static/;
expires 365d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://nextjs_prod;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
### api.sam.it.kr (Laravel API)
```nginx
server {
listen 80;
server_name api.sam.it.kr;
root /home/webservice/api/current/public;
index index.php;
access_log /var/log/nginx/api.sam.it.kr.access.log;
error_log /var/log/nginx/api.sam.it.kr.error.log;
include /etc/nginx/snippets/security.conf;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm-api.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60;
}
client_max_body_size 50M;
}
```
### mng.codebridge-x.com (Laravel Admin)
```nginx
server {
listen 80;
server_name mng.codebridge-x.com;
root /home/webservice/mng/current/public;
index index.php;
access_log /var/log/nginx/mng.codebridge-x.com.access.log;
error_log /var/log/nginx/mng.codebridge-x.com.error.log;
include /etc/nginx/snippets/security.conf;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm-admin.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60;
}
client_max_body_size 50M;
}
```
### sales.codebridge-x.com (Plain PHP)
```nginx
server {
listen 80;
server_name sales.codebridge-x.com;
root /home/webservice/sales;
index index.php index.html;
access_log /var/log/nginx/sales.codebridge-x.com.access.log;
error_log /var/log/nginx/sales.codebridge-x.com.error.log;
include /etc/nginx/snippets/security.conf;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm-sales.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60;
}
# uploads PHP 실행 차단 (보안)
location ~* /uploads/.*\.php$ {
deny all;
}
client_max_body_size 50M;
}
```
### stage.sam.it.kr / stage-api.sam.it.kr
stage.sam.it.kr은 sam.it.kr과 동일 구조 (upstream 포트: 3100).
stage-api.sam.it.kr은 api.sam.it.kr과 동일 구조 (소켓: php8.4-fpm-api-stage.sock, root: api-stage).
---
## [CI/CD] 설치 순서
| 순서 | 항목 | 의존성 |
|------|------|--------|
| ① | OS 기본 셋팅 (UFW, 스왑, 타임존) | - |
| ② | MySQL 8.4 | ① |
| ③ | Java 21 (Jenkins 런타임) | ① |
| ④ | Gitea | ② |
| ⑤ | 개발서버 post-receive hook 설정 | ④ |
| ⑥ | Jenkins | ③ |
| ⑦ | Nginx + SSL | ④⑥ |
| ⑧ | Prometheus + node_exporter | ① |
| ⑨ | Grafana | ⑧ |
| ⑩ | Jenkins 파이프라인 + Webhook | ⑥⑦ |
| ⑪ | 백업 자동화 (운영 DB 원격 백업) | ② |
| ⑫ | fail2ban + 최종 점검 | 전체 |
---
### ① OS 기본 셋팅
운영서버와 동일. 차이점:
- UFW: 22, 80, 443만 허용 (9100, 3306 불필요)
- webservice 그룹 생성 (배포 스크립트용)
### ② MySQL 8.4
운영서버와 동일한 설치 방법. 튜닝 차이:
```ini
[mysqld]
innodb_buffer_pool_size = 1536M # 운영(2048M)보다 작음
innodb_redo_log_capacity = 536870912
innodb_flush_log_at_trx_commit = 2
max_connections = 50 # 운영(100)보다 작음
```
**DB 및 사용자:**
```sql
-- Gitea DB
CREATE DATABASE gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'gitea'@'localhost' IDENTIFIED BY '<gitea_비밀번호>';
GRANT ALL PRIVILEGES ON gitea.* TO 'gitea'@'localhost';
-- 관리자
CREATE USER 'hskwon'@'localhost' IDENTIFIED WITH auth_socket;
GRANT ALL PRIVILEGES ON *.* TO 'hskwon'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
```
### ③ Java 21
```bash
sudo apt install -y openjdk-21-jre-headless
java -version
# openjdk version "21.0.x" 확인
# 여러 버전 설치 시 기본 Java 전환
sudo update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java
```
> **참고**: Java 17은 2026-03-31 Jenkins 지원 종료. Java 21 사용 필수.
### ④ Gitea
```bash
GITEA_VERSION="1.22.6"
wget -O /tmp/gitea https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64
sudo mv /tmp/gitea /usr/local/bin/gitea
sudo chmod +x /usr/local/bin/gitea
sudo adduser --system --group --disabled-password --shell /bin/bash --home /home/git git
sudo mkdir -p /var/lib/gitea/{custom,data,log}
sudo mkdir -p /etc/gitea
sudo chown -R git:git /var/lib/gitea
sudo chown git:git /etc/gitea
sudo chmod 750 /etc/gitea
```
**systemd 서비스:**
```ini
# /etc/systemd/system/gitea.service
[Unit]
Description=Gitea (Git with a cup of tea)
After=syslog.target network.target mysql.service
[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
RestartSec=10
[Install]
WantedBy=multi-user.target
```
**Gitea 설정** (`/etc/gitea/app.ini`):
```ini
[server]
DOMAIN = git.sam.it.kr
HTTP_PORT = 3000
ROOT_URL = https://git.sam.it.kr/
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
[database]
DB_TYPE = mysql
HOST = 127.0.0.1:3306
NAME = gitea
USER = gitea
PASSWD = <gitea_비밀번호>
CHARSET = utf8mb4
[repository]
ROOT = /var/lib/gitea/data/repositories
[log]
ROOT_PATH = /var/lib/gitea/log
MODE = file
LEVEL = info
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
```
**초기 설정:**
1. https://git.sam.it.kr 웹 설치 마법사 완료
2. 관리자 계정 생성 (hskwon)
3. Organization "SamProject" 생성
4. 저장소 생성: sam-api, sam-manage, sam-react-prod, sam-sales, sam-landing
5. Jenkins Webhook용 API 토큰 생성
### ⑤ 개발서버 post-receive hook (선택적 브랜치 동기화)
**토큰 보안 파일 (개발서버):**
```bash
# /data/GIT/.cicd-env (chmod 600, owner: git)
CICD_GITEA_TOKEN=<토큰>
CICD_GITEA_USER=hskwon
CICD_GITEA_HOST=git.sam.it.kr
```
**hook 스크립트** (`/data/GIT/samproject/<repo>.git/hooks/post-receive.d/push-to-cicd`):
```bash
#!/bin/bash
source /data/GIT/.cicd-env
LOGFILE=/home/webservice/logs/cicd_push_<repo>.log
CICD_REMOTE="https://${CICD_GITEA_USER}:${CICD_GITEA_TOKEN}@${CICD_GITEA_HOST}/SamProject/<repo>.git"
mkdir -p /home/webservice/logs
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
if [ "$BRANCH" = "<target_branch>" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S'): Pushing ${BRANCH} to CI/CD Gitea" >> $LOGFILE
git push $CICD_REMOTE ${BRANCH}:${BRANCH} >> $LOGFILE 2>&1
echo "$(date '+%Y-%m-%d %H:%M:%S'): Done (exit: $?)" >> $LOGFILE
fi
done
```
**동기화 요약:**
| 저장소 | hook 대상 브랜치 | 동작 |
|--------|-----------------|------|
| sam-react-prod | main, develop | CI/CD Gitea에 push |
| sam-api | main | CI/CD Gitea에 push |
| sam-manage | main | CI/CD Gitea에 push (2026-02-24 추가) |
| sam-sales | main | CI/CD Gitea에 push |
| sam-landing | main | CI/CD Gitea에 push |
### ⑥ Jenkins
```bash
# GPG 키 + APT Repository
sudo gpg --keyserver keyserver.ubuntu.com --recv-keys 7198F4B714ABFC68
sudo gpg --export 7198F4B714ABFC68 | sudo tee /usr/share/keyrings/jenkins-keyring.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.gpg]" \
https://pkg.jenkins.io/debian-stable binary/ | sudo tee \
/etc/apt/sources.list.d/jenkins.list > /dev/null
sudo apt update
sudo apt install -y jenkins
# JVM 메모리 제한
sudo mkdir -p /etc/systemd/system/jenkins.service.d
sudo tee /etc/systemd/system/jenkins.service.d/override.conf > /dev/null << 'EOF'
[Service]
Environment="JAVA_OPTS=-Xmx2048m -Xms512m -Djava.awt.headless=true"
EOF
sudo systemctl daemon-reload
sudo systemctl enable jenkins
sudo systemctl start jenkins
```
**필요 도구 설치:**
```bash
# Node.js 22 (react 빌드용)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# PHP 8.4 + Composer (선택 — Laravel 테스트용)
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install -y php8.4-cli php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip php8.4-mysql php8.4-bcmath
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
```
**SSH 키 설정:**
```bash
sudo -u jenkins ssh-keygen -t ed25519 -C "jenkins@sam-cicd" -f /var/lib/jenkins/.ssh/id_ed25519 -N ""
# 운영/개발 서버에 공개키 등록
ssh sam-prod "echo '$(cat /var/lib/jenkins/.ssh/id_ed25519.pub)' >> /home/hskwon/.ssh/authorized_keys"
ssh sam-dev "echo '$(cat /var/lib/jenkins/.ssh/id_ed25519.pub)' >> /home/hskwon/.ssh/authorized_keys"
# known_hosts 등록
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
```
**Jenkins Credentials:**
- `deploy-ssh-key`: SSH 키 (hskwon@운영/개발 서버 공용)
- `gitea-api-token`: Gitea API 토큰
**분산 빌드 설정 (Built-in Node 보안 격리):**
```bash
# 1. Built-in Node executor를 0으로 변경 (Jenkins 정지 상태에서)
sudo systemctl stop jenkins
sudo sed -i 's|<numExecutors>2</numExecutors>|<numExecutors>0</numExecutors>|' /var/lib/jenkins/config.xml
# Agent 포트 활성화 (0 = 랜덤 포트)
sudo sed -i 's|<slaveAgentPort>-1</slaveAgentPort>|<slaveAgentPort>0</slaveAgentPort>|' /var/lib/jenkins/config.xml
# 2. Agent workspace 디렉토리
sudo mkdir -p /var/lib/jenkins-agent/workspace
sudo chown -R jenkins:jenkins /var/lib/jenkins-agent
# 3. Agent 노드 설정
sudo mkdir -p /var/lib/jenkins/nodes/local-agent
# config.xml 생성 (JNLP WebSocket, executor 2, label: build)
sudo chown -R jenkins:jenkins /var/lib/jenkins/nodes/local-agent
# 4. Jenkins 시작 → Agent secret 확인 (UI 또는 Groovy 스크립트)
sudo systemctl start jenkins
# 5. Agent jar 다운로드
sudo curl -sL http://localhost:8080/jnlpJars/agent.jar -o /var/lib/jenkins-agent/agent.jar
sudo chown jenkins:jenkins /var/lib/jenkins-agent/agent.jar
# 6. Agent systemd 서비스
sudo tee /etc/systemd/system/jenkins-agent.service > /dev/null << 'AGENTEOF'
[Unit]
Description=Jenkins Build Agent
After=network.target jenkins.service
Wants=jenkins.service
[Service]
Type=simple
User=jenkins
Group=jenkins
WorkingDirectory=/var/lib/jenkins-agent
ExecStart=/usr/bin/java -jar /var/lib/jenkins-agent/agent.jar \
-url http://localhost:8080/ \
-secret <AGENT_SECRET> \
-name local-agent \
-workDir /var/lib/jenkins-agent \
-webSocket
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
AGENTEOF
sudo systemctl daemon-reload
sudo systemctl enable jenkins-agent
sudo systemctl start jenkins-agent
```
> **참고**: Agent secret은 Jenkins UI > Manage Jenkins > Nodes > local-agent에서 확인하거나,
> init.groovy.d 스크립트로 추출 가능.
### ⑦ Nginx + SSL (CI/CD)
**리버스 프록시 설정:**
```nginx
# /etc/nginx/sites-available/git.sam.it.kr
server {
listen 80;
server_name git.sam.it.kr;
client_max_body_size 500M;
proxy_request_buffering off;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# /etc/nginx/sites-available/ci.sam.it.kr
server {
listen 80;
server_name ci.sam.it.kr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 90;
proxy_buffering off;
}
}
# /etc/nginx/sites-available/monitor.sam.it.kr
server {
listen 80;
server_name monitor.sam.it.kr;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/git.sam.it.kr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/ci.sam.it.kr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/monitor.sam.it.kr /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d git.sam.it.kr
sudo certbot --nginx -d ci.sam.it.kr
sudo certbot --nginx -d monitor.sam.it.kr
```
### ⑧ Prometheus + node_exporter
```bash
# Prometheus
PROM_VERSION="2.51.0"
cd /tmp
wget https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz
tar xzf prometheus-${PROM_VERSION}.linux-amd64.tar.gz
sudo mv prometheus-${PROM_VERSION}.linux-amd64/prometheus /usr/local/bin/
sudo mv prometheus-${PROM_VERSION}.linux-amd64/promtool /usr/local/bin/
sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo mv prometheus-${PROM_VERSION}.linux-amd64/consoles /etc/prometheus/
sudo mv prometheus-${PROM_VERSION}.linux-amd64/console_libraries /etc/prometheus/
rm -rf prometheus-${PROM_VERSION}*
sudo useradd --no-create-home --shell /bin/false prometheus
sudo chown -R prometheus:prometheus /etc/prometheus /var/lib/prometheus
```
**systemd 서비스:**
```ini
# /etc/systemd/system/prometheus.service
[Unit]
Description=Prometheus
After=network-online.target
[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/usr/local/bin/prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--storage.tsdb.path=/var/lib/prometheus/ \
--storage.tsdb.retention.time=30d \
--web.listen-address=127.0.0.1:9090
Restart=always
[Install]
WantedBy=multi-user.target
```
node_exporter: 운영서버 설치와 동일.
```bash
sudo systemctl daemon-reload
sudo systemctl enable prometheus node_exporter
sudo systemctl start prometheus node_exporter
```
### ⑨ Grafana
```bash
sudo mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt update
sudo apt install -y grafana
```
**설정** (`/etc/grafana/grafana.ini`):
```ini
[server]
http_port = 3100
domain = monitor.sam.it.kr
root_url = https://monitor.sam.it.kr/
[security]
admin_password = <grafana_비밀번호>
[users]
allow_sign_up = false
```
```bash
sudo systemctl enable grafana-server
sudo systemctl start grafana-server
```
**초기 설정:** Data Source: Prometheus (http://localhost:9090) → 대시보드 임포트: Node Exporter Full (ID: 1860)
### ⑪ 백업 자동화 (운영 DB 원격 백업)
CI/CD 서버에서 운영 서버의 MySQL DB를 매일 자동 백업합니다.
**1. 운영 서버 — 백업 사용자 생성 (운영 MySQL에서 실행):**
```sql
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';
FLUSH PRIVILEGES;
```
**2. CI/CD 서버 — MySQL 인증 파일:**
```bash
cat > /home/hskwon/.sam_backup.cnf << 'EOF'
[client]
user=sam_backup
password=<백업용_비밀번호>
EOF
chmod 600 /home/hskwon/.sam_backup.cnf
```
**3. CI/CD 서버 — 백업 스크립트:**
```bash
mkdir -p /home/hskwon/scripts /home/hskwon/backups/mysql
cat > /home/hskwon/scripts/backup-db.sh << 'SCRIPT'
#!/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
SCRIPT
chmod +x /home/hskwon/scripts/backup-db.sh
```
**4. CI/CD 서버 — 크론탭 등록:**
```bash
# hskwon이 crontab 사용 가능해야 함
sudo sh -c "echo hskwon > /etc/cron.allow"
sudo chmod 644 /etc/cron.allow
# 크론 등록 (매일 새벽 3시)
(crontab -l 2>/dev/null; echo "# SAM DB 백업 (매일 새벽 3시)"; echo "0 3 * * * /home/hskwon/scripts/backup-db.sh >> /home/hskwon/backups/mysql/backup.log 2>&1") | crontab -
# 등록 확인
crontab -l
```
**5. 테스트:**
```bash
# 수동 실행
/home/hskwon/scripts/backup-db.sh
# 결과 확인
ls -lht /home/hskwon/backups/mysql/ | head -5
tail -3 /home/hskwon/backups/mysql/backup.log
```
> 상세 복원 절차 및 sam→sam_stage 동기화는 [백업/복구/재부팅](./10-backup-recovery.md) 참조.
### ⑫ fail2ban + 최종 점검
```bash
sudo apt install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
```
**최종 점검:**
```bash
# 전체 서비스 상태
sudo systemctl status nginx jenkins jenkins-agent gitea mysql prometheus grafana-server node_exporter fail2ban
# 포트 확인
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
# 백업 크론 확인
crontab -l
# 자동 시작 등록 확인
for svc in nginx jenkins jenkins-agent gitea mysql prometheus grafana-server node_exporter fail2ban; do
echo -n "$svc: "; systemctl is-enabled $svc 2>/dev/null || echo "NOT FOUND"
done
```
---
## 보안 체크리스트
### [운영]
- [x] SSH 키 인증만 허용 (비밀번호 로그인 비활성화)
- [x] root SSH 로그인 비활성화
- [x] UFW 방화벽 활성화
- [x] MySQL root 원격 접근 차단 (auth_socket)
- [x] MySQL 앱 사용자 최소 권한 (codebridge)
- [x] .env 파일 권한 600 (api, admin, sales)
- [x] storage/ 디렉토리 권한 775
- [x] Nginx security.conf 스니펫 적용
- [x] PHP display_errors = Off (모든 pool)
- [x] Laravel APP_DEBUG=false, APP_ENV=production
- [x] Sales uploads/ PHP 실행 차단
- [x] Certbot 자동 갱신 (7/7 dry-run success)
- [x] fail2ban (SSH 브루트포스 방지)
- [x] Redis bind 127.0.0.1 (외부 접근 차단)
- [x] node_exporter CI/CD IP만 허용 (UFW)
### [CI/CD]
- [x] SSH 키 인증만 허용 (PasswordAuthentication no)
- [x] root SSH 로그인 비활성화 (PermitRootLogin no)
- [x] UFW 방화벽 활성화 (22, 80, 443만)
- [x] Jenkins 관리자 계정 변경 (hskwon)
- [x] Gitea 회원가입 비활성화 (DISABLE_REGISTRATION = true)
- [x] Grafana 익명 접근 비활성화 (allow_sign_up = false)
- [x] Prometheus 외부 접근 차단 (127.0.0.1:9090)
- [x] MySQL root 원격 접근 차단 (auth_socket)
- [x] fail2ban (sshd jail)
- [x] Certbot 자동 갱신
- [x] Jenkins SSH 키 ed25519 + Credential 등록
- [x] Webhook Secret 설정 (Gitea → Jenkins)
- [x] post-receive hook 토큰 보안 (600 권한)
---
## 개발서버 비교 (참고)
| 항목 | 개발서버 | 운영서버 |
|------|----------|---------|
| OS | Ubuntu 24.04.2 | Ubuntu 24.04 (kernel 6.8.0-100) |
| CPU/RAM | 2C / 3.8GB (스왑 없음) | 2C / 8GB + 스왑 4GB |
| PHP | 8.4.15 (+ 5.6, 7.3) | 8.4.18 |
| MySQL | **8.4.8** | **8.4.8** |
| Node.js | 22.17.1 | 22.17.1 |
| Nginx | 1.24.0 | 1.24.0 |
| Redis | - | 7.0.15 (512mb) |
| PHP-FPM | 단일 www pool | 4개 분리 (api/admin/sales/stage) |
| PM2 | fork ×1 (:3001) | cluster ×2 (:3000) + fork ×1 (:3100) |
| Supervisor | - | queue worker ×2 |
| UFW | **비활성** | 활성 |
| fail2ban | - | ✅ |
---
## [개발] PM2 설정
개발서버는 ecosystem.config.js 없이 PM2 CLI로 직접 관리합니다.
```bash
# 실행 (포트 3001, Gitea가 3000 사용)
cd /home/webservice/react && pm2 start npm --name sam-react -- start -- -p 3001
# 재부팅 자동 시작 등록
pm2 save
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u hskwon --hp /home/hskwon
```
| 이름 | 모드 | 포트 | 비고 |
|------|------|------|------|
| sam-react | fork | 3001 | Gitea가 3000 사용, Jenkins 배포 시 자동 restart |
---
## [개발] MySQL 8.0 → 8.4 업그레이드 절차
Ubuntu 24.04 APT 기본은 MySQL 8.0입니다. 8.4로 업그레이드하는 절차:
### 사전 준비
```bash
# 1. DB 백업
DB_PASS=$(grep DB_PASSWORD /home/webservice/mng/.env | head -1 | cut -d= -f2)
for db in sam chandj sam_stat; do
mysqldump -ucodebridge -p$DB_PASS --no-tablespaces --skip-triggers --skip-routines $db | gzip > /tmp/${db}_backup.sql.gz
done
# 2. 인증 방식 변환 (mysql_native_password → caching_sha2_password)
# 8.4에서 mysql_native_password가 deprecated
mysql -u debian-sys-maint -p'<debian-sys-maint_비밀번호>' -e "
ALTER USER 'codebridge'@'localhost' IDENTIFIED WITH caching_sha2_password BY '<비밀번호>';
ALTER USER 'chandj'@'localhost' IDENTIFIED WITH caching_sha2_password BY '<비밀번호>';
FLUSH PRIVILEGES;"
```
> debian-sys-maint 비밀번호: `/etc/mysql/debian.cnf` 참조
### 업그레이드 실행
```bash
# 3. MySQL 중지
sudo systemctl stop mysql
# 4. MySQL APT 레포 추가
wget -q https://dev.mysql.com/get/mysql-apt-config_0.8.33-1_all.deb -O /tmp/mysql-apt-config.deb
sudo DEBIAN_FRONTEND=noninteractive dpkg -i /tmp/mysql-apt-config.deb
# 5. 레포를 8.4-lts로 변경
sudo sed -i 's/mysql-8.0/mysql-8.4-lts/g' /etc/apt/sources.list.d/mysql.list
sudo apt-get update
# 6. 업그레이드 (기존 설정 유지)
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confold" mysql-server mysql-client
# 7. 시작 및 확인
sudo systemctl start mysql
mysql --version # → 8.4.x 확인
```
### GPG 키 만료 시
MySQL APT 레포의 GPG 키가 만료된 경우:
```bash
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys B7B3B788A8D3785C
# 또는 allow-insecure 임시 허용 후 설치
```