- Trong lúc EdgeDB chuyển I/O mạng từ Python sang Rust, các bài kiểm thử HTTP fetch mới dựa trên
reqwesttưởng như chỉ bị treo trên CI ARM64, nhưng thực tế là bị crash - Phân tích core dump cho thấy điểm lỗi không nằm ở mã HTTP mới mà ở bên trong
getenv()củalibc; nó thất bại khi duyệt mảng biến môi trường và cố đọc con trỏ sai0x220 - Một luồng khác đặt
SSL_CERT_FILEvàSSL_CERT_DIRtrong đường đi củaopenssl-probe, khiếnsetenv()cấp phát lạienviron; đồng thời đường xử lý lỗi của Python gọigetenv(), tạo ra race condition - Mã Rust không có
unsaferõ ràng, nhưng khistd::env::set_var()thay đổi môi trường toàn cục, khóa nội bộ của Rust không đồng bộ hóa được cả các runtime khác hay các lời gọi trực tiếp vàolibc - Cách khắc phục là chuyển sang
rustlstrên Linux thay vì backendrust-native-tls/opensslcủareqwest; Rust 2024 edition và glibc cũng đang thay đổi theo hướng giảm các vấn đề cùng loại
Crash chỉ lộ ra trên CI ARM64
- EdgeDB đang xây dựng tính năng HTTP fetch mới trong quá trình port phần lớn mã I/O mạng từ Python sang Rust
- Họ dùng
reqwestlàm thư viện HTTP client; tính năng này chạy qua ở máy cục bộ và runner CI x86_64, nhưng thỉnh thoảng thất bại trên runner CI ARM64 - Ban đầu, có vẻ như test runner bị treo vô thời hạn; log CI không có lỗi và một bài kiểm thử cứ ở trạng thái đang chạy, rồi vài giờ sau bị timeout
- Giả thuyết ban đầu là do khác biệt về memory model giữa Intel và ARM64
- Intel có memory model tương đối chặt, trong đó mọi processor có một thứ tự toàn cục thống nhất đối với các lần ghi bộ nhớ
- ARM có memory model với thứ tự yếu hơn, trong đó các lần ghi có thể xuất hiện theo thứ tự khác nhau đối với các luồng khác nhau
Truy vết core dump trong môi trường Docker CI
- Máy CI chạy ban đêm trên Amazon AWS, nên có thể đăng nhập bằng người dùng root thực sự bên ngoài container để xem
dmesgvà log hệ thống - Họ tìm PID có vẻ đang bị treo cả trong lẫn ngoài container nhưng không thấy tiến trình đó, qua đó phát hiện đây không phải deadlock mà là crash
- Vì Docker container là một process namespace, core dump được chuyển tới Docker host; trong
journalctl, họ xác nhận core dump của tiến trìnhpython3 - Khi mở core bằng
gdblần đầu, backtrace không hữu ích vì thiếu các file.sobên trong container - Sau khi sao chép
/lib,/usr, v.v. từ container và cấu hìnhsolib-absolute-prefixcủagdb, họ xác nhận điểm crash làgetenv()tronglibc.so.6
Con trỏ biến môi trường hỏng mà getenv() đã đọc
- Backtrace đầy đủ có luồng
getenv()→__dcigettext()→strerror_r()→strerror()→PyErr_SetFromErrnoWithFilenameObjects() - Không phải mã HTTP mới crash trực tiếp; Python đang tạo ngoại lệ dựa trên errno, đi qua đường liên quan tới gettext rồi gọi
getenv() - Cài đặt
getenv()của GLIBC 2.17 duyệtenvironcủa POSIX như một danh sáchchar **, kiểm tra các con trỏ tới chuỗi biến môi trường cho đến con trỏNULLcuối cùng - Dựa trên disassembly và trạng thái thanh ghi,
getenv()crash khi cố đọc một byte tại địa chỉx19 = 0x2200x220rõ ràng không phải địa chỉ bộ nhớ hợp lệ- Khi kiểm tra chính
environ, danh sách biến môi trường hiện tại trông nhất quán
Nguyên nhân: race condition giữa setenv() và getenv()
setenv()không phải hàm có thể gọi an toàn trong môi trường đa luồng, và vấn đề gây crash lạ tronggetenv()củalibcđã nhiều lần được tái phát hiện- Khi đối chiếu disassembly với mã C,
x20tương ứng với con trỏepdùng để đi qua mảngenviron - Tại thời điểm crash,
x20là0x248b5000, cònenvironhiện tại là0x28655750, nằm sau đó khoảng 60MB - Khi so sánh vùng nhớ quanh mảng môi trường cũ với
environhiện tại, khác biệt cuối cùng xuất hiện ở các mụcSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtvàSSL_CERT_DIR=/etc/ssl/certs - Có thể xem đây là tình huống use-after-free: một luồng khác đã di chuyển
environtrong lúc gọisetenv(), còngetenv()tiếp tục đọc mảng môi trường cũ
Mối liên hệ với openssl-probe và TLS backend
- Từ một issue cũ liên quan đến
rust-native-tls, họ tìm thấy manh mối rằngopenssl-probeđặt các biến môi trườngSSL_CERT_FILEvàSSL_CERT_DIRđể tìm chứng chỉ hệ thống - Trên Linux, đường đi này được gọi khi dùng backend
opensslcủarust-native-tls - Đoạn mã
openssl-probegây vấn đề đặt hai biến môi trường bằngenv::set_var()mà không cóunsaferõ ràngSSL_CERT_FILESSL_CERT_DIR
- Vì sự kết hợp này, mã Rust không có unsafe đã tương tác xấu với việc sử dụng
libctrong cùng tiến trình và gây crash
Vì sao tái hiện được trên Linux ARM64
- Crash này xảy ra khi một luồng khác gọi
getenv()đúng vào khoảnh khắcsetenv()dùngreallocđể di chuyển mảng môi trường - Để tái hiện, nhiều điều kiện phải trùng khớp cùng lúc
- Số lượng biến môi trường phải vừa đủ để kích hoạt
realloc - Một lỗi I/O không liên quan phải bị
asynciobắt được - Đường xử lý lỗi của Python phải gọi
getenv()để lấy biến môi trườngLANGUAGEđúng vào cùng thời điểm đó
- Số lượng biến môi trường phải vừa đủ để kích hoạt
- Giá trị sai
0x220gần với kích thước môi trường cũ tính theo word 64-bit0x220 / 8 = 68- Giá trị này đã ghi đè vị trí
NULLkết thúc của khối môi trường cũ, và có vẻ là giá trị mà malloc hệ thống dùng để biểu thị kích thước free block
- Vì cần rất nhiều tiền điều kiện, việc nó có thể tái hiện khá tốt trên một nền tảng duy nhất tự thân đã là may mắn
Manh mối xác nhận từ disassembly ARM64
- Có lúc họ bối rối vì trong disassembly của
getenv()không thấy rõ nơix20thay đổi - Điểm then chốt là chế độ định địa chỉ pre-index của AArch64
ldr x19, [x20, #8]!hoạt động như saux19 = *(x20 + 8)x20 = x20 + 8
- Do chế độ định địa chỉ này,
x20vẫn đang duyệt mảng con trỏ biến môi trường dù không được ghi tường minh ở vế trái
Bản sửa đã áp dụng và thay đổi ở các dự án liên quan
- Cuối cùng, họ quyết định migrate sang
rustlstrên Linux thay vì backendrust-native-tls/opensslcủareqwest - Lý do ban đầu chọn native TLS backend là để tránh phải mang theo hai TLS engine trong lúc chuyển mã Python sang Rust
- Sau sự cố này, họ nhận định việc tạm thời mang theo hai TLS engine là chấp nhận được
- Một phương án khác là thực hiện lời gọi đầu tiên tới
try_init_ssl_cert_env_vars()trong lúc đang giữ GIL của Python- Rust có khóa nội bộ để ngăn race giữa các đoạn mã Rust khi đọc và ghi môi trường
- Nhưng khóa đó không ngăn được trường hợp mã ở ngôn ngữ khác dùng trực tiếp
libc - Giữ GIL ít nhất có thể ngăn race với các luồng Python
- Dự án Rust đã nhận thức vấn đề này và có kế hoạch biến các hàm setter môi trường thành
unsafetrong 2024 edition - Dự án glibc gần đây cũng đã thêm thay đổi nhằm tăng tính an toàn luồng của
getenv()bằng cách tránhreallocvà làm rò rỉ các mảng môi trường cũ
4 bình luận
setenvkhông an toàn luồng, và C không muốn sửa điều đóHàm
setenvlại gây rắc rối nữa rồi.Tôi sẽ đặt tiêu đề là "Tính không an toàn luồng của
stdlibtrong C thì ngay cả Rust vốn được xem là an toàn cũng không cứu nổi". :)Tôi đã hiểu rõ ràng.
Ý kiến trên Hacker News
Điểm lớn nhất ở đây là trong edition tiếp theo của Rust, hàm thiết lập biến môi trường sẽ trở thành unsafe
Nếu may mắn, ảnh hưởng có vẻ sẽ lan tới cả các crate gây ra những crash kiểu này; trong lúc đó, upstream đã có issue https://github.com/alexcrichton/openssl-probe/issues/30
getenvvàsetenvhoặcunsetenvtừ các thread khác nhau, thì thực tế vẫn chưa được sửaGiải pháp đáng tin cậy có vẻ chỉ là thay đổi các hàm này để chúng bắt buộc phải giữ mutex
Đáng tiếc là hệ sinh thái lại không như vậy: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___Trong các web framework cũng tương tự: Vue có directive
v-html, còn React códangerouslySetInnerHTML; ở khía cạnh này, tôi cho rằng Vue rõ ràng tốt hơnset_varvàremove_vartrong thư viện chuẩn Rust sẽ chính thức yêu cầu blockunsafe {}trong edition tiếp theo, edition 2024Tài liệu giờ cũng đã nhắc tới vấn đề an toàn, nhưng ngay từ đầu việc biến các hàm này thành safe là một sai lầm, và các ngôn ngữ cấp cao hơn cũng đã mắc sai lầm đó
https://doc.rust-lang.org/stable/std/env/fn.set_var.html
glibc có một bản vá giúp
getenvan toàn hơn trong nhiều trường hợp môi trường bị sửa đổi, nhưng trong C vẫn có thể truy cập trực tiếpenviron, nên khi có sửa đổi thì không thể hoàn toàn an toàn: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...Với mỗi biến môi trường đang hoạt động sẽ có rò rỉ bộ nhớ kích thước hằng số theo nghĩa khấu hao, nhưng bản thân các biến vốn đã có kiểu rò rỉ như vậy, thậm chí còn phụ thuộc vào độ dài và bao gồm cả các giá trị không còn được dùng nữa
Chắc chắn có vẻ sẽ tồn tại những trường hợp sử dụng bệnh lý khiến bộ nhớ tăng vô hạn ngay cả trong các chương trình đúng theo API
Việc đưa lỗi tăng bộ nhớ vô hạn vào các chương trình tuân thủ API để sửa những chương trình đã phá quy tắc bằng cách dùng API trên nhiều thread là điều thú vị nhưng cũng hơi khó chịu. Cảm giác như thực dụng hơn giáo điều
unsafeDù các maintainer thư viện chuẩn C phản đối việc làm cho
setenvan toàn trong môi trường đa luồng, thì ít nhất cũng cần định nghĩa một API mới an toàn với thread, dù là trong POSIX hay trước hết tạo một chuẩn trên thực tế rồi để POSIX chấp nhận sauNếu thời gian dùng để giải thích vì sao không thể làm gì được được dùng để sửa vấn đề này, thì hẳn đã có thể thay thế
setenvcũ và loại bỏ/xóa nó khỏi nhiều dự án phần mềmViệc glibc đang thực hiện thay đổi nhằm gần như loại bỏ vấn đề này khiến lời của maintainer Musl rằng trong Musl không thể sửa được trở nên kém thuyết phục
extern char **environ;Chừng nào
environcòn có thể truy cập công khai, thì thậm chí không có gì bảo đảmsetenvvàgetenvsẽ được dùng, vì hai hàm này không bắt buộcNếu có thể loại bỏ
environ, việc làm chosetenvvàgetenvan toàn với thread sẽ khá đơn giản. Nếu không thể loại bỏ thì là bất khả thi, nhưng vẫn có thể lập luận rằng việc làm chosetenvvàgetenvan toàn với thread là một cải thiện, dù không phải giải pháp hoàn chỉnhexec()không nhận môi trường làm đối số hoặc tìm kiếmPATHđể tìm file thực thi cũng sẽ cần khóaTrên Linux, gặp lỗi liên quan đến biến môi trường có vẻ như là một kiểu nghi thức nhập môn, còn trên các Unix khác thì kỳ lạ là thường ít thành vấn đề hơn
Linus và kernel sửa các lỗi POSIX theo hướng thực dụng, làm sao để chúng không thực sự phát nổ, nhưng khá buồn cười là glibc vẫn tụt hậu dù đã nhiều thập kỷ kể từ khi người ta cố gắng giảm nhẹ vấn đề dù chỉ một chút
Đúng là có đủ thứ rắc rối như
TZ, nhưng chỉ cần cung cấpgetenv_r(), đồng bộ hóa nó vớisetenv(), và khi dùnggetenv()thì cảnh báo ở giai đoạn biên dịch/liên kết thôi, thì phần lớn vấn đề đã biến mất rồiXa hơn nữa, thậm chí có thể dùng cách copy-on-write (COW), để con trỏ môi trường ở chế độ chỉ đọc
Thay vào đó, vấn đề bị đẩy sang từng ứng dụng riêng lẻ, mà tác giả ứng dụng thì gần như không thể biết các dependency đang làm gì, nên đây là một sai lầm lớn. Tình huống tôi từng gặp từ lâu cũng như vậy, và khi đó nhà cung cấp thư viện mã nguồn đóng đã bảo tôi đừng dùng cái bản sao Unix đồ chơi tên Linux đó nữa
Vấn đề không nằm ở phần hiện thực, mà là chính API.
setenv(),unsetenv(),putenv(), đặc biệt làenviron, về bản chất không an toàn trong chương trình đa luồnggetenv_r()cũng không thể cứu hoàn toàn. Vì trong lúc một luồng đang sao chép giá trị cũ của biến môi trường vào buffer được cung cấp, một luồng khác vẫn có thể gọisetenv()Tất nhiên, trường hợp sau khi nhận từ
getenv()rồi luồng khác gọisetenv()làm vùng nhớ đó mất hiệu lực thìgetenv_r()khắc phục được, nhưng không có cách nào ngăn các lời gọi khác phá vỡ APIlibc có thể giảm nhẹ một phần vấn đề bằng cách giữ mutex bên trong
getenv()/setenv()/putenv()/unsetenv(), nhưng libc vẫn không có cách nào bảo đảm giá trị màgetenv()trả về còn hợp lệ đủ lâu để mã gọi sử dụngCũng không có cách hay nào để làm cho việc truy cập trực tiếp vào
environtrở nên an toàn. Có thể biếnenvironthành thread-local, nhưng khi đó góc nhìn về môi trường của từng luồng có thể lệch nhau vĩnh viễn, và kết quả gọigetenv_r()có thể khác với việc kiểm tra trực tiếpenvironỞ đây thật sự rất khó giữ tính tương thích ngược, và chỉ riêng việc thêm mutex để bảo vệ các hàm cũng có thể làm thay đổi ngữ nghĩa của chương trình hiện có và khiến chúng hỏng
Trước đây từng có bài viết nói rằng
setenvrất tệ: https://www.evanjones.ca/setenv-is-not-thread-safe.htmlCũng đã có thảo luận, và ngay từ bình luận đầu tiên đã nói nó gây vấn đề trong Rust: https://news.ycombinator.com/item?id=38342642
Phần lớn các vấn đề còn lại ở đây có vẻ là do môi trường phát triển. Họ dùng Docker để kiểm thử trên một máy từ xa trong trung tâm dữ liệu của Amazon, và máy đó không báo được crash của tiến trình
Hơn nữa, trong container cũng không có đủ thông tin debug symbol để lấy backtrace. Nếu ngay lần lỗi đầu tiên nhận được một backtrace sạch thì đã rõ ngay rồi
Ngay từ đầu cũng không hiểu tại sao lại dùng
setenvCâu chuyện này làm tôi nhớ đến phong trào 12-factor app mà vài đồng nghiệp cũ từng rất tin tưởng. Một trong các “factor” đó là cấu hình ứng dụng phải được thực hiện bằng biến môi trường
Tôi luôn thấy điều đó hơi ngớ ngẩn, vì cách cấu hình này giống như bỏ các giá trị kiểu chuỗi vào một cái giỏ trong một không gian tên phẳng
Tôi cũng xem rủi ro của
getenv()/setenv()/environlà một lý do mạnh mẽ để không dùng biến môi trường cho cấu hìnhTất nhiên không phải lúc nào cũng có lựa chọn thay thế tuyệt vời và được hỗ trợ tốt. Tôi thích tệp cấu hình hơn, và cũng có thể dùng cấu hình mẫu rồi chỉ điền các giá trị cho dev, staging, production. Thường thì tôi dùng YAML dù nó có nhược điểm và cạm bẫy; có thể có định dạng tệp cấu hình tốt hơn, nhưng tôi vẫn cho rằng YAML tốt hơn biến môi trường rất nhiều
Trên NT, biến môi trường có thể được gõ kiểu và tạo mẫu, và còn có Registry, một cơ sở dữ liệu cấu hình có không gian tên. Dù nó dài dòng và kỳ quặc
Hơn nữa, MSVC cung cấp phiên bản an toàn luồng cho gần như mọi hàm thư viện chuẩn
Tôi thường nghe các lập trình viên C/C++ mới than phiền rằng MSVC thiếu khả năng tương thích POSIX, nhưng có vẻ họ không suy nghĩ sâu xem điều đó thật sự nghĩa là gì. Nó gần với mong muốn tương thích chéo với các chương trình C viết từ thập niên 1990 hơn
Nó cản trở khả năng suy luận hành vi chỉ từ chữ ký hàm, và biến nhiều hàm lẽ ra có thể là hàm thuần thành hàm không thuần
Nếu có một tính năng ngôn ngữ cho phép đánh dấu ứng dụng sao cho trong mọi tiến trình, biến môi trường không thể được dùng và chỉ có thể được đọc một lần duy nhất theo lô, chứ không phải từng biến một, thì tôi nghĩ mình sẽ dùng nó ở mọi nơi
getenv()hoàn toàn ổn, vấn đề làsetenv()Về lý thuyết, môi trường được thiết lập trước khi ứng dụng bí ẩn đó khởi động, nên không cần dùng thứ này
Nhưng một không gian tên phẳng, giá trị chuỗi, và một vùng toàn cục tự do dùng chung cho tất cả mọi người trong khi bạn không biết thư viện hay mô-đun nào sẽ được nạp vào, thì ngay cả khi không có vấn đề an toàn của
setenv(), đó vẫn không phải ý hayPhần lớn các vấn đề mọi người nói ở đây dường như đến từ việc muốn lạm dụng môi trường như một kho khóa-giá trị cho trạng thái toàn cục có thể thay đổi. Tôi không hiểu vì sao lại muốn làm vậy
JVM về cơ bản xem môi trường là bất biến, và có thể các công ty dùng Scala·Java như SoundCloud đã có ảnh hưởng đến phong trào 12-factor app. Tôi chưa từng gặp trường hợp môi trường thay đổi hay gây vấn đề luồng
Ngay cả khi môi trường thay đổi, bản sao bất biến được tạo khi JVM khởi động vẫn giữ nguyên, và mã tương tác với môi trường qua API Java thông thường sẽ không thấy sửa đổi đó
Vấn đề của tệp cấu hình là việc phân tích cú pháp diễn ra theo từng tiến trình. Vì vậy Linux/Unix mới lộn xộn như vậy. Mỗi công cụ có quy ước và cơ chế cấu hình khác nhau, không có chuẩn
Trong hệ sinh thái Docker, bên trong container muốn làm gì thì làm, còn giao diện với bên ngoài là hoặc mount volume rồi tuân theo cách cấu hình phức tạp của từng ứng dụng, hoặc chỉ dùng biến môi trường
Phần lớn phần mềm hiện đại chạy bằng Docker ngày nay đủ thân thiện với Docker để hành vi có thể được điều khiển hoàn toàn bằng môi trường, và trong nhiều trường hợp như vậy là đủ
Nếu dùng Docker Compose hay Kubernetes, bạn sẽ có danh sách biến môi trường định nghĩa cách khởi động tiến trình trong một tệp YAML, nên ở mức nào đó cũng có được cấu trúc mong muốn. Tôi không thích YAML, nhưng nó hoạt động đủ tốt; vấn đề cú pháp rất có thể làm hỏng cả ngày của bạn, nhưng các lựa chọn thay thế cũng không phải không có vấn đề
Tôi cũng dùng phong cách 12-factor app, nhưng sau khi vào ứng dụng thì kiểm tra hợp lệ và lưu trữ biến môi trường cùng dữ liệu. Sau đó thì không có vấn đề gì
Đây là một bài viết rất hay đào sâu vào một lỗi khó thấy
Nó có đủ cả lỗi gián đoạn, đặc thù kiến trúc, ẩn trong phụ thuộc, Rust, Python GIL, cho đến gettext
Những báo cáo khắc phục sự cố chi tiết như thế này là thứ gần nhất với việc tự mình trải qua. Trong tình huống “làm sao tôi biết được khi phụ thuộc của mình đang dùng nó”, rất khó nói đơn giản rằng “đừng dùng X là xong”
Họ nói “máy CI chạy ban đêm trên Amazon AWS, và có lợi thế là có thể dùng người dùng root thật chứ không phải container”, đồng thời lại nói “bên ngoài container không có các tệp cần thiết, và container được tối giản rất nhiều nên không thể dễ dàng cài
gdb”Giờ mọi người đã mất khả năng build và debug cục bộ mà không có đám mây và container rồi sao?
Để làm những việc hết sức nhỏ nhặt giờ lại cần đủ loại độ phức tạp đám mây và tầng triển khai. Chẳng khác nào đảo ngược 100% cuộc cách mạng PC để quay về thời điện toán mainframe cồng kềnh và đắt đỏ
Lý do là vì tiền nằm ở đám mây, và đám mây là DRM. Đưa phần mềm lên đó thì có thể thu phí thuê bao, người dùng không né được, và sự phụ thuộc hoàn hảo được duy trì mãi mãi. Nhiều khi người dùng còn không thể lấy dữ liệu của chính mình ra
Việc phân tích thời gian thực để tối ưu sản phẩm cũng tiện hơn
Kiến trúc điện toán nằm ở hạ nguồn của mô hình kinh doanh. Mainframe ban đầu chết đi vì không có internet và PC rẻ hơn, nhưng cũng vì nhà cung cấp đã mất nhiều quyền lực khóa trói
Giờ đã có cách hồi sinh một mô hình sinh lợi hơn nhiều. Quyền tự do phiền toái của người dùng đã biến mất, và nói thật, nếu người dùng có những quyền tự do đó thì họ thường không trả tiền, khiến việc kinh doanh phần mềm chất lượng cao khó thành lập
Rất có khả năng họ không tái hiện được crash trên máy cục bộ. Máy của lập trình viên phần lớn là x86 và ở đó có lẽ không bị crash
Việc xử lý crash đáng ra phải tốt hơn, nhưng có vẻ họ cũng nhận thức được vấn đề đó và đó không phải trọng tâm được bàn ở đây
Trạng thái toàn cục có thể thay đổi là xấu xa. Nếu là bạn bè thì đừng để bạn mình dùng trạng thái toàn cục có thể thay đổi
Tôi ghét biến môi trường. Đây là “cách của Linux”, nhưng hãy tránh nó như tránh dịch bệnh. Rất khuyến nghị
libc thật kinh khủng, và thế giới giờ nên vượt qua nó
Vấn đề không phải Linux, cũng không phải trạng thái hay tài nguyên toàn cục có thể thay đổi, cũng không phải libc
Vấn đề là ở chỗ tại nơi làm việc, người ta không được cho thời gian để làm việc cho đúng. Chẳng hạn, muốn bắt lỗi bằng GDB trước khi sự cố xảy ra, sếp phải cho thời gian để kiên trì debug và truy ngược mã cùng mọi thứ mà mã đó động tới
Có quá nhiều tiền đổ vào thứ mã còn nửa vời. Buồn nhưng đúng
Vì có quá nhiều vấn đề kiểu này nên cuối cùng tôi đã vá
getenv/setenv/putenvbằngLD_PRELOAD