Khi nhiều site Laravel trên cùng một minion dùng chung một pool PHP-FPM tên www, mô hình rất đơn giản nhưng có một điểm yếu chết người: một site chậm có thể chiếm hết worker, và mọi site còn lại trên máy trả 502 dù chẳng làm gì sai. Dấu hiệu quen thuộc nhất là dòng log server reached pm.max_children setting lặp đi lặp lại. Tài liệu này hướng dẫn chuyển sang mô hình mỗi website một PHP-FPM pool riêng: vì sao pool chung thất bại, cấu hình pool với pm = ondemand, cách tính RAM để không cháy máy, cách triển khai trên minion đang chạy có đường rollback, và các lỗi hay gặp.

1. Tổng quan: PHP-FPM pool chung, một site chết, cả máy chịu

Cấu hình mặc định của PHP-FPM là một file www.conf. Nếu mọi site đều fastcgi_pass về đó, số worker tối đa của cả máy do đúng một con số quyết định: pm.max_children của pool www.

Pool chung [www], max_children = 20
┌──────────────────────────────────────────────────┐
│ site-a ████████████ | site-b █ | site-c █ | ...  │
│ site-a gọi API ngoài bị treo, giữ 18/20 worker   │
│ → site-b, site-c... xếp hàng, timeout, 502       │
└──────────────────────────────────────────────────┘

Nguyên nhân giữ worker thường không phải lượng truy cập lớn. Bật slowlog trong PHP-FPM pool, bạn hay thấy worker bị dump khi đang đứng ở curl_multi_select(): ví dụ một middleware gọi object storage đồng bộ ngay trong request. Mạng chậm một chút là worker kẹt lại chờ, và một site như vậy đủ kéo sập cả minion.

; bật slowlog để biết worker đang kẹt ở hàm nào
slowlog                 = /var/log/php/www.slow
request_slowlog_timeout = 10s

Tệ hơn, log chỉ nói "PHP-FPM pool www đầy", không nói site nào gây ra. Cách chữa cháy phổ biến là cron restart PHP-FPM định kỳ hoặc script tự khởi động lại dịch vụ chết như trong bài Self-healing tiết kiệm: tự phát hiện và khởi động lại dịch vụ chết. Cả hai chỉ xử lý triệu chứng, và mỗi lần restart lại cắt ngang request đang chạy của mọi site.

2. Cơ chế: mỗi site một PHP-FPM pool, một quota

PHP-FPM cho phép chạy nhiều pool trong cùng một master process. Theo tài liệu cấu hình PHP-FPM trên php.net, mỗi pool là một khối [tên] độc lập với socket, giới hạn worker, user và giá trị php_admin_value riêng. Tách mỗi site một PHP-FPM pool nghĩa là:

  • Quota riêng: site A dùng hết worker trong PHP-FPM pool của nó thì chỉ site A chậm.
  • Socket riêng: nginx của site nào nói chuyện với PHP-FPM pool của site đó.
  • Log riêng: dòng max_children hay execution timed out ghi rõ tên pool, tức tên site.
  • Giới hạn riêng: memory_limit, timeout đặt theo từng site.

Cấu hình mẫu một PHP-FPM pool

; /etc/php/7.4/fpm/pool.d/site-a-example.conf
[site-a-example]
user  = nginx
group = nginx

listen       = /run/php/php7.4-fpm-site-a-example.sock
listen.owner = www-data          ; user mà tiến trình nginx đang chạy
listen.group = www-data
listen.mode  = 0660

pm                      = ondemand
pm.max_children         = 15
pm.process_idle_timeout = 10s
pm.max_requests         = 500

request_terminate_timeout = 60s

php_admin_value[memory_limit]        = 128M
php_admin_value[upload_max_filesize] = 128M
php_admin_value[post_max_size]       = 64M
php_admin_value[max_execution_time]  = 60
php_admin_value[date.timezone]       = Asia/Ho_Chi_Minh
php_admin_value[error_log]           = /var/log/php/site-a.example.error

Ở nginx của minion, chỉ cần đổi một dòng trong khối location ~ \.php$:

-    fastcgi_pass 127.0.0.1:9000;
+    fastcgi_pass unix:/run/php/php7.4-fpm-site-a-example.sock;

Ý nghĩa các lựa chọn trong mẫu PHP-FPM pool:

  • pm = ondemand: pool không giữ worker nào khi không có request, chỉ sinh worker khi có request tới, và tự huỷ worker rảnh quá 10 giây. Đây là chìa khoá để chạy nhiều PHP-FPM pool trên một máy RAM nhỏ.
  • pm.max_children = 15 là mức khởi điểm hợp lý cho site bình thường, site lớn có thể đặt cao hơn. Đây là trần của riêng site đó, không phải số worker luôn chạy.
  • request_terminate_timeout = 60s: request chạy quá 60 giây bị PHP-FPM giết. Worker kẹt vì gọi API treo được giải phóng sau một phút.
  • memory_limit = 128M qua php_admin_value: code của site không tự nâng lên được bằng ini_set(), nên một request lỗi không thể ăn hết RAM máy.

Hai loại user không được nhầm

Trong file PHP-FPM pool có hai cặp user với ý nghĩa khác hẳn nhau. user/group là user chạy tiến trình PHP, quyết định quyền đọc ghi storage/, bootstrap/cache của Laravel. listen.owner/listen.group là chủ của file socket, phải khớp với user mà nginx đang chạy để nginx kết nối được. Hai giá trị này có thể khác nhau, ví dụ code thuộc user nginx còn nginx chạy bằng www-data. Đặt nhầm cặp thứ nhất thì Laravel không ghi được log. Đặt nhầm cặp thứ hai thì nginx trả 502 cho mọi site đã tách. Vì vậy script tách pool không nên gắn cứng giá trị: lấy user từ www.conf đang chạy ổn định, lấy listen.owner từ dòng user trong /etc/nginx/nginx.conf.

Tách pool mới cô lập về tài nguyên. Muốn cô lập cả quyền file (site này không đọc được .env của site kia), mỗi PHP-FPM pool nên chạy bằng một user Linux riêng, kèm đổi chủ sở hữu thư mục site và cập nhật script deploy.

PHP-FPM pool mới không kế thừa www.conf

Mỗi khối pool là độc lập: directive nào không khai trong pool mới sẽ rơi về giá trị của php.ini, không lấy theo www.conf. Nếu www.conf từng đặt date.timezone và max_execution_time riêng, pool mới phải khai lại, nếu không site sẽ chạy theo múi giờ của hệ điều hành và giới hạn 30 giây mặc định.

3. Tính RAM: tổng max_children lớn mà vẫn an toàn

Giả sử một minion có 30 site, mỗi site max_children = 15: tổng trần là 450 worker, trong khi pool chung chỉ 20. RAM lấy đâu ra? Câu trả lời: max_children là trần, còn số worker thật phụ thuộc vào số request đang được xử lý cùng lúc. Với ondemand, phần lớn site tại một thời điểm có 0 worker.

Để quyết định số PHP-FPM pool và trần của từng pool, có thể ước lượng theo ba bước:

# 1. RAM dành cho PHP = RAM máy - hệ điều hành - nginx - dự phòng
RAM_PHP = RAM_VPS - 500MB - 100MB - 200MB

# 2. RAM trung bình một worker (ước lượng ban đầu, nên đo lại)
#    PHP 7.2 ~40MB, PHP 7.4 ~50MB, PHP 8.2 ~70MB

# 3. Số worker đồng thời tối đa máy chịu được
MAX_CONCURRENT = RAM_PHP / RAM_PER_WORKER

# Ví dụ: minion 2GB chạy PHP 8.2
RAM_PHP        = 2048 - 500 - 100 - 200 = 1248MB
MAX_CONCURRENT = 1248 / 70 ≈ 17 worker

RAM mỗi worker nên đo trên chính máy của bạn, vì nó phụ thuộc vào code và package:

# RSS trung bình của worker PHP-FPM đang chạy (MB)
ps -o rss= -C php-fpm8.2 | awk '{s+=$1; n++} END {printf "%d worker, TB %.0f MB\n", n, s/n/1024}'

Sau đó kiểm tra tổng số worker đồng thời ước tính ở giờ cao điểm, không phải tổng max_children, có nằm dưới MAX_CONCURRENT hay không. Site lớn thường xuyên dùng nhiều worker nên có trần riêng cao hơn, site nhỏ giữ trần thấp. Khi mọi site cùng tăng đột biến, từng PHP-FPM pool chạm trần của riêng nó, site nào vượt trần thì site đó chậm. Đó chính là mục tiêu: giới hạn thiệt hại trong phạm vi một site.

Chế độ pmWorker khi rảnhHợp với
staticLuôn giữ đủ N workerMột site lớn chiếm cả máy
dynamicGiữ min_spare_servers workerSite truy cập đều, cần phản hồi ngay
ondemand0Nhiều site trên một máy, đa số truy cập thấp hoặc trung bình

Cái giá của ondemand là request đầu tiên sau một khoảng rảnh phải chờ PHP-FPM fork worker mới. Với OPcache, độ trễ này nhỏ so với thời gian bootstrap Laravel.

4. Triển khai PHP-FPM pool trên minion đang chạy

Tách PHP-FPM pool cho nhiều site trên máy đang phục vụ khách là thao tác rủi ro, nên hãy viết thành pipeline theo cùng khung backup, sửa, kiểm tra, rollback như trong bài Pipeline deploy, đổi domain, chuyển VPS có rollback database:

  1. Backup toàn bộ pool.d/*.conf và /etc/nginx/conf.d/ của minion vào một thư mục có dấu thời gian.
  2. Với từng site: chưa có pool riêng thì tạo pool mới và sửa fastcgi_pass; đã có thì cập nhật thông số hoặc bỏ qua. Giữ pool www cho những site chưa tách.
  3. php-fpm -t và nginx -t, rồi restart PHP-FPM, reload nginx.
  4. Health check HTTP thật: curl -H 'Host: <domain>' http://<IP minion>/ cho từng site vừa xử lý, thử lại vài lần cách nhau vài giây để worker kịp khởi động.
  5. Chỉ cần một site trả 5xx hoặc timeout, coi cả đợt trên minion đó là thất bại: xoá pool mới, chép lại pool và cấu hình nginx từ backup, restart.

Bước 4 là quan trọng nhất: lỗi quyền socket không làm php-fpm -t, nginx -t hay systemctl is-active báo lỗi. Mọi thứ "xanh" trong khi site trả 502, chỉ request HTTP thật mới bắt được.

Nên có thêm thao tác ngược chiều để gộp một site về PHP-FPM pool chung, cố tình không cho chạy trên cả minion để tránh gộp nhầm hàng loạt. Thao tác này phải phân biệt www.conf đang nghe bằng unix socket hay TCP, vì cú pháp fastcgi_pass khác nhau (unix:/run/php/...sock và 127.0.0.1:9000).

5. Lỗi thường gặp và cách xử lý

  • 502 ngay sau khi tách, log nginx báo Permission denied với file .sock: listen.owner không khớp user của nginx.
  • Laravel báo không ghi được storage/logs: user của pool khác chủ sở hữu thư mục site.
  • Giờ lệch, request dài bị cắt ở 30 giây: PHP-FPM pool mới thiếu date.timezone, max_execution_time vốn khai trong www.conf.
  • Máy swap nhiều sau khi tách: trần của vài site lớn quá cao so với MAX_CONCURRENT. Hạ trần hoặc chuyển site lớn sang máy khác.

6. Giám sát sau khi tách PHP-FPM pool

  • Số lần reached pm.max_children theo từng PHP-FPM pool, để biết site nào cần nâng trần.
  • Số lần execution timed out theo từng pool, để tìm site có request treo.
  • RAM và swap của minion ở giờ cao điểm, để kiểm chứng phép tính MAX_CONCURRENT.
# đếm lỗi max_children theo pool trong log PHP-FPM
grep 'reached pm.max_children' /var/log/php8.2-fpm.log | grep -o '\[pool [^]]*\]' | sort | uniq -c

7. Checklist

  • Chia sẻ tài nguyên cần có vách ngăn. Một PHP-FPM pool chung tiết kiệm trên giấy, nhưng biến lỗi của một site thành sự cố của cả máy.
  • Restart định kỳ không phải giải pháp. request_terminate_timeout giải phóng worker kẹt đúng lúc, đúng pool.
  • Trần không phải mức dùng. Tổng max_children chỉ có ý nghĩa khi đi cùng chế độ pm và số request đồng thời thực tế.
  • Kiểm tra bằng HTTP, không bằng trạng thái dịch vụ. Một PHP-FPM pool "active" vẫn có thể không phục vụ được request nào.

Bài tiếp theo

Cài SSL thương mại: wildcard và OV. Khi chứng chỉ Let's Encrypt không đáp ứng được yêu cầu của khách hàng doanh nghiệp: ghép chain đúng thứ tự, kiểm tra và quản lý hạn chứng chỉ mua ngoài trên balancer.