Refactor Dockerfile and CI configuration for Next.js application #14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: workflow-name # 워크플로 이름 | |
| on: | |
| push: | |
| branches: # 워크플로 대상 브랜치 | |
| # - main | |
| # - test | |
| env: | |
| PROJECT_NAME: my-project # 프로젝트 명 | |
| DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_USERNAME }} | |
| APP_IMAGE_SUFFIX: "-back" | |
| DOMAIN_NAME: "example.com" | |
| BLUE_PORT: "8080" | |
| GREEN_PORT: "8081" | |
| TEST_PORT: "8082" | |
| APP_EXPOSE_PORT: "8080" | |
| MOUNT_DIR: "/volume1/project" | |
| SPRING_PROFILE_MAIN: "prod" | |
| SPRING_PROFILE_TEST: "prod" | |
| NGINX_RP_CONF: "/etc/nginx/sites-enabled/example.conf" | |
| NGINX_BACKUP_DIR: "/volume1/project/nginx/backups" | |
| jobs: | |
| build: | |
| if: ${{ !startsWith(github.event.head_commit.message, 'version(') }} # 특정 커밋 메시지 워크플로 실행 제외 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: 코드 체크아웃 | |
| uses: actions/checkout@v4 | |
| - name: JDK 설정 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| cache: 'gradle' | |
| - name: Gradle Wrapper 실행권한 부여 | |
| run: chmod +x gradlew | |
| # application-prod.yml 파일을 빌드 전에 생성 | |
| - name: Create application-prod.yml from secret | |
| run: | | |
| mkdir -p src/main/resources | |
| cat << 'EOF' > ./src/main/resources/application-prod.yml | |
| ${{ secrets.APPLICATION_PROD_YML }} | |
| EOF | |
| # 브랜치 별 active profile 설정 | |
| - name: Decide active profile | |
| id: profile | |
| run: | | |
| if [ "${GITHUB_REF_NAME}" = "deploy" ]; then | |
| echo "PROFILE=deploy" >> $GITHUB_OUTPUT | |
| else | |
| echo "PROFILE=prod" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Build with Gradle | |
| run: ./gradlew clean build -x test -Dspring.profiles.active=${{ steps.profile.outputs.PROFILE }} | |
| - name: Docker 빌드환경 설정 | |
| uses: docker/setup-buildx-action@v3 | |
| - name: DockerHub 로그인 | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Cache Docker layers | |
| uses: actions/cache@v4 | |
| with: | |
| path: /tmp/.buildx-cache | |
| key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile') }} | |
| restore-keys: | | |
| ${{ runner.os }}-buildx- | |
| - name: Docker 이미지 빌드 및 푸시 | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| push: true | |
| tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}${{ env.APP_IMAGE_SUFFIX }}:${{ github.ref_name }} | |
| cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }}${{ env.APP_IMAGE_SUFFIX }}:cache | |
| cache-to: type=inline | |
| deploy: | |
| needs: build | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Deploy | |
| uses: appleboy/[email protected] | |
| with: | |
| host: ${{ secrets.SERVER_HOST }} | |
| username: ${{ secrets.SERVER_USER }} | |
| password: ${{ secrets.SERVER_PASSWORD }} | |
| port: ${{ secrets.SERVER_PORT }} | |
| script: | | |
| set -e | |
| export PATH=$PATH:/usr/local/bin | |
| echo "[공통]환경변수 설정.." | |
| PW="${{ secrets.SERVER_PASSWORD }}" | |
| BRANCH="${{ github.ref_name }}" | |
| REPO="${{ env.DOCKERHUB_REPO }}" | |
| PROJECT="${{ env.PROJECT_NAME }}" | |
| IMG_SUFFIX="${{ env.APP_IMAGE_SUFFIX }}" | |
| IMAGE="${REPO}/${PROJECT}${IMG_SUFFIX}:${BRANCH}" | |
| CONTAINER_NAME="${PROJECT}${IMG_SUFFIX}" | |
| APP_PORT="${{ env.APP_EXPOSE_PORT }}" | |
| MOUNT_DIR="${{ env.MOUNT_DIR }}" | |
| DOMAIN="${{ env.DOMAIN_NAME }}" | |
| BLUE_PORT="${{ env.BLUE_PORT }}" | |
| GREEN_PORT="${{ env.GREEN_PORT }}" | |
| TEST_PORT="${{ env.TEST_PORT }}" | |
| NGINX_RP_CONF="${{ env.NGINX_RP_CONF }}" | |
| BACKUP_DIR="${{ env.NGINX_BACKUP_DIR }}" | |
| PROFILE_MAIN="${{ env.SPRING_PROFILE_MAIN }}" | |
| PROFILE_TEST="${{ env.SPRING_PROFILE_TEST }}" | |
| echo "브랜치=${BRANCH}" | |
| echo "이미지=${IMAGE}" | |
| echo "도메인=${DOMAIN} | BLUE_PORT=${BLUE_PORT} GREEN_PORT=${GREEN_PORT} | TEST_PORT=${TEST_PORT}" | |
| echo "[도커 풀] ${IMAGE}" | |
| echo "$PW" | sudo -S docker pull "${IMAGE}" | |
| # 테스트 브랜치 | |
| if [ "${BRANCH}" = "test" ]; then | |
| CONTAINER_NAME="${CONTAINER_NAME}-test" | |
| PORT="${TEST_PORT}" | |
| echo "[test] ${CONTAINER_NAME} 을 포트 ${PORT}로 기동" | |
| echo "컨테이너 ${CONTAINER_NAME} 존재 여부 확인 중..." | |
| if sudo docker ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then | |
| echo "컨테이너 ${CONTAINER_NAME} 이(가) 존재합니다. 중지 및 삭제 중..." | |
| echo "$PW" | sudo -S docker rm -f ${CONTAINER_NAME} | |
| echo "컨테이너 ${CONTAINER_NAME} 이(가) 삭제되었습니다." | |
| else | |
| echo "존재하는 컨테이너 ${CONTAINER_NAME} 이(가) 없습니다." | |
| fi | |
| echo "새로운 컨테이너 ${CONTAINER_NAME} 실행 중..." | |
| echo "$PW" | sudo -S docker run -d -p ${PORT}:${APP_PORT} --name ${CONTAINER_NAME} \ | |
| -e TZ=Asia/Seoul \ | |
| -e "SPRING_PROFILES_ACTIVE=${PROFILE_TEST}" \ | |
| -v /etc/localtime:/etc/localtime:ro \ | |
| -v "${MOUNT_DIR}":/app \ | |
| "${IMAGE}" | |
| echo "[test] 헬스체크 (최대 120회, 1초간격)" | |
| for i in $(seq 1 120); do | |
| if curl -fsS "http://127.0.0.1:${PORT}/actuator/health" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/docs/swagger-ui/index.html" >/dev/null 2>&1 ; then | |
| echo "헬스체크 성공 (시도 ${i})" | |
| HEALTH_OK=1 | |
| break | |
| fi | |
| echo "헬스체크 진행중... (시도 ${i}/120)" | |
| sleep 1 | |
| done | |
| if [ "${HEALTH_OK:-0}" != "1" ]; then | |
| echo "[오류] 헬스체크 실패 -> 배포 중단, 로그 출력" | |
| echo "$PW" | sudo -S docker logs --tail=200 ${CONTAINER_NAME} | |
| echo "배포 실패로 인한 컨테이너 정리 중..." | |
| echo "$PW" | sudo -S docker rm -f ${CONTAINER_NAME} || true | |
| exit 1 | |
| fi | |
| # <none> 태그로 남은 이미지 정리 | |
| echo "불필요한 dangling(<none>) 이미지 정리..." | |
| echo "$PW" | sudo -S docker image prune -af | |
| echo "✅ test 서버 배포가 성공적으로 완료되었습니다." | |
| exit 0 | |
| fi | |
| # 메인 브랜치 무중단 배포 | |
| echo "[main] Blue/Green 무중단 배포 시작" | |
| BLUE_CONTAINER_NAME="${CONTAINER_NAME}-blue" | |
| GREEN_CONTAINER_NAME="${CONTAINER_NAME}-green" | |
| echo "[main] nginx 백업 디렉터리 생성: ${BACKUP_DIR}" | |
| echo "${PW}" | sudo -S mkdir -p "${BACKUP_DIR}" | |
| echo "[main] nginx 설정 파일 백업 생성 (${NGINX_RP_CONF})" | |
| BACKUP_FILE="${BACKUP_DIR}/server.ReverseProxy.conf.$(date +%Y%m%d_%H%M%S).bak" | |
| echo "$PW" | sudo -S cp "${NGINX_RP_CONF}" "${BACKUP_FILE}" | |
| echo "[main] nginx 설정 백업 파일 생성 완료: ${BACKUP_FILE}" | |
| echo "[main] DSM 리버스프록시 설정에서 현재 활성 포트 찾기 (${NGINX_RP_CONF})" | |
| ACTIVE_PORT=$( | |
| printf '%s\n' "$PW" | sudo -S awk -v dom="${DOMAIN}" ' | |
| BEGIN { | |
| inserver=0; depth=0; entry_depth=0; | |
| # 모든 정규식 메타문자 이스케이프(방탄) | |
| dom_esc = dom; | |
| gsub(/[.[\]()+*?^$\\|]/, "\\\\&", dom_esc); | |
| } | |
| { | |
| raw = $0; | |
| # 주석 제거 후 중괄호 추적(주석 내 { } 무시) | |
| line = raw; | |
| sub(/#.*/, "", line); | |
| depth += gsub(/\{/, "{", line); | |
| depth -= gsub(/\}/, "}", line); | |
| # server_name 라인에서 토큰 단위로 정확 매칭 (여러 도메인 한 줄 대응) | |
| if (!inserver && line ~ /^[[:space:]]*server_name[[:space:]]+/) { | |
| tmp = line; | |
| sub(/^[[:space:]]*server_name[[:space:]]+/, "", tmp); | |
| sub(/[[:space:]]*;.*/, "", tmp); # 세미콜론 이후 제거 | |
| if (match(tmp, "(^|[[:space:]])" dom_esc "([[:space:]]|$)")) { | |
| inserver=1; entry_depth=depth; | |
| # 필요 시, 443 블록만 우선하고 싶다면 여기서 flag 달고 listen 443 본 뒤에만 추출하도록 확장 가능 | |
| } | |
| } | |
| # 타겟 server 블록 내부의 proxy_pass에서 localhost/127.0.0.1:포트 추출 | |
| if (inserver && line ~ /proxy_pass[[:space:]]+https?:\/\/(localhost|127\.0\.0\.1):[0-9]{2,5}/) { | |
| if (match(line, /https?:\/\/(localhost|127\.0\.0\.1):([0-9]{2,5})/, a)) { print a[2]; exit } | |
| } | |
| # server 블록 종료(일반/단일라인 모두 대응) | |
| if (inserver && (depth < entry_depth || (depth==0 && entry_depth==0))) inserver=0; | |
| } | |
| ' "${NGINX_RP_CONF}" || true | |
| ) | |
| if [ -z "${ACTIVE_PORT}" ]; then | |
| echo "[경고] 활성 포트를 찾지 못했으므로 기본값 (${BLUE_PORT})을 활성으로 간주" | |
| ACTIVE_PORT="${BLUE_PORT}" | |
| fi | |
| echo "현재 활성 포트: ${ACTIVE_PORT}" | |
| if [ "${ACTIVE_PORT}" = "${BLUE_PORT}" ]; then | |
| PORT="${GREEN_PORT}" | |
| CONTAINER_NAME="${GREEN_CONTAINER_NAME}" | |
| else | |
| PORT="${BLUE_PORT}" | |
| CONTAINER_NAME="${BLUE_CONTAINER_NAME}" | |
| fi | |
| echo "비활성 포트로 신규 배포: ${CONTAINER_NAME} (포트: ${PORT})" | |
| echo "[main] 컨테이너 ${CONTAINER_NAME} 존재 여부 확인 중..." | |
| if sudo docker ps -a --format '{{.Names}}' | grep -Eq "^${CONTAINER_NAME}\$"; then | |
| echo "컨테이너 ${CONTAINER_NAME} 이(가) 존재합니다. 중지 및 삭제 중..." | |
| echo "$PW" | sudo -S docker rm -f ${CONTAINER_NAME} | |
| echo "컨테이너 ${CONTAINER_NAME} 이(가) 삭제되었습니다." | |
| else | |
| echo "존재하는 컨테이너 ${CONTAINER_NAME} 이(가) 없습니다." | |
| fi | |
| echo "[main] 새로운 컨테이너 ${CONTAINER_NAME} 실행 중..." | |
| echo "$PW" | sudo -S docker run -d -p ${PORT}:${APP_PORT} --name ${CONTAINER_NAME} \ | |
| -e TZ=Asia/Seoul \ | |
| -e "SPRING_PROFILES_ACTIVE=${PROFILE_MAIN}" \ | |
| -v /etc/localtime:/etc/localtime:ro \ | |
| -v "${MOUNT_DIR}":/app \ | |
| "${IMAGE}" | |
| echo "[main] 헬스체크 (최대 120회, 1초간격)" | |
| for i in $(seq 1 120); do | |
| if curl -fsS "http://127.0.0.1:${PORT}/actuator/health" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || \ | |
| curl -fsS "http://127.0.0.1:${PORT}/docs/swagger-ui/index.html" >/dev/null 2>&1 ; then | |
| echo "헬스체크 성공 (시도 ${i})" | |
| HEALTH_OK=1 | |
| break | |
| fi | |
| echo "헬스체크 진행중... (시도 ${i}/120)" | |
| sleep 1 | |
| done | |
| if [ "${HEALTH_OK:-0}" != "1" ]; then | |
| echo "[오류] 헬스체크 실패 -> 배포 중단, 로그 출력" | |
| echo "$PW" | sudo -S docker logs --tail=200 ${CONTAINER_NAME} | |
| exit 1 | |
| fi | |
| echo "[main] DSM 리버스프록시 대상 토글 (${ACTIVE_PORT} -> ${PORT})" | |
| TMP_FILE="$(mktemp)" | |
| sudo awk -v dom="${DOMAIN}" -v new_port="${PORT}" ' | |
| BEGIN { | |
| inserver=0; depth=0; entry_depth=0; | |
| # 모든 정규식 메타문자 이스케이프(방탄) | |
| dom_esc = dom; | |
| gsub(/[.[\]()+*?^$\\|]/, "\\\\&", dom_esc); | |
| } | |
| { | |
| # server_name 매칭 시 현재 depth 기록 | |
| if ($0 ~ "server_name[[:space:]]+" dom_esc "[[:space:]]*;") { | |
| inserver=1; | |
| entry_depth=depth; | |
| } | |
| # 주석 제거 후 중괄호 추적(주석 내 { } 무시) | |
| line = $0; | |
| sub(/#.*/, "", line); | |
| depth += gsub(/\{/, "{", line); | |
| depth -= gsub(/\}/, "}", line); | |
| # 타겟 서버 블록 내부 & proxy_pass가 localhost/127.0.0.1:포트면 포트만 치환 | |
| if (inserver && \ | |
| $0 !~ /^[[:space:]]*#/ && \ | |
| $0 ~ /proxy_pass[[:space:]]+https?:\/\/(localhost|127\.0\.0\.1):[0-9]{2,5}/) { | |
| sub(/:[0-9]{2,5}/, ":" new_port); | |
| } | |
| print; | |
| # 종료 조건(일반 케이스 + 단일 라인 server 블록) | |
| if (inserver && (depth < entry_depth || (depth == 0 && entry_depth == 0))) { | |
| inserver=0; | |
| } | |
| } | |
| ' "${NGINX_RP_CONF}" > "${TMP_FILE}" | |
| # nginx 설정 적용 및 파일 검증 | |
| echo "[main] nginx 설정 파일 적용 및 검증 중..." | |
| echo "$PW" | sudo -S cp "${TMP_FILE}" "${NGINX_RP_CONF}" | |
| if ! (echo "$PW" | sudo -S nginx -t); then | |
| echo "[오류] nginx 설정 파일 검증 실패 -> 백업 파일로 복구" | |
| echo "$PW" | sudo -S cp "${BACKUP_FILE}" "${NGINX_RP_CONF}" | |
| echo "[main] nginx 파일 복구 완료 - 백업 파일 삭제: ${BACKUP_FILE}" | |
| echo "$PW" | sudo -S rm -f "${BACKUP_FILE}" | |
| rm -f "${TMP_FILE}" | |
| echo "[오류] 포트: ${PORT} 대상 컨테이너: ${CONTAINER_NAME} 삭제" | |
| echo "$PW" | sudo -S docker rm -f "${CONTAINER_NAME}" || true | |
| exit 1 | |
| fi | |
| echo "nginx 설정 파일 검증 성공" | |
| rm -f "${TMP_FILE}" | |
| echo "[main] nginx reload" | |
| if ! (echo "$PW" | sudo -S systemctl reload nginx); then | |
| echo "[main] reload 실패 → restart 시도" | |
| if ! (echo "$PW" | sudo -S systemctl restart nginx); then | |
| echo "[오류] nginx reload 및 restart 실패 -> 백업 파일로 복구" | |
| echo "$PW" | sudo -S cp "${BACKUP_FILE}" "${NGINX_RP_CONF}" | |
| echo "$PW" | sudo -S systemctl restart nginx | |
| echo "[main] nginx 파일 복구 완료 - 백업 파일 삭제: ${BACKUP_FILE}" | |
| echo "$PW" | sudo -S rm -f "${BACKUP_FILE}" | |
| echo "[오류] 포트: ${PORT} 대상 컨테이너: ${CONTAINER_NAME} 삭제" | |
| echo "$PW" | sudo -S docker rm -f "${CONTAINER_NAME}" || true | |
| exit 1 | |
| fi | |
| fi | |
| # nginx 재시작 후 도메인 접근 검증 | |
| echo "[main] nginx 재시작 후 도메인 접근 검증..." | |
| for i in $(seq 1 10); do | |
| if curl -fsS "https://${DOMAIN}/actuator/health" >/dev/null 2>&1 || \ | |
| curl -fsS "https://${DOMAIN}/healthz" >/dev/null 2>&1 || \ | |
| curl -fsS "https://${DOMAIN}/" >/dev/null 2>&1 || \ | |
| curl -fsS "https://${DOMAIN}/docs/swagger-ui/index.html" >/dev/null 2>&1 ; then | |
| echo "도메인 접근 검증 성공 (시도 ${i})" | |
| DOMAIN_OK=1 | |
| break | |
| fi | |
| echo "도메인 접근 검증 중... (시도 ${i}/10)" | |
| sleep 2 | |
| done | |
| if [ "${DOMAIN_OK:-0}" != "1" ]; then | |
| echo "[경고] 도메인 접근 검증 실패: 배포 진행 (DNS 전파 지연 가능성)" | |
| fi | |
| echo "[main] 이전 컨테이너 정리" | |
| sleep 5 | |
| if [ "${ACTIVE_PORT}" = "${BLUE_PORT}" ]; then | |
| OLD_CONTAINER_NAME="${BLUE_CONTAINER_NAME}" | |
| else | |
| OLD_CONTAINER_NAME="${GREEN_CONTAINER_NAME}" | |
| fi | |
| echo ${OLD_CONTAINER_NAME}을 제거합니다 | |
| if sudo docker ps -a --format '{{.Names}}' | grep -Eq "^${OLD_CONTAINER_NAME}\$"; then | |
| echo "$PW" | sudo -S docker rm -f "${OLD_CONTAINER_NAME}" || true | |
| fi | |
| # <none> 태그로 남은 이미지 정리 | |
| echo "불필요한 dangling(<none>) 이미지 정리..." | |
| echo "$PW" | sudo -S docker image prune -af | |
| # 성공 시 백업 파일 삭제 | |
| echo "[main] 배포 성공 - 백업 파일 최신 10개 보존 ${BACKUP_FILE}" | |
| echo "$PW" | sudo -S /bin/bash -c "ls -1t '${BACKUP_DIR}'/server.ReverseProxy.conf.*.bak 2>/dev/null | tail -n +11 | xargs -r rm -f" | |
| echo "✅ main 서버 배포가 성공적으로 완료되었습니다." |