Dockerizing Ruby To Stay Sane
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:
- Install Middleman
- Copy the Gemfile into the Docker container
- 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:
- Create new articles
- Build my site
- Run a server (for testing)
- 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 asmake post TITLE="Hello World"
build
generates the static HTML version of the site.serve
starts up a local web server bound tohttp://127.0.0.1:4567
docker-build
rebuilds my Docker imagedockerize
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 callCOMMAND
: 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.