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: *** [link_a.darwin] Error 1 make: *** [do_darwin-shared] Error 2 make: *** [libcrypto.1.0.0.dylib] Error 2 make: *** [shared] Error 2 make: *** [build_crypto] Error 1
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.
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
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:
To run a container from the image, run:
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
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.
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
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"
Combining commands with
-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:
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 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"
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
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.
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.