Github Action, Docker compose를 활용한 배포 자동화 CI/CD + (Spring boot, MySQL)

rivkode·2024년 1월 14일
1

이전글에 이어 이번 시간에는 Github Actions를 활용하여 CI/CD 파이프라인을 구축하여 EC2에 배포 자동화를 진행해보도록하겠습니다

진행할 순서는 아래와 같습니다

  • CI CD를 해야하는 이유가 뭘까 ?
  • 배포 자동화를 위한 툴
  • Docker compose를 활용한 ci, cd 파이프라인 작성 (CI 와 CD를 나누어서 진행)
  • CI CD 가 동작하는 과정

CI CD를 해야하는 이유

쉽게 결론 부터 말씀드리면

배포 기간이 줄어들며, 파이프라인 구축으로 자동화가 가능하고, 피드백을 통해 신속한 버그 수정이 가능합니다

소프트웨어 개발은 개발만 하고 끝나는것이 아닙니다. 개발은 혼자서 개발하는 일이 적기때문에 동료 개발자들이 수정한 내용을 통합해야하며 개발한 내용들을 사용자에게 전달하기 위해 배포라는 과정을 반드시 거쳐야 합니다. 하지만 문제는 이 배포를 할때 여러가지로 반복되는 번거로운 작업이 있기도 하며 에러가 발생할 수 있습니다. 이 배포라는 단계를 최종적으로 거친다는 것은 그만큼 시간과 비용이 들어간다는 내용입니다. 핵심은 이 배포단계자동화를 통해 충분히 개선 가능하다는 것입니다.

간단한 설명을 해보자면 배포라는 것은 컴퓨터에다가 서버를 실행시키는 것입니다. 로컬과 차이점은 말그대로 어디서나 접속이 가능한 컴퓨터에 서버를 실행시키는 것이죠.
Springboot 프로젝트 기준 jar 파일을 실행만 하면 배포가 되는 것입니다.

Docker를 활용하여 (springboot 프로젝트 기준)배포를 하기 위해서는 아래 단계가 필요합니다.

  1. jar 파일 생성
  2. jar파일을 가지고 Image 생성 (Dockerfile)
  3. 생성한 Image를 가지고 Container 생성 (Docker compose)

위 단계를 거치면 배포가 완료됩니다.

비교적 간략하게 설명하였지만 결국 저 내용들을 수행하는 것입니다.

위 과정을 배포 이후 수정사항이 생길때 마다 개발자가 일일이 타이핑을 쳐가며 수정사항을 반영해야한다면 여간 번거로운 일이 아닙니다. 또한 급하다고 프로덕션에 즉각 반영되는 브랜치에 직접 push를 하기도 하고, 귀찮아서 컨벤션이나 타입도 안맞추고 merge를 하게된다면 더 심각한 일이 일어날 수 있습니다.

이를 개선하기 위한 방법들이 여러가지가 있습니다.

배포 자동화를 위한 툴

  • Jenkins
  • Travis CI
  • Circle CI
  • Google Cloud Build
  • AWS CodeBuild
  • Github Actions

위와 같이 여러가지 툴이 존재합니다.

저는 이중 Jenkins와 Github Actions를 고민하게 되었는데요. 이중 Github Actions로 진행하게 되었습니다.

Github Actions
장점 - 쉽게 CI/CD 구축이 가능함
단점 - 비교적 최근에 나온거라 레퍼런스가 적은편

Jenkins
장점 - 레퍼런스가 많은편
단점 - CI/CD 구축이 복잡한 편

Jenkins와 Github Actions중 최종적으로 Github Actions를 선택하였습니다.

이유는 빠르게 CI/CD를 구축해야하는 요구사항이 있었기 때문이며 쉽고 간단하게 구축할 수 있었기 때문입니다.

Docker compose를 활용한 파이프라인 작성 (CI 와 CD를 나누어서 진행)

Github Actions이 동작하는 원리는 간단하게 특정 이벤트(push, pr 등등)가 발생하면 workflow에 작성된 내용들인 특정 job이 실행되는 것으로 이해하셔도 좋습니다.

먼저 Dockerfile을 프로젝트 내에 작성하셔야 합니다.

JDK 11 예시입니다.

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

도커파일은 이미지를 빌드하기위한 파일이고 생성한 해당 Image를 기반으로 Container가 실행됩니다. openjdk:11로 JAR_FILE변수에 파일경로를 등록하고 해당경로의 파일을 컨테이너의 app.jar로 옮기고 컨테이너에서 java -jar /app.jar을 실행한다는 뜻입니다. 여기서 파일경로를 보시면 알겠지만 저기에는 빌드파일이 들어가겠네요 즉 프로젝트 빌드파일을 실행하겠다는 명령어입니다.

Nginx 설정

Nginx도 같이 컨테이너화해서 spring boot 함께 사용할 예정입니다. docker-compose를 통해 설정을 진행할 예정이라 Nginx 설정파일이 필요합니다. 프로젝트에서 /nginx/conf.d/nginx.conf 경로에 맞게 nginx.conf파일을 작성해줍니다.

preonb 의 경우 container 명입니다.

server {
    listen 80;
    server_name *.compute.amazonaws.com
    access_log off;

    location / {
        proxy_pass http://preonb:8080;
        proxy_set_header Host $host:$server_port;
        proxy_set_header X-Forwarded-Host $server_name;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

위 설정을 통해 Nginx 이미지 생성을 하기 위해 nginx용 Dockerfile을 docker-file 이름으로 파일을 아래 내용으로 만들어줍니다.

FROM nginx
COPY ./nginx/conf.d/nginx.conf /etc/nginx/conf.d

이후 해당 이미지를 docker hub에 아래 명령어로 push 합니다

> docker build -f dockerfile-nginx -t jonghuni/preonb-nginx .
> docker push jonghuni/preonb-nginx

아래는 docker-compose.yml 파일을 예시입니다.

version: '3'

services:
  database:
    container_name: preonb_db
    image: mysql:8.0.22
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: test_db
      MYSQL_USER: root
      MYSQL_PASSWORD: root
      MYSQL_ROOT_HOST: '%'
      MYSQL_ROOT_PASSWORD: root
      TZ: 'Asia/Seoul'
    ports:
      - "13306:3306"
    volumes:
      - ./mysql/conf.d:/etc/mysql/conf.d
    command:
      - "mysqld"
      - "--character-set-server=utf8mb4"
      - "--collation-server=utf8mb4_unicode_ci"
    networks:
      - preon_net

  application:
    container_name: preonb
    image: preonb:latest
    build:
      context: ./
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    restart: on-failure
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://preonb_db:3306/test_db?useSSL=false&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: "root"
      SPRING_DATASOURCE_PASSWORD: "root"
    depends_on:
      - database
    networks:
      - preon_net
  nginx:
    container_name: nginx
    image: jonghuni/preonb-nginx
    ports:
      - 80:80
    depends_on:
      - application

networks:
  preon_net: {}

docker-compose에 대한 내용은 이전글에서 자세하게 확인할 수 있습니다!

위 파일을 EC2에 접속하여 그대로 넣어줍니다. 파일 위치는 /home/compose/docker-compose.yml 입니다.

이후에 CD 과정에서 해당 docker-compose.yml 파일을 참고하여 컨테이너 생성을 진행하게 됩니다.

CI 작성

main 브랜치에 Pull Request가 요청되었을때 CI 빌드 통합테스트를 진행하도록 합니다.

이때 Github Actions를 작성해야 하는데요 Github의 Actions 탭에서 파일생성이 쉽게 가능합니다.

위 set up a workflow yourself를 누르게 되면 아래 창으로 이동하게 됩니다

여기에 원하는 내용을 작성하시면 됩니다.

저는 파일이름을 testci.yml로 작성하여 진행하였습니다.

# Workflow 이름은 구별이 가능할 정도로 자유롭게 적어주어도 된다. 
# 필수 옵션은 아니다.
name: CI with Gradle

# main 브랜치에 PR 이벤트가 발생하면 Workflow가 실행된다.
# 브랜치 구분이 없으면 on: [pull_request]로 해주어도 된다.
on:
  pull_request:
    branches: [ "main" ]

# 테스트 결과 작성을 위해 쓰기권한 추가
permissions: write-all
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 application-database.yaml
      run: |
        # create application-database.yaml
        cd ./src/main/resources

        # application-database.yaml 파일 생성
        touch ./application-database.yaml

        # GitHub-Actions 에서 설정한 값을 application-database.yaml 파일에 쓰기
        echo "${{ secrets.DATABASE }}" >> ./application-database.yaml
      shell: bash

    - name: Gradle Caching
      uses: actions/cache@v3
      with:
        path: |
          ~/.gradle/caches
          ~/.gradle/wrapper
        key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
        restore-keys: |
          ${{ runner.os }}-gradle-
    
    - name: Grant Execute Permission For Gradlew
      run: chmod +x gradlew

    - name: Build With Gradle
      run: ./gradlew build -x test
      
    # build Test
    - name: 테스트 코드 실행
      run: ./gradlew --info test

    - name: Publish Unit Test Results
      uses: EnricoMi/publish-unit-test-result-action@v1
      if: ${{ always() }}
      with:
        files: build/test-results/**/*.xml
        
    - name: Publish Test Report
      uses: mikepenz/action-junit-report@v3
      if: success() || failure() # always run even if the previous step fails
      with:
        report_paths: '**/build/test-results/test/TEST-*.xml'

위 과정을 설명해보겠습니다

기본 설정

main 브랜치에 push가 될때 trigger가 발생되도록 설정하였습니다.

on:
  pull_request:
    branches: [ "main" ]

checkout&setup

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: checkout
      uses: actions/checkout@v3

    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'

jobs는 어떤 내용들을 실행할지에 대한 자세한 내용입니다.
runs-on은 github actions의 CI 서버가 어떤 환경으로 진행할지를 설정해주는 부분입니다. ubuntu-latest로 진행하였습니다. 이후 step 밑으로 순서대로 진행하도록 합니다.

작성 형식은 name으로 이름을 적어주고 uses 혹은 run 으로 어떤 작업들을 수행할지 작성합니다. 여기서 uses의 경우 이미 다른 사람이 만들어 놓은 내용들을 가져다 사용하는 형태이며 run의 경우 직접 명령어를 통해 실행되는 내용입니다.

위의 checkout의 경우 github action과 연결된 레포지토리 코드를 runner로 옮기는 것입니다. 즉, 새로 푸쉬된 코드를 업데이트 하는 과정입니다.

set up JDK의 경우 해당 프로젝트는 JDK11버전으로 작성되었으므로 이에 맞게 setup을 하는 과정입니다.

uses를사용하게 되면 위와같이 정말 편하게 세팅할 수 있는 장점이 있습니다.

환경변수 작성

여기에는 민감한 정보(application.yml 과 같은)를 github에 올리지 못할 경우 runner에서 만들어 주기 위한 작업입니다.
DB정보가 담긴 application-database.yaml파일을 github에 올리지 않고 레포지토리 settings의 secret에 DATABASE라는 이름으로 올려놨습니다. 따라서 거기의 내용을 옮겨서 파일을 runner에 만들어줄 수 있습니다.

참고로 application.yaml은 아래와 같이 올라가있습니다. 프로젝트를 돌리면 application-database.yaml을 찾아서 설정정보를 참고하게됩니다.

spring:
  profiles:
    include: database

application-database.yaml은 아래와 같이 민감한 데이터베이스 접속정보 및 설정을 담고있습니다. 전 JPA, RDB, MySQL을 사용했고 데이터베이스를 연결하는 분들은 프로젝트에 맞는 정보를 넣어주시면 됩니다.

spring:
  jpa:
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: update
  datasource:
    url: **
    username: **
    password: **
    hikari:
      maximum-pool-size: 10

gradle 캐싱

이제 빌드를 바로 진행하여도 좋지만 그 전에 gradle을 캐싱해줍니다. gradle을 캐싱하는 이유는 CI/CD 프로세스를 최적화하고 빌드 시간을 단축하기 위해서입니다.

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

빌드하기

이제 이 코드를 기반으로 빌드파일을 만들어줍니다. 그럼 build폴더가생기고 그 안에 .jar가 생성됩니다. 어디에? 바로 Runner에 생깁니다.

- name: Grant Execute Permission For Gradlew
  run: chmod +x gradlew
- name: Build With Gradle
  run: ./gradlew build -x test

테스트 진행 및 report 작성

빌드하기를 통해 jar파일이 정상적으로 빌드된 이후 동료개발자의 코드가 테스트를 정상적으로 통과하는지 확인하기 위해 테스트진행 및 결과 report를 작성합니다.

- name: 테스트 코드 실행
  run: ./gradlew --info test

- name: Publish Unit Test Results
  uses: EnricoMi/publish-unit-test-result-action@v1
  if: ${{ always() }}
  with:
    files: build/test-results/**/*.xml
        
- name: Publish Test Report
  uses: mikepenz/action-junit-report@v3
  if: success() || failure() # always run even if the previous step fails
  with:
    report_paths: '**/build/test-results/test/TEST-*.xml'

위 내용까지 CI 작업을 진행하였습니다.
main 브랜치에 PR요청이 발생하면 위 작업이 순차적으로 진행되게 됩니다.

CD 작성

정상적으로 빌드 및 테스트가 통과되었다면 수정된 내용들을 사용자에게 반영하기 위해 배포하는 과정이 필요합니다. 저희는 docker-compose 를 활용하여 Image를 생성하고 container를 만들어보겠습니다.

아래에는 배포를 위한 CD.yml 파일 예시입니다.

name: CD

#해당 브랜치에 push(merge) 했을 때
on:
  push:
    branches:
      - main

#테스트 결과 작성을 위해 쓰기권한 추가
permissions: write-all
#jdk 세팅
#gradle 캐싱
#test를 제외한 프로젝트 빌드
#도커 빌드 & 이미지 push
#docker-compose 파일을 ec2 서버에 배포
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: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-
      
      #배포를 위한 prod properties 설정
      - name: Make application-prod.properties
        run: |
          cd ./src/main/resources
          touch ./application-prod.properties
          echo "${{ secrets.PROPERTIES }}" > ./application-prod.properties
        shell: bash
        
      - name: Grant Execute Permission For Gradlew
        run: chmod +x gradlew

      - name: Build With Gradle
        run: ./gradlew build -x test

      - name: Docker build & Push
        run: |
          docker login -u ${{ secrets.DOCKER_ID }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -t ${{ secrets.DOCKER_REPO }}/preonb .
          docker push ${{ secrets.DOCKER_REPO }}/preonb

      - name: Deploy Images with Docker compose
        uses: appleboy/ssh-action@master
        env:
          APP: "preonb"
          COMPOSE: "/home/ubuntu/compose/docker-compose.yml"
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          envs: APP, COMPOSE
          script_stop: true
          script: |
            sudo docker-compose -f $COMPOSE down
            sudo docker pull ${{ secrets.DOCKER_REPO }}/preonb
            sudo docker-compose -f $COMPOSE up -d

(CI에서 진행하였던 동일한 내용은 생략하도록 하겠습니다)

빌드를 통해 .jar 파일까지 생성이 완료되면 이후 .jar 파일을 가지고 Image를 생성한 뒤 Container를 만들어주면 됩니다.

docker build & docker hub에 push

지금까지는 uses를 통해 다른사람이 만들어놓은 내용을 잘 활용하였었습니다. 이번에는 run을 사용하였기 때문에 입력한 명령어가 동작됨을 알 수 있습니다.

docker login 과 build, push 명령어를 통해 Image 생성, push를 진행할 수 있습니다.

- name: Docker build & Push
        run: |
          docker login -u ${{ secrets.DOCKER_ID }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -t ${{ secrets.DOCKER_REPO }}/preonb .
          docker push ${{ secrets.DOCKER_REPO }}/preonb

Deploy Images with Docker compose

마지막으로 Image를 hub로 부터 pull 받은뒤 docker compose를 통해 Container 화하는 과정입니다.

uses로 appleboy/ssh-action@master 를 사용하였으며 env에는 이에 대한 환경을 설정하는 부분입니다. APP에는 컨테이너 이름을 명시해주며 COMPOSE에는 이전에 docker-compose.yml 파일을 EC2에 생성하였던 파일경로를 입력하여줍니다.

이후 EC2에 접속하여 docker-compose down -> Image pull ->docker-compose up 순서대로 진행하여 배포를 마무리합니다.

- name: Deploy Images with Docker compose
        uses: appleboy/ssh-action@master
        env:
          APP: "preonb"
          COMPOSE: "/home/ubuntu/compose/docker-compose.yml"
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_PRIVATE_KEY }}
          envs: APP, COMPOSE
          script_stop: true
          script: |
            sudo docker-compose -f $COMPOSE down
            sudo docker pull ${{ secrets.DOCKER_REPO }}/preonb
            sudo docker-compose -f $COMPOSE up -d

이렇게 진행한 결과 아래와 같이 정상적으로 CI / CD 과정이 마무리 되었지만 이상하게도 배포가 이루어지지 않았습니다.

이유는 제가 MySQL을 RDS를 사용하지 않고 Container화 하여 그랬기 때문입니다. 아래 사진의 CPU 사용률을 보시면 갑자기 90%이상 튀게 되는 현상을 3차례 보실 수 있습니다. 이는 제가 3번의 배포를 진행하였으며 이때 AWS 프리티어의 성능으로는 부족하여 이렇게 EC2가 중단되는 현상을 보실 수 있습니다.

이를 파악한 뒤 수정하여 RDS를 사용하도록 변경하여 정상적으로 CI CD 를 구축할 수 있었습니다.

RDS로 변경하기 위해 몇가지 수정사항이 있었습니다

  • docker-compose.yml 에서 database 내용 삭제
  • docker-compose.yml 에서 spring 컨테이너 생성시 .env 파일을 참조하여 RDS 연결
  • cd.yml (CD script 파일) 에서 application-database.yml 파일 생성과정 추가
  • github secrets 에 DATABASE 내용 추가
  • spring 프로젝트내의 application.yml 에서 profiles 추가 (이렇게 하게되면 application-database.yml 파일 참조)

.env 파일 내용

SPRING_DATASOURCE_USERNAME=abc (예시)
SPRING_DATASOURCE_PASSWORD=def

make application-database.yml 과정 추가

## create application-database.yaml
- name: make application-database.yml
  run: |
    cd ./src/main/resources
    touch ./application-database.yml
    echo "${{ secrets.DATABASE }}" >> ./application-database.yml
  shell: bash

github secrets DATABASE 내용

spring:
  jpa:
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: update
  datasource:
    url: RDS url
    username: abc (예시)
    password: def
    hikari:
      maximum-pool-size: 10

application.yml

spring:
  profiles:
    include: database

docker-compose.yml

application:
  container_name: preonb
  image: jonghuni/preonb:latest
  ports:
    - "8080:8080"
  restart: on-failure
  env_file:
    - .env
  networks:
    - preon_net

위와 같이 변경 후 RDS와 연동할 수 있었습니다

참고자료

2개의 댓글

comment-user-thumbnail
어제

docker build&push 를 넣으신 이유가 nginx 때문인가요?

1개의 답글