↓ 본문으로 건너뛰기

Cloudflare Early Hints HTTP 103

Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103

Cloudflare에 504 에러가 수천 건 찍혔는데, 실제 사용자에게 간 건 하나도 없었습니다. 전부 Early Hints 캐시 미스였죠. HTTP 103의 동작 방식, 가짜 504와 진짜 504를 구별하는 법, 그리고 문제를 해결한 헤더 하나를 정리했습니다.

Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103

9월에 헬스 체크는 전부 초록불인데 Cloudflare 대시보드에는 요청의 22%가 실패로 찍혀 있던 일에 대해 쓴 적이 있습니다. 알고 보니 그 504 대부분은 사용자가 아니라 Cloudflare의 Early Hints 기능에서 나온 것이었고, 저는 Early Hints를 끄고 넘어갔습니다.

11월에 같은 존(zone)을 다시 열어 보니 하루 만에 504가 또 2,600건 쌓여 있었습니다. Early Hints가 다시 켜져 있었습니다. 이번에는 그 504가 정확히 무엇인지, 그리고 기능을 또 꺼야 하는지 아니면 제대로 일하게 만들어야 하는지 확실히 알고 싶었습니다.

이 존은 Fly.io 오리진 하나를 앞에서 받고 있고, 그 오리진이 세 가지를 서빙합니다. Django API, Expo 웹 앱, 그리고 Hugo로 만든 마케팅 사이트입니다. API를 기억해 두세요. API에는 따로 진짜 용량 문제가 있었고, 바로 그것 때문에 이 구분이 중요해집니다.

짧은 답
#

  • 이 504는 방문자가 아니라 Cloudflare의 Early Hints 캐시가 기록한 것입니다. Cloudflare 로그에서는 RequestSource = earlyHintsCache로 표시됩니다.
  • 여기서 504는 캐시 미스라는 뜻입니다. 그 URL에 대해 저장된 힌트가 없었다는 것이죠. 히트는 2xx로 기록됩니다.
  • 이 요청은 오리진에 절대 도달하지 않습니다. OriginResponseStatus가 0입니다.
  • Early Hints는 Link HTTP 응답 헤더로 동작합니다. HTML 안의 <link rel="preload"> 태그로는 동작하지 않습니다.
  • 헤더를 보내면, 미리 불러올 것이 있는 페이지에서는 이 미스가 히트로 바뀌고 진짜 103 Early Hints 응답이 나가기 시작합니다.

가짜 Cloudflare 504와 진짜 504 구별하기
#

Logpush나 Instant Logs를 쓴다면 무엇보다 먼저 두 필드를 보세요. RequestSource는 실제 방문자 요청이면 eyeball, Early Hints 조회면 earlyHintsCache입니다. OriginResponseStatus가 0이면 오리진에 연결조차 하지 않은 것입니다.

GraphQL Analytics API에서는 같은 구분이 requestSource 디멘션으로 나옵니다. 저는 이제 5xx가 튀면 믿기 전에 이 쿼리부터 돌립니다.

query ($zone: String!, $since: Time!) {
  viewer {
    zones(filter: { zoneTag: $zone }) {
      httpRequestsAdaptiveGroups(
        limit: 20
        filter: { datetime_geq: $since, edgeResponseStatus: 504 }
        orderBy: [count_DESC]
      ) {
        count
        dimensions { requestSource userAgent clientRequestPath }
      }
    }
  }
}

User-Agent로 묶어 봐도 같은 결과가 나옵니다. 9월에는 Early Hints 조회가 bastion early hints와 nginx-ssl early hints로 찍혔습니다. 11월에는 fl2 early hints와 nginx-ssl early hints였습니다(FL2는 Cloudflare 프록시의 새 세대입니다). 24시간 동안 이 존의 504 2,631건이 전부 이 User-Agent에서 나왔습니다. 브라우저, 앱, curl, 검색 크롤러에서 나온 것은 하나도 없었습니다.

방문자가 실제로 본 진짜 504는 Cloudflare 바깥, 즉 여러분 쪽에서 나온 것입니다. 애플리케이션 앞에 있는 프록시나 로드 밸런서가 기다리다 포기한 것이죠. nginx일 수도 있고, 클라우드 로드 밸런서일 수도 있고, 잠든 머신이 깨어나길 기다리는 플랫폼 프록시일 수도 있습니다. Cloudflare 자체의 타임아웃은 다른 코드를 씁니다. 522는 오리진에 연결하지 못했다는 뜻이고, 524는 연결은 됐지만 100초(Enterprise 미만 요금제의 기본값) 안에 응답을 받지 못했다는 뜻입니다.

그러니 504 때문에 Cloudflare 타임아웃을 늘리는 방법을 찾다가 이 글에 오셨다면: 504 뒤에 있는 타임아웃은 거의 Cloudflare 것이 아닙니다. nginx의 proxy_read_timeout, 로드 밸런서의 idle timeout, 또는 플랫폼이 머신을 깨우는 데 걸리는 시간을 확인하세요. 쓸 만한 단서가 하나 있습니다. 요청이 100초보다 한참 짧은 딱 떨어지는 숫자, 예를 들어 30초나 60초에서 실패한다면 Cloudflare가 아니라 여러분 프록시의 한도입니다. (Cloudflare의 100초 한도는 Enterprise 요금제에서만 최대 6,000초까지 늘릴 수 있고, Cloudflare 스스로도 이를 임시방편으로 봅니다.)

같은 주에 API에서는 진짜 504가 났다
#

이 구분이 중요한 이유가 여기 있습니다. 며칠 전, 바로 그 API가 실제로 무너졌습니다. API는 Fly.io의 shared-cpu-1x 머신에서 돌고 있었는데, 이 머신은 버스트 크레딧 방식입니다. 뉴질랜드 피크 트래픽에서 버스트를 다 써 버리자 초당 약 20~30 요청 수준의 지속 상한으로 스로틀링됐습니다. 응답 시간이 무너졌고 방문자들은 엣지에서 진짜 504를 받았습니다. shared-cpu-2x로 옮기자 해결됐습니다.

그런데 504 차트가 다시 가득 찼습니다. 이번에는 Early Hints 미스였죠. requestSource로 나누지 않고 보면, 그 차트는 더 큰 머신이 효과가 없었다고 말합니다. 실제로는 같은 주, 같은 존에 서로 다른 두 종류의 504가 있었고, 그중 사람에게 닿은 것은 하나뿐이었습니다. request source로 나눠 보지 않은 5xx 차트를 근거로 용량을 결정하지 마세요.

이 504의 정체
#

Early Hints가 켜진 존에서는 모든 요청마다 내부 조회가 하나 더 붙습니다. Cloudflare가 보낼 힌트가 있는지 자기 캐시를 확인하는 것이죠. 이 조회는 RequestSource = earlyHintsCache로 별도 줄에 기록됩니다. 최종 사용자 요청이 아니고, 오리진으로 보내지지도 않습니다.

그 줄의 상태 코드는 에러가 아니라 캐시 조회 결과입니다.

  • 2xx는 히트입니다. Cloudflare에 그 URL용 Link 헤더가 저장돼 있었고, 이를 103으로 브라우저에 보냈다는 뜻입니다. 제 존에서는 히트가 200과 204 두 가지로 나타납니다.
  • 504는 미스입니다. 저장된 것이 없어서 미리 보낼 것이 없었다는 뜻입니다.

오리진이 Early Hints가 쓰는 헤더를 한 번도 보내지 않으면 모든 조회가 미스가 되고, 모든 요청이 504 줄을 하나씩 남깁니다. 제가 아무것도 바꾸기 전 3시간 동안 이 존은 Early Hints 조회를 173건 기록했고, 173건 모두 미스였습니다. 기능이 켜진 뒤로 힌트를 한 번도 보낸 적이 없었던 겁니다.

9월 글에 대한 정정도 하나 하겠습니다. 저는 그때 이것을 “Fly 오리진에서 응답을 받지 못하는” 프로브라고 설명했습니다. 정확한 설명이 아닙니다. 이 조회는 오리진에 아예 가지 않습니다. 504는 타임아웃이 아니라 캐시 미스를 기록한 것입니다.

HTTP 103은 에러인가?
#

아닙니다. 1xx 대의 상태 코드는 정보성 응답, 즉 서버가 진짜 응답 전에 보낼 수 있는 중간 응답입니다. RFC 8297에 정의된 103 Early Hints는 Link 헤더를 담고 있어서, 서버가 아직 페이지를 만드는 동안 브라우저가 핵심 CSS, JavaScript, 폰트를 미리 받거나 다른 오리진에 연결을 열어 둘 수 있게 해 줍니다. 최종 200은 같은 요청에서 뒤이어 옵니다.

지원 범위는 고르지 않지만 안전합니다. Chromium 계열 브라우저(Chrome 103 이상, Edge)와 최신 Firefox는 preload와 preconnect 힌트를 모두 처리합니다. Safari는 preconnect만 씁니다. 브라우저는 103을 HTTP/2와 HTTP/3에서만 처리합니다. 이해하지 못하는 브라우저는 그냥 평소처럼 200을 기다릴 뿐이니, 103은 대부분의 방문자에게는 출발 신호를 앞당겨 주고 나머지에게는 아무 영향이 없습니다.

제 설정이 비어 있던 이유: Link 헤더 vs <link rel=preload>#

여기서 시간을 가장 많이 썼습니다. 이미 동작해야 할 것처럼 보였기 때문입니다.

Early Hints가 캐시하는 것은 딱 하나, rel=preload나 rel=preconnect 관계를 가진 Link HTTP 응답 헤더입니다. Cloudflare는 평소 응답(HTML 또는 확장자 없는 URL의 200, 301, 302)에서 이 헤더를 읽어 저장해 두고, 다음 방문자에게 전체 응답이 준비되기 전에 103으로 다시 보내 줍니다.

제 페이지에는 이미 preload가 있었습니다. 다만 HTML <head> 안의 태그로요.

<link rel="preload" href="/_expo/static/css/global-7d77bd.css" as="style">
<link rel="preload" href="/_expo/static/js/web/entry-fd7fa4.js" as="script">
<link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin>

이 태그도 쓸모가 있습니다. 브라우저가 이걸 보고 움직이니까요. 하지만 브라우저는 HTML을 받아서 파싱을 시작한 뒤에야 이 태그를 봅니다. Early Hints가 앞지르려는 바로 그 순간이죠. Expo 익스포트와 Hugo 둘 다 이 태그는 알아서 써 주지만, 헤더는 둘 다 설정하지 않습니다. 결국 기능은 켜져 있고 preload도 선언돼 있었지만, 둘은 한 번도 만나지 못했습니다.

해결책: 이미 가진 데이터로 헤더를 보내기
#

가장 깔끔한 해결책은 번들 해시가 바뀔 때마다 낡아 버리는 핵심 에셋 목록을 손으로 관리하는 것이 아닙니다. 각 페이지는 무엇을 미리 불러올지 이미 그 태그로 선언하고 있습니다. 그러니 페이지를 서빙할 때 그 태그를 Link 헤더로 옮겨 적으면 됩니다.

import re

PRELOAD = re.compile(rb"""<link\b[^>]*?\brel=["']?preload["']?[^>]*?>""", re.I)


def attr(tag: bytes, name: bytes) -> bytes | None:
    # A real attribute: preceded by whitespace, followed by =, space, / or >.
    m = re.search(
        rb"(?<=\s)" + re.escape(name)
        + rb"""(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?(?=[\s/>])""",
        tag,
        re.I,
    )
    if not m:
        return None
    return next((g for g in m.groups() if g is not None), b"")


def link_header(html: bytes, limit: int = 12) -> str | None:
    parts, seen = [], set()
    for tag in PRELOAD.findall(html):
        href = attr(tag, b"href")
        if not href or href in seen:
            continue
        seen.add(href)
        seg = f"<{href.decode()}>; rel=preload"
        if as_ := attr(tag, b"as"):
            seg += f"; as={as_.decode()}"
        if as_ == b"font" or attr(tag, b"crossorigin") is not None:
            seg += "; crossorigin"
        parts.append(seg)
        if len(parts) == limit:
            break
    return ", ".join(parts) or None

그리고 Django에서 HTML 응답에 붙입니다.

response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
    response["Link"] = link

저를 물었던 디테일 두 가지는 꼭 챙길 만합니다.

  • 속성 파서의 경계를 고정하세요. as를 단순하게 찾으면 href="/assets/..." 안의 “as"가 먼저 걸리고, 폰트 preload에서 as=font가 소리 없이 빠집니다. 속성 이름이 공백 뒤에서 시작해 =, 공백, /, >에서 끝나도록 강제하세요. Hugo 같은 정적 사이트 생성기는 href=/fonts/x.woff2 as=font crossorigin처럼 따옴표 없이 쓰기 때문에, 따옴표 있는 속성과 없는 속성을 모두 처리해야 합니다. 이건 단위 테스트가 잡아 줬습니다.
  • 폰트에는 crossorigin을 유지하세요. 이게 없는 폰트 preload는 다른 요청으로 취급돼서 폰트를 두 번 내려받게 되고, 페이지는 빨라지기는커녕 느려집니다.

103을 직접 쓸 필요는 없습니다. ASGI, Granian, uvicorn에 중간 응답 코드를 추가할 일도 없습니다. Cloudflare가 여러분의 헤더로 103을 만들고, 여러분이 할 일은 헤더를 보내는 것뿐입니다.

다른 스택에서는
#

규칙은 어디서나 같습니다. HTML 응답에 Link 헤더를 달고 나머지는 Cloudflare에 맡기세요.

  • nginx에서는 HTML을 서빙하는 location에:

    location = / {
        add_header Link "</css/app.css>; rel=preload; as=style" always;
        add_header Link "</fonts/inter.woff2>; rel=preload; as=font; crossorigin" always;
    }
  • Laravel과 Vite 조합에는 이미 이를 위한 미들웨어 AddLinkHeadersForPreloadedAssets가 있어서, Vite의 preload를 Link 헤더로 바꿔 줍니다. 여기에 보고된 이슈 두 가지가 위의 함정과 똑같습니다. modulepreload 힌트가 crossorigin 없이 나가서 브라우저가 이를 버리고 스크립트를 두 번 받을 수 있었고, 큰 페이지에서는 헤더가 Apache의 약 8KB 헤더 한도를 넘기도 했습니다. 쓰는 버전에서 둘 다 확인하세요.

  • 그 밖의 환경: Link 헤더 하나에 쉼표로 이어 쓰거나, Link 헤더를 여러 개 보내면 됩니다. 둘 다 됩니다.

사람들이 가장 많이 과하게 하는 부분은 무엇을 힌트로 줄지입니다. 렌더링에 꼭 필요한 CSS, 진입 스크립트, 첫 화면에 쓰이는 폰트 한두 개만 힌트로 주고, 모든 청크를 다 넣지는 마세요. 목록이 길면 브라우저가 지금 필요한 것보다 아직 필요 없는 것을 먼저 받게 됩니다. 저는 12개로 제한했는데, 그것도 Expo 익스포트가 preload 목록을 이미 추려 주기 때문입니다.

Cloudflare 뒤에 있지 않거나 CDN이 Early Hints를 지원하지 않는다면, Link 헤더의 효과는 조금뿐입니다. 브라우저가 본문을 파싱하기 전, 응답 헤더가 도착하자마자 그 리소스를 받기 시작할 수 있다는 정도죠. 서버나 CDN이 보내 주지 않는 한 103은 나가지 않습니다.

제대로 동작하는지 확인하는 법
#

curl로 103을 바로 볼 수 있습니다. HTTP/2로 요청하고 상태 줄과 Link 헤더만 남기세요.

$ curl -sv --http2 -o /dev/null https://example.com/register 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </_expo/static/css/reset-f5d396.css>; as=style; rel=preload, …
< HTTP/2 200
< link: </_expo/static/css/reset-f5d396.css>; rel=preload; as=style, …

103이 먼저 오고, 그다음에 200이 옵니다. Cloudflare가 힌트 안의 속성 순서를 바꾸는데, 문제없습니다. 모든 요청이 103을 받을 거라고 기대하지는 마세요. 제 경우 처음 세 번 중 두 번만 받았습니다. 변경 후 첫 방문은 헤더를 저장만 하고, 최종 응답 헤더가 이미 준비돼 있으면 Cloudflare가 103을 건너뜁니다.

그다음 위의 분석 쿼리로 돌아가 requestSource: "earlyHintsCache"로 필터링하고 상태 코드와 경로로 묶어 보세요. 고친 페이지에서는 미스 대신 히트가 보여야 합니다.

무엇이 바뀌었고, 무엇이 남았나
#

헤더를 배포하고 몇 분 만에 이 존은 처음으로 Early Hints 히트를 기록했고, curl에서는 웹 페이지에 진짜 103 응답이 보였습니다. 헤더 하나로 두 문제가 같이 풀렸습니다. 기능이 동작하기 시작했고, “504"의 일부가 사라졌습니다.

남은 문제는 JSON API에는 미리 불러올 것이 없다는 점입니다. API 엔드포인트는 계속 미스가 나고 504 줄을 계속 남기는데, API 비중이 큰 존에서는 이게 여전히 대부분입니다. 합리적인 선택지는 세 가지입니다.

  1. 효과가 있는 페이지에 헤더를 보내고, API 경로가 계속 미스를 남기는 것은 받아들입니다. 기능은 동작하고, 차트는 조용해지지만 완전히 조용하지는 않습니다.
  2. earlyHintsCache를 5xx 알림과 대시보드에서 제외합니다. 무엇을 결정하든 이건 하세요. 내부 캐시 조회 때문에 누가 호출을 받아서는 안 됩니다.
  3. Early Hints를 끕니다(대시보드의 Speed → Optimization, 또는 API의 존 설정 early_hints). Link 헤더를 앞으로도 보내지 않을 거라면요. HTML이 아주 작은 싱글 페이지 앱은 얻는 게 별로 없을 수 있고, 그러면 모든 조회가 멈추면서 노이즈도 같이 사라집니다.

저는 켜 두기로 했습니다. 웹 페이지는 이제 출발이 빨라졌고, API의 미스는 알림이 걸리는 모든 곳에서 걸러 냈습니다. 9월에 기능을 끄는 대신 했어야 할 일이 바로 이것이었습니다.

체크리스트
#

  1. Cloudflare 5xx 급증을 장애라고 부르기 전에, requestSource(또는 User-Agent)로 나눠 보세요.
  2. earlyHintsCache 504는 OriginResponseStatus = 0인 캐시 미스입니다. 무해하고, 오리진에 도달하지 않습니다.
  3. 진짜 504는 여러분의 프록시나 로드 밸런서에서 나옵니다. Cloudflare 자체의 타임아웃은 522(연결 불가)와 524(100초 동안 응답 없음)입니다.
  4. Early Hints에는 Link 응답 헤더가 필요합니다. HTML 안의 preload 태그는 해당되지 않습니다.
  5. 렌더링에 꼭 필요한 CSS, 스크립트, 폰트만 힌트로 주고, 폰트에는 crossorigin을 유지하세요.
  6. curl -sv --http2로 확인하세요. 200 전에 103이 보여야 합니다.
  7. earlyHintsCache는 알림에서 빼 두세요.
  8. 헤더를 앞으로도 보내지 않을 거라면, 로그 노이즈로 비용을 치르느니 Early Hints를 끄세요.

댓글

댓글은 검토 후 게시됩니다.