본문 바로가기

⛳️ 공동구매 서비스 총대마켓

개발 환경 CI/CD 파이프라인 구축기 | Github Actions, Self-hosted Runner, Docker 기술 선택 이유

CI/CD 필요성

작년 여름 참여했던 북링크 프로젝트에서, 백엔드 코드에 변경 사항이 발생할 때마다 깃허브 pull, 빌드, 서버 내리고 올리기 과정을 반복했다. 모든 자잘한 작업들을 수동으로 진행했기 때문에 명령어를 입력할 때 잦은 실수가 있었고, 가끔은 배포 과정을 잊어 프론트엔드 팀원들에게 미안한 상황도 종종 발생했다. CI/CD에 무지했기 때문에 발생했던 불편함이었다. 덕분에 지속적으로 코드를 통합하고 배포하는 과정이 꼭 필요함을 체감했다. 이번에 진행하는 총대마켓 프로젝트에서는 실수를 줄이기 위해 배포를 자동화해보자!

사용 기술

  • Github Actions + Self-hosted Runner
  • Docker

Github Actions 선택 이유

총대마켓 서비스가 Github Actions를 CI/CD 구축 도구로 설정한 이유 중 가장 큰 이유는 낮은 러닝커브였다. 나를 포함하여, 백엔드 팀원 중 배포 자동화 경험이 없는 팀원들이 해당 파트를 담당하기로 하였기 때문에, 빠르고 간단하게 구축 가능한 Github Actions를 택했다. 추가로 서버를 구축할 필요가 없다는 점이었다. 막상 파이프라인 구축을 시작하니 결국 self-hosted runner라는 새로운 서버를 구축해야 했지만, 기술을 선택하는 과정에서는 새로운 서버 구축에 드는 리소스를 줄이기 위함도 해당 기술을 선택하는 좋은 이유 및 기준이 되었다.

 

Self-hosted Runner 사용 이유

총대마켓 서버는 우아한테크코스에서 제공하는 AWS 보안 그룹을 사용한다. 우아한테크코스 보안 그룹은 443번과 80번 포트에 대해 모든 인바운드 경로를 열어둔 반면, 원격 접속을 위한 22번 포트에 대해서는 캠퍼스 등 특정 장소에 한해서만 열려있었다.

 

Github Actions가 기본으로 제공하는 Runner에서 총대마켓 서버에 원격 접속하기 위해서는 해당 Runner에서 총대마켓 서버로 들어오는 22번 포트에 대한 경로가 인바운드 규칙으로 열려있어야 한다. Github Actions Runner가 위치한 IP 주소를 알아내 인바운드 규칙을 변경해주면 될 것이다. 그러나 깃허브는 수시로 IP 주소를 변경하기 때문에 이 방식은 권장되지 않는다. 더불어 애초에 우리는 우아한테크코스의 보안 그룹을 사용해야 하기에 인바운드 규칙을 나의 입맛대로 변경할 수도 없는 상황이었다.

 

따라서 우리는 Github Actions가 기본으로 제공하는 Runner에서 총대마켓 서버에 원격으로 접속하는 방식이 아닌, 서버 내부에서 직접 실행이 가능한 Self-hosted Runner를 사용했다.

 

Q. Self-hosted Runner 동작 방식?

총대마켓 서버(EC2) 내에 Self-hosted Runner를 설치한다. 이렇게 되면 외부에서 EC2에 원격 접속하지 않고, Self-hosted Runner가 직접 EC2 내부에서 실행 가능하게 된다.

 

Self-hosted Runner는 HTTPS long polling 방식으로 50초간 Github 서버에 대한 통신을 열어둔다. 응답이 수신되지 않으면 새로운 long poll을 생성한다. 즉, Self-hosted Runner는 github.com에 대한 연결을 열어두고 기다리기 때문에, Github는 Self-hosted Runner에 대한 인바운드 규칙을 열어둘 필요가 없다. 그리고 이 때 Self-hosted Runner와 Github는 443번 포트(HTTPS)를 통해 소통하기 때문에 Self-hosted Runner를 품고 있는 총대마켓 서버가 443번 포트에 대한 인바운드 규칙을 열어두어야 한다.

 

참고

 

Q. 정말 22번 포트가 막혀있으면 원격 접속에 실패할까?

인바운드 규칙 중 22번 포트에 대해 제한을 걸어두었을 때 정말 원격 접속이 제한될까? 직접 확인해보고 싶었다.

 

ssh를 통해 원격으로 ec2에 접속을 시도하는 간단한 스크립트를 작성했다. 더불어, 연습을 위해 EC2 개인 서버를 생성한 후 22번 포트에 대한 인바운드 규칙을 이리저리 변경해보았다.

 

(1) 모든 IP 주소로부터의 접속을 허용한 경우

22번 포트에 대해 모든 IP 주소로부터의 통신을 허용할 경우, 예상대로 EC2 원격 접속에 성공한다.

 

(2) 특정 IP 주소 외의 접속을 차단한 경우

인바운드 규칙을 제한할 경우, 아래와 같이 EC2 원격 접속에 실패한다. GitHub Actions Runner에서 우리의 서버(EC2)에 22번 포트를 통해 접속을 시도하는데, 해당 러너가 위치한 IP 주소에 대해 인바운드 규칙을 열어두지 않았기 때문이다.

 

결론: 실제로 인바운드 규칙 중 22번 포트에 대해 제한을 걸어두면 원격 접속이 제한됨을 확인할 수 있었다 bb

 

Q. Self-hosted Runner 통신을 위해 필수로 열어두어야 하는 포트?

자 이제 Self-hosted Runner가 왜 필요한지 알았으니 EC2 내에 직접 설치해보고 어떤 포트를 열어두어야 하는지 알아보자. 설치는 해당 링크를 참고했다.

 

테스트를 위해, appleboy 라이브러리에 대한 의존성 없이 22번 포트를 통해 EC2 서버에 원격 접속하는 스크립트를 작성했다. Github Actions Ubuntu 서버에서는 22번 포트에 대한 인바운드 규칙만 모두 열어두면 예상대로 잘 동작한다. 마찬가지로 인바운드 규칙을 제한할 경우 접속에 실패한다.

 

이제 Self-hosted Runner에서 같은 스크립트를 실행시켜보자. 서버의 인바운드 규칙을 바꾸어가며 접속 성공 여부를 살펴보았다.

  • 22번 포트 제한 → 접속 불가
  • 22번 포트 제한 + 443번 포트 모든 접근 허용 → 접속 불가
  • 22번 포트 제한 + 443번 포트 모든 접근 허용 + 80번 포트 모든 접근 허용 → 접속 성공

 

결론: Self-hosted Runner가 Github와 통신하기 위해서는 443번, 80번 포트를 열어두어야 한다.

 

Docker 사용 이유

나는 도커 사용 경험이 적었기 때문에, 경험 많은 팀원들로부터 도커 없으면 나중에 서로 환경 통일하기 힘들 것이라는 설득에 넘어갔다. 언젠가는 사용하게 될 것이라는 주장이었다.

 

[ 뒤늦게 찾아본 나만의 이유 ]

- 일부 로직만 Self-hosted Runner에서 실행시켰기 때문에 dockerhub를 통해 간편하게 image(빌드 파일)를 관리하기 위함

- 이후 HTTPS 구축 과정에서 웹 서버 컨테이너를 띄우기 위해 도커 사용

 

최종 스크립트

개발 환경에 대한 CI/CD 스크립트는 아래와 같이 작성했다. Github Actions가 기본으로 제공하는 Runner를 사용하되, 서버 접속이 필요한 부분에 대해서만 Self-hosted Runner를 사용하기로. Github Actions Runner에서 빌드된 jar 파일을 Self-hosted Runner에서 실행시키기 위해 dockerhub를 수단으로 활용하였고, 그렇게 Docker를 도입하게 되었다.

 

기본 Runner 없이 Self-hosted Runner만 사용해 구축하면 사실 Docker도 불필요할 것이다. 이후에 Docker를 사용하지 않고 CI/CD를 구축하는 경험도 해보고 싶다!

name: Backend CI/CD Workflow

on:
  pull_request:
    branches: [ "develop-BE" ]
    paths: [ "backend/**" ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'corretto'
      - name: Gradle Caching
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
#      - name: Set up Gradle
#        uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5
      - name: Build with Gradle Wrapper
        run: ./gradlew clean build
        working-directory: ./backend
      - name: Docker build and push
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker build -t ${{ secrets.DOCKERHUB_IMAGE_NAME }} .
          docker tag ${{ secrets.DOCKERHUB_IMAGE_NAME }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }}:${GITHUB_SHA::7}
          docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }}:${GITHUB_SHA::7}
  deploy:
    needs: build-and-test
    runs-on: self-hosted
    steps:
      - name: Pull Image And Restart Container
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker stop ${{ secrets.DOCKERHUB_CONTAINER_NAME }} | true
          docker rm ${{ secrets.DOCKERHUB_CONTAINER_NAME }} | true
          docker image prune -a -f
          docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }}:${GITHUB_SHA::7}
          docker run --name ${{ secrets.DOCKERHUB_CONTAINER_NAME }} -d -p 80:8080 ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }}:${GITHUB_SHA::7}

 

마무리

적다보니, CI/CD 구축기보다는 왜 Self-hosted Runner가 꼭 필요했는지에 대해 집중적으로 서술한 것 같다. 스크립트를 작성하는 과정에서 이미 같은 환경의 여러 레퍼런스가 존재했고, 같이 구축하기로 한 팀원이 필요한 기술들을 미리 알아왔기 때문에 삽질하는 과정이 부족했다. 나는 삽질을 좋아하는데 그 과정이 없었어서 혼자서라도 왜 이 기술들이 꼭 필요했는지 알아보고 싶었다!

 

출처

곳곳에 링크 달아둠