Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ jobs:
shell: bash

- name: Blue-Green Deployment
# 전환 전 확인(scripts/smoke/pre_switch_check.sh)이 스모크 계정 토큰을 만들 때 쓴다.
# 서버가 실제로 쓰는 서명 키는 APPLICATION_PROD 안의 jwt.*.secret 이다. 컨테이너에 넘기는 JWT_*_TOKEN_SECRET 환경변수는
# Spring 이 jwt.accessToken.secret 으로 읽지 않는다. access 키는 배포 뒤 스모크 full 이 통과해서 같은 값인 것이 확인됐고,
# refresh 키는 아직 확인되지 않아서 여기에는 넘기지 않는다. 배포 뒤 smoke-test.yml 이 refresh 까지 보고 알린다.
env:
SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }}
SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }}
run: |
export DOCKER_CONFIG=/tmp/.docker-ci
mkdir -p "$DOCKER_CONFIG"
Expand Down Expand Up @@ -158,6 +165,19 @@ jobs:
echo "Current active environment: $CURRENT (port $CURRENT_PORT)"
echo "Deploying to target environment: $TARGET (port $TARGET_PORT)"

# 배포를 끝내지 못하면 prod-latest 를 지금 사용자를 받는 이미지로 되돌린다. 위에서 이미 새 이미지로 옮겼고,
# 그대로 두면 누가 docker-compose up 을 다시 할 때 거절된 빌드가 올라간다.
PREV_IMAGE=""
if [ "$CURRENT" != "none" ]; then
PREV_IMAGE=$(docker inspect --format '{{.Image}}' "ono-app-prod-$CURRENT" 2>/dev/null || true)
fi
restore_latest_tag() {
if [ -n "$PREV_IMAGE" ]; then
$DOCKER tag "$PREV_IMAGE" ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest \
|| echo "::warning::prod-latest 를 이전 이미지로 되돌리지 못했습니다"
fi
}

if [ "$TARGET" = "green" ]; then
docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile green up -d app-prod-green
else
Expand All @@ -183,9 +203,29 @@ jobs:
if [ "$healthy" = false ]; then
echo "ERROR: $TARGET environment failed to become healthy within $timeout seconds"
docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET
restore_latest_tag
exit 1
fi

# 전환 전 확인. 실패하면 nginx 를 건드리지 않았으니 이전 색이 계속 사용자를 받는다.
if ! bash ./scripts/smoke/pre_switch_check.sh "ono-app-prod-$TARGET" "$TARGET_PORT"; then
if [ "$CURRENT" = "none" ]; then
# 돌고 있는 앱이 없던 복구 배포다. 새 컨테이너를 내려도 나아지는 것이 없고 복구 수단만 막히니 경고만 하고 이어 간다.
echo "::warning::전환 전 확인이 실패했지만 돌고 있던 앱이 없어 그대로 전환합니다. 스모크 결과를 확인해야 합니다"
else
echo "ERROR: $TARGET environment failed the pre-switch check. Nginx stays on $CURRENT"
docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET
# 새 컨테이너는 이름으로 지운다. 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽어,
# 사용자를 받고 있는 색을 다시 만들 수 있다.
docker rm -f "ono-app-prod-$TARGET" || true
if [ -n "$(docker ps -aq --filter "name=^ono-app-prod-$TARGET\$")" ]; then
echo "::error::ono-app-prod-$TARGET 를 지우지 못했습니다. 다음 배포 전에 직접 지워야 합니다. 남아 있으면 다음 배포가 현재 색을 잘못 판단합니다"
fi
restore_latest_tag
exit 1
fi
fi

echo "Switching Nginx to $TARGET environment (port $TARGET_PORT)..."
sudo /opt/ono/scripts/switch-nginx.sh $TARGET_PORT

Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ name: Smoke Test
# - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다.
#
# 필요한 Secrets (없으면 할 수 있는 단계까지만 한다)
# JWT_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL
# JWT_ACCESS_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL
#
# JWT_ACCESS_TOKEN_SECRET 은 배포 워크플로가 서버에 넘기는 그 키다(ci-dev.yml, ci-prod.yml). 스모크는 같은 키로
# 토큰을 직접 만들어 쓰기 때문에 값이 같아야 하고, 따로 복사해 두면 키를 바꿀 때 한쪽만 남아 알림이 계속 울린다.
# JWT_*_TOKEN_SECRET 은 배포 워크플로가 컨테이너에 환경변수로 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는
# APPLICATION_PROD 안의 jwt.accessToken.secret, jwt.refreshToken.secret 이다(환경변수 이름이 Spring 설정 키와 이어지지 않는다).
# 스모크는 이 시크릿으로 토큰을 직접 만들기 때문에 두 곳의 값이 같아야 한다. 서버 키를 바꾸면 이 시크릿도 같이 바꾼다.
# refresh 키로는 서버에 저장된 적 없는 토큰을 만들어 갱신 경로가 1002 로 답하는지만 본다(서버에 쓰기 없음).
#
# workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다.

Expand Down Expand Up @@ -128,10 +130,11 @@ jobs:
SMOKE_BASE_URL: ${{ steps.plan.outputs.url }}
SMOKE_LEVEL: ${{ steps.plan.outputs.level }}
SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }}
SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }}
SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }}
run: |
if [ "$SMOKE_LEVEL" = "bootstrap" ]; then
unset SMOKE_ACCESS_TOKEN_SECRET
unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET
python3 scripts/smoke/smoke_test.py bootstrap
else
python3 scripts/smoke/smoke_test.py
Expand All @@ -144,10 +147,11 @@ jobs:
SMOKE_BASE_URL: ${{ steps.plan.outputs.url }}
SMOKE_LEVEL: ${{ steps.plan.outputs.level }}
SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }}
SMOKE_REFRESH_TOKEN_SECRET: ${{ secrets.JWT_REFRESH_TOKEN_SECRET }}
SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }}
run: |
if [ "$SMOKE_LEVEL" = "bootstrap" ]; then
unset SMOKE_ACCESS_TOKEN_SECRET
unset SMOKE_ACCESS_TOKEN_SECRET SMOKE_REFRESH_TOKEN_SECRET
python3 scripts/smoke/smoke_test.py bootstrap
else
python3 scripts/smoke/smoke_test.py
Expand Down
46 changes: 37 additions & 9 deletions scripts/smoke/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,40 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이
| 배포 워크플로가 끝난 뒤 (`workflow_run`) | 그 배포의 서버 | 배포 성공이면 `full`, 실패면 `read` | 통과와 실패 모두 Discord 로 |
| 매시 17분 (`schedule`) | prod | `read` | 실패로 바뀔 때와 복구될 때. 실패가 이어지면 6시간마다 다시 |
| 수동 실행 (`workflow_dispatch`) | 고른 서버 | 고른 단계 | 없음. prod 는 `main` 브랜치에서만 실행된다 |
| prod 배포 중 nginx 전환 직전 (`ci-prod.yml`) | 새 컨테이너 포트 `http://127.0.0.1:8080` 또는 `8081` | actuator health + `read` (토큰 갱신 확인 제외) | 실패하면 전환하지 않고 배포가 실패로 끝난다. 돌고 있던 앱이 없던 복구 배포는 경고만 하고 전환한다 |

배포 워크플로 파일은 건드리지 않았다. 배포가 끝난 것을 받아서 따로 도는 방식이라, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다.
맥미니 러너가 아니라 GitHub 호스티드 러너에서 돌기 때문에 운영 기계의 자원도 쓰지 않는다.
위 세 줄은 `smoke-test.yml` 이 GitHub 호스티드 러너에서 돌리고, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다.

마지막 줄은 `pre_switch_check.sh` 를 배포 job(맥미니 러너)이 부른다. 새 컨테이너가 healthy 가 된 뒤, nginx 를 넘기기 전에
그 컨테이너를 직접 확인하고, 실패하면 새 컨테이너를 내린다. nginx 를 건드리지 않았으니 이전 버전이 계속 사용자를 받는다.

- **actuator health 는 여기서만 본다.** actuator 는 컨테이너 안 8081 에서만 떠서(`docker-compose.prod.yml` 의 `MANAGEMENT_SERVER_PORT`) 밖에서는 닿지 않는다. db, redis, rabbit 중 하나라도 DOWN 이면 503 이다. 컨테이너 healthcheck 도 같은 주소를 보지만 30초 간격에 3번 실패해야 unhealthy 가 되어서 전환 직전에 한 번 더 본다. 응답 본문에 버전과 디스크 용량이 있어 로그에는 구성요소 이름과 상태만 남긴다.
- **쓰기가 있는 `full` 은 전환 전에 돌리지 않는다.** 전환 전에는 이전 버전이 같은 DB 로 사용자를 받고 있다. `full` 은 전환 뒤 `workflow_run` 으로 돈다.
- **러너에 python3 3.9 이상이 없으면 스모크만 건너뛰고 경고를 남긴다.** 이 확인이 생기기 전과 같은 상태라 배포를 막지 않는다.
- **이 확인을 우회해야 하면** `deploy-prod.yml`(수동 배포)을 쓴다. 거기에는 넣지 않았다.
- **실패하면 새 컨테이너를 이름으로 지우고 `prod-latest` 를 이전 이미지로 되돌린다.** 새 컨테이너가 남아 있으면 다음 배포가 blue 를 먼저 보고 현재 색을 잘못 읽는다. 지우지 못하면 `::error::` 로 남기니 다음 배포 전에 직접 지운다.
- **토큰 갱신 확인은 여기서 하지 않는다.** refresh 서명 키가 서버와 같은지 아직 확인되지 않아서, 틀리면 배포가 막히기만 한다. 배포 뒤 확인이 `1002` 로 통과하는 것을 본 뒤에 넣는다.

## 무엇을 확인하나

| 단계 | 확인 | 요청 수 |
|---|---|---:|
| `reach` | 토큰 없이 `GET /api/problems/problemCount` 가 `401 (1007)` 인가. Cloudflare, nginx, 앱의 인증 필터까지 닿았다는 뜻이다 | 1 |
| `read` | 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 17 |
| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인 | 28 |
| `read` | 토큰 갱신 경로가 `401 (1002)` 로 답하는가. 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 18 |
| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인. 이미지 업로드 URL 발급, 오답노트 등록, 조회, 지우기, 지운 뒤 404 확인 | 35 |

`read` 의 조회 API 는 폴더 목록과 썸네일, 폴더 단건, 하위 폴더, 오답노트 수, 복습할 문제, 내 오답노트, 폴더 안 오답노트,
복습노트 썸네일과 전체, 풀이 기록 수, 내 스터디룸, 태그, 학습 캘린더, 학습 리포트 요약이다.
운영 main 버전과 develop 버전 양쪽에 다 있는 API 만 골랐다.

**토큰 갱신은 실패 응답으로 확인한다.** `JWT_REFRESH_TOKEN_SECRET` 으로 서버가 발급한 적 없는 refresh token 을 만들어 보낸다.
서명이 맞으면 서버는 `refresh_token` 테이블을 찾아보고 없으니 `1002` 로 끝나고, 저장 전에 끝나서 쓰기가 없다(`JwtTokenService.refreshAccessToken`).
`1001` 이 오면 refresh 서명 키가 어긋난 것이고, 그러면 앱 사용자의 토큰 갱신이 전부 실패한다.
성공 경로는 보지 않는다. 갱신할 때마다 토큰이 바뀌어서 다음 실행에 쓸 토큰을 어딘가에 저장해야 하기 때문이다.

**오답노트는 이미지 없이 루트 폴더에 등록한다.** `X-App-Version: 99.0.0` 을 붙여서 서버가 구버전 앱으로 보고 XP 를 적립하지 않게 한다.
이미지 업로드 URL 은 발급만 확인한다. 서버가 서명만 하고 S3 에 올리지는 않아서, S3 자격 증명이 틀린 것은 여기서 잡히지 않는다.

## 운영 서버에 영향이 없는 이유

- **보낼 수 있는 요청이 스크립트에 고정되어 있다.** `ALLOWED_REQUESTS` 밖의 요청은 보내기 전에 막는다. 쓰기는 `full` 에서만, 게스트 가입은 `bootstrap` 에서만 허용한다.
Expand All @@ -35,7 +53,7 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이
- **지우는 것은 스모크 흔적뿐이다.** 이번 실행에서 만든 것과, 이름이 `__smoke_run_` 으로 시작하는 지난 실행의 흔적만 지운다.
- **요청 수와 시간에 상한이 있다.** 한 번 실행에 80개, 200초를 넘으면 멈춘다. 재시도는 GET 만, 연결 실패와 5xx, 429 에서 최대 3번이다. 쓰기는 재시도하지 않는다.
- **쓰기가 있는 실행은 대상별로 한 줄로 선다.** 조회만 하는 매시 실행은 따로 돌아서 서로 밀어내지 않는다.
- **리다이렉트를 따라가지 않고, 토큰은 https 로만 보낸다.**
- **리다이렉트를 따라가지 않고, 토큰은 https 이거나 로컬 주소(전환 전 확인)일 때만 보낸다.**

### 넣지 않은 API 와 이유

Expand All @@ -46,7 +64,9 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이
| `GET /api/users` | 로그인 미션 기록, 포인트와 마지막 접속 시각 갱신이 같이 돈다. 관리자 DAU 집계에도 잡힌다 |
| `GET /api/learning-reports` | 캐시가 없으면 OpenAI 를 부른다 |
| `GET /api/achievements` | 조건이 맞으면 업적 행을 INSERT 한다 |
| 문제 등록과 삭제 | 삭제해도 XP 와 미션 기록, develop 의 리마인더 행이 남는다 |
| S3 에 실제로 올리는 업로드 | S3 객체가 생기고, 문제에 붙지 않은 이미지를 지우는 경로를 아직 확인하지 않았다 |
| 이미지를 붙인 오답노트 | 분석 요청(OpenAI)과 S3 삭제 메시지가 생길 수 있다 |
| 토큰 갱신 성공, 로그인 | 위에 적은 이유. 로그인은 스모크 계정(게스트)으로 다시 할 수 없다 |
| 태그 만들기 | main 에서 지운 태그와 같은 이름으로 다시 만들면 유니크 인덱스에 걸린다 |
| 알림 붙인 복습노트 | Quartz 작업이 생기고, main 은 복습노트를 지워도 그 작업을 지우지 않는다 |
| 스터디룸 가입 | 다른 멤버의 피드에 보이고, 탈퇴 로직이 외래키에 막힌다 |
Expand All @@ -66,16 +86,20 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이

그래도 남는 것은 이 정도다.

- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다.
- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트, 오답노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다.
- 오답노트를 지워도 되돌려지지 않는 것이 스모크 계정에 남는다. 하루 3건까지의 `mission_log` 행, 미션 진행도, CANCELED 로 바뀐 복습 리마인더 행이다.
XP 는 버전 헤더로 막는다. 스모크 계정은 스터디룸에 들어가 있지 않아서 피드는 생기지 않는다.
- 이미지 업로드 URL 발급이 스모크 계정의 하루 200회 카운터(Redis)를 한 번 올린다.
- 스모크 계정 1명이 관리자 사용자 목록과 사용자 수에 잡힌다.
- 스모크 계정 자신의 Redis 캐시 키(`TAG_LIST`, `STREAK`, `LEARNING_REPORT_SUMMARY`)가 TTL 로 생긴다.
- Prometheus 의 사용자 행동 지표에 게스트 요청으로 잡힌다. 사용자 ID 태그가 없어서 따로 걸러낼 수는 없다.
매시 토큰 갱신 확인의 401 이 하루 24건 client_error 로 섞인다.
- 서버 DB 가 이미 죽어 있으면 조회가 500 이 되어 기존 예외 처리대로 Discord 에러 알림이 나간다(5분 중복 억제).

## 처음 설정하는 순서

1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다.
2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 서버에 넘기는 그 키다(`ci-dev.yml`, `ci-prod.yml`). 스모크가 같은 키로 토큰을 만들기 때문에 따로 등록할 것이 없다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다.
2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 과 `JWT_REFRESH_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 컨테이너에 넘기는 값이지만, 서버가 실제로 쓰는 서명 키는 `APPLICATION_PROD` 안의 `jwt.accessToken.secret`, `jwt.refreshToken.secret` 이다. 두 값이 같아야 한다. access 키는 2026-09-21 prod `full` 통과로 같은 것이 확인됐고, refresh 키는 첫 배포 뒤 확인에서 `1002` 가 나오는지 봐야 한다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다.
3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다.
4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다.
5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다.
Expand All @@ -94,6 +118,10 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된
| 토큰이 거절됐습니다 `1009` | 시크릿이 서버 서명 키와 다르다 (develop 이후) |
| 토큰이 거절됐습니다 `1005` | 시크릿이 다르거나(main 은 서명이 틀려도 1005) 시계가 어긋났다 |
| 토큰이 거절됐습니다 `1007` | 시크릿이 다르거나 서버의 Redis 블랙리스트 조회가 실패했다 |
| 토큰 갱신 경로: refresh token 을 거절했습니다 `1001` | `JWT_REFRESH_TOKEN_SECRET` 이 서버 refresh 서명 키와 다르다. 같다면 앱 사용자의 토큰 갱신이 전부 실패하고 있다 |
| 토큰 갱신 경로: 만료로 봤습니다 `1006` | 러너와 서버의 시계가 어긋났다 |
| 이미지 업로드 URL 발급 `429` | 스모크 계정의 하루 200회 한도에 닿았다. 수동 `full` 을 많이 돌렸는지 본다 |
| 전환 전 확인: actuator health 가 200 이 아닙니다 | 새 컨테이너에서 db, redis, rabbit 중 하나가 DOWN 이다. 배포 로그의 컨테이너 로그를 본다 |
| 앱이 정상 응답하지 않습니다 (5xx) | 앱 내부 오류. DB 연결을 먼저 본다 |
| 루트 폴더에 표식 폴더가 없습니다 | `SMOKE_USER_ID` 가 스모크 계정이 아니다 |
| 응답 모양이 예상과 다릅니다 | 배포된 API 의 응답 형태가 바뀌었다. 앱 파싱도 깨졌을 수 있다 |
Expand All @@ -107,7 +135,7 @@ JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된
# 닿는지만
SMOKE_BASE_URL=https://ono-prod.seungminki.shop SMOKE_LEVEL=reach python3 scripts/smoke/smoke_test.py

# 스크립트 자체 테스트 42개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한까지 본다)
# 스크립트 자체 테스트 61개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한, 전환 전 확인 스크립트까지 본다)
python3 -m unittest discover -s scripts/smoke -p 'test_*.py'
```

Expand Down
Loading
Loading