Tháng 9, tôi đã viết về một bảng điều khiển Cloudflare báo 22% request bị lỗi trong khi mọi health check vẫn xanh. Hóa ra phần lớn số 504 đó đến từ tính năng Early Hints của Cloudflare chứ không phải từ người dùng của tôi, nên tôi tắt Early Hints và chuyển sang việc khác.
Đến tháng 11, tôi mở lại đúng zone đó và thấy thêm 2.600 lỗi 504 chỉ trong một ngày. Early Hints lại đang bật. Lần này tôi muốn biết chính xác những lỗi 504 đó là gì, và cách đúng là tắt tính năng thêm lần nữa hay làm cho nó thực sự làm đúng việc của mình.
Zone này đứng trước một origin trên Fly.io, và origin đó phục vụ ba thứ: một Django API, một web app viết bằng Expo, và một trang marketing dựng bằng Hugo. Hãy ghi nhớ cái API. Nó có một câu chuyện năng lực xử lý thật của riêng nó, và đó chính là lý do sự phân biệt này quan trọng.
Câu trả lời ngắn#
- Những lỗi 504 này được ghi bởi bộ cache Early Hints của Cloudflare, không phải bởi khách truy cập. Trong log của Cloudflare, chúng có
RequestSource = earlyHintsCache. - Ở đây, 504 nghĩa là cache miss: Cloudflare không có gợi ý nào được lưu cho URL đó. Một lần hit được ghi là 2xx.
- Chúng không bao giờ chạm tới origin của bạn.
OriginResponseStatuslà0. - Early Hints chạy bằng response header HTTP
Link. Các thẻ<link rel="preload">trong HTML không cung cấp dữ liệu cho nó. - Gửi header đó đi, và trên những trang có thứ cần preload, các lần miss sẽ chuyển thành hit và bắt đầu có phản hồi
103 Early Hintsthật.
Cách phân biệt 504 Cloudflare giả với 504 thật#
Nếu bạn dùng Logpush hoặc Instant Logs, hãy xem hai trường trước tiên. RequestSource là eyeball với request của khách truy cập thật, và là earlyHintsCache với các lượt tra cứu Early Hints. OriginResponseStatus bằng 0 nghĩa là origin chưa từng được liên hệ.
Trong GraphQL Analytics API, sự phân tách đó chính là dimension requestSource. Đây là truy vấn tôi luôn chạy trước khi tin vào bất kỳ đợt tăng vọt 5xx nào:
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 }
}
}
}
}Nhóm theo User-Agent cũng cho ra cùng một câu chuyện. Vào tháng 9, các lượt tra cứu Early Hints hiện lên dưới dạng bastion early hints và nginx-ssl early hints. Đến tháng 11 thì là fl2 early hints và nginx-ssl early hints (FL2 là thế hệ proxy mới của Cloudflare). Trong 24 giờ, toàn bộ 2.631 lỗi 504 của zone đều đến từ những User-Agent này. Không có lỗi nào đến từ trình duyệt, ứng dụng, curl hay crawler của công cụ tìm kiếm.
Một lỗi 504 thật mà khách truy cập nhìn thấy thì đến từ phía bạn, sau Cloudflare: một proxy hay load balancer đứng trước ứng dụng đã chờ quá lâu và bỏ cuộc. Đó có thể là nginx, một load balancer trên cloud, hoặc proxy của nền tảng đang chờ một máy đang ngủ khởi động. Các timeout của chính Cloudflare dùng mã khác: 522 nghĩa là không kết nối được tới origin, còn 524 nghĩa là đã kết nối nhưng không nhận được phản hồi trong vòng 100 giây (mặc định trên các gói dưới Enterprise).
Vậy nếu bạn tìm đến đây vì muốn tăng timeout của Cloudflare do lỗi 504: timeout đứng sau một lỗi 504 gần như không bao giờ là của Cloudflare. Hãy kiểm tra proxy_read_timeout trong nginx, idle timeout của load balancer, hoặc thời gian nền tảng của bạn cần để đánh thức một máy. Một manh mối hữu ích: nếu request thất bại ở một con số tròn thấp hơn hẳn 100 giây, như 30 hay 60, thì đó là giới hạn của proxy của bạn, không phải của Cloudflare. (Chỉ gói Enterprise mới nâng được giới hạn 100 giây của Cloudflare, tối đa 6.000 giây, và chính Cloudflare cũng coi đó là giải pháp tạm thời.)
Cùng tuần đó, API gặp lỗi 504 thật#
Đây là lý do việc tách bạch quan trọng. Vài ngày trước đó, chính cái API ấy đã thực sự sập. Nó chạy trên máy shared-cpu-1x của Fly.io, loại máy hoạt động theo burst credit: dưới lưu lượng giờ cao điểm ở New Zealand, nó dùng hết burst và bị bóp xuống mức trần bền vững khoảng 20 đến 30 request mỗi giây. Thời gian phản hồi sụp đổ và khách truy cập nhận lỗi 504 thật ở edge. Chuyển sang shared-cpu-2x đã khắc phục được.
Rồi biểu đồ 504 lại đầy lên, lần này là các lần miss của Early Hints. Nếu đọc mà không tách theo requestSource, biểu đồ đó sẽ nói rằng máy lớn hơn chẳng giúp được gì. Thực tế, cùng một tuần, trên cùng một zone, có hai nhóm 504 hoàn toàn khác nhau, và chỉ một nhóm từng chạm tới con người. Đừng ra quyết định về năng lực xử lý dựa trên một biểu đồ 5xx chưa được tách theo request source.
Bản chất thật của những lỗi 504 này#
Trên một zone đã bật Early Hints, mỗi request đều kèm thêm một lượt tra cứu nội bộ: Cloudflare kiểm tra cache của chính nó xem có gợi ý nào để gửi không. Lượt tra cứu đó được ghi thành một dòng riêng, với RequestSource = earlyHintsCache. Nó không phải request của người dùng cuối, và không bao giờ được gửi tới origin của bạn.
Mã trạng thái trên dòng đó là kết quả tra cứu cache, không phải lỗi:
- 2xx là hit. Cloudflare có lưu header
Linkcho URL đó và đã gửi chúng tới trình duyệt dưới dạng103. Trên zone của tôi, hit xuất hiện dưới cả hai mã200và204. - 504 là miss. Không có gì được lưu, nên không có gì để gửi sớm.
Nếu origin không bao giờ gửi header mà Early Hints dựa vào, mọi lượt tra cứu đều miss và mọi request đều ghi một dòng 504. Trong ba giờ trước khi tôi thay đổi bất cứ điều gì, zone ghi nhận 173 lượt tra cứu Early Hints và cả 173 đều là miss. Kể từ khi được bật, tính năng này chưa từng gửi đi một gợi ý nào.
Một điều chỉnh cho bài viết tháng 9 của tôi: khi đó tôi mô tả chúng là các probe “không nhận được phản hồi từ origin trên Fly”. Điều đó không hoàn toàn đúng. Chúng hoàn toàn không đi tới origin. Mã 504 ghi lại một lần cache miss, không phải một lần timeout.
HTTP 103 có phải là lỗi không?#
Không. Các mã trạng thái trong dải 1xx là phản hồi thông tin: những phản hồi tạm thời mà máy chủ có thể gửi trước phản hồi thật. 103 Early Hints, được định nghĩa trong RFC 8297, mang theo header Link để trình duyệt có thể bắt đầu tải CSS, JavaScript và font quan trọng, hoặc mở sẵn kết nối tới các origin khác, trong khi máy chủ vẫn đang dựng trang. Mã 200 cuối cùng sẽ đến sau trên cùng một request.
Mức hỗ trợ không đồng đều nhưng an toàn. Các trình duyệt dựa trên Chromium (Chrome từ phiên bản 103, và Edge) cùng Firefox bản mới xử lý cả gợi ý preload lẫn preconnect. Safari chỉ dùng preconnect. Trình duyệt chỉ xử lý 103 qua HTTP/2 và HTTP/3. Trình duyệt nào không hiểu nó thì đơn giản là chờ 200 như mọi khi, nên 103 là một lợi thế xuất phát sớm cho phần lớn khách truy cập và không ảnh hưởng gì tới số còn lại.
Vì sao của tôi trống trơn: header Link so với <link rel=preload>#
Đây là chỗ tôi mất nhiều thời gian nhất, vì trông nó như thể lẽ ra đã hoạt động rồi.
Early Hints chỉ cache đúng một thứ: response header HTTP Link có quan hệ rel=preload hoặc rel=preconnect. Cloudflare đọc nó từ các phản hồi thông thường của bạn (mã 200, 301 hoặc 302, trên URL HTML hoặc không có phần mở rộng), lưu lại, rồi phát lại cho khách truy cập tiếp theo dưới dạng 103 trước khi phản hồi đầy đủ sẵn sàng.
Các trang của tôi vốn đã có preload, nhưng ở dạng thẻ trong <head> của HTML:
<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>Những thẻ này vẫn có tác dụng: trình duyệt hành động theo chúng. Nhưng trình duyệt chỉ thấy chúng sau khi đã nhận HTML và bắt đầu phân tích, mà đó lại chính là khoảnh khắc Early Hints muốn đi trước. Bản export của Expo và Hugo đều tự viết các thẻ này, nhưng không cái nào đặt header. Vậy là tính năng đã bật, preload cũng đã khai báo, nhưng hai thứ chưa bao giờ gặp nhau.
Cách sửa: gửi header từ dữ liệu bạn vốn đã có#
Cách sạch nhất không phải là tự tay duy trì một danh sách tài nguyên quan trọng, thứ sẽ lỗi thời mỗi khi hash của bundle thay đổi. Mỗi trang đã tự khai báo những gì nó muốn preload, trong chính các thẻ đó. Vậy chỉ cần chép chúng sang header Link khi phục vụ trang:
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 NoneVà gắn nó vào phản hồi HTML trong Django:
response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
response["Link"] = linkCó hai chi tiết đã làm tôi vấp, và đáng để giữ lại:
- Neo chặt bộ phân tích thuộc tính. Tìm kiếm
asmột cách ngây thơ sẽ khớp ngay chữ “as” tronghref="/assets/...", và các preload font của bạn lặng lẽ mấtas=font. Hãy bắt buộc tên thuộc tính bắt đầu sau khoảng trắng và kết thúc ở=, khoảng trắng,/hoặc>. Đồng thời xử lý cả thuộc tính có và không có dấu ngoặc kép, vì các trình tạo site tĩnh như Hugo xuất rahref=/fonts/x.woff2 as=font crossoriginmà không có dấu ngoặc. Lỗi này là do unit test bắt được. - Giữ
crossorigincho font. Một preload font thiếu nó sẽ bị coi là một request khác, font bị tải về hai lần, và trang chậm đi chứ không nhanh hơn.
Bạn không cần tự viết 103. Không có đoạn mã phản hồi tạm thời nào phải thêm vào ASGI, Granian hay uvicorn. Cloudflare dựng 103 từ header của bạn; việc duy nhất của bạn là gửi header đó.
Trên các stack khác#
Quy tắc ở đâu cũng vậy: đặt header Link lên phản hồi HTML và để Cloudflare lo phần còn lại.
nginx, trong location phục vụ HTML:
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 dùng Vite đã có sẵn middleware cho việc này,
AddLinkHeadersForPreloadedAssets, chuyển các preload của Vite thành headerLink. Hai vấn đề từng được báo cáo với nó cũng chính là những cái bẫy ở trên: các gợi ýmodulepreloadcủa nó từng được gửi đi mà không cócrossorigin, khiến trình duyệt có thể bỏ qua chúng và tải script hai lần, và trên các trang lớn, header từng vượt quá giới hạn header khoảng 8 KB của Apache. Hãy kiểm tra cả hai trên phiên bản bạn đang dùng.Mọi môi trường khác: dùng một header
Linkphân tách bằng dấu phẩy, hoặc gửi nhiều headerLink. Cách nào cũng được.
Chỗ mọi người hay làm quá tay là chọn gợi ý những gì. Chỉ gợi ý CSS quan trọng cho việc render, script đầu vào và một hai font dùng ở màn hình đầu tiên, đừng nhét mọi chunk vào. Danh sách dài khiến trình duyệt đi tải những thứ chưa cần trước cả những thứ đang cần. Tôi giới hạn ở mười hai mục, và đó là vì bản export của Expo đã chọn lọc sẵn danh sách preload.
Nếu bạn không đứng sau Cloudflare, hoặc CDN của bạn không hỗ trợ Early Hints, header Link vẫn giúp được một chút: trình duyệt có thể bắt đầu tải các tài nguyên đó ngay khi header phản hồi tới nơi, trước khi phân tích phần thân. Nhưng sẽ không có 103 nào trừ khi máy chủ hoặc CDN của bạn tự gửi.
Cách kiểm tra xem nó đã hoạt động chưa#
curl cho bạn thấy 103 trực tiếp. Gửi request bằng HTTP/2 và chỉ giữ lại các dòng trạng thái cùng header 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 đến trước, rồi mới tới 200. Cloudflare sắp xếp lại thứ tự các thuộc tính trong gợi ý, điều này vô hại. Đừng kỳ vọng mọi request đều nhận được 103. Trong ba lần đầu của tôi, chỉ có hai lần nhận được. Lần truy cập đầu tiên sau khi thay đổi chỉ lưu header lại, và Cloudflare bỏ qua 103 khi header của phản hồi cuối cùng đã sẵn sàng.
Sau đó quay lại truy vấn phân tích ở trên, lọc theo requestSource: "earlyHintsCache", nhóm theo mã trạng thái và đường dẫn. Trên những trang bạn đã sửa, hit sẽ dần thay thế miss.
Điều gì đã thay đổi, và điều gì còn lại#
Vài phút sau khi header được đưa lên production, zone ghi nhận những lần hit Early Hints đầu tiên trong lịch sử, và curl cho thấy phản hồi 103 thật trên các trang web. Một header giải quyết cùng lúc hai vấn đề: tính năng bắt đầu hoạt động, và một phần các “lỗi 504” biến mất.
Điều còn lại là một JSON API chẳng có gì để preload. Các endpoint API tiếp tục miss và tiếp tục ghi các dòng 504, và trên một zone nặng về API thì phần này vẫn chiếm đa số. Bạn có ba lựa chọn hợp lý:
- Gửi header trên những trang có lợi và chấp nhận các đường dẫn API tiếp tục ghi miss. Tính năng hoạt động, các biểu đồ yên tĩnh hơn nhưng không hoàn toàn im lặng.
- Loại
earlyHintsCachekhỏi cảnh báo 5xx và dashboard. Dù bạn quyết định gì thì cũng hãy làm việc này. Một lượt tra cứu cache nội bộ không bao giờ nên đánh thức ai dậy. - Tắt Early Hints (Speed → Optimization trong dashboard, hoặc thiết lập
early_hintscủa zone qua API) nếu bạn sẽ không bao giờ gửi headerLink. Một ứng dụng một trang có HTML rất nhỏ có thể không được lợi gì nhiều, và khi tắt thì mọi lượt tra cứu dừng lại, tiếng ồn cũng biến mất theo.
Tôi giữ nó bật. Các trang web giờ đã được xuất phát sớm hơn, các lần miss của API đã bị lọc khỏi mọi thứ có thể kích hoạt cảnh báo, và đó mới là điều lẽ ra tôi phải làm vào tháng 9 thay vì tắt tính năng đi.
Danh sách kiểm tra#
- Trước khi gọi một đợt tăng vọt 5xx trên Cloudflare là sự cố, hãy tách nó theo
requestSource(hoặc User-Agent). - Lỗi 504 của
earlyHintsCachelà cache miss vớiOriginResponseStatus = 0. Chúng vô hại và không bao giờ chạm tới origin. - Lỗi 504 thật đến từ proxy hoặc load balancer của bạn. Timeout của chính Cloudflare là 522 (không kết nối được) và 524 (100 giây không có phản hồi).
- Early Hints cần response header
Link. Thẻ preload trong HTML không được tính. - Chỉ gợi ý CSS, script và font quan trọng cho việc render, và giữ
crossorigincho font. - Kiểm tra bằng
curl -sv --http2: bạn sẽ thấy103trước200. - Để
earlyHintsCachera ngoài hệ thống cảnh báo. - Nếu bạn sẽ không bao giờ gửi header, hãy tắt Early Hints thay vì trả giá cho nó bằng tiếng ồn trong log.
