From 9ae701ff93c248bf2eb62fa869842016a65adff9 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 3 Jul 2026 18:51:19 +0900 Subject: [PATCH] fix: Harden CI against Nginx misconfiguration that caused prod 502/404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/deploy.yml | 78 ++++++++++++++++++++++++++++++++++--- CLAUDE.md | 38 +++++++++++++++++- deploy_gb.sh | 8 ++-- 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3109fe1..d2473fd 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -194,11 +194,43 @@ jobs: "\$DEPLOY_DIR/deploy_gb.sh" "\$DEPLOY_DIR" echo "--- [4.5/5] Nginx 설정 검증 ---" - # Nginx는 항상 포트 5001 (TaxBaik.Proxy)을 가리켜야 함 - # 프록시가 taxbaik_port 파일을 읽어서 자동으로 라우팅 - EXPECTED_PROXY="http://127.0.0.1:5001;" - grep "proxy_pass.*5001" /etc/nginx/sites-available/default > /dev/null 2>&1 || \ - echo "⚠️ Warning: Nginx may not be configured for port 5001 (TaxBaik.Proxy). Manual intervention may be needed." + # 실제 로드되는 파일은 sites-enabled/의 심볼릭 링크 대상만이다. + # sites-available/에 다른 파일(예: default)이 있어도 sites-enabled에 + # 링크되어 있지 않으면 nginx는 그 내용을 절대 읽지 않는다. + NGINX_CONF="" + 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초) ---" ATTEMPTS=20 @@ -257,6 +289,42 @@ jobs: REMOTE 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 "✅ TaxBaik 배포 완료 커밋: ${COMMIT} diff --git a/CLAUDE.md b/CLAUDE.md index c278e8f..e855224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -767,7 +767,8 @@ export DOTNET_PRINT_TELEMETRY_MESSAGE=false 기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가: ```nginx -# /etc/nginx/sites-available/default (또는 현재 설정 파일)에 아래 블록 추가 +# 실제 로드되는 파일: /etc/nginx/sites-available/taxbaik-domains.conf +# (sites-enabled/taxbaik-domains.conf 심볼릭 링크로 활성화됨) location /taxbaik { proxy_pass http://127.0.0.1:5001; @@ -785,6 +786,21 @@ location /taxbaik { **참고**: 단일 `/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 보안**: - `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다. - `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 파서가 실패했다. - 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다. - 외부 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. 문제 해결 diff --git a/deploy_gb.sh b/deploy_gb.sh index e42f5b1..e40ac9a 100644 --- a/deploy_gb.sh +++ b/deploy_gb.sh @@ -103,11 +103,13 @@ if [ "$SUCCESS" = "false" ]; then exit 1 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 "$TARGET_PORT" > "$PORT_FILE" -echo "✓ Traffic routed to $TARGET_PORT" -echo "⚠️ Note: Nginx will be updated by CI post-deploy script (requires root)" +echo "✓ Traffic routed to $TARGET_PORT (via TaxBaik.Proxy on 5001)" # 7. Terminate Old App echo "=== Stopping Old App on Port $ACTIVE_PORT ==="