0

Proxy Network — Mạng Proxy toàn tập Phần 3

4.9 Header hop-by-hop: thứ proxy phải xoá trước khi chuyển tiếp

Header HTTP chia làm hai loại. End-to-end đi suốt từ client tới server gốc (Authorization, Content-Type, Cookie). Hop-by-hop chỉ có nghĩa với một kết nối và phải bị xoá trước khi chuyển tiếp. RFC 9110 quy định cơ chế chung: các trường đặc thù cho kết nối được liệt kê trong header Connection và trung gian phải loại chúng; ngoài ra một số trường tự thân đã gắn với một kết nối.

Header Vì sao chỉ thuộc về một hop
Connection Chính nó là bản khai các header hop-by-hop khác. Chuyển tiếp nó là chuyển tiếp cả bản khai sai chỗ
Keep-Alive Tham số giữ kết nối của cặp client–proxy này, không liên quan tới cặp proxy–backend
TE Client khai nó chấp nhận transfer-coding nào trên đoạn dây của nó
Transfer-Encoding Cách đóng gói body trên một kết nối. Backend có thể dùng cách khác
Upgrade Đề nghị đổi giao thức (WebSocket) trên đúng kết nối đang mở
Proxy-Authorization Thông tin xác thực với proxy này. Chuyển tiếp là rò credential sang máy khác
Proxy-Authenticate Lời thách thức xác thực của proxy, chỉ có nghĩa với hop kế tiếp trên đường trả về

Proxy-Authorization là cái nguy hiểm nhất: quên xoá là gửi thẳng credential của người dùng cho backend, và nó sẽ nằm trong log của backend.

Go làm sẵn việc này trong httputil.ReverseProxy. Trước khi gửi request đi, nó (1) xoá mọi header có tên xuất hiện trong giá trị của Connection; (2) xoá thêm một danh sách cố định gồm Connection, Proxy-Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, Te, Trailer, Transfer-Encoding, Upgrade; (3) nếu request gốc là một yêu cầu upgrade thì đặt lại Connection: UpgradeUpgrade: <giá trị gốc> để WebSocket vẫn đi qua được.

Rewrite hay Director?

Nếu bạn dùng trường Rewrite (Go 1.20+), ReverseProxy còn xoá bốn header forwarding do client gửi lênForwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Prototrước khi gọi hàm Rewrite của bạn. Gọi pr.SetXForwarded() bên trong Rewrite sẽ ghi lại chúng từ pr.In.RemoteAddr, pr.In.Hostpr.In.TLS, nên chuỗi XFF gửi đi chỉ còn đúng một phần tử: thứ proxy tự quan sát được. Đó chính là hành vi mà §4.5 đòi hỏi ở hop ngoài cùng.

Director là API cũ: nó không xoá các header đó, nên XFF đến tay bạn vẫn còn nguyên phần client tự khai, và đường mặc định của nó chỉ nối thêm IP thật vào cuối. Nếu bạn cố ý muốn giữ chuỗi XFF của một CDN đứng trước, hãy chép tay pr.In.Header sang pr.Out.Header trước khi gọi SetXForwarded() — khi đó nó nối thêm thay vì ghi mới.

Phần dựng proxy đầy đủ (timeout, buffer, xử lý lỗi, WebSocket) nằm ở §10.


4.10 ViaMax-Forwards

Hai header ít dùng nhưng thuộc đúng chủ đề danh tính đường đi, cả hai đều định nghĩa trong RFC 9110.

Via ghi lại các trung gian mà một message đã đi qua. Mỗi proxy nối thêm một mục gồm phiên bản giao thức nó nhận được và tên (hoặc bí danh) của chính nó — ví dụ Via: 1.1 edge-sg, 1.1 nginx-a. Nó phục vụ ba việc: phát hiện vòng lặp chuyển tiếp (proxy thấy tên mình đã có trong Via thì biết request đang chạy vòng), gỡ lỗi đường đi, và cho server gốc biết mình đang bị proxy ở đâu đó. Đây cũng là lý do nhiều nơi cấu hình bí danh thay tên máy thật: Via phơi sơ đồ hạ tầng nội bộ cho bất kỳ ai gửi một request.

Max-Forwards giới hạn số hop mà một request TRACE hoặc OPTIONS được đi qua. Mỗi trung gian giảm giá trị đi một; về 0 thì trung gian đó phải tự trả lời thay vì chuyển tiếp. Nó là cơ chế chống vòng lặp cho đúng hai method đó, không áp dụng cho GET/POST.

Thực dụng với dự án này: chặn TRACE ở proxy ngoài cùng. Nó không mang lại giá trị nào cho một API JSON, và nó phản chiếu nguyên văn header của request — gồm cả những header bạn không muốn thấy trong log. Mặt tấn công của nó nằm ở §13.


4.11 Bảng triệu chứng → nguyên nhân

Bảng này dành cho lúc 2 giờ sáng. Mọi dòng đều là cùng một lỗi gốc: backend tin vào thứ nó tự quan sát được, trong khi sự thật nằm ở header mà không ai cấu hình cho nó.

Triệu chứng quan sát được Nguyên nhân gần như chắc chắn Cách xác nhận
Rate limit chặn nhầm hàng loạt người dùng; log thấy mọi request đến từ một IP nội bộ như 10.1.2.3 Backend đang dùng r.RemoteAddr, chưa có middleware ClientIP; hoặc có mà TRUSTED_PROXY_CIDRS để trống Đếm số IP phân biệt trong log 5 phút gần nhất. Bằng 1, hoặc bằng số instance proxy, là dính
Ai cũng "đến từ" cùng một quốc gia; geo-block chặn sạch hoặc không chặn ai Y như trên So IP trong log với IP thật của bạn khi tự gọi API từ mạng ngoài
Redirect vô hạn: trình duyệt báo ERR_TOO_MANY_REDIRECTS trên trang HTTPS Proxy terminate TLS rồi gọi backend bằng HTTP. Backend thấy r.TLS == nil → tưởng là HTTP → trả 301 sang HTTPS → vòng lại. Thiếu X-Forwarded-Proto curl -v thẳng vào backend: nếu nó trả 301 sang https:// thì đúng nó
Cookie phiên biến mất, người dùng bị đăng xuất liên tục; hoặc cookie đi qua mạng không có cờ Secure Backend tự suy scheme từ r.TLS, thấy http nên bỏ thuộc tính Secure — hoặc ngược lại, đặt Secure trên một response nó tưởng là HTTP Xem Set-Cookie trong tab Network của trình duyệt, đối chiếu thuộc tính Secure
Link tuyệt đối trong response, email xác thực, hoặc Location của redirect trỏ tới api-internal:8000 Proxy ghi đè Host bằng tên backend nội bộ và không set X-Forwarded-Host; backend sinh URL từ r.Host curl -i qua proxy rồi đọc header Location
Đúng ở staging, sai ở production Số hop khác nhau giữa hai môi trường (production có thêm CDN) trong khi code dùng đếm số hop thay vì danh sách CIDR So X-Forwarded-For nhận được ở hai môi trường
Một người dùng vượt được rate limit và ghi rác vào audit log Code lấy phần tử trái nhất của XFF Gửi thử curl -H "X-Forwarded-For: 1.2.3.4" rồi xem log ghi gì

Ba dòng ở giữa có chung một cách sửa. Phía proxy, chuyển đủ ngữ cảnh:

location / {
    proxy_pass http://api_upstream;

    # Giữ tên miền client gõ, để backend sinh link tuyệt đối đúng.
    proxy_set_header Host              $host;

    # Nối IP của kết nối hiện tại vào cuối chuỗi XFF.
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

    # Thiếu đúng dòng này là sinh ra redirect loop và cookie mất cờ Secure.
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
}

Phía backend, đừng suy ra scheme từ r.TLS khi ứng dụng chạy sau proxy — TLS đã kết thúc ở hop trước, r.TLS luôn nil, và đó là sự thật, chỉ không phải sự thật bạn cần. Đọc X-Forwarded-Proto với đúng điều kiện tin cậy ở §4.5, hoặc tốt hơn: đặt base URL của ứng dụng bằng biến môi trường và không suy diễn gì cả.

Nguyên tắc rút ra từ cả mục này

Mọi header X-Forwarded-*Forwarded đều là lời khai của người khác, không phải quan sát của bạn. Chúng có giá trị đúng bằng mức độ bạn kiểm soát được cái hop đã ghi ra chúng.

Vì vậy quy trình luôn có hai vế, bỏ vế nào cũng hỏng: (1) cấu hình hop ngoài cùng ghi đè chứ không nối thêm, (2) khai báo ở ứng dụng chính xác những hop nào là của bạn. Không vế nào thay thế được vế kia.


5. Reverse proxy chuyên sâu — cân bằng tải, sức khỏe và độ bền

Đặt một reverse proxy trước cmd/api không phải là thêm một lớp. Đó là nhận thêm bốn trách nhiệm, và cả bốn đều có thể tự bắn vào chân bạn.

Trách nhiệm Nếu làm đúng Nếu làm sai
Chọn upstream (load balancing) Tải trải đều, thêm/bớt instance không gây sốc Một instance gánh phần lớn traffic, số còn lại ngồi chơi
Biết instance nào còn sống (health check) Instance chết bị rút khỏi vòng trong vài giây Proxy tự tin gửi request vào tiến trình đã mất kết nối DB
Cắt đúng lúc (timeout, circuit breaker) Sự cố khoanh vùng được Một upstream chậm kéo tụt cả tầng proxy
Thử lại đúng chỗ (retry) Lỗi thoáng qua vô hình với người dùng Retry storm biến sự cố 5 giây thành sự cố 40 phút — và tạo ra bài viết trùng

Điểm chung: cả bốn đều là quyết định dựa trên trạng thái mà proxy phải phỏng đoán. Proxy không biết cmd/api có khoẻ không, nó chỉ suy ra từ vài tín hiệu gián tiếp. Phần khó nằm ở chỗ chọn tín hiệu nào và tin nó tới đâu.

Phép thử một câu cho cả phần này

Nếu tôi rút phích một instance cmd/api ngay bây giờ, bao lâu thì người dùng cuối cùng ngừng nhận lỗi — và trong khoảng đó bao nhiêu request bị mất?

Con số đó là điểm số của mọi thứ bạn cấu hình ở đây. Không trả lời được nghĩa là bạn chưa cấu hình gì cả, mới chỉ copy một file.

5.1 Thuật toán cân bằng tải

Mọi thuật toán đều trả lời cùng câu hỏi — request này đi vào backend nào? — nhưng khác nhau ở lượng trạng thái phải giữgiả định về backend. Hai trục đó là thứ duy nhất đáng nhớ.

5.1.1 Round-robin và weighted round-robin

Round-robin (RR) đi vòng theo danh sách; trạng thái duy nhất là một con trỏ. Giả định ngầm — và cũng là chỗ nó gãy: mọi request tốn như nhau, mọi backend mạnh như nhau. Với cmd/api thì giả định đó sai ngay từ đầu: GET /posts/{slug} là một truy vấn theo index, còn POST /posts mở transaction, ghi bảng posts, ghi bảng outbox rồi commit. RR vẫn phát mỗi backend đúng một phần ba số request, kể cả khi một backend đang mắc kẹt sau ba POST /posts nặng còn hai backend kia rảnh.

Weighted round-robin (WRR) vá được một nửa: gán trọng số cho backend mạnh yếu khác nhau.

upstream community_api {
    server api-1:8000 weight=3;   # máy 8 vCPU
    server api-2:8000 weight=1;   # máy 2 vCPU, tạm thời
    server api-3:8000 weight=1;
}

Nhưng WRR chỉ sửa chênh lệch tĩnh giữa các máy; nó không biết gì về việc ngay lúc này api-2 đang dừng vì GC hay đang chờ một truy vấn quên index. Chi tiết dễ bỏ sót: nginx cài WRR ở dạng smooth weighted round-robin — với trọng số 5:1:1 nó rải xen kẽ chứ không dồn năm request liên tiếp vào A, vì bản dồn cục tạo burst đúng vào một máy.

5.1.2 Least-connections

Least-connections chọn backend đang có ít request xử lý dở nhất. Đây là bước nhảy chất lượng thật, vì "số request đang dở" là một tín hiệu phản hồi ngược, không phải giả định: backend chậm đi thì request tồn đọng ở đó, số đếm tăng, proxy tự động ngừng gửi thêm — không ai phải cấu hình gì.

Cái giá: proxy phải giữ trạng thái toàn cục. nginx chạy nhiều worker process thì mỗi worker chỉ đếm được kết nối của chính nó; một tầng mười instance HAProxy thì mỗi instance thấy một phần mười bức tranh. Least-conn càng phân tán thì càng thoái hoá về round-robin. Bẫy đặc thù: kết nối dài làm nó mù — một backend giữ 200 kết nối WebSocket rảnh rỗi trông "bận" hơn hẳn một backend đang cong người xử lý 5 request nặng. Có cả hai loại lưu lượng thì tách thành hai upstream pool, đừng đi tìm thuật toán thông minh hơn.

5.1.3 Least-time và EWMA

Least-conn đếm số lượng; least-time đo thời gian. Vì độ trễ là dãy số nhiễu, người ta dùng EWMA (exponentially weighted moving average) thay cho giá trị đo tức thời:

ewma_mới = α × mẫu_mới + (1 − α) × ewma_cũ        với 0 < α < 1

α lớn  → phản ứng nhanh với thay đổi, nhưng nhảy loạn theo nhiễu
α nhỏ  → mượt, nhưng chậm nhận ra backend vừa hồi phục

EWMA bắt được thứ least-conn không thấy: backend đang swap hoặc mất dần kết nối DB có độ trễ tăng dần và bị né trước khi nó chết hẳn. Nhưng nó bị lừa ở mặt kia — backend trả lời nhanh mà sai (500 tức thì) vẫn được chấm điểm tốt. Cái giá thật sự là hiệu ứng bầy đàn: mọi proxy cùng thấy api-3 nhanh nhất, cùng dồn sang, làm nó chậm lại, rồi cùng bỏ chạy. Dao động này là lý do EWMA gần như luôn ghép với random two choices§5.1.6 thay vì dùng một mình.

Cái gì có sẵn ở đâu. Least-conn có ở hầu hết mọi proxy (least_conn của nginx, balance leastconn của HAProxy, LEAST_REQUEST của Envoy). Least-time thì không — nginx bản mã nguồn mở không có directive này, HAProxy cũng không; Linkerd là ví dụ nổi tiếng dùng P2C + EWMA làm mặc định. Đừng đi tìm least_time trong nginx.conf rồi kết luận là mình gõ sai chính tả.

5.1.4 IP hash

IP hash băm IP nguồn rồi lấy dư theo số backend: backend = hash(client_ip) % N. Cùng một IP luôn về cùng một backend, và proxy không giữ trạng thái nào cả — đó là toàn bộ sức hấp dẫn của nó. Ba vấn đề, theo mức độ nghiêm trọng:

  1. Đổi N là xáo trộn gần như toàn bộ. Thêm instance thứ tư vào ba instance đang chạy: mọi client mà h % 3 ≠ h % 4 đều đổi backend. Đây chính là lý do consistent hashing ra đời.
  2. Phân bố phụ thuộc phân bố IP, không phụ thuộc phân bố tải. Một nhà mạng di động NAT hàng chục nghìn người sau một IP → cả đám dồn vào một backend.
  3. IP không ổn định như bạn tưởng. Người dùng đổi IP khi chuyển Wi-Fi sang 4G; dựa vào IP hash để giữ trạng thái thì trạng thái đó mất mỗi lần họ bước ra khỏi quán cà phê.

Và chi tiết chỉ lộ ra khi đã có proxy phía trước: nếu nginx nằm sau CDN hoặc cloud load balancer, "IP nguồn" nó thấy là IP của lớp trước — toàn bộ traffic băm về đúng một giá trị và cân bằng tải biến mất hoàn toàn. Cách lấy IP thật là chủ đề của §4.

5.1.5 Consistent hashing — vòng băm và điểm ảo

Consistent hashing sửa đúng vấn đề số 1: thêm/bớt backend mà chỉ một phần nhỏ khoá đổi chỗ. Thay vì băm rồi chia dư cho N, ánh xạ cả khoá lẫn backend lên một vòng tròn giá trị băm (ví dụ 0 … 2³²−1, nối đuôi). Mỗi backend rải ra nhiều điểm ảo (virtual node, vnode). Khoá rơi vào một điểm rồi đi theo chiều kim đồng hồ, gặp vnode nào trước thì backend đó phục vụ.

Vòng băm 0 … 2³²−1 (nối đuôi nhau). A₂ A₃ là hai vnode của cùng node A.

  …──┬──[A₂]──┬─────────────┬──[B₃]──┬──────────┬──[C₁]──┬──[A₃]──…
     │        │  k1 k2 k3   │        │  k4 k5   │        │
     │        └──► về B₃ ───┘        └─► về C₁ ─┘        │

Node B chết — MỌI vnode của B biến mất khỏi vòng:

  …──┬──[A₂]──┬─────────────────────────────────┬──[C₁]──┬──[A₃]──…
     │        │  k1 k2 k3     k4 k5             │        │
     │        └───────────► đều về C₁ ──────────┘        │

  ✅ k1,k2,k3 đổi chủ — đúng phần vốn thuộc cung của B, và chỉ chúng mất cache
  ✅ k4,k5 và mọi khoá ở cung khác GIỮ NGUYÊN backend cũ → cache vẫn nóng

Vì sao nó giữ được cache hit. Với hash % N, đổi N làm toàn bộ phép chia dư đổi kết quả — không có tính cục bộ nào. Với vòng băm, một node biến mất chỉ ảnh hưởng các cung mà nó đứng ở cuối; mọi cung khác không hề biết có chuyện gì xảy ra. Về bậc độ lớn, số khoá phải di chuyển là khoảng K/N thay vì gần như toàn bộ K.

Vì sao cần điểm ảo. Mỗi node một điểm thì các cung dài ngắn ngẫu nhiên, một node có thể vô tình chiếm một phần rất lớn của vòng; rải mỗi node thành hàng chục tới hàng trăm vnode làm luật số lớn phát huy tác dụng. Vnode còn cho phép đặt trọng số tự nhiên: muốn node mạnh gấp ba, cho nó gấp ba số vnode. Và một tính chất chỉ có nhờ vnode: vì các vnode của B nằm rải rác chứ không liền khối, phần tải của B chia lại cho nhiều node còn sống — không có vnode, node đứng ngay sau B nhận nguyên gấp đôi tải và rất có thể chết theo, đúng kiểu sập dây chuyền.

upstream image_cache {
    hash $request_uri consistent;   # cùng URI → cùng node cache → tỷ lệ hit cao
    server cache-1:8080;
    server cache-2:8080;
}

Dùng cho cái gì, và KHÔNG dùng cho cái gì. Đáng dùng khi backend giữ cache hoặc shard dữ liệu — khi "đi đúng node" rẻ hơn hẳn "đi node nào cũng được". Không đáng dùng cho cmd/api: các instance api thay thế được cho nhau hoàn toàn, không giữ gì trong bộ nhớ, mọi trạng thái nằm ở PostgreSQL. Băm theo khoá ở đây chỉ đổi một thuật toán cân bằng tốt lấy một thuật toán tệ hơn mà không nhận lại gì.

5.1.6 Random two choices (power of two choices)

Thuật toán đáng hiểu nhất trong cả mục, vì nó cho gần hết lợi ích của least-conn mà không cần trạng thái toàn cục:

1. Bốc NGẪU NHIÊN 2 backend trong danh sách.
2. So sánh đúng 2 backend đó (số kết nối đang mở, hoặc EWMA độ trễ).
3. Gửi request vào cái nhẹ hơn.

Chọn ngẫu nhiên hoàn toàn (một lựa chọn) để lại độ lệch tải khá lớn — luôn có một backend "xui" nhận nhiều hơn hẳn trung bình. Chỉ cần thêm một lựa chọn thứ hai và so sánh, xác suất để backend đang nặng bị chọn tiếp giảm mạnh: nó phải xui hai lần liên tiếp. Đây là kết quả kinh điển của bài toán balls-into-bins ("the power of two random choices"): mức lệch tải cực đại giảm từ tăng theo hàm log xuống tăng theo hàm log-log, gần như phẳng ở mọi quy mô thực tế.

Vì sao không cần trạng thái toàn cục: proxy chỉ cần biết trạng thái của đúng 2 backend, và chỉ cần biết tương đối (cái nào nhẹ hơn), không cần tuyệt đối. Sai lệch giữa các instance proxy không còn quan trọng, vì mỗi instance vẫn tránh được cái tệ hơn trong hai cái nó nhìn thấy — và nó chữa luôn hiệu ứng bầy đàn, vì mười proxy chạy P2C bốc mười cặp khác nhau.

upstream community_api {
    random two least_conn;   # bốc 2, chọn cái ít kết nối hơn
    server api-1:8000;
    server api-2:8000;
    server api-3:8000;
}

5.1.7 Bảng đối chiếu

Thuật toán Cần trạng thái gì Hợp khi nào Điểm yếu
Round-robin Một con trỏ Backend đồng nhất, request đồng chi phí Mù hoàn toàn với backend đang chậm
Weighted RR Con trỏ + trọng số tĩnh Đội máy không đồng nhất Trọng số tĩnh, không phản ánh tình trạng hiện tại
Least-connections Số request đang dở của mọi backend Chi phí request lệch nhau nhiều — hợp cmd/api Chia theo worker/instance thì thoái hoá về RR; kết nối dài làm sai lệch
Least-time / EWMA Độ trễ trung bình động của mọi backend Backend chậm dần trước khi chết Bầy đàn, dao động; rất ít proxy hỗ trợ sẵn
IP hash Không (thuần hàm băm) Bắt buộc sticky mà không có cookie Đổi N là xáo trộn gần hết; NAT làm lệch; IP di động không ổn định
Consistent hashing Vòng băm (chỉ đổi khi đổi topology) Backend giữ cache hoặc shard Vô ích với backend stateless; thiếu vnode thì lệch nặng
Random two choices Trạng thái của đúng 2 backend được bốc Nhiều instance proxy, không muốn trạng thái chung Không tối ưu tuyệt đối; vẫn cần tín hiệu để so sánh

Khuyến nghị: cmd/api stateless, chi phí request lệch rõ → random two least_conn; chỉ một tầng nginx thì least_conn cũng đủ. Tuyệt đối không ip_hash§5.3 giải thích vì sao.

5.2 Health check — proxy biết gì về sức khoẻ upstream

5.2.1 Active và passive

Active health check Passive / outlier detection
Cách hoạt động Proxy chủ động gọi một endpoint theo chu kỳ Proxy quan sát chính lưu lượng thật, đếm lỗi
Phát hiện khi Chưa ai gửi request nào vào backend đó Đã có người dùng thật ăn lỗi trước
Đưa trở lại Chủ động, sau khi thấy nó trả lời tốt lại Sau một khoảng đợi, thử lại một cách mù
Chi phí Traffic nền, tăng theo số proxy × số backend Bằng không
Nhìn được gì Chỉ những gì endpoint đó kiểm tra Toàn bộ sự thật của lưu lượng thật

Active phát hiện sớm nhưng chỉ thấy đúng cái endpoint kiểm tra; passive thấy sự thật nhưng luôn trả giá bằng lỗi của người dùng thật trước khi phản ứng. Hệ thống nghiêm túc bật cả hai.

⚠️ nginx bản mã nguồn mở KHÔNG có active health check.

Nó chỉ có passive: max_fails=fail_timeout= trên directive server trong khối upstream. Directive health_check là tính năng của bản thương mại. Cần active check với nginx mã nguồn mở thì phải dùng module bên thứ ba, hoặc chuyển sang HAProxy / Envoy / Traefik / Caddy.

Đây là loại chi tiết mà mọi blog "cấu hình nginx load balancer" đều bỏ qua, rồi bạn tưởng mình đã có health check trong khi thực ra chưa.

upstream community_api {
    least_conn;
    # Passive: trong cửa sổ 10s, 3 lần thất bại thì tạm loại 10s.
    # Đây là TẤT CẢ những gì nginx mã nguồn mở làm được.
    server api-1:8000 max_fails=3 fail_timeout=10s;
    server api-2:8000 max_fails=3 fail_timeout=10s;
}

Với proxy có active check, bốn tham số dưới đây gặp lại ở mọi công cụ (tên khác nhau, ý nghĩa giống hệt):

Tham số Ý nghĩa Khởi điểm Đặt sai thì sao
interval Bao lâu gọi check một lần 2–5s Quá dài → chậm phát hiện; quá ngắn → tự DDoS chính mình
timeout Chờ tối đa một lần check 1–2s, luôn < interval Lớn hơn interval → các lần check chồng lên nhau
unhealthy_threshold Số lần liên tiếp fail mới loại 2–3 1 → rung lắc vì một gói tin rớt; 5 → phát hiện quá muộn
healthy_threshold Số lần liên tiếp pass mới nhận lại 2 1 → nhận lại một backend đang chập chờn ngay lập tức

Thời gian phát hiện tệ nhất xấp xỉ interval × unhealthy_threshold + timeout; với 2s × 3 + 1s là khoảng 7 giây — nhớ con số này, §5.9 dùng lại nó để tính thời gian drain.

5.2.2 Liveness và readiness — hai câu hỏi khác nhau

Liveness (/healthz) Readiness (/readyz)
Câu hỏi Tiến trình này còn cứu được không? Nhận request được ngay bây giờ không?
Ai hỏi Trình giám sát tiến trình (Kubernetes, systemd) Load balancer / service discovery
Fail thì làm gì Giết và khởi động lại Rút khỏi vòng cân bằng tải, không giết
Được kiểm tra dependency? Không — đây chính là việc của nó

Vì sao liveness tuyệt đối không được ping DB. PostgreSQL restart 30 giây để nâng cấp; nếu /healthz ping DB thì cả ba instance api cùng fail liveness cùng lúc → orchestrator giết cả ba → khởi động lại → DB vẫn chưa lên → fail tiếp → vòng lặp CrashLoopBackOff. Bạn vừa biến sự cố DB 30 giây thành sự cố toàn hệ thống kéo dài hàng phút, và log ngập dòng restart che mất nguyên nhân thật. Quy tắc: liveness chỉ trả lời về chính tiến trình — deadlock, panic loop, những thứ mà khởi động lại thực sự sửa được.

5.2.3 Cái bẫy: health check quá nhẹ

Đây là bẫy nguy hiểm nhất của cả phần 5, vì nó thất bại theo hướng im lặng.

Load balancer chỉ TCP connect vào :8000
        ├─► kernel bắt tay TCP xong  →  "api-2 khoẻ" ✅
        └─► nhưng bên trong api-2: pgxpool đã mất sạch kết nối tới PostgreSQL,
            mọi POST /posts trả 500 — mà LB vẫn đều đặn gửi 1/3 lưu lượng vào đây

TCP connect thành công chỉ chứng minh kernel đang lắng nghe cổng đó — không chứng minh tiến trình Go còn Accept(), và chắc chắn không chứng minh DB còn sống. Biến thể tinh vi hơn: check GET / trả 200 từ một handler tĩnh, vẫn không chứng minh gì về dependency.

Mức Check Chứng minh được Không chứng minh được
1 TCP connect Cổng đang mở Gần như mọi thứ
2 GET /healthz trả 200 tĩnh Tiến trình Go còn phục vụ HTTP Dependency có sống không
3 GET /readyzpool.Ping Còn ít nhất một kết nối DB dùng được DB có đang chậm không
4 /readyz + truy vấn nhẹ có timeout DB thực sự trả lời trong ngân sách Nghiệp vụ có đúng không

Mức 3 là tối thiểu cho cmd/api. Mức 4 tốt hơn nhưng phải cẩn thận: một truy vấn nặng chạy vài giây một lần trên mọi instance sẽ tự nó trở thành tải.

5.2.4 /healthz/readyz cho cmd/api

Package này thuộc internal/platform/ theo đúng phép thử của 02-nen-mong-platform.md §1: copy sang dự án Go khác dùng PostgreSQL là chạy được ngay.

// internal/platform/health/health.go
//
// Hai endpoint cho hai người hỏi khác nhau: trình giám sát tiến trình hỏi
// /healthz, load balancer hỏi /readyz. Trộn chúng vào nhau là mất cả hai.
package health

import (
	"context"
	"encoding/json"
	"net/http"
	"sync/atomic"
	"time"
)

// Pinger là TẤT CẢ những gì package này cần biết về database. Khai báo
// interface ở phía dùng (thay vì nhận thẳng *pgxpool.Pool) để platform/health
// không kéo theo pgx, và để test không cần DB thật. *pgxpool.Pool thoả sẵn.
type Pinger interface {
	Ping(ctx context.Context) error
}

// Handler dùng qua con trỏ: atomic.Bool bên trong không được phép sao chép.
type Handler struct {
	db Pinger

	// Bật ở bước đầu của graceful shutdown, TRƯỚC khi đóng listener.
	// Xem §5.9 — đây là mắt xích khiến deploy không sinh 502.
	draining atomic.Bool

	// Phải NGẮN hơn timeout mà LB đặt cho lần check, nếu không LB cắt trước
	// và bạn mất luôn thông tin "vì sao fail".
	checkTimeout time.Duration
}

func New(db Pinger) *Handler {
	return &Handler{db: db, checkTimeout: 2 * time.Second}
}

// Live KHÔNG chạm database: nếu Postgres restart 30 giây mà endpoint này fail,
// orchestrator giết sạch mọi instance api cùng lúc và đẩy cả hệ thống vào vòng
// lặp khởi động lại — biến một sự cố DB thành một sự cố toàn cục.
func (h *Handler) Live(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// Ready trả lời câu hỏi của load balancer: gửi request vào đây được chưa?
func (h *Handler) Ready(w http.ResponseWriter, r *http.Request) {
	// Kiểm tra draining TRƯỚC khi ping: khi đang rút khỏi LB thì câu trả lời
	// là "không" bất kể DB khoẻ tới đâu, và không nên tốn thêm một round-trip
	// DB cho mỗi lần check suốt cửa sổ drain.
	if h.draining.Load() {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "draining"})
		return
	}

	// Lấy context của request làm gốc để LB ngắt giữa chừng thì truy vấn thăm
	// dò cũng bị huỷ theo, không để lại việc mồ côi.
	ctx, cancel := context.WithTimeout(r.Context(), h.checkTimeout)
	defer cancel()

	// pool.Ping mượn một kết nối THẬT và gửi một round-trip tới Postgres.
	// Đây là điểm khác biệt với /healthz: nó bắt được ca tiến trình còn sống
	// nhưng đã mất sạch kết nối DB — đúng ca mà TCP check báo "khoẻ" sai lầm.
	if err := h.db.Ping(ctx); err != nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"status": "unready", "dependency": "postgres",
		})
		return
	}

	writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

// StartDraining khiến /readyz trả 503 nhưng server VẪN phục vụ bình thường.
func (h *Handler) StartDraining() { h.draining.Store(true) }

func writeJSON(w http.ResponseWriter, code int, body map[string]string) {
	w.Header().Set("Content-Type", "application/json")
	// no-store để không lớp cache nào ở giữa giữ lại kết quả health check:
	// một câu trả lời "ok" bị cache là thứ nguy hiểm nhất trong cả file này.
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(body)
}

Lắp vào cmd/api, thay endpoint /healthz tĩnh ở 04-module-identity.md §15.2:

hc := health.New(pool)

r.Get("/healthz", hc.Live)   // trình giám sát hỏi: có nên giết không?
r.Get("/readyz", hc.Ready)   // load balancer hỏi: có nên gửi traffic không?

cmd/workercmd/relay khác hẳn: không có listener HTTP cho người dùng nên không có gì để rút khỏi LB; sức khoẻ của chúng đo bằng consumer lag và số dòng outbox tồn đọng — xem ARCHITECTURE.md §11.4.

5.3 Sticky session — và vì sao dự án này không cần

Sticky session (session affinity) là ràng buộc: mọi request của cùng một người dùng phải về cùng một backend.

Cookie-based IP hash
Khoá dính Cookie do proxy chèn (HAProxy: cookie SRV insert indirect nocache) Địa chỉ IP nguồn
Độ chính xác Đúng từng trình duyệt Sai với mọi NAT: cả văn phòng dính chung một backend
Chịu đổi topology? Cookie chỉ ra một backend cụ thể — backend chết là mất dính Đổi số backend là xáo trộn gần hết
Client tắt cookie Hỏng Vẫn chạy
Cần gì ở proxy Ghi/đọc được cookie → phải kết thúc TLS ở proxy Không cần gì

Cookie-based chính xác hơn hẳn và là lựa chọn mặc định nếu buộc phải sticky. Nhưng câu quan trọng hơn là câu này:

Sticky session là mùi của trạng thái nằm sai chỗ.

Bạn cần sticky vì backend giữ một thứ mà backend khác không có: session trong RAM, file upload dang dở trên đĩa cục bộ, một bản cache đã ấm. Sticky không giải quyết vấn đề đó — nó giấu vấn đề đi bằng lời hứa rằng người dùng sẽ luôn quay lại đúng chỗ. Lời hứa đó vỡ đúng lúc tệ nhất: khi deploy, khi autoscale, khi một instance chết. Đúng những lúc bạn cần hệ thống chịu lỗi nhất thì sticky lại là thứ gãy đầu tiên.

Cái giá cụ thể: tải lệch không sửa được (backend dính nhóm người dùng nặng thì cứ nặng mãi, thuật toán cân bằng tải bị vô hiệu hoá đúng ở chỗ nó cần thiết); deploy làm mất trạng thái (mỗi lần rolling update thay một instance là một nhóm người dùng bị đăng xuất); autoscale gần như vô nghĩa (instance mới không nhận được người dùng cũ, instance cũ không hạ tải được).

Vì sao dự án này không cần sticky. cmd/api xác thực bằng JWT stateless (04-module-identity.md): toàn bộ danh tính nằm trong token, đã ký, do client mang theo mỗi request; instance nào nhận cũng chỉ cần verify chữ ký, không tra bảng session, không nhớ gì về request trước. Mọi trạng thái còn lại nằm ở PostgreSQL, mà PostgreSQL thì mọi instance nhìn thấy như nhau — không một byte nào sống trong RAM của riêng một instance api.

Hệ quả rất cụ thể: giết bất kỳ instance api nào, bất kỳ lúc nào, request tiếp theo của cùng người dùng vẫn chạy đúng trên instance khác. Đó chính là thứ mà mọi kiến trúc dùng sticky đang cố mua bằng tiền và độ phức tạp. Đừng vứt nó đi bằng một dòng ip_hash; copy từ blog.

5.4 Timeout — ngân sách thời gian lồng nhau

Vấn đề không phải "đặt bao nhiêu giây" mà là những giây đó đo cái gì.

# Loại timeout Đo chính xác cái gì nginx Go Khởi điểm
1 Connect tới upstream Bắt tay TCP xong chưa proxy_connect_timeout net.Dialer.Timeout 1–2s
2 TLS handshake Bắt tay TLS xong chưa (trong proxy_connect_timeout khi có proxy_ssl) Transport.TLSHandshakeTimeout 2s
3 Đọc header từ client Client gửi xong dòng đầu + header chưa client_header_timeout Server.ReadHeaderTimeout 5s
4 Đọc body từ client Khoảng cách giữa hai lần đọc body client_body_timeout Server.ReadTimeout (cả header + body) 15s
5 Ghi về client Khoảng cách giữa hai lần ghi ra client send_timeout Server.WriteTimeout 15s
6 Gửi request lên upstream Khoảng cách giữa hai lần ghi lên upstream proxy_send_timeout (không có riêng — dùng deadline của context) 10s
7 Chờ header phản hồi Từ lúc gửi xong tới lúc có status line (nằm trong proxy_read_timeout) Transport.ResponseHeaderTimeout 3–5s
8 Đọc body phản hồi Khoảng cách giữa hai lần đọc proxy_read_timeout (không có riêng — dùng deadline của context) 15s
9 Idle / keep-alive với client Giữ kết nối rảnh bao lâu keepalive_timeout Server.IdleTimeout 60–75s
10 Idle với upstream Giữ kết nối rảnh về upstream bao lâu keepalive_timeout trong khối upstream Transport.IdleConnTimeout 90s
11 Tổng thể một request Từ byte đầu tới byte cuối không có http.Client.Timeout hoặc context.WithTimeout 20s

⚠️ Các timeout proxy_* của nginx KHÔNG phải giới hạn tổng thời gian.

proxy_read_timeout 60s không có nghĩa "phản hồi phải xong trong 60 giây", mà là "khoảng cách giữa hai thao tác đọc liên tiếp không được quá 60 giây". Một upstream nhỏ giọt một byte mỗi 59 giây giữ kết nối vô hạn mà không vi phạm directive nào. nginx mã nguồn mở không có directive nào đặt trần cho tổng thời gian một request (HAProxy có timeout http-request cho pha nhận request); cần trần tổng thể thật sự thì phải đặt ở tầng khác — ở chính cmd/api, hoặc ở lớp gateway phía trên.

Phía Go, ResponseHeaderTimeout chỉ tính tới lúc header về, không tính body.

http.Transport không có trường nào giới hạn thời gian đọc body phản hồi. http.Client.Timeout thì có (bao cả dial, TLS, ghi request, đọc header và đọc body) — nhưng httputil.ReverseProxy không dùng http.Client, nó gọi thẳng RoundTripper. Viết proxy bằng Go thì trần tổng thể phải đến từ context.WithTimeout gắn vào request, không có đường tắt. Và ngay cả thế cũng phải cẩn thận: một deadline tổng thể áp lên response streaming (SSE, tải file lớn) sẽ cắt ngang một phản hồi đang chạy hoàn toàn bình thường — xem §5.7.

Nguyên tắc, chỉ một câu: timeout của proxy phải NGẮN hơn timeout của client, và DÀI hơn p99 của upstream.

Ngắn hơn client: nếu client bỏ cuộc ở giây 20 mà proxy kiên nhẫn tới giây 60, thì trong 40 giây đó proxy giữ một kết nối upstream, cmd/api giữ một goroutine, goroutine đó giữ một kết nối trong pgxpool — tất cả để tính ra một phản hồi không còn ai chờ. Dưới tải, đây là cách một sự cố nhỏ ăn sạch pool kết nối. Dài hơn p99 upstream: nếu proxy cắt ở 200ms trong khi p99 của POST /posts là 400ms, bạn tự tay biến 1% request chậm-nhưng-đúng thành 1% request lỗi — và nếu có retry thì mỗi request bị cắt lại sinh thêm một request nữa.

Client di động — người dùng bỏ cuộc / SDK timeout       30s
└── CDN / edge                                    tổng ≤ 25s
    └── Reverse proxy (nginx)                     tổng ≤ 20s
        ├── connect tới upstream                       2s
        ├── TLS handshake                              2s
        ├── gửi request lên upstream                  10s
        └── đọc phản hồi từ upstream                  15s   ◄── phải > p99 của cmd/api
            └── cmd/api (Go)              ReadHeaderTimeout 5s
                │                         WriteTimeout     15s
                └── ctx của handler                     12s
                    ├── pgxpool.Acquire                  2s
                    └── truy vấn (statement_timeout)     3s

Luật: mỗi lớp bên trong NGẮN hơn lớp bao nó,
      và lớp ngoài cùng NGẮN hơn mức kiên nhẫn của người dùng.

Vì sao phải lồng chặt: lớp trong hết hạn trước thì bạn biết lỗi ở đâu. statement_timeout nổ trước thì log cmd/api ghi rõ truy vấn nào; proxy nổ trước thì tất cả những gì bạn có là một dòng 504 và không manh mối nào. Ngân sách lồng nhau không chỉ là chuyện đúng đắn — nó là chuyện giữ lại được thông tin chẩn đoán.

// newUpstreamTransport dựng Transport dùng CHUNG cho mọi request tới cmd/api.
// Tạo Transport mới cho mỗi request là cách chắc chắn nhất để vô hiệu hoá
// connection pool — xem §5.8.
func newUpstreamTransport() *http.Transport {
	return &http.Transport{
		DialContext: (&net.Dialer{
			// Bắt tay TCP trong cùng mạng nội bộ mất mili giây. 2s ở đây không
			// phải để chờ mạng chậm, mà để phát hiện upstream đã chết NHANH,
			// còn kịp thử node khác trong ngân sách của lớp trên.
			Timeout:   2 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,

		TLSHandshakeTimeout: 2 * time.Second,

		// Timeout hữu ích nhất trong cả struct này: nó phân biệt "upstream
		// đang nghĩ" với "upstream đã treo". Chỉ tính tới lúc header về.
		ResponseHeaderTimeout: 5 * time.Second,

		ExpectContinueTimeout: 1 * time.Second,
		IdleConnTimeout:       90 * time.Second,

		// Ba con số này là ngân sách kết nối, không phải tinh chỉnh vặt — §5.8.
		MaxIdleConns:        200,
		MaxIdleConnsPerHost: 64,
		MaxConnsPerHost:     128,
	}
}

5.5 Retry an toàn

5.5.1 Chỉ retry cái retry được

Điều kiện cần là tính idempotent: RFC 9110 định nghĩa GET, HEAD, PUT, DELETE, OPTIONS, TRACE là idempotent; POSTPATCH thì không. Điều kiện đủ là câu hỏi thứ hai, khó hơn nhiều: bạn có chắc upstream chưa xử lý request đó không?

Lỗi xảy ra ở đâu Upstream đã xử lý chưa Retry an toàn không
TCP connect bị từ chối / timeout khi connect Chắc chắn chưa — byte đầu còn chưa đi ✅ An toàn với mọi method
HTTP/2 REFUSED_STREAM Chắc chắn chưa — server nói rõ nó không nhận stream này ✅ An toàn với mọi method
Kết nối đứt sau khi đã gửi xong body Không biết ❌ Nguy hiểm
Upstream trả 500/502/504 Không biết — có thể đã commit rồi mới chết ❌ Nguy hiểm với POST
Upstream trả 503 kèm Retry-After Thường là chưa (từ chối tường minh) ⚠️ Thường an toàn, nhưng vẫn là suy đoán

Hai dòng đầu là hai điều kiện an toàn chứng minh được, không phải phỏng đoán — đó chính là lý do chúng có tên riêng trong mọi proxy nghiêm túc (connect-failure, refused-stream trong retry_on của Envoy). Còn dòng "5xx sau khi đã gửi body" là dòng làm hỏng dữ liệu:

Proxy ──── POST /posts (body đầy đủ) ────► api-1
                                            │ BEGIN; INSERT posts; INSERT outbox;
                                            │ COMMIT   ✅ đã ghi!
                                            │ (panic / OOM / bị kill khi ghi response)
Proxy ◄──── kết nối đứt / 502 ──────────────┘
Proxy nghĩ "502, thử node khác"
Proxy ──── POST /posts (body Y HỆT) ─────► api-2 → COMMIT ✅ ghi lần hai

Kết quả: 2 bài viết, 2 dòng outbox → 2 event post.created → stats cộng 2.
Người dùng thấy 1 lần bấm nút, hệ thống thấy 2 bài.

POST /posts của dự án này không được retry mù. Tính chất event-driven làm hậu quả tệ hơn hệ thống thường: bản ghi trùng không dừng ở bảng posts, nó ra outbox thành hai event, được stats tiêu thụ thành hai lần cộng counter. Bạn không sửa được bằng cách xoá một dòng.

Cách sửa đúng không nằm ở tầng proxy mà ở tầng ứng dụng: khoá idempotency do client sinh, để lần gửi thứ hai với cùng khoá trả về kết quả của lần đầu thay vì tạo mới. Dự án đã có sẵn cơ chế tư duy này ở phía consumer — bảng chống trùng và mẫu guard ở 06-consumer-va-idempotency.mdARCHITECTURE.md §8. Áp cùng nguyên lý cho đường vào HTTP là việc còn phải làm.

Với nginx, mặc định may mắn là mặc định đúng:

location /api/ {
    proxy_pass http://community_api;

    # Mặc định của proxy_next_upstream là "error timeout". Quan trọng hơn:
    # nginx KHÔNG chuyển request non-idempotent (POST, PATCH, LOCK) sang
    # upstream khác trừ khi bạn viết rõ tham số "non_idempotent".
    # Đừng viết. Đây là một trong số ít mặc định an toàn sẵn.
    proxy_next_upstream error timeout http_502 http_503 http_504;

    # Trần cứng cho tổng số lần thử và tổng thời gian thử. Thiếu hai dòng này,
    # một upstream pool 10 node có thể bị quét hết cho MỘT request.
    proxy_next_upstream_tries   2;
    proxy_next_upstream_timeout 5s;
}

5.5.2 Retry storm — vì sao retry không giới hạn phóng đại sự cố

Bình thường 1000 req/s; upstream chậm đi (GC, DB checkpoint) → một phần timeout.

Mỗi lớp retry 3 lần:  Client SDK ×3  ·  CDN ×3  ·  Reverse proxy ×3  =  27 lần
1000 req/s  ──►  tới 27.000 req/s đập vào một upstream ĐANG YẾU

Upstream chết hẳn → mọi request timeout → mọi lớp retry tối đa
→ upstream vừa hồi phục thì lập tức bị 27.000 req/s dập chết lại
→ hệ thống KHÔNG tự phục hồi được, kể cả khi nguyên nhân gốc đã hết

Ba đặc điểm khiến retry storm khó chịu hơn một sự cố thường: nó khuếch đại theo cấp số nhân theo số lớp, mà số lớp thì thường không ai đếm (client, CDN, gateway, mesh sidecar, thư viện HTTP); nó tấn công đúng lúc hệ thống yếu nhất, vì retry chỉ xuất hiện khi có lỗi; và — chí mạng nhất — nó chặn đường hồi phục, sửa xong nguyên nhân gốc rồi hệ thống vẫn nằm vì hàng đợi retry tồn đọng dập nó xuống ngay khi vừa ngóc lên.

Hàng rào Làm gì Vì sao cần
Chỉ retry ở một lớp Chọn một lớp duy nhất được retry, các lớp khác tries = 1 Cắt phép nhân ngay tại gốc. Rẻ nhất, hiệu quả nhất
Retry budget Trần theo tỷ lệ: retry ≤ X% lưu lượng gốc Trần theo số lần không giới hạn được tổng; trần theo tỷ lệ thì có
Backoff + jitter Giãn dần, cộng ngẫu nhiên Không có jitter, mọi client retry đúng cùng thời điểm → sóng đồng pha
Circuit breaker Ngừng thử hẳn khi upstream rõ ràng đã hỏng Retry giả định lỗi là thoáng qua; khi lỗi không thoáng qua, chỉ breaker cứu được

Retry budget là ý tưởng đáng nhớ nhất. Với tries=3, khi 100% request lỗi thì tải lên upstream tăng gấp ba; với budget theo tỷ lệ, tỷ lệ lỗi tăng vọt thì retry tự động bị bóp lại — đúng lúc cần bóp nhất. Envoy có retry_budget trong phần circuit breaker; proxy không có thì một bộ đếm trong ứng dụng cũng làm được việc tương tự.

Hedging là biến thể ngược hướng: thay vì đợi timeout rồi mới thử lại, gửi request thứ hai tới node khác khi request đầu chậm bất thường (vượt p95) rồi lấy phản hồi nào về trước. Nó cắt đuôi p99 rất tốt, đổi lại tải tăng theo tỷ lệ hedge — và nó chỉ dùng được với request idempotent, cùng luật với retry, chỉ khác ở chỗ hai bản sao chạy song song thay vì nối tiếp. Với POST /posts thì hedging nguy hiểm y hệt retry. (Thang retry của tầng consumer là bài toán khác hẳn, ngân sách bị chặn bởi RebalanceTimeout — xem ARCHITECTURE.md §11.2.)

5.6 Circuit breaker và outlier ejection

Retry hỏi "request này thử lại được không?". Circuit breaker hỏi câu khác hẳn: "upstream này còn đáng gửi request tới nữa không?"

              lỗi vượt ngưỡng                    hết thời gian chờ
   ┌────────┐ ───────────────► ┌────────┐ ───────────────────► ┌───────────┐
   │ CLOSED │                  │  OPEN  │ ◄── probe lỗi ────── │ HALF-OPEN │
   │        │ ◄─────────────── │        │                      │  (thăm dò │
   └────────┘  đủ probe thành  └────────┘                      │   hạn chế)│
                     công ─────────────────────────────────────└───────────┘
Trạng thái Hành vi Ý nghĩa
Closed Cho qua hết, chỉ đếm lỗi Bình thường
Open Fail ngay lập tức, không gửi đi Upstream đang hỏng — không làm nó tệ thêm, và trả lỗi cho người dùng trong mili giây thay vì bắt họ chờ hết timeout
Half-open Cho qua một lượng nhỏ có kiểm soát Thăm dò xem đã hồi phục chưa, mà không dốc cả cơn lũ vào một upstream vừa đứng dậy

Half-open là phần thông minh nhất: nhảy thẳng Open → Closed thì toàn bộ lưu lượng tồn đọng đập vào upstream vừa hồi phục và giết nó lại ngay — chính là cơ chế "chặn đường hồi phục" ở §5.5.2.

Tham số Bẫy
Ngưỡng lỗi (số lần liên tiếp, hoặc tỷ lệ) Tỷ lệ chính xác hơn số lần liên tiếp, nhưng phải kèm ngưỡng dưới
Số request tối thiểu trong cửa sổ Thiếu nó, 2 lỗi trên 2 request lúc 3h sáng = tỷ lệ 100% → mở breaker cho một upstream hoàn toàn khoẻ
Độ dài cửa sổ trượt Quá dài → phản ứng chậm; quá ngắn → nhiễu
Thời gian Open trước khi thăm dò Quá ngắn → thăm dò liên tục thành một retry storm thu nhỏ
Trần số node bị loại cùng lúc Thiếu nó, một lỗi chung (DB chết) làm mọi node đều "hỏng" → loại sạch → không còn upstream nào

Dòng cuối là bẫy tinh vi nhất. Envoy đặt trần này bằng max_ejection_percent trong outlier_detection, và thêm một cơ chế nữa: khi tỷ lệ node khoẻ tụt dưới healthy_panic_threshold, Envoy bỏ qua kết quả health check và rải đều lên tất cả node. Lý luận rất đáng học: khi gần như mọi node đều bị đánh dấu hỏng, nhiều khả năng thứ hỏng là cái thước đo, không phải các node.

Retry Circuit breaker
Phạm vi Một request Cả một upstream, chia sẻ giữa mọi request
Trạng thái Gần như không Có, và phải cập nhật theo thời gian
Tác động lên upstream đang yếu Tăng tải Giảm tải
Giả định Lỗi là thoáng qua Lỗi sẽ còn tiếp diễn
Người dùng thấy gì Chờ lâu hơn, có thể vẫn thành công Lỗi ngay, nhưng nhanh và rẻ

Hai thứ đối nghịch về hướng tác động nên bổ sung cho nhau: retry xử lý lỗi ngẫu nhiên rời rạc, breaker xử lý lỗi có hệ thống. Có retry mà không breaker là công thức của retry storm; có breaker mà không retry thì mọi trục trặc nhỏ đều lọt tới người dùng. Outlier ejection là họ hàng gần: thay vì bật/tắt cả pool, nó loại riêng những node lệch khỏi đám đông dựa trên chính lưu lượng thật. Với nginx mã nguồn mở, max_fails + fail_timeout chính là một bản outlier ejection rút gọn — sau fail_timeout nginx thử lại node đó, về hành vi đó là một trạng thái half-open đơn giản, chỉ là không có tên gọi.

5.7 Buffering và streaming

Mặc định của nginx là bật buffering cả hai chiều, đúng cho phần lớn trường hợp — nhưng nó phá hỏng hoàn toàn một số loại lưu lượng.

proxy_buffering ON (mặc định)
  api-1 ──[phản hồi 2 MB, xong trong 50ms]──► nginx (nạp vào buffer/đĩa)
        goroutine + kết nối pgxpool được GIẢI PHÓNG ở đây, sau 50ms
                                             └──[nhỏ giọt 30 giây]──► client 3G

proxy_buffering OFF
  api-1 ─────────[bị ghim suốt 30 giây]──────────────────────────► client 3G
        goroutine + kết nối pgxpool bị giữ nguyên trong cả 30 giây đó

Đây là lý do thật sự của buffering: nó tách tốc độ upstream khỏi tốc độ client. nginx hướng sự kiện, một kết nối chậm đang chờ chỉ tốn vài KB bộ nhớ; cmd/api thì tốn một goroutine, các buffer của nó, và — nếu handler chưa trả kết nối DB về pool — một slot trong pgxpool với MaxConns chỉ có 10 (02-nen-mong-platform.md §3.2). Một nghìn client 3G không làm nginx nhíu mày, nhưng thừa sức làm cạn pool DB.

Cùng logic cho chiều ngược lại: proxy_request_buffering on (cũng là mặc định) khiến nginx đọc hết body request rồi mới mở kết nối tới upstream. Đây là một lớp phòng thủ slow-loris thật sự — kẻ tấn công gửi body nhỏ giọt chỉ giữ được tài nguyên của nginx, không chạm tới cmd/api. Kết hợp với ReadHeaderTimeout ở phía Go (04-module-identity.md §15.2) thì bạn có hai lớp cho hai pha tấn công: nginx chắn pha body, ReadHeaderTimeout chắn pha header.

Buffering là một lớp phòng thủ, không phải cả bức tường. Nếu proxy của bạn viết bằng Go thì mỗi kết nối đang bị giữ vẫn tốn goroutine — buffering ở đó chỉ dời chi phí từ cmd/api sang proxy chứ không xoá nó. Trần số kết nối và ReadHeaderTimeout vẫn bắt buộc.

Khi buffering là sai. Bản chất SSE là đẩy từng sự kiện tới client ngay khi có; buffering giữ chúng lại tới khi buffer đầy hoặc upstream đóng kết nối — người dùng ngồi nhìn màn hình trống rồi nhận cả cục. Triệu chứng cực khó chịu: chạy hoàn hảo khi test thẳng vào cmd/api, hỏng ngay khi đi qua nginx.

location /api/events {
    proxy_pass http://community_api;

    # Ba dòng này đi liền nhau, thiếu dòng nào cũng hỏng:
    proxy_buffering off;          # đẩy từng event ra ngay
    proxy_cache off;              # không bao giờ cache một luồng sự kiện
    proxy_read_timeout 1h;        # luồng SSE im lặng vài phút là bình thường

    # HTTP/1.1 bắt buộc cho chunked transfer; xoá Connection để không vô tình
    # chuyển tiếp "close" của client xuống upstream.
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}

Có cách nữa thường tốt hơn vì quyết định nằm ở phía ứng dụng: cmd/api tự trả header X-Accel-Buffering: no trên đúng những phản hồi cần stream; nginx hiểu header này và tắt buffering cho riêng phản hồi đó, nên bạn không phải liệt kê trước mọi đường dẫn streaming trong file cấu hình proxy. Với tải file lớn, phản hồi vượt kích thước buffer bị ghi ra file tạm rồi đọc lại — một vòng ghi/đọc đĩa thừa cho dữ liệu chỉ đi ngang qua; cân nhắc proxy_max_temp_file_size 0 cho các đường dẫn đó. Cấu hình đầy đủ ở §11.

FlushInterval trong Go. httputil.ReverseProxy có trường FlushInterval time.Duration điều khiển đúng chuyện này khi copy body phản hồi về client: 0 (mặc định) là không flush định kỳ; dương là flush theo chu kỳ đó; âm là flush ngay sau mỗi lần ghi. Và một hành vi có sẵn, đáng biết vì nó tiết kiệm cho bạn một buổi debug: ReverseProxy tự bỏ qua FlushInterval và flush ngay khi nhận ra phản hồi là streaming — cụ thể khi Content-Typetext/event-stream, hoặc khi phản hồi không khai báo Content-Length. Nói cách khác, SSE qua httputil.ReverseProxy chạy đúng mà không cần cấu hình gì, khác hẳn nginx.

proxy := &httputil.ReverseProxy{
	Rewrite: func(pr *httputil.ProxyRequest) {
		pr.SetURL(target)  // đổi scheme/host/path sang upstream
		pr.SetXForwarded() // ghi ĐÈ X-Forwarded-*, không nối thêm vào giá trị
		                   // client gửi lên — xem §4 về chống spoof
	},
	Transport: newUpstreamTransport(),

	// -1 = flush ngay sau mỗi lần ghi. Đặt tường minh khi proxy này phục vụ cả
	// lưu lượng streaming lẫn thường: với phản hồi có Content-Length bình
	// thường chi phí thêm không đáng kể, với SSE thì nó là bắt buộc.
	FlushInterval: -1,
}

Vì sao Rewrite chứ không phải Director? Director là API cũ; với nó, X-Forwarded-For được ReverseProxy nối thêm vào giá trị client gửi lên — client hoàn toàn có thể bơm sẵn một chuỗi IP giả vào đó. Rewrite nhận *ProxyRequest với request vào (In) và ra (Out) tách bạch, và SetXForwarded() ghi đè các header X-Forwarded-* dựa trên kết nối thật. Mô hình tin cậy đầy đủ ở §4; bản proxy Go hoàn chỉnh ở §10.

5.8 Connection pooling về upstream

Mặc định, nginx nói HTTP/1.0 với upstream và đóng kết nối sau mỗi request. Hậu quả theo mức độ: mỗi request trả giá bắt tay TCP (một round-trip cộng vào mọi request); nếu có TLS giữa proxy và upstream thì thêm bắt tay TLS (round-trip lẫn CPU); và thứ ba — thứ thật sự giết hệ thống — cạn cổng ephemeral.

Proxy mở kết nối ra upstream → kernel cấp một cổng nguồn ephemeral.
Kết nối đóng → phía chủ động đóng giữ cổng đó ở TIME_WAIT một lúc
               (Linux: cỡ một phút, hằng số biên dịch sẵn, không chỉnh bằng sysctl).

Dải cổng mặc định Linux (net.ipv4.ip_local_port_range): 32768…60999 ≈ 28.000 cổng
Trần cho MỘT cặp (IP đích, cổng đích):  28.000 / 60s  ≈  460 kết nối mới mỗi giây

Vượt qua đó: connect() trả EADDRNOTAVAIL. Triệu chứng: lỗi ngẫu nhiên, rải rác,
KHÔNG tương quan với tải upstream — upstream rảnh mà proxy không gọi tới được.

Con số 460 chỉ là bậc độ lớn: dải cổng chỉnh được, và cổng được đếm theo bộ bốn (IP nguồn, cổng nguồn, IP đích, cổng đích) nên thêm một địa chỉ upstream là nhân đôi trần. Nhưng bậc độ lớn mới là điều đáng nhớ: hàng trăm kết nối mới mỗi giây, không phải hàng chục nghìn. Một dịch vụ vài nghìn req/s không có keep-alive sẽ đâm vào tường này.

Cách sửa ở nginx đúng ba dòng, và thiếu hai dòng sau thì dòng đầu vô tác dụng:

upstream community_api {
    least_conn;
    server api-1:8000;
    server api-2:8000;

    # Số kết nối RẢNH giữ sẵn, tính TRÊN MỖI WORKER PROCESS.
    # Tổng thực tế = keepalive × worker_processes. Chỗ này hay tính nhầm.
    keepalive 64;
}

location /api/ {
    proxy_pass http://community_api;

    # BẮT BUỘC: mặc định nginx nói HTTP/1.0 với upstream, mà 1.0 không giữ
    # kết nối. Thiếu dòng này thì "keepalive 64" ở trên không làm gì cả.
    proxy_http_version 1.1;

    # BẮT BUỘC: xoá header Connection do client gửi lên. Nếu client gửi
    # "Connection: close", nó được chuyển tiếp nguyên xi xuống upstream và
    # đóng đúng cái kết nối mà ta vừa cố giữ.
    proxy_set_header Connection "";
}

Phía Go: MaxIdleConnsPerHost mặc định là 2.

http.TransportMaxIdleConns (tổng) và MaxIdleConnsPerHost — để 0 thì Go dùng hằng số DefaultMaxIdleConnsPerHost, và hằng số đó bằng 2. Nghĩa là một reverse proxy Go bắn 500 request đồng thời vào một upstream sẽ mở 500 kết nối, rồi khi xong đóng 498 cái vì pool chỉ giữ lại 2 — vòng lặp mở-đóng đó dẫn thẳng tới bài toán cạn cổng ở trên.

Bẫy cùng họ: kết nối chỉ được trả về pool nếu body phản hồi được đọc hết rồi đóng. Bỏ dở body giữa chừng là vứt luôn kết nối đó. httputil.ReverseProxy tự lo việc này, code thủ công gọi RoundTrip thì không.

Ngân sách kết nối dây chuyền — phần mà mỗi tầng đều cấu hình đúng theo nghĩa riêng của nó, nhưng ghép lại thì sai:

   Client              Proxy                cmd/api             PostgreSQL
      │   ~vô hạn        │  MaxConnsPerHost    │  pgxpool MaxConns   │ max_connections
      │   kết nối        │  = 128 / upstream   │  = 10 mỗi instance  │ = 100 toàn cụm
      ▼                  ▼                     ▼                     ▼
   ┌──────┐          ┌────────┐           ┌─────────┐          ┌──────────┐
   │ 5000 │ ───────► │  128   │ ────────► │   10    │ ───────► │    30    │
   └──────┘          └────────┘           └─────────┘          └──────────┘
                          ▲                    └── 118 request còn lại XẾP HÀNG
                          │                        ở pgxpool.Acquire
                          └── cổ chai THẬT nằm ở đây, nhưng không ai thấy nó
                              vì proxy vẫn báo "đã chuyển tiếp thành công"

Cơ chế hỏng: proxy đẩy 128 request đồng thời vào một instance → cmd/api sinh 128 goroutine (Go không khó khăn gì ở bước này) → cả 128 gọi pgxpool.Acquire, mười cái đầu có kết nối, 118 cái còn lại chặn tại đó → độ trễ nhìn từ ngoài tăng vọt nhưng CPU của cmd/api gần bằng 0 và PostgreSQL cũng rảnh, mọi biểu đồ đều xanh → proxy chạm proxy_read_timeout và, nếu bạn lỡ bật retry cho non-idempotent, gửi thêm request nữa vào chính hàng đợi đang tắc.

Quy tắc Cụ thể
Mức đồng thời proxy cho mỗi instance nên tương xứng MaxConns của instance đó 128 kết nối đổ vào một pool 10 chỉ tạo ra một hàng đợi vô hình. nginx có tham số max_conns= trên directive server để đặt trần này
Acquire phải có deadline pgxpool.Acquire chặn tới khi có kết nối hoặc context hết hạn. Context request có deadline (§5.4) biến "chờ vô hạn" thành "503 nhanh" — thà từ chối sớm còn hơn xếp hàng để rồi cũng timeout
Cộng MaxConns trên toàn hệ thống Ngân sách 55/100 ở 02-nen-mong-platform.md §3.2 là ngân sách chung của api + worker + relay. Scale api từ 3 lên 10 pod là vượt trần, và Postgres từ chối kết nối của cả những dịch vụ đang chạy tốt
Xuất pool.Stat() ra metric AcquiredConns(), IdleConns(), EmptyAcquireCount(), EmptyAcquireWaitTime() của *pgxpool.Stat cho biết bạn đang ở bước 3 của kịch bản trên. Không có chúng, triệu chứng trông y hệt "mạng chậm"

5.9 Graceful shutdown và draining

Mọi thứ ở trên là để chịu được lỗi ngoài dự kiến. Mục này nói về lỗi do chính bạn gây ra, theo lịch, mỗi lần deploy. Cách sai — cũng là cách mặc định nếu không ai nghĩ tới:

t=0.0s  Orchestrator gửi SIGTERM cho api-2
t=0.0s  api-2 gọi srv.Shutdown() → ĐÓNG LISTENER ngay lập tức
t=0.0s  ...nhưng LB CHƯA BIẾT GÌ. Nó vẫn coi api-2 là khoẻ.
t=0.1s  LB gửi request vào api-2  →  connection refused  →  502 ❌
t=2.0s  LB gọi health check → fail (1/3)
t=4.0s  LB gọi health check → fail (2/3)
t=6.0s  LB gọi health check → fail (3/3) → cuối cùng mới rút api-2

   →  6 giây liên tục trả 502 cho người dùng thật, MỖI LẦN deploy MỖI instance.

Cửa sổ 6 giây đó chính là công thức interval × unhealthy_threshold§5.2.1. Không phải bug của LB — LB đang làm đúng việc của nó; bug nằm ở chỗ ứng dụng đóng cửa trước khi báo cho ai biết. Trong Kubernetes chuyện này còn khó hơn vẻ ngoài: việc gỡ Pod khỏi danh sách endpoint và việc gửi SIGTERM xảy ra song song, không bảo đảm cái nào xong trước. Đây là lý do người ta đặt một preStop hook chỉ để ngủ vài giây — nghe rất ngớ ngẩn cho tới khi bạn hiểu nó mua cái gì.

① readyz → 503        Nói "đừng gửi nữa". Server VẪN phục vụ bình thường,
        │             chưa đóng gì cả. Đây là bước duy nhất hay bị thiếu.
        ▼
② ĐỢI drain           Đợi ≥ interval × unhealthy_threshold + độ trễ lan truyền.
        │             Mấu chốt: ta không từ chối, ta chờ hết khách.
        ▼
③ Đóng listener       srv.Shutdown(): ngừng nhận kết nối mới, đóng kết nối
        │             keep-alive đang rảnh, chờ request đang dở chạy xong.
        ▼
④ Chờ in-flight       Có trần thời gian. Quá hạn thì đành cắt — nhưng lúc này
        │             LB đã rút từ bước ①, nên không ai mất request mới.
        ▼
⑤ Đóng dependency     pool.Close() SAU CÙNG. Đóng ở bước ③ thì mọi request
                      đang dở lăn ra lỗi ở đúng dòng truy vấn cuối cùng.

Thứ tự này chỉ có một ý tưởng: thông báo trước, đóng cửa sau. Mọi lỗi 502 khi deploy đều là do làm ngược lại.

// Trong hàm run() của cmd/api — nối tiếp phần đã có ở 04-module-identity.md §15.2.
<-ctx.Done()
log.Info("nhận tín hiệu dừng, bắt đầu drain")

// ① Báo LB rút mình ra. Chưa đóng gì hết: mọi request tới trong cửa sổ drain
//    vẫn được phục vụ đầy đủ và đúng.
hc.StartDraining()

// ② Đứng yên đủ lâu để LB kịp thấy readyz chuyển đỏ. Ngân sách =
//    interval × unhealthy_threshold + một nhịp dự phòng; với check 2s và
//    ngưỡng 3 thì 8s là hợp lý. Đây là khoảng thời gian DUY NHẤT ngăn 502
//    lúc deploy — cắt nó đi là lỗi quay lại ngay.
drainFor := min(cfg.DrainDelay, 30*time.Second) // trần cứng, phòng cấu hình sai
time.Sleep(drainFor)

// ③④ Đóng listener rồi chờ request đang dở. Dùng context MỚI: ctx gốc đã bị
//     huỷ, dùng lại nó thì Shutdown trả về ngay và giết cả request đang chạy.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
	// Hết hạn mà vẫn còn request chạy dở → ngân sách ở bước ③ đang ngắn hơn
	// p99 thật của handler. Ghi lại để còn biết mà chỉnh.
	log.Warn("shutdown quá hạn, vẫn còn request đang xử lý", "err", err)
}

// ⑤ pool.Close() nằm ở defer trong run(), nên nó chạy SAU khi hàm này trả về,
//    tức là sau Shutdown. Thứ tự này là cố ý, không phải may mắn.
return nil

Ba chi tiết không hiển nhiên:

  • srv.Shutdown không đụng tới kết nối đã bị hijack. WebSocket và các kết nối nâng cấp giao thức nằm ngoài tầm với của nó, phải đóng bằng cơ chế riêng. cmd/api hiện chưa có, nhưng đây là thứ sẽ cắn khi thêm tính năng realtime.
  • Kết nối keep-alive mà LB đang giữ sẵn không tự biến mất khi listener đóng. Shutdown đóng những kết nối đang rảnh và không cho tái sử dụng kết nối vừa xong request, nhưng vẫn có khe đua rất hẹp: LB gửi request lên đúng kết nối vừa bị đóng. Cửa sổ drain ở bước ② che luôn trường hợp này, vì trong khoảng đó chưa có gì bị đóng cả.
  • cmd/workercmd/relay drain theo cách khác. Chúng không có listener và không nằm sau LB nào. Với worker, "drain" nghĩa là xử lý nốt batch hiện tại rồi commit offset trước khi thoát — bỏ qua bước đó thì lần khởi động sau đọc lại chính những message vừa xử lý, và bảng chống trùng ở 06-consumer-va-idempotency.md là thứ duy nhất cứu bạn. Với relay, ngắt giữa chừng an toàn theo thiết kế: dòng outbox chưa đánh dấu đã publish sẽ được lấy lại ở lượt sau.

Checklist độ bền của tầng proxy

# Hạng mục Vì sao
1 /healthz không chạm database Postgres restart không được phép giết mọi instance api
2 /readyz pool.Ping với timeout riêng Bắt được ca "tiến trình sống, DB chết" mà TCP check báo khoẻ
3 LB dùng /readyz, orchestrator dùng /healthz Hai người hỏi hai câu khác nhau; trộn vào nhau là mất cả hai
4 Không ip_hash, không sticky JWT stateless đã cho bạn thứ mà sticky cố mua bằng độ phức tạp
5 Timeout proxy < timeout client, và > p99 upstream Ngân sách lồng nhau — lớp trong nổ trước thì mới còn manh mối
6 proxy_next_upstream khôngnon_idempotent POST /posts retry mù = bài viết trùng + event trùng
7 Có trần cho ..._tries..._timeout Ngăn một request quét sạch cả upstream pool
8 Retry chỉ bật ở một lớp Cắt phép nhân của retry storm ngay tại gốc
9 keepalive + proxy_http_version 1.1 + Connection "" Thiếu hai dòng sau thì dòng đầu vô tác dụng → cạn cổng ephemeral
10 Mức đồng thời proxy tương xứng pgxpool.MaxConns Ngân sách kết nối dây chuyền, không phải tinh chỉnh vặt
11 Deploy: readyz đỏ → đợi → Shutdown → đóng pool Thiếu bước đầu là 502 mỗi lần deploy, đều đặn và tự gây ra
12 pool.Stat() xuất ra metric Cạn pool trông hệt "mạng chậm" nếu không có số liệu

6. Proxy Network đúng nghĩa — hồ proxy, xoay vòng và vận hành

6.1 "Proxy network" nghĩa là gì trong ngành

§1 đơn vị suy nghĩ là một máy trung gian. Trong ngành, cụm proxy network gần như luôn mang nghĩa khác: một hồ (pool) gồm hàng nghìn tới hàng triệu địa chỉ IP dùng làm lối ra, bán theo dịch vụ. Đơn vị không còn là "một máy chủ" mà là "một quần thể lối ra".

Phép thử một câu: bạn có phải quyết định "request này đi ra bằng IP nào" không?

  • Không → bạn đang dùng một proxy. Một dòng cấu hình, rồi quên.
  • → bạn đang vận hành một proxy network: chọn lối ra, đo sức khoẻ từng lối ra, cách ly lối ra hỏng, tính tiền băng thông, và gánh cả danh mục nghĩa vụ pháp lý — đạo đức ở §6.10.
Khối Vai trò Nằm trên đường dữ liệu?
Control plane Tài khoản, hạn mức, chính sách xoay vòng, danh sách IP đang khoẻ Không
Superproxy (gateway) Điểm vào chung, nhận request/CONNECT, quyết định exit nào phục vụ
Exit node Nơi gói tin thực sự rời mạng — IP mà đích nhìn thấy

Đừng nhầm với hai thứ trùng tên: một đội reverse proxy (§5) đứng trước hệ thống của bạn, service mesh (§9) là proxy bên trong hệ thống. Proxy network ở mục này luôn là chiều đi ra.

6.2 Bốn nguồn IP và bảng đánh đổi

Phía đích không nhìn thấy "proxy". Họ thấy một địa chỉ IP rồi tra xem nó thuộc dải nào, do tổ chức nào (ASN) sở hữu. Toàn bộ khác biệt giá cả nằm ở đúng một câu hỏi: dải IP này trông giống người dùng thật đến mức nào.

Nguồn IP Ai sở hữu dải Giá tương đối Tốc độ Tỉ lệ bị chặn Ổn định Rủi ro đạo đức
Datacenter Cloud/hosting $ Cao nhất, độ trễ thấp Cao — ASN lộ ngay Rất cao, IP giữ nguyên Thấp: IP là của bạn hoặc của một công ty
ISP / static residential Nhà mạng dân cư, máy đặt ở datacenter $$$ Cao Trung bình Cao, IP giữ nguyên Thấp–trung bình: thường có hợp đồng rõ
Residential Người dùng gia đình $$$$ Thấp–trung bình, độ trễ dao động Thấp Thấp: IP đến rồi đi theo máy người dùng Cao — xem §6.3
Mobile (4G/5G) Nhà mạng di động, thường sau CGNAT $$$$$ Dao động mạnh Thấp nhất Rất thấp, IP đổi liên tục Cao — cùng lý do với residential

Các dấu $ chỉ là thứ tự tương đối theo bậc độ lớn, không phải giá thị trường. Giá thay đổi theo nhà cung cấp, khu vực, thời điểm; đừng chép con số nào từ đây vào bảng dự toán.

  • Datacenter rẻ vì dư thừa. Một ASN cloud có hàng triệu IP, ai cũng thuê được trong 30 giây — nên bên phòng thủ chỉ cần một bảng tra ASN là lọc sạch, không cần thuật toán gì.
  • Residential đắt vì khan hiếm, và vì nó là đường mạng của một hộ gia đình thật. Không ai "sản xuất" thêm được IP dân cư.
  • Mobile đắt nhất vì CGNAT: rất nhiều thuê bao dùng chung một IP công cộng, nên chặn IP đó là chặn nhầm nhiều người thật. Thứ bạn trả tiền không phải tốc độ — mà là sự ngần ngại của người chặn.
  • ISP/static residential là món lai: dải IP đăng ký tên nhà mạng dân cư nhưng máy nằm trong datacenter — vừa nhanh, vừa ổn định, vừa không lộ ASN cloud.

Đổi lại, càng lên cao trong bảng thì độ trễ càng thất thường và tỉ lệ đứt kết nối càng lớn. Một exit residential có thể biến mất giữa chừng chỉ vì chủ nhà tắt máy. Mọi thiết kế ở §6.6 sinh ra từ sự thật đó.

6.3 IP residential đến từ đâu — vấn đề đồng thuận

Không nhà cung cấp nào sở hữu hàng triệu đường Internet gia đình. Họ mượn, qua ba con đường: SDK nhúng trong app miễn phí (thư viện "kiếm tiền thay quảng cáo", đổi lại thiết bị người dùng thành exit node); extension trình duyệt (cùng cơ chế, kèm điều khoản cho phép chuyển tiếp lưu lượng); và mạng chia sẻ băng thông (người dùng chủ động cài, được trả tiền theo băng thông cho mượn — minh bạch nhất trong ba, nhưng chỉ tương đối).

Vấn đề nằm ở chữ đồng thuận (consent). Đồng thuận thật đòi hỏi người dùng cuối hiểu ba điều: lưu lượng của người lạ sẽ đi qua đường mạng nhà họ; họ không kiểm soát được đó là lưu lượng gì; và nếu lưu lượng đó gây rắc rối thì IP ghi vào nhật ký của bên bị hại là IP nhà họ. Một dòng chữ ở trang 14 của EULA không đáp ứng được điều đó.

Nói thẳng: nếu bạn không biết IP của mình đến từ đâu, bạn đang mượn đường mạng — và có thể cả trách nhiệm pháp lý — của một người không hề biết bạn là ai.

Đây không phải đạo đức trừu tượng. Khi lưu lượng bạn gửi bị coi là lạm dụng, thứ hiện ra trong log của bên kia là địa chỉ nhà của một người xa lạ, không phải tên công ty bạn.

Bốn câu phải hỏi bất kỳ nhà cung cấp nào trước khi ký, và đòi trả lời bằng văn bản:

Câu hỏi Vì sao quan trọng
IP đến từ đâu, qua kênh nào? Không trả lời được rõ ràng đã là lý do đủ để dừng
Người dùng cuối được thông báo bằng cách nào, ở đâu? Phân biệt đồng thuận thật với đồng thuận chôn trong EULA
Họ rút lui được không, bằng thao tác gì? Đồng thuận không rút lại được thì không phải đồng thuận
Ai chịu trách nhiệm nếu exit bị dùng vào việc phi pháp? Xác định rủi ro thực sự rơi vào ai

Với đa số bài toán backend thông thường, đáp án đúng là không dùng residential — xem §6.11.

6.4 Kiến trúc: danh sách IP tĩnh so với backconnect gateway

Có đúng hai mô hình, khác nhau ở một câu: ai giữ danh sách IP.

Mô hình A — DANH SÁCH IP TĨNH             Mô hình B — BACKCONNECT GATEWAY

 App ─┬─► 203.0.113.10:8080 ─► Đích        ┌────── control plane ──────┐
      ├─► 203.0.113.11:8080 ─► Đích        │ hạn mức · chính sách xoay │
      └─► 198.51.100.7:3128 ─► Đích        │ danh sách exit đang khoẻ  │
                                           └────────────┬─────────────┘
 Ai chọn IP     : BẠN                                   │ (điều khiển)
 Ai đo sức khoẻ : BẠN                                   ▼
 Biết IP nào phục vụ request nào: CÓ        App ──► gw:7000 ──► superproxy
                                            (chỉ thấy MỘT endpoint)   │
                                               ┌──────────────────────┼───────────────┐
                                               ▼                      ▼               ▼
                                          exit A (IP 1)         exit B (IP 2)   exit N (IP N)
                                               └──────────────────────┴───────────────┘
                                                                      ▼  Đích
 Ai chọn IP / đo sức khoẻ: NHÀ CUNG CẤP · Biết IP nào phục vụ request nào: THƯỜNG LÀ KHÔNG

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.