Dockerizing Ruby To Stay Sane

Mar 18 2017

Troubled by my long blogging hiatus? This article explains why: My Ruby broke. But this time, I solved that problem once and for all.

For a non-rubyist, the hardest part about Ruby (particularly on a Mac) is getting it installed correctly. After a frustrating Middleman breakage, I decided to take a new approach: Dockerize Ruby apps and stop trying to manage a local Ruby install.

So far, it's working well.

The Conundrum

In my experience, Ruby toolchains on Mac seem to break frequently. The version of Ruby that ships with macOS is woefully out of date. Installing a Ruby version manager is an exercise in pain. And even once that is done, the dependency resolution in a mixed-Ruby environment seems fragile.

Months ago, a macOS update rendered my Middleman blog completely unusable. First it was the wrong version of Ruby. Then it was dependency conflicts. Then bundler issues. The litany of tiny issues added up to one overwhelming frustration.

So I gave up for a while. Later, I tried out Hugo (a go alternative), but found it to be lacking in some basic features. I even started to write my own static page generator. Then a friend suggested that I just use Docker.

Dockerizing Middleman

Keep in mind that I am beginning with a rather large existing Middleman site. The process may actually be even simpler for those creating a site from scratch.

Creating a Docker-based toolchain for working with Middleman has turned out to be straightforward. In less than the amount of time it took me to get RVM reliably working on macOS, I got the blog back up and running:

  • Start with Docker for Mac
  • Get the official Ruby image: docker pull ruby:2.4
  • Tweak (or create) the Gemfile
  • Create a Dockerfile that installs Middleman and then configures a Middleman project (See below)
  • Create a Makefile that encapsulates all my usual Middleman commands (More below)
  • Run it

The Gemfile

Again, I am beginning with an existing Middleman site. So I already have a Gemfile. I made several changes to it locally to get it configured the way I like:

source 'https://rubygems.org'

# These are the basics:
gem 'middleman', '~> 4.2'
gem 'middleman-livereload', '~> 3.4'
gem "middleman-blog", "~> 4.0"

# I needed this for execJS. Since I don't care too
# much about specific JS features, I chose the one that
# was easiest/fastest to install.
gem "therubyracer"

# All of this are specific to my needs:
gem "builder"
gem "nokogiri"
gem "redcarpet"

The above is broken into three sections:

  • Middleman "basics"
  • An ExecJS implementation, which I guess I need (the docs didn't say so, but middleman failed without it)
  • A few add-ons that my particular blog uses

The Dockerfile

A Dockerfile provides instructions to the Docker engine about how to build (and, in some cases, execute) a Docker image.

My Dockerfile follows a very simple process:

  1. Install Middleman
  2. Copy the Gemfile into the Docker container
  3. Run Bundler to install dependencies

The image this creates is preconfigured for my blog's needs, but doesn't actually have any blog-specific content in it.

Here's the Dockerfile:

FROM ruby:2.4

RUN gem install middleman
ADD Gemfile /usr/src/app/Gemfile
RUN cd /usr/src/app && bundle install

Build the docker image like this:

$ docker build -t "tsblog:latest" .

I added the build as a Makefile target, which we'll see shortly.

The image takes a little while to build the first time, since it fetches the base Ruby image, then installs a bunch of things. But at the end, you'll have a new image, which you can see by running docker images.

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
tsblog              latest              9d29e9a0f95e        About a minute ago   857 MB

To test it out, you can run an interactive shell and make sure the middleman command works:

$ docker run -it --rm --name middleman "tsblog:latest" /bin/sh

Executing middleman -h should show you the help text, which is a decent marker that Middleman is okay. To exit the container, run exit.

The Makefile

With my new Middleman image, I want to be able to do a few things:

  1. Create new articles
  2. Build my site
  3. Run a server (for testing)
  4. Regenerate the container next time I want to update Middleman.

So I created a make target for each of these. And I created one more utility make target.

Here's my Makefile:

# The name of my Docker image
NAME = tsblog

# The Docker image tag I want to use
TAG = latest

# The two above, combined.
IMAGE = $(NAME):$(TAG)

# Where (in the image) I want to run the app
APPROOT=/usr/src/myapp

.PHONY: post
post: TITLE ?= Unknown
post: COMMAND = bundle exec middleman article "$(TITLE)"
post: dockerize

.PHONY: build
build: COMMAND = bundle exec middleman build
build: dockerize

.PHONY: serve
serve: EFLAGS = -p 4567:4567
serve: COMMAND = bundle exec middleman serve
serve: dockerize

.PHONY: docker-build
docker-build:
    docker build -t $(IMAGE) .

.PHONY: dockerize
dockerize:
    docker run -it --rm --name $(NAME) -v "$(CURDIR)":$(APPROOT) -w $(APPROOT) $(EFLAGS) $(IMAGE) $(COMMAND)

There are five targets above:

  • post creates a new (mostly empty) blog post. It is called as make post TITLE="Hello World"
  • build generates the static HTML version of the site.
  • serve starts up a local web server bound to http://127.0.0.1:4567
  • docker-build rebuilds my Docker image
  • dockerize is a helper that runs Docker for each of the above targets.

dockerize

The dockerize target defines all be important stuff for executing my tsblog:latest image and granting it access to my Middleman files. It accepts two parameters from other targets:

  • EFLAGS: Add extra flags to the Docker call
  • COMMAND: Run this particular command inside of the Docker container.

post

The make post command takes a title from the user, and then sends it to the dockerize target with the command bundle exec middleman article

post: TITLE ?= Unknown
post: COMMAND = bundle exec middleman article "$(TITLE)"
post: dockerize

Note that TITLE ?= Unknown tells Make that if the user does not set TITLE on the command line, use the default title Unknown.

build

The make build command just executes the command to build the Middleman site.

It's important to note that with post, build, and serve, Docker and the macOS host are sharing the directory. So when a file changes locally, the Docker container has immediate access. Likewise, when the container modifies files, I see the changes in macOS.

serve

The make serve command tells Docker to start up the Middleman web server, then set everything up so that I can access the server at http://localhost:4567.

serve: EFLAGS = -p 4567:4567
serve: COMMAND = bundle exec middleman serve
serve: dockerize

To pass in the port mapping, we use the EFLAGS variable.

Note that the server will continue to run until you kill Docker or hit CTRL-C in the Docker console.

Why leave the container bound to the console window instead of running it in the background? Because middleman serve shows useful debugging information as it continually refreshes the site.

docker-build

The last target is named docker-build, and it simply rebuilds the Middleman Docker image. The next time I want to upgrade Middleman, my Gems, or the Ruby image, I merely need to change the Dockerfile and then re-run this command to build a fresh copy of the image.

YAY-DONE!

With this setup, I can continue to edit files locally on my Mac. I just run make blog and then edit the file it generates for me.

But instead of facing the frustration of running multiple versions of Ruby in the same environment, I now have a completely isolated environment for my Middleman work.

I never need to install rbenv or rvm again.