본문으로 건너뛰기

GitHub Actions를 활용한 CI/CD (Self-Hosted Runner)

· 약 18분
이동훈 (아루)
리뷰미 BE

Continuous Integration

CI는 일련의 구체적인 과정을 의미하기보다는, 개발 방식 중 하나를 뜻합니다. 기능을 구현하고, 기존 저장소에 새로운 기능을 병합하는 일련의 과정을 말하죠. 이때 빌드/통합 오류를 가능한 한 빠르게 찾아내야 하며, 이때 자동화 빌드나 테스트 도구가 활용됩니다. 이 중 하나로 Jenkins, GitHub Actions와 같은 CI/CD 도구들이 사용돼요.

이슈를 발행해 할 일을 만들고, PR을 통해 서로 리뷰하고 머지하기. 추가적인 테스트나 자동화 빌드 도구가 필요할까요? 서로 확인했으니 믿고 사용해도 되지 않을까요? 실제로 팀 내에서 서로 다른 기능을 머지하려는 도중 충돌이 발생했어요. 이를 잘 해결한 뒤 머지할 수 있었습니다. 하지만 아래와 같은 일이 추가로 발생했어요.

사람은 실수하기 마련입니다. 코드에 변화가 있었음에도 회귀 테스트regression test를 진행하지 않았어요. 결국 테스트되지 않은 코드가 PR에 포함됐어요. 이를 다른 팀원들도 확인하지 못해 실제 개발 서버에 머지됐습니다. 이를 해결하기 위해 다시 이슈를 작성하고, 핫픽스를 적용하고, … 😮‍💨

테스트와 빌드가 매 PR마다, PR 안의 커밋마다 진행된다면 편리하지 않을까요? 만약 실패한다면 저장소에 통합하는 것이 실패하도록 한다면 더 좋겠습니다. GitHub Actions을 사용하면 이를 해결할 수 있습니다. 빌드부터 테스트 자동화와 더불어 Branch protection을 통해 빌드/테스트에 실패한다면 병합하지 않도록 설정할 수도 있어요.

정보

이 글에서는 GitHub Actions를 활용한 CI, CD에 대해서 다룹니다. 아래와 같은 사전지식을 요구하나, 모르더라도 따라오는 데에 큰 어려움이 없도록 글을 써보려고 합니다. 😄

  • Git
  • Shell script
  • Gradle task

CI with GitHub Actions

📚 용어 정리

GitHub Actions는 이곳에 정말 자세하게 설명돼 있습니다. 러닝커브가 낮은 편이 아니라서 꽤나 많은 사전지식을 필요로 하지만, 간단하게 action이 동작하는 방식을 알기 위한 용어들을 소개합니다.

  • 🛠️ Workflows
    Job의 집합입니다, repository에서 특정 이벤트가 트리거되면 실행됩니다. ./github/workflows 디렉토리 밑에 .yml 또는 .yaml을 생성해야 해요. Workflow는 다른 workflow에서 재사용할 수도 있습니다.

  • 🎉 Events
    Workflow가 실행되게끔 하는 이벤트입니다. PR을 열거나, 이슈를 열거나, push하거나 등 다양한 event가 존재합니다.

  • ♻️ Jobs
    Workflow에 속하는 step의 집합이며, 같은 runner 안에서 실행됩니다. Job끼리는 병렬적으로 실행됩니다. 다른 job과의 의존관계가 존재한다면, 이를 needs 로 명시해야 합니다. 각 step은 shell script이거나, action 중 하나입니다. step은 순서대로 실행되며, 이전 step이 다음 것에 영향을 끼칩니다. 빌드한 것을 다음 step에서 활용할 수도 있어요.

  • 📋 Actions
    Github Actions에서 사용되는 custom application. workflow에서의 중복되는 코드를 라이브러리로 만들었다고 생각하면 편합니다. GitHub에서 제공하는 여러 권한을 가져갈 수 있습니다. 이를 외부 Job에서 설정해줄 수도 있어요.

  • 🏃 Runners
    Workflow가 돌아가는 os가 포함된 서버입니다. GitHub는 Ubuntu Linux, Windows, macOS Runner를 제공합니다. 직접 runner를 호스팅할 수도 있는데, 이를 self-hosted runner라고 부릅니다.

🔍 요구사항 정의 및 분석

우리는 1. 빌드에 실패하는 경우 저장소에 반영되지 않도록 하고 싶습니다. 이는 2. PR이 생성되었을 때나 변경 사항이 저장소에 반영되었을 때, 빌드 테스트를 진행할 수 있어야 합니다.

앞쪽 문장인 빌드 실패 시 저장소 반영하지 않도록 하는 것은 GitHub에서 제공하는 Branch protection을 사용해 간단하게 해결할 수 있습니다. PR에 Approved 리뷰가 일정 개수 이상, Status check 등 다양한 브랜치 보호 옵션을 제공하니 중요한 저장소라면 꼭 챙겨가야 합니다.

브랜치 보호는 되었으니, 이젠 Status check에 반영될 빌드 - 테스트를 자동화해야 합니다. 위 요구사항 문장을 조금 더 잘게 쪼개 볼까요?

PR이 생성되었을 때나 변경 사항이 저장소에 반영되었을 때,

앞서 설명한 용어 중 event에 해당해요. GitHub actions에서는 on이라는 키워드로 적용할 수 있습니다.

빌드 및 테스트를 진행할 수 있다

테스트를 진행하기 위해서는 다양한 옵션이 필요합니다. 우선 테스트를 돌릴 환경이 있어야 해요. 나아가 그 환경 안에 Java가 설치돼 있어야 하고, 저장소를 복제해 코드를 가져와야 합니다. 프로젝트에 따라 자동 빌드 툴도 필요할 수 있습니다. 저희 팀에서는 Gradle을 활용해 빌드하므로, Gradle도 필요하겠네요.

할 일이 상당히 많아 보이는데, 굵게 표시한 문구들은 전부 GitHub에서 제공합니다. 따라서 이를 활용할 수 있는 workflow 파일 하나로 완성해낼 수 있습니다. 파일을 직접 만들어보고, 실행 결과를 확인해 봅시다!

📝 Workflow 파일 작성하기

# Workflow 파일은 .github/workflows 디렉토리 아래 .yml 또는 .yaml 파일로 존재한다.

# Workflow 이름. 하나의 파일이 하나의 Workflow를 담당한다. Workflow는 Job의 집합이다.
name: Build test with Gradle

# Event. 언제 이 Workflow가 트리거돼 실행될 지 작성한다.
on:
pull_request:
branches:
- develop # develop 브랜치에 PR이 작성되는 경우

jobs:
# build라는 이름의 Job 한 개로 이루어진 workflow이다.
# build는 이름이며, 자유롭게 적을 수 있다.
build:
# 이 Job이 실행될 환경. GitHub에서는 Linux, Windows, macOS 환경을 제공한다.
runs-on: ubuntu-latest

# Job은 여러 Step으로 이뤄져 있으며, 이는 순서대로 실행된다.
steps:
# 가상환경에서 Repository를 clone해 코드를 사용할 수 있도록 한다.
- name: Checkout to current repository
uses: actions/checkout@v4

# JDK를 설치한다.
- name: Setup JDK Corretto using cached gradle dependencies
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: 17
cache: 'gradle'

# 사전 작업이 모두 준비되었으므로, 테스트를 진행한다.
- name: Build and test with gradle
run: ./gradlew test

제공하는 action들은 저장소가 존재하며, 해당 저장소에 어떻게 사용하는지 자세히 적혀있는 경우가 많습니다. 예를 들어 actions/setup-java@v4의 경우, 특별한 버전과 벤더를 선택함과 더불어 gradle 캐싱까지 진행합니다.

run에서는 실제로 실행할 쉘 스크립트를 작성하면 됩니다. shell script에 익숙하지 않다면, 자신있는 다른 언어(python, nodejs 등)로 로직을 짜준 뒤, 실행하는 방법도 있습니다.

이제 PR을 올릴 때마다 빌드 및 테스트를 진행합니다. 실패하는 경우에는 머지하는 불상사가 발생하지 않게 되었어요 🔥

Continuous Deployment

🤔 Delivery? Deployment?

CI와 함께 붙어다니는 단어로 CD가 있습니다 💿. CD는 위와 같이 두 용어가 자주 혼용되는데요, 비슷한 맥락이지만 아래와 같은 차이점이 있답니다.

  • Continuous delivery는 프로덕션 코드에 직접 빌드 결과를 제공하기까지만 하며, 배포를 위해 추가적인 작업이 필요합니다. '출시일'이라는 개념이 존재한다고 보면 됩니다.
  • Continuous deployment는 배포까지 파이프라인에 속해요. 추가적으로 개입할 프로세스가 존재하지 않으며, 사용자는 항상 최신 버전의 코드를 마주합니다.

이번 프로젝트에서의 개발 서버는 Continuous Deployment로 진행되기를 원했습니다. 기능 개발이 완료된다면 즉시 배포돼 프론트엔드가 확인하는 것을 목표로 하기 때문이예요. 이 또한 GitHub actions를 활용해 CD 파이프라인도 작성할 수 있습니다.

🚫 제약 사항

CD는 다양한 방법으로 구현될 수 있습니다. ssh 접속, aws S3와 CodeDeploy 등 다양한데요, 프로젝트를 진행하면서 다음과 같은 제약 사항이 존재해 이들을 사용할 수 없었습니다.

  • 캠퍼스 외부에서의 접속은 금지돼 있습니다. 80/443 포트를 통한 웹 접근만 가능합니다. ssh는 22번 포트로 통신합니다. (다른 방법으로 우회할 수는 있겠습니다만, 강하게 권하지 않습니다 🥹)
  • 제공된 aws IAM 계정으로는 S3의 Access Token을 사용할 수 없습니다. 따라서 S3 자동 업로드 / CodeDeploy 사용이 불가합니다. 이를 해결하기 위해 새로운 aws 계정을 생성해야 하며, 다양한 설정이 필요합니다. (Reference)

간단한 해결 방법으로 GitHub의 Self-hosted runner를 활용합니다. 서비스를 띄우는 인스턴스에 리스너를 달아두는 형식입니다. actions에서 443번 포트를 통해 workflow 정보를 전달하기 때문에, 아직까지 80포트만을 사용하는 개발 단계에서는 도전해볼 가치가 있습니다.

🚀 Self-hosted runner 구성하기

프로젝트 인스턴스를 runner로 생성해 actions에서 사용할 수 있습니다. 방법은 어렵지 않습니다! Repository setting 탭의 Actions - Runners 탭의 New self-hosted runner를 클릭해 추가합시다.

일련의 과정을 수행한다면 443 포트를 계속해서 듣고 있을 거예요. 이제 모든 준비는 끝났습니다 😄

🔍 요구사항 정의 및 분석

요구사항은 다음과 같습니다.

  • 앞선 빌드 테스트가 선행되어야 합니다.
  • gradle의 bootJar task를 통해 실행 파일을 생성합니다.
  • 현재 80 포트에 바인딩된 프로그램이 존재한다면, 이를 종료합니다.
  • 새로운 실행 파일을 띄웁니다.

위의 두 개는 이전 CI에서 진행했던 것과 거의 같습니다. 아래 두 개는 Shell script를 통해서 진행할 수 있겠네요!

📝 Workflow 파일 작성하기

name: CD using Github self-hosted runner

on:
# actions 탭에서 실행할 수 있도록 한다
workflow_dispatch:

push:
branches:
- main
- develop

env:
ARTIFACT_NAME: # 프로젝트 이름과 같이 구별할 수 있는 문자열
ARTIFACT_DIRECTORY: ./build/libs

# 해당 workflow는 두 개의 job으로 이루어져 있다.
# 하나는 빌드를 통한 jar 생성, 하나는 생성된 jar 배포이다.
# 전자는 GitHub에서 제공하는 runner를, 후자는 self-hosted runner를 사용한다.
jobs:
# 대부분 앞선 CI와 동일하나, gradle에서 테스트를 하지 않고, jar 파일을 만들어낸다
build:
name: Build Jar file and upload artifact
runs-on: ubuntu-latest

steps:
- name: Checkout to current repository
uses: actions/checkout@v4

- name: Setup JDK Corretto using cached gradle dependencies
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: 17
cache: 'gradle'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: 8.8

- name: Build and test with gradle
run: ./gradlew clean bootJar

- name: Rename artifact file
run: |
mv ${{ env.ARTIFACT_DIRECTORY }}/*.jar \
${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar

- name: Upload created artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar

deploy:
name: Deploy via self-hosted runner
needs: build
runs-on: self-hosted

steps:
- name: Download uploaded artifact
uses: actions/download-artifact@v4
# 앞선 actions/upload-artifact에서 업로드한 실행 파일을 다운로드한다
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIRECTORY }}

# 80 포트에 열려있는 프로세스를 확인하고, 존재한다면 환경변수를 설정한다
# well-known port (~1024)를 확인하거나 바인딩, 종료할 때에는 권한이 필요하다
# bash에서 실패하는 경우 전체 job이 실패하므로, || (or) true 연산을 진행한다
- name: Check if the port 80 is in use
run: |
echo "Checking ports on http..."
PID=$(sudo lsof -t -i :http || true)
if [ -n "$PID" ]; then
echo "Found process running on port http: $PID"
echo "server_running=true" >> "$GITHUB_ENV"
echo "PID=$PID" >> "$GITHUB_ENV"
else
echo "Process not found running on port http!"
echo "server_running=false" >> "$GITHUB_ENV"
fi

# 앞선 step에서 실행되고 있는 프로세스가 발견되는 경우, 이를 종료한다.
# 단, -15와 같은 graceful 종료를 진행한다.
# 종료된 것을 확인하기 위해 tail 명령어를 사용한다
# 프로그램이 종료되는 경우 tail도 종료된다
- name: Stop server if available (gracefully)
# 이 step이 실행될 조건을 설정한다
if: env.server_running == 'true'
run: |
echo "Gracefully shutting down process ${{ env.PID }}"
sudo kill -15 ${{ env.PID }}
tail --pid=${{ env.PID }} -f /dev/null

- name: Start server
run: |
sudo nohup java -jar \
${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar \
--server.port=80 &

추후 443번 포트를 통한 Http 웹 어플리케이션이 배포된다면 어떻게 될까요 🤨? 아직은 일어나지 않았지만, 이 경우에 대한 대비가 필요하겠네요.

이로써 PR을 작성하면 자동 빌드/테스트가, 해당 PR이 머지되면 전체 빌드/테스트/배포가 되도록 자동화할 수 있었습니다! Actions를 조금 더 활용한다면 Slack 알림과 같은 기능도 사용할 수 있으니, 익숙해져보는 것이 좋겠습니다 😄 개발할 때 답답하거나 불편함을 느낀다면, 이 또한 개선할 수 있는 점이니깐요!