Skip to content

Instantly share code, notes, and snippets.

@brianjbayer
Created March 21, 2022 14:56
Show Gist options
  • Save brianjbayer/7ac4ac20c1dbdabb44a6faebe3ffe281 to your computer and use it in GitHub Desktop.
Save brianjbayer/7ac4ac20c1dbdabb44a6faebe3ffe281 to your computer and use it in GitHub Desktop.
Add Automated Continuous Integration (CI) To Your Dockerfile - Docker Multi-Stage Build Anti-Pattern

Simple, Cheap Continuous Integration (CI) in a Dockerfile

Garage Pool - Brian J. Bayer


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.


Background and Controversy

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.


The Sample Application

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
    

The Dockerfile

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.

Base Stage

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.

🙇 Docker documentation on naming stages

Builder Environment Stage

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.

Linting Stage

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.

Security Scanning Stage

Next in the Dockerfileis 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

Detour About Multi-Stage Builds

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.

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 Production Image Stage

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 tested (and linted, 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.


Sample Docker Build Output

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.

Sample Output of a Successful Build

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.

Sample Output of a Failed Build

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.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment