A More Flexible Dockerfile for Rails
One of my primary motivations for working with Docker was creating a single artifact that I could toss into any environment. It has been fantastic at this. I can throw together a simple Dockerfile that will build my Rails application as an image for production in about five minutes.
FROM ruby:2.3-alpine
ADD Gemfile* /app/
RUN apk add --no-cache --virtual .build-deps build-base \
&& apk add --no-cache postgresql-dev tzdata \
&& cd /app; bundle install --without test production \
&& apk del .build-deps
ADD . /app
RUN chown -R nobody:nogroup /app
USER nobody
ENV RAILS_ENV production
WORKDIR /app
CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0", "-p", "8080"]
Except now that when I need to run the application’s test suite, I do not have the dependencies I need. That Dockerfile might look something like this.
FROM ruby:2.3-alpine
RUN apk add --no-cache build-base postgresql-dev tzdata
ADD Gemfile* /app/
RUN cd /app; bundle install
ADD . /app
RUN chown -R nobody:nogroup /app
USER nobody
WORKDIR /app
CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0", "-p", "8080"]
Many people decide to include both of these Dockerfiles in their repository as Dockerfile and Dockerfile.dev. This works perfectly fine. But now we have a production Dockerfile that never gets used during development. Of course, it is going through at least one staging environment (hopefully) but it would be nice if we had a little more testing against it.
Much like Docker provides us the ability to have a single artifact to move from system to system, I wanted to have a single Dockerfile shared between all environments. Luckily, Docker provides us with build arguments. A build argument allows us to specify a variable when building the image and then use that variable inside our Dockerfile.
In our current Rails Dockerfile, we have two primary differences between our environments:
- The gem groups that are installed
- The environment that the application runs as
Bundler’s BUNDLE_WITHOUTBUNDLE_WITHOUT allows us to specify the gem groups to skip via an environment variable making both of these resolvable through environment configuration. Using this, our shared Dockerfile could look like this:
FROM ruby:2.3-alpine
ARG BUNDLE_WITHOUT=test:development
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ADD Gemfile* /app/
RUN apk add --no-cache --virtual .build-deps build-base \
&& apk add --no-cache postgresql-dev tzdata \
&& cd /app; bundle install \
&& apk del .build-deps
ADD . /app
RUN chown -R nobody:nogroup /app
USER nobody
ARG RAILS_ENV=production
ENV RAILS_ENV ${RAILS_ENV}
WORKDIR /app
CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0", "-p", "8080"]
The secret sauce here is ARG BUNDLE_WITHOUT=test:development
. Running
docker build -t rails-app .
will use the default value provided for the
BUNDLE_WITHOUT
build argument, test:development, and a production Docker image
will be built. And if we specify the appropriate build arguments, we can
generate an image suitable for development.
docker build -t rails-app --build-arg BUNDLE_WITHOUT= --build-arg RAILS_ENV=development .
will generate our Docker image with all test and development dependencies available. Typing this for building in development would get pretty tedious so we can use docker-compose to make it easier
version: '2'
services:
app:
build:
context: .
args:
- BUNDLE_WITHOUT=
- RAILS_ENV=development
links:
- database
ports:
- "3000:8080"
env_file:
- .env
volumes:
- .:/app
tty: true
stdin_open: true
Now, docker-compose up -d
is all we need in development to both build and
launch our development image.
Finally, we have a single Dockerfile that can be used to build an image for any of our application’s needs. Of course, there are some trade-offs. For example, build time in development will suffer in some cases. But I have found only maintaining a single Dockerfile to be worth these costs.
Have another way to deal with this issue? Please share!