fix: Harden CI against Nginx misconfiguration that caused prod 502/404
TaxBaik CI/CD / build-and-deploy (push) Failing after 3m5s

Today's incident: CI reported successful deploys while the real site
returned 502 (root) then 404 (/taxbaik/) to users. Root cause was three
compounding Nginx issues, none of which the previous CI checks could see
because they only ever curled 127.0.0.1:5001 directly, bypassing Nginx:

1. Two Nginx config files existed. sites-available/default (documented,
   but NOT symlinked into sites-enabled/) was being edited repeatedly with
   zero effect. The file actually loaded was
   sites-available/taxbaik-domains.conf (-> sites-enabled/), undocumented.
2. That real file hardcoded the Green-Blue app port (5003) directly in
   both `location /` and `location /taxbaik`, instead of the persistent
   TaxBaik.Proxy on 5001. When the active port flipped to 5004, Nginx kept
   pointing at the dead 5003 -> 502.
3. Fixing the port to 5001 with a trailing slash on proxy_pass triggered
   Nginx URI rewriting, sending a double slash ("//") to the backend,
   which 404'd. Confirmed via `curl http://backend//` -> 404.

Changes:
- deploy.yml: replace the old blind `grep sites-available/default` check
  (checked the wrong, unloaded file) with a hard-failing check that (a)
  resolves the actual file via sites-enabled/ symlinks, (b) fails the
  deploy if either location block hardcodes 5003/5004 instead of 5001,
  (c) fails if /taxbaik's proxy_pass carries a stray trailing slash.
- deploy.yml: add an external, post-deploy check that curls the real
  public domain (www.taxbaik.com root, /taxbaik/, /taxbaik/admin/login)
  through Cloudflare + Nginx, with retries — this is what would have
  caught the whole incident on the very first broken deploy instead of
  requiring live user reports.
- deploy_gb.sh: drop the stale comment implying Nginx needs updating
  per-deploy; it never should, since Nginx always points at the
  persistent 5001 proxy which reads taxbaik_port itself.
- CLAUDE.md: document the real config file, the 5001-only invariant, the
  proxy_pass trailing-slash gotcha, and the Host-header/SNI trick for
  testing domain-based server blocks locally; record the incident in the
  CI troubleshooting harness section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:51:19 +09:00
parent aaa867ce02
commit 9ae701ff93
3 changed files with 114 additions and 10 deletions
+73 -5
View File
@@ -194,11 +194,43 @@ jobs:
"\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR" "\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR"
echo "--- [4.5/5] Nginx 설정 검증 ---" echo "--- [4.5/5] Nginx 설정 검증 ---"
# Nginx는 항상 포트 5001 (TaxBaik.Proxy)을 가리켜야 함 # 실제 로드되는 파일은 sites-enabled/의 심볼릭 링크 대상만이다.
# 프록시가 taxbaik_port 파일을 읽어서 자동으로 라우팅 # sites-available/에 다른 파일(예: default)이 있어도 sites-enabled에
EXPECTED_PROXY="http://127.0.0.1:5001;" # 링크되어 있지 않으면 nginx는 그 내용을 절대 읽지 않는다.
grep "proxy_pass.*5001" /etc/nginx/sites-available/default > /dev/null 2>&1 || \ NGINX_CONF=""
echo "⚠️ Warning: Nginx may not be configured for port 5001 (TaxBaik.Proxy). Manual intervention may be needed." for f in /etc/nginx/sites-enabled/*; do
if [ -e "\$f" ] && grep -q "location /taxbaik" "\$f" 2>/dev/null; then
NGINX_CONF=\$(readlink -f "\$f")
break
fi
done
if [ -z "\$NGINX_CONF" ]; then
echo "❌ FATAL: sites-enabled/ 안에서 'location /taxbaik'를 정의한 파일을 찾을 수 없음" >&2
echo " sites-available/에 파일을 수정해도 sites-enabled에 심볼릭 링크되어 있지 않으면 반영되지 않는다." >&2
exit 1
fi
echo "실제 로드되는 설정 파일: \$NGINX_CONF"
# 불변식: '/'와 '/taxbaik' location 모두 반드시 127.0.0.1:5001 (TaxBaik.Proxy)을
# 가리켜야 한다. 5003/5004를 직접 하드코딩하면 Green-Blue 포트 전환 시
# 죽은 포트를 가리키게 되어 502/404가 발생한다 (실제 발생했던 장애).
if grep -E "proxy_pass\s+http://127\.0\.0\.1:500[34]" "\$NGINX_CONF" > /dev/null 2>&1; then
echo "❌ FATAL: \$NGINX_CONF 가 포트 5003/5004를 직접 참조함 (Green-Blue 전환 시 502 발생)" >&2
echo " 수정: sudo sed -i 's|127.0.0.1:500[34]|127.0.0.1:5001|g' \$NGINX_CONF && sudo nginx -t && sudo systemctl reload nginx" >&2
exit 1
fi
# proxy_pass에 URI(끝 슬래시)가 있으면 nginx가 요청 경로를 재작성하며,
# location 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(//)가
# 전달되어 404가 발생한다 (실제 발생했던 장애). 접두사 location에서는
# proxy_pass에 URI를 붙이지 않는다.
if grep -E "location\s+/taxbaik\s*\{" -A 1 "\$NGINX_CONF" | grep -qE "proxy_pass\s+http://127\.0\.0\.1:5001/;"; then
echo "❌ FATAL: location /taxbaik 의 proxy_pass 에 불필요한 trailing slash가 있음 (이중 슬래시로 인한 404 위험)" >&2
exit 1
fi
echo "✓ Nginx 설정 검증 통과 (실제 로드 파일 확인 + 포트 5001 고정 + trailing slash 없음)"
echo "--- [5/5] 헬스 체크 (최대 60초) ---" echo "--- [5/5] 헬스 체크 (최대 60초) ---"
ATTEMPTS=20 ATTEMPTS=20
@@ -257,6 +289,42 @@ jobs:
REMOTE REMOTE
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST" echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
# 내부 127.0.0.1:5001 헬스 체크는 Nginx/Cloudflare를 거치지 않으므로
# Nginx 설정 오류(잘못된 파일 수정, 죽은 포트 하드코딩 등)를 잡지 못한다.
# 실제 사용자가 접속하는 경로 그대로 외부에서 검증해야 이런 장애를 CI가 스스로 잡는다.
check_public() {
local url="$1"
local status
status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" || echo "000")
if [ "$status" != "200" ]; then
echo " ✗ $url → HTTP $status" >&2
return 1
fi
echo " ✓ $url → HTTP $status"
return 0
}
echo "--- 실제 공개 도메인 종단 간 검증 (Nginx/Cloudflare 경유, 최대 3회 재시도) ---"
PUBLIC_OK=false
for i in 1 2 3; do
if check_public "https://www.taxbaik.com/" \
&& check_public "https://www.taxbaik.com/taxbaik/" \
&& check_public "https://www.taxbaik.com/taxbaik/admin/login"; then
PUBLIC_OK=true
break
fi
echo " 재시도 대기 중... ($i/3)"
sleep 5
done
if [ "$PUBLIC_OK" != "true" ]; then
echo "❌ FATAL: 실제 공개 도메인 검증 실패. Nginx가 죽은 포트를 가리키거나 잘못된 파일을 수정했을 가능성이 높다." >&2
echo " 확인: sites-enabled/의 실제 파일에서 location / 와 location /taxbaik 모두 127.0.0.1:5001을 가리키는지 점검" >&2
exit 1
fi
echo "✓ 실제 공개 도메인 전체 정상"
send_telegram "✅ <b>TaxBaik 배포 완료</b> send_telegram "✅ <b>TaxBaik 배포 완료</b>
커밋: <code>${COMMIT}</code> 커밋: <code>${COMMIT}</code>
+36 -2
View File
@@ -767,7 +767,8 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false
기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가: 기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가:
```nginx ```nginx
# /etc/nginx/sites-available/default (또는 현재 설정 파일)에 아래 블록 추가 # 실제 로드되는 파일: /etc/nginx/sites-available/taxbaik-domains.conf
# (sites-enabled/taxbaik-domains.conf 심볼릭 링크로 활성화됨)
location /taxbaik { location /taxbaik {
proxy_pass http://127.0.0.1:5001; proxy_pass http://127.0.0.1:5001;
@@ -785,6 +786,21 @@ location /taxbaik {
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다. **참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.
**⚠️ 중요: 실제 로드되는 Nginx 설정 파일 확인 필수 (2026-07-03 장애로 확인)**:
- `nginx.conf``include /etc/nginx/sites-enabled/*;`만 로드한다. `/etc/nginx/sites-available/`에 파일이 있어도 `sites-enabled/`에 심볼릭 링크되어 있지 않으면 **절대 반영되지 않는다**.
- 이 서버는 `sites-available/default`가 아니라 `sites-available/taxbaik-domains.conf` (→ `sites-enabled/taxbaik-domains.conf`)가 실제로 로드되는 파일이다. `default`를 아무리 수정해도 효과가 없다.
- 실제 로드 파일을 찾는 법: `ls -la /etc/nginx/sites-enabled/` 로 심볼릭 링크 대상을 먼저 확인한 뒤 그 파일을 수정한다.
- **불변식**: `taxbaik-domains.conf``location /``location /taxbaik` 모두 항상 `127.0.0.1:5001` (TaxBaik.Proxy)만 가리켜야 한다. `5003`/`5004`(Green-Blue 앱 포트)를 직접 하드코딩하면 포트 전환 시 죽은 포트를 가리키게 되어 502/404가 발생한다. 이 설정은 배포마다 바뀔 필요가 없다 — 프록시가 `~/taxbaik_port` 파일을 읽어 자동으로 활성 포트에 연결한다.
- **trailing slash 주의**: `proxy_pass http://127.0.0.1:5001;` (슬래시 없음)은 원본 요청 경로를 그대로 전달한다. `proxy_pass http://127.0.0.1:5001/;` (슬래시 있음)은 URI를 재작성하는데, `location` 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(`//`)가 전달되어 404가 발생한다. 접두사 매칭 `location`에서는 `proxy_pass`에 trailing slash를 붙이지 않는다.
- **디버깅 팁**: `curl http://127.0.0.1/taxbaik/`처럼 IP로 직접 테스트하면 `Host: 127.0.0.1` 헤더가 `server_name taxbaik.com www.taxbaik.com`과 매칭되지 않아 엉뚱한(또는 기본) server block으로 라우팅될 수 있다. 실제 도메인 기준 server block을 로컬에서 테스트하려면 Host 헤더/SNI를 강제로 지정한다:
```bash
# HTTP
curl -I -H "Host: www.taxbaik.com" http://127.0.0.1/taxbaik/
# HTTPS (SNI 포함)
curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/taxbaik/
```
- CI 배포(`deploy.yml`)는 매 배포마다 `sites-enabled/`의 실제 파일을 찾아 위 불변식을 검증하고, 위반 시 배포를 실패 처리한다. 또한 내부 `127.0.0.1:5001` 체크와 별개로 실제 공개 도메인(`https://www.taxbaik.com/`)을 외부에서 호출해 Nginx/Cloudflare 경로 전체를 검증한다 — 내부 체크만으로는 Nginx 설정 오류를 잡지 못하기 때문이다.
**Nginx 보안**: **Nginx 보안**:
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다. - `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
- `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다. - `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다.
@@ -2142,11 +2158,29 @@ else
``` ```
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다. 빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
**이번 장애 원인 기록**: 5. **"CI는 성공인데 실제 사이트는 502/404" 의심 시 — 반드시 Nginx 레이어부터 확인**
내부 헬스체크(`http://127.0.0.1:5001/...`)는 Nginx를 거치지 않으므로 Nginx 설정 오류를 잡지 못한다. CI 성공과 실제 접속 가능 여부는 별개다.
```bash
# 1) 실제 로드되는 파일 확인 (sites-available에 있어도 sites-enabled에 링크 안 되면 무효)
ls -la /etc/nginx/sites-enabled/
# 2) 그 파일에서 location / 와 location /taxbaik 이 5001을 가리키는지 확인 (5003/5004 하드코딩 금지)
grep -A 2 'location /' /etc/nginx/sites-available/taxbaik-domains.conf
# 3) 실제 도메인 기준 server block으로 로컬 검증 (Host/SNI 강제)
curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/taxbaik/
```
**이번 장애 원인 기록 (2026-06-28, YAML 파싱)**:
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다. - `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다. - 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다. - 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
**이번 장애 원인 기록 (2026-07-03, Nginx 이중 설정 파일 + 죽은 포트 + trailing slash)**:
- CI 배포는 매번 성공으로 표시됐지만 실제 `https://www.taxbaik.com/`은 502, `/taxbaik/`는 404였다. 원인은 세 가지가 겹쳐 있었다.
1. 서버에 Nginx 설정 파일이 두 개 존재했다: `sites-available/default`(문서에 기록되어 있었지만 `sites-enabled/`에 링크되지 않아 **전혀 로드되지 않음**)와 `sites-available/taxbaik-domains.conf`(→ `sites-enabled/`에 실제로 링크되어 로드됨, 문서에는 없었음). 디버깅 초반에 로드되지 않는 `default` 파일만 계속 수정하며 시간을 허비했다.
2. `taxbaik-domains.conf`의 `location /`와 `location /taxbaik`에 Green-Blue 앱 포트(`5003`)가 직접 하드코딩되어 있었다. 포트가 `5004`로 전환된 뒤에도 Nginx는 죽은 `5003`을 계속 가리켜 502가 발생했다.
3. `location /taxbaik`의 `proxy_pass`를 `http://127.0.0.1:5001/`(trailing slash 있음)로 고치자, nginx가 URI를 재작성하며 백엔드로 `//`(이중 슬래시)를 전달해 404가 발생했다. `curl http://backend//` 로 재현 확인 후 trailing slash를 제거해 해결했다.
- 근본 대책: 위 5번 체크리스트를 표준 절차로 추가했고, `deploy.yml`이 매 배포마다 (a) `sites-enabled/`의 실제 파일을 찾아 (b) 5003/5004 하드코딩과 trailing slash 오설정을 하드 실패로 검증하고, (c) 내부 체크와 별개로 `https://www.taxbaik.com/` 실도메인을 외부에서 호출해 Nginx/Cloudflare 경로 전체를 검증하도록 했다 (§6 Nginx 라우팅 참고).
--- ---
## 12. 문제 해결 ## 12. 문제 해결
+5 -3
View File
@@ -103,11 +103,13 @@ if [ "$SUCCESS" = "false" ]; then
exit 1 exit 1
fi fi
# 6. Switch Traffic (Nginx update handled by CI post-deploy script) # 6. Switch Traffic
# Nginx never needs per-deploy changes: it always proxies to the persistent
# TaxBaik.Proxy on 127.0.0.1:5001, which reads this same PORT_FILE and
# forwards to whichever port is currently active. See CLAUDE.md section 6.
echo "=== Switching Traffic to Port $TARGET_PORT ===" echo "=== Switching Traffic to Port $TARGET_PORT ==="
echo "$TARGET_PORT" > "$PORT_FILE" echo "$TARGET_PORT" > "$PORT_FILE"
echo "✓ Traffic routed to $TARGET_PORT" echo "✓ Traffic routed to $TARGET_PORT (via TaxBaik.Proxy on 5001)"
echo "⚠️ Note: Nginx will be updated by CI post-deploy script (requires root)"
# 7. Terminate Old App # 7. Terminate Old App
echo "=== Stopping Old App on Port $ACTIVE_PORT ===" echo "=== Stopping Old App on Port $ACTIVE_PORT ==="