Chuyển đến nội dung chính (Skip to content)
Kiến trúc hệ thống

Timeout budget cho Nginx và Node.js: hủy request chậm, chặn quá tải

Timeout budget cho Nginx và Node.js: hủy request chậm, chặn quá tải

Dựng Nginx, API và downstream chậm để kiểm tra deadline, hủy lời gọi và giới hạn concurrency. Phân biệt 502, 503, 504 bằng một lab có thể chạy lại.

Một API trả 504 sau 60 giây chưa chắc đang thiếu CPU. Có thể client đã bỏ cuộc từ giây thứ ba nhưng API vẫn chờ dịch vụ sau, giữ socket và chiếm một slot xử lý. Khi request tiếp tục đến, phần việc không còn người nhận kết quả vẫn tranh tài nguyên với request mới.

Bài này dựng Nginx, một API Node.js và downstream giả lập chậm. Mục tiêu thực hành là trả lỗi có kiểm soát, hủy lời gọi không còn ích lợi và chặn hàng đợi phình ra. Các mốc 1.400 ms, 1.500 ms và 2 giây là tham số của lab, không phải giá trị khuyến nghị chung cho production. Ảnh bìa chỉ minh họa kiến trúc.

1. Chốt hợp đồng thời gian trước khi viết timeout

Chọn thời gian client sẵn sàng chờ, sau đó phân bổ thời gian cho các chặng. Trong ví dụ: client đợi tối đa 3 giây, Nginx gửi ngân sách 1.500 ms cho API, API dành 100 ms để hoàn thiện response và cho downstream phần còn lại. Khoảng đệm giúp API có cơ hội trả một lỗi có nghĩa trước khi proxy tự cắt kết nối.

Ngân sách thời gian qua từng chặng: Client; Nginx; API; Downstream
Ngân sách thời gian qua từng chặng. proxy_read_timeout giới hạn khoảng chờ giữa các lần đọc, không phải deadline toàn request. Có thể mở ảnh gốc để phóng to.

Nginx mô tả proxy_read_timeout là khoảng chờ giữa hai lần đọc liên tiếp từ upstream. Một upstream đều đặn gửi vài byte có thể giữ kết nối lâu hơn giá trị đó. Do vậy, không lấy read timeout làm tổng deadline cho nghiệp vụ; streaming và SSE còn cần chính sách riêng.

Ngân sách tương đối cũng chưa tự tính thời gian chờ ở proxy hoặc thời gian đọc request body. Lab chỉ xử lý GET nhỏ, không có hàng đợi ở ingress. Với nhiều hop, mỗi hop phải trừ thời gian đã tiêu thụ rồi truyền phần còn lại; nếu dùng deadline tuyệt đối, cần kiểm soát lệch đồng hồ. Không cộng thêm một timeout đầy đủ ở mỗi service.

2. Tạo một lab riêng bằng Docker Compose

Cần Docker Engine/Compose và hai file bên dưới trong một thư mục mới. Cổng 18080 và 18110 phải trống. Node chạy bên trong container, nên không cần cài npm package cho ví dụ này. Compose chỉ publish cổng trên loopback; cấu hình HTTP không có TLS này dùng để thực nghiệm cục bộ.

Image được ghim bằng digest từ lần chạy lab để tránh tag thay đổi làm lệch kết quả. Chỉ chạy cấu hình này trong môi trường thử nghiệm riêng.

File: timeout.compose.yaml

name: thanhnh-architecture-20261020
services:
  api:
    image: node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
    command: ["node", "/lab/timeout.mjs"]
    volumes: ["./timeout.mjs:/lab/timeout.mjs:ro"]
    ports: ["127.0.0.1:18110:18110"]
  nginx:
    image: nginx:stable-alpine@sha256:970364228b1e4f932abc22d321932816782cca8a20b85a77552a7cfe9acfb3b5
    volumes: ["./nginx.conf:/etc/nginx/nginx.conf:ro"]
    ports: ["127.0.0.1:18080:80"]
    depends_on: [api]

Tạo timeout.mjs. Cùng một process chứa API và server chậm để giảm số thành phần phải cài; đây là hai HTTP listener riêng. Production cần tách failure domain và theo dõi riêng từng dịch vụ.

File: timeout.mjs

import http from 'node:http';
import { setTimeout as delay } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';

export function makeServers(apiPort = 18110, slowPort = 18111) {
  const slow = http.createServer(async (req, res) => {
    const stop = new AbortController();
    res.on('close', () => { if (!res.writableEnded) stop.abort(); });
    const ms = Math.min(5000, Math.max(0, Number(new URL(req.url, 'http://lab').searchParams.get('ms')) || 0));
    try {
      await delay(ms, undefined, { signal: stop.signal });
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ waitedMs: ms }));
    } catch { /* The simulated downstream also cooperates with cancellation. */ }
  });
  let inflight = 0;
  const api = http.createServer(async (req, res) => {
    if (inflight >= 4) {
      res.writeHead(503, { 'retry-after': '1' });
      res.end('busy'); return;
    }
    inflight++;
    const disconnected = new AbortController();
    res.on('close', () => { if (!res.writableEnded) disconnected.abort(); });
    const requested = Number(req.headers['x-request-timeout-ms']);
    const budget = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1500) : 1500;
    const deadline = AbortSignal.timeout(Math.max(1, budget - 100));
    const signal = AbortSignal.any([deadline, disconnected.signal]);
    const ms = new URL(req.url, 'http://lab').searchParams.get('ms') || '0';
    try {
      const response = await fetch(`http://127.0.0.1:${slowPort}/?ms=${encodeURIComponent(ms)}`, { signal });
      const body = await response.text(); // Body reading stays inside the deadline.
      if (!res.destroyed) { res.writeHead(response.status); res.end(body); }
    } catch {
      if (!res.destroyed) {
        res.writeHead(deadline.aborted ? 504 : 502);
        res.end(deadline.aborted ? 'downstream deadline exceeded' : 'downstream unavailable');
      }
    } finally { inflight--; }
  });
  return { api, slow, apiPort, slowPort };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const { api, slow, apiPort, slowPort } = makeServers();
  slow.listen(slowPort, '127.0.0.1');
  api.listen(apiPort, '0.0.0.0'); // Container port; Compose publishes only loopback.
  process.on('SIGTERM', () => { api.close(); slow.close(); setTimeout(() => process.exit(0), 5000).unref(); });
}

AbortSignal.timeout tạo signal hết hạn; AbortSignal.any ghép điều kiện hết giờ và client ngắt. Code đọc cả response body bên trong vùng có signal, thay vì chỉ chờ header rồi bỏ quên phần tải body. Trong môi trường lab đã kiểm tra, Node host là 24.15.0; image Node 24 được ghi phiên bản cụ thể trong hồ sơ kiểm thử.

Vì sao theo dõi close trên response?

Đóng một request đầu vào sau khi đọc xong body không đồng nghĩa người gọi đã hủy chờ response. Mẫu theo dõi res.close và kiểm tra writableEnded, tránh hủy công việc chỉ vì đọc request đã xong. Khối finally phải trả slot cả khi fetch lỗi, deadline đến hoặc kết nối bị ngắt.

Server chậm dùng timer có thể hủy nên lab thể hiện được cooperative cancellation. Một truy vấn SQL, tác vụ nén ảnh hoặc SDK khác không mặc nhiên nhận signal này. Cần truyền tín hiệu tới đúng thư viện và biết thao tác nào thật sự dừng. Timeout của client không rollback giao dịch đã commit, cũng không chứng minh downstream chưa thực hiện side effect.

3. Đặt lớp bảo vệ ở Nginx

Tạo nginx.conf. Header budget do proxy gán lại, không giữ nguyên một con số tùy ý từ Internet. Cổng API trực tiếp chỉ phục vụ kiểm tra lab; production phải giới hạn đường vào để client không vượt qua điểm chuẩn hóa này.

File: nginx.conf

events {}
http {
  log_format lab '$request_id status=$status rt=$request_time '
                 'upstream=$upstream_addr urt=$upstream_response_time';
  access_log /dev/stdout lab;
  server {
    listen 80;
    location / {
      proxy_pass http://api:18110;
      proxy_set_header X-Request-ID $request_id;
      proxy_set_header X-Request-Timeout-Ms 1500;
      proxy_connect_timeout 1s;
      proxy_send_timeout 2s;
      proxy_read_timeout 2s;
      proxy_next_upstream off;
    }
  }
}

proxy_next_upstream off làm thí nghiệm dễ đọc: một request tương ứng một lần gọi upstream. Khi bật retry thực tế, xét HTTP method và khả năng chống trùng. Tự retry thao tác tạo đơn vì thấy timeout có thể tạo hai đơn nếu lần đầu đã commit. Bài về idempotency trong cùng danh mục giải quyết phần đó.

Log có request ID, status, thời gian tổng tại Nginx và thời gian upstream. API hiện chưa ghi request ID nên lab chưa tạo một hệ tracing hoàn chỉnh. Khi tích hợp, log ID này ở điểm nhận, điểm gọi dependency và điểm kết thúc; không log body, token hoặc query chứa dữ liệu riêng tư chỉ để tìm một lỗi chậm.

4. Gây chậm có chủ đích và đọc kết quả

Khởi động rồi kiểm tra cấu hình. curl --max-time ở đây chỉ là giới hạn phía client, không thay thế deadline phía API.

File: timeout-run.sh

docker compose up -d
docker compose exec nginx nginx -t
curl -i --max-time 3 "http://127.0.0.1:18080/?ms=50"
curl -i --max-time 3 "http://127.0.0.1:18080/?ms=4000"
docker compose logs --tail=20 nginx

Request đầu dự kiến trả 200. Request có ms=4000 dự kiến nhận 504 từ API trước khi Nginx chạm read timeout; body nói rõ downstream vượt deadline. Đừng xem phần thời gian thực trên một máy local là số đo SLA. Cùng status 504 nhưng nguồn sinh khác nhau sẽ dẫn tới hướng sửa khác nhau: API báo dependency timeout khác với Nginx không nhận được response.

Khi downstream phản hồi chậm: API nhận request; Gọi downstream; Hết deadline; Dọn tài nguyên
Khi downstream phản hồi chậm. Downstream phải hợp tác hủy công việc; đóng HTTP không tự rollback giao dịch đã commit. Có thể mở ảnh gốc để phóng to.

Trong đợt kiểm thử cục bộ của bài, hai nhánh 200/504 đều đạt. Kiểm thử thêm 12 request song song, mỗi request yêu cầu downstream chờ một giây, nhận cả 200 và 503 như cơ chế admission control dự kiến. Đây là kiểm tra hành vi, không phải benchmark throughput hoặc bằng chứng chịu tải production.

5. Hạn chế concurrency để tránh biến timeout thành hàng đợi

Timeout chỉ giới hạn một công việc; nó không giới hạn số công việc được nhận. Mẫu cho phép tối đa bốn request đang xử lý trên một process. Request thứ năm nhận 503 và Retry-After thay vì đứng chờ vô hạn trong bộ nhớ. Client phải xử lý backoff có jitter, và chỉ retry khi ngữ nghĩa thao tác cho phép.

Quá tải: từ chối trước khi xếp hàng: Request mới; inflight < 4; inflight >= 4; Client retry
Quá tải: từ chối trước khi xếp hàng. Giới hạn này theo từng process, không phải quota chung cho toàn bộ cụm. Có thể mở ảnh gốc để phóng to.

Giá trị bốn được chọn để dễ gây lỗi trong lab. Khi đặt production, căn cứ vào số connection DB cho mỗi instance, tốc độ dependency, bộ nhớ mỗi request và số replica. Nếu mỗi request mở hai connection nhưng pool chỉ có mười connection, giới hạn nhận việc cần phản ánh điều đó. Tăng replica API mà không đổi giới hạn tổng có thể chuyển điểm nghẽn sang database.

Bộ đếm này nằm trong process nên không phải quota phân tán. Nó cũng chưa chia hàng đợi theo tenant hay ưu tiên tác vụ. Có thể kết hợp giới hạn cục bộ để bảo vệ tài nguyên với rate limit dùng chung để quản lý người gọi, nhưng phải ghi rõ từng cơ chế chịu trách nhiệm điều gì.

6. Đưa vào vận hành mà không che mất lỗi gốc

  1. Đo thời gian từng dependency và mức sử dụng pool trước khi chọn budget. Phân biệt thời gian chờ connection với thời gian thực thi truy vấn.
  2. Canary cấu hình trên một phần lưu lượng. Theo dõi 502, 503, 504, client disconnect và số request inflight; xem cả số lượng tuyệt đối lẫn tỷ lệ.
  3. Kiểm tra timeout của SDK và retry mặc định. Hai lần retry phía SDK có thể tiêu thụ toàn bộ budget trước khi code ứng dụng thấy lỗi.
  4. Thử dependency ngừng phản hồi, client hủy, kết nối bị reset và quá tải ngắn. Xác nhận slot và socket được giải phóng, không chỉ xác nhận có status lỗi.
  5. Khi rollback, khôi phục cùng bộ budget ở proxy và API. Chỉ tăng proxy timeout có thể khiến tải tồn đọng khó nhìn hơn.

Nếu nhận nhiều 503 sau khi giới hạn concurrency, chưa thể kết luận cần bỏ giới hạn. Có thể đó là tín hiệu hệ thống trước đây đang nhận quá nhiều việc rồi trả lỗi muộn. Quyết định tiếp theo phải dựa vào hàng đợi, thời gian dependency và tác động tới người dùng, thay vì làm biểu đồ lỗi trông đẹp hơn.

Timeout budget Nginx và Node.js: lab chống quá tải | thanhnh.id.vn