Dockerize Your Project, Part 2. Rails and PostgreSQL

In this tutorial we will create a docker image with a Rails application, push it to the private Docker Registry(that we set up in Part 1), and then how to run it together with a PostgreSQL image, wrapping everything in docker-compose.yml.

Base image

When there are more than one Ruby application in the stack, it make sense to create a common base image.

$ mkdir myproject-baseimage
$ cd myproject-baseimage

I picked a phusion/baseimage:0.9.18 as a base for our new image because it's ligthweight and Ubuntu inside is prepared to run as a docker container. We are going to add:

  • ruby 2.3
  • bundler
  • js-runtime(nodejs)

Open your favorite text editor and create the Dockerfile with the following content:

FROM phusion/baseimage:0.9.18

RUN apt-get -qy update && apt-get -qy install unzip git-core build-essential libsqlite3-dev libpq-dev nodejs

ADD build /tmp/build
RUN /tmp/build/ruby-install.sh
RUN /tmp/build/gems-install.sh
RUN rm -Rf /tmp/build

ENTRYPOINT ["/sbin/my_init"]
CMD ["/bin/bash"]

In this file we set up essential libs, then execute two scripts

  • build/ruby-install.sh
  • build/gems-install.sh

With the following content:

$ mkdir build
$ cat build/ruby-install.sh
#!/bin/bash

apt-add-repository ppa:brightbox/ruby-ng
apt-get -qy update
apt-get install -qy ruby2.3 ruby2.3-dev
$ cat build/gems-install.sh
#!/bin/bash

gem install rake bundler --no-rdoc --no-ri

And make them executable:

$ chmod 755 build/ruby-install.sh
$ chmod 755 build/gems-install.sh

Final structure:

Base image structure

We can now start the build:

$ docker build -t myproject/baseimage .

And push it to the registry:

$ docker tag myproject/baseimage registry.myproject.com/myproject/baseimage
$ docker push registry.myproject.com/myproject/baseimage

You can then check the registry catalog and image tags:

$ curl https://registry.myproject.com/v2/_catalog
{"repositories":["myproject/baseimage"]}
$ curl https://registry.myproject.com/v2/myproject/baseimage/tags/list
{"name":"myproject/baseimage","tags":["latest"]}

Since during the build we didn't tag it with any specific version, by default docker sets the version latest.

Rails application dockerization

Create a new project containing the docker build instructions for the apps, I will call it myproject-buildconfigs. This project will later be used by Jenkins. Then create a directory for our first Rails app inside:

$ mkdir myproject-buildconfigs
$ mkdir myproject-buildconfigs/myproject-app1
$ cd myproject-buildconfigs/myproject-app1

Initialize the Dockerfile inside with the following content:

FROM registry.myproject.com/myproject/baseimage:latest

ADD app /app
ADD scripts/ /
ADD etc /etc

RUN chmod u+x /start /setup
RUN /sbin/my_init /setup

ENV RAILS_ENV production

EXPOSE 3001

ENTRYPOINT ["/sbin/my_init"]
CMD ["/start"]
  • app is the application space
  • scripts/setup will perform bundle install and rake assets:precompile
  • scripts/start will create and migrate the database, then start the app in production environment
  • etc/myproject-app1/config-unicorn.rb will contain a unicorn config

Let's add these files:

$ mkdir app scripts etc etc/myproject-app1
$ cat scripts/setup
#!/bin/bash

cd /app && \
    bundle install --deployment --without development test && \
    SECRET_KEY_BASE=abc DATABASE_URL=sqlite3:tmp/tmp.db RAILS_ENV=production bundle exec rake assets:precompile
$ cat scrips/start
#!/bin/bash

cd /app
bundle exec rake db:create &&\
bundle exec rake db:migrate &&\
bundle exec unicorn -N -p 3001 -c /etc/myproject-app1/config_unicorn.rb
$ cat etc/myproject-app1/config-unicorn.rb
worker_processes Integer(ENV["unicorn_worker_count"] || 1)
timeout Integer(ENV["unicorn_worker_timeout"] || 30)

stdout_logger = ::Logger.new($stdout)
$stdout.sync = true
stdout_logger.level = ::Logger::INFO
stdout_logger.formatter = lambda do |severity, _time, _prog, message|
  "#{severity.to_s[0]}, #{message.gsub("\n", "↲")}\n"
end
logger stdout_logger

# preload_app true

before_fork do |_server, _worker|
  Signal.trap "TERM" do
    puts "Unicorn master intercepting TERM and sending myself QUIT instead"
    Process.kill "QUIT", Process.pid
  end

  ActiveRecord::Base.connection.disconnect! if defined?(ActiveRecord::Base)
end

after_fork do |_server, _worker|
  Signal.trap "TERM" do
    puts "Unicorn worker intercepting TERM and doing nothing. Wait for master to send QUIT"
  end

  ActiveRecord::Base.establish_connection if defined?(ActiveRecord::Base)
end

Final structure:

App build structure

In the next article I'll describe how to use this build configuration with Jenkins to automate dockerization of the new releases, for now let's try to build it manually and understand the process. Clone your project into the app folder, start the build and push it to the registry:

$ git clone *repo* -b *branch* --single-branch app
$ docker build -t myproject-apps/myproject-app1 .
$ docker tag myproject-apps/myproject-app1 registry.myproject.com/myproject-apps/myproject-app1
$ docker push registry.myproject.com/myproject-apps/myproject-app1

And check the catalog afterwards:

$ curl https://registry.myproject.com/v2/_catalog
{"repositories":["myproject/baseimage","myproject-apps/myproject-app1"]}

Great, we've just created the first dockerized release of our Rails application! Now the final step is to make sure the image works properly. I prefer to use postrges as database, so we would need postgres image as well.

Getting everything up and running

Since we need to start more than one container, let's create a docker-compose project. I'll call it myproject-testenv. There is an official postgres image in docker hub so let's pick it. We would also need a directory to store postgres data:

$ mkdir myproject-testenv
$ cd myproject-testenv
$ mkdir data_pg

Next, create a docker-compose.yml with the following content:

myproject-app1:
  image: registry.myproject.com/myproject-apps/myproject-app1
  ports:
    - 3001:3001
  links:
    - postgresql:postgresql
  environment:
    SECRET_KEY_BASE: "MyProjectSecretKeyBase"
    DATABASE_URL: "postgresql://pguser:pgpass@postgresql/pgtable"
postgresql:
  image: postgres:9.5
  environment:
    POSTGRES_USER: "pguser"
    POSTGRES_PASSWORD: "pgpass"
    POSTGRES_DB: "pgtable"
  volumes:
    - ./data_pg:/var/lib/postgresql/data
  ports:
    - 5432:5432

Our unicorn starts the app on the port 3001, so we're exposing it. app1 will be able to discover postgres container with a help of linking. As environment variables we set required for Rails SECRET_KEY_BASE and database location with DATABASE_URL. For postgres container, we're setting username and password pair, database name and specifying the data_pg folder on the host machine as a volume. In the end we're exposing port 5432, so database would be available on the host.

Now we can start containers with docker-compose up -d. Let's check if everything is running:

$ docker-compose ps
                 Name                               Command               State            Ports
---------------------------------------------------------------------------------------------------------
myprojecttestenv_myproject-app1_1          /sbin/my_init /start             Up       0.0.0.0:3001->3001/tcp
myprojecttestenv_postgresql_1              /docker-entrypoint.sh postgres   Up       0.0.0.0:5432->5432/tcp
$ docker-compose logs
...
$ curl localhost:3001
...

Final notes

I prefer to develop inside a vagrant box and it could be convinient to create a configured box with docker installed and ports on which apps are running exposed to the host. Then you can share this box and myproject-testenv with other developers. Say, you want to make changes in myproject-app1, the development flow should look like this:

  1. Start all the containers inside the box with docker-compose up -d
  2. Shut myproject-app1 down with docker-compose stop myproject-app1
  3. Link myproject-app1 folder on your host machine to the vagrant box
  4. Start myproject-app1 inside the box with bundle exec rails s -p 3001 or bundle exec unicorn -p 3001

Now the application stack is running inside of a vagrant box and you should see all the changes you make on your host on the exposed port 3001. The advantages of this approach are:

  • consistent and reproducible development environment within the team
  • you don't need anything except for git and code editor on your host machine

You can find basic Vagrant box config running Ubuntu 14.04 with Ruby and Docker in my repository.

Dockerize Your Project, Part 3. Continuous Rails builds