⚠️ Attention! This post is not about using Docker to develop Rails applications, but about using Docker to develop the Rails framework itself. For the former one, please, visit the Ruby on Whales article.
From boxes to containers, or a bit of history
I've been contributing to Rails (from time to time) since 2015. Developing such a massive framework as Rails very differs from working on a web application built with it. First of all, you need to cover all the possible configurations: databases, cache servers, etc.; many system dependencies (e.g., libxml2
or ffmpeg
) must be installed.
Secondly, unlike for a private project, where each team member has to deal with this setup, an open-source project should be...hm...open for everyone willing to contribute. The more complicated it is to configure a proper environment the more likely potential contributors would give up. And we don't want this, right?
Luckily, the Rails team (and Xavier Noria in particular) found a way to solve this problem—rails-dev-box. Rails Dev Box is a Vagrant configuration, which allows you to spin up a virtual machine with everything you need inside. Cool, right?
Yeah, that was really cool. In 2015.
I gave up on VM-based development in 2017, when I found that running a VM along with a couple of Electron-based apps no longer fit my laptop. I turned to containers.
Since then, I started using Docker not only for applications development but also for hacking around with Rails.
Since I mostly dealt with Active Record and Action Cable, my Docker configuration wasn't complete. Also, back in the days, the Rails codebase wasn't container-friendly (e.g., some tests relied on a Redis or PostgreSQL instance running on the same machine). Thus, I just kept my setup around (in a few commits], and haven't tried to promote to the upstream or whatever.
Lately, I've been working a new PR to Action Cable and had to re-visit my configuration (since many things have changed in the last year). I liked what I got in the end, so I decided to share it with the community.
Below is the compatibility table—which libraries are currently supported (i.e., rake test
passes):
- actioncable ✅
- actionmailbox ✅
- actionmailer ✅
- actionpack ✅
- actiontext ✅
- actionview ✅
- activejob ✅
- activemodel ✅
- activerecord:
-
rake test:sqlite3
✅ ⚠️:26761 assertions, 2 failures, 2 errors, 27 skips
(sqlite3: not found
) -
rake test:postgresql
✅ ⚠️:28766 assertions, 0 failures, 2 errors, 18 skips
(couldn't connect to /var/run/postgresql/.s.PGSQL.5432
) -
rake test:mysql2
🚫 (nomysql
database configured)
-
- activestorage ⚠️ (some system deps missing)
- activesupport ✅ ⚠️ (evented file checker tests fail 🤔)
- railties 🚫 (
No such file or directory - yarn
NOTE: JavaScript tests are not supported (no Node/Yarn env configured).
Docker, Compose, and Dip walks into a bar
It's all started with just two files: Dockerfile
and docker-compose.yml
. Although that was good enough to "build" a project and run tests, the overall developer experience was barely satisfying.
So, I went the old-fashioned way and added Dip to the mix.
Now I can run all the familiar commands (bundle
, rake
, etc.) from my host system (with a dip
prefix) without thinking about all the docker-compose --rm --it bla-bla
. Moreover, I can cd
into a subfolder (say, actioncable
), and execute commands from there just like on a host machine:
# Installing deps at the project's root level
dip bundle install
# Run all Rails tests (I never tried 🙂)
dip rake test
# That's what I usually do
cd actioncable
# Install Action Cable dev deps
dip bundle
# Run Action Cable tests
dip rake
# Or run a particular test file
dip test test/connection/streams_test.rb
The dip test
command is an alias for bundle exec ruby -Ilib:test
—and that's my favourite one ♥️
Want to give this setup a try? You can grab it right from this post (or from the gist!
Here is the configuration I keep at the project's root:
.dockerdev/
Aptfile
.bashrc
Dockerfile
compose.yml
# Active Record configs
config.yml
<some rails files>
dip.yml
And the contents of all the files:
-
.dockerdev/Dockerfile
ARG RUBY_VERSION
ARG DISTRO_NAME=bullseye
FROM ruby:$RUBY_VERSION-slim-$DISTRO_NAME
ARG DISTRO_NAME
# Common dependencies
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
build-essential \
gnupg2 \
curl \
less \
git \
&& apt-get clean \
&& rm -rf /tmp/* /var/tmp/* \
&& truncate -s 0 /var/log/*log
ARG PG_MAJOR
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
&& echo deb http://apt.postgresql.org/pub/repos/apt/ $DISTRO_NAME-pgdg main $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
libpq-dev \
postgresql-client-$PG_MAJOR && \
apt-get clean && \
rm -rf /tmp/* /var/tmp/* && \
truncate -s 0 /var/log/*log
# Application dependencies
# We use an external Aptfile for this, stay tuned
COPY Aptfile /tmp/Aptfile
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
$(grep -Ev '^\s*#' /tmp/Aptfile | xargs) && \
apt-get clean && \
rm -rf /tmp/* /var/tmp/* && \
truncate -s 0 /var/log/*log
ENV LANG C.UTF-8
ENV GEM_HOME /bundle
ENV BUNDLE_PATH=$GEM_HOME \
BUNDLE_APP_CONFIG=$BUNDLE_PATH \
BUNDLE_BIN=$BUNDLE_PATH/bin \
BUNDLE_JOBS=4 \
BUNDLE_RETRY=3
ENV PATH /app/bin:$BUNDLE_BIN:$PATH
ARG BUNDLER_VERSION
RUN gem update --system && \
gem install bundler
RUN mkdir -p /app
WORKDIR /app
CMD ["/usr/bin/bash"]
-
.dockerdev/Aptfile
vim
# Build tools
autoconf
libtool
libncurses5-dev
libxml2-dev
# ActiveRecord deps
libsqlite3-dev
default-libmysqlclient-dev
-
.dockerdev/compose.yml
x-app: &app
build:
context: .
args:
RUBY_VERSION: '3.0.2'
PG_MAJOR: '14'
image: rails-dev:7.1.0
tmpfs:
- /tmp
services:
runner:
<<: *app
stdin_open: true
tty: true
volumes:
- ..:/app:cached
- bundle:/bundle
- history:/usr/local/hist
- ./.psqlrc:/root/.psqlrc:ro
- ./.bashrc:/root/.bashrc:ro
environment:
REDIS_URL: redis://redis:6379/
DATABASE_URL: postgres://postgres:postgres@postgres/
HISTFILE: /usr/local/hist/.bash_history
XDG_DATA_HOME: /app/tmp/caches
EDITOR: vi
# Use PostgreSQL by default for AR tests
ARCONN: ${ARCONN:-postgresql}
working_dir: ${WORK_DIR:-/app}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:14
volumes:
- .psqlrc:/root/.psqlrc:ro
- postgres:/var/lib/postgresql/data
- history:/user/local/hist
environment:
PSQL_HISTFILE: /user/local/hist/.psql_history
POSTGRES_PASSWORD: postgres
# For createdb
PGPASSWORD: postgres
ports:
- 5432
healthcheck:
test: pg_isready -U postgres -h 127.0.0.1
interval: 5s
redis:
image: redis:6.2-alpine
volumes:
- redis:/data
ports:
- 6379
healthcheck:
test: redis-cli ping
interval: 1s
timeout: 3s
retries: 30
volumes:
history:
postgres:
redis:
bundle:
-
.dockerdev/.bashrc
(put your favorite Bash extensions there)
alias be="bundle exec"
-
dip.yml
version: '7.1'
environment:
WORK_DIR: /app/${DIP_WORK_DIR_REL_PATH}
compose:
files:
- .dockerdev/compose.yml
project_name: rails_dev
interaction:
# This command spins up a Rails container with the required dependencies (such as databases),
# and opens a terminal within it.
runner:
description: Open a Bash shell within a Rails container (with dependencies up)
service: runner
command: /bin/bash
# Run a Rails container without any dependent services (useful for non-Rails scripts)
bash:
description: Run an arbitrary script within a container (or open a shell without deps)
service: runner
command: /bin/bash
compose_run_options: [ no-deps ]
# A shortcut to run Bundler commands
bundle:
description: Run Bundler commands
service: runner
command: bundle
compose_run_options: [ no-deps ]
rake:
description: Run Rake commands
service: runner
command: bundle exec rake
ruby:
description: Run Ruby with Bundler activated
service: runner
command: bundle exec ruby
rubocop:
description: Run RuboCop
service: runner
command: bundle exec rubocop
compose_run_options: [ no-deps ]
test:
description: Run a single test file (an alias for ruby -Ilib:test)
service: runner
command: bundle exec ruby -Ilib:test
psql:
description: Run Postgres psql console
service: postgres
default_args: anycasts_dev
command: psql -h postgres -U postgres
createdb:
description: Create a PostgreSQL database
service: postgres
command: createdb -h postgres -U postgres
'redis-cli':
description: Run Redis console
service: redis
command: redis-cli -h redis
provision:
- dip compose down --volumes
- dip compose up -d postgres redis
- dip bundle install
- (test -f activerecord/test/config.yml) || (cp .dockerdev/config.yml activerecord/test/config.yml)
- dip createdb activerecord_unittest
- dip createdb activerecord_unittest2
Bonus: Git ignore without .gitignore
The final question: since we keep it in the project's directory, and this is not an official setup (at least, yet), we need to make sure we do not accidentally commit it to the repo. In other words, how to Git-ignore our configuration without updating the .gitignore
file? And the answer is—.git/info/exclude
. That's a specific, local, Git configuration file, which works similarly to .gitignore
. So, just open this file (say, code .git/info/exclude
) and drop the following lines:
# .git/info/exclude
dip.yml
.dockerdev/
That's it!
P.S. For hacking with Ruby (MRI), I also have a dockerized environment: ruby-dip.
Top comments (0)