-
Notifications
You must be signed in to change notification settings - Fork 0
837 lines (794 loc) · 57.4 KB
/
Copy pathdeploy.yml
File metadata and controls
837 lines (794 loc) · 57.4 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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
name: Deploy
on:
workflow_run:
workflows: [CI]
branches: [dev, staging, main]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "prod 릴리즈 semver 태그 (예: v1.0.1). 비우면 release-<sha7> 로 폴백. Promote 워크플로가 채운다."
required: false
type: string
jobs:
# 트리거 브랜치 → 배포 환경(dev/prod)·도메인·nginx conf 를 한 곳에서 결정한다.
# workflow_run: github.event.workflow_run.head_branch / workflow_dispatch: github.ref_name
# dev EC2 와 prod EC2 는 같은 워크플로 코드를 쓰되, deploy job 의 environment 키로 secret 이 갈린다.
resolve:
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
outputs:
environment: ${{ steps.r.outputs.environment }}
ref: ${{ steps.r.outputs.ref }}
domain: ${{ steps.r.outputs.domain }}
nginx_conf: ${{ steps.r.outputs.nginx_conf }}
branch: ${{ steps.r.outputs.branch }}
mem_limit: ${{ steps.r.outputs.mem_limit }}
mem_swap: ${{ steps.r.outputs.mem_swap }}
jvm_opts: ${{ steps.r.outputs.jvm_opts }}
steps:
- id: r
run: |
BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
REF="${{ github.event.workflow_run.head_sha || github.sha }}"
if [ "$BRANCH" = "main" ]; then
echo "environment=prod" >> "$GITHUB_OUTPUT"
echo "domain=api.piki.day" >> "$GITHUB_OUTPUT"
echo "nginx_conf=api.piki.day.conf" >> "$GITHUB_OUTPUT"
elif [ "$BRANCH" = "staging" ]; then
echo "environment=staging" >> "$GITHUB_OUTPUT"
echo "domain=staging.api.piki.day" >> "$GITHUB_OUTPUT"
echo "nginx_conf=staging.api.piki.day.conf" >> "$GITHUB_OUTPUT"
else
echo "environment=dev" >> "$GITHUB_OUTPUT"
echo "domain=dev.api.piki.day" >> "$GITHUB_OUTPUT"
echo "nginx_conf=dev.api.piki.day.conf" >> "$GITHUB_OUTPUT"
fi
# 컨테이너 메모리(cgroup)·JVM 힙 한도는 박스 크기별로 다르다 — prod(#596)와 dev(#680 동거 대비 리사이즈)는
# t4g.small(1.8G), staging 만 906M 박스라 현행을 유지한다. blue-green overlap(전환 중 잠깐 2개 동시) 기준:
# prod 2×768=1536 < 1.8G, dev 2×512 + 동거(extractor 384 + mysql·alloy·redis 약 160) ≈ 1.57G < 1.8G,
# staging 2×400=800 < 906M 안에 들어가 swap thrash 를 막는다.
# dev 576m/288m 산정(2026-07-07 실측 2회): 캡 400m(Xmx256)·512m(Xmx320) 모두 anon 이 캡을 초과해 상시
# swap-out 이었다. prod 실측(768m 캡에 실사용 720m, 힙 최대 512 제외 비힙 약 250m)으로 이 앱의 비힙을
# 약 250-300m 으로 확정 — 캡은 힙 + 300m 여유로 잡는다(576 = 288 + 288).
# 힙(-Xmx)은 컨테이너 RAM 안에 비힙(metaspace·thread stack·code cache·direct buffer) 여유를 남겨 잡는다.
# 값은 시작점 — 검증 배포에서 부팅 피크·overlap RAM 을 실측해 확정한다.
# GC 는 -XX:+UseG1GC 를 명시한다(환경 공통). JVM 은 컨테이너 메모리 한도를 가용 메모리로 인식하는데, G1 자동
# 선택 기준(2 CPU + 1792MB)에 못 미쳐 Serial GC 로 떨어진다(prod 256m 실측 확인 — Copy + MarkSweepCompact).
# Serial Full GC 는 단일 스레드 stop-the-world 라 힙이 클수록 1회 pause 가 길어져(prod 실측 평균 18s/회) 힙만
# 키우면 오히려 악화된다. G1 으로 병렬·증분 회수를 강제해 STW 를 짧게 가져간다.
if [ "$BRANCH" = "main" ]; then
echo "mem_limit=768m" >> "$GITHUB_OUTPUT"
echo "mem_swap=1536m" >> "$GITHUB_OUTPUT"
echo "jvm_opts=-Xmx512m -Xms64m -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" >> "$GITHUB_OUTPUT"
elif [ "$BRANCH" = "staging" ]; then
echo "mem_limit=400m" >> "$GITHUB_OUTPUT"
echo "mem_swap=800m" >> "$GITHUB_OUTPUT"
echo "jvm_opts=-Xmx256m -Xms64m -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" >> "$GITHUB_OUTPUT"
else
echo "mem_limit=576m" >> "$GITHUB_OUTPUT"
echo "mem_swap=1152m" >> "$GITHUB_OUTPUT"
echo "jvm_opts=-Xmx288m -Xms64m -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" >> "$GITHUB_OUTPUT"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
deploy:
needs: resolve
# arm64 네이티브 러너 — 배포 이미지가 linux/arm64(EC2 Graviton)라, x86 러너에서 QEMU 에뮬레이션으로
# 빌드하면 Dockerfile.ci 의 layertools extract(JVM 실행)가 에뮬돼 느리다. 네이티브 arm64 로 그 병목을 없앤다.
# public repo 라 GitHub-hosted arm64 러너는 무료. (QEMU setup 스텝은 이 전환으로 불필요해져 제거)
runs-on: ubuntu-24.04-arm
# GitHub Environment — secret 해석을 환경별로 가른다. dev env override / prod 는 repo 상속.
environment: ${{ needs.resolve.outputs.environment }}
permissions:
contents: read
# 자동 배포 시 트리거한 CI 실행의 JAR 아티팩트를 크로스런 다운로드하려면 actions:read 가 필요하다.
actions: read
# dev/prod 가 서로 배포를 막지 않도록 환경별 동시성 그룹. 같은 환경 내 재배포는 직렬화(취소 안 함).
# 그룹은 브랜치명이 아니라 "환경"으로 묶어야 한다 — feature 브랜치 수동배포(dispatch)도 dev EC2 로
# 가므로 dev 자동배포와 같은 그룹이어야 같은 박스에서 동시 blue-green 조작 레이스를 막는다.
concurrency:
group: deploy-${{ (github.event.workflow_run.head_branch || github.ref_name) == 'main' && 'prod' || 'dev' }}
cancel-in-progress: false
steps:
# 배포 대상(EC2_HOST)이 그 환경의 도메인이 가리키는 IP 와 다르면, 환경 secret override 가
# 잘못된 것(예: dev env EC2_HOST 누락 → repo prod 값으로 fallback). 하드코딩 IP 대신 DNS 를
# source of truth 로 동적 검증해, dev 코드가 엉뚱한(특히 prod) 서버로 나가는 걸 SSH/docker 전에 끊는다.
- name: Guard — 배포 대상이 환경 도메인 DNS 와 일치하는지
id: guard
env:
DOMAIN: ${{ needs.resolve.outputs.domain }}
TARGET: ${{ secrets.EC2_HOST }}
ENVIRONMENT: ${{ needs.resolve.outputs.environment }}
run: |
# 양쪽을 IP 로 정규화해 비교한다 — EC2_HOST 가 IP 든 hostname(EC2 public DNS 등)이든
# getent 가 IP 로 풀어주므로, 형식 차이로 인한 false-abort 없이 "같은 호스트냐"만 본다.
EXPECTED=$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1)
ACTUAL=$(getent hosts "$TARGET" | awk '{print $1}' | head -1)
echo "environment=$ENVIRONMENT domain=$DOMAIN domainIP=$EXPECTED target=$TARGET targetIP=$ACTUAL"
if [ -n "$EXPECTED" ] && [ -n "$ACTUAL" ] && [ "$EXPECTED" != "$ACTUAL" ]; then
# 실패 사유를 step output 으로 남겨 요약·Discord 알림이 정확한 원인을 표시하게 한다(exit 전 기록).
echo "reason=[$ENVIRONMENT] 배포 대상 EC2_HOST($ACTUAL) 가 $DOMAIN(→$EXPECTED) 와 다른 호스트 — 환경 secret 오설정(dev/prod 교차배선) 의심" >> "$GITHUB_OUTPUT"
echo "::error::배포 대상 EC2_HOST(${TARGET}→${ACTUAL}) 가 ${DOMAIN}(→${EXPECTED}) 와 다른 호스트 — 환경 secret 오설정 의심, 중단."
exit 1
fi
# (옛 러너측 'Discord admin secret 필수' 가드는 제거됐다 — 그 secret 들이 SSM 으로 옮겨가 러너의
# GH secrets 검사가 더는 성립하지 않기 때문. 같은 브릭 조건(admin secret 빈 값 → 도메인 잠금)은
# 아래 Deploy 스텝의 box 측 가드가 SSM pull 직후 값으로 검사한다. 전 환경 공통이라 커버리지도 넓다.
# 대가로 fail-fast 시점이 빌드 전 → 배포 시점으로 늦춰진다(안전성 동일, 피드백만 늦음).)
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.ref }}
# 배포 공용 블록(헬스체크 등)은 infra repo(TeamPiKi/infra)가 SSOT 다. main 을 checkout 해 blocks/ 를
# 받아온다. 이는 infra main 변경이 core 배포에 즉시 반영되는 결합이다 (SSOT 의도이나, infra main 이
# 바뀌면 core 재배포 없이도 다음 배포부터 새 블록이 적용됨을 인지할 것). public repo 라 기본
# GITHUB_TOKEN 으로 checkout 되어 별도 PAT 가 필요 없다.
- name: Checkout infra blocks
uses: actions/checkout@v4
with:
repository: TeamPiKi/infra
ref: main
path: piki-infra
sparse-checkout: |
blocks
sparse-checkout-cone-mode: true
# 자동 배포(workflow_run): 이 배포를 트리거한 CI 실행이 이미 빌드·업로드한 JAR 을 그대로 받아
# 러너에서의 재빌드(약 47초)를 없앤다. run-id 로 그 CI 실행에 못박아, 배포 커밋과 다른 커밋의 JAR 을
# 집는 레이스를 차단한다(그냥 '최신' 아티팩트를 집으면 배포 도중 다른 push 로 커밋 불일치가 난다).
# 자동 경로는 러너에 JDK·Gradle 이 필요 없다 — 도커 빌드의 layertools extract 는 이미지 안에서 돈다.
- name: Download JAR from triggering CI run
id: fetch_jar
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
name: app-jar
path: build/libs
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# 수동 배포(workflow_dispatch): 트리거한 CI 실행이 없어 아티팩트가 없다. 이때만 러너에서 직접 빌드한다.
- name: Set up JDK 25 (manual dispatch fallback)
if: github.event_name == 'workflow_dispatch'
uses: actions/setup-java@v4
with:
java-version: '25'
distribution: 'temurin'
- name: Cache Gradle (manual dispatch fallback)
if: github.event_name == 'workflow_dispatch'
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Build JAR (manual dispatch fallback)
id: build
if: github.event_name == 'workflow_dispatch'
run: ./gradlew bootJar --no-daemon
- name: Login to Docker Hub
id: docker_login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 러너가 arm64 네이티브(EC2 Graviton 과 동일 arch)라 크로스빌드가 불필요하다. buildx 없이 기본 docker
# 데몬으로 arm64 이미지를 네이티브 빌드해 setup-buildx(약 10초)를 없앤다. 태그·image ref 는 셸 보간
# injection 을 피해 env 로 받아 "$VAR" 로 쓴다(repo 컨벤션).
- name: Build and push Docker image
id: docker_push
env:
IMAGE_LATEST: ${{ secrets.DOCKERHUB_USERNAME }}/piki-core:latest
IMAGE_SHA: ${{ secrets.DOCKERHUB_USERNAME }}/piki-core:${{ needs.resolve.outputs.ref }}
run: |
docker build -f Dockerfile.ci -t "$IMAGE_LATEST" -t "$IMAGE_SHA" .
docker push "$IMAGE_LATEST"
docker push "$IMAGE_SHA"
# 배포에 필요한 설정 4개(nginx conf·provision 스크립트·alloy config·healthcheck 블록)를 SSH 연결 1회로
# 함께 올린다. 4개를 각각 scp 하면 핸드셰이크·액션 셋업이 4번 반복돼 약 37초인데, 소스가 모두 depth-3 라
# strip_components: 2 로 파일명만 남겨 한 스테이징 디렉토리(/tmp/piki-deploy)에 평평하게 안착시킨다.
# 다운스트림(Apply nginx·Provision·Health check·provision-runtime.sh)이 이 경로에서 각 파일을 읽는다.
# source: infra/* 는 Checkout, piki-infra/blocks/* 는 'Checkout infra blocks' 가 받아둔 것(둘 다 위에서 실행).
# 주의(줄끝 콤마는 오타 아님·필수): scp-action 은 source 를 콤마로만 쪼갠다(개행은 구분자 아님). 콤마 없이
# 개행만 두면 4줄이 한 경로로 합쳐져 glob 무매치 → 파일 0개 업로드되며 에러 없이 초록불로 넘어가 첫 배포가 깨진다.
- name: Upload deploy files
id: upload_files
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
source: |
infra/nginx/${{ needs.resolve.outputs.nginx_conf }},
infra/scripts/provision-runtime.sh,
infra/alloy/config.alloy,
piki-infra/blocks/healthcheck.sh
target: "/tmp/piki-deploy"
strip_components: 2
# TLS 인증서 보장 — cert 가 없을 때만 발급(idempotent). dev 첫 배포에서만 동작하고,
# prod 는 cert 가 이미 있어 통째로 skip(=nginx 안 멈춤, 다운타임 0).
# certonly --standalone 은 nginx 와 무관하게 :80 을 잠깐 점유해 발급하므로, SSL 을 참조하는
# nginx conf 적용(다음 스텝)보다 먼저여야 nginx -t 가 통과한다.
- name: Ensure TLS cert (certbot if missing)
id: cert
uses: appleboy/ssh-action@v1
env:
DOMAIN: ${{ needs.resolve.outputs.domain }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: DOMAIN
script: |
if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
echo "[certbot] $DOMAIN cert 이미 존재 — skip"
else
echo "[certbot] $DOMAIN cert 발급 (standalone)"
# --pre-hook/--post-hook 는 발급 시점뿐 아니라 certbot 갱신 타이머가 쓰는 renewal 설정에도
# 저장된다. standalone 은 :80 을 직접 잡으므로 nginx 와 충돌 → 90일 뒤 자동 갱신이 깨진다.
# hook 으로 갱신 시 nginx 를 잠깐 stop/start 하게 해 자동 갱신이 무인으로 성공하도록 한다.
sudo certbot certonly --standalone --non-interactive --agree-tos \
--email depromeet18.3@gmail.com -d "$DOMAIN" \
--pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"
fi
# nginx conf 가 include 하는 certbot 권장 SSL 파일은 certonly --standalone 로는 안 깔린다
# (certbot --nginx 설치 경로에서만 생성). 누락 시 nginx -t 가 깨지므로, certbot 패키지가
# 번들한 원본을 /etc/letsencrypt/ 로 복사한다(없을 때만 — prod 는 이미 있어 no-op, 동일 SSL 유지).
if [ ! -f /etc/letsencrypt/options-ssl-nginx.conf ]; then
SRC=$(find /usr -name options-ssl-nginx.conf 2>/dev/null | head -1)
[ -n "$SRC" ] && sudo cp "$SRC" /etc/letsencrypt/options-ssl-nginx.conf && echo "[certbot] options-ssl-nginx.conf 복사"
fi
if [ ! -f /etc/letsencrypt/ssl-dhparams.pem ]; then
SRC=$(find /usr -name ssl-dhparams.pem 2>/dev/null | head -1)
[ -n "$SRC" ] && sudo cp "$SRC" /etc/letsencrypt/ssl-dhparams.pem && echo "[certbot] ssl-dhparams.pem 복사"
fi
- name: Apply nginx config
id: nginx
uses: appleboy/ssh-action@v1
env:
NGINX_CONF: ${{ needs.resolve.outputs.nginx_conf }}
DOMAIN: ${{ needs.resolve.outputs.domain }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: NGINX_CONF,DOMAIN
script: |
SRC="/tmp/piki-deploy/$NGINX_CONF"
DST="/etc/nginx/sites-available/$DOMAIN"
# api conf 가 include 하는 team3-upstream.conf 가 없으면(새 인스턴스) nginx -t 가 깨진다.
# blue-green 전환 전이라도 include 대상이 존재하도록 기본값(8080)으로 초기화한다.
# 실제 active 포트 전환은 blue-green(Health check)이 담당하므로 이미 있으면 건드리지 않는다.
[ -f /etc/nginx/team3-upstream.conf ] || echo "server localhost:8080;" | sudo tee /etc/nginx/team3-upstream.conf
# 새 인스턴스(cloud-init nginx)는 stock default 사이트가 켜져 있어 catch-all 로 충돌 → 제거.
sudo rm -f /etc/nginx/sites-enabled/default
# 환경당 conf 는 $DOMAIN 하나만 enabled 여야 한다. 도메인 rename(#488 depromeet retire 등) 후
# 옛 도메인의 잔존 conf 가 sites-enabled 에 남으면 limit_req_zone 중복으로 nginx -t 가 깨진다.
# $DOMAIN·default 외의 sites-enabled 항목과 그 sites-available 원본을 정리한다(멱등 — 정상 배포엔 no-op).
for old in /etc/nginx/sites-enabled/*; do
[ -e "$old" ] || continue # sites-enabled 가 비어 glob 미확장 시 리터럴 '*' 보호
bn=$(basename "$old")
[ "$bn" = "$DOMAIN" ] && continue
[ "$bn" = "default" ] && continue
echo "[nginx] $DOMAIN 외 옛 conf 제거: $bn"
sudo rm -f "$old" "/etc/nginx/sites-available/$bn"
done
# 현재 설정 백업 후 교체. nginx -t 검증 실패 시 이전 설정으로 롤백하고 배포를 중단한다.
sudo cp "$DST" "${DST}.bak" 2>/dev/null || true
sudo cp "$SRC" "$DST"
# 사이트 활성화(idempotent) — 새 dev 인스턴스는 symlink 가 없어 명시적으로 enable.
sudo ln -sf "$DST" "/etc/nginx/sites-enabled/$DOMAIN"
if sudo nginx -t; then
sudo nginx -s reload
echo "nginx config 적용 + reload 완료"
else
echo "nginx -t 실패 — 이전 설정으로 롤백하고 중단"
sudo cp "${DST}.bak" "$DST" 2>/dev/null || true
exit 1
fi
- name: Provision runtime (swap·redis·mysql·nginx·alloy)
id: provision
uses: appleboy/ssh-action@v1
env:
# 환경은 트리거 브랜치에서 derive — dev·staging MySQL 기동/Alloy environment 라벨을 가른다.
# MySQL 초기 자격증명은 러너가 넘기지 않는다 — provision-runtime.sh 가 박스에서 SSM
# (/piki-core/<env>/db-*)으로 직접 읽는다 (앱 시크릿 SSM 단일화와 같은 결).
ENVIRONMENT: ${{ needs.resolve.outputs.environment }}
# Alloy 가 Grafana Cloud 로 remote-write 할 자격증명. 미등록(빈 값)이면 provision-runtime.sh 가
# Alloy 기동을 skip 한다. token 1개를 metrics·logs 가 공유, URL·USER 는 Prometheus/Loki 각각.
GRAFANA_METRICS_URL: ${{ secrets.GRAFANA_METRICS_URL }}
GRAFANA_METRICS_USER: ${{ secrets.GRAFANA_METRICS_USER }}
GRAFANA_LOGS_URL: ${{ secrets.GRAFANA_LOGS_URL }}
GRAFANA_LOGS_USER: ${{ secrets.GRAFANA_LOGS_USER }}
# Tempo(OTLP) 자격증명 — Alloy otelcol.exporter.otlp 가 GC Traces 로 보낼 endpoint·instance ID(#380).
# token 은 metrics·logs 와 공유(GRAFANA_CLOUD_TOKEN, traces:write scope 필요). 미등록이면 config.alloy 가
# 더미로 fallback 해 trace 만 비활성(메트릭·로그는 정상) — Alloy 기동은 GRAFANA_METRICS_URL 기준으로만 skip 된다.
GRAFANA_TRACES_URL: ${{ secrets.GRAFANA_TRACES_URL }}
GRAFANA_TRACES_USER: ${{ secrets.GRAFANA_TRACES_USER }}
GRAFANA_CLOUD_TOKEN: ${{ secrets.GRAFANA_CLOUD_TOKEN }}
# piki-extractor(파싱 분리 이관) 메트릭 scrape 대상 — Alloy(--network host)가 이 주소를 긁는다.
# dev=동거 컨테이너(localhost:8090), prod=전용 박스(secrets.EXTRACTOR_PROD_ADDRESS, 앱 SG→8090 허용). staging 은
# 빈 값 → config.alloy 가 discard 포트로 fallback(prod extractor 는 prod Alloy 가 이미 수집, 환경 라벨 오염 방지).
# prod 박스 private IP 는 secret 으로 관리한다(public repo 라 코드에 내부 주소를 박지 않는다).
EXTRACTOR_METRICS_TARGET: ${{ needs.resolve.outputs.environment == 'dev' && 'localhost:8090' || (needs.resolve.outputs.environment == 'prod' && secrets.EXTRACTOR_PROD_ADDRESS || '') }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: ENVIRONMENT,GRAFANA_METRICS_URL,GRAFANA_METRICS_USER,GRAFANA_LOGS_URL,GRAFANA_LOGS_USER,GRAFANA_TRACES_URL,GRAFANA_TRACES_USER,GRAFANA_CLOUD_TOKEN,EXTRACTOR_METRICS_TARGET
script: |
chmod +x /tmp/piki-deploy/provision-runtime.sh
/tmp/piki-deploy/provision-runtime.sh
- name: Deploy to ${{ needs.resolve.outputs.environment }}
id: deploy
uses: appleboy/ssh-action@v1
env:
# 앱 컨테이너 런타임 시크릿(DB·JWT·OAuth·Apple·S3·Firebase·Discord admin 등)은 더 이상 러너가
# SSH 로 밀어넣지 않는다 — 박스가 아래 script 에서 SSM(/piki-core/<env>/*)을 직접 pull 한다
# (GH secrets → SSM 단일화). 여기 남는 env 는 배포 시점 계산·조건값과, 컨테이너에 주입하지 않고
# 배포 기계가 쓰는 자격증명(DOCKERHUB_USERNAME)뿐이다.
# 박스가 어느 SSM 경로(/piki-core/<env>/)를 pull 할지 알려주는 환경명.
ENVIRONMENT: ${{ needs.resolve.outputs.environment }}
# Spring 프로파일 — prod·staging 에서 'prod' 활성. @Profile("!prod") 의 dev 전용 빈(DevAuth 등)이
# prod·staging 에서 안 뜨게 하는 핵심. staging 은 진짜 pre-prod 라 prod 와 동일 동작(#498). dev 만 'dev'(=!prod).
SPRING_PROFILES_ACTIVE: ${{ (needs.resolve.outputs.environment == 'prod' || needs.resolve.outputs.environment == 'staging') && 'prod' || 'dev' }}
# 운영 백오피스(#249) — Discord-IP 게이트(#526·#654)가 보호하므로 전 환경 on. 비민감 플래그라 secret 아님(inline).
ADMIN_ENABLED: 'true'
# dev/staging 도메인 전체를 allowlist IP 로만 연다(공개 차단). prod 는 false(실유저 공개, /admin 만 세션 게이트).
ENV_ACCESS_GATE: ${{ (needs.resolve.outputs.environment == 'dev' || needs.resolve.outputs.environment == 'staging') && 'true' || 'false' }}
# Discord admin 진입 게이트 secret(DISCORD_PUBLIC_KEY·DISCORD_ADMIN_USER_IDS·DISCORD_ADMIN_CHANNEL_ID)
# 과 ADMIN_GRANT_SIGNING_KEY 는 SSM 으로 옮겨 박스가 pull 한다(아래 script). 셋 다 필요(비면 /admin 항상
# 404)·서명키 전 환경 동일이라는 계약은 그대로다 — 값의 출처만 GH secrets → SSM 으로 바뀐다.
# 이 앱 환경(dev/staging/prod) — grant 토큰 env 바인딩(값 자체는 SSM 에서 주입).
ADMIN_ENVIRONMENT: ${{ needs.resolve.outputs.environment }}
# API 레퍼런스 문서(springdoc spec·Stoplight UI·루트 리다이렉트) 노출 — dev 만 노출, staging/prod 는 404.
# 전 엔드포인트·스키마가 외부에 드러나지 않게 dev 외엔 문서 자체를 끈다. 비민감 플래그라 secret 아님(inline).
SPRINGDOC_ENABLED: ${{ needs.resolve.outputs.environment == 'dev' && 'true' || 'false' }}
# 이미지명($DOCKERHUB_USERNAME/piki-core) 구성용 — 컨테이너에 -e 로 주입하지 않는 배포 기계 자격증명이라 잔류.
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ needs.resolve.outputs.ref }}
# 에러 트래킹(#608) — staging·prod 만 DSN 주입(빈값이면 SDK no-op). SENTRY_DSN secret 미설정이어도 빈값이라
# 배포는 안 깨지고 Sentry 만 꺼진다(non-breaking). dev 는 조건상 항상 빈값이라 off.
SENTRY_DSN: ${{ (needs.resolve.outputs.environment == 'prod' || needs.resolve.outputs.environment == 'staging') && secrets.SENTRY_DSN || '' }}
# staging·prod 가 같은 prod 프로파일을 공유하므로 environment 는 resolve 환경명으로 가른다(Sentry UI 필터·알림).
SENTRY_ENVIRONMENT: ${{ needs.resolve.outputs.environment }}
# Sentry release — prod(environment=prod) 이고 version 입력이 있을 때만 semver(inputs.version, 예: v1.0.1).
# 그 외(dev/staging·직접 push·version 없는 prod)는 배포 커밋 sha(resolve.ref = workflow_run.head_sha)로 폴백한다.
# environment=='prod' 를 함께 거는 이유: version 을 넣은 dev/staging 수동 dispatch 까지 semver 로 섞여 환경 추적성이
# 흐려지는 걸 막는다. release 잡의 GitHub Release 도 prod 한정이라, prod 에선 Sentry release == GitHub Release(v1.0.1)로
# 맞고 suspect commit·regression 이 그 태그 커밋으로 이어진다(GitHub 연동 승인 후). github.sha(deploy.yml HEAD)는 안 쓴다.
SENTRY_RELEASE: ${{ (needs.resolve.outputs.environment == 'prod' && inputs.version != '') && inputs.version || needs.resolve.outputs.ref }}
# 원격 추출(extractor) 주소 — 파싱(링크·이미지)의 유일 경로다 (이관 8단계에서 전환 스위치·embedded 삭제).
# base-url 은 환경별 고정 — dev=동거 컨테이너(앱 컨테이너는 --add-host host.docker.internal 로 호스트에 닿는다),
# staging·prod=extractor 전용 박스(secrets.EXTRACTOR_PROD_ADDRESS=host:port, 앱 SG→8090 인바운드 허용).
# prod 박스 private IP 는 secret 으로 관리한다(public repo 라 코드에 내부 주소를 박지 않는다). host:port 에 http:// 를 씌운다.
EXTRACT_REMOTE_BASE_URL: ${{ needs.resolve.outputs.environment == 'dev' && 'http://host.docker.internal:8090' || format('http://{0}', secrets.EXTRACTOR_PROD_ADDRESS) }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
# 앱 시크릿은 박스가 SSM 에서 pull 하므로 여기로 넘기지 않는다 — 계산·조건값과 SSM 경로용 ENVIRONMENT 만 넘긴다.
envs: ENVIRONMENT,SPRING_PROFILES_ACTIVE,ADMIN_ENABLED,ENV_ACCESS_GATE,ADMIN_ENVIRONMENT,SPRINGDOC_ENABLED,DOCKERHUB_USERNAME,IMAGE_TAG,SENTRY_DSN,SENTRY_ENVIRONMENT,SENTRY_RELEASE,EXTRACT_REMOTE_BASE_URL
script: |
# ── 앱 런타임 시크릿을 SSM Parameter Store 에서 pull 한다 (GH secrets → SSM 단일화) ──
# /piki-core/$ENVIRONMENT/<kebab-key>(SecureString) 전량을 받아 UPPER_SNAKE env 로 복원하고 아래 docker run
# 에 -e 로 주입한다. 값은 로그에 절대 남기지 않는다(set -x·echo 금지). aws-cli 는 공용 ECR 이미지로 실행
# (박스 설치 불필요·Docker Hub rate-limit 회피), --network host 로 박스 instance role(IMDS) 자격증명에 닿는다
# (읽기 권한=terraform iam.tf 의 app_ssm_read). 값 운반은 base64 로 감싸 공백·따옴표·$·개행(APPLE_PRIVATE_KEY
# PEM)까지 바이트 그대로 옮기고, 임시 파일은 umask 600·즉시 삭제한다. 아래 가드가 이 값들을 검사하므로 순서상
# pull 이 먼저다(옛 러너측 secret 가드를 대체 — 값이 SSM 으로 옮겨가 러너 GH secrets 검사가 성립 안 함).
# 태그는 patch 로 고정한다 - latest 는 아래 image prune 과 겹쳐 매 배포가 그 시점 latest 를 새로
# pull 하므로, 상류 변경이 이 repo 커밋 없이 배포를 깨뜨릴 수 있다. 갱신은 의도적 커밋으로만.
AWSCLI_IMAGE="public.ecr.aws/aws-cli/aws-cli:2.35.21"
# 조회·파싱 실패를 각자 즉시 끊는다 - 안 끊으면 PARAM_COUNT 가 비어 아래 -eq 비교가 깨진 채
# 흘러갈 수 있다. (전역 set -e 는 이 스크립트의 의도된 fallback 경로들을 바꿔 쓰지 않는다.)
if ! PARAMS_JSON="$(docker run --rm --network host "$AWSCLI_IMAGE" \
ssm get-parameters-by-path \
--path "/piki-core/$ENVIRONMENT/" --recursive --with-decryption \
--region ap-northeast-2 --output json)"; then
echo "::error::SSM get-parameters-by-path 호출 실패 — IAM 권한(app_ssm_read apply 여부)·네트워크(공용 ECR pull) 확인"
exit 1
fi
if ! PARAM_COUNT="$(printf '%s' "$PARAMS_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("Parameters",[])))')"; then
echo "::error::SSM 응답 JSON 파싱 실패 — aws-cli 출력 형식 변화 여부 확인"
exit 1
fi
if [ "$PARAM_COUNT" -eq 0 ]; then
echo "::error::SSM /piki-core/$ENVIRONMENT/ 에서 파라미터를 못 받았다 — 마이그레이션 워크플로 선행(값 심기) 또는 IAM 권한 확인"
exit 1
fi
# NAME<TAB>base64(VALUE) 로 뽑아 파일에 쓴 뒤(파이프 서브셸의 export 유실을 피하려 파일 리다이렉트로 루프),
# UPPER_SNAKE 로 복원해 export + '-e KEY' 플래그(RUN_ENV_FLAGS)를 모은다. 플래그엔 값이 없어(-e KEY passthrough)
# 값이 명령행·로그에 노출되지 않고, 개행 포함 PEM 도 환경 변수로만 전달된다.
umask 077
SSM_KV="$(mktemp)"
printf '%s' "$PARAMS_JSON" | python3 -c "import sys,json,base64;print('\n'.join(p['Name']+'\t'+base64.b64encode(p['Value'].encode()).decode() for p in json.load(sys.stdin).get('Parameters',[])))" > "$SSM_KV"
# 예약 변수 방어(denylist): SSM 키를 그대로 export 하므로, 배포 제어 값(ENVIRONMENT·IMAGE_TAG 등)이나
# 이 스크립트의 내부 변수와 이름이 겹치는 파라미터가 있으면 배포 동작이 덮인다. 그런 키는 즉시 실패시킨다.
# allowlist 가 아닌 denylist 인 이유: 시크릿 추가 시 워크플로 무변경이 이 전환의 핵심 이득이라, 목록 관리
# 대상을 "앱이 못 쓰는 예약 이름"으로 한정한다.
RESERVED=" ENVIRONMENT SPRING_PROFILES_ACTIVE ADMIN_ENABLED ENV_ACCESS_GATE ADMIN_ENVIRONMENT SPRINGDOC_ENABLED DOCKERHUB_USERNAME IMAGE_TAG IMAGE SENTRY_DSN SENTRY_ENVIRONMENT SENTRY_RELEASE EXTRACT_REMOTE_BASE_URL JAVA_TOOL_OPTIONS LOG_STRUCTURED TRACING_OTLP_ENABLED AWSCLI_IMAGE PARAMS_JSON PARAM_COUNT SSM_KV RUN_ENV_FLAGS TAB RESERVED ACTIVE ACTIVE_PORT INACTIVE INACTIVE_PORT PATH HOME IFS SHELL USER "
RUN_ENV_FLAGS=""
TAB="$(printf '\t')"
while IFS="$TAB" read -r P_NAME P_B64; do
[ -n "$P_NAME" ] || continue
E_KEY="$(basename "$P_NAME" | tr 'a-z-' 'A-Z_')"
case "$RESERVED" in *" $E_KEY "*)
echo "::error::SSM 파라미터 $P_NAME 가 예약 변수 $E_KEY 와 충돌 — 배포 제어·내부 변수 이름은 /piki-core/ 아래에 두지 않는다"
exit 1;;
esac
# 뒤쪽 개행 보존: 명령치환은 trailing newline 을 지우므로 sentinel(X)로 감싸 복원한다.
E_VAL="$(printf '%s' "$P_B64" | base64 -d; printf X)"; E_VAL="${E_VAL%X}"
export "$E_KEY=$E_VAL"
RUN_ENV_FLAGS="$RUN_ENV_FLAGS -e $E_KEY"
done < "$SSM_KV"
rm -f "$SSM_KV"
echo "SSM 에서 $PARAM_COUNT 개 파라미터 로드 (/piki-core/$ENVIRONMENT/)"
# ADMIN_ENABLED=true 인데 Discord admin secret 이 비면 /admin-access 진입이 전부 막혀 allowlist 를 열 수 없고
# /admin 이 영구 차단된다. 배포가 성공해버리기 전에 fail-fast 로 막는다(#249).
if [ "$ADMIN_ENABLED" = "true" ] && { [ -z "$DISCORD_PUBLIC_KEY" ] || [ -z "$DISCORD_ADMIN_USER_IDS" ] || [ -z "$DISCORD_ADMIN_CHANNEL_ID" ] || [ -z "$ADMIN_GRANT_SIGNING_KEY" ]; }; then
echo "::error::admin secret(DISCORD_PUBLIC_KEY/DISCORD_ADMIN_USER_IDS/DISCORD_ADMIN_CHANNEL_ID/ADMIN_GRANT_SIGNING_KEY) empty while ADMIN_ENABLED=true"
exit 1
fi
# APPLE_WEB_CALLBACK_URL 은 AppleProperties.webCallbackUrl(@NotBlank)로 필수다(#430). 비면 컨테이너 기동이
# BindException 으로 죽어 배포가 늦게 실패하므로, 배포 전에 fail-fast 로 원인을 즉시 드러낸다.
if [ -z "$APPLE_WEB_CALLBACK_URL" ]; then
echo "::error::APPLE_WEB_CALLBACK_URL is empty"
exit 1
fi
# 원격 추출은 파싱의 유일 경로다 — base-url 의 host 가 비면(EXTRACTOR_PROD_ADDRESS secret 미설정 →
# base-url 이 "http://" 로만 남음) RemoteExtractionHttpClientConfig 의 isNotBlank 검사는 통과해
# 배포는 성공하나 모든 파싱이 런타임에만 실패한다. 그 조용한 오설정을 배포 전에 fail-fast 로 막는다.
if [ "$EXTRACT_REMOTE_BASE_URL" = "http://" ] || [ "$EXTRACT_REMOTE_BASE_URL" = "https://" ]; then
echo "::error::EXTRACT_REMOTE_BASE_URL 의 host 가 비어 있음 (EXTRACTOR_PROD_ADDRESS secret 확인)"
exit 1
fi
IMAGE="$DOCKERHUB_USERNAME/piki-core:$IMAGE_TAG"
# 옛 배포 이미지가 쌓여 EC2 디스크가 차면 containerd 가 새 이미지를 못 받아 배포가 실패한다(#336).
# pull 전에 컨테이너 미참조 이미지를 정리해 누적을 막고, 이미 디스크가 찬 상태의 deadlock 도 깬다.
# 실행중 컨테이너(blue/green·alloy·redis)의 이미지와 볼륨은 보존된다.
# -af 로 전부 정리한다(시간 필터 X): 롤백은 "실행중 active 컨테이너 유지"라 옛 이미지를 재사용하지 않고,
# dev 는 배포가 잦아 --filter "until=72h" 류로 두면 디스크가 다시 찬다. 다중 버전 보존이 필요해지면 그때 제한한다.
docker image prune -af || true
df -h / | tail -1
docker pull "$IMAGE"
# team3-upstream.conf를 source of truth로 현재 서빙 포트 결정
# (없으면 8080 기본값 — team3-server가 8080에서 서비스 중인 초기 상태)
if [ -f /etc/nginx/team3-upstream.conf ]; then
ACTIVE_PORT=$(grep -oP '\d+' /etc/nginx/team3-upstream.conf | head -1)
else
ACTIVE_PORT=8080
echo "server localhost:8080;" | sudo tee /etc/nginx/team3-upstream.conf
fi
if [ "$ACTIVE_PORT" = "8080" ]; then
ACTIVE="blue" INACTIVE="green" INACTIVE_PORT=8081
else
ACTIVE="green" INACTIVE="blue" INACTIVE_PORT=8080
fi
echo "================================================"
echo " CURRENT : $ACTIVE (port $ACTIVE_PORT) [serving traffic]"
echo " DEPLOY : $INACTIVE (port $INACTIVE_PORT) [new version]"
echo "================================================"
# 로그 백업 후 비활성 슬롯 정리
mkdir -p /var/log/team3
docker logs "team3-$INACTIVE" > /var/log/team3/team3-$INACTIVE-$(date +%Y%m%d-%H%M%S).log 2>&1 || true
find /var/log/team3 -name "team3-*.log" -mtime +7 -delete || true
# 직전 배포의 구버전 teardown 이 timeout 으로 끊겨 이 슬롯 컨테이너가 살아있을 수 있다.
# 아래 docker run 의 name/port 충돌을 막으려 force 로 지우고, 데몬 지연에 대비해 timeout 으로 상한을 건다.
timeout 40 docker stop -t 30 "team3-$INACTIVE" 2>/dev/null || true
timeout 30 docker rm -f "team3-$INACTIVE" 2>/dev/null || true
# 새 버전 비활성 포트에 기동.
# -p 를 127.0.0.1 에 바인딩해 호스트 공인 IP(EIP)로의 직접 접근을 막는다. nginx(호스트 프로세스)와
# health check 는 localhost 로 접근하므로 영향 없고, actuator/prometheus 가 SG ingress 설정과 무관하게
# 외부에서 직접 scrape 되는 우회 경로를 구조적으로 차단한다 (nginx /actuator 차단의 보강).
# 주의: 별도 PR 의 Grafana Alloy 는 같은 호스트의 컨테이너이므로 --network host 로 띄워 localhost 를 scrape 해야 한다
# (redis 가 쓰는 172.17.0.1 bridge 바인딩 방식으로는 127.0.0.1 바인딩 포트에 닿지 못한다).
# LOG_STRUCTURED=ecs: 운영 로그를 ECS JSON 으로 출력해 Alloy 가 trace 를 Loki structured
# metadata 로 승격하게 한다(#364). 값 고정 평문이라 Secrets 불필요. 로컬은 이 변수 미설정이라
# application.yml 의 평문 콘솔 패턴(#361)을 그대로 쓴다.
# TRACING_OTLP_ENABLED=true + --add-host: span 을 OTLP 로 Alloy(otelcol.receiver.otlp)에 push 한다(#380).
# 앱 컨테이너는 bridge 네트워크라 컨테이너 안 localhost 가 --network host 인 Alloy 에 안 닿으므로,
# --add-host=host.docker.internal:host-gateway 로 호스트 게이트웨이를 잡고 application.yml 의 OTLP
# endpoint(host.docker.internal:4318)로 보낸다. 값 고정 평문이라 Secrets 불필요(로컬 미설정 → export off).
# --memory: 컨테이너 메모리 한도(cgroup). 906Mi 박스라 한도가 없으면 OOM 시 커널 전역
# OOM-killer 가 임의 프로세스를 죽이고 박스 전체가 thrash 로 무응답이 된다(2026-06-06 사고).
# 한도를 주면 초과 시 이 컨테이너만 cgroup-OOM 으로 죽고 --restart 로 재기동 — host·다른
# 컨테이너는 멀쩡. blue-green overlap 중 사고의 폭발 반경을 앱 한 개로 가둔다.
# --restart unless-stopped: redis/mysql/alloy 와 동일 정책. cgroup-OOM·박스 재부팅 후 자가 복구.
# (teardown 은 docker stop + rm -f 라 정책과 충돌하지 않는다.)
# RAM·힙 한도는 resolve job 이 박스 크기별로 정한다(environment 분기) — dev/staging 906M 는 400m/800m·힙256m,
# prod t4g.small(1.8G, #596)은 768m/1536m·힙512m. blue+green overlap(전환 중 잠깐 2개 동시)이 RAM 안에 들어가
# swap thrash 를 막는다(dev 2×400=800<906, prod 2×768=1536<1.8G). 앱 hot working set 만 RAM 에서 빠르게 돌고
# 식은 페이지는 swap 에 남는다. memory-swap 은 부팅 스파이크가 RAM 을 잠깐 넘어도 cgroup-OOM 대신 swap 으로
# 흘려 배포가 안 깨지게 하는 쿠션이다. 값은 시작점 — 검증 배포에서 부팅 피크·overlap RAM 을 실측해 확정.
# ExitOnOutOfMemoryError: 힙 OOM 시 즉시 종료→재시작.
docker run -d \
--name "team3-$INACTIVE" \
--restart unless-stopped \
-p "127.0.0.1:$INACTIVE_PORT:8080" \
--add-host=host.docker.internal:host-gateway \
--memory=${{ needs.resolve.outputs.mem_limit }} --memory-swap=${{ needs.resolve.outputs.mem_swap }} \
-e JAVA_TOOL_OPTIONS="${{ needs.resolve.outputs.jvm_opts }}" \
-e LOG_STRUCTURED="ecs" \
-e TRACING_OTLP_ENABLED="true" \
-e SPRING_PROFILES_ACTIVE="$SPRING_PROFILES_ACTIVE" \
-e EXTRACT_REMOTE_BASE_URL="$EXTRACT_REMOTE_BASE_URL" \
-e ADMIN_ENABLED="$ADMIN_ENABLED" \
-e ENV_ACCESS_GATE="$ENV_ACCESS_GATE" \
-e ADMIN_ENVIRONMENT="$ADMIN_ENVIRONMENT" \
-e SPRINGDOC_ENABLED="$SPRINGDOC_ENABLED" \
$RUN_ENV_FLAGS \
-e SENTRY_DSN="$SENTRY_DSN" \
-e SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT" \
-e SENTRY_RELEASE="$SENTRY_RELEASE" \
"$IMAGE"
- name: Health check and switch
id: health
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
# team3-upstream.conf에서 현재 서빙 포트 읽기 (source of truth)
ACTIVE_PORT=$(grep -oP '\d+' /etc/nginx/team3-upstream.conf | head -1)
if [ "$ACTIVE_PORT" = "8080" ]; then
ACTIVE="blue" INACTIVE="green" INACTIVE_PORT=8081
else
ACTIVE="green" INACTIVE="blue" INACTIVE_PORT=8080
fi
# 새로 뜬 컨테이너(INACTIVE) 헬스체크는 infra 공용 블록(blocks/healthcheck.sh)에 위임한다.
# 간격·횟수는 호출부가 소유한다 (블록은 값을 갖지 않는다). 왜 300초(60×5s)인가:
# 콜드 JVM + 빈 DB Flyway 에 더해 blue+green 공존 시 swap 압박으로 부팅이 76초에서 120초+ 까지 늘어
# 기존 120초(24×5s)를 간헐 초과해 롤백됐다(이슈 #628). 60×5s 로 늘려 swap 쓰는 느린 부팅도 끝까지 기다린다.
# ssh-action command_timeout(기본 10m) 안에서 이후 switch·docker stop 여유까지 포함해 안전하고,
# 블록은 200 을 받으면 즉시 통과(exit 0)하므로 정상 배포 속도엔 영향 없다.
if bash /tmp/piki-deploy/healthcheck.sh --url "http://localhost:$INACTIVE_PORT/health" --interval 5 --attempts 60; then
echo "================================================"
echo " HEALTH CHECK PASSED on $INACTIVE"
echo "================================================"
# Nginx upstream 전환
echo "server localhost:$INACTIVE_PORT;" | sudo tee /etc/nginx/team3-upstream.conf
sudo nginx -s reload
echo " SWITCHED : Nginx now → $INACTIVE (port $INACTIVE_PORT)"
# 구버전 종료 (team3-server 마이그레이션 포함) — switch 는 이미 끝났으므로 best-effort.
# blue+green 동시 기동 직후라 메모리 압박으로 Docker 데몬이 느려지면 docker stop 이 멈출 수 있다.
# 상한이 없으면 이미 성공한 switch 가 ssh-action 기본 command_timeout(10m)에 걸려 배포가 통째로 실패한다.
# timeout 으로 클라이언트 호출에 상한을 걸어, 데몬이 느려도 switch 성공을 막지 못하게 한다.
timeout 60 docker stop -t 30 "team3-$ACTIVE" team3-server 2>/dev/null || true
timeout 30 docker rm -f "team3-$ACTIVE" team3-server 2>/dev/null || true
echo " STOPPED : $ACTIVE (port $ACTIVE_PORT)"
echo "================================================"
exit 0
else
echo "Health check failed — rolling back"
# 실패 경로도 같은 데몬 지연에 멈출 수 있어 상한을 건다 (롤백이 10분 hang 뒤에야 보고되는 것 방지).
timeout 40 docker stop -t 30 "team3-$INACTIVE" 2>/dev/null || true
timeout 30 docker rm -f "team3-$INACTIVE" 2>/dev/null || true
exit 1
fi
- name: Write deployment summary
if: always()
continue-on-error: true
shell: bash
run: |
SHORT_SHA="${{ needs.resolve.outputs.ref }}"
SHORT_SHA="${SHORT_SHA:0:7}"
COMMIT_URL="https://github.com/${{ github.repository }}/commit/${{ needs.resolve.outputs.ref }}"
echo "${{ secrets.EC2_SSH_KEY }}" > /tmp/ec2_key && chmod 600 /tmp/ec2_key
ACTIVE_PORT=$(ssh -i /tmp/ec2_key -o StrictHostKeyChecking=no \
"${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}" \
'grep -oP "\\d+" /etc/nginx/team3-upstream.conf 2>/dev/null | head -1' 2>/dev/null || echo "")
rm -f /tmp/ec2_key
if [ "$ACTIVE_PORT" = "8080" ]; then
NOW="🔵 blue (8080)"
elif [ "$ACTIVE_PORT" = "8081" ]; then
NOW="🟢 green (8081)"
else
NOW="알 수 없음"
fi
if [ "${{ steps.health.outcome }}" = "success" ]; then
STATUS="✅ 성공"
elif [ "${{ steps.guard.outcome }}" = "failure" ]; then
STATUS="❌ 중단 (환경↔호스트 불일치 가드)"
elif [ "${{ steps.health.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (헬스체크)"
elif [ "${{ steps.deploy.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (EC2 배포)"
elif [ "${{ steps.docker_push.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (Docker 푸시)"
elif [ "${{ steps.upload_files.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (EC2 파일 업로드)"
elif [ "${{ steps.cert.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (TLS 인증서 발급)"
elif [ "${{ steps.nginx.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (nginx 설정 적용)"
elif [ "${{ steps.provision.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (런타임 프로비저닝)"
elif [ "${{ steps.build.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (Gradle 빌드)"
elif [ "${{ steps.fetch_jar.outcome }}" = "failure" ]; then
STATUS="❌ 실패 (CI 아티팩트 다운로드)"
else
STATUS="⚠️ 알 수 없음"
fi
GUARD_REASON="${{ steps.guard.outputs.reason }}"
{
echo "## 배포 결과: $STATUS"
echo ""
echo "| 항목 | 값 |"
echo "|------|-----|"
echo "| 환경 | ${{ needs.resolve.outputs.environment }} |"
echo "| 커밋 | [\`$SHORT_SHA\`]($COMMIT_URL) |"
echo "| 현재 활성 슬롯 | $NOW |"
echo "| JAR 준비 | ${{ github.event_name == 'workflow_run' && steps.fetch_jar.outcome || steps.build.outcome }} |"
echo "| Docker 푸시 | ${{ steps.docker_push.outcome }} |"
echo "| EC2 배포 | ${{ steps.deploy.outcome }} |"
echo "| 헬스체크 & 전환 | ${{ steps.health.outcome }} |"
[ -n "$GUARD_REASON" ] && echo "| 중단 사유 | $GUARD_REASON |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Notify Discord on success
if: success()
continue-on-error: true
shell: bash
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
ACTOR: ${{ github.actor }}
REF: ${{ needs.resolve.outputs.ref }}
BRANCH: ${{ needs.resolve.outputs.branch }}
DOMAIN: ${{ needs.resolve.outputs.domain }}
run: |
[ -z "$DISCORD_WEBHOOK_URL" ] && { echo "DISCORD_WEBHOOK_URL 미설정 — 알림 생략"; exit 0; }
# github 로그인 → discord user id. 매핑 없으면 로그인 이름으로 폴백(핑은 안 감).
# jq 에 넘길 맵 — 미설정이면 빈 객체. `${VAR:-{}}` 는 secret 이 설정돼 있으면 bash 가 stray `}` 를 붙여(`...}}`) jq 를 깨뜨리므로 분리한다.
DISCORD_MAP="$DISCORD_USER_MAP"; [ -n "$DISCORD_MAP" ] || DISCORD_MAP='{}'
DISCORD_ID=$(echo "$DISCORD_MAP" | jq -r --arg login "$ACTOR" '.[$login] // empty')
ACTOR_MENTION="${DISCORD_ID:+<@$DISCORD_ID>}"
ACTOR_MENTION="${ACTOR_MENTION:-$ACTOR}"
SHORT_SHA="$REF"
SHORT_SHA="${SHORT_SHA:0:7}"
COMMIT_URL="https://github.com/${{ github.repository }}/commit/$REF"
# 커밋 메시지 첫 줄만. `jq | head` 는 head 가 파이프를 닫을 때 jq 가 SIGPIPE 로 죽어 step 이 exit 2 로
# 끊기므로(#586), split 로 jq 안에서 첫 줄을 뽑는다(거대 커밋 메시지 대비).
COMMIT_MSG=$(jq -r '((.workflow_run.head_commit.message // .head_commit.message // "") | split("\n")[0])' "$GITHUB_EVENT_PATH")
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# 배포 대상을 PiKi-Dev / PiKi-Staging / PiKi-Prod 로 브랜딩 + 도메인까지 보여 "어디에 배포됐는지" 명확히.
case "${{ needs.resolve.outputs.environment }}" in
prod) TARGET="PiKi-Prod" ;;
staging) TARGET="PiKi-Staging" ;;
*) TARGET="PiKi-Dev" ;;
esac
# Discord 마크다운: 링크는 [text](url), 멘션은 <@id>. named 링크도 임베드 카드를 만들므로,
# 카드가 안 뜨게 URL 을 <> 로 감싼다(하이퍼링크 클릭은 유지). 배포 알림은 카드 없이 링크만 둔다.
TEXT=$(printf '✅ `%s` 배포 성공!\n• 경로: `%s` 브랜치 → %s (%s)\n• 배포자: %s\n• 커밋: [%s](<%s>) %s\n• [워크플로우 로그](<%s>)' \
"$TARGET" "$BRANCH" "$TARGET" "$DOMAIN" "$ACTOR_MENTION" "$SHORT_SHA" "$COMMIT_URL" "$COMMIT_MSG" "$RUN_URL")
HTTP_CODE=$(curl -s --max-time 10 -o /tmp/discord_resp.json -w '%{http_code}' -X POST "$DISCORD_WEBHOOK_URL" \
-H "Content-Type: application/json; charset=utf-8" \
--data "$(jq -n --arg content "$TEXT" '{content:$content, allowed_mentions:{parse:["users"]}}')") || HTTP_CODE="000"
[ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ] || echo "::warning::Discord 알림 전송 실패 (HTTP $HTTP_CODE) $(cat /tmp/discord_resp.json 2>/dev/null)"
- name: Notify Discord on failure
if: failure()
continue-on-error: true
shell: bash
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
ACTOR: ${{ github.actor }}
REF: ${{ needs.resolve.outputs.ref }}
BRANCH: ${{ needs.resolve.outputs.branch }}
DOMAIN: ${{ needs.resolve.outputs.domain }}
run: |
[ -z "$DISCORD_WEBHOOK_URL" ] && { echo "DISCORD_WEBHOOK_URL 미설정 — 알림 생략"; exit 0; }
# github 로그인 → discord user id. 매핑 없으면 로그인 이름으로 폴백(핑은 안 감).
# jq 에 넘길 맵 — 미설정이면 빈 객체. `${VAR:-{}}` 는 secret 이 설정돼 있으면 bash 가 stray `}` 를 붙여(`...}}`) jq 를 깨뜨리므로 분리한다.
DISCORD_MAP="$DISCORD_USER_MAP"; [ -n "$DISCORD_MAP" ] || DISCORD_MAP='{}'
DISCORD_ID=$(echo "$DISCORD_MAP" | jq -r --arg login "$ACTOR" '.[$login] // empty')
ACTOR_MENTION="${DISCORD_ID:+<@$DISCORD_ID>}"
ACTOR_MENTION="${ACTOR_MENTION:-$ACTOR}"
SHORT_SHA="$REF"
SHORT_SHA="${SHORT_SHA:0:7}"
COMMIT_URL="https://github.com/${{ github.repository }}/commit/$REF"
# 커밋 메시지 첫 줄만 (거대 메시지 대비 jq split — `jq | head` 의 SIGPIPE 중단 회피, #586).
COMMIT_MSG=$(jq -r '((.workflow_run.head_commit.message // .head_commit.message // "") | split("\n")[0])' "$GITHUB_EVENT_PATH")
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "${{ steps.guard.outcome }}" = "failure" ]; then
CAUSE="${{ steps.guard.outputs.reason }}"
CAUSE="${CAUSE:-환경 도메인↔EC2_HOST 불일치 (secret 오설정 — dev/prod 교차배선)}"
elif [ "${{ steps.build.outcome }}" = "failure" ]; then
CAUSE="Gradle 빌드 실패 (Build JAR)"
elif [ "${{ steps.fetch_jar.outcome }}" = "failure" ]; then
CAUSE="CI 아티팩트(JAR) 다운로드 실패 (트리거 CI 의 app-jar 누락/만료 · actions:read 권한)"
elif [ "${{ steps.docker_login.outcome }}" = "failure" ]; then
CAUSE="Docker Hub 로그인 실패"
elif [ "${{ steps.docker_push.outcome }}" = "failure" ]; then
CAUSE="Docker 이미지 빌드/푸시 실패"
elif [ "${{ steps.upload_files.outcome }}" = "failure" ]; then
CAUSE="EC2 파일 업로드 실패 (SSH/scp 연결 — EC2_HOST/SSH_KEY 확인)"
elif [ "${{ steps.cert.outcome }}" = "failure" ]; then
CAUSE="TLS 인증서 발급 실패 (certbot — DNS 미전파 / 80포트 / Let's Encrypt rate limit)"
elif [ "${{ steps.nginx.outcome }}" = "failure" ]; then
CAUSE="nginx 설정 적용 실패 (nginx -t 검증 실패)"
elif [ "${{ steps.provision.outcome }}" = "failure" ]; then
CAUSE="런타임 프로비저닝 실패 (swap / redis / MySQL readiness / alloy)"
elif [ "${{ steps.deploy.outcome }}" = "failure" ]; then
CAUSE="EC2 배포 실패 (docker pull/run · blue-green)"
elif [ "${{ steps.health.outcome }}" = "failure" ]; then
CAUSE="헬스체크 실패 (서버 미응답 — 앱 부팅/DB 연결 확인)"
else
CAUSE="알 수 없음 (환경 설정 단계)"
fi
case "${{ needs.resolve.outputs.environment }}" in
prod) TARGET="PiKi-Prod" ;;
staging) TARGET="PiKi-Staging" ;;
*) TARGET="PiKi-Dev" ;;
esac
TEXT=$(printf '❌ `%s` 배포 실패!\n• 경로: `%s` 브랜치 → %s (%s)\n• 배포자: %s\n• 커밋: [%s](<%s>) %s\n• 원인: %s\n• [워크플로우 로그 확인](<%s>)' \
"$TARGET" "$BRANCH" "$TARGET" "$DOMAIN" "$ACTOR_MENTION" "$SHORT_SHA" "$COMMIT_URL" "$COMMIT_MSG" "$CAUSE" "$RUN_URL")
HTTP_CODE=$(curl -s --max-time 10 -o /tmp/discord_resp.json -w '%{http_code}' -X POST "$DISCORD_WEBHOOK_URL" \
-H "Content-Type: application/json; charset=utf-8" \
--data "$(jq -n --arg content "$TEXT" '{content:$content, allowed_mentions:{parse:["users"]}}')") || HTTP_CODE="000"
[ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ] || echo "::warning::Discord 알림 전송 실패 (HTTP $HTTP_CODE) $(cat /tmp/discord_resp.json 2>/dev/null)"
# main(prod) 배포 성공 후에만 GitHub Release 자동 생성 — 릴리스 = 실제로 라이브된 버전.
# 배포 실패 시 release 가 needs(deploy) 실패로 skip 되어 "안 뜬 버전의 릴리스"가 안 남는다.
release:
needs: [resolve, deploy]
if: needs.resolve.outputs.environment == 'prod'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.ref }}
fetch-depth: 0
- name: Create GitHub Release (auto notes)
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Promote 워크플로가 prod 승격 시 채우는 semver 태그. 직접 dispatch·push 경로엔 비어 있어 SHA 로 폴백.
VERSION: ${{ inputs.version }}
run: |
if [ -n "$VERSION" ]; then
TAG="$VERSION"
else
TAG="release-$(echo '${{ needs.resolve.outputs.ref }}' | cut -c1-7)"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "release $TAG 이미 존재 — skip"
else
gh release create "$TAG" \
--target "${{ needs.resolve.outputs.ref }}" \
--title "Production $TAG" \
--generate-notes
fi
echo "url=https://github.com/${{ github.repository }}/releases/tag/$TAG" >> "$GITHUB_OUTPUT"
# prod 릴리즈가 실제로 만들어진 시점의 "버전이 몇이 됐는지" 알림 — 승격·배포 알림과 별개로 버전·릴리즈 노트를
# 권위 있게 announce 한다. deploy.yml 의 다른 Discord 알림과 동일 패턴(DISCORD_WEBHOOK_URL, continue-on-error).
- name: Notify Discord on release
if: success()
continue-on-error: true
shell: bash
env:
TAG: ${{ steps.release.outputs.tag }}
RELEASE_URL: ${{ steps.release.outputs.url }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }}
ACTOR: ${{ github.actor }}
DOMAIN: ${{ needs.resolve.outputs.domain }}
run: |
[ -z "$DISCORD_WEBHOOK_URL" ] && { echo "DISCORD_WEBHOOK_URL 미설정 — 알림 생략"; exit 0; }
# jq 에 넘길 맵 — 미설정이면 빈 객체. `${VAR:-{}}` 는 secret 이 설정돼 있으면 bash 가 stray `}` 를 붙여(`...}}`) jq 를 깨뜨리므로 분리한다.
DISCORD_MAP="$DISCORD_USER_MAP"; [ -n "$DISCORD_MAP" ] || DISCORD_MAP='{}'
DISCORD_ID=$(echo "$DISCORD_MAP" | jq -r --arg login "$ACTOR" '.[$login] // empty')
ACTOR_MENTION="${DISCORD_ID:+<@$DISCORD_ID>}"; ACTOR_MENTION="${ACTOR_MENTION:-$ACTOR}"
TEXT=$(printf '🏷️ Production 릴리즈 `%s` 배포 완료!\n• 도메인: %s\n• 릴리즈: [%s 릴리즈 노트](<%s>)\n• 배포자: %s' \
"$TAG" "$DOMAIN" "$TAG" "$RELEASE_URL" "$ACTOR_MENTION")
HTTP_CODE=$(curl -s --max-time 10 -o /tmp/discord_resp.json -w '%{http_code}' -X POST "$DISCORD_WEBHOOK_URL" \
-H "Content-Type: application/json; charset=utf-8" \
--data "$(jq -n --arg content "$TEXT" '{content:$content, allowed_mentions:{parse:["users"]}}')") || HTTP_CODE="000"
[ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ] || echo "::warning::Discord 알림 전송 실패 (HTTP $HTTP_CODE) $(cat /tmp/discord_resp.json 2>/dev/null)"