Chuyển tới nội dung chính

ONNX Runtime chết trên Alpine: lỗi ld-linux-x86-64.so.2 và vì sao libc6-compat không cứu được

· 16 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

onnxruntime-node phát hành binary dựng sẵn cho glibc, và libonnxruntime.so.1 khai báo DT_NEEDED trỏ thẳng vào ld-linux-x86-64.so.2 — chính là bộ nạp động của glibc. Alpine dùng musl, nơi file đó không tồn tại, nên dlopen hỏng ngay ở bước nạp thư viện. Cài libc6-compat hay gcompat không sửa được: chúng cấp file loader nên qua được lỗi thứ nhất, rồi chết ở lỗi thứ hai là __vsnprintf_chk: symbol not found. Cách xử lý thực tế là đổi base image sang node:22-bookworm-slim, trả thêm khoảng 89 MB image để đổi lấy một thứ chạy được.

21 giờ 30, tôi thêm Dockerfile cho ClubDay — một web app sự kiện có trò vẽ hình được chấm điểm bằng model ONNX chạy ngay trên máy chủ. Base image chọn theo phản xạ: node:22-alpine, vì nhẹ.

Chọn base image là bước đầu tiên của Docker và deployment, và cũng là bước dễ chọn sai nhất — như bên dưới.

Image build sạch. Container lên. docker ps xanh. Rồi đến lúc server nạp model thì mọi thứ đổ, với một dòng lỗi không nhắc gì tới ONNX:

Error loading shared library ld-linux-x86-64.so.2: No such file or directory
(needed by /app/node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so.1)

21 giờ 44 tôi đổi một dòng trong Dockerfile và mọi thứ chạy. Nhưng mười bốn phút ở giữa đáng được viết ra, vì dòng lỗi đó nói về một file mà bạn chưa bao giờ cài, chưa bao giờ khai trong package.json, và trên máy dev thì nó vẫn luôn ở đó.

Traefik + Cloudflare: vì sao cert hết hạn đồng loạt sau 60 ngày?

· 21 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

Khi domain bật proxy Cloudflare (mây cam) với SSL mode Full (strict), ACME HTTP-01 không dùng được: Let's Encrypt gọi http://domain/.well-known/acme-challenge/... ở cổng 80, Cloudflare nhận rồi gọi ngược về origin bằng HTTPS:443 — nơi bộ xử lý challenge của Traefik không có mặt. Bạn cần cert để qua được Cloudflare, mà cần qua Cloudflare mới lấy được cert. Lách lần đầu bằng cách tạm tắt mây cam thì được, nhưng lần gia hạn tự động sau ~60 ngày sẽ trượt âm thầm và mọi site hết hạn cùng lúc. Lối ra là DNS-01: xác thực bằng bản ghi TXT qua API Cloudflare, không request nào chạm origin. Đổi lại, cert chỉ cấp được cho những zone mà API token nhìn thấy.

Trên VPS này đang có 14 container chung một reverse proxy: 10 site WordPress, một API .NET, một app Angular, một web app sự kiện, và Traefik. Mười ba hostname, tất cả nằm sau Cloudflare, tất cả cần HTTPS và không ai muốn gia hạn cert bằng tay.

Phần kiến trúc thì đơn giản đến mức nhàm. Phần thú vị nằm ở chỗ Cloudflare và Let's Encrypt, mỗi bên làm đúng việc của mình, nhưng ghép lại thì tạo ra một vòng luẩn quẩn mà bạn chỉ phát hiện vào ngày cert hết hạn — tức là hai tháng sau khi mọi thứ trông có vẻ đã xong.

Unit of Work trong .NET và ABP: SaveChangesAsync không phải commit

· 13 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

DbContext của EF Core đã là một Unit of Work sẵn: nó gom thay đổi trong change tracker rồi đẩy xuống database trong một transaction khi gọi SaveChanges. Vì vậy tự viết thêm một IUnitOfWork chỉ để gọi SaveChanges thường là thừa. ABP thì khác hẳn: Unit of Work ở đây là ambient, tự mở theo mỗi request, và SaveChangesAsync bên trong một UoW không commit transaction — chỉ CompleteAsync mới commit. Hiểu sai chỗ này là nguồn gốc của phần lớn bug "dữ liệu lúc có lúc không".

Unit of Work là một trong những pattern bị viết lại nhiều nhất trong thế giới .NET, và cũng là pattern bị viết lại một cách thừa thãi nhiều nhất. Bài này tách làm hai phần: EF Core thuần thì bạn cần gì, và ABP đã làm sẵn những gì mà bạn nên hiểu trước khi đụng vào.

Gọi .Result khi nào thì deadlock, khi nào thì không?

· 15 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

.Result và .Wait() block thread hiện tại. Khi await tạm dừng, nó bắt lại SynchronizationContext đang hiện hành để chạy phần code phía sau đúng trên context đó. Nếu context chỉ cho phép một thread tại một thời điểm — UI thread của WPF, request context của ASP.NET Framework — mà thread đó lại đang bị .Result block, thì continuation không bao giờ vào được: deadlock. Console và ASP.NET Core không có SynchronizationContext, nên continuation chạy trên thread pool và không kẹt. Cách sửa thật sự là async all the way.

Đoạn code dưới đây chạy hoàn hảo trong một console app, và treo cứng khi bạn copy nguyên xi vào một controller của ASP.NET Framework hoặc một nút bấm trong WPF:

public string GetCustomerName(int id)
{
return GetCustomerAsync(id).Result; // treo ở đây
}

private async Task<string> GetCustomerAsync(int id)
{
var response = await _http.GetStringAsync($"/customers/{id}");
return Parse(response).Name;
}

Không có exception, không có stack trace, không có timeout. Thread đứng im vĩnh viễn. Chuyện này không phải do HttpClient, cũng không phải do async "bị lỗi" — nó là hệ quả trực tiếp của một quyết định thiết kế trong cách await hoạt động.

Vì sao EF Core bắn 201 query cho 1 màn hình danh sách? Cách sửa N+1

· 15 phút để đọc
Nguyễn Huỳnh Minh Tiến
Fullstack Developer @ Utop.io
Tóm tắt

Một danh sách 200 dòng mà log ghi 201 câu SQL chính là N+1: một truy vấn lấy danh sách, rồi mỗi dòng lại thêm một truy vấn để nạp dữ liệu liên quan. Gốc rễ là navigation property bị nạp rời rạc thay vì nạp chung. Cách sửa mặc định là projection bằng Select xuống DTO. Include chỉ hợp khi cần entity thật, và nếu Include từ hai collection trở lên thì phải thêm AsSplitQuery để khỏi nổ cartesian.

Bạn mở trang danh sách khách hàng, API trả về sau bốn giây, nhưng CPU của database gần như không nhúc nhích và câu SQL nào nhìn riêng lẻ cũng chạy trong vài mili-giây. Bật log lên thì hoá ra vấn đề không nằm ở một câu chậm, mà ở 201 câu nhanh xếp hàng nối đuôi nhau.

Đó là N+1 query. Nó không làm code sai, không ném exception, không bị unit test bắt, và trên máy local với hai mươi dòng dữ liệu seed thì nó thậm chí còn nhanh hơn bản sửa đúng. Nó chỉ lộ ra khi bảng có dữ liệu thật và độ trễ mạng tới database khác không.