Skip to content
Joey Wang
Menu

Search

Full Stack Development

Nokogiri vs libxml2: Debugging a Dynamic Library Conflict

Why Nokogiri XPath queries failed in Docker but not on an M1 Mac: a libxml2 version mismatch caused by indirect dynamic library loading in Ruby.

· 4 min read

engineering #ruby#debugging#docker#linux

Audio summary

Tests pass locally, fail in the container

Rails tests passed on an M1 MacBook and failed in Docker with a cryptic XPath error:

Nokogiri::XML::XPath::SyntaxError: ERROR: Invalid expression: .//*:a | self::*:a

The expression is valid XPath. Something else was wrong.

The warning that explains it

Buried in the logs:

WARNING: Nokogiri was built against libxml version 2.13.8, but has dynamically loaded 2.9.14

Nokogiri was compiled against one libxml2 version and running against another at runtime. The XPath syntax used in 2.13.8 doesn’t exist in 2.9.14.

How Ruby loads native extensions

require 'nokogiri' triggers a chain: Ruby finds the extension on $LOAD_PATH, calls dlopen() to load the shared library, the dynamic linker resolves its C dependencies, and function calls get bound to whatever got loaded. The bug lives in that third step: the dynamic linker can resolve to a different libxml2 than the one Nokogiri expects.

Tracing it

Platform gave the first clue:

# M1 Mac
$ ruby -e 'puts Gem::Platform.local.to_s'
arm64-darwin-24

# Linux container
$ ruby -e 'puts Gem::Platform.local.to_s'
x86_64-linux-gnu

Different platforms, different pre-compiled gems, different linking strategies. ldd on the compiled extension showed no direct libxml2 dependency at all:

$ ldd ./lib/nokogiri/3.4/nokogiri.so
linux-vdso.so.1 (0x00007ffe8ed31000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (...)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (...)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (...)
/lib64/ld-linux-x86-64.so.2 (...)

Either it’s statically linked, or it’s being resolved indirectly. LD_DEBUG confirmed the latter:

$ LD_DEBUG=libs ruby -rnokogiri -e "puts 'loaded'" 2>&1 | grep libxml2
file=/lib/x86_64-linux-gnu/libxml2.so.2 [0]; needed by nokogiri.so [0] (relocation dependency)

A relocation dependency: Nokogiri doesn’t link libxml2 directly, it needs it resolved at runtime through whatever already loaded it. The full chain:

ruby-vips  ─▶ system vips ─▶ libxml2 v2.9.14

nokogiri (built for v2.13.8) ────▶ bound to v2.9.14 instead (wrong)

ruby-vips loads first and pulls in the system’s libxml2.so.2 (2.9.14). Nokogiri loads after and finds libxml2 symbols already resolved to that version, so its calls bind to 2.9.14 instead of the 2.13.8 it was built against. XPath syntax compiled for 2.13.8 then fails at runtime.

Tools worth knowing for this class of bug

# Trace all library loading
LD_DEBUG=libs,files ruby -rnokogiri -e "puts 'loaded'" 2>&1 | grep xml

# See what's actually loaded at runtime
lsof -p $(pgrep ruby) | grep libxml

# Check symbol resolution
LD_DEBUG=symbols ruby -rnokogiri -e "Nokogiri::XML('<test/>')" 2>&1 | grep xml

# Inspect a gem's compiled extensions
find $(gem which nokogiri | xargs dirname) -name "*.so" -exec ldd {} \;

And from inside Ruby:

require 'nokogiri'
puts "Runtime version: #{Nokogiri::XML::LIBXML_VERSION}"
puts "Compiled version: #{Nokogiri::XML::LIBXML_COMPILED_VERSION}"
puts Nokogiri::VERSION_INFO.to_yaml

Debugging this in CI without endless commit cycles

The trap with a bug like this is iterating by pushing a commit, waiting for CI, reading one more log line, and repeating. Gather everything in one run instead: gate a diagnostic block behind an env var so it’s easy to enable and never runs in normal operation.

# lib/tasks/check_nokogiri.rake
namespace :check_nokogiri do
  desc "Compares Nokogiri's compiled vs loaded libxml version and flags conflicting gems"
  task versions: :environment do
    require 'nokogiri'
    compiled = Nokogiri::VERSION_INFO.dig('libxml', 'compiled')
    loaded   = Nokogiri::VERSION_INFO.dig('libxml', 'loaded')
    puts "Compiled: #{compiled}  Loaded: #{loaded}  Match: #{compiled == loaded}"

    Bundler.load.specs.each do |spec|
      next unless spec.name.include?('xml') || spec.name.include?('vips')
      next if spec.name == 'nokogiri'
      puts "Potentially conflicting gem: #{spec.name} (#{spec.version})"
    end
  end
end

Run it locally, in CI with LD_DEBUG=files,libs set, and inside the actual production container if the environments diverge:

kubectl run debug-web --image=gcr.io/your-project/your-app:debug \
  --namespace=your-namespace --restart=Never --rm -it -- bash
# then, inside the pod:
bundle exec rake check_nokogiri:versions
ldd /usr/local/bundle/gems/nokogiri-*/lib/nokogiri/*/nokogiri.so

One run in each environment tells you more than five commits of trial and error.

Fixes

Load order. Requiring Nokogiri before the gem that pulls in the conflicting system library reduces the chance of the wrong binding, though it’s not guaranteed:

require 'nokogiri'
require 'ruby-vips'

Force source compilation instead of using the pre-built binary gem:

gem uninstall nokogiri
gem install nokogiri --platform=ruby -- --use-system-libraries=false

Pin the system library version in the container image so there’s only one libxml2 to resolve to:

FROM ruby:3.4-slim
RUN apt-get update && apt-get install -y \
    libxml2-dev=2.13.8* \
    libxslt1-dev \
    && rm -rf /var/lib/apt/lists/*
COPY Gemfile* ./
RUN bundle install

Detect and warn at boot, so a mismatch shows up in logs immediately instead of as a mysterious test failure weeks later:

class Application < Rails::Application
  config.after_initialize do
    if defined?(Nokogiri) && Nokogiri::XML::LIBXML_VERSION != Nokogiri::XML::LIBXML_COMPILED_VERSION
      Rails.logger.warn "libxml2 version mismatch: compiled=#{Nokogiri::XML::LIBXML_COMPILED_VERSION}, runtime=#{Nokogiri::XML::LIBXML_VERSION}"
    end
  end
end

The lesson

Pre-compiled gems carry hidden dependencies that only surface at runtime, and they surface differently per platform: what resolves cleanly on macOS can break on Linux because the two use different pre-built binaries and different system libraries. Load order matters whenever two gems touch the same system library. And LD_DEBUG is the tool that turns “mysterious failure in one environment” into a one-line diagnosis, because it shows you what the dynamic linker actually decided, not what you assumed it would.