Docker for Rubyists: shipping your first image
Docker is approaching 1.0 and it is about to change how we ship Ruby apps. A hands-on guide to images, containers, and Dockerfiles — building a reproducible dev environment and why 'works on my machine' is finally on its way out.
There is a piece of software a little over a year old that the whole operations world is suddenly talking about, and for once the hype is warranted. Docker is approaching its 1.0 release, and it solves a problem every Ruby developer has lived with: the gap between “works on my machine” and “works on the server”. If you have ever lost an afternoon to a colleague’s mismatched OpenSSL, a missing system library behind a native gem, or a Ruby version that was subtly different in production, Docker is aimed squarely at you.
This is a hands-on introduction for Rubyists: what Docker actually is, how it differs from the virtual machines you may already use, and how to build your first image for a Ruby app.
Containers are not virtual machines
The usual first question is “isn’t this just a VM?” — and the difference is the whole point. A virtual machine virtualises hardware: it boots a full guest operating system, with its own kernel, on top of a hypervisor. That is heavy — gigabytes of disk, a minute to boot, real RAM overhead per VM.
A container virtualises the operating system. Containers share the host’s Linux kernel and isolate processes using kernel features — namespaces (so a container has its own view of the process tree, network, and filesystem) and cgroups (so it gets a bounded slice of CPU and memory). There is no guest OS to boot; a container starts in milliseconds and weighs megabytes, not gigabytes. You get most of the isolation of a VM at a tiny fraction of the cost, which is why you can run dozens of containers where you would run one or two VMs.
(A note for 2014: containers need a Linux kernel, so on a Mac or Windows you run Docker inside a tiny Linux VM via boot2docker. Native Docker on those platforms is not here yet — you are really talking to a Linux host over the wire.)
Images and containers: the class/instance analogy
The two core concepts map cleanly onto something every Rubyist knows. An image is like a class: an immutable, layered template — a filesystem snapshot plus the metadata to run it. A container is like an instance: a running (or stopped) process started from an image, with a thin writable layer on top. You build an image once and start many containers from it, exactly as you instantiate many objects from one class.
The “layered” part matters. An image is a stack of read-only layers, each the diff
from the one below, and Docker caches and shares them. Pull two images both based
on ubuntu:14.04 and the Ubuntu layer is stored once. Rebuild after changing one
line and only the affected layers rebuild. This layering is what makes Docker fast
to build and cheap to ship, and writing a good Dockerfile is largely about
working with the layer cache rather than against it.
Your first Dockerfile
A Dockerfile is the recipe for an image — a sequence of instructions, each
producing a layer. Here is a workable one for a Rails app:
FROM ruby:2.1
# system libraries your native gems need (pg, nokogiri, ...)
RUN apt-get update && apt-get install -y \
build-essential libpq-dev nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# copy ONLY the gemfiles first, then bundle — see the note below
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4
# now copy the rest of the app
COPY . .
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
The single most important trick is already in there: copy Gemfile and
Gemfile.lock and run bundle install before copying the rest of the code.
Because each instruction is a cached layer, and a layer’s cache is invalidated when
its inputs change, ordering it this way means editing your application code does
not bust the bundle layer. Your gems reinstall only when the Gemfile actually
changes — turning a two-minute rebuild into a two-second one. Get the ordering
wrong (copy everything, then bundle) and you reinstall every gem on every code
change, which is the first thing that makes people give up on Docker.
The other subtlety: bind the Rails server to 0.0.0.0, not the default
localhost. Inside the container, localhost is the container’s own loopback;
binding to all interfaces is what lets the host reach the port you publish.
Build, run, iterate
docker build -t myapp .
docker run -p 3000:3000 myapp # publish container :3000 to host :3000
-p 3000:3000 maps a host port to a container port — the bridge between the
isolated container network and your machine. Visit localhost:3000 (or the
boot2docker VM’s IP) and your app is served from inside the container.
For development you do not want to rebuild the image on every edit. Mount your source as a volume so the running container sees your live files, and the container becomes a stable environment wrapped around code you are still editing:
docker run -p 3000:3000 -v $(pwd):/app myapp
Now the Ruby version, the system libraries, the bundled gems — everything below your code — is fixed by the image, while the code itself is whatever is on disk. That is the dev-environment win: a new team member runs two commands and has the exact stack everyone else has, with no “install these fourteen things first” README.
Multiple services: databases and fig
Real apps are not one process. You have Postgres, maybe Redis, maybe a background worker — and the container philosophy is one concern per container, wired together. In 2014 the early answer to “link these containers and start them as a unit” is fig (the tool that will soon become docker-compose), which declares your stack in a small YAML file:
# fig.yml
web:
build: .
ports:
- "3000:3000"
links:
- db
volumes:
- .:/app
db:
image: postgres:9.3
fig up builds and starts the whole thing; the links make the db container
reachable from web by name. This is the shape of things to come — describing a
multi-service environment as code, reproducible on any machine — and it is worth
adopting now even while the tooling is young. (This is essentially what the
docker-lamp style of stack does: a web container linked to its database, the
whole environment captured in one file you can commit.)
Where this is going
It is early — the tooling is raw, the Mac story is a VM-in-a-VM, and best practices are still being figured out in public. But the core idea is clearly right, and the trajectory is obvious. The reproducible image you build for development is the same artefact you will ship to production, which collapses the gap between the two environments that has caused a decade of deploy-day surprises. “Works on my machine” stops being a shrug and starts being a guarantee, because the machine travels with the app.
Build one image for a real project this month. The Dockerfile will be short, the layer-cache lesson will save you immediately, and you will have a head start on what is rapidly becoming the default way to package and ship server software.