Scale Laravel với nginx + PHP-FPM

Câu hỏi phỏng vấn hay gặp: "App Laravel đang chạy 1 server, traffic tăng gấp 5, anh làm gì?" Trả lời "thêm server" là trả lời sai — hoặc đúng nhất một nửa. Muốn trả lời được, phải biết request nghẽn ở đâu. Với stack nginx + PHP-FPM, chỗ nghẽn gần như luôn là một con số duy nhất: pm.max_children.

Tài liệu này đi từ đường đi thật của 1 request, tới công thức tính số worker, rồi tới kiến trúc nhiều node và danh sách bẫy khi tách state ra khỏi máy.

Nguồn kiểm chứng. Phần nginx (upstream, least_conn, keepalive, fastcgi_keep_conn) lấy qua Context7 từ nginx.org/en/docs. Phần Laravel (optimize, cache driver, Octane worker state) lấy qua Context7 từ laravel/docs 13.xlaravel/octane 2.x. Các file config nginx trong bài đã chạy nginx -t thật (nginx 1.24.0) — output dán ở mục 5.4. Chưa verify: Context7 không có tài liệu FPM pool (php-fpm.conf), nên các directive pm.* ghi theo tài liệu php.net và kinh nghiệm vận hành, chưa chạy thử trong session này. Các con số RAM/RPS là ví dụ minh hoạ để tính, không phải đo từ hệ thống thật.

1. Nó là gì / chạy thế nào

1.1. Đường đi thật của một request

sequenceDiagram
  participant C as Client
  participant N as nginx (worker, event loop)
  participant M as php-fpm master
  participant W as php-fpm child (1 process)
  participant D as PostgreSQL / Redis

  C->>N: GET /orders
  N->>N: Thử tìm file tĩnh (try_files)
  N->>M: FastCGI qua unix socket / TCP :9000
  M->>W: Giao cho 1 child đang rảnh
  W->>W: Bootstrap Laravel (autoload, provider, route)
  W->>D: Query — BLOCKING, child ngồi đợi
  D-->>W: Rows
  W-->>N: HTML / JSON qua FastCGI
  N-->>C: HTTP 200
  Note over W: Hết request → PHP xoá sạch memory,<br/>child quay lại pool

Hai chữ quan trọng nhất trong sơ đồ: BLOCKING1 process.

1.2. Vì sao nginx nhanh mà app vẫn nghẽn

nginx và PHP-FPM có hai mô hình xử lý hoàn toàn khác nhau. Đây là gốc của mọi bài toán scale trên stack này:

nginxPHP-FPM
Mô hìnhEvent-driven, non-blocking (1 process xử lý xoay vòng nhiều connection)Process-per-request, blocking (1 process = 1 request, từ đầu tới cuối)
Số việc song songworker_processes × worker_connections — hàng chục nghìnpm.max_children — thường 20–100
Tốn RAM mỗi "việc"Vài KB / connection50–150 MB / process (Laravel)
Lúc đợi DBKhông có khái niệm đợi — nhả CPU đi làm việc khácProcess ngồi im, vẫn giữ nguyên RAM
nginx nhận 5 000 connection không sao. Nhưng nếu pm.max_children = 40 thì tại một thời điểm chỉ có 40 request PHP thật sự đang chạy. 4 960 cái còn lại đang xếp hàng. Toàn bộ việc scale stack này là làm cho hàng đợi đó không dài ra.

1.3. Little's Law — công thức tính số worker cần

Little's Law (định luật Little — số việc đang chạy trong hệ = tốc độ vào × thời gian ở lại):

concurrency = RPS × thời gian xử lý trung bình (giây)

Ví dụ minh hoạ: app bán hàng, giờ cao điểm 200 req/s, thời gian xử lý trung bình 150 ms:

concurrency = 200 × 0.15 = 30 request chạy song song

Vậy 30 children là đủ? Không. Trung bình chỉ nói được phần giữa. Ba thứ phải cộng thêm:

Ba con số này đụng nhau chính là chỗ phải ra quyết định scale — xem mục 1.6.

1.4. Ba chế độ process manager

Directive pm trong pool config quyết định FPM tạo/huỷ child thế nào:

pmCách chạyDùng khiBẫy
static Tạo đúng pm.max_children process ngay lúc start, giữ mãi Production có traffic đều. Không tốn thời gian fork lúc peak, RAM dùng bao nhiêu biết ngay từ đầu Ăn RAM cố định kể cả lúc 3h sáng không ai vào
dynamic Giữ pm.start_servers, co giãn giữa min_spare_serversmax_spare_servers, trần là max_children Máy chạy nhiều thứ khác, hoặc traffic lên xuống mạnh theo giờ Lúc traffic dựng đứng, fork process mới tốn thời gian → độ trễ tăng đúng lúc không nên tăng
ondemand Không giữ process nào. Có request mới fork, rảnh quá pm.process_idle_timeout thì giết Máy dev, staging, hoặc server chứa nhiều site nhỏ ít traffic Đừng dùng cho production traffic cao. Mỗi cold request phải trả giá fork

Cấu hình mẫu cho production (/etc/php/8.3/fpm/pool.d/app.conf):

[app]
user = www-data
group = www-data
listen = /run/php/app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Hàng đợi ở tầng kernel khi hết child. Mặc định 511.
listen.backlog = 511

pm = static
pm.max_children = 40

; Giết child sau N request để chặn memory leak tích luỹ.
; Đây là "băng dán", không phải cách sửa leak.
pm.max_requests = 500

; Giết request chạy quá lâu, trả 504. Phải >= max_execution_time.
request_terminate_timeout = 60s

; Ghi log request chậm KÈM stack trace PHP — thứ quý nhất khi debug.
slowlog = /var/log/php-fpm/app-slow.log
request_slowlog_timeout = 3s

; Bật endpoint đếm số child đang bận + độ dài hàng đợi.
pm.status_path = /fpm-status

1.5. Tính pm.max_children — từ RAM, không từ cảm giác

flowchart TD
  A["Đo RSS thật của 1 child (xem lệnh ở mục 5.2)"] --> B["RAM còn lại = Tổng RAM − OS − nginx − Redis − buffer"]
  B --> C["max_children = RAM còn lại / RSS trung bình"]
  C --> D{"Kết quả >= concurrency cần?"}
  D -->|"Có"| E["Đặt số đó, để dư 15% an toàn"]
  D -->|"Không"| F["Không đủ RAM → giảm thời gian xử lý,
hoặc thêm RAM, hoặc thêm node"]

Ví dụ minh hoạ, máy 8 GB:

Tổng RAM             8 192 MB
- OS + nginx            512 MB
- Redis                 512 MB
- Buffer/OS page cache  1 024 MB
= Còn cho PHP         6 144 MB

RSS trung bình 1 child (Laravel, có opcache): ~110 MB
pm.max_children = 6144 / 110 ≈ 55  → đặt 48 cho an toàn
Sai lầm kinh điển: đặt pm.max_children = 200 "cho chắc". Lúc peak FPM fork thật 200 process, RAM cạn, kernel OOM killer bắn chết process — thường bắn nhầm PostgreSQL hoặc chính php-fpm master. Từ chậm thành sập. Đặt thấp thì request xếp hàng; đặt cao thì máy chết. Xếp hàng luôn tốt hơn chết.

1.6. Khi hết child thì chuyện gì xảy ra

flowchart LR
  R["Request tới"] --> Q{"Còn child rảnh?"}
  Q -->|"Còn"| P["Xử lý ngay"]
  Q -->|"Hết"| B["Xếp hàng ở listen.backlog
(hàng đợi socket của kernel)"] B --> T{"Backlog đầy?"} T -->|"Chưa"| W["Đợi → user thấy chậm
(TTFB tăng dần)"] T -->|"Đầy"| E["Kernel từ chối kết nối
nginx trả 502 Bad Gateway"] W --> X{"Đợi quá fastcgi_read_timeout?"} X -->|"Quá"| G["nginx trả 504 Gateway Timeout"] X -->|"Chưa"| P

Nhớ khác biệt này, phỏng vấn hay hỏi:

1.7. Scale dọc và scale ngang

flowchart TB
  subgraph V["Scale dọc — máy to hơn"]
    V1["1 node
4 vCPU / 8 GB
48 children"] --> V2["1 node
16 vCPU / 32 GB
~200 children"] end subgraph H["Scale ngang — nhiều máy"] H1["Load balancer"] --> H2["node 1"] H1 --> H3["node 2"] H1 --> H4["node 3"] end V2 -.->|"Đụng trần: RAM đắt dần,
vẫn là 1 điểm chết duy nhất"| H1

Thứ tự làm đúng — làm hết bậc trên rồi mới xuống bậc dưới, vì bậc trên rẻ hơn nhiều lần:

  1. Giảm thời gian xử lý. Rẻ nhất. 150 ms → 50 ms là tự động tăng 3× sức chứa mà không tốn thêm đồng nào. Xem Tối ưu Laravel — N+1 query, index thiếu, chunk thay vì all().
  2. Bật opcache + php artisan optimize. Vài phút làm, cắt được phần bootstrap của mọi request.
  3. Đẩy việc nặng sang queue. Gửi mail, xuất Excel, gọi API bên thứ ba — đừng bắt child ngồi đợi trong request.
  4. Tăng pm.max_children nếu RAM còn chỗ.
  5. Scale dọc — máy to hơn. Đơn giản, không đụng vào code.
  6. Scale ngang — nhiều node. Mạnh nhất nhưng bắt buộc app phải stateless (không giữ trạng thái trên máy). Đây là chỗ mọi thứ vỡ, xem mục 3.

1.8. Kiến trúc scale ngang

flowchart TB
  U["Người dùng"] --> CDN["CDN — ảnh, CSS, JS"]
  U --> LB["Load balancer
(nginx / HAProxy / ALB)
terminate TLS, health check"] LB --> A1["App node 1
nginx + php-fpm"] LB --> A2["App node 2
nginx + php-fpm"] LB --> A3["App node 3
nginx + php-fpm"] A1 --> RD["Redis
session + cache + queue"] A2 --> RD A3 --> RD A1 --> PGW["PostgreSQL primary
(ghi)"] A2 --> PGW A3 --> PGW PGW -.->|"streaming replication"| PGR["PostgreSQL replica
(đọc)"] A1 --> PGR A2 --> PGR A3 --> PGR A1 --> S3["S3 / MinIO
file người dùng upload"] A2 --> S3 A3 --> S3 WK["Worker node
queue:work + scheduler"] --> RD WK --> PGW

Luật của kiến trúc này chỉ có một câu: app node phải vứt đi được bất cứ lúc nào mà không mất gì. Mọi thứ cần sống lâu hơn một request đều phải nằm ngoài node — session, cache, file upload, job. Node chỉ còn code và config.

Config nginx trên app node

server {
    listen 80;
    server_name app.example.com;
    root /var/www/app/current/public;
    index index.php;

    charset utf-8;
    client_max_body_size 20m;

    # Health check cho LB — không chạm PHP, luôn trả lời được kể cả khi FPM tắc.
    location = /healthz {
        access_log off;
        return 200 "ok\n";
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # File tĩnh: nginx trả thẳng, không tốn 1 child PHP nào.
    location ~* \.(?:css|js|jpg|jpeg|png|gif|ico|svg|woff2)$ {
        expires 1y;
        access_log off;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/app.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;

        fastcgi_read_timeout 60s;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    # Chặn PHP ở thư mục khác public — phòng khi upload lọt file .php
    location ~ /\.(?!well-known).* {
        deny all;
    }
}
$realpath_root thay vì $document_root: khi deploy bằng symlink (current -> releases/2026-08-29), $realpath_root trả về đường dẫn thật đã resolve. Không có nó, opcache vẫn giữ file của release cũ sau khi đổi symlink — deploy xong mà code không đổi, một trong những lỗi khó đoán nhất.

Config nginx làm load balancer

upstream app_nodes {
    least_conn;                 # gửi tới node đang ít kết nối nhất

    server 10.0.1.11:80 max_fails=3 fail_timeout=15s;
    server 10.0.1.12:80 max_fails=3 fail_timeout=15s;
    server 10.0.1.13:80 max_fails=3 fail_timeout=15s;
    server 10.0.1.14:80 backup;   # chỉ dùng khi các node trên chết hết

    keepalive 32;               # giữ sẵn connection, đỡ bắt tay TCP mỗi request
}

server {
    listen 443 ssl;
    http2 on;                   # nginx >= 1.25.1. Bản cũ hơn: listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/ssl/app/fullchain.pem;
    ssl_certificate_key /etc/ssl/app/privkey.pem;

    location / {
        proxy_pass http://app_nodes;
        proxy_http_version 1.1;
        proxy_set_header Connection "";        # bắt buộc để keepalive hoạt động
        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;

        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
        proxy_next_upstream error timeout http_502 http_504;
    }
}
Hai dòng dễ quên nhất. (1) proxy_set_header Connection "" — không có nó thì keepalive 32 vô tác dụng, nginx vẫn mở connection mới mỗi request. (2) X-Forwarded-Proto — không có nó Laravel tưởng request là HTTP, sinh URL http:// trong trang HTTPS, redirect loop. Phải kèm cả TrustProxies ở phía Laravel (mục 3.7).

2. Lợi ích

2.1. Được gì khi tách nginx và PHP-FPM đúng cách

2.2. Tách pool: cái lợi ít người dùng tới

Endpoint xuất báo cáo chạy 20 giây có thể ăn hết sạch child và làm chết cả site. Cách chặn: cho nó một pool riêng, với trần riêng.

; pool.d/web.conf — traffic thường
[web]
listen = /run/php/web.sock
pm = static
pm.max_children = 40
request_terminate_timeout = 30s

; pool.d/report.conf — endpoint nặng, trần thấp
[report]
listen = /run/php/report.sock
pm = static
pm.max_children = 6
request_terminate_timeout = 300s
location ^~ /reports/ {
    try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    # Chọn pool theo prefix URI đã lưu ở biến
    fastcgi_pass unix:/run/php/web.sock;
    fastcgi_read_timeout 30s;
}

Cách sạch hơn là dùng map để chọn socket theo URI:

map $request_uri $fpm_pool {
    default              "unix:/run/php/web.sock";
    ~^/reports/          "unix:/run/php/report.sock";
    ~^/exports/          "unix:/run/php/report.sock";
}

server {
    # ...
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass $fpm_pool;
        fastcgi_read_timeout 300s;
    }
}

Giờ báo cáo có chậm và chiếm hết 6 child thì trang chủ vẫn còn nguyên 40 child. Đây là bulkhead pattern (chia khoang kín như khoang tàu — một khoang ngập nước không làm chìm cả tàu).

2.3. Được gì khi scale ngang


3. Tác hại / hạn chế — danh sách bẫy đầy đủ

Bảy cái bẫy dưới đây là toàn bộ những thứ hỏng khi đi từ 1 node lên n node. Bẫy 1–4 là loại "chạy vẫn được nhưng sai lúc có lúc không" — loại khó chịu nhất.

3.1. Bẫy 1 — Session lưu trên file: user bị logout ngẫu nhiên

Triệu chứng: user đăng nhập xong, F5 vài lần thì bị đá ra. Với 3 node, tỉ lệ gặp là 2/3.

Vì sao: mặc định SESSION_DRIVER=file ghi vào storage/framework/sessions/trên đúng cái node đó. Request sau rơi vào node khác, không thấy file session, coi như chưa đăng nhập.

# .env
SESSION_DRIVER=redis
SESSION_CONNECTION=default
REDIS_HOST=10.0.1.20
Đừng chữa bằng sticky session. Sticky session (LB luôn gửi 1 user về 1 node) làm mất cân bằng tải, và node chết là toàn bộ user trên node đó mất session. Nó chỉ là băng dán tạm lúc chưa kịp chuyển sang Redis. Chi tiết ở Canary deployment cho Laravel + Livewire.

3.2. Bẫy 2 — File upload nằm trên đĩa local

Triệu chứng: upload ảnh xong xem được, 10 phút sau vào lại thì 404. Hoặc "lúc thấy lúc không" tuỳ lần F5.

Vì sao: Storage::disk('public')->put(...) ghi vào storage/app/public của node 1. Request đọc rơi vào node 2 — không có file.

FILESYSTEM_DISK=s3
AWS_ENDPOINT=https://minio.internal:9000   # dùng MinIO nếu không muốn phụ thuộc AWS
AWS_USE_PATH_STYLE_ENDPOINT=true

Nếu chưa chuyển được sang S3 thì NFS là phương án tạm — nhưng NFS chậm hơn đĩa local nhiều lần và tự nó lại thành điểm chết duy nhất mới.

3.3. Bẫy 3 — Cache file: mỗi node một sự thật

Triệu chứng: admin sửa cấu hình, gọi Cache::forget('settings'). Node 1 xoá cache của node 1. Node 2 và 3 vẫn phục vụ giá trị cũ, có khi nhiều ngày.

CACHE_STORE=redis

Áp dụng cho cả rate limiter và lock. Cache::lock() trên driver file ở môi trường nhiều node không khoá được gì — mỗi node khoá cái khoá của riêng nó. Đây là chỗ sinh ra double-charge, double-send mail: code có lock, log nhìn đúng, mà vẫn chạy 2 lần.

3.4. Bẫy 4 — Scheduler chạy trên mọi node

Triệu chứng: job tổng kết cuối ngày chạy 3 lần. Email nhắc nợ gửi 3 bản cho cùng một khách.

Vì sao: đưa dòng php artisan schedule:run vào crontab của cả 3 node.

Hai cách chữa, chọn một:

Schedule::command('report:daily')
    ->dailyAt('01:00')
    ->onOneServer()          // yêu cầu cache driver là redis/memcached/dynamodb
    ->withoutOverlapping();  // lần chạy trước chưa xong thì bỏ qua lần này
onOneServer() không hoạt động nếu cache driver còn là file hay array. Chữa bẫy 4 mà chưa chữa bẫy 3 thì coi như chưa chữa — và nguy hiểm hơn, vì bạn tưởng đã chữa xong.

3.5. Bẫy 5 — Deploy lệch phiên bản giữa các node

Triệu chứng: giữa lúc deploy, user tải HTML từ node đã cập nhật (asset app-b1c4.js) nhưng request lấy file JS rơi vào node cũ → 404 → trang trắng. Livewire thì lỗi "component not found".

Cách chữa:

3.6. Bẫy 6 — Opcache giữ code cũ sau khi đổi symlink

Triệu chứng: deploy xong, symlink đã trỏ release mới, mà site vẫn chạy code cũ. Reload nginx không ăn thua.

Vì sao: opcache cache bytecode theo đường dẫn. Đường dẫn current/app/Http/Controllers/X.php không đổi, nên opcache tưởng file không đổi.

; php.ini production
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0     ; production: không kiểm tra mtime → nhanh nhất
opcache.revalidate_freq=0

Với validate_timestamps=0, sau mỗi lần deploy bắt buộc:

sudo systemctl reload php8.3-fpm   # reload: master giữ nguyên, child cũ xử nốt request rồi thoát

Cộng thêm $realpath_root ở config nginx (mục 1.8) để nginx đưa đường dẫn thật của release cho FPM.

3.7. Bẫy 7 — Đứng sau LB thì mất IP thật và mất scheme

Triệu chứng: log thấy mọi request đến từ đúng 1 IP (IP của LB). Rate limiter theo IP chặn nhầm toàn bộ user. Redirect loop vô hạn trên HTTPS.

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: ['10.0.1.0/24'],       // dải IP của LB. KHÔNG dùng '*' nếu LB không phải cửa duy nhất
        headers: Request::HEADER_X_FORWARDED_FOR
            | Request::HEADER_X_FORWARDED_HOST
            | Request::HEADER_X_FORWARDED_PORT
            | Request::HEADER_X_FORWARDED_PROTO,
    );
})
Đây là lỗ hổng bảo mật, không chỉ là bug. Đặt at: '*' mà app node vẫn nhận request trực tiếp từ Internet (chưa chặn firewall) thì client tự đặt header X-Forwarded-For: 1.2.3.4 để giả IP — qua mặt rate limit, làm bẩn audit log, vượt IP allow-list. Luôn ghi rõ dải IP của LB, và chặn firewall để app node chỉ nhận từ LB.

3.8. Bảng tra nhanh: state nào phải đi đâu

Thứ cần lưuMặc định (sai khi nhiều node)Phải đổi thànhKhông đổi thì hỏng gì
SessionfileredisLogout ngẫu nhiên
Cache + lockfileredisDữ liệu cũ, lock không khoá được gì
Queuesyncredis / SQSRequest treo vì chạy job ngay trong request
File uploadlocals3 / MinIO404 lúc có lúc không
Logsingle (file)stderr + gom về 1 chỗPhải SSH vào từng node mới đọc được log
Schedulercron mọi node1 node riêng, hoặc onOneServer()Job chạy n lần
Broadcast (websocket)logReverb/Pusher + RedisEvent chỉ tới user đang nối vào 1 node

3.9. Giới hạn cứng của mô hình PHP-FPM


4. Trade-off

4.1. PHP-FPM và Laravel Octane

PHP-FPMOctane (FrankenPHP / Swoole / RoadRunner)
BootstrapMỗi request nạp lại frameworkNạp 1 lần, worker sống lâu, tái dùng
ThroughputNềnThường 2–4× (số này phụ thuộc app, phải tự đo)
Memory leakKhông thể tích luỹ — hết request là xoá sạchVấn đề thật. Biến static, singleton, listener giữ rác qua nhiều request
Rò state giữa userKhông thểCó thể. Cache user A trong singleton → user B đọc được. Lỗi bảo mật thật
Debug lúc 3h sángAi cũng biết đọcCần hiểu vòng đời worker mới debug được
Package bên thứ baCái nào cũng chạyPackage cũ giữ state có thể vỡ

Tài liệu Octane nói rõ hai luật bắt buộc (kiểm chứng qua Context7):

php artisan octane:start --server=frankenphp --workers=8 --max-requests=500

Tiêu chí chọn: nếu bottleneck là query DB chậm thì Octane không cứu được gì — vẫn ngồi đợi DB, chỉ tốn thêm rủi ro. Octane đáng giá khi đã tối ưu DB xong mà thời gian bootstrap chiếm tỉ trọng lớn trong tổng thời gian request. Đo trước, đổi sau.

4.2. Unix socket và TCP

Unix socket (/run/php/app.sock)TCP (127.0.0.1:9000)
Tốc độNhanh hơn chút — không qua TCP stackChậm hơn không đáng kể
Cùng máyBắt buộcĐược
Khác máy / container khácKhông đượcBắt buộc
Phân quyềnPhải khớp listen.owner/group/mode — sai là 502Không cần lo
Backlog đầyHay gặp lỗi connect() to unix:... failed (11: Resource temporarily unavailable)Rõ ràng hơn

Chọn: nginx và FPM cùng máy/cùng container → unix socket. Kiến trúc container tách rời (nginx 1 container, fpm 1 container) → TCP, và khi đó dùng upstream + keepalive + fastcgi_keep_conn on để đỡ chi phí bắt tay TCP:

upstream fpm_backend {
    server php-fpm:9000;
    keepalive 16;
}

server {
    listen 80;
    root /var/www/app/public;

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass fpm_backend;
        fastcgi_keep_conn on;      # cần dòng này thì keepalive ở trên mới có tác dụng
    }
}

4.3. Các đánh đổi còn lại

Quyết địnhChọn A khiChọn B khi
pm = static vs dynamicstatic: máy dành riêng cho app, traffic đều, muốn RAM đoán trước đượcdynamic: máy chia sẻ với DB/Redis, traffic chênh lệch ngày/đêm lớn
Scale dọc vs ngangDọc: app còn stateful, team nhỏ, chấp nhận downtime lúc bảo trìNgang: cần HA (không được chết), cần deploy zero-downtime, đã tách state xong
nginx LB vs cloud LB (ALB/GCLB)nginx: tự chủ, rẻ, cấu hình sâu — nhưng chính nó thành điểm chết, phải có 2 con + keepalivedCloud LB: HA sẵn, tự cấp TLS, tốn tiền theo giờ + theo GB
1 pool vs nhiều pool1 pool: app đồng nhất, mọi endpoint nhanh như nhauNhiều pool: có endpoint nặng (export, report, webhook) cần cách ly
request_terminate_timeout ngắn vs dàiNgắn (30s): bảo vệ pool, request treo bị giết sớmDài (300s): chấp nhận cho job nặng chạy — chỉ ở pool riêng

5. Khó khăn khi vận hành thực tế

5.1. Không có pm.status_path thì đang bay mù

Không bật status page thì không có cách nào biết pool sắp đầy. Chỉ biết khi user báo 502 — tức là đã muộn.

; pool config
pm.status_path = /fpm-status
# nginx — chỉ cho mạng nội bộ vào
location = /fpm-status {
    allow 127.0.0.1;
    allow 10.0.1.0/24;
    deny all;

    include fastcgi_params;
    fastcgi_pass unix:/run/php/app.sock;
    fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
}

Bốn con số phải đưa lên dashboard và đặt alert:

Chỉ sốÝ nghĩaNgưỡng alert
active processesSố child đang bận> 80% max_children trong 5 phút
listen queueSố request đang xếp hàng ở socket> 0 kéo dài — đây là cảnh báo sớm nhất
max children reachedSố lần pool chạm trần từ lúc startTăng thêm bất kỳ
slow requestsSố request vượt request_slowlog_timeoutTăng đột ngột
listen queue > 0 nghĩa là request đang phải đợi. User đã thấy chậm rồi, dù chưa ai thấy lỗi. Alert vào đây, đừng đợi tới lúc có 502.

5.2. Debug lúc 3h sáng: 502 hay 504?

flowchart TD
  S["Alert: lỗi 5xx tăng"] --> Q1{"Mã lỗi gì?"}

  Q1 -->|"502"| B1["curl unix socket / port 9000 từ chính node"]
  B1 --> B2{"FPM còn sống?"}
  B2 -->|"Không"| B3["systemctl status php8.3-fpm
rồi kiểm tra dmesg xem có OOM killer không"] B2 -->|"Còn"| B4["Xem fpm-status: max children reached tăng?
→ pool đầy, backlog tràn"] Q1 -->|"504"| C1["Xem slowlog — có stack trace PHP"] C1 --> C2{"Kẹt ở đâu?"} C2 -->|"PDO::execute"| C3["Query chậm → pg_stat_activity,
tìm lock hoặc thiếu index"] C2 -->|"curl / Guzzle"| C4["API bên ngoài treo →
đặt timeout, thêm circuit breaker"] C2 -->|"file_get_contents"| C5["I/O chậm — hay gặp với NFS"] B4 --> F["Cấp cứu: tăng max_children nếu RAM còn,
hoặc tắt tạm endpoint nặng bằng feature flag"] C3 --> F

Ba lệnh phải thuộc lòng:

# 1. Pool đang bận bao nhiêu, có ai xếp hàng không
curl -s "http://127.0.0.1/fpm-status?full" | head -20

# 2. RAM trung bình mỗi child (MB) — để tính lại max_children
ps --ppid $(pgrep -o php-fpm) -o rss= | awk '{s+=$1; n++} END {print s/n/1024 " MB, n=" n}'

# 3. Request nào đang treo, treo ở dòng code nào
tail -50 /var/log/php-fpm/app-slow.log
slowlog là công cụ quý nhất và ít người bật nhất. Nó không chỉ ghi URL chậm — nó ghi cả stack trace PHP tại thời điểm chậm, chỉ thẳng ra file và số dòng đang kẹt. Không có nó thì chỉ biết "endpoint này chậm", có nó thì biết "chậm ở OrderRepository.php:142".

5.3. Chi phí và con người

5.4. Kiểm chứng config trước khi reload

Toàn bộ file config nginx trong bài (app node, load balancer, map chọn pool, fastcgi_keep_conn, và block /fpm-status) đã được gộp lại và chạy nginx -t thật trên nginx 1.24.0 — output nguyên văn:

$ nginx -t -p ./nginxcheck -c ./nginxcheck/nginx.conf
nginx: the configuration file ./nginxcheck/nginx.conf syntax is ok
nginx: configuration file ./nginxcheck/nginx.conf test is successful

Lần chạy đầu fail thật, và đây là bẫy đáng nhớ:

nginx: [emerg] unknown directive "http2" in nginx.conf:49
http2 on; là directive riêng, chỉ có từ nginx 1.25.1. Trên 1.24.0 (bản mặc định của Ubuntu 24.04) phải viết kiểu cũ: listen 443 ssl http2;. Copy config từ blog viết cho bản mới về máy bản cũ là gặp ngay. Đây chính là lý do nginx -t phải chạy trước mọi lần reload.

Quy trình bắt buộc mỗi lần sửa config:

sudo nginx -t                      # sai cú pháp thì dừng ở đây, chưa ai bị ảnh hưởng
sudo systemctl reload nginx        # reload, không restart — connection đang chạy không bị cắt

sudo php-fpm8.3 -t                 # kiểm tra pool config
sudo systemctl reload php8.3-fpm
reload khác restart. reload: master giữ nguyên, child cũ xử nốt request đang chạy rồi mới thoát — không ai bị cắt giữa chừng. restart: giết sạch, mọi request đang chạy đứt ngang. Trên production luôn dùng reload.

6. Case thực tế và cách xử lý

Case 1 — 12h trưa, 502 Bad Gateway hàng loạt

Bối cảnh (ví dụ minh hoạ): app bán hàng, 1 node 4 vCPU / 8 GB, pm.max_children = 20. Chạy tốt nhiều tháng. Chạy campaign flash sale, traffic từ 60 lên 250 RPS. 502 tràn ngập.

Xử lý theo thứ tự:

  1. Xác nhận nguyên nhân. curl /fpm-statusactive processes: 20/20, listen queue: 340, max children reached: 1 204. Pool đầy, không phải FPM chết.
  2. Cấp cứu ngay (2 phút). Đo RSS: 95 MB/child. 20 × 95 = 1.9 GB, máy còn trống 4.5 GB. Nâng pm.max_children lên 40, reload php-fpm. 502 hết, chuyển thành "hơi chậm".
  3. Chặn nguồn gốc (30 phút). Slowlog chỉ ra 70% request chậm nằm ở ProductController@index, kẹt tại PDO::execute. Đọc code: N+1 query khi render danh sách sản phẩm. Thêm with(['category', 'images']). Thời gian trung bình từ 420 ms xuống 90 ms.
  4. Tính lại theo Little's Law. 250 × 0.09 = 23 concurrent. 40 children giờ là dư thoải mái.
  5. Chặn tái diễn. Alert khi active/max > 0.8. Bật slowlog vĩnh viễn. Thêm test đếm số query cho endpoint danh sách.
Bài học: tăng max_children là cầm máu, không phải chữa bệnh. Nếu chỉ dừng ở bước 2 thì lần sale sau vẫn sập, chỉ là ở mốc traffic cao hơn. Bước 3 mới là chữa.

Case 2 — Một endpoint chậm làm chết cả site

Bối cảnh: thêm tính năng "xuất Excel đơn hàng". Kế toán bấm xuất 50 000 dòng, mất 90 giây. 5 người cùng bấm. Toàn bộ site đứng, kể cả trang chủ.

Vì sao: 5 request × 90 giây chiếm 5 child trong suốt 90 giây. Cộng thêm traffic thường, pool 20 child cạn sạch. Một endpoint ít người dùng nhất làm chết endpoint nhiều người dùng nhất.

Xử lý:

  1. Đúng nhất — đẩy sang queue. Endpoint chỉ dispatch job rồi trả ngay "đang xử lý, xong sẽ gửi mail link tải". Child được trả lại pool sau 50 ms thay vì 90 giây.
  2. Nếu chưa kịp sửa code — cách ly bằng pool riêng. Route /exports/* sang pool report với max_children = 4 (mục 2.2). Xuất Excel có nghẽn thì chỉ nghẽn trong 4 child đó.
  3. Đặt trần cứng. request_terminate_timeout = 30s ở pool web — không request web nào được phép chạy quá 30 giây.
  4. Chặn ở tầng ứng dụng. Rate limit 1 lần / phút / user cho endpoint xuất file.

Case 3 — Thêm node thứ 2, user kêu bị đá ra liên tục

Bối cảnh: dựng node 2, thêm vào nginx upstream. 10 phút sau ticket đổ về: "đang thao tác thì bị logout", "giỏ hàng tự trống", "ảnh vừa upload xong đã 404".

Chẩn đoán: ba triệu chứng khác nhau nhưng cùng một gốc — state còn nằm trên đĩa của từng node. Đúng ba bẫy 3.1, 3.2, 3.3.

Xử lý:

  1. Rút node 2 khỏi LB ngay. Quay về 1 node, hết lỗi. Sửa xong rồi thêm lại — đừng vừa sửa vừa để user chịu trận.
  2. Chuyển session và cache sang Redis. SESSION_DRIVER=redis, CACHE_STORE=redis. Lưu ý: đổi session driver là toàn bộ user bị logout một lần — làm vào lúc vắng và báo trước.
  3. Chuyển file upload sang S3/MinIO, và migrate file cũ đang nằm ở node 1 lên trước khi bật.
  4. Rà lại bảng 3.8 — hết lượt cả 7 dòng, không bỏ dòng nào. Bẫy còn sót lại thường là scheduler (bẫy 4), vì nó chỉ lộ ra sau 24 giờ khi job cuối ngày chạy 2 lần.
  5. Test lại có kiểm soát: thêm node 2 vào LB nhưng đặt weight=1 so với weight=10 của node 1 — chỉ ~9% traffic vào node mới. Theo dõi 1 giờ, sạch thì cân bằng lại.

Case 4 — Scale app xong thì PostgreSQL chết

Bối cảnh: từ 1 node lên 3 node, mỗi node max_children = 48. App tự nhiên báo FATAL: sorry, too many clients already.

Vì sao: 3 × 48 = 144 kết nối tiềm năng tới PostgreSQL, trong khi max_connections mặc định là 100. Mỗi kết nối PostgreSQL là một process riêng, tốn ~5–10 MB — không thể nâng max_connections lên vô hạn.

Xử lý:

  1. Đặt PgBouncer trước PostgreSQL, chế độ transaction pooling: 144 kết nối từ app gom lại còn ~25 kết nối thật tới DB.
  2. Lưu ý với transaction pooling: không dùng được prepared statement dạng session-level, LISTEN/NOTIFY, và advisory lock ở mức session. Với Laravel + PDO cần đặt PGBOUNCER đúng chế độ và tắt prepared statement nếu gặp lỗi prepared statement "pdo_stmt_00000001" already exists.
  3. Tách đọc/ghi. Trỏ read query sang replica bằng cấu hình read/write trong config/database.php. Lưu ý replication lag — ghi xong đọc lại ngay có thể chưa thấy dữ liệu.
  4. Bài học chung: mỗi lần scale tầng trên, kiểm tra lại trần của tầng dưới. App node rẻ và dễ nhân bản; database thì không.

7. Câu hỏi phụ interviewer hay hỏi tiếp

"Tính pm.max_children thế nào?"

Lấy RAM còn trống sau khi trừ OS, nginx, Redis và buffer; chia cho RSS trung bình thật đo được của 1 child (Laravel thường 80–150 MB). Rồi đối chiếu với Little's Law (RPS × p95) xem có đủ không. Nếu con số RAM cho phép nhỏ hơn con số cần thì không tăng children được — phải giảm thời gian xử lý hoặc thêm node.

"502 và 504 khác nhau ở đâu?"

502 = nginx không kết nối được tới FPM (pool đầy + backlog tràn, hoặc FPM chết, hoặc sai socket). 504 = kết nối được nhưng FPM trả lời quá fastcgi_read_timeout (query chậm, API ngoài treo). 502 nhìn fpm-status, 504 nhìn slowlog.

"pm = static hay dynamic?"

Máy dành riêng cho app, traffic đều → static: RAM đoán trước được, không tốn thời gian fork đúng lúc peak. Máy chạy chung nhiều thứ hoặc traffic ngày/đêm chênh lệch lớn → dynamic. ondemand chỉ cho dev/staging hoặc server nhiều site nhỏ.

"Vì sao pm.max_children quá cao lại nguy hiểm hơn quá thấp?"

Quá thấp thì request xếp hàng — chậm, nhưng còn sống và tự hồi phục khi traffic giảm. Quá cao thì lúc peak FPM fork thật, RAM cạn, OOM killer bắn chết process ngẫu nhiên — có thể là PostgreSQL. Từ "chậm" thành "sập", và không tự hồi phục.

"Có Octane rồi thì bỏ được PHP-FPM chưa?"

Chưa chắc. Octane bỏ được phần bootstrap framework, đáng giá khi bootstrap chiếm tỉ trọng lớn. Nếu request chủ yếu là đợi DB thì Octane không cứu gì, chỉ thêm rủi ro rò state giữa các user qua singleton/static và memory leak tích luỹ. Đo trước, đổi sau, và bắt buộc đặt --max-requests.

"Nhiều node thì lỗi đầu tiên gặp là gì?"

User bị logout ngẫu nhiên — session driver còn là file. Ngay sau đó là cache file mỗi node một sự thật, file upload nằm local, và scheduler chạy n lần. Cả 4 đều cùng một gốc: state còn nằm trên đĩa của node.

"Sticky session có ổn không?"

Là băng dán tạm, không phải giải pháp. Nó làm lệch tải, và node chết là toàn bộ user trên node đó mất session. Dùng khi đang trong quá trình chuyển sang Redis, không phải đích đến.

"Scale app node xong, tầng nào chết tiếp?"

Database. số node × max_children kết nối đổ dồn về PostgreSQL, dễ vượt max_connections. Đặt PgBouncer ở giữa (transaction pooling) và tách read query sang replica. App node nhân bản dễ, database thì không.

"nginx làm gì để app đỡ tải nhất?"

Trả file tĩnh trực tiếp — mỗi file tĩnh nginx trả là một child PHP được giữ lại. Thêm gzip/brotli, cache header dài cho asset có hash trong tên, và fastcgi_cache cho trang public không phụ thuộc user (nhớ bỏ qua cache khi có cookie đăng nhập).

"Deploy xong mà code không đổi, vì sao?"

Opcache cache theo đường dẫn. Symlink current không đổi tên nên opcache tưởng file cũ. Chữa bằng $realpath_root trong config nginx và systemctl reload php-fpm sau mỗi lần deploy.


Checklist trước khi thêm node thứ 2


Đọc thêm trong repo này