Docker를 이용한 EC2 프로젝트 배포에 이어 Github Actions를 기반으로 한 CI/CD를 적용해보았다.
뻘짓을 하도 많이 해서 반복하기 싫어 깔끔하게 정리해두려고 한다.
지속적 통합, 지속적 배포의 줄임말이며, 협업 프로젝트에서 점점 필수적인 요소로 떠오르고 있는 옵션이다.
지속적 통합이란, 구성원들이 구현한 여러 기능과 관련된 코드를 빌드 및 테스트 과정을 거친 후 문제가 없다면 자동으로 통합하는 과정이다.
지속적 배포란, 이렇게 통합되어 새로 반영된 프로젝트 내 변동사항을 적용한 버전의 프로젝트의 배포를 자동화하는 과정이다.
Github Actions, Jenkins 등의 관련 Tool이 존재하지만, 일단 Github Actions를 기반으로 하여 적용을 시작해보자.
# Docker file for building the image
# jdk 17
FROM openjdk:17
# Copy the jar file to the container
ARG JAR_FILE=build/libs/*.jar
# jar file Copy
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Dspring.profiles.active=docker", "-jar","app.jar"]
이전 게시물과 동일하다.
Github Actions에 들어가 [New workflow]를 누른다.
내 프로젝트는 Java/Gradle이기에 오른쪽 위 Configure를 클릭한다.
gradle.yml이 작성되기 시작한다.
원하는 Action을 등록하면 특정 상황마다 우리가 원하는 동작을 자동화할 수 있다.
그러나 특정 동작 처리를 위해서는 환경변수나 계정 정보들을 알려주어야 하는데, 이를 yml 코드 상에서 직접적으로 노출시키는 것은 위험한 일이다.
Github에서 다행히 환경변수를 은닉하는 기능을 제공한다.
현재 Github Repository의 Setting에서 위 메뉴를 클릭한다.
[New repository secret] 버튼을 눌러 환경변수를 새롭게 추가할 수 있다.
내 프로젝트를 예시로 들면, 은닉한 변수는 다음과 같다.
특정 파일이나 키 등을 Docker 컨테이너화 시 포함할 수 있는 볼륨/마운트 등의 기능이 존재하는 것으로 확인하였으나, 일단 기초적인 현재 방식으로 처리해보자.
참고로 secret 변수는 수정 및 삭제만 가능하고 조회는 불가능하다.
Github Actions workflow를 처리하는 yml 파일에서 이러한 secret 변수는
${{ secrets.변수명 }} 형식으로 접근할 수 있다.
참고로 환경변수 FIREBASE_JSON_KEY의 경우 firebase 사용 시 내려받는 serviceAccountKey.json 파일을 base64로 인코딩한 값을 Github Secret에 추가하였고, 실제 코드에선 아래와 같이 디코딩 과정을 거쳐 JSON으로 복원하여 사용하도록 했다.
@Configuration
public class FirebaseConfig {
@Bean
public FirebaseAuth firebaseAuth() throws IOException {
// Base64 인코딩된 JSON 키 파일 읽기
String base64EncodedKey = System.getenv("FIREBASE_JSON_KEY");
// Base64 디코딩
byte[] decodedKey = Base64.getDecoder().decode(base64EncodedKey);
// 바이트 배열에서 InputStream 생성
ByteArrayInputStream serviceAccountStream = new ByteArrayInputStream(decodedKey);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccountStream))
.build();
FirebaseApp.initializeApp(options);
return FirebaseAuth.getInstance();
}
}
name: CI/CD # yml 파일 이름
on: # develop 브랜치 push/pr 시 가동
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
permissions: # 이 workflow에 Repository 읽기 권한 부여
contents: read
jobs:
CI: # CI 과정이므로 CI라고 명명하며, ubuntu 최신환경에서 실행
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3 # 체크아웃
# jdk 17 설치
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
# Gradle 설치
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ec92e829475ac0c2315ea8f9eced72db85bb337a # v3.0.0
# Gradle로 프로젝트 빌드 (-x test 유닛테스트 배제)
- name: Build with Gradle
run: ./gradlew build -x test
# 은닉한 환경변수로 Dockerhub 로그인
- name: Docker Login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
# Docker 이미지 빌드
- name: Docker Image Build
run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/alert .
# Dockerhub에 통합된 내용을 Push
- name: DockerHub Push
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/alert
실제 develop 브랜치에 해당 내용을 푸시 및 PR 요청하면 workflow가 가동되어 빌드 및 테스트 및 자동화 과정을 진행하고,
문제 발생 시 fail 처리되며 해당 workflow는 롤백된다.
workflow yml 파일을 다루는데 익숙하지 않으면 위와 같은 온갖 뻘짓이 실시간으로 드러나니 꼭 관련 형식을 숙지하고 시작하도록 하자..
CD:
runs-on: self-hosted # self-hosted 방식
needs: CI # CI가 성공한 후 진행할 수 있음
steps:
- name: Docker Container Remove # 현재 돌고 있는 도커 컨테이너 삭제
run: sudo docker rm -f alert 2>/dev/null || true
- name: Docker Old Image Remove # 기존 도커 이미지 삭제
run: sudo docker rmi ${{ secrets.DOCKERHUB_USERNAME }}/alert
- name: Docker Run New
run: sudo docker run -d --name alert
-e DB_URL=${{secrets.DB_URL}}
-e DB_USERNAME=${{secrets.DB_USERNAME}}
-e DB_PASSWORD=${{secrets.DB_PASSWORD}}
-e JWT_SECRET=${{secrets.JWT_SECRET}}
-e FIREBASE_JSON_KEY=${{secrets.FIREBASE_ACCOUNT_KEY}}
-p 8080:8080 ${{secrets.DOCKERHUB_USERNAME}}/alert
명령어는 runs-on에 self-hosted를 제외하면 이전 게시물과 큰 차이는 없다.
secret에 등록해둔 주요 환경변수를 Docker run 명령어 시 포함하도록 하여 해당 환경변수에 프로젝트에 적절하게 삽입되도록 구성하였다.
self-hosted runner란, 유저가 직접 hosting한 서버(EC2)에서 Github Action Application을 띄우는 과정을 의미한다. 자세한 설명은 이 게시물을 참고하면 좋다.
Actions에 [Management] - [Runners] - [Selt-hosted runner] 에서 New runner 버튼을 누르자.
Download 아래에 있는 잡다한 명령어를 배포한 서버 상에서 실행하고, yml 파일에서 CD 부분의 runs-on을 self-hosted라고 설정하자. 내가 배포한 EC2 서버는 Linux x64이므로 해당 환경에 맞춰서 명령어를 실행해야 한다.
명령어를 모두 실행했다면 해당 배포 환경에서
ls
cd actions/runner
./run.sh
명령어를 실행하여 Github Action의 job 요청을 배포한 서버에서 처리할 수 있도록 준비하자.
배포한 서버는 yml 파일에서 알 수 있듯이 크게 3가지 작업을 처리해주어야 한다.
1. 현재 실행중인 Springboot Docker Container 삭제 (docker rm)
2. 기존 Docker 이미지 삭제 (docker rmi)
3. 최신화된 Docker 이미지 다운로드 및 실행 (docker run)
docker logs 명령어를 통해 해당 Container의 상황을 확인한 결과 새로운 내용이 잘 반영되어 배포까지 모두 완료된 것을 확인할 수 있다.
이렇게 간단한 Docker 기반 CI/CD를 알아보았다.
때때로 Pull request가 Merge되었을 때만 배포되는 등 제약을 걸어야 하는 순간이 있다.
그런 경우엔 CI와 CD 파일을 별도로 분리하는 것이 유용할 수 있다.
name: CI
on:
push:
branches: [ "dev" ]
pull_request:
branches: [ "dev" ]
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ec92e829475ac0c2315ea8f9eced72db85bb337a # v3.0.0
- name: Build with Gradle
run: ./gradlew build -x test # 테스트코드 검사 없이 빌드
- name: Docker Login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build Docker Image
run: docker build --platform linux/amd64 -t ${{ secrets.DOCKERHUB_USERNAME }}/프로젝트명 .
- name: Push Docker Image
run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/프로젝트명
name: CD
on:
push:
branches: [ "dev" ]
pull_request:
branches: [ "dev" ]
types: [closed]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
if: github.event.pull_request.merged == true # dev 브랜치로 향하는 PR이 Merge되었을 때만 실행
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Pull Docker Image
run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/프로젝트명
- name: Remove Old Docker Container
run: sudo docker rm -f 프로젝트명 || true
- name: Run Updated Docker Container
run: sudo docker run -t --env-file ~/.env -d --name 프로젝트명 -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/프로젝트명