Một Số Kỹ Thuật Phân Tích Coredump Bằng Gdb
Vài ngày trước, một sản phẩm đang vận hành đã gặp sự cố sụp đổ nghiêm trọng. Tôi đã dành hai ngày để phân tích coredump bằng gdb, dù không thể tìm ra nguyên nhân gốc rễ nhưng vẫn muốn tổng hợp lại những kinh nghiệm thu được.
Sản phẩm này được xây dựng trên nền tảng skynet, nhưng do lý do lịch sử nên đang sử dụng phiên bản giữa năm 2015 (trước cả skynet 1.0). Hai năm qua không gặp vấn đề gì nên đội ngũ bảo trì lơ là việc cập nhật phiên bản mới.
Điểm khó khăn lớn nhất là khi sự cố xảy ra, phần mã Lua thiếu thông tin symbol debug. Phiên bản skynet hiện tại đã bổ sung tùy chọn -g khi biên dịch lua, điều này sẽ giúp định vị vấn đề hiệu quả hơn trong tương lai.
Nguyên nhân trực tiếp gây sụp đổ là con trỏ lệnh (rip) nhảy đến một địa chỉ thuộc phân đoạn dữ liệu - cụ thể là con trỏ lua_State (L) của máy ảo Lua trong luồng làm việc hiện tại. Dấu hiệu này khá rõ ràng nhờ vào các symbol debug còn lại ở các phần khác của skynet: Khi xem stack trace, địa chỉ callback của service trùng với địa chỉ L. Dùng lệnh gdb p (lua_State *)<địa_chỉ>
có thể xác nhận đây đúng là cấu trúc lua_State.
Do stack trace (xem bằng bt) không bình thường, có thể suy luận rằng bộ nhớ stack của C đã bị ghi đè somewhere trong chuỗi gọi hàm. Lúc này gdb thường phải “đoán” lại chuỗi gọi hàm dựa trên dữ liệu còn sót lại.
Với các trình biên dịch hiện đại, khi bật tối ưu hóa (ví dụ -O2), gcc sẽ sử dụng tùy chọn -fomit-frame-pointer khiến thanh ghi rbp không còn chứa frame pointer nữa. Điều này làm cho việc phân tích stack thủ công trở nên phức tạp hơn. Khi stack bị lỗi, thay vì dựa vào bt, cách tốt hơn là dùng x/40xg $rsp
để in vùng nhớ stack, sau đó tìm kiếm các giá trị nằm trong phân đoạn mã bằng lệnh x/10i <địa_chỉ>
để kiểm tra.
Một điểm cần lưu ý: Dù tìm thấy địa chỉ trả về trên stack, không có nghĩa hàm đó chưa return. Vì khi gọi hàm, địa chỉ trở về sẽ được đẩy vào stack ngay cả khi hàm đã kết thúc. Đây cũng là nguyên nhân khiến gdb đôi khi đoán sai.
Trong trường hợp này, sự cố xảy ra khi nhảy đến phân đoạn dữ liệu - thường do gọi gián tiếp qua con trỏ hàm. Lúc này cần kiểm tra các thanh ghi (dùng info registers) vì trên nền tảng 64-bit, 4 tham số đầu tiên được truyền qua thanh ghi rdi/rsi chứ không phải stack.
Về mặt logic Lua, không thể gọi L như một hàm. Giả thuyết hợp lý hơn là lỗi xảy ra trong quá trình longjmp, khi khôi phục trạng thái thanh ghi bị lỗi.值得一提的是, setjmp thường thực hiện “mangling” trên các thanh ghi nhạy cảm như rsp/rbp để tránh lỗi.
Khi debug sự cố trong Lua, trạng thái của L là chìa khóa. Vì toàn bộ luồng nghiệp vụ được điều khiển bởi máy ảo Lua, thông tin callinfo (stack frame của Lua) đặc biệt quan trọng. Trong skynet bình thường có 2 luồng L hoạt động: Một cho phân phối tin nhắn, một cho coroutine xử lý nghiệp vụ. Có thể xác định L chính bằng cách so sánh giá trị L->l_G
.
Khi thiếu symbol debug, có thể dùng add-symbol-file
để nạp thông tin cấu trúc từ các file object đã biên dịch với -g (ví dụ ldo.o). Tôi đã viết script để phân tích stack trace của hai L, dựa trên cấu trúc callinfo trong lstate.h. Thông tin debug của Lua rất phong phú, có thể truy ra tên file và số dòng mã nguồn.
Trong sự cố lần này, chương trình dừng lại ở hàm resume của L chính. Luồng con gọi skynet.sleep (thực chất là yield với tham số “SLEEP” và session). Dù Lua đã pop dữ liệu khỏi stack, chỉ cần điều chỉnh con trỏ đỉnh stack mà không xóa dữ liệu, ta vẫn có thể xem được qua gdb.
Điều kỳ lạ là trong quá trình sao chép dữ liệu giữa các thread (hàm auxresume trong lcorolib.c), bước chèn boolean kết quả (trong luaB_coresume) dường như không hoàn thành dù dữ liệu đã được sao chép. Khoảng cách giữa hai bước này rất ngắn, gần như loại trừ khả năng lỗi ở đây.
Giải thích hợp lý nhất là trong quá trình lua_resume, luồng con đã làm hỏng stack frame C khiến auxresume không return đúng về luaB_coresume. Có thể kiểm chứng qua các trường L->nny
, L->nCcalls
, L->ci->callstatus
và đặc biệt là L->errorJmp
(chứa trạng thái longjmp).
Một điểm đáng chú ý khác: Sự cố xảy ra trong giai đoạn GCSpropagate của garbage collector (xác định qua gcstate), không liên quan đến __gc hay giải phóng bộ nhớ.
Bài học kinh nghiệm
- Luôn biên dịch Lua với -g - Dù Lua ít có lỗi nghiêm trọng, thông tin debug giúp phân tích sự cố hiệu quả hơn.
- Phân tích stack trace Lua qua
L->ci
- Cấu trúc này chứa đầy đủ thông tin về chuỗi gọi hàm. - Kiểm tra dữ liệu stack Lua - Dù đã bị pop, dữ liệu vẫn tồn tại trong bộ nhớ và phản ánh trạng thái lúc sụp đổ.
- Theo dõi trạng thái GC - Các trường
gcstate
,GCdebt
giúp xác định giai đoạn hoạt động của garbage collector. - Phân tích trạng thái gọi hàm qua
nny
,nCcalls
,errorJmp
- Đặc biệt lưu ýerrorJmp=NULL
với coroutine đang yield.
Mẹo debug skynet
- Kiểm tra đối tượng context - Chứa địa chỉ service hiện tại, session yêu cầu cuối cùng và số lượng tin nhắn nhận được.
- Dò mảng H toàn cục - Định nghĩa trong
skynet_handle.c
, chứa địa chỉ tất cả service đang chạy. - Kiểm tra timer trong TI - Cấu trúc trong
skynet_timer.c
giúp xác định các timer đang chờ đánh thức.
Phân tích coredump luôn là công việc đòi hỏi sự kiên nhẫn và hiểu biết sâu sắc về cơ chế hoạt động bên trong của hệ thống. Hy vọng những chia sẻ trên sẽ giúp ích cho các bạn trong quá trình debug các ứng dụng dựa trên Lua/skynet.