[🚗 자동화 개발 회고] Husky, git action, ECR, ECS를 이용한 안정적 배포환경 구축 ( 1 )

devAnderson·2024년 3월 15일
1

TIL

목록 보기
102/106

🚍 Intro

최근 프로젝트를 다시 재구성하게 되는 상황을 마주하게 되었다.
그 과정 가운데 새로운 레포지토리를 기반으로 프로젝트를 재구축하는 작업을 진행하던 와중에 조금 더 편하게(?) 작업할 방법을 찾고싶었던 반복작업들이 있었다.

반복작업의 내용은 주로 개발 컨벤션과 관련된 내용들이 관련이 많았다.
image

협업에 있어서 컨벤션은 정말 중요한 역할을 한다고 생각한다. 추후 유지보수를 위해서라로 특정한 룰에 따라서 서로 같이 코드의 품질을 유지해나가는 것은 중요하기 때문이다.

하지만, 가끔 너무 정신없이 개발을 하다 보면 컨벤션을 지키지 못하거나 의도치 못한 휴먼에러(오타,오타,오타....) 들을 마주하게 되는 상황이 많았다.

그래서 그런 반복작업에 의한 휴먼 에러를 최대한 줄여보는 방향을 생각해보다가, 이미 이전에 개인적으로 모듈을 만들어본 기억을 회고로 남겼던 기억을 토대로 자동화를 구축해보았다.

진행했던 개발 자동화의 내용은 아래와 같다.

a. husky를 이용한 local validation,
b. git action을 이용한 CI/CD
c. script를 활용한 issue 및 pull request 자동화
d. vscode의 extension에 의한 폴더 구조 자동생성


0. 🖨 자동화가 필요한 이유

이미 위에서 설명한 내용이지만 명확하게 구분짓기 위해서 정리하려 한다.

1. 예기치 못한 human error의 방지

사람은 기계가 아니기 때문에 실수를 할 수밖에 없다.

좋은 개발환경과 관습을 위해서 정책을 잘 정의한다 하더라도 지켜지지 않으면 의미가 없으며, 정신없이 개발을 하다 보면 늘 지켜지기를 희망하는 것 자체가 Naive한 사고방식일 수 있다. 이런 불필요한 에러를 줄이는 일로 인해 전체적인 코드 품질을 균일화시킬 수 있다.

2. 개발 자체에 집중할 수 있는 환경 조성

배포 작업은 필요한 일이지만, 생각보다 몹시 귀찮은 일이다.

열심히 개발을 진행하고 나서 배포를 해야 하는데 회사 정책마다 다른 배포 프로세스를 학습하고 이것을 매번 개발 및 유지보수를 진행할 때마다 30분씩 실행해야 한다고 가정하자. 이는 개발자에게 개발을 할 수 있는 시간 30분이 반복작업을 실행하기 위해 소요되는 것과 같다.

자동화를 진행하게 된다면 개발자는 개발에 대한 책임과 관심을 오로지 자동화 프로세스에게 맡겨놓고 남은 시간을 더 발전적이고 생산적인 곳에 사용할 수 있다.

3. TDD

배포 자동화 과정을 구축하는 것은 다르게 말하면 커다란 개념으로 배포라는 작업에 대한 테스트 기반 개발을 진행하는 것과 같다.

각 과정에서 자동적으로 실행해주는 절차에 대해서 실패를 할 경우, 배포라는 작업이 완료되지 않는다는 점에서 해당 작업을 정의해두는 것 만으로도 이미 배포 작업의 각 프로세스들에 대한 테스팅을 자동으로 해주면서 안정적으로 배포를 할 수 있는 환경을 구축해주는 것이다.

이를 통해 조금 더 신뢰성이 높고 정교한 프로젝트를 유지할 수 있다.

4. 유지보수 및 인수인계를 위한 docs로써의 기능

CI/CD를 진행하면서 알게 되었지만, 자동화를 실행시키기 위해 작성해야 하는 yml 파일은 그 자체가 훌륭한 "배포 프로세스 설명서" 가 된다.

여지껏 경험해본 회사는 모든 배포 프로세스를 Notion과 같은 문서로 작성하는 경우가 많았는데, 이 Docs가 잘 작성되어 있기를 기대하기가 어렵다. 왜냐하면 보통 퇴사하기 직전에 이를 작성하는 경우가 많기 때문이다. ( 대부분 과정 일부가 빠져있거나 관습적으로 담당 개발자만 알고 있는 프로세스가 존재하는 등...)
이런 상황에서 새로운 동료가 입사를 했을 때 매번 불완전한 Docs를 확인시키기보다 자동화 테스트 검증이 완료된 yml 파일을 전달해 주고 이를 읽어보게 제안하는 것이 더 효율적인 인수인계 과정이 될 수 있다.

<굳이 자동화를 사용하지 않더라도, 프러덕트의 배포 프로세스를 설명해주는 yml의 작성은 반드시 필요한 과정이라고 이번 경험을 통해 크게 느끼게 되었다.>


✍️ 1. (local client) Husky를 이용한 local validation

CI CD에 대한 말은 많은 블로그 내용에서 들어봤지만, 정작 이것을 스스로 탐구하고 적용하게 된 것은 이번이 처음이었다.

새로운 것을 많이 만났던 만큼, 고통스럽긴 했어도 얻어가는 것이 정말 많아서 감사한 경험이었다.

여러명이서 함께 협업하며 개발을 진행하다 보면 예기치 못한 에러 코드들을 그대로 올리는 상황이 벌어지게 된다.

그리고 나면 github 콘솔에서 우리를 마주하는 수많은 merge conflict를 보며 한숨을 쉴 수 밖에 없기 마련이다.

Husky는 이런 문제점을 각각의 로컬 환경에서부터 잡아주는 아주 고마운 친구다.

Husky는 기본적으로 Git Hook을 이용하여 git과 관련된 event가 발생할 때 다양한 중간 동작을 일으키게 만들 수 있다.

자세한 사항은 husky로 git hook 하기에서 정리해주신 내용이 아주 알차기 때문에 꼭 읽어보길 바란다.

다만, 업데이트가 좀 일어난 모양인지 사용 방식이 살짝 달라졌기에 완전히 일치하진 않는다.

현재로서는 공식 문서에 따라 이렇게 진행하면 된다. (훨씬 간결해졌다)

저기서 husky init을 실행하면 자동으로 루트 위치에 .husky 폴더가 만들어지면서 안에 기본적인 pre-commit에 해당하는 파일이 생성된다.

이후, 파일 안에 git action이 발생하기 전 해야 할 쉘 스크립트 내용을 작성시키면 된다.(원래는 비어있고, 현재 위 사진은 프로젝트 내에서 사용하려고 추가한 스크립트이다.

예를 들어, commit을 실행시키기 전에 미처 파악하지 못한 코드 prettier 부분이나 tslint, type 에러들을 확인시켜주길 원한다면 아래처럼 작성할 수 있겠다.

#!/usr/bin/env sh #shebang

# the lint process includes prettier
check_lint='yarn run check:lint'
check_type='yarn run check:type'

${check_lint}
${check_type}

참고로 첫줄에 있는 내용은 shebang이라고 하며, 해당 파일을 해석해줄 인터프리터가 위치한 절대경로를 뜻한다.

이후 아래에 있는 것은 shell 명령어 내용이며, 그 안에 있는 내용은 package.json에 작성되어 있는 스크립트 명령어이다.

pakcage.json의 예시는 아래와 같다.

"scripts": {
    "dev": "next dev -p 20160",
    "build": "next build",
    "start": "next start",
    
    "check:type": "tsc --noEmit",
    "check:lint": "next lint --quiet",
    "check:prettier": "npx prettier --check .",

    "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
    "prepare": "husky" //* 해당 부분을 하단의 것으로 교체해야 함.
  	"postinstall": "npx husky"
  },

🚨 note
참고로 script의 "prepare:husky" 부분은 npx husky init 을 하면서 자동으로 추가된다.
문제는, 저 prepare은 husky를 global로 설치해놓던 상황의 버전때 유의미한 녀석이다.

만약 내가 husky를 글로벌로 설치하지 않아서 bin에 등록되어 있지 않다면, husky라는 커맨드를 찾을 수 없으므로 에러가 나게 된다.

즉, git clone을 통해 가져온 다른 팀원의 경우, husky가 제대로 작동하지 않는다.

따라서, husky에 대한 설정을 .git에 등록시켜주기 위하여 해당 부분을 자동으로 실행해주는 "postinstall" 구문을 추가해준다.
해당 구문이 등록되어 있으면, clone을 받은 다른 팀원들이 node_modules를 설치하고 난 이후 husky를 npx를 통해 실행시켜 필요한 설정을 git hook으로 연동시켜주게 된다.

이제 git hook에 따라서, pre-commit 이전에 husky가 shell script를 실행하여 구문을 실행시키고, 이것이 통과되지 못할 경우 commit이 진행되지 않게 된다.

husky를 추가하고 간단한 설정 하나만으로도 로컬에서 예기치 못한 오류들을 그대로 commit하게 되는 상황을 방지할 수 있다.

이를 응용한다면 다른 git event에 대해서도 다양한 중간 동작을 진행시킬 수 있을 것이다.

예를 들어, 현재 프로젝트에는 git commit에 대한 prefix convention을 Docs로만 유지하는 불안정성을 피하기 위해 husky의 "commit-msg"를 이용하고 있다.

아래는 그 예시이다.

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# 커밋 컨벤션
# 0. 검사 예외 조건 (자동 생성, 최초 커밋)
# 1. 접두사의 글자는 소문자
# 2. 맨 마지막 글자 '.' 마침표 금지
# 3. 커밋 접두사 (규칙: '접두사' + '콜론' + ' ')
# - feat: 새로운 기능 추가
# - fix: 버그 수정
# - docs: 문서의 수정
# - style: (코드의 수정 없이) 스타일(style)만 변경(들여쓰기 같은 포맷이나 세미콜론을 빼먹은 경우)
# - refactor: 코드를 리펙토링
# - test: Test 관련한 코드의 추가, 수정
# - chore: (코드의 수정 없이) 설정을 변경
# - design: css 코드 수정
# - build: build 및 관련 설정 변경

PREFIXES=(
  "feat"
  "fix"
  "docs"
  "style"
  "refactor"
  "test"
  "design"
  "build"
  "chore"
)

# merge prefixes = |feat|fix|docs .... etc
CONVENTION_PATTERN=$(printf "|%s" "${PREFIXES[@]}") 
# formating for regular expression = |feat|fix|docs.... to ^(feat|fix|docs):
CONVENTION_PATTERN="^(${CONVENTION_PATTERN:1}): "

#.git/COMMIT_EDITMSG
COMMIT_MSG_FILE=$1 

# commit title
FIRST_LINE=$(head -n1 "${COMMIT_MSG_FILE}")

check_auto_commit() {
  if echo "$FIRST_LINE" | grep -Eq "^(Merge branch|Merge pull request)"; then
    echo "Automatically generated commit message from git"
    exit 0
  fi
}

check_initial_commit() {
  if echo "$FIRST_LINE" | grep -Eq "^initial"; then
    echo "Initial commit"
    exit 0
  fi
}

check_commit_message_format() {
  if echo "$FIRST_LINE" | grep -qE "\.$"; then
    echo "CommitLint#1: 문장 마지막의 ('.') 마침표를 제거해주세요."
    exit 1
  fi

  if ! echo "$FIRST_LINE" | grep -Eq "$CONVENTION_PATTERN"; then
    echo "CommitLint#2: 접두사, 콜론, 띄어쓰기 형태를 확인하세요. (${PREFIXES[*]}: )"
    exit 1
  fi
}

main() {
  check_auto_commit
  check_initial_commit
  check_commit_message_format
  echo "Pass commit lint!"
}

main
  1. 잘못된 커밋 메세지 prefix를 붙이려고 할 경우

  2. 부적절한 행위를 사전에 방지시킬 수 있다.


✍️ 2. git action을 활용한 CI/CD

husky는 로컬의 개발자 컴퓨터 내에서 git event에 대해 middleware을 실행시키는 느낌이다.

하지만, 해당 코드가 remote에 올라가는 상황에서 특정 행동을 하게 해야하려면 어떻게 해야할까.

이럴때 사용해야 하는 것이 바로 git action이다.

git action은 repository에 등록시켜 pull request, merge 등 git 페이지에서 발생하는 event에 대해서 특정 작업을 진행하도록 만들 수 있다.

git action에 대한 내용은 카카오에서 어떻게 git action을 사용할까 을 보면 이해하기 쉬울 것이다.

개인적으로는 내용 자체는 어렵지 않았지만 yml 문법 자체와 극악의 디버깅으로 인해 고생을 좀 많이 하여서 기록해둔다.


1. workflow 폴더 생성

git action은 프로젝트에 존재하는 ".github" 폴더에 workflows => yml을 생성하고 repository에 등록 시 자동으로 등록된다.

아마 처음에는 폴더가 없을 것이므로 직접 내부로 들어가서 workflows 폴더를 생성해주어아 한다.

저렇게 생성할 시, yml 파일이 git action이 요구하는 명세서대로 작성되어 있으면 자동으로 action이 등록되게 된다.

등록된 내용은 actions 탭에서 확인이 가능하다. ( 아래는 현재 등록한 action이 동작했던 히스토리를 보여준다 )

등록을 하지 않고 그냥 action 탭에 가보면 아래와 같은 화면일 것인데,

친절하게도 예시 자료들을 제공하기 때문에 저것을 활용해도 좋다.

그럼 이제 yml 파일의 형태를 보러 가자.


2. yml 파일 작성하기

git action yml이 어떤 형식으로 갖춰져야 하는지에 대한 모든 설명은 git action 공식문서 에 나와있으므로 참고하면 좋다.

또한, 기본적인 설명은 git action의 구조 에 잘 정리되어 있으니 참조하면 좋지만 정리를 위해서 아래에 핵심만 간추려서 설명한다.

  1. name : 해당 yml 파일의 타이틀과 같은 것
  2. on : 어떤 remote repository의 이벤트에 따라 이 문서내용을 실행시킬것인지
  3. jobs : git action이 실행시킬 작업 정의
  4. runs-on : (jobs에서) 어떤 host의 자원을 사용해서 해당 action을 실행시킬 것인지
  5. steps : job의 실행순서 (실제 job이 돌아가는 전체 프로세스)

이를 기반으로 작성한 CI yml은 아래와 같다.

name: Continuous Integration to Development Environment

on:
  pull_request:
    branches:
      - develop
    types: [closed]

jobs:
  ### step 1. code integration check
  CI:
    runs-on: self-hosted
    if: github.event.pull_request.merged == true
    env:
      BRANCH: develop
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ env.BRANCH }}
          token: ${{ secrets.ACCESS_TOKEN }}

      - name: Set node environment
        uses: actions/setup-node@v4
        with:
          node-version: 20.11.1 # see .nvmrc

      - name: Caching Primes # for this case, node_modules
        id: cache-primes
        uses: actions/cache@v4
        with:
          path: node_modules
          key: npm-packages-${{ hashFiles('**/package-lock.json') }}

      - name: Install dependencies if no cache
        if: steps.cache-primes.outputs.cache-hit != 'true'
        run: npm install

      - run: npm run check:lint
      - run: npm run check:type

🚨 note
trigger에 해당하는 "on" 부분에 대해서 두가지의 케이스가 있다.
보통 개발자들이 협업을 할 경우, 원본 브랜치 내에서 PR을 날리거나 fork branch에서 PR을 날릴 것이다.
전자의 경우는 크게 상관이 없지만, 후자의 경우 fork branch는 기본적으로 원본 브랜치에 write 권한이 없기 때문에 fork branch에서 전달된 pull request가 git action을 발동시키지 않는다.

해결 방법은 간단하게 pull_request가 아닌, pull_request_target으로 변경해야 한다.
보안을 위해 2020년정도에 추가된 스펙이라고 한다.

name: Continuous Integration to Feature Branch
on:
  pull_request_target: #이렇게 바꿔주면 된다
    branches:
      - feature
    types: [closed]

참고로, pull_request_target이 branch가 타 저장소에서 발동시킨 액션에 대해서 작동한다고 생각해서 '아 그러면 pull_request_target이랑 pull_request랑 둘 다 둬야겠구나' 하고 둘 다 두면(나처럼) 작동하지 않으니까 하나만 해야한다.

name과 on은 직관적으로 이해하기 어렵지 않다고 생각한다.

여기서 jobs 부분을 조금 설명이 필요할 것 같아서 (그리고 내가 겪었던 고통들에 대해서) 설명을 좀 첨가하려고 한다.

1. runs-on : self-hosted

참고로 git action은 완전한 공짜가 아니다.

무료 과정은 public repository일 경우에만 해당하지만, 실제 프로덕트를 public으로 개발하는 회사는 없을 것이므로 private을 상정해야 하는데, 이 가격이 생각보다 조금 많이 나간다.

github을 host로 이용해서 내가 작성한 git action yml을 실행시키면 사용량만큼 잔인한 돈이 나가므로,돈이 나가지 않으려면 host 를 따로 등록해주는 과정이 필요하다.

즉, "나는 github이 아니라 내가 등록한 기기를 이용해서 action을 돌릴거에요", 하고 설정하는 것이다.

repository의 git action은 아래와 같이 "Settings => Actions => Runners" 를 통해 들어갈 수 있다.

위 스크린샷은 내가 이미 등록한 runner이고, 우측 상단에 보이는 "New self-hosted runner"를 통해 친절하게 등록하는 방식을 알려준다.

위에 있는 순서대로 하면 된다.

마우스를 올리게 되면 해당 command가 복사가 되기 때문에 하나하나 복붙하여 진행하면 된다. (매우 심플)

전반적인 과정은

a. actions-runner 폴더 생성
b. runner pakcage 안에다가 설치 (curl 로 tar.gz 다운로드받음)
c. tar.gz 압축풀기
d. sh로 configure 파일 실행시켜서 runner 등록하기
e. ./run.sh 로 실제 repository에 runner 실행해서 연결시키기.

위의 과정을 다 하게 되면 아까 위에서 본 것 처럼 status가 idle인 runner가 등록되어 있는 것을 확인할 수 있다.

참고로, 본인만이 아니라 다른 여러 사람들을 action-runner로 등록시키고 싶으면 위의 과정을 똑같이 해야하기 때문에, 아예 shell script로 만들어 공유하는 것이 편리하다. 아래는 공유를 위해 만든 "action-runner.mjs"라는 스크립트 파일의 내용이다.

#!/bin/bash

### 1. download-runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-osx-arm64-2.314.1.tar.gz -L https://github.com/actions/runner/releases/download/v2.314.1/actions-runner-osx-arm64-2.314.1.tar.gz
echo "e34dab0b4707ad9a9db75f5edf47a804e293af853967a5e0e3b29c8c65f3a004  actions-runner-osx-arm64-2.314.1.tar.gz" | shasum -a 256 -c
tar xzf ./actions-runner-osx-arm64-2.314.1.tar.gz
wait $!

### 2. configure-runner
./config.sh --url https://github.com/reable-dev/EMS-BEMS-front-admin-renewal --token AN6KLTWATIXCGR7HIB3E45LGAYKYQ <---- 이 부분은 매번 변경되니 관리자에 요청
wait $!

### 3. activate-runner
nohup ./run.sh &

🚨 note 1.
참고로, runner을 등록한 다음 그냥 ./run.sh로 실행시키면 해당 runner의 연결이 해당 터미널에 foreground에서 실행되기 때문에 해당 터미널을 꺼버리면 실행이 멈춰버린다.
따라서, 백그라운드에서 해당 runner의 connection이 유지될 수 있도록 "nohup ./run.sh &" 를 통해 실행하도록 한다.
"nohup"은 터미널이 끝나더라도 해당 프로세스를 멈추지 말게 하라는 뜻이고, "&" 는 백그라운드에서 실행하도록 만드는 문구이다.

🚨 note 2.
만약 runner가 여러개 등록되어 있을 경우, 1) self-hosted runner가 우선하고 2) active한 것 중 랜덤으로 사용된다. (git action에서 runs-on 부분을 태그들을 이용하여 특정 러너로 지정해줄 수도 있긴 하다) 만약 위의 runner가 실패했다고 해서 그 다음 순번의 runner로 다시 action을 실행시키는 것은 아니라고 하니 주의하도록 한다.
( 즉, action-runner로 등록되길 원하는 사람은 CICD 프로세스 내에서 필요로되는 소프트웨어, CLI 등을 다 설치해놓은 상태여야 한다. 결국 action을 돌아가게 하는 주체를 내 컴퓨터로 지정하겠다는 뜻이니 말이다.)

🚨 note 3.
self-hosted는 왠만하면 본인의 macOS로 하는 것을 추천한다. 왜냐 하면 성능 차이가 너무 많이 난다.
현재 프로젝트의 zero downtime deployment를 위해서 실무에서 로깅 및 시각화가 용이한 AWS의 ECR, ECS 서비스를 이용중인데 여기에 사용되는 것이 docker image 였기에 빌드 시간을 최대한 줄이는 것이 관건이었다.
그런데 하나의 runner을 두고 싶어서 EC2로 runner을 등록해 사용해본 결과, image build 시간이 거의 5배 이상 차이가 났기 때문에 macOS로 빌드하는 방식으로 yml을 작성하길 적극 권장한다.


혹여, docker가 아닌 ec2의 pm2를 이용한 배포를 하기 위해 ec2 접근을 위한 composite action, appleboy/ssh-action@master를 사용하게 된다면 이 appleboy가 linux 기반으로 돌아가기 때문에 self-hosted runner을 macOS로 사용하지 못하는 상황이 벌어지므로,
이럴 때에는 appleboy를 사용하지 않고 직접 pem key를 이용하여 직접 ec2에 접근한 뒤 필요한 코드를 run 하도록 한다.

🚨 note 4.
action runner을 등록하는 스크립트를 실행시키면, actions-runner 라는 폴더에 여러가지 필요한 파일들이 생성되어 있다. 그런데 중요할 점은 이 폴더를 삭제하면 안된다. 이 폴더 내에는 action에 돌아갈 때 action의 결과로 생성되는 빌드 캐시와 같은 데이터들이 쌓이고 있는 장소인데, 이 폴더를 삭제하게 될 경우 github action이 돌아가지 않는 사태가 벌어지므로 주의한다.



1. appleboy를 이용하는 CD의 예시

name: CD

on:
  pull_request:
    branches:
      - develop
    types: [closed]

jobs:
  deploy:
    runs-on: [self-hosted, Linux, X64]
    if: github.event.pull_request.merged == true
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        ref: ${{ env.BRANCH }}
        token: ${{ secrets.ACCESS_TOKEN }}
        
    - name: SSH to EC2 instance
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }} # EC2 public host
        username: ${{ secrets.USERNAME }} # EC2 만들 때 지정해줬던 user. ex) ubuntu
        key: ${{ secrets.PRIVATE_KEY }} # pem 키
        port: ${{ secrets.PORT }} # 보통 22 port로 ssh 접근.
        script: |
            ### 1. git pull
            git pull ${{ env.REPOSITORY_URL }} ${{ env.BRANCH }}

            ### 2. docker update
            sudo docker-compose -p containername down --rmi all -v
            sudo docker system prune -a
            sudo docker-compose -p containername up -d
  1. 직접 접근하여 배포하는 예시
name: CD

on:
  pull_request:
    branches:
      - develop
    types: [closed]

jobs:
  deploy:
    runs-on: [self-hosted, macOS, X64]
    if: github.event.pull_request.merged == true
    steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        ref: ${{ env.BRANCH }}
        token: ${{ secrets.ACCESS_TOKEN }}
        
    - name: SSH to EC2 instance
      run: |
      	### 1. access to EC2 using pem
        ssh -i {PEM key 주소} ubuntu@${{ secrets.EC2_HOST }}
        
        ### 2. git pull
        git pull ${{ env.REPOSITORY_URL }} ${{ env.BRANCH }}

        ### 3. docker update
        sudo docker-compose -p containername down --rmi all -v
        sudo docker system prune -a
        sudo docker-compose -p containername up -d
        
        
        # 위처럼 PEM KEY를 s3로 저장해서 주소를 주거나, 아니면 secret에 넣어서
        # 아래처럼 action step 내에서 파일을 생성한 후 접근하는 방식을 취하면 된다.
        # EOF(End Of File) 커맨드 블록 구문을 이용하여 접속이 유지된 상황에서 아래 스크립트가 진행되게 설정해준다.
        # EOF 블록 내부에는 실제 EC의 $PATH와 다르기 때문에, 필요한 PATH가 있다면 이를 수동으로 등록해주어야 제대로 작동한다(export PATH="$PATH:${{ env.NODE_PATH }}")
        
      - name: SSH into EC2 and run commands
        env:
          NODE_PATH: /home/ubuntu/.nvm/versions/node/*/bin
        run: |
          ## 1. Access to EC2 
          echo "access to EC2"
          ssh -i key.pem ${{ env.USERNAME }}@${{ env.PUBLIC_DNS }} << 'EOF'

          ## 2. run new project using pm2
          echo "run new project"
          export PATH="$PATH:${{ env.NODE_PATH }}"
          cd ${{ env.PROJECT_PATH }}
          git pull
          pm2 kill
          pm2 start --name blue npm -- run dev

          echo "success for deployment"
          EOF

2. steps : uses, env, steps.{step-id}.output, secrets 등

이 부분을 정리하는 이유는, git action yml 자체 문법이 정말 다양하고, 현재 버전이 달라져서 인터넷 상에서 공유되는 내용과 잘 맞지 않은 부분들이 있어 한참을 디버깅하게 되었으므로 자주 사용하게 되는 필수 문법들만 정리하여 공유한다.


1. uses

아까 위에서 보았던 jobs 내의 steps 들에는 uses라는 파트가 존재하기도 하는 것을 알 수 있다.

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ env.BRANCH }}
          token: ${{ secrets.ACCESS_TOKEN }}

이것은 특정 step을 미리 커스터마이징하여 만들어놓은 "Composite Action" 이다.
마치 모듈처럼 특정 반복될 것 같은 작업을 미리 추상화하여 사용할 수 있게 만들어놓은 기능으로, with를 통해서 필요한 인자들을 전달할 수 있다.

위에 보이는 Checkout은 action에서 당연하게 이루어지게 될 git checkout를 Composite화 시켜놓은 내용이다.

with 부분은 Composite Action들마다 각각 다 다르므로, 구현되어 있는 git 에서 직접 확인해보는 습관을 가지도록 한다. 혹시 직접 composite action을 만들고 싶다면 다시금 카카오에서는 git action을 어떻게 사용할까?의 중간 부분을 참고하면 좋다.


2. env

env는 step에서 사용될 환경 변수를 뜻한다. 해당 환경 변수는 step별로도 지정할 수 있고, jobs 바로 밑에서도 정의가 가능하다.

# 이렇게 jobs 바로 아래에서 정의하거나,
  CI: 
    runs-on: [self-hosted, Linux, ARM64]
    if: github.event.pull_request.merged == true
    env:
      BRANCH: develop
      
# 이렇게 steps에서 정의가 가능하다.
      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REPO_URI: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPO_NAME: ${{ secrets.ECR_REPO_NAME }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          IMAGE_NAME='${{ env.ECR_REPO_URI }}/${{ env.ECR_REPO_NAME }}:${{ env.IMAGE_TAG }}'

이렇게 정의되어 있는 env 데이터는 반드시 "${{ env.~~~ }}" 로 참조해서 사용해야 하는 것을 유의한다.

이 이야기를 왜 하냐면, 가끔 어떤 블로그를 보면 "$" 만 붙여서 사용하는 것처럼 공유했는데, 버전이 달라진 것인지는 모르겠지만 그렇게 하면 git action에서 인식을 하지 않으므로 반드시 git action에서 요구하는 문법대로 참조하도록 한다.


2. steps.{step-id}.output

git action에는 특정 step에서 이루어진 결과 변수를 다른 step에서 공유해서 사용할 수 있다.
step끼리 공유하는 전역변수 느낌으로 이해하면 좋을 것 같다.

# step에서 이렇게 내보냈으면,

      - name: echo outputs
        id: build-image
        run: |
		  ...
          echo "image=${IMAGE_NAME}" >> $GITHUB_OUTPUT
          

# 다른 step에서 outputs로 접근할 수 있다.
# steps 뒤 프로퍼티 이름은 echo를 통해 output을 생성한 step의 id에 해당한다.

      - name: Receive outputs
        run: |
		  ...
          image: ${{ steps.build-image.outputs.image }}

3. secrets

만약 너무 민감한 정보가 step에 들어있다면 이것을 노출시키는 것은 당연히 좋지 않다.
따라서, 이를 github 자체에 저장한 다음에, action이 돌아가는 상황에서 삽입하여 사용하는 것이 좋다.

넣는 방법은 위와 같이 "Settings => Secrets and variables" => "Actions" 에서 들어가서 등록할 수 있다.

secrets는 "${{ secrets.등록한 이름 }}" 으로 참조할 수 있다.

      - name: Render Amazon ECS task definition
        id: render-task-definition
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          container-name: ${{ secrets.ECS_TASK_CONTAINER_NAME }} # 이렇게

🚨 note 1.
yml은 python처럼 indentation을 기반으로 동작한다. (띄어쓰기)
즉, 띄어쓰기가 잘못 되어있을 경우 에러를 방출하게 된다. 심지어, script의 끝 부분에 띄어쓰기가 있어도 에러이니까 방심하면 안된다. (저 띄어쓰기 하나를 찾기 위해 얼마나 많은 고통을 겪었는지...)

🚨 note 2.
매번 yml에 대해서 디버깅을 할 때에 github에 올려보기란 너무 고통스러운 일이다. 완전하게 github 환경을 반영해서 테스트를 할 수는 없지만, 기초적인 yml 문법을 한줄씩 디버깅하는 것은 가능하다. "act"라는 라이브러리를 이용하면 된다.
잘 정리되어 있는 내용은 use git action locally에서 확인 가능하다.

주로 사용하게 되는 커맨드는 아래와 같다.
a. "act -l" 현재 디렉토리 기준 사용 가능한 jobs를 리스트로 보여준다
b. "act -j {job-id}" : 현재 어떤 job을 act로 실행시켜볼지 결정한다.

act를 활용하면 로컬로 어떤 문제가 발생하는 지에 대해서 디버깅이 매우 간편해진다. (act가 없을 경우 일일이 q브랜치에 push를 해보면서 짐작을 해봐야 한다.

예를 들어, github에서 알려주는 예시 자료로 act를 돌려보자.
다시금, act는 모든 케이스를 다 실행시켜줄 수 없기 때문에 문법 문제가 있는지, 혹은 외부 CLI 결과가 이상한지 등의 간단한 테스트 확인을 위한 보조용으로 사용하도록 하자.


이렇게 기본적인 yml 문법을 알게 되었다.

이를 토대로 작성한 CICD yml은 아래와 같다.

<최종 CICD yml 구조>

name: Continuous Integration and Deployment

on:
  pull_request:
    branches:
      - develop
    types: [closed]

jobs:
  ### step 1. code integration check
  CI:
    runs-on: self-hosted
    if: github.event.pull_request.merged == true
    env:
      BRANCH: develop
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ env.BRANCH }}
          token: ${{ secrets.ACCESS_TOKEN }}

      - name: Set node environment
        uses: actions/setup-node@v4
        with:
          node-version: 20.11.1 # see .nvmrc

      - name: Caching Primes # for this case, node_modules
        id: cache-primes
        uses: actions/cache@v4
        with:
          path: node_modules
          key: npm-packages-${{ hashFiles('**/package-lock.json') }}

      - name: Install dependencies if no cache
        if: steps.cache-primes.outputs.cache-hit != 'true'
        run: npm install

      - run: npm run check:lint
      - run: npm run check:type

  ### step 2. publish code to external environment
  CD:
    needs: CI
    runs-on: self-hosted
    steps:
      - name: AWS credential
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} # IAM access key
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # IAM access
          aws-region: ${{ secrets.AWS_REGION }}

      # 1. ECR
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2 # result : registry (ECR repo uri)

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REPO_URI: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPO_NAME: ${{ secrets.ECR_REPO_NAME }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          IMAGE_NAME='${{ env.ECR_REPO_URI }}/${{ env.ECR_REPO_NAME }}:${{ env.IMAGE_TAG }}'
          docker build -t ${IMAGE_NAME} .
          docker push ${IMAGE_NAME}
          docker system prune --volumes -a -f
          echo "image=${IMAGE_NAME}" >> $GITHUB_OUTPUT

      # 2. ECS
      # https://fig.io/manual/aws/ecs/describe-task-definition
      - name: Generate task-definition.json from latest active ECS task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition ${{ secrets.ECS_TASK_DEFINITION }} \
            --query taskDefinition \
            > task-definition.json

      - name: Render Amazon ECS task definition
        id: render-task-definition
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          # this step exports the task-definition as an output automatically (used from next step)
          task-definition: task-definition.json
          container-name: ${{ secrets.ECS_TASK_CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
          service: ${{ secrets.ECS_SERVICE }}
          cluster: ${{ secrets.ECS_CLUSTER }}
          wait-for-service-stability: true

CD 의 대부분의 step은 전부 composite action으로 작동하는 게 대다수이다.
만은, 해당 내용에 대해서 조금 설명이 있으면 나중에 나에게 좋을 것 같으므로 기록을 남기려고 한다.

다만, 글이 너무 길어져서 다음 글에 이어서 작성하겠다.

여튼

이쁘게 잘 CI CD가 되어서 기쁘다.

profile
자라나라 프론트엔드 개발새싹!

0개의 댓글