iamkanguk.dev

[맵필로그] GitHub Action + Shell Script를 활용한 자동 배포 + 트러블슈팅 본문

사이드 프로젝트/맵필로그

[맵필로그] GitHub Action + Shell Script를 활용한 자동 배포 + 트러블슈팅

iamkanguk 2024. 2. 26. 16:47

현재 맵필로그 개발서버를 배포할 때 다음과 같은 Flow로 배포를 했다.

  • Local 환경에서 build
  • build 결과물인 dist 디렉토리를 파일질라를 통해 원격서버에 이동
  • PM2로 동작하고 있는 프로젝트 서버 reload

하지만 어느 순간부터 너무 귀찮기 시작했고 파일질라를 사용하는 시간이 너무 아까워서 자동배포를 구현해보았다.

원래는 GitHub Actions를 활용해서 Docker + CI/CD 작업을 하려고 했는데, 1인 개발이기도 하고 아직은 그정도까지는 필요하지 않겠다고 생각이 들었다. 그리고 요즘에는 거의 Code Deploy를 사용해서 배포 자동화를 적용하는데 나는 좀 다르게 하고 싶기도 했다. 그래서 1차 개발에서는 쉘 스크립트를 활용해서 자동 배포를 구현하고 2차 개발에서는 Docker와 CI/CD를 도입해보려고 한다.

 

참고로 원래는 shell script만 활용하려고 했는데 이것 마저도 귀찮아서 GitHub Action을 통해 특정 브랜치에 이벤트가 발생하면 자동으로 스크립트를 실행해주면 좋겠다고 생각이 들어 도입을 해보게 되었다.

 

시작 전에, 원격 서버에 npm과 pm2는 설치되어있다고 가정하고 시작하겠다.

 

1.  어떤 식으로 구현이 되는가?

 

참고로, 운영 환경에서도 동일하게 적용합니다! 그리고 지금 인프라를 배포 전에 쭉 확인하는데 여기에 Docker를 도입할 수 있습니다. Docker를 도입해도 크게 달라지는 건 없기 때문에 참고해주세요!

 

- 특정 branch에 push 또는 PR이 들어옴.

- GitHub Action에서 해당 이벤트를 확인하고 workflow 실행

- workflow를 실행 후 마지막에 deploy.sh 실행 (아래 확인해주세요!)

- deploy.sh를 실행하면 ssh 로그인을 통해 원격 서버로 접속하여 필요한 파일들 Upload 후 PM2 재시작

 

2.  서버에 자동으로 배포할 수 있는 Shell Script를 작성해보자!

(1) root에 scripts 디렉토리 생성 후 안에 deploy.sh와 원격서버의 pem 키를 넣어준다.

 

pem 키는 꼭! gitignore에 추가하는 것을 잊지 말자.  그리구 꼭 필자처럼 하지 않아도 된다. 스크립트 파일과 pem 키를 같이 두지 않아도 된다. 그냥 본인이 원하는 위치에다가 넣어주면 된다.

(2) script 파일 작성

스크립트 파일을 공유하고 하나씩 천천히 알아보자. 특별히 문법에 대해서는 설명하지 않도록 하겠다. 해당 스크립트 파일은 다른 분이 블로그에 잘 정리를 해주셔서 구현하기 쉬웠다. 필자는 정리된 블로그에서 약간 수정을 하였다. 해당 블로그는 아래 참고 자료에 올려두도록 하겠다.

#!/bin/bash

PEM_PATH=<PEM 키가 저장되어 있는 경로>
if [ ! -f $PEM_PATH ]; then
    echo "DEPLOY_FAIL: not file exist \"$PEM_PATH\""
    exit 1;
fi

ENV=<ENV 파일이 저장되어 있는 경로>
if [ ! -f $ENV ]; then
    echo "DEPLOY_FAIL: not file exist \"$ENV\""
    exit 1;
fi

# 프로젝트 빌드
npm run build

BUILD=$?
if [ ! $BUILD -eq 0 ]; then
    echo -e '\n======================================\n'
    echo 'BUILD FAILED : deploying is cancel'
    echo -e '\n======================================\n'
    exit 1;
fi

USER=ubuntu // 원격 서버에 접속하려는 유저 이름
HOST=$(grep [ENV에 작성한 원격 서버의 IP주소 KEY] $ENV | cut -d '=' -f2)
DEPLOY_PATH=<배포할 프로젝트를 저장하고자 하는 디렉토리>

SERVER=$USER@$HOST
REMOTE_PATH=$SERVER:$DEPLOY_PATH

# 원격 서버에서 기존의 배포 디렉토리 삭제 및 생성
ssh -i $PEM_PATH $SERVER "sudo rm -rf $DEPLOY_PATH/dist"
ssh -i $PEM_PATH $SERVER "sudo mkdir -p -m 777 $DEPLOY_PATH/dist"

# 로컬 머신의 파일들을 원격 서버로 전송
rsync -rltvzO -e "ssh -i $PEM_PATH" -v dist/ $REMOTE_PATH/dist
rsync -rltvzO -e "ssh -i $PEM_PATH" -v package* $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v ecosystem.config.js $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v .env $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v apple-social-login-key.p8 $REMOTE_PATH
rsync -rltvzO -e "ssh -i $PEM_PATH" -v firebase-admin.json $REMOTE_PATH

echo -e '\n======================================\n'
echo 'FILE UPLOAD DONE.'
echo -e '\n======================================\n'

# 원격 서버에서 프로젝트 의존성 설치
ssh -i $PEM_PATH $SERVER "npm --prefix $DEPLOY_PATH install"

# PM2를 사용하여 애플리케이션 실행 또는 재시작
ssh -i $PEM_PATH $SERVER "pm2 startOrReload $DEPLOY_PATH/ecosystem.config.js"

echo -e '\n======================================\n'
echo 'DEPLOY SUCCESS AND DONE.'
echo -e '\n======================================\n'

 

아마 조금 다른 부분들이 있을 것이다. 지금부터 각 줄마다 어떤 기능 + 의미가 있는지에 대해서 간략하게 설명하도록 하겠다.

  • #!/bin/bash: 해당 스크립트 파일이 Bash 쉘을 사용하여 실행되어야 한다는 것을 의미한다. 만약에 쉘을 다른 것을 쓸 경우 맞춰서 수정해주면 된다.
  • 이후 원격 서버의 PEM 키가 저장되어 있는 경로 및 .env 파일 경로 변수에 저장하고 파일이 잘 저장되어 있는지 유효성 검사
  • npm run build: 프로젝트를 빌드한다.
  • BUILD=$? + if [ ! $BUILD -eq 0 ]: 바로 위에서 실행한 프로젝트 빌드 결과를 BUILD 변수에 저장하고 빌드가 정상적으로 완료되었으면 BUILD 변수에 0을 저장할 것이다. 이후 BUILD가 0이 아닌지만 체크하면 된다. 0이 아니라는 것은 빌드에 실패했다는 의미이다.
  • 이후 원격 서버의 유저, IP주소, 디렉토리 등을 설정해준다. HOST는 원격 서버의 주소를 하드코딩이 아닌 ENV 파일에 설정한 원격 서버의 IP주소를 grep 명령어를 통해 가져온다.
  • 원격 서버에 접속을 해서 dist 디렉토리를 삭제해주고 다시 만들어준다. 새로 빌드를 해서 빌드된 파일을 전달하는 것이기 때문에 dist 디렉토리를 삭제 후 재생성 해주어야 최신 파일로 저장이 된다.
  • rsync -rltvzO -e "ssh -i $PEM_PATH" -v <옮기려고 하는 로컬 파일 경로> <원격서버에서의 옮겨진 파일 경로>
    • rsync 명령어를 통해 서버를 구동시키는데 필요한 파일들을 로컬에서 원격 서버로 옮겨준다.
  • ssh -i $PEM_PATH $SERVER "npm --prefix $DEPLOY_PATH install": 프로젝트 구동에 필요한 파일들은 전부 정상적으로 옮겼기 때문에 원격 서버에서 프로젝트 의존성 설치
  • ssh -i $PEM_PATH $SERVER "pm2 startOrReload $DEPLOY_PATH/ecosystem.config.js":  의존성 설치가 정상적으로 마무리 되었으면 생성해놓은 ecosystem.config.js를 가지고 PM2에서 startOrReload 명령어를 통해 구동

위의 과정이 수행되면 정상적으로 서버를 구동할 수 있다. 혹시 몰라 생소하신 분들이 있을 까봐 필자가 아리까리 했던 문법들도 간단하게 언급하도록 하겠다.

  • $?: 가장 최근에 실행된 명령어의 종료 상태 또는 리턴 코드를 나타낸다. 성공적으로 실행되면 0을 반환하고, 그렇지 않은 경우에는 0이 아닌 다른 값을 반환한다.
  • ssh -i <키 경로> <원격서버에 접속하려는 사용자 이름>@<원격서버의 주소> <수행 명령어>
    • ssh 명령어는 원격 서버에 접속할 때 사용하는 명령어이다. 그리고 -i 옵션은 개인 키 파일을 지정하는데 사용한다. 해당 옵션을 사용하게 되면 사용자는 특정 개인 키 파일을 지정하여 해당 키와 연결된 사용자로 원격 서버에 인증하고 접속할 수 있다.
  • rsync -rltvzO -e "ssh -i $PEM_PATH" -v
    • rsync파일과 디렉토리를 동기화하는데 주로 사용한다. 네트워크를 통해 파일을 전송할 때 유용하다.
    • -e 옵션: 원격 쉘을 사용한다는 옵션이다. 해당 옵션을 사용하면 뒤에 원격 쉘로 사용될 명령어를 작성해야 한다. 필자는 원격 서버의 PEM 키를 가지고 SSH를 통해 원격 서버와 연결한다.
    • -rltvzO 옵션: rsync에 적용할 수 있는 옵션들을 모아둔 것이다. 하나하나씩 간단하게 설명해보겠다.
      • r 옵션 (recursive):  rsync가 재귀적으로 동기화하도록 한다. 즉, 하위 디렉토리까지 복사한다는 의미이다.
      • l 옵션 (links): 심볼릭 링크 보존
      • t 옵션 (timestamp): 타임스탬프 보존
      • v 옵션 (verbose): 자세한 정보를 출력
      • z 옵션 (compress): 데이터를 네트워크를 통해 전송할 때 압축하여 전송한다
      • O 옵션 (omit-dir-times): 대상 디렉토리의 수정 시간을 설정하지 않도록 지시한다
    • rsync에 대한 자세한 설명은 해당 링크에서 참고하자! 잘 정리되어 있다.

이렇게 되면 쉘 스크립트 작성은 끝났다. 어느정도 이해하셨다고 생각하겠다. 나중에 트러블 슈팅을 언급할 때도 한번 더 보시긴 해야한다.

(3) package.json에 실행 script 작성

"scripts": { "deploy": "bash ./scripts/deploy.sh"}

 

위와 같이 작성해주시고 npm run deploy 명령어를 사용하면 된다. 마찬가지로 쉘을 어떤 것을 쓰느냐에 따라 bash 부분을 바꿔주면 된다.

3.  ecosystem.config.js 작성

module.exports = {
  apps: [
    {
      name: '[PM2 실행 애플리케이션 이름]',
      cwd: '[DEPLOY_PATH와 동일하게 작성]',
      script: './dist/main.js',
      instances: 1,
      exec_mode: 'cluster',
      kill_timeout: 5000,
      autorestart: true,
      watch: true,
    },
  ],
};

 

ecosystem.config.js에 대해서는 크게 설명하지 않도록 하겠다. pm2를 수월하게 관리하기 위해서 해당 파일을 만들어서 설정할 수 있다.

name와 cwd만 신경써서 작성해주시면 좋을듯!

 

4.  GitHub Action Workflow 작성해보자!

name: Run deploy-dev.sh on dev branch

on:
  push:
    branches: ['dev']
  pull_request:
    branches: ['dev']

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20.x]

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: npm ci
        run: npm ci

      - name: Add .env file
        run: |
          touch .env
          echo "DATABASE_HOST=${{ secrets.DATABASE_HOST }}" >> .env
          (나머지 ENV 파일도~)

      - name: Add EC2 KEY
        run: |
          touch scripts/mappilogue-ec2-key.pem
          echo "${{ secrets.MAPPILOGUE_EC2_KEY_PEM }}" >> scripts/mappilogue-ec2-key.pem
          chmod 600 scripts/mappilogue-ec2-key.pem

      - name: Add Apple Social Login Key
        run: |
          touch apple-social-login-key.p8
          echo "${{ secrets.APPLE_SOCIAL_LOGIN_KEY }}" >> apple-social-login-key.p8
          chmod 400 apple-social-login-key.p8

      - name: create firebase-admin.json
        id: create-json
        uses: jsdaniell/create-json@1.1.2
        with:
          name: firebase-admin.json
          json: ${{ secrets.FIREBASE_ADMIN_JSON }}

      - name: Apply chmod 400 to firebase-admin.json
        run: chmod 400 firebase-admin.json

      - name: Add SSH key to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.HOST }} >> ~/.ssh/known_hosts

      - name: Run deploy.sh
        run: npm run deploy

 

특별하게 GitHub Action Workflow를 작성하는 방법에 대해서는 설명하지 않도록 하겠다. 너무 다른 분들이 잘 정리를 해주셔서 참고할 사이트가 많다고 생각한다.

 

GitHub Action의 Secrets에 env 파일에 저장된 값 + key file들 값도 저장해준다. 그리고 필요한 파일들을 touch + echo 를 통해 만들어주고 chmod를 통해 권한을 설정했다.

 

중간에 create firebase-admin.json 부분 블록이 조금 생소하실 분들도 있을 수 있는데 아래 트러블 슈팅 2번을 확인해주면 좋을 것 같다.

이후 마찬가지로 권한 부여를 했으며

 

GitHub Action이 해당 클라이언트이기 때문에 known_hosts를 추가해주었다. 이것도 아래 트러블 슈팅 1번에서 확인하자!

 

위의 작업들이 완료되면 package.json에 작성했던 script를 실행시켜준다. 정상적으로 작성했다면 원격 서버에 정상적으로 배포가 된 것을 확인할 수 있을 것이다.

 

5.  겪은 트러블슈팅

내 개발 편하자고 도입했다가 내 허리와 엉덩이를 아작시킨 놈이었다. 다른 분들을 위해 내가 겪은 에러에 대해서 공유하고 피해를 보지 않도록 도와드리고 싶다.

(1) GitHub Action을 실행하면서 "Host key verification failed" 에러 발생

 

해당 문제는 GitHub Action에서 배포를 하려는 원격 서버의 SSH 호스트 키를 인식하지 못해서 발생한 문제이다. SSH를 통한 첫 번째 접속에서 호스트 키가 ~/.ssh/known_hosts 파일에 없을 때 발생한다. 따라서 필자는 다음과 같이 worflow에 추가했다.

- name: Add SSH key to known_hosts
  run: |
   mkdir -p ~/.ssh
   ssh-keyscan -H ${{ secrets.HOST }} >> ~/.ssh/known_hosts

 

ssh-keyscan은 SSH 서버의 공개 키를 스캔하고 수집하는 명령어다. 일반적으로 원격 호스트의 SSH 키를 신뢰할 수 있는지 확인하거나 known_hosts 파일에 추가하여 미래의 SSH 연결 시 호스트 키 검증 경고를 방지하기 위해 사용한다.

 

그리고 -H 옵션은 ssh-keyscan의 출력으로 나오는 호스트 키를 해시 형태로 변환한다는 의미이다. 보안 강화를 위한 하나의 방법인데 known_hosts 파일에 저장된 호스트 이름이나 IP 주소를 감춰서 정보를 파악하기 어렵게 만든다.

 

마지막으로 known_hosts는 클라이언트 노드에서 SSH를 사용하여 특정 서버의 노드로 접속하려고 할 때 서버 노드의 공개키가 클라이언트 노드의 known_hosts 파일에 저장된다. 그래서 우리가 로컬에서 원격 서버로 SSH 접속을 할 때 known_hosts 파일을 보면 뭐라뭐라 저장되는 것이다.

 

GitHub Action Workflow에 해당 내용을 추가한 이유는 GitHub Action Runner가 Client인데 known_host가 없기 때문이다.

 

(2) GitHub Action의 secret에 json을 저장했을 때 발생하는 이슈

위의 workflow 파일에서 필자는 firebase-admin.json 이라는 파일을 secret에 저장했다. 이후 workflow에서 firebase-admin.json파일을 secret에 저장되어 있는 값을 가져와서 생성하려고 했는데 json 파일의 큰 따옴표가 다 사라지고 plain text 처럼 생성되어 에러가 발생했었다.

 

조금 찾아보니까 GitHub Action 에서는 json 데이터를 secret에 저장하지 말라고 되어있다.

To help ensure that GitHub redacts your secret in logs, avoid using structured data as the values of secrets. For example, avoid creating secrets that contain JSON or encoded Git blobs.

 

그래서 필자는 jsdaniell/create-json 라이브러리를 이용해서 해결하였다.

- name: create firebase-admin.json
  id: create-json
  uses: jsdaniell/create-json@1.1.2
  with:
    name: firebase-admin.json
    json: ${{ secrets.FIREBASE_ADMIN_JSON }}

 

하지만 GitHub Action에서는 json 파일을 저장하지 말라고 권고하기 때문에 이 방법은 급하신 분들에게만 추천한다. 필자는 일단 이렇게 적용해놓기는 했는데 다른 방법을 조금 더 생각해봐야 할 것 같다.

(3) rsync failed to [set permissions / set times] on operation not permitted

필자가 참고한 블로그 글과 비교해보면 필자의 스크립트 파일에서는 rsync -rltvzO 식으로 사용하는데 블로그 글에서는 rsync -avz 라고 작성되어 있다. 블로그 글을 참고해서 진행해보면 permissions / times 에러가 발생했다.

 

rsync 옵션을 avz에서 rltvzO로 바꾸니까 해당 문제는 해결이 되었다. 위에서 문법 설명을 해놨으니 참고하면 되겠다.

참고로 rsync에서 a 옵션은 Archive 모드라고 하는데, -rlgptgoD 옵션을 적용한 것과 같다.

 

  • time 이슈: rsync가 대상 시스템에서 파일 또는 디렉토리의 수정 시간을 변경하는데 필요한 권한이 없을 때 발생한다. 필자는 -O 옵션을 추가하면서 rsync가 디렉토리의 수정 시간을 변경하지 않도록 했다.
  • permission 이슈: rsync가 대상 시스템에서 파일 또는 디렉토리의 권한을 설정하려고 할 때 필요한 권한이 없을 때 발생한 이슈이다. -a를 사용하면 파일 권한, 소유자, 타임스탬프 등을 보존하는데 때로는 일부를 설정하는데 필요한 권한이 없어서 에러가 발생할 수 있다. 그래서 -a 대신에 필요한 부분만 옵션을 넣어서 사용한 것이다. 이 때 rsync 옵션에서 권한에 관련된 옵션은 제거하고 넣었다.

필자는 일단 이렇게 했는데 권한적으로 문제는 없다고 판단되어서 Fix 했다. 하지만 조금 더 엄격한 권한이 필요하다고 하다면 해당 해결방법 보다는 다른 해결법을 추천드린다. 하지만 자료는 별로 없다... ㅠ

(4) rsync: connection unexpectedly closed (0 bytes received so far) [sender]

해당 에러는 rsync를 활용해서 파일 전송 과정에서 연결이 예상치 못하게 끊겼다는 것이다. 보통 네트워크 문제, 권한 문제 혹은 원격 서버의 문제로 발생하는데 필자의 경우 위의 에러들이 발생하면서 생긴 에러여서 위의 트러블슈팅들을 해결하니 자동적으로 해당 메세지가 사라졌다.

(5) [sender] expand file_list pointer array to 1024 bytes, did not move

해당 에러는 아무리 봐도 무슨 에러인지 모르겠다.. 뭘 1024 바이트까지 확장하려고 했는데 잘 안된 것 같다라고 말하는데 지금 현재 배포는 잘 되고 있는 상황이다. 조금 더 알아보고 블로그 수정을 하도록 하겠다.

 

6.  후기

조금 시간이 오래 걸렸고 막 고도화 된 작업은 사실 아니다. 그래도 내가 평소 귀찮아했고 불편했던 작업들을 이렇게 자동으로 되게끔 해결을 하니까 뭔가 뿌듯하다. 하지만 이거 에러들 막 해결하고 뭐하고 뭐하느라 쓸데없는 커밋도 많이 했고 다른 작업들도 못했던 것 같다 ㅠ

언넝 블로그 글 마무리하고 다시 이력서쓰로...


참고 자료

- https://develop-const.tistory.com/m/43 (필자가 제일 먼저 참고한 블로그입니다! 좋은 자료 감사합니다.)

 

aws 스크립트 사용해 nest js 자동배포하기

기존에 ssh로 접속해 Git clone 받고 build 하고 install하고 pm2 start 하는 과정이 매우 번거롭게 느껴졌을텐데요. 이와 같이 반복되는 작업을 자동화하는 것은 정말로 효율적이며 시간을 절약할 수 있

develop-const.tistory.com

 

- https://velog.io/@godkimchichi/Github-Actions-secret%EC%97%90-json-%EB%84%A3%EA%B3%A0-%EC%8B%B6%EC%9D%84-%EB%95%8C

 

[Github Actions] secret에 json 넣고 싶을 때



velog.io

 

- https://gomu92.tistory.com/15

 

[llinux] ".ssh/known_hosts"?

-"~/.ssh/known_hosts"란? 클라이언트 노드에서 ssh를 사용하여 서버 노드로 접속할 때, 서버 노드의 공개 키가 클라이언트 노드의 "~/.ssh/known_hosts" 파일에 저장됨. known_hosts 파일에 서버 노드의 공개 키

gomu92.tistory.com

 

 

 

- https://inpa.tistory.com/entry/node-%F0%9F%93%9A-PM2-%EB%AA%A8%EB%93%88-%EC%82%AC%EC%9A%A9%EB%B2%95-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EC%84%9C%EB%B9%84%EC%8A%A4

 

[NODE] 📚 PM2 모듈 사용법 - 클러스터 / 무중단 서비스

Node.js 싱글 스레드 Node.js는 Chrome의 V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임(runtime)으로 ‘Event Driven’, ‘Non-Blocking I/O’ 모델을 사용해 가볍고 성능이 뛰어나 높은 평가를 받고 있

inpa.tistory.com