[Spring] GitHub Actions을 통한 CI/CD 자동화

jinsung·2일 전

BootCamp

목록 보기
10/10
post-thumbnail

EC2 + Docker Compose 기반 GitHub Actions CI/CD 자동배포 플로우

이번 프로젝트에서의 배포 자동화는 develop 브랜치에 PR 시 GitHub Actions가 Docker 이미지를 새로 빌드하고, Docker Hub에 push한 뒤, EC2 서버에서 Docker Compose를 통해 최신 이미지로 재배포하는 구조로 구성해 보았다.


GitHub Actions를 선택한 이유

1. GitHub PR 흐름과 바로 연결됨

우리는 프로젝트에서 이미 GitHub를 통해 코드를 관리하고 있었음.

PR 생성 → CI 테스트
develop merge → Docker 빌드/푸시 → EC2 배포

이 플로우를 코드 관리하는 환경인 Github에서 그대로 사용할 수 있기에 편할 거 같았다.

2. 별도 서버 운영이 필요 없음

Jenkins 나 다른 자동화 CI/CD 환경을 비교해 생각해봤을 때 우리의 프로젝트 규모에 맞으려면 서버를 띄우지 않는게 맞다고 판단했다.
GitHub Actions는 GitHub-hosted runner를 쓰면 GitHub가 실행 환경을 제공해줘서, 별도 CI 서버를 직접 운영하지 않아도 됨.

3. Secret 키 관리가 편함

Docker Hub 계정, EC2 SSH 키, 서버 IP 같은 민감한 값은 GitHub Secrets에 넣고 workflow에서 참조하면 됨.
그래서 코드에 비밀번호나 pem 키를 직접 올리지 않고도 배포 자동화를 구성할 수 있었음.


CI/CD 자동화 환경세팅 중 발생한 이슈들

1. Docker Hub 이미지 이슈

처음 docker compose pull을 했을 때 not found 에러가 발생했다.
docker-compose.yml에 image: jinsungzz/hoppin만 적혀 있어서 기본 태그가 latest로 해석됐다.
그리고 Docker Hub에도 아직 이미지가 없었다.

해결 방안

  • docker hub에 이미지 태그를 staging으로 통일 시켜 hoppin repository를 생성했다.
    image: jinsungzz/hoppin:staging

2. Dockerfile 이슈

먼저 프로젝트 루트에 Dockerfile을 만들었다.

FROM eclipse-temurin:17-jdk

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-jar", "/app.jar"]

처음 빌드할 때 아래 에러가 발생했다.

COPY build/libs/*.jar app.jar
lstat /build/libs: no such file or directory

해결 방안

  • Dockerfile은 build/libs/*.jar가 있다고 가정한다.
    그런데 ./gradlew bootJar를 먼저 안 해서 jar가 없었음
./gradlew clean bootJar
docker build -t jinsungzz/hoppin:staging .

jar 파일 생성 후 빌드하도록 수정했다.

3. Mac 아키텍쳐 이슈

로컬에서 이미지를 push한 뒤 EC2에서 이미지를 pull 했더니 아래 에러가 발생했다.

no matching manifest for linux/amd64

해결 방안

나는 맥북 M2 아키텍쳐 모델을 사용 중이다.

  • M2의 기본 빌드는 amd64
  • 에러를 보면 EC2의 빌드는 linux/amd64
    -> M2와 EC2의 빌드를 통일해 주어야 한다.
docker buildx build --platform linux/amd64 -t jinsungzz/hoppin:staging --push .

이 코드를 통한 빌드로 로컬에서 push 할 때 이미지를 linux/amd64 아키텍쳐로 수정 후 빌드하게헀다.

4. Github Secrets 이슈

나는 분명 Secrets 키 값을 입력했는데 Actions 로그인했는데 아래 에러가 발생했다.

Must provide --username with --password-stdin
Run echo "" | docker login -u "" --password-stdin

시크릿 값이 비어있다는 에러이다.
그래서 다시 시크릿 값을 잘못 넣었나? 생각해 계속 수정해서 넣어도 똑같은 에러를 받았다.
원초적으로 값이 비어있다는 것에 관점을 두고 생각해봤다.

  • 원래의 입력은 아래처럼 하나의 시크릿에 모든 환경변수를 다 넣었다.

해결 방안

Name과 Secret에 1:1로 환경변수를 적용해 주었다.

위의 STAGING_SSH_KEY 값에는 .pem의 키 값을 넣어줘야되는데

-----BEGIN OPENSSH PRIVATE KEY-----
중간 키 내용 전체
-----END OPENSSH PRIVATE KEY-----

이런 형태로 되어 있다.
이때 BEGIN / END 내용까지 모두 시크릿 값에 넣어줘야 한다!!!!!


CI scripts

1. 언제 실행되는가 ?

pr 시 브랜치가 main, develop인지 확인하기 (pr 브랜치 기준으로 실행)

on:
  pull_request:
    branches: [ develop, main ]

2. 실행환경

Github가 제공하는 ubuntu 서버에서 CI가 실행됨

runs-on: ubuntu-latest

3. 코드 가져오기

PR에 올라온 코드를 GitHub Actions 실행 환경으로 가져옴

- name: Checkout
  uses: actions/checkout@v4

4. 파일 구조 확인

파일이 실제로 있는지 디버깅

- name: Show files
  run: find . -maxdepth 3 -type f | sort

5. JDK 17 세팅

Spring Boot 빌드에 필요한 Java 17을 설치함

- name: Set up JDK 17
  uses: actions/setup-java@v4
  with:
     distribution: temurin
     java-version: 17

6. gradlew 실행 권한 부여

리눅스 환경에서는 gradlew에 실행 권한이 없으면 실행이 안 돼서 ./gradlew 실행 전에 권한을 부여했음

- name: Grant execute permission
  run: chmod +x ./gradlew

7. 테스트 실행

- name: Run tests
  run: ./gradlew clean test

8. 빌드 확인

7번에 테스트를 진행했기 때문에 -x test로 테스트를 제외하고 빌드만 확인함

- name: Build check
  run: ./gradlew build -x test

CI 코드

name: PR to Develop CI

on:
  pull_request:
    branches: [ develop, main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Show files
        run: find . -maxdepth 3 -type f | sort

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - name: Grant execute permission
        run: chmod +x ./gradlew

      - name: Run tests
        run: ./gradlew clean test

      - name: Build check
        run: ./gradlew build -x test

CD scripts

1. 언제 실행되는가?

develop 브랜치에 push가 발생하면 실행됨
즉 PR이 merge돼 develop이 업데이트되면 자동 배포가 시작됨

on:
  push:
    branches:
      - develop

2. 실행환경

GitHub가 제공하는 Ubuntu runner에서 실행됨
GitHub Actions 서버에서 빌드하고 이미지를 push함

runs-on: ubuntu-latest

3. 코드가져오고 JDK 17 세팅

현재 develop 브랜치의 코드를 Actions runner로 가져와 스프링 부트를 실행하기 위해 JDK 17을 세팅함

- name: Checkout
  uses: actions/checkout@v4

- name: Set up JDK
  uses: actions/setup-java@v4

4. gradlew 실행 권한 부여 후 jar 빌드/검증

Linux 환경에서 ./gradlew를 실행할 수 있도록 권한을 부여하고 Spring Boot 실행 jar 파일을 만들고 검증함
검증 파일은 개발한 파일 넣으면 됨

- name: Grant execute permission
  run: chmod +x ./gradlew

- name: Build jar
  run: ./gradlew clean bootJar -x test

- name: Verify jar contents
  run: |
      jar tf build/libs/*.jar | grep BOOT-INF/classes/application
      jar tf build/libs/*.jar | grep -E "AiController|AuthController|MeController"

5. Docker Hub 로그인 및 Docker Buildx 세팅

도커 허브 로그인 후 환경을 맞추기위해 어느 환경에서도 빌드해도 상관없는 Buildx 로 세팅

 - name: Docker login
   run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
 
 - name: Set up Docker Buildx
   uses: docker/setup-buildx-action@v3

6. Docker 이미지 빌드 후 push + 이미지 내부 검증

검증 시 개발한 파일을 넣으면 되는데 일단 임시로 넣어둠

docker buildx build \
  --no-cache \
  --platform linux/amd64 \
  -t jinsungzz/hoppin:staging \
  --push .
  
- name: Verify docker image contents
  run: |
      docker run --rm --entrypoint sh jinsungzz/hoppin:staging -c 'jar tf /app.jar | grep BOOT-INF/classes/application'
      docker run --rm --entrypoint sh jinsungzz/hoppin:staging -c 'jar tf /app.jar | grep -E "AiController|AuthController|MeController"'
  • --no-cache : Docker 캐시 사용 안 함
  • --platform linux/amd64 : EC2에서 실행 가능한 amd64 이미지로 빌드
  • -t jinsungzz/hoppin:staging : 이미지 이름과 태그 지정
  • --push : 빌드 후 Docker Hub에 바로 push
  • . : 현재 프로젝트 루트의 Dockerfile 사용

7. EC2 배포에 사용되는 명령어

- name: Deploy to staging EC2
  uses: appleboy/ssh-action@v1.0.3

GitHub Actions가 EC2에 SSH로 접속해서 배포 명령을 실행함
접속 정보는 Secrets에서 가져옴

script: |
    echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
    cd /home/ubuntu/app

EC2에서도 Docker Hub에서 private/public 이미지를 pull할 수 있도록 로그인하고, 배포 디렉토리로 이동

docker compose down
docker rm -f hoppin 2>/dev/null || true
docker image rm jinsungzz/hoppin:staging 2>/dev/null || true
docker system prune -af

이걸 넣은 이유는 배포했는데 이전 이미지/컨테이너가 계속 남아서 안 바뀌는 문제를 막기 위해 이전 컨테이너와 이미지를 정리하는 것임

docker network inspect hoppin-net >/dev/null 2>&1 || docker network create hoppin-net

docker pull jinsungzz/hoppin:staging

docker compose up -d --force-recreate

네트워크 확인/생성 + 최신 이미지 pull + Docker Compose 재기동


CD 코드

name: Deploy Staging

on:
  push:
    branches:
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Grant execute permission
        run: chmod +x ./gradlew

      - name: Build jar
        run: ./gradlew clean bootJar -x test

      - name: Verify jar contents
        run: |
          jar tf build/libs/*.jar | grep BOOT-INF/classes/application
          jar tf build/libs/*.jar | grep -E "AiController|AuthController|MeController"

      - name: Docker login
        run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and Push Docker image
        run: |
          docker buildx build \
            --no-cache \
            --platform linux/amd64 \
            -t jinsungzz/hoppin:staging \
            --push .

      - name: Verify docker image contents
        run: |
          docker run --rm --entrypoint sh jinsungzz/hoppin:staging -c 'jar tf /app.jar | grep BOOT-INF/classes/application'
          docker run --rm --entrypoint sh jinsungzz/hoppin:staging -c 'jar tf /app.jar | grep -E "AiController|AuthController|MeController"'

      - name: Deploy to staging EC2
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
            cd /home/ubuntu/app

            docker compose down
            docker rm -f hoppin 2>/dev/null || true
            docker image rm jinsungzz/hoppin:staging 2>/dev/null || true
            docker system prune -af

            docker network inspect hoppin-net >/dev/null 2>&1 || docker network create hoppin-net

            docker pull jinsungzz/hoppin:staging
            docker compose up -d --force-recreate

위 workflow는 자동 배포 파이프라인이다

develop 업데이트
→ jar 빌드
→ Docker 이미지 빌드
→ Docker Hub push
→ EC2 pull
→ Docker Compose 재실행

profile
Data Engineer

0개의 댓글