Updating Microsoft.Data.Sqlite on RHEL 8 can fail before your first query runs. The managed package restores successfully, the application builds, and .NET itself is supported on RHEL 8—then SQLitePCL.raw throws because the bundled libe_sqlite3.so asks for GLIBC_2.33 or GLIBC_2.34 while the production image exposes an older glibc baseline.
The safe response is not to update glibc in place or assume that operating-system support covers every transitive native asset. This guide shows how to resolve the exact package graph, inspect the ELF symbol floor, enforce it in CI, and choose a rollout or rollback using evidence from the image you actually deploy.
Table of Contents
Understand Microsoft.Data.Sqlite on RHEL 8 compatibility
Microsoft currently lists RHEL 8 as supported for .NET 10. That statement covers the .NET runtime and its documented operating-system dependencies. It does not promise that every native library restored by every NuGet dependency will retain the same minimum glibc symbol version.
Microsoft.Data.Sqlite is a thin provider. The default package graph brings in SQLitePCLRaw.bundle_e_sqlite3, which selects a platform-specific e_sqlite3 binary. NuGet 10.0.12 currently resolves SQLitePCLRaw 2.1.12. If that Linux binary was linked against newer glibc symbols than your image provides, the loader rejects it before EF Core or your SQL can compensate.
| Layer | What “supported” proves | What it does not prove |
|---|---|---|
| .NET on RHEL | The documented .NET runtime can run on the listed RHEL release | Every transitive native NuGet asset uses the same glibc floor |
| NuGet restore | The dependency graph is valid for the target framework and RID | The selected .so can load in the production image |
| Application build | Managed compile-time references are coherent | Native symbol versions will resolve at runtime |
| Developer smoke test | The library loads on the developer host | It loads on an older enterprise base image |
This is the same production principle used for a Microsoft.Data.SqlClient upgrade: validate the exact resolved dependency and the production boundary, not only the top-level version number.
Resolve the native asset you will deploy
Start from a clean restore with the same target framework, runtime identifier, lock-file policy, and package sources used by CI. The example below targets linux-x64; change the RID to match the artifact that reaches production.
dotnet restore --locked-mode -r linux-x64
dotnet list package --include-transitive
# Confirm the resolved versions from the assets file.
jq -r '
.libraries
| keys[]
| select(test("^(Microsoft.Data.Sqlite|SQLitePCLRaw)"; "i"))
' obj/project.assets.json
Do not inspect an arbitrary copy from a developer machine. Locate the asset selected by the restored graph. With the default global-packages layout, the Linux x64 library is normally under the resolved sqlitepclraw.lib.e_sqlite3 package:
packages_root="${NUGET_PACKAGES:-$HOME/.nuget/packages}"
raw_version="2.1.12"
native="$packages_root/sqlitepclraw.lib.e_sqlite3/$raw_version/runtimes/linux-x64/native/libe_sqlite3.so"
test -f "$native" || {
echo "Native asset not found: $native" >&2
exit 2
}
sha256sum "$native"
file "$native"
For a publish pipeline, also inspect the final publish directory or container layer. A self-contained publish, a custom provider, a lock-file change, or RID fallback can make the packaged asset differ from the one you expected.
Audit the glibc floor with readelf
ELF version requirements are embedded in the native library. readelf --version-info exposes each required GLIBC_x.y symbol version without executing the binary. Extract the highest requirement and compare it with the deployment image.
required_glibc="$({
readelf --version-info "$native" \
| grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
| sed 's/^GLIBC_//' \
| sort -V \
| tail -n 1
} || true)"
if [ -z "$required_glibc" ]; then
echo "Could not determine a GLIBC requirement" >&2
exit 3
fi
echo "highest required glibc: $required_glibc"
# Run this inside the actual deployment image.
available_glibc="$(ldd --version | head -n 1 | grep -oE '[0-9]+\.[0-9]+' | tail -n 1)"
echo "image glibc: $available_glibc"
The bounded proof for this article restored three current package points with .NET SDK 10.0.401 and inspected the selected linux-x64 binary. It did not provision RHEL or alter a system library.
| Microsoft.Data.Sqlite | SQLitePCLRaw native package | Highest observed GLIBC requirement | Decision for a glibc 2.28 baseline |
|---|---|---|---|
| 10.0.10 | 2.1.11 | GLIBC_2.28 | Compatible at the inspected symbol floor |
| 10.0.11 | 2.1.12 | GLIBC_2.34 (also requires 2.33) | Block |
| 10.0.12 | 2.1.12 | GLIBC_2.34 (also requires 2.33) | Block |
The 10.0.12 smoke test succeeded on the proof host because that host exposes glibc 2.39. That success confirms the inspected asset can load there; it does not contradict the lower image boundary.
Turn the audit into a CI release gate
Make the compatibility decision explicit. Store the oldest supported image baseline as pipeline configuration, inspect the native asset after restore or publish, and fail before a deployment artifact is promoted.
#!/usr/bin/env bash
set -euo pipefail
native="${1:?usage: glibc-gate.sh /path/to/libe_sqlite3.so}"
available="${2:?pass the production image glibc version, for example 2.28}"
required="$({
readelf --version-info "$native" \
| grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
| sed 's/^GLIBC_//' \
| sort -V \
| tail -n 1
} || true)"
if [[ -z "$required" ]]; then
echo "FAIL: no GLIBC version requirement was found" >&2
exit 2
fi
lowest="$(printf '%s\n%s\n' "$required" "$available" | sort -V | head -n 1)"
if [[ "$lowest" != "$required" ]]; then
echo "FAIL: native asset requires glibc $required; image provides $available" >&2
exit 1
fi
echo "PASS: native asset requires glibc $required; image provides $available"
sha256sum "$native"
Prefer deriving available from the actual container stage instead of keeping a hand-maintained number. For example, run ldd --version in the exact image digest used by the release and pass the result to the gate. Record the image digest, package-lock hash, native-library hash, required floor, and gate result as build evidence.
The version comparison above handles ordinary numeric glibc versions. Keep the comparison inside a GNU/Linux CI environment with sort -V; do not silently reuse it on a shell where version sorting has different semantics.
Choose a mitigation without hiding the risk
| Option | Use it when | Required guardrail |
|---|---|---|
| Temporarily pin the previous compatible graph | You need a fast rollback and the older packages remain acceptable for your threat and support model | Commit the lock file, document the expiry condition, and continue servicing review |
| Move to a newer base image | The organization can upgrade the supported operating-system baseline | Run the complete application and native-dependency test suite on the new image digest |
| Select a different SQLitePCLRaw provider | You deliberately own native packaging and provider initialization | Verify licensing, RID coverage, initialization, extensions, backups, concurrency, and deployment behavior |
| Wait for an upstream native package change | No safe platform or provider change is ready | Keep the gate active and verify the new binary rather than trusting the version label |
Do not “fix” an application image by copying a newer glibc into an older distribution. glibc participates in the operating-system ABI and security maintenance model; an in-place replacement can create a much larger unsupported surface. Upgrade the base image as a unit or keep the application package graph within the verified boundary.
A temporary package pin is a rollback, not a permanent exemption from updates. Apply the same risk discipline described in production .NET security update validation: document what was blocked, why it was blocked, which evidence reopens the update, and who owns the follow-up.
Verify the deployment and rollback
The static gate is necessary but not sufficient. Run a minimal SQLite operation inside the release image so the loader, provider bootstrap, file permissions, and basic SQL path execute together:
using Microsoft.Data.Sqlite;
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "select sqlite_version()";
var version = (string?)await command.ExecuteScalarAsync();
Console.WriteLine($"SQLite native library loaded: {version}");
- Build from the committed lock file and immutable base-image digest.
- Run the ELF gate against the native asset in the final artifact.
- Run the connection smoke test inside the release image.
- Deploy to one canary instance and watch startup, database initialization, and health checks.
- Roll back both the application artifact and image digest if native loading fails.
- Preserve the hashes and output so the next package update can be compared without guesswork.
Use the real application startup path for the canary. An isolated in-memory connection proves that the native library loads, but it does not exercise encrypted storage, extension loading, file-system mounts, migration locks, or the query workload used in production.
Know the limits and edge cases
- Architecture matters. Inspect the asset for every deployed RID, such as
linux-x64andlinux-arm64. One hash and symbol floor do not cover both. - musl is a different boundary. Alpine uses musl, so a glibc version comparison is the wrong gate. Verify the selected
linux-musl-*asset separately. - Publishing can change selection. Inspect the final framework-dependent, self-contained, single-file, or container output—not only the global package cache.
- Custom SQLite builds need their own evidence. Compile flags, extensions, encryption, and CPU requirements can fail even when the glibc floor passes.
- A symbol floor is not a complete compatibility test. Keep the runtime smoke test and application-level tests.
- Future packages can change again. Re-run the inspection for every resolved native hash; never hard-code 2.1.12 as the permanent affected boundary.
For Microsoft.Data.Sqlite on RHEL 8, the central release decision is simple: compare the native binary you will ship with the oldest environment you promise to support. Once that check is automated, a transitive native change becomes a controlled build failure instead of a production loader outage.
References
- Microsoft Learn: Install .NET on RHEL and CentOS Stream
- NuGet: Microsoft.Data.Sqlite 10.0.12
- dotnet/efcore issue 39148: RHEL 8 native SQLite loader failure
- SQLitePCL.raw issue 674: glibc compatibility report
- Microsoft Learn: EF Core SQLite provider
Found this useful? Support more practical developer content.