Docker in development: Episode 2

January 09, 2020

Last episode was about motivation and benefits of using docker for development. In this episode we’ll be dockerizing a Ruby on Rails app.

Before we start:

  • It’s important you’re familiar with docker (as mentioned in the last episode) and docker-compose.
  • We know there are tons of “Dockerizing X” tutorials/blog posts out there. It’s not the goal of this post to present you the absolute tutorial/blog post, but to show you the problems we encountered while taking our first steps using docker, and how to solve them (as those aren’t easily googleable).

Step 1: creating a Dockerfile

As you know, the first step is to create a Dockerfile in the root directory of our Rails app. The Dockerfile explains how the app is built, and how is it going to be executed. For a standard Rails app we normaly have (more or less) this Dockerfile:

FROM ruby:2.6

RUN apt-get update -qq && apt-get install -y build-essential curl git libpq-dev nodejs

WORKDIR /app

COPY . /app
COPY bin/entrypoint.sh /usr/bin/

RUN chmod +x /usr/bin/entrypoint.sh

RUN bundle install

ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]

If you’re familiar with docker (and we encouraged that since the beginning of the post!) you’ll understand all of it. There’s nothing out of the ordinary in this Dockerfile, but there’s one thing that you wouldn’t find in a regular rails project and that’s the entrypoint. The entrypoint is the image’s main command, and is often used in combination with helper scripts.

In our case, for a Rails app, a normal bin/entrypoint.sh file would look like this:

#!/bin/sh

set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid

exec "$@"

Let’s the start from the end of the script. $@ means “all of the parameters passed to the script”. If we look at the Dockerfile above, that is rails server -b 0.0.0.0, meaninig that exec rails server -b 0.0.0.0 will be executed within the entrypoint. The question here is: why do we need the entrypoint then? And the answer is because the container might have a pidfile that could make the cmd fail. That’s why rm -f /app/tmp/pids/server.pid needs to exist in the entrypoint.

Are we ready to build our image and start runnning our app? No, we’re not. We still haven’t defined what’s our database, where it is, and we haven’t set up our config/database.yml file. In our case, we’ll use a postgresql database. The reason why we didn’t do that yet is because we don’t want the database to be part of our app setup. If we do so, for example by installing it in our image, then our data won’t be persistent across different executions (technically, you can do that by mounting volumes and such, but it’s against best practices). This is where docker-compose takes place.

version: "3"

services:
  db:
    image: postgres
    volumes:
      - ./db/postgresql-data:/var/lib/postgresql/data
  web:
    build: .
    depends_on:
      - db
    ports:
      - 3000:3000
    volumes:
      - .:/app

Step 2: docker-compose

As mentioned before, we don’t want our database to run separtedly from our codebase. In a typical Rails app, we’d have this docker-compose.yml file in root directory of our Rails app:

Again, if you’re familiar with docker-compose, it’s very easy to understand. We defined 2 services, the app itself (web) and the postgresql database (db). The key here is that we’re mounting 2 volumes: . (our code!) will be mounted in /app in our container and ./db/postgresql-data will be mounted in our db service in /var/lib/posgresql/data. This is important as we want our db to be persisted across executions. In other words, we don’t want to run rails db:create db:migrate every time we start our containers. Also, be aware that you don’t want ./db/postgresql-data to be versioned, so make sure you ignore it. The next step is setting up config/database.yml. We’ll do it this way:

default: &default
  adapter: postgresql
  pool: 15
  timeout: 5000
  username: postgres
  host: db

development:
  <<: *default
  database: app_development

test: &test
  <<: *default
  database: app_test

production:
  <<: *default
  database: app_production

Note that this was redacted to keep it short. The important bits here are the username, set to postgresql and the host, set to db. Nothing here is azarous: postgres is the username the postgres image defined for the username, and db is the name of the service defined in our docker-compose.yml file. If you want to find out more about the postgres image, you can do that by clicking here.

Are we ready to build our image and start running our app? Technically, yes, but let’s do one more thing.

Step 3: .dockerignore

You might guess what a .dockerignore file is. If you thought it was a file that ignores the files and directories listed there when copying from the current context to the image, then you guessed correctly. We don’t want certain files to be copied onto our image to keep it small. In our typical Rails app, we’d have the following .dockerignore file:

.git
db/postgresql-data
log

Are we ready to build our image and start running our app? Yes! Now we are.

Step 4: building and running

Now that everything’s in place we can build our image and run our containers!

You can build your images and start the app by running:

$ docker-compose up --build

or the same command without --build, as it would do it by default if the images doesn’t exist.

By the time it finishes, you should be up and running, and able to see your app at http://localhost:3000.

Join us in the next episode!

logoclutchinstagramaedinfacebook

La Plata, Argentina

Calle 49 876 1/2, Piso 2 Oficina 5
CP 1900

© 2020 SINAPTIA | All rights reserved.