본문 바로가기
프로젝트/웹

(CICD) CICD 개선 일대기 - 2. 깃허브 액션의 중복 코드를 없애보자

by WOOSERK 2022. 12. 11.

CICD 개선 일대기

2022.12.11 - [프로젝트/웹] - (CICD) CICD 개선 일대기 - 1. 깃허브 액션과 슬랙을 연동시켜보자

2022.12.11 - [프로젝트/웹] - (CICD) CICD 개선 일대기 - 2. 깃허브 액션의 중복 코드를 없애보자

2022.12.12 - [프로젝트/웹] - (CICD) CICD 개선 일대기 - 3. 깃허브 액션 캐시로 node 빌드 속도를 높여보자


들어가기 전에

안녕하세요. 두 번째 글로 돌아왔습니다.

이틀 연속 글을 올리는 건 처음이네요. 앞으로 자주 쓸테니 재미없어도 자주 놀러와주세요.

 

이전 글에서 깃허브와 슬랙을 연동하는 방법을 소개했습니다. 이번 글은 깃허브 액션의 중복 코드를 없애는 방법에 대한 글입니다. 원래 하나의 글인데 3개로 쪼개서 올리다보니 개연성이 없어도 너그럽게 이해해주세요. 🎭

 


관리할 CI/CD 파일이 너무 많다

개발이 진행되면서 당연한 말이지만 프로젝트의 규모가 점점 커졌습니다. 이에 따라 CI/CD도 어떤 위기에 봉착했습니다. 그건 바로 파일이 너무 많아진 것.

 

우선, 처음에 저희는 4개의 파일을 관리하고 있었습니다.

integration 접미사를 가진 파일들은 CI 파일이며 deploy 접미사를 가진 파일들은 개발 서버용 CD 파일입니다.

4개도 사실 많지만 4개 정도면 관리할 만하다고 생각했습니다. 프로젝트 5주가 지난 지금은 어떨까요?

 

개발서버에 더해 실서버가 생기고, NestJS의 scheduler 기능을 담당하는 서버를 분리해서 관리해야 할 파일이 9개가 되었습니다.

 

많아도 너무 많습니다. 가독성 개선 작업을 했을 때도 어떤 파일을 수정했고, 어떤 파일을 수정하지 않았는지 몰라서 커밋에 몇몇 파일이 누락된 적도 있었습니다.

 

클라이언트와 서버는 각기 다른 배포 방식을 사용하고 있습니다. 클라이언트는 빌드 → NCP 오브젝트 스토리지에 업로드 → SSH로 서버에 접속하여 다운로드하며, API 서버는 도커 이미지화 -> 도커 허브에 업로드 -> SSH로 서버에 접속하여 다운로드하고 있습니다. 스케쥴러 서버는 API 서버와 같은 방식을 사용하고 있고요.

 

클라이언트와 서버는 그렇다 쳐도, 서버와 스케쥴러 서버는 파일 이름만 다른 수준에 불과합니다. 두 코드를 첨부하겠습니다. 코드가 길어 약간 간소화시켰습니다.

 

name: "[CD] DEV API 서버"
on:
  push:
    branches:
      - dev
    paths:
      - "server/**"
  workflow_dispatch:

jobs:
  build-and-push:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
      - name: Set up Docker Buildx
      - name: Login to Docker Hub

      - name: Build and Push to Docker Hub
        uses: docker/build-push-action@v3
        with:
          context: ./server
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/weview-dev:latest

  pull-and-run:
    runs-on: ubuntu-20.04
    needs: build-and-push
    steps:
      - name: Deploy with SSH
name: "[CD] DEV 스케쥴러 서버"
on:
  push:
    branches:
      - dev
    paths:
      - "scheduler-server/**"
  workflow_dispatch:

jobs:
  build-and-push:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
      - name: Set up Docker Buildx
      - name: Login to Docker Hub

      - name: Build and Push to Docker Hub
        uses: docker/build-push-action@v3
        with:
          context: ./scheduler-server
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/weview-scheduler-dev:latest

  pull-and-run:
    runs-on: ubuntu-20.04
    needs: build-and-push
    steps:
      - name: Deploy with SSH

 

파일명이나 경로명을 제외한 나머지는 모두 동일합니다. 앞서 슬랙 연동 기능을 구현할 때 깃허브 액션에서 if문이 사용 가능하다는 것을 배웠기 때문에 여기도 분기문으로 처리할 수 있지 않을까?라는 생각이 들어 파일을 줄여보기로 했습니다.

 

목표. API 서버와 스케쥴러 서버 파일 병합

우선 시급한 목표는 서버와 스케쥴러 서버 파일을 병합하는 것이었습니다. 이 작업만 성공해도 파일은 9개에서 6개로 줄어듭니다.

 

그런데 어떻게 개선을 할지 생각하던 도중 근본적인 문제가 떠올랐습니다.

 

근본적인 문제

두 파일을 병합하면 server와 scheduler-server 어디에 push 이벤트가 일어났는지에 따라 분기하면 됩니다.

 

하지만 두 서버의 배포는 병렬적으로 일어나야 합니다. 하나의 파일로 관리한다면 job으로 분리되어야 합니다. 즉, 합치게 되면 다음처럼 될 것입니다.

 

name: "[CD] DEV 서버"
on:
  push:
    branches:
      - dev
    paths:
			- "server/**"
      - "scheduler-server/**"
  workflow_dispatch:

jobs:
  build-and-push(api):
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
      - name: Set up Docker Buildx
      - name: Login to Docker Hub
      - name: Build and Push to Docker Hub

	build-and-push(scheduler):
		runs-on: ubuntu-20.04
    steps:
      - name: Checkout
      - name: Set up Docker Buildx
      - name: Login to Docker Hub
      - name: Build and Push to Docker Hub
# 생략 ...

 

결국 근본적인 문제인 코드의 반복이 해결되지 않습니다. 그리고 공식문서 어디에도 path에 따라 분기하는 방법이 적혀있지 않습니다.(혹시 있다면 알려주시면 감사드리겠습니다.)

→ 2022.12.15) 분기를 지원하는 액션이 있었습니다! 해당 액션을 적용한 내용을 아래 추가했습니다.

 

두 가지 문제점을 인지하고 나니 파일의 수를 줄이는 것이 대수가 아니라는 걸 깨달았습니다. 어차피 코드의 반복을 해결하면 파일의 수가 많아도 관리는 편할 것입니다.

 

그러면 어떻게 코드의 반복을 해결할 수 있을까요?

 

목표. 서버와 스케쥴러 서버 파일 병합 Reusable workflow 사용

공식문서를 보니 답이 있었습니다.

Reusing workflows - GitHub Docs

 

Reusing workflows - GitHub Docs

Overview Rather than copying and pasting from one workflow to another, you can make workflows reusable. You and anyone with access to the reusable workflow can then call the reusable workflow from another workflow. Reusing workflows avoids duplication. Thi

docs.github.com

 

하지만 비슷한 개념인 Composite action이 있어서, 둘의 차이가 뭔지 알아봤습니다. 친절하게도 깃허브 공식 블로그에 글이 있었습니다.

 

How to start using reusable workflows with GitHub Actions | The GitHub Blog

 

How to start using reusable workflows with GitHub Actions | The GitHub Blog

Reusable workflows offer a simple and powerful way to avoid copying and pasting workflows across your repositories.

github.blog

 

위 글에 따르면 Composite action은 별도의 레포지토리가 필요하기 때문에 관리 지점이 늘어나는 것을 지양하기 위해 Reusable workflow를 선택했습니다. 그리고 Composite action은 빌드시 로그가 생략된다는 점도 크게 작용했습니다.

 

Reusable workflow는 쉽게 말해 workflow의 공통 로직을 분리하기 위한 용도입니다. 저에게 아주 적합한 기능이라고 생각되어 바로 적용했습니다.

 

저희의 CI/CD 파일들이 관리하기 어려워진 가장 큰 이유는

  1. 개발 서버와 실서버가 나뉨
  2. API 서버와 스케쥴러 서버가 나뉨

입니다.

 

개발 서버와 실서버는 secrets가 접두사만 다르고 의미가 같고, API서버와 스케쥴러 서버는 파일들의 경로만 달랐습니다.

 

개발 서버의 secrets가 이정도고, 스크롤을 내리면 실서버의 secrets가 시작됩니다. 이제 reusable workflow를 적용하여 코드를 간소화시켜보겠습니다.

 

적용

앞에서 올린 yaml 코드를 보셨다면 처음부터 끝까지 로직이 같은 것을 아실 것입니다. 따라서 전체 로직을 reusable workflow로 구현한 뒤, 변수만 주입해주면 정상적으로 동작하게 됩니다.

 

name: [CD] 서버 공통
on:
  workflow_call:
    inputs:
      docker-context:
        description: "빌드할 도커파일 경로"
        required: true
        type: string
      docker-image-nam:
        description: "생성할 도커 이미지 이름"
        required: true
        type: string
    secrets:
      DOCKERHUB_USERNAME:
        required: true
      DOCKERHUB_ACCESS_TOKEN:
        required: true
      NCLOUD_HOST:
        required: true
      NCLOUD_USERNAME:
        required: true
      NCLOUD_PASSWORD:
        required: true
      NCLOUD_PORT:
        required: true

 

reusable workflow는 on 구문에 workflow_call이 있어야 합니다. workflow가 workflow를 호출할 때 수행되어야 함을 의미합니다. 이 때 호출하는 workflow를 caller workflow, reusable workflow는 called workflow라고 합니다.

 

workflow_call에는 inputs와 secrets, outputs를 정의할 수 있습니다. inputs와 secrets는 비슷하지만 secrets는 암호화된다는 이점이 있습니다. outputs는 caller가 이후 작업을 할 때 reusable workflow에서 연산하는 값이 필요하다면 정의하여 값을 사용할 수 있습니다.

 

여기까지가 필요한 변수에 대한 선언이었습니다. 이제 공통 작업을 정의할 시간입니다.

사실 공통 작업을 정의하는 건 특별할 것이 없습니다. 전체 코드입니다.

 

name: [CD] 서버 공통
# on 생략...
jobs:
  build-and-push:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

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

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v3
        with:
          context: ./${{ inputs.docker-context }}
          push: true
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.docker-image-name }}:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.docker-image-name }}:${{ github.ref_name }}

  pull-and-run:
    runs-on: ubuntu-20.04
    needs: build-and-push
    steps:
      - name: Deploy with SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.NCLOUD_HOST }}
          username: ${{ secrets.NCLOUD_USERNAME }}
          password: ${{ secrets.NCLOUD_PASSWORD }}
          port: ${{ secrets.NCLOUD_PORT }}
          script_stop: true
          script: |
            cd ~/server
            docker compose pull
            docker compose up --detach

 

secrets와 inputs로 시작하는 변수들이 있는데, 해당 값들이 주입받는 값입니다. 만약 주입하지 않으면 타입에 따라 기본으로 '' 또는 false 또는 0이 됩니다. 혹은 ${{}} 내부에 ||연산을 사용하여 다른 값을 정의할 수도 있습니다.

 

이제는 caller workflow를 살펴보겠습니다.

 

name: "[CD] DEV API 서버"
on:
  push:
    branches:
      - dev
    paths:
      - "server/**"
  workflow_dispatch:

jobs:
  reusable:
    uses: ./.github/workflows/reusable-deployment-server.yml
    with:
      docker-context: server
      docker-image-name: weview-dev
    secrets:
      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
      DOCKERHUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
      NCLOUD_HOST: ${{ secrets.DEV_NCLOUD_HOST }}
      NCLOUD_USERNAME: ${{ secrets.DEV_NCLOUD_USERNAME }}
      NCLOUD_PASSWORD: ${{ secrets.DEV_NCLOUD_PASSWORD }}
      NCLOUD_PORT: ${{ secrets.DEV_NCLOUD_PORT }}

 

job 이름은 편하게 정의하면 됩니다. 좀 더 명시적인 이름을 사용해도 되지만 길면 summary에서 job의 이름이 안보이는 불상사가 생깁니다.

 

 

reusable workflow 하나만으로 개발 서버와 실서버/API서버와 스케쥴러 서버의 공통 로직을 분리하고, 각기 다른 변수를 주입하여 구분하도록 했습니다. 이제 똑같은 코드가 여러 파일에 존재하는 상황은 사라졌습니다.

 

사실 파일이 너무 많아서 시작한 개선이었는데 오히려 파일은 reusable workflow 때문에 늘어났습니다. 하지만 코드의 반복을 해결했기 때문에 관리는 더욱 쉬울 것이라고 기대할 수 있습니다.

 

혹시나 클라이언트 코드가 궁금하실 수 있으니 아래 첨부하겠습니다.

name: "[CD] 클라이언트 공통"
on:
  workflow_call:
    inputs:
      bucket-name:
        description: "버킷 이름"
        required: true
        type: string
    secrets:
      VITE_SERVER_URL:
        required: true
      VITE_LOCAL_URL:
        required: true
      VITE_GITHUB_AUTH_SERVER_URL:
        required: true
      VITE_API_MODE:
        required: true
      NCLOUD_BUCKET_ACCESS_KEY:
        required: true
      NCLOUD_BUCKET_SECRET_KEY:
        required: true
      NCLOUD_HOST:
        required: true
      NCLOUD_USERNAME:
        required: true
      NCLOUD_PASSWORD:
        required: true
      NCLOUD_PORT:
        required: true

jobs:
  build-and-push:
    runs-on: ubuntu-20.04
    defaults:
      run:
        working-directory: client
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Use Node JS
        uses: actions/setup-node@v3
        with:
          node-version: "16.x"

      - name: Make .env file
        run: |
          echo "VITE_SERVER_URL=${{ secrets.DEV_VITE_SERVER_URL }}" >> .env
          echo "VITE_LOCAL_URL=${{ secrets.DEV_VITE_LOCAL_URL }}" >> .env
          echo "VITE_GITHUB_AUTH_SERVER_URL=${{ secrets.DEV_VITE_GITHUB_AUTH_SERVER_URL }}" >> .env
          echo "VITE_API_MODE=${{ secrets.DEV_VITE_API_MODE }}" >> .env

      - name: Install Npm Clean
        run: npm ci

      - name: Build
        run: npm run build

      - name: Push to NCP Object Storage
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.DEV_NCLOUD_BUCKET_ACCESS_KEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_NCLOUD_BUCKET_SECRET_KEY }}
          AWS_DEFAULT_REGION: ap-northeast-2
        run: aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp --recursive ./dist s3://${{ inputs.bucket-name }}

  pull:
    runs-on: ubuntu-20.04
    needs: build-and-push
    steps:
      - name: Deploy with SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.DEV_NCLOUD_HOST }}
          username: ${{ secrets.DEV_NCLOUD_USERNAME }}
          password: ${{ secrets.DEV_NCLOUD_PASSWORD }}
          port: ${{ secrets.DEV_NCLOUD_PORT }}
          script: aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp --recursive s3://${{ inputs.bucket-name }} ./client

 

22.12.15) 경로 분기 방법을 찾았습니다

조금 더 찾아보니 경로 분기를 추적하는 액션이 존재했습니다.

https://github.com/dorny/paths-filter

 

GitHub - dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch or pushed commits

Conditionally run actions based on files modified by PR, feature branch or pushed commits - GitHub - dorny/paths-filter: Conditionally run actions based on files modified by PR, feature branch or p...

github.com

워낙 갈구하던 기능이라 바로 적용했습니다.

jobs:
  path-check:
    runs-on: ubuntu-20.04
    outputs:
      client: ${{ steps.filter.outputs.client }}
      server: ${{ steps.filter.outputs.server }}
      scheduler-server: ${{ steps.filter.outputs.scheduler-server }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            client:
              - 'client/**'
            server:
              - 'server/**'
            scheduler-server:
              - 'scheduler-server/**'

 

이렇게 되면 client 아래 파일이 변경되면 출력값인 client가 true가 되고, 나머지도 동일한 방식입니다.

따라서 다음 작업에서 해당 값이 true인지 비교한 뒤 진행하면 됩니다.

 

client:
    needs: path-check
    if: ${{ needs.path-check.outputs.client == 'true' }}
    uses: ./.github/workflows/reusable-integration.yml
    with:
      working-directory: client

 

적용 후 만났던 오류

잘 동작할 줄 알았는데 예상치 못한 오류를 또 마주해버렸습니다.

오류인즉, 무조건 default branch랑 비교했습니다.

 

공식 문서를 살펴보니 baseref를 지정해주지 않으면 기본적으로 base는 default branch로, ref는 현재 브랜치로 설정된다고 합니다.

 

그래서 아래처럼 수정했습니다.

- uses: dorny/paths-filter@v2
        id: filter
        with:
          base: ${{ github.ref }}
          ref: ${{ github.head_ref }}
          filters: |
            client:
              - 'client/**'
            server:
              - 'server/**'
            scheduler-server:
              - 'scheduler-server/**'

 

github.ref는 push의 대상이 되는 브랜치를 의미합니다. 저희는 실서버 배포시 main, 배포서버 배포시 dev가 될 것입니다.

github.head_ref는 push를 하는 브랜치를 의미합니다. 저희는 실서버 배포시 dev, 배포서버 배포시 분기한 브랜치가 될 것입니다.

하지만, github.ref를 적용하고 나서 생각지도 못했던 문제가 있었는데, 해당 문제에 대해 다룬 글을 첨부하겠습니다.

https://wooserk.tistory.com/109

 

(CICD) 어디 가서 내가 깃허브 액션 만들었다고 말하지 마라~

들어가기 전에 쓸만한 소재가 생겨서 글을 또 작성하러 왔습니다. 오늘 글은 깃허브 액션 마켓플레이스(Github Actions marketplace)에 커스텀 액션을 등록하는 방법입니다. 오늘도 봐주셔서 감사합니

wooserk.tistory.com

 

그 외에는 의도대로 잘 동작했습니다! 이제 결과물을 볼까요?

 

깔끔해진 목록

reusable workflow를 적용해서 파일이 11개가 됐었는데 paths-filter 액션을 적용해 파일을 획기적으로 줄였습니다.(11개 → 6개)

 

그래서 훨씬 명확한 역할들로 파일이 분리됐습니다.(개발서버 배포, 실서버 배포, 빌드 및 테스트)

summary도 아름답게 표현이 됩니다.


긴 글 읽어주셔서 감사합니다. 3편은 캐시를 사용한 깃허브 액션 속도 개선으로 돌아오겠습니다.