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: Upgrade và Upgrade: <giá trị gốc> để WebSocket vẫn đi qua được.
RewritehayDirector?Nếu bạn dùng trường
Rewrite(Go 1.20+),ReverseProxycòn xoá bốn header forwarding do client gửi lên —Forwarded,X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Proto— trước khi gọi hàmRewritecủa bạn. Gọipr.SetXForwarded()bên trongRewritesẽ ghi lại chúng từpr.In.RemoteAddr,pr.In.Hostvàpr.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.
Directorlà 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 taypr.In.Headersangpr.Out.Headertrước khi gọiSetXForwarded()— 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 Via và Max-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-*và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/apingay 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ữ và 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_conncủa nginx,balance leastconncủa HAProxy,LEAST_REQUESTcủ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ìmleast_timetrongnginx.confrồ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:
- Đổi
Nlà 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. - 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.
- 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 instanceapithay 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=vàfail_timeout=trên directiveservertrong khốiupstream. Directivehealth_checklà 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 | Có — đâ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 /readyz có pool.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 và /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/worker và cmd/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 60skhô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-requestcho pha nhận request); cần trần tổng thể thật sự thì phải đặt ở tầng khác — ở chínhcmd/api, hoặc ở lớp gateway phía trên.
Phía Go,
ResponseHeaderTimeoutchỉ tính tới lúc header về, không tính body.
http.Transportkhông có trường nào giới hạn thời gian đọc body phản hồi.http.Client.Timeoutthì có (bao cả dial, TLS, ghi request, đọc header và đọc body) — nhưnghttputil.ReverseProxykhông dùnghttp.Client, nó gọi thẳngRoundTripper. Viết proxy bằng Go thì trần tổng thể phải đến từcontext.WithTimeoutgắ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; POST và PATCH 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.md và ARCHITECTURE.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/apisang proxy chứ không xoá nó. Trần số kết nối vàReadHeaderTimeoutvẫ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-Type là text/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
Rewritechứ không phảiDirector?Directorlà API cũ; với nó,X-Forwarded-ForđượcReverseProxynố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 đó.Rewritenhận*ProxyRequestvới request vào (In) và ra (Out) tách bạch, vàSetXForwarded()ghi đè các headerX-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:
MaxIdleConnsPerHostmặc định là 2.
http.TransportcóMaxIdleConns(tổng) vàMaxIdleConnsPerHost— để0thì 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.ReverseProxytự lo việc này, code thủ công gọiRoundTripthì 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.Shutdownkhô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/apihiệ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/workervàcmd/relaydrain 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.mdlà 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 có 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ông có non_idempotent |
POST /posts retry mù = bài viết trùng + event trùng |
| 7 | Có trần cho ..._tries và ..._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.
- Có → 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ụ |
Có |
| Exit node | Nơi gói tin thực sự rời mạng — IP mà đích nhìn thấy | Có |
Đừ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