diff --git a/.github/workflows/cicd-ec2-prod.yml b/.github/workflows/cicd-ec2-prod.yml index 18583aa0..dcf3ce14 100644 --- a/.github/workflows/cicd-ec2-prod.yml +++ b/.github/workflows/cicd-ec2-prod.yml @@ -3,7 +3,7 @@ name: Build and Deploy to PROD on: push: - branches: [ "main" ] + branches: [ "main-ec2" ] # 환경 변수 $변수명으로 사용 env: diff --git a/.github/workflows/cicd-light-sail-prod.yml b/.github/workflows/cicd-light-sail-prod.yml new file mode 100644 index 00000000..3ae85a22 --- /dev/null +++ b/.github/workflows/cicd-light-sail-prod.yml @@ -0,0 +1,244 @@ +name: Build and Deploy to PROD (Lightsail Blue/Green via SSH + Docker) + +on: + push: + branches: [ "main" ] + +env: + # --- 애플리케이션/컨테이너 공통 --- + PROJECT_NAME: "devdevdev" + IMAGE_NAME: "devdevdev/app" # 로컬 빌드 이미지 이름(태그 latest, SHA) + CONTAINER_BASE: "devdevdev-main-server" # 컨테이너 베이스명 + BLUE_SUFFIX: "-blue" + GREEN_SUFFIX: "-green" + + # --- 포트 구성 --- + BLUE_PORT: "18080" # 호스트 포트(Blue) + GREEN_PORT: "18081" # 호스트 포트(Green) + APP_PORT: "8080" # 컨테이너 내부 포트(Spring Boot) + + # --- 헬스체크 --- + HEALTHCHECK_PATH: "/actuator/health" # Actuator 미사용이면 "/" 로 변경하세요 + HEALTHCHECK_TIMEOUT: "3" # curl 타임아웃(초) + HEALTHCHECK_RETRY: "20" # 재시도 횟수 (5초 * 20 = 최대 100초) + + # --- SSH/Lightsail --- + SSH_USER: "ec2-user" # SSH 접속 사용자 + LIGHTSAIL_HOST: "${{ secrets.LIGHTSAIL_HOST }}" # 퍼블릭 IP 또는 도메인 + +jobs: + build: + name: Build and Deploy (Blue/Green) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + # ====== 리소스/시크릿 주입 (현재 파이프라인과 동일) ====== + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: 21 + distribution: corretto + + - name: make application-prod.yml + run: | + cd ./src/main/resources + echo "${{ secrets.application_prod }}" >> ./application-prod.yml + echo "${{ secrets.application_jwt_prod }}" >> ./application-jwt-prod.yml + echo "${{ secrets.application_oauth2_prod }}" >> ./application-oauth2-prod.yml + echo "${{ secrets.application_storage_s3_prod }}" >> ./application-storage-s3-prod.yml + echo "${{ secrets.application_open_ai }}" >> ./application-open-ai.yml + echo "${{ secrets.application_opensearch_prod }}" >> ./application-opensearch-prod.yml + + - name: make application-test.yml + run: | + cd ./src/test/resources + echo "${{ secrets.application_storage_s3 }}" >> ./application-storage-s3.yml + echo "${{ secrets.application_open_ai }}" >> ./application-open-ai.yml + echo "${{ secrets.application_opensearch_test }}" >> ./application-opensearch-test.yml + + # ====== Gradle 빌드 (Docker가 JAR을 COPY할 수 있도록 선행) ====== + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Build with Gradle (bootJar) + run: ./gradlew bootJar -x test -x asciidoctor + + # ====== Docker 빌드 ====== + - name: Use Dockerfile-prod if present + run: | + if [ -f Dockerfile-prod ]; then + rm -f Dockerfile + cp Dockerfile-prod Dockerfile + fi + + - name: Build Docker image + run: | + docker build \ + -t ${IMAGE_NAME}:${GITHUB_SHA} \ + -t ${IMAGE_NAME}:latest \ + . + + - name: Save image as archive + run: | + mkdir -p out + # SHA와 latest 두 태그 모두 아카이브에 포함 + docker save ${IMAGE_NAME}:${GITHUB_SHA} ${IMAGE_NAME}:latest | gzip > out/image.tar.gz + echo "ARCHIVE=out/image.tar.gz" >> $GITHUB_ENV + + # ====== SSH 준비 ====== + - name: Prepare SSH key + run: | + echo "${{ secrets.LIGHTSAIL_SSH_KEY }}" > key.pem + chmod 600 key.pem + mkdir -p ~/.ssh + ssh-keyscan -H ${LIGHTSAIL_HOST} >> ~/.ssh/known_hosts + + # ====== 아카이브/스크립트 전송 ====== + - name: Upload image archive + run: | + scp -i key.pem -o StrictHostKeyChecking=yes "$ARCHIVE" \ + ${SSH_USER}@${LIGHTSAIL_HOST}:/home/${SSH_USER}/image.tar.gz + + - name: Upload blue/green deploy script + run: | + cat > deploy_blue_green.sh <<'EOS' + #!/usr/bin/env bash + set -euo pipefail + + sudo systemctl enable --now docker >/dev/null 2>&1 || true + + IMAGE_NAME=${IMAGE_NAME:-devdevdev/app} + CONTAINER_BASE=${CONTAINER_BASE:-devdevdev-main-server} + BLUE_SUFFIX=${BLUE_SUFFIX:--blue} + GREEN_SUFFIX=${GREEN_SUFFIX:--green} + BLUE_PORT=${BLUE_PORT:-18080} + GREEN_PORT=${GREEN_PORT:-18081} + APP_PORT=${APP_PORT:-8080} + HEALTHCHECK_PATH=${HEALTHCHECK_PATH:-/} # actuator 없으면 / + HEALTHCHECK_TIMEOUT=${HEALTHCHECK_TIMEOUT:-3} + HEALTHCHECK_RETRY=${HEALTHCHECK_RETRY:-20} + + UPSTREAM_FILE="/etc/nginx/conf.d/backend-upstream.upstream" + BLUE_NAME="${CONTAINER_BASE}${BLUE_SUFFIX}" + GREEN_NAME="${CONTAINER_BASE}${GREEN_SUFFIX}" + + # 아카이브 경로는 HOME 기준으로 + ARCHIVE_FILE="$HOME/image.tar.gz" + + echo "[1/9] Load image: ${ARCHIVE_FILE}" + ls -lh "${ARCHIVE_FILE}" || { echo "[!] archive missing"; exit 1; } + gzip -t "${ARCHIVE_FILE}" + gunzip -c "${ARCHIVE_FILE}" | sudo docker load + + # 보강: :latest 태그가 없으면 가장 최근 태그를 latest로 재태깅 + if ! sudo docker image inspect "${IMAGE_NAME}:latest" >/dev/null 2>&1; then + echo "[info] ${IMAGE_NAME}:latest not found. Retagging…" + # 해당 리포의 임의의 태그 하나를 찾아 latest로 붙임 + NEW_TAG=$(sudo docker images --format '{{.Repository}}:{{.Tag}}' \ + | awk -v repo="${IMAGE_NAME}" -F: '$1==repo && $2!="latest"{print $2; exit}') + if [ -n "${NEW_TAG:-}" ]; then + sudo docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:latest" + else + echo "[!] no tag to retag as latest"; exit 1 + fi + fi + + ACTIVE_PORT="" + if [ -f "${UPSTREAM_FILE}" ]; then + ACTIVE_PORT=$(grep -oE '127\.0\.0\.1:([0-9]+)' "${UPSTREAM_FILE}" | awk -F: '{print $2}' || true) + fi + if [ -z "${ACTIVE_PORT}" ]; then + echo "server 127.0.0.1:${BLUE_PORT};" | sudo tee "${UPSTREAM_FILE}" >/dev/null + ACTIVE_PORT="${BLUE_PORT}" + fi + echo "[2/9] Current active port: ${ACTIVE_PORT}" + + if [ "${ACTIVE_PORT}" = "${BLUE_PORT}" ]; then + TARGET_NAME="${GREEN_NAME}"; TARGET_PORT="${GREEN_PORT}" + OLD_NAME="${BLUE_NAME}"; OLD_PORT="${BLUE_PORT}" + else + TARGET_NAME="${BLUE_NAME}"; TARGET_PORT="${BLUE_PORT}" + OLD_NAME="${GREEN_NAME}"; OLD_PORT="${GREEN_PORT}" + fi + echo "[3/9] Target container: ${TARGET_NAME} on ${TARGET_PORT}" + + if sudo docker ps -a --format '{{.Names}}' | grep -qw "${TARGET_NAME}"; then + sudo docker stop "${TARGET_NAME}" || true + sudo docker rm "${TARGET_NAME}" || true + fi + + echo "[4/9] Run new container" + sudo docker run -d \ + --name "${TARGET_NAME}" \ + --restart=always \ + -p 127.0.0.1:${TARGET_PORT}:${APP_PORT} \ + -e SPRING_PROFILES_ACTIVE=prod \ + ${IMAGE_NAME}:latest + + echo "[5/9] Health check http://127.0.0.1:${TARGET_PORT}${HEALTHCHECK_PATH}" + code=$(curl -sS -o /dev/null -w "%{http_code}" \ + --max-time ${HEALTHCHECK_TIMEOUT} --noproxy '*' \ + "http://127.0.0.1:${TARGET_PORT}${HEALTHCHECK_PATH}" || echo "000") + ok=0 + for i in $(seq 1 ${HEALTHCHECK_RETRY}); do + if curl -fsS --max-time ${HEALTHCHECK_TIMEOUT} "http://127.0.0.1:${TARGET_PORT}${HEALTHCHECK_PATH}" >/dev/null 2>&1; then + ok=1; break + fi + echo " retry $i/${HEALTHCHECK_RETRY}..." + sleep 5 + done + if [ "$ok" -ne 1 ]; then + echo "[!] Health check failed. Rollback." + sudo docker logs --tail 200 "${TARGET_NAME}" || true + sudo docker stop "${TARGET_NAME}" || true + sudo docker rm "${TARGET_NAME}" || true + exit 1 + fi + + echo "[6/9] Switch upstream to ${TARGET_PORT}" + echo "server 127.0.0.1:${TARGET_PORT};" | sudo tee "${UPSTREAM_FILE}" >/dev/null + sudo nginx -t + sudo systemctl reload nginx + + echo "[7/9] Stop old container: ${OLD_NAME} (if any)" + if sudo docker ps -a --format '{{.Names}}' | grep -qw "${OLD_NAME}"; then + sudo docker stop "${OLD_NAME}" || true + sudo docker rm "${OLD_NAME}" || true + fi + + echo "[8/9] Cleanup old archives (keep last 3)" + cd "$HOME" && ls -t image*.tar.gz | tail -n +4 | xargs -r rm -f + + echo "[9/9] Done." + EOS + chmod +x deploy_blue_green.sh + scp -i key.pem -o StrictHostKeyChecking=yes deploy_blue_green.sh ${SSH_USER}@${LIGHTSAIL_HOST}:/home/${SSH_USER}/ + + # ====== 원격 실행 ====== + - name: Remote Blue/Green deploy + run: | + ssh -i key.pem -o StrictHostKeyChecking=yes ${SSH_USER}@${LIGHTSAIL_HOST} \ + "env IMAGE_NAME='${IMAGE_NAME}' \ + CONTAINER_BASE='${CONTAINER_BASE}' \ + BLUE_SUFFIX='${BLUE_SUFFIX}' \ + GREEN_SUFFIX='${GREEN_SUFFIX}' \ + BLUE_PORT='${BLUE_PORT}' \ + GREEN_PORT='${GREEN_PORT}' \ + APP_PORT='${APP_PORT}' \ + HEALTHCHECK_PATH='${HEALTHCHECK_PATH}' \ + HEALTHCHECK_TIMEOUT='${HEALTHCHECK_TIMEOUT}' \ + HEALTHCHECK_RETRY='${HEALTHCHECK_RETRY}' \ + bash /home/${SSH_USER}/deploy_blue_green.sh" + + # ====== Slack 알림 ====== + - name: action-slack + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: "[PROD-TEST] 배포 결과를 알려드려요" + fields: repo,message,commit,author,eventName,ref,took + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: always() diff --git a/Dockerfile-prod b/Dockerfile-prod index 7c6e0949..d0416e95 100644 --- a/Dockerfile-prod +++ b/Dockerfile-prod @@ -1,9 +1,6 @@ -FROM openjdk:21-jdk -# JAR 파일 메인 디렉토리에 복사 +FROM eclipse-temurin:21-jre +WORKDIR /app COPY build/libs/*.jar app.jar - -# 타임존 설정 -ENV TZ Asia/Seoul - -# 시스템 진입점 정의 -CMD java -jar -Dspring.profiles.active=prod /app.jar \ No newline at end of file +ENV TZ=Asia/Seoul +EXPOSE 8080 +ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","/app/app.jar"] \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java index 2e249471..798895ae 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java @@ -22,6 +22,7 @@ public class SecurityConstant { "/**.html", "/**.css", "/**.js", + "/actuator/health", "/devdevdev/api/v1/oauth2/authorization/**", "/devdevdev/api/v1/oauth2/authorization/kakao", "/devdevdev/api/v1/login/oauth2/code/**", @@ -62,6 +63,7 @@ public class SecurityConstant { "/**.html", "/**.css", "/**.js", + "/actuator/health", "/devdevdev/api/v1/oauth2/authorization/**", "/devdevdev/api/v1/oauth2/authorization/kakao", "/devdevdev/api/v1/login/oauth2/code/**", diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 1f004d1b..8c612903 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -61,6 +61,17 @@ spring: max-idle: 8 min-idle: 2 +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: never + probes: + enabled: true + #MyBatis mybatis: type-aliases-package: com.dreamypatisiel.devdevdev.domain.repository