0

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 header Early-Data: 1, origin trả 425 Too Early nế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. somaxconntrầnlisten(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 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_sent$request_time luô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?

traceparent không chỉ mang trace-id, nó còn mang parent-id — định danh của span cha. Nếu nginx bịa một parent-id cố đị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ó contextlog.InfoContext(ctx, ...), log.ErrorContext(ctx, ...). Gọi log.Info(...) thì slog truyền context.Background() vào Handle, 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_FILE không có tác dụng trên Windows.

Trên Linux/BSD, crypto/x509 của Go đọc SSL_CERT_FILESSL_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:

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 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"-" 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 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_headersproxy_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:

  1. Đ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.
  2. 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 -s trên máy sinh tải, không chỉ trên máy đích.
  3. Đo trên loopback rồi kết luận cho production. 127.0.0.1 khô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ễ.
  4. 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ề:

  1. Ai được phép nói câu này? — header nào tin được, tin từ hop nào.
  2. Ai bỏ cuộc trước? — timeout của lớp nào ngắn hơn lớp nào.
  3. 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; 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 POSTIdempotency-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_urikhô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 rasrv.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() 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 Rewrite liệt kê nhóm header bị xoá là "Forwarded, X-Forwarded, X-Forwarded-Host, X-Forwarded-Proto" — nhưng phần cài đặt trong reverseproxy.go gọi outreq.Header.Del("X-Forwarded-For"), không phải X-Forwarded. Doc thiếu một hậu tố; hành vi thật là X-Forwarded-For bị xoá. Nếu bạn tin doc comment và thêm một Del "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 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-Key vào một POST để proxy tầng trên biết nó an toàn, bạn đồng thời bật luôn cơ chế retry của http.Transport cho 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_name khai báo tường minh cho từng tên miền phục vụ
  • [ ] Có default_server bắ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ểu TRUST_PROXY=true
  • [ ] Proxy ngoài cùng ghi đè X-Forwarded-For bằng $remote_addr, không nối thêm
  • [ ] X-Forwarded-ProtoX-Forwarded-Host được set ở biên
  • [ ] Ứng dụng duyệt X-Forwarded-For từ phải sang trái, dừng ở hop đầu tiên không tin cậy
  • [ ] Nếu dùng module realip của nginx: set_real_ip_from liệt kê đúng dải LB, tuyệt đối không 0.0.0.0/0
  • [ ] Rate limit và audit log dùng IP đã tính, không dùng RemoteAddr thô
  • [ ] Đã thử giả mạo bằng curl và 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
  • [ ] /readyz khô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/readyz fail → 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; POST không nằm trong danh sách
  • [ ] Retry có giới hạn số lần 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_protocol chỉ 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ó burst hợ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-Cookie hay 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_time 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí