Did you dockerize your Ruby on Rails application already? You definitely should! Read on to learn why and how.
Shipping software is a challenge. Endless installation instructions explain in detail how to install and configure an application as well as all its dependencies. But in practice, following installation instructions ends with frustration: the required version of Ruby is not available to install from the repository, the configuration file is located in another directory, the installation instructions do not cover the operating system you need or want to use, etc.
And it gets worse: to be able to scale on-demand and recover from failure, we need to automate the installation and configuration of our application and its runtime environment. Implementing the required automation with the wrong tools is very time-consuming and error-prone.
But what if you could bundle your application with all its dependencies and run it on any machine: your MacBook, your Windows PC, your test server, your on-premises server, and your cloud infrastructure? That's what Docker is all about.
In short: Docker is a toolkit to deliver software.
This blog post is an excerpt from our book Rapid Docker on AWS.
The most important part of the Docker toolkit is the container. A container is an isolated runtime environment preventing an application from accessing resources from other applications running on the same operating system. The concept of a jail - later called a container - had been around on UNIX systems for years. Docker uses the same ideas but makes them a lot easier to use.
With Docker containers, the differences between different platforms like your developer machine, your test system, and your production system are hidden under an abstraction layer. But how do you distribute your application with all its dependencies to multiple platforms? By creating a Docker image. A Docker image is similar to a virtual machine image, such as an Amazon Machine Image (AMI) that is used to launch an EC2 instance. The Docker image contains an operating system, the runtime environment, 3rd party libraries, and your application. The following figure illustrates how you can fetch an image and start a container on any platform.
But how do you create a Docker image for your web application? By creating a script that builds the image step by step: a so-called Dockerfile
.
Next, you will learn how to dockerize a typical Ruby on Rails application.
The project structure of a typical Ruby on Rails project looks like this:
├── Gemfile
├── README.md
├── Rakefile
├── app
├── babel.config.js
├── bin
├── config
├── config.ru
├── db
├── lib
├── log
├── package.json
├── postcss.config.js
├── public
├── storage
├── test
├── tmp
├── vendor
└── yarn.lock
How to bundle an application with the described project structure? Let's have a look at the Dockerfile
:
- Based on Amazon Linux 2.
- Installs Node.js, required by yarn, required by Ruby on Rails.
- Installs Ruby and Ruby on Rails with all needed dependencies.
- Installs wait-for-it - a helper to make sure that the MySQL database is up and running before the Ruby container is started with
docker-compose
. - Copies all files except the ignores defined in the file
.dockerignore
. - Installs all Ruby dependencies of the application and generates the static assets.
- Configured a custom entry point that runs the database migrations when the container starts before the application is started.
- Exposes port 3000 and defines the default command to run the application.
FROM amazonlinux:2.0.20190508
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Install Node.js (needed for yarn)
RUN curl -sL https://rpm.nodesource.com/setup_10.x | bash -
RUN yum -y install nodejs gcc-c++ make
# Install Ruby & Rails
RUN curl -sL -o /etc/yum.repos.d/yarn.repo https://dl.yarnpkg.com/rpm/yarn.repo
RUN amazon-linux-extras enable ruby2.6 \
&& yum -y install git tar gzip yarn zlib-devel sqlite-devel mariadb-devel ruby-devel rubygems-devel rubygem-bundler rubygem-io-console rubygem-irb rubygem-json rubygem-minitest rubygem-net-http-persistent rubygem-net-telnet rubygem-power_assert rubygem-rake rubygem-test-unit rubygem-thor rubygem-xmlrpc \
&& gem install rails
# Install wait-for-it
COPY docker/wait-for-it.sh /usr/local/bin/
RUN chmod u+x /usr/local/bin/wait-for-it.sh
# Copy Ruby files (see .dockerignore)
COPY . .
# Install Ruby dependencies
ENV RAILS_ENV production
ENV RAILS_LOG_TO_STDOUT enabled
ENV RAILS_SERVE_STATIC_FILES enabled
RUN bin/bundle install --deployment --without development test
# see https://github.com/rails/rails/issues/32947 for SECRET_KEY_BASE workaround
RUN SECRET_KEY_BASE=dummy bin/rails assets:precompile
# Configure custom entrypoint to run migrations
COPY docker/custom-entrypoint /usr/local/bin/
RUN chmod u+x /usr/local/bin/custom-entrypoint
ENTRYPOINT ["custom-entrypoint"]
# Expose port 3000 and start Rails server
EXPOSE 3000
CMD ["bin/rails", "server", "--binding=0.0.0.0"]
To limit the amount of data that needs to be sent to Docker, the .dockeringore
file defines an exclude list for files and directories that you typically do not need to include in the Docker image.
aws/*
log/*
storage/*
tmp/*
public/assets/*
public/packs/*
It is a best practice when dockerizing applications to use environment variables instead of configuration files. Do not use files to store the configuration for your application. Use environment variables instead. Luckily, Ruby on Rails comes with it's own configuration mechanism that supports environment variables out of the box. Check out the file config/database.rb
to see how environment variables are used to configure the database connection.
# ...
production:
<<: *default
host: <%= ENV['DATABASE_HOST'] %>
database: <%= ENV['DATABASE_NAME'] %>
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
One more thing: it is necessary to run the database migration each time you roll out a new version of your application. The easiest way to do so is to execute db:migrate
each time you start the Docker container. To do so, the Dockerfile
adds a so-called ENTRYPOINT
which references the shell script custom-entrypoint
. Each time you start the Docker container, the entry point script gets executed as well.
The custom-entrypoint
script:
- Waits until it is possible to establish a database connection.
- Executes the database migration by calling
db:migrate
. - Starts the
puma
web server.
#!/bin/bash
set -e
if [ -n "${WAIT_FOR_IT}" ]; then
wait-for-it.sh mysql:3306
fi
echo "running migrations"
bin/rails db:migrate
echo "starting $@"
exec "$@"
That's it! You are ready to build a Docker image bundling your Ruby on Rails application.
docker build -t myapp:latest .
Start a container based on the image ...
docker run -p 3000:3000 myapp:latest
... and open http://localhost:3000.
You have successfully dockerized your Ruby on Rails application. What is next?
- Push the Docker image into a private registry (e.g., Amazon ECR).
- Deploy your application in the cloud (e.g., with ECS and Fargate).
Want to learn more about how to deploy your application on AWS? Check out our new book and online seminar Rapid Docker on AWS. Production-ready infrastructure templates and deployment pipeline included.
Top comments (1)
Thank you for this walk-through! I especially appreciate the creation of a dockerfile basing off of amazon linux image.
In the
custom-entrypoint
section, you mentioned it starts the puma web server. However, I didn't see a command for puma. Was it passed in as an argument to the custom entrypoint script?Also, curious what your puma.rb looks like. Was any modifications made to this file to get things to work?