[청하] GitHub Actions CI/CD 워크플로 리팩터링
목차
1. 배경
초기 CI/CD 스크립트는 다른 팀원이 초안을 작성했고, 이후에도 해당 구조를 크게 변경하지 않는 선에서 수정이 이루어졌다. 하지만 전체 워크플로에서 jobs 단위가 명확히 구분되어 있지 않아 가독성이 떨어졌고, 변경이나 확장이 필요한 경우 유지보수가 어려운 상태였다. 이에 따라 기존 스크립트를 분석한 뒤, 가독성과 유지보수성을 높이기 위해 전체 구조를 재정비하였다.
2. 기존 GitHub Actions 워크플로
# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker
# event trigger
on:
push :
branches : ["master","dev","staging"]
permissions:
contents: read
기존 Github Actions 워크플로는 `CI/CD using github actions & docker`라는 이름으로 정의되어 있고, 이 워크플로는 master, dev, staging 브랜치에 push 이벤트가 발생할 때마다 실행된다.
jobs:
CI_CD:
runs-on: ubuntu-latest
steps:
# JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# gradle caching - 빌드 시간 향상
- name: Gradle Caching
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# gradle build
- name: Build with Gradle
run: |
chmod +x ./gradlew
./gradlew build -x test
- 환경 설정
- `actions/checkout@v4`: 저장소 코드를 워크플로 런너로 가져옴
- `actions/setup-java@v4`: Java Development Kit (JDK) 17을 설정함
- 빌드 최적화
- `actions/cache@v4`: Gradle 캐시를 설정하여 빌드 시간을 단축함
- 애플리케이션 빌드
- `Build with Gradle`: Gradle을 사용하여 애플리케이션을 빌드한다. -x test 옵션으로 테스트는 건너뛴다.
jobs:
CI_CD:
...
# Docker build & push to staging
- name: Docker build & push to staging
if: contains(github.ref, 'staging')
run: |
docker login -u ${{secrets.DOCKER_USER}} -p ${{secrets.DOCKER_TOKEN}}
docker build -t ${{secrets.DOCKER_REPOSITORY}}:staging .
docker push ${{secrets.DOCKER_REPOSITORY}}:staging
# Docker build & push to prod
- name: Docker build & push to prod
if: contains(github.ref, 'dev')
run: |
docker login -u ${{secrets.DOCKER_USER}} -p ${{secrets.DOCKER_TOKEN}}
docker build -t ${{secrets.DOCKER_REPOSITORY}} .
docker push ${{secrets.DOCKER_REPOSITORY}}
Docker 이미지 빌드 및 푸시(조건부)
- Docker build & push to staging: staging 브랜치에 푸시된 경우, Docker Hub에 staging 태스로 이미지를 빌드하고 푸시함
- Docker build & push to prod: dev 브랜치에 푸시된 경우, Docker Hub에 latest 태그(기본)로 이미지를 빌드하고 푸시함
- 두 단계 모두 docker login을 통해 인증을 수행한다.
jobs:
CI_CD:
...
# Deploy to staging server
- name: Deploy to staging
if: contains(github.ref, 'staging')
uses: appleboy/ssh-action@master
with:
host: ${{secrets.STAGING_HOST}}
username: lpromotion00
key: ${{ secrets.STAGING_PRIVATE_KEY }}
envs: GITHUB_SHA
script: |
export DB_USERNAME=${{secrets.DB_USERNAME}}
export DB_PASSWORD=${{secrets.DB_PASSWORD}}
export JWT_SECRET=${{secrets.JWT_SECRET}}
export OAUTH_CLIENT_ID=${{secrets.OAUTH_CLIENT_ID}}
export OAUTH_CLIENT_SECRET=${{secrets.OAUTH_CLIENT_SECRET}}
sudo docker ps
sudo docker stop springboot
sudo docker rm -f springboot
sudo docker pull ${{secrets.DOCKER_REPOSITORY}}:staging
sudo docker run -d -p 8081:8080 --net=host --name springboot \\
-v /mnt/data/LegalDongCode_List.txt:/app/data/LegalDongCode_List.txt \\
-e TZ=Asia/Seoul \\
-e SPRING_PROFILES_ACTIVE=staging \\
-e DB_USERNAME=${DB_USERNAME} \\
-e DB_PASSWORD=${DB_PASSWORD} \\
-e JWT_SECRET=${JWT_SECRET} \\
-e OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} \\
-e OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} \\
${{secrets.DOCKER_REPOSITORY}}:staging
sudo docker image prune -f
# Deploy to prod server
- name: Deploy to prod
uses: appleboy/ssh-action@master
id: deploy-prod
if: contains(github.ref, 'dev')
with:
host: ${{secrets.HOST}}
username: root
password: ${{secrets.SSH_PASSWORD}}
port: 22
key: ${{ secrets.PRIVATE_KEY }}
envs: GITHUB_SHA
script: |
export DB_USERNAME=${{secrets.DB_USERNAME}}
export DB_PASSWORD=${{secrets.DB_PASSWORD}}
export JWT_SECRET=${{secrets.JWT_SECRET}}
export OAUTH_CLIENT_ID=${{secrets.OAUTH_CLIENT_ID}}
export OAUTH_CLIENT_SECRET=${{secrets.OAUTH_CLIENT_SECRET}}
sudo docker ps
sudo docker stop springboot
sudo docker rm -f springboot
sudo docker pull ${{secrets.DOCKER_REPOSITORY}}
sudo docker run -d -p 8080:8080 --net=host --name springboot \\
-v /mnt/data/LegalDongCode_List.txt:/app/data/LegalDongCode_List.txt \\
-e TZ=Asia/Seoul \\
-e SPRING_PROFILES_ACTIVE=dev \\
-e DB_USERNAME=${DB_USERNAME} \\
-e DB_PASSWORD=${DB_PASSWORD} \\
-e JWT_SECRET=${JWT_SECRET} \\
-e OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} \\
-e OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} \\
${{secrets.DOCKER_REPOSITORY}}
sudo docker image prune -f
배포(조건부)
- Deploy to staging server: staging 브랜치에 푸시된 경우, `appleboy/ssh-action`을 사용하여 Staging 서버에 SSH로 접속해 다음 작업을 수행함
- 기존 Docker 컨테이너 중지 및 삭제
- Docker 이미지 풀 (staging 태그)
- 새로운 Docker 컨테이너 실행 (환경 변수 주입 포함)
- 오래된 Docker 이미지 정리
- Deploy to prod server: dev 브랜치에 푸시된 경우, `appleboy/ssh-action`을 사용하여 Production 서버에 SSH로 접속해 Staging과 유사한 배포 작업을 수행함
그 외 기존 워크플로 특징
- 단일 Job: CI(빌드)와 CD(배포)의 모든 과정이 CI_CD라는 하나의 job 내에 포함되어 있다.
- 브랜치 기반 조건: if 조건문을 사용하여 `github.ref`(현재 브랜치)에 따라 Docker 빌드/푸시 및 배포 단계를 분기한다.
- 환경 변수 주입: SSH 액션 내에서 script 블록을 통해 직접 환경 변수를 export하거나 docker run 명령에 주입한다.
- 브랜치 역할 불일치: 일반적으로 dev 브랜치는 개발 환경을, master 또는 main 브랜치는 Production 환경을 담당하지만, 현재 워크플로는 dev 브랜치를 Production 배포에 사용하고 있다.
3. 개선 방안
3.1. CI/CD 단계 분리
- 기존에는 CI(빌드)와 CD(배포)가 하나의 워크플로 내에서 처리되었다.
- `build.yml`과 `deploy-*.yml`로 역할을 명확히 분리하여 워크플로 구조를 단순화하고 유지보수를 용이하게 한다.
- CI 성공 여부에 따라 CD를 조건적으로 실행할 수 있어, 안정성을 확보할 수 있다.
3.2. 워크플로 트리거 방식 개선
- 기존에는 모든 push 이벤트에 대해 빌드와 배포가 동시에 실행되었다.
- `workflow_run` 이벤트를 사용하여, 지정된 CI 워크플로가 성공(`conclusion == 'success'`)한 경우에만 CD가 트리거되도록 변경한다.
- 이로 인해, 실패한 빌드에 대한 잘못된 배포를 방지하고 신뢰할 수 있는 상태에서만 배포가 이루어진다.
3.3. 아티팩트 기반 배포 도입
- 기존에는 동일한 워크플로 내에서 JAR 파일을 빌드하고 배포까지 진행했기 때문에, 빌드 실패 시 전체 흐름이 중단되거나 디버깅이 어려웠다.
- CI에서 생성한 빌드 아티팩트를 `download-artifact`를 통해 CD 단계에서 받아서 사용한다.
- 아티팩트 기반 배포를 통해 빌드와 배포가 완전히 분리되어 각 단계의 독립성과 안정성을 확보할 수 있다.
3.4. Docker 빌드 및 배포 구조 개선
- 기존에는 브랜치별로 조건문(`if: contains(github.ref, 'staging')`)을 사용하여 Docker 이미지를 빌드하고 푸시했기 때문에 코드 중복이 많고 확장이 어려웠다.
- 환경별 배포 워크플로를 별도로 구성하여 중복을 제거하고, 환경에 맞는 태그(prod, staging)를 명확히 관리할 수 있도록 한다.
- `docker build`, `docker push` 과정을 분리하고 명시적으로 구성하여 디버깅 및 로그 확인이 용이해진다.
4. CI 워크플로 생성 및 설정
프로젝트를 빌드하고 테스트하며, 이후 배포 단계에서 사용할 아티팩트(.jar 파일)를 생성하고 저장하는 CI 워크플로를 생성한다. 이 워크플로는 모든 환경(Staging, Production)에 공통적으로 적용된다.
`.github/workflows/` 디렉터리 아래에 build.yml 파일을 생성한다.
# .github/workflows/build.yml
name: CI - Build and Test Spring Boot App
# event trigger
on:
push:
branches:
- main
- prod
- staging
pull_request:
branches:
- main
- prod
- staging
permissions:
contents: read # 저장소 콘텐츠를 읽는 권한만 부여
jobs:
build:
runs-on: ubuntu-latest # 워크플로를 실행할 환경 (Github 호스팅 러너)
steps:
- name: Checkout Repository
uses: actions/checkout@v4 # 저장소 코드 체크아웃
- name: Set up JDK 17
uses: actions/setup-java@v4 # JDK 17 tjfwjd
with:
java-version: '17'
distribution: 'temurin' # Adoptium Temurin 배포판 사용
- name: Configure Gradle Caching
uses: actions/cache@v4 # Gradle 캐시 설정으로 빌드 시간 단축
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 # gradlew 실행 권한 부여 (Linux/macOS)
- name: Build and Test with Gradle
run: ./gradlew build -x test # 애플리케이션 빌드
- name: Upload JAR artifact
uses: actions/upload-artifact@v4 # 빌드된 JAR 파일을 아티팩트로 업로드
with:
name: spring-app-jar # 아티팩트 이름 지정
path: build/libs/*.jar # 빌드된 JAR 파일의 경로
retention-days: 5 # 아티팩트 보관 기간 (기본 90일)
- `on: push` / `on: pull_request`
- push: main, prod, staging 브랜치에 코드가 푸시될 때 워크플로가 실행됨
- pull_request: 위 브랜치들을 대상으로 하는 Pull Request가 열리거나 업데이트될 때도 워크플로가 실행되어 코드 병합 전 빌드 및 테스트를 수행
- `permissions`: contents: read: 이 워크플로는 저장소 콘텐츠를 읽기만 하므로, 최소한의 권한만 부여하여 보안을 강화
- `jobs.build`:
- `runs-on: ubuntu-latest`: GitHub에서 호스팅하는 Ubuntu 환경에서 Job을 실행
- `steps:`
- `actions/checkout@v4`: GitHub 저장소의 코드를 러너(Runner)로 가져오는 필수 액션
- `actions/setup-java@v4`: Java 프로젝트이므로 JDK를 설정. 현재 프로젝트에 맞는 버전을 지정하고, distribution을 temurin으로 설정하는 것이 일반적.
- `actions/cache@v4`: Gradle 캐시를 설정하여 빌드 시간을 단축. 이전에 빌드했던 의존성 파일들을 재사용하여 gradlew build 명령이 더 빠르게 실행될 수 있도록 도움.
- `chmod +x ./gradlew`: gradlew 스크립트에 실행 권한을 부여
- `./gradlew build`: 핵심 빌드 단계. 여기서 기존 워크플로의 x test 옵션을 제거. 모든 단위 및 통합 테스트가 빌드 과정에서 자동으로 실행됨. 테스트가 실패하면 빌드도 실패하고 워크플로 실행이 중단되므로, 버그를 조기에 발견할 수 있음.
- `actions/upload-artifact@v4`: 빌드된 .jar 파일을 아티팩트로 업로드. 이 아티팩트는 다음 단계(배포 워크플로)에서 다운로드하여 재사용됨. name과 path를 프로젝트 구조에 맞게 정확히 지정해야 함. retention-days는 아티팩트를 GitHub에 보관할 기간.
5. Staging 환경 배포 워크플로 생성 및 설정
`.github/workflows/` 디렉터리 아래에 `deploy-staging.yml` 파일을 생성한다. 이 워크플로는 staging 브랜치에서 `build.yml` 워크플로가 성공했을 때만 실행된다.
5.1. 트리거 조건 및 권한 설정
# .github/workflows/deploy-staging.yml
name: CD - Deploy to Staging
# 이 워크플로는 staging 브랜치에서 실행된 build.yml 워크플로가
# 성공적으로 완료되었을 때만 트리거됩니다.
on:
workflow_run:
workflows: ["CI - Build and Test Spring Boot App"] # build.yml의 'name' 필드와 일치
types: [completed] # build.yml이 완료되었을 때 트리거
branches: [staging] # staging 브랜치에서 build.yml이 성공했을 때만 실행
permissions:
contents: read # 저장소 콘텐츠 읽기 권한
- `name: CD - Deploy to Staging`
- GitHub Actions의 워크플로 이름으로, Actions 탭에 이 이름으로 표시된다.
- `on.workflow_run`
- `workflow_run` 이벤트는 다른 워크플로가 완료되었을 때 실행되는 트리거이다.
이 설정은 CI - Build and Test Spring Boot App 워크플로가 실행되고 나서만 현재 워크플로가 작동하도록 한다. - `workflows`: 대상이 되는 워크플로의 이름. 반드시 해당 CI 워크플로의 `name` 값과 정확히 일치해야 한다.
- GitHub은 내부적으로 문자열 매칭을 사용하므로, `"CI - Build and Test Spring Boot App"`에서 공백/대소문자도 동일해야 한다.
- `types: [completed]`: 완료된 워크플로에 대해서만 작동. `requested`, `completed` 중 하나를 설정할 수 있으며, 일반적으로는 `completed`를 사용한다.
- `branches: [staging]`: 해당 CI 워크플로가 staging 브랜치에서 실행되었을 때만 배포 워크플로를 트리거하도록 한다.
- `workflow_run` 이벤트는 다른 워크플로가 완료되었을 때 실행되는 트리거이다.
- `permissions.contents: read`
- GitHub Actions가 이 워크플로 내에서 저장소의 콘텐츠를 읽을 수 있는 권한만 허용한다.
이 설정은 불필요한 쓰기 권한을 제한해 보안을 강화하는 목적이다.
- GitHub Actions가 이 워크플로 내에서 저장소의 콘텐츠를 읽을 수 있는 권한만 허용한다.
5.2. jobs : Docker 이미지 빌드 및 Staging 서버 배포
jobs:
deploy:
runs-on: ubuntu-latest # 배포를 실행할 GitHub 호스팅 러너
# 위 workflow_run 이벤트 중에서도,
# build.yml이 staging 브랜치에서 성공한 경우에만 이 Job이 실행됩니다.
if: github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'staging'
- `jobs.deploy`: 실행할 작업(Job)의 이름. Actions UI에는 `deploy`라는 이름으로 표시된다.
- `runs-on: ubuntu-latest`: GitHub-hosted runner 중 최신 Ubuntu 환경에서 이 Job을 실행한다.
- GitHub-hosted runner는 GitHub에서 관리하는 클린한 가상 머신 환경으로, 워크플로가 실행될 때 매번 초기화된 상태에서 시작되며 실행 종료 후 폐기된다.
- `if`: 조건부 실행 필드. CI 결과가 성공(`success`)이고 `head_branch`가 `staging`일 때만 배포가 진행된다.
이는 불필요한 배포를 방지하고, 지정된 브랜치에 한해 자동화된 배포가 실행되도록 한다.
Github 호스팅 러너란?
Github 호스팅 러너(Github-hosted Runner)는 Github Actions 워크플로를 실행할 때 Github가 자동으로 제공하고 관리하는 가상머신이다. 사용자는 별도의 서버를 준비하거나 관리할 필요 없이, 워크플로 파일에서 `runs-on: ubuntu-latest`와 같이 지정하면 Github가 최신 Ubuntu 환경의 러너를 자동으로 할당해준다.
Github 호스팅 러너의 구체적인 작동 원리
- 워크플로우 트리거
워크플로우가 특정 이벤트(예: push, pull request, workflow_run 등)로 인해 트리거된다. - 러너 할당
`runs-on`에 지정된 OS(여기서는 `ubuntu-latest`)에 따라 Github가 준비한 가상머신이 할당된다. - 환경 초기화
러너는 항상 깨끗한 상태로 시작한다. 필요한 도구(예: git, docker, node, python 등)가 사전 설치되어 있으며, 추가 패키지는 워크플로에서 직접 설치할 수 있다. - Job 및 Step 실행
- 워크플로에 정의된 각 Step(명령, 액션 등)이 순서대로 실행된다. 위 코드에서는 deploy Job이 실행되며, 조건(if:)에 따라 build.yml 워크플로우가 staging 브랜치에서 성공했을 때만 실행된다.
- 각 `step:`은 독립된 셸 세션에서 실행된다. 동일한 러너 내에서는 파일 시스템 상태가 유지되지만, 환경 변수는 `run:` 스크립트 내부에서만 유효하다.
- 결과 보고 및 환경 폐기
실행 결과(성공/실패, 로그 등)는 Github Actions UI에 실시간으로 표시된다. Job이 끝나면 해당 가상머신은 완전히 폐기되어, 보안과 일관성이 보장된다.
1단계: 저장소 체크아웃
steps:
- name: Checkout Repository # Dockerfile, 배포 스크립트 등의 위해 리포지토리 체크아웃
uses: actions/checkout@v4
- `actions/checkout@v4`: 현재 커밋된 저장소의 코드를 runner에 클론한다. 이후 단계에서 필요한 설정 파일(Dockerfile, 환경설정 등)을 사용하기 위해 반드시 필요하다.
2단계: 아티팩트 다운로드
- name: Download JAP artifact # build.yml에서 업로드한 JAR 아티팩트 다운로드
uses: actions/download-artifact@v4
with:
name: spring-app-jar # build.yml에서 업로드한 아티팩트 이름
path: . # 현재 워크플로의 루트 디렉터리에 다운로드
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.TOKEN_GITHUB_ACTIONS }}
- `actions/download-artifact@v4`: 이전 워크플로(CI 단계)에서 업로드된 JAR 파일을 다운로드한다.
- `name`: CI 단계에서 `upload-artifact` 스텝에서 사용한 이름과 일치해야 한다 (`spring-app-jar`).
- `run-id`: CI 워크플로의 실행 ID. `workflow_run` 이벤트에서 참조할 수 있는 ID로, 정확히 어떤 실행에서 생성된 아티팩트를 받을 것인지 명시한다.
- `github-token`: 퍼블릭 저장소에서는 기본 `GITHUB_TOKEN`도 가능하나, 프라이빗 저장소 또는 워크플로 간 인증이 필요한 경우 별도의 PAT 토큰이 필요할 수 있다.
run-id 와 github-token 설정이 필요한 이유
- https://stackoverflow.com/questions/78506218/unable-to-download-artifacts-artifact-not-found-for-name-java-app
- https://velog.io/@hoooonshub/Unable-to-download-artifacts-Artifact-not-found-for-name-build-output-dev-Please-ensure-that-your-artifact-is-not-expired-and-the-artifact-was-uploaded-using-a-compatible-version-of-toolkitupload-artifact-깃허브-액션-Github-Actions
- https://github.blog/news-insights/product-news/get-started-with-v4-of-github-actions-artifacts/#cross-run-or-repository-downloads
설정하지 않을 경우 발생하는 오류: `Error: Unable to download artifact(s): Artifact not found for name: …`
Cross-run (or repository) downloads
The action to download artifacts has some new addons as well. In the list of inputs, we now have github-token, repository and run-id. Given a properly scoped token with actions:read, artifacts can now be downloaded from other workflow runs and repositories. By default with no token specified, the action will only be able to download from the current workflow run and any previous run attempts.
⇒ 즉, 기본 설정(github-token 없이)으로는 다른 워크플로(build.yml) 에서 업로드된 아티팩트를 다운로드할 수 없다. 이러한 상황은 GitHub에서 Cross-run download 또는 Cross-workflow download 라고 부른다.
현재는
- build.yml → workflow_run → deploy-*.yml로 워크플로가 서로 분리되어 있다.
- 즉, Cross-workflow download 로 간주되며,
- 이때는 명시적으로 run-id와 github-token을 설정해야 한다.
3단계: Docker 빌드를 위한 JAR 파일 준비
- name: Prepare JAR for Docker Build # 다운로드된 JAR 파일을 Dockerfile에서 쉽게 참조하도록 준비
run: mv *.jar app.jar
- CI 단계에서 받은 JAR 파일을 Dockerfile이 참조하는 명칭(`app.jar`)으로 변경한다.
- Dockerfile
# Start a new stage
FROM openjdk:17-jdk-alpine
# Set timezone to Asiz/Seoul
ENV TZ=Asia/Seoul
RUN apk add --no-cache tzdata && \
ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
echo "Asia/Seoul" > /etc/timezone
WORKDIR /app
ARG JAR_FILE=./build/libs
COPY ${JAR_FILE}/*.jar app.jar
ENTRYPOINT ["java", "-jar", "./app.jar", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE:-local}"]
4단계: DockerHub 로그인
- name: Docker Login # Docker Hub에 인증
run: docker login -u ${{secrets.DOCKER_USER}} -p ${{secrets.DOCKER_TOKEN}}
- 비공개 레지스트리에 이미지를 푸시하려면 인증이 필요하다.
- `DOCKER_USER`, `DOCKER_TOKEN`은 GitHub Secrets에 저장된 민감한 정보로, DockerHub의 인증 정보이다.
5단계: Docker 이미지 빌드
- name: Build Docker Image for Staging # Docker 이미지 빌드 (staging 태그 적용)
run: docker build -t ${{secrets.DOCKER_REPOSITORY}}:staging .
- 현재 경로에 있는 Dockerfile을 기준으로 Docker 이미지를 빌드한다.
- `t`: 태그를 의미하며, `${DOCKER_REPOSITORY}:staging` 형식으로 지정된다.
6단계: Docker 이미지 푸시
- name: Push Docker Image to Registry # 빌드된 Docker 이미지 푸시
run: docker push ${{secrets.DOCKER_REPOSITORY}}:staging
- 빌드한 이미지를 DockerHub 또는 GitHub Container Registry(GHCR) 등에 푸시한다.
- `DOCKER_REPOSITORY`는 Secret에 등록된 전체 이미지 경로를 포함해야 한다.
7단계: Staging 서버에 SSH 배포
- name: Deploy to Staging Server # SSH 접속 및 배포 스크립트 실행
uses: appleboy/ssh-action@master
with:
host: ${{secrets.STAGING_HOST}}
username: ${{secrets.STAGING_USERNAME}}
key: ${{ secrets.STAGING_PRIVATE_KEY }}
port: 22 # SSH 포트 (기본값)
envs: GITHUB_SHA
script: |
# 이 스크립트는 Staging 서버에서 실행됩니다.
echo "Deploying to Staging Server..."
# 환경 변수 설정
export DOCKER_REPOSITORY=${{ secrets.DOCKER_REPOSITORY }}
export DOCKER_TAG=staging
export SPRING_PROFILES_ACTIVE=staging
export DB_USERNAME=${{ secrets.DB_USERNAME }}
export DB_PASSWORD=${{ secrets.DB_PASSWORD }}
export JWT_SECRET=${{ secrets.JWT_SECRET }}
export OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }}
export OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }}
export SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}
export APP_DATA_PATH=/mnt/data/LegalDongCode_List.txt:/app/data/LegalDongCode_List.txt # 데이터 볼륨 경로 (법정동코드 파일)
# 기존 컨테이너 중지 및 삭제 (오류 발생 시 무시하도록 '|| true' 추가)
sudo docker stop springboot || true
sudo docker rm -f springboot || true
echo "Pulling Docker image: $DOCKER_REPOSITORY:$DOCKER_TAG"
sudo docker pull $DOCKER_REPOSITORY:$DOCKER_TAG
echo "Running new Docker container..."
sudo docker run -d --net=host --name springboot \\
-v $APP_DATA_PATH \\
-e TZ=Asia/Seoul \\
-e SPRING_PROFILES_ACTIVE=$SPRING_PROFILES_ACTIVE \\
-e DB_USERNAME=$DB_USERNAME \\
-e DB_PASSWORD=$DB_PASSWORD \\
-e JWT_SECRET=$JWT_SECRET \\
-e OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID \\
-e OAUTH_CLIENT_SECRET=$OAUTH_CLIENT_SECRET \\
-e SLACK_WEBHOOK_URL=$SLACK_WEBHOOK_URL \\
$DOCKER_REPOSITORY:$DOCKER_TAG
echo "Pruning old Docker images..."
sudo docker image prune -f
echo "Deployemnt to Staging completed."
- `appleboy/ssh-action`: 원격 서버에 SSH로 접속하여 명령어를 실행할 수 있는 GitHub Actions 플러그인이다.
- `host`, `username`, `key`: SSH 접속을 위한 정보는 모두 GitHub Secrets에 저장해둔다.
- `script`: 서버 접속 후 실행할 명령어들. 주로 다음 작업들을 포함한다.
- 기존 컨테이너 정지 및 제거
- 새로운 이미지 풀(Pull)
- `docker run` 명령으로 새 컨테이너 실행
- 이미지 정리(`docker image prune`) 등
기존 컨테이너 중지 및 삭제 시 || true 추가하는 이유
`|| true`는 셸 스크립트에서 "앞의 명령이 실패하더라도 스크립트 전체의 실행을 계속 진행하라"는 의미이다.
- `sudo docker stop springboot-staging || true`
- 만약 `springboot-staging`이라는 컨테이너가 이미 중지되어 있거나, 아예 존재하지 않는 경우 sudo docker stop 명령은 오류를 반환하고 종료 코드(exit code) 0이 아닌 값을 내보낸다.
- GitHub Actions의 `run` 스텝은 기본적으로 셸 명령의 종료 코드가 0이 아니면 해당 스텝을 실패로 처리하고 워크플로를 중단시킨다.
- 하지만 컨테이너가 없거나 이미 중지된 것은 배포를 진행하는 데 심각한 문제가 아니며, 오히려 정상적인 상황일 수 있다. 예를 들어 첫 배포이거나, 수동으로 이미 컨테이너를 삭제했을 수 있다.
- `|| true`를 사용하면 `docker stop` 명령이 실패하더라도 `true` 명령이 실행되어 종료 코드를 0으로 만들어주므로, GitHub Actions는 해당 스텝을 성공으로 간주하고 다음 스텝으로 넘어간다. 이는 워크플로의 불필요한 중단을 방지한다.
- `sudo docker rm -f springboot-staging || true`
- `docker rm` 명령도 마찬가지로 컨테이너가 존재하지 않으면 오류를 반환한다. `|| true`를 통해 이 경우에도 워크플로가 중단되지 않고 진행되도록 한다.
- `f` 옵션은 컨테이너가 실행 중이더라도 강제로 삭제하는 옵션이다. `stop` 명령이 먼저 실패했거나 컨테이너가 멈추지 않는 상황에 대비한다.
6. Production 환경 배포 워크플로 생성 및 설정
Production 배포는 Staging 배포와 거의 비슷하지만, 몇 가지 중요한 차이점이 있다.
- 트리거 브랜치: staging 대신 prod 브랜치에 대한 push 이벤트나 build.yml 워크플로가 prod 브랜치에서 성공했을 때 트리거되어야 한다.
- Docker 태그: Docker 이미지 태그가 staging 대신 prod 를 사용한다.
- 환경 프로필: Spring Boot 애플리케이션의 SPRING_PROFILES_ACTIVE는 prod로 설정되어야 합니다.
- 서버 정보: 배포 대상 서버의 호스트, 사용자 이름, SSH 키 및 기타 환경 변수를 Production 환경에 맞게 변경되어야 한다.
`.github/workflows/` 디렉터리 아래에 `deploy-prod.yml` 파일을 생성한다.
# .github/workflows/deploy-prod.yml
name: CD - Deploy to Production
# 이 워크플로는 prod 브랜치에서 실행된 build.yml 워크플로가
# 성공적으로 완료되었을 때만 트리거됩니다.
on:
workflow_run:
workflows: ["CI - Build and Test Spring Boot App"] # build.yml의 'name' 필드와 일치
types: [completed] # build.yml이 완료되었을 때 트리거
branches: [prod] # prod 브랜치에서 build.yml이 성공했을 때만 실행
permissions:
contents: read # 저장소 콘텐츠 읽기 권한
jobs:
deploy:
runs-on: ubuntu-latest # 배포를 실행할 GitHub 호스팅 러너
# 위 workflow_run 이벤트 중에서도,
# build.yml이 prod 브랜치에서 성공한 경우에만 이 Job이 실행됩니다.
if: github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'prod'
steps:
- name: Checkout Repository # Dockerfile, 배포 스크립트 등의 위해 리포지토리 체크아웃
uses: actions/checkout@v4
- name: Download JAP artifact # build.yml에서 업로드한 JAR 아티팩트 다운로드
uses: actions/download-artifact@v4
with:
name: spring-app-jar # build.yml에서 업로드한 아티팩트 이름
path: . # 현재 워크플로의 루트 디렉터리에 다운로드
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.TOKEN_GITHUB_ACTIONS }}
- name: Prepare JAR for Docker Build # 다운로드된 JAR 파일을 Dockerfile에서 쉽게 참조하도록 준비
run: mv *.jar app.jar
- name: Docker Login # Docker Hub에 인증
run: docker login -u ${{secrets.DOCKER_USER}} -p ${{secrets.DOCKER_TOKEN}}
- name: Build Docker Image for Production # Docker 이미지 빌드 (prod 태그 적용)
run: docker build -t ${{ secrets.DOCKER_REPOSITORY }}:prod . # prod 태그 사용
- name: Push Docker Image to Registry # 빌드된 Docker 이미지 푸시
run: docker push ${{ secrets.DOCKER_REPOSITORY }}:prod # prod 태그 푸시
- name: Deploy to Production Server # SSH 접속 및 배포 스크립트 실행
uses: appleboy/ssh-action@master
with:
host: ${{secrets.HOST}}
username: ${{secrets.USERNAME}}
key: ${{ secrets.PRIVATE_KEY }}
port: 22 # SSH 포트 (기본값)
envs: GITHUB_SHA
script: |
# 이 스크립트는 Production 서버에서 실행됩니다.
echo "Deploying to Production Server..."
# 환경 변수 설정
export DOCKER_REPOSITORY=${{ secrets.DOCKER_REPOSITORY }}
export DOCKER_TAG=prod
export SPRING_PROFILES_ACTIVE=prod
export DB_USERNAME=${{ secrets.DB_USERNAME }}
export DB_PASSWORD=${{ secrets.DB_PASSWORD }}
export JWT_SECRET=${{ secrets.JWT_SECRET }}
export OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }}
export OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }}
export SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}
export APP_DATA_PATH=/mnt/data/LegalDongCode_List.txt:/app/data/LegalDongCode_List.txt
# 기존 컨테이너 중지 및 삭제 (오류 발생 시 무시하도록 '|| true' 추가)
sudo docker stop springboot || true
sudo docker rm -f springboot || true
echo "Pulling Docker image: $DOCKER_REPOSITORY:$DOCKER_TAG"
sudo docker pull $DOCKER_REPOSITORY:$DOCKER_TAG
echo "Running new Docker container..."
sudo docker run -d --net=host --name springboot \\
-v $APP_DATA_PATH \\
-e TZ=Asia/Seoul \\
-e SPRING_PROFILES_ACTIVE=$SPRING_PROFILES_ACTIVE \\
-e DB_USERNAME=$DB_USERNAME \\
-e DB_PASSWORD=$DB_PASSWORD \\
-e JWT_SECRET=$JWT_SECRET \\
-e OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID \\
-e OAUTH_CLIENT_SECRET=$OAUTH_CLIENT_SECRET \\
-e SLACK_WEBHOOK_URL=$SLACK_WEBHOOK_URL \\
$DOCKER_REPOSITORY:$DOCKER_TAG
echo "Pruning old Docker images..."
sudo docker image prune -f
echo "Deployment to Production completed."
워크플로 내용은 `deploy-staging.yml`과 큰 차이가 없기 때문에 설명은 넘어간다.