백엔드 개발자는 보통 7계층에서 산다. API path, status code, request header, CORS를 본다. 나도 Cloudflare로 stage API를 붙였을 때 브라우저의 ERR_TOO_MANY_REDIRECTS를 보고 곧바로 redirect와 CORS를 의심했다.
그런데 Cloudflare Tunnel이 끼면 한 계층에서 바로 이름 붙일 수 없다. TLS 문제처럼 보일 수도 있고, origin 연결 문제처럼 보일 수도 있고, 실제로는 reverse proxy가 HTTP 요청을 해석하는 방식이 문제일 수도 있다. 이번에 배운 것은 "이건 L7이다"를 빨리 맞히는 법이 아니라 L3/L4 연결과 L6 TLS를 지우고 다시 L7로 돌아오는 순서였다.
요약
- 문제: Cloudflare Tunnel 뒤의 stage API에서 TLS 문제처럼 보이는 redirect loop,
308,Connection reset, CORS 실패가 한 덩어리처럼 보였다. - 접근: 백엔드가 익숙한 L7에서 바로 결론내리지 않고 DNS/연결, TLS, reverse proxy, application header를 차례로 나눴다.
- 판단: L3/L4와 L6은 주원인이 아니었고, 결정적인 신호는
Location,via: 1.1 Caddy,Host,OPTIONS preflight처럼 다시 L7에 있었다. - 해결: Tunnel route, Caddy
:80listen, Host 기반 라우팅, CORS preflight 응답을 각각 분리해서 고쳤다. - 배운 점: 계층화 디버깅은 정답 계층을 맞히는 기술이 아니라, 틀린 계층을 빨리 지우고 올바른 증거로 돌아오는 기술이다.
상황
구조는 대략 이랬다.
Browser
-> Cloudflare Edge
-> Cloudflare Tunnel
-> Ubuntu origin
-> Docker port 80
-> Caddy
-> FastAPICloudflare 대시보드의 SSL/TLS 모드는 Full (strict)였다. Cloudflare 공식 문서 기준으로 SSL/TLS encryption mode는 방문자와 Cloudflare 사이의 연결, Cloudflare와 origin 사이의 연결을 따로 다룬다. Full (strict)는 origin certificate까지 검증하는 모드다.
그래서 "Flexible이라서 origin HTTP와 HTTPS redirect가 루프를 만든다"는 흔한 설명만으로는 부족했다. Tunnel을 쓰는 상황에서는 Cloudflare Edge가 방문자 쪽 HTTPS를 맡고, cloudflared가 origin으로 어떻게 넘기는지, Caddy가 그 요청을 어떤 Host와 scheme으로 해석하는지를 같이 봐야 했다.
왜 계층으로 나눠야 했나
ERR_TOO_MANY_REDIRECTS라는 브라우저 메시지만 보면 문제 이름이 이미 정해진 것처럼 느껴진다. 하지만 그 메시지는 브라우저가 마지막에 본 7계층 증상일 뿐이다.
먼저 물어야 할 것은 달랐다.
Layer 3/4: 도메인과 포트까지 도달하는가?
Layer 6: TLS 협상 또는 인증서 검증에서 막히는가?
Layer 7: HTTP status, Location, Host, Origin header가 의도와 맞는가?이 순서로 보면 "Cloudflare 문제", "CORS 문제", "서버 문제" 같은 큰 이름이 작아진다. 이번 케이스에서도 하위 계층 연결은 대체로 살아 있었고, 실제로 어긋난 것은 HTTP redirect와 header 해석이었다.
처음에는 TLS 문제로 봤다
첫 가설은 SSL/TLS와 HTTPS 강제 설정 충돌이었다. 브라우저는 redirect loop라고 말했고, Cloudflare 대시보드에는 SSL/TLS 모드가 있었다. 그래서 처음에는 Layer 6 근처를 잡고 있었다.
Cloudflare: HTTPS로 받음
Origin: HTTP로 들어왔다고 봄
Caddy 또는 app: HTTPS로 redirect
Cloudflare: 다시 origin으로 전달이 경로가 맞다면 Location 헤더가 같은 URL을 반복하거나, HTTP와 HTTPS 사이를 계속 왕복해야 한다. 그래서 먼저 curl -I로 redirect 방향을 확인했다.
확인한 증거
첫 번째 단서는 /health였다.
GET http://stage-api.example.com/health
-> 307 Internal Redirect
-> Location: https://stage-api.example.com/health
GET https://stage-api.example.com/health
-> 200 OK
-> via: 1.1 Caddy이 결과만 보면 HTTP에서 HTTPS로 올라가는 redirect는 정상이다. 더 중요한 신호는 https://.../health가 200 OK였고 응답에 via: 1.1 Caddy가 있었다는 점이다. 이 요청은 Cloudflare Edge에서 끝난 것이 아니라 Caddy까지 도달했다.
반대로 루트 경로는 이랬다.
curl -I https://stage-api.example.com/
HTTP/2 404
content-type: application/json
via: 1.1 Caddy이것도 redirect loop가 아니었다. API 서버가 /를 열어두지 않았기 때문에 JSON 404가 내려온 것이다. 이 시점에 "도메인 전체가 무한 redirect한다"는 가설은 약해졌다.
다음 증상은 더 구체적이었다.
GET https://stage-api.example.com/health
-> 308 Permanent Redirect이 308은 TLS 실패가 아니라 Caddy의 자동 HTTPS/Host 처리와 Tunnel origin HTTP 경계가 맞지 않을 때 나타난 HTTP redirect 신호로 봤다. Layer 6에서 인증서가 깨진 것이 아니라 Layer 7에서 "이 요청을 어떤 scheme/host로 해석할 것인가"가 흔들린 것이다.
그리고 어떤 로컬 테스트에서는 localhost:80 요청이 Connection reset by peer를 냈다. 여기서 docker-proxy가 보였지만, docker-proxy는 웹 서버가 아니라 Docker의 host port를 container port로 넘기는 중계 프로세스다. 진짜 질문은 docker-proxy 뒤에서 Caddy가 실제로 :80을 듣고 있느냐였다.
마지막으로 CORS 응답에는 이런 문제가 있었다.
Access-Control-Allow-Origin: {request.header.Origin}
Access-Control-Allow-Headers:첫 줄은 Caddy placeholder가 치환되지 않고 문자열 그대로 나간 상태였다. 두 번째 줄은 preflight에서 브라우저가 허용 헤더를 확인할 수 없게 만들었다.
문제가 아니었던 것
이 문제는 네트워크가 끊긴 것이 아니었다.
- DNS와 Cloudflare edge 도달은 됐다.
- TCP 연결도 됐다.
/health는 Caddy를 거쳐 FastAPI까지 도달했다.- TLS certificate 오류도 아니었다.
그래서 "네트워크가 이상하다"는 말은 너무 넓었다. 더 정확히는 1-6계층의 전형적인 장애가 아니라, reverse proxy/gateway 경계에서 생긴 7계층 문제였다.
이번에 깨진 것은 이 세 가지였다.
- HTTP redirect semantics: 누가 HTTPS 강제를 맡는가
- Host-based routing: Caddy가 어떤 Host를 기준으로 route를 고르는가
- CORS preflight: 브라우저가
OPTIONS응답 header를 어떻게 해석하는가
해결한 방식
첫째, Cloudflare Tunnel route를 명시적으로 정리했다.
문제 상황에서는 wildcard route가 stage host까지 잡아먹을 수 있었다. Tunnel의 published application routes에서 와일드카드로 모든 서브도메인을 받으면 의도하지 않은 host가 잘못된 service로 들어갈 수 있다.
정리 기준은 간단했다.
stage-api.example.com -> http://localhost:80
ssh.example.com -> ssh://localhost:22
static asset domain -> Tunnel route로 넣지 않음둘째, Caddy가 origin HTTP entrypoint를 명시적으로 열게 했다.
{
auto_https off
}
:80 {
@stage host stage-api.example.com
handle @stage {
reverse_proxy app-api-1:8000
}
respond 404
}Tunnel 뒤에서는 Cloudflare가 visitor-facing HTTPS를 맡고 origin Caddy는 HTTP :80을 받는 구조가 더 단순했다. 여기서 중요한 것은 "Caddy가 알아서 하겠지"가 아니라 :80을 명시하는 것이다.
셋째, Host 기반 테스트를 로컬에서도 같은 조건으로 했다.
curl -i -H "Host: stage-api.example.com" \
http://127.0.0.1/healthCaddy가 Host matcher로 route를 고르는 구조라면 그냥 curl http://127.0.0.1/health는 같은 테스트가 아니다. 그 요청의 Host는 127.0.0.1이기 때문이다.
넷째, CORS는 GET이 아니라 OPTIONS부터 봤다.
curl -i -X OPTIONS \
-H "Origin: http://127.0.0.1:5500" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization,content-type" \
https://stage-api.example.com/health개발용으로는 아래처럼 명시 리스트를 내려주는 편이 안정적이었다.
handle @options {
header Access-Control-Allow-Origin "{http.request.header.Origin}"
header Access-Control-Allow-Credentials "true"
header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Authorization, Content-Type, Accept, Origin, X-Requested-With"
header Access-Control-Max-Age "86400"
respond 204
}Access-Control-Allow-Headers를 요청 header에서 그대로 반사하는 방식은 요청에 Access-Control-Request-Headers가 없거나 프록시 경계에서 비면 빈 응답을 만들 수 있었다. 그래서 개발 단계에서는 고정 리스트가 더 설명 가능했다.
단, Origin 반사와 Credentials=true는 운영 기본값으로 두면 위험하다. 쿠키나 인증 정보를 쓰는 API라면 운영에서는 허용 origin allowlist로 좁혀야 한다.
검증
검증은 한 번에 "이제 된다"가 아니라 계층별로 나눴다.
curl -I https://stage-api.example.com/health여기서는 200 OK와 via: 1.1 Caddy를 봤다. server: cloudflare만 보이면 Cloudflare edge 응답인지 origin 응답인지 구분이 어렵다. via: 1.1 Caddy는 요청이 Caddy까지 도달했다는 중요한 신호였다.
curl -i -H "Host: stage-api.example.com" \
http://127.0.0.1/health여기서는 Docker port mapping과 Caddy Host matcher가 같은 방향을 보고 있는지 확인했다.
curl -i -X OPTIONS \
-H "Origin: http://127.0.0.1:5500" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization,content-type" \
https://stage-api.example.com/health여기서는 Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Access-Control-Allow-Methods, Access-Control-Allow-Headers를 같이 확인했다.
검증 범위는 "모든 browser CORS 문제가 사라졌다"가 아니다. 이 글에서 확인한 것은 stage API 요청이 Cloudflare edge를 지나 Caddy와 FastAPI까지 도달하고, 대표 preflight 조건에서 브라우저가 필요한 header를 받을 수 있다는 점이다.
배운 점
우리가 네트워크 과목에서 7계층을 배우는 이유는 각 계층 이름을 외우기 위해서만은 아니었다. 장애가 났을 때 틀린 층을 붙잡고 시간을 쓰지 않기 위해서였다.
처음에는 Cloudflare 설정 화면의 SSL/TLS 모드부터 봤다. 그건 필요한 확인이었지만 충분하지 않았다. Tunnel 환경에서는 TLS mode만 보고 문제를 끝낼 수 없다.
이번 문제에서 좋았던 판단은 "L7 문제다"라고 빨리 말한 것이 아니었다. 오히려 L6처럼 보이는 증상을 의심하고, L3/L4 연결과 L6 TLS가 실제로 막힌 지점인지 확인한 뒤, 다시 L7의 Location, Host, Origin, Access-Control-* header로 돌아온 것이다.
백엔드 개발자는 대부분의 시간을 L7에서 보낸다. 하지만 운영 장애에서는 필요하면 L6, L5, L4까지 내려갈 수 있어야 한다. 그래야 다시 L7로 올라왔을 때 지금 보는 status code가 애플리케이션 로직의 결과인지, 프록시 경계가 만든 신호인지 구분할 수 있다.
이번 문제의 기준은 이렇게 바뀌었다.
ERR_TOO_MANY_REDIRECTS를 보면 먼저Location헤더가 어디를 가리키는지 본다.- Cloudflare 응답에서는
via: 1.1 Caddy처럼 origin hop 신호를 찾는다. - Host matcher가 있으면 로컬 테스트에서도 Host header를 맞춘다.
- CORS는 GET 실패가 아니라 OPTIONS preflight 실패로 먼저 재현한다.
docker-proxy가 보이면 Docker 포트 매핑이 있다는 뜻이지, 그 뒤의 app이 실제로 listen한다는 뜻은 아니다.
다음에는 이렇게 판단한다
Cloudflare Tunnel 뒤의 API가 이상하게 동작하면 바로 "인증서 문제"나 "CORS 문제"라고 이름 붙이지 않는다. 증상 이름보다 계층을 먼저 본다.
1. Layer 3/4: 도메인과 포트까지 도달하나?
2. Layer 6: TLS 협상 또는 인증서 검증에서 막히나?
3. Layer 7: Tunnel route가 맞는 service로 보내나?
4. Layer 7: Caddy Host matcher가 요청 Host와 맞나?
5. Layer 7: FastAPI까지 도달했나?
6. Layer 7: 브라우저가 preflight 응답을 통과시킬 수 있나?이 순서로 보면 계층을 오르내리는 기준이 생긴다. 네트워크는 정상인데 브라우저만 실패하는 상황일수록 packet보다 HTTP status, Location, Host, Origin, Access-Control-* header를 먼저 읽어야 한다. 다만 그 전에 연결과 TLS가 정말 살아 있는지 지워야 한다.
