Old Ruby and New Mac
Picture this scenario - you're a Rails developer that just got one of the new M1 or M2 Macs and are enjoying the performance boost and energy efficiency that comes from the ARM-based architecture. You've installed and configured all your dev tooling (oh-my-zsh, homebrew, rbenv, etc.) and are ready to get to work. Everything is great.
Then you receive your next assignment, which is to perform some maintenance on an old Rails 4 project using Ruby 2.3.x (yes they still exist). So you get started by installing Ruby 2.3.3 and encounter the following error:
$ rbenv install 2.3.3
To follow progress, use 'tail -f /var/folders/t7/6n5394/T/ruby-build.20230501063921.137.log' or pass --verbose
Downloading openssl-1.0.2u.tar.gz...
-> https://dqw8nmjcqpjn7.cloudfront.net/ecd0c6ffb493dd06707d38b...
Installing openssl-1.0.2u...
BUILD FAILED (macOS 13.3.1 using ruby-build 20230330)
Inspect or clean up the working tree at /var/folders/t7/6n5394/T/ruby-build.20230501063921.137.TS6
Results logged to /var/folders/t7/6n5394/T/ruby-build.20230501063921.137.log
Last 10 log lines:
_dgram_write in libcrypto.a(bss_dgram.o)
_RAND_query_egd_bytes in libcrypto.a(rand_egd.o)
...
ld: symbol(s) not found for architecture i386
clang: error: linker command failed with exit code 1 (use -v to see invocation)
make[4]: *** [link_a.darwin] Error 1
make[3]: *** [do_darwin-shared] Error 2
make[2]: *** [libcrypto.1.0.0.dylib] Error 2
make[1]: *** [shared] Error 2
make: *** [build_crypto] Error 1
That feeling...
The issue here is with OpenSSL linking against the i386 architecture, which is not compatible with the new Mac's ARM-based architecture, and so the build fails. There are some suggestions related to uninstalling OpenSSL and re-installing with alternate compile flags. You can also attempt to uninstall all the dev tooling, setup for rosetta all the things, then re-install.
I tried a variety of these and was still getting errors. Keep in mind its not just Ruby that needs to be installed, its also all the older gems, some of which require native extensions and will run into compilation issues. I was also concerned that it would cause problems when switching to other projects that use newer Ruby versions (3.x and above are compiled for the newer Macs), as I frequently toggle between different projects. In this post I'll share an alternative solution using Docker that I found easier to get working.
The idea is to build a Docker image that comes with the older Ruby version needed for this project, mount the project code into this image, and install the project dependencies as part of the image. Then a container can be run from this image, and all the usual Rails commands such as starting a server, console, etc. can be run inside the container.
Docker Image
The first thing that's needed is to build the Docker image. When doing so, we need to select a base image from which to get started. I'm using a CircleCI Ruby image as it comes with common dev tooling and a non-root user named circleci
. Here is the Dockerfile that uses the Ruby 2.3.3.
To use it, place the following file named Dockerfile
in the root of your project. Replace 2.3.3
with your Ruby version, and myapp
with your app name:
FROM circleci/ruby:2.3.3
# Set path to the app as an environment variable
# so it can be referenced again in this file as `$app`
ENV app /usr/share/myapp
# Create a directory for our app.
RUN sudo mkdir -p $app
# Make the `circleci` user owner of our app dir.
RUN sudo chown circleci $app
# Give `circleci` owner read/write/execute on app dir
# and all dirs/files within it, and read/execute for other users.
RUN sudo chmod -R 0755 $app
# Set the working directory to our app dir.
WORKDIR $app
# Copy files/dirs from build context to the current working directory in container,
# and sets the user/group ownership of copied files/dirs to circleci:circleci.
COPY --chown=circleci:circleci . .
# Replace with bundler version used by your old app.
RUN gem install bundler:1.17.3
# Install app dependencies
RUN bundle install
Docker Compose
Add the following docker-compose.yml
file to the root of the project. This will make it easy to run a container from the Dockerfile
created in the previous step:
version: "3.9"
services:
myapp:
# Use Dockerfile from current directory
build:
context: .
# Mount the current directory `.` into container at `/user/share/myapp`
volumes:
- .:/usr/share/myapp
# Map port 3000 on the container to 3000 on the host
ports:
- "3000:3000"
# Allow container to respond to Ctrl+C and to view container's output in real-time
tty: true
To build the image, run:
docker-compose build
Docker Container
To run a container from the image, run:
docker-compose up
This will run the container in the foreground, which attaches the terminal to the logs of the containers so that you can see its output. But then you can't use that terminal session for anything else, and have to open a new terminal tab to do other things.
Alternatively, you can run the container in the background (aka detached mode), and the output will not be displayed in the terminal:
docker-compose up -d
If you run it in the background, you can always view the logs with the command below which will follow the logs in real time:
docker-compose logs -f
Rails Server
Now that you have a running container, it's time to start a Rails server. To do this, first run a shell in the container, using exec
which is used to execute a command inside a running container:
docker-compose exec myapp bash
Then from the shell prompt in the container, start the Rails server:
pwd
# Should be /usr/share/myapp because of WORKDIR setting in Dockerfile
ls
# Should see contents of myapp such as models, views, controllers, lib, etc.
# Start Rails server
rails server
# Server should be listening on port 3000
At this point, you should be able to navigate to http://localhost:3000
in a browser and view your app's home page.
Note that if you shutdown your computer which will stop the container, or use docker-compose stop
to stop the container, the Rails server process that's running in the container may not shut down cleanly because it's not running as PID 1 in the container. That means if you shell in again to run a Rails server, you may get the server is already running error. We'll deal with this shortly.
Other Commands
To run any other commands in the container such as database migrations or a Rails console, open a new terminal tab, shell into the container (you can have multiple shells) and run your command. For example, to run database migrations:
docker-compose exec myapp bash
rake db:migrate
To run a Rails console:
docker-compose exec myapp bash
rails console
Combining Commands
One thing you may have noticed about running Rails dockerized is there's more typing to get things running. For example, to run a Rails console, requires three commands:
# 1. Start container
docker-compose up
# 2. Shell into container
docker-compose exec myapp bash
# 3. Run Rails console from container shell
rails console
The last two can be combined as follows:
docker-compose exec myapp rails console
Starting a Rails server requires a little more effort to get it into one step, the following may not work:
docker-compose exec myapp rails server
This is because if the ./tmp/server.pid
file still exists from last time you ran a Rails server in the container, you'll get a server is already running
error. To solve this, let's introduce a script that first removes this file, then runs the Rails server, then use docker-compose to execute this script.
Place the following in scripts/run_dev.sh
(I'm assuming you have a directory scripts
in the root of your project, if not, create it):
#!/bin/bash
cd /usr/share/myapp
rm -f ./tmp/pids/server.pid
rails server
Make it executable with chmod +x scripts/run_dev.sh
. Now you can start a Rails server by passing the -c
flag to the bash
command, which tells it to execute the script in the container:
docker-compose exec myapp bash -c "./scripts/run_dev.sh"
Even Less Typing
Combining commands with exec
and -c
flag saves some typing, but we can do even better. Let's introduce a Makefile into the project. A Makefile is a text file containing a set of instructions (rules) that specify how to build the project. The rules specify the dependencies and the commands needed to build or update them. Traditionally used for C/C++ projects, a Makefile can also make life more convenient by reducing the amount of typing needed to run commonly used commands on any project.
For example, in the previous section, we saw that to run a Rails server within the Docker container required:
docker-compose exec myapp bash -c "./scripts/run_dev.sh"
If you were to add the following Makefile
in the project root:
server:
docker-compose exec myapp bash -c "./run_dev.sh"
Then you could run this at the terminal to start the Rails server:
make server
There's still a problem though, if you don't remember to first start the container, running make server
will result in an error like this:
service "myapp" is not running container #1
This can be solved with task dependencies. We can add a start
task that will check if the container isn't already running, then start it, and then have the server
task depend on the start
task:
# Start the container if it isn't already running
start:
./scripts/start_dev_container.sh
# Run the `start` task before the `server` task
server: start
docker-compose exec myapp bash -c "./run_dev.sh"
Where the start_dev_container.sh
script attempts to run an echo statement in the container, and then inspects the return code. If the container is not started, this would error and the return code will be non-zero. If we get a non-zero return code, then we start the container in the background. Place the following in the scripts
directory of your project and run chmod +x scripts/start_dev_container.sh
to make it executable:
#!/bin/bash
docker-compose exec myapp echo "dev container is running"
ret=$?
[[ $ret -ne 0 ]] && docker-compose up -d
exit 0
You can add the most common Rails commands to the Makefile, making each dependent on the start
task. Here's what I use, feel free to add more as per your projects needs:
# Start the dev container if it's not already running.
start:
./scripts/start_dev_container.sh
# Stop the dev container.
stop:
docker-compose stop
# Display dev container status.
status:
docker-compose ps
# Start a Rails server.
server: start
docker-compose exec myapp bash -c "./run_dev.sh"
# Launch a shell in container.
shell: start
docker-compose exec myapp bash
# Launch a Rails console.
console: start
docker-compose exec myapp rails c
# Migrate database.
migrate_db: start
docker-compose exec myapp rake db:migrate
# Rollback database.
rollback_db: start
docker-compose exec myapp rake db:rollback
# Drop db, create db, load schema, custom seeds.
reset_db: start
docker-compose exec myapp rake db:reset
# Run all the RSpec tests: make test
# To run a specific test: SPECS="path/to/the_spec.rb" make test
test: start
docker-compose exec myapp rspec $$SPECS
# Run the linter.
rubocop: start
docker-compose exec myapp rubocop
# Display all available routes.
routes: start
docker-compose exec myapp rake routes
Drawbacks
There are some drawbacks with this approach. There's some increased complexity in having to learn new tooling for those not already familiar with Docker and docker-compose. Also the commands are slower to run within the container as compared to running directly on the laptop.
Conclusion
This post has covered how to use Docker and docker-compose to run an old Ruby on Rails project that doesn't easily install on the newer ARM-based Macs. We've learned how to build an image, run a container from this image, and use exec
to run commands in the container. Finally we covered how to use a Makefile with dependencies to save on some typing of lengthy commands.