Ruby in Docker images

The Dockerfile

A pipeline runner already gives you a fresh machine. An image gives you the same machine anywhere: on a laptop, in CI, on a server. Docker turns that machine into a file. For a Ruby project the base image is the official one, and the file is short because Bundler does most of the work.

FROM ruby:3.4-slim WORKDIR /app COPY Gemfile Gemfile.lock ./ RUN bundle config set --local path vendor/bundle \ && bundle config set --local without development \ && bundle install COPY lib ./lib COPY test ./test COPY Rakefile ./ CMD ["bundle", "exec", "rake", "test"]

Two things carry the file. The order: the Gemfile and the lock file are copied first and the bundle is installed before the code, so a changed test file reuses the gem layer and the rebuild takes seconds.

The second is without development. The linter from lesson 56 lives in the development group, and the image only runs tests. Leaving the group out keeps RuboCop, its parser and the gems they pull out of the image, and it keeps the build fast.

Build the image

Build it once and tag it with the project name:

docker build -t ci-demo .

#8 13.40 Bundle complete! 3 Gemfile dependencies, 3 gems now installed.

That line is the bundle install step from the middle of the build; the three gems are the test group, without the development one.

Without the flag the same step stops with an error. The linter's parser arrives as native extensions, the slim image has no compiler, and the build prints Installing json 3.1.0 with native extensions followed by Gem::Ext::BuildError: ERROR: Failed to build gem native extension. The fix is not to install a compiler in a test image; it is to keep the linter out of it.

Run the image

The image runs the command from its last layer, so a bare run starts the tests inside the container:

docker run --rm ci-demo

Run options: --seed 48949 # Running: ... Finished in 0.001403s, 2137.6733 runs/s, 2850.2310 assertions/s. 3 runs, 4 assertions, 0 failures, 0 errors, 0 skips

The --rm flag deletes the container when it exits; the image stays. To run a different command in the same image, name it after the image. A failing test gives a nonzero exit status, which is what a pipeline reads.

The image size

docker images ci-demo

REPOSITORY TAG SIZE
ci-demo latest 183MB

183 megabytes for a Ruby project with three gems. Most of it is the Ruby runtime and the base system; the gems are a few megabytes and the test files almost nothing. That is why the development group is the first thing to leave out and the compiler is not something to add back.

What to watch for

Add .dockerignore with vendor, .bundle and test/reports. Without it the build copies local gems and stale reports into the image, and the layer cache stops working.

A gem with a native extension still needs a compiler even in the test group. When that happens, use the full ruby:3.4 image or add a build stage; do not move the gem to the development group to make the build pass.

Next: Build and release a gem

Article author: Arthur Isaev