노션에서 보기 : https://cabi.oopy.io/1022ea8e-7ef4-423d-84cf-94dae980d3e2

개요

반려동물 SNS 프로젝트를 마무리 해가면서 협업에 따른 기존 코드와 새 코드의 지속적인 통합(CI), 그리고 배포(CD)를 위한 파이프라인이 필요해졌다.

직접적으로 Github를 통해 협업을 진행하고 있고, 이에 따라 Github에서 제공하는 무료 서비스인 Github-Actions를 이용해 CI를 구축해보고자 한다.

먼저 CI/CD에 대해서 알아보고, 프로젝트의 CI부터 적용해보려 한다.


CI, CD

CI/CD (Continuous Integration/Continuous Delivery)는 애플리케이션 개발을 자동화하여 더욱 짧은 주기로 사용자에게 제공하는 방법이다.

CI/CD의 핵심 모토는 지속적인 통합, 지속적인 배포를 통한 지속적인 서비스 제공이다.

특히, CI/CD는 애플리케이션의 통합 및 테스트 단계에서부터 제공 및 배포에 이르는 애플리케이션의 라이프사이클 전체에 걸쳐 지속적인 자동화와 지속적인 모니터링을 제공한다. 이러한 구축 사례를 일반적으로 “CI/CD 파이프라인”이라 부른다.


CI/CD의 "CI"는 개발자를 위한 자동화 프로세스인 지속적인 통합을 의미한다. CI가 제대로 구현되면 변경된 코드가 빌드 및 테스트를 거쳐 리포지토리에 병합된다.

CI/CD의 "CD"는 지속적인 서비스 제공 또는 지속적인 배포라는 두 용어로 혼용한다. 지속적인 제공은 개발자들이 애플리케이션에 적용한 변경 사항이 버그 테스트를 거쳐 리포지토리에 자동으로 업로드되는 것을 뜻하며, 이 리포지토리에서 애플리케이션을 실시간 프로덕션 환경으로 배포할 수 있다. 즉, 자동화를 통해 새로운 코드를 배포하는 것이다.

지속적인 배포란 개발자의 변경 사항을 리포지토리에서 고객이 사용 가능한 프로덕션 환경까지 자동으로 릴리즈하는 것을 의미한다.

CI/CD는 지속적 통합지속적 제공의 구축 사례만을 지칭할 때도 있고, 지속적 통합, 지속적 제공, 지속적 배포라는 3가지 구축 사례 모두를 의미하기도 한다.

개인적인 의견으로는 대부분이 CI/CD를 통합, 제공, 배포 3가지를 모두 포함한 것으로 일컫는 것 같다.


Github Actions?

GitHub Actions는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 지속적 통합 및 지속적 배포(CI/CD) 플랫폼이다.

workflow는 하나 이상의 작업을 실행하는 구성 가능한 자동화된 프로세스, 즉 파이프라인이라고 생각할 수 있다. 리포지토리(./github/workflows)에 체크인된 YAML 파일로 정의된다.

Github Actions는 리포지토리에서 바로 개발 workflow를 자동화, 사용자 지정 및 실행한다. 주로 특정한 이벤트가 발생할 때 workflow를 실행한다. 예를 들어, 누군가 리포지토리에 새 커밋을 할 때마다 정의해놓은 workflow를 실행할 수 있다.

이를 이용해 리포지토리에 대한 모든 PR을 빌드 및 테스트하고, 병합된 PR을 프로덕션에 배포할 수 있다. 즉, Github Actions를 통해서 CI-CD를 구축할 수 있는 것이다.


CI 구성하기

이제 CI를 작성해보자. 현재 Spring Boot를 이용하여 백엔드 서버를 구성했으므로, 이에 알맞은 구성이 필요하다. 내가 CI를 통해 구성하고 싶은 것들은 다음과 같다.

  1. (Spring) 환경변수에 해당하는 .yml 파일들이 있는 서브모듈(backend_config/)을 업데이트하고 해당 환경변수를 적용해 build한다.
  2. build 과정에서 Test를 진행한다. (실패하는 경우에 Github Actions에서 자동으로 해당 CI를 종료시킨다.) (자바 프로젝트의 경우 gradle이라는 빌드 툴에서 빌드를 하면서 기본적으로 테스트를 진행한다.)
  3. 위 동작들은 backend/ 에 있는 파일을 수정, Commit이 발생했을 때, 트리거된다.

repository fork 해오기

본인이 CI에 능한 사람이 아니라면 한번에 구성하기는 힘들 것이다. 결국 구성해보고 돌려보는 것을 반복할텐데, 이 행위를 직접 적용하려는 레포지터리에서 하면 혹시 모를 문제가 발생하지 않을까?

이를 위해 내 맘대로 해도 문제가 없도록, CI를 적용하고자 하는 리포지토리를 fork해서 구성하자.

fork하는 방법은 공식문서를 참고하자.


환경변수 관리 - git submodule

이전에 내가 사용하던 환경변수 관리방법은 기존에는 공유된 노션을 이용해서 직접 관리하고, 수정사항이 있으면 반영하고, 이를 알리는 프로세스였다. 이는 지속적으로 추가, 수정되는 환경변수들로 인해 불필요한 소통 비용을 발생시켰다. 또, 배포를 위해서 github actions의 환경변수에 직접적으로 해당 파일들을 encoding, 액션 안에서 decoding하는 방법을 이용했는데, 이 또한 변경될 때마다 계속 업데이트해줘야 하는 불편함이 있었다.

이를 더 간편하게 하는 방법을 찾아보았고, git submodule을 이용한 방법을 알게되었다.

submodule은 상위 리포지토리 안에 하위 리포지토리로 있기 때문에, config(환경변수)만 별도로 분리하여 push-pull이 가능하다는 점에서 자동화만 잘 이뤄진다면 위 문제점들을 해소해줄 수 있을 거라고 생각했다.

git submodule에 대한 정보와 적용은 Git Submodule 알아보기와, config GIT submodule을 참고하면 좋을 것 같다.


환경변수 관리 - 요구사항

submodule을 이용한 방법으로 환경변수를 관리하기 위해서 다음과 같은 사항을 만족해야 했다.

  • 환경변수는 외부로 공개되지 않은 상태에서 정해진 권한을 가진 사람들(팀원)만 접근, 수정할 수 있어야 한다.
  • build시에 해당 환경변수는 업데이트가 되어 있는 상태여야 한다.
  • build시에 해당 환경변수는 지정된 디렉토리(Spring Boot의 경우 src/main/resources .. 등)에 있어야한다. (build 시점에서 정해진 경로에 따라 직접 참조하므로)
  • 모든 CI/CD에서 위 과정은 자동으로 이뤄져야 한다.

환경변수를 외부로 공개하지 않고, submodule로 구성하는 것은 private한 리포지토리를 생성, 상위 리포지토리에 해당 submodule을 clone하고, submodule로 설정해주면 해결할 수 있다(위에 소개한 글들에 이 내용이 있다).

한편, 해당 환경변수를 매 빌드마다 업데이트하게끔 하려면, 어떻게 해야할까? 두 가지 방법이 생각 났다.

  1. build 시점에 해당 build 툴에서 직접적으로 서브모듈을 업데이트한다.
  2. 직접 서브모듈을 업데이트하거나, 상위 리포지토리를 직접 업데이트하여 서브모듈 또한 업데이트 되게 한다.

1번의 경우에는 직접 build 툴에서 pull하는 것이 빌드 툴의 책임이라고 와닿지는 않았고, 해봐야 직접적으로 shell command를 사용할 것 같다는 생각이 들어서 깔끔한 방법은 아니라고 생각이 들었다. 결국, 서브모듈이 지정되어 있다면, 2번의 경우처럼 자연스럽게 상위 리포지터리의 변경사항으로서 서브모듈의 변경사항도 업데이트되게끔 되어 있으므로, 자연스럽게 업데이트를 유도하는 방법이 나을 것이라고 생각했다. 한편, 우리의 작업환경에서는 직접 업데이트를 하는 것이 맞겠지만, CI-CD 과정에서는 자동으로 업데이트가 되도록 설정해주어야할 것이다.

서브모듈에서 환경변수들을 원하는 디렉토리로 옮겨지도록 build 툴을 수정해보자.

  • gradle.build로 구성하기내 서브모듈의 경로는 backend_config/이다.
  • 빌드를 위해 컴파일하는 과정에서, 해당 환경변수인 yaml들을 우선적으로 업데이트(copy)한 후에 진행해야하므로, depensOn을 이용해 복사가 우선적으로 이뤄지게 설정해준다.
  • tasks.register('copyMainConfig', Copy) { from 'backend_config/main/resources' into 'src/main/resources' } tasks.register('copyTestConfig', Copy) { from 'backend_config/test/resources' into 'src/test/resources' } tasks.named('processResources') { dependsOn 'copyMainConfig' } tasks.named('processTestResources') { dependsOn 'copyTestConfig' } // special thanks to dongglee

환경변수 관리 - 자동화

여기까지 구성했다면, 다음과 같은 과제가 남았다.

  • 환경변수는 외부로 공개되지 않은 상태에서 정해진 권한을 가진 사람들(팀원)만 접근, 수정할 수 있어야 한다.
  • build시에 해당 환경변수는 업데이트가 되어 있는 상태여야 한다.
  • build시에 해당 환경변수는 지정된 디렉토리(Spring Boot의 경우 src/main/resources .. 등)에 있어야한다. (build 시점에서 정해진 경로에 따라 직접 참조하므로)
  • 모든 CI/CD에서 위 과정은 자동으로 이뤄져야 한다.

위에서 말했듯, 직접적으로 업데이트하는 경우에는 두번째 문제는 상관이 없다. 한편, 우리는 CI를 통해서 자동으로 build시에 submodule을 업데이트하고, 디렉토리에 옮기고, build를 진행하는 과정을 자동화해주어야 한다.

private repository에 있는 config을 어떻게 github actions 컨테이너(CI 환경)에서 pull 할 수 있을까?

→ 이는 ssh 터널링을 통해 가능하다. ssh 터널링과 이에 사용되는 비대칭키 인증에 대한 개념은 **SSH 암호화 및 연결 프로세스 이해**를 참고하면 좋을 것 같다.

ssh 터널링용 비대칭키 key-gen 및 세팅 (참고)

  • key-gen 후에 서브모듈에 public key(?.pub)를 등록한다.
  • CI를 사용하는 컨테이너에서는 해당 public key에 대응하는 private key를 갖고 있어야 한다(해당 private repo를 pull하기 위함)
  • 이를 위해 private key를 github actions의 secret으로 등록해준다.

workflow에 yaml 구성하기

#액션의 이름을 정의한다. 
#'backend CI'라는 이름으로 표시된다.
name: backend CI

#백엔드 관련 파일의 push 이벤트나 pull request 이벤트, 
#그리고 수동으로 workflow를 실행하려는 경우(on workflow_dispatch)에 workflow가 실행되도록 설정한다.
on:
  push:
    paths:
      - "backend/**"
  pull_request:
    paths:
      - "backend/**"
  workflow_dispatch:

#이 workflow에 리포지토리에 대한 읽기 권한을 부여한다.
permissions:
  contents: read

#이 작업을 최신 Ubuntu 환경에서 실행한다.
jobs:
  build:
    runs-on: ubuntu-latest

		#SSH 키의 private key를 설정하여 비공개 서브모듈에 접근할 수 있도록 한다. 
		#이 SSH 키는 GitHub Secrets에 설정하고, base64로 디코딩한다. <- 안 해도 되긴 한다.
    steps:
      - name: Setup SSH key to access private submodule
        run: |
          mkdir -p ~/.ssh
          echo '${{ secrets.BACKEND_CONFIG_PRIVATE_KEY }}' | base64 -d > ~/.ssh/id_rsa
          chmod 400 ~/.ssh/id_rsa
		#리포지토리로 체크아웃을 진행하고 서브모듈을 참조하여 clone한다.
      - name: Checkout
        uses: actions/checkout@v3
        with:
            submodule: true
		#submodule의 내용을 update한다.
      - name: Update config submodule
        run: |
          git submodule update --init
		#컨테이너에서 Corretto JDK를 사용하도록 설정한다.
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
            java-version: "17"
            distribution: "corretto"
		#backend 디렉토리로 이동하여 Gradle 빌드를 실행한다.
		#위에서 gradle.build에서 backend_config을 가져오도로 설정했으므로, 환경변수가 반영되어 빌드된다.
      - name: Build with Gradle
        run: |
          cd backend
          chmod +x gradlew
          ./gradlew build
        shell: bash

각 step은 사용자 정의로 작성하여 원하는 동작을 컨테이너에게 수행시킬 수 있다.

on:을 통해 특정 이벤트에 따라 트리거해줄 수 있으므로, 자동화를 수행할 수 있다!

한편, 위의 스크립트대로만 실행하면 매 CI마다 의존성을 새로이 설치하므로, 속도가 매우 느리다. 이를 위한 해결책으로 캐싱을 도입해볼 수 있다.

gradle 의존성 cache

#Gradle 종속성을 캐시하여 빌드 속도를 향상시키는 옵션이다. 
#'key'는 캐시에 대한 고유 식별자다. 
#Gradle 파일이나 설정이 변경될 때마다 다시 생성되며 'restore-keys'는 캐시를 복원하는데 사용된다.
  - name: Cache dependencies
    uses: actions/cache@v3
    with:
      path: |
        ~/.gradle/caches
        ~/.gradle/wrapper
      key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
      restore-keys: |
        ${{ runner.os }}-gradle-

GitHub Actions의 캐시는 GitHub의 인프라에 저장되며, 각 job이나 run이 종료된 후에도 유지된다.

이 캐시는 이후의 workflow run에서 사용하게 된다.

run(액션)이 첫 번째로 시작할 때, 키는 **/*.gradle*, **/gradle-wrapper.properties 파일의 해시 값에 따라 생성된다. 따라서 의존성이 변경되지 않는 한 다음 CI 동작에서도 동일한 캐시 키를 사용하여 동일한 캐시를 주어 빌드 시간을 단축하게 된다.


결과

처음에는 15번의 시도가 있었지만, 테스트 실패라든지 복사가 제대로 안된다든지 하는 문제들이 있을 수 있다. 계속 돌려보면서 고치면.. 결국 해낼 수 있다.


정리

여기까지 구성하게되면, 우리는 Spring Boot 프로젝트를 자동으로 테스트, Build하는 CI를 구성한 것이다.

이 CI를 통해서 코드에 대한 검증, 안전하고 자동화된 업데이트를 수행할 수 있다.

이제 압축된 빌드 파일을 CD를 통해서 서버 인스턴스에 전달하고, 실행하도록 만들면 CI-CD를 구성할 수 있다.

하지만 CD를 하기 전에, 서버 인스턴스를 구축해야한다. CI - 서버 인스턴스 구성 - CD의 순서로 진행해보자.

EC2로 서버 인스턴스 구성하기 with Spring Boot, Docker

참고자료

CI/CD(CI CD, 지속적 통합/지속적 배포): 개념, 툴, 구축, 차이

GitHub Actions 설명서 - GitHub Docs

복사했습니다!