GitHub Actions 기반 정적 웹 애플리케이션 자동 배포

김유경·2025년 5월 31일
post-thumbnail

🥳 React 기반 정적 웹 애플리케이션을 Bastion 서버를 거쳐 Private Subnet에 위치한 Web 서버에 자동 배포하는 CI/CD 파이프라인을 구축했습니다.


인프라 구성

1. Web 서버

  • Private Subnet에 위치
  • /var/www/html에 React 빌드 파일 배치
  • Nginx가 80 포트에서 서비스 중
  • ALB와 연결되어 외부에 노출됨

2. Bastion 서버 설정

  • Public IP 설정, 포트 10000으로 Web 서버 포트 포워딩
  • GitHub Actions에서 SSH 접속 허용 (Private Key 기반)

3. GitHub Secrets 설정

키설명
BASTION_USERBastion 서버 SSH 사용자명 (예: ubuntu)
BASTION_HOSTBastion 서버의 퍼블릭 IP 주소 (예: 210.xxx.xxx.xxx)
BASTION_KEYBastion 서버에 접속하기 위한 SSH Private Key
WEB_SERVER내부 Web 서버의 프라이빗 IP (포트포워딩 대상)
APP_URL배포 완료 후 접근 가능한 서비스 URL
REACT_DEVELOP_JSON.env 파일에 들어갈 환경 변수 JSON
SLACK_WEBHOOK_URL배포 결과를 전송할 Slack Webhook URL

CI/CD 워크플로우

  1. develop 브랜치 push
  2. 코드 검사, 테스트
  3. .env 파일 자동 생성
  4. React 앱 빌드 + version.json 생성
  5. Bastion → Web 서버로 빌드 파일 복사 (rsync)
  6. 기존 서비스 백업
  7. 새 빌드 배포 및 Nginx 재시작
  8. 헬스체크
  9. 성공 시 Slack 알림, 실패 시 자동 롤백

주요 과정

1. .env 생성 자동화

  • GitHub Secrets에 JSON 형태로 환경 변수 보관
  • 빌드 시점에 .env로 변환해 React 앱에 포함
echo '${{ secrets.REACT_DEVELOP_JSON }}' | jq -r 'to_entries[] | "\(.key)=\(.value)"' > .env
echo "REACT_APP_VERSION=${{ env.DEPLOY_VERSION }}" >> .env

2. Bastion → Web 서버 파일 복사

  • rsync를 통해 Bastion 서버를 경유하여 Web 서버에 안전하게 파일 전송
  • rsync(remote sync)는 로컬 ↔ 원격, 또는 원격 ↔ 원격 사이의 파일과 디렉토리를 비교하여 변경된 파일만 빠르게 복사
- name: Deploy to Web Server via Bastion
  run: |
    rsync -avz --delete --progress \
      -e "ssh -i private_key -p 10000" \
      $APP_DIR/ ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }}:${{ env.DEPLOY_DIR }}/

3. 실패 시 자동 롤백

  • 배포 전 /var/www/html 백업
  • 실패 시 자동 복원 → 서비스 중단 최소화
if [ -f /tmp/latest_backup ]; then
  cp -r $LATEST_BACKUP/* $WEB_DIR/
  systemctl restart nginx

전체 스크립트

name: Deploy React App

on:
  push:
    branches:
      - develop
  workflow_dispatch:

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm run test
        env:
          DISABLE_ESLINT_PLUGIN: true
          CI: true

  build-and-deploy:
    needs: validate
    runs-on: ubuntu-latest
    timeout-minutes: 30

    env:
      APP_DIR: build
      BASTION_HOST: ${{ secrets.BASTION_HOST }}
      BASTION_USER: ${{ secrets.BASTION_USER }}
      BASTION_KEY: ${{ secrets.BASTION_KEY }}
      WEB_SERVER: ${{ secrets.WEB_SERVER }}
      WEB_DIR: /var/www/html
      WEB_BACKUP_DIR: /var/www/backups
      DEPLOY_DIR: /home/ubuntu/deploy
      NODE_ENV: development
      DEPLOY_VERSION: ${{ github.sha }}

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

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

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Generate .env from REACT_DEVELOP_JSON
        run: |
          echo '${{ secrets.REACT_DEVELOP_JSON }}' | jq -r 'to_entries[] | "\(.key)=\(.value)"' > .env
          echo "REACT_APP_VERSION=${{ env.DEPLOY_VERSION }}" >> .env
          echo "REACT_APP_BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> .env
          echo ".env 파일 생성 완료"
          echo "생성된 환경 변수 목록:"
          cat .env | sed 's/=.*/=***/' | sed 's/^/   - /'

      - name: Build app
        run: npm run build
        env:
          DISABLE_ESLINT_PLUGIN: true
          GENERATE_SOURCEMAP: false

      - name: Create version file
        run: |
          cat <<EOF > $APP_DIR/version.json
          {
            "version": "${{ env.DEPLOY_VERSION }}",
            "deployedAt": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
          }
          EOF

      - name: Save SSH private key
        run: |
          echo "${{ env.BASTION_KEY }}" > private_key
          chmod 600 private_key

      - name: Add known hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H -p 22 ${{ env.BASTION_HOST }} >> ~/.ssh/known_hosts
          ssh-keyscan -H -p 10000 ${{ env.BASTION_HOST }} >> ~/.ssh/known_hosts

      - name: Test SSH connection to Bastion
        run: ssh -i private_key -o ConnectTimeout=10 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'echo "Bastion SSH connection successful"'

      - name: Test SSH connection to Web Server via Bastion
        run: ssh -i private_key -o ConnectTimeout=10 -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'echo "Web Server SSH connection successful"'

      - name: Prepare deployment directory
        run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
          sudo mkdir -p ${{ env.DEPLOY_DIR }} &&
          sudo chown ubuntu:ubuntu ${{ env.DEPLOY_DIR }} &&
          sudo chmod 755 ${{ env.DEPLOY_DIR }} &&
          rm -rf ${{ env.DEPLOY_DIR }}/*
          '

      - name: Deploy to Web Server via Bastion
        run: |
          rsync -avz --delete --progress \
            -e "ssh -i private_key -p 10000" \
            $APP_DIR/ ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }}:${{ env.DEPLOY_DIR }}/

          ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
            echo "배포 시작..."
            sudo mkdir -p ${{ env.WEB_BACKUP_DIR }}
            BACKUP_DIR=${{ env.WEB_BACKUP_DIR }}/backup_$(date +%Y%m%d_%H%M%S)
            sudo mkdir -p $BACKUP_DIR
            sudo cp -r ${{ env.WEB_DIR }}/* $BACKUP_DIR/ 2>/dev/null || true
            echo $BACKUP_DIR > /tmp/latest_backup
            echo "백업 완료: $BACKUP_DIR"

            if [ -d "${{ env.DEPLOY_DIR }}" ] && [ "$(ls -A ${{ env.DEPLOY_DIR }})" ]; then
              sudo rm -rf ${{ env.WEB_DIR }}/*
              sudo cp -r ${{ env.DEPLOY_DIR }}/* ${{ env.WEB_DIR }}/
              sudo chown -R www-data:www-data ${{ env.WEB_DIR }}
              sudo rm -rf ${{ env.DEPLOY_DIR }}/*
              echo "배포 완료"
            else
              echo "배포할 파일이 없습니다"
              exit 1
            fi
            '

      - name: Restart Nginx
        run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo systemctl restart nginx'

      - name: Verify Nginx status
        run: |
          ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo systemctl is-active nginx || (echo "Nginx is not running" && exit 1)'
          ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'sudo nginx -t || (echo "Nginx configuration test failed" && exit 1)'

      - name: Health check
        run: |
          for i in {1..10}; do
            if ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} 'curl -f http://localhost/'; then
              echo "Health check passed"
              break
            fi
            if [ $i -eq 10 ]; then
              echo "Health check failed after 10 attempts"
              exit 1
            fi
            echo "Attempt $i: Health check failed, retrying in 10 seconds..."
            sleep 10
          done

      - name: External health check
        if: success()
        run: |
          if [ ! -z "${{ secrets.APP_URL }}" ]; then
            for i in {1..5}; do
              if curl -f ${{ secrets.APP_URL }}/; then
                echo "External health check passed"
                break
              fi
              if [ $i -eq 5 ]; then
                echo "External health check failed after 5 attempts"
                echo "::warning::External health check failed, but deployment completed"
              fi
              echo "External attempt $i: Health check failed, retrying in 10 seconds..."
              sleep 10
            done
          else
            echo "APP_URL not configured, skipping external health check"
          fi

      - name: Rollback on failure
        if: failure()
        run: |
          echo "배포 실패로 인한 롤백 시작..."
          ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} '
            if [ -f /tmp/latest_backup ]; then
              LATEST_BACKUP=$(cat /tmp/latest_backup)
              if [ -d "$LATEST_BACKUP" ]; then
                sudo rm -rf ${{ env.WEB_DIR }}/*
                sudo cp -r $LATEST_BACKUP/* ${{ env.WEB_DIR }}/
                sudo chown -R www-data:www-data ${{ env.WEB_DIR }}
                sudo systemctl restart nginx
                echo "롤백 완료: $LATEST_BACKUP"
              else
                echo "백업 디렉토리를 찾을 수 없습니다: $LATEST_BACKUP"
              fi
            else
              echo "백업 정보를 찾을 수 없습니다"
            fi
            '

      - name: Clean old backups
        if: success()
        run: ssh -i private_key -p 10000 ${{ env.BASTION_USER }}@${{ env.BASTION_HOST }} "sudo find ${{ env.WEB_BACKUP_DIR }}/backup_* -maxdepth 0 -mtime +7 -exec rm -rf {} \; 2>/dev/null || true"

      - name: Clean up
        if: always()
        run: rm -f private_key

      - name: Notify deployment status
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            COLOR="#36a64f"
            EMOJI="✅"
            STATUS="성공"
          else
            COLOR="#ff0000"
            EMOJI="❌"
            STATUS="실패"
          fi

          COMMIT_MESSAGE=$(git log -1 --pretty=%B | tr '\n' ' ' | sed 's/"/\\"/g')
          COMMIT_AUTHOR=$(git log -1 --pretty=%an)
          COMMIT_SHA_SHORT=$(git rev-parse --short HEAD)
          DEPLOY_TIME=$(TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z')

          if [ ! -z "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
            PAYLOAD='{
              "attachments": [
                {
                  "color": "'"${COLOR}"'",
                  "blocks": [
                    {
                      "type": "header",
                      "text": {
                        "type": "plain_text",
                        "text": "'"${EMOJI}"' PlayUs 배포 '"${STATUS}"'",
                        "emoji": true
                      }
                    },
                    {
                      "type": "section",
                      "fields": [
                        {
                          "type": "mrkdwn",
                          "text": "*버전:*\n'"${COMMIT_SHA_SHORT}"'"
                        },
                        {
                          "type": "mrkdwn",
                          "text": "*배포 시간:*\n'"${DEPLOY_TIME}"'"
                        }
                      ]
                    },
                    {
                      "type": "section",
                      "fields": [
                        {
                          "type": "mrkdwn",
                          "text": "*커밋 메시지:*\n'"${COMMIT_MESSAGE}"'"
                        },
                        {
                          "type": "mrkdwn",
                          "text": "*작성자:*\n'"${COMMIT_AUTHOR}"'"
                        }
                      ]
                    },
                    {
                      "type": "section",
                      "text": {
                        "type": "mrkdwn",
                        "text": "*배포 환경:*\n'"${NODE_ENV}"'"
                      }
                    },
                    {
                      "type": "context",
                      "elements": [
                        {
                          "type": "mrkdwn",
                          "text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|배포 상세 정보 보기>"
                        }
                      ]
                    }
                  ]
                }
              ]
            }'

            curl -X POST -H 'Content-type: application/json' --data "${PAYLOAD}" ${{ secrets.SLACK_WEBHOOK_URL }}
          else
            echo "Slack webhook URL not configured"
          fi

0개의 댓글