Skip to main content

ONNX Runtime Dies on Alpine: the ld-linux-x86-64.so.2 Error and Why libc6-compat Won't Save You

· 14 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

onnxruntime-node ships prebuilt binaries built against glibc, and libonnxruntime.so.1 carries a DT_NEEDED entry pointing directly at ld-linux-x86-64.so.2 — glibc's own dynamic loader. Alpine uses musl, where that file does not exist, so dlopen fails at the library-loading step. Installing libc6-compat or gcompat does not fix it: they provide the loader file so you clear the first error, then die on the second one, __vsnprintf_chk: symbol not found. The practical answer is to change the base image to node:22-bookworm-slim, paying about 89 MB more for something that actually runs.

At 21:30 I added a Dockerfile for ClubDay — an event web app whose drawing game is scored by an ONNX model running on the server itself. The base image was reflex: node:22-alpine, because it is small.

The image built cleanly. The container came up. docker ps was green. Then, at the moment the server loaded the model, everything collapsed with an error line that never mentions 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)

At 21:44 I changed one line in the Dockerfile and everything worked. But the fourteen minutes in between are worth writing down, because that error line names a file you never installed, never declared in package.json, and which is simply always there on your development machine.

Traefik + Cloudflare: Why Your Certificates All Expire on Day 60

· 19 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

When a domain is proxied through Cloudflare (the orange cloud) with SSL mode Full (strict), ACME HTTP-01 cannot work: Let's Encrypt requests http://domain/.well-known/acme-challenge/... on port 80, Cloudflare receives it and calls back to your origin over HTTPS:443 — where Traefik's challenge handler does not live. You need a certificate to get through Cloudflare, and you need to get through Cloudflare to obtain a certificate. You can work around the first issuance by temporarily switching the cloud to grey, but the automatic renewal around day 60 will fail silently and every site will expire at the same time. The way out is DNS-01: validation through a TXT record via the Cloudflare API, with no request ever touching the origin. The price is that certificates can only be issued for zones your API token can see.

This VPS runs 14 containers behind one shared reverse proxy: 10 WordPress sites, a .NET API, an Angular app, an event web app, and Traefik itself. Thirteen hostnames, all behind Cloudflare, all needing HTTPS, and nobody wanting to renew a certificate by hand.

The architecture is boring in the best way. What is interesting sits between Cloudflare and Let's Encrypt — two systems each doing exactly their job, which together produce a circular dependency you only discover on the day your certificates expire. That is, two months after everything appeared to be finished.

Unit of Work in .NET and ABP: SaveChangesAsync Is Not a Commit

· 12 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

EF Core's DbContext is already a Unit of Work: it collects changes in the change tracker and pushes them to the database inside one transaction when you call SaveChanges. Writing your own IUnitOfWork purely to call SaveChanges is therefore usually redundant. ABP is a different matter entirely: there, the Unit of Work is ambient, opens automatically per request, and SaveChangesAsync inside a UoW does not commit the transaction — only CompleteAsync does. Misreading that one point is the source of most "the data is there sometimes" bugs.

Unit of Work is one of the most frequently reimplemented patterns in the .NET world, and also the one most frequently reimplemented for no reason. This post splits into two parts: what you actually need with plain EF Core, and what ABP has already done for you that you should understand before touching it.

When Does Calling .Result Deadlock, and When Does It Not?

· 14 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

.Result and .Wait() block the current thread. When await suspends, it captures the current SynchronizationContext so that the code after it runs back on that same context. If the context only allows one thread at a time — a WPF UI thread, an ASP.NET Framework request context — and that thread is the one .Result is blocking, the continuation can never get in: deadlock. Console apps and ASP.NET Core have no SynchronizationContext, so the continuation runs on the thread pool and nothing gets stuck. The real fix is async all the way.

The code below runs perfectly in a console app, and freezes solid the moment you paste it verbatim into an ASP.NET Framework controller or a button handler in WPF:

public string GetCustomerName(int id)
{
return GetCustomerAsync(id).Result; // hangs here
}

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

No exception, no stack trace, no timeout. The thread simply stops forever. This is not HttpClient's fault, and async is not "broken" — it is the direct consequence of a design decision in how await works.

Why Does EF Core Fire 201 Queries for One List Screen? Fixing N+1

· 13 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

A 200-row list whose log shows 201 SQL statements is textbook N+1: one query fetches the list, then each row triggers another query to load its related data. The root cause is navigation properties being loaded one at a time instead of together. The default fix is a projection with Select into a DTO. Include is only the right tool when you need real entities, and once you Include two or more collections you need AsSplitQuery to avoid a cartesian explosion.

You open the customer list page, the API responds after four seconds, yet the database CPU barely moves and every individual SQL statement runs in a few milliseconds. Turn on logging and it turns out the problem is not one slow query, but 201 fast ones queued up nose to tail.

That is an N+1 query. It does not make the code wrong, it throws no exception, no unit test catches it, and on your local machine with twenty seeded rows it may even be faster than the correct fix. It only shows itself once the tables hold real data and the network latency to the database is non-zero.