Lựa chọn tính bạo liệt của máy chủ
(cliffle.com)- Hubris là một hệ điều hành nơi các tác vụ cô lập giao tiếp qua IPC, và với lời gọi hệ thống thứ 13 là
REPLY_FAULT, máy chủ có thể kết thúc các yêu cầu sai từ client bằng fault thay vì trả về giá trị lỗi - Từ phía client, IPC trông giống như một lời gọi hàm, nhưng vì các tác vụ được biên dịch riêng biệt, trình biên dịch không thể chặn hoàn toàn mã thao tác sai, các byte không thể diễn giải, hoặc loaned memory không phù hợp
- Trong các chương trình Hubris bình thường, những lỗi như vậy hầu như không xuất hiện nhờ cấu hình build và mã Rust được sinh ra, nên nếu ép mọi lời gọi phải dùng
Result<T, IpcError>vàunwrap()thì sẽ làm tăng kích thước mã và chi phí thời gian chạy - Kernel sẽ giết ngay tác vụ vi phạm điều kiện tiên quyết của lời gọi hệ thống mà không trả mã lỗi, và
REPLY_FAULTmở rộng cùng chính sách fail-fast đó sang cả phản hồi của máy chủ - Thiết kế này giúp bộc lộ nhanh việc dùng API sai, nhưng khiến fuzz test hoặc các tác vụ chaos gửi IPC và lời gọi hệ thống ngẫu nhiên gần như bị khởi động lại ngay lập tức, nên khó kiểm thử
Vị trí của Hubris IPC và REPLY_FAULT
- Hubris đặt một kernel nhỏ độc lập với ứng dụng ở trung tâm, còn phần lớn mã như driver, logic ứng dụng và network stack nằm trong các tác vụ cô lập được biên dịch tách biệt
- Giao tiếp giữa các tác vụ được thực hiện bằng các lời gọi hệ thống IPC do kernel triển khai
RECV: lấy thông điệp nhận có ưu tiên cao nhất, hoặc block cho đến khi có thông điệp đếnSEND: dừng tác vụ gọi, chuyển thông điệp và quyền điều khiển cho tác vụ nhận, rồi chờ đến khi nhận được phản hồiREPLY: gửi phản hồi cho tác vụ đãSENDtrước đó để nó có thể chạy lại
- Trong Hubris, client và server không phải danh tính cố định mà là vai trò do tác vụ đảm nhận
- Tác vụ dùng
SENDđóng vai trò client - Tác vụ dùng
RECVvàREPLYđóng vai trò server - Một tác vụ có thể là server với tác vụ này nhưng lại là client với tác vụ khác
- Tác vụ dùng
Những lỗi mà trình biên dịch bỏ sót ở ranh giới tác vụ
- Trong lời gọi hàm thông thường, trình biên dịch và linker đảm bảo khá nhiều về kiểu dữ liệu và đích gọi
- Nếu một hàm Rust nhận đối số
String, trình biên dịch sẽ chặn việc caller truyềnbool - Việc nhầm đích gọi như định gọi
pet_catnhưng lại gọifire_missilescũng thường không xảy ra
- Nếu một hàm Rust nhận đối số
- IPC của Hubris vượt qua ranh giới tác vụ và mỗi tác vụ được biên dịch thành một chương trình riêng, nên trình biên dịch không thể trực tiếp xác minh toàn bộ quan hệ IPC
- Các lỗi mà máy chủ IPC có thể gặp nhìn chung có ba loại
- Mã thao tác không khớp với giao diện, ví dụ giao diện chỉ có hai thao tác nhưng lại nhận “operation number 48”
- Một nhóm byte không thể diễn giải thay vì kiểu thông điệp mong đợi, hoặc thông điệp quá ngắn hay quá dài
- Thiếu loaned memory cần thiết, hoặc đáng ra cần bộ nhớ ghi được nhưng lại nhận bộ nhớ chỉ đọc
Vì sao không ép chương trình bình thường phải xử lý lỗi
- Trong các chương trình Hubris bình thường, những lỗi IPC như vậy được cấu hình để không xảy ra
- Liên kết giữa các tác vụ được cấu hình bằng thiết lập của hệ thống build nên khó nhầm lẫn lẫn nhau
- Client tạo và gửi IPC bằng mã Rust được sinh ra
- Server cũng xử lý kết quả bằng một bộ mã Rust sinh riêng
- Nếu mọi thao tác IPC đều phải trả về
Result<T, IpcError>, thì chương trình bình thường sẽ phải thêmunwrap()cho những lỗi trên thực tế không thể gặpunwrap()là gánh nặng về kích thước mã- Đồng thời còn phát sinh chi phí kiểm tra những lỗi sẽ không xảy ra trong thời gian chạy
- Nếu đặt
unwrap()hoặcpanic!bên trong mã sinh ra, có thể tập trung vị trí panic để giảm ảnh hưởng tới kích thước mã, nhưng chi phí runtime vẫn giữ nguyên - Để hỗ trợ mã lỗi phổ quát, mọi thao tác đều phải tuân theo cùng một quy ước mã hóa lỗi
- Mọi thao tác đều phải có khả năng trả lỗi
- Mọi thao tác đều phải mã hóa lỗi theo cùng một cách
- Ngay cả thao tác không thể thất bại cũng phải được biểu diễn dưới dạng có thể thất bại
- Trong firmware dựa trên Hubris, người ta liên tục phát hiện các thao tác thực sự không thể thất bại, và cấu hình chân GPIO là một ví dụ
Chính sách fault quyết liệt của kernel Hubris
- Nhiều hệ điều hành sẽ trả mã lỗi hoặc cho cơ hội xử lý exception/signal ngay cả khi điều kiện tiên quyết của lời gọi hệ thống bị vi phạm
- Trên Unix, nếu gọi
closevới file descriptor chưa mở thì sẽ nhận mã lỗi - Nếu truyền null pointer vào
openthay vì pathname thì cũng sẽ nhận mã lỗi
- Trên Unix, nếu gọi
- Hubris sẽ hủy ngay tác vụ nếu nó phá vỡ điều kiện tiên quyết của lời gọi hệ thống
- Tác vụ đó không thể thực thi thêm lệnh nào nữa
- Bản thân tác vụ không có cơ hội phục hồi hay tiếp tục
- Tác vụ supervisor của ứng dụng sẽ được báo fault, và thường sẽ xóa rồi khởi động lại tác vụ đó
- Fault do kernel tạo ra là synthetic fault
- Nó tương tự hardware fault do CPU tạo ra như dereference null pointer hay chia cho 0
- Hardware fault sinh ra từ việc vi phạm quy tắc kiến trúc bộ xử lý, còn synthetic fault sinh ra từ việc vi phạm quy tắc của kernel
- Ví dụ, trong lời gọi
SEND, nếu chỉ số tác vụ nhận nằm ngoài phạm vi của ứng dụng, hoặc con trỏ thông điệp trỏ vào vùng nhớ không có quyền truy cập, thì sẽ phát sinh synthetic fault - Hubris không cho phép fault có thể phục hồi hoặc tiếp tục thực thi
- Dù là hardware fault hay synthetic fault, tác vụ nhận fault đều chuyển sang trạng thái đã chết
- Lựa chọn này nhằm tránh các chế độ lỗi tinh vi và đơn giản hóa việc suy luận về hệ thống
Cách máy chủ phản hồi client bằng fault
REPLY_FAULTlà lời gọi hệ thống cho phép máy chủ gửi fault đến client thay cho phản hồi bình thường- Luồng
REPLYthông thường diễn ra như sau- Khi client dùng
SEND, kernel đánh dấu tác vụ client ở trạng thái “waiting to send” đối với tác vụ nhận - Khi tác vụ nhận dùng
RECV, client đó chuyển sang trạng thái “waiting for reply” - Khi server gọi
REPLY, client quay lại trạng thái runnable
- Khi client dùng
REPLY_FAULTtương tựREPLY, nhưng thay vì chuyển thông điệp và đưa tác vụ về trạng thái có thể chạy, nó gửi fault để đưa tác vụ sang trạng thái đã chết- Server không thể giết tùy ý bất kỳ tác vụ nào
REPLY_FAULTchỉ dùng được với tác vụ mà server đó đãRECVvà vẫn chưaREPLY- Nó chỉ hoạt động với client đang chờ phản hồi từ chính server đó
- Hubris dùng
REPLY_FAULTđể xử lý các lỗi sau- Mã thao tác sai
- Thông điệp hỏng, bị cắt cụt, hoặc vô nghĩa
- Trường hợp client không gửi đúng loại loaned memory cần thiết
Lỗi ứng dụng và trải nghiệm fail-fast
REPLY_FAULTcó thể được dùng không chỉ cho lỗi định dạng IPC mà còn cho lỗi đặc thù của ứng dụng- IP stack của Hubris gán cổng IP cho các tác vụ một cách tĩnh
- Nếu một tác vụ cố đụng vào cổng IP của tác vụ khác, IP stack sẽ phát fault cho tác vụ đó
- Cách làm này giảm nhu cầu xử lý các lỗi “mang tính lý thuyết” vốn lẽ ra không nên xảy ra trong thực tế, đồng thời làm lộ nhanh việc sử dụng sai trong quá trình phát triển
REPLY_FAULTgiống với mô hình mà vi phạm điều kiện tiên quyết của lời gọi hàm Rust thường dẫn tớipanic!, và trở thành phương tiện để server gây ra mộtpanic!xuyên tiến trình đối với tiến trình client- Client không cần chứa mã riêng hay phối hợp gì để hỗ trợ cơ chế này
Thiên hướng bảo mật và các hạn chế khi kiểm thử
- Eliza Weissman mô tả Hubris là “cực kỳ thù địch với chương trình độc hại”
- Nỗ lực khai thác thường trước hết bộc lộ dưới dạng lỗi hay lạm dụng API, nên một hệ thống xóa sạch trạng thái của thành phần hành xử sai có thể khó bị khai thác hơn
- Giả thuyết này vẫn chưa được kiểm chứng
- Bài viết cũng kêu gọi liên hệ nếu ai quan tâm tới việc thử khai thác Hubris
- Nhược điểm đã được quan sát là hệ thống rất khó fuzz test
- Một tác vụ chaos nhỏ sinh IPC và lời gọi hệ thống ngẫu nhiên đã được triển khai, nhưng gần như làm gì cũng bị reset ngay lập tức
- Muốn hoạt động hữu ích, nó phải dựa quyết định vào một system uptime counter thay đổi theo cách có thể quan sát được mỗi lần khởi động
REPLY_FAULTcũng cung cấp cách để server giết client một cách ngẫu nhiên nhằm ép chaos, nhưng lựa chọn này vẫn chưa được đánh giá đầy đủ- Các tác vụ Hubris thông thường không cố tình tạo động các thông điệp IPC sai, nên nhìn chung chúng có thể chạy mà không cần nhận thức về sự tồn tại của
REPLY_FAULT
1 bình luận
Ý kiến trên Hacker News
REPLY_FAULT có vẻ phù hợp khi hệ thống nhỏ và chặt chẽ, và các ứng dụng cũng chủ yếu do những người đã thiết kế toàn bộ hệ thống viết ra
Nhưng từ góc nhìn của lập trình viên ứng dụng, việc kết nối với mã bên thứ ba qua một mô hình IPC mà dịch vụ khác có thể gửi cho tiến trình của tôi một viên đạn kết liễu bất cứ lúc nào nghe khá đáng sợ
Tôi không tin các lập trình viên ứng dụng khác đến mức đó. Ngoài kia đầy những tài xế tệ hại và các tiến trình nền do những lập trình viên bị quản lý thúc ép tạo ra, những người có thể sẽ nhét vào hàng đống REPLY_FAULT mặc định không phù hợp chỉ để được về trước 8 giờ tối
Không phải lúc nào cũng dễ hiểu hết mọi điều kiện tiên quyết, và chúng còn có thể thay đổi tùy thiết bị hay phiên bản OS
Phạm vi thì kiểu gì cũng có xu hướng phình ra, nhưng có lẽ người ta sẽ không muốn cố nhét vào một task Hubris trong bộ điều khiển nhúng những việc đáng ra nên xử lý ở host hơn
Nếu server nói “client kia sai rồi” thì kernel giết client đó. Điểm cốt lõi là hai bên đã không hiểu nhau
Ngoài ra, “tiến trình” ở đây có thể khác với điều bạn thường hình dung. Trong Hubris, các thread đều chia sẻ cùng một không gian địa chỉ
REPLY_FAULT có lan truyền theo chuỗi không? Ví dụ A
SENDcho B rồi chờ, BSENDcho C rồi chờ, nếu CREPLY_FAULTthì A có chết cùng B không, tôi khá tò mòNếu không thì một task độc hại chỉ cần ủy quyền thử nghiệm cho task phụ là xong. Ngược lại, nếu có thì tổng thể nghe khá mong manh, dù tôi không hiểu Hubris đủ sâu
Hơn nữa, nếu
SENDcó thể mang tính vòng lặp hay tương hỗ thì task thậm chí có thể vô tình tự giết chính mình. Với trường hợp B → A → B, đó còn có thể tạo động lực để không dùng REPLY_FAULTLý do server có thể bắn ngược lại client không phải vì bảo mật mà vì độ tin cậy. Cách nhìn ở đây là lỗi phát sinh từ bug chứ không phải từ tấn công có chủ ý, và phản ứng cực đoan của kernel giúp lập trình viên tìm ra vấn đề nhanh nhất có thể
Tất nhiên cũng có phần giao thoa với bảo mật, và nó có thể là một lớp phòng thủ dự phòng hữu ích khi tiến trình cố làm điều mà nó không được phép làm
Hubris và trình gỡ lỗi Humility là những công nghệ mà tôi muốn đào sâu nếu có thời gian hoặc nếu phải làm một nhiệm vụ liên quan. Tiếc là lúc này thì không thể
Trong một hệ thống nơi một nhóm viết toàn bộ mã nguồn, thật thú vị khi cách làm “đá client ra khỏi quỹ đạo chỉ vì nó nhìn sai kiểu” có thể tăng tốc độ lặp lại khi phát triển
Tối qua tôi đọc về algebraic effects rồi ngủ quên, sáng nay đọc bài này thấy khá thú vị. Nếu bẻ lái một chút, đây là một kernel cho phép server thực hiện những effect mà client không thể tự xử lý
Việc tái sử dụng và kết hợp mã có lẽ sẽ khó hơn nhiều, nhưng mô hình thực thi lại đơn giản hơn rất nhiều. Với các hệ thống nhúng tĩnh thì rõ ràng đây là một đánh đổi hợp lý. Nếu cần tái sử dụng, bạn luôn có thể vendor task đó vào rồi chỉnh sửa
Thậm chí Unix còn có quá nhiều lỗi có thể bỏ qua, và cá nhân tôi nghĩ một phần đáng kể trong số đó lẽ ra phải tạo ra fatal signal. Khi đó chất lượng phần mềm nói chung hẳn đã tốt hơn đáng kể
Ví dụ, gọi
close()trên một file descriptor không hợp lệ là lỗi không gây chết nên thường bị bỏ qua. Nhưng trên thực tế nó rất nguy hiểm, đặc biệt trong ứng dụng đa luồng. Phần lớn thời gian, việc đóng nhầm file descriptor sẽ thất bại một cách vô hại, nhưng 1% trường hợp thì nó lại đóng mất socket ghi log, file khóa cơ sở dữ liệu, hoặc một kết nối IPC không liên quan. Và thế là tạo ra loại phần mềm bất ổn mà ai cũng ghétTôi nhớ đến câu thoại trong Errand of Mercy: “Anh sẽ biết rằng có rất nhiều quy tắc và quy định. Chúng sẽ được niêm yết. Vi phạm dù chỉ điều nhỏ nhất trong số đó cũng bị xử tử”
Cái này nên được viết thành một RFC Cá tháng Tư cho HTTP
Tôi đề xuất HTTP 499 “Shame on you.”. Client nhận mã 499 này nên chấm dứt task đã phát ra request đó theo cách riêng của từng ngôn ngữ, có lẽ chỉ áp dụng với các request bắt đầu bằng một header cụ thể như
Strict: trueNó cân bằng hoàn hảo cái cảm giác “cái quái gì vậy… nhưng mà thật ra, cũng ổn đấy?” trong bối cảnh này
Đọc rất thú vị, và cách làm một supervisor duy nhất này khá giống với cách một startup cũ của tôi từng cấu trúc ứng dụng để mọi thứ đều unwrap
Nó cũng làm tôi nhớ đến một trong những bài tôi thích: https://medium.com/@mattklein123/crash-early-and-crash-often...
Tôi tự hỏi liệu cách này có thực sự quá hung hăng hay không
Trên Linux, không thể trực tiếp làm sập một chương trình khác chỉ bằng cách giao tiếp qua socket, trừ trường hợp gửi dữ liệu sai vào socket
Nhưng chắc chắn là có thể giết nó. Bất cứ thứ gì đang chạy bằng root đều có thể giết thứ khác, thậm chí còn có thể reboot để hạ toàn bộ hệ thống
Khó hơn một chút và ít phổ biến hơn, nhưng ít nhất trong container thì quyền root lại khá thường thấy. Tất nhiên vẫn có cgroup nên bị giới hạn hơn, nhưng ý chính là vậy
Điều này cũng hơi khác với sự khôn ngoan thông thường kiểu “hãy rộng lượng với những gì nhận vào, và bảo thủ với những gì gửi ra”. Dù vậy, có lẽ câu đó gắn với các hệ thống mạng nhiều hơn
Nhưng có lẽ việc hệ thống phải rộng lượng với những gì nó chấp nhận là điều khó tránh. Nếu không thì làm sao có thể thay đổi nhẹ API mà không làm hỏng các chương trình hiện có?
Theo tôi biết thì nó cũng không cho phép thêm kiểu process mới ở runtime. Mọi executable có thể chạy đều phải được xác định từ lúc compile
Với đoạn “không có cách sửa vấn đề rồi tiếp tục task. Đây là một lựa chọn có chủ đích để tránh các chế độ lỗi tinh vi và đơn giản hóa việc suy luận về hệ thống”, tôi nhớ đến câu nổi tiếng của Einstein: “hãy làm mọi thứ đơn giản nhất có thể, nhưng không đơn giản hơn nữa”
Thiết kế này có vẻ đã vi phạm vế sau. Tôi không hứng thú với môi trường vận hành không thể chịu nổi chút hỗn loạn nào của thế giới thực, và cũng không rõ trong những lĩnh vực khả thi về mặt thương mại thì có nơi nào chấp nhận điều đó
Rốt cuộc là quay lại hệ thống init để nó cứ retry liên tục sao? Nhưng bằng cơ chế nào nó có thể hiểu được fault đã xảy ra để thử lại theo cách tốt hơn?
Dù sao thì tôi vẫn vỗ tay cho sự thuần khiết trong niềm tin này
Thực tế, như Cliff đã viết khá chi tiết trên blog, REPLY_FAULT ban đầu là một tính năng mà chúng tôi nghĩ có thể hơi quá hung hăng, nhưng kinh nghiệm thu được khi xây dựng, triển khai và, thành thật mà nói, debug hệ thống đã khiến chúng tôi tin rằng nó không làm hệ thống hỏng hóc thất thường mà còn khiến nó vững chắc hơn
Có thể xem thêm về tư duy và hình dạng thực tế của nó tại [0] và [1]
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
Ngay cả trong các dự án sở thích, tôi cũng từng thấy bus I2C thường xuyên bị treo khi một bit giao thức nào đó bị lệch, rồi kéo sập cả hệ thống, nên tôi thấy thiết kế này khá truyền cảm hứng
Theo cách tôi hiểu thì đây là câu chuyện về những trường hợp lỗi đã biết — tức không phải lỗi có thể xử lý — mà là các sai lệch giao thức và những điều tuyệt đối không được phép xảy ra
Như các bình luận khác cũng đã chỉ ra, đây là một OS được thiết kế riêng theo mục đích. Cũng như bạn sẽ không làm UI bằng Erlang, Hubris có vẻ rất phù hợp với không gian mà nó chiếm giữ
Nguyên nhân có thể là bug, tấn công hoặc phần cứng bị lỗi, và trong bất kỳ trường hợp nào cũng không nên tiếp tục. Phía caller đang có vấn đề nghiêm trọng, và nếu tiếp tục thì chỉ gây thêm thiệt hại lớn hơn
Nghe hơi giống triết lý “let it crash” của Erlang/OTP. Erlang được dùng trong khá nhiều phần cứng mission-critical và nổi tiếng về độ tin cậy, nên trên thực tế đây có thể không phải một điểm trừ lớn đến vậy
Nó được viết để chạy sâu bên trong các rack server của 0xide
Với đoạn “các nỗ lực khai thác thường bộc lộ trước dưới dạng lỗi API hoặc sử dụng sai, nên một hệ thống xóa trạng thái của component có hành vi sai cho mọi kiểu trục trặc hẳn sẽ khó bị khai thác hơn”, ở đây về cơ bản là ứng dụng đang kiểm tra nghiêm ngặt hơn một chút những gì nó chấp nhận
Vì thế có lợi ích về bảo mật, nhưng khác với kiểu người ta thường nghĩ đến. Nó không phải phá hủy tiến độ của kẻ tấn công để đẩy lùi chúng, mà là khiến một số trạng thái sai cụ thể — vốn trước đây có thể bị nối tiếp thành trạng thái sai như mong muốn hơn — không còn hoạt động nữa
Khi đó, thay vì tiếp tục thử cách đó, kẻ tấn công sẽ đi tìm chỗ khác