Gebündelte Freude

Oder: Wie wir durch parallele Tests mehr über das Laden von Gems lernten.

Es war einmal, vor nicht allzu langer Zeit, dass wir in einem Projekt in einem Docker-Container mit ParallelTests RSpec-Tests parallelisiert laufen lassen wollten und in unerwartete Probleme beim Setup in Docker liefen.

➜  docker-compose run --rm app rails parallel:spec
8 processes for 156 specs, ~ 19 specs per process
/usr/local/lib/ruby/gems/2.3.0/gems/bundler-1.12.5/lib/bundler/rubygems_integration.rb:373:in `block in replace_bin_path': can't find executable bundle (Gem::Exception)
  from /usr/local/lib/ruby/site_ruby/2.3.0/rubygems.rb:298:in `activate_bin_path'
  from /usr/local/bin/bundle:22:in `<main>'
...

Spoiler: Die Ursache war die Kombination aus Bundler-Version und RubyGems-Version.

Hintergrund

RubyGems ist der offizielle Paket-Manager für Ruby und ermöglicht es, Bibliotheken in Form von Gems nach Bedarf zu verwalten. Bundler dient der Verwaltung der Gem-Abhängigkeiten und erlaubt es, in einem Gemfile Gem-Versionen für ein Projekt zu spezifizieren, so dass man über verschiedene Systme und Benutzer hinweg eine konsistente Ruby-Umgebung hat.

Wie funktioniert das?

Um externen Code in Ruby einzubinden, wird typischerweise Kernel.require oder Kernel.load verwendet. Beide Methoden durchsuchen die Verzeichnisse in $LOAD_PATH nach einer passenden Datei. RubyGems ersetzt die gem- und die require-Methode im Kernel, um rechtzeitig den passenden Pfad für das gewünschte gem an den Anfang von $LOAD_PATH zu setzen, so dass es gefunden werden kann. Bundler stellt die korrekte Version der gems im Gemfile sicher, indem es $LOAD_PATH komplett leert und dann – mit Hilfe von RubyGems Gem-Pfad-Methoden – mit den passenden Pfaden befüllt. Dadurch wird außerdem gewährleistet, dass keine weiteren Gems als die in Gemfile genannten Gems (und ihre Abhängigkeiten) geladen sind.

Das bedeutet, dass Bundler und RubyGems eng miteinander verwoben sind, und dass Bundler auf Änderungen in neuen RubyGems-Versionen reagieren muss. Was uns zu unserem ursprünglichen Problem, den parallelen Tests in Docker, zurück führt.

Das Problem

Unser Docker-Image baut auf dem offiziellen Ruby 2.3 Docker-Image auf. Erstellt wurde es erstmals Ende August, so dass die damals aktuelle Version von Bundler und Rubygems installiert wurde, d.h. bundler Version 1.12.5 und rubygems Version 2.6.6. Mit RubyGems Version 2.6.2 kam eine neue Pfad-Methode, activate_bin_path, dazu, die sich auf den Bundler.setup-Prozess ausgewirkt hat. Anpassungen in Bundler führten zunächst dazu, dass die Variable GEM_PATH bei verschachtelten bundle exec Aufrufen nicht korrekt gesetzt war, wenn Bundler systemweit installierte Gems ignorieren sollte. Das ist sinnvoll, wenn man das Gem-Set komplett lokal gebündelt haben möchte – und wir hatten die Option disable_shared_gems im Docker-Container aktiviert. Ein Aufruf mit bundle exec stellt sicher, dass der Code tatsächlich im Kontext der gebündelten Gems ausgeführt wird. In unserem Docker-Container wird daher bundle exec verwendet, um rails parallel:spec auszuführen, und das wiederum ruft intern erneut bundle exec auf, um RSpec in den einzelnen Prozessen auszuführen. Also haben wir die Auswirkungen der GEM_PATH-Regression zu spüren bekommen. Für mehr Informationen zu diesem Problem: siehe Bundler Pull Request #4992.

Die GEM_PATH-Regression wurde in Bundler Version 1.13.2 gelöst. Mit Bundler Version 1.13 traten aber neue Probleme auf: Bei Bundler.require wird der $LOAD_PATH jetzt so zurückgesetzt, dass nur noch die Gems der gegebenen Gemfile-Gruppe geladen werden – und die default-Gruppe wird offensichtlich bei verschachtelten bundle exec-Aufrufen nicht weitergereicht. Dieses Problem ist derzeit ein offenes Bundler Issue (für mehr Informationen: siehe Bundler Issue #5005), es gibt aber einen Workaround. Dazu deaktiviert man eine Optimierung in bundle exec, die statt Kernel.require das etwas schlankere Kernel.load verwendet – das einige RubyGems-Mechanismen umgeht.

Die Lösung

Folgende Anpassungen haben geholfen:

  • Im Dockerfile:
    FROM ruby:2.3.1-slim
    # We need bundler version >= 1.13.2 and a matching rubygems version.
    ENV RUBYGEMS_VERSION 2.6.7
    RUN gem update --system "$RUBYGEMS_VERSION"
    ENV BUNDLER_VERSION 1.13.7
    RUN gem install bundler --version "$BUNDLER_VERSION"
    
  • In docker-compose.yml:
    services:
      app:
        environment:
          - BUNDLE_DISABLE_EXEC_LOAD=true
    

Links