Repository navigation
297 lines (271 loc) · 14.7 KB
/
Copy pathdeploy.yml
File metadata and controls
297 lines (271 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
name: Deploy to EC2 (prod)
on:
push:
branches: [ main ]
workflow_dispatch:
# 동시에 여러 배포가 겹치지 않도록 직렬화
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17 (temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# 테스트 포함 빌드: 70개 단위/슬라이스 테스트는 DB/Redis 불필요 → 배포 전 안전망으로 실행
- name: Build with Gradle (with tests)
run: ./gradlew clean build
- name: Prepare deploy jar
run: cp "$(ls build/libs/*.jar | grep -v plain | head -n 1)" deploy.jar
- name: Copy jar to EC2 (SCP)
uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
source: deploy.jar
target: /home/ubuntu/app
- name: Copy pending database migrations to EC2 (SCP)
uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
source: src/main/resources/db/migration/V20260819_08__create_content_inquiry.sql,src/main/resources/db/migration/V20260819_09__create_interview_prep_question.sql
target: /home/ubuntu/app
- name: Deploy on EC2 (SSH) + health check + rollback
uses: appleboy/[email protected]
env:
DB_URL: ${{ secrets.DB_URL }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
FRONTEND_ORIGIN: ${{ secrets.FRONTEND_ORIGIN }}
KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}
KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}
KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }}
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }}
NAVER_REDIRECT_URI: ${{ secrets.NAVER_REDIRECT_URI }}
NAVER_SEARCH_CLIENT_ID: ${{ secrets.NAVER_SEARCH_CLIENT_ID }}
NAVER_SEARCH_CLIENT_SECRET: ${{ secrets.NAVER_SEARCH_CLIENT_SECRET }}
SCHOLARSHIP_API_SERVICEKEY: ${{ secrets.SCHOLARSHIP_API_SERVICEKEY }}
ACADEMIC_INFO_API_SERVICE_KEY: ${{ secrets.ACADEMIC_INFO_API_SERVICE_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: DB_URL,DB_USERNAME,DB_PASSWORD,JWT_SECRET,MAIL_USERNAME,MAIL_PASSWORD,MAIL_FROM,FRONTEND_ORIGIN,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URI,NAVER_CLIENT_ID,NAVER_CLIENT_SECRET,NAVER_REDIRECT_URI,NAVER_SEARCH_CLIENT_ID,NAVER_SEARCH_CLIENT_SECRET,SCHOLARSHIP_API_SERVICEKEY,ACADEMIC_INFO_API_SERVICE_KEY,ANTHROPIC_API_KEY
script: |
set -e
APP_DIR=/home/ubuntu/app
ENV_DIR=/etc/wishconnect
MIGRATION_DIR="$APP_DIR/src/main/resources/db/migration"
# 1) 시크릿을 EnvironmentFile 로 기록 (root:600, 앱 실행 시 systemd 가 주입)
sudo mkdir -p "$ENV_DIR"
sudo tee "$ENV_DIR/wishconnect.env" > /dev/null <<EOF
SPRING_PROFILES_ACTIVE=prod
SWAGGER_ENABLED=false
DB_URL=${DB_URL}
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
JWT_SECRET=${JWT_SECRET}
MAIL_USERNAME=${MAIL_USERNAME}
MAIL_PASSWORD=${MAIL_PASSWORD}
MAIL_FROM=${MAIL_FROM}
FRONTEND_ORIGIN=${FRONTEND_ORIGIN}
KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID}
KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
KAKAO_REDIRECT_URI=${KAKAO_REDIRECT_URI}
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI}
NAVER_CLIENT_ID=${NAVER_CLIENT_ID}
NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET}
NAVER_REDIRECT_URI=${NAVER_REDIRECT_URI}
NAVER_SEARCH_CLIENT_ID=${NAVER_SEARCH_CLIENT_ID}
NAVER_SEARCH_CLIENT_SECRET=${NAVER_SEARCH_CLIENT_SECRET}
SCHOLARSHIP_API_SERVICEKEY=${SCHOLARSHIP_API_SERVICEKEY}
ACADEMIC_INFO_API_SERVICE_KEY=${ACADEMIC_INFO_API_SERVICE_KEY}
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
EOF
sudo chmod 600 "$ENV_DIR/wishconnect.env"
# 2) ddl-auto=validate 전에 신규 테이블을 먼저 만든다.
# 이 SQL은 CREATE IF NOT EXISTS만 사용해 재배포해도 안전하다.
if ! command -v psql > /dev/null 2>&1; then
echo "❌ psql is required to apply database migrations"
exit 1
fi
PSQL_URL="${DB_URL#jdbc:}"
for migration in \
V20260819_08__create_content_inquiry.sql \
V20260819_09__create_interview_prep_question.sql
do
MIGRATION_FILE="$MIGRATION_DIR/$migration"
if [ ! -f "$MIGRATION_FILE" ]; then
echo "❌ Migration file not found: $MIGRATION_FILE"
exit 1
fi
PGPASSWORD="$DB_PASSWORD" psql "$PSQL_URL" -U "$DB_USERNAME" \
-v ON_ERROR_STOP=1 -f "$MIGRATION_FILE"
echo "✅ Database migration applied: $migration"
done
# 3) jar 교체 (현재 버전 백업 유지 → 롤백용)
mkdir -p "$APP_DIR/logs"
if [ -f "$APP_DIR/app.jar" ]; then cp "$APP_DIR/app.jar" "$APP_DIR/app.jar.bak"; fi
mv "$APP_DIR/deploy.jar" "$APP_DIR/app.jar"
# 4) 재시작. 이전 연속 실패로 start-limit에 걸렸을 수 있어 먼저 해제한다.
sudo systemctl reset-failed wishconnect || true
sudo systemctl restart wishconnect
# 5) 헬스체크 (최대 ~180초 대기)
# 기동 시간이 기능이 늘면서 계속 길어졌다. 2026-08-17 실측 58초인데 한계가 60초라
# 멀쩡한 배포가 타임아웃으로 롤백됐다(run 32006812629). 여유를 3배로 둔다.
# t3.small 2 vCPU 에서 컨텍스트 로딩만 17초, 전체 58초.
echo "Health checking /actuator/health ..."
for i in $(seq 1 36); do
if curl -fsS http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then
echo "✅ Deploy healthy"
exit 0
fi
echo " ...attempt $i not ready, retrying in 5s"
sleep 5
done
# 6) 헬스체크 실패 → 실패 원인을 먼저 남기고 이전 jar 로 롤백
echo "❌ Health check failed — rolling back to previous jar"
echo "----- failed deployment logs -----"
sudo journalctl -u wishconnect -n 120 --no-pager || true
if [ -f "$APP_DIR/app.jar.bak" ]; then
mv "$APP_DIR/app.jar.bak" "$APP_DIR/app.jar"
sudo systemctl reset-failed wishconnect || true
sudo systemctl restart wishconnect || true
fi
echo "----- rollback logs -----"
sudo journalctl -u wishconnect -n 80 --no-pager || true
exit 1
# ── Nginx 설정 반영 ─────────────────────────────────────────────────────
# 앱 배포 단계(헬스체크·롤백 포함)가 성공한 뒤에만 실행된다(기본 if: success()).
# 앱 롤백 로직은 위 단계 안에서만 동작하고 이미 exit 0 으로 끝났으므로,
# 여기서 실패해도 앱은 롤백되지 않는다. Nginx 만 백업으로 되돌리고 워크플로를 실패 처리한다.
# 반대로 앱 배포가 실패하면 이 단계는 건너뛴다(한 번에 두 가지를 바꾸지 않는다).
- name: Copy Nginx config to EC2 (SCP)
uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
source: deploy/nginx/wishconnect.conf
target: /home/ubuntu/app/nginx
strip_components: 2
- name: Apply Nginx config (backup + nginx -t + reload + smoke test + restore)
uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
set -u
DOMAIN=api.wish-connect.com
STAGED=/home/ubuntu/app/nginx/wishconnect.conf
TARGET=/etc/nginx/sites-available/wishconnect
ENABLED=/etc/nginx/sites-enabled/wishconnect
# sites-enabled·conf.d 밖에 둔다. 안에 두면 nginx 가 백업까지 읽어 server 가 중복된다.
BACKUP_DIR=/etc/nginx/wishconnect-backup
BACKUP="$BACKUP_DIR/wishconnect.$(date +%Y%m%d%H%M%S)"
# 0) 전제 조건. 여기서 실패하면 서버는 아무것도 바뀌지 않는다.
if ! sudo -n true 2>/dev/null; then
echo "❌ sudo 가 비밀번호를 요구한다. deploy/README.md 'Nginx 전제 조건' 참고"
exit 1
fi
if [ ! -f "$STAGED" ]; then
echo "❌ 업로드된 설정이 없다: $STAGED"
exit 1
fi
if [ ! -L "$ENABLED" ] || [ "$(readlink -f "$ENABLED")" != "$TARGET" ] || [ ! -f "$TARGET" ]; then
echo "❌ $ENABLED 가 $TARGET 을 가리키는 심볼릭 링크가 아니다."
echo " 최초 1회 이전 작업이 필요하다(deploy/README.md 'Nginx 최초 이전')."
exit 1
fi
if sudo cmp -s "$STAGED" "$TARGET"; then
echo "✅ Nginx 설정 변경 없음 — 건너뜀"
exit 0
fi
# 1) 백업 (최근 10개만 유지)
sudo mkdir -p "$BACKUP_DIR"
if ! sudo cp -a "$TARGET" "$BACKUP"; then
echo "❌ 백업 실패 — 교체하지 않음"
exit 1
fi
ls -1t "$BACKUP_DIR" | tail -n +11 | while read -r old; do sudo rm -f "$BACKUP_DIR/$old"; done
echo "백업: $BACKUP"
# 실패 시 공통 복구: 파일을 되돌리고, 되돌린 설정이 문법 검사를 통과할 때만 reload 한다.
# (nginx -t 단계에서 실패했다면 reload 전이라 실행 중인 Nginx 는 애초에 바뀌지 않았다)
restore_and_fail() {
echo "❌ $1 — 백업으로 복구한다"
sudo cp -a "$BACKUP" "$TARGET"
if sudo nginx -t; then
sudo systemctl reload nginx && echo "↩️ 복구 완료(이전 설정으로 reload)"
else
echo "‼️ 백업 설정도 nginx -t 를 통과하지 못한다. reload 하지 않았다(실행 중 설정은 유지). 서버 확인 필요"
fi
exit 1
}
# 2) 교체
sudo install -m 644 -o root -g root "$STAGED" "$TARGET" || restore_and_fail "설정 파일 교체 실패"
# 3) 문법 검사. 경고 중 'conflicting server name' 은 같은 도메인 server 블록이 다른 파일에도
# 남아 있다는 뜻이고, 그러면 nginx 는 한쪽을 조용히 무시한다 → 실패로 본다.
TEST_OUT=$(sudo nginx -t 2>&1); TEST_RC=$?
echo "$TEST_OUT"
[ "$TEST_RC" -eq 0 ] || restore_and_fail "nginx -t 실패"
if echo "$TEST_OUT" | grep -q "conflicting server name"; then
restore_and_fail "server_name 충돌(같은 도메인 server 블록이 다른 파일에도 있음)"
fi
# 4) 통과했을 때만 reload
sudo systemctl reload nginx || restore_and_fail "nginx reload 실패"
sleep 2
# 5) Nginx 를 거쳐 실제로 확인 (인증서 검증 포함, 127.0.0.1 로 고정해 외부 DNS 영향 배제)
code() {
curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
--resolve "$DOMAIN:443:127.0.0.1" "$@"
}
check() {
local want="$1"; shift
local got; got=$(code "$@")
echo " $* → $got (기대 $want)"
[ "$got" = "$want" ]
}
echo "Nginx smoke test ..."
FAIL=0
curl -fsS --max-time 10 --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/actuator/health" \
| grep -q '"status":"UP"' || { echo " health via nginx → not UP"; FAIL=1; }
check 302 "https://$DOMAIN/admin/console" || FAIL=1 # 비로그인 → 로그인 화면으로
check 200 "https://$DOMAIN/admin/login.html" || FAIL=1
check 404 "https://$DOMAIN/admin/index.html" || FAIL=1
check 404 "https://$DOMAIN/swagger-ui.html" || FAIL=1
check 404 "https://$DOMAIN/swagger-ui/index.html" || FAIL=1
check 404 "https://$DOMAIN/v3/api-docs" || FAIL=1
check 301 "http://$DOMAIN/actuator/health" --resolve "$DOMAIN:80:127.0.0.1" || FAIL=1
# 2MB 본문이 Nginx 에서 413 으로 막히지 않아야 한다(앱이 받아 401/405 등으로 응답하면 정상)
BODY_CODE=$(head -c 2000000 /dev/zero | code -X POST --data-binary @- "https://$DOMAIN/actuator/health")
echo " POST 2MB → $BODY_CODE (413 이 아니어야 함)"
[ "$BODY_CODE" != "413" ] && [ "$BODY_CODE" != "000" ] || FAIL=1
SERVER_HDR=$(curl -sI --max-time 10 --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/actuator/health" \
| tr -d '\r' | awk -F': ' 'tolower($1)=="server"{print $2}')
echo " Server 헤더 → '$SERVER_HDR'"
[ "$SERVER_HDR" = "nginx" ] || FAIL=1
[ "$FAIL" -eq 0 ] || restore_and_fail "Nginx 점검 실패"
echo "✅ Nginx 설정 반영 완료"