Proxy Network — Mạng Proxy toàn tập Phần 9
| Chặng | Cái gì tốn thời gian | RTT thêm | Cách bỏ chi phí đó |
|---|---|---|---|
| Client → DNS | Tra tên, có thể qua nhiều cấp CNAME | 1+ RTT tới resolver | TTL hợp lý, giảm chuỗi CNAME |
| Client → edge | Bắt tay TCP | 1 RTT | Kết nối đã mở sẵn (keep-alive) |
| Client → edge | Bắt tay TLS | 1 RTT (TLS 1.3) / 2 RTT (TLS 1.2) | TLS 1.3, HTTP/2 hoặc HTTP/3 |
| Edge → origin | TCP + TLS lần nữa nếu không giữ kết nối | 2–3 RTT, và RTT chặng này thường dài nhất | Connection pool giữa edge và origin |
LB → proxy → cmd/api |
Bắt tay, rồi thời gian handler chạy | ~0 nếu keep-alive | keepalive + proxy_http_version 1.1 |
cmd/api → Postgres |
Bắt tay + xác thực cho mỗi kết nối mới | 1 RTT + chi phí auth | pgxpool với MinConns > 0 (02-nen-mong-platform.md) |
| Xử lý nghiệp vụ | SQL, JSON, ghi outbox | Không phải RTT — là CPU và I/O | Index, giảm số round-trip SQL |
Sàn vật lý. Ánh sáng trong sợi quang đi khoảng 200.000 km/s, tức ~5 µs mỗi km một chiều. Một RTT trên quãng 1.000 km không thể nhỏ hơn ~10 ms, thực tế luôn cao hơn vì còn thiết bị chuyển mạch trên đường. Không tuỳ chọn cấu hình nào phá được con số này — chỉ có cách giảm số RTT hoặc rút ngắn quãng đường (đó chính là lý do CDN tồn tại, xem §8).
| Tình huống bắt tay | Số RTT trước khi byte ứng dụng đầu tiên đi được |
|---|---|
| TCP mới + TLS 1.3 đầy đủ | 1 (TCP) + 1 (TLS) = 2 |
| TCP mới + TLS 1.2 đầy đủ | 1 (TCP) + 2 (TLS) = 3 |
| TCP mới + TLS 1.3 resumption (PSK) | 2 — resumption tiết kiệm CPU chữ ký, không tiết kiệm RTT |
| TCP mới + TLS 1.3 0-RTT (early data) | 1 — dữ liệu đi kèm ClientHello |
| Kết nối tái sử dụng | 0 — đây là con số cần nhắm tới |
0-RTT nhanh, nhưng trả bằng tính đúng đắn.
Early data của TLS 1.3 (RFC 8446, §8 0-RTT and Anti-Replay) có thể bị phát lại: kẻ tấn công ghi lại ClientHello kèm early data rồi gửi lại nhiều lần, và server không có cách nào phân biệt bản gốc với bản sao chỉ bằng thông tin trong bắt tay.
Hệ quả cụ thể: một
POST /postsđi qua 0-RTT có thể tạo hai bài viết. Quy tắc an toàn là chỉ cho phép 0-RTT với request idempotent. RFC 8470 định nghĩa cách làm đúng — proxy gắn headerEarly-Data: 1, origin trả425 Too Earlynếu request không an toàn để chạy trên dữ liệu chưa được xác nhận.
# Bật 0-RTT thì PHẢI báo cho upstream biết request nào đến qua early data.
# Thiếu dòng proxy_set_header, cmd/api không có cách nào tự bảo vệ mình.
ssl_early_data on;
proxy_set_header Early-Data $ssl_early_data;
Vì sao tái sử dụng kết nối là đòn bẩy lớn nhất
Nhìn lại hai bảng trên: mọi dòng có chữ "RTT" đều biến mất khi kết nối được dùng lại. Với một API trả JSON vài KB, chi phí thiết lập kết nối thường lớn hơn chi phí xử lý nghiệp vụ. Đây không phải tối ưu vi mô — nó quyết định bậc độ lớn của độ trễ.
upstream community_api {
server 127.0.0.1:8000;
# Số kết nối RẢNH giữ lại — MỖI worker process giữ riêng chừng này.
# Đây KHÔNG phải giới hạn tổng số kết nối; chỗ này hay bị hiểu nhầm nhất.
keepalive 64;
keepalive_timeout 60s;
keepalive_requests 1000;
}
server {
location /api/ {
proxy_pass http://community_api;
# HAI DÒNG NÀY LÀ BẮT BUỘC. Mặc định nginx nói HTTP/1.0 với upstream và
# gửi "Connection: close", nên khối keepalive ở trên không có tác dụng gì
# cả — im lặng, không một dòng cảnh báo nào.
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Cách xác nhận nó chạy thật: đo $upstream_connect_time trong access log — kết nối tái sử dụng cho con số gần 0.000, còn nếu nó bằng đúng RTT tới upstream ở mọi request thì keep-alive đang không hoạt động. Phía client của Go, net/http/httptrace trả lời cùng câu hỏi đó:
// cmd/nettrace/main.go — trả lời một câu hỏi: kết nối có được tái sử dụng không?
package main
import (
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"net/http/httptrace"
"os"
"time"
)
func main() {
req, err := http.NewRequest(http.MethodGet, os.Args[1], nil)
if err != nil {
log.Fatal(err)
}
start := time.Now()
// httptrace chỉ gắn hook, không đổi hành vi request. Đây là cách duy nhất
// trong stdlib để biết một request đã trả giá bắt tay hay chưa.
trace := &httptrace.ClientTrace{
TLSHandshakeDone: func(cs tls.ConnectionState, err error) {
// resumed=false ở MỌI request ⇒ session ticket không được tái dùng.
fmt.Printf("TLS %s resumed=%v alpn=%q err=%v\n",
tls.VersionName(cs.Version), cs.DidResume, cs.NegotiatedProtocol, err)
},
// Reused=true nghĩa là toàn bộ chi phí TCP + TLS bằng 0. Chạy trong vòng
// lặp mà lần nào cũng false ⇒ connection pool đang hỏng ở đâu đó.
GotConn: func(i httptrace.GotConnInfo) {
fmt.Printf("CONN reused=%v wasIdle=%v idle=%v\n", i.Reused, i.WasIdle, i.IdleTime)
},
GotFirstResponseByte: func() { fmt.Printf("TTFB %v\n", time.Since(start).Round(time.Millisecond)) },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
// http.DefaultTransport dùng http.ProxyFromEnvironment: đặt HTTP_PROXY /
// HTTPS_PROXY / NO_PROXY rồi chạy tool này là đo đúng đường đi QUA proxy.
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
fmt.Printf("DONE %s tổng %v\n", resp.Status, time.Since(start).Round(time.Millisecond))
}
14.2 Các giới hạn hệ thống thực sự gây sự cố
Proxy giữ hai socket cho mỗi request: một với client, một với upstream. Vì thế nó chạm trần hệ điều hành sớm hơn app rất nhiều, và các trần đó không báo lỗi tử tế — chúng biểu hiện thành timeout, thành 502 rải rác, thành "thỉnh thoảng chậm".
14.2.1 File descriptor
Mỗi socket là một fd, nên proxy phục vụ 10.000 kết nối đồng thời cần hơn 20.000 fd. Chỗ dễ sai: ulimit -n trong shell của bạn không phải trần của tiến trình đã chạy dưới systemd hay Docker — luôn đọc /proc/<pid>/limits.
cat /proc/$(pgrep -n nginx)/limits | grep -i 'open files' # trần THẬT của tiến trình
ls /proc/$(pgrep -n nginx)/fd | wc -l # đang dùng bao nhiêu
# Ngữ cảnh main. Đặt cả hai: worker_connections vượt quá rlimit thì nginx vẫn
# khởi động bình thường và chỉ chết lúc tải cao — đúng lúc tệ nhất.
worker_rlimit_nofile 65535;
events { worker_connections 16384; }
14.2.2 Cạn port ephemeral
Lỗi kinh điển của proxy không dùng keep-alive với upstream. Mỗi kết nối ra ngoài chiếm một port nguồn, và port đó còn bị giữ thêm ở TIME_WAIT sau khi đóng.
cat /proc/sys/net/ipv4/ip_local_port_range # thường "32768 60999" ⇒ ~28.000 port
ss -tan state time-wait | wc -l # đang kẹt bao nhiêu
Với ~28.000 port và TIME_WAIT kéo dài 60 giây trên Linux, trần lý thuyết vào khoảng 470 kết nối mới mỗi giây tới cùng một cặp IP:port đích. Vượt qua đó, connect() trả EADDRNOTAVAIL:
connect() to 10.0.0.5:8000 failed (99: Cannot assign requested address) while connecting to upstream
Ba cách sửa, theo thứ tự nên thử: (1) bật keep-alive — kết nối tái sử dụng không tiêu port mới, đây là cách chữa gốc; (2) net.ipv4.tcp_tw_reuse = 1 cho phép dùng lại socket ở TIME_WAIT cho kết nối đi ra, an toàn khi TCP timestamps bật — giảm đau, không chữa; (3) nới ip_local_port_range — mua thêm thời gian, không hơn.
Đừng đi tìm
tcp_tw_recycle.Nó từng được khuyên trên vô số blog, nhưng làm hỏng kết nối từ client sau NAT (nhiều client chung IP, timestamp không đơn điệu) và đã bị gỡ khỏi kernel Linux từ 4.12. Thấy nó trong một tài liệu nghĩa là tài liệu đó cũ hơn thứ bạn đang chạy.
14.2.3 Backlog và conntrack
Khi kernel hoàn tất bắt tay TCP mà tiến trình chưa kịp accept(), kết nối nằm trong accept queue. Queue đầy thì kernel âm thầm drop: client không nhận lỗi, client chỉ thấy chậm rồi timeout. Đó là triệu chứng khó chẩn đoán nhất mục này. somaxconn là trần — listen(fd, backlog) của ứng dụng bị cắt xuống giá trị đó, nên nâng backlog= trong nginx mà quên somaxconn thì không có tác dụng gì.
ss -ltn # với socket LISTEN: Recv-Q = đang chờ accept, Send-Q = backlog tối đa
# Recv-Q chạm Send-Q liên tục ⇒ đang mất kết nối mà không ai ghi log.
cat /proc/sys/net/core/somaxconn # trần cứng cho mọi listen backlog
nstat -az TcpExtListenOverflows TcpExtListenDrops # khác 0 là có tràn thật
sysctl -w net.core.somaxconn=4096 # tạm thời; vĩnh viễn: /etc/sysctl.d/
# Ngữ cảnh server. backlog chỉ có hiệu lực ở listen ĐẦU TIÊN của mỗi cổng.
listen 443 ssl backlog=4096;
Có NAT ở giữa (Docker bridge, node Kubernetes, gateway) thì mỗi luồng chiếm một dòng trong bảng conntrack của netfilter. Bảng đầy ⇒ gói mới bị drop, và triệu chứng giống hệt mạng chập chờn: mất gói ngẫu nhiên, retransmit, timeout không theo quy luật. Dòng dmesg là bằng chứng duy nhất phân biệt được hai thứ.
sysctl net.netfilter.nf_conntrack_max
cat /proc/sys/net/netfilter/nf_conntrack_count
dmesg | grep -i conntrack # "nf_conntrack: table full, dropping packet"
14.2.4 Triệu chứng → lệnh kiểm tra → cách sửa
| Triệu chứng | Lệnh kiểm tra | Nguyên nhân | Cách sửa |
|---|---|---|---|
24: Too many open files trong error log |
cat /proc/<pid>/limits |
Hết fd | worker_rlimit_nofile, LimitNOFILE trong unit systemd |
99: Cannot assign requested address |
ss -tan state time-wait \| wc -l |
Cạn port ephemeral | Bật keepalive upstream, sau đó mới tới tcp_tw_reuse |
| Client timeout nhưng proxy không log gì | ss -ltn, nstat xem ListenOverflows |
Accept queue tràn | Nâng somaxconn + backlog=, tăng số worker |
| Mất gói ngẫu nhiên, chỉ trên máy có NAT | dmesg \| grep conntrack |
Bảng conntrack đầy | Nâng nf_conntrack_max, giảm timeout, hoặc bỏ NAT |
upstream_connect_time ≈ RTT ở mọi request |
grep access log |
Keep-alive không chạy | proxy_http_version 1.1 + Connection "" |
14.2.5 Đối sánh trên Windows và Docker Desktop
Máy dev của dự án này là Windows 11. Các sysctl ở trên không tồn tại ở đó, nhưng khái niệm thì có, chỉ đổi tên:
| Khái niệm Linux | Trên Windows (PowerShell) |
|---|---|
ip_local_port_range |
netsh int ipv4 show dynamicport tcp; nới bằng netsh int ipv4 set dynamicport tcp start=10000 num=50000 |
ss -tan state time-wait |
Get-NetTCPConnection -State TimeWait \| Measure-Object |
ss -ltn |
Get-NetTCPConnection -State Listen |
Thời gian giữ TIME_WAIT |
Khoá registry TcpTimedWaitDelay trong HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters; khoá không tồn tại nghĩa là đang dùng mặc định của HĐH |
netstat -tanp |
netstat -ano rồi tra PID, hoặc Get-NetTCPConnection -OwningProcess |
ulimit -n |
Windows không có khái niệm tương đương ở mức tiến trình |
Quan trọng: container trong Docker Desktop không chạy trên kernel Windows — chúng chạy trong máy ảo Linux (WSL2). Mọi sysctl trong mục này áp dụng bình thường bên trong container, và phải khai báo qua compose:
# deployments/docker-compose.yml — trích phần proxy.
# Chỉ đặt được các khoá thuộc network namespace của container; nhiều khoá
# net.ipv4.* khác thuộc về host nên Docker từ chối thẳng.
services:
proxy:
image: nginx:1.27-alpine
sysctls: { net.core.somaxconn: "4096" }
ulimits:
nofile: { soft: 65535, hard: 65535 }
Và điều cần nhớ nhất: đo hiệu năng trên Docker Desktop không nói được gì về production. Lớp ảo hoá mạng giữa Windows và WSL2 có chi phí riêng. Máy dev dùng để tìm lỗi chức năng, không phải để lấy số.
14.3 Quan sát — bốn nhóm chỉ số của một proxy
Ba nhóm đầu là RED (Rate, Errors, Duration — cách đóng gói của Tom Wilkie cho service hướng request). Nhóm thứ tư, saturation, mượn từ phương pháp USE của Brendan Gregg. RED cho biết người dùng đang chịu gì; saturation cho biết còn bao nhiêu chỗ trống trước khi vỡ.
| Nhóm | Chỉ số bắt buộc | Vì sao |
|---|---|---|
| Rate | req/s tách theo upstream, theo location, theo phương thức |
Một upstream nhận gấp đôi phần còn lại nghĩa là load balancing hỏng (§5) |
| Errors | tỷ lệ 5xx tách theo upstream, và tách 5xx do proxy sinh khỏi 5xx do app trả | Một upstream chiếm hết 5xx là một instance hỏng, không phải hệ thống hỏng |
| Duration | histogram của request_time và upstream_response_time, riêng biệt |
Hiệu của chúng chính là thời gian nằm ở proxy và mạng phía client |
| Saturation | số kết nối đang mở, kết nối rảnh trong pool upstream, worker đang bận, CPU (TLS rất tốn CPU), độ sâu accept queue | Nhóm duy nhất báo trước; ba nhóm trên chỉ báo lúc đã xảy ra |
| Kèm theo | tỷ lệ cache hit theo $upstream_cache_status |
Hit ratio tụt là dấu hiệu cache key sai hoặc origin đổi Cache-Control |
Access log mặc định (combined) không có một thông tin nào về upstream, nên nó vô dụng cho việc vận hành reverse proxy. Bản tối thiểu nên dùng:
# Ngữ cảnh http. escape=json để dấu nháy trong User-Agent không phá JSON.
log_format community_json escape=json
'{"time":"$time_iso8601","request_id":"$request_id","host":"$host",'
'"remote_addr":"$remote_addr","xff":"$http_x_forwarded_for",'
'"method":"$request_method","uri":"$uri","args":"$args",'
'"proto":"$server_protocol","scheme":"$scheme","ssl_proto":"$ssl_protocol",'
'"status":$status,"bytes":$body_bytes_sent,"request_time":$request_time,'
'"upstream_addr":"$upstream_addr","upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"cache":"$upstream_cache_status","ua":"$http_user_agent"}';
access_log /var/log/nginx/access.json community_json;
Vì sao các biến
upstream_*phải nằm trong dấu nháy?Khi nginx thử nhiều upstream (retry sau lỗi) hoặc có internal redirect, các biến này trở thành danh sách:
"0.012, 0.480"hoặc"502, 200". Khi không upstream nào được chọn, chúng là"-". Bỏ nháy là JSON hỏng đúng vào lúc có sự cố — đúng lúc bạn cần log nhất. Ngược lại$status,$body_bytes_sentvà$request_timeluôn là một giá trị số nên để trần được.
| Biến | Nghĩa |
|---|---|
$upstream_connect_time |
Thời gian mở kết nối tới upstream, gồm cả bắt tay TLS nếu có. ~0 khi keep-alive chạy |
$upstream_header_time |
Từ lúc gửi request tới lúc nhận xong header — đây là "thời gian app suy nghĩ" |
$upstream_response_time |
Tới lúc nhận xong body. Trừ đi header_time ra thời gian stream body |
$request_time |
Toàn bộ, từ byte đầu đọc được từ client tới byte cuối gửi cho client |
request_time ≈ upstream_response_time → proxy trong sạch; chậm là do app.
request_time ≫ upstream_response_time → thời gian nằm NGOÀI upstream: client
upload chậm, mạng client tệ, hoặc
proxy đang buffer body ra đĩa.
upstream_response_time ≫ header_time → app trả header nhanh rồi stream body
chậm: N+1 query, phân trang thiếu LIMIT.
Chỉ đo một con số duy nhất thì mọi cuộc tranh luận "lỗi tại proxy hay tại app" đều là cảm tính; hai con số này chấm dứt tranh luận đó trong ba mươi giây. Cho saturation, bật stub_status và chỉ mở cho localhost. Trong kết quả, cột accepts lớn hơn cột handled nghĩa là nginx đã phải từ chối kết nối (thường vì chạm worker_connections); còn Waiting cao là bình thường — đó là các kết nối keep-alive đang rảnh, tức keep-alive đang làm đúng việc.
location = /nginx_status { stub_status; access_log off; allow 127.0.0.1; deny all; }
Các chỉ số phía sau proxy — outbox tồn đọng, consumer lag, DLQ — thuộc tầng event và đã có bảng riêng trong ARCHITECTURE.md §11.4. Proxy không nhìn thấy chúng; đó chính là lý do phải có cả hai bộ chỉ số.
14.4 Truy vết phân tán — một ID xuyên HTTP và Kafka
Đây là chỗ dự án community được lợi nhiều nhất, vì kiến trúc event-driven cắt một hành động của người dùng thành nhiều tiến trình: cmd/api ghi DB và outbox, cmd/relay publish, cmd/worker tiêu thụ. Không có ID chung, ba log file đó là ba câu chuyện rời rạc.
| Header | Chuẩn | Dùng để |
|---|---|---|
traceparent |
W3C Trace Context (khuyến nghị của W3C, không phải RFC) | Nối các span thành một cây trace. Dạng <version>-<trace-id 32 hex>-<parent-id 16 hex>-<flags 2 hex> |
tracestate |
W3C Trace Context | Danh sách key=value ngăn bằng dấu phẩy để từng nhà cung cấp mang trạng thái riêng. Proxy chuyển tiếp nguyên vẹn, không sửa |
X-Request-ID |
Không có chuẩn — quy ước ngành | Một ID cho một request để con người grep, và để đọc qua điện thoại khi hỗ trợ người dùng |
Không phải chọn một. traceparent cho máy, X-Request-ID cho người; chi phí giữ cả hai gần bằng không.
# Ngữ cảnh http. Ở BIÊN INTERNET: không tin X-Request-Id do client gửi.
# Client tự đặt được ID thì nó trộn được request của mình vào trace của người
# khác, hoặc nhét ký tự xuống dòng vào log của bạn. Luôn ghi đè bằng $request_id.
map $http_traceparent $tp_out {
default $http_traceparent; # có traceparent đi vào ⇒ giữ nguyên chuỗi
"" ""; # không có ⇒ để app tự sinh, proxy không bịa
}
server {
location /api/ {
proxy_pass http://community_api;
# $request_id là 32 ký tự hex nginx tự sinh cho mỗi request.
proxy_set_header X-Request-Id $request_id;
proxy_set_header traceparent $tp_out;
proxy_set_header tracestate $http_tracestate;
# Trả ID về client kèm CẢ response lỗi ⇒ người dùng báo lỗi là có ID.
# "always" rất quan trọng: thiếu nó, header không xuất hiện ở 4xx/5xx.
add_header X-Request-Id $request_id always;
}
}
Vì sao proxy không tự sinh
traceparent?Vì
traceparentkhông chỉ mangtrace-id, nó còn mangparent-id— định danh của span cha. Nếu nginx bịa mộtparent-idcố định cho mọi request, cây trace sẽ sai, và bạn tin vào một sơ đồ không đúng sự thật — tệ hơn là không có sơ đồ nào. Trace nên được sinh ở tầng có khái niệm span (app, hoặc module OpenTelemetry của nginx); proxy chỉ chuyển tiếp.
// internal/platform/httpx/middleware/trace.go
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"strings"
)
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyTraceID
)
// parseTraceparent lấy trace-id từ header traceparent của W3C Trace Context.
// Chỉ nhận version "00": đoán mò một định dạng chưa biết nguy hiểm hơn là bỏ qua.
func parseTraceparent(v string) (string, bool) {
p := strings.Split(v, "-")
if len(p) != 4 || p[0] != "00" || len(p[1]) != 32 || len(p[2]) != 16 || len(p[3]) != 2 {
return "", false
}
// trace-id toàn số 0 là giá trị không hợp lệ theo spec — coi như không có.
if _, err := hex.DecodeString(p[1]); err != nil || p[1] == strings.Repeat("0", 32) {
return "", false
}
return p[1], true
}
func randomHex(nbytes int) string {
b := make([]byte, nbytes)
// crypto/rand.Read của Go hiện tại không trả lỗi (thất bại thì panic),
// nên ở đây không có nhánh lỗi nào cần xử lý.
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Trace gán request_id + trace_id vào context và trả request_id về client.
// Cố ý KHÔNG đọc X-Request-Id do client gửi: ở biên Internet header đó là dữ
// liệu của người lạ, tin nó là mở đường cho log injection và trace giả. Chỉ tin
// khi tiến trình nằm sau một proxy nội bộ đã ghi đè header.
func Trace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := randomHex(16)
traceID, ok := parseTraceparent(r.Header.Get("Traceparent"))
if !ok {
// Không có trace hợp lệ đi vào ⇒ request này là gốc của trace mới.
traceID = randomHex(16) // 16 byte = 32 hex, đúng độ dài trace-id
}
ctx := context.WithValue(r.Context(), ctxKeyRequestID, reqID)
ctx = context.WithValue(ctx, ctxKeyTraceID, traceID)
w.Header().Set("X-Request-Id", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestID(ctx context.Context) string { v, _ := ctx.Value(ctxKeyRequestID).(string); return v }
func TraceID(ctx context.Context) string { v, _ := ctx.Value(ctxKeyTraceID).(string); return v }
// WithTraceID dùng ở phía consumer Kafka: ở đó không có HTTP request,
// trace_id đến từ Envelope.CorrelationID.
func WithTraceID(ctx context.Context, id string) context.Context {
if id == "" {
return ctx
}
return context.WithValue(ctx, ctxKeyTraceID, id)
}
// LogHandler bọc slog.Handler để MỌI dòng log tự mang request_id/trace_id.
// Không bọc thì phải nhớ truyền ID ở từng lời gọi log — và sẽ quên đúng ở
// nhánh xử lý lỗi, nơi cần ID nhất.
type LogHandler struct{ inner slog.Handler }
func NewLogHandler(inner slog.Handler) LogHandler { return LogHandler{inner: inner} }
func (h LogHandler) Enabled(ctx context.Context, l slog.Level) bool { return h.inner.Enabled(ctx, l) }
func (h LogHandler) Handle(ctx context.Context, r slog.Record) error {
if id := RequestID(ctx); id != "" {
r.AddAttrs(slog.String("request_id", id))
}
if id := TraceID(ctx); id != "" {
r.AddAttrs(slog.String("trace_id", id))
}
return h.inner.Handle(ctx, r)
}
// Hai hàm dưới PHẢI bọc lại chính LogHandler. Nếu chỉ nhúng slog.Handler mà
// quên chúng, log.With(...) trả về handler BÊN TRONG và mọi request_id biến mất:
// log vẫn chạy, vẫn đúng định dạng, chỉ thiếu trường cần khi đi truy vết.
func (h LogHandler) WithAttrs(a []slog.Attr) slog.Handler { return LogHandler{h.inner.WithAttrs(a)} }
func (h LogHandler) WithGroup(n string) slog.Handler { return LogHandler{h.inner.WithGroup(n)} }
Cái bẫy đi kèm
LogHandler: nó chỉ hoạt động khi bạn gọi các biến thể có context —log.InfoContext(ctx, ...),log.ErrorContext(ctx, ...). Gọilog.Info(...)thìslogtruyềncontext.Background()vàoHandle, không có value nào, và dòng log ra hoàn toàn không cótrace_id. Ai cũng mắc lỗi này một lần.
contracts.Envelope đã có sẵn trường CorrelationID và phương thức WithCorrelation. Việc còn lại là đảm bảo không ai quên gắn nó — đây là chỗ trace đi từ HTTP sang Kafka:
// internal/modules/post/envelope.go
package post
import (
"context"
"fmt"
"github.com/chuongtd/community/internal/contracts"
"github.com/chuongtd/community/internal/platform/httpx/middleware"
)
// newTracedEnvelope là chỗ DUY NHẤT trong module tạo envelope. Có một hàm gác
// cổng thì không thể quên gắn correlation ở một nhánh nào đó — mà chỉ cần quên
// một chỗ là trace đứt đúng tại event quan trọng nhất.
func newTracedEnvelope(ctx context.Context, eventType, aggregateID string, payload any) (contracts.Envelope, error) {
env, err := contracts.NewEnvelope(eventType, aggregateID, payload)
if err != nil {
return contracts.Envelope{}, fmt.Errorf("post: tạo envelope: %w", err)
}
// trace_id của HTTP request trở thành correlation_id của event: grep MỘT
// chuỗi hex là thấy log của cả cmd/api, cmd/relay lẫn cmd/worker.
return env.WithCorrelation(middleware.TraceID(ctx)), nil
}
Phía consumer làm chiều ngược lại: dòng đầu tiên của mọi handler là ctx = middleware.WithTraceID(ctx, env.CorrelationID), đặt trước cả bước validate — nếu handler lỗi, ta vẫn muốn chính dòng log lỗi đó mang đúng trace_id.
Kết quả cụ thể: cùng một trace_id xuất hiện trong log của ba tiến trình khác nhau, không tiến trình nào gọi hàm của tiến trình nào. Đó chính là thứ mà Luật 2 ("event là hợp đồng") lấy đi của bạn — và đây là cách lấy lại.
14.5 Bộ công cụ gỡ lỗi
Mỗi công cụ ở đây giải một câu hỏi cụ thể. Chọn theo câu hỏi, đừng chọn theo thói quen.
curl — đi qua proxy có khác không, và thời gian nằm ở đâu. Trong -v, * là ghi chú của curl, > là byte gửi đi, < là byte nhận về; với HTTPS qua forward proxy, dòng CONNECT đầu tiên cho biết proxy đang làm tunnel chứ không đọc nội dung (§3). Hiệu time_appconnect − time_connect chính là chi phí bắt tay TLS ở mục 14.1 — chạy hai lần liên tiếp, lần hai không nhanh hơn nghĩa là session resumption không hoạt động.
# -x đặt proxy cho request này; --proxy-user gửi Proxy-Authorization (RFC 9110).
curl -x http://proxy.internal:3128 --proxy-user alice:s3cret \
-v -o /dev/null https://community.example/api/posts
curl -o /dev/null -s -w @- https://community.example/api/posts <<'FMT'
dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect}
ttfb=%{time_starttransfer} total=%{time_total} status=%{http_code}
FMT
# --resolve ép tên miền trỏ về IP bạn chọn nhưng GIỮ NGUYÊN SNI và header Host:
# xác nhận origin mới TRƯỚC khi cắt DNS, thay vì cắt rồi mới biết.
curl -sS --resolve community.example:443:203.0.113.10 https://community.example/healthz
# -servername là SNI: bỏ nó thì server trả chứng chỉ mặc định và bạn đi tìm bug
# ở nhầm chỗ. -showcerts để phát hiện THIẾU chứng chỉ trung gian — trình duyệt
# thường tự vá lỗi này, curl và Go thì không, nên triệu chứng là "web mở được
# nhưng app gọi API báo lỗi TLS".
openssl s_client -connect community.example:443 -servername community.example \
-alpn h2 -showcerts </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
mitmproxy — MITM có chủ đích khi dev.
mitmproxy --listen-port 8080 # giao diện tương tác
mitmdump --listen-port 8080 -w /tmp/flows.mitm # ghi ra file để xem sau
# CA của mitmproxy sinh ra ở lần chạy đầu tiên.
curl -x http://127.0.0.1:8080 --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem \
https://community.example/api/posts
HTTPS_PROXY=http://127.0.0.1:8080 \
SSL_CERT_FILE=$HOME/.mitmproxy/mitmproxy-ca-cert.pem ./bin/api # Linux/macOS
SSL_CERT_FILEkhông có tác dụng trên Windows.Trên Linux/BSD,
crypto/x509của Go đọcSSL_CERT_FILEvàSSL_CERT_DIR. Trên Windows và macOS nó dùng bộ xác thực của hệ điều hành và bỏ qua hoàn toàn hai biến đó — phải cài CA của mitmproxy vào Trusted Root Certification Authorities thì Go mới tin. Đây là lý do phổ biến nhất khiến "cùng một lệnh mà máy khác chạy được, máy tôi thì không".
tcpdump / Wireshark / ss / dig — khi tầng HTTP không đủ.
sudo tcpdump -i any -nn -s 0 -w /tmp/proxy.pcap 'tcp port 8000 or tcp port 443'
# -nn: không phân giải tên — nhanh hơn, và không tự đẻ ra truy vấn DNS trong bản ghi
# -s 0: chụp trọn gói; thiếu nó Wireshark dựng lại HTTP bị cụt
ss -s # tổng quan theo trạng thái
ss -ltn # listener + độ sâu accept queue
ss -tanp 'sport = :8000' # ai đang nối tới cmd/api
dig +short community.example CNAME # mỗi tầng CNAME là một lần tra thêm
dig @8.8.8.8 community.example # resolver khác có thấy giống không
dig +trace community.example # đi từ root xuống, tìm chỗ uỷ quyền sai
| Bộ lọc hiển thị Wireshark | Trả lời câu hỏi |
|---|---|
tcp.flags.reset == 1 |
Ai chủ động cắt kết nối, và cắt lúc nào |
tcp.analysis.retransmission |
Có mất gói thật không, hay chỉ là app chậm |
tls.handshake.type == 1 |
Xem ClientHello: SNI, ALPN, danh sách cipher |
http.request.uri contains "/api" |
Lọc lưu lượng quan tâm (chỉ với HTTP không mã hoá) |
nginx -T — cấu hình đã hợp nhất, không phải file bạn vừa sửa. Đây là lệnh nên chạy đầu tiên mỗi khi "tôi sửa rồi mà không ăn": nguyên nhân thường là một file lạc trong conf.d/ được include sau và ghi đè, hoặc bạn sửa đúng file nhưng chưa nginx -s reload.
nginx -t # chỉ kiểm tra cú pháp
nginx -T | grep -n 'proxy_read_timeout\|client_max_body_size' # đọc giá trị THẬT
# Thay cho telnet/nc: trả về TcpTestSucceeded, kèm đường đi và độ trễ.
Test-NetConnection -ComputerName api.internal -Port 8000 -InformationLevel Detailed
Resolve-DnsName community.example -Type A
# Trên Windows PowerShell 5.1, `curl` là ALIAS của Invoke-WebRequest và KHÔNG
# hiểu các cờ ở trên. Phải gọi curl.exe thật.
curl.exe -o NUL -s -w "ttfb=%{time_starttransfer} total=%{time_total}`n" https://community.example/healthz
14.6 Bảng triệu chứng → nguyên nhân
Ba mã 5xx của proxy không thay thế nhau được, và phân biệt đúng là đã đi được nửa đường:
| Mã | Proxy đang nói gì | Nguyên nhân điển hình | Dấu vết trong error_log |
|---|---|---|---|
| 502 Bad Gateway | "Tôi có nói chuyện được với upstream, nhưng nó trả về thứ tôi không hiểu, hoặc cúp máy giữa chừng" | App crash giữa request, panic không recover, upstream đóng kết nối keep-alive đúng lúc proxy gửi request lên đó | connect() failed (111: Connection refused), upstream prematurely closed connection |
| 503 Service Unavailable | "Tôi không chọn được upstream nào để gửi" | Mọi server trong khối upstream bị health check đánh dấu down; hoặc chính proxy từ chối (limit_req, limit_conn) |
no live upstreams while connecting to upstream |
| 504 Gateway Timeout | "Upstream nhận request rồi, nhưng không trả lời kịp" | Truy vấn SQL chậm, deadlock, handler chờ dịch vụ ngoài, hoặc proxy_read_timeout quá ngắn cho endpoint đó |
upstream timed out (110: Connection timed out) while reading response header from upstream |
Phép thử một câu để nhớ: 502 là upstream trả lời sai, 503 là không có ai để hỏi, 504 là hỏi rồi mà không ai đáp. Trong log JSON ở mục 14.3, 503 thường có upstream_addr là "-" vì không upstream nào được chọn; 502 và 504 thì có địa chỉ, và 504 kèm upstream_response_time xấp xỉ đúng bằng giá trị proxy_read_timeout.
| Triệu chứng | Nguyên nhân nhiều khả năng nhất | Cách xác nhận | Cách sửa |
|---|---|---|---|
| Kết nối đứt sau đúng 60 giây | Một timeout mặc định: proxy_read_timeout của nginx mặc định 60s, idle timeout mặc định của AWS ALB cũng 60s |
Con số quá tròn chính là bằng chứng — mạng hỏng không bao giờ đúng 60,0s | Nâng proxy_read_timeout chỉ cho location cần, đồng thời sửa endpoint để trả nhanh hơn |
| WebSocket đóng sau ~1 phút | Cùng nguyên nhân: không byte nào đi qua ⇒ proxy coi là idle và đóng | So thời điểm đóng với proxy_read_timeout |
proxy_read_timeout dài cho location WS và ping/pong ở tầng ứng dụng (RFC 6455) — chỉ sửa một trong hai là chưa xong |
| Upload thất bại đúng ở 1 MB, trả 413 | client_max_body_size mặc định là 1m |
nginx -T \| grep client_max_body_size |
Đặt trong location upload, không đặt toàn cục |
| Redirect vòng vô hạn | Proxy kết thúc TLS rồi chuyển tiếp bằng http://; app thấy scheme http nên redirect sang https; lặp vô hạn |
curl -v thấy dãy 301/302 tới cùng một URL |
proxy_set_header X-Forwarded-Proto $scheme; và app phải đọc header đó (§4) |
| Một header biến mất hoàn toàn ở app | Tên header có dấu gạch dưới; nginx bỏ các header như vậy vì mặc định underscores_in_headers off |
Bắt gói giữa proxy và app: header không có ở đó dù client có gửi | Đổi sang dấu gạch ngang (X-Api-Key) — cách đúng. Hoặc underscores_in_headers on nếu không sửa được client |
| Cache trả nội dung của người khác | Response riêng tư bị cache. Nginx vốn không cache response có Set-Cookie, nhưng ai đó đã thêm proxy_ignore_headers Set-Cookie để "tăng hit ratio" |
Tìm proxy_ignore_headers và proxy_cache_key trong nginx -T |
Bỏ proxy_ignore_headers Set-Cookie; origin trả Cache-Control: private, no-store cho nội dung theo người dùng; đưa yếu tố phân biệt vào proxy_cache_key |
| Mọi IP trong DB đều là IP của proxy | App đọc RemoteAddr, mà RemoteAddr bây giờ là proxy |
SELECT DISTINCT ip FROM ... chỉ ra vài giá trị |
set_real_ip_from + real_ip_header ở proxy, và chỉ tin header đến từ IP proxy đã biết (§4) |
| 502 rải rác, không quy luật, tải thấp | Đua điều kiện keep-alive: upstream đóng kết nối rảnh đúng lúc proxy vừa gửi request lên đó | Tỷ lệ 502 tăng khi tải giảm (càng nhiều kết nối rảnh càng dễ dính) | Idle timeout của upstream phải dài hơn của proxy; bật retry cho request idempotent |
| Chậm chỉ với người dùng ở xa | RTT chi phối, và mọi RTT bị nhân lên vì thiếu keep-alive/HTTP/2 | So request_time theo vùng địa lý |
HTTP/2 hoặc HTTP/3, CDN, giảm số round-trip (§8) |
14.7 Kiểm thử tải — đo sao cho con số có nghĩa
Một phép đo sai còn tệ hơn không đo, vì nó cho bạn sự tự tin giả.
Nguyên tắc: đo upstream trước, rồi mới đo qua proxy. Chỉ có hiệu của hai phép đo mới trả lời được câu hỏi thật — proxy thêm vào bao nhiêu? Cùng thời lượng, cùng số kết nối, cùng endpoint, cùng máy sinh tải: đổi một biến duy nhất, vì đổi hai biến thì kết quả không quy trách nhiệm được cho biến nào.
hey -z 60s -c 50 http://127.0.0.1:8000/api/posts # bước 1: thẳng vào cmd/api
hey -z 60s -c 50 http://127.0.0.1/api/posts # bước 2: qua proxy, GIỮ NGUYÊN tham số
| Quan sát | Nghĩa |
|---|---|
| Proxy thêm dưới một mili-giây ở p50, cùng máy | Bình thường — chi phí chủ yếu là copy byte |
| Proxy thêm nhiều ở p99 nhưng không ở p50 | Hàng đợi. Xem accept queue, số worker, worker_connections |
| Throughput qua proxy thấp hơn hẳn | Thường là keep-alive upstream chưa bật, hoặc TLS ăn hết CPU |
| Throughput qua proxy cao hơn | Nghi ngờ ngay: có cache đang trả lời thay upstream. Kiểm tra $upstream_cache_status |
Bốn cách phổ biến để đo sai:
- Đo từ máy khác vùng. Máy sinh tải ở Singapore mà hệ thống ở Hà Nội thì RTT chiếm phần lớn con số, và bạn đang đo đường truyền quốc tế chứ không đo proxy. Đặt máy sinh tải cùng vùng với thứ đang đo; muốn biết trải nghiệm người dùng thật thì đó là một phép đo khác, tách riêng.
- Máy sinh tải chạm trần trước. Nó cũng là tiến trình mạng, cũng cạn fd và cạn port ephemeral (mục 14.2.2) — khi đó bạn đang đo giới hạn của chính mình. Luôn chạy
ss -strên máy sinh tải, không chỉ trên máy đích. - Đo trên loopback rồi kết luận cho production.
127.0.0.1không có mất gói, không có RTT, không có MTU thật. Hữu ích để tìm nghẽn CPU, vô dụng để dự đoán độ trễ. - Coordinated omission. Công cụ giữ số kết nối cố định (
ab,wrk) chỉ gửi request tiếp theo sau khi request trước xong — nên khi hệ thống chậm lại, chúng tự động giảm tải và p99 báo về đẹp một cách sai sự thật. Công cụ giữ tốc độ đến cố định (wrk2,vegeta,k6) mô phỏng đúng hành vi người dùng thật: người dùng không chờ bạn xử lý xong mới bấm tiếp.
# vegeta giữ tốc độ đến cố định — đúng thứ cần cho một con số p99 tin được.
echo "GET http://127.0.0.1/api/posts" | vegeta attack -rate=500/s -duration=60s | vegeta report
Trước khi chạy, cố định các biến: warm-up (Go không có JIT, nhưng connection pool, cache của Postgres và page cache của OS đều cần thời gian ổn định); cố định cache của proxy (đo với cache bật và cache tắt là hai phép đo khác nhau, đừng trộn); lưu cấu hình đã hợp nhất cùng kết quả bằng nginx -T > run-2026-09-04.conf, vì ba tháng sau không ai nhớ hôm đó keepalive bằng bao nhiêu; và đo cả saturation trong lúc chạy, không chỉ độ trễ — một hệ thống đạt 10.000 req/s ở 99% CPU thì con số đó không dùng để lập kế hoạch được.
Con số duy nhất đáng tin là con số bạn tự đo trên hạ tầng của mình.
Mọi benchmark công bố sẵn đều chạy trên phần cứng khác, kernel khác, cấu hình khác, payload khác. Chúng dùng để chọn thứ đáng thử, không dùng để lập kế hoạch dung lượng. Các bẫy còn lại và checklist trước khi lên production nằm ở §15.
15. Bẫy thường gặp, checklist và phụ lục
Phần này không giới thiệu khái niệm mới. Nó là thứ bạn mở ra lúc 2 giờ sáng khi production đang 502, và là thứ bạn dán vào pull request khi có người xin thêm một tầng proxy nữa.
Ba câu hỏi mà mọi bẫy dưới đây đều quy về:
- Ai được phép nói câu này? — header nào tin được, tin từ hop nào.
- Ai bỏ cuộc trước? — timeout của lớp nào ngắn hơn lớp nào.
- Ai đang cầm bản sao? — cache nào đang giữ dữ liệu của ai.
15.1 Mười lăm cái bẫy
| # | Bẫy | Triệu chứng bạn sẽ thấy | Vì sao nó xảy ra | Cách sửa |
|---|---|---|---|---|
| 1 | Tin phần tử đầu tiên của X-Forwarded-For |
Rate limit không chặn được ai; log ghi IP Việt Nam cho traffic từ botnet; ban IP xong tấn công vẫn tiếp diễn | X-Forwarded-For được nối thêm bên phải, nên phần tử trái nhất là do client tự khai. Client gửi sẵn một giá trị thì proxy nối tiếp vào sau nó, không xoá |
Ở biên: ghi đè hẳn (proxy_set_header X-Forwarded-For $remote_addr). Trong ứng dụng: duyệt ngược từ phải sang trái, bỏ các hop nằm trong CIDR mình tin. Xem §4 |
| 2 | Bật keepalive tới upstream nhưng quên proxy_http_version 1.1 |
CPU nginx và upstream cao bất thường; netstat đầy TIME_WAIT; latency p99 nhảy bậc thang |
nginx nói HTTP/1.0 với upstream theo mặc định, mà HTTP/1.0 không có keepalive — connection pool khai báo xong nhưng không được dùng | Trong location: proxy_http_version 1.1; và proxy_set_header Connection ""; để xoá header hop-by-hop client gửi lên |
| 3 | WriteTimeout giết SSE |
Kết nối SSE/long-poll bị cắt đúng chu kỳ (ví dụ cứ 30s một lần); trình duyệt tự nối lại vô tận; server tưởng có nhiều user | http.Server.WriteTimeout là deadline tuyệt đối tính cho cả response, không phải idle timeout. Response SSE không bao giờ kết thúc |
Gỡ deadline cho riêng request đó bằng http.NewResponseController(w).SetWriteDeadline(time.Time{}), giữ WriteTimeout cho phần còn lại. Phía nginx: proxy_buffering off + proxy_read_timeout đủ dài |
| 4 | Health check chỉ bắt tay TCP | LB báo "tất cả healthy" trong khi 100% request trả 500; deploy bản hỏng vẫn được nhận traffic | Tiến trình còn sống và còn listen là TCP handshake thành công — kể cả khi kết nối DB đã mất |
Health check ở tầng HTTP, gọi một endpoint thật sự kiểm tra phụ thuộc. Tách /healthz (còn sống) khỏi /readyz (sẵn sàng nhận traffic) |
| 5 | Retry POST không idempotent |
Người dùng bấm một lần, bài viết đăng hai lần; ví bị trừ tiền hai lần; không có lỗi nào trong log | Timeout không phân biệt được "upstream chưa nhận" với "upstream đã làm xong nhưng trả lời chậm". Retry ở trường hợp thứ hai là nhân đôi tác dụng phụ | Chỉ retry GET/HEAD/PUT/DELETE, hoặc POST có Idempotency-Key mà upstream thật sự khử trùng. Đừng thêm non_idempotent vào proxy_next_upstream |
| 6 | Bật proxy_protocol trên listener công khai |
Không có triệu chứng gì — cho tới lúc audit phát hiện IP trong log bị giả toàn bộ | PROXY protocol không có xác thực: khối byte mở đầu connection (một dòng text ở v1, một header nhị phân ở v2) khai luôn IP nguồn, và người nhận không có cách nào kiểm chứng lời khai đó. Nếu ai cũng kết nối thẳng tới listener đó được thì ai cũng khai được IP bất kỳ | Chỉ bật proxy_protocol trên listener mà chỉ LB truy cập tới (private subnet, security group đóng), kèm set_real_ip_from giới hạn đúng dải của LB |
| 7 | Cache trang có dữ liệu cá nhân | Người dùng A nhìn thấy tên và feed của người dùng B; lỗi biến mất khi bạn thử lại | Cache key mặc định của nginx là $scheme$proxy_host$request_uri — không có cookie trong đó. Hai người khác nhau, cùng URL, chung một entry |
Response riêng tư phải có Cache-Control: private, no-store. Không đặt proxy_ignore_headers Set-Cookie. Tách hẳn đường dẫn công khai và đường dẫn cần đăng nhập |
| 8 | Vary: Cookie trên tài nguyên dùng chung |
Hit ratio gần 0; hoá đơn CDN tăng; origin gánh đúng lượng traffic như khi chưa có CDN | Mỗi giá trị cookie khác nhau tạo một biến thể cache riêng. Mà cookie chứa session id thì gần như mỗi người một biến thể | Không sinh Vary: Cookie cho tài sản tĩnh và trang công khai. Với nội dung riêng tư, dùng Cache-Control: private thay vì trông cậy vào Vary |
| 9 | Thiếu X-Forwarded-Proto |
Vòng lặp redirect vô hạn; ERR_TOO_MANY_REDIRECTS; form gửi qua http:// bị trình duyệt chặn mixed content |
Proxy kết thúc TLS rồi nói HTTP với upstream. Upstream thấy r.TLS == nil, tưởng đang chạy plaintext, sinh redirect về http://, proxy lại đẩy về https:// |
Proxy set X-Forwarded-Proto $scheme. Ứng dụng đọc header đó (chỉ khi hop trước nằm trong danh sách tin cậy) để dựng URL tuyệt đối |
| 10 | Để MaxIdleConnsPerHost mặc định |
Proxy Go chậm hơn nginx rõ rệt dưới tải; TIME_WAIT chất đống ở phía proxy |
http.Transport mặc định giữ 2 idle connection cho mỗi host (hằng số http.DefaultMaxIdleConnsPerHost). Vượt qua đó là bắt tay TCP + TLS lại từ đầu cho từng request |
Đặt MaxIdleConnsPerHost xấp xỉ mức đồng thời thật tới upstream, và MaxIdleConns tổng tương ứng. Xem §10 |
| 11 | Không đặt ReadHeaderTimeout |
Số goroutine tăng đều rồi OOM; không có request nào trong access log giải thích việc đó | Kết nối mở ra, nhỏ giọt từng byte header, không bao giờ gửi hết. Mỗi kết nối giữ một goroutine. Đây là Slowloris | http.Server{ReadHeaderTimeout: 5 * time.Second} là mức tối thiểu cho mọi server Go phơi ra mạng, kể cả khi đã có proxy đứng trước |
| 12 | Open proxy | Máy chủ của bạn xuất hiện trong danh sách abuse; băng thông ra tăng vọt; bị nhà cung cấp cảnh cáo | Reverse proxy định tuyến theo Host header mà không có allowlist, hoặc forward proxy nội bộ bị phơi ra Internet | Khai báo server_name tường minh + một default_server trả 444. Forward proxy chỉ nghe trên interface nội bộ, có xác thực. Xem §13 |
| 13 | SSRF qua tính năng xem trước link | Response chứa nội dung nội bộ; log thấy request tới 169.254.169.254 hoặc dải 10.0.0.0/8 |
Người dùng dán URL, server đi lấy hộ. Server đứng bên trong vành đai bảo mật, nên nó tới được những nơi client không tới được | Chặn tại thời điểm connect bằng net.Dialer.Control (kiểm IP đã phân giải, chống DNS rebinding), giới hạn số redirect, chỉ cho http/https |
| 14 | Dựng service mesh khi chưa cần | Chi phí hạ tầng tăng gấp bội; mỗi sự cố phải debug thêm một lớp sidecar; không ai trong đội đọc được config | Mesh giải quyết bài toán nhiều service gọi nhau qua mạng. Dự án này là modular monolith — các module giao tiếp qua event bus, không qua mạng | Chưa tách microservice thì chưa cần mesh. Retry/timeout/mTLS ở quy mô này giải quyết bằng thư viện và một reverse proxy. Xem §9 |
| 15 | Deploy không drain | Mỗi lần deploy có một nhúm 502/504; số lỗi tỉ lệ thuận với traffic; "chỉ vài request thôi mà" | Tiến trình nhận SIGTERM và chết ngay, trong khi LB vẫn còn tin nó healthy cho tới lần health check tiếp theo |
Nhận SIGTERM → lật /readyz sang fail → ngủ đủ lâu để LB nhận ra → srv.Shutdown(ctx). Thời gian ân hạn khi tắt container phải dài hơn tổng thời gian đó |
Ba trong số đó — bẫy 1, 3 và 5 — chiếm phần lớn số sự cố thật, nên đáng được mổ kỹ hơn.
Bẫy 1 — vì sao $proxy_add_x_forwarded_for ở biên là sai
$proxy_add_x_forwarded_for = giá trị client gửi lên + , $remote_addr. Nó đúng cho một proxy ở giữa chuỗi, và sai cho proxy ngoài cùng.
Đây là kết quả đo thật, bằng đúng bài thực hành ở §15.3:
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
$ curl -H "X-Forwarded-For: 1.2.3.4" http://localhost:8080/whoami
{"x_forwarded_for":["1.2.3.4, 172.20.0.1"], ...} ❌ IP giả nằm ở vị trí đầu
# proxy_set_header X-Forwarded-For $remote_addr;
$ curl -H "X-Forwarded-For: 1.2.3.4" http://localhost:8080/whoami
{"x_forwarded_for":["172.20.0.1"], ...} ✅ giá trị giả bị bỏ
Chuyện tương đương trong Go — và ở đây thư viện chuẩn đứng về phía bạn. Trước khi gọi Rewrite, ReverseProxy tự xoá bốn header chuyển tiếp mà client gửi lên: Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto. Nên khi SetXForwarded() chạy, pr.Out không còn giá trị nào của client, và nó ghi vào đúng một phần tử: IP lấy từ pr.In.RemoteAddr.
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
// ReverseProxy đã xoá X-Forwarded-* của client khỏi pr.Out trước khi
// gọi hàm này, nên SetXForwarded ghi đúng một phần tử: IP thật của
// hop kề. Đây là hành vi ĐÚNG cho proxy ngoài cùng — không phải xoá gì thêm.
pr.SetXForwarded()
// Giữ Host gốc để upstream sinh URL tuyệt đối đúng tên miền.
pr.Out.Host = pr.In.Host
},
}
Đo bằng httptest, upstream in ra đúng cái nó nhận được khi client gửi X-Forwarded-For: 1.2.3.4:
Rewrite + SetXForwarded → [127.0.0.1] ✅ lời khai của client bị xoá
Director (không tự xử lý) → [1.2.3.4, 127.0.0.1] ❌ giá trị giả nằm ở đầu
SetXForwarded() có nối thêm — nhưng chỉ khi pr.Out đã mang sẵn X-Forwarded-For, tức là khi chính bạn chép nó từ request vào. Đó đúng là thứ cần làm khi proxy Go của bạn đứng giữa chuỗi, sau một hop mà bạn tin:
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
// Chỉ chép lại khi hop phía trước là proxy của CHÍNH TA. Chép vô điều
// kiện = tự tay dựng lại đúng lỗ hổng mà ReverseProxy vừa bịt hộ.
if ap, err := netip.ParseAddrPort(pr.In.RemoteAddr); err == nil &&
trusted.contains(ap.Addr().Unmap()) {
pr.Out.Header["X-Forwarded-For"] = pr.In.Header["X-Forwarded-For"]
}
pr.SetXForwarded()
},
Đọc code, đừng chỉ đọc doc comment
Doc comment của trường
Rewriteliệt kê nhóm header bị xoá là "Forwarded,X-Forwarded,X-Forwarded-Host,X-Forwarded-Proto" — nhưng phần cài đặt trongreverseproxy.gogọioutreq.Header.Del("X-Forwarded-For"), không phảiX-Forwarded. Doc thiếu một hậu tố; hành vi thật làX-Forwarded-Forcó bị xoá. Nếu bạn tin doc comment và thêm mộtDel"cho chắc", code vẫn chạy đúng nhưng bạn đang bảo vệ chống một mối nguy không tồn tại — và sẽ suy luận sai ở lần sửa tiếp theo.
Director (API cũ) mới là chỗ nguy hiểm, vì nó ngược lại hoàn toàn: X-Forwarded-* client gửi lên được giữ nguyên, và ReverseProxy nối IP hop kề vào sau chúng — nên giá trị giả nằm ở đầu chuỗi, y hệt $proxy_add_x_forwarded_for. Bạn vẫn can thiệp được (gọi req.Header.Del("X-Forwarded-For") trong Director, hoặc gán req.Header["X-Forwarded-For"] = nil để bỏ hẳn header), nhưng phải nhớ làm — mặc định là không an toàn. Cộng thêm một vấn đề thứ hai: header hop-by-hop bị gỡ sau khi Director trả về, mà client tự chỉ định header nào là hop-by-hop bằng cách liệt kê chúng trong Connection — nghĩa là client xoá được header do Director vừa thêm. Hai lỗ đó là lý do Rewrite ra đời.
Phía ứng dụng, quy tắc là duyệt ngược:
// ClientIP đi NGƯỢC chuỗi X-Forwarded-For, từ phải sang trái.
// Phần bên phải do các hop của CHÍNH TA ghi nên tin được; đi ngược tới khi
// gặp hop đầu tiên không nằm trong CIDR tin cậy — đó là client thật.
func (t *TrustedProxies) ClientIP(r *http.Request) (netip.Addr, bool) {
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return netip.Addr{}, false
}
cur := ap.Addr().Unmap()
// Hop kề ta không phải proxy của ta → mọi header đều là lời khai của
// người lạ. Bỏ hết, chỉ tin địa chỉ socket.
if !t.contains(cur) {
return cur, true
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, part := range strings.Split(v, ",") {
if s := strings.TrimSpace(part); s != "" {
hops = append(hops, s)
}
}
}
for i := len(hops) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(hops[i])
if err != nil {
// Giá trị rác trong chuỗi: dừng ngay thay vì đoán tiếp.
return cur, true
}
a = a.Unmap()
if !t.contains(a) {
return a, true
}
cur = a
}
return cur, true
}
Chú ý r.Header.Values (số nhiều) chứ không phải Get: client hoàn toàn có thể gửi nhiều dòng X-Forwarded-For, và chỉ đọc dòng đầu là bỏ sót.
Bẫy 3 — timeout phải lồng nhau, không được cắt nhau
Quy tắc duy nhất: lớp càng ở ngoài, timeout càng dài. Vi phạm quy tắc này thì lớp trong vẫn đang làm việc trong khi lớp ngoài đã bỏ đi — công đã tiêu, người dùng vẫn nhận lỗi, và log hai bên kể hai câu chuyện khác nhau.
client
└── LB idle timeout
└── nginx proxy_read_timeout
└── Go Server WriteTimeout
└── handler context.WithTimeout
└── query DB / gọi HTTP ra ngoài
Con số cụ thể tuỳ hệ thống; thứ tự thì không. Riêng SSE và WebSocket phải được tách ra một location khác với bộ timeout riêng, vì chúng phá vỡ mô hình "request ngắn" mà bậc thang trên giả định.
// Server.WriteTimeout là deadline TUYỆT ĐỐI cho cả response.
// Với SSE, response không bao giờ kết thúc — nên deadline phải được gỡ
// cho RIÊNG request này, thay vì tắt WriteTimeout cho toàn server.
rc := http.NewResponseController(w)
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
http.Error(w, "streaming không được hỗ trợ", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // báo nginx đừng gom buffer response này
Bẫy 5 — Go tự retry sau lưng bạn
http.Transport có cơ chế retry riêng: nếu request được gửi trên một connection đã tái sử dụng và connection đó chết trước khi nhận được byte response nào, Transport gửi lại — nhưng chỉ khi request "replayable". Điều kiện trong thư viện chuẩn (Request.isReplayable) là một phép và giữa hai vế, đừng đọc nhầm thành hoặc:
(body gửi lại được: Body == nil, Body == http.NoBody, hoặc GetBody != nil)
VÀ
(method ∈ {GET, HEAD, OPTIONS, TRACE} HOẶC có header Idempotency-Key / X-Idempotency-Key)
Hệ quả cần nhớ
Nếu bạn gắn
Idempotency-Keyvào mộtPOSTđể proxy tầng trên biết nó an toàn, bạn đồng thời bật luôn cơ chế retry củahttp.Transportcho request đó. Chỉ làm vậy khi upstream thật sự khử trùng theo khoá — nếu không, bạn vừa tự tay bật một nguồn nhân bản im lặng.
15.2 Checklist triển khai
Dùng như checklist chuyến bay: đọc to từng dòng, tự trả lời, không đánh dấu theo trí nhớ.
Trước khi bật proxy lên
- [ ] TLS kết thúc ở đúng một chỗ, và bạn biết chỗ đó là chỗ nào
- [ ] Chỉ bật TLS 1.2 trở lên (ưu tiên 1.3); tắt cipher lỗi thời
- [ ] Chứng chỉ có đủ chuỗi trung gian, và có lịch gia hạn tự động
- [ ]
server_namekhai báo tường minh cho từng tên miền phục vụ - [ ] Có
default_serverbắt mọi Host lạ và trả về444(đóng connection, không trả lời) - [ ] HTTP → HTTPS redirect, và HSTS chỉ bật khi chắc chắn không quay đầu
- [ ] Giới hạn kích thước body (
client_max_body_size) khớp với giới hạn của ứng dụng - [ ] Bốn timeout đã đặt tường minh: connect, send, read, và idle của client
- [ ] Bậc thang timeout lồng nhau đúng thứ tự (xem sơ đồ ở §15.1)
- [ ]
ReadHeaderTimeoutđã đặt ở tầng Go, không phụ thuộc vào việc proxy che chắn
Danh tính client
- [ ] Có biến
TRUSTED_PROXY_CIDRS— danh sách CIDR, không phải cờ bật/tắt kiểuTRUST_PROXY=true - [ ] Proxy ngoài cùng ghi đè
X-Forwarded-Forbằng$remote_addr, không nối thêm - [ ]
X-Forwarded-ProtovàX-Forwarded-Hostđược set ở biên - [ ] Ứng dụng duyệt
X-Forwarded-Fortừ phải sang trái, dừng ở hop đầu tiên không tin cậy - [ ] Nếu dùng module
realipcủa nginx:set_real_ip_fromliệt kê đúng dải LB, tuyệt đối không0.0.0.0/0 - [ ] Rate limit và audit log dùng IP đã tính, không dùng
RemoteAddrthô - [ ] Đã thử giả mạo bằng
curlvà xác nhận giá trị giả bị bỏ
# Gửi XFF giả từ ngoài vào. Nếu ứng dụng vẫn thấy IP thật của bạn → đúng.
curl -s -H 'X-Forwarded-For: 1.2.3.4, 5.6.7.8' https://example.com/whoami
# Thử luôn biến thể chữ hoa/thường và header trùng lặp — một số lớp
# xử lý gộp chúng lại, và chỗ gộp đó là chỗ hay có lỗ hổng.
curl -s -H 'x-forwarded-for: 1.2.3.4' -H 'X-FORWARDED-FOR: 9.9.9.9' \
https://example.com/whoami
Độ bền
- [ ]
/healthz(tiến trình sống) tách khỏi/readyz(phụ thuộc sẵn sàng) - [ ] Health check của LB là HTTP, không chỉ TCP, và không đi qua middleware xác thực
- [ ]
/readyzkhông gọi DB ở mỗi lần check nếu tần suất check cao — cache kết quả vài giây - [ ] Quy trình drain:
SIGTERM→/readyzfail → chờ ít nhất 2 chu kỳ health check →Shutdown - [ ] Thời gian ân hạn khi tắt container dài hơn drain delay cộng shutdown timeout
- [ ] Retry chỉ bật cho method idempotent;
POSTkhông nằm trong danh sách - [ ] Retry có giới hạn số lần và có ngân sách tổng, để không khuếch đại sự cố
- [ ] Connection pool tới upstream đã chỉnh (
keepaliveở nginx,MaxIdleConnsPerHostở Go) - [ ] Có một hành vi xác định khi tất cả upstream đều down (503 kèm
Retry-After, không phải treo)
// Vòng đời rút tải. Thứ tự ba bước quan trọng hơn mọi con số timeout.
ready.Store(false) // 1. LB thấy NOT READY ở lần check kế tiếp
time.Sleep(drainDelay) // 2. đợi LB thật sự gạt instance này ra khỏi pool
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { // 3. để request đang chạy hoàn tất
return err
}
Bỏ bước 2 là lỗi phổ biến nhất: Shutdown đóng listener ngay lập tức, trong khi LB vẫn còn nghĩ instance này khoẻ cho tới lần probe sau. Khoảng trống đó chính là nhúm 502 mỗi lần deploy.
Bảo mật
- [ ] Không phải open proxy: Host lạ bị từ chối, forward proxy không nghe trên interface công khai
- [ ]
proxy_protocolchỉ bật trên listener mà chỉ LB truy cập được - [ ] Mọi tính năng "server đi lấy URL hộ người dùng" đều qua dialer có SSRF guard
- [ ] Rate limit theo IP đã tính, có
bursthợp lý để không giết traffic thật - [ ] Rate limit riêng, chặt hơn, cho endpoint đăng nhập và đăng ký
- [ ] Log không chứa
Authorization,Cookie,Set-Cookiehay token trong query string - [ ] Header lộ thông tin đã tắt (
server_tokens off, không trảX-Powered-By) - [ ] Header nội bộ (ví dụ
X-Internal-User) bị xoá ở biên trước khi đi vào trong - [ ] Body lỗi từ upstream không rò stack trace ra ngoài
# Log không được chứa bí mật. $request chứa nguyên query string —
# nếu hệ thống từng lỡ nhận token qua query, dùng $uri để cắt phần đó đi.
log_format edge '$remote_addr "$request_method $uri" $status '
'ut=$upstream_response_time rt=$request_time rid=$request_id';
server_tokens off; # đừng khoe phiên bản nginx
# Xoá header nội bộ do client cố tình gửi lên. Chuỗi rỗng = không gửi tiếp.
proxy_set_header X-Internal-User "";
SSRF guard phải chặn ở thời điểm connect, không phải lúc phân tích URL:
// Ba dải mà IsGlobalUnicast lẫn IsPrivate đều KHÔNG bắt — trên cloud đây
// đúng là chỗ mạng nội bộ của nhà cung cấp hay nằm.
var denyPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT (RFC 6598)
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("198.18.0.0/15"), // benchmarking (RFC 2544)
}
dialer := &net.Dialer{
Timeout: 5 * time.Second,
// Control chạy SAU khi phân giải DNS, TRƯỚC khi connect, và nhận đúng
// địa chỉ kernel sắp mở socket tới — không còn khoảng trống nào giữa lúc
// kiểm và lúc connect để DNS rebinding chen vào.
Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
// Chuẩn hoá ::ffff:10.0.0.1 về 10.0.0.1 ngay từ đầu: với
// netip.Prefix.Contains, 4-in-6 và IPv4 là hai họ địa chỉ khác nhau,
// không Unmap thì mọi prefix IPv4 bên dưới trượt hết.
ip = ip.Unmap()
// IsGlobalUnicast loại loopback, link-local (gồm 169.254.169.254 của
// metadata service), multicast và unspecified — nhưng KHÔNG loại dải
// riêng: RFC 1918 và ULA fc00::/7 vẫn là global unicast. Hai hàm bổ
// sung nhau, không thay thế nhau.
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return fmt.Errorf("từ chối địa chỉ nội bộ %s", ip)
}
for _, p := range denyPrefixes {
if p.Contains(ip) {
return fmt.Errorf("từ chối dải %s (%s)", p, ip)
}
}
return nil
},
}
Đặt guard ở Control còn được thêm một thứ miễn phí: nó chạy cho mọi lần dial của http.Client đó, kể cả các hop redirect. Kiểm URL một lần rồi tin cả chuỗi redirect là cách thủng phổ biến thứ hai, sau việc kiểm tên miền thay vì kiểm IP.
Quan sát
- [ ] Có request id sinh ở biên, truyền xuyên suốt, và xuất hiện trong cả log proxy lẫn log ứng dụng
- [ ] Nếu client đã gửi request id hợp lệ thì dùng lại, không sinh mới — sinh mới là cắt đứt chuỗi trace tầng trên đã dựng
- [ ] Access log của proxy có
upstream_response_timevàrequest_time— hiệu số của chúng chính là thời gian đọc/ghi với client - [ ] Log ghi lại upstream nào đã phục vụ request, để quy trách nhiệm đúng instance
- [ ] Metric 5xx tách theo upstream, không gộp thành một con số chung
- [ ] Phân biệt được 502 (upstream từ chối/đứt) với 504 (upstream chậm) trên dashboard
- [ ] Có metric số connection đang mở và tỉ lệ tái dùng connection tới upstream
- [ ] Trace context (
traceparent) được chuyển tiếp, không bị proxy nuốt
All rights reserved