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