doc: how to build release binaries in containers, for several glibc floors

Binaries built on Ubuntu 22.04 require GLIBC_2.34 and GLIBCXX_3.4.30 and will not
start on Ubuntu 20.04 -- which is four of our five seeds, and an unknown share of
users. The binary the fleet actually runs today needs only GLIBC_2.29, so it was
built somewhere older; seed 176 has since been upgraded to 22.04 and now produces
binaries it is the only seed able to run.

--linux-compat and Dockerfile.compat already solved this (6d56ad854) but were
undocumented outside the build script and pinned to one base image. Parameterise
the base via ARG BASE_IMAGE (default unchanged, so --linux-compat behaves exactly
as before) and document the whole path.

doc/build-containers.md is written to be executed by a person or an agent starting
from a machine with nothing installed: why the glibc direction matters, with the
measured numbers; what already exists in the repo so nobody writes a second build
system; prerequisites and honest cost (~15GB, 4GB RAM, 1-2h per base because
depends/ builds boost, BDB, wolfssl and rust from source); one-target and
multi-target recipes; which base to pick and why 20.04 is the recommended floor
while 18.04 needs verifying (GCC 7 against -std=c++17); a mandatory verification
step with the exact objdump/readelf commands and the expected ceilings; and the
traps.

The traps are the part worth having written down: ETXTBSY when installing over a
running daemon (cp fails even after the process exits -- stage and rename, then
sha256-verify before starting); never touching configure.ac in a configured tree,
because the mtime alone triggers a reconfigure that dies on libdb_cxx; never
blind-touching a path that may not exist, which silently creates stray empty files;
RandomX needing ARCH=default or it emits AVX-512 that SIGILLs the fleet; build-win.sh
silently discarding every argument; and macOS being uncontainerisable because
depends/ has no darwin cross path at all.

Also records that full static linking is NOT the answer here: the daemon resolves
node1..node5.dragonx.is via getaddrinfo, and static glibc pushes that through NSS,
which dlopens libnss_dns at runtime and reintroduces the dependency it was meant to
remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:28:12 +02:00
parent 65130c3120
commit 04ac7c1186
2 changed files with 228 additions and 1 deletions

View File

@@ -1,4 +1,8 @@
FROM ubuntu:20.04
# Base image is parameterised so one Dockerfile can produce binaries for several
# glibc floors: docker build --build-arg BASE_IMAGE=ubuntu:18.04 ...
# The default is unchanged, so `./build.sh --linux-compat` behaves exactly as before.
ARG BASE_IMAGE=ubuntu:20.04
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive

223
doc/build-containers.md Normal file
View File

@@ -0,0 +1,223 @@
# Building release binaries in containers
Release binaries must be built in a container based on an **old** Linux distribution.
This document is written to be executed, by a person or an agent, on a machine that
has nothing set up yet.
---
## 1. Why this exists
glibc compatibility runs one way only. A binary linked against glibc 2.35 demands
symbol versions that glibc 2.31 does not have, and refuses to start. A binary linked
against glibc 2.29 runs on 2.29, 2.31 and 2.35 alike.
Measured on the actual fleet, 2026-08-25:
| binary | max GLIBC required | runs on |
|---|---|---|
| what all four 20.04 seeds run today (`v1.0.3-d159e7208`) | `GLIBC_2.29` | 18.04, 20.04, 22.04 |
| anything built on seed 176 today (Ubuntu 22.04) | `GLIBC_2.34` | 22.04 only |
The second binary will not start on four of our own five seeds. The loader reports:
```
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.30' not found
```
Nothing new is being *called*. glibc 2.34 merged libpthread and libdl into libc and
re-versioned every `pthread_*`, `dlsym` and `dladdr` symbol; 2.33 replaced the old
`__xstat` inlines with real `stat`/`fstat`/`lstat64`. All of those functions exist in
2.31 under older tags. Building against older headers is the entire fix.
**Do not try to solve this with full static linking.** The daemon calls `getaddrinfo`,
`gethostbyname` and `getnameinfo`, and it must resolve `node1..node5.dragonx.is`, which
are hard-coded and injected into `-addnode` on every node. Under a fully static glibc
binary those go through NSS, which `dlopen`s `libnss_dns.so.2` at run time — it either
fails or silently requires the target to have the same glibc you linked against, which
defeats the purpose.
---
## 2. What already exists in this repo
Do not write a new build system. Two pieces are already here:
- **`Dockerfile.compat`** — an Ubuntu base image that installs the toolchain, copies the
tree, **deletes any host-built `depends/` and object files**, runs `./util/build.sh`,
and strips the three binaries.
- **`./build.sh --linux-compat`** — builds that image, creates a throwaway container,
copies `dragonxd`, `dragonx-cli` and `dragonx-tx` out into
`release/dragonx-<version>-linux-amd64-ubuntu2004/`, adds `bootstrap-dragonx.sh`,
`asmap.dat` and the two sapling params, fixes ownership, and prints the binary's
maximum required GLIBC version.
The base image is parameterised via `ARG BASE_IMAGE` (default `ubuntu:20.04`), so the
same Dockerfile can target several glibc floors.
---
## 3. Prerequisites
Docker (the scripted path uses `docker` specifically; podman works for the manual path
if you alias or substitute it).
```sh
sudo apt-get update
sudo apt-get install -y docker.io git
sudo usermod -aG docker "$USER" # then log out and back in, or every command needs sudo
```
Budget, measured on a 4-core box:
| resource | needs |
|---|---|
| disk | ~15 GB free (the `depends/` tree alone is ~1.6 GB per target, plus image layers) |
| RAM | 4 GB minimum, 8 GB comfortable — the link step is the peak |
| time | **12 hours per base image on first build.** `depends/` builds boost, BDB, wolfssl, libevent, libsodium, libcurl and rust from source. Later builds reuse Docker layer cache unless the tree changed. |
`depends/` downloads and builds its own rust toolchain, so the host's rust (or absence
of it) is irrelevant.
---
## 4. Build one target
```sh
git clone https://git.dragonx.is/DragonX/dragonx
cd dragonx
git checkout <the tag or branch you are releasing>
./build.sh --linux-compat
```
Output lands in `release/dragonx-<version>-linux-amd64-ubuntu2004/` and the script
prints the max GLIBC at the end. `<version>` is read from `configure.ac`, not
hardcoded, so it always matches what the binaries report.
---
## 5. Build several targets
```sh
for BASE in ubuntu:18.04 ubuntu:20.04 ubuntu:22.04; do
TAG="dragonx-compat-${BASE#ubuntu:}"
TAG="${TAG//./}"
docker build --build-arg "BASE_IMAGE=$BASE" -f Dockerfile.compat -t "$TAG" .
OUT="release/dragonx-$(grep -oP 'define\(_CLIENT_VERSION_MAJOR, \K[0-9]+' configure.ac).$(grep -oP 'define\(_CLIENT_VERSION_MINOR, \K[0-9]+' configure.ac).$(grep -oP 'define\(_CLIENT_VERSION_REVISION, \K[0-9]+' configure.ac)-linux-amd64-${BASE#ubuntu:}"
mkdir -p "$OUT"
CID=$(docker create "$TAG")
for b in dragonxd dragonx-cli dragonx-tx; do docker cp "$CID:/build/src/$b" "$OUT/$b"; done
docker rm "$CID" >/dev/null
cp util/bootstrap-dragonx.sh contrib/asmap/asmap.dat sapling-output.params sapling-spend.params "$OUT/" 2>/dev/null || true
done
```
### Which base to choose
| base | glibc it provides | default GCC | verdict |
|---|---|---|---|
| `ubuntu:18.04` | 2.27 | 7 | **Verify before relying on it.** The tree is built with `-std=c++17`; GCC 7's C++17 support is incomplete and its cmake (3.10) may be too old for RandomX. Attempt only if you need to reach 18.04 users, and treat a successful build as the proof. |
| `ubuntu:20.04` | 2.31 | 9 | **Recommended floor.** GCC 9 covers C++17 fully. Evidence it works: the binary the fleet runs today requires only `GLIBC_2.29`, i.e. the code touches nothing newer, so a 20.04 build reaches 18.04 machines anyway. |
| `ubuntu:22.04` | 2.35 | 11 | **Do not ship this.** It is what we already have and what excludes four of our own seeds. Useful only for development. |
Ubuntu 20.04 left standard support in April 2025, which is precisely why it belongs in
a container on a patched host rather than on a build box someone has to maintain.
---
## 6. Verify — this step is not optional
A build that silently targets the wrong glibc looks completely normal until a user
reports that nothing starts.
```sh
BIN=release/dragonx-<version>-linux-amd64-ubuntu2004/dragonxd
# The ceiling. Must be <= the glibc of the OLDEST system you intend to support.
objdump -p "$BIN" | grep -oE 'GLIBC_2\.[0-9]+' | sort -t. -k2 -n | tail -1
objdump -p "$BIN" | grep -oE 'GLIBCXX_3\.4\.[0-9]+' | sort -t. -k3 -n | tail -1
# If the ceiling is too high, this names the symbols responsible.
readelf --dyn-syms --wide "$BIN" | grep -E '@GLIBC_2\.(3[2-9])'
```
Expected for a 20.04 build: `GLIBC_2.29` or lower, `GLIBCXX_3.4.26` or lower.
Then actually run it somewhere old. A ceiling check proves the loader will resolve the
symbols; it does not prove the binary works. `./dragonxd --version` on a real 20.04 box
is a ten-second confirmation.
---
## 7. Traps
Each of these has cost real time.
**`ETXTBSY` when installing over a running daemon.** `cp` onto the binary fails with
"Text file busy" *even after the process has exited*`pgrep` returning nothing is not
sufficient, the kernel still holds the text mapping. Stage into the same directory and
`mv` (rename is not blocked), allow ~10 s to settle, and **sha256-verify the installed
file before starting it**. A failed copy that goes unnoticed leaves the old binary
running and looks like a successful deploy.
**Never touch `configure.ac` in a configured tree.** Even `cp`-ing back a byte-identical
copy updates its mtime, which makes `make` regenerate `aclocal.m4` and `configure` and
then re-run `configure`, which fails with `libdb_cxx headers missing` because the
depends prefix is not on the command line. If it happens: confirm
`git diff --quiet HEAD -- configure.ac`, then restore mtime order oldest-to-newest with
one-second gaps — `configure.ac`/`Makefile.am`, then `aclocal.m4`, then
`configure`/`Makefile.in`, then `config.status`, then `Makefile`. Inside a container
this cannot happen, which is one more reason to build there.
**Never blind-`touch` a path that might not exist.** `touch src/config/hush-config.h`
silently *creates* an empty stray file; the real header is `bitcoin-config.h`. Check
`git status` after any timestamp surgery.
**RandomX must be built with `ARCH=default`.** `util/build.sh` already passes it and the
comment there explains why: `ARCH=native` tunes to the build machine, and a build on an
AVX-512 host emitted 746 `zmm` instructions into `librandomx.a`, which `SIGILL`s on the
entire fleet. If you ever invoke cmake by hand, pass `-DARCH=default`.
**Strip before distributing.** Unstripped is ~220 MB, stripped ~16 MB. `Dockerfile.compat`
already strips inside the container.
**`util/build-win.sh` discards every argument.** There is no `"$@"` handling in it, so
`-j$(nproc)` and `--disable-tests` are dropped on the floor and the Windows build is
single-threaded. Expect it to be far slower than you planned.
**Windows also needs `-Wa,-mbig-obj` and `-DARCH=default`.** Both are in
`util/build-win.sh` today. The mingw flag was missing from `dev` for a month; without it
the cross-compile fails at link because boost-heavy translation units exceed the
PE/COFF section limit. Do not lose it on a re-branch.
**macOS cannot be containerised.** `util/build-mac.sh` is a native-Mac script, and there
is no darwin cross-compile path in `depends/` at all: `hosts/darwin.mk` wants
`native_cctools`, which has no package definition, and there is no SDK in the tree. It
also hardcodes an Intel Homebrew GCC path, so it produces x86_64 only — no arm64, no
universal binary. macOS needs a real Mac.
**The `contrib/gitian-descriptors/` files are not a build path.** They are unmodified
upstream Bitcoin files (`name: "bitcoin-win-0.11"`, suite `trusty`) with zero DragonX
content. Ignore them.
---
## 8. Handoff checklist
- [ ] Docker installed, user in the `docker` group, ~15 GB free
- [ ] Correct tag or branch checked out, tree clean (`git status`)
- [ ] Version in `configure.ac` is the one you intend to release
- [ ] `./build.sh --linux-compat` completes
- [ ] GLIBC ceiling is **2.31 or lower** (2.29 expected)
- [ ] GLIBCXX ceiling is **3.4.28 or lower** (3.4.26 expected)
- [ ] `dragonxd --version` runs on a real machine of the oldest supported distro
- [ ] Binaries stripped, `release/` contains the bootstrap script, `asmap.dat` and both sapling params
- [ ] sha256 recorded for each artifact
One more thing that is not a build step but belongs in the same conversation: the
in-app daemon updater refuses any release without a detached signature
(`kDaemonRequireSignature = true`). Publishing checksums alone means no existing user
can update in place.