• logo

      SeolMyeongTang

  • 나의 첫 CICD 고도화 이야기

    2022년 12월 27일

    과거, 나의 첫 자동 배포기(feat. GitHub Actions) 글을 통해 GitHub Actions를 통해 자동 배포 환경을 구성하였습니다. 여러 난관을 해결하면서 개발자들이 코드를 GitHub로 push만 한다면 인프라 팀한테 알리지 않아도 자동 배포가 되어 만족스러웠습니다. 하지만 가끔 cicd가 실패하여 수동 배포를 한 적도 여럿 있었고, 예전에는 미처 생각하지 못했던 치명적인 문제도 있었습니다. 이번 시간에는 cicd 구성에 어떤 문제점이 있었고 이를 어떻게 해결하였는지에 대한 이야기를 해보려 합니다. 프론트(React), 백엔드(Spring Boot) 양 쪽 모두 cicd를 개선했으나 cicd 구조가 조금 복잡한 백엔드 cicd를 중심으로 설명드리겠습니다.

    cicd를 구축하고 반 년이 지나면서 보완하고 싶은 목록들을 아래와 같이 정리하였습니다.

    • GitHub Actions의 GitHub-hosted runners locale 설정으로 인한 배포 문제
    • 테스트 실패 시, 테스트 로그 확인의 불편함
    • 빌드, 테스트 속도 개선을 통한 배포 시간 단축(캐싱)
    • cicd 결과 유무 알림

    우선적으로 해결하고 싶은 문제들을 순서로 적었습니다. 1, 3, 4번은 프론트 cicd에서도 보완하고 싶은 공통적인 문제입니다. 먼저 첫 번째 사항부터 파헤쳐 보겠습니다.

    가끔 개발자분들이 push를 했는데 배포가 진행되지 않았다고 연락이 옵니다. 분명 cicd도 통과 되었음에도 불구하고 배포가 되지 않았다니 귀신이 곡할 노릇이었습니다. 원인을 분석해보니 공통적인 문제를 찾을 수 있었습니다. 배포 실패가 일어난 시간대가 한국 시각을 기준으로 밤 혹은 새벽 시간대였다는 것입니다. 이를 바탕으로 원인을 천천히 살펴보았습니다. cicd에서 빌드와 테스트가 정상적으로 통과되면 빌드물을 현재 날짜를 이름으로 압축하여 s3에 저장을 하게 설정하였습니다. 따라서 정상적으로 빌드가 되었음에도 locale 문제로 s3에 올라간 빌드물 이름이 현재 날짜와 동일하지 않고 하루 밀려서 배포가 진행되지 않았던겁니다. 위의 사실로 ‘GitHub Actions에서 제공해준 인스턴스(GitHub-hosted runners) locale 설정이 한국 기준이 아니라서 발생하는 문제구나’라는 원인을 찾을 수 있었습니다.

    이를 해결하기 위해 떠오른 생각이 2가지 있었습니다.

    1. GitHub-hosted runners의 locale 설정을 한국 기준으로 바꾼다.
    2. 빌드물 압축 파일 이름을 날짜가 아닌 다른 고유값으로 바꾼다.

    첫 번째 방법을 선택한다면 GitHub Actions yaml 파일에 locale을 설정하는 코드가 들어가게 되어 설정 파일이 길어지게 됩니다. 또한 하루에 여러 번 배포를 진행한다면 날짜 값이 겹쳐 해당 날짜의 최신의 빌드물만 관리할 수 있습니다.

    두 번째 방법은 압축 파일 이름을 지을 고유값만 적절히 찾는다면 위의 문제들을 해결할 수 있습니다. 문제는 고유값을 찾는 일이였습니다. 고심끝에 commit마다 고유한 id가 있으니 이름이 겹칠 일이 없는 commit id를 사용하기로 결정했습니다. 다만, 이 commit id 길이가 기니 앞 8자리를 짤라서 쓰기로 하였습니다.

    GitHub Actions에서 commit id를 쓰려면 아래의 코드와 같이 GITHUB_SHA 변수를 사용하면 됩니다. 앞 8자리만 쓸거니 GITHUB_SHA::8으로 슬라이스 처리하였습니다.

    YAML
    - name: Compress
      run: |
        tar -zcvf ${GITHUB_SHA::8}.tar.gz \
          dependencies \
          snapshot-dependencies \
          spring-boot-loader \
          application
    

    다만 주의할 점이 외부 스크립트에서 쓸 경우에는 환경변수(envs)를 지정해야 정상적으로 commit id 값을 쓸 수 있으니 참고하시길 바랍니다.

    YAML
    - name: Deploy
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST_PROD }}
        username: ${{ secrets.USERNAME_PROD }}
        port: ${{ secrets.PORT_PROD }}
        key: ${{ secrets.KEY_PROD }}
        envs: GITHUB_SHA
        script: |
          cd ~/keeper/deploy
          ./deploy_server.sh ${GITHUB_SHA::8}
    

    commit id 값 덕분에 locale 문제로 인한 배포 문제는 해결 할 수 있었고 배포할 때마다 만족스럽게 s3에 빌드물들이 겹치지 않게 올라오는 것을 확인할 수 있었습니다.

    그 다음 문제로는 백엔드 테스트 실패 시, 테스트 로그 열람에 대한 내용입니다. 백엔드 cicd 실패 원인의 90% 이상은 테스트 실패로 인한 빌드 실패입니다. Spring Boot는 테스트가 실패하면 이에 대한 로그를 build/reports/tests/test 위치에 test, package, class 별로 html 형식으로 볼 수 있습니다.

    Spring Boot 테스트 실패 목록들
    Spring Boot 테스트 실패 목록들

    이때까지는 테스트 실패 후, 실패 원인을 파악하기 위해 로그를 보려는데 GitHub-hosted runners 위에서 돌아가다 보니 cicd가 끝나면 테스트 로그 파일을 볼 수 있는 방법이 없었습니다. 그래서 따로 로컬로 가지고 와서 docker로 한 번 더 빌드하여 테스트 로그를 확인하는 아주 비효율적인 방법으로 로그를 확인했습니다.

    테스트 로그 보는 일을 2번 하지 않기 위해, 테스트 로그 결과를 s3에 올리고 호스팅하여 로그를 보는 방법을 사용했습니다. 앞서 말했듯이 Spring Boot는 테스트에 대한 결과를 build/reports/tests/test 디렉터리에 html 파일로 생성합니다. 테스트 실패할 때만 해당 결과 값이 필요하므로, 테스트 실패 시(if: failure()) build/reports/tests/test 위치에 생성된 파일들을 s3에 업로드하고 url를 통해 접속하여 쉽게 테스트 결과를 확인할 수 있도록 s3를 설정하였습니다.

    YAML
    - name: Update Test Result
      if: failure()
      run: |
        aws s3 cp --region ap-northeast-2 --recursive \
        build/reports/tests/test \
        ${{ secrets.S3_TEST_REPORT }} --recursive
    

    locale 문제로 인한 배포 문제, 테스트 결과 로그 확인 이 두 가지 문제는 이번 cicd 고도화하면서 해결해야 할 필수적인 사항들이었습니다. 다음으로는 욕심을 조금 내어 빌드, 테스트 시간을 줄여 배포 시간을 줄이는 거였습니다. 이 부분은 백엔드 개발자들의 리팩토링 작업을 통해 직접적으로 개선할 수 있겠지만 인프라 쪽에서도 할 수 있는 방법을 찾고자 해당 문제를 고민해보았습니다.

    cicd 고도화하기 전 백엔드는 약 90개의 테스트 파일이 있었고 전체 550여 개의 테스트가 작성되어있었습니다. 이러한 테스트를 통과하고 빌드하는 cicd 시간은 약 6분 30초 정도 소요되었습니다.

    cicd 고도화 하기 전 백엔드 cicd 목록들
    cicd 고도화 하기 전 백엔드 cicd 목록들

    cicd 소요 시간을 줄이기 위해 다음 2가지 작업을 진행했습니다.

    1. 빌드 및 테스트 방식 변경
    2. gradle 및 DB docker 이미지 캐싱

    1번부터 보겠습니다. 기존 빌드 방식은 Spring Boot 빌드, 테스트 작업을 docker compose를 통해 app(gradle), db(mysql), redis 컨테이너 위에서 진행했습니다. AWS의 ECR로부터 app, mysql docker 이미지를 가지고 오고 cicd용 docker compose를 실행하여 빌드와 테스트를 진행했습니다.

    YAML
    - name: Login to ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
    
    - name: Pull App Image From ECR
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: keeper-homepage-app
      run: docker pull $ECR_REGISTRY/keeper-homepage-app:${{ secrets.KEEPER_APP_TAG }}
    
    - name: Pull DB Image From ECR
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: keeper-homepage-db
      run: docker pull $ECR_REGISTRY/keeper-homepage-db:${{ secrets.KEEPER_DB_TAG }}
    
    - name: Create ENV File
      working-directory: ./docker
      run: |
        touch .env
        echo "${{ secrets.DOCKER_ENV }}" >> .env
        echo "ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }}" >> .env
    
    - name: Start Containers
      working-directory: ./docker
      run: docker-compose -p keeper up -d
    
    YAML
    services:
      app:
        container_name: app
        image: ${ECR_REGISTRY}/keeper-homepage-app:${KEEPER_APP_TAG}
        volumes:
          - ../:/home/keeper
        ports:
          - 8080:8080
        entrypoint: "/entrypoint.sh"
        environment:
          - SECRET=${SECRET}
          - MAIL_HOST=${MAIL_HOST}
          - MAIL_PORT=${MAIL_PORT}
          - MAIL_USERNAME=${MAIL_USERNAME}
          - MAIL_PASSWORD=${MAIL_PASSWORD}
          - MYSQL_DATABASE=${MYSQL_DATABASE}
          - MYSQL_USER=${MYSQL_USER}
          - MYSQL_PASSWORD=${MYSQL_PASSWORD}
        depends_on:
          - db
    
      db:
        container_name: db
        image: ${ECR_REGISTRY}/keeper-homepage-db:${KEEPER_DB_TAG}
        environment:
          - MYSQL_DATABASE=${MYSQL_DATABASE}
          - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
          - MYSQL_USER=${MYSQL_USER}
          - MYSQL_PASSWORD=${MYSQL_PASSWORD}
        command:
          - --character-set-server=utf8mb4
          - --collation-server=utf8mb4_unicode_ci
        ports:
          - 3306:3306
    
      redis:
        container_name: redis
        image: redis:6.2
        ports:
          - 6379:6379
    

    위 방법을 구현하기 위해 여러 삽질을 해서 설정하여 나름 만족한 자동 빌드 프로그램을 구성하였습니다. 하지만 다시 코드를 보니 부끄러운 부분들이 보입니다. docker compose 내용 중 app 쪽을 보면 Spring Boot 코드 전체를 볼륨으로 연결합니다. 이는 여러 번의 docker 경험으로 미루어 보았을 때, I/O 성능에 미쳐 속도가 떨어지게 됩니다. app 이미지를 위해 ECR로 부터 docker pull을 받아야하는 시간으로 전체적인 cicd 시간도 늘어나게 됩니다. 그리고 코드를 살펴보면서 의문을 품은 것이 app 컨테이너에 대한 필요성입니다. gradle 프로그램만 있으면 docker에 올리지 않고 보다 쾌적한 로컬 환경에서 빌드 시간을 줄일 수 있을거라 생각했습니다.

    위의 문제점들을 한 줄로 정리하면 다음과 같습니다. app 컨테이너를 쓰지 말자. app 컨테이너가 하는 gradle 역할을 로컬로 옮기면서 docker volume으로 인한 성능 하락을 막고 ECR로 부터 이미지를 내려받는 시간도 줄이는 효과를 기대하였습니다. 이왕 하는겸 db와 redis 부분도 로컬로 옮기면 좋지 않을까 생각했지만 이에 대한 성능이 크게 차이가 나지 않을거라 판단하여 적용하지 않았습니다.

    빌드, 테스트 환경을 GitHub Actions의 로컬로 옮기면서 순조롭게 풀리는가 했지만, 추가적으로 고려해야하는 상황이 생겼습니다. 바로 application.properties파일에 대한 부분입니다. application.properties파일은 Spring Boot 설정에 대한 내용이 담겨져있는 파일입니다. application.properties파일 내용 중 db 연결 부분과 관련하여 cicd 빌드할 때와 실배포할 때 값을 다르게 해줘야합니다. 환경이 로컬로 바뀜에 기존 컨테이너 명으로 db url를 설정해주었는데 이를 localhost로 변경해줘야 정상적으로 인식합니다. 따라서 cicd 빌드할 때는 application.properties내용을 적용하고 실배포는 application-deploy.properties를 적용하도록 하는 작업을 추가하였습니다.

    YAML
    - name: Setup JDK
      uses: actions/setup-java@v3
      with:
        java-version: 17
        distribution: temurin
    
    - name: Create application.properties
      working-directory: ./src/main/resources
      run: |
        touch application.properties
        echo '${{ secrets.APPLICATION_PROPERTIES }}' >> application.properties
        echo '${{ secrets.APPLICATION_PROPERTIES_DEPLOY }}' >> application-deploy.properties
    
    - name: Start Containers
      working-directory: ./docker
      run: docker-compose -p keeper up -d
    
    - name: Build
      run: |
        ./gradlew build --daemon --build-cache --parallel
        java -Djarmode=layertools -jar build/libs/homepage-0.0.1-SNAPSHOT.jar extract
    
    TEXT
    # application.properties
    spring.datasource.url=jdbc:mysql://localhost:3306/keeper?autoReconnect=true&serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=utf8
    
    # application-deploy.properties
    spring.datasource.url=jdbc:mysql://db:3306/keeper?autoReconnect=true&serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=utf8
    
    BASH
    java -Dspring.profiles.active=deploy -Duser.timezone=Asia/Seoul org.springframework.boot.loader.JarLauncher
    

    1번의 상황(빌드 및 테스트 방식 변경)을 해결했지만 빌드, 테스트 시간에 대한 직접적인 영향을 준게 아니라 만족스럽지 못했습니다. Spring Boot 개발자들이 주로 쓰는 intelli IDEA를 통해 빌드를 하면 순식간에 빌드가 진행된다는 점을 참고하여 코드 변경된 부분만 컴파일, 테스트를 진행하면 시간을 단축시킬 수 있지 않을까하는 생각을 시작으로 GitHub Actions 캐싱에 대해 찾아보았습니다.

    일반적인 GitHub Actions 캐싱 방법으로 아래와 같이 actions/cache를 활용하여 캐싱을 합니다.

    YAML
    - name: Cache Gradle
      uses: actions/cache@v3
      with:
        path: |
          ~/.gradle/caches
          ~/.gradle/wrapper
        key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
        restore-keys: |
          ${{ runner.os }}-gradle-
    

    path 값이 캐싱 처리하고 싶은 경로를 말합니다. 캐싱을 하기 위해 key 값을 참고하여 GitHub Actions cache 저장소에 캐시 파일이 있는지 찾습니다. 캐시 파일이 없다면 restore-keys 값을 참고하여 대체할 캐시 파일을 찾습니다. 이마저도 없다면 캐싱 과정을 생략합니다.

    gradle 캐싱 적용을 위해 찾아본 많은 글들이 위 코드와 같이 key 값을 hashFiles 함수를 이용했습니다. hashFiles이란 이름에서 알 수 있듯이 file의 hash 값을 반환합니다. 파일 내용이 변경이 된다면 hash 값도 바뀔테니 cache miss가 일어날 겁니다. 하지만 이 점에 대한 의문이 있습니다. 보통 Spring Boot .gitignore 내용을 보면 .gradle가 추가 되어있습니다[1]. GitHub-hosted runner 환경이라면 매번 새로운 환경에서 빌드가 될텐데 어떻게 .gradle 파일이 변경 되었는지 확인 할 수 있을까요. .gitignore.gradle이 추가되어있으면 사실 저 위 코드는 의미가 없어지는것이 아닌가 생각하였습니다.

    캐싱의 효과를 받기 위해 백엔드 코드를 수정할 때마다 매번 .gradle를 추가하는 방법이 있겠지만 한 번 다르게 접근해보았습니다. old, new라는 2개의 캐싱 파일을 유지하는 방법으로 구현해보는 생각을 하였습니다. Spring Boot 캐싱 과정은 다음과 같습니다.

    1. Spring Boot 빌드 전 캐시 파일 확인
      1. 존재(cache hit) → 캐시 로드 후 진행
      2. 미존재(cache miss) → 캐시 없이 진행
    2. 빌드 및 테스트
      1. 성공 → 배포 진행
    3. (2-a 일 때만) 이전 캐시 삭제 및 새로운 캐시 저장

    1번의 캐시 파일 확인과 3번의 캐시 삭제는 GitHub Actions Cache API를 활용하였습니다.

    YAML
    - name: Check Gradle Cache
      id: cache
      run: |
        RESULT=$(curl \
          -H "Accept: application/vnd.github+json" \
          -H "Authorization: Bearer ${{ secrets.TOKEN }}" \
          "${{ secrets.CACHE_PATH }}-1")
    
        if [[ $RESULT == *"created_at"* ]]
        then
          echo "new=2" >> $GITHUB_OUTPUT
          echo "old=1" >> $GITHUB_OUTPUT
        else
          echo "new=1" >> $GITHUB_OUTPUT
          echo "old=2" >> $GITHUB_OUTPUT
        fi
    
    - name: Cache Gradle
      uses: actions/cache@v3
      with:
        path: |
          ~/.gradle/caches
          ~/.gradle/wrapper
        key: ${{ runner.os }}-gradle-${{ steps.cache.outputs.new }}
        restore-keys: |
          ${{ runner.os }}-gradle-
    
    - name: Delete Previous Gradle Cache
      run: |
        curl \
        -X DELETE \
        -H "Accept: application/vnd.github+json" \
        -H "Authorization: Bearer ${{ secrets.TOKEN }}" \
        "${{ secrets.CACHE_PATH }}-${{ steps.cache.outputs.old }}"
    

    캐싱으로 인한 cicd 시간 단축을 확인하기 위해, cicd 고도화 후 cicd 소요 시간들을 살펴보았습니다. 변경된 코드에 따라 결과가 다르겠지만 전과 비교하여 평균적인 시간 면에서 사소한 이득이 있는 것을 확인했습니다.

    cicd 고도화 하기 전 백엔드 cicd 목록들
    cicd 고도화 하기 전 백엔드 cicd 목록들 [비교]

    저희 팀은 main 브랜치에 머지하기 전 develop 브랜치에 코드를 올리고 main에 머지하는 git-flow 방식을 사용합니다. develop 브랜치에 빌드와 테스트를 진행하여 캐싱을 하고 그 뒤에 main 브랜치에서 cicd를 적용하여 캐싱의 효과를 받을 수 있어 상당히 빠르게 배포를 진행할 수 있게됩니다.

    develop 브랜치에서 캐싱 작업 후 main 브랜치 빌드 및 테스트 소요 시간이 빠른 것을 확인할 수 있다
    develop 브랜치에서 캐싱 작업 후 main 브랜치 빌드 및 테스트 소요 시간이 빠른 것을 확인할 수 있다

    위의 내용들을 통해 열심히 cicd를 고쳤지만 매번 cicd 결과를 보기위해 GitHub를 왔다갔다 하는건 번거로운 일입니다. cicd 결과를 보기 위해 슬랙 웹훅 알림을 활용하였습니다. 슬랙 웹훅 알림은 다른 많은 분들이 사용하는거라 이에 대한 정보도 쉽게 찾을 수 있습니다. fields 부분만 자신의 입맛에 맞게 설정해주시면 될 것 같습니다.

    YAML
    - name: Notify Slack
      if: always()
      uses: 8398a7/action-slack@v3
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      with:
        status: ${{ job.status }}
        author_name: Keeper Devlopment Backend CICD
        fields: repo, commit, message, author, job, took
    

    며칠간의 작업으로 cicd 고도화 작업을 마무리 했습니다. cicd가 변경됨에 따라 서버 배포 스크립트도 이에 맞게 수정한 후 정상 작동을 확인했을 때의 말하지 못할 희열을 느꼈습니다.

    감격의 cicd 고도화 슬랙 공지
    감격의 cicd 고도화 슬랙 공지

    추가적으로 cicd를 손볼겸 전반적인 인프라 코드 정리도 진행했습니다.

    • 인프라 코드를 용도에 따라 빌드(build), 배포(deploy), 자동배포(cicd), 채팅(chat), 모니터링(monitoring)으로 분리
    • 한 파일에 관리되고 있었던 cicd 내용을 development, production으로 분리
    • letsencrypt ssl 파일을 볼륨으로 설정하여 관리
    • 이외의 리팩토링 작업

    이상으로 cicd 고도화에 대한 글을 정리해보았습니다. cicd 고도화 후 실패없는 배포로 슬랙에 배포 성공 메시지가 울릴 때마다 뿌듯합니다. 더욱 견고한 cicd를 만들어서 힘들었지만 재미있었고 얻은 것이 많았던 작업이었습니다. 다음 cicd를 만진다면 배포 쪽에 대해 알아보고 싶습니다. 현재 쉘 스크립트로 배포를 진행 중인데 이 부분을 더욱 안정성있게 구성해보는것이 목표입니다. 추후 해당 내용에 대한 성과가 있다면 정리해보겠습니다.

    [1] https://github.com/spring-projects/spring-boot/blob/main/.gitignore