1 điểm bởi GN⁺ 2023-07-31 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 UserCleanupScheduler xó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 đó URI củ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 URI và việc lên lịch chạy worker bị đảo lệch tại thời điểm đọc thực tế
  • Mọi tài khoản local trên instance Mastodon đều có giá trị URInull, nên khi worker gộp những tài khoản có cùng URI và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 .dump sang .sql rồ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 .sql 54GB 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
  • 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

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

 
GN⁺ 2023-07-31
Ý 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

    • Tôi cuối cùng cũng tạo tài khoản chỉ để trả lời ý này, hy vọng không nghe có vẻ quá công kích
      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 đó
    • Phương án thay thế là chuỗi rỗng à?
      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
    • Tự động hợp nhất/khử trùng lặp là một trong những bài toán rất khó, khi xử lý các bản ghi “tương tự nhau” thì nên có con người can thiệp càng nhiều càng tốt. Nó đầy các tình huống ngoại lệ và race condition; đặc biệt dữ liệu được tiêu thụ bất đồng bộ cần được truyền đi rõ ràng nhất có thể, và phải qua nhiều kiểm tra để đảm bảo sự thật thực tế chưa thay đổi
      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
    • Dùng JOIN thì NULL là điều không tránh khỏi. Bản chất JOIN là vậy
      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 đó
    • Dù có null, hàm hợp nhất lẽ ra bằng cách nào đó phải thực hiện kiểm tra null hoặc kiểm tra giá trị đúng. Thật khó tin
  • 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

    • Đồng ý. Có câu “nếu bạn chưa kiểm thử bản sao lưu, thì coi như bạn không có bản sao lưu”
      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ợ

    • Hiện tổ chức phi lợi nhuận Mastodon không cung cấp hợp đồng hỗ trợ, nhưng đó là một ý tưởng hay
      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
    • Không có trên https://joinmastodon.org/sponsors nên có lẽ họ không phải nhà tài trợ
    • Dù vậy, họ cũng đang cung cấp một instance khá lớn cho liên minh Mastodon cùng nhân lực làm việc trên đó
  • Đâ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

    • Tôi nhớ bài phân tích hậu sự cố của hachyderm cũng khá tốt. Thật mừng khi mọi người công khai minh bạch
  • 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

    • Một trong các bản sửa liên quan là https://github.com/mastodon/mastodon/commit/13ec425b721c9594...
      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

    • Ngạc nhiên là vim lại bị segmentation fault. Tôi từng thấy mở file lớn thì chậm, nhưng luôn nghĩ nó có kiểu buffering kỳ diệu nào đó để xử lý được mọi thứ. Có thể tôi đã sai
      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
    • Trước đây tôi từng quản trị một hệ thống có quá nhiều file trong một thư mục cụ thể đến mức ngay cả lệnh ls cũng không chạy xong. Có lẽ là ext3 hoặc ext2
      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?

    • Khi xem tài khoản thì gặp lỗi HTTP 500, và ý là stack trace cho lỗi 500 đó
      Đâ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
    • Ghi lại stack trace của lỗi là việc khá hợp lý. Lý tưởng thì không phải request nào cũng sinh lỗi
    • Ý là trên hệ thống production bạn không capture stack trace của lỗi sao? Làm sao biết lỗi đến từ đâu?
    • Có vẻ bạn đang nhầm stack trace với core dump hoặc thứ tương 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 cũng thắc mắc. Có lẽ họ lọc ở tầng ứng dụng, rồi kiểm tra bằng phép bằng nhau với giá trị null của ngôn ngữ đang dùng
  • 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