Github Actions과 Docker을 활용한 CI/CD 구축

짱J·2023년 1월 22일
10

DevOps

목록 보기
7/8
post-thumbnail

저번 글 Docker를 활용한 Spring Boot 프로젝트 EC2 배포에서는 Spring boot 프로젝트의 빌드 파일을 이미지로 만들어 Docker Hub에 올리고, 이를 pull해서 서버를 배포하는 방법까지 알아보았다.

Github에 코드 변경이 발생되었을 때, 위 과정을 자동으로 수행해주도록 Github Actions과 연동해보자.

Github Actions을 선택한 이유

Travis CI, Jenkins 등 다양한 CI/CD 도구가 있지만 대부분 유료이거나, 부분적 유료이다.
최대한 무료인 도구를 사용하고 싶었고, Github Actions는 public repository인 경우 무료이기 때문에 선택하였다.

그리고 Github에 공식적으로 내장된 기능이므로, Github와 함께 관리하기 편할 것이라고 생각하여 선택하였다.

Github Actions 스크립트 파일 작성


위 사진처럼 .github/workflows 폴더 안에 yml 파일을 만들어준다.

우리가 배포를 자동화하는 flow는 아래와 같다.

  1. 코드 변경이 감지되면 배포 스크립트를 실행
  2. JDK 설정
  3. gradle caching
  4. application.yml 파일 생성
  5. gradle build
  6. docker build & push
  7. deploy to EC2

파일의 전체 내용은 아래와 같다.

# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker

# event trigger
# main이나 develop 브랜치에 push가 되었을 때 실행
on:
  push:
    branches: [ "main", "develop" ]

permissions:
  contents: read

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

      # JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
      - uses: actions/checkout@v3
      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'

      # gradle caching - 빌드 시간 향상
      - 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-

      # 환경별 yml 파일 생성(1) - application.yml
      - name: make application.yml
        if: |
          contains(github.ref, 'main') ||
          contains(github.ref, 'develop')
        run: |
          mkdir ./src/main/resources # resources 폴더 생성
          cd ./src/main/resources # resources 폴더로 이동
          touch ./application.yml # application.yml 생성
          echo "${{ secrets.YML }}" > ./application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash

      # 환경별 yml 파일 생성(2) - dev
      - name: make application-dev.yml
        if: contains(github.ref, 'develop')
        run: |
          cd ./src/main/resources
          touch ./application-dev.yml
          echo "${{ secrets.YML_DEV }}" > ./application-dev.yml
        shell: bash

      # 환경별 yml 파일 생성(3) - prod
      - name: make application-prod.yml
        if: contains(github.ref, 'main')
        run: |
          cd ./src/main/resources
          touch ./application-prod.yml
          echo "${{ secrets.YML_PROD }}" > ./application-prod.yml
        shell: bash

      # gradle build
      - name: Build with Gradle
        run: ./gradlew build -x test

      # docker build & push to production
      - name: Docker build & push to prod
        if: contains(github.ref, 'main')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/docker-test-prod .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-prod

      # docker build & push to develop
      - name: Docker build & push to dev
        if: contains(github.ref, 'develop')
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/docker-test-dev .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-dev

      ## deploy to production
      - name: Deploy to prod
        uses: appleboy/ssh-action@master
        id: deploy-prod
        if: contains(github.ref, 'main')
        with:
          host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
          username: ubuntu
          key: ${{ secrets.PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            sudo docker ps
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-prod
            sudo docker run -d -p 8082:8082 ${{ secrets.DOCKER_USERNAME }}/docker-test-prod
            sudo docker image prune -f

      ## deploy to develop
      - name: Deploy to dev
        uses: appleboy/ssh-action@master
        id: deploy-dev
        if: contains(github.ref, 'develop')
        with:
          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
          username: ${{ secrets.USERNAME }} # ubuntu
          password: ${{ secrets.PASSWORD }}
          port: 22
          key: ${{ secrets.PRIVATE_KEY }}
          script: |
            sudo docker ps
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-dev
            sudo docker run -d -p 8081:8081 ${{ secrets.DOCKER_USERNAME }}/docker-test-dev
            sudo docker image prune -f

이제 파일의 각 내용에 대해 더 자세히 살펴보자.

1. 코드 변경이 감지되면 배포 스크립트를 실행

# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker

# event trigger
# main이나 develop 브랜치에 push가 되었을 때 실행
on:
  push:
    branches: [ "main", "develop" ]

permissions:
  contents: read

main 브랜치나 dev 브랜치에 push가 발생했을 때 workflow를 실행한다는 의미이다.


나는 프로젝트를 진행하면서 브랜치 전략으로 Git Flow를 많이 활용한다.

  • main 브랜치 - 배포용 서버 코드
  • develop 브랜치 - 개발용 서버 코드
  • feature 브랜치 - 기능 개발

로 크게 나누어 사용한다.

나는 서브 도메인을 활용하여 배포용 서버와 개발용 서버를 둘 다 운영할 것이기 때문에 main 브랜치와 develop 브랜치 둘 다 push 발생을 감지하게 했다.

2. JDK 설정

# JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
- uses: actions/checkout@v3
- name: Set up JDK 11
  uses: actions/setup-java@v3
  with:
    java-version: '11'
    distribution: 'temurin'

Github Actions에서 사용될 JDK를 설정한다.
프로젝트나 AWS의 java 버전과 달라도 무방하다.

3. gradle caching

# gradle caching - 빌드 시간 향상
- 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-

Gradle을 캐싱해주는 코드이다.
해당 코드는 없어도 무방하지만, 적용했을 시 빌드 시간을 단축할 수 있다.

4. application.yml 파일 생성

# 환경별 yml 파일 생성(1) - application.yml
- name: make application.yml
  if: |
    contains(github.ref, 'main') ||
    contains(github.ref, 'develop')
  run: |
    mkdir ./src/main/resources # resources 폴더 생성
    cd ./src/main/resources # resources 폴더로 이동
    touch ./application.yml # application.yml 생성
    echo "${{ secrets.YML }}" > ./application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
  shell: bash
  
# 환경별 yml 파일 생성(2) - dev
- name: make application-dev.yml
  if: contains(github.ref, 'develop')
  run: |
    cd ./src/main/resources
    touch ./application-dev.yml
    echo "${{ secrets.YML_DEV }}" > ./application-dev.yml
  shell: bash

# 환경별 yml 파일 생성(3) - prod
- name: make application-prod.yml
  if: contains(github.ref, 'main')
  run: |
    cd ./src/main/resources
    touch ./application-prod.yml
    echo "${{ secrets.YML_PROD }}" > ./application-prod.yml
  shell: bash

나는 환경별 파일을 아래와 같이 분리하여 사용하고 있다.

하지만 개인 정보가 다 들어 있어 네 파일 모두 Github에 올라가 있지 않다.
하지만 위 파일들이 없으면 빌드와 배포 모두 진행이 불가하다 !
나는 Github의 Secret Key에 중요한 정보들을 저장해주었다.

Settings > Secrets and variables > Actions로 들어가 우측 상단 버튼으로 Secret Key 추가가 가능하다.

application-local.yml을 제외한 파일들을 YML, YML_DEV, YML_PROD로 저장해주었다.

Secret Key의 내용은 저장한 뒤 볼 수 없고, 편집과 삭제만 가능하다!

5. gradle build

# gradle build
- name: Build with Gradle
  run: ./gradlew build -x test

Gradle 빌드를 진행한다.

6. docker build & push

# docker build & push to production
- name: Docker build & push to prod
  if: contains(github.ref, 'main')
  run: |
    docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
    docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/docker-test-prod .
    docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-prod

# docker build & push to develop
- name: Docker build & push to dev
  if: contains(github.ref, 'develop')
  run: |
    docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
    docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/docker-test-dev .
    docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-dev

배포용 서버와 개발용 서버는 설정 파일의 내용이 다르기 때문에 빌드 파일의 내용이 다르다. 그렇기 때문에 생성된 도커 이미지도 다르다.

그러므로, 두 경우를 나누어서 build와 push를 해주어야 한다.
그렇기 위해 두 개의 docker hub repository를 사용하였으며, 위 코드에서 docker-test-proddocker-test-dev가 repository 이름이다.

7. deploy to EC2

## deploy to production
- name: Deploy to prod
  uses: appleboy/ssh-action@master
  id: deploy-prod
  if: contains(github.ref, 'main')
  with:
    host: ${{ secrets.HOST_PROD }} # EC2 퍼블릭 IPv4 DNS
    username: ubuntu
    key: ${{ secrets.PRIVATE_KEY }}
    envs: GITHUB_SHA
    script: |
      sudo docker ps
      sudo docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-prod
      sudo docker run -d -p 8082:8082 ${{ secrets.DOCKER_USERNAME }}/docker-test-prod
      sudo docker image prune -f

## deploy to develop
- name: Deploy to dev
  uses: appleboy/ssh-action@master
  id: deploy-dev
  if: contains(github.ref, 'develop')
  with:
    host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
    username: ${{ secrets.USERNAME }} # ubuntu
    password: ${{ secrets.PASSWORD }}
    port: 22
    key: ${{ secrets.PRIVATE_KEY }}
    script: |
      sudo docker ps
      sudo docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-dev
      sudo docker run -d -p 8081:8081 ${{ secrets.DOCKER_USERNAME }}/docker-test-dev
      sudo docker image prune -f

이제 다 왔다 !!! 마지막으로 EC2에서 docker pull만 하면 된다!
그렇기 위해서는 EC2로 원격 접속을 하는 과정이 필요하다.

EC2 원격 접속에는 appleboy를 사용한다.
이 때 사용하는 Secret Key 내용은 아래와 같다.

  • host: EC2의 IP 주소 혹은 DNS
  • username: 인스턴스 생성 시 선택한 OS의 기본 사용자 이름
  • key: EC2 생성 시 받은 pem 파일의 내용

처음에는 .pem 파일인데 Secret Key는 텍스트라서 어떻게 해야 할지 갈피를 못 잡았다.

.pem 파일의 내용은 nano, vim 등의 편집기를 통해 확인할 수 있다.
편집기로 파일의 내용을 확인하고 복붙을 하면 된다 !

트러블 슈팅 모음

아래는 내가 스크립트 파일을 작성하며 겪은 오류들이다.
하지만 위에 작성된 스크립트 파일은 아래 트러블 슈팅들을 모두 수정한 스크립트 파일이므로, 잘 따라했다면 아래 작성한 오류들이 발생하지 않을 것이다!

💣 트러블 슈팅 1

The workflow is not valid. .github/workflows/github-actions.yml (Line: 39, Col: 13): Unexpected symbol: '#'. Located at position 33 within expression: contains(github.ref, 'main') || # branch가 main일 때
contains(github.ref, 'develop') # branch가 develop일 때

39번째 줄의 내용은 아래와 같다.
|| 옆에 있는 주석이 문제가 되어 이를 지워주었다.

if: |
	contains(github.ref, 'main') || # branch가 main일 때
	contains(github.ref, 'develop') # branch가 develop일 때

💣 트러블 슈팅 2

Error: Unable to resolve action `action/setup-java@v3`, repository not found

actions/setup-java@v3인데 action/setup-java@v3이라고 오타를 냈다.
오타에 주의하자!

💣 트러블 슈팅 3

/home/runner/work/_temp/5a3a1b40-2d33-4583-b05b-eb04cb136354.sh: line 1: cd: ./src/main/resources: No such file or directory

나는 application.yml, application-dev.yml, application-prod.yml 모두 깃허브에 올리지 않았기 때문에 resources 폴더가 없다.

cd ./src/main/resource 위에 mkdir ./src/main/resources를 추가해주었다.

# 환경별 yml 파일 생성(1) - application.yml
- name: make application.yml
  if: |
    contains(github.ref, 'main') ||
    contains(github.ref, 'develop')
  run: |
    mkdir ./src/main/resources # resources 폴더 생성
    cd ./src/main/resources # resources 폴더로 이동
    touch ./application.yml # application.yml 생성
    echo "${{ secrets.YML }}" > ./application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
  shell: bash

💣 트러블 슈팅 4

Task 'ktlintCheck' not found in root project 'demo'.

내가 참고한 블로그의 코드를 그대로 복붙해와서 발생한 문제이다. 해당 블로그의 프로젝트는 Kotlin으로 작성되어 Kotlin Lint 관련 옵션을 추가로 사용하였다.

- name: Build with Gradle
  run: ./gradlew build -x test -x ktlintCheck -x ktlintTestSourceSetCheck -x ktlintMainSourceSetCheck -x ktlintKotlinScriptCheck

에서 Kotlin 관련 옵션을 지우고 아래와 같이 수정하면 된다.

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

💣 트러블 슈팅 5

Step 4/5 : COPY ${JAR_FILE} app.jar
41
When using COPY with more than one source file, the destination must be a directory and end with a /
42
Error: Process completed with exit code 1.

${JAR_FILE}에 둘 이상의 파일이 선언된 문제이다.

ARG JAR_FILE=build/libs/*.jar

JAR_FILE은 위와 같이 선언되었다.

build/libs 폴더를 확인해주니 원본 jar과 함께 plain.jar 파일이 있었다.

https://dev-j.tistory.com/22 를 참고하여 plain.jar이 생성되지 않도록 build.gradle을 수정해주었다.

jar {
	enabled = false
}

💣 트러블 슈팅 6

denied: requested access to the resource is denied
docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}/test-dev .

도커 로그인 관련 문제
하지만 아이디와 패스워드 모두 일치해서 로그인 명령어 자체에는 문제가 없다고 판단
저번에 docker build 했을 때는 repo 앞에 유저 이름도 적음
이번에도 그래서 username 추가해줌

💣 트러블 슈팅 7

An image does not exist locally with the tag

나는 github 프로젝트처럼 repository에 디렉토리가 있는 구조인줄 알았다.
하지만 prod랑 dev repository 따로 파서 해결하였다.
한 repo는 한 image만 가능하다. 태그로 여러 image를 가질 수 있다 하지만 이 방법으로는 해결 못해서 레포를 나눴다.

💣 트러블 슈팅 8

Error: can't connect without a private SSH key or password
-----BEGIN RSA PRIVATE KEY----- 
(PRIVATE KEY가 적혀 있음)
-----END RSA PRIVATE KEY----

.pem 파일의 내용은 위와 같다. 이 때 private key 문자열 부분만 복사하면 안되고, -----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY---- 부분까지 전부 넣어주어야 한다!

💣 트러블 슈팅 9

배포는 되었으나, Action이 성공했다는 체크 표시✅가 안 뜨고 계속 로딩 중 표시가 떴다

docker run 명령어에 -d 옵션을 추가하여 해결할 수 있었다.

  • -d 옵션은 컨테이너를 백그라운드에서 실행한다는 뜻이다.

마무리

https://github.com/leeeeeyeon/github-actions-test

위 과정을 연습한 나의 Github Repository이다.
구글링 자료들을 따라하면서 코드의 단편만 보았을 때보다 전체 내용을 볼 수 있었을 때 더 이해하기 쉬웠다.

글만으로는 설명이 부족하다고 느끼는 사람들은 전체 코드를 함께 보면서 더 수월하게 이해할 수 있길 바라는 마음에 함께 남겨본다!

배포 지옥에서 빠져나오는 그 날 까지 모두 파이팅 : )

✨ Reference

https://zzang9ha.tistory.com/404
https://bug41.tistory.com/entry/Github-Github-Actions-사용하는법-SSH-연결

profile
[~2023.04] 블로그 이전했습니다 ㅎㅎ https://leeeeeyeon-dev.tistory.com/

1개의 댓글

comment-user-thumbnail
2023년 12월 10일

짱제이... 감사합니다

답글 달기