How to docker-compose for Rails development: Don't bundle install in Dockerfile
First published in 2019, updated in 2021.
For past 12 months, we have been containerizing Rails apps for our projects, because we needed certain binaries like GnuPG 1, and Docker Swarm’s container scheduling for the high availability and scalability offered. To close the dev-prod parity, we develop our app inside Docker container using Docker Compose, and the Dockerfile we use share the same base image and system binaries as the one used for production.
It wasn’t easy starting out here. There are 2 problems:
- Not using separate Dockerfiles for development and production.
bundle install
inside Dockerfile for development.
Problem with gems installed in the image
Each instruction in a Dockerfile makes up an read-only immutable layer. For demonstration, let’s create a huge junk file, add it, and remove it in Dockerfile.
Create a 128MB junk file of random content in terminal:
dd if=/dev/urandom of=junk bs=1M count=128
Add and delete the junk file in Dockerfile:
FROM alpine
COPY junk .
RUN rm junk
After that, build the Docker image in terminal:
docker build -t rm-junk .
docker image ls
You will get:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
rm-junk latest 7caa8295c677 6 seconds ago 139MB
In this example, the Dockerfile consists of 3 instructions. Since the layers built are immutable, the 3rd instruction containing rm
command will not be able to delete the junk
file in 2nd layer, even when you go docker run -it rm-junk sh
and you can’t find the junk
file inside.
Each layer also checks against the local cache when being built. From the sample Dockerfile above, if the junk
file never changes, RUN rm junk
will not be run again:
$ docker build -t rm-junk .
Sending build context to Docker daemon 134.2MB
Step 1/3 : FROM alpine
---> 3f53bb00af94
Step 2/3 : COPY newfile .
---> Using cache
---> a541cb67c5be
Step 3/3 : RUN rm newfile
---> Using cache
---> c507dcbff10f
Successfully built c507dcbff10f
Successfully tagged rm-junk:latest
However, if there is even a tiny change in previous layers, all the subsequent layers will have the cache invalidated and will be run. That means, if you have a patch bump in Gemfile.lock
, your entire bundle install
and subsequent instructions will be run from scratch, costing you more space, time, and bandwidth.
If your project is under active development, everytime a single change is made in Gemfile/Gemfile.lock, whether adding a gem or bumping version to patch vulnerable gems, you will have to rebuild the Docker image and run bundle install
all over again.
And if you don’t clear out the old image layers often enough, you will find your machine quickly running out of disk space soon. For developers in countries with terrible Internet access, this is a serious pain.
Wouldn’t it be great to just download the added gem or npm package, instead of 90%+ of all other unchanged stuffs again, just like how they work outside of Docker?
Use Docker volumes to store dependencies
If you use Docker Compose for development, and Docker Swarm for deployment, just make sure you have 2 separate sets of Dockerfiles and docker-compose.yml. To make life simple, we use Dockerfile.dev
and docker-compose.yml
for development, and a Dockerfile.prod
for production.
Note: I only append all Dockerfiles here with the environment for clarity’s sake. You are free to let either development or production’s Dockerfile take the vanilla, tailless Dockerfile
name.
# docker-compose.yml
version: '3'
services:
postgres:
image: postgres
ports:
- 5432
volumes:
- postgres:/var/lib/postgresql/data
web:
env_file:
- .env.dev
build:
context: .
dockerfile: Dockerfile.dev
command: bundle exec rails s -p 3000 -b '0.0.0.0'
volumes:
- .:/myapp
- bundler_gems:/usr/local/bundle/
ports:
- "3000:3000"
links:
- postgres
- redis
redis:
image: redis
ports:
- 6379
volumes:
- redis:/data
resque:
build:
context: .
dockerfile: Dockerfile.dev
command: bundle exec rake resque:work
volumes:
- .:/myapp
- bundler_gems:/usr/local/bundle/
links:
- postgres
- redis
volumes:
postgres:
redis:
bundler_gems:
You can then have your Dockerfile.dev
sans bundle install
:
# Dockerfile.dev
FROM ruby:2.6-alpine as builder
RUN apk add --no-cache \
build-base \
busybox \
ca-certificates \
curl \
git \
gnupg1 \
graphicsmagick \
libffi-dev \
libsodium-dev \
nodejs \
openssh-client \
postgresql-dev \
rsync \
yarn
RUN mkdir -p /app
WORKDIR /app
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
Now you will need to run bundle install in a separate step to get the gems:
docker-compose build
docker-compose run --rm web bundle install -j8
docker-compose run --rm web bundle exec rails db:setup
After this, you can just run the 2nd command in the listing above whenever there are changes in Gemfile/Gemfile.lock.
Passing your SSH key in as secret for private gems
Feel free to skip this section if this is irrelevant to you.
Your Rails app may have some private gems hosted on GitHub, Bitbucket, Gitlab. To pull those, you may either:
- pass in your credentials thru HTTP basic authentication,
- authenticate with SSH keys – we will be doing this because it’s safer.
It is very possible to do these without leaving any secrets or credentials in your repository and your Docker images. There are plenty of ways to go about this, personally, this is how I roll:
- Make a
.secrets
directory, put it in.gitignore
and.dockerignore
. cp ~/ssh/id_rsa .secrets/host_ssh_key
- we can’t justln -s
it in because of pesky permission issues.chmod 600 .secrets/host_ssh_key
to fix permission.- Declare the secret in
docker-compose.yml
. - Add a step to symlink your secret SSH key in your
Dockerfile.dev
.
Add this part in your Dockerfile.dev
:
RUN mkdir ~/.ssh \
&& ln -s /run/secrets/host_ssh_key ~/.ssh/id_rsa \
&& echo $'\
github.com,192.30.255.112,192.30.255.113 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== \n\
bitbucket.org,104.192.143.1,104.192.143.2,104.192.143.3 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kNvEcqTKl/VqLat/MaB33pZy0y3rJZtnqwR2qOOvbwKZYKiEO1O6VqNEBxKvJJelCq0dTXWT5pbO2gDXC6h6QDXCaHo6pOHGPUy+YBaGQRGuSusMEASYiWunYN0vCAI8QaXnWMXNMdFP3jHAJH0eDsoiGnLPBlBp4TNm6rYI74nMzgz3B9IikW4WVK+dc8KZJZWYjAuORU3jc1c/NPskD2ASinf8v3xnfXeukU0sJ5N6m5E8VLjObPEO+mN2t/FZTMZLiFqPWc/ALSqnMnnhwrNi2rbfg/rd/IpL8Le3pSBne8+seeFVBoGqzHM9yXw== \n\
' >> ~/.ssh/known_hosts
And then amend the docker-compose.yml
:
--- docker-compose.before.yml 2019-03-10 22:24:50.494973458 +0800
+++ docker-compose.after.yml 2019-03-10 22:25:33.129197452 +0800
@@ -14,6 +14,8 @@
context: .
dockerfile: Dockerfile.dev
command: bundle exec rails s -p 3000 -b '0.0.0.0'
+ secrets:
+ - source: host_ssh_key
volumes:
- .:/myapp
- bundler_gems:/usr/local/bundle/
@@ -40,6 +42,11 @@
- postgres
- redis
+secrets:
+ host_ssh_key:
+ file: ./local-secrets/host_ssh_key
+
+
volumes:
postgres:
redis:
You should be able to bundle install the gems from private repos now.
If you want to pass SSH key in when building Docker image for production, read Dockerfile for Rails app in production and other lessons learned.