0

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

8.2.3 Tiered cache / origin shield — vì sao nó kéo tải origin về hằng số

Kịch bản: bài viết /posts/di-tim-golang-a3f21b8c vừa hết hạn cache. Người dùng ở 60 quốc gia cùng mở nó.

Không có tiering: mỗi PoP MISS độc lập → mỗi PoP gửi một request tới origin. Origin nhận N request cho cùng một byte dữ liệu, với N là số PoP có lưu lượng (và tệ hơn nữa nếu tính theo số máy trong PoP).

Có tiered cache / origin shield: cấu hình một PoP (hoặc một tầng máy) làm cửa duy nhất đi ra origin. PoP biên MISS thì hỏi shield chứ không hỏi origin. Shield MISS đúng một lần.

KHÔNG shield                          CÓ origin shield
────────────                          ─────────────────

 PoP-1 ─┐                              PoP-1 ─┐
 PoP-2 ─┤                              PoP-2 ─┤
 PoP-3 ─┼──►  ORIGIN                   PoP-3 ─┼──► SHIELD ──►  ORIGIN
  ...  ─┤     (N request)               ...  ─┤    (1 PoP)     (1 request)
 PoP-N ─┘                              PoP-N ─┘

 Tải origin  ~  O(số PoP)              Tải origin  ~  O(1) cho mỗi object

Nói cho đúng: đây không phải "giảm theo cấp số nhân" — đó là cụm từ hay bị dùng sai. Cái thay đổi là hình dạng của hàm: tải origin đi từ tỉ lệ thuận với số PoP xuống gần như hằng số. Mức chênh lệch bằng đúng số PoP có lưu lượng cho object đó, nên với một mạng cỡ trăm PoP thì là bậc hai chữ số thập phân — con số cụ thể tuỳ mạng của từng nhà cung cấp, đừng chép nó lại như một hằng số. Kèm theo đó, hầu hết CDN gộp các request MISS đồng thời cho cùng một cache key thành một request đi ra origin — cơ chế này thường gọi là request collapsing hoặc coalescing, và nó chính là thứ ngăn cache stampede (một object nóng hết hạn, nghìn request cùng lúc dội vào cmd/api).

Nếu nhà cung cấp của bạn không bật shield mặc định, hãy bật. Với kiến trúc cmd/api + PostgreSQL đơn, chi phí thật của một MISS không phải là băng thông mà là một truy vấn DB. Giảm số MISS đi bao nhiêu lần thì giảm bấy nhiêu lần số truy vấn cho object đó.

8.3 Cache key — thứ quyết định tất cả

Cache key là chuỗi mà CDN dùng để tra bảng "tôi đã có bản sao này chưa". Mọi sai lầm nghiêm trọng về cache đều là sai lầm về cache key. Cache key mặc định của phần lớn CDN được tạo từ:

Thành phần Có trong key mặc định Ghi chú
Method (GET/HEAD) POST mặc định không được cache
Scheme (https) ✅ (thường)
Host a.example.comb.example.com là hai entry
Path
Query string toàn bộ Đây là nguồn gốc của cache fragmentation
Header khác Chỉ vào key gián tiếp qua Vary
Cookie Chỉ vào key nếu bạn khai Vary: Cookie hoặc cấu hình riêng

Cần tách bạch chỗ này, vì nó hay bị nói sai theo cả hai chiều. RFC 9111 §2 định nghĩa cache key: primary key là method + target URI — mà URI đã bao gồm scheme, host, path và query — còn Vary sinh ra secondary key. Nghĩa là năm dòng đầu của bảng trên chính là chuẩn. Cái không nằm trong chuẩn là mọi thứ nhà cung cấp làm thêm lên trên: bỏ bớt tham số query, sắp lại thứ tự chúng, chuẩn hoá header, nhét cookie hay mã quốc gia vào key. Trước khi đặt CDN trước bất cứ thứ gì, hãy mở tài liệu của nhà cung cấp và đọc đúng mục "cache key".

8.3.1 Cache fragmentation — cái bẫy query string

Query string nằm trong key nghĩa là ba URL này là ba entry cache riêng biệt:

/posts/di-tim-golang-a3f21b8c
/posts/di-tim-golang-a3f21b8c?utm_source=facebook
/posts/di-tim-golang-a3f21b8c?utm_source=facebook&fbclid=IwAR0xNz...

Chúng trả về cùng một nội dung. Nhưng với CDN chúng khác nhau hoàn toàn. Hậu quả:

  • Hit ratio sụt vì mỗi lượt chia sẻ trên mạng xã hội tạo ra một key mới, và fbclid gần như duy nhất cho mỗi lượt click → mỗi click là một MISS.
  • Bộ nhớ cache của PoP bị lấp đầy bằng bản sao trùng lặp, đẩy các object hữu ích ra ngoài sớm (eviction).
  • Origin của bạn nhận lưu lượng đúng bằng lưu lượng thật, y như không có CDN.

Cách xử lý, theo thứ tự ưu tiên:

  1. Danh sách trắng tham số trong cache key. Với GET /posts bạn chỉ thật sự cần sizeafter (con trỏ phân trang). Khai báo đúng hai tham số đó, bỏ hết phần còn lại khỏi key.
  2. Chuẩn hoá thứ tự tham số. ?size=10&after=x?after=x&size=10 là hai key khác nhau nếu không sắp xếp.
  3. Nếu nhà cung cấp chỉ cho phép danh sách đen, ít nhất hãy loại utm_*, fbclid, gclid, ref.

Cùng logic áp dụng cho path: /posts/Di-Tim-Golang/posts/di-tim-golang là hai key. Slug của dự án luôn viết thường (xem slugBody trong 07-module-post.md), nên hãy để origin trả 301 về dạng chuẩn thay vì phục vụ 200 cho cả hai.

8.4 Vary — phần cache key mà origin điều khiển được

Vary (RFC 9110) là cách origin nói với cache: "phản hồi này còn phụ thuộc vào những header request sau đây, hãy đưa chúng vào key."

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Encoding: br
Cache-Control: public, s-maxage=60
Vary: Accept-Encoding
Giá trị Vary Số biến thể mỗi URL Đánh giá
Accept-Encoding Nhỏ (br / gzip / không nén) ✅ Gần như luôn đúng khi có nén
Accept-Language Bằng số ngôn ngữ bạn thật sự phục vụ ⚠️ Chỉ khai khi origin thật sự đổi nội dung theo ngôn ngữ
Authorization Rất lớn ⚠️ Đúng về mặt an toàn, nhưng nếu đã private, no-store thì thừa
Cookie Gần như bằng số người dùng ❌ Vô hiệu hoá cache trên thực tế
User-Agent Hàng chục nghìn ❌ Đừng
Quên khai 1 ❌❌ Sự cố bảo mật hoặc lỗi hiển thị

Vary: Cookie không phải là "cache an toàn hơn", nó là "không cache".

Mỗi người dùng có một cookie phiên riêng. Cache key gồm cookie nghĩa là mỗi người có một entry riêng, entry đó phục vụ đúng một người, và tỉ lệ tái sử dụng bằng không. Bạn trả tiền lưu trữ cho CDN để nhận về đúng con số hit ratio của việc không dùng CDN. Nếu nội dung phụ thuộc vào cookie, câu trả lời đúng là Cache-Control: private — không cache ở tầng dùng chung — chứ không phải Vary: Cookie.

Quên Vary còn tệ hơn. Nếu GET /posts/{slug} trả về HTML có tên người đang đăng nhập ở góc phải, mà bạn không khai gì cả, thì bản HTML của người đầu tiên sẽ được phục vụ cho tất cả những người tiếp theo — sự cố bảo mật, không phải bug hiệu năng (xem khung cảnh báo ở 8.8). Lưu ý vận hành kèm theo: CDN thường chuẩn hoá một số header trước khi đưa vào key (điển hình là gộp mọi biến thể của Accept-Encoding về vài giá trị) để tránh bùng nổ biến thể — hành vi riêng của từng nhà cung cấp, phải đọc tài liệu chứ đừng suy đoán.

8.5 Điều khiển cache theo RFC 9111

RFC 9111 (HTTP Caching) là văn bản chuẩn. Điều quan trọng nhất khi đọc nó: nó phân biệt rất rõ shared cache (CDN, reverse proxy — nhiều người dùng chung) và private cache (trình duyệt — một người).

Chỉ thị Ai nghe Ý nghĩa chính xác
max-age=N Mọi cache Phản hồi còn "tươi" trong N giây kể từ lúc được sinh ra
s-maxage=N Chỉ shared cache Ghi đè max-age ở CDN; trình duyệt bỏ qua. RFC 9111 §5.2.2.10: nó mang luôn ngữ nghĩa của proxy-revalidate
public Mọi cache Được lưu kể cả khi request có Authorization
private Mọi cache Shared cache không được lưu; trình duyệt thì được
no-store Mọi cache Không được ghi ra bất kỳ đâu, kể cả đĩa tạm
no-cache Mọi cache Được lưu, nhưng phải revalidate với origin trước mỗi lần dùng lại
must-revalidate Mọi cache Khi đã stale thì cấm phục vụ bản cũ, kể cả lúc origin chết (bản chỉ dành cho shared cache là proxy-revalidate)
immutable Mọi cache Trong thời gian còn tươi, đừng revalidate kể cả khi người dùng bấm reload. Là phần mở rộng (RFC 8246), không nằm trong RFC 9111 — mức hỗ trợ của trình duyệt không đồng đều, coi nó là tối ưu thêm
no-transform Mọi cache Cấm proxy sửa nội dung (nén lại, đổi định dạng ảnh)

8.5.1 no-storeno-cache — khác nhau thật sự

Đây là cặp bị nhầm nhiều nhất, và nhầm ở đây thì rò rỉ dữ liệu.

no-store no-cache
Cache có được ghi bản sao xuống không? Không
Có được phục vụ lại không? Không có gì để phục vụ Có, sau khi hỏi lại origin và nhận 304
Dùng cho Token, dữ liệu cá nhân, mọi phản hồi có Authorization Nội dung công khai đổi bất chợt, muốn tiết kiệm băng thông nhưng luôn phải tươi
Tiết kiệm được gì Không gì cả Băng thông body khi nội dung chưa đổi

Nói ngắn: no-cache nghĩa là "hỏi lại đã", no-store nghĩa là "đừng giữ lại". Nếu bạn muốn dữ liệu người dùng không nằm trên đĩa của một PoP ở nước khác, thứ bạn cần là no-store.

8.5.2 stale-while-revalidatestale-if-error — hai chỉ thị cứu hệ thống

Hai chỉ thị này (RFC 5861) là phần có giá trị nhất trong cả mục 8 đối với một hệ thống nhỏ như dự án này.

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400
  • s-maxage=60 — CDN coi bản sao là tươi trong 60 giây.
  • stale-while-revalidate=600 — trong 600 giây sau khi hết tươi, CDN vẫn trả bản cũ ngay lập tức cho người dùng, đồng thời âm thầm gọi origin ở phía sau để làm mới. Người dùng không bao giờ phải chờ một request MISS.
  • stale-if-error=86400 — nếu origin trả 5xx, timeout hoặc không kết nối được, CDN được phép phục vụ bản cũ tới 24 giờ.

Chuẩn và thực tế lệch nhau đúng ở chỗ này — nên biết trước khi cãi nhau với nhà cung cấp.

s-maxage mang ngữ nghĩa proxy-revalidate (RFC 9111 §5.2.2.10), nên đọc theo đúng chữ của RFC 9111 thì shared cache không được phục vụ bản stale sau khi hết s-maxage. stale-while-revalidatestale-if-error (RFC 5861) chính là phần mở rộng viết ra để cho phép điều đó — cache nào không cài RFC 5861 sẽ bỏ qua hai chỉ thị này và bạn quay về hành vi revalidate cứng. Kết luận thực dụng: đừng suy hành vi từ chuẩn, hãy đo bằng header Age trên chính CDN của bạn (mục 8.10.2).

Vế thứ ba là bảo hiểm cháy nổ của bạn.

cmd/api sập, PostgreSQL hết kết nối, hay bạn deploy nhầm — với stale-if-error, phần lớn người đọc vẫn thấy nội dung (cũ vài phút) thay vì trang lỗi. Với một nền tảng nội dung, "bài viết cũ 5 phút" gần như luôn tốt hơn "502 Bad Gateway". Chi phí để có nó là một dòng header.

Điều kiện: object phải đã từng được cache. stale-if-error không cứu được request đầu tiên tới một URL chưa ai đọc.

must-revalidateđối nghịch của stale-if-error: nó nói "thà lỗi còn hơn cũ". Chỉ đặt nó cho dữ liệu mà phục vụ bản cũ gây quyết định sai — số dư, tồn kho, quyền truy cập. Với /posts/{slug} thì không.

8.5.3 Validator: ETag, Last-Modified304

Khi bản sao hết tươi, cache không nhất thiết phải tải lại toàn bộ. Nó gửi conditional request (RFC 9110):

GET /posts/di-tim-golang-a3f21b8c HTTP/1.1
Host: community.example
If-None-Match: W/"7Qk1r0aB9m2XcV4t"

Nếu nội dung chưa đổi, origin trả 304 Not Modified không có body — chỉ vài trăm byte header thay vì vài chục KB. Bản sao trong cache được "làm tươi lại" với max-age mới.

Validator Header request Độ chính xác Ghi chú
ETag If-None-Match Cao — hash nội dung Dạng mạnh "abc" và yếu W/"abc"; yếu là đủ cho JSON/HTML
Last-Modified If-Modified-Since Thấp hơn — mốc thời gian giây Hai lần sửa trong cùng một giây là không phân biệt được

Nên phát cả hai khi có sẵn dữ liệu (posts.updated_at cho Last-Modified, hash body cho ETag). Khi client gửi kèm cả If-None-Match lẫn If-Modified-Since, bên đánh giá phải xét If-None-Match trước và bỏ qua If-Modified-Since nếu ETag đã trả lời được (RFC 9110 §13.2.2) — nên trên thực tế ETag mới là thứ quyết định.

Middleware sinh ETag cho cmd/api — đặt ở internal/platform/httpx/middleware:

package middleware

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"strings"
)

// etagWriter đệm toàn bộ body để tính hash. Đây là đánh đổi có ý thức:
// đệm nghĩa là MẤT khả năng stream và tốn RAM bằng kích thước response.
// Chấp nhận được với JSON của /posts (vài chục KB), KHÔNG chấp nhận được
// với tải file hay SSE — nên chỉ gắn cho nhóm route đọc, không gắn toàn cục.
type etagWriter struct {
	http.ResponseWriter
	buf    bytes.Buffer
	status int
}

func (w *etagWriter) WriteHeader(code int) {
	// Nuốt status lại, chưa gửi đi: ta còn phải quyết định
	// có đổi nó thành 304 hay không sau khi handler chạy xong.
	w.status = code
}

func (w *etagWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK
	}
	return w.buf.Write(b)
}

// ETag sinh ETag yếu từ hash nội dung và trả 304 khi client đã có bản mới nhất.
func ETag(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Chỉ GET/HEAD mới có ngữ nghĩa cache. POST /posts/{id}/view
		// phải đi thẳng, nếu không ta sẽ đệm vô ích.
		if r.Method != http.MethodGet && r.Method != http.MethodHead {
			next.ServeHTTP(w, r)
			return
		}

		ew := &etagWriter{ResponseWriter: w}
		next.ServeHTTP(ew, r)

		if ew.status == 0 {
			ew.status = http.StatusOK
		}

		// Không sinh validator cho lỗi: một trang 500 được gắn ETag
		// sẽ bị client coi là "nội dung hợp lệ" và giữ lại.
		if ew.status != http.StatusOK {
			w.WriteHeader(ew.status)
			_, _ = w.Write(ew.buf.Bytes())
			return
		}

		sum := sha256.Sum256(ew.buf.Bytes())
		// Cắt 16 byte đầu: đủ chống va chạm cho mục đích này,
		// và giữ header ngắn vì nó đi kèm MỌI request có điều kiện.
		etag := `W/"` + base64.RawURLEncoding.EncodeToString(sum[:16]) + `"`
		w.Header().Set("ETag", etag)

		if matchETag(r.Header.Get("If-None-Match"), etag) {
			// 304 không được mang content (RFC 9110 §15.4.5), và khung tin của nó
			// LUÔN kết thúc ở dòng trống cuối phần header dù có Content-Length hay
			// không (RFC 9112 §6.3) — nên client đúng chuẩn sẽ không treo chờ body.
			// net/http cũng tự gỡ header này cho 304; dòng dưới để ý định hiện ra
			// trong code, và để một Content-Length của body cũ không lọt xuống
			// những cache trung gian dễ dãi hơn.
			w.Header().Del("Content-Length")
			w.WriteHeader(http.StatusNotModified)
			return
		}

		w.WriteHeader(ew.status)
		_, _ = w.Write(ew.buf.Bytes())
	})
}

// matchETag so khớp theo luật yếu: bỏ tiền tố W/ ở cả hai phía.
// If-None-Match là danh sách ngăn cách dấu phẩy, client có thể gửi nhiều giá trị.
// Cắt theo dấu phẩy là GẦN ĐÚNG: ngữ pháp entity-tag của RFC 9110 vẫn cho phép dấu
// phẩy nằm trong cặp ngoặc kép. ETag ta phát là base64url nên không bao giờ dính —
// nhưng đừng bê hàm này đi so khớp ETag do hệ thống khác sinh ra.
func matchETag(header, etag string) bool {
	if header == "" {
		return false
	}
	if strings.TrimSpace(header) == "*" {
		return true
	}
	want := strings.TrimPrefix(etag, "W/")
	for _, candidate := range strings.Split(header, ",") {
		if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == want {
			return true
		}
	}
	return false
}

8.6 Bảng tra: muốn điều gì → đặt header nào

Bạn muốn Header cần phát từ origin
File tĩnh có fingerprint trong tên Cache-Control: public, max-age=31536000, immutable
JSON/HTML công khai, đổi thường xuyên Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400 + ETag
CDN giữ lâu, trình duyệt giữ ngắn Cache-Control: public, max-age=30, s-maxage=3600
Chỉ trình duyệt được lưu, CDN thì không Cache-Control: private, max-age=60
Không nơi nào được lưu Cache-Control: no-store
Được lưu nhưng lần nào cũng phải hỏi lại Cache-Control: no-cache + ETag
Hết hạn thì tuyệt đối không phục vụ bản cũ Cache-Control: public, max-age=60, must-revalidate
Nội dung đổi theo nén Vary: Accept-Encoding
Cấu hình chỉ dành cho CDN, giấu khỏi trình duyệt Surrogate-Control (không phải chuẩn IETF, tuỳ nhà cung cấp)

Về max-age=31536000: đó là 365 ngày, một quy ước trong ngành cho "vĩnh viễn" chứ không phải giới hạn do chuẩn quy định. RFC 9111 còn cho phép cache kẹp (clamp) giá trị delta-seconds quá lớn xuống mức nó biểu diễn được, nên viết max-age=99999999999 để tỏ ra chắc chắn là vô ích — cứ dùng con số quy ước.

Surrogate-Control bắt nguồn từ đặc tả Edge Architecture chứ không phải từ RFC HTTP. Ưu điểm là CDN đọc và gỡ bỏ nó trước khi trả cho trình duyệt, nên bạn cấu hình được CDN mà không ảnh hưởng cache của client. Nhược điểm là mức hỗ trợ khác nhau giữa các nhà cung cấp — kiểm tra tài liệu trước khi dựa vào nó.

8.7 Purge và invalidation

Cache tốt là cache biết khi nào phải quên. Bốn cách, xếp theo độ khuyến khích ngược lại với độ phổ biến:

Cách Cơ chế Ưu Nhược
Đổi URL (fingerprint) app.9f2a1c.jsapp.b7d4e0.js Tức thì, không cần API, không có trạng thái trung gian Chỉ áp dụng cho tài nguyên do build sinh ra
Purge theo URL Gọi API xoá đúng một key Chính xác Phải biết mọi biến thể của key (query, Vary)
Purge theo tag Gắn nhãn lúc phát, xoá theo nhãn Xoá được nhóm liên quan bằng một lệnh Cần nhà cung cấp hỗ trợ; phải kỷ luật khi gắn nhãn
Purge toàn bộ Xoá sạch Đơn giản Origin nhận toàn bộ lưu lượng cùng lúc — tự gây DDoS

8.7.1 Purge theo tag (surrogate key)

Ý tưởng: khi origin trả bài viết, nó kèm một header liệt kê các "nhãn" mà nội dung này thuộc về.

HTTP/1.1 200 OK
Surrogate-Key: post-3f2a1b8c author-91c0 tag-golang
Cache-Control: public, s-maxage=300, stale-while-revalidate=600

Khi tác giả 91c0 sửa tên hiển thị, bạn gọi API purge tag author-91c0mọi trang có tên người đó biến mất khỏi cache, dù bạn không hề biết danh sách URL. Đây là mảnh ghép hợp với kiến trúc event-driven của dự án: một consumer nghe post.post.created.v1 / post.post.deleted.v1 — hai type đã có sẵn trong internal/contracts/v1 — rồi gọi purge tag post-<id> tương ứng, đúng tinh thần "event là hợp đồng". Riêng nhánh purge theo author-<id> thì chưa làm được hôm nay: module identity mới phát mỗi identity.user.registered.v1, chưa có event kiểu "user updated". Muốn có, phải thêm type vào contracts trước rồi mới viết consumer — đúng thứ tự mà luật kiến trúc số 2 áp đặt.

Tên header khác nhau giữa các nhà cung cấp (Surrogate-Key ở một số CDN, Cache-Tag ở CDN khác và đôi khi chỉ có ở gói trả phí cao). Đọc tài liệu, đừng chép header từ blog.

8.7.2 Soft purge vs hard purge

  • Hard purge — xoá hẳn entry. Request tiếp theo là MISS và phải chờ origin.
  • Soft purge — đánh dấu entry là stale thay vì xoá. Nếu bạn đã đặt stale-while-revalidate, người dùng tiếp theo nhận bản cũ ngay lập tức trong khi CDN làm mới ở phía sau.

Với một nền tảng nội dung, soft purge gần như luôn là lựa chọn đúng: bạn không cần bài viết mới xuất hiện trong 0 mili giây, bạn cần origin không bị dội. Hard purge dành cho việc phải gỡ nội dung ngay — vi phạm bản quyền, dữ liệu lộ nhầm. Đây cũng là tính năng theo nhà cung cấp, không phải cơ chế trong RFC.

8.7.3 Vì sao fingerprint tốt hơn purge

Purge là thao tác vận hành — nó có API, có độ trễ lan truyền tới các PoP (bậc giây tới chục giây, tuỳ nhà cung cấp), có hạn mức gọi, và có thể thất bại im lặng. Trong khoảng thời gian lan truyền đó, hệ thống ở trạng thái không xác định: PoP Hà Nội đã quên, PoP Frankfurt thì chưa.

Fingerprint không có trạng thái nào cả:

<script src="/static/app.9f2a1c.js"></script>

Đổi nội dung → đổi hash → đổi URL → đó là một cache key hoàn toàn mới, chưa ai từng cache. Không có gì phải quên, không có gì để lan truyền, không có API nào để gọi hỏng.

Quy tắc rút ra: cái gì đổi URL được thì đừng purge; purge chỉ dành cho cái không đổi URL được (HTML, JSON của API).

8.8 Cái gì nên và không nên cache — bảng cho dự án community

Đây là phần dễ gây sự cố nhất. Đọc kỹ.

Endpoint Cache ở CDN? Header khuyến nghị Vì sao
GET /posts/{slug} ✅ Ngắn + SWR public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400 + ETag Công khai, giống nhau với mọi người, đọc nhiều hơn ghi hàng nghìn lần
GET /posts (danh sách) ✅ Rất ngắn public, max-age=0, s-maxage=30, stale-while-revalidate=120 size/after trong key; đổi mỗi khi có bài mới
GET /users/{id}/stats ✅ Ngắn public, max-age=0, s-maxage=30 Vốn đã nhất quán cuối cùng (đọc từ read model), cache 30s không làm nó sai thêm
GET /feed (feed cá nhân hoá — endpoint giả định, dự án chưa có; hôm nay feed mới chỉ là fan-out read model trong ARCHITECTURE.md) Không private, no-store Nội dung khác nhau theo từng người. Nếu có edge KV/edge compute thì lưu ở đó theo user id, không lưu ở cache dùng chung
Mọi endpoint có Authorization Không private, no-store Xem cảnh báo bên dưới
POST /posts, DELETE /posts/{id}, POST /posts/{id}/view no-store Không idempotent; CDN mặc định cũng không cache POST/DELETE
Ảnh, CSS, JS có fingerprint ✅ Rất dài public, max-age=31536000, immutable URL đổi khi nội dung đổi
Ảnh do người dùng tải lên (URL cố định) ✅ Vừa public, max-age=300, s-maxage=3600 + purge theo tag khi xoá Không có fingerprint nên cần đường thoát
/healthz, /metrics no-store Cache một health check là biến nó thành thứ vô nghĩa

Một chi tiết dễ trượt khi đọc bảng: mọi dòng cache được đều ghi max-age tường minh bên cạnh s-maxage. Bỏ max-age đi không có nghĩa là "trình duyệt không cache" — RFC 9111 §4.2.2 cho phép cache tự đoán thời hạn khi phản hồi không nói gì (heuristic freshness, thường suy từ Last-Modified). Im lặng ở tầng header là giao quyền quyết định TTL cho người khác.

⚠️ Cache một trang có dữ liệu cá nhân là sự cố bảo mật, không phải bug hiệu năng

Hãy hình dung đúng cơ chế thất bại: người dùng A gọi GET /feed kèm Authorization: Bearer .... Origin trả 200 với feed của A. CDN — nếu header cho phép — lưu bản đó dưới key GET https://community.example/feed. Người dùng B, thậm chí một người chưa đăng nhập, gọi đúng URL đó và nhận HIT: feed của A.

Không có log lỗi nào. Không có exception. cmd/api hoàn toàn khoẻ mạnh — nó chưa từng nhìn thấy request của B. Bạn chỉ biết khi người dùng báo, và lúc đó dữ liệu đã nằm trong ổ đĩa của bao nhiêu PoP thì bạn không đếm được.

Ba lớp phòng thủ, dùng cả ba:

  1. Origin luôn phát Cache-Control: private, no-store cho mọi phản hồi sinh ra từ một request đã xác thực — mặc định là đóng, không phải mặc định là mở.
  2. Ở CDN, khai báo tường minh: request có Authorization hoặc cookie phiên thì bypass cache.
  3. Đưa vào smoketest một kiểm tra thật: gọi một endpoint riêng tư và khẳng định header phản hồi chứa no-store.

Middleware ép quy tắc số 1 ở tầng Go — đặt cạnh middleware.Auth đã có:

package middleware

import (
	"net/http"
	"strings"
)

// PrivateNoStore đảm bảo mọi phản hồi sinh ra từ một request đã xác thực
// không bao giờ được lưu ở cache dùng chung.
//
// Nó đặt header TRƯỚC khi gọi handler để handler vẫn ghi đè được nếu
// endpoint đó thật sự công khai — nhưng mặc định phải là ĐÓNG.
// Quên một dòng ở đây là rò rỉ dữ liệu người dùng, không phải chậm trang.
func PrivateNoStore(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if isAuthenticated(r) {
			w.Header().Set("Cache-Control", "private, no-store")

			// Vary: Authorization là lớp phòng thủ thứ hai. Ngay cả khi
			// ai đó cấu hình CDN bỏ qua no-store, cache key vẫn tách theo token.
			addVary(w.Header(), "Authorization")
		}
		next.ServeHTTP(w, r)
	})
}

func isAuthenticated(r *http.Request) bool {
	if r.Header.Get("Authorization") != "" {
		return true
	}
	// Cookie phiên cũng là bằng chứng xác thực — bỏ sót nhánh này
	// là bỏ sót đúng nửa số request của giao diện web.
	_, err := r.Cookie("session")
	return err == nil
}

// addVary nối thêm một field vào Vary thay vì ghi đè,
// vì handler ở tầng dưới có thể đã đặt Vary: Accept-Encoding.
func addVary(h http.Header, field string) {
	current := h.Get("Vary")
	if current == "" {
		h.Set("Vary", field)
		return
	}
	for _, existing := range strings.Split(current, ",") {
		if strings.EqualFold(strings.TrimSpace(existing), field) {
			return
		}
	}
	h.Set("Vary", current+", "+field)
}

// CacheControl đặt Cache-Control mặc định cho cả một nhóm route.
// Cũng đặt TRƯỚC khi gọi handler, để handler cá biệt vẫn ghi đè được.
func CacheControl(value string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Cache-Control", value)
			next.ServeHTTP(w, r)
		})
	}
}

Gắn vào chi đúng chỗ nhóm route cần đăng nhập đã được khai trong 07-module-post.md:

package post

import (
	"github.com/go-chi/chi/v5"

	"github.com/chuongtd/community/internal/platform/httpx/middleware"
)

func (h *Handler) Routes(r chi.Router, verifier middleware.Verifier) {
	r.Route("/posts", func(r chi.Router) {
		// Nhóm công khai: cho phép cache, và sinh ETag để tiết kiệm băng thông
		// cho các lần revalidate sau khi s-maxage hết hạn.
		r.Group(func(r chi.Router) {
			r.Use(middleware.ETag)
			r.Use(middleware.CacheControl("public, max-age=0, s-maxage=60, " +
				"stale-while-revalidate=600, stale-if-error=86400"))
			r.Get("/", h.handleList)
			r.Get("/{slug}", h.handleGetBySlug)
		})

		// Nhóm cần đăng nhập: PrivateNoStore đứng NGAY SAU Auth,
		// để mọi thứ chạy sau nó đều đã bị đóng cache mặc định.
		r.Group(func(r chi.Router) {
			r.Use(middleware.Auth(verifier))
			r.Use(middleware.PrivateNoStore)
			r.Post("/", h.handleCreate)
			r.Delete("/{id}", h.handleDelete)
			r.Post("/{id}/view", h.handleView)
		})
	})
}

8.9 CDN làm gì ngoài cache

Kể cả khi hit ratio bằng 0, năm thứ sau vẫn có giá trị.

Tính năng Cơ chế Giá trị thật cho dự án này
TLS termination gần người dùng Bắt tay TLS kết thúc ở PoP cách người dùng vài ms thay vì ở origin Bắt tay TLS tốn nhiều vòng RTT (TLS 1.3 — RFC 8446 — rút xuống 1-RTT, có resumption thì 0-RTT). RTT nhỏ đi thì mọi vòng đều rẻ đi. Kết nối PoP→origin được giữ sẵn và tái sử dụng
Nén gzip hoặc Brotli (RFC 7932, Content-Encoding: br) ở edge JSON và HTML nén rất tốt. Nén ở edge giải phóng CPU của cmd/api
Chống DDoS L3/L4 Hấp thụ flood SYN/UDP bằng dung lượng mạng Origin một máy không thể tự chống. Đây là lý do dùng CDN mạnh hơn cả cache
WAF Luật chặn ở tầng ứng dụng trước khi tới origin Có ích, nhưng không thay thế kiểm tra đầu vào trong cmd/api. Xem §13
Tối ưu ảnh Đổi kích thước / định dạng theo thiết bị ngay tại edge Ảnh trong bài viết là phần nặng nhất của trang. Lưu ý: nếu edge đổi nội dung theo request header thì Vary phải phản ánh điều đó, nếu không sẽ phục vụ WebP cho trình duyệt không đọc được

Edge compute — chạy code của bạn ngay tại PoP — là công cụ mạnh nhưng có ràng buộc phải nhớ:

  • Không có trạng thái bền vững cục bộ. Mỗi PoP độc lập. Muốn có trạng thái phải dùng dịch vụ KV/storage của chính nhà cung cấp, và dịch vụ đó thường nhất quán cuối cùng giữa các vùng — nghĩa là bạn lại gặp đúng bài toán ở ARCHITECTURE.md §9.3, lần này ở tầng mạng.
  • Ngân sách CPU rất ngắn. Runtime edge được thiết kế cho vài mili giây CPU mỗi request, không phải cho vòng lặp nặng. Con số cụ thể khác nhau theo nhà cung cấp và theo gói.
  • Không có kết nối bền tới PostgreSQL. Một connection pool ở PoP nghĩa là hàng trăm PoP × pool — vượt xa max_connections của Postgres. Xem lại phần ngân sách kết nối ở 02-nen-mong-platform.md.

Vậy đặt gì ở edge? Việc không cần trạng thái và cần chạy sớm: chuyển hướng, chuẩn hoá URL, đọc/gắn cookie A/B, kiểm tra chữ ký token trước khi cho đi tiếp, viết lại cache key. Nghiệp vụ thật cứ để ở internal/modules/**.

8.10 Đo lường: đọc chỉ số và header

8.10.1 Hit ratio theo request và theo byte

Hai con số khác nhau, đo hai thứ khác nhau, và báo cáo một con số duy nhất là cách tự lừa mình.

Chỉ số Công thức Bị chi phối bởi Trả lời câu hỏi
Theo request số request HIT / tổng request Object nhỏ, nhiều — JSON, HTML Origin của tôi đỡ được bao nhiêu lần xử lý?
Theo byte byte HIT / tổng byte Object lớn, ít — ảnh, video, bundle JS Tôi tiết kiệm được bao nhiêu băng thông và tiền?

Ví dụ điển hình gây hiểu nhầm: hit ratio theo byte 97% nhìn rất đẹp, nhưng nó có thể chỉ nói rằng ảnh đang được cache tốt. Nếu hit ratio theo request chỉ 40%, thì GET /posts/{slug} vẫn đang dội xuống PostgreSQL — đúng thứ bạn dựng CDN để tránh.

Với dự án này, chỉ số cần theo dõi là hit ratio theo request cho nhóm đường dẫn /posts*. Đó là chỉ số phản ánh tải DB.

8.10.2 Ba header cần đọc khi gỡ lỗi

Header Nguồn Đọc thế nào
Age Chuẩn — RFC 9111 Số giây phản hồi đã nằm trong cache. Age: 0 ở một URL đáng lẽ nóng ⇒ vừa MISS hoặc vừa bị purge
X-Cache Quy ước, không chuẩn Thường HIT / MISS; một số CDN ghi thêm tầng (edge/shield)
CF-Cache-Status Riêng của một nhà cung cấp Chi tiết hơn HIT/MISS — có thêm các trạng thái như bypass, expired, revalidated. Tra bảng giá trị trong tài liệu nhà cung cấp, đừng đoán theo tên

Chỉ Age là chuẩn; hai cái còn lại là quy ước — tên và tập giá trị phụ thuộc nhà cung cấp và có thể đổi. Quy trình gỡ lỗi "vì sao URL này không được cache", theo thứ tự:

# Gọi hai lần liên tiếp và chỉ nhìn các header liên quan tới cache.
# Lần 1 thường MISS (nạp vào cache), lần 2 mới nói lên sự thật.
1..2 | ForEach-Object {
    $r = Invoke-WebRequest -Uri https://community.example/posts/di-tim-golang-a3f21b8c -Method Head
    "--- lần $_ ---"
    $r.Headers.GetEnumerator() |
        Where-Object { $_.Key -match 'Cache-Control|Age|X-Cache|Vary|ETag|Set-Cookie' } |
        ForEach-Object { "{0}: {1}" -f $_.Key, ($_.Value -join ', ') }
}
# Bản bash tương đương
for i in 1 2; do echo "--- lần $i ---"; curl -sSI https://community.example/posts/di-tim-golang-a3f21b8c \
  | grep -iE 'cache-control|^age|x-cache|^vary|etag|set-cookie'; done

Đọc kết quả theo cây quyết định này:

  1. Set-Cookie trong phản hồi? → Nhiều CDN mặc định không cache phản hồi có Set-Cookie, vì nó thường mang trạng thái riêng của một người. Đây là nguyên nhân số một của "sao cache không chạy".
  2. Cache-Controlprivate / no-store / no-cache? → Origin đang tự cấm. Sửa ở cmd/api, không sửa ở CDN.
  3. s-maxage không? Nếu chỉ có max-age, kiểm tra xem cấu hình CDN có bị đặt TTL riêng đè lên không.
  4. Vary liệt kê header gì? Nếu có Cookie hoặc User-Agent → key bị phân mảnh, hit ratio sẽ luôn thấp.
  5. Age tăng dần qua các lần gọi? → Đang HIT thật. Age luôn bằng 0 → mỗi lần là một entry mới, quay lại bước 4 và mục 8.3.1.

8.11 Bẫy kinh điển

1. Đặt CDN trước API động rồi khai Vary sai. Đây là bẫy nguy hiểm nhất vì hậu quả là phục vụ nhầm người. Xem lại 8.4 và khung cảnh báo ở 8.8. Nguyên tắc an toàn: nội dung nào có thể khác nhau giữa hai người thì hoặc là private, no-store, hoặc là phải chứng minh được cache key đã tách hai người đó ra — không có phương án thứ ba.

2. Quên s-maxage. Bạn đặt max-age=60 vì muốn CDN giữ 60 giây, nhưng bạn quên rằng trình duyệt cũng nghe max-age. Trong 60 giây đó, mọi lần điều hướng bình thường — bấm một link, back/forward, mở lại tab — đều lấy từ đĩa máy người dùng và không hề rời khỏi máy họ, kể cả sau khi bạn đã purge sạch CDN. Đừng kiểm tra bằng cách bấm F5: reload thường buộc trình duyệt revalidate (nó gửi kèm Cache-Control: max-age=0), nên bạn sẽ thấy bản mới và tưởng không có vấn đề gì — chính cái bẫy này bị reload che mất. Mẫu đúng cho nội dung động: max-age=0 (trình duyệt luôn hỏi lại) + s-maxage=N (CDN giữ N giây) + stale-while-revalidate (không ai phải chờ).

3. Coi purge là tức thời. Purge lan truyền tới các PoP mất thời gian, và có nhà cung cấp giới hạn số lần gọi. Đừng thiết kế luồng nghiệp vụ kiểu "user bấm Lưu → purge → tải lại trang và mong thấy bản mới". Nếu cần thấy ngay bản mới của chính mình, hãy để request của người đó bypass cache (họ đã đăng nhập, nên đã private, no-store), hoặc đổi URL.

4. Purge toàn bộ để "cho chắc". Xoá sạch cache lúc cao điểm biến CDN thành công cụ tấn công origin của chính bạn. Mọi PoP MISS cùng lúc, cmd/api nhận toàn bộ lưu lượng thật, PostgreSQL hết kết nối. Dùng purge theo tag (8.7.1) hoặc soft purge (8.7.2).

5. Vary: Accept-Encoding bị bỏ quên khi tự nén ở origin. Nếu cmd/api tự nén Brotli mà không khai Vary: Accept-Encoding, một cache trung gian có thể phục vụ body Brotli cho client chỉ hiểu gzip. Client nhận về rác nhị phân. Cách an toàn nhất là để CDN nén và để origin trả nội dung thô — bớt một chỗ để sai.

6. Cache 4045xx quá lâu. Bài viết chưa publish trả 404, CDN cache 5 phút, tác giả publish xong vẫn thấy 404 và tưởng hệ thống hỏng. Điểm ít người biết: 404 nằm trong danh sách status code cache được theo heuristic (RFC 9110 §15.1, cùng nhóm với 200, 301, 410), nên cache có quyền giữ nó ngay cả khi bạn không phát Cache-Control nào — im lặng ở đây không có nghĩa là "không cache". 5xx thì ngược lại: chỉ được lưu khi header cho phép tường minh. Cache lỗi là hợp lệ và có ích (chống dò URL), nhưng TTL phải rất ngắn — bậc vài giây tới vài chục giây — và phải đặt tường minh thay vì để mặc heuristic.

7. "CDN che mất IP thật." Sau khi bật CDN, r.RemoteAddr trong cmd/api là IP của PoP. Rate limit theo IP sẽ gộp cả một PoP thành một "người dùng", log truy cập mất giá trị điều tra, và chặn IP thành chặn cả vùng.

Cách lấy IP đúng không phải là đọc bừa X-Forwarded-For — header đó do client gửi được và giả mạo được. Cơ chế đúng gồm hai vế: chỉ tin header khi kết nối đến thật sự xuất phát từ dải IP của CDN, và cấu hình origin chỉ nhận kết nối từ dải đó. Toàn bộ chi tiết — X-Forwarded-For nhiều hop, Forwarded của RFC 7239, header riêng của từng nhà cung cấp, và cách cài đặt trusted-proxy trong Go — nằm ở §4.

Đi kèm là vấn đề song sinh của nó: CDN chỉ che IP origin nếu origin thật sự không tiếp ai khác. Nếu kẻ tấn công tìm ra IP origin — qua lịch sử DNS, qua bản ghi máy chủ mail cùng tên miền, qua log Certificate Transparency, hay đơn giản là qua một thông báo lỗi lộ hostname nội bộ — thì họ đi thẳng vào origin và toàn bộ WAF, rate limit, chống DDoS ở CDN thành trang trí. Biện pháp: firewall chỉ cho phép dải IP của CDN, cộng thêm một header bí mật do CDN chèn và origin bắt buộc kiểm tra. Chi tiết ở §13.

8. Nhầm CDN với API Gateway. CDN tối ưu cho phân phối nội dung ở biên. Xác thực, định tuyến theo phiên bản API, quota theo khách hàng, gộp nhiều dịch vụ — đó là việc của tầng khác, xem §9. Nhét logic nghiệp vụ vào cấu hình CDN là tạo ra một phần hệ thống không nằm trong repo, không có test, và không ai review.


9. API Gateway và Service Mesh — proxy đi vào trong hệ thống

Tới đây tài liệu đã đi hết phần proxy ở bìa hệ thống: reverse proxy đứng trước một cụm server (§5), CDN đứng trước cả reverse proxy đó (§8). Mục này nói về chuyện xảy ra tiếp theo trong đời một hệ thống: proxy đi vào bên trong. Có hai bước đi vào, và chúng khác nhau về bản chất chứ không chỉ về mức độ:

  1. API gateway — vẫn đứng ở bìa, nhưng thôi không còn chỉ chuyển tiếp byte. Nó bắt đầu hiểu API: ai đang gọi, gọi phiên bản nào, còn được gọi bao nhiêu lần nữa.
  2. Service mesh — proxy nhân bản ra, mỗi tiến trình ứng dụng một cái, để quản lý lời gọi giữa các service với nhau.

Bước 1 phần lớn dự án sớm muộn cũng cần. Bước 2 thì không — và phần lớn nội dung mục này dùng để giải thích vì sao dự án community chưa nên đi bước 2, cùng với thứ duy nhất của mesh đáng bắt chước bằng tay ngay từ bây giờ.

9.1 Ba khái niệm hay bị trộn

Ba cái tên này bị dùng lẫn lộn nhiều tới mức trong một cuộc họp, ba người nói "gateway" có thể đang nghĩ về ba thứ khác nhau. Bắt đầu bằng vị trí vật lý của chúng:

     NORTH-SOUTH  (client ↔ hệ thống)
 ───────────────────────────────────────────────────────►

  Browser ──► CDN ──► Reverse proxy ──► API Gateway ──► cmd/api
              §8          §5                §9.2           │
                                                           │  EAST-WEST
                                                           │  (service ↔ service)
                                                           ▼
                                                    search / media / …
                                                           ▲
                                            service mesh sống ở đây (§9.4)
Tiêu chí Reverse proxy API gateway Service mesh
Câu hỏi nó trả lời "Request này gửi tới máy nào?" "Người gọi này là ai, được gọi API nào, còn bao nhiêu lượt?" "Hai service nói chuyện với nhau sao cho an toàn và bền?"
Hướng lưu lượng North-south North-south East-west (chủ yếu)
Tầng L4 hoặc L7 L7, hiểu ngữ nghĩa API L4 + L7, trong suốt với ứng dụng
Đơn vị cấu hình host / path → upstream API, phiên bản, consumer, plan/quota cặp service → service, chính sách theo namespace
Số bản chạy Vài Vài Bằng số tiến trình ứng dụng
Ai cấu hình SRE / đội hạ tầng Đội platform + chủ sở hữu từng API Đội platform, qua control plane
Trạng thái nó giữ Gần như không (trừ cache, sticky) Danh tính consumer, bộ đếm rate limit/quota Bảng endpoint, chứng chỉ, số liệu
Ứng dụng có phải sửa code không Không Không (nhưng phải hiểu nó tồn tại) Không — vẫn phải trả giá vận hành

Phép thử một câu để phân định

"Cái proxy này chạy bao nhiêu bản — vài bản, hay đúng bằng số tiến trình ứng dụng?"

Vài bản → reverse proxy hoặc API gateway. Đúng bằng số tiến trình → service mesh. Đây là ranh giới duy nhất không bao giờ mờ, vì nó là ranh giới về cách triển khai, không phải về tính năng. Tính năng thì chồng lấn khắp nơi: Envoy làm được cả ba vai; Nginx cộng vài module thì gần như là gateway; một gateway đặt trước một service duy nhất thì đúng là reverse proxy.

Phân định thứ hai, giữa reverse proxy và gateway: "nó có cần biết ai đang gọi không?" Reverse proxy chỉ cần biết địa chỉ đích. Gateway cần biết danh tính người gọi — API key nào, ứng dụng nào, gói cước nào — vì mọi quyết định của nó (rate limit, quota, được gọi endpoint nào) đều treo vào danh tính đó.

9.2 API gateway làm gì mà reverse proxy thường không

Cần nói rõ chữ "thường": Nginx hay HAProxy có thể làm phần lớn danh sách dưới đây bằng module, script Lua hoặc map file. Khác biệt không nằm ở chỗ "làm được hay không" mà ở chỗ cái gì là công dân hạng nhất trong mô hình cấu hình. Trong Nginx, đơn vị cấu hình là location. Trong một API gateway, đơn vị cấu hình là API, phiên bản, consumerplan — bạn không phải tự dựng bốn khái niệm đó bằng biến và if. Một nhầm lẫn hay gặp nữa, lần này trong Kubernetes: Ingress controller không phải API gateway. Ingress — và bản kế nhiệm của nó, Gateway API — chỉ mô tả việc định tuyến host/path vào Service, đúng phần việc của reverse proxy ở §5; muốn có consumer, quota hay developer portal thì vẫn phải cắm thêm một gateway thật, dù bên dưới nó cũng chạy Envoy.

Năng lực Nội dung Vì sao gateway hợp hơn
Xác thực tập trung Kiểm chữ ký JWT, introspect token OAuth2/OIDC, đổi API key → danh tính Mọi service phía sau khỏi mỗi nơi một cách kiểm; quay khoá ở một chỗ
Rate limit theo API key Giới hạn theo consumer, không phải theo IP IP là danh tính rất tệ sau NAT/CGNAT — xem §4
Quota Ngân sách theo ngày/tháng, có trạng thái, có thể mua thêm Cần lưu trữ dùng chung giữa các bản gateway (Redis hoặc tương đương)
Định tuyến theo phiên bản API /v1/v2 trỏ tới hai deployment khác nhau; hoặc theo header Accept Cho phép chạy song song hai phiên bản mà app không cần biết
Request/response transformation Thêm/bớt header, đổi tên field, đổi vỏ lỗi Giữ hợp đồng cũ cho client cũ trong lúc bên trong đã đổi
Aggregation Một request client → nhiều lời gọi nội bộ → một response gộp Tiết kiệm vòng mạng cho client di động
Developer portal Tài liệu OpenAPI, tự đăng ký, tự sinh khoá, xem mức dùng sản phẩm cho bên thứ ba, không phải cấu hình proxy

Aggregation là con dao hai lưỡi — cân nhắc kỹ nhất trong bảng trên

Gộp ba lời gọi thành một nghe rất hời cho client. Nhưng đoạn code gộp đó phải biết: gọi cái nào trước, cái nào song song, một cái lỗi thì trả lỗi cả cụm hay trả một phần, hết hạn thì sao. Đó là logic. Và nó nằm ở một nơi không có test tích hợp, không có debugger, không có kiểu dữ liệu, thường viết bằng ngôn ngữ khác với phần còn lại của hệ thống. Nếu bạn cần aggregation thật, hãy cân nhắc viết nó thành một service Go bình thường (mẫu "backend for frontend") thay vì nhét vào gateway — vẫn là một hop mạng, nhưng là một hop bạn test được, log được, và deploy bằng cùng quy trình với mọi thứ khác.

9.3 Câu hỏi phân định: cái gì ở gateway, cái gì phải ở trong ứng dụng

Đây là mục quan trọng nhất của cả phần 9 đối với dự án này. Danh sách năng lực ở trên rất hấp dẫn, và cái bẫy luôn xuất hiện theo cùng một cách: "cái này gateway làm được mà, đỡ phải sửa code Go". Nguyên tắc gồm hai vế, và vế thứ hai mới là vế quan trọng:

Gateway làm được thì để gateway làm — nhưng một QUYẾT ĐỊNH NGHIỆP VỤ thì không bao giờ được rời khỏi ứng dụng.

Phép thử để biết mình đang ở vế nào:

Nếu một request đi thẳng vào cmd/api, bỏ qua gateway hoàn toàn — điều gì trở nên sai?

  • Câu trả lời là "hệ thống bị quá tải / lộ endpoint nội bộ / thiếu header tiện lợi" → gateway là chỗ đúng.
  • Câu trả lời là "dữ liệu bị lộ / ghi sai / người này sửa được bài của người khác"thứ đó phải nằm trong ứng dụng, bất kể gateway có làm hộ hay không.

Vế thứ hai không phải chuyện giả định. Ngày nào đó sẽ có một job chạy trong cùng mạng gọi thẳng cmd/api; sẽ có một pod cấu hình sai; sẽ có người mở port ra để debug rồi quên đóng. Mọi kiểm tra chỉ tồn tại ở gateway đều biến mất trong các tình huống đó — âm thầm, và không có log nào báo là kiểm tra đã bị bỏ qua.

Việc Đặt ở đâu Lý do
Chấm dứt TLS, HTTP/2, HTTP/3 Gateway Thuần vận chuyển, ứng dụng không cần biết
CORS, security header Gateway Chính sách toàn tổ chức, đổi không cần deploy app
Rate limit theo IP / API key Gateway Cần chặn trước khi tốn tài nguyên của app
Chặn body quá lớn, header quá dài Gateway Bảo vệ app khỏi thứ nó không nên phải xử lý
Định tuyến /v1 → deployment cũ Gateway Thuần cú pháp đường dẫn
Kiểm chữ ký + hạn JWT Cả hai Gateway chặn sớm; app kiểm lại vì app không được tin ai gọi nó
"User X có được sửa bài Y không" App Muốn trả lời phải đọc bảng posts — xem bên dưới
"User chưa xác minh email chỉ đăng được N bài/ngày" App Đây là quota nghiệp vụ, không phải rate limit hạ tầng
Ẩn bớt field trong response theo quyền App Quyền đọc field là quy tắc miền, không phải biến đổi cú pháp
Idempotency-Key khi tạo bài App Cần transaction và bảng chống trùng, xem ARCHITECTURE.md §8
Ghi outbox cùng transaction App Luật 3 của kiến trúc, không thể tồn tại ngoài app

Vì sao "bài này có phải của bạn không" không bao giờ ra được gateway

Gateway biết "token này hợp lệ và thuộc về user X" — đó là xác thực (authentication), thuần tuý mật mã, không cần biết gì về miền nghiệp vụ. Gateway không biết bài viết Y là của ai, vì muốn biết thì phải đọc bảng posts. Cho gateway đọc DB của module post là phá Luật 1 theo đường vòng: gateway trở thành một thành phần của module post nhưng nằm ngoài repo, ngoài test, ngoài kiểm tra ranh giới depguardARCHITECTURE.md §13.4.

Chỗ đúng của quyết định đó là tầng service:

// internal/modules/post/service/update.go
package service

import (
	"context"
	"errors"
	"fmt"
)

// Tách ErrForbidden khỏi ErrNotFound để transport chọn được 403 hay 404.
// service KHÔNG biết mã HTTP là gì — đó là việc của transport (ARCHITECTURE §4.3).
var (
	ErrNotFound  = errors.New("post: không tìm thấy")
	ErrForbidden = errors.New("post: không phải tác giả")
)

type Post struct {
	ID       string
	AuthorID string
	Title    string
}

// Interface do bên GỌI định nghĩa: service khai báo nó cần gì. Nhờ vậy test
// nghiệp vụ chạy được mà không cần DB.
type Repository interface {
	FindByID(ctx context.Context, id string) (Post, error)
	UpdateTitle(ctx context.Context, id, title string) error
}

type Service struct{ repo Repository }

func New(repo Repository) *Service { return &Service{repo: repo} }

// UpdateTitle là ví dụ mẫu của quyết định KHÔNG BAO GIỜ rời ra gateway được:
// actorID thì gateway cấp được, nhưng phép so sánh p.AuthorID != actorID cần
// một lượt đọc DB, nên nơi duy nhất trả lời được là nơi sở hữu bảng posts.
func (s *Service) UpdateTitle(ctx context.Context, actorID, postID, title string) error {
	p, err := s.repo.FindByID(ctx, postID)
	if err != nil {
		return fmt.Errorf("update title %s: %w", postID, err)
	}
	if p.AuthorID != actorID {
		// Chặn trước khi ghi. Nếu kiểm tra này nằm ở gateway, bất kỳ ai gọi
		// được thẳng cmd/api đều sửa được bài của người khác.
		return ErrForbidden
	}
	return s.repo.UpdateTitle(ctx, postID, title)
}

JWT: gateway kiểm rồi, app vẫn phải kiểm

Dự án đã có sẵn middleware.Auth với interface Verifier04-module-identity.md §6.3 và bộ ký/kiểm JWT ở §8. Khi thêm gateway, cám dỗ đầu tiên là: gateway đã verify JWT rồi, app chỉ cần đọc header X-User-Id do gateway gắn vào. Cách đó không sai về nguyên tắc — nhiều hệ thống lớn vận hành đúng như vậy. Nhưng nó chỉ an toàn kèm đúng một điều kiện, và điều kiện đó rất hay bị bỏ quên:

cmd/api phải không thể tiếp cận được từ bất kỳ đâu ngoài gateway.

Không giữ được điều đó thì X-User-Id chỉ là một header, và bất kỳ ai chạm được vào cổng của cmd/api đều tự xưng là bất kỳ ai. Đây đúng là bài toán tin cậy header ở §4 lặp lại nguyên vẹn ở tầng khác: một header chỉ đáng tin bằng đúng cái ranh giới mạng đứng trước nó.

Với quy mô hiện tại, khuyến nghị là giữ nguyên middleware.Auth trong app và để gateway kiểm JWT thêm lần nữa như một lớp chặn sớm. Kiểm chữ ký HS256 hai lần là chi phí CPU không đáng kể; đổi lại cmd/api an toàn kể cả khi ai đó dựng nó chạy trần. Nếu về sau muốn bỏ lần kiểm thứ hai, hãy đổi cách chứng minh danh tính từ header sang mTLS — xem §9.7.

⚠️ Cảnh báo: gateway biến thành một monolith thứ hai

Chuyện này không xảy ra trong một ngày. Nó xảy ra qua mười lần "chỉ thêm một cái này nữa thôi": một đoạn Lua ghép hai response, một if kiểm quyền admin, một bảng ánh xạ mã lỗi, một chỗ đổi tên field cho app di động bản cũ. Mỗi lần đều hợp lý. Sau một năm bạn có một hệ thống thứ hai — không repo, không test, không code review, và chỉ một người biết nó chạy thế nào.

Bốn dấu hiệu bạn đã ở trong đó: (1) ship một tính năng phải sửa cả code Go cấu hình gateway, đúng thứ tự, sai thì hỏng; (2) có logic trong gateway mà cách duy nhất kiểm chứng là bấm thử trên production; (3) gateway cần kết nối tới database hoặc cache riêng của một module; (4) khi có bug, câu đầu tiên phải hỏi là "logic này nằm ở đâu?" thay vì "logic này sai chỗ nào?".

Liều thuốc phòng: cấu hình gateway nằm trong Git, qua code review, và luôn trả lời được "đoạn này thuần cú pháp hay đang quyết định nghiệp vụ?". Nếu là vế sau, chỗ của nó là Go.

9.4 Service mesh — sidecar, data plane và control plane

Ý tưởng của service mesh: mọi thứ khó về mạng — TLS lẫn nhau, retry, timeout, circuit breaker, load balancing, đo đạc — bị lôi ra khỏi code ứng dụng và đẩy vào một proxy chạy cạnh từng tiến trình. Ứng dụng cứ gọi http://search:8080 như thể mạng là phẳng và chẳng có gì đặc biệt; proxy bên cạnh chặn kết nối đó lại và làm phần khó. Trong Kubernetes, proxy đó là một container phụ trong cùng pod, gọi là sidecar, và lưu lượng bị iptables/eBPF chuyển hướng vào nó. Envoy là data plane phổ biến nhất (Istio, Consul dùng Envoy; Linkerd dùng proxy Rust riêng của nó).

  CONTROL PLANE  — không nằm trên đường đi của request
  ┌──────────────────────────────────────────────────┐
  │  biết: có service nào, endpoint nào còn sống,    │
  │        chính sách gì, chứng chỉ của ai           │
  └──────┬────────────────────────────────┬──────────┘
         │ xDS (gRPC, đẩy cấu hình)       │ xDS + chứng chỉ
         ▼                                ▼
  ┌──────────────────┐            ┌──────────────────┐
  │ Pod A            │            │ Pod B            │
  │  app ──► sidecar │═══ mTLS ══►│ sidecar ──► app  │
  │       (localhost)│            │  (localhost)     │
  └──────────────────┘            └──────────────────┘
  DATA PLANE — nằm trên đường đi của MỌI byte

Hai mặt phẳng này tách nhau vì lý do rất thực tế: control plane chết thì hệ thống vẫn chạy, chỉ là ngừng cập nhật cấu hình. Sidecar giữ bản cấu hình cuối cùng nó nhận được và tiếp tục phục vụ. Nếu control plane nằm trên đường đi của request, nó thành điểm chết chung của toàn hệ thống. Đừng đọc câu đó thành "chết cũng không sao": pod mới khởi động trong lúc đó không lấy được cấu hình lẫn chứng chỉ, và chứng chỉ ngắn hạn của pod đang chạy sẽ hết hạn — hệ thống chịu được control plane chết trong bao lâu là một con số bạn phải tự đo, không phải vô hạn.

xDS — giao thức cấu hình động

xDS là họ API mà control plane dùng để đẩy cấu hình xuống Envoy. Điểm cốt lõi: cấu hình được đẩy động qua gRPC, không phải file trên đĩa cần reload. Thêm một pod thì bảng endpoint cập nhật trong vài giây, không ai phải sửa file hay khởi động lại proxy.

API Tên đầy đủ Trả lời câu hỏi
LDS Listener Discovery Service Proxy mở cổng nào, mỗi cổng chạy chuỗi filter nào
RDS Route Discovery Service Một request HTTP với host/path này thì đi tới cluster nào
CDS Cluster Discovery Service Có những nhóm upstream nào, chính sách LB / timeout / outlier ra sao
EDS Endpoint Discovery Service Mỗi cluster hiện có những IP:port nào còn sống
SDS Secret Discovery Service Chứng chỉ và khoá riêng — nạp động, không restart
ADS Aggregated Discovery Service Gộp tất cả trên một luồng gRPC để cấu hình không cập nhật lệch pha nhau

Thứ tự LDS → RDS → CDS → EDS chính là thứ tự Envoy trả lời câu hỏi "byte này đi đâu": nhận ở listener, khớp route, chọn cluster, chọn endpoint. Hiểu bốn chữ này thì đọc log và dump cấu hình Envoy sẽ dễ hơn hẳn — chi tiết cú pháp thuộc §11.

Bốn thứ mesh cho gần như miễn phí

1. mTLS tự động và xoay chứng chỉ. Mọi kết nối giữa hai sidecar được mã hoá và xác thực hai chiều. Control plane cấp chứng chỉ ngắn hạn cho từng danh tính workload và tự xoay trước khi hết hạn, qua SDS, không restart tiến trình nào. Đây là thứ khó tự làm lại nhất§9.7 mổ xẻ riêng.

2. Timeout, retry, circuit breaker ngoài code. Chính sách "gọi service search timeout 2 giây, retry 2 lần với lỗi kết nối, ngắt mạch khi tỉ lệ lỗi vượt ngưỡng" trở thành cấu hình, đổi được mà không deploy lại ứng dụng, và áp dụng đồng nhất cho mọi ngôn ngữ trong hệ thống.

3. Traffic shifting. Vì sidecar quyết định endpoint nào nhận request, nó chia được lưu lượng theo tỉ lệ:

  canary                          mirror (shadow)
  ┌────────┐  95%  ┌──────┐       ┌────────┐  100%  ┌──────┐
  │ sidecar├──────►│ v1   │       │ sidecar├───────►│ v1   │ ◄── response
  │        │   5%  ├──────┤       │        │        ├──────┤     trả về client
  │        ├──────►│ v2   │       │        ├ ─ ─ ─ ─┤ v2   │ ◄── bản sao,
  └────────┘       └──────┘       └────────┘  copy  └──────┘     response BỎ ĐI

4. Observability đồng nhất. Sidecar nhìn thấy mọi request L7 giữa mọi cặp service, nên nó phát ra số liệu tỉ lệ lỗi và độ trễ cho toàn bộ hệ thống mà không ai phải thêm dòng code nào. Lưu ý cái nó không cho: sidecar tự nó không nối được các span thành một trace — muốn có trace liền mạch thì ứng dụng vẫn phải truyền tiếp header ngữ cảnh của lời gọi vào lời gọi tiếp theo.

⚠️ Hai cái bẫy sinh sự cố thật nhiều nhất của mesh

1 — Retry nhân bội. Mesh retry 3 lần. Code của bạn cũng retry 3 lần vì nó viết trước khi có mesh. Gateway ở bìa retry thêm 2 lần. Một lời gọi của người dùng thành tối đa 3 × 3 × 2 = 18 lời gọi tới cùng một service. Vô hại khi hệ thống khoẻ vì retry gần như không kích hoạt; nó chỉ kích hoạt khi service đích đang chậm — đúng lúc bạn nhân tải lên hàng chục lần vào thứ vốn đã sắp gục. Luật: retry chỉ được có ở đúng MỘT tầng — chọn tầng đó, tắt hai tầng còn lại, và viết ra giấy tầng nào chịu trách nhiệm.

2 — Mesh không biết request nào an toàn để retry. Sidecar chỉ thấy method và mã lỗi. Với một POST /posts bị timeout, nó không thể biết bài viết đã ghi vào DB hay chưa — timeout không có nghĩa là "chưa xảy ra". Retry mù thì người dùng có hai bài giống hệt nhau. Cách đúng duy nhất là ứng dụng làm cho thao tác idempotent (Idempotency-Key + bảng chống trùng) rồi mới bật retry — chính là mẫu ARCHITECTURE.md §8 đã dựng cho consumer Kafka. Hạ tầng không thay được tính chất này của ứng dụng, nó chỉ khuếch đại hậu quả nếu thiếu. Tương tự với mirror: nếu bản shadow nối vào database thật hay Kafka thật, bạn vừa nhân đôi mọi lệnh ghi.

9.5 Sidecar-less và ambient mesh — proxy theo node

Mô hình sidecar có một chi phí không giấu được: mỗi pod thêm một tiến trình proxy. Một nghìn pod là một nghìn Envoy, mỗi cái ăn RAM và CPU, mỗi cái phải nâng cấp, và nâng cấp proxy nghĩa là restart pod ứng dụng.

Hướng "ambient" tách việc đó ra làm hai lớp: một proxy chạy theo node lo tầng L4 (mTLS, danh tính, chính sách theo cặp service), và một proxy L7 chỉ dựng khi thật sự cần các tính năng L7 cho một nhóm service. Trong Istio, hai thành phần này tên là ztunnel (theo node) và waypoint (L7). Một số hệ thống đi xa hơn bằng eBPF để bỏ bớt lượt qua không gian người dùng.

Tiêu chí Sidecar (mỗi pod một proxy) Per-node / ambient
Số bản proxy Bằng số pod Bằng số node (+ proxy L7 khi cần)
Tài nguyên Nhân theo pod Nhân theo node — ít hơn nhiều khi mật độ pod cao
Bán kính ảnh hưởng khi proxy lỗi Một pod Mọi pod trên node đó
Nâng cấp proxy Restart từng pod ứng dụng Nâng cấp theo node, không đụng pod
Cách ly giữa các tenant Proxy nằm trong pod của app Proxy dùng chung — cần tin cậy lớp node hơn
Tính năng L7 Luôn có sẵn Chỉ có ở nơi đã dựng thêm lớp L7
Độ chín và tài liệu Lâu năm, nhiều kinh nghiệm vận hành Mới hơn, mô hình còn đang thay đổi

Kết luận thực dụng: nếu bạn đang đọc mục này để quyết định cho một hệ thống chưa có mesh, thì lựa chọn giữa sidecar và ambient chưa phải câu hỏi của bạn. Câu hỏi của bạn là mục tiếp theo.

9.6 Khi nào KHÔNG dùng service mesh

Mục này viết thẳng vì nó là kết luận thực tế cho dự án community. Dự án này là modular monolith với ba binary: cmd/api, cmd/worker, cmd/relay. Có một lý lẽ đặc thù mạnh hơn mọi lý lẽ về chi phí:

Lưu lượng east-west của kiến trúc này gần như bằng không.

api không gọi worker. worker không gọi api. relay không gọi ai cả. Ba tiến trình này nói chuyện qua PostgreSQL và Kafka — đúng theo Luật 2: event là hợp đồng, không phải lời gọi hàm.

Service mesh tồn tại để quản lý lời gọi HTTP/gRPC giữa các service. Kiến trúc này cố ý không có loại lời gọi đó. Dựng mesh ở đây là đặt trạm kiểm soát trên một con đường không có xe chạy — trong khi hai con đường thật sự có lưu lượng, Postgres và Kafka, đều có cơ chế TLS và xác thực riêng và thường nằm ngoài mesh.

Còn lại là chi phí, và nó có thật:

Mesh thêm vào Cái giá cụ thể
Một proxy cạnh mỗi tiến trình Thêm RAM/CPU nhân theo số pod; thêm một tiến trình có thể chết độc lập
Một control plane Thêm một hệ thống phân tán phải chạy HA, nâng cấp, vá lỗi, và nó chết thì bạn phải biết chuyện gì xảy ra tiếp
Hai lượt qua proxy mỗi lời gọi Thêm độ trễ ở mỗi hop (con số thật phụ thuộc cấu hình và tải — phải tự đo, đừng chép số từ blog)
Một tầng nữa để gỡ lỗi Một 503 giờ có thể tới từ app, từ sidecar bên gửi, hoặc từ sidecar bên nhận
Ràng buộc thứ tự khởi động Ứng dụng khởi động xong trước sidecar → những lời gọi đầu tiên thất bại; kinh điển và rất khó chẩn đoán
Một chuỗi công cụ mới CRD, CLI, dump cấu hình Envoy, log truy cập riêng — cả đội phải học

Ngưỡng hợp lý — khi nào mesh bắt đầu trả lãi. Không có con số ma thuật; hãy đếm xem bao nhiêu điều kiện dưới đây đang đúng, và chỉ cân nhắc khi có nhiều điều đúng cùng lúc:

  • Có nhiều service thật sự gọi nhau qua HTTP/gRPC — nhiều tới mức không ai trong đội vẽ được toàn bộ đồ thị phụ thuộc từ trí nhớ (bậc độ lớn: hàng chục service, không phải ba).
  • Nhiều đội sở hữu các phần khác nhau, không thể ép mọi đội cùng nâng cấp một thư viện client.
  • Nhiều ngôn ngữ — viết lại logic retry/timeout/mTLS ba lần cho ba stack là công việc lặp lại thật sự.
  • Có yêu cầu tuân thủ bắt buộc mã hoá mọi lưu lượng nội bộ, và cần chứng minh điều đó bằng bằng chứng.
  • Đã có nhu cầu vận hành cụ thể mà tầng deploy không đáp ứng nổi: canary tự động theo số liệu, mirror sang môi trường shadow.
  • Đã có sẵn một đội vận hành Kubernetes chín. Mesh không tự vận hành; nó thêm việc cho một đội đang tồn tại.

Nếu chỉ đúng một hai điều, câu trả lời là chưa. Đây đúng tinh thần của ARCHITECTURE.md §15.2: tách microservice chỉ khi có lý do vận hành cụ thể — cần scale riêng, cần ngôn ngữ khác, cần đội khác sở hữu. Service mesh nằm ở cùng một loại quyết định, và đáng nhắc lại nguyên câu:

Chọn độ phức tạp khi có lý do vận hành cụ thể. Chọn vì "nghe hiện đại hơn" là cách nhanh nhất để biến một hệ thống chạy tốt thành một hệ thống phân tán khó gỡ.

Làm gì thay vì mesh, ngay bây giờ

Điều dễ chịu là gần như mọi giá trị của mesh ở quy mô này đều lấy được bằng thứ đã có sẵn:

Mesh hứa gì Cách làm trong dự án này
mTLS giữa các service Làm tay đúng chỗ cần, bằng crypto/tls§9.7. Postgres và Kafka dùng TLS riêng của chúng
Timeout context.WithTimeout theo từng lời gọi + http.Client cấu hình đúng (code dưới)
Retry và DLQ Đã có ở phía consumer, ARCHITECTURE.md §11
Circuit breaker Timeout ngắn + MaxConnsPerHost đã chặn được phần lớn hiệu ứng domino; thêm thư viện breaker khi đo được là cần
Load balancing DNS round-robin, hoặc reverse proxy ở §5
Số liệu và trace log/slog + OpenTelemetry trong app, ARCHITECTURE.md §11.4
Traffic shifting Chia trọng số ở reverse proxy (§5), hoặc blue-green ở tầng deploy
// internal/platform/httpx/client.go
package httpx

import (
	"crypto/tls"
	"net"
	"net/http"
	"time"
)

// NewInternalClient là "service mesh của người nghèo": phần lớn giá trị mà mesh
// hứa hẹn ở quy mô này nằm gọn trong mấy con số dưới đây. Đối chiếu với
// http.DefaultClient: nó KHÔNG có timeout tổng, và DefaultTransport chỉ chặn khâu
// dial/bắt tay TLS chứ không chặn lúc đọc response — upstream treo là goroutine treo.
//
// Mọi con số ở đây là điểm khởi đầu để đo lại, không phải hằng số phổ quát.
func NewInternalClient(tlsCfg *tls.Config, overall time.Duration) *http.Client {
	tr := &http.Transport{
		DialContext: (&net.Dialer{
			// Timeout bắt tay TCP phải NGẮN hơn nhiều timeout cả request: một
			// địa chỉ chết cần bị loại nhanh, còn một request chậm thì không.
			Timeout:   2 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
		TLSClientConfig:     tlsCfg,
		TLSHandshakeTimeout: 5 * time.Second,
		// Chặn upstream trả header chậm mà vẫn giữ kết nối mở — dạng chết
		// mà TCP không phát hiện được vì kết nối vẫn "sống".
		ResponseHeaderTimeout: 10 * time.Second,
		// MaxConnsPerHost mới là thứ chặn số kết nối ĐỒNG THỜI (vượt hạn mức thì
		// dial bị chặn lại): thiếu nó, lúc bị dồn tải một instance mở hàng nghìn
		// kết nối vào đúng service đang yếu nhất. "Circuit breaker" thô sơ là đây.
		MaxConnsPerHost: 128,
		// MaxIdleConnsPerHost KHÔNG giới hạn tổng số kết nối — nó chỉ giữ lại bao
		// nhiêu kết nối RỖI để dùng lại; mặc định 2, quá ít cho lời gọi nội bộ.
		MaxIdleConnsPerHost: 64,
		IdleConnTimeout:     90 * time.Second,
		// Tự đặt TLSClientConfig thì Go KHÔNG bật HTTP/2 giúp nữa — phải xin lại.
		ForceAttemptHTTP2: true,
	}
	return &http.Client{
		Transport: tr,
		// Timeout của Client bao trọn cả việc đọc body — hàng rào cuối, KHÔNG
		// thay thế được context.WithTimeout đặt theo từng lời gọi.
		Timeout: overall,
	}
}

9.7 mTLS — thứ duy nhất của mesh khó tự làm lại

Trong toàn bộ danh sách tính năng của service mesh, chỉ có một thứ mà "tự làm bằng thư viện" thật sự tốn công lâu dài. Không phải bản thân cái bắt tay — Go cho bạn mTLS trong khoảng hai chục dòng, xem code bên dưới. Thứ khó là vòng đời chứng chỉ:

Việc Một cặp service Vài chục service
Cấp chứng chỉ Chạy tay một lần Phải có CA nội bộ và quy trình cấp tự động
Phân phối khoá riêng Copy vào hai chỗ Phải có secret store, không được lọt vào image hay Git
Xoay trước khi hết hạn Đặt nhắc lịch Bắt buộc tự động — chứng chỉ ngắn hạn thì con người không theo kịp
Thu hồi khi lộ Đổi thủ công Cần CRL/OCSP hoặc hạn siêu ngắn
Gắn danh tính vào chứng chỉ CN là đủ Cần chuẩn để mọi bên hiểu giống nhau → SPIFFE
Nạp lại mà không restart Restart cũng được Bắt buộc nạp nóng

Mesh làm cả cột phải một cách tự động. Đó là lý do chính đáng nhất — và với nhiều tổ chức là lý do duy nhất — để dựng mesh.

Bắt tay hai chiều

Sự khác nhau giữa TLS thường và mTLS nằm gọn ở ba thông điệp thêm vào: server hỏi bằng CertificateRequest, client trả lời bằng CertificateCertificateVerify (mô tả theo TLS 1.3, RFC 8446 — ở TLS 1.2 thứ tự và cách mã hoá khác, nhưng ý nghĩa ba thông điệp này là một):

  client                                               server
    │ ── ClientHello ──────────────────────────────────►│
    │                                                   │
    │ ◄── ServerHello                                   │
    │ ◄── EncryptedExtensions                           │
    │ ◄── CertificateRequest ───────────────────────────│  ◄── CHỈ CÓ Ở mTLS:
    │ ◄── Certificate (của SERVER)                      │      "trình chứng chỉ của anh ra"
    │ ◄── CertificateVerify (chữ ký của server)         │
    │ ◄── Finished                                      │
    │                                                   │
    │ ── Certificate (của CLIENT) ─────────────────────►│  ◄── CHỈ CÓ Ở mTLS
    │ ── CertificateVerify ────────────────────────────►│      chữ ký chứng minh client
    │ ── Finished ─────────────────────────────────────►│      đang GIỮ khoá riêng
    │                                                   │
    │                                    server verify chuỗi chứng chỉ client
    │                                    theo ClientCAs; sai → đứt kết nối,
    │                                    handler của bạn KHÔNG BAO GIỜ chạy

Điểm cần nắm: CertificateVerify là thứ làm cho mTLS khác hẳn một header bí mật. Chứng chỉ là công khai, ai cũng copy được; nhưng chữ ký trong CertificateVerify chỉ tạo được bởi bên đang giữ khoá riêng. Không thể phát lại, không thể copy từ log, không thể ăn cắp bằng cách nhìn trộm một request.

SPIFFE và SVID — danh tính có tên gọi

Khi số service tăng, câu hỏi "chứng chỉ này đại diện cho ai" cần một câu trả lời chuẩn hoá. SPIFFE quy ước danh tính là một URI:

spiffe://community.internal/svc/api
spiffe://community.internal/svc/search
└────┘   └────────────────┘└─────────┘
scheme     trust domain     workload path

Tài liệu mang danh tính đó gọi là SVID (SPIFFE Verifiable Identity Document). Với biến thể X.509, SPIFFE ID được đặt vào trường SAN URI của chứng chỉ — nghĩa là danh tính nằm trong thứ đã được CA ký, không nằm trong một header do bên gọi tự khai. Trong Go, đọc nó ra là truy cập cert.URIs.

Làm mTLS thủ công giữa cmd/api và một service nội bộ

Nếu bạn chỉ cần bảo vệ một đường — ví dụ cmd/api gọi một service tìm kiếm nội bộ có endpoint quản trị — thì dựng cả mesh là quá đắt. Đây là toàn bộ phần cần viết:

// internal/platform/mtls/mtls.go
//
// Package mtls dựng tls.Config cho lời gọi nội bộ giữa hai tiến trình của
// hệ thống. Không có nghiệp vụ nào ở đây — chép sang dự án Go khác là chạy,
// nên nó thuộc về platform (ARCHITECTURE.md §4.5).
package mtls

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"net/http"
	"os"
)

// ServerConfig dựng cấu hình cho service nội bộ: chỉ chấp nhận client trình
// được chứng chỉ do CA nội bộ ký.
func ServerConfig(certFile, keyFile, clientCAFile string) (*tls.Config, error) {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("mtls: đọc chứng chỉ server: %w", err)
	}
	pool, err := loadCAPool(clientCAFile)
	if err != nil {
		return nil, err
	}

	return &tls.Config{
		Certificates: []tls.Certificate{cert},

		// ClientCAs verify chứng chỉ CLIENT, độc lập hoàn toàn với RootCAs
		// (verify server) — dùng nhầm hai trường này là lỗi rất hay gặp.
		ClientCAs: pool,

		// Dòng biến TLS thường thành mTLS. Phải là RequireAndVerify:
		// tls.RequireAnyClientCert chỉ đòi client CÓ chứng chỉ mà không kiểm
		// chuỗi ký — chứng chỉ tự ký cũng lọt, gần như vô dụng.
		ClientAuth: tls.RequireAndVerifyClientCert,

		// Kênh nội bộ, hai đầu đều do bạn kiểm soát — không có lý do gì
		// phải hạ phiên bản để tương thích một client lạ.
		MinVersion: tls.VersionTLS13,
	}, nil
}

// ClientConfig dựng cấu hình cho bên gọi: vừa trình chứng chỉ của mình,
// vừa kiểm chứng chỉ của server theo cùng CA nội bộ.
func ClientConfig(certFile, keyFile, serverCAFile, serverName string) (*tls.Config, error) {
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("mtls: đọc chứng chỉ client: %w", err)
	}
	pool, err := loadCAPool(serverCAFile)
	if err != nil {
		return nil, err
	}

	return &tls.Config{
		Certificates: []tls.Certificate{cert},
		RootCAs:      pool,

		// ServerName được đối chiếu với SAN của chứng chỉ server. Bỏ trống thì
		// Go lấy hostname trong URL, nên phải đặt tường minh khi gọi bằng IP.
		// TUYỆT ĐỐI không "sửa" lỗi verify bằng InsecureSkipVerify — làm vậy là
		// vứt bỏ đúng nửa xác thực server của mTLS.
		ServerName: serverName,
		MinVersion: tls.VersionTLS13,
	}, nil
}

func loadCAPool(caFile string) (*x509.CertPool, error) {
	pemBytes, err := os.ReadFile(caFile)
	if err != nil {
		return nil, fmt.Errorf("mtls: đọc CA %s: %w", caFile, err)
	}
	pool := x509.NewCertPool()
	if !pool.AppendCertsFromPEM(pemBytes) {
		// AppendCertsFromPEM trả về bool chứ không trả error. Không kiểm chỗ
		// này thì một file rỗng hay sai định dạng sẽ cho ra pool RỖNG, và bạn
		// phát hiện ra lúc mọi kết nối bị từ chối trên production.
		return nil, fmt.Errorf("mtls: %s không chứa chứng chỉ PEM hợp lệ", caFile)
	}
	return pool, nil
}

// CallerID đọc SPIFFE ID trong SAN URI của chứng chỉ client. Tới được đây
// nghĩa là crypto/tls ĐÃ verify xong chuỗi theo ClientCAs, nên hàm này chỉ
// ĐỌC danh tính chứ không kiểm tra lại nó.
func CallerID(r *http.Request) (string, bool) {
	if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
		return "", false
	}
	// PeerCertificates[0] luôn là chứng chỉ lá; các phần tử sau là trung gian.
	for _, u := range r.TLS.PeerCertificates[0].URIs {
		if u.Scheme == "spiffe" {
			return u.String(), true
		}
	}
	return "", false
}

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í