Bảo Vệ Sandbox Của Dịch Vụ Skynet
Ngày hôm qua, lần đầu tiên chúng tôi mở thử nghiệm nhỏ cho tựa game MMO mới và đã phát sinh một số vấn đề bất ngờ. Sau 3 tiếng vận hành, bộ nhớ server đột ngột tăng vọt trong khi CPU không có biến động rõ rệt. Lúc đó, kỹ sư hệ thống SA đã nhận được cảnh báo qua email nhưng vì trùng giờ ăn trưa và các chức năng game vẫn hoạt động bình thường nên đã chậm xử lý nửa tiếng. Sự chậm trễ này khiến chúng tôi không thu thập đủ dữ liệu người chơi online trước khi server hoàn toàn tê liệt. Một nguyên nhân khác là quên cấu hình lưu file core dump.
Trong những phút cuối cùng, chúng tôi phát hiện một dịch vụ Lua bị treo trong vòng lặp vô hạn code C. Dù console skynet có chức năng gửi tín hiệu gián đoạn (có thể dừng máy ảo Lua), tín hiệu này lại không hiệu quả trong trường hợp này. Phân tích log cho thấy bộ nhớ tăng đột biến trong tích tắc, không phải do tích lũy lâu dài. Sau lần sập đầu tiên, server được khởi động lại ngay lập tức. Đồng thời chúng tôi chạy đồng thời test áp lực với bot trên cả môi trường nội bộ và công cộng, nhưng không thể tái tạo được sự cố.
Qua sự việc này, tôi nhận ra điểm yếu trong công cụ thu thập trạng thái sự cố thời gian thực của skynet. Ngay lập tức tôi bổ sung thêm các script hỗ trợ. Chẳng hạn, chức năng quan sát trạng thái dịch vụ và bộ nhớ trên console skynet hiện tại hoạt động bằng cách tuần tự gửi yêu cầu kiểm tra đến từng dịch vụ, sau đó tổng hợp kết quả. Khi có dịch vụ treo, toàn bộ chuỗi kiểm tra sẽ bị chặn đứng, không xuất được báo cáo.
Cách khắc phục không phức tạp: Chỉ cần thu thập từng dịch vụ riêng lẻ với cơ chế giới hạn thời gian chờ. Như vậy dù có dịch vụ chậm, hệ thống vẫn thu được báo cáo từng phần và phát hiện được dịch vụ treo. Việc không chuẩn bị sẵn script này đã khiến xử lý sự cố gặp khó khăn.
Đến buổi chiều, sự cố tái diễn. Lần này được phát hiện kịp thời khi server vẫn còn thao tác được. Chúng tôi nhanh chóng xác định dịch vụ bị treo và dùng gdb attach trực tiếp vào tiến trình debug. Phát hiện ra luồng làm việc bị kẹt trong quá trình GC (thu gom rác) của Lua khi duyệt qua một bảng dữ liệu cực lớn. Mặc dù Lua sử dụng cơ chế GC từng bước, nhưng việc duyệt qua một bảng với hàng tỷ slot lại là thao tác nguyên tử. Khi bảng dữ liệu lớn vượt quá bộ nhớ vật lý, hệ thống phải dùng đến phân vùng swap trên ổ cứng, khiến việc duyệt dữ liệu diễn ra trên ổ cứng - cực kỳ chậm chạp.
Chúng tôi xác định dịch vụ này chính là thủ phạm, bảng dữ liệu trên chiếm hơn 90% bộ nhớ. Từ stack call cho thấy dịch vụ đang xử lý một tin nhắn mạng chỉ 15 byte (log cũng ghi nhận địa chỉ và độ dài tin nhắn này, trong tương lai sẽ cân nhắc dump trực tiếp nội dung trên log).
Do giao thức truyền tin sử dụng sproto, tôi viết script nhỏ decode 15 byte đó bằng sproto và tái tạo được hiện trường. Hóa ra sự cố bắt nguồn từ việc một người chơi truyền vào số lượng vật phẩm mua bất thường. Server xử lý mua bán bằng vòng lặp O(n), trong quá trình xử lý liên tục insert dữ liệu vào bảng tạm. Khi số lượng lên đến hàng trăm triệu, hệ thống lập tức cạn kiệt bộ nhớ.
Chi tiết sự cố như sau: Khi mảng table Lua đầy, hệ thống sẽ cấp phát gấp đôi kích thước. Khi mảng đủ lớn, việc cấp phát gấp đôi sẽ kích hoạt GC. Nhưng GC lại phải duyệt qua tất cả object trong VM. Nếu dùng đến swap, bảng dữ liệu khổng lồ này sẽ được tải từ ổ cứng vào bộ nhớ để duyệt, đồng thời copy sang vùng nhớ mới - khiến vòng lặp chạy cực chậm.
Một phát hiện bất ngờ: Khi gdb attach và treo luồng lỗi lại khiến server hoạt động mượt mà. Lý do là vì GC bị dừng, không còn duyệt bộ nhớ, trong khi vẫn còn dư dung lượng cho các tiến trình khác. skynet sử dụng cơ chế đa luồng làm việc đồng đẳng, mỗi luồng chỉ lấy việc khi cần, không có hàng đợi tin nhắn riêng. Dịch vụ bị ảnh hưởng chỉ liên quan đến một người chơi (thực tế đã offline từ lâu) nên không làm tê liệt các dịch vụ khác.
Nếu lúc đó trực tiếp dừng luồng lỗi và hotfix lỗ hổng, server hoàn toàn có thể tiếp tục vận hành. Tuy nhiên hậu quả là không thu hồi được VM Lua. Phương án thân thiện hơn là tiến hành quy trình đóng server bình thường, logout toàn bộ người chơi trước khi kill tiến trình.
Qua sự cố này, tôi cho rằng skynet nên bổ sung tính năng giới hạn bộ nhớ cho từng VM Lua. Dù skynet đang chuẩn bị phát hành phiên bản 1.0 với nguyên tắc không thêm tính năng mới, tôi vẫn quyết định thêm vào vì cho rằng đây là tính năng quá quan trọng. Người dùng có thể chọn không dùng mà không gây ảnh hưởng lớn.
Ví dụ sử dụng: . Để kích hoạt, cần gọi skynet.memlimit ngay đầu script với đơn vị byte. Khi VM vượt giới hạn, hệ thống sẽ ném lỗi bộ nhớ. Trong đa số trường hợp, VM vẫn có thể tiếp tục xử lý các công việc thoát ra, vì các thao tác vượt giới hạn thường do các yêu cầu cấp phát lớn một lần (như nhân đôi bảng). Dù luồng hiện tại bị ngắt, các coroutine độc lập xử lý tin nhắn khác vẫn hoạt động bình thường nhờ cơ chế skynet.
Tôi khuyến nghị các dịch vụ đại diện người chơi nên giới hạn ở mức 128MB. Dựa trên kinh nghiệm, trong điều kiện bình thường con số này thường dưới 10MB. Hiện tại, hệ thống cũng tự động ghi log cảnh báo khi VM sử dụng 32MB/64MB/128MB (gấp đôi liên tiếp)… để hỗ trợ debug sự cố trên môi trường thực tế.
P/s: Sau sự cố, chúng tôi kiểm tra hai người chơi gây lỗi và phát hiện đây là hai người dùng khác nhau, sử dụng thiết bị khác nhau.