Cuộc gọi lúc 2 giờ sáng: Tại sao cấu hình mặc định thường thất bại
Điện thoại của tôi bắt đầu rung trên tủ đầu giường lúc 2:14 sáng. API gateway môi trường production của chúng tôi đang bị “chảy máu”. Một mạng botnet đã nhắm mục tiêu vào một endpoint tìm kiếm không yêu cầu xác thực, tấn công dồn dập với hơn 5.000 yêu cầu mỗi giây. Cơ sở dữ liệu backend đang ngập trong các lệnh join phức tạp. Mức sử dụng CPU trên các node API vọt lên 95%, và những người dùng hợp lệ phải đối mặt với lỗi 504 Gateway Timeout.
Đây là thực tế của các API công khai. Nếu bạn không điều tiết lưu lượng (throttle traffic) và xác định danh tính người gọi, bạn không phải đang vận hành một hệ thống production — bạn đang quản lý một quả bom hẹn giờ. Tôi đã dành đêm đó để chặn các dải IP thủ công trong firewall. Bài học rút ra rất rõ ràng: chúng tôi cần một hệ thống rate limiting phân tán gắn liền với các API key duy nhất.
So sánh các phương pháp: Local vs. Distributed Rate Limiting
Ban đầu tôi dựa vào tính năng limit_req_zone tích hợp sẵn của Nginx. Nó đơn giản, nhưng thất bại trong các môi trường hiện đại có khả năng tự động mở rộng (autoscaled). Nếu bạn có năm instance Nginx đứng sau một bộ cân bằng tải (load balancer), mỗi instance sẽ duy trì bộ đếm riêng biệt của nó. Một kẻ tấn công về mặt lý thuyết có thể đánh vào hệ thống của bạn với lưu lượng gấp năm lần giới hạn cho phép trước khi bất kỳ node đơn lẻ nào kích hoạt lệnh chặn.
Dưới đây là so sánh các chiến lược phổ biến trong môi trường thực tế:
- Nginx tiêu chuẩn (ngx_http_limit_req_module): Nhanh và đơn giản với độ trễ cực thấp (sub-millisecond). Nó sử dụng các vùng bộ nhớ dùng chung (shared memory zones). Tốt nhất cho: Thiết lập máy chủ đơn lẻ hoặc giảm thiểu DDoS cơ bản ở lớp biên.
- Xác thực ở cấp ứng dụng: Viết logic trong Python hoặc Node.js cho phép áp dụng các quy tắc nghiệp vụ phức tạp. Tuy nhiên, nó rất tốn kém. Vào thời điểm code được thực thi, request đã tiêu tốn một worker thread và lượng bộ nhớ đáng kể.
- Nginx + Lua + Redis: Đây là tiêu chuẩn vàng trong ngành. Nginx xử lý kết nối, Lua thực thi logic và Redis lưu trữ trạng thái toàn cục. Tốt nhất cho: Các cụm server lưu lượng cao cần giới hạn nhất quán trên 10 hoặc 100 node.
Sự đánh đổi của bảo mật dựa trên Redis
Kỹ thuật là việc quản lý sự đánh đổi. Việc chuyển trạng thái sang Redis sẽ đưa vào các biến số mới trong hạ tầng của bạn.
Lợi ích
- Tính nhất quán tuyệt đối: Nếu một người dùng bị giới hạn ở mức 1.000 request mỗi giờ, họ sẽ nhận được chính xác 1.000. Không quan trọng họ truy cập vào node gateway nào.
- Cập nhật trực tiếp: Bạn có thể thay đổi giới hạn trong Redis ngay lập tức mà không cần tải lại cấu hình Nginx hoặc khởi động lại dịch vụ.
- Phân tầng truy cập: Bạn có thể dễ dàng gán giới hạn 10 req/giây cho các key “Free Tier” và 500 req/giây cho các key “Enterprise”.
Thách thức
- Độ trễ phát sinh: Mỗi request giờ đây yêu cầu một vòng kết nối mạng (round-trip) tới Redis. Mặc dù Redis rất nhanh, việc này thường thêm 0,5ms đến 2ms độ trễ cho mỗi request.
- Độ phức tạp vận hành: Giờ đây bạn có thêm một cluster Redis để giám sát. Nếu Redis gặp sự cố, gateway của bạn cần một chiến lược fail-open (mở cho qua) hoặc fail-closed (chặn lại).
Kiến trúc: OpenResty + Redis
Tôi thích sử dụng OpenResty cho việc này. Đây là một phiên bản Nginx mạnh mẽ được đóng gói kèm với LuaJIT. Nó cho phép chúng ta can thiệp vào giai đoạn access trong vòng đời của một request. Khi một request đến gateway, chúng ta trích xuất API key, kiểm tra hạn ngạch trong Redis, và sau đó cho phép hoặc từ chối với trạng thái 429 Too Many Requests.
Bảo mật bắt đầu từ những điều cơ bản. Khi tôi thiết lập instance Redis cho gateway này, tôi đã tạo mật khẩu quản trị bằng công cụ tại toolcraft.app/vi/tools/security/password-generator. Nó chạy hoàn toàn trong trình duyệt, đảm bảo các key không bao giờ chạm tới máy chủ từ xa trước khi chúng được triển khai vào cấu hình của bạn.
Hướng dẫn triển khai: Bảo vệ API của bạn
Chúng ta sẽ triển khai thuật toán “Fixed Window” (Cửa sổ cố định). Đây là phương pháp hiệu quả nhất và dễ debug nhất khi gặp áp lực.
Bước 1: Chuẩn bị Redis Backend
Chúng ta sẽ lưu trữ các key theo định dạng ratelimit:API_KEY:TIMESTAMP. Nếu một người dùng với key usr_99 gọi API lúc 10:05 sáng, chúng ta sẽ tăng giá trị của một key Redis và cho nó hết hạn sau 60 giây. Việc này tự động dọn dẹp dữ liệu cũ.
# Xác minh thủ công qua redis-cli
INCR "limit:usr_99:202310271405"
EXPIRE "limit:usr_99:202310271405" 60
Bước 2: Logic Lua trong Nginx
Trong cấu hình OpenResty của bạn, hãy định nghĩa logic bên trong access_by_lua_block. Điều này đảm bảo việc kiểm tra diễn ra trước khi request được proxy đến các dịch vụ backend tốn kém của bạn.
http {
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
server {
listen 80;
server_name api.example.com;
location /v1/ {
access_by_lua_block {
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000) -- Thời gian chờ 1 giây
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Kết nối Redis thất bại: ", err)
return ngx.exit(500)
end
local api_key = ngx.req.get_headers()["X-API-Key"]
if not api_key then
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.say("{\"error\": \"Thiếu API Key\"}")
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- Giới hạn: 100 yêu cầu mỗi phút
local limit = 100
local current_time = os.date("%Y%m%d%H%M")
local key = "limit:" .. api_key .. ":" .. current_time
local count, err = red:incr(key)
if not count then
ngx.log(ngx.ERR, "Tăng giá trị thất bại: ", err)
return ngx.exit(500)
end
if tonumber(count) == 1 then
red:expire(key, 60)
end
if tonumber(count) > limit then
ngx.status = 429
ngx.header.content_type = "application/json"
ngx.say("{\"error\": \"Vượt quá giới hạn lưu lượng. Vui lòng thử lại trong phút kế tiếp.\"}")
return ngx.exit(429)
end
-- Quan trọng: Trả kết nối về pool
red:set_keepalive(10000, 100)
}
proxy_pass http://backend_cluster;
}
}
}
Bước 3: Xác thực Key
Rate limiting sẽ vô dụng nếu bản thân key đó là giả. Trước khi kiểm tra bộ đếm, hãy truy vấn một Redis SET để đảm bảo X-API-Key thực sự tồn tại trong hệ thống của bạn. Điều này ngăn chặn kẻ tấn công làm tràn bộ nhớ Redis bằng các key ngẫu nhiên, không tồn tại.
local is_valid, err = red:sismember("active_keys", api_key)
if is_valid == 0 then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("{\"error\": \"API Key không hợp lệ\"}")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
Kiểm tra và Giám sát
Đừng đợi đến cuộc tấn công tiếp theo mới xem hệ thống có hoạt động hay không. Sử dụng các công cụ như hey để mô phỏng một đợt bùng phát 200 request. Theo dõi instance Redis của bạn trong thời gian thực bằng redis-cli monitor. Bạn sẽ thấy các key tăng dần và quan trọng là các lỗi 429 xuất hiện khi vượt quá giới hạn.
Hãy chú ý kỹ đến connection pool của bạn. Việc mở một kết nối TCP mới tới Redis cho mỗi request HTTP sẽ giết chết hiệu năng của bạn. Luôn sử dụng set_keepalive để tái sử dụng các kết nối hiện có. Chỉ riêng việc tối ưu hóa này có thể giảm 70% độ trễ phát sinh.
Việc triển khai kiến trúc này đã cứu vãn giấc ngủ của tôi. Lần tới khi một mạng botnet cố gắng thu thập dữ liệu của chúng tôi, Nginx đã xử lý nó một cách nhẹ nhàng. Nó phục vụ hàng nghìn phản hồi 429 trong vài mili giây, và backend của chúng tôi hoàn toàn yên tĩnh. Đó chính là sức mạnh của một lớp bảo mật chủ động.

