diff --git a/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml index e6f00bb1..0cffccfb 100644 --- a/.github/workflows/backend-cd.yml +++ b/.github/workflows/backend-cd.yml @@ -75,13 +75,29 @@ jobs: deploy: runs-on: ubuntu-latest - needs: [ buildImageAndPush ] + needs: [ makeTagAndRelease, buildImageAndPush ] env: - OWNER: ${{ github.repository_owner }} + IMAGE_REPOSITORY: catfe_backend # 도커 이미지 명 + CONTAINER_1_NAME: catfe_1 # 슬롯 1 + CONTAINER_2_NAME: catfe_2 # 슬롯 2 + CONTAINER_PORT: 8080 # 컨테이너 내부 포트 + DOCKER_NETWORK: common # 도커 네트워크 + EC2_INSTANCE_TAG_NAME: team5-ec2-1 + steps: - - name: set lower case owner name + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: get instance id run: | - echo "OWNER_LC=${OWNER,,}" >> ${GITHUB_ENV} + INSTANCE_ID=$(aws ec2 describe-instances \ + --filters "Name=tag:Name, Values=${{ env.EC2_INSTANCE_TAG_NAME }}" "Name=instance-state-name, Values=running" \ + --query "Reservations[].Instances[].InstanceId" --output text) + [[ -n "${INSTANCE_ID}" && "${INSTANCE_ID}" != "None" ]] || { echo "No running instance found"; exit 1; } + echo "INSTANCE_ID=${INSTANCE_ID}" >> "${GITHUB_ENV}" - name: AWS SSM Send-Command uses: peterkimzz/aws-ssm-send-command@master @@ -90,22 +106,132 @@ jobs: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - instance-ids: "i-00e384163ab61f6cc" + instance-ids: ${{ env.INSTANCE_ID }} working-directory: / comment: Deploy command: | - # 0. env 변수 확인 - echo "OWNER_LC = ${{ env.OWNER_LC }}" + set -Eeuo pipefail + + # 1. EC2 인스턴스 아이디 확인 + echo "INSTANCE_ID=${INSTANCE_ID}" + + # 3. 실행 로그(라인 타임스탬프 부착) + LOG="/tmp/ssm-$(date +%Y%m%d_%H%M%S).log" + exec > >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG") + exec 2> >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG" >&2) + + # 4. 변수 정의 + source /etc/environment || true + OWNER_LC="${{ github.repository_owner }}" + OWNER_LC="${OWNER_LC,,}" + IMAGE_TAG="${{ needs.makeTagAndRelease.outputs.tag_name }}" + IMAGE_REPOSITORY="${{ env.IMAGE_REPOSITORY }}" + IMAGE="ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:${IMAGE_TAG}" + SLOT1="${{ env.CONTAINER_1_NAME }}" + SLOT2="${{ env.CONTAINER_2_NAME }}" + PORT_IN="${{ env.CONTAINER_PORT }}" + NET="${{ env.DOCKER_NETWORK }}" + + # 도커 이미지 pull 받기 + echo "🔹 Use image: ${IMAGE}" + docker pull "${IMAGE}" + + #5. NPM API 토큰 발급 + TOKEN=$(curl -s -X POST http://127.0.0.1:81/api/tokens \ + -H "Content-Type: application/json" \ + -d "{\"identity\": \"admin@npm.com\", \"secret\": \"${PASSWORD:-}\"}" | jq -r '.token') + + # 조회한 토큰과 도메인 검증 + [[ -n "${TOKEN}" && "${TOKEN}" != "null" ]] || { echo "NPM token issue failed"; exit 1; } + [[ -n "${DOMAIN:-}" ]] || { echo "DOMAIN is empty"; exit 1; } + + # 6. 대상 프록시 호스트 ID 조회(도메인 매칭) + PROXY_ID=$(curl -s -X GET "http://127.0.0.1:81/api/nginx/proxy-hosts" \ + -H "Authorization: Bearer ${TOKEN}" \ + | jq ".[] | select(.domain_names[]==\"${APP_1_DOMAIN}\") | .id") + + # 조회한 프록시 호스트 ID 검증 + [[ -n "${PROXY_ID}" && "${PROXY_ID}" != "null" ]] || { echo "Proxy host not found for ${APP_1_DOMAIN}"; exit 1; } + + # 현재 프록시가 바라보는 업스트림(컨테이너명) 조회 + CURRENT_HOST=$(curl -s -X GET "http://127.0.0.1:81/api/nginx/proxy-hosts/${PROXY_ID}" \ + -H "Authorization: Bearer ${TOKEN}" \ + | jq -r '.forward_host') + + echo "🔎 CURRENT_HOST: ${CURRENT_HOST:-none}" + + # 7. 역할(blue/green) 판정 (blue -> 현재 운영 중인 서버, green -> 교체할 서버) + if [[ "${CURRENT_HOST:-}" == "${SLOT1}" ]]; then + BLUE="${SLOT1}" + GREEN="${SLOT2}" + + elif [[ "${CURRENT_HOST:-}" == "${SLOT2}" ]]; then + BLUE="${SLOT2}" + GREEN="${SLOT1}" + + # 초기 배포 + else + BLUE="none" + GREEN="${SLOT1}" + + # 조건문 종료 + fi + echo "🎨 role -> blue(now): ${BLUE}, green(next): ${GREEN}" + + # 8. Green 역할 컨테이너 + docker rm -f "${Green}" > /dev/null 2>&1 || true + echo "run new container -> ${Green}" + docker run -d --name "${Green}" \ + --restart unless-stopped \ + --network "${NET}" \ + -e TZ=Asia/Seoul \ + "${IMAGE}" + + # 9. 헬스체크 + echo "⏱ health-check: ${GREEN}" + TIMEOUT=120 + INTERVAL=3 + ELAPSED=0 + sleep 8 # 초기부팅 여유 + + while (( ELAPSED < TIMEOUT )); do + CODE=$(docker exec "${GREEN}" curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT_IN}/actuator/health" || echo 000) + [[ "${CODE}" == "200" ]] && { echo "✅ ${GREEN} healthy"; break; } + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) + done + [[ "${CODE:-000}" == "200" ]] || { echo "❌ ${GREEN} health failed"; docker logs --tail=200 "${GREEN}" || true; docker rm -f "${GREEN}" || true; exit 1; } + + # 10. 업스트림 전환 + NEW_CFG=$(jq -n --arg host "${GREEN}" --argjson port ${PORT_IN} '{forward_host:$host, forward_port:$port}') + curl -s -X PUT "http://127.0.0.1:81/api/nginx/proxy-hosts/${PROXY_ID}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${NEW_CFG}" >/dev/null + echo "🔁 switch upstream → ${GREEN}:${PORT_IN}" + + # 11. 이전 Blue 종료 + if [[ "${BLUE}" != "none" ]]; then + docker stop "${BLUE}" >/dev/null 2>&1 || true + docker rm "${BLUE}" >/dev/null 2>&1 || true + echo "🧹 removed old blue: ${BLUE}" + fi + + # 12. 이미지 정리 + { + docker images --format '{{.Repository}}:{{.Tag}}' \ + | grep -F "ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:" \ + | grep -v -F ":${IMAGE_TAG}" \ + | grep -v -F ":latest" \ + | xargs -r docker rmi + } || true + + echo "🏁 Blue/Green switch complete. now blue = ${GREEN}" + + + + - # 1. 최신 이미지 pull - docker pull ghcr.io/${{ env.OWNER_LC }}/catfe-backend:latest - # 2. 기존 컨테이너 종료 및 제거 - docker stop catfe-backend 2>/dev/null - docker rm catfe-backend 2>/dev/null - # 3. 새로운 컨테이너 실행 - docker run -d --name catfe-backend -p 8080:8080 ghcr.io/${{ env.OWNER_LC }}/catfe-backend:latest - # 4. dangling 이미지 삭제 - docker rmi $(docker images -f "dangling=true" -q) \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0111e9e7..59746d63 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-websocket") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-actuator") // Database & JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 2360153e..5f633b54 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -194,10 +194,15 @@ sudo mkswap /swapfile sudo swapon /swapfile sudo sh -c 'echo "/swapfile swap swap defaults 0 0" >> /etc/fstab' -# git 설치 -yum install git -y - -#도커 설치 및 실행/활성화 +# 환경변수 세팅(/etc/environment) +echo "PASSWORD=${var.password_1}" >> /etc/environment +echo "DOMAIN=${var.catfe_domain_1}" >> /etc/environment +echo "GITHUB_ACCESS_TOKEN_OWNER=${var.github_access_token_1_owner}" >> /etc/environment +ehco "GITHUB_ACCESS_TOKEN=${var.github_access_token_1}" >> /etc/environment +# EC2 환경변수 등록 +source /etc/environment + +# 도커 설치 및 실행/활성화 yum install docker -y systemctl enable docker systemctl start docker @@ -229,6 +234,8 @@ docker run -d \ -v /dockerProjects/npm_1/volumes/etc/letsencrypt:/etc/letsencrypt \ jc21/nginx-proxy-manager:latest +# ghcr.io 로그인 +echo "${var.github_access_token_1}" | docker login ghcr.io -u ${var.github_access_token_1_owner} --password-stdin END_OF_FILE } diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 00000000..e22f5ba7 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,3 @@ +variable "catfe_domain_1" { + description = "api.catfe.site" +} \ No newline at end of file diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 293dddbd..64478985 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -44,6 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용 .requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용 .requestMatchers("/h2-console/**").permitAll() // H2 Console 허용 + .requestMatchers("/actuator/health").permitAll() // 헬스 체크 허용 .anyRequest().authenticated() )