Một website báo lỗi 502 có thể do nginx trên balancer, do PHP-FPM trên minion, do một câu query khoá bảng trên database, hoặc trên Kubernetes là do pod vừa bị kill vì vượt giới hạn RAM. Mở đầu series vận hành, bài này trình bày quy trình điều tra sự cố trên hạ tầng nhiều tầng: đi theo đường đi của request, mỗi tầng hỏi đúng một câu, lấy bằng chứng từ log trước khi đoán, và ghi lại mọi thứ để viết hậu kiểm. Quy trình áp dụng cho cả mô hình Cloud VPS Balancer – Minion – Database lẫn cluster Kubernetes. Các bài sau của series là từng tình huống điển hình, viết theo đúng khung ở cuối bài.

1. Vì sao điều tra sự cố nhiều tầng dễ lạc hướng

Trong mô hình nhiều tầng, một request đi qua ít nhất ba máy, mỗi máy một bộ log và có thể một múi giờ ghi log khác nhau. Ba kiểu lạc hướng hay gặp nhất khi điều tra sự cố:

  • Sửa ở tầng mình quen: thấy 502 là restart PHP-FPM, trong khi lỗi thật nằm ở database. Site sống lại vài phút rồi chết tiếp, và bằng chứng đã mất theo lần restart.
  • Nhìn một site, kết luận cho cả cụm: site chậm có thể do code của nó, cũng có thể do site khác ăn hết tài nguyên máy.
  • Đọc log sai mốc giờ: VPS thường ghi log theo giờ địa phương, còn event trong Kubernetes theo UTC. Lệch vài tiếng là đủ để buổi điều tra sự cố đi theo một đoạn log không liên quan.

Nguyên tắc chung: khoanh vùng trước, đi từ ngoài vào trong, chỉ chuyển sang tầng sau khi tầng trước đã có bằng chứng.

2. Bản đồ đường đi của request: điểm xuất phát của điều tra sự cố

Trước khi điều tra sự cố, người trực phải vẽ được đường đi của request tới site đang lỗi. Hai sơ đồ tổng quát:

CLOUD VPS
  trình duyệt ─► (Cloudflare, nếu có) ─► balancer (nginx, SSL)
      ─► minion (nginx local ─► PHP-FPM theo phiên bản)
      ─► máy database (MySQL, nhiều schema)
  log: /var/log/nginx/<domain>.error.log | /var/log/phpX.Y-fpm.log
       <thư mục site>/storage/logs/laravel.log

KUBERNETES
  trình duyệt ─► load balancer (NodeBalancer trên LKE) ─► ingress controller
      ─► Service ─► pod của site ─► database
  log: kubectl logs | Loki | kubectl get events

Cả hai cùng cấu trúc cổng vào, tầng chạy code, tầng dữ liệu, nên quy trình điều tra sự cố bên dưới dùng chung, chỉ khác công cụ.

3. Bước 1 khi điều tra sự cố: khoanh vùng

Ba câu hỏi đầu tiên, nên trả lời trong vài phút: lỗi ở một site hay nhiều site; các site lỗi có chung balancer, minion, database hay node không; lỗi bắt đầu từ lúc nào, và lúc đó có ai vừa deploy hay đổi cấu hình không. Nhiều site cùng một minion lỗi thì nghi minion; nhiều minion chung một database thì nghi database.

Công cụ khoanh vùng nhanh nhất là curl đo thời gian từng chặng, rồi gọi thẳng vào từng tầng để loại dần Cloudflare, DNS và balancer:

# đo từng chặng: DNS, TCP, TLS, byte đầu tiên, tổng
curl -o /dev/null -s -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
     https://example.com/

# gọi thẳng balancer, bỏ qua Cloudflare và DNS công cộng
curl -sk -o /dev/null -w '%{http_code} %{time_total}\n' --resolve example.com:443:10.0.0.12 https://example.com/

# gọi thẳng minion qua mạng private, bỏ qua balancer
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: example.com' http://10.0.0.21/

Gọi thẳng minion vẫn chậm thì balancer vô can; minion nhanh mà qua balancer chậm thì lỗi ở balancer hoặc đường mạng. Ý nghĩa từng biến của -w có trong tài liệu curl.

4. Tầng cổng vào: balancer và ingress controller

Trên Cloud VPS, nên cho mỗi domain một file log riêng trên balancer. connect() failed (111: Connection refused) nghĩa là không nói chuyện được với minion, upstream timed out (110) nghĩa là minion nhận request nhưng trả lời quá chậm. Bảng giải nghĩa 502, 504, 413 có ở bài Tầng Balancer: Nginx reverse proxy, SSL offload và định tuyến domain. Khi điều tra sự cố, thứ cần thêm là quy mô:

# lỗi upstream của một domain, gom theo phút
grep -E 'upstream timed out|Connection refused|prematurely closed' \
     /var/log/nginx/example.com.error.log | awk '{print $1" "substr($2,1,5)}' | uniq -c | tail

# các domain đang lỗi có cùng trỏ về một minion không
grep -h 'upstream timed out' /var/log/nginx/*.error.log \
     | grep -oE 'upstream: "http://[0-9.]+' | sort | uniq -c | sort -rn | head

Lệnh thứ hai thường là bước ngoặt: mọi dòng lỗi cùng trỏ về một IP minion thì phạm vi chỉ còn một máy. Trên Kubernetes, vai trò này thuộc về ingress controller. Log của ingress-nginx có định dạng nginx và tên upstream dạng namespace-service-port, nên lọc theo site rất dễ:

kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --since=30m \
  | grep site-a | grep -E ' 50[234] ' | tail

5. Tầng chạy code: minion và pod

Trên minion, xem lần lượt: dịch vụ còn sống không, PHP-FPM hết worker chưa, ứng dụng ghi gì, kernel có kill tiến trình nào.

systemctl status nginx php8.2-fpm --no-pager | grep -E 'Active|Main PID'
grep 'max_children' /var/log/php8.2-fpm.log | tail -3
tail -n 50 /var/www/example.com/storage/logs/laravel.log
dmesg -T | grep -iE 'out of memory|killed process' | tail

Dòng server reached pm.max_children chỉ cho biết PHP-FPM hết worker, chưa nói vì sao worker bận: traffic tăng thật, bot, hay đang chờ database. Vì vậy bước tiếp theo là xem tầng dữ liệu, thay vì vội tăng max_children. Cấu hình PHP-FPM trên minion có ở bài Tầng Minion: chạy nhiều phiên bản PHP song song.

Điều tra sự cố trên Kubernetes dùng cùng bộ câu hỏi, chỉ khác lệnh. Pod đã restart thì log của lần chạy trước chỉ còn lấy được bằng --previous:

kubectl get pods -n site-a -o wide          # RESTARTS, node đang chạy
kubectl describe pod <tên-pod> -n site-a    # Last State: OOMKilled? Exit code?
kubectl logs <tên-pod> -n site-a --previous | tail -50
kubectl get events -n site-a --sort-by=.lastTimestamp | tail

Event của Kubernetes mặc định chỉ được giữ khoảng một giờ, nên với sự cố đã qua cần tìm trong hệ thống log tập trung như Loki (xem bài Logging trên Kubernetes: từ promtail, fluentd đến Loki):

{namespace="site-a"} |~ "SQLSTATE|Allowed memory size|max_children"

Nhiều pod cùng node restart một lúc thì nhìn lên node (RAM, đĩa); một pod restart lặp lại thì nghi limit của chính pod.

6. Điều tra sự cố ở tầng dữ liệu

Điều tra sự cố ở tầng dữ liệu thường bắt đầu từ một manh mối ở tầng trên: PHP-FPM cạn worker mà CPU minion vẫn thấp, nghĩa là worker đang chờ, thường là chờ database. Xem những gì đang chạy:

mysql --login-path=db-1 -e "SHOW FULL PROCESSLIST" | grep -v Sleep | sort -k6 -n -r | head -20
mysql --login-path=db-1 -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'"

Ba dạng hay gặp: một query chạy rất lâu kéo theo cả hàng đợi; nhiều kết nối kẹt ở trạng thái chờ khoá; số kết nối chạm max_connections. Ý nghĩa các trạng thái có trong tài liệu General Thread States của MySQL; cấu hình tham khảo ở bài Tầng Database: MySQL đa tenant trên VPS cấu hình nhỏ. Nếu database chạy trong cluster, các câu lệnh trên chạy qua kubectl exec vào pod MySQL.

7. Nhật ký điều tra sự cố: ghi trong lúc làm

Mỗi lần điều tra sự cố, người trực nên mở một file ghi chép và điền theo thời gian, kể cả những hướng đã thử mà sai. Mẫu dưới đây chỉ minh hoạ cách ghi, dùng mốc tương đối tính từ lúc nhận báo lỗi:

T+0     Báo lỗi: site-a.example trả 502                  (nguồn: hỗ trợ khách)
T+3p    curl --resolve vào balancer: 502 sau 0,1 giây
T+5p    error log balancer: Connection refused → 10.0.0.21 (mọi domain trên minion này)
T+7p    Giả thuyết 1: PHP-FPM chết → SAI, php-fpm active, không có max_children
T+10p   nginx trên minion không chạy; dmesg có Killed process (nginx)
T+11p   Khởi động lại nginx → 200   (khắc phục tạm, chưa phải nguyên nhân gốc)

Cột thời gian, nguồn bằng chứng và ghi chú "khắc phục tạm" là ba thứ quan trọng nhất của nhật ký điều tra sự cố.

Hậu kiểm nên theo một khung cố định, và cả series này cũng theo khung đó: Triệu chứng, Điều tra (đúng thứ tự đã làm), Nguyên nhân gốc, Khắc phục (tạm thời rồi lâu dài), Phòng ngừa, Bài học. Hậu kiểm viết theo tinh thần không đổ lỗi: không nêu tên người, chỉ ra hệ thống đã cho phép lỗi xảy ra ở đâu, điều gì chưa rõ thì ghi "chưa xác định".

8. Những cái bẫy khi điều tra sự cố

BẫyHậu quảCách tránh
Restart trước, xem log sauMất trạng thái lúc lỗi: tiến trình treo, query đang chạy, pod cũChụp processlist, kubectl describe, logs --previous rồi mới restart
Tự động khởi động lại che mất nguyên nhânSite sống lại nhưng lỗi lặp lại mà không ai thấyMọi cơ chế tự sửa (xoá pod khi gặp 502, restart dịch vụ chết) phải để lại log và thông báo
Log đã xoay, pod đã tạo lạiMất bằng chứng của lần lỗi đầu tiênzgrep cả file .gz; tìm trong Loki thay vì chỉ kubectl logs
Cloudflare che lỗi thật của originChỉ thấy trang lỗi của CloudflareLuôn thử curl --resolve thẳng vào balancer
Đổi nhiều thứ cùng lúcHết lỗi nhưng không biết nhờ thay đổi nàoMỗi lần một thay đổi, ghi lại, kiểm tra rồi mới làm tiếp

9. Checklist và bài học

  • Vẽ được đường đi request của site lỗi trước khi mở log.
  • Trả lời ba câu khoanh vùng: một hay nhiều site, chung tầng nào, bắt đầu từ khi nào.
  • Gọi thẳng từng tầng bằng curl --resolve và -H 'Host:' để loại trừ.
  • Chụp lại trạng thái trước mọi lần restart.
  • Ghi nhật ký điều tra sự cố có mốc thời gian, nguồn bằng chứng, múi giờ.
  • Kết thúc bằng hậu kiểm và ít nhất một thay đổi phòng ngừa.

Thứ tiết kiệm thời gian nhất khi điều tra sự cố không phải công cụ mà là thói quen: khoanh vùng trước, bằng chứng trước giả thuyết. Quan sát tập trung (metric, log gom về một chỗ) rút ngắn điều tra sự cố đáng kể so với SSH vào từng máy. Các bài của series nằm trong chuyên mục blog kỹ thuật của Sudo.

Bài tiếp theo

Conflict khi nhiều pipeline cùng push: push_with_retry và bài học GitOps. Khi nhiều pipeline cùng ghi vào một repo Helm, lệnh git push có thể bị từ chối, và một hàm thử lại phải được viết cẩn thận hơn ta tưởng.