Proxy Network — Mạng Proxy toàn tập Phần 7
send-proxy-v2 — khi upstream cần IP thật mà không có header để đọc. Thêm send-proxy-v2 vào dòng server (server api1 10.0.1.11:8080 check send-proxy-v2 inter 3s rise 2 fall 3) thì HAProxy chèn một khối metadata ở đầu kết nối TCP, trước byte đầu tiên của HTTP. Ưu điểm so với X-Forwarded-For: nó không phải header HTTP nên client không giả được, và dùng được cả với giao thức không phải HTTP.
Cái giá: upstream bắt buộc phải hiểu nó. Bật send-proxy-v2 mà cmd/api không parse thì Go đọc khối metadata như thể đó là dòng request và trả 400 cho mọi kết nối — dịch vụ chết sạch ngay giây bạn reload. net/http không hỗ trợ PROXY protocol; phải bọc net.Listener bằng thư viện bên thứ ba (github.com/pires/go-proxyproto cung cấp proxyproto.Listener) rồi truyền listener đã bọc cho srv.Serve. Sau khi bọc, conn.RemoteAddr() trả về IP client thật thay vì IP của HAProxy. Nếu bạn chỉ cần IP ở tầng HTTP thì X-Forwarded-For đơn giản hơn nhiều và không kèm rủi ro "chết sạch" ở trên.
Runtime API — thứ nginx OSS không có. Rút một node ra để deploy mà không rơi request nào chỉ là một dòng: echo "set server be_api/api1 state drain" | socat stdio /var/run/haproxy.sock (ngừng nhận kết nối mới, phục vụ nốt kết nối đang có), rồi state ready để trả lại đội hình. show table fe_public in ra bảng stick-table đang giữ IP nào — cực hữu ích lúc đang bị bắn. Với nginx open source, việc tương đương đòi hỏi sửa file rồi nginx -s reload.
11.4 Envoy
Envoy không nhắm vào người dùng cuối mà nhắm vào control plane. Toàn bộ cấu hình là proto schema; file YAML dưới đây thực chất chỉ là bản "tĩnh" của những gì bình thường được đẩy xuống động qua xDS.
# envoy.yaml — bootstrap tối thiểu nhưng đầy đủ cho cmd/api (Envoy API v3)
admin:
address: { socket_address: { address: 127.0.0.1, port_value: 9901 } } # KHÔNG mở ra 0.0.0.0
static_resources:
listeners:
- name: listener_https
address: { socket_address: { address: 0.0.0.0, port_value: 443 } }
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
alpn_protocols: ["h2", "http/1.1"]
tls_certificates:
- { certificate_chain: { filename: /etc/envoy/certs/fullchain.pem },
private_key: { filename: /etc/envoy/certs/privkey.pem } }
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO
# use_remote_address: lấy IP của kết nối làm nguồn sự thật rồi tự nối vào
# X-Forwarded-For. xff_num_trusted_hops = 0 nghĩa là KHÔNG có proxy nào
# trước Envoy — đặt sai là mở đường cho client giả IP, y hệt bẫy §11.2.4.
use_remote_address: true
xff_num_trusted_hops: 0
generate_request_id: true # giữ id nếu client đã gửi
upgrade_configs: [{ upgrade_type: websocket }] # thiếu là Envoy từ chối Upgrade
access_log:
- name: envoy.access_loggers.file
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
path: /dev/stdout
log_format:
# RESPONSE_FLAGS nói RÕ vì sao request hỏng — xem phần dưới.
json_format: { request_id: "%REQ(X-REQUEST-ID)%", path: "%REQ(:PATH)%", status: "%RESPONSE_CODE%", flags: "%RESPONSE_FLAGS%" }
route_config:
name: local_route
virtual_hosts:
- name: community
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: cluster_api
timeout: 60s
retry_policy: { retry_on: "5xx,reset,connect-failure", num_retries: 2, per_try_timeout: 10s }
http_filters:
# Nén bật bằng compressor filter, đặt TRƯỚC router:
# envoy.filters.http.compressor với compressor_library `gzip`
# (typed_config: ...compression.gzip.compressor.v3.Gzip).
- name: envoy.filters.http.router # router LUÔN là filter cuối
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: cluster_api
# STRICT_DNS: Envoy phân giải DNS ĐỊNH KỲ và cập nhật endpoint — container đổi IP thì
# Envoy tự theo, trong khi nginx OSS phân giải một lần lúc khởi động rồi giữ mãi.
type: STRICT_DNS
connect_timeout: 5s
lb_policy: LEAST_REQUEST
load_assignment:
cluster_name: cluster_api
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: api, port_value: 8080 } } }
health_checks:
- timeout: 2s
interval: 3s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /healthz
expected_statuses: [{ start: 200, end: 201 }] # nửa mở: chỉ khớp đúng 200
# outlier_detection = health check THỤ ĐỘNG. Check chủ động hỏi /healthz — endpoint
# đó thường trả 200 kể cả khi handler nghiệp vụ đang hỏng. Outlier detection nhìn
# traffic THẬT: node trả 5xx liên tiếp thì bị đá khỏi pool một lúc rồi cho thử lại.
# max_ejection_percent 50: không đá quá nửa pool — tập tễnh còn hơn mất sạch.
outlier_detection: { consecutive_5xx: 5, interval: 10s, base_ejection_time: 30s, max_ejection_percent: 50 }
Envoy mạnh ở đâu. Ba thứ, cả ba đều khó bắt chước. xDS: control plane đẩy cấu hình xuống qua gRPC, áp dụng ngay, không reload, không rơi kết nối — với hàng trăm service thay đổi liên tục thì đây là điều kiện cần, không phải tiện nghi. Observability là mặc định: mỗi cluster/listener/filter đều xuất counter và histogram sẵn, và RESPONSE_FLAGS trong access log nói thẳng vì sao request hỏng (upstream timeout, no healthy upstream, circuit breaker mở) — thứ mà với nginx bạn phải suy luận từ mã lỗi. Chuẩn hoá: Envoy là data plane của Istio, Consul và nhiều API gateway, học một lần hiểu cả một họ sản phẩm — xem §9.
Envoy nặng ở đâu. File trên là bản tối thiểu cho một upstream, và đã dài hơn cấu hình HAProxy phục vụ hai node. Cấu hình là proto lồng nhau với @type URL dài dòng; sai một mức thụt lề là Envoy từ chối khởi động với thông báo tham chiếu tới tên field trong proto — không phải tới dòng trong file của bạn. Envoy cũng ăn RAM đáng kể hơn nginx/HAProxy.
Kết luận thẳng cho dự án này. Với một
cmd/api, mộtcmd/worker, mộtcmd/relaytrên một hai máy, Envoy là lựa chọn sai: bạn trả toàn bộ học phí mà không dùng đến thứ duy nhất biện minh cho nó — cấu hình động ở quy mô lớn. Chỉ nhìn lại Envoy khi đã có control plane, hoặc khi đi vào Istio và Envoy tới cùng gói.
11.5 Caddy
# deployments/Caddyfile
{
email admin@example.com
servers {
# Chỉ tin X-Forwarded-For từ các dải này. Vai trò y hệt set_real_ip_from của
# nginx (§11.2.4), và cùng một cái bẫy: đừng bao giờ để 0.0.0.0/0.
trusted_proxies static 10.0.0.0/8 172.16.0.0/12
}
}
community.example.com {
encode zstd gzip
log {
output file /var/log/caddy/access.log
format json
}
# SSE: flush_interval -1 = đẩy ngay từng byte, không gom buffer.
# Tương đương proxy_buffering off của nginx.
@sse path /events*
reverse_proxy @sse api:8080 {
flush_interval -1
}
reverse_proxy api:8080 {
lb_policy least_conn
health_uri /healthz
health_interval 3s
health_timeout 2s
health_status 200
transport http {
keepalive 60s # < IdleTimeout của cmd/api, xem §11.2.2
keepalive_idle_conns 32
dial_timeout 5s
read_timeout 60s
}
}
}
Đó là toàn bộ. Không block upstream, không proxy_http_version, không map cho WebSocket, không một dòng nào về chứng chỉ. Vì sao ngắn được đến vậy — và vì sao đó là lựa chọn tốt cho dự án một người: TLS tự động và mặc định bật, Caddy tự xin chứng chỉ Let's Encrypt lúc khởi động rồi tự gia hạn (không cron, không certbot, không cái ngày chứng chỉ hết hạn lúc 2 giờ sáng; điều kiện là tên miền trỏ về máy này và cổng 80/443 ra được Internet). Mặc định của nó là mặc định đúng: reverse_proxy tự dùng HTTP/1.1, tự giữ keepalive, tự đặt X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host, tự cho WebSocket đi qua — hai cái bẫy lớn nhất của nginx ở §11.2.2 và §11.2.3 đơn giản là không tồn tại ở đây. Và vì nó là một binary tĩnh viết bằng Go, encode zstd gzip chạy ngay không cần biên dịch module như ngx_brotli, cùng hệ sinh thái với cmd/api, đổi cấu hình lúc chạy qua Admin API JSON ở localhost:2019.
Cái giá, nói sòng phẳng: Caddy chạy trên Go runtime nên có GC, và ở tải rất cao thì nginx/HAProxy (C, không GC) vẫn nhỉnh hơn ở đuôi độ trễ — nhưng với lưu lượng của một nền tảng cộng đồng đang lớn dần, khác biệt đó bị chính thời gian truy vấn Postgres nuốt mất từ lâu. Về request id: Caddy không có biến dựng sẵn tương đương $request_id được ghi mặc định thành một trường log riêng, nhưng middleware.RequestID trong internal/platform/httpx/middleware đã sinh id và ghi lại vào response header X-Request-ID, mà access log JSON của Caddy ghi cả header của response — vẫn nối vết được, chỉ khác ở chỗ id do app sinh chứ không phải do biên sinh.
11.6 Traefik — nhận diện service qua Docker label
Traefik đảo ngược mô hình cấu hình: thay vì bạn khai upstream trong file của proxy, service tự khai báo mình bằng label, còn Traefik theo dõi Docker socket để phát hiện. Phần tĩnh chỉ khai những thứ có trước mọi service:
# deployments/traefik.yml — cấu hình TĨNH
entryPoints:
web: { address: ":80" }
websecure:
address: ":443"
forwardedHeaders:
trustedIPs: ["10.0.0.0/8"] # lại là bài toán tin XFF của §11.2.4
providers:
docker:
exposedByDefault: false # ✅ opt-in: container phải TỰ xin mới lộ ra
network: community_edge
certificatesResolvers:
le:
acme:
email: admin@example.com
storage: /letsencrypt/acme.json
httpChallenge: { entryPoint: web }
accessLog:
format: json
fields:
headers:
names: { X-Request-Id: keep } # giữ id do cmd/api sinh, để nối log
Traefik core không có middleware sinh request id, nên id vẫn do middleware.RequestID của cmd/api sinh — khối accessLog ở trên chỉ có nhiệm vụ giữ header đó lại trong log của biên.
Phần động nằm ngay trên service api trong compose:
api:
image: community/api:latest
networks: [edge, backend]
labels:
- "traefik.enable=true"
- "traefik.docker.network=community_edge"
- "traefik.http.routers.api.rule=Host(`community.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=le"
# Cổng NỘI BỘ của container, không phải cổng publish. Chính vì Traefik nói chuyện
# trong mạng Docker nên api không cần publish gì ra host — xem §11.9.
- "traefik.http.services.api.loadbalancer.server.port=8080"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=5s"
- "traefik.http.middlewares.api-compress.compress=true"
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
- "traefik.http.routers.api.middlewares=api-compress,api-ratelimit"
docker compose up -d --scale api=3 và Traefik có ngay ba backend — không sửa file cấu hình nào, không reload.
exposedByDefault: falsekhông phải tuỳ chọn thẩm mỹ. Đểtruethì mọi container trong network được Traefik tự động lộ ra Internet với hostname sinh tự động — nghĩa là ngày bạn thêmkafka-uihay mộtpgadminđể debug, nó ra Internet cùng lúc, không mật khẩu, không ai biết. Đổi lại, Traefik cần đọc Docker socket: mount/var/run/docker.sockluôn để chế độ chỉ đọc, và hiểu rằng quyền đọc socket đó gần tương đương quyền root trên host — chi tiết ở §13.
11.7 Bảng so sánh
| Tiêu chí | Nginx | HAProxy | Envoy | Caddy | Traefik |
|---|---|---|---|---|---|
| Học phí | Trung bình — cú pháp riêng, nhiều bẫy mặc định | Trung bình–cao, nhưng nhất quán | Cao nhất — proto lồng nhau, phải hiểu filter chain | Thấp nhất — mặc định gần như đã đúng | Thấp–trung; phải hiểu tách tĩnh/động |
| TLS tự động | Không — cần certbot + cron | Không — certbot/lego (bản rất mới có hướng bổ sung ACME, kiểm chứng theo phiên bản bạn dùng) | Không — cần SDS / cert-manager | Có, mặc định | Có (ACME tích hợp) |
| Đổi cấu hình không reload | Không (bản OSS) — sửa file rồi reload (graceful) |
Có — Runtime API qua stats socket | Có — xDS, lý do tồn tại của nó | Có — Admin API JSON :2019 |
Có — provider theo dõi Docker/K8s |
| Observability | Nghèo sẵn có — stub_status sơ sài, phải dựng exporter + parse log |
Tốt — trang stats, endpoint Prometheus tích hợp | Tốt nhất — stats theo cluster/listener, RESPONSE_FLAGS chỉ rõ nguyên nhân |
Khá — log JSON, endpoint metrics | Tốt — dashboard + Prometheus |
| Hiệu năng | Rất cao (C, không GC) | Rất cao (C, không GC); mạnh nhất ở vai trò LB thuần | Cao, nhưng tốn RAM hơn hẳn | Tốt (Go, có GC) | Tốt (Go, có GC) |
| Cộng đồng | Lớn nhất — mọi vấn đề đều đã có người gặp | Tài liệu chính thức xuất sắc, cộng đồng nhỏ hơn | Tài liệu đầy đủ nhưng khó vào; nhiều kiến thức nằm trong hệ Istio | Tài liệu tốt, cộng đồng đang lớn | Rất nhiều ví dụ Docker |
| Hợp với ai | Đội đã quen nginx; cần phục vụ cả static + proxy | Hệ thống cần LB nghiêm túc, thao tác lúc chạy, nhiều node | Có platform team và control plane; đi cùng service mesh | Dự án một người / đội nhỏ — đúng trường hợp tài liệu này | Docker/K8s nhiều service lên xuống liên tục |
Về cột hiệu năng, nói cho đúng: trên phần cứng hiện đại cả năm công cụ xử lý được lưu lượng HTTP thường cùng một bậc độ lớn, và với ứng dụng chạm Postgres như cmd/api thì chi phí proxy bị thời gian truy vấn che gần hết. Khác biệt chỉ lộ ra ở tải rất cao, ở đuôi p99, và ở số kết nối đồng thời — không phải ở throughput trung bình mà các biểu đồ benchmark hay khoe. Đừng chọn proxy vì benchmark; chọn vì mô hình vận hành.
11.8 Đối sánh: Squid làm forward/egress proxy cho cmd/worker
Năm công cụ trên đều là reverse proxy — đứng trước server, bảo vệ server. Squid nằm ở phía đối diện: forward proxy, đứng trước client, kiểm soát client được đi ra đâu (phân loại đầy đủ ở §2). Tình huống rất cụ thể với dự án này: cmd/worker xử lý event và có lúc phải gọi ra ngoài — lấy Open Graph metadata của link người dùng dán vào bài, gửi webhook, gọi API bên thứ ba. Mỗi lần gọi ra là một lần mở rộng bề mặt tấn công: nếu URL đến từ nội dung người dùng, kẻ tấn công có thể trỏ nó vào 169.254.169.254 hoặc vào chính postgres:5432 trong mạng nội bộ (SSRF).
# /etc/squid/squid.conf
http_port 3128
acl localnet src 10.0.0.0/8 172.16.0.0/12
acl SSL_ports port 443
acl CONNECT method CONNECT
acl allowed_dst dstdomain "/etc/squid/allowed-domains.txt"
# Thứ tự CÓ NGHĨA: Squid duyệt từ trên xuống, dừng ở luật khớp đầu tiên.
http_access deny !localnet # ngoài mạng nội bộ đừng hòng dùng proxy này
http_access deny CONNECT !SSL_ports # chỉ CONNECT tới 443, chặn tunnel cổng khác
http_access allow localnet allowed_dst
http_access deny all # ✅ luật cuối LUÔN là deny, không phải allow
access_log stdio:/var/log/squid/access.log
# /etc/squid/allowed-domains.txt — dấu chấm đầu khớp cả domain lẫn mọi subdomain
.githubusercontent.com
.cloudinary.com
api.sendgrid.com
Squid nhìn thấy gì với HTTPS? Với
https://, client gửiCONNECT api.sendgrid.com:443rồi mở tunnel TLS xuyên qua Squid. Squid chỉ đọc được host và port trong dòng CONNECT — không thấy đường dẫn, không thấy header, không thấy body. Nghĩa là allowlist ở đây có độ mịn cấp domain, không phải cấp endpoint. Muốn mịn hơn phải bật SSL bump — tức Squid tự ký chứng chỉ và giải mã traffic, một quyết định lớn: bạn vừa dựng một MITM hợp pháp trong hệ thống của mình, kèm một kho khoá riêng phải bảo vệ. Với dự án này, cấp domain là đủ để chặn đúng lớp SSRF nguy hiểm nhất.
Phía Go, ghim cứng proxy thay vì tin biến môi trường:
// internal/platform/egress/client.go
package egress
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
)
// NewClient tạo HTTP client bắt buộc đi qua egress proxy.
func NewClient(proxyURL string) (*http.Client, error) {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("egress: proxy URL không hợp lệ: %w", err)
}
tr := &http.Transport{
// Ghim cứng bằng http.ProxyURL thay vì http.ProxyFromEnvironment.
// ProxyFromEnvironment đọc HTTP_PROXY/HTTPS_PROXY — biến đó rất dễ thiếu khi
// worker chạy dưới systemd hoặc trong CI, và khi thiếu thì request LỌT THẲNG ra
// Internet, bỏ qua toàn bộ allowlist. Sai lầm này im lặng: mọi thứ vẫn chạy
// đúng, chỉ có chính sách bảo mật là biến mất.
Proxy: http.ProxyURL(u),
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
MaxIdleConnsPerHost: 8,
TLSHandshakeTimeout: 5 * time.Second,
}
// Timeout tổng là hàng rào cuối: kể cả khi mọi timeout con bị đặt sai, một request
// không thể treo worker quá 30 giây.
return &http.Client{Transport: tr, Timeout: 30 * time.Second}, nil
}
11.9 Bổ sung vào deployments/ — đặt proxy trước api, và gỡ cổng của api
Đây là thay đổi có giá trị bảo mật cao nhất trong cả mục, và nó chủ yếu là việc xoá vài dòng.
# deployments/docker-compose.proxy.yml
# Chạy kèm file gốc:
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
name: community
services:
caddy:
image: caddy:2-alpine
container_name: community-caddy
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # ← chứng chỉ + khoá ACME. Mất volume này là mỗi lần
- caddy_config:/config # restart lại xin chứng chỉ mới, rất nhanh chạm giới
# hạn tần suất của CA.
networks: [edge, backend]
depends_on:
api: { condition: service_healthy }
restart: unless-stopped
api:
build: { context: .., dockerfile: deployments/Dockerfile }
container_name: community-api
# ❌ KHÔNG có `ports:` ở đây. Đó là toàn bộ trọng tâm của mục này.
environment:
HTTP_PORT: 8080
DATABASE_URL: postgres://app:secret@postgres:5432/community?sslmode=disable
KAFKA_BROKERS: kafka:9092
healthcheck:
# Ảnh scratch/distroless KHÔNG có wget hay curl — lệnh này sẽ luôn thất bại và
# Caddy không bao giờ khởi động (depends_on: service_healthy). Với ảnh tối giản,
# dùng một binary Go nhỏ tự viết làm healthcheck.
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks: [backend]
depends_on:
postgres: { condition: service_healthy }
kafka: { condition: service_healthy }
restart: unless-stopped
networks:
edge: {} # mạng duy nhất chạm được Internet; chỉ caddy ở đây
# internal: true CẮT HẲN đường ra Internet của mọi container trong mạng backend.
# postgres, kafka, api không cần gọi ra ngoài — đúng thứ ta muốn. Container nào thực
# sự cần gọi ra (cmd/worker) thì đi qua Squid ở §11.8 — Squid nối cả hai mạng.
backend: { internal: true }
volumes: { caddy_data: {}, caddy_config: {} }
Vì sao "không publish cổng app" là một biện pháp bảo mật thật
Trước thay đổi, api có ports: ["8080:8080"]. Câu chuyện thường được kể là "cổng 8080 thì ai vào, firewall chặn rồi". Cả hai vế đều lung lay.
Docker ghi luật vào iptables, thường nằm dưới firewall của bạn. Trên Linux, docker compose mở cổng bằng cách chèn luật DNAT vào chuỗi riêng của Docker; luật đó có thể được xử lý trước các luật ufw/firewalld bạn cấu hình ở tầng trên. Kết quả: ufw status báo cổng đóng, mà curl http://<ip-public>:8080/healthz từ ngoài vẫn ra 200. Người vận hành tin vào output của ufw — và tin nhầm. Bỏ cổng đi thì không còn gì để cấu hình sai: đây là khác biệt giữa phòng thủ bằng cấu hình và phòng thủ bằng kiến trúc. Firewall là một luật ai đó phải viết đúng rồi giữ đúng qua mọi lần đổi hạ tầng; không có ports: nghĩa là kernel không hề có socket nào lắng nghe trên interface public cho cmd/api — không có gì để bypass, không có gì để quên.
Nó cũng ép mọi request đi qua đúng một cửa. Còn cổng 8080 mở là còn một đường vòng: bỏ qua TLS, bỏ qua rate limit, bỏ qua set_real_ip_from, bỏ qua access log của biên. Mọi biện pháp ở §11.2–§11.6 chỉ có giá trị khi không tồn tại đường khác vào — một cổng phụ biến toàn bộ cấu hình biên thành đồ trang trí. Và nó thu nhỏ bề mặt bị quét: bot quét cổng 8080/3000/5000 trên toàn IPv4 liên tục, nên /healthz, /debug/pprof, trang metrics — hoàn toàn hợp lý khi chỉ nội mạng thấy — trở thành thông tin miễn phí cho người lạ khi lộ ra ngoài.
expose:không thay thế được việc này, và cũng không bảo vệ gì cả.expose: ["8080"]trong Compose chỉ là tài liệu: nó không mở cổng ra host, nhưng cũng không đóng gì lại — mọi container cùng mạng đều gọi đượcapi:8080bất kể cóexposehay không. Ranh giới thật nằm ở chỗ container nào được nối vào mạng nào; đó là lý doapichỉ cóbackend, còncaddycó cảedgevàbackend.
Áp dụng nguyên tắc đó cho phần còn lại của docker-compose.yml hiện có: kafka-ui đang publish 8080:8080 và postgres đang publish 5432:5432. Trên máy dev thì tiện; trên máy thật đó là một giao diện quản trị Kafka không mật khẩu và một Postgres nghe trên Internet. Đổi sang 127.0.0.1:8080:8080 và 127.0.0.1:5432:5432 để chỉ loopback của host vào được, rồi truy cập bằng SSH tunnel. Kiến trúc tổng thể nằm ở §12; chỉ số cần theo dõi sau khi dựng xong nằm ở §14.
cmd/api cũng nên tự thu hẹp
Khi proxy chạy cùng máy (không trong Docker), việc bỏ ports: không áp dụng được. Hàng rào tương đương nằm trong code:
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
if err := run(http.DefaultServeMux); err != nil {
slog.Error("server dừng bất thường", "err", err)
os.Exit(1)
}
}
func run(h http.Handler) error {
// Nghe trên 127.0.0.1, KHÔNG phải 0.0.0.0 hay ":8080". Chuỗi ":8080" bind lên MỌI
// interface, kể cả IP public — chỉ cần một lần firewall cấu hình sai là app lộ
// thẳng ra Internet. Với 127.0.0.1, kernel từ chối mọi kết nối không đến từ chính
// máy này, bất kể firewall thế nào; reverse proxy cùng máy vẫn kết nối bình thường.
const addr = "127.0.0.1:8080"
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 5 * time.Second, // chống slowloris
WriteTimeout: 60 * time.Second,
// PHẢI lớn hơn keepalive_timeout của proxy (60s ở §11.2.1). Ngược lại sẽ có
// race: Go đóng kết nối rảnh đúng lúc proxy ghi request lên đó → proxy báo 502,
// còn app không thấy request nào cả.
IdleTimeout: 75 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("không mở được listener %s: %w", addr, err)
}
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
}
// Shutdown chờ request đang dở chạy xong. Không có nó, mỗi lần deploy là một nhúm
// người dùng nhận connection reset giữa chừng.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
Hai hàng rào này bổ sung cho nhau chứ không thay thế nhau: bỏ ports: chặn ở tầng mạng Docker, 127.0.0.1 chặn ở tầng kernel của chính tiến trình. Cái nào hỏng thì cái kia vẫn còn.
12. Áp dụng vào dự án community — kiến trúc đề xuất
Mười một mục trước là kiến thức chung. Mục này trả lời đúng một câu hỏi: với github.com/chuongtd/community — ba binary, PostgreSQL, Kafka, Outbox — thì đặt proxy ở đâu, và mỗi trách nhiệm thuộc về ai? Đây cũng là mục dễ làm hỏng kiến trúc nhất: proxy là thứ duy nhất nằm ngoài code Go mà vẫn sửa được hành vi của request, và chính vì tiện nên nghiệp vụ bắt đầu rò rỉ ra file cấu hình lúc nào không hay. §12.9 là phép thử để biết mình đã rò tới đâu.
12.1 Sơ đồ topology đề xuất
Internet
│ HTTPS
┌────────────▼─────────────┐
│ CDN │ tài nguyên tĩnh
│ (giai đoạn sau — §12.8) │ + GET công khai cache được
└────────────┬─────────────┘
│ miss / không cache được
┌────────────▼─────────────┐
│ reverse proxy │ TLS termination, HTTP/2,
│ Caddy hoặc Nginx │ nén, rate limit thô theo IP,
│ │ X-Forwarded-*, X-Request-ID
└────────────┬─────────────┘
│ HTTP/1.1, mạng nội bộ
┌────────────▼─────────────┐
│ cmd/api :8000 │ ◄── TIẾN TRÌNH DUY NHẤT
│ /healthz /readyz │ có traffic ĐI VÀO
└────────────┬─────────────┘
═══════════════════════════════╪══════════════════════════════════════
MẠNG NỘI BỘ — không route được từ Internet
═══════════════════════════════╪══════════════════════════════════════
┌─────────────┴─────────────┐
┌─────▼─────────┐ ┌───────▼───────┐
│ PostgreSQL │ │ Kafka │
│ :5432 │ │ :9092 │
└───▲───────▲───┘ └───▲───────▲───┘
│ └── cmd/relay ────┘ │
│ outbox → Kafka │
│ CHỈ traffic RA │
└────────── cmd/worker ───────────┘
Kafka → DB
CHỈ traffic RA
Ba điều cần đọc ra từ sơ đồ này:
- Chỉ
cmd/apicó cổng lắng nghe phục vụ người ngoài.cmd/workervàcmd/relaykhông mở cổng nào cho traffic người dùng — chúng chủ động mở kết nối ra Postgres và Kafka. Không có gì để đặt reverse proxy ở phía trước, vì không có "phía trước". - Reverse proxy chỉ đứng trên một cạnh duy nhất của đồ thị: cạnh Internet →
cmd/api. Mọi cạnh còn lại là giao thức nhị phân trong mạng nội bộ. - Đường sự kiện không đi qua proxy. Một request
POST /postsđi qua proxy đúng một lần; sau đópost.post.created.v1đi tiếp qua outbox → Kafka →cmd/worker, và không có mét dây nào của hành trình đó có proxy HTTP. Điều này quan trọng khi debug: nếu triệu chứng nằm ởuser_statssai số, proxy gần như chắc chắn vô can.
cmd/workervàcmd/relaycó cần/healthzkhông?Cần, nhưng vì lý do khác. Không có load balancer nào hỏi chúng, nên health check ở đây không dùng để rút node khỏi vòng quay — nó dùng để orchestrator (Docker, systemd, Kubernetes) biết khi nào nên restart. Với
cmd/relaythì chỉ số đáng theo dõi không phải "tiến trình còn sống" mà là "hàng chờ outbox còn trẻ", xem bước 5 §13.1. Mộtcmd/relaysống nhăn nhưng kẹt vòng lặp thì/healthzvẫn trả 200 vui vẻ.
12.2 Vì sao không có proxy trước Kafka và Postgres
Câu hỏi này xuất hiện thường xuyên vì sơ đồ trên trông "mất cân đối": tại sao HTTP thì có lớp bảo vệ mà DB với Kafka thì phơi ra? Câu trả lời không phải "vì lười" mà là proxy L7 HTTP không có gì để làm ở đó.
Một reverse proxy HTTP sống nhờ ba thứ: nó đọc được ranh giới request, nó thấy được method và path để định tuyến, và mỗi request độc lập với request trước. Giao thức Postgres và Kafka không có thứ nào trong ba thứ đó:
| HTTP/1.1 | Giao thức Postgres | Giao thức Kafka | |
|---|---|---|---|
| Khung dữ liệu | text, có ranh giới rõ | nhị phân, khung có độ dài | nhị phân, khung có độ dài |
| Trạng thái giữa các lần gọi | không (stateless) | có: transaction, prepared statement, SET, temp table |
có: thành viên consumer group, offset đang giữ |
| Định tuyến theo nội dung | theo path/host | không có khái niệm path | có API key nhị phân, nhưng client tự chọn broker |
| Ai quyết định đích đến | proxy | client (một kết nối, một backend) | client, sau khi hỏi metadata |
Với Kafka còn một điểm nữa mà nhiều người mất cả buổi mới hiểu: client Kafka không nói chuyện với một địa chỉ, nó nói chuyện với cả cụm. Nó kết nối tới địa chỉ bootstrap, hỏi metadata, nhận lại danh sách broker theo đúng tên mà broker tự khai (advertised.listeners phía broker), rồi mở kết nối trực tiếp tới broker đang giữ leader của từng partition. Đặt một proxy TCP ở giữa mà không sửa advertised.listeners thì client sẽ nhận được danh sách địa chỉ nội bộ và đi vòng qua proxy — hoặc tệ hơn, đi tới một tên nó không phân giải được. Muốn proxy thật thì mỗi broker phải có một cổng/tên riêng phía proxy và advertised.listeners phải trỏ về đúng cặp đó. franz-go không thay đổi được sự thật này; đây là thiết kế của giao thức.
Kết luận thực dụng: giữ Kafka và Postgres trong mạng nội bộ, chặn ở tầng mạng, không đặt proxy HTTP trước chúng. Nếu bắt buộc phải có một trung gian, nó phải là:
- L4 (TCP) — chuyển tiếp byte, không hiểu nội dung. Dùng khi cần NAT hoặc TLS passthrough. Nó cân bằng tải được, nhưng chỉ ở mức kết nối: chọn backend đúng một lần, lúc bắt tay TCP. Một kết nối Postgres là một phiên có trạng thái, nên không thể rải từng câu lệnh bên trong kết nối đó sang backend khác giữa chừng — và với một primary duy nhất thì phần "cân bằng" cũng chẳng còn gì để làm. Xem §2 về khác biệt L4/L7.
- Proxy hiểu giao thức — với Postgres đó chính là PgBouncer.
PgBouncer là một proxy, và pool mode là quyết định thật sự
PgBouncer nằm giữa ứng dụng và Postgres, nói đúng giao thức Postgres, và gộp N kết nối client thành M kết nối server. Nó tồn tại vì mỗi kết nối Postgres là một process phía server — đắt, và max_connections là tài nguyên chung của cả hệ thống (bảng ngân sách kết nối ở bước 2 §3.2 đã đếm đúng chỗ này).
Ba pool_mode của nó khác nhau ở chỗ kết nối server được trả về hồ khi nào:
pool_mode |
Trả kết nối server về hồ khi | Giữ được gì | Hợp với dự án này? |
|---|---|---|---|
session |
client ngắt kết nối | mọi thứ: transaction, prepared statement, SET, LISTEN |
✅ An toàn tuyệt đối, nhưng gộp được ít nhất |
transaction |
transaction kết thúc | chỉ những gì nằm trong một transaction | ✅ Gộp tốt nhất — nhưng phải chỉnh pgx, xem dưới |
statement |
mỗi câu lệnh kết thúc | không gì cả; transaction nhiều câu lệnh bị cấm | ❌ Không dùng được |
statement bị loại thẳng, và lý do là kiến trúc chứ không phải khẩu vị: Luật 3 của dự án yêu cầu ghi bảng nghiệp vụ và ghi bảng outbox nằm trong cùng một transaction. Pool mode statement cấm transaction nhiều câu lệnh, tức là cấm luôn Outbox Pattern. Chọn nó là tự tay phá luật kiến trúc bằng một dòng cấu hình hạ tầng — đúng kiểu rò rỉ mà §12.9 nói tới.
transaction mode và prepared statement của pgx — cái bẫy im lặng
pgx/v5 mặc định dùng chế độ thực thi có cache prepared statement (QueryExecModeCacheStatement — kiểm chứng được ở conn.go của pgx v5.10.0, nơi defaultQueryExecMode được gán đúng hằng số này): lần đầu chạy một câu SQL, nó PREPARE với một tên sinh tự động và ghi nhớ tên đó; các lần sau chỉ gửi tên + tham số. Prepared statement thuộc về một kết nối server cụ thể — nó không phải đối tượng dùng chung của cả cụm.
Bây giờ ghép hai thứ lại. Ở transaction mode, transaction thứ nhất của bạn chạy trên kết nối server A và PREPARE ở đó. Transaction thứ hai — vẫn cùng một *pgxpool.Conn phía ứng dụng — có thể rơi vào kết nối server B, nơi cái tên đó chưa từng tồn tại. Postgres trả về ERROR: prepared statement "..." does not exist, SQLSTATE 26000. Nhớ mã đó: nó là dấu vân tay của đúng tình huống này, và là thứ đáng grep trước tiên khi log bắt đầu có lỗi lạ sau khi ai đó thêm PgBouncer vào chuỗi.
Vì sao đây là cái bẫy chứ không phải một lỗi bình thường: nó không xảy ra khi tải thấp. Lúc dev, PgBouncer gần như luôn trả lại đúng kết nối cũ vì hồ rảnh. Lỗi chỉ nở rộ đúng lúc đông người — tức là lúc bạn ít muốn debug nhất — và nó ngắt quãng, nên rất dễ bị đổ oan cho mạng.
Hai cách xử lý, chọn một:
// internal/platform/postgres/pool.go — thêm vào Connect() ở bước 2
// khi và chỉ khi DSN trỏ tới PgBouncer chạy transaction mode.
//
// QueryExecModeExec vẫn dùng extended protocol, một round trip, nhưng KHÔNG
// giữ tên prepared statement giữa các lần gọi, nên không còn phụ thuộc vào
// việc lần sau có rơi đúng kết nối server cũ hay không.
//
// Hai cái giá, cả hai đều thật:
// 1. Mỗi câu lệnh phải parse + plan lại phía server — đo trước khi kết luận.
// 2. Kiểu tham số suy ra từ KIỂU GO và gửi ở định dạng text, thay vì hỏi
// server như các mode kia. Kiểu nhập nhằng (map[string]string là json hay
// hstore?) bị từ chối thẳng. Đây là thay đổi HÀNH VI, không chỉ hiệu năng.
poolCfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec
Cách thứ hai: dùng bản PgBouncer có hỗ trợ prepared statement ở transaction mode và bật tường minh tham số max_prepared_statements. Tính năng này chỉ có ở các bản PgBouncer gần đây, và giá trị mặc định của tham số đã đổi giữa các bản — ở bản đầu tiên có tính năng thì nó tắt (0), bản sau bật sẵn. Đừng đoán theo trí nhớ; hỏi thẳng cái process đang chạy:
# Admin console của chính PgBouncer — database ảo tên "pgbouncer".
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW CONFIG;" \
| grep -E "pool_mode|max_prepared_statements"
Nếu max_prepared_statements là 0 mà pool_mode là transaction, thì cái bẫy ở trên đang bật, và nó sẽ nổ vào lúc đông người chứ không phải bây giờ.
Còn gì nữa mất ở
transactionmode. Bất cứ thứ gì sống lâu hơn một transaction đều biến mất:LISTEN/NOTIFY, advisory lock cấp phiên,SETcấp phiên, cursorWITH HOLD. Với dự án này, mục đáng lưu ý nhất làLISTEN/NOTIFY— nếu sau này bạn muốn nâng cấpcmd/relaytừ polling sang đánh thức bằngNOTIFY, PgBouncer ởtransactionmode sẽ lặng lẽ chặn đường đó. Biết trước thì chọn kiến trúc khác; biết sau thì phải đổi cả hạ tầng.
Khi nào mới cần PgBouncer? Không phải bây giờ. Ngân sách kết nối ở bước 2 (api 3×10 + worker 2×10 + relay 1×5) vẫn thoải mái dưới max_connections mặc định. PgBouncer đáng thêm khi số instance nhân lên tới mức tổng MaxConns chạm trần Postgres — tức là cùng thời điểm bạn cần nhiều cmd/api ở §12.8. Thêm sớm là thêm một tiến trình phải vận hành, phải giám sát, phải hiểu ba pool mode, đổi lấy một vấn đề bạn chưa có.
12.3 Trách nhiệm đặt ở đâu — bảng quyết định
Đây là bảng để dán lên tường. Cột cuối là phần quan trọng nhất: vì sao không phải chỗ kia.
| Trách nhiệm | Proxy | cmd/api |
Vì sao đặt ở đó |
|---|---|---|---|
| TLS termination | ✅ | ❌ | Chứng chỉ, gia hạn, cấu hình cipher là việc vận hành. Đưa vào Go nghĩa là mỗi lần đổi chứng chỉ phải deploy lại ứng dụng |
| HTTP/2, HTTP/3 phía client | ✅ | ❌ | Người dùng hưởng lợi từ multiplexing ở chặng xa; chặng proxy→app trong mạng nội bộ (RTT dưới một mili-giây) thì HTTP/1.1 là đủ — và với Nginx cũng không có lựa chọn nào khác: proxy_pass nói HTTP/1.0 mặc định, HTTP/1.1 khi bật proxy_http_version 1.1, không nói HTTP/2 với upstream (ngoại lệ duy nhất là grpc_pass) |
| Nén response (gzip/brotli) | ✅ | ❌ | Nén một chỗ. Nén hai lần là đốt CPU hai lần và có nguy cơ sinh Content-Encoding chồng nhau |
| Giới hạn kích thước body | ✅ | ✅ | Phòng thủ nhiều lớp: proxy chặn trước khi tốn goroutine + kết nối DB; http.MaxBytesReader trong handler chặn khi proxy bị cấu hình sai |
| Rate limit thô theo IP | ✅ | ❌ | Chặn flood rẻ tiền trước khi nó chạm vào Go. Proxy làm việc này với chi phí gần bằng không |
| Rate limit theo user | ❌ | ✅ | Proxy không biết ai đang gọi. "Tài khoản mới đăng tối đa N bài/ngày" là luật nghiệp vụ, phải nằm ở module |
| Xác thực JWT | ❌ | ✅ | Xem callout bên dưới |
| Phân quyền (ai sửa được bài nào) | ❌ | ✅ | Cần đọc DB mới biết author_id. Proxy không có DB, và không được có |
| CORS | ❌ | ✅ | Một chỗ duy nhất — xem giải thích bên dưới |
Sinh X-Request-ID |
✅ | kế thừa | Proxy là điểm đầu tiên thấy request; nó ghi ID vào log của chính nó, ứng dụng dùng lại đúng ID đó |
| Access log | ✅ | ✅ | Hai mức độ khác nhau — xem giải thích bên dưới |
/healthz, /readyz |
gọi | ✅ phơi ra | Chỉ ứng dụng mới biết mình có sẵn sàng hay không. Proxy chỉ hỏi và tin câu trả lời |
| Phục vụ tài nguyên tĩnh | ✅ | ❌ | cmd/api không nên tồn tại để đọc file từ đĩa |
| Chuyển hướng HTTP → HTTPS | ✅ | ❌ | Xảy ra trước khi request tới ứng dụng; ứng dụng chỉ cần đọc X-Forwarded-Proto |
Vì sao JWT ở ứng dụng chứ không ở gateway
Có một mô hình phổ biến là để API gateway kiểm chữ ký JWT rồi truyền X-User-Id xuống dưới (§9 mô tả kỹ). Mô hình đó hợp lý khi có hàng chục service và bạn muốn tránh lặp code kiểm token ở mọi nơi. Dự án này có một tiến trình HTTP, nên toàn bộ lợi ích đó bằng không, còn cái giá thì có thật:
- Gateway phải giữ
JWT_SECRET. Bí mật đó đang nằm ở đúng một chỗ (cmd/api), giờ nằm ở hai — bước 5 §8 đã nêu nguyên tắc: đừng rải bí mật vào tiến trình không có nhu cầu dùng nó. cmd/apibuộc phải tin headerX-User-Id. Ngày nào đó ai đó gọi thẳng vào cổng 8000 — từ một container khác, từ một máy bị chiếm — và trở thành bất kỳ ai họ muốn. Toàn bộ mô hình bảo mật của bạn giờ phụ thuộc vào một luật firewall.middleware.Auth(Verifier)ở bước 4 §6.3 đã cố tình không biết JWT là gì; nó chỉ biếtVerify(rawToken) (subject, error). Đổi sang session hay API key chỉ cần một implementation khác. Chuyển việc đó lên nginx là vứt bỏ điểm nới lỏng đó để lấy về một file cấu hình không test được.
Quy tắc phân định: proxy được phép từ chối request; nó không được phép quyết định request đó có ý nghĩa gì.
Chặn 10.000 request/giây từ một IP: từ chối — hợp lệ. Kiểm chữ ký để biết đây là user nào rồi cho qua: quyết định danh tính — thuộc về module
identity. Ranh giới nằm đúng ở chỗ proxy bắt đầu cần biết ai.
CORS: chọn ứng dụng, và chỉ ứng dụng
CORS trông giống việc của proxy — nó chỉ là vài header. Đặt ở ứng dụng vì ba lý do:
- Hai chỗ cùng đặt là hỏng hẳn. Nếu cả proxy lẫn ứng dụng cùng thêm
Access-Control-Allow-Origin, response có hai header trùng tên. Phép kiểm CORS của trình duyệt yêu cầu đúng một giá trị, nên nó thất bại: response vẫn về tới trình duyệt, nhưng JavaScript chỉ nhận được một network error, không đọc được gì. Triệu chứng: lỗi CORS trong console dù cả hai bên "đều đã bật CORS" — và càng khó tin hơn vìcurlthì thấy header nằm đó, thậm chí nằm hai lần. Đây là một trong những lỗi tốn thời gian nhất trong danh sách ở §15. - Preflight cần biết route. Trả lời
OPTIONSđúng nghĩa là nói được endpoint này chấp nhận method nào. Chỉ routerchibiết điều đó; nginx phải chép lại bảng route bằng tay và nó sẽ lệch ngay lần thêm endpoint tiếp theo. Vary: Originphải chuẩn. Thiếu nó, cache phía trước (kể cả CDN ở §8) có thể phục vụ response chứa origin của người khác. Sinh header này ở nơi biết danh sách origin cho phép là an toàn hơn.
Việc còn lại của proxy chỉ là không đụng vào: không thêm, không xoá header CORS.
Access log: cả hai, nhưng trả lời hai câu hỏi khác nhau
| Proxy ghi | cmd/api ghi |
|
|---|---|---|
| Đơn vị | mọi kết nối, kể cả cái không tới được app | mọi request đã vào handler |
| Thấy được | 429 do rate limit, 502 khi app chết, TLS bắt tay hỏng, thời gian upstream | request_id, user, mã lỗi nghiệp vụ, tên module |
| Không thấy | request là của ai, vì sao thất bại về mặt nghiệp vụ | request bị chặn trước khi tới nơi |
| Câu hỏi trả lời được | "Traffic có tới không, và tốn bao lâu ở đâu?" | "Ai đã làm gì, và vì sao hỏng?" |
Chúng không thừa nhau. Khi người dùng báo "tôi bấm đăng bài mà không có gì xảy ra", log ứng dụng có thể trống trơn — vì request bị proxy chặn ở 429 và chưa bao giờ tới Go. Không có log proxy, bạn sẽ đi tìm bug trong service.CreatePost suốt buổi chiều. Sợi chỉ nối hai bên là X-Request-ID: proxy sinh ID và ghi vào dòng log của mình, middleware.RequestID ở bước 4 §6.3 nhận header đó và mang nó xuyên suốt — vào slog, vào Envelope.CorrelationID, ra tới response lỗi.
Chi tiết dễ bỏ sót: proxy phải ghi đè
X-Request-ID, không phải "thêm nếu chưa có". Nginx có biến$request_idsẵn cho việc này (proxy_set_header X-Request-ID $request_id;). Nếu để header của client đi qua nguyên vẹn, một client tinh nghịch gửi cùng một ID cho mọi request là đủ làm hỏng khả năng truy vết của bạn — và middleware ở bước 4 sẽ chấp nhận nó, vì nó chỉ chặn độ dài chứ không chặn trùng lặp.
12.4 IP client đi vào đâu trong code
Đường đi có bốn chặng, và mỗi chặng có một quy tắc riêng.
X-Forwarded-For (do proxy ghi)
│
▼ middleware.RealIP ── dùng TRUSTED_PROXY_CIDRS
clientip.FromRequest ── thuật toán đi ngược, §4
│
▼
context.Context ── khoá riêng tư, không ai ghi đè được
│
├──► slog attr "client_ip" (log vận hành)
├──► rate limit theo user (nghiệp vụ, trong module)
└──► audit record (chỉ hành động nhạy cảm)
Thuật toán phân giải nằm trọn trong internal/platform/clientip ở §4 — đi ngược chuỗi từ phải sang trái, dừng ở hop đầu tiên không thuộc dải tin cậy. Phần thuộc về mục này là chỗ nối nó vào dự án, và nó phải mỏng:
// internal/platform/httpx/middleware/realip.go
package middleware
import (
"net/http"
"net/netip"
"github.com/chuongtd/community/internal/platform/clientip"
)
// RealIP đặt IP thật của client vào context, dựa trên danh sách CIDR tin cậy
// nạp từ TRUSTED_PROXY_CIDRS.
//
// Middleware này cố tình mỏng: toàn bộ phần khó — đi ngược chuỗi X-Forwarded-For,
// xử lý địa chỉ IPv4-mapped, chặn chuỗi rác — nằm trong package clientip.
// Tách như vậy để logic đó test được mà không cần dựng một http.Request.
func RealIP(trusted []netip.Prefix) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr, ok := clientip.FromRequest(r, trusted)
if !ok {
// Không xác định được thì KHÔNG gắn gì vào context.
// Thà thiếu còn hơn có một IP sai nằm trong audit log: IP sai
// tệ hơn không có IP, vì nó chỉ đích danh một người vô can.
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r.WithContext(clientip.NewContext(r.Context(), addr)))
})
}
}
Chặng sang slog cũng nên là một hàm, không phải một dòng chép đi chép lại ở mọi handler:
// internal/platform/clientip/attr.go
package clientip
import (
"context"
"log/slog"
)
// Attr trả về thuộc tính log cho IP client, hoặc Attr rỗng khi không xác định
// được. Handler chuẩn của log/slog bỏ qua Attr rỗng, nên dòng log sẽ không có
// khoá "client_ip" với giá trị vô nghĩa — quan trọng khi bạn lọc log theo IP
// và không muốn chuỗi rỗng lọt vào kết quả.
func Attr(ctx context.Context) slog.Attr {
addr, ok := From(ctx)
if !ok {
return slog.Attr{}
}
return slog.String("client_ip", addr.String())
}
Thứ tự middleware trong cmd/api/main.go có ý nghĩa:
r := chi.NewRouter()
r.Use(middleware.RequestID) // 1. có ID trước đã
r.Use(middleware.RealIP(trustedNets)) // 2. rồi mới tới IP
r.Use(middleware.Recover(log)) // 3. panic sau đây vẫn log đủ ID + IP
Recover phải nằm sau hai cái kia, nếu không thì dòng log của một panic sẽ thiếu đúng hai thông tin bạn cần nhất để tái hiện nó. Cái giá của thứ tự này — nói ra cho sòng phẳng — là panic xảy ra bên trong RequestID hoặc RealIP sẽ không được bắt, và nó rơi thẳng vào panic mặc định của net/http (kết nối bị đóng, tiến trình vẫn sống). Đổi lại được: hai middleware đó chỉ đọc header và parse địa chỉ, không gọi mạng, không gọi DB — chúng là hai đoạn code ít có khả năng panic nhất trong toàn bộ chuỗi.
Còn audit thì sao — có nên đẩy IP vào event không? Không. Envelope ở ARCHITECTURE.md §5.2 cố ý không có trường metadata tự do, và IP là dữ liệu cá nhân. Nhét nó vào Payload nghĩa là mọi consumer đều nhận được nó, kể cả stats — module chỉ cần cộng trừ counter. Đó vừa là rò rỉ dữ liệu cá nhân vừa là phá vỡ nguyên tắc "event chứa đúng cái consumer cần". IP thuộc về đường request đồng bộ: ghi vào log, và nếu cần lưu lâu dài thì ghi vào bảng audit của chính module xử lý hành động đó, trong cùng transaction với hành động ấy.
12.5 Cấu hình thêm vào dự án
Một biến môi trường mới, đọc bằng caarlos0/env/v11 như mọi biến khác ở bước 4 §4:
// internal/platform/config/config.go — bổ sung vào bản ở bước 5.
type Config struct {
// ... các trường sẵn có giữ nguyên ...
// TrustedProxyCIDRs liệt kê dải mạng mà từ đó header X-Forwarded-* được
// coi là thật. ĐỂ TRỐNG là mặc định an toàn: trống nghĩa là "không tin ai",
// và clientip sẽ dùng thẳng RemoteAddr.
//
// Vì sao không đặt sẵn dải riêng (10/8, 172.16/12, 192.168/16): trong
// container, "mạng nội bộ" thường gồm cả những thứ bạn không kiểm soát.
// Danh sách này phải do người vận hành khai, đúng một lần, có ý thức.
TrustedProxyCIDRs []string `env:"TRUSTED_PROXY_CIDRS" envSeparator:","`
// OutboxLagBudget: hàng chờ outbox già hơn mức này thì /readyz báo degraded.
OutboxLagBudget time.Duration `env:"OUTBOX_LAG_BUDGET" envDefault:"30s"`
// ShutdownDrainDelay: khoảng chờ giữa lúc /readyz bắt đầu trả 503 và lúc
// đóng listener. Phải LỚN HƠN (chu kỳ health check × số lần fail) của proxy.
ShutdownDrainDelay time.Duration `env:"SHUTDOWN_DRAIN_DELAY" envDefault:"5s"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
}
Phân tích CIDR lúc khởi động, không phải mỗi request — và để lỗi cấu hình lộ ra ngay, đúng tinh thần fail fast ở bước 2 §3.1:
// clientip.ParsePrefixes trả lỗi nếu có mục sai. Gõ nhầm "10.0.0.0/33"
// phải làm tiến trình chết lúc khởi động, chứ không phải âm thầm biến
// mọi X-Forwarded-For thành không đáng tin lúc 3 giờ sáng.
trustedNets, err := clientip.ParsePrefixes(cfg.TrustedProxyCIDRs)
if err != nil {
return err
}
Bổ sung .env.example:
# ── Proxy ────────────────────────────────────────────────
# Trống = cmd/api nhận kết nối trực tiếp, không đọc X-Forwarded-For.
# Có proxy: khai đúng dải của proxy, KHÔNG khai 0.0.0.0/0.
TRUSTED_PROXY_CIDRS=
OUTBOX_LAG_BUDGET=30s
SHUTDOWN_DRAIN_DELAY=5s
SHUTDOWN_TIMEOUT=15s
Phía proxy chỉ cần gửi đủ bốn header cho ứng dụng — Host, X-Forwarded-For, X-Forwarded-Proto, X-Request-ID — cộng hai dòng bắt buộc để keepalive thật sự hoạt động (cú pháp đầy đủ, kể cả bản Caddy/HAProxy, ở §11):
# ── ngữ cảnh http { } ──────────────────────────────────────
upstream community_api {
server 127.0.0.1:8000;
# Giữ sẵn kết nối tới cmd/api để không bắt tay TCP lại cho mỗi request.
keepalive 32;
}
# ── ngữ cảnh server { } ────────────────────────────────────
location / {
proxy_pass http://community_api;
# BẮT BUỘC khi upstream có keepalive: mặc định proxy_pass nói HTTP/1.0,
# mà HTTP/1.0 không giữ kết nối — chỉ thị keepalive ở trên sẽ im lặng
# không có tác dụng gì. Đây là cặp bị quên thường xuyên nhất trong nginx.
proxy_http_version 1.1;
# Và phải xoá header Connection do client gửi: nếu để nguyên, nginx
# chuyển tiếp "Connection: close" xuống upstream và tự đóng đúng cái
# kết nối vừa định giữ lại.
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
}
Connection là header hop-by-hop — nó mô tả quan hệ của đúng một chặng, không phải ý định của client với server cuối; RFC 9110 §7.6.1 liệt kê nó trong nhóm không được chuyển tiếp nguyên vẹn. Xoá nó ở biên proxy không phải mẹo vặt, mà là làm đúng giao thức. (X-Forwarded-* là quy ước có trước chuẩn; bản chuẩn hoá là header Forwarded của RFC 7239 — §4 so sánh hai đường.)
$proxy_add_x_forwarded_fornối thêm, không ghi đè — nó lấy header client gửi rồi thêm$remote_addrvào cuối. Nghe như một lỗ hổng, nhưng nó khớp chính xác với thuật toán đi-ngược-từ-phải ở §4: phần tử do proxy tự tay ghi luôn nằm bên phải, phần tử do client bịa nằm bên trái và bị bỏ qua ngay khi ta gặp hop không tin cậy đầu tiên. Hai mảnh này chỉ khớp khiTRUSTED_PROXY_CIDRSđược khai đúng. Khai0.0.0.0/0là tự phá cả hai.
12.6 /healthz và /readyz — hai câu hỏi khác nhau
Bước 4 đã có /healthz trả {"status":"ok"}. Cần thêm /readyz, và tuyệt đối không gộp hai cái làm một:
/healthz (liveness) |
/readyz (readiness) |
|
|---|---|---|
| Câu hỏi | "Tiến trình còn sống không?" | "Có nên gửi request tới đây không?" |
| Chạm vào DB | Không | Có |
| Ai hỏi | orchestrator | reverse proxy / load balancer |
| Hậu quả khi fail | restart tiến trình | rút khỏi vòng quay, không restart |
Vì sao /healthz không được ping DB. Hãy tưởng tượng Postgres chớp mất một nhịp — failover, một lần VACUUM FULL bị lỡ tay, hay chỉ là mạng nội bộ giật. Giả sử nhịp đó dài hơn chu kỳ × ngưỡng fail của liveness probe. Nếu /healthz ping DB, mọi instance cmd/api cùng fail liveness và bị restart cùng lúc. Postgres vừa tỉnh lại đã phải chịu cả một đợt mở kết nối mới, cộng với việc các instance mất luôn kết nối đang giữ và mất luôn mọi thứ đang xử lý dở. Một sự cố ngắn được nhân lên thành một sự cố dài — do chính health check gây ra.
Nói gọn thành một quy tắc: liveness probe không được phụ thuộc vào thứ nằm ngoài tiến trình. Mỗi dependency bạn thêm vào /healthz là một cách mới để cả cụm tự sát đồng bộ.
// internal/platform/health/health.go
package health
import (
"context"
"encoding/json"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Checker phục vụ /healthz và /readyz. Nó chỉ biết pool và đồng hồ, không biết
// bài viết hay người dùng là gì — nên nó đúng chỗ ở platform/ (bước 2 §1).
type Checker struct {
pool *pgxpool.Pool
lagBudget time.Duration
draining atomic.Bool
mu sync.Mutex
lagAt time.Time
lag time.Duration
lagErr error
cacheFor time.Duration
}
func New(pool *pgxpool.Pool, lagBudget time.Duration) *Checker {
return &Checker{pool: pool, lagBudget: lagBudget, cacheFor: 5 * time.Second}
}
// Drain bật cờ "sắp nghỉ". Gọi TRƯỚC srv.Shutdown — xem §12.7.
func (c *Checker) Drain() { c.draining.Store(true) }
// Live không chạm vào bất kỳ dependency nào. Trả lời được nghĩa là runtime Go
// còn lập lịch được goroutine — đúng và đủ cho một liveness probe.
func (c *Checker) Live(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (c *Checker) Ready(w http.ResponseWriter, r *http.Request) {
// Cờ drain được kiểm TRƯỚC mọi thứ khác: lúc đang tắt, câu trả lời là 503
// bất kể DB khoẻ tới đâu.
if c.draining.Load() {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "draining"})
return
}
// Chặn trên thời gian. Không có nó, một truy vấn treo sẽ giữ kết nối pool
// và biến chính phép kiểm tra sức khoẻ thành nguyên nhân của sự cố.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := c.pool.Ping(ctx); err != nil {
// DB chết thì instance này KHÔNG phục vụ được gì: 503 là sự thật.
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"status": "unready",
"reason": "database",
})
return
}
body := map[string]any{"status": "ok"}
switch lag, err := c.outboxLag(ctx); {
case err != nil:
body["outbox"] = "unknown"
case lag > c.lagBudget:
// CÓ CHỦ ĐÍCH: vẫn 200. Lý do ngay dưới khối code này.
body["status"] = "degraded"
body["outbox_lag_seconds"] = lag.Seconds()
default:
body["outbox_lag_seconds"] = lag.Seconds()
}
writeJSON(w, http.StatusOK, body)
}
func (c *Checker) outboxLag(ctx context.Context) (time.Duration, error) {
// Khoá giữ NGUYÊN trong lúc truy vấn là có chủ đích: hai /readyz tới cùng
// lúc thì cái thứ hai chờ kết quả của cái thứ nhất, thay vì cùng bắn một
// câu SQL nữa. Trần thời gian chờ đã có ở ctx của Ready().
c.mu.Lock()
defer c.mu.Unlock()
// Health check chạy vài giây một lần, trên mỗi node, mãi mãi. Không cache
// thì phép đo sức khoẻ tự nó trở thành một nguồn tải đều đặn lên DB —
// nhỏ, nhưng không bao giờ dừng và lớn dần theo số node.
if !c.lagAt.IsZero() && time.Since(c.lagAt) < c.cacheFor {
return c.lag, c.lagErr
}
// Cùng câu hỏi với câu SQL số 2 ở bước 5 §13.1: tuổi của event cũ nhất
// chưa gửi. Ngưỡng theo TUỔI, không theo số lượng — 5000 dòng vừa ghi là
// bình thường, 1 dòng chờ 40 phút là relay đã chết.
const q = `
SELECT coalesce(extract(epoch FROM now() - min(occurred_at)), 0)::float8
FROM outbox
WHERE published_at IS NULL AND dead_at IS NULL`
var seconds float64
err := c.pool.QueryRow(ctx, q).Scan(&seconds)
c.lagAt = time.Now()
c.lag = time.Duration(seconds * float64(time.Second))
c.lagErr = err
return c.lag, err
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Health check không được nằm trong cache của bất kỳ ai — proxy, CDN hay
// trình duyệt. Một câu "ok" bị cache lại là node chết vẫn báo sống.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
Vì sao outbox trễ vẫn trả 200 mà không phải 503?
Vì
/readyztrả lời cho proxy, và câu duy nhất proxy làm được với một câu trả lời xấu là rút node khỏi vòng quay. Nhưng outbox trễ là lỗi củacmd/relayhoặc của Kafka —cmd/apikhông sửa được gì. Rút hết nodecmd/apira khỏi vòng quay chỉ đạt được một thứ: website chết hoàn toàn vì một tiến trình khác đang chậm.Trường
"status": "degraded"tồn tại để hệ thống giám sát đọc và cảnh báo cho con người — đúng vai trò của chỉ số số 1 trongARCHITECTURE.md§11.4. Nguyên tắc chung: chỉ trả 503 cho những thứ mà việc rút node này ra thực sự khiến tình hình tốt hơn.
Lắp vào router, và lưu ý cả hai endpoint đều nằm ngoài nhóm có xác thực:
checker := health.New(pool, cfg.OutboxLagBudget)
r.Get("/healthz", checker.Live)
r.Get("/readyz", checker.Ready)
Phía proxy nên chặn /readyz từ Internet: nó tiết lộ độ trễ nội bộ của hệ thống, là món quà miễn phí cho người đang dò tìm thời điểm bạn yếu nhất. Cho phép đúng dải IP của proxy và mạng giám sát.
12.7 Deploy zero-downtime — thứ tự rút lui
Đây là chỗ đa số hệ thống mất điểm, và triệu chứng luôn giống nhau: mỗi lần deploy là một chùm 502.
Nguyên nhân không phải Go đóng kết nối thô bạo — srv.Shutdown đã chờ request đang chạy xong rất tử tế. Nguyên nhân là thời điểm: Shutdown đóng listener ngay lập tức, trong khi proxy vẫn tin node này còn sống và tiếp tục gửi request tới. Những request gửi trong khe hở đó gặp cổng đã đóng → proxy trả 502.
Sửa bằng cách nói trước, làm sau:
SIGTERM
│
├─[1]─► /readyz trả 503 NGAY — server VẪN phục vụ bình thường
│
├─[2]─► chờ ShutdownDrainDelay
│ proxy thấy fail đủ số lần → rút node khỏi vòng quay
│ traffic mới ngừng chảy tới, traffic cũ vẫn được phục vụ trọn vẹn
│
├─[3]─► srv.Shutdown(ctx) — đóng listener, chờ request đang dở
│
└─[4]─► pool.Close(), đóng producer Kafka
All Rights Reserved