기타

생산성 향상을 위한 배포 자동화 Github action, 웹훅, Slack notification

변기원 2024. 9. 16. 00:38

프로젝트가 어느 정도 안정기에 들어감에 따라 시간이 많이 들어가는 작업들을 자동화하고 있다.

그중, 테스트 서버에 배포하는 과정을 자동화 한 경험과 트러블슈팅.

테스트 서버의 특징은 배포되는 브랜치가 정해져 있는 게 아니라 매번 현재 작업 중인 기능을 테스트할 때마다 브랜치를 옮겨 다닌다.

기존에는 직접 ssh로 원격 서버에 접속해서 git branch를 옮겨 다니며 직접 pm2로 프로젝트를 실행시켜 줬다.

개발자가 직접 원격에 접속하고, 명령어를 하나하나 입력하고 혹은 빌드 에러나 예상치 못한 에러가 발생하는 경우 갑작스레 pm2 프로세스가 완전히 내려가버리는 일도 있어서 "지금 ~~~ 한 작업 중이라 테스트서버 접속 이상 있다"라고 소통해야 하고 

팀원들에게 불편을 초래하는 경우도 있었다.

이런 일을 없애고 개발자가 직접 원격 서버에 접속하는 일을 없애고자 시작하게 되었다.

1. 배포용 브랜치 생성

- 매번 브랜치가 변경되며 빌드되어야 하는 특성이 있어서 어떻게 배포를 자동화해야 할지 고민하다가 테스트 서버용 브랜치를 생성하기로 했다. 배포하고자 할 때는 해당 브랜치에 작업브랜치를 강제푸시 한다. 테스트 서버는 결국 원격 작업브랜치와 커밋내역만 일치하면 되기 때문에 배포용 브랜치에 작업브랜치를 강제푸시 하도록 했다.

 

2. 쉘스크립트 작성

source ~/.bashrc
cd {/프로젝트폴더}
sudo git pull
sudo pnpm i
sudo pnpm build
sudo pm2 kill
sudo pm2 start pnpm -w -i 1 --name "next" -- start
cd ..

기존에 작성되어 있던 단순한 쉘스크립트이다. 쉘스크립트가 작성되어 있긴 했지만 개발자가 직접 git status를 청소해 주고 git branch를 옮겨 다니기는 여전했다. 그리고 만약 pnpm build시점에 에러가 발생해도 pm2 kill, pm2 start가 실행되어서 아예 서비스가 내려가는 경우가 몇 번 있었다. 이 두 가지 불편함을 해결해야 한다.

#!/bin/bash
set -e

# 함수 정의 : 슬랙 메세지 전송
send_slack_message() {
        local message=$1
        local status=$2
        local emoji=":ghost:"
        [[ "$status" == "success" ]] && emoji=":white_check_mark:" || emoji=":x:"
        curl -X POST --data-urlencode "payload={\"text\": \"$message\", \"icon_emoji\": \"$emoji\"}" $SLACK_MESSAGE
}

handle_error(){
        send_slack_message "Test서버 deploy 실패: $1" "failure"
        exit 1
}

# 환경 변수 로드
source ~/.bashrc

# 프로젝트 디렉토리로 이동
cd {/프로젝트폴더}

# 현재 변경사항 모두 제거
sudo git reset --hard
sudo git clean -fd

# 원격의 최신 변경사항 가져오기
sudo git fetch origin test || handle_error "git fetch 실패"

# 로컬 브랜치를 원격의 test 브랜치로 강제 리셋
sudo git reset --hard origin/test

# 의존성 설치
sudo pnpm i || handle_error "의존성 설치 실패"

# 프로젝트 빌드
sudo pnpm build || handle_error "빌드 실패"
# PM2 프로세스 모두 종료
sudo pm2 kill

# 새 PM2 프로세스 시작
sudo pm2 start pnpm -w -i 1 --name "next" -- start || handle_error "pm2 시작 실패"

# rsync를 통한 파일 동기화 (이 부분은 필요한 경우에만 사용)
# rsync -avz --delete -e "ssh -i ~~~~~~

# 최신 커밋 정보 가져오기
COMMIT_INFO=$(git log -1 --pretty=format:"%h - %s (%an)")

echo "Deployment completed successfully"

send_slack_message "Test서버 deploy 성공!\n> \`$COMMIT_INFO\`" "success"

# 상위 디렉토리로 이동
cd ..

테스트용 브랜치가 확정되었으므로 쉘스크립트 내에서 git status를 청소해 주고 최신 커밋을 가져오는 모든 행동을 할 수 있다.

그리고 만약 중간에, 예를 들면 pnpm build시점에 에러가 발생하면 에러처리를 해서 handle_error 함수로 에러를 처리한다. 

슬랙 메시지를 통해 에러가 어떤 단계에서 발생했는지 정확히 알려주고 exit 1을 통해 스크립트를 종료시켜서 비정상인 경우 pm2 kill까지 실행되지 않도록 하여 웹 프로세스가 완전히 내려가는 일을 방지한다.  

여기까지 하고 수동으로 test브랜치에 직접 강제푸시해서 쉘스크립트를 실행해서 배포가 제대로 되는지까지 테스트하면 된다.

만약 수동으로 sh를 실행시켜서 배포가 잘 된다면 이제 깃헙 액션을 사용해서 자동으로 sh파일만 실행시켜 주면 된다.

 

3. 깃헙 액션 SSH 접속

깃헙 액션 yml 파일을 작성해서 SSH로 원격 접속 후, deploy.sh파일을 실행하려 했지만 우리 서비스는 vpn을 통해 보안이 되어있었기 때문에 깃헙액션 우분투 가상환경 ip로는 접속할 수 없었다.

 

4. 서버에 sh파일 실행하는 express 서버 띄우기

어떻게 해야 하나 고민하다가 서버 내부에 express 서버를 하나 띄우고 파일 시스템을 통해 deploy.sh파일을 실행시키는 api를 하나 만들었다. 이 api만 호출하면 되므로 외부 ip에서 접속을 하거나 그럴 필요는 없었다.

// server.js
const express = require('express');
const { execFile } = require('child_process');
const crypto = require('crypto');

const app = express();
const port = 포트번호;
const secretKey = 시크릿키;
const deployScriptPath = '{파일 path}/deploy.sh';

app.use(express.json());

app.post('/deploy-test', (req, res) => {
  const signature = req.headers['x-hub-signature-256'];
  const payload = JSON.stringify(req.body);
  const hmac = crypto.createHmac('sha256', secretKey);
  const digest = 'sha256=' + hmac.update(payload).digest('hex');

  if (signature === digest) {
    execFile(deployScriptPath, (error, stdout, stderr) => {
      if (error) {
        console.error(`execFile error: ${error}`);
        return res.status(500).send('Error executing deploy script');
      }
      console.log(`stdout: ${stdout}`);
      console.error(`stderr: ${stderr}`);
      res.status(200).send('Deploy script executed successfully');
    });
  } else {   
 	res.status(403).send('Invalid signature');
  }
});

app.listen(port, () => {
  console.log(`Webhook server listening port: ${port}`);
});

이제 이 api 엔드포인트로 post요청을 보내면 deploy.sh파일이 실행된다.

 

5. 깃헙액션 수정

name: Deploy Test Branch

on:
  push:
    branches:
      - test

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Call Deploy Webhook API
        run: |
          PAYLOAD='{"ref":"${{ github.ref }}","sha":"${{ github.sha }}"}'
          SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.WEBHOOK_SECRET }}" -binary | xxd -p -c 256)
          curl -X POST "http://YOUR_DOMAIN:YOUR_PORT/deploy-test" \
          -H "Content-Type: application/json" \
          -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
          -d "$PAYLOAD"

단순히 api만 요청하면 되므로 다른 jobs는 필요 없고

우분투 가상환경에서 curl로 post요청만 보내면 된다.

secrets는 깃허브 레포지토리 setting에서 Repository secret에 환경변수이다. DEPLOY_URL, SECRET_KEY를 입력해야 express 서버에서 if (signature === digest) 이 조건이 참이 되어 execFile이 실행된다.

-d 로 data를 첨부하면 서버에서는 req.body로 받고, -H는 헤더로 첨부하게된다. 서버에서는 헤더의 signature와 바디의 데이터로 직접 만든 digest를 비교한다. 이 digest와 signature가 일치해야 동일한 비밀키로 비교되므로 올바른 요청임을 알수있다.

 

이제 모든 준비가 완료되었다. test브랜치에 강제푸시를 매번 직접 입력하기 어려우니 스크립트를 작성해서 팀원들이 사용할 수 있도록 하자. deploy:test와 같이 알기 쉽게 만들어서 작업 브랜치 터미널에서 바로 테스트 서버로 디플로이 할 수 있도록 하자.

그럼 test 브랜치에 강제푸시가 들어간다. github action이 실행되어 DEPLOY_URL로 post요청이 발생한다.

서버의 express서버가 이 요청을 받아서 유효한 signature인지 판단한 후에 deploy.sh를 실행한다.

deploy.sh는 현재 test 브랜치로 커밋내역을 reset 하고 순차적으로 과정을 거친 후 pm2 start를 한다.

만약 모든 과정이 성공하면 slack을 통해 팀원들에게 어떤 서버에 배포가 성공했다고 알려준다.

 

결국 개발자는 작업브랜치에서 deploy:test 같은 스크립트를 한번 입력하는 것 만으로 모든 과정을 자동화할 수 있다. 

심지어 배포가 완료되면 팀원들에게 noti를 보내서 소통을 줄일 수도 있었다.