Nexus-gg Project Using Google Cloud Platform

2025.11.30 ~ 2025.12.8

구성한 아키택처

받은 피드백(루카)
1. nat와 로드밸런서가 같은 서브넷에 주입되어 있는 구조가 맞는가?
2. External service가 vpc 외부에 있는건 맞지만 dns 하위에서 묶여야하는것은 아닌가? ig에 의해서 라우팅이 묶이기 때문

알아두면 좋을 알고리즘
1. load-balancer에서 헬스 테크가 잘 되었다는 의미가 api 1회 성공이 아니라 몇초 단위로 api를 호출하는데 그중 몇회가 성공하면 서버가 살아있는 것이다.
-> 네트워크가 불안정해서 성공과 실패를 반복하거나 하나의 인스턴스에 두개의 vm을 띄우고 헬스체크를 여러개 호출하는데 vm 둘중 하나는 성공, 하는는 실패 일때 위 규칙성을 통해 인스턴스가 죽었다고 판단할 수도 있음

NAT와 Load Balancer가 같은 서브넷에 주입되어 있는 구조가 맞는가?

1. NAT란?

private vm이 인터넷을 이용하기 위한 기술

2. 동작 원리

OutBound

  • private subnet에 속한 vm이 외부와 통신을 원함
  • 다만 private subnet에 있는 서버들은 공인 ip가 없음 -> 인터넷과 통신 불가
  • SNAT(source network address translation)을 통해 출반지 주소를 변화시킴
    1. 패킷 생성
      private vm이 외부로 요청을 보냄
    2. 라우팅
      subnet의 route table 규칙에 따라 패킷이 NAT Gateway로 전달됨
    3. SNAT 수행
      NAT Gateway는 패킷의 source IP를 자신의 public IP로 덮어쓰고 이 매핑 정보를 기록해둠

InBound

  • 외부서버가 NAT Gateway의 public IP로 응답을 보냄
  • session table을 확인
  • 패킷의 Destination IP를 다시 private IP로 변환하여 내부 인스턴트로 포워딩

3. Load Balancer란?

고가용성을 보장하기 위한 리버스 프록시

4. 주요 기능

리버스 프록시
클라이언트가 서버의 실제 IP를 모르게 하고, 로드 밸런서가 대신 요청을 받아 서버들에게 전달하는 구조.

SPOF(single poinr of failure) 제거
서버가 한대만 있다면 서버가 죽는순간 서비스가 중단되지만, 로드밸런서는 여러대의 서버를 통해 죽은 서버는 제외하고 산 서버로만 트래픽을 보내 서비스가 절대 멈추지 않게함

Health Check
서버가 살아있는지 확인하는 작업. 일정 주기로 트래픽을 보내 확인함. 만약 응답이 없거나 에러가 뜨면 해당 서버를 unhealthy로 마킹하고 트래픽 전송을 중단
Balancing Algorithm
트래픽을 나누는 규칙(round robin, least connection, ip hash)

5. 동작 원리(Google Cloud Load Balancer 기준)

  1. 클라이언트가 도메인(https://nexus-gg.kro.kr)을 입력
  2. 도메인이 구글 로드밸런서의 Anycast IP로 변환
  3. 가장 가까운 구글 엣지 서버로 접속
  4. 암호화된 https 패킷을 복호화
  5. 구글의 내부 광케이틀을 타고 서울 리전으로 이동
  6. 로드 밸런서 설정을 보고 그룹을 구분하여 요청을 전달
  7. 헬스체크 확인 후 balancing
  8. 서버로부터 받은 response를 다시 https 암호화후 사용자에게 전달

6. 결론

Load Balancer는 VPC 외부에서 진입점으로, NAT는 VPC 경계에서 출구점으로 기능하며 Public Subnet에 포함 x

External service가 vpc 외부에 있는건 맞지만 dns 하위에서 묶여야하는것은 아닌가?

서비스의 논리적 구성요소는 맞지만, 인프라/네트워크 관점에서 봤을 때 GCS는 googleapis.com 도메인을 사용하는 외부 관리형 서비스임. 도메인(nexus-gg.kro.kr)의 DNS 레코드가 직접 관여하지 않으므로, 네트워크 계층 구조상 분리하여 표현하는 것이 정확하다고 생각함

피드백을 적용한 최종 도메인
[]

Architecture 구성 순서

1. VPC 생성

  • 방화벽 설정
    1. 이름 : nexus-allow-ssh-iap
      목적 : GCP 콘솔에서 SSH를 통해 VM에 접속
    2. 이름 : nexus-gg-vpc-allow-custom
      목적 : 내 VPC 내부의 subnet에서 출발한 신호만 허용
    3. 이름 : nexus-gg-vpc-allow-http/https
      목적 : 기본적인 웹 요청 수신

2. private subnet

3. VM 생성

네트워크 인터페이스 설정 :

  • 네트워크 : 위에서 만든 VPC
  • 서브넷 : 위에서 만든 Private Subnet

4. Cloud SQL 연결

  • 비공개 IP 및 비공개 서비스 액세스로 설정

5. Cloud Storage 연결

  • 공개 액세스 차단
  • IAM 권한 부여 -> VM이 사용중인 서비스 계정에 스토리지 객체 관리자 권한을 추가(storage 접근은 보통 서버를 통하기 때문에 코드 없이도 접근 가능하게 설정)

6. NAT 연결

  • 라우터 생성 후 연결
  • vpc의 네트워크의 모든 서브넷 범위에 NAT을 적용

7. docker 설치

GCP 콘솔에서 ssh를 통해 vm 접속 후

  # 시스템 패키지 목록 업데이트
  sudo apt-get update

  # Docker 설치에 필요한 기본 패키지들 설치
  sudo apt-get install ca-certificates curl gnupg lsb-release -y

  # Docker GPG 키를 저장할 디렉토리 생성 (권한 755)
  sudo mkdir -m 0755 -p /etc/apt/keyrings

  # Docker 공식 GPG 키 다운로드 및 저장
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg
  --dearmor -o /etc/apt/keyrings/docker.gpg

  # GPG 키 파일에 읽기 권한 부여
  sudo chmod a+r /etc/apt/keyrings/docker.gpg

  # Docker 저장소를 apt 소스 목록에 추가
  echo "deb [arch=$(dpkg --print-architecture) 
  signed-by=/etc/apt/keyrings/docker.gpg] 
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee
  /etc/apt/sources.list.d/docker.list > /dev/null

  # 패키지 목록 업데이트
  sudo apt-get update

  # Docker Engine, CLI, containerd 및 플러그인들 설치
  sudo apt-get install docker-ce docker-ce-cli containerd.io
  docker-buildx-plugin docker-compose-plugin -y

  # ============================================
  # Google Cloud SDK 설치
  # ============================================

  # Google Cloud 저장소 관련 패키지 설치
  sudo apt-get install apt-transport-https ca-certificates gnupg -y

  # Google Cloud GPG 키 다운로드 및 저장
  curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg
  --dearmor -o /usr/share/keyrings/cloud.google.gpg

  # Google Cloud SDK 저장소 추가 (main 브랜치)
  echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] 
  https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee
  /etc/apt/sources.list.d/google-cloud-sdk.list

  # 패키지 목록 업데이트
  sudo apt-get update

  # Google Cloud SDK 설치
  sudo apt-get install google-cloud-sdk -y

  # ============================================
  # Docker 및 GCP Artifact Registry 인증 설정
  # ============================================

  # Docker를 GCP Artifact Registry(서울 리전)에 인증 설정
  gcloud auth configure-docker asia-northeast3-docker.pkg.dev

  # ============================================
  # 사용자 권한 설정
  # ============================================

  # 현재 사용자를 docker 그룹에 추가 (sudo 없이 docker 명령 사용 가능)
  sudo usermod -aG docker $USER

  # setiguy1 사용자를 docker 그룹에 추가
  sudo usermod -aG docker setiguy1

  # runner 사용자를 docker 그룹에 추가
  sudo usermod -aG docker runner

  # ============================================
  # 설치 확인
  # ============================================

  # Docker 버전 확인
  docker --version

  # Google Cloud SDK 버전 확인
  gcloud --version

  # home 디렉토리가 있는 사용자 목록 확인
  cat /etc/passwd | grep home

  # 실행 중인 Docker 컨테이너 목록 확인
  docker ps

  # nexus-spring-container 컨테이너의 로그 확인 (컨테이너가 실행 중인 경우)
  docker logs nexus-spring-container

주의! Google Cloud 서비스 계정 사용 시:

  • 서비스 계정 이메일: example@project-id.iam.gserviceaccount.com
  • SSH 접속 사용자명: example
    → @ 앞부분과 SSH 사용자명이 일치해야 인증 가능

8. Load Balancer 연결

  • 프론트엔드 설정

    프로토콜 : Https
    IP : 고정 IP 예약후 사용
    포트 : 443

  • 백엔드 설정

    인스턴스 그룹 생성 및 설정: 사용중인 네트워크와 서브넷으로 설정
    프로토콜 : Http -> 로드밸런서가 암호화를 해제했으므로 백엔드 서버에는Http로 전달
    포트번호 8080
    경로 규칙 : 단순 호스트 및 경로 규칙

  • 프록시 전용 서브넷 생성

  • 방화벽 설정

    1. 이름 : allow-health-check
      목적 : Health 체크를 위함
      경로 : 130.211.0.0/22, 35.191.0.0/16, 프록시 전용 서브넷 IP

9. Https 설정

  • 로드밸런서의 프론트엔드 설정에서 설정한 고정 IP를 구매한 도메인의 목적지로 설정(레코드 타입: A)
  • SSL 인증서 발급
  • 인증서 발급 후 인증 대기

10. Github Action을 통한 CI/CD

  • github runner에서 사용할 IAM 설정

    다음 4개 권한 부여
    Artifact Registry 작성자
    목적: docker push
    Compute 인스턴스 관리자(v1)
    목적: gcloud compute ssh
    Compute OS 관리자 로그인
    목적: VM 내부에서 sudo 사용
    IAP 보안 터널 사용자
    목적: IAP 중계 서버를 통한 VM 접속

  • CI

name: CI - Build and Test

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main
      - develop

# 동일 브랜치의 이전 워크플로우 취소
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle with caching
        uses: gradle/actions/setup-gradle@v3
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/main' }}

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Run tests with parallel execution
        run: |
          ./gradlew test \
            --no-daemon \
            --parallel \
            --max-workers=4 \
            --build-cache

      - name: Upload test results (on failure)
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ github.sha }}
          path: build/test-results/
          retention-days: 7

      - name: Upload coverage reports
        if: github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v4
        with:
          name: coverage-reports-${{ github.sha }}
          path: build/reports/
          retention-days: 30

  build:
    name: Build Docker Image
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Load environment variables safely
        id: env
        run: |
          echo "${{ secrets.PROD_ENV_FILE }}" > .env.tmp

          while IFS='=' read -r key value || [ -n "$key" ]; do
            [[ "$key" =~ ^#.*$ ]] && continue
            [[ -z "$key" ]] && continue

            key=$(echo "$key" | xargs)
            value=$(echo "$value" | xargs)
            value="${value%\"}"
            value="${value#\"}"

            echo "${key}=${value}" >> $GITHUB_OUTPUT
          done < .env.tmp

          rm -f .env.tmp

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker for Artifact Registry
        run: |
          gcloud auth configure-docker ${{ steps.env.outputs.GCP_REGION }}-docker.pkg.dev

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.env.outputs.GCP_REGION }}-docker.pkg.dev/${{ steps.env.outputs.GCP_PROJECT_ID }}/${{ steps.env.outputs.ARTIFACT_REGISTRY_REPO }}/lol-highlight-backend
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable=true

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64

      - name: Output image tags
        run: |
          echo "Built and pushed images:"
          echo "${{ steps.meta.outputs.tags }}"
  • CD
name: CD - Deploy to Production

on:
  workflow_run:
    workflows: ["CI - Build and Test"]
    types:
      - completed
    branches:
      - main

jobs:
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    environment: production
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha }}

      - name: Load environment variables safely
        id: env
        run: |
          echo "${{ secrets.PROD_ENV_FILE }}" > .env.tmp

          while IFS='=' read -r key value || [ -n "$key" ]; do
            [[ "$key" =~ ^#.*$ ]] && continue
            [[ -z "$key" ]] && continue

            key=$(echo "$key" | xargs)
            value=$(echo "$value" | xargs)
            value="${value%\"}"
            value="${value#\"}"

            echo "${key}=${value}" >> $GITHUB_OUTPUT
          done < .env.tmp

          rm -f .env.tmp

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Get latest image tag
        id: image
        run: |
          IMAGE_TAG="${{ steps.env.outputs.GCP_REGION }}-docker.pkg.dev/${{ steps.env.outputs.GCP_PROJECT_ID }}/${{ steps.env.outputs.ARTIFACT_REGISTRY_REPO }}/lol-highlight-backend:main-$(git rev-parse --short HEAD)"
          echo "tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
          echo "Using image: $IMAGE_TAG"

      - name: Deploy to Compute Engine with zero-downtime
        id: deploy
        env:
          INSTANCE_NAME: ${{ steps.env.outputs.COMPUTE_INSTANCE_NAME }}
          ZONE: ${{ steps.env.outputs.COMPUTE_ZONE }}
          IMAGE_TAG: ${{ steps.image.outputs.tag }}
          GCP_REGION: ${{ steps.env.outputs.GCP_REGION }}
        run: |
          echo "${{ secrets.PROD_ENV_FILE }}" > .env.tmp
          gcloud compute scp .env.tmp $INSTANCE_NAME:/tmp/.env --zone=$ZONE --tunnel-through-iap
          rm -f .env.tmp

          gcloud compute ssh $INSTANCE_NAME --zone=$ZONE --tunnel-through-iap --command="
            set -e

            export IMAGE_TAG='$IMAGE_TAG'
            export GCP_REGION='$GCP_REGION'

            echo '==> Step 1: Docker 인증'
            gcloud auth configure-docker \$GCP_REGION-docker.pkg.dev

            echo '==> Step 2: 현재 실행 중인 이미지 저장 (롤백용)'
            PREVIOUS_IMAGE=\$(docker inspect nexus-spring-container --format='{{.Config.Image}}' 2>/dev/null || echo 'none')
            echo \"Previous image: \$PREVIOUS_IMAGE\"

            echo '==> Step 3: 새 이미지 Pull'
            if ! docker pull \$IMAGE_TAG; then
              echo 'Failed to pull new image'
              exit 1
            fi

            echo '==> Step 4: 새 컨테이너 시작 (임시 포트 8081)'
            docker run -d \
              --name nexus-spring-container-new \
              --restart unless-stopped \
              -p 8081:8080 \
              --env-file /tmp/.env \
              \$IMAGE_TAG

            echo '==> Step 5: 새 컨테이너 헬스체크 (최대 30초)'
            HEALTHY=false
            for i in {1..15}; do
              if curl -f http://localhost:8081/health 2>/dev/null; then
                HEALTHY=true
                echo \"Health check passed after \$i attempts\"
                break
              fi
              echo \"Health check attempt \$i/15...\"
              sleep 2
            done

            if [ \"\$HEALTHY\" = true ]; then
              echo '==> Step 6: 헬스체크 성공, 기존 컨테이너 교체'

              docker stop nexus-spring-container || true
              docker rm nexus-spring-container || true

              docker stop nexus-spring-container-new
              docker rm nexus-spring-container-new

              docker run -d \
                --name nexus-spring-container \
                --restart unless-stopped \
                -p 8080:8080 \
                --env-file /tmp/.env \
                \$IMAGE_TAG

              echo '==> Step 7: 최종 확인'
              sleep 3
              if curl -f http://localhost:8080/health 2>/dev/null; then
                echo 'Deployment successful!'
              else
                echo 'Warning: Final health check failed, but container is running'
              fi

              docker images --format '{{.Repository}}:{{.Tag}}' | \
                grep 'lol-highlight-backend' | \
                tail -n +4 | \
                xargs -r docker rmi 2>/dev/null || true

            else
              echo '==> Rollback: 헬스체크 실패, 새 컨테이너 제거'
              docker stop nexus-spring-container-new || true
              docker rm nexus-spring-container-new || true
              docker rmi \$IMAGE_TAG || true

              echo 'Rollback completed. Previous container still running.'
              exit 1
            fi

            rm -f /tmp/.env
            echo 'Deployment completed successfully'
          "

      - name: Wait for load balancer
        run: sleep 5

      - name: Health check via Load Balancer
        run: |
          LOAD_BALANCER_IP="${{ secrets.LOAD_BALANCER_IP }}"

          echo "Health check via Load Balancer..."

          for i in {1..10}; do
            if curl -f -m 5 http://${LOAD_BALANCER_IP}/health 2>/dev/null; then
              echo "Health check passed!"
              exit 0
            fi
            echo "Attempt $i/10..."
            sleep 3
          done

          echo "Health check failed after 10 attempts"
          exit 1

      - name: Deployment success
        if: success()
        run: |
          echo "Deployment completed successfully!"
          echo "Image: ${{ steps.image.outputs.tag }}"
          echo "Time: $(date)"

      - name: Deployment failure
        if: failure()
        run: |
          echo "Deployment failed!"
          echo "Image: ${{ steps.image.outputs.tag }}"
          echo "Previous container should still be running."
  • mysql 서브쿼리 안에는 limit 사용불가

0개의 댓글