Skip to content

Commit 1db6cdc

Browse files
authored
Merge branch 'main' into feat/be/85
2 parents 680e3e3 + c025b42 commit 1db6cdc

File tree

22 files changed

+855
-25
lines changed

22 files changed

+855
-25
lines changed

.github/workflows/deploy.yml

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

Dockerfile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# 첫 번째 스테이지: 빌드 스테이지
2+
FROM gradle:jdk-21-and-23-graal-jammy AS builder
3+
4+
# 작업 디렉토리 설정
5+
WORKDIR /app
6+
7+
# 소스 코드와 Gradle 래퍼 복사
8+
COPY build.gradle.kts .
9+
COPY settings.gradle.kts .
10+
COPY src/main/resources/*.yml src/main/resources/
11+
12+
13+
# 종속성 설치
14+
RUN gradle dependencies --no-daemon
15+
16+
# 소스 코드 복사
17+
COPY .env .
18+
COPY src src
19+
20+
# 애플리케이션 빌드 (테스트 및 ktlint 스킵)
21+
RUN gradle build -x test -x ktlintTestSourceSetCheck -x ktlintMainSourceSetCheck -x ktlintKotlinScriptCheck --no-daemon
22+
23+
# 두 번째 스테이지: 실행 스테이지
24+
FROM container-registry.oracle.com/graalvm/jdk:21
25+
26+
# 작업 디렉토리 설정
27+
WORKDIR /app
28+
29+
# 첫 번째 스테이지에서 빌드된 JAR 파일 복사
30+
COPY --from=builder /app/build/libs/*.jar app.jar
31+
COPY --from=builder /app/.env .env
32+
33+
# 실행할 JAR 파일 지정
34+
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]

infra/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.terraform
2+
.terraform.lock.hcl
3+
terraform.tfstate
4+
terraform.tfstate.backup
5+
.terraform.tfstate.lock.info
6+
secrets.tf

0 commit comments

Comments
 (0)