시작하며
안녕하세요. 카카오엔터프라이즈에서 카카오워크의 서버 개발을 담당하고 있는 워크서버개발파트의 Dori(김동환)입니다. 제가 소속된 워크서버개발파트는 작년 상반기 까지 CI(Continuous Integration) 도구로 젠킨스(Jenkins)를 사용했고, 젠킨스 인스턴스를 직접 관리해 왔습니다. 젠킨스는 CI 도구로서 분명 많은 장점이 있지만, 젠킨스 인스턴스를 직접 관리하다 보니 인스턴스의 네트워크나 디스크 등의 꾸준한 관리가 결코 쉽지 않았습니다. 저희 파트에서는 이런 포인트들을 지속적으로 관리하기에 시간적으로나 리소스적으로 비용이 크다고 판단하여, 젠킨스의 대안을 찾고 있었는데요. 때마침 사내에서 GitHub Actions 인프라를 새롭게 구축했고, 저희 파트의 새로운 CI 도구로 GitHub Actions를 채택하게 되었습니다. 결과적으로 현재 GitHub Actions를 너무 만족하면서 사용하고 있는데요. 이번 포스팅에서는 GitHub Actions에 대해 간략하게 설명드리고, 워크서버개발파트에서 CI 도구로써 GitHub Actions을 어떻게 사용하고 있는지 알려드리려고 합니다.
GitHub Actions에 대해
GitHub Actions는 GitHub에서 제공하는 서비스로, 빌드, 테스트, 배포 파이프라인을 자동화할 수 있는 CI(Continuous Integration, 지속 통합)와 CD(Continuous Deployment, 지속 배포) 플랫폼입니다. GitHub Actions를 사용하면 GitHub 리포지토리에서 손쉽게 CI/CD 결과를 확인하고 관리할 수 있습니다. 또한, YAML 포맷을 사용하여 가독성이 높고, 이미 구현되어 있는 수많은 액션을 활용하여 간단하게 CI/CD 플로우를 작성할 수 있습니다.
Workflow
GitHub Actions에서 최상위 개념은 워크플로(Workflow)입니다. 워크플로는 쉽게 말해 '작업의 흐름'으로, 특정한 목적을 위한 일련의 실행 트리거, 환경, 기능들을 모두 포함합니다. 워크플로는 코드 저장소 내의 github/workflows 폴더 아래에 YAML 파일로 작성하면 되는데요. 하나의 코드 저장소에는 여러 개의 워크플로 파일이 존재할 수 있습니다.
워크플로 파일에서는 on 속성을 통해 해당 워크플로가 언제 실행될지 정의합니다. 아래 예시에서는 main, develop 브랜치에 커밋이 푸시되거나, Pull Requests가 만들어지고 동기화될 때 워크플로가 실행되도록 하였습니다.
on:
push:
branches:
- main
- develop
pull_request:
Job
jobs 속성을 통해 워크플로가 실행되면 수행할 Job을 정의합니다. GitHub Actions에서 Job이란 독립된 환경에서 돌아가는 하나의 처리 단위를 의미합니다. 하나의 워크플로에는 여러 개의 Job을 정의해 줄 수 있는데, 각각의 Job은 다른 Job과는 별개의 독립적인 환경에서 실행됩니다.
Job에서 필수적으로 정의해야 할 속성은 runs-on과 steps입니다.
runs-on에는 Job을 실행할 러너(환경)를 정의합니다. Public GitHub을 사용하신다면, GitHub에서 제공하는 러너를 사용하여 Job을 실행할 수 있습니다. Enterprise GitHub를 사용하는 경우, 직접 GitHub Actions Runner를 만들어 사용하게 됩니다. 카카오엔터프라이즈는 Enterprise GitHub를 사용하고 있고, 직접 GitHub Actions Runner를 구현했는데요. 이와 관련한 자세한 설명은 카카오엔터프라이즈가 GitHub Actions를 사용하는 이유 포스팅을 참고하시면 좋을 것 같습니다.
jobs:
echo-hello-world:
runs-on: ubuntu-latest
name: Echo Hello World Job
steps:
- uses: actions/checkout@v2
- run: echo Hello World!
위 예시에서는 echo-hello-world라는 Job이 하나 존재합니다. 이 Job은 ubuntu-latest 러너에서 실행되며, actions/checkout 액션과 echo Hello World! 명령어를 차례로 실행합니다.
위에서 설명한 내용은 GitHub Actions의 아주 기초적인 부분에 불과하지만, 이 글에서는 해당 지식으로도 충분히 이해가 되실 것이라 생각합니다. 이제부터는 워크서버개발파트에서 GitHub Actions을 어떻게 사용하고 있는지 알려드리겠습니다.
GitHub Actions 사용기
저희 워크서버개발파트에서 사용 중인 GitHub Actions 워크플로는 다음의 Job들로 이루어져 있습니다.
처음 보이는 Setup Job은 빌드 시작 알람을 보내고, 이후 Job에서는 공통적으로 사용하는 값을 정의합니다. 캐시의 사용 여부와 캐시 키 값 등이 그 예시입니다. 이후 병렬적으로 처리되는 3가지 Job에서는 각각 정적 분석(Mix Dialyzer), 유닛 테스트(Mix Test), 애플리케이션 빌드(Application Build)를 수행합니다.
위 세 가지 Job이 모두 성공했다면 애플리케이션 빌드 결과물을 배포가 가능한 형태인 Docker Image로 빌드한 뒤, 사내 도커 레지스트리에 배포하게 됩니다. 마지막으로 빌드의 성공/실패 여부를 알림으로 보내면 워크플로가 완료됩니다.
순차 처리에서 병렬 처리로
이렇듯 저희 파트에서 사용하는 워크플로는 여러 개의 Job으로 이루어져 있으며, 각각의 Job들이 병렬적으로 수행되는 구조입니다. 하지만 처음부터 이러한 구조를 가졌던 것은 아닙니다.
GitHub Actions를 사용하기 시작한 초창기에는 아래와 같이 2개의 Job만을 사용하였습니다. 정적 분석과 유닛 테스트, 애플리케이션 빌드까지 모든 과정이 순차적으로 이루어지도록 구성했었죠.
하지만, 이러한 순차적인 구조로 인해 발생하는 문제가 여럿 있었습니다. 우선 병렬적으로 수행이 가능한 작업들이 순차적으로 이루어지니, 당연히 시간이 오래 걸릴 수밖에 없었습니다. 특히 저희가 개발 중인 엘릭서 프로젝트의 경우, 정적 분석과 유닛 테스트, 애플리케이션 빌드가 모두 다른 환경을 사용하여 별도의 빌드 결과물이 필요합니다.
그렇기에 이전 스텝에서 정적 분석을 위한 컴파일을 하였더라도, 유닛 테스트를 위한 컴파일은 새로 수행해야 합니다. 즉, 서로 완전히 연관이 없는 작업을 불필요하게 순차적으로 작업하다 보니 많은 시간이 소요된 것입니다.
또한, GitHub Actions의 성능을 개선하기 위해 별도로 개발한 캐시 액션을 사용하고 있는데요. 앞에서 언급한 것처럼 여러 스텝에서 필요한 결과물이 별도로 존재하기에, 이를 하나의 Job에서 모두 불러오고 업데이트하려고 하니 캐시를 처리하는 비용이 증가하였습니다.
마지막으로 여러 관심사를 한 번에 체크하기 어려웠습니다. GitHub Actions에서는 하나의 Job 안의 Step들은 순차적으로 실행되고, 기본적으로 어느 한 Step이 실패한다면 그 이후의 Step들은 실행되지 않습니다. 따라서 정적 분석을 우선 수행한 다음, 유닛 테스트를 수행하였습니다. 그러니 정적 분석이 실패한다면, 유닛 테스트는 수행되지 않게 됩니다. 하지만 정적 분석과 유닛 테스트는 서로 다른 관심사입니다. 만약 정적 분석이 실패하더라도 유닛 테스트를 수행하기를 원했으며, 유닛 테스트가 정상적으로 성공하는지 확인하고 싶었습니다. 하지만 순차적인 구조 때문에 이러한 요구사항을 만족시키기가 어려웠습니다.
이러한 문제들을 해결하고 성능을 높이기 위해, 불필요하게 순차적으로 진행되던 여러 작업들을 각기 다른 Job으로 분리하여 병렬적으로 처리하도록 개선하였습니다.
우선 여러 작업들에서 공통으로 사용하는 값들은 Setup Job에서 정의하고 Output으로 사용할 수 있도록 구현하였습니다. 그리고 정적 분석과 유닛 테스트, 애플리케이션 빌드를 각각 Job으로 분리한 뒤, needs 문법을 통해 Setup Job이 성공한 후에 동시에 수행되도록 하였습니다.
jobs:
setup:
name: Setup Job
dialyzer:
needs: setup
name: Mix Dialyzer
test:
needs: setup
name: Mix Test
build:
needs: setup
name: Application Build
이렇게 Job을 분리하고 병렬적으로 처리함으로써 앞에서 언급한 문제를 해결할 수 있었습니다.
우선 기존 순차적으로 모든 과정을 수행할 때는 14분이라는 긴 시간이 필요했지만, 서로 독립적인 작업을 병렬로 처리함으로써 3분이라는 짧은 시간만에 모든 작업이 수행되었습니다. 또한, 캐시를 적용할 데이터 역시 분리되었기 때문에 이를 업로드/다운로드하는 시간을 많이 줄일 수 있었습니다.
마지막으로 특정 한 관심사의 실패 여부와 관계없이 모든 관심사들의 결과를 한 번에 확인할 수 있게 되었습니다. 이제 정적 검사, 유닛 테스트와 같은 다양한 관심사를 한번의 실행에서 하나씩 확인할 필요가 없어졌습니다. 단 한 번의 워크플로 실행으로 그러한 모든 관심사들의 결과를 확인하고, 문제가 있다면 이를 빠르게 수정할 수 있게 된 것입니다.
커스텀 액션
GitHub Actions의 큰 장점 중 하나는 커스텀 액션(Custom Actions)을 쉽게 개발하고 사용할 수 있다는 점입니다.
GitHub Actions에는 빌드 속도를 개선하기 위한 캐시 액션이 있지만, Enterprise GitHub에서는 사용하는 Runner에 직접 캐싱을 하는 것이 더 빠르다는 이유로 캐시 액션이 지원되지 않습니다. 하지만 저희는 프로젝트의 모든 Pull Requests와 브랜치에 대해 테스트와 빌드를 수행하기에, 단순히 Runner에 직접 캐싱을 해버리면 이 각각의 흐름에 딱 맞는 캐싱을 하기 어려워지게 됩니다. 또한, 전사적으로 공유하는 Runner를 사용하기에 Runner의 개수가 많습니다. 단순히 Runner에 직접 캐싱하는 경우 모든 Runner에 캐시가 최신화되어 있다고 보장할 수도 없습니다.
이러한 문제를 해결하기 위해 카카오 i 클라우드의 제품 중 하나인 Object Storage를 사용하여 캐싱을 하기로 결정했습니다. 단 이 경우, 별도로 구현이 필요한 사항이 굉장히 많아집니다. 인증 처리부터 캐싱할 파일을 처리하는 작업, 에러 핸들링 등 굉장히 할 일이 많아지기에 워크플로 파일에 이것을 모두 작성하기에는 어려움이 있습니다. 또한, 모든 프로젝트에 대해 공통적으로 적용해야 하므로, 중복된 구현의 문제도 있는데요. 이러한 상황에서 사용할 수 있는 방법이 바로 커스텀 액션입니다.
GitHub Actions에서는 특정 동작을 수행하기 위한 로직들을 커스텀 액션으로 개발할 수 있습니다. 그리고 워크플로 파일에서는 단순히 커스텀 액션을 호출하는 것으로 원하는 동작을 간단히 수행할 수 있습니다. 또한, 커스텀 액션은 여러 레포지토리에서 공유하여 사용할 수 있기에, 여러 레포지토리에 중복된 로직들이 구현되는 것도 방지할 수 있습니다.
저희 파트는 필요한 캐시 로직들을 커스텀 액션으로 직접 개발하여 사용하고 있습니다. 아래 코드는 저희가 실제로 캐시 액션을 사용하는 코드입니다. 본래는 복잡한 구현이 들어가야 하는 동작을 커스텀 액션으로 미리 구현해 놓고, 이 동작이 필요한 곳에서는 커스텀 액션을 간단히 호출하기만 하면 됩니다.
- name: 캐시 액션
id: cache
uses: ep/github-actions-cache@v2
with:
paths: |
deps
_build
priv/plts
key: example-${{ env.ELIXIR_VERSION }}-${{ needs.setup.outputs.cache_name }}-${{ hashFiles('./mix.lock') }}
restore-keys: |
example-${{ env.ELIXIR_VERSION }}-${{ hashFiles('./mix.lock') }}
example-${{ env.ELIXIR_VERSION }}-${{ needs.setup.outputs.cache_name }}
example-${{ env.ELIXIR_VERSION }}
download-cache: ${{ needs.setup.outputs.download_cache }}
update-cache: ${{ needs.setup.outputs.update_cache }}
env:
KAKAO_I_CLOUD_ID: ${{ secrets.KAKAO_I_CLOUD_ID }}
KAKAO_I_CLOUD_SECRET: ${{ secrets.KAKAO_I_CLOUD_SECRET }}
커스텀 액션을 사용하지 않았을 때와 사용했을 때의 차이를 표로 정리해보았는데요.
커스텀 액션으로 캐시를 직접 구현함으로써, Docker를 사용한 독립적인 환경으로 액션을 실행하면서도 캐시를 사용하여 GitHub Actions 빌드의 성능을 높일 수 있었습니다. 현재 이 캐시 액션은 약간의 개량을 거쳐 카카오엔터프라이즈 전사적으로 제공하고 있을 정도로 만족스러운 커스텀 액션이 되었습니다.
현재 저희 파트는 캐시 액션 말고도 파트 규칙에 맞춰 자동으로 도커 태그를 만들어주는 액션이나, Pull Requests의 특정 라벨을 체크해 주는 액션 등을 직접 개발하여 적극적으로 활용하고 있습니다.
마치며
지금까지 워크서버개발파트에서 CI 도구를 젠킨스에서 GitHub Actions로 변경한 이유와 GitHub Actions를 사용했을 때 얻을 수 있는 다양한 개발상의 이점을 살펴보았습니다. 특히, GitHub 페이지에서 바로 빌드 결과를 확인/실행하고 여러 페이지를 오갈 필요가 없는 점, 그리고 커스텀 액션을 통해 워크플로 파일을 간단히 유지하면서도 빌드 과정에서 원하는 동작을 쉽게 사용할 수 있는 점은 오직 GitHub Actions만의 장점 같습니다.
편리한 사용성과 개발 편의성, 팀의 개발 상황에 맞는 액션을 사용함으로써 괄목할만한 성능 향상도 덤으로 따라왔습니다. 기존 CI 도구로 사용했던 Jenkins에서 GitHub Actions로 마이그레이션도 진행했는데요. GitHub Actions와 Jenkins는 여러 유사성을 공유하므로, GitHub Actions로 마이그레이션하는 것도 비교적 간단했습니다.
GitHub Actions가 분명 뜨겁게 부상하는 CI/CD 도구이기는 하지만, 코드 보관 및 관리의 편리성, 아직 초기 단계로 커뮤니티 지원이 Jenkins에 비해 상대적으로 부족한 점도 고려해 봐야 할 것입니다. 그럼에도 불구하고, 혹시 새롭게 CI 서버를 구축해야 하는 상황이거나 혹은 기존 사용하던 CI 서버를 변경할 계획이 있으시다면, GitHub Actions을 채택해 보는 것이 좋은 선택이 될 것입니다. 감사합니다.
댓글