Zaid Annas
Devsinc inc. 30/08/2018
The point is to setup rails production/development environment with docker to cut the initial setup time for deployment as well as local setting up for project.
- Should be feature complete.
- Should be easier to setup than regular local setup
- Should be easier to deploy than manual rails deployment
We will be using minimal container setup and will try to keep the container size as small as possible.
Create a folder name docker
in your project root directory, Now create two more directories inside it app
and web
.
-app_name
-app
-db
-config
-database.yml
...
-docker
-app
-DockerFile
-web
-DockerFile
-nginx.conf
-docker-compose.yml
The folder structure we have created is just to keep our files in modular way, you can keep it anywhere you want.
Put DockerFile of rails app inside app
folder.
# Using alpine image for small size
FROM ruby:2.4.2-alpine3.7
# Use virtual build-dependencies tag so we can remove these packages after bundle install
RUN apk update && apk add --update --no-cache --virtual build-dependency build-base ruby-dev mysql-dev postgresql-dev git sqlite-dev
# Set an environment variable where the Rails app is installed to inside of Docker image
ENV RAILS_ROOT /var/www/app_name
# make a new directory where our project will be copied
RUN mkdir -p $RAILS_ROOT
# Set working directory within container
WORKDIR $RAILS_ROOT
# Setting env up
ARG RAILS_ENV
ENV RAILS_ENV=$RAILS_ENV
ENV RAKE_ENV=$RAILS_ENV
ENV RACK_ENV=$RAILS_ENV
# Adding gems
COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock
# development/production differs in bundle install
RUN if [[ "$RAILS_ENV" == "production" ]]; then\
bundle install --jobs 20 --retry 5 --without development test;\
else bundle install --jobs 20 --retry 5; fi
# Remove build dependencies and install runtime dependencies
RUN apk del build-dependency
RUN apk add --update mariadb-client-libs postgresql-client postgresql-libs sqlite-libs nodejs tzdata
# Adding project files
COPY . .
RUN bundle exec rake assets:precompile
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
These configurations will install essential system requirements, copy your project to docker container, install gems , precompile your assets.
You can use the following puma.config
file for this setup
app_dir = File.expand_path("../..", __FILE__)
shared_dir = "#{app_dir}/tmp"
environment ENV["RACK_ENV"] || "development"
threads_count = Integer(ENV["MAX_THREADS"] || 1) # this is should be calculated so (web_concurrency * max_threads * num dynos) PLUS whatever other db threads will be used (by workers for example) is < than allowed heroku pg connections.
threads threads_count, threads_count
preload_app!
rackup DefaultRackup
if ENV["RACK_ENV"].nil? || ENV["RACK_ENV"] == "development" # don't need this for production just local
port ENV["PORT"] || 3000
else
# easier to debug if development is running in single process
workers Integer(ENV["WEB_CONCURRENCY"] || 1) # this should be upped in prod as it's using 3 for 1x dyno, 6 for 2x
# bind "ssl://127.0.0.1:#{ENV['SSL_PORT'] || 3001}?key=#{ENV['SSL_KEY']}&cert=#{ENV['SSL_CERT']}"
# Deamonize puma server to run in background
daemonize
# Set up puma to listen on unix socket location(instead of tcp)
#bind "unix://#{shared_dir}/sockets/rev1.sock?umask=0111"
port ENV["PORT"] || 3000
# Redirect puma logs(access and error) for this site to shared/logs dir
stdout_redirect "#{shared_dir}/logs/rev1.stdout.log", "#{shared_dir}/logs/rev1.stderr.log", true
# Set master PID and state locations
pidfile "#{shared_dir}/pids/rev1.pid"
state_path "#{shared_dir}/pids/rev1.state"
activate_control_app
end
before_fork do
require "puma_worker_killer"
unless Rails.env.development? || Rails.env.test?
PumaWorkerKiller.enable_rolling_restart(3 * 3600)
end
ActiveRecord::Base.connection_pool.disconnect!
end
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_restart do
Sidekiq.redis.shutdown(&:close)
end
# Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart
We need a reverse proxy, in our case the Nginx web server, to proxy requests to Puma
Put DockerFile of nginx inside web
folder
# Base image
FROM nginx:mainline-alpine
# Install dependencies
RUN apk update && apk add --update apache2-utils
# Using argument for conditional setup in conf file
ARG RAILS_ENV
ENV RAILS_ENV $RAILS_ENV
# establish where Nginx should look for files
ENV RAILS_ROOT /var/www/app_name
# Set our working directory inside the image
WORKDIR $RAILS_ROOT
# create log directory
RUN mkdir log
# copy over static assets
COPY public public/
# Copy Nginx config template
COPY docker/web/nginx.conf /tmp/docker.nginx
# substitute variable references in the Nginx config template for real values from the environment
# put the final config in its place
RUN envsubst '$RAILS_ROOT $RAILS_ENV' < /tmp/docker.nginx > /etc/nginx/conf.d/default.conf
EXPOSE 80
# Use the "exec" form of CMD so Nginx shuts down gracefully on SIGTERM (i.e. `docker stop`)
CMD [ "nginx", "-g", "daemon off;" ]
Put nginx.conf inside web
folder
upstream rails_app {
# if ( $RAILS_ENV = "production" ){
# server unix://$RAILS_ROOT/tmp/sockets/app_name.sock;
# else
server app:3000;
# }
}
server {
# Nginx should listen on port 80 for requests to localhost
listen 80 default_server;
listen [::]:80 default_server;
# define your domain
server_name app_name.com www.app_name.com;
# define the public application root
root $RAILS_ROOT/public;
index index.html;
# define where Nginx should write its logs
access_log $RAILS_ROOT/log/nginx.access.log;
error_log $RAILS_ROOT/log/nginx.error.log;
# deny requests for files that should never be accessed
location ~ /\. {
deny all;
}
location ~* ^.+\.(rb|log)$ {
deny all;
}
# serve static (compiled) assets directly if they exist (for rails production)
location ~ ^/(assets|images|javascripts|stylesheets|swfs|system)/ {
try_files $uri @rails;
access_log off;
gzip_static on;
# to serve pre-gzipped version
expires max;
add_header Cache-Control public;
add_header Last-Modified "";
add_header ETag "";
break;
}
# send non-static file requests to the app server
location / {
try_files $uri @rails;
}
location @rails {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout invalid_header http_502;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://rails_app;
}
}
Manage all containers with docker-compose
Since our application will be running across multiple containers it would be nice to control them all as one. That is what Docker Compose does for us. To get our app started with Docker Compose create a file docker-compose.yml in the root of your Rails app.
version: '3.7'
# To access data postgres writes within container from host machine
volumes:
postgres_data: {}
services:
db:
image: postgres:11-alpine
restart: always
environment:
POSTGRES_PASSWORD: $DB_PASSWORD
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:alpine
ports:
- "6379:6379"
app:
build:
context: .
dockerfile: ./docker/app/DockerFile
args:
RAILS_ENV: $RAILS_ENV
depends_on:
- db
- redis
# env_file: .docker/.env
ports:
- "3000:3000"
web:
build:
context: .
dockerfile: ./docker/web/DockerFile
args:
RAILS_ENV: $RAILS_ENV
depends_on:
- app
ports:
- 80:80
To resolve environment variables in
docker-compose.yml
file, you can generate a.env
file in the same directory wheredocker-compose.yml
file is saved. docker-compose will automatically get the environment variables from this file. Which in our case is the same file we use for our rails server too and is located on project root path
Reference docker container named db
that we are using to run our Postgresql database. you’ll need to update your database.yml similar to this
default: &default
adapter: postgresql
encoding: unicode
username: postgres
password: <%= ENV['DB_PASSWORD'] %>
pool: 5
host: db
development:
<<: *default
database: app_name_development
At this point you should be able to build all containers with docker-compose build
Once built you can initialize your DB with docker-compose run app rake db:create
and then populate it using docker-compose run app rake db:migrate db:seed
.
If you would like your container to run the same executable every time, then you should consider using entrypoint in combination with CMD. See ENTRYPOINT
Now we can finally run application with docker-compose up
.
To verify that all three containers are up and running execute docker ps
To remove all containers and images you can run these commands
# bash/zsh
docker rm $(docker ps -a -q)
docker rmi $(docker images -q)
# fish
docker rm (docker ps -a -q)
docker rmi (docker images -q)
You can run any command in running container
docker-compose exec container_name command
Or run command in new container
docker-compose run container_name command