Sunday, September 11, 2016

How do I create my Dockerfiles ?

Overview

I have been working in Docker for 3 years and have seen it grow from release 0.6 to 1.12.
During this period I have come across a lot of resources on the web talking about the best practices for writing a Dockerfile, however, I could not find a single place that provided me:
  • Goals of an ‘effective’ Dockerfile.
  • Steps that I can follow to write an effective Dockerfile.


In this post, I will walk through the steps that I use to write Dockerfile for my microservice. It assumes that you have


Disclaimer:
  • I do not claim that this is the best or the only way for writing Dockerfile. But these steps have saved me a lot of time building and deploying my applications using Docker.
  • This post is a compilation of different resources I found on the web while using Docker over several years.


I am all ears to different ideas and suggestions for other approaches.

Goals for writing an effective Dockerfile:

I have tried to outline the goals for writing effective dockerfiles:
  • Reusability: Reuse as much as possible. (Do not reinvent the wheel)
  • Optimize build times (Utilize docker caching as much as possible)
  • Optimize image size (Add only what is needed to your production image.)
  • Optimize image layers (More the image layers, more are the requests made during docker pull. Try to keep number of layers to minimal)

Let us  start containerizing!



Step 1: Define the application skeleton

I start my application development by creating a basic “hello world” skeleton for my app. Once I have skeleton for my application:
  • I identify the command to run my application
  • I define a default configuration for running my application.
  • I provide some way of injecting credentials to my application. For my simple apps, I inject them using environment variables.

Step 2: Define .dockerignore

The ignore file allows you to exclude the stuff that you do not need to run the application. I do this before I write my Dockerfile so that I ensure that I do not sneak in files that are irrelevant for my production code like:
  • README
  • Test code
  • Git files (.git*)
  • Travis / CI Build files


Our continuous deployment builder (image-factory) utilizes docker caching. Just by adding a dockerignore file:
  • It reduces build times for my application when I merge code from a feature branch to develop  and develop to master.
  • It re-uses the same built image if production code has not changed.
  • It ensures that I do not have test dependencies in my production-like container, thereby reducing docker image size.


Here are examples of typical dockerignore files I use for different projects:


Step 3:  Understanding docker builds.



Before I proceed with the next steps, I want to highlight few points with respect to docker builds:
  • Every instruction in a dockerfile creates a new read-only image layer.
  • Docker build caches image layer for every step.
  • Subsequent docker builds will use cached layers until the step where docker detects that instruction has been modified and requires re-build. From that step until the last, docker would execute every step, producing new image layers which get cached.


Knowing these points can help us utilize docker cache effectively during builds as well as during deploys. In addition,  I found “docker images and containers” section from userguide really helpful for understanding docker builds.


Step 4:  Choosing a base image



I like to create a thin container for my micro-services and try to keep the size of my docker image to minimal. Here are some of the guidelines I follow when it comes to choosing a base image in order to create a thin container:
  • Look for a minimal docker image to start off with. There are plenty of images already published in docker.io and I try to reuse one of the existing ones as my base rather creating one from scratch.
  • Ensure that there is a big enough community backing the base image.
  • For a lot of my micro-services, I am transitioning towards using alpine-based docker images. These are tiny docker images and add very little overhead to the overall size of the image. Some of the sources I have been using are:
    • Official repositories for java (openjdk-8-jre-alpine, openjdk-7-jre-alpine)
    • Official repositories for python (2.7-alpine, 3.5-alpine)


Note: There are some caveats of using alpine-based images, but for the most part , it did not impact my services (http://gliderlabs.viewdocs.io/docker-alpine/caveats/)


  • Favor a specific version tag for the images. This eliminates the need of synchronizing the base image on builder machines. For e.g.:  I use mhart/alpine-node:4.4.3 instead of mhart/alpine-node:4.
  • Favor images with only runtime as opposed to full devtools. For example, python runtime as opposed to python-dev and buildtools.  In upcoming steps , I will show how to minimize the image layer size by installing dev-tools during the build step and then removing it after all dependencies have been built as part of the same step.


Step 5:  Defining Environment Variables

Before installing anything, I define the global environment variables, which are typically used during docker build.  
I limit the  runtime environment variables to 5 inside a Dockerfile. If I need more, I end up defining defaults in a wrapper shell script that gets invoked at runtime in order to minimize the number of image layers.

Step 6:  Installing Native Dependencies

My application sometimes requires  one or more native dependencies that are not part of docker base image,like curl, openssl, etc. Here are some of the guidelines I follow to install these dependencies:


  • Is it a runtime dependency or build-time dependency ?  As often as possible , I try to keep runtime dependency as part of an independent image layer. Only in exceptional cases, where build time dependencies take a long time to install or I need the dev tool during my build step like maven, I would install it as part of this step.
  • Try to install all dependencies in a single RUN command which minimizes the number of image layers. It is likely that native dependencies do not change frequently and the cached layer gets utilized for subsequent docker builds.
  • If installing dependencies from a package manager, ensure that you clean up the package manager cache or install dependencies with the no-cache option. For more information see
  • If I need buildtools or development dependencies to build native dependencies, I install them as part of this step but remove them as part of the cleanup. This helps minimize the size of image layer.  


  • If my application requires a wrapper shell script or the main process does not handle signal properly, I also end up installing dumb-init and use it as main program for launching my application. This avoids the need for doing additional signal handling and also helps in avoiding zombie processes.
  • I try to keep debugging tools / dev tools out of my production docker image. For local development, I end up extending production docker image and add debugging tools / dev tools.

Step 7:  Installing Application Dependencies

Typically I do this as 2 docker instructions :


The advantage of pulling dependencies boils down to:
  • Fetching  / Building dependencies take a significant portion of the build time making a perfect candidate of layer cache.
  • You change application code more frequently than changing dependencies.  


When installing the app dependencies, here are some tricks I use:
  • As often as possible, I try to use single docker instruction to install all my application dependencies.
  • Sometimes my dependencies require buildtools for building them natively. In such a case, I will install buildtools  prior to installing dependencies and remove it in the end as part of the same run command. This helps in keeping image size small and also minimizes the number of image layers.
  • If I am using tools like mvn or gradle (for java based projects), I try to fetch all dependencies so that I can build in offline mode.


Here is an example from python base project:


Here are examples of Dockerfile from other tech :


Step 8:  Build the application

Next, I add the source files for my application and build it if necessary.  To simplify:
  • I add all files from my checked out code.
  • Ensure that the non-production files are excluded in dockerignore.
  • Build the application in offline mode. (This is typically required for compiled languages like java). E.g.:  mvn -o package

Step 9:  Finalizing Build

This step consists of defining instructions typically required at runtime:


Step 10: Inspect Image Layers

Once my dockerfile is ready, I build it and inspect the size of cached layers.


#!/bin/sh -e
docker build --rm -t <image-name> .
docker history <image-name>


IMAGE               CREATED             CREATED BY                                      SIZE                
83e021f975f6        17 hours ago        /bin/sh -c #(nop) CMD ["/usr/bin/dumb-init" "   0 B                 
bd08811de63d        17 hours ago        /bin/sh -c #(nop) WORKDIR /opt/mongo-connecto   0 B                 
1afd908a8233        17 hours ago        /bin/sh -c chmod +x /opt/mongo-connector/run.   887 B               
6970e22d824e        17 hours ago        /bin/sh -c #(nop) ADD dir:57ee24a0c2e51bd01b1   16.5 kB             
bd259deba982        18 hours ago        /bin/sh -c pip3 install --ignore-installed  -   10.44 MB            
c7f44177feaa        18 hours ago        /bin/sh -c #(nop) ADD file:f26715e3c1ce68ce4b   108 B               
7977ca821fec        18 hours ago        /bin/sh -c apk add --no-cache --update --virt   14.78 MB            


This allows you to look at the size introduced by each instruction in dockerfile (including the ones from base image).  If I am modifying an existing service, I always do before and after comparison to see if I have not significantly increased size of my image. It also allows me to find the instruction that contributes significant size to my docker image.

Summary

Using the steps above and utilizing docker caching,
  • average build times have reduced significantly.  (From minutes to seconds)
  • average deploy times have reduced significantly.  (From minutes to seconds)
  • disk utilization due to cached layers has reduced significantly.
  • I was able to eliminate orphan processes.


Docker builds have simplified development process of writing micro-services. As the technology evolved, the amount of time that I spent in automation reduced significantly, giving me ample of time to improve the quality of my business code.

References