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.
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.
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
- Create a
Dockerfilethat installs Middleman and then configures a Middleman project (See below)
- Create a
Makefilethat encapsulates all my usual Middleman commands (More below)
- Run it
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
middlemanfailed without it)
- A few add-ons that my particular blog uses
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.
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 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
middleman -h should show you the help text, which is a decent marker that Middleman is okay. To exit the container, run
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
# 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:
postcreates a new (mostly empty) blog post. It is called as
make post TITLE="Hello World"
buildgenerates the static HTML version of the site.
servestarts up a local web server bound to
docker-buildrebuilds my Docker image
dockerizeis a helper that runs Docker for each of the above targets.
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.
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
TITLE ?= Unknown tells Make that if the user does not set
TITLE on the command line, use the default title
make build command just executes the command to build the Middleman site.
It's important to note that with
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.
make serve command tells Docker to start up the Middleman web server, then set everything up so that I can access the server at
serve: EFLAGS = -p 4567:4567 serve: COMMAND = bundle exec middleman serve serve: dockerize
To pass in the port mapping, we use the
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.
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.
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