This is absolutely no substitute for true automated Continuous Integration (CI), but it is better than nothing and it is fairly simple and cheap if you are already building images. It is also a great illustration of how to use multi-stage builds in your Dockerfiles.
You can add running your unit tests, linting, static security scanning
as stages during your docker build
. If any of these fail, your image will
not build. You can also use stages to make your image smaller.
Now, whenever you build your image, you can be assured your image meets your basic code and quality standards.
In this post, you will learn how to add running your linting, dependency security scanning, and your unit tests as part of building your production image using Docker's multi-stage builds. This post uses a sample Ruby project, but this approach can be used for almost any language.
TL;DR: it is better than nothing
Transparency is a virtue. I did not invent this approach and it comes with legit criticism.
I adapted this approach from a Capital One post Using Multi-Stage Builds to Simplify And Standardize Build Processes which offered the age-old and compelling argument of "why not?"
But, when researching Docker and image/container "best" practices, I found this great article on Docker anti-patterns.
This article argues against and specifically calls out (in unfortunately gender-specific terms) "Using Docker as poor man’s CI/CD." as "anti-pattern 9."
I have a strong belief (loosely held) in the concept of "best fit" practices over "best" practices, believing that practices are best applied in context. A bad practice could be the least worst option in a particular situation making it the best practice at the time. Although, it may still not be ideal, it may work well enough.
So if perhaps you are just getting started on a code project or just have a code repository without an available Continuous Integration system, this approach does offer the assurance of CI with little cost (assuming that you are using Docker/containerization).
It is also easy to remove and replace later with true CI.
In the Capital One post, they used a Node JavaScript application. This example is for a Ruby application with RSpec as the test framework.
Being Ruby, it uses...
- RuboCop for linting
bundle exec rubocop
- bundler-audit
for dependency security scanning
bundle exec bundle-audit check --update
- RSpec for the unit tests
bundle exec rspec
Here is the Dockerfile
containing the automated CI that runs linting,
security scanning, and unit tests or fails the build...
### Base Image ###
FROM ruby:2.7.5-alpine AS ruby-alpine
### Builder Stage ###
FROM ruby-alpine AS builder
# Alpine needs build-base for building native extensions
RUN apk --update add --virtual build-dependencies build-base
# Use the same version of Bundler in the Gemfile.lock
RUN gem install bundler:2.3.4
WORKDIR /app
# Install the Ruby dependencies (defined in the Gemfile/Gemfile.lock)
COPY Gemfile Gemfile.lock ./
RUN bundle install
### Lint Stage ###
FROM builder AS lint
COPY . .
RUN bundle exec rubocop
### Security Static Scan Stage ###
FROM builder AS secscan
# Add git for bundler-audit
RUN apk add --no-cache git
# Just need Gemfile.lock to scan
RUN bundle exec bundle-audit check --update
### Unit Test Stage ###
FROM lint AS test
COPY --from=lint . .
RUN bundle exec rspec
### Deploy Stage ###
FROM ruby-alpine AS deploy
# Throw errors if Gemfile has been modified since Gemfile.lock
RUN bundle config --global frozen 1
# Run as deployer USER instead of as root
RUN adduser -D deployer
USER deployer
# Copy over the built gems directory from the scanned layer
COPY --from=secscan --chown=deployer /usr/local/bundle/ /usr/local/bundle/
# Copy in app source from the lint layer
WORKDIR /app
COPY --from=test --chown=deployer /app/ /app/
# Run the default app command
#CMD whatever
This is an optimized multi-stage build so there are several stages to cover.
It starts with the base layer, here an official Ruby Alpine Linux image.
### Base Image ###
FROM ruby:2.7.5-alpine AS ruby-alpine
You can name and then later reference a stage. You name a stage in a
Dockerfile
with AS
. Here we are naming the base stage ruby-alpine
.
Next is the builder
stage. Most programming languages, even interpreted
ones like Ruby, have a build environment. For Ruby it is to natively
compile libraries (gems) like nokogiri.
### Builder Stage ###
FROM ruby-alpine AS builder
# Alpine needs build-base for building native extensions
RUN apk --update add --virtual build-dependencies build-base
# Use the same version of Bundler in the Gemfile.lock
RUN gem install bundler:2.3.4
WORKDIR /app
# Install the Ruby dependencies (defined in the Gemfile/Gemfile.lock)
COPY Gemfile Gemfile.lock ./
RUN bundle install
This starts with the ruby-alpine
stage as the base image and names
this stage builder
.
To build the project, it needs the Alpine gcclib
equivalent for
natively compiling the Ruby gems for the project so it installs it.
It needs the Ruby library manager bundler
(always good idea to sync
it with yourGemfile.lock
). It makes the image working directory /app
(creating it if necessary). To install and build the libraries, bundler
only needs Gemfile
and Gemfile.lock
. bundle install
installs
(and natively compiles) the libraries for the project.
Generally the build environment is not needed for production which usually only needs the built artifact (for Ruby, the gems i.e. libraries).
The builder environment is usually in the development and unit testing environment.
Here is the first instance of the CI in the Dockerfile, the linting stage.
### Lint Stage ###
FROM builder AS lint
COPY . .
RUN bundle exec rubocop
It uses the builder
stage as its base image naming this stage lint
.
To lint the source code, it will need the source code so it copies
it in (builder
only has Gemfile
and Gemfile.lock
and the built gems).
It then runs the RuboCop linting command.
Next in the Dockerfile
is the CI of running the static security scan of
the dependencies. It is very similar to the linting stage other than
needing git
to update the vulnerabilities list and only needing the
Gemfile
and Gemfile.lock
files already in the builder
.
### Security Static Scan Stage ###
FROM builder AS secscan
# Add git for bundler-audit
RUN apk add --no-cache git
# Just need Gemfile.lock to scan
RUN bundle exec bundle-audit check --update
You may already know this, but to continue down the Dockerfile
to the
next stage, requires some information about multi-stage builds.
The first thing to know about multi-stage builds is that by default the
last stage in a Dockerfile
is the target stage of the build, as in
docker build .
. This why the deployment stage (what will be deployed to
production) is always the last stage in a Dockerfile
.
The next thing to know about multi-stage builds is that with
DOCKER_BUILDKIT
(DOCKER_BUILDKIT=1
), only those stages required to
build the target stage (e.g. deployment stage) are built. Any stages
in the Dockerfile
not needed for the target stage are skipped.
What this means is that for your production image to have required CI
stages. the production image must depend on them in the Dockerfile
.
You can also "chain" your stage dependencies by making a stage on which your production image depends dependent on another CI stage. That is what we will do with the unit testing stage.
Here the test stage (which we will use in our production image stage)
will depend on the lint
stage especially since it has the source code
which it copies from that layer.
### Unit Test Stage ###
FROM lint AS test
COPY --from=lint . .
RUN bundle exec rspec
This completes the CI stages of the Dockerfile
.
The final stage is always the production image stage which must require your CI stages in order for them to be run during the build.
# Run as deployer USER instead of as root
RUN adduser -D deployer
USER deployer
# Copy over the built gems directory from the scanned layer
COPY --from=secscan --chown=deployer /usr/local/bundle/ /usr/local/bundle/
# Copy in app source from the lint layer
WORKDIR /app
COPY --from=test --chown=deployer /app/ /app/
# Run the default app command
#CMD whatever
For security, you should not run as root, so it adds the deployer
user and changes to it to run the application.
In the next block it both requires the CI stages and optimizes the image
size by only copying in the security-scanned, built libraries (originally
from the builder
layer) and then changing the image working directory
and copying the unit test
ed (and lint
ed, since it was the base image
for test
) source code to run. Since the image now runs as deployer
,
the COPY
changes ownership of the files to the running deployer
user.
The final step in Dockerfile would be to run the entrypoint CMD
for the
target application, for this example application this line is commented
out.
Now that you have seen the Dockerfile
with the added CI stages, here is
some sample output when you do a docker build
with it.
Here is the sample output when the CI stages pass and the production stage is built successfully...
% docker build .
[+] Building 16.1s (23/23) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 1.35kB 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ruby:2.7.5-alpine 0.9s
=> [auth] library/ruby:pull token for registry-1.docker.io 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 5.58kB 0.0s
=> [ruby-alpine 1/1] FROM docker.io/library/ruby:2.7.5-alpine@sha256:bd211c7b64ded4d22cf6fd91bf13264ede1c503213edbf73901cbbc870ff37e6 0.0s
=> CACHED [builder 1/5] RUN apk --update add --virtual build-dependencies build-base 0.0s
=> CACHED [builder 2/5] RUN gem install bundler:2.3.4 0.0s
=> CACHED [builder 3/5] WORKDIR /app 0.0s
=> CACHED [builder 4/5] COPY Gemfile Gemfile.lock ./ 0.0s
=> CACHED [builder 5/5] RUN bundle install 0.0s
=> [lint 1/2] COPY . . 0.0s
=> [lint 2/2] RUN bundle exec rubocop 2.1s
=> [test 1/2] COPY --from=lint . . 2.8s
=> [test 2/2] RUN bundle exec rspec 0.8s
=> CACHED [deploy 1/5] RUN bundle config --global frozen 1 0.0s
=> CACHED [deploy 2/5] RUN adduser -D deployer 0.0s
=> CACHED [secscan 1/2] RUN apk add --no-cache git 0.0s
=> CACHED [secscan 2/2] RUN bundle exec bundle-audit check --update 0.0s
=> CACHED [deploy 3/5] COPY --from=secscan --chown=deployer /usr/local/bundle/ /usr/local/bundle/ 0.0s
=> CACHED [deploy 4/5] WORKDIR /app 0.0s
=> [deploy 5/5] COPY --from=test --chown=deployer /app/ /app/ 3.0s
=> exporting to image 3.5s
=> => exporting layers 3.5s
=> => writing image sha256:adb4f1d93c653d3ae0d69ef2ce7e8af72e0e6a758668e53646f1c5dd86e0a81f
You may notice that it is not easy to tell from this output that the CI stages actually ran.
To prove that the CI stages are running and to see the output when one fails, you can introduce a fault that will cause one of the CI stages to fail.
Here is the sample build output when the linting stage fails...
% docker build .
[+] Building 3.4s (13/22)
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 37B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ruby:2.7.5-alpine 1.0s
=> [auth] library/ruby:pull token for registry-1.docker.io 0.0s
=> [ruby-alpine 1/1] FROM docker.io/library/ruby:2.7.5-alpine@sha256:bd211c7b64ded4d22cf6fd91bf13264ede1c503213edbf73901cbbc870ff37e6 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 3.37kB 0.0s
=> CACHED [builder 1/5] RUN apk --update add --virtual build-dependencies build-base 0.0s
=> CACHED [builder 2/5] RUN gem install bundler:2.3.4 0.0s
=> CACHED [builder 3/5] WORKDIR /app 0.0s
=> CACHED [builder 4/5] COPY Gemfile Gemfile.lock ./ 0.0s
=> CACHED [builder 5/5] RUN bundle install 0.0s
=> [lint 1/2] COPY . . 0.0s
=> ERROR [lint 2/2] RUN bundle exec rubocop 2.2s
------
> [lint 2/2] RUN bundle exec rubocop:
#19 2.200 Inspecting 3 files
#19 2.200 .C.
#19 2.200
#19 2.200 Offenses:
#19 2.200
#19 2.200 spec/any_old_spec.rb:2:1: C: [Correctable] Layout/EmptyLineAfterMagicComment: Add an empty line after magic comments.
#19 2.200 require 'spec_helper'
#19 2.200 ^
#19 2.200
#19 2.200 3 files inspected, 1 offense detected, 1 offense auto-correctable
#19 2.200
#19 2.200 Tip: Based on detected gems, the following RuboCop extension libraries might be helpful:
#19 2.200 * rubocop-rake (https://rubygems.org/gems/rubocop-rake)
#19 2.200 * rubocop-rspec (https://rubygems.org/gems/rubocop-rspec)
#19 2.200
#19 2.200 You can opt out of this message by adding the following to your config (see https://docs.rubocop.org/rubocop/extensions.html#extension-suggestions for more options):
#19 2.200 AllCops:
#19 2.200 SuggestExtensions: false
------
executor failed running [/bin/sh -c bundle exec rubocop]: exit code: 1
The output shows that the CI stages are indeed running and that the build will fail if the CI stage fails.
This concludes this post on the anti-pattern of putting CI in your
Dockerfile
. Although it is no substitute for a true Continuous
Integration, it is better than no automated CI at all. Hopefully,
you also learned a bit about multi-stage Docker builds as well.