Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 88 additions & 14 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,37 +88,111 @@ jobs:
EOF
shell: bash

# 10. Docker Compose로 배포 (PRODUCTION)
- name: Deploy with Docker Compose
# 10. Blue-Green 무중단 배포
- name: Blue-Green Deployment
run: |
# MySQL과 Redis 시작 (이미 실행 중이면 스킵)
docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod

# Spring Boot 운영 앱만 재시작
docker-compose -f docker-compose.prod.yml --env-file .env.prod stop app-prod || true
docker-compose -f docker-compose.prod.yml --env-file .env.prod rm -f app-prod || true
docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d app-prod
# Orphan 컨테이너 정리 (기존 ono-app-prod 등)
echo "Cleaning up orphan containers..."
docker stop ono-app-prod 2>/dev/null || true
docker rm ono-app-prod 2>/dev/null || true
docker stop ono-app 2>/dev/null || true
docker rm ono-app 2>/dev/null || true

# 현재 활성화된 환경 확인 (Blue가 실행 중이면 Green으로 배포, 그 반대도 동일)
BLUE_RUNNING=$(docker ps --filter "name=ono-app-prod-blue" --filter "status=running" -q)
GREEN_RUNNING=$(docker ps --filter "name=ono-app-prod-green" --filter "status=running" -q)

if [ -n "$BLUE_RUNNING" ]; then
# Blue가 실행 중이면 Green으로 배포
CURRENT="blue"
TARGET="green"
CURRENT_PORT=8080
TARGET_PORT=8081
elif [ -n "$GREEN_RUNNING" ]; then
# Green이 실행 중이면 Blue로 배포
CURRENT="green"
TARGET="blue"
CURRENT_PORT=8081
TARGET_PORT=8080
else
# 둘 다 실행 중이지 않으면 Blue로 초기 배포
CURRENT="none"
TARGET="blue"
CURRENT_PORT=0
TARGET_PORT=8080
fi

echo "Current active environment: $CURRENT (port $CURRENT_PORT)"
echo "Deploying to target environment: $TARGET (port $TARGET_PORT)"

# Target 환경에 새 버전 배포
if [ "$TARGET" = "green" ]; then
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile green up -d app-prod-green
else
docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d app-prod-blue
fi

# 헬스체크 대기 (최대 1분)
echo "Waiting for production application to be healthy..."
timeout=60
# 헬스체크 대기 (최대 2분)
echo "Waiting for $TARGET environment to be healthy..."
timeout=120
elapsed=0
healthy=false

while [ $elapsed -lt $timeout ]; do
if docker-compose -f docker-compose.prod.yml ps app-prod | grep -q "healthy"; then
echo "Production application is healthy!"
if docker ps | grep "ono-app-prod-$TARGET" | grep -q "healthy"; then
echo "$TARGET environment is healthy!"
healthy=true
break
fi
echo "Waiting... ($elapsed seconds)"
sleep 5
elapsed=$((elapsed + 5))
done

if [ "$healthy" = false ]; then
echo "ERROR: $TARGET environment failed to become healthy within $timeout seconds"
docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET
exit 1
fi

# Nginx 설정 업데이트 (Blue-Green 전환)
echo "Switching Nginx to $TARGET environment (port $TARGET_PORT)..."
sudo /opt/ono/scripts/switch-nginx.sh $TARGET_PORT

# Nginx 설정 테스트 및 재로드
if sudo /opt/homebrew/bin/nginx -t; then
sudo /opt/homebrew/bin/nginx -s reload
echo "Nginx successfully switched to $TARGET environment"
else
echo "ERROR: Nginx configuration test failed"
# 원래 환경으로 롤백
sudo /opt/ono/scripts/switch-nginx.sh $CURRENT_PORT
exit 1
fi

# 10초 대기 후 이전 환경 종료 (안전을 위한 대기)
echo "Waiting 10 seconds before stopping old environment..."
sleep 10

# 이전 환경 종료 (초기 배포가 아닌 경우만)
if [ "$CURRENT" != "none" ]; then
echo "Stopping old $CURRENT environment..."
docker-compose -f docker-compose.prod.yml stop app-prod-$CURRENT || true
docker-compose -f docker-compose.prod.yml rm -f app-prod-$CURRENT || true
else
echo "Initial deployment - no old environment to stop"
fi

# 컨테이너 상태 확인
echo "=== Current Container Status ==="
docker-compose -f docker-compose.prod.yml ps

# 로그 출력 (마지막 50줄)
echo "=== Production Application Logs ==="
docker-compose -f docker-compose.prod.yml logs --tail=50 app-prod
echo "=== $TARGET Environment Logs ==="
docker-compose -f docker-compose.prod.yml logs --tail=50 app-prod-$TARGET
shell: bash

# 12. 오래된 Docker 이미지 정리
Expand Down Expand Up @@ -149,4 +223,4 @@ jobs:
}]
}"
shell: bash
continue-on-error: true
continue-on-error: true
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies {
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2'
implementation 'org.springframework.boot:spring-boot-starter-quartz'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// Hibernate Core
implementation 'org.hibernate:hibernate-core:6.5.2.Final'
Expand Down
54 changes: 50 additions & 4 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ services:
retries: 5
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru

# Spring Boot Application - PRODUCTION
app-prod:
# Spring Boot Application - PRODUCTION (Blue)
app-prod-blue:
image: ${DOCKER_USERNAME}/ono:prod-latest
container_name: ono-app-prod
container_name: ono-app-prod-blue
restart: unless-stopped
ports:
- "8080:8080" # 운영 서버 포트
- "8080:8080" # Blue 포트
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql-prod:3306/ono_db_prod?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
Expand Down Expand Up @@ -88,6 +88,52 @@ services:
retries: 3
start_period: 60s

# Spring Boot Application - PRODUCTION (Green)
app-prod-green:
image: ${DOCKER_USERNAME}/ono:prod-latest
container_name: ono-app-prod-green
restart: unless-stopped
ports:
- "8081:8080" # Green 포트 (외부 8081 -> 컨테이너 내부 8080)
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql-prod:3306/ono_db_prod?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD}
SPRING_DATA_REDIS_HOST: redis-prod
SPRING_DATA_REDIS_PORT: 6379
# 환경 변수
SPRING_CRYPTO_SECRET_KEY: ${SPRING_CRYPTO_SECRET_KEY}
JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET}
JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION}
JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET}
JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION}
CLOUD_AWS_S3_BUCKET: ${CLOUD_AWS_S3_BUCKET}
CLOUD_AWS_CREDENTIALS_ACCESS_KEY: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY}
CLOUD_AWS_CREDENTIALS_SECRET_KEY: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SENTRY_DSN: ${SENTRY_DSN}
ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
volumes:
- app_prod_logs:/app/logs
depends_on:
mysql-prod:
condition: service_healthy
redis-prod:
condition: service_healthy
networks:
- ono-network
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
profiles:
- green # 기본적으로는 비활성화, 배포 시에만 활성화

networks:
ono-network:
driver: bridge
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti

http.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/", "/robots.txt", "/home","/images/**", "/login", "/css/**", "/js/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/", "/robots.txt", "/home","/images/**", "/login", "/css/**", "/js/**", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/fcm/**").hasAnyRole("GUEST", "MEMBER", "ADMIN")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {

String requestURI = request.getRequestURI();
if(requestURI.startsWith("/actuator/") ||
requestURI.startsWith("/api/auth") ||
requestURI.equals("/") ||
requestURI.equals("/robots.txt") ||
requestURI.equals("/home") ||
requestURI.startsWith("/login") ||
requestURI.startsWith("/swagger-ui") ||
requestURI.startsWith("/v3/api-docs")
) {
return;
}
String errorMessage = (String) request.getAttribute("errorMessage");

if(errorMessage == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final RedisTokenService redisTokenService;

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();

// JWT 필터를 건너뛸 경로들
return path.startsWith("/actuator/") ||
path.startsWith("/api/auth/") ||
path.equals("/") ||
path.equals("/robots.txt") ||
path.equals("/home") ||
path.startsWith("/images/") ||
path.equals("/login") ||
path.startsWith("/css/") ||
path.startsWith("/js/") ||
path.startsWith("/swagger-ui/") ||
path.startsWith("/v3/api-docs/");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
Expand Down