Trên một minion chạy vài website Laravel Octane, tất cả các site cùng trả 502 Bad Gateway trong gần một phút rồi tự sống lại. Chỉ một site thật sự hết bộ nhớ, các site còn lại không có gì bất thường nhưng vẫn chết theo. Đây là tình huống Octane OOM điển hình khi nhiều site Octane dùng chung một supervisor. Tài liệu này mô tả cách điều tra (kể cả cái bẫy hay gặp khi nhìn biểu đồ), cơ chế kernel và systemd biến một lần Octane OOM của một site thành sự cố của cả minion, và cách tách miền lỗi để lần sau chỉ một site chịu ảnh hưởng.

Kiến thức nền về Octane, FrankenPHP và cách đặt nó sau nginx có trong bài Laravel Octane và FrankenPHP: chạy Laravel như một app server.

Triệu chứng của Octane OOM trên minion dùng chung

  • Mọi site Octane trên cùng minion trả 502 cùng một lúc, kể cả site không có lưu lượng.
  • Lỗi kéo dài chừng một phút, sau đó tất cả tự phục hồi mà không ai can thiệp.
  • Biểu đồ RAM của minion không có đỉnh nào chạm trần. Ngay sau mốc sự cố, đường RAM lại tụt sâu.
  • Trước đó một site vừa nhận lượng truy cập tăng đột biến, ví dụ từ một chiến dịch quảng cáo.

Quy trình điều tra

Bước 1: đừng kết luận từ biểu đồ

Câu hỏi đầu tiên là "máy có hết RAM không?". Nhìn biểu đồ thì câu trả lời có vẻ là không. Đây là cái bẫy. Prometheus lấy mẫu RAM theo chu kỳ vài chục giây, còn cú phình bộ nhớ của tiến trình chỉ kéo dài trong khoảnh khắc trước khi bị giết. Ngay khi tiến trình bị kill, bộ nhớ được trả lại, nên biểu đồ chỉ kịp ghi một cú tụt. Với Octane OOM, biểu đồ chỉ dùng để xem xu hướng: máy có đang sát mép từ trước không (swap đã dùng nhiều, Free còn rất ít), chứ không dùng để kết luận.

Bước 2: kernel log là nguồn sự thật về Octane OOM

Đọc log của kernel và của systemd quanh mốc sự cố. Lưu ý journalctl nhận mốc theo múi giờ của máy, thường là UTC:

# log kernel quanh mốc sự cố
journalctl -k --since "YYYY-MM-DD HH:MM" --until "YYYY-MM-DD HH:MM" | grep -i "out of memory"

# trạng thái unit supervisor trong cùng khoảng thời gian
journalctl -u supervisor --since "YYYY-MM-DD HH:MM" --until "YYYY-MM-DD HH:MM"

# kết quả điển hình (rút gọn)
Out of memory: Killed process 4479 (frankenphp) total-vm:...kB, anon-rss:...kB
supervisor.service: Failed with result 'oom-kill'.

Dòng Killed process cho biết chính xác tiến trình nào bị giết và RSS của nó lúc đó. Trong tình huống điển hình, một tiến trình frankenphp của một site chiếm tới hơn một nửa RAM của máy. Kết luận đầu tiên: không phải "máy thiếu RAM chung chung", mà là một tiến trình phình to.

Bước 3: vì sao một site phình to tới Octane OOM

Đếm access log của site đó theo từng phút để xem lượng truy cập:

# số request mỗi phút trong một giờ quanh sự cố
awk '{print substr($4, 2, 17)}' /var/log/nginx/site-a.example.access \
  | sort | uniq -c | sort -k2

Một site chạy Octane với --workers=1 --max-requests=500 (tham số mô tả trong tài liệu Laravel Octane) có nghĩa là một worker phục vụ đủ 500 request mới được làm mới. Khi lưu lượng lên hàng chục request mỗi giây, vòng đời đó chỉ còn vài giây, nhưng tiến trình vẫn có thể phình lên hơn 1 GB nếu code giữ lại dữ liệu giữa các request (biến static, singleton tích luỹ, cache trong bộ nhớ). Nếu chưa chỉ ra được đoạn code giữ bộ nhớ, hãy ghi nhận mức phình và lưu lượng, đừng đoán.

Bước 4: vì sao các site khác cùng chết

Đây là câu hỏi quan trọng nhất, vì nó biến một lần Octane OOM thành sự cố của cả minion. Khi các site được khai báo là program trong cùng một supervisor, và supervisor chạy dưới một unit systemd duy nhất, toàn bộ tiến trình Octane nằm trong cùng một cgroup:

system.slice/supervisor.service          ← một unit, một cgroup
 ├─ supervisord
 ├─ site-a-octane   (frankenphp, RSS phình)  ← bị OOM killer giết
 ├─ site-b-octane   (frankenphp)             ← chết theo
 ├─ site-c-octane   (frankenphp)             ← chết theo
 └─ site-d-octane   (frankenphp)             ← chết theo

Khi kernel giết một tiến trình trong unit, systemd áp chính sách OOMPolicy= của unit đó. Theo tài liệu systemd.service, mặc định là stop: unit bị dừng hẳn, kéo theo mọi tiến trình còn lại trong cgroup. Đó là dòng Failed with result 'oom-kill'. Unit supervisor.service đóng gói sẵn của Debian có Restart=on-failure và RestartSec=50s, nên systemd đợi đúng 50 giây rồi mới bật lại supervisor. Thời lượng gián đoạn do cấu hình mặc định này quyết định, không phải do thời gian site lỗi hồi phục.

# kiểm tra nhanh chính sách của unit
systemctl show supervisor -p OOMPolicy -p Restart -p RestartUSec -p KillMode

Nguyên nhân gốc: Octane OOM chỉ là mồi lửa

  1. Mồi lửa: lưu lượng tăng đột biến vào một site, trên một tiến trình chạy dài tích luỹ bộ nhớ qua nhiều request.
  2. Không có vách ngăn bộ nhớ: không site nào có giới hạn RAM riêng, nên Octane OOM xảy ra ở mức cả máy. Tiến trình được phép ăn tới khi cạn RAM và swap, lúc đó kernel mới ra tay.
  3. Chung một miền lỗi: nhiều site chung một unit systemd. Với OOMPolicy=stop, lỗi của một tiến trình con trở thành lỗi của cả unit, và RestartSec quyết định thời lượng gián đoạn.

Lớp thứ ba mới là thứ biến sự cố của một site thành sự cố của cả minion. Nếu chỉ tối ưu code của site gây lỗi, lần Octane OOM sau ở một site khác vẫn kéo sập mọi site. Minion RAM thấp, đã chạy sát giới hạn là trường hợp không nên dùng Octane nếu không theo dõi được RAM từng tiến trình.

Khắc phục

Ngay sau sự cố Octane OOM

Về dịch vụ, thường không cần thao tác gì: supervisor tự được khởi động lại. Việc cần làm ngay sau một lần Octane OOM là xác nhận đúng nguyên nhân bằng kernel log, thay vì restart thêm dịch vụ khiến log bị trộn. Trong lúc chờ đổi kiến trúc:

  • Giảm --max-requests của site đang có lưu lượng lớn để worker được làm mới thường xuyên hơn.
  • Khi biết trước có chiến dịch lớn, theo dõi RSS của tiến trình frankenphp trực tiếp trên máy (ps -o pid,rss,cmd -C frankenphp), không chỉ qua biểu đồ.

Lâu dài: mỗi site Octane một unit systemd

Để lần Octane OOM tiếp theo chỉ ảnh hưởng đúng một site, tách phần HTTP của mỗi site Octane ra một unit systemd riêng, có cgroup và giới hạn bộ nhớ riêng. Supervisor chỉ còn giữ queue worker. Unit mẫu:

# /etc/systemd/system/octane-site-a.service
[Service]
User=www-data
WorkingDirectory=/var/www/site-a.example
ExecStartPre=/bin/sh -c 'fuser -k -9 8001/tcp 2>/dev/null || true'
ExecStart=/usr/bin/php8.4 artisan octane:start --server=frankenphp \
    --host=127.0.0.1 --port=8001 --admin-port=9001 \
    --workers=1 --max-requests=100
Restart=always
RestartSec=1s
KillMode=control-group
MemoryHigh=450M
MemoryMax=600M
Tiêu chíChung supervisorUnit riêng mỗi site
Phạm vi ảnh hưởng khi một site bị Octane OOMMọi site Octane trên minionChỉ site đó
Thời gian tự hồi phụcTheo RestartSec của gói (50 giây)Vài giây (RestartSec=1s)
Giới hạn RAMKhông có, ăn tới khi cạn máyMemoryHigh bắt đầu thu hồi, MemoryMax giết trong cgroup của site
Queue workerChung supervisor với webỞ lại supervisor, tách khỏi web

Ba điểm cần kiểm chứng trước khi áp phương án chống Octane OOM này:

  • Tổng MemoryMax lớn hơn RAM máy: giới hạn riêng chặn được một site phình, nhưng nhiều site cùng phình vẫn có thể đẩy cả máy vào OOM toàn cục. Hãy cộng tổng và so với RAM thật.
  • Giới hạn khởi động lại của systemd: với RestartSec=1s, một site lỗi liên tục có thể chạm ngưỡng StartLimitBurst mặc định và bị systemd ngừng khởi động lại. Cần quyết định rõ hành vi này.
  • Chuyển đổi không chồng chéo: program Octane phải được gỡ khỏi supervisor trước khi bật unit, nếu không hai bên sẽ giành cùng một cổng.

Lỗi thường gặp

  • Kết luận "không hết RAM" vì biểu đồ không có đỉnh. Cú phình quá ngắn để lấy mẫu bắt được.
  • Restart cả supervisor khi deploy một site. Mỗi lần deploy lại tạo một đợt gián đoạn cho mọi site, giống hệt một lần Octane OOM.
  • Đặt MemoryMax mà không đặt MemoryHigh. Tiến trình bị giết đột ngột thay vì được thu hồi bộ nhớ dần.

Checklist phòng ngừa Octane OOM

  • Cảnh báo dựa trên sự kiện OOM trong kernel log, không chỉ dựa trên ngưỡng RAM trung bình.
  • Mọi tiến trình chạy dài (Octane, queue worker) phải có giới hạn bộ nhớ riêng, ở tầng cgroup hoặc tham số của chính ứng dụng.
  • Không đặt nhiều site chung một unit systemd nếu một site lỗi được phép dừng cả unit. Kiểm tra OOMPolicy, Restart, RestartSec của mọi unit đang gánh nhiều site.
  • Deploy một site Octane chỉ tác động tới program của site đó (supervisorctl restart <program>).
  • Đội vận hành cần được báo trước các chiến dịch quảng cáo lớn để theo dõi đúng minion.
  • Ghi mỗi lần Octane OOM vào sổ sự cố kèm tên site, RSS lúc bị giết và lưu lượng quanh đó, để thấy site nào lặp lại.

Bài học

  • Biểu đồ nói về xu hướng, kernel log nói về sự kiện. Với Octane OOM hay bất kỳ OOM nào, luôn bắt đầu từ journalctl -k.
  • Thời lượng sự cố do cấu hình quyết định. Giá trị mặc định của gói phần mềm không ai chọn cho hệ thống của mình, hãy đọc và đặt lại.
  • Vách ngăn quan trọng hơn tối ưu. Tối ưu code giảm khả năng lặp lại ở một site. Tách miền lỗi mới chặn được việc Octane OOM lan sang site khác.
  • Hậu kiểm không đổ lỗi. Chiến dịch quảng cáo là việc bình thường của khách hàng. Hạ tầng phải chịu được nó, hoặc ít nhất chỉ để site đó chịu thiệt.

Bài tiếp theo

no live upstreams: đọc error.log Nginx khi upstream bị đánh dấu chết. Cách phân biệt upstream timed out, connection refused và no live upstreams, và điều gì xảy ra khi cả nhóm upstream cùng bị đánh dấu chết.