Phát triển phần mềm11 tháng 6, 202613 phút đọc

Nginx không "dở chứng" — bạn chỉ đang đọc config sai cách

Nginx không "dở chứng" — bạn chỉ đang đọc config sai cách

Tư duy cốt lõi: config là bản khai báo, không phải kịch bản

Đây là gốc rễ của gần như mọi hiểu lầm về Nginx. Hãy khắc cốt ghi tâm ba điều:

Một là, Nginx không chạy từ trên xuống theo thứ tự bạn viết. Khi một request đến, Nginx không "duyệt lần lượt" các block location như chạy if-else. Nó chọn ra đúng một block location phù hợp nhất dựa trên một thuật toán ưu tiên (sẽ nói ở dưới), rồi mới thực thi các directive bên trong block đó.

Hai là, directive được kế thừa theo ngữ cảnh. Cấu hình chảy từ ngoài vào trong: httpserverlocation. Một directive đặt ở server sẽ áp dụng cho mọi location bên dưới — trừ khi bị ghi đè. Nghe đơn giản, nhưng có một nhóm directive đặc biệt kế thừa theo kiểu "được ăn cả ngã về không" mà tôi sẽ chỉ ra ở cái bẫy số 5.

Ba là, một khi nắm được hai điều trên, các "cú vả" kinh điển bên dưới không còn là phép màu. Chúng đều là hệ quả logic của cùng một bộ luật.

Giữ cái khung này trong đầu khi đọc tiếp.

Vì sao Nginx xứng đáng đứng ở tiền tuyến

Trước khi mổ xẻ những cái bẫy, cần hiểu vì sao gần như cả ngành lại tin tưởng giao cánh cổng cho nó.

Một worker, vạn kết nối

App Server truyền thống (kiểu Apache prefork, hay một thread pool của Tomcat) phục vụ theo mô hình "mỗi request một thread": có khách vào là cấp một nhân viên đi theo. Khách ngồi ngâm cứu menu — tức là I/O chậm, mạng lag — thì nhân viên đó cũng đứng chôn chân chờ. Mười nghìn khách cùng lúc là cần mười nghìn nhân viên; RAM bốc hơi, hệ thống quỳ.

Nginx đi theo hướng khác: mô hình hướng sự kiện (event-driven). Mỗi tiến trình worker chạy một vòng lặp sự kiện, dùng I/O không chặn (epoll trên Linux) để ôm hàng nghìn kết nối cùng lúc. Worker nhận đơn của khách này, ném vào bếp, quay sang nhận đơn khách khác, bếp xong món nào thì quay lại trả món đó — không ai phải đứng chờ ai. Đây chính là lý do Nginx ra đời: Igor Sysoev viết nó vào đầu những năm 2000 để giải bài toán C10k — phục vụ mười nghìn kết nối đồng thời trên một con server mà không sập. Kết quả: tốn rất ít RAM mà gánh được lượng kết nối khổng lồ.

Tấm khiên trước Backend

Đứng ở ngoài cùng, Nginx gánh luôn những việc nặng nhọc và lặp đi lặp lại: bắt tay SSL/TLS, nén dữ liệu (Gzip/Brotli), giới hạn tốc độ request (rate limit), lọc bớt traffic rác, phục vụ file tĩnh. Backend phía sau nhờ vậy được rảnh tay tập trung vào đúng một việc: xử lý logic nghiệp vụ. Tách phần "đối ngoại" ra khỏi phần "xử lý" — đó là giá trị kiến trúc thật sự của reverse proxy, không chỉ là "đặt thêm một lớp cho oai".

Bảy cái bẫy — và vì sao chúng hoàn toàn đoán trước được

1. Dấu / định mệnh trong proxy_pass

Đây là cú 404 huyền thoại khiến bao người nghi ngờ nhân sinh.

# Cách A — proxy_pass CHỈ có host, không có URI
location /api/ {
    proxy_pass http://backend;
}
# /api/users   ->   Backend nhận:  /api/users    (giữ nguyên)
# Cách B — proxy_pass có URI (dù chỉ là một dấu "/")
location /api/ {
    proxy_pass http://backend/;
}
# /api/users   ->   Backend nhận:  /users        (phần /api/ bị thay bằng /)

Luật nằm ở đây, và nó tuyệt đối nhất quán: nếu proxy_pass có kèm phần URI (kể cả chỉ là dấu /), Nginx sẽ lấy phần URI đó thay thế cho đúng đoạn đã khớp trong location. Nếu proxy_pass chỉ có host, Nginx forward nguyên vẹn URI gốc. Không có ngoại lệ, không có ngẫu nhiên. Khi Backend báo 404 còn bạn thì cào tóc, việc đầu tiên cần làm là bật log của Backend lên xem nó thực sự nhận được path nào — câu trả lời gần như luôn nằm ở cái dấu gạch chéo này.

2. Thứ tự ưu tiên của location

Bạn muốn chặn một thư mục nhạy cảm, viết hẳn một block deny all. Nhưng Nginx vẫn thả cửa.

location /uploads/ {              # prefix thường — ưu tiên THẤP
    deny all;
}

location ~* \.(jpg|jpeg|png)$ {   # regex — ưu tiên CAO hơn prefix thường
    proxy_pass http://backend;
}
# /uploads/secret.jpg  ->  khớp regex trước  ->  deny all bị bỏ qua  ->  lộ file

Nhớ lại tư duy cốt lõi: Nginx không duyệt tuần tự, nó chọn theo độ ưu tiên của loại toán tử. Thứ tự chính xác là: khớp tuyệt đối = → prefix ưu tiên ^~ → regex (~, ~*) theo thứ tự xuất hiện → cuối cùng mới đến prefix thường (chọn cái khớp dài nhất). Block ~* \.jpg$ là regex nên "qua mặt" được location /uploads/ vốn chỉ là prefix thường. Cách sửa là nâng độ ưu tiên cho block chặn:

location ^~ /uploads/ {   # ^~ : đã khớp prefix này thì dừng, không thèm xét regex
    deny all;
}

Không phải lỗi của Nginx — chỉ là bạn chưa biết luật chơi.

3. Mặc định 1MB và cú 413

Trên máy local upload video vèo vèo. Đóng Docker mang lên server, user upload tấm avatar 5MB — bùm, 413 Request Entity Too Large. Client chửi, sếp hỏi.

Lý do: mặc định Nginx giới hạn body của request ở 1MB (client_max_body_size 1m). Đây là một ví dụ kinh điển của triết lý "default an toàn" mà Nginx theo đuổi — và những default an toàn đó lại thường rất "thù địch" với production nếu bạn không tinh chỉnh. Sửa thì dễ:

client_max_body_size 20M;

Nhưng nhớ hai điều: đặt nó ở đúng ngữ cảnh (http, server, hay riêng một location upload), và đừng dại đặt 0 (bỏ giới hạn) trên endpoint công khai — đó là lời mời gọi tấn công nhồi bộ nhớ.

4. Failover không "tức thì" như bạn tưởng

Bạn có hai Backend chạy load balancing, một con crash. Bạn đinh ninh Nginx sẽ chuyển ngay sang con còn lại? Có, nhưng "ngay" hay không phụ thuộc vào cách con kia chết:

  • Nếu server chết kiểu từ chối kết nối (process tắt, cổng đóng, trả về RST): kết nối hỏng tức thì, Nginx chuyển sang con khác gần như ngay lập tức. User hầu như không cảm nhận được.

  • Nếu server chết kiểu mất tích (cả máy down, firewall nuốt gói tin không phản hồi): Nginx phải chờ hết proxy_connect_timeout — mặc định tận 60 giây — rồi mới chịu bỏ cuộc. Đây mới chính là thủ phạm của màn "loading vô tận / lúc được lúc không" mà user gặp.

Còn max_fails (mặc định 1) và fail_timeout (mặc định 10s) thì quyết định: sau bao nhiêu lần lỗi thì Nginx "gạch tên" server đó, và gạch trong bao lâu. Cấu hình thực tế nên trông như sau:

upstream backend {
    server 10.0.0.1:8080 max_fails=2 fail_timeout=10s;
    server 10.0.0.2:8080 max_fails=2 fail_timeout=10s;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_connect_timeout 2s;            # đừng chờ 60s cho một server đã chết
        proxy_next_upstream error timeout http_502 http_503;
    }
}

Điểm "sâu" ở đây: bản Nginx open-source chỉ có health check bị động — nó đánh giá server sống/chết qua chính các request thật bị lỗi, chứ không chủ động ping kiểm tra. Vậy nên đừng kỳ vọng default lo hết cho bạn: default chỉ đoán một kịch bản chung chung, còn topology của bạn thì bạn phải tự khai báo.

5. Cái bẫy ít ai nói: add_headerproxy_set_header "nuốt" cấu hình cha

Đây là directive nhóm "được ăn cả ngã về không" mà tôi hứa ở đầu bài — và là thứ khiến nhiều người mất hàng giờ mà không hề ngờ tới.

Quy tắc: với add_header (và proxy_set_header), một block con chỉ kế thừa các header từ block cha khi và chỉ khi bản thân nó không khai báo dòng nào cùng loại. Hễ bạn thêm dù chỉ một add_header trong location, toàn bộ add_header kế thừa từ server/http sẽ biến mất khỏi location đó.

server {
    add_header X-Frame-Options "DENY";
    add_header Strict-Transport-Security "max-age=31536000";

    location /static/ {
        add_header Cache-Control "public, max-age=86400";
        # X-Frame-Options và HSTS phía trên VỪA BỐC HƠI khỏi /static/
    }
}

Bạn tưởng mình chỉ thêm một header cache, hóa ra vô tình thổi bay luôn các header bảo mật ở location đó. Cùng logic với proxy_set_header: nếu bạn khai báo bất kỳ proxy_set_header nào trong location, bạn mất các header kế thừa, và nếu không tự set lại Host, Backend sẽ nhận Host: $proxy_host (tên trong proxy_pass) thay vì Host gốc của client — đủ để khiến nhiều app sinh lỗi redirect hoặc routing. Đó là lý do bộ bốn dòng dưới đây gần như là "bùa hộ mệnh" mỗi khi proxy:

location /api/ {
    proxy_pass http://backend;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

6. CORS "lúc được lúc không" — bóng ma kinh điển

CORS là loại lỗi khiến Dev frontend lẫn backend đổ lỗi cho nhau nhiều nhất, và chính cái phần "random" mới gây ức chế: cũng endpoint đó, lúc gọi ngon lành, lúc trình duyệt đỏ lòm báo "No 'Access-Control-Allow-Origin' header is present". Gần như mọi trường hợp "random" đều quy về một trong ba nguyên nhân sau — và cả ba đều bắt nguồn từ chính cách Nginx xử lý header.

Nguyên nhân số một: thiếu chữ always. Đây là cú lừa tinh vi nhất. add_header của Nginx mặc định chỉ gắn header khi response có mã 2xx hoặc 3xx (200, 201, 204, 301, 302...). Nghĩa là: khi request thành công, trình duyệt thấy đủ header CORS — chạy ngon. Nhưng ngay khi backend trả về lỗi (500, 401, 403), header CORS biến mất, và trình duyệt thay vì báo đúng lỗi 500 lại la làng "lỗi CORS". Vậy nên nó "random" theo đúng nghĩa đen: cứ backend lỗi là CORS lỗi theo. Cách sửa nằm gọn trong một từ khóa — always:

location /api/ {
    add_header Access-Control-Allow-Origin  "https://app.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

    # Xử lý preflight ngay tại Nginx, đừng để trình duyệt chờ backend
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  "https://app.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
        return 204;
    }

    proxy_pass http://backend;
}

(Để ý: bộ header phải lặp lại bên trong block if — đúng như cái bẫy số 5, header khai báo bên ngoài không "chui" được vào trong if.)

Nguyên nhân số hai: gắn header hai lần. Nếu cả Nginx backend cùng set Access-Control-Allow-Origin, trình duyệt nhận hai giá trị và từ chối thẳng thừng ("contains multiple values"). Nó "random" vì có path thì chỉ Nginx trả header, có path backend cũng trả. Nguyên tắc: chọn một nơi duy nhất chịu trách nhiệm về CORS, không chia đôi.

Nguyên nhân số ba: preflight và config không đồng nhất. Với request "không đơn giản" (có header Authorization, method PUT/DELETE...), trình duyệt gửi một request OPTIONS thăm dò trước. Nếu Nginx đẩy OPTIONS xuống backend mà backend không trả header CORS, preflight fail và request thật không bao giờ được gửi đi. Tương tự, nếu bạn có nhiều node Nginx mà config CORS lệch nhau, request rơi trúng node nào sẽ quyết định CORS sống hay chết — lại "random" theo load balancing (cú bẫy số 4).

7. WAF chặn nhầm — và đội lốt thành lỗi CORS

Bạn dựng một WAF (ModSecurity + OWASP CRS, hay WAF của Cloudflare/AWS) ở tiền tuyến để chặn tấn công. Rồi một ngày, vài user báo thao tác bị lỗi ngẫu nhiên — người này gửi form thì 403, người kia thì không sao. Bạn lục log backend: sạch trơn, request còn chẳng tới nơi.

Đó là false positive của WAF: một request hợp lệ vô tình khớp với luật phát hiện tấn công. User dán một đoạn nội dung trông giống cú pháp SQL, một payload có ký tự lạ, một chuỗi base64 dài... WAF tưởng là SQL injection / XSS và đạp ra 403. Nó "random" vì chỉ kích hoạt với đúng những payload chạm luật — phần lớn request bình thường vẫn lọt.

Và đây là chỗ hai cú bẫy này chập vào nhau — phần "sâu" nhất: WAF chặn request trước khi nó tới app, nên cái response 403 đó không hề có header CORS. Trình duyệt thấy thiếu Access-Control-Allow-Origin liền báo "lỗi CORS". Thế là bạn lao vào sửa CORS hàng giờ, trong khi thủ phạm thật là một luật WAF đang âm thầm chặn. Dấu hiệu nhận ra: lỗi hiện ra dưới dạng CORS, nhưng status thật là 403, và backend không ghi nhận request nào.

Cách xử lý đúng không phải là tắt béng WAF (vứt luôn tấm khiên), mà là:

  • Đọc log của chính WAF (audit log của ModSecurity), ở đó có rule ID đã chặn và lý do thật — không phải đoán qua màn hình trình duyệt.

  • Tinh chỉnh: loại trừ đúng rule gây false positive cho đúng endpoint (rule exclusion), hoặc hạ paranoia level nếu CRS quá gắt — thay vì tắt toàn bộ.

  • Đảm bảo trang lỗi của WAF/Nginx cũng đính kèm header CORS (lại là chữ always), để ít nhất trình duyệt báo đúng 403 thay vì che giấu sau lỗi CORS.

Bài học chung của cả hai cú bẫy: trình duyệt nói "CORS" không có nghĩa lỗi nằm ở CORS. Rất thường, CORS chỉ là triệu chứng; bệnh nằm ở một response lỗi, hoặc ở một WAF đang làm đúng việc của nó mà bạn quên mất nó tồn tại.

15 phút đáng giá: việc cần làm trước mỗi lần deploy

Không cần thuộc lòng, chỉ cần đi qua checklist này:

  • Đặt client_max_body_size đúng với nhu cầu thật của app (đừng để 1MB mặc định cắn trộm).

  • Khi proxy, luôn forward Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto.

  • Kiểm tra lại add_header: location con có vô tình nuốt mất security header của cha không?

  • Với load balancing, hạ proxy_connect_timeout và khai báo proxy_next_upstream để failover dứt khoát.

  • Thử các đường dẫn biên với từng location xem cái nào thực sự khớp (=, ^~, regex, prefix).

  • Đọc kỹ từng dấu / trong proxy_pass trước khi đổ lỗi cho Backend.

  • Gắn always cho mọi header CORS để chúng sống sót qua cả những response lỗi.

  • Khi thấy "lỗi CORS", kiểm tra status thật (403 hay 500?) và log của WAF trước khi lao vào sửa CORS.

Chốt

Nginx giống một chiếc xe côn tay: cực bốc, cực tiết kiệm, cân được mọi địa hình khắc nghiệt. Nhưng nếu không biết vào số, không hiểu luật, nó sẽ giật cục và hất bạn xuống đường không thương tiếc.

Khác biệt giữa người "đánh vật" với Nginx và người "cưỡi" được nó không phải là thuộc nhiều dòng config hơn, mà là nắm được ba thứ: config là khai báo, location được chọn theo độ ưu tiên, và directive kế thừa theo ngữ cảnh (với vài ngoại lệ "được ăn cả ngã về không"). Hiểu ba điều đó, bạn không còn debug bằng cách thử-và-sai nữa — bạn đoán được hành vi trước cả khi reload.

Đừng chỉ apt install nginx rồi bê file config mặc định trên mạng về. Bỏ ra 15 phút hiểu cách nó định tuyến request, sếp sẽ khen bạn "tay to", hệ thống chạy mượt — và quan trọng nhất, bạn sẽ ngủ ngon trong cái đêm deploy.


Bạn từng dính cú nào trong năm cái bẫy trên, hay còn cú nào dị hơn? Kể ở phần bình luận để anh em cùng tránh nhé.

Bài viết liên quan

Sáu trạm, sáu cách chết: hành trình sống còn của một HTTP requestThiết kế hệ thống
14 thg 6, 202619 min

Sáu trạm, sáu cách chết: hành trình sống còn của một HTTP request

Ghi chép kỹ thuật & chiêm nghiệm — về con đường mà mỗi request phải đi, và tại sao nó lại đi như vậy.

Đọc
Góc Nhìn "Reverse LSP": Khi Bản Năng Sinh Tồn Buộc Bạn Phải Vi Phạm Nguyên TắcPhát triển phần mềm
28 thg 5, 20268 min

Góc Nhìn "Reverse LSP": Khi Bản Năng Sinh Tồn Buộc Bạn Phải Vi Phạm Nguyên Tắc

SOLID không phải là một tôn giáo, và các nguyên tắc thiết kế không phải là những điều răn bất di bất dịch. Đứng ở góc độ một Tech Lead thực chiến, đôi khi quyết định bẻ gãy nguyên tắc Liskov Substitution Principle (LSP) lại là lựa chọn trưởng thành để cứu sống hệ thống. Hãy cùng phân tích 4 kịch bản trade-off kinh điển và nghệ thuật khoanh vùng 'mã độc' an toàn.

Đọc
Code bớt "tham tham", đời bớt khổ: Sức mạnh của Single Responsibility dưới góc nhìn thực chiếnPhát triển phần mềm
24 thg 5, 20267 min

Code bớt "tham tham", đời bớt khổ: Sức mạnh của Single Responsibility dưới góc nhìn thực chiến

Phần lớn bug nghiêm trọng trên Production không đến từ thuật toán tồi—mà đến từ việc nhồi nhét quá nhiều trách nhiệm vào một chỗ. Đây là góc nhìn thực chiến về Nguyên lý Đơn Trách Nhiệm (SRP): cái "bẫy tiện tay" sinh ra God Object như thế nào, vì sao một hàm xử lý đơn hàng điển hình trở thành quả mìn bảo trì, và cách một Senior tái cấu trúc nó thành các tầng sạch sẽ, dễ test. Kiến trúc sạch không hề miễn phí—nhưng khi dự án phình lên 100 tính năng, đó chính là thứ giữ cho nó không sụp đổ.

Đọc