카푸카 제거 #730
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: CI-CD_Pipeline | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| actions: read | |
| on: | |
| push: | |
| branches: [ main, develop, 'release/**' ] | |
| pull_request: | |
| branches: [ main, develop, 'release/**' ] | |
| workflow_dispatch: | |
| jobs: | |
| tests: | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ ubuntu-latest ] | |
| include: | |
| - os: ubuntu-latest | |
| gradle_cmd: "./gradlew" | |
| report_path: "backend/build/reports/tests" | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| SPRING_PROFILES_ACTIVE: test-ci | |
| # Redis 서비스 추가 | |
| services: | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| env: | |
| REDIS_PASSWORD: "" | |
| # Qdrant 서비스 추가 | |
| qdrant: | |
| image: qdrant/qdrant:v1.3.1 | |
| ports: | |
| - 6333:6333 | |
| - 6334:6334 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| cache: gradle | |
| # gradlew 실행 권한 부여 | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x backend/gradlew | |
| # Redis 연결 테스트 | |
| - name: Test Redis connection | |
| run: | | |
| echo "Testing Redis connection..." | |
| timeout 10s bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/localhost/6379; do sleep 1; done' | |
| echo "Redis is ready!" | |
| # Qdrant 연결 테스트 | |
| - name: Wait for Qdrant | |
| run: | | |
| echo "Waiting for Qdrant to be ready..." | |
| timeout 40s bash -c 'until curl -sSf http://localhost:6333/collections >/dev/null; do sleep 1; done' | |
| echo "Qdrant is ready!" | |
| # application-test.yml에서 사용하는 모든 환경변수를 .env 파일에 생성 | |
| - name: Create test .env file | |
| working-directory: backend | |
| run: | | |
| cat > .env << 'EOF' | |
| # Datasource 설정 (application-test.yml에서 참조) | |
| TEST_DATASOURCE_URL=jdbc:h2:mem:db_test;MODE=MySQL | |
| TEST_DATASOURCE_USERNAME=sa | |
| TEST_DATASOURCE_PASSWORD= | |
| TEST_DATASOURCE_DRIVER=org.h2.Driver | |
| # JPA 설정 (application-test.yml에서 참조) | |
| TEST_JPA_HIBERNATE_DDL_AUTO=create-drop | |
| send_email_address=${{ secrets.SEND_EMAIL_ADDRESS }} | |
| send_email_password=${{ secrets.SEND_EMAIL_PASSWORD }} | |
| # Redis 설정 (application-test.yml에서 참조, GitHub Actions 서비스 사용) | |
| TEST_REDIS_HOST=localhost | |
| TEST_REDIS_PORT=6379 | |
| TEST_REDIS_PASSWORD= | |
| # Qdrant | |
| TEST_QDRANT_HOST=localhost | |
| TEST_QDRANT_PORT=6333 | |
| # CI/CD 환경에서는 Embedded Redis 끄기 | |
| SPRING_DATA_REDIS_EMBEDDED=false | |
| # JWT 설정 (application-test.yml에서 참조) | |
| CUSTOM_JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} | |
| CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600 | |
| EOF | |
| - name: Export .env into runner env | |
| working-directory: backend | |
| run: | | |
| while IFS= read -r line; do | |
| [[ -z "$line" || "$line" =~ ^# ]] && continue | |
| echo "$line" >> $GITHUB_ENV | |
| done < .env | |
| - name: Run unit, and domain tests | |
| run: ${{ matrix.gradle_cmd }} clean test | |
| working-directory: backend | |
| - name: Upload Test Reports | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-reports-${{ matrix.os }} | |
| path: ${{ matrix.report_path }} | |
| retention-days: 7 | |
| build-artifacts: | |
| needs: tests | |
| runs-on: ubuntu-latest | |
| if: startsWith(github.ref, 'refs/heads/main') || startsWith(github.ref, 'refs/heads/release/') | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 21 | |
| cache: gradle | |
| # gradlew 실행 권한 부여 | |
| - name: Grant execute permission for gradlew | |
| run: chmod +x backend/gradlew | |
| # 빌드용 .env 파일 생성 (Configuration Properties 바인딩용 최소 환경변수만) | |
| - name: Create build .env file | |
| working-directory: backend | |
| run: | | |
| cat > .env << 'EOF' | |
| # JWT Configuration Properties 바인딩용 (빌드 시 필요) | |
| CUSTOM_JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} | |
| CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=300 | |
| EOF | |
| - name: Gradle bootJar | |
| working-directory: backend | |
| run: ./gradlew --no-daemon clean bootJar -x test | |
| - name: Copy JAR to dist | |
| working-directory: backend | |
| run: | | |
| mkdir -p dist | |
| cp $(ls build/libs/*.jar | grep -v plain | head -n 1) dist/app.jar | |
| - name: Upload backend jar | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: backend-jar | |
| path: backend/dist/app.jar | |
| docker-build: | |
| needs: build-artifacts | |
| runs-on: ubuntu-latest | |
| outputs: | |
| image_to_deploy: ${{ steps.compute.outputs.image_to_deploy }} | |
| if: startsWith(github.ref, 'refs/heads/main') || startsWith(github.ref, 'refs/heads/release/') | |
| env: | |
| REGISTRY: ghcr.io | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set lowercase repo + Compute tags by branch | |
| id: compute | |
| run: | | |
| REPO="${GITHUB_REPOSITORY,,}" | |
| echo "IMAGE_PREFIX=$REPO" >> "$GITHUB_ENV" | |
| if [[ "$GITHUB_REF_NAME" == "main" ]]; then | |
| IMG_TAGS="ghcr.io/${REPO}/balaw:${GITHUB_SHA},ghcr.io/${REPO}/balaw:latest" | |
| echo "image_to_deploy=ghcr.io/${REPO}/balaw:latest" >> "$GITHUB_OUTPUT" | |
| else | |
| BR_TAG="${GITHUB_REF_NAME//\//-}" | |
| IMG_TAGS="ghcr.io/${REPO}/balaw:rc-${BR_TAG}-${GITHUB_SHA},ghcr.io/${REPO}/balaw:rc-${BR_TAG}-latest" | |
| echo "image_to_deploy=ghcr.io/${REPO}/balaw:rc-${BR_TAG}-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "IMG_TAGS=$IMG_TAGS" >> "$GITHUB_ENV" | |
| # 3) 디버그 출력(로그 확인용) | |
| echo "IMAGE_PREFIX=$REPO" | |
| echo "IMG_TAGS=$IMG_TAGS" | |
| - name: Download backend jar | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: backend-jar | |
| path: backend/dist | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build & push backend (runtime-only) | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: backend | |
| file: backend/Dockerfile | |
| push: true | |
| tags: ${{ env.IMG_TAGS }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| deploy-prod: | |
| needs: docker-build | |
| runs-on: ubuntu-latest | |
| if: github.ref == 'refs/heads/main' | |
| env: | |
| IMAGE_REPOSITORY: balaw # GHCR 이미지 리포지토리명(소유자 포함 X) | |
| CONTAINER_1_NAME: app_1 # 슬롯1(고정 이름) | |
| CONTAINER_2_NAME: app_2 # 슬롯2(고정 이름) | |
| CONTAINER_PORT: 8080 # 컨테이너 내부 포트(스프링부트) | |
| EC2_INSTANCE_TAG_NAME: devcos-team03-ec2-1 # 배포 대상 EC2 Name 태그 | |
| DOCKER_NETWORK: common # 도커 네트워크 | |
| BACKEND_DIR: backend # Dockerfile 위치 | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - name: Set lowercase repo name | |
| run: echo "IMAGE_PREFIX=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Blue/Green 무중단 배포 | |
| 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 태그로 EC2 인스턴스 조회 (없으면 실패) | |
| - name: 인스턴스 ID 가져오기 | |
| id: get_instance_id | |
| run: | | |
| 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 | |
| with: | |
| 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: ${{ env.INSTANCE_ID }} | |
| working-directory: / | |
| comment: Deploy | |
| command: | | |
| set -Eeuo pipefail | |
| # prod.env 복원 | |
| install -d -m 700 /home/ec2-user/configs | |
| printf "%s" "${{ secrets.PROD_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env | |
| chmod 600 /home/ec2-user/configs/prod.env | |
| test -s /home/ec2-user/configs/prod.env || { echo "prod.env empty"; exit 1; } | |
| # --------------------------------------------------------- | |
| # 0) 실행 로그(라인 타임스탬프 부착) | |
| # --------------------------------------------------------- | |
| 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) | |
| # --------------------------------------------------------- | |
| # 1) 변수 정의 | |
| # - 슬롯 이름은 고정(두 개의 컨테이너 이름) | |
| # - blue/green은 "역할"이며 매 배포마다 바뀜 | |
| # --------------------------------------------------------- | |
| source /etc/environment || true # 시스템 전역 변수(+ 비밀) 주입 시도 | |
| OWNER_LC="${{ github.repository_owner }}" | |
| OWNER_LC="${OWNER_LC,,}" # 소문자 표준화 | |
| IMAGE="${{ needs.docker-build.outputs.image_to_deploy }}" | |
| SLOT1="${{ env.CONTAINER_1_NAME }}" | |
| SLOT2="${{ env.CONTAINER_2_NAME }}" | |
| PORT_IN="${{ env.CONTAINER_PORT }}" | |
| NET="${{ env.DOCKER_NETWORK }}" | |
| echo "GHCR login" | |
| echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u "${{ github.repository_owner }}" --password-stdin | |
| echo "🔹 Use image: ${IMAGE}" | |
| docker pull "${IMAGE}" | |
| # --------------------------------------------------------- | |
| # 2) Nginx Proxy Manager(NPM) API 토큰 발급 | |
| # - /etc/environment 등에 설정된 PASSWORD_1, APP_1_DOMAIN 사용 가정 | |
| # --------------------------------------------------------- | |
| TOKEN=$(curl -s -X POST http://127.0.0.1:81/api/tokens \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"identity\": \"[email protected]\", \"secret\": \"${PASSWORD_1:-}\"}" | jq -r '.token') | |
| # 토큰/도메인 검증(없으면 실패) | |
| [[ -n "${TOKEN}" && "${TOKEN}" != "null" ]] || { echo "NPM token issue failed"; exit 1; } | |
| [[ -n "${APP_1_DOMAIN:-}" ]] || { echo "APP_1_DOMAIN is empty"; exit 1; } | |
| # 대상 프록시 호스트 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") | |
| [[ -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}" | |
| # --------------------------------------------------------- | |
| # 3) 역할(blue/green) 판정 | |
| # - blue : 현재 운영 중(CURRENT_HOST) | |
| # - 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}" | |
| # --------------------------------------------------------- | |
| # 4) green 역할 컨테이너 재기동 | |
| # - 같은 네트워크 상에서 NPM이 컨테이너명:PORT로 프록시하므로 -p 불필요 | |
| # --------------------------------------------------------- | |
| docker rm -f "${GREEN}" >/dev/null 2>&1 || true | |
| echo "🚀 run new container → ${GREEN}" | |
| docker run -d --name "${GREEN}" \ | |
| --restart unless-stopped \ | |
| --network "${NET}" \ | |
| --env-file /home/ec2-user/configs/prod.env \ | |
| -e TZ=Asia/Seoul \ | |
| "${IMAGE}" | |
| # --------------------------------------------------------- | |
| # 5) 헬스체크 (/actuator/health 200 OK까지 대기) | |
| # --------------------------------------------------------- | |
| 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; } | |
| # --------------------------------------------------------- | |
| # 6) 업스트림 전환 (forward_host/forward_port만 업데이트) | |
| # --------------------------------------------------------- | |
| NEW_CFG=$(jq -n --arg host "${GREEN}" --argjson port ${PORT_IN} '{forward_host:$host, forward_port:$port, forward_scheme:"http"}') | |
| 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}" | |
| # --------------------------------------------------------- | |
| # 7) 이전 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 | |
| # --------------------------------------------------------- | |
| # 8) 이미지 정리(현재 태그/ latest 제외) - 결과 없음도 오류 아님 | |
| # - pipefail과 grep의 종료코드(1)를 고려해 블록 전체에 || true 적용 | |
| # --------------------------------------------------------- | |
| { | |
| docker images --format '{{.Repository}}:{{.Tag}}' \ | |
| | grep -F "ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:" \ | |
| | grep -v -F ":latest" \ | |
| | xargs -r docker rmi | |
| } || true | |
| echo "🏁 Blue/Green switch complete. now blue = ${GREEN}" | |
| deploy-rc: | |
| needs: docker-build | |
| runs-on: ubuntu-latest | |
| if: startsWith(github.ref, 'refs/heads/release/') | |
| env: | |
| REGISTRY: ghcr.io | |
| DOCKER_IMAGE_NAME: balaw | |
| EC2_INSTANCE_TAG_NAME_TEST: devcos-team03-dev-ec2-1 # 배포 대상 EC2 Name 태그 | |
| steps: | |
| - name: Configure AWS creds | |
| 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 태그로 EC2 인스턴스 조회 (없으면 실패) | |
| - name: 인스턴스 ID 가져오기 | |
| id: get_instance_id | |
| run: | | |
| INSTANCE_ID=$(aws ec2 describe-instances \ | |
| --filters "Name=tag:Name,Values=${{ env.EC2_INSTANCE_TAG_NAME_TEST }}" "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: Set lowercase repo name | |
| run: echo "IMAGE_PREFIX=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Derive RC tag | |
| run: echo "BR_TAG=$(echo '${{ github.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV | |
| - name: AWS SSM Send-Command (simple replace) | |
| uses: peterkimzz/aws-ssm-send-command@master | |
| with: | |
| 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: ${{ env.INSTANCE_ID }} | |
| working-directory: / | |
| comment: Replace deploy | |
| command: | | |
| bash -lc ' | |
| set -euo pipefail | |
| echo "===== 현재 실행 중인 컨테이너 =====" | |
| docker ps -a || true | |
| echo "===== 기존 컨테이너 종료 & 제거 =====" | |
| docker stop app 2>/dev/null || true | |
| docker rm app 2>/dev/null || true | |
| # prod.env 복원 | |
| install -d -m 700 /home/ec2-user/configs | |
| printf "%s" "${{ secrets.PROD_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env | |
| chmod 600 /home/ec2-user/configs/prod.env | |
| test -s /home/ec2-user/configs/prod.env || { echo "prod.env empty"; exit 1; } | |
| # GHCR 로그인 | |
| echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin | |
| # 사용할 이미지(브랜치/커밋 고정) | |
| IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ env.DOCKER_IMAGE_NAME }}:rc-${{ env.BR_TAG }}-${{ github.sha }}" | |
| echo "Pulling $IMAGE" | |
| docker pull "$IMAGE" | |
| echo "===== 새로운 컨테이너 실행 =====" | |
| docker run --env-file /home/ec2-user/configs/prod.env \ | |
| -e SPRING_PROFILES_ACTIVE=prod \ | |
| -d --name app \ | |
| --network common \ | |
| -p 8080:8080 \ | |
| "$IMAGE" | |
| echo "===== 새 컨테이너 로그 출력 =====" | |
| sleep 5 | |
| docker logs --tail=100 app || true | |
| echo "===== 배포 완료 =====" | |
| docker rmi $(docker images -f "dangling=true" -q) || true | |
| ' |