Skip to content

Commit 5456851

Browse files
authored
FEAT: CD 기능 추가 (#26)
* FEAT: Dockerfile, .dockerignore 추가 * FEAT: Backend-CD 파일 초안 추가 * FIX: CI 수정 - JDK distribution 변경 (graalvm -> corretto) - 빌드 및 테스트 분리 * fix: Backend CD 수정 - Image repository 명 변경 - 동시 실행 중 좀 더 명확한 그룹 네이밍으로 수정 - 불필요한 레지스트리 로그인 삭제 - 패키지 배포 대상 개인 -> 조직 변경 * fix: Backend CI 수정 - 동시 실행 중 좀 더 명확한 그룹 네이밍으로 수정 * fix: Backend CI 수정 - Gradle 권한 오류 수정 * fix: Backend CD 수정 - 명확한 OWNER_LC 사용
1 parent 14004ed commit 5456851

File tree

4 files changed

+421
-11
lines changed

4 files changed

+421
-11
lines changed

.github/workflows/Backend-CD.yml

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# 마지막 수정: 250924
2+
# 작성자: gooraeng
3+
4+
# BEFORE YOU READ..
5+
# 아래의 사항은 250924 기준으로 변경사항 발생 시 수정될 수 있습니다.
6+
#
7+
# 1. 초기 CD는 패키지 생성 확인 목적으로 작성되어 항상 실패 할 예정입니다.
8+
# 2. 패키지 관리
9+
# (1) 일관적 관리를 위해 조직의 리포지토리로 설정했습니다.
10+
# (2) 생성된 Package는 env 와 같은 환경 변수가 담겨있지 않을 예정이므로 현재 public 공개를 검토 중입니다.
11+
# * Private 결정 시 관리는 어떻게 할 것인가
12+
# 3. AWS SSM Send-Command 액션
13+
# (1) 스크립트 전송이나 인증만 잘 되면 스크립트 내용에 상관없이 Action은 성공으로 간주됩니다.
14+
# 내부적으로 EC2 상에서만 실패로 간주됩니다.
15+
# (2) 따라서, 실제 성공 여부는 EC2 내부에서 별도로 생성되는 배포 과정을 담은 로그 파일에서 확인해야 합니다.
16+
17+
name: deploy
18+
19+
env:
20+
IMAGE_REPOSITORY: onetop-relife-be # GHCR 이미지 리포지토리명(소유자 포함 X)
21+
CONTAINER_1_NAME: relife_1 # 슬롯1(고정 이름)
22+
CONTAINER_2_NAME: relife_2 # 슬롯2(고정 이름)
23+
CONTAINER_PORT: 8080 # 컨테이너 내부 포트(스프링부트)
24+
EC2_INSTANCE_TAG_NAME: relife-ec2-1 # 배포 대상 EC2 Name 태그
25+
DOCKER_NETWORK: common # 도커 네트워크
26+
BACKEND_DIR: back # Dockerfile 위치
27+
28+
concurrency:
29+
# 같은 브랜치에서 여러 커밋이 몰려도 동일한 그룹으로 관리
30+
group: relife-backend-cd-${{ github.workflow }}-${{ github.ref }}
31+
# 기존 작업은 새 커밋이 들어오더라도 바로 종료하지 않고 끝날 때 까지 대기
32+
# 예) tag는 있는데 package가 없는 상황
33+
cancel-in-progress: false
34+
35+
on:
36+
push:
37+
paths: [
38+
".github/workflows/**",
39+
"back/src/**",
40+
"back/build.gradle.kts",
41+
"back/settings.gradle.kts",
42+
"back/Dockerfile"
43+
]
44+
# TODO: 향후 Git flow 전략 적용 시 논의 필요
45+
branches:
46+
- main
47+
48+
# 권한 최소화/명시화
49+
permissions:
50+
contents: write # 태그/릴리즈
51+
packages: write # GHCR 푸시
52+
53+
# 기본 Shell
54+
defaults:
55+
run:
56+
shell: bash
57+
58+
jobs:
59+
# ---------------------------------------------------------
60+
# 1) 태그/릴리즈 생성
61+
# ---------------------------------------------------------
62+
createTagAndRelease:
63+
runs-on: ubuntu-latest
64+
outputs:
65+
tag_name: ${{ steps.create_tag.outputs.new_tag }} # 이후 잡에서 사용할 태그명
66+
steps:
67+
- uses: actions/checkout@v4
68+
69+
# 버전 태그 자동 생성 (vX.Y.Z)
70+
- name: Create Tag
71+
id: create_tag
72+
uses: mathieudutour/[email protected]
73+
with:
74+
github_token: ${{ secrets.GITHUB_TOKEN }}
75+
76+
# 릴리즈 생성
77+
- name: Create Release
78+
id: create_release
79+
uses: actions/create-release@v1
80+
env:
81+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82+
with:
83+
tag_name: ${{ steps.create_tag.outputs.new_tag }}
84+
release_name: Release ${{ steps.create_tag.outputs.new_tag }}
85+
body: ${{ steps.create_tag.outputs.changelog }}
86+
draft: false
87+
prerelease: false
88+
89+
# ---------------------------------------------------------
90+
# 2) 도커 이미지 빌드/푸시 (캐시 최대 활용)
91+
# ---------------------------------------------------------
92+
buildImageAndPush:
93+
name: 도커 이미지 빌드와 푸시
94+
needs: createTagAndRelease
95+
runs-on: ubuntu-latest
96+
steps:
97+
- uses: actions/checkout@v4
98+
99+
- name: Docker Buildx 설치
100+
uses: docker/setup-buildx-action@v3
101+
102+
# 저장소 소유자명을 소문자로 (GHCR 경로 표준화)
103+
- name: set lower case owner name
104+
run: |
105+
echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}"
106+
env:
107+
OWNER: ${{ github.repository_owner }}
108+
109+
# 캐시를 최대한 활용하여 빌드 → 버전태그 및 latest 동시 푸시
110+
- name: 빌드 앤 푸시
111+
uses: docker/build-push-action@v6
112+
with:
113+
context: ${{ env.BACKEND_DIR }}
114+
push: true
115+
cache-from: type=gha
116+
cache-to: type=gha,mode=max
117+
tags: |
118+
ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:${{ needs.createTagAndRelease.outputs.tag_name }}
119+
ghcr.io/${{ env.OWNER_LC }}/${{ env.IMAGE_REPOSITORY }}:latest
120+
121+
# ---------------------------------------------------------
122+
# 3) Blue/Green 무중단 배포 (EC2 + NPM 스위치)
123+
# ---------------------------------------------------------
124+
deploy:
125+
name: Blue/Green 무중단 배포
126+
runs-on: ubuntu-latest
127+
needs: [createTagAndRelease, buildImageAndPush]
128+
steps:
129+
# AWS 자격 구성
130+
- uses: aws-actions/configure-aws-credentials@v4
131+
with:
132+
aws-region: ${{ secrets.AWS_REGION }}
133+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
134+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
135+
136+
# Name 태그로 EC2 인스턴스 조회 (없으면 실패)
137+
- name: 인스턴스 ID 가져오기
138+
id: get_instance_id
139+
run: |
140+
INSTANCE_ID=$(aws ec2 describe-instances \
141+
--filters "Name=tag:Name,Values=${{ env.EC2_INSTANCE_TAG_NAME }}" "Name=instance-state-name,Values=running" \
142+
--query "Reservations[].Instances[].InstanceId" --output text)
143+
[[ -n "${INSTANCE_ID}" && "${INSTANCE_ID}" != "None" ]] || { echo "No running instance found"; exit 1; }
144+
echo "INSTANCE_ID=${INSTANCE_ID}" >> "${GITHUB_ENV}"
145+
146+
# 원격(SSM)으로 Blue/Green 스위치 수행
147+
- name: AWS 배포 실행
148+
uses: peterkimzz/aws-ssm-send-command@master
149+
env:
150+
DOT_ENV_CONTENT: ${{ secrets.DOT_ENV }}
151+
NGINX_ADMIN_EMAIL: ${{ secrets.NGNIX_ADMIN_EMAIL }}
152+
with:
153+
aws-region: ${{ secrets.AWS_REGION }}
154+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
155+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
156+
instance-ids: ${{ env.INSTANCE_ID }}
157+
working-directory: /
158+
comment: Deploy
159+
command: |
160+
set -Eeuo pipefail
161+
162+
# ---------------------------------------------------------
163+
# 0) 실행 로그(라인 타임스탬프 부착)
164+
# ---------------------------------------------------------
165+
LOG="/tmp/ssm-$(date +%Y%m%d_%H%M%S).log"
166+
exec > >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG")
167+
exec 2> >(awk '{ fflush(); print strftime("[%Y-%m-%d %H:%M:%S]"), $0 }' | tee -a "$LOG" >&2)
168+
169+
# ---------------------------------------------------------
170+
# 1) 변수 정의
171+
# - 슬롯 이름은 고정(두 개의 컨테이너 이름)
172+
# - blue/green은 "역할"이며 매 배포마다 바뀜
173+
# ---------------------------------------------------------
174+
source /etc/environment || true # 시스템 전역 변수(+ 비밀) 주입 시도
175+
OWNER_LC="${{ github.repository_owner }}"
176+
OWNER_LC="${OWNER_LC,,}"
177+
IMAGE_TAG="${{ needs.createTagAndRelease.outputs.tag_name }}"
178+
IMAGE_REPOSITORY="${{ env.IMAGE_REPOSITORY }}"
179+
IMAGE="ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:${IMAGE_TAG}"
180+
SLOT1="${{ env.CONTAINER_1_NAME }}"
181+
SLOT2="${{ env.CONTAINER_2_NAME }}"
182+
PORT_IN="${{ env.CONTAINER_PORT }}"
183+
NET="${{ env.DOCKER_NETWORK }}"
184+
ENV_FILE="/tmp/relife.env"
185+
186+
echo "🔹 Use image: ${IMAGE}"
187+
if ! docker pull "${IMAGE}"; then
188+
echo "❌ Failed to pull image: ${IMAGE}"
189+
exit 1
190+
fi
191+
echo "✅ Successfully pulled image."
192+
193+
# ---------------------------------------------------------
194+
# GitHub Secret으로 임시 .env 파일 생성
195+
# ---------------------------------------------------------
196+
echo "🔐 Creating .env file from GitHub Secret..."
197+
# GitHub Actions에서 secrets.DOT_ENV 값을 받아와 파일로 쓴다.
198+
# 특수문자 문제를 피하기 위해 printf와 히어독(here-doc)을 사용한다.
199+
printf '%s' "${{ env.DOT_ENV_CONTENT }}" > "${ENV_FILE}"
200+
201+
if [[ ! -s "${ENV_FILE}" ]]; then
202+
echo "❌ Failed to create env file from secret or it is empty."
203+
exit 1
204+
fi
205+
echo "✅ Successfully created env file."
206+
207+
# ---------------------------------------------------------
208+
# 2) Nginx Proxy Manager(NPM) API 토큰 발급
209+
# - /etc/environment 등에 설정된 PASSWORD_1, APP_BACK_DOMAIN 사용 가정
210+
# ---------------------------------------------------------
211+
TOKEN=$(curl -s -X POST http://127.0.0.1:81/api/tokens \
212+
-H "Content-Type: application/json" \
213+
-d "{\"identity\": \"${{ env.NGINX_ADMIN_EMAIL }}\", \"secret\": \"${PASSWORD_1}\"}" | jq -r '.token')
214+
215+
# 토큰/도메인 검증(없으면 실패)
216+
[[ -n "${TOKEN}" && "${TOKEN}" != "null" ]] || { echo "NPM token issue failed"; exit 1; }
217+
[[ -n "${APP_BACK_DOMAIN:-}" ]] || { echo "APP_BACK_DOMAIN is empty"; exit 1; }
218+
219+
# 대상 프록시 호스트 ID 조회(도메인 매칭)
220+
PROXY_ID=$(curl -s -X GET "http://127.0.0.1:81/api/nginx/proxy-hosts" \
221+
-H "Authorization: Bearer ${TOKEN}" \
222+
| jq ".[] | select(.domain_names[]==\"${APP_BACK_DOMAIN}\") | .id")
223+
224+
[[ -n "${PROXY_ID}" && "${PROXY_ID}" != "null" ]] || { echo "Proxy host not found for ${APP_BACK_DOMAIN}"; exit 1; }
225+
226+
# 현재 프록시가 바라보는 업스트림(컨테이너명) 조회
227+
CURRENT_HOST=$(curl -s -X GET "http://127.0.0.1:81/api/nginx/proxy-hosts/${PROXY_ID}" \
228+
-H "Authorization: Bearer ${TOKEN}" \
229+
| jq -r '.forward_host')
230+
231+
echo "🔎 CURRENT_HOST: ${CURRENT_HOST:-none}"
232+
233+
# ---------------------------------------------------------
234+
# 3) 역할(blue/green) 판정
235+
# - blue : 현재 운영 중(CURRENT_HOST)
236+
# - green: 다음 운영(교체 대상)
237+
# ---------------------------------------------------------
238+
if [[ "${CURRENT_HOST:-}" == "${SLOT1}" ]]; then
239+
BLUE="${SLOT1}"
240+
GREEN="${SLOT2}"
241+
elif [[ "${CURRENT_HOST:-}" == "${SLOT2}" ]]; then
242+
BLUE="${SLOT2}"
243+
GREEN="${SLOT1}"
244+
else
245+
BLUE="none" # 초기 배포
246+
GREEN="${SLOT1}"
247+
fi
248+
echo "🎨 role -> blue(now): ${BLUE}, green(next): ${GREEN}"
249+
250+
# ---------------------------------------------------------
251+
# 4) green 역할 컨테이너 재기동
252+
# - 같은 네트워크 상에서 NPM이 컨테이너명:PORT로 프록시하므로 -p 불필요
253+
# ---------------------------------------------------------
254+
docker rm -f "${GREEN}" >/dev/null 2>&1 || true
255+
echo "🚀 run new container → ${GREEN}"
256+
docker run -d --name "${GREEN}" \
257+
--restart unless-stopped \
258+
--network "${NET}" \
259+
--env-file "${ENV_FILE}" \
260+
-e TZ=Asia/Seoul \
261+
"${IMAGE}"
262+
263+
# ---------------------------------------------------------
264+
# 5) 헬스체크 (/actuator/health 200 OK까지 대기)
265+
# ---------------------------------------------------------
266+
echo "⏱ health-check: ${GREEN}"
267+
TIMEOUT=120
268+
INTERVAL=3
269+
ELAPSED=0
270+
sleep 8 # 초기 부팅 여유
271+
272+
while (( ELAPSED < TIMEOUT )); do
273+
CODE=$(docker exec "${GREEN}" curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT_IN}/actuator/health" || echo 000)
274+
if [[ "${CODE}" == "200" ]]; then
275+
echo "✅ ${GREEN} is healthy"
276+
break
277+
fi
278+
279+
echo "⏳ waiting for container... (${ELAPSED}/${TIMEOUT}s)"
280+
sleep "${INTERVAL}"
281+
ELAPSED=$((ELAPSED + INTERVAL))
282+
done
283+
284+
if [[ "${CODE:-000}" != "200" ]]; then
285+
echo "❌ ${GREEN} health failed"
286+
docker logs --tail=200 "${GREEN}" || true
287+
docker rm -f "${GREEN}" || true
288+
exit 1;
289+
fi
290+
291+
# ---------------------------------------------------------
292+
# 6) 업스트림 전환 (forward_host/forward_port만 업데이트)
293+
# ---------------------------------------------------------
294+
NEW_CFG=$(jq -n --arg host "${GREEN}" --argjson port ${PORT_IN} '{forward_host:$host, forward_port:$port}')
295+
296+
HTTP_CODE=$(curl -s -w "%{http_code}" -o /dev/null \
297+
-X PUT "http://127.0.0.1:81/api/nginx/proxy-hosts/${PROXY_ID}" \
298+
-H "Authorization: Bearer ${TOKEN}" \
299+
-H "Content-Type: application/json" \
300+
-d "${NEW_CFG}")
301+
302+
if [[ "${HTTP_CODE}" -eq 200 || "${HTTP_CODE}" -eq 201 ]]; then
303+
echo "✅ Upstream switch successful with status code ${HTTP_CODE}."
304+
else
305+
echo "❌ Failed to switch upstream! Received HTTP status code: ${HTTP_CODE}"
306+
# [수정] 전환 실패 시, 새로 띄운 Green 컨테이너는 즉시 제거하고 실패 처리
307+
docker logs --tail=200 "${GREEN}" || true
308+
docker rm -f "${GREEN}" || true
309+
exit 1
310+
fi
311+
312+
# ---------------------------------------------------------
313+
# 7) 이전 blue 종료(최초 배포면 생략) 및 임시 파일 삭제
314+
# ---------------------------------------------------------
315+
if [[ "${BLUE}" != "none" ]]; then
316+
docker stop "${BLUE}" >/dev/null 2>&1 || true
317+
docker rm "${BLUE}" >/dev/null 2>&1 || true
318+
echo "🧹 Removed old blue: ${BLUE}"
319+
fi
320+
321+
rm -f "${ENV_FILE}"
322+
echo "🧹 Removed temporary env file."
323+
324+
# ---------------------------------------------------------
325+
# 8) 이미지 정리(현재 태그/ latest 제외) - 결과 없음도 오류 아님
326+
# - pipefail과 grep의 종료코드(1)를 고려해 블록 전체에 || true 적용
327+
# ---------------------------------------------------------
328+
{
329+
docker images --format '{{.Repository}}:{{.Tag}}' \
330+
| grep -F "ghcr.io/${OWNER_LC}/${IMAGE_REPOSITORY}:" \
331+
| grep -v -F ":${IMAGE_TAG}" \
332+
| grep -v -F ":latest" \
333+
| xargs -r docker rmi
334+
} || true
335+
336+
echo "🏁 Blue/Green switch complete. now blue is ${GREEN}"

.github/workflows/Backend-CI.yml

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# 마지막 수정: 250924
2+
# Backend CI Workflow 입니다.
3+
14
name: Backend PR CI
25

36
env:
@@ -29,7 +32,7 @@ on:
2932

3033
# 동일 브랜치에서 연속 실행 방지
3134
concurrency:
32-
group: backend-ci-${{ github.ref }}-${{ github.event.pull_request.number }}
35+
group: relife-backend-ci-${{ github.ref }}-${{ github.event.pull_request.number }}
3336
cancel-in-progress: true
3437

3538
permissions:
@@ -54,34 +57,34 @@ jobs:
5457
uses: actions/checkout@v4
5558

5659
# JDK 설정
57-
# graalvm 21 사용
60+
# Graalvm 21 사용 (Docker 및 개발 환경 일치)
5861
- name: Set up JDK 21
5962
uses: actions/setup-java@v5
6063
with:
6164
distribution: "graalvm"
6265
java-version: "21"
6366
cache: "gradle"
6467

65-
# Gradle 캐싱
66-
- name: Set up Gradle
67-
uses: gradle/actions/setup-gradle@v4
68-
6968
# Gradle 실행 권한 부여
7069
- name: Grant execute permission for gradlew
7170
run: chmod +x gradlew
7271

7372
# Secret으로 부터 .env 생성
74-
- name: Generate .env
73+
- name: Generate .env from Secret
7574
run: |
7675
printf "%s" "${{ secrets.DOT_ENV }}" > .env
7776
78-
# Gradle 빌드 및 테스트 진행
79-
- name: Execute Build and Tests
80-
run: ./gradlew clean build test --no-daemon --warning-mode=all
77+
# Gradle 빌드 및 테스트
78+
# 빌드 / 테스트 분리
79+
- name: Execute Build
80+
run: ./gradlew clean build -x test --warning-mode=all
81+
82+
- name: Execute Tests
83+
run: ./gradlew test --info
8184

8285
# 테스트 실행 결과 로깅 Action
8386
- name: Generate JUnit Test Report
8487
uses: mikepenz/action-junit-report@v5
85-
if: success() || failure() # 성공 여부 상관없이 실행
88+
if: always()
8689
with:
8790
report_paths: "**/build/test-results/test/TEST-*.xml"

0 commit comments

Comments
 (0)