ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [CI/CD] SpringBoot 프로젝트를 AWS LightSail에 Docker, Github Actions를 활용하여 배포 및 Tibero6와 연결하기
    CI CD 2023. 11. 16. 22:54

    📖 CI/CD란?

    CI는 Continuous Integration의 약자로 지속적 통합을 의미하며, CD는 Continuous Delivery로 지속적 배포를 의미한다.

    이는 애플리케이션 개발 단계를 자동화하여, 계속 똑같은 작업을 반복하는 수고로움을 덜어주는 기능이다.

     

    📖 CI 구축하기

    📒 Github Action 설정

     

    CI는 브랜치에 머지하기 전, 테스트를 통해서 오류가 있는지 확인하는 작업이라 생각하면 될 것 같다.

    자바 기반의 스프링부트를 사용했기 때문에 아래 사진처럼 GitHub Actions를 만들 때 Java with Gradle을 사용하였다.

     

     

    그러면 아래 사진과 같은 Edit 창이 뜨게 된다.

    해당 .yml 파일에 원하는 과정을 작성하여 저장하면 CI 설정을 할 수 있다.

     

     

    아래 코드는 CI.yml 예시 파일이다.

    name: CI
    
    on:
      pull_request:
        branches: [ "main" ]
    
    permissions:
      contents: read
    
    # 작업 시작을 알림
    jobs:
      build:
    
        runs-on: ubuntu-latest
    
        steps:
        - uses: actions/checkout@v3
        
        - name: Set up JDK 11
          uses: actions/setup-java@v3
          with:
            java-version: '11'
            distribution: 'temurin'
    
        - name: Make test application_yml
          env:
            APPLICATION_YML: ${{ secrets.APPLICATION_YML }}
          run: |
            mkdir -p ./src/test/resources && cd "$_"
            touch ./application.yml
            echo $APPLICATION_YML | base64 --decode > application.yml
    
        - name: Run chmod to gradlew executable
          run: chmod +x ./gradlew
            
        - name: Build with Gradle
          uses: gradle/gradle-build-action@각자 번호
          with:
            arguments: build

     

    on : 어디서 특정 이벤트가 발생할지를 정해준다.

    build : CI flow의 이름을 설정한다.

    name : CI flow 안에 있는 작업들의 이름을 설정한다. (원하는 이름으로 바꿔서 사용해도 무방함)

    env : Gihub 설정에 있는 Github Actions Secrets값을 사용하기 위해 사용한다.

    run : 작업을 실행시키기 위해서 사용된다. 파이프 '|'를 사용하여 여러 작업을 동시에 수행할 수 있다.

     

     

    Make test application_yml 단계가 존재하는 이유는 다음과 같다.

    Github 원격 저장소에 작업 내용들을 올린 브랜치에는 application.yml 파일은 .gitignore를 통해 원격 저장소에

    올라가지 않도록 적용해 놓았기 때문에, 테스트 할 때 application.yml 안에 있는 내용들을 가져오지 못하는

    오류를 막기 위해 해당 단계가 존재한다.

    또한 지금 작성하는 CI 파일의 경우, Mock으로 단위테스트를 진행하기 때문에 프로젝트 안에 존재하는

    통합테스트인 프로젝트이름test를 삭제해주어야 정상적으로 작동된다.

     

     

    📒 Github Actions Secrets 설정

    위 .yml 파일에 존재하는 ${{ secrets.APPLICATION_YML }} 와 같이 외부에 노출이 되면 안되는 민감한 정보들을

    GitHub의 secrets로 저장해놓고 workflows에서 환경 변수로 사용할 수 있게끔 해주는 편리하고 유용한 기능이다.

    그러나 Secrets에 한 번 저장한 값은 이후에 따로 확인할 수 없기 때문에

    다른 곳에 값을 백업하여 기억하는 것을 권장한다.

     

    우선 Github 레포지토리에서 Settings에 들어간 후,

    왼쪽 하단에 위치한 Secrets and variables -> Actions에 들어가준다.

     

    그러면 아래 화면처럼 뜨게 되는데, secrets는 Environment secrets와 Repository secrets가 존재한다.

    Environment secrets : 특정 브랜치에만 적용되는 값을 저장하기 위한 공간

    Repository secrets : Repository에 있는 모든 브랜치에서 사용할 수 있는 값을 저장하는 공간

     

    나의 경우 메인 브랜치에 적용하기 위해서 Repository secrets를 통해 작성해보았다.

    New repository secret 버튼을 눌러주고 Name과 Secret을 저장해주면 된다!

    위에서 적용한 ${{ secrets.APPLICATION_YML }}를 사용하기 위해서는 Name에는 APPLICATION_YML을 넣어주면 되지만, 스프링 설정 파일에 쓰고 있는 application.yml 파일을 그대로 넣으면 안된다!

     

          run: |
            mkdir -p ./src/test/resources && cd "$_"
            touch ./application.yml
            echo $APPLICATION_YML | base64 --decode > application.yml

    위 코드 부분을 보면 mkdir로 폴더를 생성하고 touch로 파일을 만든 후, echo 명령어에서

    $APPLICATION_YML 내용을 base64 형식으로 decode한 값을 application.yml으로 리다이렉션을 해준다.

    즉, application.yml에 있는 내용을 base64 인코딩 사이트에 붙여넣고, 인코딩된 값을 Secret에 넣어준다.

    그러나 단위테스트를 진행하기 때문에 application.yml 파일에서 spring.datasource와 spring.jpa에

    관한 부분은 삭제하고 넣어야한다. (중요!!!)

     

    https://www.convertstring.com/ko/EncodeDecode/Base64Encode

     

    Base64로 인코딩 - 온라인 Base64로 인코더

     

    www.convertstring.com

     

     

    정상적으로 저장이 되었다면 아래와 같은 화면이 나와야한다.

     

     

    📒 CI 결과 확인

    CI 설정을 완료했으니 제대로 동작하는지 확인을 해보자

    우선 다른 브랜치에서 main 브랜치로 PR을 보내준다. (EX. main <----- feat/CI)

     

    PR을 보낸 후, 제대로 CI 설정이 되어있다면 Actions 탭에 들어갔을 때 황색 불이 보이면서

    진행중인 작업 과정이 보여야한다.

     

    이후 정상적으로 마무리가 된다면 모든 작업에 체크 표시가 되면서 마지막에는 초록불이 들어오게 된다.

     

    빌드가 완료되면, 이제 PR을 merge하고 branch에 작성했던 코드를 push하면 된다.

    CI 끝!!

     

     

     

    📖 CD 개요

    이번에는 CD에 대해서 다뤄보고자 한다.

    우선 CD는 맨 처음에 말한 것처럼 Continuous Delivery의 약자로, 지속적 배포를 의미한다.

     

    일반적으로 빌드된 프로젝트를 서버에 띄울려면, SCP나 FTPS 프로토콜을 사용하여

    인스턴스로 파일을 전송하여 실행하는 과정을 거치게 된다.

     

    이러한 방식은 프로젝트를 수행하며 개발하는데 있어서 큰 불편함을 주게 되는데,

    서버를 직접 띄우고 내리는 이 과정을 프로젝트가 끝날 때까지 수동으로 해줘야된다면

    개발자 입장에서도 그렇고 전반적인 프로젝트의 개발 속도가 현저히 떨어질 수 있다.

     

    이러한 불필요한 행동을 조금 더 편하게 작업하기 위한 방법이 바로 CD이다.

     

     

    📖 CD 구축하기

    📒 Github Action 설정

     

    우선, Github Action을 사용하고자 하는 깃허브 레포지토리에 들어간다.

    위에 Actions로 들어가서 아래와 같이 New workflow 버튼 클릭

     

    스프링 부트에 적용할 것이기 때문에 Java with Gradle을 클릭

     

     

    이제 .yml을 작성해주자!

    # This workflow uses actions that are not certified by GitHub.
    # They are provided by a third-party and are governed by
    # separate terms of service, privacy policy, and support
    # documentation.
    # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
    # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle
    
    
    name: CD
    
    on:
      push:
        branches: [ "main" ]
    
    permissions:
      contents: read
    
    jobs:
      check_repo:
        runs-on: ubuntu-latest
        outputs:
          cd-skip: ${{ steps.check_repo.outputs.cd-skip-value }}
        steps:
          - uses: actions/checkout@v3
          - name: Check Repo
            run: |
              if [ "$GITHUB_REPOSITORY" != "YOUR_USER/YOUR_REPO_NAME" ]; then
                echo 'cd-skip-value=true' >> $GITHUB_OUTPUT
              else
                echo 'cd-skip-value=false' >> $GITHUB_OUTPUT
              fi
    
      skip-cd:
        needs: check_repo
        if: github.repository != 'TABA-4th/taba-backend-springboot'
        runs-on: ubuntu-latest
        steps:
          - name: Skip CD
            run: |
              echo "CD will be terminated soon..."
              exit 0
    
      build:
        runs-on: ubuntu-latest
        if: github.repository == 'TABA-4th/taba-backend-springboot'
        needs: check_repo
        environment: main
        steps:
        - uses: actions/checkout@v3
        
        - name: Set up JDK 11
          uses: actions/setup-java@v3
          with:
            java-version: '11'
            distribution: 'adopt'
    
        - name: Make test properties
          env:
            PROPERTIES: ${{ secrets.PROPERTIES_TEST }}
          run: |
            mkdir -p ./src/test/resources && cd "$_"
            touch ./application.yml
            echo $PROPERTIES | base64 --decode > application.yml
            
        - name: Build with Gradle
          uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
          with:
            arguments: build
            gradle-version: '7.6'
    
        - name: Docker build
          env:
            USERNAME: ${{ secrets.DOCKER_USERNAME }}
            REPO: ${{ secrets.DOCKER_TABA_SPRINGBOOT_REPO }}
          run: |
            docker build -t $USERNAME/$REPO:${GITHUB_SHA::7} .
    
        - name: Docker push
          env:
            USERNAME: ${{ secrets.DOCKER_USERNAME }}
            PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
            REPO: ${{ secrets.DOCKER_TABA_SPRINGBOOT_REPO }}
          run: |
            docker login -u $USERNAME -p $PASSWORD
            docker push $USERNAME/$REPO:${GITHUB_SHA::7}
    
        - name: SSH Remote Commands
          uses: appleboy/ssh-action@master
          env:
            USERNAME: ${{ secrets.DOCKER_USERNAME }}
            REPO: ${{ secrets.DOCKER_TABA_SPRINGBOOT_REPO }}
            VOLUME: ${{ secrets.TABA_SPRINGBOOT_DOCKER_VOLUME }}
          with:
            host: ${{ secrets.TABA_SPRINGBOOT_SERVER_HOST }}
            username: ubuntu
            key: ${{ secrets.TABA4_PRIVATE_PEM_KEY }}
            port: ${{ secrets.TABA_SPRINGBOOT_SERVER_PORT }}
            envs: GITHUB_SHA,USERNAME,REPO,VOLUME
            script: |
              docker pull $USERNAME/$REPO:${GITHUB_SHA::7}
              docker stop $(docker ps -qa)
              docker run -it -d --rm -p 8081:8080 -v $VOLUME -e SPRING_CONFIG_LOCATION=/app/application.yml $USERNAME/$REPO:${GITHUB_SHA::7}

     

    위와 같이 jobs 안을 보면 크게 3단계로 나눠져있다.

     

    check_repo

    • runs-on: ubuntu-latest: 최신 버전의 Ubuntu 가상 환경에서 실행된다는 의미이다.
    • outputs:
      • 이 작업의 출력으로 cd-skip라는 값을 정의한다.
      • cd-skip은 steps.check_repo.outputs.cd-skip-value에서 가져온 값으로 설정된다.
    • steps:
      • actions/checkout@v3: 현재 GitHub 작업이 실행 중인 리포지토리의 코드를 체크아웃한다.
      • Check Repo: 현재 리포지토리가 특정 사용자/리포지토리 이름(YOUR_USER/YOUR_REPO_NAME)과 일치하지 않으면, cd-skip-value=true를 출력 변수에 설정한다. 이는 연속 배포를 건너뛰어야 함을 나타내는데, 만약 일치한다면 cd-skip-value=false를 설정하여 CD를 계속 진행할 수 있음을 나타내게 된다.

     

    skip-cd

    • needs: 이 작업은 check_repo 작업이 완료된 후에 실행된다.
    • steps: 이 작업에는 CD를 건너뛰는 단계가 포함되어 있다. "CD will be terminated soon..."을 실행하고, exit 0으로 작업을 성공적으로 종료하게 된다.

     

    build

    이 부분은 실질적으로 Java 애플리케이션의 빌드, 도커 이미지 생성 및 배포를 수행하게 된다.

    배포는 SSH를 통한 원격 서버에서의 컨테이너 배포로 진행된다.

    참고로 위에 .yml 파일을 보면 docker build 명령어가 존재하는 걸 볼 수 있는데

    docker build는 Dockerfile이 사전에 구성되어 있어야지 가능한 명령어이다.

    그러므로 Dockerfile도 작성해주자!

     

     

    📖 Dockerfile이란?

    Dockerfile은 Docker Image를 생성하기 위한 스크립트 파일이다.

    원하는 대로 명령어를 작성한 후에 빌드하면 명령문을 순서대로 실행하여 도커 이미지를 만들게 된다.

    FROM openjdk:11
    ARG JAR_FILE=build/libs/*.jar
    COPY ${JAR_FILE} app.jar
    
    EXPOSE 8080/tcp
    ENTRYPOINT ["java","-jar","/app.jar"]

     

     

    📖 SSH Remote Commands

        - name: SSH Remote Commands
          uses: appleboy/ssh-action@master
          env:
            USERNAME: ${{ secrets.DOCKER_USERNAME }}
            REPO: ${{ secrets.DOCKER_TABA_SPRINGBOOT_REPO }}
            VOLUME: ${{ secrets.TABA_SPRINGBOOT_DOCKER_VOLUME }}
          with:
            host: ${{ secrets.TABA_SPRINGBOOT_SERVER_HOST }}
            username: ubuntu
            key: ${{ secrets.TABA4_PRIVATE_PEM_KEY }}
            port: ${{ secrets.TABA_SPRINGBOOT_SERVER_PORT }}
            envs: GITHUB_SHA,USERNAME,REPO,VOLUME
            script: |
              docker pull $USERNAME/$REPO:${GITHUB_SHA::7}
              docker stop $(docker ps -qa)
              docker run -it -d --rm -p 8081:8080 -v $VOLUME -e SPRING_CONFIG_LOCATION=/app/application.yml $USERNAME/$REPO:${GITHUB_SHA::7}

    .yml에서 build 마지막 부분에 위와 같은 내용이 존재하는데,

    이 부분을 요약하면 원격 서버에 SSH로 접속하여 새 Docker 이미지를 배포하고 실행하게 된다.

    참고로 이 과정에서 이전에 실행 중이던 컨테이너는 정지되고, 새 이미지를 사용한 새 컨테이너가 시작된다.

     

     

    📖 끝

    이렇게 CI/CD 작업 과정을 포스팅해봤다.

    당시에 프로젝트를 진행하면서 스프링부트 CI/CD 과정은 급하게 동기에게 배우며 적용하느라 정리할 시간이 많이 부족했고

    그 과정에서 발생했던 오류들을 해결하기 위해 잠을 줄여가며 많이 애를 먹었던 기억이 난다.

    하지만 해결하고 나서 프론트엔드 팀원들과 백엔드 개발 효율성이 올라가고 만족해 하는 것을 보고

    뿌듯했던 그 기분은 아직도 잊을 수 없는 것 같다.

    개인적으로 아직도 부족한 부분이 많다고 생각하여 CI/CD 관련해서 추가적인 공부를 해 나아가야겠다!

     

     

    📖 Docker run 하면서 발생했던 오류들

    📒 오류 1

    Error starting Tomcat context. Exception: org.springframework.beans.factory.BeanCreationException. Message: Error creating bean with name 'webConfig': Injection of autowired dependencies failed; nested exception is java.lang.IllegalArgumentException: Could not resolve placeholder 'app.cors' in value "${app.cors}”

     

    해결

    말그대로 application.yml의 내용이 주입되지 않았다는 내용이다.

    우선 application.yml 파일을 인스턴스 서버로 가져와서 /home/ubuntu 경로에 저장해주었다.

    docker에서 pull 받은 이미지를 실행할 때 아래와 같이 작성해주었다.

    docker run -it -d --rm -p 8081:8080 -v /home/ubuntu:/app -e SPRING_CONFIG_LOCATION=/app/application.yml $USERNAME/$REPO:${GITHUB_SHA::7}

     

    이는 application.yml 파일을 호스트 머신의 /home/ubuntu 경로에서 컨테이너 내부의 /app 경로로 볼륨 마운트를 수행하고, docker run 명령어에서 -e 옵션을 사용하여 환경 변수로 application.yml 파일을 사용하도록 전달하는 명령어이다.

    추가로 나의 경우, 호스트의 포트 8081과 컨테이너의 포트 8080을 매핑하였다.

    즉, 호스트에서 8081 포트를 통해 컨테이너의 8080 포트에 접근하도록 하였다.

    물론 이로 인해 해당 aws 인스턴스의 IPv4 firewall 8081 port를 접근할 수 있도록 허용해주었다.

     

     

     

    📒 오류 2

    JDBC-90401:Connection refused by the server. - Connection refused (Connection refused)

     

    이 오류를 해결하기 위해서 상당히 많은 시간이 소요되었다. 분명 로컬 환경에서 Spring JPA와 Tibero6를 연결할 때 문제없이 잘 되었는데 배포할 때 다음과 같은 오류가 발생하니 너무 당혹스러웠다. 여러 블로그들을 찾아보니 DB 접속 정보를 잘못 작성했거나, DB가 기동 중이지 않을 경우 등 나와는 해당되지 않는 이유들이어서 해결하는 데 더 애를 먹었던 것 같다.

     

    위 사진처럼 tblistener도 정상적으로 띄어져 있었고 boot failed 같은 문제도 발생하지 않았다.
    계속 방황하며 시간을 보내던 와중, 곰곰이 생각하니 답은 위에서 말한 곳에 있었다.
    바로 지금까지 로컬환경에서 Spring JPA와 Tibero6를 연결했기 때문이다.

     

    위와 같이 application.yml 파일에서 기존에 127.0.0.1로 되어있었던 빨간색 박스 영역을

    인스턴스 서버 IP 주소로 변경해주고, /home/tibero/tibero6/client/config 경로에 존재하는

    tbdsn.tbr 파일도 빨간색 박스 영역을 인스턴스 서버 IP 주소로 변경해주었다.

     

     

    참고로 tbdsn.tbr 파일은 

    클라이언트가 Tibero의 데이터베이스에 접속하기 위해 필요한 정보를 가지고 있는 환경설정 파일이다.

     

     

     

    📒 오류 3

    org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'redissonClient' defined in class path resource [com/taba/nimonaemo/global/config/redis/RedisConfig.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.redisson.api.RedissonClient]: Factory method 'redissonClient' threw exception; nested exception is org.redisson.client.RedisConnectionException: Unable to connect to Redis server: localhost/127.0.0.1:6379

     

     

    이 오류는 redis 외부 접속을 허용해주어야 한다. 기존에 로컬에서는 application.yml에 redis host를 localhost로 설정했기도 하고, Redis.conf 설정을 외부 호스트에서 접속 가능하도록 변경해주어야한다.

     

    1. application.yml에서 host 주소 변경

        redis가 설치된 인스턴스 ip 주소로 변경해준다.

     

    2. Redis.conf 설정 변경

    $ sudo vi /etc/redis/redis.conf
    
    # 기존 주소
    bind 127.0.0.1 ::1
    
    # 변경 주소
    bind 0.0.0.0 ::1

     

    3. ps -ef | grep redis로 현 상태 확인 후, 127.0.0.1:6379 라면, 재부팅

    kill -9 {포트번호}

    sudo service redis-server start

     

    위 사진에서는 0.0.0.0:6379 이므로 재부팅을 하지 않아도 되나,

    혹여나 127.0.0.1:6379 라면 위 사진의 경우 kill -9 142598을 입력해준다.

Designed by Tistory.