GitHub Action 정리

민수빈·2022년 6월 10일
2
post-thumbnail

자동 배포를 간단히 구성하기 위해 GitHub Action을 주로 사용하는데
매번 사용만 하고 넘어가서 매번 다시 찾아보고 떠올리는데 시간이 들곤했다. 🤣
이번 자동 배포를 구성하면서 겪었던 문제와 주로 사용하는 것들을 기록해놓자.

개념

Workflow

  • 여러 Job으로 구성
  • Event로 트리거
  • YAML 파일로 작성 (.github/workflow 에 위치)

Event

  • Workflow를 실행하는 특정 활동이나 규칙
    • 특정 브랜치로 Push
    • 특정 브랜치로 PR
    • 특정 시간대에 반복 (Cron) 등

Job

  • 여러 Step으로 구성
  • 다른 Job의 실행에 의존 관계 가지거나 독립적일 수 있음

Step

  • Task 집합
  • 커맨드 실행하거나(run:) Action 실행(uses:) 가능

Action

  • Workflow에서 가장 작은 블럭
  • 재사용 가능한 컴포넌트
  • 개인적 Action / Marketplace의 공용 Action 모두 사용 가능

Runner

  • Workflow 실행 인스턴스
  • GitHub-hosted runner (Github 호스팅) / Self-hosted runner

실전

구성해야할 배포 환경은 나름(?) 간단하다. (아직 다 안했기 때문에 하는 말)
TypeScript 기반의 Node.js 서버를 Docker 컨테이너로 띄우는 것을 자동화하자.

코드를 작성하기 전에 배포를 위해 거쳐야하는 단계를 정리해보고 시작하자.
() 로 표시한 부분은 추후에 적용할 예정이자 아직 적용하지 못한 스텝이다.
1. GitHub를 통해 프로젝트 코드를 가져온다.
2. Config 파일과 같이 필요한 Secret들을 생성해놓는다.
3. Docker를 셋업한다.
4. 프로젝트의 Dockerfile을 통해 Docker image를 생성한다.
5. (Docker image 빌드시 node_module을 캐싱한다.)
6. Docker Hub에 빌드한 이미지를 Push한다.
7. SSH를 통해 배포할 서버에 접속한다.
8. 빌드했던 이미지를 가져와 Container를 실행한다.
9. (Nginx 설정을 통해 HTTPS 요청을 받아 컨테이너로 리버스 프록시한다.)

먼저 주의할 점

GitHub Action 스크립트를 YAML로 작성하기 때문에
공백과 같은 indentation이 틀리게되면 Action이 제대로 동작하지 않을 수 있다.
(Action이 돌아가지 않고 바로 실패하거나 지나치게 길게 대기만(Queued)한다면 YAML의 문법이 틀린 것이 아닐 지 의심해보자!)

GitHub 홈페이지에서 기본적인 GitHub Action YAML들을 확인해볼 수 있으므로 indentation과 문법에 주의해서 YAML 파일을 작성하자.
(GitHub Repository > Action > New workflow)

(다양한 종류의 Workflow 별로 기본 템플릿이 제공된다.)

Tip

바로 정말 배포하고 싶은 프로젝트에서 진행하기보다
Forked한 레포나 개인 레포를 따로 만들어서 실행해보는 것이 더 좋다.
그렇지 않으면 쓸데없는 커밋이 쌓이기 쉽다. (생각보다 공백, 철자 등 사소하게 수정해야하는 것이 많은데 그럴때마다 배포되는 브랜치에 푸시하기 부담스럽다.)

(이렇게 막 배포해볼 거라면 더더욱.. 😅)

삽질 과정

이상한 삽질을 많이 했다. (이래서 자주자주 해봐야하는 건데.. 배포는 몰아서 하게 된다)
문제를 겪었던 부분을 간략하게나마 정리했다.

indentation 처리 & job마다 runs-on 속성을 명시

YAML 인덴트 체크를 해주는 좋은 사이트가 많으니 활용하도록 하자...
example validator site
Github Preview를 통해 확인해보는 것도 좋은 것 같다!

job은 runs-on 속성을 통해 해당 job을 처리할 runner를 정한다.
job은 job마다 독립적으로 처리되므로 job 마다 명시해줘야한다.

jobs:
  build:
    runs-on: ubuntu-latest
  • self-hosted
    runs-on: [self-hosted, linux]
  • GitHub-hosted
    runs-on: ubuntu-latest

코드 수정하다가 빼먹은 step 추가

YAML을 올리기 전에는 indentation과 내용을 다시 한 번 확인하자..

데이터를 공유하기 위해 Job을 하나로 통합

GitHub Action의 job은 서로 독립적이다.
물론 의존 관계(실행 순서)를 명시하기 위해 needs와 같은 속성을 사용할 수 있지만 결국 다른 job은 서로 다른 runner에서 동작한다.
따라서 기존 job에서의 데이터는 Docker Hub와 같은 별개의 저장소에 올라가지 않는 한 공유할 수 없다.
따라서 같이 사용해야할 데이터(config 파일이나 코드 등)가 있다면 같은 Job으로 구성하자.
(build와 deploy를 다른 job으로 구성했다가 파일이 없다는 에러를 몇 번이나 보고서야 깨달았다. 😱)

echo 커맨드를 통해 JSON 파일 작성시 double quote(") escape 처리

JSON과 같이 프로퍼티를 "와 같이 감싸줄 필요가 있는 경우라면 커맨드를 작성할 때 더 주의하자.
echo에서 "는 명령문 내의 문자열을 나타내므로 \"처럼 이스케이프 처리를 해주지않으면 파일에 작성되지 않는다.

여러줄 echo를 통한 파일 작성을 다음과 같이 작성할 수 있다.

steps:
      - name: Make File
        env:
          FOO: ${{ secrets.FOO }}
          VAR: ${{ secrets.VAR }}
        run: |
          echo   "{
           \"example\": {
              \"foo\": \"$FOO\",
              \"bar\": \"$BAR\",
            }
          }" > example.json

ssh-action key 대신 password 사용

SSH 접속시 패스워드를 통해 접속해야하는 것을 key 속성을 계속 명시하고 있었다.
무언가를 사용할때는 공식 문서를 꼼꼼히 읽자.. apleboy/ssh-action

Dockerfile 변경

TSC로 컴파일해야했고 npm install을 통해 의존성을 설치해줄 필요도 있었다.
초기에는 tsc를 통해 먼저 js 변환 후 node_modules를 설치해줄 계획으로 다음과 같이 Dockerfile을 작성했다.

# node 14 버전 베이스
FROM node:14
# /app이라는 작업 공간을 생성
WORKDIR /app
# 현재 디렉토리의 모든 파일을 /app 내부로 복사
COPY . .
# tsc를 위해 typescript를 전역적으로 설치 
RUN npm install -g typescript
# ts를 js로 컴파일
RUN tsc --build
# 의존 모듈 관련 package.json package-lock.json js 위치 디렉토리로 복사
# (tsconfig.json의 "outDir":"디렉토리명"을 통해 결과 디렉토리를 지정한다.)
COPY packages*.json ./build

RUN cd build
# 해당 디렉토리 내부에서 node_modules 설치 
RUN npm install
CMD ["node", "app.js"]
EXPOSE 8080 

보다시피 의식의 흐름대로 작성해서 복사했던 파일을 다시 복사하기도 하고
디렉토리를 이동하는 등의 작업때문에 경로가 헷갈리기 쉽다.
따라서 다른 Dockerfile을 참조해서 최대한 단순히 변경했다.

FROM node:14
WORKDIR /app
COPY package.json .
RUN npm install && npm install typescript -g
COPY . .
RUN tsc
CMD ["node", "./build/app.js"]
EXPOSE 8080

tsc 컴파일보다 의존성 모듈 설치를 먼저하는 순으로 변경되었다.

배포 과정 중에 이미지 빌드가 가장 오래걸려서 (npm install 이 가장 오래 걸린다.)
차후에 node_modules를 캐시해서 이미지 빌드에 소요되는 시간을 줄여야겠다.

Docker container - Host local service 연결 가능하도록 설정

우리 프로젝트는 테스트할 때는 AWS RDS를 DB로 사용하고
실제 배포 서버에서는 서버 내부에 MySQL을 설치 후 사용하기로 했다.

즉 Node.js 서버를 Docker container로 실행시 Host MySQL을 연결해야했다.
Container의 localhost는 도커 컨테이너 내부를 가리키므로 당연히 서버(호스트)의 MySQL과 연결되지 않는다.

해결책을 찾아본 결과 host.docker.internal을 찾을 수 있었다.
Docker에서는 호스트의 내부 IP 주소와 매칭되는 특별한 DNS 이름인 host.docker.internal을 제공한다. (관련 Docker 문서)

Mac의 Docker Desktop의 경우 바로 사용할 수 있기 때문에 로컬에서 이미지 빌드 후 컨테이너 실행하니 바로 잘 연결되었다.

binimini/test-growth:latest 이미지가 생성된 것을 볼 수 있다.

Linux에서의 Docker를 위한 설정 적용

그리고 행복하게 배포 스크립트를 돌렸다.
(그건 문서를 꼼꼼히 읽지 않은자의 행복회로였다고)

하지만 배포 서버의 컨테이너는 getaddrinfo ENOTFOUND host.docker.internal와 같은 에러를 내면서 해당 DNS name을 인식하지 못했다.

찾아본 결과 18.03 이전의 host.docker.internal은 Mac과 Windows에서만 지원되었다. 하지만 20.10.0 버전의 Docker부터 Linux에서도 지원된다.

그러면 왜 안됐을까?

하지만 Linux에서 사용하기 위해선 추가적인 태그를 적용시켜줘야했다.
컨테이너 실행시 --add-host=host.docker.internal:host-gateway 태그로 host.docker.internal을 사용할 수 있다. (관련 글)

이때의 --add-host는 Docker container 내부에 host(와 그에 매칭되는 IP 주소)를 추가한다. 즉 위의 명령문을 통해 host.docker.internal이라는 host명에 host-gateway를 매칭한다.
이를 대체해서 Docker에서의 default bridge network를 위한 gateway 주소인 172.17.0.1를 사용할 수도 있다고 한다.

이런 Docker에서의 bridge와 network 관련해서 추후에 추가로 알아봐야할 것 같다. 🙃

전체 파일

Dockerfile (Velog는 코드블록에 Dockerfile 어떻게 적용하나요 😥)

FROM node:14
WORKDIR /app
COPY package.json .
RUN npm install && npm install typescript -g
COPY . .
RUN tsc
CMD ["node", "./build/app.js"]
EXPOSE 8080

deploy.yaml

name: Deploy

on:
  push:
    branches: ['dev']

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout GitHub
        uses: actions/checkout@v2

      - name: Make secrets
        env:
          USERNAME: ${{ secrets.DB_USERNAME}}
          PASSWORD: ${{ secrets.DB_PASSWORD}}
          DATABASE: ${{ secrets.DB_DATABASE}}
        run: |
          mkdir config
          echo   "{
           \"development\": {
              \"username\": \"$USERNAME\",
              \"password\": \"$PASSWORD\",
              \"database\": \"$DATABASE\",
              \"host\": \"host.docker.internal\",
              \"dialect\": \"mysql\"
            }
          }" > config/db-config.json
      - name: Set up Docker
        uses: docker/setup-buildx-action@v2

      - name: Build image
        run: docker build . -t ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest

      - name: Docker login
        run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin

      - name: Push image
        run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest

      - name: SSH remote and Run Docker container
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.REMOTE_IP }}
          username: ${{ secrets.REMOTE_USERNAME }}
          password: ${{ secrets.REMOTE_PASSWORD }}
          port: ${{ secrets.REMOTE_PORT }}
          script: |
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest
            docker stop growth-be
            docker rm growth-be
            docker run --restart=unless-stopped --add-host=host.docker.internal:host-gateway -d -p 8080:8080 --name growth-be ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest
            docker rmi $(docker images -f "dangling=true" -q)

간략히 단계를 살펴보자.

GitHub Action을 정의하는 Workflow YAML 또한 GitHub에 올라가므로 숨겨야할 정보는 입력할 수 없다. 따라서 GitHub Repository Secret을 통해 넘겨주도록 했다. (GitHub Repository > Settings > Secrets > Actions > Repository secrets)

넘겨주는 시크릿 값으로는 배포 서버 IP, DockerHub 계정명, 비밀번호 등등이 있다.

릴리즈 브랜치가 있다면 릴리즈 브랜치에 명시하겠지만, 우리 프로젝트는 따로 존재하지 않으므로 dev 브랜치에 푸시될 경우 배포되도록 했다.

      - name: Checkout GitHub
        uses: actions/checkout@v2

기존의 checkout 액션을 이용해서 GitHub 코드를 가져온다.

	  - name: Make secrets
      //..

Node.js 서버에서 DB와 관련된 설정 파일을 필요로 하므로 echo 커맨드를 사용해서 config 디렉토리 내 JSON 파일을 만들어낸다.
(추후에 설정 파일이 많아지면 방식의 개선이 필요할 것 같은데 좋은 방법을 아직 찾지 못했다. 😹)

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

      - name: Build image
        run: docker build . -t ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest

만들어져있는 액션을 이용해 GitHub Runner에 Docker Set up하고
받아왔던 프로젝트 코드와 프로젝트 내부의 Dockerfile을 통해 image를 빌드한다.
(<넘겨주는 username>/growth-be:latest로)

	  - name: Docker login
        //..

      - name: Push image
        //..

도커 허브 계정에 로그인한뒤 만든 이미지를 푸시한다.

- name: SSH remote and Run Docker container
        uses: appleboy/ssh-action@master
        //..
          script: |
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest
            docker stop growth-be
            docker rm growth-be
            docker run --restart=unless-stopped --add-host=host.docker.internal:host-gateway -d -p 8080:8080 --name growth-be ${{ secrets.DOCKERHUB_USERNAME }}/growth-be:latest
            docker rmi $(docker images -f "dangling=true" -q)

최종적으로 appleboy/ssh-action 액션을 이용해서 이후 기존에 서버에 접속한후, 컨테이너를 필요한 옵션에 따라 돌려주면 끝이다!
script에 뭐가 많은 것 같지만 사실 하는 게 많지 않다.

  • 기존 컨테이너를 정지
  • 기존 컨테이너를 삭제
  • 필요한 태그로 컨테이너 실행
  • (이미지가 최신화되면서) latest 태그가 없어진 이미지를 삭제

Docker container 옵션 설명

  • --restart-unless-stopped stop을 통해 중지하지 않는한 에러로 종료되어도 재시작한다. (예상치 못한 에러로 서버가 멈춰도 아예 다운되는 것을 방지하기 위해 사용했다.)
  • --add-host=[] 위에서 설명한 Linux 환경에서 host.docker.internal 사용하기 위한 옵션
  • -d container를 background로 실행
  • -p <호스트 포트>:<컨테이너 포트> container의 포트와 로컬의 포트를 매칭한다.
  • --name="이름" 컨테이너의 편리한 처리를 위해 이름 부여 (logs, stop, rm시 용이하기 위해서)

결과

사실 도커 이미지를 자동 배포할때 가장 좋은 것은 버전을 명시해주는 것이라고 생각한다. (현재 방식은 롤백하거나 버전 구분이 어렵다.)
따라서 릴리즈 브랜치를 분리한 경우 Docker image에 버전을 명시해서 롤백하기 쉽도록 하는 게 좋을 것 같다.

추후에 추가 구현하면 수정하러 오자! 🙌

profile
개발 기록. 이전 블로그 (알고리즘 위주) : https://blog.naver.com/tnqls5417

0개의 댓글