"Hệ thống có chống được DDoS không?" là câu hỏi chúng tôi thường nhận được khi trao đổi kỹ thuật với khách hàng. Câu trả lời thật lòng không phải chỉ là "có" hay "không", mà là: bạn đang nói tới kiểu DDoS nào? Một đợt bơm 20 Gbps lưu lượng rác vào đường truyền, một bot giữ hàng nghìn kết nối mở mà gần như không gửi dữ liệu, và một script gọi endpoint tìm kiếm nặng nhất 50 lần mỗi giây đều có thể làm hệ thống ngừng phục vụ. Nhưng ba kiểu đó không thể chặn bằng cùng một cơ chế. Mỗi kiểu phải được xử lý ở một lớp khác nhau.
Bài viết này đi qua từng lớp bảo vệ mà chúng tôi đang dùng thực tế: cấu hình OpenResty ở biên, cache, bộ giới hạn trong API gateway, rồi rate-limit nằm ngay trong ứng dụng. Quan trọng hơn, bài viết cũng nói rõ các đánh đổi phía sau những lựa chọn đó, kể cả vài bài học chỉ lộ ra sau khi vận hành thật.
1. Ba loại tấn công, ba chỗ chặn khác nhau
Nguyên tắc xuyên suốt rất đơn giản: chặn càng sớm càng rẻ. Một request bị loại ngay ở biên gần như không tốn gì. Nhưng nếu request đó đi lọt vào ứng dụng, nó có thể chiếm một kết nối, bắt hệ thống xác thực JWT, rồi kéo thêm vài truy vấn Postgres. Mà Postgres thường là tầng khó nhân bản nhất. Vì vậy phòng thủ phải xếp thành nhiều lớp: lớp nào chặn được rẻ nhất thì chặn ở đó, phần còn lại mới đi tiếp xuống lớp sau.
| Loại tấn công | Cách nó làm hệ thống ngừng phục vụ | Chặn ở đâu |
|---|---|---|
| Volumetric (L3/L4) | Bơm băng thông rác cho tới khi đường truyền nghẽn | Trên đường truyền, trước khi tới máy chủ của bạn |
| Cạn tài nguyên kết nối (slowloris) | Giữ hàng nghìn kết nối mở, gửi từng byte một, không cần băng thông | Biên: timeout và giới hạn số kết nối |
| Tầng ứng dụng (L7) | Gọi đúng endpoint đắt tiền nhất — tìm kiếm, đăng nhập, AI | Gateway và trong chính ứng dụng |
Có một điều cần nói thẳng: tấn công volumetric không thể chặn bằng phần mềm chạy trên chính máy chủ đang bị đánh. Khi đường truyền đã nghẽn, cấu hình nginx tinh vi đến đâu cũng không còn ý nghĩa, vì gói tin thậm chí không tới được máy chủ để bị từ chối. Lớp đó thuộc về nhà cung cấp hạ tầng, CDN hoặc dịch vụ scrubbing đứng phía trước. Phần một đội phát triển kiểm soát được tốt hơn là hai nhóm còn lại: cạn tài nguyên kết nối và tấn công tầng ứng dụng. Phần còn lại của bài tập trung vào hai nhóm này.
2. Tầng biên: những giới hạn rẻ nhất, đặt ngay cửa vào
Mọi request đi vào hệ thống đều đi qua OpenResty trước. Đây là nơi phù hợp để đặt các giới hạn đơn giản nhất: giới hạn tốc độ, giới hạn số kết nối, timeout và kích thước body. Những giới hạn này không hiểu nghiệp vụ, nhưng rất rẻ và bảo vệ được mọi thứ phía sau. Cấu hình dưới đây đang chạy cho chính trang bạn đang đọc:
# Trần theo IP: ~5 request/giây, cho phép dồn ngắn 20 request
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# Siết timeout — cắt kết nối rác kiểu slowloris
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
reset_timedout_connection on;
# Trang tĩnh: không có lý do gì để nhận body lớn
client_max_body_size 2m;
large_client_header_buffers 4 8k;
limit_req_status 429;
limit_conn_status 429;
server_tokens off;
Ba nhóm giới hạn này xử lý ba vấn đề khác nhau, và phần hay bị bỏ quên nhất lại là timeout.
limit_req chặn kẻ gọi quá nhanh. limit_conn chặn kẻ mở quá nhiều kết nối song song.
Còn timeout chặn kiểu tấn công gần như không làm gì cả: slowloris. Nó mở rất nhiều kết nối, rồi
mỗi kết nối chỉ gửi nhỏ giọt vài byte header để giữ trạng thái "đang hoạt động". Kiểu tấn công này gần như không
tốn băng thông, nên nhìn biểu đồ lưu lượng có thể vẫn bình thường. Nhưng nó chiếm hết slot kết nối, khiến người
dùng thật không còn chỗ để vào. Thứ xử lý nó không phải giới hạn tốc độ, mà là timeout rõ ràng:
client_header_timeout 10s nghĩa là nếu gửi header không xong trong 10 giây thì đóng kết nối.
Một chi tiết nhỏ nhưng rất đáng chỉnh là limit_req_status 429. Mặc định nginx thường trả 503 khi
vượt hạn mức. 503 dễ bị hiểu là "máy chủ đang hỏng", trong khi vấn đề thật sự là "client gọi quá nhanh". Mã 429,
nếu đi kèm Retry-After, nói đúng tình huống hơn: client biết nên chờ bao lâu, còn hệ thống giám sát
của bạn cũng không báo động nhầm rằng backend đang sập.
Một hạn mức đặt ở biên bảo vệ mọi thứ phía sau nó, kể cả những đoạn code bạn chưa viết. Một hạn mức đặt trong ứng dụng chỉ bảo vệ đúng route bạn nhớ ra để gắn nó.
3. Request rẻ nhất là request không bao giờ tới backend
Trước khi nói tiếp về việc chặn, cần nói về việc hấp thụ. Trong thực tế, tầng giúp chúng tôi chịu tải nhiều nhất không phải rate-limit, mà là cache.
Trong Z-EDU, các route công khai được gắn header Cache-Control bằng decorator
@HttpCache. Nhờ vậy CDN/Traefik có thể trả response ngay ở biên, request không cần đi tới NestJS.
Khi traffic vào trang danh sách khoá học tăng vọt, dù đến từ người thật hay bot, phần lớn tải được hấp thụ ở đây
mà backend gần như không biết. Về mặt chống DDoS, đây là một lớp rất đáng giá: nó chuyển áp lực từ
ứng dụng sang bộ nhớ đệm, mà bộ nhớ đệm thì rẻ và dễ mở rộng hơn nhiều.
Nhưng cache chỉ giúp được khi request trúng cache. Kẻ tấn công có thể thêm tham số ngẫu nhiên vào URL để
ép request thành cache-miss và đẩy tải xuống database. Lúc này cần lớp thứ hai:
CacheService.wrap gộp các lần miss cùng khoá thành một lần tính (single-flight). Nếu 100 request cùng
miss một khoá, chỉ một request đi tính kết quả, 99 request còn lại chờ kết quả đó. Cơ chế này vốn dùng để chống
cache stampede khi cache hết hạn giữa giờ cao điểm, nhưng cũng rất hữu ích khi gặp cache-busting.
Chúng tôi đã viết riêng về ba tầng cache trong
Cache ba tầng cho SaaS multi-tenant.
4. Gateway: một con số cho mọi route là một con số sai
Ở Z-Cloud Workspace, mọi request đi qua một API gateway chạy trên OpenResty, với bộ rate-limit viết bằng Lua. Đây là nơi thấy rất rõ vì sao không nên đặt một hạn mức chung cho toàn bộ API.
Vấn đề là các endpoint không có cùng "giá". Đọc danh sách file có thể chỉ mất vài mili-giây. Gọi mô hình AI thì tốn CPU và tiền trả cho nhà cung cấp. Đăng nhập lại kéo theo Keycloak và phép băm mật khẩu vốn được thiết kế để chậm. Nếu đặt hạn mức chung đủ thoáng cho thao tác đọc, nó sẽ quá lỏng với đăng nhập. Nếu đặt đủ chặt cho đăng nhập, người dùng bình thường sẽ liên tục gặp 429 khi dùng các tính năng nhẹ hơn.
Nên gateway phân loại từng request vào một nhóm và áp hạn mức riêng cho nhóm đó:
| Nhóm | Hạn mức / 60 giây | Vì sao |
|---|---|---|
auth_sensitive |
10 | Đăng nhập, xác minh. Người thật không đăng nhập 10 lần một phút |
ai |
20 | Mỗi lần gọi là tiền thật trả cho nhà cung cấp mô hình |
heavy |
30 | Tìm kiếm, xuất file, chuyển đổi định dạng — tốn CPU |
write |
60 | Ghi xuống DB |
read |
300 | Đọc, thường đã có cache đỡ |
realtime |
600 | Chat và cộng tác vốn dĩ nói nhiều — chặn ở đây là làm hỏng sản phẩm |
tenant_total |
5000 | Trần cho cả một doanh nghiệp, bất kể bao nhiêu người dùng |
Dòng cuối chỉ xuất hiện vì hệ thống là multi-tenant: nhiều doanh nghiệp dùng chung một nền tảng, nhưng dữ liệu
được tách biệt. Các hạn mức phía trên đếm theo từng người dùng. Tuy vậy, một doanh nghiệp có ba trăm nhân viên,
hoặc một script bị lỗi nhưng đang chạy bằng tài khoản hợp lệ, vẫn có thể tiêu hết tài nguyên chung mà không người
dùng nào vượt hạn mức cá nhân. tenant_total là trần cho cả tenant, để sự cố của một khách hàng không
kéo theo những khách hàng còn lại. Trong hệ thống multi-tenant, "hàng xóm ồn ào" thường phổ biến hơn tấn công có
chủ đích, và nó hay đến từ tích hợp bị lỗi hơn là từ kẻ xấu.
Đếm bằng cửa sổ trượt, không phải cửa sổ cố định
Cách đếm đơn giản nhất là cửa sổ cố định: mỗi phút có một bộ đếm, hết phút thì reset. Cách này có một lỗ hổng ngay tại ranh giới thời gian. Với hạn mức 10 request/phút, client có thể gửi 10 request ở giây 59 và thêm 10 request nữa ở giây 61. Trong thực tế là 20 request trong vài giây, nhưng trên sổ sách vẫn không vi phạm.
Vì vậy gateway dùng cửa sổ trượt. Nó lấy số request trong cửa sổ hiện tại, cộng thêm phần còn hiệu lực của cửa sổ trước, tính theo thời gian đã trôi qua. Toàn bộ phép tính chạy trong một script Lua nguyên tử trong Redis: đọc, tính và tăng bộ đếm trong cùng một lần gọi. Chi tiết này rất quan trọng vì gateway có nhiều bản sao. Nếu hai bản sao cùng đọc rồi cùng ghi trên một khoá, hạn mức có thể bị vượt âm thầm đúng lúc tải cao nhất, tức đúng lúc bạn cần rate-limit chính xác nhất.
Khoá theo danh tính, không chỉ theo IP
IP không phải lúc nào cũng là khoá tốt. Cả một văn phòng có thể đi ra Internet bằng một IP duy nhất; nhà mạng di động cũng có thể gom rất nhiều thuê bao sau một địa chỉ NAT. Nếu chỉ chặn theo IP, một người dùng thao tác quá nhiều có thể khiến cả công ty của họ bị 429. Ngược lại, nếu botnet có hàng nghìn IP, hạn mức theo IP gần như không cản được bao nhiêu.
Vì vậy gateway ưu tiên đếm theo danh tính khi xác định được danh tính. Request đã đăng nhập được đếm theo khoá dẫn xuất từ phiên người dùng, đã băm và không lưu token thô trong Redis. Chỉ khi chưa xác định được người dùng thì hệ thống mới rơi về IP. Kết quả thực tế là cả văn phòng dùng chung IP vẫn có hạn mức riêng cho từng người, còn kẻ tấn công dù đổi IP liên tục cũng không thoát khỏi hạn mức gắn với tài khoản đang dùng.
5. Trong ứng dụng: chặn trước cả khi xác thực
Gateway không phải điểm dừng cuối. Trong Z-Auto, API NestJS vẫn có bộ rate-limit riêng. Điểm quan trọng nhất ở
đây là thứ tự chạy: RateLimitGuard được đăng ký là guard toàn cục đầu tiên,
nghĩa là nó chạy trước cả guard xác thực JWT.
// app.module.ts — thứ tự này có ý nghĩa
providers: [
{ provide: APP_GUARD, useClass: RateLimitGuard }, // ← trước tiên
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
]
Nếu đảo ngược thứ tự này, mỗi request rác vẫn phải đi qua bước verify chữ ký JWT, có thể kéo theo truy vấn người
dùng, rồi mới bị từ chối vì vượt hạn mức. Bạn vẫn trả 429, nhưng đã tiêu đúng loại tài nguyên mà cuộc
tấn công muốn làm cạn. Khi đặt bộ đếm lên trước, request thứ 121 từ một IP có thể bị loại chỉ sau một lệnh
INCR trong Redis, không chạm tới code nghiệp vụ.
Bộ đếm dùng Redis nên mọi bản sao API dùng chung một hạn mức. Bộ đếm đặt trong RAM của từng tiến trình không làm được điều đó: nếu chạy 4 replica, hạn mức thực tế sẽ gấp 4 lần con số bạn viết trong code. Cấu trúc hạn mức chia làm ba lớp: nhóm riêng cho endpoint nhạy cảm, nhóm mặc định cho route công khai, và nhóm mặc định cho route đã đăng nhập.
| Xô | Hạn mức | Ghi chú |
|---|---|---|
| Đăng nhập showroom | 10 / 60 giây | Làm chậm dò mật khẩu |
| Đăng nhập quản trị hệ thống | 8 / 60 giây | Chặt hơn — bề mặt giá trị cao hơn |
| Đăng ký tài khoản | 10 / 300 giây | Chống tạo tài khoản hàng loạt |
| Đăng ký mua gói | 5 / 600 giây | Thao tác chạm tới thanh toán |
| Route công khai (mặc định) | 120 / 60 giây | Bề mặt lộ ra ngoài |
| Route đã đăng nhập (mặc định) | 600 / 60 giây | Người dùng thật thao tác nhiều hơn khách vãng lai |
Có hai chi tiết nhỏ nhưng rất thực tế. Thứ nhất, hạn mức có thể chỉnh khi hệ thống đang chạy: quản trị viên sửa ngay trên màn hình quản trị, phần ghi đè lưu ở Redis, và mọi bản sao API nhận giá trị mới trong vài giây mà không cần deploy lại. Khi đang có sự cố, chỉnh được trong 10 giây khác rất xa với việc phải build và deploy lại. Thứ hai, log chỉ ghi một dòng cảnh báo tại thời điểm một IP vượt ngưỡng, không ghi thêm cho từng request bị chặn sau đó. Khi có hàng chục nghìn request mỗi phút, ghi log quá nhiều có thể tự biến thành sự cố mới: đầy đĩa, nghẽn logging pipeline, hoặc làm chậm chính ứng dụng.
Có hai loại route được miễn rate-limit, và lý do rất rõ. /health được miễn vì load balancer gọi nó
liên tục; nếu tính nó vào hạn mức, hệ thống có thể tự chặn chính công cụ giám sát của mình. Webhook thanh toán
cũng được miễn, vì trả 429 ở đây có thể gây thiệt hại thật: cổng thanh toán báo giao dịch thành công, nhưng hệ
thống từ chối vì "quá nhanh", dẫn tới khách đã trả tiền mà đơn hàng không được ghi nhận. Endpoint loại này phải
được bảo vệ bằng xác thực chữ ký của bên gửi, không phải bằng rate-limit.
Rate-limit là công cụ chặn khối lượng. Với những endpoint mà mất một request là mất tiền, phải bảo vệ bằng xác thực, không phải bằng hạn mức.
6. Lớp cuối: khi kẻ tấn công không cần nhiều request
Tới đây, phần lớn biện pháp đều dựa trên giả định "tấn công = nhiều request". Nhưng có những kiểu lạm dụng không cần nhiều request; chỉ cần mỗi request đủ đắt. Đây là lúc rate-limit thuần túy không đủ, và lớp bảo vệ phải hiểu nghiệp vụ cụ thể.
Trên form đăng ký nhận tư vấn của Z-Auto, lớp đầu tiên là honeypot: một trường nhập bị ẩn với
người dùng thật. Người thật không điền trường này, nhưng bot thường tự điền mọi trường nó thấy. Nếu trường đó có
giá trị, API trả về { ok: true } nhưng không ghi gì xuống database. Cách này không cho bot biết nó đã
bị phát hiện, và cũng không tốn Redis, nên vẫn hoạt động ngay cả khi các bộ đếm khác gặp sự cố.
Lớp thứ hai là giới hạn theo khoá nghiệp vụ, không chỉ theo IP. Số lead từ một IP bị giới hạn, nhưng số lead trên cùng một số điện thoại cũng bị giới hạn. Bot đổi IP khá dễ; đổi số điện thoại thật thì tốn kém hơn nhiều. Nguyên tắc chung: hãy đếm trên thứ kẻ tấn công khó làm giả nhất, không chỉ trên thứ bạn dễ đo nhất.
Lớp thứ ba bảo vệ một thứ rất dễ bị quên trong thời AI: hoá đơn. Trợ lý AI trên storefront gọi tới mô hình ngôn ngữ, và mỗi lần gọi đều tốn tiền thật. Kẻ tấn công không cần làm sập hệ thống; chỉ cần gọi đều suốt đêm là sáng hôm sau bạn nhận một hoá đơn khó chịu. Vì vậy trợ lý bị chặn ở ba lớp: hạn mức theo IP, trần theo ngày gắn với từng người truy cập qua cookie httpOnly, và sổ tín dụng của showroom. Mỗi lần gọi phải trừ tín dụng, hết tín dụng thì dừng. Thứ tự rất quan trọng: khi vượt hạn mức, hệ thống trả lời bằng câu soạn sẵn trước khi gọi mô hình. Nếu chặn sau khi đã gọi mô hình, bạn chỉ chặn được response, còn chi phí thì đã phát sinh.
7. Câu hỏi khó nhất: Redis chết thì sao?
Mọi bộ đếm ở trên đều dựa vào Redis. Nên có một câu hỏi không thể né: khi Redis không trả lời, request tiếp theo được cho qua hay bị chặn?
Không có đáp án đúng cho mọi trường hợp. Chỉ có hai kiểu rủi ro, và bạn phải chọn kiểu mình chịu được. Fail-closed nghĩa là không kiểm tra được hạn mức thì chặn request. Cách này an toàn hơn về mặt bảo vệ, nhưng một sự cố Redis có thể biến thành sự cố toàn hệ thống: người dùng không làm gì sai vẫn bị từ chối. Fail-open nghĩa là không kiểm tra được thì cho request đi tiếp. Hệ thống vẫn phục vụ, nhưng trong khoảng thời gian đó lớp giới hạn gần như không còn tác dụng.
Chúng tôi không chọn một cách cho tất cả endpoint. Mặc định là fail-open, vì với phần lớn API, để lọt vài phút
traffic chưa giới hạn vẫn ít hại hơn việc từ chối toàn bộ khách hàng thật vì một sự cố hạ tầng phụ. Nhưng nhóm
auth_sensitive ở gateway thì fail-closed. Khi Redis không truy cập được, endpoint
đăng nhập không được mở tự do mà chuyển sang bộ đếm cục bộ trong bộ nhớ chia sẻ của gateway. Bộ đếm này kém chính xác
hơn vì mỗi bản sao gateway đếm riêng, nên hạn mức thực tế sẽ lỏng hơn cấu hình. Nhưng với mật khẩu người dùng,
"kém chính xác" vẫn tốt hơn nhiều so với không giới hạn gì.
Fail-open là một lựa chọn hợp lý. Fail-open âm thầm thì không. Nếu bộ giới hạn của bạn tắt đi mà không ai biết, bạn không có một chế độ suy giảm — bạn có một lỗ hổng.
Vì vậy trạng thái của Redis là một chỉ số được theo dõi, không phải một giả định. Báo cáo rate-limit trả về
redisHealthy cùng với số liệu, và đây là loại cảnh báo phải dựng trước khi cần tới nó.
8. Hai bài học phải trả giá mới có
Một bộ giới hạn đặt sai chỗ còn tệ hơn không có
Cấu hình rate-limit ở tầng Traefik cho Z-Auto đã được viết ra, và hiện đang bị vô hiệu hoá có chủ đích. Lý do: hệ thống chạy trên Docker Swarm, và với chế độ định tuyến mặc định của Swarm, Traefik nhìn thấy IP của lớp ingress cho mọi client, không phải IP thật của người dùng. Bật rate-limit theo IP trong tình trạng đó nghĩa là toàn bộ người dùng trên thế giới bị gộp chung vào một bộ đếm duy nhất. Kết quả không phải là "chặn được kẻ tấn công" — mà là hệ thống tự trả 429 cho chính khách hàng của mình, và trông y hệt một cuộc DDoS thành công. Chúng tôi để nó tắt cho tới khi cấu hình mạng cho phép Traefik nhìn thấy IP thật.
Bài học tổng quát hơn nhiều so với Swarm: một bộ giới hạn chỉ đúng khi khoá mà nó đếm là đúng. Trước khi bật bất kỳ hạn mức theo IP nào, hãy trả lời một câu: IP mà lớp này nhìn thấy là IP của người dùng, hay là IP của cái proxy đứng ngay trước nó?
Tin vào header của client là tự mở cửa
Câu hỏi trên có một mặt trái nguy hiểm không kém. Sau reverse proxy, IP thật của client nằm trong header
X-Forwarded-For. Nhưng header là thứ client tự gửi — nên nếu ứng dụng của bạn đọc
X-Forwarded-For một cách mù quáng, bất kỳ ai cũng vô hiệu hoá được toàn bộ rate-limit của bạn bằng
cách bịa một IP mới cho mỗi request. Bộ giới hạn vẫn chạy, biểu đồ vẫn đẹp, và nó không chặn được gì cả.
X-Forwarded-For chỉ đáng tin khi proxy đứng trước nó là của bạn và request không có đường
nào đi vòng qua proxy đó. Cùng nguyên tắc, gateway của Z-Cloud Workspace xoá sạch mọi header tin cậy nội
bộ trên request đến, trước khi làm bất cứ việc gì khác:
-- strip-trust-headers.lua — chạy đầu mọi request
ngx.req.clear_header("X-Gateway-Verified")
ngx.req.clear_header("X-Verified-User-Id")
ngx.req.clear_header("X-Verified-User-Email")
ngx.req.clear_header("X-Tenant-Code")
-- …
Các dịch vụ phía sau tin vào những header này để biết người gọi là ai — chúng do gateway đặt sau khi đã xác minh
JWT. Nếu gateway không xoá chúng trước, một client chỉ cần tự gửi kèm X-Verified-User-Id là trở
thành bất kỳ ai. Nguyên tắc: mọi header mà hệ thống nội bộ tin tưởng đều phải bị xoá ở cửa vào và chỉ
được đặt lại bởi chính lớp đã xác minh nó. Điều này vượt ra ngoài phạm vi DDoS, nhưng nó cùng một gốc —
biên giới giữa "dữ liệu do client cung cấp" và "dữ kiện hệ thống đã tự xác minh".
9. Không đo thì không biết mình đang bị tấn công
Một bộ rate-limit không có số liệu là một bộ rate-limit bạn không dám chỉnh: siết vào thì sợ chặn nhầm khách, nới ra thì sợ thủng. Nên mỗi lần kiểm tra hạn mức đều được ghi nhận (best-effort, không nằm trên đường request), và màn hình quản trị trả lời được ba câu hỏi:
- Tỷ lệ chặn theo từng nhóm hạn mức. Một nhóm có tỷ lệ chặn cao đều đặn thường không phải dấu hiệu bị tấn công — nó là dấu hiệu hạn mức đặt quá chặt, và người đang bị chặn là khách hàng của bạn.
- Danh sách IP bị chặn nhiều nhất, giữ trong một tập có giới hạn kích thước. Giới hạn này là cố ý: một danh sách "mọi IP từng bị chặn" sẽ tự phình ra vô hạn trong đúng lúc bị tấn công — tức là bộ theo dõi tấn công lại trở thành một cách để làm cạn bộ nhớ.
- Redis còn sống không. Vì như đã nói ở trên, đây là câu trả lời cho "lớp giới hạn có đang thật sự hoạt động không".
Điểm chung với triết lý quan sát ở các hệ thống khác của chúng tôi: cảnh báo phải dựng trên chỉ số nói lên trạng thái nghiệp vụ, không chỉ trên CPU và bộ nhớ. Một cuộc tấn công L7 được thiết kế tốt có thể làm hệ thống của bạn ngừng phục vụ mà CPU vẫn ở mức bình thường.
10. Danh sách kiểm tra
Nếu bạn đang rà lại hệ thống của mình, đây là những câu hỏi đáng trả lời theo thứ tự:
- Timeout đã siết chưa? Đây là thứ rẻ nhất và bị bỏ quên nhiều nhất. Không có nó, một kết nối nhỏ giọt có thể chiếm slot của bạn vô thời hạn.
- Có trần cho số kết nối đồng thời trên mỗi IP không? Giới hạn tốc độ và giới hạn kết nối chặn hai thứ khác nhau.
- Body size có trần theo từng route không? Một endpoint upload cần nhận 500MB; endpoint đăng nhập thì không — và nếu nó cũng nhận 500MB, bạn vừa tặng kẻ tấn công một cách để làm cạn bộ nhớ.
- Hạn mức có phân biệt route đắt và route rẻ không? Một con số chung cho mọi endpoint luôn vừa quá chặt vừa quá lỏng.
- Bộ đếm nằm ở đâu? Trong RAM tiến trình thì hạn mức thật của bạn đang nhân với số replica.
- Khoá đếm có giả mạo được không? Nếu nó đến từ một header client tự gửi, câu trả lời là có.
- Đã viết ra fail-open hay fail-closed cho từng nhóm chưa, và có cảnh báo khi rơi vào chế độ suy giảm không?
- Endpoint nào tốn tiền mỗi lần gọi? Những endpoint đó cần trần chi phí, không chỉ trần tốc độ — và phải chặn trước khi phát sinh chi phí.
- Log khi bị tấn công có làm đầy đĩa không? Ghi một dòng lúc vượt ngưỡng, không ghi mỗi request.
Kết luận
Không có lớp nào ở trên tự nó "chống được DDoS". Cấu hình biên không cứu bạn khỏi một script gọi API tìm kiếm. Rate-limit trong ứng dụng không cứu bạn khỏi slowloris. Và không thứ nào trong bài này cứu bạn khỏi một đợt bơm băng thông đủ lớn — lớp đó nằm ngoài phạm vi một đội phần mềm, và ai nói ngược lại là đang bán cho bạn thứ gì đó.
Thứ thật sự hiệu quả là chấp nhận rằng đây là một bài toán nhiều lớp: siết timeout và giới hạn kết nối ở biên vì chúng rẻ; hấp thụ bằng cache vì request không tới backend là request không tốn gì; phân loại route theo giá thật của chúng ở gateway; đặt bộ đếm lên trước cả xác thực trong ứng dụng; và với những endpoint tiêu tiền, chặn ở tầng chi phí chứ không chỉ ở tầng tốc độ. Cuối cùng, hãy viết rõ điều gì xảy ra khi Redis không hoạt động, và dựng cảnh báo dựa trên chính quyết định đó.
Phần lớn "sự cố DDoS" mà chúng tôi từng gỡ hoá ra không đến từ kẻ xấu: một tích hợp của khách hàng bị lỗi vòng
lặp, một bot thu thập dữ liệu không đọc robots.txt, hoặc một cron job gọi lại chính API của mình
theo cấp số nhân. Các lớp phòng thủ trong bài này xử lý những tình huống đó bằng cùng cơ chế dùng để chặn tấn
công thật. Đó là lý do chúng đáng được xây dựng, kể cả khi bạn nghĩ không ai có lý do để tấn công mình.