You are currently viewing GitHub Action으로 EC2에 블루그린 배포 및 PR 코멘트

GitHub Action으로 EC2에 블루그린 배포 및 PR 코멘트

TL; DR

  1. GitHub Actions와 EC2를 활용해 PR 생성·커밋 시 자동 배포 및 PR 코멘트로 접근 URL을 제공하는 파이프라인을 구축했다.
  2. 블루-그린 방식으로 EC2에 빌드 파일을 배포하고 nginx 리버스 프록시로 슬롯 전환 및 HTTPS 접근을 처리한다.
  3. 자동·수동 배포, 헬스체크, 배포 이력 관리까지 포함해 스테이징 환경에서 빠르게 결과를 확인할 수 있도록 구성했다.


1. Intro

최근에 사이드 프로젝트를 하면서 로컬개발된 내용을 실제 배포환경에서도 동일하게 볼 수 있을지 파이프라인을 구성할 필요가 있었다.

프론트엔드부분의 경우 아직은 정적웹페이지 위주라 S3+Cloudfront를 사용한 Object storage+CDN 배포라던가 Vercel/Amplify 같은 SaaS? Managed? 서비스를 통해서 배포도 가능하다. 하지만 처음에 EC2로 시작한 이유는

  1. 리소스 낭비 감소
    • 프론트만이라면 서버없이 배포할 수 있지만 백단을 생각하면 호스팅할 서버가 필요하다. 그리고 개발/스테이징 단계에서는 최소단계로 해도 부하가 남는다. 그럼 굳이 외부요소로 뺄 필요가 없다
  2. 완전한 통제감
    • 다른곳에서 관리되는곳을 쓴다면 편하기는 하겠지만 남는게 없을것같다. 프론트쪽들은 잘 모르기 때문에.. 동작하는것도 보고 부하는 얼마나 걸리는지보고 지금 배포같은기능이나 이것저것 붙이려면 직접 관리하는게 낫지않을까 싶음
  3. 혹시모르는 억까 감소
    • 서버리스나 CDN 특징이겠지만.. 서버리스는 트래픽이 많이없으면 서버를 내려서 드물게 접근한다면 초기 접속시간 이슈가 있고(콜드스타트) CDN이면 배포를 마쳐도 로컬지역캐시에 남아있어 배포외에 캐시업데이트등으로도 시간이 필요하다고 알고있어 빠르게 개발내용 확인한다는 스테이징단계에 조금 부적절하지 않나라는 생각을 했다 (근데 그냥 안쓸이유 찾아서 억까한거같기도)

어느정도 윤곽나오고 실제 배포단에서는 효율적 사용을 위해 S3+CDN으로 넘어갈 부분은 넘기고할듯



2. 파이프라인 목적 및 플로우

  • 필요하다고 공유받았던 기능은
    • PR 생성시 배포되서 볼 수 있어야함
    • 번거롭게 찾아가지않고 PR 코멘트 등으로 바로 접근가능했으면 함
  • 개인적으로 추가하면 좋겠다고 생각한 기능은
    • 자동배포외에 필요한 커밋/형상으로 배포할 수 있는 수동 배포
    • 신규형상 문제시 비교를 위해 기존형상은 남겨두기
    • PR 생성시 뿐만 아니라 커밋이 추가된다면(push) 각 브랜치 마지막 형상으로 동기화(synchronize)


  • 필요한 구성요소는 GitHub Action, EC2(nginx+node)
  1. 자동(PR이벤트) 또는 수동(gitSHA 입력)을 통해 workflow trigger
  2. GHA Runner에서 배포파일 빌드 후 압축하여 EC2 인스턴스로 SSH 전달
  3. EC2에서는 빌드파일 기반 서버 기동(nohup)
  4. nginx를 통한 HTTPS Redirection 및 reverse proxy
  5. 배포 이후 헬스체크(HTTP 200) 이후 PR 코멘트로 접근URL 반환

3. 상세 구성

3.1. GHA workflow

  • 작동전 설정 내용을 간소화하기위해 SSH Private Key만 GHA Secret 등록하여 사용
  • 배포 유형에 따라 자동이면 PR넘버/제목 및 SHA, 수동이면 브랜치+SHA 이름 설정
  • 빌드 이후 EC2로 아티팩트 전달 및 배포 스크립트 실행
  • 전달 이후 헬스 체크 < 배포된 서비스에 대한 헬스체크가 아닐 수 있어 업데이트필요
  • 정상이면 PR코멘트나 GHA Summary에 관련 정보 남김
  • 중지 지점
    • 빌드가 실패했을 때
    • 배포 이후 URL 접근 테스트 실패 시

.github/workflow/deploy.yaml

name: deploy frontend

# 실행 이름: PR이벤트면 PR번호/제목 + SHA, 수동이면 브랜치+입력SHA
run-name: |
  ${{ github.event_name == 'pull_request' &&
      format('Deploy PR #{0} "{1}" / {2}',
        github.event.pull_request.number,
        github.event.pull_request.title,
        github.event.pull_request.head.sha
      )
      || format('Deploy BR {0} / {1}',
        github.ref_name,
        github.event.inputs.commit || 'latest HEAD'
      )
  }}

on:
  workflow_dispatch:
    inputs:
      commit:
        description: "8자리 short SHA (비워두면 브랜치의 최신 커밋)"
        required: false
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
    # ✅ 공통 checkout: 이벤트별로 SHA 결정
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0
        ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}

    # ✅ SHA 계산 (PR이면 head.sha, 수동이면 입력값 or HEAD)
    - name: Resolve commit SHA
      id: resolve_sha
      run: |
        if [ "${{ github.event_name }}" = "pull_request" ]; then
          FULL_SHA="${{ github.event.pull_request.head.sha }}"
        else
          if [ -n "${{ github.event.inputs.commit }}" ]; then
            FULL_SHA=$(git rev-parse ${{ github.event.inputs.commit }})
          else
            FULL_SHA=$(git rev-parse HEAD)
          fi
        fi
        SHORT_SHA=$(echo $FULL_SHA | cut -c1-8)
        echo "Using commit: $FULL_SHA ($SHORT_SHA)"
        echo "full_sha=$FULL_SHA" >> $GITHUB_OUTPUT
        echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '22'
        cache: 'npm'

    # ✅ 커밋 SHA 치환
    - name: Replace commit SHA in Home.tsx
      run: |
        SHORT_SHA="${{ steps.resolve_sha.outputs.short_sha }}"
        sed -i "s/COMMIT_SHA/$SHORT_SHA/g" src/components/pages/Home.tsx

    - name: Install dependencies
      run: npm ci

    - name: Build application
      run: npm run build

    # ✅ 배포용 tar.gz 생성
    - name: Create deployment archive
      run: |
        if [ -d build ]; then
          tar -czf deploy.tar.gz -C build .
        else
          echo "❌ No build output found!" && exit 1
        fi

    - name: Setup SSH
      uses: webfactory/ssh-agent@v0.8.0
      with:
        ssh-private-key: ${{ secrets.SSH_GROOT_KEY }}

    - name: Add server to known hosts
      run: |
        mkdir -p ~/.ssh
        ssh-keyscan -H deploy.logonme.click >> ~/.ssh/known_hosts || true

    - name: Deploy to server
      run: |
        scp deploy.tar.gz ec2-user@deploy.logonme.click:/home/ec2-user/
        ssh ec2-user@deploy.logonme.click "cd /home/ec2-user && ./deploy.sh"

    # ✅ 배포 후 슬롯 확인
    - name: Get active slot
      id: active_slot
      run: |
        SLOT=$(ssh ec2-user@deploy.logonme.click "(cat /home/ec2-user/DEPLOY_SLOT || echo 'green')")
        echo "slot=$SLOT" >> $GITHUB_OUTPUT

    # ✅ 배포된 URL 정상 응답 확인 (5분 동안 10초 간격 재시도)
    - name: Verify deployed URL
      run: |
        SLOT="${{ steps.active_slot.outputs.slot }}"
        URL="https://${SLOT}.logonme.click"

        echo "🔍 배포 검증 시작: ${URL}"
        ATTEMPTS=30   # 30회 * 10초 = 5분
        COUNT=0

        while [ $COUNT -lt $ATTEMPTS ]; do
          STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${URL}" || echo "000")
          echo "Attempt $((COUNT+1)) → HTTP $STATUS_CODE"

          if [ "$STATUS_CODE" = "200" ]; then
            echo "✅ 검증 성공! HTTP 200"
            exit 0
          fi

          COUNT=$((COUNT+1))
          echo "⏳ 10초 후 재시도..."
          sleep 10
        done

        echo "❌ 검증 실패! 5분 동안 HTTP 200 응답을 받지 못했습니다."
        exit 1

    # ✅ Summary 출력 (PR이벤트와 수동 둘 다 지원)
    - name: Write deployment summary
      run: |
        SLOT="${{ steps.active_slot.outputs.slot }}"
        SHORT_SHA="${{ steps.resolve_sha.outputs.short_sha }}"
        URL="https://${SLOT}.logonme.click"

        echo "## 🚀 프론트엔드 배포 완료" >> $GITHUB_STEP_SUMMARY
        echo "" >> $GITHUB_STEP_SUMMARY

        if [ "${{ github.event_name }}" = "pull_request" ]; then
          echo "| 항목 | 값 |" >> $GITHUB_STEP_SUMMARY
          echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
          echo "| PR 번호 | #${{ github.event.pull_request.number }} |" >> $GITHUB_STEP_SUMMARY
          echo "| 제목 | ${{ github.event.pull_request.title }} |" >> $GITHUB_STEP_SUMMARY
          echo "| 브랜치 | ${{ github.head_ref }} |" >> $GITHUB_STEP_SUMMARY
        else
          echo "| 항목 | 값 |" >> $GITHUB_STEP_SUMMARY
          echo "|------|-----|" >> $GITHUB_STEP_SUMMARY
          echo "| 브랜치 | ${{ github.ref_name }} |" >> $GITHUB_STEP_SUMMARY
        fi

        echo "| 커밋 SHA | \`${SHORT_SHA}\` |" >> $GITHUB_STEP_SUMMARY
        echo "| 배포 슬롯 | \`${SLOT}\` |" >> $GITHUB_STEP_SUMMARY
        echo "| 접속 URL | [${URL}](${URL}) |" >> $GITHUB_STEP_SUMMARY

    # ✅ PR 이벤트면 코멘트 작성
    - name: Comment on PR with deployed commit
      if: github.event_name == 'pull_request'
      uses: actions/github-script@v7
      with:
        script: |
          const slot = "${{ steps.active_slot.outputs.slot }}".trim();
          const shortSha = "${{ steps.resolve_sha.outputs.short_sha }}";
          const url = `https://${slot}.logonme.click`;
          const pr_number = Number("${{ github.event.pull_request.number }}");

          github.rest.issues.createComment({
            issue_number: pr_number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: `🚀 PR **#${pr_number} (${context.payload.pull_request.head.ref})**\n커밋 \`${shortSha}\` 이(가) **${slot}** 슬롯에 배포되었습니다!\n👉 [${url}](${url})`
          });

3.2. EC2 deploy.sh

  • 서버내에 DEPLOY_SLOT 파일로 다음 배포 슬롯 결정 < 이거도 꼬이면 장애지점
  • 전달받은 deploy.tar.gz를 적절한 포트에 배포
  • 서버실행은 nohup으로 처리
    • system daemon 등록이나 pm2 같이 안정성을 위해 더 나은 선택지들이 있지만… 스테이징인데 굳이.. + 팀원 인지부하감소

/home/ec2-user/deploy.sh

#!/bin/bash
set -e

# 로그 파일 설정
LOG_DIR="/home/ec2-user/deploy-logs"
mkdir -p $LOG_DIR
LOG_FILE="$LOG_DIR/deploy_$(date +%Y%m%d_%H%M%S).log"

# 로그 함수
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $LOG_FILE
}

log "=== Groot Frontend 블루그린 배포 시작 ==="

# 현재 슬롯 확인 // 없으면 green으로 간주, blue로 시작 
if [ -f /home/ec2-user/DEPLOY_SLOT ]; then
  CURRENT_SLOT=$(cat /home/ec2-user/DEPLOY_SLOT)
else
  CURRENT_SLOT="green"
fi

# 다음 슬롯 결정
if [ "$CURRENT_SLOT" == "blue" ]; then
  NEXT_SLOT="green"
  NEXT_PORT=30200
else
  NEXT_SLOT="blue"
  NEXT_PORT=30100
fi

log "현재 슬롯: $CURRENT_SLOT"
log "다음 슬롯: $NEXT_SLOT (포트: $NEXT_PORT)"

APP_DIR="/home/ec2-user/fe-$NEXT_SLOT"

# 새 코드 반영
log "앱 디렉토리 정리: $APP_DIR"
rm -rf $APP_DIR
mkdir -p $APP_DIR

log "배포 파일 압축 해제"
tar -xzf /home/ec2-user/deploy.tar.gz -C $APP_DIR

# 해당 슬롯 프로세스만 중지
log "기존 프로세스 중지 시작"
if pgrep -f "serve -s $APP_DIR -l $NEXT_PORT"; then
  log "포트 $NEXT_PORT에서 실행 중인 프로세스 중지"
  pkill -f "serve -s $APP_DIR -l $NEXT_PORT"
  sleep 2
fi

log "새 버전 실행 시작"
nohup npx serve -s $APP_DIR -l $NEXT_PORT > $APP_DIR/server.log 2>&1 &
SERVE_PID=$!
log "serve 프로세스 PID: $SERVE_PID"

# 프로세스 시작 확인
sleep 3
if ps -p $SERVE_PID > /dev/null; then
  log "새 버전 실행 성공"
else
  log "ERROR: 새 버전 실행 실패"
  exit 1
fi

# 슬롯 기록
echo "$NEXT_SLOT" > /home/ec2-user/DEPLOY_SLOT
log "슬롯 정보 업데이트: $NEXT_SLOT"

# nginx 설정 확인 및 업데이트
sudo nginx -s reload >> $LOG_FILE 2>&1
log "nginx 설정 리로드"

log "=== 배포 완료: $NEXT_SLOT ($NEXT_PORT) ==="
log "접속 URL: http://localhost:$NEXT_PORT"
log "로그 파일: $LOG_FILE"



3.3. EC2 nginx.conf

  • letsencrypt를 통한 사전 인증서 생성 필요 / 상세설명 스킵
  • 요청하는 URL에 따라 해당하는 포트로 요청 리다이렉트

/etc/nginx/conf.d/fe.conf

# 80 → 443 리디렉션
server {
    listen 80;
    server_name preview.logonme.click;  

    return 301 https://$host$request_uri;
}

# blue.logonme.click
server {
    listen 443 ssl;
    http2 on;
    server_name blue.logonme.click;

    ssl_certificate     /etc/letsencrypt/live/logonme.click/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/logonme.click/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://127.0.0.1:30100;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# green.logonme.click
server {
    listen 443 ssl;
    http2 on;
    server_name green.logonme.click;

    ssl_certificate     /etc/letsencrypt/live/logonme.click/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/logonme.click/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://127.0.0.1:30200;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

3.4. 기타 EC2 설정

  • GHA Runner의 SSH 접근 허용
    • 보안그룹에서 SSH(22) ipv4 allow anywhere (키로 접근할거니까)
  • 웹서비스 제공을 위한 HTTPS 접근 허용
    • 보안그룹에서 HTTPS(443) ipv4 allow anywhere
  • 서버 실행환경 구성
    • node 설치라던지.. 그런거
    • 컨테이너로 말아버릴걸 그랬나


4. Outro

  • 해당 deploy.yaml을 적절 위치에 추가하면
  • PR 생성 및 커밋 푸시시 업데이트 되어 코멘트 달림
  • Actions 탭에서 언제 어떤 커밋이 나갔는지 배포 확인 가능
  • 액션 서머리에서도 관련 내용 확인가능

하는 원하는 동작 수행이 가능하다

노출된 URL은 하나고 그 뒷단의 서비스가 변경되는게 아니라 그냥 2개의 staging환경 구성이라 진짜 블루그린은 아니긴하고.. 그냥 2slot 배포라고 하는게 적절한가? 싶기도

좀더 블루그린에 가깝게하려면

  1. 접근 URL을 고정하고 upstream 블럭으로 실제 노출된 서버만 nginx.conf로 제어
  2. 기존 2슬롯은.. live.*와 qa.*느낌으로 구성하고 승격하는 식으로 써먹을 수 있을듯

Leave a Reply