Một lỗi cấu hình rất phổ biến trên các minion chạy nhiều site: file cấu hình có khai fastcgi_cache_key và fastcgi_cache_valid, nhưng không có fastcgi_cache_path và directive fastcgi_cache. Cache trang trông như đã bật, nhưng tỉ lệ hit là 0%. Khi bật fastcgi_cache thật cho một site Laravel, phần tốn công nhất không phải là bật cache, mà là header Set-Cookie: nó làm cache không bao giờ hit, và nếu xử lý sai thì cache sẽ phát session của người này cho người khác. Tài liệu này hướng dẫn cách bật fastcgi_cache cho site nội dung công khai, cách xử lý Set-Cookie, cách kiểm tra, và những hệ quả phụ cần lường trước.

Bối cảnh về TTFB và cách tách từng chặng đo có trong bài TTFB cao với Googlebot: tách từng chặng và nghi vấn scraper nội bộ.

Triệu chứng

  • Site Laravel nội dung công khai, chạy PHP-FPM pool riêng. TTFB qua domain thật cao hơn nhiều so với thời gian PHP xử lý trên minion.
  • File cấu hình site có đủ "dáng" của cache nhưng thiếu hai dòng quyết định, kèm vài dòng nguy hiểm:
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.2-fpm-site-a-example.sock;
    fastcgi_pass_header Set-Cookie;                       # nguy hiểm khi bật cache
    fastcgi_pass_header Cookie;
    fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
    fastcgi_cache_key ...;
    fastcgi_cache_valid 200 301 302 30s;
    fastcgi_cache_use_stale updating error timeout invalid_header http_500;
    # không có fastcgi_cache_path, không có "fastcgi_cache <zone>" → không cache gì cả
}
  • Bật fastcgi_cache theo một bản nháp chỉnh sửa vội: mọi request đều MISS.

Điều tra

fastcgi_cache "ma": vì sao cấu hình trông đúng mà không chạy

fastcgi_cache_key, fastcgi_cache_valid, fastcgi_cache_use_stale chỉ là tham số. Theo tài liệu module fastcgi của nginx, cache chỉ hoạt động khi có vùng nhớ khai bằng fastcgi_cache_path và directive fastcgi_cache <zone> trong location. Thiếu hai dòng đó, nginx không báo lỗi, nginx -t vẫn qua, và mọi request vẫn chạy PHP. Kiểm tra nhanh trên một minion:

cd /etc/nginx/conf.d
grep -l "fastcgi_cache_valid" *.conf | wc -l      # số site "có vẻ" đã cache
grep -lE "fastcgi_cache\s+\w+;" *.conf | wc -l    # số site thật sự bật fastcgi_cache

Vì sao bật fastcgi_cache rồi vẫn luôn MISS

Gọi thẳng vào minion với header Host và xem header trả về:

curl -sI -H 'Host: site-a.example' http://10.0.0.44/ | grep -iE 'set-cookie|cache-control'
Cache-Control: no-cache, private
Set-Cookie: XSRF-TOKEN=...
Set-Cookie: sitea_session=...

Middleware group web của Laravel (StartSession, VerifyCsrfToken) gắn cookie session và XSRF-TOKEN vào mọi response, kể cả cho khách lần đầu ghé. Đây là hành vi mặc định, không phải lỗi. Còn nginx, theo mặc định, không cache response có Set-Cookie hoặc có Cache-Control: private, no-cache. Hai mặc định an toàn này gặp nhau thì fastcgi_cache không bao giờ lưu được trang nào. Muốn cache, phải chủ động bỏ qua các header đó bằng fastcgi_ignore_headers, và từ đó trở đi thì an toàn là trách nhiệm của mình.

Hai lỗi hay gặp trong bản nháp

  • Sai tên cookie. Điều kiện bypass dựa trên laravel_session. Nhưng tên cookie session của Laravel sinh từ APP_NAME (xem config/session.php), site đặt tên riêng thì cookie thật là sitea_session. Regex sai thì điều kiện bypass theo session không bao giờ khớp.
  • Gỡ nhầm fastcgi_ignore_headers Cache-Control Expires Set-Cookie cùng với fastcgi_pass_header, nên nginx thấy Cache-Control: no-cache, private và từ chối lưu. Đây chính là lý do luôn MISS.

Hai cách xử lý Set-Cookie khi bật fastcgi_cache

Khi đã cho nginx bỏ qua Set-Cookie, bản cache sẽ lưu cả header đó. Có hai hướng:

Cách 1: bypass theo URL và cookie, giữ Set-CookieCách 2: ẩn Set-Cookie trên trang được cache
Cơ chếKhông cache POST, /admin, request có query string, request đã có cookie session hoặc XSRF-TOKENThêm fastcgi_hide_header Set-Cookie cho location được cache
Ai được ăn cacheKhách chưa có cookie: Googlebot, bot không giữ cookie, khách lần đầuNhư cách 1
Rủi roBản cache mang Set-Cookie của khách đầu tiên. Trong thời gian cache, khách chưa có cookie khác trúng bản cache sẽ nhận cùng session IDfastcgi_hide_header là directive tĩnh theo location. Đặt chung location với /admin là không ai đăng nhập được
Độ phức tạpThấpPhải tách location riêng cho /admin. Sau try_files, request được xử lý lại từ đầu bảng location, khớp sai là gãy đăng nhập mà không có lỗi nào báo

Cách 1 chỉ chấp nhận được khi đã kiểm tra đủ ba điều kiện: trang công khai không có nội dung phụ thuộc session (không view nào dùng @csrf hay form POST), session của khách vãng lai là session rỗng, và /admin luôn bị bypass. Với guard session mặc định, Laravel cũng đổi session ID khi người dùng đăng nhập. Lựa chọn này chỉ đúng với site nội dung thuần. Site có giỏ hàng hay tài khoản khách phải dùng cách 2 hoặc cache ở tầng ứng dụng.

Cấu hình fastcgi_cache mẫu

fastcgi_cache_path /var/cache/nginx/site-a levels=1:2 keys_zone=sitea_cache:20m
                   max_size=200m inactive=10m;

location ~ \.php$ {
    # ... fastcgi_pass, fastcgi_param như cũ
    set $skip_cache 0;
    if ($request_method = POST)                       { set $skip_cache 1; }
    if ($request_uri ~* "^/admin")                    { set $skip_cache 1; }
    if ($http_cookie ~* "(sitea_session|XSRF-TOKEN)") { set $skip_cache 1; }
    if ($query_string != "")                          { set $skip_cache 1; }

    fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
    fastcgi_cache        sitea_cache;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache     $skip_cache;
    fastcgi_cache_valid  200 301 302 30s;
    fastcgi_cache_use_stale updating error timeout invalid_header http_500;
    add_header X-Cache-Status $upstream_cache_status always;
}

Kiểm tra bắt buộc trước khi coi fastcgi_cache là xong:

U=https://site-a.example/
curl -sI $U | grep -i x-cache-status                          # MISS
curl -sI $U | grep -i x-cache-status                          # HIT (trong 30 giây)
curl -sI -H 'Cookie: sitea_session=x' $U | grep -i x-cache    # BYPASS
curl -s -o /dev/null -w '%{http_code}\n' ${U}admin/login      # 200

# kiểm tra rò session: hai cookie jar khác nhau không được nhận cùng session ID
curl -s -c a.txt -o /dev/null $U; curl -s -c b.txt -o /dev/null $U
grep sitea_session a.txt b.txt

Đi kèm, có thể bật config:cache, route:cache của Laravel sau khi xác nhận không có chỗ nào gọi env() ngoài thư mục config. Kết quả điển hình: TTFB giảm khoảng một nửa, phần còn lại chủ yếu là mạng và TLS. Bản HIT trả từ minion chỉ mất dưới một mili giây.

Lỗi thường gặp và hệ quả phụ

Permission denied ngẫu nhiên ở thư mục file tạm

Error log có các dòng [crit] ... Permission denied rải rác. Nguyên nhân hay gặp: một thư mục con của /var/lib/nginx/fastcgi/ thuộc user cũ (ví dụ www-data) sau một lần đổi user chạy nginx. Chỉ những response phải ghi tạm ra đĩa và rơi đúng vào thư mục con đó mới lỗi, nên lỗi trông như ngẫu nhiên. Sửa bằng chown -R cho khớp các thư mục con còn lại, không cần restart.

Site nhanh hơn thì bị rate limit nhiều hơn

Sau khi bật cache, Search Console có thể báo tỉ lệ crawl thất bại tăng. Lý do: site nhanh hơn, Google crawl mạnh hơn, và chạm ngưỡng limit_req vốn được đặt từ thời site còn chậm, nhận 429. Whitelist theo dải IP Googlebot tốn công bảo trì; cách đơn giản hơn là nâng ngưỡng theo lưu lượng thật, ví dụ rate=300r/m burst=100 cho trang thường. Lưu ý: nginx giữ lại vùng nhớ của limit_req_zone qua lần reload khi tên và kích thước không đổi, nên trạng thái đếm cũ vẫn còn. Đổi thông số thì đổi tên zone (ví dụ thêm hậu tố _v2).

Số của Google không phải số của mình

Nếu máy đo nằm cùng mạng, cùng thành phố với balancer, mọi phép đo gần như không có độ trễ mạng thật. Đo từ nhiều vùng địa lý sẽ thấy riêng bước bắt tay TCP từ châu Âu hay Mỹ đã mất hàng trăm mili giây, và HTTPS cần nhiều lượt đi về. Không cấu hình fastcgi_cache nào xử lý được khoảng cách địa lý; muốn thấp hơn cần một lớp CDN đặt gần nơi Google crawl.

Checklist khi bật fastcgi_cache

  • Mọi site bật fastcgi_cache phải có add_header X-Cache-Status $upstream_cache_status always, và tỉ lệ hit được kiểm tra sau khi bật.
  • Rà toàn bộ file cấu hình: có fastcgi_cache_valid mà không có fastcgi_cache <zone> là cấu hình ma, nên gỡ hoặc bật thật.
  • Gỡ fastcgi_pass_header Set-Cookie khỏi mẫu cấu hình.
  • Lấy tên cookie session từ config/session.php và APP_NAME thật của từng site, không đoán.
  • Kiểm tra rò session bằng hai cookie jar khác nhau gọi cùng một URL.
  • Đổi thông số limit_req_zone thì đổi tên zone.
  • Đo TTFB từ ngoài mạng của datacenter, ở nhiều vùng địa lý.

Bài học

  • Cấu hình "có vẻ đúng" nguy hiểm hơn cấu hình thiếu. Thiếu thì người ta còn đi tìm, có vẻ đúng thì không ai nghi.
  • fastcgi_cache không hiểu session. Nó trả nguyên byte đã lưu, kể cả header. Mọi quyết định an toàn phải nằm trong điều kiện bypass.
  • Rủi ro được chấp nhận phải ghi rõ lý do. Cách 1 an toàn với site nội dung thuần, không phải an toàn nói chung.
  • Tối ưu một tầng có thể làm lộ giới hạn ở tầng khác. Site nhanh hơn kéo theo lượng crawl lớn hơn, và rate limit cũ trở thành nút thắt mới.

Bài tiếp theo

Cache làm rò session: hai lỗ hổng phát hiện kịp. Khi đem cách làm này sang một site thương mại điện tử có giỏ hàng, bước kiểm tra trước khi bật cache phát hiện hai lỗ hổng có thể trộn session và lộ thông tin đơn hàng của khách.