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:
- One docker container for each of my apps.
- One database container.
- 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.
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):