Vivaldi Social đã gặp chuyện gì?
(thomasp.vivaldi.net)- Vào ngày 8/7/2023, các tài khoản người dùng cũ trên instance Mastodon của Vivaldi Social đã biến mất, và cuối cùng xảy ra sự cố 198 tài khoản bị gộp vào một tài khoản từ xa duy nhất
- Nguyên nhân không phải do xóa thủ công hay tấn công, mà là do hành vi gộp tài khoản của Mastodon kết hợp với cấu hình sao chép PostgreSQL dựa trên Makara của Vivaldi Social khiến thứ tự xử lý bị lệch
- Các tài khoản trông như đã bị xóa, nhưng tên người dùng lại được cấp phát lại và cả ảnh đại diện lẫn ảnh header cũng biến mất, nên vấn đề được thu hẹp vào cơ chế hoạt động nội bộ của ứng dụng Mastodon
- Đội vận hành vừa chuẩn bị rollback toàn bộ DB, vừa song song viết script khôi phục chọn lọc để phục hồi tài khoản, bài đăng, follow, follower và dữ liệu quan hệ
- Mastodon v4.1.5 bao gồm việc chặn Sidekiq worker dùng Makara và sửa thứ tự gộp tài khoản, nên quản trị viên máy chủ dùng DB replication cần kiểm tra đường đọc của worker
Cuối tuần sự cố khiến 198 tài khoản biến mất
- Khoảng 17:25 CEST thứ Bảy, ngày 8/7/2023, tab Vivaldi Social yêu cầu đăng nhập lại, và sau khi đăng nhập thì phát hiện timeline trang chủ trống rỗng
- Trên các tài khoản quản trị hệ thống khác cũng xuất hiện cùng triệu chứng; kiểm tra cơ sở dữ liệu cho thấy các tài khoản bị ảnh hưởng sau khi bị xóa thì sẽ được tạo lại như tài khoản mới khi người dùng đăng nhập lại
- Vivaldi Social có bản sao lưu đêm lúc 23:00 UTC thứ Sáu, và đội vận hành bắt đầu sao chép file backup để xác nhận khả năng khôi phục
- Trong thao tác xóa tài khoản Mastodon thông thường, tên người dùng sẽ bị đặt trước vĩnh viễn nên không thể tái sử dụng, nhưng trong sự cố này cùng tên người dùng lại được cấp phát lại, tức là không phải xóa bình thường
Việc xóa vẫn đang tiếp diễn
- Ban đầu, các tài khoản cũ có ID thấp hơn 142 đã biến mất; đến 19:10 thì cả các tài khoản có ID thấp hơn 217 cũng biến mất, cho thấy quá trình xóa vẫn đang tiếp diễn
- Lúc 19:18, đội ngũ đã nhờ các nhà phát triển Mastodon hỗ trợ; sau khi Renaud phản hồi, Claire và Eugen cũng tham gia điều tra
- Lúc 19:20, sau khi khởi động lại các instance Docker của Mastodon, việc xóa dừng lại và ID tài khoản thấp nhất trong cơ sở dữ liệu là 236
- Trong suốt sự cố, số tài khoản bị xóa hoặc bị gộp cuối cùng được xác nhận là 198
Thu hẹp xuống hành vi ứng dụng chứ không phải tấn công
- Đội vận hành và các nhà phát triển Mastodon đã kiểm tra khả năng
UserCleanupSchedulerxóa các tài khoản “unconfirmed”, nhưng loại trừ vì những người dùng bị xóa không thể khớp điều kiện của truy vấn đó - Vì đã nâng cấp lên Mastodon 4.1.3 trước sự cố 48 giờ, họ cũng rà soát các thay đổi giữa v4.1.2 và v4.1.3, bao gồm cả các thay đổi do Vivaldi công bố, nhưng không tìm ra nguyên nhân liên quan
- Trên filesystem, ảnh đại diện và ảnh header của các tài khoản bị xóa cũng biến mất, xác nhận rằng đây không phải xóa trực tiếp trong DB mà là ứng dụng Mastodon đã thực thi thao tác xóa
- Họ tìm dấu vết xâm nhập hay tấn công trong log và filesystem nhưng không có bằng chứng; cũng không xác nhận được khả năng khai thác liên quan đến bản vá bảo mật của Mastodon v4.1.3
- Tối thứ Bảy, đội ngũ triển khai một bản vá thêm log cho hành vi xóa tài khoản; sau khi bản vá được phát hành lúc 00:29 CEST, cả nhóm nghỉ ngơi
Manh mối quyết định: các bài đăng dồn vào một tài khoản từ xa
- 13:56 Chủ nhật, có báo cáo rằng trang hồ sơ của chuyên gia bảo mật Vivaldi là Yngve trả về lỗi HTTP 500; tài khoản này không nằm trong 198 tài khoản bị xóa
- Trong log, cùng một tài khoản từ cùng một instance Mastodon từ xa xuất hiện lặp đi lặp lại; trong bài viết, tài khoản này được ẩn danh là tài khoản trên
social.example.com - Truy vấn lấy status của tài khoản từ xa đó trả về 17.600 dòng
- Đến 14:43, so sánh với backup xác nhận rằng toàn bộ status của tất cả tài khoản đã bị xóa đã được gán lại cho một người dùng trên
social.example.com - Sau 15:00, thông qua log
AccountMergingWorker, Rails console và các truy vấn DB bổ sung, giả thuyết rằng worker gộp tài khoản đã gộp mọi tài khoản vào một tài khoản từ xa trở nên rất chắc chắn
Nguyên nhân gốc: gộp tài khoản và độ trễ sao chép PostgreSQL
- Vivaldi Social đang dùng cấu hình sao chép PostgreSQL 2 máy chủ, và tiến trình worker có thể đọc cơ sở dữ liệu từ máy chủ standby thông qua Makara
- Kịch bản sự cố mà Claire đưa ra lúc 17:28 như sau
- Vivaldi Social nhận được thông báo đổi tên tài khoản từ
social.example.com - Khi tài khoản mới được tạo trong cơ sở dữ liệu, trường
URIđược ghi lànull - Sau đó
URIcủa tài khoản mới được đặt thành giá trị đúng của tài khoản từ xa - Một tác vụ
AccountMergingWorkerđược lên lịch qua Redis để gộp dữ liệu từ tài khoản cũ sang tài khoản mới - Do độ trễ sao chép cơ sở dữ liệu, thứ tự giữa việc đặt
URIvà việc lên lịch chạy worker bị đảo lệch tại thời điểm đọc thực tế
- Vivaldi Social nhận được thông báo đổi tên tài khoản từ
- Mọi tài khoản local trên instance Mastodon đều có giá trị
URIlànull, nên khi worker gộp những tài khoản có cùngURIvào tài khoản từ xa mới, tất cả tài khoản local đều bị khớp vào - Các nhà phát triển cho rằng điều này càng dễ xảy ra khi tải cơ sở dữ liệu tăng cao khiến độ trễ sao chép kéo dài hơn
- Đội vận hành và các nhà phát triển Mastodon kết luận rằng cấu hình này rất có thể là nguyên nhân gốc
Bản vá và thay đổi cấu hình
- Sau khi thu hẹp được nguyên nhân, đội vận hành tập trung vào khôi phục dữ liệu, còn Claire đảm nhận việc viết bản vá để ngăn tái diễn
- Hlini phụ trách áp dụng bản vá và thay đổi cấu hình sao chép không còn được khuyến nghị nữa
- Trong lúc triển khai lúc 17:58 đã phát sinh sự cố, gây ra thời gian ngừng hoạt động toàn phần duy nhất trong cả cuối tuần; đến 18:18 thì Vivaldi Social hoạt động trở lại
- Đến 18:44, bản vá và thay đổi cấu hình đã được triển khai thành công, và đội ngũ đánh giá sự cố tương tự sẽ không lặp lại
Khôi phục: phục hồi chọn lọc thay vì rollback toàn bộ
- Ban đầu nhóm cân nhắc rollback toàn bộ cơ sở dữ liệu, nhưng do vấn đề hiệu năng đã biết nên phải thực hiện quy trình phức tạp: chuyển file backup
.dumpsang.sqlrồi chỉnh sửa file văn bản 54GB - Đội vận hành tiến hành song song thủ tục khôi phục toàn phần và khôi phục chọn lọc
- Hlini chỉnh sửa file
.sql54GB và chuẩn bị cho phương án khôi phục toàn phần - Thomas viết script phục hồi các tài khoản bị xóa và dữ liệu liên quan
- Hlini chỉnh sửa file
- Trong lúc viết script có lỗi xử lý tham chiếu trong parameter binding của truy vấn PDO, và Ísak là người phát hiện ra
- Đến 23:04, phần đầu tiên sửa các bản ghi user, account, identity của 198 người dùng bị ảnh hưởng đã hoàn tất
- Đến 23:55, script khôi phục chọn lọc để đưa status, follows, followers và dữ liệu quan hệ khác về trạng thái trước sự cố đã hoàn thành
Hoàn tất khôi phục chọn lọc và các chỉnh sửa tiếp theo
- Do ràng buộc quan hệ trong cơ sở dữ liệu, quá trình khôi phục được thực hiện theo 2 giai đoạn
- Trước tiên khôi phục bản ghi user/account/identity của toàn bộ 198 người
- Sau đó khôi phục phần dữ liệu quan hệ còn lại
- Nếu một số người dùng đã đăng nhập lại sau sự cố và thiết lập follow, sẽ phát sinh lỗi khóa trùng; script được sửa để xóa các bản ghi cũ không thể khôi phục và giữ lại bản ghi mới hơn
- Lúc 01:27 CEST thứ Hai, tác vụ cuối cùng của script hoàn tất; đến 01:40 thì việc lập chỉ mục lại home feed xong
- Kết quả là home feed của 198 tài khoản đã được khôi phục, và không còn cần rollback toàn bộ
- Trong thứ Hai và thứ Ba, các vấn đề phát sinh tiếp theo cũng được sửa thêm
- Vấn đề đăng nhập của 6 tài khoản có ký hiệu trong tên người dùng
- Mất dữ liệu thiết lập web của 198 tài khoản
- Sai lệch bộ đếm hồ sơ như số follower, số bài đăng
- 4 tài khoản có dữ liệu sai
Bản sửa chính thức của Mastodon
- Các nhà phát triển Mastodon đã cảnh báo cho quản trị viên các máy chủ khác về rủi ro khi chạy Mastodon với cấu hình sao chép dựa trên Makara
- Họ kết luận rằng kiểu cấu hình này khá hiếm, thường chỉ được cân nhắc ở các instance lớn như Vivaldi Social
- Mastodon v4.1.5 bao gồm hai bản sửa liên quan đến sự cố này
Dòng thời gian sự cố theo UTC
- Thứ Bảy 15:15: thông điệp đổi tên tài khoản từ một instance bên ngoài được gửi đến Vivaldi Social, và tác vụ gộp tài khoản sai bắt đầu
- Thứ Bảy 15:25: quan sát thấy dấu hiệu đầu tiên của sự cố
- Thứ Bảy 17:20: sau khi khởi động lại container Docker, tác vụ gộp tài khoản dừng lại; từ 15:15 đến 17:20 có tổng cộng 198 tài khoản bị xóa hoặc gộp
- Chủ nhật 13:00: xác định được nguyên nhân gốc có khả năng nhất
- Chủ nhật 14:25: xác nhận nguyên nhân gốc
- Chủ nhật 21:55: bắt đầu khôi phục dữ liệu
- Chủ nhật 23:27: hoàn tất khôi phục dữ liệu
- Thứ Hai 10:40: sửa 6 tài khoản có ký hiệu trong tên người dùng
- Thứ Hai 11:05: khôi phục dữ liệu thiết lập web đã mất
- Thứ Ba 15:31: sửa các giá trị bộ đếm bị sai
- Thứ Ba 16:01: sửa 4 tài khoản có dữ liệu sai
1 bình luận
Ý kiến trên Hacker News
Đây là một bài hồi tưởng rất hay, đặc biệt cũng thể hiện rõ cái giá về mặt con người như thiếu ngủ ảnh hưởng lớn thế nào đến việc xử lý các sự cố phức tạp
Phần nổi bật nhất với tôi là đoạn “tài khoản mới được tạo trong cơ sở dữ liệu với giá trị null ở trường URI”
Mỗi lần đọc phân tích hậu sự cố liên quan đến cơ sở dữ liệu, gần như lúc nào NULL cũng lẩn khuất gần hiện trường. Dù NULL không phải thủ phạm, nó luôn nên nằm trong danh sách bị thẩm vấn
Lời khuyên là đừng dựa vào NULL như một giá trị sentinel, và nếu có thể thì tốt nhất là không cho phép nó trong cơ sở dữ liệu. Dù có vẻ có lợi, vài năm sau ý nghĩa của mô hình dữ liệu thay đổi, rồi một câu lệnh trông vô hại nào đó kỳ vọng NULL hoặc NOT NULL lại cho ra kết quả bất ngờ, tạo thành lỗi khó tìm và triệt tiêu lợi ích ấy
Vụ này là một race condition, nhưng nếu tài khoản cục bộ và tài khoản từ xa được phân biệt rõ bằng kiểu dữ liệu, thứ tự thao tác có thể đã không quan trọng, và mã hợp nhất tài khoản cũng có thể được giới hạn trong phạm vi hẹp hơn
Null là một giá trị hoàn toàn hợp lệ của dữ liệu và nên được xử lý như vậy. Các giá trị mặc định kiểu dùng -1 cho boolean hoặc dùng giá trị rỗng cho chuỗi có thể khiến một hệ thống vốn sẽ báo lỗi khi chạy nếu là NULL trông như đang hoạt động, nhưng điều đó không có nghĩa hệ thống hoạt động đúng như kỳ vọng; nó chỉ im lặng hơn mà thôi
Tôi hiểu sự cám dỗ muốn che NULL đi, nhưng “không có” cũng là một trạng thái hợp lệ của dữ liệu ngang với “có”, và hệ thống nói chung nên được viết để chấp nhận điều đó
Trong trường hợp này, tôi nghĩ vấn đề không phải NULL trong cơ sở dữ liệu mà là NULL ở tầng ứng dụng
Nếu NULL là một giá trị buộc phải xử lý, giống như một dạng Maybe monad, thì cuối cùng ta sẽ phải xử lý nó và suy nghĩ về nó. Dù là chuỗi rỗng, chuỗi null của ngôn ngữ đang dùng, hay một giá trị đánh dấu đặc biệt tự tạo thì cũng không khác nhau nhiều
Trong nhiều trường hợp, người triển khai nên trước hết nghĩ đến các mối lo và yêu cầu tương tác mà xung đột hợp nhất kiểu Git đòi hỏi, rồi từ điểm xuất phát đó đặt ra các giả định đơn giản hóa phù hợp với miền bài toán
Nhìn vào mã nguồn Mastodon https://github.com/mastodon/mastodon/blob/main/app/workers/a..., dường như thậm chí còn không có danh sách rõ ràng “sẽ hợp nhất từ những ID nào” được phía khởi tạo yêu cầu hợp nhất truyền cho trình thực thi hợp nhất bất đồng bộ, nên có lẽ chuyện này xảy ra chỉ là vấn đề thời gian
Đây không phải lời chỉ trích Mastodon. Chính tôi từng viết logic hợp nhất có race condition còn tệ hơn nhiều và cũng đã chịu hậu quả. Thật ra, việc một tính năng như vậy tồn tại trong một dự án tình nguyện như https://opencollective.com/mastodon đã là điều đáng kinh ngạc. Dù vậy, đây vẫn là một trường hợp đáng để cảnh giác
Sâu xa hơn, thực tế vốn lộn xộn, và cơ sở dữ liệu không thể từ chối xử lý chỉ vì thực tế lộn xộn, nên NULL là không thể tránh. Ví dụ, nếu bạn muốn mô hình hóa kính ngữ, tiền tố danh xưng, hậu tố danh xưng rồi dùng dữ liệu đó để tạo lời chào đầy đủ, thì ít nhất sẽ có người không có hậu tố danh xưng. Ngay cả khi không lưu NULL, bạn vẫn sẽ nhận được NULL từ kết quả JOIN dùng để tạo lời chào
Có thể loại bỏ một số giá trị NULL cụ thể, nhưng không thể loại bỏ sự thật rằng trong thực tế “không áp dụng” hoặc “không biết” thường là giá trị hợp lệ, và cơ sở dữ liệu phải xử lý điều đó
Luồng diễn biến khiến tôi đồng cảm ở đây là bắt đầu từ “có bản sao lưu toàn bộ cơ sở dữ liệu nên chỉ cần khôi phục toàn bộ”, rồi chuyển sang “khôi phục toàn bộ khó, có downtime và tác dụng phụ”, sau đó lại thành “có thể khéo léo khôi phục một phần chỉ những dữ liệu bị thiếu”, làm thủ công thì gặp lỗi kỳ lạ, cuối cùng triển khai cơ chế khôi phục chọn lọc tạm thời rồi dọn nốt năm dữ liệu bị thiếu cuối cùng. Chỉ mong là họ không bỏ sót cái thứ sáu
Bất cứ ai luyện tập sao lưu/khôi phục thì lần nào cũng diễn ra theo kiểu này. Rốt cuộc, việc quyết định dữ liệu nào từ ảnh sao lưu cần được khôi phục luôn là chuyện ở cấp ứng dụng
Tuy vậy trong trường hợp này tôi không rõ vấn đề là gì. Nếu khôi phục toàn bộ từ bản sao lưu tốt cuối cùng thì một số bài đăng được đưa lên trong khoảng thời gian đó sẽ biến mất, đáng tiếc thật, nhưng đó là cách giải quyết ngay lập tức thay vì làm thủ công và chịu bất định
Tôi ấn tượng với đoạn Renaud, Claire và Eugen trong đội phát triển Mastodon đã giúp đỡ vượt quá mong đợi
Tôi không biết Vivaldi có hỗ trợ tài chính cho Mastodon hay không, và cũng không tìm thấy tên họ trên trang nhà tài trợ. Nếu chưa, hy vọng sự việc này sẽ khiến Vivaldi hoặc các công ty khác dùng Mastodon cân nhắc tài trợ hoặc hợp đồng hỗ trợ
Việc tài trợ vẫn mở và thực sự tạo tác động lớn. Có nhân sự toàn thời gian cho dự án là cực kỳ quan trọng, nhưng hiện phía kỹ thuật ngoài nhà sáng lập Eugen ra chỉ có 1 lập trình viên toàn thời gian và 1 người phụ trách DevOps
Đây là một trong những bài phân tích hậu sự cố khá hay mà tôi đọc được sau một thời gian dài
Việc mục 2 và 3 không được xử lý nguyên tử có vẻ là một vấn đề. Tất nhiên có thể có lý do khiến việc đó không hề đơn giản, nhưng tôi vẫn chưa xem code và lúc nào đó sẽ phải xem
Nhìn thì có vẻ việc làm cho nó trở nên nguyên tử khá đơn giản
Trước đây chỉ là không cần làm vậy. Ý là việc không nguyên tử cũng không gây vấn đề, trừ khi ai đó cấu hình tệ bằng cách nối sidekiq vào một server cơ sở dữ liệu cũ, tức là replica. Ở đây cấu hình đó có vẻ là vấn đề chính
Lần đầu phải khôi phục một SQL dump khổng lồ, tôi không thể quên cảnh vim thật sự bị lỗi segmentation fault khi cố đọc nó
Khi đó tôi phát hiện ra phép màu split(1), tức là chia file thành các mảnh. Tôi đã tách dump lớn thành mỗi bảng một file
Tất nhiên một bảng riêng lẻ cũng có thể rất lớn, nhưng ít nhất các file trở nên đồng đều hơn, dễ chuyển đổi query bằng các công cụ khác như sed hay awk hơn
Tuy vậy, đến mức phải chỉnh sửa dump để khôi phục dữ liệu thì quy trình khôi phục chắc chắn đã có gì đó sai nghiêm trọng. Dĩ nhiên khi thực sự rơi vào tình huống đó thì hiểu biết này cũng chẳng giúp được bao nhiêu
Cách vòng tránh là viết một script Python để xử lý mọi thứ dần dần, rồi chuyển file vào các thư mục con theo tiền tố chung
Đến đoạn “Claire yêu cầu toàn bộ stack trace của mục log, và cũng có thể trích xuất được nó từ log”, tôi đã phải nhướng mày
Đây hoặc là ma thuật voodoo thâm sâu, hoặc code/cấu hình đang biến Xeon thành mức 286. Chẳng phải mỗi request sẽ lên đến hàng megabyte sao?
Đây là hành vi mặc định của Ruby on Rails. Khi có lỗi 500 hoặc lỗi không xác định, nó in stack trace, nội dung chỉ cỡ số dòng và đường dẫn file
Tôi đang vận hành một app Rails có thiết kế khá tệ, vừa kiểm tra thì stack trace của một lỗi 500 là 5KiB. Lỗi 500 chỉ xảy ra khoảng một lần mỗi giờ, nên chưa đến 1MiB mỗi ngày
Giữ call stack ở gần thực ra khá ổn về hiệu năng. Hành vi ngoại lệ mặc định của Java cũng là kèm stack trace theo mỗi exception, dù không in ra, nhưng các ứng dụng Java vẫn chạy tốt. Dù sao cũng phải biết cách return, nên call stack vốn đã có sẵn, thông tin cần thêm chỉ là debug symbol gồm tên file và số dòng. Với đặc thù ngôn ngữ Ruby, thông tin đó dù sao cũng cần thiết
Làm sao có thể có chuyện “tất cả tài khoản local của instance Mastodon đều có trường URI là null nên tất cả đều khớp”?
NULL = NULL được đánh giá là FALSE. SQL dùng logic ba giá trị, chính xác hơn là logic ba giá trị yếu của Kleene, và áp dụng bất kỳ toán tử nào lên NULL cũng ra NULL
Tôi không rõ các tài khoản có giá trị NULL trong cột URI đã khớp query bằng cách nào. NULL không được so sánh là bằng NULL. Đây là ma thuật Rails kinh khủng nào đó à?
Đọc đoạn 6 người dùng có ký hiệu trong tên người dùng không đăng nhập được, và chuyện đó do lỗi trong script khôi phục nên đã sửa dễ dàng, tôi có cảm giác UTF-8 lại lập công thêm một lần nữa