Learning Rails – Improving Deployment (Part 9)

Tue Jan 24, 2017 - 1100 Words

Today we’re going to be improving deployment of the meal plan application by setting up a web server (Nginx) in front of our application server.

Goal for this Tutorial:

  • Pre-compile assets as part of our image building process
  • Add Nginx to our web application stack
  • Handle Deploying New Application Changes

We current have our application running in the wild, but pointing a user at an IP address with a port number isn’t a great approach. It’s also not great that there is a really long first load time when the application has to compile the status assets (javascript, styles, etc). We can fix both of these issues by using a web server and pre-compiling our assets.

Before we get going too far though we’re going to improve our ability to run commands using our docker-compose.prod.yml file. Let’s create script/prod to make this a little easier to type:

script/prod

#!/bin/sh

docker-compose -f docker-compose.prod.yml "$@"

After you save the file don’t forget to make it executable:

$ chmod +x script/prod

Note: If you’re on windows you can created a console alias by following the instructions here

Pre-compiling Application Assets

We’re going to pre-compile assets as part of our Docker image build so that we can do it before we ever mess with our containers that are already running. This will make our application much faster on the first load and force our ruby process to do less once we have Nginx in place. We’ll start by creating a separate Dockerfile for production.

$ cp Dockerfile Dockerfile.prod

We’re calling this Dockefile.prod, but it would really work for any configuration environment that we want to have our assets pre-compiled into. Next, we’ll make a few changes to the file to get our assets to be pre-compiled:

Dockerfile.prod

FROM ruby:2.3.1

RUN apt-get update -yqq \
  && apt-get install -yqq --no-install-recommends \
    postgresql-client \
    nodejs \
  && apt-get -q clean \
  && rm -rf /var/lib/apt/lists

# Pre-install gems with native extensions
RUN gem install nokogiri -v "1.6.8.1"

WORKDIR /usr/src/app
COPY Gemfile* ./
RUN bundle install
COPY . .

# Pre-compile assets
ENV RAILS_ENV production
RUN rails assets:precompile

CMD script/start

Next, we will need to set up our docker-compose.prod.yml file to use the proper file to build our image.

docker-compose.prod.yml

version: "2"

volumes:
  db-data:
    external: false

services:
  prod_db:
    image: postgres
    env_file: .env.prod
    volumes:
      - db-data:/var/lib/postgresql/db-data

  prod_app:
    build:
      context: .
      dockerfile: Dockerfile.prod
    env_file: .env.prod
    ports:
      - "3000:3000"
    depends_on:
      - prod_db

Let’s build it and watch the assets be precompiled using our new script/prod

$ script/prod build prod_app

You can see that writes the values out to /usr/src/app/public/assets within the image and even creates gzipped versions for us. This is important to notice because we’ll need to share these files from our prod_app container over to our web server that we’re about to get up and running.

Setting up Nginx as our Web Server

There are a few things that we want Nginx to handle for us:

  1. Proxying for our Rails application. This will allow us to set up multiple backends when we need them.
  2. Serving static assets that have been precompiled.

For us to be able to do this without creating a custom Nginx image we will need to set up a volume to share our assets from our prod_app container over to our webserver container, and a volume to share our Nginx site configuration into the container also. Let’s lay out our docker-compose.prod.yml file to let us accomplish this:

docker-compose.prod.yml

version: "2"

volumes:
  assets:
    external: false
  configs:
    external: false
  db-data:
    external: false

services:
  webserver:
    image: "nginx:1.11.8"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - assets:/usr/share/nginx/html
      - configs:/etc/nginx/conf.d

  prod_db:
    image: postgres
    env_file: .env.prod
    volumes:
      - db-data:/var/lib/postgresql/db-data

  prod_app:
    build:
      context: .
      dockerfile: Dockerfile.prod
    env_file: .env.prod
    ports:
      - "3000:3000"
    volumes:
      - assets:/usr/share/nginx/html
      - configs:/etc/nginx/conf.d
    depends_on:
      - prod_db
      - webserver

We’re also going to need to create a new file that will represent our site configuration in Nginx so that it will proxy to our Rails app.

site.conf

server {
  listen 80;

  # Properly serve assets
  location ~ ^/(assets)/ {
    root /usr/share/nginx/html;
    gzip_static on;
    expires max;
    add_header Cache-Control public;
    add_header ETag "";
  }

  # Proxy requests to rails app
  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass_header Set-Cookie;
    proxy_pass http://prod_app:3000;
  }
}

This brings us to a “gotcha” with volumes that we hadn’t previously looked at. Once a volume has been created and is connected to a container, it won’t be removed or overwritten by simply connecting another container. This will have an effect because our webserver container will create our assets and config containers with its own default values and then when we create our prod_app container we won’t be able to simply share our assets over because they volume already exists.

Serving Proper Assets on Update

Now that we have our site file up we’re going to need to rebuild our image so that it is packaged in, but before we do that we’re going to make sure that when we start our container in the production environment that we are ensuring the newest assets are in the right spot. We’ll do this by modifying our script/start:

script/start

#!/bin/bash -e

if [[ -a /tmp/puma.pid ]]; then
  rm /tmp/puma.pid
fi

if [[ $RAILS_ENV == "production" ]]; then
  rake assets:precompile
  mkdir -p /usr/share/nginx/html
  cp -R public/* /usr/share/nginx/html/
  mkdir -p /etc/nginx/conf.d/
  cp site.conf /etc/nginx/conf.d/default.conf
fi

rails server -b 0.0.0.0 -P /tmp/puma.pid

Since we precompile assets when we build the image doing so again here doesn’t really cost us anything. We ensure that the html directory exists so that we can copy the assets from the public directory over. Finally, we move our site.conf file into the configuration directory for Nginx. It’s safe for us to now flip the flag we set in our .env.prod file that was making rails serve and compile assets on the fly.

POSTGRES_USER=meal_planner
POSTGRES_PASSWORD=dF1nu8xT6jBz01iXAfYDCmGdQO1IOc4EOgqVB703
POSTGRES_HOST=prod_db

RAILS_ENV=production
# RAILS_SERVE_STATIC_FILES=false
RAILS_LOG_TO_STDOUT=true
SECRET_KEY_BASE=7a475ef05d7f1100ae91c5e7ad6ab4706ce5d303e6bbb8da153d2accb7cb53fa5faeff3161b29232b3c08d6417bd05686094d04e22950a4767bc9236991570ad

Now we can rebuild our image and spin up our containers. We’ll start the webserver container first so that we do get the Nginx static files as the first files in the assets and config volumes.

$ script/prod up -d webserver prod_db
$ script/prod up -d --build prod_app

If you head to localhost you should see the Nginx splash page, but that’s not the goal. The reason that you’re seeing this instead of our application is that we didn’t restart Nginx after we dropped off a new config for it to read. This is one occasion that I will actually say that using docker-compose restart will work just fine for us:

$ script/prod restart webserver

Now if you head back to localhost you should see the meal plan application being served up (with assets) although we didn’t specify a port.

Recap

Today we vastly improved the deployment of our rails application by having a web server serve our static assets and proxy requests to our application so that we don’t need to connect to port 3000 directly. There’s still more improvement that can be done by digging deeper into Nginx, but for now, this works.