I'm working on building neetoCI, which is a CI/CD solution. While building pre-compiled Ruby binaries we ran into some challenges. This blog post explores the problems we faced and how we solved them.
Pre-compiled Ruby binaries
Pre-compiled Ruby binaries are distribution-ready versions of Ruby that include optimized features for specific systems. These Ruby binaries save time by eliminating the need to compile Ruby source code manually. Pre-compiled Ruby binaries help users quickly deploy applications that use different versions of Ruby on multiple machines.
RVM (Ruby Version Manager) is widely used for managing Ruby installations on Unix-like systems. RVM provides customized pre-compiled Ruby binaries tailored for various CPU architectures. These binaries offer additional features like readline support and SSL/TLS support. You can find them at RVM binaries.
The Need for pre-compiled Ruby Binaries
neetoCI must execute user code in a containerized environment. A Ruby environment is essential for running Ruby on Rails applications. However, relying on the system's Ruby version is impractical since it may differ from the user's required version. Although rbenv or rvm can be used to install the necessary Ruby version, this approach could be slow. To save time, we chose to leverage pre-compiled Ruby binaries.
As a CI/CD system, neetoCI must ensure that all versions of Ruby that an application requires are always available. Hence we decided to build our binaries instead of relying on binaries provided by RVM. Also, this would allow us to do more system-specific optimizations to the Ruby binary at build time.
Building pre-compiled Ruby binaries
We built a Ruby binary following the official documentation . We were able to execute it on our local development machines. But the same binary ran into an error in our CI/CD environment.
1$ bundle config path vendor/bundle 2./ruby: bad interpreter: No such file or directory
To debug the issue, we initially focused on $PATH. However, even after resolving the $PATH issues, the problem persisted. We conducted a thorough investigation to identify the root cause. Unfortunately, not much was written on the Internet about this error. There was no mention of it in the official Ruby documentation.
As the next step, we decided to download the binary for version 3.2.2 from RVM. While examining the configuration file, we noticed that the following arguments were used with the configure command during the Ruby binary build process:
1configure_args="'--prefix=/usr/share/rvm/rubies/ruby-3.2.2' '--enable-load-relative' '--sysconfdir=/etc' '--disable-install-doc' '--enable-shared'"
Here are the explanations of the configuration arguments:
--prefix=/usr/share/rvm/rubies/ruby-3.2.2: This specifies the directory where the Ruby binaries, libraries and other files will be kept after the installation is done.
--enable-load-relative: This specifies that Ruby can load relative paths for dynamically linked libraries. It allows the usage of relative paths instead of absolute paths when loading shared libraries. This feature can be beneficial in specific deployment scenarios.
--sysconfdir=/etc: This argument sets the directory where Ruby's system configuration files will be installed. In this case, it specifies the /etc directory as the location for these files.
--disable-install-doc: When this option is enabled, the installation of documentation files during the build process is disabled. This can help speed up the build process and save disk space, especially if you do not require the documentation files.
--enable-shared: Enabling this option allows the building of shared libraries for Ruby. Shared libraries enable Ruby to dynamically link and load specific functionality at runtime, leading to potential performance improvements and reduced memory usage.
In simpler terms, when the --enable-load-relative flag is enabled, the compiled Ruby binary can search for shared libraries in its own directory using the $ORIGIN variable.
When I built the binary on the docker registry then the passed --prefix was something like /usr/share/neetoci. When the binary is built then binary had /usr/share/neetoci hard coded at various places. When we download this binary and use in CI then in the CI environment Ruby is looking for /user/share/neetoci to load dependencies.
By enabling --enable-load-relative flag while building the binary Ruby will not use the hard coded value. Rather Ruby will use $ORIGIN variable and will search for the dependencies in directory mentioned in $ORIGIN.
This is particularly helpful when the Ruby binary is relocated to a different directory or system. By using relative paths with $ORIGIN, the binary can find its shared libraries regardless of its new location. Without this flag, shared libraries are loaded using absolute paths, which can cause issues if the binary is moved to a different location and cannot locate its shared libraries.
In our specific use case, where we create and download binaries in separate containers, we encountered an error due to the absolute paths. To overcome this, we enabled the --enable-load-relative flag. This allowed the binary to find its shared libraries successfully, and it worked as expected in our CI/CD environment.