This article is based on a post originally published on blog.chef.io and expanded for a DevOps engineering audience.
Background: Why Native Binary Dependencies Are Complicated
If your primary experience is with managed-runtime languages — Java, C#, Ruby — or with Windows development, you may never have had to think deeply about how a compiled binary finds its dependencies at runtime on Linux. On Windows, finding a missing low-level dependency usually means downloading an MSI and moving on. The situation on Linux is meaningfully different, and understanding it is essential for anyone building software with Chef Habitat.
This article explains the fundamentals of ELF binaries, static versus dynamic linking, and how Habitat exploits dynamic linking to deliver genuine, dependency-complete application portability.
What Is an ELF Binary?
ELF stands for Executable and Linkable Format. It is the standard binary format used for executables, shared libraries, and object files on Linux and most Unix-like systems. An ELF file is structured as a set of headers followed by named sections (commonly .text for machine instructions, .data for initialised data, and so on).
One of the most important jobs of an ELF binary is to tell the operating system how to load the program into memory and which external symbols (functions, variables) must be resolved before the program can run. That information lives in dedicated ELF sections and is read by the dynamic linker — typically ld-linux.so — every time the binary is executed.
Compiling vs. Linking: Two Distinct Steps
Developers often use the word “compiling” to mean the whole build process, but there are actually two separate operations:
- Compiling — The compiler reads source code files and translates them into machine-language object files (
.ofiles). Object files on their own cannot be executed. - Linking — The linker (
ldon Linux) takes one or more object files, resolves references to external libraries, and produces a final executable or shared library. If your code calls a function from an external library and that function cannot be found, the linker — not the compiler — reports the error.
Static vs. Dynamic Linking
Static Linking
In static linking, the machine code from every external library is copied directly into the final executable. The result is a single, self-contained binary with no runtime dependency on external shared object files. Static binaries are easy to deploy, but they are larger and must be recompiled and redeployed whenever a dependency is updated (for example, to patch a security vulnerability).
Dynamic Linking
Dynamic linking keeps external code outside the executable. Instead of embedding library code, the linker writes metadata into the ELF binary that names the required shared object files (.so on Linux, .dll on Windows). When the binary is executed, the operating system’s dynamic linker reads that metadata and loads the correct shared libraries into the process at runtime.
The benefits of dynamic linking include:
- Smaller application binaries, because library code is not duplicated.
- The ability to update a shared library across all applications that use it without recompiling any of them.
- Memory efficiency, because the OS can share a single copy of a loaded library between multiple running processes.
The trade-off is that the correct version of every required shared library must be present on the target machine at runtime. This is the classic source of “it works on my machine” deployment failures.
How the Linux Dynamic Linker Finds Shared Libraries
The linker (ld) and the runtime dynamic linker (ld-linux.so) follow a strict, ordered set of rules when searching for shared libraries. Understanding this order is critical for diagnosing dependency errors and for understanding how Habitat controls the process. The following search order is taken directly from the ld man page:
- Directories specified by
-rpath-linkoptions (link time only; not embedded in the binary). - Directories specified by
-rpathoptions (embedded in the ELF binary and honoured at runtime). - The contents of the
LD_RUN_PATHenvironment variable, if-rpathand-rpath-linkwere not used (ELF, native linkers only). - On SunOS, directories specified by
-Loptions if-rpathwas not used. - The contents of the
LD_LIBRARY_PATHenvironment variable (native linkers). - The
DT_RUNPATHorDT_RPATHentries inside a shared library being loaded (DT_RPATHis ignored ifDT_RUNPATHis present). - The default system directories: normally
/liband/usr/lib. - Directories listed in
/etc/ld.so.conf(ELF systems with a native linker).
Two entries in this list are especially important for Habitat: -rpath (step 2) and DT_RUNPATH/DT_RPATH (step 6). Both allow a path to be baked directly into an ELF binary so that the runtime linker knows exactly where to look — regardless of what is installed in /usr/lib or what LD_LIBRARY_PATH says.
The Dependency Problem in Traditional Linux Deployments
Consider a common scenario: your application depends on the ZeroMQ library. During development you run apt-get install libzmq-dev and the library lands somewhere under /usr. Your application builds and runs. When you promote to production, you (or your configuration management tool) must ensure that the same library — ideally at the same version — is installed on every production node.
This is fragile for several reasons:
- Different Linux distributions package different versions of the same library.
- Two applications on the same node may require different, incompatible versions of the same library. Most package managers cannot install multiple versions of a library into
/usr/libsimultaneously. - Configuration management tools such as Chef can enforce that a specific version is installed, but they cannot easily manage version conflicts between co-located applications.
How Habitat Solves the Problem with Dynamic Linking
Dependencies Travel with the Application
Habitat packages applications as .hart files. When a Habitat package is installed, Habitat also installs every package listed in the application’s dependency manifest. These packages are placed under Habitat’s own root directory (by default /hab/pkgs/), completely separate from the system’s /usr tree. They do not conflict with system-installed libraries or with libraries belonging to other Habitat-managed applications.
Build-Time and Runtime Paths Are the Same
During the Habitat build process, the linker is configured to embed the paths to Habitat-managed dependencies directly into the resulting ELF binary using RPATH or RUNPATH entries. This means that at runtime, the dynamic linker resolves shared libraries from those same Habitat package directories — not from /usr/lib or whatever happens to be on LD_LIBRARY_PATH.
The practical consequence: the exact binaries linked at build time are the exact binaries loaded at runtime. No separate configuration management layer is needed to enforce this guarantee.
Side-by-Side Versioning
Because each Habitat application’s dependencies live under their own versioned paths within /hab/pkgs/, two applications on the same node can depend on different versions of the same library and run simultaneously without conflict. The RPATH baked into each binary points to its own specific dependency version.
The Habitat Studio: Keeping Builds Clean
A potential weakness in this model is the build step itself. If during a build the linker silently falls through to /usr/lib and picks up a system library that is not listed as a Habitat dependency, the resulting binary will have a runtime dependency that Habitat does not know about and will not install. The package would break on any node that does not happen to have that exact system library version installed.
Habitat prevents this with the Studio: a clean, minimal, isolated build environment. Inside the Studio, system library paths are not available to the linker. The only libraries the linker can find are those explicitly declared as Habitat dependencies in the build plan. If your plan is missing a dependency, the build fails loudly inside the Studio — which is exactly the right behaviour. A build failure during development is far preferable to a mysterious runtime failure in production.
Practical Implications for Plan Authors
Understanding the ELF linker search order and how Habitat manipulates it helps you write better build plans and diagnose build failures faster. Key points to keep in mind:
- Declare all dependencies explicitly. If a library your software needs is not in your plan’s dependency list, the Studio build will fail. Treat that failure as useful signal, not an annoyance.
- Be cautious with build systems that hard-code paths. CMake files, configure scripts, and downloaded pre-built binaries may expect libraries in
/usr/lib. Inside the Habitat Studio those paths do not exist, so such build systems require patching or careful configuration. - Understand
-rpathvsLD_LIBRARY_PATH. Relying onLD_LIBRARY_PATHat runtime is fragile because it can be overridden or unset. Habitat favours embedding paths directly in the binary viaRPATH/RUNPATH, which is more robust. - Software that uses non-GCC toolchains adds complexity. Projects that use Clang or non-standard build systems (such as .NET Core) may download pre-compiled binaries or have build scripts that assume standard filesystem layouts. These require additional work to Habitat-ise correctly.
Summary
ELF binaries on Linux carry metadata that tells the dynamic linker exactly where to find shared library dependencies at runtime. By controlling that metadata during the Habitat build process — specifically by embedding Habitat package paths into the binary’s RPATH/RUNPATH — Habitat guarantees that an application always loads the same dependencies it was built against, regardless of what is installed on the host system. The Habitat Studio enforces this at build time by removing access to system library paths entirely. The result is genuine, reproducible application portability: install the .hart package and everything the application needs comes with it.