Loading...

Blog

Latest blog posts

Docker with Ruby on Rails: development and production

After reading quite a lot about Docker I wanted to Dockerize my RoR apps, using Docker in both development and production environment. However I did not find THE PLACE where I could find all the information needed. I had to read from many places and do a lot of try-fail tests. I'll write here what is the procedure that I use right now. Maybe its not the best, but its the best I could. Be aware that this is not for learning Docker. You should have knowledge on how docker works.

System Architecture

In production I have a VPS with different Ruby on Rails apps. I want them to be easy to move or duplicate (ex: create a staging version of an app) and I want the cleanest solution possible. The architecture will be the following: For each app I'll have:

  1. One docker container for each of my apps.
  2. One database container.
  3. One nginx container that will serve static files or proxy to rails.

In addition I'll create one nginx container that will proxy to the specific containers depending on the domain used.

Docker Production System architecturee

Decisions made

I've decided that each app will have its own database container, and nginx container. This will create a really clean solution, although it may come with an efficiency cost.

Changes done to the existing rails app

In order to dockerize my apps I've done the following changes

Database

I will use the same database configuration (including name) in all the instance (development, production). As we are using a container for each app, if we have two different instances of the same application they will use a different containers, so there will be no problems there. The database.yml is left as follows:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: 5
  database: app_database
  host: db
  user: postgres
  password:

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default

Using Puma

I used to use Passenger in production. However I've migrated to Puma now that I'm Dockerizing the app. Just create the following conf/puma.rb file. We will run puma in our Dockers.

workers Integer(ENV['WEB_CONCURRENCY'] || 2)
threads_count = Integer(ENV['MAX_THREADS'] || 5)
threads threads_count, threads_count

preload_app!

rackup      DefaultRackup
port        ENV['PORT']     || 3000
environment ENV['RACK_ENV'] || 'development'

#on_worker_boot do
#  # Worker specific setup for Rails 4.1+
#  # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
#  ActiveRecord::Base.establish_connection
#end

on_worker_boot do
  # Valid on Rails up to 4.1 the initializer method of setting `pool` size
  ActiveSupport.on_load(:active_record) do
    config = ActiveRecord::Base.configurations[Rails.env] ||
                Rails.application.config.database_configuration[Rails.env]
    config['pool'] = ENV['MAX_THREADS'] || 5
    ActiveRecord::Base.establish_connection(config)
  end
end

You will need to add also the puma gem to your gemfile

gem 'puma'

The dockerfile

We will have two docker files. One for development (Dockerfile) and one for production (Dockerfile.prod).

Dockerfile

FROM ruby:2.2.3
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs && rm -rf /var/lib/apt/lists/*

# App specific installations are run separatelly so previous is a rehused container
RUN apt-get install -y imagemagick && rm -rf /var/lib/apt/lists/*

ENV INSTALL_PATH /app
RUN mkdir -p $INSTALL_PATH
WORKDIR $INSTALL_PATH

COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install --jobs 20 --retry 5
COPY . ./

Dockerfile.prod

FROM ruby:2.2.3
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs && rm -rf /var/lib/apt/lists/*

# App specific installations are run separatelly so previous is a rehused container
RUN apt-get install -y imagemagick && rm -rf /var/lib/apt/lists/*

ENV INSTALL_PATH /app
RUN mkdir -p $INSTALL_PATH
WORKDIR $INSTALL_PATH

COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install --jobs 20 --retry 5 --without development test

# Set Rails to run in production
ENV RAILS_ENV production 
ENV RACK_ENV production

COPY . ./

# Precompile Rails assets. We set a dummy secret key
RUN bundle exec rake SECRET_KEY_BASE=pickasecuretoken  assets:precompile

The differences are:

  • In production we skip development and test gems
  • We set the environment to production
  • Assets are pre-compiled

The docker-compose

We will use a general docker-compose.yml file, a docker-compose.override.yml for development specifics, and a docker-compose.production.yml for production specifics.

docker-compose.yml

version: '2'
services:
  db:
    image: postgres
    ports:
    - "5432"
  app:
    command: bundle exec puma -C config/puma.rb
    ports:
      - "3000"
    depends_on:
      - db
  nginx:
    image: nginx
    volumes_from:
      - app
    depends_on:
      - app
  db_backup:
    image: tritoone/postgres_backup:0.1
    depends_on:
      - db
    volumes:
      - ./backup:/backup
    environment:
  #    - PGUSER=postgres
  #    - PGPASSWORD=
  #    - PGPORT=5432
  #    - PGHOST=db
      - PGDATABASE=app_database

docker-compose.override.yml

version: '2'
services:
  app:
    build: .
    volumes:
      - .:/app
      - ./public/system/:/app/public/system/
    ports:
      - 3000:3000
  nginx:
    volumes:
      - ./nginx.devel.conf:/etc/nginx/conf.d/default.conf
    ports:
      - 80:80
  db_backup:
    command: /bin/true

docker-compose.production.yml

version: '2'
services:
  app:
    image: MY_USER/MY_IMAGE:0.1
    volumes:
      - ./file_storage:/app/public/system/
      - assets:/app/public/assets/
    environment:
      - SECRET_KEY_BASE=COPY_YOUR_KEY_HERE
  nginx:
    ports:
      - 80
    volumes:
      - ./nginx.production.conf:/etc/nginx/conf.d/default.conf
    networks:
      default:
      nginx_default:
        aliases:
          - MY_APP_NAME
volumes:
  assets:
networks:
  nginx_default:
    external: true

Attention: You will need to change MYUSER/MYIMAGE:0.1 and COPYYOURKEYHERE and MYAPP_NAME for your values.

Comments

  • In development we use "build: ." so that the image is build, but in production we use the build image.
  • We are using paperclip, so we need to persist public/system/. In Development we link the system folder with the container system folder. In production we link it to a folder named file_storage. You may remove this lines if not using paperclip.
  • In production we create a volume for the assets so that they are accessible form the nginx.
  • In development we publish the ports on the host. In production we don't. The Main nginx (on nginx_default network) will be the one that will publish in port 80.
  • We have added a container dbbackup (image [tritoone/postgresbackup]). This container will be in charge of starting cron that backups the database in production. We have added it too in development (although it runs nothing) because it will help us to load a dump to our database.

Nginx configuration

The nginx configuration files defined in the docker-compose are:

nginx.devel.conf

upstream railsapp { 
  server app:3000; 
}

server {
  listen 80;
  location / {
    proxy_pass http://railsapp; 
  }
}

nginx.production.conf

upstream railsapp { 
  server app:3000; 
}

server {
  listen 80;

  root /app/public;

  try_files $uri @railsapp;

  location ^~ /assets/ {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
  }

  location @railsapp {
    proxy_pass http://railsapp; 
  }
}

In development we serve all the files throw rails, where in production, nginx will serve static files itself.

Let's start development dockers

In development you can check that everything is working starting the containers with

docker-compose up

You can now access to your app in localhost:3000 or localhost.

The Main nginx

In production we will need to have a main nginx that will proxy to the specific nginx depending on the location.

We are using the following nginx configuration file:

upstream myapp { 
  server MY_APP_NAME:80;
}

server {
  listen 80;
  server mydomain.com
  location /{
    proxy_pass http://myapp;
  }
}

and the following docker-compose.yml:

version: '2'
services:
  nginx:
    image: nginx
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    ports:
      - 80:80

Let's start production dockers

First of all we will need to build and publish the production image:

docker build -f Dockerfile.prod -t "MY_IMAGE:0.1" .
docker tag "MY_IMAGE:0.1" "MY_USER/MY_IMAGE:0.1"
docker push "MY_USER/MY_IMAGE:0.1"

In production place the docker-compose.yml, docker-compose.production.yml (name it docker-compose.override.yml), and nginx.production.conf. and run docker-compose up. You will need to start also the main nginx container.

That's all. You have now your app available in production too.

DB Backup

Using tritoone/postgres_backup image, a cron will be run each day and it will create a backup of the database.

You can restore the database from a backup with docker-compose exec dbbackup restore.sh PATHTO_BACKUP. Check tritoone/postgres_backup readme for more information.

Run as services

Finally we want to run the docker-compose as a service so that it is started on boot. We will use systemctl.

First we create the myapp.service (in /etc/systemd/system/myapp.service). Check that the paths of the docker-compose files are correct

[Unit]
Description=My app containers
After=docker.service
Requires=docker.service

[Service]
TimeoutStartSec=0
Restart=always
ExecStart=/usr/local/bin/docker-compose -f /home/user/my_app/docker-compose.yml -f /home/user/my_app/docker-compose.override.yml up
ExecStop=/usr/local/bin/docker-compose -f /home/user/my_app/docker-compose.yml -f /home/user/my_app/docker-compose.override.yml stop

[Install]
WantedBy=multi-user.target

We need to start the main nginx too. Lets create a service in /etc/systemd/system/nginx.service

[Unit]
Description=Main Nginx containers
After=docker.service
Requires=docker.service
After=my_app.service
Requires=my_app.service

[Service]
TimeoutStartSec=0
Restart=always
ExecStart=/usr/local/bin/docker-compose -f /home/user/nginx/docker-compose.yml up
ExecStop=/usr/local/bin/docker-compose -f /home/user/nginx/docker-compose.yml stop

[Install]
WantedBy=multi-user.target

Finally we need to enable the services and start them

sudo systemctl enable my_app
sudo systemctl enable nginx
sudo systemctl start nginx

You can also stop a service by

sudo systemctl stop my_app

Bibliography

This has been possible in part because of the following help (among other):