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}"
0 commit comments