Kỹ thuật6 tháng 8, 20269 phút đọc

Nhận webhook trên Cloudflare Workers: nhìn từ Worker chúng tôi đang vận hành

Không có Node crypto, quỹ CPU tính bằng mili giây và waitUntil không bền vững. Những gì thay đổi khi bên nhận là một Worker, từ chính ingress của AnyHook.

ĐỒNG BỘ · MỘT THỜI HẠN, DÙNG CHUNGStripexác minh + handler AI45 giâytimeout của bên gửi hết ở đâythử lại → chạy trùngXÁC NHẬN Ở EDGE + GỬI BẤT ĐỒNG BỘ · HAI THỜI HẠNStripeedgelưu + 200~1 giâyhàng đợithử lạihandler45s, vẫn ổnđồng hồ của nhà cung cấp dừng ở edge
Lý do đặt bên nhận trên Workers là vì xác nhận và lưu trữ đều rẻ, còn mọi thứ tốn kém có thể diễn ra ở nơi khác.

Ingress của AnyHook là một Cloudflare Worker. Mọi webhook gửi tới dịch vụ đều vào đây trước, được xác minh chữ ký, được lưu lại và bên gửi nhận mã 200 sau khoảng một giây, phần lớn thời gian đó là một vòng đi về tới region của hàng đợi. Hệ thống này đã chạy trên production được một thời gian, và những chỗ khó chịu lại không phải những chỗ tôi đã đoán.

Bài này là phần đi kèm, nhìn từ phía bên nhận, của bài Nhận webhook trên Vercel mà không bị mất. Các kiểu hỏng thực sự khác nhau, và phần lớn đến từ việc Workers không phải Node.

TL;DR

  • Mặc định không có node:crypto. Xác minh chữ ký phải đi qua WebCrypto, vốn là bất đồng bộ
  • Đọc body một lần, dưới dạng byte, trước khi bất cứ thứ gì parse nó, như ở mọi nơi khác
  • Tự áp giới hạn kích thước payload ngay trong lúc stream, vì đọc hết một body lớn rồi mới biết nó lớn là sai thứ tự
  • ctx.waitUntil kéo dài request sau khi đã phản hồi nhưng không bền vững, y như của Vercel
  • Quỹ CPU trên gói Workers Free nhỏ và tách biệt với thời gian thực (wall-clock). Chờ một API chậm không tốn quỹ đó; một vòng lặp dày đặc trên payload lớn thì có
  • Worker là nơi rất tốt để xác nhận (ack) và lưu trữ, nhưng là nơi tồi để chạy business logic

Xác minh chữ ký là bất đồng bộ, và điều đó thay đổi luồng điều khiển

Trên Node, bạn viết một hàm xác minh đồng bộ và gọi nó sớm. Các method của WebCrypto trả về promise, nên cùng logic đó trở thành bất đồng bộ, và bạn dễ bị cám dỗ await nó sau khi đã parse body để định tuyến. Đừng làm vậy. Xác minh phải đi trước mọi thứ tin vào payload.

Đây là khung mà ingress của chúng tôi dùng để giải mã secret của nhà cung cấp đã lưu, trước khi dùng nó để xác minh. Mọi thứ đều là crypto.subtle:

const keyMaterial = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
  "AES-GCM",
  false,
  ["decrypt"],
);

const decrypted = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv, tagLength: 128 },
  keyMaterial,
  dataToDecrypt,
);

Hai chi tiết sẽ làm bạn mất thời gian nếu phải tự vấp vào.

AES-GCM của WebCrypto yêu cầu auth tag được nối vào cuối ciphertext, trong khi createDecipheriv của Node nhận nó riêng qua setAuthTag. Nếu bạn mã hóa trên Node rồi giải mã trên Worker, bạn phải tự nối hai phần lại. Không có gì báo cho bạn biết điều này; bạn chỉ nhận được một OperationError.

WebCrypto không có timingSafeEqual chuẩn. Khi xác minh HMAC, nên dùng crypto.subtle.verify, hàm này so sánh giúp bạn trong thời gian hằng số, thay vì tự tính digest rồi so sánh chuỗi.

Bạn có thể bật nodejs_compat để có một shim node:crypto, và với một HMAC đơn giản thì đó là đường tắt hợp lý. Nhưng shim chỉ là lớp tương thích bọc quanh cùng những primitive đó, còn viết thẳng trên WebCrypto nghĩa là code xác minh cũng chạy nguyên vẹn trên Deno, Bun và trình duyệt.

Đọc body dưới dạng byte, và dừng sớm nếu nó quá lớn

Quy tắc raw body áp dụng ở mọi nơi: HMAC phủ đúng những byte đã tới, nên đừng parse gì trước khi xác minh. Gọi await request.text() hoặc await request.arrayBuffer() trước tiên.

Điều riêng của một bên nhận ở edge (lớp máy chủ biên của Cloudflare) là bạn là cửa trước cho bất kỳ ai trên internet có URL của bạn, và buffer một body lớn tùy ý rồi mới quyết định là không muốn nhận nó chính là cách bạn bị tính tiền cho lưu lượng của người khác. Ingress của chúng tôi kiểm tra content-length trước, rồi áp giới hạn thêm lần nữa trong lúc stream, vì content-length chỉ là lời khai của client chứ không phải sự thật:

const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;

const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  totalBytes += value.byteLength;
  if (totalBytes > limitBytes) {
    reader.cancel();          // stop pulling bytes we have decided to reject
    return null;
  }
  chunks.push(value);
}

reader.cancel() là phần đáng chép. Không có nó, bạn vẫn tải hết một payload mà bạn đã quyết định từ chối.

Cửa kiểm tra rẻ nhất đi trước

Bên nhận ở edge là một endpoint công khai, và thứ tự các bước kiểm tra là quyết định về chi phí không kém gì quyết định về bảo mật.

Thứ tự của chúng tôi: từ chối request thiếu những header mà nhà cung cấp được xưng danh luôn gửi, sau đó xác minh chữ ký nếu đã cấu hình secret, rồi mới tính sự kiện vào hạn mức. Một request tự xưng là webhook của Stripe mà không có header Stripe-Signature bị từ chối trước khi bất kỳ phép mã hóa nào chạy và trước khi bất cứ thứ gì được đếm. Kẻ tấn công không có đúng header gần như không đốt được gì.

Thứ tự đó chỉ hiệu quả nếu bước kiểm tra rẻ thật sự rẻ. Kiểm tra header có mặt hay không là một lần tra map. Xác minh chữ ký là một lần import key cộng một HMAC. Hạn mức là một lần đọc KV. Xếp chúng theo thứ tự đó, các thao tác tốn kém chỉ chạy trên lưu lượng đã xứng đáng với chúng.

Bên dưới tất cả là giới hạn tốc độ (rate limit) theo IP và theo App (một URL nhận cho một nguồn webhook, ví dụ “Stripe production”), cộng một negative cache ngắn hạn cho những slug không tồn tại, để việc quét tìm endpoint hợp lệ không biến thành hóa đơn.

waitUntil không phải hàng đợi, nhắc lại lần nữa

ctx.waitUntil giữ Worker sống sau khi đã phản hồi để tác vụ nền chạy xong. Nó thực sự hữu ích, nhưng nó không mang lại độ bền vững.

Nếu isolate bị loại bỏ hoặc request bị hủy giữa chừng, phần việc đó mất. Không có thử lại, không có dead letter, không có bản ghi. Mà bạn đã trả về 200 rồi, nên bên gửi coi như sự kiện đã được gửi thành công và sẽ không bao giờ thử lại. Sự kiện đó giờ không tồn tại ở đâu cả.

waitUntil · MÃ 200 LÀ LỜI HỨA BẠN KHÔNG GIỮ ĐƯỢCxác minhtrả về 200waitUntil(work)chỉ nằm trong bộ nhớbị thu hồi → mất, không gửi lạiLƯU TRƯỚC · MÃ 200 LÀ THẬTxác minhghi bền vữngtrả về 200xử lýthử lại được
Mã 200 duy nhất đáng gửi là mã bạn có thể chứng minh bằng thứ đã được ghi lại.

Đây đúng là cái bẫy giống waitUntil của Vercel, và quy tắc cũng y hệt: lưu trước, xác nhận sau. Ghi sự kiện vào nơi bền vững, trả về 200, rồi mới xử lý.

Chúng tôi chỉ dùng waitUntil cho phần việc mất cũng được, như tăng bộ đếm. Nếu một bộ đếm số lần từ chối báo thiếu ba lần trong một lần eviction, không ai bị thiệt. Nếu mất payload của một sự kiện, sẽ có người bị thiệt.

Nó cũng ngắn. Cloudflare hủy phần việc trong waitUntil 30 giây sau khi phản hồi, chia chung cho mọi promise bạn đã giao cho nó, và chỉ riêng một lệnh gọi mô hình cũng có thể vượt quá thời gian đó. Bài Handler webhook của bạn gọi LLM nói về thứ nên dùng thay thế trên từng nền tảng.

Với những gì bắt buộc phải sống sót, lựa chọn là một hàng đợi, một kho lưu trữ bền vững hoặc cả hai. Ingress của chúng tôi publish vào một hàng đợi được quản lý; nếu publish lỗi, nó ghi tạm sự kiện vào R2, và nếu cả bước đó cũng lỗi, nó đưa sự kiện vào Postgres. Nếu cả ba đều lỗi, chúng tôi trả về 503 thay vì 200, để chính cơ chế thử lại của bên gửi trở thành tuyến phòng thủ cuối cùng. Quyết định đó đáng được nói rõ: trả về 200 cho một sự kiện bạn không lưu được ở đâu là một lời nói dối khiến bạn mất sự kiện đó.

Thời gian CPU không phải thời gian thực

Workers tính tiền và giới hạn theo thời gian CPU, không theo thời gian request của bạn ngồi chờ. Điều này làm những người chuyển từ Lambda sang bối rối, vì ở đó hai thứ là một.

Await một lần ghi database hay một API upstream không tốn CPU. Parse một payload JSON hai megabyte thì có. Băm một body lớn thì có. Một vòng lặp qua mười nghìn phần tử mảng thì có.

Với một bên nhận webhook, phần lớn đây là tin tốt, vì cấu trúc bạn vốn muốn gần như toàn là I/O: đọc byte, xác minh, ghi, phản hồi. Những thao tác sẽ làm bạn dính đòn là những thứ người ta thêm vào mà không nghĩ, như JSON.parse trên một payload bạn chỉ cần lưu, hay một regex chạy trên toàn bộ body.

Nếu bạn chỉ cần định tuyến theo một header và lưu lại các byte, đừng parse body gì cả. Lưu nó dưới dạng byte và để consumer phía downstream, nơi có quỹ CPU thực sự, làm việc parse.

Cái gì thuộc về edge và cái gì không

Sau khi chạy trên production, ranh giới rõ ràng hơn hẳn so với lúc đầu.

Hợp với Worker: kết thúc TLS gần bên gửi, từ chối rác, xác minh chữ ký, lưu payload, publish vào hàng đợi, trả về 200 nhanh. Tất cả đều nặng về I/O, đều rẻ, đều nhạy với độ trễ theo cách thực sự được lợi khi ở gần bên gọi.

Không hợp với Worker: business logic của bạn. Bất cứ thứ gì cần connection pool sống lâu, cây dependency lớn, CPU nặng hay hệ sinh thái Node. Không phải vì không thể làm cho nó chạy, mà vì bạn sẽ phải vật lộn với nền tảng mà chẳng được lợi gì, trong khi toàn bộ lý do đặt bên nhận ở edge là để phần tốn kém diễn ra ở nơi khác, theo lịch riêng của nó.

Cách chia đó, xác nhận ở edge và gửi đi bất đồng bộ, là kiến trúc nền tảng của AnyHook, chứ không phải một lập luận chúng tôi nghĩ ra sau đó.

Một cái bẫy khi deploy đáng biết

Nếu bạn chạy nhiều môi trường trong một wrangler.toml, wrangler deploy mà không có --env production sẽ đẩy lên môi trường mặc định. Worker production vẫn chạy code cũ và không có lỗi nào, vì lần deploy thực sự đã thành công, chỉ là không đúng chỗ bạn định.

Chúng tôi đã mất một buổi chiều vì chuyện này. Secret cũng vậy: wrangler secret put NAME --env production, nếu không thì bạn đã đặt nó ở một nơi không phục vụ lưu lượng nào.

Điều cần nhớ

Worker là một bên nhận webhook rất tốt, miễn là bạn chấp nhận bản chất của nó: một cửa trước nặng về I/O, không có Node, không có độ bền vững trong waitUntil và một quỹ CPU trừng phạt những lần parse bạn không cần.

Đọc byte trước khi bất cứ thứ gì parse chúng, hủy stream khi payload vượt giới hạn, xếp các bước từ chối rẻ trước các bước đắt và không bao giờ trả về 200 cho thứ bạn chưa ghi lại bền vững. Mọi thứ khác thuộc về downstream, nơi có thời gian.

Tất cả bài viết6 tháng 8, 2026 · 9 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.